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

# Évaluation et sélection de modèles

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

import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import make_classification, make_regression
from sklearn.linear_model import LogisticRegression, Ridge
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.svm import SVC
from sklearn.model_selection import (
    cross_val_score, KFold, StratifiedKFold, LeaveOneOut,
    cross_validate, learning_curve, train_test_split
)
from sklearn.metrics import (
    mean_absolute_error, mean_squared_error, r2_score,
    accuracy_score, precision_score, recall_score, f1_score,
    confusion_matrix, roc_curve, auc, classification_report,
    ConfusionMatrixDisplay
)
import warnings
warnings.filterwarnings('ignore')

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

## Biais et variance

Tout modèle d'apprentissage automatique est soumis à une tension fondamentale entre deux sources d'erreur antagonistes : le **biais** et la **variance**. Comprendre ce dilemme est indispensable pour diagnostiquer les problèmes d'un modèle et choisir les bonnes stratégies de remédiation.

```{admonition} Biais et variance
:class: tip
Le **biais** (*bias*) mesure à quel point les prédictions d'un modèle s'écartent systématiquement de la vérité. Un modèle à fort biais fait des hypothèses trop simplistes sur les données — il ne parvient pas à capturer la complexité réelle du phénomène. On parle de **sous-apprentissage** (*underfitting*).

La **variance** mesure la sensibilité du modèle aux fluctuations des données d'entraînement. Un modèle à forte variance s'est trop adapté aux données d'entraînement, au point de mémoriser leur bruit plutôt que d'apprendre la structure sous-jacente. On parle de **sur-apprentissage** (*overfitting*).
```

L'erreur totale d'un modèle peut se décomposer mathématiquement en trois termes :

$$\mathbb{E}[(\hat{y} - y)^2] = \text{Biais}^2 + \text{Variance} + \sigma^2$$

où $\sigma^2$ est l'erreur irréductible due au bruit inhérent aux données. Cette décomposition montre qu'il est impossible d'éliminer simultanément le biais et la variance : réduire l'un tend à augmenter l'autre. C'est le **dilemme biais-variance** (*bias-variance tradeoff*).

```{note}
En pratique, on distingue trois régimes :

- **Sous-apprentissage** : le modèle est trop simple. Il réalise de mauvaises performances à la fois sur les données d'entraînement et sur les données de test. Remèdes : augmenter la complexité du modèle, ajouter des caractéristiques, réduire la régularisation.
- **Bon équilibre** : les erreurs d'entraînement et de test sont toutes deux faibles et proches l'une de l'autre. C'est l'objectif visé.
- **Sur-apprentissage** : le modèle est trop complexe. Il performe très bien sur l'entraînement mais mal sur le test. Remèdes : réduire la complexité, ajouter de la régularisation, collecter davantage de données.
```

Les **courbes d'apprentissage** (*learning curves*) sont l'outil de diagnostic par excellence. Elles représentent les performances d'entraînement et de validation en fonction du nombre d'exemples d'entraînement utilisés, et permettent d'identifier visuellement la nature du problème.

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

X, y = make_classification(n_samples=1000, n_features=20, n_informative=10,
                           random_state=42)

fig, axes = plt.subplots(1, 3, figsize=(16, 5))
models = [
    (DecisionTreeClassifier(max_depth=1, random_state=42), "Sous-apprentissage\n(profondeur=1)"),
    (DecisionTreeClassifier(max_depth=4, random_state=42), "Bon équilibre\n(profondeur=4)"),
    (DecisionTreeClassifier(max_depth=None, random_state=42), "Sur-apprentissage\n(profondeur=∞)"),
]

train_sizes = np.linspace(0.2, 1.0, 8)

for ax, (model, title) in zip(axes, models):
    train_sz, train_scores, val_scores = learning_curve(
        model, X, y, train_sizes=train_sizes, cv=5,
        scoring='accuracy', n_jobs=-1, shuffle=True, random_state=42
    )
    train_mean = train_scores.mean(axis=1)
    train_std = train_scores.std(axis=1)
    val_mean = val_scores.mean(axis=1)
    val_std = val_scores.std(axis=1)

    ax.plot(train_sz, train_mean, 'o-', color='#e74c3c', label='Entraînement', lw=2)
    ax.fill_between(train_sz, train_mean - train_std, train_mean + train_std,
                    alpha=0.15, color='#e74c3c')
    ax.plot(train_sz, val_mean, 's-', color='#2ecc71', label='Validation', lw=2)
    ax.fill_between(train_sz, val_mean - val_std, val_mean + val_std,
                    alpha=0.15, color='#2ecc71')
    ax.set_title(title, fontsize=12, fontweight='bold')
    ax.set_xlabel("Nombre d'exemples d'entraînement")
    ax.set_ylabel("Accuracy")
    ax.set_ylim(0.4, 1.05)
    ax.legend(fontsize=9)

