---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# Performances : Numba, Dask, Polars

```{code-cell} python
:tags: [hide-input]

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
```

La data science en Python repose sur un écosystème de bibliothèques remarquablement efficaces, mais il arrive que l'on se retrouve face à des goulots d'étranglement qui rendent les traitements péniblement lents. Dès que l'on travaille sur des jeux de données de plusieurs gigaoctets, que l'on enchaîne des boucles Python pures sur des millions d'éléments ou que l'on souhaite exploiter plusieurs cœurs en parallèle, les approches classiques montrent leurs limites. Ce chapitre présente un ensemble d'outils complémentaires — **Numba**, **Dask** et **Polars** — qui permettent de repousser ces limites, chacun selon une stratégie différente. Mais avant de chercher à accélérer un code, encore faut-il savoir *où* il perd du temps.

## Identifier les goulots d'étranglement

Optimiser sans mesurer est l'une des erreurs les plus courantes en développement logiciel. On croit souvent savoir quelle partie du code est lente, mais l'intuition se trompe régulièrement. Les outils de profilage permettent de quantifier précisément où le temps est passé, afin de concentrer les efforts d'optimisation là où ils auront le plus d'impact.

### `timeit` : mesurer la durée d'une expression

L'outil le plus simple est `timeit`, qui exécute une expression un grand nombre de fois et en mesure la durée moyenne. En mode interactif (Jupyter ou IPython), on utilise les commandes magiques `%timeit` (une ligne) et `%%timeit` (une cellule entière).

```{code-cell} python
import numpy as np

a = np.random.rand(1_000_000)

# Mesurer la somme NumPy vs Python pur
%timeit np.sum(a)          # quelques microsecondes
%timeit sum(a)             # plusieurs centaines de millisecondes
```

Le facteur entre les deux peut dépasser 100×. `timeit` est parfait pour comparer des implémentations alternatives d'une même opération.

### `cProfile` : profiler une fonction entière

Pour identifier quelle *fonction* dans un programme consomme le plus de temps, `cProfile` est l'outil standard de Python. Il instrumente chaque appel de fonction et produit un rapport détaillé.

```{code-cell} python
import cProfile
import pstats

def traitement_lourd(n):
    total = 0
    for i in range(n):
        total += i ** 2
    return total

profiler = cProfile.Profile()
profiler.enable()
traitement_lourd(1_000_000)
profiler.disable()

stats = pstats.Stats(profiler)
stats.sort_stats("cumulative")
stats.print_stats(10)  # Affiche les 10 fonctions les plus lentes
```

Le rapport indique pour chaque fonction : le nombre d'appels, le temps total passé dans la fonction elle-même (*tottime*) et le temps cumulé incluant les fonctions appelées (*cumtime*). C'est ce dernier qui permet de remonter la chaîne d'appels jusqu'au vrai coupable.

### `line_profiler` : profiler ligne par ligne

Quand `cProfile` a identifié une fonction suspecte, `line_profiler` permet de descendre au niveau de chaque ligne pour voir exactement où le temps est dépensé.

```python
# Installation : pip install line-profiler
# En Jupyter : %load_ext line_profiler

def calcul_intensif(data):
    resultats = []
    for x in data:
        y = x ** 2 + 2 * x + 1   # Ligne coûteuse ?
        resultats.append(y)
    return resultats

data = list(range(100_000))
%lprun -f calcul_intensif calcul_intensif(data)
```

La sortie montre, pour chaque ligne, le pourcentage du temps total qu'elle représente. C'est souvent révélateur : une boucle avec un `append` peut être remplacée par une compréhension de liste ou une opération NumPy, avec un gain de 10× ou plus.

### `memory_profiler` : surveiller la consommation mémoire

Les problèmes de performance ne sont pas toujours liés au temps de calcul. Un processus qui consomme trop de mémoire peut ralentir toute la machine en forçant l'utilisation du disque comme mémoire virtuelle (*swap*). `memory_profiler` mesure la consommation mémoire ligne par ligne.

```python
# Installation : pip install memory-profiler
# En Jupyter : %load_ext memory_profiler

@profile
def creer_grande_liste():
    a = [i ** 2 for i in range(1_000_000)]   # ~8 Mo
    b = [x * 2 for x in a]                    # ~8 Mo supplémentaires
    return b

%mprun -f creer_grande_liste creer_grande_liste()
```

