Source code for pybnf.diagnostics

"""Convergence diagnostics for MCMC samplers — pure R-hat / ESS math.

Extracted from ``algorithms/samplers/base.py`` (M2.2 move 5, ADR-0009): the
convergence diagnostics are substantial, already shared by all four samplers,
project-specific, and oracle-testable, so their pure numerical core lives here
as free functions — a peer of ``objective.py``, navigable, and importable by the
benchmark harness (M2.5) without reaching into ``samplers/``. The
instance-coupled glue (``report_convergence_diagnostics`` /
``check_convergence`` / ``_write_diagnostics``) and the PSet→array bridge
(``_param_vec``) stay on :class:`BayesianAlgorithm`, which delegates the math
here.

Implements the rank-normalized split-R-hat and bulk/tail effective sample size
of Vehtari, Gelman, Simpson, Carpenter & Bürkner (2021), *Bayesian Analysis*
(the same conventions as Stan / ArviZ).

The data layout throughout: ``chain_history`` is a list of ``num_parallel``
per-chain histories, each a list of ``(n_dim,)`` parameter vectors in the
sampling space (see ``BayesianAlgorithm._param_vec``). The internal ``chains``
arrays are ``(n_chains, n_draws, n_dim)`` (or ``(n_chains, n_draws)`` for the
single-parameter ESS kernel).
"""

import numpy as np
from scipy import stats


