404 lines
16 KiB
HTML
404 lines
16 KiB
HTML
<!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>
|