Module 07 — hmmlearn: The Workhorse
Part II · Tooling & Evaluation · Status: Draft v0.1 Scope: Fitting Gaussian HMMs with hmmlearn, reproducibly · Prerequisites: Modules 03, 04, 05
Overview
This module turns the Module 03 sketch into a repeatable workflow. You learn the hmmlearn estimator the way you learned a scikit-learn estimator: construct it, fit it, then ask it for states and scores.
You also learn its two guardrails. One is the variance floor that stops a Gaussian state from collapsing onto a single point. The other is the convergence monitor that tells you whether training actually finished. By the end you can fit the shared synthetic series and write a reproduction card a stranger could re-run.
How It Works
hmmlearn follows the scikit-learn estimator contract. You construct a model object with hyperparameters, call fit on a 2D observation array, and then query the fitted object. Fitting runs Baum-Welch (EM) under the hood; the object stores the learned start vector, transition matrix, means, and covariances as trailing-underscore attributes.
Four query methods cover almost everything you need. Predict returns the single most likely state path (Viterbi). Predict-proba returns per-time-step state probabilities (forward-backward posteriors).
Score-samples returns per-step log likelihoods plus the total. Sample generates fresh synthetic bars from a fitted model, which is handy for simulation checks.
flowchart TD
A[Build GaussianHMM] --> B[Call fit on returns]
B --> C[Check monitor converged]
C --> D[Predict path and proba]
D --> E[Score and compare]
E --> F[Write reproduction card]How to read this: the chain runs top to bottom, and you stop at C if training did not converge. No downstream number is trustworthy until the monitor says the fit finished.
The sklearn-Style API on the Shared Series
You build the model with four decisions: two states, diagonal covariances, a cap on iterations, and a tolerance for stopping. The constructor also takes explicit start, transition, and emission parameters when you want to control initialization instead of accepting the random default.
The snippet below fits the shared synthetic series from Module 03. It uses the exact recipe: seed 42, 2000 bars, true daily spreads of 0.005 and 0.02 with sticky transitions. Reshape the returns to two dimensions first, because hmmlearn rejects flat 1D input.
import numpy as np
from hmmlearn.hmm import GaussianHMM
# Same shared series as Module 03: seed 42, 2000 bars.
rng = np.random.default_rng(42)
n_bars = 2000
trans_true = np.array([[0.97, 0.03], [0.08, 0.92]])
state_vol = np.array([0.005, 0.02])
hidden = np.zeros(n_bars, dtype=int)
returns = np.zeros((n_bars, 1))
for day in range(1, n_bars):
hidden[day] = rng.choice(2, p=trans_true[hidden[day - 1]])
returns[day, 0] = rng.normal(0.0, state_vol[hidden[day]])
# random_state=42 fixes the EM start; tol stops wasted iterations.
model = GaussianHMM(
n_components=2, covariance_type="diag", n_iter=100,
tol=1e-4, random_state=42, reg_covar=1e-4,
)
model.fit(returns)
fitted_vol = np.sort(np.sqrt(model.covars_.ravel()))
print("fitted vols:", np.round(fitted_vol, 4))
print("converged:", model.monitor_.converged)
The printed pair should bracket the truth: one spread near 0.005 and one near 0.02. Sort before comparing, because label order is arbitrary. Decode with model.predict for the Viterbi path and model.predict_proba for per-day state probabilities; score new bars with model.score_samples.
[!TIP] Pass startprob_prior, transmat_prior, means_prior, and covars_prior when you want a Bayesian-flavored initialization. Pass startprob_, transmat_, means_, and covars_ only when you want to fix the exact starting point, for example to restart from a previous fit.
Collapse Protection and Convergence
A Gaussian state can cheat. If its mean lands exactly on one observation and its spread shrinks toward zero, the likelihood explodes while the model learns nothing. Module 05 explains the pathology; here you apply the fix, which is the reg_covar floor.
Setting reg_covar adds a small constant to the diagonal of every covariance at each EM step. The default is 1e-4, which is reasonable for returns scaled as decimals. If you rescale returns to percentages, rescale your thinking too: the same floor means something different on differently scaled data.
# Compare a weak floor against the default on the same data.
weak = GaussianHMM(
n_components=2, covariance_type="diag", n_iter=100,
tol=1e-4, random_state=42, reg_covar=1e-8,
).fit(returns)
print("weak floor vols:", np.round(np.sort(
np.sqrt(weak.covars_.ravel())), 4))
print("weak converged:", weak.monitor_.converged)
print("iterations used:", len(model.monitor_.history))
Convergence lives on model.monitor_. The converged flag tells you whether EM stopped because the log-likelihood gain fell below tol rather than because it hit n_iter. The history list records the per-iteration scores, so you can plot the climb and spot a fit that plateaued early versus one that was still climbing when iterations ran out.
[!WARNING] A converged flag of False means the numbers are provisional. Raise n_iter, loosen tol, or restart from a new random_state before you interpret anything.
What hmmlearn Is and Is Not
hmmlearn version 0.3.3 was released 2024-10-31 under a BSD-3 license, and the project describes itself as in limited-maintenance mode. That status matters for planning: expect stability and bug fixes, not new model families or GPU support.
What you get is the NumPy/Cython workhorse for small-to-medium problems: Gaussian, multinomial, and mixture emissions with a scikit-learn API.
What you do not get is GPU training, minibatch stochastic training, or a variational-Bayes model selector beyond provisional helpers. When you need those, Module 08 shows where to go.
| Need | hmmlearn answer |
|---|---|
| 2 to 4 Gaussian states, thousands of bars | Yes, the sweet spot |
| Exact start control for restarts | Yes, via init params |
| GPU or very long sequences | No, see Module 08 |
| Automatic state-count choice | No, see Module 06 |
[!NOTE] Limited maintenance is not abandonment. It means the API you learn here is stable, and your reproduction cards stay valid longer.
Testing It Honestly
Version pinning comes first. Record hmmlearn==0.3.3 alongside your NumPy and scikit-learn versions, because EM numerics can shift across dependency releases. Seed discipline comes second: seed both the data generator (default_rng(42)) and the estimator (random_state=42), and note that one seed covers data while the other covers optimization.
Verify three checkable outputs. The sorted fitted spreads should straddle the truth, one below 0.01 and one above 0.012. The monitor should report converged True within 100 iterations. Refitting with a second seed should recover the same sorted spreads within a few basis points; a large swing flags the local-optima problem from Module 04, not a data problem.
Your reproduction card needs four lines: data recipe (seed 42, 2000 bars, vols 0.005/0.02), library versions, random seeds, and the two fitted spreads plus the converged flag. If any line is missing, the result is anecdote, not evidence.
A final habit separates professionals from dabblers. Save the fitted transition matrix and means alongside the spreads, because a later module that reloads only the spreads cannot reconstruct the decoded path. Disk is cheap; irreproducible state paths are expensive.
Worked-number sanity check you can do in your head: the true calm share is roughly 0.73, so about 1460 of 2000 bars. If your decoded stressed share lands between 0.15 and 0.40, the fit is in the right neighborhood. Outside that band, suspect the initialization before you suspect the data.
Hands-On Project
Fit, verify, and document the shared series end to end with hmmlearn only.
- Regenerate the shared series with seed 42 and confirm 2000 rows and one column.
- Fit the Module 03 snippet and record the sorted spreads and the converged flag.
- Refit with random_state=7 and random_state=123; record how far the sorted spreads move.
- Plot monitor_.history and note the iteration where gains flatten.
- Try n_init-style manual restarts: keep the best of five seeds by total score and report the winner.
- Write the four-line reproduction card and save it next to the fitted spreads.
Success looks like two spreads near 0.005 and 0.02, converged True, and seed-to-seed movement under a few basis points. Larger movement sends you back to Module 04 for multi-restart protocol before you trust any downstream claim.
Key Takeaways
- hmmlearn mirrors scikit-learn: construct, fit on 2D data, then predict, predict_proba, score_samples, or sample.
- Version 0.3.3 (2024-10-31, BSD-3) is in project-declared limited maintenance: stable, not expanding.
- reg_covar floors each covariance diagonal and blocks single-point collapse.
- monitor_.converged plus history tells you whether EM finished or just ran out of iterations.
- Control initialization with startprob, transmat, and emission init params when the default start misbehaves.
- Every fit ships with a reproduction card: data recipe, versions, seeds, and checkable outputs.
- GPU training and variational selection live outside hmmlearn; Module 08 covers them.
References
- hmmlearn developers. hmmlearn documentation and changelog (GaussianHMM API, reg_covar, ConvergenceMonitor). https://hmmlearn.readthedocs.io/ — role: Mechanics. Retrieved 2026-09-17.
- hmmlearn developers. hmmlearn 0.3.3 release notes (2024-10-31, BSD-3 license, limited-maintenance statement). https://github.com/hmmlearn/hmmlearn/releases — role: Mechanics. Retrieved 2026-09-17.
- Rabiner, L. R. (1989). A tutorial on hidden Markov models. Proc. IEEE, 77(2). https://courses.physics.illinois.edu/ece417/fa2017/rabiner89.pdf — role: Mechanics. Retrieved 2026-09-17.
- Bishop, C. M. (2006). Pattern Recognition and Machine Learning, Section 9.2.2 (Gaussian collapse and variance floors). https://www.microsoft.com/en-us/research/people/cmbishop/ — role: Background. Retrieved 2026-09-17.
- Exclusion: auto-generated DeepWiki summaries of the hmmlearn repo — rejected as secondary inference; the project’s own docs and release notes govern version and maintenance facts.