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.

A working deep learning model rests on three pillars:

  1. Training data: its quality, diversity, balance among classes, and noise level.
  2. Architecture: the family of functions the network can represent, and how many parameters it spends doing so.
  3. Training strategy: the loss function, the optimizer, the learning rate, the batch size, the stopping rule.

Most model failures trace back to exactly one of these pillars, and each pillar fails with a recognizable signature. This notebook is a hands-on lab: we hold one classification task fixed and break each pillar on purpose, so you learn to read the signatures before you meet them in your project data.

The lab uses small multi-layer perceptrons on a synthetic lithology table. Every experiment runs in seconds on a laptop CPU. You can raise sample counts and epochs on your own machine; the lessons do not change.

🖥️ Lecture slides — Session 24 — lab I (Wed Nov 25)

🖥️ Lecture slides — Session 25 — lab II (Mon Nov 30)

1. One task, one small model

The task for the whole lab: classify rock samples into three lithology classes (granite, basalt, andesite) from 9 geochemical and physical features (major-element oxides in wt%, density in g/cm3, magnetic susceptibility in SI units). The data comes from mlgeo_synth.geochem_table, a generator that plants realistic correlations between oxides through a latent differentiation index. Because the generator is ours, we control the exact amount of label noise, class imbalance, and sensor noise — the knobs real data never gives us.

One adjustment makes the lab realistic: the generator’s raw tables are almost perfectly separable, which no field instrument delivers. Every dataset in this lab therefore carries a fixed measurement-noise floor of one standard deviation per feature, applied to training, validation, and test alike. The corruption experiments in Section 3 add specific defects on top of that floor.

1.1 Train, validation, and test sets

Before any experiment, the data is split three ways:

  • Training set: used to fit the model. It should be the largest portion, typically 60-80% of the data, and as diverse and balanced as the problem allows.
  • Validation set: used during training to tune hyperparameters and detect overfitting. It gives an estimate of performance on unseen data while decisions are still being made. Typically 10-20%.
  • Test set: used once, at the end, to report generalization. It must stay completely separate from every decision made during model development. If information from the test set influences training or model selection, the reported performance is inflated. This failure is called data leakage, and it appears again in Section 5.

One detail matters for this lab: our test set is always clean (no label noise, no defects beyond the shared measurement-noise floor), even when we corrupt the training data on purpose. That is how we measure what corruption costs.

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: cpu; using: cpu
df = mlgeo_synth.geochem_table(n=3000, label_noise=0.0, seed=0)
print(df.shape)
df.head()
(3000, 10)
Loading...
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()
<Figure size 1000x350 with 2 Axes>

The classes are imbalanced by construction (andesite is rare) and they overlap in feature space. Both properties are typical of geochemical classification problems.

1.2 Lab helpers

Three pieces of code carry the whole lab: a data function with corruption knobs, a configurable MLP, and a training loop that records learning curves. Read them once, carefully; every experiment below is a few lines that call them.

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 ax

A note on the training loop: it samples a fresh random minibatch each step (minibatch stochastic gradient descent, Section 2.4) and records train and validation loss once per epoch, evaluated in eval() mode on the full splits. Those two curves per run are the diagnostic instrument for the entire lab.

2. What training minimizes

Before the experiments, the vocabulary. A loss function measures how far the model’s predictions are from the labels. It provides the single number that gradient descent pushes downhill, so its choice shapes everything the network learns. Loss functions L(y,y^,w)\mathcal{L}(\mathbf{y}, \mathbf{\hat{y}}, \mathbf{w}) quantify the residuals between ground-truth labels y\mathbf{y} and predictions y^\mathbf{\hat{y}}, and they must be differentiable with respect to the model parameters w\mathbf{w}.

