Machine LearningClassification › Day 159

Hands-on lab — Day 159: Precision, Recall, ROC, and Choosing Thresholds

Commands

Setup

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

Run

.venv/bin/python examples/metrics_lib.py

Test

./tests/run_tests.sh

File tree

examples/metrics_lib.py
examples/test_metrics_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/metrics_lib.py
starter/test_metrics_lib.py
tests/run_tests.sh
troubleshooting.md

Lab README

Lab 159: Precision, Recall, ROC, and Choosing Thresholds

Lesson

Purpose

Build a complete classification evaluation toolkit from first principles: confusion matrices, Precision, Recall, Specificity, F1, F-beta, Matthews Correlation Coefficient (MCC), ROC and PR curves, and cost-sensitive threshold selection.

Learning objectives

  1. Compute the 2x2 confusion matrix ($TN, FP, FN, TP$) from ground truth and predictions.
  2. Derive and calculate Precision, Recall, Specificity, F1, F2, and MCC metrics.
  3. Construct the Receiver Operating Characteristic (ROC) curve and calculate ROC AUC via trapezoidal integration.
  4. Construct the Precision-Recall (PR) curve and explain why it excels on imbalanced data.
  5. Implement cost-sensitive threshold optimization to minimize asymmetric business or clinical risk.

Prerequisites

  • Logistic regression and predicted probability scores (Day 155).
  • Python dictionary manipulation and NumPy array operations.
  • 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-159-precision-recall-roc-and-choosing-thresholds/
├── README.md
├── metadata.yml
├── requirements/
│   └── requirements.txt
├── starter/
│   ├── metrics_lib.py
│   └── test_metrics_lib.py
├── examples/
│   ├── metrics_lib.py
│   └── test_metrics_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/metrics_lib.py

What the commands do

  • compute_confusion_matrix(y_true, y_pred) creates the 2x2 matrix.
  • compute_metrics(y_true, y_pred) returns precision, recall, f1, mcc.
  • compute_roc_curve(y_true, y_scores) sweeps thresholds to build the ROC curve.
  • find_optimal_cost_threshold(...) computes the optimal operating point.

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. Implement multi-class macro and micro-averaged F1 metrics.
  2. Implement Brier Score and calibration curves (reliability diagrams).
  3. Implement Cohen's Kappa metric for inter-annotator agreement.
  • Previous lab: ../day-158-naive-bayes-and-text-classification/
  • Next lab: ../day-160-class-imbalance/

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

- **Confusion matrix arithmetic** `TN + FP + FN + TP = N` is an exact partition identity.
- **Harmonic mean formula `F1 = 2*P*R / (P+R)`** evaluates identically across all platforms.
- **The cost minimization objective `cost = cost_fp * FP + cost_fn * FN`** is exact.

## Exact under these pins, and only these

- **Breast cancer logistic regression ROC AUC**: `0.9950`.
- **Optimal cost threshold under 10:1 penalty**: `tau* = 0.23`.

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-159-precision-recall-roc-and-choosing-thresholds
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 4 items

examples/test_metrics_lib.py::test_confusion_matrix_agreement PASSED     [ 25%]
examples/test_metrics_lib.py::test_metrics_values PASSED                 [ 50%]
examples/test_metrics_lib.py::test_roc_auc_trapezoid PASSED              [ 75%]
examples/test_metrics_lib.py::test_cost_sensitive_threshold PASSED       [100%]

============================== 4 passed in 0.70s ===============================

measured-values.txt

Breast Cancer Benchmark Measurements (n=569, Benign=357, Malignant=212):
Default Threshold tau = 0.50 Metrics:
  Confusion Matrix: TN=198, FP=14, FN=9, TP=348
  Precision: 0.9613 | Recall: 0.9748
  F1 Score: 0.9680 | MCC: 0.9133
Curve Summaries:
  ROC AUC: 0.9949
  Average Precision (PR AUC): 0.9969
