Try it. Click anywhere on the bumpy curve to drop a ball there. It rolls downhill (gradient descent) and settles into the nearest valley. Did it find the deepest valley — the global minimum — or get trapped in a shallower one?
<p class="hint">Click anywhere on the curve to drop a ball. It rolls <b>downhill</b> (gradient descent) into the nearest valley. Did it reach the <span class="g">deepest</span> one — or get <span class="r">trapped</span>?</p>
<svg id="plot" viewBox="0 0 480 220" class="plot"></svg>
<div id="status" class="status"></div>
<div class="btns">
<button id="many" type="button">Many starts</button>
<button id="reset" type="button" class="ghost">Clear</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.hint .g { color: #0a7d33; font-weight: 700; } .hint .r { color: #c0392b; font-weight: 700; }
.plot { width: 100%; max-width: 520px; background: #f4f7f9; border: 1px solid #e2e6eb; border-radius: 10px; display: block; cursor: pointer; }
.curve { fill: none; stroke: #457b9d; stroke-width: 2.5; }
.gmark { stroke: #0a7d33; stroke-width: 1.5; stroke-dasharray: 3 3; }
.ball { fill: #e76f51; stroke: #fff; stroke-width: 1.5; }
.ball.good { fill: #0a7d33; }
.status { font-weight: 800; min-height: 1.4em; margin: .8rem 0 .5rem; font-size: 1rem; }
.status.g { color: #0a7d33; } .status.r { color: #c0392b; } .status.n { color: #777; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .5rem 1rem; border: 1px solid #457b9d; background: #457b9d; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #457b9d; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Non-convex optimization: gradient descent rolls a ball downhill into the
// NEAREST valley (local minimum) — not necessarily the global one. Finding
// the global minimum of a bumpy function is NP-hard in general.
const X0 = 0, X1 = 10, PADL = 20, PADR = 460, PADT = 20, PADB = 195;
function f(x) { return 0.6 * Math.sin(1.6 * x) + Math.sin(0.75 * x + 0.5) + 0.07 * (x - 5) * (x - 5); }
function df(x) { const h = 1e-4; return (f(x + h) - f(x - h)) / (2 * h); }
// sample for plot range + global min
let YMIN = Infinity, YMAX = -Infinity, GX = 0, GMIN = Infinity;
for (let i = 0; i <= 600; i++) { const x = X0 + (X1 - X0) * i / 600, y = f(x); if (y < YMIN) YMIN = y; if (y > YMAX) YMAX = y; if (y < GMIN) { GMIN = y; GX = x; } }
const sx = x => PADL + (x - X0) / (X1 - X0) * (PADR - PADL);
const sy = y => PADT + (1 - (y - YMIN) / (YMAX - YMIN)) * (PADB - PADT);
const fromSx = px => X0 + (px - PADL) / (PADR - PADL) * (X1 - X0);
function descend(x) { let lr = 0.06; for (let i = 0; i < 220; i++) { x -= lr * df(x); x = Math.max(X0, Math.min(X1, x)); } return x; }
let balls = []; // {x}
let anim = null;
function curvePath() { let d = ''; for (let i = 0; i <= 240; i++) { const x = X0 + (X1 - X0) * i / 240; d += (i ? 'L' : 'M') + sx(x).toFixed(1) + ' ' + sy(f(x)).toFixed(1) + ' '; } return d; }
function render() {
let s = '<path class="curve" d="' + curvePath() + '"/>';
s += '<line class="gmark" x1="' + sx(GX) + '" y1="' + sy(GMIN) + '" x2="' + sx(GX) + '" y2="' + PADB + '"/>';
s += fmt('<text x="%v%" y="%PADB%%v2%" style="font:600 9px system-ui;fill:#0a7d33">global min</text>', { v: sx(GX) - 4, PADB: PADB, v2: 14 });
balls.forEach(b => { const good = Math.abs(f(b.x) - GMIN) < 0.15; s += '<circle class="ball' + (good ? ' good' : '') + '" cx="' + sx(b.x) + '" cy="' + (sy(f(b.x)) - 6) + '" r="6"/>'; });
document.getElementById('plot').innerHTML = s;
}
function dropAt(px) {
if (anim) { clearInterval(anim); anim = null; }
let x = Math.max(X0, Math.min(X1, fromSx(px)));
balls = [{ x }];
let step = 0;
anim = setInterval(() => {
x -= 0.06 * df(x); x = Math.max(X0, Math.min(X1, x)); balls[0].x = x; render();
if (++step > 200) { clearInterval(anim); anim = null; finish(x); }
}, 16);
render();
}
function finish(x) {
const st = document.getElementById('status');
if (Math.abs(f(x) - GMIN) < 0.15) { st.className = 'status g'; st.textContent = '✓ Reached the global minimum!'; }
else { st.className = 'status r'; st.textContent = '✗ Trapped in a local valley — the global minimum is deeper, elsewhere.'; }
}
function many() {
if (anim) { clearInterval(anim); anim = null; }
balls = [];
let reached = 0;
for (let i = 0; i < 11; i++) { const start = X0 + (X1 - X0) * (i + 0.5) / 11; const end = descend(start); balls.push({ x: end }); if (Math.abs(f(end) - GMIN) < 0.15) reached++; }
render();
const st = document.getElementById('status');
st.className = 'status n';
st.innerHTML = fmt('From 11 evenly spaced starts, only <b style="color:#0a7d33">%reached%</b> reached the global minimum — the rest got stuck. That’s why optimizers use many random restarts.', { reached: reached });
}
document.getElementById('plot').addEventListener('click', e => {
const svg = document.getElementById('plot'); const r = svg.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width * 480; dropAt(px);
});
document.getElementById('many').addEventListener('click', many);
document.getElementById('reset').addEventListener('click', () => { if (anim) clearInterval(anim); anim = null; balls = []; document.getElementById('status').textContent = ''; render(); });
render();
<p class="hint">Click anywhere on the curve to drop a ball. It rolls <b>downhill</b> (gradient descent) into the nearest valley. Did it reach the <span class="g">deepest</span> one — or get <span class="r">trapped</span>?</p>
<svg id="plot" viewBox="0 0 480 220" class="plot"></svg>
<div id="status" class="status"></div>
<div class="btns">
<button id="many" type="button">Many starts</button>
<button id="reset" type="button" class="ghost">Clear</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.hint .g { color: #0a7d33; font-weight: 700; } .hint .r { color: #c0392b; font-weight: 700; }
.plot { width: 100%; max-width: 520px; background: #f4f7f9; border: 1px solid #e2e6eb; border-radius: 10px; display: block; cursor: pointer; }
.curve { fill: none; stroke: #457b9d; stroke-width: 2.5; }
.gmark { stroke: #0a7d33; stroke-width: 1.5; stroke-dasharray: 3 3; }
.ball { fill: #e76f51; stroke: #fff; stroke-width: 1.5; }
.ball.good { fill: #0a7d33; }
.status { font-weight: 800; min-height: 1.4em; margin: .8rem 0 .5rem; font-size: 1rem; }
.status.g { color: #0a7d33; } .status.r { color: #c0392b; } .status.n { color: #777; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .5rem 1rem; border: 1px solid #457b9d; background: #457b9d; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #457b9d; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Non-convex optimization: gradient descent rolls a ball downhill into the
// NEAREST valley (local minimum) — not necessarily the global one. Finding
// the global minimum of a bumpy function is NP-hard in general.
const X0 = 0, X1 = 10, PADL = 20, PADR = 460, PADT = 20, PADB = 195;
function f(x) { return 0.6 * Math.sin(1.6 * x) + Math.sin(0.75 * x + 0.5) + 0.07 * (x - 5) * (x - 5); }
function df(x) { const h = 1e-4; return (f(x + h) - f(x - h)) / (2 * h); }
// sample for plot range + global min
let YMIN = Infinity, YMAX = -Infinity, GX = 0, GMIN = Infinity;
for (let i = 0; i <= 600; i++) { const x = X0 + (X1 - X0) * i / 600, y = f(x); if (y < YMIN) YMIN = y; if (y > YMAX) YMAX = y; if (y < GMIN) { GMIN = y; GX = x; } }
const sx = x => PADL + (x - X0) / (X1 - X0) * (PADR - PADL);
const sy = y => PADT + (1 - (y - YMIN) / (YMAX - YMIN)) * (PADB - PADT);
const fromSx = px => X0 + (px - PADL) / (PADR - PADL) * (X1 - X0);
function descend(x) { let lr = 0.06; for (let i = 0; i < 220; i++) { x -= lr * df(x); x = Math.max(X0, Math.min(X1, x)); } return x; }
let balls = []; // {x}
let anim = null;
function curvePath() { let d = ''; for (let i = 0; i <= 240; i++) { const x = X0 + (X1 - X0) * i / 240; d += (i ? 'L' : 'M') + sx(x).toFixed(1) + ' ' + sy(f(x)).toFixed(1) + ' '; } return d; }
function render() {
let s = '<path class="curve" d="' + curvePath() + '"/>';
s += '<line class="gmark" x1="' + sx(GX) + '" y1="' + sy(GMIN) + '" x2="' + sx(GX) + '" y2="' + PADB + '"/>';
s += fmt('<text x="%v%" y="%PADB%%v2%" style="font:600 9px system-ui;fill:#0a7d33">global min</text>', { v: sx(GX) - 4, PADB: PADB, v2: 14 });
balls.forEach(b => { const good = Math.abs(f(b.x) - GMIN) < 0.15; s += '<circle class="ball' + (good ? ' good' : '') + '" cx="' + sx(b.x) + '" cy="' + (sy(f(b.x)) - 6) + '" r="6"/>'; });
document.getElementById('plot').innerHTML = s;
}
function dropAt(px) {
if (anim) { clearInterval(anim); anim = null; }
let x = Math.max(X0, Math.min(X1, fromSx(px)));
balls = [{ x }];
let step = 0;
anim = setInterval(() => {
x -= 0.06 * df(x); x = Math.max(X0, Math.min(X1, x)); balls[0].x = x; render();
if (++step > 200) { clearInterval(anim); anim = null; finish(x); }
}, 16);
render();
}
function finish(x) {
const st = document.getElementById('status');
if (Math.abs(f(x) - GMIN) < 0.15) { st.className = 'status g'; st.textContent = '✓ Reached the global minimum!'; }
else { st.className = 'status r'; st.textContent = '✗ Trapped in a local valley — the global minimum is deeper, elsewhere.'; }
}
function many() {
if (anim) { clearInterval(anim); anim = null; }
balls = [];
let reached = 0;
for (let i = 0; i < 11; i++) { const start = X0 + (X1 - X0) * (i + 0.5) / 11; const end = descend(start); balls.push({ x: end }); if (Math.abs(f(end) - GMIN) < 0.15) reached++; }
render();
const st = document.getElementById('status');
st.className = 'status n';
st.innerHTML = fmt('From 11 evenly spaced starts, only <b style="color:#0a7d33">%reached%</b> reached the global minimum — the rest got stuck. That’s why optimizers use many random restarts.', { reached: reached });
}
document.getElementById('plot').addEventListener('click', e => {
const svg = document.getElementById('plot'); const r = svg.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width * 480; dropAt(px);
});
document.getElementById('many').addEventListener('click', many);
document.getElementById('reset').addEventListener('click', () => { if (anim) clearInterval(anim); anim = null; balls = []; document.getElementById('status').textContent = ''; render(); });
render();
Drop it from different spots and watch the outcome change: the same simple rule lands in different valleys depending on where it starts. Hit Many starts to scatter balls everywhere — only some find the true bottom. That's the whole trap of non-convex optimization, live.
Comments
Loading comments...