fig.suptitle("Courbes d'apprentissage — diagnostic du biais et de la variance",
             fontsize=14, fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()
```

## Validation croisée

La validation croisée (*cross-validation*) est la technique standard pour évaluer de façon fiable les performances généralisées d'un modèle. Elle résout le problème fondamental de tout protocole d'évaluation : la variabilité liée au choix particulier du découpage entraînement/test.

```{admonition} Validation croisée à k plis
:class: tip
La **validation croisée à k plis** (*k-fold cross-validation*) divise le jeu de données en $k$ sous-ensembles (*plis*) de taille approximativement égale. Le modèle est entraîné $k$ fois : à chaque itération, $k-1$ plis servent à l'entraînement et le pli restant sert à l'évaluation. Les $k$ scores obtenus sont ensuite agrégés — généralement par leur moyenne et leur écart-type — pour produire une estimation robuste des performances.
```

La fonction `cross_val_score` de scikit-learn est le point d'entrée le plus direct :

```{code-cell} python
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, KFold

X_iris, y_iris = load_iris(return_X_y=True)

# Validation croisée à 5 plis
model = LogisticRegression(max_iter=200)
scores = cross_val_score(model, X_iris, y_iris, cv=5, scoring='accuracy')

print(f"Scores par pli : {scores.round(4)}")
print(f"Moyenne : {scores.mean():.4f} ± {scores.std():.4f}")
```

La classe `KFold` donne un contrôle plus fin sur le découpage :

```{code-cell} python
kf = KFold(n_splits=5, shuffle=True, random_state=42)

for fold, (train_idx, val_idx) in enumerate(kf.split(X_iris)):
    X_train, X_val = X_iris[train_idx], X_iris[val_idx]
    y_train, y_val = y_iris[train_idx], y_iris[val_idx]
    model.fit(X_train, y_train)
    score = model.score(X_val, y_val)
    print(f"Pli {fold + 1} — taille entraînement : {len(train_idx)}, "
          f"taille validation : {len(val_idx)}, accuracy : {score:.4f}")
```

Pour les problèmes de classification avec des classes déséquilibrées, `StratifiedKFold` garantit que chaque pli respecte la distribution des classes originale :

```{code-cell} python
from sklearn.model_selection import StratifiedKFold

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores_stratified = cross_val_score(model, X_iris, y_iris, cv=skf, scoring='accuracy')
print(f"StratifiedKFold — Moyenne : {scores_stratified.mean():.4f} ± {scores_stratified.std():.4f}")
```

`LeaveOneOut` est un cas extrême de la validation croisée où $k = n$ : chaque exemple est tour à tour le seul élément de l'ensemble de validation. Cette approche est très coûteuse en calcul mais produit une estimation presque sans biais, particulièrement utile lorsque les données sont très rares.

```{code-cell} python
from sklearn.model_selection import LeaveOneOut

# Illustré sur un petit sous-ensemble équilibré (10 exemples par classe)
idx = np.concatenate([np.where(y_iris == c)[0][:10] for c in np.unique(y_iris)])
X_small, y_small = X_iris[idx], y_iris[idx]
loo = LeaveOneOut()
scores_loo = cross_val_score(model, X_small, y_small, cv=loo)
print(f"LeaveOneOut ({loo.get_n_splits(X_small)} plis) — "
      f"Moyenne : {scores_loo.mean():.4f} ± {scores_loo.std():.4f}")
```

La fonction `cross_validate` va plus loin que `cross_val_score` : elle renvoie les scores sur l'entraînement et sur la validation, ainsi que les temps d'entraînement et de prédiction.

```{code-cell} python
from sklearn.model_selection import cross_validate

results = cross_validate(
    model, X_iris, y_iris, cv=5,
    scoring=['accuracy', 'f1_macro'],
    return_train_score=True
)

for key, values in results.items():
    print(f"{key:30s} : {values.round(4)}")
```

```{note}
Le choix du nombre de plis $k$ résulte d'un compromis. Avec $k = 5$ ou $k = 10$, on obtient une bonne estimation avec un coût de calcul raisonnable. Un $k$ élevé réduit le biais de l'estimation (les modèles sont entraînés sur presque toutes les données) mais augmente la variance et le temps de calcul. La règle empirique la plus répandue est d'utiliser $k = 5$ comme valeur par défaut et $k = 10$ lorsque les données sont abondantes.
```

## Métriques de régression

Pour les problèmes de régression, plusieurs métriques permettent de quantifier la qualité des prédictions numériques. Chacune possède ses propres propriétés et interprétations.

```{admonition} Métriques de régression fondamentales
:class: tip
Soient $y_i$ les valeurs réelles et $\hat{y}_i$ les prédictions pour $n$ observations :

- **MAE** (*Mean Absolute Error*) : $\text{MAE} = \frac{1}{n}\sum_{i=1}^n |y_i - \hat{y}_i|$. Robuste aux valeurs aberrantes, exprimée dans la même unité que la cible.
- **MSE** (*Mean Squared Error*) : $\text{MSE} = \frac{1}{n}\sum_{i=1}^n (y_i - \hat{y}_i)^2$. Pénalise fortement les grandes erreurs ; sensible aux outliers.
- **RMSE** (*Root MSE*) : $\text{RMSE} = \sqrt{\text{MSE}}$. Même unité que la cible, plus interprétable que le MSE.
- **R²** (*coefficient de détermination*) : $R^2 = 1 - \frac{\sum(y_i - \hat{y}_i)^2}{\sum(y_i - \bar{y})^2}$. Mesure la part de variance expliquée par le modèle ; vaut 1 pour un modèle parfait et peut être négatif pour un modèle pire que la moyenne.
```

```{code-cell} python
from sklearn.datasets import fetch_california_housing
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.ensemble import RandomForestRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline

housing = fetch_california_housing()
X_h, y_h = housing.data, housing.target
X_train_h, X_test_h, y_train_h, y_test_h = train_test_split(
    X_h, y_h, test_size=0.2, random_state=42
)

models_reg = {
    "Régression linéaire": Pipeline([
        ('scaler', StandardScaler()),
        ('model', LinearRegression())
    ]),
    "Ridge (α=1.0)": Pipeline([
        ('scaler', StandardScaler()),
        ('model', Ridge(alpha=1.0))
    ]),
    "Forêt aléatoire": RandomForestRegressor(n_estimators=100, random_state=42),
}

print(f"{'Modèle':<25} {'MAE':>8} {'RMSE':>8} {'R²':>8} {'MAPE':>8}")
print("-" * 65)
for name, mdl in models_reg.items():
    mdl.fit(X_train_h, y_train_h)
    y_pred = mdl.predict(X_test_h)
    mae = mean_absolute_error(y_test_h, y_pred)
    rmse = np.sqrt(mean_squared_error(y_test_h, y_pred))
    r2 = r2_score(y_test_h, y_pred)
    # MAPE manuel
    mape = np.mean(np.abs((y_test_h - y_pred) / (y_test_h + 1e-8))) * 100
    print(f"{name:<25} {mae:>8.3f} {rmse:>8.3f} {r2:>8.3f} {mape:>7.1f}%")
```

```{note}
Le MAPE (*Mean Absolute Percentage Error*) est particulièrement utile lorsque les valeurs cibles sont d'ordres de grandeur très différents, car il exprime l'erreur en pourcentage. Cependant, il devient instable lorsque les valeurs cibles sont proches de zéro (division par un petit nombre). Dans ce cas, des variantes comme le sMAPE (*symmetric MAPE*) sont préférables.
```

## Métriques de classification

L'évaluation d'un classifieur est plus riche que celle d'un régresseur, car elle intègre la notion de types d'erreurs. La **matrice de confusion** est le point de départ de toute analyse.

```{admonition} Matrice de confusion et métriques dérivées
:class: tip
Pour un problème binaire avec une classe positive et une classe négative :

- **VP** (vrais positifs) : exemples positifs correctement classés.
- **VN** (vrais négatifs) : exemples négatifs correctement classés.
- **FP** (faux positifs) : exemples négatifs classés comme positifs (*erreur de type I*).
- **FN** (faux négatifs) : exemples positifs classés comme négatifs (*erreur de type II*).

Les métriques dérivées sont :
- **Accuracy** : $\frac{VP + VN}{VP + VN + FP + FN}$
- **Précision** : $\frac{VP}{VP + FP}$ — parmi les prédictions positives, quelle fraction est réellement positive ?
- **Rappel** (*recall* ou *sensibilité*) : $\frac{VP}{VP + FN}$ — parmi les exemples réellement positifs, quelle fraction est détectée ?
- **F1** : $\frac{2 \times \text{Précision} \times \text{Rappel}}{\text{Précision} + \text{Rappel}}$ — moyenne harmonique, équilibre entre précision et rappel.
```

```{code-cell} python
X_clf, y_clf = make_classification(n_samples=1000, n_features=20,
                                   n_informative=10, random_state=42)
X_tr, X_te, y_tr, y_te = train_test_split(X_clf, y_clf, test_size=0.3,
                                           random_state=42)

clf_models = {
    "Régression logistique": LogisticRegression(max_iter=500),
    "Forêt aléatoire": RandomForestClassifier(n_estimators=100, random_state=42),
    "Gradient Boosting": GradientBoostingClassifier(n_estimators=100, random_state=42),
}

for name, clf in clf_models.items():
    clf.fit(X_tr, y_tr)
    y_pred = clf.predict(X_te)
    print(f"\n=== {name} ===")
    print(classification_report(y_te, y_pred, target_names=["Classe 0", "Classe 1"]))
```

La **courbe ROC** (*Receiver Operating Characteristic*) et son aire sous la courbe (**AUC**) permettent d'évaluer la qualité discriminante d'un classifieur indépendamment du seuil de décision.

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

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# --- Courbes ROC superposées ---
ax = axes[0]
colors = sns.color_palette("muted", len(clf_models))

for (name, clf), color in zip(clf_models.items(), colors):
    if hasattr(clf, "predict_proba"):
        y_prob = clf.predict_proba(X_te)[:, 1]
    else:
        y_prob = clf.decision_function(X_te)
    fpr, tpr, _ = roc_curve(y_te, y_prob)
    roc_auc = auc(fpr, tpr)
    ax.plot(fpr, tpr, lw=2, color=color,
            label=f"{name} (AUC = {roc_auc:.3f})")

ax.plot([0, 1], [0, 1], 'k--', lw=1.5, label="Classifieur aléatoire (AUC = 0.500)")
ax.set_xlabel("Taux de faux positifs (FPR)", fontsize=11)
ax.set_ylabel("Taux de vrais positifs (TPR)", fontsize=11)
ax.set_title("Courbes ROC — comparaison des modèles", fontsize=13, fontweight='bold')
ax.legend(loc="lower right", fontsize=9)
ax.set_xlim([-0.02, 1.02])
ax.set_ylim([-0.02, 1.05])

# --- Matrice de confusion annotée (meilleur modèle) ---
ax = axes[1]
best_name = "Gradient Boosting"
best_clf = clf_models[best_name]
y_pred_best = best_clf.predict(X_te)
cm = confusion_matrix(y_te, y_pred_best)

sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=ax,
            xticklabels=["Prédit 0", "Prédit 1"],
            yticklabels=["Réel 0", "Réel 1"],
            linewidths=0.5, linecolor='gray',
            annot_kws={"size": 14, "weight": "bold"})
ax.set_title(f"Matrice de confusion\n{best_name}", fontsize=13, fontweight='bold')
ax.set_ylabel("Classe réelle", fontsize=11)
ax.set_xlabel("Classe prédite", fontsize=11)

# Annotations des types d'erreurs
ax.text(0.5, 0.5, "VP", ha='center', va='bottom', transform=ax.get_xaxis_transform(),
        fontsize=8, color='white', alpha=0)

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

```{admonition} Choisir la bonne métrique selon le contexte
:class: note
Le choix de la métrique dépend du coût relatif des erreurs :

- **Détection de cancer** : le faux négatif (ne pas détecter un cancer) est catastrophique. On privilégie le **rappel** maximal, quitte à tolérer plus de faux positifs.
- **Filtre anti-spam** : un faux positif (supprimer un courriel légitime) est très gênant. On privilégie la **précision** élevée.
- **Système de recommandation** : on cherche un équilibre entre précision et rappel ; le **F1** ou l'**AUC-ROC** sont des choix pertinents.
- **Classes très déséquilibrées** (1 % de positifs) : l'accuracy est trompeuse (un modèle qui prédit toujours "négatif" atteint 99 %). On préfère l'**AUC**, le **F1** ou la **précision-rappel AUC**.
```

## Sélection de modèles

Sélectionner le meilleur modèle parmi plusieurs candidats requiert une démarche rigoureuse qui va au-delà de la simple comparaison des scores moyens. Il faut tenir compte de la variabilité des estimations, du coût de calcul, de l'interprétabilité et des contraintes de déploiement.

```{admonition} Protocole de sélection rigoureuse
:class: tip
Un protocole de sélection rigoureuse repose sur trois ensembles distincts :

1. **Ensemble d'entraînement** : utilisé pour ajuster les paramètres du modèle.
2. **Ensemble de validation** (ou plis de validation croisée) : utilisé pour sélectionner les hyperparamètres et comparer les architectures.
3. **Ensemble de test** : utilisé **une seule fois**, en fin de processus, pour estimer les performances réelles. Il ne doit jamais influencer les décisions de modélisation.
```

La comparaison statistique de modèles utilise souvent le **test de Wilcoxon** ou le **test t apparié** sur les scores obtenus par validation croisée :

```{code-cell} python
from scipy import stats

# Comparer deux modèles par leurs scores de validation croisée
cv_lr = cross_val_score(LogisticRegression(max_iter=500), X_clf, y_clf,
                        cv=10, scoring='roc_auc')
cv_rf = cross_val_score(RandomForestClassifier(n_estimators=100, random_state=42),
                        X_clf, y_clf, cv=10, scoring='roc_auc')

stat, pvalue = stats.wilcoxon(cv_lr, cv_rf)
print(f"Régression logistique — AUC : {cv_lr.mean():.4f} ± {cv_lr.std():.4f}")
print(f"Forêt aléatoire       — AUC : {cv_rf.mean():.4f} ± {cv_rf.std():.4f}")
print(f"Test de Wilcoxon : statistique = {stat:.3f}, p-valeur = {pvalue:.4f}")
print(f"Différence significative (α=0.05) : {'Oui' if pvalue < 0.05 else 'Non'}")
```

**MLflow** est la bibliothèque de référence pour le suivi des expériences d'apprentissage automatique. Elle permet d'enregistrer automatiquement les paramètres, les métriques et les artefacts de chaque expérience, de les comparer dans une interface web et de reproductibiliser les résultats.

```python
import mlflow
import mlflow.sklearn

# Démarrer une expérience MLflow
mlflow.set_experiment("selection_modeles")

with mlflow.start_run(run_name="foret_aleatoire"):
    # Paramètres du modèle
    n_estimators = 100
    max_depth = 5
    mlflow.log_param("n_estimators", n_estimators)
    mlflow.log_param("max_depth", max_depth)

    # Entraînement
    rf = RandomForestClassifier(n_estimators=n_estimators,
                                max_depth=max_depth, random_state=42)
    rf.fit(X_tr, y_tr)

    # Métriques
    auc_score = roc_auc_score(y_te, rf.predict_proba(X_te)[:, 1])
    mlflow.log_metric("auc", auc_score)
    mlflow.log_metric("accuracy", rf.score(X_te, y_te))

    # Sauvegarder le modèle
    mlflow.sklearn.log_model(rf, "model")
    print(f"AUC enregistré : {auc_score:.4f}")
```

```{note}
L'interface web de MLflow (`mlflow ui`) permet de visualiser et comparer toutes les expériences passées dans un tableau de bord interactif. Elle facilite notamment la détection de corrélations entre hyperparamètres et performances, et la sélection du meilleur run pour la mise en production. MLflow s'intègre aussi bien avec scikit-learn qu'avec PyTorch, TensorFlow ou XGBoost.
```

## Résumé

Ce chapitre a posé les bases d'une évaluation rigoureuse des modèles d'apprentissage automatique :

- Le **dilemme biais-variance** est la tension fondamentale de tout apprentissage : un modèle trop simple sous-apprend (fort biais), un modèle trop complexe sur-apprend (forte variance). Les courbes d'apprentissage permettent de diagnostiquer visuellement ces deux régimes.
- La **validation croisée** est la technique standard pour estimer les performances généralisées d'un modèle. `KFold`, `StratifiedKFold` et `cross_validate` offrent différents niveaux de contrôle et d'information.
- Pour la **régression**, le MAE, le RMSE et le R² sont les métriques principales. Le choix dépend de la sensibilité souhaitée aux valeurs aberrantes et de la nécessité d'une interprétabilité directe.
- Pour la **classification**, la matrice de confusion révèle la nature des erreurs. La précision, le rappel, le F1 et l'AUC-ROC permettent d'évaluer différents aspects du classifieur selon le contexte métier.
- La **sélection de modèles** doit s'appuyer sur des tests statistiques (Wilcoxon, test t) et un protocole à trois ensembles. MLflow facilite le suivi et la reproductibilité des expériences.

Le chapitre suivant marque une transition vers le deep learning : nous découvrirons PyTorch, sa représentation des données sous forme de tenseurs et son mécanisme de différentiation automatique (autograd), qui constituent le socle de tout réseau de neurones moderne.
