chronix_analytics/preprocess/
auto_features.rs1use crate::preprocess::features;
15
16#[derive(Debug, Clone)]
18pub struct FeatureMatrix {
19 pub names: Vec<String>,
21 pub data: Vec<f64>,
23 pub n_rows: usize,
25}
26
27impl FeatureMatrix {
28 #[inline]
30 pub fn n_features(&self) -> usize {
31 self.names.len()
32 }
33
34 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 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#[derive(Debug, Clone)]
49pub struct AutoFeatureConfig {
50 pub period: Option<usize>,
52 pub rolling_windows: Vec<usize>,
54 pub variance_threshold: Option<f64>,
56 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 pub fn with_period(mut self, period: usize) -> Self {
74 self.period = Some(period);
75 self
76 }
77
78 pub fn with_windows(mut self, windows: Vec<usize>) -> Self {
80 self.rolling_windows = windows;
81 self
82 }
83
84 pub fn with_variance_threshold(mut self, threshold: f64) -> Self {
86 self.variance_threshold = Some(threshold);
87 self
88 }
89}
90
91pub 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 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 let vals = &values[..n];
131 for &w in &config.rolling_windows {
132 if w == 0 || w > n {
133 continue;
134 }
135 let rmean = rolling_mean(vals, w);
137 names.push(format!("rolling_mean_{w}"));
138 columns.push(rmean);
139
140 let rstd = features::rolling_std(vals, w);
142 names.push(format!("rolling_std_{w}"));
143 columns.push(rstd);
144
145 let rmin = rolling_min(vals, w);
147 names.push(format!("rolling_min_{w}"));
148 columns.push(rmin);
149
150 let rmax = rolling_max(vals, w);
152 names.push(format!("rolling_max_{w}"));
153 columns.push(rmax);
154 }
155
156 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 && period > 0
167 && period < n
168 {
169 let lag_p = features::lag(vals, period);
170 names.push(format!("lag_{period}"));
171 columns.push(lag_p);
172 }
173
174 let n_features = names.len();
176 let mut data = vec![0.0; n * n_features];
177 for (col_idx, col) in columns.iter().enumerate() {
178 for row in 0..n {
179 data[row * n_features + col_idx] = col[row];
180 }
181 }
182
183 let mut matrix = FeatureMatrix {
184 names,
185 data,
186 n_rows: n,
187 };
188
189 if let Some(threshold) = config.variance_threshold {
191 prune_low_variance(&mut matrix, threshold);
192 }
193
194 matrix
195}
196
197fn prune_low_variance(matrix: &mut FeatureMatrix, threshold: f64) {
199 let nf = matrix.n_features();
200 let n = matrix.n_rows;
201 if nf == 0 || n < 2 {
202 return;
203 }
204
205 let mut keep: Vec<bool> = Vec::with_capacity(nf);
206 for col in 0..nf {
207 let mut sum = 0.0;
208 let mut sum_sq = 0.0;
209 let mut count = 0usize;
210 for row in 0..n {
211 let v = matrix.data[row * nf + col];
212 if !v.is_nan() {
213 sum += v;
214 sum_sq += v * v;
215 count += 1;
216 }
217 }
218 let var = if count > 1 {
219 (sum_sq - sum * sum / count as f64) / (count - 1) as f64
220 } else {
221 0.0
222 };
223 keep.push(var >= threshold);
224 }
225
226 let new_nf = keep.iter().filter(|&&k| k).count();
227 if new_nf == nf {
228 return; }
230
231 let new_names: Vec<String> = matrix
232 .names
233 .iter()
234 .zip(keep.iter())
235 .filter(|&(_, &k)| k)
236 .map(|(n, _)| n.clone())
237 .collect();
238
239 let mut new_data = Vec::with_capacity(n * new_nf);
240 for row in 0..n {
241 for (col, &k) in keep.iter().enumerate() {
242 if k {
243 new_data.push(matrix.data[row * nf + col]);
244 }
245 }
246 }
247
248 matrix.names = new_names;
249 matrix.data = new_data;
250}
251
252fn rolling_mean(values: &[f64], window: usize) -> Vec<f64> {
254 let n = values.len();
255 let mut out = vec![f64::NAN; n];
256 if window == 0 || window > n {
257 return out;
258 }
259 let mut sum: f64 = values[..window].iter().sum();
260 out[window - 1] = sum / window as f64;
261
262 const RECOMPUTE_INTERVAL: usize = 1024;
264 for i in window..n {
265 if (i - window).is_multiple_of(RECOMPUTE_INTERVAL) {
266 sum = values[i + 1 - window..=i].iter().sum();
267 } else {
268 sum += values[i] - values[i - window];
269 }
270 out[i] = sum / window as f64;
271 }
272 out
273}
274
275fn rolling_min(values: &[f64], window: usize) -> Vec<f64> {
278 let n = values.len();
279 let mut out = vec![f64::NAN; n];
280 if window == 0 || window > n {
281 return out;
282 }
283 let mut deque = std::collections::VecDeque::with_capacity(window);
285 for i in 0..n {
286 while let Some(&front) = deque.front() {
288 if front + window <= i {
289 deque.pop_front();
290 } else {
291 break;
292 }
293 }
294 while let Some(&back) = deque.back() {
296 if values[back] >= values[i] {
297 deque.pop_back();
298 } else {
299 break;
300 }
301 }
302 deque.push_back(i);
303 if i >= window - 1 {
304 out[i] = values[deque[0]];
305 }
306 }
307 out
308}
309
310fn rolling_max(values: &[f64], window: usize) -> Vec<f64> {
313 let n = values.len();
314 let mut out = vec![f64::NAN; n];
315 if window == 0 || window > n {
316 return out;
317 }
318 let mut deque = std::collections::VecDeque::with_capacity(window);
319 for i in 0..n {
320 while let Some(&front) = deque.front() {
321 if front + window <= i {
322 deque.pop_front();
323 } else {
324 break;
325 }
326 }
327 while let Some(&back) = deque.back() {
328 if values[back] <= values[i] {
329 deque.pop_back();
330 } else {
331 break;
332 }
333 }
334 deque.push_back(i);
335 if i >= window - 1 {
336 out[i] = values[deque[0]];
337 }
338 }
339 out
340}
341
342fn decompose_epoch_secs(epoch_secs: i64) -> (u32, u32, u32, u32) {
346 const SECS_PER_DAY: i64 = 86400;
348
349 let day_secs = epoch_secs.rem_euclid(SECS_PER_DAY);
350 let hour = (day_secs / 3600) as u32;
351
352 let days = epoch_secs.div_euclid(SECS_PER_DAY);
354 let dow = ((days + 3) % 7) as u32;
356
357 let (y, m, d) = civil_from_days(days);
359 let _ = y; let dom = d as u32;
361 let moy = m as u32;
362
363 (hour, dow, dom, moy)
364}
365
366fn civil_from_days(days: i64) -> (i64, i64, i64) {
369 let z = days + 719468;
370 let era = z.div_euclid(146097);
371 let doe = z.rem_euclid(146097);
372 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
373 let y = yoe + era * 400;
374 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
375 let mp = (5 * doy + 2) / 153;
376 let d = doy - (153 * mp + 2) / 5 + 1;
377 let m = if mp < 10 { mp + 3 } else { mp - 9 };
378 let y = if m <= 2 { y + 1 } else { y };
379 (y, m, d)
380}
381
382#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn test_auto_features_basic() {
391 let n = 100;
392 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 60_000_000_000).collect(); let vals: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin()).collect();
394
395 let config = AutoFeatureConfig::default().with_windows(vec![5, 10]);
396 let fm = auto_features(&ts, &vals, &config);
397
398 assert_eq!(fm.n_rows, n);
399 assert_eq!(fm.n_features(), 15);
401 assert!(fm.names.contains(&"hour_of_day".to_string()));
402 assert!(fm.names.contains(&"rolling_mean_5".to_string()));
403 assert!(fm.names.contains(&"lag_1".to_string()));
404 }
405
406 #[test]
407 fn test_auto_features_with_period() {
408 let n = 50;
409 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 3_600_000_000_000).collect();
410 let vals: Vec<f64> = (0..n).map(|i| i as f64).collect();
411
412 let config = AutoFeatureConfig::default()
413 .with_windows(vec![5])
414 .with_period(24);
415 let fm = auto_features(&ts, &vals, &config);
416
417 assert_eq!(fm.n_features(), 12);
419 assert!(fm.names.contains(&"lag_24".to_string()));
420 }
421
422 #[test]
423 fn test_variance_threshold_pruning() {
424 let n = 50;
425 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 60_000_000_000).collect();
426 let vals = vec![42.0; n];
428
429 let config = AutoFeatureConfig::default()
430 .with_windows(vec![5])
431 .with_variance_threshold(0.01);
432 let fm = auto_features(&ts, &vals, &config);
433
434 assert!(fm.n_features() < 12);
437 }
438
439 #[test]
440 fn test_feature_matrix_column_by_name() {
441 let n = 20;
442 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 60_000_000_000).collect();
443 let vals: Vec<f64> = (0..n).map(|i| i as f64).collect();
444
445 let config = AutoFeatureConfig {
446 temporal_features: false,
447 rolling_windows: vec![],
448 period: None,
449 variance_threshold: None,
450 };
451 let fm = auto_features(&ts, &vals, &config);
452
453 assert_eq!(fm.n_features(), 2);
455 let lag1 = fm.column_by_name("lag_1").unwrap();
456 assert!(lag1[0].is_nan());
457 assert_eq!(lag1[1], 0.0);
458 assert_eq!(lag1[2], 1.0);
459 }
460
461 #[test]
462 fn test_rolling_mean() {
463 let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
464 let rm = rolling_mean(&v, 3);
465 assert!(rm[0].is_nan());
466 assert!(rm[1].is_nan());
467 assert!((rm[2] - 2.0).abs() < 1e-9);
468 assert!((rm[3] - 3.0).abs() < 1e-9);
469 assert!((rm[4] - 4.0).abs() < 1e-9);
470 }
471
472 #[test]
473 fn test_rolling_min_max() {
474 let v = vec![3.0, 1.0, 4.0, 1.0, 5.0];
475 let rmin = rolling_min(&v, 3);
476 let rmax = rolling_max(&v, 3);
477 assert!(rmin[0].is_nan());
478 assert!(rmin[1].is_nan());
479 assert_eq!(rmin[2], 1.0);
480 assert_eq!(rmin[3], 1.0);
481 assert_eq!(rmin[4], 1.0);
482 assert_eq!(rmax[2], 4.0);
483 assert_eq!(rmax[3], 4.0);
484 assert_eq!(rmax[4], 5.0);
485 }
486
487 #[test]
488 fn test_decompose_epoch_known_date() {
489 let (hour, dow, dom, moy) = decompose_epoch_secs(1_705_314_600);
492 assert_eq!(hour, 10);
493 assert_eq!(dow, 0); assert_eq!(dom, 15);
495 assert_eq!(moy, 1);
496 }
497
498 #[test]
499 fn test_decompose_epoch_weekend() {
500 let (_, dow, _, _) = decompose_epoch_secs(1_705_158_000);
503 assert_eq!(dow, 5); }
505
506 #[test]
507 fn test_no_temporal_features() {
508 let n = 10;
509 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 60_000_000_000).collect();
510 let vals: Vec<f64> = (0..n).map(|i| i as f64).collect();
511
512 let config = AutoFeatureConfig {
513 temporal_features: false,
514 rolling_windows: vec![3],
515 period: None,
516 variance_threshold: None,
517 };
518 let fm = auto_features(&ts, &vals, &config);
519
520 assert_eq!(fm.n_features(), 6);
522 assert!(!fm.names.contains(&"hour_of_day".to_string()));
523 }
524}