Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Claude
597e40afaf
Wiki: rewrite as a connected chain of pages with project engineering details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFVgZ8Nct8BEh9k7bjuRdw
2026-10-19 11:02:28 +00:00
int
69ad76bde2
Wiki: remove out-of-context project details, define terms
Drop the ON/OFF aside, the 8-replica anecdote and "next step" wording;
explain the per-line spike rule, define drawn-digit accuracy, separate
GoldenModel accuracy from counter verification, and phrase backend and
neuron-flag notes without project-internal references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFVgZ8Nct8BEh9k7bjuRdw
2026-10-19 08:42:01 +00:00
int
917808edda
Add project wiki built on SharedDocsLib
Definitions, system overview, architecture comparison and interactive
widgets: AltAILIF neuron, input coding, STDP, Altai core grid and
crossbar, quantisation, chip budget, and three recognisers (STDP
columns, native network, tick-trained spiking network) computed in
the browser with integer arithmetic on their real weights.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFVgZ8Nct8BEh9k7bjuRdw
2026-10-19 08:26:11 +00:00
6 changed files with 1541 additions and 0 deletions

2
wiki/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
assets/inject.generated.js
build/

24
wiki/README.md Normal file
View file

@ -0,0 +1,24 @@
# Вики «Нейроморфное распознавание на Алтае»
Документация проекта на [SharedDocsLib](https://git.snw.su/snw/SharedDocsLib): определения, устройство системы, сравнение архитектур и интерактивные виджеты.
## Сборка
```bash
uv pip install "git+https://git.snw.su/snw/SharedDocsLib.git@main"
python wiki/knp_wiki.py build # статический комплект в wiki/build: index.html, manifest.json, version, index.zip
python wiki/knp_wiki.py # live-preview на http://127.0.0.1:1234/index.html
```
## Устройство
| Файл | Что внутри |
|---|---|
| `knp_wiki.py` | страницы (`@Page`) и компоненты |
| `assets/widgets.js` | интерактивные виджеты; оживляют `<div data-widget="…">` после вставки страницы |
| `assets/widgets.css` | стили виджетов на переменных темы SharedDocsLib |
| `data/models.json` | веса трёх сетей (колонки STDP, нативная, импульсная по тактам), 24 тестовые цифры MNIST, точки графика «точность — энергия» |
Страницы вставляются через `innerHTML`, поэтому `<script>` внутри страницы не выполняется. Виджеты подключаются через `custom_js`: при сборке `data/models.json` и `assets/widgets.js` склеиваются в `assets/inject.generated.js`.
Все три сети в виджетах считаются целочисленно, так же как на чипе. Расчёт сверен с Python-эталоном на 10 000 тестовых цифрах MNIST: нативная сеть и импульсная сеть по тактам дают те же ответы и ту же точность.

89
wiki/assets/widgets.css Normal file
View file

@ -0,0 +1,89 @@
/* Виджеты вики: монохромная тема SharedDocsLib плюс три смысловых цвета (спайк, заряд, верно/неверно). */
:root {
--w-spike: #c8571b;
--w-pot: #1f7a83;
--w-good: #2f7d47;
--w-bad: #b83a3a;
}
:root[data-theme="dark"] {
--w-spike: #f08a3e;
--w-pot: #45bcc4;
--w-good: #5cc27a;
--w-bad: #ef6b6b;
}
.content .lead { max-width: 720px; color: var(--muted); font-size: 1.22em; line-height: 1.5; }
.content .w-card { margin: 1.4rem 0 1.8rem; padding: 18px; border: 1px solid var(--border); border-radius: 20px; background: var(--shell); display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.content .w-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.content .w-row.w-top { align-items: flex-start; }
.content .w-row.w-between { justify-content: space-between; }
.content .w-stack { display: flex; flex-direction: column; gap: 12px; }
.content .w-canvas { width: 100%; display: block; border-radius: 14px; background: var(--surface); }
.content .w-svg { width: 100%; max-width: 440px; display: block; margin: 0 auto; }
.content .w-svg.w-wide { max-width: none; min-width: 560px; }
.content .w-scroll { overflow-x: auto; }
.content .w-note { margin: 0; color: var(--muted); font-size: .95em; }
.content .w-mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; font-size: .9em; }
.content .w-cap { color: var(--muted); font-size: .85em; }
.content .w-btn, .content .w-seg button { font: inherit; font-size: .92em; color: var(--text); background: var(--control); border: 1px solid var(--border); border-radius: 12px; padding: 7px 13px; cursor: pointer; }
.content .w-btn:hover, .content .w-seg button:hover { border-color: var(--muted); }
.content .w-btn[aria-pressed="true"], .content .w-seg button[aria-pressed="true"] { background: var(--selection-bg); color: var(--selection-text); border-color: var(--selection-bg); }
.content .w-seg { display: inline-flex; flex-wrap: wrap; gap: 4px; }
.content .w-btn:focus-visible, .content .w-seg button:focus-visible, .content .w-card input:focus-visible { outline: 2px solid var(--w-pot); outline-offset: 2px; }
.content .w-ctl { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 150px; color: var(--muted); font-size: .88em; }
.content .w-ctl b { color: var(--text); font-family: ui-monospace, Menlo, Consolas, monospace; }
.content .w-card input[type="range"] { width: 100%; accent-color: var(--w-pot); }
.content .w-legend { display: flex; flex-wrap: wrap; gap: 16px; color: var(--muted); font-size: .85em; }
.content .w-legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; background: var(--c); }
.content .w-verdict { margin: 0; font-weight: 600; }
.content .w-verdict.ok, .content .ok { color: var(--w-good); }
.content .w-verdict.no, .content .no { color: var(--w-bad); }
.content .w-fig { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.content .w-fig.w-grow { flex: 1; min-width: 220px; }
.content .w-fig.w-win .w-g5 { outline: 2px solid var(--w-spike); outline-offset: 3px; border-radius: 6px; }
.content .w-g5 { display: grid; grid-template-columns: repeat(5, 24px); gap: 3px; }
.content .w-g5 div { width: 24px; height: 24px; border-radius: 4px; }
.content .w-px { width: 120px; height: 120px; image-rendering: pixelated; border-radius: 10px; background: #000; display: block; }
.content .w-pad { width: 220px; max-width: 100%; aspect-ratio: 1; border-radius: 14px; background: #000; touch-action: none; cursor: crosshair; display: block; }
.content .w-gal { display: flex; flex-wrap: wrap; gap: 6px; }
.content .w-gal button { position: relative; padding: 2px; border: 2px solid var(--border); border-radius: 10px; background: var(--shell); cursor: pointer; line-height: 0; }
.content .w-gal button[aria-pressed="true"] { border-color: var(--text); }
.content .w-gal canvas { width: 38px; height: 38px; image-rendering: pixelated; border-radius: 6px; }
.content .w-gal span { position: absolute; right: 5px; bottom: 4px; font: 600 10px ui-monospace, monospace; color: #fff; text-shadow: 0 0 2px #000; line-height: 1; }
.content .w-cmp { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
.content .w-panel { border: 1px solid var(--border); border-radius: 16px; padding: 14px; display: flex; flex-direction: column; gap: 10px; min-width: 0; background: var(--surface); }
.content .w-ans { font-size: 2em; font-weight: 700; line-height: 1; }
.content .w-badge { font-size: .8em; padding: 2px 9px; border-radius: 99px; border: 1px solid var(--border); color: var(--muted); }
.content .w-badge.ok { border-color: var(--w-good); }
.content .w-badge.no { border-color: var(--w-bad); }
.content .w-bars { display: grid; grid-template-columns: 14px 1fr 60px; gap: 3px 8px; align-items: center; font: .8em ui-monospace, monospace; }
.content .w-bars i { display: block; height: 9px; border-radius: 3px; background: var(--faint); min-width: 1px; }
.content .w-bars i.win { background: var(--text); }
.content .w-bars b { font-weight: 400; color: var(--muted); text-align: right; }
.content .w-energy { display: flex; flex-wrap: wrap; gap: 6px 14px; color: var(--muted); font-size: .85em; font-variant-numeric: tabular-nums; }
.content .w-race { display: flex; flex-direction: column; gap: 3px; width: 100%; }
.content .w-lane { display: grid; grid-template-columns: 16px 14px 1fr 54px; gap: 8px; align-items: center; padding: 2px 6px; border-radius: 8px; }
.content .w-lane.fire { background: color-mix(in srgb, var(--w-spike) 18%, transparent); }
.content .w-lane.lead { outline: 1.5px solid var(--w-good); }
.content .w-tank { width: 14px; height: 16px; border-radius: 3px; background: var(--control); position: relative; overflow: hidden; }
.content .w-tank i { position: absolute; left: 0; right: 0; bottom: 0; background: var(--w-pot); }
.content .w-dots { display: flex; gap: 3px; flex-wrap: wrap; }
.content .w-dots i { width: 9px; height: 9px; border-radius: 50%; background: var(--w-spike); }
.content .w-lane b { font-weight: 400; color: var(--muted); text-align: right; }
.content .w-kv { display: grid; grid-template-columns: minmax(110px, max-content) 1fr; gap: 8px 16px; font-size: .95em; }
.content .w-kv b { font-weight: 600; }
.content .w-xb { display: grid; grid-template-columns: minmax(84px, auto) repeat(4, minmax(46px, 1fr)); gap: 5px; align-items: center; }
.content .w-cell { height: 38px; border-radius: 10px; border: 1px solid var(--border); background: var(--control); color: var(--text); font: 600 .9em ui-monospace, monospace; cursor: pointer; }
.content .w-cell.pos { background: color-mix(in srgb, var(--w-pot) 22%, var(--control)); }
.content .w-cell.neg { background: color-mix(in srgb, var(--w-bad) 20%, var(--control)); }
.content .w-cnt { text-align: center; font: 600 .85em ui-monospace, monospace; padding: 4px 0; border-radius: 8px; }
.content .w-cnt.fire { background: var(--w-spike); color: #fff; }
.content .w-meter { display: flex; flex-direction: column; gap: 5px; }
.content .w-bar { height: 10px; border-radius: 99px; background: var(--control); position: relative; overflow: hidden; }
.content .w-bar::after { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; background: var(--text); opacity: .5; }
.content .w-bar i { position: absolute; inset: 0 auto 0 0; border-radius: 99px; background: var(--w-good); }
.content .w-bar i.no { background: var(--w-bad); }
@media (max-width: 640px) { .content .w-px { width: 96px; height: 96px; } .content .w-kv { grid-template-columns: 1fr; } }
@media print { .content .w-card { display: none; } }
@media (prefers-reduced-motion: reduce) { .content * { transition: none !important; } }
.content .next { margin-top: 2.4rem; padding-top: 1rem; border-top: 1px solid var(--border); color: var(--muted); }
.content .next a { font-weight: 600; }

429
wiki/assets/widgets.js Normal file
View file

@ -0,0 +1,429 @@
/* Интерактивные виджеты вики «Нейроморфное распознавание на Алтае».
* Страница вставляется через innerHTML, поэтому скрипты в ней не выполняются: здесь мы находим
* элементы <div data-widget="имя"> и оживляем их. Данные моделей (WIKI_DATA) добавляет build.py. */
(() => {
"use strict";
const W = {};
const redraws = new Set();
const cssv = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
const h = (tag, attrs = {}, ...kids) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") e.className = v; else if (k === "text") e.textContent = v;
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v); else e.setAttribute(k, v);
}
kids.flat().forEach((c) => c != null && e.append(c));
return e;
};
const NS = "http://www.w3.org/2000/svg";
const s = (tag, attrs = {}, text) => { const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; return e; };
const fmt = (x, d = 0) => Number(x).toLocaleString("ru-RU", { maximumFractionDigits: d, minimumFractionDigits: d });
const pct = (x, d = 1) => fmt(100 * x, d) + "%";
const rgb = (c) => { c = c.trim(); if (c.startsWith("#")) { const v = parseInt(c.slice(1), 16); return [v >> 16 & 255, v >> 8 & 255, v & 255]; } const m = c.match(/\d+/g); return m ? m.slice(0, 3).map(Number) : [128, 128, 128]; };
const mix = (a, b, t) => a.map((x, i) => Math.round(x + (b[i] - x) * t));
const C = () => ({ text: cssv("--text"), muted: cssv("--muted"), faint: cssv("--faint"), border: cssv("--border"), shell: cssv("--shell"), surface: cssv("--surface"), control: cssv("--control"),
spike: cssv("--w-spike"), pot: cssv("--w-pot"), good: cssv("--w-good"), bad: cssv("--w-bad") });
const dec = (b64, T) => { const b = atob(b64), u = new Uint8Array(b.length); for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return new T(u.buffer); };
function canvas(height, label) {
const c = h("canvas", { class: "w-canvas", "aria-label": label || "" });
c.dataset.h = height;
return c;
}
function fit(c) { const r = window.devicePixelRatio || 1, H = +c.dataset.h; c.style.height = H + "px"; c.width = Math.max(1, c.clientWidth * r); c.height = H * r; const g = c.getContext("2d"); g.setTransform(r, 0, 0, r, 0, 0); return g; }
const font = (size, weight = 400) => `${weight} ${size}px ${cssv("--accessibility-font-family") || "system-ui"}, system-ui, sans-serif`;
const seg = (items, onPick, initial = 0) => {
const box = h("div", { class: "w-seg", role: "group" });
items.forEach((label, i) => box.append(h("button", { type: "button", "aria-pressed": String(i === initial), text: label, onclick: () => { [...box.children].forEach((b, k) => b.setAttribute("aria-pressed", String(k === i))); onPick(i); } })));
return box;
};
const btn = (label, onclick) => h("button", { type: "button", class: "w-btn", text: label, onclick });
const slider = (label, min, max, value, onInput, show = (v) => v) => {
const out = h("b", { text: show(value) });
const input = h("input", { type: "range", min, max, value });
input.addEventListener("input", () => { out.textContent = show(+input.value); onInput(+input.value); });
return h("label", { class: "w-ctl" }, h("span", {}, label, " ", out), input);
};
const note = (text) => h("p", { class: "w-note", text });
const card = (el) => { el.classList.add("w-card"); return el; };
const alive = (el, fn) => { const r = () => { if (el.isConnected) fn(); else redraws.delete(r); }; redraws.add(r); return r; };
function grid28(cv, f) { const g = cv.getContext("2d"), id = g.createImageData(28, 28); for (let p = 0; p < 784; p++) id.data.set([...f(p), 255], p * 4); g.putImageData(id, 0, 0); }
/* ---------- общие модели (если данные встроены) ---------- */
const D = globalThis.WIKI_DATA || null;
const M = {};
if (D) {
M.gal = { px: dec(D.gallery.pixels, Uint8Array), labels: D.gallery.labels, idx: D.gallery.idx };
M.cols = { ...D.cols, W: dec(D.cols.w, Int8Array) };
M.nat = { ...D.native, W1: dec(D.native.w1, Int8Array), B1: dec(D.native.b1, Int16Array), W2: dec(D.native.w2, Int16Array), B2: dec(D.native.b2, Int16Array) };
M.snn = { ...D.snn, W1: dec(D.snn.w1, Int8Array), B1: dec(D.snn.b1, Int8Array), W2: dec(D.snn.w2, Int8Array), B2: dec(D.snn.b2, Int8Array) };
}
const galImg = (k) => M.gal.px.subarray(k * 784, (k + 1) * 784);
/* ---------- обработка на хосте: нормализация 20×20 + центр масс, выпрямление, усреднение ---------- */
const HOST = (() => {
function comShift(o) { let m = 0, mx = 0, my = 0; for (let y = 0; y < 28; y++) for (let x = 0; x < 28; x++) { const v = o[y * 28 + x]; m += v; mx += v * x; my += v * y; } if (!m) return o; const dx = Math.floor(13.5 - mx / m + .5), dy = Math.floor(13.5 - my / m + .5), r = new Uint8Array(784); for (let y = 0; y < 28; y++) for (let x = 0; x < 28; x++) { const sx = x - dx, sy = y - dy; if (sx >= 0 && sx < 28 && sy >= 0 && sy < 28) r[y * 28 + x] = o[sy * 28 + sx]; } return r; }
function bbox(g) { let x0 = 28, y0 = 28, x1 = -1, y1 = -1, n = 0; for (let y = 0; y < 28; y++) for (let x = 0; x < 28; x++) if (g[y * 28 + x] > 16) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); n++; } return n < 2 ? null : [x0, y0, x1, y1]; }
function normalize(g) { const b = bbox(g); if (!b) return new Uint8Array(784); const [x0, y0, x1, y1] = b, cw = x1 - x0 + 1, ch = y1 - y0 + 1, r = 20 / Math.max(cw, ch), nw = Math.max(1, Math.floor(cw * r + .5)), nh = Math.max(1, Math.floor(ch * r + .5)), ox = (28 - nw) >> 1, oy = (28 - nh) >> 1, o = new Uint8Array(784);
for (let y = 0; y < nh; y++) for (let x = 0; x < nw; x++) { const xx = Math.min(cw - 1, Math.max(0, (x + .5) * cw / nw - .5)), yy = Math.min(ch - 1, Math.max(0, (y + .5) * ch / nh - .5)), lx = Math.floor(xx), ly = Math.floor(yy), hx = Math.min(lx + 1, cw - 1), hy = Math.min(ly + 1, ch - 1), ax = xx - lx, ay = yy - ly, at = (a, c) => g[(a + y0) * 28 + c + x0];
o[(y + oy) * 28 + x + ox] = Math.floor((at(ly, lx) * (1 - ax) + at(ly, hx) * ax) * (1 - ay) + (at(hy, lx) * (1 - ax) + at(hy, hx) * ax) * ay + .5); }
return comShift(o); }
function deskew(g) { let m = 0, cx = 0, cy = 0; for (let y = 0; y < 28; y++) for (let x = 0; x < 28; x++) { const v = g[y * 28 + x]; m += v; cx += v * x; cy += v * y; } if (!m) return g; cx /= m; cy /= m; let m02 = 0, m11 = 0; for (let y = 0; y < 28; y++) for (let x = 0; x < 28; x++) { const v = g[y * 28 + x]; m02 += v * (y - cy) ** 2; m11 += v * (y - cy) * (x - cx); } m02 /= m; m11 /= m; if (m02 < 1e-3) return g;
const a = m11 / m02, o = new Uint8Array(784), px = (x, y) => (x >= 0 && x < 28 && y >= 0 && y < 28) ? g[y * 28 + x] : 0;
for (let y = 0; y < 28; y++) for (let x = 0; x < 28; x++) { const u = (x + .5) + a * (y + .5) - a * cy - .5, lx = Math.floor(u), ax = u - lx; o[y * 28 + x] = Math.round(px(lx, y) * (1 - ax) + px(lx + 1, y) * ax); } return o; }
function box(src, sw, sh, dw, dh) { const ax = (sn, dn) => { const sc = sn / dn, w = []; for (let j = 0; j < dn; j++) { const a = j * sc, b = (j + 1) * sc, ws = []; for (let i = Math.floor(a); i < Math.ceil(b); i++) { const o = Math.min(b, i + 1) - Math.max(a, i); if (o > 0) ws.push([i, o / sc]); } w.push(ws); } return w; };
const wx = ax(sw, dw), wy = ax(sh, dh), tmp = new Float64Array(dw * sh), o = new Uint8Array(dw * dh);
for (let y = 0; y < sh; y++) for (let j = 0; j < dw; j++) { let t = 0; for (const [i, w] of wx[j]) t += src[y * sw + i] * w; tmp[y * dw + j] = t; }
for (let j = 0; j < dh; j++) for (let x = 0; x < dw; x++) { let t = 0; for (const [i, w] of wy[j]) t += tmp[i * dw + x] * w; o[j * dw + x] = Math.min(255, Math.round(t)); } return o; }
function normArea(g) { const b = bbox(g); if (!b) return new Uint8Array(784); const [x0, y0, x1, y1] = b, cw = x1 - x0 + 1, ch = y1 - y0 + 1, r = 20 / Math.max(cw, ch), nw = Math.max(1, Math.floor(cw * r + .5)), nh = Math.max(1, Math.floor(ch * r + .5)), crop = new Uint8Array(cw * ch);
for (let y = 0; y < ch; y++) for (let x = 0; x < cw; x++) crop[y * cw + x] = g[(y + y0) * 28 + x + x0];
const sm = box(crop, cw, ch, nw, nh), o = new Uint8Array(784), ox = (28 - nw) >> 1, oy = (28 - nh) >> 1; for (let y = 0; y < nh; y++) for (let x = 0; x < nw; x++) o[(y + oy) * 28 + x + ox] = sm[y * nw + x]; return comShift(o); }
const prepare = (raw) => { const n = normalize(raw), d = deskew(n); return { normalized: n, deskewed: d, final: normArea(d) }; };
return { prepare, normalize };
})();
/* ---------- три сети: точный целочисленный расчёт ---------- */
function runCols(img) {
const m = M.cols, n = m.thr.length, col = m.col, Wt = m.W, STEPS = 15, ACT = 10, st = new Float32Array(784), raster = [];
for (let t = 0; t < STEPS; t++) { const r = new Uint8Array(784); if (t < ACT) for (let p = 0; p < 784; p++) { st[p] += Math.fround(img[p] * Math.fround(1 / 255)); if (st[p] >= 1) { r[p] = 1; st[p] -= 1; } } raster.push(r); }
let pot = new Float64Array(n), prevR = new Uint8Array(784), prevF = new Uint8Array(n), first = -1, inSp = 0, syn = 0, fired = 0; const votes = new Int32Array(10);
for (let t = 0; t < STEPS; t++) {
for (let c = 0; c < 10; c++) for (let k = 0; k < col; k++) if (prevF[c * col + k]) { votes[c]++; break; }
let kF = 0; for (let j = 0; j < n; j++) kF += prevF[j];
let rs = 0; for (let p = 0; p < 784; p++) rs += prevR[p]; inSp += rs; syn += rs * n;
const f = new Uint8Array(n);
for (let j = 0; j < n; j++) { let v = Math.round(pot[j]); const row = j * 784; for (let p = 0; p < 784; p++) if (prevR[p]) v += Wt[row + p]; if (m.inhib && kF) v -= m.inhib * (kF - prevF[j]); if (v >= m.thr[j]) { f[j] = 1; v = 0; fired++; if (first < 0) first = j; } pot[j] = Math.max(v, 0); }
prevR = raster[t]; prevF = f;
}
let b = -1, bv = 0; for (let i = 0; i < 10; i++) if (votes[i] > bv) { bv = votes[i]; b = i; }
return { answer: b, votes: Array.from(votes), first, energy: { ticks: STEPS, input: inSp, neurons: fired, synaptic: syn } };
}
function runNative(img) {
const m = M.nat, T = 32, NH = 256, r = new Int32Array(784), p = new Float64Array(NH), hc = new Int32Array(NH);
let inSp = 0, syn = 0;
const nz1 = m.nz1 || (m.nz1 = Int32Array.from({ length: 784 }, (_, i) => { let c = 0; for (let j = 0; j < NH; j++) if (m.W1[i * NH + j]) c++; return c; }));
for (let t = 0; t < T; t++) {
for (let i = 0; i < 784; i++) { r[i] += img[i]; if (r[i] >= 255) { r[i] -= 255; inSp++; syn += nz1[i]; const row = i * NH; for (let j = 0; j < NH; j++) p[j] += m.W1[row + j]; } }
for (let j = 0; j < NH; j++) { p[j] += m.B1[j]; if (p[j] >= 32) { hc[j]++; p[j] -= 32; } if (p[j] < 0) p[j] = 0; }
}
const pos = new Int32Array(10), neg = new Int32Array(10);
for (let c = 0; c < 10; c++) for (let g = 0; g < 8; g++) { let cp = 0, cn = 0; for (let k = g * 32; k < (g + 1) * 32; k++) { const w = m.W2[k * 10 + c]; if (w > 0) cp += hc[k] * w; else if (w < 0) cn -= hc[k] * w; } if (g === 0) { cp += T * Math.max(m.B2[c], 0); cn += T * Math.max(-m.B2[c], 0); } pos[c] += Math.floor(cp / m.threshold); neg[c] += Math.floor(cn / m.threshold); }
let hid = 0; for (let j = 0; j < NH; j++) { hid += hc[j]; let nz = 0; for (let c = 0; c < 10; c++) if (m.W2[j * 10 + c]) nz++; syn += hc[j] * nz; }
const score = Array.from(pos, (v, c) => v - neg[c]); let best = 0; for (let c = 1; c < 10; c++) if (score[c] > score[best]) best = c;
return { answer: best, score, pos, neg, hidden: hc, energy: { ticks: T, input: inSp, neurons: hid + pos.reduce((a, b) => a + b, 0) + neg.reduce((a, b) => a + b, 0), synaptic: syn } };
}
function runSNN(img) {
const m = M.snn, T = m.ticks, NH = 256, th = m.th_h, to = m.th_o;
const tin = new Int8Array(784).fill(-1); for (let p = 0; p < 784; p++) if (img[p] > 32) tin[p] = Math.min(T - 1, Math.floor((255 - img[p]) * T / 224));
const v1 = new Int32Array(NH), v2 = new Int32Array(10), cnt = new Int32Array(10), first = new Int32Array(10).fill(99);
const nz1 = m.nz1 || (m.nz1 = Int32Array.from({ length: 784 }, (_, i) => { let c = 0; for (let j = 0; j < NH; j++) if (m.W1[i * NH + j]) c++; return c; }));
const nz2 = m.nz2 || (m.nz2 = Int32Array.from({ length: NH }, (_, j) => { let c = 0; for (let k = 0; k < 10; k++) if (m.W2[j * 10 + k]) c++; return c; }));
let prev = new Uint8Array(NH), syn = 0, hid = 0, inSp = 0; const trace = [];
for (let t = 0; t <= T; t++) {
const s2 = new Uint8Array(10);
for (let k = 0; k < 10; k++) { let v = v2[k] + m.B2[k]; for (let j = 0; j < NH; j++) if (prev[j]) v += m.W2[j * 10 + k]; if (v >= to) { s2[k] = 1; v -= to; cnt[k]++; if (first[k] === 99) first[k] = t; } v2[k] = Math.max(v, 0); }
for (let j = 0; j < NH; j++) if (prev[j]) syn += nz2[j];
const sin = new Uint8Array(784), s1 = new Uint8Array(NH);
if (t < T) {
for (let p = 0; p < 784; p++) if (tin[p] === t) { sin[p] = 1; inSp++; syn += nz1[p]; }
for (let j = 0; j < NH; j++) { let v = v1[j] + m.B1[j]; for (let p = 0; p < 784; p++) if (sin[p]) v += m.W1[p * NH + j]; if (v >= th) { s1[j] = 1; v -= th; hid++; } v1[j] = Math.max(v, 0); }
}
trace.push({ sin, s1, v1: Int32Array.from(v1), s2, v2: Int32Array.from(v2), cnt: Int32Array.from(cnt), first: Int32Array.from(first) });
prev = s1;
}
const lead = (st) => { let a = -1, b = -1; for (let k = 0; k < 10; k++) { if (!st.cnt[k]) continue; const key = st.cnt[k] * 1000 - st.first[k]; if (key > b) { b = key; a = k; } } return a; };
const ans = lead(trace[T]); let settled = T; for (let t = T; t >= 0; t--) { if (lead(trace[t]) !== ans) break; settled = t; }
return { answer: ans, trace, tin, settled, energy: { ticks: T + 1, input: inSp, neurons: hid + cnt.reduce((a, b) => a + b, 0), synaptic: syn } };
}
/* ---------- рисовалка: штрихи перерисовываются пером MNIST в 28×28 ---------- */
function drawPad(onImage) {
const cv = h("canvas", { class: "w-pad", width: 280, height: 280, "aria-label": "Нарисуйте цифру мышью или пальцем" });
const g = cv.getContext("2d"); let strokes = [], drawing = false, last = null;
const clear = () => { g.fillStyle = "#000"; g.fillRect(0, 0, 280, 280); strokes = []; };
const pos = (e) => { const r = cv.getBoundingClientRect(); return [(e.clientX - r.left) * 280 / r.width, (e.clientY - r.top) * 280 / r.height]; };
const line = (a, b) => { g.strokeStyle = "#fff"; g.lineWidth = 16; g.lineCap = g.lineJoin = "round"; g.beginPath(); g.moveTo(...a); g.lineTo(...b); g.stroke(); };
cv.addEventListener("pointerdown", (e) => { e.preventDefault(); drawing = true; last = pos(e); cv.setPointerCapture(e.pointerId); if (cv.dataset.shown) { clear(); delete cv.dataset.shown; } strokes.push([last]); line(last, last); });
cv.addEventListener("pointermove", (e) => { if (!drawing) return; e.preventDefault(); const p = pos(e); strokes[strokes.length - 1].push(p); line(last, p); last = p; });
const end = () => { if (!drawing) return; drawing = false; render(); };
cv.addEventListener("pointerup", end); cv.addEventListener("pointercancel", end);
function render() {
const pts = strokes.flat(); if (!pts.length) return;
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]), x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
const pen = 2.5, sc = (20 - pen) / Math.max(x1 - x0, y1 - y0, 20), sm = document.createElement("canvas"); sm.width = sm.height = 28;
const c = sm.getContext("2d"); c.fillStyle = "#000"; c.fillRect(0, 0, 28, 28); c.strokeStyle = "#fff"; c.lineWidth = pen; c.lineCap = c.lineJoin = "round";
const tx = (x) => 14 + (x - (x0 + x1) / 2) * sc, ty = (y) => 14 + (y - (y0 + y1) / 2) * sc;
for (const st of strokes) { c.beginPath(); c.moveTo(tx(st[0][0]), ty(st[0][1])); for (const p of (st.length > 1 ? st : [st[0], st[0]])) c.lineTo(tx(p[0]), ty(p[1])); c.stroke(); }
const d = c.getImageData(0, 0, 28, 28).data, img = new Uint8Array(784); for (let i = 0; i < 784; i++) img[i] = d[i * 4];
onImage(img, null);
}
function show(img) { clear(); const t = document.createElement("canvas"); t.width = t.height = 28; grid28(t, (p) => [img[p], img[p], img[p]]); g.imageSmoothingEnabled = false; g.drawImage(t, 0, 0, 280, 280); cv.dataset.shown = "1"; }
clear();
return { el: cv, clear, show };
}
function gallery(onPick, count = 24) {
const box = h("div", { class: "w-gal", role: "group", "aria-label": "Тестовые цифры MNIST" });
for (let k = 0; k < Math.min(count, M.gal.labels.length); k++) {
const cv = h("canvas", { width: 28, height: 28 }); grid28(cv, (p) => { const v = galImg(k)[p]; return [v, v, v]; });
box.append(h("button", { type: "button", "aria-label": `Цифра ${M.gal.labels[k]}, тест №${M.gal.idx[k]}`, onclick: () => { [...box.children].forEach((b, i) => b.setAttribute("aria-pressed", String(i === k))); onPick(galImg(k), M.gal.labels[k]); } }, cv, h("span", { text: M.gal.labels[k] })));
}
return box;
}
/* =================== ВИДЖЕТЫ =================== */
/* 1. Нейрон AltAILIF */
W.neuron = (el) => {
card(el); const P = { w: 3, t: 10, l: 0, r: .4 }; let v = 0, hist = [], auto = true, diff = false, pending = 0; const N = 60;
const cv = canvas(210, "Уровень нейрона по тактам");
function tick() { let inp = pending; pending = 0; if (auto && Math.random() < P.r) inp++; v += inp * P.w; if (P.l) v -= Math.min(P.l, Math.abs(v)) * Math.sign(v); let f = false; if (v >= P.t) { f = true; v = diff ? v - P.t : 0; } if (v < 0) v = 0; hist.push({ v, inp, f }); if (hist.length > N) hist.shift(); draw(); }
function draw() { if (!cv.isConnected) return; const g = fit(cv), Wd = cv.clientWidth, H = 210, top = 22, bot = 24, c = C(), mx = Math.max(P.t * 1.4, ...hist.map((q) => q.v), 1), y = (q) => top + (H - top - bot) * (1 - q / mx), dx = Wd / (N - 1), off = N - hist.length;
g.clearRect(0, 0, Wd, H); g.setLineDash([5, 4]); g.strokeStyle = c.muted; g.beginPath(); g.moveTo(0, y(P.t)); g.lineTo(Wd, y(P.t)); g.stroke(); g.setLineDash([]);
g.fillStyle = c.muted; g.font = font(12); g.fillText("порог " + P.t, 6, y(P.t) - 5);
g.strokeStyle = c.pot; g.lineWidth = 2.2; g.beginPath(); hist.forEach((q, i) => { const x = (i + off) * dx; i ? g.lineTo(x, y(q.v)) : g.moveTo(x, y(q.v)); }); g.stroke(); g.lineWidth = 1;
hist.forEach((q, i) => { const x = (i + off) * dx; if (q.f) { g.fillStyle = c.spike; g.fillRect(x - 2, 3, 4, 14); } if (q.inp) { g.fillStyle = c.text; g.fillRect(x - 1.5, H - 17, 3, 12); } }); }
el.append(cv, h("div", { class: "w-legend" }, h("span", { style: "--c: var(--w-pot)", text: "уровень" }), h("span", { style: "--c: var(--w-spike)", text: "выходной спайк" }), h("span", { style: "--c: var(--text)", text: "входной спайк" })),
h("div", { class: "w-row" }, btn("Послать спайк", () => { pending++; if (!auto) tick(); }), seg(["Поток спайков", "Вручную"], (i) => { auto = i === 0; }), seg(["После выстрела: в ноль", "минус порог"], (i) => { diff = i === 1; })),
h("div", { class: "w-row" }, slider("Вес связи", 1, 10, 3, (x) => P.w = x), slider("Порог", 2, 30, 10, (x) => { P.t = x; draw(); }), slider("Утечка за такт", 0, 3, 0, (x) => P.l = x), slider("Частота входа", 5, 100, 40, (x) => P.r = x / 100, (x) => x + "%")));
for (let i = 0; i < N; i++) tick();
const timer = setInterval(() => { if (!el.isConnected) return clearInterval(timer); if (auto) tick(); }, 220);
alive(el, draw);
};
/* 2. Кодирование входа: частота против времени */
W.coding = (el) => {
card(el); let mode = 0, T = 8; const bright = [250, 200, 150, 100, 60, 34];
const cv = canvas(220, "Спайки шести пикселей по тактам");
const info = note("");
function draw() { if (!cv.isConnected) return; const g = fit(cv), Wd = cv.clientWidth, H = 220, c = C(), L = 52, R = 70, rowH = H / bright.length, tw = (Wd - L - R) / T; g.clearRect(0, 0, Wd, H); let total = 0;
bright.forEach((b, r) => { const y = r * rowH; g.fillStyle = `rgb(${b},${b},${b})`; g.fillRect(10, y + 6, 30, rowH - 12); g.strokeStyle = c.border; g.strokeRect(10, y + 6, 30, rowH - 12); let n = 0, acc = 0;
for (let t = 0; t < T; t++) { g.fillStyle = c.surface; g.fillRect(L + t * tw + 1, y + 8, tw - 2, rowH - 16); let fire = false;
if (mode === 0) { acc += b; if (acc >= 255) { acc -= 255; fire = true; } } else { const tt = b > 32 ? Math.min(T - 1, Math.floor((255 - b) * T / 224)) : -1; fire = tt === t; }
if (fire) { n++; g.fillStyle = c.spike; g.fillRect(L + t * tw + tw * .25, y + 8, Math.max(2, tw * .5), rowH - 16); } }
total += n; g.fillStyle = c.text; g.font = font(13, 600); g.fillText(`${n} сп.`, Wd - R + 10, y + rowH / 2 + 5); });
info.textContent = mode === 0 ? `Частотный код: яркость = как часто стреляет пиксель. За ${T} тактов эти 6 пикселей дали ${total} спайков. Нужно много тактов, чтобы различить оттенки.` :
`Временной код: каждый пиксель стреляет один раз, ярче — раньше. ${total} спайков на 6 пикселей при любом числе тактов. Информация в том, КОГДА пришёл спайк.`; }
el.append(cv, info, h("div", { class: "w-row" }, seg(["Частотный код", "Временной код"], (i) => { mode = i; draw(); }), slider("Тактов", 2, 32, 8, (x) => { T = x; draw(); })));
draw(); alive(el, draw);
};
/* 3. Сложение сигналов в нейроне */
W.synsum = (el) => {
card(el); const ins = [{ w: 4, d: 1 }, { w: 4, d: 1 }, { w: 4, d: 3 }], TH = 10, T = 7;
const cv = canvas(220, "Сложение входов по тактам"), verdict = h("p", { class: "w-verdict" });
function draw() { if (!cv.isConnected) return; const g = fit(cv), Wd = cv.clientWidth, H = 220, c = C(), L = 70, R = Wd - 16, tx = (t) => L + (t + .5) * (R - L) / T; g.clearRect(0, 0, Wd, H);
let v = 0, fire = -1; const pot = []; for (let t = 0; t < T; t++) { ins.forEach((q) => { if (q.d === t) v += q.w; }); if (v < 0) v = 0; if (fire < 0 && v >= TH) fire = t; pot.push(v); }
g.font = font(11); g.fillStyle = c.muted; g.textAlign = "center"; for (let t = 0; t < T; t++) g.fillText("такт " + t, tx(t), 12);
ins.forEach((q, i) => { const y = 24 + i * 26; g.textAlign = "left"; g.fillStyle = c.muted; g.font = font(12); g.fillText("вход " + (i + 1), 4, y + 14); g.strokeStyle = c.border; g.beginPath(); g.moveTo(L, y + 10); g.lineTo(R, y + 10); g.stroke(); g.fillStyle = c.text; g.fillRect(tx(0) - 2, y + 2, 4, 16);
if (q.d) { g.setLineDash([3, 3]); g.strokeStyle = c.muted; g.beginPath(); g.moveTo(tx(0), y + 10); g.lineTo(tx(q.d), y + 10); g.stroke(); g.setLineDash([]); }
g.fillStyle = q.w >= 0 ? c.good : c.bad; g.beginPath(); g.arc(tx(q.d), y + 10, 8, 0, 7); g.fill(); g.fillStyle = c.shell; g.font = font(10, 700); g.textAlign = "center"; g.fillText((q.w > 0 ? "+" : "") + q.w, tx(q.d), y + 14); });
const pT = 112, pB = H - 24, y = (q) => pB - Math.min(q, TH * 1.6) / (TH * 1.6) * (pB - pT), bw = (R - L) / T * .5;
g.setLineDash([5, 4]); g.strokeStyle = c.muted; g.beginPath(); g.moveTo(L, y(TH)); g.lineTo(R, y(TH)); g.stroke(); g.setLineDash([]); g.fillStyle = c.muted; g.textAlign = "left"; g.fillText("порог", 4, y(TH) + 4);
pot.forEach((q, t) => { g.fillStyle = t === fire ? c.spike : c.pot; g.globalAlpha = t === fire ? 1 : .7; g.fillRect(tx(t) - bw / 2, y(q), bw, pB - y(q)); g.globalAlpha = 1; g.fillStyle = c.text; g.font = font(11, 600); g.textAlign = "center"; g.fillText(q, tx(t), y(q) - 4); });
verdict.className = "w-verdict " + (fire >= 0 ? "ok" : "no"); verdict.textContent = fire >= 0 ? `Выстрел на такте ${fire}: сошлось ${pot[fire]} при пороге ${TH}.` : `Молчит: максимум ${Math.max(...pot)} при пороге ${TH}.`; }
const ctl = h("div", { class: "w-stack" });
ins.forEach((q, i) => ctl.append(h("div", { class: "w-row" }, h("span", { class: "w-mono", text: "вход " + (i + 1) }), slider("вес", -6, 8, q.w, (x) => { q.w = x; draw(); }, (x) => (x > 0 ? "+" : "") + x), slider("задержка, тактов", 0, 5, q.d, (x) => { q.d = x; draw(); }))));
el.append(cv, verdict, ctl, note("Одинаковая задержка — спайки приходят вместе и складываются: нейрон становится детектором совпадения. Отрицательный вес — тормозящая связь, она может отменить выстрел."));
draw(); alive(el, draw);
};
/* 4. STDP: вход до выстрела крепнет, после — слабеет */
W.stdp = (el) => {
card(el); const T = 30, TH = 10, WIN = 5, NM = ["А", "Б", "В", "Г"]; let w, run = null, n = 0, timer = null;
const cv = canvas(290, "Четыре входа нейрона и правило STDP"), say = note(""), info = h("span", { class: "w-mono" });
const reset = () => { w = [3.5, 3.5, 3.5, 3.5]; n = 0; trial(); };
function trial() { const base = 8 + Math.floor(Math.random() * 3), sp = [0, 1, 2].map((i) => [base + i * 2 + Math.floor(Math.random() * 2)]); sp.push([2 + Math.floor(Math.random() * (T - 4))]); if (Math.random() < .5) sp[3].push(2 + Math.floor(Math.random() * (T - 4)));
let v = 0; const pot = [], fires = []; for (let t = 0; t < T; t++) { sp.forEach((q, i) => { if (q.includes(t)) v += w[i]; }); if (v >= TH) { fires.push(t); pot.push(TH); v = 0; } else pot.push(v); }
const before = w.slice(), marks = sp.map((q) => q.map((t) => { let b = null; fires.forEach((f) => { const d = t - f; if (b === null || Math.abs(d) < Math.abs(b)) b = d; }); if (b === null) return { t, k: "far", dw: 0 }; if (b <= 0 && b >= -WIN) return { t, k: "help", dw: .6 * Math.exp(b / 3) }; if (b > 0 && b <= WIN) return { t, k: "late", dw: -.5 * Math.exp(-b / 3) }; return { t, k: "far", dw: 0 }; }));
marks.forEach((m, i) => m.forEach((q) => { w[i] = Math.max(0, Math.min(6, w[i] + q.dw)); })); n++; run = { sp, pot, fires, marks, before }; draw(); }
function draw() { if (!cv.isConnected || !run) return; const g = fit(cv), Wd = cv.clientWidth, H = 290, c = C(), L = 48, R = Wd - 74, tx = (t) => L + (t + .5) * (R - L) / T; g.clearRect(0, 0, Wd, H);
const col = (k) => k === "help" ? c.good : k === "late" ? c.bad : c.faint;
run.sp.forEach((q, i) => { const y = 12 + i * 28; g.strokeStyle = c.border; g.beginPath(); g.moveTo(L, y + 10); g.lineTo(R, y + 10); g.stroke(); g.fillStyle = c.text; g.font = font(12, 600); g.textAlign = "left"; g.fillText("вход " + NM[i], 2, y + 14);
run.marks[i].forEach((m) => { g.fillStyle = col(m.k); g.fillRect(tx(m.t) - 2.5, y, 5, 20); }); const a = w[i], b = run.before[i]; g.fillStyle = a > b + .01 ? c.good : a < b - .01 ? c.bad : c.muted; g.font = font(12, 600); g.fillText("вес " + fmt(a, 1), R + 8, y + 14); });
const pT = 132, pB = H - 40, y = (q) => pB - q / TH * (pB - pT);
run.fires.forEach((f) => { g.globalAlpha = .1; g.fillStyle = c.good; g.fillRect(tx(f - WIN) - 4, 6, tx(f) - tx(f - WIN) + 4, pB - 6); g.fillStyle = c.bad; g.fillRect(tx(f), 6, tx(f + WIN) - tx(f), pB - 6); g.globalAlpha = 1; });
g.setLineDash([5, 4]); g.strokeStyle = c.muted; g.beginPath(); g.moveTo(L, y(TH)); g.lineTo(R, y(TH)); g.stroke(); g.setLineDash([]);
g.strokeStyle = c.pot; g.lineWidth = 2.2; g.beginPath(); run.pot.forEach((q, t) => { t ? g.lineTo(tx(t), y(q)) : g.moveTo(tx(t), y(q)); }); g.stroke(); g.lineWidth = 1;
run.fires.forEach((f) => { g.fillStyle = c.spike; g.fillRect(tx(f) - 3, pB + 6, 6, 14); g.font = font(11, 600); g.textAlign = "center"; g.fillText("выстрел", tx(f), pB + 34); });
info.textContent = "показов: " + n; const abc = (w[0] + w[1] + w[2]) / 3;
say.textContent = !run.fires.length ? "Входы не дотянули нейрон до порога, учиться не на чем. Покажите ещё раз." : n < 3 ? "А, Б, В пришли почти вместе и вместе дотянули нейрон до порога — их связи крепнут. Г — шум: если он пришёл после выстрела, его связь слабеет." :
`После ${n} показов связи узора А-Б-В ≈ ${fmt(abc, 1)}, связь шума Г = ${fmt(w[3], 1)}. Нейрон сам выучил узор, без учителя.`; }
el.append(cv, h("div", { class: "w-legend" }, h("span", { style: "--c: var(--w-good)", text: "помог — крепнет" }), h("span", { style: "--c: var(--w-bad)", text: "опоздал — слабеет" }), h("span", { style: "--c: var(--faint)", text: "далеко — без изменений" })),
h("div", { class: "w-row" }, btn("Показать ещё раз", trial), seg(["Пауза", "Показывать подряд"], (i) => { clearInterval(timer); timer = i ? setInterval(() => { if (!el.isConnected) return clearInterval(timer); trial(); }, 900) : null; }), btn("Сначала", reset), info), say);
reset(); alive(el, draw);
};
/* 5. Соревнование нейронов (WTA) */
W.wta = (el) => {
card(el); const pat = (q) => q.replace(/\s/g, "").split("").map(Number), X = pat("10001 01010 00100 01010 10001"), O = pat("01110 10001 10001 10001 01110");
const cos = (a, b) => { let t = 0, na = 0, nb = 0; for (let i = 0; i < a.length; i++) { t += a[i] * b[i]; na += a[i] ** 2; nb += b[i] ** 2; } return na && nb ? t / Math.sqrt(na * nb) : 0; };
let comp = true, wts, shown = 0, last = null, winner = -1;
const init = () => { wts = [0, 1].map(() => Array.from({ length: 25 }, () => .25 + Math.random() * .2)); shown = 0; last = null; winner = -1; };
const present = () => { const im = Math.random() < .5 ? X : O, sc = wts.map((w) => w.reduce((s2, x, i) => s2 + x * im[i], 0) / (w.reduce((a, b) => a + b, 0) || 1)), win = sc[0] >= sc[1] ? 0 : 1; wts.forEach((w, n2) => { if (!comp || n2 === win) for (let i = 0; i < 25; i++) w[i] += .25 * (im[i] - w[i]); }); last = im; winner = comp ? win : -1; shown++; };
const g5 = (vals, f) => { const d = h("div", { class: "w-g5" }); vals.forEach((v) => d.append(h("div", { style: `background:${f(v)}` }))); return d; };
const box = h("div", { class: "w-row w-top" }), say = note(""), info = h("span", { class: "w-mono" });
function draw() { box.replaceChildren(h("div", { class: "w-fig" }, h("span", { class: "w-cap", text: "показали" }), g5(last || new Array(25).fill(0), (v) => v ? "var(--text)" : "var(--control)")),
...wts.map((w, n2) => { const f = h("div", { class: "w-fig" + (winner === n2 ? " w-win" : "") }, h("span", { class: "w-cap", text: "веса нейрона " + (n2 + 1) }), g5(w, (v) => `color-mix(in srgb, var(--w-pot) ${Math.round(Math.max(0, Math.min(1, v)) * 95)}%, var(--control))`)); return f; }));
const d = (a, b) => a.reduce((s2, x, i) => s2 + Math.abs(x - b[i]), 0) / 25, split = (d(wts[0], X) < .25 && d(wts[1], O) < .25) || (d(wts[0], O) < .25 && d(wts[1], X) < .25);
info.textContent = "показано: " + shown;
say.textContent = !shown ? "Покажем вперемешку крестики и нолики. Рамка отмечает победителя — учится только он." : split ? "Нейроны поделили работу: один выучил крестик, другой нолик. Никто не говорил им, кому что." : !comp && shown >= 10 ? "Без соревнования оба нейрона выучили одинаковую смесь крестика и нолика и не различают картинки." : "Покажите ещё."; }
el.append(box, h("div", { class: "w-row" }, seg(["С соревнованием", "Без соревнования"], (i) => { comp = i === 0; init(); draw(); }), btn("Показать 10 картинок", () => { for (let i = 0; i < 10; i++) present(); draw(); }), btn("Сначала", () => { init(); draw(); }), info), say);
init(); draw();
};
/* 7. Кроссбар ядра */
W.crossbar = (el) => {
card(el); const cyc = [0, 2, 3, 4, 5, 6, 8, -3, -5]; let Wt = [[4, 2, 0, 3], [4, 0, 5, 3], [3, 4, 0, 0], [0, 4, 6, 2], [0, 0, 3, 5], [-3, 0, 0, -5]], on = [true, true, false, false, false, false], sums = null;
const tb = h("div", { class: "w-xb" }), say = note("");
function draw() { tb.replaceChildren(); tb.append(h("span", { class: "w-cap", text: "линия ↓ / нейрон →" })); for (let n2 = 0; n2 < 4; n2++) tb.append(h("span", { class: "w-cap", text: "нейрон " + (n2 + 1) }));
Wt.forEach((r, a) => { tb.append(h("button", { type: "button", class: "w-btn", "aria-pressed": String(on[a]), text: "линия " + (a + 1), onclick: () => { on[a] = !on[a]; sums = null; draw(); } }));
r.forEach((w, n2) => tb.append(h("button", { type: "button", class: "w-cell" + (w > 0 ? " pos" : w < 0 ? " neg" : ""), text: w ? (w > 0 ? "+" + w : String(w)) : "·", "aria-label": `вес ${w}`, onclick: () => { r[n2] = cyc[(cyc.indexOf(w) + 1) % cyc.length]; sums = null; draw(); } }))); });
tb.append(h("span", { class: "w-cap", text: "разных весов" }));
const bad = []; for (let n2 = 0; n2 < 4; n2++) { const k = new Set(Wt.map((r) => Math.abs(r[n2])).filter(Boolean)).size; if (k > 4) bad.push(n2 + 1); tb.append(h("span", { class: "w-cnt " + (k > 4 ? "no" : "ok"), text: k + " из 4" })); }
tb.append(h("span", { class: "w-cap", text: "сумма за такт" })); for (let n2 = 0; n2 < 4; n2++) tb.append(h("span", { class: "w-cnt" + (sums && sums[n2] >= 10 ? " fire" : ""), text: sums ? String(sums[n2]) : "—" }));
say.textContent = bad.length ? `У нейрона ${bad.join(", ")} больше 4 разных величин веса — такой столбец чип не примет.` : sums ? (sums.some((v) => v >= 10) ? "Выстрелили нейроны, набравшие порог 10: каждый сложил веса из строк, по которым пришёл спайк." : "Никто не набрал порог 10.") : "Включите строки (входящие спайки), нажмите «Такт». Нажатие на клетку меняет вес."; }
el.append(tb, h("div", { class: "w-row" }, btn("Такт: сложить и проверить порог", () => { sums = [0, 1, 2, 3].map((n2) => Wt.reduce((t, r, a) => t + (on[a] ? r[n2] : 0), 0)); draw(); })), say);
draw();
};
/* 8. Квантование весов */
W.quant = (el) => {
card(el); const N = 12; let w = [], K = 4; const cv = canvas(230, "Дробные и целые веса нейрона"), info = h("p", { class: "w-mono" }), verdict = h("p", { class: "w-verdict" });
const fresh = () => { w = Array.from({ length: N }, () => { const v = Math.random() * 2 - 1; return Math.abs(v) < .05 ? .05 * Math.sign(v || 1) : v; }); };
function pal(k) { const m = w.map(Math.abs).sort((a, b) => a - b); let lv = Array.from({ length: k }, (_, i) => m[Math.min(m.length - 1, Math.floor((i + .5) * m.length / k))]); for (let it = 0; it < 25; it++) { const sm = new Array(k).fill(0), ct = new Array(k).fill(0); m.forEach((x) => { let b = 0; lv.forEach((l, i) => { if (Math.abs(x - l) < Math.abs(x - lv[b])) b = i; }); sm[b] += x; ct[b]++; }); lv = lv.map((l, i) => ct[i] ? sm[i] / ct[i] : l); } return [...new Set(lv.map((v) => +v.toFixed(6)))].sort((a, b) => a - b); }
function draw() { if (!cv.isConnected) return; const p = pal(K), mx = Math.max(...w.map(Math.abs)), toInt = (v) => Math.round(v / mx * 255), q = w.map((x) => { let b = p[0]; p.forEach((l) => { if (Math.abs(Math.abs(x) - l) < Math.abs(Math.abs(x) - b)) b = l; }); return Math.sign(x) * b; });
const g = fit(cv), Wd = cv.clientWidth, H = 230, c = C(), mid = H / 2, amp = H / 2 - 22, bw = (Wd - 20) / N; g.clearRect(0, 0, Wd, H); g.strokeStyle = c.border; g.beginPath(); g.moveTo(10, mid); g.lineTo(Wd - 10, mid); g.stroke();
p.forEach((l) => [1, -1].forEach((sg) => { const y = mid - sg * l / mx * amp; g.setLineDash([3, 4]); g.strokeStyle = c.pot; g.globalAlpha = .5; g.beginPath(); g.moveTo(10, y); g.lineTo(Wd - 10, y); g.stroke(); g.globalAlpha = 1; g.setLineDash([]); }));
w.forEach((x, i) => { const X0 = 10 + i * bw; g.fillStyle = c.control; g.fillRect(X0 + bw * .12, Math.min(mid, mid - x / mx * amp), bw * .34, Math.abs(x / mx * amp)); g.fillStyle = q[i] >= 0 ? c.pot : c.bad; g.fillRect(X0 + bw * .5, Math.min(mid, mid - q[i] / mx * amp), bw * .34, Math.abs(q[i] / mx * amp));
g.fillStyle = c.text; g.font = font(10, 600); g.textAlign = "center"; g.fillText(toInt(q[i]), X0 + bw * .67, q[i] >= 0 ? mid - q[i] / mx * amp - 5 : mid - q[i] / mx * amp + 13); });
const err = Math.abs(w.reduce((a, b) => a + b, 0) - q.reduce((a, b) => a + b, 0)) / w.reduce((a, b) => a + Math.abs(b), 0);
info.textContent = `ступеньки на чипе: ${p.map(toInt).join(", ")} · ошибка суммы ${pct(err)}`; verdict.className = "w-verdict " + (K <= 4 ? "ok" : "no"); verdict.textContent = K <= 4 ? "Такой нейрон можно загрузить на Алтай." : `${K} разных величин: точнее, но чип такой нейрон не примет.`; }
el.append(cv, info, verdict, h("div", { class: "w-row" }, slider("Сколько разных величин разрешено", 1, 10, 4, (x) => { K = x; draw(); }), btn("Другой нейрон", () => { fresh(); draw(); })),
note("Серые столбики — дробные веса после обучения, цветные — целые веса на чипе. Ошибка — насколько изменится сумма, если все входы выстрелят разом."));
fresh(); draw(); alive(el, draw);
};
/* 9. Влезет ли сеть в чип */
W.fit = (el) => {
card(el); const LIM = { n: 8192, w: 4, p: 32767 }, PRE = [["Нативная сеть цифр", { n: 416, w: 3, p: 12864 }], ["Импульсная сеть по тактам", { n: 266, w: 3, p: (D && D.snn.max_potential) || 900 }], ["Детекторы + выход (кошки/собаки)", { n: 39458, w: 110, p: 569761 }]];
let F = { ...PRE[0][1] }; const meters = h("div", { class: "w-stack" }), verdict = h("p", { class: "w-verdict" });
const lg = (v, lo, hi) => Math.round(Math.exp(Math.log(lo) + (Math.log(hi) - Math.log(lo)) * v / 100)), il = (v, lo, hi) => Math.round(100 * (Math.log(v) - Math.log(lo)) / (Math.log(hi) - Math.log(lo)));
const sN = h("input", { type: "range", min: 0, max: 100 }), sW = h("input", { type: "range", min: 1, max: 120 }), sP = h("input", { type: "range", min: 0, max: 100 });
function meter(name, v, lim) { const ok = v <= lim; return h("div", { class: "w-meter" }, h("div", { class: "w-row w-between" }, h("span", { text: name }), h("span", { class: "w-mono " + (ok ? "ok" : "no"), text: `${fmt(v)} / предел ${fmt(lim)}` })), h("div", { class: "w-bar" }, h("i", { class: ok ? "ok" : "no", style: `width:${Math.min(v / lim * 50, 100)}%` }))); }
function draw() { meters.replaceChildren(meter("Нейронов", F.n, LIM.n), meter("Разных весов на нейрон", F.w, LIM.w), meter("Максимальный уровень потенциала", F.p, LIM.p)); sN.value = il(F.n, 100, 131072); sW.value = F.w; sP.value = il(Math.max(F.p, 100), 100, 1e6);
const bad = []; if (F.n > LIM.n) bad.push("нейронов больше, чем в прототипе"); if (F.w > LIM.w) bad.push("слишком много разных весов"); if (F.p > LIM.p) bad.push("уровень переполняет 16 бит"); verdict.className = "w-verdict " + (bad.length ? "no" : "ok"); verdict.textContent = bad.length ? "Не влезет: " + bad.join(", ") + "." : "Влезает в прототип Алтая."; }
sN.oninput = () => { F.n = lg(+sN.value, 100, 131072); draw(); }; sW.oninput = () => { F.w = +sW.value; draw(); }; sP.oninput = () => { F.p = lg(+sP.value, 100, 1e6); draw(); };
el.append(seg(PRE.map((p) => p[0]), (i) => { F = { ...PRE[i][1] }; draw(); }), meters, verdict,
h("div", { class: "w-row" }, h("label", { class: "w-ctl" }, h("span", { text: "Нейронов" }), sN), h("label", { class: "w-ctl" }, h("span", { text: "Разных весов" }), sW), h("label", { class: "w-ctl" }, h("span", { text: "Уровень" }), sP)),
note("Черта посередине полосы — предел чипа. Оценка по трём ограничениям; настоящий размещатель проверяет ещё входящие линии ядер."));
draw();
};
/* 10. Схема трёх архитектур */
/* 11. Одна цифра — три сети */
W.compare = (el) => {
card(el); if (!D) { el.append(note("Данные моделей не загружены.")); return; }
const pad = drawPad((img) => run(img, null)), out = h("div", { class: "w-cmp" }), raw = h("canvas", { class: "w-px", width: 28, height: 28 }), prep = h("canvas", { class: "w-px", width: 28, height: 28 });
let lastRaw = null, lastLabel = null;
function panel(name, r, label, extra) {
const ok = r.answer === label, vd = label == null ? "" : r.answer < 0 ? "молчит" : ok ? "верно" : `ошибка, это ${label}`;
return h("div", { class: "w-panel" }, h("div", { class: "w-row w-between" }, h("b", { text: name }), h("span", { class: "w-ans", text: r.answer < 0 ? "–" : r.answer }), h("span", { class: "w-badge " + (label == null ? "" : ok ? "ok" : "no"), text: vd })), extra,
h("div", { class: "w-energy" }, h("span", { text: `тактов ${r.energy.ticks}` }), h("span", { text: `входных спайков ${fmt(r.energy.input)}` }), h("span", { text: `синапт. событий ${fmt(r.energy.synaptic)}` })));
}
const bars = (vals, ans, fmtv) => { const mx = Math.max(1, ...vals.map((v) => Math.max(v, 0))); return h("div", { class: "w-bars" }, ...vals.flatMap((v, d) => [h("span", { text: d }), h("i", { class: d === ans ? "win" : "", style: `width:${100 * Math.max(v, 0) / mx}%` }), h("b", { text: fmtv(d) })])); };
function run(img, label) {
lastRaw = img; lastLabel = label; const p = HOST.prepare(img); grid28(raw, (q) => [img[q], img[q], img[q]]); grid28(prep, (q) => [p.final[q], p.final[q], p.final[q]]);
const a = runCols(img), b = runNative(p.final), c2 = runSNN(p.final);
out.replaceChildren(panel("Колонки STDP", a, label, bars(a.votes, a.answer, (d) => a.votes[d] + " гол.")), panel("Нативная сеть", b, label, bars(b.score, b.answer, (d) => `${b.pos[d]}−${b.neg[d]}`)),
panel(`Импульсная, ${M.snn.ticks} тактов`, c2, label, bars(Array.from(c2.trace[c2.trace.length - 1].cnt), c2.answer, (d) => c2.trace[c2.trace.length - 1].cnt[d] + " сп.")));
}
el.append(h("div", { class: "w-row w-top" }, h("div", { class: "w-fig" }, pad.el, btn("Очистить", () => pad.clear())), h("div", { class: "w-fig" }, h("span", { class: "w-cap", text: "вход" }), raw, h("span", { class: "w-cap", text: "после хоста" }), prep)),
gallery((img, lab) => { pad.show(img); run(img, lab); }), out,
note("Все три сети считаются здесь целочисленно, так же как на чипе. Колонки получают исходную картинку, две другие — картинку после обработки на хосте (центровка и выпрямление). Синаптическое событие — одно сложение «спайк × вес»: на чипе энергия тратится именно на них."));
el.querySelector(".w-gal button").click(); alive(el, () => lastRaw && run(lastRaw, lastLabel));
};
/* 12. Импульсная сеть по тактам */
W.ticks = (el) => {
card(el); if (!D) { el.append(note("Данные моделей не загружены.")); return; }
const m = M.snn, T = m.ticks; let res = null, t = T, label = null, timer = null, inp = null;
const pad = drawPad((img) => load(img, null)), inCv = h("canvas", { class: "w-px", width: 28, height: 28, "aria-label": "Входные спайки по тактам" }), hidCv = h("canvas", { class: "w-px", width: 16, height: 16, "aria-label": "256 скрытых нейронов" });
const outBox = h("div", { class: "w-race" }), tickLab = h("b", { class: "w-mono" }), say = note(""), en = h("div", { class: "w-energy" });
const range = h("input", { type: "range", min: 0, max: T, value: T, "aria-label": "такт" }); range.oninput = () => { stop(); t = +range.value; draw(); };
const play = btn("▶ Пуск", () => timer ? stop() : start());
function start() { if (t >= T) t = -1; play.textContent = "❚❚ Пауза"; timer = setInterval(() => { if (!el.isConnected) return stop(); t++; draw(); if (t >= T) stop(); }, 650); }
function stop() { clearInterval(timer); timer = null; play.textContent = "▶ Пуск"; }
function load(img, lab) { label = lab; inp = HOST.prepare(img).final; res = runSNN(inp); t = -1; draw(); stop(); start(); }
function draw() {
if (!res || !inCv.isConnected) return; const c = C(), tt = Math.max(0, t), st = res.trace[tt], sp = rgb(c.spike), pot = rgb(c.pot), bg = rgb(c.control), dim = rgb(c.muted);
range.value = tt; tickLab.textContent = `такт ${tt} из ${T}`;
grid28(inCv, (p) => { const tp = res.tin[p]; if (tp === tt && tt < T) return sp; if (tp >= 0 && tp < tt) return mix(bg, dim, .55); if (tp > tt) return mix(bg, [inp[p], inp[p], inp[p]], .25); return bg; });
const fired = new Uint8Array(256); for (let k = 0; k <= tt && k < res.trace.length; k++) res.trace[k].s1.forEach((v, j) => { if (v) fired[j] = 1; });
const hg = hidCv.getContext("2d"), id = hg.createImageData(16, 16);
for (let j = 0; j < 256; j++) { const col = st.s1[j] ? sp : fired[j] ? mix(bg, sp, .35) : mix(bg, pot, Math.min(1, st.v1[j] / m.th_h)); id.data.set([...col, 255], j * 4); } hg.putImageData(id, 0, 0);
const ans = res.answer; outBox.replaceChildren(...Array.from({ length: 10 }, (_, d) => { const lv = Math.min(1, st.v2[d] / m.th_o), n = st.cnt[d];
return h("div", { class: "w-lane" + (st.s2[d] ? " fire" : "") + (tt >= res.settled && d === ans ? " lead" : "") }, h("span", { class: "w-mono", text: d }), h("div", { class: "w-tank" }, h("i", { style: `height:${lv * 100}%` })), h("div", { class: "w-dots" }, ...Array.from({ length: n }, () => h("i"))), h("b", { class: "w-mono", text: n + " сп." })); }));
const e = res.energy, nat = 338328; en.replaceChildren(h("span", { text: `входных спайков: ${fmt(e.input)}` }), h("span", { text: `спайков нейронов: ${fmt(e.neurons)}` }), h("span", { text: `синаптических событий: ${fmt(e.synaptic)}` }), h("span", { text: `нативная сеть на 32 тактах тратит в среднем ≈${fmt(nat)}` }));
say.textContent = tt === 0 ? "Такт 0: стреляют самые яркие пиксели. Скрытый слой копит заряд; выходы пока пусты — спайкам нужен такт, чтобы дойти." :
tt < res.settled ? `Такт ${tt}: подходят пиксели потусклее, скрытые нейроны стреляют и передают спайки выходам. Лидер ещё может смениться.` :
`С такта ${res.settled} лидер больше не меняется: ответ «${ans}»${label == null ? "" : ans === label ? " — верно" : ` — ошибка, это ${label}`}. Дальше можно не считать: это и есть ранний ответ.`;
}
el.append(h("div", { class: "w-row w-top" }, h("div", { class: "w-fig" }, pad.el, btn("Очистить", () => pad.clear())),
h("div", { class: "w-fig" }, h("span", { class: "w-cap", text: "входные спайки этого такта" }), inCv), h("div", { class: "w-fig" }, h("span", { class: "w-cap", text: "256 скрытых нейронов" }), hidCv),
h("div", { class: "w-fig w-grow" }, h("span", { class: "w-cap", text: "10 выходов: заряд и спайки" }), outBox)),
h("div", { class: "w-row" }, play, range, tickLab), say, en, gallery((img, lab) => { pad.show(img); load(img, lab); }),
h("div", { class: "w-legend" }, h("span", { style: "--c: var(--w-spike)", text: "спайк на этом такте" }), h("span", { style: "--c: var(--w-pot)", text: "заряд до порога" }), h("span", { style: "--c: var(--muted)", text: "пиксель уже выстрелил" })));
el.querySelector(".w-gal button").click(); alive(el, draw);
};
/* 13. Обработка на хосте по шагам */
W.hostprep = (el) => {
card(el); if (!D) { el.append(note("Данные моделей не загружены.")); return; }
const cvs = ["рисунок 28×28", "вписан в 20×20 и отцентрован", "наклон выпрямлен", "время спайка каждого пикселя"].map((cap) => [h("canvas", { class: "w-px", width: 28, height: 28 }), cap]);
const pad = drawPad((img) => show(img));
function show(img) { const p = HOST.prepare(img), T = M.snn.ticks, c = C(), sp = rgb(c.spike), bg = rgb(c.control);
grid28(cvs[0][0], (q) => [img[q], img[q], img[q]]); grid28(cvs[1][0], (q) => [p.normalized[q], p.normalized[q], p.normalized[q]]); grid28(cvs[2][0], (q) => [p.final[q], p.final[q], p.final[q]]);
grid28(cvs[3][0], (q) => { const v = p.final[q]; if (v <= 32) return bg; const tt = Math.min(T - 1, Math.floor((255 - v) * T / 224)); return mix(sp, bg, tt / T); }); }
el.append(h("div", { class: "w-row w-top" }, h("div", { class: "w-fig" }, pad.el, btn("Очистить", () => pad.clear())), ...cvs.map(([cv, cap]) => h("div", { class: "w-fig" }, h("span", { class: "w-cap", text: cap }), cv))),
gallery((img) => { pad.show(img); show(img); }, 12), note("Последняя картинка: чем ярче оранжевый, тем раньше стреляет пиксель. Тусклые пиксели стреляют в последних тактах, самые тёмные не стреляют вовсе."));
el.querySelector(".w-gal button").click();
};
/* 14. Точность против энергии */
W.tradeoff = (el) => {
card(el); const P = (D && D.tradeoff) || []; const cv = canvas(300, "Точность против синаптических событий"), tip = note("Наведите на точку.");
let pts = [];
function draw() { if (!cv.isConnected) return; const g = fit(cv), Wd = cv.clientWidth, H = 300, c = C(), L = 52, R = Wd - 18, T0 = 16, B = H - 42, lx = (v) => L + (Math.log10(v) - 3.8) / (6 - 3.8) * (R - L), y = (a) => B - (a - 88) / (100 - 88) * (B - T0);
g.clearRect(0, 0, Wd, H); g.strokeStyle = c.border; g.fillStyle = c.muted; g.font = font(11); g.textAlign = "right";
[88, 92, 96, 100].forEach((a) => { g.beginPath(); g.moveTo(L, y(a)); g.lineTo(R, y(a)); g.stroke(); g.fillText(a + "%", L - 6, y(a) + 4); });
g.textAlign = "center"; [1e4, 3e4, 1e5, 3e5, 1e6].forEach((v) => { g.beginPath(); g.moveTo(lx(v), T0); g.lineTo(lx(v), B); g.stroke(); g.fillText(v >= 1e6 ? "1 млн" : fmt(v / 1000) + " тыс.", lx(v), B + 16); });
g.fillText("синаптических событий на картинку (логарифм) →", (L + R) / 2, H - 6);
pts = P.map((p) => ({ ...p, x: lx(p.syn), y: y(p.acc) }));
pts.forEach((p) => { g.fillStyle = p.kind === "snn" ? c.spike : p.kind === "native" ? c.pot : c.muted; g.beginPath(); g.arc(p.x, p.y, p.best ? 8 : 6, 0, 7); g.fill(); if (p.best) { g.strokeStyle = c.text; g.lineWidth = 2; g.stroke(); g.lineWidth = 1; } g.fillStyle = c.text; g.font = font(11, 600); g.textAlign = "left"; g.fillText(p.short, p.x + 10, p.y + 4 + (p.dy || 0)); }); }
cv.addEventListener("pointermove", (e) => { const r = cv.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top; let b = null, bd = 30; pts.forEach((p) => { const d = Math.hypot(p.x - mx, p.y - my); if (d < bd) { bd = d; b = p; } }); if (b) tip.textContent = `${b.name}: ${fmt(b.acc, 2)}% на тесте, ${b.ticks} тактов, ≈${fmt(b.syn)} синаптических событий, ≈${fmt(b.inp)} входных спайков.`; });
el.append(cv, h("div", { class: "w-legend" }, h("span", { style: "--c: var(--muted)", text: "колонки STDP" }), h("span", { style: "--c: var(--w-pot)", text: "нативная сеть, частотный код" }), h("span", { style: "--c: var(--w-spike)", text: "импульсная по тактам" })), tip);
draw(); alive(el, draw);
};
/* 15. Бюджет нейронов для распознавания мусора */
W.budget = (el) => {
card(el); let S = 32, st = 3, K = 24, win = 6, Hd = 128, out = 4; const res = h("div", { class: "w-stack" });
function draw() { const p = Math.floor((S - win) / st) + 1, det = p * p * K, total = det + Hd + out * 2, cores = Math.ceil(total / 512), ok = total <= 8192;
res.replaceChildren(h("div", { class: "w-energy" }, h("span", { text: `позиций окна ${p}×${p} = ${fmt(p * p)}` }), h("span", { text: `детекторов ${fmt(det)}` }), h("span", { text: `всего нейронов ${fmt(total)}` }), h("span", { text: `≈${cores} ядер из 16` })),
h("div", { class: "w-bar" }, h("i", { class: ok ? "ok" : "no", style: `width:${Math.min(total / 8192 * 50, 100)}%` })), h("p", { class: "w-verdict " + (ok ? "ok" : "no"), text: ok ? `Влезает: занято ${Math.round(total / 8192 * 100)}% прототипа.` : `Не влезает: нужно в ${fmt(total / 8192, 1)} раза больше нейронов.` })); }
el.append(h("div", { class: "w-row" }, slider("Размер окна кадра, пикселей", 16, 64, S, (x) => { S = x; draw(); }), slider("Шаг детекторов", 1, 6, st, (x) => { st = x; draw(); }), slider("Типов детекторов", 4, 128, K, (x) => { K = x; draw(); })),
h("div", { class: "w-row" }, slider("Размер детектора", 3, 9, win, (x) => { win = x; draw(); }), slider("Скрытых нейронов", 16, 512, Hd, (x) => { Hd = x; draw(); }), slider("Классов", 2, 10, out, (x) => { out = x; draw(); })), res,
note("Детекторов = позиций × типов: на чипе нет общих весов, каждый детектор в каждой позиции — отдельный нейрон. Предел прототипа — 8192 нейрона, ≈512 на ядро."));
draw();
};
/* --------- подключение --------- */
function mountAll() { document.querySelectorAll("[data-widget]:not([data-mounted])").forEach((el) => { el.dataset.mounted = "1"; const f = W[el.dataset.widget]; if (!f) return; try { f(el); } catch (e) { el.textContent = "Виджет не загрузился: " + e.message; console.error(e); } }); }
new MutationObserver(() => mountAll()).observe(document.documentElement, { childList: true, subtree: true });
document.addEventListener("docslib:rendered", mountAll);
new MutationObserver(() => redraws.forEach((f) => f())).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
let rt; window.addEventListener("resize", () => { clearTimeout(rt); rt = setTimeout(() => redraws.forEach((f) => f()), 120); });
mountAll();
globalThis.WIKI_WIDGETS = W;
})();

