///|
/// Exponentially weighted drift detector for an online metric.
pub struct EwmaDetector {
decay : Double
threshold : Double
level : Double
initialized : Bool
updates : Int
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn EwmaDetector::new(decay : Double, threshold : Double) -> EwmaDetector {
if decay <= 0.0 || decay > 1.0 || threshold <= 0.0 {
abort("EwmaDetector: invalid decay or threshold")
}
{ decay, threshold, level: 0.0, initialized: false, updates: 0 }
}
///|
pub fn EwmaDetector::update(
self : EwmaDetector,
value : Double,
) -> EwmaDetector {
let level = if self.initialized {
self.decay * value + (1.0 - self.decay) * self.level
} else {
value
}
{ ..self, level, initialized: true, updates: self.updates + 1 }
}
///|
pub fn EwmaDetector::level(self : EwmaDetector) -> Double {
self.level
}
///|
pub fn EwmaDetector::updates(self : EwmaDetector) -> Int {
self.updates
}
///|
pub fn EwmaDetector::deviation(self : EwmaDetector, value : Double) -> Double {
(value - self.level).abs()
}
///|
pub fn EwmaDetector::is_drift(self : EwmaDetector, value : Double) -> Bool {
self.deviation(value) > self.threshold
}
///|
pub fn EwmaDetector::reset(self : EwmaDetector) -> EwmaDetector {
{ ..self, level: 0.0, initialized: false, updates: 0 }
}
///|
/// CUSUM detector for a mean shift with a configurable allowance.
pub struct CusumDetector {
target : Double
allowance : Double
threshold : Double
positive : Double
negative : Double
updates : Int
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn CusumDetector::new(
target : Double,
allowance : Double,
threshold : Double,
) -> CusumDetector {
if allowance < 0.0 || threshold <= 0.0 {
abort("CusumDetector: invalid allowance or threshold")
}
{ target, allowance, threshold, positive: 0.0, negative: 0.0, updates: 0 }
}
///|
pub fn CusumDetector::update(
self : CusumDetector,
value : Double,
) -> CusumDetector {
let delta = value - self.target
let positive = (self.positive + delta - self.allowance).max(0.0)
let negative = (self.negative - delta - self.allowance).max(0.0)
{ ..self, positive, negative, updates: self.updates + 1 }
}
///|
pub fn CusumDetector::positive_score(self : CusumDetector) -> Double {
self.positive
}
///|
pub fn CusumDetector::negative_score(self : CusumDetector) -> Double {
self.negative
}
///|
pub fn CusumDetector::is_drift(self : CusumDetector) -> Bool {
self.positive >= self.threshold || self.negative >= self.threshold
}
///|
pub fn CusumDetector::direction(self : CusumDetector) -> Int {
if self.positive >= self.threshold {
1
} else if self.negative >= self.threshold {
-1
} else {
0
}
}
///|
pub fn CusumDetector::reset(self : CusumDetector) -> CusumDetector {
{ ..self, positive: 0.0, negative: 0.0, updates: 0 }
}
///|
pub fn detect_step(
values : Array[Double],
baseline : Double,
threshold : Double,
) -> Int? {
let detector = CusumDetector::new(baseline, 0.0, threshold)
let mut current = detector
for index, value in values {
current = current.update(value)
if current.is_drift() {
return Some(index)
}
}
None
}
///|
pub fn rolling_z_scores(values : Array[Double], window : Int) -> Array[Double] {
if window <= 0 {
abort("rolling_z_scores: window must be positive")
}
let result = Array::make(values.length(), 0.0)
let mut rolling = RollingWindow::new(window)
for index, value in values {
rolling = rolling.push(value)
let deviation = rolling.variance().sqrt()
result[index] = if deviation == 0.0 {
0.0
} else {
(value - rolling.mean()) / deviation
}
}
result
}