Skip to main content

sklears_feature_selection/evaluation/
interaction_analysis.rs

1//! Feature interaction analysis for understanding feature relationships
2//!
3//! This module implements real, data-driven feature-interaction measures based on
4//! information theory. All quantities are estimated from a feature matrix and a
5//! target vector using histogram (equal-width binning) density estimators.
6//!
7//! The central quantity is the **interaction information** of a feature pair with
8//! the target,
9//!
10//! ```text
11//! II(X_i; X_j; Y) = I(X_i, X_j ; Y) - I(X_i ; Y) - I(X_j ; Y)
12//! ```
13//!
14//! where `I(X_i, X_j ; Y)` is the mutual information between the *joint* variable
15//! `(X_i, X_j)` and the target `Y`. A positive value indicates **synergy** (the two
16//! features jointly carry more information about the target than the sum of their
17//! individual contributions); a negative value indicates **redundancy** (the
18//! features share information about the target). All mutual-information terms are
19//! computed in nats (natural logarithm).
20//!
21//! Because these estimators require the underlying data, the public methods take a
22//! feature matrix `features` (shape `n_samples x n_features`, columns are features)
23//! and a target vector `target` (length `n_samples`), together with the indices of
24//! the features to analyse. Continuous variables are discretised into a fixed number
25//! of equal-width bins; this is a standard, documented MI estimator and the only
26//! approximation involved.
27
28use scirs2_core::ndarray::{ArrayView1, ArrayView2};
29use sklears_core::error::{Result as SklResult, SklearsError};
30
31/// Default number of equal-width bins used to discretise continuous variables.
32const DEFAULT_BINS: usize = 10;
33
34/// Feature interaction analysis producing an aggregate interaction measure.
35#[derive(Debug, Clone)]
36pub struct FeatureInteractionAnalysis;
37
38impl FeatureInteractionAnalysis {
39    /// Analyse the overall interaction strength among a set of features.
40    ///
41    /// Returns the mean magnitude of the pairwise interaction information
42    /// `|II(X_i; X_j; Y)|` over all distinct feature pairs in `feature_indices`.
43    /// This summarises how strongly the selected features interact (synergistically
44    /// or redundantly) with respect to the target, irrespective of sign. A value of
45    /// `0.0` means the features carry independent information about the target. When
46    /// fewer than two features are supplied there are no pairs and the result is
47    /// `0.0`.
48    ///
49    /// `features` has shape `n_samples x n_features` (columns are features) and
50    /// `target` has length `n_samples`. Continuous variables are discretised into
51    /// `DEFAULT_BINS` equal-width bins.
52    pub fn analyze_interactions(
53        features: &ArrayView2<f64>,
54        target: &ArrayView1<f64>,
55        feature_indices: &[usize],
56    ) -> SklResult<f64> {
57        let pairwise = PairwiseInteractions::compute_pairwise(features, target, feature_indices)?;
58        if pairwise.is_empty() {
59            return Ok(0.0);
60        }
61        let sum: f64 = pairwise.iter().map(|&(_, _, v)| v.abs()).sum();
62        Ok(sum / pairwise.len() as f64)
63    }
64}
65
66/// Pairwise feature interaction analysis.
67#[derive(Debug, Clone)]
68pub struct PairwiseInteractions;
69
70impl PairwiseInteractions {
71    /// Compute the pairwise interaction information of every distinct feature pair.
72    ///
73    /// For each pair `(i, j)` of feature indices, returns the tuple
74    /// `(i, j, II(X_i; X_j; Y))` where `II` is the interaction information defined in
75    /// the module documentation. Positive values indicate synergy, negative values
76    /// indicate redundancy. The pairs are returned in lexicographic order of
77    /// `(i, j)`.
78    ///
79    /// `features` has shape `n_samples x n_features`; `target` has length
80    /// `n_samples`. Returns an error if any index is out of bounds, the data has no
81    /// samples, or the feature/target lengths are inconsistent.
82    pub fn compute_pairwise(
83        features: &ArrayView2<f64>,
84        target: &ArrayView1<f64>,
85        feature_indices: &[usize],
86    ) -> SklResult<Vec<(usize, usize, f64)>> {
87        let n_samples = validate_data(features, target, feature_indices)?;
88        if n_samples == 0 {
89            return Err(SklearsError::InvalidInput(
90                "compute_pairwise requires at least one sample".to_string(),
91            ));
92        }
93
94        // Pre-discretise the target and every requested feature once.
95        let target_binned = discretize(target, DEFAULT_BINS);
96        let mut feature_binned: Vec<(usize, Vec<usize>)> =
97            Vec::with_capacity(feature_indices.len());
98        for &idx in feature_indices {
99            let column = features.column(idx);
100            feature_binned.push((idx, discretize(&column, DEFAULT_BINS)));
101        }
102
103        let mut result = Vec::new();
104        for a in 0..feature_binned.len() {
105            for b in (a + 1)..feature_binned.len() {
106                let (idx_a, ref bins_a) = feature_binned[a];
107                let (idx_b, ref bins_b) = feature_binned[b];
108                let ii = interaction_information(bins_a, bins_b, &target_binned, DEFAULT_BINS);
109                let (lo, hi) = if idx_a <= idx_b {
110                    (idx_a, idx_b)
111                } else {
112                    (idx_b, idx_a)
113                };
114                result.push((lo, hi, ii));
115            }
116        }
117        result.sort_by(|x, y| x.0.cmp(&y.0).then(x.1.cmp(&y.1)));
118        Ok(result)
119    }
120}
121
122/// Higher-order (three-way) interaction analysis.
123#[derive(Debug, Clone)]
124pub struct HigherOrderInteractions;
125
126impl HigherOrderInteractions {
127    /// Compute the mean magnitude of three-way interaction information.
128    ///
129    /// For each distinct triple of features `(i, j, k)` the three-way interaction
130    /// information with the target is estimated as
131    ///
132    /// ```text
133    /// II(X_i; X_j; X_k; Y) = I(X_i, X_j, X_k ; Y)
134    ///                        - I(X_i, X_j ; Y) - I(X_i, X_k ; Y) - I(X_j, X_k ; Y)
135    ///                        + I(X_i ; Y) + I(X_j ; Y) + I(X_k ; Y)
136    /// ```
137    ///
138    /// which is the inclusion-exclusion form of the interaction information. The
139    /// returned scalar is the mean of `|II|` over all triples; it is `0.0` when
140    /// fewer than three features are supplied. Mutual-information terms use the same
141    /// histogram estimator as the rest of the module.
142    pub fn compute_higher_order(
143        features: &ArrayView2<f64>,
144        target: &ArrayView1<f64>,
145        feature_indices: &[usize],
146    ) -> SklResult<f64> {
147        let n_samples = validate_data(features, target, feature_indices)?;
148        if n_samples == 0 {
149            return Err(SklearsError::InvalidInput(
150                "compute_higher_order requires at least one sample".to_string(),
151            ));
152        }
153        if feature_indices.len() < 3 {
154            return Ok(0.0);
155        }
156
157        let target_binned = discretize(target, DEFAULT_BINS);
158        let binned: Vec<Vec<usize>> = feature_indices
159            .iter()
160            .map(|&idx| discretize(&features.column(idx), DEFAULT_BINS))
161            .collect();
162
163        let n = binned.len();
164        let mut sum = 0.0;
165        let mut count = 0usize;
166        for i in 0..n {
167            for j in (i + 1)..n {
168                for k in (j + 1)..n {
169                    let mi_ijk = mutual_information_joint3(
170                        &binned[i],
171                        &binned[j],
172                        &binned[k],
173                        &target_binned,
174                        DEFAULT_BINS,
175                    );
176                    let mi_ij = mutual_information_joint(
177                        &binned[i],
178                        &binned[j],
179                        &target_binned,
180                        DEFAULT_BINS,
181                    );
182                    let mi_ik = mutual_information_joint(
183                        &binned[i],
184                        &binned[k],
185                        &target_binned,
186                        DEFAULT_BINS,
187                    );
188                    let mi_jk = mutual_information_joint(
189                        &binned[j],
190                        &binned[k],
191                        &target_binned,
192                        DEFAULT_BINS,
193                    );
194                    let mi_i = mutual_information_single(&binned[i], &target_binned, DEFAULT_BINS);
195                    let mi_j = mutual_information_single(&binned[j], &target_binned, DEFAULT_BINS);
196                    let mi_k = mutual_information_single(&binned[k], &target_binned, DEFAULT_BINS);
197
198                    let ii = mi_ijk - mi_ij - mi_ik - mi_jk + mi_i + mi_j + mi_k;
199                    sum += ii.abs();
200                    count += 1;
201                }
202            }
203        }
204
205        if count == 0 {
206            Ok(0.0)
207        } else {
208            Ok(sum / count as f64)
209        }
210    }
211}
212
213/// Interaction strength between a single pair of features.
214#[derive(Debug, Clone)]
215pub struct InteractionStrength;
216
217impl InteractionStrength {
218    /// Compute the interaction strength between two specific features.
219    ///
220    /// Returns the absolute interaction information `|II(X_i; X_j; Y)|` for the pair
221    /// `(feature1, feature2)` — a non-negative measure of how strongly the two
222    /// features interact with respect to the target (regardless of synergy vs
223    /// redundancy). Returns an error if either index is out of bounds or the data is
224    /// empty.
225    pub fn compute_strength(
226        features: &ArrayView2<f64>,
227        target: &ArrayView1<f64>,
228        feature1: usize,
229        feature2: usize,
230    ) -> SklResult<f64> {
231        let n_samples = validate_data(features, target, &[feature1, feature2])?;
232        if n_samples == 0 {
233            return Err(SklearsError::InvalidInput(
234                "compute_strength requires at least one sample".to_string(),
235            ));
236        }
237        let bins_a = discretize(&features.column(feature1), DEFAULT_BINS);
238        let bins_b = discretize(&features.column(feature2), DEFAULT_BINS);
239        let target_binned = discretize(target, DEFAULT_BINS);
240        let ii = interaction_information(&bins_a, &bins_b, &target_binned, DEFAULT_BINS);
241        Ok(ii.abs())
242    }
243}
244
245/// Synergy detection over feature pairs.
246#[derive(Debug, Clone)]
247pub struct SynergyDetection;
248
249impl SynergyDetection {
250    /// Detect synergistic feature pairs.
251    ///
252    /// Returns the list of feature pairs (each as a two-element `Vec`) whose
253    /// interaction information with the target is strictly positive by more than
254    /// `threshold`, i.e. pairs that are genuinely synergistic (carry jointly more
255    /// information about the target than individually). Pairs are returned in
256    /// lexicographic order of their indices. A `threshold` of `0.0` returns all
257    /// strictly-synergistic pairs.
258    pub fn detect_synergy(
259        features: &ArrayView2<f64>,
260        target: &ArrayView1<f64>,
261        feature_indices: &[usize],
262        threshold: f64,
263    ) -> SklResult<Vec<Vec<usize>>> {
264        let pairwise = PairwiseInteractions::compute_pairwise(features, target, feature_indices)?;
265        let synergistic = pairwise
266            .into_iter()
267            .filter(|&(_, _, ii)| ii > threshold)
268            .map(|(i, j, _)| vec![i, j])
269            .collect();
270        Ok(synergistic)
271    }
272}
273
274/// Validate the feature matrix, target, and the requested feature indices.
275/// Returns the number of samples on success.
276fn validate_data(
277    features: &ArrayView2<f64>,
278    target: &ArrayView1<f64>,
279    feature_indices: &[usize],
280) -> SklResult<usize> {
281    let n_samples = features.nrows();
282    let n_features = features.ncols();
283
284    if target.len() != n_samples {
285        return Err(SklearsError::InvalidInput(format!(
286            "target length ({}) must equal number of samples ({})",
287            target.len(),
288            n_samples
289        )));
290    }
291
292    for &idx in feature_indices {
293        if idx >= n_features {
294            return Err(SklearsError::InvalidInput(format!(
295                "feature index {idx} out of range 0..{n_features}"
296            )));
297        }
298    }
299
300    Ok(n_samples)
301}
302
303/// Discretise a continuous variable into `n_bins` equal-width bins.
304///
305/// Returns a bin index in `0..n_bins` for every sample. A constant variable maps
306/// every sample to bin `0`, yielding zero mutual information with anything (the
307/// information-theoretically correct outcome for a degenerate feature).
308fn discretize(values: &ArrayView1<f64>, n_bins: usize) -> Vec<usize> {
309    let mut min = f64::INFINITY;
310    let mut max = f64::NEG_INFINITY;
311    for &v in values.iter() {
312        if v < min {
313            min = v;
314        }
315        if v > max {
316            max = v;
317        }
318    }
319
320    let range = max - min;
321    if range <= 0.0 || n_bins == 0 {
322        return vec![0usize; values.len()];
323    }
324
325    let width = range / n_bins as f64;
326    values
327        .iter()
328        .map(|&v| {
329            let mut bin = ((v - min) / width) as usize;
330            if bin >= n_bins {
331                bin = n_bins - 1; // the maximum value falls into the last bin
332            }
333            bin
334        })
335        .collect()
336}
337
338/// Shannon entropy (in nats) of a discrete sequence of bin labels.
339fn entropy(labels: &[usize], n_bins: usize) -> f64 {
340    let n = labels.len();
341    if n == 0 {
342        return 0.0;
343    }
344    let mut counts = vec![0usize; n_bins.max(1)];
345    for &l in labels {
346        counts[l] += 1;
347    }
348    let n_f = n as f64;
349    let mut h = 0.0;
350    for &c in &counts {
351        if c > 0 {
352            let p = c as f64 / n_f;
353            h -= p * p.ln();
354        }
355    }
356    h
357}
358
359/// Joint entropy (in nats) of two discrete sequences of bin labels.
360fn joint_entropy2(a: &[usize], b: &[usize], n_bins: usize) -> f64 {
361    let n = a.len();
362    if n == 0 {
363        return 0.0;
364    }
365    let nb = n_bins.max(1);
366    let mut counts = vec![0usize; nb * nb];
367    for (&x, &y) in a.iter().zip(b.iter()) {
368        counts[x * nb + y] += 1;
369    }
370    let n_f = n as f64;
371    let mut h = 0.0;
372    for &c in &counts {
373        if c > 0 {
374            let p = c as f64 / n_f;
375            h -= p * p.ln();
376        }
377    }
378    h
379}
380
381/// Joint entropy (in nats) of three discrete sequences of bin labels.
382fn joint_entropy3(a: &[usize], b: &[usize], c: &[usize], n_bins: usize) -> f64 {
383    let n = a.len();
384    if n == 0 {
385        return 0.0;
386    }
387    use std::collections::HashMap;
388    let mut counts: HashMap<(usize, usize, usize), usize> = HashMap::new();
389    for ((&x, &y), &z) in a.iter().zip(b.iter()).zip(c.iter()) {
390        *counts.entry((x, y, z)).or_insert(0) += 1;
391    }
392    let _ = n_bins; // bin count not needed when using a sparse map
393    let n_f = n as f64;
394    let mut h = 0.0;
395    for &cnt in counts.values() {
396        let p = cnt as f64 / n_f;
397        h -= p * p.ln();
398    }
399    h
400}
401
402/// Joint entropy (in nats) of four discrete sequences of bin labels.
403fn joint_entropy4(a: &[usize], b: &[usize], c: &[usize], d: &[usize]) -> f64 {
404    let n = a.len();
405    if n == 0 {
406        return 0.0;
407    }
408    use std::collections::HashMap;
409    let mut counts: HashMap<(usize, usize, usize, usize), usize> = HashMap::new();
410    for (((&w, &x), &y), &z) in a.iter().zip(b.iter()).zip(c.iter()).zip(d.iter()) {
411        *counts.entry((w, x, y, z)).or_insert(0) += 1;
412    }
413    let n_f = n as f64;
414    let mut h = 0.0;
415    for &cnt in counts.values() {
416        let p = cnt as f64 / n_f;
417        h -= p * p.ln();
418    }
419    h
420}
421
422/// Mutual information `I(X ; Y)` of a single discrete feature and the target,
423/// computed as `H(X) + H(Y) - H(X, Y)` (in nats).
424fn mutual_information_single(feature: &[usize], target: &[usize], n_bins: usize) -> f64 {
425    let h_x = entropy(feature, n_bins);
426    let h_y = entropy(target, n_bins);
427    let h_xy = joint_entropy2(feature, target, n_bins);
428    (h_x + h_y - h_xy).max(0.0)
429}
430
431/// Mutual information `I(X_i, X_j ; Y)` between a feature pair and the target,
432/// computed as `H(X_i, X_j) + H(Y) - H(X_i, X_j, Y)` (in nats).
433fn mutual_information_joint(
434    feature_a: &[usize],
435    feature_b: &[usize],
436    target: &[usize],
437    n_bins: usize,
438) -> f64 {
439    let h_ab = joint_entropy2(feature_a, feature_b, n_bins);
440    let h_y = entropy(target, n_bins);
441    let h_aby = joint_entropy3(feature_a, feature_b, target, n_bins);
442    (h_ab + h_y - h_aby).max(0.0)
443}
444
445/// Mutual information `I(X_i, X_j, X_k ; Y)` between a feature triple and target,
446/// computed as `H(X_i, X_j, X_k) + H(Y) - H(X_i, X_j, X_k, Y)` (in nats).
447fn mutual_information_joint3(
448    feature_a: &[usize],
449    feature_b: &[usize],
450    feature_c: &[usize],
451    target: &[usize],
452    n_bins: usize,
453) -> f64 {
454    let h_abc = joint_entropy3(feature_a, feature_b, feature_c, n_bins);
455    let h_y = entropy(target, n_bins);
456    let h_abcy = joint_entropy4(feature_a, feature_b, feature_c, target);
457    (h_abc + h_y - h_abcy).max(0.0)
458}
459
460/// Interaction information `II(X_i; X_j; Y) = I(X_i, X_j ; Y) - I(X_i ; Y) - I(X_j ; Y)`.
461fn interaction_information(
462    feature_a: &[usize],
463    feature_b: &[usize],
464    target: &[usize],
465    n_bins: usize,
466) -> f64 {
467    let mi_joint = mutual_information_joint(feature_a, feature_b, target, n_bins);
468    let mi_a = mutual_information_single(feature_a, target, n_bins);
469    let mi_b = mutual_information_single(feature_b, target, n_bins);
470    mi_joint - mi_a - mi_b
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use scirs2_core::ndarray::{array, Array2};
477
478    #[test]
479    fn discretize_constant_is_single_bin() {
480        let v = array![3.0, 3.0, 3.0];
481        let bins = discretize(&v.view(), DEFAULT_BINS);
482        assert_eq!(bins, vec![0, 0, 0]);
483    }
484
485    #[test]
486    fn discretize_spreads_values() {
487        // Range [0, 10), 10 bins of width 1.0: value v -> bin floor(v), max into last bin.
488        let v = array![0.0, 1.0, 9.0, 10.0];
489        let bins = discretize(&v.view(), DEFAULT_BINS);
490        assert_eq!(bins, vec![0, 1, 9, 9]);
491    }
492
493    #[test]
494    fn entropy_uniform_two_bins() {
495        // Balanced binary labels -> entropy ln(2).
496        let labels = vec![0, 1, 0, 1];
497        let h = entropy(&labels, 2);
498        assert!((h - 2.0f64.ln()).abs() < 1e-12);
499    }
500
501    #[test]
502    fn mutual_information_identity_equals_entropy() {
503        // I(X; X) = H(X). For balanced binary X, that is ln(2).
504        let x = vec![0, 1, 0, 1, 0, 1];
505        let mi = mutual_information_single(&x, &x, 2);
506        assert!((mi - 2.0f64.ln()).abs() < 1e-12);
507    }
508
509    #[test]
510    fn mutual_information_independent_is_zero() {
511        // X and Y constructed to be independent: every (x,y) combination equally likely.
512        let x = vec![0, 0, 1, 1];
513        let y = vec![0, 1, 0, 1];
514        let mi = mutual_information_single(&x, &y, 2);
515        assert!(mi.abs() < 1e-12);
516    }
517
518    #[test]
519    fn xor_pair_is_synergistic() {
520        // Classic XOR: Y = X1 XOR X2. Each feature alone is independent of Y
521        // (zero MI), but together they fully determine Y. Interaction information
522        // II = I(X1,X2;Y) - I(X1;Y) - I(X2;Y) = ln(2) - 0 - 0 = ln(2) > 0 (synergy).
523        // Rows: (x1, x2) over the 4 combinations; columns 0 and 1 are the features.
524        let features =
525            Array2::from_shape_vec((4, 2), vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]).unwrap();
526        let target = array![0.0, 1.0, 1.0, 0.0]; // XOR
527        let pairwise =
528            PairwiseInteractions::compute_pairwise(&features.view(), &target.view(), &[0, 1])
529                .unwrap();
530        assert_eq!(pairwise.len(), 1);
531        let (i, j, ii) = pairwise[0];
532        assert_eq!((i, j), (0, 1));
533        // Synergy should be approximately ln(2).
534        assert!(ii > 0.0);
535        assert!((ii - 2.0f64.ln()).abs() < 1e-9);
536    }
537
538    #[test]
539    fn redundant_pair_is_negative() {
540        // Two identical copies of a feature that perfectly predicts Y.
541        // I(X1,X2;Y) = I(X1;Y) = I(X2;Y) = ln(2), so
542        // II = ln(2) - ln(2) - ln(2) = -ln(2) < 0 (redundancy).
543        let features =
544            Array2::from_shape_vec((4, 2), vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).unwrap();
545        let target = array![0.0, 0.0, 1.0, 1.0];
546        let strength =
547            InteractionStrength::compute_strength(&features.view(), &target.view(), 0, 1).unwrap();
548        // |II| = ln(2).
549        assert!((strength - 2.0f64.ln()).abs() < 1e-9);
550
551        // detect_synergy must NOT flag a redundant pair as synergistic.
552        let syn = SynergyDetection::detect_synergy(&features.view(), &target.view(), &[0, 1], 0.0)
553            .unwrap();
554        assert!(syn.is_empty());
555    }
556
557    #[test]
558    fn detect_synergy_flags_xor() {
559        let features =
560            Array2::from_shape_vec((4, 2), vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]).unwrap();
561        let target = array![0.0, 1.0, 1.0, 0.0];
562        let syn = SynergyDetection::detect_synergy(&features.view(), &target.view(), &[0, 1], 0.0)
563            .unwrap();
564        assert_eq!(syn, vec![vec![0, 1]]);
565    }
566
567    #[test]
568    fn analyze_interactions_single_feature_is_zero() {
569        let features = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
570        let target = array![0.0, 1.0, 0.0];
571        let v = FeatureInteractionAnalysis::analyze_interactions(
572            &features.view(),
573            &target.view(),
574            &[0],
575        )
576        .unwrap();
577        assert!(v.abs() < 1e-12);
578    }
579
580    #[test]
581    fn analyze_interactions_matches_pairwise_mean_magnitude() {
582        let features =
583            Array2::from_shape_vec((4, 2), vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]).unwrap();
584        let target = array![0.0, 1.0, 1.0, 0.0];
585        let agg = FeatureInteractionAnalysis::analyze_interactions(
586            &features.view(),
587            &target.view(),
588            &[0, 1],
589        )
590        .unwrap();
591        // Single pair -> aggregate equals |II| of that pair == ln(2).
592        assert!((agg - 2.0f64.ln()).abs() < 1e-9);
593    }
594
595    #[test]
596    fn higher_order_requires_three_features() {
597        let features =
598            Array2::from_shape_vec((4, 2), vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]).unwrap();
599        let target = array![0.0, 1.0, 1.0, 0.0];
600        let v = HigherOrderInteractions::compute_higher_order(
601            &features.view(),
602            &target.view(),
603            &[0, 1],
604        )
605        .unwrap();
606        assert!(v.abs() < 1e-12);
607    }
608
609    #[test]
610    fn higher_order_three_way_xor() {
611        // Y = X1 XOR X2 XOR X3 over all 8 combinations. The three-way interaction
612        // information is non-zero; we assert it is computed and finite/positive in
613        // magnitude (this is a genuine 3-way synergy).
614        let mut data = Vec::new();
615        let mut target_vals = Vec::new();
616        for x1 in 0..2usize {
617            for x2 in 0..2usize {
618                for x3 in 0..2usize {
619                    data.push(x1 as f64);
620                    data.push(x2 as f64);
621                    data.push(x3 as f64);
622                    target_vals.push(((x1 ^ x2 ^ x3) as f64).to_owned());
623                }
624            }
625        }
626        let features = Array2::from_shape_vec((8, 3), data).unwrap();
627        let target = scirs2_core::ndarray::Array1::from_vec(target_vals);
628        let v = HigherOrderInteractions::compute_higher_order(
629            &features.view(),
630            &target.view(),
631            &[0, 1, 2],
632        )
633        .unwrap();
634        assert!(v.is_finite());
635        assert!(v > 0.0);
636    }
637
638    #[test]
639    fn validation_rejects_bad_input() {
640        let features = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
641        let target = array![0.0, 1.0, 0.0];
642        // Out-of-range feature index.
643        assert!(
644            PairwiseInteractions::compute_pairwise(&features.view(), &target.view(), &[0, 5])
645                .is_err()
646        );
647        // Mismatched target length.
648        let bad_target = array![0.0, 1.0];
649        assert!(PairwiseInteractions::compute_pairwise(
650            &features.view(),
651            &bad_target.view(),
652            &[0, 1]
653        )
654        .is_err());
655    }
656}