Skip to main content

trustformers_debug/gradient_debugger/
anomaly_detection.rs

1//! Advanced Gradient Anomaly Detection System
2//!
3//! This module provides sophisticated anomaly detection capabilities for gradient
4//! analysis, including baseline establishment, pattern recognition, and contextual
5//! anomaly classification.
6
7use crate::anomaly_detector::{Anomaly, AnomalySeverity};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, VecDeque};
11
12/// Advanced gradient anomaly detection
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct GradientAnomalyDetector {
15    pub enabled: bool,
16    pub sensitivity: f64,
17    pub detection_window: usize,
18    pub anomaly_history: VecDeque<GradientAnomaly>,
19    pub baseline_statistics: HashMap<String, BaselineGradientStats>,
20}
21
22impl Default for GradientAnomalyDetector {
23    fn default() -> Self {
24        Self {
25            enabled: true,
26            sensitivity: 0.8,
27            detection_window: 50,
28            anomaly_history: VecDeque::with_capacity(1000),
29            baseline_statistics: HashMap::new(),
30        }
31    }
32}
33
34impl GradientAnomalyDetector {
35    pub fn new(sensitivity: f64, window_size: usize) -> Self {
36        Self {
37            enabled: true,
38            sensitivity,
39            detection_window: window_size,
40            anomaly_history: VecDeque::with_capacity(1000),
41            baseline_statistics: HashMap::new(),
42        }
43    }
44
45    pub fn establish_baseline(&mut self, layer_name: &str, gradient_history: &[f64]) {
46        if gradient_history.len() < 10 {
47            return;
48        }
49
50        let mean = gradient_history.iter().sum::<f64>() / gradient_history.len() as f64;
51        let variance = gradient_history.iter().map(|&x| (x - mean).powi(2)).sum::<f64>()
52            / gradient_history.len() as f64;
53        let std = variance.sqrt();
54
55        let mut sorted_values = gradient_history.to_vec();
56        sorted_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
57
58        let median_idx = sorted_values.len() / 2;
59        let median = if sorted_values.len().is_multiple_of(2) {
60            (sorted_values[median_idx - 1] + sorted_values[median_idx]) / 2.0
61        } else {
62            sorted_values[median_idx]
63        };
64
65        let percentile_5_idx = (sorted_values.len() as f64 * 0.05) as usize;
66        let percentile_95_idx = (sorted_values.len() as f64 * 0.95) as usize;
67
68        let baseline = BaselineGradientStats {
69            mean,
70            std,
71            median,
72            percentile_95: sorted_values[percentile_95_idx.min(sorted_values.len() - 1)],
73            percentile_5: sorted_values[percentile_5_idx],
74            samples: gradient_history.len(),
75        };
76
77        self.baseline_statistics.insert(layer_name.to_string(), baseline);
78    }
79
80    pub fn detect_anomalies(
81        &mut self,
82        layer_name: &str,
83        gradient_norm: f64,
84        step: usize,
85    ) -> Vec<GradientAnomaly> {
86        if !self.enabled {
87            return Vec::new();
88        }
89
90        let baseline = match self.baseline_statistics.get(layer_name) {
91            Some(baseline) => baseline,
92            None => return Vec::new(), // No baseline established yet
93        };
94
95        let mut anomalies = Vec::new();
96
97        // Statistical anomaly detection
98        if let Some(anomaly) =
99            self.detect_statistical_anomaly(layer_name, gradient_norm, step, baseline)
100        {
101            anomalies.push(anomaly);
102        }
103
104        // Pattern-based anomaly detection
105        if let Some(anomaly) = self.detect_pattern_anomaly(layer_name, gradient_norm, step) {
106            anomalies.push(anomaly);
107        }
108
109        // Add to history
110        for anomaly in &anomalies {
111            if self.anomaly_history.len() >= 1000 {
112                self.anomaly_history.pop_front();
113            }
114            self.anomaly_history.push_back(anomaly.clone());
115        }
116
117        anomalies
118    }
119
120    fn detect_statistical_anomaly(
121        &self,
122        layer_name: &str,
123        gradient_norm: f64,
124        step: usize,
125        baseline: &BaselineGradientStats,
126    ) -> Option<GradientAnomaly> {
127        let z_score = (gradient_norm - baseline.mean) / baseline.std;
128        let threshold = 2.0 + (1.0 - self.sensitivity) * 2.0; // Threshold between 2-4 based on sensitivity
129
130        if z_score.abs() > threshold {
131            let anomaly_type = if z_score > 0.0 {
132                if z_score > threshold * 1.5 {
133                    AnomalyType::SuddenSpike
134                } else {
135                    AnomalyType::SuddenSpike
136                }
137            } else {
138                AnomalyType::SuddenDrop
139            };
140
141            let severity = (z_score.abs() / threshold).min(1.0);
142
143            Some(GradientAnomaly {
144                layer_name: layer_name.to_string(),
145                anomaly_type,
146                severity,
147                timestamp: Utc::now(),
148                context: AnomalyContext {
149                    step,
150                    gradient_norm,
151                    expected_range: (baseline.percentile_5, baseline.percentile_95),
152                    deviation_magnitude: z_score.abs(),
153                },
154            })
155        } else {
156            None
157        }
158    }
159
160    /// Real "expected range" for `layer_name`'s gradient norm, for the
161    /// [`AnomalyContext`] attached to pattern-based (as opposed to
162    /// single-sample statistical) anomalies. [`Self::detect_anomalies`]
163    /// only ever calls [`Self::detect_pattern_anomaly`] after already
164    /// confirming a baseline exists for `layer_name` (it early-returns
165    /// otherwise), so the same real
166    /// `(percentile_5, percentile_95)` used by
167    /// [`Self::detect_statistical_anomaly`] is available here too --
168    /// reusing it keeps both anomaly kinds reporting the SAME real
169    /// "normal" band for a layer, rather than one carrying a measured
170    /// range and the other a fabricated `(0.0, 1.0)` regardless of the
171    /// layer's actual gradient scale (which is very often << 1.0 or >>
172    /// 1.0). The `None` branch is defensive only -- reachable if this
173    /// method is ever called directly without going through
174    /// `detect_anomalies`'s baseline check -- and falls back to the real
175    /// observed min/max gradient norm across the recent pattern window
176    /// (still genuine data, never an invented constant).
177    fn expected_range_for(
178        &self,
179        layer_name: &str,
180        recent_anomalies: &[&GradientAnomaly],
181    ) -> (f64, f64) {
182        if let Some(baseline) = self.baseline_statistics.get(layer_name) {
183            return (baseline.percentile_5, baseline.percentile_95);
184        }
185        let norms: Vec<f64> = recent_anomalies.iter().map(|a| a.context.gradient_norm).collect();
186        match (
187            norms.iter().cloned().fold(f64::INFINITY, f64::min),
188            norms.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
189        ) {
190            (lo, hi) if lo.is_finite() && hi.is_finite() => (lo, hi),
191            _ => (0.0, 0.0),
192        }
193    }
194
195    fn detect_pattern_anomaly(
196        &self,
197        layer_name: &str,
198        gradient_norm: f64,
199        step: usize,
200    ) -> Option<GradientAnomaly> {
201        // Look for patterns in recent anomaly history for this layer
202        let recent_anomalies: Vec<&GradientAnomaly> = self
203            .anomaly_history
204            .iter()
205            .filter(|a| a.layer_name == layer_name)
206            .rev()
207            .take(10)
208            .collect();
209
210        if recent_anomalies.len() >= 3 {
211            // Check for oscillation pattern
212            let oscillation_count = recent_anomalies
213                .windows(2)
214                .filter(|pair| {
215                    matches!(
216                        (&pair[0].anomaly_type, &pair[1].anomaly_type),
217                        (AnomalyType::SuddenSpike, AnomalyType::SuddenDrop)
218                            | (AnomalyType::SuddenDrop, AnomalyType::SuddenSpike)
219                    )
220                })
221                .count();
222
223            if oscillation_count >= 2 {
224                return Some(GradientAnomaly {
225                    layer_name: layer_name.to_string(),
226                    anomaly_type: AnomalyType::Oscillation,
227                    severity: 0.7,
228                    timestamp: Utc::now(),
229                    context: AnomalyContext {
230                        step,
231                        gradient_norm,
232                        expected_range: self.expected_range_for(layer_name, &recent_anomalies),
233                        deviation_magnitude: oscillation_count as f64,
234                    },
235                });
236            }
237        }
238
239        // Check for stagnation
240        if recent_anomalies.len() >= 5 {
241            let all_similar = recent_anomalies.windows(2).all(|pair| {
242                (pair[0].context.gradient_norm - pair[1].context.gradient_norm).abs() < 1e-6
243            });
244
245            if all_similar {
246                return Some(GradientAnomaly {
247                    layer_name: layer_name.to_string(),
248                    anomaly_type: AnomalyType::Stagnation,
249                    severity: 0.8,
250                    timestamp: Utc::now(),
251                    context: AnomalyContext {
252                        step,
253                        gradient_norm,
254                        expected_range: self.expected_range_for(layer_name, &recent_anomalies),
255                        deviation_magnitude: 0.0,
256                    },
257                });
258            }
259        }
260
261        None
262    }
263
264    pub fn get_anomaly_summary(&self, layer_name: Option<&str>) -> AnomalySummary {
265        let filtered_anomalies: Vec<&GradientAnomaly> = match layer_name {
266            Some(name) => self.anomaly_history.iter().filter(|a| a.layer_name == name).collect(),
267            None => self.anomaly_history.iter().collect(),
268        };
269
270        let total_anomalies = filtered_anomalies.len();
271        let mut anomaly_type_counts = HashMap::new();
272        let mut severity_sum = 0.0;
273
274        for anomaly in &filtered_anomalies {
275            *anomaly_type_counts.entry(anomaly.anomaly_type.clone()).or_insert(0) += 1;
276            severity_sum += anomaly.severity;
277        }
278
279        let average_severity =
280            if total_anomalies > 0 { severity_sum / total_anomalies as f64 } else { 0.0 };
281
282        // Convert GradientAnomaly to Anomaly objects
283        let anomalies: Vec<Anomaly> = filtered_anomalies
284            .iter()
285            .map(|gradient_anomaly| {
286                let severity = if gradient_anomaly.severity >= 0.8 {
287                    AnomalySeverity::Critical
288                } else if gradient_anomaly.severity >= 0.6 {
289                    AnomalySeverity::High
290                } else if gradient_anomaly.severity >= 0.3 {
291                    AnomalySeverity::Medium
292                } else {
293                    AnomalySeverity::Low
294                };
295
296                // Convert gradient-specific anomaly type to general anomaly type
297                let general_anomaly_type = match gradient_anomaly.anomaly_type {
298                    AnomalyType::SuddenSpike => {
299                        crate::anomaly_detector::AnomalyType::GradientExplosion
300                    },
301                    AnomalyType::SuddenDrop => {
302                        crate::anomaly_detector::AnomalyType::GradientVanishing
303                    },
304                    AnomalyType::Oscillation => {
305                        crate::anomaly_detector::AnomalyType::NumericalInstability
306                    },
307                    AnomalyType::Stagnation => {
308                        crate::anomaly_detector::AnomalyType::GradientVanishing
309                    },
310                    AnomalyType::Chaos => {
311                        crate::anomaly_detector::AnomalyType::NumericalInstability
312                    },
313                };
314
315                let description = format!(
316                    "Gradient anomaly of type {:?} detected with severity {:.2}",
317                    gradient_anomaly.anomaly_type, gradient_anomaly.severity
318                );
319
320                let mut metadata = HashMap::new();
321                metadata.insert(
322                    "step".to_string(),
323                    gradient_anomaly.context.step.to_string(),
324                );
325                metadata.insert(
326                    "gradient_norm".to_string(),
327                    gradient_anomaly.context.gradient_norm.to_string(),
328                );
329                metadata.insert(
330                    "expected_range_min".to_string(),
331                    gradient_anomaly.context.expected_range.0.to_string(),
332                );
333                metadata.insert(
334                    "expected_range_max".to_string(),
335                    gradient_anomaly.context.expected_range.1.to_string(),
336                );
337                metadata.insert(
338                    "deviation_magnitude".to_string(),
339                    gradient_anomaly.context.deviation_magnitude.to_string(),
340                );
341                metadata.insert(
342                    "original_anomaly_type".to_string(),
343                    format!("{:?}", gradient_anomaly.anomaly_type),
344                );
345
346                Anomaly {
347                    anomaly_type: general_anomaly_type,
348                    timestamp: gradient_anomaly.timestamp,
349                    location: gradient_anomaly.layer_name.clone(),
350                    description,
351                    severity,
352                    metadata,
353                }
354            })
355            .collect();
356
357        AnomalySummary {
358            layer_name: layer_name.map(|s| s.to_string()),
359            total_anomalies,
360            anomaly_type_counts,
361            average_severity,
362            recent_trend: self.analyze_recent_trend(&filtered_anomalies),
363            recommendations: self.generate_anomaly_recommendations(&filtered_anomalies),
364            anomalies,
365        }
366    }
367
368    fn analyze_recent_trend(&self, anomalies: &[&GradientAnomaly]) -> AnomalyTrend {
369        if anomalies.len() < 5 {
370            return AnomalyTrend::Stable;
371        }
372
373        let recent_anomalies: Vec<&GradientAnomaly> =
374            anomalies.iter().rev().take(10).cloned().collect();
375        let older_anomalies: Vec<&GradientAnomaly> =
376            anomalies.iter().rev().skip(10).take(10).cloned().collect();
377
378        if older_anomalies.is_empty() {
379            return AnomalyTrend::Stable;
380        }
381
382        let recent_avg_severity: f64 = recent_anomalies.iter().map(|a| a.severity).sum::<f64>()
383            / recent_anomalies.len() as f64;
384        let older_avg_severity: f64 =
385            older_anomalies.iter().map(|a| a.severity).sum::<f64>() / older_anomalies.len() as f64;
386
387        let trend_threshold = 0.1;
388        if recent_avg_severity > older_avg_severity + trend_threshold {
389            AnomalyTrend::Increasing
390        } else if recent_avg_severity < older_avg_severity - trend_threshold {
391            AnomalyTrend::Decreasing
392        } else {
393            AnomalyTrend::Stable
394        }
395    }
396
397    fn generate_anomaly_recommendations(&self, anomalies: &[&GradientAnomaly]) -> Vec<String> {
398        let mut recommendations = Vec::new();
399
400        let spike_count = anomalies
401            .iter()
402            .filter(|a| matches!(a.anomaly_type, AnomalyType::SuddenSpike))
403            .count();
404        let drop_count = anomalies
405            .iter()
406            .filter(|a| matches!(a.anomaly_type, AnomalyType::SuddenDrop))
407            .count();
408        let oscillation_count = anomalies
409            .iter()
410            .filter(|a| matches!(a.anomaly_type, AnomalyType::Oscillation))
411            .count();
412        let stagnation_count = anomalies
413            .iter()
414            .filter(|a| matches!(a.anomaly_type, AnomalyType::Stagnation))
415            .count();
416
417        if spike_count > 3 {
418            recommendations
419                .push("Consider reducing learning rate to prevent gradient explosion".to_string());
420            recommendations.push("Add gradient clipping to stabilize training".to_string());
421        }
422
423        if drop_count > 3 {
424            recommendations.push("Check for vanishing gradient issues".to_string());
425            recommendations
426                .push("Consider using residual connections or better initialization".to_string());
427        }
428
429        if oscillation_count > 2 {
430            recommendations.push("Reduce learning rate to dampen oscillations".to_string());
431            recommendations
432                .push("Consider using momentum or adaptive learning rate methods".to_string());
433        }
434
435        if stagnation_count > 2 {
436            recommendations.push(
437                "Learning may have plateaued - consider learning rate scheduling".to_string(),
438            );
439            recommendations
440                .push("Check for potential convergence or training data issues".to_string());
441        }
442
443        if recommendations.is_empty() {
444            recommendations.push("Gradient behavior appears normal".to_string());
445        }
446
447        recommendations
448    }
449}
450
451/// Gradient anomaly event
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct GradientAnomaly {
454    pub layer_name: String,
455    pub anomaly_type: AnomalyType,
456    pub severity: f64,
457    pub timestamp: DateTime<Utc>,
458    pub context: AnomalyContext,
459}
460
461/// Types of gradient anomalies
462#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
463pub enum AnomalyType {
464    SuddenSpike,
465    SuddenDrop,
466    Oscillation,
467    Stagnation,
468    Chaos,
469}
470
471/// Context information for anomalies
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct AnomalyContext {
474    pub step: usize,
475    pub gradient_norm: f64,
476    pub expected_range: (f64, f64),
477    pub deviation_magnitude: f64,
478}
479
480/// Baseline statistics for anomaly detection
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct BaselineGradientStats {
483    pub mean: f64,
484    pub std: f64,
485    pub median: f64,
486    pub percentile_95: f64,
487    pub percentile_5: f64,
488    pub samples: usize,
489}
490
491/// Summary of anomaly detection results
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct AnomalySummary {
494    pub layer_name: Option<String>,
495    pub total_anomalies: usize,
496    pub anomaly_type_counts: HashMap<AnomalyType, usize>,
497    pub average_severity: f64,
498    pub recent_trend: AnomalyTrend,
499    pub recommendations: Vec<String>,
500    pub anomalies: Vec<Anomaly>,
501}
502
503/// Trend in anomaly occurrence
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub enum AnomalyTrend {
506    Increasing,
507    Stable,
508    Decreasing,
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn make_anomaly(layer: &str, anomaly_type: AnomalyType, norm: f64) -> GradientAnomaly {
516        GradientAnomaly {
517            layer_name: layer.to_string(),
518            anomaly_type,
519            severity: 0.5,
520            timestamp: Utc::now(),
521            context: AnomalyContext {
522                step: 0,
523                gradient_norm: norm,
524                expected_range: (0.0, 0.0),
525                deviation_magnitude: 0.0,
526            },
527        }
528    }
529
530    #[test]
531    fn test_oscillation_uses_real_baseline_expected_range_not_placeholder() {
532        let mut detector = GradientAnomalyDetector::new(0.8, 50);
533        // A baseline whose real "normal" band is nowhere near (0.0, 1.0) --
534        // exactly the case the old hardcoded placeholder always got wrong.
535        let history: Vec<f64> = (0..20).map(|i| 100.0 + (i as f64 % 3.0)).collect();
536        detector.establish_baseline("layer0", &history);
537        let baseline = detector.baseline_statistics.get("layer0").cloned().expect("established");
538
539        // Seed a Spike/Drop/Spike history (push order == chronological
540        // order, oldest first) so `detect_pattern_anomaly` sees 2 real
541        // oscillation transitions.
542        detector
543            .anomaly_history
544            .push_back(make_anomaly("layer0", AnomalyType::SuddenSpike, 500.0));
545        detector
546            .anomaly_history
547            .push_back(make_anomaly("layer0", AnomalyType::SuddenDrop, 1.0));
548        detector
549            .anomaly_history
550            .push_back(make_anomaly("layer0", AnomalyType::SuddenSpike, 500.0));
551
552        let anomaly = detector
553            .detect_pattern_anomaly("layer0", 500.0, 99)
554            .expect("a 3-entry Spike/Drop/Spike history must trigger an oscillation anomaly");
555        assert!(matches!(anomaly.anomaly_type, AnomalyType::Oscillation));
556        assert_eq!(
557            anomaly.context.expected_range,
558            (baseline.percentile_5, baseline.percentile_95),
559            "expected_range must be the REAL baseline band, not the old hardcoded (0.0, 1.0)"
560        );
561        assert_ne!(anomaly.context.expected_range, (0.0, 1.0));
562    }
563
564    #[test]
565    fn test_stagnation_uses_real_baseline_expected_range_not_placeholder() {
566        let mut detector = GradientAnomalyDetector::new(0.8, 50);
567        let history: Vec<f64> = (0..20).map(|i| 100.0 + (i as f64 % 3.0)).collect();
568        detector.establish_baseline("layer0", &history);
569        let baseline = detector.baseline_statistics.get("layer0").cloned().expect("established");
570
571        // 5 near-identical recent gradient norms -> real stagnation
572        // pattern (all SuddenDrop, so no oscillation transitions fire
573        // first).
574        for _ in 0..5 {
575            detector.anomaly_history.push_back(make_anomaly(
576                "layer0",
577                AnomalyType::SuddenDrop,
578                42.0,
579            ));
580        }
581
582        let anomaly = detector
583            .detect_pattern_anomaly("layer0", 42.0, 99)
584            .expect("5 near-identical recent norms must trigger a stagnation anomaly");
585        assert!(matches!(anomaly.anomaly_type, AnomalyType::Stagnation));
586        assert_eq!(
587            anomaly.context.expected_range,
588            (baseline.percentile_5, baseline.percentile_95),
589            "expected_range must be the REAL baseline band, not the old hardcoded (0.0, 1.0)"
590        );
591        assert_ne!(anomaly.context.expected_range, (0.0, 1.0));
592    }
593
594    #[test]
595    fn test_expected_range_for_falls_back_to_observed_bounds_without_baseline() {
596        // Defensive fallback path: no baseline established for this layer
597        // (only reachable if `expected_range_for` were ever called outside
598        // `detect_anomalies`'s own baseline gate). Must still be real
599        // data -- the min/max of what was actually observed -- never a
600        // constant.
601        let detector = GradientAnomalyDetector::new(0.8, 50);
602        let a1 = make_anomaly("orphan", AnomalyType::SuddenSpike, 5.0);
603        let a2 = make_anomaly("orphan", AnomalyType::SuddenDrop, 1.0);
604        let range = detector.expected_range_for("orphan", &[&a1, &a2]);
605        assert_eq!(range, (1.0, 5.0));
606    }
607
608    #[test]
609    fn test_detect_anomalies_end_to_end_publishes_real_expected_range() {
610        // Full public-API path (not the private helpers directly): builds
611        // up real oscillation history purely through repeated
612        // `detect_anomalies` calls, the way a real caller would.
613        let mut detector = GradientAnomalyDetector::new(0.8, 50);
614        let history: Vec<f64> = (0..20).map(|i| 10.0 + (i as f64 % 2.0)).collect();
615        detector.establish_baseline("layer0", &history);
616        let baseline = detector.baseline_statistics.get("layer0").cloned().expect("established");
617
618        let mut last_oscillation = None;
619        for (step, norm) in [500.0, 0.001, 500.0, 0.001].into_iter().enumerate() {
620            for anomaly in detector.detect_anomalies("layer0", norm, step) {
621                if matches!(anomaly.anomaly_type, AnomalyType::Oscillation) {
622                    last_oscillation = Some(anomaly);
623                }
624            }
625        }
626
627        let oscillation = last_oscillation.expect(
628            "alternating far-above/far-below-baseline norms must \
629                 eventually trigger a real oscillation anomaly through the public API",
630        );
631        assert_eq!(
632            oscillation.context.expected_range,
633            (baseline.percentile_5, baseline.percentile_95)
634        );
635        assert_ne!(oscillation.context.expected_range, (0.0, 1.0));
636    }
637}