Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Cette leçon est le point d’orgue du pilier « données ». Les leçons précédentes vous ont appris à lire, nettoyer, transformer et décrire des données géoscientifiques. Celle-ci définit ce que veut dire, pour un jeu de données, être prêt pour l’IA, et met en scène les deux modes de défaillance qui ruinent des projets par ailleurs soignés : la fuite de données par prétraitement et un mauvais découpage pour des données autocorrélées.

Lecture Slides

1. Une définition opérationnelle des données prêtes pour l’IA

« Prêt pour l’IA » n’est pas une impression. Un jeu de données est prêt pour l’IA quand il passe cette liste de contrôle :

  1. Provenance, licence et citation documentées. D’où vient chaque octet, à qui appartient-il, et comment doit-il être cité ? Si une partie des données est synthétique, cela est déclaré.
  2. Métadonnées lisibles par la machine (une fiche de données). Un fichier qui voyage avec les données et indique quelles sont les variables, dans quelles unités, comment elles ont été collectées ou engendrées, et quelles sont les limites connues. Un README écrit pour des humains ne suffit pas ; la fiche doit être analysable par un programme (YAML, JSON).
  3. Formes bien rangées. Une ligne par observation, une colonne par variable pour les tables ; des dimensions explicites et nommées pour les tableaux (arrays). Pas d’en-têtes fusionnés, pas d’unités noyées dans des chaînes de caractères.
  4. Unités explicites. Chaque variable physique porte son unité dans les métadonnées, pas dans un commentaire que quelqu’un finira par perdre.
  5. Une politique des données manquantes. Quelles valeurs sont manquantes, pourquoi, comment elles sont encodées (NaN, pd.NA, une valeur sentinelle) et quel traitement est recommandé.
  6. Des découpages de référence livrés avec les données. L’appartenance aux ensembles d’entraînement, de validation et de test est stockée dans une colonne ou un fichier d’indices, pour que chaque utilisateur évalue sur les mêmes données mises de côté.
  7. Des garde-fous contre les fuites de données. Le découpage respecte la structure de corrélation des données (temps, espace, appartenance à un événement), et toutes les statistiques de prétraitement sont calculées sur le seul ensemble d’entraînement.
  8. Un inventaire des classes et des événements. Combien d’échantillons, combien de chaque classe, combien d’événements distincts. Un problème d’événements rares n’a rien à voir avec un problème équilibré, et les utilisateurs doivent le savoir avant d’entraîner.

Le reste de ce carnet construit un petit jeu de données qui passe la liste de contrôle, puis enfreint exprès les règles 6 et 7 pour que vous voyiez les dégâts dans les métriques.

🖥️ Diapositives du cours — Séance 10 (mer. 21 oct.)

2. Rappels de méthode : des données brutes à un jeu de données pour l’apprentissage

Avant de pouvoir satisfaire la liste de contrôle, les données doivent exister sous une forme utilisable. Un flux de travail condensé, tiré des leçons précédentes :

  1. Posez le problème en termes généraux. Transformer AA en BB ? Prédire yy à partir de XX ? Choisir une variable cible pose déjà le problème (régression, classification, détection).
  2. Établissez ce qui est réalistement possible. Passez la littérature en revue, celle de l’apprentissage automatique et l’autre. Si les experts atteignent une exactitude xx, c’est votre premier point de comparaison.
  3. Rassemblez et organisez les données tôt. Un seul emplacement, des structures lisibles par la machine (NumPy, Xarray, pandas), enregistrées dans des formats standard (Parquet, netCDF, Zarr, HDF5). N’écrasez jamais les données brutes.
  4. Caractérisez les données. Histogrammes, distributions, nuages croisés, matrices de corrélation (leçons 2.4 et 2.7). Conservez les scripts d’exploration.
  5. Envisagez l’extraction de caractéristiques et la réduction de dimension. Caractéristiques statistiques, temporelles ou spectrales (leçon 2.11) ; ACP ou ICA (leçon 2.12).
  6. Envisagez l’augmentation de données. Bootstrap, bruit synthétique, copies transformées — avec provenance déclarée (leçons 2.6 et 2.10).
  7. Rendez la chaîne de traitement reproductible. Un script ou un Pipeline sklearn qui va des données brutes au produit prêt pour l’IA sans aucune étape manuelle.
import os

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

import mlgeo_synth

rng = np.random.default_rng(2026)
os.makedirs("data/ai_ready_demo", exist_ok=True)

3. Construire le jeu de données : une série de type débit de rivière avec des crues rares

