▶ 展示 · 回索引
gradient-descent-blind-descent.html
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>蒙眼下山:梯度下降 / Blind Descent: Gradient Descent</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #e9eee9; font-family: "Noto Sans TC", "Microsoft JhengHei", "PingFang TC", sans-serif; }
.stage { position: relative; width: min(100vw, 1600px); aspect-ratio: 16 / 9; container-type: inline-size; overflow: hidden; background: #f8f9f5; box-shadow: 0 22px 70px #22304126; }
canvas { width: 100%; height: 100%; display: block; }
.controls { position: absolute; left: 3.75%; right: 3.75%; bottom: 1.4%; height: 6.2%; padding: 0 2.1%; display: flex; align-items: center; gap: 2.1%; border-radius: 1.2cqw; background: #e5f2ec; border: 1px solid #cbded4; color: #163333; }
.rate { white-space: nowrap; font-size: clamp(11px, 1.3cqw, 21px); font-weight: 700; }
.rate strong { font-family: Georgia, serif; color: #0a7771; font-size: 1.3em; margin-left: .3em; }
input[type=range] { flex: 1; min-width: 60px; accent-color: #0a8178; }
.presets { display: flex; gap: .5cqw; }
button { appearance: none; border: 1px solid #b9cfc5; border-radius: 999px; background: #fffefa; color: #244240; font: 700 clamp(10px, 1cqw, 16px) "Noto Sans TC", "Microsoft JhengHei", sans-serif; padding: .55em .85em; white-space: nowrap; cursor: pointer; }
button[aria-pressed=true] { background: #0a8178; border-color: #0a8178; color: white; }
button:focus-visible, input:focus-visible { outline: 3px solid #db9226; outline-offset: 3px; }
.save { border-color: #0a8178; color: #0a6c65; }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
@media (max-width: 850px) {
body { display: block; background: #e9eee9; }
.viewport { overflow-x: auto; width: 100vw; }
.stage { width: 960px; max-width: none; }
.controls { gap: 1.4%; }
}
@media print { body { background: white; } .stage { box-shadow: none; width: 100%; } .save { display: none; } }
</style>
</head>
<body>
<div class="viewport">
<main class="stage">
<canvas id="infographic" width="1600" height="900" role="img" aria-label="梯度下降四個公式與二次函數的互動路徑。Four gradient descent equations and an interactive quadratic descent path.">瀏覽器須支援 HTML5 Canvas。Your browser needs HTML5 Canvas.</canvas>
<div class="controls" aria-label="學習率操作區 / Learning rate controls">
<label class="rate" for="eta">學習率 / Learning rate η <strong id="eta-value">0.10</strong></label>
<input id="eta" type="range" min="0.05" max="1.15" step="0.05" value="0.10" aria-describedby="eta-hint">
<span id="eta-hint" class="sr-only">山谷路徑與手算表同步更新。The path and calculation table update together.</span>
<div class="presets" aria-label="學習率範例 / Learning rate examples">
<button type="button" data-eta="0.10" aria-pressed="true">0.1 慢 / Slow</button>
<button type="button" data-eta="0.50" aria-pressed="false">0.5 一步 / One step</button>
<button type="button" data-eta="1.00" aria-pressed="false">1.0 震盪 / Cycle</button>
</div>
<button type="button" id="save" class="save">下載 / Export PNG</button>
</div>
<p id="summary" class="sr-only" aria-live="polite"></p>
</main>
</div>
<script>
(() => {
const canvas = document.getElementById('infographic');
const ctx = canvas.getContext('2d');
const slider = document.getElementById('eta');
const valueLabel = document.getElementById('eta-value');
const summary = document.getElementById('summary');
const buttons = [...document.querySelectorAll('[data-eta]')];
const C = { bg:'#f8f9f5', ink:'#192b36', muted:'#5e6d74', line:'#dbe4df', white:'#ffffff', teal:'#0a8178', tealSoft:'#e5f2ec', orange:'#e09a30', coral:'#d4544f', coralSoft:'#faeae6', navy:'#162e3d', pale:'#eef3f0' };
const uiFont = '"Noto Sans TC", "Microsoft JhengHei", "PingFang TC", sans-serif';
const mathFont = 'Georgia, "Times New Roman", serif';
function rounded(x,y,w,h,r,fill,stroke,lineWidth=1) {
ctx.beginPath(); ctx.roundRect(x,y,w,h,r);
if (fill) { ctx.fillStyle=fill; ctx.fill(); }
if (stroke) { ctx.strokeStyle=stroke; ctx.lineWidth=lineWidth; ctx.stroke(); }
}
function text(str,x,y,size=20,color=C.ink,weight=400,align='left',font=uiFont) {
ctx.fillStyle=color; ctx.font=`${weight} ${size}px ${font}`;
ctx.textAlign=align; ctx.textBaseline='middle'; ctx.fillText(str,x,y);
}
function line(x1,y1,x2,y2,color=C.line,width=1,dash=[]) {
ctx.save(); ctx.strokeStyle=color; ctx.lineWidth=width; ctx.setLineDash(dash);
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); ctx.restore();
}
function arrow(x1,y1,x2,y2,color=C.teal,width=3,head=12) {
line(x1,y1,x2,y2,color,width);
const a=Math.atan2(y2-y1,x2-x1); ctx.fillStyle=color;
ctx.beginPath(); ctx.moveTo(x2,y2); ctx.lineTo(x2-head*Math.cos(a-.5),y2-head*Math.sin(a-.5));
ctx.lineTo(x2-head*Math.cos(a+.5),y2-head*Math.sin(a+.5)); ctx.closePath(); ctx.fill();
}
function dot(x,y,r,fill=C.teal,stroke=C.white) {
ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.fillStyle=fill;ctx.fill();
ctx.strokeStyle=stroke;ctx.lineWidth=3;ctx.stroke();
}
function format(n) {
if (Math.abs(n)<1e-9) return '0';
return Number(n.toFixed(5)).toString();
}
function mathCard(x,number,title,formula,detailZh,detailEn,accent,formulaSize=31) {
rounded(x,161,355,142,16,C.white,C.line);
rounded(x+17,179,43,30,8,accent);
text(number,x+38,194,17,C.white,700,'center');
text(title,x+72,195,18,C.muted,700);
if (number==='01') {
text('min',x+20,237,37,C.ink,400,'left',mathFont);
text('w',x+43,259,17,C.ink,400,'left',mathFont);
text('f(w)',x+105,239,37,C.ink,400,'left',mathFont);
} else {
text(formula,x+20,239,formulaSize,C.ink,400,'left',mathFont);
}
text(detailZh,x+20,272,16,C.muted,400);
text(detailEn,x+20,290,14,C.muted,400);
}
function drawHeader() {
ctx.fillStyle=C.bg;ctx.fillRect(0,0,1600,900);
rounded(60,44,82,27,9,C.navy);
text('KP–1',101,58,16,C.white,700,'center');
text('梯度下降 · 蒙眼下山',60,105,47,C.ink,700);
text('GRADIENT DESCENT · BLIND DESCENT',755,79,24,C.teal,700);
text('感覺腳下坡度,往反方向走一小步。',755,117,22,C.muted,400);
line(60,143,1540,143,C.line,2);
mathCard(60,'01','任務 / Goal','min f(w)','對可微函數找低點','Minimize a differentiable function',C.navy,37);
mathCard(435,'02','位移 / Update','w⁽ᵏ⁺¹⁾ = w⁽ᵏ⁾ + Δw','新位置=舊位置+位移','New position = old position + step',C.navy,29);
mathCard(810,'03','安全步長 / Safe step','Δw = −(1/L)∇f(w⁽ᵏ⁾)','L 限制坡度變化;取步長 1/L','L bounds gradient change; step = 1/L',C.orange,29);
mathCard(1185,'04','實務更新 / In practice','w⁽ᵏ⁺¹⁾ = w⁽ᵏ⁾ − η∇f(w⁽ᵏ⁾)','負梯度指向下坡','Negative gradient points downhill',C.teal,28);
for (const x of [420,795,1170]) arrow(x+2,232,x+11,232,C.muted,2,6);
}
const p={left:125,right:945,top:408,bottom:715};
let plotLimit=5;
const mapX=w=>p.left+(w+plotLimit)/(2*plotLimit)*(p.right-p.left);
const mapY=f=>p.bottom-f/(plotLimit*plotLimit)*(p.bottom-p.top);
function drawChart(eta,ws) {
plotLimit=Math.max(5,Math.ceil(Math.max(...ws.map(Math.abs))*1.08));
rounded(60,320,920,452,18,C.white,C.line);
text('二次谷底 / Quadratic valley',88,351,25,C.ink,700);
text('每一步往哪裡走? / Which way do we step?',88,382,17,C.muted);
rounded(772,337,173,41,12,C.tealSoft);
text('f(w) = w²',858,358,24,C.teal,700,'center',mathFont);
const maxF=plotLimit*plotLimit;
for (const f of [0,maxF/3,2*maxF/3,maxF]) {
const y=mapY(f); line(p.left,y,p.right,y,C.line,1,f===0?[]:[4,7]);
text(String(Number(f.toFixed(1))),p.left-18,y,16,C.muted,400,'right',mathFont);
}
const half=Math.round(plotLimit/2);
for (const w of [-plotLimit,-half,0,half,plotLimit]) {
const x=mapX(w); line(x,p.bottom-4,x,p.bottom+5,C.muted,1.2);
text(String(w),x,p.bottom+20,17,C.muted,400,'center',mathFont);
}
arrow(p.left,p.bottom,p.right+6,p.bottom,C.muted,1.8,9);
arrow(mapX(0),p.bottom,p.left+(p.right-p.left)/2,p.top-6,C.muted,1.8,9);
text('w',p.right+8,p.bottom+23,20,C.ink,700,'left',mathFont);
ctx.save(); ctx.translate(79,559); ctx.rotate(-Math.PI/2);
text('f(w)',0,0,19,C.ink,700,'center',mathFont); ctx.restore();
// Draw the bowl and its minimum before drawing parameter updates.
ctx.beginPath();
for (let w=-plotLimit;w<=plotLimit+.015;w+=plotLimit/140) {
const x=mapX(w),y=mapY(w*w);
if (w===-plotLimit) ctx.moveTo(x,y); else ctx.lineTo(x,y);
}
ctx.strokeStyle=C.navy;ctx.lineWidth=4;ctx.stroke();
dot(mapX(0),mapY(0),7,C.orange);
text('谷底 / minimum · w* = 0',mapX(0)+17,mapY(0)-19,19,C.orange,700);
const isCycle=Math.abs(eta-1)<1e-9;
for (let k=0;k<3;k++) {
const x1=mapX(ws[k]),y1=mapY(ws[k]*ws[k]);
const x2=mapX(ws[k+1]),y2=mapY(ws[k+1]*ws[k+1]);
if (Math.hypot(x2-x1,y2-y1)<4) continue;
if (isCycle) {
const cy=Math.min(y1,y2)-43-k*24;
ctx.beginPath();ctx.moveTo(x1,y1-7);ctx.quadraticCurveTo((x1+x2)/2,cy,x2,y2-8);
ctx.strokeStyle=k%2?C.coral:C.teal;ctx.lineWidth=3;ctx.stroke();
const t=.94,dx=2*(1-t)*((x1+x2)/2-x1)+2*t*(x2-(x1+x2)/2),
dy=2*(1-t)*(cy-(y1-7))+2*t*((y2-8)-cy),a=Math.atan2(dy,dx);
ctx.fillStyle=k%2?C.coral:C.teal;
ctx.beginPath();ctx.moveTo(x2,y2-8);ctx.lineTo(x2-12*Math.cos(a-.5),y2-8-12*Math.sin(a-.5));ctx.lineTo(x2-12*Math.cos(a+.5),y2-8-12*Math.sin(a+.5));ctx.fill();
} else {
const dir=Math.atan2(y2-y1,x2-x1);
const ux=Math.cos(dir),uy=Math.sin(dir);
arrow(x1+ux*13,y1+uy*13,x2-ux*12,y2-uy*12,C.teal,3,10);
}
}
const grouped=[];
ws.forEach((w,k)=>{
let g=grouped.find(g=>Math.abs(g.w-w)<1e-7);
if(g) g.ks.push(k); else grouped.push({w,ks:[k]});
});
for (const g of grouped) {
const x=mapX(g.w),y=mapY(g.w*g.w);
dot(x,y,9,g.ks[0]===0?C.coral:C.teal);
const label=`k = ${g.ks.join(', ')}`;
let lx=x,ly=y-27;
if (g.w>3.7) lx=x-5;
if (g.w<-3.7) lx=x+8;
if (g.ks.length>1) ly=Math.abs(g.w)<.001?y-54:y+27;
text(label,lx,ly,18,g.ks[0]===0?C.coral:C.teal,700,'center',mathFont);
}
if (!isCycle) {
// The initial derivative is always +8. The arrows distinguish the gradient from the chosen displacement.
const x=mapX(4),y=mapY(16);
arrow(x+10,y-5,x+65,y-54,C.coral,3,10);
text('坡度 +8(上坡)',x-210,y-77,18,C.coral,700);
text('Gradient +8 (uphill)',x-210,y-55,16,C.coral,400);
} else {
rounded(272,581,525,69,12,C.coralSoft);
text('η = 1:4 ↔ −4,高度維持 16',534,601,22,C.coral,700,'center');
text('Cycles between 4 and −4; f(w) stays at 16',534,628,17,C.coral,400,'center');
}
text('箭頭=更新方向;圓點=每步位置 / Arrows = updates; dots = iterates',88,760,17,C.muted);
}
function drawTable(eta,ws) {
rounded(1000,320,540,452,18,C.white,C.line);
text('手算三步 / Three updates',1027,360,25,C.ink,700);
text(`f(w) = w² · w⁽⁰⁾ = 4 · η = ${eta.toFixed(2)} · ∇f = 2w`,1027,393,19,C.muted,400,'left',mathFont);
const x=[1033,1100,1207,1318,1432,1514];
const labels=['k','w⁽ᵏ⁾','∇f','η∇f','w⁽ᵏ⁺¹⁾','f(w⁽ᵏ⁾)'];
rounded(1026,419,488,43,8,C.pale);
labels.forEach((v,i)=>text(v,x[i],441,17,C.muted,700,i?'right':'left',mathFont));
for (let k=0;k<3;k++) {
const y=493+59*k;
if(k>0) line(1026,y-30,1514,y-30,C.line,1);
const w=ws[k];
const row=[k,format(w),format(2*w),format(eta*2*w),format(ws[k+1]),format(w*w)];
row.forEach((v,i)=>text(String(v),x[i],y,19,i===4?C.teal:C.ink,i===4?700:400,i?'right':'left',mathFont));
}
line(1026,643,1514,643,C.line,1);
const finalW=ws[3], finalF=finalW*finalW;
text('三步後 / After 3 steps',1027,675,18,C.muted,700);
text(`w⁽³⁾ = ${format(finalW)}`,1027,715,25,C.teal,700,'left',mathFont);
text(`f = ${format(finalF)}`,1514,715,25,C.teal,700,'right',mathFont);
let status;
if (eta<.5-1e-8) status='同側靠近 / Approaches from one side';
else if (Math.abs(eta-.5)<1e-8) status='一步到谷底 / One step to minimum';
else if (eta<1-1e-8) status='跨越但下降 / Oscillates with descent';
else if (Math.abs(eta-1)<1e-8) status='兩點往返 / Two-point cycle';
else status='越走越遠 / Diverges';
text(status,1027,749,17,eta>=1?C.coral:C.muted,700);
summary.textContent=`學習率 ${eta.toFixed(2)}。三步後 w 等於 ${format(finalW)},函數值等於 ${format(finalF)}。${status}。Learning rate ${eta.toFixed(2)}; after three steps, w is ${format(finalW)} and f is ${format(finalF)}.`;
}
function drawFooter(eta) {
text('適用範圍:此例 0 < η < 1 時下降,η = 1 震盪,η > 1 發散;1/L 是 L-smooth 目標的保守步長;非凸下降不保證局部或全域最小。',62,793,17,C.ink);
text('Scope: Here, 0 < η < 1 descends, η = 1 cycles, η > 1 diverges. For L-smooth functions, 1/L is safe; nonconvex descent does not guarantee a local or global minimum.',62,815,16,C.muted);
// This strip is covered by the native controls on screen but remains in exported PNGs.
rounded(60,831,1480,56,14,C.tealSoft);
text(`學習率 / Learning rate η = ${eta.toFixed(2)}`,92,859,20,C.teal,700);
line(420,859,1330,859,'#b7d3c6',8);
dot(420+eta/1.15*910,859,11,C.teal,C.white);
text('0',420,879,13,C.muted,400,'center',mathFont);
text('0.5',420+.5/1.15*910,879,13,C.muted,400,'center',mathFont);
text('1.0',420+1/1.15*910,879,13,C.muted,400,'center',mathFont);
text('1.15',1330,879,13,C.muted,400,'center',mathFont);
}
function draw() {
const eta=Number(slider.value);
const ws=[4]; for(let k=0;k<3;k++) ws.push(ws[k]-eta*2*ws[k]);
ctx.clearRect(0,0,1600,900);
drawHeader();drawChart(eta,ws);drawTable(eta,ws);drawFooter(eta);
valueLabel.textContent=eta.toFixed(2);
buttons.forEach(b=>b.setAttribute('aria-pressed',Math.abs(Number(b.dataset.eta)-eta)<1e-8?'true':'false'));
}
slider.addEventListener('input',draw);
buttons.forEach(b=>b.addEventListener('click',()=>{slider.value=b.dataset.eta;draw();}));
document.getElementById('save').addEventListener('click',()=>{
const a=document.createElement('a');a.download=`gradient-descent-eta-${Number(slider.value).toFixed(2)}.png`;
a.href=canvas.toDataURL('image/png');a.click();
});
if(document.fonts?.ready) document.fonts.ready.then(draw);
draw();
})();
</script>
</body>
</html>