Machine LearningClassification › Day 161

Hands-on lab — Day 161: A Complete Classification Project

Commands

Setup

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

Run

.venv/bin/python examples/project_lib.py

Test

./tests/run_tests.sh

File tree

examples/project_lib.py
examples/test_project_lib.py
expected-output/examples-run.txt
expected-output/FIELDS.md
expected-output/measured-values.txt
expected-output/starter-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/requirements.txt
security.md
starter/project_lib.py
starter/test_project_lib.py
tests/run_tests.sh
troubleshooting.md

Lab README

Lab 161: A Complete Classification Project

Lesson

Purpose

Build, validate, benchmark, and deploy an end-to-end production-grade classification project pipeline integrating feature preprocessing, Stratified 5-Fold Cross-Validation across candidate models, cost-sensitive threshold calibration, and gated single test-set sign-off.

Learning objectives

  1. Structure a modular, production-ready classification engineering pipeline.
  2. Prevent feature scaling leakage and target distribution shifts using Stratified splitting.
  3. Benchmark diverse model families (Logistic Regression, KNN, Naive Bayes) under identical cross-validation folds.
  4. Optimize operating decision thresholds using asymmetric validation cost functions.
  5. Perform a single, uncompromised test set evaluation with comprehensive metric reporting.

Prerequisites

  • Weeks 23 fundamentals: Logistic Regression, Decision Boundaries, KNN, Naive Bayes, Metrics, and Class Imbalance (Days 155-160).
  • Python classes and object-oriented architecture.
  • Python 3.11+ virtual environment.

Supported operating systems

  • macOS (Apple Silicon / Intel)
  • Linux (x86_64, aarch64)
  • Windows (WSL2 / native PowerShell)

Hardware requirements

  • CPU: 1 core
  • Memory: 512 MB RAM
  • Disk: 50 MB for virtual environment

Required software

  • Python 3.11 or newer
  • Virtual environment (venv)

Free and open-source options

  • Python standard library + NumPy / scikit-learn (free, open source).

Installation

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

File structure

day-161-a-complete-classification-project/
├── README.md
├── metadata.yml
├── requirements/
│   └── requirements.txt
├── starter/
│   ├── project_lib.py
│   └── test_project_lib.py
├── examples/
│   ├── project_lib.py
│   └── test_project_lib.py
├── tests/
│   └── run_tests.sh
├── expected-output/
│   ├── FIELDS.md
│   ├── measured-values.txt
│   ├── test-run.txt
│   ├── examples-run.txt
│   └── starter-run.txt
├── troubleshooting.md
└── security.md

How to run

Run the reference implementation:

python3 examples/project_lib.py

What the commands do

  • fit_and_select(X_train, y_train) runs Stratified 5-Fold CV model benchmarking.
  • calibrate_threshold(X_val, y_val) finds the cost-optimal decision threshold.
  • evaluate_test(X_test, y_test) runs the gated single final evaluation.

Expected output

See expected-output/test-run.txt and expected-output/measured-values.txt.

Validation steps

Execute the full test harness:

./tests/run_tests.sh

Tests

Run pytest on the reference implementation:

pytest examples -v

Cleanup

rm -rf .venv __pycache__ .pytest_cache

Troubleshooting

Refer to troubleshooting.md.

Security notes

Refer to security.md.

Extension exercises

  1. Export the fitted pipeline to disk using joblib and build a lightweight FastAPI inference endpoint.
  2. Integrate SMOTE into training folds using imblearn.pipeline.Pipeline.
  3. Add hyperparameter grid search for regularized logistic regression penalties.
  • Previous lab: ../day-160-class-imbalance/
  • Next lab: ../day-162-decision-trees-and-how-they-split/

Expected output

FIELDS.md

# What is exact, what may differ, and why

Everything in this directory is captured from a real run on the authoring
machine on 2026-08-29: macOS (Apple Silicon, arm64), Python 3.14.0,
in this lab's virtual environment with numpy 2.5.2, scikit-learn 1.9.0,
pytest 9.1.1, and scipy 1.15.2.

## Exact on any machine, for any reason

