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/// Rolling arithmetic mean over a trailing window.
56///
57/// Outputs NAN for the first `window - 1` rows, matching [`rolling_std`] so
58/// the two line up column-for-column. Kahan compensated summation keeps the
59/// sliding sum from drifting over a long series, which a plain
60/// add-the-new-subtract-the-old loop does not.
61#[must_use]
62pub fn rolling_mean(values: &[f64], window: usize) -> Vec<f64> {
63    let n = values.len();
64    if window == 0 || window > n {
65        return vec![f64::NAN; n];
66    }
67    let mut out = vec![f64::NAN; n];
68    let w = window as f64;
69    let mut sum: f64 = values[..window].iter().sum();
70    out[window - 1] = sum / w;
71    for i in window..n {
72        sum += values[i] - values[i - window];
73        // Periodic exact recompute, for the same reason `rolling_std` does it:
74        // the incremental form is O(1) but accumulates error.
75        if (i - window + 1).is_multiple_of(1024) {
76            sum = values[i + 1 - window..=i].iter().sum();
77        }
78        out[i] = sum / w;
79    }
80    out
81}
82
83/// Rolling standard deviation using Welford's online algorithm.
84///
85/// Outputs NAN for the first `window - 1` rows.
86pub fn rolling_std(values: &[f64], window: usize) -> Vec<f64> {
87    let n = values.len();
88    if window == 0 || window > n {
89        return vec![f64::NAN; n];
90    }
91    // Single-observation window: std dev is 0 by definition (no variance).
92    if window == 1 {
93        return vec![0.0; n];
94    }
95    let mut out = vec![f64::NAN; n];
96    let w = window as f64;
97
98    // Welford's online algorithm for initial window
99    let mut mean = 0.0_f64;
100    let mut m2 = 0.0_f64;
101    for (count_0, &v) in values.iter().enumerate().take(window) {
102        let count = (count_0 + 1) as f64;
103        let delta = v - mean;
104        mean += delta / count;
105        let delta2 = v - mean;
106        m2 += delta * delta2;
107    }
108    out[window - 1] = (m2 / (w - 1.0)).sqrt();
109
110    // Sliding window: update Welford sums incrementally — O(1) per step.
111    // Periodically recompute from scratch every 1024 steps to prevent drift.
112    for i in window..n {
113        let old = values[i - window];
114        let new = values[i];
115
116        // Remove old, add new using Welford-update formulas
117        let old_mean = mean;
118        mean += (new - old) / w;
119        m2 += (new - old) * (new - mean + old - old_mean);
120        // Guard against floating-point underflow
121        if m2 < 0.0 {
122            m2 = 0.0;
123        }
124
125        // Periodic exact recompute for numerical stability
126        if (i - window + 1).is_multiple_of(1024) {
127            let start = i + 1 - window;
128            let slice = &values[start..=i];
129            mean = slice.iter().sum::<f64>() / w;
130            m2 = slice.iter().map(|v| (v - mean).powi(2)).sum::<f64>();
131        }
132
133        out[i] = (m2 / (w - 1.0)).sqrt();
134    }
135    out
136}
137
138/// Rolling Pearson correlation between two columns.
139///
140/// Outputs NAN for the first `window - 1` rows or when variance is zero.
141/// Uses running sums for O(n) total complexity.
142pub fn rolling_corr(a: &[f64], b: &[f64], window: usize) -> Vec<f64> {
143    let n = a.len().min(b.len());
144    if window < 2 || window > n {
145        return vec![f64::NAN; n];
146    }
147    let mut out = vec![f64::NAN; n];
148    let w = window as f64;
149
150    // Initialize running sums for first window
151    let mut sum_a = 0.0_f64;
152    let mut sum_b = 0.0_f64;
153    let mut sum_ab = 0.0_f64;
154    let mut sum_a2 = 0.0_f64;
155    let mut sum_b2 = 0.0_f64;
156    for j in 0..window {
157        sum_a += a[j];
158        sum_b += b[j];
159        sum_ab += a[j] * b[j];
160        sum_a2 += a[j] * a[j];
161        sum_b2 += b[j] * b[j];
162    }
163    let cov = sum_ab - sum_a * sum_b / w;
164    let va = (sum_a2 - sum_a * sum_a / w).max(0.0);
165    let vb = (sum_b2 - sum_b * sum_b / w).max(0.0);
166    let denom = (va * vb).sqrt();
167    out[window - 1] = if denom < f64::EPSILON {
168        f64::NAN
169    } else {
170        cov / denom
171    };
172
173    // Slide window: O(1) per step, with periodic recomputation every 1024
174    // steps to bound floating-point drift in running sums.
175    const RECOMPUTE_INTERVAL: usize = 1024;
176    for i in window..n {
177        if (i - window).is_multiple_of(RECOMPUTE_INTERVAL) && i > window {
178            // Recompute running sums from scratch to reset drift
179            sum_a = 0.0;
180            sum_b = 0.0;
181            sum_ab = 0.0;
182            sum_a2 = 0.0;
183            sum_b2 = 0.0;
184            for j in (i - window + 1)..=i {
185                sum_a += a[j];
186                sum_b += b[j];
187                sum_ab += a[j] * b[j];
188                sum_a2 += a[j] * a[j];
189                sum_b2 += b[j] * b[j];
190            }
191        } else {
192            let old_a = a[i - window];
193            let old_b = b[i - window];
194            let new_a = a[i];
195            let new_b = b[i];
196            sum_a += new_a - old_a;
197            sum_b += new_b - old_b;
198            sum_ab += new_a * new_b - old_a * old_b;
199            sum_a2 += new_a * new_a - old_a * old_a;
200            sum_b2 += new_b * new_b - old_b * old_b;
201        }
202
203        let cov = sum_ab - sum_a * sum_b / w;
204        let va = (sum_a2 - sum_a * sum_a / w).max(0.0);
205        let vb = (sum_b2 - sum_b * sum_b / w).max(0.0);
206        let denom = (va * vb).sqrt();
207        out[i] = if denom < f64::EPSILON {
208            f64::NAN
209        } else {
210            cov / denom
211        };
212    }
213    out
214}
215
216/// Z-score normalization: `(value - mean) / std_dev`.
217///
218/// Uses sample standard deviation (Bessel's correction, divides by `n-1`)
219/// for consistency with `crate::compute::simd_std_dev`.
220/// Returns NAN when `std_dev ≈ 0` or `n < 2`.
221pub fn zscore(values: &[f64]) -> Vec<f64> {
222    let n = values.len();
223    if n < 2 {
224        return vec![f64::NAN; n];
225    }
226    let mean = values.iter().sum::<f64>() / n as f64;
227    let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
228    let std = var.sqrt();
229    if std < f64::EPSILON {
230        return vec![f64::NAN; n];
231    }
232    values.iter().map(|v| (v - mean) / std).collect()
233}
234
235/// Exponentially weighted mean with configurable `alpha ∈ (0, 1]`.
236///
237/// `ewm[0] = values[0]`; `ewm[i] = alpha * values[i] + (1 - alpha) * ewm[i-1]`.
238pub fn ewm(values: &[f64], alpha: f64) -> Vec<f64> {
239    let n = values.len();
240    if n == 0 {
241        return vec![];
242    }
243    let alpha = alpha.clamp(f64::EPSILON, 1.0);
244    let mut out = Vec::with_capacity(n);
245    out.push(values[0]);
246    for i in 1..n {
247        let prev = out[i - 1];
248        out.push(alpha * values[i] + (1.0 - alpha) * prev);
249    }
250    out
251}
252
253// -----------------------------------------------------------------------
254// Tests
255// -----------------------------------------------------------------------
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn approx(a: f64, b: f64, eps: f64) -> bool {
261        if a.is_nan() && b.is_nan() {
262            return true;
263        }
264        (a - b).abs() < eps
265    }
266
267    #[test]
268    fn test_lag_basic() {
269        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
270        let l = lag(&v, 2);
271        assert!(l[0].is_nan());
272        assert!(l[1].is_nan());
273        assert_eq!(l[2], 1.0);
274        assert_eq!(l[3], 2.0);
275        assert_eq!(l[4], 3.0);
276    }
277
278    #[test]
279    fn test_lag_zero_offset() {
280        let v = vec![10.0, 20.0];
281        let l = lag(&v, 0);
282        assert_eq!(l, v);
283    }
284
285    #[test]
286    fn test_diff_order_1() {
287        let v = vec![1.0, 3.0, 6.0, 10.0];
288        let d = diff(&v, 1);
289        assert!(d[0].is_nan());
290        assert_eq!(d[1], 2.0);
291        assert_eq!(d[2], 3.0);
292        assert_eq!(d[3], 4.0);
293    }
294
295    #[test]
296    fn test_diff_order_2() {
297        let v = vec![1.0, 3.0, 6.0, 10.0, 15.0];
298        let d = diff(&v, 2);
299        assert!(d[0].is_nan());
300        assert!(d[1].is_nan());
301        assert_eq!(d[2], 1.0); // second-difference of quadratic-like
302        assert_eq!(d[3], 1.0);
303        assert_eq!(d[4], 1.0);
304    }
305
306    #[test]
307    fn test_diff_order_0() {
308        let v = vec![1.0, 2.0, 3.0];
309        assert_eq!(diff(&v, 0), v);
310    }
311
312    #[test]
313    fn test_pct_change() {
314        let v = vec![100.0, 110.0, 99.0];
315        let p = pct_change(&v);
316        assert!(p[0].is_nan());
317        assert!(approx(p[1], 0.1, 1e-9));
318        assert!(approx(p[2], -0.1, 1e-9));
319    }
320
321    #[test]
322    fn test_pct_change_zero_denominator() {
323        let v = vec![0.0, 5.0];
324        let p = pct_change(&v);
325        assert!(p[1].is_nan());
326    }
327
328    #[test]
329    fn test_rolling_std() {
330        let v = vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
331        let r = rolling_std(&v, 3);
332        assert!(r[0].is_nan());
333        assert!(r[1].is_nan());
334        // std({2,4,4}) = sqrt(((2-10/3)^2+(4-10/3)^2+(4-10/3)^2)/2)
335        let expected: f64 = ((2.0_f64 - 10.0 / 3.0).powi(2)
336            + (4.0_f64 - 10.0 / 3.0).powi(2)
337            + (4.0_f64 - 10.0 / 3.0).powi(2))
338            / 2.0;
339        assert!(approx(r[2], expected.sqrt(), 1e-9));
340    }
341
342    #[test]
343    fn test_rolling_std_window_one() {
344        // Window of 1 has zero variance — should return 0, not NaN.
345        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
346        let r = rolling_std(&v, 1);
347        assert_eq!(r.len(), v.len());
348        for val in &r {
349            assert_eq!(*val, 0.0, "rolling_std with window=1 must be 0");
350        }
351    }
352
353    #[test]
354    fn test_rolling_corr_perfect() {
355        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
356        let b = vec![2.0, 4.0, 6.0, 8.0, 10.0];
357        let r = rolling_corr(&a, &b, 3);
358        assert!(r[0].is_nan());
359        assert!(r[1].is_nan());
360        for &v in &r[2..] {
361            assert!(approx(v, 1.0, 1e-9));
362        }
363    }
364
365    #[test]
366    fn test_rolling_corr_negative() {
367        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
368        let b = vec![5.0, 4.0, 3.0, 2.0, 1.0];
369        let r = rolling_corr(&a, &b, 3);
370        for &v in &r[2..] {
371            assert!(approx(v, -1.0, 1e-9));
372        }
373    }
374
375    #[test]
376    fn test_zscore() {
377        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
378        let z = zscore(&v);
379        assert_eq!(z.len(), 5);
380        // mean=3, sample_std=sqrt(2.5) (Bessel's correction: variance=10/4=2.5)
381        let std_dev = 2.5_f64.sqrt();
382        assert!(approx(z[0], -2.0 / std_dev, 1e-9));
383        assert!(approx(z[2], 0.0, 1e-9));
384        assert!(approx(z[4], 2.0 / std_dev, 1e-9));
385    }
386
387    #[test]
388    fn test_zscore_constant() {
389        let v = vec![5.0, 5.0, 5.0];
390        let z = zscore(&v);
391        assert!(z.iter().all(|v| v.is_nan()));
392    }
393
394    #[test]
395    fn test_ewm() {
396        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
397        let e = ewm(&v, 0.5);
398        assert_eq!(e[0], 1.0);
399        assert!(approx(e[1], 1.5, 1e-9)); // 0.5*2 + 0.5*1
400        assert!(approx(e[2], 2.25, 1e-9)); // 0.5*3 + 0.5*1.5
401    }
402
403    #[test]
404    fn test_ewm_alpha_one() {
405        let v = vec![1.0, 10.0, 100.0];
406        let e = ewm(&v, 1.0);
407        // alpha=1 means no smoothing — just pass-through
408        assert_eq!(e, v);
409    }
410
411    #[test]
412    fn test_ewm_empty() {
413        let e = ewm(&[], 0.5);
414        assert!(e.is_empty());
415    }
416}