Nous engendrons dix ans d’une série journalière de type débit : un cycle saisonnier de débit de base plus du bruit, avec des crues rares injectées suivant un processus de Poisson. Comme le générateur est le nôtre, nous connaissons la vérité terrain : chaque échantillon porte une étiquette (event) et un identifiant d’événement (event_id). C’est exactement le genre de jeu de données synthétique que la leçon 2.10 jugeait recevable — nous nous en servons pour développer et évaluer une méthodologie, et nous déclarons comment il a été fabriqué.

Le problème que nous allons poser : dire si aujourd’hui est un jour de crue à partir des sept jours de débit précédents (une prévision immédiate, ou nowcasting). Les crues sont rares : c’est donc un problème de classification déséquilibré — chose courante en géosciences (crues, séismes, glissements de terrain, efflorescences algales).

n_years = 10
n_days = int(365.25 * n_years)
day = np.arange(n_days)

# Seasonal baseflow: winter-peaking annual cycle plus white noise, in m^3/s
baseflow = 25.0 + 12.0 * np.cos(2 * np.pi * (day - 20) / 365.25) + rng.normal(0, 3.0, n_days)

df = mlgeo_synth.inject_rare_events(
    baseflow,
    rate_per_year=4.0,
    duration_days=(3, 15),
    amplitude=(5.0, 60.0),
    shape="spike",
    samples_per_day=1,
    seed=2026,
)
df.index = pd.date_range("2015-01-01", periods=len(df), freq="D")
df.index.name = "date"
df = df.rename(columns={"value": "discharge_m3s"})
df.head()
Loading...
fig, ax = plt.subplots(figsize=(11, 3.5))
ax.plot(df.index, df["discharge_m3s"], lw=0.6, color="steelblue", label="discharge")
ax.fill_between(df.index, 0, df["discharge_m3s"].max(), where=df["event"] == 1,
                color="firebrick", alpha=0.3, label="flood (label = 1)")
ax.set_xlabel("date")
ax.set_ylabel("discharge (m$^3$/s)")
ax.legend(loc="upper right")
ax.set_title("Synthetic daily discharge with injected rare floods")
plt.tight_layout()
<Figure size 1100x350 with 1 Axes>
# Zoom on one event to see the shape: sharp rise, exponential recession
one_event = df[df["event_id"] == df.loc[df["event"] == 1, "event_id"].iloc[0]]
window = df.loc[one_event.index[0] - pd.Timedelta(days=15):
                one_event.index[-1] + pd.Timedelta(days=25)]
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(window.index, window["discharge_m3s"], color="steelblue")
ax.fill_between(window.index, 0, window["discharge_m3s"].max(),
                where=window["event"] == 1, color="firebrick", alpha=0.3)
ax.set_ylabel("discharge (m$^3$/s)")
ax.set_title("One flood event (shaded)")
plt.tight_layout()
<Figure size 800x300 with 1 Axes>

L’inventaire des classes et des événements

Point 8 de la liste de contrôle. Deux nombres différents comptent, et ils ne sont pas interchangeables : la fraction d’échantillons étiquetés 1, et le nombre d’événements distincts. Les plis de validation croisée se comptent en événements, pas en échantillons : 40 événements répartis en cinq plis ne laissent qu’environ 8 événements par pli, quel que soit le nombre de jours de crue.

n_events = df.loc[df["event_id"] >= 0, "event_id"].nunique()
durations = df[df["event_id"] >= 0].groupby("event_id").size()

inventory = pd.Series({
    "total samples (days)": len(df),
    "flood samples": int(df["event"].sum()),
    "flood sample fraction": round(df["event"].mean(), 4),
    "distinct flood events": n_events,
    "median event duration (days)": durations.median(),
    "max event duration (days)": int(durations.max()),
})
inventory
total samples (days) 3652.0000 flood samples 312.0000 flood sample fraction 0.0854 distinct flood events 33.0000 median event duration (days) 9.0000 max event duration (days) 15.0000 dtype: float64

4. La fiche de données

Les points 1, 2, 4, 5 et 8 de la liste de contrôle, dans un seul fichier lisible par la machine. Une fiche de données voyage avec les données — même dossier, même dépôt, versionnés ensemble. Voici la fiche de ce jeu de données, en YAML :

name: synthetic_discharge_floods
version: "1.0"
created: "2026-08-11"
description: >
  Ten years of synthetic daily river-discharge-like data with rare injected
  flood events, for teaching rare-event classification and split design.
