use crate::solvers::alm::{AlmDistribution, AlmRegressor};
use crate::solvers::lm_dynamic::InformationCriterion;
use crate::solvers::traits::{FittedRegressor, Regressor};
use faer::{Col, Mat};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DemandType {
Regular,
Intermittent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DemandDistribution {
Poisson,
NegativeBinomial,
Geometric,
Normal,
Gamma,
LogNormal,
RectifiedNormal,
}
impl DemandDistribution {
pub fn to_alm_distribution(&self) -> AlmDistribution {
match self {
DemandDistribution::Poisson => AlmDistribution::Poisson,
DemandDistribution::NegativeBinomial => AlmDistribution::NegativeBinomial,
DemandDistribution::Geometric => AlmDistribution::Geometric,
DemandDistribution::Normal => AlmDistribution::Normal,
DemandDistribution::Gamma => AlmDistribution::Gamma,
DemandDistribution::LogNormal => AlmDistribution::LogNormal,
DemandDistribution::RectifiedNormal => AlmDistribution::RectifiedNormal,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AnomalyType {
None,
Stockout,
NewProduct,
ObsoleteProduct,
HighOutlier,
LowOutlier,
}
#[derive(Debug, Clone)]
pub struct DistributionParameters {
pub mean: f64,
pub variance: f64,
pub shape: Option<f64>,
pub zero_prob: Option<f64>,
pub scale: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct DemandClassification {
pub demand_type: DemandType,
pub is_fractional: bool,
pub distribution: DemandDistribution,
pub parameters: DistributionParameters,
pub anomalies: Vec<AnomalyType>,
pub ic_values: HashMap<DemandDistribution, f64>,
pub zero_proportion: f64,
pub n_observations: usize,
}
impl DemandClassification {
pub fn has_stockouts(&self) -> bool {
self.anomalies.contains(&AnomalyType::Stockout)
}
pub fn anomaly_counts(&self) -> HashMap<AnomalyType, usize> {
let mut counts = HashMap::new();
for anomaly in &self.anomalies {
*counts.entry(*anomaly).or_insert(0) += 1;
}
counts
}
pub fn is_new_product(&self) -> bool {
self.anomalies.contains(&AnomalyType::NewProduct)
}
pub fn is_obsolete_product(&self) -> bool {
self.anomalies.contains(&AnomalyType::ObsoleteProduct)
}
}
#[derive(Debug, Clone)]
pub struct AidClassifier {
#[allow(dead_code)]
anomaly_alpha: f64,
intermittent_threshold: f64,
detect_anomalies: bool,
ic_type: InformationCriterion,
}
impl Default for AidClassifier {
fn default() -> Self {
Self {
anomaly_alpha: 0.05,
intermittent_threshold: 0.3,
detect_anomalies: true,
ic_type: InformationCriterion::AICc,
}
}
}
impl AidClassifier {
pub fn new() -> Self {
Self::default()
}
pub fn builder() -> AidClassifierBuilder {
AidClassifierBuilder::default()
}
pub fn classify(&self, y: &Col<f64>) -> DemandClassification {
let n = y.nrows();
if n == 0 {
return DemandClassification {
demand_type: DemandType::Regular,
is_fractional: false,
distribution: DemandDistribution::Normal,
parameters: DistributionParameters {
mean: 0.0,
variance: 0.0,
shape: None,
zero_prob: None,
scale: None,
},
anomalies: Vec::new(),
ic_values: HashMap::new(),
zero_proportion: 0.0,
n_observations: 0,
};
}
let is_fractional = self.detect_fractional(y);
let zero_count = y.iter().filter(|&&v| v == 0.0).count();
let zero_proportion = zero_count as f64 / n as f64;
let y_mean: f64 = y.iter().sum::<f64>() / n as f64;
let y_var: f64 = y.iter().map(|&v| (v - y_mean).powi(2)).sum::<f64>() / n as f64;
let demand_type = if zero_proportion > self.intermittent_threshold {
DemandType::Intermittent
} else {
DemandType::Regular
};
let candidates = self.get_candidate_distributions(demand_type, is_fractional);
let mut ic_values: HashMap<DemandDistribution, f64> = HashMap::new();
let mut best_dist = candidates[0];
let mut best_ic = f64::INFINITY;
let mut best_params = DistributionParameters {
mean: y_mean,
variance: y_var,
shape: None,
zero_prob: Some(zero_proportion),
scale: None,
};
for dist in candidates {
if let Some((ic, params)) = self.fit_distribution(y, dist) {
ic_values.insert(dist, ic);
if ic < best_ic {
best_ic = ic;
best_dist = dist;
best_params = params;
}
}
}
let anomalies = if self.detect_anomalies {
self.detect_anomalies_impl(y, best_dist, &best_params)
} else {
vec![AnomalyType::None; n]
};
DemandClassification {
demand_type,
is_fractional,
distribution: best_dist,
parameters: best_params,
anomalies,
ic_values,
zero_proportion,
n_observations: n,
}
}
fn detect_fractional(&self, y: &Col<f64>) -> bool {
y.iter().any(|&v| v != v.round())
}
fn get_candidate_distributions(
&self,
demand_type: DemandType,
is_fractional: bool,
) -> Vec<DemandDistribution> {
match (demand_type, is_fractional) {
(DemandType::Regular, false) => vec![
DemandDistribution::Poisson,
DemandDistribution::NegativeBinomial,
DemandDistribution::Normal,
],
(DemandType::Regular, true) => vec![
DemandDistribution::Normal,
DemandDistribution::Gamma,
DemandDistribution::LogNormal,
],
(DemandType::Intermittent, false) => vec![
DemandDistribution::NegativeBinomial,
DemandDistribution::Geometric,
DemandDistribution::Poisson,
],
(DemandType::Intermittent, true) => vec![
DemandDistribution::RectifiedNormal,
DemandDistribution::Gamma,
DemandDistribution::Normal,
],
}
}
fn fit_distribution(
&self,
y: &Col<f64>,
dist: DemandDistribution,
) -> Option<(f64, DistributionParameters)> {
let n = y.nrows();
let x = Mat::from_fn(n, 1, |_, _| 1.0);
let alm_dist = dist.to_alm_distribution();
let has_non_positive = y.iter().any(|&v| v <= 0.0);
if has_non_positive
&& matches!(
dist,
DemandDistribution::Gamma | DemandDistribution::LogNormal
)
{
return None;
}
let model = AlmRegressor::builder()
.distribution(alm_dist)
.with_intercept(false) .compute_inference(false)
.build();
match model.fit(&x, y) {
Ok(fitted) => {
let result = fitted.result();
let scale = fitted.scale();
let ll = result.log_likelihood;
let k = result.n_parameters;
let ic = self.ic_type.compute(ll, k, n);
let mean = result.fitted_values.iter().sum::<f64>() / n as f64;
let residuals = &result.residuals;
let variance: f64 = residuals.iter().map(|&r| r.powi(2)).sum::<f64>() / n as f64;
let zero_count = y.iter().filter(|&&v| v == 0.0).count();
let zero_prob = zero_count as f64 / n as f64;
let shape = match dist {
DemandDistribution::Gamma => {
if variance > 0.0 {
Some(mean.powi(2) / variance)
} else {
Some(1.0)
}
}
DemandDistribution::NegativeBinomial => {
if variance > mean && mean > 0.0 {
Some(mean.powi(2) / (variance - mean))
} else {
Some(1.0)
}
}
_ => None,
};
Some((
ic,
DistributionParameters {
mean,
variance,
shape,
zero_prob: Some(zero_prob),
scale: Some(scale),
},
))
}
Err(_) => None,
}
}
fn detect_anomalies_impl(
&self,
y: &Col<f64>,
_dist: DemandDistribution,
params: &DistributionParameters,
) -> Vec<AnomalyType> {
let n = y.nrows();
let mut anomalies = vec![AnomalyType::None; n];
if n == 0 {
return anomalies;
}
let mean = params.mean;
let std_dev = params.variance.sqrt();
let mut leading_zeros = 0;
for i in 0..n {
if y[i] == 0.0 {
leading_zeros += 1;
} else {
break;
}
}
if leading_zeros > 0 && (leading_zeros as f64 / n as f64) > 0.1 {
for anomaly in anomalies.iter_mut().take(leading_zeros) {
*anomaly = AnomalyType::NewProduct;
}
}
let mut trailing_zeros = 0;
for i in (0..n).rev() {
if y[i] == 0.0 {
trailing_zeros += 1;
} else {
break;
}
}
if trailing_zeros > 0 && (trailing_zeros as f64 / n as f64) > 0.1 {
for anomaly in anomalies.iter_mut().skip(n - trailing_zeros) {
if *anomaly == AnomalyType::None {
*anomaly = AnomalyType::ObsoleteProduct;
}
}
}
if mean > 0.0 {
let _z_threshold = 2.0;
for i in leading_zeros..(n - trailing_zeros) {
if y[i] == 0.0 && anomalies[i] == AnomalyType::None {
let before_nonzero = if i > 0 {
(0..i).rev().take(3).any(|j| y[j] > 0.0)
} else {
false
};
let after_nonzero = ((i + 1)..n).take(3).any(|j| y[j] > 0.0);
if before_nonzero && after_nonzero {
anomalies[i] = AnomalyType::Stockout;
}
}
}
}
if std_dev > 0.0 {
let high_threshold = mean + 3.0 * std_dev;
let low_threshold = (mean - 3.0 * std_dev).max(0.0);
for i in 0..n {
if anomalies[i] == AnomalyType::None {
if y[i] > high_threshold {
anomalies[i] = AnomalyType::HighOutlier;
} else if y[i] > 0.0 && y[i] < low_threshold && mean > 0.0 {
anomalies[i] = AnomalyType::LowOutlier;
}
}
}
}
anomalies
}
}
#[derive(Debug, Clone)]
pub struct AidClassifierBuilder {
anomaly_alpha: f64,
intermittent_threshold: f64,
detect_anomalies: bool,
ic_type: InformationCriterion,
}
impl Default for AidClassifierBuilder {
fn default() -> Self {
Self {
anomaly_alpha: 0.05,
intermittent_threshold: 0.3,
detect_anomalies: true,
ic_type: InformationCriterion::AICc,
}
}
}
impl AidClassifierBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn anomaly_alpha(mut self, alpha: f64) -> Self {
self.anomaly_alpha = alpha.clamp(0.001, 0.5);
self
}
pub fn intermittent_threshold(mut self, threshold: f64) -> Self {
self.intermittent_threshold = threshold.clamp(0.0, 1.0);
self
}
pub fn detect_anomalies(mut self, detect: bool) -> Self {
self.detect_anomalies = detect;
self
}
pub fn ic(mut self, ic: InformationCriterion) -> Self {
self.ic_type = ic;
self
}
pub fn build(self) -> AidClassifier {
AidClassifier {
anomaly_alpha: self.anomaly_alpha,
intermittent_threshold: self.intermittent_threshold,
detect_anomalies: self.detect_anomalies,
ic_type: self.ic_type,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_defaults() {
let classifier = AidClassifier::builder().build();
assert_eq!(classifier.ic_type, InformationCriterion::AICc);
assert!(classifier.detect_anomalies);
}
#[test]
fn test_builder_custom() {
let classifier = AidClassifier::builder()
.intermittent_threshold(0.5)
.detect_anomalies(false)
.ic(InformationCriterion::BIC)
.build();
assert!(!classifier.detect_anomalies);
assert_eq!(classifier.ic_type, InformationCriterion::BIC);
}
#[test]
fn test_regular_count_demand() {
let y = Col::from_fn(50, |i| ((i % 5) + 5) as f64);
let result = AidClassifier::new().classify(&y);
assert_eq!(result.demand_type, DemandType::Regular);
assert!(!result.is_fractional);
assert_eq!(result.zero_proportion, 0.0);
}
#[test]
fn test_intermittent_demand() {
let y = Col::from_fn(50, |i| if i % 3 == 0 { 5.0 } else { 0.0 });
let result = AidClassifier::builder()
.intermittent_threshold(0.3)
.build()
.classify(&y);
assert_eq!(result.demand_type, DemandType::Intermittent);
assert!(result.zero_proportion > 0.5);
}
#[test]
fn test_fractional_demand() {
let y = Col::from_fn(30, |i| 5.5 + (i as f64 * 0.1).sin());
let result = AidClassifier::new().classify(&y);
assert!(result.is_fractional);
assert_eq!(result.demand_type, DemandType::Regular);
}
#[test]
fn test_stockout_detection() {
let mut y = Col::from_fn(30, |_| 10.0);
y[10] = 0.0; y[15] = 0.0;
let result = AidClassifier::builder()
.detect_anomalies(true)
.build()
.classify(&y);
assert!(result.has_stockouts());
}
#[test]
fn test_new_product_detection() {
let y = Col::from_fn(30, |i| if i < 10 { 0.0 } else { 5.0 });
let result = AidClassifier::new().classify(&y);
assert!(result.is_new_product());
for i in 0..10 {
assert_eq!(result.anomalies[i], AnomalyType::NewProduct);
}
}
#[test]
fn test_obsolete_product_detection() {
let y = Col::from_fn(30, |i| if i < 20 { 5.0 } else { 0.0 });
let result = AidClassifier::new().classify(&y);
assert!(result.is_obsolete_product());
for i in 20..30 {
assert_eq!(result.anomalies[i], AnomalyType::ObsoleteProduct);
}
}
#[test]
fn test_empty_data() {
let y = Col::zeros(0);
let result = AidClassifier::new().classify(&y);
assert_eq!(result.n_observations, 0);
assert!(result.anomalies.is_empty());
}
#[test]
fn test_ic_values_populated() {
let y = Col::from_fn(50, |i| (i + 1) as f64);
let result = AidClassifier::new().classify(&y);
assert!(!result.ic_values.is_empty());
}
#[test]
fn test_demand_distribution_to_alm() {
assert_eq!(
DemandDistribution::Poisson.to_alm_distribution(),
AlmDistribution::Poisson
);
assert_eq!(
DemandDistribution::Normal.to_alm_distribution(),
AlmDistribution::Normal
);
assert_eq!(
DemandDistribution::Gamma.to_alm_distribution(),
AlmDistribution::Gamma
);
}
#[test]
fn test_anomaly_counts() {
let mut y = Col::from_fn(30, |_| 10.0);
y[10] = 0.0;
y[15] = 0.0;
y[25] = 100.0;
let result = AidClassifier::new().classify(&y);
let counts = result.anomaly_counts();
assert!(
counts.get(&AnomalyType::Stockout).unwrap_or(&0) > &0
|| counts.get(&AnomalyType::HighOutlier).unwrap_or(&0) > &0
);
}
}