1. Import Libraries¶

Before doing anything else, we import the libraries this lab needs and set up the Kaggle environment.

Why these libraries?¶

  • NumPy / pandas — numerical arrays and tabular data.
  • Matplotlib — plotting learning curves, heatmaps, and search results.
  • json / time / os / platform / subprocess / datetime — the plumbing behind reproducibility: writing config files, timing runs, reading library versions, calling git, and timestamping experiments.

Learning Outcomes¶

  • Import the libraries used throughout this lab.
  • Understand what each one is for before it shows up in later sections.
In [1]:
# This Python 3 environment comes with many helpful analytics libraries installed
# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import os

# Input data files (if any are attached) are available in the read-only "../input/" directory
for dirname, _, filenames in os.walk('/kaggle/input'):
    for filename in filenames:
        print(os.path.join(dirname, filename))

# You can write up to 20GB to /kaggle/working/, which is preserved as output
# when you create a version using "Save & Run All"

# scikit-learn, pandas, numpy, matplotlib, and joblib are already installed.
# optuna, autogluon.tabular, wandb, and trackio are NOT preinstalled on Kaggle
# - each is installed right where it's first used, not here, so you only pay
# the install cost for the sections you actually run.

** Syntax clarity — os.walk()**

for dirname, _, filenames in os.walk('/kaggle/input'):
    for filename in filenames:
        print(os.path.join(dirname, filename))
  • os.walk(path) walks a directory tree and yields one tuple per folder: (current_folder_path, list_of_subfolders, list_of_files_in_it).
  • _ is just a variable name — by convention it means "I'm not going to use this value". Here we don't care about the list of subfolder names, only the files, so it's thrown away into _ instead of a name like subdirs.
  • The inner for filename in filenames loops over the files found in that one folder.
  • os.path.join(dirname, filename) glues a folder and a filename into a full path using the right separator (/ on Linux/Mac, \ on Windows) — always prefer it over manually writing dirname + "/" + filename.
In [3]:
import json, time, os, platform, subprocess
from pathlib import Path
from datetime import datetime

RUNS_DIR = Path("/kaggle/working/runs"); RUNS_DIR.mkdir(parents=True, exist_ok=True)

2. Load Dataset¶

We use the Breast Cancer Wisconsin (Diagnostic) dataset. It ships inside scikit-learn, so there is nothing to download or attach — load_breast_cancer() returns 569 samples, 30 numeric features per sample (radius, texture, perimeter, area, smoothness, concavity, ... each reported as a mean, a standard error, and a "worst" value), and a binary target: malignant or benign.

Why a built-in dataset for this lab?¶

This lab is about how you train, not about the data itself — seeds, environments, experiment logs, and search strategies work the same way regardless of what the data looks like. A dataset that loads in one line with zero wrangling means class time goes to those concepts instead of data engineering.

Learning Outcomes¶

  • Load a dataset directly from scikit-learn.
  • Understand the shape of the data you will be training on for the rest of the lab.
In [4]:
from sklearn.datasets import load_breast_cancer

data = load_breast_cancer(as_frame=True)
df = data.frame.copy()
df["diagnosis"] = df["target"].map({0: "malignant", 1: "benign"})
df = df.drop(columns=["target"])
print(df.shape)
df.head()
(569, 31)
Out[4]:
mean radius mean texture mean perimeter mean area mean smoothness mean compactness mean concavity mean concave points mean symmetry mean fractal dimension ... worst texture worst perimeter worst area worst smoothness worst compactness worst concavity worst concave points worst symmetry worst fractal dimension diagnosis
0 17.99 10.38 122.80 1001.0 0.11840 0.27760 0.3001 0.14710 0.2419 0.07871 ... 17.33 184.60 2019.0 0.1622 0.6656 0.7119 0.2654 0.4601 0.11890 malignant
1 20.57 17.77 132.90 1326.0 0.08474 0.07864 0.0869 0.07017 0.1812 0.05667 ... 23.41 158.80 1956.0 0.1238 0.1866 0.2416 0.1860 0.2750 0.08902 malignant
2 19.69 21.25 130.00 1203.0 0.10960 0.15990 0.1974 0.12790 0.2069 0.05999 ... 25.53 152.50 1709.0 0.1444 0.4245 0.4504 0.2430 0.3613 0.08758 malignant
3 11.42 20.38 77.58 386.1 0.14250 0.28390 0.2414 0.10520 0.2597 0.09744 ... 26.50 98.87 567.7 0.2098 0.8663 0.6869 0.2575 0.6638 0.17300 malignant
4 20.29 14.34 135.10 1297.0 0.10030 0.13280 0.1980 0.10430 0.1809 0.05883 ... 16.67 152.20 1575.0 0.1374 0.2050 0.4000 0.1625 0.2364 0.07678 malignant

5 rows × 31 columns

3. Why Reproducibility Matters¶

Two words that sound the same but mean different things:

Word Meaning
Reproducibility Same code + same data → same result. Anyone can rerun your work.
Replicability New code or new data → same conclusion. Someone else confirms your finding.

Reproducibility is the smaller, easier goal — and you should always reach it. The cell below trains the exact same model three times with no seed set anywhere.

Learning Outcomes¶

  • See the "same code, different answer" bug for yourself.
  • Recognise how many independent places randomness can enter a single training run.
In [5]:
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

X = df[data.feature_names]
y = df["diagnosis"]

# Train the SAME model three times, with no seed set anywhere
for i in range(3):
    Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2)
    m = RandomForestClassifier(n_estimators=50).fit(Xtr, ytr)
    print(f"Run {i+1}: accuracy = {m.score(Xte, yte):.4f}")
Run 1: accuracy = 0.9561
Run 2: accuracy = 0.9386
Run 3: accuracy = 0.9386

** Syntax clarity — f-strings and method chaining**

print(f"Run {i+1}: accuracy = {m.score(Xte, yte):.4f}")
  • f"...{expr}..." is an f-string: anything inside { } is evaluated as real Python and dropped into the string. {i+1} just does the arithmetic inline instead of you writing str(i+1).
  • :.4f after an expression in an f-string is a format spec — .4f means "format as a fixed-point float with 4 digits after the decimal point". Try :.2f, :.0%, or :, to see other common ones below.
  • m.score(Xte, yte) is method chaining in spirit: m is a fitted model, and .score(X, y) is a method every scikit-learn estimator has — it predicts on X and compares to y, returning accuracy for classifiers (R² for regressors).

You should see three different numbers from identical code. Both lines inside the loop are sources of randomness: train_test_split picks a different random 80/20 split each time, and RandomForestClassifier builds different trees each time (different bootstrap samples, different feature subsets per split) — even a fixed split would still not give the same trees.

4. Random Seeds¶

A seed is a starting number for the random number generator. Same seed → same "random" numbers → same result. Randomness in ML comes from many places: the train/test split, weight initialisation, data shuffling, dropout, and some GPU operations — so a single random_state=42 in one place is rarely enough.

A Function That Seeds Everything¶

set_seed() below seeds Python's own random, NumPy, and (if installed) PyTorch in one call, so you only ever have to remember one function name.

Learning Outcomes¶

  • Write and use a function that seeds every library that might use randomness.
  • Prove that seeding removes the bug from Section 3.
  • Show, with real numbers, why one seeded run is not enough to compare two models.
  • Break down which source of randomness — the split, the model, or both — contributes most to that variance.
  • Recognise a second, easy-to-miss source of randomness: KFold(shuffle=True).
