Fitting#
EM Fitters#
EM fitters for normix distributions.
Model knows math, fitter knows iteration.
BatchEMFitter is batch EM with a dual-loop architecture (lax.scan
when both backends are JAX, otherwise a Python for-loop).
IncrementalEMFitter is online / mini-batch EM with pluggable eta
update rules.
- class normix.fitting.em.EMResult(model, log_likelihoods, param_changes, n_iter, converged, elapsed_time, diverged=False, solver_failed=False)[source]#
Bases:
objectResult of an EM fitting procedure.
converged,diverged, andsolver_failedare separate. Tolerance met isconverged=True. A non-finite normal-block change or objective isdiverged=True: the last finite model is kept. A non-finite mixing distribution after the M-step issolver_failed=True, again with the last finite model kept. On the Python loop,SolverDidNotConvergeorValueErrorsets the same flag. Exhaustingmax_iterleaves all three false. They are not opposites.- Parameters:
- class normix.fitting.em.BatchEMFitter(*, algorithm='em', max_iter=200, tol=0.001, verbose=0, regularization='none', e_step_backend='jax', m_step_backend='cpu', m_step_method='newton', eta_update=None, track_ll=False, m_step_kwargs=None)[source]#
Bases:
objectBatch EM / MCECM algorithm with dual-loop architecture.
EM (default): E-step → M-step (all params) → regularize.
MCECM: E-step → M-step (normal params only) → regularize → E-step → M-step (subordinator only).
Convergence is the Aitken remaining gap of the mean log-likelihood \(\ell_n\) (nats per observation) from the E-step conjugacy identity; stop when \(\ell_\infty - \ell_{t+1} <\)
tol. Three consecutive \(\ell\) values are required, so the earliest stop isn_iter=3. Hybrid-scale RMS onem_convergence_params()is recorded inEMResult.param_changesas a diagnostic, not the stop. Covariance regularisations leave the density unchanged, so they share this criterion. Under scalar-\(\tau\) MAP shrinkage the tracked sequence is the penalised objective.- Loop selection is automatic:
lax.scan when both backends are ‘jax’, verbose <= 1, algorithm=’em’, and no eta_update rule
Python for-loop otherwise
- Parameters:
algorithm (str) – ‘em’ (default) or ‘mcecm’.
max_iter (int) – Maximum number of iterations.
tol (float) – Convergence tolerance on the Aitken remaining gap of \(\ell_n\) in nats per observation.
tol=1e-3means the extrapolated remaining improvement is less than \(10^{-3}\) nats/obs.verbose (int) – 0 = silent, 1 = summary, 2 = per-iteration table.
regularization (str) –
Strategy applied after each M-step:
'none'— no regularization.'det_sigma_one'— enforce \(|\Sigma| = 1\) (the original GH convention; equivalent toregularize_det_sigma(target_log_det=0)).'det_sigma_x'— enforce \(|\Sigma| = |\Sigma_0|\) where \(\Sigma_0\) is the dispersion of the initial model passed tofit(). Useful when comparing GH / FactorGH parameters against VG / NIG / NInvG, which leave \(|\Sigma|\) at the empirical scale.'a_eq_b'— rescale the GIG subordinator so \(a = b = \sqrt{ab}\). Trivial no-op for VG / NInvG / MultivariateNormal.
e_step_backend (str) – ‘jax’ (default) or ‘cpu’.
m_step_backend (str) – ‘jax’ or ‘cpu’ (default, faster for GIG).
m_step_method (str) – ‘newton’ (default), ‘lbfgs’, or ‘bfgs’.
eta_update (EtaUpdateRule or None) – Optional eta combination rule (e.g.
Shrinkage(IdentityUpdate(), eta0, tau)). When set, the E-step output is transformed before the M-step.track_ll (bool) – When
True, record the per-iteration conjugacy mean log-likelihood (the Aitken sequence) inEMResult.log_likelihoodswithout requiringverbose >= 1. These are \(\ell_n(\theta_t)\) at the E-step parameters, not a second Bessel pass throughmarginal_log_likelihood().m_step_kwargs (dict or None) – Extra keyword arguments forwarded verbatim to every
m_step/m_step_subordinatorcall (in addition tobackendandmethod). Used to thread estimand controls such as the VGalpha_minshape bound down to the subordinator’sfrom_expectation. Values must be static (e.g. Python floats) to stay compatible with thelax.scanpath.None= no extras.
- fit(model, X)[source]#
Run batch EM or MCECM. Auto-selects lax.scan or Python loop.
- Parameters:
model (NormalMixture) – Instance of a NormalMixture subclass, used as initial parameters.
X (jax.Array) – Data array, shape
(n, d).
- Returns:
EMResult with fitted model, convergence diagnostics, and timing.
Xis centered at its sample mean before any statistic isaccumulated, and that mean is added back onto
mu.gamma,Sigma, and the subordinator are unchanged by the shift. Ashrinkage target equal to the initial model’s own η is rebuilt
in these coordinates; any other target is translated as a
population moment.
- Return type:
- class normix.fitting.em.IncrementalEMFitter(*, eta_update=None, batch_size=256, max_steps=200, inner_iter=1, verbose=0, regularization='none', e_step_backend='jax', m_step_backend='cpu', m_step_method='newton')[source]#
Bases:
objectIncremental EM with pluggable eta update rules.
Replaces
OnlineEMFitterandMiniBatchEMFitter. Processes data in random mini-batches, applies anEtaUpdateRuleto combine the running \(\eta\) with each batch estimate, then M-steps on the combined \(\eta\).- Parameters:
eta_update (EtaUpdateRule) – How to combine running η with each batch estimate.
batch_size (int) – Observations per batch.
max_steps (int) – Number of batches to process (total budget).
inner_iter (int) – 1 = online (default); >1 = fine-tuning on each batch.
verbose (int) –
0 = silent; 1 = periodic summary.
Scan path (JAX backends only):
verbosemust be0so diagnostics do not rely on Python side effects each step.regularization (str) – Same options as
BatchEMFitter—'none'|'det_sigma_one'|'det_sigma_x'|'a_eq_b'.e_step_backend (str) – Passed through to
e_step/m_step.m_step_backend (str) – Passed through to
e_step/m_step.m_step_method (str) – Passed through to
e_step/m_step.
Solvers#
Bregman divergence solvers.
Minimises f(θ) − θ·η over θ, where f is any convex function (e.g. the log-partition ψ for an exponential family). At the minimum ∇f(θ*) = η.
Public API#
- solve_bregman
Single starting point.
- solve_bregman_multistart
Ordered starting points, stopping at the first finite converged JAX solve. CPU solves select the best result from a Python loop.
- bregman_objective
Utility \(f(\theta) - \theta\cdot\eta\).
- make_jit_newton_solver
Build a stable
@jax.jitNewton solve specialised to a fixed(f, grad_fn, hess_fn, bounds). Repeated calls with matching shapes/dtypes hit the XLA cache, avoiding the per-call re-tracing thatsolve_bregmanincurs from fresh closures.
Backends × methods#
backend=’jax’, method=’newton’ custom lax.while_loop Newton, autodiff or analytical Hessian backend=’jax’, method=’lbfgs’ jaxopt LBFGSB (bounds native) or LBFGS (reparam) backend=’jax’, method=’bfgs’ jaxopt BFGS with reparameterization for bounds backend=’cpu’, method=’lbfgs’ scipy L-BFGS-B (native bounds) backend=’cpu’, method=’bfgs’ scipy BFGS; bounds via the same reparametrization backend=’cpu’, method=’newton’ scipy trust-exact; bounds via the same reparametrization
Gradient / Hessian sources#
- grad_fnθ → ∇f(θ). For backend=’cpu’: pure CPU (numpy) gradient.
For backend=’jax’, method=’newton’: JAX-traceable ∇ψ(θ). If None with backend=’cpu’: hybrid — jax.grad compiled → NumPy callbacks.
- hess_fnθ → ∇²f(θ). Required for method=’newton’.
For backend=’cpu’: pure CPU (numpy) Hessian. For backend=’jax’, method=’newton’: JAX-traceable ∇²ψ(θ). If None with method=’newton’: jax.hessian of the full objective.
Both grad_fn and hess_fn operate in theta-space only. The solver handles all reparameterization internally via the chain rule.
- class normix.fitting.solvers.BregmanResult(theta, fun, grad_norm, num_steps, converged, elapsed_time=0.0, kkt_residual=None, reason=None)[source]#
Bases:
objectResult of a Bregman divergence minimization.
Scalar fields accept both Python and JAX types so the result can live inside a lax.scan carry without concretization errors.
- Parameters:
- exception normix.fitting.solvers.SolverDidNotConverge[source]#
Bases:
RuntimeErrorEager Bregman inversion did not meet the residual tolerance.
- normix.fitting.solvers.bregman_objective(theta, eta, f)[source]#
f(θ) − θ·η — convex dual whose minimum gives ∇f(θ*) = η.
- normix.fitting.solvers.solve_bregman(f, eta, theta0, *, backend='jax', method='lbfgs', bounds=None, max_steps=500, tol=1e-10, grad_fn=None, hess_fn=None, verbose=0)[source]#
Minimise f(θ) − θ·η over θ.
- Parameters:
f (Callable) – Convex function θ → scalar (e.g. log-partition ψ).
eta (jax.Array) – Target vector (e.g. expectation parameters η).
theta0 (jax.Array) – Initial guess.
backend ('jax' or 'cpu') –
'jax'is JIT-able;'cpu'uses scipy and is not JIT-able.method ('lbfgs', 'bfgs', or 'newton')
bounds (tuple of jax.Array, or None) –
(lower, upper)pair, each shape(d,);None→ unconstrained. JAX Newton, JAX BFGS, CPU BFGS, and CPU trust-exact enforce bounds by reparameterization. CPU L-BFGS-B takes them natively.max_steps (int) – Iteration budget.
tol (float) – Convergence tolerance on ‖∇f(θ) − η‖∞. On a reparametrised CPU solve, SciPy
gtolis the φ-gradient.convergedis still the free θ residual.grad_fn (Callable, optional) – θ → ∇f(θ). For backend=’cpu’: must accept and return numpy arrays. For backend=’jax’, method=’newton’: must be JAX-traceable. If None with backend=’cpu’: falls back to jax.grad (hybrid mode).
hess_fn (Callable, optional) – θ → ∇²f(θ). Required for method=’newton’. For backend=’cpu’: must accept numpy arrays and return numpy array. For backend=’jax’, method=’newton’: must be JAX-traceable. If None with method=’newton’: jax.hessian of the full objective is used.
verbose (int) – 0 = silent, >= 1 = print summary after solve. Summaries are skipped while tracing.
- Return type:
- normix.fitting.solvers.solve_bregman_multistart(f, eta, theta0_batch, *, backend='jax', method='lbfgs', bounds=None, max_steps=500, tol=1e-10, grad_fn=None, hess_fn=None, verbose=0)[source]#
Solve from ordered starting points, preferring finite convergence.
- Parameters:
theta0_batch (jax.Array or list of jax.Array) – Nonempty
(K, dim)array or list of arrays. JAX tries starts in order and stops at the first finite converged result. If none converges, it returns the lowest finite objective. Put preferred starts first. CPU solves use a Python loop over all starts.verbose (int) – 0 = silent, >= 1 = print summary. Summaries are skipped while tracing.
eta (Array)
backend (str)
method (str)
max_steps (int)
tol (float)
grad_fn (Callable | None)
hess_fn (Callable | None)
- Return type:
- normix.fitting.solvers.make_jit_newton_solver(f, grad_fn, hess_fn, bounds=None)[source]#
Build a
@jax.jit-decorated Newton solver specialised to one problem.The returned callable has signature
solve(eta, theta0, max_steps=20, tol=1e-10) -> (theta_opt, fun, grad_norm, converged)wheremax_stepsandtolare static arguments.All distribution-level inputs (
f,grad_fn,hess_fn,bounds) are baked into the closure at construction time. Repeated calls with the same array shapes and dtypes therefore reuse the compiled XLA executable.Use this in EM hot paths where
solve_bregmanwould otherwise build a fresh Python closure on every call and force JAX to re-trace the same Newton kernel on each iteration.- Parameters:
f (Callable) – Convex objective ψ(θ) → scalar (must be JAX-traceable).
grad_fn (Callable) – \(\nabla\psi(\theta) \to \mathbb{R}^d\).
hess_fn (Callable) – \(\nabla^2\psi(\theta) \to \mathbb{R}^{d\times d}\).
bounds (tuple or None) –
(lower, upper), each shape \((d,)\), orNone.
- Returns:
Jit-compiled Newton solver. Returns a 4-tuple of JAX arrays
(theta, fun, grad_norm, converged); wrap inBregmanResultexternally if needed.- Return type:
Callable
Eta Parametrization#
NormalMixtureEta — expectation parametrization for normal variance-mean mixtures.
The six fields are the batch-averaged sufficient statistics \(\hat\eta = \frac{1}{n}\sum_i E[t(X_i, Y_i) \mid X_i]\), in the theory order used in Shrinkage with Penalized Likelihood and Factor Analysis for Generalized Hyperbolic Distributions:
This is the expectation parametrization of
JointNormalMixture.
GIG arrays use the last-axis order (E_log_Y, E_inv_Y, E_Y).
The private packing helpers convert between these layouts by name.
- class normix.fitting.eta.EStepResult(eta, log_lik)[source]#
Bases:
ModuleE-step output: aggregated \(\eta\) and mean log-likelihood.
\(\ell_n\) is not a field of
NormalMixtureEta—affine_combine/ shrinkage would mix it. It is computed from the E-step’s posterior GIG log-partition (already inBesselMoments.log_k) via conjugacy, with no extra Bessel calls and no cache on the model.
- class normix.fitting.eta.NormalMixtureEta(E_inv_Y, E_Y, E_log_Y, E_X, E_X_inv_Y, E_XXT_inv_Y)[source]#
Bases:
ModuleAggregated expectation parameters for normal variance-mean mixtures.
Fields are stored in theory order
(s_1, …, s_6): the first six statistics are shared withFactorMixtureStatsso that shrinkage targets, weights, and tests written for the standard family transfer unchanged.- Parameters:
- class normix.fitting.eta.FactorMixtureStats(E_inv_Y, E_Y, E_log_Y, E_X, E_X_inv_Y, E_XXT_inv_Y, E_XZT_inv_sqrtY, E_Z_inv_sqrtY, E_Z_sqrtY, E_ZZT)[source]#
Bases:
ModuleAggregated expectation parameters for factor-analysis mixtures.
Fields are stored in theory order
(s_1, …, s_{10})from Factor Analysis for Generalized Hyperbolic Distributions. The first six are identical toNormalMixtureEta(so shrinkage targets, η-update rules, and weight pytrees designed for the standard family broadcast onto the factor family without modification). The four extra fields involve the latent factor \(Z\).- Parameters:
- normix.fitting.eta.affine_combine(eta_prev, eta_new, b, c, a=None)[source]#
Affine combination \(\eta_t = a + b\,\eta_{t-1} + c\,\hat\eta\).
The weights
bandcmay be:scalar (Python number or 0-d
jax.Array) — broadcast to every leaf ofeta;stats-shape pytree (same type as
eta_prev/eta_new) — block-diagonal weighting; leaf-wise multiply;callable
η → η— arbitrary linear operator on η (e.g. aneqx.nn.Linearwrapped to operate on a flattened pytree).
The shift
ais eitherNone(zero) or a stats-shape pytree.- Parameters:
eta_prev – Running state \(\eta_{t-1}\).
eta_new – New batch estimate \(\hat\eta\).
b (float | Array | NormalMixtureEta | Callable[[...], NormalMixtureEta]) – Weight on previous state.
c (float | Array | NormalMixtureEta | Callable[[...], NormalMixtureEta]) – Weight on new estimate.
a – Additive shift (e.g. shrinkage prior).
Nonemeans zero.
Eta Update Rules#
Eta update rules for incremental and penalised EM.
Two-layer abstraction#
The most general rule is
implemented by EtaUpdateRule via __call__. This leaves room
for non-affine predictors (e.g. an MLP that maps
(η_{t-1}, η̂) → η_t) without another API revision.
Most rules in this module are affine:
implemented by AffineRule, which delegates to
AffineRule.weights() returning (a, b, c, state) and combines
via affine_combine().
All rules are equinox.Module pytrees so their hyperparameters
(e.g. tau0, w, tau) are JAX array leaves — JIT-compatible
and differentiable for future meta-learning of step-size schedules.
- class normix.fitting.eta_rules.EtaUpdateRule[source]#
Bases:
ModuleAbstract base for eta update rules.
The fitter only knows
__call__(); whether a concrete rule is affine, a combinator, or an ML-style predictor is invisible at the call site.Subclasses override
__call__(). Rules with no per-step memory inheritinitial_state()returning an emptydict.
- class normix.fitting.eta_rules.AffineRule[source]#
Bases:
EtaUpdateRuleSpecialisation: \(\eta_t = a + b\,\eta_{t-1} + c\,\hat\eta\).
Subclasses implement
weights()instead of__call__(). The base class provides a single__call__()that delegates toweights()and runs the combination throughaffine_combine().
- class normix.fitting.eta_rules.IdentityUpdate[source]#
Bases:
AffineRulePass-through: \(\eta_t = \hat\eta\) (standard batch EM).
- class normix.fitting.eta_rules.RobbinsMonroUpdate(tau0=10.0)[source]#
Bases:
AffineRuleRobbins–Monro: \(c = 1/(\tau_0 + t)\), \(b = 1 - c\).
- Parameters:
tau0 (float) – Initial step-size denominator (higher → slower adaptation).
- class normix.fitting.eta_rules.SampleWeightedUpdate[source]#
Bases:
AffineRuleIncremental mean: \(b = n/(n+m)\), \(c = m/(n+m)\).
Tracks cumulative sample count n; each batch contributes m.
- class normix.fitting.eta_rules.EWMAUpdate(w=0.1)[source]#
Bases:
AffineRuleExponentially weighted moving average: \(b = 1-w\), \(c = w\).
- Parameters:
w (float) – Weight on the new batch (0 < w ≤ 1).
- class normix.fitting.eta_rules.Shrinkage(base, eta0, tau=0.5)[source]#
Bases:
EtaUpdateRuleShrinkage combinator: pull the running η toward a prior on top of any base rule.
\[\eta_t = \frac{\tau}{1+\tau} \odot \eta_0 + \frac{1}{1+\tau} \odot \mathrm{base}(\eta_{t-1}, \hat\eta),\]where \(\odot\) is per-field multiplication (broadcast for scalar \(\tau\)).
The combinator composes with any base rule — affine (
IdentityUpdate,RobbinsMonroUpdate,EWMAUpdate,SampleWeightedUpdate) or non-affine (e.g. an MLP-based predictor) — without losing the base rule’s state.- Parameters:
base (EtaUpdateRule) – Base rule that produces the unshrunk update \(\mathrm{base}(\eta_{t-1}, \hat\eta)\).
eta0 (NormalMixtureEta) – Prior expectation parameters (shrinkage target). Must be a complete stats pytree of the same type as
eta_prev/eta_new.tau (float, jax.Array, or NormalMixtureEta) –
Shrinkage strength.
scalar — uniform shrinkage on every sufficient statistic; matches the penalised-MLE in Shrinkage with Penalized Likelihood.
stats pytree (e.g.
NormalMixtureEtawith scalar leaves) — per-field shrinkage. Setting all but one leaf to0shrinks only that statistic (e.g.Σalone via theE_XXT_inv_Yfield).
Examples
Batch EM with uniform shrinkage:
rule = Shrinkage(IdentityUpdate(), eta0, tau=0.5)
Batch EM with Σ-only shrinkage:
tau_pytree = NormalMixtureEta( E_inv_Y=0.0, E_Y=0.0, E_log_Y=0.0, E_X=jnp.zeros(d), E_X_inv_Y=jnp.zeros(d), E_XXT_inv_Y=jnp.full((d, d), 0.5), ) rule = Shrinkage(IdentityUpdate(), eta0, tau=tau_pytree)
Robbins–Monro online + shrinkage:
rule = Shrinkage(RobbinsMonroUpdate(tau0=10.0), eta0, tau=0.1)
Notes
The state pytree is owned by
base:initial_state()and__call__()thread the base rule’s state unchanged through the shrinkage step.See also
normix.fitting.shrinkage_targetshelpers for building
eta0.
- base: EtaUpdateRule#
- class normix.fitting.eta_rules.AffineUpdate(a=None, b=0.0, c=1.0)[source]#
Bases:
AffineRuleUser-defined constant \((a, b, c)\).
All three coefficients are pytree values —
bandcare scalarjax.Arrayleaves,ais an optionalNormalMixtureEta. For time-varying schedules, subclassEtaUpdateRuledirectly.- Parameters:
a (NormalMixtureEta or None) – Additive shift (e.g. scaled prior).
b (float) – Weight on previous state.
c (float) – Weight on new batch.
- a: NormalMixtureEta | None#
Shrinkage Targets#
Shrinkage target builders for penalised EM.
The Shrinkage combinator pulls the
running expectation parameters toward a prior \(\eta_0\). The helpers
below construct that prior from a fitted (or moment-initialised)
NormalMixture, optionally substituting
a custom dispersion \(\Sigma_0\).
Per Shrinkage with Penalized Likelihood (Shrunk Sufficient Statistics), the prior expectation parameters are
All four constructors return a complete six-field
NormalMixtureEta. The Σ-only variants reuse
the model’s own \((\mu, \gamma, p, a, b)\) to fill the other five
fields, keeping the public contract simple (”eta0 is always a full
prior”) while the user’s per-field tau selects which fields are
actually shrunk.
- normix.fitting.shrinkage_targets.eta0_from_model(model)[source]#
Prior \(\eta_0\) equal to the model’s current expectation parameters.
Equivalent to
model.compute_eta_from_model().- Parameters:
model (NormalMixture) – Source model; the prior reuses its \((\mu, \gamma, \Sigma)\) and subordinator parameters.
- Returns:
Six-field expectation pytree.
- Return type:
- normix.fitting.shrinkage_targets.eta0_isotropic(model, sigma2)[source]#
Prior with an isotropic dispersion \(\Sigma_0 = \sigma^2 I_d\).
- Parameters:
model (NormalMixture) – Source model; provides \((\mu, \gamma, p, a, b)\).
sigma2 (float) – Common variance for the isotropic prior. Must be positive.
- Returns:
Six-field expectation pytree.
- Return type:
- normix.fitting.shrinkage_targets.eta0_diagonal(model, diag)[source]#
Prior with a diagonal dispersion \(\Sigma_0 = \mathrm{diag}(\text{diag})\).
- Parameters:
model (NormalMixture) – Source model; provides \((\mu, \gamma, p, a, b)\).
diag (jax.Array) – Diagonal entries of \(\Sigma_0\), shape \((d,)\). Must be positive.
- Returns:
Six-field expectation pytree.
- Return type:
- normix.fitting.shrinkage_targets.eta0_with_sigma(model, Sigma0)[source]#
Prior \(\eta_0\) reusing model parameters with a custom \(\Sigma_0\).
Substitutes
Sigma0for the model’s covariance in the \(s_6\) term while keeping \(\mu, \gamma\) and the subordinator expectations frommodel. This is the building block for “shrink Σ only” workflows: combine with a per-fieldtauthat is non-zero only onE_XXT_inv_Y.- Parameters:
model (NormalMixture) – Source model; provides \((\mu, \gamma, p, a, b)\).
Sigma0 (jax.Array) – Prior dispersion to embed in \(s_6\), shape \((d, d)\). Must be positive semi-definite (not checked).
- Returns:
Six-field expectation pytree with
E_XXT_inv_Yrebuilt fromSigma0.- Return type: