use crate::error::{AnomalyError, AnomalyResult};
use crate::handle::LcgRng;
fn xavier_init_f64(fan_in: usize, fan_out: usize, rng: &mut LcgRng) -> Vec<f64> {
let limit = (6.0_f64 / (fan_in + fan_out) as f64).sqrt();
(0..fan_in * fan_out)
.map(|_| {
let u = rng.next_f32() as f64;
u * 2.0 * limit - limit
})
.collect()
}
type LayerCache = (Vec<Vec<f64>>, Vec<Vec<f64>>);
struct SoftSvddMlp {
w: [Vec<f64>; 3],
b: [Vec<f64>; 3],
out_dims: [usize; 3],
in_dim: usize,
}
impl SoftSvddMlp {
fn new(
in_dim: usize,
h1: usize,
h2: usize,
latent: usize,
rng: &mut LcgRng,
) -> AnomalyResult<Self> {
for (name, d) in [
("in_dim", in_dim),
("h1", h1),
("h2", h2),
("latent", latent),
] {
if d == 0 {
return Err(AnomalyError::InvalidLayerDims {
msg: format!("{name} must be > 0"),
});
}
}
let w = [
xavier_init_f64(in_dim, h1, rng),
xavier_init_f64(h1, h2, rng),
xavier_init_f64(h2, latent, rng),
];
let b = [
vec![0.0_f64; h1],
vec![0.0_f64; h2],
vec![0.0_f64; latent], ];
Ok(Self {
w,
b,
out_dims: [h1, h2, latent],
in_dim,
})
}
fn forward_with_cache(&self, x: &[f64]) -> AnomalyResult<LayerCache> {
if x.len() != self.in_dim {
return Err(AnomalyError::DimensionMismatch {
expected: self.in_dim,
got: x.len(),
});
}
let n_layers = 3;
let mut pre = Vec::with_capacity(n_layers);
let mut post = Vec::with_capacity(n_layers);
let mut act: Vec<f64> = x.to_vec();
for l in 0..n_layers {
let fan_in = if l == 0 {
self.in_dim
} else {
self.out_dims[l - 1]
};
let fan_out = self.out_dims[l];
let z: Vec<f64> = (0..fan_out)
.map(|o| {
let row_start = o * fan_in;
self.b[l][o]
+ self.w[l][row_start..row_start + fan_in]
.iter()
.zip(act.iter())
.map(|(wi, ai)| wi * ai)
.sum::<f64>()
})
.collect();
let a: Vec<f64> = if l < 2 {
z.iter().map(|&v| v.max(0.0)).collect()
} else {
z.clone()
};
pre.push(z);
post.push(a.clone());
act = a;
}
Ok((pre, post))
}
fn forward(&self, x: &[f64]) -> AnomalyResult<Vec<f64>> {
let (_, post) = self.forward_with_cache(x)?;
Ok(post[2].clone())
}
fn backward_update(
&mut self,
x: &[f64],
center: &[f64],
scale: f64, lr: f64,
) -> AnomalyResult<()> {
let (pre, post) = self.forward_with_cache(x)?;
let latent = &post[2];
let n_lat = self.out_dims[2];
let mut delta: Vec<f64> = (0..n_lat)
.map(|j| 2.0 * scale * (latent[j] - center[j]))
.collect();
for l in (0..3).rev() {
let fan_in = if l == 0 {
self.in_dim
} else {
self.out_dims[l - 1]
};
let fan_out = self.out_dims[l];
let input_act: &[f64] = if l == 0 { x } else { &post[l - 1] };
for (o, &d_o) in delta.iter().enumerate().take(fan_out) {
let row_start = o * fan_in;
for (k, &ai) in input_act.iter().enumerate().take(fan_in) {
self.w[l][row_start + k] -= lr * d_o * ai;
}
if l < 2 {
self.b[l][o] -= lr * d_o;
}
}
if l == 0 {
break;
}
let prev_out = self.out_dims[l - 1];
let mut prev_delta = vec![0.0_f64; prev_out];
for (i, pd) in prev_delta.iter_mut().enumerate().take(prev_out) {
let acc: f64 = delta
.iter()
.enumerate()
.take(fan_out)
.map(|(o, &d_o)| self.w[l][o * fan_in + i] * d_o)
.sum();
*pd = if pre[l - 1][i] > 0.0 { acc } else { 0.0 };
}
delta = prev_delta;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SoftSvddConfig {
pub input_dim: usize,
pub hidden1: usize,
pub hidden2: usize,
pub latent_dim: usize,
pub nu: f64,
pub lr: f64,
pub n_epochs: usize,
}
impl SoftSvddConfig {
pub fn validate(&self) -> AnomalyResult<()> {
for (name, d) in [
("input_dim", self.input_dim),
("hidden1", self.hidden1),
("hidden2", self.hidden2),
("latent_dim", self.latent_dim),
] {
if d == 0 {
return Err(AnomalyError::InvalidLayerDims {
msg: format!("{name} must be > 0"),
});
}
}
if !(self.nu > 0.0 && self.nu <= 1.0) {
return Err(AnomalyError::InvalidNu { nu: self.nu as f32 });
}
if self.n_epochs == 0 {
return Err(AnomalyError::Internal {
msg: "n_epochs must be > 0".into(),
});
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SoftSvddFit {
pub w1: Vec<f64>,
pub b1: Vec<f64>,
pub w2: Vec<f64>,
pub b2: Vec<f64>,
pub w3: Vec<f64>,
pub center: Vec<f64>,
pub radius: f64,
pub input_dim: usize,
pub hidden1: usize,
pub hidden2: usize,
pub latent_dim: usize,
}
impl SoftSvddFit {
fn to_mlp(&self, rng: &mut LcgRng) -> AnomalyResult<SoftSvddMlp> {
let mut mlp = SoftSvddMlp::new(
self.input_dim,
self.hidden1,
self.hidden2,
self.latent_dim,
rng,
)?;
mlp.w[0].clone_from(&self.w1);
mlp.b[0].clone_from(&self.b1);
mlp.w[1].clone_from(&self.w2);
mlp.b[1].clone_from(&self.b2);
mlp.w[2].clone_from(&self.w3);
Ok(mlp)
}
}
fn radius_quantile(squared_dists: &[f64], nu: f64) -> f64 {
if squared_dists.is_empty() {
return 0.0;
}
let mut sorted = squared_dists.to_vec();
sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len();
let idx = ((1.0 - nu) * n as f64).floor() as usize;
let idx = idx.min(n - 1);
sorted[idx].max(0.0)
}
fn sq_dist(a: &[f64], b: &[f64]) -> f64 {
a.iter()
.zip(b.iter())
.map(|(ai, bi)| (ai - bi).powi(2))
.sum()
}
pub fn soft_svdd_fit(
x: &[f64],
n: usize,
cfg: &SoftSvddConfig,
seed: u64,
) -> AnomalyResult<SoftSvddFit> {
cfg.validate()?;
if n == 0 {
return Err(AnomalyError::EmptyInput);
}
let d = cfg.input_dim;
if x.len() != n * d {
return Err(AnomalyError::DimensionMismatch {
expected: n * d,
got: x.len(),
});
}
let mut rng = LcgRng::new(seed);
let mut mlp = SoftSvddMlp::new(d, cfg.hidden1, cfg.hidden2, cfg.latent_dim, &mut rng)?;
let mut center = vec![0.0_f64; cfg.latent_dim];
for i in 0..n {
let sample = &x[i * d..(i + 1) * d];
let rep = mlp.forward(sample)?;
for (cj, rj) in center.iter_mut().zip(rep.iter()) {
*cj += rj;
}
}
let inv_n = 1.0 / n as f64;
for cj in &mut center {
*cj *= inv_n;
if cj.abs() < 0.01 {
*cj = 0.01;
}
}
let mut sq_dists: Vec<f64> = (0..n)
.map(|i| {
let rep = mlp
.forward(&x[i * d..(i + 1) * d])
.unwrap_or_else(|_| center.clone());
sq_dist(&rep, ¢er)
})
.collect();
let mut radius_sq = radius_quantile(&sq_dists, cfg.nu);
let scale = 1.0 / (cfg.nu * n as f64);
for _epoch in 0..cfg.n_epochs {
for i in 0..n {
let rep = mlp.forward(&x[i * d..(i + 1) * d])?;
sq_dists[i] = sq_dist(&rep, ¢er);
}
radius_sq = radius_quantile(&sq_dists, cfg.nu);
for i in 0..n {
if sq_dists[i] > radius_sq {
mlp.backward_update(&x[i * d..(i + 1) * d], ¢er, scale, cfg.lr)?;
}
}
}
Ok(SoftSvddFit {
w1: mlp.w[0].clone(),
b1: mlp.b[0].clone(),
w2: mlp.w[1].clone(),
b2: mlp.b[1].clone(),
w3: mlp.w[2].clone(),
center,
radius: radius_sq.sqrt(),
input_dim: cfg.input_dim,
hidden1: cfg.hidden1,
hidden2: cfg.hidden2,
latent_dim: cfg.latent_dim,
})
}
pub fn soft_svdd_score(fit: &SoftSvddFit, x: &[f64], n: usize) -> AnomalyResult<Vec<f64>> {
let d = fit.input_dim;
if n == 0 {
return Err(AnomalyError::EmptyInput);
}
if x.len() != n * d {
return Err(AnomalyError::DimensionMismatch {
expected: n * d,
got: x.len(),
});
}
let mut dummy_rng = LcgRng::new(0);
let mlp = fit.to_mlp(&mut dummy_rng)?;
let r_sq = fit.radius * fit.radius;
let mut scores = Vec::with_capacity(n);
for i in 0..n {
let rep = mlp.forward(&x[i * d..(i + 1) * d])?;
let dist_sq = sq_dist(&rep, &fit.center);
scores.push(dist_sq - r_sq);
}
Ok(scores)
}
pub fn soft_svdd_predict(fit: &SoftSvddFit, x: &[f64], n: usize) -> AnomalyResult<Vec<bool>> {
let scores = soft_svdd_score(fit, x, n)?;
Ok(scores.iter().map(|&s| s > 0.0).collect())
}
pub fn soft_svdd_radius(fit: &SoftSvddFit) -> f64 {
fit.radius
}
#[cfg(test)]
mod tests {
use super::*;
fn make_config(input_dim: usize) -> SoftSvddConfig {
SoftSvddConfig {
input_dim,
hidden1: 8,
hidden2: 6,
latent_dim: 4,
nu: 0.1,
lr: 1e-3,
n_epochs: 5,
}
}
#[test]
fn soft_svdd_fit_returns_finite_radius() {
let cfg = make_config(4);
let x: Vec<f64> = (0..20).map(|i| i as f64 * 0.05).collect();
let fit = soft_svdd_fit(&x, 5, &cfg, 42)
.expect("fit with valid config and seed 42 should succeed");
assert!(
fit.radius.is_finite() && fit.radius >= 0.0,
"radius={}",
fit.radius
);
}
#[test]
fn soft_svdd_scores_are_finite() {
let cfg = make_config(4);
let x_train: Vec<f64> = (0..40).map(|i| i as f64 * 0.02).collect();
let fit = soft_svdd_fit(&x_train, 10, &cfg, 7)
.expect("fit with valid config and seed 7 should succeed");
let x_test: Vec<f64> = vec![0.5, 0.5, 0.5, 0.5, 100.0, 100.0, 100.0, 100.0];
let scores =
soft_svdd_score(&fit, &x_test, 2).expect("score on 2 test samples should succeed");
assert_eq!(scores.len(), 2);
assert!(scores.iter().all(|s| s.is_finite()), "scores={scores:?}");
}
#[test]
fn soft_svdd_outlier_has_higher_score() {
let cfg = make_config(4);
let x_train: Vec<f64> = (0..40).map(|i| (i as f64) * 0.01).collect();
let fit = soft_svdd_fit(&x_train, 10, &cfg, 13)
.expect("fit with valid config and seed 13 should succeed");
let inlier = vec![0.05, 0.05, 0.05, 0.05];
let outlier = vec![999.0, 999.0, 999.0, 999.0];
let s_in =
soft_svdd_score(&fit, &inlier, 1).expect("score on inlier sample should succeed")[0];
let s_out =
soft_svdd_score(&fit, &outlier, 1).expect("score on outlier sample should succeed")[0];
assert!(s_out > s_in, "s_out={s_out} s_in={s_in}");
}
#[test]
fn soft_svdd_predict_extreme_outlier() {
let cfg = SoftSvddConfig {
input_dim: 2,
hidden1: 6,
hidden2: 4,
latent_dim: 2,
nu: 0.05,
lr: 5e-3,
n_epochs: 20,
};
let x_train: Vec<f64> = (0..20)
.flat_map(|i| vec![i as f64 * 0.05, i as f64 * 0.05])
.collect();
let fit = soft_svdd_fit(&x_train, 20, &cfg, 99)
.expect("fit on 20-sample training set should succeed");
let x_test = vec![0.0, 0.0, 1000.0, 1000.0];
let preds =
soft_svdd_predict(&fit, &x_test, 2).expect("predict on 2 test samples should succeed");
assert!(preds[1], "extreme outlier should be anomaly");
}
#[test]
fn soft_svdd_radius_fn() {
let cfg = make_config(3);
let x: Vec<f64> = (0..15).map(|i| i as f64 * 0.1).collect();
let fit = soft_svdd_fit(&x, 5, &cfg, 17)
.expect("fit with valid config and seed 17 should succeed");
assert_eq!(soft_svdd_radius(&fit), fit.radius);
}
#[test]
fn soft_svdd_config_invalid_nu() {
let mut cfg = make_config(4);
cfg.nu = 0.0;
let x: Vec<f64> = vec![0.0; 40];
assert!(soft_svdd_fit(&x, 10, &cfg, 1).is_err());
}
#[test]
fn soft_svdd_config_invalid_dim() {
let mut cfg = make_config(4);
cfg.hidden1 = 0;
let x: Vec<f64> = vec![0.0; 40];
assert!(soft_svdd_fit(&x, 10, &cfg, 1).is_err());
}
}