Machine LearningClassification › Day 155

Hands-on lab — Day 155: Logistic Regression

Commands

Setup

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

Run

.venv/bin/python examples/logistic_lib.py

Test

./tests/run_tests.sh

File tree

examples/logistic_lib.py
examples/test_logistic_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/logistic_lib.py
starter/test_logistic_lib.py
tests/run_tests.sh
troubleshooting.md

Lab README

Lab 155: Logistic Regression from First Principles

Lesson

  • Lesson title: Logistic Regression
  • Day number: 155 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-155-logistic-regression
  • 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-155-logistic-regression when the site is running.

Purpose

Build, train, and validate a binary logistic regression classifier from first principles using NumPy, then benchmark against scikit-learn's LogisticRegression.

Learning objectives

  1. Implement the numerically stable sigmoid activation function.
  2. Compute predicted probabilities and binary cross-entropy (log loss).
  3. Derive and implement analytical gradients with respect to weights and bias.
  4. Train the model with batch gradient descent on standardized features.
  5. Benchmark scratch convergence and accuracy against scikit-learn.

Prerequisites

  • Linear regression concepts (Day 148-153).
  • Vectorized NumPy array operations and matrix multiplication (np.dot).
  • 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, BSD/MIT open source).

Installation

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

File structure

day-155-logistic-regression/
├── README.md
├── metadata.yml
├── requirements/
│   └── requirements.txt
├── starter/
│   ├── logistic_lib.py
│   └── test_logistic_lib.py
├── examples/
│   ├── logistic_lib.py
│   └── test_logistic_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/logistic_lib.py

What the commands do

  • sigmoid(z) maps any real-valued score to (0, 1).
  • predict_proba(X, w, b) evaluates linear combinations through the sigmoid.
  • binary_cross_entropy(y, p) computes the negative log likelihood.
  • compute_gradients(X, y, p) calculates exact gradient steps.

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. Add L2 regularization (Ridge penalty) to gradient descent.
  2. Implement Newton-Raphson optimization (Iteratively Reweighted Least Squares / IRLS).
  3. Extend to multiclass classification via Softmax / Multinomial cross-entropy.
  • Previous lab: ../day-154-a-complete-regression-project/
  • Next lab: ../day-156-decision-boundaries/

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 sigmoid midpoint `sigma(0) = 0.5`** and symmetry `sigma(-z) = 1 - sigma(z)` are analytical mathematical identities.
- **The binary cross-entropy at `p = 0.5` equals `ln(2) = 0.693147...`** regardless of machine or operating system.
- **The gradient vanishing condition at `p = y`** is an exact analytical result of `grad = (1/N) * X^T (p - y)`.
- **The breast cancer dataset sample counts** (569 samples, 30 features, 357 benign, 212 malignant, 62.74% benign rate) are exact from `sklearn.datasets.load_breast_cancer`.

## Exact under these pins, and only these

- **Batch gradient descent loss after 1000 epochs (lr=0.2)** achieves `< 0.10` log loss and `>= 0.95` accuracy on standardized breast cancer features.
- **L-BFGS unregularized LogisticRegression accuracy** achieves `0.9912` accuracy and `0.0271` log loss on standardized breast cancer data.

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-155-logistic-regression
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 4 items

examples/test_logistic_lib.py::test_sigmoid_properties PASSED            [ 25%]
examples/test_logistic_lib.py::test_binary_cross_entropy_extremes PASSED [ 50%]
examples/test_logistic_lib.py::test_gradient_descent_convergence PASSED  [ 75%]
examples/test_logistic_lib.py::test_breast_cancer_benchmark PASSED       [100%]

============================== 4 passed in 0.84s ===============================

measured-values.txt

Breast Cancer Dataset Measurements (n=569, d=30):
Baseline Majority Class Rate: 0.6274 (Benign=357, Malignant=212)
Scikit-Learn LogisticRegression (C=1e9):
  Training Accuracy: 1.0000
  Log Loss: 0.0003
