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

# Pandas — agrégation, groupby et jointures

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

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

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

L'une des forces majeures de Pandas est sa capacité à agréger, résumer et combiner des données de façon expressive et efficace. Que l'on cherche à calculer des statistiques par groupe, à fusionner plusieurs sources de données ou à calculer des moyennes mobiles sur une série temporelle, Pandas offre des opérateurs de haut niveau qui évitent d'écrire des boucles explicites. Ce chapitre couvre ces opérations en profondeur, des fondamentaux du patron *split-apply-combine* jusqu'aux techniques avancées de performance.

La maîtrise de `groupby` et de `merge` est indispensable dans tout projet de data science : la grande majorité des analyses nécessitent de regrouper des observations par catégorie et de combiner des tables provenant de différentes sources. Ces opérations correspondent directement à ce que l'on ferait en SQL avec `GROUP BY`, `JOIN` et les fonctions de fenêtre.

## `groupby` — le patron split-apply-combine

Le patron **split-apply-combine** (*diviser-appliquer-combiner*) est le modèle mental qui sous-tend toutes les opérations `groupby` :

1. **Split** : diviser le DataFrame en sous-groupes selon une ou plusieurs clés.
2. **Apply** : appliquer une fonction à chaque sous-groupe indépendamment.
3. **Combine** : rassembler les résultats en un seul objet.

```{admonition} GroupBy
:class: tip
Un objet **GroupBy** est un intermédiaire paresseux (*lazy*) créé par `df.groupby(clé)`. Il ne réalise aucun calcul immédiatement : il stocke uniquement la partition du DataFrame en groupes. Le calcul n'est déclenché que lorsqu'on appelle une méthode d'agrégation (`sum`, `mean`, `agg`, etc.), de transformation (`transform`) ou de filtrage (`filter`).
```

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

np.random.seed(42)
df = pd.DataFrame({
    'region':  ['Nord', 'Sud', 'Est', 'Nord', 'Sud', 'Est', 'Nord', 'Sud'],
    'produit': ['A', 'A', 'B', 'B', 'A', 'A', 'B', 'B'],
    'ventes':  [120, 95, 145, 110, 130, 88, 160, 102],
    'coûts':   [60, 45, 70, 55, 65, 42, 80, 50],
})

# Agrégation simple
print("--- Ventes totales par région ---")
print(df.groupby('region')['ventes'].sum())
print()

# Agrégation multiple avec agg
print("--- Statistiques par région ---")
print(df.groupby('region')['ventes'].agg(['sum', 'mean', 'count', 'std']))
```

### `agg` avec un dictionnaire — agrégations différentes par colonne

```{code-cell} python
resultat = df.groupby('region').agg(
    ventes_total=('ventes', 'sum'),
    ventes_moy=('ventes', 'mean'),
    coûts_total=('coûts', 'sum'),
    nb_transactions=('ventes', 'count'),
)
print(resultat)
```

### Agrégation par plusieurs clés

```{code-cell} python
print(df.groupby(['region', 'produit']).agg(
    ventes_sum=('ventes', 'sum'),
    coûts_sum=('coûts', 'sum'),
).assign(marge=lambda x: x['ventes_sum'] - x['coûts_sum']))
```

### `filter` — filtrer des groupes entiers

`filter` conserve ou supprime des groupes entiers en fonction d'une condition portant sur le groupe :

```{code-cell} python
# Conserver uniquement les régions dont la vente totale dépasse 300
df_filtre = df.groupby('region').filter(lambda x: x['ventes'].sum() > 300)
print(df_filtre)
```

### `apply` — opérations arbitraires par groupe

`apply` est le couteau suisse des opérations par groupe. La fonction passée reçoit le sous-DataFrame complet du groupe et peut retourner n'importe quoi (scalaire, série, DataFrame) :

```{code-cell} python
def top_produit(groupe):
    """Retourne le produit le plus vendu dans le groupe."""
    return groupe.loc[groupe['ventes'].idxmax(), 'produit']

print(df.groupby('region').apply(top_produit, include_groups=False))
```

## Fonctions d'agrégation

Pandas propose un large éventail de fonctions d'agrégation intégrées, optimisées en Cython pour être nettement plus rapides que leurs équivalents Python purs.

```{code-cell} python
s = pd.Series([10, 20, 30, 20, 10, 40, np.nan])

