Un modèle d’apprentissage profond qui fonctionne repose sur trois piliers :
- Les données d’entraînement : leur qualité, leur diversité, l’équilibre entre classes et leur niveau de bruit.
- L’architecture : la famille de fonctions que le réseau peut représenter, et combien de paramètres il dépense pour le faire.
- La stratégie d’entraînement : la fonction de perte, l’optimiseur, le taux d’apprentissage, la taille de lot, la règle d’arrêt.
La plupart des défaillances de modèles remontent à exactement un de ces piliers, et chaque pilier échoue avec une signature reconnaissable. Ce carnet est un TP pratique : nous maintenons fixe une tâche de classification et cassons chaque pilier exprès, pour que vous appreniez à lire les signatures avant de les rencontrer dans les données de votre projet.
Le TP utilise de petits perceptrons multicouches sur une table de lithologies synthétique. Chaque expérience s’exécute en quelques secondes sur le CPU d’un ordinateur portable. Vous pouvez augmenter les nombres d’échantillons et d’époques sur votre propre machine ; les leçons ne changent pas.
1. Une tâche, un petit modèle¶
La tâche pour tout le TP : classifier des échantillons de roches en trois classes lithologiques (granite, basalte, andésite) à partir de 9 caractéristiques géochimiques et physiques (oxydes d’éléments majeurs en % massique, densité en g/cm3, susceptibilité magnétique en unités SI). Les données viennent de mlgeo_synth.geochem_table, un générateur qui installe des corrélations réalistes entre oxydes via un indice de différenciation latent. Le générateur étant le nôtre, nous contrôlons exactement la quantité de bruit d’étiquetage, de déséquilibre des classes et de bruit de capteur — les boutons de réglage que les données réelles n’offrent jamais.
Un ajustement rend le TP réaliste : les tables brutes du générateur sont presque parfaitement séparables, ce qu’aucun instrument de terrain ne livre. Chaque jeu de données de ce TP porte donc un plancher fixe de bruit de mesure d’un écart-type par caractéristique, appliqué de la même façon à l’entraînement, à la validation et au test. Les expériences de corruption de la section 3 ajoutent des défauts spécifiques par-dessus ce plancher.
1.1 Ensembles d’entraînement, de validation et de test¶
Avant toute expérience, les données sont découpées en trois :
- Ensemble d’entraînement : sert à ajuster le modèle. Il doit être la plus grande portion, typiquement 60–80 % des données, et aussi divers et équilibré que le problème le permet.
- Ensemble de validation : sert pendant l’entraînement à régler les hyperparamètres et à détecter le surapprentissage. Il donne une estimation de la performance sur données inédites pendant que les décisions se prennent encore. Typiquement 10–20 %.
- Ensemble de test : utilisé une seule fois, à la fin, pour rapporter la généralisation. Il doit rester complètement à l’écart de toute décision prise pendant le développement du modèle. Si de l’information de l’ensemble de test influence l’entraînement ou la sélection du modèle, la performance rapportée est gonflée. Cette défaillance s’appelle la fuite de données, et elle réapparaît à la section 5.
Un détail compte pour ce TP : notre ensemble de test est toujours propre (pas de bruit d’étiquetage, pas de défauts au-delà du plancher partagé de bruit de mesure), même quand nous corrompons exprès les données d’entraînement. C’est ainsi que nous mesurons ce que coûte la corruption.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import confusion_matrix, recall_score
import mlgeo_synth
# Device-agnostic setup. The models in this lab are tiny (a few thousand
# parameters), and for models this small the CPU is faster than an
# accelerator because kernel-launch overhead dominates. We detect the
# accelerator to show the pattern, then deliberately train on CPU.
detected = torch.device("cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu")
device = torch.device("cpu")
print(f"accelerator detected: {detected}; using: {device}")
torch.manual_seed(0)
np.random.seed(0)accelerator detected: mps; using: cpu
df = mlgeo_synth.geochem_table(n=3000, label_noise=0.0, seed=0)
print(df.shape)
df.head()(3000, 10)
CLASSES = ["granite", "basalt", "andesite"]
FEATURES = [c for c in df.columns if c != "label"]
fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))
df["label"].value_counts().loc[CLASSES].plot.bar(ax=ax[0], color="tab:gray")
ax[0].set_ylabel("count"); ax[0].set_title("Class balance")
for c in CLASSES:
sub = df[df["label"] == c]
ax[1].scatter(sub["SIO2"], sub["MGO"], s=4, alpha=0.4, label=c)
ax[1].set_xlabel("SiO2 (wt%)"); ax[1].set_ylabel("MgO (wt%)")
ax[1].set_title("Two of the nine features"); ax[1].legend(markerscale=3)
plt.tight_layout()
Les classes sont déséquilibrées par construction (l’andésite est rare) et se recouvrent dans l’espace des caractéristiques. Ces deux propriétés sont typiques des problèmes de classification géochimique.
1.2 Les outils du TP¶
Trois morceaux de code portent tout le TP : une fonction de données avec des boutons de corruption, un MLP configurable et une boucle d’entraînement qui enregistre les courbes d’apprentissage. Lisez-les une fois, attentivement ; chaque expérience ci-dessous tient en quelques lignes qui les appellent.
def table_to_xy(frame):
X = frame[FEATURES].to_numpy(dtype=np.float32)
y = frame["label"].map({c: i for i, c in enumerate(CLASSES)}).to_numpy()
return X, y.astype(np.int64)
def make_data(n_train=3000, label_noise=0.0, minority_keep=1.0,
feature_noise=0.0, base_noise=1.0, relabel=None,
hetero_sigma=None, seed=0):
'''Train/val splits with optional corruption; test set always clean.
label_noise : fraction of flipped labels in train+val (uniform flips)
minority_keep : fraction of andesite (minority) samples kept in train+val
feature_noise : std of EXTRA Gaussian noise added to standardized features
(sensor degradation, in units of one feature std)
base_noise : the shared measurement-noise floor on all splits
relabel : optional function frame -> frame applied after generation,
for structured label errors (Section 3.1)
hetero_sigma : optional tuple of noise levels; each TRAINING sample draws
its noise floor from these instead of base_noise, and the
per-sample sigma comes back as a 7th element (Section 3.3)
'''
frame = mlgeo_synth.geochem_table(n=n_train, label_noise=label_noise, seed=seed)
if relabel is not None:
frame = relabel(frame)
if minority_keep < 1.0:
minority = frame[frame["label"] == "andesite"]
drop = minority.sample(frac=1.0 - minority_keep, random_state=seed).index
frame = frame.drop(index=drop)
X, y = table_to_xy(frame)
Xtr, Xva, ytr, yva = train_test_split(X, y, test_size=0.25,
random_state=seed, stratify=y)
# fixed clean test set, drawn from the same generator with a held seed
Xte, yte = table_to_xy(mlgeo_synth.geochem_table(n=1500, label_noise=0.0, seed=987))
scaler = StandardScaler().fit(Xtr) # fit on train only
Xtr, Xva, Xte = (scaler.transform(a) for a in (Xtr, Xva, Xte))
rngs = [np.random.default_rng(1000 * seed + k) for k in range(3)]
# measurement-noise floor on every split; extra sensor noise on top
if hetero_sigma is None:
Xtr = Xtr + rngs[0].normal(0, 1, Xtr.shape) * np.hypot(base_noise, feature_noise)
else: # per-sample noise floor on the training split
sigma_tr = rngs[0].choice(hetero_sigma, size=len(Xtr)).astype(np.float32)
Xtr = Xtr + rngs[0].normal(0, 1, Xtr.shape) * sigma_tr[:, None]
Xva = Xva + rngs[1].normal(0, 1, Xva.shape) * np.hypot(base_noise, feature_noise)
Xte = Xte + rngs[2].normal(0, 1, Xte.shape) * base_noise
t = lambda a: torch.as_tensor(np.ascontiguousarray(a), dtype=torch.float32)
out = (t(Xtr), torch.as_tensor(ytr), t(Xva), torch.as_tensor(yva),
t(Xte), torch.as_tensor(yte))
if hetero_sigma is not None:
out = out + (torch.as_tensor(sigma_tr),)
return out
class MLP(nn.Module):
def __init__(self, width=32, depth=2, dropout=0.0, n_in=9, n_out=3):
super().__init__()
layers, d = [], n_in
for _ in range(depth):
layers += [nn.Linear(d, width), nn.ReLU()]
if dropout > 0:
layers.append(nn.Dropout(dropout))
d = width
layers.append(nn.Linear(d, n_out)) # raw logits; softmax lives in the loss
self.net = nn.Sequential(*layers)
def forward(self, x):
return self.net(x)
def count_params(model):
return sum(p.numel() for p in model.parameters())loss_fn = nn.CrossEntropyLoss()
def accuracy(model, X, y):
model.eval()
with torch.no_grad():
return (model(X.to(device)).argmax(1).cpu() == y).float().mean().item()
def train_model(model, data, epochs=25, lr=1e-3, batch_size=256,
weight_decay=0.0, optimizer="adam", scheduler=None, seed=0):
'''Minibatch training; returns a history dict of per-epoch curves.'''
Xtr, ytr, Xva, yva, Xte, yte = data
model.to(device)
opt_cls = {"adam": torch.optim.Adam, "sgd": torch.optim.SGD}[optimizer]
opt = opt_cls(model.parameters(), lr=lr, weight_decay=weight_decay)
sched = scheduler(opt) if scheduler is not None else None
n = len(Xtr)
hist = {k: [] for k in ["train_loss", "val_loss", "train_acc", "val_acc", "lr"]}
g = torch.Generator().manual_seed(seed)
for _ in range(epochs):
model.train()
for i in range(0, n, batch_size):
idx = torch.randperm(n, generator=g)[:batch_size] if batch_size < n else slice(None)
opt.zero_grad()
loss = loss_fn(model(Xtr[idx].to(device)), ytr[idx].to(device))
loss.backward()
opt.step()
model.eval()
with torch.no_grad():
hist["train_loss"].append(loss_fn(model(Xtr.to(device)), ytr.to(device)).item())
hist["val_loss"].append(loss_fn(model(Xva.to(device)), yva.to(device)).item())
hist["train_acc"].append(accuracy(model, Xtr, ytr))
hist["val_acc"].append(accuracy(model, Xva, yva))
hist["lr"].append(opt.param_groups[0]["lr"])
if sched is not None:
sched.step()
return hist
def plot_history(hist, ax=None, title="", logy=False):
'''Train/validation loss curves for one run.'''
if ax is None:
_, ax = plt.subplots(figsize=(5, 3))
ax.plot(hist["train_loss"], label="train")
ax.plot(hist["val_loss"], label="validation")
if logy:
ax.set_yscale("log")
ax.set_xlabel("epoch"); ax.set_ylabel("cross-entropy loss")
ax.set_title(title); ax.legend(); ax.grid(alpha=0.3)
return axUne remarque sur la boucle d’entraînement : elle tire un mini-lot (minibatch) aléatoire frais à chaque pas (descente de gradient stochastique par mini-lots, section 2.4) et enregistre les pertes d’entraînement et de validation une fois par époque, évaluées en mode eval() sur les découpages complets. Ces deux courbes par exécution sont l’instrument de diagnostic de tout le TP.
2. Ce que l’entraînement minimise¶
Avant les expériences, le vocabulaire. Une fonction de perte mesure l’écart entre les prédictions du modèle et les étiquettes. Elle fournit le nombre unique que la descente de gradient pousse vers le bas : son choix façonne donc tout ce que le réseau apprend. Les fonctions de perte quantifient les résidus entre les étiquettes de vérité terrain et les prédictions , et elles doivent être différentiables par rapport aux paramètres du modèle .
2.1 Pertes pour la régression¶
L’erreur quadratique moyenne (MSE) pénalise quadratiquement les grandes erreurs. C’est le choix naturel quand les erreurs sont proches d’une gaussienne :
L’erreur absolue moyenne (MAE) pénalise moins les grandes erreurs, ce qui convient aux distributions d’erreurs à queues lourdes. Son gradient a une amplitude constante, ce qui peut ralentir la convergence près du minimum ; les optimiseurs adaptatifs comme Adam atténuent ce défaut :
La perte de Huber bascule entre les deux : quadratique pour les erreurs plus petites qu’un seuil δ (gradients lisses près du minimum), linéaire au-delà (les valeurs aberrantes ne dominent pas).
2.2 Pertes pour la classification¶
Les classificateurs binaires produisent une probabilité via la sigmoïde logistique et s’entraînent avec l’entropie croisée binaire :
Pour classes, le réseau produit un score par classe et le softmax convertit les scores en probabilités :
avec l’entropie croisée multi-classes comme perte. nn.CrossEntropyLoss de PyTorch applique le softmax en interne : c’est pourquoi notre MLP renvoie des logits (les scores bruts, avant softmax).
2.3 Pertes adaptées aux géosciences¶
Les pertes standard passent à côté d’objectifs qui comptent dans notre domaine. Comme toute fonction différentiable des prédictions peut devenir un terme de perte, nous pouvons encoder ces objectifs directement :
Pondération de classes pour les événements rares. Séismes, glissements de terrain et signaux d’éboulements sont des classes rares ; pondérer davantage leurs erreurs (
nn.CrossEntropyLoss(weight=...)) empêche l’optimiseur de les ignorer. La section 3.2 montre pourquoi cela compte.Similarité de formes d’onde. Un réseau qui reconstruit des sismogrammes doit coller oscillation par oscillation. Ajouter un terme en coefficient de corrélation récompense un alignement de phase que la MSE voit à peine :
Contraintes physiques. Quand la sortie est un champ physique, un terme de perte peut pénaliser les violations d’une équation d’évolution (conservation de la masse, résidu d’une équation de la chaleur). C’est la base des réseaux de neurones informés par la physique, carnet 4.7.
Pertes sensibles à l’incertitude. Les prévisions probabilistes s’entraînent avec la log-vraisemblance négative, qui pénalise à la fois l’excès et le défaut de confiance.
2.4 La descente de gradient et le taux d’apprentissage¶
L’entraînement met à jour les paramètres dans la direction qui réduit la perte :
où α est le taux d’apprentissage, l’hyperparamètre le plus lourd de conséquences de ce carnet.
![]() | ![]() |
|---|---|
| Descente de gradient sur une perte convexe et bien conditionnée. | Une perte mal conditionnée, à minimums locaux. |
![]() | ![]() |
|---|---|
| α trop petit : la convergence rampe. | α trop grand : les pas dépassent le minimum. |
Trois variantes diffèrent par la quantité de données vue à chaque mise à jour :
- La descente de gradient par lots utilise tout l’ensemble d’entraînement à chaque pas : gradients exacts, pas coûteux.
- La descente de gradient stochastique (SGD) utilise un échantillon par pas : mises à jour bon marché et bruitées, sensibles à la mise à l’échelle des caractéristiques.
- La descente de gradient par mini-lots utilise un petit sous-ensemble aléatoire par pas. C’est le standard en apprentissage profond, et ce que fait notre
train_model. La taille de lot arbitre entre bruit du gradient et coût du pas ; la section 5.2 en mesure l’effet.
Les optimiseurs adaptatifs (Adam, RMSProp) remettent à l’échelle le pas de chaque paramètre à l’aide de statistiques glissantes du gradient. Adam est le choix par défaut de ce cours.
2.5 Biais, variance, sous-apprentissage, surapprentissage¶
L’erreur de généralisation se décompose en trois parts :
- Biais : l’erreur due à de mauvaises hypothèses de modèle (ajuster une droite à une courbe). Un biais élevé se manifeste par une perte élevée sur les données d’entraînement elles-mêmes.
- Variance : l’erreur due à une sensibilité excessive aux échantillons d’entraînement particuliers. Une variance élevée se manifeste par un écart entre pertes d’entraînement et de validation.
- Erreur irréductible : le bruit des données. Aucun modèle ne l’élimine ; seules de meilleures données le font (réparer les capteurs, retirer les valeurs aberrantes — pilier 1).
Sous-apprentissage = biais élevé : les pertes d’entraînement et de validation plafonnent toutes deux à une valeur élevée. Remèdes : modèle plus gros, meilleures caractéristiques, moins de régularisation.
Surapprentissage = variance élevée : la perte d’entraînement continue de baisser pendant que la perte de validation stagne puis remonte. Le modèle a commencé à mémoriser le bruit. Remèdes : plus de données, un modèle plus petit, de la régularisation, un arrêt précoce.
Une règle à mémoriser : vous ne savez pas si vous pouvez surapprendre tant que vous ne l’avez pas fait. Faites grossir le modèle jusqu’au surapprentissage, puis reculez ou régularisez. Les deux signatures apparaissent sur de vraies courbes à la section 5.
2.6 Régularisation¶
La régularisation contraint un modèle flexible à se comporter plus simplement :
- Pénalité L2 (ridge) : ajouter à la perte, ce qui rétrécit tous les poids. Dans les optimiseurs PyTorch, c’est l’argument
weight_decay. - Pénalité L1 (lasso) : ajouter , qui pousse les poids sans importance exactement à zéro — une forme de sélection de caractéristiques.
- Elastic net : une combinaison pondérée des deux.
- Dropout (extinction aléatoire de neurones) : mettre aléatoirement à zéro une fraction des activations pendant l’entraînement, pour qu’aucune unité ne puisse compter sur des partenaires précis. Utilisé au carnet 4.2 et disponible comme bouton de réglage dans notre
MLP. - Arrêt précoce : arrêter l’entraînement quand la perte de validation cesse de s’améliorer (section 5.3).
- Augmentation de données : agrandir et diversifier l’ensemble d’entraînement avec des copies transformées (décalages, bruit ajouté). Elle régularise par le pilier des données plutôt que par le modèle.
3. Pilier 1 — Curation des données d’entraînement¶
La qualité d’un modèle commence dans les données, et aucune architecture ni aucun optimiseur ne sauve un ensemble d’entraînement corrompu. Notre générateur contrôlant exactement la corruption, nous pouvons mesurer ce que coûte chaque défaut. Dans chaque expérience, l’ensemble de test reste propre : les courbes isolent donc l’effet de la qualité des données d’entraînement.
3.1 Bruit d’étiquetage¶
Les vraies étiquettes sont fausses plus souvent qu’on n’aime l’admettre : les pointés d’analystes divergent, les catalogues héritent d’erreurs historiques, les classifications de terrain sont révisées. Nous inversons au hasard une fraction des étiquettes d’entraînement et réentraînons — dans deux régimes de données, parce que la réponse dépend de la quantité de données dont vous disposez. Chaque point est une moyenne sur trois graines.
noise_levels = [0.0, 0.05, 0.15, 0.30]
regimes = {"3000 training samples": 3000, "600 training samples": 600}
sweep = {} # regime -> array of shape (noise levels, seeds)
for name, n in regimes.items():
curves = []
for p in noise_levels:
accs = []
for s in [1, 2, 3]:
data = make_data(n_train=n, label_noise=p, seed=s)
torch.manual_seed(s)
model = MLP(width=128, depth=2)
train_model(model, data, epochs=50, batch_size=128, seed=s)
accs.append(accuracy(model, *data[4:6]))
curves.append(accs)
print(f"n={n:4d} label noise {p:4.0%} -> clean-test accuracy "
f"{np.mean(accs):.3f} (seeds span {np.min(accs):.3f}-{np.max(accs):.3f})")
sweep[name] = np.array(curves)
plt.figure(figsize=(5.5, 3.4))
x = np.array(noise_levels) * 100
for name, curves in sweep.items():
line, = plt.plot(x, curves.mean(axis=1), "o-", label=name)
plt.fill_between(x, curves.min(axis=1), curves.max(axis=1),
color=line.get_color(), alpha=0.2)
plt.xlabel("label noise in training data (%)")
plt.ylabel("accuracy on clean test set")
plt.legend(); plt.grid(alpha=0.3)
plt.title("What wrong labels cost depends on data volume\n(bands: min-max over 3 seeds)")
plt.tight_layout()
n=3000 label noise 0% -> clean-test accuracy 0.945 (seeds span 0.943-0.946)
n=3000 label noise 5% -> clean-test accuracy 0.933 (seeds span 0.929-0.936)
n=3000 label noise 15% -> clean-test accuracy 0.929 (seeds span 0.911-0.939)
n=3000 label noise 30% -> clean-test accuracy 0.910 (seeds span 0.899-0.919)
n= 600 label noise 0% -> clean-test accuracy 0.936 (seeds span 0.933-0.940)
n= 600 label noise 5% -> clean-test accuracy 0.933 (seeds span 0.925-0.941)
n= 600 label noise 15% -> clean-test accuracy 0.921 (seeds span 0.913-0.928)
n= 600 label noise 30% -> clean-test accuracy 0.866 (seeds span 0.851-0.887)

