III-36 · Learning Curves and Model Selection

Each curve point is a fitted experiment

Grow nested training groups and trace the fitted preprocessing, objective and held-out error behind every plotted point.

Chapters in Part VIII
  1. III-35 · Cross-Validation and Hyperparameter Tuning
  2. III-36 · Learning Curves and Model Selection
  3. III-37 · Class Imbalance, Calibration and Metric Choice
  4. III-38 · Preprocessing, Feature Engineering and Pipelines
  5. III-39 · Reproducibility and Numerical Reliability

Part VIII · experimental workflow sheet

Fit more training groups; inspect the real errors

Hand-authored synthetic rows; seed controls group assignment only, never data generation. Data version p08-hand-v1. Edits are recorded in the displayed state. Probability rows are a separate hand-authored forecast example.

Split and workflow
Regression row editor

Workflow phase 1 / 5. Replay preserves immutable states; edits fork the displayed frame. Revealing the test is the final step: do not tune after seeing it.

Raw X / engineered X / yObservations are mathematical columns; batch stores their transpose in rows. Missing raw values use null.
12 × 1 / n × 1 / n × 1
ParametersSelected on equal-weight CV MSE; intercept unpenalized; exact ties use first grid candidate.
power=2, λ=0
Test groupGroup allocation uses only IDs/groups and seed. Test values never fit preprocessing, select a model or enter a curve.
f
Data and RNGHand-authored rows. Seed controls only group shuffle. Full displayed JSON records edited values.
p08-hand-v1
LCG draws (u32)1664525·state + 1013904223 mod 2³²; Fisher–Yates draws descending.
1025555898, 3923423697, 2630631676, 3981355051, 211918734
Numerical limitsOverflow and underflow to zero reject the edit. Constant transformed features use scale 1 and zero slope when λ=0.
binary64; finite arithmetic required
Experimental workflow, active phase 1 of five12345
1 Split → 2 Fold fits → 3 Select → 4 Development refit → 5 Final test. Active phase 1; replay exposes each step.
Locked group split; no stratification is claimed
RoleIDs
Developmenta1, a2, b1, b2, c1, c2, d1, d2, e1, e2
Untouched testf1, f2
Fold 1Train: b1, b2, c1, c2, d1, d2
Validation: a1, a2, e1, e2
Fold 2Train: a1, a2, c1, c2, e1, e2
Validation: b1, b2, d1, d2
Fold 3Train: a1, a2, b1, b2, d1, d2, e1, e2
Validation: c1, c2
J(w,b)=1n∑i(b+wϕi−yi)2+λw2,CV=1K∑kMSEkJ(w,b)=\frac1n\sum_i(b+w\phi_i-y_i)^2+\lambda w^2,\quad \mathrm{CV}=\frac1K\sum_k\mathrm{MSE}_k
Actual fitted candidates; exact ties retain first row; chosen p=2, λ=0
Power / λTraining MSE / objective per foldValidation MSE per foldCV mean
1 / 01.014912 / 1.014912; 7.194625 / 7.194625; 4.668608 / 4.66860822.674681; 5.666231; 12.98572813.775547
1 / 11.212573 / 1.410234; 7.26576 / 7.336896; 4.945792 / 5.22297620.408144; 6.092143; 11.98497312.82842
1 / 41.520924 / 1.647427; 7.376731 / 7.422258; 5.378199 / 5.55559720.313584; 6.369024; 11.40897112.697193
2 / 01.457512 / 1.457512; 1.85763 / 1.85763; 1.162242 / 1.1622423.363659; 2.193051; 0.4401481.998953
2 / 11.539146 / 1.626071; 4.218731 / 5.500845; 3.103842 / 4.1556828.981694; 3.784647; 5.701456.15593
2 / 41.674595 / 1.732384; 6.215624 / 6.807238; 4.742027 / 5.22685415.244768; 5.467973; 9.0289079.913883

Selected candidate · fold 1

