Skip to main content

shap_rs/
background.rs

1//! Background datasets used by SHAP explainers.
2//!
3//! A background dataset represents the reference distribution against which
4//! feature contributions are measured. For tabular SHAP explainers, this is
5//! typically a matrix of observations with shape `(n_samples, n_features)`.
6
7use ndarray::{Array2, ArrayView1, ArrayView2};
8use rand::seq::SliceRandom;
9use rand::Rng;
10
11use crate::error::{Result, ShapError};
12
13/// A reference dataset used by a SHAP explainer.
14///
15/// `Background` owns its data so that explainers can safely retain it for
16/// their entire lifetime.
17///
18/// The data has shape:
19///
20/// ```text
21/// (n_background_samples, n_features)
22/// ```
23#[derive(Debug, Clone, PartialEq, serde::Serialize)]
24pub struct Background {
25    data: Array2<f64>,
26}
27#[derive(serde::Deserialize)]
28struct BackgroundPayload {
29    data: Array2<f64>,
30}
31impl<'de> serde::Deserialize<'de> for Background {
32    fn deserialize<D: serde::Deserializer<'de>>(
33        deserializer: D,
34    ) -> std::result::Result<Self, D::Error> {
35        let payload = <BackgroundPayload as serde::Deserialize>::deserialize(deserializer)?;
36        Self::new(payload.data).map_err(serde::de::Error::custom)
37    }
38}
39
40impl Background {
41    /// Revalidates a background after deserialization.
42    pub fn validate(&self) -> Result<()> {
43        if self.data.nrows() == 0 {
44            return Err(ShapError::EmptyBackground);
45        }
46        if self.data.ncols() == 0 {
47            return Err(ShapError::InvalidConfiguration(
48                "background must contain at least one feature".into(),
49            ));
50        }
51        Ok(())
52    }
53    /// Creates a background dataset from an owned array.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`ShapError::EmptyBackground`] when the supplied array has
58    /// zero rows.
59    pub fn new(data: Array2<f64>) -> Result<Self> {
60        if data.nrows() == 0 {
61            return Err(ShapError::EmptyBackground);
62        }
63        if data.ncols() == 0 {
64            return Err(ShapError::InvalidConfiguration(
65                "background must contain at least one feature".to_string(),
66            ));
67        }
68
69        Ok(Self { data })
70    }
71
72    /// Creates a background dataset by copying an array view.
73    pub fn from_view(data: ArrayView2<'_, f64>) -> Result<Self> {
74        Self::new(data.to_owned())
75    }
76
77    /// Returns the underlying background data.
78    pub fn data(&self) -> ArrayView2<'_, f64> {
79        self.data.view()
80    }
81
82    /// Returns the number of background observations.
83    pub fn n_samples(&self) -> usize {
84        self.data.nrows()
85    }
86
87    /// Returns the number of features.
88    pub fn n_features(&self) -> usize {
89        self.data.ncols()
90    }
91
92    /// Returns one background observation.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`ShapError::InvalidConfiguration`] if `index` is outside
97    /// the background dataset.
98    pub fn row(&self, index: usize) -> Result<ArrayView1<'_, f64>> {
99        self.validate()?;
100        if index >= self.n_samples() {
101            return Err(ShapError::InvalidConfiguration(format!(
102                "background row index {index} is out of bounds for {} rows",
103                self.n_samples()
104            )));
105        }
106        self.data
107            .row(index)
108            .into_shape_with_order(self.n_features())
109            .map_err(|_| {
110                ShapError::InvalidConfiguration(format!("unable to access background row {index}"))
111            })
112    }
113
114    /// Creates a new background dataset containing a random sample of the
115    /// existing observations.
116    ///
117    /// Sampling is performed without replacement.
118    ///
119    /// If `n_samples` is greater than or equal to the current number of
120    /// observations, a clone of the complete background dataset is returned.
121    pub fn sample<R: Rng + ?Sized>(&self, n_samples: usize, rng: &mut R) -> Result<Self> {
122        self.validate()?;
123        if n_samples == 0 {
124            return Err(ShapError::InvalidConfiguration(
125                "background sample size must be greater than zero".to_string(),
126            ));
127        }
128
129        if n_samples >= self.n_samples() {
130            return Ok(self.clone());
131        }
132
133        let mut indices: Vec<usize> = (0..self.n_samples()).collect();
134        indices.shuffle(rng);
135        indices.truncate(n_samples);
136
137        let sampled = self.data.select(ndarray::Axis(0), &indices);
138
139        Self::new(sampled)
140    }
141
142    /// Returns a background dataset containing the specified rows.
143    ///
144    /// This method preserves the order of `indices`.
145    pub fn select(&self, indices: &[usize]) -> Result<Self> {
146        self.validate()?;
147        if indices.is_empty() {
148            return Err(ShapError::EmptyBackground);
149        }
150
151        for &index in indices {
152            if index >= self.n_samples() {
153                return Err(ShapError::InvalidConfiguration(format!(
154                    "background row index {index} is out of bounds for {} rows",
155                    self.n_samples()
156                )));
157            }
158        }
159
160        Self::new(self.data.select(ndarray::Axis(0), indices))
161    }
162}
163
164/// Strategy used to reduce a large background dataset.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
166pub enum BackgroundSampling {
167    /// Use every background observation.
168    #[default]
169    All,
170
171    /// Randomly sample a fixed number of observations.
172    Random(usize),
173}
174
175impl BackgroundSampling {
176    /// Applies this sampling strategy to a background dataset.
177    pub fn apply<R: Rng + ?Sized>(
178        self,
179        background: &Background,
180        rng: &mut R,
181    ) -> Result<Background> {
182        match self {
183            Self::All => Ok(background.clone()),
184            Self::Random(n_samples) => background.sample(n_samples, rng),
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use ndarray::array;
193    use rand::rngs::StdRng;
194    use rand::SeedableRng;
195
196    #[test]
197    fn creates_background() {
198        let data = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0],];
199
200        let background = Background::new(data).unwrap();
201
202        assert_eq!(background.n_samples(), 3);
203        assert_eq!(background.n_features(), 2);
204    }
205
206    #[test]
207    fn rejects_empty_background() {
208        let data = Array2::<f64>::zeros((0, 2));
209
210        let result = Background::new(data);
211
212        assert!(matches!(result, Err(ShapError::EmptyBackground)));
213    }
214
215    #[test]
216    fn exposes_data() {
217        let data = array![[1.0, 2.0], [3.0, 4.0],];
218
219        let background = Background::new(data.clone()).unwrap();
220
221        assert_eq!(background.data(), data.view());
222    }
223
224    #[test]
225    fn accesses_row() {
226        let data = array![[1.0, 2.0], [3.0, 4.0],];
227
228        let background = Background::new(data).unwrap();
229
230        let row = background.row(1).unwrap();
231
232        assert_eq!(row, array![3.0, 4.0].view());
233    }
234
235    #[test]
236    fn random_sampling_is_reproducible() {
237        let data = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0], [9.0, 10.0],];
238
239        let background = Background::new(data).unwrap();
240
241        let mut rng1 = StdRng::seed_from_u64(42);
242        let mut rng2 = StdRng::seed_from_u64(42);
243
244        let sample1 = background.sample(3, &mut rng1).unwrap();
245        let sample2 = background.sample(3, &mut rng2).unwrap();
246
247        assert_eq!(sample1.data(), sample2.data());
248    }
249
250    #[test]
251    fn sampling_all_rows_returns_clone() {
252        let data = array![[1.0, 2.0], [3.0, 4.0],];
253
254        let background = Background::new(data.clone()).unwrap();
255
256        let mut rng = StdRng::seed_from_u64(42);
257
258        let sampled = background.sample(10, &mut rng).unwrap();
259
260        assert_eq!(sampled.data(), data.view());
261    }
262
263    #[test]
264    fn selects_rows() {
265        let data = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0],];
266
267        let background = Background::new(data).unwrap();
268
269        let selected = background.select(&[2, 0]).unwrap();
270
271        assert_eq!(selected.data(), array![[5.0, 6.0], [1.0, 2.0],].view());
272    }
273
274    #[test]
275    fn rejects_empty_selection() {
276        let data = array![[1.0, 2.0], [3.0, 4.0],];
277
278        let background = Background::new(data).unwrap();
279
280        let result = background.select(&[]);
281
282        assert!(matches!(result, Err(ShapError::EmptyBackground)));
283    }
284
285    #[test]
286    fn rejects_invalid_selection_index() {
287        let data = array![[1.0, 2.0], [3.0, 4.0],];
288
289        let background = Background::new(data).unwrap();
290
291        let result = background.select(&[5]);
292
293        assert!(matches!(result, Err(ShapError::InvalidConfiguration(_))));
294    }
295
296    #[test]
297    fn sampling_strategy_all() {
298        let data = array![[1.0, 2.0], [3.0, 4.0],];
299
300        let background = Background::new(data.clone()).unwrap();
301
302        let mut rng = StdRng::seed_from_u64(42);
303
304        let result = BackgroundSampling::All
305            .apply(&background, &mut rng)
306            .unwrap();
307
308        assert_eq!(result.data(), data.view());
309    }
310
311    #[test]
312    fn sampling_strategy_random() {
313        let data = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0],];
314
315        let background = Background::new(data).unwrap();
316
317        let mut rng = StdRng::seed_from_u64(42);
318
319        let result = BackgroundSampling::Random(2)
320            .apply(&background, &mut rng)
321            .unwrap();
322
323        assert_eq!(result.n_samples(), 2);
324        assert_eq!(result.n_features(), 2);
325    }
326}