Deux régimes, deux histoires. Avec 3 000 échantillons d’entraînement, même 30 % d’étiquettes inversées au hasard coûtent peu : l’entropie croisée fait la moyenne sur de nombreux échantillons, les inversions sont symétriques, et la majorité correcte suffit encore à fixer les frontières de classes. Avec 600 échantillons, le même modèle a assez de capacité pour mémoriser les étiquettes inversées au lieu de les moyenner, et l’exactitude perd plusieurs points. Le bruit d’étiquetage est le plus dangereux exactement là où vivent d’ordinaire les géosciences : de petits jeux étiquetés et des modèles assez grands pour les mémoriser. Les bandes ombrées sont l’étendue min-max sur les trois graines — la variabilité d’une exécution à l’autre qu’une expérience à graine unique cache. Lisez les bandes avant de croire une différence entre deux points : là où les bandes se recouvrent, la différence peut être un hasard d’initialisation, pas le bouton que vous avez tourné. Ici, l’étendue n’est pas décorative : à 3 000 échantillons, les trois graines couvrent jusqu’à 0,028 (à 15 % de bruit) — l’essentiel des 0,035 qui séparent les moyennes propre et à 30 % de bruit — si bien qu’une version à graine unique de cette figure aurait pu raconter à peu près n’importe quelle histoire. Chaque chiffre de synthèse de ce TP issu d’exécutions répétées est désormais rapporté avec son étendue sur les graines.
Ce qu’il faut chercher dans les données réelles. Une exactitude de validation qui plafonne sous ce que suggère le recouvrement des classes est un symptôme de qualité d’étiquetage, pas un problème d’architecture. Pour le détecter : inspectez les échantillons que le modèle rate avec le plus d’assurance (ils sont souvent mal étiquetés, pas mal classés), faites réétiqueter un sous-ensemble aléatoire par un second analyste et mesurez l’accord, et croisez les étiquettes avec des catalogues indépendants. Et rappelez-vous que le bruit qui fait des dégâts en pratique est rarement symétrique : un analyste qui confond systématiquement deux types de signaux biaise la frontière elle-même, ce qui est bien pire que des inversions aléatoires — l’expérience suivante mesure exactement cela. Corriger un millier d’étiquettes bat généralement l’ajout d’un million de paramètres.
3.2 Déséquilibre des classes¶
L’andésite est déjà la classe rare. Nous la raréfions encore et surveillons le rappel par classe, pas l’exactitude globale.
Désaccord structuré : quand les erreurs ont un motif¶
Les inversions aléatoires uniformes sont l’erreur d’étiquetage la plus clémente possible, et l’expérience ci-dessus montre pourquoi : avec assez de données, elles disparaissent en moyenne. Les vraies erreurs d’étiquettes viennent de personnes, et les personnes divergent selon des motifs. Nous simulons une campagne de terrain cartographiée par deux géologues qui se sont partagé la zone. Chacun étiquette mal 30 % de ses échantillons — le même taux d’erreur total que la plus dure des expériences uniformes ci-dessus — mais leurs confusions ne sont jamais aléatoires : le granite glisse vers l’andésite, le basalte glisse vers l’andésite, l’andésite vers l’un ou l’autre de ses voisins. Personne n’appelle jamais un basalte un granite. La confusion se concentre entre lithologies adjacentes sur l’axe de différenciation, ce qui est exactement le comportement du désaccord cartographique réel.
Même régime à 3 000 échantillons, même modèle, même budget d’entraînement ; seul le motif d’erreur change.
def two_mappers(rate, seed):
'''Relabeling by two simulated mappers whose errors are only ever between
adjacent lithologies (granite <-> andesite <-> basalt).'''
confusion = {
"A": {"granite": "andesite", "andesite": "granite", "basalt": "andesite"},
"B": {"granite": "andesite", "andesite": "basalt", "basalt": "andesite"},
}
def relabel(frame):
rng = np.random.default_rng(seed)
out = frame.copy()
labels = out["label"].to_numpy().copy()
is_B = rng.random(len(out)) < 0.5 # who logged each sample
err = rng.random(len(out)) < rate
for k in np.where(err)[0]:
labels[k] = confusion["B" if is_B[k] else "A"][labels[k]]
out["label"] = labels
return out
return relabel
rate = 0.30
adj_accs, confmats = [], []
for s in [1, 2, 3]:
data = make_data(n_train=3000, relabel=two_mappers(rate, seed=s), seed=s)
torch.manual_seed(s)
model = MLP(width=128, depth=2)
train_model(model, data, epochs=50, batch_size=128, seed=s)
adj_accs.append(accuracy(model, *data[4:6]))
model.eval()
with torch.no_grad():
pred = model(data[4].to(device)).argmax(1).cpu().numpy()
confmats.append(confusion_matrix(data[5].numpy(), pred, labels=[0, 1, 2]))
uniform_accs = sweep["3000 training samples"][noise_levels.index(0.30)]
clean_accs = sweep["3000 training samples"][noise_levels.index(0.0)]
for name, accs in [("no label errors", clean_accs),
("30% uniform flips", uniform_accs),
("30% adjacent-class disagreement", adj_accs)]:
print(f"{name:32s}: clean-test accuracy {np.mean(accs):.3f} "
f"(seeds span {np.min(accs):.3f}-{np.max(accs):.3f})")
cm = np.mean(confmats, axis=0)
cm = cm / cm.sum(axis=1, keepdims=True) # row-normalized (recall)
fig, ax = plt.subplots(figsize=(4.4, 3.6))
im = ax.imshow(cm, cmap="Blues", vmin=0, vmax=1)
ax.set_xticks(range(3)); ax.set_xticklabels(CLASSES)
ax.set_yticks(range(3)); ax.set_yticklabels(CLASSES)
for r in range(3):
for c_ in range(3):
ax.text(c_, r, f"{cm[r, c_]:.2f}", ha="center", va="center",
color="white" if cm[r, c_] > 0.5 else "black")
ax.set_xlabel("predicted"); ax.set_ylabel("true")
ax.set_title("Clean-test confusion after training on\nadjacent-class disagreement (30%)")
plt.colorbar(im, ax=ax, label="fraction of true class")
plt.tight_layout()
no label errors : clean-test accuracy 0.945 (seeds span 0.943-0.946)
30% uniform flips : clean-test accuracy 0.910 (seeds span 0.899-0.919)
30% adjacent-class disagreement : clean-test accuracy 0.820 (seeds span 0.799-0.850)