Transform fit IDs
b1, b2, c1, c2, d1, d2
Impute / center / scale
−0.4 / −0.4 / 0.974679
Model fit IDs
b1, b2, c1, c2, d1, d2
Intercept / slope
1.094953 / 0.571713
Training MSE / objectiveObjective adds λw²; validation uses MSE alone.
1.457512 / 1.457512
Validation rows transformed with the inspected training fit
IDFilled / z / φTarget / predictionSquared error
a1−3 / −2.667544 / 7.1157898 / 5.1631468.04774
a2−2.5 / −2.154555 / 4.6421056 / 3.7489075.067418
e12 / 2.462348 / 6.0631584 / 4.5613420.315105
e22.5 / 2.975337 / 8.8526326 / 6.1561220.024374
Real fitted learning curvesSolid training MSE and dashed validation MSE. Numeric values and exact nested memberships are in the table. X is training row count; Y is MSE.246
● Solid: training MSE · □ Dashed: validation MSE · horizontal: training row count · vertical: MSE (0 to 29.5625). Every point refits the selected p=2, λ=0. No test rows enter a curve.
Fold 1 validation fixed at a1, a2, e1, e2. These descriptive curves reuse tuning validation; they are not an unbiased final test.
n / exact training IDsTraining MSE / objectiveValidation MSEFit impute / center / scale
2 / d1, d20.0625 / 0.062529.56250.75 / 0.75 / 0.25
4 / c1, c2, d1, d20 / 02.0694440 / 0 / 0.790569
6 / b1, b2, c1, c2, d1, d21.457512 / 1.4575123.363659−0.4 / −0.4 / 0.974679

Each solid or dashed point comes from an actual fit on the exact nested IDs in the table; every fit learns its own preprocessing.

Read the IDs behind the line

Inspect the first curve point and its training IDs. The next point adds a whole training group, retaining every earlier ID. Fold 1 validation membership stays fixed. Each point refits the imputer, scaler, feature transform, slope and intercept on its own training subset. The chosen power and penalty come from the explicit development CV grid above the plot; they are shared across sizes. Test observations are absent from both the curve training subsets and curve validation set.

Change a development target and watch the curve values change. Change a test target: curve fits and selected parameters stay fixed. The plot is a projection of the same authoritative numerical state as the table, equations and inspector. Use shifted data to observe how training-fitted centering absorbs a common feature translation; use constant data to see a flat, perfectly fitted synthetic case rather than a fabricated generic learning-curve shape.

A learning curve is a family of procedures

For nested training subsets T₁⊂T₂⊂⋯ and a fixed validation set V, let fⱼ be the model fitted on Tⱼ using a declared hyperparameter h. Two comparable descriptive errors are

Etrain(j)=1∣Tj∣∑i∈Tj(fj(xi)−yi)2,Eval(j)=1∣V∣∑i∈V(fj(xi)−yi)2.E_{\mathrm{train}}(j)=\frac1{|T_j|}\sum_{i\in T_j}(f_j(x_i)-y_i)^2, \quad E_{\mathrm{val}}(j)=\frac1{|V|}\sum_{i\in V}(f_j(x_i)-y_i)^2.

The horizontal coordinate is the actual row count |Tⱼ|, not a percentage without a denominator. Nested membership gives a controlled interpretation of “more data”: the larger dataset contains the smaller one. It does not guarantee that added groups are equally informative or representative. These prefixes follow seeded group order and preserve group boundaries.

Training error and validation error have the same units, squared target units. The ridge objective is different:

Jj=Etrain(j)+λwj2.J_j=E_{\mathrm{train}}(j)+\lambda w_j^2.

The table shows both, but the solid curve plots training MSE. Comparing validation MSE to a penalized objective would confuse fit error with a complexity term. Because the objective normalizes squared error by n, a fixed λ has the same per-observation convention at each size; implementations using unnormalized SSE must adjust their penalty convention accordingly.

Derive a two-row fit by hand

Consider a separate literal training pair x=(0,2), y=(0,2), no missing values, linear feature and λ=1. The fitted center is 1, population scale is 1 and transformed feature values are (−1,1). Feature and target means are 0 and 1. Feature variance and feature-target covariance are both 1. Therefore w=1/(1+1)=1/2 and b=1.

Predictions on the training pair are (1/2,3/2). Residuals prediction minus target are (1/2,−1/2), so training MSE is 1/4 and objective is 1/4+1·(1/2)²=1/2. For fixed validation x=4, y=4, the same fitted transform yields z=3, prediction 5/2 and validation MSE 9/4. The validation value does not refit the center or scale.

Adding another training pair would require a new fit. It is invalid to take the earlier w and move the plotted point to a larger sample count. The board computes every point from its actual subset and records fitted preprocessing and parameters in data-numerical.

What curve shapes suggest

