Skip to main content

chronix_analytics/preprocess/
features.rs

1//! Temporal feature extraction functions for time-series data.
2//!
3//! All functions operate on `&[f64]` slices and return `Vec<f64>`,
4//! using `f64::NAN` where values cannot be computed (e.g. boundary rows).
5
6/// Lag: value `offset` rows earlier. Returns NAN for first `offset` rows.
7///
8/// When `offset >= values.len()`, all output values are NAN (the entire
9/// series is shifted beyond the available data).
10#[inline]
11pub fn lag(values: &[f64], offset: usize) -> Vec<f64> {
12    let n = values.len();
13    let mut out = vec![f64::NAN; n];
14    if offset < n {
15        out[offset..n].copy_from_slice(&values[..(n - offset)]);
16    }
17    out
18}
19
20/// N-th order differencing.
21///
22/// `order = 1`: `diff[i] = values[i] - values[i-1]`.
23/// `order = 2`: second-difference, etc.  Leading `order` elements are NAN.
24pub fn diff(values: &[f64], order: usize) -> Vec<f64> {
25    if order == 0 {
26        return values.to_vec();
27    }
28    let mut prev = values.to_vec();
29    for _ in 0..order {
30        let mut cur = vec![f64::NAN; prev.len()];
31        for i in 1..prev.len() {
32            cur[i] = prev[i] - prev[i - 1];
33        }
34        prev = cur;
35    }
36    prev
37}
38
39/// Percentage change: `(values[i] - values[i-1]) / values[i-1]`.
40/// First element is NAN.  Returns NAN when `values[i-1]` is zero.
41pub fn pct_change(values: &[f64]) -> Vec<f64> {
42    let n = values.len();
43    let mut out = vec![f64::NAN; n];
44    for i in 1..n {
45        let prev = values[i - 1];
46        out[i] = if prev.abs() < f64::EPSILON {
47            f64::NAN
48        } else {
49            (values[i] - prev) / prev
50        };
51    }
52    out
53}
54
55/// Sliding first and second moments of the **non-NULL** values in a window,
56/// maintained by Welford add/remove.
57///
58/// Welford rather than running sums of `x` and `x²`, because a metrics
59/// database's commonest series is a counter: values near 1e9 with a variance
60/// near 1 leave `Σx² − (Σx)²/n` with no significant digits at all. Welford
61/// never forms either square.
62///
63/// `NULL` — a NaN in the caller's slice — is simply not an observation:
64/// nothing is added for it and nothing removed. That is what makes the whole
65/// family agree with `AVG(v) OVER (ROWS n PRECEDING)` sitting beside it in
66/// the same `SELECT`, and it is what the previous incremental form could not
67/// do. There, one NULL made `mean` and `m2` NaN, and every later row of the
68/// partition came out NULL too — until an exact recompute every 1024 steps
69/// silently healed it, so the damage ran to the next multiple of 1024 and a
70/// NULL at row 5 and one at row 1500 broke a different number of rows.
71#[derive(Debug, Default, Clone, Copy)]
72struct Moments {
73    n: usize,
74    mean: f64,
75    m2: f64,
76}
77
78impl Moments {
79    fn add(&mut self, x: f64) {
80        if !x.is_finite() {
81            return;
82        }
83        self.n += 1;
84        let d = x - self.mean;
85        self.mean += d / self.n as f64;
86        self.m2 += d * (x - self.mean);
87    }
88
89    fn remove(&mut self, x: f64) {
90        if !x.is_finite() {
91            return;
92        }
93        if self.n <= 1 {
94            *self = Self::default();
95            return;
96        }
97        let prev_mean = self.mean;
98        self.n -= 1;
99        self.mean -= (x - prev_mean) / self.n as f64;
100        self.m2 -= (x - prev_mean) * (x - self.mean);
101        // Cancellation can leave a sum of squares just below zero.
102        if self.m2 < 0.0 {
103            self.m2 = 0.0;
104        }
105    }
106
107    fn from_slice(xs: &[f64]) -> Self {
108        let mut m = Self::default();
109        for &x in xs {
110            m.add(x);
111        }
112        m
113    }
114
115    /// Sample standard deviation, `NaN` with fewer than two observations.
116    fn sample_std(&self) -> f64 {
117        if self.n < 2 {
118            return f64::NAN;
119        }
120        (self.m2 / (self.n - 1) as f64).sqrt()
121    }
122}
123
124/// How often the sliding moments are rebuilt from the window exactly.
125///
126/// Welford's add/remove pair is self-correcting for the mean but not for
127/// `m2`, and a long series subtracts a great many times.
128const RECOMPUTE_INTERVAL: usize = 1024;
129
130/// Rolling arithmetic mean of the non-NULL values in a trailing window.
131///
132/// Outputs NAN for the first `window - 1` rows — there is no full window yet
133/// — and thereafter the mean of whatever the window holds, which is NAN only
134/// if the window holds nothing. `AVG(v) OVER (ROWS window - 1 PRECEDING)`
135/// answers the same question with the same NULL rule.
136#[must_use]
137pub fn rolling_mean(values: &[f64], window: usize) -> Vec<f64> {
138    let n = values.len();
139    if window == 0 || window > n {
140        return vec![f64::NAN; n];
141    }
142    let mut out = vec![f64::NAN; n];
143    let mut m = Moments::from_slice(&values[..window]);
144    out[window - 1] = if m.n == 0 { f64::NAN } else { m.mean };
145    for i in window..n {
146        m.remove(values[i - window]);
147        m.add(values[i]);
148        if (i - window + 1).is_multiple_of(RECOMPUTE_INTERVAL) {
149            m = Moments::from_slice(&values[i + 1 - window..=i]);
150        }
151        out[i] = if m.n == 0 { f64::NAN } else { m.mean };
152    }
153    out
154}
155
156/// Rolling sample standard deviation of the non-NULL values in a trailing
157/// window, by Welford's algorithm.
158///
159/// Outputs NAN for the first `window - 1` rows and wherever the window holds
160/// fewer than two values — including `window == 1`, where a sample standard
161/// deviation divides by zero. Returning `0.0` there, which this used to do,
162/// asserts that a single reading has no spread; it has no *measurable*
163/// spread, which is what NULL says.
164#[must_use]
165pub fn rolling_std(values: &[f64], window: usize) -> Vec<f64> {
166    let n = values.len();
167    if window == 0 || window > n {
168        return vec![f64::NAN; n];
169    }
170    let mut out = vec![f64::NAN; n];
171    let mut m = Moments::from_slice(&values[..window]);
172    out[window - 1] = m.sample_std();
173    for i in window..n {
174        m.remove(values[i - window]);
175        m.add(values[i]);
176        if (i - window + 1).is_multiple_of(RECOMPUTE_INTERVAL) {
177            m = Moments::from_slice(&values[i + 1 - window..=i]);
178        }
179        out[i] = m.sample_std();
180    }
181    out
182}
183
184/// Sliding co-moments of the pairs in a window where **both** sides are
185/// non-NULL — pairwise-complete, as `PearsonCorrelation::compute` is over a
186/// whole partition.
187#[derive(Debug, Default, Clone, Copy)]
188struct CoMoments {
189    n: usize,
190    mx: f64,
191    my: f64,
192    m2x: f64,
193    m2y: f64,
194    cxy: f64,
195}
196
197impl CoMoments {
198    fn add(&mut self, x: f64, y: f64) {
199        if !x.is_finite() || !y.is_finite() {
200            return;
201        }
202        self.n += 1;
203        let nf = self.n as f64;
204        let dx = x - self.mx;
205        let dy = y - self.my;
206        self.mx += dx / nf;
207        self.my += dy / nf;
208        self.m2x += dx * (x - self.mx);
209        self.m2y += dy * (y - self.my);
210        self.cxy += dx * (y - self.my);
211    }
212
213    fn remove(&mut self, x: f64, y: f64) {
214        if !x.is_finite() || !y.is_finite() {
215            return;
216        }
217        if self.n <= 1 {
218            *self = Self::default();
219            return;
220        }
221        let (px, py) = (self.mx, self.my);
222        self.n -= 1;
223        let nf = self.n as f64;
224        self.mx -= (x - px) / nf;
225        self.my -= (y - py) / nf;
226        self.m2x -= (x - px) * (x - self.mx);
227        self.m2y -= (y - py) * (y - self.my);
228        self.cxy -= (x - px) * (y - self.my);
229        if self.m2x < 0.0 {
230            self.m2x = 0.0;
231        }
232        if self.m2y < 0.0 {
233            self.m2y = 0.0;
234        }
235    }
236
237    fn from_slices(xs: &[f64], ys: &[f64]) -> Self {
238        let mut c = Self::default();
239        for (&x, &y) in xs.iter().zip(ys) {
240            c.add(x, y);
241        }
242        c
243    }
244
245    /// Pearson correlation, `NaN` with fewer than two pairs or when either
246    /// side has no spread — `0.0` there would assert "uncorrelated" where
247    /// the truth is "there is nothing to correlate".
248    fn correlation(&self) -> f64 {
249        if self.n < 2 {
250            return f64::NAN;
251        }
252        let denom = (self.m2x * self.m2y).sqrt();
253        if denom < f64::EPSILON {
254            return f64::NAN;
255        }
256        self.cxy / denom
257    }
258}
259
260/// Rolling Pearson correlation over a trailing window, pairwise-complete.
261///
262/// Outputs NAN for the first `window - 1` rows and wherever the window holds
263/// fewer than two complete pairs.
264#[must_use]
265pub fn rolling_corr(a: &[f64], b: &[f64], window: usize) -> Vec<f64> {
266    let n = a.len().min(b.len());
267    if window < 2 || window > n {
268        return vec![f64::NAN; n];
269    }
270    let mut out = vec![f64::NAN; n];
271    let mut c = CoMoments::from_slices(&a[..window], &b[..window]);
272    out[window - 1] = c.correlation();
273    for i in window..n {
274        c.remove(a[i - window], b[i - window]);
275        c.add(a[i], b[i]);
276        if (i - window + 1).is_multiple_of(RECOMPUTE_INTERVAL) {
277            c = CoMoments::from_slices(&a[i + 1 - window..=i], &b[i + 1 - window..=i]);
278        }
279        out[i] = c.correlation();
280    }
281    out
282}
283
284/// Z-score normalisation against the partition's own mean and sample
285/// standard deviation.
286///
287/// The statistics are taken over the non-NULL values; a NULL row stays NULL
288/// and does not make the rest of the partition NULL, which is what summing a
289/// slice containing one NaN used to do. Returns all-NAN when fewer than two
290/// values are present or the spread is zero.
291///
292/// Uses the sample standard deviation (Bessel's correction, `n-1`) for
293/// consistency with `crate::compute::simd_std_dev`.
294#[must_use]
295pub fn zscore(values: &[f64]) -> Vec<f64> {
296    let n = values.len();
297    let m = Moments::from_slice(values);
298    let std = m.sample_std();
299    if !std.is_finite() || std < f64::EPSILON {
300        return vec![f64::NAN; n];
301    }
302    values
303        .iter()
304        .map(|v| {
305            if v.is_finite() {
306                (v - m.mean) / std
307            } else {
308                f64::NAN
309            }
310        })
311        .collect()
312}
313
314/// Exponentially weighted mean with smoothing factor `alpha ∈ (0, 1]`.
315///
316/// `ewm[i] = alpha * values[i] + (1 - alpha) * ewm[i-1]`, seeded with the
317/// first non-NULL value — pandas' `ewm(alpha=…, adjust=False).mean()`.
318///
319/// A NULL contributes no observation: the state carries across it unchanged
320/// and is what the row reports, exactly as `rolling_mean` still reports its
321/// window's mean at a NULL row. The statistic is a property of the samples
322/// seen so far, and a row with no sample does not undefine it. Folding the
323/// NaN into the recurrence, which is what this used to do, made every row
324/// after the first NULL NULL as well.
325///
326/// Matches pandas' `ewm(alpha=…, adjust=False, ignore_na=True).mean()`.
327#[must_use]
328pub fn ewm(values: &[f64], alpha: f64) -> Vec<f64> {
329    let n = values.len();
330    let alpha = alpha.clamp(f64::EPSILON, 1.0);
331    let mut out = vec![f64::NAN; n];
332    let mut state: Option<f64> = None;
333    for i in 0..n {
334        let v = values[i];
335        if v.is_finite() {
336            state = Some(match state {
337                None => v,
338                Some(prev) => alpha * v + (1.0 - alpha) * prev,
339            });
340        }
341        // Before the first sample there is no state and the row is NULL;
342        // after it, a NULL row reports the state unchanged.
343        if let Some(cur) = state {
344            out[i] = cur;
345        }
346    }
347    out
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    fn approx(a: f64, b: f64, eps: f64) -> bool {
355        if a.is_nan() && b.is_nan() {
356            return true;
357        }
358        (a - b).abs() < eps
359    }
360
361    #[test]
362    fn test_lag_basic() {
363        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
364        let l = lag(&v, 2);
365        assert!(l[0].is_nan());
366        assert!(l[1].is_nan());
367        assert_eq!(l[2], 1.0);
368        assert_eq!(l[3], 2.0);
369        assert_eq!(l[4], 3.0);
370    }
371
372    #[test]
373    fn test_lag_zero_offset() {
374        let v = vec![10.0, 20.0];
375        let l = lag(&v, 0);
376        assert_eq!(l, v);
377    }
378
379    #[test]
380    fn test_diff_order_1() {
381        let v = vec![1.0, 3.0, 6.0, 10.0];
382        let d = diff(&v, 1);
383        assert!(d[0].is_nan());
384        assert_eq!(d[1], 2.0);
385        assert_eq!(d[2], 3.0);
386        assert_eq!(d[3], 4.0);
387    }
388
389    #[test]
390    fn test_diff_order_2() {
391        let v = vec![1.0, 3.0, 6.0, 10.0, 15.0];
392        let d = diff(&v, 2);
393        assert!(d[0].is_nan());
394        assert!(d[1].is_nan());
395        assert_eq!(d[2], 1.0); // second-difference of quadratic-like
396        assert_eq!(d[3], 1.0);
397        assert_eq!(d[4], 1.0);
398    }
399
400    #[test]
401    fn test_diff_order_0() {
402        let v = vec![1.0, 2.0, 3.0];
403        assert_eq!(diff(&v, 0), v);
404    }
405
406    #[test]
407    fn test_pct_change() {
408        let v = vec![100.0, 110.0, 99.0];
409        let p = pct_change(&v);
410        assert!(p[0].is_nan());
411        assert!(approx(p[1], 0.1, 1e-9));
412        assert!(approx(p[2], -0.1, 1e-9));
413    }
414
415    #[test]
416    fn test_pct_change_zero_denominator() {
417        let v = vec![0.0, 5.0];
418        let p = pct_change(&v);
419        assert!(p[1].is_nan());
420    }
421
422    #[test]
423    fn test_rolling_std() {
424        let v = vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
425        let r = rolling_std(&v, 3);
426        assert!(r[0].is_nan());
427        assert!(r[1].is_nan());
428        // std({2,4,4}) = sqrt(((2-10/3)^2+(4-10/3)^2+(4-10/3)^2)/2)
429        let expected: f64 = ((2.0_f64 - 10.0 / 3.0).powi(2)
430            + (4.0_f64 - 10.0 / 3.0).powi(2)
431            + (4.0_f64 - 10.0 / 3.0).powi(2))
432            / 2.0;
433        assert!(approx(r[2], expected.sqrt(), 1e-9));
434    }
435
436    #[test]
437    fn test_rolling_std_window_one() {
438        // A *sample* standard deviation of one observation divides by
439        // `n - 1 = 0`: it is undefined, not zero. This used to return 0.0
440        // under the comment "window of 1 has zero variance", which asserts
441        // that a single reading has been observed not to vary. pandas'
442        // `.rolling(1).std()` and `numpy.std(ddof=1)` both answer NaN, and
443        // every other member of this family uses the sample form.
444        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
445        let r = rolling_std(&v, 1);
446        assert_eq!(r.len(), v.len());
447        for val in &r {
448            assert!(
449                val.is_nan(),
450                "rolling_std with window=1 is undefined, got {val}"
451            );
452        }
453    }
454
455    #[test]
456    fn test_rolling_corr_perfect() {
457        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
458        let b = vec![2.0, 4.0, 6.0, 8.0, 10.0];
459        let r = rolling_corr(&a, &b, 3);
460        assert!(r[0].is_nan());
461        assert!(r[1].is_nan());
462        for &v in &r[2..] {
463            assert!(approx(v, 1.0, 1e-9));
464        }
465    }
466
467    #[test]
468    fn test_rolling_corr_negative() {
469        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
470        let b = vec![5.0, 4.0, 3.0, 2.0, 1.0];
471        let r = rolling_corr(&a, &b, 3);
472        for &v in &r[2..] {
473            assert!(approx(v, -1.0, 1e-9));
474        }
475    }
476
477    #[test]
478    fn test_zscore() {
479        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
480        let z = zscore(&v);
481        assert_eq!(z.len(), 5);
482        // mean=3, sample_std=sqrt(2.5) (Bessel's correction: variance=10/4=2.5)
483        let std_dev = 2.5_f64.sqrt();
484        assert!(approx(z[0], -2.0 / std_dev, 1e-9));
485        assert!(approx(z[2], 0.0, 1e-9));
486        assert!(approx(z[4], 2.0 / std_dev, 1e-9));
487    }
488
489    #[test]
490    fn test_zscore_constant() {
491        let v = vec![5.0, 5.0, 5.0];
492        let z = zscore(&v);
493        assert!(z.iter().all(|v| v.is_nan()));
494    }
495
496    #[test]
497    fn test_ewm() {
498        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
499        let e = ewm(&v, 0.5);
500        assert_eq!(e[0], 1.0);
501        assert!(approx(e[1], 1.5, 1e-9)); // 0.5*2 + 0.5*1
502        assert!(approx(e[2], 2.25, 1e-9)); // 0.5*3 + 0.5*1.5
503    }
504
505    #[test]
506    fn test_ewm_alpha_one() {
507        let v = vec![1.0, 10.0, 100.0];
508        let e = ewm(&v, 1.0);
509        // alpha=1 means no smoothing — just pass-through
510        assert_eq!(e, v);
511    }
512
513    #[test]
514    fn test_ewm_empty() {
515        let e = ewm(&[], 0.5);
516        assert!(e.is_empty());
517    }
518}