provenance:
  synthetic: true                      # disclosed, per lesson 2.10
  generator: mlgeo_synth.inject_rare_events
  generator_params: {rate_per_year: 4.0, duration_days: [3, 15],
                     amplitude: [5.0, 60.0], shape: spike, seed: 2026}
  background: "25 + 12*cos(annual cycle) + N(0, 3.0), seed 2026"
license: CC-BY-4.0
citation: "MLGeo course materials, University of Washington, 2026 edition."
sampling:
  cadence: daily
  start: "2015-01-01"
  n_samples: 3652
variables:
  discharge_m3s: {dtype: float64, units: m^3/s, description: daily mean discharge}
  event:         {dtype: int64, units: null, description: "label: 1 = flood day"}
  event_id:      {dtype: int64, units: null, description: "flood id; -1 = background"}
  split:         {dtype: str, values: [train, val, test], description: benchmark split}
missing_data:
  policy: "no missing values by construction; real gauges gap during floods —
           document gaps, do not silently interpolate labels"
class_inventory:
  flood_sample_fraction: ~0.09
  distinct_events: ~40
splits:
  scheme: chronological
  train: "2015-01-01 to 2021-12-31"
  val:   "2022-01-01 to 2022-12-31"
  test:  "2023-01-01 to 2024-12-31"
  rule: "no event spans a split boundary check; preprocessing statistics
         from train only"
known_limitations:
  - synthetic; event shapes are idealized (linear rise, exponential decay)
  - no measurement gaps, rating-curve errors, or sensor drift

Rien ici n’est décoratif. Chaque entrée répond à une question qu’un utilisateur futur (vous compris) devrait sinon deviner.

# Ship the splits WITH the data (checklist item 6): a split column, assigned
# chronologically, then write data + card to the same folder.
df["split"] = "train"
df.loc["2022-01-01":"2022-12-31", "split"] = "val"
df.loc["2023-01-01":, "split"] = "test"
print(df["split"].value_counts())
print("\nFlood events per split:")
print(df[df["event_id"] >= 0].groupby("split", observed=True)["event_id"].nunique())
split
train    2557
test      730
val       365
Name: count, dtype: int64

Flood events per split:
split
test      6
train    22
val       5
Name: event_id, dtype: int64
data_card = """\
name: synthetic_discharge_floods
version: "1.0"
created: "2026-08-11"
description: >
  Ten years of synthetic daily river-discharge-like data with rare injected
  flood events, for teaching rare-event classification and split design.
provenance:
  synthetic: true
  generator: mlgeo_synth.inject_rare_events
  generator_params: {rate_per_year: 4.0, duration_days: [3, 15], amplitude: [5.0, 60.0], shape: spike, seed: 2026}
license: CC-BY-4.0
citation: "MLGeo course materials, University of Washington, 2026 edition."
sampling: {cadence: daily, start: "2015-01-01", n_samples: %d}
variables:
  discharge_m3s: {dtype: float64, units: m^3/s}
  event: {dtype: int64, description: "label: 1 = flood day"}
  event_id: {dtype: int64, description: "flood id; -1 = background"}
  split: {dtype: str, values: [train, val, test]}
missing_data: {policy: "no missing values by construction"}
splits: {scheme: chronological, train: "2015-2021", val: "2022", test: "2023-2024"}
""" % len(df)

df.to_csv("data/ai_ready_demo/synthetic_discharge_floods.csv")
with open("data/ai_ready_demo/data_card.yml", "w") as f:
    f.write(data_card)
sorted(os.listdir("data/ai_ready_demo"))
['data_card.yml', 'synthetic_discharge_floods.csv']
# "Machine-readable" means a script can consume it. Read the card back:
import yaml

with open("data/ai_ready_demo/data_card.yml") as f:
    card = yaml.safe_load(f)
print(card["variables"]["discharge_m3s"])
print(card["splits"])
{'dtype': 'float64', 'units': 'm^3/s'}
{'scheme': 'chronological', 'train': '2015-2021', 'val': '2022', 'test': '2023-2024'}

5. Des caractéristiques pour un classifieur de crues

Nous construisons un jeu de caractéristiques délibérément simple : le débit de chacun des sept jours précédents (caractéristiques décalées, de q_lag1 = hier à q_lag7), plus le cycle annuel encodé en sinus et cosinus du jour de l’année. Le débit du jour n’est pas une caractéristique : l’utiliser rendrait la tâche triviale (un jour de crue est un jour de fort débit). Prévoir l’instant présent à partir du passé garde le problème honnête et tient l’étiquette hors des caractéristiques. L’objet de cette leçon n’est pas le modèle — c’est ce que les découpages et le prétraitement font à l’évaluation.

