III-04 · Training, Validation, Testing and Generalization

Protect the question you have not answered yet

Keep every observation’s identity while changing which data can fit, choose and finally evaluate a predictor.

Chapters in Part I
  1. III-01 · What Machine Learning Actually Learns
  2. III-02 · Samples, Features, Labels and Targets
  3. III-03 · Learning Problems
  4. III-04 · Training, Validation, Testing and Generalization
  5. III-05 · Parameters, Hyperparameters, Loss and Optimization
  6. III-06 · Underfitting, Overfitting, Bias, Variance and Baselines

Keep identities while changing assignments

Hand-authored toy rows; paired IDs represent repeated observations from one group. The seed chooses assignments, not data values. Source: seeded; split seed 42. Seeded defaults split independent units roughly 60/20/20, with integer rounding shown by counts.

Drag an ID handle to a partition, or focus its handle and press Left/Right. The destination menu gives the same operation. Only handles capture touch gestures.

train · 6

Group g4
Group g4
Group g5
Group g5
Group g6
Group g6

validation · 2

Group g3
Group g3

test · 4

Group g1
Group g1
Group g2
Group g2

Group leakage: none. Duplicate signatures across partitions: none.

train X/y6 samples; 0 outside training x range.
6 × 1
validation X/y2 samples; 2 outside training x range.
2 × 1
test X/y4 samples; 4 outside training x range.
4 × 1
train baseline MSE
≈ 38.2222

Outside training x range: none.

validation baseline MSE
≈ 53.7778

Outside training x range: g3-a, g3-b.

test baseline MSE
≈ 107.7778

Outside training x range: g1-a, g1-b, g2-a, g2-b.

Same rows, training-only mean ≈ 12.3333. IDs and groups never change during repartitioning.
IDGroupxyPartitionBaselineResidual
g1-ag101test≈ 12.3333≈ 11.3333
g1-bg101test≈ 12.3333≈ 11.3333
g2-ag213test≈ 12.3333≈ 9.3333
g2-bg213test≈ 12.3333≈ 9.3333
g3-ag325validation≈ 12.3333≈ 7.3333
g3-bg325validation≈ 12.3333≈ 7.3333
g4-ag437train≈ 12.3333≈ 5.3333
g4-bg437train≈ 12.3333≈ 5.3333
g5-ag549train≈ 12.3333≈ 3.3333
g5-bg549train≈ 12.3333≈ 3.3333
g6-ag61021train≈ 12.3333≈ −8.6667
g6-bg61021train≈ 12.3333≈ −8.6667

A separate preprocessing leakage counterexample

Seed 42 selects an integer translation of this hand-authored three-row experiment. The two training rows have x = 1, 3 and y = −1, 1. Held-out x = 11, y = 9. A fixed predictor uses centered x with weight 1 and bias 0.

Training-only feature mean
≈ 2
Honest held-out MSE
≈ 0
Whole-dataset feature mean · invalid
≈ 5
Leaked held-out MSE
≈ 9
Target-leak baseline MSE · invalid
≈ 81 → ≈ 36

Whole-dataset feature centering reads held-out x before evaluation. Here it worsens validation error; leakage is invalid regardless of its direction. Whole-target centering also reads held-out y and makes the constant baseline look better.

Frame 1 / 1 · edits start from the displayed state.

Drag an ID between partitions, use Left/Right on its handle, choose its destination, or enter partition index 0/1/2. Group preservation moves paired observations together.

Three roles, not three kinds of data

Training data fit the parameters and any learned preprocessing statistics. Validation data help choose a procedure, such as polynomial degree or learning rate. Test data evaluate the selected procedure after those decisions. These roles govern which information may influence a decision; they do not change what a sample means.

Generalization is performance on new observations from a relevant data-generating process. A small training error measures fit to known targets. Held-out error is useful evidence only when the evaluation observations were not used to fit or repeatedly choose the procedure, and when their collection process relates to the intended use.

The sheet uses twelve hand-authored rows: pairs at x=0,1,2,3,4,10x=0,1,2,3,4,10 with y=2x+1y=2x+1. Each pair has two IDs but one group. The duplicated values represent repeated observations, deliberately making leakage inspectable. The seed chooses memberships, not data values.

Partition identities and independent units

For training, validation and test ID sets T,V,ET,V,E, require

T∩V=T∩E=V∩E=∅,T∪V∪E=I,T\cap V=T\cap E=V\cap E=\varnothing,\qquad T\cup V\cup E=I,

where II contains every sample ID exactly once. Changing a destination never changes xx, yy, the ID or group.

