Skip to main content

chronix_analytics/preprocess/
auto_features.rs

1//! Automatic feature generation for time-series data.
2//!
3//! Produces a [`FeatureMatrix`] containing named feature columns derived from
4//! raw timestamps and values:
5//!
6//! - **Temporal:** hour_of_day, day_of_week, day_of_month, month_of_year, is_weekend
7//! - **Statistical:** rolling_mean, rolling_std, rolling_min, rolling_max at
8//!   configurable windows (default: 60, 360, 1440, 10080 — representing
9//!   1 h, 6 h, 24 h, 7 d when the interval is 1 minute).
10//! - **Lag-based:** lag_1, lag_2, lag_period (requires known period)
11//!
12//! Optional variance-threshold pruning removes near-constant features.
13
14use crate::preprocess::features;
15
16/// Named feature matrix: one row per observation, one column per feature.
17#[derive(Debug, Clone)]
18pub struct FeatureMatrix {
19    /// Feature names (column headers).
20    pub names: Vec<String>,
21    /// Row-major matrix: `data[row * n_features + col]`.
22    pub data: Vec<f64>,
23    /// Number of rows.
24    pub n_rows: usize,
25}
26
27impl FeatureMatrix {
28    /// Number of features (columns).
29    #[inline]
30    pub fn n_features(&self) -> usize {
31        self.names.len()
32    }
33
34    /// Get a feature column by index (returns a `Vec<f64>` copy).
35    pub fn column(&self, idx: usize) -> Vec<f64> {
36        let nf = self.n_features();
37        (0..self.n_rows).map(|r| self.data[r * nf + idx]).collect()
38    }
39
40    /// Get a feature column by name.
41    pub fn column_by_name(&self, name: &str) -> Option<Vec<f64>> {
42        let idx = self.names.iter().position(|n| n == name)?;
43        Some(self.column(idx))
44    }
45}
46
47/// Configuration for auto-feature generation.
48#[derive(Debug, Clone)]
49pub struct AutoFeatureConfig {
50    /// Known seasonal period (in number of observations).
51    pub period: Option<usize>,
52    /// Rolling window sizes (number of observations).  Default: `[60, 360, 1440, 10080]`.
53    pub rolling_windows: Vec<usize>,
54    /// Variance threshold for pruning.  `None` → keep all features.
55    pub variance_threshold: Option<f64>,
56    /// Whether to generate temporal features from nanosecond timestamps.
57    pub temporal_features: bool,
58}
59
60impl Default for AutoFeatureConfig {
61    fn default() -> Self {
62        Self {
63            period: None,
64            rolling_windows: vec![60, 360, 1440, 10080],
65            variance_threshold: None,
66            temporal_features: true,
67        }
68    }
69}
70
71impl AutoFeatureConfig {
72    /// Sets the seasonal period (in number of observations).
73    pub fn with_period(mut self, period: usize) -> Self {
74        self.period = Some(period);
75        self
76    }
77
78    /// Overrides the rolling window sizes.
79    pub fn with_windows(mut self, windows: Vec<usize>) -> Self {
80        self.rolling_windows = windows;
81        self
82    }
83
84    /// Sets the variance threshold for pruning near-constant features.
85    pub fn with_variance_threshold(mut self, threshold: f64) -> Self {
86        self.variance_threshold = Some(threshold);
87        self
88    }
89}
90
91/// Generate features from timestamps (nanosecond epoch) and values.
92pub fn auto_features(
93    timestamps_ns: &[i64],
94    values: &[f64],
95    config: &AutoFeatureConfig,
96) -> FeatureMatrix {
97    let n = timestamps_ns.len().min(values.len());
98    let mut names: Vec<String> = Vec::new();
99    let mut columns: Vec<Vec<f64>> = Vec::new();
100
101    // --- Temporal features ---
102    if config.temporal_features {
103        let mut hour = Vec::with_capacity(n);
104        let mut dow = Vec::with_capacity(n);
105        let mut dom = Vec::with_capacity(n);
106        let mut month = Vec::with_capacity(n);
107        let mut weekend = Vec::with_capacity(n);
108
109        for &ts in timestamps_ns.iter().take(n) {
110            let secs = ts / 1_000_000_000;
111            let (h, d_of_w, d_of_m, m_of_y) = decompose_epoch_secs(secs);
112            hour.push(h as f64);
113            dow.push(d_of_w as f64);
114            dom.push(d_of_m as f64);
115            month.push(m_of_y as f64);
116            weekend.push(if d_of_w >= 5 { 1.0 } else { 0.0 });
117        }
118
119        names.extend([
120            "hour_of_day".into(),
121            "day_of_week".into(),
122            "day_of_month".into(),
123            "month_of_year".into(),
124            "is_weekend".into(),
125        ]);
126        columns.extend([hour, dow, dom, month, weekend]);
127    }
128
129    // --- Statistical features: rolling windows ---
130    let vals = &values[..n];
131    for &w in &config.rolling_windows {
132        if w == 0 || w > n {
133            continue;
134        }
135        // rolling mean
136        let rmean = rolling_mean(vals, w);
137        names.push(format!("rolling_mean_{w}"));
138        columns.push(rmean);
139
140        // rolling std
141        let rstd = features::rolling_std(vals, w);
142        names.push(format!("rolling_std_{w}"));
143        columns.push(rstd);
144
145        // rolling min
146        let rmin = rolling_min(vals, w);
147        names.push(format!("rolling_min_{w}"));
148        columns.push(rmin);
149
150        // rolling max
151        let rmax = rolling_max(vals, w);
152        names.push(format!("rolling_max_{w}"));
153        columns.push(rmax);
154    }
155
156    // --- Lag features ---
157    let lag1 = features::lag(vals, 1);
158    names.push("lag_1".into());
159    columns.push(lag1);
160
161    let lag2 = features::lag(vals, 2);
162    names.push("lag_2".into());
163    columns.push(lag2);
164
165    if let Some(period) = config.period {
166        if period > 0 && period < n {
167            let lag_p = features::lag(vals, period);
168            names.push(format!("lag_{period}"));
169            columns.push(lag_p);
170        }
171    }
172
173    // --- Build row-major matrix ---
174    let n_features = names.len();
175    let mut data = vec![0.0; n * n_features];
176    for (col_idx, col) in columns.iter().enumerate() {
177        for row in 0..n {
178            data[row * n_features + col_idx] = col[row];
179        }
180    }
181
182    let mut matrix = FeatureMatrix {
183        names,
184        data,
185        n_rows: n,
186    };
187
188    // --- Variance-threshold pruning ---
189    if let Some(threshold) = config.variance_threshold {
190        prune_low_variance(&mut matrix, threshold);
191    }
192
193    matrix
194}
195
196/// Remove features whose variance is below `threshold`.
197fn prune_low_variance(matrix: &mut FeatureMatrix, threshold: f64) {
198    let nf = matrix.n_features();
199    let n = matrix.n_rows;
200    if nf == 0 || n < 2 {
201        return;
202    }
203
204    let mut keep: Vec<bool> = Vec::with_capacity(nf);
205    for col in 0..nf {
206        let mut sum = 0.0;
207        let mut sum_sq = 0.0;
208        let mut count = 0usize;
209        for row in 0..n {
210            let v = matrix.data[row * nf + col];
211            if !v.is_nan() {
212                sum += v;
213                sum_sq += v * v;
214                count += 1;
215            }
216        }
217        let var = if count > 1 {
218            (sum_sq - sum * sum / count as f64) / (count - 1) as f64
219        } else {
220            0.0
221        };
222        keep.push(var >= threshold);
223    }
224
225    let new_nf = keep.iter().filter(|&&k| k).count();
226    if new_nf == nf {
227        return; // nothing to prune
228    }
229
230    let new_names: Vec<String> = matrix
231        .names
232        .iter()
233        .zip(keep.iter())
234        .filter(|(_, &k)| k)
235        .map(|(n, _)| n.clone())
236        .collect();
237
238    let mut new_data = Vec::with_capacity(n * new_nf);
239    for row in 0..n {
240        for (col, &k) in keep.iter().enumerate() {
241            if k {
242                new_data.push(matrix.data[row * nf + col]);
243            }
244        }
245    }
246
247    matrix.names = new_names;
248    matrix.data = new_data;
249}
250
251/// Rolling mean (trailing window).  NAN for first `window - 1` rows.
252fn rolling_mean(values: &[f64], window: usize) -> Vec<f64> {
253    let n = values.len();
254    let mut out = vec![f64::NAN; n];
255    if window == 0 || window > n {
256        return out;
257    }
258    let mut sum: f64 = values[..window].iter().sum();
259    out[window - 1] = sum / window as f64;
260
261    // Periodic recomputation every 1024 steps to bound FP drift.
262    const RECOMPUTE_INTERVAL: usize = 1024;
263    for i in window..n {
264        if (i - window).is_multiple_of(RECOMPUTE_INTERVAL) {
265            sum = values[i + 1 - window..=i].iter().sum();
266        } else {
267            sum += values[i] - values[i - window];
268        }
269        out[i] = sum / window as f64;
270    }
271    out
272}
273
274/// Rolling min (trailing window) using monotonic deque — O(n) total.
275/// NAN for first `window - 1` rows.
276fn rolling_min(values: &[f64], window: usize) -> Vec<f64> {
277    let n = values.len();
278    let mut out = vec![f64::NAN; n];
279    if window == 0 || window > n {
280        return out;
281    }
282    // Deque stores indices; front is always the index of the min in the current window
283    let mut deque = std::collections::VecDeque::with_capacity(window);
284    for i in 0..n {
285        // Remove elements outside the window
286        while let Some(&front) = deque.front() {
287            if front + window <= i {
288                deque.pop_front();
289            } else {
290                break;
291            }
292        }
293        // Maintain monotonically increasing invariant
294        while let Some(&back) = deque.back() {
295            if values[back] >= values[i] {
296                deque.pop_back();
297            } else {
298                break;
299            }
300        }
301        deque.push_back(i);
302        if i >= window - 1 {
303            out[i] = values[deque[0]];
304        }
305    }
306    out
307}
308
309/// Rolling max (trailing window) using monotonic deque — O(n) total.
310/// NAN for first `window - 1` rows.
311fn rolling_max(values: &[f64], window: usize) -> Vec<f64> {
312    let n = values.len();
313    let mut out = vec![f64::NAN; n];
314    if window == 0 || window > n {
315        return out;
316    }
317    let mut deque = std::collections::VecDeque::with_capacity(window);
318    for i in 0..n {
319        while let Some(&front) = deque.front() {
320            if front + window <= i {
321                deque.pop_front();
322            } else {
323                break;
324            }
325        }
326        while let Some(&back) = deque.back() {
327            if values[back] <= values[i] {
328                deque.pop_back();
329            } else {
330                break;
331            }
332        }
333        deque.push_back(i);
334        if i >= window - 1 {
335            out[i] = values[deque[0]];
336        }
337    }
338    out
339}
340
341/// Decompose a Unix epoch second into (hour_of_day, day_of_week, day_of_month, month_of_year).
342///
343/// Uses a simplified civil-date algorithm (no leap-second handling).
344fn decompose_epoch_secs(epoch_secs: i64) -> (u32, u32, u32, u32) {
345    // seconds in a day
346    const SECS_PER_DAY: i64 = 86400;
347
348    let day_secs = epoch_secs.rem_euclid(SECS_PER_DAY);
349    let hour = (day_secs / 3600) as u32;
350
351    // days since epoch (1970-01-01 was Thursday = day 4)
352    let days = epoch_secs.div_euclid(SECS_PER_DAY);
353    // 0=Mon … 6=Sun.  1970-01-01 = Thursday = 3.
354    let dow = ((days + 3) % 7) as u32;
355
356    // Civil date from days since epoch — Howard Hinnant's algorithm
357    let (y, m, d) = civil_from_days(days);
358    let _ = y; // not needed
359    let dom = d as u32;
360    let moy = m as u32;
361
362    (hour, dow, dom, moy)
363}
364
365/// Convert days since 1970-01-01 to (year, month 1–12, day 1–31).
366/// Adapted from Howard Hinnant's `civil_from_days`.
367fn civil_from_days(days: i64) -> (i64, i64, i64) {
368    let z = days + 719468;
369    let era = z.div_euclid(146097);
370    let doe = z.rem_euclid(146097);
371    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
372    let y = yoe + era * 400;
373    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
374    let mp = (5 * doy + 2) / 153;
375    let d = doy - (153 * mp + 2) / 5 + 1;
376    let m = if mp < 10 { mp + 3 } else { mp - 9 };
377    let y = if m <= 2 { y + 1 } else { y };
378    (y, m, d)
379}
380
381// -----------------------------------------------------------------------
382// Tests
383// -----------------------------------------------------------------------
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_auto_features_basic() {
390        let n = 100;
391        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 60_000_000_000).collect(); // 1-min interval
392        let vals: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin()).collect();
393
394        let config = AutoFeatureConfig::default().with_windows(vec![5, 10]);
395        let fm = auto_features(&ts, &vals, &config);
396
397        assert_eq!(fm.n_rows, n);
398        // 5 temporal + 4*2 rolling + 2 lag = 15
399        assert_eq!(fm.n_features(), 15);
400        assert!(fm.names.contains(&"hour_of_day".to_string()));
401        assert!(fm.names.contains(&"rolling_mean_5".to_string()));
402        assert!(fm.names.contains(&"lag_1".to_string()));
403    }
404
405    #[test]
406    fn test_auto_features_with_period() {
407        let n = 50;
408        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 3_600_000_000_000).collect();
409        let vals: Vec<f64> = (0..n).map(|i| i as f64).collect();
410
411        let config = AutoFeatureConfig::default()
412            .with_windows(vec![5])
413            .with_period(24);
414        let fm = auto_features(&ts, &vals, &config);
415
416        // 5 temporal + 4 rolling + 3 lag (lag_1, lag_2, lag_24) = 12
417        assert_eq!(fm.n_features(), 12);
418        assert!(fm.names.contains(&"lag_24".to_string()));
419    }
420
421    #[test]
422    fn test_variance_threshold_pruning() {
423        let n = 50;
424        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 60_000_000_000).collect();
425        // Constant signal → many features will have near-zero variance
426        let vals = vec![42.0; n];
427
428        let config = AutoFeatureConfig::default()
429            .with_windows(vec![5])
430            .with_variance_threshold(0.01);
431        let fm = auto_features(&ts, &vals, &config);
432
433        // Constant values → rolling_std=NAN, lag features are 42 (constant) → pruned
434        // Most features should be pruned since the signal is constant
435        assert!(fm.n_features() < 12);
436    }
437
438    #[test]
439    fn test_feature_matrix_column_by_name() {
440        let n = 20;
441        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 60_000_000_000).collect();
442        let vals: Vec<f64> = (0..n).map(|i| i as f64).collect();
443
444        let config = AutoFeatureConfig {
445            temporal_features: false,
446            rolling_windows: vec![],
447            period: None,
448            variance_threshold: None,
449        };
450        let fm = auto_features(&ts, &vals, &config);
451
452        // Only lag_1, lag_2
453        assert_eq!(fm.n_features(), 2);
454        let lag1 = fm.column_by_name("lag_1").unwrap();
455        assert!(lag1[0].is_nan());
456        assert_eq!(lag1[1], 0.0);
457        assert_eq!(lag1[2], 1.0);
458    }
459
460    #[test]
461    fn test_rolling_mean() {
462        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
463        let rm = rolling_mean(&v, 3);
464        assert!(rm[0].is_nan());
465        assert!(rm[1].is_nan());
466        assert!((rm[2] - 2.0).abs() < 1e-9);
467        assert!((rm[3] - 3.0).abs() < 1e-9);
468        assert!((rm[4] - 4.0).abs() < 1e-9);
469    }
470
471    #[test]
472    fn test_rolling_min_max() {
473        let v = vec![3.0, 1.0, 4.0, 1.0, 5.0];
474        let rmin = rolling_min(&v, 3);
475        let rmax = rolling_max(&v, 3);
476        assert!(rmin[0].is_nan());
477        assert!(rmin[1].is_nan());
478        assert_eq!(rmin[2], 1.0);
479        assert_eq!(rmin[3], 1.0);
480        assert_eq!(rmin[4], 1.0);
481        assert_eq!(rmax[2], 4.0);
482        assert_eq!(rmax[3], 4.0);
483        assert_eq!(rmax[4], 5.0);
484    }
485
486    #[test]
487    fn test_decompose_epoch_known_date() {
488        // 2024-01-15 10:30:00 UTC = Monday
489        // epoch = 1705314600
490        let (hour, dow, dom, moy) = decompose_epoch_secs(1_705_314_600);
491        assert_eq!(hour, 10);
492        assert_eq!(dow, 0); // Monday = 0
493        assert_eq!(dom, 15);
494        assert_eq!(moy, 1);
495    }
496
497    #[test]
498    fn test_decompose_epoch_weekend() {
499        // 2024-01-13 15:00:00 UTC = Saturday
500        // epoch = 1705158000
501        let (_, dow, _, _) = decompose_epoch_secs(1_705_158_000);
502        assert_eq!(dow, 5); // Saturday = 5
503    }
504
505    #[test]
506    fn test_no_temporal_features() {
507        let n = 10;
508        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 60_000_000_000).collect();
509        let vals: Vec<f64> = (0..n).map(|i| i as f64).collect();
510
511        let config = AutoFeatureConfig {
512            temporal_features: false,
513            rolling_windows: vec![3],
514            period: None,
515            variance_threshold: None,
516        };
517        let fm = auto_features(&ts, &vals, &config);
518
519        // 4 rolling (mean, std, min, max @3) + 2 lag = 6
520        assert_eq!(fm.n_features(), 6);
521        assert!(!fm.names.contains(&"hour_of_day".to_string()));
522    }
523}