2.1 Losses for regression

  • Mean squared error penalizes large errors quadratically. It is the natural choice when errors are close to Gaussian:

    MSE=1mi=1m(y^iyi)2\mathrm{MSE} = \frac{1}{m} \sum_{i=1}^m \left( \hat{y}_i - y_i \right)^2

  • Mean absolute error penalizes large errors less, which suits heavy-tailed error distributions. Its gradient has constant magnitude, which can slow convergence near the minimum; adaptive optimizers such as Adam mitigate this:

    MAE=1mi=1my^iyi\mathrm{MAE} = \frac{1}{m} \sum_{i=1}^m |\hat{y}_i - y_i|

  • Huber loss switches between the two: quadratic for errors smaller than a threshold δ (smooth gradients near the minimum), linear beyond it (outliers do not dominate).

2.2 Losses for classification

Binary classifiers output a probability through the logistic sigmoid σ(x)=1/(1+ex)\sigma(x) = 1/(1 + e^{-x}) and train with the binary cross-entropy:

L(w)=1mi=1m[yilogp^i+(1yi)log(1p^i)]\mathcal{L}(\mathbf{w}) = - \frac{1}{m} \sum_{i=1}^m \left[ y_i \log \hat{p}_i + (1 - y_i) \log(1 - \hat{p}_i) \right]

For KK classes, the network outputs one score sks_k per class and the softmax converts scores to probabilities:

p^k=exp(sk)j=1Kexp(sj)\hat{p}_k = \frac{\exp(s_k)}{\sum_{j=1}^K \exp(s_j)}

with the multi-class cross-entropy as the loss. PyTorch’s nn.CrossEntropyLoss applies the softmax internally, which is why our MLP outputs raw logits.

2.3 Geoscience-aware losses

Standard losses miss objectives that matter in our field. Because any differentiable function of predictions can be a loss term, we can encode those objectives directly:

  • Class weighting for rare events. Earthquakes, landslides, and rock-fall signals are rare classes; weighting their errors more (nn.CrossEntropyLoss(weight=...)) keeps the optimizer from ignoring them. Section 3.2 shows why this matters.

  • Waveform similarity. A network that reconstructs seismograms should match wiggle for wiggle. Adding a correlation-coefficient term rewards phase alignment that MSE barely sees:

    L(u^,u)=MSE(u^,u)+λ(1CC(u^,u))\mathcal{L}(\mathbf{\hat{u}}, \mathbf{u}) = \mathrm{MSE}(\mathbf{\hat{u}}, \mathbf{u}) + \lambda \left(1 - \mathrm{CC}(\mathbf{\hat{u}}, \mathbf{u})\right)

  • Physics constraints. When the output is a physical field, a loss term can penalize violations of a governing equation (conservation of mass, a heat equation residual). This is the basis of physics-informed neural networks, notebook 4.7.

  • Uncertainty-aware losses. Probabilistic forecasts train with negative log-likelihood, which penalizes both over- and under-confidence.

2.4 Gradient descent and the learning rate

Training updates the parameters in the direction that reduces the loss:

wj(k+1)=wj(k)αLwjw_j^{(k+1)} = w_j^{(k)} - \alpha \frac{\partial \mathcal{L}}{\partial w_j}

where α is the learning rate, the single most consequential hyperparameter in this notebook.

Gradient descent on a convex lossGradient descent with local minima
Gradient descent on a convex, well-behaved loss.A poorly behaved loss with local minima.
Learning rate too smallLearning rate too large
α too small: convergence crawls.α too large: steps overshoot the minimum.

Three variants differ in how much data each update sees:

  • Batch gradient descent uses the full training set per step: exact gradients, expensive steps.
  • Stochastic gradient descent (SGD) uses one sample per step: cheap, noisy updates, sensitive to feature scaling.
  • Minibatch gradient descent uses a small random subset per step. This is the standard in deep learning, and what our train_model does. The batch size trades gradient noise against step cost; Section 5.2 measures the effect.

Adaptive optimizers (Adam, RMSProp) rescale each parameter’s step using running gradient statistics. Adam is the default in this course.

2.5 Bias, variance, underfitting, overfitting

