mixle.inference.freeze_rollup module¶
Freeze/roll-up cache – per-datum log-density caching over frozen subtrees (workstream D2).
Frame (see the ConditionalJIT track, D1-D6): the estimator tree is an IR. D1
(mixle.inference.node_report) instruments every node with a per-round residual/Q-gain
report and an update_kind classification. D2 spends that report: once a subtree’s D1 report
says it has stopped changing (a structurally "frozen" node, or a converged residual/Q-gain),
this module skips recomputing its per-datum log-density on every subsequent E-step round and
instead reuses a cached array – turning E-step cost from O(full tree) into
O(active fraction).
Correctness backbone (unchanged from the rest of the D-track): freeze/roll-up is a SCHEDULING
optimization only. It never changes what is computed, only how often – a cached per-datum
log-density is byte-identical to a freshly recomputed one for the SAME parameters, and the cache
is invalidated (recomputed) the instant a subtree’s parameters move again. The Neal-Hinton free
energy F this module tracks is the same observed-data-log-likelihood objective
mixle.inference.em.observed_log_likelihood() already tracks for vanilla EM; F is the audit
receipt that the cache never silently drifted from a real EM trajectory.
Scope: this module targets mixle.stats.latent.mixture.MixtureDistribution, the
combinator with the clearest “some subtrees stop mattering” story (a component whose mixture
weight collapses near zero stops moving under EM – its sufficient-statistic contribution is
~0 – and stays frozen once collapsed). The freeze/roll-up mechanics (cache, invalidation,
D1-driven detection, active-fraction accounting) generalize to any combinator whose E-step
already produces one per-datum log-density array per child (Composite/Sequence), but a single,
well-tested combinator is the honest S/M-effort scope for this item; later track items
(D3’s scheduler) are expected to widen it.
- class FreezeRollupStats(round_index, n_components, n_active, n_frozen, n_zero_weight, n_log_density_evals, objective, accepted=True)[source]
Bases:
objectOne round’s accounting for the freeze/roll-up cache – the acceptance-criteria receipt.
n_log_density_evalsis the “wall-clock proxy” this module actually optimizes: the count of realcomponent.seq_log_density(...)calls issued this round (eachO(nobs)), as opposed to a cache hit (O(1), a dict lookup + signature compare).objectiveis the real, provably-monotone Neal-Hinton F for this round (observed-data log-likelihood), read straight off the E-step this module already had to run – no parallel F-tracking mechanism.- Parameters:
- property active_fraction: float
Fraction of components genuinely (re-)evaluated this round, out of all components.
- class FreezeRollupCache(*, q_gain_tol=_DEFAULT_Q_GAIN_TOL, weight_tol=_DEFAULT_WEIGHT_TOL, weight_delta_tol=_DEFAULT_WEIGHT_DELTA_TOL, freeze_patience=_DEFAULT_FREEZE_PATIENCE)[source]
Bases:
objectPer-mixture-component cache of per-datum log-density, keyed by component index.
A component is eligible for caching once
mixle.inference.node_report.node_report()reports it as D1-"frozen"(theNeutralcapability) or as having a converged residual/ Q-gain (abs(q_gain) < q_gain_tol) sustained forfreeze_patienceconsecutive rounds while its mixture weight has collapsed belowweight_tol(the natural mixture-specific trigger: a near-zero-weight component’s data-weighted M-step contribution is ~0, so its params – and hence its residual/Q-gain – stop moving). Once frozen, the caller (seerun_em_freeze_rollup()) also skips that component’s M-step entirely and carries its model object forward unchanged, so the cached signature can never go stale on its own; the cache only invalidates if a caller explicitly mutates/re-estimates a “frozen” component (invalidate()) or the component’s own parameter signature is found to have moved (a belt-and-suspenders check on every lookup, not just a documented invariant).- invalidate(idx=None)[source]
Drop a cached entry (
idx=Noneclears the whole cache and freeze streaks).Call this if a caller explicitly re-triggers a component the cache had frozen (e.g. a later scheduler decides to re-activate it): the next lookup is guaranteed to recompute rather than silently return the stale array.
- Parameters:
idx (int | None)
- Return type:
None
- is_frozen(idx, component, weight)[source]
Return the D1-driven freeze verdict for component
idxthis round.Reads a fresh
NodeReportevery round (cheap: a small Monte-Carlo self-residual, not a real-data pass) so a component that later moves again (unfrozen) is detected immediately – the streak resets to 0 the instant the residual/Q-gain stops looking converged, the weight climbs back out ofweight_tol, or the weight itself is still moving.Both a converged own-residual/Q-gain AND a converged weight are required: a mixture component’s own fit (mean/variance) can stabilize well before the joint E-step’s responsibility reallocation across near-degenerate components finishes settling its weight (slow-manifold EM plateaus). Freezing on residual/Q-gain alone would risk locking in a component’s weight (and hence the M-step never revisiting it) before it has actually reached its coordinate-ascent fixed point – silently changing what the fit converges to, which the ConditionalJIT track’s own correctness backbone forbids (a scheduler may change speed, never the answer). Requiring the weight to also have stopped moving (
abs(weight - prev_weight) < weight_delta_tol) forfreeze_patienceconsecutive rounds is the guard against that false-freeze failure mode.
- component_log_density(idx, component, enc, *, frozen)[source]
Return
(log_density, was_cache_hit)for one component on this round.A cache hit costs a dict lookup + an
O(param_count)signature compare – never a call intocomponent.seq_log_density(O(nobs)), which is the entire point of D2. Any mismatch between the cached signature and the component’s current parameters – frozen or not – forces a recompute, so a stale cache can never silently persist past the point where it is wrong (acceptance criterion 3).
- detect_frozen(cache, model)[source]
Return the set of component indices D1 reports as frozen for
modelthis round.
- run_em_freeze_rollup(enc_data, estimator, initial_model, *, max_its=10, delta=1.0e-9, cache=None, q_gain_tol=_DEFAULT_Q_GAIN_TOL, weight_tol=_DEFAULT_WEIGHT_TOL, weight_delta_tol=_DEFAULT_WEIGHT_DELTA_TOL, freeze_patience=_DEFAULT_FREEZE_PATIENCE, accept_tolerance=_DEFAULT_ACCEPT_TOLERANCE)[source]
Run EM over a
MixtureDistributionwith D1-driven freeze/roll-up E-step caching.Mirrors
mixle.inference.em.run_em()+PosteriorTransformEM’s soft-EM update (same GEM/ECM coordinate-ascent structure: an E-step producing responsibilities, then a per-block conditional M-step), with two differences that only affect SPEED, never correctness:A component D1 reports as frozen (see
FreezeRollupCache.is_frozen()) reuses last round’s cached per-datum log-density instead of recomputing it, and is excluded from the M-step (its model object carries forward unchanged) – so a round with onlykofKcomponents active costsO(k/K)of a full round’sseq_log_densitywork.Every round is objective-gated exactly like
MonotonicEM: the candidate model’s objective is checked (again cache-aware, so this costs the sameO(active fraction)) and the step is rejected – keeping the previous model – if the objective would decrease beyondaccept_tolerance. This is what makes the returnedhistorya real monotone-F receipt, not just an assumption.
Returns
(final_model, history)wherehistory[i]is roundi’sFreezeRollupStats(includingobjective, the real Neal-Hinton F for that round, andn_log_density_evals, the wall-clock proxy this module optimizes).- Parameters:
- Return type: