Technical writing

Structured Prediction / Mathematical NLP

Understanding Viterbi Decoding in Linear-Chain CRFs

A derivation of exact MAP decoding from the score of a linear-chain conditional random field, with backpointers and an auditable NumPy implementation.

Sequence labelling looks local only until two plausible labels compete. A token-level classifier can assign one score to every label at every position, but it cannot say that I-PER should usually follow B-PER, or that a medical problem span is unlikely to jump directly into a medication span. A linear-chain conditional random field (CRF) adds those dependencies while keeping exact decoding possible.

This article develops Viterbi decoding from the CRF sequence score rather than presenting the recurrence as a formula to memorize. The essential move is to retain the best prefix for each possible final label. Prefixes ending in the same label have identical options in the future, so all but the best one can be discarded.

Motivation

Let an input sequence be x=(x1,,xT)\mathbf{x}=(x_1,\ldots,x_T), and let its label sequence be y=(y1,,yT)\mathbf{y}=(y_1,\ldots,y_T), where every yty_t belongs to a set of KK labels. In named entity recognition, the input could be clinical tokens and the labels could be BIO tags such as B-PROBLEM, I-PROBLEM, and O.

A neural encoder produces an emission score st(j,x)s_t(j,\mathbf{x}): how compatible position tt is with label jj. These scores are not probabilities and need not sum to one. The CRF also learns a transition score AijA_{ij} for moving from label ii at position t1t-1 to label jj at position tt.

Independent decoding would choose the highest emission at every position:

y^t=argmaxjst(j,x).\hat y_t = \arg\max_j s_t(j,\mathbf{x}).

That decision ignores the transition matrix. Viterbi instead finds one sequence whose total score is maximal:

y^=argmaxyScore(x,y).\hat{\mathbf{y}} = \arg\max_{\mathbf{y}} \operatorname{Score}(\mathbf{x},\mathbf{y}).

The distinction matters whenever a slightly weaker local label enables a much more coherent sequence.

Sequence scoring

For now, omit explicit start and stop states. The score of a complete label sequence is

Score(x,y)=t=1Tst(yt,x)+t=2TAyt1,yt.\operatorname{Score}(\mathbf{x},\mathbf{y}) = \sum_{t=1}^{T} s_t(y_t,\mathbf{x}) + \sum_{t=2}^{T} A_{y_{t-1},y_t}.

The first sum collects one emission at every position. The second collects one transition for every adjacent pair. Our matrix convention is important: row ii is the previous label and column jj is the current label.

Many implementations add vectors astarta^{\text{start}} and astopa^{\text{stop}}:

Score(x,y)=ay1start+t=1Tst(yt,x)+t=2TAyt1,yt+ayTstop.\operatorname{Score}(\mathbf{x},\mathbf{y}) = a^{\text{start}}_{y_1} + \sum_{t=1}^{T} s_t(y_t,\mathbf{x}) + \sum_{t=2}^{T} A_{y_{t-1},y_t} + a^{\text{stop}}_{y_T}.

They change initialization and termination, not the core recurrence.

During probabilistic training, the CRF defines

p(yx)=exp(t=1Tst(yt,x)+t=2TAyt1,yt)Z(x),p(\mathbf{y}\mid\mathbf{x}) = \frac{ \exp\left( \sum_{t=1}^{T} s_t(y_t,\mathbf{x}) + \sum_{t=2}^{T} A_{y_{t-1},y_t} \right) }{ Z(\mathbf{x}) },

where

Z(x)=yexp(Score(x,y)).Z(\mathbf{x}) = \sum_{\mathbf{y}'} \exp\bigl(\operatorname{Score}(\mathbf{x},\mathbf{y}')\bigr).

The normalizer Z(x)Z(\mathbf{x}) is essential for likelihoods, but it does not affect the most likely sequence for a fixed input. Since the exponential is monotone and the denominator is shared by every candidate, MAP decoding reduces to maximizing the unnormalized score.

Why brute force is intractable

There are KTK^T possible label sequences. With nine labels and a sentence of 40 tokens, exhaustive search considers 9409^{40}, roughly 1.5×10381.5\times10^{38}, paths. Scoring a path is cheap; enumerating the paths is impossible.

The layered graph below makes the redundancy visible. Every path reaching label jj at time tt has the same set of outgoing transitions. If two such prefixes have scores 12.4 and 10.1, extending the weaker prefix with any identical suffix keeps it 2.3 points behind. It can never recover.

