Optimization & Calculus for ML

Gradient descent is just walking downhill in high dimensions. Your intuition fails at 100D, so here’s code instead.

Derivatives: The Engine of Learning

A derivative measures sensitivity. How much does the output change when I nudge the input? This is literally what “training” means: finding inputs (weights) where the output (loss) is minimal.

import numpy as np

# The derivative of f(x) at point x:
# f'(x) ≈ (f(x+h) - f(x-h)) / (2h)  [symmetric difference, more accurate]

def numerical_derivative(f, x, h=1e-7):
    """Approximate the derivative at point x."""
    return (f(x + h) - f(x - h)) / (2 * h)

# Example: derivative of f(x) = x^2 at x = 3
# Analytical: f'(x) = 2x → f'(3) = 6
f = lambda x: x**2
print(f"f(x) = x^2")
print(f"f'(3) = {numerical_derivative(f, 3):.6f}")
print(f"Analytical answer: 6.0")

For ML, the derivative tells us which direction to adjust weights. Positive derivative = increase weights, increase loss. Negative derivative = decrease weights, decrease loss. Simple logic, powerful application.

The Chain Rule: How Backprop Works

The chain rule is the entire reason deep learning works. When y = f(g(x)), the derivative is:

dy/dx = dy/du × du/dx  (where u = g(x))

This lets us decompose a complex function’s derivative into smaller pieces. Each layer only needs to know its own derivative. Multiply them together, chain them, and you get the full gradient.

def chain_rule(df_du, du_dx):
    """The chain rule: df/dx = df/du × du/dx."""
    return df_du * du_dx

# Example: y = sin(x^2)
# Inner function: u = x^2, du/dx = 2x
# Outer function: y = sin(u), dy/du = cos(u)
# dy/dx = cos(x^2) × 2x

def dy_dx(x):
    u = x**2
    du_dx = 2 * x
    dy_du = np.cos(u)
    return dy_du * du_dx

print(f"dy/dx at x=2: {dy_dx(2):.6f}")
# cos(4) × 4 = -0.6536 × 4 = -2.6146

# Numerical verification
f = lambda x: np.sin(x**2)
print(f"Numerical: {numerical_derivative(f, 2):.6f}")

# In a neural network:
# loss → (L2) → MSE → (diff) → residual → (square) → MSE value
# To compute d(loss)/d(w), we chain through ALL these operations.

Gradient Descent

Gradient descent updates weights to minimize loss:

w_new = w_old — lr × ∂L/∂w

The learning rate (lr) determines step size. Too large: diverge. Too small: takes forever. Just right: converges.

def gradient_descent(loss_func, gradient_func, x0, lr=0.1, epsilon=1e-7, max_iters=10000):
    """
    Basic gradient descent optimizer.
    
    loss_func: f(w) → scalar (the loss)
    gradient_func: f'(w) → d(f)/dw
    x0: starting point
    lr: learning rate
    epsilon: convergence threshold
    max_iters: maximum iterations
    """
    x = x0
    for i in range(max_iters):
        grad = gradient_func(x)
        x_new = x - lr * grad
        
        if abs(x_new - x) < epsilon:
            break
        
        x = x_new
    
    return x, loss_func(x), i + 1

# Example: minimize f(x) = x^2
# Minimum at x = 0, f(0) = 0
f = lambda x: x**2
grad_f = lambda x: 2 * x

opt_x, opt_f, n_iters = gradient_descent(f, grad_f, x0=5, lr=0.1)
print(f"Minimum: x_opt={opt_x:.6f}, f(x)={opt_f:.6f}, iterations={n_iters}")

Why Learning Rate Matters More Than Architecture

def learning_rate_experiment():
    """Show how learning rate affects convergence."""
    f = lambda x: x**4 - 2*x**2 + 1  # "W" shaped function
    grad_f = lambda x: 4*x**3 - 4*x
    
    results = {}
    
    for lr in [0.01, 0.1, 0.5, 1.0, 1.5]:
        x = 2.0  # Start at the right
        loss_history = []
        
        for i in range(100):
            grad = grad_f(x)
            loss_history.append(f(x))
            x -= lr * grad
            
            if abs(grad) < 1e-7:
                break
        
        results[lr] = {
            'loss': loss_history[-1] if loss_history else float('inf'),
            'iterations': len(loss_history),
            'final_x': x,
            'converged': loss_history and abs(loss_history[-1]) < 1e-3
        }
    
    for lr, res in results.items():
        status = "✓ converged" if res['converged'] else "✗ diverged/final"
        print(f"lr={lr:5.2f} → x={res['final_x']:8.4f}, loss={res['loss']:10.4f}, iters={res['iterations']:3d} {status}")

