bayesmithDocumentation0.10.0 source

bayesmith.exact.loglinear

Source-derived signatures and contracts. See the reference index for coverage and conventions.

Module contract
Log space as a graph transform: multiplicative noise made conjugate.

Two observation models produce data whose LOG is Gaussian about an affine
prediction, and they arrive in different clothes:

* **multiplicative Gaussian** -- ``d = mu (1 + f w)``, ``w ~ N(0, 1)``: the
  radiometer equation's own form, declared as ``Normal(mu, f |mu|)``. Taking
  logs, ``log d = log mu + log(1 + f w)`` and ``log(1 + f w) -> N(-f^2/2,
  f^2)`` to first order -- an approximation whose size is measured below.
* **log-Gaussian** -- ``d = mu exp(s w)``, declared as ``LogNormal(ell, s)``
  with ``ell`` the log-location: here ``log d = ell + s w`` EXACTLY, with no
  correction term and no threshold.

Either way, in log space the noise is additive AND its scale no longer
depends on the prediction -- so a latent the prediction is ``exp``-affine in
(a gain entered as ``exp(log_gain)``) has an exact conjugate block there,
where in the original space it had only NUTS; and the reweighting fixed point
:func:`~bayesmith.exact.gls.iterative_gls` exists to find has nothing left to
iterate on.

**The implementation is a graph-to-graph transform, and that is the design.**
:func:`log_space` rewrites each observed node into an ordinary ``Normal`` in
log space -- ``loc`` becomes ``log`` of the old prediction (or the LogNormal's
own ``ell``, which already is one), ``scale`` becomes the constant ``f`` (or
``s``), ``observed`` becomes ``log d`` (plus ``f^2/2`` in the first-order
case), and ``depends_on_prediction`` becomes ``False`` because it genuinely
is. Everything downstream is then STOCK: the per-element affinity check with
its two criteria and roundoff floors, ``unchecked_operator``,
``wiener_solve``/``gcr_sample``, ``precision_at``, the Gibbs machinery -- none
of it knows or needs to know that the graph it was handed carries logs. One
transform, zero re-implementations, and the checking machinery that catches a
false claim is the same one that catches it in linear space.

**Which scenario a node is, is probed rather than declared.** The transform
evaluates each observed node's distribution at two points of the latents'
own priors: a ``LogNormal`` is the exact case by type; a ``Normal`` whose
``scale / loc`` is the same at both points (while ``loc`` itself moved) is
multiplicative with that ratio as ``f``; a ``Normal`` whose scale did not
track the prediction is additive, and log space is refused for it --
:class:`~bayesmith.errors.NotLogLinear` -- because a transform that does not
simplify the noise is just a different, wrong likelihood.

**The first-order approximation, and its size.** ``E[log(1 + f w)] =
-f^2/2`` to leading order and ``Var[log(1 + f w)]`` exceeds ``f^2``.
Measured over 2e7 draws:

======  ==================  =====================
``f``   ``Var / f^2 - 1``   ``mean / (-f^2/2)``
======  ==================  =====================
0.004   below the MC floor  1.00
0.06    0.0088              1.006
0.10    0.0258              1.016
0.30    0.3983              1.185
======  ==================  =====================

:data:`FIRST_ORDER_MAX_FRACTIONAL` refuses above 0.06, where the variance
taken as ``f^2`` is 1 % wrong. A radiometer at 61 kHz x 1 s has
``f = 4.05e-3`` -- fifteen times below the refusal -- so the threshold is
not a limit met by observing; reaching it means a channel width or an
integration time is not what was intended. The ``LogNormal`` route has no
threshold at all, which is the reason to declare one when the noise really
is log-Gaussian.

**Positivity is refused eagerly, never propagated.** ``log`` of a
non-positive value is NaN, and ``nan > rtol`` is ``False`` -- so a NaN
departure would read as PASSING the affinity check, and a NaN residual as a
converged solve. Data is checked at transform time on concrete values;
a prediction that goes non-positive at a probe surfaces through the stock
non-finite-baseline refusal.