Figure 1. A linear-chain CRF as a layered directed acyclic graph. Viterbi keeps one winning incoming edge per node, then recovers the global path by following those edges backward.

Definition

Optimal substructure

If the highest-scoring complete path ends in label jj at time tt, then its prefix ending at any earlier position must also be the highest-scoring prefix among paths with that same endpoint. Otherwise we could replace the prefix with a better one and improve the complete path, a contradiction.

Dynamic-programming decomposition

Define the Viterbi state

δt(j)=maxy1,,yt1yt=jScore(x1:t,y1:t).\delta_t(j) = \max_{\substack{y_1,\ldots,y_{t-1}\\y_t=j}} \operatorname{Score}(x_{1:t}, y_{1:t}).

In words, δt(j)\delta_t(j) is the score of the best prefix through position tt subject to ending with label jj. This final condition is what makes the state sufficient. A single global best prefix is not enough because different end labels offer different transition scores at the next position.

With start scores, initialization is

δ1(j)=ajstart+s1(j,x).\delta_1(j) = a^{\text{start}}_j + s_1(j,\mathbf{x}).

There is no previous ordinary label at t=1t=1, so no entry from AA is used.

Derivation of the recurrence

Suppose the current label at position tt is fixed to jj. The preceding label must be some ii. A candidate path is composed of:

  1. the best prefix ending in ii, with score δt1(i)\delta_{t-1}(i);
  2. the transition from ii to jj, with score AijA_{ij};
  3. the current emission, with score st(j,x)s_t(j,\mathbf{x}).

Maximizing over the only unresolved boundary label gives

δt(j)=maxi[δt1(i)+Aij+st(j,x)].\delta_t(j) = \max_i \left[ \delta_{t-1}(i) + A_{ij} + s_t(j,\mathbf{x}) \right].

Because the emission does not depend on ii, an equivalent implementation is

δt(j)=st(j,x)+maxi[δt1(i)+Aij].\delta_t(j) = s_t(j,\mathbf{x}) + \max_i\left[\delta_{t-1}(i)+A_{ij}\right].

The explicit form is often easier to audit against tensor broadcasting.

Viterbi invariant

After processing position tt, δt(j)\delta_t(j) equals the maximum score of every length-tt label sequence ending in jj, for every label jj.

Proof sketch

At t=1t=1, the only path ending in jj consists of the start transition and the first emission, so initialization is exact. Assume the claim holds at t1t-1. Every path ending in jj at tt arrives from exactly one previous label ii. For a fixed ii, the induction hypothesis says δt1(i)\delta_{t-1}(i) is the best possible prefix. Adding AijA_{ij} and st(j,x)s_t(j,\mathbf{x}), then maximizing over all ii, therefore considers the best member of every possible final-transition group. This is precisely the best path ending in jj.

At the last position, include the stop score:

y^T=argmaxj[δT(j)+ajstop].\hat y_T = \arg\max_j\left[\delta_T(j)+a^{\text{stop}}_j\right].

If start and stop scores are not modelled, use zero vectors.

Backpointers

The dynamic program above returns the best score, but a decoder needs the labels. At every state, store which predecessor achieved the maximum:

ψt(j)=argmaxi[δt1(i)+Aij].\psi_t(j) = \arg\max_i \left[ \delta_{t-1}(i)+A_{ij} \right].

After selecting the final label, move backward:

y^t1=ψt(y^t),t=T,T1,,2.\hat y_{t-1}=\psi_t(\hat y_t), \qquad t=T,T-1,\ldots,2.

This is why taking argmax independently from each row of the completed score table is wrong. A high-scoring state at time t1t-1 may not be the predecessor of the selected state at time tt. The backpointer records the compatible choice.

AlgorithmExact MAP decoding in a first-order linear chain
Viterbi decoding — pseudocode
score[j] ← start[j] + emission[1, j] for every label j
 
for t ← 2 … T:
    candidate[i, j] ← score[i] + transition[i, j]
    backpointer[t, j] ← argmax_i candidate[i, j]
    score[j] ← max_i candidate[i, j] + emission[t, j]
 
last ← argmax_j (score[j] + stop[j])
path[T] ← last
 
for t ← T … 2:
    path[t - 1] ← backpointer[t, path[t]]
 
return path and its score

Complexity analysis

