Visualizer: stroke-feature network with spiking readout panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
int 2026-10-15 01:58:22 +03:00
parent 2f210b16f6
commit 1888412f31
4 changed files with 251 additions and 19 deletions

View file

@ -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 <npz>`: в данные страницы добавляются ядра признаков штрихов, веса спайкового выхода и точность на всём тесте (считается тем же кодом, что в экспериментах). Сеть: признаки 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).

View file

@ -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; } }
</style>
@ -173,6 +182,29 @@ table.conf td { color: var(--fg); }
</div>
</section>
<section class="panel stack" id="rbay" hidden>
<div class="row" style="justify-content:space-between">
<span class="label">4 · Сеть штрихов: детекторы штрихов → пулинг → 10 выходных нейронов</span>
<div class="answer">
<span class="label">Ответ</span>
<span class="big mono" id="ranswer">–</span>
<span class="verdict none" id="rverdict">нет ответа</span>
</div>
</div>
<p class="note">Другая сеть, не колонки. 16 детекторов (8 наклонов штриха × 2 толщины) смотрят на всю картинку; каждая карта сжимается до 7×7 клеток, где остаётся самый сильный отклик. Эти 784 значения спайкают так же, как пиксели, а 10 выходных нейронов AltAI считают спайки с целыми весами. Веса выхода обучены вне чипа на сдвинутых, повёрнутых и утолщённых копиях MNIST.</p>
<div class="rgrid">
<div class="stack" style="gap:6px">
<span class="label">Карты штрихов, 7×7</span>
<div class="fmaps" id="fmaps"></div>
</div>
<div class="stack" style="gap:6px">
<span class="label">Спайки выходных нейронов за 15 шагов</span>
<div id="rvotes" class="rvotes"></div>
</div>
</div>
<p class="note mono" id="racc"></p>
</section>
<section class="two">
<div class="panel stack">
<div class="row" style="justify-content:space-between">
@ -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) => `<span class="dg">${d}</span><i class="${d === r.answer ? "win" : ""}" style="width:${100 * v / mx}%"></i><b>${v}</b>`).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);

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,9 @@
"""Builds altai_viz.html: embeds trained weights, sample test images and full-test statistics into app.html.
usage: python3 export_data.py <out.html> <images.bin> <labels.target> "<name>|<dump_dir>|<inhib>" ...
usage: python3 export_data.py <out.html> <images.bin> <labels.target> "<name>|<dump_dir>|<inhib>" ... [--readout r.npz]
--readout: stroke features + spiking readout network (npz with w, thr, kernels, scale from
readout_coarseA_page.npz); its test accuracy is computed here with the same code as the experiments.
"""
import base64
import json
@ -19,7 +22,13 @@ def b64(arr, dtype):
def main():
out, images, labels_path, *specs = sys.argv[1:]
args = sys.argv[1:]
readout = None
if "--readout" in args:
i = args.index("--readout")
readout = args[i + 1]
del args[i:i + 2]
out, images, labels_path, *specs = args
total = len(open(labels_path).read().split())
imgs, labels = load_images(images, labels_path, total - 10000, 10000)
@ -50,7 +59,25 @@ def main():
pick += list(rng.choice(fixed, min(GALLERY_HARD, len(fixed)), replace=False))
pick += list(rng.choice(broken, min(GALLERY_HARD // 2, len(broken)), replace=False))
pick = sorted(set(int(i) for i in pick))
extra = {}
if readout:
sys.path.insert(0, "/mnt/c/knp/features")
sys.path.insert(0, "/mnt/c/knp/readout")
import stroke_features as sf
import spiking_readout as sr
r = np.load(readout)
sf.use_preset("coarse")
sf.P["scale"] = float(r["scale"])
p = sr.spiking_predict(sf.features(imgs.reshape(-1, 28, 28)), r["w"], float(r["thr"]))
confusion = np.zeros((CLASSES, CLASSES + 1), dtype=int)
for lab, pr in zip(labels, p):
confusion[lab, pr if pr >= 0 else CLASSES] += 1
extra["readout"] = {"name": "Штрихи + спайковый выход", "kernels": sf.KERNELS.astype(float).round(6).tolist(),
"scale": float(r["scale"]), "thr": float(r["thr"]), "w": r["w"].astype(int).tolist(),
"confusion": confusion.tolist(), "accuracy": float((p == labels).mean())}
print("readout", f"{(p == labels).mean():.4f}")
data = {
**extra,
"models": models,
"gallery": {"index": pick, "labels": labels[pick].tolist(), "pixels": b64(imgs[pick], np.uint8)},
}