**Ownership.** bayesmith owns the graph-side transform, its approximation
threshold and mean-shift arithmetic, the refusal vocabulary, and the oracles
that certify them. rheplicant now consumes that arithmetic while retaining its
pipeline-specific adapter and probe machinery. This implementation was
originally ported from ``rheplicant.inference.loglinear`` and re-founded on the
graph; that is historical provenance, not a second source of truth. The
bayesmith form is one transform in front of the existing exact machinery,
whose per-element check also adds a sigma-weighted second criterion absent
from the original implementation.

FIRST_ORDER_MAX_FRACTIONAL

Defined in bayesmith.exact.loglinear, line 111.

FIRST_ORDER_MAX_FRACTIONAL: float = 0.06

LOG_DEFAULT_SCALES

Defined in bayesmith.exact.loglinear, line 121.

LOG_DEFAULT_SCALES: tuple[float, ...] = (0.001, 0.01, 0.1, 1.0)

LogSpace

Defined in bayesmith.exact.loglinear, line 131.

class LogSpace
graph: Graph
kind: dict[str, str]
fractional: dict[str, jax.Array]
skipped: dict[str, str]
log_jacobian: jax.Array = dataclasses.field(default_factory=lambda: jnp.zeros(()))
A graph taken to log space, with the reading that justified it.

Attributes:
    graph: the transformed model. Every observed node is an ordinary
        ``Normal`` whose ``loc`` is the log-prediction, whose ``scale``
        is constant in the latents (``depends_on_prediction=False``, and
        genuinely so), and whose ``observed`` is the log data, shifted by
        ``f^2/2`` where the first-order route applied. Latents, priors,
        deterministic structure and plates are untouched -- which is why
        every downstream consumer takes this graph verbatim.
    kind: ``{observed: "lognormal" | "multiplicative"}`` -- which scenario
        each node was read as. ``"lognormal"`` is exact;
        ``"multiplicative"`` carries the first-order caveat the module
        docstring sizes.
    fractional: ``{observed: f}`` for the multiplicative nodes, absent
        for the exact ones. Broadcast to the node's shape, so a
        channel-dependent bandwidth is representable.
    skipped: ``{observed: why}`` for nodes left UNTRANSFORMED -- additive
        noise, a distribution the route does not read. A skipped node
        keeps its original form in :attr:`graph`, which is harmless to a
        log block that does not reach it (its design column there is
        zero) and disqualifying for one that does --
        :func:`~bayesmith.dispatch.factor.factor_partition` checks
        reachability against exactly this dict.
    log_jacobian: the log absolute Jacobian of the change of variables
        this transform performed, summed over every node whose data was
        replaced by its logarithm. For ``y = log d`` that is
        ``sum(log |dy/dd|) = -sum(log d)``, and it is the term that
        SEPARATES the two densities:

            ``log_joint(original) == log_joint(graph) + log_jacobian``

        so it is the correction an evidence ADDS, sign included, rather
        than a magnitude a caller has to orient. Measured on a six-point
        fixture: ``-1.3459909820953684``.

        Recorded rather than applied, and the distinction is the whole of
        it. Adding it to :attr:`graph`'s own ``log_joint`` would move every
        downstream consumer by a data-dependent constant that no posterior
        shape, no diagnostic and no predictive check can see -- the class
        of silent change R4 exists to prevent. An evidence assembler reads
        this field and nothing else does; a posterior is unaffected because
        a constant is unaffected by conditioning.

        Zero for a graph with no transformed node, which cannot happen --
        ``log_space`` refuses that case -- but is the right value anyway.

check_log_linearity

Defined in bayesmith.exact.loglinear, line 508.

check_log_linearity(graph: Graph, names: Iterable[str], at: dict[str, Any] | None=None, *, scales: tuple[float, ...]=LOG_DEFAULT_SCALES, rtol: float | None=None, at_points: Any=None, key: jax.Array | None=None) -> dict[int, dict[float, float]]
Verify ``log`` of every prediction is affine in a block -- or a group.