feat = pd.DataFrame(index=df.index)
for lag in range(1, 8):
    feat[f"q_lag{lag}"] = df["discharge_m3s"].shift(lag)
doy = df.index.dayofyear
feat["doy_sin"] = np.sin(2 * np.pi * doy / 365.25)
feat["doy_cos"] = np.cos(2 * np.pi * doy / 365.25)
feat["event"] = df["event"]
feat["event_id"] = df["event_id"]
feat["split"] = df["split"]
feat = feat.dropna()

X = feat[[c for c in feat.columns if c.startswith(("q_lag", "doy_"))]]
y = feat["event"]
X.shape, y.mean().round(4)
((3645, 9), np.float64(0.0856))

6. Ajouter une covariable maillée : la jointure raster-station

Notre enregistrement de station est une série temporelle ponctuelle, mais les prédicteurs qui comptent pour les vrais problèmes environnementaux vivent souvent sur une grille : température et précipitations de réanalyse, humidité du sol satellitaire, température de surface de la mer, un MNT. Presque tout projet d’apprentissage automatique en environnement finit donc par effectuer la même opération — extraire les valeurs de la grille aux coordonnées de la station, puis les fusionner sur l’axe temporel de la station — et il vaut la peine de la faire une fois, soigneusement, en nommant chaque choix. Nous utilisons le champ synthétique maillé de température mensuelle du cours (mlgeo_synth.climate_field) : pas de nouveau téléchargement, et la structure du champ est connue.

Deux décalages doivent être résolus, et chacun est une décision :

  1. Espace : la station est un point ; la grille est un ensemble de centres de mailles. Maille la plus proche ou interpolation ?
  2. Temps : la grille est mensuelle ; la station est journalière. Quelle valeur mensuelle attribuer à un jour donné ?
import xarray as xr

# A 10-year synthetic monthly temperature field, wrapped with labeled coordinates
field, field_truth = mlgeo_synth.climate_field(n_lat=40, n_lon=80, n_months=120, seed=2026)
grid_time = pd.date_range("2015-01-01", periods=field.shape[0], freq="MS")
temp_grid = xr.DataArray(
    field,
    coords={"time": grid_time, "lat": field_truth["lat"], "lon": field_truth["lon"]},
    dims=("time", "lat", "lon"),
    name="t_anom_c",
    attrs={"units": "degC", "description": "synthetic monthly surface temperature anomaly"},
)

# Our (fictional) gauge location. NOTE the longitude convention: this grid runs
# 0-360; a gauge at 122.4 W is 237.6 E. Mixing 0-360 and +/-180 conventions is
# the single most common raster-join bug -- check the grid's coords first.
gauge_lat, gauge_lon = 47.6, 237.6

fig, ax = plt.subplots(figsize=(8, 4))
temp_grid.isel(time=6).plot(ax=ax, cmap="RdBu_r")
ax.plot(gauge_lon, gauge_lat, "k*", ms=16)
ax.set_title("Gridded product (one month); star = the gauge")
plt.tight_layout()
<Figure size 800x400 with 2 Axes>

Espace : .sel contre .interp

xarray fournit les deux estimateurs, en une ligne chacun : .sel(..., method="nearest") s’accroche au centre de maille le plus proche, .interp(...) interpole bilinéairement à partir des quatre mailles voisines. Pour un champ régulier comme la température, le bilinéaire est la meilleure estimation ponctuelle ; pour un raster catégoriel (occupation du sol, unité géologique), l’interpolation n’a aucun sens — moyenner « granite » et « basalte » produit une absurdité — et le plus proche voisin est le seul choix correct. Indiquez lequel vous avez utilisé : sur cette grille grossière (4,5°) les deux diffèrent, et sur de vrais problèmes à l’échelle kilométrique l’écart peut tout dominer.

t_nearest = temp_grid.sel(lat=gauge_lat, lon=gauge_lon, method="nearest")
t_bilinear = temp_grid.interp(lat=gauge_lat, lon=gauge_lon)

print(f"gauge at ({gauge_lat:.1f}N, {gauge_lon:.1f}E); "
      f"nearest cell center at ({float(t_nearest.lat):.1f}N, {float(t_nearest.lon):.1f}E)")
print(f"RMS difference nearest vs bilinear: "
      f"{float(np.sqrt(((t_nearest - t_bilinear) ** 2).mean())):.3f} degC")

