bayesmithDocumentation0.10.0 source

bayesmith.diagnose.sensitivity

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

Module contract
Prior sensitivity: how far each latent's own prior moved the answer.

Every latent in a graph carries a density, and every exit reads it. None of
them says what the prior *did*. That is a different question from "is the
posterior right", and it is the one a referee asks: the mode you report sits
somewhere between where the data put it and where the prior wanted it, and
the only honest way to quote it is with the distance stated.

**A chain cannot answer this about itself.** On the tour's nonlinear pair
(the cross-check fixture shared with rheplicant) the declared
``fg_beta ~ Normal(2.3, 0.3)`` moves the mode by **0.0069 sigma**. The Monte
Carlo standard error of a posterior mean from ``n_eff`` draws is
``1/sqrt(n_eff)`` sigma, so seeing 0.0069 sigma at all needs ``n_eff`` of
order 2 x 10^4, and *measuring* it needs a second chain with the prior
removed to difference against -- two chains whose noise adds in quadrature.
Running longer is not a small ask, it is a 10^4-fold one, and it answers a
question two Newton solves answer exactly.

Two routes, and they are both deterministic.

**The closed form.** Write the negative log-posterior as ``l(x) + 0.5 *
sum(((x - m) / s)^2)``, where ``l`` is every probabilistic term EXCEPT the
selected latents' own priors (the observed nodes, and any unselected latent
whose density the selection parameterises), and ``(m, s)`` are the selected
priors' location and scale. The direction convention, stated once and
unambiguously because its ancestor's docstring was read both ways:

    ``theta_hat - theta_L  =  H^{-1} P (m - theta_hat)``,   ``P = diag(s^-2)``

where ``theta_hat`` is the MAP, ``theta_L`` the mode of ``l`` alone, and
``H`` is ``l``'s curvature at ``theta_hat``. The left side is **MAP minus
likelihood-only mode**: a positive entry means the prior pulled that latent
UP relative to where the data alone would put it, and that is the sign
:attr:`PriorSensitivityReport.shift_sigma` reports. ``H`` is the
LIKELIHOOD's curvature, not the posterior's ``H + P`` already in hand for
``sigma_post``: putting that here is wrong by ``diag((H + P)^-1 P)``, the
prior's share of the posterior precision -- invisible where the prior is
weak and unbounded as it tightens, which is the regime the report exists to
describe.

Per latent the diagonal law is ``sigma_post * |m - theta_hat| / s^2`` --
what :attr:`PriorSensitivityReport.criterion_std` inverts -- plus a cross
term from every OTHER latent's prior. The cross term is not decoration: on
the tour's pair it is ten times the amplitude's own pull and of the opposite
sign, so a per-latent scalar formula would report the direction of the bias
wrongly, which is worse than reporting its size wrongly.

**The refit.** Newton to the mode with the selected priors in the
objective, Newton again with them removed, difference the two. No
expansion, no linearisation -- the answer the closed form is approximating.
Which of the two is the approximate one depends on the model, which is the
reason for shipping both rather than picking: on a latent the prediction is
affine in, the log-posterior is exactly quadratic, the closed form is exact
and the refit loses digits to cancellation; on a nonlinear pair the roles
reverse. :attr:`PriorSensitivityReport.verified` says whether they agreed.

Three things this does NOT do, stated so they are not assumed.

It is **local**. Both routes expand about one mode; a multimodal posterior
has more than one, and the prior's job there may be to select between them,
which is not a displacement and is not measured here.

It reads each selected latent's **own declared density** only. A selected
latent whose ``dist_fn`` is not a Gaussian with parameters fixed under the
selection is refused by name rather than approximated -- see
:func:`prior_sensitivity`'s ``Raises``. Two refusal classes its rheplicant
ancestor needed have no bayesmith shape at all: a latent with *no* prior
cannot be declared here (every ``sample`` node carries a density), and a
prior living at a solver call-site cannot either (the graph is the single
statement of the model).

It needs the **likelihood's** mode to exist. Along a direction the rest
term does not hold there is no such mode, only a ray, and the displacement
from a ray is not a number -- so a selection whose rest-term curvature is
singular or digit-starved at the mode is refused. The verdict is taken on
that curvature rather than on the observed Jacobian's rank, because in a
graph the two can disagree where they never could in rheplicant: a latent
that reaches no observed node can still be held by a downstream latent's
density, which is part of the rest term. The refusal delegates its NAMING
to :func:`~bayesmith.diagnose.identifiability.identifiability` where the
two verdicts agree, since that is the tool that attributes a null
direction to latents in scale-free coordinates.

CRITERION_SHIFT

Defined in bayesmith.diagnose.sensitivity, line 128.

CRITERION_SHIFT: float = 0.1

