Travaux pratiques - Pandas, Polars et visualisation avec Plotly¶
Références externes utiles :
Objectif de cette séance
Cette séance a trois objectifs :
Rappeler les opérations usuelles de Pandas (lecture, filtrage, agrégation, jointure) qui se retrouvent avec une syntaxe similaire dans Polars et Spark.
Découvrir Polars : comparer son API à celle de Pandas et mesurer le gain de performance par rapport à Pandas.
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
Combien y a-t-il de films distincts dans
ratings? Et d’utilisateurs distincts ? Employez.nunique().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
Quel est le film le mieux noté parmi ceux ayant reçu au moins 50 notes ?
Combien de films du genre
"Drama"(genrescontient 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
Quel facteur d’accélération observez-vous sur votre machine ? Est-il cohérent avec les facteurs mentionnés en cours ?
Répétez la mesure avec une opération de jointure entre
df_pletmovies_pl(utilisezdf_pl.join(movies_pl, on="movieId", how="left")). Comparez avec Pandas (df_pd.merge(...)).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
Identifiez visuellement deux ou trois films très populaires (beaucoup de notes) et très bien notés. Retrouvez-les dans
stats_films.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 utilisezcolor="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 :
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 ?
Pour la pré-agrégation : expliquez pourquoi cette approche est souvent préférable à l’échantillonnage pour la visualisation exploratoire.
Pour WebGL : mesurez le temps de rendu avec
%%timepour 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é) :
Chargez
ratings.csvetmovies.csv.Faites une jointure sur
movieId.La colonne
genrescontient des genres séparés par"|". Utilisezpl.col("genres").str.split("|").explode()(après unwith_columns) pour obtenir une ligne par genre (une note peut contribuer à plusieurs genres).Calculez pour chaque genre : note moyenne, nombre de notes, nombre de films distincts.
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 :
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.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é ?