Module 04 — Baum-Welch in Practice
Part I · Foundations & the Inference Engine · Status: Draft v0.1 Scope: EM updates, restarts, convergence checks, and label switching · Prerequisites: Modules 01, 02
Overview
Baum-Welch is the expectation-maximization algorithm for hidden Markov models. It alternates between guessing which hidden path explains the data and updating the model to fit that guess better. Each full cycle is guaranteed not to lower the likelihood.
The catch is the word local. Baum-Welch climbs to the nearest peak, not the highest one. Your starting point decides which peak you reach. This module shows you that failure on the Module 01 toy problem, then gives you the restart and convergence protocol that professionals actually use.
How It Works
Each iteration has two phases. In the E-step you run forward-backward and compute expected counts: how often each state was probably occupied, and how often each state-to-state jump probably happened. In the M-step you normalize those expected counts into fresh transition and emission rows.
Picture it as a census of ghosts. You never see the hidden path, so you take a probabilistic census over all possible paths, then set each parameter to the census average. Recompute the census under the new parameters and repeat until the score barely moves.
The two count tables you actually need
Only two accumulators drive the whole update. The occupancy table holds, for each state and each day, the probability the chain sat there. The jump table holds, for each ordered state pair, the expected number of transitions across the full sequence. Divide each jump row by its row total and you get the next transition matrix. Weight the observations by occupancy and you get the next emission rows.
flowchart TD
A[Start from initial guess] --> B[E-step expected counts]
B --> C[M-step normalize rows]
C --> D[Score log-likelihood]
D --> E{Improved by more than tol?}
E -->|Yes| B
E -->|No| F[Stop and report]How to read this: the loop B-C-D-E is one EM cycle, and the tolerance check at E is what decides convergence.
Convergence theory matters here. Wu (1983) established the general EM convergence behavior, and Balakrishnan et al. (2017) gave local statistical guarantees showing EM-style updates converge when started near a good solution. Neither result promises a global optimum from an arbitrary start. That gap is exactly why restarts exist.
Expected-Count Updates on the Toy Problem
You work the Module 01 toy problem: hidden weather states Rainy and Sunny emitting Walk, Shop, or Clean. The snippet below runs one transparent EM cycle in plain numpy so you can see the census-and-normalize logic with no library hiding it.
import numpy as np
# Toy setup from Module 01: states Rainy/Sunny, obs Walk/Shop/Clean.
obs_seq = np.array([0, 2, 1, 0, 0, 2, 1, 0, 2, 1, 0, 0])
n_states, n_obs = 2, 3
trans = np.array([[0.6, 0.4], [0.3, 0.7]]) # current guess
emit = np.array([[0.4, 0.3, 0.3], [0.3, 0.3, 0.4]])
start = np.array([0.5, 0.5])
# Forward pass with per-step scaling (Module 02 machinery).
n = len(obs_seq)
alpha = np.zeros((n, n_states))
scale = np.zeros(n)
alpha[0] = start * emit[:, obs_seq[0]]
scale[0] = alpha[0].sum()
alpha[0] /= scale[0]
for t in range(1, n):
alpha[t] = (alpha[t - 1] @ trans) * emit[:, obs_seq[t]]
scale[t] = alpha[t].sum()
alpha[t] /= scale[t] # rescale so rows never underflow
The backward pass and the expected-count accumulation follow the same pattern. State occupancy at each time step is the scaled product of forward and backward terms, and pairwise jump counts come from the one-step bridge between them. You then normalize each count row so it sums to one, which yields the next transition and emission matrices.
[!NOTE] You do not hand-roll this in production. hmmlearn wraps the same loop with scaling and stopping logic. This snippet exists so you trust the machinery, not so you replace the library.
Initialization Sensitivity and the Restart Protocol
The same data can lead Baum-Welch to different answers from different starts. The snippet below fits hmmlearn twice on one discrete observation sequence with two different seeds and prints both final scores.
import numpy as np
from hmmlearn.hmm import CategoricalHMM
# One fixed dataset; only the EM start changes between fits.
rng = np.random.default_rng(42)
train_seq = rng.integers(0, 3, size=400).reshape(-1, 1)
scores = {}
for seed in [1, 7]:
fit = CategoricalHMM(
n_components=2, n_iter=100, tol=1e-4, random_state=seed,
)
fit.fit(train_seq)
scores[seed] = round(float(fit.score(train_seq)), 1)
print("log-likelihood by seed:", scores)
Expect the two scores to differ in illustrative runs, sometimes by tens of points, with each seed winning on some datasets. Treat that as illustrative of the phenomenon, not as a measured finding about these exact seeds. The structural lesson holds: different starts reach different local peaks, so a single fit proves nothing.
Your protocol is therefore fixed. Run at least five to ten restarts with different seeds, keep the model with the best training log-likelihood, and report the spread across restarts alongside the winner. A tight spread means a stable peak. A wide spread means your data admits several stories and you must say so.
Reading the spread honestly
Suppose your eight restarts return scores spanning 40 points of log-likelihood. That gap is not numerical dust; it represents genuinely different explanations of the same data, perhaps one where the states split rainy-versus-sunny behavior and another where they split frequent-versus-rare observation habits. Your job is to report the winner, name the runner-up pattern in one sentence, and flag the dataset as contested. Reviewers trust a winner picked from a reported spread far more than a winner presented as the only fit.
[!TIP] Fix one seed per restart and log it. A restart protocol you cannot reproduce is just expensive randomness.
Convergence Checks and Label Switching
Two stopping knobs control every fit: the tolerance and the iteration cap. Training stops when the log-likelihood improves by less than the tolerance, or when the iteration cap is hit. Always inspect which one fired. Tolerance-triggered stops signal genuine convergence. Cap-triggered stops mean the run was cut off mid-climb and needs a larger budget.
Label switching is the second trap. State labels are arbitrary, so seed 1 may call the rainy pattern state 0 while seed 7 calls it state 1. Never compare raw state 0 across runs. Re-anchor first: sort states by a stable signature such as the fitted emission order, then compare. The snippet below shows the standard reorder.
Pick a signature that separates your states on substance. For the toy problem the Walk probability works because rainy and sunny days differ most there. For the Module 03 return series you sort by fitted spread instead. The rule is the same either way: choose the signature before you look at the runs, apply it uniformly, and document it so the next reader reproduces your ordering.
# Re-anchor: order states by P(Walk) so runs become comparable.
order = np.argsort(fit.emissionprob_[:, 0])[::-1]
anchored_emit = fit.emissionprob_[order]
anchored_trans = fit.transmat_[np.ix_(order, order)]
print("anchored emission rows:\n", np.round(anchored_emit, 3))
[!WARNING] An unconverged run plus unsorted labels is how phantom regime differences are born. Check the convergence flag, then re-anchor, then interpret, in that order.
Testing Your Implementation
You verify four things. First, your from-scratch forward pass agrees with hmmlearn’s score on the toy sequence within a small tolerance. Second, two different seeds produce log-likelihoods that are not bit-identical, demonstrating the local-optima phenomenon. Third, a five-restart protocol picks a winner at least as good as any single seed. Fourth, your re-anchoring snippet maps both seeds onto the same emission ordering.
Hands-On Project
Build a restart harness you will reuse for the rest of the curriculum. Fit the Module 01 toy problem with eight different seeds, record each seed’s final log-likelihood and convergence flag, and plot or tabulate the spread.
- Run eight CategoricalHMM fits with seeds 1 through 8 on one fixed sequence.
- Record the winner, the worst run, and the gap between them.
- Re-anchor all eight emission matrices by sorting on the Walk column.
- Write one paragraph: is this dataset’s peak stable or contested, and what would you report to a skeptical reviewer?
- Rerun the harness with the tolerance tightened tenfold and note which seeds change their winner. Seeds that flip under a tighter tolerance were never converged in the first place.
Save the harness script itself, not just its output. You will point it at Gaussian data in Module 06 and at real diagnostics in Part II, and an undocumented harness rewrite each time is how restart discipline silently dies.
Key Takeaways
- Baum-Welch alternates expected counts (E-step) with row normalization (M-step).
- Likelihood never decreases across a full EM cycle, but the peak is local.
- Different initializations can converge to genuinely different solutions.
- The restart protocol is five to ten seeded fits; keep the best likelihood.
- Convergence means the tolerance fired, not the iteration cap.
- State labels are arbitrary; re-anchor by fitted emission order before comparing.
- Report the spread across restarts, not just the winning score.
References
- Wu, C. F. J. (1983). On the convergence properties of the EM algorithm. Annals of Statistics, 11(1). https://doi.org/10.1214/aos/1176346060 — role: Mechanics. Retrieved 2026-09-17.
- Balakrishnan, S., Wainwright, M. J., & Yu, B. (2017). Statistical guarantees for the EM algorithm. JMLR, 18. https://www.jmlr.org/papers/v18/16-542.html — role: Mechanics. Retrieved 2026-09-17.
- hmmlearn developers. hmmlearn documentation (CategoricalHMM, convergence monitoring). https://hmmlearn.readthedocs.io/ — role: Mechanics. Retrieved 2026-09-17.
- Exclusion: GeeksforGeeks-style EM tutorials — prove the teaching pattern, not production fitting practice.