"""DreamAlgorithm — the DREAM(ZS) sampler (the ``dream`` fit type).
DiffeRential Evolution Adaptive Metropolis drawing proposal donors from a
growing ZS archive (Vrugt 2016). Extracted byte-identical (M1 Step 4).
Subclasses the sampler base (BayesianAlgorithm) and inherits the run loop +
execution seam from Algorithm; it makes no core.* call of its own.
"""
from .base import BayesianAlgorithm, MCMCFamilyConfig
from ...pset import PSet, OutOfBoundsException
from ...printing import print1, print2
from ...registry import register_fit_type
from typing import Any, Optional
import logging
import numpy as np
import copy
from pydantic import Field
from scipy import stats
# Preserve the original module logger name so log records keep the
# 'pybnf.algorithms' channel.
logger = logging.getLogger('pybnf.algorithms')
[docs]
class DreamConfig(MCMCFamilyConfig):
"""Config for DREAM(ZS) (dream), co-located with the method (ADR-0006). Adds the
DREAM-specific keys on top of the shared family fields; the β-ladder
``postprocess`` hook is inherited. ``PDreamConfig`` (p_dream) subclasses this and
adds ``precondition_adapt``. ``adaptive_step_size`` is typed ``Any`` so a user
int 0/1 stays an int (matching the old behavior). Values byte-identical to the
old global defaults.
"""
gamma_prob: float = 0.1
zeta: float = 1e-6
lambda_: float = Field(0.1, alias='lambda')
crossover_number: int = 3
adaptive_step_size: Any = True # bool default; user may pass int 0/1
archive_size: Optional[int] = None
archive_thin_rate: int = 10
snooker_prob: float = 0.1
delta: int = 1
outlier_method: str = 'iqr'
[docs]
@classmethod
def postprocess(cls, conf_dict, fit_type):
"""The MCMC β-ladder (inherited from ``MCMCFamilyConfig``) plus the
DREAM-family ``step_size`` coupling (ADR-0013): a user-set ``step_size``
pins ``adaptive_step_size`` off. Both keys are owned only by DREAM/P-DREAM,
so the coupling is intra-family -- it formerly lived as a global write in
``config.py`` that, post-narrowing, would have stamped an orphan
``adaptive_step_size`` onto any ``mh``/``am``/``sa`` fit that set
``step_size``. ``PDreamConfig`` inherits this (it owns both keys too)."""
super().postprocess(conf_dict, fit_type)
if 'step_size' in conf_dict:
conf_dict['adaptive_step_size'] = False
return conf_dict
[docs]
@register_fit_type('dream', family='sampler', display_name='DREAM(ZS)',
schema=DreamConfig)
class DreamAlgorithm(BayesianAlgorithm):
"""
Implements a variant of the DREAM algorithm as described in Vrugt (2016) Environmental Modelling
and Software.
Adapts Bayesian MCMC to use methods from differential evolution for accelerated convergence and
more efficient sampling of parameter space
"""
def __init__(self, config):
super().__init__(config)
self.ncr = [(1+x)/self.config.config['crossover_number'] for x in range(self.config.config['crossover_number'])]
self.ncr_count = len(self.ncr)
self.g_prob = self.config.config['gamma_prob']
self.adaptive_step_size = config.config['adaptive_step_size']
self.acceptances = [0]*self.num_parallel
self.acceptance_rates = [0.0]*self.num_parallel
# CR adaptation state
self.cr_probs = np.ones(self.ncr_count) / self.ncr_count
self.cr_total_distance = np.zeros(self.ncr_count)
self.cr_usage_count = np.zeros(self.ncr_count)
self.cr_adapt_end = self.burn_in // 2
self.cr_frozen = False
# Per-generation tracking for CR adaptation
self.gen_cr_indices = [None] * self.num_parallel
self.gen_x_old = [None] * self.num_parallel
self.gen_x_std = np.ones(self.n_dim)
# ZS archive: external archive of past states for proposal generation
m0 = config.config['archive_size']
self.archive_m0 = m0 if m0 is not None else 10 * self.n_dim
self.archive_thin_rate = config.config['archive_thin_rate']
self.archive = [] # list of PSet objects
# Snooker update
self.snooker_prob = config.config['snooker_prob']
self.gen_log_snooker_correction = [0.0] * self.num_parallel
# Multiple chain pairs
self.delta = config.config['delta']
# Outlier detection method
self.outlier_method = config.config['outlier_method']
[docs]
def start_run(self, setup_samples=True):
first_psets = super().start_run(setup_samples)
# Initialize the ZS archive with m0 random draws from the prior
self.archive = [self.random_pset() for _ in range(self.archive_m0)]
logger.info('Initialized ZS archive with %d entries (d=%d)' % (self.archive_m0, self.n_dim))
return first_psets
def _proposal_pset(self, xp_vec):
"""Materialize a proposal vector (in sampling space ``u``) into a PSet
*without reflection* — DREAM rejects an out-of-bounds proposal rather
than folding it back into the box. Returns the PSet, or ``None`` if any
coordinate falls outside its parameter's bounds.
Shared by the snooker proposal and P-DREAM's whitened proposal, which
differ only in how ``xp_vec`` is built and in their Hastings/return
bookkeeping (kept in each method, per ADR-0009).
"""
try:
# The shared inverse bridge maps each coordinate back via its scale
# (FreeParameter.from_sampling_space); reflect=False makes an
# out-of-box coordinate raise, which we turn into a rejected proposal.
return self._pset_from_u(xp_vec, reflect=False)
except OutOfBoundsException:
return None
[docs]
def calculate_snooker_pset(self, idx):
"""
Snooker update proposal (ter Braak & Vrugt, 2008).
Projects archive points onto the line through the current state and a reference archive point,
then jumps along that axis.
Returns (PSet or None, log_correction) where log_correction is the log of the Hastings
correction factor (d-1)*log(||Xp - Zc|| / ||X - Zc||).
"""
x0 = self.current_pset[idx]
x0_vec = self._param_vec(x0)
# Draw three distinct archive indices: c (reference), a, b (for projection difference)
sel = self.chain_rngs[idx].choice(len(self.archive), 3, replace=False)
zc_vec = self._param_vec(self.archive[sel[0]])
za_vec = self._param_vec(self.archive[sel[1]])
zb_vec = self._param_vec(self.archive[sel[2]])
# Snooker axis: line through x0 and zc
axis = x0_vec - zc_vec
axis_norm_sq = np.dot(axis, axis)
if axis_norm_sq < 1e-20:
return None, 0.0
# Project za and zb onto the snooker axis
za_proj = zc_vec + axis * (np.dot(za_vec - zc_vec, axis) / axis_norm_sq)
zb_proj = zc_vec + axis * (np.dot(zb_vec - zc_vec, axis) / axis_norm_sq)
# Jump vector along the axis
diff_proj = za_proj - zb_proj
# Gamma for snooker: U(1.2, 2.2) per Vrugt (2016)
gamma_s = self.chain_rngs[idx].uniform(1.2, 2.2)
# Small perturbations
zeta_vec = self.chain_rngs[idx].normal(0, self.config.config['zeta'], size=self.n_dim)
lamb = self.chain_rngs[idx].uniform(-self.config.config['lambda'], self.config.config['lambda'])
xp_vec = x0_vec + zeta_vec + (1.0 + lamb) * gamma_s * diff_proj
# Build the proposed PSet (reject rather than reflect out-of-bounds)
proposal = self._proposal_pset(xp_vec)
if proposal is None:
return None, 0.0
# Hastings correction: (||Xp - Zc|| / ||X - Zc||)^(d-1).
# dist_x0_zc = sqrt(axis_norm_sq) is already guaranteed nonzero by the
# axis_norm_sq < 1e-20 check above, so no divide-by-zero guard is needed here.
dist_xp_zc = np.linalg.norm(xp_vec - zc_vec)
dist_x0_zc = np.linalg.norm(x0_vec - zc_vec)
log_correction = (self.n_dim - 1) * np.log(dist_xp_zc / dist_x0_zc)
return proposal, log_correction
def _detect_outliers_iqr(self, mean_ln_p):
"""IQR outlier detection: chains below Q25 - 2*IQR are outliers."""
Q75 = np.percentile(mean_ln_p, 75)
Q25 = np.percentile(mean_ln_p, 25)
iqr = Q75 - Q25
return np.where(mean_ln_p < Q25 - 2.0 * iqr)[0]
def _detect_outliers_grubbs(self, mean_ln_p):
"""Grubbs test for a single minimum outlier at significance alpha=0.01."""
N = len(mean_ln_p)
if N < 3:
return np.array([], dtype=int)
mu = np.mean(mean_ln_p)
sd = np.std(mean_ln_p, ddof=1)
if sd < 1e-20:
return np.array([], dtype=int)
G = (mu - np.min(mean_ln_p)) / sd
alpha = 0.01
t_crit_sq = stats.t.ppf(alpha / (2 * N), N - 2) ** 2
T_c = (N - 1) / np.sqrt(N) * np.sqrt(t_crit_sq / (N - 2 + t_crit_sq))
if G > T_c:
return np.array([np.argmin(mean_ln_p)])
return np.array([], dtype=int)
[docs]
def detect_and_reset_outliers(self):
"""
Detect outlier chains using the configured method on mean log-posteriors
(last 50% of history). Reset outlier chains to copies of randomly selected
non-outlier chains.
Methods: 'iqr' (interquartile range), 'grubbs' (Grubbs test at alpha=0.01).
"""
min_len = min(len(h) for h in self.ln_posterior_history)
start = min_len // 2
if min_len - start < 5:
return
mean_ln_p = np.array([
np.mean(self.ln_posterior_history[j][start:min_len]) for j in range(self.num_parallel)
])
if self.outlier_method == 'grubbs':
outlier_indices = self._detect_outliers_grubbs(mean_ln_p)
else:
outlier_indices = self._detect_outliers_iqr(mean_ln_p)
if len(outlier_indices) == 0:
return
good_indices = [i for i in range(self.num_parallel) if i not in outlier_indices]
if len(good_indices) == 0:
return
for out_idx in outlier_indices:
# Cross-chain reset (resolved at the generation barrier) -> root rng.
donor_idx = self.rng.choice(good_indices)
logger.warning('Outlier chain %d reset to chain %d at iteration %d (method=%s)'
% (out_idx, donor_idx, self.iteration[out_idx], self.outlier_method))
self.current_pset[out_idx] = copy.deepcopy(self.current_pset[donor_idx])
self.ln_current_P[out_idx] = self.ln_current_P[donor_idx]
self.ln_posterior_history[out_idx][start:min_len] = self.ln_posterior_history[donor_idx][start:min_len]
self.chain_history[out_idx][start:min_len] = self.chain_history[donor_idx][start:min_len]
[docs]
def got_result(self, res):
"""
Called by the scheduler when a simulation is completed, with the pset that was run, and the resulting simulation
data
:param res: PSet that was run in this simulation
:type res: Result
:return: List of PSet(s) to be run next.
"""
pset = res.pset
score = res.score
self.total_evaluations += 1
index = self._chain_index_from_name(pset.name)
# Calculate posterior of finished job
lnprior = self.ln_prior(pset)
lnlikelihood = -score
lnposterior = lnprior + lnlikelihood
# Metropolis-Hastings criterion (includes snooker Hastings correction when applicable)
ln_p_accept = min(0., lnposterior - self.ln_current_P[index]
+ self.gen_log_snooker_correction[index])
if np.log(self.chain_rngs[index].uniform()) < ln_p_accept: # accept update based on MH criterion
self.current_pset[index] = pset
self.ln_current_P[index] = lnposterior
self.acceptances[index] += 1
self.evaluate_constraints(res.simdata, index)
self.record_pointwise_loglik(res, index)
# Store chain history (after accept/reject, so it reflects the kept state)
self.chain_history[index].append(self._param_vec(self.current_pset[index]))
self.ln_posterior_history[index].append(self.ln_current_P[index])
# CR adaptation: compute standardized distance traveled
if not self.cr_frozen and self.gen_cr_indices[index] is not None:
x_new_vec = self._param_vec(self.current_pset[index])
if self.gen_x_old[index] is not None:
diff_vec = x_new_vec - self.gen_x_old[index]
sd_dist = np.sum((diff_vec / np.maximum(self.gen_x_std, 1e-10)) ** 2)
self.cr_total_distance[self.gen_cr_indices[index]] += sd_dist
self.cr_usage_count[self.gen_cr_indices[index]] += 1
# Record that this individual is complete
self.wait_for_sync[index] = True
self.iteration[index] += 1
self.acceptance_rates[index] = self.acceptances[index] / self.iteration[index]
# Update histograms and trajectories if necessary
if self.iteration[index] % self.sample_every == 0 and self.iteration[index] > self.burn_in:
self.sample_pset(self.current_pset[index], self.ln_current_P[index], index)
if (self.iteration[index] % (self.sample_every * self.output_hist_every) == 0
and self.iteration[index] > self.burn_in):
self.update_histograms('_%i' % self.iteration[index])
# Wait for entire generation to finish
# Loop handles the case where all proposals are out of bounds: advance
# the generation counter and try again instead of returning an empty
# list (which would exhaust the job pool and silently end the run).
while np.all(self.wait_for_sync):
self.wait_for_sync = [False] * self.num_parallel
if min(self.iteration) >= self.max_iterations:
self.update_histograms('_final')
self.report_constraint_satisfaction('_final')
return 'STOP'
if self.iteration[index] % 10 == 0:
print1('Completed iteration %i of %i' % (self.iteration[index], self.max_iterations))
print2(f'Acceptance rates: {str(self.acceptance_rates)}\n')
else:
print2('Completed iteration %i of %i' % (self.iteration[index], self.max_iterations))
# Convergence diagnostics (R-hat, ESS) on their own stride (PERF-1)
if self.iteration[index] % self.diagnostics_every == 0:
max_rhat = self.report_convergence_diagnostics(self.iteration[index])
if self.check_convergence(self.iteration[index], max_rhat):
return 'STOP'
logger.debug('Completed %i iterations' % self.iteration[index])
print2(f'Current -Ln Posteriors: {str(self.ln_current_P)}')
# Outlier detection (every 10 iterations, only during burn-in)
if self.iteration[index] % 10 == 0 and self.iteration[index] <= self.burn_in:
self.detect_and_reset_outliers()
# CR adaptation: update probabilities
if (self.iteration[index] % 10 == 0
and self.iteration[index] <= self.cr_adapt_end
and not self.cr_frozen):
with np.errstate(divide='ignore', invalid='ignore'):
mean_dist = self.cr_total_distance / np.maximum(self.cr_usage_count, 1)
if np.sum(mean_dist) > 0:
self.cr_probs = mean_dist / np.sum(mean_dist)
logger.debug(f'Updated CR probabilities: {str(self.cr_probs)}')
elif self.iteration[index] > self.cr_adapt_end and not self.cr_frozen:
self.cr_frozen = True
logger.debug('CR probabilities frozen at iteration %d: %s'
% (self.iteration[index], str(self.cr_probs)))
# Grow the ZS archive: every K generations, append current chain states
if self.iteration[index] % self.archive_thin_rate == 0:
for i in range(self.num_parallel):
self.archive.append(copy.deepcopy(self.current_pset[i]))
logger.debug('Archive grown to %d entries at iteration %d'
% (len(self.archive), self.iteration[index]))
# Save old states and compute population std for CR adaptation
for i in range(self.num_parallel):
self.gen_x_old[i] = self._param_vec(self.current_pset[i])
all_vecs = np.array(self.gen_x_old)
self.gen_x_std = np.std(all_vecs, axis=0)
next_gen = []
for i, p in enumerate(self.current_pset):
if self.chain_rngs[i].uniform() < self.snooker_prob:
# Snooker update
new_pset, log_corr = self.calculate_snooker_pset(i)
self.gen_log_snooker_correction[i] = log_corr
self.gen_cr_indices[i] = None # no CR for snooker
else:
# Parallel direction update
new_pset, cr_idx = self.calculate_new_pset(i)
self.gen_log_snooker_correction[i] = 0.0
self.gen_cr_indices[i] = cr_idx
if new_pset:
new_pset.name = 'iter%irun%i' % (self.iteration[i], i)
next_gen.append(new_pset)
else:
# Out-of-bounds proposal: treat as a Metropolis rejection.
# Record the current state in chain history (chain stays in place)
# so that diagnostics (R-hat, ESS) correctly reflect the non-movement.
logger.debug('Proposed PSet for chain %d is out of bounds. Treating as rejection.' % i)
self.chain_history[i].append(self._param_vec(self.current_pset[i]))
self.ln_posterior_history[i].append(self.ln_current_P[i])
self.wait_for_sync[i] = True
self.iteration[i] += 1
self.acceptance_rates[i] = self.acceptances[i] / self.iteration[i]
if self.iteration[i] % self.sample_every == 0 and self.iteration[i] > self.burn_in:
self.sample_pset(self.current_pset[i], self.ln_current_P[i], i)
if not next_gen:
logger.warning('All %d proposals were out of bounds at iteration %d. '
'Advancing to next generation.'
% (self.num_parallel, min(self.iteration)))
continue
return next_gen
return []
[docs]
def calculate_new_pset(self, idx):
"""
Uses differential evolution-like update to calculate new PSet.
Returns (PSet, cr_idx) or (None, cr_idx) if the proposal is out of bounds.
:param idx: Index of PSet to update
:return: tuple of (PSet or None, int)
"""
x0 = self.current_pset[idx]
# Draw 2*delta donor states from the ZS archive (without replacement)
sel = self.chain_rngs[idx].choice(len(self.archive), 2 * self.delta, replace=False)
# Sample crossover value and mask
cr_idx = self.chain_rngs[idx].choice(self.ncr_count, p=self.cr_probs)
cr = self.ncr[cr_idx]
while True:
ds = self.chain_rngs[idx].uniform(size=self.n_dim) <= cr # sample parameter subspace
if np.any(ds):
break
# Gamma selection: mode jump (gamma=1) or adaptive/fixed step size
if self.chain_rngs[idx].uniform() < self.g_prob:
gamma = 1
ds[:] = True # mode jump updates all dimensions
else:
d_prime = int(np.sum(ds))
if self.adaptive_step_size:
gamma = 2.38 / np.sqrt(2.0 * self.delta * d_prime)
else:
gamma = self.step_size
new_vars = []
for i, d in enumerate(ds):
k = self.variables[i]
if d:
# Sum of delta difference vectors: sum_{j=1}^{delta} (Z_a_j - Z_b_j)
total_diff = 0.0
for j in range(self.delta):
total_diff += self.archive[sel[j]].get_param(k.name).diff(
self.archive[sel[self.delta + j]].get_param(k.name))
else:
total_diff = 0.0
zeta = self.chain_rngs[idx].normal(0, self.config.config['zeta'])
lamb = self.chain_rngs[idx].uniform(-self.config.config['lambda'], self.config.config['lambda'])
# Differential evolution calculation (while satisfying detailed balance)
try:
# Do not reflect the parameter (need to reject if outside bounds)
new_var = x0.get_param(k.name).add(zeta + (1. + lamb) * gamma * total_diff, False)
new_vars.append(new_var)
except OutOfBoundsException:
logger.debug("Variable %s is outside of bounds")
return None, cr_idx
return PSet(new_vars), cr_idx