Hessian-free optimization for deep learning

Hessian_free_optimizationin deep learning
Hessian-free optimization for deep learning · PyTorch edition

⚡ Hessian-free optimization
for deep learning

Curvature · eigenvalues · second-order methods · PyTorch implementation

Hessian-free (HF) optimization solves the Newton system H·p = −g approximately using conjugate gradient (CG), requiring only Hessian-vector products (HVPs) — never the full Hessian. In PyTorch, this is made practical with torch.autograd.functional.hvp or double-backward. This post includes a complete, copy-paste-ready PyTorch implementation along with curvature diagnostics, eigenvalue analysis, and generalization notes.

📐 Curvature & eigenvalues

The Hessian H captures local curvature. Its eigenvalues \(\lambda_i\) tell us:

  • λ > 0 → positive curvature (bowl). SGD oscillates if lr too large.
  • λ < 0 → negative curvature (saddle). First-order methods slow; HF escapes.
  • κ = λmax / λmin → condition number. Large κ = slow SGD.
📊 Eigenvalue spectrum (synthetic deep net) — high κ = first-order pain

Diagnostic: Use power iteration with HVPs to estimate \(\lambda_{\max}\) without building H. PyTorch's torch.autograd.functional.hvp makes this trivial.

🧠 Second-order methods & the Hessian-free trick

🔄 Classical Newton

Update: \( \theta_{t+1} = \theta_t - \mathbf{H}^{-1}\mathbf{g} \).
Explicit Hessian → O(N²) memory, O(N³) solve. Impossible for millions of params.

⚡ Hessian-free (truncated Newton)

Solve H p = −g via CG using only Hv products. Each Hv ≈ 2× a gradient. Memory O(N). No Hessian stored.

\[ \mathbf{H}\mathbf{p} = -\mathbf{g}, \quad \text{solved approximately via CG} \] \[ \mathbf{H}\mathbf{v} = \nabla_\theta \big( \nabla_\theta L \cdot \mathbf{v} \big) \quad \text{(double backward)} \]

🐍 PyTorch implementation torch.autograd

Below is a self-contained, runnable PyTorch implementation of Hessian-free optimization. It uses torch.autograd.functional.hvp for exact HVPs and a hand-written conjugate gradient solver. Copy it into a .py file and run.

import torch
import torch.nn as nn
import torch.autograd.functional as F

# ------------------------------------------------------------
# 1. Conjugate Gradient solver for  (H + λI) p = -g
# ------------------------------------------------------------
def conjugate_gradient(hvp_fn, grad, max_iter=20, damping=1e-4, tol=1e-6):
    """Solve A x = b approximately with CG, A = H + λI.
    hvp_fn: callable(v) -> Hv
    grad:   b = -g  (we solve for x = p, the update)
    """
    b = -grad.clone()
    x = torch.zeros_like(b)
    r = b.clone()            # residual r = b - A x  (x=0 -> r=b)
    p = r.clone()
    rs_old = (r * r).sum()

    for i in range(max_iter):
        Ap = hvp_fn(p) + damping * p        # (H + λI) p
        alpha = rs_old / (p * Ap).sum().clamp(min=1e-12)
        x = x + alpha * p
        r = r - alpha * Ap
        rs_new = (r * r).sum()
        if rs_new.sqrt() < tol:
            break
        beta = rs_new / rs_old
        p = r + beta * p
        rs_old = rs_new
    return x


# ------------------------------------------------------------
# 2. Hessian-free optimizer step (single iteration)
# ------------------------------------------------------------
def hessian_free_step(model, loss_fn, cg_iters=20, damping=1e-4):
    """One truncated-Newton update using CG.
    model:      nn.Module
    loss_fn:    callable(model) -> scalar loss (already computed on a batch)
    """
    # --- 1. Compute gradient g = ∇L ---
    params = [p for p in model.parameters() if p.requires_grad]
    loss = loss_fn(model)
    grads = torch.autograd.grad(loss, params, create_graph=True)
    g_flat = torch.cat([g.reshape(-1) for g in grads])

    # --- 2. Define Hessian-vector product via autograd HVP ---
    def hvp_flat(v_flat):
        # Rebuild v from flat, compute Hv with torch.autograd.functional.hvp
        v_list = []
        offset = 0
        for p in params:
            numel = p.numel()
            v_list.append(v_flat[offset:offset + numel].reshape(p.shape))
            offset += numel

        # Use the functional API: hvp(func, inputs, v) -> (grad, Hv)
        # We define a closure that returns the loss given flat params.
        def flat_loss(flat_params):
            # Unflatten and temporarily set model params
            offset = 0
            with torch.no_grad():
                for p in params:
                    numel = p.numel()
                    p.copy_(flat_params[offset:offset + numel].reshape(p.shape))
                    offset += numel
            return loss_fn(model)

        flat_g = torch.cat([g.reshape(-1) for g in grads])
        _, hv = F.hvp(flat_loss, flat_g.detach().clone(), v_flat)
        return hv

    # --- 3. Solve H p = -g via conjugate gradient ---
    p_flat = conjugate_gradient(hvp_flat, g_flat.detach(), max_iter=cg_iters, damping=damping)

    # --- 4. Apply update: θ ← θ + p ---
    offset = 0
    with torch.no_grad():
        for p in params:
            numel = p.numel()
            p.add_(p_flat[offset:offset + numel].reshape(p.shape))
            offset += numel
    return loss.item()