# Fonctions d'agrégation courantes
print(f"sum       : {s.sum()}")
print(f"mean      : {s.mean():.2f}")
print(f"median    : {s.median()}")
print(f"std       : {s.std():.4f}")
print(f"var       : {s.var():.4f}")
print(f"count     : {s.count()}")          # exclut NaN
print(f"nunique   : {s.nunique()}")        # valeurs distinctes
print(f"first     : {s.first_valid_index()}")
print(f"min/max   : {s.min()} / {s.max()}")
print(f"quantile  : {s.quantile(0.25)}")
```

### Agrégations personnalisées

On peut passer des fonctions personnalisées à `agg`. Pour de meilleures performances, préférer les fonctions NumPy ou Numba plutôt que les fonctions Python pures :

```{code-cell} python
def intervalle_confiance_95(x):
    """Retourne l'intervalle de confiance à 95 % (format chaîne)."""
    n = len(x)
    moy = x.mean()
    ec = x.std() / np.sqrt(n)
    return f"[{moy - 1.96*ec:.1f}, {moy + 1.96*ec:.1f}]"

print(df.groupby('region')['ventes'].agg(['mean', intervalle_confiance_95]))
```

## Jointures

La combinaison de plusieurs DataFrames selon des clés communes est une opération omniprésente. Pandas implémente les quatre types de jointures SQL via `merge()`.

```{admonition} Types de jointures
:class: tip
- **Inner join** (`how='inner'`) : ne conserve que les lignes dont la clé est présente dans les deux tables — intersection.
- **Left join** (`how='left'`) : conserve toutes les lignes de la table gauche, et complète avec des `NaN` si la clé est absente de la table droite.
- **Right join** (`how='right'`) : conserve toutes les lignes de la table droite, symétrique du left join.
- **Outer join** (`how='outer'`) : conserve toutes les lignes des deux tables — union.
```

```{code-cell} python
clients = pd.DataFrame({
    'client_id': [1, 2, 3, 4],
    'nom': ['Alice', 'Bob', 'Charlie', 'Diana'],
    'ville': ['Paris', 'Lyon', 'Nantes', 'Bordeaux'],
})

commandes = pd.DataFrame({
    'commande_id': [101, 102, 103, 104, 105],
    'client_id': [1, 2, 1, 3, 5],   # client_id=5 n'existe pas dans clients
    'montant': [250, 180, 320, 95, 410],
})

# Inner join
inner = pd.merge(clients, commandes, on='client_id', how='inner')
print("--- Inner join ---")
print(inner)

# Left join (tous les clients, même sans commande)
left = pd.merge(clients, commandes, on='client_id', how='left')
print("\n--- Left join ---")
print(left)

# Outer join
outer = pd.merge(clients, commandes, on='client_id', how='outer',
                 indicator=True)
print("\n--- Outer join avec indicateur ---")
print(outer[['nom', 'commande_id', 'montant', '_merge']])
```

### Jointures avec clés multiples et suffixes

```{code-cell} python
stocks_2023 = pd.DataFrame({
    'produit': ['A', 'B', 'C'],
    'region': ['Nord', 'Nord', 'Sud'],
    'stock': [500, 300, 400],
})

stocks_2024 = pd.DataFrame({
    'produit': ['A', 'B', 'C'],
    'region': ['Nord', 'Nord', 'Sud'],
    'stock': [520, 280, 430],
})

comparaison = pd.merge(
    stocks_2023, stocks_2024,
    on=['produit', 'region'],
    suffixes=('_2023', '_2024')
)
comparaison['évolution_%'] = (
    (comparaison['stock_2024'] - comparaison['stock_2023'])
    / comparaison['stock_2023'] * 100
).round(1)
print(comparaison)
```

## Concaténation

`pd.concat()` assemble plusieurs DataFrames ou Series en les empilant verticalement (`axis=0`) ou horizontalement (`axis=1`).

```{code-cell} python
df_q1 = pd.DataFrame({'région': ['Nord', 'Sud'], 'ventes': [100, 90]})
df_q2 = pd.DataFrame({'région': ['Nord', 'Sud'], 'ventes': [115, 105]})
df_q3 = pd.DataFrame({'région': ['Nord', 'Sud'], 'ventes': [125, 98]})

# Concaténation verticale avec clés d'identification
annuel = pd.concat(
    [df_q1, df_q2, df_q3],
    keys=['T1', 'T2', 'T3'],
    names=['trimestre', 'id']
)
print(annuel)
```

```{note}
Par défaut, `pd.concat()` avec `axis=0` réinitialise l'index si `ignore_index=True` est passé. Sans ce paramètre, les index originaux sont conservés, ce qui peut créer des doublons. Pour `axis=1`, les DataFrames sont alignés par leur index : si les index ne correspondent pas, des `NaN` sont introduits aux positions manquantes. Il vaut mieux s'assurer de l'alignement des index avant une concaténation horizontale.
```

## Fenêtres glissantes

Les fenêtres glissantes permettent de calculer des statistiques sur une fenêtre mobile autour de chaque point d'une série temporelle. Elles sont particulièrement utiles pour lisser les données ou calculer des tendances.

```{code-cell} python
np.random.seed(0)
idx = pd.date_range('2024-01-01', periods=60, freq='D')
prix = pd.Series(100 + np.cumsum(np.random.randn(60) * 2), index=idx)

