Skip to main content

shap_rs/explainers/
gradient.rs

1use crate::{
2    Background, DifferentiablePredict, Explainer, Explanation, Result, ShapError,
3    UncertainExplanation,
4};
5use ndarray::{Array2, Array3, ArrayView2, Axis, Slice};
6use rand::{rngs::StdRng, Rng, SeedableRng};
7/// Expected Gradients (Gradient SHAP) with background interpolation and
8/// optional Gaussian local smoothing.
9pub struct GradientExplainer<M> {
10    model: M,
11    background: Background,
12    nsamples: usize,
13    seed: u64,
14    local_smoothing: f64,
15    batch_size: usize,
16}
17impl<M> GradientExplainer<M> {
18    pub fn new(model: M, background: Background) -> Self {
19        Self {
20            model,
21            background,
22            nsamples: 256,
23            seed: 0,
24            local_smoothing: 0.0,
25            batch_size: 256,
26        }
27    }
28    pub fn with_nsamples(mut self, n: usize) -> Self {
29        self.nsamples = n;
30        self
31    }
32    pub fn with_seed(mut self, s: u64) -> Self {
33        self.seed = s;
34        self
35    }
36    pub fn with_local_smoothing(mut self, s: f64) -> Self {
37        self.local_smoothing = s;
38        self
39    }
40    /// Limits gradient rows submitted to the autodiff backend in one call.
41    pub fn with_batch_size(mut self, batch_size: usize) -> Self {
42        self.batch_size = batch_size;
43        self
44    }
45}
46impl<M: DifferentiablePredict> GradientExplainer<M> {
47    /// Repeats Expected Gradients with independent deterministic seeds and
48    /// returns the standard error of the mean attribution.
49    pub fn explain_with_uncertainty(
50        &self,
51        x: ArrayView2<'_, f64>,
52        repeats: usize,
53    ) -> Result<UncertainExplanation> {
54        if repeats < 2 {
55            return Err(ShapError::InvalidConfiguration(
56                "uncertainty estimation requires at least two repeats".into(),
57            ));
58        }
59        let mut runs = Vec::with_capacity(repeats);
60        for repeat in 0..repeats {
61            runs.push(
62                GradientExplainer {
63                    model: &self.model,
64                    background: self.background.clone(),
65                    nsamples: self.nsamples,
66                    seed: self.seed.wrapping_add(repeat as u64),
67                    local_smoothing: self.local_smoothing,
68                    batch_size: self.batch_size,
69                }
70                .explain(x)?,
71            );
72        }
73        let shape = runs[0].values().dim();
74        let mut mean = Array3::<f64>::zeros(shape);
75        for run in &runs {
76            ndarray::Zip::from(&mut mean)
77                .and(run.values())
78                .for_each(|average, &value| *average += value);
79        }
80        mean.mapv_inplace(|value| value / repeats as f64);
81        let mut variance = Array3::<f64>::zeros(shape);
82        for run in &runs {
83            ndarray::Zip::from(&mut variance)
84                .and(run.values())
85                .and(&mean)
86                .for_each(|sum, &value, &average| *sum += (value - average).powi(2));
87        }
88        let standard_errors =
89            variance.mapv(|value| (value / ((repeats - 1) * repeats) as f64).sqrt());
90        let explanation = Explanation::new(mean, runs[0].base_values().to_owned(), x.to_owned())?;
91        UncertainExplanation::new(explanation, standard_errors, repeats)
92    }
93}
94impl<M: DifferentiablePredict> Explainer for GradientExplainer<M> {
95    fn explain(&self, x: ArrayView2<'_, f64>) -> Result<Explanation> {
96        let m = self.background.n_features();
97        if x.nrows() == 0 {
98            return Err(ShapError::EmptyData);
99        }
100        if x.ncols() != m {
101            return Err(ShapError::DimensionMismatch {
102                expected: format!("{m} features"),
103                found: format!("{}", x.ncols()),
104            });
105        }
106        if self.nsamples == 0
107            || self.batch_size == 0
108            || !self.local_smoothing.is_finite()
109            || self.local_smoothing < 0.
110        {
111            return Err(ShapError::InvalidConfiguration(
112                "nsamples and batch size must be positive and local smoothing non-negative".into(),
113            ));
114        }
115        let prediction = self.model.predict(self.background.data())?;
116        if prediction.nrows() != self.background.n_samples() || prediction.ncols() == 0 {
117            return Err(ShapError::DimensionMismatch {
118                expected: format!("{} background predictions", self.background.n_samples()),
119                found: format!("{:?}", prediction.dim()),
120            });
121        }
122        let base = prediction.mean_axis(Axis(0)).unwrap();
123        let o = base.len();
124        crate::error::checked_f64_shape(&[x.nrows(), m, o], "gradient explanation")?;
125        crate::error::checked_f64_shape(&[self.nsamples, m], "gradient sampling batch")?;
126        let bases = Array2::from_shape_fn((x.nrows(), o), |(_, k)| base[k]);
127        let std = feature_std(&self.background);
128        let mut values = Array3::zeros((x.nrows(), m, o));
129        for n in 0..x.nrows() {
130            let mut rng = StdRng::seed_from_u64(crate::coalition::sample_seed(self.seed, x.row(n)));
131            let mut points = Array2::zeros((self.nsamples, m));
132            let mut deltas = Array2::zeros((self.nsamples, m));
133            for s in 0..self.nsamples {
134                let b = rng.gen_range(0..self.background.n_samples());
135                let alpha = rng.gen::<f64>();
136                for j in 0..m {
137                    let noise = if self.local_smoothing > 0. {
138                        gaussian(&mut rng) * self.local_smoothing * std[j]
139                    } else {
140                        0.
141                    };
142                    let delta = x[[n, j]] + noise - self.background.data()[[b, j]];
143                    deltas[[s, j]] = delta;
144                    points[[s, j]] = self.background.data()[[b, j]] + alpha * delta
145                }
146            }
147            for start in (0..self.nsamples).step_by(self.batch_size) {
148                let end = start.saturating_add(self.batch_size).min(self.nsamples);
149                let gradients = self
150                    .model
151                    .gradients(points.slice_axis(Axis(0), Slice::from(start..end)))?;
152                if gradients.dim() != (end - start, m, o) {
153                    return Err(ShapError::DimensionMismatch {
154                        expected: format!("({}, {m}, {o}) gradients", end - start),
155                        found: format!("{:?}", gradients.dim()),
156                    });
157                }
158                if gradients.iter().any(|v| !v.is_finite()) {
159                    return Err(ShapError::ModelError(
160                        "gradient contains a non-finite value".into(),
161                    ));
162                }
163                for j in 0..m {
164                    for k in 0..o {
165                        values[[n, j, k]] += (start..end)
166                            .map(|sample| gradients[[sample - start, j, k]] * deltas[[sample, j]])
167                            .sum::<f64>()
168                            / self.nsamples as f64
169                    }
170                }
171            }
172        }
173        Explanation::new(values, bases, x.to_owned())
174    }
175}
176fn gaussian<R: Rng + ?Sized>(rng: &mut R) -> f64 {
177    let u1 = rng.gen::<f64>().max(f64::MIN_POSITIVE);
178    let u2 = rng.gen::<f64>();
179    (-2. * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
180}
181fn feature_std(bg: &Background) -> Vec<f64> {
182    let mean = bg.data().mean_axis(Axis(0)).unwrap();
183    (0..bg.n_features())
184        .map(|j| {
185            (bg.data()
186                .column(j)
187                .iter()
188                .map(|x| (x - mean[j]).powi(2))
189                .sum::<f64>()
190                / bg.n_samples() as f64)
191                .sqrt()
192        })
193        .collect()
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::Predict;
200    use ndarray::array;
201    struct Linear;
202    impl Predict for Linear {
203        fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
204            Ok(x.map_axis(Axis(1), |r| 2. * r[0] - r[1])
205                .insert_axis(Axis(1)))
206        }
207    }
208    impl DifferentiablePredict for Linear {
209        fn gradients(&self, x: ArrayView2<'_, f64>) -> Result<Array3<f64>> {
210            Ok(Array3::from_shape_fn((x.nrows(), 2, 1), |(_, j, _)| {
211                if j == 0 {
212                    2.
213                } else {
214                    -1.
215                }
216            }))
217        }
218    }
219    #[test]
220    fn expected_gradients_is_exact_for_linear_models() {
221        let e = GradientExplainer::new(Linear, Background::new(array![[0., 0.]]).unwrap())
222            .with_nsamples(32)
223            .with_batch_size(3)
224            .explain(array![[3., 4.]].view())
225            .unwrap();
226        assert!((e.values()[[0, 0, 0]] - 6.).abs() < 1e-12);
227        assert!((e.values()[[0, 1, 0]] + 4.).abs() < 1e-12);
228        assert!((e.reconstructed()[[0, 0]] - 2.).abs() < 1e-12);
229    }
230
231    #[test]
232    fn reports_uncertainty_for_stochastic_expected_gradients() {
233        let e =
234            GradientExplainer::new(Linear, Background::new(array![[0., 0.], [2., 4.]]).unwrap())
235                .with_nsamples(16)
236                .explain_with_uncertainty(array![[3., 4.]].view(), 4)
237                .unwrap();
238        assert_eq!(e.repeats(), 4);
239        assert_eq!(e.standard_errors().dim(), (1, 2, 1));
240        assert!(e.standard_errors().iter().all(|value| value.is_finite()));
241    }
242}