A 2-2-1 neural network learns XOR. Step through the full training loop: forward arithmetic, chain-rule backward, optimizer update.
import { App, Component, div, h1, h2, h3, p, pre, span, button, a, svg, g, circle, line, path, rect, text } from "domeleon"
type Params = {
W1: number[][] // [hidden][input] — W1[j][i] is the weight from input i to hidden j
b1: number[]
W2: number[] // length 2 — single output, so this is a vector not a matrix
b2: number
}
type AdamState = {
mW1: number[][]; vW1: number[][]
mb1: number[]; vb1: number[]
mW2: number[]; vW2: number[]
mb2: number; vb2: number
}
type Forward = {
x: number[]; z1: number[]; h: number[]; z2: number; y: number; target: number; loss: number
}
type Backward = {
dy: number; dz2: number; dh: number[]; dz1: number[]
dW1: number[][]; db1: number[]; dW2: number[]; db2: number
}
type Deltas = {
dW1: number[][]; db1: number[]; dW2: number[]; db2: number
}
// Mulberry32 — small reproducible PRNG.
function makeRng(seed: number) {
let s = seed >>> 0
return () => {
s = (s + 0x6D2B79F5) >>> 0
let t = s
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function initParams(seed: number): Params {
const rand = makeRng(seed)
const r = () => (rand() * 2 - 1) * 0.8
return {
W1: [[r(), r()], [r(), r()]],
b1: [r() * 0.1, r() * 0.1],
W2: [r(), r()],
b2: r() * 0.1,
}
}
function initAdam(): AdamState {
return {
mW1: [[0, 0], [0, 0]], vW1: [[0, 0], [0, 0]],
mb1: [0, 0], vb1: [0, 0],
mW2: [0, 0], vW2: [0, 0],
mb2: 0, vb2: 0,
}
}
type HiddenAct = "tanh" | "relu"
// Output activation is hardcoded to tanh — only the hidden choice toggles.
// Output needs a bounded range for MSE against {0,1} targets.
function hAct(z: number, kind: HiddenAct): number {
return kind === "tanh" ? Math.tanh(z) : Math.max(0, z)
}
function hActGrad(h: number, z: number, kind: HiddenAct): number {
return kind === "tanh" ? (1 - h * h) : (z > 0 ? 1 : 0)
}
// Forward: x -> W1·x + b1 -> hAct -> W2·h + b2 -> tanh -> (y - target)²
function forward(p: Params, x: number[], target: number, hk: HiddenAct): Forward {
const z1 = [
p.W1[0][0] * x[0] + p.W1[0][1] * x[1] + p.b1[0],
p.W1[1][0] * x[0] + p.W1[1][1] * x[1] + p.b1[1],
]
const hVals = [hAct(z1[0], hk), hAct(z1[1], hk)]
const z2 = p.W2[0] * hVals[0] + p.W2[1] * hVals[1] + p.b2
const y = Math.tanh(z2)
const loss = (y - target) ** 2
return { x, z1, h: hVals, z2, y, target, loss }
}
// Backward: chain rule op by op; each line below labels which adjoint it computes.
function backward(p: Params, f: Forward, hk: HiddenAct): Backward {
const dy = 2 * (f.y - f.target) // ∂L/∂y
const dz2 = dy * (1 - f.y * f.y) // through output tanh
const dh = [dz2 * p.W2[0], dz2 * p.W2[1]] // through W2
const dz1 = [dh[0] * hActGrad(f.h[0], f.z1[0], hk), // through hidden activation
dh[1] * hActGrad(f.h[1], f.z1[1], hk)]
const dW1 = [ // dW1[j][i] = dz1[j] · x[i]
[dz1[0] * f.x[0], dz1[0] * f.x[1]],
[dz1[1] * f.x[0], dz1[1] * f.x[1]],
]
const db1 = [dz1[0], dz1[1]]
const dW2 = [dz2 * f.h[0], dz2 * f.h[1]]
const db2 = dz2
return { dy, dz2, dh, dz1, dW1, db1, dW2, db2 }
}
function applySGD(p: Params, g: Backward, lr: number): Deltas {
const dW1: number[][] = [[0, 0], [0, 0]]
const db1: number[] = [0, 0]
const dW2: number[] = [0, 0]
for (let j = 0; j < 2; j++) {
for (let i = 0; i < 2; i++) {
const d = -lr * g.dW1[j][i]; dW1[j][i] = d; p.W1[j][i] += d
}
db1[j] = -lr * g.db1[j]; p.b1[j] += db1[j]
dW2[j] = -lr * g.dW2[j]; p.W2[j] += dW2[j]
}
const db2 = -lr * g.db2; p.b2 += db2
return { dW1, db1, dW2, db2 }
}
// Adam: per-parameter running averages of gradient (m) and squared gradient (v),
// bias-corrected, step = -lr · m̂ / (√v̂ + ε). Every parameter has its own m, v.
function applyAdam(p: Params, g: Backward, s: AdamState, t: number, lr: number): Deltas {
const beta1 = 0.9, beta2 = 0.999, eps = 1e-8
const c1 = 1 - Math.pow(beta1, t)
const c2 = 1 - Math.pow(beta2, t)
const upd = (grad: number, m: number, v: number) => {
const newM = beta1 * m + (1 - beta1) * grad
const newV = beta2 * v + (1 - beta2) * grad * grad
const mHat = newM / c1
const vHat = newV / c2
return { delta: -lr * mHat / (Math.sqrt(vHat) + eps), m: newM, v: newV }
}
const dW1: number[][] = [[0, 0], [0, 0]]
const db1: number[] = [0, 0]
const dW2: number[] = [0, 0]
for (let j = 0; j < 2; j++) {
for (let i = 0; i < 2; i++) {
const u = upd(g.dW1[j][i], s.mW1[j][i], s.vW1[j][i])
dW1[j][i] = u.delta; p.W1[j][i] += u.delta
s.mW1[j][i] = u.m; s.vW1[j][i] = u.v
}
const u1 = upd(g.db1[j], s.mb1[j], s.vb1[j])
db1[j] = u1.delta; p.b1[j] += u1.delta
s.mb1[j] = u1.m; s.vb1[j] = u1.v
const u2 = upd(g.dW2[j], s.mW2[j], s.vW2[j])
dW2[j] = u2.delta; p.W2[j] += u2.delta
s.mW2[j] = u2.m; s.vW2[j] = u2.v
}
const u3 = upd(g.db2, s.mb2, s.vb2)
const db2 = u3.delta; p.b2 += db2
s.mb2 = u3.m; s.vb2 = u3.v
return { dW1, db1, dW2, db2 }
}
const EXAMPLES = [
{ x: [0, 0], y: 0 },
{ x: [0, 1], y: 1 },
{ x: [1, 0], y: 1 },
{ x: [1, 1], y: 0 },
]
// 'idle' → first click computes forward.
// forward → backward → updated → (next click) advances exIdx and forwards next example.
type Phase = "idle" | "forward" | "backward" | "updated"
// Granularity of one "advance" — orthogonal from "advance once vs run":
// phase = one sub-step (forward, backward, or updated)
// step = one full training step (3 phases, one example)
// epoch = one full pass through all 4 examples (12 phases)
type StepUnit = "phase" | "step" | "epoch"
class Demo {
p!: Params
s!: AdamState
exIdx = 0
phase: Phase = "idle"
optimizer: "sgd" | "adam" = "sgd"
hiddenAct: HiddenAct = "tanh"
stepUnit: StepUnit = "phase"
lr = 0.1
t = 0 // Adam bias-correction counter (only increments on Adam updates, so it's distinct from trainStep)
trainStep = 0
seed = 7
// fwd is always populated (constructor → reset() → forward on example 0);
// diagram never looks naked at idle.
fwd!: Forward
bwd: Backward | null = null
deltas: Deltas | null = null
lossHistory: { loss: number, exIdx: number }[] = []
// All-time max loss for chart scaling. Monotonically non-decreasing so the
// y-axis doesn't rescale upward as old high-loss bars age out of the visible
// window (which would create a false "loss going back up" appearance).
lossMax = 0
constructor() { this.reset() }
reset() {
this.p = initParams(this.seed)
this.s = initAdam()
this.exIdx = 0
this.phase = "idle"
this.t = 0
this.trainStep = 0
this.fwd = forward(this.p, EXAMPLES[0].x, EXAMPLES[0].y, this.hiddenAct)
this.bwd = null; this.deltas = null
this.lossHistory = []
this.lossMax = 0
}
reseed() {
this.seed = Math.floor(Math.random() * 100000)
this.reset()
}
step() {
if (this.phase === "idle" || this.phase === "updated") {
if (this.phase === "updated") this.exIdx = (this.exIdx + 1) % EXAMPLES.length
const ex = EXAMPLES[this.exIdx]
this.fwd = forward(this.p, ex.x, ex.y, this.hiddenAct)
this.bwd = null; this.deltas = null
this.phase = "forward"
} else if (this.phase === "forward") {
this.bwd = backward(this.p, this.fwd, this.hiddenAct)
this.phase = "backward"
} else if (this.phase === "backward") {
if (this.optimizer === "adam") {
this.t++
this.deltas = applyAdam(this.p, this.bwd!, this.s, this.t, this.lr)
} else {
this.deltas = applySGD(this.p, this.bwd!, this.lr)
}
this.lossHistory.push({ loss: this.fwd.loss, exIdx: this.exIdx })
if (this.lossHistory.length > 200) this.lossHistory.shift()
if (this.fwd.loss > this.lossMax) this.lossMax = this.fwd.loss
this.trainStep++
this.phase = "updated"
}
}
fullStep() {
do { this.step() } while (this.phase !== "updated")
}
// One pass through all 4 examples. Always lands on "updated" so the
// user sees the post-update state — a partial start (mid-step) gets
// completed by the first fullStep, then 3 more full steps follow.
cycleStep() {
for (let i = 0; i < 4; i++) this.fullStep()
}
}
// Inline SVG icons. Font glyphs (▶ ⏸ ↺) center unpredictably across
// platforms; SVG with a fixed viewBox is reliable. Shapes use currentColor
// so the button's `color` flows through (e.g. `.accent` for purple).
function btnIcon(...shapes: any[]) {
return svg({ viewBox: "0 0 16 16", width: "14", height: "14", class: "btn-icon" }, ...shapes)
}
const iconPlay = () => btnIcon(path({ d: "M3 2 L13 8 L3 14 Z", fill: "currentColor" }))
const iconPause = () => btnIcon(
rect({ x: 3, y: 3, width: 3, height: 10, fill: "currentColor" }),
rect({ x: 10, y: 3, width: 3, height: 10, fill: "currentColor" }),
)
const iconStep = () => btnIcon(
path({ d: "M3 3 L11 8 L3 13 Z", fill: "currentColor" }),
rect({ x: 12, y: 3, width: 2, height: 10, fill: "currentColor" }),
)
function fmt(x: number, d = 2): string {
if (Number.isNaN(x)) return "NaN"
if (Math.abs(x) < 1e-6) return "0"
return x.toFixed(d)
}
function fmtSign(x: number, d = 3): string {
if (Number.isNaN(x)) return "NaN"
const s = x.toFixed(d)
return x >= 0 ? "+" + s : s
}
// Uses --w-pos / --w-neg so neuron tints stay consistent with edge colors
// across themes. Hardcoded rgba() would lock one palette in for both.
function neuronFill(v: number): string {
const pct = (0.12 + 0.4 * Math.min(Math.abs(v), 1)) * 100
const token = v >= 0 ? "var(--w-pos)" : "var(--w-neg)"
return `color-mix(in srgb, ${token} ${pct}%, transparent)`
}
const X_IN = 80, X_HID = 410, X_OUT = 740
const Y_TOP = 47, Y_BOT = 217, Y_OUT = 132
const R = 24
const yFor = (j: number) => j === 0 ? Y_TOP : Y_BOT
interface IRoot {
demo: Demo
inspected: string | null
inspect(key: string | null): void
running: boolean
}
// Discriminated form of an inspected element. Click sites pass a string key
// (e.g. "W1.0.1"); parseSel turns it into a typed object so dispatch is one
// switch instead of six startsWith branches.
type Selection =
| { kind: "x", i: number }
| { kind: "h", j: number }
| { kind: "y" }
| { kind: "b1", j: number }
| { kind: "b2" }
| { kind: "W1", j: number, i: number }
| { kind: "W2", j: number }
function parseSel(key: string): Selection {
if (key === "y") return { kind: "y" }
if (key === "b2") return { kind: "b2" }
if (key.startsWith("x.")) return { kind: "x", i: parseInt(key.slice(2)) }
if (key.startsWith("h.")) return { kind: "h", j: parseInt(key.slice(2)) }
if (key.startsWith("b1.")) return { kind: "b1", j: parseInt(key.slice(3)) }
if (key.startsWith("W2.")) return { kind: "W2", j: parseInt(key.slice(3)) }
if (key.startsWith("W1.")) { const [j, i] = key.slice(3).split(".").map(Number); return { kind: "W1", j, i } }
throw new Error("parseSel: unknown key " + key)
}
// Unified accessor for a learnable parameter: name, read/write into Params,
// and the current ∂/Δ. Collapses what would otherwise be five separate
// switches inside inlineChart (one per quantity).
type ParamAccess = {
name: string
read: () => number
write: (v: number) => void
grad: number | null
delta: number | null
}
function paramAccess(d: Demo, sel: Selection): ParamAccess {
const b = d.bwd, dt = d.deltas
switch (sel.kind) {
case "W1": {
const { j, i } = sel
return {
name: `W1[${j}][${i}]`,
read: () => d.p.W1[j][i],
write: v => { d.p.W1[j][i] = v },
grad: b ? b.dW1[j][i] : null,
delta: dt ? dt.dW1[j][i] : null,
}
}
case "W2": {
const { j } = sel
return {
name: `W2[${j}]`,
read: () => d.p.W2[j],
write: v => { d.p.W2[j] = v },
grad: b ? b.dW2[j] : null,
delta: dt ? dt.dW2[j] : null,
}
}
case "b1": {
const { j } = sel
return {
name: `b1[${j}]`,
read: () => d.p.b1[j],
write: v => { d.p.b1[j] = v },
grad: b ? b.db1[j] : null,
delta: dt ? dt.db1[j] : null,
}
}
case "b2":
return {
name: "b2",
read: () => d.p.b2,
write: v => { d.p.b2 = v },
grad: b ? b.db2 : null,
delta: dt ? dt.db2 : null,
}
default:
throw new Error("paramAccess: not a learnable param")
}
}
class Diagram extends Component {
get root() { return this.ctx.root as any as IRoot }
get demo() { return this.root.demo }
view() {
return svg({
viewBox: "0 0 820 264",
width: "100%",
preserveAspectRatio: "xMidYMid meet",
class: "diagram-svg",
// Click on empty SVG background deselects. Child elements (neurons,
// edges, charts) bubble up here too, but their event.target points
// back to themselves; only a true background click has target === svg.
onClick: (e: any) => {
if (e.target === e.currentTarget) this.root.inspect(null)
},
},
this.edges(),
this.neurons(),
this.paramCharts(),
)
}
edges() {
const d = this.demo
const out: any[] = []
for (let j = 0; j < 2; j++) {
for (let i = 0; i < 2; i++) {
out.push(this.edge(X_IN, yFor(i), X_HID, yFor(j), d.p.W1[j][i], `W1.${j}.${i}`))
}
}
for (let j = 0; j < 2; j++) {
out.push(this.edge(X_HID, yFor(j), X_OUT, Y_OUT, d.p.W2[j], `W2.${j}`))
}
return out
}
selectionHalo(cx: number, cy: number) {
return circle({ cx, cy, r: R + 5, fill: "none", stroke: "var(--accent)", strokeWidth: "2", strokeOpacity: "0.6" })
}
// Edge is just the connection line; the value, ∂, and Δ are shown inside
// the in-situ parameter chart positioned at the edge's midpoint (see
// paramCharts).
edge(x1: number, ya: number, x2: number, yb: number, w: number, key: string) {
const isSel = this.root.inspected === key
const thickness = Math.min(0.8 + Math.abs(w) * 2.5, 5)
const stroke = isSel ? "var(--accent)" : (w >= 0 ? "var(--w-pos)" : "var(--w-neg)")
return g({ style: { cursor: "pointer" }, onClick: () => this.root.inspect(key) },
// Wide transparent hit area so thin lines are still easy to click.
line({ x1, y1: ya, x2, y2: yb, stroke: "transparent", strokeWidth: "12" }),
line({ x1, y1: ya, x2, y2: yb, stroke, strokeWidth: String(isSel ? Math.max(thickness + 1, 3) : thickness), strokeOpacity: isSel ? "1" : "0.65" }),
)
}
// Two-circle pattern: opaque base under the rgba tint, so edges passing
// through the circle's footprint don't bleed through the semi-transparent
// fill. `mirror=true` puts label-and-z below instead of above (bottom-row
// hidden neuron and the output). Label right-anchored at cx, z= left-anchored
// at cx+3 — keeps them visually adjacent regardless of value width.
bodyNeuron(cx: number, cy: number, label: string, post: number, pre: number, neuronKey: string, mirror = false) {
const root = this.root
const isNeuronSel = root.inspected === neuronKey
const labelY = mirror ? cy + R + 17 : cy - R - 11
return g(
circle({ cx, cy, r: R, fill: "var(--neuron-bg)" }),
isNeuronSel ? this.selectionHalo(cx, cy) : null,
circle({
cx, cy, r: R,
fill: neuronFill(post),
stroke: "var(--border-strong)",
strokeWidth: "1.5",
style: { cursor: "pointer" },
onClick: () => root.inspect(neuronKey),
}),
// pointer-events:none only on the value text — it sits over the
// clickable circle and would otherwise steal the click.
text({ x: cx, y: cy + 5, textAnchor: "middle", class: "n-val", style: { pointerEvents: "none" } }, fmt(post, 2)),
text({ x: cx, y: labelY, textAnchor: "end", class: "n-label" }, label),
text({ x: cx + 3, y: labelY, textAnchor: "start", class: "n-sub" }, `z=${fmt(pre, 2)}`),
)
}
neurons() {
const d = this.demo
const f = d.fwd
const out: any[] = []
const root = this.root
for (let i = 0; i < 2; i++) {
const y = yFor(i)
const key = `x.${i}`
const isSel = root.inspected === key
// Bottom-row input: label below the circle (mirrored), so the diagram
// is symmetric across the horizontal midline.
const labelY = i === 1 ? y + R + 17 : y - R - 11
out.push(g(
isSel ? this.selectionHalo(X_IN, y) : null,
circle({
cx: X_IN, cy: y, r: R,
fill: "var(--neuron-bg)",
stroke: "var(--border-strong)",
strokeWidth: "1.5",
style: { cursor: "pointer" },
onClick: () => root.inspect(key),
}),
text({ x: X_IN, y: y + 5, textAnchor: "middle", class: "n-val", style: { pointerEvents: "none" } }, fmt(f.x[i], 1)),
text({ x: X_IN, y: labelY, textAnchor: "middle", class: "n-label" }, `x${i+1}`),
))
}
for (let j = 0; j < 2; j++) {
out.push(this.bodyNeuron(
X_HID, yFor(j), `h${j+1}`,
f.h[j],
f.z1[j],
`h.${j}`,
j === 1, // mirror the bottom-row neuron
))
}
out.push(this.bodyNeuron(
X_OUT, Y_OUT, "y",
f.y,
f.z2,
"y",
true, // label + z below — keeps the space above y clear for the b2 chart
))
return out
}
// All 9 learnable parameters get an in-situ mini-chart. Layout:
// - W1 charts: vertically stacked column at x=245 (diagonals would
// otherwise overlap at the edge crossing).
// - b1 charts: between h1 and h2 at equal thirds.
// - W2 charts: at the hidden→output edge midpoints.
// - b2 chart: above the output neuron.
paramCharts() {
const out: any[] = []
const W1_X = 245
const w1Stack: { j: 0|1, i: 0|1, cy: number }[] = [
{ j: 0, i: 0, cy: 42 },
{ j: 1, i: 0, cy: 102 },
{ j: 0, i: 1, cy: 162 },
{ j: 1, i: 1, cy: 222 },
]
for (const p of w1Stack) {
out.push(this.inlineChart({ kind: "W1", j: p.j, i: p.i }, W1_X, p.cy, `W1.${p.j}.${p.i}`))
}
const b1Step = (Y_BOT - Y_TOP) / 3
out.push(this.inlineChart({ kind: "b1", j: 0 }, X_HID, Y_TOP + b1Step, "b1.0"))
out.push(this.inlineChart({ kind: "b1", j: 1 }, X_HID, Y_BOT - b1Step, "b1.1"))
out.push(this.inlineChart({ kind: "W2", j: 0 }, (X_HID + X_OUT) / 2, (Y_TOP + Y_OUT) / 2, "W2.0"))
out.push(this.inlineChart({ kind: "W2", j: 1 }, (X_HID + X_OUT) / 2, (Y_BOT + Y_OUT) / 2, "W2.1"))
out.push(this.inlineChart({ kind: "b2" }, X_OUT, Y_OUT - 58, "b2"))
return out
}
// The actual mini-chart for one parameter. Sweeps the parameter through a
// range (everything else fixed), plots loss as a curve, marks the current
// value, and overlays a tangent whose slope IS ∂. Self-contained with
// overlaid name + value + ∂ + Δ.
inlineChart(sel: Selection, cx: number, cy: number, key: string) {
const d = this.demo
const acc = paramAccess(d, sel)
const cur = acc.read()
const ex = EXAMPLES[d.exIdx]
const curLoss = forward(d.p, ex.x, ex.y, d.hiddenAct).loss
const spread = Math.max(1.0, 2 * Math.abs(cur))
const N = 24
const pts: { v: number, loss: number }[] = []
for (let i = 0; i <= N; i++) {
const v = cur - spread + (2 * spread * i / N)
acc.write(v)
pts.push({ v, loss: forward(d.p, ex.x, ex.y, d.hiddenAct).loss })
}
acc.write(cur)
const grad = (d.phase === "backward" || d.phase === "updated") ? acc.grad : null
const delta = d.phase === "updated" ? acc.delta : null
// Tangent shows whenever a gradient is available — same condition as
// ∂. On `updated` the slope is one step stale (∂ was computed at the
// pre-update position), but the line is drawn through (cur, curLoss),
// so the dot still sits on it — only the slope direction is nudged.
const tangentGrad = grad
const W = 108, H = 46
const PLOT_LEFT = 4, PLOT_RIGHT = W - 4
const PLOT_TOP = 13, PLOT_BOT = H - 13
const xMin = pts[0].v, xMax = pts[pts.length - 1].v
const yMax = Math.max(0.001, ...pts.map(p => p.loss))
const sx = (v: number) => PLOT_LEFT + ((v - xMin) / (xMax - xMin)) * (PLOT_RIGHT - PLOT_LEFT)
const sy = (l: number) => PLOT_BOT - (l / yMax) * (PLOT_BOT - PLOT_TOP)
const pathD = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${sx(p.v).toFixed(1)} ${sy(p.loss).toFixed(1)}`).join(' ')
const cxPt = sx(cur), cyPt = sy(curLoss)
const tDx = (xMax - xMin) * 0.22
const tangentEl = tangentGrad !== null ? line({
x1: sx(cur - tDx), y1: sy(curLoss - tangentGrad * tDx),
x2: sx(cur + tDx), y2: sy(curLoss + tangentGrad * tDx),
stroke: "var(--grad-color)",
strokeWidth: "1.5",
strokeLineCap: "round",
}) : null
const isSel = this.root.inspected === key
return g({
transform: `translate(${(cx - W / 2).toFixed(1)}, ${(cy - H / 2).toFixed(1)})`,
style: { cursor: "pointer" },
onClick: () => this.root.inspect(key),
},
rect({
x: 0, y: 0, width: W, height: H,
fill: "var(--chart-tint)",
stroke: isSel ? "var(--accent)" : "var(--border)",
strokeWidth: isSel ? "1.5" : "0.6",
rx: "3",
}),
text({ x: 4, y: 9, class: "il-name" }, acc.name),
text({ x: W - 4, y: 9, textAnchor: "end", class: "il-val" }, fmt(cur, 2)),
line({ x1: PLOT_LEFT, y1: PLOT_BOT, x2: PLOT_RIGHT, y2: PLOT_BOT, stroke: "var(--border)", strokeWidth: "0.4" }),
path({ d: pathD, stroke: "var(--text-muted)", strokeWidth: "1", fill: "none" }),
tangentEl,
circle({ cx: cxPt, cy: cyPt, r: "1.8", fill: "var(--accent)" }),
grad !== null ? text({ x: 4, y: H - 4, class: "il-grad" }, `∂${fmtSign(grad, 2)}`) : null,
delta !== null ? text({ x: W - 4, y: H - 4, textAnchor: "end", class: "il-delta" }, `Δ${fmtSign(delta, 3)}`) : null,
)
}
}
class Root extends Component implements IRoot {
demo = new Demo()
diagram = new Diagram()
runTimer: number | null = null
activeTab: "demo" | "code" = "demo"
inspected: string | null = null
// Advance by one of the currently-selected StepUnit. Used by both the
// Advance button and the Run autoplay loop, so "advance once" and "play"
// share the same granularity setting.
advanceOnce() {
switch (this.demo.stepUnit) {
case "phase": this.demo.step(); return
case "step": this.demo.fullStep(); return
case "epoch": this.demo.cycleStep(); return
}
}
step() { this.advanceOnce(); this.update() }
stopRun() { if (this.runTimer !== null) { clearInterval(this.runTimer); this.runTimer = null } }
reset() { this.stopRun(); this.demo.reset(); this.update() }
reseed() { this.stopRun(); this.demo.reseed(); this.update() }
setOptimizer(o: "sgd" | "adam") { this.stopRun(); this.demo.optimizer = o; this.demo.reset(); this.inspected = null; this.update() }
setHiddenAct(a: HiddenAct) { this.stopRun(); this.demo.hiddenAct = a; this.demo.reset(); this.inspected = null; this.update() }
setStepUnit(u: StepUnit) { this.demo.stepUnit = u; this.update() }
setLr(lr: number) { this.demo.lr = lr; this.update() }
inspect(key: string | null) {
// Same key = deselect; otherwise select. Lets callers just pass their key.
this.inspected = (key !== null && this.inspected === key) ? null : key
this.update()
}
get running() { return this.runTimer !== null }
toggleRun() {
if (this.running) {
this.stopRun()
} else {
this.runTimer = window.setInterval(() => {
this.advanceOnce()
this.update()
}, 80)
}
this.update()
}
view() {
const d = this.demo
const info = this.phaseInfo()
const showDemo = this.activeTab === "demo"
const showCode = this.activeTab === "code"
return div({ class: "app" },
div({ class: "header" },
h1("XOR X-ray"),
p({}, "A 2-2-1 neural network learns XOR. Step through the full training loop: forward arithmetic, chain-rule backward, optimizer update."),
),
div({ class: "tabs" },
this.tabBtn("demo", "Step through"),
this.tabBtn("code", "In tensorgrad"),
),
div({ class: "tab-content", style: { display: showDemo ? "block" : "none" } },
div({ class: "controls" },
div({ class: "btn-group" },
button({ onClick: () => this.toggleRun(), class: ["icon", "accent"] }, this.running ? iconPause() : iconPlay()),
button({ onClick: () => this.step(), class: ["icon", "accent"], title: "Advance" }, iconStep()),
button({ onClick: () => this.reset(), class: "icon" }, "↺"),
button({ onClick: () => this.reseed() }, "New seed"),
),
this.toggleGroup<StepUnit>("Advance by",
[{ value: "phase", label: "phase" }, { value: "step", label: "step" }, { value: "epoch", label: "epoch" }],
d.stepUnit, u => this.setStepUnit(u)),
this.toggleGroup<HiddenAct>("Activation",
[{ value: "tanh", label: "tanh" }, { value: "relu", label: "ReLU" }],
d.hiddenAct, a => this.setHiddenAct(a)),
this.toggleGroup<"sgd" | "adam">("Optimizer",
[{ value: "sgd", label: "SGD" }, { value: "adam", label: "Adam" }],
d.optimizer, o => this.setOptimizer(o)),
this.toggleGroup<number>("LR",
[0.01, 0.1, 0.5].map(lr => ({ value: lr, label: String(lr) })),
d.lr, lr => this.setLr(lr)),
div({ class: "stat" }, `step ${d.trainStep}`),
),
div({ class: ["panel", "phase-banner", `phase-${d.phase}`] },
div({ class: "phase-title" }, info.title),
div({ class: "phase-desc" }, info.desc),
),
div({ class: ["panel", "diagram-panel"] },
this.diagram.view(),
this.inspector(),
),
div({ class: "subpanel-row" },
this.predictionsPanel(),
this.lossChart(),
),
),
div({ class: "tab-content", style: { display: showCode ? "block" : "none" } },
this.handoffPanel(),
),
)
}
tabBtn(key: "demo" | "code", label: string) {
return button({
class: ["tab-btn", this.activeTab === key && "active"],
onClick: () => {
if (this.activeTab === key) return
this.activeTab = key
this.update()
},
}, label)
}
toggleGroup<T>(label: string, choices: { value: T, label: string }[], current: T, onSelect: (v: T) => void) {
return div({ class: "toggle-group" },
div({ class: "toggle-label" }, label),
choices.map(c => button({
onClick: () => onSelect(c.value),
class: ["toggle", current === c.value && "active"],
}, c.label)),
)
}
inspector() {
const sel = this.inspected
if (!sel) {
return div({ class: "inspector inspector-empty" },
"Click any neuron or weight in the diagram to see its math here.",
)
}
return div({ class: "inspector" },
div({ class: "inspector-head" },
div({ class: "inspector-label" }, this.inspectorTitle(sel)),
button({ class: "inspector-close", onClick: () => this.inspect(null) }, "✕"),
),
div({ class: "inspector-body" }, this.inspectorBody(sel)),
)
}
inspectorTitle(sel: string): string {
const p = parseSel(sel)
switch (p.kind) {
case "x": return `Input x${p.i + 1}`
case "h": return `Hidden neuron h${p.j + 1}`
case "y": return "Output neuron y"
case "b1": return `Hidden bias b1[${p.j}]`
case "b2": return "Output bias b2"
case "W1": return `Weight W1[${p.j}][${p.i}] — input x${p.i + 1} → hidden h${p.j + 1}`
case "W2": return `Weight W2[${p.j}] — hidden h${p.j + 1} → output y`
}
}
// Phase-accumulating: forward shows the element's activation; backward adds
// its gradient; updated adds the optimizer step. By 'updated' the user has
// seen the full chain for this element.
inspectorBody(sel: string) {
const d = this.demo
const f = d.fwd
const b = d.bwd
const deltas = d.deltas
const lines: string[] = []
const p = parseSel(sel)
switch (p.kind) {
case "x": {
lines.push(`x${p.i + 1} = ${fmt(f.x[p.i])} (this step's input)`)
break
}
case "h": {
const j = p.j
lines.push(`h${j + 1} = ${d.hiddenAct}(W1[${j}]·x + b1[${j}])`)
lines.push(` = ${d.hiddenAct}(${fmt(d.p.W1[j][0])}·${fmt(f.x[0])} + ${fmt(d.p.W1[j][1])}·${fmt(f.x[1])} + ${fmt(d.p.b1[j])})`)
lines.push(` = ${d.hiddenAct}(${fmt(f.z1[j])}) = ${fmt(f.h[j], 3)}`)
if (b) {
lines.push("")
const actDeriv = d.hiddenAct === "tanh" ? `(1 − h²) = ${fmt(1 - f.h[j] ** 2, 3)}` : `(z > 0) = ${f.z1[j] > 0 ? 1 : 0}`
lines.push(`∂h${j + 1} = ∂z2 · W2[${j}] = ${fmt(b.dz2, 3)} · ${fmt(d.p.W2[j])} = ${fmt(b.dh[j], 3)}`)
lines.push(`∂z${j + 1} = ∂h${j + 1} · ${actDeriv} = ${fmt(b.dz1[j], 3)}`)
}
break
}
case "y": {
lines.push(`y = tanh(W2·h + b2)`)
lines.push(` = tanh(${fmt(d.p.W2[0])}·${fmt(f.h[0])} + ${fmt(d.p.W2[1])}·${fmt(f.h[1])} + ${fmt(d.p.b2)})`)
lines.push(` = tanh(${fmt(f.z2)}) = ${fmt(f.y, 3)}`)
lines.push("")
lines.push(`loss = (y − target)² = (${fmt(f.y, 3)} − ${f.target})² = ${fmt(f.loss, 4)}`)
if (b) {
lines.push("")
lines.push(`∂y = 2·(y − target) = 2·(${fmt(f.y, 3)} − ${f.target}) = ${fmt(b.dy, 3)}`)
lines.push(`∂z2 = ∂y · (1 − y²) = ${fmt(b.dy, 3)} · ${fmt(1 - f.y ** 2, 3)} = ${fmt(b.dz2, 3)}`)
}
break
}
case "W1": {
const { j, i } = p
lines.push(`current value: ${fmt(d.p.W1[j][i], 3)}`)
lines.push(`forward contribution: w·x${i + 1} = ${fmt(d.p.W1[j][i])} · ${fmt(f.x[i])} = ${fmt(d.p.W1[j][i] * f.x[i], 3)}`)
if (b) {
lines.push("")
lines.push(`∂W1[${j}][${i}] = ∂z${j + 1} · x${i + 1} = ${fmt(b.dz1[j], 3)} · ${fmt(f.x[i])} = ${fmt(b.dW1[j][i], 3)}`)
}
if (deltas) this.appendOptimizerLines(lines, b!.dW1[j][i], deltas.dW1[j][i], d.s.mW1[j][i], d.s.vW1[j][i])
break
}
case "W2": {
const j = p.j
lines.push(`current value: ${fmt(d.p.W2[j], 3)}`)
lines.push(`forward contribution: w·h${j + 1} = ${fmt(d.p.W2[j])} · ${fmt(f.h[j])} = ${fmt(d.p.W2[j] * f.h[j], 3)}`)
if (b) {
lines.push("")
lines.push(`∂W2[${j}] = ∂z2 · h${j + 1} = ${fmt(b.dz2, 3)} · ${fmt(f.h[j])} = ${fmt(b.dW2[j], 3)}`)
}
if (deltas) this.appendOptimizerLines(lines, b!.dW2[j], deltas.dW2[j], d.s.mW2[j], d.s.vW2[j])
break
}
case "b1": {
const j = p.j
lines.push(`current value: ${fmt(d.p.b1[j], 3)}`)
if (b) {
lines.push("")
lines.push(`∂b1[${j}] = ∂z${j + 1} = ${fmt(b.dz1[j], 3)} (bias gradient = pre-activation gradient)`)
}
if (deltas) this.appendOptimizerLines(lines, b!.db1[j], deltas.db1[j], d.s.mb1[j], d.s.vb1[j])
break
}
case "b2": {
lines.push(`current value: ${fmt(d.p.b2, 3)}`)
if (b) {
lines.push("")
lines.push(`∂b2 = ∂z2 = ${fmt(b.dz2, 3)}`)
}
if (deltas) this.appendOptimizerLines(lines, b!.db2, deltas.db2, d.s.mb2, d.s.vb2)
break
}
}
const showChartNote = p.kind === "W1" || p.kind === "W2" || p.kind === "b1" || p.kind === "b2"
return [
...lines.map(l => div({ class: l === "" ? "inspector-gap" : "inspector-line" }, l)),
showChartNote ? div({ class: "inspector-note" },
"The optimizer here merely sees the orange tangent line for this parameter, generated by backprop. Purely for you, the reader, we create a mini-chart computing what the loss would be for nearby parameter values, if all other parameters were held constant. This would be far too expensive in a real model, and furthermore, the 1-dimensional axis here is just one of n parameter axes that together define the real loss function.",
) : null,
]
}
// The optimizer math is the same shape for every parameter — factor it
// here so each parameter's inspector body just delegates.
appendOptimizerLines(lines: string[], grad: number, delta: number, m: number, v: number) {
const d = this.demo
lines.push("")
if (d.optimizer === "sgd") {
lines.push(`Δ = −lr · ∂ = −${d.lr} · ${fmt(grad, 3)} = ${fmt(delta, 4)} (current shown above is post-update)`)
return
}
const c1 = 1 - Math.pow(0.9, d.t)
const c2 = 1 - Math.pow(0.999, d.t)
lines.push(`Adam (t=${d.t}, β₁=0.9, β₂=0.999):`)
lines.push(` m̂ = m / (1 − 0.9^t) = ${fmt(m, 4)} / ${fmt(c1, 3)} = ${fmt(m / c1, 4)}`)
lines.push(` v̂ = v / (1 − 0.999^t) = ${fmt(v, 5)} / ${fmt(c2, 5)} = ${fmt(v / c2, 5)}`)
lines.push(` Δ = −lr · m̂ / √v̂ = ${fmt(delta, 4)} (current shown above is post-update)`)
}
phaseInfo(): { title: string; desc: string } {
const d = this.demo
switch (d.phase) {
case "idle":
return {
title: "Ready",
desc: 'Tiny network: 2 inputs, 2 hidden neurons (tanh), 1 output (tanh). Random weights. Click "Advance" to start training on the four XOR examples, one at a time.',
}
case "forward": {
const opSentence = d.hiddenAct === "tanh"
? "Each neuron multiplies its incoming values by the weight of their connections, adds its bias (learned value above each circle), then passes the result through tanh, squashing it to between −1 and 1."
: "Each neuron multiplies its incoming values by the weight of their connections, adds its bias (learned value above each circle). Hidden neurons then pass the result through ReLU (negative becomes 0, positive passes through); the output uses tanh, squashing to between −1 and 1."
return {
title: "Forward done",
desc: `Walked the network forward. ${opSentence} The result appears inside. We compare the output to the target (the answer we want); loss = (output − target)².`,
}
}
case "backward": {
const actDeriv = d.hiddenAct === "tanh"
? "With tanh, the hidden derivative is 1 − h² (smooth, always nonzero)."
: "With ReLU, the hidden derivative is a 0/1 mask — ∂z is zero at any 'dead' neuron (z ≤ 0)."
return {
title: "Backward done",
desc: `Walked the network in reverse via the chain rule. ∂z (say "partial z") at each neuron and ∂ on each edge is the local gradient — how much the loss changes per unit change there. ${actDeriv}`,
}
}
case "updated": {
const opt = d.optimizer === "adam" ? "Adam" : "SGD"
const desc = d.optimizer === "adam"
? "Walked the network in reverse again (in practice this step can interleave with backprop). As each weight was traversed, it moved by −lr · m̂ / (√v̂ + ε), where m̂ and v̂ are running averages of the gradient and its square (one m, one v per parameter — that's the 'adaptive per-parameter' part). After a few steps the Δ values stop being proportional to ∂. Toggle to SGD to see what proportional-to-∂ updates look like."
: "Walked the network in reverse again (in practice this step can interleave with backprop). As each weight was traversed, it moved by −lr · ∂ — every edge gets the same lr factor, so Δ is directly proportional to gradient magnitude. Toggle to Adam to see per-parameter adaptive step sizes instead."
return {
title: `${opt} update done`,
desc,
}
}
}
}
predictionsPanel() {
const d = this.demo
return div({ class: ["panel", "subpanel"] },
h3("Network output on all 4 XOR inputs"),
div({ class: "pred-table" },
EXAMPLES.map((ex, i) => {
const f = forward(d.p, ex.x, ex.y, d.hiddenAct)
const correct = Math.abs(f.y - ex.y) < 0.3
const isCurrent = i === d.exIdx
return div({ class: ["pred-row", correct ? "correct" : "incorrect", isCurrent && "current"] },
span({ class: "ex-dot", style: { background: `var(--ex-color-${i})` } }),
span({ class: "pred-in" }, `(${ex.x[0]}, ${ex.x[1]})`),
span({ class: "pred-arrow" }, "→"),
span({ class: "pred-out" }, fmt(f.y, 3)),
span({ class: "pred-tgt" }, `(want ${ex.y})`),
span({ class: "pred-mark" }, correct ? "✓" : "·"),
)
}),
),
div({ class: "pred-note" }, "The outlined row is the example currently being trained on. All four rows are re-evaluated after every optimizer step — even rows we're not training on shift, because the weights are shared across all inputs. The network has learned XOR when all four are ✓."),
)
}
lossChart() {
const d = this.demo
const hist = d.lossHistory
const maxL = d.lossMax > 0 ? d.lossMax : null
return div({ class: ["panel", "subpanel"] },
h3({},
`Loss per step (last ${hist.length})`,
span({ class: "chart-current" }, ` · loss ${fmt(d.fwd.loss, 4)}`),
maxL !== null ? span({ class: "chart-current" }, ` · max ${fmt(maxL, 3)}`) : null,
),
div({ class: "loss-legend" },
EXAMPLES.map((ex, i) => span({ class: "ex-item" },
span({ class: "ex-swatch", style: { background: `var(--ex-color-${i})` } }),
`(${ex.x[0]}, ${ex.x[1]})`,
)),
),
hist.length < 1
? div({ class: "chart-empty" }, "(no training steps yet)")
: this.renderLossChart(hist, maxL ?? 0.01),
)
}
// Bars not a line: each step is a discrete event measuring one specific
// example — no meaningful interpolation between samples.
renderLossChart(hist: { loss: number, exIdx: number }[], maxL: number) {
const w = 320, ht = 100
const innerHt = ht - 6
const barW = w / hist.length
const bars = hist.map((h, i) => {
const barHt = (h.loss / maxL) * innerHt
return rect({
x: i * barW,
y: ht - barHt - 3,
width: Math.max(barW, 0.5),
height: barHt,
fill: `var(--ex-color-${h.exIdx})`,
})
})
return svg({ viewBox: `0 0 ${w} ${ht}`, width: "100%", preserveAspectRatio: "none", class: "loss-chart" },
bars,
)
}
handoffPanel() {
return div({ class: ["panel", "handoff"] },
h2("The same network and training, in tensorgrad"),
p({}, "This is how to write the same logic in tensorgrad. It's a tiny library that compiles neural networks to WebGPU, good for in-browser demos and visualisations."),
p({}, "Visualize ", a({ href: 'https://tinyurl.com/3k6zf6mf', target: '_blank' }, 'this network as a dataflow diagram'), ", drawn by nn-dna, a tool that turns plain-English descriptions of neural networks into architecture diagrams."),
pre({ class: "code" }, this.handoffCode()),
)
}
handoffCode() {
return `import {
Module, compile, Linear, tanh, sub, square, mean,
type Tensor,
} from 'tensorgrad'
class XORNet extends Module {
l1 = new Linear(2, 2) // input → hidden
l2 = new Linear(2, 1) // hidden → output
}
function netFwd(m: XORNet, x: Tensor): Tensor {
return tanh(m.l2.fwd(tanh(m.l1.fwd(x))))
}
function lossFn(m: XORNet, { x, y }: { x: Tensor; y: Tensor }): Tensor {
return mean(square(sub(netFwd(m, x), y)))
}
const train = await compile({
model: new XORNet(),
loss: lossFn,
inputs: { x: [1, 2], y: [1, 1] }, // batch size 1
optimizer: { kind: 'adam', lr: 0.1 },
})
const examples = [
{ x: [0, 0], y: [0] },
{ x: [0, 1], y: [1] },
{ x: [1, 0], y: [1] },
{ x: [1, 1], y: [0] },
]
for (let step = 0; step < 200; step++) {
const ex = examples[step % 4]
await train.step({
x: new Float32Array(ex.x),
y: new Float32Array(ex.y),
})
}`
}
}
new App({ root: new Root(), id: "app" })