CRF Training › The Forward-Backward Algorithm

Michael BrenndoerferUpdated May 4, 202510 min read
Language AI HandbookAll chapters
Part 6 of 67Sequence Labeling
Chapter 43CRF Training › The Forward-Backward Algorithm
Reading section 3 of 11 · The Forward-Backward Algorithm · 10 minSection 3 of 11 · 10 min

Book contents

Section 3 of 11 · 10 min read

CRF Training › The Forward-Backward AlgorithmOne section · Sections in this chapter3 / 11 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Computing the partition function Z(x)Z(\mathbf{x}) by enumerating all sequences is intractable. The forward-backward algorithm exploits the sequential structure of the model to compute it in polynomial time.

The key insight is that the CRF score decomposes into local terms. At each position tt, the contribution depends only on the label at tt and the label at t−1t-1. This Markov property enables dynamic programming: we can build up the sum over all sequences incrementally, position by position.

Think of the forward-backward algorithm as a two-pass bookkeeping system for computing partial sums efficiently. The forward pass sweeps left to right, and at each position it records the total score of all partial paths that end at that position with each possible label. The backward pass sweeps right to left and records the total score of all partial paths that extend from each position to the end. When you multiply the forward score at a node with its backward score, you get the total score of all complete paths that pass through that node. This product is proportional to the marginal probability of being in that state at that position.

The algorithm draws its name and structure from the forward-backward algorithm for Hidden Markov Models, which you may have encountered in the previous chapter on HMMs. The CRF version works the same way conceptually, but in log space to avoid numerical underflow when sequences grow long. In practice, working in log space converts multiplications into additions and uses the log-sum-exp trick to combine scores safely. The transition from probability space to log space is a consistent theme in modern sequence modeling, and it applies here with the same motivation: multiplying many small probabilities together quickly produces values too tiny for floating-point arithmetic to represent accurately.

Dynamic Programming and the Principle of Optimal Substructure

The forward-backward algorithm is an application of dynamic programming, which works whenever a problem has optimal substructure: the solution to a larger problem can be built from solutions to smaller subproblems. Here, the key observation is that summing over all paths through a trellis can be decomposed into summing over paths to each intermediate state and then combining those partial sums. This is exactly the same structure that makes the Viterbi algorithm efficient for finding the single best path. The forward-backward algorithm is essentially Viterbi's cousin, computing the sum over all paths rather than the maximum.

Forward Variables

Think of the forward variable as an accumulator. As you sweep through the sequence from left to right, the forward variable at each position records everything you need to know about paths that have arrived at each possible label state. You never need to remember which specific path got you there, only the total aggregated score of all such paths. This is the essence of dynamic programming: discard the details, keep the aggregate.

The forward variable αt(j)\alpha_t(j) represents the sum of scores for all partial label sequences that end with label jj at position tt:

αt(j)=∑y1:t:yt=jexp⁡(∑t′=1t∑kλkfk(yt′−1,yt′,x,t′))\alpha_t(j) = \sum_{\mathbf{y}_{1:t}: y_t = j} \exp\left(\sum_{t'=1}^{t} \sum_{k} \lambda_k f_k(y_{t'-1}, y_{t'}, \mathbf{x}, t')\right)

where:

  • y1:t\mathbf{y}_{1:t}: all possible label sequences from position 1 to tt
  • yt=jy_t = j: constrained to end with label jj
  • The summation accumulates scores of all paths reaching state jj at time tt

We compute forward variables recursively:

Base case (t = 1):

α1(j)=exp⁡(∑kλkfk(y0,j,x,1))\alpha_1(j) = \exp\left(\sum_{k} \lambda_k f_k(y_0, j, \mathbf{x}, 1)\right)

where y0y_0 is a special start symbol.

Recursive case:

αt(j)=∑i∈Lαt−1(i)⋅exp⁡(∑kλkfk(i,j,x,t))\alpha_t(j) = \sum_{i \in L} \alpha_{t-1}(i) \cdot \exp\left(\sum_{k} \lambda_k f_k(i, j, \mathbf{x}, t)\right)

