mod dataset_monitor;
mod live_window;
mod prediction_drift;
#[cfg(feature = "label-drift")]
mod label_drift;
pub use dataset_monitor::{
DatasetMonitor, FeatureConfig, DEFAULT_ALPHA, DEFAULT_DATASET_FRACTION_THRESHOLD,
DEFAULT_PSI_THRESHOLD,
};
pub use live_window::{LiveWindow, WindowMode};
pub use prediction_drift::PredictionDriftMonitor;
#[cfg(feature = "label-drift")]
pub use label_drift::{LabelDriftMonitor, LabelDriftReport};
use crate::distribution::FeatureKind;
use std::fmt;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MetricKind {
Psi,
Kl,
Js,
Ks,
ChiSquare,
}
impl MetricKind {
pub fn higher_is_more_drift(self) -> bool {
matches!(self, MetricKind::Psi | MetricKind::Kl | MetricKind::Js)
}
pub fn is_test(self) -> bool {
matches!(self, MetricKind::Ks | MetricKind::ChiSquare)
}
pub fn label(self) -> &'static str {
match self {
MetricKind::Psi => "PSI",
MetricKind::Kl => "KL",
MetricKind::Js => "JS",
MetricKind::Ks => "KS",
MetricKind::ChiSquare => "Chi2",
}
}
}
impl fmt::Display for MetricKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MetricScore {
pub kind: MetricKind,
pub statistic: f64,
pub p_value: Option<f64>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DriftVerdict {
Stable,
Drifted,
}
impl DriftVerdict {
pub fn is_drifted(self) -> bool {
matches!(self, DriftVerdict::Drifted)
}
}
impl fmt::Display for DriftVerdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
DriftVerdict::Stable => "stable",
DriftVerdict::Drifted => "DRIFTED",
})
}
}
#[derive(Clone, Debug)]
pub struct FeatureDrift {
pub feature: String,
pub kind: FeatureKind,
pub scores: Vec<MetricScore>,
pub primary: MetricKind,
pub threshold: f64,
pub verdict: DriftVerdict,
pub reference_histogram: crate::binning::Histogram,
pub live_histogram: crate::binning::Histogram,
}
impl FeatureDrift {
pub fn score(&self, kind: MetricKind) -> Option<&MetricScore> {
self.scores.iter().find(|s| s.kind == kind)
}
pub fn primary_statistic(&self) -> f64 {
self.score(self.primary)
.map(|s| s.statistic)
.unwrap_or(f64::NAN)
}
pub fn drifted(&self) -> bool {
self.verdict.is_drifted()
}
}
#[derive(Clone, Debug)]
pub struct DriftReport {
pub features: Vec<FeatureDrift>,
pub dataset_fraction_threshold: f64,
}
impl DriftReport {
pub fn drifted_features(&self) -> impl Iterator<Item = &FeatureDrift> {
self.features.iter().filter(|f| f.drifted())
}
pub fn drifted_fraction(&self) -> f64 {
if self.features.is_empty() {
return 0.0;
}
self.drifted_features().count() as f64 / self.features.len() as f64
}
pub fn dataset_drift_detected(&self) -> bool {
self.drifted_fraction() > self.dataset_fraction_threshold
}
pub fn dataset_verdict(&self) -> DriftVerdict {
if self.dataset_drift_detected() {
DriftVerdict::Drifted
} else {
DriftVerdict::Stable
}
}
}
impl fmt::Display for DriftReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "DriftReport ({} features)", self.features.len())?;
for feat in &self.features {
write!(f, " {:<20} {:>8} ", feat.feature, feat.verdict.to_string())?;
let parts: Vec<String> = feat
.scores
.iter()
.map(|s| match s.p_value {
Some(p) => format!("{}={:.4} (p={:.4})", s.kind, s.statistic, p),
None => format!("{}={:.4}", s.kind, s.statistic),
})
.collect();
writeln!(f, "{}", parts.join(", "))?;
}
writeln!(
f,
" dataset: {} ({:.0}% of features drifted, threshold {:.0}%)",
self.dataset_verdict(),
self.drifted_fraction() * 100.0,
self.dataset_fraction_threshold * 100.0,
)
}
}