Skip to main content

shap_rs/
sparse.rs

1//! Native CSR model boundary and sparse permutation SHAP.
2
3use crate::{EvaluationConfig, Explanation, Link, Result, ShapError};
4use ndarray::{Array2, Array3, ArrayView2, Axis};
5use rand::{rngs::StdRng, seq::SliceRandom, SeedableRng};
6use serde::{Deserialize, Deserializer, Serialize};
7use std::collections::{HashMap, HashSet, VecDeque};
8
9/// Validated compressed-sparse-row matrix.
10#[derive(Debug, Clone, PartialEq, Serialize)]
11pub struct SparseMatrix {
12    rows: usize,
13    columns: usize,
14    indptr: Vec<usize>,
15    indices: Vec<usize>,
16    values: Vec<f64>,
17}
18
19#[derive(Deserialize)]
20struct SparseMatrixPayload {
21    rows: usize,
22    columns: usize,
23    indptr: Vec<usize>,
24    indices: Vec<usize>,
25    values: Vec<f64>,
26}
27
28impl<'de> Deserialize<'de> for SparseMatrix {
29    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
30        let payload = SparseMatrixPayload::deserialize(deserializer)?;
31        Self::new(
32            payload.rows,
33            payload.columns,
34            payload.indptr,
35            payload.indices,
36            payload.values,
37        )
38        .map_err(serde::de::Error::custom)
39    }
40}
41
42impl SparseMatrix {
43    pub fn new(
44        rows: usize,
45        columns: usize,
46        indptr: Vec<usize>,
47        indices: Vec<usize>,
48        values: Vec<f64>,
49    ) -> Result<Self> {
50        let matrix = Self {
51            rows,
52            columns,
53            indptr,
54            indices,
55            values,
56        };
57        matrix.validate()?;
58        Ok(matrix)
59    }
60
61    pub fn from_dense(dense: ArrayView2<'_, f64>) -> Result<Self> {
62        if dense.ncols() == 0 {
63            return Err(ShapError::InvalidConfiguration(
64                "sparse matrices must contain at least one column".into(),
65            ));
66        }
67        let mut indptr = Vec::with_capacity(dense.nrows().saturating_add(1));
68        let mut indices = Vec::new();
69        let mut values = Vec::new();
70        indptr.push(0);
71        for row in dense.rows() {
72            for (column, value) in row.iter().copied().enumerate() {
73                if value != 0.0 {
74                    indices.push(column);
75                    values.push(value);
76                }
77            }
78            indptr.push(indices.len());
79        }
80        Self::new(dense.nrows(), dense.ncols(), indptr, indices, values)
81    }
82
83    pub fn nrows(&self) -> usize {
84        self.rows
85    }
86
87    pub fn ncols(&self) -> usize {
88        self.columns
89    }
90
91    pub fn nnz(&self) -> usize {
92        self.values.len()
93    }
94
95    pub fn indptr(&self) -> &[usize] {
96        &self.indptr
97    }
98
99    pub fn indices(&self) -> &[usize] {
100        &self.indices
101    }
102
103    pub fn values(&self) -> &[f64] {
104        &self.values
105    }
106
107    pub fn validate(&self) -> Result<()> {
108        if self.columns == 0 {
109            return Err(ShapError::InvalidConfiguration(
110                "sparse matrices must contain at least one column".into(),
111            ));
112        }
113        if self.indptr.len() != self.rows.saturating_add(1)
114            || self.indptr.first().copied() != Some(0)
115            || self.indptr.last().copied() != Some(self.indices.len())
116            || self.indices.len() != self.values.len()
117        {
118            return Err(ShapError::InvalidConfiguration(
119                "invalid CSR pointer or value lengths".into(),
120            ));
121        }
122        if self.values.contains(&0.0) {
123            return Err(ShapError::InvalidConfiguration(
124                "canonical CSR values must not contain explicit zeros".into(),
125            ));
126        }
127        for row in 0..self.rows {
128            let start = self.indptr[row];
129            let end = self.indptr[row + 1];
130            if start > end || end > self.indices.len() {
131                return Err(ShapError::InvalidConfiguration(
132                    "CSR row pointers must be monotonic and in bounds".into(),
133                ));
134            }
135            let row_indices = &self.indices[start..end];
136            if row_indices.iter().any(|index| *index >= self.columns)
137                || row_indices.windows(2).any(|pair| pair[0] >= pair[1])
138            {
139                return Err(ShapError::InvalidConfiguration(
140                    "CSR column indices must be sorted, unique, and in bounds".into(),
141                ));
142            }
143        }
144        Ok(())
145    }
146
147    pub fn row(&self, row: usize) -> Result<SparseRowView<'_>> {
148        if row >= self.rows {
149            return Err(ShapError::InvalidSampleIndex {
150                index: row,
151                n_samples: self.rows,
152            });
153        }
154        let range = self.indptr[row]..self.indptr[row + 1];
155        Ok(SparseRowView {
156            columns: self.columns,
157            indices: &self.indices[range.clone()],
158            values: &self.values[range],
159        })
160    }
161
162    pub fn to_dense(&self) -> Result<Array2<f64>> {
163        crate::error::checked_f64_shape(&[self.rows, self.columns], "dense sparse-matrix view")?;
164        let mut dense = Array2::zeros((self.rows, self.columns));
165        for row in 0..self.rows {
166            let sparse = self.row(row)?;
167            for (&column, &value) in sparse.indices.iter().zip(sparse.values) {
168                dense[[row, column]] = value;
169            }
170        }
171        Ok(dense)
172    }
173
174    fn concatenate_rows(parts: &[Self]) -> Result<Self> {
175        let columns = parts.first().map_or(0, Self::ncols);
176        if columns == 0 || parts.iter().any(|part| part.ncols() != columns) {
177            return Err(ShapError::DimensionMismatch {
178                expected: format!("sparse batches with {columns} columns"),
179                found: "incompatible sparse batch columns".into(),
180            });
181        }
182        let rows = parts.iter().try_fold(0usize, |total, part| {
183            total.checked_add(part.nrows()).ok_or_else(|| {
184                ShapError::InvalidConfiguration("sparse batch row count overflow".into())
185            })
186        })?;
187        let nnz = parts.iter().try_fold(0usize, |total, part| {
188            total.checked_add(part.nnz()).ok_or_else(|| {
189                ShapError::InvalidConfiguration("sparse batch nonzero count overflow".into())
190            })
191        })?;
192        let pointers = rows.checked_add(1).ok_or_else(|| {
193            ShapError::InvalidConfiguration("sparse batch pointer count overflow".into())
194        })?;
195        let mut indptr = Vec::new();
196        let mut indices = Vec::new();
197        let mut values = Vec::new();
198        indptr.try_reserve_exact(pointers).map_err(|error| {
199            ShapError::InvalidConfiguration(format!(
200                "cannot allocate sparse batch row pointers: {error}"
201            ))
202        })?;
203        indices.try_reserve_exact(nnz).map_err(|error| {
204            ShapError::InvalidConfiguration(format!(
205                "cannot allocate sparse batch indices: {error}"
206            ))
207        })?;
208        values.try_reserve_exact(nnz).map_err(|error| {
209            ShapError::InvalidConfiguration(format!("cannot allocate sparse batch values: {error}"))
210        })?;
211        indptr.push(0);
212        for part in parts {
213            for row in 0..part.nrows() {
214                let range = part.indptr[row]..part.indptr[row + 1];
215                indices.extend_from_slice(&part.indices[range.clone()]);
216                values.extend_from_slice(&part.values[range]);
217                indptr.push(indices.len());
218            }
219        }
220        Self::new(rows, columns, indptr, indices, values)
221    }
222}
223
224/// Borrowed CSR row.
225#[derive(Debug, Clone, Copy)]
226pub struct SparseRowView<'a> {
227    columns: usize,
228    indices: &'a [usize],
229    values: &'a [f64],
230}
231
232impl SparseRowView<'_> {
233    pub fn len(&self) -> usize {
234        self.columns
235    }
236
237    pub fn is_empty(&self) -> bool {
238        self.columns == 0
239    }
240
241    pub fn indices(&self) -> &[usize] {
242        self.indices
243    }
244
245    pub fn values(&self) -> &[f64] {
246        self.values
247    }
248
249    pub fn get(&self, column: usize) -> Result<f64> {
250        if column >= self.columns {
251            return Err(ShapError::InvalidFeatureIndex {
252                index: column,
253                n_features: self.columns,
254            });
255        }
256        Ok(self
257            .indices
258            .binary_search(&column)
259            .map(|position| self.values[position])
260            .unwrap_or(0.0))
261    }
262}
263
264/// Prediction contract for models with a native CSR input boundary.
265pub trait SparsePredict {
266    fn predict_sparse(&self, input: &SparseMatrix) -> Result<Array2<f64>>;
267    fn n_features(&self) -> Option<usize> {
268        None
269    }
270    fn n_outputs(&self) -> Option<usize> {
271        None
272    }
273}
274
275impl<T: SparsePredict + ?Sized> SparsePredict for &T {
276    fn predict_sparse(&self, input: &SparseMatrix) -> Result<Array2<f64>> {
277        (**self).predict_sparse(input)
278    }
279    fn n_features(&self) -> Option<usize> {
280        (**self).n_features()
281    }
282    fn n_outputs(&self) -> Option<usize> {
283        (**self).n_outputs()
284    }
285}
286
287pub struct FnSparseModel<F> {
288    predict_fn: F,
289    n_features: Option<usize>,
290    n_outputs: Option<usize>,
291}
292
293impl<F> FnSparseModel<F> {
294    pub fn new(predict_fn: F) -> Self {
295        Self {
296            predict_fn,
297            n_features: None,
298            n_outputs: None,
299        }
300    }
301    pub fn with_n_features(mut self, n_features: usize) -> Self {
302        self.n_features = Some(n_features);
303        self
304    }
305    pub fn with_n_outputs(mut self, n_outputs: usize) -> Self {
306        self.n_outputs = Some(n_outputs);
307        self
308    }
309}
310
311impl<F> SparsePredict for FnSparseModel<F>
312where
313    F: Fn(&SparseMatrix) -> Result<Array2<f64>>,
314{
315    fn predict_sparse(&self, input: &SparseMatrix) -> Result<Array2<f64>> {
316        (self.predict_fn)(input)
317    }
318    fn n_features(&self) -> Option<usize> {
319        self.n_features
320    }
321    fn n_outputs(&self) -> Option<usize> {
322        self.n_outputs
323    }
324}
325
326/// Interventional masker backed by CSR background rows.
327#[derive(Debug, Clone)]
328pub struct SparseIndependentMasker {
329    background: SparseMatrix,
330}
331
332impl SparseIndependentMasker {
333    pub fn new(background: SparseMatrix) -> Result<Self> {
334        if background.nrows() == 0 {
335            return Err(ShapError::EmptyBackground);
336        }
337        Ok(Self { background })
338    }
339
340    pub fn background(&self) -> &SparseMatrix {
341        &self.background
342    }
343
344    pub fn mask(&self, sample: SparseRowView<'_>, present: &[bool]) -> Result<SparseMatrix> {
345        if sample.len() != self.background.ncols() || present.len() != self.background.ncols() {
346            return Err(ShapError::DimensionMismatch {
347                expected: format!("{} sparse features", self.background.ncols()),
348                found: format!("sample {}, mask {}", sample.len(), present.len()),
349            });
350        }
351        let pointer_capacity = self.background.nrows().checked_add(1).ok_or_else(|| {
352            ShapError::InvalidConfiguration("sparse mask row pointer count overflow".into())
353        })?;
354        let additional = self
355            .background
356            .nrows()
357            .checked_mul(sample.indices.len())
358            .and_then(|count| count.checked_add(self.background.nnz()))
359            .ok_or_else(|| {
360                ShapError::InvalidConfiguration("sparse masked nonzero bound overflow".into())
361            })?;
362        let mut indptr = Vec::new();
363        let mut indices = Vec::new();
364        let mut values = Vec::new();
365        indptr
366            .try_reserve_exact(pointer_capacity)
367            .map_err(|error| {
368                ShapError::InvalidConfiguration(format!(
369                    "cannot allocate sparse mask row pointers: {error}"
370                ))
371            })?;
372        indices.try_reserve(additional).map_err(|error| {
373            ShapError::InvalidConfiguration(format!(
374                "cannot allocate sparse masked indices: {error}"
375            ))
376        })?;
377        values.try_reserve(additional).map_err(|error| {
378            ShapError::InvalidConfiguration(format!(
379                "cannot allocate sparse masked values: {error}"
380            ))
381        })?;
382        indptr.push(0);
383        for row in 0..self.background.nrows() {
384            let background = self.background.row(row)?;
385            let mut sample_position = 0;
386            let mut background_position = 0;
387            while sample_position < sample.indices.len()
388                || background_position < background.indices.len()
389            {
390                let sample_column = sample.indices.get(sample_position).copied();
391                let background_column = background.indices.get(background_position).copied();
392                let column = match (sample_column, background_column) {
393                    (Some(left), Some(right)) => left.min(right),
394                    (Some(left), None) => left,
395                    (None, Some(right)) => right,
396                    (None, None) => break,
397                };
398                let sample_value = if sample_column == Some(column) {
399                    let value = sample.values[sample_position];
400                    sample_position += 1;
401                    value
402                } else {
403                    0.0
404                };
405                let background_value = if background_column == Some(column) {
406                    let value = background.values[background_position];
407                    background_position += 1;
408                    value
409                } else {
410                    0.0
411                };
412                let value = if present[column] {
413                    sample_value
414                } else {
415                    background_value
416                };
417                if value != 0.0 {
418                    indices.push(column);
419                    values.push(value);
420                }
421            }
422            indptr.push(indices.len());
423        }
424        SparseMatrix::new(
425            self.background.nrows(),
426            self.background.ncols(),
427            indptr,
428            indices,
429            values,
430        )
431    }
432}
433
434struct SparseCoalitionEvaluator<'a, M> {
435    model: &'a M,
436    masker: &'a SparseIndependentMasker,
437    config: EvaluationConfig,
438    cache: HashMap<u64, Vec<f64>>,
439    order: VecDeque<u64>,
440    rows_evaluated: usize,
441    outputs: Option<usize>,
442}
443
444impl<'a, M: SparsePredict> SparseCoalitionEvaluator<'a, M> {
445    fn new(
446        model: &'a M,
447        masker: &'a SparseIndependentMasker,
448        config: EvaluationConfig,
449    ) -> Result<Self> {
450        Ok(Self {
451            model,
452            masker,
453            config: config.validate()?,
454            cache: HashMap::new(),
455            order: VecDeque::new(),
456            rows_evaluated: 0,
457            outputs: None,
458        })
459    }
460
461    fn evaluate(&mut self, sample: SparseRowView<'_>, masks: &[u64]) -> Result<Vec<Vec<f64>>> {
462        let mut result = HashMap::new();
463        let mut missing = Vec::new();
464        let mut seen = HashSet::new();
465        for &mask in masks {
466            if let Some(value) = self.cache.get(&mask).cloned() {
467                self.touch(mask);
468                result.insert(mask, value);
469            } else if seen.insert(mask) {
470                missing.push(mask);
471            }
472        }
473        for chunk in missing.chunks(self.config.coalition_batch_size) {
474            let parts = chunk
475                .iter()
476                .map(|mask| {
477                    self.masker.mask(
478                        sample,
479                        &crate::coalition::members(*mask, self.masker.background.ncols()),
480                    )
481                })
482                .collect::<Result<Vec<_>>>()?;
483            let batch = SparseMatrix::concatenate_rows(&parts)?;
484            if self
485                .config
486                .max_model_rows
487                .is_some_and(|limit| self.rows_evaluated.saturating_add(batch.nrows()) > limit)
488            {
489                return Err(ShapError::InvalidConfiguration(
490                    "model row evaluation limit exceeded".into(),
491                ));
492            }
493            if let Some(features) = self.model.n_features() {
494                if features != batch.ncols() {
495                    return Err(ShapError::DimensionMismatch {
496                        expected: format!("{features} sparse model features"),
497                        found: format!("{}", batch.ncols()),
498                    });
499                }
500            }
501            let predictions = self.model.predict_sparse(&batch)?;
502            if predictions.nrows() != batch.nrows() || predictions.ncols() == 0 {
503                return Err(ShapError::DimensionMismatch {
504                    expected: format!("({}, outputs>0)", batch.nrows()),
505                    found: format!("{:?}", predictions.dim()),
506                });
507            }
508            if predictions.iter().any(|value| !value.is_finite()) {
509                return Err(ShapError::ModelError(
510                    "sparse prediction contains a non-finite value".into(),
511                ));
512            }
513            if let Some(outputs) = self.outputs {
514                if outputs != predictions.ncols() {
515                    return Err(ShapError::OutputDimensionMismatch {
516                        expected: outputs,
517                        found: predictions.ncols(),
518                    });
519                }
520            } else {
521                self.outputs = Some(predictions.ncols());
522            }
523            self.rows_evaluated =
524                self.rows_evaluated
525                    .checked_add(batch.nrows())
526                    .ok_or_else(|| {
527                        ShapError::InvalidConfiguration("sparse row count overflow".into())
528                    })?;
529            let mut offset = 0;
530            for (&mask, part) in chunk.iter().zip(&parts) {
531                let end = offset + part.nrows();
532                let value = predictions
533                    .slice_axis(Axis(0), ndarray::Slice::from(offset..end))
534                    .mean_axis(Axis(0))
535                    .unwrap()
536                    .to_vec();
537                offset = end;
538                while self.cache.len() >= self.config.cache_capacity {
539                    let Some(evicted) = self.order.pop_front() else {
540                        break;
541                    };
542                    self.cache.remove(&evicted);
543                }
544                self.cache.insert(mask, value.clone());
545                self.order.push_back(mask);
546                result.insert(mask, value);
547            }
548        }
549        masks
550            .iter()
551            .map(|mask| {
552                result.get(mask).cloned().ok_or_else(|| {
553                    ShapError::Other("sparse coalition cache invariant failed".into())
554                })
555            })
556            .collect()
557    }
558
559    fn touch(&mut self, mask: u64) {
560        if let Some(position) = self.order.iter().position(|value| *value == mask) {
561            self.order.remove(position);
562        }
563        self.order.push_back(mask);
564    }
565}
566
567/// Monte-Carlo permutation SHAP that keeps inputs, backgrounds, and coalition
568/// batches in CSR form. Explanation display data is densified once at the end.
569pub struct SparsePermutationExplainer<M> {
570    model: M,
571    masker: SparseIndependentMasker,
572    n_permutations: usize,
573    seed: u64,
574    antithetic: bool,
575    link: Link,
576    evaluation: EvaluationConfig,
577}
578
579impl<M> SparsePermutationExplainer<M> {
580    pub fn new(model: M, background: SparseMatrix) -> Result<Self> {
581        Ok(Self {
582            model,
583            masker: SparseIndependentMasker::new(background)?,
584            n_permutations: 128,
585            seed: 0,
586            antithetic: true,
587            link: Link::Identity,
588            evaluation: EvaluationConfig {
589                coalition_batch_size: 64,
590                cache_capacity: 65536,
591                max_model_rows: None,
592            },
593        })
594    }
595    pub fn with_n_permutations(mut self, count: usize) -> Self {
596        self.n_permutations = count;
597        self
598    }
599    pub fn with_seed(mut self, seed: u64) -> Self {
600        self.seed = seed;
601        self
602    }
603    pub fn with_antithetic(mut self, enabled: bool) -> Self {
604        self.antithetic = enabled;
605        self
606    }
607    pub fn with_link(mut self, link: Link) -> Self {
608        self.link = link;
609        self
610    }
611    pub fn with_evaluation_config(mut self, config: EvaluationConfig) -> Self {
612        self.evaluation = config;
613        self
614    }
615}
616
617impl<M: SparsePredict> SparsePermutationExplainer<M> {
618    pub fn explain(&self, input: &SparseMatrix) -> Result<Explanation> {
619        input.validate()?;
620        let features = self.masker.background.ncols();
621        if input.nrows() == 0 {
622            return Err(ShapError::EmptyData);
623        }
624        if input.ncols() != features {
625            return Err(ShapError::DimensionMismatch {
626                expected: format!("{features} sparse features"),
627                found: format!("{}", input.ncols()),
628            });
629        }
630        if features >= 63 {
631            return Err(ShapError::InvalidConfiguration(
632                "sparse permutation SHAP currently supports at most 62 features".into(),
633            ));
634        }
635        if self.n_permutations == 0 {
636            return Err(ShapError::InvalidConfiguration(
637                "n_permutations must be positive".into(),
638            ));
639        }
640        self.n_permutations.checked_mul(features).ok_or_else(|| {
641            ShapError::InvalidConfiguration("sparse permutation step count overflow".into())
642        })?;
643        let mut probe = SparseCoalitionEvaluator::new(&self.model, &self.masker, self.evaluation)?;
644        let outputs = probe.evaluate(input.row(0)?, &[0])?[0].len();
645        crate::error::checked_f64_shape(
646            &[input.nrows(), features, outputs],
647            "sparse permutation explanation",
648        )?;
649        let mut values = Array3::zeros((input.nrows(), features, outputs));
650        let mut bases = Array2::zeros((input.nrows(), outputs));
651        for sample_index in 0..input.nrows() {
652            let sample = input.row(sample_index)?;
653            let mut rng = StdRng::seed_from_u64(sparse_sample_seed(self.seed, sample));
654            let mut requested = vec![0u64];
655            let mut steps = Vec::with_capacity(self.n_permutations * features);
656            let mut generated = 0;
657            while generated < self.n_permutations {
658                let mut order = (0..features).collect::<Vec<_>>();
659                order.shuffle(&mut rng);
660                append_order(&order, &mut requested, &mut steps);
661                generated += 1;
662                if self.antithetic && generated < self.n_permutations {
663                    order.reverse();
664                    append_order(&order, &mut requested, &mut steps);
665                    generated += 1;
666                }
667            }
668            let mut evaluator =
669                SparseCoalitionEvaluator::new(&self.model, &self.masker, self.evaluation)?;
670            let evaluated = evaluator
671                .evaluate(sample, &requested)?
672                .into_iter()
673                .map(|row| {
674                    row.into_iter()
675                        .map(|value| self.link.forward(value))
676                        .collect::<Result<Vec<_>>>()
677                })
678                .collect::<Result<Vec<_>>>()?;
679            for output in 0..outputs {
680                bases[[sample_index, output]] = evaluated[0][output];
681            }
682            for (feature, before, after) in steps {
683                for output in 0..outputs {
684                    values[[sample_index, feature, output]] += (evaluated[after][output]
685                        - evaluated[before][output])
686                        / self.n_permutations as f64;
687                }
688            }
689        }
690        Explanation::new(values, bases, input.to_dense()?)
691    }
692}
693
694fn append_order(order: &[usize], requested: &mut Vec<u64>, steps: &mut Vec<(usize, usize, usize)>) {
695    let mut mask = 0u64;
696    let mut before = 0usize;
697    for &feature in order {
698        mask |= 1u64 << feature;
699        requested.push(mask);
700        let after = requested.len() - 1;
701        steps.push((feature, before, after));
702        before = after;
703    }
704}
705
706fn sparse_sample_seed(seed: u64, sample: SparseRowView<'_>) -> u64 {
707    fn mix(mut value: u64) -> u64 {
708        value ^= value >> 30;
709        value = value.wrapping_mul(0xBF58_476D_1CE4_E5B9);
710        value ^= value >> 27;
711        value = value.wrapping_mul(0x94D0_49BB_1331_11EB);
712        value ^ (value >> 31)
713    }
714    sample.indices.iter().zip(sample.values).fold(
715        mix(seed ^ sample.columns as u64),
716        |state, (&index, &value)| mix(state ^ mix(index as u64) ^ mix(value.to_bits())),
717    )
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use ndarray::array;
724    use std::cell::Cell;
725
726    #[test]
727    fn csr_validation_and_dense_round_trip() {
728        let dense = array![[0., 2., 0.], [3., 0., 4.]];
729        let sparse = SparseMatrix::from_dense(dense.view()).unwrap();
730        assert_eq!(sparse.nnz(), 3);
731        assert_eq!(sparse.to_dense().unwrap(), dense);
732        assert!(SparseMatrix::new(1, 2, vec![0, 2], vec![1, 1], vec![2., 3.]).is_err());
733    }
734
735    #[test]
736    fn sparse_masker_merges_rows_without_dense_coalitions() {
737        let background =
738            SparseMatrix::from_dense(array![[0., 2., 0.], [3., 0., 4.]].view()).unwrap();
739        let sample_matrix = SparseMatrix::from_dense(array![[5., 0., 6.]].view()).unwrap();
740        let masker = SparseIndependentMasker::new(background).unwrap();
741        let masked = masker
742            .mask(sample_matrix.row(0).unwrap(), &[true, false, true])
743            .unwrap();
744        assert_eq!(
745            masked.to_dense().unwrap(),
746            array![[5., 2., 6.], [5., 0., 6.]]
747        );
748        assert_eq!(masked.nnz(), 5);
749    }
750
751    #[test]
752    fn sparse_permutation_matches_additive_model_without_dense_model_input() {
753        let sparse_calls = Cell::new(0usize);
754        let model = FnSparseModel::new(|input: &SparseMatrix| {
755            sparse_calls.set(sparse_calls.get() + 1);
756            Ok(Array2::from_shape_fn((input.nrows(), 1), |(row, _)| {
757                let sparse = input.row(row).unwrap();
758                sparse
759                    .indices()
760                    .iter()
761                    .zip(sparse.values())
762                    .map(|(&column, &value)| (column as f64 + 1.0) * value)
763                    .sum()
764            }))
765        });
766        let background = SparseMatrix::from_dense(array![[0., 0., 0.]].view()).unwrap();
767        let input = SparseMatrix::from_dense(array![[2., 0., 4.]].view()).unwrap();
768        let explanation = SparsePermutationExplainer::new(model, background)
769            .unwrap()
770            .with_n_permutations(2)
771            .explain(&input)
772            .unwrap();
773        assert_eq!(explanation.values(), array![[[2.], [0.], [12.]]].view());
774        assert_eq!(explanation.reconstructed(), array![[14.]]);
775        assert!(sparse_calls.get() > 0);
776    }
777}