Skip to main content

rill_ml/
weighted.rs

1//! Additive weighted-learning APIs and Preview weighted accumulators.
2//!
3//! The frozen unweighted traits and state layouts are unchanged. A zero
4//! weight validates inputs but performs no state update and does not increment
5//! `samples_seen`.
6
7use crate::error::{RillError, checked_finite_add, checked_increment, ensure_finite};
8#[cfg(feature = "serde")]
9use crate::persistence::ValidateState;
10use crate::traits::{OnlineBinaryClassifier, OnlineRegressor};
11
12/// Online statistic accepting finite, non-negative sample weights.
13pub trait WeightedStatistic {
14    /// Incorporate a value with a caller-defined weight.
15    fn update_weighted(&mut self, value: f64, weight: f64) -> Result<(), RillError>;
16    /// Sum of accepted positive weights.
17    fn total_weight(&self) -> f64;
18    /// Number of accepted positive-weight observations.
19    fn samples_seen(&self) -> u64;
20    /// Reset values, total weight, and sample count.
21    fn reset(&mut self);
22}
23
24/// Additive weighted update contract for online regressors.
25pub trait WeightedOnlineRegressor: OnlineRegressor {
26    /// Learn from a finite non-negative weighted sample.
27    fn learn_weighted(
28        &mut self,
29        features: &[f64],
30        target: f64,
31        weight: f64,
32    ) -> Result<(), RillError>;
33}
34
35/// Additive weighted update contract for online binary classifiers.
36pub trait WeightedOnlineBinaryClassifier: OnlineBinaryClassifier {
37    /// Learn from a finite non-negative weighted sample.
38    fn learn_weighted(
39        &mut self,
40        features: &[f64],
41        target: bool,
42        weight: f64,
43    ) -> Result<(), RillError>;
44}
45
46/// Validate the shared weight contract.
47pub fn validate_weight(weight: f64) -> Result<(), RillError> {
48    ensure_finite("weight", weight)?;
49    if weight < 0.0 {
50        return Err(RillError::InvalidParameter {
51            name: "weight",
52            value: weight,
53        });
54    }
55    Ok(())
56}
57
58/// Preview O(1)-space weighted arithmetic mean.
59#[derive(Debug, Clone, Default, PartialEq)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
62pub struct WeightedMean {
63    total_weight: f64,
64    mean: f64,
65    samples_seen: u64,
66}
67
68impl WeightedMean {
69    /// Create an empty weighted mean.
70    pub const fn new() -> Self {
71        Self {
72            total_weight: 0.0,
73            mean: 0.0,
74            samples_seen: 0,
75        }
76    }
77
78    /// Current weighted mean, or `None` before a positive weight is seen.
79    pub fn value(&self) -> Option<f64> {
80        (self.total_weight > 0.0).then_some(self.mean)
81    }
82}
83
84impl WeightedStatistic for WeightedMean {
85    fn update_weighted(&mut self, value: f64, weight: f64) -> Result<(), RillError> {
86        ensure_finite("value", value)?;
87        validate_weight(weight)?;
88        if weight == 0.0 {
89            return Ok(());
90        }
91        let next_weight = checked_finite_add(self.total_weight, weight, "total weight")?;
92        let delta = value - self.mean;
93        ensure_finite("weighted mean delta", delta)?;
94        let next_mean = self.mean + delta * (weight / next_weight);
95        ensure_finite("weighted mean", next_mean)?;
96        let next_samples = checked_increment(self.samples_seen, "weighted mean sample")?;
97        self.total_weight = next_weight;
98        self.mean = next_mean;
99        self.samples_seen = next_samples;
100        Ok(())
101    }
102
103    fn total_weight(&self) -> f64 {
104        self.total_weight
105    }
106
107    fn samples_seen(&self) -> u64 {
108        self.samples_seen
109    }
110
111    fn reset(&mut self) {
112        *self = Self::new();
113    }
114}
115
116/// Preview O(1)-space weighted population variance (West's update).
117#[derive(Debug, Clone, Default, PartialEq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
120pub struct WeightedVariance {
121    total_weight: f64,
122    mean: f64,
123    m2: f64,
124    samples_seen: u64,
125}
126
127impl WeightedVariance {
128    /// Create an empty weighted variance.
129    pub const fn new() -> Self {
130        Self {
131            total_weight: 0.0,
132            mean: 0.0,
133            m2: 0.0,
134            samples_seen: 0,
135        }
136    }
137
138    /// Weighted mean, if any positive weight has been observed.
139    pub fn mean(&self) -> Option<f64> {
140        (self.total_weight > 0.0).then_some(self.mean)
141    }
142
143    /// Population variance `Σw(x-μ)² / Σw`.
144    pub fn value(&self) -> Option<f64> {
145        (self.total_weight > 0.0).then_some(self.m2 / self.total_weight)
146    }
147}
148
149impl WeightedStatistic for WeightedVariance {
150    fn update_weighted(&mut self, value: f64, weight: f64) -> Result<(), RillError> {
151        ensure_finite("value", value)?;
152        validate_weight(weight)?;
153        if weight == 0.0 {
154            return Ok(());
155        }
156        let next_weight = checked_finite_add(self.total_weight, weight, "total weight")?;
157        let delta = value - self.mean;
158        ensure_finite("weighted variance delta", delta)?;
159        let ratio = weight / next_weight;
160        let next_mean = self.mean + ratio * delta;
161        ensure_finite("weighted variance mean", next_mean)?;
162        let m2_delta = weight * delta * (value - next_mean);
163        ensure_finite("weighted variance m2 delta", m2_delta)?;
164        let next_m2 = checked_finite_add(self.m2, m2_delta, "weighted variance m2")?;
165        if next_m2 < -f64::EPSILON * next_weight.max(1.0) {
166            return Err(RillError::InvalidState(
167                "weighted variance became negative".to_owned(),
168            ));
169        }
170        let next_samples = checked_increment(self.samples_seen, "weighted variance sample")?;
171        self.total_weight = next_weight;
172        self.mean = next_mean;
173        self.m2 = next_m2.max(0.0);
174        self.samples_seen = next_samples;
175        Ok(())
176    }
177
178    fn total_weight(&self) -> f64 {
179        self.total_weight
180    }
181
182    fn samples_seen(&self) -> u64 {
183        self.samples_seen
184    }
185
186    fn reset(&mut self) {
187        *self = Self::new();
188    }
189}
190
191/// Preview weighted exponentially decayed mean.
192///
193/// A sample weight `w` uses effective smoothing
194/// `1 - (1 - alpha)^w`, matching repeated integer-weight updates while also
195/// supporting fractional weights.
196#[derive(Debug, Clone, PartialEq)]
197#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
198#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
199pub struct WeightedExponentiallyWeightedMean {
200    alpha: f64,
201    total_weight: f64,
202    mean: f64,
203    samples_seen: u64,
204}
205
206impl WeightedExponentiallyWeightedMean {
207    /// Create an accumulator with `alpha` in `(0, 1]`.
208    pub fn new(alpha: f64) -> Result<Self, RillError> {
209        ensure_finite("alpha", alpha)?;
210        if !(0.0 < alpha && alpha <= 1.0) {
211            return Err(RillError::InvalidParameter {
212                name: "alpha",
213                value: alpha,
214            });
215        }
216        Ok(Self {
217            alpha,
218            total_weight: 0.0,
219            mean: 0.0,
220            samples_seen: 0,
221        })
222    }
223
224    /// Current value, or `None` before a positive weight is observed.
225    pub fn value(&self) -> Option<f64> {
226        (self.total_weight > 0.0).then_some(self.mean)
227    }
228
229    /// Configured base smoothing factor.
230    pub fn alpha(&self) -> f64 {
231        self.alpha
232    }
233}
234
235impl WeightedStatistic for WeightedExponentiallyWeightedMean {
236    fn update_weighted(&mut self, value: f64, weight: f64) -> Result<(), RillError> {
237        ensure_finite("value", value)?;
238        validate_weight(weight)?;
239        if weight == 0.0 {
240            return Ok(());
241        }
242        let next_weight = checked_finite_add(self.total_weight, weight, "total weight")?;
243        let next_samples = checked_increment(self.samples_seen, "weighted EW mean sample")?;
244        let next_mean = if self.total_weight == 0.0 {
245            value
246        } else {
247            let effective_alpha = 1.0 - (1.0 - self.alpha).powf(weight);
248            ensure_finite("effective alpha", effective_alpha)?;
249            self.mean + effective_alpha * (value - self.mean)
250        };
251        ensure_finite("weighted EW mean", next_mean)?;
252        self.total_weight = next_weight;
253        self.samples_seen = next_samples;
254        self.mean = next_mean;
255        Ok(())
256    }
257
258    fn total_weight(&self) -> f64 {
259        self.total_weight
260    }
261
262    fn samples_seen(&self) -> u64 {
263        self.samples_seen
264    }
265
266    fn reset(&mut self) {
267        self.total_weight = 0.0;
268        self.mean = 0.0;
269        self.samples_seen = 0;
270    }
271}
272
273/// Preview weighted mean absolute error.
274#[derive(Debug, Clone, Default, PartialEq)]
275#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
276#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
277pub struct WeightedMae {
278    errors: WeightedMean,
279}
280
281impl WeightedMae {
282    /// Update with a weighted truth/prediction pair.
283    pub fn update(&mut self, truth: f64, prediction: f64, weight: f64) -> Result<(), RillError> {
284        ensure_finite("truth", truth)?;
285        ensure_finite("prediction", prediction)?;
286        let error = (truth - prediction).abs();
287        ensure_finite("absolute error", error)?;
288        self.errors.update_weighted(error, weight)
289    }
290
291    /// Current weighted MAE.
292    pub fn value(&self) -> Option<f64> {
293        self.errors.value()
294    }
295
296    /// Sum of weights.
297    pub fn total_weight(&self) -> f64 {
298        self.errors.total_weight()
299    }
300
301    /// Number of positive-weight observations.
302    pub fn samples_seen(&self) -> u64 {
303        self.errors.samples_seen()
304    }
305
306    /// Reset all metric state.
307    pub fn reset(&mut self) {
308        self.errors.reset();
309    }
310}
311
312/// Preview weighted mean squared error.
313#[derive(Debug, Clone, Default, PartialEq)]
314#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
315#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
316pub struct WeightedMse {
317    errors: WeightedMean,
318}
319
320impl WeightedMse {
321    /// Update with a weighted truth/prediction pair.
322    pub fn update(&mut self, truth: f64, prediction: f64, weight: f64) -> Result<(), RillError> {
323        ensure_finite("truth", truth)?;
324        ensure_finite("prediction", prediction)?;
325        let error = truth - prediction;
326        let squared = error * error;
327        ensure_finite("squared error", squared)?;
328        self.errors.update_weighted(squared, weight)
329    }
330
331    /// Current weighted MSE.
332    pub fn value(&self) -> Option<f64> {
333        self.errors.value()
334    }
335
336    /// Sum of weights.
337    pub fn total_weight(&self) -> f64 {
338        self.errors.total_weight()
339    }
340
341    /// Number of positive-weight observations.
342    pub fn samples_seen(&self) -> u64 {
343        self.errors.samples_seen()
344    }
345
346    /// Reset all metric state.
347    pub fn reset(&mut self) {
348        self.errors.reset();
349    }
350}
351
352#[cfg(feature = "serde")]
353fn validate_weighted_moments(
354    total_weight: f64,
355    samples_seen: u64,
356    values: &[(&'static str, f64)],
357) -> Result<(), RillError> {
358    ensure_finite("total weight", total_weight)?;
359    if total_weight < 0.0 || (samples_seen == 0) != (total_weight == 0.0) {
360        return Err(RillError::InvalidState(
361            "weighted state has inconsistent count and total weight".to_owned(),
362        ));
363    }
364    for &(name, value) in values {
365        ensure_finite(name, value)?;
366    }
367    Ok(())
368}
369
370#[cfg(feature = "serde")]
371impl ValidateState for WeightedMean {
372    fn validate_state(&self) -> Result<(), RillError> {
373        validate_weighted_moments(self.total_weight, self.samples_seen, &[("mean", self.mean)])
374    }
375}
376
377#[cfg(feature = "serde")]
378impl ValidateState for WeightedVariance {
379    fn validate_state(&self) -> Result<(), RillError> {
380        validate_weighted_moments(
381            self.total_weight,
382            self.samples_seen,
383            &[("mean", self.mean), ("m2", self.m2)],
384        )?;
385        if self.m2 < 0.0 {
386            return Err(RillError::InvalidState(
387                "weighted variance m2 must be non-negative".to_owned(),
388            ));
389        }
390        Ok(())
391    }
392}
393
394#[cfg(feature = "serde")]
395impl ValidateState for WeightedExponentiallyWeightedMean {
396    fn validate_state(&self) -> Result<(), RillError> {
397        if !(self.alpha.is_finite() && 0.0 < self.alpha && self.alpha <= 1.0) {
398            return Err(RillError::InvalidState(
399                "weighted EW mean alpha must be in (0, 1]".to_owned(),
400            ));
401        }
402        validate_weighted_moments(self.total_weight, self.samples_seen, &[("mean", self.mean)])
403    }
404}
405
406#[cfg(feature = "serde")]
407impl ValidateState for WeightedMae {
408    fn validate_state(&self) -> Result<(), RillError> {
409        self.errors.validate_state()
410    }
411}
412
413#[cfg(feature = "serde")]
414impl ValidateState for WeightedMse {
415    fn validate_state(&self) -> Result<(), RillError> {
416        self.errors.validate_state()
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use approx::assert_abs_diff_eq;
424
425    #[test]
426    fn weighted_mean_and_variance_match_offline() {
427        let samples = [(1.0, 0.5), (3.0, 2.0), (10.0, 1.5)];
428        let total = samples.iter().map(|(_, weight)| weight).sum::<f64>();
429        let offline_mean = samples.iter().map(|(x, w)| x * w).sum::<f64>() / total;
430        let offline_variance = samples
431            .iter()
432            .map(|(x, w)| w * (x - offline_mean).powi(2))
433            .sum::<f64>()
434            / total;
435        let mut mean = WeightedMean::new();
436        let mut variance = WeightedVariance::new();
437        for (x, weight) in samples {
438            mean.update_weighted(x, weight).unwrap();
439            variance.update_weighted(x, weight).unwrap();
440        }
441        assert_abs_diff_eq!(mean.value().unwrap(), offline_mean, epsilon = 1e-12);
442        assert_abs_diff_eq!(variance.value().unwrap(), offline_variance, epsilon = 1e-12);
443    }
444
445    #[test]
446    fn integer_weight_ew_mean_matches_repeated_samples() {
447        let mut weighted = WeightedExponentiallyWeightedMean::new(0.2).unwrap();
448        let mut repeated = WeightedExponentiallyWeightedMean::new(0.2).unwrap();
449        weighted.update_weighted(1.0, 1.0).unwrap();
450        repeated.update_weighted(1.0, 1.0).unwrap();
451        weighted.update_weighted(5.0, 3.0).unwrap();
452        for _ in 0..3 {
453            repeated.update_weighted(5.0, 1.0).unwrap();
454        }
455        assert_abs_diff_eq!(
456            weighted.value().unwrap(),
457            repeated.value().unwrap(),
458            epsilon = 1e-12
459        );
460    }
461
462    #[test]
463    fn zero_weight_is_validated_noop_and_bad_weights_are_atomic() {
464        let mut mean = WeightedMean::new();
465        mean.update_weighted(3.0, 0.0).unwrap();
466        assert_eq!(mean, WeightedMean::new());
467        let before = mean.clone();
468        assert!(mean.update_weighted(1.0, -1.0).is_err());
469        assert!(mean.update_weighted(1.0, f64::NAN).is_err());
470        assert_eq!(mean, before);
471    }
472
473    #[test]
474    fn weighted_errors_match_offline_calculation_and_reset() {
475        let samples: [(f64, f64, f64); 3] = [(2.0, 1.0, 0.5), (5.0, 2.0, 2.0), (-1.0, 1.0, 1.5)];
476        let total_weight = samples.iter().map(|sample| sample.2).sum::<f64>();
477        let expected_mae = samples
478            .iter()
479            .map(|(truth, prediction, weight)| weight * (truth - prediction).abs())
480            .sum::<f64>()
481            / total_weight;
482        let expected_mse = samples
483            .iter()
484            .map(|(truth, prediction, weight)| weight * (truth - prediction).powi(2))
485            .sum::<f64>()
486            / total_weight;
487        let mut mae = WeightedMae::default();
488        let mut mse = WeightedMse::default();
489        for (truth, prediction, weight) in samples {
490            mae.update(truth, prediction, weight).unwrap();
491            mse.update(truth, prediction, weight).unwrap();
492        }
493        assert_abs_diff_eq!(mae.value().unwrap(), expected_mae, epsilon = 1e-12);
494        assert_abs_diff_eq!(mse.value().unwrap(), expected_mse, epsilon = 1e-12);
495        assert_eq!(mae.samples_seen(), 3);
496        assert_eq!(mse.total_weight(), total_weight);
497        mae.reset();
498        mse.reset();
499        assert_eq!(mae.value(), None);
500        assert_eq!(mse.samples_seen(), 0);
501    }
502
503    #[cfg(feature = "serde")]
504    #[test]
505    fn weighted_state_roundtrip_and_corruption_rejected() {
506        let mut variance = WeightedVariance::new();
507        variance.update_weighted(2.0, 0.5).unwrap();
508        let json = serde_json::to_string(&variance).unwrap();
509        let restored: WeightedVariance = serde_json::from_str(&json).unwrap();
510        restored.validate_state().unwrap();
511        let corrupt = json.replace("\"total_weight\":0.5", "\"total_weight\":-1.0");
512        let restored: WeightedVariance = serde_json::from_str(&corrupt).unwrap();
513        assert!(restored.validate_state().is_err());
514    }
515}