Gradient Descent
Small steps downhill, one hyperparameter from disaster.
Beginner / optimization / gradient-methods
Gradient descent fits the same noisy line as the Least Squares experiment, but iteratively: the parameters step against the MSE gradient. The contour plot shows the loss surface and the descent path - shrink the learning rate and it crawls, raise it past stability and the path flies off the plot. That contrast with the closed-form page is the entire lesson of first-order methods.
Tagged: learning rate, loss surface, divergence, MSE
Live simulation
Learning rate past stability and the descent path leaves the plot. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Learning rate past stability and the descent path leaves the plot.
Mathematics
Gradient descent minimizes a loss by stepping against the gradient: theta <- theta - lr * grad L(theta). For the line fit, L(a, b) is the mean squared residual - a bowl in (a, b) space - and the path of the descent is a curve drawn on that bowl.
L(a, b) = (1/N) sum (y_i - a x_i - b)^2
dL/da = -2/N sum r_i x_i; dL/db = -2/N sum r_i
stability: lr < 2 / (largest eigenvalue of the Hessian)
The stability bound is the slider's danger zone: push the learning rate past it and the iterates oscillate outward - divergence, visibly. Below it, the path converges to the same (a, b) the normal equations compute in closed form on the Least Squares page. Same problem, two philosophies, one shared test: both must agree on the answer.
How it works
The loss surface is precomputed on a 72x48 grid in (a, b) space and rendered as banded shading; the descent path is drawn over it with markers every few epochs. Data is generated from a true line with seeded noise; the truth marker shows where the optimum lies.
Each tick performs a fixed number of full-batch steps - no stochasticity, so the path is smooth and the divergence clean.
Implementation
src/engine/experiments/gradient-descent.ts · 189 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import { gaussian } from "../core/rng"; |
| 3 | import type { ParameterDef, ThemeColors } from "../core/types"; |
| 4 | |
| 5 | /** |
| 6 | * Gradient descent fitting the same noisy line as the Least Squares |
| 7 | * experiment - but iteratively: (a, b) take small steps down the MSE loss. |
| 8 | * The contour plot shows the loss surface and the path; a learning rate |
| 9 | * that's too large visibly diverges. The end state should agree with the |
| 10 | * closed-form solution the other page computes exactly. |
| 11 | */ |
| 12 | export class GradientDescentExperiment extends BaseExperiment { |
| 13 | readonly id = "gradient-descent"; |
| 14 | |
| 15 | private xs: number[] = []; |
| 16 | private ys: number[] = []; |
| 17 | private a = 0; |
| 18 | private b = 0; |
| 19 | private epoch = 0; |
| 20 | private loss = 0; |
| 21 | private path: { a: number; b: number }[] = []; |
| 22 | private readonly trueA = 1.2; |
| 23 | private readonly trueB = 0.4; |
| 24 | // Contour cache in (a, b) space. |
| 25 | private contour = new Float64Array(0); |
| 26 | |
| 27 | protected params(): ParameterDef[] { |
| 28 | return [ |
| 29 | { key: "lr", label: "Learning rate", min: 0.01, max: 1.2, step: 0.01, defaultValue: 0.25 }, |
| 30 | { key: "steps", label: "Steps per tick", min: 1, max: 20, step: 1, defaultValue: 2 }, |
| 31 | { key: "noise", label: "Data noise", min: 0, max: 0.5, step: 0.02, defaultValue: 0.15 }, |
| 32 | ]; |
| 33 | } |
| 34 | |
| 35 | protected onReset(): void { |
| 36 | const noise = this.num("noise"); |
| 37 | this.xs = []; |
| 38 | this.ys = []; |
| 39 | for (let i = 0; i < 80; i++) { |
| 40 | const x = this.rng(); |
| 41 | this.xs.push(x); |
| 42 | this.ys.push(this.trueA * x + this.trueB + gaussian(this.rng) * noise); |
| 43 | } |
| 44 | this.a = 0; |
| 45 | this.b = 0; |
| 46 | this.epoch = 0; |
| 47 | this.path = [{ a: this.a, b: this.b }]; |
| 48 | this.computeLoss(); |
| 49 | this.buildContour(); |
| 50 | } |
| 51 | |
| 52 | protected onParameterChange(key: string): void { |
| 53 | if (key === "noise") this.reset(); |
| 54 | void key; |
| 55 | } |
| 56 | |
| 57 | private mse(a: number, b: number): number { |
| 58 | let sum = 0; |
| 59 | for (let i = 0; i < this.xs.length; i++) { |
| 60 | const r = this.ys[i] - (a * this.xs[i] + b); |
| 61 | sum += r * r; |
| 62 | } |
| 63 | return sum / this.xs.length; |
| 64 | } |
| 65 | |
| 66 | private computeLoss(): void { |
| 67 | this.loss = this.mse(this.a, this.b); |
| 68 | } |
| 69 | |
| 70 | private buildContour(): void { |
| 71 | const w = 72; |
| 72 | const h = 48; |
| 73 | this.contour = new Float64Array(w * h); |
| 74 | let min = Infinity; |
| 75 | let max = -Infinity; |
| 76 | for (let gy = 0; gy < h; gy++) { |
| 77 | for (let gx = 0; gx < w; gx++) { |
| 78 | const a = (gx / w) * 3; |
| 79 | const b = (gy / h) * 1.6; |
| 80 | const value = this.mse(a, b); |
| 81 | this.contour[gy * w + gx] = value; |
| 82 | min = Math.min(min, value); |
| 83 | max = Math.max(max, value); |
| 84 | } |
| 85 | } |
| 86 | // Store log-scaled for contrast. |
| 87 | for (let i = 0; i < this.contour.length; i++) { |
| 88 | this.contour[i] = Math.log10(Math.max(1e-6, this.contour[i] - min) + 1); |
| 89 | } |
| 90 | void max; |
| 91 | } |
| 92 | |
| 93 | protected onUpdate(): void { |
| 94 | const lr = this.num("lr"); |
| 95 | const steps = this.num("steps"); |
| 96 | const n = this.xs.length; |
| 97 | for (let s = 0; s < steps; s++) { |
| 98 | // Gradients of MSE over the whole batch. |
| 99 | let ga = 0; |
| 100 | let gb = 0; |
| 101 | for (let i = 0; i < n; i++) { |
| 102 | const r = this.ys[i] - (this.a * this.xs[i] + this.b); |
| 103 | ga += -2 * r * this.xs[i]; |
| 104 | gb += -2 * r; |
| 105 | } |
| 106 | ga /= n; |
| 107 | gb /= n; |
| 108 | this.a -= lr * ga; |
| 109 | this.b -= lr * gb; |
| 110 | this.epoch += 1; |
| 111 | } |
| 112 | this.computeLoss(); |
| 113 | this.path.push({ a: this.a, b: this.b }); |
| 114 | if (this.path.length > 600) this.path.shift(); |
| 115 | } |
| 116 | |
| 117 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 118 | ctx.fillStyle = theme.bg; |
| 119 | ctx.fillRect(0, 0, this.width, this.height); |
| 120 | const w = 72; |
| 121 | const h = 48; |
| 122 | // Loss contours (banded log-MSE) |
| 123 | const cw = this.width / w; |
| 124 | const ch = this.height / h; |
| 125 | for (let gy = 0; gy < h; gy++) { |
| 126 | for (let gx = 0; gx < w; gx++) { |
| 127 | const value = this.contour[gy * w + gx]; |
| 128 | const t = Math.min(1, value / 2.2); |
| 129 | ctx.fillStyle = t > 0.55 ? theme.accent : theme.fgSecondary; |
| 130 | ctx.globalAlpha = 0.05 + t * 0.22; |
| 131 | ctx.fillRect(gx * cw, gy * ch, cw + 0.5, ch + 0.5); |
| 132 | } |
| 133 | } |
| 134 | ctx.globalAlpha = 1; |
| 135 | const toX = (a: number) => (a / 3) * this.width; |
| 136 | const toY = (b: number) => (b / 1.6) * this.height; |
| 137 | // Descent path |
| 138 | ctx.strokeStyle = theme.accent; |
| 139 | ctx.lineWidth = 1.8; |
| 140 | ctx.beginPath(); |
| 141 | this.path.forEach((point, index) => { |
| 142 | if (index === 0) ctx.moveTo(toX(point.a), toY(point.b)); |
| 143 | else ctx.lineTo(toX(point.a), toY(point.b)); |
| 144 | }); |
| 145 | ctx.stroke(); |
| 146 | ctx.fillStyle = theme.fg; |
| 147 | this.path.forEach((point, index) => { |
| 148 | if (index % 6 !== 0 && index !== this.path.length - 1) return; |
| 149 | ctx.beginPath(); |
| 150 | ctx.arc(toX(point.a), toY(point.b), 2.4, 0, Math.PI * 2); |
| 151 | ctx.fill(); |
| 152 | }); |
| 153 | // True parameters marker |
| 154 | ctx.strokeStyle = theme.viz[1]; |
| 155 | ctx.setLineDash([4, 3]); |
| 156 | ctx.beginPath(); |
| 157 | ctx.moveTo(toX(this.trueA), 0); |
| 158 | ctx.lineTo(toX(this.trueA), this.height); |
| 159 | ctx.moveTo(0, toY(this.trueB)); |
| 160 | ctx.lineTo(this.width, toY(this.trueB)); |
| 161 | ctx.stroke(); |
| 162 | ctx.setLineDash([]); |
| 163 | ctx.lineWidth = 1; |
| 164 | ctx.font = "11px ui-monospace, monospace"; |
| 165 | ctx.fillStyle = theme.fgTertiary; |
| 166 | ctx.fillText("LOSS SURFACE over (a, b)", 10, 16); |
| 167 | ctx.fillStyle = theme.viz[1]; |
| 168 | ctx.fillText("TRUE a, b", this.width - 84, 16); |
| 169 | } |
| 170 | |
| 171 | getMetrics() { |
| 172 | return { |
| 173 | epoch: this.epoch, |
| 174 | a: this.a.toFixed(4), |
| 175 | b: this.b.toFixed(4), |
| 176 | loss: this.loss.toExponential(3), |
| 177 | trueValues: `${this.trueA} / ${this.trueB}`, |
| 178 | }; |
| 179 | } |
| 180 | |
| 181 | describe(): string { |
| 182 | return `Gradient descent at epoch ${this.epoch}: a = ${this.a.toFixed(3)}, b = ${this.b.toFixed(3)}, loss ${this.loss.toExponential(2)} - descending the same surface least squares solves in one step.`; |
| 183 | } |
| 184 | |
| 185 | entities(): number { |
| 186 | return this.xs.length; |
| 187 | } |
| 188 | } |
| 189 | |
Performance
- Time per tick
- O(N) per step (one pass over the data)
- Space
- The data plus a path buffer
- Notes
- The convergence test asserts the descent end state agrees with least squares within 5% - two pages, one answer.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.