Files
foxhunt/crates/ml-regime/src/multi_cusum.rs
jgrusewski b4178952d4 fix(ml): BF16/F32 boundary alignment, GPU-resident ops across all ML crates
- Cast input to weight dtype in DQN residual, rmsnorm, noisy_layers
- Set use_gpu=true in QNetworkConfig defaults and all config sites
- Resolve BF16 boundary mismatches in attention, curiosity, branching,
  distributional_dueling across ml-dqn
- GPU-resident regime ops with BF16 boundary casts, eliminate .expect() in CUDA paths
- Eliminate all Device::Cpu fallbacks — GPU-only across 10 ML crates
- PPO: cast logits to F32 before softmax, cast batch tensors to training dtype
- Gradient collapse detection for RegimeConditionalDQN
- Wire halt_grad_collapse from CUDA guard kernel to halt training
- Dead neuron detection uses active network VarMap + squeeze factored readback
- Increment gradient_logging_step in GPU PER path
- Gradient collapse warmup guards use original buffer_size
- Cap training steps per epoch + tracing migration
- Replace Tensor::all() with sum_all() for pinned Candle compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 11:59:31 +01:00

418 lines
13 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.
//! Multi-CUSUM Detector
//!
//! Implements parallel CUSUM monitoring across multiple features simultaneously:
//! - Independent CUSUM for each feature dimension (returns, volatility, volume)
//! - Combined detection with configurable modes (ANY, ALL, WEIGHTED_VOTE)
//! - Feature weighting based on importance
//!
//! Use case: Detect structural breaks by monitoring multiple market characteristics
//! simultaneously (e.g., sudden changes in returns + volatility + volume).
use super::cusum::{CUSUMDetector, StructuralBreak};
use serde::{Deserialize, Serialize};
use tracing::warn;
/// Configuration for a single CUSUM detector
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CUSUMConfig {
/// Detection threshold (h parameter, typically 4-5σ)
pub threshold: f64,
/// Baseline mean (μ parameter)
pub baseline_mean: f64,
/// Baseline standard deviation (σ parameter)
pub baseline_std: f64,
/// Minimum bars between detections (debounce)
pub min_bars_between: usize,
}
impl Default for CUSUMConfig {
fn default() -> Self {
Self {
threshold: 4.0,
baseline_mean: 0.0,
baseline_std: 1.0,
min_bars_between: 20,
}
}
}
/// Detection mode for multi-feature monitoring
/// Note: Cannot derive Eq because f64 doesn't implement Eq (floating point equality is non-transitive)
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[derive(Default)]
pub enum DetectionMode {
/// Any feature triggers detection
#[default]
Any,
/// All features must trigger detection
All,
/// Weighted vote exceeds threshold
WeightedVote { threshold: f64 },
}
/// Multi-feature breakpoint result
#[derive(Debug, Clone, PartialEq)]
pub struct MultiBreak {
/// Bar index where break was detected
pub bar_index: usize,
/// Features that triggered detection (feature index)
pub triggered_features: Vec<usize>,
/// Individual breakpoints for each triggered feature
pub breakpoints: Vec<StructuralBreak>,
/// Combined detection score (for weighted mode)
pub detection_score: f64,
}
/// Multi-CUSUM Detector for parallel feature monitoring
#[derive(Debug, Clone)]
pub struct MultiCUSUM {
/// Independent CUSUM detectors for each feature
cusum_detectors: Vec<CUSUMDetector>,
/// Feature importance weights (must sum to 1.0)
feature_weights: Vec<f64>,
/// Detection mode
detection_mode: DetectionMode,
/// Total bars processed
total_bars: usize,
/// Last detected multi-break (if any)
last_break: Option<MultiBreak>,
/// Configuration for each feature (for baseline updates)
configs: Vec<CUSUMConfig>,
}
impl MultiCUSUM {
/// Create a new Multi-CUSUM detector
///
/// # Arguments
/// * `feature_configs` - CUSUM configuration for each feature
/// * `feature_weights` - Importance weights for each feature (must sum to ~1.0)
/// * `detection_mode` - How to combine feature detections
///
/// # Returns
/// * `Ok(MultiCUSUM)` if configuration is valid
/// * `Err(String)` if validation fails
pub fn new(
feature_configs: Vec<CUSUMConfig>,
feature_weights: Vec<f64>,
detection_mode: DetectionMode,
) -> Result<Self, String> {
if feature_configs.is_empty() {
return Err("At least one feature required".to_owned());
}
if feature_configs.len() != feature_weights.len() {
return Err(format!(
"Feature count mismatch: {} configs vs {} weights",
feature_configs.len(),
feature_weights.len()
));
}
// Validate weights sum to ~1.0 (within tolerance)
let weight_sum: f64 = feature_weights.iter().sum();
if (weight_sum - 1.0).abs() > 0.01 {
return Err(format!(
"Feature weights must sum to 1.0 (got {})",
weight_sum
));
}
// Validate all weights are non-negative
if feature_weights.iter().any(|&w| w < 0.0) {
return Err("Feature weights must be non-negative".to_owned());
}
// Create independent CUSUM detectors
// drift_allowance = 0.5 (standard value)
let cusum_detectors = feature_configs
.iter()
.map(|config| {
CUSUMDetector::new(
config.baseline_mean,
config.baseline_std,
0.5, // drift_allowance (k parameter)
config.threshold,
)
})
.collect();
Ok(Self {
cusum_detectors,
feature_weights,
detection_mode,
total_bars: 0,
last_break: None,
configs: feature_configs,
})
}
/// Update all CUSUM detectors with new feature values
///
/// # Arguments
/// * `features` - Feature values for current bar (must match detector count)
/// * `bar_index` - Current bar index for tracking
///
/// # Returns
/// * `Some(MultiBreak)` if structural break detected (per detection mode)
/// * `None` otherwise
pub fn update(&mut self, features: &[f64], bar_index: usize) -> Option<MultiBreak> {
if features.len() != self.cusum_detectors.len() {
warn!(
expected = self.cusum_detectors.len(),
got = features.len(),
"Feature count mismatch"
);
return None;
}
self.total_bars += 1;
// Update all detectors and collect triggered features
let mut triggered_features = Vec::new();
let mut breakpoints = Vec::new();
for (i, (detector, &value)) in self
.cusum_detectors
.iter_mut()
.zip(features.iter())
.enumerate()
{
if let Some(break_point) = detector.update(value) {
// Respect min_bars_between debounce
if detector.observations_since_reset() >= self.configs[i].min_bars_between {
triggered_features.push(i);
breakpoints.push(break_point);
detector.reset(); // Reset after detection
}
}
}
// Apply detection logic based on mode
let detection_result = match self.detection_mode {
DetectionMode::Any => {
(!triggered_features.is_empty()).then_some(1.0) // Score = 1.0 for ANY mode
},
DetectionMode::All => {
(triggered_features.len() == self.cusum_detectors.len()).then_some(1.0) // Score = 1.0 for ALL mode
},
DetectionMode::WeightedVote { threshold } => {
// Calculate weighted score
let score: f64 = triggered_features
.iter()
.map(|&i| self.feature_weights[i])
.sum();
(score >= threshold).then_some(score)
},
};
if let Some(score) = detection_result {
let multi_break = MultiBreak {
bar_index,
triggered_features: triggered_features.clone(),
breakpoints,
detection_score: score,
};
self.last_break = Some(multi_break.clone());
Some(multi_break)
} else {
None
}
}
/// Get status of all CUSUM detectors
pub fn get_feature_statuses(&self) -> Vec<CUSUMStatus> {
self.cusum_detectors
.iter()
.enumerate()
.map(|(i, detector)| {
let (cumsum_pos, cumsum_neg) = detector.get_current_sums();
CUSUMStatus {
cumsum_pos,
cumsum_neg,
bars_since_reset: detector.observations_since_reset(),
total_bars: self.total_bars,
last_break: None, // Not tracking individual breaks
feature_index: i,
}
})
.collect()
}
/// Get last detected multi-break (if any)
pub fn last_break(&self) -> Option<&MultiBreak> {
self.last_break.as_ref()
}
/// Get number of features being monitored
pub fn feature_count(&self) -> usize {
self.cusum_detectors.len()
}
/// Get feature weights
pub fn feature_weights(&self) -> &[f64] {
&self.feature_weights
}
/// Get detection mode
pub fn detection_mode(&self) -> DetectionMode {
self.detection_mode
}
/// Get total bars processed
pub fn total_bars(&self) -> usize {
self.total_bars
}
/// Update baseline parameters for a specific feature
pub fn update_feature_baseline(&mut self, feature_index: usize, mean: f64, std: f64) {
if feature_index < self.configs.len() {
self.configs[feature_index].baseline_mean = mean;
self.configs[feature_index].baseline_std = std;
// Recreate the detector with new baseline
self.cusum_detectors[feature_index] = CUSUMDetector::new(
mean,
std,
0.5, // drift_allowance
self.configs[feature_index].threshold,
);
}
}
}
/// CUSUM detector status for multi-feature monitoring
#[derive(Debug, Clone)]
pub struct CUSUMStatus {
/// Current positive cumulative sum
pub cumsum_pos: f64,
/// Current negative cumulative sum
pub cumsum_neg: f64,
/// Bars since last reset
pub bars_since_reset: usize,
/// Total bars processed
pub total_bars: usize,
/// Last detected breakpoint (if any)
pub last_break: Option<StructuralBreak>,
/// Feature index
pub feature_index: usize,
}
#[cfg(test)]
#[allow(clippy::assertions_on_result_states, clippy::redundant_clone)]
mod tests {
use super::*;
fn create_test_configs(n_features: usize) -> Vec<CUSUMConfig> {
(0..n_features)
.map(|i| CUSUMConfig {
threshold: 3.0 + i as f64 * 0.5,
baseline_mean: 0.0,
baseline_std: 0.01,
min_bars_between: 10,
})
.collect()
}
#[test]
fn test_multi_cusum_creation() {
let configs = create_test_configs(3);
let weights = vec![0.5, 0.3, 0.2];
let detector = MultiCUSUM::new(configs, weights, DetectionMode::Any);
assert!(detector.is_ok());
let detector = detector.unwrap();
assert_eq!(detector.feature_count(), 3);
}
#[test]
fn test_multi_cusum_weight_validation() {
let configs = create_test_configs(3);
// Weights don't sum to 1.0
let bad_weights = vec![0.3, 0.3, 0.3];
let result = MultiCUSUM::new(configs.clone(), bad_weights, DetectionMode::Any);
assert!(result.is_err());
// Negative weights
let bad_weights = vec![0.5, 0.6, -0.1];
let result = MultiCUSUM::new(configs.clone(), bad_weights, DetectionMode::Any);
assert!(result.is_err());
}
#[test]
fn test_detection_mode_any() {
let configs = create_test_configs(3);
let weights = vec![0.5, 0.3, 0.2];
let mut detector = MultiCUSUM::new(configs, weights, DetectionMode::Any).unwrap();
// Stable data
for i in 0..50 {
let features = vec![0.0, 0.0, 0.0];
assert!(detector.update(&features, i).is_none());
}
// Trigger first feature only
let mut detected = false;
for i in 50..100 {
let features = vec![0.05, 0.0, 0.0]; // First feature breaks
if let Some(multi_break) = detector.update(&features, i) {
assert_eq!(multi_break.triggered_features.len(), 1);
assert_eq!(multi_break.triggered_features[0], 0);
detected = true;
break;
}
}
assert!(detected, "ANY mode should detect when one feature triggers");
}
#[test]
fn test_detection_mode_weighted_vote() {
let configs = create_test_configs(3);
let weights = vec![0.5, 0.3, 0.2];
let mode = DetectionMode::WeightedVote { threshold: 0.6 };
let mut detector = MultiCUSUM::new(configs, weights, mode).unwrap();
// Stable data
for i in 0..50 {
let features = vec![0.0, 0.0, 0.0];
assert!(detector.update(&features, i).is_none());
}
// Trigger only volume (weight=0.2, below 0.6 threshold)
for i in 50..80 {
let features = vec![0.0, 0.0, 0.05];
assert!(
detector.update(&features, i).is_none(),
"Should not detect when score < threshold"
);
}
// Trigger returns + volatility (0.5 + 0.3 = 0.8 > 0.6)
let mut detected = false;
for i in 80..150 {
let features = vec![0.05, 0.05, 0.0];
if let Some(multi_break) = detector.update(&features, i) {
assert!(multi_break.detection_score >= 0.6);
detected = true;
break;
}
}
assert!(
detected,
"Weighted vote should detect when score >= threshold"
);
}
#[test]
fn test_empty_features() {
let configs = Vec::new();
let weights = Vec::new();
let result = MultiCUSUM::new(configs, weights, DetectionMode::Any);
assert!(result.is_err(), "Should reject empty feature list");
}
}