This recursion says: to get the total score of paths ending in jj at position tt, sum over all possible previous labels ii, multiplying the forward score at t−1t-1 (all paths ending in ii) by the transition score from ii to jj.

Notice that the recursion reuses the previous time step's forward variables rather than recomputing everything from scratch. Each αt(j)\alpha_t(j) requires only the ∣L∣|L| values from αt−1(⋅)\alpha_{t-1}(\cdot), so the total storage is O(T⋅∣L∣)O(T \cdot |L|) and the total computation is O(T⋅∣L∣2)O(T \cdot |L|^2): for each of TT positions and each of ∣L∣|L| possible current labels, we sum over ∣L∣|L| possible previous labels. This is a dramatic improvement over the O(∣L∣T)O(|L|^T) cost of brute-force enumeration.

The partition function is simply the sum of all forward variables at the final position:

Z(x)=∑j∈LαT(j)Z(\mathbf{x}) = \sum_{j \in L} \alpha_T(j)

Backward Variables

The backward variable is symmetric to the forward variable but runs in the opposite direction. While the forward variable answers "what is the total score of all paths that reach state ii at position tt coming from the left?", the backward variable answers "what is the total score of all paths that extend from state ii at position tt to the right end of the sequence?" Together, a forward value and a backward value bracket a specific state at a specific position, and their product gives the total score of all complete paths that pass through that state.

The backward variable βt(i)\beta_t(i) represents the sum of scores for all partial sequences starting from position t+1t+1, given that position tt has label ii:

βt(i)=∑yt+1:Texp⁡(∑t′=t+1T∑kλkfk(yt′−1,yt′,x,t′))\beta_t(i) = \sum_{\mathbf{y}_{t+1:T}} \exp\left(\sum_{t'=t+1}^{T} \sum_{k} \lambda_k f_k(y_{t'-1}, y_{t'}, \mathbf{x}, t')\right)

where yt=iy_t = i is fixed.

Base case (t = T):

βT(i)=1for all i\beta_T(i) = 1 \quad \text{for all } i

Recursive case:

βt(i)=∑j∈Lβt+1(j)⋅exp⁡(∑kλkfk(i,j,x,t+1))\beta_t(i) = \sum_{j \in L} \beta_{t+1}(j) \cdot \exp\left(\sum_{k} \lambda_k f_k(i, j, \mathbf{x}, t+1)\right)

Computing Marginal Probabilities

The forward and backward variables together give us the probability of any label at any position. In practice, this is why we run both passes: the forward pass alone gives us the partition function, but both passes together give us the full set of marginal probabilities that we need for gradient computation. The posterior marginal probability of label jj at position tt is:

P(yt=j∣x)=αt(j)⋅βt(j)Z(x)P(y_t = j | \mathbf{x}) = \frac{\alpha_t(j) \cdot \beta_t(j)}{Z(\mathbf{x})}

And the probability of a specific transition from ii to jj at positions t−1t-1 to tt:

P(yt−1=i,yt=j∣x)=αt−1(i)⋅Mt(i,j)⋅βt(j)Z(x)P(y_{t-1} = i, y_t = j | \mathbf{x}) = \frac{\alpha_{t-1}(i) \cdot M_t(i, j) \cdot \beta_t(j)}{Z(\mathbf{x})}

where Mt(i,j)=exp⁡(∑kλkfk(i,j,x,t))M_t(i, j) = \exp\left(\sum_k \lambda_k f_k(i, j, \mathbf{x}, t)\right) is the transition score matrix at position tt.

Let's implement the forward-backward algorithm:

In[4]:
Code
import numpy as np


