#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Sampling utility functions for brutus.
This module contains functions for statistical sampling, quantile computation,
and random number generation used in Bayesian inference workflows. These
utilities are essential for posterior sampling and uncertainty quantification.
Functions
---------
quantile : Weighted quantiles
Compute (weighted) quantiles from samples
draw_sar : Posterior sampling
Draw from (scale, A_V, R_V) posterior
sample_multivariate_normal : Gaussian sampling
Sample from multivariate normal with bounds
See Also
--------
brutus.analysis.individual.BruteForce : Uses these for posterior sampling
brutus.utils.math : Mathematical utilities
Notes
-----
The `quantile` function supports weighted samples, which is crucial for
computing credible intervals from posterior samples with non-uniform weights
(e.g., from importance sampling or nested sampling).
The `draw_sar` function is specifically designed for sampling the joint
posterior of distance scale, extinction, and reddening curve shape from
BruteForce fitting results.
Examples
--------
Weighted quantile computation:
>>> import numpy as np
>>> from brutus.utils.sampling import quantile
>>>
>>> # Samples with different weights
>>> samples = np.array([1, 2, 3, 4, 5])
>>> weights = np.array([1, 1, 1, 1, 10]) # Last sample heavily weighted
>>>
>>> # Compute median and 68% credible interval
>>> q = np.array([0.16, 0.5, 0.84])
>>> intervals = quantile(samples, q, weights=weights)
>>> print(f"Median: {intervals[1]:.2f}")
Drawing from posterior:
>>> from brutus.utils.sampling import draw_sar
>>>
>>> # Posterior means and covariances from fitting
>>> # scales, avs, rvs, covs_sar = ... (from BruteForce)
>>>
>>> # Generate posterior samples
>>> # samples = draw_sar(scales, avs, rvs, covs_sar, ndraws=1000)
"""
import warnings
import numpy as np
from numba import jit, prange
__all__ = ["quantile", "draw_sar", "sample_multivariate_normal"]
[docs]
def quantile(x, q, weights=None):
"""
Compute (weighted) quantiles from an input set of samples.
This function computes quantiles from a set of samples, optionally
with weights, using the midpoint-CDF convention: the empirical CDF is
evaluated at ``(cumsum(w) - 0.5 * w) / sum(w)`` and linearly
interpolated. Omitting `weights` is exactly equivalent to passing
uniform weights, so the reported quantiles do not depend on whether a
trivially-uniform weights array happens to be supplied.
Parameters
----------
x : `~numpy.ndarray` with shape `(nsamps,)`
Input samples.
q : `~numpy.ndarray` with shape `(nquantiles,)` or float
The list of quantiles to compute from `[0., 1.]`.
weights : `~numpy.ndarray` with shape `(nsamps,)`, optional
The associated weight from each sample. If None, all samples
are weighted equally.
Returns
-------
quantiles : `~numpy.ndarray` with shape `(nquantiles,)` or float
The (weighted) sample quantiles computed at `q`.
Raises
------
ValueError
If quantiles are outside [0, 1] or if dimensions don't match.
Examples
--------
>>> import numpy as np
>>> x = np.array([1, 2, 3, 4, 5])
>>> q = np.array([0.25, 0.5, 0.75])
>>> quantile(x, q)
array([1.75, 3. , 4.25])
>>> weights = np.array([1, 1, 1, 1, 10]) # Last sample heavily weighted
>>> quantile(x, q, weights=weights)
array([4. , 4.63636364, 5. ])
"""
# Initial check.
x = np.atleast_1d(x)
q = np.atleast_1d(q)
# Quantile check.
if np.any(q < 0.0) or np.any(q > 1.0):
raise ValueError("Quantiles must be between 0. and 1.")
# Unweighted samples use unit weights so that both paths share the same
# midpoint-CDF quantile convention (weights=None must agree with
# explicitly-uniform weights).
if weights is None:
weights = np.ones(len(x))
else:
weights = np.atleast_1d(weights)
if len(x) != len(weights):
raise ValueError("Dimension mismatch: len(weights) != len(x).")
idx = np.argsort(x) # sort samples
sw = weights[idx] # sort weights
# Compute CDF at sample midpoints for proper quantile calculation
cdf = (np.cumsum(sw, dtype=float) - 0.5 * sw) / np.sum(sw)
return np.interp(q, cdf, x[idx])
[docs]
def draw_sar(
scales,
avs,
rvs,
covs_sar,
ndraws=500,
avlim=(0.0, 6.0),
rvlim=(1.0, 8.0),
rstate=None,
max_attempts=10000,
):
"""
Generate random draws from the joint scale-A_V-R_V posterior for a
given object.
This function generates Monte Carlo samples from the joint posterior
of scale factors, reddening (A_V), and reddening curve shape (R_V)
for stellar fitting applications.
Parameters
----------
scales : `~numpy.ndarray` of shape `(Nsamps)`
An array of scale factors `s` derived between the models and the data.
avs : `~numpy.ndarray` of shape `(Nsamps)`
An array of reddenings `A(V)` derived for the models.
rvs : `~numpy.ndarray` of shape `(Nsamps)`
An array of reddening shapes `R(V)` derived for the models.
covs_sar : `~numpy.ndarray` of shape `(Nsamps, 3, 3)`
An array of covariance matrices corresponding to `(scales, avs, rvs)`.
ndraws : int, optional
The number of desired random draws. Default is `500`.
avlim : 2-tuple, optional
The A_V limits used to truncate results. Default is `(0., 6.)`.
rvlim : 2-tuple, optional
The R_V limits used to truncate results. Default is `(1., 8.)`.
rstate : `~numpy.random.RandomState`, `~numpy.random.Generator`, or
module, optional
Random state used for the draws (anything exposing a
``normal(loc, scale, size)`` method, including the `numpy.random`
module itself). If None, uses the default numpy random state.
max_attempts : int, optional
Maximum number of rejection-sampling passes per posterior sample
before the remaining slots are padded with the mean values (with a
warning). Default is `10000`.
Returns
-------
sdraws : `~numpy.ndarray` of shape `(Nsamps, Ndraws)`
Scale-factor samples.
adraws : `~numpy.ndarray` of shape `(Nsamps, Ndraws)`
Reddening (A_V) samples.
rdraws : `~numpy.ndarray` of shape `(Nsamps, Ndraws)`
Reddening shape (R_V) samples.
Notes
-----
The function samples from multivariate normal distributions defined by
the means (scales, avs, rvs) and covariances (covs_sar), then applies
rejection sampling to ensure all samples fall within the specified
limits for A_V and R_V.
All `Nsamps` distributions are drawn in a single batched
`sample_multivariate_normal` call per rejection pass (rather than one
`numpy.random.multivariate_normal` call per distribution), which avoids
per-call dispatch and per-matrix SVD overhead. The accepted draws follow
the same truncated Gaussian distribution as before, but the underlying
RNG stream differs, so individual draws are not reproducible against
older versions even with a fixed seed.
Examples
--------
>>> import numpy as np
>>> scales = np.array([1.0, 1.1])
>>> avs = np.array([0.1, 0.2])
>>> rvs = np.array([3.1, 3.3])
>>> covs_sar = np.array([[[0.01, 0, 0], [0, 0.01, 0], [0, 0, 0.1]],
... [[0.01, 0, 0], [0, 0.01, 0], [0, 0, 0.1]]])
>>> sdraws, adraws, rdraws = draw_sar(scales, avs, rvs, covs_sar, ndraws=100)
>>> sdraws.shape
(2, 100)
"""
if rstate is None:
rstate = np.random
scales = np.asarray(scales, dtype=float)
avs = np.asarray(avs, dtype=float)
rvs = np.asarray(rvs, dtype=float)
covs_sar = np.asarray(covs_sar, dtype=float)
# Generate realizations for each (scale, av, rv, cov_sar) set.
nsamps = len(scales)
sdraws, adraws, rdraws = np.zeros((3, nsamps, ndraws))
means = np.column_stack((scales, avs, rvs))
# Rejection-sample all still-deficient distributions together: each pass
# draws `ndraws` candidates per active distribution in ONE batched
# Cholesky-based call (instead of one numpy `multivariate_normal` per
# distribution) and fills accepted draws in order.
nfilled = np.zeros(nsamps, dtype=np.int64)
active = np.arange(nsamps)
n_attempts = 0
while active.size > 0 and n_attempts < max_attempts:
n_attempts += 1
# Draw samples; shape (3, ndraws, Nactive).
draws = sample_multivariate_normal(
means[active], covs_sar[active], size=ndraws, rstate=rstate
)
s_mc, a_mc, r_mc = draws[0].T, draws[1].T, draws[2].T # (Nactive, ndraws)
# Flag draws that are out of bounds.
inbounds = (
(s_mc >= 0.0)
& (a_mc >= avlim[0])
& (a_mc <= avlim[1])
& (r_mc >= rvlim[0])
& (r_mc <= rvlim[1])
)
for k in range(active.size):
i = active[k]
good = np.flatnonzero(inbounds[k])
take = min(good.size, ndraws - nfilled[i])
if take > 0:
sel = good[:take]
fill = slice(nfilled[i], nfilled[i] + take)
sdraws[i, fill] = s_mc[k, sel]
adraws[i, fill] = a_mc[k, sel]
rdraws[i, fill] = r_mc[k, sel]
nfilled[i] += take
active = active[nfilled[active] < ndraws]
# Any distribution still deficient after `max_attempts` passes gets its
# remaining slots padded with the mean values (matching the historical
# per-sample fallback).
for i in active:
warnings.warn(
f"draw_sar: only collected {nfilled[i]}/{ndraws} "
f"in-bounds samples after {max_attempts} attempts for "
f"sample {i}. Padding with mean values.",
RuntimeWarning,
stacklevel=2,
)
sdraws[i, nfilled[i] :] = scales[i]
adraws[i, nfilled[i] :] = avs[i]
rdraws[i, nfilled[i] :] = rvs[i]
return sdraws, adraws, rdraws
@jit(nopython=True, cache=True)
def _cholesky_3x3(A):
"""
Compute the Cholesky factor of a 3x3 positive SEMI-definite matrix.
Uses explicit formulas optimized for the 3x3 case. Semi-definite inputs
are handled with the standard rank-deficient completion: each pivot
argument is clamped at zero before the square root, and the entries below
an exactly-zero pivot are set to zero rather than divided (their target
values are zero for any exact PSD matrix). For strictly positive-definite
input this is bit-identical to the textbook factorization; for singular
PSD input it returns a valid factor with ``L @ L.T == A`` instead of
dividing by zero (which, inside a parallel numba kernel, silently leaves
the output buffer uninitialized).
"""
L = np.zeros_like(A)
# L[0,0] = sqrt(A[0,0])
L[0, 0] = np.sqrt(max(A[0, 0], 0.0))
if L[0, 0] > 0.0:
# L[1,0] = A[1,0] / L[0,0]; L[2,0] = A[2,0] / L[0,0]
L[1, 0] = A[1, 0] / L[0, 0]
L[2, 0] = A[2, 0] / L[0, 0]
# L[1,1] = sqrt(A[1,1] - L[1,0]^2)
L[1, 1] = np.sqrt(max(A[1, 1] - L[1, 0] * L[1, 0], 0.0))
if L[1, 1] > 0.0:
# L[2,1] = (A[2,1] - L[2,0] * L[1,0]) / L[1,1]
L[2, 1] = (A[2, 1] - L[2, 0] * L[1, 0]) / L[1, 1]
# L[2,2] = sqrt(A[2,2] - L[2,0]^2 - L[2,1]^2)
L[2, 2] = np.sqrt(max(A[2, 2] - L[2, 0] * L[2, 0] - L[2, 1] * L[2, 1], 0.0))
return L
@jit(nopython=True, cache=True, parallel=True)
def _sample_multivariate_normal_jit(mean, cov, size, eps, random_samples):
"""
Numba-accelerated core multivariate normal sampling.
Parameters
----------
mean : ndarray of shape (Ndist, dim)
Means of the multivariate distributions.
cov : ndarray of shape (Ndist, dim, dim)
Covariances of the multivariate distributions.
size : int
Number of samples to draw from each distribution.
eps : float
Regularization parameter for numerical stability.
random_samples : ndarray of shape (Ndist, dim, size)
Pre-generated standard normal samples.
Returns
-------
samples : ndarray of shape (dim, size, Ndist)
Transformed samples.
Notes
-----
Parallelized over the distribution index ``n`` (each distribution's
regularization, Cholesky factor, and sample transform are independent),
so the result is bitwise-identical to a serial evaluation.
"""
N, d = mean.shape
# Per-distribution: regularize, Cholesky-factor, transform. All independent
# across n, so prange is safe and bitwise-identical to a serial loop.
result = np.empty((d, size, N))
for n in prange(N):
Kn = cov[n].copy()
for i in range(d):
Kn[i, i] += eps
Ln = _cholesky_3x3(Kn)
for s in range(size):
for i in range(d):
val = mean[n, i]
for j in range(d):
val += Ln[i, j] * random_samples[n, j, s]
result[i, s, n] = val
return result
def _antithetic_normals(rstate, N, d, size):
"""
Standard-normal draws of shape ``(N, d, size)`` arranged in antithetic
pairs along the last (sample) axis.
For each base draw ``z`` we also emit ``-z``. Because ``z`` and ``-z`` are
each marginally standard normal, any Monte-Carlo average over these samples
remains unbiased, while the negative correlation within a pair cancels the
linear component of the integrand's variance (variance reduction). When
``size`` is odd the final sample is an unpaired ordinary draw. This also
halves the number of underlying Gaussian draws generated.
"""
nh = (size + 1) // 2
base = rstate.normal(loc=0, scale=1, size=d * nh * N).reshape(N, d, nh)
z = np.empty((N, d, size))
z[:, :, :nh] = base
z[:, :, nh:] = -base[:, :, : size - nh]
return z
[docs]
def sample_multivariate_normal(
mean, cov, size=1, eps=1e-30, rstate=None, antithetic=False
):
"""
Draw samples from many multivariate normal distributions.
Returns samples from an arbitrary number of multivariate distributions.
The multivariate distributions must all have the same dimension.
This function is optimized for drawing from many distributions
simultaneously using Cholesky decomposition.
Parameters
----------
mean : `~numpy.ndarray` of shape `(Ndist, dim)` or `(dim,)`
Means of the various multivariate distributions, where
`Ndist` is the number of desired distributions and
`dim` is the dimension of the distributions.
cov : `~numpy.ndarray` of shape `(Ndist, dim, dim)` or `(dim, dim)`
Covariances of the various multivariate distributions, where
`Ndist` is the number of desired distributions and
`dim` is the dimension of the distributions.
size : int, optional
Number of samples to draw from each distribution. Default is `1`.
eps : float, optional
Small factor added to covariances prior to Cholesky decomposition.
Helps ensure numerical stability and should have no effect on the
outcome. Default is `1e-30`.
rstate : `~numpy.random.RandomState`, optional
`~numpy.random.RandomState` instance. If None, uses default numpy
random state.
antithetic : bool, optional
If True (only effective for the 3D fast path), draw the underlying
standard normals in antithetic pairs ``(z, -z)`` along the sample axis.
This leaves every Monte-Carlo estimate unbiased but reduces its variance
(and halves the number of Gaussian draws generated). Default is False.
Returns
-------
samples : `~numpy.ndarray` of shape `(dim, size, Ndist)` or `(dim, size)`
Sampled values. For a single distribution, returns `(dim, size)`.
For multiple distributions, returns `(dim, size, Ndist)`.
Notes
-----
Provided covariances must be positive semi-definite. Use the `isPSD`
function from `brutus.utils.math` to check individual matrices if unsure.
For a single distribution, this function simply calls numpy's
multivariate_normal. For multiple distributions, it uses Cholesky
decomposition for efficiency.
Examples
--------
>>> import numpy as np
>>> # Single distribution
>>> mean = np.array([0, 1])
>>> cov = np.array([[1, 0.5], [0.5, 1]])
>>> samples = sample_multivariate_normal(mean, cov, size=100)
>>> samples.shape
(2, 100)
>>> # Multiple distributions
>>> means = np.array([[0, 1], [2, 3]]) # 2 distributions, 2D each
>>> covs = np.array([[[1, 0], [0, 1]], [[2, 0.5], [0.5, 2]]])
>>> samples = sample_multivariate_normal(means, covs, size=50)
>>> samples.shape
(2, 50, 2)
"""
if rstate is None:
rstate = np.random
# If we have a single distribution, just revert to `numpy.random` version.
if len(np.shape(mean)) == 1:
samples = rstate.multivariate_normal(mean, cov, size=size)
return samples.T # Transpose to match expected (dim, size) format
# For multiple distributions, check dimension compatibility
N, d = np.shape(mean)
if d == 3:
# Use numba-accelerated version for 3D case
if antithetic:
z = _antithetic_normals(rstate, N, d, size)
else:
z = rstate.normal(loc=0, scale=1, size=d * size * N).reshape(N, d, size)
ans = _sample_multivariate_normal_jit(mean, cov, size, eps, z)
else:
# Fall back to numpy for non-3D cases
ans = []
for i in range(N):
samples_i = rstate.multivariate_normal(mean[i], cov[i], size=size)
ans.append(samples_i.T) # Transpose to match expected format
ans = np.array(ans) # Shape: (N, d, size)
ans = np.transpose(ans, (1, 2, 0)) # Convert to (d, size, N)
return ans