Generalization error decomposes into three parts:

  • Bias: error from wrong model assumptions (fitting a line to a curve). High bias shows up as high loss on the training data itself.
  • Variance: error from excessive sensitivity to the particular training samples. High variance shows up as a gap between training and validation loss.
  • Irreducible error: noise in the data. No model removes it; only better data does (fix sensors, remove outliers — Pillar 1).

Underfitting = high bias: both training and validation losses plateau at a high value. Remedies: bigger model, better features, less regularization.

Overfitting = high variance: training loss keeps falling while validation loss stalls and then climbs. The model has started to memorize noise. Remedies: more data, a smaller model, regularization, early stopping.

A rule worth memorizing: you do not know whether you can overfit until you do. Grow the model until it overfits, then back off or regularize. Both signatures appear in real curves in Section 5.

2.6 Regularization

Regularization constrains a flexible model to behave more simply:

  • L2 penalty (ridge): add λ12w22\lambda \frac{1}{2} \lVert \mathbf{w} \rVert_2^2 to the loss, shrinking all weights. In PyTorch optimizers this is the weight_decay argument.
  • L1 penalty (lasso): add λw1\lambda \lVert \mathbf{w} \rVert_1, which drives unimportant weights to exactly zero — a form of feature selection.
  • Elastic net: a weighted combination of both.
  • Dropout: randomly zero a fraction of activations during training, so no unit can rely on specific partners. Used in notebook 4.2 and available as a knob in our MLP.
  • Early stopping: stop training when validation loss stops improving (Section 5.3).
  • Data augmentation: enlarge and diversify the training set with transformed copies (shifts, added noise). It regularizes through the data pillar rather than the model.

3. Pillar 1 — Training-data curation

Model quality starts in the data, and no architecture or optimizer rescues a corrupted training set. Because our generator controls the corruption exactly, we can measure what each defect costs. In every experiment the test set stays clean, so the curves isolate the effect of training-data quality.

3.1 Label noise

Real labels are wrong more often than we like to admit: analyst picks disagree, catalogs inherit historical errors, field classifications get revised. We flip a fraction of training labels at random and retrain — in two data regimes, because the answer depends on how much data you have. Each point averages three seeds.

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.930  (seeds span 0.913-0.939)
n=3000  label noise  30%  ->  clean-test accuracy 0.910  (seeds span 0.901-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)
<Figure size 550x340 with 1 Axes>

Two regimes, two stories. With 3000 training samples, even 30% of randomly flipped labels costs little: cross-entropy averages over many samples, the flips are symmetric, and the correct majority still pins down the class boundaries. With 600 samples the same model has enough capacity to memorize the flipped labels instead of averaging them away, and accuracy falls several points. Label noise is most dangerous exactly where geoscience usually lives: small labeled datasets and models large enough to memorize them. The shaded bands are the min-max spread over the three seeds — the run-to-run variability that a single-seed experiment hides. Read the bands before trusting any difference between two points: where the bands overlap, the difference may be initialization luck, not the knob you turned. Here the spread is not decoration: at 3000 samples the three seeds span up to 0.028 (at 15% noise) — most of the 0.035 that separates the clean and 30%-noise means — so a single-seed version of this figure could have told nearly any story. Every summary number in this lab that comes from repeated runs is now reported with its seed span..

What to look for in real data. Validation accuracy that plateaus below what the class overlap suggests is a label-quality symptom, not an architecture problem. To detect it: inspect the samples the model gets most confidently “wrong” (they are often mislabeled, not misclassified), have a second analyst relabel a random subset and measure agreement, and cross-check labels against independent catalogs. And remember the noise that does the damage in practice is rarely symmetric: an analyst who systematically confuses two signal types biases the boundary itself, which is far worse than random flips — the next experiment measures exactly that. Fixing a thousand labels usually beats adding a million parameters.

3.2 Class imbalance

Andesite is already the rare class. We now shrink it further and watch per-class recall, not overall accuracy.

