use crate::NeuralResult;
use scirs2_core::ndarray::{s, Array1, Array2, Axis};
use sklears_core::error::SklearsError;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct InterpretationConfig {
pub n_steps: usize,
pub baseline: Option<Array2<f64>>,
pub use_abs: bool,
pub noise_level: f64,
pub n_samples: usize,
}
impl Default for InterpretationConfig {
fn default() -> Self {
Self {
n_steps: 50,
baseline: None,
use_abs: false,
noise_level: 0.1,
n_samples: 50,
}
}
}
#[derive(Debug, Clone)]
pub struct AttributionScores {
pub scores: Array2<f64>,
pub total_attribution: f64,
pub method: String,
pub metadata: HashMap<String, f64>,
}
impl AttributionScores {
pub fn top_features(&self, k: usize) -> Vec<(usize, f64)> {
let mut feature_scores: Vec<(usize, f64)> = self
.scores
.axis_iter(Axis(1))
.enumerate()
.map(|(idx, col)| {
let total_score = col.iter().map(|&x| x.abs()).sum::<f64>();
(idx, total_score)
})
.collect();
feature_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
feature_scores.into_iter().take(k).collect()
}
pub fn normalize(&mut self) {
let max_val = self.scores.iter().map(|&x| x.abs()).fold(0.0, f64::max);
if max_val > 0.0 {
self.scores.mapv_inplace(|x| x / max_val);
}
}
}
pub trait InterpretableModel {
fn compute_gradients(
&self,
input: &Array2<f64>,
target_class: Option<usize>,
) -> NeuralResult<Array2<f64>>;
fn forward_with_activations(
&self,
input: &Array2<f64>,
) -> NeuralResult<(Array2<f64>, Vec<Array2<f64>>)>;
fn num_classes(&self) -> usize;
}
pub struct ModelInterpreter {
config: InterpretationConfig,
}
impl ModelInterpreter {
pub fn new() -> Self {
Self {
config: InterpretationConfig::default(),
}
}
pub fn with_config(config: InterpretationConfig) -> Self {
Self { config }
}
pub fn vanilla_gradients<M: InterpretableModel>(
&self,
model: &M,
input: &Array2<f64>,
target_class: Option<usize>,
) -> NeuralResult<AttributionScores> {
let gradients = model.compute_gradients(input, target_class)?;
let scores = if self.config.use_abs {
gradients.mapv(|x: f64| x.abs())
} else {
gradients
};
let total_attribution = scores.sum();
let mut metadata = HashMap::new();
metadata.insert(
"max_gradient".to_string(),
scores.iter().fold(0.0f64, |a, &b| a.max(b.abs())),
);
metadata.insert("mean_gradient".to_string(), scores.mean().unwrap_or(0.0));
Ok(AttributionScores {
scores,
total_attribution,
method: "VanillaGradients".to_string(),
metadata,
})
}
pub fn integrated_gradients<M: InterpretableModel>(
&self,
model: &M,
input: &Array2<f64>,
target_class: Option<usize>,
) -> NeuralResult<AttributionScores> {
let baseline = self
.config
.baseline
.clone()
.unwrap_or_else(|| Array2::zeros(input.raw_dim()));
if baseline.raw_dim() != input.raw_dim() {
return Err(SklearsError::ShapeMismatch {
expected: format!("{:?}", input.raw_dim()),
actual: format!("{:?}", baseline.raw_dim()),
});
}
let mut integrated_gradients: Array2<f64> = Array2::zeros(input.raw_dim());
for i in 0..self.config.n_steps {
let alpha = (i as f64 + 1.0) / self.config.n_steps as f64;
let interpolated = &baseline + &((input - &baseline) * alpha);
let gradients = model.compute_gradients(&interpolated, target_class)?;
integrated_gradients = integrated_gradients + gradients;
}
let difference = input - &baseline;
let scores = difference * integrated_gradients / self.config.n_steps as f64;
let scores = if self.config.use_abs {
scores.mapv(|x: f64| x.abs())
} else {
scores
};
let total_attribution = scores.sum();
let mut metadata = HashMap::new();
metadata.insert("n_steps".to_string(), self.config.n_steps as f64);
metadata.insert(
"max_attribution".to_string(),
scores.iter().fold(0.0, |a, &b| a.max(b.abs())),
);
Ok(AttributionScores {
scores,
total_attribution,
method: "IntegratedGradients".to_string(),
metadata,
})
}
pub fn smooth_grad<M: InterpretableModel>(
&self,
model: &M,
input: &Array2<f64>,
target_class: Option<usize>,
) -> NeuralResult<AttributionScores> {
use scirs2_core::random::essentials::Normal;
use scirs2_core::random::prelude::*;
let mut rng = thread_rng();
let noise_dist = Normal::new(0.0, self.config.noise_level).map_err(|_| {
SklearsError::InvalidParameter {
name: "noise_level".to_string(),
reason: "Invalid noise level for normal distribution".to_string(),
}
})?;
let mut total_gradients = Array2::zeros(input.raw_dim());
for _ in 0..self.config.n_samples {
let noise = Array2::from_shape_fn(input.raw_dim(), |_| rng.sample(noise_dist));
let noisy_input = input + &noise;
let gradients = model.compute_gradients(&noisy_input, target_class)?;
total_gradients = total_gradients + gradients;
}
let scores = total_gradients / self.config.n_samples as f64;
let scores = if self.config.use_abs {
scores.mapv(|x: f64| x.abs())
} else {
scores
};
let total_attribution = scores.sum();
let mut metadata = HashMap::new();
metadata.insert("n_samples".to_string(), self.config.n_samples as f64);
metadata.insert("noise_level".to_string(), self.config.noise_level);
metadata.insert("variance".to_string(), scores.var(1.0));
Ok(AttributionScores {
scores,
total_attribution,
method: "SmoothGrad".to_string(),
metadata,
})
}
pub fn guided_backprop<M: InterpretableModel>(
&self,
model: &M,
input: &Array2<f64>,
target_class: Option<usize>,
) -> NeuralResult<AttributionScores> {
let mut gradients = model.compute_gradients(input, target_class)?;
gradients.zip_mut_with(input, |grad, &inp| {
if inp <= 0.0 {
*grad = 0.0;
}
});
let scores = if self.config.use_abs {
gradients.mapv(|x: f64| x.abs())
} else {
gradients
};
let total_attribution = scores.sum();
let mut metadata = HashMap::new();
metadata.insert(
"positive_gradients".to_string(),
scores.iter().filter(|&&x| x > 0.0).count() as f64,
);
Ok(AttributionScores {
scores,
total_attribution,
method: "GuidedBackprop".to_string(),
metadata,
})
}
pub fn layer_relevance_propagation<M: InterpretableModel>(
&self,
model: &M,
input: &Array2<f64>,
target_class: Option<usize>,
) -> NeuralResult<AttributionScores> {
let (output, activations) = model.forward_with_activations(input)?;
let relevance = if let Some(class) = target_class {
let mut r = Array2::zeros(output.raw_dim());
for (i, mut row) in r.axis_iter_mut(Axis(0)).enumerate() {
if let Some(class_val) = output.get((i, class)) {
row[class] = *class_val;
}
}
r
} else {
output.clone()
};
let scores = if self.config.use_abs {
relevance.mapv(|x: f64| x.abs())
} else {
relevance
};
let total_attribution = scores.sum();
let mut metadata = HashMap::new();
metadata.insert("n_layers".to_string(), activations.len() as f64);
Ok(AttributionScores {
scores,
total_attribution,
method: "LayerRelevancePropagation".to_string(),
metadata,
})
}
pub fn comprehensive_analysis<M: InterpretableModel>(
&self,
model: &M,
input: &Array2<f64>,
target_class: Option<usize>,
) -> NeuralResult<Vec<AttributionScores>> {
let methods = vec![
(
"vanilla",
self.vanilla_gradients(model, input, target_class)?,
),
(
"integrated",
self.integrated_gradients(model, input, target_class)?,
),
("smooth", self.smooth_grad(model, input, target_class)?),
("guided", self.guided_backprop(model, input, target_class)?),
(
"lrp",
self.layer_relevance_propagation(model, input, target_class)?,
),
];
Ok(methods.into_iter().map(|(_, scores)| scores).collect())
}
}
impl Default for ModelInterpreter {
fn default() -> Self {
Self::new()
}
}
pub struct FeatureImportanceAnalyzer;
impl FeatureImportanceAnalyzer {
pub fn permutation_importance<M, F>(
model: &M,
input: &Array2<f64>,
targets: &Array1<f64>,
metric_fn: F,
n_repeats: usize,
) -> NeuralResult<Array1<f64>>
where
M: InterpretableModel,
F: Fn(&Array2<f64>, &Array1<f64>) -> f64,
{
use scirs2_core::random::prelude::*;
let n_features = input.ncols();
let mut importances = Array1::zeros(n_features);
let mut rng = thread_rng();
let (baseline_pred, _) = model.forward_with_activations(input)?;
let baseline_score = metric_fn(&baseline_pred, targets);
for feature_idx in 0..n_features {
let mut feature_scores = Vec::new();
for _ in 0..n_repeats {
let mut permuted_input = input.clone();
let mut feature_values: Vec<f64> = permuted_input.column(feature_idx).to_vec();
feature_values.shuffle(&mut rng);
for (i, &val) in feature_values.iter().enumerate() {
permuted_input[[i, feature_idx]] = val;
}
let (permuted_pred, _) = model.forward_with_activations(&permuted_input)?;
let permuted_score = metric_fn(&permuted_pred, targets);
feature_scores.push(baseline_score - permuted_score);
}
importances[feature_idx] = feature_scores.iter().sum::<f64>() / n_repeats as f64;
}
Ok(importances)
}
pub fn shap_values<M: InterpretableModel>(
model: &M,
input: &Array2<f64>,
background: &Array2<f64>,
n_samples: usize,
) -> NeuralResult<Array2<f64>> {
use scirs2_core::random::prelude::*;
let mut rng = thread_rng();
let n_features = input.ncols();
let n_instances = input.nrows();
let mut shap_values = Array2::zeros((n_instances, n_features));
for instance_idx in 0..n_instances {
let instance = input.row(instance_idx);
for _ in 0..n_samples {
let coalition: Vec<bool> = (0..n_features).map(|_| rng.gen_bool(0.5)).collect();
for feature_idx in 0..n_features {
let mut with_feature = background
.row(rng.gen_range(0..background.nrows()))
.to_owned();
let mut without_feature = with_feature.clone();
for (i, &include) in coalition.iter().enumerate() {
if include {
with_feature[i] = instance[i];
without_feature[i] = instance[i];
}
}
with_feature[feature_idx] = instance[feature_idx];
let with_input = with_feature.insert_axis(Axis(0));
let without_input = without_feature.insert_axis(Axis(0));
let (with_pred, _) = model.forward_with_activations(&with_input)?;
let (without_pred, _) = model.forward_with_activations(&without_input)?;
let contribution =
with_pred.mean().unwrap_or(0.0) - without_pred.mean().unwrap_or(0.0);
shap_values[[instance_idx, feature_idx]] += contribution;
}
}
for feature_idx in 0..n_features {
shap_values[[instance_idx, feature_idx]] /= n_samples as f64;
}
}
Ok(shap_values)
}
pub fn lime<M: InterpretableModel>(
model: &M,
instance: &Array1<f64>,
n_samples: usize,
n_features: usize,
kernel_width: f64,
) -> NeuralResult<(Array1<f64>, f64)> {
use scirs2_core::random::prelude::*;
let mut rng = thread_rng();
let n_dims = instance.len();
let mut samples = Vec::new();
let mut predictions = Vec::new();
let mut weights = Vec::new();
for _ in 0..n_samples {
let mask: Array1<f64> =
Array1::from_shape_fn(n_dims, |_| if rng.gen_bool(0.5) { 1.0 } else { 0.0 });
let perturbed = instance * &mask;
let distance = (&perturbed - instance)
.iter()
.map(|&x| x * x)
.sum::<f64>()
.sqrt();
let weight = (-distance * distance / (kernel_width * kernel_width)).exp();
samples.push(perturbed.clone());
let input = perturbed.insert_axis(Axis(0));
let (pred, _) = model.forward_with_activations(&input)?;
predictions.push(pred[[0, 0]]);
weights.push(weight);
}
let (coefficients, intercept) =
Self::weighted_linear_regression(&samples, &predictions, &weights)?;
let mut feature_importance: Vec<(usize, f64)> = coefficients
.iter()
.enumerate()
.map(|(idx, &coef)| (idx, coef.abs()))
.collect();
feature_importance
.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let top_features = feature_importance
.into_iter()
.take(n_features)
.collect::<Vec<_>>();
let mut explanation = Array1::zeros(n_dims);
for (idx, importance) in top_features {
explanation[idx] = importance;
}
Ok((explanation, intercept))
}
fn weighted_linear_regression(
X: &[Array1<f64>],
y: &[f64],
weights: &[f64],
) -> NeuralResult<(Array1<f64>, f64)> {
let n_samples = X.len();
let n_features = X[0].len();
if n_samples == 0 {
return Err(SklearsError::InvalidParameter {
name: "X".to_string(),
reason: "Empty sample array".to_string(),
});
}
let mut design_matrix = Array2::zeros((n_samples, n_features + 1));
let mut target_vector = Array1::zeros(n_samples);
let mut weight_matrix = Array2::zeros((n_samples, n_samples));
for i in 0..n_samples {
design_matrix[[i, 0]] = 1.0; for j in 0..n_features {
design_matrix[[i, j + 1]] = X[i][j];
}
target_vector[i] = y[i];
weight_matrix[[i, i]] = weights[i];
}
let mut XtWX = Array2::zeros((n_features + 1, n_features + 1));
let mut XtWy = Array1::zeros(n_features + 1);
for i in 0..n_samples {
let w = weights[i];
for j in 0..(n_features + 1) {
XtWy[j] += w * design_matrix[[i, j]] * target_vector[i];
for k in 0..(n_features + 1) {
XtWX[[j, k]] += w * design_matrix[[i, j]] * design_matrix[[i, k]];
}
}
}
let coefficients_with_intercept = Self::solve_linear_system(XtWX, XtWy)?;
let intercept = coefficients_with_intercept[0];
let coefficients = coefficients_with_intercept.slice(s![1..]).to_owned();
Ok((coefficients, intercept))
}
fn solve_linear_system(mut A: Array2<f64>, mut b: Array1<f64>) -> NeuralResult<Array1<f64>> {
let n = A.nrows();
for i in 0..n {
let mut max_row = i;
for k in (i + 1)..n {
if A[[k, i]].abs() > A[[max_row, i]].abs() {
max_row = k;
}
}
if max_row != i {
for j in 0..n {
let temp = A[[i, j]];
A[[i, j]] = A[[max_row, j]];
A[[max_row, j]] = temp;
}
let temp = b[i];
b[i] = b[max_row];
b[max_row] = temp;
}
if A[[i, i]].abs() < 1e-10 {
return Err(SklearsError::InvalidParameter {
name: "A".to_string(),
reason: "Singular matrix".to_string(),
});
}
for k in (i + 1)..n {
let factor = A[[k, i]] / A[[i, i]];
for j in i..n {
A[[k, j]] -= factor * A[[i, j]];
}
b[k] -= factor * b[i];
}
}
let mut x = Array1::zeros(n);
for i in (0..n).rev() {
let mut sum = b[i];
for j in (i + 1)..n {
sum -= A[[i, j]] * x[j];
}
x[i] = sum / A[[i, i]];
}
Ok(x)
}
}
#[derive(Debug, Clone)]
pub struct ConceptActivationVector {
pub concept_name: String,
pub vector: Array1<f64>,
pub accuracy: f64,
pub layer_name: String,
}
impl ConceptActivationVector {
pub fn new(
concept_name: String,
vector: Array1<f64>,
accuracy: f64,
layer_name: String,
) -> Self {
Self {
concept_name,
vector,
accuracy,
layer_name,
}
}
pub fn directional_derivative(&self, gradient: &Array1<f64>) -> f64 {
let cav_norm = self.vector.iter().map(|&x| x * x).sum::<f64>().sqrt();
if cav_norm < 1e-10 {
return 0.0;
}
let normalized_cav = &self.vector / cav_norm;
gradient
.iter()
.zip(normalized_cav.iter())
.map(|(&g, &c)| g * c)
.sum()
}
}
pub struct TCAVAnalyzer;
impl TCAVAnalyzer {
pub fn train_cav(
concept_activations: &Array2<f64>,
random_activations: &Array2<f64>,
concept_name: String,
layer_name: String,
) -> NeuralResult<ConceptActivationVector> {
let n_concept = concept_activations.nrows();
let n_random = random_activations.nrows();
let n_features = concept_activations.ncols();
if random_activations.ncols() != n_features {
return Err(SklearsError::InvalidParameter {
name: "activations".to_string(),
reason: "Concept and random activations must have same number of features"
.to_string(),
});
}
let n_total = n_concept + n_random;
let mut X = Array2::zeros((n_total, n_features));
let mut y = Array1::zeros(n_total);
for i in 0..n_concept {
for j in 0..n_features {
X[[i, j]] = concept_activations[[i, j]];
}
y[i] = 1.0;
}
for i in 0..n_random {
for j in 0..n_features {
X[[n_concept + i, j]] = random_activations[[i, j]];
}
y[n_concept + i] = 0.0;
}
let (weights, accuracy) = Self::train_linear_classifier(&X, &y)?;
Ok(ConceptActivationVector::new(
concept_name,
weights,
accuracy,
layer_name,
))
}
fn train_linear_classifier(
X: &Array2<f64>,
y: &Array1<f64>,
) -> NeuralResult<(Array1<f64>, f64)> {
let n_samples = X.nrows();
let n_features = X.ncols();
if n_samples == 0 {
return Err(SklearsError::InvalidParameter {
name: "X".to_string(),
reason: "Empty training data".to_string(),
});
}
let mut XtX = Array2::zeros((n_features, n_features));
let mut Xty = Array1::zeros(n_features);
for i in 0..n_samples {
for j in 0..n_features {
Xty[j] += X[[i, j]] * y[i];
for k in 0..n_features {
XtX[[j, k]] += X[[i, j]] * X[[i, k]];
}
}
}
let lambda = 0.01;
for i in 0..n_features {
XtX[[i, i]] += lambda;
}
let weights = FeatureImportanceAnalyzer::solve_linear_system(XtX, Xty)?;
let mut correct = 0;
for i in 0..n_samples {
let prediction = X
.row(i)
.iter()
.zip(weights.iter())
.map(|(&x, &w)| x * w)
.sum::<f64>();
let pred_class = if prediction > 0.5 { 1.0 } else { 0.0 };
if (pred_class - y[i]).abs() < 0.5 {
correct += 1;
}
}
let accuracy = correct as f64 / n_samples as f64;
Ok((weights, accuracy))
}
pub fn tcav_score<M: InterpretableModel>(
model: &M,
cav: &ConceptActivationVector,
test_instances: &Array2<f64>,
target_class: usize,
) -> NeuralResult<f64> {
let n_instances = test_instances.nrows();
let mut positive_count = 0;
for i in 0..n_instances {
let instance = test_instances.row(i).to_owned().insert_axis(Axis(0));
let gradients = model.compute_gradients(&instance, Some(target_class))?;
let grad_flat = gradients.row(0).to_owned();
let sensitivity = cav.directional_derivative(&grad_flat);
if sensitivity > 0.0 {
positive_count += 1;
}
}
let tcav = positive_count as f64 / n_instances as f64;
Ok(tcav)
}
pub fn tcav_significance_test<M: InterpretableModel>(
model: &M,
concept_cav: &ConceptActivationVector,
test_instances: &Array2<f64>,
random_cavs: &[ConceptActivationVector],
target_class: usize,
) -> NeuralResult<(f64, f64)> {
let concept_tcav = Self::tcav_score(model, concept_cav, test_instances, target_class)?;
let mut random_tcavs = Vec::new();
for random_cav in random_cavs {
let random_tcav = Self::tcav_score(model, random_cav, test_instances, target_class)?;
random_tcavs.push(random_tcav);
}
let n_higher = random_tcavs.iter().filter(|&&t| t >= concept_tcav).count();
let p_value = n_higher as f64 / random_cavs.len() as f64;
Ok((concept_tcav, p_value))
}
}
#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
struct MockModel {
weights: Array2<f64>,
}
impl InterpretableModel for MockModel {
fn compute_gradients(
&self,
input: &Array2<f64>,
_target_class: Option<usize>,
) -> NeuralResult<Array2<f64>> {
let n_samples = input.nrows();
let mut gradients = Array2::zeros(input.raw_dim());
for i in 0..n_samples {
for j in 0..input.ncols() {
gradients[[i, j]] = self.weights[[0, j % self.weights.ncols()]];
}
}
Ok(gradients)
}
fn forward_with_activations(
&self,
input: &Array2<f64>,
) -> NeuralResult<(Array2<f64>, Vec<Array2<f64>>)> {
let output = input.dot(&self.weights.t());
Ok((output, vec![input.clone()]))
}
fn num_classes(&self) -> usize {
self.weights.nrows()
}
}
#[test]
fn test_vanilla_gradients() {
let model = MockModel {
weights: Array2::from_shape_vec((1, 3), vec![0.5, -0.3, 0.8])
.expect("array shape mismatch"),
};
let input = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, -1.0, 0.5, 1.5])
.expect("array shape mismatch");
let interpreter = ModelInterpreter::new();
let result = interpreter
.vanilla_gradients(&model, &input, None)
.expect("operation should succeed");
assert_eq!(result.method, "VanillaGradients");
assert_eq!(result.scores.raw_dim(), input.raw_dim());
}
#[test]
fn test_integrated_gradients() {
let model = MockModel {
weights: Array2::from_shape_vec((1, 2), vec![1.0, -1.0]).expect("array shape mismatch"),
};
let input = Array2::from_shape_vec((1, 2), vec![2.0, 3.0]).expect("array shape mismatch");
let config = InterpretationConfig {
n_steps: 10,
baseline: Some(Array2::zeros((1, 2))),
..Default::default()
};
let interpreter = ModelInterpreter::with_config(config);
let result = interpreter
.integrated_gradients(&model, &input, None)
.expect("operation should succeed");
assert_eq!(result.method, "IntegratedGradients");
assert_eq!(result.scores.raw_dim(), input.raw_dim());
}
#[test]
fn test_attribution_scores() {
let scores = Array2::from_shape_vec((2, 3), vec![0.5, -0.3, 0.8, -0.2, 0.9, -0.1])
.expect("array shape mismatch");
let mut attribution = AttributionScores {
scores,
total_attribution: 1.6,
method: "Test".to_string(),
metadata: HashMap::new(),
};
let top_features = attribution.top_features(2);
assert_eq!(top_features.len(), 2);
attribution.normalize();
let max_val = attribution
.scores
.iter()
.map(|&x| x.abs())
.fold(0.0, f64::max);
assert!(max_val <= 1.0 + 1e-10);
}
#[test]
fn test_lime_explanation() {
let model = MockModel {
weights: Array2::from_shape_vec((1, 3), vec![0.5, -0.3, 0.8])
.expect("array shape mismatch"),
};
let instance = Array1::from_vec(vec![1.0, 2.0, 3.0]);
let (explanation, intercept) =
FeatureImportanceAnalyzer::lime(&model, &instance, 100, 3, 1.0)
.expect("operation should succeed");
assert_eq!(explanation.len(), 3);
assert!(intercept.is_finite());
assert!(explanation.iter().any(|&x| x.abs() > 0.0));
}
#[test]
fn test_lime_kernel_width() {
let model = MockModel {
weights: Array2::from_shape_vec((1, 2), vec![1.0, -1.0]).expect("array shape mismatch"),
};
let instance = Array1::from_vec(vec![2.0, 3.0]);
let result1 = FeatureImportanceAnalyzer::lime(&model, &instance, 200, 2, 0.5);
let result2 = FeatureImportanceAnalyzer::lime(&model, &instance, 200, 2, 2.0);
if let Ok((exp1, _)) = result1 {
assert_eq!(exp1.len(), 2);
}
if let Ok((exp2, _)) = result2 {
assert_eq!(exp2.len(), 2);
}
}
#[test]
fn test_weighted_linear_regression() {
let X = vec![
Array1::from_vec(vec![1.0, 0.5]),
Array1::from_vec(vec![2.0, 1.2]),
Array1::from_vec(vec![3.0, 2.1]),
Array1::from_vec(vec![4.0, 3.3]),
Array1::from_vec(vec![5.0, 4.8]),
];
let y = vec![2.5, 4.4, 7.1, 10.3, 14.8];
let weights = vec![1.0, 1.0, 1.0, 1.0, 1.0];
let result = FeatureImportanceAnalyzer::weighted_linear_regression(&X, &y, &weights);
match result {
Ok((coefs, intercept)) => {
assert_eq!(coefs.len(), 2);
assert!(intercept.is_finite());
}
Err(_) => {
}
}
}
#[test]
fn test_solve_linear_system() {
let A =
Array2::from_shape_vec((2, 2), vec![2.0, 1.0, 1.0, 3.0]).expect("array shape mismatch");
let b = Array1::from_vec(vec![5.0, 6.0]);
let x =
FeatureImportanceAnalyzer::solve_linear_system(A, b).expect("operation should succeed");
assert_eq!(x.len(), 2);
assert_abs_diff_eq!(x[0], 1.8, epsilon = 0.01);
assert_abs_diff_eq!(x[1], 1.4, epsilon = 0.01);
}
#[test]
fn test_cav_creation() {
let vector = Array1::from_vec(vec![0.5, -0.3, 0.8]);
let cav = ConceptActivationVector::new(
"striped".to_string(),
vector.clone(),
0.85,
"layer_3".to_string(),
);
assert_eq!(cav.concept_name, "striped");
assert_eq!(cav.accuracy, 0.85);
assert_eq!(cav.layer_name, "layer_3");
assert_eq!(cav.vector.len(), 3);
}
#[test]
fn test_cav_directional_derivative() {
let vector = Array1::from_vec(vec![1.0, 0.0, 0.0]);
let cav =
ConceptActivationVector::new("test".to_string(), vector, 0.9, "layer_1".to_string());
let gradient = Array1::from_vec(vec![0.5, 0.3, 0.2]);
let sensitivity = cav.directional_derivative(&gradient);
assert!(sensitivity > 0.4 && sensitivity < 0.6);
}
#[test]
fn test_train_cav() {
let concept_acts = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 1.5, 2.5, 1.2, 2.2])
.expect("array shape mismatch");
let random_acts = Array2::from_shape_vec((3, 2), vec![0.0, 0.5, -0.5, 0.8, 0.2, 0.3])
.expect("array shape mismatch");
let cav = TCAVAnalyzer::train_cav(
&concept_acts,
&random_acts,
"concept_a".to_string(),
"layer_2".to_string(),
)
.expect("operation should succeed");
assert_eq!(cav.concept_name, "concept_a");
assert_eq!(cav.layer_name, "layer_2");
assert_eq!(cav.vector.len(), 2);
assert!((0.0..=1.0).contains(&cav.accuracy));
}
#[test]
fn test_tcav_score() {
let model = MockModel {
weights: Array2::from_shape_vec((1, 3), vec![0.5, -0.3, 0.8])
.expect("array shape mismatch"),
};
let vector = Array1::from_vec(vec![1.0, 0.0, 0.0]);
let cav = ConceptActivationVector::new(
"test_concept".to_string(),
vector,
0.9,
"layer_1".to_string(),
);
let test_instances = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, -1.0, 0.5, 1.5])
.expect("array shape mismatch");
let tcav = TCAVAnalyzer::tcav_score(&model, &cav, &test_instances, 0)
.expect("operation should succeed");
assert!((0.0..=1.0).contains(&tcav));
}
#[test]
fn test_cav_dimension_mismatch() {
let concept_acts = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 1.5, 2.5, 3.5])
.expect("array shape mismatch");
let random_acts = Array2::from_shape_vec((2, 2), vec![0.0, 0.5, -0.5, 0.8])
.expect("array shape mismatch");
let result = TCAVAnalyzer::train_cav(
&concept_acts,
&random_acts,
"test".to_string(),
"layer_1".to_string(),
);
assert!(result.is_err());
}
}