If a group is the independent unit, all its members must share a partition. Different row IDs from the same entity can remain strongly related. Likewise, identical values with different IDs can cross partitions; the sheet reports duplicate signatures separately from group leakage.

At reset, groups are preserved. Seeded splitting sorts the independent unit names by UTF-16 code-unit order, then applies descending Fisher–Yates shuffling using the displayed unsigned 32-bit LCG. Approximately 60%60\% of units train, 20%20\% validate, and the remainder test; integer rounding is reflected in actual counts. Six groups yield three, one and two groups, hence six, two and four rows.

A seed specifies an algorithmic experiment, not a guarantee of an unbiased split. Sorting before shuffling makes memberships reproducible when the same inputs arrive in another order. Disabling group preservation explicitly repartitions individual IDs and exposes the resulting leakage.

Fit and evaluate a literal baseline

To isolate partition effects, the sheet fits a constant predictor using only training targets:

cT=1∣T∣∑i∈Tyi,y^i=cT,MSE⁡A=1∣A∣∑i∈A(cT−yi)2.c_T=\frac1{|T|}\sum_{i\in T}y_i,\qquad \hat y_i=c_T,\qquad \operatorname{MSE}_A=\frac1{|A|}\sum_{i\in A}(c_T-y_i)^2.

At seed 4242, groups g4, g5 and g6 train, g3 validates, and g1 and g2 test. The six training targets are 7,7,9,9,21,217,7,9,9,21,21, so cT=74/6=37/3c_T=74/6=37/3.

The training MSE is 344/9≈38.2222344/9\approx38.2222. Validation targets are both 55, giving (37/3−5)2=484/9≈53.7778(37/3-5)^2=484/9\approx53.7778. Test targets are 1,1,3,31,1,3,3, giving MSE 970/9≈107.7778970/9\approx107.7778. These different scores use the same fitted constant and different evaluation rows. Re-estimating the constant separately on each held-out set would change the evaluated procedure.

A deliberate distribution mismatch

Choose deliberate distribution shift. Inputs at most 33 train; x=4x=4 validates; x=10x=10 tests. The training mean target is 44, and the displayed MSEs are 55, 2525 and 289289. The sheet identifies held-out inputs outside the training range.

Disjoint IDs alone do not establish that the training distribution represents deployment. Time, geography, measurement devices or collection rules can create a mismatch. Our constant baseline performs poorly away from the training range; that is a statement about this toy predictor. It does not show that every algorithm must fail on these rows.

Work the preprocessing leakage counterexample

The separate three-row example at seed 4242 has training inputs [1,3][1,3], training targets [−1,1][-1,1], held-out input 1111 and target 99. The fixed predictor is y^=x−μ\hat y=x-\mu.

Fit μ\mu on training only: μT=(1+3)/2=2\mu_T=(1+3)/2=2, so the held-out prediction is 11−2=911-2=9 and its MSE is 00. Fit the feature mean on all three inputs: μall=(1+3+11)/3=5\mu_{\mathrm{all}}=(1+3+11)/3=5, so prediction is 66 and MSE is 99.

That whole-dataset preprocessing read the held-out input before evaluation. It is invalid even though the score became worse. Changing the held-out distance leaves the honest training mean fixed while changing the leaked mean.

There is a second, direct target leak. The honest constant baseline uses the training-label mean 00, giving held-out MSE 8181. Including the held-out target produces a whole-label mean 33 and MSE 3636. The apparent improvement is information contamination, not learned generalization.

Execute the actual split reference

NumPy evaluates means and squared residuals; integer arithmetic reproduces the shared seed algorithm. A separate arithmetic oracle checks membership invariants, a closed-form LCG recurrence and exact mean/loss values. Run .venv/bin/python -B code/python/p01/split_generalization.py.

code/python/p01/split_generalization.py
"""III-04: seeded group partitions, honest baseline evaluation and preprocessing leakage."""
from copy import deepcopy
import json
from decimal import Decimal
import numpy as np
from seed import lcg
from numeric import is_finite_number

PARTITIONS = ('train', 'validation', 'test')

def _number_text(value):
    # Presentation only: duplicate identity is numeric-pair equality, never decimal spelling.
    value = float(value)
    if value == 0:
        return '0'
    text = repr(value)
    if 'e' not in text:
        return text.removesuffix('.0')
    mantissa, exponent = text.split('e')
    exponent = int(exponent)
    if -6 <= exponent < 21:
        return format(Decimal(text),'f')
    return f"{mantissa.removesuffix('.0')}e{exponent:+d}"

