Machine Learning › Classification › Day 159
Hands-on lab — Day 159: Precision, Recall, ROC, and Choosing Thresholds
- ← Back to the Day 159 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/machine-learning/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
- Lesson title: Precision, Recall, ROC, and Choosing Thresholds
- Day number: 159 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-159-precision-recall-roc-and-choosing-thresholds
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-159-precision-recall-roc-and-choosing-thresholdswhen the site is running.
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
- Compute the 2x2 confusion matrix ($TN, FP, FN, TP$) from ground truth and predictions.
- Derive and calculate Precision, Recall, Specificity, F1, F2, and MCC metrics.
- Construct the Receiver Operating Characteristic (ROC) curve and calculate ROC AUC via trapezoidal integration.
- Construct the Precision-Recall (PR) curve and explain why it excels on imbalanced data.
- 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
- Implement multi-class macro and micro-averaged F1 metrics.
- Implement Brier Score and calibration curves (reliability diagrams).
- Implement Cohen's Kappa metric for inter-annotator agreement.
Navigation
- 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:
ZeroDivisionErrororRuntimeWarning: invalid value encountered in scalar dividewhenTP + 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.