Même taux d’erreur, trois fois plus de dégâts : 30 % d’inversions uniformes coûtent 3,5 points d’exactitude (de 0,945 à 0,910), 30 % de désaccord entre classes adjacentes en coûtent 12,5 (de 0,945 à 0,820), et aucune graine ne s’approche de combler l’écart (meilleure graine : 0,850). La matrice de confusion montre où sont partis les points. Les deux cartographes déversent leurs erreurs dans « andésite » : le modèle a donc appris une classe andésite qui annexe les zones frontalières de ses voisines : 14 % des vrais granites et 16 % des vrais basaltes reviennent maintenant « andésite », tandis que le rappel de la vraie andésite tombe à 0,61. Et cela dans le régime à 3 000 échantillons — celui où les inversions uniformes étaient presque gratuites.
Ce résultat compte bien au-delà de ce carnet, alors le voici sans le code. Chaque étiquette d’un jeu de données géoscientifique est une interprétation, pas une mesure. Une carte géologique enregistre ce qu’un cartographe a conclu d’affleurements, de blocs épars et d’arbitrages aux contacts ; un pointé de phase sismique enregistre l’endroit où un analyste a décidé que l’onde arrivait ; un inventaire de glissements de terrain enregistre les formes de versant qu’un interprète a acceptées comme ruptures. Envoyez indépendamment deux experts qualifiés sur le même terrain, les mêmes formes d’onde ou les mêmes images, et ils divergeront — le plus souvent entre catégories adjacentes : granodiorite contre granite, Pn contre P, vieux glissement contre moraine bosselée. Ce désaccord est mesurable (cartographiez deux fois un sous-ensemble, pointez deux fois une journée d’enregistrements) et atteint couramment un pourcentage à deux chiffres aux frontières de catégories.
Le désaccord structuré fait plus mal que l’erreur aléatoire parce qu’il vote de façon cohérente. Mille inversions aléatoires s’éparpillent dans toutes les directions et s’annulent ; mille confusions entre classes adjacentes poussent toutes la même frontière de classe dans le même sens, si bien que davantage de données rend le modèle plus confiant dans l’erreur partagée des cartographes, pas moins. Conséquence pratique : l’accord inter-annotateurs est un plafond pour l’exactitude que tout modèle peut démontrer, parce que le modèle est noté sur des étiquettes qui portent le désaccord. Avant de passer un mois sur les architectures, passez une journée à mesurer la fréquence à laquelle deux experts s’accordent sur vos étiquettes — ce chiffre vous dit quand cesser de régler et commencer à réétiqueter.
keep_fractions = [1.0, 0.5, 0.2, 0.05]
rows = []
for keep in keep_fractions:
data = make_data(minority_keep=keep, seed=2)
torch.manual_seed(2)
model = MLP(width=32, depth=2)
train_model(model, data, epochs=25, seed=2)
Xte, yte = data[4], data[5]
model.eval()
with torch.no_grad():
pred = model(Xte.to(device)).argmax(1).cpu().numpy()
rec = recall_score(yte.numpy(), pred, average=None, labels=[0, 1, 2])
rows.append({"minority kept": keep, "overall acc": (pred == yte.numpy()).mean(),
**{f"recall {c}": r for c, r in zip(CLASSES, rec)}})
imbalance = pd.DataFrame(rows).set_index("minority kept")
print(imbalance.round(3))
imbalance[[f"recall {c}" for c in CLASSES]].plot(marker="o", figsize=(5.5, 3.2))
plt.xlabel("fraction of andesite samples kept in training")
plt.ylabel("per-class recall on clean test set")
plt.grid(alpha=0.3); plt.gca().invert_xaxis()
plt.title("Overall accuracy hides the collapse")
plt.tight_layout() overall acc recall granite recall basalt recall andesite
minority kept
1.00 0.946 0.972 0.967 0.735
0.50 0.949 0.984 0.975 0.677
0.20 0.915 0.984 0.981 0.329
0.05 0.881 0.984 0.981 0.000

