Part 8 · Machine Learning · Module 56

scikit-learn in Practice

In M55 you built models by hand. Professionals use scikit-learn: one consistent API for dozens of models, preprocessing that cannot leak, honest cross-validation, and tuning in a few lines. This module teaches the library the way it is used on real tabular data, and ends with an end-to-end workflow you can reuse.

⏱ ~3.5 hoursPrerequisites: M55, M51

The estimator API

scikit-learn's great idea is that every algorithm is an object with the same few methods. Learn the pattern once and you can use any of its hundreds of classes:

Two kinds of numbers live on an estimator, and the naming convention tells them apart. Hyperparameters are settings you choose, passed to the constructor: LogisticRegression(C=0.5). Learned attributes are computed by fit and end in an underscore: coef_, intercept_, classes_, mean_. If an attribute ends in _ and you have not called fit, it does not exist yet.

Remember: fit learns, predict/transform apply. Constructor arguments are hyperparameters; trailing-underscore attributes are learned.

Preprocessing mixed tabular data

Real tables mix numbers ("age", "monthly_spend"), categories ("plan", "country"), and holes (missing values). Most models need a purely numeric matrix with no missing values, so we transform each column type differently:

Remember: impute, then scale numbers; impute, then one-hot categories; glue them with ColumnTransformer; set handle_unknown="ignore" for production safety.

Pipelines prevent leakage

A Pipeline chains transformers and a final estimator into one estimator. Calling pipe.fit(X_train, y_train) fits each step on the training data in order; calling pipe.predict(X_test) only applies the already-fitted steps. That ordering is exactly the "statistics from train only" rule of M55, enforced by code instead of discipline.

rawDataFrame ColumnTransformer impute+scale impute+1-hot numeric cols categorical cols model ŷ fit(X_train, y_train): each step learns from train only, then passes transformed data on
The whole chain behaves like one model. Cross-validation refits it inside every fold.

The danger is subtle, so here is a dramatic demonstration. We generate pure noise: 60 samples, 2,000 random features, random labels. There is nothing to learn; honest accuracy is 50%. If we select the 20 "best" features on all the data and then cross-validate, the selection step has already peeked at every test fold's labels.

Remember: put every data-dependent step inside the Pipeline, and cross-validate the Pipeline, not pre-transformed data.

A tour of the models

Linear models

LinearRegression, Ridge, Lasso, LogisticRegression. Fast, interpretable coefficients, good baselines. Need scaled features and cannot bend unless you add features.

k-nearest neighbors

KNeighborsClassifier(n_neighbors=5) predicts the majority label of the k closest training points. No training cost, slow prediction on big data, sensitive to scaling and to many features.

Decision trees

DecisionTreeClassifier(max_depth=3) asks yes/no questions ("petal length ≤ 2.45?"). Readable, no scaling needed, but a deep tree overfits badly.

Random forests

RandomForestClassifier averages many deep trees, each on a bootstrap sample with random feature subsets. Averaging cancels their variance. Strong default, little tuning.

Gradient boosting

HistGradientBoostingClassifier adds shallow trees one at a time, each fixing the previous ensemble's errors. Usually the most accurate choice on tabular data; handles missing values natively. GradientBoostingClassifier is the older, slower version.

Support vector machines

SVC(kernel="rbf") finds the widest-margin boundary, with kernels for curved boundaries. Excellent on small/medium data; scales poorly beyond ~100k rows; needs scaling.

A decision tree is the easiest model to look inside. Each box shows the question, the impurity (Gini: the chance two random samples in the box have different labels), how many samples reach it, and the class counts.

Different models draw different decision boundaries. On the curved make_moons data, compare:

Remember: start with a linear baseline, then try a random forest and a histogram gradient booster; scale features for linear models, k-NN, and SVMs.

Cross-validation

A single validation split is noisy: with 150 samples, a lucky split can swing accuracy by several points. k-fold cross-validation splits the data into k folds, trains k times, each time holding out a different fold, and averages the k scores. Every sample is used for validation exactly once, and the spread of the scores tells you how stable the estimate is.