Structured disagreement: when the errors have a pattern

Uniform random flips are the kindest possible label error, and the experiment above shows why: with enough data they average away. Real label errors come from people, and people disagree in patterns. We simulate a field campaign mapped by two geologists who split the area between them. Each mislabels 30% of their samples — the same total error rate as the harshest uniform experiment above — but their confusions are never random: granite slides to andesite, basalt slides to andesite, andesite to either neighbor. Nobody ever calls a basalt a granite. The confusion concentrates between adjacent lithologies on the differentiation axis, which is exactly how real mapping disagreement behaves.

Same 3000-sample regime, same model, same training budget; only the error pattern changes.

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.901-0.919)
30% adjacent-class disagreement : clean-test accuracy 0.819  (seeds span 0.799-0.846)
<Figure size 440x360 with 2 Axes>

Same error rate, three times the damage: 30% uniform flips cost 3.5 accuracy points (0.945 to 0.910), 30% adjacent-class disagreement costs 12.5 (0.945 to 0.820), and no seed comes close to closing the gap (best seed 0.850). The confusion matrix shows where the points went. Both mappers dump their errors into “andesite”, so the model has learned an andesite class that annexes the boundary zones of its neighbors: 14% of true granites and 16% of true basalts now come back “andesite”, while true andesite recall falls to 0.61. And this is the 3000-sample regime — the one where uniform flips were nearly free.

This result matters far beyond this notebook, so here it is without the code. Every label in a geoscience dataset is an interpretation, not a measurement. A geologic map records what one mapper concluded from outcrops, float, and judgment calls at contacts; a seismic phase pick records where one analyst decided the wave arrived; a landslide inventory records which slope features one interpreter accepted as failures. Send two qualified experts over the same ground, the same waveforms, or the same imagery independently and they will disagree — most often between adjacent categories: granodiorite versus granite, Pn versus P, old landslide versus hummocky moraine. That disagreement is measurable (double-map a subset, double-pick a day of records) and is routinely a two-digit percentage at the category boundaries.

Structured disagreement hurts more than random error because it votes coherently. A thousand random flips scatter in all directions and cancel; a thousand adjacent-class confusions all push the same class boundary the same way, so more data makes the model more confident in the mappers’ shared mistake, not less. The practical consequence: inter-rater agreement is a ceiling on the accuracy any model can demonstrate, because the model is graded against labels that carry the disagreement. Before spending a month on architectures, spend a day measuring how often two experts agree on your labels — that number tells you when to stop tuning and start relabeling.

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
<Figure size 550x320 with 1 Axes>

Read the two columns against each other. Overall accuracy drifts down a few points — nothing alarming on a dashboard. Andesite recall collapses to zero: the model has stopped predicting the class entirely. Notice also that even at full data the rare class starts disadvantaged (recall 0.74 against 0.97 for the majority classes). This is the most common silent failure in geoscience classification, where the rare class (the eruption, the induced event, the landslide) is the one you care about.

What to look for in real data. Always report the full confusion matrix and per-class recall; never accuracy alone. If the minority class matters, use class weights in the loss, oversample it, or generate augmented minority examples — and check that the fix shows up in recall, not just in the loss.

3.3 Sensor noise on the features

Now the labels are right but the instrument degrades: we add extra Gaussian noise to the training and validation features, on top of the shared 1.0σ measurement floor, in units of one feature standard deviation. The test set keeps the normal floor — this simulates training on data from a worse sensor than the one you deploy.

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
<Figure size 500x320 with 1 Axes>

Once the added noise reaches a few times the natural spread of each feature, the class clusters smear into each other and accuracy slides toward chance. No model recovers separation that the measurements no longer contain — this is the irreducible-error term of Section 2.5 made visible.

What to look for in real data. Estimate measurement uncertainty per feature (repeat measurements, instrument specifications, calibration records) and compare it against the between-class separation of that feature. Features whose noise exceeds their class separation add no signal; cleaning or dropping them is data curation, not defeat.

