{ "cells": [ { "cell_type": "markdown", "id": "8d00ab3f", "metadata": {}, "source": "# Lesson 32: Optimization\n\nLessons 30-31 used plain gradient descent: take the gradient, take a fixed-size step against it, repeat. That's enough to prove the *idea* works, but it's rarely how real networks are trained. This lesson covers four practical upgrades — **momentum**, **adaptive step sizes (Adam)**, **weight initialization schemes**, and **learning rate schedules** — each fixing a specific, concrete failure mode of plain gradient descent." }, { "cell_type": "code", "execution_count": null, "id": "950789d5", "metadata": {}, "outputs": [], "source": "import numpy as np\nimport torch\nimport torch.nn as nn\nimport matplotlib.pyplot as plt" }, { "cell_type": "markdown", "id": "ca269d40", "metadata": {}, "source": [ "## A loss landscape where plain gradient descent struggles\n", "\n", "$f(x,y) = 0.05x^2 + 5y^2$ is a bowl that's steep in $y$ and shallow in $x$. A step size large enough to make progress along $x$ overshoots along $y$, causing the classic *zigzag*." ] }, { "cell_type": "code", "execution_count": null, "id": "995a0fa4", "metadata": {}, "outputs": [], "source": [ "def f(p):\n", " return 0.05 * p[0]**2 + 5 * p[1]**2\n", "\n", "def grad_f(p):\n", " return np.array([0.1 * p[0], 10 * p[1]])\n", "\n", "start = np.array([-4.0, 1.0])\n", "lr = 0.05\n", "\n", "p = start.copy()\n", "sgd_path = [p.copy()]\n", "for _ in range(30):\n", " p = p - lr * grad_f(p)\n", " sgd_path.append(p.copy())\n", "sgd_path = np.array(sgd_path)\n", "\n", "print(f'plain SGD, 30 steps: final loss = {f(sgd_path[-1]):.4f}')" ] }, { "cell_type": "markdown", "id": "2155b589", "metadata": {}, "source": [ "## Momentum: remember where you were heading\n", "\n", "Instead of stepping purely along the current gradient, accumulate a running **velocity** — a weighted average of past gradients — and step along that instead:\n", "\n", "$$v \\leftarrow \\beta v - \\eta\\,\\nabla f(p), \\qquad p \\leftarrow p + v$$\n", "\n", "Consistent gradient directions (like the shallow $x$ direction here) reinforce each other and accelerate; oscillating directions (the steep $y$ direction, flipping sign every step) partially cancel out and damp down." ] }, { "cell_type": "code", "execution_count": null, "id": "597bd82b", "metadata": {}, "outputs": [], "source": [ "momentum = 0.9\n", "p = start.copy()\n", "v = np.zeros(2)\n", "mom_path = [p.copy()]\n", "for _ in range(30):\n", " g = grad_f(p)\n", " v = momentum * v - lr * g\n", " p = p + v\n", " mom_path.append(p.copy())\n", "mom_path = np.array(mom_path)\n", "\n", "print(f'SGD + momentum, 30 steps: final loss = {f(mom_path[-1]):.4f}')" ] }, { "cell_type": "markdown", "id": "a4ce459d", "metadata": {}, "source": [ "### Sanity check against PyTorch" ] }, { "cell_type": "code", "execution_count": null, "id": "8c7b2b7c", "metadata": {}, "outputs": [], "source": [ "p_t = torch.tensor(start.copy(), requires_grad=True)\n", "opt = torch.optim.SGD([p_t], lr=lr, momentum=momentum)\n", "for _ in range(30):\n", " opt.zero_grad()\n", " loss = 0.05 * p_t[0]**2 + 5 * p_t[1]**2\n", " loss.backward()\n", " opt.step()\n", "\n", "print(f'our result: {mom_path[-1]}')\n", "print(f'torch result: {p_t.detach().numpy()}')\n", "print(f'max diff: {np.abs(p_t.detach().numpy() - mom_path[-1]).max():.2e}')" ] }, { "cell_type": "markdown", "id": "5d15e784", "metadata": {}, "source": "## Adam: per-parameter adaptive step sizes\n\n**Adam** (Kingma & Ba, 2014) tracks both a momentum-like running mean of the gradient ($m$) *and* a running mean of the squared gradient ($v$), then divides the step by $\\sqrt{v}$ — automatically shrinking the step size for parameters with consistently large gradients (like the steep $y$ direction here) and boosting it for parameters with small ones:\n\n$$m \\leftarrow \\beta_1 m + (1-\\beta_1)g, \\qquad v \\leftarrow \\beta_2 v + (1-\\beta_2)g^2, \\qquad p \\leftarrow p - \\eta\\frac{\\hat{m}}{\\sqrt{\\hat{v}}+\\epsilon}$$\n\n($\\hat{m}, \\hat{v}$ are bias-corrected versions of $m, v$, which matter mainly in the first few steps.)" }, { "cell_type": "code", "execution_count": null, "id": "9fe3a4bf", "metadata": {}, "outputs": [], "source": [ "beta1, beta2, adam_eps = 0.9, 0.999, 1e-8\n", "adam_lr = 0.3\n", "\n", "p = start.copy()\n", "m, v_sq = np.zeros(2), np.zeros(2)\n", "adam_path = [p.copy()]\n", "for t in range(1, 31):\n", " g = grad_f(p)\n", " m = beta1 * m + (1 - beta1) * g\n", " v_sq = beta2 * v_sq + (1 - beta2) * g**2\n", " m_hat = m / (1 - beta1**t)\n", " v_hat = v_sq / (1 - beta2**t)\n", " p = p - adam_lr * m_hat / (np.sqrt(v_hat) + adam_eps)\n", " adam_path.append(p.copy())\n", "adam_path = np.array(adam_path)\n", "\n", "p_t2 = torch.tensor(start.copy(), requires_grad=True)\n", "opt2 = torch.optim.Adam([p_t2], lr=adam_lr, betas=(beta1, beta2), eps=adam_eps)\n", "for _ in range(30):\n", " opt2.zero_grad()\n", " loss = 0.05 * p_t2[0]**2 + 5 * p_t2[1]**2\n", " loss.backward()\n", " opt2.step()\n", "\n", "print(f'Adam, 30 steps: final loss = {f(adam_path[-1]):.4f}')\n", "print(f'max diff vs torch.optim.Adam: {np.abs(p_t2.detach().numpy() - adam_path[-1]).max():.2e}')" ] }, { "cell_type": "markdown", "id": "311ce961", "metadata": {}, "source": [ "### All three paths, visualized" ] }, { "cell_type": "code", "execution_count": null, "id": "2d0fdc20", "metadata": {}, "outputs": [], "source": [ "xs = np.linspace(-4.5, 1, 200)\n", "ys = np.linspace(-1.5, 1.5, 200)\n", "XX, YY = np.meshgrid(xs, ys)\n", "ZZ = 0.05 * XX**2 + 5 * YY**2\n", "\n", "plt.contour(XX, YY, ZZ, levels=20, colors='lightgray', linewidths=0.7)\n", "plt.plot(*sgd_path.T, '-o', markersize=3, label=f'plain SGD (loss={f(sgd_path[-1]):.3f})')\n", "plt.plot(*mom_path.T, '-o', markersize=3, label=f'momentum (loss={f(mom_path[-1]):.3f})')\n", "plt.plot(*adam_path.T, '-o', markersize=3, label=f'Adam (loss={f(adam_path[-1]):.3f})')\n", "plt.scatter([0], [0], marker='*', s=150, color='black', zorder=5, label='minimum')\n", "plt.legend(fontsize=8)\n", "plt.title('30 steps of each optimizer on the same narrow bowl')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "46bc9846", "metadata": {}, "source": [ "Plain SGD is stuck oscillating across the narrow valley, barely progressing along the shallow direction. Momentum smooths out the oscillation and travels farther. Adam, which independently rescales each direction, damps the steep axis and races along the shallow one, reaching the lowest loss of the three in the same 30 steps." ] }, { "cell_type": "markdown", "id": "f5ac0d62", "metadata": {}, "source": [ "## Weight initialization: why zero doesn't work\n", "\n", "It's tempting to initialize all weights to zero — it seems like the most \"neutral\" starting point. It's actually catastrophic for any layer with more than one unit: every hidden unit computes the exact same function of the input, gets the exact same gradient, and gets updated by the exact same amount, forever. This **symmetry** never breaks on its own." ] }, { "cell_type": "code", "execution_count": null, "id": "640113c5", "metadata": {}, "outputs": [], "source": [ "rng = np.random.default_rng(0)\n", "theta_in = rng.uniform(0, 2 * np.pi, 60)\n", "inner = np.stack([0.5 * np.cos(theta_in), 0.5 * np.sin(theta_in)], axis=1) + rng.normal(0, 0.1, (60, 2))\n", "theta_out = rng.uniform(0, 2 * np.pi, 60)\n", "outer = np.stack([2.0 * np.cos(theta_out), 2.0 * np.sin(theta_out)], axis=1) + rng.normal(0, 0.15, (60, 2))\n", "X = np.vstack([inner, outer])\n", "y = np.concatenate([np.zeros(60), np.ones(60)])\n", "\n", "def sigmoid(z):\n", " return 1 / (1 + np.exp(-z))\n", "\n", "def relu(z):\n", " return np.maximum(0, z)\n", "\n", "def relu_deriv(z):\n", " return (z > 0).astype(np.float64)\n", "\n", "H = 4\n", "\n", "def train_mlp(W1, b1, W2, b2, n_epochs=3000, lr=0.1):\n", " for _ in range(n_epochs):\n", " z1 = X @ W1 + b1\n", " a1 = relu(z1)\n", " z2 = (a1 @ W2 + b2).ravel()\n", " p = sigmoid(z2)\n", " n = len(y)\n", " grad_z2 = ((p - y) / n).reshape(-1, 1)\n", " grad_W2 = a1.T @ grad_z2\n", " grad_b2 = grad_z2.sum(axis=0)\n", " grad_a1 = grad_z2 @ W2.T\n", " grad_z1 = grad_a1 * relu_deriv(z1)\n", " grad_W1 = X.T @ grad_z1\n", " grad_b1 = grad_z1.sum(axis=0)\n", " W2 -= lr * grad_W2; b2 -= lr * grad_b2\n", " W1 -= lr * grad_W1; b1 -= lr * grad_b1\n", " return W1, b1, W2, b2, p\n", "\n", "W1_zero, b1_zero = np.zeros((2, H)), np.zeros(H)\n", "W2_zero, b2_zero = np.zeros((H, 1)), np.zeros(1)\n", "W1_zero, b1_zero, W2_zero, b2_zero, p_zero = train_mlp(W1_zero, b1_zero, W2_zero, b2_zero)\n", "acc_zero = ((p_zero > 0.5).astype(float) == y).mean()\n", "\n", "rng2 = np.random.default_rng(8)\n", "W1_rand = rng2.normal(size=(2, H)) * 0.7\n", "W2_rand = rng2.normal(size=(H, 1)) * 0.7\n", "b1_rand, b2_rand = np.zeros(H), np.zeros(1)\n", "_, _, _, _, p_rand = train_mlp(W1_rand, b1_rand, W2_rand, b2_rand)\n", "acc_rand = ((p_rand > 0.5).astype(float) == y).mean()\n", "\n", "print(f'zero-initialized: accuracy = {acc_zero:.1%}')\n", "print(f'randomly initialized: accuracy = {acc_rand:.1%}')\n", "print()\n", "print('all 4 hidden units still have identical weight vectors after 3000 steps of zero-init training:')\n", "print(np.round(W1_zero, 4))" ] }, { "cell_type": "markdown", "id": "7226ef5e", "metadata": {}, "source": [ "With zero initialization, all four hidden units stay locked at exactly zero forever — the model never escapes the trivial (chance-level) solution, no matter how long it trains. Small *random* initialization breaks the symmetry: every unit starts out computing something slightly different, so gradient descent can push them apart and let them specialize. This is why every framework's default layer initialization uses small random values, never zeros." ] }, { "cell_type": "markdown", "id": "e7578cf7", "source": "## Weight initialization schemes: how large should \"small random\" be?\n\nZero-init fails completely; \"small random\" fixes it. But *how* small? Pick a fixed standard deviation and it works for one layer width, then quietly fails at another. Track the variance of activations after passing random data through a deep stack of `tanh`-activated linear layers, using the same fixed weight std at every width:", "metadata": {} }, { "cell_type": "code", "id": "da8eb24a", "source": "def forward_variance(width, std, depth=20):\n x = torch.randn(100, width)\n for _ in range(depth):\n W = torch.randn(width, width) * std\n x = torch.tanh(x @ W)\n return x.var().item()\n\nprint('fixed weight std = 0.05, activation variance after 20 tanh layers:')\nfor width in [16, 64, 256]:\n print(f' width={width:4d}: variance = {forward_variance(width, std=0.05):.2e}')", "metadata": {}, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "id": "8d1078c3", "source": "A fixed std of 0.05 vanishes to essentially zero after 20 layers, and gets *worse* as the layer gets wider — more incoming connections means more terms summed into each output, so a fixed per-weight std pushes the pre-activation further from zero even as the individual weights stay the same size. **Xavier/Glorot initialization** (Glorot & Bengio, 2010) fixes this by scaling the std with the layer's **fan-in** (number of inputs): `std = sqrt(1 / fan_in)`. Repeat the same experiment with Xavier-scaled weights:", "metadata": {} }, { "cell_type": "code", "id": "988826bf", "source": "def forward_variance_xavier(width, depth=20):\n x = torch.randn(100, width)\n for _ in range(depth):\n std = (1.0 / width) ** 0.5\n W = torch.randn(width, width) * std\n x = torch.tanh(x @ W)\n return x.var().item()\n\nprint('Xavier std = sqrt(1/fan_in), activation variance after 20 tanh layers:')\nfor width in [16, 64, 256]:\n print(f' width={width:4d}: variance = {forward_variance_xavier(width):.3f}')\n\n# check the formula against PyTorch's own implementation\nlayer = nn.Linear(256, 256, bias=False)\nnn.init.xavier_normal_(layer.weight)\nexpected_std = (2.0 / (256 + 256)) ** 0.5 # nn.init's xavier_normal_ uses fan_in AND fan_out\nprint(f'\\nnn.init.xavier_normal_ weight std: {layer.weight.std().item():.4f} (formula predicts {expected_std:.4f})')", "metadata": {}, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "id": "35277db1", "source": "Xavier keeps the variance in the same ballpark regardless of width, instead of vanishing catastrophically. But Xavier's derivation assumes a symmetric activation like `tanh`; **Kaiming/He initialization** (He et al., 2015) is the same idea adjusted for `ReLU`, which zeros out roughly half its inputs and so needs twice the variance to compensate: `std = sqrt(2 / fan_in)`. Using Xavier's `tanh`-derived scale on a `ReLU` network still vanishes:", "metadata": {} }, { "cell_type": "code", "id": "84ca459b", "source": "def forward_variance_relu(width, depth=20, kaiming=True):\n x = torch.randn(100, width)\n for _ in range(depth):\n std = (2.0 / width) ** 0.5 if kaiming else (1.0 / width) ** 0.5\n W = torch.randn(width, width) * std\n x = torch.relu(x @ W)\n return x.var().item()\n\nv_xavier_on_relu = forward_variance_relu(256, kaiming=False)\nv_kaiming = forward_variance_relu(256, kaiming=True)\nprint(f'ReLU net, Xavier scale (sqrt(1/fan_in), no ReLU correction): variance after 20 layers = {v_xavier_on_relu:.2e}')\nprint(f'ReLU net, Kaiming scale (sqrt(2/fan_in)): variance after 20 layers = {v_kaiming:.3f}')\n\nlayer2 = nn.Linear(256, 256, bias=False)\nnn.init.kaiming_normal_(layer2.weight, nonlinearity='relu')\nexpected_std2 = (2.0 / 256) ** 0.5\nprint(f'\\nnn.init.kaiming_normal_ weight std: {layer2.weight.std().item():.4f} (formula predicts {expected_std2:.4f})')", "metadata": {}, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "id": "79f4c316", "source": "The rule of thumb that follows: use Kaiming init for `ReLU`-family networks (the default for `nn.Conv2d`/`nn.Linear` is actually already a variant of this), and Xavier for `tanh`/`sigmoid`. Both are strictly better than picking a fixed std and hoping — they're derived, not tuned, from a simple requirement: keep activation variance roughly constant from layer to layer, so a network can be made arbitrarily deep without silently losing its signal before training even starts. This is the same instinct behind Lesson 35's `xavier_normal_` call in the vanishing-gradient demo, and behind batch normalization (also Lesson 35) — normalizing activation statistics is a recurring fix for the same underlying problem, applied at different points (once at initialization, continuously during training).", "metadata": {} }, { "cell_type": "markdown", "id": "2d29c169", "source": "## Learning rate schedules\n\nA single fixed learning rate for an entire training run is another thing that's easy to reach for and often not quite right: a rate large enough to make fast early progress is often too large to settle precisely once training gets close to a minimum. **Learning rate schedules** change the rate over time. Three common ones, validated against `torch.optim.lr_scheduler`:\n\n- **Step decay**: multiply the rate by a fixed factor every `N` steps.\n- **Cosine annealing**: smoothly decay along a cosine curve from the initial rate down to (near) zero.\n- **Linear warmup**: *ramp up* from zero over the first few steps, before applying the main schedule — used because early updates, before a model's gradient statistics have stabilized, can be unreliable enough that a large rate from step one causes damage a few steps of gradual ramp-up would have avoided.", "metadata": {} }, { "cell_type": "code", "id": "a6b64fb3", "source": "base_lr = 0.5\n\ndef check_schedule(name, manual_fn, torch_scheduler_factory, steps):\n p = torch.tensor([1.0], requires_grad=True)\n opt = torch.optim.SGD([p], lr=base_lr)\n sched = torch_scheduler_factory(opt)\n torch_lrs = []\n for step in range(steps):\n torch_lrs.append(opt.param_groups[0]['lr'])\n opt.step()\n sched.step()\n manual_lrs = [manual_fn(step) for step in range(steps)]\n max_diff = max(abs(a - b) for a, b in zip(torch_lrs, manual_lrs))\n print(f'{name}: max diff vs torch.optim.lr_scheduler = {max_diff:.2e}')\n return manual_lrs\n\nstep_lrs = check_schedule(\n 'step decay',\n lambda step: base_lr * (0.5 ** (step // 5)),\n lambda opt: torch.optim.lr_scheduler.StepLR(opt, step_size=5, gamma=0.5),\n steps=20)\n\ncos_lrs = check_schedule(\n 'cosine annealing',\n lambda step: 0.5 * base_lr * (1 + np.cos(np.pi * step / 20)),\n lambda opt: torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=20),\n steps=20)\n\nwarmup_steps = 5\nwarmup_lrs = check_schedule(\n 'linear warmup',\n lambda step: base_lr * min(1.0, (step + 1) / warmup_steps),\n lambda opt: torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda=lambda step: min(1.0, (step + 1) / warmup_steps)),\n steps=10)\n\nplt.figure(figsize=(6, 4))\nplt.plot(step_lrs, '-o', markersize=3, label='step decay')\nplt.plot(cos_lrs, '-o', markersize=3, label='cosine annealing')\nplt.plot(warmup_lrs, '-o', markersize=3, label='linear warmup')\nplt.xlabel('step'); plt.ylabel('learning rate'); plt.legend(fontsize=8)\nplt.title('Three learning rate schedules')\nplt.show()", "metadata": {}, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "id": "baffb86a", "source": "### Why decay actually helps: noisy gradients\n\nOn the exact, noise-free narrow bowl from the top of this lesson, a well-chosen constant learning rate converges just fine — there's nothing to decay away from. Real training almost never sees the exact gradient, though: each step uses a *mini-batch* estimate, which is noisy. Simulate that by adding random noise to the gradient every step, and compare a constant rate against step decay.", "metadata": {} }, { "cell_type": "code", "id": "a70143a7", "source": "def run_noisy(schedule_fn, steps=400, seed=0, noise_std=0.6):\n rng = np.random.default_rng(seed)\n p = np.array([-4.0, 1.0])\n losses = []\n for t in range(steps):\n noisy_g = grad_f(p) + rng.normal(0, noise_std, size=2) # stand-in for mini-batch noise\n p = p - schedule_fn(t) * noisy_g\n losses.append(f(p))\n return losses\n\nnoisy_lr = 0.18\nconst_losses = run_noisy(lambda t: noisy_lr)\ndecay_losses = run_noisy(lambda t: noisy_lr * (0.3 ** (t // 80)))\n\nprint(f'constant lr: mean loss over final 50 steps = {np.mean(const_losses[-50:]):.4f}')\nprint(f'step decay: mean loss over final 50 steps = {np.mean(decay_losses[-50:]):.4f}')\n\nplt.figure(figsize=(6, 4))\nplt.semilogy(const_losses, label='constant lr', alpha=0.8)\nplt.semilogy(decay_losses, label='step decay', alpha=0.8)\nplt.xlabel('step'); plt.ylabel('loss (log scale)'); plt.legend(fontsize=8)\nplt.title('Noisy gradients: constant lr vs. step decay')\nplt.show()", "metadata": {}, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "id": "7a72ce88", "source": "With noisy gradients, a constant learning rate large enough to make fast early progress never actually settles — it keeps bouncing around the minimum by an amount proportional to the learning rate itself, forever. Step decay makes the same fast early progress, then shrinks the rate as training continues, tightening that bounce and landing far closer to the true minimum. This is the real justification for learning rate schedules: not that a fixed rate is \"wrong\" on some idealized noise-free problem, but that it can't simultaneously be large (for speed) and small (for precision) when every gradient is a noisy estimate, which is the normal situation for real mini-batch training.", "metadata": {} }, { "cell_type": "markdown", "id": "0a900192", "metadata": {}, "source": "### Exercise\n\n1. Increase `momentum` from 0.9 to 0.99 on the narrow-bowl problem. Does it converge faster, or does it start to overshoot the minimum and oscillate on the *shallow* axis now?\n2. Try initializing the MLP's weights identically but *nonzero* (e.g. `np.full((2, H), 0.3)` for both layers) instead of all-zero. Does symmetry still fail to break? Why would you expect the same failure mode from any initialization where every hidden unit starts identical, not just an all-zero one?\n3. Adam's `adam_lr` (0.3) is much larger than plain SGD's `lr` (0.05) in this notebook, yet Adam remains stable while a plain SGD run with `lr=0.3` would diverge wildly on the steep axis. Try it and confirm. What does that suggest about *why* Adam is often described as being more forgiving of the learning-rate choice?\n4. `forward_variance_relu` compares Xavier vs. Kaiming scale at `width=256`. Rerun it at `width=16` and `width=1024`. Does the *gap* between the two get bigger or smaller as width grows, and does that match the fan-in scaling argument used to derive both formulas?\n5. In the noisy-gradient learning-rate demo, try `noise_std=0.0` (no noise at all). Does step decay still help, hurt, or make no difference relative to a constant rate — and does that match the claim that schedules matter *because of* gradient noise, not despite the loss surface itself?" } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.x" } }, "nbformat": 4, "nbformat_minor": 5 }