Fit a latent-variable model via EM + Laplace approximation
Source:R/em_laplace.R
tulpa_em_laplace.RdGeneric EM engine. Model packages provide callbacks for the E-step
(latent variable posterior) and the M-step encoding (how to assemble
submodel data from weights). Each M-step submodel block is fit
independently via tulpa_laplace(); the engine reads family and
offset per block so heterogeneous-family mixtures (e.g. a binomial
zero submodel + poisson positive submodel for a hurdle model) work
without engine changes.
Arguments
- e_step
Callback:
function(fits, ...) -> list(weights = numeric, ...). Called once per EM iteration with the current per-submodeltulpa_laplace()fits. Must return a list whoseweightselement is the latent-variable posterior used by the next M-step.- m_step_encode
Callback:
function(weights, ...) -> list of blocks. Each block is itself a list with the following fields:y(numeric, required) – response.X(matrix, required) – fixed-effects design withnrow(X) == length(y).family(character scalar, required) – one of"binomial","poisson","gaussian","negbin"/"neg_binomial_2","gamma","beta". Forwarded totulpa_laplace()for that block.n_trials(numeric orNULL, optional; absent orNULLdefaults to 1) – binomial trial counts.offset(numeric orNULL, optional) – observation-level offset, length-matched toywhen non-NULL.phi(numeric scalar, optional) – dispersion forwarded totulpa_laplace()(used bynegbin,gamma).weights(numeric, optional; lengthlength(y)) – per-observation likelihood weight, scaling that row's log-density, score and Fisher information. This is the channel a soft (fractional) latent label travels on; see the section below.re_list,spatial(optional) – forwarded as-is.
- spatial, re_list
Not consumed at the driver level – latent structure is per-submodel, set as a
spatial/re_listfield on each blockm_step_encode()returns. Supplying either here is an error (rather than a silent no-op).- max_iter
Maximum EM iterations.
- tol
Convergence tolerance on max relative parameter change.
- damping
EM damping factor in
[0, 1). Withdamping = d, the E-step weights are smoothed between iterations as(1 - d) * new + d * prev(d = 0is no damping); the M-step then refits on the smoothed weights, so the parameter update is damped indirectly through the weights rather than by mixing successive parameter vectors.- correction
Post-EM correction.
"none"returns the EM point estimate only."mi"drawsn_imputationsindependent hardzfrom the converged posterior weights P(z|y, theta_hat), refits each block on the hard z, and pools viarubins_pool()."gibbs"runs a warm-startedz|theta -> theta|zMarkov chain of lengthn_gibbsstarting from the EM fits, also pooled viarubins_pool()."auto"resolves to"none".- n_imputations
Number of MI draws (default
20L). Used whencorrection = "mi".- n_gibbs
Length of the Gibbs chain (default
10L). Used whencorrection = "gibbs".- draw_z
Optional function
function(weights) -> hard_zthat turns the E-step's continuous weights into a hard latent draw. Used only bycorrection %in% c("mi", "gibbs"). The default treatsweightsas a numeric vector of Bernoulli probabilities and draws per-observation. Multi-class / matrix-valued latent structures must supply their own callback.- m_step_extra
Optional
function(fits, weights, ...) -> fits. Fired once per M-step in every phase (EM iterations, MI draws, Gibbs steps). Receives the freshly assembled list oftulpa_laplace()results (fits), the continuous E-step weights P(z|y, theta) (NOT the hard z draw used by MI/Gibbs to encode the block), and any extra arguments forwarded through.... Returns a list with the same length and names as the input, possibly with mutated dispersion / shape / precision fields (e.g.fits[[k]]$phi). Use this to update non-eta parameters that fall out of the Laplace M-step (NB overdispersion, Gamma shape, Beta precision, Gaussian sigma). WhenNULL(default), behavior is unchanged.- beta_prior
Optional Gaussian prior on the fixed effects, applied to every block fit via
tulpa_laplace()(i.e. blocks without apriorfield).NULL(default) keeps the weak built-in prior. Otherwise a list withsd(required) and optionalmean; seetulpa_laplace(). The same prior flows into the MI / Gibbs correction refits, so penalized corrections come for free. A block may override the default by setting its ownbeta_priorfield inm_step_encode(e.g. different priors for the occupancy and detection submodels). Use scalarmean/sdhere when blocks differ in width; per-coefficient vectors belong on the block.- verbose
Print per-iteration progress.
- ...
Forwarded to
e_step,m_step_encode, andm_step_extra.
Value
A list with:
fits– named list oftulpa_laplace()results, one per block.weights– final E-step weights.n_iter– number of EM iterations actually run.converged– logical.history–data.frame(iter, delta)of max relative parameter change per iteration.correction– the resolved correction mode ("none","mi", or"gibbs").pooled– present whencorrection %in% c("mi", "gibbs"). Named list of pooled per-submodel summaries fromrubins_pool().draws– present whencorrection %in% c("mi", "gibbs"). List of per-draw fits withbeta/seattached.
Details
This is an engine block, not a front door: model packages call it
programmatically, so its tuning knobs (max_iter, tol, damping)
sit in the signature rather than in a control list.
Soft latent labels go in weights, not in y
The M-step maximizes the expected complete-data log-likelihood. For a
Bernoulli latent z_i carrying E-step posterior weight w_i that is
$$Q = \sum_i [\, w_i \log p_i + (1 - w_i) \log (1 - p_i) \,],$$
a weighted Bernoulli log-likelihood, which carries no binomial
coefficient. Encode it as two rows per unit – y = 1 at weight w_i and
y = 0 at weight 1 - w_i:
A fractional y on a binomial block is refused, and is not the same
objective: it asks for the exact binomial density at a non-integer
response, whose normalizer lchoose(n, y) is not zero there and depends on
w. On the weighted encoding the block's log_marginal is the Laplace
marginal of Q, an EM objective that increases across iterations.
Examples
# \donttest{
# Zero-inflated Poisson via EM: the E-step scores the posterior probability
# that each zero is non-structural. Those are soft labels, so the occupancy
# arm is the weighted Bernoulli above -- two rows per unit -- and the
# abundance arm weights each count by the same w.
set.seed(1)
n <- 200
z <- rbinom(n, 1, 0.7)
y <- rpois(n, 4) * z
X <- cbind(1, rnorm(n))
e_step <- function(fits, ...) {
if (!length(fits)) return(list(weights = pmax(as.numeric(y > 0), 0.5)))
psi <- plogis(drop(X %*% fits$occ$mode))
lam <- exp(drop(X %*% fits$abund$mode))
w <- ifelse(y > 0, 1, psi * exp(-lam) / (psi * exp(-lam) + (1 - psi)))
list(weights = w)
}
m_step_encode <- function(weights, ...) {
list(
occ = list(y = rep(c(1, 0), each = n), X = rbind(X, X),
weights = c(weights, 1 - weights), family = "binomial"),
abund = list(y = y, X = X, family = "poisson", weights = weights)
)
}
res <- tulpa_em_laplace(e_step, m_step_encode, verbose = FALSE)
res$converged
plogis(res$fits$occ$mode[1]) # P(non-structural), truth 0.7
exp(res$fits$abund$mode[1]) # abundance mean, truth 4
# }