Travaux pratiques - Pandas, Polars et visualisation avec Plotly

Références externes utiles :

Objectif de cette séance

Cette séance a trois objectifs :

  1. Rappeler les opérations usuelles de Pandas (lecture, filtrage, agrégation, jointure) qui se retrouvent avec une syntaxe similaire dans Polars et Spark.

  2. Découvrir Polars : comparer son API à celle de Pandas et mesurer le gain de performance par rapport à Pandas.

  3. Explorer Plotly Express pour la visualisation interactive de données, en abordant (brièvement ici) la question du passage à l’échelle des visualisations.

Tout au long de cette séance nous travaillons sur le jeu de données MovieLens, qui recense des notes attribuées par des utilisateurs à des films.

Données de la séance

Téléchargez et décompressez les données MovieLens (version small, ~1 million de notes) :

mkdir -p tpPandasPolarsPlotly/data
wget -nc https://files.grouplens.org/datasets/movielens/ml-latest-small.zip \
     -P tpPandasPolarsPlotly/
unzip -n tpPandasPolarsPlotly/ml-latest-small.zip -d tpPandasPolarsPlotly/
mv tpPandasPolarsPlotly/ml-latest-small/*.csv tpPandasPolarsPlotly/data/
rm tpPandasPolarsPlotly/ml-latest-small.zip

Les fichiers disponibles sont :

  • ratings.csv : notes (userId, movieId, rating, timestamp), env. 100 000 lignes;

  • movies.csv : métadonnées des films (movieId, title, genres);

  • tags.csv : étiquettes libres attribuées par les utilisateurs;

  • links.csv : correspondances avec IMDb et TMDb.

Manipulation de données avec Pandas

Pandas est la bibliothèque DataFrame de référence en Python. Un DataFrame Pandas est un tableau à deux dimensions avec des colonnes nommées, stocké en mémoire sur une seule machine. Nous en rappelons ici les opérations fondamentales qui réapparaissent dans Polars comme dans Spark.

Chargement et exploration

import pandas as pd
import numpy as np

ratings = pd.read_csv("tpPandasPolarsPlotly/data/ratings.csv")
movies  = pd.read_csv("tpPandasPolarsPlotly/data/movies.csv")

print(ratings.shape)   # (nombre de lignes, nombre de colonnes)
ratings.head()         # affiche les 5 premières lignes dans Jupyter
# Types des colonnes et valeurs manquantes
ratings.info()

# Statistiques descriptives des colonnes numériques
ratings.describe()

Question

  1. Combien y a-t-il de films distincts dans ratings ? Et d’utilisateurs distincts ? Employez .nunique().

  2. Quelle est la distribution des notes (valeurs de rating) ? Utilisez .value_counts() ou .describe().

Filtrage et sélection

# Sélectionner une colonne
notes = ratings["rating"]

# Filtrer les lignes : ne garder que les très bonnes notes
top_notes = ratings[ratings["rating"] >= 4.5]
print(top_notes.shape)

# Sélectionner plusieurs colonnes
sous_ensemble = ratings[["userId", "movieId", "rating"]]

# Filtrer sur plusieurs conditions
bons_recents = ratings[(ratings["rating"] >= 4.0) & (ratings["timestamp"] > 1_000_000_000)]

Question

Extrayez toutes les notes attribuées par l’utilisateur userId = 1. Combien de films a-t-il notés ? Quelle est la moyenne des notes qu’il a données ?

Agrégation avec groupby

# Note moyenne par film
moy_par_film = (
    ratings
    .groupby("movieId")["rating"]
    .mean()
    .reset_index()
    .rename(columns={"rating": "note_moy"})
)
moy_par_film.head()
# Nombre de notes par film
nb_par_film = (
    ratings
    .groupby("movieId")["rating"]
    .count()
    .reset_index()
    .rename(columns={"rating": "nb_notes"})
)
nb_par_film.head()
# Plusieurs agrégations en une seule passe
stats_par_film = (
    ratings
    .groupby("movieId")["rating"]
    .agg(note_moy="mean", nb_notes="count", note_min="min", note_max="max")
    .reset_index()
)
stats_par_film.head()

Jointure

# Jointure à gauche entre statistiques (issues de ratings) et titres et genres
stats_films = stats_par_film.merge(movies, on="movieId", how="left")
stats_films.head()
# Films les plus populaires (ayant reçu le plus de notes)
stats_films.sort_values("nb_notes", ascending=False).head(10)[["title", "nb_notes", "note_moy"]]

Questions

  1. Quel est le film le mieux noté parmi ceux ayant reçu au moins 50 notes ?

  2. Combien de films du genre "Drama" (genres contient la chaîne "Drama") ont une note moyenne supérieure à 4.0 ?

Colonnes calculées et transformations

# Convertir le timestamp Unix en datetime
ratings["date"] = pd.to_datetime(ratings["timestamp"], unit="s")
ratings["annee"] = ratings["date"].dt.year

# Note centrée-réduite par utilisateur (pour éliminer les biais individuels)
ratings["rating_centree"] = (
    ratings.groupby("userId")["rating"]
    .transform(lambda x: (x - x.mean()) / x.std())
)
ratings[["userId", "movieId", "rating", "date", "annee", "rating_centree"]].head()

Question

Calculez, pour chaque année, la note moyenne et le nombre de notes attribuées. Triez par année. Observez-vous une évolution de la note moyenne au fil du temps ?

Passage à Polars

Nous reprenons maintenant les mêmes opérations avec Polars pour constater les différences d’API et mesurer les gains de performance.

import polars as pl

Chargement et exploration

# Mode eager (lecture immédiate)
ratings_pl = pl.read_csv("tpPandasPolarsPlotly/data/ratings.csv")
movies_pl  = pl.read_csv("tpPandasPolarsPlotly/data/movies.csv")

print(ratings_pl.shape)
ratings_pl.head()
# Schéma (types des colonnes)
print(ratings_pl.schema)

# Statistiques descriptives
ratings_pl.describe()

Filtrage et sélection

En Polars, les colonnes sont désignées par des expressions pl.col(), ce qui permet à l’optimiseur de requêtes de raisonner sur les opérations avant de les exécuter :

# Filtrage
top_notes_pl = ratings_pl.filter(pl.col("rating") >= 4.5)
print(top_notes_pl.shape)

# Sélection de colonnes
sous_ensemble_pl = ratings_pl.select(["userId", "movieId", "rating"])

# Combinaison
bons_recents_pl = ratings_pl.filter(
    (pl.col("rating") >= 4.0) & (pl.col("timestamp") > 1_000_000_000)
)

Agrégation avec group_by

stats_pl = (
    ratings_pl
    .group_by("movieId")
    .agg([
        pl.col("rating").mean().alias("note_moy"),
        pl.col("rating").count().alias("nb_notes"),
        pl.col("rating").min().alias("note_min"),
        pl.col("rating").max().alias("note_max"),
    ])
)
stats_pl.head()

Note

group_by en Polars ne garantit pas l’ordre des groupes dans le résultat (contrairement à groupby de Pandas qui préserve l’ordre d’apparition). Ajoutez un .sort("movieId") si l’ordre est important.

Jointure et colonnes calculées

# Jointure
stats_films_pl = stats_pl.join(movies_pl, on="movieId", how="left")

# Colonne calculée : conversion du timestamp en année
ratings_pl = ratings_pl.with_columns(
    (pl.col("timestamp") * 1_000)      # car Polars attend des millisecondes
    .cast(pl.Datetime)
    .dt.year()
    .alias("annee")
)

# Top 10 films les plus populaires
(stats_films_pl
 .sort("nb_notes", descending=True)
 .head(10)
 .select(["title", "nb_notes", "note_moy"]))

Question

Reproduisez en Polars le calcul de la note moyenne par année. Comparez votre code Polars avec le code Pandas équivalent : quelles différences syntaxiques notez-vous ?

Mode lazy et optimiseur de requêtes

En mode lazy, Polars accumule les opérations dans un graphe de requête et les optimise avant exécution. C’est le mode recommandé pour les traitements sur des fichiers volumineux :

# Lecture lazy : le fichier n'est pas lu immédiatement et entièrement
lf = pl.scan_csv("tpPandasPolarsPlotly/data/ratings.csv")

# Construction de la requête (aucun calcul n'est fait immédiatement)
requete = (
    lf
    .filter(pl.col("rating") >= 4.0)
    .group_by("movieId")
    .agg([
        pl.col("rating").mean().alias("note_moy"),
        pl.col("rating").count().alias("nb_notes"),
    ])
    .sort("note_moy", descending=True)
)

# Afficher le plan d'exécution optimisé
print(requete.explain())

# Déclencher l'exécution (et la lecture de ce qui est indispensable)
result = requete.collect()
result.head(10)

Notez dans le plan d’exécution les optimisations appliquées : le filtre est poussé au plus près de la lecture (predicate pushdown), et seules les colonnes movieId et rating sont lues depuis le fichier (projection pushdown).

Comparaison de performances Pandas vs Polars

Mesurons concrètement le gain de performance sur une opération d’agrégation. Pour rendre la comparaison plus significative, nous générons un jeu de données plus grand (le nombre N de lignes peut être modifié). Noter que dans notre exemple les données sont générées directement en mémoire, l’accélération potentielle obtenue par Polars grâce à une lecture sélective depuis le stockage de masse n’est donc pas prise en compte.

Génération d’un jeu de données synthétique plus grand

import numpy as np

rng = np.random.default_rng(42)
N = 5_000_000   # 5 millions de lignes

data_synth = {
    "userId":   rng.integers(1, 10_001, N),
    "movieId":  rng.integers(1, 50_001, N),
    "rating":   rng.choice([0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0], N),
    "timestamp": rng.integers(800_000_000, 1_700_000_000, N),
}

# Version Pandas
import pandas as pd
df_pd = pd.DataFrame(data_synth)

# Version Polars
import polars as pl
df_pl = pl.DataFrame(data_synth)

print(f"Taille : {N:,} lignes, {df_pd.memory_usage(deep=True).sum() / 1e6:.0f} Mo (Pandas)")

Mesurons maintenant le temps d’exécution d’une agrégation groupby (les résultats dépendent, naturellement, des ressources de l’ordinateur sur lequel le code est exécuté) :

import time

# Pandas
t0 = time.perf_counter()
res_pd = (
    df_pd
    .groupby("movieId")["rating"]
    .agg(["mean", "count", "std"])
)
t_pandas = time.perf_counter() - t0
print(f"Pandas  : {t_pandas:.2f} s")

# Polars eager
t0 = time.perf_counter()
res_pl = (
    df_pl
    .group_by("movieId")
    .agg([
        pl.col("rating").mean().alias("mean"),
        pl.col("rating").count().alias("count"),
        pl.col("rating").std().alias("std"),
    ])
)
t_polars = time.perf_counter() - t0
print(f"Polars  : {t_polars:.2f} s")
print(f"Accélération : x{t_pandas / t_polars:.1f}")

Question

  1. Quel facteur d’accélération observez-vous sur votre machine ? Est-il cohérent avec les facteurs mentionnés en cours ?

  2. Répétez la mesure avec une opération de jointure entre df_pl et movies_pl (utilisez df_pl.join(movies_pl, on="movieId", how="left")). Comparez avec Pandas (df_pd.merge(...)).

  3. Essayez de faire varier N (1 million, 5 millions, 20 millions). Comment évolue le rapport de performance ?

Visualisation interactive avec Plotly Express

Plotly Express est l’API haut niveau de la bibliothèque Plotly. Elle permet de créer des graphiques interactifs (zoom, survol, sélection, export) en une seule ligne de code et s’intègre nativement dans Jupyter. Plotly Express accepte indifféremment des DataFrames Pandas ou Polars.

import plotly.express as px

# Si nécessaire : !pip install plotly
# Dans Jupyter : les graphiques sont affichés directement dans le carnet

Distributions : histogrammes et boîtes à moustaches

Distribution des notes :

fig = px.histogram(
    ratings,
    x="rating",
    nbins=20,
    title="Distribution des notes MovieLens",
    labels={"rating": "Note", "count": "Nombre de notes"},
    color_discrete_sequence=["steelblue"],
)
fig.show()

Boîte à moustaches des notes par année :

fig = px.box(
    ratings,
    x="annee",
    y="rating",
    title="Distribution des notes par année",
    labels={"annee": "Année", "rating": "Note"},
)
fig.show()

Question

Tracez un histogramme du nombre de notes par film à partir de stats_films. Que constatez-vous sur la forme de la distribution ?

Nuages de points interactifs

Nous pouvons examiner la relation entre la note moyenne d’un film et le nombre de notes reçues pour ce film. On sélectionne les films avec au moins 10 notes pour réduire le bruit :

df_plot = stats_films[stats_films["nb_notes"] >= 10].copy()

fig = px.scatter(
    df_plot,
    x="nb_notes",
    y="note_moy",
    hover_name="title",          # info-bulle au survol
    color="note_moy",
    color_continuous_scale="RdYlGn",
    size="nb_notes",             # taille des points proportionnelle au nombre de notes
    size_max=20,
    log_x=True,
    title="Note moyenne vs popularité (films avec ≥ 10 notes)",
    labels={"nb_notes": "Nombre de notes (log)", "note_moy": "Note moyenne"},
)
fig.show()

Survolez les points pour voir le titre du film correspondant. Zoomez sur des zones d’intérêt. C’est là que Plotly apporte une valeur significative par rapport à Matplotlib pour l’exploration interactive des données.

Question

  1. Identifiez visuellement deux ou trois films très populaires (beaucoup de notes) et très bien notés. Retrouvez-les dans stats_films.

  2. Reprenez le même scatter plot en colorant les points selon le genre principal du film. Pour simplifier, extrayez le premier genre de la colonne genres (df_plot["genre_principal"] = df_plot["genres"].str.split("|").str[0]) et utilisez color="genre_principal".

Graphiques d’évolution temporelle

Évolution de la note moyenne par année :

par_annee = (
    ratings
    .groupby("annee")["rating"]
    .agg(note_moy="mean", nb_notes="count")
    .reset_index()
    .sort_values("annee")
)

fig = px.line(
    par_annee,
    x="annee",
    y="note_moy",
    title="Évolution de la note moyenne par année",
    labels={"annee": "Année", "note_moy": "Note moyenne"},
    markers=True,
)
fig.show()

Graphique à deux séries, note et volume :

from plotly.subplots import make_subplots
import plotly.graph_objects as go

fig = make_subplots(specs=[[{"secondary_y": True}]])
fig.add_trace(
    go.Scatter(x=par_annee["annee"], y=par_annee["note_moy"],
               name="Note moyenne", mode="lines+markers"),
    secondary_y=False,
)
fig.add_trace(
    go.Bar(x=par_annee["annee"], y=par_annee["nb_notes"],
           name="Nombre de notes", opacity=0.4),
    secondary_y=True,
)
fig.update_layout(title="Note moyenne et volume par année")
fig.update_yaxes(title_text="Note moyenne", secondary_y=False)
fig.update_yaxes(title_text="Nombre de notes", secondary_y=True)
fig.show()

Visualisation à grande échelle : la limite du rendu côté client

Plotly Express envoie les données brutes au navigateur en JSON pour les rendre côté client. Cela fonctionne bien jusqu’à quelques dizaines de milliers de points ; au-delà, le navigateur devient lent et la figure illisible car les points sont superposés.

Illustrons ce problème sur les données synthétiques de 5 millions de lignes :

# Tentative d'affichage de 5 millions de points — NE PAS exécuter tel quel !
# fig = px.scatter(df_pd, x="movieId", y="rating")   # navigateur bloqué

# Solution 1 : échantillonnage avant visualisation
echantillon = df_pd.sample(n=10_000, random_state=42)
fig = px.scatter(
    echantillon,
    x="movieId",
    y="rating",
    opacity=0.3,
    title="Échantillon de 10 000 notes (sur 5 millions)",
)
fig.show()
# Solution 2 : pré-agrégation, c'est à dire visualiser les statistiques plutôt que les données brutes
stats_aggr = (
    df_pd
    .groupby("movieId")["rating"]
    .agg(note_moy="mean", nb_notes="count")
    .reset_index()
)
# stats_aggr a ~50 000 lignes (nombre de films distincts), gérable
fig = px.scatter(
    stats_aggr,
    x="movieId",
    y="note_moy",
    size="nb_notes",
    size_max=10,
    opacity=0.5,
    title="Note moyenne par film (données agrégées de 50 000 points)",
)
fig.show()
# Solution 3 : rendu WebGL pour des centaines de milliers de points
import plotly.graph_objects as go

echantillon_large = df_pd.sample(n=200_000, random_state=42)
fig = go.Figure(go.Scattergl(      # Scattergl utilise WebGL au lieu de SVG
    x=echantillon_large["movieId"],
    y=echantillon_large["rating"],
    mode="markers",
    marker=dict(size=6, opacity=0.2, color="steelblue"),
))
fig.update_layout(title="200 000 notes, rendu WebGL (Scattergl)")
fig.show()

Note sur Datashader

Pour des volumes vraiment massifs (millions à milliards de points), la solution de référence est Datashader, qui rasterise les données côté serveur en une image agrégée avant de l’envoyer au navigateur. Datashader s’intègre avec Dask pour traiter des données qui ne tiennent pas en RAM et avec Bokeh ou Plotly pour l’affichage interactif. Nous l’aborderons lors de la séance consacrée à la visualisation de données massives.

Question

Comparez visuellement les trois solutions présentées (échantillonnage, agrégation, WebGL) sur les données synthétiques de 5 millions de lignes :

  1. Pour l”échantillonnage : essayez des tailles d’échantillon de 1 000, 10 000 et 50 000 points. À partir de quelle taille la structure globale des données est-elle bien restituée ?

  2. Pour la pré-agrégation : expliquez pourquoi cette approche est souvent préférable à l’échantillonnage pour la visualisation exploratoire.

  3. Pour WebGL : mesurez le temps de rendu avec %%time pour 50 000, 200 000 et 500 000 points. À partir de quel volume le rendu devient-il lent ?

Exercice de synthèse

Nous terminons cette séance par un exercice qui fait appel à la fois à Polars et Plotly Express.

Contexte

Nous souhaitons identifier les genres de films qui ont la meilleure réputation auprès des utilisateurs, en tenant compte à la fois de la note moyenne et du volume de notes. Certains genres sont peut-être bien notés mais peu vus, d’autres très populaires mais moyennement appréciés.

Question 1 : Préparation des données avec Polars

En utilisant Polars (mode lazy recommandé) :

  1. Chargez ratings.csv et movies.csv.

  2. Faites une jointure sur movieId.

  3. La colonne genres contient des genres séparés par "|". Utilisez pl.col("genres").str.split("|").explode() (après un with_columns) pour obtenir une ligne par genre (une note peut contribuer à plusieurs genres).

  4. Calculez pour chaque genre : note moyenne, nombre de notes, nombre de films distincts.

  5. Filtrez les genres avec au moins 1 000 notes. Triez par note moyenne décroissante.

Question 2 : Visualisation avec Plotly Express

À partir du résultat de la question 1 :

  1. Créez un graphique à barres (px.bar) de la note moyenne par genre, trié par note décroissante. Colorez les barres selon la note moyenne.

  2. Créez un nuage de points avec en abscisse le nombre de films, en ordonnée la note moyenne, la taille des points proportionnelle au nombre de notes et le nom du genre en info-bulle. Quel genre offre le meilleur équilibre popularité/qualité ?