19. Polars#
En plus de ce qui est fourni dans Anaconda, ce cours nécessitera les bibliothèques suivantes :
!pip install --upgrade polars yfinance
19.1. Aperçu général#
Polars est une bibliothèque de manipulation de données rapide pour Python, écrite en Rust.
Elle a gagné une popularité considérable en tant qu’alternative moderne à pandas en raison de ses avantages en matière de performance.
Polars est conçue en tenant compte de la performance et de l’efficacité mémoire, en s’appuyant sur :
Le format colonnaire Apache Arrow pour un accès rapide aux données
L”évaluation paresseuse pour optimiser l’exécution des requêtes
Le traitement parallèle pour utiliser tous les cœurs de processeur disponibles
Une API expressive construite autour d’expressions de colonnes
Astuce
Pourquoi envisager Polars plutôt que pandas ?
Mémoire : pandas nécessite généralement 5 à 10 fois la taille de votre jeu de données en RAM ; Polars n’en nécessite que 2 à 4 fois
Vitesse : Polars est 10 à 100 fois plus rapide pour de nombreuses opérations courantes
Voir : Les benchmarks TPC-H de Polars pour des comparaisons de performance à jour
Tout au long du cours, nous supposerons que les importations suivantes ont été effectuées
import polars as pl
import numpy as np
import matplotlib.pyplot as plt
Comme Pandas, Polars définit deux types de données importants : Series et DataFrame.
Vous pouvez considérer une Series comme une colonne de données, telle qu’une collection d’observations sur une seule variable.
Un DataFrame est un objet à deux dimensions permettant de stocker des colonnes de données liées entre elles.
19.2. Series#
Commençons par les Series.
Nous commençons par créer une série de quatre observations aléatoires
s = pl.Series(name='daily returns', values=np.random.randn(4))
s
| daily returns |
|---|
| f64 |
| 2.218478 |
| -0.37565 |
| -1.465469 |
| -1.298266 |
Note
Contrairement aux Series pandas, les Series Polars n’ont pas d’indice de ligne. Polars est centré sur les colonnes — l’accès aux données est géré par des expressions de colonnes et des masques booléens plutôt que par des étiquettes de ligne. Consultez le guide de migration Polars pour les utilisateurs de pandas pour plus de détails.
Les Series Polars sont construites sur les tableaux Apache Arrow et prennent en charge de nombreuses opérations familières
s * 100
| daily returns |
|---|
| f64 |
| 221.847765 |
| -37.564952 |
| -146.546938 |
| -129.826624 |
Les valeurs absolues sont disponibles sous forme de méthode
s.abs()
| daily returns |
|---|
| f64 |
| 2.218478 |
| 0.37565 |
| 1.465469 |
| 1.298266 |
Nous pouvons également obtenir rapidement des statistiques récapitulatives
s.describe()
| statistic | value |
|---|---|
| str | f64 |
| "count" | 4.0 |
| "null_count" | 0.0 |
| "mean" | -0.230227 |
| "std" | 1.701356 |
| "min" | -1.465469 |
| "25%" | -1.298266 |
| "50%" | -0.37565 |
| "75%" | -0.37565 |
| "max" | 2.218478 |
Étant donné que Polars n’a pas d’indice de ligne, les données étiquetées nécessitent un DataFrame.
Par exemple, pour associer des symboles boursiers à des rendements :
df = pl.DataFrame({
'company': ['AMZN', 'AAPL', 'MSFT', 'GOOG'],
'daily returns': np.random.randn(4)
})
df
| company | daily returns |
|---|---|
| str | f64 |
| "AMZN" | -0.428645 |
| "AAPL" | 0.039412 |
| "MSFT" | -1.396405 |
| "GOOG" | -1.28706 |
Nous accédons à une valeur en filtrant sur une expression de colonne
df.filter(
pl.col('company') == 'AMZN'
).select('daily returns').item()
-0.42864520897469816
Les mises à jour utilisent également des expressions plutôt qu’une affectation par indice
df = df.with_columns(
pl.when(pl.col('company') == 'AMZN')
.then(0)
.otherwise(pl.col('daily returns'))
.alias('daily returns')
)
df
| company | daily returns |
|---|---|
| str | f64 |
| "AMZN" | 0.0 |
| "AAPL" | 0.039412 |
| "MSFT" | -1.396405 |
| "GOOG" | -1.28706 |
Nous pouvons également vérifier l’appartenance
'AAPL' in df['company']
True
19.3. DataFrames#
Alors qu’une Series est une seule colonne de données, un DataFrame comporte plusieurs colonnes, une pour chaque variable.
Comme dans Pandas, travaillons avec les données des Penn World Tables.
Nous les lisons à l’aide de pl.read_csv
url = ('https://raw.githubusercontent.com/QuantEcon/'
'lecture-python-programming/main/lectures/_static/'
'lecture_specific/pandas/data/test_pwt.csv')
df = pl.read_csv(url)
df
| country | country isocode | year | POP | XRAT | tcgdp | cc | cg |
|---|---|---|---|---|---|---|---|
| str | str | i64 | f64 | f64 | f64 | f64 | f64 |
| "Argentina" | "ARG" | 2000 | 37335.653 | 0.9995 | 295072.21869 | 75.716805 | 5.578804 |
| "Australia" | "AUS" | 2000 | 19053.186 | 1.72483 | 541804.6521 | 67.759026 | 6.720098 |
| "India" | "IND" | 2000 | 1.0063e6 | 44.9416 | 1.7281e6 | 64.575551 | 14.072206 |
| "Israel" | "ISR" | 2000 | 6114.57 | 4.07733 | 129253.89423 | 64.436451 | 10.266688 |
| "Malawi" | "MWI" | 2000 | 11801.505 | 59.543808 | 5026.221784 | 74.707624 | 11.658954 |
| "South Africa" | "ZAF" | 2000 | 45064.098 | 6.93983 | 227242.36949 | 72.71871 | 5.726546 |
| "United States" | "USA" | 2000 | 282171.957 | 1.0 | 9.8987e6 | 72.347054 | 6.032454 |
| "Uruguay" | "URY" | 2000 | 3219.793 | 12.099592 | 25255.961693 | 78.97874 | 5.108068 |
19.3.1. Sélection des données#
Nous pouvons sélectionner des lignes par découpage (slicing) et des colonnes par nom
df[2:5]
| country | country isocode | year | POP | XRAT | tcgdp | cc | cg |
|---|---|---|---|---|---|---|---|
| str | str | i64 | f64 | f64 | f64 | f64 | f64 |
| "India" | "IND" | 2000 | 1.0063e6 | 44.9416 | 1.7281e6 | 64.575551 | 14.072206 |
| "Israel" | "ISR" | 2000 | 6114.57 | 4.07733 | 129253.89423 | 64.436451 | 10.266688 |
| "Malawi" | "MWI" | 2000 | 11801.505 | 59.543808 | 5026.221784 | 74.707624 | 11.658954 |
Pour sélectionner des colonnes spécifiques, passez une liste de noms à select
df.select(['country', 'tcgdp'])
| country | tcgdp |
|---|---|
| str | f64 |
| "Argentina" | 295072.21869 |
| "Australia" | 541804.6521 |
| "India" | 1.7281e6 |
| "Israel" | 129253.89423 |
| "Malawi" | 5026.221784 |
| "South Africa" | 227242.36949 |
| "United States" | 9.8987e6 |
| "Uruguay" | 25255.961693 |
Ces opérations peuvent être combinées
df[2:5].select(['country', 'tcgdp'])
| country | tcgdp |
|---|---|
| str | f64 |
| "India" | 1.7281e6 |
| "Israel" | 129253.89423 |
| "Malawi" | 5026.221784 |
19.3.2. Filtrage par conditions#
La méthode filter accepte des expressions booléennes construites à partir de pl.col
df.filter(pl.col('POP') >= 20000)
| country | country isocode | year | POP | XRAT | tcgdp | cc | cg |
|---|---|---|---|---|---|---|---|
| str | str | i64 | f64 | f64 | f64 | f64 | f64 |
| "Argentina" | "ARG" | 2000 | 37335.653 | 0.9995 | 295072.21869 | 75.716805 | 5.578804 |
| "India" | "IND" | 2000 | 1.0063e6 | 44.9416 | 1.7281e6 | 64.575551 | 14.072206 |
| "South Africa" | "ZAF" | 2000 | 45064.098 | 6.93983 | 227242.36949 | 72.71871 | 5.726546 |
| "United States" | "USA" | 2000 | 282171.957 | 1.0 | 9.8987e6 | 72.347054 | 6.032454 |
Plusieurs conditions peuvent être combinées avec & (et) et | (ou)
df.filter(
(pl.col('country').is_in(['Argentina', 'India', 'South Africa'])) &
(pl.col('POP') > 40000)
)
| country | country isocode | year | POP | XRAT | tcgdp | cc | cg |
|---|---|---|---|---|---|---|---|
| str | str | i64 | f64 | f64 | f64 | f64 | f64 |
| "India" | "IND" | 2000 | 1.0063e6 | 44.9416 | 1.7281e6 | 64.575551 | 14.072206 |
| "South Africa" | "ZAF" | 2000 | 45064.098 | 6.93983 | 227242.36949 | 72.71871 | 5.726546 |
Les expressions peuvent impliquer des opérations arithmétiques entre colonnes
df.filter(
(pl.col('cc') + pl.col('cg') >= 80) & (pl.col('POP') <= 20000)
)
| country | country isocode | year | POP | XRAT | tcgdp | cc | cg |
|---|---|---|---|---|---|---|---|
| str | str | i64 | f64 | f64 | f64 | f64 | f64 |
| "Malawi" | "MWI" | 2000 | 11801.505 | 59.543808 | 5026.221784 | 74.707624 | 11.658954 |
| "Uruguay" | "URY" | 2000 | 3219.793 | 12.099592 | 25255.961693 | 78.97874 | 5.108068 |
Sélectionnons le pays ayant la part de consommation des ménages la plus élevée
df.filter(pl.col('cc') == pl.col('cc').max())
| country | country isocode | year | POP | XRAT | tcgdp | cc | cg |
|---|---|---|---|---|---|---|---|
| str | str | i64 | f64 | f64 | f64 | f64 | f64 |
| "Uruguay" | "URY" | 2000 | 3219.793 | 12.099592 | 25255.961693 | 78.97874 | 5.108068 |
19.3.3. Expressions de colonnes#
Une différence essentielle avec pandas est que Polars utilise des expressions de colonnes pour les transformations plutôt que des appels apply élément par élément.
Voici un exemple calculant le maximum de chaque colonne numérique
df.select(
pl.col(['year', 'POP', 'XRAT', 'tcgdp', 'cc', 'cg'])
.max()
.name.suffix('_max')
)
| year_max | POP_max | XRAT_max | tcgdp_max | cc_max | cg_max |
|---|---|---|---|---|---|
| i64 | f64 | f64 | f64 | f64 | f64 |
| 2000 | 1.0063e6 | 59.543808 | 9.8987e6 | 78.97874 | 14.072206 |
Les expressions peuvent être utilisées dans with_columns pour ajouter ou modifier des colonnes
df.with_columns(
(pl.col('XRAT') / 10).alias('XRAT_scaled'),
pl.col(pl.Float64).round(2)
)
| country | country isocode | year | POP | XRAT | tcgdp | cc | cg | XRAT_scaled |
|---|---|---|---|---|---|---|---|---|
| str | str | i64 | f64 | f64 | f64 | f64 | f64 | f64 |
| "Argentina" | "ARG" | 2000 | 37335.65 | 1.0 | 295072.22 | 75.72 | 5.58 | 0.09995 |
| "Australia" | "AUS" | 2000 | 19053.19 | 1.72 | 541804.65 | 67.76 | 6.72 | 0.172483 |
| "India" | "IND" | 2000 | 1006300.3 | 44.94 | 1.7281e6 | 64.58 | 14.07 | 4.49416 |
| "Israel" | "ISR" | 2000 | 6114.57 | 4.08 | 129253.89 | 64.44 | 10.27 | 0.407733 |
| "Malawi" | "MWI" | 2000 | 11801.5 | 59.54 | 5026.22 | 74.71 | 11.66 | 5.954381 |
| "South Africa" | "ZAF" | 2000 | 45064.1 | 6.94 | 227242.37 | 72.72 | 5.73 | 0.693983 |
| "United States" | "USA" | 2000 | 282171.96 | 1.0 | 9.8987e6 | 72.35 | 6.03 | 0.1 |
| "Uruguay" | "URY" | 2000 | 3219.79 | 12.1 | 25255.96 | 78.98 | 5.11 | 1.209959 |
La logique conditionnelle utilise pl.when(...).then(...).otherwise(...)
df.with_columns(
pl.when(pl.col('POP') >= 20000)
.then(pl.col('POP'))
.otherwise(None)
.alias('POP_filtered')
).select(['country', 'POP', 'POP_filtered'])
| country | POP | POP_filtered |
|---|---|---|
| str | f64 | f64 |
| "Argentina" | 37335.653 | 37335.653 |
| "Australia" | 19053.186 | null |
| "India" | 1.0063e6 | 1.0063e6 |
| "Israel" | 6114.57 | null |
| "Malawi" | 11801.505 | null |
| "South Africa" | 45064.098 | 45064.098 |
| "United States" | 282171.957 | 282171.957 |
| "Uruguay" | 3219.793 | null |
Note
Polars fournit map_elements comme solution de secours pour appliquer des fonctions
Python arbitraires ligne par ligne, mais cela contourne le moteur d’expression
optimisé et devrait être évité lorsqu’une expression native existe.
19.3.4. Valeurs manquantes#
Insérons quelques valeurs nulles pour démontrer les techniques d’imputation
df_nulls = df.with_row_index().with_columns(
pl.when(pl.col('index') == 0)
.then(None).otherwise(pl.col('XRAT')).alias('XRAT'),
pl.when(pl.col('index') == 3)
.then(None).otherwise(pl.col('cc')).alias('cc'),
pl.when(pl.col('index') == 5)
.then(None).otherwise(pl.col('tcgdp')).alias('tcgdp'),
pl.when(pl.col('index') == 6)
.then(None).otherwise(pl.col('POP')).alias('POP'),
).drop('index')
df_nulls
| country | country isocode | year | POP | XRAT | tcgdp | cc | cg |
|---|---|---|---|---|---|---|---|
| str | str | i64 | f64 | f64 | f64 | f64 | f64 |
| "Argentina" | "ARG" | 2000 | 37335.653 | null | 295072.21869 | 75.716805 | 5.578804 |
| "Australia" | "AUS" | 2000 | 19053.186 | 1.72483 | 541804.6521 | 67.759026 | 6.720098 |
| "India" | "IND" | 2000 | 1.0063e6 | 44.9416 | 1.7281e6 | 64.575551 | 14.072206 |
| "Israel" | "ISR" | 2000 | 6114.57 | 4.07733 | 129253.89423 | null | 10.266688 |
| "Malawi" | "MWI" | 2000 | 11801.505 | 59.543808 | 5026.221784 | 74.707624 | 11.658954 |
| "South Africa" | "ZAF" | 2000 | 45064.098 | 6.93983 | null | 72.71871 | 5.726546 |
| "United States" | "USA" | 2000 | null | 1.0 | 9.8987e6 | 72.347054 | 6.032454 |
| "Uruguay" | "URY" | 2000 | 3219.793 | 12.099592 | 25255.961693 | 78.97874 | 5.108068 |
Remplissons toutes les valeurs nulles par zéro
df_nulls.fill_null(0)
| country | country isocode | year | POP | XRAT | tcgdp | cc | cg |
|---|---|---|---|---|---|---|---|
| str | str | i64 | f64 | f64 | f64 | f64 | f64 |
| "Argentina" | "ARG" | 2000 | 37335.653 | 0.0 | 295072.21869 | 75.716805 | 5.578804 |
| "Australia" | "AUS" | 2000 | 19053.186 | 1.72483 | 541804.6521 | 67.759026 | 6.720098 |
| "India" | "IND" | 2000 | 1.0063e6 | 44.9416 | 1.7281e6 | 64.575551 | 14.072206 |
| "Israel" | "ISR" | 2000 | 6114.57 | 4.07733 | 129253.89423 | 0.0 | 10.266688 |
| "Malawi" | "MWI" | 2000 | 11801.505 | 59.543808 | 5026.221784 | 74.707624 | 11.658954 |
| "South Africa" | "ZAF" | 2000 | 45064.098 | 6.93983 | 0.0 | 72.71871 | 5.726546 |
| "United States" | "USA" | 2000 | 0.0 | 1.0 | 9.8987e6 | 72.347054 | 6.032454 |
| "Uruguay" | "URY" | 2000 | 3219.793 | 12.099592 | 25255.961693 | 78.97874 | 5.108068 |
Ou remplissons avec les moyennes des colonnes
cols = ['cc', 'tcgdp', 'POP', 'XRAT']
df_nulls.with_columns(
pl.col(cols).fill_null(pl.col(cols).mean())
)
| country | country isocode | year | POP | XRAT | tcgdp | cc | cg |
|---|---|---|---|---|---|---|---|
| str | str | i64 | f64 | f64 | f64 | f64 | f64 |
| "Argentina" | "ARG" | 2000 | 37335.653 | 18.618141 | 295072.21869 | 75.716805 | 5.578804 |
| "Australia" | "AUS" | 2000 | 19053.186 | 1.72483 | 541804.6521 | 67.759026 | 6.720098 |
| "India" | "IND" | 2000 | 1.0063e6 | 44.9416 | 1.7281e6 | 64.575551 | 14.072206 |
| "Israel" | "ISR" | 2000 | 6114.57 | 4.07733 | 129253.89423 | 72.400502 | 10.266688 |
| "Malawi" | "MWI" | 2000 | 11801.505 | 59.543808 | 5026.221784 | 74.707624 | 11.658954 |
| "South Africa" | "ZAF" | 2000 | 45064.098 | 6.93983 | 1.8033e6 | 72.71871 | 5.726546 |
| "United States" | "USA" | 2000 | 161269.871714 | 1.0 | 9.8987e6 | 72.347054 | 6.032454 |
| "Uruguay" | "URY" | 2000 | 3219.793 | 12.099592 | 25255.961693 | 78.97874 | 5.108068 |
Polars prend également en charge le remplissage en avant (fill_null(strategy='forward')) et l’interpolation.
Il existe des outils d’imputation plus avancés disponibles dans scikit-learn.
19.3.5. Visualisation#
Construisons une colonne de PIB par habitant et traçons-la
df = (df
.select(['country', 'POP', 'tcgdp'])
.rename({'POP': 'population', 'tcgdp': 'total GDP'})
.with_columns(
(pl.col('population') * 1e3).alias('population')
)
.with_columns(
(pl.col('total GDP') * 1e6 / pl.col('population'))
.alias('GDP percap')
)
.sort('GDP percap', descending=True)
)
df
| country | population | total GDP | GDP percap |
|---|---|---|---|
| str | f64 | f64 | f64 |
| "United States" | 2.82171957e8 | 9.8987e6 | 35080.381854 |
| "Australia" | 1.9053186e7 | 541804.6521 | 28436.433261 |
| "Israel" | 6.11457e6 | 129253.89423 | 21138.672749 |
| "Argentina" | 3.7335653e7 | 295072.21869 | 7903.229085 |
| "Uruguay" | 3.219793e6 | 25255.961693 | 7843.97062 |
| "South Africa" | 4.5064098e7 | 227242.36949 | 5042.647686 |
| "India" | 1.0063e9 | 1.7281e6 | 1717.324719 |
| "Malawi" | 1.1801505e7 | 5026.221784 | 425.896679 |
Nous pouvons extraire directement les colonnes pour matplotlib
Note
Polars fournit également une API de tracé intégrée
basée sur Altair (par exemple, df.plot.bar(x=..., y=...)).
Nous utilisons matplotlib ici pour rester cohérents avec le reste de la série de cours.
fig, ax = plt.subplots()
ax.bar(df['country'].to_list(), df['GDP percap'].to_list())
ax.set_xlabel('country', fontsize=12)
ax.set_ylabel('GDP per capita', fontsize=12)
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
19.4. Évaluation paresseuse#
L’une des fonctionnalités les plus puissantes de Polars est l”évaluation paresseuse (lazy evaluation).
Au lieu d’exécuter chaque opération immédiatement, le mode paresseux collecte le plan de requête complet et l’optimise avant de l’exécuter.
19.4.1. Mode immédiat vs mode paresseux#
# Rechargement du jeu de données
url = ('https://raw.githubusercontent.com/QuantEcon/'
'lecture-python-programming/main/lectures/_static/'
'lecture_specific/pandas/data/test_pwt.csv')
df_full = pl.read_csv(url)
L’API immédiate (eager) s’exécute directement (comme pandas)
result_eager = (df_full
.filter(pl.col('tcgdp') > 1000)
.select(['country', 'year', 'tcgdp'])
.sort('tcgdp', descending=True)
)
result_eager.head()
| country | year | tcgdp |
|---|---|---|
| str | i64 | f64 |
| "United States" | 2000 | 9.8987e6 |
| "India" | 2000 | 1.7281e6 |
| "Australia" | 2000 | 541804.6521 |
| "Argentina" | 2000 | 295072.21869 |
| "South Africa" | 2000 | 227242.36949 |
L’API paresseuse (lazy) construit plutôt un plan de requête
lazy_query = (df_full.lazy()
.filter(pl.col('tcgdp') > 1000)
.select(['country', 'year', 'tcgdp'])
.sort('tcgdp', descending=True)
)
print(lazy_query.explain())
SORT BY [descending: [true]] [col("tcgdp")]
FILTER col("tcgdp") > 1000.0
FROM
DF ["country", "country isocode", "year", "POP", ...]; PROJECT["country", "year", "tcgdp"] 3/8 COLUMNS
Appelez collect pour exécuter le plan
result_lazy = lazy_query.collect()
result_lazy.head()
| country | year | tcgdp |
|---|---|---|
| str | i64 | f64 |
| "United States" | 2000 | 9.8987e6 |
| "India" | 2000 | 1.7281e6 |
| "Australia" | 2000 | 541804.6521 |
| "Argentina" | 2000 | 295072.21869 |
| "South Africa" | 2000 | 227242.36949 |
19.4.2. Optimisation des requêtes#
Le moteur paresseux applique automatiquement plusieurs optimisations :
Descente des prédicats (predicate pushdown) — les filtres sont appliqués le plus tôt possible
Descente des projections (projection pushdown) — seules les colonnes nécessaires sont lues depuis la source
Élimination des sous-expressions communes — les calculs en double sont fusionnés
Voyons comment Polars réécrit une requête à plusieurs étapes
optimized = (df_full.lazy()
.select(['country', 'year', 'tcgdp', 'POP'])
.filter(pl.col('tcgdp') > 500)
.with_columns(
(pl.col('tcgdp') / pl.col('POP')).alias('gdp_per_capita')
)
.filter(pl.col('gdp_per_capita') > 10)
.select(['country', 'year', 'gdp_per_capita'])
)
print("Optimized plan:")
print(optimized.explain())
Optimized plan:
FILTER col("gdp_per_capita") > 10.0
FROM
simple π 3/3 ["country", "year", ... 1 other column]
WITH_COLUMNS:
[(col("tcgdp") / col("POP")).alias("gdp_per_capita")]
FILTER col("tcgdp") > 500.0
FROM
DF ["country", "country isocode", "year", "POP", ...]; PROJECT["country", "year", "tcgdp", "POP"] 4/8 COLUMNS
L’exécution du plan nous donne le résultat final
optimized.collect()
| country | year | gdp_per_capita |
|---|---|---|
| str | i64 | f64 |
| "Australia" | 2000 | 28.436433 |
| "Israel" | 2000 | 21.138673 |
| "United States" | 2000 | 35.080382 |
19.4.3. Comparaison des performances#
Comparons pandas, Polars en mode immédiat, et Polars en mode paresseux sur la même tâche.
Nous commençons avec un petit jeu de données (les Penn World Tables utilisées ci-dessus) pour montrer que pour de petites données, les différences sont négligeables
import pandas as pd
import time
# Petit jeu de données -- Penn World Tables (~8 lignes)
url = ('https://raw.githubusercontent.com/QuantEcon/'
'lecture-python-programming/main/lectures/_static/'
'lecture_specific/pandas/data/test_pwt.csv')
small_pd = pd.read_csv(url)
small_pl = pl.read_csv(url)
Maintenant, chronométrons la même opération de filtrage-sélection-tri dans chaque bibliothèque
# pandas
start = time.perf_counter()
_ = (small_pd
.query('tcgdp > 500')
[['country', 'year', 'tcgdp', 'POP']]
.assign(gdp_pc=lambda d: d['tcgdp'] / d['POP'])
.sort_values('gdp_pc', ascending=False))
pd_small = time.perf_counter() - start
# Polars immédiat
start = time.perf_counter()
_ = (small_pl
.filter(pl.col('tcgdp') > 500)
.select(['country', 'year', 'tcgdp', 'POP'])
.with_columns((pl.col('tcgdp') / pl.col('POP')).alias('gdp_pc'))
.sort('gdp_pc', descending=True))
pl_small = time.perf_counter() - start
print(f"Small data -- pandas: {pd_small:.4f}s | Polars eager: {pl_small:.4f}s")
Small data -- pandas: 0.0064s | Polars eager: 0.0010s
Sur quelques lignes, la différence de vitesse est négligeable — utilisez celle des API que vous trouvez la plus pratique.
Passons maintenant à 5 millions de lignes, où la différence devient évidente.
La tâche consiste à : filtrer les lignes où value > 0, calculer un produit
pondéré value * weight, puis calculer la moyenne de ce produit au sein de chaque groupe —
une moyenne pondérée groupée.
n = 5_000_000
np.random.seed(42)
groups = np.random.choice(['A', 'B', 'C', 'D'], n)
values = np.random.randn(n)
weights = np.random.rand(n)
extra1 = np.random.randn(n)
extra2 = np.random.randn(n)
big_pd = pd.DataFrame({
'group': groups, 'value': values,
'weight': weights, 'extra1': extra1, 'extra2': extra2
})
big_pl = pl.DataFrame({
'group': groups, 'value': values,
'weight': weights, 'extra1': extra1, 'extra2': extra2
})
D’abord, la référence pandas
start = time.perf_counter()
tmp = big_pd[big_pd['value'] > 0][['group', 'value', 'weight']].copy()
tmp['weighted'] = tmp['value'] * tmp['weight']
_ = tmp.groupby('group')['weighted'].mean()
pd_time = time.perf_counter() - start
print(f"pandas: {pd_time:.4f}s")
pandas: 0.1252s
Ensuite, Polars en mode immédiat
start = time.perf_counter()
_ = (big_pl
.filter(pl.col('value') > 0)
.select(['group', 'value', 'weight'])
.with_columns(
(pl.col('value') * pl.col('weight')).alias('weighted'))
.group_by('group')
.agg(pl.col('weighted').mean()))
eager_time = time.perf_counter() - start
print(f"Polars eager: {eager_time:.4f}s")
Polars eager: 0.0364s
Et enfin, Polars en mode paresseux
start = time.perf_counter()
_ = (big_pl.lazy()
.filter(pl.col('value') > 0)
.select(['group', 'value', 'weight'])
.with_columns(
(pl.col('value') * pl.col('weight')).alias('weighted'))
.group_by('group')
.agg(pl.col('weighted').mean())
.collect())
lazy_time = time.perf_counter() - start
print(f"Polars lazy: {lazy_time:.4f}s")
Polars lazy: 0.0308s
Ce qu’il faut retenir :
Pour de petits volumes de données (milliers de lignes), pandas et Polars se comportent de manière similaire — choisissez en fonction de vos préférences d’API et de l’écosystème.
Pour des volumes de données moyens à grands (centaines de milliers de lignes et plus), Polars peut être significativement plus rapide grâce à son moteur Rust, à l’exécution parallèle et (en mode paresseux) à l’optimisation des requêtes.
L’API paresseuse est particulièrement puissante lors de la lecture depuis le disque — scan_csv retourne directement un LazyFrame, de sorte que les filtres et projections sont poussés jusqu’au lecteur de fichier.
Astuce
Utilisez pl.scan_csv(path) plutôt que pl.read_csv(path) lorsque vous travaillez avec
de gros fichiers CSV.
Seules les colonnes et lignes réellement nécessaires seront lues depuis le disque.
Voir la documentation E/S de Polars.
19.5. Sources de données en ligne#
Comme dans Pandas, Python permet d’interroger facilement des bases de données en ligne.
Une base de données importante pour les économistes est FRED — une vaste collection de séries temporelles maintenue par la Fed de St. Louis.
La méthode read_csv de Polars peut récupérer des données directement depuis une URL.
Nous utilisons try_parse_dates=True pour analyser automatiquement la colonne de date
fred_url = ('https://fred.stlouisfed.org/graph/fredgraph.csv?'
'bgcolor=%23e1e9f0&chart_type=line&drp=0&'
'fo=open%20sans&graph_bgcolor=%23ffffff&'
'height=450&mode=fred&recession_bars=on&'
'txtcolor=%23444444&ts=12&tts=12&width=1318&'
'nt=0&thu=0&trc=0&show_legend=yes&'
'show_axis_titles=yes&show_tooltip=yes&'
'id=UNRATE&scale=left&cosd=1948-01-01&'
'coed=2024-06-01&line_color=%234572a7&'
'link_values=false&line_style=solid&'
'mark_type=none&mw=3&lw=2&ost=-99999&'
'oet=99999&mma=0&fml=a&fq=Monthly&fam=avg&'
'fgst=lin&fgsnd=2020-02-01&line_index=1&'
'transformation=lin&vintage_date=2024-07-29&'
'revision_date=2024-07-29&nd=1948-01-01')
data = pl.read_csv(fred_url, try_parse_dates=True)
Examinons les premières lignes
data.head()
| observation_date | UNRATE |
|---|---|
| date | f64 |
| 1948-01-01 | 3.4 |
| 1948-02-01 | 3.8 |
| 1948-03-01 | 4.0 |
| 1948-04-01 | 3.9 |
| 1948-05-01 | 3.5 |
Et obtenons des statistiques récapitulatives
data.describe()
| statistic | observation_date | UNRATE |
|---|---|---|
| str | str | f64 |
| "count" | "918" | 918.0 |
| "null_count" | "0" | 0.0 |
| "mean" | "1986-03-17 06:30:35.294117" | 5.693246 |
| "std" | null | 1.710248 |
| "min" | "1948-01-01" | 2.5 |
| "25%" | "1967-02-01" | 4.4 |
| "50%" | "1986-04-01" | 5.5 |
| "75%" | "2005-05-01" | 6.7 |
| "max" | "2024-06-01" | 14.8 |
Traçons le taux de chômage de 2006 à 2012
filtered = data.filter(
(pl.col('observation_date') >= pl.date(2006, 1, 1)) &
(pl.col('observation_date') <= pl.date(2012, 12, 31))
)
fig, ax = plt.subplots()
ax.plot(filtered['observation_date'].to_list(),
filtered['UNRATE'].to_list())
ax.set_title('US Unemployment Rate')
ax.set_xlabel('year', fontsize=12)
ax.set_ylabel('%', fontsize=12)
plt.show()
Polars prend en charge de nombreux formats de fichiers tels que Excel, JSON, Parquet, ainsi que des connexions directes à des bases de données.
19.6. Exercices#
Exercice 19.1
Avec ces importations :
import datetime as dt
import yfinance as yf
Écrivez un programme pour calculer la variation en pourcentage du prix sur l’année 2021 pour les actions suivantes :
ticker_list = {'INTC': 'Intel',
'MSFT': 'Microsoft',
'IBM': 'IBM',
'BHP': 'BHP',
'TM': 'Toyota',
'AAPL': 'Apple',
'AMZN': 'Amazon',
'C': 'Citigroup',
'QCOM': 'Qualcomm',
'KO': 'Coca-Cola',
'GOOG': 'Google'}
Voici une fonction qui lit les cours de clôture dans un DataFrame Polars :
def read_data_polars(ticker_list,
start=dt.datetime(2021, 1, 1),
end=dt.datetime(2021, 12, 31)):
"""
Read closing price data from Yahoo Finance
and return a Polars DataFrame.
"""
dataframes = []
for tick in ticker_list:
stock = yf.Ticker(tick)
prices = stock.history(start=start, end=end)
df = pl.DataFrame({
'Date': list(prices.index.date),
tick: prices['Close'].values
}).with_columns(pl.col('Date').cast(pl.Date))
dataframes.append(df)
result = dataframes[0]
for df in dataframes[1:]:
result = result.join(
df, on='Date', how='full', coalesce=True
)
return result.sort('Date')
ticker = read_data_polars(ticker_list)
Note
Les jointures Polars ne garantissent pas l’ordre des lignes en sortie — les clés
présentes uniquement d’un côté sont ajoutées plutôt qu’insérées à leur place.
Il s’agit du même thème « pas d’indice, pas d’alignement automatique » évoqué plus haut : en l’absence
d’étiquettes de ligne sur lesquelles s’aligner, l’ordre est quelque chose que l’on demande explicitement.
D’où le sort('Date') avant le retour, dont dépend tout calcul ultérieur
de type first()/last().
Complétez le programme pour tracer le résultat sous forme de graphique à barres.
Solution
Calculons les variations en pourcentage à l’aide d’expressions Polars :
price_change = ticker.select([
((pl.col(tick).last() / pl.col(tick).first() - 1) * 100)
.alias(tick)
for tick in ticker_list.keys()
]).transpose(
include_header=True,
header_name='ticker',
column_names=['pct_change']
).with_columns(
pl.col('ticker')
.replace_strict(ticker_list, default=pl.col('ticker'))
.alias('company')
).sort('pct_change')
print(price_change)
shape: (11, 3)
┌────────┬────────────┬───────────┐
│ ticker ┆ pct_change ┆ company │
│ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ str │
╞════════╪════════════╪═══════════╡
│ BHP ┆ -2.249094 ┆ BHP │
│ C ┆ 3.550561 ┆ Citigroup │
│ AMZN ┆ 5.845049 ┆ Amazon │
│ INTC ┆ 6.868538 ┆ Intel │
│ KO ┆ 14.922476 ┆ Coca-Cola │
│ … ┆ … ┆ … │
│ TM ┆ 23.416767 ┆ Toyota │
│ QCOM ┆ 25.318529 ┆ Qualcomm │
│ AAPL ┆ 38.550766 ┆ Apple │
│ MSFT ┆ 57.179654 ┆ Microsoft │
│ GOOG ┆ 68.960885 ┆ Google │
└────────┴────────────┴───────────┘
Traçons les résultats directement avec matplotlib :
companies = price_change['company'].to_list()
changes = price_change['pct_change'].to_list()
colors = ['red' if x < 0 else 'blue' for x in changes]
fig, ax = plt.subplots(figsize=(10, 8))
ax.bar(companies, changes, color=colors)
ax.set_xlabel('stock', fontsize=12)
ax.set_ylabel('percentage change in price', fontsize=12)
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
Exercice 19.2
En utilisant read_data_polars de Exercice 19.1, obtenez la variation en pourcentage d’une année sur l’autre pour les indices suivants :
indices_list = {'^GSPC': 'S&P 500',
'^IXIC': 'NASDAQ',
'^DJI': 'Dow Jones',
'^N225': 'Nikkei'}
Tracez le résultat sous forme de graphique en série temporelle.
Solution
indices_data = read_data_polars(
indices_list,
start=dt.datetime(1971, 1, 1),
end=dt.datetime(2021, 12, 31)
)
indices_data = indices_data.with_columns(
pl.col('Date').dt.year().alias('year')
)
Calculons les rendements annuels à l’aide d’opérations de regroupement :
yearly_returns = indices_data.group_by('year').agg([
*[pl.col(idx).drop_nulls().first().alias(f'{idx}_first')
for idx in indices_list],
*[pl.col(idx).drop_nulls().last().alias(f'{idx}_last')
for idx in indices_list]
])
for idx, name in indices_list.items():
yearly_returns = yearly_returns.with_columns(
((pl.col(f'{idx}_last') - pl.col(f'{idx}_first'))
/ pl.col(f'{idx}_first') * 100).alias(name)
)
yearly_returns = (yearly_returns
.select(['year', *indices_list.values()])
.sort('year')
)
print(yearly_returns)
shape: (51, 5)
┌──────┬────────────┬────────────┬───────────┬────────────┐
│ year ┆ S&P 500 ┆ NASDAQ ┆ Dow Jones ┆ Nikkei │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ i32 ┆ f64 ┆ f64 ┆ f64 ┆ f64 │
╞══════╪════════════╪════════════╪═══════════╪════════════╡
│ 1971 ┆ 12.002188 ┆ 14.120003 ┆ null ┆ 36.407234 │
│ 1972 ┆ 16.110952 ┆ 17.668274 ┆ null ┆ 92.011231 │
│ 1973 ┆ -18.094035 ┆ -31.523435 ┆ null ┆ -17.697016 │
│ 1974 ┆ -29.811633 ┆ -35.350697 ┆ null ┆ -9.914309 │
│ 1975 ┆ 28.4209 ┆ 27.874797 ┆ null ┆ 16.798024 │
│ … ┆ … ┆ … ┆ … ┆ … │
│ 2017 ┆ 18.415027 ┆ 27.155799 ┆ 24.331151 ┆ 16.182267 │
│ 2018 ┆ -7.009394 ┆ -5.303631 ┆ -6.028635 ┆ -14.853703 │
│ 2019 ┆ 28.714796 ┆ 34.603667 ┆ 22.23998 ┆ 20.931737 │
│ 2020 ┆ 15.292907 ┆ 41.751104 ┆ 6.019231 ┆ 18.269064 │
│ 2021 ┆ 29.132182 ┆ 23.964416 ┆ 20.428169 ┆ 5.625169 │
└──────┴────────────┴────────────┴───────────┴────────────┘
Statistiques récapitulatives :
yearly_returns.select(list(indices_list.values())).describe()
| statistic | S&P 500 | NASDAQ | Dow Jones | Nikkei |
|---|---|---|---|---|
| str | f64 | f64 | f64 | f64 |
| "count" | 51.0 | 51.0 | 30.0 | 51.0 |
| "null_count" | 0.0 | 0.0 | 21.0 | 0.0 |
| "mean" | 9.20986 | 13.094786 | 9.10453 | 7.850346 |
| "std" | 16.398012 | 24.616625 | 14.134825 | 24.384181 |
| "min" | -37.58465 | -40.197764 | -32.716831 | -39.695649 |
| "25%" | 0.255632 | 2.470561 | 2.072082 | -6.094919 |
| "50%" | 11.677594 | 14.312575 | 9.387316 | 7.667034 |
| "75%" | 19.671602 | 27.874797 | 21.451016 | 20.931737 |
| "max" | 34.157394 | 84.294285 | 33.311106 | 92.011231 |
Traçons chaque indice dans un sous-graphique :
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
years = yearly_returns['year'].to_list()
for iter_, ax in enumerate(axes.flatten()):
name = list(indices_list.values())[iter_]
values = yearly_returns[name].to_list()
ax.plot(years, values, 'o-', linewidth=2, markersize=4)
ax.axhline(y=0, color='k', linestyle='--', alpha=0.3)
ax.set_ylabel('yearly return (%)', fontsize=12)
ax.set_xlabel('year', fontsize=12)
ax.set_title(name, fontsize=12)
plt.tight_layout()
plt.show()