Source code for binference.samplers.nuts

"""The No-U-Turn Sampler engine (exact for every prior)."""

from __future__ import annotations

import copy
import os
import pickle
import warnings
from collections.abc import Iterator
from typing import Any

import numpy as np

from .._utils import RNGLike, progress, spawn, warn
from ..model import PopulationModel
from ..results import PopulationResult
from ..warnings import ConvergenceWarning
from ._curvature import covariance_from_hessian, fd_hessian, fd_hessian_diag
from ._nuts_core import Metric, adaptation_windows, find_reasonable_eps, nuts_step
from .base import Engine

__all__ = ["NUTS"]

#: ``target_accept`` when it is ``None``, except for logistic priors.
_TARGET_ACCEPT = 0.8
#: ``target_accept`` for logistic priors when it is ``None``.
_TARGET_ACCEPT_LOGISTIC = 0.9
#: ``metric="auto"`` is dense for logistic priors up to this many dimensions, and up to
#: this many the warm-up curvature is averaged over every chain's point, not taken at one.
_DENSE_MAX_DIM = 200
#: Up to this many dimensions, warm-up starts the metric from the curvature at the mode.
_HESSIAN_MAX_DIM = 500
# Partial centring re-creates a funnel when the field itself pins the scale
# hyperparameters down poorly (few modes per hyperparameter). In validation runs 0.28
# separated K <= 6, N <= 40 (0.33-0.62; divergences when centred) from K >= 10
# (0.11-0.25; 3-20x more ESS/s when centred).
#: Logistic priors stay non-centred when the median spread of the modes' log-scales given
#: the field exceeds this.
_CENTRING_MAX_SD = 0.28
#: Warm-up iteration of the partial centring of logistic priors, or the first slow
#: window's start if that is earlier.
_GEOMETRY_ITER = 25
#: ``t0``, ``gamma`` and ``kappa`` of the dual-averaging step-size adaptation.
_DUAL_AVERAGING = (10.0, 0.05, 0.75)


def _centred(model: PopulationModel, lam: np.ndarray) -> PopulationModel:
    """The model with its logistic prior partially centred by weights ``lam``."""
    return model._with_bound_prior(model.prior.with_centering(lam))


def _recentre(prior, x: np.ndarray, lam: np.ndarray) -> np.ndarray:
    """``prior.recentre`` on the prior's block of ``x``; an inferred selection's
    logit S after it is unchanged (DECISIONS #80)."""
    d = prior.dim
    return np.concatenate([prior.recentre(x[:d], lam), x[d:]])


def _conditional_log_scale_sd(model, prior, pts: list[np.ndarray]) -> np.ndarray:
    """Spread of each mode's log-scale given the field, from the hyperparameter curvature.

    In the centred form the field stays put while the hyperparameters move, so the
    curvature of the hyperparameter block alone says how well the field pins them.
    (The marginal spread from the full Hessian is unreliable: weakly identified
    length scales can make it arbitrarily large.) Modes with fixed scales get 0.
    """
    n_act = prior.n_active
    if prior.n_free == 0:
        return np.zeros(n_act)
    zeros = np.zeros(n_act)
    fn = _centred(model, zeros).log_density_unconstrained_and_grad
    hyper = range(n_act, prior.dim)  # the hyperparameters, before any logit S
    H = np.mean(
        [fd_hessian(fn, _recentre(prior, x, zeros), cols=hyper)[hyper] for x in pts], axis=0
    )
    cov = covariance_from_hessian(H)
    if cov is None:
        return np.full(n_act, np.inf)
    _, dlogs = prior.log_scales(prior.theta_from_x(np.mean(pts, axis=0)[: prior.dim]))
    G = dlogs[prior.free]  # (n_free, n_active): d log s_j / d log theta
    return np.sqrt(np.maximum(np.einsum("aj,ab,bj->j", G, cov, G), 0.0))


