1use ndarray::{Array1, ArrayView1, ArrayView2};
13use rand::SeedableRng;
14use rand_chacha::ChaCha8Rng;
15
16use crate::error::{DmlError, Result};
17use crate::learner::{MaxFeatures, Node, Tree, TreeBuilder, TreeParams};
18
19#[derive(Debug, Clone)]
21pub struct PolicyTree {
22 depth: usize,
23 min_samples_leaf: usize,
24 ccp_alpha: f64,
25 tree: Tree,
26 n_features: usize,
27}
28
29#[derive(Debug, Clone, Copy, Default)]
31struct NodeStats {
32 weight: f64,
33 weight1: f64,
34}
35
36impl NodeStats {
37 fn share1(&self) -> f64 {
38 self.weight1 / self.weight
39 }
40
41 fn gini(&self) -> f64 {
42 let p1 = self.share1();
43 1.0 - p1 * p1 - (1.0 - p1) * (1.0 - p1)
44 }
45}
46
47impl PolicyTree {
48 pub fn fit(
52 signal: ArrayView1<'_, f64>,
53 features: ArrayView2<'_, f64>,
54 depth: usize,
55 min_samples_leaf: usize,
56 ccp_alpha: f64,
57 ) -> Result<Self> {
58 let (n, p) = features.dim();
59 if signal.len() != n {
60 return Err(DmlError::DimensionMismatch(format!(
61 "signal has {} values, features {n} rows",
62 signal.len()
63 )));
64 }
65 if p == 0 || min_samples_leaf == 0 || ccp_alpha.is_nan() || ccp_alpha < 0.0 {
66 return Err(DmlError::InvalidArgument(
67 "need at least one feature, min_samples_leaf >= 1 and ccp_alpha >= 0".into(),
68 ));
69 }
70 let labels: Vec<f64> = signal
71 .iter()
72 .map(|&s| (s.signum_or_zero() + 1.0) / 2.0)
73 .collect();
74 let weights: Vec<f64> = signal.iter().map(|s| s.abs()).collect();
75 let rows: Vec<usize> = (0..n).filter(|&i| weights[i] > 0.0).collect();
77 if rows.is_empty() {
78 return Err(DmlError::InvalidArgument(
79 "the signal is zero everywhere".into(),
80 ));
81 }
82 let (grown, _) = TreeBuilder {
83 x: features,
84 y: &labels,
85 w: &weights,
86 params: TreeParams {
87 max_depth: Some(depth),
88 min_samples_split: 2,
89 min_samples_leaf,
90 max_features: MaxFeatures::All,
91 },
92 n_try: p,
93 rng: ChaCha8Rng::seed_from_u64(0),
94 importance: vec![0.0; p],
95 }
96 .grow(rows.clone());
97
98 let mut stats = vec![NodeStats::default(); grown.nodes.len()];
100 for &i in &rows {
101 let mut node = 0usize;
102 loop {
103 stats[node].weight += weights[i];
104 stats[node].weight1 += weights[i] * labels[i];
105 match grown.nodes[node] {
106 Node::Leaf { .. } => break,
107 Node::Split {
108 feature,
109 threshold,
110 left,
111 right,
112 } => {
113 node = if features[[i, feature]] <= threshold {
114 left
115 } else {
116 right
117 } as usize
118 }
119 }
120 }
121 }
122 let tree = prune(&grown, &stats, ccp_alpha);
123 Ok(Self {
124 depth,
125 min_samples_leaf,
126 ccp_alpha,
127 tree,
128 n_features: p,
129 })
130 }
131
132 pub fn depth(&self) -> usize {
133 self.depth
134 }
135
136 pub fn min_samples_leaf(&self) -> usize {
137 self.min_samples_leaf
138 }
139
140 pub fn ccp_alpha(&self) -> f64 {
141 self.ccp_alpha
142 }
143
144 pub fn n_leaves(&self) -> usize {
146 self.tree
147 .nodes
148 .iter()
149 .filter(|n| matches!(n, Node::Leaf { .. }))
150 .count()
151 }
152
153 pub fn predict(&self, features: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
157 if features.ncols() != self.n_features {
158 return Err(DmlError::DimensionMismatch(format!(
159 "the tree was fitted on {} features, got {}",
160 self.n_features,
161 features.ncols()
162 )));
163 }
164 Ok(features
165 .rows()
166 .into_iter()
167 .map(|row| f64::from(self.leaf_share(row) > 0.5))
168 .collect())
169 }
170
171 fn leaf_share(&self, row: ArrayView1<'_, f64>) -> f64 {
172 match self.tree.nodes[self.tree.leaf_index(row)] {
173 Node::Leaf { value } => value,
174 Node::Split { .. } => unreachable!("leaf_index returns a leaf"),
175 }
176 }
177
178 pub fn export_text(&self, feature_names: Option<&[String]>) -> String {
181 let name = |j: usize| match feature_names {
182 Some(names) if j < names.len() => names[j].clone(),
183 _ => format!("x{j}"),
184 };
185 let mut lines: Vec<(usize, String)> = Vec::new();
186 self.write_rules(0, 0, &name, &mut lines);
187 lines
188 .into_iter()
189 .map(|(depth, text)| format!("{}|--- {text}\n", "| ".repeat(depth)))
190 .collect()
191 }
192
193 fn write_rules(
194 &self,
195 node: usize,
196 depth: usize,
197 name: &dyn Fn(usize) -> String,
198 lines: &mut Vec<(usize, String)>,
199 ) {
200 match self.tree.nodes[node] {
201 Node::Leaf { value } => {
202 let class = if value > 0.5 { "treat" } else { "do not treat" };
203 lines.push((
204 depth,
205 format!("recommend: {class} (share of positive effects {value:.2})"),
206 ));
207 }
208 Node::Split {
209 feature,
210 threshold,
211 left,
212 right,
213 } => {
214 lines.push((depth, format!("{} <= {threshold:.4}", name(feature))));
215 self.write_rules(left as usize, depth + 1, name, lines);
216 lines.push((depth, format!("{} > {threshold:.4}", name(feature))));
217 self.write_rules(right as usize, depth + 1, name, lines);
218 }
219 }
220 }
221}
222
223trait SignumOrZero {
225 fn signum_or_zero(self) -> f64;
226}
227
228impl SignumOrZero for f64 {
229 fn signum_or_zero(self) -> f64 {
230 if self > 0.0 {
231 1.0
232 } else if self < 0.0 {
233 -1.0
234 } else {
235 0.0
236 }
237 }
238}
239
240fn prune(tree: &Tree, stats: &[NodeStats], ccp_alpha: f64) -> Tree {
246 let mut order: Vec<usize> = Vec::with_capacity(tree.nodes.len());
248 let mut stack = vec![0usize];
249 while let Some(node) = stack.pop() {
250 order.push(node);
251 if let Node::Split { left, right, .. } = tree.nodes[node] {
252 stack.push(right as usize);
253 stack.push(left as usize);
254 }
255 }
256 let m = order.len();
257 let mut pre = vec![0usize; tree.nodes.len()]; for (k, &node) in order.iter().enumerate() {
259 pre[node] = k;
260 }
261 let children = |k: usize| match tree.nodes[order[k]] {
262 Node::Split { left, right, .. } => Some((pre[left as usize], pre[right as usize])),
263 Node::Leaf { .. } => None,
264 };
265 let total = stats[0].weight;
266 let r_node: Vec<f64> = order
267 .iter()
268 .map(|&node| stats[node].weight * stats[node].gini() / total)
269 .collect();
270 let mut parent = vec![usize::MAX; m];
271 for k in 0..m {
272 if let Some((l, r)) = children(k) {
273 parent[l] = k;
274 parent[r] = k;
275 }
276 }
277 let mut is_leaf: Vec<bool> = (0..m).map(|k| children(k).is_none()).collect();
278 let mut r_branch = vec![0.0; m];
279 let mut n_leaves = vec![0usize; m];
280 for leaf in (0..m).filter(|&k| is_leaf[k]) {
281 r_branch[leaf] = r_node[leaf];
282 let mut k = leaf;
283 while k != 0 {
284 let up = parent[k];
285 r_branch[up] += r_node[leaf];
286 n_leaves[up] += 1;
287 k = up;
288 }
289 }
290 let mut candidate: Vec<bool> = is_leaf.iter().map(|l| !l).collect();
291 let mut in_subtree = vec![true; m];
292 while candidate[0] {
293 let (mut alpha, mut pruned) = (f64::MAX, 0usize);
294 for k in (0..m).filter(|&k| candidate[k]) {
295 let a = (r_node[k] - r_branch[k]) / (n_leaves[k] as f64 - 1.0);
296 if a < alpha {
297 alpha = a;
298 pruned = k;
299 }
300 }
301 if ccp_alpha < alpha {
302 break;
303 }
304 let mut stack = vec![pruned];
306 while let Some(k) = stack.pop() {
307 if !in_subtree[k] {
308 continue;
309 }
310 candidate[k] = false;
311 is_leaf[k] = false;
312 in_subtree[k] = false;
313 if let Some((l, r)) = children(k) {
314 stack.push(l);
315 stack.push(r);
316 }
317 }
318 is_leaf[pruned] = true;
319 in_subtree[pruned] = true;
320 let n_pruned = n_leaves[pruned] - 1;
321 n_leaves[pruned] = 0;
322 let r_diff = r_node[pruned] - r_branch[pruned];
323 r_branch[pruned] = r_node[pruned];
324 let mut k = parent[pruned];
325 while k != usize::MAX {
326 n_leaves[k] -= n_pruned;
327 r_branch[k] += r_diff;
328 k = parent[k];
329 }
330 }
331 let mut nodes = Vec::new();
333 let mut stack = vec![(0usize, usize::MAX, false)]; while let Some((k, parent_slot, is_right)) = stack.pop() {
335 let slot = nodes.len();
336 if is_leaf[k] {
337 nodes.push(Node::Leaf {
338 value: stats[order[k]].share1(),
339 });
340 } else {
341 let Node::Split {
342 feature, threshold, ..
343 } = tree.nodes[order[k]]
344 else {
345 unreachable!("internal nodes are splits")
346 };
347 nodes.push(Node::Split {
348 feature,
349 threshold,
350 left: 0,
351 right: 0,
352 });
353 let (l, r) = children(k).expect("split");
354 stack.push((r, slot, true));
355 stack.push((l, slot, false));
356 }
357 if parent_slot != usize::MAX {
358 if let Node::Split { left, right, .. } = &mut nodes[parent_slot] {
359 if is_right {
360 *right = slot as u32;
361 } else {
362 *left = slot as u32;
363 }
364 }
365 }
366 }
367 Tree { nodes }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use ndarray::{array, Array2};
374
375 #[test]
376 fn splits_on_the_feature_that_separates_positive_from_negative_effects() {
377 let n = 200;
379 let x = Array2::from_shape_fn((n, 2), |(i, j)| {
380 if j == 0 {
381 i as f64 / n as f64 - 0.5
382 } else {
383 ((i * 37) % 11) as f64
384 }
385 });
386 let signal: Array1<f64> = (0..n)
387 .map(|i| if x[[i, 0]] > 0.0 { 2.0 } else { -1.0 })
388 .collect();
389 let tree = PolicyTree::fit(signal.view(), x.view(), 2, 8, 0.01).unwrap();
390 assert_eq!(tree.n_leaves(), 2);
391 let pred = tree
392 .predict(array![[-0.3, 1.0], [0.3, 1.0]].view())
393 .unwrap();
394 assert_eq!(pred.to_vec(), vec![0.0, 1.0]);
395 assert!(tree.export_text(None).contains("x0 <="));
396 }
397
398 #[test]
399 fn large_ccp_alpha_prunes_to_the_root() {
400 let x = Array2::from_shape_fn((40, 1), |(i, _)| i as f64);
401 let signal: Array1<f64> = (0..40).map(|i| if i < 20 { -1.0 } else { 3.0 }).collect();
402 let tree = PolicyTree::fit(signal.view(), x.view(), 2, 8, 10.0).unwrap();
403 assert_eq!(tree.n_leaves(), 1);
404 assert_eq!(tree.predict(x.view()).unwrap().sum(), 40.0);
406 }
407}