0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A small neural network — dense layers, hand-written forward pass, hand-written backprop, Adam on mini-batches of 32 — learning to separate two groups of dots live in the tab, at roughly two hundred passes through the data every second. The coloured field behind the scatter is the network's guess for every point on the map, redrawn each animation frame as the weights move, and every unit in the diagram underneath shows its own slice of that same map, so you can watch a few soft blobs turn into the pieces that add up to a spiral. It opens already training on the two-spiral set and has all 240 points on the right side in about three seconds. Then break it: drop to one hidden layer and it crawls into the seventies and sits there for as long as you will watch, because one bend is not enough for two arms that wrap; push the learning rate to 0.3 and the loss curve widens into a ribbon that bounces around chance and never settles. Both failures are the real thing, not a scripted animation — nothing is fetched, nothing is pretrained, and the gradients are computed right there in the loop.
Source — the code you see is the code that just ran 1260 lines 52.7 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Neural Network Playground — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local: the two class colours. Blue vs amber means "which group",
nothing else. Green stays reserved for "every point is on the right side". */
:root {
--c-a: #2E6FBF; /* group A */
--c-b: #C05600; /* group B */
--shadow: 0 1px 2px rgba(26,29,33,.05);
--scrim: rgba(255,255,255,.88);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--c-a: #6FA8FF; --c-b: #FFB454;
--shadow: 0 1px 2px rgba(0,0,0,.35);
--scrim: rgba(28,31,38,.88);
}
}
:root[data-theme="dark"] {
--c-a: #6FA8FF; --c-b: #FFB454;
--shadow: 0 1px 2px rgba(0,0,0,.35);
--scrim: rgba(28,31,38,.88);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap {
max-width: 1080px; margin: 0 auto; padding: 22px 16px 26px;
display: flex; flex-direction: column; gap: 16px;
}
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 70ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.stage { display: grid; grid-template-columns: minmax(0,1fr) 318px; gap: 16px; align-items: start; }
.stage > .colmain { grid-column: 1; grid-row: 1; }
.stage > .panel { grid-column: 2; grid-row: 1 / span 2; }
.stage > .netcard { grid-column: 1; grid-row: 2; }
@media (max-width: 900px) {
.stage { grid-template-columns: minmax(0,1fr); }
.stage > .colmain, .stage > .panel, .stage > .netcard { grid-column: 1; grid-row: auto; }
}
.field {
position: relative; overflow: hidden;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); aspect-ratio: 6 / 5; box-shadow: var(--shadow);
}
@media (max-width: 470px) { .field { aspect-ratio: 1 / 1; } }
canvas { display: block; width: 100%; height: 100%; }
#board { touch-action: pan-y; cursor: crosshair; }
canvas:focus { outline: none; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
.chip {
position: absolute; left: 10px; bottom: 10px; pointer-events: none;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
color: var(--muted); background: var(--scrim);
border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
max-width: calc(100% - 20px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.chip b { color: var(--ink); font-weight: 600; }
.tag {
position: absolute; right: 10px; top: 10px; pointer-events: none;
font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase;
color: var(--muted); background: var(--scrim);
border: 1px solid var(--line); border-radius: 999px; padding: 4px 9px;
}
.keys { display: flex; flex-wrap: wrap; gap: 7px 14px; align-items: center; padding: 9px 2px 0; }
.key { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .01em; }
.key .dot { width: 11px; height: 11px; border-radius: 50%; flex: none; }
.key .dot.hollow { background: none !important; border: 1.6px solid var(--muted); }
.panel { display: flex; flex-direction: column; gap: 12px; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow); }
.card h2 {
margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
display: flex; justify-content: space-between; align-items: baseline; gap: 8px;
}
.card h2 .n { letter-spacing: .04em; text-transform: none; color: var(--muted); font-weight: 400; font-size: 10.5px; }
.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
.stat { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.stat .k { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
.stat .v {
font-family: var(--font-mono); font-size: 17px; letter-spacing: -.025em;
font-variant-numeric: tabular-nums; color: var(--ink); line-height: 1.2;
}
.stat .v.ok { color: var(--run); }
.stat .v.bad { color: var(--danger); }
.sparkbox { margin-top: 10px; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; background: var(--bg); }
#spark { height: 46px; }
.sparkfoot {
display: flex; justify-content: space-between; gap: 8px;
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .05em;
color: var(--muted); margin-top: 4px;
}
.pillrow { display: flex; align-items: center; gap: 8px; margin-top: 11px; flex-wrap: wrap; }
.pill {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em; text-transform: uppercase;
border: 1px solid var(--line); border-radius: 999px; padding: 3px 9px; color: var(--muted); white-space: nowrap;
}
.pill.go { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.pill.done { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.bad { color: var(--danger); border-color: currentColor; }
.verdict { margin: 9px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--muted); min-height: 3em; }
.verdict b { color: var(--ink); font-weight: 600; }
.grid3 { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 7px; }
#dataBtns button { font-size: 12px; padding: 7px 5px; }
.grid2 { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; }
.seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); font-weight: 600; }
.note { margin: 9px 0 0; font-size: 12px; line-height: 1.45; color: var(--muted); }
.sub { margin: 12px 0 5px; font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
.sub:first-of-type { margin-top: 2px; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.ctl-top label { font-size: 13px; }
.ctl-top .val { font-family: var(--font-mono); font-size: 12px; color: var(--ink); font-variant-numeric: tabular-nums; }
.ticks { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 9.5px; color: var(--muted); letter-spacing: .04em; margin-top: 1px; }
input[type=range] {
-webkit-appearance: none; appearance: none; display: block;
width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
border-radius: 50%; background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb {
width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
button {
font: inherit; font-size: 13px; font-family: var(--font-sans);
color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px;
padding: 7px 10px; cursor: pointer; line-height: 1.2; min-width: 0;
transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .42; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
.row { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 11px; }
.row > button { flex: 1 1 auto; }
.netcard .netwrap { border: 1px solid var(--line); border-radius: 7px; background: var(--bg); overflow: hidden; }
#net { display: block; width: 100%; }
.mathrow { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; }
.mathrow div { font-family: var(--font-mono); font-size: 10.5px; line-height: 1.5; color: var(--muted); letter-spacing: -.01em; }
.mathrow div b { color: var(--ink); font-weight: 600; }
footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em; padding-top: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (max-width: 540px) { .netcard h2 .n { display: none; } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Neural Network Playground</h1>
<p class="lede">
A small neural network, written from scratch right here, learning to tell two groups of dots apart.
It starts knowing nothing: the coloured background is its <b>current guess for every point on the
map</b>, and it bends that guess a little after every pass through the data. The two spirals are the
hard one — watch it find the wrap.
</p>
</header>
<main class="stage">
<div class="colmain">
<div class="field" id="field">
<canvas id="board" aria-label="Map of the training points with the network's current decision boundary drawn behind them."
>A scatter of dots in two groups, with the network's coloured guess drawn behind them.</canvas>
<div class="tag" id="tag" aria-hidden="true">two spirals</div>
<div class="chip" id="chip" aria-hidden="true"></div>
</div>
<div class="keys">
<span class="key"><span class="dot" style="background:var(--c-a)"></span>group A</span>
<span class="key"><span class="dot" style="background:var(--c-b)"></span>group B</span>
<span class="key"><span class="dot hollow"></span>still on the wrong side</span>
<span class="key">background = what the network would guess there</span>
</div>
</div>
<div class="panel">
<section class="card" aria-label="Training readout">
<h2>Training <span class="n" id="paramsV">0 weights</span></h2>
<div class="stats">
<div class="stat"><span class="k">epoch</span><span class="v" id="epochV">0</span></div>
<div class="stat"><span class="k">loss</span><span class="v" id="lossV">0.693</span></div>
<div class="stat"><span class="k">correct</span><span class="v" id="accV">—</span></div>
</div>
<div class="sparkbox"><canvas id="spark" aria-hidden="true"></canvas></div>
<div class="sparkfoot" aria-hidden="true"><span>LOSS PER EPOCH</span><span id="rateV">—</span></div>
<div class="pillrow"><span class="pill" id="pill">ready</span></div>
<p class="verdict" id="verdict"></p>
</section>
<section class="card" aria-label="Controls">
<h2>Run</h2>
<div class="row" style="margin-top:0">
<button type="button" class="primary" id="playBtn">Pause</button>
<button type="button" id="stepBtn">Step 200</button>
<button type="button" id="resetBtn">Reset</button>
</div>
<div class="sub" style="margin-top:13px">Learning rate</div>
<div class="ctl-top">
<label for="lr">How big a nudge per pass</label><span class="val" id="lrV">0.030</span>
</div>
<input type="range" id="lr" min="0" max="1000" step="1" value="596" aria-describedby="lrHelp">
<div class="ticks" aria-hidden="true"><span>0.001</span><span>0.017</span><span>0.300</span></div>
<p class="note" id="lrHelp">Too small and it crawls. Too large and it thrashes — the loss curve goes ragged and never settles.</p>
</section>
<section class="card" aria-label="Data">
<h2>Data</h2>
<div class="grid3 seg" id="dataBtns" role="group" aria-label="Choose a dataset"></div>
<p class="note" id="dataNote"></p>
</section>
<section class="card" aria-label="Network shape">
<h2>Shape <span class="n" id="shapeV">2-8-8-1</span></h2>
<div class="sub">Hidden layers</div>
<div class="grid3 seg" id="layerBtns" role="group" aria-label="Number of hidden layers"></div>
<div class="ctl-top" style="margin-top:13px">
<label for="neurons">Neurons per layer</label><span class="val" id="neuronsV">8</span>
</div>
<input type="range" id="neurons" min="2" max="8" step="1" value="8">
<div class="ticks" aria-hidden="true"><span>2</span><span>5</span><span>8</span></div>
<div class="sub">Activation</div>
<div class="grid2 seg" id="actBtns" role="group" aria-label="Activation function"></div>
<p class="note">Changing the shape builds a fresh network and starts over from random weights.</p>
</section>
</div>
<section class="card netcard" aria-label="Inside the network">
<h2>Inside the network <span class="n">each tile is what that unit sees, over the same map</span></h2>
<div class="netwrap"><canvas id="net" aria-hidden="true"></canvas></div>
<p class="sr-only" id="netAlt"></p>
<div class="mathrow">
<div><b>forward</b> a = act(W·x + b), then p = sigmoid(...)</div>
<div><b>loss</b> −[y·log p + (1−y)·log(1−p)]</div>
<div><b>backward</b> chain rule, layer by layer</div>
<div><b>update</b> Adam, mini-batches of 32</div>
</div>
</section>
</main>
<footer>demo · sloprun.dev</footer>
</div>
<div class="sr-only" role="status" aria-live="polite" id="live"></div>
<script>
(function () {
"use strict";
/* ═══ theme plumbing ════════════════════════════════════════════════════ */
var root = document.documentElement;
var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
var mqRM = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduced = !!(mqRM && mqRM.matches);
var coarse = !!(window.matchMedia && matchMedia("(hover: none)").matches);
var PROBE_HINT = coarse ? "drag across the map to read the network's guess"
: "hover the map to read the network's guess";
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme);
readTheme(); drawAll();
}
});
if (mqDark) {
var onScheme = function () { readTheme(); drawAll(); };
if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme);
}
/* ═══ colour helpers ════════════════════════════════════════════════════ */
var COL = {};
function hexToRgb(h) {
h = String(h).trim();
if (h.charAt(0) === "#") h = h.slice(1);
if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
var n = parseInt(h, 16);
if (isNaN(n)) return null;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function tok(name, fb) { return hexToRgb(getComputedStyle(root).getPropertyValue(name)) || fb; }
function css(c) { return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")"; }
function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function mix(a, b, t) {
return [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t];
}
function readTheme() {
COL.surface = tok("--surface", [255,255,255]);
COL.bg = tok("--bg", [246,246,243]);
COL.ink = tok("--ink", [26,29,33]);
COL.muted = tok("--muted", [90,96,104]);
COL.line = tok("--line", [227,226,220]);
COL.accent = tok("--accent", [192,86,0]);
COL.run = tok("--run", [26,127,55]);
COL.a = tok("--c-a", [46,111,191]);
COL.b = tok("--c-b", [192,86,0]);
COL.dark = (COL.bg[0] + COL.bg[1] + COL.bg[2]) / 3 < 128;
COL.fieldA = COL.dark ? 0.34 : 0.40; /* the guess field sits behind the dots */
}
readTheme();
/* ═══ deterministic randomness ══════════════════════════════════════════ */
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function gauss(rnd) {
var u = 0, v = 0;
while (u === 0) u = rnd();
while (v === 0) v = rnd();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
/* ═══ datasets ══════════════════════════════════════════════════════════
Every point lives in roughly [-1, 1] on both axes. Labels are 0 / 1. */
var DATASETS = [
{ id: "spiral", name: "two spirals",
note: "Two arms wound around each other. A straight line can never split these — the network has to learn a curve that spirals too.",
brief: "Two arms that wrap around each other." },
{ id: "circles", name: "rings",
note: "A blob inside a ring. Easy for even a tiny network: one closed curve does it, and the loss drops in a couple of hundred passes.",
brief: "A blob inside a ring." },
{ id: "xor", name: "four blobs",
note: "Diagonal pairs belong together. Needs at least one hidden layer — this is the classic problem a single layer of weights cannot solve.",
brief: "Diagonal pairs belong together." }
];
function makeData(kind, seed) {
var rnd = mulberry32(seed), X = [], Y = [], i, c, n;
if (kind === "spiral") {
n = 120;
for (c = 0; c < 2; c++) for (i = 0; i < n; i++) {
var r = 0.06 + 0.94 * (i / n);
var t = 1.75 * 2 * Math.PI * (i / n) + c * Math.PI;
X.push([r * Math.cos(t) + gauss(rnd) * 0.035, r * Math.sin(t) + gauss(rnd) * 0.035]);
Y.push(c);
}
} else if (kind === "circles") {
n = 120;
for (c = 0; c < 2; c++) for (i = 0; i < n; i++) {
var rr = c === 0 ? 0.45 * Math.sqrt(rnd()) : 0.72 + 0.28 * Math.sqrt(rnd());
var th = rnd() * 2 * Math.PI;
X.push([rr * Math.cos(th) + gauss(rnd) * 0.03, rr * Math.sin(th) + gauss(rnd) * 0.03]);
Y.push(c);
}
} else {
n = 60;
var cs = [[0.5, 0.5, 0], [-0.5, -0.5, 0], [0.5, -0.5, 1], [-0.5, 0.5, 1]];
for (var b = 0; b < 4; b++) for (i = 0; i < n; i++) {
X.push([cs[b][0] + gauss(rnd) * 0.16, cs[b][1] + gauss(rnd) * 0.16]);
Y.push(cs[b][2]);
}
}
return { X: X, Y: Y, n: X.length };
}
/* ═══ the network ═══════════════════════════════════════════════════════
Plain dense layers. Hidden units use tanh or relu, the single output
unit uses a sigmoid, and the loss is binary cross-entropy. Gradients are
computed by hand below — nothing is imported, nothing is fetched. */
function makeNet(sizes, act, seed) {
var rnd = mulberry32(seed), L = [], l;
for (l = 1; l < sizes.length; l++) {
var fi = sizes[l - 1], fo = sizes[l], last = (l === sizes.length - 1);
/* He init for relu hidden units, Xavier-ish otherwise */
var scale = (act === "relu" && !last) ? Math.sqrt(2 / fi) : Math.sqrt(1 / fi);
var W = new Float64Array(fi * fo);
for (var k = 0; k < W.length; k++) W[k] = gauss(rnd) * scale;
L.push({
fi: fi, fo: fo, last: last,
W: W, B: new Float64Array(fo),
mW: new Float64Array(fi * fo), vW: new Float64Array(fi * fo),
mB: new Float64Array(fo), vB: new Float64Array(fo),
gW: new Float64Array(fi * fo), gB: new Float64Array(fo),
a: new Float64Array(fo), z: new Float64Array(fo), d: new Float64Array(fo)
});
}
var params = 0;
for (l = 0; l < L.length; l++) params += L[l].W.length + L[l].B.length;
return { L: L, act: act, sizes: sizes.slice(), t: 0, params: params };
}
var probe2 = [0, 0];
function forward(net, x) {
var L = net.L, inp = x, l, i, j;
for (l = 0; l < L.length; l++) {
var la = L[l];
for (j = 0; j < la.fo; j++) {
var s = la.B[j], off = j * la.fi;
for (i = 0; i < la.fi; i++) s += la.W[off + i] * inp[i];
la.z[j] = s;
la.a[j] = la.last ? 1 / (1 + Math.exp(-s))
: (net.act === "relu" ? (s > 0 ? s : 0) : Math.tanh(s));
}
inp = la.a;
}
return L[L.length - 1].a[0];
}
function predictAt(net, x, y) { probe2[0] = x; probe2[1] = y; return forward(net, probe2); }
var B1 = 0.9, B2 = 0.999, EPS = 1e-8, BATCH = 32;
function trainEpoch(net, data, lr, order, rnd) {
var N = data.n, L = net.L, i, l, j, ii, q;
for (i = N - 1; i > 0; i--) {
var k = (rnd() * (i + 1)) | 0, tmp = order[i]; order[i] = order[k]; order[k] = tmp;
}
var total = 0;
for (var st = 0; st < N; st += BATCH) {
var end = st + BATCH; if (end > N) end = N;
var m = end - st;
for (l = 0; l < L.length; l++) { L[l].gW.fill(0); L[l].gB.fill(0); }
for (q = st; q < end; q++) {
var idx = order[q], x = data.X[idx], y = data.Y[idx];
var p = forward(net, x);
var pc = p < 1e-9 ? 1e-9 : (p > 1 - 1e-9 ? 1 - 1e-9 : p);
total += -(y * Math.log(pc) + (1 - y) * Math.log(1 - pc));
/* dLoss/dz for a sigmoid output under cross-entropy is just (p - y) */
var lastI = L.length - 1;
L[lastI].d[0] = p - y;
for (l = lastI; l >= 0; l--) {
var la = L[l], prev = (l === 0) ? x : L[l - 1].a;
for (j = 0; j < la.fo; j++) {
var dj = la.d[j], off = j * la.fi;
la.gB[j] += dj;
for (ii = 0; ii < la.fi; ii++) la.gW[off + ii] += dj * prev[ii];
}
if (l > 0) {
var pl = L[l - 1];
for (ii = 0; ii < la.fi; ii++) {
var s = 0;
for (j = 0; j < la.fo; j++) s += la.W[j * la.fi + ii] * la.d[j];
pl.d[ii] = (net.act === "relu") ? (pl.z[ii] > 0 ? s : 0)
: s * (1 - pl.a[ii] * pl.a[ii]);
}
}
}
}
/* Adam step on the batch-averaged gradient */
net.t++;
var c1 = 1 - Math.pow(B1, net.t), c2 = 1 - Math.pow(B2, net.t), inv = 1 / m;
for (l = 0; l < L.length; l++) {
var lb = L[l], w, g;
for (w = 0; w < lb.W.length; w++) {
g = lb.gW[w] * inv;
lb.mW[w] = B1 * lb.mW[w] + (1 - B1) * g;
lb.vW[w] = B2 * lb.vW[w] + (1 - B2) * g * g;
lb.W[w] -= lr * (lb.mW[w] / c1) / (Math.sqrt(lb.vW[w] / c2) + EPS);
}
for (w = 0; w < lb.B.length; w++) {
g = lb.gB[w] * inv;
lb.mB[w] = B1 * lb.mB[w] + (1 - B1) * g;
lb.vB[w] = B2 * lb.vB[w] + (1 - B2) * g * g;
lb.B[w] -= lr * (lb.mB[w] / c1) / (Math.sqrt(lb.vB[w] / c2) + EPS);
}
}
}
return total / N;
}
function scoreAll(net, data) {
var right = 0, loss = 0, N = data.n;
for (var i = 0; i < N; i++) {
var p = forward(net, data.X[i]), y = data.Y[i];
var pc = p < 1e-9 ? 1e-9 : (p > 1 - 1e-9 ? 1 - 1e-9 : p);
loss += -(y * Math.log(pc) + (1 - y) * Math.log(1 - pc));
pred[i] = p;
if ((p >= 0.5 ? 1 : 0) === y) right++;
}
return { right: right, loss: loss / N };
}
/* ═══ state ═════════════════════════════════════════════════════════════ */
var DOMAIN = 1.15; /* half-width of the visible map, in data units */
var EPOCHS_PER_SEC = 200; /* paced by wall clock so it looks the same everywhere */
var st = {
dataId: "spiral",
layers: 2,
neurons: 8,
act: "tanh",
lr: 0.03,
playing: !reduced,
epoch: 0,
loss: 0.693,
right: 0,
diverged: false,
seed: 7
};
var data = makeData(st.dataId, 7);
var pred = new Float64Array(data.n);
var net, order, shuffleRnd, hist = [], rate = 0, rateCount = 0, rateT0 = 0;
function sizesOf() {
var s = [2];
for (var i = 0; i < st.layers; i++) s.push(st.neurons);
s.push(1);
return s;
}
function rebuild(newSeed) {
if (newSeed != null) st.seed = newSeed;
net = makeNet(sizesOf(), st.act, st.seed);
order = new Int32Array(data.n);
for (var i = 0; i < data.n; i++) order[i] = i;
shuffleRnd = mulberry32(st.seed * 7919 + 13);
st.epoch = 0; st.diverged = false;
hist = [];
var s = scoreAll(net, data);
st.loss = s.loss; st.right = s.right;
hist.push(s.loss);
rate = 0; rateCount = 0; rateT0 = 0;
allocActs();
syncUI();
}
/* ═══ canvases ══════════════════════════════════════════════════════════ */
var field = document.getElementById("field");
var board = document.getElementById("board");
var bctx = board.getContext("2d");
var spark = document.getElementById("spark");
var sctx = spark.getContext("2d");
var netc = document.getElementById("net");
var nctx = netc.getContext("2d");
var off = document.createElement("canvas");
var octx = off.getContext("2d");
var GW = 104, GH = 88, gimg = null;
off.width = GW; off.height = GH;
var bW = 0, bH = 0, dpr = 1;
function fitCanvas(cv, ctx, cssW, cssH) {
dpr = Math.min(window.devicePixelRatio || 1, 2);
var w = Math.max(1, Math.round(cssW * dpr)), h = Math.max(1, Math.round(cssH * dpr));
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function layout() {
var r = field.getBoundingClientRect();
bW = Math.max(120, Math.round(r.width));
bH = Math.max(120, Math.round(r.height));
fitCanvas(board, bctx, bW, bH);
/* keep the boundary grid roughly square-celled */
var target = 104;
if (bW >= bH) { GW = target; GH = Math.max(24, Math.round(target * bH / bW)); }
else { GH = target; GW = Math.max(24, Math.round(target * bW / bH)); }
off.width = GW; off.height = GH;
gimg = octx.createImageData(GW, GH);
var sr = spark.getBoundingClientRect();
fitCanvas(spark, sctx, Math.max(60, Math.round(sr.width)), 46);
var nr = netc.getBoundingClientRect();
var nH = 66 + Math.max(3, st.neurons) * 30;
if (nH > 330) nH = 330;
netc.style.height = nH + "px";
fitCanvas(netc, nctx, Math.max(200, Math.round(nr.width)), nH);
}
/* pixel <-> data mapping: the square domain fits the short side of the box */
function unitsPerPx() { return (2 * DOMAIN) / Math.min(bW, bH); }
function px2x(px) { return (px - bW / 2) * unitsPerPx(); }
function px2y(py) { return -(py - bH / 2) * unitsPerPx(); }
function x2px(x) { return bW / 2 + x / unitsPerPx(); }
function y2px(y) { return bH / 2 - y / unitsPerPx(); }
/* ═══ drawing: the map ══════════════════════════════════════════════════ */
function drawBoundary() {
if (!gimg) return;
var d = gimg.data, u = unitsPerPx();
var halfW = bW / 2, halfH = bH / 2;
var i = 0;
for (var gy = 0; gy < GH; gy++) {
var py = (gy + 0.5) * bH / GH;
var yy = -(py - halfH) * u;
for (var gx = 0; gx < GW; gx++) {
var pxx = (gx + 0.5) * bW / GW;
var xx = (pxx - halfW) * u;
var p = predictAt(net, xx, yy);
if (p !== p) p = 0.5; /* NaN guard */
var t = (p - 0.5) * 2; /* -1 .. 1 */
var mag = Math.pow(Math.abs(t), 0.72) * COL.fieldA;
var c = mix(COL.surface, t < 0 ? COL.a : COL.b, mag);
d[i++] = c[0]; d[i++] = c[1]; d[i++] = c[2]; d[i++] = 255;
}
}
octx.putImageData(gimg, 0, 0);
}
function drawMap() {
bctx.save();
bctx.clearRect(0, 0, bW, bH);
bctx.imageSmoothingEnabled = true;
if (bctx.imageSmoothingQuality) bctx.imageSmoothingQuality = "high";
bctx.drawImage(off, 0, 0, bW, bH);
/* faint axes through the origin */
bctx.strokeStyle = rgba(COL.ink, 0.10);
bctx.lineWidth = 1;
bctx.beginPath();
bctx.moveTo(0, Math.round(y2px(0)) + 0.5); bctx.lineTo(bW, Math.round(y2px(0)) + 0.5);
bctx.moveTo(Math.round(x2px(0)) + 0.5, 0); bctx.lineTo(Math.round(x2px(0)) + 0.5, bH);
bctx.stroke();
/* points: filled once the network puts them on the right side */
var r = bW < 380 ? 3 : 3.7;
for (var i = 0; i < data.n; i++) {
var X = data.X[i], y = data.Y[i];
var cx = x2px(X[0]), cy = y2px(X[1]);
var ok = (pred[i] >= 0.5 ? 1 : 0) === y;
var c = y === 0 ? COL.a : COL.b;
bctx.beginPath();
bctx.arc(cx, cy, r, 0, 6.28318530718);
if (ok) {
bctx.fillStyle = css(c); bctx.fill();
bctx.lineWidth = 1.1; bctx.strokeStyle = rgba(COL.surface, 0.9); bctx.stroke();
} else {
bctx.fillStyle = rgba(COL.surface, 0.72); bctx.fill();
bctx.lineWidth = 1.6; bctx.strokeStyle = css(c); bctx.stroke();
}
}
bctx.restore();
}
/* ═══ drawing: the loss sparkline ═══════════════════════════════════════ */
function drawSpark() {
var w = spark.width / dpr, h = spark.height / dpr;
sctx.clearRect(0, 0, w, h);
var n = hist.length;
if (n < 2) return;
var top = 0, i;
for (i = 0; i < n; i++) if (hist[i] > top && isFinite(hist[i])) top = hist[i];
if (top < 0.05) top = 0.05;
var pad = 4, gh = h - pad * 2;
function yOf(v) {
if (!isFinite(v)) v = top;
var t = v / top; if (t > 1) t = 1; if (t < 0) t = 0;
return pad + (1 - t) * gh;
}
/* One pixel column covers many epochs once the run is long, so keep BOTH
the lowest and the highest loss in the column. When training settles the
two collapse onto a single line; when the learning rate is too big the
gap opens into a visible ribbon. Plotting only the minimum (as a plain
sparkline does) would draw a thrashing run as a smooth flat line. */
var pts = [], W = Math.max(2, Math.floor(w));
for (var px = 0; px < W; px++) {
var a = Math.floor(px / W * n), b = Math.floor((px + 1) / W * n);
if (b <= a) b = a + 1; if (b > n) b = n;
var lo = Infinity, hi = -Infinity;
for (i = a; i < b; i++) {
var hv = hist[i];
if (!isFinite(hv)) hv = top;
if (hv < lo) lo = hv;
if (hv > hi) hi = hv;
}
if (lo === Infinity) { lo = hi = hist[n - 1]; }
pts.push([px, yOf(lo), yOf(hi)]);
}
var curve = st.diverged ? tok("--danger", [201, 60, 55]) : COL.accent;
/* area under the low edge */
sctx.beginPath();
sctx.moveTo(pts[0][0], h);
for (i = 0; i < pts.length; i++) sctx.lineTo(pts[i][0], pts[i][1]);
sctx.lineTo(pts[pts.length - 1][0], h);
sctx.closePath();
sctx.fillStyle = rgba(curve, 0.11);
sctx.fill();
/* min-to-max ribbon: thin when it settles, fat when it thrashes */
sctx.beginPath();
sctx.moveTo(pts[0][0], pts[0][2]);
for (i = 1; i < pts.length; i++) sctx.lineTo(pts[i][0], pts[i][2]);
for (i = pts.length - 1; i >= 0; i--) sctx.lineTo(pts[i][0], pts[i][1]);
sctx.closePath();
sctx.fillStyle = rgba(curve, 0.28);
sctx.fill();
/* line */
sctx.beginPath();
for (i = 0; i < pts.length; i++) (i ? sctx.lineTo(pts[i][0], pts[i][1]) : sctx.moveTo(pts[i][0], pts[i][1]));
sctx.lineWidth = 1.4;
sctx.strokeStyle = css(curve);
sctx.stroke();
/* head marker */
var last = pts[pts.length - 1];
sctx.beginPath();
sctx.arc(last[0] - 1, last[1], 2, 0, 6.28318530718);
sctx.fillStyle = css(COL.accent);
sctx.fill();
}
/* ═══ drawing: the network diagram ══════════════════════════════════════
Every unit gets a small tile showing its own output across the same map,
computed by running an 18x18 grid of points through the network once and
keeping each layer's activations. */
var NG = 18, gridPts = null, acts = null, actMax = null;
function buildGridPts() {
gridPts = new Float64Array(NG * NG * 2);
var k = 0;
for (var gy = 0; gy < NG; gy++) for (var gx = 0; gx < NG; gx++) {
gridPts[k++] = (gx + 0.5) / NG * 2 * DOMAIN - DOMAIN;
gridPts[k++] = DOMAIN - (gy + 0.5) / NG * 2 * DOMAIN;
}
}
buildGridPts();
function allocActs() {
acts = []; actMax = [];
for (var l = 0; l < net.L.length; l++) {
acts.push(new Float32Array(net.L[l].fo * NG * NG));
actMax.push(new Float32Array(net.L[l].fo));
}
}
function computeActs() {
var P = NG * NG, l, j;
for (l = 0; l < actMax.length; l++) actMax[l].fill(1e-6);
for (var g = 0; g < P; g++) {
probe2[0] = gridPts[g * 2]; probe2[1] = gridPts[g * 2 + 1];
forward(net, probe2);
for (l = 0; l < net.L.length; l++) {
var la = net.L[l], arr = acts[l];
for (j = 0; j < la.fo; j++) {
var v = la.a[j];
if (v !== v) v = 0;
arr[j * P + g] = v;
var av = Math.abs(v);
if (av > actMax[l][j]) actMax[l][j] = av;
}
}
}
}
var tileCv = document.createElement("canvas");
var tctx = tileCv.getContext("2d");
tileCv.width = NG; tileCv.height = NG;
var timg = tctx.createImageData(NG, NG);
function paintTile(get) {
var d = timg.data, i = 0;
for (var g = 0; g < NG * NG; g++) {
var v = get(g); /* -1 .. 1 */
if (v !== v) v = 0;
if (v > 1) v = 1; if (v < -1) v = -1;
var c = mix(COL.surface, v < 0 ? COL.a : COL.b, Math.pow(Math.abs(v), 0.75) * 0.85);
d[i++] = c[0]; d[i++] = c[1]; d[i++] = c[2]; d[i++] = 255;
}
tctx.putImageData(timg, 0, 0);
}
function drawNet() {
var w = netc.width / dpr, h = netc.height / dpr;
nctx.clearRect(0, 0, w, h);
computeActs();
var cols = 2 + st.layers; /* inputs + hidden + output */
var counts = [2];
for (var i = 0; i < st.layers; i++) counts.push(st.neurons);
counts.push(1);
var maxN = Math.max.apply(null, counts);
var padTop = 10, padBot = 20;
var tile = Math.min(30, Math.floor((h - padTop - padBot - (maxN - 1) * 6) / maxN));
if (tile < 10) tile = 10;
var gap = 6;
var colW = w / cols;
var xs = [];
for (i = 0; i < cols; i++) xs.push(colW * (i + 0.5));
function nodeY(ci, j) {
var n = counts[ci];
var totalH = n * tile + (n - 1) * gap;
var top = padTop + (h - padTop - padBot - totalH) / 2;
return top + j * (tile + gap);
}
/* edges first, under the tiles */
for (var l = 0; l < net.L.length; l++) {
var la = net.L[l];
var x0 = xs[l] + tile / 2, x1 = xs[l + 1] - tile / 2;
for (var j = 0; j < la.fo; j++) {
var y1 = nodeY(l + 1, j) + tile / 2;
for (var ii = 0; ii < la.fi; ii++) {
var y0 = nodeY(l, ii) + tile / 2;
var wv = la.W[j * la.fi + ii];
if (wv !== wv) wv = 0;
var mag = Math.abs(wv);
var alpha = Math.min(0.8, 0.10 + mag * 0.30);
nctx.beginPath();
nctx.moveTo(x0, y0); nctx.lineTo(x1, y1);
nctx.lineWidth = Math.max(0.5, Math.min(3.2, mag * 1.1));
nctx.strokeStyle = rgba(wv >= 0 ? COL.b : COL.a, alpha);
nctx.stroke();
}
}
}
nctx.imageSmoothingEnabled = true;
if (nctx.imageSmoothingQuality) nctx.imageSmoothingQuality = "high";
function blit(cx, cy) {
nctx.save();
nctx.beginPath();
var rr = 4;
roundRect(nctx, cx, cy, tile, tile, rr);
nctx.clip();
nctx.drawImage(tileCv, cx, cy, tile, tile);
nctx.restore();
nctx.beginPath();
roundRect(nctx, cx + 0.5, cy + 0.5, tile - 1, tile - 1, 4);
nctx.lineWidth = 1;
nctx.strokeStyle = rgba(COL.ink, 0.16);
nctx.stroke();
}
/* inputs: the raw coordinates */
for (i = 0; i < 2; i++) {
(function (which) {
paintTile(function (g) { return gridPts[g * 2 + which] / DOMAIN; });
})(i);
blit(xs[0] - tile / 2, nodeY(0, i));
}
/* hidden + output units */
var P = NG * NG;
for (l = 0; l < net.L.length; l++) {
var lay = net.L[l], arr = acts[l];
for (j = 0; j < lay.fo; j++) {
(function (a, jj, isLast, mx) {
paintTile(function (g) {
var v = a[jj * P + g];
if (isLast) return (v - 0.5) * 2;
if (st.act === "relu") return Math.min(1, v / mx);
return v;
});
})(arr, j, lay.last, Math.max(1e-6, actMax[l][j]));
blit(xs[l + 1] - tile / 2, nodeY(l + 1, j));
}
}
/* column labels in mono */
nctx.font = "9px " + getComputedStyle(root).getPropertyValue("--font-mono");
nctx.textAlign = "center";
nctx.textBaseline = "alphabetic";
nctx.fillStyle = css(COL.muted);
var labels = ["input"];
for (i = 0; i < st.layers; i++) labels.push(st.layers === 1 ? "hidden" : "hidden " + (i + 1));
labels.push("output");
for (i = 0; i < cols; i++) nctx.fillText(labels[i].toUpperCase(), xs[i], h - 7);
}
function roundRect(ctx, x, y, w, h, r) {
if (ctx.roundRect) { ctx.roundRect(x, y, w, h, r); return; }
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function drawAll() { drawBoundary(); drawMap(); drawSpark(); drawNet(); }
/* ═══ UI wiring ═════════════════════════════════════════════════════════ */
var $ = function (id) { return document.getElementById(id); };
var epochV = $("epochV"), lossV = $("lossV"), accV = $("accV"), rateV = $("rateV");
var paramsV = $("paramsV"), shapeV = $("shapeV"), pill = $("pill"), verdict = $("verdict");
var tag = $("tag"), chip = $("chip"), live = $("live"), dataNote = $("dataNote");
var playBtn = $("playBtn"), stepBtn = $("stepBtn"), resetBtn = $("resetBtn");
var lrEl = $("lr"), lrV = $("lrV"), neuronsEl = $("neurons"), neuronsV = $("neuronsV");
var netAlt = $("netAlt");
function say(msg) { live.textContent = msg; }
/* dataset buttons */
var dataBtns = $("dataBtns");
DATASETS.forEach(function (d) {
var b = document.createElement("button");
b.type = "button"; b.textContent = d.name;
b.setAttribute("aria-pressed", String(d.id === st.dataId));
b.addEventListener("click", function () {
if (st.dataId === d.id) return;
st.dataId = d.id;
data = makeData(d.id, 7);
pred = new Float64Array(data.n);
rebuild((Math.random() * 1e9) | 0);
drawAll();
say(d.name + " loaded, network reset");
});
dataBtns.appendChild(b);
});
/* hidden-layer buttons */
var layerBtns = $("layerBtns");
[1, 2, 3].forEach(function (k) {
var b = document.createElement("button");
b.type = "button"; b.textContent = String(k);
b.setAttribute("aria-label", k + " hidden layer" + (k > 1 ? "s" : ""));
b.setAttribute("aria-pressed", String(k === st.layers));
b.addEventListener("click", function () {
if (st.layers === k) return;
st.layers = k;
layout(); rebuild(); drawAll();
say(k + " hidden layer" + (k > 1 ? "s" : "") + ", network reset");
});
layerBtns.appendChild(b);
});
/* activation buttons */
var actBtns = $("actBtns");
[["tanh", "tanh"], ["relu", "relu"]].forEach(function (p) {
var b = document.createElement("button");
b.type = "button"; b.textContent = p[1];
b.style.fontFamily = "var(--font-mono)";
b.setAttribute("aria-pressed", String(p[0] === st.act));
b.addEventListener("click", function () {
if (st.act === p[0]) return;
st.act = p[0];
rebuild(); drawAll();
say(p[1] + " activation, network reset");
});
actBtns.appendChild(b);
});
function pressGroup(container, isOn) {
var bs = container.querySelectorAll("button");
for (var i = 0; i < bs.length; i++) bs[i].setAttribute("aria-pressed", String(isOn(i)));
}
/* learning rate: log scale from 0.001 to 0.3 */
function lrFromSlider(v) {
var t = v / 1000;
return Math.exp(Math.log(0.001) + t * (Math.log(0.3) - Math.log(0.001)));
}
function sliderFromLr(lr) {
return Math.round(1000 * (Math.log(lr) - Math.log(0.001)) / (Math.log(0.3) - Math.log(0.001)));
}
lrEl.value = String(sliderFromLr(st.lr));
lrEl.addEventListener("input", function () {
st.lr = lrFromSlider(+lrEl.value);
lrV.textContent = st.lr.toFixed(3);
});
neuronsEl.addEventListener("input", function () {
var n = +neuronsEl.value;
if (n === st.neurons) return;
st.neurons = n;
neuronsV.textContent = String(n);
layout(); rebuild(); drawAll();
});
neuronsEl.addEventListener("change", function () {
say(st.neurons + " neurons per layer, network reset");
});
playBtn.addEventListener("click", function () {
if (st.diverged) { rebuild((Math.random() * 1e9) | 0); drawAll(); }
st.playing = !st.playing;
if (st.playing) { rateT0 = 0; rateCount = 0; }
syncUI();
say(st.playing ? "training" : "paused at epoch " + st.epoch);
});
stepBtn.addEventListener("click", function () {
if (st.diverged) return;
for (var i = 0; i < 200 && !st.diverged; i++) runEpoch();
refreshScore(); syncUI(); drawAll();
say("stepped to epoch " + st.epoch + ", " + st.right + " of " + data.n + " correct");
});
resetBtn.addEventListener("click", function () {
rebuild((Math.random() * 1e9) | 0);
drawAll();
say("new random weights, back to epoch 0");
});
/* hover readout — supplementary, mouse only */
function onProbe(e) {
var r = board.getBoundingClientRect();
var px = e.clientX - r.left, py = e.clientY - r.top;
if (px < 0 || py < 0 || px > r.width || py > r.height) return;
var p = predictAt(net, px2x(px * bW / r.width), px2y(py * bH / r.height));
if (p !== p) p = 0.5;
var side = p >= 0.5 ? "group B" : "group A";
var conf = (p >= 0.5 ? p : 1 - p);
chip.innerHTML = "guess here: <b>" + side + "</b> " + (conf * 100).toFixed(0) + "% sure";
}
board.addEventListener("pointermove", onProbe);
board.addEventListener("pointerleave", function () {
chip.textContent = PROBE_HINT;
});
/* ═══ readout ═══════════════════════════════════════════════════════════ */
function fmtLoss(v) {
if (!isFinite(v)) return "—";
return v < 0.001 ? v.toExponential(1).replace("e-", "e−") : v.toFixed(3);
}
function syncUI() {
var solved = st.right === data.n;
epochV.textContent = String(st.epoch);
lossV.textContent = fmtLoss(st.loss);
lossV.className = "v" + (st.diverged ? " bad" : "");
accV.textContent = st.right + "/" + data.n;
accV.className = "v" + (solved && !st.diverged ? " ok" : "");
paramsV.textContent = net.params + " weights";
shapeV.textContent = sizesOf().join("-");
rateV.textContent = st.playing && rate > 0 ? Math.round(rate) + " EPOCHS/S" : "—";
lrV.textContent = st.lr.toFixed(3);
neuronsV.textContent = String(st.neurons);
playBtn.textContent = st.diverged ? "Start over" : (st.playing ? "Pause" : (st.epoch ? "Resume" : "Train"));
stepBtn.disabled = st.diverged;
tag.textContent = DATASETS.filter(function (d) { return d.id === st.dataId; })[0].name;
dataNote.textContent = DATASETS.filter(function (d) { return d.id === st.dataId; })[0].note;
pressGroup(dataBtns, function (i) { return DATASETS[i].id === st.dataId; });
pressGroup(layerBtns, function (i) { return i + 1 === st.layers; });
pressGroup(actBtns, function (i) { return (i === 0 ? "tanh" : "relu") === st.act; });
/* only call it a dead end once it has genuinely stopped improving */
var slow = (st.epoch > 1400 && st.right / data.n < 0.9) ? recent() : null;
if (slow && slow.drop >= 0.05) slow = null;
var cls = "pill", txt = "ready", v;
if (st.diverged) {
cls = "pill bad"; txt = "blew up";
v = "The nudges were too big and the weights ran off to infinity. Turn the <b>learning rate</b> down and start over.";
} else if (solved) {
cls = "pill done"; txt = "all " + data.n + " correct";
v = "Every single point is on the right side of the line it drew. The loss keeps falling from here — the boundary just gets crisper.";
} else if (!st.playing) {
cls = "pill"; txt = st.epoch ? "paused" : "ready";
v = st.epoch
? "Paused at epoch " + st.epoch + ". Press <b>Resume</b>, or take 200 passes at a time."
: "Fresh random weights — the background is pure guesswork. Press <b>Train</b>.";
} else if (slow) {
/* the learning rate is only a plausible culprit when it is actually
large — at a sane rate a flat curve means the shape is the limit */
if (st.lr > 0.05 && (slow.spread > 0.10 || slow.mean > 0.60)) {
cls = "pill bad"; txt = "thrashing";
v = "The loss bounces instead of falling — every nudge overshoots the bottom. Turn the <b>learning rate</b> down.";
} else if (st.lr < 0.005) {
cls = "pill go"; txt = "crawling";
v = "It is still learning, just far too slowly to watch. Turn the <b>learning rate</b> up.";
} else {
cls = "pill go"; txt = "stuck";
v = "It has stopped improving. This shape can't bend the boundary enough — try <b>more neurons</b> or another hidden layer.";
}
} else {
cls = "pill go"; txt = "training";
v = st.epoch < 60
? "First passes: the boundary is still one soft smear across the map."
: "Each pass nudges every weight a little against the slope of the loss. Watch the hollow dots fill in.";
}
pill.className = cls; pill.textContent = txt;
verdict.innerHTML = v;
netAlt.textContent = "Network shape " + sizesOf().join("-") + " with " + st.act +
" activation, " + net.params + " weights, epoch " + st.epoch + ", " +
st.right + " of " + data.n + " points correct.";
}
function refreshScore() {
var s = scoreAll(net, data);
st.loss = s.loss; st.right = s.right;
}
/* How the loss has behaved over the last stretch of epochs. Used to tell
"the nudges are too big" (curve bounces, or sits pinned at chance) apart
from "this shape cannot bend far enough" (curve smooth and flat) and from
"the nudges are too small" (curve still falling, imperceptibly). */
function recent() {
var n = hist.length, a = n - 150; if (a < 0) a = 0;
var w = n - a; if (w < 120) return null;
var mn = Infinity, mx = -Infinity, sum = 0, i, v;
for (i = a; i < n; i++) {
v = hist[i]; if (!isFinite(v)) v = 20;
if (v < mn) mn = v;
if (v > mx) mx = v;
sum += v;
}
var mean = sum / w, mid = a + (w >> 1), s1 = 0, s2 = 0;
for (i = a; i < mid; i++) { v = hist[i]; s1 += isFinite(v) ? v : 20; }
for (i = mid; i < n; i++) { v = hist[i]; s2 += isFinite(v) ? v : 20; }
s1 /= (mid - a); s2 /= (n - mid);
return {
spread: (mx - mn) / Math.max(mean, 0.02),
mean: mean,
drop: (s1 - s2) / Math.max(s1, 1e-6)
};
}
function runEpoch() {
var l = trainEpoch(net, data, st.lr, order, shuffleRnd);
st.epoch++;
if (!isFinite(l) || Math.abs(l) > 1e6) {
st.diverged = true; st.playing = false; st.loss = l;
hist.push(isFinite(l) ? l : (hist.length ? hist[hist.length - 1] * 2 : 1));
return;
}
st.loss = l;
if (hist.length < 60000) hist.push(l);
}
/* ═══ main loop ═════════════════════════════════════════════════════════ */
var acc = 0, lastT = 0, frame = 0, uiFrame = 0, bCost = 0, bEvery = 1;
function tick(now) {
requestAnimationFrame(tick);
if (!lastT) lastT = now;
var dt = Math.min(0.25, (now - lastT) / 1000);
lastT = now;
if (st.playing && !st.diverged) {
acc += dt * EPOCHS_PER_SEC;
var budget = Math.min(24, Math.floor(acc));
acc -= budget;
for (var i = 0; i < budget && !st.diverged; i++) runEpoch();
if (budget > 0) {
rateCount += budget;
if (!rateT0) rateT0 = now;
else if (now - rateT0 > 500) { rate = rateCount / ((now - rateT0) / 1000); rateCount = 0; rateT0 = now; }
refreshScore();
/* the boundary grid is the expensive part — if this machine is slow,
recompute it every other frame instead of dropping the frame rate */
if (frame % bEvery === 0) {
var bt = performance.now();
drawBoundary();
var el = performance.now() - bt;
bCost = bCost ? bCost * 0.85 + el * 0.15 : el;
bEvery = bCost > 6 ? 2 : 1;
}
drawMap();
drawSpark();
frame++;
if (frame % (reduced ? 12 : 6) === 0) drawNet();
uiFrame++;
if (uiFrame % 3 === 0) syncUI();
}
if (st.diverged) { refreshScore(); drawAll(); syncUI(); say("training blew up — lower the learning rate"); }
} else {
acc = 0;
}
}
/* ═══ resize ════════════════════════════════════════════════════════════ */
var rt = null;
function onResize() {
if (rt) clearTimeout(rt);
rt = setTimeout(function () { layout(); drawAll(); }, 80);
}
if (window.ResizeObserver) {
var ro = new ResizeObserver(onResize);
ro.observe(field);
}
window.addEventListener("resize", onResize);
/* ═══ boot ══════════════════════════════════════════════════════════════ */
var solvedAnnounced = false;
rebuild(7);
layout();
lrV.textContent = st.lr.toFixed(3);
chip.textContent = PROBE_HINT;
if (reduced) {
/* Reduced motion means nothing moves until asked. An untrained network is
a grey smear, so run the opening stretch up front (about 100ms) and open
on a half-learned boundary with a real loss curve, paused. */
for (var boot = 0; boot < 260 && !st.diverged; boot++) runEpoch();
refreshScore();
chip.textContent = "paused for reduced motion — press Resume to keep training";
}
drawAll();
syncUI();
requestAnimationFrame(tick);
/* announce the win once per run */
setInterval(function () {
if (st.right === data.n && !st.diverged) {
if (!solvedAnnounced) { solvedAnnounced = true; say("solved — all " + data.n + " points correct at epoch " + st.epoch); }
} else solvedAnnounced = false;
}, 700);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.