shap-rs 0.1.0

Native Rust implementations of model-agnostic, linear, and TreeSHAP explainers
Documentation
#![cfg(feature = "json-adapters")]

use ndarray::{Array1, Array2, Array3, ArrayView2};
use serde::Deserialize;
use shap_rs::{
    explainers::{
        ExactExplainer, HierarchicalPartitionExplainer, KernelExplainer, LinearExplainer,
        PartitionNode, PartitionTree, PermutationExplainer,
    },
    Background, Explainer, Explanation, FnModel, Link,
};

#[derive(Deserialize)]
struct Expected {
    values: Vec<Vec<Vec<f64>>>,
    base_values: Vec<Vec<f64>>,
}

#[derive(Deserialize)]
struct Fixture {
    background: Vec<Vec<f64>>,
    samples: Vec<Vec<f64>>,
    coefficients: Vec<Vec<f64>>,
    intercept: Vec<f64>,
    exact: Expected,
    permutation: Expected,
    kernel: Expected,
    linear: Expected,
    partition: Expected,
    exact_logit: Expected,
}

fn matrix(rows: &[Vec<f64>]) -> Array2<f64> {
    Array2::from_shape_vec(
        (rows.len(), rows.first().unwrap().len()),
        rows.iter().flatten().copied().collect(),
    )
    .unwrap()
}

fn assert_matches(actual: &Explanation, expected: &Expected, tolerance: f64) {
    let dimensions = (
        expected.values.len(),
        expected.values[0].len(),
        expected.values[0][0].len(),
    );
    let values = Array3::from_shape_vec(
        dimensions,
        expected
            .values
            .iter()
            .flatten()
            .flatten()
            .copied()
            .collect(),
    )
    .unwrap();
    let bases = matrix(&expected.base_values);
    assert_eq!(actual.values().dim(), values.dim());
    assert_eq!(actual.base_values().dim(), bases.dim());
    for (left, right) in actual.values().iter().zip(values.iter()) {
        assert!((left - right).abs() <= tolerance, "{left} != {right}");
    }
    for (left, right) in actual.base_values().iter().zip(bases.iter()) {
        assert!((left - right).abs() <= tolerance, "{left} != {right}");
    }
}

#[test]
fn core_explainers_match_python_shap_046() {
    let fixture: Fixture =
        serde_json::from_str(include_str!("fixtures/python_shap_linear.json")).unwrap();
    let background_data = matrix(&fixture.background);
    let samples = matrix(&fixture.samples);
    let coefficients = matrix(&fixture.coefficients);
    let intercept = Array1::from(fixture.intercept.clone());
    let make_model = || {
        let coefficients = coefficients.clone();
        let intercept = intercept.clone();
        FnModel::new(move |x: ArrayView2<'_, f64>| Ok(x.dot(&coefficients) + &intercept))
    };
    let make_background = || Background::new(background_data.clone()).unwrap();

    assert_matches(
        &ExactExplainer::new(make_model(), make_background())
            .explain(samples.view())
            .unwrap(),
        &fixture.exact,
        1e-10,
    );
    assert_matches(
        &PermutationExplainer::new(make_model(), make_background())
            .with_n_permutations(300)
            .with_seed(7)
            .explain(samples.view())
            .unwrap(),
        &fixture.permutation,
        1e-10,
    );
    assert_matches(
        &KernelExplainer::new(make_model(), make_background())
            .with_nsamples(256)
            .with_seed(7)
            .explain(samples.view())
            .unwrap(),
        &fixture.kernel,
        1e-8,
    );
    assert_matches(
        &LinearExplainer::new(coefficients.clone(), intercept.clone(), make_background())
            .unwrap()
            .explain(samples.view())
            .unwrap(),
        &fixture.linear,
        1e-10,
    );
    let hierarchy = PartitionTree::new(
        PartitionNode::Group(
            Box::new(PartitionNode::Group(
                Box::new(PartitionNode::Feature(0)),
                Box::new(PartitionNode::Feature(1)),
            )),
            Box::new(PartitionNode::Feature(2)),
        ),
        3,
    )
    .unwrap();
    assert_matches(
        &HierarchicalPartitionExplainer::new(make_model(), make_background(), hierarchy)
            .explain(samples.view())
            .unwrap(),
        &fixture.partition,
        1e-10,
    );
    let probability_model = FnModel::new(|x: ArrayView2<'_, f64>| {
        Ok(Array2::from_shape_fn((x.nrows(), 1), |(row, _)| {
            let margin = 0.7 * x[[row, 0]] - 0.4 * x[[row, 1]] + 0.2 * x[[row, 2]] + 0.1;
            1.0 / (1.0 + (-margin).exp())
        }))
    });
    assert_matches(
        &ExactExplainer::new(probability_model, make_background())
            .with_link(Link::Logit)
            .explain(samples.view())
            .unwrap(),
        &fixture.exact_logit,
        1e-10,
    );
}