use scirs2_core::ndarray::{ArrayView1, ArrayView2};
use sklears_core::error::{Result as SklResult, SklearsError};
const DEFAULT_BINS: usize = 10;
#[derive(Debug, Clone)]
pub struct FeatureInteractionAnalysis;
impl FeatureInteractionAnalysis {
pub fn analyze_interactions(
features: &ArrayView2<f64>,
target: &ArrayView1<f64>,
feature_indices: &[usize],
) -> SklResult<f64> {
let pairwise = PairwiseInteractions::compute_pairwise(features, target, feature_indices)?;
if pairwise.is_empty() {
return Ok(0.0);
}
let sum: f64 = pairwise.iter().map(|&(_, _, v)| v.abs()).sum();
Ok(sum / pairwise.len() as f64)
}
}
#[derive(Debug, Clone)]
pub struct PairwiseInteractions;
impl PairwiseInteractions {
pub fn compute_pairwise(
features: &ArrayView2<f64>,
target: &ArrayView1<f64>,
feature_indices: &[usize],
) -> SklResult<Vec<(usize, usize, f64)>> {
let n_samples = validate_data(features, target, feature_indices)?;
if n_samples == 0 {
return Err(SklearsError::InvalidInput(
"compute_pairwise requires at least one sample".to_string(),
));
}
let target_binned = discretize(target, DEFAULT_BINS);
let mut feature_binned: Vec<(usize, Vec<usize>)> =
Vec::with_capacity(feature_indices.len());
for &idx in feature_indices {
let column = features.column(idx);
feature_binned.push((idx, discretize(&column, DEFAULT_BINS)));
}
let mut result = Vec::new();
for a in 0..feature_binned.len() {
for b in (a + 1)..feature_binned.len() {
let (idx_a, ref bins_a) = feature_binned[a];
let (idx_b, ref bins_b) = feature_binned[b];
let ii = interaction_information(bins_a, bins_b, &target_binned, DEFAULT_BINS);
let (lo, hi) = if idx_a <= idx_b {
(idx_a, idx_b)
} else {
(idx_b, idx_a)
};
result.push((lo, hi, ii));
}
}
result.sort_by(|x, y| x.0.cmp(&y.0).then(x.1.cmp(&y.1)));
Ok(result)
}
}
#[derive(Debug, Clone)]
pub struct HigherOrderInteractions;
impl HigherOrderInteractions {
pub fn compute_higher_order(
features: &ArrayView2<f64>,
target: &ArrayView1<f64>,
feature_indices: &[usize],
) -> SklResult<f64> {
let n_samples = validate_data(features, target, feature_indices)?;
if n_samples == 0 {
return Err(SklearsError::InvalidInput(
"compute_higher_order requires at least one sample".to_string(),
));
}
if feature_indices.len() < 3 {
return Ok(0.0);
}
let target_binned = discretize(target, DEFAULT_BINS);
let binned: Vec<Vec<usize>> = feature_indices
.iter()
.map(|&idx| discretize(&features.column(idx), DEFAULT_BINS))
.collect();
let n = binned.len();
let mut sum = 0.0;
let mut count = 0usize;
for i in 0..n {
for j in (i + 1)..n {
for k in (j + 1)..n {
let mi_ijk = mutual_information_joint3(
&binned[i],
&binned[j],
&binned[k],
&target_binned,
DEFAULT_BINS,
);
let mi_ij = mutual_information_joint(
&binned[i],
&binned[j],
&target_binned,
DEFAULT_BINS,
);
let mi_ik = mutual_information_joint(
&binned[i],
&binned[k],
&target_binned,
DEFAULT_BINS,
);
let mi_jk = mutual_information_joint(
&binned[j],
&binned[k],
&target_binned,
DEFAULT_BINS,
);
let mi_i = mutual_information_single(&binned[i], &target_binned, DEFAULT_BINS);
let mi_j = mutual_information_single(&binned[j], &target_binned, DEFAULT_BINS);
let mi_k = mutual_information_single(&binned[k], &target_binned, DEFAULT_BINS);
let ii = mi_ijk - mi_ij - mi_ik - mi_jk + mi_i + mi_j + mi_k;
sum += ii.abs();
count += 1;
}
}
}
if count == 0 {
Ok(0.0)
} else {
Ok(sum / count as f64)
}
}
}
#[derive(Debug, Clone)]
pub struct InteractionStrength;
impl InteractionStrength {
pub fn compute_strength(
features: &ArrayView2<f64>,
target: &ArrayView1<f64>,
feature1: usize,
feature2: usize,
) -> SklResult<f64> {
let n_samples = validate_data(features, target, &[feature1, feature2])?;
if n_samples == 0 {
return Err(SklearsError::InvalidInput(
"compute_strength requires at least one sample".to_string(),
));
}
let bins_a = discretize(&features.column(feature1), DEFAULT_BINS);
let bins_b = discretize(&features.column(feature2), DEFAULT_BINS);
let target_binned = discretize(target, DEFAULT_BINS);
let ii = interaction_information(&bins_a, &bins_b, &target_binned, DEFAULT_BINS);
Ok(ii.abs())
}
}
#[derive(Debug, Clone)]
pub struct SynergyDetection;
impl SynergyDetection {
pub fn detect_synergy(
features: &ArrayView2<f64>,
target: &ArrayView1<f64>,
feature_indices: &[usize],
threshold: f64,
) -> SklResult<Vec<Vec<usize>>> {
let pairwise = PairwiseInteractions::compute_pairwise(features, target, feature_indices)?;
let synergistic = pairwise
.into_iter()
.filter(|&(_, _, ii)| ii > threshold)
.map(|(i, j, _)| vec![i, j])
.collect();
Ok(synergistic)
}
}
fn validate_data(
features: &ArrayView2<f64>,
target: &ArrayView1<f64>,
feature_indices: &[usize],
) -> SklResult<usize> {
let n_samples = features.nrows();
let n_features = features.ncols();
if target.len() != n_samples {
return Err(SklearsError::InvalidInput(format!(
"target length ({}) must equal number of samples ({})",
target.len(),
n_samples
)));
}
for &idx in feature_indices {
if idx >= n_features {
return Err(SklearsError::InvalidInput(format!(
"feature index {idx} out of range 0..{n_features}"
)));
}
}
Ok(n_samples)
}
fn discretize(values: &ArrayView1<f64>, n_bins: usize) -> Vec<usize> {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for &v in values.iter() {
if v < min {
min = v;
}
if v > max {
max = v;
}
}
let range = max - min;
if range <= 0.0 || n_bins == 0 {
return vec![0usize; values.len()];
}
let width = range / n_bins as f64;
values
.iter()
.map(|&v| {
let mut bin = ((v - min) / width) as usize;
if bin >= n_bins {
bin = n_bins - 1; }
bin
})
.collect()
}
fn entropy(labels: &[usize], n_bins: usize) -> f64 {
let n = labels.len();
if n == 0 {
return 0.0;
}
let mut counts = vec![0usize; n_bins.max(1)];
for &l in labels {
counts[l] += 1;
}
let n_f = n as f64;
let mut h = 0.0;
for &c in &counts {
if c > 0 {
let p = c as f64 / n_f;
h -= p * p.ln();
}
}
h
}
fn joint_entropy2(a: &[usize], b: &[usize], n_bins: usize) -> f64 {
let n = a.len();
if n == 0 {
return 0.0;
}
let nb = n_bins.max(1);
let mut counts = vec![0usize; nb * nb];
for (&x, &y) in a.iter().zip(b.iter()) {
counts[x * nb + y] += 1;
}
let n_f = n as f64;
let mut h = 0.0;
for &c in &counts {
if c > 0 {
let p = c as f64 / n_f;
h -= p * p.ln();
}
}
h
}
fn joint_entropy3(a: &[usize], b: &[usize], c: &[usize], n_bins: usize) -> f64 {
let n = a.len();
if n == 0 {
return 0.0;
}
use std::collections::HashMap;
let mut counts: HashMap<(usize, usize, usize), usize> = HashMap::new();
for ((&x, &y), &z) in a.iter().zip(b.iter()).zip(c.iter()) {
*counts.entry((x, y, z)).or_insert(0) += 1;
}
let _ = n_bins; let n_f = n as f64;
let mut h = 0.0;
for &cnt in counts.values() {
let p = cnt as f64 / n_f;
h -= p * p.ln();
}
h
}
fn joint_entropy4(a: &[usize], b: &[usize], c: &[usize], d: &[usize]) -> f64 {
let n = a.len();
if n == 0 {
return 0.0;
}
use std::collections::HashMap;
let mut counts: HashMap<(usize, usize, usize, usize), usize> = HashMap::new();
for (((&w, &x), &y), &z) in a.iter().zip(b.iter()).zip(c.iter()).zip(d.iter()) {
*counts.entry((w, x, y, z)).or_insert(0) += 1;
}
let n_f = n as f64;
let mut h = 0.0;
for &cnt in counts.values() {
let p = cnt as f64 / n_f;
h -= p * p.ln();
}
h
}
fn mutual_information_single(feature: &[usize], target: &[usize], n_bins: usize) -> f64 {
let h_x = entropy(feature, n_bins);
let h_y = entropy(target, n_bins);
let h_xy = joint_entropy2(feature, target, n_bins);
(h_x + h_y - h_xy).max(0.0)
}
fn mutual_information_joint(
feature_a: &[usize],
feature_b: &[usize],
target: &[usize],
n_bins: usize,
) -> f64 {
let h_ab = joint_entropy2(feature_a, feature_b, n_bins);
let h_y = entropy(target, n_bins);
let h_aby = joint_entropy3(feature_a, feature_b, target, n_bins);
(h_ab + h_y - h_aby).max(0.0)
}
fn mutual_information_joint3(
feature_a: &[usize],
feature_b: &[usize],
feature_c: &[usize],
target: &[usize],
n_bins: usize,
) -> f64 {
let h_abc = joint_entropy3(feature_a, feature_b, feature_c, n_bins);
let h_y = entropy(target, n_bins);
let h_abcy = joint_entropy4(feature_a, feature_b, feature_c, target);
(h_abc + h_y - h_abcy).max(0.0)
}
fn interaction_information(
feature_a: &[usize],
feature_b: &[usize],
target: &[usize],
n_bins: usize,
) -> f64 {
let mi_joint = mutual_information_joint(feature_a, feature_b, target, n_bins);
let mi_a = mutual_information_single(feature_a, target, n_bins);
let mi_b = mutual_information_single(feature_b, target, n_bins);
mi_joint - mi_a - mi_b
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::{array, Array2};
#[test]
fn discretize_constant_is_single_bin() {
let v = array![3.0, 3.0, 3.0];
let bins = discretize(&v.view(), DEFAULT_BINS);
assert_eq!(bins, vec![0, 0, 0]);
}
#[test]
fn discretize_spreads_values() {
let v = array![0.0, 1.0, 9.0, 10.0];
let bins = discretize(&v.view(), DEFAULT_BINS);
assert_eq!(bins, vec![0, 1, 9, 9]);
}
#[test]
fn entropy_uniform_two_bins() {
let labels = vec![0, 1, 0, 1];
let h = entropy(&labels, 2);
assert!((h - 2.0f64.ln()).abs() < 1e-12);
}
#[test]
fn mutual_information_identity_equals_entropy() {
let x = vec![0, 1, 0, 1, 0, 1];
let mi = mutual_information_single(&x, &x, 2);
assert!((mi - 2.0f64.ln()).abs() < 1e-12);
}
#[test]
fn mutual_information_independent_is_zero() {
let x = vec![0, 0, 1, 1];
let y = vec![0, 1, 0, 1];
let mi = mutual_information_single(&x, &y, 2);
assert!(mi.abs() < 1e-12);
}
#[test]
fn xor_pair_is_synergistic() {
let features =
Array2::from_shape_vec((4, 2), vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]).unwrap();
let target = array![0.0, 1.0, 1.0, 0.0]; let pairwise =
PairwiseInteractions::compute_pairwise(&features.view(), &target.view(), &[0, 1])
.unwrap();
assert_eq!(pairwise.len(), 1);
let (i, j, ii) = pairwise[0];
assert_eq!((i, j), (0, 1));
assert!(ii > 0.0);
assert!((ii - 2.0f64.ln()).abs() < 1e-9);
}
#[test]
fn redundant_pair_is_negative() {
let features =
Array2::from_shape_vec((4, 2), vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).unwrap();
let target = array![0.0, 0.0, 1.0, 1.0];
let strength =
InteractionStrength::compute_strength(&features.view(), &target.view(), 0, 1).unwrap();
assert!((strength - 2.0f64.ln()).abs() < 1e-9);
let syn = SynergyDetection::detect_synergy(&features.view(), &target.view(), &[0, 1], 0.0)
.unwrap();
assert!(syn.is_empty());
}
#[test]
fn detect_synergy_flags_xor() {
let features =
Array2::from_shape_vec((4, 2), vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]).unwrap();
let target = array![0.0, 1.0, 1.0, 0.0];
let syn = SynergyDetection::detect_synergy(&features.view(), &target.view(), &[0, 1], 0.0)
.unwrap();
assert_eq!(syn, vec![vec![0, 1]]);
}
#[test]
fn analyze_interactions_single_feature_is_zero() {
let features = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
let target = array![0.0, 1.0, 0.0];
let v = FeatureInteractionAnalysis::analyze_interactions(
&features.view(),
&target.view(),
&[0],
)
.unwrap();
assert!(v.abs() < 1e-12);
}
#[test]
fn analyze_interactions_matches_pairwise_mean_magnitude() {
let features =
Array2::from_shape_vec((4, 2), vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]).unwrap();
let target = array![0.0, 1.0, 1.0, 0.0];
let agg = FeatureInteractionAnalysis::analyze_interactions(
&features.view(),
&target.view(),
&[0, 1],
)
.unwrap();
assert!((agg - 2.0f64.ln()).abs() < 1e-9);
}
#[test]
fn higher_order_requires_three_features() {
let features =
Array2::from_shape_vec((4, 2), vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]).unwrap();
let target = array![0.0, 1.0, 1.0, 0.0];
let v = HigherOrderInteractions::compute_higher_order(
&features.view(),
&target.view(),
&[0, 1],
)
.unwrap();
assert!(v.abs() < 1e-12);
}
#[test]
fn higher_order_three_way_xor() {
let mut data = Vec::new();
let mut target_vals = Vec::new();
for x1 in 0..2usize {
for x2 in 0..2usize {
for x3 in 0..2usize {
data.push(x1 as f64);
data.push(x2 as f64);
data.push(x3 as f64);
target_vals.push(((x1 ^ x2 ^ x3) as f64).to_owned());
}
}
}
let features = Array2::from_shape_vec((8, 3), data).unwrap();
let target = scirs2_core::ndarray::Array1::from_vec(target_vals);
let v = HigherOrderInteractions::compute_higher_order(
&features.view(),
&target.view(),
&[0, 1, 2],
)
.unwrap();
assert!(v.is_finite());
assert!(v > 0.0);
}
#[test]
fn validation_rejects_bad_input() {
let features = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
let target = array![0.0, 1.0, 0.0];
assert!(
PairwiseInteractions::compute_pairwise(&features.view(), &target.view(), &[0, 5])
.is_err()
);
let bad_target = array![0.0, 1.0];
assert!(PairwiseInteractions::compute_pairwise(
&features.view(),
&bad_target.view(),
&[0, 1]
)
.is_err());
}
}