iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 18

Day 18 — 教學動畫:canvas 上跑真的計算

  • 分享至 

  • xImage
  •  

今天要解的問題

Day 9 的 noisy-OR 拉桿證明了一件事:互動比文字更能傳達直覺。 但那個只是一行公式的即時計算。

有些概念需要動畫才講得清:

  • 卷積是什麼?(核在影像上滑動、逐格算內積)
  • EM 演算法的 E-step 在做什麼?(責任 γ 在示例間重新分配)
  • 梯度下降怎麼走?(在等高線上滾下去)

這類東西最常見的做法是錄一段假動畫——預先算好幾個關鍵影格,或者乾脆用 CSS 動畫做出「看起來像在計算」的效果。我不要那樣。

原則:動畫必須跑真的計算

理由不是潔癖,是假動畫會教錯東西

具體例子:如果我用假動畫演示一個分類器掃描影像,我會挑好看的路徑、讓熱區剛好落在正確位置。學生看完的結論是「這個方法很準」。但真模型跑起來可能是:有些位置誤判、機率累積得比預期慢、某些樣式根本抓不到。那些「不完美」才是真正的教學內容——它們解釋了為什麼要調超參數、為什麼要正規化。

所以架構是:

離線(Node,建置期)          →  前端(瀏覽器,執行期)
訓練模型、輸出參數檔              載入參數、做推論、畫動畫
scripts/train-*.js               js/data/*-model.js  +  js/views/*-demo.js

前端只做推論,不做訓練。 訓練需要迭代、需要隨機性、可能要幾秒到幾分鐘;推論是純函式,毫秒級,而且每次結果一樣(可重現的教學素材)。

為什麼參數檔是 JS 而不是 JSON

/* 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 會污染所有後續計算)。

前端推論 + canvas 動畫

/* 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,這在損失函數上是個穩定的局部極小。

兩招修好:

  1. 輸入標準化(x - mu) / sd):原始像素值域集中在 0–1,梯度訊號太弱。標準化後梯度尺度合理。
  2. 暖啟動:用正袋中「對比最大的示例」的方向初始化 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

瀏覽器:

  • 播放 → 綠框逐格移動、熱圖累積、π 上升。
  • 單步 → 一格一格。
  • 切到負例 → π 停在低值(這是教學重點,要確認真的低)。
  • 換課再回來 → 動畫重置正常、DevTools Performance 沒有殘留的計時器活動。
  • 系統開啟「減少動態效果」→ 點播放直接跳到最終結果,不做逐格動畫。

小結與明天預告

今天的核心原則:

動畫必須跑真的計算。 假動畫會教錯東西,而且會讓你錯過真正的知識(我就是因為跑真模型才學到 MIL 的塌陷與暖啟動)。

架構上的三個要點:

  1. 離線訓練 → 純資料檔 → 前端推論。訓練用確定性隨機(固定種子),輸出可重現。
  2. 資料檔輸出成 window.X = {...} 的 JS,file:// 可用、SW 可快取。
  3. 有計時器的互動元件需要顯式清理,幂等守衛不夠。

教學設計上:單步比播放重要、一定要有負例。

明天做效能與無障礙——把 Lighthouse 推到綠。主要工作是解決一個從 Day 6 就存在的問題:課文頁一次載入全部 20 個課文檔,1.5 MB


上一篇
Day 17 — 學習儀表板:把 localStorage 變成圖
下一篇
Day 19 — 效能與無障礙:把 Lighthouse 推到綠
系列文
知識圖譜 : 技能樹式學習歷程19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言