fig, ax = plt.subplots(figsize=(10, 3.2))
ax.plot(grid_time, t_nearest, lw=0.8, label="nearest cell")
ax.plot(grid_time, t_bilinear, lw=0.8, label="bilinear interpolation")
ax.set_ylabel("temperature anomaly (°C)")
ax.legend()
ax.set_title("The gridded field extracted at the gauge, two ways")
plt.tight_layout()
gauge at (47.6N, 237.6E); nearest cell center at (47.4N, 238.5E)
RMS difference nearest vs bilinear: 0.037 degC
<Figure size 1000x320 with 1 Axes>

Temps : une fusion causale

La série extraite est mensuelle ; la table de caractéristiques est journalière. Le geste tentant est d’interpoler la température au pas journalier — mais une interpolation linéaire entre centres de mois utilise la valeur du mois suivant, c’est-à-dire une information du futur : une caractéristique de prévision construite ainsi fuit, exactement au sens de la section 7. La jointure causale est pandas.merge_asof(..., direction="backward") : chaque jour reçoit la valeur mensuelle disponible la plus récente et rien qui vienne du futur. C’est le bon comportement par défaut pour des caractéristiques prédictives ; une interpolation lisse convient pour une analyse rétrospective, et dans les deux cas le choix a sa place dans la fiche de données.

Nous fusionnons la covariable dans une copie de la table de caractéristiques et relançons le classifieur de crues sur les caractéristiques de la section 5, avec et sans elle, en utilisant le découpage chronologique livré et un prétraitement ajusté sur le seul ensemble d’entraînement.

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

# Station-side table of the extracted covariate
t_station = (t_bilinear.drop_vars(["lat", "lon"]).to_dataframe().reset_index()
             .rename(columns={"time": "date"}))

# Causal merge onto the daily feature table (both sorted by date)
feat_g = pd.merge_asof(feat.reset_index().sort_values("date"), t_station,
                       on="date", direction="backward").set_index("date")
print(feat_g[["q_lag1", "t_anom_c", "event", "split"]].head(3))

tr = feat_g["split"] == "train"
te = feat_g["split"] == "test"
base_cols = [c for c in feat_g.columns if c.startswith(("q_lag", "doy_"))]

for cols, label in [(base_cols, "discharge features only"),
                    (base_cols + ["t_anom_c"], "+ gridded temperature")]:
    pipe = make_pipeline(StandardScaler(),
                         LogisticRegression(max_iter=2000, class_weight="balanced"))
    pipe.fit(feat_g.loc[tr, cols], feat_g.loc[tr, "event"])
    ap = average_precision_score(feat_g.loc[te, "event"],
                                 pipe.predict_proba(feat_g.loc[te, cols])[:, 1])
    print(f"average precision, {label}: {ap:.3f}")
               q_lag1  t_anom_c  event  split
date                                         
2015-01-08  35.717825 -7.432165      0  train
2015-01-09  37.612688 -7.432165      0  train
2015-01-10  35.942248 -7.432165      0  train
average precision, discharge features only: 0.722
average precision, + gridded temperature: 0.710

Le score bouge à peine — et c’est l’issue honnête : ce champ de température synthétique n’a aucun lien causal avec les crues injectées, et une covariable correctement jointe qui ne porte aucune information sur la cible ne doit pas changer la métrique. L’objet de cette section est la jointure, désormais à l’abri des fuites, avec unités indiquées et reproductible ; sur un vrai projet, ces mêmes six lignes amènent les précipitations de réanalyse sur une station, la température de surface de la mer sur une bouée ou l’humidité du sol sur un forage, et là la covariable porte souvent la compétence prédictive.

Ce que la grille synthétique nous a épargné, et qu’une vraie ne nous épargnera pas : la reprojection entre systèmes de coordonnées de référence (rioxarray/rasterio pour tout ce qui est dans un CRS projeté), la vérification 0–360 contre ±180 en longitude signalée plus haut, le fait que la valeur d’une maille est une moyenne surfacique et non une mesure ponctuelle (une erreur de représentativité qui croît avec le relief), et la sémantique des horodatages (« 2015-01 » date-t-il le début, le milieu ou la fin de l’intervalle moyenné ?). Chacun de ces points est une entrée de fiche de données : la provenance de la colonne fusionnée — grille source, méthode d’extraction, règle de jointure temporelle — fait désormais partie de la provenance de votre jeu de données (points 1 et 2 de la liste de contrôle).

7. Fuite de données par prétraitement

Point 7 de la liste de contrôle, première partie. Chaque statistique que vous calculez à partir des données — une moyenne, un écart-type, une base ACP, une valeur d’imputation — fait partie du modèle. Si vous la calculez sur l’ensemble des données puis découpez, de l’information de la période de test a déjà coulé dans les caractéristiques sur lesquelles le modèle s’entraîne. C’est la fuite de données par prétraitement.

