- 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>
418 lines
13 KiB
Rust
418 lines
13 KiB
Rust
//! 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");
|
||
}
|
||
}
|