SPLIT_SAMPLES = [dict(id=f'g{i+1}-{copy}', group=f'g{i+1}', x=x, y=2*x+1) for i, x in enumerate([0,1,2,3,4,10]) for copy in ('a','b')]

def samples_valid(samples):
    if not 3 <= len(samples) <= 64:
        raise ValueError('Require 3–64 split samples')
    ids = set()
    for row in samples:
        if not isinstance(row['id'], str) or not row['id'].strip() or row['id'] in ids or not isinstance(row['group'], str) or not row['group'].strip() or not is_finite_number(row['x']) or not is_finite_number(row['y']) or abs(row['x']) > 20 or abs(row['y']) > 50:
            raise ValueError('Invalid sample ID, group, finite value or range')
        ids.add(row['id'])

def seed_split(samples, seed, keep_groups):
    samples_valid(samples)
    if type(keep_groups) is not bool:
        raise ValueError('Group preservation must be boolean')
    random = lcg(seed)
    # Trigger seed validation even if an input fails before the first draw.
    if type(seed) is not int or not 0 <= seed <= 0xffffffff:
        raise ValueError('Seed must be unsigned 32-bit')
    units = sorted({row['group'] if keep_groups else row['id'] for row in samples}, key=lambda text: text.encode('utf-16-be', 'surrogatepass'))
    if len(units) < 3:
        raise ValueError('Need at least three independent split units')
    for i in range(len(units)-1, 0, -1):
        j = int(next(random) * (i+1))
        units[i], units[j] = units[j], units[i]
    n_train, n_validation = max(1, int(len(units)*.6)), max(1, int(len(units)*.2))
    membership = {unit: 'train' if i < n_train else 'validation' if i < n_train+n_validation else 'test' for i, unit in enumerate(units)}
    return dict(samples=sorted(deepcopy(samples), key=lambda row: row['id'].encode('utf-16-be', 'surrogatepass')), seed=seed, keepGroups=keep_groups,
                assignments={row['id']: membership[row['group'] if keep_groups else row['id']] for row in sorted(samples, key=lambda row: row['id'].encode('utf-16-be', 'surrogatepass'))}, source='seeded')

SPLIT_CANONICAL = seed_split(SPLIT_SAMPLES, 42, True)

def derive_split(state):
    samples_valid(state['samples'])
    if type(state['seed']) is not int or not 0 <= state['seed'] <= 0xffffffff or type(state['keepGroups']) is not bool or state['source'] not in ('seeded','manual','distribution-shift') or set(state['assignments']) != {row['id'] for row in state['samples']} or any(value not in PARTITIONS for value in state['assignments'].values()):
        raise ValueError('Every sample needs exactly one valid partition')
    groups = sorted({row['group'] for row in state['samples']}, key=lambda text: text.encode('utf-16-be', 'surrogatepass'))
    group_leaks = [group for group in groups if len({state['assignments'][row['id']] for row in state['samples'] if row['group'] == group}) > 1]
    if state['keepGroups'] and group_leaks:
        raise ValueError('A group must stay in one partition')
    rows = [dict(**row, partition=state['assignments'][row['id']]) for row in state['samples']]
    training = [row for row in rows if row['partition'] == 'train']
    mean = float(np.mean([row['y'] for row in training])) if training else None
    pair_partitions = {}
    for row in rows:
        pair_partitions.setdefault((row['x'],row['y']),set()).add(row['partition'])
    duplicates = [f'{_number_text(x)}|{_number_text(y)}' for (x,y),partitions in pair_partitions.items() if len(partitions)>1]
    subsets = []
    for partition in PARTITIONS:
        data = [row for row in rows if row['partition'] == partition]
        predictions = [] if mean is None else [dict(id=row['id'], prediction=mean, residual=mean-row['y']) for row in data]
        residual = np.array([[row['residual']] for row in predictions], dtype=np.float64)
        mse = float(np.mean(residual**2)) if mean is not None and data else None
        reason = 'The training set is empty: its baseline mean is undefined.' if mean is None else f'No {partition} samples: MSE is undefined.' if not data else None
        outside = [row['id'] for row in data if row['x'] < min(item['x'] for item in training) or row['x'] > max(item['x'] for item in training)] if training else []
        subsets.append(dict(partition=partition, rows=data, count=len(data), shape=[len(data),1], predictions=predictions, mse=mse, reason=reason, outOfRange=outside))
    return dict(state=deepcopy(state), rows=rows, groups=groups, groupLeaks=group_leaks, duplicateLeaks=duplicates, baselineMean=mean, subsets=subsets, partitionCount=len(rows), provenance='Hand-authored toy rows; paired IDs represent repeated observations from one group. The seed chooses assignments, not data values.')

