technique
Forward algorithm
Score an observation sequence under a hidden Markov model, and read off the current hidden state along the way, in one left-to-right pass.
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
Two questions come up the moment you have a hidden Markov model. "How plausible is this level under my model?" lets a tool reject a generated level that is technically possible but unlike anything a designer approved. "Given what the player just did, what mode are they probably in?" lets a game adapt without asking. Both are the same computation, and the forward algorithm does it in one pass over the sequence.
The idea
The model is : hidden states, initial distribution , transitions , emissions . You are handed observations and want
the probability that the model produces exactly this sequence by any hidden path at all.
The direct route is to list every hidden path, compute its probability times the probability it emits these observations, and add them up. There are paths. For a modest section types and a column level that is , about terms. Not slow: impossible.
The escape is dynamic programming. Define the forward variable
the probability of having seen the first observations and being in state at time . It bundles together every path that ends in at , and that bundle is all you need to continue, because of the Markov property.
Initialization. At there is no previous state. Start in and emit :
Recursion. To be in at , you were in some at (with all of already accounted for by ), moved , and emitted :
Termination. The sequence ended in some state, so sum over them:
The table has columns and rows. Each cell costs a sum of terms. That is multiplications instead of .
Worked example
Two hidden states, 1 Run and 2 Gap. Two tile symbols, 1 flat and 2 pit.
Rows of : Run stays Run 0.7, Gap returns to Run 0.6. Rows of : Run shows flat 0.9, Gap shows pit 0.8. Observations: , .
| (flat) | (pit) | (flat) | |
|---|---|---|---|
| Column sum | 0.76 | 0.2384 | 0.151504 |
Termination: .
You can check this against the brute-force sum. There are hidden paths. The largest single term is Run, Gap, Run: . Adding all eight gives , the same number.
Filtering: what state are we in right now?
Divide a column by its sum and you get the probability of each hidden state given everything observed so far:
From the table: after "flat" the model is 0.72 / 0.76 = 0.947 sure of Run. After "flat, pit" it is 0.1856 / 0.2384 = 0.779 sure of Gap. After "flat, pit, flat" it is 0.133488 / 0.151504 = 0.881 sure of Run again. The belief tracks the evidence one step behind, which is exactly what you want from a game reading a player's actions.
Log space and scaling
Each is a product of about probabilities. If the typical factor is , then by the numbers are around , and a few hundred columns later they fall below the smallest positive double (about ), so the whole column becomes zero (see logarithms and underflow). The standard fix is to scale each column. After computing the raw column, sum it to get , divide every entry by , and remember . The scaled column is the filtered distribution from the previous section, so it always sums to 1 and never underflows. The total probability is the product of the scale factors, so
On the example: , and the scaled column is . Feeding that scaled column into the recursion gives raw values , so . One more step gives . Then , and . Same answer, no tiny numbers anywhere.
Report scores as log-probabilities and compare them per column () so that levels of different lengths are comparable.
JavaScript
// pi[i], A[i][j], B[j][k] are arrays of numbers; O is an array of symbol indices (0-based).
// Returns logP and filtered[t][j] = P(X_t = j | O_1..O_t).
function forward(pi, A, B, O) {
const N = pi.length, T = O.length;
const filtered = [];
let logP = 0, prev = null;
for (let t = 0; t < T; t++) {
const cur = new Array(N).fill(0);
for (let j = 0; j < N; j++) {
let s = 0;
if (t === 0) s = pi[j];
else for (let i = 0; i < N; i++) s += prev[i] * A[i][j];
cur[j] = s * B[j][O[t]];
}
const c = cur.reduce((a, b) => a + b, 0); // scale factor for this column
if (c === 0) return { logP: -Infinity, filtered }; // model says this sequence is impossible
for (let j = 0; j < N; j++) cur[j] /= c;
logP += Math.log(c);
filtered.push(cur);
prev = cur;
}
return { logP, filtered };
}
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(forward(pi, A, B, [0, 1, 0])); // logP -1.8871..., filtered[2] = [0.881, 0.119]
In a map generator
- Filtering the player. Hidden states are player modes (exploring, fighting, stuck); observations are bucketed actions (move, jump, attack, idle). Run one forward step per action and read the filtered column. When "stuck" passes 0.7 for several steps, offer a hint or nudge the regime chain toward Calm. The cost is multiplications per action, which is nothing.
- Scoring a candidate level. After the layout stage emits a column sequence, compute under the fitted model and reject or resample anything more than a chosen margin below the training levels' typical score. This is how the tool keeps a fitted generator from wandering into tile combinations the designer never approved.
- Comparing models. Two candidate parameter files, one set of approved levels: the one with the higher total log-probability fits better. This is also the quantity Baum-Welch drives upward.
Common mistakes
- Skipping the scaling. Works on 20-column test levels, returns for every real level. Scale from day one.
- Comparing raw log-probabilities of different lengths. A 200-column level always scores lower than a 50-column one. Divide by .
- A zero in for a tile that actually appears. The whole sequence gets probability 0 and every downstream score is . Give every emission a small floor unless it is truly forbidden by the tile catalog.
- Multiplying by on the wrong side. walks down column of . Using row instead gives numbers that still sum to something plausible and are wrong.
- Reading the filtered column as the most likely path. The most likely state at each time, read independently, can form a path that has zero probability as a whole. For the best single path use Viterbi.
Cost
Time is : observations, and each of the cells in a column sums over predecessors. Memory is if you only keep the current column and the running log sum, or if you keep the filtered table for later inspection or for Baum-Welch. With under 20 and in the hundreds this runs in microseconds; it only starts to hurt when Baum-Welch calls it thousands of times over hundreds of sequences.
Going further
- The Viterbi algorithm, the same table with max in place of sum.
- Baum-Welch, which pairs the forward table with a backward table to fit the model.
- The backward variable and smoothing, for the probability of a past state given the whole sequence.
- Log-sum-exp, an alternative to scaling that works entirely in log space.