3.4 How much data is enough?

The classic learning-curve diagnostic plots performance against training-set size. This is the function to reuse whenever someone asks “would more data help?” — a question better measured than debated.

Heteroscedastic quality: when you know how noisy each sample is

The sweep above degraded every sample equally. Real tables mix provenances: half your geochemistry from a laboratory XRF with tight analytical error, half from a portable field unit several times worse — and the metadata usually says which is which, because analytical uncertainty is reported per sample. (Sensor streams do the same thing in time: mlgeo_synth.degrade_series returns a per-sample sigma_mm column for exactly this reason.) Noise whose variance changes from sample to sample is called heteroscedastic, and it is the aleatoric half of the uncertainty vocabulary from lesson 3.9: randomness in the measurement itself, which no amount of training data removes — but which you can refuse to treat as truth.

The standard cross-entropy treats every sample as equally trustworthy. If the variance is known, the fix is to weight each sample’s loss by the inverse of its variance (wi1/σi2w_i \propto 1/\sigma_i^2), the classification analogue of weighted least squares. We measure what ignoring the metadata costs and what the weights recover: each training sample draws its noise floor from {0.5, 4.0} standard deviations with equal probability (lab-grade or field-grade), and the same model trains once without the weights and once with them.

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)

The mixed-quality table costs 1.7 points against the all-lab-grade reference (0.933 vs 0.950). Handing the model the variance it already had in the metadata recovers 1.3 of them (0.946) — no new data, one line of loss code — and the seed bands of the two schemes do not overlap (0.932-0.935 vs 0.939-0.951), so the gain is not initialization luck.

What to look for in real data. Any column named uncertainty, std_err, quality, or pick_weight is an invitation to weight the loss — seismic catalogs, geochemical databases, and GNSS solutions all ship one, and most pipelines drop it at ingest. Down-weighting is also the honest alternative to the tempting shortcut of deleting the noisy half: the field-grade samples still carry signal, just less of it per sample, and the weights price that in.

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)
<Figure size 500x320 with 1 Axes>

The curve rises steeply, then flattens: past a few thousand samples, more identical data buys little. If the curve is still rising at your current data size, collect more data before tuning anything else. If it has flattened, more of the same data will not help; better labels, better features, or harder examples might.

4. Pillar 2 — Architecture

With the data held clean and fixed, we now vary the model. The questions are always the same: how wide, how deep, and compared to what baseline?

4.1 Width

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
<Figure size 500x320 with 1 Axes>

Accuracy saturates within a factor of a few of the smallest adequate width. Past that point, parameters are free in accuracy but not in training time, memory, or overfitting risk on smaller datasets.

4.2 Depth at a fixed parameter budget

Is it better to spend a fixed parameter budget on depth or width? We solve for the width that gives each depth roughly the same parameter count, then compare.

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

On a 9-feature tabular problem, depth buys little: the correlations the generator planted are low-order, and a shallow-but-wide network represents them as well as a deep one. Depth pays when the data has hierarchical structure — edges to textures to objects in images (notebook 4.3), samples to motifs to phrases in sequences (notebook 4.4). Match the architecture to the structure of the data, not to fashion.

4.3 The baseline you must always run