MAX_BACKTRACKS

Defined in bayesmith.diagnose.sensitivity, line 171.

MAX_BACKTRACKS: int = 40

MAX_JITTER_ESCALATIONS

Defined in bayesmith.diagnose.sensitivity, line 179.

MAX_JITTER_ESCALATIONS: int = 30

MAX_NEWTON_STEPS

Defined in bayesmith.diagnose.sensitivity, line 157.

MAX_NEWTON_STEPS: int = 100

NEWTON_TOL

Defined in bayesmith.diagnose.sensitivity, line 165.

NEWTON_TOL: float = 1e-13

VERIFY_ATOL

Defined in bayesmith.diagnose.sensitivity, line 150.

VERIFY_ATOL: float = 1e-06

VERIFY_RTOL

Defined in bayesmith.diagnose.sensitivity, line 141.

VERIFY_RTOL: float = 0.003

PriorSensitivityReport

Defined in bayesmith.diagnose.sensitivity, line 425.

class PriorSensitivityReport
names: tuple[str, ...]
shapes: tuple[tuple[int, ...], ...]
spans: tuple[tuple[int, int], ...]
n_par: int
mode: np.ndarray
prior_loc: np.ndarray
prior_std: np.ndarray
mean_offset: np.ndarray
sigma_post: np.ndarray
shift_sigma: np.ndarray
shift_sigma_refit: np.ndarray
verified: np.ndarray
criterion_std: np.ndarray
precision: np.ndarray
newton_steps: int
refit_steps: int
refit_converged: bool
_PER_ELEMENT: ClassVar[tuple[str, ...]] = ('mode', 'prior_loc', 'prior_std', 'mean_offset', 'sigma_post', 'shift_sigma', 'shift_sigma_refit', 'verified', 'criterion_std')
What the selected priors did to the mode, per latent, in posterior sigmas.

A plain frozen dataclass holding **numpy**, for the same reasons
:class:`~bayesmith.diagnose.identifiability.IdentifiabilityReport` is
one: a derived verdict rather than a differentiable model, whose float64
contents would silently truncate the moment a default-precision JAX
caller touched them, and whose ``verified`` is a decision no traced
program can branch on.

Every array is flat over the SELECTED latents, in the order they were
asked for -- :attr:`names` and :attr:`spans` are the only coordinate
system in the object, and it is the selection's own order, not sorted
order.

Attributes:
    names: the latents analysed, in the order the caller asked for.
    shapes: their shapes, in the same order.
    spans: ``(start, stop)`` of each latent in the flat vector.
    n_par: total number of real parameters.
    mode: ``theta_hat``, the MAP found by Newton on the exact
        log-posterior.
    prior_loc: the declared ``m``, broadcast per element.
    prior_std: the declared ``s``, broadcast per element.
    mean_offset: ``|m - theta_hat|``. A magnitude -- the direction of the
        pull lives in :attr:`shift_sigma`'s sign, where a reader will
        look for it.
    sigma_post: ``sqrt(diag(Sigma))`` at the mode, from the exact Hessian
        of the negative log-posterior -- the observed curvature the
        Newton refit walks on, which is the one the two routes have to
        share if their agreement is to mean anything.
    shift_sigma: the closed form, signed, in units of :attr:`sigma_post`.
        Positive means the prior pulled the latent UP relative to the
        likelihood-only mode; negative, DOWN.
    shift_sigma_refit: the same displacement from an actual second Newton
        solve with the selected priors removed. All-NaN if that solve did
        not converge, in which case :attr:`refit_converged` is ``False``.
    verified: per element, whether the two routes agreed to
        :data:`VERIFY_RTOL` (with :data:`VERIFY_ATOL` underneath).
    criterion_std: the prior width at which this latent's shift would
        reach :data:`CRITERION_SHIFT`, from the diagonal law
        ``sqrt(sigma_post * mean_offset / 0.1)``. Compare it with
        :attr:`prior_std`: a declared width comfortably ABOVE it is the
        statement that the prior is not driving the fit. ``0.0`` when the
        prior mean sits exactly on the mode, which means no tightening of
        it moves anything.
    precision: the ``(n_par, n_par)`` posterior precision at the mode.
    newton_steps, refit_steps: what the two solves cost.
    refit_converged: whether the likelihood-only solve reached a mode.

PriorSensitivityReport.for_latent

for_latent(self, name: str) -> dict[str, np.ndarray]
Every per-element quantity for one latent, reshaped like the latent.

The form a caller acts in:
``report.for_latent("fg_beta")["shift_sigma"]`` is a number about
beta, not an offset into a vector whose layout the caller has to
have got right.

PriorSensitivityReport.mode_of