[docs] def split_chains(chain_history, num_parallel): """Build the split-chains array used for R-hat and ESS. Uses the last 50% of each chain, then splits that window in two (doubling the chain count, catching within-chain non-stationarity). Returns an ``(2 * num_parallel, half, n_dim)`` array, or ``None`` if there is too little history (fewer than 20 recorded steps, or fewer than 5 per split half). """ min_len = min(len(h) for h in chain_history) if min_len < 20: return None start = min_len // 2 usable = min_len - start half = usable // 2 if half < 5: return None chunks = [] for j in range(num_parallel): chunk = chain_history[j][start:start + 2 * half] chunks.append(chunk[:half]) chunks.append(chunk[half:2 * half]) return np.array(chunks) # (2N, half, n_dim)
[docs] def split_chain_rhat(chains): """ Compute the potential scale reduction factor R-hat from an array of chains, using the Vehtari et al. (2021) convention R = sqrt(var_plus / W), where var_plus = ((n-1)/n) W + B/n. This is the form paired with the rank normalization and folding done by rhat() (and matches Stan / ArviZ). It deliberately omits the older Gelman & Rubin (1992) df-style correction sqrt((N+1)/N * (var_plus/W) - (n-1)/(N n)): that factor only inflates R-hat when the chains have not converged (it cancels to ~1 at convergence), making PyBNF report systematically higher R-hat than reference tools on the same chains -- harmful for cross-tool comparison -- without changing any convergence decision. chains: (N, n, d) array Returns: (d,) array of R-hat values """ N, n, d = chains.shape mu_chains = np.mean(chains, axis=1) s2_chains = np.var(chains, axis=1, ddof=1) B = n * np.var(mu_chains, axis=0, ddof=1) W = np.mean(s2_chains, axis=0) var_plus = ((n - 1) / n) * W + (1.0 / n) * B with np.errstate(divide='ignore', invalid='ignore'): return np.sqrt(var_plus / W)
[docs] def rhat(chain_history, num_parallel): """ Compute rank-normalized split-R-hat for each parameter (Vehtari, Gelman, Simpson, Carpenter & Burkner, 2021, Bayesian Analysis). Steps: 1. Split each chain in half (doubles the number of chains, catches within-chain non-stationarity) 2. Rank-normalize across all split chains (replaces values with normal scores of their ranks) 3. Compute R-hat on both the ranked values and folded ranked values (detects scale differences) 4. Return the element-wise maximum Returns a numpy array of shape (n_dim,) or None if insufficient data. """ chains = split_chains(chain_history, num_parallel) if chains is None: return None N_split, n, d = chains.shape # Rank-normalize each parameter across all split chains ranked = np.empty_like(chains) for p in range(d): flat = chains[:, :, p].ravel() order = flat.argsort() ranks = np.empty_like(order, dtype=float) ranks[order] = np.arange(1, len(flat) + 1) # Transform ranks to normal scores: Phi^{-1}((rank - 3/8) / (S + 1/4)) z_scores = stats.norm.ppf((ranks - 0.375) / (len(flat) + 0.25)) ranked[:, :, p] = z_scores.reshape(N_split, n) # R-hat on ranked values (detects location differences) rhat_rank = split_chain_rhat(ranked) # Folded R-hat: fold around the median to detect scale differences folded = np.abs(ranked - np.median(ranked, axis=(0, 1), keepdims=True)) rhat_fold = split_chain_rhat(folded) # Return the element-wise max with np.errstate(invalid='ignore'): return np.fmax(rhat_rank, rhat_fold)
[docs] def ess_from_chains(chains): """ Compute effective sample size from an (M, n) array of chains using FFT-based autocovariance and Geyer's initial positive sequence estimator. """ M, n = chains.shape if n < 4: return float('nan') chain_means = np.mean(chains, axis=1) W = np.mean(np.var(chains, axis=1, ddof=1)) B_over_n = np.var(chain_means, ddof=1) var_hat = ((n - 1) / n) * W + B_over_n if var_hat < 1e-30: return float(M * n) # FFT autocovariance for each chain (biased estimator), averaged across chains npad = 1 << (2 * n - 1).bit_length() mean_acov = np.zeros(n) for m in range(M): x = chains[m] - chain_means[m] xpad = np.zeros(npad) xpad[:n] = x ft = np.fft.rfft(xpad) acov = np.fft.irfft(ft * np.conj(ft))[:n] / n mean_acov += acov mean_acov /= M # Combined autocorrelation: rho_t = 1 - (W - mean_acov[t]) / var_hat # Use the within-chain variance W (ddof=1) as the lag-0 anchor, per # Vehtari et al. (2021) / Stan. (Using mean_acov[0] == ((n-1)/n)*W instead # introduces an O(1/n) downward bias in ESS; negligible but non-standard.) # Geyer's initial positive sequence: sum consecutive pairs, stop at first negative pair tau = 0.0 t = 1 while t < n - 1: rho_t = 1.0 - (W - mean_acov[t]) / var_hat rho_t1 = 1.0 - (W - mean_acov[t + 1]) / var_hat P = rho_t + rho_t1 if P < 0: break tau += P t += 2 ess_val = M * n / max(1.0 + 2.0 * tau, 1.0) return max(ess_val, 1.0)
[docs] def ess(chain_history, num_parallel): """ Compute bulk and tail effective sample size per Vehtari et al. (2021). Bulk ESS: computed on rank-normalized values (same transform as R-hat). Tail ESS: minimum ESS of the 5% and 95% quantile indicators. Returns (bulk_ess, tail_ess) arrays of shape (n_dim,) or (None, None). """ chains = split_chains(chain_history, num_parallel) if chains is None: return None, None M, n, d = chains.shape bulk_ess = np.zeros(d) tail_ess = np.zeros(d) for p in range(d): param_chains = chains[:, :, p] # (M, n) # Bulk ESS: rank-normalize then compute ESS flat = param_chains.ravel() order = flat.argsort() ranks = np.empty_like(order, dtype=float) ranks[order] = np.arange(1, len(flat) + 1) z_scores = stats.norm.ppf((ranks - 0.375) / (len(flat) + 0.25)) ranked_chains = z_scores.reshape(M, n) bulk_ess[p] = ess_from_chains(ranked_chains) # Tail ESS: ESS of quantile indicators q05 = np.quantile(flat, 0.05) q95 = np.quantile(flat, 0.95) ind_low = (param_chains <= q05).astype(float) ind_high = (param_chains >= q95).astype(float) ess_low = ess_from_chains(ind_low) ess_high = ess_from_chains(ind_high) tail_ess[p] = min(ess_low, ess_high) return bulk_ess, tail_ess