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
This commit is contained in:
int 2026-10-19 08:26:11 +00:00
parent 4936928a8d
commit 917808edda
No known key found for this signature in database
6 changed files with 1228 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: нативная сеть и импульсная сеть по тактам дают те же ответы и ту же точность.

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

@ -0,0 +1,92 @@
/* Виджеты вики: монохромная тема 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); }
.content .def { border-left: 2px solid var(--text); padding: .2rem 0 .2rem 1rem; margin: 1rem 0 1.4rem; font-size: 1.08em; }
.content .facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin: 1.2rem 0 1.6rem; }
.content .facts div { border: 1px solid var(--border); border-radius: 16px; padding: 12px 14px; background: var(--shell); display: flex; flex-direction: column; gap: 2px; }
.content .facts b { font-size: 1.5em; font-weight: 600; font-variant-numeric: tabular-nums; }
.content .facts span { color: var(--muted); font-size: .88em; }
@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; } }

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

@ -0,0 +1,468 @@
/* Интерактивные виджеты вики «Нейроморфное распознавание на Алтае».
* Страница вставляется через 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();
};
/* 6. Сетка ядер Алтая */
W.chipgrid = (el) => {
card(el); const svg = s("svg", { viewBox: "0 0 420 420", class: "w-svg", role: "img", "aria-label": "Сетка 4×4 ядра" }), say = note("Нажмите на ядро: пакет со спайком войдёт через порт и пройдёт по соседям."); let path = [], step = 0, target = null, timer = null;
const gx = (i) => 40 + i * 90, gy = (j) => 80 + j * 82;
function draw() { const c = C(); svg.replaceChildren(); svg.append(s("rect", { x: 34, y: 18, width: 352, height: 30, rx: 10, fill: c.spike, opacity: .18 }), s("text", { x: 210, y: 38, "text-anchor": "middle", fill: c.text, "font-size": 13 }, "порт: вход с камеры и выход ответа"));
for (let i = 0; i < 4; i++) { svg.append(s("line", { x1: gx(i) + 35, y1: 48, x2: gx(i) + 35, y2: gy(0), stroke: c.border, "stroke-width": 3 })); for (let j = 0; j < 3; j++) svg.append(s("line", { x1: gx(i) + 35, y1: gy(j) + 70, x2: gx(i) + 35, y2: gy(j + 1), stroke: c.border, "stroke-width": 3 }), s("line", { x1: gx(j) + 70, y1: gy(i) + 35, x2: gx(j + 1), y2: gy(i) + 35, stroke: c.border, "stroke-width": 3 })); }
const vis = new Set(path.slice(0, step + 1).map((p) => p.join()));
for (let j = 0; j < 4; j++) for (let i = 0; i < 4; i++) { const isT = target && target[0] === i && target[1] === j, v = vis.has(i + "," + j);
const r = s("rect", { x: gx(i), y: gy(j), width: 70, height: 70, rx: 14, fill: isT ? c.spike : v ? c.control : c.shell, stroke: v ? c.spike : c.text, "stroke-width": 1.5, tabindex: 0, role: "button", "aria-label": "ядро " + (j * 4 + i + 1), style: "cursor:pointer" });
r.addEventListener("click", () => send(i, j)); r.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); send(i, j); } });
svg.append(r, s("text", { x: gx(i) + 35, y: gy(j) + 33, "text-anchor": "middle", fill: isT ? "#fff" : c.text, "font-size": 13, "pointer-events": "none" }, "ядро " + (j * 4 + i + 1)), s("text", { x: gx(i) + 35, y: gy(j) + 51, "text-anchor": "middle", fill: isT ? "#fff" : c.muted, "font-size": 11, "pointer-events": "none" }, "≈512 нейр.")); }
if (path.length) { const p = path[Math.min(step, path.length - 1)]; svg.append(s("circle", { cx: gx(p[0]) + 35, cy: p[1] < 0 ? 33 : gy(p[1]) + 35, r: 8, fill: c.spike, stroke: c.shell, "stroke-width": 2 })); } }
function send(i, j) { target = [i, j]; path = [[i, -1]]; for (let y = 0; y <= j; y++) path.push([i, y]); step = 0; clearInterval(timer);
timer = setInterval(() => { if (!el.isConnected) return clearInterval(timer); step++; draw(); if (step >= path.length - 1) { clearInterval(timer); say.textContent = `Пакет вошёл через порт и дошёл до ядра ${j * 4 + i + 1} за ${j + 1} переход(а). Чем дальше ядро и чем больше пакетов, тем дольше путь — поэтому размещатель кладёт связанные нейроны рядом.`; } }, 260); draw(); }
el.append(svg, say); draw(); alive(el, 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. Схема трёх архитектур */
W.arch = (el) => {
card(el); let mode = 0; const svg = s("svg", { viewBox: "0 0 700 300", class: "w-svg w-wide", role: "img", "aria-label": "Схема архитектуры" }), tab = h("div", { class: "w-kv" });
const ROWS = [
[["Слои", "пиксели → 10 колонок × 20 нейронов"], ["Ответ", "колонка, которая выстрелила больше"], ["Обучение", "в KNP: STDP и дофамин, без учителя ошибок"], ["Слабость", "учит трафареты, не видит отсутствие линий"]],
[["Слои", "пиксели → 256 скрытых → 160 «за/против»"], ["Ответ", "«за» минус «против», максимум"], ["Обучение", "обычная сеть на компьютере → перенос весов"], ["Цена", "32 такта частотного кода, сотни тысяч синаптических событий"]],
[["Слои", "пиксели → 256 скрытых → 10 выходов"], ["Ответ", "больше спайков, при равенстве — раньше"], ["Обучение", "градиент прямо по тактам динамики Алтая"], ["Выигрыш", "каждый пиксель стреляет один раз, ответ за несколько тактов"]]];
const txt = (x, y, t, size = 12, color, weight = 400, anchor = "middle") => svg.append(s("text", { x, y, "text-anchor": anchor, fill: color || C().muted, "font-size": size, "font-weight": weight }, t));
function draw() { const c = C(); svg.replaceChildren(); const lines = [];
const D7 = [[1, 0], [2, 0], [3, 0], [4, 0], [4, 1], [3, 2], [2, 3], [2, 4], [2, 5]];
for (let y = 0; y < 6; y++) for (let x = 0; x < 6; x++) svg.append(s("rect", { x: 16 + x * 13, y: 110 + y * 13, width: 11, height: 11, rx: 2, fill: D7.some(([a, b]) => a === x && b === y) ? c.text : c.control }));
txt(55, 96, mode === 2 ? "пиксели → один спайк" : "пиксели → спайки");
if (mode === 0) { for (let k = 0; k < 10; k++) { const cx = 190 + k * 36, win = k === 7; svg.append(s("rect", { x: cx - 14, y: 56, width: 28, height: 176, rx: 9, fill: win ? c.control : "none", stroke: win ? c.spike : c.border, "stroke-width": win ? 2 : 1 })); for (let n2 = 0; n2 < 10; n2++) svg.append(s("circle", { cx: cx - 5 + (n2 % 2) * 10, cy: 70 + Math.floor(n2 / 2) * 34, r: 4, fill: win && n2 < 6 ? c.spike : c.pot })); txt(cx, 252, String(k), 13, win ? c.spike : c.text, 600); lines.push([96, 145, cx - 14, 90 + (k * 37) % 120]); }
txt(352, 40, "10 колонок по 20 нейронов: каждая колонка — трафарет своей цифры", 12, c.text, 600); txt(352, 280, "ответ — колонка «7»: выстрелила больше всех", 13, c.text, 600); }
else { const hx = 210, hy = 70; for (let i = 0; i < 64; i++) svg.append(s("circle", { cx: hx + (i % 8) * 16, cy: hy + Math.floor(i / 8) * 20, r: 4.5, fill: (i * 29) % 7 < 2 ? c.spike : c.pot })); txt(hx + 56, 50, "256 скрытых нейронов (показана четверть)", 12, c.text, 600); for (let k = 0; k < 8; k++) lines.push([96, 145, hx - 6, hy + k * 20]);
const ox = 420, oy = 64;
if (mode === 1) { for (let d = 0; d < 10; d++) { const y = oy + d * 19; txt(ox - 10, y + 5, String(d), 12, d === 7 ? c.spike : c.text, 600, "end"); for (let g2 = 0; g2 < 8; g2++) svg.append(s("rect", { x: ox + g2 * 9, y: y - 5, width: 7, height: 10, rx: 2, fill: c.good, opacity: d === 7 ? 1 : .3 }), s("rect", { x: ox + 80 + g2 * 9, y: y - 5, width: 7, height: 10, rx: 2, fill: c.bad, opacity: d === 4 || d === 9 ? .9 : .25 })); lines.push([hx + 118, hy + (d * 31) % 140, ox - 2, y]); }
txt(ox + 32, 48, "за ×8", 11, c.good, 600); txt(ox + 112, 48, "против ×8", 11, c.bad, 600); svg.append(s("rect", { x: 600, y: 140, width: 90, height: 54, rx: 12, fill: c.text })); txt(645, 163, "хост:", 11, c.shell, 600); txt(645, 181, "за − против", 11, c.shell); txt(330, 282, "32 такта · каждый пиксель стреляет до 32 раз", 13, c.text, 600); }
else { for (let d = 0; d < 10; d++) { const y = oy + d * 19; txt(ox - 10, y + 5, String(d), 12, d === 7 ? c.spike : c.text, 600, "end"); svg.append(s("circle", { cx: ox + 10, cy: y, r: 6, fill: d === 7 ? c.spike : c.pot })); for (let t = 0; t < 8; t++) svg.append(s("rect", { x: ox + 26 + t * 12, y: y - 4, width: 9, height: 8, rx: 2, fill: d === 7 && t >= 3 && t % 2 ? c.spike : c.control })); lines.push([hx + 118, hy + (d * 31) % 140, ox + 4, y]); }
txt(ox + 74, 48, "такты 0…7", 11, c.text, 600); txt(330, 282, "8 тактов · каждый пиксель стреляет один раз", 13, c.text, 600); } }
lines.forEach(([x1, y1, x2, y2]) => svg.prepend(s("line", { x1, y1, x2, y2, stroke: c.border })));
tab.replaceChildren(...ROWS[mode].flatMap(([k, v]) => [h("b", { text: k }), h("span", { text: v })])); }
el.append(seg(["Колонки STDP", "Нативная сеть", "Импульсная по тактам"], (i) => { mode = i; draw(); }), h("div", { class: "w-scroll" }, svg), tab); draw(); alive(el, draw);
};
/* 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

641
wiki/knp_wiki.py Normal file
View file

@ -0,0 +1,641 @@
"""Вики «Нейроморфное распознавание на Алтае» на 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, Details, H1, H2, H3, List, LocalLink, P, 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 Def(text):
return f'<p class="def">{text}</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 Facts(items):
cells = "".join(f"<div><b>{value}</b><span>{label}</span></div>" for value, label in items)
return f'<div class="facts">{cells}</div>'
@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("Система, в которой распознавание изображений выполняет импульсная нейронная сеть "
"на нейроморфном процессоре «Алтай», а подготовка входа и чтение ответа — обычный компьютер робота."),
Def("<b>Нейроморфное распознавание</b> — распознавание образов сетью из импульсных нейронов, "
"которые обмениваются короткими событиями-спайками и тратят энергию только в момент события. "
"В отличие от обычной нейросети, вычисление идёт по тактам и разреженно: молчащий нейрон ничего не считает."),
Pipeline(),
H2("Из чего состоит"),
Table(["Часть", "Что это", "Роль в системе"], [
["Алтай", "нейроморфный процессор Мотив НТ: сетка ядер с целочисленными нейронами", "исполняет импульсную сеть"],
["KNP", "Kaspersky Neuromorphic Platform: программная платформа импульсных сетей", "описывает сеть, моделирует её на CPU/GPU, загружает на Алтай"],
["GoldenModel", "программная копия Алтая из бэкенда Мотив НТ", "доказывает, что сеть заработает на чипе"],
["Хост", "обычный процессор робота", "готовит вход, кодирует его спайками, читает ответ"],
["Обучение", "обычный компьютер, PyTorch / NumPy", "находит веса с учётом ограничений чипа"],
]),
H2("Ключевые свойства"),
Facts([
("97,4%", "точность импульсной сети на цифрах MNIST"),
("8 тактов", "от первого спайка до ответа"),
("≈143", "входных спайка на картинку — по одному на пиксель"),
("≈20 тыс.", "синаптических событий на картинку"),
("8192", "нейрона в 16-ядерном прототипе Алтая"),
("4", "разных величины веса на нейрон"),
]),
Callout("Чип не обучается: в официальном бэкенде Алтая пластичность выключена. Поэтому сеть учат на компьютере "
"сразу под рамки чипа, а на Алтай загружают готовые целые веса.", title="Главное ограничение", kind="note"),
H2("Как читать вики"),
Links([
LocalLink("Импульсные сети", snn_section) + " — что такое импульсный нейрон, как кодируется вход, как сети учатся.",
LocalLink("Платформа KNP", knp_section) + " — программная модель сети и способы её исполнения.",
LocalLink("Процессор Алтай", altai_section) + " — устройство чипа и правила, которым должна подчиняться сеть.",
LocalLink("Архитектуры распознавания", arch_section) + " — четыре способа построить распознаватель и их сравнение.",
LocalLink("Распознавание мусора", trash) + " — как всё это ложится на задачу робота.",
]),
]
@Page("1.1", "Глоссарий")
def glossary():
return [
H1("Глоссарий"),
Table(["Термин", "Определение"], [
["Спайк", "Короткое событие «нейрон выстрелил». Несёт один бит и момент времени."],
["Такт", "Шаг дискретного времени. За такт нейрон суммирует пришедшие спайки и решает, стрелять ли."],
["Потенциал (уровень)", "Накопленный заряд нейрона. Когда он достигает порога, нейрон стреляет."],
["Порог", "Уровень, при котором нейрон выдаёт спайк. На Алтае — целое число."],
["Синапс, вес", "Связь между нейронами. Вес — на сколько изменится потенциал получателя от одного спайка."],
["Синаптическое событие", "Одно сложение «спайк × вес». Основная мера работы и энергии импульсной сети."],
["Популяция", "Группа однотипных нейронов в KNP."],
["Проекция", "Пучок связей из одной популяции в другую, с весами и задержками."],
["Частотный код", "Яркость пикселя передаётся частотой спайков."],
["Временной код", "Яркость передаётся моментом единственного спайка: ярче — раньше."],
["STDP", "Правило обучения: связь крепнет, если входной спайк пришёл незадолго до выстрела, и слабеет, если после."],
["Дофамин", "Третий сигнал обучения: награда или наказание, превращающие недавние изменения связей в постоянные."],
["Суррогатный градиент", "Способ обучать импульсную сеть обратным распространением: ступенька спайка заменяется гладкой функцией."],
["Квантование", "Перевод дробных весов в небольшой набор целых, который понимает чип."],
["Кроссбар", "Таблица связей внутри ядра Алтая: строки — входящие линии, столбцы — нейроны."],
["Размещатель", "Часть бэкенда Алтая, раскладывающая нейроны и связи по ядрам."],
["GoldenModel", "Эталонная программная модель Алтая, совпадающая с чипом по счётчикам спайков."],
["Хост", "Обычный процессор, который готовит вход для чипа и читает его ответ."],
["Валидация и тест", "На валидации выбирают настройки; тест смотрят один раз, иначе оценка завышена."],
]),
]
# ---------------------------------------------------------------- 2. Импульсные сети
@Page("2", "Импульсные сети")
def snn_section():
return [
H1("Импульсные нейронные сети"),
Def("<b>Импульсная нейронная сеть</b> (spiking neural network, SNN) — сеть, в которой нейроны хранят внутреннее "
"состояние и обмениваются не числами, а событиями-спайками во времени."),
H2("Чем отличается от обычной нейросети"),
Table(["", "Обычная нейросеть", "Импульсная сеть"], [
["Сигнал между нейронами", "вещественное число", "спайк: есть или нет, и когда"],
["Время", "нет: один проход", "дискретные такты"],
["Состояние нейрона", "нет", "потенциал, копится между тактами"],
["Объём вычислений", "все нейроны на каждом входе", "только там, где пришли спайки"],
["Обучение", "обратное распространение", "STDP, дофамин или суррогатный градиент"],
["Где выгодна", "сервер, видеокарта", "нейроморфный чип: дрон, робот, датчик"],
]),
P("Выигрыш импульсной сети — в разреженности. Если на картинке 150 закрашенных пикселей из 784, "
"то при временном коде на чип приходит около 150 событий, и каждое затрагивает только свои связи."),
Links([
LocalLink("Импульсный нейрон", neuron_page),
LocalLink("Кодирование входа", coding_page),
LocalLink("Связи и сложение сигналов", links_page),
LocalLink("Обучение", learning_page),
]),
]
@Page("2.1", "Импульсный нейрон")
def neuron_page():
return [
H1("Импульсный нейрон AltAILIF"),
Def("<b>AltAILIF</b> — модель нейрона «накопление–утечка–выстрел», повторяющая нейрон процессора «Алтай»: "
"целочисленный 16-битный потенциал, целые порог, утечка и значение сброса."),
P("Удобная аналогия — стакан. Каждый входной спайк доливает в него воду (вес связи), вода понемногу "
"испаряется (утечка), а когда уровень доходит до черты (порог), нейрон выдаёт спайк и стакан частично или полностью опустошается."),
Widget("neuron"),
H2("Шаг нейрона за такт"),
List([
"к потенциалу прибавляются веса всех пришедших спайков (тормозящие — со знаком минус);",
"прибавляется утечка; при флаге leak_rev её знак следует за знаком потенциала;",
"если потенциал ≥ порога — спайк; при is_diff из потенциала вычитается порог, при is_reset он сбрасывается;",
"если потенциал ниже отрицательного порога — при saturate он упирается в этот порог.",
], ordered=True),
Table(["Флаг", "Что делает", "Как используется"], [
["is_diff", "после выстрела вычитает порог, остаток сохраняется", "в наших сетях: точный счёт заряда"],
["is_reset", "после выстрела сбрасывает потенциал в значение сброса", "штатный пример KNP"],
["saturate", "не даёт потенциалу уйти ниже отрицательного порога", "отрицательный порог 0: уровень не ниже нуля"],
["leak_rev", "утечка всегда тянет потенциал к нулю", "в наших сетях утечка 0"],
]),
Callout("Все числа нейрона целые, потенциал — 16 бит со знаком (от −32 768 до 32 767). "
"Сеть, у которой сумма входов за такт выходит за этот диапазон, на чипе не заработает.", title="Ограничение", kind="note"),
]
@Page("2.2", "Кодирование входа")
def coding_page():
return [
H1("Кодирование входа"),
Def("<b>Кодирование</b> — правило, по которому хост превращает яркость пикселей в спайки на входе сети."),
Widget("coding"),
Table(["", "Частотный код", "Временной код"], [
["Что несёт информацию", "сколько раз пиксель выстрелил", "в какой такт пиксель выстрелил"],
["Спайков на пиксель", "до T (числа тактов)", "не больше одного"],
["Входных спайков на цифру MNIST", "≈730 за 8 тактов, ≈3200 за 32", "≈143 при любом T"],
["Сколько нужно тактов", "много: точность оттенков растёт с T", "мало: 4–8"],
["Как обучать", "можно перенести обычную сеть", "нужно обучать по тактам"],
]),
P("Частотный код проще: число спайков приближённо равно активации обычной нейросети, поэтому её веса можно перенести. "
"Временной код экономнее, но нейроны должны реагировать на порядок прихода спайков, поэтому сеть учат прямо на динамике."),
Details("Почему вход ON/OFF не нужен сети, обученной на компьютере",
"Канал OFF стреляет на пустых пикселях, его значение равно «255 − яркость». У нейрона с весами разного знака и смещением "
"это уже выражается через канал ON, поэтому нового сигнала OFF не даёт, а входов и спайков становится вдвое больше. "
"Проверка на нативной сети: 96,3% с ON/OFF против 97,7% без него. Колонкам STDP такой сигнал нужен, но они "
"от лишних спайков замолкают."),
]
@Page("2.3", "Связи и сложение сигналов")
def links_page():
return [
H1("Связи и сложение сигналов"),
Def("<b>Проекция</b> — набор связей из одной группы нейронов в другую. Схему соединения выбирает конструктор сети, "
"обучение меняет только веса."),
Table(["Схема в KNP", "Кто с кем связан", "Где применяется"], [
["all_to_all", "каждый с каждым", "полносвязные слои: 784 → 256"],
["one_to_one", "i-й с i-м", "передача без изменений"],
["index_based", "по своему правилу, например окно", "детекторы признаков: каждый смотрит в свой кусок"],
["fixed_probability", "случайно с заданной вероятностью", "разреженные сети"],
["exclusive", "каждый с каждым, кроме себя", "взаимное торможение, «победитель забирает всё»"],
]),
H2("Сложение"),
P("Всё, что пришло в нейрон за один такт, складывается. У каждой связи есть задержка: спайк, отправленный на такте N "
"с задержкой D, учитывается на такте N + D."),
Widget("synsum"),
H2("Соревнование"),
P("Нейроны, которые видят одно и то же и учатся по одному правилу, выучивают одно и то же. Взаимное торможение "
"заставляет их делить работу: учится только тот, кто выстрелил первым."),
Widget("wta"),
]
@Page("2.4", "Обучение")
def learning_page():
return [
H1("Обучение импульсных сетей"),
Def("Спайк — это ступенька, у неё нет производной. Поэтому обычное обратное распространение к импульсной сети напрямую "
"не применимо, и существует несколько семейств правил обучения."),
H2("STDP: локальное правило без учителя"),
P("После выстрела каждый вход разбирается отдельно. Спайк, пришедший незадолго до выстрела, помог — связь крепнет. "
"Пришедший после — нейрон обошёлся без него, связь слабеет."),
Widget("stdp"),
H2("Сравнение способов"),
Table(["Способ", "Где идёт", "Чему учит", "Цифры MNIST"], [
["STDP (классический)", "в KNP", "частые узоры, без меток", "—"],
["STDP с ресурсом + дофамин (колонки KNP)", "в KNP", "«похоже на мою цифру»", "≈92%"],
["Дофамин по ошибке (дельта-правило)", "в KNP", "один слой по ошибке ответа", "≈94,8% (валидация)"],
["Перенос обычной сети", "на компьютере", "всю сеть обратным распространением по аналоговой копии", "97,8%"],
["Суррогатный градиент по тактам", "на компьютере", "всю сеть прямо на динамике спайков", "97,4% за 8 тактов"],
]),
Callout("Чем больше обучения уходит на компьютер, тем выше точность. Алтай не обучается вообще, поэтому для него "
"естественный путь — обучение на компьютере с учётом ограничений чипа.", title="Вывод", kind="note"),
H3("Суррогатный градиент"),
P("Прямой проход идёт по тактам с настоящими спайками. При обратном проходе производная ступеньки заменяется "
"гладкой функцией 1/(1+4|v|)². Сеть видит ту же арифметику, что и чип: целые веса, вычитание порога, уровень не ниже нуля, задержку в такт."),
]
# ---------------------------------------------------------------- 3. KNP
@Page("3", "Платформа KNP")
def knp_section():
return [
H1("Kaspersky Neuromorphic Platform"),
Def("<b>KNP</b> — открытая программная платформа (C++ и Python, Apache 2.0) для описания, обучения и исполнения "
"импульсных сетей на CPU, GPU и нейроморфном процессоре «Алтай»."),
H2("Модель сети"),
Table(["Уровень", "Что это"], [
["Популяция", "группа однотипных нейронов; параметры задаёт функция-генератор"],
["Проекция", "связи из популяции в популяцию: вес, задержка, тип, номера нейронов"],
["Network", "популяции и проекции"],
["Model", "сеть плюс входные и выходные каналы"],
["ModelExecutor", "прогоняет модель по тактам на выбранном бэкенде"],
]),
P("Каждый такт: популяции обновляют нейроны → шина доставляет сообщения → проекции превращают спайки в воздействия."),
Code('''P = knp::core::Population<AltAILIF>
R = knp::core::Projection<DeltaSynapse>
P hidden(make_neuron, 256); // 256 нейронов AltAILIF
R input(input_uid, hidden.get_uid(), gen, n); // связи вход → скрытый слой
Network net; net.add_population(hidden); net.add_projection(input);
Model model(net); model.add_input_channel(...); model.add_output_channel(...);
ModelExecutor exec(model, backend, inputs); exec.start(until_step);''', "cpp"),
Links([LocalLink("Бэкенды и модели нейронов", backends_page)]),
]
@Page("3.1", "Бэкенды и модели нейронов")
def backends_page():
return [
H1("Бэкенды и модели нейронов"),
Def("<b>Бэкенд</b> — исполнитель сети. Сеть описывается один раз, а запускается на любом бэкенде, который поддерживает её типы нейронов."),
Table(["Бэкенд", "Нейроны", "Обучение", "Замечание"], [
["CPU, один поток", "LIF, BLIFAT, AltAILIF и STDP-варианты", "да", "эталон"],
["CPU, много потоков", "LIF, BLIFAT", "частично", "AltAILIF не поддерживает"],
["CUDA (официальный)", "BLIFAT", "нет", "AltAILIF не поддерживает"],
["CUDA для AltAILIF (наш)", "AltAILIF, Delta", "нет", "побайтно совпадает с CPU; при угрозе потери точности float32 падает с ошибкой"],
["Алтай: GoldenModel и чип", "AltAILIF, Delta", "нет", "закрытая библиотека Мотив НТ, deb-пакет 1.0.2"],
]),
Table(["Модель нейрона", "Что это", "Где исполняется"], [
["LIF", "классический нейрон с утечкой", "CPU"],
["BLIFAT", "расширенная модель Kaspersky: адаптивный порог, торможение", "CPU, CUDA"],
["AltAILIF", "ровно возможности нейрона Алтая", "CPU, наш CUDA, Алтай"],
]),
P("Для распознавателя на Алтае имеет значение только AltAILIF с синапсами Delta: остальные модели на чип не переносятся."),
]
# ---------------------------------------------------------------- 4. Алтай
@Page("4", "Процессор Алтай")
def altai_section():
return [
H1("Процессор «Алтай»"),
Def("<b>Алтай</b> — нейроморфный процессор компании Мотив НТ: сетка одинаковых ядер, каждое из которых хранит нейроны "
"и таблицу их связей и обменивается спайками с соседями пакетами."),
Facts([("16", "ядер в прототипе"), ("8192", "нейрона в прототипе"), ("131 072", "нейрона в максимальной конфигурации"),
("16 бит", "потенциал со знаком"), ("4", "величины веса на нейрон"), ("нет", "обучения на кристалле")]),
Links([LocalLink("Сетка ядер", grid_page), LocalLink("Ядро и кроссбар", crossbar_page),
LocalLink("Ограничения и квантование", rules_page), LocalLink("Размещение и проверка", placement_page)]),
]
@Page("4.1", "Сетка ядер")
def grid_page():
return [
H1("Сетка ядер"),
Def("Ядра соединены с соседями; внешний мир подключается через <b>порт</b> — сейчас только с северной стороны сетки, стандартной длины 4 ядра."),
Widget("chipgrid"),
P("В эмуляторе по умолчанию сетка 4×4. Число нейронов в ядре — около 512 (8192 нейрона прототипа на 16 ядер; расчётная величина)."),
]
@Page("4.2", "Ядро и кроссбар")
def crossbar_page():
return [
H1("Ядро и кроссбар"),
Def("<b>Кроссбар</b> — таблица связей ядра: строки — входящие линии (аксоны), столбцы — нейроны, в клетке — вес. "
"Пришёл спайк по строке — каждый нейрон-столбец прибавляет вес из этой строки."),
Widget("crossbar"),
P("Отсюда главное ограничение: у нейрона не может быть больше 4 разных величин веса. Знак задаётся отдельно и в счёт не идёт."),
]
@Page("4.3", "Ограничения и квантование")
def rules_page():
return [
H1("Ограничения и квантование"),
Table(["Правило", "Следствие для сети"], [
["вес — целое, |w| ≤ 255", "веса после обучения переводятся в целые"],
["не больше 4 разных величин веса на нейрон", "учить сразу с палитрой, например {0, ±1, ±2, ±4}"],
["нулевых связей нет", "связь с весом 0 не создаётся"],
["один тип связи, знак в весе", "торможение — отрицательный вес"],
["потенциал 16 бит со знаком", "сумма входов за такт ограничена"],
["начальный потенциал игнорируется", "смещение подаётся отдельными входными спайками"],
["одинаковые номера входа в одном такте склеиваются", "каждой линии свой номер"],
["обучения нет", "веса загружаются готовыми"],
]),
H2("Квантование"),
Def("<b>Квантование</b> — замена дробных весов ступеньками из небольшого набора. Чем меньше ступенек, тем грубее нейрон."),
Widget("quant"),
H2("Влезет ли сеть"),
Widget("fit"),
]
@Page("4.4", "Размещение и проверка")
def placement_page():
return [
H1("Размещение и проверка"),
Def("<b>Размещатель</b> — часть бэкенда Алтая, которая оценивает число нужных ядер, раскладывает нейроны и связи и отказывается, "
"если сеть не помещается. Нейрон, которому нужно больше входов, чем строк в ядре, он делит на несколько."),
Table(["Ступень проверки", "Что доказывает"], [
["Целочисленный эталон (NumPy)", "арифметика сети записана верно"],
["KNP на CPU", "граф собран правильно, счётчики спайков совпадают с эталоном"],
["KNP на GPU", "то же на большом объёме данных, побайтно"],
["GoldenModel", "размещение и правила чипа соблюдены, счётчики совпадают"],
["Чип AltAI_HW", "работа на железе"],
]),
Callout("Принятие сети размещателем ещё не доказывает правильность. Конфигурация с 8 копиями сети в сетке 4×4 "
"была принята, но счётчики спайков разошлись с эталоном. Критерий — совпадение счётчиков каждого нейрона.",
title="Наблюдение", kind="note"),
]
# ---------------------------------------------------------------- 5. Архитектуры
@Page("5", "Архитектуры распознавания")
def arch_section():
return [
H1("Архитектуры распознавания"),
Def("<b>Архитектура</b> распознавателя — устройство слоёв, способ кодирования входа и способ обучения. "
"Все четыре архитектуры ниже построены на нейронах AltAILIF."),
Widget("arch"),
Table(["Архитектура", "Устройство", "Обучение", "Ложится на чип"], [
["Колонки STDP", "пиксели → 10 колонок × 20 нейронов", "в KNP, STDP + дофамин", "да"],
["Детекторы + выход", "200 типов детекторов × 196 позиций → хост → 256 → 2", "детекторы k-средними, выход на компьютере", "нет"],
["Нативная сеть", "784 → 256 → 160 «за/против», 32 такта", "перенос обычной сети", "да, проверено"],
["Импульсная по тактам", "784 → 256 → 10, 8 тактов, временной код", "суррогатный градиент", "да, по рамкам"],
]),
Links([LocalLink("Колонки STDP", columns_page), LocalLink("Детекторы признаков", detectors_page),
LocalLink("Нативная сеть", native_page), LocalLink("Импульсная сеть по тактам", ticks_page),
LocalLink("Сравнение архитектур", compare_page)]),
]
@Page("5.1", "Колонки STDP")
def columns_page():
return [
H1("Колонки STDP"),
Def("<b>Колонки</b> — архитектура штатного примера KNP: на каждый класс отводится колонка из 20 нейронов, "
"которые учатся по STDP с дофамином; ответ — колонка, выстрелившая больше всех."),
H2("Как работает"),
List(["картинка кодируется частотно за 10 тактов;", "спайки идут во все колонки;",
"при обучении метка через вспомогательную популяцию будит колонку правильного класса, и её связи с активными пикселями крепнут;",
"при распознавании соседи в колонке тормозят друг друга, чтобы нейроны специализировались."], ordered=True),
H2("Свойства"),
Table(["Свойство", "Значение"], [
["Точность на MNIST", "≈92%"], ["Обучение на платформе", "да"],
["Сдвиг цифры на 2 пикселя", "точность падает до ≈64–67%"],
["Типичная ошибка", "4 → 9: трафарет девятки целиком накрывает четвёрку"],
]),
P("Колонка учит «похоже на мою цифру», но не «не путай с соседней», и не получает сигнала «здесь линии нет»: "
"пустой пиксель молчит. Это потолок архитектуры, а не настроек."),
]
@Page("5.2", "Детекторы признаков")
def detectors_page():
return [
H1("Детекторы признаков с хостом"),
Def("Архитектура для фотографий: слой нейронов-детекторов ищет выученные узоры по всей картинке, хост суммирует "
"их отклики по областям и нормирует, затем небольшая сеть выдаёт класс."),
Table(["Ступень", "Где исполняется", "Размер"], [
["пиксели → спайки, 10 тактов", "хост", "32×32×3"],
["детекторы узоров 6×6×3, шаг 2", "KNP", "200 типов × 196 позиций = 39 200 нейронов"],
["суммирование по четвертям, нормировка", "хост", "800 чисел"],
["скрытый слой и выход", "KNP", "256 + 2 нейрона"],
]),
P("Точность на кошках и собаках CIFAR-10 — 71,35%, на транспорте — 71,25% (валидация). На Алтай архитектура не ложится: "
"нейронов в 5 раз больше прототипа, у нейронов 55–110 разных весов вместо 4, потенциалы доходят до ±570 000, "
"а между слоями работает хост."),
Callout("На чипе нет общих весов: детектор «вертикальная черта» в каждой позиции — отдельный нейрон. "
"Поэтому число нейронов растёт как «типы × позиции».", title="Почему так много нейронов", kind="note"),
]
@Page("5.3", "Нативная сеть")
def native_page():
return [
H1("Нативная сеть"),
Def("<b>Нативная сеть</b> — двухслойная сеть, целиком составленная из нейронов AltAILIF и уложенная в рамки чипа. "
"Её веса найдены обучением обычной сети и перенесены."),
Table(["Слой", "Размер", "Параметры"], [
["вход", "784", "частотный код, 32 такта"],
["скрытый", "256 AltAILIF", "веса {0, ±1, ±2, ±4}, порог 32, вычитание порога"],
["выход", "160 AltAILIF", "10 классов × 8 групп «за» и 8 групп «против», порог 4"],
["хост", "—", "«за» минус «против», максимум"],
]),
H2("Почему выход «за/против»"),
P("Потенциал не опускается ниже нуля, а у нейрона мало разных весов. Поэтому отрицательные доводы собираются отдельными "
"нейронами «против», а вычитание делает хост. Так сеть учитывает «здесь есть крыша — значит не 4»."),
H2("Обучение"),
List(["обычная сеть 784 → 256 → 10 учится обратным распространением; активация ограничена 0…4 и округляется, как счёт спайков;",
"веса скрытого слоя переводятся в целые {0, ±1, ±2, ±4};",
"выход заново обучается по настоящим счётчикам спайков скрытого слоя и раскладывается на группы."], ordered=True),
Table(["Свойство", "Значение"], [
["Точность на MNIST", "97,8%; с выпрямлением наклона на хосте — 98,0%"],
["Проверка", "KNP CPU и CUDA — все счётчики совпали; GoldenModel — 975 из 1000"],
["Цена", "≈3200 входных спайков и ≈338 тыс. синаптических событий на картинку"],
]),
]
@Page("5.4", "Импульсная сеть по тактам")
def ticks_page():
return [
H1("Импульсная сеть по тактам"),
Def("<b>Импульсная сеть по тактам</b> — сеть 784 → 256 → 10 нейронов AltAILIF с временным кодом входа, "
"обученная суррогатным градиентом прямо на целочисленной динамике чипа."),
Widget("ticks"),
H2("Устройство"),
Table(["Элемент", "Значение"], [
["Вход", "каждый пиксель ярче 32 стреляет один раз; такт = ⌊(255 − яркость)·8/224⌋"],
["Скрытый слой", "256 нейронов, порог 8, веса и смещения {0, ±1, ±2, ±4}"],
["Выход", "10 нейронов, порог 8, задержка 1 такт"],
["Ответ", "больше спайков; при равенстве — раньше первый спайк"],
["Ранний ответ", "такт, после которого лидер больше не меняется"],
]),
H2("Обучение"),
List(["прямой проход идёт по 8 тактам с настоящими спайками и вычитанием порога;",
"с 9-й эпохи веса в прямом проходе огрубляются до палитры чипа, градиент проходит «насквозь»;",
"к ошибке добавлен штраф за спайки скрытого слоя — сеть учится молчать, когда может;",
"настройки выбираются по валидации, итог считается отдельным целочисленным симулятором."], ordered=True),
Table(["Свойство", "Значение"], [
["Точность на MNIST", "97,4%"], ["Имитация рисования мышью", "95,9%"], ["Тактов", "8"],
["Входных спайков", "≈143"], ["Синаптических событий", "≈20 тыс. — в 17 раз меньше нативной сети"],
["Проверка", "целочисленный эталон; KNP и GoldenModel — следующий шаг"],
]),
]
@Page("5.5", "Сравнение архитектур")
def compare_page():
return [
H1("Сравнение архитектур"),
Def("Сравнение на одной задаче — цифры MNIST, тест 10 000 изображений — по точности, числу тактов и работе чипа."),
Table(["", "Колонки STDP", "Нативная сеть", "Импульсная по тактам"], [
["Точность", "92,2%", "97,8%", "97,4%"],
["Тактов", "15", "32", "8"],
["Входных спайков", "≈870", "≈3200", "≈143"],
["Синаптических событий", "≈175 тыс.", "≈338 тыс.", "≈20 тыс."],
["Нейронов", "200", "416", "266"],
["Учится на платформе", "да", "нет", "нет"],
["Проверено в GoldenModel", "—", "да", "ещё нет"],
]),
H2("Одна цифра — три сети"),
P("Нарисуйте цифру или выберите тестовую. Все три сети считаются здесь целочисленно, как на чипе."),
Widget("compare"),
H2("Точность против работы чипа"),
P("Каждая точка — сеть или режим. Чем левее, тем меньше сложений «спайк × вес» и энергии; чем выше, тем точнее."),
Widget("tradeoff"),
P("Обрезать нативную сеть по тактам можно до 8 — дальше точность обваливается (84,6% на 4 тактах). "
"Сеть, обученная по тактам с временным кодом, держит 97% даже на 4 тактах."),
]
# ---------------------------------------------------------------- 6–8
@Page("6", "Обработка на хосте")
def host_page():
return [
H1("Обработка на хосте"),
Def("<b>Хост</b> — обычный процессор рядом с чипом. Он делает то, что дёшево сделать один раз на картинку: "
"вырезает и центрирует объект, выпрямляет наклон и превращает яркость в моменты спайков."),
Widget("hostprep"),
Table(["Шаг", "Что делает", "Зачем"], [
["рамка 20×20", "вписывает объект в квадрат 20×20 и ставит центр масс в центр 28×28", "размер и положение не мешают"],
["выпрямление", "сдвигает строки так, чтобы главная ось стала вертикальной", "наклонный почерк узнаётся лучше: +0,35 п.п. на MNIST"],
["усреднение", "уменьшает с усреднением по площади", "сохраняет серые края, как в обучающих данных"],
["временной код", "яркость → номер такта", "один спайк на пиксель"],
]),
Callout("Обработка на хосте не должна вклиниваться между слоями сети. Тогда вся сеть живёт на чипе, а хост только готовит вход и читает выход.",
title="Граница хоста", kind="note"),
]
@Page("7", "Метрики и методика")
def metrics_page():
return [
H1("Метрики и методика"),
Table(["Метрика", "Определение"], [
["Точность", "доля правильных ответов на тесте"],
["Синаптические события", "число сложений «спайк × вес» на одну картинку; главная мера энергии на нейроморфном чипе"],
["Входные спайки", "сколько событий хост отправляет на чип"],
["Такты", "длительность распознавания в шагах чипа"],
["Совпадение счётчиков", "счётчик спайков каждого нейрона одинаков в эталоне, KNP и GoldenModel"],
]),
H2("Правила"),
List(["настройки выбираются только на валидации; тест смотрится один раз;",
"цифра без источника (файл метрик или журнал проверки) не считается результатом;",
"разница меньше 0,3 п.п. при одном прогоне — в пределах шума;",
"точность на тесте MNIST не равна точности на рисунках мышью: это другой «почерк»."]),
P("Для оценки рисования используется имитация: скелет цифры MNIST перерисовывается пером постоянной толщины. "
"Нативная сеть теряет на ней около 4 п.п.; обучение на таких копиях и выпрямление сокращают потерю до 2 п.п."),
]
@Page("8", "Распознавание мусора")
def trash():
return [
H1("Распознавание мусора"),
Def("Целевая задача: робот с процессором «Алтай» распознаёт мусор в кадре своей камеры."),
H2("Постановка"),
List(["хост режет кадр на окна и для каждого окна готовит вход сети;",
"чип отвечает по окну: «мусор / не мусор» или тип;",
"где находится мусор, видно по сработавшим окнам."]),
H2("Бюджет нейронов"),
P("Детектор узоров на чипе стоит нейрон на каждую позицию. Подберите размер окна, шаг и число типов детекторов так, чтобы сеть влезла в прототип."),
Widget("budget"),
H2("Что переносится из распознавания цифр"),
Table(["Приём", "Почему подходит"], [
["обучение на компьютере под рамки чипа", "чип не учится; точность выше, чем у правил STDP"],
["временной код и обучение по тактам", "мало спайков и тактов — меньше энергии на кадр"],
["обработка на хосте только на входе", "вся сеть остаётся на кристалле"],
["проверка счётчиков в KNP и GoldenModel", "доказательство без платы"],
]),
H2("Открытые вопросы"),
List(["конфигурация чипа и наличие платы;", "классы: «мусор / не мусор» или типы;",
"камера робота, в том числе событийная;", "данные: открытые наборы и снимки с камеры робота."]),
]
HEADER = [
HeaderButton("Обзор", overview),
HeaderButton("Импульсные сети", snn_section),
HeaderButton("Алтай", altai_section),
HeaderButton("Архитектуры", arch_section),
HeaderButton("Сравнение", compare_page),
]
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)