Every deep model needs a cheap reference point. For classification, that is multinomial logistic regression — equivalently, our MLP with depth 0 (a single linear layer 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

The gap is real but modest — typical for well-behaved tabular data. If your deep model cannot beat the linear baseline, the problem is the data or the features, not a missing layer. Report the baseline in every project; it is the honest denominator for every claim about deep learning.

4.4 Deep ensembles: uncertainty from disagreement

Train the same architecture from several random seeds and the runs converge to different functions that agree on easy samples and disagree near class boundaries. That disagreement is a practical uncertainty signal (Lakshminarayanan et al., 2017, NeurIPS), and it costs nothing but repeated training. Whether the signal is any good is not something to assert; it splits into two measurable claims. Discrimination: does disagreement rank samples by their risk of error? Calibration: when the ensemble says 80%, is it right about 80% of the time? The two are independent — a model can rank its errors perfectly while every stated probability is 20 points too confident — and we measure both.

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()
<Figure size 1050x360 with 3 Axes>

Two payoffs. First, the ensemble mean is usually at least as accurate as the best single member. Second — and more useful in practice — samples where the members disagree are exactly the samples the ensemble gets wrong most often, and they cluster along class boundaries in feature space. In a real workflow you would route the flagged samples to a human analyst. This is discrimination — the same skill the vote-spread of lesson 3.9 had, measured the same way: bin by the uncertainty score, check that accuracy falls across the bins. Nothing in this figure says whether the ensemble’s probabilities mean what they claim. The final-project milestone offers this analysis as one of the two required uncertainty experiments.

5. Pillar 3 — Training strategies

Same data, same architecture: everything below changes only how we train.

5.1 The learning rate, three ways

That second property is calibration, and it needs the tools from lesson 3.6: a reliability diagram on the ensemble-mean probabilities, summarized by the expected calibration error (ECE) — the average gap between stated confidence and observed accuracy, weighted by how many samples land in each confidence bin. For a 3-class problem the predicted-class confidence lives between 1/3 and 1, so the bins do too. We compare the ensemble mean against a single member to see what averaging buys.

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
<Figure size 500x360 with 1 Axes>

The measurement corrects the claim an earlier draft of this section made. Lakshminarayanan et al. (2017) found that deep ensembles improve calibration, and on deep networks — especially under distribution shift — they generally do. Here the single member is already nearly calibrated (ECE 0.015) and the ensemble mean gains nothing measurable (ECE 0.017; both curves hug the diagonal). A two-layer MLP on nine features with a 1σ noise floor is not miscalibrated enough to fix. “Well calibrated” is a measurement you attach to a model after running this cell, not an adjective that comes bundled with a method.

Keep the two diagnostics distinct in your write-ups. The quintile plot answers “can I trust the ranking?” — useful for routing samples to an analyst, where only the order matters. The reliability diagram answers “can I trust the number?” — required whenever the probability itself feeds a decision threshold someone else owns, the situation lesson 3.6 warned about. Reporting one as evidence for the other is the exact error this section originally contained, and it survives peer review depressingly often.

4.5 MC dropout: a second uncertainty method from one model

Five training runs is a real cost when one run takes a week. Monte Carlo dropout buys an uncertainty estimate from a single trained model: train with dropout as usual (the knob our MLP has carried since Section 2.6), then at prediction time leave dropout on and run the same input through the network TT times. Each pass samples a different random subnetwork; the mean over passes is the prediction and the spread over passes is the uncertainty — an approximation to Bayesian inference over the weights (Gal & Ghahramani, 2016, ICML).

The honest comparison is head-to-head on the same data and the same test set: one dropout model with T=50T=50 stochastic passes against the 5-member ensemble, scored on accuracy, calibration (ECE), and discrimination (spread-vs-error), with the cost stated next to the numbers.

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()
<Figure size 1050x360 with 2 Axes>

The numbers refuse to crown the expensive method. MC dropout matches the ensemble on accuracy (0.952 vs 0.949 — a difference inside the seed spread of a single run), edges it on calibration (ECE 0.011 vs 0.017), and draws the same discrimination curve (accuracy in the highest-spread quintile 0.79 vs 0.77; the other four quintiles agree to three decimals) — for one training run instead of five. The 50 prediction passes are the cheap side of the ledger; the training runs are the expensive one. On this task, one dropout model is the rational choice. The reasons the ensemble survives in practice sit outside this table: its documented calibration advantage appears on large networks under distribution shift, its members train in parallel while dropout passes at scale can bottleneck inference, and its disagreement comes from genuinely independent solutions — the property we now take out of range.

4.6 Out of range: interpolation, extrapolation, and what disagreement can flag

Every score in this notebook so far shares a hidden assumption: the test set was drawn from the same generator, over the same parameter ranges, as the training set. The model interpolates — it predicts inside the region of feature space its training data covered. Deployment breaks the assumption routinely: the mapped area ends, the next pluton is more evolved than anything in the survey, the new station sits on softer ground. Predicting there is extrapolation, and lesson 3.8 showed with its ladder of splits that interpolation and extrapolation are different claims — the split you choose decides which claim your score supports. Here we watch one model cross the line, and ask the operational question: does ensemble disagreement warn us when it happens?

The setup uses the generator’s own hidden knob. Each sample’s oxides are driven by a latent differentiation index (Section 1); its observable proxy is the per-class standardized SiO2. We train an ensemble only on samples within one standard deviation of each unit’s typical composition — the field campaign mapped only typical granites, typical basalts, typical andesites — then evaluate on a fresh draw spanning the full range, including the evolved and primitive extremes the model has never seen.

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)
<Figure size 1050x360 with 2 Axes>

