bayesmith.marginal.factorize
Source-derived signatures and contracts. See the reference index for coverage and conventions.
Module contract
Which latents survive an epoch, and which are integrated away inside it.
rheplicant declares this: a user tags each latent ``scope="global"``,
``"per_epoch"`` or ``"linked"``, and a ``Factorization`` partitions the space
by those tags. The migration spec's argument for rewriting B11 rather than
transplanting it is that bayesmith's graph already carries plates, so the
partition can be **derived** -- "which kills the whole error class
`factorize.py` exists for (the same space declared twice)".
**That argument is two-thirds right, and this module is written to the
measured version of it.** ``docs/probes/probe_10_b11_scope_derivation.py``:
* ``global`` and ``per_epoch`` really are a plate-membership question --
``node.plate`` is empty for one and names the epoch plate for the other.
* ``linked`` is expressible, and reaches the exact path in its NON-CENTRED
form (iid innovations through a deterministic linear recursion). But in
that form the innovations are per-epoch and the chain lives in a
``Deterministic`` node, so **``node.plate`` cannot tell a linked latent
from an ordinary per-epoch one.**
Plate membership is therefore a hypothesis, not the answer, and this module
tests it rather than trusting it. :func:`epoch_leakage` asks the question the
fold actually depends on -- *does this latent touch any epoch but its own* --
and that is the same question for a linked latent, a mis-plated one, and a
model whose author simply did not think about it.
**What a leak can actually look like, measured.** Three facts about plates
narrow it, and each was found by building a fixture that did not work:
* a plated ``Deterministic``'s function sees a SCALAR for a plated parent, so
a cross-epoch map cannot be written as a plated node at all;
* a plated node whose parents are ALL unplated is refused by the graph itself
("nothing to map over");
* so a leak needs a plated consumer with BOTH a plated parent and an unplated
``(E,)`` map. Then it broadcasts, and every epoch's observation depends on
every epoch's latent.
The other reachable shape is an observation that is not plate-tagged at all:
its axis is an epoch axis only by the author's intention, and nothing in the
graph says so, which is exactly why it cannot be sliced. Both are refused,
and ``tests/marginal/test_factorize.py`` carries one fixture for each -- the
guard has two reachable branches and neither is decoration.
**Why it matters, measured.** A per-epoch nuisance is integrated exactly once,
inside its own epoch. One that also touches its neighbours is integrated E
times instead, and the result is a finite, plausible, WRONG evidence: on a
four-epoch fixture, folding a latent that leaks into the next epoch moves the
answer by 0.15 nats, and at sixteen and thirty-two epochs the gap runs to
several and then tens of nats with **no consistent sign**. At zero leakage
the fold is exact to the bit. Nothing downstream tests for it, which is why
this is a refusal rather than a diagnostic.
**A second refusal, added 2026-08-31, and found the same way.** The fold's
design is one jacobian of the observed predictions, taken at zeros. That is
the whole design ONLY when the predictions are affine in the survivors and
per-epoch latents jointly; on a map that is not, the jacobian describes the
point and not the model. Measured on `mu = g * n`: the jacobian at zeros is
identically zero, so the leakage probe above read 0.0, the folded term was a
finite constant independent of the data, and `log_prob({"g": x})` was bitwise
identical for x = -2.0, 0.0 and +2.0. The module's own docstrings had always
named `check_linearity` as the thing that says a non-affine map "cannot be
folded at all" -- nothing called it. :func:`factorize` does now, before the
leakage probe, because a leakage score is meaningless on a map whose jacobian
does not describe it.LEAK_RTOL
Defined in bayesmith.marginal.factorize, line 83.
LEAK_RTOL: float = 1e-10Factorization
Defined in bayesmith.marginal.factorize, line 86.
class Factorization(eqx.Module)
epoch_plate: str = eqx.field(static=True)
survivors: tuple[str, ...] = eqx.field(static=True)
per_epoch: tuple[str, ...] = eqx.field(static=True)A campaign's latents, split by the extent of data they are constant over.
Attributes:
epoch_plate: the plate whose axis indexes epochs.
survivors: latents outside that plate -- constant over the whole
campaign, so they are what a streamed term is over.
per_epoch: latents inside it -- integrated away within their own
epoch, and therefore never accumulated.epoch_leakage
Defined in bayesmith.marginal.factorize, line 102.
epoch_leakage(graph: Graph, name: str, epoch_plate: str, at: dict[str, Any]) -> floatHow far outside its own epoch a per-epoch latent reaches, relatively.
``0.0`` means the latent touches only its own epoch's observations, which
is what makes integrating it once per epoch the same as integrating it
once. Anything else means it does not.
The measurement is one forward-mode jacobian of the observed predictions
with respect to the latent -- the same map :func:`compress` will build a
design from -- so it asks about the model the fold will actually see,
not about a declaration.
Returns:
``max |off-epoch| / max |any|`` over every observed node, or ``0.0``
if the latent moves nothing at all. Relative to the latent's OWN
largest sensitivity, so a latent the model barely uses is judged on
the same scale as one it leans on -- an absolute floor would wave
through a leak in a weakly-used nuisance and refuse a well-used one
for roundoff.factorize
Defined in bayesmith.marginal.factorize, line 184.
factorize(graph: Graph, epoch_plate: str, *, at: dict[str, Any] | None=None, rtol: float=LEAK_RTOL) -> FactorizationDerive the campaign's factorization from the graph, and check it.
Plate membership gives the partition; :func:`epoch_leakage` and
:func:`~bayesmith.exact.linearity.check_linearity` decide whether it is
TRUE. Both are refusals rather than diagnostics, for measured reasons:
* a per-epoch latent that reaches its neighbours' data would be integrated
once per epoch instead of once, and the wrong answer is finite and
plausible;
* a map that is not affine in the survivors and per-epoch latents JOINTLY
makes the fold's design -- one jacobian, taken at zeros -- describe the
point rather than the model. Measured on `mu = g * n`: the jacobian
at zeros is identically zero, so the leakage probe read 0.0 and the
folded term was a finite constant independent of the data.
Affinity is checked BEFORE leakage, because the leakage score is one of
those jacobians at one point -- on a non-affine map it is meaningless.
Args:
graph: the model.
epoch_plate: the plate whose axis indexes epochs.
at: values for the latents this check holds fixed. Defaults to zeros,
which is enough because the leakage of an affine model does not
depend on the point -- and a model that is not affine in its
per-epoch latents cannot be folded at all.
rtol: relative leakage above which a latent is refused.
Raises:
StructureError: if the plate is not in the graph; if no latent
survives the epoch, which would leave nothing to accumulate; if
the observed predictions are not jointly affine in the survivors
and per-epoch latents; or if a per-epoch latent is not
epoch-local.