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

# API Scikit-learn et Pipeline

```{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)
rng = np.random.default_rng(42)
```

L'une des raisons fondamentales du succès de Scikit-learn n'est pas uniquement la qualité de ses algorithmes, mais la **cohérence exceptionnelle de son interface**. Que l'on entraîne une régression linéaire, un réseau de neurones peu profond, un algorithme de clustering ou un transformateur de variables, les objets exposent toujours le même contrat d'interface. Cette uniformité permet de remplacer un modèle par un autre sans modifier le reste du code, de composer des étapes en pipeline, et d'utiliser les outils de validation croisée et de recherche d'hyperparamètres de façon universelle.

Ce chapitre explore l'API de Scikit-learn dans sa profondeur : les trois rôles d'un estimateur (estimateur, transformateur, prédicteur), les conventions d'interface, la classe `Pipeline`, le `ColumnTransformer` pour les prétraitements hétérogènes, et les bonnes pratiques qui garantissent l'absence de fuite de données (*data leakage*) entre l'entraînement et la validation.

## Estimateurs, transformateurs, prédicteurs

L'API Scikit-learn repose sur trois rôles fondamentaux que peuvent jouer les objets. Ces rôles ne sont pas mutuellement exclusifs : de nombreux objets combinent deux ou trois rôles.

```{admonition} Estimateur (Estimator)
:class: tip
Un **estimateur** est tout objet qui apprend des paramètres à partir de données. Il implémente la méthode **`fit(X, y=None)`** qui prend une matrice d'observations `X` (et éventuellement un vecteur cible `y`) et stocke les paramètres appris dans des attributs de l'instance — conventionnellement nommés avec un suffixe `_` (underscore) pour indiquer qu'ils ont été calculés lors du `fit`. Exemples : `LinearRegression`, `StandardScaler`, `KMeans`.
```

```{admonition} Transformateur (Transformer)
:class: tip
Un **transformateur** est un estimateur qui, après avoir été ajusté, peut produire une nouvelle représentation des données. Il implémente la méthode **`transform(X)`** qui applique la transformation apprise lors du `fit` à de nouvelles données. La méthode **`fit_transform(X)`** est un raccourci équivalent à `fit(X)` suivi de `transform(X)`, souvent optimisé. Exemples : `StandardScaler`, `PCA`, `OneHotEncoder`, `SimpleImputer`.
```

```{admonition} Prédicteur (Predictor)
:class: tip
Un **prédicteur** est un estimateur qui, après avoir été ajusté, peut produire des prédictions sur de nouvelles données. Il implémente la méthode **`predict(X)`** qui retourne un vecteur de prédictions (classes ou valeurs numériques). Les classifieurs implémentent également **`predict_proba(X)`** qui retourne des probabilités par classe, et **`score(X, y)`** qui retourne la métrique d'évaluation par défaut (exactitude pour les classifieurs, R² pour les régresseurs). Exemples : `LinearRegression`, `RandomForestClassifier`, `SVR`.
```

```{code-cell} python
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import make_regression

# Génération de données synthétiques
X, y = make_regression(n_samples=200, n_features=5, noise=15, random_state=42)

# StandardScaler : estimateur + transformateur
scaler = StandardScaler()
print("Avant fit — attributs appris :")
print(f"  mean_ existe : {hasattr(scaler, 'mean_')}")

scaler.fit(X)
print("Après fit :")
print(f"  mean_  : {scaler.mean_.round(3)}")
print(f"  scale_ : {scaler.scale_.round(3)}")

X_scaled = scaler.transform(X)
print(f"\nX original  — μ={X.mean():.3f}, σ={X.std():.3f}")
print(f"X normalisé — μ={X_scaled.mean():.6f}, σ={X_scaled.std():.6f}")
```

```{code-cell} python
# LinearRegression : estimateur + prédicteur
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X_scaled, y,
                                                       test_size=0.2,
                                                       random_state=42)

reg = LinearRegression()
reg.fit(X_train, y_train)

print("Paramètres appris :")
print(f"  coef_      : {reg.coef_.round(2)}")
print(f"  intercept_ : {reg.intercept_:.3f}")
print()

y_pred = reg.predict(X_test)
score = reg.score(X_test, y_test)
print(f"R² sur le jeu de test : {score:.4f}")
print(f"Premières prédictions : {y_pred[:5].round(2)}")
```

## Convention `fit`/`transform`/`predict`

La convention d'interface de Scikit-learn impose des règles précises sur les types d'entrée et de sortie, les attributs accessibles avant et après `fit`, et la gestion des paramètres.