1
wiki/data/models.json Normal file

File diff suppressed because one or more lines are too long

996
wiki/knp_wiki.py Normal file
View file

@ -0,0 +1,996 @@
"""Вики «Нейроморфное распознавание на Алтае» на SharedDocsLib.
Сборка статического комплекта: python wiki/knp_wiki.py build
Live-preview на порту 1234: python wiki/knp_wiki.py
Интерактивные виджеты лежат в assets/widgets.js и подключаются через custom_js: страница
вставляется через innerHTML, поэтому скрипты внутри неё не выполняются. Данные моделей
(data/models.json) дописываются в начало custom_js при сборке.
"""
import json
import sys
from pathlib import Path
from docslib import HeaderButton, build, run
from docslib.components import Callout, Code, H1, H2, H3, List, LocalLink, Table
from docslib.hooks import Component, Page
HERE = Path(__file__).resolve().parent
ASSETS = HERE / "assets"
TITLE = "Нейроморфное распознавание на Алтае"
@Component
def Lead(text):
return f'<p class="lead">{text}</p>'
@Component
def Text(html):
"""Абзац с доверенной разметкой: жирный текст и ссылки LocalLink."""
return f"<p>{html}</p>"
@Component
def Widget(name):
return f'<div data-widget="{name}"></div>'
@Component
def Links(items):
"""Список со ссылками: пункты уже содержат доверенный HTML LocalLink."""
return "<ul>" + "".join(f"<li>{item}</li>" for item in items) + "</ul>"
@Component
def Steps(items, ordered=False):
"""Список с доверенной разметкой в пунктах."""
tag = "ol" if ordered else "ul"
return f"<{tag}>" + "".join(f"<li>{item}</li>" for item in items) + f"</{tag}>"
@Component
def Next(title, page, why):
return f'<p class="next">Дальше: {LocalLink(title, page)} — {why}</p>'
@Component
def Pipeline():
"""Схема системы: хост → чип → хост. Цвета берутся из темы через currentColor."""
boxes = [
(10, "Камера", "кадр"), (165, "Хост", "окно и центровка"), (320, "Кодирование", "яркость → такт"),
(475, "Алтай", "импульсная сеть"), (630, "Хост", "счёт спайков"),
]
parts = []
for i, (x, title, sub) in enumerate(boxes):
chip = title == "Алтай"
parts.append(
f'<rect x="{x}" y="30" width="140" height="70" rx="16" fill="{"currentColor" if chip else "none"}" '
f'stroke="currentColor" stroke-width="1.5" opacity="{1 if chip else .9}"/>'
f'<text x="{x + 70}" y="60" text-anchor="middle" font-size="15" font-weight="600" '
f'fill="{"var(--shell)" if chip else "currentColor"}">{title}</text>'
f'<text x="{x + 70}" y="82" text-anchor="middle" font-size="11" '
f'fill="{"var(--shell)" if chip else "currentColor"}" opacity=".75">{sub}</text>'
)
if i < len(boxes) - 1:
parts.append(f'<path d="M{x + 143},65 L{x + 162},65" stroke="currentColor" stroke-width="1.5" marker-end="url(#a)"/>')
return (
'<figure><div class="w-scroll"><svg viewBox="0 0 780 130" style="width:100%;min-width:620px;color:var(--text)" role="img" '
'aria-label="Камера, хост, кодирование, процессор Алтай, хост">'
'<defs><marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">'
'<path d="M0,0 L8,4 L0,8" fill="none" stroke="currentColor"/></marker></defs>'
+ "".join(parts)
+ '<text x="390" y="122" text-anchor="middle" font-size="12" fill="currentColor" opacity=".7">'
"обучение — на обычном компьютере; на чип загружаются готовые целочисленные веса</text>"
"</svg></div><figcaption>Путь кадра через систему</figcaption></figure>"
)
# ---------------------------------------------------------------- 1. Обзор
@Page("1", "Обзор")
def overview():
return [
H1("Нейроморфное распознавание на Алтае"),
Lead("Как заставить нейроморфный процессор «Алтай» распознавать изображения: что умеет чип, какие сети на него ложатся, "
"как их обучать и как доказать, что сеть заработает на кристалле."),
H2("Задача"),
Text("Конечная цель — робот, который сам находит мусор в кадре своей камеры, а распознавание выполняет «Алтай». "
"Пока цель недостижима напрямую: нет ни данных с робота, ни платы с чипом, а сам путь от картинки до ответа на "
"чипе нужно было сначала построить. Поэтому вся техника отработана на рукописных цифрах MNIST — "
"задаче, где ответ известен, а ошибку легко увидеть глазами. Всё, что описано в вики, переносится на мусор, "
"кроме самих весов сети."),
H2("Зачем нейроморфный чип"),
Text("Обычная нейросеть на каждую картинку перемножает все входы на все веса. Для сети 784 → 256 → 10 это "
"<b>203 264</b> умножения со сложением, сколько бы пикселей ни было закрашено. Импульсная сеть считает только там, "
"где что-то произошло: нейрон, получивший спайк, прибавляет к своему уровню вес связи, и всё. Лучшая сеть из этой "
"вики распознаёт цифру за <b>≈20 тысяч</b> таких сложений — в 10 раз меньше операций, и это сложения, а не умножения. "
"На роботе с батареей это и есть довод в пользу чипа."),
Callout("Энергию самого «Алтая» на этих сетях никто не измерял: платы в проекте нет. Сравнение идёт по числу операций — "
"синаптических событий, а не по джоулям.", title="Оговорка", kind="note"),
H2("Как устроена система"),
Pipeline(),
Text("Камера даёт кадр. Хост — обычный процессор робота — вырезает окно с объектом, выравнивает его и превращает яркость "
"пикселей в спайки. Чип прогоняет спайки через сеть по тактам. Хост считает выходные спайки и выбирает ответ. "
"Обучение в эту цепочку не входит: «Алтай» не умеет менять веса, поэтому сеть учат на компьютере и загружают готовой."),
H2("Что уже доказано, а что нет"),
Table(["Сеть", "Точность на MNIST", "Где проверена", "Статус"], [
["Нативная сеть, 784 → 256 → 160", "97,76%", "KNP на CPU и GPU — 10 000 цифр; GoldenModel — 1000 цифр (97,50%)",
"счётчики спайков всех нейронов совпали с эталоном; ближе всего к чипу"],
["Импульсная сеть по тактам, 784 → 256 → 10", "97,42%", "целочисленный эталон на Python и в браузере",
"в 17 раз экономнее нативной; в KNP и GoldenModel ещё не запускалась"],
["Колонки STDP (пример KNP)", "92,2%", "KNP на CPU", "единственная сеть, которая учится в самой платформе; держится за положение цифры"],
["Дофамин по ошибке", "94,8% (валидация)", "KNP на CPU с доработанным бэкендом", "учится в платформе, но признаки штрихов считает хост"],
["Детекторы признаков, кошки/собаки", "71,35% на CIFAR-10", "KNP на GPU", "на «Алтай» не ложится: в 5 раз больше нейронов и десятки весов на нейрон"],
]),
Text("Физический чип не проверялся ни разу. Самое сильное доказательство — программная копия чипа GoldenModel из "
"официального бэкенда: на ней нативная сеть дала те же спайки, что и эталон, нейрон в нейрон."),
H2("Порядок чтения"),
Text("Вики построена как цепочка вопросов. "
f"{LocalLink('Импульсная сеть', snn_section)} — как считает сеть из спайков. "
f"{LocalLink('Процессор «Алтай»', altai_section)} — что из этого умеет чип и какие правила он навязывает. "
f"{LocalLink('Платформа KNP', knp_section)} — чем сеть описывают и запускают. "
f"{LocalLink('Обучение под «Алтай»', learning_section)} — главная часть: четыре способа получить веса и их сравнение. "
f"{LocalLink('Вход: хост и рисунки', host_page)} — почему рисунок мышью распознаётся хуже теста и как это лечить. "
f"{LocalLink('От цифр к мусору', trash_section)} — что из этого переносится на робота и чего не хватает."),
Next("Импульсная сеть", snn_section, "с чего начинается любая сеть на «Алтае»."),
]
# ---------------------------------------------------------------- 2. Импульсная сеть
@Page("2", "Импульсная сеть")
def snn_section():
return [
H1("Импульсная сеть"),
Lead("Сеть, в которой нейроны хранят заряд между тактами и обмениваются не числами, а событиями-спайками."),
Text("Обычная нейросеть — это формула: картинка на входе, числа на выходе, один проход. Импульсная сеть — это процесс во "
"времени. На каждом такте нейроны получают спайки, копят заряд и, дойдя до порога, сами выдают спайк. "
"Информация живёт в том, <b>сколько</b> спайков пришло и <b>когда</b>."),
Table(["", "Обычная нейросеть", "Импульсная сеть"], [
["Сигнал между нейронами", "вещественное число", "спайк: есть или нет"],
["Время", "нет, один проход", "такты; нейрон помнит заряд между ними"],
["Работа на картинку", "все веса, всегда", "только веса связей, по которым пришёл спайк"],
["Арифметика", "умножение и сложение", "сложение веса"],
["Обучение", "обратное распространение ошибки", "спайк не дифференцируется — нужны обходные пути"],
]),
Text("Три страницы раздела идут снизу вверх: "
f"{LocalLink('нейрон', neuron_page)}, {LocalLink('вход', coding_page)}, {LocalLink('слой', layer_page)}. "
"Все примеры — на нейроне AltAILIF, том самом, что стоит на «Алтае»."),
Next("Нейрон «Алтая»", neuron_page, "из чего всё собирается."),
]
@Page("2.1", "Нейрон «Алтая»")
def neuron_page():
return [
H1("Нейрон «Алтая»: AltAILIF"),
Lead("Целочисленный нейрон «накопление — утечка — выстрел». Всё, что умеет сеть на чипе, сводится к правилам этого нейрона."),
H2("Правило такта"),
List([
"к уровню (потенциалу) прибавляются веса всех связей, по которым в этот такт пришёл спайк;",
"прибавляется утечка — постоянная добавка, тянущая уровень вверх или вниз;",
"если уровень ≥ порога — нейрон выдаёт спайк, и уровень либо уменьшается на порог (флаг is_diff), "
"либо сбрасывается в заданное значение (флаг is_reset);",
"если уровень ниже отрицательного порога — при флаге saturate он упирается в этот порог.",
], ordered=True),
Text("Все числа целые: веса, порог, утечка. Уровень хранится в 16 битах со знаком, от −32 768 до 32 767."),
H2("Пример по тактам"),
Text("Порог 8, после выстрела порог вычитается, ниже нуля уровень не опускается — ровно так настроены скрытые нейроны "
"сетей в этой вики."),
Table(["Такт", "Пришло", "Уровень после сложения", "Спайк", "Уровень в конце такта"], [
["0", "+4, +4, +2", "10", "да", "2 — остаток сохранён"],
["1", "+4", "6", "нет", "6"],
["2", "+2, +4", "12", "да", "4"],
["3", "−4, −4", "−4", "нет", "0 — ниже нуля нельзя"],
["4", "+4", "4", "нет", "4"],
]),
Widget("neuron"),
Text("Поставьте вес 3 и порог 9 и посмотрите, как часто нейрон стреляет при вычитании порога и при сбросе в ноль. "
"С вычитанием спайков ровно ⌊заряд / порог⌋: заряд 21 при пороге 3 даёт 7 спайков. Это проверено на официальной "
"программной копии чипа, а не только в описании."),
H2("Почему такие флаги"),
Text("<b>Вычитание порога</b> превращает нейрон в счётчик: число спайков пропорционально полученному заряду. "
"Поэтому частоту спайков можно приравнять к выходу обычного нейрона и переносить в импульсную сеть веса обычной — "
f"на этом построена {LocalLink('нативная сеть', native_page)}. При сбросе в ноль излишек сгорает, и счёт искажается."),
Text("<b>Пол на нуле</b> защищает от переполнения и упрощает проверку, но у него есть цена: нейрон не помнит "
"«отрицательных доводов». Если на такте 0 пришло −8, а на такте 1 +8, нейрон всё равно выстрелит, хотя в сумме "
"заряд нулевой. Отсюда две особенности сетей ниже: выход «за/против» у нативной сети и важность порядка спайков "
"у сети по тактам."),
Table(["Флаг", "Что делает", "Как выставлен в сетях вики"], [
["is_diff", "после выстрела вычитает порог", "включён: остаток не теряется"],
["is_reset", "после выстрела сбрасывает уровень", "выключен"],
["saturate + отрицательный порог", "не пускает уровень ниже отрицательного порога", "включён, порог 0: уровень ≥ 0"],
["утечка", "добавка каждый такт; при leak_rev тянет к нулю", "0: нейрон копит заряд всю картинку"],
]),
Next("Вход: яркость в спайки", coding_page, "как в нейрон попадает картинка."),
]
@Page("2.2", "Вход: яркость в спайки")
def coding_page():
return [
H1("Вход: как яркость становится спайками"),
Lead("Чип принимает только спайки. Хост решает, каким правилом превратить яркость пикселя в события, — и от этого "
"правила зависят и точность, и число тактов, и работа чипа."),
H2("Частотный код"),
Text("У каждого пикселя есть накопитель. На каждом такте к нему прибавляется яркость (0…255); как только накопитель "
"дошёл до 255, пиксель выдаёт спайк, и 255 вычитается. Белый пиксель стреляет каждый такт, серый 128 — через такт, "
"чёрный молчит. За T тактов пиксель даёт примерно яркость × T / 255 спайков."),
H2("Временной код"),
Text("Каждый пиксель ярче 32 стреляет <b>один раз</b>, и яркость записана в моменте: такт = ⌊(255 − яркость) · T / 224⌋. "
"При T = 8 белый пиксель стреляет на такте 0, яркость 200 — на такте 1, 128 — на такте 4, 40 — на такте 7. "
"Тусклые края штриха приходят последними."),
Widget("coding"),
Table(["", "Частотный код", "Временной код"], [
["Чем несёт яркость", "числом спайков", "моментом единственного спайка"],
["Входных спайков на цифру MNIST", "≈730 за 8 тактов, ≈3200 за 32", "≈143 при любом числе тактов"],
["Что нужно для точности", "много тактов: оттенки различаются только при большом T", "4–8 тактов"],
["Как получить веса", "можно перенести обычную сеть: число спайков ≈ активация", "сеть надо учить прямо на тактах"],
]),
Text("Частотный код удобен для переноса, но дорог: картинка из 143 закрашенных пикселей превращается в 3200 событий. "
"Временной код отправляет на чип по одному событию на закрашенный пиксель — меньше уже нельзя."),
Callout("Подать яркость пачкой спайков в одном такте нельзя: если в одном такте по одной входной линии пришло несколько "
"спайков, «Алтай» засчитывает один. Это проверено на GoldenModel: 25 одинаковых спайков дали эффект одного. "
"Поэтому сила сигнала разворачивается во времени или раскладывается по разным линиям.",
title="Ограничение чипа", kind="warning"),
Next("Слой: сложение и торможение", layer_page, "что происходит, когда спайки доходят до слоя нейронов."),
]
@Page("2.3", "Слой: сложение и торможение")
def layer_page():
return [
H1("Слой: сложение, задержка, торможение"),
Lead("Слой — это группа нейронов и набор связей к ней. Связи только складывают; всё остальное делает порог."),
H2("Сложение за такт"),
Text("Всё, что пришло в нейрон за такт, складывается со своими весами, включая отрицательные. У каждой связи есть задержка "
"не меньше одного такта: спайк, выпущенный на такте N, доходит на такт N + 1. Поэтому в сети из двух слоёв ответ "
"отстаёт от входа на такт, а после последнего входного такта нужен ещё один, чтобы выход получил всё."),
Widget("synsum"),
H2("Торможение и «победитель забирает всё»"),
Text("Отрицательный вес — это торможение. Если связать нейроны слоя друг с другом отрицательными связями, первый "
"выстреливший гасит остальных. Так делают, когда нейроны видят одно и то же и должны поделить работу: без торможения "
"одинаковые нейроны с одинаковым правилом обучения выучивают одно и то же. На этом построено соревнование в "
f"{LocalLink('колонках STDP', columns_page)}."),
Widget("wta"),
H2("Кто с кем связан"),
Text("Схему связей задаёт конструктор сети, обучение меняет только веса. В сетях этой вики встречаются две схемы: "
"полносвязная (каждый вход с каждым нейроном слоя, 784 × 256) и локальная, когда нейрон смотрит в своё окно "
f"картинки. Локальная — основа {LocalLink('детекторов признаков', objects_page)}, и у неё на чипе есть цена: "
"общих весов нет, каждый детектор в каждой позиции — отдельный нейрон."),
Next("Процессор «Алтай»", altai_section, "какие из этих возможностей есть у чипа и с какими ограничениями."),
]
# ---------------------------------------------------------------- 3. Алтай
@Page("3", "Процессор «Алтай»")
def altai_section():
return [
H1("Процессор «Алтай»"),
Lead("Нейроморфный процессор компании Мотив НТ: сетка одинаковых ядер, в каждом — нейроны AltAILIF и таблица их связей."),
Table(["Характеристика", "Значение"], [
["Прототип «Алтай-2»", "16 ядер, 8192 нейрона"],
["Масштабируемая конфигурация", "до 131 072 нейронов"],
["Нейрон", "AltAILIF: целые числа, уровень 16 бит со знаком"],
["Связь", "целый вес, |w| ≤ 255; не больше 4 разных величин веса на нейрон"],
["Обучение на кристалле", "нет: в официальном бэкенде пластичность выключена"],
["Ввод и вывод", "через порт с северной стороны сетки ядер"],
]),
Text("Источник цифр — руководство KNP 2.0. В нём описан «Алтай-2»; переносить эти числа на другие поколения чипа нельзя."),
H2("Чем мы пользуемся вместо чипа"),
Text("Платы нет, поэтому вместо чипа — официальный бэкенд Мотив НТ для KNP, пакет knp-altai-backend 1.0.2 из релиза "
"KNP 2.0.0. В нём два устройства: AltAI_HW — сам чип, и AltAI_GM — <b>GoldenModel</b>, программная копия, "
"которая считает так же, как кристалл, и раскладывает сеть по ядрам тем же размещателем. По умолчанию это "
"сетка 4 × 4 ядра. Библиотека закрытая: её поведение изучено только опытами, без дизассемблирования. "
"В самом пакете устройство называется AltAIv1_GoldenModel, хотя руководство говорит об «Алтае-2»."),
Links([
LocalLink("Ядро и веса", core_page) + " — откуда правило «4 величины веса на нейрон».",
LocalLink("Правила для сети и как их обходить", rules_page) + " — полный список ограничений и решения, проверенные на GoldenModel.",
LocalLink("Проверка в GoldenModel", verify_page) + " — как доказать, что сеть заработает, и на чём легко обмануться.",
]),
Next("Ядро и веса", core_page, "почему у нейрона так мало разных весов."),
]
@Page("3.1", "Ядро и веса")
def core_page():
return [
H1("Ядро и веса"),
Lead("Ядро хранит своих нейронов и связи к ним. Входящий спайк приходит по линии, и каждый нейрон ядра прибавляет вес, "
"записанный для этой линии."),
Text("Удобно представлять ядро таблицей: строки — входящие линии, столбцы — нейроны, в клетке — вес. Спайк по строке "
"заставляет каждый нейрон-столбец прибавить свою клетку. Руководство KNP формулирует главное ограничение так: "
"у нейрона не больше <b>четырёх разных величин веса</b>, знак выбирается отдельно и в счёт не идёт. "
"Вес 3 и вес −3 — одна величина; веса 1, 2, 4 и 255 — уже четыре."),
Widget("crossbar"),
Text("Поменяйте веса в таблице: когда у столбца появляется пятая величина, нейрон перестаёт помещаться в правило. "
"Обычная сеть после обучения имеет у каждого нейрона сотни разных весов — поэтому без квантования её на чип не положить."),
H2("Что занимает «величину»"),
Text("Величины расходуют не только обученные веса. В нативной сети у скрытого нейрона веса {1, 2, 4} и ещё −255 для "
"очистки уровня между картинками — ровно четыре. Обученным весам остаётся три уровня, и это определило палитру "
f"{{0, ±1, ±2, ±4}} во всех сетях вики. Подробно — на странице {LocalLink('правил', rules_page)}."),
Next("Правила для сети и обходы", rules_page, "все ограничения чипа сразу и обход каждого."),
]
@Page("3.2", "Правила для сети и обходы")
def rules_page():
return [
H1("Правила для сети и как их обходить"),
Lead("Сеть, которая нарушает хоть одно правило, на «Алтай» не загрузится или посчитает не то. Ниже — все ограничения, "
"найденные в руководстве и опытах на GoldenModel, и решения, которыми нативная сеть их обходит."),
Table(["Правило чипа", "Чем мешает", "Решение"], [
["Вес — целое, |w| ≤ 255", "обученные веса дробные", "квантование: вес × масштаб, округление до палитры"],
["Не больше 4 величин веса на нейрон", "у обученного нейрона сотни разных весов",
"палитра {0, ±1, ±2, ±4}; у выходных нейронов вход делится на группы по 32 со своими уровнями"],
["Связи с весом 0 не бывает", "нулевые веса после квантования",
"нулевая связь просто не создаётся; входные линии без связей не отправляются, иначе ошибка «неизвестный вход»"],
["Один тип связи, знак в весе", "тормозящая связь типа INHIBITORY не прошла в GoldenModel",
"торможение — связь EXCITATORY с отрицательным весом"],
["Начальный уровень нейрона не учитывается", "смещение (bias) нельзя задать стартовым зарядом",
"смещение +b — это b «тактовых» входных линий с весом +1, которые стреляют каждый такт"],
["Повтор одной линии в такте = один спайк", "нельзя подать сильный сигнал пачкой",
"сила разворачивается во времени; очистка идёт по 128 разным линиям"],
["Уровень — 16 бит со знаком", "сумма за картинку может переполниться",
"до загрузки считается верхняя граница: T × сумма положительных весов; у нативной сети 12 864 и 1 120"],
["Уровень не сбрасывается между картинками сам", "заряд прошлой картинки портит следующую",
"после картинки — пачка −255 по 128 линиям, всего −32 640: сначала чистится скрытый слой, потом выход"],
["Обучения нет", "веса нельзя подстроить на месте", "обучение на компьютере, загрузка готовых весов"],
]),
H2("Квантование"),
Text("Квантование заменяет дробный вес ближайшим из палитры. Чем меньше ступенек, тем грубее нейрон: из тонкого "
"профиля весов остаются три уровня. Ошибку квантования сеть переживает, если учится уже с огрублёнными весами — "
f"так сделано в {LocalLink('сети по тактам', ticks_page)}."),
Widget("quant"),
H2("Влезет ли сеть"),
Text("Три проверки, которые можно сделать до размещателя: число нейронов, число разных весов у самого «разнообразного» "
"нейрона и наибольший уровень. Переключитесь на сеть детекторов — она проваливает все три."),
Widget("fit"),
Callout("Эти три числа необходимы, но недостаточны. Размещатель проверяет ещё входящие линии ядер и маршруты, а "
"правильность счёта доказывает только сравнение спайков с эталоном.", title="Предел оценки", kind="note"),
Next("Проверка в GoldenModel", verify_page, "как это сравнение устроено."),
]
@Page("3.3", "Проверка в GoldenModel")
def verify_page():
return [
H1("Проверка в GoldenModel"),
Lead("Сеть считается рабочей на «Алтае», когда счётчик спайков каждого нейрона на каждой картинке совпал с "
"эталоном. Совпадения точности недостаточно."),
H2("Лестница проверки"),
Table(["Ступень", "Что доказывает", "Время на нативной сети"], [
["Целочисленный эталон (NumPy)", "арифметика сети записана верно", "—"],
["KNP, однопоточный CPU", "граф собран правильно; счётчики равны эталону", "65 с на 10 000 цифр"],
["KNP, наш CUDA-бэкенд", "то же на больших объёмах, побайтно равно CPU", "11,6 с на 10 000 цифр"],
["GoldenModel", "сеть размещается по ядрам и считает по правилам чипа", "≈6 с на одну цифру; 1000 цифр — 143 с в 8 процессах"],
["Чип AltAI_HW", "работа на железе", "не проверялось"],
]),
Text("Почему счётчики, а не точность: две сети с разными ошибками могут дать одинаковый процент. Совпадение 256 скрытых "
"и 160 выходных счётчиков на каждой картинке исключает и ошибку в весах, и потерянный спайк, и неочищенный уровень."),
H2("Размещатель может принять сеть и всё равно посчитать не то"),
Text("Чтобы ускорить проверку, в GoldenModel загрузили 8 копий нативной сети сразу. Размещатель граф принял и выделил "
"24 ядра при стандартной сетке 4 × 4. Счёт разошёлся: 26 495 расхождений скрытых счётчиков на 319 картинках из 1000, "
"точность 93,6% вместо 97,5%. Явная сетка 8 × 8 тоже не помогла. Причина не установлена."),
Text("Вывод: принятие графа размещателем ничего не доказывает. Рабочий режим — не больше двух копий сети на процесс "
"в стандартной сетке 4 × 4, а параллельность — отдельными процессами эмулятора. Исполнитель нативной сети "
"отказывается запускать GoldenModel с большим числом копий."),
H2("Что проверено опытами"),
Text("Поведение закрытой библиотеки установлено крошечными сетями на GoldenModel:"),
Table(["Опыт", "Результат", "Вывод"], [
["заряд 21, порог 3, вычитание порога", "7 спайков", "остаток сохраняется"],
["25 одинаковых входных спайков в такте, вес 1, порог 21", "0 спайков", "повторы схлопываются в один"],
["начальный уровень 21, порог 3, без входа", "0 спайков", "начальный уровень не учитывается"],
["утечка +1, порог 3", "спайки на тактах 3, 6, 9, 12, 15", "утечка работает как постоянный вход"],
["24 / 128 / 512 тактов крошечной сети", "0,69 / 3,3 / 13 с", "у эмулятора большие накладные расходы на такт"],
]),
H2("Правила прогонов"),
List([
"каждый прогон — в новый пустой каталог; старый результат не может замаскировать новый сбой;",
"до и после прогона сверяются хеши модели и исполняемого файла;",
"проверяется точный список номеров картинок, а не только их количество;",
"при первом расхождении счётчиков прогон останавливается и пишет FAILED.json; VERIFIED.json появляется только при полном совпадении.",
]),
Text("Итог для нативной сети: 975 из 1000 первых тестовых цифр в GoldenModel, все счётчики совпали. "
"Полные 10 000 в GoldenModel не запускались."),
Next("Платформа KNP", knp_section, "в чём эту сеть описывают и чем запускают."),
]
# ---------------------------------------------------------------- 4. KNP
@Page("4", "Платформа KNP")
def knp_section():
return [
H1("Платформа KNP"),
Lead("Kaspersky Neuromorphic Platform — открытая библиотека (C++ и Python, Apache 2.0): сеть описывается один раз и "
"исполняется на CPU, GPU или «Алтае»."),
H2("Из чего состоит модель"),
Table(["Объект", "Что это"], [
["Population", "группа однотипных нейронов; параметры каждого задаёт функция-генератор"],
["Projection", "связи в популяцию: для каждой — вес, задержка, тип, номер источника и получателя"],
["Network", "набор популяций и проекций"],
["Model", "сеть плюс входные каналы (куда хост шлёт спайки) и выходные (откуда читает)"],
["ModelExecutor", "гоняет модель по тактам на выбранном бэкенде и вызывает код хоста каждый такт"],
]),
Text("Каждый такт исполнитель берёт входные спайки из каналов, бэкенд обновляет нейроны и доставляет спайки по проекциям, "
"выходные спайки складываются в каналы. Хост между тактами решает, что подать дальше."),
H2("Нативная сеть в коде"),
Text("Сокращённый фрагмент из mnist-altai-native/native_digits.cpp. Видны все обходы со страницы "
f"{LocalLink('правил', rules_page)}: тактовые линии смещения, очистка по 128 линиям весом −255, только тип EXCITATORY."),
Code('''using P = knp::core::Population<knp::neuron_traits::AltAILIF>;
using R = knp::core::Projection<knp::synapse_traits::DeltaSynapse>;
auto make = [&](int thr) { // нейрон: вычитание порога, уровень не ниже 0, без утечки
N n; n.activation_threshold_ = thr; n.negative_activation_threshold_ = 0;
n.is_diff_ = true; n.is_reset_ = false; n.saturate_ = true; n.potential_leak_ = 0; return n;
};
P hidden([&](size_t) { return make(32); }, 256);
P output([&](size_t) { return make(4); }, 160);
auto add = [](auto& v, int pre, int post, int w) { // нулевых связей нет, знак — в весе
if (w) v.push_back(R::Synapse{{float(w), 1, OutputType::EXCITATORY}, size_t(pre), size_t(post)});
};
for (int h = 0; h < 256; h++) {
for (int p = 0; p < 784; p++) add(hin, p, h, w1[p * 256 + h]); // пиксели
for (int c = 0; c < abs(b1[h]); c++) add(hin, 784 + c, h, b1[h] > 0 ? 1 : -1); // смещение
for (int c = 0; c < 128; c++) add(hin, 784 + clock + c, h, -255); // очистка
}
Network net; net.add_population(hidden); net.add_population(output);
net.add_projection(R(input_uid, hidden.get_uid(), from(hin), hin.size())); /* … */
Model model(std::move(net));
model.add_input_channel(in_ch, input_projection_uid);
model.add_output_channel(out_ch, output.get_uid());
ModelExecutor exec(model, backend, {{in_ch, send_spikes}});
exec.start([&](size_t step) { return !done; });''', "cpp"),
H2("Бэкенды"),
Table(["Бэкенд", "Нейроны", "Обучение", "Замечание"], [
["CPU, однопоточный", "LIF, BLIFAT, AltAILIF и их STDP-варианты", "да", "эталон KNP"],
["CPU, многопоточный", "LIF, BLIFAT", "частично", "AltAILIF не поддерживает"],
["CUDA официальный", "BLIFAT", "нет", "AltAILIF не поддерживает"],
["CUDA для AltAILIF", "AltAILIF + Delta", "нет", "написан в проекте; побайтно совпадает с CPU, в 5,6 раза быстрее на пачке из 8 картинок"],
["AltAI: GoldenModel и чип", "AltAILIF + Delta", "нет", "закрытая библиотека Мотив НТ, пакет 1.0.2"],
]),
Text("Для «Алтая» имеет смысл только связка AltAILIF + DeltaSynapse: модели LIF и BLIFAT на чип не переносятся, "
"а STDP работает только на CPU."),
Next("Подводные камни KNP", knp_pitfalls, "где KNP ведёт себя не так, как ожидаешь."),
]
@Page("4.1", "Подводные камни KNP")
def knp_pitfalls():
return [
H1("Подводные камни KNP"),
Lead("Ошибки и неочевидное поведение, найденные при работе с KNP 2.0.0. Каждое стоило дней, поэтому записано."),
Table(["Что", "Симптом", "Причина и решение"], [
["ModelLoader::init_channels", "примерно каждое 8–12-е обучение молча проваливается: веса остаются стартовыми, точность случайная",
"вход картинки иногда подписывается на канал меток. Исправлено в репозитории проекта, в KasperskyLab/knp не отправлено"],
["Порог BLIFAT после обучения", "пример mnist-learn молчит на 68% тестовых цифр (30,9% верных)",
"добавка к порогу растёт при обучении и застывает. Порог на проверке задаётся отдельно: 75,5%"],
["Одинаковые нейроны колонки", "20 нейронов колонки имеют одинаковые веса и работают как один",
"одинаковый старт и общий сигнал метки. Разброс стартовых весов σ = 20: 82,5% → 92,2%"],
["Дофамин опаздывает на такт", "обучение по ошибке не сходится",
"дофамин приходит на такт позже входных спайков; нужна поправка KNP_DELTA_LAG=1"],
["Обучение молчащего нейрона", "штатный STDP не меняет связи нейрона, который не выстрелил",
"для обучения по ошибке собрана отдельная копия CPU-бэкенда (readout/build_delta_cpu.py)"],
["stochastic_stimulation", "параметр задан, но ни на что не влияет", "CPU-бэкенд его не читает"],
["Ничья в «победитель забирает всё»", "один прогон из пяти схлопывает все веса к нулю",
"предположительно случайный выбор победителя с seed из std::random_device; не подтверждено"],
["Сохранение сети", "обученную сеть примера нельзя загрузить для проверки",
"пример не вызывает save_network, режим inference_only не загружает модель"],
["get_grid_cols_rows", "ошибка компоновки с бэкендом AltAI", "функция объявлена в заголовке, но отсутствует в пакете 1.0.2"],
]),
H2("Переключатели примера mnist-learn"),
Text("Изменения в примере сделаны переменными окружения: без них пример работает как в KNP."),
Table(["Переменная", "Что делает"], [
["KNP_MNIST_THR, KNP_MNIST_ADD_THR_SCALE", "порог входных нейронов на проверке и множитель добавки к нему"],
["KNP_COL_JITTER", "случайный разброс стартовых весов колонок"],
["KNP_COL_PUNISH", "отрицательный дофамин колонкам чужих классов; лучшее 0,012–0,02"],
["KNP_DELTA, KNP_DELTA_LAG", "обучение выхода по ошибке и сдвиг дофамина на такт"],
["KNP_W_RANGE", "диапазон весов при обучении по ошибке"],
["KNP_MNIST_INPUT_SIZE", "размер входа: признаки вместо 784 пикселей"],
["KNP_MNIST_DUMP", "выгрузка весов и порогов после обучения"],
]),
Next("Обучение под «Алтай»", learning_section, "главный вопрос: откуда взять веса."),
]
# ---------------------------------------------------------------- 5. Обучение
@Page("5", "Обучение под «Алтай»")
def learning_section():
return [
H1("Обучение под «Алтай»"),
Lead("«Алтай» не учится, а спайк — ступенька без производной. Поэтому вопрос «как обучить сеть» здесь главный, и ответов "
"на него четыре."),
Text("Обычную сеть учат обратным распространением ошибки: считают, как ответ зависит от каждого веса, и сдвигают веса. "
"Для импульсной сети это напрямую не работает: выход нейрона меняется скачком, производная почти везде равна нулю. "
"Отсюда два пути: учиться <b>локальными правилами</b>, которым производная не нужна, или учиться <b>на компьютере</b>, "
"заменив ступеньку чем-то гладким."),
Table(["Способ", "Где учится", "Чему учит", "Цифры MNIST"], [
["Колонки STDP", "в KNP", "«похоже на мою цифру», без сравнения с чужими", "92,2%"],
["Дофамин по ошибке", "в KNP", "один слой по ошибке ответа; признаки готовит хост", "94,8% (валидация)"],
["Перенос обычной сети", "на компьютере", "всю сеть через её аналоговую копию", "97,76%"],
["Обучение по тактам", "на компьютере", "всю сеть прямо на спайковой динамике чипа", "97,42% за 8 тактов"],
]),
Text("Закономерность видна сразу: чем больше обучения уходит на компьютер, тем выше точность. Для «Алтая», который "
"учиться не умеет, это не уступка, а естественный путь. Обучение внутри KNP ценно другим — оно показывает, что "
"платформа может учиться сама, если когда-нибудь появится чип с пластичностью."),
Links([
LocalLink("Колонки STDP", columns_page) + " — как учится штатный пример KNP и почему он упирается в 92%.",
LocalLink("Дофамин по ошибке", delta_page) + " — правило перцептрона, собранное из дофамина KNP.",
LocalLink("Перенос обычной сети", native_page) + " — нативная сеть, проверенная в GoldenModel.",
LocalLink("Обучение по тактам", ticks_page) + " — та же точность в 17 раз дешевле.",
LocalLink("Сравнение", compare_page) + " — все сети на одной цифре и на одном графике.",
]),
Next("Колонки STDP", columns_page, "с чего начинал KNP."),
]
@Page("5.1", "Колонки STDP")
def columns_page():
return [
H1("Колонки STDP"),
Lead("Штатный пример KNP mnist-learn: на каждую цифру — колонка из 20 нейронов, которые учатся по STDP с дофамином. "
"Ответ — колонка, которая стреляет больше всех."),
H2("Как учится"),
Text("Картинка подаётся частотным кодом за 10 тактов во все колонки сразу. Во время обучения метка через вспомогательную "
"популяцию будит колонку правильной цифры, и у её нейронов крепнут связи с пикселями, которые стреляли перед "
"выстрелом нейрона. Это <b>STDP</b> — правило, зависящее от порядка спайков: вход за мгновение до выстрела помог, "
"связь растёт; вход после выстрела не понадобился, связь слабеет."),
Widget("stdp"),
Text("В KNP используется вариант с синаптическим ресурсом: вес растёт тем медленнее, чем ближе к пределу, и изменения "
"закрепляются наградой — дофамином. Внутри колонки нейроны тормозят друг друга, чтобы каждый выучил свой стиль "
"написания."),
H2("Что пришлось исправить"),
Text("В исходном примере все 20 нейронов колонки стартовали с одинаковых весов и получали один и тот же сигнал метки — "
"колонка работала как один нейрон, 82,5%. Небольшой случайный разброс стартовых весов дал нейронам разные "
"стили, а торможение −255 между всеми нейронами на проверке оставило голос самому похожему: <b>92,2%</b> на полном "
f"MNIST. Попутно найдена ошибка загрузчика KNP — см. {LocalLink('подводные камни', knp_pitfalls)}."),
H2("Почему дальше 92% не идёт"),
Text("Колонка выучивает <b>трафарет</b>: «где у моей цифры обычно есть линия». Отсюда четыре следствия, и все они "
"измерены:"),
List([
"сдвиг цифры на 2 пикселя роняет точность до 64–67%, утолщение или утончение линии — до 75–81%: трафарет перестаёт совпадать;",
"типичная ошибка 4 → 9: трафарет девятки накрывает четвёрку целиком, а «не путай с девяткой» колонку никто не учил — "
"награда бывает только за свою цифру; наказание чужих колонок дофамином даёт +2…4 п.п., но не снимает потолок;",
"пустой пиксель молчит, поэтому колонка не видит довода «здесь линии нет». Попытка добавить такие доводы отдельными "
"входами «пиксель тёмный» (кодирование ON/OFF) утроила число входных спайков, и сеть замолчала;",
"на плотном входе колонки молчат вообще: после STDP большинство весов отрицательные, сумма уходит в минус.",
]),
Text("Это ограничение архитектуры, а не настроек: колонки сравнивают с образцом, но не учатся различать классы."),
Next("Дофамин по ошибке", delta_page, "как научить сеть именно различать, не выходя из KNP."),
]
@Page("5.2", "Дофамин по ошибке")
def delta_page():
return [
H1("Дофамин по ошибке"),
Lead("Правило перцептрона, собранное из механизмов KNP: нейрон получает дофамин «цель минус ответ», и связь меняется "
"на эту величину, если по ней только что пришёл спайк."),
H2("Правило"),
Text("Метка даёт нейрону своей цифры положительный дофамин на каждом такте, собственный спайк нейрона — отрицательный. "
"Если нейрон должен был стрелять и стрелял, сумма около нуля, ничего не меняется. Если должен, но молчал — связи с "
"активными входами крепнут. Если стрелял зря — слабеют. В отличие от колонок, сеть учится и на чужих цифрах: "
"«не стреляй на девятку» получается само."),
Text("В штатном KNP так не выйдет: STDP меняет только связи выстрелившего нейрона, а здесь нужно учить и молчащего. "
"Для этого собрана отдельная копия CPU-бэкенда (readout/build_delta_cpu.py, режим KNP_DELTA). Оригинальные "
"библиотеки не менялись. Дофамин в KNP приходит на такт позже входа, поэтому обязателен KNP_DELTA_LAG=1."),
H2("Результаты"),
Table(["Задача (валидация)", "Колонки STDP", "Дофамин по ошибке в KNP", "Тот же слой, обученный на компьютере"], [
["Цифры, признаки штрихов", "83,5% (200 нейронов)", "94,8% (10 нейронов)", "94,9%"],
["Цифры со сдвигом на 2 пикселя, обучение со сдвигами", "—", "91,3%", "89,9%"],
["Кошки/собаки, выученные признаки", "64–65%", "65–69%", "74%"],
]),
Text("На цифрах обучение внутри платформы догнало обучение на компьютере. На объектах разрыв 5–9 п.п. не объяснён: "
"диапазон весов и число проходов не помогают."),
H2("Ограничение"),
Text("Правило учит один слой, а один слой на пикселях — это линейный классификатор, около 92% на MNIST. Поэтому вход "
"у него — <b>признаки штрихов</b>, которые считает хост: 8 направлений линии × 2 толщины, сложенные по клеткам 4 × 4. "
"Получается, что половина распознавания идёт не на чипе. Чтобы на чипе была вся сеть, нужны веса для нескольких "
"слоёв — а их даёт только обучение на компьютере."),
Next("Перенос обычной сети", native_page, "первая сеть, целиком живущая на «Алтае»."),
]
@Page("5.3", "Перенос обычной сети")
def native_page():
return [
H1("Перенос обычной сети: нативная сеть"),
Lead("Двухслойная сеть из нейронов AltAILIF, уложенная во все правила чипа. Веса взяты у обычной сети, обученной так, "
"чтобы её числа можно было заменить счётом спайков."),
Table(["Слой", "Размер", "Устройство"], [
["вход", "784 линии + тактовые + 128 линий очистки", "частотный код, 32 такта"],
["скрытый", "256 AltAILIF", "порог 32, вычитание порога; веса {0, ±1, ±2, ±4}; смещение тактовыми линиями"],
["выход", "160 AltAILIF", "10 цифр × 8 групп × «за» и «против»; порог 4"],
["хост", "—", "по каждой цифре: спайки «за» минус спайки «против», затем максимум"],
]),
H2("Почему это работает"),
Text("Скрытый нейрон с вычитанием порога за 32 такта выдаёт ⌊заряд / 32⌋ спайков, но не больше 32. Это ступенчатая "
"функция с насыщением, почти та же, что ReLU с ограничением у обычной сети. Значит, если обычную сеть учить с "
"активацией, ограниченной и округлённой так же, её веса подойдут импульсной. Учитель — сеть 784 → 256 → 10 "
"(mnist-fast), обученная со сдвигами, поворотами и изменением толщины."),
H2("Рецепт"),
List([
"веса скрытого слоя учителя × 8 округляются до {0, ±1, ±2, ±4}, смещения — до целых;",
"через импульсный скрытый слой прогоняются все 70 000 картинок и записываются настоящие счётчики спайков;",
"выходной слой обучается заново уже на этих счётчиках — так он учитывает ошибки квантования скрытого слоя;",
"выходные веса квантуются отдельно для каждой группы из 32 скрытых нейронов: уровни {0, 1, ≈0,45·max, max}, "
"чтобы у выходного нейрона было не больше трёх величин плюс −255 очистки;",
"масштаб и порог выхода выбираются по валидации (5000 картинок): масштаб 4, порог 4; тест открывается после выбора.",
], ordered=True),
H2("Почему выход «за/против»"),
Text("Выходной вес бывает и отрицательным: «здесь есть верхняя перекладина — значит, не 4». Но уровень нейрона не "
"опускается ниже нуля, и отрицательный довод, пришедший раньше положительного, теряется. Поэтому положительные "
"веса идут в нейрон «за», отрицательные — со знаком плюс в нейрон «против», а вычитание делает хост. Деление на 8 "
"групп нужно уже из-за правила четырёх величин: у каждой группы свои три уровня."),
H2("Результаты"),
Table(["Свойство", "Значение"], [
["Точность на тесте MNIST", "97,76% — эталон, KNP на CPU и наш CUDA дают одинаковые счётчики на всех 10 000"],
["GoldenModel", "975 из 1000 первых тестовых цифр, все счётчики совпали"],
["Цена", "≈3200 входных спайков и ≈338 тыс. синаптических событий на цифру"],
["Наибольший уровень", "оценка сверху 12 864 (скрытый) и 1 120 (выход) — в 16 бит помещается"],
]),
H2("Можно ли короче"),
Text("32 такта частотного кода — дорого. Если просто обрезать ту же сеть по тактам, точность держится до 8, а потом "
"обваливается: сеть училась на 32 оттенках, и при 4 тактах серые пиксели неотличимы от чёрных."),
Table(["Тактов", "Точность", "Синаптических событий"], [
["32", "97,76%", "338 тыс."], ["16", "97,66%", "164 тыс."], ["8", "97,19%", "77 тыс."], ["4", "84,63%", "33 тыс."],
]),
Next("Обучение по тактам", ticks_page, "как сделать сеть, которой мало тактов не мешает."),
]
@Page("5.4", "Обучение по тактам")
def ticks_page():
return [
H1("Обучение по тактам"),
Lead("Сеть 784 → 256 → 10 из AltAILIF, обученная прямо на спайковой целочисленной динамике чипа. Вход — временной код: "
"один спайк на пиксель, ответ за 8 тактов."),
Widget("ticks"),
Text("Нарисуйте цифру и прокрутите такты. Яркие пиксели стреляют первыми, скрытые нейроны начинают отвечать с первого "
"такта, и выход обычно определяется задолго до восьмого: такт, после которого лидер больше не меняется, показан "
"как ранний ответ."),
H2("Как обучить ступеньку"),
Text("Прямой проход идёт по настоящим тактам: спайки, вычитание порога, пол на нуле, задержка в такт между слоями — та же "
"арифметика, что на чипе. При обратном проходе производная ступеньки заменяется гладким «колоколом» "
"1 / (1 + 4|v|)², где v — расстояние уровня до порога. Это <b>суррогатный градиент</b>: он говорит весам, "
"в какую сторону сдвинуть уровень, чтобы нейрон стрельнул или промолчал."),
H2("Рецепт"),
List([
"30 эпох, Adam, шаг 0,002 с косинусным спадом, пачки по 256; половина каждой пачки — те же цифры, перерисованные "
"пером постоянной толщины, плюс сдвиги и повороты;",
"первые 8 эпох веса дробные; с 9-й в прямом проходе они огрубляются до {0, ±1, ±2, ±4}, а градиент проходит мимо "
"огрубления. Сеть доучивается уже с теми весами, что будут на чипе;",
"ответ сети при обучении — число выходных спайков плюс половина остатка уровня, чтобы ошибка была видна и при равенстве счёта;",
"к ошибке добавлен штраф λ за спайки скрытого слоя: сеть учится молчать, когда может;",
"после каждой эпохи веса переводятся в целые и проверяются отдельным целочисленным симулятором на валидации; "
"лучшая эпоха выбирается по нему.",
], ordered=True),
H2("Варианты"),
Table(["Код", "Тактов", "λ", "Валидация", "Тест", "Тест, рисунки", "Синапт. событий", "Скрытых спайков"], [
["временной", "8", "0,1", "96,66%", "97,42%", "95,85%", "20 150", "45"],
["временной", "8", "0,5", "96,13%", "97,11%", "95,10%", "25 175", "21"],
["временной", "6", "0,5", "96,10%", "97,12%", "95,07%", "24 927", "18"],
["временной", "4", "0,5", "95,88%", "97,08%", "95,05%", "24 129", "17"],
["временной", "4", "0,1", "95,17%", "96,11%", "93,93%", "18 142", "41"],
["частотный", "8", "0,1", "95,91%", "97,22%", "94,37%", "84 590", "62"],
["частотный", "4", "0,1", "93,07%", "94,35%", "90,86%", "37 405", "47"],
]),
Text("Выбрана первая строка — по валидации, тест открыт после выбора. Два наблюдения из таблицы:"),
Steps([
"<b>Временной код выигрывает у частотного</b> при том же числе тактов: точнее и в 4 раза дешевле. Частотному коду на "
"8 тактах не хватает оттенков, временной передаёт яркость моментом.",
"<b>Штраф за скрытые спайки не равен экономии.</b> Почти вся работа — во входном слое: каждый из ≈143 входных спайков "
"задевает в среднем ≈139 скрытых нейронов, это ≈19,7 тыс. из 20 тыс. событий. Сильный штраф вдвое снижает "
"скрытые спайки, но сеть компенсирует это более плотными входными весами, и событий становится больше. Экономить "
"нужно на числе ненулевых входных весов.",
]),
H2("Что осталось до чипа"),
Text("Правила чипа соблюдены на бумаге: палитра {0, 1, 2, 4}, наибольший уровень 304 при пределе 32 767, смещение — "
"постоянный вход каждый такт, то есть тактовые линии, как у нативной сети. Но в KNP и GoldenModel сеть ещё не "
"запускалась, и для неё нет очистки между картинками. Это следующий шаг, после которого её можно ставить в один "
"ряд с нативной."),
Next("Сравнение", compare_page, "все сети рядом."),
]
@Page("5.5", "Сравнение")
def compare_page():
return [
H1("Сравнение сетей"),
Lead("Три сети, которые целиком состоят из нейронов AltAILIF, на одной задаче: тест MNIST, 10 000 цифр."),
Table(["", "Колонки STDP", "Нативная сеть", "Обучение по тактам"], [
["Точность, тест", "92,2%", "97,76%", "97,42%"],
["Точность, рисунки", "—", "94,1% (повтор рецепта)", "95,9%"],
["Тактов", "15", "32", "8"],
["Входных спайков", "≈870", "≈3200", "≈143"],
["Синаптических событий", "≈175 тыс.", "≈338 тыс.", "≈20 тыс."],
["Нейронов", "200", "416", "266"],
["Где учится", "в KNP", "на компьютере", "на компьютере"],
["Проверена в GoldenModel", "нет", "да, 1000 цифр", "нет"],
]),
H2("Одна цифра — три сети"),
Text("Нарисуйте цифру или выберите тестовую. Все три сети считаются здесь целочисленно, по тем же правилам, что "
"на чипе. Сдвиньте рисунок к краю или нарисуйте очень толстой линией — колонки ошибутся первыми."),
Widget("compare"),
H2("Точность против работы чипа"),
Text("Каждая точка — сеть или её режим. Левее — меньше сложений и, значит, энергии; выше — точнее. Нативная сеть, "
"обрезанная по тактам, ползёт влево и вниз; сеть, обученная по тактам, стоит в левом верхнем углу."),
Widget("tradeoff"),
H2("Что выбирать"),
Table(["Нужно", "Сеть", "Почему"], [
["доказательство «работает на Алтае» сегодня", "нативная", "единственная прошла GoldenModel"],
["минимум энергии и тактов", "обучение по тактам", "в 17 раз меньше событий при той же точности; осталось пройти GoldenModel"],
["обучение без компьютера, внутри платформы", "колонки или дофамин по ошибке", "единственные варианты; точность ниже"],
]),
Next("Вход: хост и рисунки", host_page, "почему на рисунках точность ниже и что с этим делает хост."),
]
# ---------------------------------------------------------------- 6. Хост
@Page("6", "Вход: хост и рисунки")
def host_page():
return [
H1("Вход: хост и рисунки"),
Lead("Сеть видит только то, что подал хост. Рисунок мышью отличается от цифры MNIST сильнее, чем кажется, и этот "
"разрыв закрывает подготовка входа и обучение на похожих рисунках."),
H2("Почему рисунок — другая задача"),
Text("Цифры MNIST — отсканированный почерк: штрих переменной толщины с серыми краями, цифра вписана в квадрат 20 × 20 "
"и поставлена центром масс в центр поля 28 × 28. Рисунок мышью — перо одной толщины, жёсткие края, произвольные "
"размер и место. Сеть, выучившая MNIST, видит на рисунке незнакомый «почерк»."),
Text("Чтобы мерить это без людей, тестовые цифры MNIST перерисованы так, как их нарисовала бы мышь: из цифры "
"выделяется осевая линия и обводится пером постоянной толщины на поле 280 × 280, потом уменьшается. Доля "
"верных ответов на таких копиях — <b>точность на рисунках</b>."),
H2("Что делает хост"),
Widget("hostprep"),
Table(["Шаг", "Что делает", "Зачем"], [
["рамка", "вписывает рисунок в квадрат 20 × 20 и ставит центр масс в центр 28 × 28", "как в MNIST: размер и положение перестают мешать"],
["выпрямление наклона", "по моментам изображения находит наклон главной оси и сдвигает строки, чтобы она стала вертикальной", "наклонный почерк приводится к прямому"],
["уменьшение с усреднением", "каждый пиксель 28 × 28 — среднее своей области", "сохраняет серые края, как у сканов"],
["кодирование", "яркость → спайки", "частотный или временной код"],
]),
H2("Что помогает и насколько"),
Text("Опыты на рецепте нативной сети: один и тот же учитель, меняется только подготовка и обучающие данные."),
Table(["Вариант", "Тест MNIST", "Рисунки"], [
["как было", "97,66%", "94,08%"],
["+ обучение на перерисованных копиях", "97,15%", "95,20%"],
["+ выпрямление наклона", "98,01%", "94,96%"],
["+ выпрямление и перерисованные копии", "97,76%", "95,86%"],
["скелет: и тест, и рисунки сводятся к тонкой линии", "95,98%", "92,53%"],
]),
Text("Лучший вариант — выпрямление вместе с обучением на копиях: +1,8 п.п. на рисунках почти без потери на тесте. "
"Приведение всего к тонкой линии вредит: вместе с разницей толщины теряется и полезная информация о форме."),
Callout("Хост готовит вход и читает выход, но не вклинивается между слоями сети. Иначе часть распознавания уходит с чипа, "
"как у детекторов с суммированием на хосте.", title="Граница хоста", kind="note"),
Next("От цифр к мусору", trash_section, "что из этого переносится на робота."),
]
# ---------------------------------------------------------------- 7. Мусор
@Page("7", "От цифр к мусору")
def trash_section():
return [
H1("От цифр к мусору"),
Lead("Цифры были полигоном. На роботе другой вход — цветной кадр камеры, другие классы и другой бюджет нейронов."),
H2("Что переносится"),
Table(["Приём", "Почему подходит мусору"], [
["обучение на компьютере сразу под палитру чипа", "чип не учится; точность выше, чем у правил внутри KNP"],
["временной код и обучение по тактам", "≈1 событие на значимый пиксель; меньше энергии на кадр"],
["хост только на входе: окно, центровка, кодирование", "вся сеть остаётся на кристалле"],
["проверка счётчиков в KNP и GoldenModel", "доказательство без платы"],
["обходы правил чипа: тактовые смещения, очистка, «за/против»", "не зависят от задачи"],
]),
H2("Что не переносится"),
Text("Сама сеть. Цифра — это чёрно-белый штрих в центре поля, мусор — цветной объект на пёстром фоне. "
"Полносвязного слоя на пикселях для объектов мало: нужны признаки — узоры, которые ищутся по всей картинке. "
f"Что известно о признаках и почему они плохо ложатся на чип — на странице {LocalLink('объектов', objects_page)}."),
Links([
LocalLink("Объекты: что уже известно", objects_page) + " — опыты на кошках и собаках CIFAR-10.",
LocalLink("План распознавателя мусора", trash_plan) + " — шаги и открытые вопросы.",
]),
Next("Объекты: что известно", objects_page, "что дали опыты с объектами."),
]
@Page("7.1", "Объекты: что известно")
def objects_page():
return [
H1("Объекты: что уже известно"),
Lead("Перед мусором техника проверялась на фотографиях кошек и собак из CIFAR-10 (32 × 32, цвет). Это близкая задача: "
"два класса, пёстрый фон, объект в разных позах."),
H2("Узкое место — признаки"),
Text("Первая сеть брала готовые признаки HOG (направления перепадов яркости) и колонки STDP — около 62%. Проверка "
"показала, что дело не в колонках: даже линейный классификатор на HOG даёт около 60%. В признаках просто мало "
"информации."),
Text("Признаки, выученные из самих картинок, дали больше. Из картинок вырезаются кусочки 6 × 6, и метод k-средних "
"находит в них 400–1600 типичных узоров, без меток. Нейрон узора стреляет, если кусочек похож на его узор, а "
"спайки суммируются по четвертям картинки, чтобы не зависеть от точного положения. Без спайков на таких признаках "
"получается 77% — это потолок для выбранных признаков. Выучить узоры можно и нейронным правилом «победитель "
"забирает всё» по одной картинке: не хуже пакетного k-средних."),
H2("Где теряется точность"),
Table(["Переход", "Потеря", "Почему"], [
["нормировка контраста убрана", "−0,7 п.п.", "одним нейроном её не сделать, но потеря мала"],
["признаки и выход на спайках", "−8 п.п.", "нейрон признака за картинку стреляет в среднем один раз: оттенки силы признака теряются"],
["колонки STDP вместо обученного выхода", "до −10 п.п.", "на плотных признаках колонки молчат; приходится оставлять 256 сильнейших из 2048"],
]),
Text("Итог на свежем тесте из 600 картинок: колонки STDP в KNP — 67,0%, полностью спайковая цепочка — 68,3% "
"(погрешность ±3,7 п.п.). Лучшая сеть — детекторы признаков с выходом, обученным на компьютере: 71,35% на "
"официальном тесте CIFAR, исполнение в KNP на GPU."),
H2("Почему детекторы не ложатся на чип"),
Text("Аудит сохранённых весов этой сети против правил «Алтая»:"),
Table(["Проверка", "Сеть детекторов", "Предел «Алтая-2»"], [
["нейронов", "39 458 (200 узоров × 196 позиций + 258)", "8192 в прототипе"],
["разных величин веса на нейрон", "до 55 у детекторов, до 110 в скрытом слое", "4"],
["наибольший уровень", "от −492 108 до 569 761", "−32 768…32 767"],
["хост между слоями", "суммирование по четвертям, нормировка, перепаковка", "сеть целиком на чипе"],
]),
Text("Главная причина — отсутствие общих весов. В свёрточной сети узор «вертикальная черта» хранится один раз и "
"прикладывается ко всем позициям. На чипе каждая позиция — отдельный нейрон со своей копией весов, и число "
"нейронов растёт как «узоры × позиции». Подберите параметры, при которых сеть влезет в прототип:"),
Widget("budget"),
Text("Влезть можно только за счёт меньшего окна, большего шага и малого числа узоров — то есть ценой точности. "
"Это главный вопрос для мусора."),
Next("План распознавателя мусора", trash_plan, "как из этого собрать распознаватель мусора."),
]
@Page("7.2", "План распознавателя мусора")
def trash_plan():
return [
H1("План распознавателя мусора"),
Lead("Порядок работ, при котором каждый шаг проверяем, а ошибка видна до того, как появится плата."),
Steps([
"<b>Постановка.</b> Начать с двух классов «мусор / не мусор» на окне кадра. Хост режет кадр на окна, чип отвечает "
"по каждому, место мусора видно по сработавшим окнам.",
"<b>Данные.</b> Открытые наборы с фотографиями мусора (например, TACO, TrashNet) для начала и снимки с камеры робота "
"для честного теста. Тест не трогать до выбора модели.",
"<b>Бюджет.</b> До обучения выбрать размер окна, число узоров и шаг так, чтобы сеть влезла в 8192 нейрона; "
"проверить три числа со страницы правил.",
"<b>Обучение.</b> Сеть по тактам с временным кодом, огрублением весов до палитры с середины обучения и штрафом за "
"спайки; узоры первого слоя — локальные окна без общих весов.",
"<b>Проверка.</b> Целочисленный эталон → KNP на CPU и GPU → GoldenModel, сравнение счётчиков всех нейронов.",
"<b>Плата.</b> Запуск на AltAI_HW и замер энергии на кадр.",
], ordered=True),
H2("Открытые вопросы"),
Table(["Вопрос", "От чего зависит"], [
["какая конфигурация «Алтая» будет на роботе и есть ли плата", "бюджет нейронов, возможность измерить энергию"],
["классы: «мусор / не мусор» или типы мусора", "размер выходного слоя и объём данных"],
["какая камера: обычная или событийная", "событийная камера сама выдаёт спайки, и кодирование на хосте не нужно"],
["сколько кадров в секунду нужно роботу", "сколько тактов можно потратить на окно"],
]),
Next("Справочник", reference_section, "словарь терминов, правила измерений и карта репозитория."),
]
# ---------------------------------------------------------------- 8. Справочник
@Page("8", "Справочник")
def reference_section():
return [
H1("Справочник"),
Links([
LocalLink("Глоссарий", glossary) + " — термины вики в одном месте.",
LocalLink("Методика измерений", metrics_page) + " — как получены цифры и когда им верить.",
LocalLink("Где что лежит", repo_page) + " — код, модели и команды в репозитории проекта.",
]),
]
@Page("8.1", "Глоссарий")
def glossary():
return [
H1("Глоссарий"),
Table(["Термин", "Определение"], [
["Спайк", "событие «нейрон выстрелил» в определённый такт; несёт один бит"],
["Такт", "шаг времени сети; за такт нейрон складывает пришедшее и решает, стрелять ли"],
["Уровень (потенциал)", "накопленный заряд нейрона; на «Алтае» — целое 16 бит"],
["Порог", "уровень, при котором нейрон выдаёт спайк"],
["Вес", "на сколько изменится уровень получателя от одного спайка по связи"],
["Палитра весов", "набор допустимых величин веса, например {0, ±1, ±2, ±4}"],
["Синаптическое событие", "одно сложение «спайк → вес» в получателе; мера работы и энергии сети"],
["Частотный код", "яркость передаётся числом спайков за картинку"],
["Временной код", "яркость передаётся моментом единственного спайка: ярче — раньше"],
["Тактовая линия", "входная линия, которая стреляет каждый такт; так на «Алтае» задаётся смещение нейрона"],
["Очистка", "пачка отрицательных спайков после картинки, обнуляющая уровни"],
["STDP", "правило: связь крепнет, если вход пришёл перед выстрелом, и слабеет, если после"],
["Дофамин", "сигнал награды в KNP, который закрепляет или отменяет недавние изменения связей"],
["Суррогатный градиент", "замена производной ступеньки-спайка гладкой функцией при обучении"],
["Квантование", "перевод дробных весов в целые из палитры"],
["Хост", "обычный процессор рядом с чипом: готовит вход и читает ответ"],
["GoldenModel", "программная копия «Алтая» из официального бэкенда, AltAI_GM"],
["Размещатель", "часть бэкенда «Алтая», раскладывающая нейроны и связи по ядрам"],
["Счётчики спайков", "сколько раз выстрелил каждый нейрон за картинку; по ним сравнивают исполнителей"],
["Точность на рисунках", "точность на тестовых цифрах, перерисованных пером постоянной толщины"],
]),
]
@Page("8.2", "Методика измерений")
def metrics_page():
return [
H1("Методика измерений"),
Lead("Как получены цифры в вики и при каких условиях им можно верить."),
H2("Данные"),
Text("MNIST: 60 000 обучающих и 10 000 тестовых цифр. Из обучающих 5000 отложены на валидацию. Все настройки — пороги, "
"масштабы, число эпох, выбор лучшего варианта — выбираются по валидации. Тест открывается один раз, после "
"выбора: иначе оценка завышена."),
H2("Метрики"),
Table(["Метрика", "Определение"], [
["Точность", "доля верных ответов на тесте; молчание сети считается ошибкой"],
["Точность на рисунках", "то же на перерисованных тестовых цифрах"],
["Синаптические события", "сложений «спайк → вес» на одну картинку, среднее по тесту"],
["Входные спайки", "сколько событий хост отправляет на чип за картинку"],
["Такты", "длительность распознавания одной картинки"],
["Совпадение счётчиков", "счётчик каждого нейрона одинаков в эталоне, KNP и GoldenModel на каждой картинке"],
]),
H2("Шум"),
Text("Разница меньше 0,3 п.п. на тесте MNIST при одном прогоне — в пределах случайности. На 1000 картинках разброс "
"по seed ±1–2 п.п., на 600 картинках погрешность ±3,7 п.п. Выводы о разнице в 1–2 п.п. на малых выборках без "
"нескольких seed ненадёжны."),
H2("Источник"),
Text("Цифра без источника не считается результатом. У каждого прогона есть файл итогов: final-summary.json, "
"VERIFIED.json или result.json рядом с моделью. Результат, записанный в FAILED.json, не используется, даже "
"если точность выглядит правдоподобно."),
]
@Page("8.3", "Где что лежит")
def repo_page():
return [
H1("Где что лежит"),
Lead("Репозиторий проекта — git.snw.su/int/our_knp. Пути ниже — от его корня."),
Table(["Что", "Где"], [
["нативная сеть: обучение, граф KNP, проверки", "mnist-altai-native/: prepare_native.py, native_digits.cpp, run_verify.py, run_sharded.py"],
["учитель нативной сети", "mnist-fast/: model_core.py, train_fast.py"],
["колонки STDP и переключатели KNP_*", "examples/examples/mnist-learn/models/altai/construct_network.cpp"],
["бэкенд для дофамина по ошибке", "readout/build_delta_cpu.py; запуски — readout/delta_runs.sh"],
["признаки штрихов", "features/stroke_features.py"],
["признаки объектов, спайковая цепочка", "objects/kmeans_features.py, objects/spiking_pipeline.py, objects/dopamine_readout.py"],
["симулятор колонок и страницы рисования", "viz/"],
["отчёты и журналы", "reports/; начинать с reports/Итоги_и_план.md"],
]),
H2("Нативная сеть: сборка и проверка"),
Code('''python3 mnist-altai-native/build_native.py # собрать native_digits
python mnist-altai-native/prepare_native.py # переобучить (перезаписывает модель)
python mnist-altai-native/run_verify.py cpu 10000 8 cpu-run # KNP CPU, 10 000 цифр
python mnist-altai-native/run_verify.py cuda 10000 8 cuda-run # наш CUDA-бэкенд
python mnist-altai-native/run_sharded.py gm-run # GoldenModel, первые 1000, 8 процессов''', "bash"),
Text("Каждый прогон пишет в новый каталог; VERIFIED.json появляется только при совпадении всех счётчиков."),
Callout("Код сети по тактам и опытов с рисунками пока не в репозитории: веса и симулятор лежат только в данных этой "
"вики (data/models.json, widgets.js).", title="Не в репозитории", kind="warning"),
]
HEADER = [
HeaderButton("Обзор", overview),
HeaderButton("Алтай", altai_section),
HeaderButton("Обучение", learning_section),
HeaderButton("Сравнение", compare_page),
HeaderButton("Мусор", trash_section),
]
def custom_js() -> Path:
"""Данные моделей + виджеты в одном файле custom_js."""
out = ASSETS / "inject.generated.js"
data = json.loads((HERE / "data" / "models.json").read_text(encoding="utf-8"))
out.write_text("globalThis.WIKI_DATA = " + json.dumps(data, ensure_ascii=False, separators=(",", ":")) + ";\n"
+ (ASSETS / "widgets.js").read_text(encoding="utf-8"), encoding="utf-8")
return out
OPTIONS = dict(title=TITLE, assets_dir=str(ASSETS), custom_css=str(ASSETS / "widgets.css"), header_buttons=HEADER)
if __name__ == "__main__":
js = custom_js()
if len(sys.argv) > 1 and sys.argv[1] == "build":
result = build(build_directory=str(HERE / "build"), custom_js=str(js), manifest_path="./manifest.json", **OPTIONS)
print("built", HERE / "build")
else:
run(port=1234, host="0.0.0.0", custom_js=str(js), live_preview=True, **OPTIONS)