Lisez les deux colonnes en regard l’une de l’autre. L’exactitude globale dérive de quelques points vers le bas — rien d’alarmant sur un tableau de bord. Le rappel de l’andésite s’effondre à zéro : le modèle a complètement cessé de prédire la classe. Notez aussi que même à pleines données, la classe rare part désavantagée (rappel de 0,74 contre 0,97 pour les classes majoritaires). C’est la défaillance silencieuse la plus courante en classification géoscientifique, où la classe rare (l’éruption, l’événement induit, le glissement de terrain) est celle qui vous importe.
Ce qu’il faut chercher dans les données réelles. Rapportez toujours la matrice de confusion complète et le rappel par classe ; jamais l’exactitude seule. Si la classe minoritaire compte, utilisez des poids de classes dans la perte, suréchantillonnez-la ou générez des exemples minoritaires augmentés — et vérifiez que le correctif se voit dans le rappel, pas seulement dans la perte.
3.3 Bruit de capteur sur les caractéristiques¶
Cette fois les étiquettes sont justes mais l’instrument se dégrade : nous ajoutons du bruit gaussien supplémentaire aux caractéristiques d’entraînement et de validation, par-dessus le plancher de mesure partagé de 1,0σ, en unités d’un écart-type de caractéristique. L’ensemble de test garde le plancher normal — cela simule un entraînement sur les données d’un capteur pire que celui que vous déployez.
sensor_levels = [0.0, 2.0, 4.0, 8.0]
sensor_accs = []
for s in sensor_levels:
data = make_data(feature_noise=s, seed=3)
torch.manual_seed(3)
model = MLP(width=64, depth=2)
train_model(model, data, epochs=25, seed=3)
sensor_accs.append(accuracy(model, *data[4:6]))
print(f"extra sensor noise {s:.1f} sigma -> clean-test accuracy {sensor_accs[-1]:.3f}")
plt.figure(figsize=(5, 3.2))
plt.plot(sensor_levels, sensor_accs, "o-")
plt.xlabel("extra feature noise std (in units of feature std)")
plt.ylabel("accuracy on clean test set")
plt.grid(alpha=0.3); plt.title("Measurement noise erodes class separation")
plt.tight_layout()extra sensor noise 0.0 sigma -> clean-test accuracy 0.950
extra sensor noise 2.0 sigma -> clean-test accuracy 0.928
extra sensor noise 4.0 sigma -> clean-test accuracy 0.876
extra sensor noise 8.0 sigma -> clean-test accuracy 0.785

Dès que le bruit ajouté atteint quelques fois la dispersion naturelle de chaque caractéristique, les amas de classes se fondent les uns dans les autres et l’exactitude glisse vers le hasard. Aucun modèle ne récupère une séparation que les mesures ne contiennent plus — c’est le terme d’erreur irréductible de la section 2.5 rendu visible.
Ce qu’il faut chercher dans les données réelles. Estimez l’incertitude de mesure par caractéristique (mesures répétées, spécifications de l’instrument, registres de calibration) et comparez-la à la séparation entre classes de cette caractéristique. Les caractéristiques dont le bruit dépasse la séparation de classes n’ajoutent aucun signal ; les nettoyer ou les abandonner relève de la curation des données, pas de la défaite.
3.4 Combien de données suffisent ?¶
Le diagnostic classique de la courbe d’apprentissage trace la performance en fonction de la taille de l’ensemble d’entraînement. C’est la fonction à réutiliser chaque fois qu’on vous demande « davantage de données aideraient-elles ? » — une question qui se mesure mieux qu’elle ne se débat.
Qualité hétéroscédastique : quand vous savez à quel point chaque échantillon est bruité¶
Le balayage ci-dessus dégradait chaque échantillon également. Les vraies tables mélangent les provenances : la moitié de votre géochimie vient d’un XRF de laboratoire à l’erreur analytique serrée, l’autre moitié d’un appareil de terrain portable plusieurs fois pire — et les métadonnées disent en général laquelle est laquelle, parce que l’incertitude analytique est rapportée par échantillon. (Les flux de capteurs font la même chose dans le temps : mlgeo_synth.degrade_series renvoie une colonne sigma_mm par échantillon exactement pour cette raison.) Un bruit dont la variance change d’un échantillon à l’autre est dit hétéroscédastique, et c’est la moitié aléatoire du vocabulaire de l’incertitude de la leçon 3.9 : un aléa de la mesure elle-même, qu’aucune quantité de données d’entraînement n’élimine — mais que vous pouvez refuser de traiter comme vérité.
L’entropie croisée standard traite chaque échantillon comme également fiable. Si la variance est connue, le correctif consiste à pondérer la perte de chaque échantillon par l’inverse de sa variance (), l’analogue en classification des moindres carrés pondérés. Nous mesurons ce que coûte d’ignorer les métadonnées et ce que les poids récupèrent : chaque échantillon d’entraînement tire son plancher de bruit dans {0,5 ; 4,0} écarts-types avec probabilité égale (qualité laboratoire ou qualité terrain), et le même modèle s’entraîne une fois sans les poids et une fois avec.
mix = (0.5, 4.0) # each training sample: lab-grade or field-grade noise
def train_weighted(model, data, weights, epochs=25, lr=1e-3, batch_size=256, seed=0):
'''Minibatch training with a per-sample weight inside the cross-entropy.'''
Xtr, ytr = data[0], data[1]
model.to(device)
opt = torch.optim.Adam(model.parameters(), lr=lr)
per_sample = nn.CrossEntropyLoss(reduction="none")
g = torch.Generator().manual_seed(seed)
n = len(Xtr)
for _ in range(epochs):
model.train()
for i in range(0, n, batch_size):
idx = torch.randperm(n, generator=g)[:batch_size]
opt.zero_grad()
loss = (per_sample(model(Xtr[idx].to(device)), ytr[idx].to(device))
* weights[idx].to(device)).mean()
loss.backward()
opt.step()
hetero_accs = {}
for scheme in ["ignore the metadata", "inverse-variance weights"]:
accs = []
for s in [1, 2, 3]:
*data6, sigma = make_data(n_train=3000, hetero_sigma=mix, seed=s)
w = 1.0 / sigma**2
w = w / w.mean() # normalize so the lr keeps its meaning
torch.manual_seed(s)
model = MLP(width=64, depth=2)
if scheme == "ignore the metadata":
train_model(model, tuple(data6), epochs=25, seed=s)
else:
train_weighted(model, tuple(data6), w, epochs=25, seed=s)
accs.append(accuracy(model, *data6[4:6]))
hetero_accs[scheme] = accs
print(f"{scheme:26s}: clean-test accuracy {np.mean(accs):.3f} "
f"(seeds span {np.min(accs):.3f}-{np.max(accs):.3f})")
print(f"{'all lab-grade (reference)':26s}: clean-test accuracy {sensor_accs[0]:.3f}"
" (the sensor sweep at 0 extra noise)")
ignore the metadata : clean-test accuracy 0.933 (seeds span 0.932-0.935)
inverse-variance weights : clean-test accuracy 0.946 (seeds span 0.939-0.951)
all lab-grade (reference) : clean-test accuracy 0.950 (the sensor sweep at 0 extra noise)
La table de qualité mixte coûte 1,7 point face à la référence tout-laboratoire (0,933 contre 0,950). Rendre au modèle la variance qu’il avait déjà dans les métadonnées en récupère 1,3 (0,946) — aucune donnée nouvelle, une seule ligne de code de perte — et les bandes de graines des deux schémas ne se recouvrent pas (0,932–0,935 contre 0,939–0,951) : le gain n’est pas un hasard d’initialisation.
Ce qu’il faut chercher dans les données réelles. Toute colonne nommée uncertainty, std_err, quality ou pick_weight est une invitation à pondérer la perte — catalogues sismiques, bases de données géochimiques et solutions GNSS en livrent tous une, et la plupart des chaînes de traitement la jettent à l’ingestion. La sous-pondération est aussi l’alternative honnête au raccourci tentant qui consiste à supprimer la moitié bruitée : les échantillons de qualité terrain portent encore du signal, simplement moins par échantillon, et les poids en fixent le juste prix.
def plot_learning_curves(sizes, width=32, depth=2, epochs=25, seed=4):
'''Test accuracy as a function of training-set size (Pillar 1 diagnostic).'''
accs = []
for n in sizes:
data = make_data(n_train=n, seed=seed)
torch.manual_seed(seed)
model = MLP(width=width, depth=depth)
train_model(model, data, epochs=epochs, seed=seed)
accs.append(accuracy(model, *data[4:6]))
plt.figure(figsize=(5, 3.2))
plt.semilogx(sizes, accs, "o-")
plt.xlabel("training-set size (samples, log scale)")
plt.ylabel("accuracy on clean test set")
plt.grid(alpha=0.3, which="both")
plt.title("Learning curve: accuracy vs training-set size")
plt.tight_layout()
return accs
sizes = [200, 500, 1000, 2000, 4000]
accs_vs_size = plot_learning_curves(sizes)
La courbe monte en flèche, puis s’aplatit : passé quelques milliers d’échantillons, davantage de données identiques n’apporte presque rien. Si la courbe monte encore à votre taille de données actuelle, collectez plus de données avant de régler quoi que ce soit d’autre. Si elle s’est aplatie, davantage des mêmes données n’aidera pas ; de meilleures étiquettes, de meilleures caractéristiques ou des exemples plus difficiles, peut-être.
4. Pilier 2 — Architecture¶
Les données maintenues propres et fixes, nous varions maintenant le modèle. Les questions sont toujours les mêmes : quelle largeur, quelle profondeur, et comparé à quelle référence ?
4.1 Largeur¶
data = make_data(seed=5)
widths = [4, 16, 64, 256]
rows = []
for w in widths:
torch.manual_seed(5)
model = MLP(width=w, depth=2)
train_model(model, data, epochs=25, seed=5)
rows.append({"width": w, "params": count_params(model),
"test acc": accuracy(model, *data[4:6])})
width_sweep = pd.DataFrame(rows)
print(width_sweep)
plt.figure(figsize=(5, 3.2))
plt.semilogx(width_sweep["params"], width_sweep["test acc"], "o-")
plt.xlabel("parameter count (log scale)")
plt.ylabel("test accuracy")
plt.grid(alpha=0.3, which="both"); plt.title("Width sweep at depth 2")
plt.tight_layout() width params test acc
0 4 75 0.852000
1 16 483 0.882667
2 64 4995 0.951333
3 256 69123 0.949333