```{note}
Toutes les configurations d'un estimateur Scikit-learn sont des **hyperparamètres** passés au constructeur `__init__`. Aucun hyperparamètre ne doit être modifié après la création de l'objet via un attribut direct — on crée un nouvel estimateur. Les attributs terminant par `_` (underscore) sont des **paramètres appris** disponibles uniquement après `fit`. Tenter d'appeler `transform` ou `predict` avant `fit` lève une exception `NotFittedError`. `get_params()` retourne le dictionnaire des hyperparamètres, et `set_params(**params)` permet de les modifier (utile pour `GridSearchCV`).
```

```python
from sklearn.ensemble import RandomForestClassifier
from sklearn.exceptions import NotFittedError

clf = RandomForestClassifier(n_estimators=100, max_depth=5, random_state=42)

# get_params() retourne les hyperparamètres
print(clf.get_params())
# {'bootstrap': True, 'max_depth': 5, 'n_estimators': 100, ...}

# set_params() modifie des hyperparamètres (utile dans GridSearchCV)
clf.set_params(n_estimators=200, max_depth=8)

clf.fit(X_train, y_train)   # y_train est binaire ici pour illustration
y_pred = clf.predict(X_test)
y_proba = clf.predict_proba(X_test)   # probabilités par classe
```

La méthode `fit_transform(X)` est définie dans la classe `TransformerMixin` et appelle `fit(X).transform(X)`. Elle est généralement plus efficace que les deux appels séparés car certaines implémentations peuvent réutiliser des calculs intermédiaires.

```{admonition} Cycle complet d'utilisation d'un transformateur
:class: note
La règle d'or pour éviter toute fuite de données est de **n'appeler `fit` que sur les données d'entraînement**, puis d'appliquer `transform` séparément sur le jeu d'entraînement et le jeu de test. Cette règle est automatiquement respectée par le `Pipeline`.

```python
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()

# CORRECT : fit uniquement sur l'entraînement
X_train_sc = scaler.fit_transform(X_train)   # fit + transform en une fois
X_test_sc  = scaler.transform(X_test)        # transform seulement (pas de fit !)

# INCORRECT : data leakage !
# scaler.fit(X)           # ← utilise toutes les données
# X_train_sc = scaler.transform(X_train)
# X_test_sc  = scaler.transform(X_test)
```

## Pipeline

La classe `Pipeline` enchaîne une séquence d'estimateurs en un seul objet. Toutes les étapes sauf la dernière doivent être des transformateurs (implémenter `fit` et `transform`). La dernière étape peut être un transformateur ou un prédicteur.

```{admonition} Pipeline
:class: tip
Un **`Pipeline`** est un méta-estimateur qui orchestre une séquence d'étapes. Lors d'un appel à `pipeline.fit(X, y)`, chaque étape intermédiaire appelle `fit_transform(X, y)` et passe le résultat à l'étape suivante. La dernière étape appelle `fit(X_transformed, y)`. Lors d'un appel à `pipeline.predict(X)`, chaque étape intermédiaire appelle `transform(X)`, et la dernière étape appelle `predict(X_transformed)`. Le `Pipeline` garantit mécaniquement l'absence de fuite de données lorsqu'il est utilisé dans une validation croisée.
```

```{code-cell} python
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import Ridge
from sklearn.datasets import make_regression
from sklearn.model_selection import train_test_split, cross_val_score

X, y = make_regression(n_samples=300, n_features=10, noise=20, random_state=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0)

# Construction du Pipeline
pipe = Pipeline([
    ('normalisation', StandardScaler()),       # étape 1 : centrage-réduction
    ('regression',    Ridge(alpha=1.0)),        # étape 2 : prédicteur
])

# Interface identique à un estimateur ordinaire
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
print(f"R² sur le test : {pipe.score(X_test, y_test):.4f}")

# Validation croisée : fit/transform respectés automatiquement
scores_cv = cross_val_score(pipe, X, y, cv=5, scoring='r2')
print(f"R² en validation croisée (5 folds) : {scores_cv.mean():.4f} ± {scores_cv.std():.4f}")

# Accès aux étapes et à leurs paramètres appris
print(f"\nMoyennes apprises par le scaler : {pipe['normalisation'].mean_[:3].round(3)}")
print(f"Coefficients de Ridge           : {pipe['regression'].coef_[:3].round(3)}")
```

### `make_pipeline` — syntaxe abrégée

`make_pipeline()` crée un `Pipeline` en nommant automatiquement les étapes d'après le nom de la classe en minuscules, ce qui évite de répéter les noms explicitement pour les pipelines simples.

```python
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import Lasso

