optirs_core/streaming/adaptive_streaming/
anomaly_ensemble.rs1use super::anomaly_detection::{AnomalyDetectionResult, AnomalySeverity, AnomalyType};
10use super::anomaly_scoring::DetectionCounters;
11
12use crate::utils::try_scalar_str;
13use scirs2_core::numeric::Float;
14use std::collections::HashMap;
15
16pub struct EnsembleAnomalyDetector<A: Float + Send + Sync> {
18 voting_strategy: EnsembleVotingStrategy,
20 detector_weights: HashMap<String, A>,
25 detector_counters: HashMap<String, DetectionCounters>,
35 pending_verdicts: HashMap<String, bool>,
38 ensemble_config: EnsembleConfig<A>,
40}
41
42#[derive(Debug, Clone)]
44pub enum EnsembleVotingStrategy {
45 Majority,
47 Weighted,
49 MaxScore,
51 AverageScore,
53 MedianScore,
55 Adaptive,
58 Stacking,
60}
61
62#[derive(Debug, Clone)]
64pub struct EnsembleConfig<A: Float + Send + Sync> {
65 pub min_consensus: usize,
67 pub ensemble_threshold: A,
69 pub dynamic_weighting: bool,
71 pub evaluation_window: usize,
73 pub context_based_selection: bool,
75}
76
77impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> EnsembleAnomalyDetector<A> {
78 pub(super) fn new(voting_strategy: EnsembleVotingStrategy) -> Result<Self, String> {
79 Ok(Self {
80 voting_strategy,
81 detector_weights: HashMap::new(),
82 detector_counters: HashMap::new(),
83 pending_verdicts: HashMap::new(),
84 ensemble_config: EnsembleConfig {
85 min_consensus: 2,
86 ensemble_threshold: try_scalar_str::<A, _>(0.5)?,
87 dynamic_weighting: true,
88 evaluation_window: 100,
89 context_based_selection: false,
90 },
91 })
92 }
93
94 pub(super) fn set_voting_strategy(&mut self, strategy: EnsembleVotingStrategy) {
96 self.voting_strategy = strategy;
97 }
98
99 pub fn voting_strategy(&self) -> &EnsembleVotingStrategy {
101 &self.voting_strategy
102 }
103
104 pub(super) fn set_detector_weight(&mut self, detector_name: &str, weight: A) {
106 self.detector_weights
107 .insert(detector_name.to_string(), weight);
108 }
109
110 pub(super) fn record_outcome(&mut self, was_true_anomaly: bool) {
117 for (name, verdict) in self.pending_verdicts.drain() {
118 self.detector_counters
119 .entry(name)
120 .or_default()
121 .record_outcome(verdict, was_true_anomaly);
122 }
123 }
124
125 pub fn detector_balanced_accuracy(&self, detector_name: &str) -> Option<f64> {
128 self.detector_counters
129 .get(detector_name)?
130 .balanced_accuracy()
131 }
132
133 pub fn measured_weights(&self) -> Option<HashMap<String, A>> {
140 let mut weights = HashMap::new();
141 let mut measured_any = false;
142 for (name, counters) in &self.detector_counters {
143 if let Some(balanced) = counters.balanced_accuracy() {
144 measured_any = true;
145 if let Some(weight) = A::from(balanced) {
146 weights.insert(name.clone(), weight);
147 }
148 }
149 }
150 measured_any.then_some(weights)
151 }
152
153 fn weighted_vote(
159 results: &HashMap<String, AnomalyDetectionResult<A>>,
160 weight_of: &dyn Fn(&str) -> A,
161 ) -> Result<(bool, A), String> {
162 let mut weight_sum = A::zero();
163 let mut weighted_score = A::zero();
164 let mut weighted_votes = A::zero();
165 for (name, result) in results {
166 let weight = weight_of(name);
167 weight_sum = weight_sum + weight;
168 weighted_score = weighted_score + weight * result.anomaly_score;
169 if result.is_anomaly {
170 weighted_votes = weighted_votes + weight;
171 }
172 }
173 if weight_sum <= A::zero() {
174 return Err(
175 "weighted ensemble voting needs a positive total detector weight".to_string(),
176 );
177 }
178 let score = weighted_score / weight_sum;
179 let vote_share = weighted_votes / weight_sum;
180 Ok((vote_share > try_scalar_str::<A, _>(0.5)?, score))
181 }
182
183 pub(super) fn combine_results(
184 &mut self,
185 results: HashMap<String, AnomalyDetectionResult<A>>,
186 ) -> Result<AnomalyDetectionResult<A>, String> {
187 if results.is_empty() {
188 return Ok(AnomalyDetectionResult {
189 is_anomaly: false,
190 anomaly_score: A::zero(),
191 confidence: A::zero(),
192 anomaly_type: None,
193 severity: AnomalySeverity::Low,
194 metadata: HashMap::new(),
195 });
196 }
197
198 self.pending_verdicts.clear();
202 for (name, result) in &results {
203 self.detector_counters
204 .entry(name.clone())
205 .or_default()
206 .record_prediction(result.is_anomaly, result.anomaly_score);
207 self.pending_verdicts
208 .insert(name.clone(), result.is_anomaly);
209 }
210
211 let anomaly_count = results.values().filter(|r| r.is_anomaly).count();
212 let total_count = results.len();
213
214 let avg_score = results.values().map(|r| r.anomaly_score).sum::<A>()
215 / try_scalar_str::<A, _>(total_count)?;
216 let avg_confidence = results.values().map(|r| r.confidence).sum::<A>()
217 / try_scalar_str::<A, _>(total_count)?;
218
219 let threshold = self.ensemble_config.ensemble_threshold;
226 let (is_anomaly, ensemble_score) = match self.voting_strategy {
227 EnsembleVotingStrategy::Majority => (anomaly_count > total_count / 2, avg_score),
228 EnsembleVotingStrategy::MaxScore => {
229 let max_score = results
230 .values()
231 .map(|r| r.anomaly_score)
232 .fold(A::zero(), |acc, s| if s > acc { s } else { acc });
233 (max_score > threshold, max_score)
234 }
235 EnsembleVotingStrategy::AverageScore => (avg_score > threshold, avg_score),
236 EnsembleVotingStrategy::MedianScore => {
237 let mut scores: Vec<A> = results.values().map(|r| r.anomaly_score).collect();
238 scores.sort_by(crate::utils::total_order);
239 let median = if scores.len().is_multiple_of(2) {
240 (scores[scores.len() / 2 - 1] + scores[scores.len() / 2])
241 / try_scalar_str::<A, _>(2.0)?
242 } else {
243 scores[scores.len() / 2]
244 };
245 (median > threshold, median)
246 }
247 EnsembleVotingStrategy::Weighted => {
248 let one = A::one();
249 let weights = &self.detector_weights;
250 Self::weighted_vote(&results, &|name| *weights.get(name).unwrap_or(&one))?
251 }
252 EnsembleVotingStrategy::Adaptive => {
253 match self.measured_weights() {
259 Some(measured) if measured.values().any(|w| *w > A::zero()) => {
260 Self::weighted_vote(&results, &|name| {
261 measured.get(name).copied().unwrap_or_else(A::zero)
262 })?
263 }
264 _ => Self::weighted_vote(&results, &|_| A::one())?,
268 }
269 }
270 EnsembleVotingStrategy::Stacking => {
271 return Err(
272 "ensemble voting strategy `Stacking` is not implemented: stacking \
273 feeds the member scores into a second-stage model trained on held-out \
274 labelled data, and this detector carries no meta-learner and no \
275 held-out split to train one on. Use `Adaptive`, which weights the \
276 members by their measured balanced accuracy from the same feedback \
277 path"
278 .to_string(),
279 );
280 }
281 };
282 let avg_score = ensemble_score;
283
284 Ok(AnomalyDetectionResult {
285 is_anomaly,
286 anomaly_score: avg_score,
287 confidence: avg_confidence,
288 anomaly_type: if is_anomaly {
289 Some(AnomalyType::StatisticalOutlier)
290 } else {
291 None
292 },
293 severity: if avg_score > try_scalar_str::<A, _>(0.8)? {
294 AnomalySeverity::High
295 } else if avg_score > try_scalar_str::<A, _>(0.5)? {
296 AnomalySeverity::Medium
297 } else {
298 AnomalySeverity::Low
299 },
300 metadata: HashMap::new(),
301 })
302 }
303
304 pub(super) fn adjust_sensitivity(&mut self, adjustment: A) -> Result<(), String> {
305 self.ensemble_config.ensemble_threshold = (self.ensemble_config.ensemble_threshold
306 + adjustment)
307 .max(try_scalar_str::<A, _>(0.1)?)
308 .min(try_scalar_str::<A, _>(0.9)?);
309 Ok(())
310 }
311}
312
313#[cfg(test)]
314#[path = "anomaly_ensemble_tests.rs"]
315mod anomaly_ensemble_tests;