Scratch Logistic Regression (epochs=1000, lr=0.2):
  Sigmoid at midpoint z=0: 0.5000
  Log loss at p=0.5 for balanced target: 0.6931

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-155-logistic-regression
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 3 items

starter/test_logistic_lib.py::test_sigmoid_stub PASSED                   [ 33%]
starter/test_logistic_lib.py::test_predict_proba_stub PASSED             [ 66%]
starter/test_logistic_lib.py::test_binary_cross_entropy_stub PASSED      [100%]

============================== 3 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: All mathematical identities verified exactly

=== 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/logistic_lib.py (2689 bytes)
"""
Logistic Regression reference library implementation.
"""
import numpy as np


def sigmoid(z: np.ndarray) -> np.ndarray:
    """
    Compute the logistic sigmoid function in a numerically stable manner:
    sigma(z) = 1 / (1 + exp(-z))
    """
    z = np.asarray(z, dtype=float)
    z_clipped = np.clip(z, -500.0, 500.0)
    return 1.0 / (1.0 + np.exp(-z_clipped))


def predict_proba(X: np.ndarray, w: np.ndarray, b: float) -> np.ndarray:
    """
    Compute predicted probabilities P(y=1|X) given weights w and intercept b.
    """
    X = np.asarray(X, dtype=float)
    w = np.asarray(w, dtype=float)
    z = np.dot(X, w) + b
    return sigmoid(z)


def binary_cross_entropy(y_true: np.ndarray, y_prob: np.ndarray, eps: float = 1e-15) -> float:
    """
    Compute binary cross-entropy (log loss):
    L = - (1/N) * sum(y * log(p) + (1 - y) * log(1 - p))
    """
    y_t = np.asarray(y_true, dtype=float)
    y_p = np.asarray(y_prob, dtype=float)
    y_p_clipped = np.clip(y_p, eps, 1.0 - eps)
    loss = -np.mean(y_t * np.log(y_p_clipped) + (1.0 - y_t) * np.log(1.0 - y_p_clipped))
    return float(loss)


def compute_gradients(X: np.ndarray, y_true: np.ndarray, y_prob: np.ndarray) -> tuple[np.ndarray, float]:
    """
    Compute exact gradients of binary cross-entropy:
    grad_w = (1/N) * X^T (y_prob - y_true)
    grad_b = (1/N) * sum(y_prob - y_true)
    """
    X = np.asarray(X, dtype=float)
    y_t = np.asarray(y_true, dtype=float)
    y_p = np.asarray(y_prob, dtype=float)
    N = len(y_t)
    diff = y_p - y_t
    grad_w = (1.0 / N) * np.dot(X.T, diff)
    grad_b = float((1.0 / N) * np.sum(diff))
    return grad_w, grad_b


def fit_logistic_regression(
    X: np.ndarray,
    y: np.ndarray,
    lr: float = 0.1,
    epochs: int = 1000,
    tol: float = 1e-7
) -> tuple[np.ndarray, float, list[float]]:
    """
    Fit logistic regression model using batch gradient descent.
    """
    X = np.asarray(X, dtype=float)
    y = np.asarray(y, dtype=float)
    N, D = X.shape
    w = np.zeros(D, dtype=float)
    b = 0.0
    history = []

    for epoch in range(epochs):
        probs = predict_proba(X, w, b)
        loss = binary_cross_entropy(y, probs)
        history.append(loss)

        grad_w, grad_b = compute_gradients(X, y, probs)
        w -= lr * grad_w
        b -= lr * grad_b

        if np.linalg.norm(grad_w) < tol and abs(grad_b) < tol:
            break

    return w, b, history


def predict_classes(X: np.ndarray, w: np.ndarray, b: float, threshold: float = 0.5) -> np.ndarray:
    """
    Predict binary class labels (0 or 1) based on decision threshold tau.
    """
    probs = predict_proba(X, w, b)
    return (probs >= threshold).astype(int)