class _Chain:
    """State of one NUTS chain, including its step-size and metric adaptation."""

    def __init__(self, x0, fn, rng, n_warmup, target_accept, dense):
        self.fn = fn
        self.rng = rng
        self.x = np.asarray(x0, dtype=float)
        self.lp, self.g = fn(self.x)
        d = self.x.size
        self.metric = Metric(np.eye(d) if dense else np.ones(d))
        self.informed = False  # metric initialised from curvature rather than the identity
        self.dense = dense
        self.eps = find_reasonable_eps(self.x, self.lp, self.g, self.metric, fn, rng)
        self.target = target_accept
        self._restart_dual_averaging()
        self.windows = adaptation_windows(n_warmup)
        self.win_ends = {e for _, e in self.windows}
        self.buf: list[np.ndarray] = []
        self.n_warmup = n_warmup
        self.it = 0

    def reset_geometry(self, fn, x, inv_m) -> None:
        """Move to a new parametrisation and/or metric mid warm-up."""
        self.fn = fn
        self.x = np.asarray(x, dtype=float)
        self.lp, self.g = fn(self.x)
        if inv_m is not None:
            self.metric = Metric(inv_m)
            self.informed = True
        self.buf = []
        self.eps = find_reasonable_eps(self.x, self.lp, self.g, self.metric, self.fn, self.rng)
        self._restart_dual_averaging()

    def _restart_dual_averaging(self):
        self.mu = np.log(10.0 * self.eps)
        self.Hbar = 0.0
        self.log_eps_bar = 0.0
        self.m = 0

    def step(self, max_depth, adapt):
        x, lp, g, acc, depth, div, nl, energy = nuts_step(
            self.x, self.lp, self.g, self.eps, self.metric, self.fn, self.rng, max_depth
        )
        self.x, self.lp, self.g = x, lp, g
        if adapt:
            self._adapt(acc)
        self.it += 1
        return acc, depth, div, nl, energy

    def _adapt(self, acc):
        t0, gamma, kappa = _DUAL_AVERAGING
        self.m += 1
        m = self.m
        self.Hbar = (1.0 - 1.0 / (m + t0)) * self.Hbar + (self.target - acc) / (m + t0)
        log_eps = self.mu - np.sqrt(m) / gamma * self.Hbar
        eta = m ** (-kappa)
        self.log_eps_bar = eta * log_eps + (1.0 - eta) * self.log_eps_bar
        self.eps = float(np.exp(log_eps))
        it = self.it
        if any(s <= it < e for s, e in self.windows):
            self.buf.append(self.x.copy())
        if (it + 1) in self.win_ends:
            arr = np.asarray(self.buf)
            n = len(arr)
            d = self.x.size
            prev = self.metric.inv_m
            if self.dense:
                cov = np.atleast_2d(np.cov(arr.T)) if n > 1 else prev
                if self.informed:  # shrink toward the curvature-based metric
                    k = d + 5.0
                    inv_m = (n / (n + k)) * cov + (k / (n + k)) * prev
                else:  # Stan's regularisation
                    inv_m = (n / (n + 5.0)) * cov + 1e-3 * (5.0 / (n + 5.0)) * np.eye(d)
            else:
                var = arr.var(axis=0, ddof=1) if n > 1 else prev
                if self.informed:
                    inv_m = (n / (n + 5.0)) * var + (5.0 / (n + 5.0)) * prev
                else:
                    inv_m = (n / (n + 5.0)) * var + 1e-3 * (5.0 / (n + 5.0))
            self.metric = Metric(inv_m)
            self.buf = []
            self.eps = find_reasonable_eps(self.x, self.lp, self.g, self.metric, self.fn, self.rng)
            self._restart_dual_averaging()
        if it == self.n_warmup - 1 and self.m > 0:
            self.eps = float(np.exp(self.log_eps_bar))


_STAT_KEYS = ("accept_stat", "tree_depth", "diverging", "n_leapfrog", "energy")