At each of T1T-1 recurrent steps, every one of KK destination labels compares all KK source labels. Time complexity is therefore O(TK2)O(TK^2). The full backpointer table stores one integer per position and label, so reconstruction takes O(TK)O(TK) memory. The score calculation itself needs only the previous and current rows, or O(K)O(K) memory.

Time
O(TK²)
Dense transition matrix
Backpointers
O(TK)
Required for path recovery
Rolling scores
O(K)
Two score vectors suffice

This is exact, not a beam-search approximation. Constraints can also make it cheaper in practice: invalid BIO transitions may receive negative infinity, and a sparse transition graph can avoid evaluating forbidden edges.

For batched neural inference, emissions usually have shape [batch, time, labels]. A broadcasted addition forms scores of shape [batch, previous_label, current_label] at each time step. Masks must prevent padded positions from overwriting the last valid state for shorter sequences.

Python implementation

The NumPy implementation below chooses the transition convention used throughout this article: transitions[i, j] scores a move from previous label i to current label j.

Reference implementation

viterbi.py
from __future__ import annotations
 
import numpy as np
from numpy.typing import NDArray
 
 
def viterbi_decode(
    emissions: NDArray[np.floating],
    transitions: NDArray[np.floating],
    start: NDArray[np.floating] | None = None,
    stop: NDArray[np.floating] | None = None,
) -> tuple[list[int], float]:
    """Return the highest-scoring label path and its unnormalized score."""
    if emissions.ndim != 2:
        raise ValueError("emissions must have shape [time, labels]")
    time_steps, labels = emissions.shape
    if time_steps == 0 or labels == 0:
        raise ValueError("emissions must describe a non-empty sequence")
    if transitions.shape != (labels, labels):
        raise ValueError("transitions must have shape [labels, labels]")
 
    start_scores = (
        np.zeros(labels, dtype=emissions.dtype) if start is None else np.asarray(start)
    )
    stop_scores = (
        np.zeros(labels, dtype=emissions.dtype) if stop is None else np.asarray(stop)
    )
    if start_scores.shape != (labels,) or stop_scores.shape != (labels,):
        raise ValueError("start and stop must each have shape [labels]")
 
    scores = start_scores + emissions[0]
    backpointers = np.full((time_steps, labels), -1, dtype=np.int64)
 
    for t in range(1, time_steps):
        # candidate[i, j] extends previous label i with current label j.
        candidate = scores[:, None] + transitions
        backpointers[t] = np.argmax(candidate, axis=0)
        scores = np.max(candidate, axis=0) + emissions[t]
 
    terminal_scores = scores + stop_scores
    last = int(np.argmax(terminal_scores))
    best_score = float(terminal_scores[last])
 
    path = [last]
    for t in range(time_steps - 1, 0, -1):
        last = int(backpointers[t, last])
        path.append(last)
    path.reverse()
    return path, best_score

The update is deliberately split into a transition maximum and an emission addition. A fused expression is shorter, but this form exposes the two axes at which most implementation errors occur.

For a differentiable PyTorch CRF, decoding is normally run outside gradient tracking. Training uses the gold path score and the log partition function, not the Viterbi score. Replacing logsumexp with max during negative log-likelihood training changes the objective.

A small worked example

Consider two tokens, “time flies”, and two labels: noun NN and verb VV. Use zero start and stop scores. Let rows be time positions and columns be labels:

S=[2.20.30.41.9],A=[0.50.20.10.3].S= \begin{bmatrix} 2.2 & 0.3 \\ 0.4 & 1.9 \end{bmatrix}, \qquad A= \begin{bmatrix} 0.5 & -0.2 \\ -0.1 & 0.3 \end{bmatrix}.

At the first token:

δ1(N)=2.2,δ1(V)=0.3.\delta_1(N)=2.2,\qquad \delta_1(V)=0.3.

At the second token, the best paths ending in NN and VV are

δ2(N)=max(2.2+0.5,  0.30.1)+0.4=3.1,δ2(V)=max(2.20.2,  0.3+0.3)+1.9=3.9.\begin{aligned} \delta_2(N) &= \max(2.2+0.5,\;0.3-0.1)+0.4 =3.1,\\ \delta_2(V) &= \max(2.2-0.2,\;0.3+0.3)+1.9 =3.9. \end{aligned}

Both maxima come from previous label NN, so ψ2(N)=N\psi_2(N)=N and ψ2(V)=N\psi_2(V)=N. The best final label is VV; following its backpointer gives the path (N,V)(N,V) with score 3.9.

