Pychenette est une série de notebooks, en français, courts et originaux avec Python.
Auteur : Francis Wolinski
Pour tout commentaire : pychenette[at]yotta-conseil.fr
Il y a 18 mois, en novembre 2022, nous avions publié une introduction au method chaining avec la librairie pandas (voir https://www.stat4decision.com/fr/method-chaining-avec-la-librairie-pandas/). Depuis, le succès de cette librairie ne s'est pas départi. Une nouvelle version majeure a été publiée en avril 2023. Elle permet de gérer un backend data basé sur Apache Arrow au lieu des types habituels de NumPy.
Toutefois, nous allons plutôt nous pencher vers une autre librairie gérant des dataframes : polars, Dataframes powered by a multithreaded, vectorized query engine, written in Rust. Polars fait partie des nombreuses librairies qui gèrent des dataframes (voir https://github.com/jcmkk3/awesome-dataframes).
Polars a été développé par Ritchie Vink pendant la période du COVID en 2020. La librarie est conçue pour fournir des capacités de manipulation de données rapides et efficaces en termes de mémoire pour de grands datasets. Polars BV est aussi une entreprise fondée à Amsterdam en 2023 et dotée de 3,6 M€ de capital et en charge du développement de la librairie.
En 3 ans de développement polars a acquis 26.7k ★ sur github et comprend environ 400 contributeurs en mai 2024.
import polars as pl
import hvplot as hv
hv.extension('matplotlib', 'plotly')
On utilise les opérations classiques de polars pour charger un jeu de données et les préparer :
read_csv()
lecture des fichiers CSV en mode eager, il existe également scan_csv()
en mode lazy,drop_nulls()
suppression des lignes avec des valeurs manquantes,rename()
renommage des colonnes,filter()
sélection de lignes,with_columns()
modification ou ajout de colonnes,select()
sélection de colonnes,sort()
tri par colonne.tab = (pl
.read_csv('data/nat2022.csv', separator=';', null_values={"preusuel": "_PRENOMS_RARES", "annais":"XXXX"})
.drop_nulls()
.rename({'sexe': 'gender', 'preusuel': 'name', 'annais': 'year', 'nombre': 'births'})
.filter(pl.col("name").str.len_chars() > 1)
.with_columns(pl.col('gender').replace([1, 2], ["M", "F"]),
pl.col('name').str.to_titlecase())
.select(pl.col("year"), pl.col("name"), pl.col("gender"),pl.col("births"))
.sort(['year', 'gender', 'births', 'name'], descending=[False, False, True, False])
)
tab
year | name | gender | births |
---|---|---|---|
i64 | str | str | i64 |
1900 | "Marie" | "F" | 48713 |
1900 | "Jeanne" | "F" | 13981 |
1900 | "Marguerite" | "F" | 8058 |
1900 | "Germaine" | "F" | 6981 |
1900 | "Louise" | "F" | 6696 |
… | … | … | … |
2022 | "Éliano" | "M" | 3 |
2022 | "Élohan" | "M" | 3 |
2022 | "Éphraïm" | "M" | 3 |
2022 | "Îmran" | "M" | 3 |
2022 | "Özgür" | "M" | 3 |
hv.output(backend='matplotlib')
(tab
.pivot(values="births", index="year", columns="gender", aggregate_function="sum")
.plot
.line(x="year",
y=["F", "M"],
title="Nombre de naissances par année et par genre")
)
hv.output(backend='plotly')
(tab
.pivot(values="births", index="year", columns="gender", aggregate_function="sum")
.plot
.line(x="year",
y=["F", "M"],
title="Nombre de naissances par année et par genre")
)
En remplaçant la fonction read_csv()
par scan_csv()
on accède à deux fonctionnalités :
scan_csv()
permet de charger un ensemble de fichiers, en fournissant une liste de fichiers ou en utilisant un wildcard '*'
à la linux,scan_csv()
retourne un LazyFrame
, et non un DataFrame
, qui définit un plan d'exécution optimisé des requêtes qu'il est possible de détailler avec la méthode explain()
ou de visualiser avec la méthode show_graph()
et enfin d'exécuter avec la méthode collect()
.Dans les exemples ci-dessous, on a repris le code précédent jusqu'avant l'instruction pivot()
qui n'est pas implémentée par la classe LazyFrame
.
lazy = (pl
.scan_csv('data/nat2022.csv', separator=';', null_values={"preusuel": "_PRENOMS_RARES", "annais":"XXXX"})
.drop_nulls()
.rename({'sexe': 'gender', 'preusuel': 'name', 'annais': 'year', 'nombre': 'births'})
.filter(pl.col("name").str.len_chars() > 1)
.with_columns(pl.col('gender').replace([1, 2], ["M", "F"]),
pl.col('name').str.to_titlecase())
.select(pl.col("year"), pl.col("name"), pl.col("gender"),pl.col("births"))
.sort(['year', 'gender', 'births', 'name'], descending=[False, False, True, False])
)
explain()
¶La méthode explain()
permet de détailler le plan d'exécution de la requête.
print(lazy.explain())
SORT BY [col("year"), col("gender"), col("births"), col("name")] SIMPLE_PROJECTION WITH_COLUMNS: [col("gender").replace([Series, Series]), col("name").str.titlecase()], [] RENAME Csv SCAN data/nat2022.csv PROJECT */4 COLUMNS SELECTION: [([(col("preusuel").str.len_chars()) > (1)]) & ([([([(col("sexe").is_not_null()) & (col("preusuel").is_not_null())]) & (col("annais").is_not_null())]) & (col("nombre").is_not_null())])]
show_graph()
¶La méthode show_graph()
permet de visualiser le plan d'exécution de la requête.
lazy.show_graph()
collect()
¶Ensuite, pour afficher le graphique, il faut utiliser la méthode collect()
pour exécuter les requêtes, matérialiser le dataframe et ensuite appliquer le pivot.
(lazy
.collect()
.pivot(values="births", index="year", columns="gender", aggregate_function="sum")
.plot
.line(x="year",
y=["F", "M"],
title="Nombre de naissances par année et par genre")
)
Dans cet exemple, on charge les données mensuelles de l'indice d'humidité des sols fournies par Météo France et on affiche une carte départementale de la moyenne de cet indice pour une année donnée.
Cet exemple plus complet va nous permet d'utiliser une partie de la richesse de la librairie polars.
L’indicateur SWI « Uniforme » : Le SWI (de l’anglais Soil Wetness Index) est un indice d’humidité des sols documenté dans la littérature scientifique. Il représente, sur une profondeur d’environ deux mètres, l’état de la réserve en eau du sol par rapport à la réserve utile (eau disponible pour l’alimentation des plantes).
Dans le modèle SIM, le territoire de France métropolitaine est découpé en mailles géographiques de 8 kilomètres de côté. Il est ainsi couvert par 8 981 mailles. Chacune des mailles ainsi définie est numérotée et recouvre tout ou partie d’une commune. Ce maillage est fixe et n’évolue pas d’une année sur l’autre (coordonnées Lambert II étendu).
Source : https://donneespubliques.meteofrance.fr/?fond=produit&id_produit=301&id_rubrique=40
On utilise la méthode scan_csv()
. La conversion des dates s'effectue avec la méthode str.to_date()
.
On obtient un lazyframe qui correspond à un dataframe de 5,8 M lignes.
swi = (pl
.scan_csv("SWI_Package_1969-2022/*.csv", separator=";", decimal_comma=True, dtypes={"DATE":pl.String})
.with_columns(pl.col("DATE").str.to_date("%Y%m"))
)
tab = swi.collect()
print("Taille du dataframe", tab.shape)
tab.head()
Taille du dataframe (5819688, 5)
NUMERO | LAMBX | LAMBY | DATE | SWI_UNIF_MENS3 |
---|---|---|---|---|
i64 | i64 | i64 | date | f64 |
2 | 641374 | 7106309 | 1969-01-01 | 1.013 |
2 | 641374 | 7106309 | 1969-02-01 | 1.054 |
2 | 641374 | 7106309 | 1969-03-01 | 1.089 |
2 | 641374 | 7106309 | 1969-04-01 | 1.062 |
2 | 641374 | 7106309 | 1969-05-01 | 1.054 |
Pour afficher ces informations dans une carte départementale, il faut passer des coordonnées Lambert aux départements.
On va d'abord passer des coordonnées Lambert aux coordonnées GPS avec la librairie lambert, puis utiliser le fichier "correspondance-code-insee-code-postal.csv"
fourni par l'Insee pour passer des coordonnées GPS aux départements.
Le chargement du fichier de l'Insee nécessite d'extraire la latitude et la longitude qui sont fournies sous la forme d'une chaîne de caractères dans la colonne "geo_point_2d"
, par exemple : "43.2904403081, 0.650641474176"
.
La méthode str.split_exact()
permet d'extraire les 2 parties dans une structure de données. La méthode struct.rename_fields()
permet de renommer les membres de la structure de données qui sont ensuite convertis en flottants. Enfin, la méthode unnest()
permet de remonter les membres de la structure de données au niveau du dataframe.
Le résultat est un dataframe avec pour chacune des 36000 communes son code de département ainsi que sa latitude et sa longitude.
insee = (pl
.read_csv("data/correspondance-code-insee-code-postal.csv",
separator=";",
columns=["Code INSEE", "Code Département", "Département", "geo_point_2d"])
.with_columns(pl.col("geo_point_2d").str.split_exact(r", ", 1).struct.rename_fields(["lat", "lon"]).alias("lat_lon").cast(pl.Float64))
.unnest("lat_lon")
)
print("Taille du dataframe", insee.shape)
insee.head()
Taille du dataframe (36742, 6)
Code INSEE | Département | geo_point_2d | Code Département | lat | lon |
---|---|---|---|---|---|
str | str | str | str | f64 | f64 |
"31080" | "HAUTE-GARONNE" | "43.2904403081, 0.650641474176" | "31" | 43.29044 | 0.650641 |
"11143" | "AUDE" | "42.9291375888, 2.90138923544" | "11" | 42.929138 | 2.901389 |
"43028" | "HAUTE-LOIRE" | "45.1306448726, 4.07952494849" | "43" | 45.130645 | 4.079525 |
"78506" | "YVELINES" | "48.5267627187, 1.80513972814" | "78" | 48.526763 | 1.80514 |
"84081" | "VAUCLUSE" | "43.9337788848, 4.90875878315" | "84" | 43.933779 | 4.908759 |
Ensuite la fonction get_departement()
ci-dessous permet de trouver le département correspondant à une latitude et une longitude données en recherchant la commune la plus proche, c'est-à-dire, celle qui minimise la distance euclidienne (au carré).
On utilise les méthodes row()
et arg_min()
pour sélectionner la ligne minimisant le calcul effectué.
def get_departement(lon, lat):
dist2 = (insee.get_column("lon") - lon) ** 2 + (insee.get_column("lat") - lat) ** 2
dico = insee.row(dist2.arg_min(), named=True)
return dico["Code Département"]
print(get_departement(0.650641474176, 43.2904403081))
31
Enfin, en partant du lazyframe SWI, on unicise les coordonnées de Lambert (car il y a toutes les années), puis on applique la fonction de conversion vers les coordonnées GPS fournie par la librairie lambert, ensuite on extrait la latitude et la longitude de l'objet LambertPoint
obtenu et enfin on recherche le département avec la fonction définie ci-dessus.
On utilise la méthode map_elements()
qui permet d'appliquer une fonction (ou une lambda) à une colonne, ou plusieurs via une structure, en indiquant également le data type retourné (voir https://docs.pola.rs/py-polars/html/reference/datatypes.html).
On obtient ainsi un autre lazyframe avec un code de département pour chacune des 8981 mailles.
from lambert import convertToWGS84Deg, Lambert93
swi_gps = (swi
.unique(subset=["NUMERO", "LAMBX", "LAMBY"])
.with_columns(pl.struct(['LAMBX', 'LAMBY']).map_elements(lambda v: convertToWGS84Deg(v['LAMBX'], v['LAMBY'], Lambert93), return_dtype=pl.Object).alias('WGS84Deg'))
.with_columns(pl.col('WGS84Deg').map_elements(lambda v: get_departement(v.getX(), v.getY()), return_dtype=pl.String).alias('département'))
.sort(by="NUMERO")
)
tab = swi_gps.collect()
print("Taille du dataframe", tab.shape)
tab.head()
Taille du dataframe (8981, 7)
NUMERO | LAMBX | LAMBY | DATE | SWI_UNIF_MENS3 | WGS84Deg | département |
---|---|---|---|---|---|---|
i64 | i64 | i64 | date | f64 | object | str |
2 | 641374 | 7106309 | 1969-01-01 | 1.013 | <lambert.LambertPoint object at 0x7f213eba9250> | "59" |
3 | 649370 | 7106242 | 1969-01-01 | 0.967 | <lambert.LambertPoint object at 0x7f213ebc4550> | "59" |
4 | 657366 | 7106175 | 1969-01-01 | 0.985 | <lambert.LambertPoint object at 0x7f213eb82310> | "59" |
5 | 665362 | 7106108 | 1969-01-01 | 1.014 | <lambert.LambertPoint object at 0x7f213ebc7d50> | "59" |
6 | 673358 | 7106041 | 1969-01-01 | 1.029 | <lambert.LambertPoint object at 0x7f213fd63350> | "59" |
Pour terminer, on effectue une jointure entre le lazyframe SWI initial et le résultat obtenu dans le lazyframe SWI_GPS. On ajoute une colonne "année"
tirée de la colonne "DATE"
et on calcule la moyenne des données SWI par année et par département.
On obtient un lazyframe avec la moyenne de l'indice SWI pour chaque département et chaque année.
data = (swi
.join(swi_gps.select(["NUMERO", "département"]), on="NUMERO")
.with_columns(pl.col("DATE").dt.year().alias("année"))
.group_by(["département", "année"])
.agg(pl.col('SWI_UNIF_MENS3').mean())
)
tab = data.collect()
print("Taille du dataframe", tab.shape)
tab.head()
Taille du dataframe (5184, 3)
département | année | SWI_UNIF_MENS3 |
---|---|---|
str | i32 | f64 |
"04" | 1978 | 0.827043 |
"29" | 1989 | 0.659335 |
"54" | 1971 | 0.671302 |
"92" | 1984 | 0.748771 |
"68" | 2005 | 0.816618 |
Pour afficher ces données dans une carte choroplèthe, il faut utiliser un fichier geojson qui fournit les polygones de chaque département français. Le fichier utilisé provient du site : https://france-geojson.gregoiredavid.fr/
import json
with open("./data/departements.geojson") as f:
departements = json.loads(f.read())
La production du graphique est ensuite assez immédiate en utilisant le module plotly.graph_objects.
Il faut filtrer les données calculées selon une année particulière. Le lien avec le fichier geojson s'effectue via la clé "properties.code"
.
import plotly.graph_objects as go
def display_year(data, year):
# select year and collect dataframe
df = data.filter(pl.col("année")==year).collect()
# choropleth_mapbox
fig = go.Figure()
fig.add_trace(go.Choroplethmapbox(locations=df["département"],
text=df["département"],
z=df["SWI_UNIF_MENS3"],
geojson=departements,
featureidkey="properties.code",
colorscale="YlGnBu",
))
fig.update_layout(mapbox_style="carto-positron",
mapbox_zoom=4.2,
mapbox_center={"lat": 47.0, "lon": 3.0},
width=700,
height=600,
title_text=f"Humidité moyenne des sols pour l'année {year}", font_size=10)
return fig
fig = display_year(data, 2022)
fig.show()