examples/test_logistic_lib.py (2000 bytes)
"""
Tests for reference logistic regression implementation.
"""
import pytest
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
import logistic_lib as logreg


def test_sigmoid_properties():
    assert abs(logreg.sigmoid(np.array([0.0]))[0] - 0.5) < 1e-9
    assert logreg.sigmoid(np.array([100.0]))[0] > 0.9999
    assert logreg.sigmoid(np.array([-100.0]))[0] < 0.0001
    
    z = np.array([-3.5, -1.0, 0.0, 1.2, 4.0])
    np.testing.assert_allclose(logreg.sigmoid(-z), 1.0 - logreg.sigmoid(z), atol=1e-9)


def test_binary_cross_entropy_extremes():
    y_true = np.array([1.0, 0.0])
    y_perfect = np.array([0.99999, 0.00001])
    assert logreg.binary_cross_entropy(y_true, y_perfect) < 1e-4

    y_mid = np.array([0.5, 0.5])
    assert abs(logreg.binary_cross_entropy(y_true, y_mid) - np.log(2.0)) < 1e-4


def test_gradient_descent_convergence():
    rng = np.random.default_rng(42)
    X = rng.normal(size=(100, 2))
    y = ((X[:, 0] * 2.0 - X[:, 1] * 1.5 + 0.5) > 0).astype(float)

    w, b, history = logreg.fit_logistic_regression(X, y, lr=0.5, epochs=300)
    assert history[-1] < history[0]
    assert history[-1] < 0.25

    preds = logreg.predict_classes(X, w, b)
    acc = np.mean(preds == y)
    assert acc >= 0.90


def test_breast_cancer_benchmark():
    data = load_breast_cancer()
    X = data.data
    y = data.target
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)

    w, b, history = logreg.fit_logistic_regression(X_scaled, y, lr=0.2, epochs=1000)
    scratch_preds = logreg.predict_classes(X_scaled, w, b)
    scratch_acc = np.mean(scratch_preds == y)

    sk_model = LogisticRegression(C=1e9, solver="lbfgs", max_iter=1000)
    sk_model.fit(X_scaled, y)
    sk_preds = sk_model.predict(X_scaled)
    sk_acc = np.mean(sk_preds == y)

    assert scratch_acc >= 0.95
    assert sk_acc >= 0.95
    assert abs(scratch_acc - sk_acc) < 0.03
metadata.yml (796 bytes)
lesson_id: D155
day: 155
kind: classification-fundamentals
languages:
  - python
setup_commands:
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
run_commands:
  - .venv/bin/python examples/logistic_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 -> 3 passed.
  Numerical verification reproduced exact sigmoid midpoint, symmetry, and log-loss at p=0.5.
