Skip to main content

shap_rs/
masker.rs

1use crate::{Background, Result, ShapError};
2use ndarray::{Array1, Array2, ArrayView1, Axis};
3pub trait Masker {
4    /// Number of coalition features exposed to an explainer.
5    fn n_features(&self) -> usize;
6    /// Number of columns expected in model input samples.
7    ///
8    /// This differs from [`Masker::n_features`] for grouped maskers.
9    fn n_input_features(&self) -> usize {
10        self.n_features()
11    }
12    /// Values stored on the feature axis of the resulting explanation.
13    fn attribution_data(&self, samples: ndarray::ArrayView2<'_, f64>) -> Result<Array2<f64>> {
14        if samples.ncols() != self.n_input_features() {
15            return Err(ShapError::DimensionMismatch {
16                expected: format!("{} input features", self.n_input_features()),
17                found: format!("{}", samples.ncols()),
18            });
19        }
20        Ok(samples.to_owned())
21    }
22    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>>;
23
24    /// Visits model-input batches for one coalition.
25    ///
26    /// The default implementation emits the result of [`Masker::mask`] once.
27    /// Streaming maskers override this method so background rows can be read
28    /// and evaluated incrementally without materializing the complete masked
29    /// distribution.
30    fn for_each_masked_batch(
31        &self,
32        sample: ArrayView1<'_, f64>,
33        present: &[bool],
34        visitor: &mut dyn FnMut(Array2<f64>) -> Result<()>,
35    ) -> Result<()> {
36        visitor(self.mask(sample, present)?)
37    }
38
39    /// Whether [`Masker::for_each_masked_batch`] should be consumed directly
40    /// instead of participating in ordinary multi-coalition batching.
41    fn streams_masked_batches(&self) -> bool {
42        false
43    }
44}
45impl<T: Masker + ?Sized> Masker for &T {
46    fn n_features(&self) -> usize {
47        (**self).n_features()
48    }
49    fn n_input_features(&self) -> usize {
50        (**self).n_input_features()
51    }
52    fn attribution_data(&self, samples: ndarray::ArrayView2<'_, f64>) -> Result<Array2<f64>> {
53        (**self).attribution_data(samples)
54    }
55    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
56        (**self).mask(sample, present)
57    }
58    fn for_each_masked_batch(
59        &self,
60        sample: ArrayView1<'_, f64>,
61        present: &[bool],
62        visitor: &mut dyn FnMut(Array2<f64>) -> Result<()>,
63    ) -> Result<()> {
64        (**self).for_each_masked_batch(sample, present, visitor)
65    }
66    fn streams_masked_batches(&self) -> bool {
67        (**self).streams_masked_batches()
68    }
69}
70
71/// Adapts an incremental background source into a masker.
72///
73/// The callback may load rows from disk, a database, or another out-of-core
74/// source and pass each masked batch to `visitor`. Every emitted batch must
75/// have `n_features` columns and at least one row. Model-agnostic
76/// explainers consume the batches immediately and retain only running output
77/// sums. Calling [`Masker::mask`] directly remains supported but necessarily
78/// collects all emitted batches.
79pub struct FnStreamingMasker<F> {
80    n_features: usize,
81    batch_fn: F,
82}
83
84impl<F> FnStreamingMasker<F> {
85    pub fn new(n_features: usize, batch_fn: F) -> Result<Self> {
86        if n_features == 0 {
87            return Err(ShapError::InvalidConfiguration(
88                "streaming masker feature count must be positive".into(),
89            ));
90        }
91        Ok(Self {
92            n_features,
93            batch_fn,
94        })
95    }
96}
97
98impl<F> Masker for FnStreamingMasker<F>
99where
100    F: Fn(ArrayView1<'_, f64>, &[bool], &mut dyn FnMut(Array2<f64>) -> Result<()>) -> Result<()>,
101{
102    fn n_features(&self) -> usize {
103        self.n_features
104    }
105
106    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
107        let mut batches = Vec::new();
108        self.for_each_masked_batch(sample, present, &mut |batch| {
109            batches.push(batch);
110            Ok(())
111        })?;
112        let rows = batches.iter().try_fold(0usize, |rows, batch| {
113            rows.checked_add(batch.nrows()).ok_or_else(|| {
114                ShapError::InvalidConfiguration("streaming masker row count overflow".into())
115            })
116        })?;
117        crate::error::checked_f64_shape(
118            &[rows, self.n_features],
119            "collected streaming masker output",
120        )?;
121        let mut output = Array2::zeros((rows, self.n_features));
122        let mut offset = 0;
123        for batch in batches {
124            let end = offset + batch.nrows();
125            output
126                .slice_axis_mut(Axis(0), ndarray::Slice::from(offset..end))
127                .assign(&batch);
128            offset = end;
129        }
130        Ok(output)
131    }
132
133    fn for_each_masked_batch(
134        &self,
135        sample: ArrayView1<'_, f64>,
136        present: &[bool],
137        visitor: &mut dyn FnMut(Array2<f64>) -> Result<()>,
138    ) -> Result<()> {
139        if sample.len() != self.n_features || present.len() != self.n_features {
140            return Err(ShapError::DimensionMismatch {
141                expected: format!("{} features", self.n_features),
142                found: format!("sample {}, mask {}", sample.len(), present.len()),
143            });
144        }
145        let mut emitted = false;
146        let mut checked_visitor = |batch: Array2<f64>| {
147            if batch.nrows() == 0 || batch.ncols() != self.n_features {
148                return Err(ShapError::DimensionMismatch {
149                    expected: format!("(rows>0, {}) streaming batch", self.n_features),
150                    found: format!("{:?}", batch.dim()),
151                });
152            }
153            emitted = true;
154            visitor(batch)
155        };
156        (self.batch_fn)(sample, present, &mut checked_visitor)?;
157        if !emitted {
158            return Err(ShapError::MaskerError(
159                "streaming masker returned no rows".into(),
160            ));
161        }
162        Ok(())
163    }
164
165    fn streams_masked_batches(&self) -> bool {
166        true
167    }
168}
169
170/// Makes groups of source columns behave as single coalition features.
171///
172/// `groups` must form an exact partition of the wrapped masker's input
173/// columns. Returned SHAP values therefore have one feature axis entry per
174/// group, while the model continues to receive its original columns.
175#[derive(Debug, Clone)]
176pub struct GroupedMasker<K> {
177    inner: K,
178    groups: Vec<Vec<usize>>,
179}
180
181impl<K: Masker> GroupedMasker<K> {
182    pub fn new(inner: K, groups: Vec<Vec<usize>>) -> Result<Self> {
183        let n = inner.n_features();
184        if n != inner.n_input_features() {
185            return Err(ShapError::InvalidConfiguration(
186                "nested grouped maskers are not supported".into(),
187            ));
188        }
189        if groups.is_empty() || groups.iter().any(Vec::is_empty) {
190            return Err(ShapError::InvalidConfiguration(
191                "feature groups must be non-empty".into(),
192            ));
193        }
194        let mut seen = vec![false; n];
195        for &column in groups.iter().flatten() {
196            if column >= n {
197                return Err(ShapError::InvalidFeatureIndex {
198                    index: column,
199                    n_features: n,
200                });
201            }
202            if std::mem::replace(&mut seen[column], true) {
203                return Err(ShapError::InvalidConfiguration(format!(
204                    "source column {column} belongs to more than one feature group"
205                )));
206            }
207        }
208        if seen.iter().any(|included| !included) {
209            return Err(ShapError::InvalidConfiguration(
210                "feature groups must cover every source column exactly once".into(),
211            ));
212        }
213        Ok(Self { inner, groups })
214    }
215
216    pub fn inner(&self) -> &K {
217        &self.inner
218    }
219
220    pub fn groups(&self) -> &[Vec<usize>] {
221        &self.groups
222    }
223}
224
225impl<K: Masker> Masker for GroupedMasker<K> {
226    fn n_features(&self) -> usize {
227        self.groups.len()
228    }
229
230    fn n_input_features(&self) -> usize {
231        self.inner.n_input_features()
232    }
233
234    fn attribution_data(&self, samples: ndarray::ArrayView2<'_, f64>) -> Result<Array2<f64>> {
235        if samples.ncols() != self.n_input_features() {
236            return Err(ShapError::DimensionMismatch {
237                expected: format!("{} input features", self.n_input_features()),
238                found: format!("{}", samples.ncols()),
239            });
240        }
241        let mut grouped = Array2::zeros((samples.nrows(), self.groups.len()));
242        for (group_index, group) in self.groups.iter().enumerate() {
243            for row in 0..samples.nrows() {
244                grouped[[row, group_index]] = group
245                    .iter()
246                    .map(|&column| samples[[row, column]])
247                    .sum::<f64>()
248                    / group.len() as f64;
249            }
250        }
251        Ok(grouped)
252    }
253
254    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
255        if sample.len() != self.n_input_features() || present.len() != self.n_features() {
256            return Err(ShapError::DimensionMismatch {
257                expected: format!(
258                    "{} input columns and {} feature groups",
259                    self.n_input_features(),
260                    self.n_features()
261                ),
262                found: format!("sample {}, mask {}", sample.len(), present.len()),
263            });
264        }
265        let mut expanded = vec![false; self.inner.n_features()];
266        for (group, &on) in self.groups.iter().zip(present) {
267            for &column in group {
268                expanded[column] = on;
269            }
270        }
271        self.inner.mask(sample, &expanded)
272    }
273
274    fn for_each_masked_batch(
275        &self,
276        sample: ArrayView1<'_, f64>,
277        present: &[bool],
278        visitor: &mut dyn FnMut(Array2<f64>) -> Result<()>,
279    ) -> Result<()> {
280        if sample.len() != self.n_input_features() || present.len() != self.n_features() {
281            return Err(ShapError::DimensionMismatch {
282                expected: format!(
283                    "{} input columns and {} feature groups",
284                    self.n_input_features(),
285                    self.n_features()
286                ),
287                found: format!("sample {}, mask {}", sample.len(), present.len()),
288            });
289        }
290        let mut expanded = vec![false; self.inner.n_features()];
291        for (group, &on) in self.groups.iter().zip(present) {
292            for &column in group {
293                expanded[column] = on;
294            }
295        }
296        self.inner.for_each_masked_batch(sample, &expanded, visitor)
297    }
298
299    fn streams_masked_batches(&self) -> bool {
300        self.inner.streams_masked_batches()
301    }
302}
303/// Adapts a closure into a masker, enabling conditional, sparse, structured,
304/// text, or image masking without implementing a named type.
305pub struct FnMasker<F> {
306    n_features: usize,
307    mask_fn: F,
308}
309impl<F> FnMasker<F> {
310    pub fn new(n_features: usize, mask_fn: F) -> Result<Self> {
311        if n_features == 0 {
312            return Err(ShapError::InvalidConfiguration(
313                "masker must expose at least one feature".into(),
314            ));
315        }
316        Ok(Self {
317            n_features,
318            mask_fn,
319        })
320    }
321}
322impl<F> Masker for FnMasker<F>
323where
324    F: Fn(ArrayView1<'_, f64>, &[bool]) -> Result<Array2<f64>>,
325{
326    fn n_features(&self) -> usize {
327        self.n_features
328    }
329    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
330        if sample.len() != self.n_features || present.len() != self.n_features {
331            return Err(ShapError::DimensionMismatch {
332                expected: format!("{} features", self.n_features),
333                found: format!("sample {}, mask {}", sample.len(), present.len()),
334            });
335        }
336        (self.mask_fn)(sample, present)
337    }
338}
339/// Replaces absent features with a single fixed reference vector.
340#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
341#[serde(try_from = "FixedMaskerPayload")]
342pub struct FixedMasker {
343    reference: Array1<f64>,
344}
345#[derive(serde::Deserialize)]
346struct FixedMaskerPayload {
347    reference: Array1<f64>,
348}
349impl TryFrom<FixedMaskerPayload> for FixedMasker {
350    type Error = ShapError;
351    fn try_from(payload: FixedMaskerPayload) -> Result<Self> {
352        Self::new(payload.reference)
353    }
354}
355impl FixedMasker {
356    pub fn new(reference: Array1<f64>) -> Result<Self> {
357        if reference.is_empty() {
358            return Err(ShapError::InvalidConfiguration(
359                "fixed masker reference cannot be empty".into(),
360            ));
361        }
362        Ok(Self { reference })
363    }
364    pub fn reference(&self) -> ndarray::ArrayView1<'_, f64> {
365        self.reference.view()
366    }
367    pub fn validate(&self) -> Result<()> {
368        if self.reference.is_empty() {
369            return Err(ShapError::InvalidConfiguration(
370                "fixed masker reference cannot be empty".into(),
371            ));
372        }
373        Ok(())
374    }
375}
376impl Masker for FixedMasker {
377    fn n_features(&self) -> usize {
378        self.reference.len()
379    }
380    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
381        self.validate()?;
382        if sample.len() != self.n_features() || present.len() != self.n_features() {
383            return Err(ShapError::DimensionMismatch {
384                expected: format!("{} features", self.n_features()),
385                found: format!("sample {}, mask {}", sample.len(), present.len()),
386            });
387        }
388        let mut row = self.reference.clone();
389        for (j, &on) in present.iter().enumerate() {
390            if on {
391                row[j] = sample[j]
392            }
393        }
394        Array2::from_shape_vec((1, row.len()), row.to_vec())
395            .map_err(|e| ShapError::MaskerError(e.to_string()))
396    }
397}
398
399/// Masks numeric token IDs with a configured mask token. Positions marked
400/// immutable (for example BOS/EOS) are always retained.
401#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
402#[serde(try_from = "TextMaskerPayload")]
403pub struct TextMasker {
404    n_tokens: usize,
405    mask_token: f64,
406    immutable: Vec<bool>,
407}
408#[derive(serde::Deserialize)]
409struct TextMaskerPayload {
410    n_tokens: usize,
411    mask_token: f64,
412    immutable: Vec<bool>,
413}
414impl TryFrom<TextMaskerPayload> for TextMasker {
415    type Error = ShapError;
416    fn try_from(payload: TextMaskerPayload) -> Result<Self> {
417        let masker = Self {
418            n_tokens: payload.n_tokens,
419            mask_token: payload.mask_token,
420            immutable: payload.immutable,
421        };
422        masker.validate()?;
423        Ok(masker)
424    }
425}
426impl TextMasker {
427    pub fn new(n_tokens: usize, mask_token: f64) -> Result<Self> {
428        if n_tokens == 0 || !mask_token.is_finite() {
429            return Err(ShapError::InvalidConfiguration(
430                "text masker requires tokens and a finite mask token".into(),
431            ));
432        }
433        Ok(Self {
434            n_tokens,
435            mask_token,
436            immutable: vec![false; n_tokens],
437        })
438    }
439    pub fn with_immutable_positions(mut self, positions: &[usize]) -> Result<Self> {
440        for &j in positions {
441            if j >= self.n_tokens {
442                return Err(ShapError::InvalidFeatureIndex {
443                    index: j,
444                    n_features: self.n_tokens,
445                });
446            }
447            self.immutable[j] = true
448        }
449        Ok(self)
450    }
451    pub fn validate(&self) -> Result<()> {
452        if self.n_tokens == 0
453            || !self.mask_token.is_finite()
454            || self.immutable.len() != self.n_tokens
455        {
456            return Err(ShapError::InvalidConfiguration(
457                "text masker has invalid token metadata".into(),
458            ));
459        }
460        Ok(())
461    }
462}
463impl Masker for TextMasker {
464    fn n_features(&self) -> usize {
465        self.n_tokens
466    }
467    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
468        self.validate()?;
469        if sample.len() != self.n_tokens || present.len() != self.n_tokens {
470            return Err(ShapError::DimensionMismatch {
471                expected: format!("{} tokens", self.n_tokens),
472                found: format!("sample {}, mask {}", sample.len(), present.len()),
473            });
474        }
475        let row = (0..self.n_tokens)
476            .map(|j| {
477                if present[j] || self.immutable[j] {
478                    sample[j]
479                } else {
480                    self.mask_token
481                }
482            })
483            .collect::<Vec<_>>();
484        Array2::from_shape_vec((1, self.n_tokens), row)
485            .map_err(|e| ShapError::MaskerError(e.to_string()))
486    }
487}
488
489/// One tokenizer-produced piece used by [`TokenizedTextMasker`].
490#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
491#[serde(try_from = "TextTokenPayload")]
492pub struct TextToken {
493    id: f64,
494    text: String,
495    special: bool,
496    group: usize,
497}
498
499#[derive(serde::Deserialize)]
500struct TextTokenPayload {
501    id: f64,
502    text: String,
503    special: bool,
504    group: usize,
505}
506
507impl TryFrom<TextTokenPayload> for TextToken {
508    type Error = ShapError;
509
510    fn try_from(payload: TextTokenPayload) -> Result<Self> {
511        Ok(Self::new(payload.id, payload.text, payload.group)?.special(payload.special))
512    }
513}
514
515impl TextToken {
516    pub fn new(id: f64, text: impl Into<String>, group: usize) -> Result<Self> {
517        if !id.is_finite() {
518            return Err(ShapError::InvalidConfiguration(
519                "text token ID must be finite".into(),
520            ));
521        }
522        Ok(Self {
523            id,
524            text: text.into(),
525            special: false,
526            group,
527        })
528    }
529
530    pub fn special(mut self, special: bool) -> Self {
531        self.special = special;
532        self
533    }
534
535    pub fn id(&self) -> f64 {
536        self.id
537    }
538
539    pub fn text(&self) -> &str {
540        &self.text
541    }
542
543    pub fn is_special(&self) -> bool {
544        self.special
545    }
546
547    pub fn group(&self) -> usize {
548        self.group
549    }
550}
551
552/// Controls whether tokenizer special pieces participate in coalitions.
553#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
554pub enum SpecialTokenPolicy {
555    /// Always retain special pieces and exclude them from the explanation.
556    Preserve,
557    /// Treat special pieces like ordinary pieces in their configured group.
558    Mask,
559}
560
561/// Tokenizer-aware fixed-width text masking.
562///
563/// The caller supplies the tokenizer's pieces, numeric IDs, and grouping. This
564/// keeps the crate tokenizer-independent while allowing reconstructed strings
565/// to follow the tokenizer's exact piece spelling. Equal group labels share a
566/// coalition bit, which is useful for word pieces or byte-pair tokens.
567#[derive(Debug, Clone, PartialEq)]
568pub struct TokenizedTextMasker {
569    tokens: Vec<TextToken>,
570    position_groups: Vec<Option<usize>>,
571    group_count: usize,
572    mask_token_id: f64,
573    mask_text: String,
574    separator: String,
575    special_policy: SpecialTokenPolicy,
576}
577
578impl TokenizedTextMasker {
579    pub fn new(
580        tokens: Vec<TextToken>,
581        mask_token_id: f64,
582        mask_text: impl Into<String>,
583        special_policy: SpecialTokenPolicy,
584    ) -> Result<Self> {
585        if tokens.is_empty() || !mask_token_id.is_finite() {
586            return Err(ShapError::InvalidConfiguration(
587                "tokenized text masker requires tokens and a finite mask token ID".into(),
588            ));
589        }
590        if tokens.iter().any(|token| !token.id.is_finite()) {
591            return Err(ShapError::InvalidConfiguration(
592                "text token IDs must be finite".into(),
593            ));
594        }
595        let mut groups = std::collections::HashMap::new();
596        let mut position_groups = Vec::with_capacity(tokens.len());
597        for token in &tokens {
598            if token.special && special_policy == SpecialTokenPolicy::Preserve {
599                position_groups.push(None);
600            } else {
601                let next = groups.len();
602                let group = *groups.entry(token.group).or_insert(next);
603                position_groups.push(Some(group));
604            }
605        }
606        if groups.is_empty() {
607            return Err(ShapError::InvalidConfiguration(
608                "tokenized text masker must expose at least one non-preserved token group".into(),
609            ));
610        }
611        Ok(Self {
612            tokens,
613            position_groups,
614            group_count: groups.len(),
615            mask_token_id,
616            mask_text: mask_text.into(),
617            separator: String::new(),
618            special_policy,
619        })
620    }
621
622    /// Sets text inserted between reconstructed tokenizer pieces.
623    pub fn with_separator(mut self, separator: impl Into<String>) -> Self {
624        self.separator = separator.into();
625        self
626    }
627
628    pub fn tokens(&self) -> &[TextToken] {
629        &self.tokens
630    }
631
632    pub fn special_token_policy(&self) -> SpecialTokenPolicy {
633        self.special_policy
634    }
635
636    /// Returns the model-ready token IDs represented by this tokenization.
637    pub fn token_ids(&self) -> Array1<f64> {
638        Array1::from_iter(self.tokens.iter().map(TextToken::id))
639    }
640
641    /// Reconstructs masked text using the tokenizer's original piece strings.
642    pub fn reconstruct(&self, present: &[bool]) -> Result<String> {
643        if present.len() != self.group_count {
644            return Err(ShapError::DimensionMismatch {
645                expected: format!("{} token groups", self.group_count),
646                found: format!("{}", present.len()),
647            });
648        }
649        Ok(self
650            .tokens
651            .iter()
652            .zip(&self.position_groups)
653            .map(|(token, group)| match group {
654                None => token.text.as_str(),
655                Some(group) if present[*group] => token.text.as_str(),
656                Some(_) => self.mask_text.as_str(),
657            })
658            .collect::<Vec<_>>()
659            .join(&self.separator))
660    }
661}
662
663impl Masker for TokenizedTextMasker {
664    fn n_features(&self) -> usize {
665        self.group_count
666    }
667
668    fn n_input_features(&self) -> usize {
669        self.tokens.len()
670    }
671
672    fn attribution_data(&self, samples: ndarray::ArrayView2<'_, f64>) -> Result<Array2<f64>> {
673        if samples.ncols() != self.tokens.len() {
674            return Err(ShapError::DimensionMismatch {
675                expected: format!("{} token IDs", self.tokens.len()),
676                found: format!("{}", samples.ncols()),
677            });
678        }
679        let mut grouped = Array2::zeros((samples.nrows(), self.group_count));
680        let mut counts = vec![0usize; self.group_count];
681        for group in self.position_groups.iter().flatten() {
682            counts[*group] += 1;
683        }
684        for row in 0..samples.nrows() {
685            for (position, group) in self.position_groups.iter().enumerate() {
686                if let Some(group) = group {
687                    grouped[[row, *group]] += samples[[row, position]];
688                }
689            }
690            for group in 0..self.group_count {
691                grouped[[row, group]] /= counts[group] as f64;
692            }
693        }
694        Ok(grouped)
695    }
696
697    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
698        if sample.len() != self.tokens.len() || present.len() != self.group_count {
699            return Err(ShapError::DimensionMismatch {
700                expected: format!(
701                    "{} token IDs and {} token groups",
702                    self.tokens.len(),
703                    self.group_count
704                ),
705                found: format!("sample {}, mask {}", sample.len(), present.len()),
706            });
707        }
708        let row = self
709            .position_groups
710            .iter()
711            .enumerate()
712            .map(|(position, group)| match group {
713                None => sample[position],
714                Some(group) if present[*group] => sample[position],
715                Some(_) => self.mask_token_id,
716            })
717            .collect::<Vec<_>>();
718        Array2::from_shape_vec((1, row.len()), row)
719            .map_err(|error| ShapError::MaskerError(error.to_string()))
720    }
721}
722
723/// Masks a flattened image against a reference image. Each channel value is
724/// an independently explainable feature; dimensions are retained as metadata.
725#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
726#[serde(try_from = "ImageMaskerPayload")]
727pub struct ImageMasker {
728    width: usize,
729    height: usize,
730    channels: usize,
731    reference: Array1<f64>,
732}
733
734/// Baseline used to replace absent image segments.
735#[derive(Debug, Clone, PartialEq)]
736pub enum ImageBaseline {
737    /// A fixed flattened image with the same dimensions as the input.
738    Reference(Array1<f64>),
739    /// A box blur computed from each input image. `radius` is measured in pixels.
740    Blur { radius: usize },
741}
742
743/// Masks a flattened image by segment (for example, by superpixel).
744/// All channels belonging to a pixel are controlled by the same coalition bit.
745#[derive(Debug, Clone, PartialEq)]
746pub struct SegmentedImageMasker {
747    width: usize,
748    height: usize,
749    channels: usize,
750    segments: Vec<usize>,
751    segment_count: usize,
752    baseline: ImageBaseline,
753}
754
755impl SegmentedImageMasker {
756    pub fn new(
757        width: usize,
758        height: usize,
759        channels: usize,
760        segments: Vec<usize>,
761        baseline: ImageBaseline,
762    ) -> Result<Self> {
763        let pixels = width
764            .checked_mul(height)
765            .ok_or_else(|| ShapError::InvalidConfiguration("image dimensions overflow".into()))?;
766        let values = pixels
767            .checked_mul(channels)
768            .ok_or_else(|| ShapError::InvalidConfiguration("image dimensions overflow".into()))?;
769        if values == 0 || segments.len() != pixels {
770            return Err(ShapError::DimensionMismatch {
771                expected: format!("{pixels} segment labels"),
772                found: format!("{}", segments.len()),
773            });
774        }
775        let mut labels = std::collections::HashMap::new();
776        let mut normalized = Vec::with_capacity(pixels);
777        for label in segments {
778            let next = labels.len();
779            let index = *labels.entry(label).or_insert(next);
780            normalized.push(index);
781        }
782        match &baseline {
783            ImageBaseline::Reference(reference) if reference.len() != values => {
784                return Err(ShapError::DimensionMismatch {
785                    expected: format!("{values} reference values"),
786                    found: format!("{}", reference.len()),
787                });
788            }
789            ImageBaseline::Blur { radius: 0 } => {
790                return Err(ShapError::InvalidConfiguration(
791                    "image blur radius must be positive".into(),
792                ));
793            }
794            _ => {}
795        }
796        Ok(Self {
797            width,
798            height,
799            channels,
800            segment_count: labels.len(),
801            segments: normalized,
802            baseline,
803        })
804    }
805
806    pub fn dimensions(&self) -> (usize, usize, usize) {
807        (self.width, self.height, self.channels)
808    }
809
810    pub fn segments(&self) -> &[usize] {
811        &self.segments
812    }
813
814    pub fn baseline(&self) -> &ImageBaseline {
815        &self.baseline
816    }
817
818    fn blurred(&self, sample: ArrayView1<'_, f64>, radius: usize) -> Array1<f64> {
819        let mut output = Array1::zeros(sample.len());
820        for y in 0..self.height {
821            let y0 = y.saturating_sub(radius);
822            let y1 = y.saturating_add(radius).min(self.height - 1);
823            for x in 0..self.width {
824                let x0 = x.saturating_sub(radius);
825                let x1 = x.saturating_add(radius).min(self.width - 1);
826                let count = (y1 - y0 + 1) * (x1 - x0 + 1);
827                for channel in 0..self.channels {
828                    let sum = (y0..=y1)
829                        .flat_map(|source_y| (x0..=x1).map(move |source_x| (source_y, source_x)))
830                        .map(|(source_y, source_x)| {
831                            sample[(source_y * self.width + source_x) * self.channels + channel]
832                        })
833                        .sum::<f64>();
834                    output[(y * self.width + x) * self.channels + channel] = sum / count as f64;
835                }
836            }
837        }
838        output
839    }
840}
841
842impl Masker for SegmentedImageMasker {
843    fn n_features(&self) -> usize {
844        self.segment_count
845    }
846    fn n_input_features(&self) -> usize {
847        self.width * self.height * self.channels
848    }
849
850    fn attribution_data(&self, samples: ndarray::ArrayView2<'_, f64>) -> Result<Array2<f64>> {
851        if samples.ncols() != self.n_input_features() {
852            return Err(ShapError::DimensionMismatch {
853                expected: format!("{} image values", self.n_input_features()),
854                found: format!("{}", samples.ncols()),
855            });
856        }
857        let mut grouped = Array2::zeros((samples.nrows(), self.segment_count));
858        let mut counts = vec![0usize; self.segment_count];
859        for &segment in &self.segments {
860            counts[segment] += self.channels;
861        }
862        for row in 0..samples.nrows() {
863            for (pixel, &segment) in self.segments.iter().enumerate() {
864                for channel in 0..self.channels {
865                    grouped[[row, segment]] += samples[[row, pixel * self.channels + channel]];
866                }
867            }
868            for segment in 0..self.segment_count {
869                grouped[[row, segment]] /= counts[segment] as f64;
870            }
871        }
872        Ok(grouped)
873    }
874
875    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
876        if sample.len() != self.n_input_features() || present.len() != self.segment_count {
877            return Err(ShapError::DimensionMismatch {
878                expected: format!(
879                    "{} image values and {} segments",
880                    self.n_input_features(),
881                    self.segment_count
882                ),
883                found: format!("sample {}, mask {}", sample.len(), present.len()),
884            });
885        }
886        let mut output = match &self.baseline {
887            ImageBaseline::Reference(reference) => reference.clone(),
888            ImageBaseline::Blur { radius } => self.blurred(sample, *radius),
889        };
890        for (pixel, &segment) in self.segments.iter().enumerate() {
891            if present[segment] {
892                for channel in 0..self.channels {
893                    let index = pixel * self.channels + channel;
894                    output[index] = sample[index];
895                }
896            }
897        }
898        Array2::from_shape_vec((1, output.len()), output.to_vec())
899            .map_err(|error| ShapError::MaskerError(error.to_string()))
900    }
901}
902
903/// Adapts an image inpainting function into a segment-aware masker.
904pub struct InpaintingImageMasker<F> {
905    segmented: SegmentedImageMasker,
906    inpaint: F,
907}
908
909impl<F> InpaintingImageMasker<F> {
910    pub fn new(segmented: SegmentedImageMasker, inpaint: F) -> Self {
911        Self { segmented, inpaint }
912    }
913    pub fn segmented(&self) -> &SegmentedImageMasker {
914        &self.segmented
915    }
916}
917
918impl<F> Masker for InpaintingImageMasker<F>
919where
920    F: Fn(ArrayView1<'_, f64>, &[bool], &[usize], (usize, usize, usize)) -> Result<Array2<f64>>,
921{
922    fn n_features(&self) -> usize {
923        self.segmented.n_features()
924    }
925    fn n_input_features(&self) -> usize {
926        self.segmented.n_input_features()
927    }
928    fn attribution_data(&self, samples: ndarray::ArrayView2<'_, f64>) -> Result<Array2<f64>> {
929        self.segmented.attribution_data(samples)
930    }
931    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
932        if sample.len() != self.n_input_features() || present.len() != self.n_features() {
933            return Err(ShapError::DimensionMismatch {
934                expected: format!(
935                    "{} image values and {} segments",
936                    self.n_input_features(),
937                    self.n_features()
938                ),
939                found: format!("sample {}, mask {}", sample.len(), present.len()),
940            });
941        }
942        let output = (self.inpaint)(
943            sample,
944            present,
945            self.segmented.segments(),
946            self.segmented.dimensions(),
947        )?;
948        if output.nrows() == 0 || output.ncols() != self.n_input_features() {
949            return Err(ShapError::DimensionMismatch {
950                expected: format!(
951                    "(rows>0, {}) inpainted image batch",
952                    self.n_input_features()
953                ),
954                found: format!("{:?}", output.dim()),
955            });
956        }
957        Ok(output)
958    }
959}
960#[derive(serde::Deserialize)]
961struct ImageMaskerPayload {
962    width: usize,
963    height: usize,
964    channels: usize,
965    reference: Array1<f64>,
966}
967impl TryFrom<ImageMaskerPayload> for ImageMasker {
968    type Error = ShapError;
969    fn try_from(payload: ImageMaskerPayload) -> Result<Self> {
970        Self::new(
971            payload.width,
972            payload.height,
973            payload.channels,
974            payload.reference,
975        )
976    }
977}
978impl ImageMasker {
979    pub fn new(
980        width: usize,
981        height: usize,
982        channels: usize,
983        reference: Array1<f64>,
984    ) -> Result<Self> {
985        let expected = width
986            .checked_mul(height)
987            .and_then(|x| x.checked_mul(channels))
988            .ok_or_else(|| ShapError::InvalidConfiguration("image dimensions overflow".into()))?;
989        if expected == 0 || reference.len() != expected {
990            return Err(ShapError::DimensionMismatch {
991                expected: format!("{expected} image values"),
992                found: format!("{}", reference.len()),
993            });
994        }
995        Ok(Self {
996            width,
997            height,
998            channels,
999            reference,
1000        })
1001    }
1002    pub fn dimensions(&self) -> (usize, usize, usize) {
1003        (self.width, self.height, self.channels)
1004    }
1005    pub fn validate(&self) -> Result<()> {
1006        let expected = self
1007            .width
1008            .checked_mul(self.height)
1009            .and_then(|value| value.checked_mul(self.channels))
1010            .ok_or_else(|| ShapError::InvalidConfiguration("image dimensions overflow".into()))?;
1011        if expected == 0 || self.reference.len() != expected {
1012            return Err(ShapError::DimensionMismatch {
1013                expected: format!("{expected} image values"),
1014                found: format!("{}", self.reference.len()),
1015            });
1016        }
1017        Ok(())
1018    }
1019}
1020impl Masker for ImageMasker {
1021    fn n_features(&self) -> usize {
1022        self.reference.len()
1023    }
1024    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
1025        self.validate()?;
1026        if sample.len() != self.n_features() || present.len() != self.n_features() {
1027            return Err(ShapError::DimensionMismatch {
1028                expected: format!("{} image values", self.n_features()),
1029                found: format!("sample {}, mask {}", sample.len(), present.len()),
1030            });
1031        }
1032        let mut row = self.reference.clone();
1033        for (j, &on) in present.iter().enumerate() {
1034            if on {
1035                row[j] = sample[j]
1036            }
1037        }
1038        Array2::from_shape_vec((1, row.len()), row.to_vec())
1039            .map_err(|e| ShapError::MaskerError(e.to_string()))
1040    }
1041}
1042#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1043pub struct IndependentMasker {
1044    background: Background,
1045}
1046impl IndependentMasker {
1047    pub fn new(background: Background) -> Self {
1048        Self { background }
1049    }
1050    pub fn background(&self) -> &Background {
1051        &self.background
1052    }
1053    pub fn baseline(&self) -> Result<Array1<f64>> {
1054        self.background.validate()?;
1055        self.background
1056            .data()
1057            .mean_axis(Axis(0))
1058            .ok_or(ShapError::EmptyBackground)
1059    }
1060}
1061impl Masker for IndependentMasker {
1062    fn n_features(&self) -> usize {
1063        self.background.n_features()
1064    }
1065    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
1066        self.background.validate()?;
1067        if sample.len() != self.n_features() || present.len() != self.n_features() {
1068            return Err(ShapError::DimensionMismatch {
1069                expected: format!("{} features", self.n_features()),
1070                found: format!("sample {}, mask {}", sample.len(), present.len()),
1071            });
1072        }
1073        let mut out = self.background.data().to_owned();
1074        for (j, &on) in present.iter().enumerate() {
1075            if on {
1076                out.column_mut(j).fill(sample[j])
1077            }
1078        }
1079        Ok(out)
1080    }
1081}
1082
1083/// Empirical conditional tabular masker using nearest background rows on the
1084/// currently present features. Categorical columns use exact-match distance;
1085/// numerical columns use variance-scaled squared distance.
1086#[derive(Debug, Clone)]
1087pub struct ConditionalTabularMasker {
1088    background: Background,
1089    categorical: Vec<bool>,
1090    scales: Vec<f64>,
1091    neighbors: usize,
1092}
1093
1094impl ConditionalTabularMasker {
1095    pub fn new(
1096        background: Background,
1097        categorical_features: &[usize],
1098        neighbors: usize,
1099    ) -> Result<Self> {
1100        background.validate()?;
1101        if neighbors == 0 {
1102            return Err(ShapError::InvalidConfiguration(
1103                "conditional tabular neighbors must be positive".into(),
1104            ));
1105        }
1106        let mut categorical = vec![false; background.n_features()];
1107        for &feature in categorical_features {
1108            if feature >= categorical.len() {
1109                return Err(ShapError::InvalidFeatureIndex {
1110                    index: feature,
1111                    n_features: categorical.len(),
1112                });
1113            }
1114            if categorical[feature] {
1115                return Err(ShapError::InvalidConfiguration(
1116                    "categorical feature indices must be unique".into(),
1117                ));
1118            }
1119            categorical[feature] = true;
1120        }
1121        let means = background.data().mean_axis(Axis(0)).unwrap();
1122        let scales = (0..background.n_features())
1123            .map(|feature| {
1124                let variance = background
1125                    .data()
1126                    .column(feature)
1127                    .iter()
1128                    .filter(|value| value.is_finite())
1129                    .map(|value| (value - means[feature]).powi(2))
1130                    .sum::<f64>()
1131                    / background.n_samples() as f64;
1132                let scale = variance.sqrt();
1133                if scale.is_finite() && scale > f64::EPSILON {
1134                    scale
1135                } else {
1136                    1.0
1137                }
1138            })
1139            .collect();
1140        Ok(Self {
1141            background,
1142            categorical,
1143            scales,
1144            neighbors,
1145        })
1146    }
1147
1148    pub fn background(&self) -> &Background {
1149        &self.background
1150    }
1151
1152    pub fn neighbors(&self) -> usize {
1153        self.neighbors
1154    }
1155}
1156
1157impl Masker for ConditionalTabularMasker {
1158    fn n_features(&self) -> usize {
1159        self.background.n_features()
1160    }
1161
1162    fn mask(&self, sample: ArrayView1<'_, f64>, present: &[bool]) -> Result<Array2<f64>> {
1163        self.background.validate()?;
1164        if sample.len() != self.n_features() || present.len() != self.n_features() {
1165            return Err(ShapError::DimensionMismatch {
1166                expected: format!("{} features", self.n_features()),
1167                found: format!("sample {}, mask {}", sample.len(), present.len()),
1168            });
1169        }
1170        let selected = if present.iter().any(|value| *value) {
1171            let mut distances = self
1172                .background
1173                .data()
1174                .rows()
1175                .into_iter()
1176                .enumerate()
1177                .map(|(row, values)| {
1178                    let distance = present
1179                        .iter()
1180                        .enumerate()
1181                        .filter(|(_, enabled)| **enabled)
1182                        .map(|(feature, _)| {
1183                            let left = sample[feature];
1184                            let right = values[feature];
1185                            if left.is_nan() || right.is_nan() {
1186                                if left.is_nan() && right.is_nan() {
1187                                    0.0
1188                                } else {
1189                                    1.0
1190                                }
1191                            } else if self.categorical[feature] {
1192                                if left == right {
1193                                    0.0
1194                                } else {
1195                                    1.0
1196                                }
1197                            } else {
1198                                ((left - right) / self.scales[feature]).powi(2)
1199                            }
1200                        })
1201                        .sum::<f64>();
1202                    (row, distance)
1203                })
1204                .collect::<Vec<_>>();
1205            distances.sort_by(|left, right| {
1206                left.1
1207                    .total_cmp(&right.1)
1208                    .then_with(|| left.0.cmp(&right.0))
1209            });
1210            distances
1211                .into_iter()
1212                .take(self.neighbors.min(self.background.n_samples()))
1213                .map(|(row, _)| row)
1214                .collect::<Vec<_>>()
1215        } else {
1216            (0..self.background.n_samples()).collect()
1217        };
1218        let mut output = self.background.select(&selected)?.data().to_owned();
1219        for (feature, enabled) in present.iter().copied().enumerate() {
1220            if enabled {
1221                output.column_mut(feature).fill(sample[feature]);
1222            }
1223        }
1224        Ok(output)
1225    }
1226}
1227#[cfg(test)]
1228mod structured_tests {
1229    use super::*;
1230    use ndarray::array;
1231    #[test]
1232    fn text_masker_preserves_special_tokens() {
1233        let m = TextMasker::new(3, 99.)
1234            .unwrap()
1235            .with_immutable_positions(&[0])
1236            .unwrap();
1237        let out = m
1238            .mask(array![1., 2., 3.].view(), &[false, false, true])
1239            .unwrap();
1240        assert_eq!(out, array![[1., 99., 3.]]);
1241    }
1242    #[test]
1243    fn tokenized_text_groups_pieces_and_reconstructs_text() {
1244        let masker = TokenizedTextMasker::new(
1245            vec![
1246                TextToken::new(101., "[CLS]", 99).unwrap().special(true),
1247                TextToken::new(10., "walk", 0).unwrap(),
1248                TextToken::new(11., "##ing", 0).unwrap(),
1249                TextToken::new(20., " home", 1).unwrap(),
1250                TextToken::new(102., "[SEP]", 100).unwrap().special(true),
1251            ],
1252            0.,
1253            "[MASK]",
1254            SpecialTokenPolicy::Preserve,
1255        )
1256        .unwrap();
1257        assert_eq!(masker.n_features(), 2);
1258        assert_eq!(masker.n_input_features(), 5);
1259        assert_eq!(
1260            masker
1261                .mask(masker.token_ids().view(), &[false, true])
1262                .unwrap(),
1263            array![[101., 0., 0., 20., 102.]]
1264        );
1265        assert_eq!(
1266            masker.reconstruct(&[false, true]).unwrap(),
1267            "[CLS][MASK][MASK] home[SEP]"
1268        );
1269        assert_eq!(
1270            masker
1271                .attribution_data(array![[101., 10., 12., 20., 102.]].view())
1272                .unwrap(),
1273            array![[11., 20.]]
1274        );
1275    }
1276
1277    #[test]
1278    fn tokenized_text_can_mask_special_tokens() {
1279        let masker = TokenizedTextMasker::new(
1280            vec![
1281                TextToken::new(101., "CLS", 0).unwrap().special(true),
1282                TextToken::new(5., "word", 1).unwrap(),
1283            ],
1284            -1.,
1285            "_",
1286            SpecialTokenPolicy::Mask,
1287        )
1288        .unwrap()
1289        .with_separator(" ");
1290        assert_eq!(masker.reconstruct(&[false, true]).unwrap(), "_ word");
1291        assert_eq!(
1292            masker
1293                .mask(masker.token_ids().view(), &[false, true])
1294                .unwrap(),
1295            array![[-1., 5.]]
1296        );
1297    }
1298    #[test]
1299    fn image_masker_uses_reference() {
1300        let m = ImageMasker::new(1, 1, 2, array![0.1, 0.2]).unwrap();
1301        let out = m.mask(array![0.8, 0.9].view(), &[true, false]).unwrap();
1302        assert_eq!(out, array![[0.8, 0.2]]);
1303    }
1304    #[test]
1305    fn segmented_image_masker_controls_pixels_and_channels_together() {
1306        let masker = SegmentedImageMasker::new(
1307            2,
1308            1,
1309            2,
1310            vec![7, 9],
1311            ImageBaseline::Reference(array![0.1, 0.2, 0.3, 0.4]),
1312        )
1313        .unwrap();
1314        let output = masker
1315            .mask(array![1., 2., 3., 4.].view(), &[true, false])
1316            .unwrap();
1317        assert_eq!(output, array![[1., 2., 0.3, 0.4]]);
1318        assert_eq!(masker.n_features(), 2);
1319        assert_eq!(
1320            masker
1321                .attribution_data(array![[1., 3., 5., 7.]].view())
1322                .unwrap(),
1323            array![[2., 6.]]
1324        );
1325    }
1326
1327    #[test]
1328    fn segmented_image_blur_is_channel_aware() {
1329        let masker =
1330            SegmentedImageMasker::new(3, 1, 1, vec![0, 1, 2], ImageBaseline::Blur { radius: 1 })
1331                .unwrap();
1332        let output = masker
1333            .mask(array![0., 3., 9.].view(), &[false, false, false])
1334            .unwrap();
1335        assert_eq!(output, array![[1.5, 4., 6.]]);
1336    }
1337
1338    #[test]
1339    fn inpainting_adapter_validates_callback_shape() {
1340        let segmented =
1341            SegmentedImageMasker::new(1, 1, 1, vec![0], ImageBaseline::Reference(array![0.]))
1342                .unwrap();
1343        let masker = InpaintingImageMasker::new(
1344            segmented,
1345            |_: ArrayView1<'_, f64>,
1346             _: &[bool],
1347             _: &[usize],
1348             _: (usize, usize, usize)|
1349             -> Result<Array2<f64>> { Ok(Array2::zeros((1, 0))) },
1350        );
1351        assert!(masker.mask(array![1.].view(), &[false]).is_err());
1352    }
1353    #[test]
1354    fn conditional_tabular_masker_uses_categorical_and_numeric_distance() {
1355        let masker = ConditionalTabularMasker::new(
1356            Background::new(array![[0., 0.], [1., 0.1], [1., 10.], [2., 0.2]]).unwrap(),
1357            &[0],
1358            1,
1359        )
1360        .unwrap();
1361        let conditioned = masker.mask(array![1., 9.].view(), &[true, true]).unwrap();
1362        assert_eq!(conditioned, array![[1., 9.]]);
1363        let categorical_only = masker.mask(array![1., 9.].view(), &[true, false]).unwrap();
1364        assert_eq!(categorical_only, array![[1., 0.1]]);
1365        assert_eq!(
1366            masker
1367                .mask(array![1., 9.].view(), &[false, false])
1368                .unwrap()
1369                .nrows(),
1370            4
1371        );
1372    }
1373    #[test]
1374    fn grouped_masker_expands_coalitions_and_preserves_source_mapping() {
1375        let inner = FixedMasker::new(array![0., 0., 0.]).unwrap();
1376        let masker = GroupedMasker::new(inner, vec![vec![0, 2], vec![1]]).unwrap();
1377        let masked = masker
1378            .mask(array![2., 4., 8.].view(), &[true, false])
1379            .unwrap();
1380        assert_eq!(masked, array![[2., 0., 8.]]);
1381        assert_eq!(masker.groups(), &[vec![0, 2], vec![1]]);
1382        assert_eq!(
1383            masker
1384                .attribution_data(array![[2., 4., 8.]].view())
1385                .unwrap(),
1386            array![[5., 4.]]
1387        );
1388    }
1389    #[test]
1390    fn rejects_invalid_deserialized_style_maskers_before_indexing() {
1391        let text = TextMasker {
1392            n_tokens: 2,
1393            mask_token: 0.,
1394            immutable: vec![false],
1395        };
1396        assert!(text.mask(array![1., 2.].view(), &[false, false]).is_err());
1397
1398        let image = ImageMasker {
1399            width: 2,
1400            height: 2,
1401            channels: 1,
1402            reference: array![0., 0.],
1403        };
1404        assert!(image.validate().is_err());
1405    }
1406
1407    #[cfg(feature = "json-adapters")]
1408    #[test]
1409    fn serializable_builtin_maskers_round_trip() {
1410        let fixed = FixedMasker::new(array![0., 1.]).unwrap();
1411        let decoded: FixedMasker =
1412            serde_json::from_str(&serde_json::to_string(&fixed).unwrap()).unwrap();
1413        assert_eq!(decoded, fixed);
1414
1415        let independent = IndependentMasker::new(Background::new(array![[0., 1.]]).unwrap());
1416        let decoded: IndependentMasker =
1417            serde_json::from_str(&serde_json::to_string(&independent).unwrap()).unwrap();
1418        assert_eq!(decoded, independent);
1419    }
1420}