learning_rate_experiment()

# Key takeaway: lr matters more than the model architecture
# A good lr on a simple model beats a bad lr on a complex model
# Every "breakthrough architecture" that didn't fix lr was lucky

Momentum

Momentum smooths out the optimization path by accumulating gradients over time:

v_t = β × v_{t-1} — lr × ∇L(w)
w_t = w_{t-1} + v_t

β (typically 0.9) acts as friction. Small gradients build up → large effective steps. Large gradients are dampened → less oscillation.

def gradient_descent_momentum(gradient_func, x0, lr=0.1, beta=0.9, epsilon=1e-7):
    """Gradient descent with momentum."""
    x = x0
    v = 0
    for i in range(10000):
        grad = gradient_func(x)
        v = beta * v - lr * grad
        x = x + v
        
        if abs(v) < epsilon:
            break
    
    return x, v, i + 1

f = lambda x: x**2
grad_f = lambda x: 2 * x

# Without momentum, starting far from the minimum:
x_no_momentum, _, n_iters_no = gradient_descent(f, grad_f, x0=5, lr=0.1)

# With momentum:
x_with_momentum, _, n_iters_with = gradient_descent_momentum(grad_f, x0=5, lr=0.1)

print(f"Without momentum: x={x_no_momentum:.6f}, iterations={n_iters_no}")
print(f"With momentum:    x={x_with_momentum:.6f}, iterations={n_iters_with}")
# Momentum converges faster because it "accelerates" in consistent directions
# and dampens oscillations when gradients flip sign.

Adam: The Default Optimizer (And Why)

Adam combines momentum (velocity) with adaptive learning rates per parameter. It’s the default for a reason: it works, most of the time.

m_t = β1 × m_{t-1} + (1 — β1) × ∇L(w)        # Momentum (first moment)
v_t = β2 × v_{t-1} + (1 — β2) × ∇L(w)^2        # Variance (second moment)
m_hat = m_t / (1 — β1^t)                         # Bias correction
v_hat = v_t / (1 — β2^t)                         # Bias correction
w_t = w_{t-1} — lr × m_hat / (√v_hat + ε)       # Update
def adam_step(param, grad, m, v, t, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8):
    """
    Adam optimizer step.
    
    param: current parameter value
    grad: gradient
    m: running average of gradients (momentum)
    v: running average of squared gradients (variance)
    t: current time step (for bias correction)
    """
    m = beta1 * m + (1 - beta1) * grad
    v = beta2 * v + (1 - beta2) * grad**2
    
    m_hat = m / (1 - beta1**t)
    v_hat = v / (1 - beta2**t)
    
    new_param = param - lr * m_hat / (np.sqrt(v_hat) + eps)
    
    return new_param, m, v

# Common hyperparameters:
# lr = 1e-3 (most things work at this)
# beta1 = 0.9, beta2 = 0.999, eps = 1e-8  (defaults, don't change)
# The only parameter that really matters is lr

def test_adam():
    """Test Adam on a simple optimization problem."""
    f = lambda x: x**4 - 2*x**2 + 1  # "W" shaped - good test case
    grad_f = lambda x: 4*x**3 - 4*x
    
    x = 3.0  # Start far from optimum
    m = 0
    v = 0
    
    history = []
    
    for t in range(1, 201):
        grad = grad_f(x)
        x, m, v = adam_step(x, grad, m, v, t, lr=0.1)
        history.append((t, f(x)))
        
        if abs(grad) < 1e-7:
            break
    
    print(f"Starting x: 3.0")
    print(f"Final x:    {x:.6f}")
    print(f"Final loss: {f(x):.6f}")
    print(f"Iterations: {t}")
    print(f"Final grad: {grad:.6e}")
    
    # Check: Adam finds the local minimum near x=1 (not the global minimum at x=3, which is higher)
    print(f"\nTrue local minima: x=-1 and x=1")
    print(f"Found minimum at: x={x:.6f}")

test_adam()

Learning Rate Schedules

A fixed learning rate is suboptimal. Starting aggressive, then slowing down improves convergence. Here are the most common schedules:

def learning_rate_schedule(step, initial_lr=0.001, schedule='cosine', **kwargs):
    """
    Learning rate scheduler.
    
    schedule options:
    - 'constant': always return initial_lr (default Adam behavior)
    - 'step': multiply by gamma every gamma_step steps
    - 'cosine': cosine annealing from initial_lr to 0
    - 'exponential': exponential decay per step
    """
    if schedule == 'constant' or schedule not in kwargs:
        return initial_lr
    
    if schedule == 'step':
        gamma = kwargs.get('gamma', 0.1)
        gamma_step = kwargs.get('gamma_step', 30)
        return initial_lr * (gamma ** (step // gamma_step))
    
    if schedule == 'cosine':
        steps = kwargs.get('steps', 100)
        min_lr = kwargs.get('min_lr', 1e-5)
        return min_lr + 0.5 * (initial_lr - min_lr) * (1 + np.cos(np.pi * step / steps))
    
    if schedule == 'exponential':
        decay = kwargs.get('decay', 0.95)
        return initial_lr * (decay ** step)
    
    return initial_lr

# Test all schedules
import matplotlib

steps = 100
schedules = {
    'constant': {'schedule': 'constant'},
    'step': {'schedule': 'step', 'gamma': 0.5, 'gamma_step': 20},
    'cosine': {'schedule': 'cosine', 'steps': steps},
    'exponential': {'schedule': 'exponential', 'decay': 0.95}
}

for name in schedules:
    lrs = [learning_rate_schedule(s, 0.01, **schedules[name]) for s in range(steps)]
    print(f"{name:15s}: lr[0]={lrs[0]:.4f}, lr[50]={max(lrs[50], 1e-8):.6e}, lr[99]={max(lrs[99], 1e-8):.6e}")

# Cosine annealing is the most popular because:
# 1. Smooth decay is better than step decay (no sudden lr drops)
# 2. Reaching lr=0 at the end is good for convergence
# 3. It's the default for PyTorch's lr_scheduler.cosine_with_restarts()

Gradient Clipping

When gradients explode, training blows up. Gradient clipping caps the gradient norm to prevent this:

def clip_gradients(grads, max_norm=1.0):
    """
    Clip gradients by norm to prevent exploding gradients.
    
    If ||grads|| > max_norm, scale all gradients so ||grads|| = max_norm.
    This preserves direction but limits magnitude.
    """
    grad_norm = np.sqrt(sum(np.sum(g**2) for g in grads))
    
    if grad_norm > max_norm:
        scale = max_norm / grad_norm
        clipped = [g * scale for g in grads]
    else:
        clipped = grads
    
    return clipped, grad_norm

# Example: simulate exploding gradients
grads_exploding = [np.random.normal(0, 10, (10, 10)) for _ in range(3)]
clipped, original_norm = clip_gradients(grads_exploding, max_norm=1.0)

print(f"Original gradient norm: {original_norm:.4f}")
clipped_norm = np.sqrt(sum(np.sum(g**2) for g in clipped))
print(f"Clipped gradient norm: {clipped_norm:.4f}")
print(f"Reduced by: {original_norm/max(clipped_norm, 1e-8):.1f}x")

# Gradient clipping is essential for:
# - Transformer models (long sequences → exploding gradients)
# - GANs (generative gradients → exploding gradients)
# - Reinforcement learning (reward scaling → exploding gradients)
# But it does not fix vanishing gradients — that requires architecture changes.

Convex vs Non-Convex Optimization

Convex functions have one global minimum. Non-convex functions have many local minima. This distinction matters for understanding why ML training works:

def is_convex(f, x0=-5, x1=5, n_points=100):
    """
    Heuristic test: if function is convex,
    the function value at any point should be above the chord.
    """
    x = np.linspace(x0, x1, n_points)
    y = f(x)
    
    # For convex: y(λx1 + (1-λ)x0) ≤ λf(x1) + (1-λ)f(x0)
    # We check if all points lie below the line connecting endpoints
    chord = np.linspace(f(x0), f(x1), n_points)
    
    return np.all(y <= chord)

# f(x) = x^2 is convex
print(f"x^2 is convex: {is_convex(lambda x: x**2)}")

# f(x) = x^4 - 3x^2 + 2 is NOT convex (it's the "W" function)
f_w = lambda x: x**4 - 3*x**2 + 2
print(f"x^4-3x^2+2 is convex: {is_convex(f_w)}")

# f(x) = e^x IS convex
print(f"e^x is convex: {is_convex(lambda x: np.exp(x))}")

# In ML:
# - Linear regression: convex (one global minimum)
# - Logistic regression: convex
# - Neural networks: NOT convex (many local minima, saddle points)
# - Yet: SGD finds good minima anyway — this is an open research question
# - The consensus: in high dimensions, most local minima have similar loss
# - So we don't worry about getting stuck in local minima (we worry about saddle points instead)

Saddle Points: The Real Problem

In high-dimensional non-convex optimization, saddle points (where some directions go up, some go down) are more problematic than local minima. They have zero gradient everywhere (flat in all directions that matter locally).

def saddle_point_test(f, grad_f, hess_f, x):
    """
    Identify saddle points: gradient is zero but not all eigenvalues of Hessian are positive.
    """
    g = grad_f(x)
    H = hess_f(x)
    
    # Gradient should be near zero at critical point
    gradient_magnitude = np.sqrt(np.sum(g**2))
    
    # Eigenvalues of Hessian tell us the curvature in different directions
    eigenvalues = np.linalg.eigvals(H)
    
    saddle = gradient_magnitude < 1e-7 and np.any(eigenvalues < 0)
    
    return {
        'gradient_magnitude': gradient_magnitude,
        'eigenvalues': eigenvalues,
        'is_saddle': saddle,
        'is_min': gradient_magnitude < 1e-7 and np.all(eigenvalues > 1e-7)
    }

def saddle_function(x, y):
    """Saddle surface: f(x,y) = x^2 - y^2"""
    return x**2 - y**2

def grad_saddle(x, y):
    return np.array([2*x, -2*y])

def hess_saddle(x, y):
    return np.array([[2, 0], [0, -2]])

# Analyze critical point at origin (0, 0) — this is a saddle!
result = saddle_point_test(saddle_function, grad_saddle, hess_saddle, [0, 0])
print(f"Critical point at (0,0): saddle={result['is_saddle']}, minimum={result['is_min']}")
print(f"Eigenvalues: {result['eigenvalues']}")
# Positive eigenvalue → direction where function increases
# Negative eigenvalue → direction where function decreases
# This is the definition of a saddle point!

# In practice: momentum helps escape saddle points because:
# 1. Different directions have different gradients (even if small)
# 2. Momentum accumulates in the direction of largest negative curvature
# 3. Adam's second moment creates direction-dependent learning rates
# This is why Adam beats vanilla SGD on non-convex problems

Practical Applications in AI

Optimizer Comparison

def optimizer_comparison(loss_func, grad_func, n_trials=10):
    """Compare optimizers on the same function."""
    results = {}
    
    for method in ['sgd', 'momentum', 'adam']:
        losses = []
        for trial in range(n_trials):
            x_init = np.random.rand() * 10 - 5  # Random start
            x = x_init
            m, v = 0, 0
            converged = False
            
            for t in range(1000):
                g = grad_func(x)
                
                if method == 'sgd':
                    x = x - 0.1 * g
                
                elif method == 'momentum':
                    m = 0.9 * m - 0.1 * g
                    x = x + m
                
                elif method == 'adam':
                    m = 0.9 * m + 0.1 * g
                    v = 0.999 * v + 0.01 * g**2
                    m_hat = m / (1 - 0.9**t)
                    v_hat = v / (1 - 0.999**t)
                    x = x - m_hat / (np.sqrt(v_hat) + 1e-8)
            
            losses.append(abs(x))
        
        results[method] = {
            'mean_loss': np.mean(losses),
            'std_loss': np.std(losses),
            'convergence_rate': np.mean([np.sqrt(np.sum((grad_func(x_init))**2))/np.mean(losses) 
                                         for x_init in np.random.randn(n_trials) * 5 for _ in range(1)][:1])
        }
    
    for method in results:
        r = results[method]
        print(f"{method:10s}: mean_optima={r['mean_loss']:.4f}, std={r['std_loss']:.4f}")
    
    # Adam generally wins because:
    # 1. Adaptive lr per parameter → works with different scales
    # 2. Momentum → faster convergence
    # 3. Bias correction → effective lr is correct early on

Summary

Optimization in ML is about:

Build with gradients. Optimize with Adam. Schedule your learning rate. Clip when needed. The rest is engineering.