contree/common/loss.rs
1//! The cost of a leaf, and whether threshold skipping may assume it behaves
2//! like misclassification.
3
4use crate::common::{classification_error, Cost};
5
6/// What a custom loss receives for a leaf.
7#[derive(Copy, Clone, Debug, PartialEq, Eq)]
8pub enum LossInput {
9 /// The number of instances of each class in the leaf.
10 ClassCounts,
11 /// The ids of the leaf's instances (their row in the training data).
12 Indices,
13}
14
15/// A leaf, as a custom loss sees it.
16#[derive(Copy, Clone, Debug)]
17pub enum Leaf<'a> {
18 /// The number of instances of each class.
19 ClassCounts(&'a [usize]),
20 /// The ids of the instances.
21 Indices(&'a [usize]),
22}
23
24/// A caller's leaf loss: `(cost, class predicted)`. The cost of a tree is the
25/// sum of the costs of its leaves, and a cost must be non-negative.
26pub trait LeafLoss: Send + Sync {
27 fn cost(&self, leaf: Leaf<'_>) -> (Cost, usize);
28}
29
30/// The loss a search minimises.
31#[derive(Default)]
32pub enum Loss {
33 /// Misclassification: the number of instances not of the majority
34 /// class.
35 #[default]
36 Misclassification,
37 /// A caller's loss, with the input it asks for.
38 Custom {
39 function: Box<dyn LeafLoss>,
40 input: LossInput,
41 },
42}
43
44impl Loss {
45 /// A caller's loss.
46 pub fn custom(function: Box<dyn LeafLoss>, input: LossInput) -> Self {
47 Loss::Custom { function, input }
48 }
49
50 /// Whether this is the built-in misclassification.
51 pub fn is_misclassification(&self) -> bool {
52 matches!(self, Loss::Misclassification)
53 }
54
55 /// Whether the loss reads instance ids rather than class counts.
56 pub fn uses_indices(&self) -> bool {
57 matches!(
58 self,
59 Loss::Custom {
60 input: LossInput::Indices,
61 ..
62 }
63 )
64 }
65
66 /// The cost of a leaf with these class counts and instance ids.
67 /// `indices` is only called for a loss that reads them.
68 pub fn leaf(
69 &self,
70 class_counts: &[usize],
71 indices: impl FnOnce() -> Vec<usize>,
72 ) -> (Cost, usize) {
73 match self {
74 Loss::Misclassification => classification_error(class_counts),
75 Loss::Custom {
76 function,
77 input: LossInput::ClassCounts,
78 } => function.cost(Leaf::ClassCounts(class_counts)),
79 Loss::Custom {
80 function,
81 input: LossInput::Indices,
82 } => function.cost(Leaf::Indices(&indices())),
83 }
84 }
85}
86
87/// Whether the search skips thresholds: the interval pruner, and the skipping
88/// and early stops of the depth-2 solver's scan.
89///
90/// Skipping assumes what misclassification guarantees: moving `n` instances
91/// into a leaf never lowers its cost and raises it by at most `n`, and
92/// removing them never raises it and lowers it by at most `n`. A custom loss
93/// may not, so `Auto` turns skipping off for one. `On` forces it, and the
94/// depth-2 solver then counts the moves that break the assumption.
95///
96/// With a minimum support above 1 the interval pruner stays off whatever the
97/// setting, since its rules do not hold there for any loss; `On` then forces
98/// only the depth-2 solver's skipping, whose argument does not involve the
99/// support.
100#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
101pub enum Skipping {
102 /// On for misclassification, off for a custom loss.
103 #[default]
104 Auto,
105 /// On whatever the loss.
106 On,
107 /// Off.
108 Off,
109}
110
111impl Skipping {
112 /// Whether thresholds are skipped for this loss.
113 ///
114 /// The interval pruner adds its own condition, a minimum support of 1;
115 /// see `IntervalsPruner::is_sound`.
116 pub fn applies(self, loss: &Loss) -> bool {
117 match self {
118 Skipping::Auto => loss.is_misclassification(),
119 Skipping::On => true,
120 Skipping::Off => false,
121 }
122 }
123}