Three classic bugs, measured, and the scikit-learn objects that remove them
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 rows | new rows |
|---|---|
| 200, NaN, 400, 600, 800 | NaN, 900 |
NaN (not a number) marks a missing value.
median 500 → column 200, 500, 400, 600, 800
mean 500, std \(\sqrt{40{,}000} = 200\)
new rows: NaN → 500 → 0; 900 → \((900-500)/200\) = 2
median 900 → both rows become 900
mean 900, no spread
new rows: 0 and 0
| 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.
log = FunctionTransformer(np.log1p, feature_names_out='one-to-one') # nothing to learn
| skewness of the four count columns | |
|---|---|
| before | 3.85, 3.32, 5.17, 3.26 |
| after \(\log(1+x)\) | −1.09, −1.0, −1.04, −1.06 |
fit does nothing, so it cannot leak. 'one-to-one' keeps get_feature_names_out working.A clipper: learn the 1st and 99th percentiles of each column on the training rows, pull every value outside them back to the bound.
__init__ only stores the hyperparameters.fit learns and returns self; learned state ends with _ (lower_, upper_).transform applies what was learned, after checking it was fit.TransformerMixin gives fit_transform; OneToOneFeatureMixin the feature names.total_rooms: 162.2 to 11,248.0
total_rooms multiplied by 1,000 (a unit error). What happens to its prediction, with and without the clipper?| true value | $65,600 |
| correct row | $90,030 |
| typo, no clipper | $251,020 |
| typo, clipper | $145,690 |
Still wrong, and no transformer can fix bad input. But the damage is bounded, and the bound was learned from training data only.
full.named_steps, or indexing like a list: full[-1]named_transformers_['num'][0].statistics_: the eight training medians (437 bedrooms)full[:-1] = the preprocessing only: (2, 13)joblib.dump(full, 'housing-knn.joblib')
3.9 MB (k-NN keeps all the training districts); identical predictions after the round trip.
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?
full.get_params() # 46 parameters, e.g. columntransformer__num__simpleimputer__strategy
full.set_params(kneighborsregressor__n_neighbors=20)
step__parameter.GridSearchCV and these names.Pipeline: two calls, the same predictions, the three bugs gone by construction.ColumnTransformer: a branch per group of columns, selected by name.FunctionTransformer, or a class with fit/transform.1. load and look
2. split once
3. the preprocessing, column by column
4. fit on train, score on test
5. what each step learned
6. what the model sees
7. new passengers, raw
8. save, load, compare
9. a transformer of your own: the title