1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
// Ensemble voting for the streaming anomaly detector.
//
// Each strategy computes the quantity it names. Two of them used to fall
// through to a `_` arm and silently behave as plain min-consensus voting; that
// is gone. `Adaptive` is now backed by real per-detector confusion counters fed
// from the ground-truth feedback path, and `Stacking` — the one strategy that
// genuinely needs a trained second-stage model — returns an honest error.
use super::anomaly_detection::{AnomalyDetectionResult, AnomalySeverity, AnomalyType};
use super::anomaly_scoring::DetectionCounters;
use crate::utils::try_scalar_str;
use scirs2_core::numeric::Float;
use std::collections::HashMap;
/// Ensemble anomaly detector combining multiple methods
pub struct EnsembleAnomalyDetector<A: Float + Send + Sync> {
/// Ensemble voting strategy
voting_strategy: EnsembleVotingStrategy,
/// Per-detector weights used by
/// [`EnsembleVotingStrategy::Weighted`]. Detectors with no explicit weight
/// count as `1`, so an unconfigured ensemble weights every detector
/// equally rather than ignoring them.
detector_weights: HashMap<String, A>,
/// Per-detector confusion counters, the measurement
/// [`EnsembleVotingStrategy::Adaptive`] derives its weights from.
///
/// These are the ensemble's own counters, not the ML detectors': the ML
/// detectors are told the *ensemble's* verdict when ground truth arrives
/// (that is what "the outcome of the most recent detection" means to them),
/// and the statistical detectors carry no counters at all. Weighting a vote
/// needs each member's own verdict scored against the truth, which is
/// exactly what these record.
detector_counters: HashMap<String, DetectionCounters>,
/// Each member's verdict on the most recently combined point, awaiting
/// ground truth. Cleared once labelled, so one detection is scored once.
pending_verdicts: HashMap<String, bool>,
/// Ensemble configuration
ensemble_config: EnsembleConfig<A>,
}
/// Ensemble voting strategies
#[derive(Debug, Clone)]
pub enum EnsembleVotingStrategy {
/// Simple majority voting
Majority,
/// Weighted voting using the operator-supplied per-detector weights
Weighted,
/// Maximum anomaly score
MaxScore,
/// Average anomaly score
AverageScore,
/// Median anomaly score
MedianScore,
/// Weighted voting whose weights are *measured* from each detector's own
/// confusion matrix (balanced accuracy), rather than configured.
Adaptive,
/// Stacking with meta-learner
Stacking,
}
/// Ensemble configuration
#[derive(Debug, Clone)]
pub struct EnsembleConfig<A: Float + Send + Sync> {
/// Minimum number of detectors that must agree
pub min_consensus: usize,
/// Threshold for ensemble anomaly score
pub ensemble_threshold: A,
/// Enable dynamic detector weighting
pub dynamic_weighting: bool,
/// Performance evaluation window
pub evaluation_window: usize,
/// Enable detector selection based on context
pub context_based_selection: bool,
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> EnsembleAnomalyDetector<A> {
pub(super) fn new(voting_strategy: EnsembleVotingStrategy) -> Result<Self, String> {
Ok(Self {
voting_strategy,
detector_weights: HashMap::new(),
detector_counters: HashMap::new(),
pending_verdicts: HashMap::new(),
ensemble_config: EnsembleConfig {
min_consensus: 2,
ensemble_threshold: try_scalar_str::<A, _>(0.5)?,
dynamic_weighting: true,
evaluation_window: 100,
context_based_selection: false,
},
})
}
/// Replaces the voting strategy.
pub(super) fn set_voting_strategy(&mut self, strategy: EnsembleVotingStrategy) {
self.voting_strategy = strategy;
}
/// Strategy currently in force.
pub fn voting_strategy(&self) -> &EnsembleVotingStrategy {
&self.voting_strategy
}
/// Sets the weight [`EnsembleVotingStrategy::Weighted`] gives one detector.
pub(super) fn set_detector_weight(&mut self, detector_name: &str, weight: A) {
self.detector_weights
.insert(detector_name.to_string(), weight);
}
/// Records ground truth for the most recently combined point against every
/// member's own verdict.
///
/// This is the ground-truth feedback path the adaptive weighting needs: a
/// detector that flagged the point is scored as a true or false positive,
/// one that did not is scored as a true or false negative.
pub(super) fn record_outcome(&mut self, was_true_anomaly: bool) {
for (name, verdict) in self.pending_verdicts.drain() {
self.detector_counters
.entry(name)
.or_default()
.record_outcome(verdict, was_true_anomaly);
}
}
/// Balanced accuracy measured for one member, or `None` before any ground
/// truth has been recorded for it.
pub fn detector_balanced_accuracy(&self, detector_name: &str) -> Option<f64> {
self.detector_counters
.get(detector_name)?
.balanced_accuracy()
}
/// Weights derived from the measured per-detector confusion matrices, or
/// `None` while no member has any labelled outcome.
///
/// The weight is balanced accuracy `(TPR + TNR) / 2`, which is insensitive
/// to how rare anomalies are; plain accuracy would hand a detector that
/// flags nothing on a 1%-anomaly stream a weight of 0.99.
pub fn measured_weights(&self) -> Option<HashMap<String, A>> {
let mut weights = HashMap::new();
let mut measured_any = false;
for (name, counters) in &self.detector_counters {
if let Some(balanced) = counters.balanced_accuracy() {
measured_any = true;
if let Some(weight) = A::from(balanced) {
weights.insert(name.clone(), weight);
}
}
}
measured_any.then_some(weights)
}
/// Weighted vote and weighted mean score under an arbitrary weighting.
///
/// Shared by [`EnsembleVotingStrategy::Weighted`] (configured weights) and
/// [`EnsembleVotingStrategy::Adaptive`] (measured weights), so the two
/// cannot drift apart.
fn weighted_vote(
results: &HashMap<String, AnomalyDetectionResult<A>>,
weight_of: &dyn Fn(&str) -> A,
) -> Result<(bool, A), String> {
let mut weight_sum = A::zero();
let mut weighted_score = A::zero();
let mut weighted_votes = A::zero();
for (name, result) in results {
let weight = weight_of(name);
weight_sum = weight_sum + weight;
weighted_score = weighted_score + weight * result.anomaly_score;
if result.is_anomaly {
weighted_votes = weighted_votes + weight;
}
}
if weight_sum <= A::zero() {
return Err(
"weighted ensemble voting needs a positive total detector weight".to_string(),
);
}
let score = weighted_score / weight_sum;
let vote_share = weighted_votes / weight_sum;
Ok((vote_share > try_scalar_str::<A, _>(0.5)?, score))
}
pub(super) fn combine_results(
&mut self,
results: HashMap<String, AnomalyDetectionResult<A>>,
) -> Result<AnomalyDetectionResult<A>, String> {
if results.is_empty() {
return Ok(AnomalyDetectionResult {
is_anomaly: false,
anomaly_score: A::zero(),
confidence: A::zero(),
anomaly_type: None,
severity: AnomalySeverity::Low,
metadata: HashMap::new(),
});
}
// Record each member's own verdict, so that a later
// `record_outcome` can score it against the truth. This is the only
// place the ensemble sees the individual verdicts.
self.pending_verdicts.clear();
for (name, result) in &results {
self.detector_counters
.entry(name.clone())
.or_default()
.record_prediction(result.is_anomaly, result.anomaly_score);
self.pending_verdicts
.insert(name.clone(), result.is_anomaly);
}
let anomaly_count = results.values().filter(|r| r.is_anomaly).count();
let total_count = results.len();
let avg_score = results.values().map(|r| r.anomaly_score).sum::<A>()
/ try_scalar_str::<A, _>(total_count)?;
let avg_confidence = results.values().map(|r| r.confidence).sum::<A>()
/ try_scalar_str::<A, _>(total_count)?;
// The ensemble the detector actually builds is `Weighted`, which used
// to fall through to the `_` arm and behave as plain min-consensus
// voting -- the weights were never consulted at all. Each strategy now
// computes the quantity it names, and the one that has no
// implementation behind it says so instead of silently pretending to
// be a different strategy.
let threshold = self.ensemble_config.ensemble_threshold;
let (is_anomaly, ensemble_score) = match self.voting_strategy {
EnsembleVotingStrategy::Majority => (anomaly_count > total_count / 2, avg_score),
EnsembleVotingStrategy::MaxScore => {
let max_score = results
.values()
.map(|r| r.anomaly_score)
.fold(A::zero(), |acc, s| if s > acc { s } else { acc });
(max_score > threshold, max_score)
}
EnsembleVotingStrategy::AverageScore => (avg_score > threshold, avg_score),
EnsembleVotingStrategy::MedianScore => {
let mut scores: Vec<A> = results.values().map(|r| r.anomaly_score).collect();
scores.sort_by(crate::utils::total_order);
let median = if scores.len().is_multiple_of(2) {
(scores[scores.len() / 2 - 1] + scores[scores.len() / 2])
/ try_scalar_str::<A, _>(2.0)?
} else {
scores[scores.len() / 2]
};
(median > threshold, median)
}
EnsembleVotingStrategy::Weighted => {
let one = A::one();
let weights = &self.detector_weights;
Self::weighted_vote(&results, &|name| *weights.get(name).unwrap_or(&one))?
}
EnsembleVotingStrategy::Adaptive => {
// Measured weights when ground truth has arrived; uniform
// otherwise. Uniform is the honest default: before any label
// exists there is no evidence that one member deserves more say
// than another, and inventing a prior ranking would be a
// fabrication dressed as a measurement.
match self.measured_weights() {
Some(measured) if measured.values().any(|w| *w > A::zero()) => {
Self::weighted_vote(&results, &|name| {
measured.get(name).copied().unwrap_or_else(A::zero)
})?
}
// Either no ground truth yet, or every member has measured
// zero skill — in both cases there is nothing to rank the
// members by, so the vote stays uniform.
_ => Self::weighted_vote(&results, &|_| A::one())?,
}
}
EnsembleVotingStrategy::Stacking => {
return Err(
"ensemble voting strategy `Stacking` is not implemented: stacking \
feeds the member scores into a second-stage model trained on held-out \
labelled data, and this detector carries no meta-learner and no \
held-out split to train one on. Use `Adaptive`, which weights the \
members by their measured balanced accuracy from the same feedback \
path"
.to_string(),
);
}
};
let avg_score = ensemble_score;
Ok(AnomalyDetectionResult {
is_anomaly,
anomaly_score: avg_score,
confidence: avg_confidence,
anomaly_type: if is_anomaly {
Some(AnomalyType::StatisticalOutlier)
} else {
None
},
severity: if avg_score > try_scalar_str::<A, _>(0.8)? {
AnomalySeverity::High
} else if avg_score > try_scalar_str::<A, _>(0.5)? {
AnomalySeverity::Medium
} else {
AnomalySeverity::Low
},
metadata: HashMap::new(),
})
}
pub(super) fn adjust_sensitivity(&mut self, adjustment: A) -> Result<(), String> {
self.ensemble_config.ensemble_threshold = (self.ensemble_config.ensemble_threshold
+ adjustment)
.max(try_scalar_str::<A, _>(0.1)?)
.min(try_scalar_str::<A, _>(0.9)?);
Ok(())
}
}
#[cfg(test)]
#[path = "anomaly_ensemble_tests.rs"]
mod anomaly_ensemble_tests;