Large training and validation errors can suggest underfitting, but also a poor metric, corrupt labels, uninformative features or distribution shift. A large gap can suggest overfitting or validation mismatch. These are diagnostic hypotheses. Neither pattern uniquely identifies a causal explanation. More data can increase training error as a previously easy subset becomes more diverse, and validation error can rise when newly added groups pull the fit away from this particular validation group.

Fold-to-fold CV scores above the curve expose one source of sampling variability. This bounded lab uses one nested prefix sequence and one fixed validation fold; it does not invent confidence ribbons or claim that three points estimate a variance distribution. To estimate uncertainty, repeat the entire declared sampling procedure and summarize genuinely fitted repeats, respecting group or time boundaries. More curve points alone do not make them independent repeats.

Canonical fits and explorer

X stores n×1 row observations after one scalar engineered feature; y is n×1, w is 1×1 and b is scalar. The closed-form fit recomputes all subset means. Constant features use a declared scale-one convention; with zero variance and λ=0, zero slope gives the mean-target prediction. All-missing training features are rejected because no observed training mean exists.

code/python/p08/evaluation.py
"""Canonical bounded evaluation lab. X: n×1 rows; y: n×1; one slope + intercept.

Hand data, LCG-seeded group allocation. No claim of stratification. Owner runs tests.
"""
import json
import math
import numbers
import sys
from pathlib import Path
import numpy as np

ROOT = Path(__file__).resolve().parents[3]
DATA = json.loads((ROOT / "data/p08/experiments.json").read_text())
DEFAULT = dict(rows=DATA["rows"], probabilities=DATA["probabilities"], seed=7,
               threshold=.5, folds=3, phase=0, t=1e8, delta=1e-8)
GRID = [(power, penalty) for power in (1, 2) for penalty in (0, 1, 4)]


def number(v, name="number"):
    if isinstance(v, (bool, np.bool_)) or not isinstance(v, numbers.Real):
        raise ValueError(name + " must be a real number, not Boolean")
    try:
        v = float(v)
    except (OverflowError, ValueError):
        raise ValueError(name + " is not binary64 representable") from None
    if not math.isfinite(v):
        raise ValueError(name + " must be finite")
    return v


def finite(v):
    if not math.isfinite(v):
        raise ValueError("Numerical result unavailable: binary64 overflow")
    return v


def mul(a, b):
    v = finite(a * b)
    if a != 0 and b != 0 and v == 0:
        raise ValueError("Numerical result unavailable: product underflow")
    return v


def div(a, b):
    if b == 0:
        raise ValueError("Numerical result unavailable: zero divisor")
    v = finite(a / b)
    if a != 0 and v == 0:
        raise ValueError("Numerical result unavailable: quotient underflow")
    return v


def mean(values):
    if not values:
        raise ValueError("Mean needs observations")
    if all(v == values[0] for v in values):
        return values[0]
    total = 0.
    for v in values:
        total = finite(total + div(v, len(values)))
    return total


def integer(v, low, high, name):
    v = number(v, name)
    if not v.is_integer() or not low <= v <= high:
        raise ValueError(name + " must be an integer in bounds")
    return int(v)


def identifiers(rows):
    ids = set()
    for r in rows:
        if not isinstance(r, dict) or not isinstance(r.get("id"), str) or not r["id"] or r["id"] in ids:
            raise ValueError("Unique nonempty row IDs required")
        ids.add(r["id"])


def validate(s):
    if not isinstance(s, dict):
        raise ValueError("Experiment must be an object")
    rows, probs = s.get("rows"), s.get("probabilities")
    if not isinstance(rows, list) or not 8 <= len(rows) <= 64:
        raise ValueError("8–64 dense regression rows required")
    if not isinstance(probs, list) or not 1 <= len(probs) <= 64:
        raise ValueError("1–64 dense probability rows required")
    identifiers(rows)
    identifiers(probs)
    for r in rows:
        if not isinstance(r.get("group"), str) or not r["group"]:
            raise ValueError("Nonempty group required")
        if not isinstance(r.get("x"), list) or len(r["x"]) != 1:
            raise ValueError("X must contain dense one-feature rows")
        if r["x"][0] is not None:
            number(r["x"][0], "feature")
        number(r.get("y"), "target")
    for r in probs:
        if number(r.get("y"), "binary label") not in (0, 1) or not 0 <= number(r.get("p"), "probability") <= 1:
            raise ValueError("Binary label and probability in [0,1] required")
    integer(s.get("seed"), 0, 2**32-1, "seed")
    integer(s.get("folds"), 2, 4, "folds")
    integer(s.get("phase"), 0, 4, "phase")
    if not 0 <= number(s.get("threshold"), "threshold") <= 1:
        raise ValueError("Threshold in [0,1] required")
    if not 1 <= number(s.get("t"), "t") <= 1e12 or not 1e-12 <= number(s.get("delta"), "delta") <= .1:
        raise ValueError("Numerical counterexample parameters out of bounds")


