III-38 · Preprocessing, Feature Engineering and Pipelines
A transform has a training membership
Compare identical held-out observations through a proper training-only pipeline and a contaminated fit-before-split pipeline.
Part VIII · experimental workflow sheet
Fit the transform on training rows only
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.
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
| Role | IDs |
|---|---|
| Development | a1, a2, b1, b2, c1, c2, d1, d2, e1, e2 |
| Untouched test | f1, f2 |
| Fold 1 | Train: b1, b2, c1, c2, d1, d2 Validation: a1, a2, e1, e2 |
| Fold 2 | Train: a1, a2, c1, c2, e1, e2 Validation: b1, b2, d1, d2 |
| Fold 3 | Train: a1, a2, b1, b2, d1, d2, e1, e2 Validation: c1, c2 |
Comparison fixes power 1 and λ=1, training IDs b1, b2, c1, c2, d1, d2, held-out validation IDs a1, a2, e1, e2. Only the transform fit membership changes. Leaked fitting reads held-out features, never held-out labels; even this is contamination. The difference may be positive or negative.
Proper: fit on training
- 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.666667 / −0.444591
- Training MSE / objectiveObjective adds λw²; validation uses MSE alone.
- 1.212573 / 1.410234
Leaked: fit before splitting
- Transform fit IDs
- b1, b2, c1, c2, d1, d2, a1, a2, e1, e2
- Impute / center / scale
- −0.333333 / −0.333333 / 1.760682
- Model fit IDs
- b1, b2, c1, c2, d1, d2
- Intercept / slope
- 1.654983 / −0.370274
- Training MSE / objectiveObjective adds λw²; validation uses MSE alone.
- 1.489306 / 1.62641
- Proper held-out MSE
- 20.408144
- Leaked held-out MSE
- 20.259445
- Leaked − proper MSE
- −0.148699
| ID / target | Proper filled / z / prediction / error² | Leaked filled / z / prediction / error² |
|---|---|---|
| a1 / 8 | −3 / −2.667544 / 2.852632 / 26.495402 | −3 / −1.514565 / 2.215788 / 33.45711 |
| a2 / 6 | −2.5 / −2.154555 / 2.624561 / 11.393586 | −2.5 / −1.230584 / 2.110637 / 15.127145 |
| e1 / 4 | 2 / 2.462348 / 0.57193 / 11.751665 | 2 / 1.325244 / 1.164279 / 8.041313 |
| e2 / 6 | 2.5 / 2.975337 / 0.34386 / 31.991924 | 2.5 / 1.609225 / 1.059128 / 24.412213 |
Locate the contamination
Find the transform fit IDs in the proper path. They match fold 1 training IDs. The leaked path adds fold 1 validation IDs, while the model fit IDs remain training-only in both paths. The held-out table exposes filled features, standardized coordinates and predictions for the same observations. The scalar difference is actual leaked MSE minus proper MSE, not a decorative warning label.
Change a validation feature and inspect both fitted states. The proper preprocessing statistics do not change; the leaked statistics can. Even when validation labels are never fitted, validation feature information has crossed the boundary. Reset and compare with constant or shifted data. Leakage need not improve the displayed score, and some datasets give zero difference; neither outcome makes the procedure legitimate.
Fit is distinct from transform
A fitted transform stores information learned from a declared training set T. Transform applies that stored information to any row without learning again. A pipeline orders these operations:
- Fit a mean imputer on observed training features.
- Fill missing training features with that mean; fit center and population scale on the filled training rows.
- Apply the declared feature power to the standardized scalar.
- Fit slope and intercept on training engineered features and targets.
- Apply the same fitted steps to held-out rows and evaluate predictions.
In CV, repeat the entire fit sequence separately inside each training fold. In the final model, fit it again on all development rows after selecting hyperparameters. Test rows enter only transform and evaluation. Storing a single scaler before CV is not equivalent to this process.
Derive each fitted state
Let Oₜ be the observed feature entries among training rows. With at least one observed value,
This is population RMS scale on the fitted empirical training batch, not sample standard deviation. If variance is zero, the lab stores scale 1 and marks the feature constant. All-missing training features are rejected because mₜ is unavailable. The raw n×1 batch may contain explicit null entries; after filling, the model receives dense finite n×1 values.
Feature engineering chooses φ=zᵖ, p∈2. Here p=2 means a single quadratic feature, not both linear and quadratic terms. The order matters: (x−μ)²/s² is generally different from (x²−mean(x²))/scale(x²). The selected procedure includes this order and the meaning of λ. Fitting ridge after scaling makes the penalty refer to the standardized feature’s units.
A leakage counterexample with exact answers
Take training x=(0,2), y=(0,2), and held-out x=(missing,10), y=(0,10). Use linear feature and λ=1. Both paths fit the model only on the same two training rows.
Proper fitting gives imputer 1, center 1, scale 1. Training z=(−1,1), so w=1/2 and b=1. Held-out predictions are 1 and 11/2. Squared errors are 1 and 81/4, giving held-out MSE 85/8=10.625.
Leaked transform fitting uses observed x=(0,2,10), so the imputer is 4. Filled fit values are (0,2,4,10), center is 4, and population variance is (16+4+0+36)/4=14. The scale is √14. Transformed training values are (−4/√14,−2/√14), feature mean −3/√14, variance 1/14 and covariance with y 1/√14. Therefore
Held-out transformed values are 0 and 6/√14. Predictions are 6/5 and 8/5; squared errors are 36/25 and 1764/25. Their MSE is 36. The leaked-minus-proper difference is 36−85/8=203/8=25.375. Here contamination makes the score worse. The exact sign is irrelevant to the rule: held-out feature information changed the fitted procedure.
The board applies the same paired experiment to its full hand dataset and fold 1. The literal four-row counterexample above is separately committed for owner oracle and parity tests; it is not presented as the board’s numerical opening state.
Inspect the actual implementation
The reference fits each transform from an explicit list of IDs. fit_ids defaults to model training IDs; the paired leakage path deliberately supplies training plus validation IDs. Every output includes that membership and the numerical imputer, center, scale, feature mean, slope and intercept. Canonical NumPy stores the engineered X and y as column-shaped row batches; no AutoML service or hidden estimator imports are needed.
"""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)Explorer and mistakes
Choose b2 to inspect an explicitly missing raw value, then change an observed row. The mean imputer changes only when that row belongs to its fit membership. A missing held-out value uses the stored training imputer; it does not use its held-out neighbors. Marking a feature missing is different from treating zero as missing. The editor can restore an observed zero, and the table exposes filled values so the distinction remains visible.
A frozen feature mapping like a declared square can be applied without learning a statistic. A learned category vocabulary, feature selector, dimensionality reduction or target encoder must still fit inside the training boundary. Target encoding adds label leakage hazards and requires additional within-training cross-fitting; this lab does not implement it. Do not fit preprocessing on a test set merely because it is “unsupervised.”
Limits and connections
Mean imputation assumes a useful observed mean and can erase missingness information. Scaling does not remove distribution shift or make unrelated features comparable in meaning. The lab is bounded to one raw feature, one engineered feature and a closed-form ridge fit; it is not a recommended universal production preprocessing recipe. III-29 explains fitted centering, and III-35 supplies the fold boundaries.
Five graded investigations
What you can now explain
You can distinguish fit from transform, derive imputation and scaling state, trace feature order and calculate an actual leakage difference. You can audit a complete fitted procedure by membership. III-39 adds experiment provenance and arithmetic reliability to that audit.