ML Pipelines and Experiment Tracking
Applied ML in Production · Session 7
Six sessions in, our modelling code is a sequence of instructions in a human head: encode the categoricals, split, take the training medians, fill both sides, fit the scaler on train, transform both, train, threshold at 0.20 — unless the loan is large, in which case 0.15.
It works because we have been careful. It will stop working the first time somebody runs the cells out of order, or trains on a fresh extract, or tries to serve the model from a web service that has no idea what our training medians were.
This session replaces that discipline with two pieces of machinery:
- a
Pipeline, so the entire chain from raw columns to prediction is one object that cannot be assembled wrongly, and - an experiment tracker, so every run's parameters, metrics and model file are recorded instead of remembered.
Neither improves the model. Both are what make a model something a team can own.
How to work through this
Type the code. The pipeline section is the most valuable twenty lines in the module — you will reuse them on every project you ever do.
Run each cell, read the output, then read the commentary. If a cell errors, run from the top.
Learning objectives
After this session you will be able to:
- Build a
ColumnTransformerthat treats numeric and categorical columns differently, inside a singlePipeline. - Explain why a pipeline makes the session 2 leakage rule automatic.
- Cross-validate and grid-search a whole pipeline, preprocessing included.
- Add engineered features inside a pipeline rather than before it.
- List what has to be recorded for a run to be reproducible.
- Log parameters, metrics and models to MLflow, and compare runs.
- Reload a logged model and make a prediction from it.
Setup
Note what is not here: no get_dummies, no fillna, no StandardScaler
applied by hand. The pipeline will do all of it.
%matplotlib inline
from pathlib import Path
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
pd.set_option("display.width", 130)
def find_repository_root():
for folder in [Path.cwd(), *Path.cwd().parents]:
if (folder / "data").is_dir():
return folder
raise FileNotFoundError("could not find the repository root")
ROOT = find_repository_root()
loans = pd.read_csv(ROOT / "data" / "loan_default.csv", parse_dates=["application_date"])
NUMERIC = ["loan_amount", "tenure_months", "interest_rate", "previous_loans",
"days_past_due_history", "annual_income", "credit_score"]
CATEGORICAL = ["sector", "employment_type", "has_collateral"]
X = loans[NUMERIC + CATEGORICAL] # raw columns, gaps and text included
y = loans["defaulted"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42
)
print(f"train {X_train.shape}, test {X_test.shape}")
print(f"missing values still in the training data: {int(X_train.isna().sum().sum()):,}")
train (9000, 10), test (3000, 10)
missing values still in the training data: 1,499
1. What we have been doing by hand
Sessions 2 to 6 built this chain, in this order, every time:
- Encode categorical columns to numbers.
- Split into train and test.
- Compute medians on the training set.
- Fill gaps on both sides with those medians.
- Fit a scaler on the training set; transform both.
- Fit the model.
- Apply a threshold.
Every step has a rule attached, and four separate things can go wrong: wrong order, statistics computed on the wrong rows, a column list that drifts out of sync, and a serving environment that has none of the intermediate objects.
A Pipeline turns all of it into one estimator with one fit.
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
numeric_steps = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
])
categorical_steps = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("encode", OneHotEncoder(handle_unknown="ignore", drop="first")),
])
preprocessing = ColumnTransformer([
("numeric", numeric_steps, NUMERIC),
("categorical", categorical_steps, CATEGORICAL),
])
model = Pipeline([
("prepare", preprocessing),
("classify", LogisticRegression(max_iter=1000)),
])
model
Pipeline(steps=[('prepare',
ColumnTransformer(transformers=[('numeric',
Pipeline(steps=[('impute',
SimpleImputer(strategy='median')),
('scale',
StandardScaler())]),
['loan_amount',
'tenure_months',
'interest_rate',
'previous_loans',
'days_past_due_history',
'annual_income',
'credit_score']),
('categorical',
Pipeline(steps=[('impute',
SimpleImputer(strategy='most_frequent')),
('encode',
OneHotEncoder(drop='first',
handle_unknown='ignore'))]),
['sector', 'employment_type',
'has_collateral'])])),
('classify', LogisticRegression(max_iter=1000))])In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
Parameters
['loan_amount', 'tenure_months', 'interest_rate', 'previous_loans', 'days_past_due_history', 'annual_income', 'credit_score']
Parameters
Parameters
['sector', 'employment_type', 'has_collateral']
Parameters
Parameters
Parameters
Read the structure above. ColumnTransformer sends the numeric columns down one
branch and the categorical columns down another, then glues the results back
together. The whole thing is one estimator, so it still obeys session 4's
contract: fit, predict, predict_proba.
from sklearn.metrics import average_precision_score, roc_auc_score
model.fit(X_train, y_train) # raw frame in, trained model out
probabilities = model.predict_proba(X_test)[:, 1]
print(f"ROC-AUC {roc_auc_score(y_test, probabilities):.3f}")
print(f"average precision {average_precision_score(y_test, probabilities):.3f}")
ROC-AUC 0.782
average precision 0.382
The same numbers as session 5, from one fit on raw columns with missing values
and text in them.
Four things came free with that:
Leakage is now structural. The imputer and scaler are fitted inside fit, on
whatever rows fit was given. There is no way to accidentally include the test
set — the code that could do it no longer exists.
Unseen categories will not crash it. handle_unknown="ignore" means a branch
or sector the model never saw becomes all zeros rather than an exception. Session
2 flagged this as the reason get_dummies cannot be used in production.
The column list lives in one place. NUMERIC and CATEGORICAL are declared
once and used by the pipeline forever.
It is one object. One thing to cross-validate, one thing to save, one thing to load in a web service — which is session 9.
# Inspect what the pipeline built: names come out of the fitted encoder.
encoded_names = model.named_steps["prepare"].get_feature_names_out()
print(f"{len(encoded_names)} columns after preprocessing")
print(list(encoded_names[:5]), "...")
coefficients = pd.Series(model.named_steps["classify"].coef_[0], index=encoded_names)
print("\nstrongest three:")
print(coefficients.abs().sort_values(ascending=False).head(3).round(3).to_string())
16 columns after preprocessing
['numeric__loan_amount', 'numeric__tenure_months', 'numeric__interest_rate', 'numeric__previous_loans', 'numeric__days_past_due_history'] ...
strongest three:
numeric__interest_rate 0.618
categorical__employment_type_Informal 0.410
numeric__annual_income 0.331
2. Cross-validating the whole thing
This is where a pipeline stops being tidiness and starts being correctness.
When you cross-validate a pipeline, scikit-learn refits every step on each training fold — including the imputer's medians and the scaler's statistics. Do it by hand and those statistics are computed once, over all the training data, including the rows being held out for validation. The result is a score that is slightly, invisibly, optimistic.
from sklearn.model_selection import StratifiedKFold, cross_val_score
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X_train, y_train, cv=cv, scoring="average_precision")
print("average precision per fold:", scores.round(3))
print(f"mean {scores.mean():.3f} ± {scores.std():.3f}")
average precision per fold: [0.383 0.353 0.405 0.312 0.365]
mean 0.363 ± 0.031
The same fold spread as session 6, now computed honestly by construction rather than by care.
Preprocessing choices become tunable
Once preprocessing lives inside the pipeline, GridSearchCV can search it. The
__ notation reaches into nested steps: prepare__numeric__impute__strategy is
"the strategy of the impute step of the numeric branch of prepare".
from sklearn.model_selection import GridSearchCV
grid = {
"prepare__numeric__impute__strategy": ["median", "mean"],
"classify__C": [0.1, 1, 10],
}
search = GridSearchCV(model, grid, cv=cv, scoring="average_precision", n_jobs=-1)
search.fit(X_train, y_train)
print("best:", search.best_params_)
print(f"best score: {search.best_score_:.4f} (baseline {scores.mean():.4f})")
best: {'classify__C': 0.1, 'prepare__numeric__impute__strategy': 'median'}
best score: 0.3641 (baseline 0.3634)
"Should I impute with the median or the mean?" stops being an opinion and becomes a measurement — one that is made inside each fold, where it belongs.
3. Engineered features, inside the pipeline
Session 3's ratios were computed on the whole dataframe before splitting. That was safe because a ratio of two columns in the same row uses no information from other rows — but only just, and the habit is dangerous.
Anything computed from a column belongs inside the pipeline. FunctionTransformer
wraps a plain function into an estimator so it can sit in the chain.
from sklearn.preprocessing import FunctionTransformer
def add_credit_ratios(frame):
"""Add the ratios a credit officer computes by hand. Row-wise only."""
frame = frame.copy()
income = frame["annual_income"].fillna(frame["annual_income"].median())
frame["loan_to_income"] = frame["loan_amount"] / income
frame["monthly_instalment"] = frame["loan_amount"] / frame["tenure_months"]
return frame
NUMERIC_PLUS = NUMERIC + ["loan_to_income", "monthly_instalment"]
featured_model = Pipeline([
("engineer", FunctionTransformer(add_credit_ratios)),
("prepare", ColumnTransformer([
("numeric", numeric_steps, NUMERIC_PLUS),
("categorical", categorical_steps, CATEGORICAL),
])),
("classify", LogisticRegression(max_iter=1000)),
])
featured_scores = cross_val_score(featured_model, X_train, y_train, cv=cv,
scoring="average_precision")
print(f"without ratios {scores.mean():.4f} ± {scores.std():.4f}")
print(f"with ratios {featured_scores.mean():.4f} ± {featured_scores.std():.4f}")
without ratios 0.3634 ± 0.0313
with ratios 0.3624 ± 0.0306
Still no improvement, exactly as session 3 measured — but now the feature step travels with the model. When session 9 serves this object, the ratios are computed inside it, from the raw fields the API receives. Nobody has to remember to recreate them.
That is the real argument for putting feature engineering in the pipeline: the training code and the serving code become the same code. Mismatch between those two is one of the most common production failures in the industry, and it is entirely preventable.
4. Reproducibility: what actually has to be recorded
"Reproducible" means someone else, on another machine, in six months, gets your number. That needs five things, and a notebook records none of them by default.
| What | Why | How |
|---|---|---|
| Code version | The pipeline definition changes weekly | Git commit hash |
| Data version | "The extract" is not a fixed thing | File hash, row count, date range |
| Parameters | Which C? Which threshold? Which imputer? | Logged with the run |
| Metrics | The claim being made | Logged with the run |
| Environment | scikit-learn changes defaults between versions | requirements.txt, Python version |
Plus one thing that is not a record but a habit: set every seed. Our
random_state=42 appears in the split and in every estimator that samples. Without
it, two runs of the same code produce two different numbers and you cannot tell
improvement from noise.
import hashlib
import sklearn
data_file = ROOT / "data" / "loan_default.csv"
data_hash = hashlib.md5(data_file.read_bytes()).hexdigest()[:12]
run_context = {
"data_file": data_file.name,
"data_md5": data_hash,
"rows": len(loans),
"date_range": f"{loans['application_date'].min():%Y-%m-%d} to {loans['application_date'].max():%Y-%m-%d}",
"sklearn_version": sklearn.__version__,
"random_state": 42,
}
for key, value in run_context.items():
print(f" {key:<16} {value}")
data_file loan_default.csv
data_md5 18b4b7c4918f
rows 12000
date_range 2023-01-01 to 2024-12-31
sklearn_version 1.9.0
random_state 42
That dictionary is the minimum. If the data hash changes, the number changes, and knowing which happened first saves a week of confusion.
5. Experiment tracking with MLflow
By session 6 we had run dozens of configurations. Which one produced the 0.3665? What threshold was it evaluated at? Where is that model now?
If the answer is "somewhere in the notebook", the work is not recoverable. An experiment tracker is a database that answers those questions automatically.
MLflow runs entirely on your laptop. We point it at a local SQLite file — the
old ./mlruns file store still works in some versions but is on its way out, and
SQLite is what the tool now expects.
import logging
import mlflow
import mlflow.sklearn
logging.getLogger("mlflow").setLevel(logging.ERROR) # keep the notebook output readable
mlflow.set_tracking_uri(f"sqlite:///{ROOT / 'mlflow.db'}")
mlflow.set_experiment("loan-default")
print("tracking:", mlflow.get_tracking_uri())
print("\nTo browse these runs, from the repository root run:")
print(" mlflow ui --backend-store-uri sqlite:///mlflow.db")
print(" (then open http://127.0.0.1:5000)")
tracking: sqlite:////Users/aayush/Documents/Kings/AI/AIModule/mlflow.db
To browse these runs, from the repository root run:
mlflow ui --backend-store-uri sqlite:///mlflow.db
(then open http://127.0.0.1:5000)
A run is one training attempt. Inside a run you log three kinds of thing:
- parameters — the settings you chose (
log_param) - metrics — the numbers you measured (
log_metric) - artifacts — files, including the model itself (
log_model)
from sklearn.ensemble import RandomForestClassifier
candidates = {
"logistic-C1": Pipeline([("prepare", preprocessing),
("classify", LogisticRegression(max_iter=1000))]),
"logistic-C0.1": Pipeline([("prepare", preprocessing),
("classify", LogisticRegression(max_iter=1000, C=0.1))]),
"random-forest": Pipeline([("prepare", preprocessing),
("classify", RandomForestClassifier(n_estimators=200,
random_state=42))]),
}
for run_name, candidate in candidates.items():
with mlflow.start_run(run_name=run_name):
fold_scores = cross_val_score(candidate, X_train, y_train, cv=cv,
scoring="average_precision")
candidate.fit(X_train, y_train)
test_probabilities = candidate.predict_proba(X_test)[:, 1]
mlflow.log_params(run_context)
mlflow.log_param("estimator", candidate.named_steps["classify"].__class__.__name__)
mlflow.log_metric("cv_average_precision", fold_scores.mean())
mlflow.log_metric("cv_std", fold_scores.std())
mlflow.log_metric("test_average_precision",
average_precision_score(y_test, test_probabilities))
mlflow.log_metric("test_roc_auc", roc_auc_score(y_test, test_probabilities))
mlflow.sklearn.log_model(candidate, name="model", serialization_format="pickle")
print(f"logged {run_name}: cv {fold_scores.mean():.4f} ± {fold_scores.std():.4f}")
logged logistic-C1: cv 0.3634 ± 0.0313
logged logistic-C0.1: cv 0.3641 ± 0.0317
logged random-forest: cv 0.3340 ± 0.0258
Three runs, three models saved, every parameter and metric recorded — in about fifteen lines wrapped around code we had already written.
(MLflow warns that pickle can execute code when loaded. For a local teaching
setup that is fine; in a shared production system you would use the safer skops
format or keep the artifact store locked down.)
runs = mlflow.search_runs(experiment_names=["loan-default"])
comparison = runs[["tags.mlflow.runName", "params.estimator",
"metrics.cv_average_precision", "metrics.test_average_precision",
"metrics.test_roc_auc"]]
comparison.columns = ["run", "estimator", "cv_AP", "test_AP", "test_AUC"]
print(comparison.round(4).to_string(index=False))
run estimator cv_AP test_AP test_AUC
random-forest RandomForestClassifier 0.3340 0.3672 0.7628
logistic-C0.1 LogisticRegression 0.3641 0.3807 0.7819
logistic-C1 LogisticRegression 0.3634 0.3817 0.7823
That table is the thing a notebook cannot give you: every experiment you have
ever run, side by side, with the settings that produced each one — queryable from
Python, and browsable in mlflow ui.
Note the disagreement in that table. On cross-validated average precision the logistic runs win comfortably (0.364 against 0.334); on the single test split the random forest looks competitive. Session 6 told us which to believe — the cross-validated number, averaged over five folds, rather than one draw.
Without a tracker that comparison lives in somebody's memory of "I think the forest was better". With one, it is a table anybody can re-read in six months.
6. Model versioning: getting a run's model back
Every logged model has a URI. Load it and it is the pipeline again — preprocessing included, ready to score raw applications.
best = runs.sort_values("metrics.cv_average_precision", ascending=False).iloc[0]
best_name = best["tags.mlflow.runName"]
loaded_model = mlflow.sklearn.load_model(f"runs:/{best['run_id']}/model")
one_application = X_test.head(1)
print(f"best run: {best_name} (cv AP {best['metrics.cv_average_precision']:.4f})")
print(f"reloaded from MLflow: {loaded_model.predict_proba(one_application)[0, 1]:.4f}")
print(f"still in memory here: {candidates[best_name].predict_proba(one_application)[0, 1]:.4f}")
best run: logistic-C0.1 (cv AP 0.3641)
reloaded from MLflow: 0.2828
still in memory here: 0.2828
Same number, from a model reconstructed out of the tracking store rather than from memory. That round trip is the whole point: the run is the deliverable, not the notebook.
In a larger setup the next step is the model registry — the same store, with
named models and stages (staging, production) so a service can ask for "the
current production loan-default model" rather than a run id. The mechanics are the
same; the discipline is the same; only the naming changes. Session 8 puts that in
the context of the wider MLOps lifecycle.
7. Rules that survive contact with a real project
- Everything that learns from data goes inside the pipeline. Imputers,
scalers, encoders, feature builders, resamplers. If it has a
fit, it belongs in the chain. - Nothing is fitted outside cross-validation. If you cannot express a step as a pipeline stage, that is a warning sign, not an excuse.
- One run, one record. Parameters, metrics, data hash, code version, model artifact — logged at the moment they are true, not reconstructed later.
- Name runs like a human.
logistic-C0.1-with-ratiosbeatsrun_2026_09_16_17_42. - Log the failures too. The tracker's value is the record of what did not work, which is how you stop a colleague repeating it next month.
imbalanced-learnhas its ownPipeline.imblearn.pipeline.Pipelineresamples the training fold only, and leaves validation folds alone. scikit-learn's plainPipelinecannot do that correctly — if you use SMOTE, use theirs.
Your turn
1. Add the threshold to the pipeline story. Our model outputs probabilities; the decision needs a threshold. Log the chosen threshold as a parameter and the resulting recall, flag rate and expected cost as metrics, for thresholds 0.15, 0.20 and 0.25. Which run would you promote?
2. Search preprocessing properly. Extend the grid to include
prepare__categorical__encode__drop ("first" versus None) and
prepare__numeric__scale (StandardScaler() versus "passthrough"). Does
anything beat the defaults?
3. Use the imblearn pipeline. Rebuild the model with
imblearn.pipeline.Pipeline and a SMOTE step, cross-validate it, and log the
run. Compare its cross-validated average precision to the plain pipeline's. Does
resampling help once it is done correctly inside each fold?
4. Break reproducibility on purpose. Remove random_state from the split, run
the same training three times, and log all three. How far apart are the metrics?
Is the difference bigger than the gap between your best two models?
5. Size-aware thresholds, tracked. Session 6's size-aware thresholds saved NPR 8.6m. Log that configuration as a run with its cost metric, so the saving is recorded rather than remembered.
6. Open the UI. Run mlflow ui --backend-store-uri sqlite:///mlflow.db from
the repository root, sort your runs by cv_average_precision, and find the exact
parameters of the best one. This is the workflow you will use for the final project.
If you remember nothing else
A pipeline is one estimator from raw columns to prediction. Anything that learns from data lives inside it — then leakage is structurally impossible rather than carefully avoided.
ColumnTransformer handles the two-branch reality of tabular data: impute and
scale the numbers, impute and encode the text, in one object.
Cross-validating a pipeline refits preprocessing per fold. That is the honest number; doing it by hand is quietly optimistic.
Training code and serving code should be the same code. Feature engineering inside the pipeline is how that happens.
A run must record five things: code version, data version, parameters, metrics, environment. Set your seeds so the sixth — luck — is not a factor.
The run is the deliverable, not the notebook. If you cannot reload a model and reproduce its number, you have a demo, not a system.
Glossary
| Term | Meaning |
|---|---|
| Pipeline | Transformers plus a final estimator composed into one estimator |
ColumnTransformer |
Applies different preprocessing to different column groups |
FunctionTransformer |
Wraps a plain function as a pipeline step |
handle_unknown="ignore" |
Encoder behaviour for categories unseen in training |
__ notation |
How grid search addresses nested pipeline parameters |
| Experiment | A named collection of runs in a tracker |
| Run | One training attempt, with its parameters, metrics and artifacts |
| Artifact | A file produced by a run — the model, a plot, a data sample |
| Model URI | The address of a logged model, e.g. runs:/<run_id>/model |
| Model registry | Named, versioned models with lifecycle stages |
| Reproducibility | Same code, data, parameters and environment giving the same number |
Further reading
- scikit-learn User Guide, Pipelines and composite estimators — and the Common pitfalls page on why pipelines prevent leakage.
- MLflow documentation, MLflow Tracking quickstart — ten minutes end to end.
imbalanced-learndocumentation, Pipeline — the resampling-aware version.- Sculley et al., Hidden Technical Debt in Machine Learning Systems — glue code and pipeline jungles, which is precisely what this session is defending against.
Next session: Introduction to MLOps — the lifecycle around the model: deployment workflow, monitoring, CI/CD concepts, and the drift that has been sitting in this dataset since session 2.