This commit is contained in:
2026-06-12 17:19:26 +08:00
parent a3ebb28ce0
commit 8d2e18c5a4
20 changed files with 3011 additions and 411 deletions
+403
View File
@@ -0,0 +1,403 @@
<!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>