# Copyright 2020- The Blackjax Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""MCMC diagnostics."""
import jax
import jax.numpy as jnp
import numpy as np
from scipy.fftpack import next_fast_len # type: ignore
from blackjax.types import Array, ArrayLike
__all__ = [
"potential_scale_reduction",
"effective_sample_size",
"rhat",
"ess_bulk",
"ess_tail",
"pareto_khat",
"psis_weights",
]
[docs]
def potential_scale_reduction(
input_array: ArrayLike, chain_axis: int = 0, sample_axis: int = 1
) -> Array:
"""Gelman and Rubin (1992)'s potential scale reduction for computing multiple MCMC chain convergence.
Parameters
----------
input_array:
An array representing multiple chains of MCMC samples. The array must
contains a chain dimension and a sample dimension.
chain_axis
The axis indicating the multiple chains. Default to 0.
sample_axis
The axis indicating a single chain of MCMC samples. Default to 1.
Returns
-------
NDArray of the resulting statistics (r-hat), with the chain and sample dimensions squeezed.
Notes
-----
The diagnostic is computed by:
.. math:: \\hat{R} = \\frac{\\hat{V}}{W}
where :math:`W` is the within-chain variance and :math:`\\hat{V}` is the posterior variance
estimate for the pooled traces. This is the potential scale reduction factor, which
converges to unity when each of the traces is a sample from the target posterior. Values
greater than one indicate that one or more chains have not yet converged :cite:p:`stan_rhat,gelman1992inference`.
"""
assert (
input_array.shape[chain_axis] > 1
), "potential_scale_reduction as implemented only works for two or more chains."
num_samples = input_array.shape[sample_axis]
# Compute stats for each chain
per_chain_mean = input_array.mean(axis=sample_axis, keepdims=True)
per_chain_var = input_array.var(axis=sample_axis, ddof=1, keepdims=True)
# Compute between-chain stats
between_chain_variance = num_samples * per_chain_mean.var(
axis=chain_axis, ddof=1, keepdims=True
)
# Compute within-chain stats
within_chain_variance = per_chain_var.mean(axis=chain_axis, keepdims=True)
# Estimate of marginal posterior variance
rhat_value = jnp.sqrt(
(between_chain_variance / within_chain_variance + num_samples - 1)
/ (num_samples)
)
return rhat_value.squeeze()
[docs]
def rhat(input_array: ArrayLike, chain_axis: int = 0, sample_axis: int = 1) -> Array:
"""Rank-normalized split-R̂ (Vehtari et al. 2021).
The modern improved R̂ diagnostic. Combines two split-chain R̂ values —
one on rank-normalized draws and one on rank-normalized *folded* draws —
and returns the maximum. The folded component catches scale/variance
non-convergence that the bulk component can miss.
This matches the default ``az.rhat(method="rank")`` convention in ArviZ.
Parameters
----------
input_array
An array representing multiple chains of MCMC samples. The array must
contain a chain dimension and a sample dimension. At least 2 chains
and at least 4 draws per chain are required.
chain_axis
The axis indicating the multiple chains. Default 0.
sample_axis
The axis indicating a single chain of MCMC samples. Default 1.
Returns
-------
NDArray of the resulting R̂ values, with chain and sample dimensions
squeezed. Values close to 1.0 indicate convergence; values above 1.01
suggest chains have not converged.
Notes
-----
Algorithm (Vehtari et al. 2021, § 4):
1. Split each chain in half → 2× chains.
2. Rank-normalize with the Blom plotting position
:math:`z_r = \\Phi^{-1}((r - 3/8) / (n + 1/4))` over the joint pool.
3. Compute the standard split-R̂ on the rank-normalized draws (**bulk**).
4. Compute the folded draws :math:`|x - \\mathrm{median}(x)|`, rank-normalize
them, and compute split-R̂ again (**tail**).
5. Return :math:`\\max(\\hat{R}_{\\text{bulk}}, \\hat{R}_{\\text{tail}})`.
References
----------
.. cite:p:`vehtari2021rank`
"""
x = _to_standard_axes(jnp.asarray(input_array), chain_axis, sample_axis)
# Split each chain in half → (2*nchains, nsamples//2, …).
x_split = _split_chains(x)
# Bulk: rank-normalize split draws, then compute split-R̂.
x_rn = _rank_normalize(x_split)
rhat_bulk = potential_scale_reduction(x_rn, chain_axis=0, sample_axis=1)
# Tail: fold the split draws about their joint median, rank-normalize,
# then compute split-R̂. Catches variance non-convergence.
nchains_split = x_split.shape[0]
nsamples_split = x_split.shape[1]
extra_shape = x_split.shape[2:]
x_flat = x_split.reshape(nchains_split * nsamples_split, *extra_shape)
# Global median per trailing dimension (scalar when extra_shape is empty).
x_folded = jnp.abs(x_split - jnp.median(x_flat, axis=0))
x_folded_rn = _rank_normalize(x_folded)
rhat_tail = potential_scale_reduction(x_folded_rn, chain_axis=0, sample_axis=1)
return jnp.maximum(rhat_bulk, rhat_tail)
[docs]
def effective_sample_size(
input_array: ArrayLike, chain_axis: int = 0, sample_axis: int = 1
) -> Array:
"""Compute estimate of the effective sample size (ess).
Parameters
----------
input_array:
An array representing multiple chains of MCMC samples. The array must
contains a chain dimension and a sample dimension.
chain_axis
The axis indicating the multiple chains. Default to 0.
sample_axis
The axis indicating a single chain of MCMC samples. Default to 1.
Returns
-------
NDArray of the resulting statistics (ess), with the chain and sample dimensions squeezed.
Notes
-----
The basic ess (:math:`N_{\\mathit{eff}}`) diagnostic is computed by:
.. math:: \\hat{N}_{\\mathit{eff}} = \\frac{MN}{\\hat{\\tau}}
.. math:: \\hat{\\tau} = -1 + 2 \\sum_{t'=0}^K \\hat{P}_{t'}
where :math:`M` is the number of chains, :math:`N` the number of draws,
:math:`\\hat{\\rho}_t` is the estimated _autocorrelation at lag :math:`t`, and
:math:`K` is the last integer for which :math:`\\hat{P}_{K} = \\hat{\\rho}_{2K} +
\\hat{\\rho}_{2K+1}` is still positive :cite:p:`stan_ess,gelman1995bayesian`.
The current implementation is similar to Stan, which uses Geyer's initial monotone sequence
criterion :cite:p:`geyer1992practical,geyer2011introduction`.
"""
input_shape = input_array.shape
sample_axis = sample_axis if sample_axis >= 0 else len(input_shape) + sample_axis
num_chains = input_shape[chain_axis]
num_samples = input_shape[sample_axis]
assert (
num_samples > 1
), f"The input array must have at least 2 samples, got only {num_samples}."
mean_across_chain = input_array.mean(axis=sample_axis, keepdims=True)
# Compute autocovariance estimates for every lag for the input array using FFT.
centered_array = input_array - mean_across_chain
m = next_fast_len(2 * num_samples)
ifft_ary = jnp.fft.rfft(centered_array, n=m, axis=sample_axis)
ifft_ary *= jnp.conjugate(ifft_ary)
autocov_value = jnp.fft.irfft(ifft_ary, n=m, axis=sample_axis)
autocov_value = (
jnp.take(autocov_value, jnp.arange(num_samples), axis=sample_axis) / num_samples
)
mean_autocov_var = autocov_value.mean(chain_axis, keepdims=True)
mean_var0 = (
jnp.take(mean_autocov_var, jnp.array([0]), axis=sample_axis)
* num_samples
/ (num_samples - 1.0)
)
weighted_var = mean_var0 * (num_samples - 1.0) / num_samples
weighted_var = jax.lax.cond(
num_chains > 1,
lambda mean_across_chain: weighted_var
+ mean_across_chain.var(axis=chain_axis, ddof=1, keepdims=True),
lambda _: weighted_var,
operand=mean_across_chain,
)
# Geyer's initial positive sequence
num_samples_even = num_samples - num_samples % 2
mean_autocov_var_tp1 = jnp.take(
mean_autocov_var, jnp.arange(1, num_samples_even), axis=sample_axis
)
rho_hat = jnp.concatenate(
[
jnp.ones_like(mean_var0),
1.0 - (mean_var0 - mean_autocov_var_tp1) / weighted_var,
],
axis=sample_axis,
)
rho_hat = jnp.moveaxis(rho_hat, sample_axis, 0)
rho_hat_even = rho_hat[0::2]
rho_hat_odd = rho_hat[1::2]
mask0 = (rho_hat_even + rho_hat_odd) > 0.0
carry_cond = jnp.ones_like(mask0[0])
max_t = jnp.zeros_like(mask0[0], dtype=int)
def positive_sequence_body_fn(state, mask_t):
t, carry_cond, max_t = state
next_mask = carry_cond & mask_t
next_max_t = jnp.where(next_mask, jnp.ones_like(max_t) * t, max_t)
return (t + 1, next_mask, next_max_t), next_mask
(*_, max_t_next), mask = jax.lax.scan(
positive_sequence_body_fn, (0, carry_cond, max_t), mask0
)
indices = jnp.indices(max_t_next.shape)
indices = tuple([max_t_next + 1] + [indices[i] for i in range(max_t_next.ndim)])
rho_hat_odd = jnp.where(mask, rho_hat_odd, jnp.zeros_like(rho_hat_odd))
# improve estimation
mask_even = mask.at[indices].set(rho_hat_even[indices] > 0)
rho_hat_even = jnp.where(mask_even, rho_hat_even, jnp.zeros_like(rho_hat_even))
# Geyer's initial monotone sequence
def monotone_sequence_body_fn(rho_hat_sum_tm1, rho_hat_sum_t):
update_mask = rho_hat_sum_t > rho_hat_sum_tm1
next_rho_hat_sum_t = jnp.where(update_mask, rho_hat_sum_tm1, rho_hat_sum_t)
return next_rho_hat_sum_t, (update_mask, next_rho_hat_sum_t)
rho_hat_sum = rho_hat_even + rho_hat_odd
_, (update_mask, update_value) = jax.lax.scan(
monotone_sequence_body_fn, rho_hat_sum[0], rho_hat_sum
)
rho_hat_even_final = jnp.where(update_mask, update_value / 2.0, rho_hat_even)
rho_hat_odd_final = jnp.where(update_mask, update_value / 2.0, rho_hat_odd)
# compute effective sample size
ess_raw = num_chains * num_samples
tau_hat = (
-1.0
+ 2.0 * jnp.sum(rho_hat_even_final + rho_hat_odd_final, axis=0)
- rho_hat_even_final[indices]
)
tau_hat = jnp.maximum(tau_hat, 1 / np.log10(ess_raw))
ess = ess_raw / tau_hat
return ess.squeeze()
def splitR(position, num_chains, superchain_size, func_for_splitR=jnp.square):
# combine the chains in super-chains to compute expectation values
func_mk = jax.vmap(func_for_splitR)(position) # shape = (# chains, # func)
func_mk = func_mk.reshape(
num_chains // superchain_size, superchain_size, func_mk.shape[-1]
) # shape = (# superchains, # chains in superchain, # func)
func_k = jnp.average(func_mk, axis=1) # shape = (# superchains, # func)
func_sq_k = jnp.average(
jnp.square(func_mk), axis=1
) # shape = (# superchains, # func)
W_k = (
(func_sq_k - jnp.square(func_k)) * superchain_size / (superchain_size - 1)
) # variance withing k-th superchain
W = jnp.average(W_k, axis=0) # average within superchain variance
B = jnp.var(func_k, axis=0, ddof=1) # between superchain variance
R = jnp.sqrt(1.0 + (B / W)) # splitR, shape = (# func)
return R
def _to_standard_axes(x: Array, chain_axis: int, sample_axis: int) -> Array:
"""Move chain and sample dimensions to positions 0 and 1 via ``jnp.transpose``.
All other dimensions are appended in their original relative order. The
function handles negative axis indices correctly.
"""
ndim = x.ndim
c = chain_axis % ndim
s = sample_axis % ndim
rest = [i for i in range(ndim) if i != c and i != s]
return jnp.transpose(x, [c, s] + rest)
def _split_chains(x: Array) -> Array:
"""Split each chain in half along the sample axis (axis 1).
Parameters
----------
x
Array of shape ``(nchains, nsamples, …)``. If ``nsamples`` is odd the
last sample is dropped so both halves are equal-length.
Returns
-------
Array of shape ``(2 * nchains, nsamples // 2, …)``.
"""
nsamples = x.shape[1]
half = nsamples // 2
# Trim to even length so both halves are the same size.
x = x[:, : 2 * half]
first = x[:, :half]
second = x[:, half:]
return jnp.concatenate([first, second], axis=0)
def _rank_normalize(x: Array) -> Array:
"""Rank-normalize draws using the Blom plotting position.
Ranks are computed over the joint pool of all ``nchains * nsamples`` values
independently for each element of the trailing dimensions.
Parameters
----------
x
Array of shape ``(nchains, nsamples, …)`` with chains on axis 0 and
draws on axis 1.
Returns
-------
Array of the same shape containing rank-normalized z-scores.
Notes
-----
The plotting position follows Vehtari et al. (2021), equation (12):
.. math:: z_r = \\Phi^{-1}\\!\\left(\\frac{r - 3/8}{n + 1/4}\\right)
where :math:`r` is the 1-indexed rank and :math:`n = \\text{nchains}
\\times \\text{nsamples}`.
"""
nchains, nsamples = x.shape[0], x.shape[1]
extra_shape = x.shape[2:]
n = nchains * nsamples
# Pool chains and draws into the leading axis: (n, …extra).
x_flat = x.reshape(n, *extra_shape)
# Double argsort gives 0-indexed ranks; +1 for 1-indexed.
ranks = jnp.argsort(jnp.argsort(x_flat, axis=0), axis=0).astype(float) + 1
# Blom plotting position.
z = jax.scipy.special.ndtri((ranks - 3.0 / 8) / (n + 1.0 / 4))
return z.reshape(nchains, nsamples, *extra_shape)
[docs]
def ess_bulk(
input_array: ArrayLike, chain_axis: int = 0, sample_axis: int = 1
) -> Array:
"""Bulk effective sample size (rank-normalized split-chain ESS).
Computes the bulk ESS from Vehtari et al. (2021): rank-normalizes draws
after splitting each chain in half, then applies the standard
autocorrelation-based :func:`effective_sample_size` estimator. This
diagnostic is robust to non-stationarity and multimodality.
Parameters
----------
input_array
An array representing multiple chains of MCMC samples. The array must
contain a chain dimension and a sample dimension.
chain_axis
The axis indicating the multiple chains. Default 0.
sample_axis
The axis indicating a single chain of MCMC samples. Default 1.
Returns
-------
NDArray of the resulting bulk-ESS, with chain and sample dimensions squeezed.
Notes
-----
Algorithm:
1. Split each chain in half → 2× chains.
2. Pool all draws and rank-normalize with :math:`z_r = \\Phi^{-1}((r-3/8)/(n+1/4))`.
3. Apply :func:`effective_sample_size` to the rank-normalized draws.
References
----------
.. cite:p:`vehtari2021rank`
"""
x = _to_standard_axes(jnp.asarray(input_array), chain_axis, sample_axis)
x_split = _split_chains(x)
x_rn = _rank_normalize(x_split)
return effective_sample_size(x_rn)
[docs]
def ess_tail(
input_array: ArrayLike,
chain_axis: int = 0,
sample_axis: int = 1,
prob: float = 0.90,
) -> Array:
"""Tail effective sample size.
Computes the tail ESS from Vehtari et al. (2021) as the minimum of the
ESS of the lower- and upper-tail indicator functions applied to split-chain
draws.
The tail quantiles are determined by ``prob``: the lower tail uses the
``(1 - prob) / 2`` quantile and the upper tail uses the
``(1 + prob) / 2`` quantile. The default ``prob=0.90`` corresponds to
the 5th/95th percentiles, which matches ``az.ess(method="tail")`` in
ArviZ (the ArviZ default is also ``prob=(0.05, 0.95)``).
Parameters
----------
input_array
An array representing multiple chains of MCMC samples. The array must
contain a chain dimension and a sample dimension.
chain_axis
The axis indicating the multiple chains. Default 0.
sample_axis
The axis indicating a single chain of MCMC samples. Default 1.
prob
Central-interval probability that determines the tail quantiles.
Lower quantile: ``(1 - prob) / 2``; upper quantile:
``(1 + prob) / 2``. Default ``0.90`` gives the 5th/95th-percentile
tail, matching ``az.ess(method="tail")`` (ArviZ default).
Returns
-------
NDArray of the resulting tail-ESS, with chain and sample dimensions squeezed.
Notes
-----
Algorithm:
1. Split each chain in half → 2× chains.
2. Compute pooled lower/upper quantiles (at ``(1-prob)/2`` and
``(1+prob)/2``) across all split chains and draws.
3. Form indicator series :math:`\\mathbf{1}(x \\le q_{\\text{low}})` and
:math:`\\mathbf{1}(x \\ge q_{\\text{high}})`.
4. Compute :func:`effective_sample_size` for each indicator.
5. Return :math:`\\min(\\text{ESS}_\\text{lower}, \\text{ESS}_\\text{upper})`.
References
----------
.. cite:p:`vehtari2021rank`
"""
x = _to_standard_axes(jnp.asarray(input_array), chain_axis, sample_axis)
x_split = _split_chains(x)
nchains, nsamples = x_split.shape[0], x_split.shape[1]
extra_shape = x_split.shape[2:]
# Tail quantiles derived from the central-interval probability.
q_low = (1.0 - prob) / 2.0
q_high = (1.0 + prob) / 2.0
# Pooled quantiles over all chains and draws, per trailing dimension.
x_flat = x_split.reshape(nchains * nsamples, *extra_shape)
q_lo = jnp.quantile(x_flat, q_low, axis=0)
q_hi = jnp.quantile(x_flat, q_high, axis=0)
# Indicator series (float for ESS computation).
# Broadcast q_lo/q_hi over the (nchains, nsamples) leading axes.
I_lower = (x_split <= q_lo[None, None]).astype(float)
I_upper = (x_split >= q_hi[None, None]).astype(float)
ess_lower = effective_sample_size(I_lower)
ess_upper = effective_sample_size(I_upper)
return jnp.minimum(ess_lower, ess_upper)
[docs]
def pareto_khat(x: ArrayLike, tail: str = "both", tail_frac: float = 0.10) -> Array:
"""Pareto shape parameter k̂ for tail diagnosis.
Fits a Generalised Pareto Distribution (GPD) to the upper and/or lower
tail of a 1-D sample and returns the estimated shape parameter k̂.
Parameters
----------
x
1-D array of draws (or any array; it is ravelled before use).
tail
Which tail to fit: ``"upper"``, ``"lower"``, or ``"both"`` (default).
When ``"both"``, returns the maximum of the two k̂ estimates.
tail_frac
Fraction of samples used as the tail. Default 0.10 (10 %).
A minimum of 5 tail samples is always enforced.
Returns
-------
Scalar Array: the Pareto shape estimate k̂. Values below 0.5 indicate
reliable tail estimates; 0.5–0.7 are moderate; above 0.7 may be
unreliable.
Notes
-----
Uses the Zhang & Stephens (2009) empirical-Bayes estimator implemented
in the internal :func:`_gpdfit`. The upper tail is modelled directly;
the lower tail is reflected and modelled as an upper tail.
"""
x_flat = jnp.asarray(x).ravel()
n = x_flat.shape[0]
tail_size = max(int(tail_frac * n), 5)
x_sorted = jnp.sort(x_flat) # ascending
if tail in ("upper", "both"):
upper_tail = x_sorted[n - tail_size :]
threshold_upper = x_sorted[n - tail_size - 1]
exc_upper = upper_tail - threshold_upper # >= 0, ascending
k_upper, _ = _gpdfit(exc_upper)
if tail in ("lower", "both"):
# Reflect the lower tail so it becomes an upper-tail problem.
lower_tail_reflected = -x_sorted[:tail_size][::-1] # ascending
threshold_lower = -x_sorted[tail_size]
exc_lower = lower_tail_reflected - threshold_lower # >= 0, ascending
k_lower, _ = _gpdfit(exc_lower)
if tail == "upper":
return k_upper
if tail == "lower":
return k_lower
return jnp.maximum(k_upper, k_lower)
def _gpdfit(exceedances: Array) -> tuple[Array, Array]:
"""Empirical Bayes GPD fit via Zhang & Stephens (2009).
Fits a Generalised Pareto Distribution to ``exceedances`` (a 1-D array of
non-negative values in *ascending* order) using the Bayesian model-averaging
estimator. All operations on fixed-shape arrays so the function is
JIT-compatible (``exceedances.shape[0]`` must be static).
"""
n = exceedances.shape[0] # static at trace time
prior_bs, prior_k = 3, 10
m_est = 30 + int(n**0.5) # static
# Guard against the degenerate case where all exceedances are zero
# (e.g. all importance weights equal). In that case k=0, sigma=0
# and the caller will leave the tail unchanged.
tiny = jnp.finfo(exceedances.dtype).tiny
is_degenerate = exceedances[-1] < tiny
safe_exc = jnp.where(is_degenerate, jnp.ones_like(exceedances), exceedances)
# Grid of candidate rate parameters b.
b = 1.0 - jnp.sqrt(m_est / (jnp.arange(1, m_est + 1) - 0.5))
b = b / (prior_bs * safe_exc[int(n / 4 + 0.5) - 1])
b = b + 1.0 / safe_exc[-1]
# k estimate for each b (mean log1p).
k_ary = jnp.mean(jnp.log1p(-b[:, None] * safe_exc[None, :]), axis=1)
# Profile log-likelihood weights.
len_scale = n * (jnp.log(-b / k_ary) - k_ary - 1.0)
w = jnp.exp(len_scale - jax.nn.logsumexp(len_scale))
# Posterior mean of b, then derive k and sigma.
b_post = jnp.sum(b * w)
k = jnp.mean(jnp.log1p(-b_post * safe_exc))
sigma = -k / b_post
# Weakly informative prior shrinks k toward 0.5.
k = (n * k + prior_k * 0.5) / (n + prior_k)
k = jnp.where(is_degenerate, jnp.zeros(()), k)
sigma = jnp.where(is_degenerate, jnp.zeros(()), sigma)
return k, sigma
def _gpinv(p: Array, k: Array, sigma: Array) -> Array:
"""GPD quantile function (inverse CDF)."""
return jnp.where(
jnp.abs(k) < 1e-10,
-sigma * jnp.log1p(-p),
sigma * jnp.expm1(-k * jnp.log1p(-p)) / k,
)
[docs]
def psis_weights(log_ratios: Array, r_eff: float = 1.0) -> tuple[Array, Array]:
"""Pareto Smoothed Importance Sampling (PSIS) log weights.
Implements the PSIS smoothing step from :cite:p:`vehtari2017practical`:
the ``M`` largest importance ratios (in ratio space) are replaced by sorted
Generalised Pareto quantiles fitted by the empirical Bayes estimator of
Zhang & Stephens (2009), then all weights are normalised.
This is a pure-JAX, JIT-compatible implementation faithful to Algorithm 1
of Vehtari, Gelman & Gabry (2017).
Parameters
----------
log_ratios
Log importance ratios ``log p(θ) − log q(θ)``, shape ``(n,)``.
Need not be normalised.
r_eff
Relative effective sample size of the proposal, ``S_eff / n``.
Use the default of ``1.0`` for i.i.d. draws (e.g. Pathfinder);
set to the actual ESS ratio for correlated MCMC chains. Values
below 1 increase the tail size ``M`` to compensate for correlation.
Returns
-------
log_weights
Normalised log importance weights, shape ``(n,)``.
``jnp.exp(log_weights).sum() == 1`` up to floating-point precision.
pareto_k
Pareto shape parameter estimate (scalar ``Array``). Values below 0.5
indicate reliable estimates; 0.5–0.7 are moderate; above 0.7 may give
unreliable estimates. ``jnp.inf`` means the tail was too small to fit
(fewer than 5 samples).
Notes
-----
Tail size: ``M = min(floor(3*sqrt(n/r_eff)), n//5)``, matching the paper.
The GPD is only applied when ``k >= 1/3``; lighter tails are left
unsmoothed (only normalised). Fitting uses empirical Bayes in
importance-ratio space, the same approach as ArviZ.
"""
n = log_ratios.shape[0]
M = min(max(int(3.0 * (n / r_eff) ** 0.5), 5), n // 5)
if M < 5:
# Too few tail samples for a reliable GPD fit; return normalised
# weights and k=inf to signal this to the caller.
log_w = log_ratios - jax.nn.logsumexp(log_ratios)
return log_w, jnp.asarray(jnp.inf)
# Stabilise numerically.
lw = log_ratios - log_ratios.max()
# Sort ascending so that the M largest are at positions [n-M:].
sorted_idx = jnp.argsort(lw)
lw_sorted = lw[sorted_idx]
# Threshold: largest value below the tail (in log and ratio space).
threshold_log = lw_sorted[n - M - 1]
threshold_ratio = jnp.exp(threshold_log)
# Work in ratio (non-log) space for GPD fitting, as in the original paper.
tail_ratio = jnp.exp(lw_sorted[n - M :]) # ascending, shape (M,)
exceedances = tail_ratio - threshold_ratio # >= 0, ascending
k, sigma = _gpdfit(exceedances)
# Uniform quantile positions within the tail: (0.5/M, 1.5/M, ..., (M-0.5)/M).
p = (jnp.arange(M) + 0.5) / M # ascending
smoothed = threshold_ratio + _gpinv(p, k, sigma)
# Cap smoothed values at the observed tail maximum.
smoothed = jnp.minimum(smoothed, tail_ratio[-1])
# Only replace the tail when k >= 1/3 (paper Algorithm 1, step 7).
# For lighter tails the raw order statistics are already reliable.
lw_smooth = jnp.where(
k >= 1.0 / 3.0,
lw_sorted.at[n - M :].set(jnp.log(smoothed)),
lw_sorted,
)
# Restore original ordering and normalise.
lw_orig = jnp.zeros_like(lw_smooth).at[sorted_idx].set(lw_smooth)
log_w = lw_orig - jax.nn.logsumexp(lw_orig)
return log_w, k