fit_gmm_nhmm extends NHMM (covariate-dependent transitions) with GMM
emissions per state: each regime hosts a Gaussian mixture, capturing
internal sub-modes that a single Gaussian smooths out.
This is Phase A.10, shipped 2026-05-22. The implementation strategy
(2-stage decomposition) and its rejected alternative (K·M joint-state
expansion) are documented in
ADR-0007 and in the
A.10 spec.
When a regime has internal sub-modes that you want to keep separate without inflating the number of macro states.
Classic crypto motivation:
- A bear regime mixes two qualitatively different behaviours: slow grinding decline and panic flash crash. They share the same "downside risk" macro label, but their volatility and tail shape differ.
- A bull regime similarly mixes smooth uptrend and FOMO spike. Same regime, different micro-dynamics.
Two ways to model this with a vanilla single-Gaussian NHMM, both bad:
| Approach | Failure mode |
|---|---|
| One Gaussian per regime (K=2) | Bear/bull means are an average over sub-modes; volatility is smeared; tail behaviour is lost. |
| One regime per sub-mode (K=4) | Transitions within a regime (smooth-bull → FOMO-bull) get modelled as macro regime switches, polluting the transition matrix. |
GMM-NHMM keeps K=2 macro regimes, modulated by your covariates, and
gives each regime a n_mix=2 Gaussian mixture. The macro transitions
mean what you want them to mean; the sub-modes live one level down.
GMM-NHMM is fit via 2-stage decomposition (Stratégie A from the spec):
-
Stage 1 — fit a standard GMM-HMM (
hmmlearn.GMMHMM) onXwith homogeneous transitions. This gives the mixture parameters${w_{km}, \mu_{km}, \Sigma_{km}}$ and a Viterbi macro path. -
Stage 2 — for each source state
$i$ , fit a multinomial logistic regression of the next macro state on the covariatesZ, using the Viterbi(z_t, z_{t+1})pairs as supervision. This yields the covariate-dependent transition matrix$A_{ij}(Z_t)$ .
The joint EM alternative (which would treat the model as a single HMM
on
def fit_gmm_nhmm(
topology: Topology,
X: np.ndarray,
Z: np.ndarray,
*,
covariate_names: list[str],
seed: int | None = None,
lengths: np.ndarray | None = None,
min_transitions: int = 10,
regularization: float = 1.0,
) -> GMMNHMMFittedModel: ...-
topology.emission.typemust be"gmm".n_mixis the number of Gaussian components per state. -
Xis shape(T, n_features). -
Zis shape(T, P). EachZ[t]drives the row-stochastic$A_{ij}(Z_t)$ at time$t$ . -
min_transitionscontrols Stage-2 robustness: any source state with fewer than this many observed transitions falls back to the Stage-1 homogeneous transmat row (rather than fitting a degenerate logit on too few points). -
regularizationis the inverse L2 strengthCforsklearn.linear_model.LogisticRegression.
from hmm_core.topology import Topology, EmissionSpec, FitSpec, InitSpec
from hmm_core.gmm_nhmm import fit_gmm_nhmm
topo = Topology(
name="btc_regimes_with_submodes",
n_states=2,
state_names=["calm", "volatile"],
emission=EmissionSpec(
type="gmm",
covariance_type="diag",
n_features=2,
n_mix=2, # M = 2 sub-modes per regime
),
allowed_transitions=None, # ergodic macro transitions
startprob="uniform",
init=InitSpec(strategy="kmeans", seed=42),
fit=FitSpec(algorithm="baum_welch", n_iter=100, tol=1e-4),
)result = fit_gmm_nhmm(
topo,
X,
Z, # shape (T, P)
covariate_names=["vol_proxy"],
seed=42,
)The result is a GMMNHMMFittedModel dataclass:
| Attribute | Shape / type | Meaning |
|---|---|---|
base |
FittedModel |
Stage-1 GMM-HMM (use for .model.means_, .model.weights_, .bic, .log_likelihood). |
A_t |
(T, K, K) |
Covariate-dependent transitions; row-stochastic, mask-respected. |
classifiers |
dict[int, LogisticRegression] |
Stage-2 logit per source state. |
fallback_rows |
dict[int, ndarray] |
Homogeneous row used when Stage-2 had too few transitions for that source. |
covariate_names |
list[str] |
Echoed for traceability. |
result.A_at(t) # K x K matrix at time t
result.n_states # K
result.n_mix # MA_at(t) falls back to the base homogeneous transmat for t outside
[0, T), which is occasionally useful for warm-starting downstream
analyses.
fallback_rows will be populated for any source state where:
- fewer than
min_transitions(Z_t, z_{t+1})pairs were observed, or - the observed next-state set has < 2 distinct values (logistic regression needs at least two classes).
The semantics are: that source row of result.fallback_rows.keys() to see which sources fell back.
!!! tip "When fallbacks fire, you usually want either more data or fewer states"
Frequent fallbacks usually signal a state that's barely visited.
Either lengthen the sequence or reduce K.
The dataclass has a Jupyter _repr_html_ that displays:
- A stats table (
K,M,T, covariates, fitted classifiers count, log-likelihood, BIC). - The time-averaged transition matrix heatmap.
- A per-regime sub-mode breakdown (weights and means).
Just evaluate result in a notebook cell.
resultFor a worked example with the 2-regime × 2-sub-mode synthetic data,
see notebooks/05_gmm_nhmm_submodes.ipynb.
GMM-NHMM has more parameters than a single-Gaussian NHMM. BIC is the right tool to decide whether the extra mixture components pay off:
from hmm_core.nhmm import fit_nhmm
result_gmm = fit_gmm_nhmm(topo_gmm, X, Z, covariate_names=["vol_proxy"])
result_gauss = fit_nhmm(topo_gaussian, X, Z, covariate_names=["vol_proxy"])
print(f"GMM-NHMM BIC : {result_gmm.base.bic:.1f}")
print(f"Gaussian NHMM BIC : {result_gauss.base.bic:.1f}")If the data genuinely has sub-modes, GMM-NHMM should win on BIC despite the parameter penalty. If it doesn't, that's a signal that your regimes are already uni-modal in your features.
These are the identifiability limits called out in the spec; the code does not hard-enforce them, but going past them is asking for trouble:
-
$K \leq 4$ . Beyond that the macro regimes are hard to identify even with rich covariates. -
$M \leq 3$ . Beyond that the within-regime components become indistinguishable. -
$P \leq 6$ covariate columns. Each Stage-2 logit has$K \cdot P$ free coefficients per source; running thin on transitions makes them unstable. -
$T \gtrsim 200 \cdot \text{free_params}$ rule of thumb for stability.
Label switching (state permutation across re-fits) is not handled
inside fit_gmm_nhmm itself. If you re-fit with a different seed and
get permuted state labels, post-process by sorting states on (e.g.)
- Phase A.10 spec — full math derivation, EM equations, history of the rejected Strategy A.
- ADR-0007 — why 2-stage, why no joint EM in MVP.
- Factorial NHMM guide — the same 2-stage pattern applied to independent regime dimensions instead of internal sub-modes.
notebooks/05_gmm_nhmm_submodes.ipynb— runnable end-to-end with synthetic 2×2 data.