In [6]:
import random

def set_seed(seed=42):
    """Set the seed for every library that might use randomness."""
    random.seed(seed)                    # Python's own random
    np.random.seed(seed)                 # NumPy
    os.environ["PYTHONHASHSEED"] = str(seed)
    try:
        import torch
        torch.manual_seed(seed)
        torch.cuda.manual_seed_all(seed)
    except Exception:
        # Not installed, or installed but broken (e.g. a numpy/torch binary
        # ABI mismatch on this particular environment) - this lab never
        # actually needs torch, so skip it either way rather than crash.
        pass
    print(f"All seeds set to {seed}")

set_seed(42)
All seeds set to 42

** Syntax clarity — try/except around an import**

try:
    import torch
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
except Exception:
    # No PyTorch installed - that's fine, skip it
    pass
  • You can import inside a function, not just at the top of a file. It only runs when that function is actually called, and it's a normal local variable from then on.
  • Wrapping it in try/except Exception means: "if torch isn't installed (or anything else in this block fails), don't crash the whole function — just move on." This is exactly why set_seed() works whether or not PyTorch happens to be installed in your environment.
  • pass is a real Python keyword meaning "do nothing" — it's a placeholder for a block that's intentionally empty, since Python requires some statement inside every except.
  • os.environ["PYTHONHASHSEED"] = str(seed) sets an environment variable (not a Python variable) — some libraries read these before Python even starts hashing strings, so it has to be a string, not an int.
In [ ]:
# The try/except-import pattern in isolation, with a module that
# definitely doesn't exist, so you can see it fail safely.
try:
    import this_library_does_not_exist  # noqa
    print("imported it")
except Exception as e:
    print(f"Skipped cleanly: {type(e).__name__}: {e}")

print("Execution continues normally after the except block.")
In [ ]:
# TODO

# 1. Rerun the Section 3 experiment with set_seed(42) + random_state=42
#    everywhere, three times, and confirm the accuracies are identical.

# 2. Rerun with seeds 0, 1, 2, 3, 4. Print the five accuracies and their
#    mean and standard deviation.
In [ ]:
# TODO (Extension) - which source of randomness matters most?

# 1. Split only: fix the model's random_state=42, vary the split 15 times.

# 2. Model only: fix the split's random_state=42, vary the model 15 times.

# 3. Both unseeded, 15 times.

# Print a table: source | mean | std
In [ ]:
# TODO (Extension) - the KFold shuffle trap: KFold(shuffle=True) without a
# random_state reshuffles differently every call, even inside the same script.

# 1. KFold(shuffle=True), no random_state, 5 repeats - print the scores.

# 2. Same but with random_state=42 - confirm the scores are now identical.

5. Environment Management¶

Your code depends on libraries. If a classmate has scikit-learn 1.0 and you have 1.5, your results may differ. An environment is a fixed, named set of libraries — freeze it, and share it.

Learning Outcomes¶

  • Print the exact versions of the libraries you are using.
  • Write a requirements.txt and an environment.yml by hand.
  • Write a function that captures the full environment: versions, OS, git commit, and timestamp.
In [7]:
import sklearn
print("Python :", platform.python_version())
print("OS     :", platform.system(), platform.release())
print("NumPy  :", np.__version__)
print("Pandas :", pd.__version__)
print("Sklearn:", sklearn.__version__)
Python : 3.12.13
OS     : Linux 6.12.90+
NumPy  : 2.0.2
Pandas : 2.3.3
Sklearn: 1.6.1
In [ ]:
# TODO

# 1. Print a requirements.txt (package==version) and an environment.yml
#    using the versions above.

# 2. Write capture_environment() -> dict with python/os/library versions,
#    git commit hash (or "not a git repo"), and a timestamp.
#    Tip: subprocess.run(["git","rev-parse","HEAD"], capture_output=True)

** Syntax clarity — subprocess.run for calling git**

The hint in the TODO above says:

subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True)
  • subprocess.run(...) runs a shell command from Python and waits for it to finish (compare this to the !git ... notebook shortcut from earlier labs — this is the same idea, but callable from a .py script, which ! magics are not).
  • The command is a list of strings, one per word — ["git", "rev-parse", "HEAD"] is the same as typing git rev-parse HEAD in a terminal. Never pass it as one big string like "git rev-parse HEAD" unless you also pass shell=True (which has security downsides and isn't needed here).
  • capture_output=True means "don't print the command's output to the screen — hand it back to me instead," accessible afterwards as result.stdout (bytes) or, with text=True added, as a normal string.
In [ ]:
import subprocess

# Demo with a command that always exists, so it runs even outside a git repo
result = subprocess.run(["echo", "hello from a subprocess"],
                         capture_output=True, text=True)

print("return code:", result.returncode)   # 0 means success
print("stdout:", result.stdout.strip())

# What capture_environment() should do with git specifically:
def git_commit_or_message():
    try:
        result = subprocess.run(["git", "rev-parse", "HEAD"],
                                 capture_output=True, text=True)
        if result.returncode == 0:
            return result.stdout.strip()
        return "not a git repo"
    except FileNotFoundError:
        return "git not installed"

print(git_commit_or_message())

6. Recording Experiments¶

An experiment record is everything you need to explain a number later: what settings you used, what data, what code, and what came out. log_run() below saves one experiment as a JSON file in runs/.

Learning Outcomes¶

  • Log a training run's hyperparameters, metrics, and environment to disk.
  • Log a metric across training steps, not just at the end.
  • Read every saved run back into one comparison table.
  • Organise run outputs into one folder per run instead of one flat file per run.
In [8]:
def log_run(name, hyperparams, metrics, notes=""):
    """Save one experiment as a JSON file in runs/."""
    run = {
        "name": name,
        "time": datetime.now().isoformat(timespec="seconds"),
        "hyperparams": hyperparams,
        "metrics": metrics,
        "environment": {"python": platform.python_version(),
                        "numpy": np.__version__},
        "notes": notes,
    }
    path = RUNS_DIR / f"{name}_{run['time'].replace(':','-')}.json"
    path.write_text(json.dumps(run, indent=2))
    return path

log_run("baseline_rf",
        {"n_estimators": 50, "max_depth": None, "seed": 42},
        {"val_accuracy": 0.94, "train_time_s": 0.4},
        notes="First try, no tuning")
Out[8]:
PosixPath('/kaggle/working/runs/baseline_rf_2026-08-24T07-14-00.json')

** Syntax clarity — building a nested dict, and datetime formatting**

run = {
    "name": name,
    "time": datetime.now().isoformat(timespec="seconds"),
    "hyperparams": hyperparams,
    ...
}
  • This is just a dict literal — {key: value, key: value, ...} — but notice the values themselves can be anything, including another dict (hyperparams, metrics, environment are all dicts nested inside the outer one). This nested shape is exactly what turns into readable, nested JSON when you later do json.dumps(run, indent=2).
  • datetime.now() gives the current date+time as a datetime object. .isoformat() turns it into a standard string like "2026-08-24T07:13:21". timespec="seconds" trims off microseconds so your run names/logs don't have ugly .930857 fragments in them.
  • notes="" in the function signature is a default argument — callers can leave it out entirely (log_run("rf1", hp, m)) and it'll just be an empty string, or override it (log_run("rf1", hp, m, notes="baseline")).
In [ ]:
from datetime import datetime

# isoformat() timespec options, side by side
now = datetime.now()
print(now.isoformat())                       # full precision, microseconds included
print(now.isoformat(timespec="seconds"))      # trimmed to seconds - what log_run() uses
print(now.isoformat(timespec="minutes"))      # trimmed further

# Building the same nested-dict shape log_run() uses, standalone:
example_run = {
    "name": "demo-run",
    "time": now.isoformat(timespec="seconds"),
    "hyperparams": {"n_estimators": 100, "max_depth": 8},
    "metrics": {"val_acc": 0.94},
}
import json
print(json.dumps(example_run, indent=2))
In [9]:
# TODO

# 1. Train a RandomForestClassifier (n_estimators=100, max_depth=8,
#    min_samples_split=2, seed=42) and log it with log_run(), including
#    train accuracy, validation accuracy, training time, and
#    capture_environment().
In [ ]:
# TODO

# 2. Train a SGDClassifier for 20 rounds with partial_fit, logging
#    train/val accuracy each round. Plot both curves.
In [ ]:
# TODO

# 3. Write load_all_runs() -> DataFrame of every run in runs/, sorted by
#    val_accuracy (highest first).
In [ ]:
# TODO

# 4. Save one run's model, config, and metrics into its own folder instead
#    of one flat JSON file, e.g. runs/2026-08-09_rf-baseline_seed42/.

7. Experiment Tracking with TrackIO¶

Local JSON files (Section 6) work, but you built all the plumbing yourself — no dashboard, no charts, no easy way to compare 20 runs at a glance. TrackIO is a lightweight, local-first experiment tracker from Hugging Face: no account, no API key, no internet required — it stores runs in a local SQLite database and gives you a Gradio dashboard to browse them. Its API is intentionally almost identical to Weights & Biases (Section 14), so trackio.init/log/finish should look familiar once you get there — the difference is entirely about where the data lives: on your machine (TrackIO) vs. a hosted service (W&B).

Learning Outcomes¶

  • Log a run's config and metrics with TrackIO, with no account setup.
  • Log a metric at every step of training to get a real curve, not one point.
  • Compare several logged runs against each other.
  • Fire an alert automatically when a metric crosses a threshold.
In [10]:
!pip install -q trackio
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.0/3.0 MB 44.1 MB/s eta 0:00:00a 0:00:01
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 61.7/61.7 kB 2.8 MB/s eta 0:00:00
ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
gradio 5.50.0 requires gradio-client==1.14.0, but you have gradio-client 2.6.0 which is incompatible.
In [11]:
import trackio

trackio.init(project="stt-ai-breast-cancer", name="rf-baseline",
             config={"n_estimators": 100, "max_depth": 8, "seed": 42})

model = RandomForestClassifier(n_estimators=100, max_depth=8, random_state=42).fit(Xtr, ytr)

trackio.log({"train_acc": model.score(Xtr, ytr), "val_acc": model.score(Xte, yte)})
trackio.finish()
print("Run logged.")
* Trackio project initialized: stt-ai-breast-cancer
* Trackio metrics logged to: /root/.cache/huggingface/trackio
* psutil detected, enabling automatic CPU/system metrics logging
* Created new run: rf-baseline
* Run finished. Uploading logs to Trackio (please wait...)
Run logged.
In [ ]:
# TODO

# 1. ONE TrackIO run (trackio.init once): train a GradientBoostingClassifier,
#    sweeping n_estimators from 10 to 100 in steps of 10. At each step, call
#    trackio.log({...}, step=n_estimators) with train_accuracy and
#    test_accuracy. trackio.finish() once at the end. This is the difference
#    between Section 6's "log the final number" and "log a curve".
In [ ]:
# TODO

# 2. Sweep max_depth 1-20 for a DecisionTreeClassifier, log the overfit gap,
#    alert if gap > 0.05, then plot train vs test accuracy vs depth.
In [ ]:
# TODO

# 3. Log 5 classifiers (LogisticRegression, DecisionTreeClassifier,
#    RandomForestClassifier, GradientBoostingClassifier, and an SVM inside a
#    Pipeline with StandardScaler) as 5 SEPARATE TrackIO runs. For each, log
#    cv_accuracy (5-fold), test_accuracy, and fit_time_s. Print a leaderboard
#    DataFrame sorted by cv_accuracy - build it from values computed locally
#    in Python, not by reading them back from TrackIO (its API is for
#    browsing/managing runs, not a metrics database you query from code).

Launch the dashboard¶

Run the cell below and open the printed URL to see every run from this section, including the training curve and the overfit-gap alert. On Kaggle, open_browser=False avoids trying to open a local browser window that doesn't exist in the notebook environment — copy the printed URL into a new browser tab instead.

In [ ]:
trackio.show(project="stt-ai-breast-cancer", open_browser=False, block_thread=False)
print("Dashboard starting - open the printed URL above in your browser.")

8. Parameters vs Hyperparameters¶

This difference confuses many students, so be clear:

Parameters Hyperparameters
Who sets them the training process you, before training
Examples weights, biases, split points in a tree learning rate, max_depth, batch size, number of trees
When fixed after training before training

Learning Outcomes¶

  • Tell parameters and hyperparameters apart for a trained model.
  • Show, with a plot, how one hyperparameter changes model behaviour and where overfitting begins.
In [12]:
m = RandomForestClassifier(n_estimators=10, max_depth=4, random_state=0).fit(X, y)

# Hyperparameters: what YOU chose
print("Hyperparameters:", {k: m.get_params()[k]
                           for k in ["n_estimators", "max_depth", "criterion"]})

# Parameters: what TRAINING learned - here, the tree splits
total_nodes = sum(t.tree_.node_count for t in m.estimators_)
print("Learned parameters (total tree nodes):", total_nodes)
Hyperparameters: {'n_estimators': 10, 'max_depth': 4, 'criterion': 'gini'}
Learned parameters (total tree nodes): 228
In [ ]:
# TODO

# 1. Sort each of these into "parameter" or "hyperparameter": learning rate,
#    weights of a neural network layer, number of epochs, the split value at
#    a decision tree node, batch size, dropout rate, the bias term, k in
#    k-nearest-neighbours, the centroids found by k-means.
parameters = ["..."]
hyperparameters = ["..."]

# 2. Train a DecisionTreeClassifier at max_depth = 1, 3, 5, 10, and None.
#    Plot train and validation accuracy against depth, and mark where
#    validation accuracy peaks and where overfitting begins.

** Syntax clarity — dict comprehension and generator expression**

{k: m.get_params()[k] for k in ["n_estimators", "max_depth", "criterion"]}

This is a dict comprehension — read it right-to-left like a sentence: "for each k in this list of names, make a key: value pair where the key is k and the value is m.get_params()[k]." It's a compact way to build a dict by filtering/transforming an existing iterable, instead of writing a for loop with .append() or manual assignment.

sum(t.tree_.node_count for t in m.estimators_)

This is a generator expression — very similar to a list comprehension ([t.tree_.node_count for t in m.estimators_]) but without the square brackets, so it produces values one at a time instead of building the whole list in memory first. sum(...) then adds them all up. For a RandomForestClassifier, m.estimators_ is the list of individual decision trees it trained, and .tree_.node_count is how many nodes (learned split points) are inside one tree.

In [ ]:
# Dict comprehension, broken down step by step
params_i_want = ["n_estimators", "max_depth", "criterion"]
all_params = {"n_estimators": 10, "max_depth": 4, "criterion": "gini", "n_jobs": None}

# The comprehension:
picked = {k: all_params[k] for k in params_i_want}
print(picked)

# ...is shorthand for this loop:
picked_the_long_way = {}
for k in params_i_want:
    picked_the_long_way[k] = all_params[k]
print(picked_the_long_way)

# Generator expression vs list comprehension - same values, sum() doesn't care
node_counts_list = [3, 7, 5]          # pretend these came from t.tree_.node_count
total_a = sum(n for n in node_counts_list)   # generator expression
total_b = sum([n for n in node_counts_list]) # list comprehension - also works, just builds a list first
print(total_a, total_b)
In [ ]:
# TODO

# 1. Sort each of these into "parameter" or "hyperparameter": learning rate,
#    weights of a neural network layer, number of epochs, the split value at
#    a decision tree node, batch size, dropout rate, the bias term, k in
#    k-nearest-neighbours, the centroids found by k-means.
parameters = ["..."]
hyperparameters = ["..."]

# 2. Train a DecisionTreeClassifier at max_depth = 1, 3, 5, 10, and None.
#    Plot train and validation accuracy against depth, and mark where
#    validation accuracy peaks and where overfitting begins.

9. Grid Search¶

Grid search tries every combination of the values you list. It is simple and complete, but it grows very fast: with $k$ hyperparameters and $v$ values each, a grid needs $v^k$ combinations, each multiplied again by the number of cross-validation folds.

Learning Outcomes¶

  • Run a grid search with GridSearchCV.
  • Calculate how many model fits a grid search needs before running it.
  • See, with a real number, why grid search stops scaling once you have many hyperparameters.
  • Extension: implement grid search yourself, from scratch, to see exactly what GridSearchCV is doing internally.
In [13]:
from sklearn.model_selection import GridSearchCV, KFold

grid = {
    "n_estimators": [50, 100],
    "max_depth": [4, 8, None],
}
gs = GridSearchCV(RandomForestClassifier(random_state=42),
                  grid, cv=3, scoring="accuracy", n_jobs=-1)
gs.fit(Xtr, ytr)
print("Best settings:", gs.best_params_)
print("Best CV score:", round(gs.best_score_, 4))
print("Total fits:", len(gs.cv_results_["params"]) * 3)
Best settings: {'max_depth': 4, 'n_estimators': 100}
Best CV score: 0.9472
Total fits: 18
In [ ]:
# TODO

# 1. Combinations, total fits, total time for 5x4x3x3 values, 5-fold CV.

# 2. Real grid search over n_estimators/max_depth: top 5 settings + heatmap.

# 3. Recompute the combination count for 8 hyperparameters, 3 values each.
In [14]:
# TODO (Extension) - build MyGridSearchCV from scratch. GridSearchCV feels
# like magic until you build it yourself - fill in the three methods below,
# then test on breast cancer with a small DecisionTreeClassifier grid and
# compare against sklearn's GridSearchCV on the same grid.
import itertools

class MyGridSearchCV:
    def __init__(self, estimator, param_grid, cv=5, scoring="accuracy"):
        self.estimator = estimator
        self.param_grid = param_grid
        self.cv = cv
        self.scoring = scoring
        self.best_params_ = None
        self.best_score_ = -np.inf
        self.cv_results_ = []

    def _get_all_combinations(self):
        """Return a list of dicts, one per combination of self.param_grid."""
        # YOUR CODE HERE
        pass

    def _score_single_combination(self, params, X, y):
        """Run k-fold CV for one param combination using KFold directly.
        Return the mean accuracy across folds."""
        # YOUR CODE HERE
        pass

    def fit(self, X, y):
        """Run the grid search. Populate best_params_/best_score_/cv_results_."""
        # YOUR CODE HERE
        pass

    def get_results_df(self):
        return pd.DataFrame(self.cv_results_).sort_values("mean_test_score", ascending=False)


# Test on breast cancer + compare with sklearn's GridSearchCV

10. Random Search¶

Random search picks combinations at random from ranges you give. Surprising fact: for the same budget, it usually beats grid search, because it tests far more distinct values of whichever hyperparameter actually matters.

Learning Outcomes¶

  • Run a random search with RandomizedSearchCV.
  • Compare grid and random search under the exact same fit budget.
  • Choose a log-uniform range instead of a uniform one for a learning-rate-like hyperparameter.
  • Extension: implement random search yourself, and see where grid vs. random search actually land in parameter space.
In [15]:
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint

dist = {
    "n_estimators": randint(20, 300),
    "max_depth": randint(2, 20),
    "min_samples_split": randint(2, 20),
}
rs = RandomizedSearchCV(RandomForestClassifier(random_state=42),
                        dist, n_iter=20, cv=3, random_state=42, n_jobs=-1)
rs.fit(Xtr, ytr)
print("Best settings:", rs.best_params_)
print("Best CV score:", round(rs.best_score_, 4))
Best settings: {'max_depth': 10, 'min_samples_split': 3, 'n_estimators': 111}
Best CV score: 0.9494

** Syntax clarity — distribution objects vs. plain lists**

from scipy.stats import randint
dist = {
    "n_estimators": randint(20, 300),
    ...
}
  • randint(20, 300) is not a number and not a list — it's a scipy distribution object. It doesn't hold any values yet; it knows how to produce random integers between 20 and 300 (inclusive of 20, exclusive of 300) whenever something calls .rvs() on it.
  • This is exactly why MyRandomSearchCV (the next TODO) needs to handle two different kinds of values in param_distributions: a plain list (sample uniformly with random.choice(...)) vs. anything with an .rvs(random_state=...) method (call that method instead). Check with hasattr(value, "rvs") to tell them apart.
  • RandomizedSearchCV(..., dist, n_iter=20, ...) then calls .rvs() on each distribution n_iter times to build n_iter random combinations — contrast with GridSearchCV, which has no n_iter, because it always runs every combination.
In [ ]:
from scipy.stats import randint, loguniform

dist = randint(20, 300)     # a distribution object - nothing sampled yet
print(type(dist))
print(dist.rvs())           # sample one value
print(dist.rvs(random_state=42))   # sample one value, reproducibly
print(dist.rvs(size=5, random_state=42))  # sample 5 at once

# Telling a list apart from a distribution object, the way MyRandomSearchCV needs to:
def sample_one(value, rng):
    if hasattr(value, "rvs"):          # it's a scipy distribution
        return value.rvs(random_state=rng)
    else:                              # assume it's a plain list -> sample uniformly
        return rng.choice(value)

import numpy as np
rng = np.random.default_rng(42)
print(sample_one(randint(2, 20), rng))
print(sample_one([2, 5, 10, 20], rng))
In [ ]:
# TODO

# 1. Grid search vs random search, both exactly 20 fits. Compare best score
#    and time in a small table.

# 2. Draw 2000 samples from np.random.uniform(0.0001, 0.1) and 2000 from
#    scipy.stats.loguniform(0.0001, 0.1). Print what share of each falls
#    below 0.001 - this is why log-uniform is the better choice for
#    something like a learning rate.
In [19]:
# TODO (Extension) - build MyRandomSearchCV from scratch. Same interface as
# MyGridSearchCV, plus n_iter and random_state. Each param_distributions
# value is either a list (sample uniformly) or a scipy distribution (anything
# with .rvs(random_state=...), like randint(2, 20)). Fill in the two methods
# below, then test on breast cancer and compare against sklearn's
# RandomizedSearchCV on the same distributions.
class MyRandomSearchCV:
    def __init__(self, estimator, param_distributions, n_iter=20, cv=5,
                 scoring="accuracy", random_state=None):
        self.estimator = estimator
        self.param_distributions = param_distributions
        self.n_iter = n_iter
        self.cv = cv
        self.scoring = scoring
        self.random_state = random_state
        self.best_params_ = None
        self.best_score_ = -np.inf
        self.cv_results_ = []

    def _sample_params(self, rng):
        """One value per hyperparameter: rng.choice() for a list,
        .rvs(random_state=rng) for a scipy distribution."""
        # YOUR CODE HERE
        pass

    def fit(self, X, y):
        """Run n_iter random trials with a manual KFold loop
        (reuse the same CV logic as MyGridSearchCV)."""
        # YOUR CODE HERE
        pass

    def get_results_df(self):
        return pd.DataFrame(self.cv_results_).sort_values("mean_test_score", ascending=False)


# Test on breast cancer + compare with sklearn's RandomizedSearchCV
In [ ]:
# TODO (Extension) - coverage plot. Grid and random search don't just score
# differently, they land in different places in parameter space. Scatter the
# 20 grid points and the 20 random-search points from the first TODO above,
# colour-coded, on the same (n_estimators, max_depth) axes.

11. Bayesian Optimization¶

Grid and random search are blind — they never learn from earlier tries. Bayesian optimization builds a cheap model of "which settings look promising" from the results so far, then uses an acquisition function to pick the next setting to try, balancing exploiting (near the best known point) and exploring (where little is known yet).

Learning Outcomes¶

  • Run a Bayesian search with Optuna.
  • Compare grid, random, and Bayesian search under the same budget.
  • Plot the "best score so far" curve to see Bayesian search learn over trials.
  • Extension: read which hyperparameter mattered most, confirm Optuna is itself reproducible, tune a regression model, and let Optuna choose the model family for you.
In [20]:
!pip install -q optuna
In [21]:
import optuna
from sklearn.model_selection import cross_val_score

optuna.logging.set_verbosity(optuna.logging.WARNING)

def objective(trial):
    params = {
        "n_estimators":      trial.suggest_int("n_estimators", 20, 300),
        "max_depth":         trial.suggest_int("max_depth", 2, 20),
        "min_samples_split": trial.suggest_int("min_samples_split", 2, 20),
    }
    model = RandomForestClassifier(**params, random_state=42)
    return cross_val_score(model, Xtr, ytr, cv=3).mean()

study = optuna.create_study(direction="maximize",
                            sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=20)
print("Best settings:", study.best_params)
print("Best score:", round(study.best_value, 4))
Best settings: {'n_estimators': 59, 'max_depth': 7, 'min_samples_split': 8}
Best score: 0.9472

** Syntax clarity — Optuna's objective(trial) pattern**

def objective(trial):
    params = {
        "n_estimators": trial.suggest_int("n_estimators", 20, 300),
        ...
    }
    model = RandomForestClassifier(**params, random_state=42)
    return cross_val_score(model, Xtr, ytr, cv=3).mean()

study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=20)
  • objective is a plain function you write, but with a required signature: it takes one argument, conventionally named trial, and returns a single number — the score Optuna should try to maximise (or minimise). Optuna calls this function once per trial.
  • trial.suggest_int("n_estimators", 20, 300) asks Optuna "pick a value for the hyperparameter named n_estimators, an integer between 20 and 300, and remember it as part of this trial." Optuna uses the results of previous trials to bias what it suggests next — that's the "Bayesian" part. There's also trial.suggest_float(name, low, high, log=True) for floats, useful for learning rates (see Section 10's log-uniform point).
  • **params unpacks a dict into keyword arguments — RandomForestClassifier(**{"n_estimators": 100, "max_depth": 8}) is exactly the same call as RandomForestClassifier(n_estimators=100, max_depth=8). This only works because the dict's keys match the constructor's actual argument names.
  • Because objective is a normal Python function, it can freely use Xtr/ytr from the surrounding notebook scope (a closure) — you don't need to pass them in as extra arguments.
In [ ]:
# **kwargs unpacking, isolated from Optuna entirely
def describe_forest(n_estimators, max_depth):
    print(f"A forest with {n_estimators} trees, max depth {max_depth}")

settings = {"n_estimators": 100, "max_depth": 8}
describe_forest(**settings)          # same as describe_forest(n_estimators=100, max_depth=8)

# A minimal objective(trial) that doesn't need real data or sklearn,
# to see the suggest_* / optimize shape in isolation:
import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)

