This is the picture people mean when they say a network activates. Two numbers go in on the left, and every neuron downstream lights up in proportion to how strongly it responds. The signal sweeps left to right, layer by layer.
EXP-035
Move the cursor over the canvas — it is part of the simulation.
Drag your finger across the canvas — it is part of the simulation.
What to look for
Move the cursor: it is the input. Small moves near the middle barely change anything and then a whole branch of the network suddenly switches sign. That sensitivity is where nonlinearity lives, and it is why a network can do things a straight line cannot.
The maths
a⁽ˡ⁾ = tanh(W⁽ˡ⁾a⁽ˡ⁻¹⁾ + b⁽ˡ⁾)
One layer. A matrix multiply, a bias, and a squashing function. Stack it and that is the whole forward pass.
tanh(z) ∈ (−1, 1)
The squashing is what matters. Without it every layer collapses into a single linear map and depth buys you nothing at all.
flow ∝ |w·a|
What the edges draw. A connection is bright when it has both a large weight and an active source, which is what makes some paths through the network visibly dominate.
How it is built
Weights are random rather than trained: the point here is the shape of the computation, not the answer. Each frame runs a full forward pass, then draws every connection with opacity proportional to how much signal is flowing through it and every node sized by its activation. A sweeping front gates the layers so you can see the order things happen in.
The code, step by step
Cut straight from lab.js. These are the real lines that run above, not a simplified version.
01
The forward pass
const acts = [[ix, iy]];
for (let l = 0; l < D; l++) {
const prev = acts[acts.length - 1], out = [];
for (const row of Wt[l]) {
let z = 0;
for (let k = 0; k < row.length; k++) z += row[k] * prev[k];
out.push(Math.tanh(z));
}
acts.push(out);
Every layer is stored, not just the final answer, because the whole point is drawing what happened in between. Multiply by weights, sum, squash with tanh, hand it to the next layer.
02
The sweeping front
const front = (t * P.speed * 0.6) % (D + 2);
const reach = l => Math.max(0, Math.min(1, front - l));
A single number that walks across the layers and gates how much each one is drawn. Without it every layer would light at once and you would lose the sense of a signal travelling.
A connection is bright when the weight is large and the source neuron is active. Drawing every edge equally would be a grey mess; weighting by actual flow is what makes some paths visibly dominate.
Why it matters
Every large model is this picture with more layers and vastly more nodes. Nothing else about the mechanism changes: numbers come in, get multiplied by weights, get squashed, and move right. Seeing it at nine neurons per layer makes the rest less mysterious.
The whole thing
Everything above, in one piece. This is the entire experiment as it lives in lab.js.
FULL
lab.js · neural
{ id: 'neural',
name: L('A network thinking', 'Una red pensando'),
note: L('Signal entering on the left and spreading. Node brightness is activation.',
'Señal entrando por la izquierda y propagándose. El brillo del nodo es su activación.'),
params: [
{ key: 'depth', label: L('Layers', 'Capas'), min: 2, max: 6, step: 1, def: 4 },
{ key: 'width', label: L('Units per layer', 'Neuronas por capa'), min: 3, max: 14, step: 1, def: 9 },
{ key: 'speed', label: L('Signal speed', 'Velocidad de la señal'), min: 0.2, max: 3, step: 0.1, def: 1 }
],
make() {
let Wt = [], D = 0, N = 0;
const rnd = () => (Math.random() * 2 - 1) * 1.4;
function build(depth, width) {
D = depth; N = width; Wt = [];
// Capa 0 recibe 2 entradas; el resto recibe la capa anterior completa
for (let l = 0; l < D; l++) {
const inN = l === 0 ? 2 : N;
Wt.push(Array.from({ length: l === D - 1 ? 1 : N },
() => Array.from({ length: inN }, rnd)));
}
}
build(4, 9);
return {
reset() { build(D, N); },
step(ctx, w, h, t, acc, P, M) {
if ((P.depth | 0) !== D || (P.width | 0) !== N) build(P.depth | 0, P.width | 0);
// Entrada: el cursor. Sin cursor, un punto que orbita solo.
const ix = M.in ? (M.x / w) * 2 - 1 : Math.cos(t * 0.5) * 0.8;
const iy = M.in ? (M.y / h) * 2 - 1 : Math.sin(t * 0.7) * 0.8;
// Propagación hacia adelante, guardando cada capa
const acts = [[ix, iy]];
for (let l = 0; l < D; l++) {
const prev = acts[acts.length - 1], out = [];
for (const row of Wt[l]) {
let z = 0;
for (let k = 0; k < row.length; k++) z += row[k] * prev[k];
out.push(Math.tanh(z));
}
acts.push(out);
}
// El frente de señal barre las capas: eso es lo que se lee como pensar
const front = (t * P.speed * 0.6) % (D + 2);
const reach = l => Math.max(0, Math.min(1, front - l));
const padX = w * 0.12, spanX = w - padX * 2;
const colX = i => padX + (spanX * i) / (acts.length - 1);
const nodeY = (layer, i) => {
const n = layer.length;
return h * 0.5 + (i - (n - 1) / 2) * Math.min(h * 0.11, h * 0.8 / Math.max(n, 1));
};
// Conexiones: grosor por |peso|, brillo por cuánto viaja por ellas
for (let l = 0; l < D; l++) {
const from = acts[l], to = acts[l + 1], g = reach(l);
if (g <= 0) continue;
for (let j = 0; j < to.length; j++) {
for (let k = 0; k < from.length; k++) {
const wgt = Wt[l][j][k];
const flow = Math.abs(wgt * from[k]) * g;
if (flow < 0.03) continue;
ctx.lineWidth = 0.4 + Math.min(1.6, Math.abs(wgt) * 0.8);
ctx.strokeStyle = wgt >= 0
? `rgba(${acc},${Math.min(0.6, flow * 0.55).toFixed(3)})`
: `rgba(235,238,245,${Math.min(0.35, flow * 0.3).toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(colX(l), nodeY(from, k));
ctx.lineTo(colX(l + 1), nodeY(to, j));
ctx.stroke();
}
}
}
// Nodos: radio y halo según |activación|
for (let l = 0; l < acts.length; l++) {
const layer = acts[l], g = reach(l - 1) || (l === 0 ? 1 : 0);
for (let i = 0; i < layer.length; i++) {
const a = Math.abs(layer[i]) * (l === 0 ? 1 : g);
const x = colX(l), y = nodeY(layer, i);
ctx.fillStyle = `rgba(${acc},${(0.05 + a * 0.12).toFixed(3)})`;
ctx.beginPath(); ctx.arc(x, y, 5 + a * 13, 0, TAU); ctx.fill();
ctx.fillStyle = layer[i] >= 0
? `rgba(${acc},${(0.25 + a * 0.75).toFixed(3)})`
: `rgba(235,238,245,${(0.2 + a * 0.6).toFixed(3)})`;
ctx.beginPath(); ctx.arc(x, y, 2.4 + a * 3.4, 0, TAU); ctx.fill();
}
}
// Salida
const out = acts[acts.length - 1][0];
ctx.fillStyle = `rgba(${acc},0.85)`; ctx.font = '11px monospace';
ctx.fillText('in (' + ix.toFixed(2) + ', ' + iy.toFixed(2) + ')', 12, h - 28);
ctx.fillText('out ' + out.toFixed(3), 12, h - 12);
}
};
}
},