Skip to main content

shap_rs/explainers/
linear.rs

1use crate::{Background, Explainer, Explanation, Result, ShapError};
2use ndarray::{Array1, Array2, Array3, ArrayView2};
3/// Exact interventional SHAP values for `y = x W + intercept`.
4pub struct LinearExplainer {
5    coefficients: Array2<f64>,
6    intercept: Array1<f64>,
7    background: Background,
8}
9
10/// Exact linear SHAP under a multivariate-Gaussian feature distribution.
11/// Missing features use their conditional expectation given present features.
12pub struct CorrelatedLinearExplainer {
13    coefficients: Array2<f64>,
14    intercept: Array1<f64>,
15    mean: Array1<f64>,
16    covariance: Array2<f64>,
17    max_features: usize,
18    ridge: f64,
19}
20impl CorrelatedLinearExplainer {
21    pub fn new(
22        coefficients: Array2<f64>,
23        intercept: Array1<f64>,
24        mean: Array1<f64>,
25        covariance: Array2<f64>,
26    ) -> Result<Self> {
27        let m = coefficients.nrows();
28        if mean.len() != m || covariance.dim() != (m, m) || coefficients.ncols() != intercept.len()
29        {
30            return Err(ShapError::DimensionMismatch {
31                expected: format!("{m}-feature mean/covariance and matching outputs"),
32                found: format!(
33                    "mean {}, covariance {:?}, intercept {}",
34                    mean.len(),
35                    covariance.dim(),
36                    intercept.len()
37                ),
38            });
39        }
40        if covariance
41            .iter()
42            .chain(mean.iter())
43            .chain(coefficients.iter())
44            .chain(intercept.iter())
45            .any(|v| !v.is_finite())
46        {
47            return Err(ShapError::InvalidConfiguration(
48                "linear parameters must be finite".into(),
49            ));
50        }
51        for i in 0..m {
52            for j in 0..m {
53                if (covariance[[i, j]] - covariance[[j, i]]).abs() > 1e-10 {
54                    return Err(ShapError::InvalidConfiguration(
55                        "covariance must be symmetric".into(),
56                    ));
57                }
58            }
59        }
60        validate_positive_semidefinite(&covariance)?;
61        Ok(Self {
62            coefficients,
63            intercept,
64            mean,
65            covariance,
66            max_features: 16,
67            ridge: 1e-10,
68        })
69    }
70    pub fn with_max_features(mut self, n: usize) -> Self {
71        self.max_features = n;
72        self
73    }
74    pub fn with_ridge(mut self, x: f64) -> Self {
75        self.ridge = x;
76        self
77    }
78    fn value(&self, x: ndarray::ArrayView1<'_, f64>, mask: u64) -> Result<Vec<f64>> {
79        let m = self.mean.len();
80        let present = (0..m).filter(|&j| mask & (1 << j) != 0).collect::<Vec<_>>();
81        let absent = (0..m).filter(|&j| mask & (1 << j) == 0).collect::<Vec<_>>();
82        let mut expected = self.mean.clone();
83        for &j in &present {
84            expected[j] = x[j]
85        }
86        if !present.is_empty() {
87            let mut a = vec![vec![0.; present.len()]; present.len()];
88            let mut delta = vec![0.; present.len()];
89            for (i, &r) in present.iter().enumerate() {
90                delta[i] = x[r] - self.mean[r];
91                for (j, &c) in present.iter().enumerate() {
92                    a[i][j] = self.covariance[[r, c]]
93                }
94                a[i][i] += self.ridge
95            }
96            let alpha = solve_vector(a, delta)?;
97            for &u in &absent {
98                expected[u] = self.mean[u]
99                    + present
100                        .iter()
101                        .enumerate()
102                        .map(|(i, &s)| self.covariance[[u, s]] * alpha[i])
103                        .sum::<f64>();
104            }
105        }
106        Ok((0..self.intercept.len())
107            .map(|o| self.intercept[o] + expected.dot(&self.coefficients.column(o)))
108            .collect())
109    }
110}
111impl Explainer for CorrelatedLinearExplainer {
112    fn explain(&self, x: ArrayView2<'_, f64>) -> Result<Explanation> {
113        let m = self.mean.len();
114        if x.nrows() == 0 {
115            return Err(ShapError::EmptyData);
116        }
117        if x.ncols() != m {
118            return Err(ShapError::DimensionMismatch {
119                expected: format!("{m} features"),
120                found: format!("{}", x.ncols()),
121            });
122        }
123        if m > self.max_features || m >= 63 {
124            return Err(ShapError::InvalidConfiguration(format!(
125                "correlated linear SHAP supports at most {} features",
126                self.max_features
127            )));
128        }
129        if !self.ridge.is_finite() || self.ridge < 0.0 {
130            return Err(ShapError::InvalidConfiguration(
131                "ridge must be finite and non-negative".into(),
132            ));
133        }
134        let o = self.intercept.len();
135        crate::error::checked_f64_shape(&[x.nrows(), m, o], "correlated linear explanation")?;
136        let mut values = Array3::zeros((x.nrows(), m, o));
137        let mut bases = Array2::zeros((x.nrows(), o));
138        let factorial = (0..=m)
139            .scan(1., |v, k| {
140                if k > 0 {
141                    *v *= k as f64
142                }
143                Some(*v)
144            })
145            .collect::<Vec<_>>();
146        for n in 0..x.nrows() {
147            let mut cache = Vec::with_capacity(1 << m);
148            for mask in 0..1u64 << m {
149                cache.push(self.value(x.row(n), mask)?)
150            }
151            for k in 0..o {
152                bases[[n, k]] = cache[0][k]
153            }
154            for j in 0..m {
155                for mask in (0..1u64 << m).filter(|z| z & (1 << j) == 0) {
156                    let s = mask.count_ones() as usize;
157                    let w = factorial[s] * factorial[m - s - 1] / factorial[m];
158                    for k in 0..o {
159                        values[[n, j, k]] +=
160                            w * (cache[(mask | (1 << j)) as usize][k] - cache[mask as usize][k])
161                    }
162                }
163            }
164        }
165        Explanation::new(values, bases, x.to_owned())
166    }
167}
168#[allow(clippy::needless_range_loop)]
169fn solve_vector(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Result<Vec<f64>> {
170    let n = a.len();
171    for c in 0..n {
172        let p = (c..n)
173            .max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))
174            .unwrap();
175        if a[p][c].abs() < 1e-14 {
176            return Err(ShapError::SolverError(
177                "conditional covariance is singular".into(),
178            ));
179        }
180        a.swap(c, p);
181        b.swap(c, p);
182        let d = a[c][c];
183        for j in c..n {
184            a[c][j] /= d
185        }
186        b[c] /= d;
187        for i in 0..n {
188            if i == c {
189                continue;
190            }
191            let f = a[i][c];
192            for j in c..n {
193                a[i][j] -= f * a[c][j]
194            }
195            b[i] -= f * b[c]
196        }
197    }
198    Ok(b)
199}
200fn validate_positive_semidefinite(covariance: &Array2<f64>) -> Result<()> {
201    let n = covariance.nrows();
202    let scale = (0..n)
203        .map(|i| covariance[[i, i]].abs())
204        .fold(1.0_f64, f64::max);
205    let tolerance = scale * 1e-12;
206    let mut lower = Array2::<f64>::zeros((n, n));
207    for i in 0..n {
208        for j in 0..=i {
209            let remainder =
210                covariance[[i, j]] - (0..j).map(|k| lower[[i, k]] * lower[[j, k]]).sum::<f64>();
211            if i == j {
212                if remainder < -tolerance {
213                    return Err(ShapError::InvalidConfiguration(
214                        "covariance must be positive semidefinite".into(),
215                    ));
216                }
217                lower[[i, j]] = remainder.max(0.0).sqrt();
218            } else if lower[[j, j]] > tolerance.sqrt() {
219                lower[[i, j]] = remainder / lower[[j, j]];
220            } else if remainder.abs() > tolerance {
221                return Err(ShapError::InvalidConfiguration(
222                    "covariance must be positive semidefinite".into(),
223                ));
224            }
225        }
226    }
227    Ok(())
228}
229impl LinearExplainer {
230    pub fn new(
231        coefficients: Array2<f64>,
232        intercept: Array1<f64>,
233        background: Background,
234    ) -> Result<Self> {
235        if coefficients.nrows() != background.n_features()
236            || coefficients.ncols() != intercept.len()
237        {
238            return Err(ShapError::DimensionMismatch {
239                expected: format!(
240                    "coefficients ({}, outputs), matching intercept",
241                    background.n_features()
242                ),
243                found: format!(
244                    "coefficients {:?}, intercept {}",
245                    coefficients.dim(),
246                    intercept.len()
247                ),
248            });
249        }
250        if coefficients
251            .iter()
252            .chain(intercept.iter())
253            .any(|v| !v.is_finite())
254        {
255            return Err(ShapError::InvalidConfiguration(
256                "linear parameters must be finite".into(),
257            ));
258        }
259        Ok(Self {
260            coefficients,
261            intercept,
262            background,
263        })
264    }
265}
266impl Explainer for LinearExplainer {
267    fn explain(&self, x: ArrayView2<'_, f64>) -> Result<Explanation> {
268        if x.nrows() == 0 {
269            return Err(ShapError::EmptyData);
270        }
271        if x.ncols() != self.coefficients.nrows() {
272            return Err(ShapError::DimensionMismatch {
273                expected: format!("{} features", self.coefficients.nrows()),
274                found: format!("{}", x.ncols()),
275            });
276        }
277        let mean = self.background.data().mean_axis(ndarray::Axis(0)).unwrap();
278        crate::error::checked_f64_shape(
279            &[x.nrows(), x.ncols(), self.intercept.len()],
280            "linear explanation",
281        )?;
282        let mut v = Array3::zeros((x.nrows(), x.ncols(), self.intercept.len()));
283        let mut b = Array2::zeros((x.nrows(), self.intercept.len()));
284        for i in 0..x.nrows() {
285            for o in 0..self.intercept.len() {
286                b[[i, o]] = self.intercept[o] + mean.dot(&self.coefficients.column(o));
287                for j in 0..x.ncols() {
288                    v[[i, j, o]] = (x[[i, j]] - mean[j]) * self.coefficients[[j, o]]
289                }
290            }
291        }
292        Explanation::new(v, b, x.to_owned())
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use ndarray::array;
300
301    #[test]
302    fn linear_values_are_closed_form() {
303        let background = Background::new(array![[0.0, 2.0], [2.0, 4.0]]).unwrap();
304        let explainer =
305            LinearExplainer::new(array![[2.0], [-1.0]], array![3.0], background).unwrap();
306        let explanation = explainer.explain(array![[3.0, 5.0]].view()).unwrap();
307        assert_eq!(explanation.base_values()[[0, 0]], 2.0);
308        assert_eq!(explanation.values()[[0, 0, 0]], 4.0);
309        assert_eq!(explanation.values()[[0, 1, 0]], -2.0);
310        assert_eq!(explanation.reconstructed()[[0, 0]], 4.0);
311    }
312    #[test]
313    fn correlated_linear_matches_independent_case_for_diagonal_covariance() {
314        let e = CorrelatedLinearExplainer::new(
315            array![[2.], [-1.]],
316            array![1.],
317            array![0., 0.],
318            array![[1., 0.], [0., 1.]],
319        )
320        .unwrap()
321        .explain(array![[3., 4.]].view())
322        .unwrap();
323        assert!((e.values()[[0, 0, 0]] - 6.).abs() < 1e-8);
324        assert!((e.values()[[0, 1, 0]] + 4.).abs() < 1e-8);
325        assert!((e.reconstructed()[[0, 0]] - 3.).abs() < 1e-8);
326    }
327
328    #[test]
329    fn correlated_linear_rejects_indefinite_covariance() {
330        let result = CorrelatedLinearExplainer::new(
331            array![[1.], [1.]],
332            array![0.],
333            array![0., 0.],
334            array![[1., 2.], [2., 1.]],
335        );
336        assert!(matches!(result, Err(ShapError::InvalidConfiguration(_))));
337    }
338
339    #[test]
340    fn correlated_linear_accepts_singular_covariance() {
341        let result = CorrelatedLinearExplainer::new(
342            array![[1.], [1.]],
343            array![0.],
344            array![0., 0.],
345            array![[1., 1.], [1., 1.]],
346        );
347        assert!(result.is_ok());
348    }
349
350    #[test]
351    fn linear_explainers_reject_non_finite_parameters_and_ridge() {
352        let background = Background::new(array![[0., 0.]]).unwrap();
353        assert!(LinearExplainer::new(array![[1.], [1.]], array![f64::NAN], background).is_err());
354
355        let correlated =
356            CorrelatedLinearExplainer::new(array![[1.]], array![0.], array![0.], array![[1.]])
357                .unwrap()
358                .with_ridge(-1.);
359        assert!(correlated.explain(array![[1.]].view()).is_err());
360    }
361}