1use scirs2_core::ndarray::{ArrayView1, ArrayView2};
29use sklears_core::error::{Result as SklResult, SklearsError};
30
31const DEFAULT_BINS: usize = 10;
33
34#[derive(Debug, Clone)]
36pub struct FeatureInteractionAnalysis;
37
38impl FeatureInteractionAnalysis {
39 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#[derive(Debug, Clone)]
68pub struct PairwiseInteractions;
69
70impl PairwiseInteractions {
71 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 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#[derive(Debug, Clone)]
124pub struct HigherOrderInteractions;
125
126impl HigherOrderInteractions {
127 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#[derive(Debug, Clone)]
215pub struct InteractionStrength;
216
217impl InteractionStrength {
218 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#[derive(Debug, Clone)]
247pub struct SynergyDetection;
248
249impl SynergyDetection {
250 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
274fn 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
303fn 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; }
333 bin
334 })
335 .collect()
336}
337
338fn 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
359fn 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
381fn 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; 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
402fn 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
422fn 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
431fn 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
445fn 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
460fn 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 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 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 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 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 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]; 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 assert!(ii > 0.0);
535 assert!((ii - 2.0f64.ln()).abs() < 1e-9);
536 }
537
538 #[test]
539 fn redundant_pair_is_negative() {
540 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 assert!((strength - 2.0f64.ln()).abs() < 1e-9);
550
551 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 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 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 assert!(
644 PairwiseInteractions::compute_pairwise(&features.view(), &target.view(), &[0, 5])
645 .is_err()
646 );
647 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}