What goes wrong when the train/test rule is kept by hand, and how one object keeps it for us
fit and transform calls that must stay in the right order, on the right data, forever.total_bedrooms has holes)ocean_proximityPredicts a district as the average of the 10 most similar training districts.
Two properties matter here: it depends on the scale of the columns, and it is fast.
Preprocessing: fill the holes with the median, then standardize. The k-NN lesson studies the model itself.
imputer = SimpleImputer(strategy='median')
scaler = StandardScaler()
model = KNeighborsRegressor(n_neighbors=10)
A_train = scaler.fit_transform(imputer.fit_transform(X_train[num])) # learn on train
model.fit(A_train, y_train)
A_test = scaler.transform(imputer.transform(X_test[num])) # replay on test
pred = model.predict(A_test)
fit_transform on train, transform on test.Each bug below changes one thing in the prediction code. The trained model is the same every time.
Training 200, NaN, 400, 600, 800; new NaN, 900 (NaN, not a number: missing). Predict. Re-fit on the 2 new rows, or on all 7: what does 900 become?
| the scaler is re-fit on… | MAE |
|---|---|
| the whole test set at once | $42,814 |
| each region's batch | $65,754 |
| one district at a time | $116,049 |
the model refuses: Input X contains NaN.
the lucky case: an error
silent: raw numbers (a total_rooms of 2,000 where it learned values around 0), a confident prediction for every district
The model receives numbers, not names: column 5 is "population" only because it was column 5 during training. Swap households and population:
a district has about three times more residents than households; the only hint is a UserWarning
scikit-learn compares the names with those of fit and refuses.
Three of the six bars are worse than ignoring the data. The most dangerous is the second one: the bug that looks correct.
Pipeline: one estimator, many steps
pipe = make_pipeline(SimpleImputer(strategy='median'), StandardScaler(),
KNeighborsRegressor(n_neighbors=10))
pipe.fit(X_train[num], y_train) # every step learns from the training rows
pred_pipe = pipe.predict(X_test[num]) # every step replays what it learned
4,128 predictions one by one = all at once
refused: the names must match those of fit
pipe.predict never calls fit on a transformer: the batch size stops mattering.fit + transform); the last can be anything with fit. The pipeline exposes the methods of its last step: predict, score, predict_proba.fit and predict do, step by stepfit: each transformer is fit on what it receives and transforms it for the next one; the last step is only fit. predict: only transform, with the parameters frozen at fit time.
A do-nothing step that reports, for total_bedrooms, the missing values and the mean.
| call | rows | missing | mean |
|---|---|---|---|
| fit · raw | 16,512 | 163 | 538.92 |
| fit · imputed | 16,512 | 0 | 537.92 |
| predict · raw | 4,128 | 44 | 533.66 |
| predict · scaled | 4,128 | 0 | −0.01 |
No: the test rows are measured with the ruler of the training rows. A test mean of exactly 0.00 would be the symptom of bug 1.
During fit, only the 16,512 training rows exist. The 44 test holes get the training median.
Pipeline or make_pipeline
pipe_named = Pipeline([('impute', SimpleImputer(strategy='median')),
('scale', StandardScaler()),
('model', KNeighborsRegressor(n_neighbors=10))])
make_pipeline names each step after its class: 'simpleimputer', 'standardscaler', 'kneighborsregressor'.Pipeline takes the names from you.Same kind of object; names only matter when you refer to a step later. Short explicit names pay off in long pipelines.
ColumnTransformer
preprocess = ColumnTransformer([
('num', make_pipeline(SimpleImputer(strategy='median'), StandardScaler()), num),
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), ['ocean_proximity']),
])
full = make_pipeline(preprocess, KNeighborsRegressor(n_neighbors=10))
full.fit(X_train, y_train) # the raw 9-column table goes in
ocean_proximitya triple (name, transformer, columns); a branch can itself be a pipeline
get_feature_names_out says what each of the 13 columns is: the prefix is the branch (num__, cat__), the one-hot columns carry the category. The missing total_bedrooms receives the training median, 437.
remainderColumns no branch mentions are dropped by default: 2 columns out.
'passthrough' appends them unchanged: 9 columns, but unscaled (and one is text).
make_column_selector(dtype_include='number') picks columns by type when fit.
They trust the types: a zip code stored as a number lands in the numeric branch.
'drop' is the safe default: a forgotten identifier never reaches the model silently. Explicit lists say exactly what happens.ColumnTransformer selects by name, so bug 3 disappears.'LAKE', a category never seen: zeros in the five one-hot columns and a prediction, $90,030, the same as with its real label.handle_unknown='ignore' the same row raises Found unknown categories, and one new value in production takes the whole service down. ISLAND has only 5 districts in the whole table.Fit a step on all 20,640 districts before the split, or inside the pipeline. Two kinds of step:
learns means and standard deviations; never looks at the price
replaces each zone by the average price of that zone (zones: coordinates rounded to ~1 km)
12,590 zones · 8,237 with a single district · 44% of test zones unseen in training
| encoder fit on all rows | $14,567 |
| encoder inside the pipeline | $42,552 |
| no zone at all | $60,383 |
Predict. Switch to test = later period, then to fit on all rows. Do the test rows look more or less shifted? Did the model get any better?
Pipeline: fit walks the steps with fit_transform, predict only with transform. Same predictions, the three bugs gone.ColumnTransformer: a branch per group of columns, selected by name; remainder='drop'; handle_unknown='ignore'.| symptom | what to do |
|---|---|
| fine in the notebook, bad in production | fit once, inside a pipeline |
| a score far better than expected | put every fitted step inside the pipeline |
Pace: steps 1 to 3 in 10 min; step 4 and the first simulation in 10 min; step 5 and the second in 10 min.