Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

optimization.py

48_optimization_and_matrix_calculus/optimization.py  ·  154 lines  ·  view on GitHub

"""
Optimization and matrix calculus boilerplate.
Interview-friendly code with direct formulas.
"""
from typing import Callable, Tuple

import numpy as np


def sigmoid(x: np.ndarray) -> np.ndarray:
    return 1.0 / (1.0 + np.exp(-x))


def stable_softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
    """Softmax with max subtraction for numerical stability."""
    shifted = x - np.max(x, axis=axis, keepdims=True)
    exp_x = np.exp(shifted)
    return exp_x / np.sum(exp_x, axis=axis, keepdims=True)


def binary_cross_entropy(y_true: np.ndarray, y_prob: np.ndarray, eps: float = 1e-12) -> float:
    """Average binary cross-entropy."""
    y_prob = np.clip(y_prob, eps, 1.0 - eps)
    loss = -(y_true * np.log(y_prob) + (1.0 - y_true) * np.log(1.0 - y_prob))
    return float(np.mean(loss))


def linear_regression_gradients(
    X: np.ndarray, y: np.ndarray, w: np.ndarray, b: float
) -> Tuple[np.ndarray, float]:
    """
    Gradients of average MSE loss for linear regression.
    """
    n = X.shape[0]
    preds = X @ w + b
    errors = preds - y
    grad_w = (X.T @ errors) * (2.0 / n)
    grad_b = float(2.0 * np.mean(errors))
    return grad_w, grad_b


def logistic_regression_gradients(
    X: np.ndarray, y: np.ndarray, w: np.ndarray, b: float
) -> Tuple[np.ndarray, float, np.ndarray]:
    """
    Gradients of average BCE loss for logistic regression.

    Key result:
        grad_w = X^T (p - y) / n
        grad_b = mean(p - y)
    """
    logits = X @ w + b
    probs = sigmoid(logits)
    errors = probs - y
    grad_w = (X.T @ errors) / X.shape[0]
    grad_b = float(np.mean(errors))
    return grad_w, grad_b, probs


def logistic_regression_step(
    X: np.ndarray, y: np.ndarray, w: np.ndarray, b: float, lr: float = 0.1
) -> Tuple[np.ndarray, float, float]:
    """
    One gradient descent step for logistic regression.

    Returns:
        new_w, new_b, loss_before_step
    """
    grad_w, grad_b, probs = logistic_regression_gradients(X, y, w, b)
    loss = binary_cross_entropy(y, probs)
    w = w - lr * grad_w
    b = b - lr * grad_b
    return w, b, loss


def numerical_gradient_1d(f: Callable[[float], float], x: float, eps: float = 1e-5) -> float:
    """Central-difference numerical gradient for a scalar function."""
    return float((f(x + eps) - f(x - eps)) / (2.0 * eps))


def quadratic_objective(w: np.ndarray, A: np.ndarray, b: np.ndarray) -> float:
    """
    f(w) = 0.5 * w^T A w - b^T w
    """
    return float(0.5 * w.T @ A @ w - b.T @ w)


def quadratic_gradient(w: np.ndarray, A: np.ndarray, b: np.ndarray) -> np.ndarray:
    """Gradient of 0.5 * w^T A w - b^T w."""
    return A @ w - b


def gradient_descent_quadratic(
    A: np.ndarray, b: np.ndarray, lr: float = 0.1, steps: int = 50
) -> np.ndarray:
    """Simple gradient descent on a quadratic objective."""
    w = np.zeros(A.shape[0])
    for _ in range(steps):
        w -= lr * quadratic_gradient(w, A, b)
    return w


def condition_number(A: np.ndarray) -> float:
    """
    Condition number based on singular values.
    Larger means worse conditioning.
    """
    singular_values = np.linalg.svd(A, compute_uv=False)
    return float(singular_values[0] / singular_values[-1])


if __name__ == "__main__":
    print("Optimization and Matrix Calculus Boilerplate")
    print("=" * 60)

    # Logistic regression demo
    X = np.array(
        [
            [1.0, 2.0],
            [1.5, 1.8],
            [3.0, 3.5],
            [3.2, 4.0],
        ]
    )
    y = np.array([0.0, 0.0, 1.0, 1.0])
    w = np.zeros(X.shape[1])
    b = 0.0

    print("Logistic Regression Steps")
    for step in range(5):
        w, b, loss = logistic_regression_step(X, y, w, b, lr=0.5)
        print(f"step={step:02d} loss={loss:.4f} w={w.round(4)} b={b:.4f}")

    # Numerical gradient check
    print("\nNumerical Gradient Check")
    f = lambda x: x**2 + 3.0 * x
    x0 = 2.0
    grad_numeric = numerical_gradient_1d(f, x0)
    grad_exact = 2.0 * x0 + 3.0
    print(f"numeric gradient: {grad_numeric:.6f}")
    print(f"exact gradient:   {grad_exact:.6f}")

    # Quadratic optimization
    print("\nQuadratic Optimization")
    A = np.array([[4.0, 0.0], [0.0, 1.0]])
    b_vec = np.array([8.0, 2.0])
    w_star = gradient_descent_quadratic(A, b_vec, lr=0.2, steps=50)
    print(f"Approximate optimum: {w_star.round(4)}")
    print(f"Condition number of A: {condition_number(A):.4f}")

    # Stable softmax
    print("\nStable Softmax")
    logits = np.array([1000.0, 1001.0, 999.0])
    print(stable_softmax(logits).round(6))