Files
gpt_from_scratch/mlp_playground.html
2026-06-12 17:19:26 +08:00

404 lines
16 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MLP Playground — Neuron → Layer → MLP (section 5.1)</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<style>
:root {
--bg: #0f1420;
--panel: #161d2e;
--panel-2: #1d2940;
--ink: #e8edf6;
--muted: #94a3b8;
--line: #2a3550;
--pos: #4ade80; /* positive weight / activation */
--neg: #f87171; /* negative weight / activation */
--accent: #60a5fa;
--accent-2: #fbbf24;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: radial-gradient(1200px 600px at 70% -10%, #1b2740 0%, var(--bg) 55%);
color: var(--ink);
font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
line-height: 1.5;
padding: 22px clamp(14px, 3vw, 40px) 60px;
}
h1 { font-size: clamp(20px, 2.6vw, 28px); margin: 0 0 4px; }
.sub { color: var(--muted); margin: 0 0 18px; max-width: 70ch; }
.sub b { color: var(--ink); }
.layout { display: grid; grid-template-columns: 300px 1fr; gap: 18px; align-items: start; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
.panel {
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px 16px 18px;
box-shadow: 0 10px 30px rgba(0,0,0,.35);
}
.panel h2 { font-size: 14px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 0 0 12px; }
.field { margin-bottom: 16px; }
.field label { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
.field label .val { font-variant-numeric: tabular-nums; color: var(--accent); font-weight: 600; }
input[type=range] { width: 100%; accent-color: var(--accent); }
.btn {
appearance: none; border: 1px solid var(--line); cursor: pointer;
background: var(--panel-2); color: var(--ink);
padding: 9px 12px; border-radius: 9px; font-size: 13px; font-weight: 600;
transition: .15s; width: 100%; margin-bottom: 9px;
}
.btn:hover { border-color: var(--accent); color: #fff; }
.btn.primary { background: linear-gradient(180deg, #3b82f6, #2563eb); border-color: #2563eb; }
.btn.primary:hover { background: linear-gradient(180deg, #4f93ff, #2f6fe0); }
.legend { display: flex; flex-direction: column; gap: 7px; font-size: 12.5px; color: var(--muted); }
.legend .row { display: flex; align-items: center; gap: 8px; }
.swatch { width: 22px; height: 4px; border-radius: 3px; }
.stage { background: rgba(0,0,0,.18); border-radius: 14px; }
svg { display: block; width: 100%; height: auto; }
.out-card {
margin-top: 14px; padding: 14px 16px; border-radius: 12px;
border: 1px solid var(--line); background: rgba(0,0,0,.2);
display: flex; align-items: center; justify-content: space-between; gap: 14px;
}
.out-card .label { color: var(--muted); font-size: 13px; }
.out-card .verdict { font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums; }
/* tooltip */
.tip {
position: fixed; pointer-events: none; z-index: 50; opacity: 0;
background: #0b1322; border: 1px solid var(--accent); color: var(--ink);
padding: 8px 10px; border-radius: 9px; font-size: 12.5px; max-width: 240px;
box-shadow: 0 8px 24px rgba(0,0,0,.5); transition: opacity .12s;
}
.tip b { color: var(--accent); }
.tip .mono { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; }
/* node + edge base styles */
.edge { transition: stroke-opacity .2s; }
.node circle { stroke: #0b1322; stroke-width: 1.5px; cursor: pointer; }
.node-label { font-size: 11px; fill: var(--muted); }
.layer-title { font-size: 12px; fill: var(--ink); font-weight: 700; text-anchor: middle; }
.layer-cap { font-size: 11px; fill: var(--muted); text-anchor: middle; }
.hint { color: var(--muted); font-size: 12px; margin-top: 10px; }
.pill { display:inline-block; padding:2px 7px; border:1px solid var(--line); border-radius:999px; font-size:11px; color:var(--muted); }
</style>
</head>
<body>
<h1>MLP Playground — <span style="color:var(--accent)">Neuron → Layer → MLP</span></h1>
<p class="sub">
The reviewer-panel story from <b>section 5.1</b>, made live. Two inputs
(<b>income</b>, <b>credit</b>) flow through two panels of 8 reviewers into one final judge:
the <span class="pill">2 → 8 → 8 → 1</span> network. Every line is a weight, every dot is a
<span class="mono">tanh(w·x + b)</span> opinion. Drag the inputs, hover anything, or press play.
</p>
<div class="layout">
<div class="panel">
<h2>Inputs (the application)</h2>
<div class="field">
<label>Income (x₁) <span class="val" id="x1v">0.50</span></label>
<input type="range" id="x1" min="-1" max="1" step="0.01" value="0.5" />
</div>
<div class="field">
<label>Credit (x₂) <span class="val" id="x2v">-0.30</span></label>
<input type="range" id="x2" min="-1" max="1" step="0.01" value="-0.3" />
</div>
<h2 style="margin-top:6px">Controls</h2>
<button class="btn primary" id="play">▶ Animate forward pass</button>
<button class="btn" id="reroll">🎲 New random weights</button>
<button class="btn" id="reset">↺ Reset inputs</button>
<h2 style="margin-top:18px">Read the colors</h2>
<div class="legend">
<div class="row"><span class="swatch" style="background:var(--pos)"></span> positive weight / activation</div>
<div class="row"><span class="swatch" style="background:var(--neg)"></span> negative weight / activation</div>
<div class="row"><span class="swatch" style="background:var(--muted);height:2px"></span> thin line = weak, thick = strong</div>
</div>
<div class="out-card">
<span class="label">Final judge<br>score (tanh)</span>
<span class="verdict" id="verdict"></span>
</div>
<p class="hint" id="verdict-text">Closer to <b style="color:var(--pos)">+1</b> = likely good tenant, closer to <b style="color:var(--neg)">1</b> = likely risky.</p>
</div>
<div class="panel stage">
<svg id="net" viewBox="0 0 980 560" preserveAspectRatio="xMidYMid meet" aria-label="MLP diagram"></svg>
<p class="hint" style="padding:0 6px 4px">
Hover a <b>line</b> to see its weight, or a <b>dot</b> to see that reviewer's
<span class="mono">w·x + b</span> and squashed opinion. Layers left→right: form → junior panel → senior panel → final judge.
</p>
</div>
</div>
<div class="tip" id="tip"></div>
<script>
// ----------------------------------------------------------------------------
// Tiny MLP, faithful to section 5.1: Neuron = tanh(w·x + b), Layer = neurons in
// parallel, MLP = layers in sequence. Shape: 2 -> 8 -> 8 -> 1.
// ----------------------------------------------------------------------------
const SIZES = [2, 8, 8, 1];
const LAYER_TITLES = ["Inputs", "Hidden panel 1", "Hidden panel 2", "Final judge"];
const LAYER_CAPS = ["income, credit", "8 junior reviewers", "8 senior reviewers", "1 score"];
const rand = () => Math.random() * 2 - 1; // uniform(-1, 1), like micrograd
const tanh = (z) => Math.tanh(z);
// Build network: weights[l] is matrix [nout x nin], biases[l] is [nout].
let weights, biases;
function buildNetwork() {
weights = []; biases = [];
for (let l = 0; l < SIZES.length - 1; l++) {
const nin = SIZES[l], nout = SIZES[l + 1];
weights.push(Array.from({length: nout}, () => Array.from({length: nin}, rand)));
biases.push(Array.from({length: nout}, rand));
}
}
buildNetwork();
// Forward pass. Returns activations per layer (layer 0 = raw inputs) and the
// pre-activation (w·x+b) for each neuron so the tooltip can show both.
function forward(x) {
const acts = [x.slice()];
const preacts = [x.slice()]; // inputs have no pre-activation; mirror for indexing
let cur = x.slice();
for (let l = 0; l < weights.length; l++) {
const z = [], a = [];
for (let j = 0; j < weights[l].length; j++) {
let s = biases[l][j];
for (let i = 0; i < cur.length; i++) s += weights[l][j][i] * cur[i];
z.push(s); a.push(tanh(s));
}
preacts.push(z); acts.push(a); cur = a;
}
return { acts, preacts };
}
// ----------------------------------------------------------------------------
// Layout geometry
// ----------------------------------------------------------------------------
const W = 980, H = 560, PAD_X = 90, PAD_TOP = 70, PAD_BOT = 50;
const svg = d3.select("#net");
const tip = d3.select("#tip");
function layerX(l) { return PAD_X + (W - 2 * PAD_X) * (l / (SIZES.length - 1)); }
function nodeY(l, j) {
const n = SIZES[l];
const top = PAD_TOP, bot = H - PAD_BOT, span = bot - top;
if (n === 1) return (top + bot) / 2;
return top + span * (j / (n - 1));
}
// color scales: weights & activations mapped to red(-)/green(+)
function weightColor(w) { return w >= 0 ? "var(--pos)" : "var(--neg)"; }
function actColor(a) { return a >= 0 ? "var(--pos)" : "var(--neg)"; }
function weightWidth(w) { return 0.6 + Math.min(5.5, Math.abs(w) * 3.2); }
const gEdges = svg.append("g").attr("class", "edges");
const gNodes = svg.append("g").attr("class", "nodes");
const gTitles = svg.append("g").attr("class", "titles");
// layer titles + captions
function drawTitles() {
gTitles.selectAll("*").remove();
SIZES.forEach((n, l) => {
gTitles.append("text").attr("class", "layer-title")
.attr("x", layerX(l)).attr("y", 28).text(LAYER_TITLES[l]);
gTitles.append("text").attr("class", "layer-cap")
.attr("x", layerX(l)).attr("y", 46).text(LAYER_CAPS[l]);
});
}
// build edges + nodes once (geometry is static; only color/width/anim update)
let edgeSel, nodeSel;
function drawStructure() {
// edges
const edges = [];
for (let l = 0; l < weights.length; l++) {
for (let j = 0; j < SIZES[l + 1]; j++) {
for (let i = 0; i < SIZES[l]; i++) {
edges.push({ l, i, j,
x1: layerX(l), y1: nodeY(l, i),
x2: layerX(l + 1), y2: nodeY(l + 1, j) });
}
}
}
edgeSel = gEdges.selectAll("line").data(edges).join("line")
.attr("class", "edge")
.attr("x1", d => d.x1).attr("y1", d => d.y1)
.attr("x2", d => d.x2).attr("y2", d => d.y2)
.on("mousemove", (e, d) => {
const w = weights[d.l][d.j][d.i];
showTip(e, `<b>Weight</b> · panel line<br>
<span class="mono">layer ${d.l}${d.l + 1}</span><br>
from neuron ${d.i} → neuron ${d.j}<br>
<span class="mono">w = ${w.toFixed(3)}</span><br>
<span style="color:${w>=0?'var(--pos)':'var(--neg)'}">${w>=0?'boosts':'damps'} this signal</span>`);
})
.on("mouseleave", hideTip);
// nodes
const nodes = [];
SIZES.forEach((n, l) => {
for (let j = 0; j < n; j++) nodes.push({ l, j, x: layerX(l), y: nodeY(l, j) });
});
const g = gNodes.selectAll("g.node").data(nodes).join("g").attr("class", "node")
.attr("transform", d => `translate(${d.x},${d.y})`);
g.selectAll("circle").data(d => [d]).join("circle").attr("r", d => d.l === 0 || d.l === SIZES.length - 1 ? 13 : 11);
nodeSel = g;
// input row labels
gNodes.selectAll("text.in-lab").remove();
["x₁ income", "x₂ credit"].forEach((t, i) => {
gNodes.append("text").attr("class", "node-label in-lab")
.attr("x", layerX(0) - 22).attr("y", nodeY(0, i) + 4)
.attr("text-anchor", "end").text(t);
});
g.on("mousemove", (e, d) => {
if (d.l === 0) {
showTip(e, `<b>Input</b><br><span class="mono">x${d.j+1} = ${state.acts[0][d.j].toFixed(3)}</span><br>${d.j===0?'monthly income':'credit score'} (raw form field)`);
} else {
const z = state.preacts[d.l][d.j], a = state.acts[d.l][d.j];
const role = d.l === SIZES.length - 1 ? "final judge" : `reviewer ${d.j} in panel ${d.l}`;
showTip(e, `<b>Neuron</b> · ${role}<br>
<span class="mono">w·x + b = ${z.toFixed(3)}</span><br>
<span class="mono">tanh(...) = <b style="color:${a>=0?'var(--pos)':'var(--neg)'}">${a.toFixed(3)}</b></span><br>
${a>=0?'leaning yes':'leaning no'}`);
}
}).on("mouseleave", hideTip);
}
// ----------------------------------------------------------------------------
// Paint current state (colors + widths) onto edges and nodes
// ----------------------------------------------------------------------------
let state = { acts: [], preacts: [] };
function recompute() {
const x1 = +d3.select("#x1").property("value");
const x2 = +d3.select("#x2").property("value");
state = forward([x1, x2]);
paint();
updateVerdict();
}
function paint() {
edgeSel
.attr("stroke", d => weightColor(weights[d.l][d.j][d.i]))
.attr("stroke-width", d => weightWidth(weights[d.l][d.j][d.i]))
.attr("stroke-opacity", 0.45);
nodeSel.select("circle")
.attr("fill", d => {
const a = state.acts[d.l] ? state.acts[d.l][d.j] : 0;
const mag = Math.min(1, Math.abs(a));
const base = a >= 0 ? [74,222,128] : [248,113,113];
// blend toward dark panel for low magnitude
const mix = (c) => Math.round(40 + (c - 40) * (0.25 + 0.75 * mag));
return `rgb(${mix(base[0])},${mix(base[1])},${mix(base[2])})`;
});
}
function updateVerdict() {
const out = state.acts[state.acts.length - 1][0];
const el = d3.select("#verdict");
el.text((out >= 0 ? "+" : "") + out.toFixed(3))
.style("color", out >= 0 ? "var(--pos)" : "var(--neg)");
d3.select("#verdict-text").html(
out >= 0
? `Score <b style="color:var(--pos)">${out.toFixed(2)}</b> → leaning <b>good tenant</b>.`
: `Score <b style="color:var(--neg)">${out.toFixed(2)}</b> → leaning <b>risky</b>.`
);
}
// ----------------------------------------------------------------------------
// Animated forward pass: light up layer by layer, pulse edges between them
// ----------------------------------------------------------------------------
let playing = false;
function animate() {
if (playing) return;
playing = true;
// dim everything first
edgeSel.attr("stroke-opacity", 0.08);
nodeSel.select("circle").attr("opacity", 0.25);
d3.select("#play").attr("disabled", true).text("▶ playing…");
const STEP = 620;
// light input layer
lightLayer(0, 0);
for (let l = 0; l < weights.length; l++) {
pulseEdges(l, STEP * (l + 0.15));
lightLayer(l + 1, STEP * (l + 1));
}
setTimeout(() => {
edgeSel.transition().duration(300).attr("stroke-opacity", 0.45);
nodeSel.select("circle").transition().duration(300).attr("opacity", 1);
playing = false;
d3.select("#play").attr("disabled", null).text("▶ Animate forward pass");
}, STEP * (weights.length + 1) + 250);
}
function lightLayer(l, delay) {
nodeSel.filter(d => d.l === l).select("circle")
.transition().delay(delay).duration(280)
.attr("opacity", 1)
.attr("r", d => (d.l === 0 || d.l === SIZES.length - 1 ? 13 : 11) + 4)
.transition().duration(220)
.attr("r", d => d.l === 0 || d.l === SIZES.length - 1 ? 13 : 11);
}
function pulseEdges(l, delay) {
edgeSel.filter(d => d.l === l)
.transition().delay(delay).duration(300).attr("stroke-opacity", 0.9)
.transition().duration(320).attr("stroke-opacity", 0.18);
}
// ----------------------------------------------------------------------------
// Tooltip helpers
// ----------------------------------------------------------------------------
function showTip(e, html) {
tip.html(html).style("opacity", 1)
.style("left", (e.clientX + 14) + "px")
.style("top", (e.clientY + 14) + "px");
}
function hideTip() { tip.style("opacity", 0); }
// ----------------------------------------------------------------------------
// Wire up controls
// ----------------------------------------------------------------------------
function syncLabels() {
d3.select("#x1v").text((+d3.select("#x1").property("value")).toFixed(2));
d3.select("#x2v").text((+d3.select("#x2").property("value")).toFixed(2));
}
d3.select("#x1").on("input", () => { syncLabels(); recompute(); });
d3.select("#x2").on("input", () => { syncLabels(); recompute(); });
d3.select("#play").on("click", animate);
d3.select("#reroll").on("click", () => { buildNetwork(); drawStructure(); recompute(); });
d3.select("#reset").on("click", () => {
d3.select("#x1").property("value", 0.5);
d3.select("#x2").property("value", -0.3);
syncLabels(); recompute();
});
// ----------------------------------------------------------------------------
// Init
// ----------------------------------------------------------------------------
drawTitles();
drawStructure();
syncLabels();
recompute();
</script>
</body>
</html>