L’exactitude sature à moins d’un facteur de quelques unités de la plus petite largeur adéquate. Au-delà, les paramètres sont gratuits en exactitude, mais pas en temps d’entraînement, en mémoire ni en risque de surapprentissage sur des jeux plus petits.
4.2 La profondeur à budget de paramètres fixé¶
Vaut-il mieux dépenser un budget de paramètres fixé en profondeur ou en largeur ? Nous calculons la largeur qui donne à chaque profondeur à peu près le même nombre de paramètres, puis comparons.
def width_for_budget(depth, budget=5000):
'''Smallest width whose MLP meets or exceeds the parameter budget.'''
for w in range(1, 1024):
if count_params(MLP(width=w, depth=depth)) >= budget:
return w
return 1024
rows = []
for d in [1, 2, 4]:
w = width_for_budget(d)
torch.manual_seed(6)
model = MLP(width=w, depth=d)
train_model(model, data, epochs=25, seed=6)
rows.append({"depth": d, "width": w, "params": count_params(model),
"test acc": accuracy(model, *data[4:6])})
depth_sweep = pd.DataFrame(rows)
print(depth_sweep) depth width params test acc
0 1 385 5008 0.952000
1 2 65 5138 0.954667
2 4 39 5190 0.950667
Sur un problème tabulaire à 9 caractéristiques, la profondeur rapporte peu : les corrélations plantées par le générateur sont d’ordre bas, et un réseau peu profond mais large les représente aussi bien qu’un réseau profond. La profondeur paie quand les données ont une structure hiérarchique — des contours aux textures puis aux objets dans les images (carnet 4.3), des échantillons aux motifs puis aux phrases dans les séquences (carnet 4.4). Accordez l’architecture à la structure des données, pas à la mode.
4.3 La référence qu’il faut toujours exécuter¶
Tout modèle profond a besoin d’un point de comparaison bon marché. Pour la classification, c’est la régression logistique multinomiale — de façon équivalente, notre MLP de profondeur 0 (une seule couche linéaire plus softmax).
torch.manual_seed(7)
logistic = MLP(width=1, depth=0) # depth=0 -> a single linear layer
train_model(logistic, data, epochs=25, seed=7)
torch.manual_seed(7)
best_mlp = MLP(width=64, depth=2)
train_model(best_mlp, data, epochs=25, seed=7)
print(f"logistic baseline ({count_params(logistic):5d} params): "
f"test acc {accuracy(logistic, *data[4:6]):.3f}")
print(f"MLP 64x2 ({count_params(best_mlp):5d} params): "
f"test acc {accuracy(best_mlp, *data[4:6]):.3f}")logistic baseline ( 30 params): test acc 0.879
MLP 64x2 ( 4995 params): test acc 0.953
L’écart est réel mais modeste — typique de données tabulaires bien conditionnées. Si votre modèle profond ne bat pas la référence linéaire, le problème vient des données ou des caractéristiques, pas d’une couche manquante. Rapportez la référence dans chaque projet ; c’est le dénominateur honnête de toute affirmation sur l’apprentissage profond.
4.4 Ensembles profonds : l’incertitude par le désaccord¶
Entraînez la même architecture depuis plusieurs graines aléatoires et les exécutions convergent vers des fonctions différentes, qui s’accordent sur les échantillons faciles et divergent près des frontières de classes. Ce désaccord est un signal d’incertitude pratique (Lakshminarayanan et al., 2017, NeurIPS), et il ne coûte rien d’autre que des entraînements répétés. Savoir si le signal est bon n’est pas une affirmation à poser ; cela se scinde en deux propositions mesurables. Discrimination : le désaccord classe-t-il les échantillons selon leur risque d’erreur ? Calibration : quand l’ensemble dit 80 %, a-t-il raison environ 80 % du temps ? Les deux sont indépendantes — un modèle peut classer parfaitement ses erreurs pendant que chaque probabilité affichée est trop confiante de 20 points — et nous mesurons les deux.
n_members = 5
member_probs, member_accs = [], []
Xte, yte = data[4], data[5]
for s in range(n_members):
torch.manual_seed(100 + s) # different init per member
member = MLP(width=64, depth=2)
train_model(member, data, epochs=25, seed=100 + s) # different batches too
member.eval()
with torch.no_grad():
probs = torch.softmax(member(Xte.to(device)), dim=1).cpu().numpy()
member_probs.append(probs)
member_accs.append(accuracy(member, Xte, yte))
member_probs = np.stack(member_probs) # (members, samples, classes)
ens_mean = member_probs.mean(axis=0)
ens_pred = ens_mean.argmax(1)
ens_acc = (ens_pred == yte.numpy()).mean()
# disagreement: std across members of the probability of the ensemble's chosen class
ens_std = member_probs.std(axis=0)[np.arange(len(yte)), ens_pred]
print("member accuracies:", np.round(member_accs, 3))
print(f"ensemble-mean accuracy: {ens_acc:.3f}")member accuracies: [0.952 0.949 0.951 0.949 0.949]
ensemble-mean accuracy: 0.949
fig, ax = plt.subplots(1, 2, figsize=(10.5, 3.6))
# discrimination check: accuracy within quintiles of ensemble disagreement
bins = np.quantile(ens_std, np.linspace(0, 1, 6))
bin_idx = np.clip(np.digitize(ens_std, bins[1:-1]), 0, 4)
correct = ens_pred == yte.numpy()
bin_acc = [correct[bin_idx == b].mean() for b in range(5)]
ax[0].bar(range(5), bin_acc, color="tab:gray")
ax[0].set_xticks(range(5))
ax[0].set_xticklabels(["lowest", "low", "mid", "high", "highest"])
ax[0].set_xlabel("ensemble disagreement (quintile)")
ax[0].set_ylabel("ensemble accuracy in bin")
ax[0].set_title("Discrimination: agreement predicts correctness")
ax[0].grid(alpha=0.3, axis="y")
# where the uncertain samples live in feature space
sio2 = Xte[:, FEATURES.index("SIO2")].numpy()
mgo = Xte[:, FEATURES.index("MGO")].numpy()
sc = ax[1].scatter(sio2, mgo, c=ens_std, s=8, cmap="viridis")
flag = np.argsort(ens_std)[-20:] # 20 most uncertain samples
ax[1].scatter(sio2[flag], mgo[flag], facecolors="none",
edgecolors="red", s=60, label="20 most uncertain")
plt.colorbar(sc, ax=ax[1], label="ensemble std of predicted-class prob.")
ax[1].set_xlabel("SiO2 (standardized)"); ax[1].set_ylabel("MgO (standardized)")
ax[1].set_title("Uncertain samples sit on class boundaries")
ax[1].legend()
plt.tight_layout()
Deux gains. D’abord, la moyenne de l’ensemble est généralement au moins aussi exacte que le meilleur membre individuel. Ensuite — et c’est plus utile en pratique — les échantillons où les membres divergent sont exactement ceux que l’ensemble rate le plus souvent, et ils se regroupent le long des frontières de classes dans l’espace des caractéristiques. Dans un flux de travail réel, vous enverriez les échantillons signalés à un analyste humain. C’est la discrimination — la même compétence qu’avait l’étalement des votes de la leçon 3.9, mesurée de la même façon : regroupez par score d’incertitude, vérifiez que l’exactitude décroît d’un groupe à l’autre. Rien dans cette figure ne dit si les probabilités de l’ensemble signifient ce qu’elles prétendent. Le jalon du projet final propose cette analyse comme l’une des deux expériences d’incertitude exigées.
5. Pilier 3 — Stratégies d’entraînement¶
Mêmes données, même architecture : tout ce qui suit ne change que la façon d’entraîner.
5.1 Le taux d’apprentissage, en trois régimes¶
Cette seconde propriété est la calibration, et elle exige les outils de la leçon 3.6 : un diagramme de fiabilité sur les probabilités moyennes de l’ensemble, résumé par l’erreur de calibration attendue (ECE) — l’écart moyen entre confiance affichée et exactitude observée, pondéré par le nombre d’échantillons tombant dans chaque case de confiance. Pour un problème à 3 classes, la confiance de la classe prédite vit entre 1/3 et 1, et les cases aussi. Nous comparons la moyenne de l’ensemble à un membre unique pour voir ce que la moyenne apporte.
def reliability(probs, y_true, n_bins=10):
'''Top-label reliability: bin samples by predicted-class confidence.
Returns per-bin mean confidence, per-bin accuracy, counts, and ECE.'''
conf = probs.max(axis=1)
correct = probs.argmax(axis=1) == y_true
edges = np.linspace(1 / 3, 1.0, n_bins + 1) # 3 classes: confidence >= 1/3
idx = np.clip(np.digitize(conf, edges[1:-1]), 0, n_bins - 1)
counts = np.bincount(idx, minlength=n_bins)
bin_conf = np.array([conf[idx == b].mean() if counts[b] else np.nan
for b in range(n_bins)])
bin_acc = np.array([correct[idx == b].mean() if counts[b] else np.nan
for b in range(n_bins)])
ok = counts > 0
ece = np.sum(np.abs(bin_acc[ok] - bin_conf[ok]) * counts[ok]) / counts.sum()
return bin_conf, bin_acc, counts, ece
y_np = yte.numpy()
bc_s, ba_s, _, ece_single = reliability(member_probs[0], y_np)
bc_e, ba_e, _, ece_ens = reliability(ens_mean, y_np)
plt.figure(figsize=(5, 3.6))
plt.plot([1 / 3, 1], [1 / 3, 1], "k--", lw=1, label="perfect calibration")
plt.plot(bc_s, ba_s, "s-", label=f"single member (ECE {ece_single:.3f})")
plt.plot(bc_e, ba_e, "o-", label=f"5-member ensemble mean (ECE {ece_ens:.3f})")
plt.xlabel("stated confidence (bin mean)")
plt.ylabel("observed accuracy in bin")
plt.legend(); plt.grid(alpha=0.3)
plt.title("Reliability of the predicted-class probability")
plt.tight_layout()
print(f"single member accuracy {(member_probs[0].argmax(1) == y_np).mean():.3f}"
f" ECE {ece_single:.3f}")
print(f"ensemble mean accuracy {ens_acc:.3f} ECE {ece_ens:.3f}")
single member accuracy 0.952 ECE 0.015
ensemble mean accuracy 0.949 ECE 0.017

