Source code for pybnf.algorithms.samplers.pdream

"""PDreamAlgorithm — Preconditioned DREAM (the ``p_dream`` fit type).

DREAM(ZS) with proposals computed in a covariance-whitened parameter space, for
better sampling of correlated posteriors. Extracted byte-identical (M1 Step 4).
Subclasses DreamAlgorithm (a sibling leaf in this sub-package).
"""


from .dream import DreamAlgorithm, DreamConfig
from ...registry import register_fit_type

from typing import Any

import logging
import numpy as np


# Preserve the original module logger name so log records keep the
# 'pybnf.algorithms' channel.
logger = logging.getLogger('pybnf.algorithms')


[docs] class PDreamConfig(DreamConfig): """Config for preconditioned DREAM (p_dream), co-located with the method (ADR-0006). Extends :class:`DreamConfig` with the one preconditioning key ``PDreamAlgorithm`` adds; everything else (incl. the β-ladder hook) is inherited.""" precondition_adapt: Any = None
[docs] @register_fit_type('p_dream', family='sampler', display_name='Preconditioned DREAM', schema=PDreamConfig) class PDreamAlgorithm(DreamAlgorithm): """ P-DREAM: Preconditioned DREAM. Extends DREAM(ZS) by computing DE proposals in a covariance-whitened parameter space. An online covariance estimate C is learned from the chain history (as in Adaptive Metropolis). Donors are transformed to whitened coordinates z = L_inv @ x before computing DE differences, and crossover is applied in whitened space where dimensions are decorrelated. The proposal remains symmetric (DE differences from an external archive), so standard Metropolis-Hastings acceptance is valid without additional Hastings correction. After a configurable adaptation period, the covariance is updated every generation from the pooled chain history. Before adaptation begins, the sampler behaves identically to DREAM(ZS). """ def __init__(self, config): super().__init__(config) pa = config.config['precondition_adapt'] self.precondition_adapt = pa if pa is not None else self.burn_in // 2 self._cov_L = None # Cholesky factor of the covariance estimate self._cov_L_inv = None # Inverse of Cholesky factor (whitening matrix) self._preconditioned = False def _update_covariance(self): """ Estimate the covariance from pooled chain history and compute Cholesky factors. Discards the first 50% of each chain as warmup (matching the convention used by diagnostics.split_chains for R-hat/ESS) so the early burn-in transient does not inflate the preconditioner. Pooling across chains is intentional: P-DREAM's global preconditioner wants a proposal scale large enough for archive-based mode hopping. """ # Pool the post-warmup half of each chain history into one matrix all_samples = [] for chain in self.chain_history: if len(chain) > 1: all_samples.extend(chain[len(chain) // 2:]) if len(all_samples) < 2 * self.n_dim: return # Not enough samples yet X = np.array(all_samples) n = X.shape[0] d = X.shape[1] # Sample covariance with Haario-style regularization: C = Cov(X) + eps*I cov = np.cov(X, rowvar=False) eps = 1e-6 * np.trace(cov) / d if np.trace(cov) > 0 else 1e-6 cov += eps * np.eye(d) try: L = np.linalg.cholesky(cov) self._cov_L = L self._cov_L_inv = np.linalg.solve(L, np.eye(d)) if not self._preconditioned: self._preconditioned = True logger.info('P-DREAM: preconditioning activated at iteration %d ' 'with %d pooled samples (d=%d)' % (min(self.iteration), n, d)) else: logger.debug('P-DREAM: covariance updated with %d samples' % n) except np.linalg.LinAlgError: logger.warning('P-DREAM: Cholesky decomposition failed, ' 'skipping covariance update') def _whiten(self, x_vec): """Transform a parameter vector to whitened space: z = L_inv @ x.""" return self._cov_L_inv @ x_vec def _unwhiten_diff(self, dz_vec): """Transform a difference vector from whitened space back: dx = L @ dz.""" return self._cov_L @ dz_vec
[docs] def got_result(self, res): """Override to update covariance estimate after each generation sync.""" result = super().got_result(res) # After a full generation sync with new proposals, update the covariance if isinstance(result, list) and len(result) > 0: if min(self.iteration) >= self.precondition_adapt: self._update_covariance() return result
[docs] def calculate_new_pset(self, idx): """ DE proposal in whitened space. When preconditioning is active: 1. Transform current state and archive donors to z = L_inv @ x 2. Compute DE difference in z-space 3. Apply crossover in z-space (dimensions are decorrelated) 4. Scale and add perturbation in z-space 5. Convert the total jump back: dx = L @ dz_total 6. Propose x_new = x_current + dx Before preconditioning activates, falls back to standard DREAM proposals. """ if not self._preconditioned: return super().calculate_new_pset(idx) x0 = self.current_pset[idx] x0_vec = self._param_vec(x0) # 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) # Whiten the donor states z_donors = [] for s in sel: z_donors.append(self._whiten(self._param_vec(self.archive[s]))) # Sample crossover value and mask (in whitened space where dims are independent) 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 if np.any(ds): break # Gamma selection if self.chain_rngs[idx].uniform() < self.g_prob: gamma = 1 ds[:] = True # mode jump: update 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 # Compute DE difference in whitened space dz_total = np.zeros(self.n_dim) for j in range(self.delta): dz_total += z_donors[j] - z_donors[self.delta + j] # Apply crossover mask in whitened space dz_masked = np.where(ds, dz_total, 0.0) # Small perturbations in whitened space zeta_z = 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']) # Total jump in whitened space, then transform back to original space dz_jump = zeta_z + (1.0 + lamb) * gamma * dz_masked dx_jump = self._unwhiten_diff(dz_jump) # Build proposed PSet in original space (reject rather than reflect) xp_vec = x0_vec + dx_jump proposal = self._proposal_pset(xp_vec) if proposal is None: logger.debug("Proposed parameter outside of bounds") return None, cr_idx return proposal, cr_idx