```{note}
La règle d'or de l'optimisation est souvent résumée ainsi : « *Measure, don't guess* ». Il faut toujours profiler avant d'optimiser, puis mesurer à nouveau après pour s'assurer que la modification a bien produit l'effet escompté. Une optimisation non mesurée peut parfois dégrader les performances dans certains cas particuliers tout en les améliorant dans le cas testé.
```

## Numba : compilation JIT pour Python

**Numba** est une bibliothèque qui compile du code Python en code machine natif à la volée (*Just-In-Time compilation*, ou JIT). Elle est particulièrement efficace pour les boucles numériques pures que NumPy ne peut pas vectoriser directement. Au lieu de réécrire le code en C ou en Fortran, on ajoute simplement un décorateur Python.

```{admonition} Compilation JIT
:class: tip
La **compilation JIT** (*Just-In-Time*) est une technique où le code source n'est pas compilé à l'avance (comme en C ou en Rust), mais lors de la première exécution. Le compilateur analyse les types réels des arguments et génère du code machine optimisé pour ces types. Les exécutions suivantes utilisent ce code compilé directement, sans passer par l'interpréteur Python. Le premier appel est plus lent (coût de compilation), mais tous les suivants sont très rapides.
```

### `@jit` et `@njit` : accélérer des boucles