def toy_objective(trial):
    x = trial.suggest_float("x", -10, 10)
    return -(x - 3) ** 2   # maximised when x == 3

toy_study = optuna.create_study(direction="maximize")
toy_study.optimize(toy_objective, n_trials=30)
print("Best x found:", toy_study.best_params)
In [ ]:
# TODO

# 1. Grid vs random vs Optuna, 20 trials each, one comparison table.

# 2. Plot best-score-so-far for random search and Optuna on the same graph.
In [ ]:
# TODO (Extension) - parameter importance. Optuna can compute how much each
# hyperparameter contributed to the score variance across your `study` from
# above. Print optuna.importance.get_param_importances(study) and plot a
# horizontal bar chart (param name on y-axis, importance on x-axis).
In [ ]:
# TODO (Extension) - is Optuna itself reproducible? Run two fresh studies
# with the exact same objective, n_trials=10, and TPESampler(seed=42).
# Extract [t.value for t in study.trials] from each and assert they are
# equal. What does this prove - and where would it break?
In [ ]:
# TODO (Extension) - a regression task. Everything so far has been
# classification; switch to regression. Tune GradientBoostingRegressor on the
# California Housing dataset (built into scikit-learn, downloaded on first
# use - needs Internet On). Minimise RMSE, 20 trials. n_estimators 50-300,
# learning_rate log-uniform 1e-3 to 1.0, max_depth 2-8.
In [ ]:
# TODO (Extension) - let Optuna pick the model family. Use
# trial.suggest_categorical to let Optuna choose both the model family AND
# its hyperparameters in a single study - a tiny hand-rolled AutoML. Include
# DecisionTree, RandomForest, and KNN. Run 30 trials on the breast cancer
# data, then count how many trials picked each family.
def objective_automl(trial):
    classifier = trial.suggest_categorical("classifier", ["DT", "RF", "KNN"])
    # if classifier == "DT": suggest DT params, build DecisionTreeClassifier
    # elif classifier == "RF": suggest RF params, build RandomForestClassifier
    # elif classifier == "KNN": suggest KNN params, build KNeighborsClassifier
    # return 5-fold CV accuracy on Xtr, ytr
    pass

