1use std::collections::HashMap;
33use std::sync::atomic::{AtomicU64, Ordering};
34use thiserror::Error;
35
36#[derive(Debug, Error, Clone, PartialEq)]
42pub enum FeatureError {
43 #[error("missing required feature: {0}")]
45 MissingFeature(String),
46
47 #[error("type mismatch for feature '{feature}': expected {expected}, got {got}")]
49 TypeMismatch {
50 feature: String,
51 expected: String,
52 got: String,
53 },
54
55 #[error("invalid transform: {0}")]
57 InvalidTransform(String),
58
59 #[error("empty input")]
61 EmptyInput,
62}
63
64#[derive(Clone, Debug, PartialEq)]
70pub enum FeatureValue {
71 Float(f64),
73 Integer(i64),
75 Boolean(bool),
77 Categorical(String),
79 Missing,
81}
82
83impl FeatureValue {
84 pub fn type_name(&self) -> &'static str {
86 match self {
87 FeatureValue::Float(_) => "Float",
88 FeatureValue::Integer(_) => "Integer",
89 FeatureValue::Boolean(_) => "Boolean",
90 FeatureValue::Categorical(_) => "Categorical",
91 FeatureValue::Missing => "Missing",
92 }
93 }
94
95 pub fn as_f64(&self) -> Option<f64> {
98 match self {
99 FeatureValue::Float(v) => Some(*v),
100 FeatureValue::Integer(v) => Some(*v as f64),
101 FeatureValue::Boolean(b) => Some(if *b { 1.0 } else { 0.0 }),
102 FeatureValue::Categorical(_) | FeatureValue::Missing => None,
103 }
104 }
105}
106
107#[derive(Clone, Debug, PartialEq)]
113pub enum FeatureTransform {
114 StandardScaler { mean: f64, std: f64 },
116
117 MinMaxScaler { min: f64, max: f64 },
119
120 Log1p,
122
123 Sqrt,
125
126 Clip { lo: f64, hi: f64 },
128
129 OneHotEncode { categories: Vec<String> },
132
133 Binarize { threshold: f64 },
135
136 PolynomialFeatures { degree: u32 },
138
139 ImputeMean { mean: f64 },
141
142 ImputeMode { mode: String },
144}
145
146#[derive(Clone, Debug)]
152pub struct FeatureSpec {
153 pub name: String,
155 pub transforms: Vec<FeatureTransform>,
157}
158
159#[derive(Clone, Debug)]
161pub struct ExtractedFeatures {
162 pub feature_names: Vec<String>,
165 pub values: Vec<f64>,
167}
168
169#[derive(Clone, Debug)]
176enum PipelineValue {
177 Scalar(FeatureValue),
178 OneHot(Vec<f64>),
179}
180
181#[derive(Clone, Debug, Default)]
187pub struct FePipelineStats {
188 pub total_features: usize,
190 pub total_extractions: u64,
192 pub avg_output_dim: f64,
194}
195
196pub struct FeatureExtractor {
210 specs: Vec<FeatureSpec>,
211 total_extractions: AtomicU64,
212 extraction_dim_sum: AtomicU64,
213}
214
215impl FeatureExtractor {
216 pub fn new() -> Self {
218 Self {
219 specs: Vec::new(),
220 total_extractions: AtomicU64::new(0),
221 extraction_dim_sum: AtomicU64::new(0),
222 }
223 }
224
225 pub fn add_spec(&mut self, spec: FeatureSpec) -> &mut Self {
227 self.specs.push(spec);
228 self
229 }
230
231 pub fn output_dim(&self) -> usize {
237 self.specs.iter().map(spec_output_dim).sum()
238 }
239
240 pub fn feature_names(&self) -> Vec<String> {
242 let mut names = Vec::with_capacity(self.output_dim());
243 for spec in &self.specs {
244 push_feature_names(spec, &mut names);
245 }
246 names
247 }
248
249 pub fn extract(
254 &self,
255 input: &HashMap<String, FeatureValue>,
256 ) -> Result<ExtractedFeatures, FeatureError> {
257 let mut feature_names: Vec<String> = Vec::with_capacity(self.output_dim());
258 let mut values: Vec<f64> = Vec::with_capacity(self.output_dim());
259
260 for spec in &self.specs {
261 let raw = input
262 .get(&spec.name)
263 .cloned()
264 .unwrap_or(FeatureValue::Missing);
265
266 let pipeline_val = apply_transforms(spec, raw)?;
267
268 match pipeline_val {
269 PipelineValue::Scalar(fv) => {
270 let v = scalar_to_f64(&spec.name, fv)?;
271 feature_names.push(spec.name.clone());
272 values.push(v);
273 }
274 PipelineValue::OneHot(vec) => {
275 for (i, v) in vec.into_iter().enumerate() {
276 feature_names.push(format!("{}_cat_{}", spec.name, i));
277 values.push(v);
278 }
279 }
280 }
281 }
282
283 let dim = values.len() as u64;
285 self.total_extractions.fetch_add(1, Ordering::Relaxed);
286 self.extraction_dim_sum.fetch_add(dim, Ordering::Relaxed);
287
288 Ok(ExtractedFeatures {
289 feature_names,
290 values,
291 })
292 }
293
294 pub fn extract_batch(
298 &self,
299 inputs: &[HashMap<String, FeatureValue>],
300 ) -> Result<Vec<ExtractedFeatures>, FeatureError> {
301 if inputs.is_empty() {
302 return Err(FeatureError::EmptyInput);
303 }
304 inputs.iter().map(|inp| self.extract(inp)).collect()
305 }
306
307 pub fn stats(&self) -> FePipelineStats {
309 let total_extractions = self.total_extractions.load(Ordering::Relaxed);
310 let dim_sum = self.extraction_dim_sum.load(Ordering::Relaxed);
311 let avg_output_dim = if total_extractions == 0 {
312 0.0
313 } else {
314 dim_sum as f64 / total_extractions as f64
315 };
316 FePipelineStats {
317 total_features: self.output_dim(),
318 total_extractions,
319 avg_output_dim,
320 }
321 }
322}
323
324impl Default for FeatureExtractor {
325 fn default() -> Self {
326 Self::new()
327 }
328}
329
330pub fn fit_standard_scaler(values: &[f64]) -> FeatureTransform {
339 if values.is_empty() {
340 return FeatureTransform::StandardScaler {
341 mean: 0.0,
342 std: 0.0,
343 };
344 }
345 let n = values.len() as f64;
346 let mean = values.iter().copied().sum::<f64>() / n;
347 let variance = values.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n;
348 let std = variance.sqrt();
349 FeatureTransform::StandardScaler { mean, std }
350}
351
352pub fn fit_minmax_scaler(values: &[f64]) -> FeatureTransform {
356 if values.is_empty() {
357 return FeatureTransform::MinMaxScaler { min: 0.0, max: 0.0 };
358 }
359 let mut min = values[0];
360 let mut max = values[0];
361 for &v in &values[1..] {
362 if v < min {
363 min = v;
364 }
365 if v > max {
366 max = v;
367 }
368 }
369 FeatureTransform::MinMaxScaler { min, max }
370}
371
372pub fn fit_onehot(categories: &[String]) -> FeatureTransform {
376 let mut cats: Vec<String> = categories.to_vec();
377 cats.sort();
378 cats.dedup();
379 FeatureTransform::OneHotEncode { categories: cats }
380}
381
382fn apply_transforms(
388 spec: &FeatureSpec,
389 value: FeatureValue,
390) -> Result<PipelineValue, FeatureError> {
391 let mut current = PipelineValue::Scalar(value);
392
393 for transform in &spec.transforms {
394 current = apply_one_transform(spec, transform, current)?;
395 }
396
397 Ok(current)
398}
399
400fn apply_one_transform(
402 spec: &FeatureSpec,
403 transform: &FeatureTransform,
404 current: PipelineValue,
405) -> Result<PipelineValue, FeatureError> {
406 match transform {
407 FeatureTransform::ImputeMean { mean } => match current {
409 PipelineValue::Scalar(FeatureValue::Missing) => {
410 Ok(PipelineValue::Scalar(FeatureValue::Float(*mean)))
411 }
412 other => Ok(other),
413 },
414
415 FeatureTransform::ImputeMode { mode } => match current {
416 PipelineValue::Scalar(FeatureValue::Missing) => Ok(PipelineValue::Scalar(
417 FeatureValue::Categorical(mode.clone()),
418 )),
419 other => Ok(other),
420 },
421
422 FeatureTransform::StandardScaler { mean, std } => {
424 let x = require_numeric(spec, current)?;
425 let result = if std.abs() < f64::EPSILON {
426 0.0
427 } else {
428 (x - mean) / std
429 };
430 Ok(PipelineValue::Scalar(FeatureValue::Float(result)))
431 }
432
433 FeatureTransform::MinMaxScaler { min, max } => {
434 let x = require_numeric(spec, current)?;
435 let range = max - min;
436 let result = if range.abs() < f64::EPSILON {
437 0.0
438 } else {
439 (x - min) / range
440 };
441 Ok(PipelineValue::Scalar(FeatureValue::Float(result)))
442 }
443
444 FeatureTransform::Log1p => {
445 let x = require_numeric(spec, current)?;
446 let sign = if x >= 0.0 { 1.0 } else { -1.0 };
447 Ok(PipelineValue::Scalar(FeatureValue::Float(
448 (1.0 + x.abs()).ln() * sign,
449 )))
450 }
451
452 FeatureTransform::Sqrt => {
453 let x = require_numeric(spec, current)?;
454 let sign = if x >= 0.0 { 1.0 } else { -1.0 };
455 Ok(PipelineValue::Scalar(FeatureValue::Float(
456 x.abs().sqrt() * sign,
457 )))
458 }
459
460 FeatureTransform::Clip { lo, hi } => {
461 let x = require_numeric(spec, current)?;
462 Ok(PipelineValue::Scalar(FeatureValue::Float(
463 x.max(*lo).min(*hi),
464 )))
465 }
466
467 FeatureTransform::Binarize { threshold } => {
468 let x = require_numeric(spec, current)?;
469 Ok(PipelineValue::Scalar(FeatureValue::Float(
470 if x > *threshold { 1.0 } else { 0.0 },
471 )))
472 }
473
474 FeatureTransform::PolynomialFeatures { degree } => {
475 let x = require_numeric(spec, current)?;
476 if *degree == 0 {
477 return Err(FeatureError::InvalidTransform(
478 "PolynomialFeatures degree must be >= 1".to_string(),
479 ));
480 }
481 let mut poly_vals = Vec::with_capacity(*degree as usize);
484 let mut pow = x;
485 for _ in 1..=*degree {
486 poly_vals.push(pow);
487 pow *= x;
488 }
489 Ok(PipelineValue::OneHot(poly_vals))
490 }
491
492 FeatureTransform::OneHotEncode { categories } => {
494 let cat = require_categorical(spec, current)?;
495 let one_hot: Vec<f64> = categories
496 .iter()
497 .map(|c| if c == &cat { 1.0 } else { 0.0 })
498 .collect();
499 Ok(PipelineValue::OneHot(one_hot))
500 }
501 }
502}
503
504fn require_numeric(spec: &FeatureSpec, pv: PipelineValue) -> Result<f64, FeatureError> {
508 match pv {
509 PipelineValue::Scalar(fv) => match fv.as_f64() {
510 Some(v) => Ok(v),
511 None => {
512 let type_name = fv.type_name();
513 if type_name == "Missing" {
514 Err(FeatureError::MissingFeature(spec.name.clone()))
515 } else {
516 Err(FeatureError::TypeMismatch {
517 feature: spec.name.clone(),
518 expected: "numeric (Float/Integer/Boolean)".to_string(),
519 got: type_name.to_string(),
520 })
521 }
522 }
523 },
524 PipelineValue::OneHot(v) => {
525 Err(FeatureError::TypeMismatch {
528 feature: spec.name.clone(),
529 expected: "numeric scalar".to_string(),
530 got: format!("multi-value vector of length {}", v.len()),
531 })
532 }
533 }
534}
535
536fn require_categorical(spec: &FeatureSpec, pv: PipelineValue) -> Result<String, FeatureError> {
538 match pv {
539 PipelineValue::Scalar(FeatureValue::Categorical(s)) => Ok(s),
540 PipelineValue::Scalar(FeatureValue::Missing) => {
541 Err(FeatureError::MissingFeature(spec.name.clone()))
542 }
543 PipelineValue::Scalar(fv) => Err(FeatureError::TypeMismatch {
544 feature: spec.name.clone(),
545 expected: "Categorical".to_string(),
546 got: fv.type_name().to_string(),
547 }),
548 PipelineValue::OneHot(v) => Err(FeatureError::TypeMismatch {
549 feature: spec.name.clone(),
550 expected: "Categorical".to_string(),
551 got: format!("multi-value vector of length {}", v.len()),
552 }),
553 }
554}
555
556fn scalar_to_f64(name: &str, fv: FeatureValue) -> Result<f64, FeatureError> {
558 match fv.as_f64() {
559 Some(v) => Ok(v),
560 None => {
561 let type_name = fv.type_name();
562 if type_name == "Missing" {
563 Err(FeatureError::MissingFeature(name.to_string()))
564 } else {
565 Err(FeatureError::TypeMismatch {
566 feature: name.to_string(),
567 expected: "numeric scalar".to_string(),
568 got: type_name.to_string(),
569 })
570 }
571 }
572 }
573}
574
575fn spec_output_dim(spec: &FeatureSpec) -> usize {
577 for transform in spec.transforms.iter().rev() {
579 match transform {
580 FeatureTransform::OneHotEncode { categories } => return categories.len(),
581 FeatureTransform::PolynomialFeatures { degree } => return *degree as usize,
582 _ => {}
583 }
584 }
585 1
586}
587
588fn push_feature_names(spec: &FeatureSpec, names: &mut Vec<String>) {
590 let dim = spec_output_dim(spec);
591 if dim == 1 {
592 names.push(spec.name.clone());
593 } else {
594 for i in 0..dim {
596 names.push(format!("{}_cat_{}", spec.name, i));
597 }
598 }
599}
600
601#[cfg(test)]
606mod tests {
607 use std::collections::HashMap;
608
609 use crate::ml_feature_extractor::{
610 fit_minmax_scaler, fit_onehot, fit_standard_scaler, FeatureError, FeatureExtractor,
611 FeatureSpec, FeatureTransform, FeatureValue,
612 };
613
614 fn single_spec(name: &str, transforms: Vec<FeatureTransform>) -> FeatureSpec {
617 FeatureSpec {
618 name: name.to_string(),
619 transforms,
620 }
621 }
622
623 fn make_input(name: &str, val: FeatureValue) -> HashMap<String, FeatureValue> {
624 let mut m = HashMap::new();
625 m.insert(name.to_string(), val);
626 m
627 }
628
629 fn extract_scalar(spec: FeatureSpec, val: FeatureValue) -> f64 {
630 let mut ex = FeatureExtractor::new();
631 ex.add_spec(spec.clone());
632 let input = make_input(&spec.name, val);
633 let res = ex.extract(&input).expect("test: should succeed");
634 res.values[0]
635 }
636
637 #[test]
640 fn test_standard_scaler_basic() {
641 let t = FeatureTransform::StandardScaler {
642 mean: 10.0,
643 std: 2.0,
644 };
645 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(14.0));
646 assert!((v - 2.0).abs() < 1e-12, "v={v}");
647 }
648
649 #[test]
650 fn test_standard_scaler_zero_std() {
651 let t = FeatureTransform::StandardScaler {
652 mean: 5.0,
653 std: 0.0,
654 };
655 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(99.0));
656 assert_eq!(v, 0.0, "zero std must produce 0.0");
657 }
658
659 #[test]
660 fn test_standard_scaler_negative_result() {
661 let t = FeatureTransform::StandardScaler {
662 mean: 10.0,
663 std: 2.0,
664 };
665 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(6.0));
666 assert!((v - (-2.0)).abs() < 1e-12, "v={v}");
667 }
668
669 #[test]
670 fn test_standard_scaler_integer_input() {
671 let t = FeatureTransform::StandardScaler {
672 mean: 0.0,
673 std: 1.0,
674 };
675 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Integer(3));
676 assert!((v - 3.0).abs() < 1e-12);
677 }
678
679 #[test]
680 fn test_standard_scaler_boolean_input() {
681 let t = FeatureTransform::StandardScaler {
682 mean: 0.0,
683 std: 1.0,
684 };
685 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Boolean(true));
686 assert!((v - 1.0).abs() < 1e-12);
687 }
688
689 #[test]
692 fn test_minmax_scaler_midpoint() {
693 let t = FeatureTransform::MinMaxScaler {
694 min: 0.0,
695 max: 10.0,
696 };
697 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(5.0));
698 assert!((v - 0.5).abs() < 1e-12, "v={v}");
699 }
700
701 #[test]
702 fn test_minmax_scaler_at_min() {
703 let t = FeatureTransform::MinMaxScaler {
704 min: 0.0,
705 max: 10.0,
706 };
707 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.0));
708 assert!((v - 0.0).abs() < 1e-12);
709 }
710
711 #[test]
712 fn test_minmax_scaler_at_max() {
713 let t = FeatureTransform::MinMaxScaler {
714 min: 0.0,
715 max: 10.0,
716 };
717 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(10.0));
718 assert!((v - 1.0).abs() < 1e-12);
719 }
720
721 #[test]
722 fn test_minmax_scaler_zero_range() {
723 let t = FeatureTransform::MinMaxScaler { min: 5.0, max: 5.0 };
724 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(5.0));
725 assert_eq!(v, 0.0, "zero range must produce 0.0");
726 }
727
728 #[test]
731 fn test_log1p_positive() {
732 let t = FeatureTransform::Log1p;
733 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.0));
734 assert!((v - 0.0).abs() < 1e-12, "ln(1+0)=0, v={v}");
735 }
736
737 #[test]
738 fn test_log1p_positive_value() {
739 let t = FeatureTransform::Log1p;
740 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(1.0));
742 assert!((v - 2.0_f64.ln()).abs() < 1e-12, "v={v}");
743 }
744
745 #[test]
746 fn test_log1p_negative_value() {
747 let t = FeatureTransform::Log1p;
749 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(-3.0));
750 let expected = -(4.0_f64.ln());
751 assert!((v - expected).abs() < 1e-12, "v={v}, expected={expected}");
752 }
753
754 #[test]
757 fn test_sqrt_positive() {
758 let t = FeatureTransform::Sqrt;
759 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(9.0));
760 assert!((v - 3.0).abs() < 1e-12, "v={v}");
761 }
762
763 #[test]
764 fn test_sqrt_negative() {
765 let t = FeatureTransform::Sqrt;
766 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(-4.0));
768 assert!((v - (-2.0)).abs() < 1e-12, "v={v}");
769 }
770
771 #[test]
772 fn test_sqrt_zero() {
773 let t = FeatureTransform::Sqrt;
774 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.0));
775 assert_eq!(v, 0.0);
776 }
777
778 #[test]
781 fn test_clip_below_lo() {
782 let t = FeatureTransform::Clip { lo: 0.0, hi: 1.0 };
783 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(-5.0));
784 assert_eq!(v, 0.0);
785 }
786
787 #[test]
788 fn test_clip_above_hi() {
789 let t = FeatureTransform::Clip { lo: 0.0, hi: 1.0 };
790 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(10.0));
791 assert_eq!(v, 1.0);
792 }
793
794 #[test]
795 fn test_clip_within_range() {
796 let t = FeatureTransform::Clip { lo: 0.0, hi: 1.0 };
797 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.5));
798 assert!((v - 0.5).abs() < 1e-12);
799 }
800
801 #[test]
804 fn test_binarize_above_threshold() {
805 let t = FeatureTransform::Binarize { threshold: 0.5 };
806 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.7));
807 assert_eq!(v, 1.0);
808 }
809
810 #[test]
811 fn test_binarize_below_threshold() {
812 let t = FeatureTransform::Binarize { threshold: 0.5 };
813 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.3));
814 assert_eq!(v, 0.0);
815 }
816
817 #[test]
818 fn test_binarize_at_threshold() {
819 let t = FeatureTransform::Binarize { threshold: 0.5 };
821 let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.5));
822 assert_eq!(v, 0.0);
823 }
824
825 #[test]
828 fn test_polynomial_degree2() {
829 let spec = single_spec(
830 "x",
831 vec![FeatureTransform::PolynomialFeatures { degree: 2 }],
832 );
833 let mut ex = FeatureExtractor::new();
834 ex.add_spec(spec);
835 let input = make_input("x", FeatureValue::Float(3.0));
836 let res = ex.extract(&input).expect("test: should succeed");
837 assert_eq!(res.values.len(), 2);
838 assert!((res.values[0] - 3.0).abs() < 1e-12, "x={}", res.values[0]);
839 assert!((res.values[1] - 9.0).abs() < 1e-12, "x²={}", res.values[1]);
840 }
841
842 #[test]
843 fn test_polynomial_degree3() {
844 let spec = single_spec(
845 "x",
846 vec![FeatureTransform::PolynomialFeatures { degree: 3 }],
847 );
848 let mut ex = FeatureExtractor::new();
849 ex.add_spec(spec);
850 let input = make_input("x", FeatureValue::Float(2.0));
851 let res = ex.extract(&input).expect("test: should succeed");
852 assert_eq!(res.values.len(), 3);
853 assert!((res.values[0] - 2.0).abs() < 1e-12);
854 assert!((res.values[1] - 4.0).abs() < 1e-12);
855 assert!((res.values[2] - 8.0).abs() < 1e-12);
856 }
857
858 #[test]
859 fn test_polynomial_degree0_error() {
860 let spec = single_spec(
861 "x",
862 vec![FeatureTransform::PolynomialFeatures { degree: 0 }],
863 );
864 let mut ex = FeatureExtractor::new();
865 ex.add_spec(spec);
866 let input = make_input("x", FeatureValue::Float(1.0));
867 let err = ex.extract(&input).unwrap_err();
868 assert!(matches!(err, FeatureError::InvalidTransform(_)));
869 }
870
871 #[test]
874 fn test_onehot_known_category() {
875 let cats = vec!["a".to_string(), "b".to_string(), "c".to_string()];
876 let spec = single_spec(
877 "color",
878 vec![FeatureTransform::OneHotEncode { categories: cats }],
879 );
880 let mut ex = FeatureExtractor::new();
881 ex.add_spec(spec);
882 let input = make_input("color", FeatureValue::Categorical("b".to_string()));
883 let res = ex.extract(&input).expect("test: should succeed");
884 assert_eq!(res.values, vec![0.0, 1.0, 0.0]);
885 }
886
887 #[test]
888 fn test_onehot_unknown_category_all_zeros() {
889 let cats = vec!["a".to_string(), "b".to_string()];
890 let spec = single_spec(
891 "x",
892 vec![FeatureTransform::OneHotEncode { categories: cats }],
893 );
894 let mut ex = FeatureExtractor::new();
895 ex.add_spec(spec);
896 let input = make_input("x", FeatureValue::Categorical("z".to_string()));
897 let res = ex.extract(&input).expect("test: should succeed");
898 assert_eq!(res.values, vec![0.0, 0.0]);
899 }
900
901 #[test]
902 fn test_onehot_feature_names_expanded() {
903 let cats = vec!["x".to_string(), "y".to_string()];
904 let spec = single_spec(
905 "col",
906 vec![FeatureTransform::OneHotEncode { categories: cats }],
907 );
908 let mut ex = FeatureExtractor::new();
909 ex.add_spec(spec);
910 let names = ex.feature_names();
911 assert_eq!(names, vec!["col_cat_0", "col_cat_1"]);
912 }
913
914 #[test]
917 fn test_impute_mean_missing_value() {
918 let t = FeatureTransform::ImputeMean { mean: 42.0 };
919 let spec = single_spec(
920 "x",
921 vec![
922 t,
923 FeatureTransform::StandardScaler {
924 mean: 42.0,
925 std: 1.0,
926 },
927 ],
928 );
929 let mut ex = FeatureExtractor::new();
930 ex.add_spec(spec);
931 let input = make_input("x", FeatureValue::Missing);
932 let res = ex.extract(&input).expect("test: should succeed");
933 assert!((res.values[0] - 0.0).abs() < 1e-12);
935 }
936
937 #[test]
938 fn test_impute_mean_not_missing_unchanged() {
939 let t = FeatureTransform::ImputeMean { mean: 0.0 };
940 let spec = single_spec("x", vec![t]);
941 let v = extract_scalar(spec, FeatureValue::Float(7.0));
942 assert!((v - 7.0).abs() < 1e-12);
943 }
944
945 #[test]
946 fn test_impute_mode_missing_categorical() {
947 let cats = vec!["cat".to_string(), "dog".to_string()];
948 let spec = single_spec(
949 "pet",
950 vec![
951 FeatureTransform::ImputeMode {
952 mode: "cat".to_string(),
953 },
954 FeatureTransform::OneHotEncode { categories: cats },
955 ],
956 );
957 let mut ex = FeatureExtractor::new();
958 ex.add_spec(spec);
959 let input = make_input("pet", FeatureValue::Missing);
960 let res = ex.extract(&input).expect("test: should succeed");
961 assert_eq!(res.values, vec![1.0, 0.0]);
963 }
964
965 #[test]
968 fn test_missing_feature_no_imputer_error() {
969 let t = FeatureTransform::StandardScaler {
970 mean: 0.0,
971 std: 1.0,
972 };
973 let spec = single_spec("x", vec![t]);
974 let mut ex = FeatureExtractor::new();
975 ex.add_spec(spec);
976 let input: HashMap<String, FeatureValue> = HashMap::new();
977 let err = ex.extract(&input).unwrap_err();
978 assert!(matches!(err, FeatureError::MissingFeature(ref name) if name == "x"));
979 }
980
981 #[test]
982 fn test_missing_feature_key_not_in_map_error() {
983 let t = FeatureTransform::StandardScaler {
984 mean: 0.0,
985 std: 1.0,
986 };
987 let spec = single_spec("age", vec![t]);
988 let mut ex = FeatureExtractor::new();
989 ex.add_spec(spec);
990 let input = make_input("height", FeatureValue::Float(1.75));
992 let err = ex.extract(&input).unwrap_err();
993 assert!(matches!(err, FeatureError::MissingFeature(ref name) if name == "age"));
994 }
995
996 #[test]
999 fn test_chain_clip_then_standard_scaler() {
1000 let spec = single_spec(
1002 "x",
1003 vec![
1004 FeatureTransform::Clip { lo: 0.0, hi: 1.0 },
1005 FeatureTransform::StandardScaler {
1006 mean: 0.5,
1007 std: 0.5,
1008 },
1009 ],
1010 );
1011 let v = extract_scalar(spec, FeatureValue::Float(100.0));
1012 assert!((v - 1.0).abs() < 1e-12, "v={v}");
1014 }
1015
1016 #[test]
1017 fn test_chain_impute_then_log1p() {
1018 let spec = single_spec(
1019 "x",
1020 vec![
1021 FeatureTransform::ImputeMean { mean: 0.0 },
1022 FeatureTransform::Log1p,
1023 ],
1024 );
1025 let v = extract_scalar(spec, FeatureValue::Missing);
1026 assert_eq!(v, 0.0);
1028 }
1029
1030 #[test]
1033 fn test_multi_spec_output_order() {
1034 let mut ex = FeatureExtractor::new();
1035 ex.add_spec(single_spec(
1036 "a",
1037 vec![FeatureTransform::StandardScaler {
1038 mean: 0.0,
1039 std: 1.0,
1040 }],
1041 ));
1042 ex.add_spec(single_spec(
1043 "b",
1044 vec![FeatureTransform::MinMaxScaler {
1045 min: 0.0,
1046 max: 10.0,
1047 }],
1048 ));
1049
1050 let mut input = HashMap::new();
1051 input.insert("a".to_string(), FeatureValue::Float(1.0));
1052 input.insert("b".to_string(), FeatureValue::Float(5.0));
1053
1054 let res = ex.extract(&input).expect("test: should succeed");
1055 assert_eq!(res.values.len(), 2);
1056 assert!((res.values[0] - 1.0).abs() < 1e-12); assert!((res.values[1] - 0.5).abs() < 1e-12); }
1059
1060 #[test]
1061 fn test_output_dim() {
1062 let mut ex = FeatureExtractor::new();
1063 ex.add_spec(single_spec(
1064 "a",
1065 vec![FeatureTransform::StandardScaler {
1066 mean: 0.0,
1067 std: 1.0,
1068 }],
1069 ));
1070 ex.add_spec(single_spec(
1071 "b",
1072 vec![FeatureTransform::OneHotEncode {
1073 categories: vec!["x".to_string(), "y".to_string(), "z".to_string()],
1074 }],
1075 ));
1076 ex.add_spec(single_spec(
1077 "c",
1078 vec![FeatureTransform::PolynomialFeatures { degree: 2 }],
1079 ));
1080 assert_eq!(ex.output_dim(), 6);
1082 }
1083
1084 #[test]
1087 fn test_stats_after_single_extract() {
1088 let mut ex = FeatureExtractor::new();
1089 ex.add_spec(single_spec(
1090 "x",
1091 vec![FeatureTransform::StandardScaler {
1092 mean: 0.0,
1093 std: 1.0,
1094 }],
1095 ));
1096 let input = make_input("x", FeatureValue::Float(1.0));
1097 ex.extract(&input).expect("test: should succeed");
1098 let s = ex.stats();
1099 assert_eq!(s.total_extractions, 1);
1100 assert!((s.avg_output_dim - 1.0).abs() < 1e-12);
1101 }
1102
1103 #[test]
1104 fn test_stats_accumulate_over_batch() {
1105 let mut ex = FeatureExtractor::new();
1106 ex.add_spec(single_spec("x", vec![FeatureTransform::Log1p]));
1107 let batch: Vec<HashMap<String, FeatureValue>> = (0..5)
1108 .map(|i| make_input("x", FeatureValue::Float(i as f64)))
1109 .collect();
1110 ex.extract_batch(&batch).expect("test: should succeed");
1111 assert_eq!(ex.stats().total_extractions, 5);
1112 }
1113
1114 #[test]
1117 fn test_extract_batch_empty_returns_error() {
1118 let ex = FeatureExtractor::new();
1119 let err = ex.extract_batch(&[]).unwrap_err();
1120 assert_eq!(err, FeatureError::EmptyInput);
1121 }
1122
1123 #[test]
1124 fn test_extract_batch_multiple_inputs() {
1125 let mut ex = FeatureExtractor::new();
1126 ex.add_spec(single_spec(
1127 "v",
1128 vec![FeatureTransform::MinMaxScaler {
1129 min: 0.0,
1130 max: 10.0,
1131 }],
1132 ));
1133 let batch = vec![
1134 make_input("v", FeatureValue::Float(0.0)),
1135 make_input("v", FeatureValue::Float(5.0)),
1136 make_input("v", FeatureValue::Float(10.0)),
1137 ];
1138 let results = ex.extract_batch(&batch).expect("test: should succeed");
1139 assert_eq!(results.len(), 3);
1140 assert!((results[0].values[0] - 0.0).abs() < 1e-12);
1141 assert!((results[1].values[0] - 0.5).abs() < 1e-12);
1142 assert!((results[2].values[0] - 1.0).abs() < 1e-12);
1143 }
1144
1145 #[test]
1148 fn test_fit_standard_scaler() {
1149 let t = fit_standard_scaler(&[1.0, 2.0, 3.0, 4.0, 5.0]);
1150 if let FeatureTransform::StandardScaler { mean, std } = t {
1151 assert!((mean - 3.0).abs() < 1e-10, "mean={mean}");
1152 assert!((std - 2.0_f64.sqrt()).abs() < 1e-10, "std={std}");
1154 } else {
1155 panic!("expected StandardScaler");
1156 }
1157 }
1158
1159 #[test]
1160 fn test_fit_standard_scaler_empty() {
1161 let t = fit_standard_scaler(&[]);
1162 assert!(matches!(
1163 t,
1164 FeatureTransform::StandardScaler {
1165 mean: 0.0,
1166 std: 0.0
1167 }
1168 ));
1169 }
1170
1171 #[test]
1172 fn test_fit_minmax_scaler() {
1173 let t = fit_minmax_scaler(&[3.0, 1.0, 7.0, -2.0]);
1174 if let FeatureTransform::MinMaxScaler { min, max } = t {
1175 assert!((min - (-2.0)).abs() < 1e-12);
1176 assert!((max - 7.0).abs() < 1e-12);
1177 } else {
1178 panic!("expected MinMaxScaler");
1179 }
1180 }
1181
1182 #[test]
1183 fn test_fit_minmax_scaler_empty() {
1184 let t = fit_minmax_scaler(&[]);
1185 assert!(matches!(
1186 t,
1187 FeatureTransform::MinMaxScaler { min: 0.0, max: 0.0 }
1188 ));
1189 }
1190
1191 #[test]
1192 fn test_fit_onehot_dedup_sort() {
1193 let cats = vec![
1194 "banana".to_string(),
1195 "apple".to_string(),
1196 "banana".to_string(),
1197 "cherry".to_string(),
1198 ];
1199 let t = fit_onehot(&cats);
1200 if let FeatureTransform::OneHotEncode { categories } = t {
1201 assert_eq!(categories, vec!["apple", "banana", "cherry"]);
1202 } else {
1203 panic!("expected OneHotEncode");
1204 }
1205 }
1206
1207 #[test]
1210 fn test_type_mismatch_categorical_to_scaler() {
1211 let t = FeatureTransform::StandardScaler {
1212 mean: 0.0,
1213 std: 1.0,
1214 };
1215 let spec = single_spec("x", vec![t]);
1216 let mut ex = FeatureExtractor::new();
1217 ex.add_spec(spec);
1218 let input = make_input("x", FeatureValue::Categorical("hello".to_string()));
1219 let err = ex.extract(&input).unwrap_err();
1220 assert!(matches!(err, FeatureError::TypeMismatch { .. }));
1221 }
1222
1223 #[test]
1224 fn test_type_mismatch_float_to_onehot() {
1225 let t = FeatureTransform::OneHotEncode {
1226 categories: vec!["a".to_string()],
1227 };
1228 let spec = single_spec("x", vec![t]);
1229 let mut ex = FeatureExtractor::new();
1230 ex.add_spec(spec);
1231 let input = make_input("x", FeatureValue::Float(1.0));
1232 let err = ex.extract(&input).unwrap_err();
1233 assert!(matches!(err, FeatureError::TypeMismatch { .. }));
1234 }
1235
1236 #[test]
1239 fn test_feature_names_alignment_with_values() {
1240 let mut ex = FeatureExtractor::new();
1241 ex.add_spec(single_spec("score", vec![FeatureTransform::Log1p]));
1242 ex.add_spec(single_spec(
1243 "color",
1244 vec![FeatureTransform::OneHotEncode {
1245 categories: vec!["red".to_string(), "blue".to_string()],
1246 }],
1247 ));
1248
1249 let mut input = HashMap::new();
1250 input.insert("score".to_string(), FeatureValue::Float(1.0));
1251 input.insert(
1252 "color".to_string(),
1253 FeatureValue::Categorical("red".to_string()),
1254 );
1255
1256 let res = ex.extract(&input).expect("test: should succeed");
1257 assert_eq!(res.feature_names.len(), res.values.len());
1258 assert_eq!(res.feature_names[0], "score");
1259 assert_eq!(res.feature_names[1], "color_cat_0");
1260 assert_eq!(res.feature_names[2], "color_cat_1");
1261 }
1262}