Le décorateur le plus simple est `@jit`. En ajoutant `nopython=True` (ou en utilisant l'alias `@njit`), on indique à Numba de refuser silencieusement de recourir à l'interpréteur Python si une partie du code ne peut pas être compilée — ce qui garantit les meilleures performances.

```{code-cell} python
from numba import jit, njit
import numpy as np

# Version Python pure
def somme_carres_python(n):
    total = 0.0
    for i in range(n):
        total += i * i
    return total

# Version Numba compilée JIT
@njit
def somme_carres_numba(n):
    total = 0.0
    for i in range(n):
        total += i * i
    return total

# Premier appel : déclenche la compilation (plus lent)
_ = somme_carres_numba(1000)

# Comparaison (après compilation)
# %timeit somme_carres_python(10_000_000)  # ~ 1 seconde
# %timeit somme_carres_numba(10_000_000)   # ~ 5 millisecondes
```

L'accélération typique avec `@njit` sur des boucles numériques est de l'ordre de **50× à 200×** par rapport à Python pur.

### `@vectorize` : créer des ufuncs NumPy

Numba permet également de créer des *ufuncs* (universal functions) NumPy personnalisées avec le décorateur `@vectorize`. Une ufunc s'applique élément par élément sur des tableaux NumPy de toute dimension, comme `np.sin` ou `np.exp`.

```{code-cell} python
from numba import vectorize, float64

@vectorize([float64(float64, float64)])
def activation_relu(x, seuil):
    """ReLU avec seuil personnalisé."""
    return x if x > seuil else 0.0

import numpy as np
data = np.random.randn(1_000_000)
resultat = activation_relu(data, 0.0)  # Appliqué sur tout le tableau, en C
```

### Cas d'usage appropriés

Numba excelle dans des situations précises : simulations numériques avec de nombreuses itérations, calculs sur des tableaux NumPy avec des conditions complexes, algorithmes de physique ou de finance quantitative. En revanche, Numba n'accélère pas le code qui manipule des objets Python arbitraires (dictionnaires, listes hétérogènes), ni les opérations sur les DataFrames Pandas.

```{admonition} Simulation de Monte-Carlo avec Numba
:class: note
La simulation de Monte-Carlo pour estimer π illustre parfaitement le cas d'usage de Numba : des millions d'itérations indépendantes avec des opérations arithmétiques simples.

```{code-cell} python
@njit
def estimer_pi(n_echantillons):
    compteur = 0
    for _ in range(n_echantillons):
        x = np.random.random()
        y = np.random.random()
        if x * x + y * y <= 1.0:
            compteur += 1
    return 4.0 * compteur / n_echantillons

# Premier appel (compilation)
pi_approx = estimer_pi(10_000_000)
print(f"π ≈ {pi_approx:.6f}")
```

## Dask : calcul parallèle sur de grandes données

**Dask** est une bibliothèque de calcul parallèle et distribué conçue pour traiter des données qui ne tiennent pas entièrement en mémoire vive. Elle offre des interfaces familières — `dask.array` imite NumPy, `dask.dataframe` imite Pandas — mais exécute les calculs en parallèle sur plusieurs cœurs ou plusieurs machines.

```{admonition} Graphe de tâches (DAG)
:class: tip
Dask représente les calculs sous forme d'un **graphe acyclique dirigé** (*Directed Acyclic Graph*, DAG) de tâches. Chaque opération (addition, filtrage, agrégation) crée un nœud dans ce graphe, sans l'exécuter immédiatement. Ce n'est qu'à l'appel de `.compute()` que Dask analyse le graphe, optimise les dépendances entre tâches et les exécute en parallèle. Cette approche *lazy* permet d'éviter les calculs inutiles et de minimiser les transferts de données.
```

### `dask.array` : NumPy sur de grands tableaux

`dask.array` découpe les tableaux en morceaux (*chunks*) et applique les opérations NumPy sur chaque chunk indépendamment.

```python
import dask.array as da

# Créer un tableau Dask de 10 Go (divisé en chunks de 100 Mo)
x = da.random.random((100_000, 100_000), chunks=(1_000, 1_000))

# Les opérations sont paresseuses — rien n'est calculé encore
y = x + x.T
z = y.mean(axis=0)

# Le calcul effectif se lance ici
resultat = z.compute()
```

### `dask.dataframe` : Pandas sur des fichiers trop grands

`dask.dataframe` est l'interface la plus utilisée en pratique. Elle permet de lire et de traiter des dizaines de fichiers CSV ou Parquet comme s'ils formaient un seul DataFrame Pandas.

```python
import dask.dataframe as dd

# Lire tous les fichiers CSV d'un répertoire d'un coup
ddf = dd.read_csv("donnees/ventes_*.csv")

# API similaire à Pandas
resultat = (
    ddf
    .groupby("region")
    ["chiffre_affaires"]
    .sum()
    .compute()
)
```

### Le scheduler et `dask.distributed`

Par défaut, Dask utilise un scheduler threadé pour les tableaux et un scheduler multi-processus pour les DataFrames. Pour les workloads plus complexes ou pour distribuer les calculs sur un cluster, on utilise `dask.distributed`.

```python
from dask.distributed import Client

# Démarrer un cluster local sur tous les cœurs disponibles
client = Client()
print(client)  # Affiche le tableau de bord : http://localhost:8787

# Toutes les opérations Dask suivantes utilisent maintenant ce client
import dask.dataframe as dd
ddf = dd.read_parquet("donnees_enormes/")
resultat = ddf.groupby("categorie").agg({"prix": "mean"}).compute()

client.close()
```

Le tableau de bord Dask (accessible via le navigateur) offre une visualisation en temps réel de l'avancement des tâches, de l'utilisation mémoire et des transferts de données.

```{note}
Dask n'est pas toujours plus rapide que Pandas sur des données qui tiennent en mémoire. La parallélisation introduit un surcoût de coordination qui peut rendre Dask *plus lent* sur de petits jeux de données. Sa valeur ajoutée apparaît clairement lorsque les données dépassent la RAM disponible, ou lorsque l'on peut exploiter de nombreux cœurs CPU sur des opérations véritablement parallélisables.
```

## Polars : DataFrames ultra-rapides en Rust

**Polars** est une bibliothèque de DataFrames écrite entièrement en **Rust**, conçue dès le départ pour la performance et le parallélisme. Contrairement à Dask qui parallélise Pandas, Polars réécrit l'ensemble de la logique de traitement avec une architecture optimisée : colonnes stockées en mémoire contiguë, exécution parallèle sur tous les cœurs, API *lazy* avec optimiseur de requêtes.

```{admonition} Évaluation paresseuse (Lazy API)
:class: tip
L'**API lazy** de Polars permet de construire un plan de requête sans l'exécuter immédiatement. Lorsqu'on appelle `.collect()`, Polars analyse le plan complet, applique des optimisations (élimination des colonnes inutiles, fusion des filtres, réorganisation des opérations) puis exécute le calcul en parallèle sur tous les cœurs disponibles. Cette approche est similaire à un optimiseur SQL.
```

### API de base : Eager vs Lazy

```{code-cell} python
import polars as pl
import numpy as np
import tempfile, os

# Génération d'un jeu de données de démonstration
rng = np.random.default_rng(42)
n = 10_000
_df_tmp = pl.DataFrame({
    "region":  rng.choice(["Nord", "Sud", "Est", "Ouest"], n).tolist(),
    "produit": rng.choice(["A", "B", "C", "D", "E"], n).tolist(),
    "montant": rng.uniform(10, 500, n).tolist(),
})
_parquet_path = os.path.join(tempfile.mkdtemp(), "ventes.parquet")
_df_tmp.write_parquet(_parquet_path)

# API eager (immédiate) — similaire à Pandas
df = pl.read_parquet(_parquet_path)
resultat_eager = (
    df
    .filter(pl.col("region") == "Nord")
    .group_by("produit")
    .agg(pl.col("montant").sum().alias("total"))
    .sort("total", descending=True)
)
print("Eager :")
print(resultat_eager)

# API lazy (recommandée pour les grands jeux de données)
resultat_lazy = (
    pl.scan_parquet(_parquet_path)             # Lecture paresseuse
    .filter(pl.col("region") == "Nord")
    .group_by("produit")
    .agg(pl.col("montant").sum().alias("total"))
    .sort("total", descending=True)
    .collect()                                  # Exécution effective
)
print("\nLazy :")
print(resultat_lazy)
```

### Comparaison avec Pandas et migration progressive

L'API de Polars diffère de celle de Pandas, mais les concepts sont proches. La migration peut se faire progressivement : on peut convertir des DataFrames entre les deux bibliothèques.

```{code-cell} python
import pandas as pd
import polars as pl

# Pandas vers Polars
df_pandas = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
df_polars = pl.from_pandas(df_pandas)

# Polars vers Pandas
df_pandas_retour = df_polars.to_pandas()
```

Les différences syntaxiques clés à connaître :

| Opération | Pandas | Polars |
|---|---|---|
| Sélectionner une colonne | `df["col"]` | `df["col"]` ou `df.select("col")` |
| Filtrer | `df[df["a"] > 0]` | `df.filter(pl.col("a") > 0)` |
| Créer une colonne | `df["c"] = df["a"] + df["b"]` | `df.with_columns((pl.col("a") + pl.col("b")).alias("c"))` |
| Grouper | `df.groupby("k").agg(...)` | `df.group_by("k").agg(...)` |

## Visualisation comparative : benchmark

```{code-cell} python
:tags: [hide-input]

import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

# Données de benchmark simulées (temps en secondes, plus bas = mieux)
# Source : benchmarks publics polars.rs et dask documentation
operations = [
    "Lecture CSV\n1 Go",
    "GroupBy\nagrégation",
    "Jointure\n2 tables",
    "Filtrage\n+ tri",
    "Pivot\nlarge",
]

temps_pandas = [12.4, 8.7, 15.2, 3.1, 22.6]
temps_polars = [2.1,  1.3,  2.8,  0.4,  3.9]
temps_dask   = [4.8,  3.2,  6.1,  1.2,  8.7]   # Dask sur 4 cœurs

x = np.arange(len(operations))
largeur = 0.25

fig, ax = plt.subplots(figsize=(13, 6))

barres_pandas = ax.bar(x - largeur, temps_pandas, largeur,
                       label="Pandas", color="#4878d0", alpha=0.85)
barres_polars = ax.bar(x,           temps_polars, largeur,
                       label="Polars", color="#ee854a", alpha=0.85)
barres_dask   = ax.bar(x + largeur, temps_dask,   largeur,
                       label="Dask (4 cœurs)", color="#6acc65", alpha=0.85)

# Étiquettes de valeurs
for barres in [barres_pandas, barres_polars, barres_dask]:
    for barre in barres:
        hauteur = barre.get_height()
        ax.annotate(
            f"{hauteur:.1f}s",
            xy=(barre.get_x() + barre.get_width() / 2, hauteur),
            xytext=(0, 4),
            textcoords="offset points",
            ha="center", va="bottom",
            fontsize=8.5, fontweight="bold",
        )

ax.set_xlabel("Opération", fontsize=12)
ax.set_ylabel("Temps d'exécution (secondes)", fontsize=12)
ax.set_title(
    "Benchmark : Pandas vs Polars vs Dask\n"
    "(jeu de données synthétique — temps plus bas = plus rapide)",
    fontsize=13, fontweight="bold",
)
ax.set_xticks(x)
ax.set_xticklabels(operations, fontsize=10)
ax.legend(fontsize=11)
ax.set_ylim(0, 28)

# Annotation facteur d'accélération Polars vs Pandas
for i, (tp, tpo) in enumerate(zip(temps_pandas, temps_polars)):
    facteur = tp / tpo
    ax.text(
        x[i], max(tp, tpo) + 2.5,
        f"×{facteur:.0f}",
        ha="center", va="bottom",
        fontsize=9, color="#c0392b", fontweight="bold",
    )

ax.text(
    0.98, 0.97,
    "×N = facteur d'accélération\nPolars vs Pandas",
    transform=ax.transAxes,
    ha="right", va="top",
    fontsize=9, color="#c0392b",
    bbox=dict(boxstyle="round,pad=0.3", facecolor="white",
              edgecolor="#c0392b", alpha=0.8),
)

plt.tight_layout()
plt.show()
```

## Quand utiliser quoi ?

Le choix de l'outil dépend de plusieurs facteurs : la taille des données, le type de calcul, les ressources disponibles et la complexité acceptable du code. Le tableau suivant offre une aide à la décision.

```{note}
Ces catégories ne sont pas mutuellement exclusives. Dans un pipeline de production, il est courant de combiner les outils : lire les données avec Polars (rapide et expressif), utiliser Numba pour un algorithme sur-mesure qui n'existe pas dans les bibliothèques standard, et passer à Dask uniquement lorsque les données dépassent la mémoire disponible.
```

| Situation | Outil recommandé | Raison |
|---|---|---|
| Données < 1 Go, opérations standard | **Pandas** | Simple, bien documenté, large écosystème |
| Données < 50 Go, opérations standard | **Polars** | 5-20× plus rapide que Pandas |
| Boucles Python intensives sur tableaux NumPy | **Numba** | Compilation JIT, accélération 50-200× |
| Données > RAM disponible | **Dask** | Traitement par chunks, parallélisme |
| Cluster de machines | **Dask Distributed** | Distribution sur plusieurs nœuds |
| Algorithmes personnalisés vectorisés | **Numba `@vectorize`** | ufuncs compilées en C |
| SQL analytique sur Parquet/CSV | **DuckDB** | Voir chapitre 19 |

## Résumé

Ce chapitre a présenté les principaux outils pour améliorer les performances des traitements Python en data science :

- **Profiler avant d'optimiser** : `timeit` pour les micro-benchmarks, `cProfile` pour identifier les fonctions lentes, `line_profiler` pour descendre au niveau de la ligne, `memory_profiler` pour surveiller la mémoire.
- **Numba** compile du code Python en code machine natif via la compilation JIT. Les décorateurs `@njit` et `@vectorize` permettent d'accélérer des boucles numériques de 50 à 200× sans quitter Python.
- **Dask** parallélise les calculs sur des données trop grandes pour la mémoire en découpant le travail en chunks et en les traitant sur plusieurs cœurs ou machines. Son API imite NumPy et Pandas.
- **Polars** est un moteur de DataFrames écrit en Rust qui surpasse Pandas de 5 à 20× sur les opérations courantes, grâce à son architecture parallèle et à son optimiseur de requêtes (API lazy).
- Le **choix de l'outil** dépend de la taille des données et du type de calcul : Polars pour le traitement tabulaire rapide, Numba pour les algorithmes numériques sur-mesure, Dask pour les données qui ne tiennent pas en mémoire.

Dans le chapitre suivant, nous nous intéressons aux **formats de données** : comment stocker et lire efficacement de grands volumes d'informations avec Parquet, HDF5, Zarr et DuckDB.
