Below is a tiny weather HMM. Two hidden states — Sunny and Rainy — and one clue per day: did your colleague carry an umbrella? Roll a random sequence of clues, then run the Viterbi decoder and watch it fill in the most likely hidden weather behind them.
<p class="hint">A hidden weather chain emits one clue per day: did your colleague carry an umbrella? You never see the weather — only the clues. Press New clues, then Run Viterbi to decode the single most likely sequence of hidden Sunny/Rainy days.</p>
<div class="row" id="clues"></div>
<div class="row" id="states"></div>
<div class="status" id="status">Roll a sequence of clues to begin.</div>
<div class="btns">
<button id="roll" type="button">New clues</button>
<button id="run" type="button">Run Viterbi</button>
<button id="reset" type="button" class="ghost">Clear states</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.row { display: flex; gap: 4px; flex-wrap: wrap; margin: .3rem 0; }
.cell { width: 46px; height: 46px; display: flex; align-items: center; justify-content: center;
font: 700 20px system-ui, sans-serif; border-radius: 8px; user-select: none; }
.clue { background: #e8eef3; color: #1d3557; border: 1px solid #cdd9e3; }
.state { border: 1px solid #cdd9e3; background: #f6f8fa; color: #aab2bd; font-size: 22px; }
.state.sun { background: #ffe9b0; border-color: #f0c850; color: #8a6a00; }
.state.rain { background: #c7d9ef; border-color: #8fb0dc; color: #1d3557; }
.status { font-size: .98rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// --- A 2-state Hidden Markov Model (weather) ---------------------------------
// Hidden states: Sun, Rain. Observation per day: umbrella (U) or no umbrella (N).
// We DECODE: given the clue sequence, find the single most likely hidden path.
// The Viterbi algorithm does this with dynamic programming in O(N * S^2),
// instead of brute-forcing all 2^N possible sun/rain sequences.
const STATES = ['Sun', 'Rain'];
const START = { Sun: 0.6, Rain: 0.4 }; // day-1 prior
const TRANS = { Sun: { Sun: 0.8, Rain: 0.2 }, // weather is sticky
Rain: { Sun: 0.4, Rain: 0.6 } };
const EMIT = { Sun: { U: 0.1, N: 0.9 }, // umbrella rare on sun
Rain: { U: 0.8, N: 0.2 } }; // umbrella likely on rain
const N_DAYS = 8;
const ICON = { Sun: '☀', Rain: '☂' }; // sun / umbrella glyphs
let clues = []; // array of 'U' / 'N'
let path = null; // decoded states, or null
// Sample a hidden path + clues honestly from the model (so clues are realistic).
function pick(dist){ let r = Math.random(); for (const k in dist){ r -= dist[k]; if (r <= 0) return k; } return Object.keys(dist).pop(); }
function roll(){
clues = []; let s = pick(START);
for (let t = 0; t < N_DAYS; t++){ clues.push(pick(EMIT[s])); s = pick(TRANS[s]); }
path = null; render(); setStatus('Clues rolled. Press "Run Viterbi" to decode.');
}
// --- Viterbi: log-space dynamic programming ---------------------------------
function viterbi(obs){
const L = (x) => Math.log(x);
const V = [{}], back = [{}];
for (const s of STATES) V[0][s] = L(START[s]) + L(EMIT[s][obs[0]]);
for (let t = 1; t < obs.length; t++){
V[t] = {}; back[t] = {};
for (const cur of STATES){
let best = -Infinity, arg = null;
for (const prev of STATES){
const score = V[t-1][prev] + L(TRANS[prev][cur]); // best path INTO cur
if (score > best){ best = score; arg = prev; }
}
V[t][cur] = best + L(EMIT[cur][obs[t]]);
back[t][cur] = arg;
}
}
// backtrack from the best final state
let last = STATES[0];
for (const s of STATES) if (V[obs.length-1][s] > V[obs.length-1][last]) last = s;
const out = new Array(obs.length); out[obs.length-1] = last;
for (let t = obs.length-1; t > 0; t--) out[t-1] = back[t][out[t]];
return out;
}
function run(){
if (!clues.length) return;
path = viterbi(clues); render();
console.log('clues :', clues.join(' '));
console.log('decoded:', path.join(' '));
setStatus(fmt('Decoded the most likely hidden weather — checked all %length% days at once, not 2^%length% = %v% guesses.', { length: clues.length, v: 1 << clues.length }));
}
// --- rendering --------------------------------------------------------------
function cell(cls, txt){ const d = document.createElement('div'); d.className = 'cell ' + cls; d.textContent = txt; return d; }
function render(){
const cr = document.getElementById('clues'); cr.innerHTML = '';
const sr = document.getElementById('states'); sr.innerHTML = '';
clues.forEach((c, i) => {
cr.appendChild(cell('clue', c === 'U' ? ICON.Rain : '·'));
if (path) sr.appendChild(cell('state ' + (path[i] === 'Sun' ? 'sun' : 'rain'), path[i] === 'Sun' ? ICON.Sun : ICON.Rain));
else sr.appendChild(cell('state', '?'));
});
}
function setStatus(t){ document.getElementById('status').textContent = t; }
document.getElementById('roll').addEventListener('click', roll);
document.getElementById('run').addEventListener('click', run);
document.getElementById('reset').addEventListener('click', () => { path = null; render(); setStatus('Cleared. Press "Run Viterbi" to decode the clues.'); });
roll();
<p class="hint">A hidden weather chain emits one clue per day: did your colleague carry an umbrella? You never see the weather — only the clues. Press New clues, then Run Viterbi to decode the single most likely sequence of hidden Sunny/Rainy days.</p>
<div class="row" id="clues"></div>
<div class="row" id="states"></div>
<div class="status" id="status">Roll a sequence of clues to begin.</div>
<div class="btns">
<button id="roll" type="button">New clues</button>
<button id="run" type="button">Run Viterbi</button>
<button id="reset" type="button" class="ghost">Clear states</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.row { display: flex; gap: 4px; flex-wrap: wrap; margin: .3rem 0; }
.cell { width: 46px; height: 46px; display: flex; align-items: center; justify-content: center;
font: 700 20px system-ui, sans-serif; border-radius: 8px; user-select: none; }
.clue { background: #e8eef3; color: #1d3557; border: 1px solid #cdd9e3; }
.state { border: 1px solid #cdd9e3; background: #f6f8fa; color: #aab2bd; font-size: 22px; }
.state.sun { background: #ffe9b0; border-color: #f0c850; color: #8a6a00; }
.state.rain { background: #c7d9ef; border-color: #8fb0dc; color: #1d3557; }
.status { font-size: .98rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// --- A 2-state Hidden Markov Model (weather) ---------------------------------
// Hidden states: Sun, Rain. Observation per day: umbrella (U) or no umbrella (N).
// We DECODE: given the clue sequence, find the single most likely hidden path.
// The Viterbi algorithm does this with dynamic programming in O(N * S^2),
// instead of brute-forcing all 2^N possible sun/rain sequences.
const STATES = ['Sun', 'Rain'];
const START = { Sun: 0.6, Rain: 0.4 }; // day-1 prior
const TRANS = { Sun: { Sun: 0.8, Rain: 0.2 }, // weather is sticky
Rain: { Sun: 0.4, Rain: 0.6 } };
const EMIT = { Sun: { U: 0.1, N: 0.9 }, // umbrella rare on sun
Rain: { U: 0.8, N: 0.2 } }; // umbrella likely on rain
const N_DAYS = 8;
const ICON = { Sun: '☀', Rain: '☂' }; // sun / umbrella glyphs
let clues = []; // array of 'U' / 'N'
let path = null; // decoded states, or null
// Sample a hidden path + clues honestly from the model (so clues are realistic).
function pick(dist){ let r = Math.random(); for (const k in dist){ r -= dist[k]; if (r <= 0) return k; } return Object.keys(dist).pop(); }
function roll(){
clues = []; let s = pick(START);
for (let t = 0; t < N_DAYS; t++){ clues.push(pick(EMIT[s])); s = pick(TRANS[s]); }
path = null; render(); setStatus('Clues rolled. Press "Run Viterbi" to decode.');
}
// --- Viterbi: log-space dynamic programming ---------------------------------
function viterbi(obs){
const L = (x) => Math.log(x);
const V = [{}], back = [{}];
for (const s of STATES) V[0][s] = L(START[s]) + L(EMIT[s][obs[0]]);
for (let t = 1; t < obs.length; t++){
V[t] = {}; back[t] = {};
for (const cur of STATES){
let best = -Infinity, arg = null;
for (const prev of STATES){
const score = V[t-1][prev] + L(TRANS[prev][cur]); // best path INTO cur
if (score > best){ best = score; arg = prev; }
}
V[t][cur] = best + L(EMIT[cur][obs[t]]);
back[t][cur] = arg;
}
}
// backtrack from the best final state
let last = STATES[0];
for (const s of STATES) if (V[obs.length-1][s] > V[obs.length-1][last]) last = s;
const out = new Array(obs.length); out[obs.length-1] = last;
for (let t = obs.length-1; t > 0; t--) out[t-1] = back[t][out[t]];
return out;
}
function run(){
if (!clues.length) return;
path = viterbi(clues); render();
console.log('clues :', clues.join(' '));
console.log('decoded:', path.join(' '));
setStatus(fmt('Decoded the most likely hidden weather — checked all %length% days at once, not 2^%length% = %v% guesses.', { length: clues.length, v: 1 << clues.length }));
}
// --- rendering --------------------------------------------------------------
function cell(cls, txt){ const d = document.createElement('div'); d.className = 'cell ' + cls; d.textContent = txt; return d; }
function render(){
const cr = document.getElementById('clues'); cr.innerHTML = '';
const sr = document.getElementById('states'); sr.innerHTML = '';
clues.forEach((c, i) => {
cr.appendChild(cell('clue', c === 'U' ? ICON.Rain : '·'));
if (path) sr.appendChild(cell('state ' + (path[i] === 'Sun' ? 'sun' : 'rain'), path[i] === 'Sun' ? ICON.Sun : ICON.Rain));
else sr.appendChild(cell('state', '?'));
});
}
function setStatus(t){ document.getElementById('status').textContent = t; }
document.getElementById('roll').addEventListener('click', roll);
document.getElementById('run').addEventListener('click', run);
document.getElementById('reset').addEventListener('click', () => { path = null; render(); setStatus('Cleared. Press "Run Viterbi" to decode the clues.'); });
roll();
Notice what the decoder does. It does not judge each day in isolation — an umbrella on one day might still be best explained as a sunny day if the days around it scream "sunny." Viterbi weighs the whole path at once, balancing how likely each clue is against how likely the weather is to change from one day to the next. Brute force would test every one of the 2n possible sun/rain sequences; Viterbi gets the same answer in time that grows only linearly with the number of days.
Comments
Loading comments...