Skip to main content

ipfrs_tensorlogic/
feature_extractor.rs

1//! TensorFeatureExtractor — statistical and structural feature extraction from tensor data.
2//!
3//! Extracts mean, variance, skewness, min/max, range, L-norms, and histogram features
4//! from raw `f64` tensor slices for use in downstream ML pipelines.
5
6/// Which feature to extract from a tensor slice.
7#[derive(Clone, Debug, PartialEq, Eq, Hash)]
8pub enum FeatureKind {
9    /// Arithmetic mean: Σxᵢ / n  (0.0 when empty)
10    Mean,
11    /// Population variance: E\[x²\] − E\[x\]²  (0.0 when empty)
12    Variance,
13    /// Standardised third central moment: E[(x−μ)³] / σ³  (0.0 when σ=0 or empty)
14    Skewness,
15    /// Minimum element  (0.0 when empty)
16    Min,
17    /// Maximum element  (0.0 when empty)
18    Max,
19    /// Range: max − min  (0.0 when empty)
20    Range,
21    /// L¹ norm: Σ|xᵢ|
22    L1Norm,
23    /// L² norm: √(Σxᵢ²)
24    L2Norm,
25    /// Histogram: divide [min, max] into `bins` equal-width buckets and count elements.
26    /// Produces `bins` values.  When min == max every element falls in bucket 0.
27    Histogram {
28        /// Number of equal-width histogram bins.
29        bins: usize,
30    },
31}
32
33// ─────────────────────────────────────────────────────────────────────────────
34
35/// A single extracted feature together with its (possibly multi-valued) result.
36#[derive(Clone, Debug, PartialEq)]
37pub struct ExtractedFeature {
38    /// Which feature was extracted.
39    pub kind: FeatureKind,
40    /// Feature values — length 1 for scalar features, `bins` for Histogram.
41    pub values: Vec<f64>,
42}
43
44// ─────────────────────────────────────────────────────────────────────────────
45
46/// Configuration for [`TensorFeatureExtractor`].
47#[derive(Clone, Debug)]
48pub struct ExtractorConfig {
49    /// Ordered list of features to extract.
50    pub features: Vec<FeatureKind>,
51    /// Default bin count used when a `Histogram { bins }` entry is added via
52    /// [`ExtractorConfig::default`].
53    pub histogram_bins: usize,
54}
55
56impl Default for ExtractorConfig {
57    fn default() -> Self {
58        Self {
59            features: vec![
60                FeatureKind::Mean,
61                FeatureKind::Variance,
62                FeatureKind::Min,
63                FeatureKind::Max,
64                FeatureKind::L2Norm,
65            ],
66            histogram_bins: 10,
67        }
68    }
69}
70
71// ─────────────────────────────────────────────────────────────────────────────
72
73/// The result of extracting features from a single tensor.
74#[derive(Clone, Debug)]
75pub struct ExtractionResult {
76    /// Opaque identifier for the source tensor.
77    pub tensor_id: u64,
78    /// Extracted features in the order specified by [`ExtractorConfig::features`].
79    pub features: Vec<ExtractedFeature>,
80}
81
82impl ExtractionResult {
83    /// Flatten all feature values into a single `Vec<f64>` in declaration order.
84    pub fn feature_vector(&self) -> Vec<f64> {
85        self.features
86            .iter()
87            .flat_map(|f| f.values.iter().copied())
88            .collect()
89    }
90}
91
92// ─────────────────────────────────────────────────────────────────────────────
93
94/// Cumulative statistics for a [`TensorFeatureExtractor`] session.
95#[derive(Clone, Debug, Default)]
96pub struct ExtractorStats {
97    /// Total number of individual feature extractions performed (features × tensors).
98    pub total_extractions: u64,
99    /// Total number of tensors processed.
100    pub total_tensors_processed: u64,
101    /// Running average of the feature-vector length across all processed tensors.
102    pub avg_feature_vector_len: f64,
103}
104
105// ─────────────────────────────────────────────────────────────────────────────
106
107/// Extracts statistical and structural features from tensor data.
108pub struct TensorFeatureExtractor {
109    /// Active configuration.
110    pub config: ExtractorConfig,
111    /// Accumulated statistics.
112    pub stats: ExtractorStats,
113}
114
115impl TensorFeatureExtractor {
116    /// Create a new extractor with the given configuration.
117    pub fn new(config: ExtractorConfig) -> Self {
118        Self {
119            config,
120            stats: ExtractorStats::default(),
121        }
122    }
123
124    /// Extract all configured features from `data` and tag the result with `tensor_id`.
125    ///
126    /// Updates internal statistics on each call.
127    pub fn extract(&mut self, tensor_id: u64, data: &[f64]) -> ExtractionResult {
128        let n = data.len();
129
130        // Pre-compute shared statistics once to avoid redundant passes.
131        let precomputed = PrecomputedStats::compute(data);
132
133        let features: Vec<ExtractedFeature> = self
134            .config
135            .features
136            .iter()
137            .map(|kind| {
138                let values = extract_one(kind, data, n, &precomputed);
139                ExtractedFeature {
140                    kind: kind.clone(),
141                    values,
142                }
143            })
144            .collect();
145
146        // ── update stats ──────────────────────────────────────────────────
147        let fv_len: usize = features.iter().map(|f| f.values.len()).sum();
148        let prev_total = self.stats.total_tensors_processed;
149        let prev_avg = self.stats.avg_feature_vector_len;
150
151        self.stats.total_tensors_processed += 1;
152        self.stats.total_extractions += features.len() as u64;
153
154        // Incremental mean update: avg_new = (avg_old * prev_total + fv_len) / new_total
155        let new_total = prev_total + 1;
156        self.stats.avg_feature_vector_len =
157            (prev_avg * prev_total as f64 + fv_len as f64) / new_total as f64;
158
159        ExtractionResult {
160            tensor_id,
161            features,
162        }
163    }
164
165    /// Extract features from a batch of `(tensor_id, data)` pairs, returning
166    /// results in the same order as the input.
167    pub fn extract_batch(&mut self, tensors: Vec<(u64, Vec<f64>)>) -> Vec<ExtractionResult> {
168        tensors
169            .into_iter()
170            .map(|(id, data)| self.extract(id, &data))
171            .collect()
172    }
173
174    /// Return a reference to the accumulated statistics.
175    pub fn stats(&self) -> &ExtractorStats {
176        &self.stats
177    }
178}
179
180// ─────────────────────────────────────────────────────────────────────────────
181// Internal helpers
182// ─────────────────────────────────────────────────────────────────────────────
183
184/// Statistics computed in a single pass over the data, shared across feature kinds.
185struct PrecomputedStats {
186    mean: f64,
187    variance: f64,
188    /// σ (std-dev); 0.0 when variance == 0 or n == 0.
189    std_dev: f64,
190    min: f64,
191    max: f64,
192}
193
194impl PrecomputedStats {
195    fn compute(data: &[f64]) -> Self {
196        if data.is_empty() {
197            return Self {
198                mean: 0.0,
199                variance: 0.0,
200                std_dev: 0.0,
201                min: 0.0,
202                max: 0.0,
203            };
204        }
205
206        let n = data.len() as f64;
207
208        // Single-pass: sum, sum-of-squares, min, max.
209        let mut sum = 0.0_f64;
210        let mut sum_sq = 0.0_f64;
211        let mut min = data[0];
212        let mut max = data[0];
213
214        for &x in data {
215            sum += x;
216            sum_sq += x * x;
217            if x < min {
218                min = x;
219            }
220            if x > max {
221                max = x;
222            }
223        }
224
225        let mean = sum / n;
226        // Population variance: E[x²] − E[x]²
227        let variance = (sum_sq / n) - (mean * mean);
228        // Guard against floating-point negatives near zero.
229        let variance = if variance < 0.0 { 0.0 } else { variance };
230        let std_dev = variance.sqrt();
231
232        Self {
233            mean,
234            variance,
235            std_dev,
236            min,
237            max,
238        }
239    }
240}
241
242/// Compute the values for a single `FeatureKind`.
243fn extract_one(kind: &FeatureKind, data: &[f64], n: usize, pre: &PrecomputedStats) -> Vec<f64> {
244    match kind {
245        FeatureKind::Mean => vec![pre.mean],
246
247        FeatureKind::Variance => vec![pre.variance],
248
249        FeatureKind::Skewness => {
250            if n == 0 || pre.std_dev == 0.0 {
251                return vec![0.0];
252            }
253            // E[(x − μ)³] / σ³
254            let mu = pre.mean;
255            let sigma3 = pre.std_dev * pre.std_dev * pre.std_dev;
256            let third_moment: f64 = data
257                .iter()
258                .map(|&x| {
259                    let d = x - mu;
260                    d * d * d
261                })
262                .sum::<f64>()
263                / n as f64;
264            vec![third_moment / sigma3]
265        }
266
267        FeatureKind::Min => vec![pre.min],
268
269        FeatureKind::Max => vec![pre.max],
270
271        FeatureKind::Range => vec![pre.max - pre.min],
272
273        FeatureKind::L1Norm => {
274            let l1: f64 = data.iter().map(|x| x.abs()).sum();
275            vec![l1]
276        }
277
278        FeatureKind::L2Norm => {
279            let l2: f64 = data.iter().map(|x| x * x).sum::<f64>().sqrt();
280            vec![l2]
281        }
282
283        FeatureKind::Histogram { bins } => {
284            let bins = *bins;
285            if bins == 0 {
286                return Vec::new();
287            }
288
289            let mut counts = vec![0.0_f64; bins];
290
291            if n == 0 {
292                return counts;
293            }
294
295            let lo = pre.min;
296            let hi = pre.max;
297
298            if (hi - lo).abs() < f64::EPSILON {
299                // All values identical — everything in bucket 0.
300                counts[0] = n as f64;
301            } else {
302                let width = hi - lo;
303                for &x in data {
304                    // Normalise to [0, 1) then map to bucket index.
305                    let t = (x - lo) / width;
306                    // Clamp to [0, bins-1] — the last element hits exactly 1.0.
307                    let idx = ((t * bins as f64) as usize).min(bins - 1);
308                    counts[idx] += 1.0;
309                }
310            }
311
312            counts
313        }
314    }
315}
316
317// ─────────────────────────────────────────────────────────────────────────────
318// Tests
319// ─────────────────────────────────────────────────────────────────────────────
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    // ── helpers ──────────────────────────────────────────────────────────────
326
327    fn extractor_for(kinds: Vec<FeatureKind>) -> TensorFeatureExtractor {
328        TensorFeatureExtractor::new(ExtractorConfig {
329            features: kinds,
330            histogram_bins: 10,
331        })
332    }
333
334    fn single(kind: FeatureKind, data: &[f64]) -> f64 {
335        let mut ex = extractor_for(vec![kind]);
336        let res = ex.extract(0, data);
337        res.features[0].values[0]
338    }
339
340    // ── Mean ─────────────────────────────────────────────────────────────────
341
342    #[test]
343    fn test_mean_basic() {
344        let v = single(FeatureKind::Mean, &[1.0, 2.0, 3.0, 4.0, 5.0]);
345        assert!((v - 3.0).abs() < 1e-12, "mean={v}");
346    }
347
348    #[test]
349    fn test_mean_single_element() {
350        let v = single(FeatureKind::Mean, &[7.5]);
351        assert!((v - 7.5).abs() < 1e-12, "mean={v}");
352    }
353
354    #[test]
355    fn test_mean_empty() {
356        let v = single(FeatureKind::Mean, &[]);
357        assert_eq!(v, 0.0);
358    }
359
360    #[test]
361    fn test_mean_negative() {
362        let v = single(FeatureKind::Mean, &[-2.0, -4.0]);
363        assert!((v - (-3.0)).abs() < 1e-12, "mean={v}");
364    }
365
366    // ── Variance ─────────────────────────────────────────────────────────────
367
368    #[test]
369    fn test_variance_basic() {
370        // Population variance of [2, 4, 4, 4, 5, 5, 7, 9] == 4.0
371        let data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
372        let v = single(FeatureKind::Variance, &data);
373        assert!((v - 4.0).abs() < 1e-10, "var={v}");
374    }
375
376    #[test]
377    fn test_variance_empty() {
378        assert_eq!(single(FeatureKind::Variance, &[]), 0.0);
379    }
380
381    #[test]
382    fn test_variance_constant_data() {
383        // All identical → variance must be zero.
384        let data = [3.0_f64; 100];
385        let v = single(FeatureKind::Variance, &data);
386        assert!(v.abs() < 1e-10, "var={v}");
387    }
388
389    #[test]
390    fn test_variance_population_formula() {
391        // E[x²] − E[x]²  for [1, 3]:  E[x²]=5, E[x]=2, var=5-4=1
392        let v = single(FeatureKind::Variance, &[1.0, 3.0]);
393        assert!((v - 1.0).abs() < 1e-12, "var={v}");
394    }
395
396    // ── Skewness ─────────────────────────────────────────────────────────────
397
398    #[test]
399    fn test_skewness_empty() {
400        assert_eq!(single(FeatureKind::Skewness, &[]), 0.0);
401    }
402
403    #[test]
404    fn test_skewness_zero_when_sigma_zero() {
405        // All identical → σ=0, skewness must be 0.
406        let data = [5.0_f64; 10];
407        assert_eq!(single(FeatureKind::Skewness, &data), 0.0);
408    }
409
410    #[test]
411    fn test_skewness_symmetric_distribution() {
412        // A perfectly symmetric distribution has skewness ≈ 0.
413        let data: Vec<f64> = (-50..=50).map(|i| i as f64).collect();
414        let v = single(FeatureKind::Skewness, &data);
415        assert!(v.abs() < 1e-10, "skewness={v}");
416    }
417
418    #[test]
419    fn test_skewness_right_skewed() {
420        // Data with a long right tail should have positive skewness.
421        let data = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 100.0];
422        let v = single(FeatureKind::Skewness, &data);
423        assert!(v > 0.0, "expected positive skewness, got {v}");
424    }
425
426    // ── Min / Max ─────────────────────────────────────────────────────────────
427
428    #[test]
429    fn test_min_basic() {
430        assert!((single(FeatureKind::Min, &[3.0, 1.0, 4.0, 1.0, 5.0]) - 1.0).abs() < 1e-12);
431    }
432
433    #[test]
434    fn test_max_basic() {
435        assert!((single(FeatureKind::Max, &[3.0, 1.0, 4.0, 1.0, 5.0]) - 5.0).abs() < 1e-12);
436    }
437
438    #[test]
439    fn test_min_empty() {
440        assert_eq!(single(FeatureKind::Min, &[]), 0.0);
441    }
442
443    #[test]
444    fn test_max_empty() {
445        assert_eq!(single(FeatureKind::Max, &[]), 0.0);
446    }
447
448    // ── Range ─────────────────────────────────────────────────────────────────
449
450    #[test]
451    fn test_range_basic() {
452        let v = single(FeatureKind::Range, &[1.0, 5.0, 3.0, -2.0]);
453        assert!((v - 7.0).abs() < 1e-12, "range={v}");
454    }
455
456    #[test]
457    fn test_range_empty() {
458        assert_eq!(single(FeatureKind::Range, &[]), 0.0);
459    }
460
461    #[test]
462    fn test_range_single() {
463        assert_eq!(single(FeatureKind::Range, &[42.0]), 0.0);
464    }
465
466    // ── L1Norm ───────────────────────────────────────────────────────────────
467
468    #[test]
469    fn test_l1norm_basic() {
470        // |1| + |-2| + |3| + |-4| = 10
471        let v = single(FeatureKind::L1Norm, &[1.0, -2.0, 3.0, -4.0]);
472        assert!((v - 10.0).abs() < 1e-12, "l1={v}");
473    }
474
475    #[test]
476    fn test_l1norm_empty() {
477        assert_eq!(single(FeatureKind::L1Norm, &[]), 0.0);
478    }
479
480    // ── L2Norm ───────────────────────────────────────────────────────────────
481
482    #[test]
483    fn test_l2norm_basic() {
484        // √(3² + 4²) = 5
485        let v = single(FeatureKind::L2Norm, &[3.0, 4.0]);
486        assert!((v - 5.0).abs() < 1e-12, "l2={v}");
487    }
488
489    #[test]
490    fn test_l2norm_empty() {
491        assert_eq!(single(FeatureKind::L2Norm, &[]), 0.0);
492    }
493
494    // ── Histogram ─────────────────────────────────────────────────────────────
495
496    #[test]
497    fn test_histogram_uniform_distribution() {
498        // 100 values uniform in [0, 100), 10 bins → each bin ≈ 10 counts.
499        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
500        let mut ex = extractor_for(vec![FeatureKind::Histogram { bins: 10 }]);
501        let res = ex.extract(0, &data);
502        let counts = &res.features[0].values;
503        assert_eq!(counts.len(), 10);
504        for &c in counts {
505            // each bucket should have exactly 10 elements
506            assert!((c - 10.0).abs() < 1.0, "bucket count unexpected: {c}");
507        }
508    }
509
510    #[test]
511    fn test_histogram_min_max_all_in_bucket_zero() {
512        // When min == max every element must land in bin 0.
513        let data = [7.0_f64; 20];
514        let mut ex = extractor_for(vec![FeatureKind::Histogram { bins: 5 }]);
515        let res = ex.extract(0, &data);
516        let counts = &res.features[0].values;
517        assert_eq!(counts.len(), 5);
518        assert!((counts[0] - 20.0).abs() < 1e-12, "bin0={}", counts[0]);
519        for &c in &counts[1..] {
520            assert_eq!(c, 0.0);
521        }
522    }
523
524    #[test]
525    fn test_histogram_empty_data() {
526        let mut ex = extractor_for(vec![FeatureKind::Histogram { bins: 4 }]);
527        let res = ex.extract(0, &[]);
528        let counts = &res.features[0].values;
529        assert_eq!(counts.len(), 4);
530        for &c in counts {
531            assert_eq!(c, 0.0);
532        }
533    }
534
535    #[test]
536    fn test_histogram_known_distribution() {
537        // 3 values in [0,3]: 0, 1, 2 — 3 bins of width 1.
538        let data = [0.0, 1.0, 2.0];
539        let mut ex = extractor_for(vec![FeatureKind::Histogram { bins: 3 }]);
540        let res = ex.extract(0, &data);
541        let counts = &res.features[0].values;
542        assert_eq!(counts.len(), 3);
543        // Each of the three values should land in a distinct bin.
544        let total: f64 = counts.iter().sum();
545        assert!((total - 3.0).abs() < 1e-12, "total={total}");
546    }
547
548    // ── feature_vector flattening ─────────────────────────────────────────────
549
550    #[test]
551    fn test_feature_vector_flatten() {
552        let mut ex = extractor_for(vec![
553            FeatureKind::Mean,
554            FeatureKind::Histogram { bins: 3 },
555            FeatureKind::L2Norm,
556        ]);
557        let res = ex.extract(42, &[1.0, 2.0, 3.0]);
558        let fv = res.feature_vector();
559        // Mean(1) + Histogram(3) + L2Norm(1) = 5 values
560        assert_eq!(fv.len(), 5, "fv={fv:?}");
561    }
562
563    #[test]
564    fn test_feature_vector_all_scalar() {
565        let mut ex = extractor_for(vec![
566            FeatureKind::Mean,
567            FeatureKind::Variance,
568            FeatureKind::Min,
569            FeatureKind::Max,
570            FeatureKind::L2Norm,
571        ]);
572        let res = ex.extract(0, &[1.0, 2.0, 3.0]);
573        assert_eq!(res.feature_vector().len(), 5);
574    }
575
576    // ── extract_batch ─────────────────────────────────────────────────────────
577
578    #[test]
579    fn test_extract_batch_multiple_tensors() {
580        let mut ex = extractor_for(vec![FeatureKind::Mean]);
581        let results = ex.extract_batch(vec![
582            (1, vec![1.0, 2.0, 3.0]),
583            (2, vec![4.0, 5.0, 6.0]),
584            (3, vec![7.0, 8.0, 9.0]),
585        ]);
586        assert_eq!(results.len(), 3);
587        assert!((results[0].features[0].values[0] - 2.0).abs() < 1e-12);
588        assert!((results[1].features[0].values[0] - 5.0).abs() < 1e-12);
589        assert!((results[2].features[0].values[0] - 8.0).abs() < 1e-12);
590    }
591
592    #[test]
593    fn test_extract_batch_tensor_ids_preserved() {
594        let mut ex = extractor_for(vec![FeatureKind::Max]);
595        let results = ex.extract_batch(vec![(99, vec![1.0]), (1000, vec![2.0])]);
596        assert_eq!(results[0].tensor_id, 99);
597        assert_eq!(results[1].tensor_id, 1000);
598    }
599
600    // ── stats accumulation ────────────────────────────────────────────────────
601
602    #[test]
603    fn test_stats_accumulate_tensors_processed() {
604        let mut ex = extractor_for(vec![FeatureKind::Mean, FeatureKind::Variance]);
605        ex.extract(0, &[1.0, 2.0]);
606        ex.extract(1, &[3.0, 4.0]);
607        ex.extract(2, &[5.0, 6.0]);
608        assert_eq!(ex.stats().total_tensors_processed, 3);
609    }
610
611    #[test]
612    fn test_stats_total_extractions() {
613        // 3 features × 2 tensors = 6 extractions
614        let mut ex = extractor_for(vec![FeatureKind::Mean, FeatureKind::Min, FeatureKind::Max]);
615        ex.extract(0, &[1.0]);
616        ex.extract(1, &[2.0]);
617        assert_eq!(ex.stats().total_extractions, 6);
618    }
619
620    #[test]
621    fn test_stats_avg_feature_vector_len() {
622        // All-scalar config → fv_len == features.len() always.
623        let mut ex = extractor_for(vec![FeatureKind::Mean, FeatureKind::L2Norm]);
624        ex.extract(0, &[1.0]);
625        ex.extract(1, &[2.0]);
626        // avg should be 2.0 (both have fv_len=2)
627        assert!((ex.stats().avg_feature_vector_len - 2.0).abs() < 1e-12);
628    }
629
630    #[test]
631    fn test_stats_via_accessor() {
632        let mut ex = extractor_for(vec![FeatureKind::Mean]);
633        ex.extract(0, &[1.0]);
634        let s = ex.stats();
635        assert_eq!(s.total_tensors_processed, 1);
636    }
637
638    // ── default config ────────────────────────────────────────────────────────
639
640    #[test]
641    fn test_default_config_features() {
642        let cfg = ExtractorConfig::default();
643        assert!(cfg.features.contains(&FeatureKind::Mean));
644        assert!(cfg.features.contains(&FeatureKind::Variance));
645        assert!(cfg.features.contains(&FeatureKind::Min));
646        assert!(cfg.features.contains(&FeatureKind::Max));
647        assert!(cfg.features.contains(&FeatureKind::L2Norm));
648        assert_eq!(cfg.histogram_bins, 10);
649    }
650
651    // ── edge cases ────────────────────────────────────────────────────────────
652
653    #[test]
654    fn test_all_features_empty_data() {
655        let kinds = vec![
656            FeatureKind::Mean,
657            FeatureKind::Variance,
658            FeatureKind::Skewness,
659            FeatureKind::Min,
660            FeatureKind::Max,
661            FeatureKind::Range,
662            FeatureKind::L1Norm,
663            FeatureKind::L2Norm,
664            FeatureKind::Histogram { bins: 4 },
665        ];
666        let mut ex = extractor_for(kinds);
667        let res = ex.extract(0, &[]);
668        for feat in &res.features {
669            match &feat.kind {
670                FeatureKind::Histogram { bins } => {
671                    assert_eq!(feat.values.len(), *bins);
672                    for &v in &feat.values {
673                        assert_eq!(v, 0.0);
674                    }
675                }
676                _ => {
677                    assert_eq!(feat.values.len(), 1);
678                    assert_eq!(feat.values[0], 0.0, "kind={:?}", feat.kind);
679                }
680            }
681        }
682    }
683
684    #[test]
685    fn test_histogram_bin_total_equals_n() {
686        // The total count across all histogram bins must equal data.len().
687        let data: Vec<f64> = (0..137).map(|i| i as f64 * 0.7 - 10.0).collect();
688        let mut ex = extractor_for(vec![FeatureKind::Histogram { bins: 7 }]);
689        let res = ex.extract(0, &data);
690        let total: f64 = res.features[0].values.iter().sum();
691        assert!((total - data.len() as f64).abs() < 1e-10, "total={total}");
692    }
693}