Une partie essentielle du travail du data scientist est d’être en mesure de synthétiser une information dans des représentations graphiques percutantes. Ce chapitre permet de découvrir les enjeux de la représentation de données avec Python, l’écosystème pour faire ceci. Il ouvre également à la représentation interactive de données avec Plotly.
Pour essayer les exemples présents dans ce tutoriel :
Compétences à l’issue de ce chapitre
Découvrir l’écosystème matplotlib et
seaborn pour la construction de graphiques par enrichissement successif de couches.
Découvrir le récent écosystème plotnine.
qui est une implémentation en Python du packageRggplot2
pour ce type de représentation et qui, grâce à sa grammaire des graphiques, offre une syntaxe puissante pour construire des visualisations de données.
Découvrir le principe des représentations interactives HTML (format web) grâce aux packages plotly et altair.
Apprendre les enjeux de la représentation graphique, les compromis nécessaires pour construire un message clair et les limites de certaines représentations classiques.
La pratique de la data visualisation se fera, dans ce cours, en répliquant des graphiques qu’on peut trouver sur
la page de l’open data de la ville de Paris
ici ou en proposant des alternatives à ceux-ci sur les mêmes données.
L’objectif de ce chapitre n’est pas de faire un inventaire complet des graphiques pouvant être fait avec Python, ce serait long, assez insipide et peu pertinent car des sites le font déjà très bien à partir d’une grande variété d’exemple, notamment le site python-graph-gallery.com/. L’objectif est plutôt d’illustrer, par la pratique, quelques enjeux liés à l’utilisation des principales librairies graphiques de Python.
On peut distinguer quelques grandes familles de représentations graphiques: les représentations de distributions propres à une variable, les représentations de relations entre plusieurs variables, les cartes qui permettent de représenter dans l’espace une ou plusieurs variables…
Ces familles se ramifient elles-mêmes en de multiples types de figures. Par exemple, selon la nature du phénomène, les représentations de relations peuvent prendre la forme d’une série temporelle (évolution d’une variable dans le temps), d’un nuage de point (corrélation entre deux variables), d’un diagramme en barre (pour souligner le rapport relatif entre les valeurs d’une variable en fonction d’une autre), etc.
Plutôt qu’un inventaire à la Prévert des types de visualisations possibles, ce chapitre et le suivant vont plutôt proposer quelques visualisations qui pourraient donner envie d’aller plus loin dans l’analyse avant la mise en oeuvre d’une forme de modélisation. Ce chapitre est consacré aux visualisations traditionnelles, le suivant est dédié à la cartographie. Ces deux chapitres font partie d’un tout visant à offrir les premiers éléments pour synthétiser l’information présente dans un jeu de données.
Le pas suivant est d’approfondir le travail de communication et de synthèse par le biais de communications pouvant prendre des formes aussi diverses que des rapports, des publications scientifiques ou articles, des présentations, une application interactive, un site web ou des notebooks comme ceux proposés par ce cours. Le principe général est identique quelle que soit le medium utilisé et intéresse particulièrement les data scientists lorsqu’ils font appel à de l’exploitation intensive de données. Ce sera l’objet d’un chapitre futur de ce cours1.
Important
Être capable de construire des visualisations de données
intéressantes est une compétence nécessaire à tout
data scientist ou chercheur. Pour améliorer
la qualité de ces visualisations, il est recommandé
de suivre certains conseils donnés par des spécialistes
de la dataviz sur la sémiologie graphique.
Les bonnes visualisations de données, comme celles du New York Times,
reposent certes sur des outils adaptés (des librairies JavaScript)
mais aussi sur certaines règles de représentation qui permettent
de comprendre en quelques secondes le message d’une visualisation.
Ce post de blog
est une ressource qu’il est utile de consulter régulièrement.
Ce post de blog d’Albert Rapp montre bien comment construire graduellement une bonne visualisation
de données.
Ce chapitre s’appuie sur les données de comptage des passages de vélo dans les points de mesure parisiens diffusés sur le site de l’open data de la ville de Paris.
L’exploitation de l’historique récent a été grandement facilité par la diffusion des données au format Parquet, un format moderne plus pratique que le CSV. Pour en savoir plus sur ce format, vous pouvez consulter les ressources évoquées dans le paragraphe consacré à ce format dans le chapitre d’approfondissement.
Code pour importer les données à partir du format Parquet
import osimport requestsfrom tqdm import tqdmimport pandas as pdimport duckdburl ="https://opendata.paris.fr/api/explore/v2.1/catalog/datasets/comptage-velo-donnees-compteurs/exports/parquet?lang=fr&timezone=Europe%2FParis"filename ="comptage_velo_donnees_compteurs.parquet"# DOWNLOAD FILE --------------------------------# Perform the HTTP request and stream the downloadresponse = requests.get(url, stream=True)ifnot os.path.exists(filename):# Perform the HTTP request and stream the download response = requests.get(url, stream=True)# Check if the request was successfulif response.status_code ==200:# Get the total size of the file from the headers total_size =int(response.headers.get("content-length", 0))# Open the file in write-binary mode and use tqdm to show progresswithopen(filename, "wb") asfile, tqdm( desc=filename, total=total_size, unit="B", unit_scale=True, unit_divisor=1024, ) as bar:# Write the file in chunksfor chunk in response.iter_content(chunk_size=1024):if chunk: # filter out keep-alive chunksfile.write(chunk) bar.update(len(chunk))else:print(f"Failed to download the file. Status code: {response.status_code}")else:print(f"The file '{filename}' already exists.")# READ FILE AND CONVERT TO PANDAS --------------------------query ="""SELECT id_compteur, nom_compteur, id, sum_counts, date FROM read_parquet('comptage_velo_donnees_compteurs.parquet')"""# READ WITH DUCKDB AND CONVERT TO PANDASdf = duckdb.sql(query).df()df.head(3)
2 Premières productions graphiques avec l’API Matplotlib de Pandas
Chercher à produire une visualisation parfaite du premier coup est
illusoire. Il est beaucoup plus réaliste d’améliorer graduellement
une représentation graphique afin, petit à petit, de mettre en
avant les effets de structure dans un jeu de données.
Nous allons donc commencer par nous représenter la distribution
des passages aux principales stations de mesure.
Pour cela nous allons produire
rapidement un barplot puis l’améliorer graduellement.
Dans cette partie, nous allons ainsi
reproduire les deux premiers graphiques de la
page d’analyse des données :
Les 10 compteurs avec la moyenne horaire la plus élevée et Les 10 compteurs ayant comptabilisé le plus de vélos. Les valeurs chiffrées des graphiques peuvent être différentes de celles de la page en ligne, c’est normal, car nous ne travaillons pas systématiquement sur les données ayant la même fraicheur que celles en ligne.
Pour importer les librairies graphiques que nous utiliserons dans ce chapitre, il faut faire
import matplotlib.pyplot as pltimport seaborn as sns1from plotnine import*
1
Importer des librairies sous la forme from package import * n’est pas une très bonne pratique. Néanmoins, pour un package comme plotnine, dont nous allons utiliser de nombreuses fonctions, ce serait un peu fastidieux d’importer les fonctions au cas par cas. De plus, cela permet de réutiliser presque tels quels les exemples de code de la librairie Rggplot, nombreux sur internet avec démonstrations visuelles. from package import * est l’équivalent Python de la pratique library(package) en R.
2.1 Comprendre, en quelques mots, le principe de matplotlib
matplotlib date du début des années 2000 et a émergé pour proposer une alternative en Python à la création de graphiques sous Matlab, un logiciel propriétaire de calcul numérique. matplotlib est donc une librairie assez ancienne, antérieure à l’émergence de Python dans l’écosystème du traitement de données. Cela s’en ressent sur la logique de construction de matplotlib qui n’est pas toujours intuitive lorsqu’on est familier de l’écosystème moderne de la data science. Heureusement, il existe de nombreuses librairies qui s’appuient sur matplotlib mais qui visent à founir une syntaxe plus familière aux data scientists.
matplotlib propose principalement deux niveaux d’abstraction: la figure et les axes. La figure est, en quelques sortes, la “toile” globale qui contient un ou plusieurs axes dans lesquels s’inséreront des graphiques. Selon les cas, il faudra jouer avec les paramètres de figure ou d’axe, ce qui rend très flexible la construction d’un graphique mais peut également être déroutant car on ne sait jamais trop quel niveau d’abstraction il faut modifier pour mettre à jour sa figure2. Comme le montre la Figure 2.1, tout les éléments d’une figure sont paramétrables.
En pratique, il existe deux manières de créer et mettre à jour sa figure selon qu’on préfère passer par:
l’approche explicite, héritière d’une logique de programmation orientée objet, où on crée des objets Figure et Axes et met à jour ceux-ci.
l’approche implicite, basée sur l’interface pyplot qui utilise une succession de fonctions pour mettre à jour les objets créés implicitement.
import numpy as npimport matplotlib.pyplot as pltx = np.linspace(0, 2, 100) # Sample data.# Note that even in the OO-style, we use `.pyplot.figure` to create the Figure.fig, ax = plt.subplots(figsize=(5, 2.7), layout="constrained")ax.plot(x, x, label="linear") # Plot some data on the Axes.ax.plot(x, x**2, label="quadratic") # Plot more data on the Axes...ax.plot(x, x**3, label="cubic") # ... and some more.ax.set_xlabel("x label") # Add an x-label to the Axes.ax.set_ylabel("y label") # Add a y-label to the Axes.ax.set_title("Simple Plot") # Add a title to the Axes.ax.legend() # Add a legend.
Ces éléments constituent le minimum pour comprendre la logique de matplotlib. Pour être plus à l’aise avec ces concepts, la pratique répétée est indispensable.
2.2 Découvrir matplotlib par l’intermédiaire de Pandas
Exercice 1 : Produire un premier graphique
Les données comportent plusieurs dimensions pouvant faire l’objet d’une analyse statistique. Nous allons commencer par nous focaliser sur le volume de passage à tel ou tel compteur.
Puisque nous avons comme objectif de synthétiser l’information présente dans notre jeu de données, nous devons d’abord mettre en œuvre quelques agrégations ad hoc pour produire un graphique lisible.
Garder les dix bornes à la moyenne la plus élevée. Comme pour obtenir un graphique ordonné du plus grand au plus petit avec les méthodes plot de Pandas, il faut avoir les données ordonnées du plus petit au plus grand (oui c’est bizarre mais c’est comme ça…), réordonner les données.
En premier lieu, sans se préoccuper des éléments de style ni de la beauté
du graphique, créer la structure du barplot (diagramme en bâtons) de la
page d’analyse des données.
Pour préparer le travail sur la deuxième figure, ne conserver
que les 10 compteurs ayant comptabilisé le plus de vélos.
Comme pour la question 2, créer un barplot
pour reproduire la figure 2 de l’open data parisien.
Les 10 principales stations à l’issue de la question 1 représentent celles ayant la moyenne la plus élevée pour le volume de passages de vélos. Ces données réordonnées permettent de créer un graphique lisible et de mettre en avant les stations les plus fréquentées.
sum_counts
nom_compteur
72 boulevard Voltaire NO-SE
157.567443
27 quai de la Tournelle SE-NO
163.064454
Quai d'Orsay E-O
176.006612
35 boulevard de Ménilmontant NO-SE
177.815658
Totem 64 Rue de Rivoli Totem 64 Rue de Rivoli Vélos E-O
188.804384
Figure 1, sans travail sur le style, présente les données sous forme de barplot basique. Bien qu’elle montre les informations essentielles, elle manque de mise en page esthétique, de couleurs harmonieuses et d’annotations claires, nécessaires pour améliorer la lisibilité et l’impact visuel.
Figure 2 sans travail sur le style:
On commence à avoir quelque chose qui commence à transmettre
un message synthétique sur la nature des données.
On peut néanmoins remarquer plusieurs éléments problématiques
(par exemple les labels) mais
aussi des éléments ne correspondant pas (les titres des axes, etc.) ou
manquants (le nom du graphique…).
Comme les graphiques produits par Pandas suivent la logique très flexible de matplotlib, il est possible de les customiser. Cependant, cela demande généralement beaucoup de travail et la grammaire matplotlib n’est pas aussi normalisée que celle de ggplot en R.
Si on désire rester dans l’écosystème matplotlib, il est préférable de directement utiliser seaborn, qui offre quelques arguments prêts à l’emploi. Sinon on peut basculer sur l’écosystème plotnine qui offrira une syntaxe normalisée pour modifier les différents
3 Utiliser directement seaborn
3.1 Comprendre seaborn en quelques lignes
seaborn est une interface haut niveau au dessus de matplotlib. Ce package offre un ensemble de fonctionnalités pour créer des figures ou des axes matplotlib directement depuis une fonction admettant de nombreux arguments et, si besoin d’aller plus loin dans la customisation, d’utiliser les fonctionnalités de matplotlib pour mettre à jour la figure, que ce soit par le biais de l’approche implicite ou explicite décrites précédemment.
Comme pour matplotlib, seaborn permet de faire la même figure de multiples manières. seaborn hérite de la dualité axes-figures de matplotlib et il faudra souvent jouer avec un niveau ou l’autre. La principale caractéristique de seaborn est d’offrir quelques points d’entrées standardisés, par exemple seaborn.relplot ou seaborn.catplot, et une logique d’inputs basée sur le DataFrame là où matplotlib est structurée autour du arrayNumpy.
La figure comporte maintenant un message mais il est encore peu
lisible. Il y a plusieurs manières de faire un barplot en seaborn. Les deux principales
sont :
sns.catplot ;
sns.barplot.
On propose d’utiliser sns.catplot pour cet exercice. Il s’agit d’un point d’entrée assez fréquent pour faire des graphiques d’une variable discrétisée.
3.2 Le diagramme en barre (barplot)
Exercice 2: reproduire la première figure avec seaborn
Réinitialiser l’index des dataframesdf1 et df2
pour avoir une colonne ‘Nom du compteur’. Réordonner les données
de manière décroissante pour obtenir un graphique ordonné dans
le bon sens avec seaborn.
Refaire le graphique précédent avec la fonction catplot de seaborn. Pour
contrôler la taille du graphique vous pouvez utiliser les arguments height et
aspect.
Ajouter les titres des axes et le titre du graphique pour le premier graphique
Essayez de colorer en rouge l’axe des x. Vous pouvez pré-définir un
style avec sns.set_style("ticks", {"xtick.color": "red"})
A l’issue de la question 2, c’est-à-dire en utilisant
seaborn pour reproduire de manière minimale
un barplot, on obtient :
Après quelques réglages esthétiques, à l’issue des questions 3 et 4,
on obtient une figure proche de celle du portail open data parisien.
Les paramètres supplémentaires proposés à la question 4 permettent finalement d’obtenir la figure
On comprend ainsi que le boulevard de Sébastopol est le plus emprunté,
ce qui ne vous suprendra pas si vous faites du vélo à Paris.
Néanmoins, si vous n’êtes pas familiers avec la géographie parisienne,
cela sera peu informatif pour vous, vous allez avoir besoin d’une
représentation graphique supplémentaire: une carte ! Nous verrons
ceci lors d’un prochain chapitre.
Exercice 2bis : reproduire la figure “Les 10 compteurs ayant comptabilisé le plus de vélos”
En suivant l’approche graduelle de l’exercice 2,
refaire le graphique Les 10 compteurs ayant comptabilisé le plus de vélos
avec seaborn
3.3 Un exemple d’alternative au barplot, le lollipop chart
Les diagrammes en batons (barplot) sont extrêmement communs, sans doute à cause de l’héritage d’Excel où ces graphiques sont faisables en deux clics. Néanmoins, en ce qui concerne le message à transmettre, ils sont loin d’être parfaits. Par exemple, les barres prennent beaucoup d’espace visuel
ce qui peut brouiller le message à transmettre sur le rapport entre les observations.
Sur le plan sémiologique, c’est à dire sur le plan de l’efficacité du message à transmettre,
les lollipop charts sont préférables : ils
transmettent la même information mais avec moins de signes visuels pouvant brouiller sa compréhension.
Les lollipop chart ne sont pas parfaits non plus mais sont un peu plus efficaces pour transmettre le message. Pour en savoir plus sur les alternatives au barplot, la conférence d’Eric Mauvière pour le réseau des data scientists de la statistique publique, dont le message principal est “Désempilez vos figures” mérite le détour (disponible sur le site ssphub.netlify.app/)
Exercice 3 (optionnel) : reproduire la figure 2 avec un lollipop chart
En suivant l’approche graduelle de l’exercice 2,
refaire le graphique Les 10 compteurs ayant comptabilisé le plus de vélos.
Text(0, 0.5, 'La somme des vélos comptabilisés sur la période sélectionnée')
4 La même figure avec plotnine
plotnine est le nouveau venu dans l’écosystème de la visualisation en Python. Cette librairie est développée par Posit, l’entreprise à l’origine de l’éditeur RStudio et de l’écosystème du tidyverse si central dans le langage R. Cette librairie vise à importer la logique de ggplot en Python c’est à dire une grammaire des graphiques normalisée, lisible et flexible héritère de Wilkinson (2012).
Dans cette approche, un graphique est vu comme une succession de couches qui, une fois superposées, donneront la figure suivante. En soi, ce principe n’est pas différent de celui de matplotlib. Néanmoins, la grammaire utilisée par plotnine est beaucoup plus intuitive et normalisée ce qui offrira beaucoup plus d’autonomie pour modifier sa figure.
Avec plotnine, il n’y a plus de point d’entrée dual figure-axe. Comme l’illustrent les slides ci-dessous, :
On initialise une figure
On met à jour les couches (layers), un niveau d’abstraction très général concernant aussi bien les données représentées que les échelles des axes ou la couleur
A la fin, on peut jouer sur l’esthétique en modifiant les labels des axes, de la légende, les titres, etc.
Dérouler les slides ci-dessous ou cliquer ici
pour afficher les slides en plein écran.
Exercice 4: reproduire la première figure avec plotnine
Ceci est le même exercice que l’exercice 2. L’objectif est de faire cette figure avec plotnine
5 Premières agrégations temporelles
On va maintenant se concentrer sur la dimension temporelle de notre
jeu de données à travers deux approches :
Un diagramme en barre synthétisant l’information de notre jeu de données
de manière mensuelle ;
Des séries instructives sur la dynamique temporelle. Cela sera l’objet de la prochaine partie.
Avant cela, nous allons enrichir ces données pour bénéficier d’un historique plus long, permettant notamment d’avoir la période Covid dans nos données, ce qui présente un intérêt du fait de la dynamique particulière du trafic dans cette période (arrêt brutal, reprise très forte…).
Voir le code pour bénéficier d’un historique plus long de données
import requestsimport zipfileimport ioimport osfrom pathlib import Pathimport pandas as pdimport geopandas as gpdlist_useful_columns = ["Identifiant du compteur","Nom du compteur","Identifiant du site de comptage","Nom du site de comptage","Comptage horaire","Date et heure de comptage",]# GENERIC FUNCTION TO RETRIEVE DATA -------------------------def download_unzip_and_read( url, extract_to=".", list_useful_columns=list_useful_columns):""" Downloads a zip file from the specified URL, extracts its contents, and reads the CSV file based on the filename pattern in the URL. Parameters: - url (str): The URL of the zip file to download. - extract_to (str): The directory where the contents of the zip file should be extracted. Returns: - df (DataFrame): The loaded pandas DataFrame from the extracted CSV file. """try:# Extract the file pattern from the URL (filename without the extension) file_pattern = url.split("/")[-1].replace("_zip/", "")# Send a GET request to the specified URL to download the file response = requests.get(url) response.raise_for_status() # Ensure we get a successful response# Create a ZipFile object from the downloaded byteswith zipfile.ZipFile(io.BytesIO(response.content)) as z:# Extract all the contents to the specified directory z.extractall(path=extract_to)print(f"Extracted all files to {os.path.abspath(extract_to)}") dir_extract_to = Path(extract_to)# dir_extract_to = Path(f"./{file_pattern}/")# Look for the file matching the pattern csv_filename = [f.name for f in dir_extract_to.iterdir() if f.suffix ==".csv"]ifnot csv_filename:print(f"No file matching pattern '{file_pattern}' found.")returnNone# Read the first matching CSV file into a pandas DataFrame csv_path = os.path.join(dir_extract_to.name, csv_filename[0])print(f"Reading file: {csv_path}") df = pd.read_csv(csv_path, sep=";")# CONVERT TO GEOPANDAS df[["latitude", "longitude"]] = df["Coordonnées géographiques"].str.split(",", expand=True ) df["latitude"] = pd.to_numeric(df["latitude"]) df["longitude"] = pd.to_numeric(df["longitude"]) gdf = gpd.GeoDataFrame( df, geometry=gpd.points_from_xy(df.longitude, df.latitude) )# CONVERT TO TIMESTAMP df["Date et heure de comptage"] = ( df["Date et heure de comptage"] .astype(str) .str.replace(r"\+.*", "", regex=True) ) df["Date et heure de comptage"] = pd.to_datetime( df["Date et heure de comptage"], format="%Y-%m-%dT%H:%M:%S", errors="coerce" ) gdf = df.loc[:, list_useful_columns]return gdfexcept requests.exceptions.RequestException as e:print(f"Error: The downloaded file has not been found: {e}")returnNoneexcept zipfile.BadZipFile as e:print(f"Error: The downloaded file is not a valid zip file: {e}")returnNoneexceptExceptionas e:print(f"An error occurred: {e}")returnNonedef read_historical_bike_data(year): dataset ="comptage_velo_donnees_compteurs" url_comptage =f"https://opendata.paris.fr/api/datasets/1.0/comptage-velo-historique-donnees-compteurs/attachments/{year}_{dataset}_csv_zip/" df_comptage = download_unzip_and_read( url_comptage, extract_to=f"./extracted_files_{year}" )if df_comptage isNone: url_comptage_alternative = url_comptage.replace("_csv_zip", "_zip") df_comptage = download_unzip_and_read( url_comptage_alternative, extract_to=f"./extracted_files_{year}" )return df_comptage# IMPORT HISTORICAL DATA -----------------------------historical_bike_data = pd.concat( [read_historical_bike_data(year) for year inrange(2018, 2024)])rename_columns_dict = {"Identifiant du compteur": "id_compteur","Nom du compteur": "nom_compteur","Identifiant du site de comptage": "id","Nom du site de comptage": "nom_site","Comptage horaire": "sum_counts","Date et heure de comptage": "date",}historical_bike_data = historical_bike_data.rename(columns=rename_columns_dict)# IMPORT LATEST MONTHS ----------------import osimport requestsfrom tqdm import tqdmimport pandas as pdimport duckdburl ="https://opendata.paris.fr/api/explore/v2.1/catalog/datasets/comptage-velo-donnees-compteurs/exports/parquet?lang=fr&timezone=Europe%2FParis"filename ="comptage_velo_donnees_compteurs.parquet"# DOWNLOAD FILE --------------------------------# Perform the HTTP request and stream the downloadresponse = requests.get(url, stream=True)ifnot os.path.exists(filename):# Perform the HTTP request and stream the download response = requests.get(url, stream=True)# Check if the request was successfulif response.status_code ==200:# Get the total size of the file from the headers total_size =int(response.headers.get("content-length", 0))# Open the file in write-binary mode and use tqdm to show progresswithopen(filename, "wb") asfile, tqdm( desc=filename, total=total_size, unit="B", unit_scale=True, unit_divisor=1024, ) as bar:# Write the file in chunksfor chunk in response.iter_content(chunk_size=1024):if chunk: # filter out keep-alive chunksfile.write(chunk) bar.update(len(chunk))else:print(f"Failed to download the file. Status code: {response.status_code}")else:print(f"The file '{filename}' already exists.")# READ FILE AND CONVERT TO PANDASquery ="""SELECT id_compteur, nom_compteur, id, sum_counts, date FROM read_parquet('comptage_velo_donnees_compteurs.parquet')"""# READ WITH DUCKDB AND CONVERT TO PANDASdf = duckdb.sql(query).df()df.head(3)# PUT THEM TOGETHER ----------------------------historical_bike_data["date"] = historical_bike_data["date"].dt.tz_localize(None)df["date"] = df["date"].dt.tz_localize(None)historical_bike_data = historical_bike_data.loc[ historical_bike_data["date"] < df["date"].min()]df = pd.concat([historical_bike_data, df])
Pour commencer, reproduisons la troisième figure qui est, encore une fois,
un barplot. Ici, sur le plan sémiologique, ce n’est pas justifier d’utiliser un barplot, une simple série suffirait à fournir une information similaire.
La première question du prochain exerice implique une première rencontre avec
une donnée temporelle à travers une opération assez classique en séries
temporelles : changer le format d’une date pour pouvoir faire une agrégation
à un pas de temps plus large.
Exercice 5: barplot des comptages mensuels
Créer une variable month dont le format respecte, par exemple, le schéma 2019-08 grâce à la bonne option de la méthode dt.to_period
Appliquer les conseils précédents pour construire et améliorer graduellement un graphique afin d’obtenir une figure similaire à la 3e production sur la page de l’open data parisien. Faire cette figure d’abord depuis début 2022 puis sur toute la période de notre historique
Question optionnelle: représenter la même information sous forme de lollipop
month
value
0
2022-01
29.489743
1
2022-02
34.567832
La figure avec les données depuis début 2022 aura cet aspect si elle a été construite avec plotnine:
Avec seaborn, elle ressemblera plutôt à ceci:
Text(0.5, 1.0, 'Moyenne mensuelle des comptages vélos')
Si vous préférez représenter cela sous forme de lollipop3:
---------------------------------------------------------------------------NameError Traceback (most recent call last)
File /opt/conda/lib/python3.12/site-packages/plotnine/mapping/evaluation.py:223, in evaluate(aesthetics, data, env) 222try:
--> 223 new_val =env.eval(col,inner_namespace=data) 224exceptExceptionas e:
File /opt/conda/lib/python3.12/site-packages/plotnine/mapping/_env.py:69, in Environment.eval(self, expr, inner_namespace) 68 code = _compile_eval(expr)
---> 69returneval( 70code,{},StackedLookup([inner_namespace]+self.namespaces) 71)
File <string-expression>:1NameError: name 'value' is not defined
The above exception was the direct cause of the following exception:
PlotnineError Traceback (most recent call last)
File /opt/conda/lib/python3.12/site-packages/IPython/core/formatters.py:925, in IPythonDisplayFormatter.__call__(self, obj) 923 method = get_real_method(obj, self.print_method)
924if method isnotNone:
--> 925method() 926returnTrue
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:141, in ggplot._ipython_display_(self) 134def_ipython_display_(self):
135""" 136 Display plot in the output of the cell 137 138 This method will always be called when a ggplot object is the 139 last in the cell. 140 """--> 141self._display()
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:175, in ggplot._display(self) 172 save_format ="png" 174 buf = BytesIO()
--> 175self.save(buf,format=save_format,verbose=False) 176 display_func = get_display_function(format)
177 display_func(buf.getvalue())
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:663, in ggplot.save(self, filename, format, path, width, height, units, dpi, limitsize, verbose, **kwargs) 615defsave(
616self,
617 filename: Optional[str| Path | BytesIO] =None,
(...) 626**kwargs: Any,
627 ):
628""" 629 Save a ggplot object as an image file 630 (...) 661 Additional arguments to pass to matplotlib `savefig()`. 662 """--> 663 sv =self.save_helper( 664filename=filename, 665format=format, 666path=path, 667width=width, 668height=height, 669units=units, 670dpi=dpi, 671limitsize=limitsize, 672verbose=verbose, 673**kwargs, 674) 676with plot_context(self).rc_context:
677 sv.figure.savefig(**sv.kwargs)
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:612, in ggplot.save_helper(self, filename, format, path, width, height, units, dpi, limitsize, verbose, **kwargs) 609if dpi isnotNone:
610self.theme =self.theme + theme(dpi=dpi)
--> 612 figure =self.draw(show=False) 613return mpl_save_view(figure, fig_kwargs)
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:272, in ggplot.draw(self, show) 270self= deepcopy(self)
271with plot_context(self, show=show):
--> 272self._build() 274# setup 275self.figure, self.axs =self.facet.setup(self)
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:362, in ggplot._build(self) 358 layout.setup(layers, self)
360# Compute aesthetics to produce data with generalised 361# variable names--> 362layers.compute_aesthetics(self) 364# Transform data using all scales 365 layers.transform(scales)
File /opt/conda/lib/python3.12/site-packages/plotnine/layer.py:457, in Layers.compute_aesthetics(self, plot) 455defcompute_aesthetics(self, plot: ggplot):
456for l inself:
--> 457l.compute_aesthetics(plot)
File /opt/conda/lib/python3.12/site-packages/plotnine/layer.py:260, in layer.compute_aesthetics(self, plot) 253defcompute_aesthetics(self, plot: ggplot):
254""" 255 Return a dataframe where the columns match the aesthetic mappings 256 257 Transformations like 'factor(cyl)' and other 258 expression evaluation are made in here 259 """--> 260 evaled =evaluate(self.mapping._starting,self.data,plot.environment) 261 evaled_aes = aes(**{str(col): col for col in evaled})
262 plot.scales.add_defaults(evaled, evaled_aes)
File /opt/conda/lib/python3.12/site-packages/plotnine/mapping/evaluation.py:226, in evaluate(aesthetics, data, env) 224exceptExceptionas e:
225 msg = _TPL_EVAL_FAIL.format(ae, col, str(e))
--> 226raise PlotnineError(msg) frome 228try:
229 evaled[ae] = new_val
PlotnineError: "Could not evaluate the 'y' mapping: 'value' (original error: name 'value' is not defined)"
/opt/conda/lib/python3.12/site-packages/IPython/lib/pretty.py:787: FutureWarning: Using repr(plot) to draw and show the plot figure is deprecated and will be removed in a future version. Use plot.show().
---------------------------------------------------------------------------NameError Traceback (most recent call last)
File /opt/conda/lib/python3.12/site-packages/plotnine/mapping/evaluation.py:223, in evaluate(aesthetics, data, env) 222try:
--> 223 new_val =env.eval(col,inner_namespace=data) 224exceptExceptionas e:
File /opt/conda/lib/python3.12/site-packages/plotnine/mapping/_env.py:69, in Environment.eval(self, expr, inner_namespace) 68 code = _compile_eval(expr)
---> 69returneval( 70code,{},StackedLookup([inner_namespace]+self.namespaces) 71)
File <string-expression>:1NameError: name 'value' is not defined
The above exception was the direct cause of the following exception:
PlotnineError Traceback (most recent call last)
File /opt/conda/lib/python3.12/site-packages/IPython/core/formatters.py:711, in PlainTextFormatter.__call__(self, obj) 704 stream = StringIO()
705 printer = pretty.RepresentationPrinter(stream, self.verbose,
706self.max_width, self.newline,
707 max_seq_length=self.max_seq_length,
708 singleton_pprinters=self.singleton_printers,
709 type_pprinters=self.type_printers,
710 deferred_pprinters=self.deferred_printers)
--> 711printer.pretty(obj) 712 printer.flush()
713return stream.getvalue()
File /opt/conda/lib/python3.12/site-packages/IPython/lib/pretty.py:419, in RepresentationPrinter.pretty(self, obj) 408return meth(obj, self, cycle)
409if (
410clsisnotobject 411# check if cls defines __repr__ (...) 417andcallable(_safe_getattr(cls, "__repr__", None))
418 ):
--> 419return_repr_pprint(obj,self,cycle) 421return _default_pprint(obj, self, cycle)
422finally:
File /opt/conda/lib/python3.12/site-packages/IPython/lib/pretty.py:787, in _repr_pprint(obj, p, cycle) 785"""A pprint that just redirects to the normal repr function.""" 786# Find newlines and replace them with p.break_()--> 787 output =repr(obj) 788 lines = output.splitlines()
789with p.group():
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:131, in ggplot.__repr__(self) 125 msg = (
126"Using repr(plot) to draw and show the plot figure is " 127"deprecated and will be removed in a future version. " 128"Use plot.show()." 129 )
130 warn(msg, category=FutureWarning, stacklevel=2)
--> 131self.show() 132returnf"<Figure Size: ({W} x {H})>"
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:150, in ggplot.show(self) 143defshow(self):
144""" 145 Show plot using the matplotlib backend set by the user 146 147 Users should prefer this method instead of printing or repring 148 the object. 149 """--> 150self._display()if is_inline_backend() elseself.draw(show=True)
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:175, in ggplot._display(self) 172 save_format ="png" 174 buf = BytesIO()
--> 175self.save(buf,format=save_format,verbose=False) 176 display_func = get_display_function(format)
177 display_func(buf.getvalue())
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:663, in ggplot.save(self, filename, format, path, width, height, units, dpi, limitsize, verbose, **kwargs) 615defsave(
616self,
617 filename: Optional[str| Path | BytesIO] =None,
(...) 626**kwargs: Any,
627 ):
628""" 629 Save a ggplot object as an image file 630 (...) 661 Additional arguments to pass to matplotlib `savefig()`. 662 """--> 663 sv =self.save_helper( 664filename=filename, 665format=format, 666path=path, 667width=width, 668height=height, 669units=units, 670dpi=dpi, 671limitsize=limitsize, 672verbose=verbose, 673**kwargs, 674) 676with plot_context(self).rc_context:
677 sv.figure.savefig(**sv.kwargs)
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:612, in ggplot.save_helper(self, filename, format, path, width, height, units, dpi, limitsize, verbose, **kwargs) 609if dpi isnotNone:
610self.theme =self.theme + theme(dpi=dpi)
--> 612 figure =self.draw(show=False) 613return mpl_save_view(figure, fig_kwargs)
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:272, in ggplot.draw(self, show) 270self= deepcopy(self)
271with plot_context(self, show=show):
--> 272self._build() 274# setup 275self.figure, self.axs =self.facet.setup(self)
File /opt/conda/lib/python3.12/site-packages/plotnine/ggplot.py:362, in ggplot._build(self) 358 layout.setup(layers, self)
360# Compute aesthetics to produce data with generalised 361# variable names--> 362layers.compute_aesthetics(self) 364# Transform data using all scales 365 layers.transform(scales)
File /opt/conda/lib/python3.12/site-packages/plotnine/layer.py:457, in Layers.compute_aesthetics(self, plot) 455defcompute_aesthetics(self, plot: ggplot):
456for l inself:
--> 457l.compute_aesthetics(plot)
File /opt/conda/lib/python3.12/site-packages/plotnine/layer.py:260, in layer.compute_aesthetics(self, plot) 253defcompute_aesthetics(self, plot: ggplot):
254""" 255 Return a dataframe where the columns match the aesthetic mappings 256 257 Transformations like 'factor(cyl)' and other 258 expression evaluation are made in here 259 """--> 260 evaled =evaluate(self.mapping._starting,self.data,plot.environment) 261 evaled_aes = aes(**{str(col): col for col in evaled})
262 plot.scales.add_defaults(evaled, evaled_aes)
File /opt/conda/lib/python3.12/site-packages/plotnine/mapping/evaluation.py:226, in evaluate(aesthetics, data, env) 224exceptExceptionas e:
225 msg = _TPL_EVAL_FAIL.format(ae, col, str(e))
--> 226raise PlotnineError(msg) frome 228try:
229 evaled[ae] = new_val
PlotnineError: "Could not evaluate the 'y' mapping: 'value' (original error: name 'value' is not defined)"
Enfin, sur l’ensemble de la période, la série prendra plutôt cette forme:
6 Première série temporelle
Il est plus commun de représenter les données ayant une dimension temporelle
sous la forme de série
que de barres empilées.
Exercice 5: barplot des comptages mensuels
Créer une variable day qui transforme l’horodatage en format journalier
du type 2021-05-01 avec dt.day.
Reproduire la figure de la page d’open data
7 Des graphiques réactifs grâce aux librairies Javascript
7.1 L’écosystème disponible depuis Python
L’inconvénient des figures figées construites avec matplotlib ou plotnine est que celles-ci ne permettent
pas d’interaction avec le lecteur. Toute l’information doit donc être
contenue dans la figure ce qui peut la rendre difficile à lire.
Si la figure est bien faite, avec différents niveaux d’information, cela
peut bien fonctionner.
Il est néanmoins plus simple, grâce aux technologies web, de proposer des
visualisations à plusieurs niveaux. Un premier niveau d’information, celui du
coup d’oeil, peut suffire à assimiler les principaux messages de la
visualisation. Ensuite, un comportement plus volontaire de recherche
d’information secondaire peut permettre d’en savoir plus. Les visualisations
réactives, qui sont maintenant la norme dans le monde de la dataviz,
permettent ce type d’approche : le lecteur d’une visualisation peut passer
sa souris à la recherche d’information complémentaire (par exemple les
valeurs exactes) ou cliquer pour faire apparaître des informations complémentaires
sur la visualisation ou autour.
Ces visualisations reposent sur le même triptyque que l’ensemble de l’écosystème
web : HTML, CSS et JavaScript. Les utilisateurs de Python
ne vont jamais manipuler directement ces langages, qui demandent une
certaine expertise, mais vont utiliser des librairies au niveau de R qui génèreront automatiquement tout le code HTML, CSS et JavaScript
permettant de créer la figure.
Il existe plusieurs écosystèmes Javascript mis à disposition des développeurs.euses par le biais de Python. Les deux principales librairies sont Plotly, associée à l’écosystème Javascript du même nom, et Altair, associée à l’écosystème Vega et Altair en Javascript4. Pour permettre aux pythonistes de découvrir la librairie Javascript émergente Observable Plot, l’ingénieur de recherche français Julien Barnier a développé pyobsplot une librairie Python permettant d’utiliser cet écosystème depuis Python.
L’interactivité ne doit pas juste être un gadget n’apportant pas de lisibilité supplémentaire, voire la détériorant. Il est rare de pouvoir se contenter de la figure produite sans avoir à fournir un travail supplémentaire pour la rendre efficace.
7.1.1 La librairie Plotly
Le package Plotly est une surcouche à la librairie JavascriptPlotly.js qui permet de créer et manipuler des objets graphiques de manière
très flexible afin de produire des objets réactifs sans avoir à recourir
à Javascript.
Le point d’entrée recommandé est le module plotly.express
(documentation ici) qui offre une approche intuitive pour construire des graphiques pouvant être modifiés a posteriori si besoin (par exemple pour customiser les axes).
Visualiser les figures produites par Plotly
Dans un notebookJupyter classique, les lignes suivantes de code permettent d’afficher le résultat d’une commande Plotly sous un bloc de code :
Pour JupyterLab, l’extension jupyterlab-plotly s’avère nécessaire:
!jupyter labextension install jupyterlab-plotly
7.2 Réplication de l’exemple précédent avec Plotly
Les modules suivants seront nécessaires pour construire des graphiques
avec plotly:
import plotlyimport plotly.express as px
Exercice 7: un barplot avec Plotly
L’objectif est de reconstuire le premier diagramme en barre rouge avec Plotly.
Réalisez le graphique en utilisant la fonction adéquate avec plotly.express et…
Ne pas prendre le
thème par défaut mais un à fond blanc, pour avoir un résultat ressemblant
à celui proposé sur le site de l’open-data.
Pour la couleur rouge,
vous pouvez utiliser l’argument color_discrete_sequence.
Ne pas oublier de nommer les axes
Pensez à la couleur du texte de l’axe inférieur
Tester un autre thème, à fond sombre. Pour les couleurs, faire un
groupe stockant les trois plus fortes valeurs puis les autres.
La première question permet de construire le graphique suivant :
Alors qu’avec le thème sombre (question 2), on obtient :
7.3 La librairie altair
Pour cet exemple, nous allons reconstruire notre figure précédente
Comme ggplot/plotnine, Vega est un écosystème graphique visant à proposer une implémentation de la grammaire des graphiques de Wilkinson (2012). La syntaxe de Vega est donc basée sur un principe déclaratif: on déclare une construction par couches et transformations de données progressives.
A l’origine, Vega est basée sur une syntaxe JSON, d’où son lien fort avec Javascript. Néanmoins, il existe une API Python qui permet de faire ce type de figures interactives nativement en Python. Pour comprendre la logique de construction d’un code altair, voici comment répliquer la figure précédente avec:
Voir l’architecture d’une figure altair
import altair as altcolor_scale = alt.Scale(domain=[True, False], range=["green", "red"])fig2 = ( alt.Chart(df1)1 .mark_bar()2 .encode( x=alt.X("average(sum_counts):Q", title="Moyenne du comptage par heure sur la période sélectionnée",3 ), y=alt.Y("nom_compteur:N", sort="-x", title=""), color=alt.Color("top:N", scale=color_scale, legend=alt.Legend(title="Top")), tooltip=[ alt.Tooltip("nom_compteur:N", title="Nom du compteur"), alt.Tooltip("sum_counts:Q", title="Moyenne horaire"),4 ], )5 .properties(title="Les 10 compteurs avec la moyenne horaire la plus élevée") .configure_view(strokeOpacity=0))fig2.interactive()
1
On déclare d’abord le dataframe qui sera utilisé, comme nous le faisions avec ggplot(df) avec plotnine. Puis le type de figure désirée (ici un diagramme en barre, mark_bar dans la grammaire d’altair)
2
On définit notre couche principale avec encode. Celle-ci peut accepter simplement des noms de colonnes ou des constructeurs plus complexes, comme ici
3
On définit un constructeur pour notre axe des x, à la fois pour gérer l’échelle des valeurs mais aussi les paramètres de celle-ci (labels, etc.). Ici, on définit l’axe des x comme une valeur continue (:Q), moyenne de sum_counts pour chaque valeur de \(y\). Cette moyenne n’est pas indispensable, on aurait pu se contenter d’écrire sum_counts:Q voire même sum_counts mais c’est pour illustrer la gestion des transformations de données dans altair
4
Le tooltip nous permet de gérer l’interactivité de notre figure.
5
Les propriétés viennent à la fin de notre déclaration pour finaliser la figure
md`Ce fichier a été modifié __${table_commit.length}__ fois depuis sa création le ${creation_string} (dernière modification le ${last_modification_string})`
functionreplacePullRequestPattern(inputString, githubRepo) {// Use a regular expression to match the pattern #digitvar pattern =/#(\d+)/g;// Replace the pattern with ${github_repo}/pull/#digitvar replacedString = inputString.replace(pattern,'[#$1]('+ githubRepo +'/pull/$1)');return replacedString;}
table_commit = {// Get the HTML table by its class namevar table =document.querySelector('.commit-table');// Check if the table existsif (table) {// Initialize an array to store the table datavar dataArray = [];// Extract headers from the first rowvar headers = [];for (var i =0; i < table.rows[0].cells.length; i++) { headers.push(table.rows[0].cells[i].textContent.trim()); }// Iterate through the rows, starting from the second rowfor (var i =1; i < table.rows.length; i++) {var row = table.rows[i];var rowData = {};// Iterate through the cells in the rowfor (var j =0; j < row.cells.length; j++) {// Use headers as keys and cell content as values rowData[headers[j]] = row.cells[j].textContent.trim(); }// Push the rowData object to the dataArray dataArray.push(rowData); } }return dataArray}
// Get the element with class 'git-details'{var gitDetails =document.querySelector('.commit-table');// Check if the element existsif (gitDetails) {// Hide the element gitDetails.style.display='none'; }}
Wilkinson, Leland. 2012. The grammar of graphics. Springer.
Notes de bas de page
Ce chapitre sera construit autour de l’écosystème Quarto. En attendant ce chapitre, vous pouvez consulter la documentation exemplaire de cet écosystème et pratiquer, ce sera le meilleur moyen de découvrir.↩︎
Heureusement, comme il existe un énorme corpus de code en ligne utilisant matplotlib, les assistants de code comme ChatGPT ou Github Copilot sont précieux pour construire un graphique à partir d’instructions.↩︎
J’ai retiré la couleur sur l’axe des ordonnées qui, je trouve,
apporte peu à la figure voire dégrade la compréhension du message.↩︎
Le nom de ces librairies est inspiré de la constellation du triangle d’été dont Véga et Altair sont deux membres.↩︎
Citation
BibTeX
@book{galiana2023,
author = {Galiana, Lino},
title = {Python pour la data science},
date = {2023},
url = {https://pythonds.linogaliana.fr/},
doi = {10.5281/zenodo.8229676},
langid = {fr}
}