Read the table first. The error rate nearly doubles across the training bound — 4.9% in range, 8.0% just past it, 8.5% beyond two units — a gradual failure, because an unusually evolved granite is still mostly granite-like. Mean disagreement rises with it, 0.013 to 0.021: the ensemble notices. And of the 77 out-of-range errors, exactly one is a silent failure — wrong, with disagreement at or below the in-range median. That single sample is the mechanism to memorize: an extreme composition that landed inside a neighboring class’s familiar territory, so all five members confidently agreed on the same wrong label. Disagreement flags unfamiliar regions of feature space; it cannot flag a sample whose label changed while its features came to look familiar. Under a larger shift, that one sample becomes a population.

The scatter adds the second caution: the in-range and out-of-range disagreement distributions overlap heavily. The mean rises 60%, but no threshold on the spread reconstructs the training bound — the bound is metadata that only you have, which is why it must be stated, not inferred from the model’s behavior.

Your training distribution is a contract. Every number this lab produced — accuracy, ECE, the quintile plots — is a term of that contract, valid for samples drawn from the ranges the training data covered. Outside those ranges the model is extrapolating, and no diagnostic computed inside the range certifies what it does out there. State the range alongside the model whenever you release one, and treat predictions beyond it as unwarranted until out-of-range labels say otherwise.

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()
<Figure size 1200x330 with 3 Axes>

Three signatures to memorize:

  • Too high: the loss oscillates or explodes; each step overshoots the valley. (With unstable runs the loss can reach inf or NaN; the log axis makes the explosion readable.)
  • Too low: both curves creep down a nearly straight line and training “works” but would need hundreds of epochs.
  • Well chosen: fast early drop, then a smooth flattening.

When in doubt, sweep the learning rate in powers of 10 first. Nothing else in this section matters until this is roughly right.

5.2 Batch size

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()
<Figure size 550x340 with 1 Axes>

Small batches take many noisy steps per epoch: fast initial progress and a mild regularizing effect, at the price of jitter. Large batches take few smooth steps: each epoch does less, so at a fixed epoch budget the curve lags, and very large batches often generalize slightly worse. Batch size also sets memory use, which is what usually decides it on real hardware. The common practice: the largest batch that fits comfortably in memory, with the learning rate re-tuned when the batch changes.

5.3 Overfitting live, and early stopping

To force overfitting we give a large model a small, noisy training set and let it run long.

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.564
<Figure size 500x300 with 1 Axes>

The textbook signature: training loss falls toward zero while validation loss bottoms out and climbs. Early stopping regularizes by simply keeping the model from the epoch where validation loss was lowest. The standard implementation keeps a patience counter:

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.pt

Notebook 4.2 covers the checkpointing half of this pattern.

5.4 Learning-rate schedulers

A schedule starts with a large learning rate for fast progress and shrinks it for a precise finish — a compromise between the “too high” and “too low” panels of 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()
<Figure size 1050x340 with 2 Axes>