def forward_backward(log_potentials, transition_scores):
    """
    Compute forward and backward variables for a CRF.

    Args:
        log_potentials: Array of shape (T, num_labels) with emission scores
        transition_scores: Array of shape (num_labels, num_labels) with transition scores

    Returns:
        alpha: Forward variables (T, num_labels)
        beta: Backward variables (T, num_labels)
        log_Z: Log partition function
    """
    T, num_labels = log_potentials.shape

    # Forward pass (in log space for numerical stability)
    log_alpha = np.zeros((T, num_labels))
    log_alpha[0] = log_potentials[0]

    for t in range(1, T):
        for j in range(num_labels):
            # Sum over all previous labels
            scores = (
                log_alpha[t - 1]
                + transition_scores[:, j]
                + log_potentials[t, j]
            )
            log_alpha[t, j] = np.logaddexp.reduce(scores)

    # Backward pass
    log_beta = np.zeros((T, num_labels))
    log_beta[T - 1] = 0  # log(1) = 0

    for t in range(T - 2, -1, -1):
        for i in range(num_labels):
            # Sum over all next labels
            scores = (
                log_beta[t + 1]
                + transition_scores[i, :]
                + log_potentials[t + 1]
            )
            log_beta[t, i] = np.logaddexp.reduce(scores)

    # Partition function
    log_Z = np.logaddexp.reduce(log_alpha[T - 1])

    return log_alpha, log_beta, log_Z
In[5]:
Code
# Example: 5-position sequence with 3 labels (O, B-PER, I-PER)
np.random.seed(42)
T, num_labels = 5, 3
label_names = ["O", "B-PER", "I-PER"]

# Emission scores (from features)
log_potentials = np.random.randn(T, num_labels)

# Transition scores (learned)
transition_scores = np.random.randn(num_labels, num_labels)
# Make I-PER after O unlikely (BIO constraint approximation)
transition_scores[0, 2] = -5  # O -> I-PER

log_alpha, log_beta, log_Z = forward_backward(log_potentials, transition_scores)

# Compute marginal probabilities
log_marginals = log_alpha + log_beta - log_Z
marginals = np.exp(log_marginals)
Out[6]:
Console
Forward-Backward Algorithm Results
==================================================

Sequence length: 5
Number of labels: 3
Log partition function: 4.9991
Partition function Z(x): 148.2794

Marginal probabilities P(y_t = j | x):
--------------------------------------------------
Position             O       B-PER       I-PER
--------------------------------------------------
1               0.2108      0.3561      0.4332
2               0.5345      0.1528      0.3127
3               0.3839      0.5196      0.0965
4               0.3917      0.1740      0.4343
5               0.7945      0.0880      0.1175

Notice how the marginals sum to 1.0 at each position. This is because they represent the probability distribution over labels at that position, marginalized over all possible label sequences. The forward-backward algorithm computes these marginals in O(T⋅∣L∣2)O(T \cdot |L|^2) time, compared to O(∣L∣T)O(|L|^T) for brute-force enumeration.

In practice, a few details require care. First, the raw forward and backward variables are products of many numbers less than one, so they underflow to zero for long sequences. Implementing everything in log space, as the code above does using np.logaddexp, avoids this problem. Second, the log-sum-exp computation must be done carefully: naively exponentiating large scores before summing can overflow. The standard trick is to subtract the maximum value before exponentiating, compute the sum, then add the maximum back. NumPy's logaddexp handles this internally.

The implementation above uses a simple nested loop that is easy to read but not fast. Real CRF libraries vectorize this computation using matrix operations: the recursion αt=logsumexp(αt−1+transition_scores,axis=0)+log_potentialst\alpha_t = \text{logsumexp}(\alpha_{t-1} + \text{transition\_scores}, \text{axis}=0) + \text{log\_potentials}_t can be computed as a matrix-vector product in log space, reducing the constant factor significantly without changing the asymptotic complexity.

Out[7]:
Visualization
Trellis diagram showing forward and backward probability flows for a 5-position sequence with 3 labels.
Forward-backward computation visualized as a trellis. Forward variables (α) flow left to right, accumulating path scores. Backward variables (β) flow right to left. At each state, the product α·β/Z gives the marginal probability. The heat intensity shows the posterior probability of each state.

The trellis visualization shows how forward and backward passes combine. Each node's color intensity represents its marginal probability. States with high marginal probability (darker blue) are more likely to be part of the optimal sequence.