<div class="hmm-desc">
<strong>Hidden Markov Model:</strong> Two hidden weather states (Sunny / Rainy) observed via umbrella use.
</div>
<div class="controls">
<button id="btn-random" type="button">Randomize</button>
<button id="btn-run" type="button">Run Viterbi</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="obs-row" id="obs-row"></div>
<div id="trellis-wrap">
<canvas id="trellis" width="560" height="200"></canvas>
</div>
<div class="result" id="result"></div>
<div class="prob-info" id="prob-info"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hmm-desc { font-size: .85rem; color: #555; margin-bottom: .6rem; }
.controls { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .7rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.obs-row { display: flex; gap: 6px; margin-bottom: .6rem; flex-wrap: wrap; align-items: center; }
.obs-label { font-size: .75rem; color: #777; margin-right: 4px; }
.obs-chip { padding: .25rem .55rem; border-radius: 6px; font-size: .85rem; font-weight: 600;
border: 1.5px solid transparent; }
.obs-chip.umbrella { background: #dbeafe; border-color: #3b82f6; color: #1d4ed8; }
.obs-chip.no-umbrella { background: #fef9c3; border-color: #ca8a04; color: #78350f; }
#trellis-wrap { overflow-x: auto; }
#trellis { display: block; max-width: 100%; }
.result { margin-top: .6rem; font-size: .95rem; font-weight: 600; min-height: 1.3em; }
.result.ready { color: #0a7d33; }
.result.wait { color: #777; }
.prob-info { font-size: .8rem; color: #555; margin-top: .25rem; font-family: ui-monospace, monospace; min-height: 1.2em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// โโ HMM parameters โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// States: 0 = Sunny, 1 = Rainy
// Observations: 0 = No umbrella, 1 = Umbrella
const STATES = ['Sunny','Rainy'];
const OBS_NAMES = ['No umbrella','Umbrella'];
const PI = [0.6, 0.4]; // initial state probs
const TRANS = [[0.7, 0.3], // SunnyโSunny, SunnyโRainy
[0.4, 0.6]]; // RainyโSunny, RainyโRainy
const EMIT = [[0.8, 0.2], // Sunny: P(no-umbrella)=0.8, P(umbrella)=0.2
[0.1, 0.9]]; // Rainy: P(no-umbrella)=0.1, P(umbrella)=0.9
const SEQ_LEN = 7;
const COLOR_SUNNY = '#f59e0b'; // amber
const COLOR_RAINY = '#3b82f6'; // blue
const COLOR_EDGE = '#94a3b8';
const COLOR_BEST = '#16a34a'; // green for traceback path
let observations = [];
let vitTbl = null; // [t][s] = log-prob
let backPtr = null; // [t][s] = predecessor state
let bestPath = null;
// โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function randObs() {
const out = [];
// Generate from a random state sequence so observations are realistic
let s = Math.random() < PI[0] ? 0 : 1;
for (let t = 0; t < SEQ_LEN; t++) {
out.push(Math.random() < EMIT[s][1] ? 1 : 0);
const r = Math.random();
s = r < TRANS[s][0] ? 0 : 1;
}
return out;
}
// Viterbi in log space
function viterbi(obs) {
const T = obs.length, N = STATES.length;
const V = Array.from({length: T}, () => new Array(N).fill(-Infinity));
const B = Array.from({length: T}, () => new Array(N).fill(-1));
for (let s = 0; s < N; s++)
V[0][s] = Math.log(PI[s]) + Math.log(EMIT[s][obs[0]]);
for (let t = 1; t < T; t++) {
for (let s = 0; s < N; s++) {
let best = -Infinity, bestPrev = 0;
for (let p = 0; p < N; p++) {
const v = V[t-1][p] + Math.log(TRANS[p][s]);
if (v > best) { best = v; bestPrev = p; }
}
V[t][s] = best + Math.log(EMIT[s][obs[t]]);
B[t][s] = bestPrev;
}
}
// Traceback
const path = new Array(T);
path[T-1] = V[T-1][0] >= V[T-1][1] ? 0 : 1;
for (let t = T-2; t >= 0; t--) path[t] = B[t+1][path[t+1]];
return { V, B, path };
}
// โโ Canvas drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const canvas = document.getElementById('trellis');
const ctx = canvas.getContext('2d');
const PAD_LEFT = 60, PAD_TOP = 30, PAD_BOT = 30;
const ROW_H = 70, NODE_R = 18;
function colX(t) {
const W = canvas.width - PAD_LEFT - 20;
return PAD_LEFT + (t / (SEQ_LEN - 1)) * W;
}
function rowY(s) { return PAD_TOP + s * ROW_H; }
function drawBase() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// State labels
ctx.font = '12px system-ui';
ctx.fillStyle = COLOR_SUNNY; ctx.textAlign = 'right';
ctx.fillText('Sunny', PAD_LEFT - 8, rowY(0) + 4);
ctx.fillStyle = COLOR_RAINY;
ctx.fillText('Rainy', PAD_LEFT - 8, rowY(1) + 4);
// Edges (all gray first)
for (let t = 0; t < SEQ_LEN - 1; t++) {
for (let s = 0; s < 2; s++) {
for (let ns = 0; ns < 2; ns++) {
ctx.beginPath();
ctx.moveTo(colX(t), rowY(s));
ctx.lineTo(colX(t+1), rowY(ns));
ctx.strokeStyle = COLOR_EDGE;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.35;
ctx.stroke();
ctx.globalAlpha = 1;
}
}
}
}
function drawNodes(upToT, showScores) {
for (let t = 0; t <= upToT; t++) {
for (let s = 0; s < 2; s++) {
const x = colX(t), y = rowY(s);
ctx.beginPath();
ctx.arc(x, y, NODE_R, 0, 2*Math.PI);
ctx.fillStyle = s === 0 ? COLOR_SUNNY : COLOR_RAINY;
ctx.globalAlpha = 0.9;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2;
ctx.stroke();
// score text
if (showScores && vitTbl) {
const score = vitTbl[t][s];
ctx.font = 'bold 9px ui-monospace, monospace';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText(score.toFixed(2), x, y + 3);
}
}
}
}
function drawTraceback() {
if (!bestPath) return;
for (let t = 0; t < SEQ_LEN - 1; t++) {
ctx.beginPath();
ctx.moveTo(colX(t), rowY(bestPath[t]));
ctx.lineTo(colX(t+1), rowY(bestPath[t+1]));
ctx.strokeStyle = COLOR_BEST;
ctx.lineWidth = 3.5;
ctx.stroke();
}
// Highlight path nodes
for (let t = 0; t < SEQ_LEN; t++) {
const x = colX(t), y = rowY(bestPath[t]);
ctx.beginPath();
ctx.arc(x, y, NODE_R + 4, 0, 2*Math.PI);
ctx.strokeStyle = COLOR_BEST;
ctx.lineWidth = 2.5;
ctx.stroke();
}
}
// โโ Render observation chips โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function renderObs() {
const row = document.getElementById('obs-row');
row.innerHTML = '';
const lbl = document.createElement('span');
lbl.className = 'obs-label'; lbl.textContent = 'Observations:';
row.appendChild(lbl);
observations.forEach(o => {
const chip = document.createElement('span');
chip.className = 'obs-chip ' + (o ? 'umbrella' : 'no-umbrella');
chip.textContent = o ? 'โ Umbrella' : 'โ No umbrella';
row.appendChild(chip);
});
}
// โโ Animation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let animating = false;
function runAnimation() {
if (animating) return;
animating = true;
const res = viterbi(observations);
vitTbl = res.V; backPtr = res.B; bestPath = res.path;
let t = 0;
function step() {
drawBase();
drawNodes(t, true);
if (t === SEQ_LEN - 1) {
drawTraceback();
showResult();
animating = false;
return;
}
t++;
setTimeout(step, 380);
}
step();
}
function showResult() {
const names = bestPath.map(s => STATES[s]).join(' โ ');
document.getElementById('result').textContent = fmt('โ Best path: %names%', { names: names });
document.getElementById('result').className = 'result ready';
const finalScore = vitTbl[SEQ_LEN-1][bestPath[SEQ_LEN-1]];
document.getElementById('prob-info').textContent =
fmt('log-probability of best path: %finalScore% | path probability โ %toExponential%', { finalScore: finalScore.toFixed(4), toExponential: Math.exp(finalScore).toExponential(3) });
}
function resetAll() {
vitTbl = null; backPtr = null; bestPath = null; animating = false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBase();
drawNodes(SEQ_LEN - 1, false);
document.getElementById('result').textContent = 'Press Run Viterbi to decode.';
document.getElementById('result').className = 'result wait';
document.getElementById('prob-info').textContent = '';
}
// โโ Init โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
observations = randObs();
renderObs();
resetAll();
document.getElementById('btn-random').addEventListener('click', () => {
if (animating) return;
observations = randObs();
renderObs();
resetAll();
});
document.getElementById('btn-run').addEventListener('click', () => { runAnimation(); });
document.getElementById('btn-reset').addEventListener('click', () => { if (!animating) resetAll(); });
<div class="hmm-desc">
<strong>Hidden Markov Model:</strong> Two hidden weather states (Sunny / Rainy) observed via umbrella use.
</div>
<div class="controls">
<button id="btn-random" type="button">Randomize</button>
<button id="btn-run" type="button">Run Viterbi</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="obs-row" id="obs-row"></div>
<div id="trellis-wrap">
<canvas id="trellis" width="560" height="200"></canvas>
</div>
<div class="result" id="result"></div>
<div class="prob-info" id="prob-info"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hmm-desc { font-size: .85rem; color: #555; margin-bottom: .6rem; }
.controls { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .7rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.obs-row { display: flex; gap: 6px; margin-bottom: .6rem; flex-wrap: wrap; align-items: center; }
.obs-label { font-size: .75rem; color: #777; margin-right: 4px; }
.obs-chip { padding: .25rem .55rem; border-radius: 6px; font-size: .85rem; font-weight: 600;
border: 1.5px solid transparent; }
.obs-chip.umbrella { background: #dbeafe; border-color: #3b82f6; color: #1d4ed8; }
.obs-chip.no-umbrella { background: #fef9c3; border-color: #ca8a04; color: #78350f; }
#trellis-wrap { overflow-x: auto; }
#trellis { display: block; max-width: 100%; }
.result { margin-top: .6rem; font-size: .95rem; font-weight: 600; min-height: 1.3em; }
.result.ready { color: #0a7d33; }
.result.wait { color: #777; }
.prob-info { font-size: .8rem; color: #555; margin-top: .25rem; font-family: ui-monospace, monospace; min-height: 1.2em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// โโ HMM parameters โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// States: 0 = Sunny, 1 = Rainy
// Observations: 0 = No umbrella, 1 = Umbrella
const STATES = ['Sunny','Rainy'];
const OBS_NAMES = ['No umbrella','Umbrella'];
const PI = [0.6, 0.4]; // initial state probs
const TRANS = [[0.7, 0.3], // SunnyโSunny, SunnyโRainy
[0.4, 0.6]]; // RainyโSunny, RainyโRainy
const EMIT = [[0.8, 0.2], // Sunny: P(no-umbrella)=0.8, P(umbrella)=0.2
[0.1, 0.9]]; // Rainy: P(no-umbrella)=0.1, P(umbrella)=0.9
const SEQ_LEN = 7;
const COLOR_SUNNY = '#f59e0b'; // amber
const COLOR_RAINY = '#3b82f6'; // blue
const COLOR_EDGE = '#94a3b8';
const COLOR_BEST = '#16a34a'; // green for traceback path
let observations = [];
let vitTbl = null; // [t][s] = log-prob
let backPtr = null; // [t][s] = predecessor state
let bestPath = null;
// โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function randObs() {
const out = [];
// Generate from a random state sequence so observations are realistic
let s = Math.random() < PI[0] ? 0 : 1;
for (let t = 0; t < SEQ_LEN; t++) {
out.push(Math.random() < EMIT[s][1] ? 1 : 0);
const r = Math.random();
s = r < TRANS[s][0] ? 0 : 1;
}
return out;
}
// Viterbi in log space
function viterbi(obs) {
const T = obs.length, N = STATES.length;
const V = Array.from({length: T}, () => new Array(N).fill(-Infinity));
const B = Array.from({length: T}, () => new Array(N).fill(-1));
for (let s = 0; s < N; s++)
V[0][s] = Math.log(PI[s]) + Math.log(EMIT[s][obs[0]]);
for (let t = 1; t < T; t++) {
for (let s = 0; s < N; s++) {
let best = -Infinity, bestPrev = 0;
for (let p = 0; p < N; p++) {
const v = V[t-1][p] + Math.log(TRANS[p][s]);
if (v > best) { best = v; bestPrev = p; }
}
V[t][s] = best + Math.log(EMIT[s][obs[t]]);
B[t][s] = bestPrev;
}
}
// Traceback
const path = new Array(T);
path[T-1] = V[T-1][0] >= V[T-1][1] ? 0 : 1;
for (let t = T-2; t >= 0; t--) path[t] = B[t+1][path[t+1]];
return { V, B, path };
}
// โโ Canvas drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const canvas = document.getElementById('trellis');
const ctx = canvas.getContext('2d');
const PAD_LEFT = 60, PAD_TOP = 30, PAD_BOT = 30;
const ROW_H = 70, NODE_R = 18;
function colX(t) {
const W = canvas.width - PAD_LEFT - 20;
return PAD_LEFT + (t / (SEQ_LEN - 1)) * W;
}
function rowY(s) { return PAD_TOP + s * ROW_H; }
function drawBase() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// State labels
ctx.font = '12px system-ui';
ctx.fillStyle = COLOR_SUNNY; ctx.textAlign = 'right';
ctx.fillText('Sunny', PAD_LEFT - 8, rowY(0) + 4);
ctx.fillStyle = COLOR_RAINY;
ctx.fillText('Rainy', PAD_LEFT - 8, rowY(1) + 4);
// Edges (all gray first)
for (let t = 0; t < SEQ_LEN - 1; t++) {
for (let s = 0; s < 2; s++) {
for (let ns = 0; ns < 2; ns++) {
ctx.beginPath();
ctx.moveTo(colX(t), rowY(s));
ctx.lineTo(colX(t+1), rowY(ns));
ctx.strokeStyle = COLOR_EDGE;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.35;
ctx.stroke();
ctx.globalAlpha = 1;
}
}
}
}
function drawNodes(upToT, showScores) {
for (let t = 0; t <= upToT; t++) {
for (let s = 0; s < 2; s++) {
const x = colX(t), y = rowY(s);
ctx.beginPath();
ctx.arc(x, y, NODE_R, 0, 2*Math.PI);
ctx.fillStyle = s === 0 ? COLOR_SUNNY : COLOR_RAINY;
ctx.globalAlpha = 0.9;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2;
ctx.stroke();
// score text
if (showScores && vitTbl) {
const score = vitTbl[t][s];
ctx.font = 'bold 9px ui-monospace, monospace';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText(score.toFixed(2), x, y + 3);
}
}
}
}
function drawTraceback() {
if (!bestPath) return;
for (let t = 0; t < SEQ_LEN - 1; t++) {
ctx.beginPath();
ctx.moveTo(colX(t), rowY(bestPath[t]));
ctx.lineTo(colX(t+1), rowY(bestPath[t+1]));
ctx.strokeStyle = COLOR_BEST;
ctx.lineWidth = 3.5;
ctx.stroke();
}
// Highlight path nodes
for (let t = 0; t < SEQ_LEN; t++) {
const x = colX(t), y = rowY(bestPath[t]);
ctx.beginPath();
ctx.arc(x, y, NODE_R + 4, 0, 2*Math.PI);
ctx.strokeStyle = COLOR_BEST;
ctx.lineWidth = 2.5;
ctx.stroke();
}
}
// โโ Render observation chips โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function renderObs() {
const row = document.getElementById('obs-row');
row.innerHTML = '';
const lbl = document.createElement('span');
lbl.className = 'obs-label'; lbl.textContent = 'Observations:';
row.appendChild(lbl);
observations.forEach(o => {
const chip = document.createElement('span');
chip.className = 'obs-chip ' + (o ? 'umbrella' : 'no-umbrella');
chip.textContent = o ? 'โ Umbrella' : 'โ No umbrella';
row.appendChild(chip);
});
}
// โโ Animation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let animating = false;
function runAnimation() {
if (animating) return;
animating = true;
const res = viterbi(observations);
vitTbl = res.V; backPtr = res.B; bestPath = res.path;
let t = 0;
function step() {
drawBase();
drawNodes(t, true);
if (t === SEQ_LEN - 1) {
drawTraceback();
showResult();
animating = false;
return;
}
t++;
setTimeout(step, 380);
}
step();
}
function showResult() {
const names = bestPath.map(s => STATES[s]).join(' โ ');
document.getElementById('result').textContent = fmt('โ Best path: %names%', { names: names });
document.getElementById('result').className = 'result ready';
const finalScore = vitTbl[SEQ_LEN-1][bestPath[SEQ_LEN-1]];
document.getElementById('prob-info').textContent =
fmt('log-probability of best path: %finalScore% | path probability โ %toExponential%', { finalScore: finalScore.toFixed(4), toExponential: Math.exp(finalScore).toExponential(3) });
}
function resetAll() {
vitTbl = null; backPtr = null; bestPath = null; animating = false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBase();
drawNodes(SEQ_LEN - 1, false);
document.getElementById('result').textContent = 'Press Run Viterbi to decode.';
document.getElementById('result').className = 'result wait';
document.getElementById('prob-info').textContent = '';
}
// โโ Init โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
observations = randObs();
renderObs();
resetAll();
document.getElementById('btn-random').addEventListener('click', () => {
if (animating) return;
observations = randObs();
renderObs();
resetAll();
});
document.getElementById('btn-run').addEventListener('click', () => { runAnimation(); });
document.getElementById('btn-reset').addEventListener('click', () => { if (!animating) resetAll(); });
Notice how the algorithm never backtracks or searches blindly. At each step it keeps only N partial-path scores โ one per state โ and extends them forward. The traceback at the end recovers the full optimal path in a single backward pass. This is the power of dynamic programming: throwing away all but the best incoming path at each node.
Comments
Loading comments...