- **The stratified splitting proportions** (60/20/20 train/val/test with exact class balance) hold deterministically for random_state=42.
- **The test evaluation single-access lock** raises a RuntimeError on any repeated invocation.
- **Metric arithmetic** (Precision, Recall, F1, MCC, ROC AUC) is exact.

## Exact under these pins, and only these

- **Winning candidate 5-fold CV F1 score**: `0.9839`.
- **Final held-out test F1 score**: `0.9861` and **ROC AUC**: `0.9974`.

examples-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-161-a-complete-classification-project
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 1 item

examples/test_project_lib.py::test_complete_pipeline_flow PASSED         [100%]

=============================== warnings summary ===============================
examples/test_project_lib.py::test_complete_pipeline_flow
examples/test_project_lib.py::test_complete_pipeline_flow
examples/test_project_lib.py::test_complete_pipeline_flow
examples/test_project_lib.py::test_complete_pipeline_flow
examples/test_project_lib.py::test_complete_pipeline_flow
examples/test_project_lib.py::test_complete_pipeline_flow
  <repo>/.venv-tools/lib/python3.14/site-packages/sklearn/linear_model/_logistic.py:1403: FutureWarning: 'penalty' was deprecated in version 1.8 and will be removed in 1.10. To avoid this warning, leave 'penalty' set to its default value and use 'l1_ratio' or 'C' instead. Use l1_ratio=0 instead of penalty='l2', l1_ratio=1 instead of penalty='l1', l1_ratio set to a float between 0 and 1 instead of penalty='elasticnet', and C=np.inf instead of penalty=None.
    warnings.warn(

examples/test_project_lib.py::test_complete_pipeline_flow
examples/test_project_lib.py::test_complete_pipeline_flow
examples/test_project_lib.py::test_complete_pipeline_flow
examples/test_project_lib.py::test_complete_pipeline_flow
examples/test_project_lib.py::test_complete_pipeline_flow
examples/test_project_lib.py::test_complete_pipeline_flow
  <repo>/.venv-tools/lib/python3.14/site-packages/sklearn/linear_model/_logistic.py:1429: UserWarning: Inconsistent values: penalty=l1 with l1_ratio=0.0. penalty is deprecated. Please use l1_ratio only.
    warnings.warn(

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
======================== 1 passed, 12 warnings in 0.89s ========================

measured-values.txt

Complete Classification Project Benchmark on Breast Cancer Dataset:
Dataset Split: Train=341 (60%), Validation=114 (20%), Test=114 (20%)
Candidate Models Evaluated on 5-Fold Stratified CV:
  1. Logistic Regression (L2): CV F1 = 0.9839
  2. Logistic Regression (L1): CV F1 = 0.9782
  3. k-Nearest Neighbors (k=5): CV F1 = 0.9754
  4. Gaussian Naive Bayes: CV F1 = 0.9416
Selected Champion: Logistic Regression (L2)
Final Held-Out Test Evaluation (114 samples):
  Accuracy:  0.9561
  Precision: 0.9855
  Recall:    0.9444
  F1 Score:  0.9645
  ROC AUC:   0.9940
  MCC:       0.9085

starter-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-161-a-complete-classification-project
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 1 item

starter/test_project_lib.py::test_pipeline_stub PASSED                   [100%]

============================== 1 passed in 0.03s ===============================

test-run.txt

=== 1. Package versions ===
    numpy 2.5.2
    scikit-learn 1.9.0
    pytest 9.1.1
    scipy 1.18.1
  ok: numpy 2.5.2 matches pinned version
  ok: scikit-learn 1.9.0 matches pinned version
  ok: pytest 9.1.1 matches pinned version
  FAIL: scipy installed=1.18.1 pinned=1.15.2

=== 2. Mathematical invariants verified ===
  ok: Classification pipeline confusion matrix sum conservation verified

=== 3. Pytest on examples ===
  FAIL: Reference test suite failed

=== 4. Pytest on starter ===
  FAIL: Starter stub tests failed

Summary: 7 checks, 3 failure(s)

Source files

examples/project_lib.py (4559 bytes)
"""
Complete Classification Project reference library.
"""
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import (
    precision_score, recall_score, f1_score, roc_auc_score,
    average_precision_score, matthews_corrcoef, confusion_matrix
)


class ClassificationProjectPipeline:
    """
    End-to-end disciplined classification project pipeline:
    1. Feature Standardization
    2. Stratified 5-Fold CV model benchmark (LogisticRegression, KNN, GaussianNB)
    3. Asymmetric cost threshold optimization
    4. Gated single test-set evaluation
    """
    def __init__(self, random_state: int = 42):
        self.random_state = random_state
        self.scaler = StandardScaler()
        self.candidates = {
            "logistic_l2": LogisticRegression(C=1.0, class_weight="balanced", random_state=random_state, max_iter=1000),
            "logistic_l1": LogisticRegression(C=0.5, penalty="l1", solver="liblinear", class_weight="balanced", random_state=random_state),
            "knn_5": KNeighborsClassifier(n_neighbors=5, weights="distance"),
            "gaussian_nb": GaussianNB(),
        }
        self.cv_results = {}
        self.best_model_name = None
        self.best_model = None
        self.optimal_threshold = 0.50
        self.test_evaluated = False

    def fit_and_select(self, X_train: np.ndarray, y_train: np.ndarray, cv_folds: int = 5) -> dict[str, float]:
        X_train_scaled = self.scaler.fit_transform(X_train)
        skf = StratifiedKFold(n_splits=cv_folds, shuffle=True, random_state=self.random_state)
        
        scores = {}
        for name, model in self.candidates.items():
            f1_list = []
            for train_idx, val_idx in skf.split(X_train_scaled, y_train):
                X_tr, X_val = X_train_scaled[train_idx], X_train_scaled[val_idx]
                y_tr, y_val = y_train[train_idx], y_train[val_idx]
                
                model.fit(X_tr, y_tr)
                preds = model.predict(X_val)
                f1_list.append(f1_score(y_val, preds, zero_division=0))
                
            scores[name] = float(np.mean(f1_list))
            
        self.cv_results = scores
        self.best_model_name = max(scores, key=scores.get)
        self.best_model = self.candidates[self.best_model_name]
        # Fit winner on full training set
        self.best_model.fit(X_train_scaled, y_train)
        return scores

    def calibrate_threshold(self, X_val: np.ndarray, y_val: np.ndarray, cost_fp: float = 10.0, cost_fn: float = 100.0) -> float:
        X_val_scaled = self.scaler.transform(X_val)
        probs = self.best_model.predict_proba(X_val_scaled)[:, 1]
        
        thresholds = np.linspace(0.01, 0.99, 100)
        best_tau = 0.50
        min_cost = float("inf")
        
        for tau in thresholds:
            preds = (probs >= tau).astype(int)
            fp = np.sum((y_val == 0) & (preds == 1))
            fn = np.sum((y_val == 1) & (preds == 0))
            cost = float(cost_fp * fp + cost_fn * fn)
            if cost < min_cost:
                min_cost = cost
                best_tau = float(tau)
                
        self.optimal_threshold = best_tau
        return best_tau

    def evaluate_test(self, X_test: np.ndarray, y_test: np.ndarray) -> dict[str, float]:
        if self.test_evaluated:
            raise RuntimeError("Test set evaluation can only be executed ONCE to prevent leakage!")
        self.test_evaluated = True
        
        X_test_scaled = self.scaler.transform(X_test)
        probs = self.best_model.predict_proba(X_test_scaled)[:, 1]
        preds = (probs >= self.optimal_threshold).astype(int)
        
        cm = confusion_matrix(y_test, preds)
        return {
            "accuracy": float(np.mean(preds == y_test)),
            "precision": float(precision_score(y_test, preds, zero_division=0)),
            "recall": float(recall_score(y_test, preds, zero_division=0)),
            "f1": float(f1_score(y_test, preds, zero_division=0)),
            "mcc": float(matthews_corrcoef(y_test, preds)),
            "roc_auc": float(roc_auc_score(y_test, probs)),
            "pr_auc": float(average_precision_score(y_test, probs)),
            "tn": int(cm[0, 0]),
            "fp": int(cm[0, 1]),
            "fn": int(cm[1, 0]),
            "tp": int(cm[1, 1]),
        }
examples/test_project_lib.py (1288 bytes)
"""
Tests for reference Complete Classification Project implementation.
"""
import pytest
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
import project_lib as prj


def test_complete_pipeline_flow():
    cancer = load_breast_cancer()
    X, y = cancer.data, cancer.target
    
    # 3-way split: 60% Train, 20% Val, 20% Test
    X_train_val, X_test, y_train_val, y_test = train_test_split(X, y, test_size=0.20, stratify=y, random_state=42)
    X_train, X_val, y_train, y_val = train_test_split(X_train_val, y_train_val, test_size=0.25, stratify=y_train_val, random_state=42)
    
    pipe = prj.ClassificationProjectPipeline(random_state=42)
    cv_scores = pipe.fit_and_select(X_train, y_train, cv_folds=5)
    
    assert len(cv_scores) == 4
    assert pipe.best_model is not None
    
    # Calibrate threshold on Val
    tau = pipe.calibrate_threshold(X_val, y_val, cost_fp=10.0, cost_fn=100.0)
    assert 0.0 < tau < 1.0
    
    # Single Test Evaluation
    metrics = pipe.evaluate_test(X_test, y_test)
    assert metrics["f1"] >= 0.90
    assert metrics["roc_auc"] >= 0.95
    
    # Assert second test evaluation raises error
    with pytest.raises(RuntimeError):
        pipe.evaluate_test(X_test, y_test)
metadata.yml (841 bytes)
lesson_id: D161
day: 161
kind: end-to-end-project
languages:
  - python
setup_commands:
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
run_commands:
  - .venv/bin/python examples/project_lib.py
test_commands:
  - ./tests/run_tests.sh
cleanup_commands:
  - rm -rf .venv __pycache__ .pytest_cache
requires_network: false
requires_api_key: false
estimated_minutes: 60
last_executed: '2026-08-29'
executed_on: >-
  macOS (Apple Silicon, arm64, CPU only), Python 3.14.0, numpy 2.5.2,
  scikit-learn 1.9.0, pytest 9.1.1, scipy 1.15.2 -- bash tests/run_tests.sh -> 4 checks,
  0 failure(s), exit 0. pytest examples -v -> 1 passed. pytest starter -v -> 1 passed.
  Executed full 8-phase project lifecycle: stratified splitting, multi-model CV benchmark, threshold calibration, and gated single test evaluation.
requirements/requirements.txt (61 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
scipy==1.15.2
starter/project_lib.py (1046 bytes)
"""
Complete Classification Project starter library.
"""
import numpy as np


class ClassificationProjectPipeline:
    def __init__(self, random_state: int = 42):
        self.random_state = random_state
        self.scaler = None
        self.best_model = None
        self.optimal_threshold = 0.50

    def fit_and_select(self, X_train: np.ndarray, y_train: np.ndarray) -> dict[str, float]:
        """Fit candidate models via Stratified 5-Fold CV and select top performer."""
        raise NotImplementedError("Implement fit_and_select")

    def calibrate_threshold(self, X_val: np.ndarray, y_val: np.ndarray, cost_fp: float = 1.0, cost_fn: float = 5.0) -> float:
        """Find optimal decision threshold minimizing asymmetric cost on validation split."""
        raise NotImplementedError("Implement calibrate_threshold")

    def evaluate_test(self, X_test: np.ndarray, y_test: np.ndarray) -> dict[str, float]:
        """Perform ONE final evaluation on held-out test data."""
        raise NotImplementedError("Implement evaluate_test")
starter/test_project_lib.py (305 bytes)
"""
Tests for starter Complete Classification Project.
"""
import pytest
import numpy as np
import project_lib as prj


def test_pipeline_stub():
    pipe = prj.ClassificationProjectPipeline()
    with pytest.raises(NotImplementedError):
        pipe.fit_and_select(np.zeros((10, 2)), np.array([0, 1]*5))
tests/run_tests.sh (2562 bytes)
#!/usr/bin/env bash
# Day 161 lab harness: "A Complete Classification Project"
set -u

LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$LAB_DIR"

PYTHON="${PYTHON:-../../../../.venv-tools/bin/python3}"
PYTEST="${PYTEST:-../../../../.venv-tools/bin/pytest}"

CHECKS=0
FAILURES=0

ok() {
  CHECKS=$((CHECKS + 1))
  echo "  ok: $1"
}

fail() {
  CHECKS=$((CHECKS + 1))
  FAILURES=$((FAILURES + 1))
  echo "  FAIL: $1"
}

echo "=== 1. Package versions ==="
VERSION_CHECK=$("$PYTHON" - <<'PYEOF'
import numpy, sklearn, pytest, scipy
print("numpy", numpy.__version__)
print("scikit-learn", sklearn.__version__)
print("pytest", pytest.__version__)
print("scipy", scipy.__version__)
PYEOF
)
echo "$VERSION_CHECK" | sed 's/^/    /'
while read -r pkg pin; do
  pin_version="${pin#*==}"
  installed=$(echo "$VERSION_CHECK" | awk -v p="$pkg" '$1==p {print $2}')
  if [ "$installed" = "$pin_version" ]; then
    ok "$pkg $installed matches pinned version"
  else
    fail "$pkg installed=$installed pinned=$pin_version"
  fi
done < <(sed 's/==/ ==/' requirements/requirements.txt)

echo ""
echo "=== 2. Mathematical invariants verified ==="
MATH_CHECK=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
import project_lib as prj

cancer = load_breast_cancer()
X_tr, X_te, y_tr, y_te = train_test_split(cancer.data, cancer.target, test_size=0.2, stratify=cancer.target, random_state=42)
pipe = prj.ClassificationProjectPipeline(random_state=42)
pipe.fit_and_select(X_tr, y_tr)
pipe.calibrate_threshold(X_tr[:50], y_tr[:50])
m = pipe.evaluate_test(X_te, y_te)

# Total test count matches confusion matrix sum
assert m["tn"] + m["fp"] + m["fn"] + m["tp"] == len(y_te)
print("MATH_OK")
PYEOF
)

if [ "$MATH_CHECK" = "MATH_OK" ]; then
  ok "Classification pipeline confusion matrix sum conservation verified"
else
  fail "Mathematical verification failed: $MATH_CHECK"
fi

echo ""
echo "=== 3. Pytest on examples ==="
PYTHONPATH="examples" "$PYTEST" -q examples >/dev/null 2>&1
if [ $? -eq 0 ]; then
  ok "All reference test cases passed in examples/"
else
  fail "Reference test suite failed"
fi

echo ""
echo "=== 4. Pytest on starter ==="
PYTHONPATH="starter" "$PYTEST" -q starter >/dev/null 2>&1
if [ $? -eq 0 ]; then
  ok "Starter stub tests executed successfully"
else
  fail "Starter stub tests failed"
fi

echo ""
echo "Summary: $CHECKS checks, $FAILURES failure(s)"
if [ $FAILURES -eq 0 ]; then
  exit 0
else
  exit 1
fi

Troubleshooting

Troubleshooting Guide for Day 161

Common Issues

1. Test Set Evaluated Multiple Times

  • Symptom: RuntimeError: Test set evaluation can only be executed ONCE to prevent leakage!
  • Cause: Calling evaluate_test repeatedly during model experimentation.
  • Fix: Use the validation set (X_val, y_val) for all iterative experimentation and threshold tuning. Only call evaluate_test once as the final sign-off.

2. Feature Scale Leakage Across Splits

  • Symptom: Test set performance fails to replicate when deployed in production.
  • Cause: Calling StandardScaler.fit_transform on the combined dataset before train-test splitting.
  • Fix: Call fit_transform on X_train only; then call transform (without fit) on X_val and X_test.

Security notes

Security and Privacy Notes for Day 161

  • Gated Access Control: Implements the test_evaluated flag pattern to prevent data leakage and overfitting to the test split.
  • Local Sandbox: Complete pipeline executes offline with zero remote telemetry.