def split(rows, seed, folds):
    if not isinstance(rows, list) or not rows:
        raise ValueError("Dense regression rows required")
    identifiers(rows)
    seed = integer(seed, 0, 2**32-1, "seed")
    folds = integer(folds, 2, 4, "folds")
    for r in rows:
        if not isinstance(r.get("group"), str) or not r["group"]:
            raise ValueError("Nonempty group required")
    groups = sorted(set(r["group"] for r in rows), key=lambda g: g.encode("utf-16-be", "surrogatepass"))
    if len(groups) < folds + 2:
        raise ValueError("Need at least folds+2 groups")
    state = int(seed)
    draws = []
    for i in range(len(groups)-1, 0, -1):
        state = (1664525 * state + 1013904223) % 2**32
        draws.append(state)
        j = state * (i+1) // 2**32
        groups[i], groups[j] = groups[j], groups[i]
    test_group, dev_groups = groups[0], groups[1:]
    test = [r["id"] for r in rows if r["group"] == test_group]
    dev = [r["id"] for r in rows if r["group"] != test_group]
    boards = []
    for k in range(folds):
        held = dev_groups[k::folds]
        boards.append(dict(train=[r["id"] for r in rows if r["group"] in dev_groups and r["group"] not in held],
                           validation=[r["id"] for r in rows if r["group"] in held]))
    return dict(groups=groups, draws=draws, development=dev, test=test, folds=boards)


def take(rows, ids):
    if not isinstance(rows, list) or not 1 <= len(rows) <= 64 or not isinstance(ids, list):
        raise ValueError("Dense rows and membership arrays required")
    identifiers(rows)
    lookup = {r["id"]: r for r in rows}
    if not ids or len(set(ids)) != len(ids) or any(i not in lookup for i in ids):
        raise ValueError("Membership requires unique known IDs")
    return [lookup[i] for i in ids]


def transform_fit(rows, ids):
    selected = take(rows, ids)
    for r in selected:
        if not isinstance(r.get("x"), list) or len(r["x"]) != 1:
            raise ValueError("Dense one-feature row required")
    observed = [number(r["x"][0]) for r in selected if r["x"][0] is not None]
    impute = mean(observed)
    filled = [impute if r["x"][0] is None else number(r["x"][0]) for r in selected]
    center = mean(filled)
    variance = mean([mul(finite(v-center), finite(v-center)) for v in filled])
    scale = math.sqrt(variance) if variance > 0 else 1.
    return dict(fitIds=list(ids), impute=impute, center=center, scale=scale, constant=variance == 0)


def transform(row, state, power):
    if not isinstance(row, dict) or not isinstance(row.get("x"), list) or len(row["x"]) != 1:
        raise ValueError("Dense one-feature row required")
    integer(power, 1, 2, "power")
    number(state["impute"]); number(state["center"])
    if number(state["scale"]) <= 0:
        raise ValueError("Positive fitted scale required")
    filled = state["impute"] if row["x"][0] is None else number(row["x"][0])
    z = div(finite(filled-state["center"]), state["scale"])
    feature = z if power == 1 else mul(z, z)
    return dict(id=row["id"], filled=filled, z=z, feature=feature)


def fit(rows, train_ids, power, penalty, fit_ids=None):
    power = integer(power, 1, 2, "power")
    penalty = number(penalty, "penalty")
    if penalty < 0:
        raise ValueError("Nonnegative ridge penalty required")
    pre = transform_fit(rows, train_ids if fit_ids is None else fit_ids)
    selected = take(rows, train_ids)
    features = [transform(r, pre, power)["feature"] for r in selected]
    targets = [number(r["y"]) for r in selected]
    # NumPy holds the declared row batches; arithmetic order matches the browser contract.
    X, y = np.asarray(features, dtype=np.float64).reshape(-1, 1), np.asarray(targets, dtype=np.float64).reshape(-1, 1)
    fm, ym = mean(X[:, 0].tolist()), mean(y[:, 0].tolist())
    dx, dy = [finite(v-fm) for v in features], [finite(v-ym) for v in targets]
    variance = mean([mul(v, v) for v in dx])
    covariance = mean([mul(a, b) for a, b in zip(dx, dy)])
    denominator = finite(variance + penalty)
    slope = div(covariance, denominator) if denominator else 0.
    intercept = finite(ym - mul(slope, fm))
    model = dict(trainIds=list(train_ids), preprocessing=pre, power=power, penalty=penalty,
                 featureMean=fm, targetMean=ym, slope=slope, intercept=intercept,
                 convention="unpenalized intercept; mean squared error + lambda*slope^2; zero slope if unidentifiable")
    training = evaluate(rows, train_ids, model)
    model.update(trainingMse=training["mse"], objective=finite(training["mse"] + mul(penalty, mul(slope, slope))))
    return model