Nous standardisons ici les caractéristiques de deux façons :

  • Faux : ajuster StandardScaler sur toutes les données, puis découper et entraîner.
  • Juste : découper d’abord, ajuster le normalisateur sur les seules données d’entraînement et l’appliquer (figé) aux données de test.

Les crues des années de test déplacent la moyenne et la variance calculées sur l’ensemble des données : la version fausse souffle donc discrètement au modèle quelque chose sur la période de test. Nous notons avec la précision moyenne (average precision, aire sous la courbe précision-rappel), la métrique appropriée pour une classe positive rare — l’exactitude atteindrait environ 97 % pour un modèle qui ne prédit jamais de crue.

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score
from sklearn.preprocessing import StandardScaler

train_mask = feat["split"] == "train"
test_mask = feat["split"] == "test"
X_train, y_train = X[train_mask], y[train_mask]
X_test, y_test = X[test_mask], y[test_mask]

# WRONG: scaler sees the test data
scaler_leaky = StandardScaler().fit(X)
clf = LogisticRegression(max_iter=2000, class_weight="balanced")
clf.fit(scaler_leaky.transform(X_train), y_train)
ap_leaky = average_precision_score(y_test, clf.predict_proba(scaler_leaky.transform(X_test))[:, 1])

# RIGHT: scaler sees only the training data
scaler_clean = StandardScaler().fit(X_train)
clf = LogisticRegression(max_iter=2000, class_weight="balanced")
clf.fit(scaler_clean.transform(X_train), y_train)
ap_clean = average_precision_score(y_test, clf.predict_proba(scaler_clean.transform(X_test))[:, 1])

print(f"average precision, scaler fit on ALL data (leaky): {ap_leaky:.3f}")
print(f"average precision, scaler fit on TRAIN only:       {ap_clean:.3f}")
print("\nScaler means for q_lag1 differ because the test years contain floods:")
print(f"  full-data mean: {scaler_leaky.mean_[0]:.3f}   train-only mean: {scaler_clean.mean_[0]:.3f}")
average precision, scaler fit on ALL data (leaky): 0.722
average precision, scaler fit on TRAIN only:       0.722

Scaler means for q_lag1 differ because the test years contain floods:
  full-data mean: 26.002   train-only mean: 25.948

Sur ce jeu de données, les deux scores sont pour ainsi dire identiques : la série est longue, à peu près stationnaire, et un normalisateur ne porte que deux nombres par caractéristique, qu’une régression logistique peut absorber dans ses poids. Ne vous en réjouissez pas trop vite. L’écart se creuse quand les données sont courtes, quand la période de test s’éloigne de la période d’entraînement, ou quand le prétraitement est plus riche qu’une simple mise à l’échelle — bases ACP, modèles d’imputation, sélection de caractéristiques et normalisation par centiles globaux fuient tous bien davantage qu’une moyenne et un écart-type. La discipline ne coûte rien : découpez d’abord, puis ajustez chaque étape de prétraitement sur le seul ensemble d’entraînement. Dans sklearn, mettez le normalisateur et le modèle dans un même Pipeline, pour que la validation croisée réajuste automatiquement le prétraitement à l’intérieur de chaque pli.

Une fuite qui saccage la métrique : le sur-échantillonnage avant le découpage

Voici une fuite de prétraitement qui mord, et l’une des erreurs les plus fréquentes avec les événements rares. Pour combattre le déséquilibre des classes, les étudiants sur-échantillonnent souvent la classe rare — dupliquer les jours de crue jusqu’à équilibrer les classes — puis découpent aléatoirement en entraînement et test. Les copies dupliquées d’un même jour de crue atterrissent des deux côtés du découpage. Le modèle est alors testé sur des copies exactes de ses échantillons d’entraînement.

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.utils import resample

Xa, ya = X.to_numpy(), y.to_numpy()

# WRONG: balance the classes first, split second
X_pos_up, y_pos_up = resample(Xa[ya == 1], ya[ya == 1],
                              n_samples=int((ya == 0).sum()), random_state=0)
X_bal = np.vstack([Xa[ya == 0], X_pos_up])
y_bal = np.concatenate([ya[ya == 0], y_pos_up])
X_tr, X_te, y_tr, y_te = train_test_split(X_bal, y_bal, test_size=0.3,
                                          random_state=0, stratify=y_bal)
rf = RandomForestClassifier(n_estimators=200, random_state=0).fit(X_tr, y_tr)
ap_wrong = average_precision_score(y_te, rf.predict_proba(X_te)[:, 1])