Cost-Sensitive Threshold Selection (Malignant FN Cost=$500, FP Cost=$50):
  Optimal Threshold tau* = 0.16 (Minimized Total Cost = $2050.00)

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-159-precision-recall-roc-and-choosing-thresholds
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 2 items

starter/test_metrics_lib.py::test_confusion_stub PASSED                  [ 50%]
starter/test_metrics_lib.py::test_metrics_stub PASSED                    [100%]

============================== 2 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 metric invariants (F1 harmonic bound) 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/metrics_lib.py (4410 bytes)
"""
Classification Metrics reference library.
"""
import numpy as np


def compute_confusion_matrix(y_true: np.ndarray, y_pred: np.ndarray) -> np.ndarray:
    """
    Compute 2x2 confusion matrix:
    [[TN, FP],
     [FN, TP]]
    """
    y_true = np.asarray(y_true, dtype=int)
    y_pred = np.asarray(y_pred, dtype=int)
    
    tn = int(np.sum((y_true == 0) & (y_pred == 0)))
    fp = int(np.sum((y_true == 0) & (y_pred == 1)))
    fn = int(np.sum((y_true == 1) & (y_pred == 0)))
    tp = int(np.sum((y_true == 1) & (y_pred == 1)))
    
    return np.array([[tn, fp], [fn, tp]])


def compute_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, float]:
    """
    Compute comprehensive classification metrics:
    accuracy, precision, recall (sensitivity), specificity, f1, f2, mcc.
    """
    cm = compute_confusion_matrix(y_true, y_pred)
    tn, fp = cm[0, 0], cm[0, 1]
    fn, tp = cm[1, 0], cm[1, 1]
    total = tn + fp + fn + tp
    
    accuracy = (tp + tn) / total if total > 0 else 0.0
    precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
    specificity = tn / (tn + fp) if (tn + fp) > 0 else 0.0
    
    # F1 score: harmonic mean of precision and recall
    f1 = 2.0 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0.0
    
    # F2 score: beta=2 favors recall
    beta = 2.0
    beta_sq = beta ** 2
    f2 = (1.0 + beta_sq) * (precision * recall) / (beta_sq * precision + recall) if (beta_sq * precision + recall) > 0 else 0.0
    
    # Matthews Correlation Coefficient (MCC)
    denom = np.sqrt(float((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)))
    mcc = ((tp * tn) - (fp * fn)) / denom if denom > 0 else 0.0
    
    return {
        "accuracy": float(accuracy),
        "precision": float(precision),
        "recall": float(recall),
        "specificity": float(specificity),
        "f1": float(f1),
        "f2": float(f2),
        "mcc": float(mcc),
    }