# Moyenne mobile simple sur 7 jours
ma7 = prix.rolling(window=7).mean()

# Moyenne mobile exponentielle (plus de poids aux valeurs récentes)
ema7 = prix.ewm(span=7, adjust=False).mean()

# Fenêtre croissante (expanding) : statistique depuis le début
cummax = prix.expanding().max()

print("Rolling 7j (premières valeurs) :", ma7.head(10).round(2).tolist())
print("EWM 7j   (premières valeurs) :", ema7.head(10).round(2).tolist())
```

```{admonition} Moyenne mobile exponentielle (EWM)
:class: tip
La **moyenne mobile exponentielle** (`ewm`) attribue des poids décroissants exponentiellement aux observations passées. Le paramètre `span` correspond approximativement au nombre de périodes considéré. Elle réagit plus vite aux changements récents qu'une moyenne mobile simple (`rolling`), qui pondère uniformément toutes les observations de la fenêtre.
```

## Performance Pandas

Pandas propose plusieurs mécanismes pour améliorer les performances sur des jeux de données volumineux.

### `eval()` et `query()`

`eval()` et `query()` utilisent le moteur `numexpr` pour évaluer des expressions arithmétiques ou des filtres sur des colonnes sans créer de tableaux intermédiaires, réduisant ainsi la consommation mémoire :

```{code-cell} python
n = 100_000
df_perf = pd.DataFrame({
    'a': np.random.randn(n),
    'b': np.random.randn(n),
    'c': np.random.randn(n),
    'cat': np.random.choice(['X', 'Y', 'Z'], n),
})

# Approche classique (crée des tableaux intermédiaires)
# result = df_perf[(df_perf['a'] + df_perf['b']) > df_perf['c']]

# Avec query() : plus lisible, moins de mémoire
result = df_perf.query('(a + b) > c and cat == "X"')
print(f"Lignes filtrées : {len(result):,}")

# eval() pour créer une nouvelle colonne
df_perf.eval('d = a**2 + b**2 + c**2', inplace=True)
print(df_perf['d'].describe().round(3))
```

### Types catégoriels et Pandas 2.0

Les types catégoriels réduisent drastiquement la consommation mémoire pour les colonnes à faible cardinalité, et accélèrent les opérations `groupby`. Avec Pandas 2.0 et le mécanisme **Copy-on-Write**, les modifications sur une vue ne propagent plus vers l'objet original, rendant le comportement plus prévisible :

```{code-cell} python
# Comparaison mémoire : object vs Categorical
n = 1_000_000
s_object = pd.Series(np.random.choice(['Région A', 'Région B', 'Région C'], n))
s_cat = s_object.astype('category')

