What You'll Build
In this exercise you will build an AutoML training pipeline that automatically searches across multiple model families and their hyperparameters to find the best cricket in-form classifier, then tracks every trial and promotes the winner. Rather than hand-picking one algorithm and tuning it manually, you will let Optuna choose between, say, a gradient-boosted tree and a random forest, and tune the selected model's parameters within the same study, logging each trial to MLflow. This combines the orchestration mindset of Module 2 with the adaptive search of Optuna into a single automated experiment. By the end you will have a pipeline that turns the open-ended question 'which model and settings work best here' into a systematic, reproducible search whose entire history is recorded and whose best model is saved ready for deployment, the kind of automation that lets a small team evaluate far more options than they ever could by hand.
Prerequisites
- Completion of lesson 02 (MLflow tracking) and lesson 08 (Optuna), or equivalent familiarity with both.
- Python 3.10 or newer with pip, and the ability to install optuna, mlflow, and scikit-learn.
- Comfort defining a Python function, returning a value, and reading a small dataset.
- Understanding of the difference between hyperparameters and learned parameters, and what a cross-validation score means.
- A terminal where you can run scripts and launch the MLflow UI to inspect tracked runs.
Setup & Project Structure
You will create a focused project that separates the dataset, the AutoML search logic, and the tracked outputs, mirroring how a real automated-training service is laid out. The search logic lives in src, MLflow writes runs to a local store, and the promoted best model lands in outputs. Keeping the search definition separate from configuration and results matters because it lets you rerun the same search with a different trial budget or dataset without touching the core logic, and it keeps the tuning history cleanly recorded for later inspection. Install the three dependencies and lay out the folders before writing any code.
# Create the AutoML project skeleton and install dependencies.
mkdir -p cricket-automl/{data,src,outputs}
cd cricket-automl
python -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
pip install optuna==3.* mlflow==2.* scikit-learn==1.* pandas==2.*
# Resulting structure:
# cricket-automl/
# |-- data/
# | `-- innings.csv # dataset (created by src/data.py)
# |-- src/
# | |-- data.py # Step 1: dataset + features
# | |-- search.py # Step 2: Optuna objective over model families
# | `-- automl.py # Step 3: run study, track, promote best
# |-- outputs/ # best_model.pkl lands here
# `-- mlruns/ # MLflow local tracking store
echo 'AutoML project skeleton ready.'
Step 1 — Foundation
Step 1 builds the dataset and a deterministic feature loader, the fixed base every trial will be evaluated against. You will generate a labelled cricket innings dataset and a function that returns features and labels reproducibly. The concept behind this step is fair comparison: an AutoML search only produces a meaningful winner if every candidate model is scored on identical, leak-free data, so the data layer must be deterministic and shared. By fixing the dataset and the split seed here, you guarantee that differences in trial scores reflect differences in models and hyperparameters, not random variation in the data each trial happened to see.
# src/data.py -- Step 1: deterministic dataset + feature loader.
import csv, os, random
SEED = 1983
DATA_VERSION = '[email protected]'
def generate_dataset(path='data/innings.csv', n=400):
"""Synthetic but reproducible: same seed -> same file."""
rng = random.Random(SEED)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', newline='') as f:
w = csv.writer(f)
w.writerow(['batting_average', 'strike_rate', 'boundary_pct', 'match_winner'])
for _ in range(n):
avg = rng.uniform(10, 65)
sr = rng.uniform(60, 160)
bpct = rng.uniform(0.1, 0.75)
# label: strong innings tend to win matches (with some noise)
score = 0.03 * avg + 0.02 * sr + 1.5 * bpct + rng.gauss(0, 1)
w.writerow([round(avg, 2), round(sr, 2), round(bpct, 3),
1 if score > 4.0 else 0])
return path
def load_features(path='data/innings.csv'):
X, y = [], []
for r in csv.DictReader(open(path)):
X.append([float(r['batting_average']), float(r['strike_rate']),
float(r['boundary_pct'])])
y.append(int(r['match_winner']))
return X, y
if __name__ == '__main__':
generate_dataset()
X, y = load_features()
print(f'{DATA_VERSION}: {len(X)} innings, {sum(y)} winners')
Step 2 — Core Logic
Step 2 builds the heart of the AutoML pipeline: an Optuna objective that first chooses a model family and then tunes that family's hyperparameters, all within a single trial. This is where define-by-run shines, because a categorical suggestion for the model type branches into different parameter spaces depending on which model was picked, something a static grid cannot express cleanly. Each trial trains the chosen model with its suggested parameters, scores it with cross-validation on the fixed dataset, and returns that score for Optuna to optimise. This conditional search is what lets one study explore fundamentally different algorithms on equal footing rather than tuning a single predetermined model.
# src/search.py -- Step 2: an Optuna objective over multiple model families.
import optuna
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from data import load_features, SEED
def build_model(trial):
"""Choose a family, then tune it -> conditional, define-by-run space."""
family = trial.suggest_categorical('family', ['rf', 'gb', 'logreg'])
if family == 'rf':
return RandomForestClassifier(
n_estimators=trial.suggest_int('rf_n_estimators', 50, 400, step=50),
max_depth=trial.suggest_int('rf_max_depth', 2, 12),
random_state=SEED)
if family == 'gb':
return GradientBoostingClassifier(
n_estimators=trial.suggest_int('gb_n_estimators', 50, 400, step=50),
learning_rate=trial.suggest_float('gb_lr', 1e-3, 0.3, log=True),
max_depth=trial.suggest_int('gb_max_depth', 2, 6),
random_state=SEED)
return LogisticRegression(
C=trial.suggest_float('lr_C', 1e-3, 100, log=True), max_iter=1000)
def objective(trial):
X, y = load_features()
model = build_model(trial)
return cross_val_score(model, X, y, cv=3).mean() # fair, fixed-data scoring
if __name__ == '__main__':
study = optuna.create_study(direction='maximize',
sampler=optuna.samplers.TPESampler(seed=SEED))
study.optimize(objective, n_trials=10)
print('Best so far:', study.best_params, round(study.best_value, 3))
Step 3 — Integration & Enhancement
Step 3 integrates the search with tracking and promotion: you run the full study, log every trial to MLflow as a nested run, then retrain the best configuration on all the data and save it as the promoted model. This brings the pipeline together into something deployable, because a search that finds a great configuration is useless if the result is not recorded and the winning model is not persisted. By logging each trial under one parent run you get a complete, comparable tuning history in the MLflow UI, and by refitting and saving the best params you produce a single artifact ready to serve, with a clear record of exactly which search produced it.
# src/automl.py -- Step 3: run study, track every trial, promote the best.
import pickle
import optuna, mlflow
from data import load_features, generate_dataset, SEED, DATA_VERSION
from search import build_model
from sklearn.model_selection import cross_val_score
mlflow.set_experiment('cricket-automl')
def tracked_objective(trial):
X, y = load_features()
model = build_model(trial)
score = cross_val_score(model, X, y, cv=3).mean()
with mlflow.start_run(nested=True, run_name=f'trial-{trial.number}'):
mlflow.log_params(trial.params) # family + its tuned params
mlflow.log_param('data_version', DATA_VERSION)
mlflow.log_metric('cv_accuracy', score)
return score
def run_automl(n_trials=30):
generate_dataset()
with mlflow.start_run(run_name='automl-search') as parent:
study = optuna.create_study(
direction='maximize',
sampler=optuna.samplers.TPESampler(seed=SEED),
pruner=optuna.pruners.MedianPruner())
study.optimize(tracked_objective, n_trials=n_trials)
mlflow.log_metric('best_cv_accuracy', study.best_value)
mlflow.log_params({f'best_{k}': v for k, v in study.best_params.items()})
# Refit the winner on ALL data and promote it.
X, y = load_features()
best = build_model(optuna.trial.FixedTrial(study.best_params))
best.fit(X, y)
with open('outputs/best_model.pkl', 'wb') as f:
pickle.dump(best, f)
print(f'Best: {study.best_params} acc={study.best_value:.3f}')
print(f'Promoted -> outputs/best_model.pkl (parent run {parent.info.run_id[:8]})')
return study
if __name__ == '__main__':
run_automl()
Step 4 — Testing & Verification
Verify two things: the search runs end to end and produces a tracked history with a promoted model, and the result is reproducible because the dataset, sampler, and model seeds are all fixed. Run the AutoML script, confirm a best configuration and saved model, then open the MLflow UI to inspect every trial nested under the parent run. Finally rerun the search and confirm the best parameters and score are identical, proving the seeds delivered reproducibility across the whole pipeline.
# Run the AutoML search and verify reproducibility.
cd cricket-automl && source .venv/bin/activate
python src/automl.py # generates data, runs study, promotes best
# Expected (illustrative) output:
# Best: {'family': 'gb', 'gb_n_estimators': 200, 'gb_lr': 0.043, 'gb_max_depth': 3} acc=0.812
# Promoted -> outputs/best_model.pkl (parent run 7f3a9c1d)
# Inspect every trial nested under the parent run:
mlflow ui --port 5000 # open http://localhost:5000
# Reproducibility check: rerun and compare the best result.
python -c "from src.automl import run_automl; \
s1=run_automl(15); s2=run_automl(15); \
print('reproducible:', s1.best_params==s2.best_params, \
round(s1.best_value,3), round(s2.best_value,3))"
# Expected: reproducible: True 0.81x 0.81x
Warning: A common error is forgetting to seed one of the three randomness sources, the data generator, the Optuna sampler, or the model's random_state, and then being puzzled when two runs of the search disagree on the best configuration. AutoML compounds randomness across the dataset, the search, and each model fit, so every source must be seeded for the pipeline as a whole to be reproducible. Seed all three from a single SEED constant.
Extension Challenge: Add a fourth model family (such as a support vector classifier) and a held-out test set so the promoted model is evaluated on data the search never saw, reporting both the cross-validation score and the honest test score. For a harder stretch, persist the Optuna study to a SQLite database with load_if_exists so the search is resumable, and add a guard that refuses to overwrite outputs/best_model.pkl unless the new best beats the incumbent's recorded test score.
- AutoML searches across model families and their hyperparameters together, finding a better answer than committing to one algorithm upfront.
- Optuna's define-by-run objective lets a categorical model choice branch into different, conditional hyperparameter spaces within one trial.
- Every candidate must be scored on the same fixed, leak-free data so trial differences reflect models and settings, not data variation.
- Logging each trial as a nested MLflow run yields a complete, comparable tuning history under one parent run for inspection.
- Promotion means refitting the best configuration on all data and persisting it as a single deployable artifact tied to the search that produced it.
- Reproducible AutoML requires seeding every randomness source, the data generator, the sampler, and each model fit, from one shared seed.