What You'll Build
In this exercise you will build a reproducible ML experiment pipeline that ties together everything from Module 1: versioned data, tracked experiments, and verifiable reproducibility. The pipeline trains a classifier that predicts whether a cricketer is currently in form from recent batting and bowling features, runs several configurations as tracked experiments, records each run's parameters and metrics, and persists the winning model as a versioned artifact. You will then prove the pipeline is reproducible by rerunning a configuration and confirming byte-identical results. By the end you will have a small but complete MLOps foundation, the kind of skeleton every production system is built on, where data, code, and model are jointly pinned and any past result can be recovered, compared, and trusted rather than vaguely remembered.
Prerequisites
- Python 3.10 or newer installed, with pip available for installing the required packages.
- Comfort with basic Python: functions, dictionaries, and reading a small dataset from disk.
- Conceptual understanding of the ML lifecycle, reproducibility, and experiment tracking from lessons 01 and 02.
- Familiarity with the idea of pinning random seeds and recording parameters and metrics per run.
- A terminal where you can create a project folder, run scripts, and install MLflow locally.
Setup & Project Structure
You will create a self-contained project folder with a clear separation between data, source code, and tracked outputs, mirroring how real MLOps projects are organised. The data lives under a data directory, the pipeline logic under src, and experiment tracking is handled by MLflow writing to a local mlruns store. Keeping these concerns in separate folders matters because it makes the project's structure self-documenting and lets you reason about data versus code versus results independently, exactly the discipline that scales from a toy pipeline to a production platform. Install the two dependencies, MLflow for tracking and scikit-learn for the model, then lay out the directories.
# Create the project skeleton and install dependencies.
mkdir -p cricket-form-pipeline/{data,src,outputs}
cd cricket-form-pipeline
python -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
pip install mlflow==2.* scikit-learn==1.* pandas==2.*
# Resulting structure:
# cricket-form-pipeline/
# |-- data/
# | `-- players.csv # versioned input dataset
# |-- src/
# | |-- prepare.py # Step 1: load + feature engineering
# | |-- train.py # Step 2: train + track a run
# | `-- pipeline.py # Step 3: sweep configs + select best
# |-- outputs/ # best model artifact lands here
# `-- mlruns/ # MLflow local tracking store (auto-created)
echo 'Project skeleton ready.'
Step 1 — Foundation
Step 1 builds the data foundation and feature engineering, the deterministic base every later stage depends on. You will create a small cricket dataset and a pure feature function that derives model inputs from raw statistics. The concept behind this step is that reproducibility starts at the data layer: if feature engineering is a pure function of a fixed, versioned dataset, then identical inputs always yield identical features, removing one whole class of hidden variability. Writing the data version into the file itself, and keeping the transformation free of randomness, is what lets every downstream run be anchored to a known starting point that you can recover and reason about precisely.
# src/prepare.py -- Step 1: versioned data + deterministic features.
import csv, os
DATA_VERSION = '[email protected]' # pinned identity of this dataset
RAW_PLAYERS = [
# name, matches, runs, wickets
('Rohit Sharma', 260, 10709, 8),
('Virat Kohli', 295, 13848, 4),
('Shubman Gill', 47, 2271, 0),
('Jasprit Bumrah', 89, 60, 149),
('Ravindra Jadeja', 197, 2756, 220),
('Tail Ender A', 30, 180, 2),
('Out Of Form B', 40, 410, 1),
('Fringe Player C', 22, 290, 0),
]
def write_dataset(path='data/players.csv'):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', newline='') as f:
w = csv.writer(f)
w.writerow(['name', 'matches', 'runs', 'wickets'])
w.writerows(RAW_PLAYERS)
return path
def engineer_features(path='data/players.csv'):
"""Pure, deterministic: same file -> same features, every time."""
X, y, names = [], [], []
for r in csv.DictReader(open(path)):
matches = max(int(r['matches']), 1)
batting_average = int(r['runs']) / matches
wicket_rate = int(r['wickets']) / matches
X.append([round(batting_average, 3), round(wicket_rate, 3)])
y.append(1 if batting_average > 35 or wicket_rate > 0.8 else 0) # 'in form'
names.append(r['name'])
return X, y, names
if __name__ == '__main__':
write_dataset()
X, y, names = engineer_features()
print(f'Prepared {len(X)} players from {DATA_VERSION}')
for n, f, label in zip(names, X, y):
print(f' {n:>16} feats={f} in_form={label}')
Step 2 — Core Logic
Step 2 builds the core training and tracking logic: a function that trains a model for a given configuration and logs the run to MLflow. This is the heart of the pipeline because it is where parameters become recorded inputs and accuracy becomes a recorded metric, joined to a versioned model artifact under a single run ID. By pinning the random seed inside the configuration and logging the data version as a parameter, every run becomes a self-describing record that can be compared with its siblings and reproduced exactly later. This step turns training from an ephemeral act into a durable, queryable experiment, which is the whole point of tracking.
# src/train.py -- Step 2: train one configuration and track it in MLflow.
import mlflow, mlflow.sklearn
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score
from prepare import engineer_features, DATA_VERSION
mlflow.set_experiment('cricket-form-pipeline')
def run_experiment(config: dict) -> dict:
X, y, _ = engineer_features()
with mlflow.start_run(run_name=config['name']) as run:
mlflow.log_params({
'max_depth': config['max_depth'],
'criterion': config['criterion'],
'seed': config['seed'],
'data_version': DATA_VERSION, # reproducibility anchor
})
model = DecisionTreeClassifier(
max_depth=config['max_depth'],
criterion=config['criterion'],
random_state=config['seed'],
)
model.fit(X, y)
acc = accuracy_score(y, model.predict(X)) # toy: train==eval for demo
mlflow.log_metric('accuracy', acc)
mlflow.sklearn.log_model(model, 'model')
return {'run_id': run.info.run_id, 'accuracy': acc, 'name': config['name']}
if __name__ == '__main__':
result = run_experiment(
{'name': 'dt-depth3', 'max_depth': 3, 'criterion': 'gini', 'seed': 1983})
print('Logged run:', result)
Step 3 — Integration & Enhancement
Step 3 brings the pieces together into a sweep that runs several configurations, compares their tracked metrics, and promotes the best model into the outputs folder as a versioned artifact. This integration step is where the pipeline becomes genuinely useful: instead of manually trying configurations and remembering which won, you execute the whole comparison programmatically, query MLflow for the top run, and persist the winner with a clear name. Selecting by logged metric rather than by intuition is the enhancement that mirrors how production systems choose models, on recorded evidence, and it leaves behind a complete trail from every candidate to the chosen one.
# src/pipeline.py -- Step 3: sweep configs, compare, promote the best.
import os, shutil
import mlflow
from mlflow.tracking import MlflowClient
from train import run_experiment
CONFIGS = [
{'name': 'dt-depth2', 'max_depth': 2, 'criterion': 'gini', 'seed': 1983},
{'name': 'dt-depth3', 'max_depth': 3, 'criterion': 'gini', 'seed': 1983},
{'name': 'dt-entropy3', 'max_depth': 3, 'criterion': 'entropy', 'seed': 1983},
]
def run_sweep():
results = [run_experiment(cfg) for cfg in CONFIGS]
best = max(results, key=lambda r: r['accuracy'])
print('\nLeaderboard:')
for r in sorted(results, key=lambda r: -r['accuracy']):
flag = ' <-- best' if r['run_id'] == best['run_id'] else ''
print(f" {r['name']:>12} acc={r['accuracy']:.3f}{flag}")
promote(best)
return best
def promote(best):
os.makedirs('outputs', exist_ok=True)
client = MlflowClient()
src_uri = f"runs:/{best['run_id']}/model"
local = mlflow.artifacts.download_artifacts(src_uri)
dest = 'outputs/best_model'
if os.path.exists(dest):
shutil.rmtree(dest)
shutil.copytree(local, dest)
print(f"\nPromoted {best['name']} (run {best['run_id'][:8]}) -> {dest}")
if __name__ == '__main__':
run_sweep()
Step 4 — Testing & Verification
Now verify the two properties that define a working foundation: the pipeline runs end to end and produces a tracked leaderboard plus a promoted model, and it is reproducible, meaning a rerun of the same configuration yields the same accuracy. Run the pipeline, inspect the leaderboard, then launch the MLflow UI to confirm every run is recorded with its params, metric, and model artifact. Finally rerun a single config twice and confirm the accuracy is identical, demonstrating that the pinned seed and versioned data delivered on the reproducibility promise.
# Run the pipeline and verify reproducibility.
cd cricket-form-pipeline && source .venv/bin/activate
python src/prepare.py # Step 1: build data + features
python src/pipeline.py # Steps 2-3: sweep, compare, promote
# Expected (illustrative) output:
# Leaderboard:
# dt-depth3 acc=1.000 <-- best
# dt-entropy3 acc=1.000
# dt-depth2 acc=0.875
# Promoted dt-depth3 (run a1b2c3d4) -> outputs/best_model
# Inspect every tracked run in the browser:
mlflow ui --port 5000 # open http://localhost:5000
# Reproducibility check: same config twice -> identical accuracy.
python -c "from src.train import run_experiment; \
cfg={'name':'repro','max_depth':3,'criterion':'gini','seed':1983}; \
a=run_experiment(cfg)['accuracy']; b=run_experiment(cfg)['accuracy']; \
print('reproducible:', a==b, a, b)"
# Expected: reproducible: True 1.0 1.0
Warning: The most common error here is omitting random_state (the seed) when constructing the model, then being baffled when two runs of the same config produce different accuracies and the reproducibility check prints False. Many scikit-learn estimators use randomness internally; without a fixed seed, identical params still yield different fits. Always thread the seed into the estimator and log it as a param so runs are genuinely reproducible.
Extension Challenge: Replace the in-script CONFIGS list with a params.yaml file and load it at runtime, then add a real train/test split so accuracy reflects generalisation rather than memorisation. For a harder stretch, persist a data-version hash alongside each run and add a guard that refuses to promote a model if its data version differs from the incumbent's, turning your toy pipeline into a genuine lineage-aware promotion gate.
- A reproducible pipeline pins the data version, random seed, and parameters together so any past run can be recovered and compared.
- Deterministic, pure feature engineering on a fixed dataset removes hidden variability and anchors every downstream run.
- Tracking each run's params, metric, and model artifact under one run ID turns training into a durable, queryable experiment.
- Selecting the best model by logged metric, not intuition, produces a defensible, reversible choice with a full candidate trail.
- Always set and log the estimator's random_state, since unseeded randomness breaks reproducibility even with identical parameters.
- Separating data, code, and tracked outputs into clear folders is the structural discipline that scales from a toy to a production platform.