Skip to main content

dml_core/
cluster.rs

1//! Cluster-robust DML (Chiang, Kato, Ma & Sasaki, 2022), as DoubleML:
2//! one-way or two-way clustered observations.
3//!
4//! With clustered data, sample splitting assigns whole clusters to folds (for
5//! two-way clustering the folds are the `K²` combinations of the two cluster
6//! dimensions, so no cluster appears in both the training and the test set),
7//! θ solves a fold-weighted score equation, and the variance sums the score
8//! within clusters.
9
10use std::collections::{BTreeMap, HashSet};
11
12use ndarray::{Array1, ArrayView1};
13
14use crate::crossfit::{Fold, KFold};
15use crate::error::{DmlError, Result};
16use crate::score::LinearScore;
17
18/// One fold of a cluster split: the observation-level fold and, for each
19/// cluster variable, the clusters in its test set.
20#[derive(Debug, Clone, PartialEq)]
21pub struct ClusterFold {
22    pub fold: Fold,
23    pub test_clusters: Vec<Vec<i64>>,
24}
25
26/// Cluster-level sample splitting for one repetition (DoubleML's
27/// `DoubleMLClusterResampling`): K-fold over the distinct clusters of each
28/// variable (seeded per variable), then the Cartesian product of the folds,
29/// first variable outermost.
30pub(crate) fn split_clusters(
31    clusters: &[Array1<i64>],
32    n_folds: usize,
33    seed: u64,
34) -> Result<Vec<ClusterFold>> {
35    let n = clusters[0].len();
36    let mut per_var: Vec<(Vec<i64>, Vec<usize>)> = Vec::new(); // (distinct clusters, fold of each cluster)
37    for (v, c) in clusters.iter().enumerate() {
38        let mut distinct: Vec<i64> = c.to_vec();
39        distinct.sort_unstable();
40        distinct.dedup();
41        let var_seed = seed.wrapping_add((v as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F));
42        let folds = KFold::new(n_folds, var_seed)?
43            .split(distinct.len())
44            .map_err(|_| {
45                DmlError::InvalidArgument(format!(
46                    "cluster variable {v} has {} clusters, fewer than {n_folds} folds",
47                    distinct.len()
48                ))
49            })?;
50        let mut fold_of = vec![0usize; distinct.len()];
51        for (k, f) in folds.iter().enumerate() {
52            for &i in &f.test {
53                fold_of[i] = k;
54            }
55        }
56        per_var.push((distinct, fold_of));
57    }
58    // Fold index of every observation in every cluster variable.
59    let obs_fold: Vec<Vec<usize>> = clusters
60        .iter()
61        .zip(&per_var)
62        .map(|(c, (distinct, fold_of))| {
63            c.iter()
64                .map(|v| fold_of[distinct.binary_search(v).expect("collected above")])
65                .collect()
66        })
67        .collect();
68    let m = clusters.len();
69    let combos = n_folds.pow(m as u32);
70    let mut out = Vec::with_capacity(combos);
71    for combo in 0..combos {
72        // Mixed-radix digits, first variable most significant.
73        let mut digits = vec![0usize; m];
74        let mut rest = combo;
75        for v in (0..m).rev() {
76            digits[v] = rest % n_folds;
77            rest /= n_folds;
78        }
79        let test: Vec<usize> = (0..n)
80            .filter(|&i| (0..m).all(|v| obs_fold[v][i] == digits[v]))
81            .collect();
82        let train: Vec<usize> = (0..n)
83            .filter(|&i| (0..m).all(|v| obs_fold[v][i] != digits[v]))
84            .collect();
85        let test_clusters = per_var
86            .iter()
87            .zip(&digits)
88            .map(|((distinct, fold_of), &k)| {
89                distinct
90                    .iter()
91                    .zip(fold_of)
92                    .filter(|(_, &f)| f == k)
93                    .map(|(&c, _)| c)
94                    .collect()
95            })
96            .collect();
97        out.push(ClusterFold {
98            fold: Fold { train, test },
99            test_clusters,
100        });
101    }
102    Ok(out)
103}
104
105/// Test clusters derived from a fold's test rows (exact for one-way
106/// clustering and for two-way designs where every cluster pair is observed).
107pub(crate) fn derive_test_clusters(clusters: &[Array1<i64>], fold: &Fold) -> Vec<Vec<i64>> {
108    clusters
109        .iter()
110        .map(|c| {
111            let mut v: Vec<i64> = fold.test.iter().map(|&i| c[i]).collect();
112            v.sort_unstable();
113            v.dedup();
114            v
115        })
116        .collect()
117}
118
119/// Cluster structure of a fit: the cluster variables and, per repetition,
120/// the cluster folds used.
121#[derive(Debug, Clone)]
122pub(crate) struct ClusterInfo {
123    pub clusters: Vec<Array1<i64>>,
124    pub n_folds_per_cluster: usize,
125    /// `[rep][fold]`.
126    pub folds: Vec<Vec<ClusterFold>>,
127    /// Observations of each cluster, per variable.
128    members: Vec<BTreeMap<i64, Vec<usize>>>,
129}
130
131impl ClusterInfo {
132    pub(crate) fn new(clusters: Vec<Array1<i64>>, folds: Vec<Vec<ClusterFold>>) -> Result<Self> {
133        if clusters.is_empty() || clusters.len() > 2 {
134            return Err(DmlError::InvalidArgument(
135                "one or two cluster variables are supported".into(),
136            ));
137        }
138        let m = clusters.len() as u32;
139        let n_folds = folds.first().map_or(0, |f| f.len());
140        let k = (n_folds as f64).powf(1.0 / m as f64).round() as usize;
141        if k.pow(m) != n_folds || folds.iter().any(|f| f.len() != n_folds) {
142            return Err(DmlError::InvalidArgument(
143                "cluster folds must be the product of the per-variable folds".into(),
144            ));
145        }
146        let members = clusters
147            .iter()
148            .map(|c| {
149                let mut map: BTreeMap<i64, Vec<usize>> = BTreeMap::new();
150                for (i, &v) in c.iter().enumerate() {
151                    map.entry(v).or_default().push(i);
152                }
153                map
154            })
155            .collect();
156        Ok(Self {
157            clusters,
158            n_folds_per_cluster: k,
159            folds,
160            members,
161        })
162    }
163
164    pub(crate) fn n_ways(&self) -> usize {
165        self.clusters.len()
166    }
167
168    /// Number of clusters (two-way: the smaller dimension), DoubleML's
169    /// variance scaling factor.
170    pub(crate) fn n_clusters(&self) -> usize {
171        self.members.iter().map(BTreeMap::len).min().unwrap_or(0)
172    }
173
174    /// θ as the root of the fold-weighted score: each fold's sums are scaled by
175    /// `1 / Π |test clusters|`.
176    pub(crate) fn solve_theta(&self, score: &LinearScore, rep: usize) -> Result<f64> {
177        let (mut sa, mut sb) = (0.0, 0.0);
178        for cf in &self.folds[rep] {
179            let scale = 1.0
180                / cf.test_clusters
181                    .iter()
182                    .map(|c| c.len() as f64)
183                    .product::<f64>();
184            sa += scale * cf.fold.test.iter().map(|&i| score.psi_a[i]).sum::<f64>();
185            sb += scale * cf.fold.test.iter().map(|&i| score.psi_b[i]).sum::<f64>();
186        }
187        if sa == 0.0 || !sa.is_finite() {
188            return Err(DmlError::InvalidArgument(
189                "weighted mean of psi_a is zero or not finite: theta is not identified".into(),
190            ));
191        }
192        Ok(-sb / sa)
193    }
194
195    /// Cluster-robust variance `σ̂² = Γ̂ / (C · Ĵ²)` (DoubleML's `_var_est`),
196    /// so that `se = √σ̂²`.
197    pub(crate) fn variance(
198        &self,
199        psi: ArrayView1<'_, f64>,
200        psi_deriv: ArrayView1<'_, f64>,
201        rep: usize,
202    ) -> f64 {
203        let k = self.n_folds_per_cluster as f64;
204        let (mut gamma, mut jac) = (0.0, 0.0);
205        let sum_over = |idx: &[usize]| idx.iter().map(|&i| psi[i]).sum::<f64>();
206        for cf in &self.folds[rep] {
207            let test_deriv: f64 = cf.fold.test.iter().map(|&i| psi_deriv[i]).sum();
208            if self.n_ways() == 1 {
209                let i_k = &cf.test_clusters[0];
210                let c = 1.0 / i_k.len() as f64;
211                for cl in i_k {
212                    let s = self.members[0].get(cl).map_or(0.0, |m| sum_over(m));
213                    gamma += c * s * s;
214                }
215                jac += test_deriv / i_k.len() as f64;
216            } else {
217                let (i_k, j_l) = (&cf.test_clusters[0], &cf.test_clusters[1]);
218                let (ni, nj) = (i_k.len() as f64, j_l.len() as f64);
219                let c = ni.min(nj) / (ni * nj).powi(2);
220                let in_j: HashSet<i64> = j_l.iter().copied().collect();
221                let in_i: HashSet<i64> = i_k.iter().copied().collect();
222                for cl in i_k {
223                    if let Some(m) = self.members[0].get(cl) {
224                        let s: f64 = m
225                            .iter()
226                            .filter(|&&i| in_j.contains(&self.clusters[1][i]))
227                            .map(|&i| psi[i])
228                            .sum();
229                        gamma += c * s * s;
230                    }
231                }
232                for cl in j_l {
233                    if let Some(m) = self.members[1].get(cl) {
234                        let s: f64 = m
235                            .iter()
236                            .filter(|&&i| in_i.contains(&self.clusters[0][i]))
237                            .map(|&i| psi[i])
238                            .sum();
239                        gamma += c * s * s;
240                    }
241                }
242                jac += test_deriv / (ni * nj);
243            }
244        }
245        let norm = k.powi(self.n_ways() as i32);
246        let (gamma, jac) = (gamma / norm, jac / norm);
247        gamma / (self.n_clusters() as f64 * jac * jac)
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use ndarray::array;
255
256    #[test]
257    fn one_way_split_keeps_clusters_together() {
258        let c = Array1::from_shape_fn(40, |i| (i / 4) as i64); // 10 clusters of 4
259        let folds = split_clusters(std::slice::from_ref(&c), 3, 7).unwrap();
260        assert_eq!(folds.len(), 3);
261        let mut seen = [false; 40];
262        for cf in &folds {
263            for &i in &cf.fold.test {
264                assert!(!seen[i]);
265                seen[i] = true;
266                assert!(cf.test_clusters[0].contains(&c[i]));
267            }
268            // No cluster is in both training and test rows.
269            assert!(cf
270                .fold
271                .train
272                .iter()
273                .all(|&i| !cf.test_clusters[0].contains(&c[i])));
274            assert_eq!(cf.fold.train.len() + cf.fold.test.len(), 40);
275        }
276        assert!(seen.iter().all(|&s| s));
277    }
278
279    #[test]
280    fn two_way_split_is_the_fold_product() {
281        let a = Array1::from_shape_fn(36, |i| (i / 6) as i64);
282        let b = Array1::from_shape_fn(36, |i| (i % 6) as i64);
283        let folds = split_clusters(&[a.clone(), b.clone()], 2, 1).unwrap();
284        assert_eq!(folds.len(), 4);
285        let total: usize = folds.iter().map(|cf| cf.fold.test.len()).sum();
286        assert_eq!(total, 36);
287        for cf in &folds {
288            for &i in &cf.fold.train {
289                assert!(
290                    !cf.test_clusters[0].contains(&a[i]) && !cf.test_clusters[1].contains(&b[i])
291                );
292            }
293        }
294    }
295
296    #[test]
297    fn one_way_variance_with_singleton_clusters_is_the_iid_variance() {
298        // With one observation per cluster and one fold assignment, the
299        // clustered variance reduces to mean(ψ²) / (n · J²).
300        let n = 6;
301        let c = Array1::from_shape_fn(n, |i| i as i64);
302        let folds = split_clusters(std::slice::from_ref(&c), 2, 3).unwrap();
303        let info = ClusterInfo::new(vec![c], vec![folds]).unwrap();
304        let psi = array![1.0, -2.0, 0.5, 0.3, -1.0, 1.2];
305        let deriv = Array1::from_elem(n, -2.0);
306        let iid = psi.dot(&psi) / n as f64 / 4.0 / n as f64;
307        assert!((info.variance(psi.view(), deriv.view(), 0) - iid).abs() < 1e-12);
308    }
309}