def evaluate(rows, ids, model):
    evaluated = []
    for r in take(rows, ids):
        transformed = transform(r, model["preprocessing"], model["power"])
        prediction = finite(model["intercept"] + mul(model["slope"], transformed["feature"]))
        residual = finite(prediction-number(r["y"]))
        evaluated.append(dict(transformed, target=number(r["y"]), prediction=prediction,
                              residual=residual, squaredError=mul(residual, residual)))
    return dict(ids=list(ids), rows=evaluated, mse=mean([r["squaredError"] for r in evaluated]))


def metrics(rows, threshold):
    if not isinstance(rows, list) or not 1 <= len(rows) <= 64:
        raise ValueError("1–64 dense probability rows required")
    identifiers(rows)
    threshold = number(threshold)
    if not rows or not 0 <= threshold <= 1:
        raise ValueError("Nonempty probability rows and threshold in [0,1] required")
    tp = fp = tn = fn = 0
    errors, logs = [], []
    decisions = []
    bins = [dict(low=i/5, high=(i+1)/5, ids=[], count=0, meanProbability=None, frequency=None) for i in range(5)]
    for r in rows:
        y, p = number(r.get("y")), number(r.get("p"))
        if y not in (0, 1) or not 0 <= p <= 1:
            raise ValueError("Binary label and probability required")
        positive = p >= threshold
        tp += int(positive and y == 1); fp += int(positive and y == 0)
        tn += int(not positive and y == 0); fn += int(not positive and y == 1)
        errors.append(mul(p-y, p-y))
        decisions.append(dict(id=r["id"], y=y, p=p, positive=positive, squaredError=errors[-1]))
        q = p if y else 1-p
        if q == 0:
            logs.append(None)
        else:
            logs.append(finite(-math.log(q)))
        b = next(b for b in bins if p >= b["low"] and (p < b["high"] or b["high"] == 1))
        b["ids"].append(r["id"])
    for b in bins:
        selected = [r for r in rows if r["id"] in b["ids"]]
        b["count"] = len(selected)
        if selected:
            b["meanProbability"] = mean([number(r["p"]) for r in selected])
            b["frequency"] = mean([number(r["y"]) for r in selected])
    ratio = lambda a, b: a/b if b else None
    recall, specificity = ratio(tp, tp+fn), ratio(tn, tn+fp)
    return dict(rows=decisions, tp=tp, fp=fp, tn=tn, fn=fn, accuracy=(tp+tn)/len(rows),
                balancedAccuracy=(recall+specificity)/2 if recall is not None and specificity is not None else None,
                precision=ratio(tp, tp+fp), recall=recall, f1=ratio(2*tp, 2*tp+fp+fn),
                brier=mean(errors), logLoss=mean(logs) if None not in logs else None,
                logReason="Unbounded log loss: a true class has probability zero" if None in logs else None, bins=bins)


def numerics(t, delta):
    t, delta = number(t), number(delta)
    if not 1 <= t <= 1e12 or not 1e-12 <= delta <= .1:
        raise ValueError("Counterexample parameters out of bounds")
    root = math.sqrt(finite(mul(t, t)+1))
    rounded = finite(1 + delta)
    return dict(left=(1e16 + -1e16) + 1, right=1e16 + (-1e16 + 1),
                fragile=finite(root-t), stable=div(1, finite(root+t)),
                difference=finite(rounded-1), reciprocal=div(1, finite(rounded-1)),
                nominalReciprocal=div(1, delta), condition=div(abs(rounded)+1, abs(rounded-1)))


