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 ColumnTransformer that treats numeric and categorical columns differently, inside a single Pipeline.
  • 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:

  1. Encode categorical columns to numbers.
  2. Split into train and test.
  3. Compute medians on the training set.
  4. Fill gaps on both sides with those medians.
  5. Fit a scaler on the training set; transform both.
  6. Fit the model.
  7. 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.

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

  1. 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.
  2. Nothing is fitted outside cross-validation. If you cannot express a step as a pipeline stage, that is a warning sign, not an excuse.
  3. One run, one record. Parameters, metrics, data hash, code version, model artifact — logged at the moment they are true, not reconstructed later.
  4. Name runs like a human. logistic-C0.1-with-ratios beats run_2026_09_16_17_42.
  5. 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.
  6. imbalanced-learn has its own Pipeline. imblearn.pipeline.Pipeline resamples the training fold only, and leaves validation folds alone. scikit-learn's plain Pipeline cannot 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-learn documentation, 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.