1use crate::{Background, Explainer, Explanation, Result, ShapError};
2use ndarray::{Array1, Array2, Array3, ArrayView2};
3pub struct LinearExplainer {
5 coefficients: Array2<f64>,
6 intercept: Array1<f64>,
7 background: Background,
8}
9
10pub 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}