def experiment(s):
    validate(s)
    rows = s["rows"]
    board = split(rows, s["seed"], s["folds"])
    candidates = []
    for power, penalty in GRID:
        scores = []
        for fold in board["folds"]:
            model = fit(rows, fold["train"], power, penalty)
            validation = evaluate(rows, fold["validation"], model)
            scores.append(dict(model=model, validation=validation))
        candidates.append(dict(power=power, penalty=penalty, folds=scores,
                               meanValidation=mean([f["validation"]["mse"] for f in scores])))
    # Strict less-than: an exact tie retains the first (power ascending, penalty ascending).
    chosen = candidates[0]
    for c in candidates[1:]:
        if c["meanValidation"] < chosen["meanValidation"]:
            chosen = c
    final = fit(rows, board["development"], chosen["power"], chosen["penalty"])
    test = evaluate(rows, board["test"], final)
    fold = board["folds"][0]
    train_groups = [g for g in board["groups"][1:] if any(r["group"] == g and r["id"] in fold["train"] for r in rows)]
    curves = []
    for k in range(1, len(train_groups)+1):
        ids = [r["id"] for r in rows if r["group"] in train_groups[:k]]
        model = fit(rows, ids, chosen["power"], chosen["penalty"])
        curves.append(dict(n=len(ids), ids=ids, model=model, validation=evaluate(rows, fold["validation"], model)))
    proper = fit(rows, fold["train"], 1, 1)
    leaked = fit(rows, fold["train"], 1, 1, fold["train"]+fold["validation"])
    proper_score, leaked_score = evaluate(rows, fold["validation"], proper), evaluate(rows, fold["validation"], leaked)
    m = metrics(s["probabilities"], s["threshold"])
    tradeoff = [dict(threshold=t, **{k: v for k, v in metrics(s["probabilities"], t).items() if k in ("precision", "recall", "tp", "fp", "tn", "fn")})
                for t in sorted(set([0., 1.] + [number(r["p"]) for r in s["probabilities"]]))]
    return dict(board=board, candidates=candidates, selected=dict(power=chosen["power"], penalty=chosen["penalty"]),
                final=final, test=test, curves=curves, leakage=dict(proper=proper, leaked=leaked,
                properScore=proper_score, leakedScore=leaked_score, difference=finite(leaked_score["mse"]-proper_score["mse"])),
                metrics=m, tradeoff=tradeoff, numerics=numerics(s["t"], s["delta"]),
                provenance=dict(dataVersion=DATA["version"], data=DATA["provenance"], seed=s["seed"], folds=s["folds"],
                threshold=s["threshold"], model="scalar engineered-feature ridge with intercept", grid=[list(g) for g in GRID],
                split="UTF-16 sorted groups; Fisher–Yates descending; u32 LCG; first group test; remaining round-robin CV",
                selection="equal-weight mean fold MSE; first grid candidate on exact ties", arithmetic="constant-preserving binary64 sequential mean of divided terms"))


if __name__ == "__main__":
    try:
        request = json.load(sys.stdin) if not sys.stdin.isatty() else DEFAULT
        print(json.dumps(experiment(request), allow_nan=False))
    except (ValueError, TypeError, KeyError) as error:
        print(str(error), file=sys.stderr)
        sys.exit(1)

Try two and four folds. The training groups available to fold 1 change, so the nested row counts change too. Inspect every membership before comparing curves across splits. Change the split seed using keyboard arrows or a numeric entry; reset restores seed 7 and the original rows synchronously. Replay can reveal the final test, but that test remains outside the plotted curves.

Limits and connections

The one-feature quadratic option can describe a U-shaped response while the linear option cannot; this connects to III-11. It is still not an arbitrary polynomial expansion. Scaling changes the numerical meaning of a ridge penalty, so preprocessing and hyperparameters form one fitted procedure. Missingness itself may be informative; a production experiment would examine an indicator feature or domain-specific imputation rather than assuming the mean is adequate.

The displayed toy errors cannot guarantee deployment quality. Examine data provenance, group representativeness, costs of errors and future drift before deciding to collect more data or deploy a model.

Five graded investigations

What makes the displayed training subsets nested?
In the literal two-row example, what is the ridge objective?
Does validation error have to fall as training groups are added?
May each validation batch fit a new scale for its own curve point?
Are curves using a tuning validation fold unbiased final evidence?

What you can now explain

You can connect every plotted point to a fitted subset, distinguish MSE from the ridge objective, interpret gaps cautiously and account for selection reuse. III-37 asks whether the evaluation metric measures the decision you actually care about.