pipe = make_pipeline(
    StandardScaler(),
    PolynomialFeatures(degree=2, include_bias=False),
    Lasso(alpha=0.1),
)

# Les noms des étapes sont générés automatiquement
print(pipe.steps)
# [('standardscaler', StandardScaler()),
#  ('polynomialfeatures', PolynomialFeatures(degree=2, ...)),
#  ('lasso', Lasso(alpha=0.1))]
```

## ColumnTransformer

Dans les problèmes réels, les DataFrames contiennent des colonnes de natures différentes : des variables numériques qui nécessitent une normalisation, des variables catégorielles qui nécessitent un encodage, des dates qui nécessitent une extraction de caractéristiques. `ColumnTransformer` permet d'appliquer des transformations différentes à différentes colonnes en une seule étape.

```{admonition} ColumnTransformer
:class: tip
**`ColumnTransformer`** applique des transformations différentes à des sous-ensembles de colonnes. Chaque entrée de la liste `transformers` est un triplet `(nom, transformateur, colonnes)`. La valeur `'drop'` supprime les colonnes spécifiées. La valeur `'passthrough'` les passe sans transformation. Par défaut, toutes les colonnes non mentionnées sont supprimées (comportement contrôlé par `remainder='drop'` ou `remainder='passthrough'`).
```

```{code-cell} python
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline

# Jeu de données mixte
df_mix = pd.DataFrame({
    'age':       [25, 32, np.nan, 45, 28],
    'salaire':   [38000, 52000, 61000, np.nan, 44000],
    'ville':     ['Paris', 'Lyon', 'Paris', 'Marseille', 'Lyon'],
    'diplôme':   ['Licence', 'Master', 'Master', 'Doctorat', 'Licence'],
    'cible':     [0, 1, 1, 1, 0],
})

X = df_mix.drop('cible', axis=1)
y = df_mix['cible']

# Colonnes par type
cols_num = ['age', 'salaire']
cols_cat = ['ville', 'diplôme']

# Transformateurs par type
num_transformer = Pipeline([
    ('imputation', SimpleImputer(strategy='median')),
    ('normalisation', StandardScaler()),
])

cat_transformer = Pipeline([
    ('imputation', SimpleImputer(strategy='most_frequent')),
    ('encodage', OneHotEncoder(handle_unknown='ignore', sparse_output=False)),
])

# Assemblage
preprocesseur = ColumnTransformer(transformers=[
    ('num', num_transformer, cols_num),
    ('cat', cat_transformer, cols_cat),
], remainder='drop')

X_transformed = preprocesseur.fit_transform(X)
print(f"Shape avant transformation : {X.shape}")
print(f"Shape après transformation  : {X_transformed.shape}")

# Noms des colonnes produites
noms_num = cols_num  # inchangés après StandardScaler
noms_cat = preprocesseur.named_transformers_['cat']['encodage'].get_feature_names_out(cols_cat).tolist()
print(f"\nColonnes produites : {noms_num + noms_cat}")
```

### Pipeline complet avec ColumnTransformer

La combinaison d'un `ColumnTransformer` et d'un modèle dans un `Pipeline` constitue le schéma de référence pour les projets de machine learning sur données réelles.

```python
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.model_selection import cross_val_score

pipe_complet = Pipeline([
    ('preprocessing', preprocesseur),
    ('classifieur',   LogisticRegression(max_iter=1000)),
])

# Le pipeline entier peut s'utiliser avec cross_val_score
scores = cross_val_score(pipe_complet, X, y, cv=3, scoring='accuracy')
print(f"Exactitude (cross-val) : {scores.mean():.3f} ± {scores.std():.3f}")
```

## Hyperparamètres et `GridSearchCV`

L'un des avantages majeurs des `Pipeline` est leur intégration transparente avec les outils de recherche d'hyperparamètres. La convention de nommage `étape__paramètre` (double underscore) permet de cibler n'importe quel hyperparamètre d'une étape.

```python
from sklearn.model_selection import GridSearchCV

param_grid = {
    'preprocessing__num__normalisation__with_std':  [True, False],
    'classifieur__C':                               [0.01, 0.1, 1.0, 10.0],
    'classifieur__penalty':                         ['l1', 'l2'],
}

grid_search = GridSearchCV(
    pipe_complet,
    param_grid,
    cv=5,
    scoring='f1',
    n_jobs=-1,       # paralléliser sur tous les cœurs
    verbose=1,
)