:func:`log_space` decides the scenarios, then the STOCK
:func:`~bayesmith.exact.linearity.check_linearity` runs on the
transformed graph -- same per-element criteria, same roundoff floors,
same at-point sweep over the outside latents' priors. There is no
separate log-space probe loop to drift from the linear one.

Args and returns as for the stock check, with one difference:
``scales`` defaults to :data:`LOG_DEFAULT_SCALES`, for the overflow
reason its docstring gives.

Raises:
    NotLogLinear: if no log-space reading exists (from
        :func:`log_space`), or if log of the prediction departs from
        affinity in these latents -- the inner check's refusal, re-raised
        under the blameless class with its message carried verbatim, so
        a dispatcher probing many latents can catch "no" narrowly.
    GraphError: propagated -- a malformed ``names`` or ``at`` is misuse,
        not a classification outcome.

log_linear_operator

Defined in bayesmith.exact.loglinear, line 553.

log_linear_operator(graph: Graph, names: Iterable[str], at: dict[str, Any] | None=None, *, nuts_latents: Iterable[str] | None=None, scales: tuple[float, ...]=LOG_DEFAULT_SCALES, rtol: float | None=None, at_points: Any=None, key: jax.Array | None=None) -> tuple[LinearBlock, LogSpace]
Check the log-linearity claim, then export the block. The entry point.

The returned block is an ordinary
:class:`~bayesmith.exact.block.LinearBlock` over the TRANSFORMED graph:
its ``offset`` is the log-prediction with the block at zero, its ``data``
is the log data (shifted by ``f^2/2`` where first-order), and the
matching noise is ``precision_at(returned.graph, values)`` -- constant in
the latents, which is the point. The :class:`LogSpace` comes back with
the block because every further step (a Gibbs sweep's rebuilds, the
precision, a NUTS comparison) runs against the transformed graph, and
handing back the block alone would leave the caller to reconstruct it.
``nuts_latents`` is the completed plan's global NUTS witness; evidence-
bearing transformed graphs forward it unchanged to the stock operator.

log_space

Defined in bayesmith.exact.loglinear, line 410.

log_space(graph: Graph) -> LogSpace
Take the graph to log space, deciding each observed node's scenario.

See the module docstring for the two scenarios and for why the result is
an ordinary graph. The probe environments are two points of the latents'
own priors, so the scenario is read where inference will actually run.

A node with no log-Gaussian reading -- additive noise, a distribution
the route does not read -- is left UNTRANSFORMED and recorded in
``skipped`` with its reason, rather than refusing the whole transform:
on a graph with several observations, a log block only ever needs the
ones ITS latents reach, and a caller (``factor_partition`` does) checks
that reachability against ``skipped``. Non-positive DATA on an otherwise
multiplicative node still refuses loudly -- that is a contradiction in
the model, not a scenario mismatch.

Raises:
    NotLogLinear: if NO observed node could be transformed -- then there
        is no log space to hand back at all, and the aggregated per-node
        reasons say why; or if a multiplicative node's data is
        non-positive, which no routing can repair.
    StructureError: propagated from the graph machinery where the graph
        itself is broken, which is a different statement from "no
        log-linear structure" and deliberately keeps its own class.

multiplicative_log_data

Defined in bayesmith.exact.loglinear, line 189.

multiplicative_log_data(observed: jax.Array, fractional: jax.Array | float) -> tuple[jax.Array, jax.Array]
``(log d + f^2/2, f)`` -- the first-order transform, on bare arrays.

The two halves of one claim about the noise, returned together so a
caller cannot take the shift without the sigma that goes with it. The
``f^2/2`` adds back ``E[log(1 + f w)]`` to leading order; it is a
CONSTANT -- the same for every sample, independent of the prediction --
so adding it is exact arithmetic rather than an estimate.

Positivity of ``observed`` is the CALLER's obligation here, stated
rather than checked: this is the seam another package's own data
handling (masks, flags) delegates its arithmetic to, and a check on this
side would force that caller to pre-fill values it is about to weight by
zero anyway. :func:`log_space`, which owns whole graphs, does check.

Type a concept or API name.