mod page_hinkley;
pub use page_hinkley::{PageHinkleyChange, PageHinkleyDetector};
use crate::binning::{BinDefinition, Histogram};
use crate::distribution::ReferenceDistribution;
use crate::error::{DriftError, Result};
use crate::metrics::psi;
use sketches_ddsketch::{Config, DDSketch};
pub const DEFAULT_PROBES: usize = 512;
pub struct OnlineDistribution {
sketch: DDSketch,
probes: usize,
}
impl Default for OnlineDistribution {
fn default() -> Self {
Self::new()
}
}
impl OnlineDistribution {
pub fn new() -> Self {
Self {
sketch: DDSketch::new(Config::defaults()),
probes: DEFAULT_PROBES,
}
}
pub fn with_probes(mut self, probes: usize) -> Self {
self.probes = probes.max(1);
self
}
pub fn update(&mut self, value: f64) {
if value.is_finite() {
self.sketch.add(value);
}
}
pub fn count(&self) -> usize {
self.sketch.count()
}
pub fn is_empty(&self) -> bool {
self.sketch.count() == 0
}
pub fn snapshot_histogram(&self, edges: &[f64]) -> Result<Histogram> {
let n_bins = edges.len().saturating_sub(1);
if n_bins == 0 {
return Err(DriftError::InvalidBinCount(n_bins));
}
let mut counts = vec![0.0f64; n_bins];
if self.sketch.count() > 0 {
for k in 0..self.probes {
let q = (k as f64 + 0.5) / self.probes as f64;
if let Ok(Some(v)) = self.sketch.quantile(q) {
counts[bin_index(edges, v, n_bins)] += 1.0;
}
}
}
Histogram::new(
BinDefinition::Continuous {
edges: edges.to_vec(),
},
counts,
)
}
}
fn bin_index(edges: &[f64], v: f64, n_bins: usize) -> usize {
if v <= edges[0] {
0
} else if v >= edges[n_bins] {
n_bins - 1
} else {
match edges.partition_point(|&e| e <= v) {
0 => 0,
p if p >= n_bins => n_bins - 1,
p => p - 1,
}
}
}
struct StreamFeature {
name: String,
reference_hist: Histogram,
edges: Vec<f64>,
online: OnlineDistribution,
threshold: f64,
}
pub struct StreamingMonitor {
features: Vec<StreamFeature>,
dataset_fraction_threshold: f64,
}
impl Default for StreamingMonitor {
fn default() -> Self {
Self::new()
}
}
impl StreamingMonitor {
pub fn new() -> Self {
Self {
features: Vec::new(),
dataset_fraction_threshold: 0.5,
}
}
pub fn with_dataset_fraction_threshold(mut self, fraction: f64) -> Self {
self.dataset_fraction_threshold = fraction;
self
}
pub fn add_feature(&mut self, reference: &ReferenceDistribution, threshold: f64) -> Result<()> {
let edges = match reference.histogram().bins() {
BinDefinition::Continuous { edges } => edges.clone(),
BinDefinition::Categorical { .. } => {
return Err(DriftError::InvalidConfig(format!(
"streaming feature '{}' must be continuous",
reference.name()
)))
}
};
self.features.push(StreamFeature {
name: reference.name().to_string(),
reference_hist: reference.histogram().clone(),
edges,
online: OnlineDistribution::new(),
threshold,
});
Ok(())
}
pub fn update(&mut self, feature: &str, value: f64) -> Result<()> {
let f = self
.features
.iter_mut()
.find(|f| f.name == feature)
.ok_or_else(|| DriftError::UnknownFeature(feature.to_string()))?;
f.online.update(value);
Ok(())
}
pub fn feature_psi(&self, feature: &str) -> Result<f64> {
let f = self
.features
.iter()
.find(|f| f.name == feature)
.ok_or_else(|| DriftError::UnknownFeature(feature.to_string()))?;
let live = f.online.snapshot_histogram(&f.edges)?;
psi(&f.reference_hist, &live)
}
pub fn report(&self) -> Result<StreamingReport> {
let mut features = Vec::with_capacity(self.features.len());
for f in &self.features {
let live = f.online.snapshot_histogram(&f.edges)?;
let psi_value = psi(&f.reference_hist, &live)?;
features.push(StreamFeatureDrift {
feature: f.name.clone(),
psi: psi_value,
count: f.online.count(),
threshold: f.threshold,
drifted: psi_value > f.threshold,
});
}
Ok(StreamingReport {
features,
dataset_fraction_threshold: self.dataset_fraction_threshold,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct StreamFeatureDrift {
pub feature: String,
pub psi: f64,
pub count: usize,
pub threshold: f64,
pub drifted: bool,
}
#[derive(Clone, Debug)]
pub struct StreamingReport {
pub features: Vec<StreamFeatureDrift>,
pub dataset_fraction_threshold: f64,
}
impl StreamingReport {
pub fn drifted_fraction(&self) -> f64 {
if self.features.is_empty() {
return 0.0;
}
self.features.iter().filter(|f| f.drifted).count() as f64 / self.features.len() as f64
}
pub fn dataset_drift_detected(&self) -> bool {
self.drifted_fraction() > self.dataset_fraction_threshold
}
}