technique
Viterbi algorithm
Recover the single most likely hidden path behind an observation sequence by filling the forward table with max instead of sum and walking backpointers.
Before this
This page assumes you are comfortable with:
- techniqueHidden Markov modelsA Markov chain you cannot see, driving tiles you can, plus the three algorithms that score, decode, and fit it.
- prerequisiteDynamic programmingFill a table one column at a time so every cell reuses the column before it, the single pattern behind the forward algorithm and Viterbi
- prerequisiteLogarithms and underflowWhy multiplying hundreds of probabilities gives zero on a computer, and how adding logs, comparing in log space, and the log-sum-exp trick fix it
Why you need this
A designer hands you a level built by hand. You want to know which column was a Run, which a Gap, which a Climb, so you can count how sections follow each other and seed your generator with real numbers. Nobody labeled the columns. Viterbi labels them for you: given the tiles and a hidden Markov model, it returns the one hidden path most likely to have produced them.
The idea
The decoding question is: which hidden sequence maximizes for the observations you have? There are candidates, and as with the forward algorithm, dynamic programming collapses them into a table of cells.
Define the Viterbi variable as the best log-probability of any hidden path that ends in state at time and emits along the way. Logs, because a path's probability is a product of factors that would underflow, and because in log space products become sums (see logarithms and underflow). Since is increasing, the path with the largest log-probability is the path with the largest probability.
Initialization.
Recursion. The best path ending in at came from the best path ending in some at , then took the step and emitted :
Compare this with the forward recursion, . Same shape, same table, with where the sum was and logs where the products were. Forward asks "how much probability flows into from all predecessors"; Viterbi asks "which single predecessor sends the most".
Backpointers. Record which won each max:
Termination. The best final state is , and the best path's log-probability is .
Backtracking. Walk the pointers from the end: for down to .
In log space with signs flipped, and are non-negative costs, and Viterbi is a shortest-path search through a graph with layers of nodes: the same relaxation you would write for a layered DAG, and the reason it is exact rather than a heuristic.
Worked example
Two hidden states, 1 Run and 2 Gap; two tiles, 1 flat and 2 pit; the same model as the forward page:
Observations: , so .
The code works in logs. For hand checking it is easier to carry the raw product and take the log at the end; both columns are shown. Each cell lists the two candidates (in raw form), the winner times the emission, and the backpointer.
| (flat) | (pit) | (pit) | (flat) | |
|---|---|---|---|---|
| Run candidates | from Run ; from Gap | from Run ; from Gap | from Run ; from Gap | |
| raw | ||||
| log | ||||
| Run | Gap | Gap | ||
| Gap candidates | from Run ; from Gap | from Run ; from Gap | from Run ; from Gap | |
| raw | ||||
| log | ||||
| Run | Gap | Gap |
Termination: at , Run () beats Gap (), so .
Backtracking: , so . , so . , so .
The decoded path is Run, Gap, Gap, Run, with probability (log ). Read it against the tiles: flat under Run, two pits under two Gaps, flat under Run. Note that at the forward algorithm's filtered belief and Viterbi's choice agree (Gap), but they need not in general; Viterbi commits to a whole path, and the filtered column does not.
Note also that at is larger than at . That is fine: the emission at is flat under Run (0.9), much larger than pit under Run (0.1) at , and the switch of predecessor from Run to Gap brings a bigger along.
JavaScript
// pi[i], A[i][j], B[j][k]: probabilities. O: symbol indices (0-based).
// Returns the most likely state path and its log-probability.
function viterbi(pi, A, B, O) {
const N = pi.length, T = O.length;
const lg = (x) => (x > 0 ? Math.log(x) : -Infinity);
const delta = [], psi = [];
delta[0] = pi.map((p, j) => lg(p) + lg(B[j][O[0]]));
psi[0] = new Array(N).fill(-1);
for (let t = 1; t < T; t++) {
delta[t] = new Array(N); psi[t] = new Array(N);
for (let j = 0; j < N; j++) {
let best = -Infinity, arg = 0;
for (let i = 0; i < N; i++) {
const v = delta[t - 1][i] + lg(A[i][j]);
if (v > best) { best = v; arg = i; }
}
delta[t][j] = best + lg(B[j][O[t]]);
psi[t][j] = arg;
}
}
let last = 0;
for (let j = 1; j < N; j++) if (delta[T - 1][j] > delta[T - 1][last]) last = j;
const path = new Array(T);
path[T - 1] = last;
for (let t = T - 1; t > 0; t--) path[t - 1] = psi[t][path[t]];
return { path, logP: delta[T - 1][last] };
}
const pi = [0.8, 0.2], A = [[0.7, 0.3], [0.6, 0.4]], B = [[0.9, 0.1], [0.2, 0.8]];
console.log(viterbi(pi, A, B, [0, 1, 1, 0])); // path [0, 1, 1, 0], logP -3.511...
In a map generator
- Labeling approved levels. Take a hand-authored or approved level, turn each column into a tile symbol, and decode. Each column now carries a section label.
- Counting to initialize. With labels in hand, count how often each label follows each label and normalize the rows: that is an estimate of . Count how often each tile appears under each label: that is an estimate of . Add 1 to every count first so nothing is zero. This is cheap and gives Baum-Welch a starting point near a sensible answer instead of a random one.
- Segmenting for the editor. Show the decoded labels as a colored strip above the level in the design tool. Designers can correct labels, and the corrections become training data.
- Explaining a rejected level. When the forward score flags a generated level as unlikely, the Viterbi path tells you where it went wrong: the run of six Hazards at columns 40 to 45.
Common mistakes
- Using probabilities instead of logs. Fine at ; at every cell is 0 and every backpointer is 0. Log from the first line.
- . A zero in or must become , not
NaN.Math.log(0)is in JavaScript, which is fine, but a floored value of1e-300that then gets multiplied is not. Keep the guard explicit. - Storing only the last backpointer column. Backtracking needs all columns of . Memory is and that is unavoidable for the path.
- Reading the per-column argmax as the path. The state that is individually most likely at each can string together into a path that is impossible (a transition with ). Viterbi is the only thing that returns a coherent path.
- Ties broken inconsistently. Two candidates with equal log-probability must resolve the same way every run or the labels flicker between builds. Use strict
>and iterate in a fixed order.
Cost
Time is : observations, cells per column, candidates per cell. Memory is for the backpointers (the table itself can be two columns). For and a 200-column level that is 7,200 comparisons and 1,200 stored bytes, far below anything that matters. It hurts only when grows into the hundreds, which happens if you encode a second-order model as pairs of states.
Going further
- Baum-Welch, which replaces hard Viterbi labels with soft expected counts.
- The forward-backward algorithm and posterior decoding, for the most likely state at each time rather than the most likely whole path.
- Beam search, an approximation that keeps only the top few entries when is large.
- Dijkstra's algorithm, to see Viterbi as shortest path on a layered graph.