La mesure corrige l’affirmation que faisait un brouillon antérieur de cette section. Lakshminarayanan et al. (2017) ont trouvé que les ensembles profonds améliorent la calibration, et sur les réseaux profonds — surtout sous décalage de distribution — c’est généralement le cas. Ici, le membre unique est déjà presque calibré (ECE de 0,015) et la moyenne de l’ensemble ne gagne rien de mesurable (ECE de 0,017 ; les deux courbes épousent la diagonale). Un MLP à deux couches sur neuf caractéristiques avec un plancher de bruit de 1σ n’est pas assez mal calibré pour être corrigé. « Bien calibré » est une mesure qu’on attache à un modèle après avoir exécuté cette cellule, pas un adjectif livré avec une méthode.
Gardez les deux diagnostics distincts dans vos comptes rendus. Le graphique par quintiles répond à « puis-je faire confiance au classement ? » — utile pour router des échantillons vers un analyste, où seul l’ordre compte. Le diagramme de fiabilité répond à « puis-je faire confiance au nombre ? » — requis dès que la probabilité elle-même alimente un seuil de décision qui appartient à quelqu’un d’autre, la situation contre laquelle la leçon 3.6 mettait en garde. Présenter l’un comme preuve de l’autre est exactement l’erreur que cette section contenait à l’origine, et elle survit à l’évaluation par les pairs avec une fréquence déprimante.
4.5 MC dropout : une seconde méthode d’incertitude à partir d’un seul modèle¶
Cinq entraînements sont un coût réel quand une exécution prend une semaine. Le Monte Carlo dropout (MC dropout) achète une estimation d’incertitude à partir d’un seul modèle entraîné : entraînez avec dropout comme d’habitude (le bouton que notre MLP porte depuis la section 2.6), puis, au moment de la prédiction, laissez le dropout actif et faites passer la même entrée fois dans le réseau. Chaque passe échantillonne un sous-réseau aléatoire différent ; la moyenne des passes est la prédiction et leur dispersion est l’incertitude — une approximation de l’inférence bayésienne sur les poids (Gal & Ghahramani, 2016, ICML).
La comparaison honnête est un face-à-face sur les mêmes données et le même ensemble de test : un modèle à dropout avec passes stochastiques contre l’ensemble à 5 membres, notés sur l’exactitude, la calibration (ECE) et la discrimination (dispersion en fonction de l’erreur), le coût affiché à côté des chiffres.
torch.manual_seed(150)
mc_model = MLP(width=64, depth=2, dropout=0.2)
train_model(mc_model, data, epochs=25, seed=150)
T = 50
torch.manual_seed(151) # fixes which units drop in each pass
mc_model.train() # dropout stays ACTIVE at prediction time
with torch.no_grad():
mc_probs = np.stack([
torch.softmax(mc_model(Xte.to(device)), dim=1).cpu().numpy()
for _ in range(T)])
mc_model.eval()
mc_mean = mc_probs.mean(axis=0)
mc_pred = mc_mean.argmax(1)
mc_acc = (mc_pred == y_np).mean()
mc_std = mc_probs.std(axis=0)[np.arange(len(yte)), mc_pred]
bc_m, ba_m, _, ece_mc = reliability(mc_mean, y_np)
head_to_head = pd.DataFrame({
"deep ensemble (5 members)": {"test accuracy": round(ens_acc, 3),
"ECE": round(ece_ens, 3),
"models trained": 5, "prediction passes": 5},
f"MC dropout (T={T})": {"test accuracy": round(mc_acc, 3),
"ECE": round(ece_mc, 3),
"models trained": 1, "prediction passes": T},
}).T
print(head_to_head)
test accuracy ECE models trained \
deep ensemble (5 members) 0.949 0.017 5.0
MC dropout (T=50) 0.952 0.011 1.0
prediction passes
deep ensemble (5 members) 5.0
MC dropout (T=50) 50.0
def quintile_accuracy(spread, pred, y_true):
'''Accuracy within quintiles of an uncertainty score (discrimination).'''
edges = np.quantile(spread, np.linspace(0, 1, 6))
idx = np.clip(np.digitize(spread, edges[1:-1]), 0, 4)
correct = pred == y_true
return [correct[idx == b].mean() for b in range(5)]
fig, ax = plt.subplots(1, 2, figsize=(10.5, 3.6))
ax[0].plot([1 / 3, 1], [1 / 3, 1], "k--", lw=1)
ax[0].plot(bc_e, ba_e, "o-", label=f"ensemble (ECE {ece_ens:.3f})")
ax[0].plot(bc_m, ba_m, "s-", label=f"MC dropout (ECE {ece_mc:.3f})")
ax[0].set_xlabel("stated confidence"); ax[0].set_ylabel("observed accuracy")
ax[0].set_title("Calibration"); ax[0].legend(); ax[0].grid(alpha=0.3)
xq = np.arange(5)
ax[1].bar(xq - 0.19, quintile_accuracy(ens_std, ens_pred, y_np), 0.38,
label="ensemble")
ax[1].bar(xq + 0.19, quintile_accuracy(mc_std, mc_pred, y_np), 0.38,
label="MC dropout")
ax[1].set_xticks(xq)
ax[1].set_xticklabels(["lowest", "low", "mid", "high", "highest"])
ax[1].set_xlabel("spread quintile"); ax[1].set_ylabel("accuracy in bin")
ax[1].set_title("Discrimination: spread vs error")
ax[1].legend(); ax[1].grid(alpha=0.3, axis="y")
plt.tight_layout()

