Skip to main content

shap_rs/explainers/
partition.rs

1/// A validated, non-overlapping partition of all input features.
2#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
3#[serde(try_from = "FeaturePartitionPayload")]
4pub struct FeaturePartition {
5    groups: Vec<Vec<usize>>,
6    n_features: usize,
7}
8#[derive(serde::Deserialize)]
9struct FeaturePartitionPayload {
10    groups: Vec<Vec<usize>>,
11    n_features: usize,
12}
13impl TryFrom<FeaturePartitionPayload> for FeaturePartition {
14    type Error = ShapError;
15    fn try_from(payload: FeaturePartitionPayload) -> Result<Self> {
16        Self::new(payload.groups, payload.n_features)
17    }
18}
19
20use crate::{
21    coalition, evaluation::CoalitionEvaluator, Background, EvaluationConfig, Explainer,
22    Explanation, IndependentMasker, Link, Masker, Predict, Result, ShapError,
23};
24use ndarray::{Array2, Array3, ArrayView2};
25use rand::{rngs::StdRng, Rng, SeedableRng};
26
27/// Exact Owen values for a partition of the input features. This preserves
28/// group boundaries while allocating each group's contribution among members.
29pub struct PartitionExplainer<M, K = IndependentMasker> {
30    model: M,
31    masker: K,
32    partition: FeaturePartition,
33    max_features: usize,
34    evaluation: EvaluationConfig,
35    link: Link,
36}
37impl<M> PartitionExplainer<M, IndependentMasker> {
38    pub fn new(model: M, background: Background, partition: FeaturePartition) -> Self {
39        Self::from_masker(model, IndependentMasker::new(background), partition)
40    }
41}
42impl<M, K> PartitionExplainer<M, K> {
43    pub fn from_masker(model: M, masker: K, partition: FeaturePartition) -> Self {
44        Self {
45            model,
46            masker,
47            partition,
48            max_features: 20,
49            evaluation: EvaluationConfig {
50                coalition_batch_size: 64,
51                cache_capacity: 1 << 20,
52                max_model_rows: None,
53            },
54            link: Link::Identity,
55        }
56    }
57    pub fn with_max_features(mut self, n: usize) -> Self {
58        self.max_features = n;
59        self
60    }
61    pub fn with_evaluation_config(mut self, c: EvaluationConfig) -> Self {
62        self.evaluation = c;
63        self
64    }
65    pub fn with_link(mut self, link: Link) -> Self {
66        self.link = link;
67        self
68    }
69}
70impl<M: Predict, K: Masker> Explainer for PartitionExplainer<M, K> {
71    fn explain(&self, x: ArrayView2<'_, f64>) -> Result<Explanation> {
72        let m = self.masker.n_features();
73        self.partition.validate()?;
74        if self.partition.n_features() != m {
75            return Err(ShapError::DimensionMismatch {
76                expected: format!("partition for {m} features"),
77                found: format!("partition for {} features", self.partition.n_features()),
78            });
79        }
80        if x.nrows() == 0 {
81            return Err(ShapError::EmptyData);
82        }
83        if x.ncols() != self.masker.n_input_features() {
84            return Err(ShapError::DimensionMismatch {
85                expected: format!("{} input features", self.masker.n_input_features()),
86                found: format!("{}", x.ncols()),
87            });
88        }
89        if m > self.max_features || m >= 63 {
90            return Err(ShapError::InvalidConfiguration(format!(
91                "exact Owen values support at most {} features",
92                self.max_features
93            )));
94        }
95        let masks = coalition::all(m).collect::<Vec<_>>();
96        let mut first = CoalitionEvaluator::new(&self.model, &self.masker, self.evaluation)?;
97        let o = first.evaluate(x.row(0), &[0])?[0].len();
98        crate::error::checked_f64_shape(&[x.nrows(), m, o], "partition explanation")?;
99        let mut values = Array3::zeros((x.nrows(), m, o));
100        let mut bases = Array2::zeros((x.nrows(), o));
101        let groups = self.partition.groups();
102        let ng = groups.len();
103        let group_masks = groups
104            .iter()
105            .map(|g| g.iter().fold(0u64, |z, &j| z | (1u64 << j)))
106            .collect::<Vec<_>>();
107        let fg = factorials(ng.max(groups.iter().map(Vec::len).max().unwrap_or(0)));
108        for n in 0..x.nrows() {
109            let mut evaluator =
110                CoalitionEvaluator::new(&self.model, &self.masker, self.evaluation)?;
111            let cache = evaluator
112                .evaluate(x.row(n), &masks)?
113                .into_iter()
114                .map(|row| {
115                    row.into_iter()
116                        .map(|value| self.link.forward(value))
117                        .collect::<Result<Vec<_>>>()
118                })
119                .collect::<Result<Vec<_>>>()?;
120            for k in 0..o {
121                bases[[n, k]] = cache[0][k]
122            }
123            for (g_index, group) in groups.iter().enumerate() {
124                let others = (0..ng).filter(|&g| g != g_index).collect::<Vec<_>>();
125                for &feature in group {
126                    let peers = group
127                        .iter()
128                        .copied()
129                        .filter(|&j| j != feature)
130                        .collect::<Vec<_>>();
131                    for outer in 0..(1u64 << others.len()) {
132                        let selected_groups = outer.count_ones() as usize;
133                        let outer_weight =
134                            fg[selected_groups] * fg[ng - selected_groups - 1] / fg[ng];
135                        let mut base_mask = 0u64;
136                        for (pos, &g) in others.iter().enumerate() {
137                            if outer & (1 << pos) != 0 {
138                                base_mask |= group_masks[g]
139                            }
140                        }
141                        for inner in 0..(1u64 << peers.len()) {
142                            let selected_features = inner.count_ones() as usize;
143                            let inner_weight = fg[selected_features]
144                                * fg[group.len() - selected_features - 1]
145                                / fg[group.len()];
146                            let mut mask = base_mask;
147                            for (pos, &j) in peers.iter().enumerate() {
148                                if inner & (1 << pos) != 0 {
149                                    mask |= 1 << j
150                                }
151                            }
152                            for k in 0..o {
153                                values[[n, feature, k]] += outer_weight
154                                    * inner_weight
155                                    * (cache[(mask | (1 << feature)) as usize][k]
156                                        - cache[mask as usize][k]);
157                            }
158                        }
159                    }
160                }
161            }
162        }
163        Explanation::new(values, bases, self.masker.attribution_data(x)?)
164    }
165}
166fn factorials(n: usize) -> Vec<f64> {
167    (0..=n)
168        .scan(1.0, |v, k| {
169            if k > 0 {
170                *v *= k as f64
171            }
172            Some(*v)
173        })
174        .collect()
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
178pub enum PartitionNode {
179    Feature(usize),
180    Group(Box<PartitionNode>, Box<PartitionNode>),
181}
182#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
183#[serde(try_from = "PartitionTreePayload")]
184pub struct PartitionTree {
185    root: PartitionNode,
186    n_features: usize,
187}
188#[derive(serde::Deserialize)]
189struct PartitionTreePayload {
190    root: PartitionNode,
191    n_features: usize,
192}
193impl TryFrom<PartitionTreePayload> for PartitionTree {
194    type Error = ShapError;
195    fn try_from(payload: PartitionTreePayload) -> Result<Self> {
196        Self::new(payload.root, payload.n_features)
197    }
198}
199impl PartitionTree {
200    pub fn new(root: PartitionNode, n_features: usize) -> Result<Self> {
201        if n_features == 0 {
202            return Err(ShapError::InvalidConfiguration(
203                "partition tree must contain features".into(),
204            ));
205        }
206        let mut seen = vec![false; n_features];
207        fn visit(n: &PartitionNode, seen: &mut [bool]) -> Result<()> {
208            match n {
209                PartitionNode::Feature(j) => {
210                    if *j >= seen.len() || seen[*j] {
211                        return Err(ShapError::InvalidConfiguration(
212                            "partition tree must contain each feature exactly once".into(),
213                        ));
214                    }
215                    seen[*j] = true
216                }
217                PartitionNode::Group(a, b) => {
218                    visit(a, seen)?;
219                    visit(b, seen)?
220                }
221            }
222            Ok(())
223        }
224        visit(&root, &mut seen)?;
225        if seen.iter().any(|x| !*x) {
226            return Err(ShapError::InvalidConfiguration(
227                "partition tree must contain each feature exactly once".into(),
228            ));
229        }
230        Ok(Self { root, n_features })
231    }
232    pub fn root(&self) -> &PartitionNode {
233        &self.root
234    }
235    pub fn n_features(&self) -> usize {
236        self.n_features
237    }
238    /// Revalidates a hierarchy after deserialization.
239    pub fn validate(&self) -> Result<()> {
240        Self::new(self.root.clone(), self.n_features).map(|_| ())
241    }
242    fn permutation_count(&self) -> Option<usize> {
243        fn rec(node: &PartitionNode) -> Option<usize> {
244            match node {
245                PartitionNode::Feature(_) => Some(1),
246                PartitionNode::Group(left, right) => {
247                    rec(left)?.checked_mul(rec(right)?)?.checked_mul(2)
248                }
249            }
250        }
251        rec(&self.root)
252    }
253    fn permutations(&self) -> Vec<Vec<usize>> {
254        fn rec(n: &PartitionNode) -> Vec<Vec<usize>> {
255            match n {
256                PartitionNode::Feature(j) => vec![vec![*j]],
257                PartitionNode::Group(a, b) => {
258                    let left = rec(a);
259                    let right = rec(b);
260                    let mut out = Vec::with_capacity(left.len() * right.len() * 2);
261                    for l in &left {
262                        for r in &right {
263                            let mut lr = l.clone();
264                            lr.extend(r);
265                            out.push(lr);
266                            let mut rl = r.clone();
267                            rl.extend(l);
268                            out.push(rl)
269                        }
270                    }
271                    out
272                }
273            }
274        }
275        rec(&self.root)
276    }
277
278    fn sampled_permutations(&self, samples: usize, seed: u64) -> Vec<Vec<usize>> {
279        fn sample(node: &PartitionNode, rng: &mut StdRng) -> Vec<usize> {
280            match node {
281                PartitionNode::Feature(feature) => vec![*feature],
282                PartitionNode::Group(left, right) => {
283                    let mut left = sample(left, rng);
284                    let mut right = sample(right, rng);
285                    if rng.gen_bool(0.5) {
286                        left.append(&mut right);
287                        left
288                    } else {
289                        right.append(&mut left);
290                        right
291                    }
292                }
293            }
294        }
295        let mut rng = StdRng::seed_from_u64(seed);
296        (0..samples).map(|_| sample(&self.root, &mut rng)).collect()
297    }
298}
299
300/// Exact hierarchical Owen values for a binary feature partition tree.
301pub struct HierarchicalPartitionExplainer<M, K = IndependentMasker> {
302    model: M,
303    masker: K,
304    tree: PartitionTree,
305    max_permutations: usize,
306    approximate_samples: Option<(usize, u64)>,
307    evaluation: EvaluationConfig,
308    link: Link,
309}
310impl<M> HierarchicalPartitionExplainer<M, IndependentMasker> {
311    pub fn new(model: M, background: Background, tree: PartitionTree) -> Self {
312        Self::from_masker(model, IndependentMasker::new(background), tree)
313    }
314}
315impl<M, K> HierarchicalPartitionExplainer<M, K> {
316    pub fn from_masker(model: M, masker: K, tree: PartitionTree) -> Self {
317        Self {
318            model,
319            masker,
320            tree,
321            max_permutations: 65536,
322            approximate_samples: None,
323            evaluation: EvaluationConfig {
324                coalition_batch_size: 64,
325                cache_capacity: 1 << 20,
326                max_model_rows: None,
327            },
328            link: Link::Identity,
329        }
330    }
331    pub fn with_max_permutations(mut self, n: usize) -> Self {
332        self.max_permutations = n;
333        self
334    }
335    /// Enables deterministic Monte Carlo hierarchy-consistent permutations
336    /// when exact enumeration exceeds `max_permutations`.
337    pub fn with_approximate_samples(mut self, samples: usize, seed: u64) -> Self {
338        self.approximate_samples = Some((samples, seed));
339        self
340    }
341    pub fn with_evaluation_config(mut self, c: EvaluationConfig) -> Self {
342        self.evaluation = c;
343        self
344    }
345    pub fn with_link(mut self, link: Link) -> Self {
346        self.link = link;
347        self
348    }
349}
350impl<M: Predict, K: Masker> Explainer for HierarchicalPartitionExplainer<M, K> {
351    fn explain(&self, x: ArrayView2<'_, f64>) -> Result<Explanation> {
352        let m = self.masker.n_features();
353        self.tree.validate()?;
354        if x.nrows() == 0 {
355            return Err(ShapError::EmptyData);
356        }
357        if x.ncols() != self.masker.n_input_features() || self.tree.n_features() != m {
358            return Err(ShapError::DimensionMismatch {
359                expected: format!(
360                    "{} input features and {m} features in hierarchy",
361                    self.masker.n_input_features()
362                ),
363                found: format!("data {}, hierarchy {}", x.ncols(), self.tree.n_features()),
364            });
365        }
366        if m >= 63 {
367            return Err(ShapError::InvalidConfiguration(
368                "hierarchical Owen values support at most 62 features".into(),
369            ));
370        }
371        let permutation_count = self.tree.permutation_count().ok_or_else(|| {
372            ShapError::InvalidConfiguration("hierarchy permutation count overflowed".into())
373        })?;
374        let permutations = if permutation_count > self.max_permutations {
375            let (samples, seed) = self.approximate_samples.ok_or_else(|| {
376                ShapError::InvalidConfiguration(format!(
377                    "hierarchy generates {} permutations, exceeding limit {}",
378                    permutation_count, self.max_permutations
379                ))
380            })?;
381            if samples == 0 {
382                return Err(ShapError::InvalidConfiguration(
383                    "approximate hierarchy samples must be positive".into(),
384                ));
385            }
386            self.tree.sampled_permutations(samples, seed)
387        } else {
388            self.tree.permutations()
389        };
390        let step_count = permutations.len().checked_mul(m).ok_or_else(|| {
391            ShapError::InvalidConfiguration("hierarchy step count overflowed".into())
392        })?;
393        crate::error::checked_f64_shape(&[step_count], "hierarchy permutation steps")?;
394        let mut probe = CoalitionEvaluator::new(&self.model, &self.masker, self.evaluation)?;
395        let o = probe.evaluate(x.row(0), &[0])?[0].len();
396        crate::error::checked_f64_shape(&[x.nrows(), m, o], "hierarchical explanation")?;
397        let mut values = Array3::zeros((x.nrows(), m, o));
398        let mut bases = Array2::zeros((x.nrows(), o));
399        for n in 0..x.nrows() {
400            let mut requested = vec![0u64];
401            let mut steps = Vec::with_capacity(step_count);
402            for order in &permutations {
403                let mut mask = 0;
404                let mut before = 0;
405                for &j in order {
406                    mask |= 1 << j;
407                    requested.push(mask);
408                    let after = requested.len() - 1;
409                    steps.push((j, before, after));
410                    before = after
411                }
412            }
413            let mut evaluator =
414                CoalitionEvaluator::new(&self.model, &self.masker, self.evaluation)?;
415            let evaluated = evaluator
416                .evaluate(x.row(n), &requested)?
417                .into_iter()
418                .map(|row| {
419                    row.into_iter()
420                        .map(|value| self.link.forward(value))
421                        .collect::<Result<Vec<_>>>()
422                })
423                .collect::<Result<Vec<_>>>()?;
424            for k in 0..o {
425                bases[[n, k]] = evaluated[0][k]
426            }
427            for (j, before, after) in steps {
428                for k in 0..o {
429                    values[[n, j, k]] +=
430                        (evaluated[after][k] - evaluated[before][k]) / permutations.len() as f64
431                }
432            }
433        }
434        Explanation::new(values, bases, self.masker.attribution_data(x)?)
435    }
436}
437
438/// Builds a binary hierarchy using average-linkage clustering on absolute
439/// Pearson-correlation distance (`1 - |r|`).
440pub fn correlation_partition(background: &Background) -> Result<PartitionTree> {
441    let m = background.n_features();
442    let data = background.data();
443    let means = data.mean_axis(ndarray::Axis(0)).unwrap();
444    let mut clusters = (0..m)
445        .map(|j| (vec![j], PartitionNode::Feature(j)))
446        .collect::<Vec<_>>();
447    let corr = |a: usize, b: usize| {
448        let mut xy = 0.;
449        let mut xx = 0.;
450        let mut yy = 0.;
451        for i in 0..data.nrows() {
452            let x = data[[i, a]] - means[a];
453            let y = data[[i, b]] - means[b];
454            xy += x * y;
455            xx += x * x;
456            yy += y * y
457        }
458        if xx == 0. || yy == 0. {
459            0.
460        } else {
461            (xy / (xx * yy).sqrt()).abs()
462        }
463    };
464    while clusters.len() > 1 {
465        let mut best = (0, 1, f64::INFINITY);
466        for i in 0..clusters.len() {
467            for j in i + 1..clusters.len() {
468                let mut distance = 0.0;
469                for &a in &clusters[i].0 {
470                    for &b in &clusters[j].0 {
471                        distance += 1.0 - corr(a, b)
472                    }
473                }
474                let d = distance / (clusters[i].0.len() * clusters[j].0.len()) as f64;
475                if d < best.2 {
476                    best = (i, j, d)
477                }
478            }
479        }
480        let (i, j, _) = best;
481        let (right_features, right) = clusters.remove(j);
482        let (left_features, left) = clusters.remove(i);
483        let mut features = left_features;
484        features.extend(right_features);
485        clusters.push((
486            features,
487            PartitionNode::Group(Box::new(left), Box::new(right)),
488        ))
489    }
490    PartitionTree::new(clusters.pop().unwrap().1, m)
491}
492
493#[cfg(test)]
494#[allow(clippy::items_after_test_module)]
495mod tests {
496    use super::*;
497    use crate::{metrics::check_additivity, FixedMasker, FnModel, GroupedMasker};
498    use ndarray::{array, ArrayView2, Axis};
499    #[test]
500    fn owen_values_respect_groups_and_local_accuracy() {
501        let model = FnModel::new(|x: ArrayView2<'_, f64>| {
502            Ok(x.map_axis(Axis(1), |r| r[0] * r[1] + r[2])
503                .insert_axis(Axis(1)))
504        });
505        let bg = Background::new(array![[0., 0., 0.]]).unwrap();
506        let partition = FeaturePartition::new(vec![vec![0, 1], vec![2]], 3).unwrap();
507        let e = PartitionExplainer::new(model, bg, partition)
508            .explain(array![[1., 1., 1.]].view())
509            .unwrap();
510        assert!((e.values()[[0, 0, 0]] - 0.5).abs() < 1e-12);
511        assert!((e.values()[[0, 1, 0]] - 0.5).abs() < 1e-12);
512        assert!((e.values()[[0, 2, 0]] - 1.).abs() < 1e-12);
513        check_additivity(&e, array![[2.]].view(), 1e-12).unwrap();
514    }
515    #[test]
516    fn partition_explainer_preserves_structured_source_groups() {
517        let model = FnModel::new(|x: ArrayView2<'_, f64>| {
518            Ok(x.map_axis(Axis(1), |row| row[0] * row[1] + row[2])
519                .insert_axis(Axis(1)))
520        });
521        let masker = GroupedMasker::new(
522            FixedMasker::new(array![0., 0., 0.]).unwrap(),
523            vec![vec![0, 1], vec![2]],
524        )
525        .unwrap();
526        let explanation = PartitionExplainer::from_masker(
527            model,
528            masker,
529            FeaturePartition::new(vec![vec![0], vec![1]], 2).unwrap(),
530        )
531        .explain(array![[2., 3., 4.]].view())
532        .unwrap();
533        assert_eq!(explanation.values(), &array![[[6.], [4.]]]);
534        assert_eq!(explanation.data(), array![[2.5, 4.]].view());
535    }
536    #[test]
537    fn hierarchical_owen_values_are_locally_accurate() {
538        let tree = PartitionTree::new(
539            PartitionNode::Group(
540                Box::new(PartitionNode::Group(
541                    Box::new(PartitionNode::Feature(0)),
542                    Box::new(PartitionNode::Feature(1)),
543                )),
544                Box::new(PartitionNode::Feature(2)),
545            ),
546            3,
547        )
548        .unwrap();
549        assert_eq!(tree.permutations().len(), 4);
550        let model = FnModel::new(|x: ArrayView2<'_, f64>| {
551            Ok(x.map_axis(Axis(1), |r| r[0] * r[2]).insert_axis(Axis(1)))
552        });
553        let bg = Background::new(array![[0., 0., 0.]]).unwrap();
554        let e = HierarchicalPartitionExplainer::new(model, bg, tree)
555            .explain(array![[1., 8., 1.]].view())
556            .unwrap();
557        assert!((e.values()[[0, 0, 0]] - 0.5).abs() < 1e-12);
558        assert!(e.values()[[0, 1, 0]].abs() < 1e-12);
559        assert!((e.values()[[0, 2, 0]] - 0.5).abs() < 1e-12);
560    }
561    #[test]
562    fn correlation_clustering_contains_every_feature() {
563        let bg = Background::new(array![[0., 0., 2.], [1., 1., 1.], [2., 2., 0.]]).unwrap();
564        let tree = correlation_partition(&bg).unwrap();
565        assert_eq!(tree.n_features(), 3);
566        assert_eq!(tree.permutations().len(), 4);
567    }
568    #[test]
569    fn rejects_invalid_deserialized_style_partitions_before_evaluation() {
570        let invalid = FeaturePartition {
571            groups: vec![vec![0, 0]],
572            n_features: 2,
573        };
574        let model =
575            FnModel::new(|x: ArrayView2<'_, f64>| Ok(x.sum_axis(Axis(1)).insert_axis(Axis(1))));
576        let result =
577            PartitionExplainer::new(model, Background::new(array![[0., 0.]]).unwrap(), invalid)
578                .explain(array![[1., 1.]].view());
579        assert!(matches!(result, Err(ShapError::InvalidConfiguration(_))));
580    }
581    #[test]
582    fn checks_hierarchy_permutation_limit_before_generation() {
583        fn hierarchy(features: std::ops::Range<usize>) -> PartitionNode {
584            let mut nodes = features.map(PartitionNode::Feature).collect::<Vec<_>>();
585            while nodes.len() > 1 {
586                let right = nodes.pop().unwrap();
587                let left = nodes.pop().unwrap();
588                nodes.push(PartitionNode::Group(Box::new(left), Box::new(right)));
589            }
590            nodes.pop().unwrap()
591        }
592        let tree = PartitionTree::new(hierarchy(0..18), 18).unwrap();
593        assert_eq!(tree.permutation_count(), Some(1 << 17));
594        let model =
595            FnModel::new(|x: ArrayView2<'_, f64>| Ok(x.sum_axis(Axis(1)).insert_axis(Axis(1))));
596        let result = HierarchicalPartitionExplainer::new(
597            model,
598            Background::new(Array2::zeros((1, 18))).unwrap(),
599            tree.clone(),
600        )
601        .with_max_permutations(16)
602        .explain(Array2::ones((1, 18)).view());
603        assert!(matches!(result, Err(ShapError::InvalidConfiguration(_))));
604        let approximate = HierarchicalPartitionExplainer::new(
605            FnModel::new(|x: ArrayView2<'_, f64>| Ok(x.sum_axis(Axis(1)).insert_axis(Axis(1)))),
606            Background::new(Array2::zeros((1, 18))).unwrap(),
607            tree,
608        )
609        .with_max_permutations(16)
610        .with_approximate_samples(32, 7)
611        .explain(Array2::ones((1, 18)).view())
612        .unwrap();
613        assert!(approximate
614            .values()
615            .iter()
616            .all(|value| (*value - 1.0).abs() < 1e-12));
617    }
618    #[test]
619    fn binary_hierarchy_matches_flat_owen_values_for_two_groups() {
620        fn predict(x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
621            Ok(Array2::from_shape_fn((x.nrows(), 2), |(i, output)| {
622                let r = x.row(i);
623                if output == 0 {
624                    r[0] * r[2] + r[1].sin() + r[3]
625                } else {
626                    (r[0] + r[1]) * (r[2] - r[3])
627                }
628            }))
629        }
630        let background = Background::new(array![
631            [0., 0., 0., 0.],
632            [1., -1., 0.5, 2.],
633            [-0.5, 2., 1., -1.]
634        ])
635        .unwrap();
636        let sample = array![[2., 0.25, -1., 3.]];
637        let flat = PartitionExplainer::new(
638            FnModel::new(predict),
639            background.clone(),
640            FeaturePartition::new(vec![vec![0, 1], vec![2, 3]], 4).unwrap(),
641        )
642        .explain(sample.view())
643        .unwrap();
644        let hierarchy = PartitionTree::new(
645            PartitionNode::Group(
646                Box::new(PartitionNode::Group(
647                    Box::new(PartitionNode::Feature(0)),
648                    Box::new(PartitionNode::Feature(1)),
649                )),
650                Box::new(PartitionNode::Group(
651                    Box::new(PartitionNode::Feature(2)),
652                    Box::new(PartitionNode::Feature(3)),
653                )),
654            ),
655            4,
656        )
657        .unwrap();
658        let nested =
659            HierarchicalPartitionExplainer::new(FnModel::new(predict), background, hierarchy)
660                .explain(sample.view())
661                .unwrap();
662        for (actual, expected) in nested.values().iter().zip(flat.values()) {
663            assert!((actual - expected).abs() < 1e-12);
664        }
665        assert_eq!(nested.base_values(), flat.base_values());
666    }
667    #[test]
668    fn partition_logit_link_explains_log_odds() {
669        let model =
670            FnModel::new(|x: ArrayView2<'_, f64>| Ok(x.column(0).to_owned().insert_axis(Axis(1))));
671        let explanation = PartitionExplainer::new(
672            model,
673            Background::new(array![[0.5]]).unwrap(),
674            FeaturePartition::new(vec![vec![0]], 1).unwrap(),
675        )
676        .with_link(Link::Logit)
677        .explain(array![[0.8]].view())
678        .unwrap();
679        assert!((explanation.reconstructed()[[0, 0]] - 4f64.ln()).abs() < 1e-12);
680    }
681}
682impl FeaturePartition {
683    pub fn new(groups: Vec<Vec<usize>>, n_features: usize) -> crate::Result<Self> {
684        if n_features == 0 {
685            return Err(crate::ShapError::InvalidConfiguration(
686                "partition must contain at least one feature".into(),
687            ));
688        }
689        let mut seen = vec![false; n_features];
690        for g in &groups {
691            if g.is_empty() {
692                return Err(crate::ShapError::InvalidConfiguration(
693                    "partition groups cannot be empty".into(),
694                ));
695            }
696            for &j in g {
697                if j >= n_features || seen[j] {
698                    return Err(crate::ShapError::InvalidConfiguration(
699                        "partition must contain every feature exactly once".into(),
700                    ));
701                }
702                seen[j] = true
703            }
704        }
705        if seen.iter().any(|x| !*x) {
706            return Err(crate::ShapError::InvalidConfiguration(
707                "partition must contain every feature exactly once".into(),
708            ));
709        }
710        Ok(Self { groups, n_features })
711    }
712    /// Revalidates a partition after deserialization.
713    pub fn validate(&self) -> crate::Result<()> {
714        Self::new(self.groups.clone(), self.n_features).map(|_| ())
715    }
716    pub fn groups(&self) -> &[Vec<usize>] {
717        &self.groups
718    }
719    pub fn n_features(&self) -> usize {
720        self.n_features
721    }
722}