def assign_split(state, identifier, partition):
    derive_split(state)
    selected = next((row for row in state['samples'] if row['id'] == identifier), None)
    if selected is None or partition not in PARTITIONS:
        raise ValueError('Unknown sample or destination')
    return {**deepcopy(state), 'source':'manual', 'assignments':{row['id']: partition if row['id'] == identifier or (state['keepGroups'] and row['group'] == selected['group']) else state['assignments'][row['id']] for row in state['samples']}}

def shift_split(state):
    derive_split(state)
    return {**deepcopy(state), 'source':'distribution-shift', 'assignments':{row['id']:'train' if row['x'] <= 3 else 'validation' if row['x'] <= 4 else 'test' for row in state['samples']}}

def split_trace(state):
    derive_split(state)
    return [dict(index=i,state=deepcopy(state) if i==0 else seed_split(state['samples'],(state['seed']+i)&0xffffffff,state['keepGroups']),
                 explanation='Starting assignments; no sample data changes.' if i==0 else f"Repartition with unsigned seed {(state['seed']+i)&0xffffffff}; preserve all sample IDs and data.") for i in range(4)]

def leakage_example(seed, distance=10):
    if not is_finite_number(distance) or not 6 <= distance <= 20:
        raise ValueError('Held-out distance must be finite and 6–20')
    offset = int(next(lcg(seed))*4)
    X_train = np.array([[offset],[offset+2]], dtype=np.float64)
    X_validation = np.array([[offset+distance]], dtype=np.float64)
    target = distance-1
    train_mean = float(X_train.mean())
    whole_mean = float(np.concatenate((X_train, X_validation)).mean())
    honest = float((X_validation-train_mean)[0,0])
    leaked = float((X_validation-whole_mean)[0,0])
    return dict(seed=seed, distance=distance, offset=offset, trainX=X_train[:,0].tolist(), trainY=[-1,1], validationX=offset+distance, validationTarget=target,
                trainingMean=train_mean, wholeMean=whole_mean, honestPrediction=honest, leakedPrediction=leaked, honestMSE=(honest-target)**2, leakedMSE=(leaked-target)**2,
                targetCentering=dict(trainingMean=0, wholeMean=target/3, honestMSE=target**2, leakedMSE=(target/3-target)**2),
                reason='Whole-dataset feature centering reads held-out x before evaluation. Here it worsens validation error; leakage is invalid regardless of its direction. Whole-target centering also reads held-out y and makes the constant baseline look better.')

if __name__ == '__main__':
    print(json.dumps(dict(split=derive_split(SPLIT_CANONICAL), leakage=leakage_example(42)), indent=2, allow_nan=False))

Golden cases cover seed boundaries, input-order invariance, group and individual splits, manual moves and deliberate shift. The immutable replay stores the starting state followed by three successive seeds, with complete rows and memberships in every frame.

Explore and preserve the evaluation boundary

Move g1-a to validation with groups enabled. Its paired row moves too. Disable preservation, then move just one member: inspect both group and duplicate warnings. A dropdown, numeric partition index and handle arrow keys perform the same membership operation as dragging.

Move every group out of training. The baseline mean and losses become undefined with a reason. Empty validation or test partitions also have undefined MSE; they do not earn a perfect zero. Replay selects actual historical memberships. Editing starts from that displayed frame and clears history; reset restores seed 4242 and the original rows.

Limits and connections

Three-way seeded splitting requires at least three independent units. This toy demonstration does not estimate uncertainty, implement detailed cross-validation, or prove a split is appropriate for temporal data. Seeds use integers in [0,232−1][0,2^{32}-1]; changing the seed does not manufacture independent observations.

Arithmetic goldens use 10−1110^{-11} relative-plus-absolute tolerance, while identity, group membership and ordering checks are exact. The next chapter inspects the fitting update. The capacity lab then couples editable memberships to actual model complexity and training-only fitting.

Check your understanding

What is the test set’s role after model choices are made?
Two rows are repeated measurements of the same entity. How should a group split handle them?
For seed 42, train x = [1, 3] and held-out x = 11. Which centering mean is honest?
In the centering example, leakage changes validation MSE from 0 to 9. Is it acceptable?
The training partition becomes empty. What is its mean baseline?

Checkpoints

You can distinguish fitting, choosing and evaluating; preserve ID and group invariants; calculate a training-only baseline on held-out rows; and identify leakage even when its score gets worse. A useful evaluation protects the information boundary and states which future observations it represents.