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