From 1888412f31b0a2ceaa280e807a98da57b4f558cd Mon Sep 17 00:00:00 2001 From: int Date: Thu, 15 Oct 2026 01:58:22 +0300 Subject: [PATCH] Visualizer: stroke-feature network with spiking readout panel Co-Authored-By: Claude Opus 5.5 --- changes-2026-10-14.md | 7 +++ viz/app.html | 115 ++++++++++++++++++++++++++++++++++++++--- viz/aug_viz.html | 117 ++++++++++++++++++++++++++++++++++++++---- viz/export_data.py | 31 ++++++++++- 4 files changed, 251 insertions(+), 19 deletions(-) diff --git a/changes-2026-10-14.md b/changes-2026-10-14.md index b55cddb..c2146f3 100644 --- a/changes-2026-10-14.md +++ b/changes-2026-10-14.md @@ -327,3 +327,10 @@ KNP, validation: `fc_v_plain` 81,0%, `fc_v_augB` 79,5%. Симулятор (2000 - Колонки KNP на выученных признаках (validation): top-384 — 45,7–54,7% (молчат 96–308), top-512 — 22–37% (молчат 459–695); top-256 c40 — 62,2–**64,8%** (t4000 p0,012); top-256 t1000/t2500 p0,02 — 63,0/63,4%; top-128 — 50,9–55,9%; top-64 — 50% (один класс); top-256 c80 — 64,8/**65,1%**; top-128 c80 — 50,0/55,2%. Предел колонок на этих признаках ≈ 65%: плотнее — молчат, реже — теряют информацию. - `objects/dopamine_readout.py`: выход «собака − кошка», обучаемый локальным трёхфакторным правилом: на каждой картинке синапс меняется на η·e·(число спайков входа), e = цель − tanh(сумма) — знаковый дофамин; усреднение весов по ходу; 20 эпох; вход нормирован средним числом спайков (постоянное усиление синапса). На точных счётчиках слоя 1 (K=800, 10 шагов): линейно 69,2% (ridge 71,0%). - Нейрон-решатель без подбора порога по validation: смещение поднимается на thr/ACTIVE за шаг, порог k × медиана входа (k = 10…200 — результат не зависит), так что «дошёл до порога» = «сумма > 0». Ridge — **71,8%**; дофаминовые веса после обрезки |w| по 99,5-му перцентилю перед целочислением — **69,3–69,6%** (без обрезки 53,9%: редкие входы получают огромные веса, остальные округляются в 0). + +### 02:10 – 02:40: новая сеть на странице +- `viz/export_data.py --readout `: в данные страницы добавляются ядра признаков штрихов, веса спайкового выхода и точность на всём тесте (считается тем же кодом, что в экспериментах). Сеть: признаки coarse (масштаб 0,3842), выход обучен на `fcoarseA_val90000` (90 000 = 30 000 × 3 с аугментацией A), порог 16; веса `~/run/exp/readout_coarseA.npz`, для страницы `readout_coarseA_page.npz`. +- `viz/app.html`: панель «4 · Сеть штрихов» — 16 карт 7×7, спайки 10 выходных нейронов, ответ; сеть входит в строку «Итог всех сетей» (работают сдвиги и рисование). +- Тест MNIST (10 000, симулятор): колонки 92,2%, аугментация B 89,3%, **сеть штрихов 94,7%**. +- Проверка JS против Python (QuickJS, установлен в `~/anvenv` через pip): 105/105 одинаковых ответов на картинках галереи, признаки расходятся максимум на 1 из 255 (округление); весь скрипт страницы разбирается без ошибок. +- Опубликовано на тот же адрес: https://claude.ai/artifact/Vc1q1WCZuPY2QAHBSGtrKF (версия 2). diff --git a/viz/app.html b/viz/app.html index 33d6c70..66ea12f 100644 --- a/viz/app.html +++ b/viz/app.html @@ -102,6 +102,15 @@ table.conf td { color: var(--fg); } .kpi { display: flex; gap: 20px; flex-wrap: wrap; } .kpi div { display: flex; flex-direction: column; gap: 2px; } .kpi strong { font: 600 22px var(--f-mono); } +.rgrid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; } +@media (max-width: 760px) { .rgrid { grid-template-columns: 1fr; } } +.fmaps { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 4px; max-width: 420px; } +.fmaps canvas { width: 100%; aspect-ratio: 1; image-rendering: pixelated; border-radius: 3px; display: block; } +.rvotes { display: grid; grid-template-columns: 22px minmax(0, 1fr) 36px; gap: 4px 8px; align-items: center; } +.rvotes .dg { font: 600 13px var(--f-mono); text-align: right; } +.rvotes i { display: block; height: 12px; background: var(--spike); border-radius: 2px; min-width: 1px; } +.rvotes i.win { background: var(--ok); } +.rvotes b { font: 400 12px var(--f-mono); color: var(--muted); } @media (prefers-reduced-motion: reduce) { * { transition: none !important; } } @@ -173,6 +182,29 @@ table.conf td { color: var(--fg); } + +
@@ -336,17 +368,84 @@ function setImage(img, label, note) { $("srcnote").textContent = note; recompute(); if (!timer) { step = -1; play(); } } function renderAllNets() { - if (models.length < 2) { $("allnets").textContent = ""; return; } - $("allnets").textContent = "Итог всех сетей: " + models.map(m => { - const v = (m === model ? trace : simulate(image, m))[STEPS - 1].votes, a = argmax(v); - const mark = imageLabel === null || a < 0 ? "" : a === imageLabel ? " ✓" : " ✗"; - return `${m.name} — ${a < 0 ? "нет ответа" : a}${mark}`; - }).join(" · "); + const mark = a => imageLabel === null || a < 0 ? "" : a === imageLabel ? " ✓" : " ✗"; + const parts = models.map(m => { + const a = argmax((m === model ? trace : simulate(image, m))[STEPS - 1].votes); + return `${m.name} — ${a < 0 ? "нет ответа" : a}${mark(a)}`; + }); + if (R) { const a = readoutRun(image).answer; parts.push(`${R.name} — ${a < 0 ? "нет ответа" : a}${mark(a)}`); } + $("allnets").textContent = parts.length > 1 ? "Итог всех сетей: " + parts.join(" · ") : ""; +} + +// --- stroke features + spiking readout (mirrors features/stroke_features.py and readout/spiking_readout.py) --- +const R = DATA.readout || null; +function strokeFeatures(img) { + const K = R.kernels, nk = K.length, ks = K[0].length, h = ks >> 1, out = new Uint8Array(nk * 49); + const x = Float32Array.from(img, v => Math.fround(v / 255)); + for (let k = 0; k < nk; k++) { + const resp = new Float32Array(PIX); + for (let y = 0; y < 28; y++) for (let xx = 0; xx < 28; xx++) { + let s = 0; + for (let dy = 0; dy < ks; dy++) { const yy = y + dy - h; if (yy < 0 || yy > 27) continue; + for (let dx = 0; dx < ks; dx++) { const xs = xx + dx - h; if (xs >= 0 && xs < 28) s += K[k][dy][dx] * x[yy * 28 + xs]; } } + resp[y * 28 + xx] = Math.max(s, 0); + } + // max pooling 5×5, stride 4, padding 1 → 7×7 + for (let py = 0; py < 7; py++) for (let px = 0; px < 7; px++) { + let m = 0; + for (let dy = 0; dy < 5; dy++) for (let dx = 0; dx < 5; dx++) { + const yy = 4 * py + dy - 1, xs = 4 * px + dx - 1; + if (yy >= 0 && yy < 28 && xs >= 0 && xs < 28) m = Math.max(m, resp[yy * 28 + xs]); + } + out[k * 49 + py * 7 + px] = Math.floor(Math.min(255, m * 255 / R.scale)); + } + } + return out; +} +function readoutRun(img) { + const f = strokeFeatures(img), n = f.length, W = R.w, C = 10; + const st = new Float32Array(n), inc = Float32Array.from(f, v => Math.fround(v * Math.fround(1 / 255))); + const pot = new Float64Array(C), count = new Int32Array(C), first = new Int32Array(C).fill(STEPS); + for (let t = 0; t < STEPS; t++) { + if (t < ACTIVE) { + for (let c = 0; c < C; c++) pot[c] += W[n][c]; + for (let i = 0; i < n; i++) { st[i] = Math.fround(st[i] + inc[i]); if (st[i] >= 1) { st[i] = Math.fround(st[i] - 1); for (let c = 0; c < C; c++) pot[c] += W[i][c]; } } + } + for (let c = 0; c < C; c++) { if (pot[c] >= R.thr) { count[c]++; if (first[c] === STEPS) first[c] = t; pot[c] = 0; } pot[c] = Math.max(pot[c], 0); } + } + let best = -1, bs = -Infinity; + for (let c = 0; c < C; c++) { const s = count[c] * (STEPS + 1) - first[c]; if (s > bs) { bs = s; best = c; } } + if (Math.max(...count) === 0) best = -1; + return { features: f, count, answer: best }; +} +function renderReadout() { + if (!R || !image) return; + const r = readoutRun(image), box = $("fmaps"), on = hexRGB(css("--spike")), off = hexRGB(css("--cell-off")); + if (!box.children.length) for (let k = 0; k < 16; k++) { const cv = document.createElement("canvas"); cv.width = cv.height = 7; box.append(cv); } + [...box.children].forEach((cv, k) => { + const c = cv.getContext("2d"), id = c.createImageData(7, 7); + for (let i = 0; i < 49; i++) id.data.set([...mix(off, on, r.features[k * 49 + i] / 255), 255], i * 4); + c.putImageData(id, 0, 0); + cv.title = `${["0°", "22°", "45°", "67°", "90°", "112°", "135°", "157°"][k % 8]}, ${k < 8 ? "тонкий" : "толстый"} штрих`; + }); + const mx = Math.max(1, ...r.count); + $("rvotes").innerHTML = [...r.count].map((v, d) => `${d}${v}`).join(""); + $("ranswer").textContent = r.answer < 0 ? "–" : r.answer; + const vd = $("rverdict"); + if (r.answer < 0) { vd.className = "verdict none"; vd.textContent = "нет ответа"; } + else if (imageLabel === null) { vd.className = "verdict none"; vd.textContent = "итог"; } + else if (r.answer === imageLabel) { vd.className = "verdict ok"; vd.textContent = "верно"; } + else { vd.className = "verdict bad"; vd.textContent = `ошибка, это ${imageLabel}`; } +} +if (R) { + $("rbay").hidden = false; + $("racc").textContent = `Весь тест MNIST: ${(100 * R.accuracy).toFixed(1).replace(".", ",")}% верных ответов (симулятор, те же веса и правила нейронов, что в экспериментах).`; } function recompute() { if (!image) return; trace = simulate(image, model); renderAllNets(); + renderReadout(); firstWinner = -1; for (const s of trace) { const j = s.fired.indexOf(1); if (j >= 0) { firstWinner = j; break; } } digit = firstWinner >= 0 ? Math.floor(firstWinner / model.col) : (imageLabel ?? digit); @@ -454,8 +553,8 @@ function renderConf() { t.innerHTML = h; } -matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { renderStep(); renderMaps(); renderConf(); }); -new MutationObserver(() => { renderStep(); renderMaps(); renderConf(); }).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); +matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { renderStep(); renderMaps(); renderConf(); renderReadout(); }); +new MutationObserver(() => { renderStep(); renderMaps(); renderConf(); renderReadout(); }).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); resetDraw(); renderModels(); renderDigits(); renderConf(); const startIdx = gal.labels.findIndex((l, i) => l === 3); diff --git a/viz/aug_viz.html b/viz/aug_viz.html index ea509ce..b85000e 100644 --- a/viz/aug_viz.html +++ b/viz/aug_viz.html @@ -102,6 +102,15 @@ table.conf td { color: var(--fg); } .kpi { display: flex; gap: 20px; flex-wrap: wrap; } .kpi div { display: flex; flex-direction: column; gap: 2px; } .kpi strong { font: 600 22px var(--f-mono); } +.rgrid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; } +@media (max-width: 760px) { .rgrid { grid-template-columns: 1fr; } } +.fmaps { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 4px; max-width: 420px; } +.fmaps canvas { width: 100%; aspect-ratio: 1; image-rendering: pixelated; border-radius: 3px; display: block; } +.rvotes { display: grid; grid-template-columns: 22px minmax(0, 1fr) 36px; gap: 4px 8px; align-items: center; } +.rvotes .dg { font: 600 13px var(--f-mono); text-align: right; } +.rvotes i { display: block; height: 12px; background: var(--spike); border-radius: 2px; min-width: 1px; } +.rvotes i.win { background: var(--ok); } +.rvotes b { font: 400 12px var(--f-mono); color: var(--muted); } @media (prefers-reduced-motion: reduce) { * { transition: none !important; } } @@ -173,6 +182,29 @@ table.conf td { color: var(--fg); }
+ +
@@ -192,7 +224,7 @@ table.conf td { color: var(--fg); }