Skip to main content

shap_rs/tree/
model.rs

1use crate::{Predict, Result, ShapError};
2use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5pub enum MissingBranch {
6    Left,
7    Right,
8}
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11pub enum SplitComparison {
12    LessThan,
13    LessThanOrEqual,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub enum MissingValuePolicy {
18    NaN,
19    Zero,
20    None,
21}
22
23/// A node in a binary regression tree. `cover` is the training weight reaching
24/// the node and is used to integrate out features that are not observed.
25#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
26pub enum Node {
27    Leaf {
28        values: Vec<f64>,
29        cover: f64,
30    },
31    Split {
32        feature: usize,
33        threshold: f64,
34        left: usize,
35        right: usize,
36        missing: MissingBranch,
37        cover: f64,
38    },
39    NumericalSplit {
40        feature: usize,
41        threshold: f64,
42        comparison: SplitComparison,
43        left: usize,
44        right: usize,
45        missing: MissingBranch,
46        missing_value: MissingValuePolicy,
47        cover: f64,
48    },
49    CategoricalSplit {
50        feature: usize,
51        categories: Vec<i64>,
52        left: usize,
53        right: usize,
54        missing: MissingBranch,
55        missing_value: MissingValuePolicy,
56        cover: f64,
57    },
58}
59/// Framework-neutral columnar representation used by model adapters.
60#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
61pub struct TreeArrays {
62    pub features: Vec<Option<usize>>,
63    pub thresholds: Vec<f64>,
64    pub left_children: Vec<Option<usize>>,
65    pub right_children: Vec<Option<usize>>,
66    pub missing: Vec<MissingBranch>,
67    pub leaf_values: Vec<Option<Vec<f64>>>,
68    pub covers: Vec<f64>,
69    pub root: usize,
70    pub n_features: usize,
71}
72impl Node {
73    pub fn cover(&self) -> f64 {
74        match self {
75            Self::Leaf { cover, .. }
76            | Self::Split { cover, .. }
77            | Self::NumericalSplit { cover, .. }
78            | Self::CategoricalSplit { cover, .. } => *cover,
79        }
80    }
81
82    pub fn split_feature(&self) -> Option<usize> {
83        match self {
84            Self::Leaf { .. } => None,
85            Self::Split { feature, .. }
86            | Self::NumericalSplit { feature, .. }
87            | Self::CategoricalSplit { feature, .. } => Some(*feature),
88        }
89    }
90
91    pub fn children(&self) -> Option<(usize, usize)> {
92        match self {
93            Self::Leaf { .. } => None,
94            Self::Split { left, right, .. }
95            | Self::NumericalSplit { left, right, .. }
96            | Self::CategoricalSplit { left, right, .. } => Some((*left, *right)),
97        }
98    }
99
100    pub(crate) fn hot_child(&self, value: f64) -> Option<usize> {
101        fn is_missing(value: f64, policy: MissingValuePolicy) -> bool {
102            match policy {
103                MissingValuePolicy::NaN => value.is_nan(),
104                MissingValuePolicy::Zero => value.is_nan() || value == 0.0,
105                MissingValuePolicy::None => false,
106            }
107        }
108        fn missing_child(branch: MissingBranch, left: usize, right: usize) -> usize {
109            match branch {
110                MissingBranch::Left => left,
111                MissingBranch::Right => right,
112            }
113        }
114        match self {
115            Self::Leaf { .. } => None,
116            Self::Split {
117                threshold,
118                left,
119                right,
120                missing,
121                ..
122            } => Some(if value.is_nan() {
123                missing_child(*missing, *left, *right)
124            } else if value <= *threshold {
125                *left
126            } else {
127                *right
128            }),
129            Self::NumericalSplit {
130                threshold,
131                comparison,
132                left,
133                right,
134                missing,
135                missing_value,
136                ..
137            } => Some(if is_missing(value, *missing_value) {
138                missing_child(*missing, *left, *right)
139            } else if match comparison {
140                SplitComparison::LessThan => value < *threshold,
141                SplitComparison::LessThanOrEqual => value <= *threshold,
142            } {
143                *left
144            } else {
145                *right
146            }),
147            Self::CategoricalSplit {
148                categories,
149                left,
150                right,
151                missing,
152                missing_value,
153                ..
154            } => Some(if is_missing(value, *missing_value) {
155                missing_child(*missing, *left, *right)
156            } else if value.is_finite()
157                && value.fract() == 0.0
158                && value >= i64::MIN as f64
159                && value <= i64::MAX as f64
160                && categories.contains(&(value as i64))
161            {
162                *left
163            } else {
164                *right
165            }),
166        }
167    }
168}
169
170#[derive(Debug, Clone, serde::Serialize)]
171pub struct Tree {
172    nodes: Vec<Node>,
173    root: usize,
174    n_features: usize,
175    n_outputs: usize,
176}
177#[derive(serde::Deserialize)]
178struct TreePayload {
179    nodes: Vec<Node>,
180    root: usize,
181    n_features: usize,
182    n_outputs: usize,
183}
184impl<'de> serde::Deserialize<'de> for Tree {
185    fn deserialize<D: serde::Deserializer<'de>>(
186        deserializer: D,
187    ) -> std::result::Result<Self, D::Error> {
188        let payload = <TreePayload as serde::Deserialize>::deserialize(deserializer)?;
189        let tree = Self::new(payload.nodes, payload.root, payload.n_features)
190            .map_err(serde::de::Error::custom)?;
191        if tree.n_outputs() != payload.n_outputs {
192            return Err(serde::de::Error::custom(
193                "serialized tree output count does not match its leaves",
194            ));
195        }
196        Ok(tree)
197    }
198}
199impl Tree {
200    /// Revalidates a tree after deserialization or adapter conversion.
201    pub fn validate(&self) -> Result<()> {
202        Self::new(self.nodes.clone(), self.root, self.n_features).map(|_| ())
203    }
204    pub fn from_arrays(a: TreeArrays) -> Result<Self> {
205        let n = a.features.len();
206        if [
207            a.thresholds.len(),
208            a.left_children.len(),
209            a.right_children.len(),
210            a.missing.len(),
211            a.leaf_values.len(),
212            a.covers.len(),
213        ]
214        .iter()
215        .any(|&x| x != n)
216        {
217            return Err(ShapError::DimensionMismatch {
218                expected: format!("{n} entries in every tree array"),
219                found: "inconsistent tree array lengths".into(),
220            });
221        }
222        let mut nodes = Vec::with_capacity(n);
223        for i in 0..n {
224            match (&a.features[i], &a.leaf_values[i]) {
225                (None, Some(values)) => nodes.push(Node::Leaf {
226                    values: values.clone(),
227                    cover: a.covers[i],
228                }),
229                (Some(feature), None) => nodes.push(Node::Split {
230                    feature: *feature,
231                    threshold: a.thresholds[i],
232                    left: a.left_children[i].ok_or_else(|| {
233                        ShapError::InvalidConfiguration(format!("split {i} has no left child"))
234                    })?,
235                    right: a.right_children[i].ok_or_else(|| {
236                        ShapError::InvalidConfiguration(format!("split {i} has no right child"))
237                    })?,
238                    missing: a.missing[i],
239                    cover: a.covers[i],
240                }),
241                _ => {
242                    return Err(ShapError::InvalidConfiguration(format!(
243                        "node {i} must be exactly one of leaf or split"
244                    )))
245                }
246            }
247        }
248        Self::new(nodes, a.root, a.n_features)
249    }
250    pub fn new(nodes: Vec<Node>, root: usize, n_features: usize) -> Result<Self> {
251        if nodes.is_empty() || root >= nodes.len() {
252            return Err(ShapError::InvalidConfiguration(
253                "tree must have a valid root".into(),
254            ));
255        }
256        if n_features == 0 {
257            return Err(ShapError::InvalidConfiguration(
258                "tree must have at least one feature".into(),
259            ));
260        }
261        let mut outputs = None;
262        for (i, node) in nodes.iter().enumerate() {
263            if !node.cover().is_finite() || node.cover() < 0.0 {
264                return Err(ShapError::InvalidConfiguration(format!(
265                    "node {i} has invalid cover"
266                )));
267            }
268            match node {
269                Node::Leaf { values, .. } => {
270                    if values.is_empty() || values.iter().any(|v| !v.is_finite()) {
271                        return Err(ShapError::InvalidConfiguration(format!(
272                            "leaf {i} has invalid values"
273                        )));
274                    }
275                    if outputs
276                        .replace(values.len())
277                        .is_some_and(|n| n != values.len())
278                    {
279                        return Err(ShapError::InvalidConfiguration(
280                            "all leaves must have the same output count".into(),
281                        ));
282                    }
283                }
284                Node::Split {
285                    feature,
286                    left,
287                    right,
288                    threshold,
289                    ..
290                } => {
291                    if *feature >= n_features
292                        || *left >= nodes.len()
293                        || *right >= nodes.len()
294                        || !threshold.is_finite()
295                    {
296                        return Err(ShapError::InvalidConfiguration(format!(
297                            "split node {i} is invalid"
298                        )));
299                    }
300                }
301                Node::NumericalSplit {
302                    feature,
303                    left,
304                    right,
305                    threshold,
306                    ..
307                } => {
308                    if *feature >= n_features
309                        || *left >= nodes.len()
310                        || *right >= nodes.len()
311                        || !threshold.is_finite()
312                    {
313                        return Err(ShapError::InvalidConfiguration(format!(
314                            "numerical split node {i} is invalid"
315                        )));
316                    }
317                }
318                Node::CategoricalSplit {
319                    feature,
320                    categories,
321                    left,
322                    right,
323                    ..
324                } => {
325                    if *feature >= n_features
326                        || *left >= nodes.len()
327                        || *right >= nodes.len()
328                        || categories.is_empty()
329                    {
330                        return Err(ShapError::InvalidConfiguration(format!(
331                            "categorical split node {i} is invalid"
332                        )));
333                    }
334                }
335            }
336        }
337        let n_outputs =
338            outputs.ok_or_else(|| ShapError::InvalidConfiguration("tree has no leaves".into()))?;
339        let tree = Self {
340            nodes,
341            root,
342            n_features,
343            n_outputs,
344        };
345        tree.validate_graph()?;
346        Ok(tree)
347    }
348    fn validate_graph(&self) -> Result<()> {
349        fn visit(t: &Tree, i: usize, state: &mut [u8]) -> Result<()> {
350            if state[i] == 1 {
351                return Err(ShapError::InvalidConfiguration(
352                    "tree contains a cycle".into(),
353                ));
354            }
355            if state[i] == 2 {
356                return Err(ShapError::InvalidConfiguration(
357                    "tree node has multiple parents".into(),
358                ));
359            }
360            state[i] = 1;
361            if let Some((left, right)) = t.nodes[i].children() {
362                visit(t, left, state)?;
363                visit(t, right, state)?;
364            }
365            state[i] = 2;
366            Ok(())
367        }
368        let mut state = vec![0; self.nodes.len()];
369        visit(self, self.root, &mut state)?;
370        if state.contains(&0) {
371            return Err(ShapError::InvalidConfiguration(
372                "tree contains nodes unreachable from the root".into(),
373            ));
374        }
375        Ok(())
376    }
377    pub fn nodes(&self) -> &[Node] {
378        &self.nodes
379    }
380    pub fn root(&self) -> usize {
381        self.root
382    }
383    pub fn n_features(&self) -> usize {
384        self.n_features
385    }
386    pub fn n_outputs(&self) -> usize {
387        self.n_outputs
388    }
389    pub fn predict_row(&self, x: ArrayView1<'_, f64>) -> Result<&[f64]> {
390        if x.len() != self.n_features {
391            return Err(ShapError::DimensionMismatch {
392                expected: format!("{} features", self.n_features),
393                found: format!("{}", x.len()),
394            });
395        }
396        let mut i = self.root;
397        loop {
398            match &self.nodes[i] {
399                Node::Leaf { values, .. } => return Ok(values),
400                node => i = node.hot_child(x[node.split_feature().unwrap()]).unwrap(),
401            }
402        }
403    }
404    pub fn expected_value(&self) -> Vec<f64> {
405        fn rec(t: &Tree, i: usize) -> Vec<f64> {
406            match &t.nodes[i] {
407                Node::Leaf { values, .. } => values.clone(),
408                node => {
409                    let (left, right) = node.children().unwrap();
410                    let a = rec(t, left);
411                    let b = rec(t, right);
412                    let total = t.nodes[left].cover() + t.nodes[right].cover();
413                    let p = if total > 0.0 {
414                        t.nodes[left].cover() / total
415                    } else {
416                        0.5
417                    };
418                    a.into_iter()
419                        .zip(b)
420                        .map(|(x, y)| p * x + (1.0 - p) * y)
421                        .collect()
422                }
423            }
424        }
425        rec(self, self.root)
426    }
427    #[cfg(test)]
428    pub(crate) fn conditional_value(&self, x: ArrayView1<'_, f64>, present: &[bool]) -> Vec<f64> {
429        fn rec(t: &Tree, i: usize, x: ArrayView1<'_, f64>, p: &[bool]) -> Vec<f64> {
430            match &t.nodes[i] {
431                Node::Leaf { values, .. } => values.clone(),
432                node => {
433                    let feature = node.split_feature().unwrap();
434                    let (left, right) = node.children().unwrap();
435                    if p[feature] {
436                        let c = node.hot_child(x[feature]).unwrap();
437                        rec(t, c, x, p)
438                    } else {
439                        let a = rec(t, left, x, p);
440                        let b = rec(t, right, x, p);
441                        let total = t.nodes[left].cover() + t.nodes[right].cover();
442                        let q = if total > 0.0 {
443                            t.nodes[left].cover() / total
444                        } else {
445                            0.5
446                        };
447                        a.into_iter()
448                            .zip(b)
449                            .map(|(u, v)| q * u + (1.0 - q) * v)
450                            .collect()
451                    }
452                }
453            }
454        }
455        rec(self, self.root, x, present)
456    }
457}
458
459#[derive(Debug, Clone, serde::Serialize)]
460pub struct TreeEnsemble {
461    trees: Vec<(Tree, f64)>,
462    output_groups: Vec<Option<usize>>,
463    base_values: Array1<f64>,
464    n_features: usize,
465}
466#[derive(serde::Deserialize)]
467struct TreeEnsemblePayload {
468    trees: Vec<(Tree, f64)>,
469    #[serde(default)]
470    output_groups: Vec<Option<usize>>,
471    base_values: Array1<f64>,
472    n_features: usize,
473}
474impl<'de> serde::Deserialize<'de> for TreeEnsemble {
475    fn deserialize<D: serde::Deserializer<'de>>(
476        deserializer: D,
477    ) -> std::result::Result<Self, D::Error> {
478        let payload = <TreeEnsemblePayload as serde::Deserialize>::deserialize(deserializer)?;
479        let ensemble = if payload.output_groups.is_empty() {
480            Self::new(payload.trees, payload.base_values.to_vec())
481        } else {
482            Self::new_with_output_groups(
483                payload.trees,
484                payload.base_values.to_vec(),
485                payload.output_groups,
486            )
487        }
488        .map_err(serde::de::Error::custom)?;
489        if ensemble.n_features() != payload.n_features {
490            return Err(serde::de::Error::custom(
491                "serialized ensemble feature count does not match its trees",
492            ));
493        }
494        Ok(ensemble)
495    }
496}
497impl TreeEnsemble {
498    pub fn new(trees: Vec<(Tree, f64)>, base_values: Vec<f64>) -> Result<Self> {
499        let groups = vec![None; trees.len()];
500        Self::new_with_output_groups(trees, base_values, groups)
501    }
502
503    pub fn new_with_output_groups(
504        trees: Vec<(Tree, f64)>,
505        base_values: Vec<f64>,
506        output_groups: Vec<Option<usize>>,
507    ) -> Result<Self> {
508        if trees.is_empty() {
509            return Err(ShapError::InvalidConfiguration(
510                "ensemble must contain a tree".into(),
511            ));
512        }
513        let nf = trees[0].0.n_features();
514        let no = trees[0].0.n_outputs();
515        if base_values.len() != no
516            || output_groups.len() != trees.len()
517            || output_groups.iter().flatten().any(|&group| group >= no)
518            || base_values.iter().any(|v| !v.is_finite())
519            || trees
520                .iter()
521                .any(|(t, w)| t.n_features() != nf || t.n_outputs() != no || !w.is_finite())
522        {
523            return Err(ShapError::DimensionMismatch {
524                expected: format!("trees with {nf} features and {no} outputs"),
525                found: "inconsistent ensemble".into(),
526            });
527        }
528        Ok(Self {
529            trees,
530            output_groups,
531            base_values: Array1::from(base_values),
532            n_features: nf,
533        })
534    }
535    pub fn trees(&self) -> &[(Tree, f64)] {
536        &self.trees
537    }
538    pub fn output_groups(&self) -> &[Option<usize>] {
539        &self.output_groups
540    }
541    pub fn base_offset(&self) -> ndarray::ArrayView1<'_, f64> {
542        self.base_values.view()
543    }
544    pub fn n_features(&self) -> usize {
545        self.n_features
546    }
547    pub fn n_outputs(&self) -> usize {
548        self.base_values.len()
549    }
550    pub fn expected_value(&self) -> Array1<f64> {
551        let mut v = self.base_values.clone();
552        for (t, w) in &self.trees {
553            for (o, x) in t.expected_value().into_iter().enumerate() {
554                v[o] += w * x
555            }
556        }
557        v
558    }
559    /// Predicts raw tree outputs with a per-sample base margin replacing the
560    /// ensemble's fixed base offset (matching XGBoost `base_margin` semantics).
561    pub fn predict_with_base_margin(
562        &self,
563        x: ArrayView2<'_, f64>,
564        base_margin: ArrayView2<'_, f64>,
565    ) -> Result<Array2<f64>> {
566        if base_margin.dim() != (x.nrows(), self.n_outputs()) {
567            return Err(ShapError::DimensionMismatch {
568                expected: format!("({}, {}) base margins", x.nrows(), self.n_outputs()),
569                found: format!("{:?}", base_margin.dim()),
570            });
571        }
572        if base_margin.iter().any(|value| !value.is_finite()) {
573            return Err(ShapError::InvalidConfiguration(
574                "base margins must be finite".into(),
575            ));
576        }
577        let mut prediction = self.predict(x)?;
578        for row in 0..prediction.nrows() {
579            for output in 0..prediction.ncols() {
580                prediction[[row, output]] += base_margin[[row, output]] - self.base_values[output];
581            }
582        }
583        Ok(prediction)
584    }
585    /// Revalidates all trees and ensemble dimensions after deserialization.
586    pub fn validate(&self) -> Result<()> {
587        for (t, w) in &self.trees {
588            t.validate()?;
589            if !w.is_finite() {
590                return Err(ShapError::InvalidConfiguration(
591                    "tree weight must be finite".into(),
592                ));
593            }
594        }
595        Self::new_with_output_groups(
596            self.trees.clone(),
597            self.base_values.to_vec(),
598            self.output_groups.clone(),
599        )
600        .map(|_| ())
601    }
602}
603
604impl Predict for TreeEnsemble {
605    fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
606        self.validate()?;
607        if x.ncols() != self.n_features {
608            return Err(ShapError::DimensionMismatch {
609                expected: format!("{} features", self.n_features),
610                found: format!("{}", x.ncols()),
611            });
612        }
613        crate::error::checked_f64_shape(&[x.nrows(), self.base_values.len()], "tree prediction")?;
614        let mut out = Array2::from_shape_fn((x.nrows(), self.base_values.len()), |(_, o)| {
615            self.base_values[o]
616        });
617        for i in 0..x.nrows() {
618            for (t, w) in &self.trees {
619                for (o, v) in t.predict_row(x.row(i))?.iter().enumerate() {
620                    out[[i, o]] += w * v
621                }
622            }
623        }
624        Ok(out)
625    }
626    fn n_features(&self) -> Option<usize> {
627        Some(self.n_features)
628    }
629    fn n_outputs(&self) -> Option<usize> {
630        Some(self.base_values.len())
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    #[test]
639    fn rejects_unreachable_and_shared_nodes() {
640        let leaf = || Node::Leaf {
641            values: vec![0.],
642            cover: 1.,
643        };
644        let unreachable = Tree::new(
645            vec![
646                Node::Split {
647                    feature: 0,
648                    threshold: 0.,
649                    left: 1,
650                    right: 2,
651                    missing: MissingBranch::Left,
652                    cover: 2.,
653                },
654                leaf(),
655                leaf(),
656                leaf(),
657            ],
658            0,
659            1,
660        );
661        assert!(unreachable.is_err());
662
663        let shared = Tree::new(
664            vec![
665                Node::Split {
666                    feature: 0,
667                    threshold: 0.,
668                    left: 1,
669                    right: 1,
670                    missing: MissingBranch::Left,
671                    cover: 2.,
672                },
673                leaf(),
674            ],
675            0,
676            1,
677        );
678        assert!(shared.is_err());
679    }
680
681    #[test]
682    fn rejects_non_finite_ensemble_base_values() {
683        let tree = Tree::new(
684            vec![Node::Leaf {
685                values: vec![1.],
686                cover: 1.,
687            }],
688            0,
689            1,
690        )
691        .unwrap();
692        assert!(TreeEnsemble::new(vec![(tree, 1.)], vec![f64::NAN]).is_err());
693    }
694}