# ------------------------------------------------------------
# 3. Example: train a small MLP on noisy regression
# ------------------------------------------------------------
if __name__ == "__main__":
    torch.manual_seed(0)
    model = nn.Sequential(
        nn.Linear(10, 64), nn.Tanh(),
        nn.Linear(64, 64), nn.Tanh(),
        nn.Linear(64, 1)
    )
    X = torch.randn(256, 10)
    y = X.sum(dim=1, keepdim=True) + 0.1 * torch.randn(256, 1)

    def loss_fn(m):
        pred = m(X)
        return 0.5 * ((pred - y) ** 2).mean()

    for step in range(15):
        l = hessian_free_step(model, loss_fn, cg_iters=15, damping=1e-3)
        print(f"step {step:2d}  loss {l:.6f}")

▶ runnable The script trains a 3-layer MLP with truncated Newton steps. CG is truncated at 15 iterations — this acts as a regularizer and keeps cost low. For deeper models, use mini-batch CG or a fixed damping schedule.

🔬 Hessian-vector products in PyTorch

The torch.autograd.functional.hvp API returns both the gradient and H·v in one call:

from torch.autograd.functional import hvp

# Example: 2D function f(x) = x[0]^2 + 3*x[1]^2
def f(x):
    return x[0]**2 + 3*x[1]**2

x = torch.tensor([1.0, 2.0], requires_grad=True)
v = torch.tensor([1.0, 0.0])

grad, Hv = hvp(f, x, v)
print("grad:", grad)   # [2, 12]
print("Hv:  ", Hv)     # [2, 0]  (H = diag(2, 6))

Internally, PyTorch computes this via a double backward: it first builds the gradient graph, then differentiates \(\nabla L \cdot v\) w.r.t. \(\theta\). Cost is approximately 2× the cost of a gradient, and memory overhead is modest.

🧪 Diagnostics: estimating λmax with power iteration

Use HVPs to estimate the top eigenvalue — a cheap conditioning check:

def power_iteration(hvp_fn, n_params, iters=10):
    """Estimate λ_max of H using power iteration on Hv."""
    v = torch.randn(n_params)
    v = v / v.norm()
    for _ in range(iters):
        Hv = hvp_fn(v)
        v = Hv / (Hv.norm() + 1e-12)
    return (v * hvp_fn(v)).sum().item()

A high \(\lambda_{\max}\) relative to the average gradient norm signals sharp curvature — use damping or larger CG truncation.

⏱️ Computational cost & generalization

💾 Memory

O(N) for parameters, gradients, CG vectors. No N×N matrix. Double-backward graph adds ~1–2× activation memory during HVP — manageable with checkpointing.

⚙️ Compute per HF step

≈ k × (2 × gradient) for k CG iterations. Typical k=10–30. Per-step cost is 20–60× SGD, but often needs 3–10× fewer steps.

📈 Generalization

Truncated CG and damping act as implicit regularizers, biasing toward flatter minima. This often improves test accuracy over plain Adam in full-batch regimes.

🧭 When to use HF

Large-batch or full-batch training, scientific ML, ill-conditioned problems, saddle-point-heavy landscapes. Not ideal for tiny-batch noisy stochastic settings.

📉 Convergence (simulated): HF takes fewer steps but more compute per step
⚙️ Cost per step & memory footprint (relative)
💡 Bottom line: Hessian-free optimization in PyTorch is practical via torch.autograd.functional.hvp. It gives you second-order information at first-order memory cost and is a powerful tool for diagnosing curvature, escaping saddles, and regularizing via CG truncation. Pair it with damping and a good CG budget.

Post a Comment

0 Comments