print(f"Mémoire (object)      : {s_object.memory_usage(deep=True) / 1e6:.2f} Mo")
print(f"Mémoire (Categorical) : {s_cat.memory_usage(deep=True) / 1e6:.2f} Mo")
print(f"Facteur de réduction  : {s_object.memory_usage(deep=True) / s_cat.memory_usage(deep=True):.1f}x")
```

```{note}
**Pandas 2.0 et Copy-on-Write.** Depuis Pandas 2.0, le mode *Copy-on-Write* (CoW) est disponible et sera activé par défaut dans les versions futures. Sous CoW, toute opération qui modifie un DataFrame crée implicitement une copie si nécessaire, éliminant le comportement ambigu `SettingWithCopyWarning`. Il est recommandé d'activer CoW dès aujourd'hui avec `pd.options.mode.copy_on_write = True` pour préparer la migration vers les versions futures.
```

## Visualisation — Diagramme split-apply-combine

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

fig = plt.figure(figsize=(16, 10))
fig.suptitle('Pattern split-apply-combine avec groupby', fontsize=15,
             fontweight='bold')

colors = sns.color_palette('muted', 3)
color_nord, color_sud, color_est = colors[0], colors[1], colors[2]
gray = '#ecf0f1'
dark = '#2c3e50'

# ---- DataFrame original ----
ax_orig = fig.add_axes([0.01, 0.55, 0.22, 0.38])
ax_orig.set_xlim(0, 4)
ax_orig.set_ylim(0, 9)
ax_orig.axis('off')
ax_orig.set_title('DataFrame\noriginal', fontsize=11, fontweight='bold',
                  color=dark)

data_rows = [
    ('Nord', '120', color_nord),
    ('Sud',  ' 95', color_sud),
    ('Est',  '145', color_est),
    ('Nord', '110', color_nord),
    ('Sud',  '130', color_sud),
    ('Est',  ' 88', color_est),
    ('Nord', '160', color_nord),
    ('Sud',  '102', color_sud),
]

# En-tête
ax_orig.add_patch(patches.FancyBboxPatch((0.1, 8.0), 3.8, 0.7,
    boxstyle='round,pad=0.05', facecolor='#bdc3c7', edgecolor='none'))
ax_orig.text(1.1, 8.35, 'région', fontsize=8, fontweight='bold', color=dark,
             va='center')
ax_orig.text(2.7, 8.35, 'ventes', fontsize=8, fontweight='bold', color=dark,
             va='center')

for i, (reg, val, col) in enumerate(data_rows):
    y = 7.2 - i * 0.85
    ax_orig.add_patch(patches.FancyBboxPatch((0.1, y), 3.8, 0.65,
        boxstyle='round,pad=0.03', facecolor=col, alpha=0.25,
        edgecolor=col, linewidth=1.2))
    ax_orig.text(1.1, y + 0.32, reg, fontsize=8, va='center', color=dark)
    ax_orig.text(2.85, y + 0.32, val, fontsize=8, va='center', color=dark)

# ---- Flèche SPLIT ----
ax_split = fig.add_axes([0.235, 0.60, 0.08, 0.28])
ax_split.axis('off')
ax_split.annotate('', xy=(0.95, 0.8), xytext=(0.05, 0.5),
    arrowprops=dict(arrowstyle='->', color=color_nord, lw=2.5))
ax_split.annotate('', xy=(0.95, 0.5), xytext=(0.05, 0.5),
    arrowprops=dict(arrowstyle='->', color=color_sud, lw=2.5))
ax_split.annotate('', xy=(0.95, 0.2), xytext=(0.05, 0.5),
    arrowprops=dict(arrowstyle='->', color=color_est, lw=2.5))
ax_split.text(0.5, 0.05, 'SPLIT', ha='center', fontsize=9, fontweight='bold',
              color=dark)

# ---- Groupes séparés ----
groupes = [
    ('Nord', ['120', '110', '160'], color_nord, [0.33, 0.75, 0.25, 0.22]),
    ('Sud',  [' 95', '130', '102'], color_sud,  [0.33, 0.48, 0.25, 0.22]),
    ('Est',  ['145', ' 88'],        color_est,  [0.33, 0.21, 0.25, 0.22]),
]

for nom, vals, col, rect in groupes:
    ax_g = fig.add_axes(rect)
    ax_g.set_xlim(0, 3)
    ax_g.set_ylim(0, len(vals) + 0.8)
    ax_g.axis('off')
    ax_g.set_title(f'Groupe : {nom}', fontsize=9, fontweight='bold',
                   color=col, pad=2)
    for j, v in enumerate(vals):
        y = len(vals) - j - 0.2
        ax_g.add_patch(patches.FancyBboxPatch((0.1, y - 0.45), 2.8, 0.6,
            boxstyle='round,pad=0.03', facecolor=col, alpha=0.22,
            edgecolor=col, linewidth=1.0))
        ax_g.text(1.5, y - 0.15, f'{nom} : {v}',
                  fontsize=7.5, va='center', ha='center', color=dark)

# ---- Flèche APPLY ----
ax_apply = fig.add_axes([0.59, 0.60, 0.07, 0.28])
ax_apply.axis('off')
for frac, col in [(0.8, color_nord), (0.5, color_sud), (0.2, color_est)]:
    ax_apply.annotate('', xy=(0.95, frac), xytext=(0.05, frac),
        arrowprops=dict(arrowstyle='->', color=col, lw=2.5))
ax_apply.text(0.5, 0.05, 'APPLY\n(sum)', ha='center', fontsize=9,
              fontweight='bold', color=dark)

# ---- Résultats par groupe ----
resultats = [
    ('Nord', '390', color_nord, [0.67, 0.74, 0.14, 0.14]),
    ('Sud',  '327', color_sud,  [0.67, 0.54, 0.14, 0.14]),
    ('Est',  '233', color_est,  [0.67, 0.34, 0.14, 0.14]),
]

for nom, tot, col, rect in resultats:
    ax_r = fig.add_axes(rect)
    ax_r.set_xlim(0, 2)
    ax_r.set_ylim(0, 1)
    ax_r.axis('off')
    ax_r.add_patch(patches.FancyBboxPatch((0.05, 0.15), 1.9, 0.7,
        boxstyle='round,pad=0.05', facecolor=col, alpha=0.3,
        edgecolor=col, linewidth=1.5))
    ax_r.text(1.0, 0.5, f'{nom} : {tot}',
              fontsize=9, fontweight='bold', ha='center', va='center',
              color=dark)

# ---- Flèche COMBINE ----
ax_comb = fig.add_axes([0.82, 0.60, 0.07, 0.28])
ax_comb.axis('off')
for frac, col in [(0.8, color_nord), (0.5, color_sud), (0.2, color_est)]:
    ax_comb.annotate('', xy=(0.95, 0.5), xytext=(0.05, frac),
        arrowprops=dict(arrowstyle='->', color=col, lw=2.5))
ax_comb.text(0.5, 0.05, 'COMBINE', ha='center', fontsize=9,
             fontweight='bold', color=dark)

# ---- DataFrame résultat ----
ax_res = fig.add_axes([0.90, 0.60, 0.09, 0.28])
ax_res.set_xlim(0, 3)
ax_res.set_ylim(0, 4)
ax_res.axis('off')
ax_res.set_title('Résultat', fontsize=10, fontweight='bold', color=dark, pad=2)

ax_res.add_patch(patches.FancyBboxPatch((0.1, 3.2), 2.8, 0.6,
    boxstyle='round,pad=0.05', facecolor='#bdc3c7', edgecolor='none'))
ax_res.text(1.5, 3.5, 'région  ventes',
            fontsize=7, fontweight='bold', ha='center', va='center', color=dark)

for i, (nom, tot, col, _) in enumerate(resultats):
    y = 2.5 - i * 0.85
    ax_res.add_patch(patches.FancyBboxPatch((0.1, y), 2.8, 0.65,
        boxstyle='round,pad=0.03', facecolor=col, alpha=0.25,
        edgecolor=col, linewidth=1.0))
    ax_res.text(0.8, y + 0.32, nom, fontsize=7.5, va='center', color=dark)
    ax_res.text(2.2, y + 0.32, tot, fontsize=7.5, va='center', color=dark)

# ---- Code correspondant en bas ----
ax_code = fig.add_axes([0.01, 0.05, 0.98, 0.18])
ax_code.axis('off')
code_text = (
    "df.groupby('region')['ventes'].sum()\n\n"
    "# Résultat :\n"
    "# region\n"
    "# Est     233\n"
    "# Nord    390\n"
    "# Sud     327\n"
    "# Name: ventes, dtype: int64"
)
ax_code.add_patch(patches.FancyBboxPatch((0.01, 0.05), 0.98, 0.9,
    boxstyle='round,pad=0.03', facecolor='#2c3e50', edgecolor='none',
    transform=ax_code.transAxes))
ax_code.text(0.03, 0.55, code_text, fontsize=9, va='center', color='#ecf0f1',
             fontfamily='monospace', transform=ax_code.transAxes)

plt.show()
```

