Day 9 的 noisy-OR 拉桿證明了一件事:互動比文字更能傳達直覺。 但那個只是一行公式的即時計算。
有些概念需要動畫才講得清:
這類東西最常見的做法是錄一段假動畫——預先算好幾個關鍵影格,或者乾脆用 CSS 動畫做出「看起來像在計算」的效果。我不要那樣。
理由不是潔癖,是假動畫會教錯東西。
具體例子:如果我用假動畫演示一個分類器掃描影像,我會挑好看的路徑、讓熱區剛好落在正確位置。學生看完的結論是「這個方法很準」。但真模型跑起來可能是:有些位置誤判、機率累積得比預期慢、某些樣式根本抓不到。那些「不完美」才是真正的教學內容——它們解釋了為什麼要調超參數、為什麼要正規化。
所以架構是:
離線(Node,建置期) → 前端(瀏覽器,執行期)
訓練模型、輸出參數檔 載入參數、做推論、畫動畫
scripts/train-*.js js/data/*-model.js + js/views/*-demo.js
前端只做推論,不做訓練。 訓練需要迭代、需要隨機性、可能要幾秒到幾分鐘;推論是純函式,毫秒級,而且每次結果一樣(可重現的教學素材)。
/* js/data/demo-model.js — 自動產生,勿手改 */
window.DEMO_MODEL = {
v: 1,
k: 3, // 核大小 3×3
beta: [0.42, -0.11, 0.38, /* … */],// 學到的權重
b0: -2.1,
mu: [/* 標準化用的平均 */], sd: [/* 標準差 */],
examples: [ { img: [/* 8×8 */], label: 1 }, /* … */ ],
};
老理由:file:// 不能 fetch(Day 6 起一貫)。script src 永遠可以。
順帶好處:這個檔可以直接被 Day 16 的 service worker 快取,離線也能跑動畫。
不用 TensorFlow、不用 PyTorch。這是個小模型(幾十個參數),純 JS 手寫,跑在 Node 上:
/* scripts/train-demo.js */
/* 1. 確定性隨機:訓練必須可重現,否則每次跑出不同模型、動畫也不同 */
function lcg(seed) {
let s = seed >>> 0;
return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = lcg(20260803);
/* 2. 合成資料:8×8 影像,正例含一個「十字」樣式 */
function makeImage(positive, rnd) {
const img = new Array(64).fill(0).map(() => rnd() * 0.3); // 背景噪聲
if (positive) {
const cx = 1 + Math.floor(rnd() * 5), cy = 1 + Math.floor(rnd() * 5);
for (let d = -1; d <= 1; d++) {
img[(cy + d) * 8 + cx] = 0.9 + rnd() * 0.1; // 垂直
img[cy * 8 + (cx + d)] = 0.9 + rnd() * 0.1; // 水平
}
}
return img;
}
/* 3. 把影像切成所有 3×3 子區塊(示例),每張影像是一個「袋」 */
function patches(img, k = 3, side = 8) {
const out = [];
for (let i = 0; i + k <= side; i++)
for (let j = 0; j + k <= side; j++) {
const p = [];
for (let a = 0; a < k; a++) for (let b = 0; b < k; b++) p.push(img[(i + a) * side + (j + b)]);
out.push({ i, j, x: p });
}
return out; // 6×6 = 36 個示例
}
模型用 noisy-OR 把「示例機率」聚合成「袋機率」:
p_ij = σ(β·x_ij + b0)
π_i = 1 − ∏_j (1 − p_ij)
也就是「至少一個示例為正,整袋就為正」。這正好對應 Day 9 那個 noisy-OR 拉桿在講的事——同一個概念,兩種呈現深度。
訓練用 EM + 梯度:
const sigmoid = z => 1 / (1 + Math.exp(-z));
function bagProb(bag, beta, b0) {
let logQ = 0; // log ∏(1-p) —— 在對數空間算避免下溢
const ps = bag.map(({ x }) => {
const p = sigmoid(x.reduce((s, v, k) => s + v * beta[k], 0) + b0);
logQ += Math.log(Math.max(1e-12, 1 - p));
return p;
});
return { pi: 1 - Math.exp(logQ), ps };
}
function train(bags, labels, { k = 3, iters = 400, lr = 0.5 } = {}) {
const dim = k * k;
/* 暖啟動:用正袋中對比最大的示例當初始方向(標準 MIL 初始化)
沒有這步會塌陷成「全部判負」——見下面的雷 */
let beta = warmStart(bags, labels, dim);
let b0 = -2;
for (let t = 0; t < iters; t++) {
const gB = new Array(dim).fill(0); let gb = 0;
for (let i = 0; i < bags.length; i++) {
const { pi, ps } = bagProb(bags[i], beta, b0);
const z = labels[i];
/* E-step:責任 γ_ij = 示例 j 對「這袋為正」的貢獻比重 */
/* M-step 梯度:∂logL/∂p_ij,正袋與負袋方向相反 */
const coef = z === 1 ? 1 / Math.max(1e-9, pi) : -1 / Math.max(1e-9, 1 - pi);
for (let j = 0; j < bags[i].length; j++) {
const p = ps[j];
const dpi_dp = (1 - pi) / Math.max(1e-9, 1 - p); // ∂π/∂p_j
const dp_dz = p * (1 - p); // sigmoid 導數
const g = coef * dpi_dp * dp_dz;
for (let m = 0; m < dim; m++) gB[m] += g * bags[i][j].x[m];
gb += g;
}
}
const n = bags.length;
for (let m = 0; m < dim; m++) beta[m] += lr * gB[m] / n;
b0 += lr * gb / n;
}
return { beta, b0 };
}
Math.log(Math.max(1e-12, 1 - p)) 與 Math.max(1e-9, pi) 是必要的數值保護(Day 13 學到的教訓:浮點下溢會靜靜地變成 NaN,而 NaN 會污染所有後續計算)。
/* js/views/demo-anim.js — 註冊成 Day 9 的 Interactive 遊戲 */
Interactive.games["conv-scan"] = function (el) {
const M = window.DEMO_MODEL;
if (!M) { el.innerHTML = "<p>模型資料未載入。</p>"; return; }
el.innerHTML = `
<div class="mg-title">🎬 卷積掃描:核逐格滑動,看整袋機率 π 怎麼累積</div>
<div class="anim-row">
<canvas class="anim-img" width="256" height="256" aria-label="輸入影像與掃描位置"></canvas>
<canvas class="anim-heat" width="256" height="256" aria-label="各位置的示例機率熱圖"></canvas>
</div>
<div class="anim-out">整袋為正機率 π = <b class="anim-pi">0.000</b>
<span class="anim-truth"></span></div>
<div class="mg-bar"><i class="anim-fill"></i></div>
<div class="anim-ctl">
<button class="btn anim-play">▶ 開始</button>
<button class="btn anim-step">單步</button>
<select class="anim-pick" aria-label="選擇範例"></select>
<label class="anim-speed">速度
<input type="range" min="1" max="10" value="5" aria-label="動畫速度"></label>
</div>
<p class="anim-note">紫色越深 = 該位置的示例機率 p 越高。π = 1 − ∏(1 − p),
所以只要有一個位置很確定,π 就會迅速接近 1。</p>`;
const cImg = el.querySelector(".anim-img").getContext("2d");
const cHeat = el.querySelector(".anim-heat").getContext("2d");
let ex = 0, pos = 0, timer = null, logQ = 0;
const SIDE = 8, K = M.k, STEPS = (SIDE - K + 1) ** 2, PX = 256 / SIDE;
/* 推論:跟訓練時完全同一條公式 */
const patchProb = (img, i, j) => {
let z = M.b0;
for (let a = 0; a < K; a++)
for (let b = 0; b < K; b++) {
const raw = img[(i + a) * SIDE + (j + b)];
const idx = a * K + b;
z += ((raw - M.mu[idx]) / M.sd[idx]) * M.beta[idx]; // 標準化後再算
}
return 1 / (1 + Math.exp(-z));
};
const drawImage = (img, hi) => {
for (let i = 0; i < SIDE; i++)
for (let j = 0; j < SIDE; j++) {
const v = Math.round(255 * (1 - img[i * SIDE + j]));
cImg.fillStyle = `rgb(${v},${v},${v})`;
cImg.fillRect(j * PX, i * PX, PX, PX);
}
if (hi) { // 綠框標出當前核位置
cImg.strokeStyle = "#16a34a"; cImg.lineWidth = 3;
cImg.strokeRect(hi.j * PX, hi.i * PX, K * PX, K * PX);
}
};
const step = () => {
const img = M.examples[ex].img;
const i = Math.floor(pos / (SIDE - K + 1)), j = pos % (SIDE - K + 1);
const p = patchProb(img, i, j);
logQ += Math.log(Math.max(1e-12, 1 - p));
const pi = 1 - Math.exp(logQ);
drawImage(img, { i, j });
/* 熱圖:該位置塗上與 p 成比例的紫 */
cHeat.fillStyle = `rgba(124,58,237,${p.toFixed(3)})`;
cHeat.fillRect(j * PX, i * PX, K * PX, K * PX);
el.querySelector(".anim-pi").textContent = pi.toFixed(3);
el.querySelector(".anim-fill").style.width = (pi * 100).toFixed(1) + "%";
pos++;
if (pos >= STEPS) { stop(); }
};
const reset = () => {
pos = 0; logQ = 0;
cHeat.clearRect(0, 0, 256, 256);
drawImage(M.examples[ex].img, null);
el.querySelector(".anim-pi").textContent = "0.000";
el.querySelector(".anim-fill").style.width = "0%";
el.querySelector(".anim-truth").textContent =
`(這張的真實標籤:${M.examples[ex].label ? "正例" : "負例"})`;
};
const stop = () => { clearInterval(timer); timer = null;
el.querySelector(".anim-play").textContent = "↻ 重播"; };
/* 尊重使用者的減少動態偏好(Day 19 的無障礙原則,這裡先做) */
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
el.querySelector(".anim-play").addEventListener("click", () => {
if (timer) return stop();
if (pos >= STEPS) reset();
if (reduce) { while (pos < STEPS) step(); return; } // 直接跳到結果,不做動畫
const speed = +el.querySelector(".anim-speed input").value;
timer = setInterval(step, 420 / speed);
el.querySelector(".anim-play").textContent = "⏸ 暫停";
});
el.querySelector(".anim-step").addEventListener("click", () => { if (pos < STEPS) step(); });
const sel = el.querySelector(".anim-pick");
sel.innerHTML = M.examples.map((e, i) =>
`<option value="${i}">範例 ${i + 1}(${e.label ? "正" : "負"})</option>`).join("");
sel.addEventListener("change", () => { ex = +sel.value; stop(); reset(); });
reset();
};
課文只要放一個 div:
<div class="minigame" data-game="conv-scan"></div>
Day 9 定的 Interactive.games 註冊機制在這裡直接複用——課文保持是純內容,動畫邏輯在 View 層。
1. 「單步」按鈕比「播放」重要。 自動播放很好看,但學生真正需要的是「停在這一格,看清楚這格算出什麼」。單步模式讓動畫變成可控的教具。
2. 一定要有負例。 我的範例選單裡正負各三個。學生看正例會覺得「π 衝到 1,好厲害」;看負例才會理解「π 停在 0.16,因為沒有任何位置很確定」。沒有負例的示範是廣告,不是教學。
模型塌陷:全部判負也有 50% 準確率。
第一版訓練跑完,模型把所有袋都判為負——正負各半的資料集,準確率剛好 50%,看起來「有在學但學不好」。實際上是退化解:b0 一路降到很負,beta 全部趨近 0,這在損失函數上是個穩定的局部極小。
兩招修好:
(x - mu) / sd):原始像素值域集中在 0–1,梯度訊號太弱。標準化後梯度尺度合理。beta(標準 MIL 初始化)。這給了一個「已經指向正確方向」的起點,避免落進退化解。修完之後袋層級訓練準確率 100%,而且學到的核長得像「偵測高對比中心」——可解釋,而且與資料生成方式吻合。這就是要跑真模型的價值:如果我做的是假動畫,我永遠不會知道塌陷這件事,也就學不到「MIL 需要暖啟動」這個真正的知識。
canvas 的兩層疊放。 影像與熱圖是兩個 canvas 疊在一起(CSS position: absolute),而不是畫在同一個上。理由:熱圖是累積的(每格畫上去就留著),影像是每格重畫的(要更新綠框)。分兩層就不需要「重畫影像時保留熱圖」的麻煩。
setInterval 沒清掉。 Day 6 的換課會重新渲染課文,但舊的 setInterval 還在跑——它持有的 canvas 已經從 DOM 移除,於是每 420ms 對一個孤兒節點畫圖。症狀是「換課幾次後頁面變慢」。修法是在 Interactive.activate() 前先清理:
/* interactive.js:註冊清理函式 */
const Interactive = {
_cleanups: [],
activate(root) {
this._cleanups.forEach(fn => { try { fn(); } catch {} });
this._cleanups = [];
/* … 掃描並啟用元件,遊戲可透過 Interactive.onCleanup(fn) 註冊清理 … */
},
onCleanup(fn) { this._cleanups.push(fn); },
};
/* 遊戲裡 */
Interactive.onCleanup(() => clearInterval(timer));
這是 Day 9 那個 dataset.ready 幂等守衛照顧不到的另一半:幂等防止重複綁定,但不會停掉已經在跑的計時器。有計時器/動畫迴圈的元件都需要顯式清理。
離線訓練的隨機性。 第一版用 Math.random(),每次訓練出來的模型都不同——動畫每次重新產生就變樣,而且沒辦法回頭重現「上次那個好看的結果」。改用固定種子的 LCG(跟 Day 14 亂序同一個手法),訓練變成確定性的:同一份腳本跑十次,輸出 byte-for-byte 相同。這也讓 Day 15 那招「重跑產生器 + git diff --exit-code」對它有效。
推論冒煙測試(與動畫用同一條公式,確保訓練與推論一致):
/* scripts/check-demo-model.js */
const M = require("../js/data/demo-model-node.js");
let ok = 0;
for (const ex of M.examples) {
let logQ = 0;
for (let i = 0; i + M.k <= 8; i++)
for (let j = 0; j + M.k <= 8; j++) logQ += Math.log(Math.max(1e-12, 1 - patchProb(M, ex.img, i, j)));
const pi = 1 - Math.exp(logQ);
const pred = pi > 0.5 ? 1 : 0;
console.log(`label=${ex.label} π=${pi.toFixed(3)} ${pred === ex.label ? "✓" : "✗"}`);
if (pred === ex.label) ok++;
}
console.log(`${ok}/${M.examples.length}`);
process.exit(ok === M.examples.length ? 0 : 1);
實測輸出:正例 π ≈ 0.997–1.000、負例 π ≈ 0.16,6/6 正確。這個測試同時驗了兩件事:模型真的有學到東西,而且前端推論公式與訓練公式一致(如果標準化寫錯,這裡立刻失敗)。
node scripts/train-demo.js # 重跑訓練,輸出 js/data/demo-model.js
git diff --exit-code js/data/ # 確定性檢查:同一份腳本應產生相同輸出
node scripts/check-demo-model.js # 6/6
node scripts/verify.js
python3 -m http.server 8901
瀏覽器:
今天的核心原則:
動畫必須跑真的計算。 假動畫會教錯東西,而且會讓你錯過真正的知識(我就是因為跑真模型才學到 MIL 的塌陷與暖啟動)。
架構上的三個要點:
window.X = {...} 的 JS,file:// 可用、SW 可快取。教學設計上:單步比播放重要、一定要有負例。
明天做效能與無障礙——把 Lighthouse 推到綠。主要工作是解決一個從 Day 6 就存在的問題:課文頁一次載入全部 20 個課文檔,1.5 MB。