requirements/requirements.txt (61 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
scipy==1.15.2
starter/logistic_lib.py (1408 bytes)
"""
Logistic Regression library starter implementation.
"""
import numpy as np


def sigmoid(z: np.ndarray) -> np.ndarray:
    """Compute the logistic sigmoid function."""
    raise NotImplementedError("Implement sigmoid")


def predict_proba(X: np.ndarray, w: np.ndarray, b: float) -> np.ndarray:
    """Compute predicted probabilities for class 1."""
    raise NotImplementedError("Implement predict_proba")


def binary_cross_entropy(y_true: np.ndarray, y_prob: np.ndarray, eps: float = 1e-15) -> float:
    """Compute binary cross-entropy (log loss)."""
    raise NotImplementedError("Implement binary_cross_entropy")


def compute_gradients(X: np.ndarray, y_true: np.ndarray, y_prob: np.ndarray) -> tuple[np.ndarray, float]:
    """Compute gradients of loss with respect to w and b."""
    raise NotImplementedError("Implement compute_gradients")


def fit_logistic_regression(
    X: np.ndarray,
    y: np.ndarray,
    lr: float = 0.1,
    epochs: int = 1000,
    tol: float = 1e-6
) -> tuple[np.ndarray, float, list[float]]:
    """Fit logistic regression model using batch gradient descent."""
    raise NotImplementedError("Implement fit_logistic_regression")


def predict_classes(X: np.ndarray, w: np.ndarray, b: float, threshold: float = 0.5) -> np.ndarray:
    """Predict binary class labels (0 or 1) based on probability threshold."""
    raise NotImplementedError("Implement predict_classes")
starter/test_logistic_lib.py (541 bytes)
"""
Tests for starter logistic regression implementation.
"""
import pytest
import numpy as np
import logistic_lib as logreg


def test_sigmoid_stub():
    with pytest.raises(NotImplementedError):
        logreg.sigmoid(np.array([0.0]))


def test_predict_proba_stub():
    with pytest.raises(NotImplementedError):
        logreg.predict_proba(np.zeros((2, 2)), np.zeros(2), 0.0)


def test_binary_cross_entropy_stub():
    with pytest.raises(NotImplementedError):
        logreg.binary_cross_entropy(np.array([1, 0]), np.array([0.8, 0.2]))
tests/run_tests.sh (2621 bytes)
#!/usr/bin/env bash
# Day 155 lab harness: "Logistic Regression"
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 logistic_lib as logreg

s0 = logreg.sigmoid(np.array([0.0]))[0]
assert abs(s0 - 0.5) < 1e-9, f"s(0)={s0}"

z = np.array([-2.5, 0.0, 2.5])
s_pos = logreg.sigmoid(z)
s_neg = logreg.sigmoid(-z)
assert np.allclose(s_neg, 1.0 - s_pos, atol=1e-9), "sigmoid symmetry violated"

y = np.array([1.0, 0.0])
p = np.array([0.5, 0.5])
loss_mid = logreg.binary_cross_entropy(y, p)
assert abs(loss_mid - np.log(2.0)) < 1e-9, f"loss_mid={loss_mid}"

X = np.eye(2)
y_match = np.array([1.0, 0.0])
p_match = np.array([1.0, 0.0])
gw, gb = logreg.compute_gradients(X, y_match, p_match)
assert np.allclose(gw, 0.0) and abs(gb) < 1e-9, "gradients not zero on exact match"

print("MATH_OK")
PYEOF
)

if [ "$MATH_CHECK" = "MATH_OK" ]; then
  ok "All mathematical identities verified exactly"
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 4 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 155

Common Issues

1. Overflow in Sigmoid Computation (exp overflow)

  • Symptom: RuntimeWarning: overflow encountered in exp when calculating 1.0 / (1.0 + np.exp(-z)).
  • Cause: Large negative values of z cause np.exp(-z) to exceed floating point limits (~709.78 in float64).
  • Fix: Clip z into the safe range np.clip(z, -500.0, 500.0) or compute numerically stable sigmoid piecewise: np.where(z >= 0, 1.0 / (1.0 + np.exp(-z)), np.exp(z) / (1.0 + np.exp(z))).

2. Log of Zero in Binary Cross-Entropy

  • Symptom: RuntimeWarning: divide by zero encountered in log returning NaN loss.
  • Cause: Predicted probability p reaches exactly 0.0 or 1.0.
  • Fix: Clip probabilities np.clip(p, 1e-15, 1.0 - 1e-15).

3. Exploding Gradients without Feature Scaling

  • Symptom: Loss increases to infinity or oscillates wildly.
  • Cause: Features with unscaled large magnitudes produce massive gradient steps.
  • Fix: Standardize features using StandardScaler (zero mean, unit variance) before gradient descent.

Security notes

Security and Privacy Notes for Day 155

  • Local Execution: All code runs entirely in offline memory with zero outbound network calls.
  • Synthetic & Standard Datasets: The lab uses sklearn.datasets.load_breast_cancer (a public de-identified dataset) and synthetic vectors.
  • Filesystem Safety: The lab creates no persistent files outside its own directory.