The demo below runs a small Elman RNN with two hidden units through a hand-crafted sequence of five tokens. Each token is represented as a single number (its index). At each step the network computes a new hidden state using its recurrence formula and displays both the input and the evolving state vector.
<p class="hint">An Elman RNN with 2 hidden units processes a token sequence one step at a time. At each step the hidden state <b>h</b> is updated using the previous state and the new input. Watch the state evolve — and notice how early tokens fade as the sequence grows.</p>
<div class="controls">
<label>Sequence length:
<input type="range" id="seqLen" min="3" max="12" value="5">
<span id="seqLenVal">5</span>
</label>
<button id="stepBtn" type="button">Step →</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div id="timeline"></div>
<div class="legend">
<span class="dot input"></span> Input token
<span class="dot h0"></span> h[0]
<span class="dot h1"></span> h[1]
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-bottom: .8rem; }
label { display: flex; align-items: center; gap: .4rem; font-size: .88rem; }
input[type=range] { width: 100px; accent-color: #1d3557; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
#timeline { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; min-height: 140px; }
.step-col { display: flex; flex-direction: column; align-items: center; gap: 4px; width: 52px; }
.step-col .label { font-size: .72rem; color: #666; }
.bar-wrap { display: flex; flex-direction: column; align-items: center; gap: 3px; width: 100%; }
.bar { width: 28px; border-radius: 4px; transition: height .35s ease; min-height: 4px; }
.bar.input { background: #457b9d; }
.bar.h0 { background: #e63946; }
.bar.h1 { background: #2a9d8f; }
.bar-val { font-size: .7rem; color: #333; text-align: center; }
.legend { margin-top: .6rem; font-size: .8rem; display: flex; align-items: center; gap: 4px; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.dot.input { background: #457b9d; }
.dot.h0 { background: #e63946; }
.dot.h1 { background: #2a9d8f; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Tiny Elman RNN: h_t = tanh(Wh * h_{t-1} + Wx * x_t + b)
// Fixed weights chosen so the hidden state carries visible but decaying memory.
// No imports, no external libs — pure vanilla JS.
// 2 hidden units, 1 input dimension
const Wh = [[0.6, -0.3], [0.4, 0.5]]; // 2x2 recurrent weights
const Wx = [[0.9], [0.7]]; // 2x1 input weights
const b = [0.05, -0.05]; // bias
function matvec(M, v) {
return M.map(row => row.reduce((s, w, j) => s + w * v[j], 0));
}
function tanh(x) { return Math.tanh(x); }
function rnnStep(h, x) {
// h: [h0, h1], x: scalar input
const recurrent = matvec(Wh, h);
const input = matvec(Wx, [x]);
return [
tanh(recurrent[0] + input[0] + b[0]),
tanh(recurrent[1] + input[1] + b[1])
];
}
// Generate a simple token sequence (repeating 1,0,1,0,... pattern)
function makeSequence(len) {
return Array.from({length: len}, (_, i) => i % 2 === 0 ? 1 : -1);
}
let seq = [];
let states = [];
let currentStep = 0;
const MAX_BAR = 100; // px
function init() {
const len = parseInt(document.getElementById('seqLen').value);
seq = makeSequence(len);
states = [];
currentStep = 0;
// initial hidden state = zeros
states.push([0, 0]);
render();
}
function step() {
if (currentStep >= seq.length) return;
const h_prev = states[states.length - 1];
const x = seq[currentStep];
const h_next = rnnStep(h_prev, x);
states.push(h_next);
currentStep++;
render();
}
function render() {
const tl = document.getElementById('timeline');
tl.innerHTML = '';
// Show each consumed step as a column (step 0 = initial state, steps 1..currentStep = after processing)
for (let t = 0; t <= currentStep; t++) {
const col = document.createElement('div');
col.className = 'step-col';
const stepLabel = document.createElement('div');
stepLabel.className = 'label';
stepLabel.textContent = t === 0 ? 'init' : fmt('t=%t%', { t: t });
col.appendChild(stepLabel);
const barWrap = document.createElement('div');
barWrap.className = 'bar-wrap';
// Input bar (only for t > 0)
if (t > 0) {
const xVal = seq[t - 1];
const inputBar = document.createElement('div');
inputBar.className = 'bar input';
const h = Math.round(Math.abs(xVal) * MAX_BAR * 0.5 + 8);
inputBar.style.height = h + 'px';
barWrap.appendChild(inputBar);
const inputVal = document.createElement('div');
inputVal.className = 'bar-val';
inputVal.textContent = xVal > 0 ? '+1' : '-1';
barWrap.appendChild(inputVal);
} else {
// placeholder
const ph = document.createElement('div');
ph.style.height = '26px';
barWrap.appendChild(ph);
}
// h0 bar
const h0 = states[t][0];
const bar0 = document.createElement('div');
bar0.className = 'bar h0';
bar0.style.height = Math.round(Math.abs(h0) * MAX_BAR + 6) + 'px';
barWrap.appendChild(bar0);
const val0 = document.createElement('div');
val0.className = 'bar-val';
val0.textContent = h0.toFixed(2);
barWrap.appendChild(val0);
// h1 bar
const h1 = states[t][1];
const bar1 = document.createElement('div');
bar1.className = 'bar h1';
bar1.style.height = Math.round(Math.abs(h1) * MAX_BAR + 6) + 'px';
barWrap.appendChild(bar1);
const val1 = document.createElement('div');
val1.className = 'bar-val';
val1.textContent = h1.toFixed(2);
barWrap.appendChild(val1);
col.appendChild(barWrap);
tl.appendChild(col);
}
document.getElementById('stepBtn').disabled = currentStep >= seq.length;
if (currentStep >= seq.length) {
document.getElementById('stepBtn').textContent = 'Done';
} else {
const remaining = seq.length - currentStep;
document.getElementById('stepBtn').textContent =
fmt('Step → (t=%currentStep%%v%, x=%v2%)', { currentStep: currentStep, v: 1, v2: seq[currentStep] > 0 ? '+1' : '-1' });
}
}
document.getElementById('seqLen').addEventListener('input', function() {
document.getElementById('seqLenVal').textContent = this.value;
init();
});
document.getElementById('stepBtn').addEventListener('click', step);
document.getElementById('resetBtn').addEventListener('click', init);
init();
// Verification log (node-testable)
// Run all 5 steps of the default sequence and log final hidden state
(function verify() {
const testSeq = makeSequence(5);
let h = [0, 0];
const log = [];
for (let i = 0; i < testSeq.length; i++) {
h = rnnStep(h, testSeq[i]);
log.push('t=' + (i+1) + ' x=' + testSeq[i] + ' h=[' + h.map(v => v.toFixed(4)).join(', ') + ']');
}
log.forEach(l => console.log(l));
// Sanity: h values must stay in (-1, 1) since tanh is applied
const ok = h.every(v => v > -1 && v < 1);
console.log(fmt('tanh bounds check: %v%', { v: ok ? 'PASS' : 'FAIL' }));
})();
<p class="hint">An Elman RNN with 2 hidden units processes a token sequence one step at a time. At each step the hidden state <b>h</b> is updated using the previous state and the new input. Watch the state evolve — and notice how early tokens fade as the sequence grows.</p>
<div class="controls">
<label>Sequence length:
<input type="range" id="seqLen" min="3" max="12" value="5">
<span id="seqLenVal">5</span>
</label>
<button id="stepBtn" type="button">Step →</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div id="timeline"></div>
<div class="legend">
<span class="dot input"></span> Input token
<span class="dot h0"></span> h[0]
<span class="dot h1"></span> h[1]
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-bottom: .8rem; }
label { display: flex; align-items: center; gap: .4rem; font-size: .88rem; }
input[type=range] { width: 100px; accent-color: #1d3557; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
#timeline { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; min-height: 140px; }
.step-col { display: flex; flex-direction: column; align-items: center; gap: 4px; width: 52px; }
.step-col .label { font-size: .72rem; color: #666; }
.bar-wrap { display: flex; flex-direction: column; align-items: center; gap: 3px; width: 100%; }
.bar { width: 28px; border-radius: 4px; transition: height .35s ease; min-height: 4px; }
.bar.input { background: #457b9d; }
.bar.h0 { background: #e63946; }
.bar.h1 { background: #2a9d8f; }
.bar-val { font-size: .7rem; color: #333; text-align: center; }
.legend { margin-top: .6rem; font-size: .8rem; display: flex; align-items: center; gap: 4px; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.dot.input { background: #457b9d; }
.dot.h0 { background: #e63946; }
.dot.h1 { background: #2a9d8f; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Tiny Elman RNN: h_t = tanh(Wh * h_{t-1} + Wx * x_t + b)
// Fixed weights chosen so the hidden state carries visible but decaying memory.
// No imports, no external libs — pure vanilla JS.
// 2 hidden units, 1 input dimension
const Wh = [[0.6, -0.3], [0.4, 0.5]]; // 2x2 recurrent weights
const Wx = [[0.9], [0.7]]; // 2x1 input weights
const b = [0.05, -0.05]; // bias
function matvec(M, v) {
return M.map(row => row.reduce((s, w, j) => s + w * v[j], 0));
}
function tanh(x) { return Math.tanh(x); }
function rnnStep(h, x) {
// h: [h0, h1], x: scalar input
const recurrent = matvec(Wh, h);
const input = matvec(Wx, [x]);
return [
tanh(recurrent[0] + input[0] + b[0]),
tanh(recurrent[1] + input[1] + b[1])
];
}
// Generate a simple token sequence (repeating 1,0,1,0,... pattern)
function makeSequence(len) {
return Array.from({length: len}, (_, i) => i % 2 === 0 ? 1 : -1);
}
let seq = [];
let states = [];
let currentStep = 0;
const MAX_BAR = 100; // px
function init() {
const len = parseInt(document.getElementById('seqLen').value);
seq = makeSequence(len);
states = [];
currentStep = 0;
// initial hidden state = zeros
states.push([0, 0]);
render();
}
function step() {
if (currentStep >= seq.length) return;
const h_prev = states[states.length - 1];
const x = seq[currentStep];
const h_next = rnnStep(h_prev, x);
states.push(h_next);
currentStep++;
render();
}
function render() {
const tl = document.getElementById('timeline');
tl.innerHTML = '';
// Show each consumed step as a column (step 0 = initial state, steps 1..currentStep = after processing)
for (let t = 0; t <= currentStep; t++) {
const col = document.createElement('div');
col.className = 'step-col';
const stepLabel = document.createElement('div');
stepLabel.className = 'label';
stepLabel.textContent = t === 0 ? 'init' : fmt('t=%t%', { t: t });
col.appendChild(stepLabel);
const barWrap = document.createElement('div');
barWrap.className = 'bar-wrap';
// Input bar (only for t > 0)
if (t > 0) {
const xVal = seq[t - 1];
const inputBar = document.createElement('div');
inputBar.className = 'bar input';
const h = Math.round(Math.abs(xVal) * MAX_BAR * 0.5 + 8);
inputBar.style.height = h + 'px';
barWrap.appendChild(inputBar);
const inputVal = document.createElement('div');
inputVal.className = 'bar-val';
inputVal.textContent = xVal > 0 ? '+1' : '-1';
barWrap.appendChild(inputVal);
} else {
// placeholder
const ph = document.createElement('div');
ph.style.height = '26px';
barWrap.appendChild(ph);
}
// h0 bar
const h0 = states[t][0];
const bar0 = document.createElement('div');
bar0.className = 'bar h0';
bar0.style.height = Math.round(Math.abs(h0) * MAX_BAR + 6) + 'px';
barWrap.appendChild(bar0);
const val0 = document.createElement('div');
val0.className = 'bar-val';
val0.textContent = h0.toFixed(2);
barWrap.appendChild(val0);
// h1 bar
const h1 = states[t][1];
const bar1 = document.createElement('div');
bar1.className = 'bar h1';
bar1.style.height = Math.round(Math.abs(h1) * MAX_BAR + 6) + 'px';
barWrap.appendChild(bar1);
const val1 = document.createElement('div');
val1.className = 'bar-val';
val1.textContent = h1.toFixed(2);
barWrap.appendChild(val1);
col.appendChild(barWrap);
tl.appendChild(col);
}
document.getElementById('stepBtn').disabled = currentStep >= seq.length;
if (currentStep >= seq.length) {
document.getElementById('stepBtn').textContent = 'Done';
} else {
const remaining = seq.length - currentStep;
document.getElementById('stepBtn').textContent =
fmt('Step → (t=%currentStep%%v%, x=%v2%)', { currentStep: currentStep, v: 1, v2: seq[currentStep] > 0 ? '+1' : '-1' });
}
}
document.getElementById('seqLen').addEventListener('input', function() {
document.getElementById('seqLenVal').textContent = this.value;
init();
});
document.getElementById('stepBtn').addEventListener('click', step);
document.getElementById('resetBtn').addEventListener('click', init);
init();
// Verification log (node-testable)
// Run all 5 steps of the default sequence and log final hidden state
(function verify() {
const testSeq = makeSequence(5);
let h = [0, 0];
const log = [];
for (let i = 0; i < testSeq.length; i++) {
h = rnnStep(h, testSeq[i]);
log.push('t=' + (i+1) + ' x=' + testSeq[i] + ' h=[' + h.map(v => v.toFixed(4)).join(', ') + ']');
}
log.forEach(l => console.log(l));
// Sanity: h values must stay in (-1, 1) since tanh is applied
const ok = h.every(v => v > -1 && v < 1);
console.log(fmt('tanh bounds check: %v%', { v: ok ? 'PASS' : 'FAIL' }));
})();
Watch the hidden state columns: earlier tokens leave a trace that shapes later computations. Then notice what happens as you increase the sequence length — the influence of the very first token fades. That gradual erasure is the vanishing-gradient problem made visible.
Comments
Loading comments...