# RIGHT: split first, oversample the training set only
X_tr, X_te, y_tr, y_te = train_test_split(Xa, ya, test_size=0.3,
                                          random_state=0, stratify=ya)
X_pos_up, y_pos_up = resample(X_tr[y_tr == 1], y_tr[y_tr == 1],
                              n_samples=int((y_tr == 0).sum()), random_state=0)
rf = RandomForestClassifier(n_estimators=200, random_state=0).fit(
    np.vstack([X_tr[y_tr == 0], X_pos_up]),
    np.concatenate([y_tr[y_tr == 0], y_pos_up]))
ap_right = average_precision_score(y_te, rf.predict_proba(X_te)[:, 1])

print(f"average precision, oversample BEFORE split (leaky): {ap_wrong:.3f}")
print(f"average precision, oversample train only:           {ap_right:.3f}")
average precision, oversample BEFORE split (leaky): 1.000
average precision, oversample train only:           0.848

La version qui fuit affiche un score parfait ou presque — pure fiction, produite entièrement par des échantillons dupliqués à cheval sur le découpage. Tout prétraitement qui crée, duplique, sélectionne ou transforme des échantillons en s’appuyant sur la connaissance de l’ensemble des données doit avoir lieu à l’intérieur de l’ensemble d’entraînement, jamais avant le découpage.

8. Découpages pour données autocorrélées

Point 7 de la liste de contrôle, deuxième partie, et la première source de scores gonflés en apprentissage automatique appliqué aux géosciences. Nos caractéristiques sont des fenêtres de sept jours d’une série régulière : le jour tt et le jour t+1t+1 ont six valeurs décalées sur sept en commun. Un découpage aléatoire disperse les jours d’une même crue entre entraînement et test. Le modèle « prédit » alors un jour de test parce qu’il a mémorisé ses quasi-jumeaux dans l’entraînement. Le score est réel ; la compétence, non, et elle ne se transférera pas à une crue que le modèle n’a jamais vue.

Le remède est de découper le long de la structure de corrélation :

  • TimeSeriesSplit entraîne sur le passé et teste sur le futur — le scénario de déploiement en prévision.
  • GroupKFold garde les groupes intacts : tous les jours d’une même crue restent du même côté du découpage. Nous groupons les jours de crue par event_id et les jours de fond en blocs mensuels.

Un choix délibéré de plus : nous passons de la régression logistique à une forêt aléatoire. Un modèle linéaire n’a presque aucune capacité à mémoriser des échantillons individuels : il masque donc le problème de découpage. Une forêt aléatoire, elle, mémorise volontiers les quasi-doublons — et c’est exactement cette capacité qu’un découpage aléatoire récompense. Nous exécutons la chaîne identique (normalisateur + classifieur, pour que le prétraitement se réajuste dans chaque pli) sous les trois schémas de validation croisée.

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GroupKFold, KFold, TimeSeriesSplit, cross_val_score
from sklearn.pipeline import make_pipeline

pipe = make_pipeline(StandardScaler(),
                     RandomForestClassifier(n_estimators=200, random_state=0,
                                            class_weight="balanced"))

