hmm(K, family) fits a K-state hidden Markov model: the response at
each time point is drawn from one of K state-dependent copies of
family, and the unobserved state follows a first-order Markov chain
along time within group. The state sequence is summed out exactly
by the forward algorithm, which is evaluated on the same AD tape as
everything else, so random effects, smooths and distributional
predictors compose with it unchanged.
Arguments
- K
Number of hidden states (at least 2, at most 9 - beyond that the
tr{i}{j}dpar names stop being unambiguous).- family
The state-dependent (emission) family. Any univariate family with a
muparameter works, plusfrmtmb::multinomial()for categorical emissions. Ordinal families, mixtures and nestedhmm()components are refused.- time
Variable giving the order of observations within a sequence, as a bare name or a one-sided formula. Omit it to use each row's position in the data.
- group
Variable identifying the sequences, as a bare name or a one-sided formula. Omit it to treat the whole data set as one sequence.
- init
Initial-state distribution:
"stationary"(the default),"estimated", or"uniform".- trans
Default one-sided formula for every transition cell.
Parameters
Each of the wrapped family's distributional parameters is copied once
per state and suffixed with the state index, exactly as frmtmb::mixture()
does: hmm(2, gaussian()) has mu1, mu2, sigma1, sigma2. The
main model formula applies to every state's location parameter;
override one state with bf(y ~ x, mu2 ~ x + (1 | id)). Every dpar
takes the full formula grammar, random effects included.
Transition probabilities are a row-wise multinomial logit with
state 1 as the reference cell in every row, named tr{i}{j} for
the move from state i to state j (j >= 2): a two-state chain has
tr12 and tr22. This is depmixS4's parameterization, and the
coefficients agree with it to four decimals (dev/hmm-feasibility.md,
probe B1). hmmTMB and moveHMM instead reference the diagonal;
the two span the same model and only the reported coefficients differ.
trans gives every transition cell the same default formula; a single
cell is overridden the ordinary way, bf(y ~ 1, tr12 ~ x). The
covariate value at time t drives the transition from t to
t + 1, so a sequence's last row never contributes a transition -
again depmixS4's convention.
Initial distribution
"stationary"The stationary distribution of the transition matrix, solved on the tape. Costs no parameters and is the right default for long sequences and for many short ones. It needs a CONSTANT transition matrix, so it is refused when any transition dpar carries a predictor.
"estimated"K - 1free logits (state 1 the reference), reported ashmm_ldel_1 ...and counted indf."uniform"Fixed at
1 / K.
"estimated" estimates ONE initial distribution shared across every
sequence. This is worth knowing before comparing with hmmTMB,
whose initial_state = "estimated" estimates a SEPARATE one per
sequence. On 20 sequences of 100 rows that is 38 extra parameters on
hmmTMB's side and 19.678 log-likelihood units, with nothing in
either package's output saying the two fits are of different models.
(Against init = "uniform" here the same hmmTMB fit is 21.231 units
higher, because uniform costs frmtmb its own two initial-distribution
parameters as well; the two figures differ by that 1.553 and it is
the first that belongs to the sentence above.)
Pin the initial distribution on both sides before comparing.
init = "uniform" here against fixpar = list(delta0 = <all NA>)
there makes the two likelihoods the same function, and they then
agree to 1e-11 relative (dev/latent-2p3-hmmtmb-probe4.R).
Decoding and the fitted values
E[y_t] under an HMM is sum_k P(S_t = k | y) mu_k(x_t), which needs
the smoothed state probability and therefore a backward pass over the
whole sequence. hmm_probs() returns those probabilities and
hmm_viterbi() the maximum-a-posteriori state path; fitted(),
frm_linpred(type = "response") and residuals() all route through
hmm_probs(), so they report the occupancy-weighted mean rather than
any single state's.
Label switching and local optima
The likelihood is invariant to permuting the states, so the state
means start at spread response quantiles (see frmtmb::mixture()); a start
with every state mean equal sits on the symmetry axis and the
optimizer never leaves it. Relabeling between runs is expected and is
not fought.
Multimodality is real and NO convergence diagnostic reports it. On a
two-state model with a random intercept in each state mean, 25
sequences of 30, the default cold start converges to
-1096.09575602 where the optimum is -1087.99646521: 8.099
log-likelihood units below it, with convergence == 0, a positive
definite Hessian, no non-finite standard error, a largest gradient
of 7.00028e-04 (6.4e-07 of the log-likelihood), and diagnose()
printing "No convergence problems detected". That last clause is
true of this fit by a factor of 1.43: diagnose()'s clean verdict
needs max|grad| under 1e-3 ABSOLUTE, and a construction whose
gradient landed just the other side of that would get a warning
instead. What is 8.099 units wrong here is the answer, not the
gradient, and no threshold on a gradient can see that. The optimum is confirmed
independently by a hand-rolled MakeADFun(random =) and by hmmTMB
to the last digit (dev/hmm-feasibility.md, probe D4;
dev/latent-2p3-repro81.R reproduces it against this family).
hmm_starts() is the remedy: it refits from jittered starting
values, returns the best, and reports the spread of the OPTIMA the
refits reached. On that probe, hmm_starts(fit, n = 8, jitter = 2)
recovered the better optimum on 5 of 5 seeds, and so did jitter = 1, 4 and 8; jitter = 0.5 recovered it on 1 of 5, which is
what the default of 2 is chosen from.
frm_allfit() is a different check, and on this probe it is a
reassuring one: it re-runs every optimizer from the SAME start, so it
tests the optimizer rather than the surface. All four (nlminb, optim,
bobyqa, NLopt L-BFGS) reach -1096.096 with convergence == 0 and a
log-likelihood spread of 7.8e-07, which is agreement on the wrong
answer (dev/latent-2p3-allfit.R).
Recovery at a realistic scale
Measured at the design named in the realistic-scale table of
dev/extension-gaps-plan.md: 50 sequences of 500 steps, 25 000 rows,
K = 3 gaussian with state means -2, 0 and 3 and a common standard
deviation of 0.7, and a transition matrix whose free logits are
tr12 -1.5, tr13 -2.5, tr22 2.0, tr23 -1.0, tr32 -1.0 and
tr33 2.0. Scripts and tables: dev/latent-2p3-hmm.R and
dev/latent-findings.md.
Fixed transitions, against depmixS4. Recovery over 40 replicates
(seeds 20260910 to 20260949): the largest bias on any of the twelve
parameters is 0.014 on a transition logit whose own standard error is
0.083, the spread across replicates matches the standard error each
fit reports, and Wald coverage is 95.6 percent (459 of 480, Monte
Carlo interval 93.8 to 97.5). On the response scale the state means
come back within 0.021, the state standard deviations within 0.017,
and every cell of the transition matrix within 0.014. The fitted
labels were the true labels in 40 of 40, and the Hessian was positive
definite in 40 of 40. Against depmixS4 on the same data, best of four
random EM starts at tol = 1e-12, over 3 replicates: the two
log-likelihoods agree to between 3.1e-14 and 4.9e-12 relative, and
the means, standard deviations and transition matrix to 1.5e-06,
which is 1.6e-04 of one of this fit's own standard errors.
tr12 ~ (1 | id), against hmmTMB. Recovery over 15 replicates,
with a true sd(tr12 | id) of 0.6: it comes back at 0.615 on average
with a spread of 0.067, and Wald coverage over all thirteen
parameters is 94.4 percent (184 of 195, Monte Carlo interval 91.1 to
97.6). Against hmmTMB fitting the same model on the same data, over
6 replicates, the two Laplace marginal log-likelihoods agree to
between 6.1e-12 and 6.9e-11 relative and the estimates to 1.3e-04,
which is 1.5e-02 of one standard error. Fifteen replicates put about
ten points of Monte Carlo error on any single parameter's coverage,
so read the overall figure rather than the rows.
No replicate of either arm converged to a local optimum. That is a statement about these two designs and not about the family: see "Label switching and local optima" for a design where the cold start does exactly that, with every diagnostic clean.
Missing responses
An NA response is a time point the chain passes THROUGH without
emitting: the row is kept, its emission factor is masked out, and the
transition into and out of it is unchanged. This departs from the
package-wide na.action, deliberately - dropping the row would
shorten the chain and make one transition stand in for several, which
biases the transition matrix (measurably: with 3 of 20 points missing
the fitted off-diagonal moved from 0.115 to 0.128 against a true
0.10). nobs() therefore counts every row. fitted() and
residuals() are NA at a masked row, while hmm_probs() is not:
the neighbouring observations still say where the chain was.
Cost
Evaluating the likelihood is linear in the number of ROWS and free in
the number of sequences: a thousand sequences of five cost what one of
five thousand does. Both the tape and the decoding passes are
organized by time step, so what they actually cost is
length of the LONGEST sequence times K^2. At 20 000 rows a fit
takes about 3 s cut into a thousand short sequences and about the same
as one long chain, but hmm_probs() takes 0.06 s in the first shape
and 1.3 s in the second. Below 5 000 rows nothing is noticeable.
On a long chain the objective's own magnitude also makes the
optimizer's relative convergence test bite before its gradient test
does; judge max|grad| per observation rather than in absolute terms.
Boundaries
An unpenalized multinomial logit will send an emission or transition
probability to 0 whenever a category is rare inside a state, and the
optimizer then reports singular convergence at a perfectly good
optimum. set_prior() on the affected logit is the remedy. A prior
on a state's location names the state with dpar (dpar = "mu1"),
one specification per state: a class "b" or "Intercept" prior
without dpar is refused, because it does not say which state it
means.
Random effects and the Laplace approximation
A random effect in a state's linear predictor is integrated by the
Laplace approximation OUTSIDE the exact state sum. That integrand is a
mixture over state sequences and is not Gaussian, so the approximation
is genuinely approximate here even for a gaussian response. Measured
against adaptive Gauss-Hermite quadrature on a 40-sequence,
25-observation model with one scalar random intercept, the bias was
-0.126 in the log-likelihood (8.9e-5 relative) and 4.4e-4 absolute
in the parameters (dev/hmm-feasibility.md, probe D1). quadrature = TRUE is refused: its rule integrates a random effect against a
PRODUCT of per-row densities, and the forward algorithm is not one.
What is refused
REML (the restricted likelihood would integrate out only one state's
location coefficients, which matches no standard definition),
quadrature, frmtmb_control(profile = TRUE), weights(), cens(),
trunc(), se() and mi() on the response, multivariate models and
rescor, residuals(type = "osa"), frm_linpred(se.fit = TRUE) on the
response scale, and conditional_effects(). A grouping in which every
sequence has length 1 is refused too: the chain is then unidentified
and the model is a frmtmb::mixture().
Examples
set.seed(11)
n_seq <- 20; len <- 25
G <- matrix(c(0.9, 0.1, 0.2, 0.8), 2, 2, byrow = TRUE)
mu <- c(0, 3); sg <- c(0.6, 0.6)
dd <- do.call(rbind, lapply(seq_len(n_seq), function(id) {
s <- integer(len); s[1] <- 1L
for (t in 2:len) s[t] <- sample.int(2, 1, prob = G[s[t - 1], ])
data.frame(id = id, t = seq_len(len),
y = rnorm(len, mu[s], sg[s]), state = s)
}))
fit <- frm(bf(y ~ 1),
family = hmm(K = 2, gaussian(), time = t, group = id),
data = dd)
fixef(fit)
#> Estimate Est.Error Q2.5 Q97.5
#> mu1_Intercept -0.01572669 0.02991719 -0.07436331 0.04290993
#> mu2_Intercept 3.05387119 0.04762266 2.96053249 3.14720990
#> tr12_Intercept -2.27402375 0.18066844 -2.62812739 -1.91992011
#> tr22_Intercept 1.11746311 0.18472127 0.75541608 1.47951014
# smoothed state probabilities and the MAP path
head(hmm_probs(fit))
#> state1 state2
#> [1,] 1.0000000 1.420375e-11
#> [2,] 1.0000000 4.670620e-09
#> [3,] 1.0000000 3.274867e-11
#> [4,] 1.0000000 2.084125e-08
#> [5,] 1.0000000 5.615109e-09
#> [6,] 0.9999152 8.478153e-05
mean(hmm_viterbi(fit) == dd$state)
#> [1] 1
# fitted() is the occupancy-weighted mean, not state 1's
cor(fitted(fit)[, "Estimate"], dd$y)
#> [1] 0.9299445
# \donttest{
# one state's mean takes its own predictor, random effects included
frm(bf(y ~ 1, mu2 ~ 1 + (1 | id)),
family = hmm(K = 2, gaussian(), time = t, group = id),
data = dd)
#> Boundary (singular) fit: sd_id__mu2_Intercept is at zero. The likelihood does not change as it moves further that way, so it has no standard error (NaN in summary(), vcov() and confint()); the other standard errors are kept. lme4 reports such a fit as singular; diagnose() lists the components at the boundary
#> Family: hmm(2, gaussian)
#> Links: mu1 = identity; sigma1 = log; mu2 = identity; sigma2 = log;
#> tr12 = identity; tr22 = identity
#>
#> Formula: y ~ 1
#> Data: dd (Number of observations: 500)
#> Method: ML logLik: -617.932 AIC: 1249.86 BIC: 1279.37
#>
#> Multilevel Hyperparameters:
#> ~id (Number of levels: 20)
#> Estimate Est.Error l-95% CI u-95% CI
#> sd(mu2_Intercept) 0 NA NA NA
#>
#> Regression Coefficients:
#> Estimate Est.Error l-95% CI u-95% CI z value Pr(>|z|)
#> mu1_Intercept -0.02 0.03 -0.07 0.04 -0.53 0.6
#> mu2_Intercept 3.05 0.05 2.96 3.15 64.13 < 2e-16
#> tr12_Intercept -2.27 0.18 -2.63 -1.92 -12.59 < 2e-16
#> tr22_Intercept 1.12 0.18 0.76 1.48 6.05 1.5e-09
#>
#> Further Distributional Parameters:
#> Estimate Est.Error l-95% CI u-95% CI
#> sigma1 0.56 0.02 0.52 0.60
#> sigma2 0.57 0.03 0.51 0.65
#>
#> Parameters without a standard error (NaN wherever shown):
#> sd_id__mu2_Intercept: at the edge of the parameter space (a variance at
#> zero, or a correlation, mixing or range parameter at its limit), where
#> the likelihood no longer changes: a boundary, or singular, fit
# covariate-dependent transitions: trans = sets every cell's default
dd$x <- rnorm(nrow(dd))
frm(bf(y ~ 1),
family = hmm(K = 2, gaussian(), time = t, group = id,
init = "estimated", trans = ~x),
data = dd)
#> Family: hmm(2, gaussian)
#> Links: mu1 = identity; sigma1 = log; mu2 = identity; sigma2 = log;
#> tr12 = identity; tr22 = identity
#>
#> Formula: y ~ 1
#> Data: dd (Number of observations: 500)
#> Method: ML logLik: -610.619 AIC: 1239.24 BIC: 1277.17
#>
#> Regression Coefficients:
#> Estimate Est.Error l-95% CI u-95% CI z value Pr(>|z|)
#> mu1_Intercept -0.02 0.03 -0.07 0.04 -0.53 0.60
#> mu2_Intercept 3.05 0.05 2.96 3.15 64.07 < 2e-16
#> tr12_Intercept -2.11 0.18 -2.45 -1.76 -11.93 < 2e-16
#> tr22_Intercept 1.29 0.21 0.88 1.70 6.18 6.5e-10
#> tr12_x 0.08 0.18 -0.28 0.44 0.45 0.65
#> tr22_x -0.06 0.19 -0.43 0.31 -0.30 0.77
#>
#> Further Distributional Parameters:
#> Estimate Est.Error l-95% CI u-95% CI
#> sigma1 0.56 0.02 0.52 0.60
#> sigma2 0.57 0.03 0.51 0.65
# }