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.
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
validation · 2
test · 4
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.
| ID | Group | x | y | Partition | Baseline | Residual |
|---|---|---|---|---|---|---|
| g1-a | g1 | 0 | 1 | test | ≈ 12.3333 | ≈ 11.3333 |
| g1-b | g1 | 0 | 1 | test | ≈ 12.3333 | ≈ 11.3333 |
| g2-a | g2 | 1 | 3 | test | ≈ 12.3333 | ≈ 9.3333 |
| g2-b | g2 | 1 | 3 | test | ≈ 12.3333 | ≈ 9.3333 |
| g3-a | g3 | 2 | 5 | validation | ≈ 12.3333 | ≈ 7.3333 |
| g3-b | g3 | 2 | 5 | validation | ≈ 12.3333 | ≈ 7.3333 |
| g4-a | g4 | 3 | 7 | train | ≈ 12.3333 | ≈ 5.3333 |
| g4-b | g4 | 3 | 7 | train | ≈ 12.3333 | ≈ 5.3333 |
| g5-a | g5 | 4 | 9 | train | ≈ 12.3333 | ≈ 3.3333 |
| g5-b | g5 | 4 | 9 | train | ≈ 12.3333 | ≈ 3.3333 |
| g6-a | g6 | 10 | 21 | train | ≈ 12.3333 | ≈ −8.6667 |
| g6-b | g6 | 10 | 21 | train | ≈ 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.
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 with . 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 , require
where contains every sample ID exactly once. Changing a destination never changes , , 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 of units train, 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:
At seed , groups g4, g5 and g6 train, g3 validates, and g1 and g2 test. The six training targets are , so .
The training MSE is . Validation targets are both , giving . Test targets are , giving MSE . 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 train; validates; tests. The training mean target is , and the displayed MSEs are , and . 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 has training inputs , training targets , held-out input and target . The fixed predictor is .
Fit on training only: , so the held-out prediction is and its MSE is . Fit the feature mean on all three inputs: , so prediction is and MSE is .
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 , giving held-out MSE . Including the held-out target produces a whole-label mean and MSE . 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.
"""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 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 ; changing the seed does not manufacture independent observations.
Arithmetic goldens use 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
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.