Les chiffres refusent de couronner la méthode coûteuse. Le MC dropout égale l’ensemble en exactitude (0,952 contre 0,949 — une différence à l’intérieur de l’étendue de graines d’une seule exécution), le devance en calibration (ECE de 0,011 contre 0,017) et trace la même courbe de discrimination (exactitude dans le quintile de plus forte dispersion : 0,79 contre 0,77 ; les quatre autres quintiles s’accordent à trois décimales) — pour un entraînement au lieu de cinq. Les 50 passes de prédiction sont le côté bon marché du registre ; les entraînements sont le côté coûteux. Sur cette tâche, un seul modèle à dropout est le choix rationnel. Les raisons pour lesquelles l’ensemble survit en pratique sont hors de ce tableau : son avantage documenté en calibration apparaît sur de grands réseaux sous décalage de distribution, ses membres s’entraînent en parallèle alors que les passes de dropout à grande échelle peuvent devenir le goulot d’étranglement de l’inférence, et son désaccord provient de solutions véritablement indépendantes — la propriété que nous mettons maintenant à l’épreuve hors plage.
4.6 Hors plage : interpolation, extrapolation, et ce que le désaccord peut signaler¶
Chaque score de ce carnet jusqu’ici partage une hypothèse cachée : l’ensemble de test a été tiré du même générateur, sur les mêmes plages de paramètres, que l’ensemble d’entraînement. Le modèle interpole — il prédit à l’intérieur de la région de l’espace des caractéristiques que ses données d’entraînement couvraient. Le déploiement brise l’hypothèse couramment : la zone cartographiée s’arrête, le pluton suivant est plus évolué que tout ce que la campagne a vu, la nouvelle station repose sur un sol plus meuble. Prédire là, c’est extrapoler, et la leçon 3.8 a montré avec son échelle de découpages qu’interpolation et extrapolation sont des affirmations différentes — le découpage que vous choisissez décide quelle affirmation votre score soutient. Ici, nous regardons un modèle franchir la ligne, et nous posons la question opérationnelle : le désaccord d’ensemble nous prévient-il quand cela arrive ?
Le montage utilise le bouton caché du générateur. Les oxydes de chaque échantillon sont pilotés par un indice de différenciation latent (section 1) ; son proxy observable est le SiO2 standardisé par classe. Nous entraînons un ensemble uniquement sur les échantillons à moins d’un écart-type de la composition typique de chaque unité — la campagne de terrain n’a cartographié que des granites typiques, des basaltes typiques, des andésites typiques — puis nous évaluons sur un tirage frais couvrant la plage complète, y compris les extrêmes évolués et primitifs que le modèle n’a jamais vus.
def diff_index(frame, stats=None):
'''Per-class standardized SiO2: observable proxy for the latent
differentiation index, the generator parameter we bound.'''
if stats is None:
stats = frame.groupby("label")["SIO2"].agg(["mean", "std"])
mu = frame["label"].map(stats["mean"]).to_numpy()
sd = frame["label"].map(stats["std"]).to_numpy()
return (frame["SIO2"].to_numpy() - mu) / sd, stats
train_frame = mlgeo_synth.geochem_table(n=4000, seed=40)
z_train, z_stats = diff_index(train_frame)
bounded = train_frame[np.abs(z_train) <= 1.0] # the "mapped" range
print(f"training keeps {len(bounded)} of {len(train_frame)} samples "
"(|z| <= 1: the central ~68% of each unit's compositional range)")
test_frame = mlgeo_synth.geochem_table(n=3000, seed=41) # fresh draw, FULL range
z_test, _ = diff_index(test_frame, z_stats)
Xb, yb = table_to_xy(bounded)
Xtr_b, Xva_b, ytr_b, yva_b = train_test_split(Xb, yb, test_size=0.25,
random_state=40, stratify=yb)
Xte_f, yte_f = table_to_xy(test_frame)
scaler_b = StandardScaler().fit(Xtr_b)
Xtr_b, Xva_b, Xte_f = (scaler_b.transform(a) for a in (Xtr_b, Xva_b, Xte_f))
rngs = [np.random.default_rng(4000 + k) for k in range(3)]
Xtr_b, Xva_b, Xte_f = (a + r.normal(0, 1, a.shape) # the usual 1-sigma noise floor
for a, r in zip((Xtr_b, Xva_b, Xte_f), rngs))
t32 = lambda a: torch.as_tensor(np.ascontiguousarray(a), dtype=torch.float32)
data_b = (t32(Xtr_b), torch.as_tensor(ytr_b), t32(Xva_b), torch.as_tensor(yva_b),
t32(Xte_f), torch.as_tensor(yte_f))
ood_probs = []
for s in range(5):
torch.manual_seed(200 + s)
member = MLP(width=64, depth=2)
train_model(member, data_b, epochs=25, seed=200 + s)
member.eval()
with torch.no_grad():
ood_probs.append(torch.softmax(member(data_b[4].to(device)),
dim=1).cpu().numpy())
ood_probs = np.stack(ood_probs)
ood_mean = ood_probs.mean(axis=0)
ood_pred = ood_mean.argmax(1)
ood_correct = ood_pred == yte_f
ood_std = ood_probs.std(axis=0)[np.arange(len(yte_f)), ood_pred]
training keeps 2723 of 4000 samples (|z| <= 1: the central ~68% of each unit's compositional range)
abs_z = np.abs(z_test)
groups = {"in range (|z| <= 1)": abs_z <= 1.0,
"near extrapolation (1 < |z| <= 2)": (abs_z > 1.0) & (abs_z <= 2.0),
"far extrapolation (|z| > 2)": abs_z > 2.0}
ood_table = pd.DataFrame(
[{"range": g, "n": int(m.sum()),
"error rate": 1 - ood_correct[m].mean(),
"mean disagreement": ood_std[m].mean()} for g, m in groups.items()]
).set_index("range")
print(ood_table.round(3))
in_med = np.median(ood_std[groups["in range (|z| <= 1)"]])
wrong_ood = (abs_z > 1.0) & ~ood_correct
silent = wrong_ood & (ood_std <= in_med) # wrong, yet looks as calm as in-range
print(f"\nout-of-range errors: {wrong_ood.sum()} on {int((abs_z > 1).sum())} "
"out-of-range samples")
print(f"silent failures (wrong AND disagreement at or below the in-range median): "
f"{silent.sum()} of {wrong_ood.sum()} "
f"({silent.sum() / max(wrong_ood.sum(), 1):.0%} of out-of-range errors)")
fig, ax = plt.subplots(1, 2, figsize=(10.5, 3.6))
ax[0].scatter(abs_z[ood_correct], ood_std[ood_correct], s=6, alpha=0.35,
label="correct")
ax[0].scatter(abs_z[~ood_correct], ood_std[~ood_correct], s=12, alpha=0.8,
color="tab:red", label="wrong")
ax[0].axvline(1.0, color="k", ls="--", lw=1, label="training bound")
ax[0].axhline(in_med, color="tab:gray", ls=":", lw=1,
label="in-range median spread")
ax[0].set_xlabel("|differentiation index| of test sample")
ax[0].set_ylabel("ensemble disagreement")
ax[0].set_title("Disagreement across the training bound")
ax[0].legend(fontsize=8); ax[0].grid(alpha=0.3)
edges = [0, 0.5, 1.0, 1.5, 2.0, 3.5]
centers, err_b, dis_b = [], [], []
for lo, hi in zip(edges[:-1], edges[1:]):
m = (abs_z >= lo) & (abs_z < hi)
if m.sum() < 10:
continue
centers.append((lo + hi) / 2)
err_b.append(1 - ood_correct[m].mean())
dis_b.append(ood_std[m].mean())
ax[1].plot(centers, err_b, "o-", color="tab:red", label="error rate")
ax[1].plot(centers, dis_b, "s-", color="tab:blue", label="mean disagreement")
ax[1].axvline(1.0, color="k", ls="--", lw=1, label="training bound")
ax[1].set_xlabel("|differentiation index| (bin center)")
ax[1].set_ylabel("error rate / mean disagreement")
ax[1].set_title("Error grows out of range; does the flag keep up?")
ax[1].legend(); ax[1].grid(alpha=0.3)
plt.tight_layout()
n error rate mean disagreement
range
in range (|z| <= 1) 2046 0.049 0.013
near extrapolation (1 < |z| <= 2) 824 0.080 0.017
far extrapolation (|z| > 2) 130 0.085 0.021
out-of-range errors: 77 on 954 out-of-range samples
silent failures (wrong AND disagreement at or below the in-range median): 1 of 77 (1% of out-of-range errors)

Lisez d’abord le tableau. Le taux d’erreur double presque en franchissant la borne d’entraînement — 4,9 % dans la plage, 8,0 % juste au-delà, 8,5 % au-delà de deux unités — une défaillance graduelle, parce qu’un granite inhabituellement évolué reste surtout granitique. Le désaccord moyen monte avec lui, de 0,013 à 0,021 : l’ensemble s’en aperçoit. Et sur les 77 erreurs hors plage, exactement une est une défaillance silencieuse — fausse, avec un désaccord au niveau ou au-dessous de la médiane en plage. Cet unique échantillon est le mécanisme à mémoriser : une composition extrême qui a atterri à l’intérieur du territoire familier d’une classe voisine, si bien que les cinq membres se sont accordés avec assurance sur la même étiquette fausse. Le désaccord signale les régions inconnues de l’espace des caractéristiques ; il ne peut pas signaler un échantillon dont l’étiquette a changé pendant que ses caractéristiques prenaient un air familier. Sous un décalage plus fort, cet échantillon unique devient une population.
Le nuage de points ajoute la seconde mise en garde : les distributions du désaccord en plage et hors plage se recouvrent largement. La moyenne monte de 60 %, mais aucun seuil sur la dispersion ne reconstruit la borne d’entraînement — la borne est une métadonnée que vous seul possédez, et c’est pourquoi elle doit être déclarée, pas inférée du comportement du modèle.
Votre distribution d’entraînement est un contrat. Chaque nombre produit par ce TP — exactitude, ECE, graphiques par quintiles — est une clause de ce contrat, valable pour des échantillons tirés des plages couvertes par les données d’entraînement. Hors de ces plages, le modèle extrapole, et aucun diagnostic calculé dans la plage ne certifie ce qu’il y fait. Déclarez la plage à côté du modèle chaque fois que vous en publiez un, et traitez les prédictions au-delà comme injustifiées tant que des étiquettes hors plage n’ont pas dit le contraire.
fig, axes = plt.subplots(1, 3, figsize=(12, 3.3), sharex=True)
for ax, (lr, label) in zip(axes, [(5.0, "too high (diverges)"),
(1e-5, "too low (crawls)"),
(1e-3, "well chosen")]):
torch.manual_seed(8)
model = MLP(width=32, depth=2)
hist = train_model(model, data, epochs=30, lr=lr, optimizer="sgd"
if lr == 5.0 else "adam", seed=8)
plot_history(hist, ax=ax, title=f"lr = {lr:g}: {label}", logy=(lr == 5.0))
plt.tight_layout()
Trois signatures à mémoriser :
- Trop haut : la perte oscille ou explose ; chaque pas dépasse la vallée. (Sur les exécutions instables, la perte peut atteindre
infouNaN; l’axe logarithmique rend l’explosion lisible.) - Trop bas : les deux courbes descendent en rampant le long d’une ligne presque droite ; l’entraînement « marche » mais demanderait des centaines d’époques.
- Bien choisi : chute rapide au début, puis aplatissement régulier.
Dans le doute, balayez d’abord le taux d’apprentissage en puissances de 10. Rien d’autre dans cette section ne compte tant que celui-ci n’est pas à peu près juste.
5.2 Taille de lot¶
plt.figure(figsize=(5.5, 3.4))
for bs in [16, 128, 1024]:
torch.manual_seed(9)
model = MLP(width=32, depth=2)
hist = train_model(model, data, epochs=30, batch_size=bs, seed=9)
plt.plot(hist["val_loss"], label=f"batch {bs}")
plt.xlabel("epoch"); plt.ylabel("validation loss")
plt.legend(); plt.grid(alpha=0.3)
plt.title("Batch size: noise vs progress per epoch")
plt.tight_layout()
Les petits lots font beaucoup de pas bruités par époque : progression initiale rapide et léger effet régularisant, au prix d’une agitation des courbes. Les grands lots font peu de pas lisses : chaque époque accomplit moins, si bien qu’à budget d’époques fixé la courbe traîne, et les très grands lots généralisent souvent légèrement moins bien. La taille de lot fixe aussi l’usage mémoire, ce qui la décide généralement sur du matériel réel. La pratique courante : le plus grand lot qui tient confortablement en mémoire, avec le taux d’apprentissage re-réglé quand le lot change.
5.3 Le surapprentissage en direct, et l’arrêt précoce¶
Pour forcer le surapprentissage, nous donnons à un grand modèle un ensemble d’entraînement petit et bruité, et nous le laissons tourner longtemps.
small_noisy = make_data(n_train=400, label_noise=0.10, seed=10)
torch.manual_seed(10)
big_model = MLP(width=256, depth=3)
hist_over = train_model(big_model, small_noisy, epochs=150, seed=10)
best_epoch = int(np.argmin(hist_over["val_loss"]))
ax = plot_history(hist_over, title="Overfitting: 24k params, 300 training samples")
ax.axvline(best_epoch, color="k", ls="--", lw=1,
label=f"early stop here (epoch {best_epoch})")
ax.legend()
print(f"val loss at best epoch {best_epoch}: {hist_over['val_loss'][best_epoch]:.3f}")
print(f"val loss at final epoch: {hist_over['val_loss'][-1]:.3f}")val loss at best epoch 15: 0.393
val loss at final epoch: 1.561

La signature d’école : la perte d’entraînement tombe vers zéro pendant que la perte de validation touche son plancher puis remonte. L’arrêt précoce régularise en gardant simplement le modèle de l’époque où la perte de validation était la plus basse. L’implémentation standard tient un compteur de patience :
best_val, patience, wait = float("inf"), 20, 0
for epoch in range(max_epochs):
train_one_epoch(...)
val = validation_loss(...)
if val < best_val:
best_val, wait = val, 0
torch.save(model.state_dict(), "best.pt") # checkpoint the best model
else:
wait += 1
if wait > patience:
break # stop; reload best.ptLe carnet 4.2 couvre la moitié checkpoint (point de sauvegarde) de ce motif.
5.4 Planificateurs de taux d’apprentissage¶
Un planificateur (scheduler) démarre avec un grand taux d’apprentissage pour progresser vite, puis le rétrécit pour finir avec précision — un compromis entre les panneaux « trop haut » et « trop bas » de la section 5.1.
fig, ax = plt.subplots(1, 2, figsize=(10.5, 3.4))
runs = {
"fixed lr = 0.05": dict(lr=0.05, scheduler=None),
"cosine 0.05 -> 0": dict(lr=0.05,
scheduler=lambda opt: torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=40)),
}
for label, kw in runs.items():
torch.manual_seed(11)
model = MLP(width=32, depth=2)
hist = train_model(model, data, epochs=40, optimizer="sgd", seed=11, **kw)
ax[0].plot(hist["lr"], label=label)
ax[1].plot(hist["val_loss"], label=label)
ax[0].set_xlabel("epoch"); ax[0].set_ylabel("learning rate")
ax[0].set_title("The schedule"); ax[0].legend(); ax[0].grid(alpha=0.3)
ax[1].set_xlabel("epoch"); ax[1].set_ylabel("validation loss")
ax[1].set_title("Its effect"); ax[1].legend(); ax[1].grid(alpha=0.3)
plt.tight_layout()
StepLR (réduire le taux d’un facteur donné toutes les époques) et le recuit en cosinus (cosine annealing) sont les deux plans que vous rencontrerez le plus souvent. Sur un problème aussi petit, le gain est modeste ; sur les grands modèles, les planificateurs sont la pratique standard.
5.5 Exercice : diagnostiquer six entraînements cassés¶
Ci-dessous, les courbes d’apprentissage de six exécutions d’entraînement, chacune cassée d’une façon différente (ou suspecte d’une façon différente). Les pathologies, dans un ordre mélangé : taux d’apprentissage trop haut, taux d’apprentissage trop bas, surapprentissage, sous-apprentissage (capacité insuffisante), bruit d’étiquetage massif, et fuite de données vers l’ensemble de validation.
Diagnostiquez chaque exécution à partir de ses courbes avant de lire le code de génération au-dessus de la figure ou d’ouvrir la solution.
def make_broken_runs():
'''Six rigged training runs. Diagnose from the curves before reading this.'''
runs = {}
d = make_data(seed=20)
torch.manual_seed(20)
runs["high_lr"] = train_model(MLP(32, 2), d, epochs=40, lr=5.0,
optimizer="sgd", seed=20)
torch.manual_seed(21)
runs["low_lr"] = train_model(MLP(32, 2), d, epochs=40, lr=1e-5, seed=21)
torch.manual_seed(22)
runs["overfit"] = train_model(MLP(256, 3), make_data(n_train=400, seed=22),
epochs=100, seed=22)
torch.manual_seed(23)
runs["underfit"] = train_model(MLP(1, 1), d, epochs=40, seed=23) # width-1 bottleneck
torch.manual_seed(24)
runs["label_noise"] = train_model(MLP(256, 2),
make_data(n_train=800, label_noise=0.35, seed=24),
epochs=100, seed=24)
# leakage-style pathology: training inputs get heavy augmentation noise,
# validation stays pristine -> validation beats training throughout
d_leak = list(make_data(seed=25))
rng = np.random.default_rng(25)
d_leak[0] = d_leak[0] + torch.as_tensor(
rng.normal(0, 0.8, d_leak[0].shape), dtype=torch.float32)
torch.manual_seed(25)
runs["leakage"] = train_model(MLP(32, 2), tuple(d_leak), epochs=40, seed=25)
return runs
broken = make_broken_runs()
display_order = ["overfit", "leakage", "high_lr", "label_noise", "low_lr", "underfit"]
letters = "ABCDEF"
fig, axes = plt.subplots(2, 3, figsize=(12, 6.5))
for ax, letter, key in zip(axes.ravel(), letters, display_order):
plot_history(broken[key], ax=ax, title=f"Run {letter}",
logy=(max(broken[key]["train_loss"]) > 10))
plt.tight_layout()
Pour chaque exécution, notez : la pathologie, la preuve dans les courbes, et le correctif que vous essaieriez en premier.
Solution
Exécution A — surapprentissage. La perte d’entraînement tombe vers zéro pendant que la perte de validation touche tôt son plancher et remonte régulièrement. Un modèle de 24 000 paramètres n’a vu que 300 échantillons d’entraînement. Correctif : plus de données, un modèle plus petit, de la régularisation, ou un arrêt précoce au minimum de validation.
Exécution B — fuite de données (ou augmentation côté entraînement seul). La perte de validation reste sous la perte d’entraînement pendant toute l’exécution. De vraies données de validation ne sont jamais systématiquement plus faciles que les données d’entraînement ; quand la validation gagne, soupçonnez que des échantillons de validation ont fui dans l’entraînement, que le découpage a dupliqué des échantillons quasi identiques de part et d’autre de la frontière, ou que le bruit ou l’augmentation n’a été appliqué qu’aux entrées d’entraînement (la cause réelle ici). Correctif : auditez le découpage avant de croire les chiffres.
Exécution C — taux d’apprentissage trop haut. La perte explose en quelques époques (notez l’axe logarithmique) au lieu de décroître. Les pas dépassent le minimum et divergent. Correctif : divisez le taux d’apprentissage par 10 à 100 et relancez.
Exécution D — bruit d’étiquetage massif. La perte d’entraînement continue de s’enfoncer pendant que le modèle surdimensionné mémorise les étiquettes inversées, tandis que la perte de validation atteint vite un plancher et y reste — elle plafonne plutôt qu’elle ne remonte brutalement, et l’exactitude de validation est plafonnée bien au-dessous de ce que les caractéristiques permettent. La distinguer du surapprentissage demande du soin : avec du bruit d’étiquetage, aucune régularisation ne relève le plafond de validation. Correctif : auditez et nettoyez les étiquettes (section 3.1).
Exécution E — taux d’apprentissage trop bas. Les deux courbes descendent une ligne peu pentue, presque droite, et sont encore loin de la convergence après 40 époques. Rien ne cloche, sauf la patience. Correctif : multipliez le taux d’apprentissage par 10 à 100.
Exécution F — sous-apprentissage. Les deux courbes plafonnent presque immédiatement à une perte élevée, avec un écart entraînement-validation minuscule. Le réseau de largeur 1 ne peut pas représenter les frontières de classes. Correctif : augmentez la capacité (largeur/profondeur) ou construisez de meilleures caractéristiques.
5.6 Recherche d’hyperparamètres avec Optuna¶
Après les balayages manuels, la version systématique. Optuna échantillonne des combinaisons d’hyperparamètres, observe le score de validation de chaque essai et concentre la recherche là où les scores sont bons. Vingt essais sur notre petite tâche prennent moins d’une minute.
import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)
data_opt = make_data(n_train=2000, seed=30)
def objective(trial):
lr = trial.suggest_float("lr", 1e-4, 1e-1, log=True)
width = trial.suggest_categorical("width", [16, 32, 64, 128])
dropout = trial.suggest_float("dropout", 0.0, 0.5)
weight_decay = trial.suggest_float("weight_decay", 1e-6, 1e-2, log=True)
torch.manual_seed(31)
model = MLP(width=width, depth=2, dropout=dropout)
hist = train_model(model, data_opt, epochs=12, lr=lr,
weight_decay=weight_decay, seed=31)
return max(hist["val_acc"])
study = optuna.create_study(direction="maximize",
sampler=optuna.samplers.TPESampler(seed=0))
study.optimize(objective, n_trials=20)
print("best validation accuracy:", round(study.best_value, 3))
print("best hyperparameters:", study.best_params)
trial_vals = [t.value for t in study.trials]
plt.figure(figsize=(5.5, 3.2))
plt.plot(trial_vals, "o", alpha=0.6, label="trial")
plt.plot(np.maximum.accumulate(trial_vals), "-", label="best so far")
plt.xlabel("trial"); plt.ylabel("validation accuracy")
plt.legend(); plt.grid(alpha=0.3)
plt.title("Optuna search, 20 trials")
plt.tight_layout()best validation accuracy: 0.928
best hyperparameters: {'lr': 0.024973286104060587, 'width': 32, 'dropout': 0.0716766437045232, 'weight_decay': 0.0060072494759062}