fold 1fold 2fold 3fold 4fold 5 score 1score 2score 3score 4score 5
5-fold CV: train on four folds (light), validate on one (dark), rotate, average.

Notice "k-NN unscaled": wine's features range from about 0.1 to over 1,000, so the large ones dominate the distance. Scaling is not optional for distance-based models.

Remember: report the mean and spread of CV scores; stratify classification folds; group or time-order folds when rows are not independent.

Hyperparameter tuning

GridSearchCV tries every combination in a grid, scoring each with cross-validation, then refits the best on all the data you gave it. RandomizedSearchCV samples a fixed number of combinations from distributions, which finds good settings far faster when there are many hyperparameters (most of them usually do not matter much).

Remember: grids for one or two knobs, randomized search for many; use step__param names; keep a test set the search never touches.

Class imbalance

Fraud, churn, defects, disease: the interesting class is usually rare. With 5% positives, a model can reach 95% accuracy by never predicting the positive class. Tools that help:

Remember: with rare classes, judge by recall/precision/average precision, stratify, use class weights, and tune the threshold to the costs.

Feature importance and permutation importance

Stakeholders ask "why?". Tree ensembles expose feature_importances_: how much each feature reduced impurity across all splits. It is free but biased: it favors features with many distinct values (an ID column can look important) and is computed on training data.

Permutation importance is model-agnostic and more honest: on held-out data, shuffle one column (breaking its relationship to the target) and measure how much the score drops. A big drop means the model relies on that feature. If two features are strongly correlated, shuffling one barely hurts because the other carries the same information, so read low scores with care.

Remember: importance explains the model, not the world; prefer permutation importance on held-out data; correlated features share credit.

Saving and loading models

Training can take hours; serving must take milliseconds. So we fit once, save the fitted pipeline to a file, and load it in the serving process. scikit-learn's documented options:

Remember: save the whole fitted pipeline; joblib for your own files, skops when files cross trust boundaries; pin versions with pixi.lock.

An end-to-end workflow

Here is the shape of a real project on a tabular dataset: predict whether a subscriber will cancel ("churn") from mixed numeric and categorical data with missing values. We generate the data so the cell works offline, but every step is what you would do with a real CSV loaded by pd.read_csv.

  1. Hold out a test set first, stratified.
  2. Build a preprocessing ColumnTransformer and a baseline model inside a Pipeline.
  3. Compare candidates with cross-validation on the training set.
  4. Tune the best candidate.
  5. Evaluate once on test; inspect errors and importances.
  6. Save the fitted pipeline with its metadata.

Remember: split first, pipeline everything, compare with CV against a dummy baseline, tune, test once, save the pipeline.

Practice

scikit-learn, NumPy, and pandas are available in these exercises.

Write build_preprocessor(numeric, categorical) returning an unfitted ColumnTransformer that, for the numeric column names, imputes the median then standardizes, and for the categorical names imputes the most frequent value then one-hot encodes with handle_unknown="ignore" and sparse_output=False.

Hint

Two tuples of (name, transformer, columns); each transformer is a small Pipeline of an imputer and a scaler/encoder.

Show solution
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