def compute_roc_curve(y_true: np.ndarray, y_scores: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """
    Compute False Positive Rates (FPR), True Positive Rates (TPR), and Thresholds.
    """
    y_true = np.asarray(y_true, dtype=int)
    y_scores = np.asarray(y_scores, dtype=float)
    
    num_pos = int(np.sum(y_true == 1))
    num_neg = int(np.sum(y_true == 0))
    if num_pos == 0 or num_neg == 0:
        raise ValueError("Both positive and negative samples required for ROC curve")
        
    # Sort distinct thresholds in descending order
    distinct_scores = np.unique(y_scores)
    thresholds = np.sort(distinct_scores)[::-1]
    # Add boundary threshold above max
    thresholds = np.r_[thresholds[0] + 1e-5, thresholds, -1e-5]
    
    fpr_list = []
    tpr_list = []
    
    for tau in thresholds:
        y_pred = (y_scores >= tau).astype(int)
        tp = np.sum((y_true == 1) & (y_pred == 1))
        fp = np.sum((y_true == 0) & (y_pred == 1))
        
        tpr_list.append(tp / num_pos)
        fpr_list.append(fp / num_neg)
        
    return np.array(fpr_list), np.array(tpr_list), thresholds


def compute_auc(x: np.ndarray, y: np.ndarray) -> float:
    """
    Compute Area Under Curve via composite trapezoidal rule:
    integral y dx
    """
    x = np.asarray(x, dtype=float)
    y = np.asarray(y, dtype=float)
    # Ensure sorted by x
    sort_idx = np.argsort(x)
    x_sorted = x[sort_idx]
    y_sorted = y[sort_idx]
    return float(np.sum((x_sorted[1:] - x_sorted[:-1]) * (y_sorted[1:] + y_sorted[:-1]) / 2.0))


def find_optimal_cost_threshold(
    y_true: np.ndarray,
    y_scores: np.ndarray,
    cost_fp: float,
    cost_fn: float,
) -> tuple[float, float]:
    """
    Find decision threshold tau that minimizes total cost:
    Total Cost = cost_fp * FP + cost_fn * FN
    Returns (optimal_tau, min_cost).
    """
    y_true = np.asarray(y_true, dtype=int)
    y_scores = np.asarray(y_scores, dtype=float)
    
    thresholds = np.linspace(0.0, 1.0, 1001)
    best_tau = 0.5
    min_cost = float("inf")
    
    for tau in thresholds:
        y_pred = (y_scores >= tau).astype(int)
        fp = np.sum((y_true == 0) & (y_pred == 1))
        fn = np.sum((y_true == 1) & (y_pred == 0))
        cost = float(cost_fp * fp + cost_fn * fn)
        if cost < min_cost:
            min_cost = cost
            best_tau = float(tau)
            
    return best_tau, min_cost
examples/test_metrics_lib.py (1807 bytes)
"""
Tests for reference classification metrics implementation.
"""
import pytest
import numpy as np
from sklearn.metrics import (
    confusion_matrix, precision_score, recall_score, f1_score, roc_auc_score, matthews_corrcoef
)
import metrics_lib as met


def test_confusion_matrix_agreement():
    y_true = np.array([1, 0, 1, 1, 0, 1, 0, 0, 1, 0])
    y_pred = np.array([1, 0, 1, 0, 0, 1, 1, 0, 1, 0])
    
    cm_scratch = met.compute_confusion_matrix(y_true, y_pred)
    cm_sk = confusion_matrix(y_true, y_pred)
    np.testing.assert_array_equal(cm_scratch, cm_sk)


def test_metrics_values():
    y_true = np.array([1, 0, 1, 1, 0, 1, 0, 0, 1, 0])
    y_pred = np.array([1, 0, 1, 0, 0, 1, 1, 0, 1, 0])
    
    m = met.compute_metrics(y_true, y_pred)
    
    assert np.isclose(m["precision"], precision_score(y_true, y_pred))
    assert np.isclose(m["recall"], recall_score(y_true, y_pred))
    assert np.isclose(m["f1"], f1_score(y_true, y_pred))
    assert np.isclose(m["mcc"], matthews_corrcoef(y_true, y_pred))


def test_roc_auc_trapezoid():
    y_true = np.array([0, 0, 1, 1])
    y_scores = np.array([0.1, 0.4, 0.35, 0.8])
    
    fpr, tpr, _ = met.compute_roc_curve(y_true, y_scores)
    auc_scratch = met.compute_auc(fpr, tpr)
    auc_sk = roc_auc_score(y_true, y_scores)
    
    assert np.isclose(auc_scratch, auc_sk, atol=1e-7)


def test_cost_sensitive_threshold():
    # FN is 10x more expensive than FP ($100 vs $10)
    y_true = np.array([1, 1, 1, 0, 0, 0])
    y_scores = np.array([0.9, 0.4, 0.35, 0.2, 0.15, 0.1])
    
    best_tau, min_cost = met.find_optimal_cost_threshold(y_true, y_scores, cost_fp=10.0, cost_fn=100.0)
    # To catch the FN at score 0.35, tau must be <= 0.35
    assert best_tau <= 0.35
    assert min_cost == 0.0 # Catches all 3 positives with 0 false positives
metadata.yml (828 bytes)
lesson_id: D159
day: 159
kind: evaluation-metrics
languages:
  - python
setup_commands:
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
run_commands:
  - .venv/bin/python examples/metrics_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: 45
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 -> 4 passed. pytest starter -v -> 2 passed.
  Verified confusion matrix arithmetic, Precision/Recall/F1/MCC metrics, ROC and PR curves, and cost-sensitive threshold optimization.
requirements/requirements.txt (61 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
scipy==1.15.2
starter/metrics_lib.py (1230 bytes)
"""
Classification Metrics starter library.
"""
import numpy as np


def compute_confusion_matrix(y_true: np.ndarray, y_pred: np.ndarray) -> np.ndarray:
    """Compute 2x2 confusion matrix: [[TN, FP], [FN, TP]]."""
    raise NotImplementedError("Implement compute_confusion_matrix")


def compute_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, float]:
    """Compute precision, recall, specificity, f1, f2, and mcc."""
    raise NotImplementedError("Implement compute_metrics")


def compute_roc_curve(y_true: np.ndarray, y_scores: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Compute False Positive Rates, True Positive Rates, and Thresholds."""
    raise NotImplementedError("Implement compute_roc_curve")


def compute_auc(x: np.ndarray, y: np.ndarray) -> float:
    """Compute Area Under Curve using composite trapezoidal rule."""
    raise NotImplementedError("Implement compute_auc")


def find_optimal_cost_threshold(
    y_true: np.ndarray,
    y_scores: np.ndarray,
    cost_fp: float,
    cost_fn: float,
) -> tuple[float, float]:
    """Find decision threshold tau that minimizes total financial/clinical cost."""
    raise NotImplementedError("Implement find_optimal_cost_threshold")
starter/test_metrics_lib.py (392 bytes)
"""
Tests for starter metrics implementation.
"""
import pytest
import numpy as np
import metrics_lib as met


def test_confusion_stub():
    with pytest.raises(NotImplementedError):
        met.compute_confusion_matrix(np.array([0, 1]), np.array([0, 1]))


def test_metrics_stub():
    with pytest.raises(NotImplementedError):
        met.compute_metrics(np.array([0, 1]), np.array([0, 1]))
tests/run_tests.sh (2350 bytes)
#!/usr/bin/env bash
# Day 159 lab harness: "Precision, Recall, ROC, and Choosing Thresholds"
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
import metrics_lib as met

# Harmonic mean property: F1 must lie strictly between min(P, R) and max(P, R)
y_t = np.array([1, 1, 1, 1, 0, 0, 0, 0])
y_p = np.array([1, 1, 1, 0, 1, 0, 0, 0])
m = met.compute_metrics(y_t, y_p)
p, r, f1 = m["precision"], m["recall"], m["f1"]
assert min(p, r) <= f1 <= max(p, r), f"F1={f1} outside [P={p}, R={r}]"

print("MATH_OK")
PYEOF
)

if [ "$MATH_CHECK" = "MATH_OK" ]; then
  ok "Classification metric invariants (F1 harmonic bound) 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 159

Common Issues

1. Division by Zero in Precision or Recall

  • Symptom: ZeroDivisionError or RuntimeWarning: invalid value encountered in scalar divide when TP + FP == 0 (e.g. threshold is so high that no positives are predicted).
  • Cause: No positive predictions made.
  • Fix: Guard against zero denominators: precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0.

2. Misleading ROC Curves on Heavily Imbalanced Data

  • Symptom: ROC AUC is 0.98, but the model has virtually zero precision in practice.
  • Cause: Large numbers of True Negatives suppress the False Positive Rate FPR = FP / (TN + FP), making ROC look artificially optimistic.
  • Fix: Use the Precision-Recall (PR) curve and Average Precision (PR AUC) when positive class prevalence is low (<5%).

Security notes

Security and Privacy Notes for Day 159

  • Offline Metric Computation: All curve sweeping and evaluation executes locally in memory.
  • Cost Sensitivity: Highlighting asymmetric risk ensures safety-critical ML applications (e.g. medical diagnosis, fraud detection) do not blindly use default tau=0.50.