StepLR (drop the rate by a factor every kk epochs) and cosine annealing are the two schedules you will meet most often. On a problem this small the gain is modest; on large models, schedules are standard practice.

5.5 Exercise: diagnose six broken training runs

Below are learning curves from six training runs, each broken in a different way (or suspicious in a different way). The pathologies, in shuffled order: learning rate too high, learning rate too low, overfitting, underfitting (not enough capacity), heavy label noise, and data leakage into the validation set.

Diagnose each run from its curves before reading the generation code above the figure or opening the 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()
<Figure size 1200x650 with 6 Axes>

For each run, write down: the pathology, the evidence in the curves, and the fix you would try first.

5.6 Hyperparameter search with Optuna

After the manual sweeps, the systematic version. Optuna samples hyperparameter combinations, observes the validation score of each trial, and focuses the search where scores are good. Twenty trials on our small task take under a 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.0249732861040606, 'width': 32, 'dropout': 0.0716766437045232, 'weight_decay': 0.006007249475906206}
<Figure size 550x320 with 1 Axes>

Search the hyperparameters that Section 5 showed matter — learning rate first, then regularization and width — over ranges your manual sweeps found sensible. Always keep the final test set out of the search: Optuna optimizes the validation score, and the test set is spent only once at the end.

5.7 Why architecture search stopped mattering

Ten years ago, neural architecture search — algorithms that design network wiring automatically — was an active research front. It has largely faded, for a simple reason: a handful of canonical architectures (the MLP, the convolutional network, the transformer) plus scale won. In domain after domain, taking a standard architecture and spending the compute on more data, more parameters, and better training beat bespoke wiring discovered by search. What remains of the search problem moved to where you just practiced it: hyperparameters (Optuna and its relatives) and, above all, the data pillar. This is also the practical advice for your projects: pick the canonical architecture that matches your data’s structure, then spend your effort on data curation and training diagnostics.

6. Checklist for training a deep learning model

  1. Characterize the data (Chapter 2): dimensionality, units, correlations, class balance.
  2. Set aside the test data first. Consider causality and independence between test and training samples: random splits leak when samples are correlated in time or space.
  3. Curate the training data (Pillar 1): audit labels, quantify measurement noise, check per-class counts, and diversify with augmentation where physical transformations make sense.
  4. Design the network (Pillar 2): match the architecture family to the structure of the data, start small, and always include the linear or classical-ML baseline.
  5. Define the loss appropriate to the task and the domain objectives (Section 2), consulting the PyTorch loss-function documentation and scikit-learn metrics.
  6. Choose the optimizer and training strategy (Pillar 3): Adam as a default, learning rate swept in powers of ten, the largest comfortable batch, a scheduler for longer runs.
  7. Train with diagnostics on: record training and validation curves every run, checkpoint the best validation model, early-stop when validation stalls.
  8. Evaluate honestly: per-class metrics, the untouched test set once, an uncertainty estimate (a deep ensemble if you can afford five runs, MC dropout if you cannot), a calibration check on any probability you report (Section 4.4), and a statement of the training ranges outside which none of the numbers apply (Section 4.6).

7. Summary

You now have a diagnostic vocabulary attached to curves you have generated yourself: what label noise — random and structured — class imbalance, and sensor noise — uniform and heteroscedastic — cost (Pillar 1); how width, depth, baselines, ensembles, and MC dropout behave, how calibration differs from discrimination, and what happens out of range (Pillar 2); and what learning rate, batch size, overfitting, early stopping, and schedulers look like in learning curves (Pillar 3). The six broken runs of Section 5.5 are the exam that real projects will keep administering.

Apply this lab to your own project: for the final-project milestone you will reuse the sweep patterns on your architectures, keep a diagnostics appendix with at least one failed run, and run either the deep-ensemble analysis from Section 4.4 or the fine-tuning experiment from notebook 4.6.