# Run 30 trials, print best, then count how often each family was chosen.

12. Data Leakage & Pipelines¶

(Extension section)

Data leakage happens when information from outside the training fold sneaks into training — most commonly by fitting a preprocessing step (like scaling) on the whole dataset before cross-validation, so every "unseen" validation fold has already influenced the scaler. The fix is to put preprocessing inside a Pipeline, so it gets refit on only the training fold at every step of cross-validation.

Learning Outcomes¶

  • See data leakage inflate a CV score with your own eyes.
  • Fix it with a Pipeline, and explain why that fixes it.
  • Tune a Pipeline end-to-end with GridSearchCV.
In [23]:
from sklearn.svm import SVC
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

# --- WRONG: scale before CV (leaky) ---
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)  # sees ALL data, including future validation folds!

leaky_score = cross_val_score(
    SVC(C=1, kernel="rbf", random_state=42), X_scaled, y, cv=5
).mean()

# --- RIGHT: scale inside a Pipeline ---
pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("svm", SVC(C=1, kernel="rbf", random_state=42)),
])
correct_score = cross_val_score(pipe, X, y, cv=5).mean()

print(f"Leaky  (wrong)    CV accuracy: {leaky_score:.4f}")
print(f"Correct (Pipeline) CV accuracy: {correct_score:.4f}")
print(f"Difference: {leaky_score - correct_score:+.4f}")
if abs(leaky_score - correct_score) < 0.005:
    print("(Small or zero gap here - breast cancer has 569 fairly stable rows, so a "
          "per-fold scaler and a whole-dataset scaler end up with nearly identical "
          "mean/std anyway. The leak is still real; it just doesn't move the needle "
          "much on this particular dataset. The Pipeline fix costs nothing, so use it "
          "regardless of whether the gap happens to be visible - on a smaller dataset, "
          "or with a leakier step like selecting features by correlation with the "
          "label, the same mistake can inflate scores by many points.)")