# Groups: one group per flood event; background days grouped by 30-day block
day_index = np.arange(len(feat))
groups = np.where(feat["event_id"] >= 0,
                  feat["event_id"],
                  1000 + day_index // 30)

cv_schemes = {
    "KFold (random, shuffled)": (KFold(n_splits=5, shuffle=True, random_state=0), None),
    "TimeSeriesSplit": (TimeSeriesSplit(n_splits=5), None),
    "GroupKFold (by event)": (GroupKFold(n_splits=5), groups),
}

results = {}
for name, (cv, g) in cv_schemes.items():
    scores = cross_val_score(pipe, X, y, cv=cv, groups=g,
                             scoring="average_precision")
    results[name] = scores
    print(f"{name:28s} AP = {scores.mean():.3f} +/- {scores.std():.3f}")
KFold (random, shuffled)     AP = 0.839 +/- 0.034
TimeSeriesSplit              AP = 0.704 +/- 0.039
GroupKFold (by event)        AP = 0.734 +/- 0.053
fig, ax = plt.subplots(figsize=(7, 3.5))
ax.boxplot([results[k] for k in cv_schemes], tick_labels=list(cv_schemes))
ax.set_ylabel("average precision")
ax.set_title("The same model, three ways of splitting the same data")
plt.tight_layout()
<Figure size 700x350 with 1 Axes>

Le KFold aléatoire affiche de loin le score le plus élevé, et c’est le moins digne de confiance des trois : ses plis de test sont pleins d’échantillons quasi copiés d’échantillons d’entraînement, si bien que la forêt est notée sur des crues qu’elle a de fait déjà vues. TimeSeriesSplit et GroupKFold répondent à la question qui vous importe vraiment — le modèle détecte-t-il des crues qu’il n’a jamais vues ? — et ils y répondent de façon moins flatteuse. (TimeSeriesSplit obtient aussi le score le plus bas en partie parce que ses premiers plis n’entraînent que sur une fraction des données.)

Règles empiriques :

  • Séries temporelles → découper dans le temps (TimeSeriesSplit, ou une mise de côté chronologique comme notre colonne split livrée avec les données).
  • Données par événement (séismes, tempêtes, crues) → découper par événement (GroupKFold sur l’identifiant d’événement). Ne laissez jamais des fenêtres d’un même événement chevaucher le découpage.
  • Données spatiales → découper par région, pas par pixel ; des pixels voisins sont des quasi-doublons.
  • Dans le doute, demandez-vous : un échantillon de l’ensemble de test pourrait-il être trivialement reconstruit à partir d’échantillons de l’ensemble d’entraînement ? Si oui, le découpage est mauvais.

Voilà pourquoi le point 6 de la liste dit que les découpages sont livrés avec les données : celui qui construit le jeu de données connaît la structure de corrélation mieux que n’importe quel utilisateur en aval, et un découpage stocké rend comparables tous les scores publiés.

9. Ensembles d’entraînement, de validation et de test

Le rôle d’un ensemble d’entraînement et d’un ensemble de test

Un ensemble d’entraînement est le socle des modèles d’apprentissage automatique. Il fournit les données à partir desquelles l’algorithme apprend les motifs, les relations et les représentations nécessaires à la prédiction ou à la décision. Son but premier est de permettre au modèle de minimiser l’erreur, ou fonction de perte, en optimisant ses paramètres.

Un ensemble de test sert à évaluer la performance du modèle sur des données jamais vues. Le préparer suppose :

  1. Découpage des données : l’ensemble de test est un sous-ensemble des données, distinct de l’ensemble d’entraînement, souvent 10 à 30 % du jeu de données — choisi en tenant compte de la structure de corrélation, comme ci-dessus.
  2. Principe de mise de côté : les données de test ne doivent jamais recouvrir les données d’entraînement ou de validation, et l’on n’y touche qu’une fois, à la fin. Chaque fois que vous jetez un œil aux scores de test et modifiez le modèle en conséquence, l’ensemble de test se dégrade en ensemble de validation.
  3. Représentativité du monde réel : les données de test doivent refléter les situations où le modèle sera déployé, pour garantir une évaluation robuste de la performance.

Apprentissage automatique classique (CML) et apprentissage profond (DL)

  1. CML : les modèles sont plus simples, avec moins d’hyperparamètres à régler (les hyperparamètres sont les réglages que vous choisissez avant l’entraînement — profondeur d’arbre, taux d’apprentissage — par opposition aux paramètres que le modèle apprend des données). Un ensemble d’entraînement sert à ajuster le modèle, ensuite évalué sur l’ensemble de test. La validation croisée (comme à la section 8) évalue la performance et réduit le surapprentissage, ce qui rend souvent inutile un ensemble de validation séparé.

  2. DL : les modèles sont complexes, souvent avec des millions de paramètres, et demandent un ensemble de validation en plus des ensembles d’entraînement et de test. L’ensemble de validation sert pendant l’entraînement à régler les hyperparamètres (taux d’apprentissage, nombre de couches) et à surveiller le surapprentissage (arrêt précoce quand la perte de validation cesse de s’améliorer).

Un découpage typique en DL :

  • Ensemble d’entraînement : 70 à 80 % des données.
  • Ensemble de validation : 10 à 20 % des données.
  • Ensemble de test : 10 à 20 % des données.

L’ensemble de validation protège l’ensemble de test : le réglage des hyperparamètres consomme l’ensemble de validation, si bien que l’ensemble de test reste véritablement inconnu du modèle.

10. Cette liste de contrôle est notée

Le jeu de données prêt pour l’IA que vous construirez pour le point d’étape de votre projet sera évalué au regard de la liste de contrôle de la section 1 : provenance et licence, fiche de données, formes bien rangées et unités, politique des données manquantes, découpages de référence livrés, garde-fous contre les fuites de données, inventaire des classes et des événements. Voyez le travail du chapitre et la description du projet final.

Si vous ne devez retenir qu’une phrase de ce pilier : le découpage et le prétraitement font partie du jeu de données, ils ne sont pas une réflexion après coup sur le modèle.