mode_of(self, name: str) -> np.ndarray
``theta_hat`` for one latent, shaped like it.

PriorSensitivityReport.worst

worst(self) -> tuple[str, int, float]
``(latent, index within it, signed shift)``, by largest magnitude.

What to print first. An anonymous argmax over the flat vector would
name a position in a layout the caller did not choose.

PriorSensitivityReport.shift_at

shift_at(self, name: str, prior_std: Any) -> np.ndarray
The shift this latent would suffer under a DIFFERENT prior width.

The counterfactual :attr:`criterion_std` inverts, evaluated exactly
rather than through the diagonal law: only ``name``'s entries of
``P`` are replaced, and
``(H + P_s)^-1 P_s (I + H^-1 P_d)(m - theta_hat)`` is solved whole.
Every other latent's prior stays as declared, cross terms included
-- which is why this can return a shift of the opposite sign to
``name``'s own pull once ``name``'s prior is loose enough for a
neighbour's to dominate.

**Anchoring.** That is not the expression :attr:`shift_sigma` uses,
and the comment on the solve below derives why: the two are the same
displacement written about different modes, and a counterfactual can
only stand on the likelihood's. The ``(I + H^-1 P_d)`` factor is how
it gets there without a second fit, and it makes the whole thing
exact on a quadratic. Two consequences worth stating: at
``P_s = P_d`` it collapses to :attr:`shift_sigma` algebraically, so
``shift_at(name, declared_width)`` returns the reported shift to the
last bit -- if it did not, one of the two would be wrong. And what
is left at tight widths is the model, not the method.

Reported in the sigma the caller actually HAS -- :attr:`sigma_post`,
at the declared priors -- not in the sigma the counterfactual prior
would produce. Dividing each row of a ladder by its own width would
fold the prior's shrinking of the error bar into a number meant to
report only the movement of the mode, and 'a 0.1 sigma shift' would
then mean a different displacement in every row.

Args:
    name: the latent to re-prior.
    prior_std: its hypothetical width -- a scalar, or anything
        broadcastable to the latent's shape.

Returns:
    The signed shift in posterior sigmas, shaped like the latent.

Raises:
    GraphError: if ``name`` is not in this report, or the width is
        not positive and finite, or does not broadcast.

prior_sensitivity

Defined in bayesmith.diagnose.sensitivity, line 655.

prior_sensitivity(graph: Graph, *, names: Sequence[str] | str | None=None, at: dict[str, jax.Array] | None=None) -> PriorSensitivityReport
How far the selected priors moved the mode, in posterior sigmas.

See the module docstring for why a NUTS run cannot be asked this and for
the two routes taken instead. Both are deterministic; nothing here
samples. The data and the noise are the graph's own -- its observed
nodes -- so there is nothing separate to pass and nothing that can
disagree with what a sampler would read.

The work is two Newton solves -- one on the exact log-posterior, one
with the selected priors removed -- plus one dense Jacobian for the rank
check. That is a design-time cost for tens to a few thousand parameters,
the same envelope :func:`~bayesmith.diagnose.identifiability.
identifiability` states, and for the same reason.

Args:
    graph: the model. Every SELECTED latent must carry a diagonal
        Gaussian density whose parameters are fixed under the selection.
    names: which latents to analyse -- a sequence, or a bare string for
        one. ``None`` means all of them, in declaration order. A subset
        asks the CONDITIONAL question, holding the rest at ``at``.
    at: values for the latents NOT selected, and the Newton starting
        point for those that are. Defaults to each latent's prior
        centre.

Returns:
    A :class:`PriorSensitivityReport`. The two numbers to read first are
    ``shift_sigma`` and ``criterion_std``::

        report = prior_sensitivity(graph)
        name, index, shift = report.worst
        criterion = report.for_latent(name)["criterion_std"].ravel()[index]
        print(f"{name}[{index}] moved {shift:+.4f} sigma by its prior; "
              f"0.1 sigma would need s = {criterion:.3g}")

Raises:
    GraphError: if ``names``/``at`` name an undeclared latent or a value
        does not broadcast; if a selected latent is complex or
        non-floating; if a selected latent's density is parameterised by
        the selection itself; if the likelihood's own curvature at the
        mode is singular or digit-starved, so the likelihood-only mode
        the shift is measured from does not exist; or if the arithmetic
        comes back float32 -- run the call (graph construction included)
        inside ``with jax.enable_x64(True):``.
    NotGaussian: if a selected latent's density has no quadratic form (a
        Uniform, an ImproperUniform, a LogNormal, ...).
    ConvergenceError: if the Newton solve for the MAP does not converge.
        (A failed VERIFICATION refit is reported, not raised:
        ``refit_converged=False`` and an all-NaN refit column.)

Type a concept or API name.