7
Hopfield Memory Recall
click to corrupt · drag Y for # stored patterns
idle
541 lines · vanilla
view source
// Hopfield network as associative memory on a 12x12 binary grid (N = 144 neurons,
// bipolar states s_i in {-1,+1}). Patterns are stored via the Hebbian rule
// W_ij = (1/N) * sum_p xi_i^p xi_j^p for i != j, W_ii = 0.
// Dynamics are asynchronous Glauber updates: pick a random neuron and set
// s_i <- sign(sum_j W_ij s_j).
// The Lyapunov energy E = -1/2 sum_ij W_ij s_i s_j is non-increasing.
// Capacity ~ 0.14 N: above that, the network falls into spurious mixed states
// instead of stored patterns. The user controls the number of stored patterns
// via mouseY to demonstrate this.
//
// Sandbox contract: vanilla worker + OffscreenCanvas, no DOM.
const N_SIDE = 12;
const N = N_SIDE * N_SIDE; // 144
const MAX_PATTERNS = 10;
const MIN_PATTERNS = 3;
const UPDATES_PER_FRAME = 24; // async Glauber steps per draw frame
const CONVERGE_STEPS = 60; // no flips for this many updates -> converged
const HOLD_FRAMES = 90; // pause on the recalled pattern
const ENERGY_HIST = 360; // energy graph ring buffer
const CORRUPT_RADIUS = 1.5; // brush radius in grid cells
// Palette — neuro-deep-purple background, warm accents.
const BG = '#0e0a18';
const PANEL = '#171127';
const PANEL_EDGE = 'rgba(255,255,255,0.05)';
const GRID_LINE = 'rgba(255,255,255,0.06)';
const ON_COLOR = '#f4c97a'; // warm gold for s=+1
const OFF_COLOR = '#221830'; // muted near-bg for s=-1
const HOVER_TINT = 'rgba(244,201,122,0.18)';
const TEXT = '#e9e4f5';
const MUTED = 'rgba(233,228,245,0.55)';
const ACCENT = '#c084fc'; // purple-400
const ACCENT_DIM = 'rgba(192,132,252,0.55)';
const ENERGY_LINE = '#22d3ee'; // cyan-400
const ENERGY_FILL = 'rgba(34,211,238,0.15)';
const FLASH = 'rgba(244,201,122,0.35)';
// --- runtime state ---
let W = 0, H = 0;
let gridBox = { x: 0, y: 0, w: 0, h: 0, cell: 0 };
let energyBox = { x: 0, y: 0, w: 0, h: 0 };
let hudBox = { x: 0, y: 0, w: 0, h: 0 };
let s; // Int8Array length N, bipolar +/-1
let weights; // Float32Array N*N
let patterns = []; // Array<Int8Array>, each length N
let nStored = 4; // current number of stored patterns
let lastNStored = -1; // detect changes to rebuild
let energyHist; // Float32Array ring buffer of energies
let energyIdx = 0;
let energyCount = 0;
let stableCount = 0; // # consecutive no-flip Glauber attempts
let holdLeft = 0; // post-convergence pause frames
let recalledIdx = -1; // index of pattern that current state matches (or -1)
let lastClickFrame = -1000;
let mouseDownPrev = false;
let frameCount = 0;
let rng; // Mulberry32 PRNG for repeatable patterns
// ---------- PRNG (so canned patterns are stable across reloads) ----------
function mulberry32(seed) {
let t = seed >>> 0;
return function () {
t = (t + 0x6D2B79F5) >>> 0;
let x = t;
x = Math.imul(x ^ (x >>> 15), x | 1);
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
// ---------- canned glyph patterns (rendered on a 12x12 bipolar grid) ----------
//
// Each glyph is an Array<string> of length 12, '#' = +1, '.' = -1.
// We define a small library; if more are requested we synthesise pseudo-random
// orthogonal-ish noise patterns to fill up to MAX_PATTERNS.
const GLYPHS = [
// 0: heart
[
'............',
'...##....##.',
'..####..####',
'.###########',
'.###########',
'.###########',
'..#########.',
'...#######..',
'....#####...',
'.....###....',
'......#.....',
'............',
],
// 1: star
[
'............',
'......#.....',
'......#.....',
'.....###....',
'############',
'.##########.',
'..########..',
'..########..',
'.###.##.###.',
'.##......##.',
'.#........#.',
'............',
],
// 2: greek omega
[
'............',
'....####....',
'..########..',
'.###....###.',
'.##......##.',
'##........##',
'##........##',
'##........##',
'.##......##.',
'.#.#....#.#.',
'X##.X..X.##X', // legs — placeholder; sanitised below
'............',
],
// 3: smiley face
[
'............',
'...######...',
'..########..',
'.##.####.##.',
'.##.####.##.',
'.##########.',
'.##########.',
'.##.####.##.',
'..##....##..',
'..########..',
'...######...',
'............',
],
// 4: arrow up
[
'......#.....',
'.....###....',
'....#####...',
'...#######..',
'..#########.',
'.###########',
'.....###....',
'.....###....',
'.....###....',
'.....###....',
'.....###....',
'.....###....',
],
// 5: checker / cross
[
'............',
'.##......##.',
'..##....##..',
'...##..##...',
'....####....',
'.....##.....',
'.....##.....',
'....####....',
'...##..##...',
'..##....##..',
'.##......##.',
'............',
],
// 6: yin-yang-ish spiral seed
[
'............',
'...#####....',
'..##...##...',
'.##.....##..',
'.##..#..##..',
'.##..#..##..',
'..#..#..##..',
'..##.#..##..',
'...#######..',
'....######..',
'.....####...',
'............',
],
];
// Sanitise glyph 2 (used a placeholder); turn 'X' into '#'.
for (let i = 0; i < GLYPHS.length; i++) {
for (let r = 0; r < GLYPHS[i].length; r++) {
GLYPHS[i][r] = GLYPHS[i][r].replace(/X/g, '#');
}
}
function glyphToPattern(glyph) {
const p = new Int8Array(N);
for (let y = 0; y < N_SIDE; y++) {
const row = glyph[y] || '............';
for (let x = 0; x < N_SIDE; x++) {
p[y * N_SIDE + x] = row[x] === '#' ? 1 : -1;
}
}
return p;
}
function randomPattern(seedRng) {
// Pseudo-random bipolar pattern, biased slightly sparse so it looks visually
// distinct from glyphs. Each cell is +1 with prob 0.5.
const p = new Int8Array(N);
for (let i = 0; i < N; i++) p[i] = seedRng() < 0.5 ? -1 : 1;
return p;
}
// ---------- network construction ----------
function buildPatterns(k) {
// Always start with the canned glyphs in order, then pad with PRNG patterns.
const out = [];
const prng = mulberry32(0x1f3a5c11);
for (let i = 0; i < k; i++) {
if (i < GLYPHS.length) out.push(glyphToPattern(GLYPHS[i]));
else out.push(randomPattern(prng));
}
return out;
}
function buildWeights(pats) {
const w = new Float32Array(N * N);
for (let p = 0; p < pats.length; p++) {
const xp = pats[p];
for (let i = 0; i < N; i++) {
const xi = xp[i];
if (xi === 0) continue;
for (let j = i + 1; j < N; j++) {
const v = xi * xp[j];
w[i * N + j] += v;
w[j * N + i] += v;
}
}
}
// Diagonal stays zero by construction. Apply Hebbian normalisation.
const inv = 1 / N;
for (let k = 0; k < w.length; k++) w[k] *= inv;
return w;
}
function rebuildNetwork() {
patterns = buildPatterns(nStored);
weights = buildWeights(patterns);
lastNStored = nStored;
}
// ---------- dynamics ----------
function localField(i) {
let h = 0;
const row = i * N;
for (let j = 0; j < N; j++) h += weights[row + j] * s[j];
return h;
}
function totalEnergy() {
// E = -1/2 sum_ij W_ij s_i s_j. Use upper triangle, then *2 cancels the 1/2.
let e = 0;
for (let i = 0; i < N; i++) {
const si = s[i];
const row = i * N;
for (let j = i + 1; j < N; j++) {
e -= weights[row + j] * si * s[j];
}
}
return e;
}
function glauberStep() {
// One async update on a random neuron. Returns true if it flipped.
const i = (Math.random() * N) | 0;
const h = localField(i);
const target = h >= 0 ? 1 : -1;
if (s[i] !== target) {
s[i] = target;
return true;
}
return false;
}
function classifyState() {
// Return index of stored pattern (or its negation) within Hamming
// similarity > 0.92, else -1. Negations of stored patterns are also
// attractors of the Hebbian network.
let bestIdx = -1, bestOverlap = 0.92;
for (let p = 0; p < patterns.length; p++) {
let sum = 0;
const xp = patterns[p];
for (let i = 0; i < N; i++) sum += xp[i] * s[i];
const m = Math.abs(sum) / N;
if (m > bestOverlap) {
bestOverlap = m;
bestIdx = p;
}
}
return bestIdx;
}
// ---------- corruption ----------
function corruptFromPattern(pIdx, flipFrac) {
const src = patterns[pIdx];
s = new Int8Array(N);
for (let i = 0; i < N; i++) {
s[i] = (Math.random() < flipFrac) ? -src[i] : src[i];
}
resetEnergyAfterCorrupt();
}
function corruptRandom() {
s = new Int8Array(N);
for (let i = 0; i < N; i++) s[i] = Math.random() < 0.5 ? -1 : 1;
resetEnergyAfterCorrupt();
}
function resetEnergyAfterCorrupt() {
stableCount = 0;
holdLeft = 0;
recalledIdx = -1;
energyIdx = 0;
energyCount = 0;
energyHist.fill(0);
pushEnergy(totalEnergy());
}
function pushEnergy(e) {
energyHist[energyIdx] = e;
energyIdx = (energyIdx + 1) % ENERGY_HIST;
if (energyCount < ENERGY_HIST) energyCount++;
}
// ---------- layout ----------
function layout() {
const pad = 12;
const hudH = 56;
hudBox = { x: pad, y: pad, w: W - pad * 2, h: hudH };
const energyH = Math.max(70, Math.min(120, ((H - hudH - pad * 4) * 0.22) | 0));
energyBox = {
x: pad,
y: H - pad - energyH,
w: W - pad * 2,
h: energyH,
};
const gridAreaY = hudBox.y + hudBox.h + pad;
const gridAreaH = energyBox.y - pad - gridAreaY;
const gridAreaW = W - pad * 2;
const side = Math.max(60, Math.min(gridAreaW, gridAreaH));
const cell = Math.floor(side / N_SIDE);
const realSide = cell * N_SIDE;
gridBox = {
x: pad + ((gridAreaW - realSide) / 2) | 0,
y: gridAreaY + ((gridAreaH - realSide) / 2) | 0,
w: realSide,
h: realSide,
cell,
};
}
// ---------- input ----------
function handleInput(input) {
// mouseY (top -> bottom) controls nStored: top of screen = MAX, bottom = MIN.
// The user can "crank Y" to overload the network.
const yFrac = clamp(input.mouseY / H, 0, 1);
const target = MIN_PATTERNS + Math.round((1 - yFrac) * (MAX_PATTERNS - MIN_PATTERNS));
if (target !== nStored) {
nStored = target;
rebuildNetwork();
// Re-seed with a fresh corruption so the user sees the new attractor landscape.
corruptFromPattern(0, 0.30);
}
// Click anywhere to corrupt-from-pattern-0 with stronger flips. Clicking
// inside the grid also paints flips directly on the current state at
// that cell — this is the "draw your own corruption" UX.
const down = input.mouseDown;
if (down) {
const inGrid = pointInGrid(input.mouseX, input.mouseY);
if (inGrid) {
paintFlipsAt(input.mouseX, input.mouseY);
// Painting interrupts convergence hold.
stableCount = 0;
holdLeft = 0;
recalledIdx = -1;
} else if (!mouseDownPrev) {
// Outside the grid: full corruption from a random stored pattern.
const which = (Math.random() * Math.min(patterns.length, 3)) | 0;
corruptFromPattern(which, 0.35);
lastClickFrame = frameCount;
}
}
mouseDownPrev = down;
}
function pointInGrid(px, py) {
return px >= gridBox.x && px < gridBox.x + gridBox.w
&& py >= gridBox.y && py < gridBox.y + gridBox.h;
}
function paintFlipsAt(px, py) {
const cell = gridBox.cell;
const cx = (px - gridBox.x) / cell;
const cy = (py - gridBox.y) / cell;
const r = CORRUPT_RADIUS;
const x0 = Math.max(0, Math.floor(cx - r));
const x1 = Math.min(N_SIDE - 1, Math.ceil(cx + r));
const y0 = Math.max(0, Math.floor(cy - r));
const y1 = Math.min(N_SIDE - 1, Math.ceil(cy + r));
for (let yy = y0; yy <= y1; yy++) {
for (let xx = x0; xx <= x1; xx++) {
const dx = (xx + 0.5) - cx;
const dy = (yy + 0.5) - cy;
if (dx * dx + dy * dy <= r * r) s[yy * N_SIDE + xx] = -s[yy * N_SIDE + xx];
}
}
}
function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
// ---------- drawing ----------
function roundRect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function drawPanel(ctx, x, y, w, h) {
ctx.fillStyle = PANEL;
roundRect(ctx, x, y, w, h, 8);
ctx.fill();
ctx.strokeStyle = PANEL_EDGE;
ctx.lineWidth = 1;
ctx.stroke();
}
function drawHUD(ctx) {
const b = hudBox;
drawPanel(ctx, b.x, b.y, b.w, b.h);
// Title row
ctx.fillStyle = TEXT;
ctx.font = '13px ui-sans-serif, system-ui, sans-serif';
ctx.textBaseline = 'top';
ctx.fillText('Hopfield network · 12×12 = 144 neurons', b.x + 12, b.y + 8);
// Stored count meter
const meterX = b.x + 12;
const meterY = b.y + 30;
const meterW = Math.min(220, b.w - 24);
const meterH = 10;
ctx.fillStyle = 'rgba(255,255,255,0.06)';
roundRect(ctx, meterX, meterY, meterW, meterH, 5);
ctx.fill();
// Capacity threshold: P_max ~ 0.138 N ≈ 20, but visually the spurious
// states kick in well before that on a 144-cell grid with structured
// (non-orthogonal) glyphs. Show a danger zone past ~5.
const dangerFrac = Math.min(1, Math.max(0, (nStored - 5) / (MAX_PATTERNS - 5 + 1e-6)));
if (dangerFrac > 0) {
ctx.fillStyle = 'rgba(244,114,182,0.45)';
roundRect(ctx, meterX, meterY, meterW * dangerFrac, meterH, 5);
ctx.fill();
}
ctx.fillStyle = ACCENT;
const filled = (nStored - MIN_PATTERNS) / (MAX_PATTERNS - MIN_PATTERNS);
roundRect(ctx, meterX, meterY, Math.max(8, meterW * filled), meterH, 5);
ctx.fill();
ctx.fillStyle = MUTED;
ctx.font = '11px ui-sans-serif, system-ui, sans-serif';
ctx.fillText(`stored patterns: ${nStored} (drag Y to change)`, meterX, meterY + meterH + 4);
// Right-side status
const rightX = b.x + b.w - 12;
ctx.textAlign = 'right';
ctx.fillStyle = TEXT;
ctx.font = '12px ui-sans-serif, system-ui, sans-serif';
let status;
if (holdLeft > 0) {
status = (recalledIdx >= 0 && recalledIdx < GLYPHS.length)
? `recalled stored pattern #${recalledIdx + 1}`
: 'converged to spurious attractor';
} else {
status = `relaxing… (${stableCount}/${CONVERGE_STEPS} stable)`;
}
ctx.fillText(status, rightX, b.y + 8);
ctx.fillStyle = MUTED;
ctx.font = '11px ui-sans-serif, system-ui, sans-serif';
const E = energyCount > 0 ? energyHist[(energyIdx - 1 + ENERGY_HIST) % ENERGY_HIST] : 0;
ctx.fillText(`E = ${E.toFixed(3)}`, rightX, b.y + 26);
ctx.fillText(`P/N ≈ ${(nStored / N).toFixed(3)} (critical ≈ 0.138)`, rightX, b.y + 42);
ctx.textAlign = 'left';
}
function drawGrid(ctx) {
const b = gridBox;
// Panel background
drawPanel(ctx, b.x - 8, b.y - 8, b.w + 16, b.h + 16);
const cell = b.cell;
for (let i = 0; i < N; i++) {
const x = i % N_SIDE;
const y = (i / N_SIDE) | 0;
const px = b.x + x * cell;
const py = b.y + y * cell;
if (s[i] > 0) {
ctx.fillStyle = ON_COLOR;
ctx.fillRect(px + 1, py + 1, cell - 2, cell - 2);
} else {
ctx.fillStyle = OFF_COLOR;
ctx.fillRect(px + 1, py + 1, cell - 2, cell - 2);
}
}
// Convergence flash overlay
if (holdLeft > 0) {
const t = holdLeft / HOLD_FRAMES;
ctx.fillStyle = `rgba(244,201,122,${0.06 + 0.05 * t})`;
roundRect(ctx, b.x - 2, b.y - 2, b.w + 4, b.h + 4, 4);
ctx.fill();
}
// Lattice lines (subtle)
ctx.strokeStyle = GRID_LINE;
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i <= N_SIDE; i++) {
ctx.moveTo(b.x + i * cell + 0.5, b.y);
ctx.lineTo(b.x + i * cell + 0.5, b.y + b.h);
ctx.moveTo(b.x, b.y + i * cell + 0.5);
ctx.lineTo(b.x + b.w, b.y + i * cell + 0.5);
}
ctx.stroke();
}
function drawEnergy(ctx) {
const b = energyBox;
drawPanel(ctx, b.x, b.y, b.w, b.h);
// Label
ctx.fillStyle = MUTED;
ctx.font = '11px ui-sans-serif, system-ui, sans-serif';
ctx.textBaseline = 'top';
ctx.fillText('energy E(t) = -½ Σ Wᵢⱼ sᵢsⱼ', b.x + 10, b.y + 6);
if (energyCount < 2) return;
// Compute current y range from the buffer.
let lo = Infinity, hi = -Infinity;
for (let i = 0; i < energyCount; i++) {
const v = energyHist[i];
if (v < lo) lo = v;
if (v > hi) hi = v;
}
if (hi - lo < 1e-3) { hi += 1e-2; lo -= 1e-2; }
const padTop = 22;
const padBot = 10;
const innerY = b.y + padTop;
const innerH = b.h - padTop - padBot;
const xFor = (i) => b.x + 10 + (i / Math.max(1, energyCount - 1)) * (b.w - 20);
const yFor = (v) => innerY + (1 - (v - lo) / (hi - lo)) * innerH;
// Filled area
ctx.beginPath();
// Iterate in chronological order through the ring buffer.
const startIdx = energyCount < ENERGY_HIST ? 0 : energyIdx;
for (let k = 0; k < energyCount; k++) {
const idx = (startIdx + k) % ENERGY_HIST;
const x = xFor(k);
const y = yFor(energyHist[idx]);
if (k === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
// Close to baseline
const lastX = xFor(energyCount - 1);
ctx.lineTo(lastX, innerY + innerH);
ctx.lineTo(b.x + 10, innerY + innerH);
ctx.closePath();
ctx.fillStyle = ENERGY_FILL;
ctx.fill();
// Line
ctx.beginPath();
for (let k = 0; k < energyCount; k++) {
const idx = (startIdx + k) % ENERGY_HIST;
const x = xFor(k);
const y = yFor(energyHist[idx]);
if (k === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = ENERGY_LINE;
ctx.lineWidth = 1.6;
ctx.stroke();
// Current value marker
const lastY = yFor(energyHist[(energyIdx - 1 + ENERGY_HIST) % ENERGY_HIST]);
ctx.fillStyle = ENERGY_LINE;
ctx.beginPath();
ctx.arc(lastX, lastY, 2.5, 0, Math.PI * 2);
ctx.fill();
}
// ---------- lifecycle ----------
function init({ width, height }) {
W = width; H = height;
energyHist = new Float32Array(ENERGY_HIST);
rng = mulberry32(0xdeadbeef);
rebuildNetwork();
// Seed with a corruption of the first stored pattern.
corruptFromPattern(0, 0.30);
layout();
}
function tick({ ctx, width, height, input }) {
if (width !== W || height !== H) {
W = width; H = height;
layout();
} else if (gridBox.w === 0) {
layout();
}
frameCount++;
handleInput(input);
// Run async Glauber updates unless we're holding on a converged state.
if (holdLeft > 0) {
holdLeft--;
if (holdLeft === 0) {
// Re-seed with a corruption of a (randomly chosen) stored pattern,
// weighted toward the first 3 canned glyphs for recognisability.
const which = (Math.random() * Math.min(patterns.length, 3)) | 0;
corruptFromPattern(which, 0.32);
}
} else {
for (let k = 0; k < UPDATES_PER_FRAME; k++) {
const flipped = glauberStep();
if (flipped) stableCount = 0;
else stableCount++;
if (stableCount >= CONVERGE_STEPS) {
recalledIdx = classifyState();
holdLeft = HOLD_FRAMES;
break;
}
}
// Push one energy sample per frame for a smooth curve.
pushEnergy(totalEnergy());
}
// --- render ---
ctx.fillStyle = BG;
ctx.fillRect(0, 0, W, H);
drawGrid(ctx);
drawHUD(ctx);
drawEnergy(ctx);
}
Comments (0)
Log in to comment.