The demo below simulates gradient descent on a two-layer network learning a simple regression problem. Both networks start from identical random weights. Press Train both to watch 200 steps of SGD โ one network uses batch normalization after the first layer, the other does not.
<div class="controls">
<button id="trainBtn" type="button">Train both</button>
<button id="resetBtn" type="button" class="ghost">Reset & randomize</button>
</div>
<div class="chart-area">
<canvas id="lossChart" width="560" height="240"></canvas>
</div>
<div class="legend">
<span class="dot blue"></span><span>With BatchNorm</span>
<span class="dot red"></span><span>Without BatchNorm</span>
</div>
<div id="summary" class="summary"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; gap: .5rem; margin-bottom: .6rem; flex-wrap: wrap; }
button {
font: 600 14px system-ui, sans-serif;
padding: .45rem .9rem;
border: 1px solid #1d3557;
background: #1d3557;
color: #fff;
border-radius: 8px;
cursor: pointer;
}
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
.chart-area { width: 100%; overflow-x: auto; }
canvas { display: block; max-width: 100%; }
.legend { display: flex; align-items: center; gap: .4rem .9rem; flex-wrap: wrap; font-size: .85rem; margin-top: .4rem; }
.dot { width: 14px; height: 14px; border-radius: 50%; display: inline-block; }
.dot.blue { background: #2563eb; }
.dot.red { background: #dc2626; }
.summary { font-size: .88rem; margin-top: .55rem; min-height: 1.3em; color: #1d3557; font-weight: 600; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulates training a small 2-layer network (with vs without BatchNorm)
// on synthetic regression data: y = sin(x) + noise.
// We track the Mean Squared Error loss at each step.
// BatchNorm is approximated by normalizing the hidden activations over
// the mini-batch at forward-pass time (ฮณ=1, ฮฒ=0 fixed for simplicity).
const STEPS = 200;
const BATCH = 16;
const LR_BN = 0.12; // higher LR is safe with BN
const LR_NO = 0.015; // must be lower without BN to avoid divergence
const HIDDEN = 12;
const EPS = 1e-8;
// โโ Utilities โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function randn() {
// Box-Muller transform
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function relu(x) { return Math.max(0, x); }
function reluGrad(x) { return x > 0 ? 1 : 0; }
// โโ Dataset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function makeDataset(n) {
const X = [], Y = [];
for (let i = 0; i < n; i++) {
const x = (Math.random() * 4 - 2); // [-2, 2]
X.push(x);
Y.push(Math.sin(x) + randn() * 0.15);
}
return { X, Y };
}
// โโ Network weights โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function initWeights() {
// Layer 1: 1 -> HIDDEN
const W1 = Array.from({ length: HIDDEN }, () => randn() * 0.5);
const b1 = Array(HIDDEN).fill(0);
// Layer 2: HIDDEN -> 1
const W2 = Array.from({ length: HIDDEN }, () => randn() * 0.1);
const b2 = [0];
return { W1, b1, W2, b2 };
}
// โโ Forward pass โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function forward(x, weights, useBN, bnStats) {
// Hidden layer pre-activations
const pre = weights.W1.map((w, j) => w * x + weights.b1[j]);
let hidden;
if (useBN) {
// Normalize over the single example using running stats
const mu = bnStats.mu;
const sig = bnStats.sig;
hidden = pre.map(p => relu((p - mu) / Math.sqrt(sig * sig + EPS)));
} else {
hidden = pre.map(relu);
}
// Output layer
const out = hidden.reduce((s, h, j) => s + weights.W2[j] * h, weights.b2[0]);
return { pre, hidden, out };
}
// โโ Mini-batch SGD step โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function trainStep(weights, X, Y, useBN, bnStats, lr) {
// Sample random mini-batch
const idx = Array.from({ length: BATCH }, () => Math.floor(Math.random() * X.length));
// Compute batch BN stats from pre-activations if needed
if (useBN) {
const allPre = idx.map(i => weights.W1.map((w, j) => w * X[i] + weights.b1[j]));
const newMu = Array(HIDDEN).fill(0);
const newVar = Array(HIDDEN).fill(0);
for (const pre of allPre) pre.forEach((p, j) => { newMu[j] += p / BATCH; });
for (const pre of allPre) pre.forEach((p, j) => { newVar[j] += (p - newMu[j]) ** 2 / BATCH; });
// Update running stats (exponential moving average)
bnStats.mu = 0.9 * bnStats.mu + 0.1 * (newMu.reduce((a, b) => a + b) / HIDDEN);
bnStats.sig = 0.9 * bnStats.sig + 0.1 * Math.sqrt(newVar.reduce((a, b) => a + b) / HIDDEN + EPS);
}
let totalLoss = 0;
const dW1 = Array(HIDDEN).fill(0);
const db1 = Array(HIDDEN).fill(0);
const dW2 = Array(HIDDEN).fill(0);
let db2 = 0;
for (const i of idx) {
const { pre, hidden, out } = forward(X[i], weights, useBN, bnStats);
const err = out - Y[i];
totalLoss += err * err;
// Backprop
const dOut = 2 * err;
db2 += dOut;
hidden.forEach((h, j) => { dW2[j] += dOut * h; });
const dHidden = weights.W2.map((w, j) => dOut * w * reluGrad(pre[j]));
dHidden.forEach((d, j) => {
dW1[j] += d * X[i];
db1[j] += d;
});
}
// Gradient descent update
weights.W1.forEach((_, j) => {
weights.W1[j] -= lr * dW1[j] / BATCH;
weights.b1[j] -= lr * db1[j] / BATCH;
weights.W2[j] -= lr * dW2[j] / BATCH;
});
weights.b2[0] -= lr * db2 / BATCH;
return totalLoss / BATCH;
}
// โโ Drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const canvas = document.getElementById('lossChart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 20, right: 20, bottom: 40, left: 50 };
function drawAxes(maxLoss) {
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
// Axes
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top);
ctx.lineTo(PAD.left, H - PAD.bottom);
ctx.lineTo(W - PAD.right, H - PAD.bottom);
ctx.stroke();
// Y ticks
ctx.fillStyle = '#555';
ctx.font = '11px system-ui';
ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const val = (maxLoss * i / 4).toFixed(2);
const y = H - PAD.bottom - (H - PAD.top - PAD.bottom) * i / 4;
ctx.fillText(val, PAD.left - 5, y + 4);
ctx.strokeStyle = '#eee'; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(W - PAD.right, y); ctx.stroke();
}
// X label
ctx.fillStyle = '#555';
ctx.textAlign = 'center';
ctx.fillText('Training step', W / 2, H - 5);
// Y label
ctx.save();
ctx.translate(13, H / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Loss (MSE)', 0, 0);
ctx.restore();
}
function drawCurve(losses, color, maxLoss) {
if (losses.length < 2) return;
const xScale = (W - PAD.left - PAD.right) / (STEPS - 1);
const yScale = (H - PAD.top - PAD.bottom) / maxLoss;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
losses.forEach((l, i) => {
const x = PAD.left + i * xScale;
const y = H - PAD.bottom - Math.min(l, maxLoss) * yScale;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
}
// โโ Main logic โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let bnLosses = [], noBnLosses = [];
let animId = null;
const btn = document.getElementById('trainBtn');
const rst = document.getElementById('resetBtn');
const sum = document.getElementById('summary');
let dataset, wBN, wNO, bnStats;
function init() {
dataset = makeDataset(200);
wBN = initWeights();
wNO = { ...wBN,
W1: [...wBN.W1], b1: [...wBN.b1],
W2: [...wBN.W2], b2: [...wBN.b2]
};
bnStats = { mu: 0, sig: 1 };
bnLosses = [];
noBnLosses = [];
sum.textContent = '';
drawAxes(1);
btn.disabled = false;
}
function runTraining() {
btn.disabled = true;
rst.disabled = true;
let step = 0;
function tick() {
for (let k = 0; k < 4 && step < STEPS; k++, step++) {
bnLosses.push(trainStep(wBN, dataset.X, dataset.Y, true, bnStats, LR_BN));
noBnLosses.push(trainStep(wNO, dataset.X, dataset.Y, false, bnStats, LR_NO));
}
const maxL = Math.max(...bnLosses, ...noBnLosses, 0.01);
drawAxes(maxL);
drawCurve(bnLosses, '#2563eb', maxL);
drawCurve(noBnLosses, '#dc2626', maxL);
if (step < STEPS) {
animId = requestAnimationFrame(tick);
} else {
rst.disabled = false;
const finalBN = bnLosses[bnLosses.length - 1].toFixed(4);
const finalNO = noBnLosses[noBnLosses.length - 1].toFixed(4);
sum.textContent =
fmt('Final loss โ With BatchNorm: %finalBN% | Without: %finalNO%', { finalBN: finalBN, finalNO: finalNO });
// Log to console for verification
console.log('BatchNorm final loss:', finalBN, '| No-BN final loss:', finalNO);
}
}
animId = requestAnimationFrame(tick);
}
btn.addEventListener('click', () => { if (!btn.disabled) runTraining(); });
rst.addEventListener('click', () => {
if (animId) { cancelAnimationFrame(animId); animId = null; }
init();
});
init();
<div class="controls">
<button id="trainBtn" type="button">Train both</button>
<button id="resetBtn" type="button" class="ghost">Reset & randomize</button>
</div>
<div class="chart-area">
<canvas id="lossChart" width="560" height="240"></canvas>
</div>
<div class="legend">
<span class="dot blue"></span><span>With BatchNorm</span>
<span class="dot red"></span><span>Without BatchNorm</span>
</div>
<div id="summary" class="summary"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; gap: .5rem; margin-bottom: .6rem; flex-wrap: wrap; }
button {
font: 600 14px system-ui, sans-serif;
padding: .45rem .9rem;
border: 1px solid #1d3557;
background: #1d3557;
color: #fff;
border-radius: 8px;
cursor: pointer;
}
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
.chart-area { width: 100%; overflow-x: auto; }
canvas { display: block; max-width: 100%; }
.legend { display: flex; align-items: center; gap: .4rem .9rem; flex-wrap: wrap; font-size: .85rem; margin-top: .4rem; }
.dot { width: 14px; height: 14px; border-radius: 50%; display: inline-block; }
.dot.blue { background: #2563eb; }
.dot.red { background: #dc2626; }
.summary { font-size: .88rem; margin-top: .55rem; min-height: 1.3em; color: #1d3557; font-weight: 600; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulates training a small 2-layer network (with vs without BatchNorm)
// on synthetic regression data: y = sin(x) + noise.
// We track the Mean Squared Error loss at each step.
// BatchNorm is approximated by normalizing the hidden activations over
// the mini-batch at forward-pass time (ฮณ=1, ฮฒ=0 fixed for simplicity).
const STEPS = 200;
const BATCH = 16;
const LR_BN = 0.12; // higher LR is safe with BN
const LR_NO = 0.015; // must be lower without BN to avoid divergence
const HIDDEN = 12;
const EPS = 1e-8;
// โโ Utilities โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function randn() {
// Box-Muller transform
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function relu(x) { return Math.max(0, x); }
function reluGrad(x) { return x > 0 ? 1 : 0; }
// โโ Dataset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function makeDataset(n) {
const X = [], Y = [];
for (let i = 0; i < n; i++) {
const x = (Math.random() * 4 - 2); // [-2, 2]
X.push(x);
Y.push(Math.sin(x) + randn() * 0.15);
}
return { X, Y };
}
// โโ Network weights โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function initWeights() {
// Layer 1: 1 -> HIDDEN
const W1 = Array.from({ length: HIDDEN }, () => randn() * 0.5);
const b1 = Array(HIDDEN).fill(0);
// Layer 2: HIDDEN -> 1
const W2 = Array.from({ length: HIDDEN }, () => randn() * 0.1);
const b2 = [0];
return { W1, b1, W2, b2 };
}
// โโ Forward pass โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function forward(x, weights, useBN, bnStats) {
// Hidden layer pre-activations
const pre = weights.W1.map((w, j) => w * x + weights.b1[j]);
let hidden;
if (useBN) {
// Normalize over the single example using running stats
const mu = bnStats.mu;
const sig = bnStats.sig;
hidden = pre.map(p => relu((p - mu) / Math.sqrt(sig * sig + EPS)));
} else {
hidden = pre.map(relu);
}
// Output layer
const out = hidden.reduce((s, h, j) => s + weights.W2[j] * h, weights.b2[0]);
return { pre, hidden, out };
}
// โโ Mini-batch SGD step โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function trainStep(weights, X, Y, useBN, bnStats, lr) {
// Sample random mini-batch
const idx = Array.from({ length: BATCH }, () => Math.floor(Math.random() * X.length));
// Compute batch BN stats from pre-activations if needed
if (useBN) {
const allPre = idx.map(i => weights.W1.map((w, j) => w * X[i] + weights.b1[j]));
const newMu = Array(HIDDEN).fill(0);
const newVar = Array(HIDDEN).fill(0);
for (const pre of allPre) pre.forEach((p, j) => { newMu[j] += p / BATCH; });
for (const pre of allPre) pre.forEach((p, j) => { newVar[j] += (p - newMu[j]) ** 2 / BATCH; });
// Update running stats (exponential moving average)
bnStats.mu = 0.9 * bnStats.mu + 0.1 * (newMu.reduce((a, b) => a + b) / HIDDEN);
bnStats.sig = 0.9 * bnStats.sig + 0.1 * Math.sqrt(newVar.reduce((a, b) => a + b) / HIDDEN + EPS);
}
let totalLoss = 0;
const dW1 = Array(HIDDEN).fill(0);
const db1 = Array(HIDDEN).fill(0);
const dW2 = Array(HIDDEN).fill(0);
let db2 = 0;
for (const i of idx) {
const { pre, hidden, out } = forward(X[i], weights, useBN, bnStats);
const err = out - Y[i];
totalLoss += err * err;
// Backprop
const dOut = 2 * err;
db2 += dOut;
hidden.forEach((h, j) => { dW2[j] += dOut * h; });
const dHidden = weights.W2.map((w, j) => dOut * w * reluGrad(pre[j]));
dHidden.forEach((d, j) => {
dW1[j] += d * X[i];
db1[j] += d;
});
}
// Gradient descent update
weights.W1.forEach((_, j) => {
weights.W1[j] -= lr * dW1[j] / BATCH;
weights.b1[j] -= lr * db1[j] / BATCH;
weights.W2[j] -= lr * dW2[j] / BATCH;
});
weights.b2[0] -= lr * db2 / BATCH;
return totalLoss / BATCH;
}
// โโ Drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const canvas = document.getElementById('lossChart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 20, right: 20, bottom: 40, left: 50 };
function drawAxes(maxLoss) {
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
// Axes
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top);
ctx.lineTo(PAD.left, H - PAD.bottom);
ctx.lineTo(W - PAD.right, H - PAD.bottom);
ctx.stroke();
// Y ticks
ctx.fillStyle = '#555';
ctx.font = '11px system-ui';
ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const val = (maxLoss * i / 4).toFixed(2);
const y = H - PAD.bottom - (H - PAD.top - PAD.bottom) * i / 4;
ctx.fillText(val, PAD.left - 5, y + 4);
ctx.strokeStyle = '#eee'; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(W - PAD.right, y); ctx.stroke();
}
// X label
ctx.fillStyle = '#555';
ctx.textAlign = 'center';
ctx.fillText('Training step', W / 2, H - 5);
// Y label
ctx.save();
ctx.translate(13, H / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Loss (MSE)', 0, 0);
ctx.restore();
}
function drawCurve(losses, color, maxLoss) {
if (losses.length < 2) return;
const xScale = (W - PAD.left - PAD.right) / (STEPS - 1);
const yScale = (H - PAD.top - PAD.bottom) / maxLoss;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
losses.forEach((l, i) => {
const x = PAD.left + i * xScale;
const y = H - PAD.bottom - Math.min(l, maxLoss) * yScale;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
}
// โโ Main logic โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let bnLosses = [], noBnLosses = [];
let animId = null;
const btn = document.getElementById('trainBtn');
const rst = document.getElementById('resetBtn');
const sum = document.getElementById('summary');
let dataset, wBN, wNO, bnStats;
function init() {
dataset = makeDataset(200);
wBN = initWeights();
wNO = { ...wBN,
W1: [...wBN.W1], b1: [...wBN.b1],
W2: [...wBN.W2], b2: [...wBN.b2]
};
bnStats = { mu: 0, sig: 1 };
bnLosses = [];
noBnLosses = [];
sum.textContent = '';
drawAxes(1);
btn.disabled = false;
}
function runTraining() {
btn.disabled = true;
rst.disabled = true;
let step = 0;
function tick() {
for (let k = 0; k < 4 && step < STEPS; k++, step++) {
bnLosses.push(trainStep(wBN, dataset.X, dataset.Y, true, bnStats, LR_BN));
noBnLosses.push(trainStep(wNO, dataset.X, dataset.Y, false, bnStats, LR_NO));
}
const maxL = Math.max(...bnLosses, ...noBnLosses, 0.01);
drawAxes(maxL);
drawCurve(bnLosses, '#2563eb', maxL);
drawCurve(noBnLosses, '#dc2626', maxL);
if (step < STEPS) {
animId = requestAnimationFrame(tick);
} else {
rst.disabled = false;
const finalBN = bnLosses[bnLosses.length - 1].toFixed(4);
const finalNO = noBnLosses[noBnLosses.length - 1].toFixed(4);
sum.textContent =
fmt('Final loss โ With BatchNorm: %finalBN% | Without: %finalNO%', { finalBN: finalBN, finalNO: finalNO });
// Log to console for verification
console.log('BatchNorm final loss:', finalBN, '| No-BN final loss:', finalNO);
}
}
animId = requestAnimationFrame(tick);
}
btn.addEventListener('click', () => { if (!btn.disabled) runTraining(); });
rst.addEventListener('click', () => {
if (animId) { cancelAnimationFrame(animId); animId = null; }
init();
});
init();
Comments
Loading comments...