Source code for pyfli.bayes_utils.unc_trustworthiness

"""Per-pixel posterior uncertainty (MAD), credible-interval and goodness-of-fit
trustworthiness maps."""

from functools import lru_cache

import numpy as np
from scipy.special import gammaln, xlogy
from scipy.stats import median_abs_deviation

_TRUST_EPS = 1e-12
_MU_MIN = 1e-6
_MU_MAX = 1e4
_N_GRID = 800


[docs] def unc(samples, axis=-1, scale=1.0): """ Median absolute deviation (MAD) of posterior samples. Parameters ---------- samples : np.ndarray Posterior draws, e.g. ``(H, W, NUM_SAMPLES)`` from ``BiPipeline.output_samples[key]``. axis : int Sample axis. scale : float or str Passed to :func:`scipy.stats.median_abs_deviation`. ``1.0`` gives the raw MAD (same as ``BiPipeline`` uncertainties); ``"normal"`` gives a standard-deviation-comparable value. Returns ------- np.ndarray MAD map with the sample axis removed. """ return median_abs_deviation(np.asarray(samples, float), axis=axis, scale=scale)
[docs] def trustworthiness_CrI(samples, median_map=None, ci=90.0, axis=-1): """ Trustworthiness from the relative credible-interval width: ``1 / (1 + (q_high - q_low) / |median|)``, in ``(0, 1]``. Parameters ---------- samples : np.ndarray Posterior draws, e.g. ``(H, W, NUM_SAMPLES)``. median_map : np.ndarray | None Posterior median map; computed from ``samples`` when ``None``. ci : float Central credible interval in percent (90 uses the 5th and 95th percentiles). axis : int Sample axis. Returns ------- np.ndarray Trustworthiness map. ``NaN`` where the median is ~0 (relative width undefined, including all-zero masked-out pixels). """ if not 0 < ci < 100: raise ValueError(f"ci must be in (0, 100), got {ci}.") samples = np.asarray(samples, float) if median_map is None: median_map = np.median(samples, axis=axis) median_map = np.asarray(median_map, float) map_shape = tuple(np.delete(samples.shape, axis % samples.ndim)) if median_map.shape != map_shape: raise ValueError( f"median_map shape {median_map.shape} does not match samples shape " f"{samples.shape} without axis {axis}." ) tail = (100.0 - ci) / 2.0 q_low, q_high = np.percentile(samples, [tail, 100.0 - tail], axis=axis) denom = np.abs(median_map) defined = denom > _TRUST_EPS rciw = np.divide( q_high - q_low, denom, out=np.full(denom.shape, np.nan), where=defined ) return 1.0 / (1.0 + rciw)
@lru_cache(maxsize=4) def _deviance_moment_table(mu_min=_MU_MIN, mu_max=_MU_MAX, n_grid=_N_GRID): mu_grid = np.logspace(np.log10(mu_min), np.log10(mu_max), n_grid) E = np.empty(n_grid) V = np.empty(n_grid) for i, m in enumerate(mu_grid): half_width = 12.0 * np.sqrt(m) + 20.0 y = np.arange( max(0, int(m - half_width)), int(np.ceil(m + half_width)) + 1, dtype=float ) p = np.exp(xlogy(y, m) - m - gammaln(y + 1.0)) d = 2.0 * (xlogy(y, y / m) - (y - m)) E[i] = np.sum(p * d) V[i] = np.sum(p * (d - E[i]) ** 2) return np.log(mu_grid), E, V
[docs] class trustworthiness_gof: """ Goodness-of-fit score ``G`` in ``(0, 1]`` for FLIM / TCSPC decay fits. The fit's Poisson deviance ``D`` is standardized against its exact expected mean and variance under Poisson noise (so dim pixels are not rewarded for ``chi2_nu < 1``), giving ``z``, which is mapped to a score: - ``mode="one-sided"``: ``G = exp(-max(0, z)^2 / (2 z_plus^2))`` penalizes only underfitting. - ``mode="two-sided"``: additionally multiplies by ``exp(-max(0, -z)^2 / (2 z_minus^2))`` to penalize implausibly good fits. The time-bin axis is ``axis`` (last by default), so ``y`` and ``mu`` can be ``(K,)``, ``(n_pixels, K)`` or ``(H, W, K)``. Parameters ---------- n_params : int Number of fitted parameters, subtracted from the expected deviance. mode : str ``"one-sided"`` or ``"two-sided"``. z_plus : float Width of the penalty for ``z > 0`` (underfitting). z_minus : float Width of the penalty for ``z < 0`` (overfitting), two-sided mode only. expectation : str ``"exact"``: ``z = (D - (sum E_k - n_params)) / sqrt(sum V_k)``. ``"asymptotic"``: ``z = (D - nu) / sqrt(2 nu)``. axis : int Time-bin axis. """ MODES = ("one-sided", "two-sided") EXPECTATIONS = ("exact", "asymptotic") def __init__( self, n_params, mode="one-sided", z_plus=3.0, z_minus=5.0, expectation="exact", axis=-1, ): if mode not in self.MODES: raise ValueError(f"mode must be one of {self.MODES}, got {mode!r}.") if expectation not in self.EXPECTATIONS: raise ValueError( f"expectation must be one of {self.EXPECTATIONS}, got {expectation!r}." ) if z_plus <= 0 or z_minus <= 0: raise ValueError("z_plus and z_minus must be positive.") self.n_params = n_params self.mode = mode self.z_plus = z_plus self.z_minus = z_minus self.expectation = expectation self.axis = axis
[docs] @staticmethod def deviance_terms(y, mu): """Per-bin Poisson deviance ``2 [y ln(y/mu) - (y - mu)]`` (``2 mu`` when ``y = 0``).""" y = np.asarray(y, dtype=float) mu = np.clip(np.asarray(mu, dtype=float), 1e-12, None) return 2.0 * (xlogy(y, y / mu) - (y - mu))
[docs] @staticmethod def expected_moments(mu): """Exact per-bin ``(E[d], Var[d])`` for ``y ~ Poisson(mu)``, same shape as ``mu``.""" mu = np.asarray(mu, dtype=float) log_grid, E_tab, V_tab = _deviance_moment_table() log_mu = np.log(np.clip(mu, _MU_MIN, None)) E = np.interp(log_mu, log_grid, E_tab) V = np.interp(log_mu, log_grid, V_tab) large = mu > _MU_MAX return np.where(large, 1.0, E), np.where(large, 2.0, V)
[docs] def zscore(self, y, mu, mask=None): """ Standardized deviance per pixel. Parameters ---------- y : np.ndarray Measured counts. mu : np.ndarray Fitted model counts, same shape as ``y``. mask : np.ndarray | None Boolean array broadcastable to ``y`` selecting the fit window. Returns ------- tuple[np.ndarray, np.ndarray, np.ndarray] ``(z, D, nu)`` with the time-bin axis removed. """ d = self.deviance_terms(y, mu) w = ( np.ones_like(d) if mask is None else np.broadcast_to(mask, d.shape).astype(float) ) D = np.sum(w * d, axis=self.axis) nu = np.sum(w, axis=self.axis) - self.n_params if self.expectation == "exact": E, V = self.expected_moments(np.broadcast_to(mu, d.shape)) mean = np.sum(w * E, axis=self.axis) - self.n_params var = np.sum(w * V, axis=self.axis) else: mean, var = nu, 2.0 * nu z = (D - mean) / np.sqrt(np.maximum(var, 1e-12)) return z, D, nu
[docs] def score(self, z): """Map standardized deviance ``z`` to the fit score ``G`` in ``(0, 1]``.""" z = np.asarray(z, dtype=float) penalty = np.maximum(0.0, z) ** 2 / (2.0 * self.z_plus**2) if self.mode == "two-sided": penalty = penalty + np.maximum(0.0, -z) ** 2 / (2.0 * self.z_minus**2) return np.exp(-penalty)
def __call__(self, y, mu, mask=None): """ Goodness of fit for every pixel. Parameters ---------- y : np.ndarray Measured counts. mu : np.ndarray Fitted model counts, same shape as ``y``. mask : np.ndarray | None Boolean array broadcastable to ``y`` selecting the fit window. Returns ------- dict ``D``, ``nu``, ``chi2_nu``, ``z`` and ``G`` maps. """ z, D, nu = self.zscore(y, mu, mask=mask) return { "D": D, "nu": nu, "chi2_nu": D / np.maximum(nu, 1), "z": z, "G": self.score(z), }