use super::anomaly_detection::{
AnomalyDetectionResult, AnomalySeverity, AnomalyType, DetectionCounters, MLAnomalyDetector,
MLModelMetrics,
};
use super::optimizer::StreamingDataPoint;
use super::statistics as stats;
use scirs2_core::numeric::Float;
use scirs2_core::random::{seeded_rng, CoreRandom};
use std::collections::{HashMap, VecDeque};
use std::time::Instant;
const DEFAULT_WINDOW: usize = 512;
fn feature_vector<A: Float + Send + Sync>(data_point: &StreamingDataPoint<A>) -> Option<Vec<f64>> {
let features: Vec<f64> = data_point
.features
.iter()
.map(|v| v.to_f64().unwrap_or(f64::NAN))
.collect();
if features.is_empty() || features.iter().any(|v| !v.is_finite()) {
return None;
}
Some(features)
}
fn from_f64<A: Float>(value: f64) -> Result<A, String> {
A::from(value).ok_or_else(|| format!("{value} cannot be represented in the element type"))
}
fn squared_distance(a: &[f64], b: &[f64]) -> f64 {
a.iter()
.zip(b.iter())
.map(|(x, y)| {
let d = x - y;
d * d
})
.sum()
}
fn severity_for(score: f64, threshold: f64) -> AnomalySeverity {
if threshold <= 0.0 {
return AnomalySeverity::Low;
}
let ratio = score / threshold;
if ratio >= 2.0 {
AnomalySeverity::Critical
} else if ratio >= 1.5 {
AnomalySeverity::High
} else if ratio >= 1.0 {
AnomalySeverity::Medium
} else {
AnomalySeverity::Low
}
}
fn result_from_score<A: Float + Send + Sync>(
score: f64,
threshold: f64,
anomaly_type: AnomalyType,
metadata: HashMap<String, A>,
) -> Result<AnomalyDetectionResult<A>, String> {
let is_anomaly = score > threshold;
let confidence = if threshold > 0.0 {
((score - threshold).abs() / threshold).min(1.0)
} else {
0.0
};
Ok(AnomalyDetectionResult {
is_anomaly,
anomaly_score: from_f64(score)?,
confidence: from_f64(confidence)?,
anomaly_type: is_anomaly.then_some(anomaly_type),
severity: severity_for(score, threshold),
metadata,
})
}
#[derive(Debug, Clone)]
enum ITreeNode {
Split {
feature: usize,
threshold: f64,
left: Box<ITreeNode>,
right: Box<ITreeNode>,
},
Leaf { size: usize },
}
fn average_path_length(n: usize) -> f64 {
if n <= 1 {
return 0.0;
}
let n = n as f64;
let harmonic = (n - 1.0).ln() + std::f64::consts::EULER_GAMMA;
2.0 * harmonic - 2.0 * (n - 1.0) / n
}
fn build_itree(
sample: &[Vec<f64>],
depth: usize,
max_depth: usize,
rng: &mut CoreRandom<scirs2_core::random::rngs::StdRng>,
) -> ITreeNode {
if depth >= max_depth || sample.len() <= 1 {
return ITreeNode::Leaf { size: sample.len() };
}
let dimensions = sample[0].len();
if dimensions == 0 {
return ITreeNode::Leaf { size: sample.len() };
}
let feature = rng.gen_range(0..dimensions);
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for row in sample {
let value = row.get(feature).copied().unwrap_or(0.0);
if value < min {
min = value;
}
if value > max {
max = value;
}
}
if max <= min || !(min.is_finite() && max.is_finite()) {
return ITreeNode::Leaf { size: sample.len() };
}
let threshold = rng.gen_range(min..max);
let mut left = Vec::new();
let mut right = Vec::new();
for row in sample {
if row.get(feature).copied().unwrap_or(0.0) < threshold {
left.push(row.clone());
} else {
right.push(row.clone());
}
}
if left.is_empty() || right.is_empty() {
return ITreeNode::Leaf { size: sample.len() };
}
ITreeNode::Split {
feature,
threshold,
left: Box::new(build_itree(&left, depth + 1, max_depth, rng)),
right: Box::new(build_itree(&right, depth + 1, max_depth, rng)),
}
}
fn path_length(tree: &ITreeNode, point: &[f64], depth: usize) -> f64 {
match tree {
ITreeNode::Leaf { size } => depth as f64 + average_path_length(*size),
ITreeNode::Split {
feature,
threshold,
left,
right,
} => {
let value = point.get(*feature).copied().unwrap_or(0.0);
if value < *threshold {
path_length(left, point, depth + 1)
} else {
path_length(right, point, depth + 1)
}
}
}
}
pub struct IsolationForestDetector<A: Float + Send + Sync> {
trees: Vec<ITreeNode>,
window: VecDeque<Vec<f64>>,
window_capacity: usize,
tree_count: usize,
subsample_size: usize,
max_depth: usize,
threshold: f64,
since_refit: usize,
refit_interval: usize,
rng: CoreRandom<scirs2_core::random::rngs::StdRng>,
counters: DetectionCounters,
training_time: std::time::Duration,
inference_time_total: std::time::Duration,
inference_samples: usize,
_marker: std::marker::PhantomData<A>,
}
impl<A: Float + Send + Sync> IsolationForestDetector<A> {
pub fn new() -> Result<Self, String> {
Self::with_parameters(100, 256, 0.6, 20250817)
}
pub fn with_parameters(
tree_count: usize,
subsample_size: usize,
threshold: f64,
seed: u64,
) -> Result<Self, String> {
if tree_count == 0 {
return Err("isolation forest needs at least one tree".to_string());
}
if subsample_size < 2 {
return Err("isolation forest subsample size must be at least 2".to_string());
}
if !(threshold > 0.0 && threshold < 1.0) {
return Err(format!(
"isolation forest threshold must lie strictly in (0, 1), got {threshold}"
));
}
let max_depth = (subsample_size as f64).log2().ceil().max(1.0) as usize;
Ok(Self {
trees: Vec::new(),
window: VecDeque::with_capacity(DEFAULT_WINDOW),
window_capacity: DEFAULT_WINDOW.max(subsample_size),
tree_count,
subsample_size,
max_depth,
threshold,
since_refit: 0,
refit_interval: subsample_size,
rng: seeded_rng(seed),
counters: DetectionCounters::default(),
training_time: std::time::Duration::ZERO,
inference_time_total: std::time::Duration::ZERO,
inference_samples: 0,
_marker: std::marker::PhantomData,
})
}
pub fn is_fitted(&self) -> bool {
!self.trees.is_empty()
}
fn push_observation(&mut self, features: Vec<f64>) {
if self.window.len() >= self.window_capacity {
self.window.pop_front();
}
self.window.push_back(features);
self.since_refit += 1;
}
fn fit_forest(&mut self) {
let available = self.window.len();
if available < 2 {
return;
}
let started = Instant::now();
let sample_size = self.subsample_size.min(available);
let pool: Vec<Vec<f64>> = self.window.iter().cloned().collect();
let mut trees = Vec::with_capacity(self.tree_count);
for _ in 0..self.tree_count {
let mut indices: Vec<usize> = (0..available).collect();
for i in 0..sample_size {
let j = self.rng.gen_range(i..available);
indices.swap(i, j);
}
let subsample: Vec<Vec<f64>> = indices[..sample_size]
.iter()
.map(|&i| pool[i].clone())
.collect();
trees.push(build_itree(&subsample, 0, self.max_depth, &mut self.rng));
}
self.trees = trees;
self.since_refit = 0;
self.training_time += started.elapsed();
}
pub fn score(&self, features: &[f64]) -> Option<f64> {
if self.trees.is_empty() {
return None;
}
let mean_depth: f64 = self
.trees
.iter()
.map(|tree| path_length(tree, features, 0))
.sum::<f64>()
/ self.trees.len() as f64;
let normaliser = average_path_length(self.subsample_size.min(self.window.len().max(2)));
if normaliser <= 0.0 {
return None;
}
Some(2.0_f64.powf(-mean_depth / normaliser))
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> MLAnomalyDetector<A>
for IsolationForestDetector<A>
{
fn detect_anomaly(
&mut self,
data_point: &StreamingDataPoint<A>,
) -> Result<AnomalyDetectionResult<A>, String> {
let features = feature_vector(data_point)
.ok_or_else(|| "isolation forest: data point has no finite features".to_string())?;
if self.trees.is_empty() || self.since_refit >= self.refit_interval {
self.fit_forest();
}
let started = Instant::now();
let Some(score) = self.score(&features) else {
let mut metadata = HashMap::new();
metadata.insert(
"window_size".to_string(),
from_f64::<A>(self.window.len() as f64)?,
);
return Ok(AnomalyDetectionResult {
is_anomaly: false,
anomaly_score: A::zero(),
confidence: A::zero(),
anomaly_type: None,
severity: AnomalySeverity::Low,
metadata,
});
};
self.inference_time_total += started.elapsed();
self.inference_samples += 1;
let mut metadata = HashMap::new();
metadata.insert(
"tree_count".to_string(),
from_f64::<A>(self.trees.len() as f64)?,
);
metadata.insert(
"window_size".to_string(),
from_f64::<A>(self.window.len() as f64)?,
);
let result =
result_from_score(score, self.threshold, AnomalyType::SpatialAnomaly, metadata)?;
self.counters
.record_prediction(result.is_anomaly, result.anomaly_score);
Ok(result)
}
fn train(&mut self, training_data: &[StreamingDataPoint<A>]) -> Result<(), String> {
let mut accepted = 0usize;
for data_point in training_data {
if let Some(features) = feature_vector(data_point) {
self.push_observation(features);
accepted += 1;
}
}
if accepted == 0 {
return Err("isolation forest: no usable training points".to_string());
}
self.fit_forest();
Ok(())
}
fn update_incremental(&mut self, data_point: &StreamingDataPoint<A>) -> Result<(), String> {
let features = feature_vector(data_point)
.ok_or_else(|| "isolation forest: data point has no finite features".to_string())?;
self.push_observation(features);
Ok(())
}
fn record_outcome(&mut self, predicted_anomaly: bool, was_true_anomaly: bool) {
self.counters
.record_outcome(predicted_anomaly, was_true_anomaly);
}
fn get_performance_metrics(&self) -> Result<MLModelMetrics<A>, String> {
self.counters
.to_metrics(self.name(), self.training_time, self.mean_inference_time())
}
fn name(&self) -> String {
"isolation_forest".to_string()
}
}
impl<A: Float + Send + Sync> IsolationForestDetector<A> {
fn mean_inference_time(&self) -> std::time::Duration {
if self.inference_samples == 0 {
std::time::Duration::ZERO
} else {
self.inference_time_total / self.inference_samples as u32
}
}
}
pub struct OneClassSvmDetector<A: Float + Send + Sync> {
support_vectors: Vec<Vec<f64>>,
coefficients: Vec<f64>,
max_support_vectors: usize,
rho: f64,
nu: f64,
eta: f64,
lambda: f64,
gamma: Option<f64>,
window: VecDeque<Vec<f64>>,
window_capacity: usize,
updates: usize,
counters: DetectionCounters,
training_time: std::time::Duration,
inference_time_total: std::time::Duration,
inference_samples: usize,
_marker: std::marker::PhantomData<A>,
}
impl<A: Float + Send + Sync> OneClassSvmDetector<A> {
pub fn new() -> Result<Self, String> {
Self::with_parameters(0.05, 0.1, 0.01, 128)
}
pub fn with_parameters(
nu: f64,
eta: f64,
lambda: f64,
max_support_vectors: usize,
) -> Result<Self, String> {
if !(nu > 0.0 && nu < 1.0) {
return Err(format!("one-class SVM nu must lie in (0, 1), got {nu}"));
}
if !(eta.is_finite() && eta > 0.0) {
return Err(format!(
"one-class SVM learning rate must be positive, got {eta}"
));
}
if max_support_vectors < 1 {
return Err("one-class SVM needs at least one support vector slot".to_string());
}
Ok(Self {
support_vectors: Vec::new(),
coefficients: Vec::new(),
max_support_vectors,
rho: 0.0,
nu,
eta,
lambda,
gamma: None,
window: VecDeque::with_capacity(DEFAULT_WINDOW),
window_capacity: DEFAULT_WINDOW,
updates: 0,
counters: DetectionCounters::default(),
training_time: std::time::Duration::ZERO,
inference_time_total: std::time::Duration::ZERO,
inference_samples: 0,
_marker: std::marker::PhantomData,
})
}
pub fn support_vector_count(&self) -> usize {
self.support_vectors.len()
}
pub fn rho(&self) -> f64 {
self.rho
}
fn estimate_gamma(&self) -> Option<f64> {
if self.window.len() < 4 {
return None;
}
let points: Vec<&Vec<f64>> = self.window.iter().collect();
let mut distances = Vec::with_capacity(points.len());
for i in 1..points.len() {
distances.push(squared_distance(points[i - 1], points[i]));
}
let median = stats::median(&distances)?;
if median > 0.0 {
Some(1.0 / median)
} else {
None
}
}
fn kernel(&self, a: &[f64], b: &[f64], gamma: f64) -> f64 {
(-gamma * squared_distance(a, b)).exp()
}
pub fn decision_value(&self, features: &[f64]) -> Option<f64> {
let gamma = self.gamma?;
if self.support_vectors.is_empty() {
return Some(0.0);
}
Some(
self.support_vectors
.iter()
.zip(self.coefficients.iter())
.map(|(sv, &alpha)| alpha * self.kernel(sv, features, gamma))
.sum(),
)
}
fn push_observation(&mut self, features: Vec<f64>) {
if self.window.len() >= self.window_capacity {
self.window.pop_front();
}
self.window.push_back(features);
if self.gamma.is_none() {
self.gamma = self.estimate_gamma();
}
}
fn learn_one(&mut self, features: &[f64]) {
let Some(gamma) = self.gamma else { return };
let started = Instant::now();
let f_x: f64 = self
.support_vectors
.iter()
.zip(self.coefficients.iter())
.map(|(sv, &alpha)| alpha * self.kernel(sv, features, gamma))
.sum();
let decay = 1.0 - self.eta * self.lambda;
for alpha in &mut self.coefficients {
*alpha *= decay;
}
if f_x < self.rho {
self.support_vectors.push(features.to_vec());
self.coefficients.push(self.eta);
self.rho -= self.eta * (1.0 - self.nu);
} else {
self.rho += self.eta * self.nu;
}
while self.support_vectors.len() > self.max_support_vectors {
let mut weakest = 0usize;
let mut weakest_magnitude = f64::INFINITY;
for (index, alpha) in self.coefficients.iter().enumerate() {
if alpha.abs() < weakest_magnitude {
weakest_magnitude = alpha.abs();
weakest = index;
}
}
self.support_vectors.remove(weakest);
self.coefficients.remove(weakest);
}
self.updates += 1;
self.training_time += started.elapsed();
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> MLAnomalyDetector<A>
for OneClassSvmDetector<A>
{
fn detect_anomaly(
&mut self,
data_point: &StreamingDataPoint<A>,
) -> Result<AnomalyDetectionResult<A>, String> {
let features = feature_vector(data_point)
.ok_or_else(|| "one-class SVM: data point has no finite features".to_string())?;
let started = Instant::now();
let Some(f_x) = self.decision_value(&features) else {
let mut metadata = HashMap::new();
metadata.insert(
"window_size".to_string(),
from_f64::<A>(self.window.len() as f64)?,
);
return Ok(AnomalyDetectionResult {
is_anomaly: false,
anomaly_score: A::zero(),
confidence: A::zero(),
anomaly_type: None,
severity: AnomalySeverity::Low,
metadata,
});
};
self.inference_time_total += started.elapsed();
self.inference_samples += 1;
let score = (self.rho - f_x).max(0.0);
let is_anomaly = self.updates > 0 && f_x < self.rho;
let scale = self.rho.abs().max(self.eta);
let confidence = (score / scale).min(1.0);
let mut metadata = HashMap::new();
metadata.insert("decision_value".to_string(), from_f64::<A>(f_x)?);
metadata.insert("rho".to_string(), from_f64::<A>(self.rho)?);
metadata.insert(
"support_vectors".to_string(),
from_f64::<A>(self.support_vectors.len() as f64)?,
);
let result = AnomalyDetectionResult {
is_anomaly,
anomaly_score: from_f64(score)?,
confidence: from_f64(confidence)?,
anomaly_type: is_anomaly.then_some(AnomalyType::PointAnomaly),
severity: severity_for(score, scale),
metadata,
};
self.counters
.record_prediction(result.is_anomaly, result.anomaly_score);
Ok(result)
}
fn train(&mut self, training_data: &[StreamingDataPoint<A>]) -> Result<(), String> {
let mut accepted = 0usize;
for data_point in training_data {
if let Some(features) = feature_vector(data_point) {
self.push_observation(features.clone());
self.learn_one(&features);
accepted += 1;
}
}
if accepted == 0 {
return Err("one-class SVM: no usable training points".to_string());
}
Ok(())
}
fn update_incremental(&mut self, data_point: &StreamingDataPoint<A>) -> Result<(), String> {
let features = feature_vector(data_point)
.ok_or_else(|| "one-class SVM: data point has no finite features".to_string())?;
self.push_observation(features.clone());
self.learn_one(&features);
Ok(())
}
fn record_outcome(&mut self, predicted_anomaly: bool, was_true_anomaly: bool) {
self.counters
.record_outcome(predicted_anomaly, was_true_anomaly);
}
fn get_performance_metrics(&self) -> Result<MLModelMetrics<A>, String> {
let mean_inference = if self.inference_samples == 0 {
std::time::Duration::ZERO
} else {
self.inference_time_total / self.inference_samples as u32
};
self.counters
.to_metrics(self.name(), self.training_time, mean_inference)
}
fn name(&self) -> String {
"one_class_svm".to_string()
}
}
pub struct LofDetector<A: Float + Send + Sync> {
window: VecDeque<Vec<f64>>,
window_capacity: usize,
k: usize,
threshold: f64,
counters: DetectionCounters,
inference_time_total: std::time::Duration,
inference_samples: usize,
_marker: std::marker::PhantomData<A>,
}
impl<A: Float + Send + Sync> LofDetector<A> {
pub fn new() -> Result<Self, String> {
Self::with_parameters(20, 1.5, DEFAULT_WINDOW)
}
pub fn with_parameters(
k: usize,
threshold: f64,
window_capacity: usize,
) -> Result<Self, String> {
if k < 2 {
return Err(format!("LOF needs k >= 2, got {k}"));
}
if !(threshold.is_finite() && threshold > 0.0) {
return Err(format!("LOF threshold must be positive, got {threshold}"));
}
if window_capacity <= k {
return Err("LOF window must be larger than k".to_string());
}
Ok(Self {
window: VecDeque::with_capacity(window_capacity),
window_capacity,
k,
threshold,
counters: DetectionCounters::default(),
inference_time_total: std::time::Duration::ZERO,
inference_samples: 0,
_marker: std::marker::PhantomData,
})
}
fn push_observation(&mut self, features: Vec<f64>) {
if self.window.len() >= self.window_capacity {
self.window.pop_front();
}
self.window.push_back(features);
}
fn neighbour_distances(&self, point: &[f64], exclude: Option<usize>) -> Vec<(usize, f64)> {
let mut distances: Vec<(usize, f64)> = self
.window
.iter()
.enumerate()
.filter(|(index, _)| Some(*index) != exclude)
.map(|(index, other)| (index, squared_distance(point, other).sqrt()))
.collect();
distances.sort_by(|a, b| stats::total_order(&a.1, &b.1));
distances
}
fn k_distance(&self, index: usize) -> Option<f64> {
let point = self.window.get(index)?;
let distances = self.neighbour_distances(point, Some(index));
distances.get(self.k - 1).map(|(_, d)| *d)
}
fn lrd_of_member(&self, index: usize) -> Option<f64> {
let point = self.window.get(index)?;
let distances = self.neighbour_distances(point, Some(index));
if distances.len() < self.k {
return None;
}
let mut total = 0.0_f64;
for &(neighbour_index, distance) in distances.iter().take(self.k) {
let neighbour_k_distance = self.k_distance(neighbour_index)?;
total += neighbour_k_distance.max(distance);
}
let mean_reachability = total / self.k as f64;
if mean_reachability <= 0.0 {
return Some(f64::MAX.sqrt());
}
Some(1.0 / mean_reachability)
}
pub fn score(&self, point: &[f64]) -> Option<f64> {
if self.window.len() <= self.k {
return None;
}
let distances = self.neighbour_distances(point, None);
if distances.len() < self.k {
return None;
}
let mut reachability_total = 0.0_f64;
let mut neighbour_lrd_total = 0.0_f64;
for &(neighbour_index, distance) in distances.iter().take(self.k) {
let neighbour_k_distance = self.k_distance(neighbour_index)?;
reachability_total += neighbour_k_distance.max(distance);
neighbour_lrd_total += self.lrd_of_member(neighbour_index)?;
}
let mean_reachability = reachability_total / self.k as f64;
let lrd_point = if mean_reachability <= 0.0 {
f64::MAX.sqrt()
} else {
1.0 / mean_reachability
};
let mean_neighbour_lrd = neighbour_lrd_total / self.k as f64;
if lrd_point <= 0.0 {
return None;
}
Some(mean_neighbour_lrd / lrd_point)
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> MLAnomalyDetector<A>
for LofDetector<A>
{
fn detect_anomaly(
&mut self,
data_point: &StreamingDataPoint<A>,
) -> Result<AnomalyDetectionResult<A>, String> {
let features = feature_vector(data_point)
.ok_or_else(|| "LOF: data point has no finite features".to_string())?;
let started = Instant::now();
let Some(score) = self.score(&features) else {
let mut metadata = HashMap::new();
metadata.insert(
"window_size".to_string(),
from_f64::<A>(self.window.len() as f64)?,
);
return Ok(AnomalyDetectionResult {
is_anomaly: false,
anomaly_score: A::zero(),
confidence: A::zero(),
anomaly_type: None,
severity: AnomalySeverity::Low,
metadata,
});
};
self.inference_time_total += started.elapsed();
self.inference_samples += 1;
let mut metadata = HashMap::new();
metadata.insert("k".to_string(), from_f64::<A>(self.k as f64)?);
metadata.insert(
"window_size".to_string(),
from_f64::<A>(self.window.len() as f64)?,
);
let result = result_from_score(
score,
self.threshold,
AnomalyType::ContextualAnomaly,
metadata,
)?;
self.counters
.record_prediction(result.is_anomaly, result.anomaly_score);
Ok(result)
}
fn train(&mut self, training_data: &[StreamingDataPoint<A>]) -> Result<(), String> {
let mut accepted = 0usize;
for data_point in training_data {
if let Some(features) = feature_vector(data_point) {
self.push_observation(features);
accepted += 1;
}
}
if accepted == 0 {
return Err("LOF: no usable training points".to_string());
}
Ok(())
}
fn update_incremental(&mut self, data_point: &StreamingDataPoint<A>) -> Result<(), String> {
let features = feature_vector(data_point)
.ok_or_else(|| "LOF: data point has no finite features".to_string())?;
self.push_observation(features);
Ok(())
}
fn record_outcome(&mut self, predicted_anomaly: bool, was_true_anomaly: bool) {
self.counters
.record_outcome(predicted_anomaly, was_true_anomaly);
}
fn get_performance_metrics(&self) -> Result<MLModelMetrics<A>, String> {
let mean_inference = if self.inference_samples == 0 {
std::time::Duration::ZERO
} else {
self.inference_time_total / self.inference_samples as u32
};
self.counters
.to_metrics(self.name(), std::time::Duration::ZERO, mean_inference)
}
fn name(&self) -> String {
"lof".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::Array1;
use std::collections::HashMap as StdHashMap;
fn point(features: Vec<f64>) -> StreamingDataPoint<f64> {
StreamingDataPoint {
features: Array1::from_vec(features),
target: None,
timestamp: Instant::now(),
source_id: None,
quality_score: 1.0,
metadata: StdHashMap::new(),
}
}
fn wobble(index: usize) -> f64 {
((index as f64) * 0.7548776662).fract() - 0.5
}
fn cluster(n: usize) -> Vec<StreamingDataPoint<f64>> {
(0..n)
.map(|i| point(vec![wobble(i), wobble(i * 7 + 3)]))
.collect()
}
#[test]
fn isolation_forest_scores_outliers_above_inliers() {
let mut detector = IsolationForestDetector::<f64>::new().expect("detector");
detector.train(&cluster(400)).expect("train");
assert!(detector.is_fitted());
let inlier_score = detector.score(&[0.0, 0.0]).expect("inlier score");
let outlier_score = detector.score(&[50.0, -50.0]).expect("outlier score");
assert!(
outlier_score > inlier_score,
"A2 regression: outlier scored {outlier_score} but inlier scored \
{inlier_score} (a constant score would make these equal)"
);
assert!(
(inlier_score - outlier_score).abs() > 1e-6,
"scores are indistinguishable — the detector is not scoring the input"
);
let outlier = MLAnomalyDetector::detect_anomaly(&mut detector, &point(vec![50.0, -50.0]))
.expect("detect");
assert!(outlier.is_anomaly, "far-away point must be flagged");
let inlier = MLAnomalyDetector::detect_anomaly(&mut detector, &point(vec![0.0, 0.0]))
.expect("detect");
assert!(!inlier.is_anomaly, "cluster centre must not be flagged");
}
#[test]
fn one_class_svm_learns_a_boundary_and_flags_novelty() {
let mut detector = OneClassSvmDetector::<f64>::new().expect("detector");
detector.train(&cluster(400)).expect("train");
assert!(
detector.support_vector_count() > 0,
"A2 regression: the SVM admitted no support vectors — it never trained"
);
let inlier_value = detector.decision_value(&[0.0, 0.0]).expect("inlier");
let outlier_value = detector.decision_value(&[40.0, 40.0]).expect("outlier");
assert!(
inlier_value > outlier_value,
"the decision function must rate a cluster point above a distant one \
({inlier_value} vs {outlier_value})"
);
let outlier = MLAnomalyDetector::detect_anomaly(&mut detector, &point(vec![40.0, 40.0]))
.expect("detect");
assert!(
outlier.is_anomaly,
"A2 regression: the SVM still never reports an anomaly (score={})",
outlier.anomaly_score
);
assert!(
(outlier.anomaly_score - 0.2).abs() > 1e-9,
"score {} looks like the old hard-coded 0.2",
outlier.anomaly_score
);
}
#[test]
fn lof_reports_density_ratio_near_one_for_inliers() {
let mut detector = LofDetector::<f64>::with_parameters(10, 1.5, 256).expect("detector");
detector.train(&cluster(200)).expect("train");
let inlier = detector.score(&[0.0, 0.0]).expect("inlier score");
let outlier = detector.score(&[30.0, 30.0]).expect("outlier score");
assert!(
inlier < 2.0,
"LOF of a point inside the cluster should be near 1, got {inlier}"
);
assert!(
outlier > inlier * 2.0,
"LOF of a sparse-region point ({outlier}) must clearly exceed an \
inlier's ({inlier})"
);
let result = MLAnomalyDetector::detect_anomaly(&mut detector, &point(vec![30.0, 30.0]))
.expect("detect");
assert!(result.is_anomaly);
assert!(
(result.anomaly_score - 0.1).abs() > 1e-9,
"score {} looks like the old hard-coded 0.1",
result.anomaly_score
);
}
#[test]
fn the_three_ml_detectors_produce_distinct_scores() {
let training = cluster(300);
let probe = point(vec![12.0, -8.0]);
let mut forest = IsolationForestDetector::<f64>::new().expect("forest");
forest.train(&training).expect("train");
let mut svm = OneClassSvmDetector::<f64>::new().expect("svm");
svm.train(&training).expect("train");
let mut lof = LofDetector::<f64>::with_parameters(10, 1.5, 512).expect("lof");
lof.train(&training).expect("train");
let forest_score = MLAnomalyDetector::detect_anomaly(&mut forest, &probe)
.expect("forest")
.anomaly_score;
let svm_score = MLAnomalyDetector::detect_anomaly(&mut svm, &probe)
.expect("svm")
.anomaly_score;
let lof_score = MLAnomalyDetector::detect_anomaly(&mut lof, &probe)
.expect("lof")
.anomaly_score;
assert!((forest_score - svm_score).abs() > 1e-9);
assert!((svm_score - lof_score).abs() > 1e-9);
assert!((forest_score - lof_score).abs() > 1e-9);
}
#[test]
fn performance_metrics_require_real_labelled_feedback() {
let mut detector = IsolationForestDetector::<f64>::new().expect("detector");
detector.train(&cluster(200)).expect("train");
assert!(
detector.get_performance_metrics().is_err(),
"A2 regression: metrics were reported without any labelled outcome"
);
for _ in 0..3 {
MLAnomalyDetector::record_outcome(&mut detector, true, true);
}
MLAnomalyDetector::record_outcome(&mut detector, true, false);
MLAnomalyDetector::record_outcome(&mut detector, false, true);
for _ in 0..5 {
MLAnomalyDetector::record_outcome(&mut detector, false, false);
}
let metrics = detector.get_performance_metrics().expect("metrics");
assert!(
(metrics.precision - 0.75).abs() < 1e-12,
"precision {}",
metrics.precision
);
assert!(
(metrics.recall - 0.75).abs() < 1e-12,
"recall {}",
metrics.recall
);
assert!(
(metrics.accuracy - 0.8).abs() < 1e-12,
"accuracy {}",
metrics.accuracy
);
assert!(
(metrics.false_positive_rate - 1.0 / 6.0).abs() < 1e-12,
"fpr {}",
metrics.false_positive_rate
);
assert!(
(metrics.f1_score - 0.75).abs() < 1e-12,
"f1 {}",
metrics.f1_score
);
assert!(
(metrics.accuracy - 0.85).abs() > 1e-9,
"accuracy is still the hard-coded 0.85"
);
}
#[test]
fn cold_detectors_report_no_score_instead_of_a_constant() {
let mut forest = IsolationForestDetector::<f64>::new().expect("forest");
let cold =
MLAnomalyDetector::detect_anomaly(&mut forest, &point(vec![1.0, 2.0])).expect("detect");
assert!(!cold.is_anomaly);
assert_eq!(cold.anomaly_score, 0.0);
assert_eq!(cold.confidence, 0.0);
let mut lof = LofDetector::<f64>::new().expect("lof");
assert!(lof.score(&[0.0, 0.0]).is_none());
let cold_lof =
MLAnomalyDetector::detect_anomaly(&mut lof, &point(vec![1.0, 2.0])).expect("detect");
assert!(!cold_lof.is_anomaly);
assert_eq!(cold_lof.anomaly_score, 0.0);
}
#[test]
fn non_finite_features_are_rejected() {
let mut forest = IsolationForestDetector::<f64>::new().expect("forest");
forest.train(&cluster(100)).expect("train");
let broken = point(vec![f64::NAN, 1.0]);
assert!(MLAnomalyDetector::detect_anomaly(&mut forest, &broken).is_err());
assert!(MLAnomalyDetector::update_incremental(&mut forest, &broken).is_err());
}
}