Destination at t=2t=2From NNFrom VVWinnerFinal score
NN2.2+0.52.2+0.50.30.10.3-0.1NN3.13.1
VV2.20.22.2-0.20.3+0.30.3+0.3NN3.93.9

Notice that the locally strongest second-token emission is already VV, but the decoder still needs the transition calculation to identify its predecessor and evaluate the sequence consistently.

Common implementation mistakes

Reversing the transition axes

Some libraries define transitions[current, previous]; others use transitions[previous, current]. Both are valid. Mixing the convention between training, gold-sequence scoring, and decoding is not. Write the candidate tensor shape beside the broadcasting expression and test it with an asymmetric matrix.

Applying the emission on the wrong axis

The current emission st(j)s_t(j) is added after maximizing over predecessor ii. In a candidate tensor indexed by [i, j], it broadcasts across rows, not columns. An emission accidentally indexed by ii scores the previous state twice and the current state not at all.

Greedy backtracking

Choosing argmax(delta[t]) at every time step does not recover one connected path. Select the final state once and follow stored predecessors. The result may pass simple tests when emissions dominate, which makes this bug unusually persistent.

Ignoring sequence lengths in a batch

If padded time steps run the recurrence, their arbitrary emissions and transitions can replace the valid terminal score. Use a mask to retain the old score when a sequence has ended, and begin backtracking at each sequence’s last valid position.

Mishandling impossible transitions

For constrained BIO decoding, invalid transitions can be assigned -inf. Be careful when every predecessor of a state is impossible: an entire row may remain -inf. In mixed-precision code, a large finite negative sentinel is sometimes more stable, but it must be low enough never to win.

Forgetting start and stop scores

If the model learned boundary parameters, omitting them at inference changes the model being decoded. Conversely, do not silently add boundaries to a model trained without them.

Relationship between Viterbi and the forward algorithm

Viterbi and the forward algorithm traverse the same dynamic-programming graph. They differ only in how alternative prefixes are combined.

The log-space forward state is

αt(j)=st(j,x)+logsumexpi[αt1(i)+Aij].\alpha_t(j) = s_t(j,\mathbf{x}) + \operatorname{logsumexp}_i \left[ \alpha_{t-1}(i)+A_{ij} \right].

It aggregates the mass of all paths ending in jj. At termination, logsumexp over the final states (plus stop scores) yields logZ(x)\log Z(\mathbf{x}). Viterbi replaces logsumexp with max, retaining only the highest-scoring path:

δt(j)=st(j,x)+maxi[δt1(i)+Aij].\delta_t(j) = s_t(j,\mathbf{x}) + \max_i \left[ \delta_{t-1}(i)+A_{ij} \right].

This is sometimes described using semirings. The forward algorithm operates in the log semiring, where path alternatives combine with logsumexp; Viterbi operates in the max-plus semiring, where they combine with max. Both use addition to extend a path.

The distinction answers two different questions:

  • Forward: what total probability mass do all label sequences contribute?
  • Viterbi: which single label sequence has the greatest score?

Posterior decoding asks yet another question—choosing labels from marginal probabilities—and need not return the MAP sequence. In a constrained label space, independent marginal choices can even form an invalid transition.

Practical checklist

Before trusting a decoder:

  1. document whether AijA_{ij} means iji\to j or jij\to i;
  2. compare against exhaustive enumeration on tiny cases;
  3. test a length-one sequence, where no ordinary transition is used;
  4. test non-zero start and stop scores;
  5. test ties and decide whether deterministic first-index behavior is acceptable;
  6. test batches with different valid lengths;
  7. verify the decoded score by rescoring the returned path directly.

The larger lesson extends beyond CRFs. Dynamic programming becomes possible when many histories share the same future-relevant state. Here that state is simply the last label. Once that equivalence is visible, an exponential search becomes a compact table plus a trail of backpointers.

References

Selected references

  1. Lafferty, McCallum, and Pereira. “Conditional Random Fields: Probabilistic Models for Segmenting and Labeling Sequence Data.” ICML, 2001.
  2. Sutton and McCallum. “An Introduction to Conditional Random Fields.” Foundations and Trends in Machine Learning, 2012.
  3. Viterbi. “Error Bounds for Convolutional Codes and an Asymptotically Optimum Decoding Algorithm.” IEEE Transactions on Information Theory, 1967.
  4. Rabiner. “A Tutorial on Hidden Markov Models and Selected Applications in Speech Recognition.” Proceedings of the IEEE, 1989.