## Résumé

Ce chapitre a exploré les opérations d'agrégation, de jointure et de performance dans Pandas :

- Le patron **split-apply-combine** est le cœur de `groupby` : on divise le DataFrame par clé, on applique une fonction à chaque groupe, puis on recombine les résultats. `agg`, `transform`, `filter` et `apply` couvrent tous les cas d'usage.
- Les **fonctions d'agrégation** intégrées (`sum`, `mean`, `count`, `nunique`, `std`...) sont optimisées. `agg` avec un dictionnaire nommé permet de calculer plusieurs métriques en une seule passe.
- `merge()` implémente les quatre types de jointures SQL (inner, left, right, outer). L'argument `indicator=True` permet de diagnostiquer les non-correspondances. `pd.concat()` assemble des tables verticalement ou horizontalement.
- Les **fenêtres glissantes** (`rolling`, `expanding`, `ewm`) permettent de calculer des statistiques locales sur des séries temporelles, indispensables pour la détection de tendances.
- `eval()`, `query()` et les **types catégoriels** sont les principaux leviers de performance. Le mécanisme **Copy-on-Write** de Pandas 2.0 rend le comportement des copies plus prévisible.

Le chapitre suivant introduit **Xarray**, la bibliothèque dédiée aux données multidimensionnelles avec coordonnées nommées, particulièrement adaptée aux données climatiques, géospatiales et scientifiques.