grid_search.fit(X, y)
print(f"Meilleurs paramètres : {grid_search.best_params_}")
print(f"Meilleur score F1    : {grid_search.best_score_:.4f}")
```

## `clone` — copie d'un estimateur non ajusté

La fonction `clone()` crée une copie d'un estimateur avec les mêmes hyperparamètres mais sans les paramètres appris. Elle est utile pour initialiser plusieurs estimateurs identiques (par exemple dans un ensemble), ou pour s'assurer qu'un estimateur est dans son état initial.

```python
from sklearn.base import clone

scaler_ajuste = StandardScaler().fit(X_train)
print(f"Ajusté : {hasattr(scaler_ajuste, 'mean_')}")  # True

scaler_clone = clone(scaler_ajuste)
print(f"Cloné  : {hasattr(scaler_clone, 'mean_')}")   # False — paramètres appris supprimés
print(f"Mêmes hyperparamètres : {scaler_clone.get_params() == scaler_ajuste.get_params()}")  # True
```

```{note}
Il ne faut pas confondre `clone()` et `copy.deepcopy()`. `deepcopy` copie l'objet en entier, y compris les paramètres appris. `clone` ne copie que les hyperparamètres et retourne un objet **non ajusté**. Pour dupliquer un modèle déjà entraîné, `deepcopy` est approprié. Pour réinitialiser un modèle, `clone` est le bon outil.
```

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

# Illustration visuelle de l'architecture Pipeline
fig, ax = plt.subplots(figsize=(13, 4))
ax.set_xlim(0, 13)
ax.set_ylim(0, 4)
ax.axis('off')
ax.set_title("Architecture d'un Pipeline Scikit-learn", fontsize=13, fontweight='bold', pad=10)

etapes = [
    ("Données brutes\nX, y", "#b0bec5", 0.5),
    ("ColumnTransformer\n(imputation +\nnormalisation +\nencodage)", "#4fc3f7", 2.2),
    ("FeatureSelector\n(optionnel)", "#81c784", 5.1),
    ("Modèle\n(fit / predict)", "#ce93d8", 7.8),
    ("Prédictions\nŷ", "#ffb74d", 10.5),
]

for label, couleur, x_center in etapes:
    rect = patches.FancyBboxPatch((x_center - 1.0, 1.0), 2.0, 2.0,
        boxstyle="round,pad=0.1", linewidth=2,
        edgecolor=couleur, facecolor=couleur, alpha=0.25)
    ax.add_patch(rect)
    ax.text(x_center, 2.0, label, ha='center', va='center',
            fontsize=8.5, fontweight='bold', color='#263238',
            multialignment='center')

# Flèches
for i in range(len(etapes) - 1):
    x1 = etapes[i][2] + 1.05
    x2 = etapes[i+1][2] - 1.05
    ax.annotate('', xy=(x2, 2.0), xytext=(x1, 2.0),
                arrowprops=dict(arrowstyle='->', color='#455a64', lw=2.0))

# Étiquette Pipeline
ax.annotate('', xy=(11.55, 0.65), xytext=(1.15, 0.65),
            arrowprops=dict(arrowstyle='<->', color='#1565c0', lw=1.5))
ax.text(6.35, 0.35, "Pipeline.fit(X_train, y_train)  /  Pipeline.predict(X_test)",
        ha='center', va='center', fontsize=9, color='#1565c0', fontstyle='italic')

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

## Résumé

Ce chapitre a présenté l'architecture et les conventions de l'API Scikit-learn :

- Scikit-learn repose sur trois rôles d'interface : l'**estimateur** (`fit`), le **transformateur** (`transform`), et le **prédicteur** (`predict`). Un objet peut combiner plusieurs rôles.
- La convention `fit`/`transform`/`predict` est universelle : les paramètres appris sont stockés dans des attributs suffixés `_`, les hyperparamètres sont passés au constructeur, et `get_params()`/`set_params()` permettent leur accès et modification programmatique.
- Le **`Pipeline`** enchaîne des transformations et un prédicteur final en un seul objet cohérent. Il garantit l'absence de fuite de données lors de la validation croisée, car `fit_transform` n'est appelé que sur les données d'entraînement à chaque fold.
- **`make_pipeline`** est la syntaxe abrégée du `Pipeline` avec nommage automatique des étapes.
- **`ColumnTransformer`** permet d'appliquer des transformations différentes à des colonnes différentes, ce qui est indispensable pour les DataFrames avec des types mixtes. La combinaison `ColumnTransformer` + `Pipeline` + modèle est le schéma standard pour les projets réels.
- **`clone()`** crée une copie non ajustée d'un estimateur, utile pour réinitialiser ou dupliquer des objets.
