Skip to main content

dml_core/
policy.rs

1//! Policy trees: a shallow decision tree that recommends treatment where the
2//! estimated effect is positive, as DoubleML's `DoubleMLPolicyTree` (Athey and
3//! Wager, 2021).
4//!
5//! The tree classifies the sign of the orthogonal signal `ψ_b` (labels
6//! `(sign + 1) / 2`) with sample weights `|ψ_b|`, so misclassifying units with
7//! large effects costs more. It is grown like scikit-learn's
8//! `DecisionTreeClassifier` (Gini criterion, depth and leaf-size limits) and
9//! then pruned by minimal cost-complexity pruning (`ccp_alpha`), with the same
10//! pruning order as scikit-learn.
11
12use 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/// One node of a fitted [`PolicyTree`] (see [`PolicyTree::nodes`]).
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub enum PolicyNode {
22    /// Rows with `feature <= threshold` go to node `left`, the others to `right`.
23    Split {
24        feature: usize,
25        threshold: f64,
26        left: usize,
27        right: usize,
28    },
29    /// A leaf: the weighted share of positive effects; treatment is
30    /// recommended when it exceeds 0.5.
31    Leaf { share_positive: f64 },
32}
33
34/// A fitted policy tree.
35#[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/// Weighted size and Gini impurity of one node.
45#[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    /// Fit on the orthogonal signal (one value per unit) and the features the
64    /// policy may depend on. DoubleML's defaults: `min_samples_leaf = 8`,
65    /// `ccp_alpha = 0.01`.
66    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        // scikit-learn ignores samples with zero weight.
91        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        // Weighted class counts of every node, from the training rows.
114        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    /// Number of leaves (distinct recommendations groups).
160    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    /// Recommended treatment (1) or not (0) for each row of `features`: the
169    /// class with the larger weighted share in the row's leaf (no treatment on
170    /// ties, as scikit-learn's argmax).
171    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    /// The nodes in preorder; node 0 is the root (for plotting the tree).
194    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    /// The decision rules as text, in the style of scikit-learn's
218    /// `export_text`; `feature_names` defaults to `x0, x1, …`.
219    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
262/// `f64::signum` maps 0 to 1; numpy's `sign` maps it to 0.
263trait 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
279/// Minimal cost-complexity pruning, as scikit-learn's `_cost_complexity_prune`:
280/// repeatedly collapse the internal node with the smallest effective alpha
281/// `(r(t) − r(T_t)) / (|leaves(T_t)| − 1)` while it is at most `ccp_alpha`,
282/// where `r(t) = W_t · Gini_t / W_root`. Nodes are visited in scikit-learn's
283/// depth-first (preorder) numbering, so ties are broken the same way.
284fn prune(tree: &Tree, stats: &[NodeStats], ccp_alpha: f64) -> Tree {
285    // Preorder numbering: node, left subtree, right subtree.
286    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()]; // node id -> preorder index
297    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        // Remove the subtree below `pruned`; it becomes a leaf.
344        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    // Rebuild the kept nodes, with leaf values = weighted share of class 1.
371    let mut nodes = Vec::new();
372    let mut stack = vec![(0usize, usize::MAX, false)]; // (preorder index, parent slot, is right child)
373    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        // Effect positive when x0 > 0, negative otherwise; x1 is noise.
417        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        // Weighted share of positive effects is 60/80 > 0.5: treat everyone.
444        assert_eq!(tree.predict(x.view()).unwrap().sum(), 40.0);
445    }
446}