Below is a bumpy 1-D loss curve. Drop a ball anywhere and it rolls downhill by gradient descent: each step moves it against the slope, scaled by the learning rate. Press Step to take one step at a time, or Run to animate the whole descent.
<p class="hint">The curve is a <b>loss</b>: low is good. The ball rolls downhill by gradient descent — each step moves it against the slope, scaled by the <b>learning rate</b>. Small rate = slow but smooth; too large = overshoot, oscillate, even diverge. Click the curve to drop the ball anywhere.</p>
<canvas id="plot" width="460" height="240"></canvas>
<div class="row">
<label>Learning rate <span id="lrval">0.10</span></label>
<input id="lr" type="range" min="1" max="220" value="10">
</div>
<div class="status" id="status">Drop the ball, then step.</div>
<div class="btns">
<button id="step" type="button">Step</button>
<button id="run" type="button">Run</button>
<button id="reset" type="button" class="ghost">Reset</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; }
canvas { width: 100%; max-width: 460px; height: auto; border: 1px solid #cdd9e3;
border-radius: 8px; background: #f7fafc; display: block; touch-action: none; }
.row { display: flex; align-items: center; gap: .7rem; margin: .7rem 0 .2rem; }
.row label { font-size: .9rem; font-weight: 600; color: #1d3557; white-space: nowrap; }
.row label span { font-family: ui-monospace, monospace; color: #c92f3c; }
.row input[type=range] { flex: 1; accent-color: #1d3557; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.warn { color: #c77700; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Gradient descent on a 1-D loss f(x): repeatedly do x <- x - lr * f'(x).
// The ball moves AGAINST the slope. The learning rate (lr) sets the step size:
// too small -> slow; too large -> overshoots the minimum, oscillates, diverges.
// The curve has two valleys, so where you start decides which LOCAL minimum you reach.
const cv = document.getElementById('plot'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const XMIN = -6, XMAX = 6; // domain shown
// Loss with two wells: a smooth double-well plus a tilt. Minima near x≈-2.1 and x≈2.7.
function f(x){ return 0.05*x*x*x*x - 0.55*x*x + 0.35*x + 3.2; }
function df(x){ return 0.2*x*x*x - 1.1*x + 0.35; } // exact derivative of f
// ----- scaling between math coords and canvas pixels -----
let YMIN = Infinity, YMAX = -Infinity;
for(let x=XMIN; x<=XMAX; x+=0.02){ const y=f(x); if(y<YMIN)YMIN=y; if(y>YMAX)YMAX=y; }
YMIN -= 0.5; YMAX += 0.8;
const sx = x => (x-XMIN)/(XMAX-XMIN)*W;
const sy = y => H - (y-YMIN)/(YMAX-YMIN)*H;
let x = -4.5, lr = 0.10, steps = 0, running = null, lastY = null;
function drawCurve(){
ctx.clearRect(0,0,W,H);
ctx.beginPath();
for(let px=0; px<=W; px++){ const xx = XMIN + px/W*(XMAX-XMIN); const py = sy(f(xx));
px===0 ? ctx.moveTo(px,py) : ctx.lineTo(px,py); }
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2; ctx.stroke();
}
function drawBall(){
const px = sx(x), py = sy(f(x));
// slope arrow (downhill tangent direction)
const g = df(x), dir = g>0 ? -1 : 1;
ctx.beginPath(); ctx.moveTo(px,py); ctx.lineTo(px+dir*26, py); ctx.strokeStyle='#c92f3c';
ctx.lineWidth=2; ctx.stroke();
ctx.beginPath(); ctx.moveTo(px+dir*26,py); ctx.lineTo(px+dir*18,py-5);
ctx.lineTo(px+dir*18,py+5); ctx.closePath(); ctx.fillStyle='#c92f3c'; ctx.fill();
ctx.beginPath(); ctx.arc(px,py,7,0,7); ctx.fillStyle='#e63946';
ctx.strokeStyle='#fff'; ctx.lineWidth=2; ctx.fill(); ctx.stroke();
}
function render(){ drawCurve(); if(isFinite(x) && x>=XMIN-2 && x<=XMAX+2) drawBall(); }
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+cls; }
function step(){
const g = df(x);
const nx = x - lr*g; // the gradient-descent update
const ny = f(nx);
if(!isFinite(nx) || nx < XMIN-2 || nx > XMAX+2){
setStatus('Diverged! The step was too big — the ball flew off the surface.','bad');
stopRun(); x = nx; render(); return false;
}
const overshot = (lastY!=null && ny > lastY + 1e-9); // loss went UP = overshoot
x = nx; steps++; lastY = ny; render();
if(Math.abs(g) < 1e-3){
setStatus(fmt('Converged at x≈%x% after %steps% steps (slope ≈ 0).', { x: x.toFixed(2), steps: steps }),'ok');
stopRun(); return false;
}
if(overshot) setStatus(fmt('Step %steps%: overshooting — loss went up. Lower the rate.', { steps: steps }),'warn');
else setStatus(fmt('Step %steps%: rolling downhill. slope=%g%', { steps: steps, g: g.toFixed(3) }),'');
return true;
}
function stopRun(){ if(running){ clearInterval(running); running=null;
document.getElementById('run').textContent='Run'; } }
function toggleRun(){
if(running){ stopRun(); return; }
document.getElementById('run').textContent='Stop';
running = setInterval(()=>{ if(!step()) stopRun(); }, 120);
}
function reset(){ stopRun(); x=-4.5; steps=0; lastY=null; render(); setStatus('Drop the ball, then step.',''); }
document.getElementById('lr').addEventListener('input', e=>{
lr = (+e.target.value)/100; document.getElementById('lrval').textContent = lr.toFixed(2);
});
document.getElementById('step').addEventListener('click', step);
document.getElementById('run').addEventListener('click', toggleRun);
document.getElementById('reset').addEventListener('click', reset);
cv.addEventListener('pointerdown', e=>{
stopRun(); const r=cv.getBoundingClientRect();
const px = (e.clientX-r.left)/r.width*W;
x = XMIN + px/W*(XMAX-XMIN); steps=0; lastY=null; render();
setStatus(fmt('Ball dropped at x=%x%. Now step.', { x: x.toFixed(2) }),'');
});
render();
<p class="hint">The curve is a <b>loss</b>: low is good. The ball rolls downhill by gradient descent — each step moves it against the slope, scaled by the <b>learning rate</b>. Small rate = slow but smooth; too large = overshoot, oscillate, even diverge. Click the curve to drop the ball anywhere.</p>
<canvas id="plot" width="460" height="240"></canvas>
<div class="row">
<label>Learning rate <span id="lrval">0.10</span></label>
<input id="lr" type="range" min="1" max="220" value="10">
</div>
<div class="status" id="status">Drop the ball, then step.</div>
<div class="btns">
<button id="step" type="button">Step</button>
<button id="run" type="button">Run</button>
<button id="reset" type="button" class="ghost">Reset</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; }
canvas { width: 100%; max-width: 460px; height: auto; border: 1px solid #cdd9e3;
border-radius: 8px; background: #f7fafc; display: block; touch-action: none; }
.row { display: flex; align-items: center; gap: .7rem; margin: .7rem 0 .2rem; }
.row label { font-size: .9rem; font-weight: 600; color: #1d3557; white-space: nowrap; }
.row label span { font-family: ui-monospace, monospace; color: #c92f3c; }
.row input[type=range] { flex: 1; accent-color: #1d3557; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.warn { color: #c77700; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Gradient descent on a 1-D loss f(x): repeatedly do x <- x - lr * f'(x).
// The ball moves AGAINST the slope. The learning rate (lr) sets the step size:
// too small -> slow; too large -> overshoots the minimum, oscillates, diverges.
// The curve has two valleys, so where you start decides which LOCAL minimum you reach.
const cv = document.getElementById('plot'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const XMIN = -6, XMAX = 6; // domain shown
// Loss with two wells: a smooth double-well plus a tilt. Minima near x≈-2.1 and x≈2.7.
function f(x){ return 0.05*x*x*x*x - 0.55*x*x + 0.35*x + 3.2; }
function df(x){ return 0.2*x*x*x - 1.1*x + 0.35; } // exact derivative of f
// ----- scaling between math coords and canvas pixels -----
let YMIN = Infinity, YMAX = -Infinity;
for(let x=XMIN; x<=XMAX; x+=0.02){ const y=f(x); if(y<YMIN)YMIN=y; if(y>YMAX)YMAX=y; }
YMIN -= 0.5; YMAX += 0.8;
const sx = x => (x-XMIN)/(XMAX-XMIN)*W;
const sy = y => H - (y-YMIN)/(YMAX-YMIN)*H;
let x = -4.5, lr = 0.10, steps = 0, running = null, lastY = null;
function drawCurve(){
ctx.clearRect(0,0,W,H);
ctx.beginPath();
for(let px=0; px<=W; px++){ const xx = XMIN + px/W*(XMAX-XMIN); const py = sy(f(xx));
px===0 ? ctx.moveTo(px,py) : ctx.lineTo(px,py); }
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2; ctx.stroke();
}
function drawBall(){
const px = sx(x), py = sy(f(x));
// slope arrow (downhill tangent direction)
const g = df(x), dir = g>0 ? -1 : 1;
ctx.beginPath(); ctx.moveTo(px,py); ctx.lineTo(px+dir*26, py); ctx.strokeStyle='#c92f3c';
ctx.lineWidth=2; ctx.stroke();
ctx.beginPath(); ctx.moveTo(px+dir*26,py); ctx.lineTo(px+dir*18,py-5);
ctx.lineTo(px+dir*18,py+5); ctx.closePath(); ctx.fillStyle='#c92f3c'; ctx.fill();
ctx.beginPath(); ctx.arc(px,py,7,0,7); ctx.fillStyle='#e63946';
ctx.strokeStyle='#fff'; ctx.lineWidth=2; ctx.fill(); ctx.stroke();
}
function render(){ drawCurve(); if(isFinite(x) && x>=XMIN-2 && x<=XMAX+2) drawBall(); }
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+cls; }
function step(){
const g = df(x);
const nx = x - lr*g; // the gradient-descent update
const ny = f(nx);
if(!isFinite(nx) || nx < XMIN-2 || nx > XMAX+2){
setStatus('Diverged! The step was too big — the ball flew off the surface.','bad');
stopRun(); x = nx; render(); return false;
}
const overshot = (lastY!=null && ny > lastY + 1e-9); // loss went UP = overshoot
x = nx; steps++; lastY = ny; render();
if(Math.abs(g) < 1e-3){
setStatus(fmt('Converged at x≈%x% after %steps% steps (slope ≈ 0).', { x: x.toFixed(2), steps: steps }),'ok');
stopRun(); return false;
}
if(overshot) setStatus(fmt('Step %steps%: overshooting — loss went up. Lower the rate.', { steps: steps }),'warn');
else setStatus(fmt('Step %steps%: rolling downhill. slope=%g%', { steps: steps, g: g.toFixed(3) }),'');
return true;
}
function stopRun(){ if(running){ clearInterval(running); running=null;
document.getElementById('run').textContent='Run'; } }
function toggleRun(){
if(running){ stopRun(); return; }
document.getElementById('run').textContent='Stop';
running = setInterval(()=>{ if(!step()) stopRun(); }, 120);
}
function reset(){ stopRun(); x=-4.5; steps=0; lastY=null; render(); setStatus('Drop the ball, then step.',''); }
document.getElementById('lr').addEventListener('input', e=>{
lr = (+e.target.value)/100; document.getElementById('lrval').textContent = lr.toFixed(2);
});
document.getElementById('step').addEventListener('click', step);
document.getElementById('run').addEventListener('click', toggleRun);
document.getElementById('reset').addEventListener('click', reset);
cv.addEventListener('pointerdown', e=>{
stopRun(); const r=cv.getBoundingClientRect();
const px = (e.clientX-r.left)/r.width*W;
x = XMIN + px/W*(XMAX-XMIN); steps=0; lastY=null; render();
setStatus(fmt('Ball dropped at x=%x%. Now step.', { x: x.toFixed(2) }),'');
});
render();
Now play with the learning rate slider. Make it small and the ball creeps down smoothly but slowly. Make it bigger and it descends faster — until, past a point, it starts to overshoot and oscillate, and if you push further it diverges, flying off the surface entirely. Drop the ball on different sides and you'll also see it settle into different local minima: gradient descent only ever finds the bottom of the valley it happens to be in, not the deepest valley overall.
Comments
Loading comments...