Leaky  (wrong)    CV accuracy: 0.9736
Correct (Pipeline) CV accuracy: 0.9736
Difference: +0.0000
(Small or zero gap here - breast cancer has 569 fairly stable rows, so a per-fold scaler and a whole-dataset scaler end up with nearly identical mean/std anyway. The leak is still real; it just doesn't move the needle much on this particular dataset. The Pipeline fix costs nothing, so use it regardless of whether the gap happens to be visible - on a smaller dataset, or with a leakier step like selecting features by correlation with the label, the same mistake can inflate scores by many points.)
In [ ]:
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("svm", SVC()),
])

# get_params() shows exactly the step__param names GridSearchCV expects:
for name in pipe.get_params():
    if "__" in name:
        print(name)

The direction is always the same — leaky ≥ correct, never the other way around — but the size of the gap depends on the dataset, as the printout above just showed you.

In [ ]:
# TODO

# 1. One sentence: why does fitting StandardScaler on the whole dataset
#    before cross_val_score leak information from the validation folds?
answer = "..."

# 2. Build Pipeline(StandardScaler -> SVC) and tune it with GridSearchCV on
#    the breast cancer data. svm__C: [0.01, 0.1, 1, 10, 100],
#    svm__kernel: ["linear", "rbf"] - note the svm__ prefix, needed because
#    "svm" is the pipeline step's name.

13. AutoML and AutoGluon¶

