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 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 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 if let Some(threshold) = config.variance_threshold {
190 prune_low_variance(&mut matrix, threshold);
191 }
192
193 matrix
194}
195
196fn 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; }
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
251fn 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 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
274fn 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 let mut deque = std::collections::VecDeque::with_capacity(window);
284 for i in 0..n {
285 while let Some(&front) = deque.front() {
287 if front + window <= i {
288 deque.pop_front();
289 } else {
290 break;
291 }
292 }
293 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
309fn 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
341fn decompose_epoch_secs(epoch_secs: i64) -> (u32, u32, u32, u32) {
345 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 let days = epoch_secs.div_euclid(SECS_PER_DAY);
353 let dow = ((days + 3) % 7) as u32;
355
356 let (y, m, d) = civil_from_days(days);
358 let _ = y; let dom = d as u32;
360 let moy = m as u32;
361
362 (hour, dow, dom, moy)
363}
364
365fn 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#[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(); 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 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 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 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 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 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 let (hour, dow, dom, moy) = decompose_epoch_secs(1_705_314_600);
491 assert_eq!(hour, 10);
492 assert_eq!(dow, 0); assert_eq!(dom, 15);
494 assert_eq!(moy, 1);
495 }
496
497 #[test]
498 fn test_decompose_epoch_weekend() {
499 let (_, dow, _, _) = decompose_epoch_secs(1_705_158_000);
502 assert_eq!(dow, 5); }
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 assert_eq!(fm.n_features(), 6);
521 assert!(!fm.names.contains(&"hour_of_day".to_string()));
522 }
523}