Week 2 · The project continued: pipelines and tuning
Read the words of the video
- Remember preparing ingredients one at a time for cooking? It's easy to make mistakes, like forgetting to wash test vegetables the same way, or tasting with the same spoon you cooked with. This week, we build an assembly line for our data, just like a perfect kitchen, ensuring every step is done identically, every time.
- This assembly line is called a 'Pipeline'. It takes raw data, processes it through steps, and gives predictions. To taste our dish fairly, we use 'cross-validation', like having several tasters, each trying a different portion. This helps us truly improve our recipe by trying different settings.
- Imagine a model that scores a perfect zero on the data it studied, but then struggles badly on new data. This is classic 'overfitting'! It’s like memorising an exam answer key, but then failing when the questions change. Always judge a model by fair cross-validation, not just its training performance.
- For the exam, remember how to count models trained: combinations times folds, plus one for the final 'refit'! With Pipelines, fair cross-validation, and smart tuning, you can build great models. You’ve learned to prepare data and evaluate models honestly, like a master chef testing new recipes!
The one-minute story
Last week you prepared your ingredients one at a time. Doing that by hand every time is how mistakes sneak in: you forget to wash the test vegetables the same way, or you taste the dish with the same spoon you cooked with.
This week we build an assembly line: a Pipeline that takes raw data in one end and gives predictions out the other, with every step done identically, every time, in the right order.
Then we taste the dish fairly using cross-validation (several tasters, each trying a different portion), and improve the recipe by trying different settings: grid search tries every combination on a menu, random search samples some of them. Finally, one honest taste with the test set we locked away.
By the end of this week you can:
- build Pipelines and ColumnTransformers and set their parameters with double underscores;
- explain why a decision tree overfits and show it with train vs cross-validated error;
- use cross-validation (
cross_val_score,KFold,LeaveOneOut) and handle negative scoring; - count the models a
GridSearchCVorRandomizedSearchCVwill train; - evaluate the final model on the test set once.
Words you need
- Pipeline - a chain of steps: some transformers, then (optionally) a final estimator.
- ColumnTransformer - applies different transformers to different columns, then glues the results side by side.
- Hyperparameter - a setting you choose before training (tree depth, learning rate,
alpha). Not learned from data. - Parameter - a number the model learns (
coef_, split points). - Cross-validation (CV) - splitting the training data into folds and rotating which fold is held out.
- Grid / randomised search - automated hyperparameter tuning with CV.
- Refit - retraining the best model on all the training data once the search is done.
Idea 1: Pipelines
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
pipe = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
("model", LinearRegression()),
])
pipe.fit(X_train, y_train) # fit_transform through the first two, fit on the last
pipe.predict(X_test) # transform through the first two, predict with the last
pipe.score(X_test, y_test)
Rules the exam likes:
- All steps but the last must be transformers (they need
fitandtransform). The last step can be anything: another transformer or a predictor. (So "the last step must be a transformer" is false.) - A
Pipelineis itself an estimator: you canfit,predict, put it in a grid search, or nest it in another pipeline (composition). make_pipeline(StandardScaler(), LinearRegression())is the shortcut: it names the steps for you in lowercase class names:['standardscaler', 'linearregression'].- Access a step:
pipe.named_steps["model"]. - Set or search a nested hyperparameter with double underscores:
"step_name__param_name", e.g.model__fit_intercept,impute__strategy.
pipe.set_params(model__fit_intercept=False)
Why pipelines? (1) No data leakage: inside cross-validation the imputer and scaler are re-fitted on each training fold only. (2) One object to fit, predict, save and tune. (3) Less code, fewer mistakes.
Idea 2: ColumnTransformer
Numbers and categories need different treatment:
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
num_pipe = Pipeline([("imp", SimpleImputer(strategy="median")),
("sc", StandardScaler())])
prep = ColumnTransformer([
("num", num_pipe, ["area", "rooms"]), # name, transformer, columns
("cat", OneHotEncoder(handle_unknown="ignore"), ["city"]),
])
full = Pipeline([("prep", prep), ("model", LinearRegression())])
On our 8-house table (6 training rows) the transformed matrix is (6, 5): two scaled numeric columns plus three one-hot city columns, named by prep.get_feature_names_out():
['num__area', 'num__rooms', 'cat__city_A', 'cat__city_B', 'cat__city_C']
- Columns you do not list are dropped by default (
remainder="drop"). Useremainder="passthrough"to keep them untouched. (With onlyarealisted and the default, the output has 1 column; withpassthroughit keeps the other two as well: 3 columns.) make_column_transformer(...)is the name-free shortcut;make_column_selector(dtype_include=np.number)picks columns by type.- If any piece returns a sparse matrix (one-hot), ColumnTransformer decides whether the result is sparse by density.
Idea 3: Train a model, and watch it overfit
On the diabetes data set that ships with scikit-learn (442 patients, 10 features, target = disease progression), with an 80/20 split and random_state=42:
from sklearn.metrics import root_mean_squared_error
from sklearn.model_selection import cross_val_score
for model in [LinearRegression(), DecisionTreeRegressor(random_state=0)]:
model.fit(X_train, y_train)
rmse_train = root_mean_squared_error(y_train, model.predict(X_train))
cv_rmse = np.sqrt(-cross_val_score(model, X_train, y_train, cv=5,
scoring="neg_mean_squared_error"))
The numbers we got (your last digits could differ with other library versions):
| Model | Train RMSE | 5-fold CV RMSE | Test RMSE |
|---|---|---|---|
| LinearRegression | 53.6 | 56.0 | 53.9 |
| DecisionTreeRegressor (default) | 0.0 | 80.7 | 72.9 |
| RandomForestRegressor (100 trees) | 21.6 | 59.0 | 55.8 |
The decision tree scored a perfect 0 on the data it studied, and much worse on fresh data (80.7). It memorised the training set, noise and all: classic overfitting. The linear model scores about the same on seen and unseen data: it is honest but modest. The forest sits between: far better than one tree on new data because averaging many trees cancels their individual quirks (MLT week 11).
Lesson: training error alone is a lie. Always judge a model by cross-validation (or a held-out set).
Note on API versions: older tutorials write
mean_squared_error(y, pred, squared=False)for RMSE. That argument was removed in newer scikit-learn; useroot_mean_squared_error(or take thenp.sqrtyourself). If a question shows the old form, treat it as the RMSE idea.
Idea 4: Cross-validation
-fold cross-validation with :
- Split the training data into 5 equal folds.
- Train on 4 folds, validate on the 5th. Rotate so every fold is the validation fold once.
- You get 5 scores; their mean is the CV estimate, their spread tells you how stable it is.
scores = cross_val_score(model, X_train, y_train, cv=5) # array of 5 numbers
- The default score is the estimator's own
score: for regressors, accuracy for classifiers. For linear regression on diabetes,cv=5gave values[0.43 0.523 0.483 0.426 0.55], mean . - scikit-learn maximises scores, so error metrics are returned negated:
scoring="neg_mean_squared_error"gives negative numbers. To get RMSE:np.sqrt(-scores). cv=5with a classifier automatically uses stratified folds (class ratios preserved).KFold(n_splits=4)on 10 points gives validation sizes3, 3, 2, 2(the first folds take the extra points).LeaveOneOut(): folds, each holding out one example: almost unbiased but costs fits. Use it ascross_val_score(model, X, y, cv=LeaveOneOut()).cross_val_predictreturns, for each example, the prediction made when it was in the validation fold (useful for confusion matrices in week 6).ShuffleSplittakes random subsamples that may overlap.
Idea 5: Fine-tuning hyperparameters
A hyperparameter is chosen by you; a parameter is learned. Instead of guessing, search with cross-validation.
from sklearn.model_selection import GridSearchCV
param_grid = {"max_depth": [2, 3, 4, 5], "min_samples_leaf": [1, 5, 10]}
grid = GridSearchCV(DecisionTreeRegressor(random_state=0), param_grid,
cv=5, scoring="neg_mean_squared_error")
grid.fit(X_train, y_train)
grid.best_params_, grid.best_score_, grid.best_estimator_
Counting the work (a favourite question):
Here combinations, folds fits, plus 1 refit of the best model on all training data (refit=True is the default). For the grid {"n_estimators": [100,200,300], "max_depth": [None,5,10,20], "max_features": ["sqrt","log2"]} there are combinations, so with cv=5 that is 120 fits (121 with the refit). If the grid is a list of dictionaries, the combination counts add: [{"a":[1,2,3],"b":[4,5]}, {"c":[6,7]}] gives .
RandomizedSearchCV samples n_iter random combinations (from lists or from probability distributions) instead of trying all of them:
from sklearn.model_selection import RandomizedSearchCV
rs = RandomizedSearchCV(est, {"max_depth": range(1, 11), "min_samples_leaf": range(1, 21)},
n_iter=7, cv=3, random_state=0)
That tests only of the possible combinations: fits. Its main advantage: the cost is fixed by you (n_iter) however big the search space, it handles continuous ranges, and in practice it finds near-best settings much faster than a full grid.
Useful attributes: best_params_, best_score_ (a mean CV score, negated if you used a neg_ metric), best_estimator_, cv_results_.
Idea 6: The final evaluation, once
After tuning, evaluate the chosen model on the test set once:
final_model = grid.best_estimator_
rmse_test = root_mean_squared_error(y_test, final_model.predict(X_test))
Expect it to land close to the CV estimate (often slightly worse, because you picked the winner using the training data). Do not go back and tweak after seeing the test score, or the test set stops being honest. A 95% confidence interval for the generalisation error can be built from the per-example squared errors with a distribution (scipy.stats.t.interval). Save the model with joblib.dump(final_model, "model.pkl") and reload with joblib.load.
Custom transformers. To make your own, write a class with fit (returns self) and transform, and inherit BaseEstimator and TransformerMixin (you then get get_params/set_params and fit_transform for free). FunctionTransformer(np.log1p) wraps a plain function.
Solved examples
Question. A GridSearchCV with cv=4 uses {"C": [0.1, 1, 10], "kernel": ["linear", "rbf"], "gamma": ["scale", "auto"]}. How many models are trained, counting the final refit?
Solution. Combinations: . Fits: . Plus the refit: 49.
Question. cross_val_score(..., scoring="neg_mean_squared_error", cv=2) returns [-4.0, -9.0]. What are the fold RMSEs and their average?
Solution. Negate: MSE . RMSE . Average RMSE . (Note: the mean of the RMSEs is not the RMSE of the mean MSE, which would be . State which you compute.)
Question. Which is valid? (a) Pipeline([("model", LinearRegression()), ("sc", StandardScaler())]) (b) Pipeline([("sc", StandardScaler()), ("model", LinearRegression())]) (c) Pipeline([("sc", StandardScaler()), ("pca", PCA())]).
Solution. (a) Invalid: a predictor (no transform) sits in an intermediate position. (b) Valid: transformer, then a predictor last. (c) Valid: the last step may also be a transformer.
Your turn
In Pipeline([("pre", prep), ("model", Ridge())]), write the grid key to tune Ridge's alpha.
Need a hint?
step__param.
Show the full solution
"model__alpha", e.g. GridSearchCV(pipe, {"model__alpha": [0.1, 1, 10]}, cv=5).
RandomizedSearchCV(est, dist, n_iter=20, cv=5). How many fits, with refit?
Need a hint?
.
Show the full solution
fits, refit .
Data has columns a, b (numeric), c (city with 4 categories) and d (an ID you want to ignore). A ColumnTransformer scales a, b and one-hot encodes c, leaving the default remainder. How many output columns?
Need a hint?
Unlisted columns are dropped.
Show the full solution
(scaled) (one-hot) . Column d is dropped. With remainder="passthrough" it would be 7.
A tree has train RMSE 0.5 and CV RMSE 18. A linear model has train RMSE 15 and CV RMSE 16. Which do you trust more, and what is the tree's problem?
Need a hint?
Compare the gap between train and CV.
Show the full solution
Trust the linear model (CV error 16 beats 18, and train/CV agree). The tree overfits (a huge gap): it memorised the training set.
(a) GridSearchCV can only tune estimators, not whole pipelines. (b) With cv=5, cross_val_score returns 5 numbers. (c) neg_mean_squared_error values are positive. (d) RandomizedSearchCV always finds the global best setting.
Need a hint?
Recall the lesson.
Show the full solution
(a) False: a pipeline is an estimator. (b) True. (c) False: negated, so they are . (d) False: it samples; it is fast but not exhaustive.
Exam radar
MLP Quiz 1 summaries mention cross-validation in 16 papers, ColumnTransformer in 15, pipeline in 9 (and "Pipeline and ColumnTransformer" in 6), GridSearchCV in 9, cross-validation and grid search in 6, StratifiedKFold in 6, LOOCV in 7, shuffle split in 4. In the End Term, ensembles' hyper-parameter grids are counted ("how many unique combinations are in the grid?"), RandomizedSearchCV's advantage over a full grid is asked, and assertion-reason items about pipelines appear ("the last step must be a transformer: false").
Expect: count the fits; which LeaveOneOut code is right; the grid key with double underscores; what remainder does; and whether a given list of steps is a valid pipeline.
- Total fits combinations folds, plus one refit if the question counts it (read the wording).
- Error scorers are negated; take
np.sqrt(-score)for RMSE. - Only the last step of a Pipeline may lack
transform. - Unlisted columns in a ColumnTransformer are silently dropped.
- Training error of an unrestricted tree is 0: that is not good news.
- Do not tune on the test set.
Quick quiz
A GridSearchCV has 3 values of one hyperparameter, 4 of another, and cv=5. Without counting the refit, how many models are trained?
- 12
- 17
- 60
- 15
In a scikit-learn Pipeline, which step may be a predictor that has no transform method?
- the first
- any middle step
- only the last
- none
How do you tune the alpha of a Ridge step named "model" inside a pipeline?
- alpha
- model.alpha
- model__alpha
- model_alpha
Select all TRUE statements. (select all that apply)
- cross_val_score with scoring "neg_mean_squared_error" returns negative numbers.
- RandomizedSearchCV fixes the number of combinations with n_iter.
- A fully grown decision tree has a high training error.
- ColumnTransformer keeps unlisted columns by default.
Recap
- Pipeline = transformers then a final estimator; no leakage; tune nested parameters with
step__param. ColumnTransformer treats columns differently; unlisted columns are dropped by default. - Trees overfit: train error 0, validation error large. Judge with cross-validation (
cross_val_score; scores are negated for errors;LeaveOneOut= folds). - GridSearchCV fits models (+1 refit); RandomizedSearchCV fits .
- Evaluate on the test set once at the end.
- Fits ; list-of-dict grids add combinations
- RMSE from negated MSE scores:
np.sqrt(-scores) Pipeline([(name, step), ...]),make_pipeline(...); tune with"step__param"ColumnTransformer([(name, transformer, columns)], remainder="drop"|"passthrough")cross_val_score(est, X, y, cv=5, scoring=...);LeaveOneOut();KFold(n_splits=k)
Practise with real exam questions
These come straight from past papers (with the real figures and the official answer key). Open 40 real questions on this week All Quiz 1 papers