AutoML automates the whole pipeline: it cleans features, tries several model families, tunes them, and often blends the best ones together — preprocessing → model selection → hyperparameter tuning → ensembling, with no per-model code from you. (Section 11's Extension had you build a tiny version of this by hand with suggest_categorical — this is the industrial-strength version.)

Timing note: pip install autogluon.tabular pulls in several ML frameworks and can take 5-15 minutes depending on what's cached. Start the install, keep working while it runs, and come back to predictor.fit(...) once it's done — don't block the room waiting on it.

Learning Outcomes¶

  • Train and evaluate an AutoGluon TabularPredictor.
  • Read a leaderboard: how many models were tried, which family won, and whether the winner is a blend.
  • Compare a hand-tuned model against AutoGluon on score and total time (including your own setup time).
In [24]:
!pip install -q autogluon.tabular
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 514.8/514.8 kB 13.7 MB/s eta 0:00:0000:01
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 248.4/248.4 kB 11.5 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 112.3/112.3 kB 4.8 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 90.7/90.7 kB 3.3 MB/s eta 0:00:00
In [25]:
from autogluon.tabular import TabularPredictor

train_df = pd.concat([Xtr, ytr], axis=1)
test_df  = pd.concat([Xte, yte], axis=1)

predictor = TabularPredictor(label="diagnosis", path="ag_models").fit(
    train_df,
    time_limit=60,                # stop after 1 minute - this is a small dataset
    presets="medium_quality",     # use "best_quality" if you have more time
)
print(predictor.evaluate(test_df))
Verbosity: 2 (Standard Logging)
=================== System Info ===================
AutoGluon Version:  1.6.1
Python Version:     3.12.13
Operating System:   Linux
Platform Machine:   x86_64
Platform Version:   #1 SMP Sat May 30 15:40:53 UTC 2026
CPU Count:          4
Pytorch Version:    2.10.0+cpu
CUDA Version:       CUDA is not available
Memory Avail:       29.39 GB / 31.35 GB (93.8%)
Disk Space Avail:   19.50 GB / 19.52 GB (99.9%)
===================================================
Presets specified: ['medium_quality']
Using hyperparameters preset: hyperparameters='default'
Beginning AutoGluon training ... Time limit = 60s
AutoGluon will save models to "/kaggle/working/ag_models"
Train Data Rows:    455
Train Data Columns: 30
Label Column:       diagnosis
AutoGluon infers your prediction problem is: 'binary' (because only two unique label-values observed).
	2 unique label values:  ['benign', 'malignant']
	If 'binary' is not the correct problem_type, please manually specify the problem_type parameter during Predictor init (You may specify problem_type as one of: ['binary', 'multiclass', 'regression', 'quantile'])
Problem Type:       binary
Preprocessing data...
Selected class <--> label mapping:  class 1 = malignant, class 0 = benign
	Note: For your binary classification, AutoGluon arbitrarily selected which label-value represents positive (malignant) vs negative (benign) class.
	To explicitly set the positive_class, either rename classes to 1 and 0, or specify positive_class in Predictor init.
Using Feature Generators to preprocess the data ...
Fitting AutoMLPipelineFeatureGenerator...
	Available Memory:                    30094.77 MB
	Train Data (Original)  Memory Usage: 0.10 MB (0.0% of available memory)
	Inferring data type of each feature based on column values. Set feature_metadata_in to manually specify special dtypes of the features.
	Stage 1 Generators:
		Fitting AsTypeFeatureGenerator...
	Stage 2 Generators:
		Fitting FillNaFeatureGenerator...
	Stage 3 Generators:
		Fitting IdentityFeatureGenerator...
	Stage 4 Generators:
		Fitting DropUniqueFeatureGenerator...
	Stage 5 Generators:
		Fitting DropDuplicatesFeatureGenerator...
	Types of features in original data (raw dtype, special dtypes):
		('float', []) : 30 | ['mean radius', 'mean texture', 'mean perimeter', 'mean area', 'mean smoothness', ...]
	Types of features in processed data (raw dtype, special dtypes):
		('float', []) : 30 | ['mean radius', 'mean texture', 'mean perimeter', 'mean area', 'mean smoothness', ...]
	0.1s = Fit runtime
	30 features in original data used to generate 30 features in processed data.
	Train Data (Processed) Memory Usage: 0.10 MB (0.0% of available memory)
Data preprocessing and feature engineering runtime = 0.09s ...
AutoGluon will gauge predictive performance using evaluation metric: 'accuracy'
	To change this, specify the eval_metric parameter of Predictor()
Automatically generating train/validation split with holdout_frac=0.2, Train Rows: 364, Val Rows: 91
User-specified model hyperparameters to be fit:
{
	'NN_TORCH': [{}],
	'GBM': [{'extra_trees': True, 'ag_args': {'name_suffix': 'XT'}}, {}, {'learning_rate': 0.03, 'num_leaves': 128, 'feature_fraction': 0.9, 'min_data_in_leaf': 3, 'ag_args': {'name_suffix': 'Large', 'priority': 0, 'hyperparameter_tune_kwargs': None}}],
	'CAT': [{}],
	'XGB': [{}],
	'FASTAI': [{}],
	'RF': [{'criterion': 'gini', 'ag_args': {'name_suffix': 'Gini', 'problem_types': ['binary', 'multiclass']}}, {'criterion': 'entropy', 'ag_args': {'name_suffix': 'Entr', 'problem_types': ['binary', 'multiclass']}}, {'criterion': 'squared_error', 'ag_args': {'name_suffix': 'MSE', 'problem_types': ['regression', 'quantile']}}],
	'XT': [{'criterion': 'gini', 'ag_args': {'name_suffix': 'Gini', 'problem_types': ['binary', 'multiclass']}}, {'criterion': 'entropy', 'ag_args': {'name_suffix': 'Entr', 'problem_types': ['binary', 'multiclass']}}, {'criterion': 'squared_error', 'ag_args': {'name_suffix': 'MSE', 'problem_types': ['regression', 'quantile']}}],
}
Fitting 11 L1 models, fit_strategy="sequential" ...
Fitting model: LightGBMXT ... Training model for up to 59.91s of the 59.90s of remaining time.
	Fitting with cpus=2, gpus=0, mem=0.1/29.4 GB
	0.978	 = Validation score   (accuracy)
	5.07s	 = Training   runtime
	0.0s	 = Validation runtime
Fitting model: LightGBM ... Training model for up to 54.82s of the 54.82s of remaining time.
	Fitting with cpus=2, gpus=0, mem=0.1/29.3 GB
	0.967	 = Validation score   (accuracy)
	0.51s	 = Training   runtime
	0.0s	 = Validation runtime
Fitting model: RandomForestGini ... Training model for up to 54.29s of the 54.29s of remaining time.
	Fitting with cpus=4, gpus=0
	0.956	 = Validation score   (accuracy)
	1.03s	 = Training   runtime
	0.11s	 = Validation runtime
Fitting model: RandomForestEntr ... Training model for up to 53.14s of the 53.14s of remaining time.
	Fitting with cpus=4, gpus=0
	0.956	 = Validation score   (accuracy)
	1.02s	 = Training   runtime
	0.11s	 = Validation runtime
Fitting model: CatBoost ... Training model for up to 51.99s of the 51.99s of remaining time.
	Fitting with cpus=2, gpus=0
	0.978	 = Validation score   (accuracy)
	3.61s	 = Training   runtime
	0.0s	 = Validation runtime
Fitting model: ExtraTreesGini ... Training model for up to 48.37s of the 48.37s of remaining time.
	Fitting with cpus=4, gpus=0
	0.967	 = Validation score   (accuracy)
	1.05s	 = Training   runtime
	0.11s	 = Validation runtime
Fitting model: ExtraTreesEntr ... Training model for up to 47.19s of the 47.19s of remaining time.
	Fitting with cpus=4, gpus=0
	0.967	 = Validation score   (accuracy)
	1.04s	 = Training   runtime
	0.11s	 = Validation runtime
Fitting model: NeuralNetFastAI ... Training model for up to 46.02s of the 46.01s of remaining time.
	Fitting with cpus=2, gpus=0, mem=0.0/29.3 GB
	0.978	 = Validation score   (accuracy)
	3.4s	 = Training   runtime
	0.02s	 = Validation runtime
Fitting model: XGBoost ... Training model for up to 42.57s of the 42.56s of remaining time.
	Fitting with cpus=2, gpus=0
	0.967	 = Validation score   (accuracy)
	0.31s	 = Training   runtime
	0.0s	 = Validation runtime
Fitting model: NeuralNetTorch ... Training model for up to 42.23s of the 42.23s of remaining time.
	Fitting with cpus=2, gpus=0, mem=0.0/29.3 GB
	0.978	 = Validation score   (accuracy)
	6.47s	 = Training   runtime
	0.02s	 = Validation runtime
Fitting model: LightGBMLarge ... Training model for up to 35.73s of the 35.72s of remaining time.
	Fitting with cpus=2, gpus=0, mem=0.1/29.2 GB
	0.956	 = Validation score   (accuracy)
	1.21s	 = Training   runtime
	0.0s	 = Validation runtime
Fitting model: WeightedEnsemble_L2 ... Training model for up to 59.91s of the 34.50s of remaining time.
	Fitting 1 model on all data | Fitting with cpus=4, gpus=0, mem=0.0/29.2 GB
	Ensemble Weights: {'LightGBMXT': 1.0}
	0.978	 = Validation score   (accuracy)
	0.11s	 = Training   runtime
	0.0s	 = Validation runtime
AutoGluon training complete, total runtime = 25.95s ... Best model: WeightedEnsemble_L2 | Estimated inference throughput: 22332.3 rows/s (91 batch size)
Disabling decision threshold calibration for metric `accuracy` due to having fewer than 10000 rows of validation data for calibration, to avoid overfitting (91 rows).
	`accuracy` is generally not improved through threshold calibration. Force calibration via specifying `calibrate_decision_threshold=True`.
TabularPredictor saved. To load, use: predictor = TabularPredictor.load("/kaggle/working/ag_models")
{'accuracy': 0.9385964912280702, 'balanced_accuracy': np.float64(0.9299999999999999), 'mcc': np.float64(0.8804608268433263), 'roc_auc': np.float64(0.9846874999999999), 'f1': 0.9247311827956989, 'precision': 1.0, 'recall': 0.86}

** Syntax clarity — pd.concat(axis=1) and .fit(**kwargs) with many options**

train_df = pd.concat([Xtr, ytr], axis=1)
  • pd.concat([...]) glues DataFrames/Series together. axis=1 means side by side as new columns (like a spreadsheet JOIN by row position) — contrast with the default axis=0, which stacks rows on top of each other. Here, Xtr (features) and ytr (the label) get glued into one DataFrame because TabularPredictor expects a single DataFrame containing both, not separate X/y like scikit-learn does.
TabularPredictor(label="diagnosis", path="ag_models").fit(
    train_df, time_limit=60, presets="medium_quality")
  • TabularPredictor(...) is constructed first (telling it which column is the target and where to save models), then .fit(train_df, ...) is a separate call — this two-step "configure, then fit" shape is common in AutoML libraries, versus scikit-learn's "configure everything in the constructor" style you've used all lab.
  • time_limit=60 is a budget in seconds, not a number of models — AutoGluon trains as many models as it can fit in that time, then blends the best ones. presets trades speed for quality; use "best_quality" only when you're not on a strict time budget.
In [ ]:
import pandas as pd

# axis=1 (side by side) vs axis=0 (stacked), on tiny toy frames
features = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
labels = pd.Series(["yes", "no"], name="target")

side_by_side = pd.concat([features, labels], axis=1)   # what AutoGluon wants
print(side_by_side)

stacked = pd.concat([features, features], axis=0)      # NOT what you want here
print(stacked)
In [ ]:
# TODO

# 1. Leaderboard: models trained, winner, is it a WeightedEnsemble blend?

# 2. Feature importance ranking - does the top feature match intuition?

# 3. Compare Optuna (Section 11) vs AutoGluon: score, wall time, human time.

14. Weights & Biases Sweeps¶

Local JSON files (Section 6) and TrackIO (Section 7) work great for one person. Weights & Biases (W&B) is a hosted version of the same idea — it records runs, draws the charts for you, and can run the search itself, but the data lives on their servers instead of yours, which is what makes team dashboards and sharing a URL possible. A sweep is an automated hyperparameter search.

One-time setup: create your account and connect it to Kaggle¶

1. Create a free account (2 minutes, one time only — do this before lab):

  • Go to wandb.ai and sign up (Google, GitHub, or email — no credit card needed).
  • Once logged in, go to wandb.ai/authorize and copy your API key (a long string of letters/digits/underscores). Keep this tab open, or copy the key somewhere private — you'll paste it once in the next step.

2. Add the key to this Kaggle notebook as a Secret (do this once per notebook, not once per run):

  • In the Kaggle notebook editor, open the right-hand side panel → Add-ons → Secrets.
  • Click Add a new secret. Set the label to exactly WANDB_API_KEY (the code below looks for this exact name), paste your API key as the value, and save.
  • Make sure the secret's toggle is switched on / attached for this notebook — Kaggle secrets are off by default for notebooks that didn't create them, so if you copy or fork this notebook you must re-attach it.
  • Never paste the raw API key into a code cell. The Secrets panel keeps it out of the notebook's saved file entirely, so it stays private even if you share, fork, or make the notebook public.

3. Run the login cell below once per session. It pulls the key from the Secret and logs in silently — no interactive "paste your API key" prompt, which matters because Kaggle's Save & Run All executes the whole notebook unattended and can't answer an interactive prompt.

Learning Outcomes¶

  • Log a run's config and metrics to Weights & Biases.
  • Launch a sweep and compare grid/random/bayes sweep methods.
  • Read a sweep's parallel-coordinates and importance charts to plan a narrower follow-up sweep.
In [30]:
!pip install -q wandb
In [ ]:
# Run once per session
from kaggle_secrets import UserSecretsClient

# Initialize the secrets client
user_secrets = UserSecretsClient()

# Retrieve your secret value using its label
secret_value = user_secrets.get_secret("WANDB_API_KEY")

import os
os.environ["WANDB_API_KEY"] = secret_value

import wandb
wandb.login()
print("Logged in to W&B.")
In [ ]:
wandb.init(project="stt-ai-breast-cancer-hpo", name="rf-baseline",
           config={"n_estimators": 100, "max_depth": 8, "seed": 42})

cfg = wandb.config
model = RandomForestClassifier(n_estimators=cfg.n_estimators,
                               max_depth=cfg.max_depth,
                               random_state=cfg.seed).fit(Xtr, ytr)

wandb.log({"train_acc": model.score(Xtr, ytr),
           "val_acc":   model.score(Xte, yte)})
wandb.finish()
In [ ]:
# All three parameters use discrete "values" lists, not "min"/"max" ranges.
# This matters specifically for method="grid": grid search needs a finite set
# of values for every parameter. Give it a "min"/"max" range instead and it
# will silently enumerate EVERY integer in that range - for n_estimators
# (20-300) x max_depth (2-20) x 4 values that's ~21,000 grid points, which
# looks like an infinite loop (it's not infinite, just enormous and slow).
# Using "values" everywhere also means grid/random/bayes all search the exact
# same space, making the count=8 comparison in the next TODO a fair one.
sweep_config = {
    "method": "bayes",                                   # or "grid" / "random"
    "metric": {"name": "val_acc", "goal": "maximize"},
    "parameters": {
        "n_estimators":      {"values": [20, 60, 100, 150, 200, 300]},
        "max_depth":         {"values": [2, 4, 8, 12, 16, 20]},
        "min_samples_split": {"values": [2, 5, 10, 20]},
    },
}

def train_one():
    wandb.init()
    cfg = wandb.config
    model = RandomForestClassifier(n_estimators=cfg.n_estimators,
                                   max_depth=cfg.max_depth,
                                   min_samples_split=cfg.min_samples_split,
                                   random_state=42).fit(Xtr, ytr)
    wandb.log({"val_acc": model.score(Xte, yte)})

# sweep_id = wandb.sweep(sweep_config, project="stt-ai-breast-cancer-hpo")
# wandb.agent(sweep_id, function=train_one, count=8)
In [ ]:
# TODO

# 1. Run the sweep 3 times (method = grid, random, bayes), 8 runs each.
#    Print the best val_acc from each.

# 2. (On wandb.ai) note which hyperparameter matters most in the
#    parallel-coordinates chart - write your observation as a comment.

15. Lab Exercise¶

In [1]:
# ==========================================================
# 🏆 Lab Exercise: One Reproducible Training Script
# ==========================================================
#
# Objective
# ---------
# Combine everything from Sections 1-6 into one script that a classmate can
# run to get your exact validation accuracy - the real test of reproducibility.
In [ ]:
# ✅ Task 1: Write train.py that reads settings from config.json.
#
# ✅ Task 2: Set every seed inside the script.
#
# ✅ Task 3: Load the data and print its file hash.
#
# ✅ Task 4: Train the model and print the validation accuracy.
#
# ✅ Task 5: Save the model, metrics, and environment info into one run folder.
#
# ✅ Task 6: Swap train.py, config.json, and requirements.txt with a
#            classmate. Run theirs. Do you get their exact number? If not,
#            find out why and write down the cause.

16. Final Challenge¶

(Extension section)

A full capstone tuning workflow on the Wine dataset (built into scikit-learn, multi-class, 178 samples), pulling together everything from this lab.

Part A: build a leaderboard from the floor up¶

In [ ]:
# TODO

# On the Wine dataset, build a leaderboard with:
# 1. DummyClassifier baseline (most-frequent-class strategy) - what does a
#    classifier that isn't even trying score?
# 2. LogisticRegression (default)
# 3. RandomForest tuned with Optuna (30 trials)
# 4. DIY AutoML: >=4 model families, each with its own small GridSearchCV
# Print one leaderboard sorted by CV accuracy.

Part B: a reusable comparison function¶

In [ ]:
# TODO

# Create a reusable function compare_search_methods(X, y, n_trials=20) that
# runs grid search, random search, and Bayesian search (Optuna) on any
# (X, y) with the same fit budget, and returns one DataFrame comparing best
# score and time taken for all three - so you never have to copy-paste the
# Section 9-11 code again for a new dataset. Test it on the Wine data
# from Part A.

Reflection (write your answers as comments, no code needed)¶

  1. Your MyGridSearchCV (Section 9) and sklearn's GridSearchCV should give the same best score. If they differ slightly, what could cause that?
  2. Looking at Section 10's coverage plot, which method covered the parameter space more evenly for the same budget? Why does that matter?
  3. A colleague suggests running Optuna with 500 trials on every project. Give two reasons that might be wasteful.
  4. Why does putting StandardScaler inside a Pipeline (Section 12) prevent data leakage during cross-validation?
  5. Looking at Section 11's parameter importance chart, which hyperparameter mattered most? Does that match your intuition?
In [ ]:
# TODO - write your answers as a dict of short strings, no need to run code
reflection_answers = {
    1: "...",
    2: "...",
    3: "...",
    4: "...",
    5: "...",
}