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/// A fitted policy tree.
20#[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/// Weighted size and Gini impurity of one node.
30#[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    /// Fit on the orthogonal signal (one value per unit) and the features the
49    /// policy may depend on. DoubleML's defaults: `min_samples_leaf = 8`,
50    /// `ccp_alpha = 0.01`.
51    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        // scikit-learn ignores samples with zero weight.
76        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        // Weighted class counts of every node, from the training rows.
99        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    /// Number of leaves (distinct recommendations groups).
145    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    /// Recommended treatment (1) or not (0) for each row of `features`: the
154    /// class with the larger weighted share in the row's leaf (no treatment on
155    /// ties, as scikit-learn's argmax).
156    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    /// The decision rules as text, in the style of scikit-learn's
179    /// `export_text`; `feature_names` defaults to `x0, x1, …`.
180    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
223/// `f64::signum` maps 0 to 1; numpy's `sign` maps it to 0.
224trait 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
240/// Minimal cost-complexity pruning, as scikit-learn's `_cost_complexity_prune`:
241/// repeatedly collapse the internal node with the smallest effective alpha
242/// `(r(t) − r(T_t)) / (|leaves(T_t)| − 1)` while it is at most `ccp_alpha`,
243/// where `r(t) = W_t · Gini_t / W_root`. Nodes are visited in scikit-learn's
244/// depth-first (preorder) numbering, so ties are broken the same way.
245fn prune(tree: &Tree, stats: &[NodeStats], ccp_alpha: f64) -> Tree {
246    // Preorder numbering: node, left subtree, right subtree.
247    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()]; // node id -> preorder index
258    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        // Remove the subtree below `pruned`; it becomes a leaf.
305        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    // Rebuild the kept nodes, with leaf values = weighted share of class 1.
332    let mut nodes = Vec::new();
333    let mut stack = vec![(0usize, usize::MAX, false)]; // (preorder index, parent slot, is right child)
334    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        // Effect positive when x0 > 0, negative otherwise; x1 is noise.
378        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        // Weighted share of positive effects is 60/80 > 0.5: treat everyone.
405        assert_eq!(tree.predict(x.view()).unwrap().sum(), 40.0);
406    }
407}