Files
foxhunt/crates/ml-regime/src/bayesian_changepoint.rs
jgrusewski 341a2cadf0 feat(dqn): H100 curiosity module, branching config, regime conditioning + clippy clean
Adds curiosity-driven exploration (ForwardDynamicsModel + ICM reward),
configurable branching DQN fields (num_order_types, num_urgency_levels),
regime-conditional importance sampling with ADX/CUSUM thresholds, and
GPU curiosity training kernel support.

Also fixes remaining 5 clippy errors from WIP merge:
- gpu_smoketest: add 8 missing DQNConfig fields
- curiosity.rs: replace needless_range_loop with slice fill
- benchmark files: remove redundant #[cfg_attr] on unconditionally ignored tests

40 files changed, +1486/-1068 lines. 0 clippy errors, 0 warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:30:51 +01:00

477 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Bayesian Online Changepoint Detection (BOCD)
//!
//! This module implements the Bayesian Online Changepoint Detection algorithm
//! for probabilistic regime change detection in financial time series.
//!
//! # Algorithm Overview
//!
//! BOCD maintains a distribution over the current "run length" (time since last changepoint)
//! and updates it online as new data arrives. The algorithm computes:
//!
//! 1. **Run-length distribution**: P(rₜ|x₁:ₜ) - probability that the current run length is r
//! 2. **Hazard function**: H(r) = 1/λ - probability of changepoint given run length r
//! 3. **Predictive probability**: P(xₜ|x₁:ₜ₋₁, rₜ) - likelihood of observation given run length
//!
//! # Mathematical Formulation
//!
//! The core update equations are:
//!
//! ```text
//! P(rₜ|x₁:ₜ) ∝ P(xₜ|rₜ, x₁:ₜ₋₁) × [
//! P(rₜ₋₁ = rₜ - 1|x₁:ₜ₋₁) × (1 - H(rₜ-1)) if rₜ > 0
//! Σᵣ P(rₜ₋₁ = r|x₁:ₜ₋₁) × H(r) if rₜ = 0
//! ]
//! ```
//!
//! Where:
//! - H(r) = 1/λ is the constant hazard function (λ = expected run length)
//! - P(xₜ|rₜ, x₁:ₜ₋₁) is computed using a conjugate Gaussian model
//!
//! # Usage
//!
//! ```rust
//! use ml::regime::bayesian_changepoint::{BayesianChangepointDetector, ChangepointInfo};
//!
//! // Create detector with hazard rate λ=100 (expect changepoint every 100 bars)
//! let mut detector = BayesianChangepointDetector::new(100.0, 0.3, 200);
//!
//! // Update with new values
//! for price in &[100.0, 101.0, 102.0, 150.0] { // Jump at 150.0
//! if let Some(info) = detector.update(*price) {
//! println!("Changepoint detected! Probability: {}", info.probability);
//! }
//! }
//!
//! // Query current state
//! let prob = detector.get_changepoint_probability();
//! let run_length = detector.get_expected_run_length();
//! ```
//!
//! # Performance
//!
//! - Target: <150μs per update (Bayesian computation intensive)
//! - Memory: O(max_run_length) for probability distribution
//! - Online: Constant time per update (no recomputation of history)
//!
//! # References
//!
//! - Adams & MacKay (2007): "Bayesian Online Changepoint Detection"
//! - Used in: Regime detection, structural break identification, adaptive strategies
use serde::{Deserialize, Serialize};
/// Information about a detected changepoint
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangepointInfo {
/// Probability of changepoint (0.0 to 1.0)
pub probability: f64,
/// Expected run length since last changepoint
pub expected_run_length: f64,
/// Maximum a posteriori (MAP) run length
pub map_run_length: usize,
/// Current observation value
pub value: f64,
/// Time index of detection
pub time_index: usize,
}
/// Bayesian Online Changepoint Detector
///
/// Maintains a distribution over the current run length (time since last changepoint)
/// and updates it online using Bayesian inference.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BayesianChangepointDetector {
/// Hazard rate parameter (λ): Expected run length = 1/hazard_rate
/// Higher λ → more frequent changepoints
/// Lower λ → less frequent changepoints
hazard_rate: f64,
/// Probability threshold for changepoint detection (0.0 to 1.0)
/// Typical values: 0.2-0.4
changepoint_prob_threshold: f64,
/// Maximum run length to track (truncation for computational efficiency)
max_run_length: usize,
/// Current run-length probability distribution P(rₜ|x₁:ₜ)
/// Index r represents run length r (time since last changepoint)
run_length_probs: Vec<f64>,
/// Sufficient statistics for Gaussian model (online updates)
/// mean`[r]` = mean of observations for run length r
means: Vec<f64>,
/// Sufficient statistics for Gaussian model (online updates)
/// variance`[r]` = variance of observations for run length r
variances: Vec<f64>,
/// Number of observations for each run length
counts: Vec<f64>,
/// Current time index (number of observations processed)
time_index: usize,
/// Prior hyperparameters for Gaussian model
/// μ₀: Prior mean
prior_mean: f64,
/// Prior hyperparameters for Gaussian model
/// κ₀: Prior precision (pseudo-count)
prior_precision: f64,
/// Prior hyperparameters for Gaussian model
/// α₀: Prior degrees of freedom
prior_alpha: f64,
/// Prior hyperparameters for Gaussian model
/// β₀: Prior variance scale
prior_beta: f64,
}
impl BayesianChangepointDetector {
/// Create a new Bayesian changepoint detector
///
/// # Arguments
///
/// * `hazard_rate` - Expected run length = 1/hazard_rate (e.g., 100.0 → expect changepoint every 100 bars)
/// * `threshold` - Probability threshold for changepoint detection (0.0 to 1.0, typical: 0.2-0.4)
/// * `max_run_length` - Maximum run length to track (truncation, typical: 200-500)
///
/// # Returns
///
/// New detector instance with default Gaussian priors
///
/// # Examples
///
/// ```rust
/// use ml::regime::bayesian_changepoint::BayesianChangepointDetector;
///
/// // Expect changepoint every 100 bars, detect at 30% probability, track up to 200 bars
/// let detector = BayesianChangepointDetector::new(100.0, 0.3, 200);
/// ```
pub fn new(hazard_rate: f64, threshold: f64, max_run_length: usize) -> Self {
// Initialize run-length distribution: P(r₀ = 0) = 1.0 (start with run length 0)
let mut run_length_probs = vec![0.0; max_run_length + 1];
run_length_probs[0] = 1.0;
// Non-informative priors for Gaussian model
// prior_beta = 1e4 gives prior variance = β/α = 1e4, broad enough for any
// data scale. This prevents the changepoint hypothesis from being starved
// by a narrow prior when the first observations set the mean far from 0.
let prior_mean = 0.0; // μ₀: No prior knowledge of mean
let prior_precision = 0.01; // κ₀: Low confidence in prior mean
let prior_alpha = 1.0; // α₀: Minimal degrees of freedom
let prior_beta = 1e4; // β₀: Broad variance scale (supports any data scale)
Self {
hazard_rate,
changepoint_prob_threshold: threshold,
max_run_length,
run_length_probs,
means: vec![prior_mean; max_run_length + 1],
variances: vec![1.0; max_run_length + 1],
counts: vec![0.0; max_run_length + 1],
time_index: 0,
prior_mean,
prior_precision,
prior_alpha,
prior_beta,
}
}
/// Update detector with a new observation
///
/// # Arguments
///
/// * `value` - New observation value
///
/// # Returns
///
/// Some(ChangepointInfo) if changepoint detected (P(r=0) > threshold), None otherwise
///
/// # Algorithm
///
/// 1. Compute predictive probabilities P(xₜ|rₜ, x₁:ₜ₋₁) for all run lengths
/// 2. Update run-length distribution using Bayes' rule and hazard function
/// 3. Update sufficient statistics for Gaussian model
/// 4. Check if P(r=0) exceeds threshold
///
/// # Examples
///
/// ```rust
/// use ml::regime::bayesian_changepoint::BayesianChangepointDetector;
///
/// let mut detector = BayesianChangepointDetector::new(100.0, 0.3, 200);
///
/// // Stable regime
/// for i in 0..50 {
/// detector.update(100.0 + (i as f64 * 0.1));
/// }
///
/// // Regime change (sudden jump)
/// if let Some(info) = detector.update(150.0) {
/// println!("Changepoint detected at probability {}", info.probability);
/// }
/// ```
pub fn update(&mut self, value: f64) -> Option<ChangepointInfo> {
self.time_index += 1;
// Step 1: Compute predictive probabilities P(xₜ|rₜ, x₁:ₜ₋₁) for all run lengths
let mut predictive_probs = vec![0.0; self.max_run_length + 1];
for r in 0..=self.max_run_length {
if self.run_length_probs[r] > 1e-10 {
// Skip negligible probabilities
predictive_probs[r] = self.compute_predictive_probability(value, r);
}
}
// Step 2: Update run-length distribution using Bayes' rule and hazard function
let mut new_probs = vec![0.0; self.max_run_length + 1];
// Growth probabilities: P(rₜ = r+1|x₁:ₜ) from P(rₜ₋₁ = r|x₁:ₜ₋₁)
for r in 0..self.max_run_length {
if self.run_length_probs[r] > 1e-10 {
let survival_prob = 1.0 - self.hazard_function(r);
new_probs[r + 1] += self.run_length_probs[r] * predictive_probs[r] * survival_prob;
}
}
// Changepoint probability: P(rₜ = 0|x₁:ₜ) = Σᵣ P(rₜ₋₁ = r|x₁:ₜ₋₁) × H(r) × P(xₜ|rₜ=0)
let mut changepoint_prob = 0.0;
for r in 0..=self.max_run_length {
if self.run_length_probs[r] > 1e-10 {
changepoint_prob += self.run_length_probs[r] * self.hazard_function(r);
}
}
new_probs[0] = changepoint_prob * predictive_probs[0];
// Step 3: Normalize probabilities
let total: f64 = new_probs.iter().sum();
if total > 1e-10 {
for p in &mut new_probs {
*p /= total;
}
} else {
// Numerical underflow: Reset to initial state
new_probs = vec![0.0; self.max_run_length + 1];
new_probs[0] = 1.0;
}
// Step 3b: If a changepoint is detected, reset sufficient statistics
// so the new regime starts with fresh priors. Without this, the shared
// Welford statistics carry contamination from the old regime, preventing
// the algorithm from properly tracking subsequent regime changes.
if new_probs[0] > self.changepoint_prob_threshold {
self.counts = vec![0.0; self.max_run_length + 1];
self.means = vec![self.prior_mean; self.max_run_length + 1];
self.variances = vec![1.0; self.max_run_length + 1];
}
// Step 4: Update sufficient statistics for Gaussian model
self.update_sufficient_statistics(value, &new_probs);
// Update run-length distribution
self.run_length_probs = new_probs;
// Step 5: Check for changepoint detection
let cp_prob = self.run_length_probs[0];
(cp_prob > self.changepoint_prob_threshold).then(|| ChangepointInfo {
probability: cp_prob,
expected_run_length: self.get_expected_run_length(),
map_run_length: self.get_map_run_length(),
value,
time_index: self.time_index,
})
}
/// Get current changepoint probability P(r=0|x₁:ₜ)
///
/// # Returns
///
/// Probability that a changepoint just occurred (0.0 to 1.0)
pub fn get_changepoint_probability(&self) -> f64 {
self.run_length_probs[0]
}
/// Get expected run length E[r|x₁:ₜ] = Σᵣ r × P(r|x₁:ₜ)
///
/// # Returns
///
/// Expected number of bars since last changepoint
pub fn get_expected_run_length(&self) -> f64 {
self.run_length_probs
.iter()
.enumerate()
.map(|(r, &prob)| r as f64 * prob)
.sum()
}
/// Get maximum a posteriori (MAP) run length
///
/// # Returns
///
/// Most likely run length (arg max P(r|x₁:ₜ))
pub fn get_map_run_length(&self) -> usize {
self.run_length_probs
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(r, _)| r)
.unwrap_or(0)
}
/// Compute hazard function H(r) = 1/λ (constant hazard)
///
/// # Arguments
///
/// * `run_length` - Current run length r
///
/// # Returns
///
/// Probability of changepoint given run length r
fn hazard_function(&self, _run_length: usize) -> f64 {
// Constant hazard: H(r) = 1/λ for all r
1.0 / self.hazard_rate
}
/// Compute predictive probability P(xₜ|rₜ, x₁:ₜ₋₁) using Student's t-distribution
///
/// # Arguments
///
/// * `value` - Observation value xₜ
/// * `run_length` - Run length r
///
/// # Returns
///
/// Predictive probability (likelihood of observation)
///
/// # Algorithm
///
/// Uses conjugate Gaussian model with Normal-Inverse-Gamma priors.
/// Predictive distribution is Student's t with parameters:
/// - Location: μᵣ (posterior mean)
/// - Scale: √(βᵣ(κᵣ+1)/(αᵣκᵣ)) (posterior variance)
/// - Degrees of freedom: 2α
fn compute_predictive_probability(&self, value: f64, run_length: usize) -> f64 {
// r=0 (changepoint hypothesis): always use the prior predictive distribution.
// In Adams & MacKay (2007), a changepoint means a NEW regime starts with
// no evidence — the prior parameters describe the new regime's distribution.
// Without this, the shared sufficient statistics at r=0 get contaminated
// by observations from the current regime, preventing detection.
if run_length == 0 {
let variance = self.prior_beta / self.prior_alpha;
return self.gaussian_pdf(value, self.prior_mean, variance);
}
let n = self.counts[run_length];
if n < 1e-10 {
let variance = self.prior_beta / self.prior_alpha;
return self.gaussian_pdf(value, self.prior_mean, variance);
}
// Compute posterior parameters
let mean = self.means[run_length];
let variance = self.variances[run_length];
// Posterior precision: κᵣ = κ₀ + n (accumulates confidence with observations)
let location = mean;
let kappa_r = self.prior_precision + n;
let scale_squared = variance * (kappa_r + 1.0) / kappa_r;
// Cap at 1.0: gaussian_pdf for narrow distributions can exceed 1.0 at the
// mode (it's a density, not a probability). In BOCD, growth factor =
// pred × (1H). When pred > 1, growth > 1H per step, causing exponential
// amplification of hazard-redistributed probability mass across ALL run
// lengths, drowning the changepoint signal. Capping at 1.0 ensures growth
// ≤ (1H) < 1, so hazard-redistributed mass decays geometrically.
let pred = if n > 10.0 {
self.gaussian_pdf(value, location, scale_squared)
} else {
let df = 2.0 * self.prior_alpha + n;
self.student_t_pdf(value, location, scale_squared, df)
};
pred.min(1.0)
}
/// Gaussian PDF: N(x|μ, σ²)
///
/// Uses 1e-300 floor (not 1e-10) to preserve relative probability ordering
/// between run lengths. A flat 1e-10 floor masks the discrimination between
/// established regimes (very low prob for outliers) and the prior (moderate
/// prob for any value).
fn gaussian_pdf(&self, x: f64, mean: f64, variance: f64) -> f64 {
if variance < 1e-10 {
return if (x - mean).abs() < 1e-6 { 1.0 } else { 1e-300 };
}
let diff = x - mean;
let exponent = -0.5 * diff * diff / variance;
let normalization = 1.0 / (2.0 * std::f64::consts::PI * variance).sqrt();
(normalization * exponent.exp()).max(1e-300)
}
/// Student's t-distribution PDF (approximation)
fn student_t_pdf(&self, x: f64, location: f64, scale: f64, df: f64) -> f64 {
if scale < 1e-10 {
return if (x - location).abs() < 1e-6 {
1.0
} else {
1e-300
};
}
// Simplified Student's t approximation (sufficient for BOCD)
let diff = (x - location) / scale.sqrt();
let factor = 1.0 + diff * diff / df;
let exponent = -(df + 1.0) / 2.0;
(factor.powf(exponent) / scale.sqrt()).max(1e-300)
}
/// Update sufficient statistics for Gaussian model (online updates)
///
/// # Arguments
///
/// * `value` - New observation
/// * `probs` - New run-length distribution
fn update_sufficient_statistics(&mut self, value: f64, probs: &[f64]) {
// Update statistics for each run length using online formulas
for r in 0..=self.max_run_length {
if probs[r] > 1e-10 {
let prev_count = self.counts[r];
let prev_mean = self.means[r];
// Update count (weighted by probability)
self.counts[r] = prev_count + probs[r];
// Update mean (online Welford's algorithm)
let delta = value - prev_mean;
self.means[r] = prev_mean + delta * probs[r] / self.counts[r];
// Update variance (online Welford's algorithm)
let delta2 = value - self.means[r];
self.variances[r] =
(prev_count * self.variances[r] + probs[r] * delta * delta2) / self.counts[r];
}
}
}
/// Reset detector to initial state
pub fn reset(&mut self) {
self.run_length_probs = vec![0.0; self.max_run_length + 1];
self.run_length_probs[0] = 1.0;
self.means = vec![self.prior_mean; self.max_run_length + 1];
self.variances = vec![1.0; self.max_run_length + 1];
self.counts = vec![0.0; self.max_run_length + 1];
self.time_index = 0;
}
}
// Tests are in ml/tests/bayesian_changepoint_test.rs