Cherchez les hyperparamètres dont la section 5 a montré l’importance — le taux d’apprentissage d’abord, puis la régularisation et la largeur — sur des plages que vos balayages manuels ont trouvées raisonnables. Gardez toujours l’ensemble de test final hors de la recherche : Optuna optimise le score de validation, et l’ensemble de test ne se dépense qu’une seule fois, à la fin.
5.7 Pourquoi la recherche d’architecture a cessé de compter¶
Il y a dix ans, la recherche d’architecture neuronale — des algorithmes qui conçoivent automatiquement le câblage des réseaux — était un front de recherche actif. Elle s’est largement estompée, pour une raison simple : une poignée d’architectures canoniques (le MLP, le réseau convolutif, le transformer) plus le passage à l’échelle ont gagné. Domaine après domaine, prendre une architecture standard et dépenser le calcul en davantage de données, davantage de paramètres et un meilleur entraînement a battu les câblages sur mesure découverts par recherche. Ce qui reste du problème de recherche s’est déplacé là où vous venez de vous exercer : les hyperparamètres (Optuna et ses parents) et, surtout, le pilier des données. C’est aussi le conseil pratique pour vos projets : choisissez l’architecture canonique qui correspond à la structure de vos données, puis dépensez votre effort en curation des données et en diagnostics d’entraînement.
6. Liste de contrôle pour entraîner un modèle d’apprentissage profond¶
- Caractérisez les données (chapitre 2) : dimensionnalité, unités, corrélations, équilibre des classes.
- Mettez de côté les données de test en premier. Réfléchissez à la causalité et à l’indépendance entre échantillons de test et d’entraînement : les découpages aléatoires fuient quand les échantillons sont corrélés en temps ou en espace.
- Organisez les données d’entraînement (pilier 1) : auditez les étiquettes, quantifiez le bruit de mesure, vérifiez les effectifs par classe et diversifiez par augmentation là où des transformations physiques ont du sens.
- Concevez le réseau (pilier 2) : accordez la famille d’architectures à la structure des données, commencez petit et incluez toujours la référence linéaire ou de ML classique.
- Définissez la perte adaptée à la tâche et aux objectifs du domaine (section 2), en consultant la documentation des fonctions de perte de PyTorch et les métriques de scikit-learn.
- Choisissez l’optimiseur et la stratégie d’entraînement (pilier 3) : Adam par défaut, le taux d’apprentissage balayé en puissances de dix, le plus grand lot confortable, un planificateur pour les longues exécutions.
- Entraînez avec les diagnostics allumés : enregistrez les courbes d’entraînement et de validation à chaque exécution, posez un checkpoint sur le meilleur modèle en validation, arrêtez tôt quand la validation stagne.
- Évaluez honnêtement : des métriques par classe, l’ensemble de test intact une seule fois, une estimation d’incertitude (un ensemble profond si vous pouvez vous offrir cinq entraînements, le MC dropout sinon), une vérification de calibration sur toute probabilité que vous rapportez (section 4.4), et une déclaration des plages d’entraînement hors desquelles aucun de ces chiffres ne s’applique (section 4.6).
7. Résumé¶
Vous disposez maintenant d’un vocabulaire de diagnostic attaché à des courbes que vous avez générées vous-même : ce que coûtent le bruit d’étiquetage — aléatoire et structuré —, le déséquilibre des classes et le bruit de capteur — uniforme et hétéroscédastique (pilier 1) ; comment se comportent largeur, profondeur, références, ensembles profonds et MC dropout, en quoi la calibration diffère de la discrimination, et ce qui se passe hors plage (pilier 2) ; et à quoi ressemblent taux d’apprentissage, taille de lot, surapprentissage, arrêt précoce et planificateurs dans les courbes d’apprentissage (pilier 3). Les six exécutions cassées de la section 5.5 sont l’examen que les vrais projets ne cesseront de vous faire passer.
Appliquez ce TP à votre propre projet : pour le jalon du projet final, vous réutiliserez les motifs de balayage sur vos architectures, tiendrez une annexe de diagnostics avec au moins une exécution ratée, et mènerez soit l’analyse d’ensemble profond de la section 4.4, soit l’expérience d’affinage du carnet 4.6.