def build_preprocessor(numeric, categorical):
    num = Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())])
    cat = Pipeline([
        ("impute", SimpleImputer(strategy="most_frequent")),
        ("onehot", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
    ])
    return ColumnTransformer([("num", num, list(numeric)), ("cat", cat, list(categorical))])

Write make_model(C=1.0) returning a Pipeline with two steps named "scale" (a StandardScaler) and "clf" (a LogisticRegression with the given C and max_iter=2000). Then write cv_accuracy(model, X, y) that returns the mean accuracy of 5-fold StratifiedKFold(shuffle=True, random_state=0) cross-validation as a float.

Hint

cross_val_score(model, X, y, cv=StratifiedKFold(5, shuffle=True, random_state=0)).mean(), wrapped in float(...).

Show solution
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

def make_model(C=1.0):
    return Pipeline([("scale", StandardScaler()), ("clf", LogisticRegression(C=C, max_iter=2000))])

def cv_accuracy(model, X, y):
    cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
    return float(cross_val_score(model, X, y, cv=cv).mean())

Write tune_knn(X, y) that grid-searches n_neighbors over [1, 3, 5, 7, 9, 11, 15] for a pipeline make_pipeline(StandardScaler(), KNeighborsClassifier()) with cv=5, and returns a tuple (best_k, best_cv_score) (an int and a float).

Hint

make_pipeline names the step "kneighborsclassifier", so the grid key is "kneighborsclassifier__n_neighbors".

Show solution
from sklearn.model_selection import GridSearchCV
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

def tune_knn(X, y):
    pipe = make_pipeline(StandardScaler(), KNeighborsClassifier())
    grid = GridSearchCV(pipe, {"kneighborsclassifier__n_neighbors": [1, 3, 5, 7, 9, 11, 15]}, cv=5)
    grid.fit(X, y)
    return int(grid.best_params_["kneighborsclassifier__n_neighbors"]), float(grid.best_score_)

Write top_features(model, X_val, y_val, names, k=3) that runs permutation_importance(model, X_val, y_val, n_repeats=10, random_state=0) and returns the k feature names with the largest mean importance, most important first.

Hint

np.argsort(result.importances_mean)[::-1][:k] gives the indexes of the top k.

Show solution
import numpy as np
from sklearn.inspection import permutation_importance

def top_features(model, X_val, y_val, names, k=3):
    result = permutation_importance(model, X_val, y_val, n_repeats=10, random_state=0)
    order = np.argsort(result.importances_mean)[::-1][:k]
    return [str(names[i]) for i in order]

Write threshold_for_recall(y_true, proba, target_recall) that returns the largest threshold t among the distinct values of proba such that predicting proba >= t achieves recall of at least target_recall. A larger threshold means fewer false alarms, so we want the largest one that still meets the recall goal.

Hint

Loop over sorted(set(proba), reverse=True) and return the first threshold whose recall np.mean(proba[y_true == 1] >= t) reaches the target.

Show solution
import numpy as np

def threshold_for_recall(y_true, proba, target_recall):
    y_true, proba = np.asarray(y_true), np.asarray(proba)
    positives = proba[y_true == 1]
    for t in sorted(set(proba.tolist()), reverse=True):
        if np.mean(positives >= t) >= target_recall:
            return t
    return float(proba.min())

Mini-project

Mini-projectEnd-to-end classification with a model-selection report

Build a reproducible script that selects, tunes, evaluates, and saves a classifier, and writes a Markdown report a colleague could review. Set up with pixi init churn-model, then pixi add scikit-learn pandas matplotlib.

  1. Data: generate the churn DataFrame from the workflow section (or use load_breast_cancer(as_frame=True) plus one categorical column you derive), and save it as a CSV so the script starts from a file like real work.
  2. train.py: stratified train/test split; a ColumnTransformer; at least four candidates (a dummy baseline, logistic regression, random forest, histogram gradient boosting) compared with stratified 5-fold CV on two metrics (ROC-AUC and average precision).
  3. Tune the best two candidates (grid or randomized search); pick a winner by CV, not by test.
  4. Evaluate the winner once on test: confusion matrix, classification report, ROC-AUC, and a threshold chosen for a stated business goal (for example "catch 80% of churners").
  5. Permutation importance on test; save the bar chart as PNG.
  6. Save the fitted pipeline with joblib together with the scikit-learn version, the chosen threshold, and the feature list. Write report.md with a comparison table of every candidate.
  7. Add a [tasks] entry so pixi run train runs the whole thing.
  • All preprocessing lives inside the pipeline; nothing is fitted before the split
  • The report's table shows mean ± std CV scores for every candidate, including the dummy baseline
  • The test set is used exactly once, after model selection
  • The decision threshold is justified by a stated cost or recall goal
  • The saved file reloads in a fresh process and gives identical predictions
  • pixi run train reproduces the same numbers twice in a row

Stretch: nested cross-validation for an unbiased estimate of the whole tuning procedure; calibrate probabilities with CalibratedClassifierCV (see M59); save with skops instead of joblib.

Quiz

Recap

Going further