def _advance_chain(chain: _Chain, n_iter: int, adapt: bool, max_depth: int, record: bool):
    """Advance one chain; returns ``(draws, log_probs, stats, n_divergent)``."""
    d = chain.x.size
    xs = np.empty((n_iter, d)) if record else None
    lps = np.empty(n_iter) if record else None
    st = np.empty((len(_STAT_KEYS), n_iter)) if record else None
    n_div = 0
    for i in range(n_iter):
        acc, depth, div, nl, energy = chain.step(max_depth, adapt)
        n_div += int(div)
        if record:
            xs[i] = chain.x
            lps[i] = chain.lp
            st[:, i] = (acc, depth, div, nl, energy)
    return xs, lps, st, n_div


def _cpu_count() -> int:
    # the cores this process may use (CPU affinity, e.g. under a batch scheduler)
    return os.process_cpu_count() or 1


def _worker_threads(n_workers: int) -> int:
    """BLAS threads per worker: the cores shared between workers, never above the
    limit already in force in this process (e.g. ``OMP_NUM_THREADS=1``; audit N-3)."""
    from threadpoolctl import threadpool_info

    share = max(1, _cpu_count() // n_workers)
    info = threadpool_info()
    # the BLAS pools' limit (another runtime, e.g. a single-threaded OpenMP, does not cap
    # it); where no BLAS pool is visible (e.g. Accelerate), every reported limit counts
    blas = [lib["num_threads"] for lib in info if lib.get("user_api") == "blas"]
    limits = [max(blas)] if blas else [lib["num_threads"] for lib in info]
    return min([share, *limits])


_WORKER_MODEL: PopulationModel | None = None  # the base model, sent once per worker


def _worker_model(model: PopulationModel) -> PopulationModel:
    """The model without the data kept for value draws (DECISIONS #84), which workers
    never use: kept samples need not be copied to each of them."""
    lk = model.likelihoods
    if "within" not in lk.meta:
        return model
    out = copy.copy(model)
    out.likelihoods = lk._copy(meta={k: v for k, v in lk.meta.items() if k != "within"})
    return out


def _init_worker(model: PopulationModel, n_threads: int) -> None:
    global _WORKER_MODEL
    from threadpoolctl import threadpool_limits

    _WORKER_MODEL = model
    threadpool_limits(limits=n_threads)


def _chain_worker(payload):
    """Run in a worker process: advance one chain and send it back without the model.

    An error from the model comes back as a value, so the parent can tell it from a
    failure of the pool itself (audit N-7).
    """
    chain, lam, n_iter, adapt, max_depth, record = payload
    try:
        model = _WORKER_MODEL if lam is None else _centred(_WORKER_MODEL, lam)
        chain.fn = model.log_density_unconstrained_and_grad
        out = _advance_chain(chain, n_iter, adapt, max_depth, record)
    except Exception as err:
        return err
    chain.fn = None
    return chain, out


[docs] class NUTS(Engine): """No-U-Turn Sampler on the exact posterior, for every prior. Samples the unconstrained coordinates of the model (isometric log-ratios for simplex priors, whitened fields and log-hyperparameters for smooth priors, log-rates for rates) with multinomial NUTS, dual-averaging step size adaptation and Stan-style windowed metric adaptation. Chains run in lock-step, so the engine supports progress reporting, a generator interface (:meth:`sample`) and resuming (call :meth:`run` again to draw more samples without re-running warm-up). Parameters ---------- model : PopulationModel n_chains : int Number of independent chains (default 4; needed for R-hat). n_warmup : int Warm-up (adaptation) iterations per chain. target_accept : float, optional Target mean acceptance statistic for step-size adaptation. Defaults to 0.8, or 0.9 for logistic (smoothness) priors, whose hyperparameters make the geometry harder. max_depth : int Maximum tree depth (at least 1; at most ``2**max_depth`` gradient evaluations per draw). metric : {"auto", "diag", "dense"} Adapted Euclidean metric. ``"auto"`` uses a dense metric for logistic (smoothness) priors with at most 200 unconstrained dimensions, whose hyperparameters correlate with the field, and a diagonal one otherwise. reparametrize : bool Partially centre the basis coefficients of logistic priors during warm-up. For logistic priors, switch each basis coefficient between the non-centred and centred forms according to how strongly the data constrain it (partial non-centring with weights ``1 / -H_jj`` from the curvature at the chains' positions early in warm-up). If the field pins the hyperparameters down poorly (few basis coefficients per hyperparameter, e.g. a handful of bins), the coefficients stay non-centred, which avoids funnels. This changes the sampling coordinates, not the posterior. init : array_like (dim,) or (n_chains, dim), optional Starting points in the model's unconstrained coordinates; a single point gets ``jitter`` added for each chain. Every start must have a finite log density. By default chains start at the posterior mode (in unconstrained coordinates) plus ``jitter``. jitter : float Standard deviation of the Gaussian jitter around the starting point. n_jobs : int Number of worker processes for the chains (``-1``: one per CPU this process may use). The default ``1`` runs the chains in this process. The workers share the cores' BLAS threads instead of oversubscribing them, never exceeding a thread limit already set. Parallel and serial runs use the same random streams, so their draws agree up to rounding differences in multithreaded linear algebra. Parallel runs show no progress bar. rng : int, Generator or None Random number generator (SPEC 7); each chain gets an independent child. progress : bool Show a progress bar. Notes ----- For logistic (smoothness) priors, after ``min(25, first window start)`` warm-up iterations (none when ``n_warmup < 20``, which also skips metric adaptation) the engine reparametrises the prior (``reparametrize``) and, for at most 500 dimensions, initialises the metric from the finite-difference curvature of the log posterior at the chains' positions. Each later adaptation window shrinks its estimate toward the previous window's metric, starting from that curvature-based one. Simplex and rate priors use Stan's windowed adaptation unchanged. Results (``x``, ``log_density_unconstrained``, ``model``) are in the prior's non-centred coordinates whatever the sampling coordinates; ``info["centring"]`` holds the partial-centring weights used, and ``info["inv_metric"]`` is in those sampling coordinates. """ name = "nuts" joint = True markov = True
[docs] def __init__( self, model: PopulationModel, *, n_chains: int = 4, n_warmup: int = 1000, target_accept: float | None = None, max_depth: int = 10, metric: str = "auto", reparametrize: bool = True, init: Any = None, jitter: float = 0.1, n_jobs: int = 1, rng: RNGLike = None, progress: bool = False, ) -> None: logistic = model.prior.has_centering # results are in the non-centred form, so only engines ever hold a centred prior if logistic and np.any(model.prior.lam != 1.0): raise ValueError( "model: the logistic prior must be in its non-centred form (prior.lam all 1), " "as the model is built; NUTS chooses the centring itself." ) super().__init__(model, rng=rng, progress=progress) if n_chains < 1: raise ValueError("n_chains must be at least 1.") if max_depth < 1: raise ValueError("max_depth must be at least 1.") if n_warmup < 0 or jitter < 0: raise ValueError("n_warmup and jitter must be non-negative.") if n_jobs == -1: n_jobs = _cpu_count() if n_jobs < 1: raise ValueError("n_jobs must be a positive integer or -1.") self.n_jobs = int(n_jobs) if metric not in ("auto", "diag", "dense"): raise ValueError("metric must be 'auto', 'diag' or 'dense'.") if target_accept is None: target_accept = _TARGET_ACCEPT_LOGISTIC if logistic else _TARGET_ACCEPT if not 0 < target_accept < 1: raise ValueError("target_accept must be in (0, 1).") self.n_chains = int(n_chains) self.n_warmup = int(n_warmup) self.target_accept = float(target_accept) self.max_depth = int(max_depth) if metric == "auto": metric = "dense" if logistic and model.dim <= _DENSE_MAX_DIM else "diag" self.metric = metric self.reparametrize = bool(reparametrize) self.init = init self.jitter = float(jitter) self._chains: list[_Chain] | None = None self._base_model = model self._lam = None # partial-centring weights of the sampling coordinates, once chosen self._pool = None # worker processes, kept for one sample() (audit N-13) self._warm = False self._x: list[np.ndarray] = [] self._lp: list[np.ndarray] = [] self._stats: dict[str, list[np.ndarray]] = {k: [] for k in _STAT_KEYS} self.warmup_divergences = 0
# ---------------------------------------------------------- set-up def _initial_points(self) -> np.ndarray: d = self.model.dim child = spawn(self.rng, 1)[0] if self.init is not None: x0 = np.asarray(self.init, dtype=float) if x0.shape == (d,): x0 = np.tile(x0, (self.n_chains, 1)) x0 = x0 + self.jitter * child.standard_normal(x0.shape) if x0.shape != (self.n_chains, d): raise ValueError(f"init must have shape ({d},) or ({self.n_chains}, {d}).") if not all(np.isfinite(self.model.log_density_unconstrained(x)) for x in x0): raise ValueError("init has a starting point with zero posterior density.") return x0 from .optimize import MAP with warnings.catch_warnings(): warnings.simplefilter("ignore", ConvergenceWarning) mode = MAP(self.model, space="unconstrained").run() x_mode = mode.x pts = [] for _ in range(self.n_chains): for scale in (self.jitter, self.jitter / 10, 0.0): x = x_mode + scale * child.standard_normal(d) if np.isfinite(self.model.log_density_unconstrained(x)): break pts.append(x) return np.asarray(pts) def _setup(self) -> None: if self._chains is not None: return x0 = self._initial_points() rngs = spawn(self.rng, self.n_chains) fn = self.model.log_density_unconstrained_and_grad self._chains = [ _Chain(x0[c], fn, rngs[c], self.n_warmup, self.target_accept, self.metric == "dense") for c in range(self.n_chains) ] def _adapt_geometry(self) -> None: """Reparametrise a logistic prior and initialise the metric from curvature. Only for priors with a ``with_centering`` method (the logistic family): for simplex priors the curvature at single points is a poor guide to the posterior scales (sparse bins), so they keep Stan's adaptation. """ assert self._chains is not None base = self._base_model # non-centred (see __init__) prior0 = base.prior if not prior0.has_centering: return chains = self._chains d = base.dim pts = [ch.x for ch in chains] # curvature at every chain's position for small problems, one chain otherwise n_pts = len(chains) if d <= _DENSE_MAX_DIM else 1 model = base if self.reparametrize and prior0.n_active > 0: fn0 = base.log_density_unconstrained_and_grad n_act = prior0.n_active # only the field's diagonal curvature is needed here (audit N-10) diag = np.mean([fd_hessian_diag(fn0, x, range(n_act)) for x in pts[:n_pts]], axis=0) lam = np.clip(1.0 / np.maximum(-diag, 1.0), 0.0, 1.0) # one decision for all modes that share the hyperparameters: mixing centred # and non-centred modes under one length scale mixed worst in validation sd_c = _conditional_log_scale_sd(base, prior0, pts) scaled = sd_c > 0.0 if np.any(scaled) and np.median(sd_c[scaled]) > _CENTRING_MAX_SD: lam[scaled] = 1.0 model = _centred(base, lam) pts = [_recentre(prior0, x, lam) for x in pts] self._lam = lam self.model = model fn = model.log_density_unconstrained_and_grad inv_m = None if d <= _HESSIAN_MAX_DIM: H = np.mean([fd_hessian(fn, x) for x in pts[:n_pts]], axis=0) cov = covariance_from_hessian(H) if cov is not None: inv_m = cov if self.metric == "dense" else np.diag(cov).copy() for ch, x in zip(chains, pts, strict=True): ch.reset_geometry(fn, x, inv_m) # ---------------------------------------------------------- running def _parallel(self, n_iter: int, adapt: bool, record: bool): """Advance every chain ``n_iter`` iterations in worker processes. Returns per-chain outputs, or ``None`` if no worker pool could be used (the caller then runs serially; the chains are untouched). An error raised by the model in a worker comes back as a value and is raised here. """ from concurrent.futures import ProcessPoolExecutor from concurrent.futures.process import BrokenProcessPool assert self._chains is not None chains = self._chains fn = self.model.log_density_unconstrained_and_grad for ch in chains: ch.fn = None # the model goes to each worker once, not with every chain payloads = [(ch, self._lam, n_iter, adapt, self.max_depth, record) for ch in chains] try: if self._pool is None: model = _worker_model(self._base_model) try: # e.g. a lambda in a prior cannot be sent to workers pickle.dumps((model, payloads[0])) except (pickle.PicklingError, AttributeError, TypeError) as err: return self._serial_from_now(err) n_workers = min(self.n_jobs, len(chains)) self._pool = ProcessPoolExecutor( max_workers=n_workers, initializer=_init_worker, initargs=(model, _worker_threads(n_workers)), ) # errors from the model come back as values (see _chain_worker), so only a # failure of the pool itself falls back to serial (audit N-7) results = list(self._pool.map(_chain_worker, payloads)) except (OSError, BrokenProcessPool, NotImplementedError) as err: return self._serial_from_now(err) finally: for ch in chains: ch.fn = fn for r in results: if isinstance(r, BaseException): raise r self._chains = [] outs = [] for ch, out in results: ch.fn = fn self._chains.append(ch) outs.append(out) return outs def _serial_from_now(self, err: BaseException) -> None: warn( f"Could not run chains in parallel ({type(err).__name__}: {err}); " "running them serially.", RuntimeWarning, ) self.n_jobs = 1 # do not try again for this engine self._close_pool() def _close_pool(self) -> None: if self._pool is not None: self._pool.shutdown() self._pool = None def _advance(self, n_iter: int, adapt: bool, desc: str) -> None: """Warm-up iterations for every chain (parallel or interleaved serial).""" if n_iter <= 0: return assert self._chains is not None if self.n_jobs > 1 and len(self._chains) > 1: outs = self._parallel(n_iter, adapt, record=False) if outs is not None: self.warmup_divergences += sum(o[3] for o in outs) return for _ in progress(range(n_iter), n_iter, desc, self.progress): for ch in self._chains: _, _, div, _, _ = ch.step(self.max_depth, adapt=adapt) self.warmup_divergences += int(div)
[docs] def warmup(self) -> None: """Run warm-up (adaptation) if it has not been run yet.""" self._setup() if self._warm: return assert self._chains is not None windows = self._chains[0].windows logistic = self._base_model.prior.has_centering # adapt the geometry once the chains have left the mode for the typical set switch = min(_GEOMETRY_ITER, windows[0][0]) if (windows and logistic) else 0 try: self._advance(switch, adapt=True, desc="NUTS warm-up") if switch > 0: self._adapt_geometry() self._advance(self.n_warmup - switch, adapt=True, desc="NUTS warm-up") finally: self._close_pool() # warm-up phases share one pool; sampling starts its own self._warm = True
def _record(self, xs, lps, st, n_done: int) -> None: if n_done: self._x.append(xs[:, :n_done]) self._lp.append(lps[:, :n_done]) for j, k in enumerate(_STAT_KEYS): self._stats[k].append(st[:, j, :n_done]) def _report(self, X: np.ndarray, LP: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Draws and log densities from the sampling coordinates to the model's (audit N-1).""" if self._lam is None: return X, LP d = self.model.prior.dim # an inferred selection's logit S follows unchanged x, lp = self.model.prior.to_noncentred(X[..., :d].reshape(-1, d), LP.reshape(-1)) x = np.concatenate([x.reshape(*X.shape[:-1], d), X[..., d:]], axis=-1) return x, lp.reshape(LP.shape)
[docs] def sample(self, n_samples: int) -> Iterator[np.ndarray]: """Generator over post-warm-up iterations, yielding ``(n_chains, dim)`` positions. Positions are in the model's unconstrained coordinates (the prior's non-centred form). Draws are also recorded and included in the next :meth:`run` / :attr:`result`. With ``n_jobs > 1`` the chains advance in worker processes and the positions are yielded once all have finished. Parameters ---------- n_samples : int Iterations per chain. """ try: self.warmup() assert self._chains is not None C, d = self.n_chains, self.model.dim n_samples = int(n_samples) if self.n_jobs > 1 and C > 1 and n_samples > 0: outs = self._parallel(n_samples, adapt=False, record=True) if outs is not None: xs, lps = self._report( np.stack([o[0] for o in outs]), np.stack([o[1] for o in outs]) ) self._record(xs, lps, np.stack([o[2] for o in outs]), n_samples) yield from (xs[:, i] for i in range(n_samples)) return xs = np.empty((C, n_samples, d)) lps = np.empty((C, n_samples)) st = np.empty((C, len(_STAT_KEYS), n_samples)) n_done = 0 try: for i in progress(range(n_samples), n_samples, "NUTS sampling", self.progress): for c, ch in enumerate(self._chains): acc, depth, div, nl, energy = ch.step(self.max_depth, adapt=False) xs[c, i] = ch.x lps[c, i] = ch.lp st[c, :, i] = (acc, depth, div, nl, energy) xs[:, i], lps[:, i] = self._report(xs[:, i], lps[:, i]) n_done = i + 1 yield xs[:, i] finally: self._record(xs, lps, st, n_done) finally: self._close_pool()
[docs] def run(self, n_samples: int = 1000) -> PopulationResult: """Warm up (first call only), draw ``n_samples`` per chain, and return all draws so far. Parameters ---------- n_samples : int, optional Draws per chain after warm-up. """ for _ in self.sample(int(n_samples)): pass return self.result
@property def result(self) -> PopulationResult: """A :class:`~binference.PopulationResult` with every draw recorded so far.""" if not self._x: raise RuntimeError("No draws yet; call run().") X = np.concatenate(self._x, axis=1) LP = np.concatenate(self._lp, axis=1) C, n, d = X.shape base = self._base_model info: dict[str, Any] = {} if self._lam is None else {"centring": self._lam} vals = base.transform(X.reshape(-1, d)).reshape(C, n, -1) stats = {k: np.concatenate(v, axis=1) for k, v in self._stats.items()} info.update(stats) info["n_divergent"] = int(stats["diverging"].sum()) info["warmup_divergences"] = self.warmup_divergences info["max_depth_fraction"] = float(np.mean(stats["tree_depth"] >= self.max_depth)) info["step_size"] = np.array([ch.eps for ch in self._chains]) info["inv_metric"] = [ch.metric.inv_m for ch in self._chains] E = stats["energy"] with np.errstate(invalid="ignore", divide="ignore"): ebfmi = np.sum(np.diff(E, axis=1) ** 2, axis=1) / np.sum( (E - E.mean(axis=1, keepdims=True)) ** 2, axis=1 ) info["ebfmi"] = ebfmi info.update(base._joint_info(X)) hyper = getattr(base.prior, "hyper_names", []) if hyper: u = X[..., base.prior.n_active : base.prior.dim] info["hyperparameters"] = {name: np.exp(u[..., j]) for j, name in enumerate(hyper)} return PopulationResult( base, vals, method="nuts", x=X, log_density_unconstrained=LP, info=info, )