Skip to main content

dml_core/
data.rs

1use std::borrow::Cow;
2
3use ndarray::{concatenate, Array1, Array2, ArrayView1, ArrayView2, Axis};
4
5use crate::error::{DmlError, Result};
6
7/// Data for a DML model.
8///
9/// - `y`: outcome, length `n`
10/// - `d`: one or more treatments, shape `(n, k)` (continuous or 0/1)
11/// - `x`: covariates / confounders, shape `(n, p)`
12/// - `z`: optional instrument, length `n` (for [`Pliv`](crate::Pliv))
13///
14/// With several treatments each one is estimated in turn, and the other
15/// treatments are added to the covariates (as in DoubleML).
16#[derive(Debug, Clone)]
17pub struct DmlData {
18    y: Array1<f64>,
19    d: Array2<f64>,
20    x: Array2<f64>,
21    z: Option<Array1<f64>>,
22    treatment_names: Vec<String>,
23}
24
25fn all_finite<'a>(mut values: impl Iterator<Item = &'a f64>) -> bool {
26    values.all(|v| v.is_finite())
27}
28
29impl DmlData {
30    /// Single treatment.
31    pub fn new(y: Array1<f64>, d: Array1<f64>, x: Array2<f64>) -> Result<Self> {
32        let n = d.len();
33        let d = d.into_shape_with_order((n, 1)).expect("1-D to (n, 1)");
34        Self::with_treatments(y, d, x)
35    }
36
37    /// Several treatments, one per column of `d`.
38    pub fn with_treatments(y: Array1<f64>, d: Array2<f64>, x: Array2<f64>) -> Result<Self> {
39        let n = y.len();
40        if n < 2 {
41            return Err(DmlError::InvalidArgument(
42                "need at least 2 observations".into(),
43            ));
44        }
45        if d.nrows() != n || x.nrows() != n {
46            return Err(DmlError::DimensionMismatch(format!(
47                "y has {n} rows, d has {}, x has {}",
48                d.nrows(),
49                x.nrows()
50            )));
51        }
52        if d.ncols() == 0 {
53            return Err(DmlError::InvalidArgument(
54                "need at least one treatment".into(),
55            ));
56        }
57        if !all_finite(y.iter()) || !all_finite(d.iter()) || !all_finite(x.iter()) {
58            return Err(DmlError::InvalidArgument(
59                "data contains NaN or infinite values".into(),
60            ));
61        }
62        let treatment_names = if d.ncols() == 1 {
63            vec!["d".to_string()]
64        } else {
65            (0..d.ncols()).map(|j| format!("d{j}")).collect()
66        };
67        Ok(Self {
68            y,
69            d,
70            x,
71            z: None,
72            treatment_names,
73        })
74    }
75
76    /// Attach an instrument `z` (required by [`Pliv`](crate::Pliv)).
77    pub fn with_instrument(mut self, z: Array1<f64>) -> Result<Self> {
78        if z.len() != self.n_obs() {
79            return Err(DmlError::DimensionMismatch(format!(
80                "z has {} rows, expected {}",
81                z.len(),
82                self.n_obs()
83            )));
84        }
85        if !all_finite(z.iter()) {
86            return Err(DmlError::InvalidArgument(
87                "instrument contains NaN or infinite values".into(),
88            ));
89        }
90        self.z = Some(z);
91        Ok(self)
92    }
93
94    /// Names shown in summaries (default `d`, or `d0`, `d1`, …).
95    pub fn with_treatment_names<S: Into<String>>(
96        mut self,
97        names: impl IntoIterator<Item = S>,
98    ) -> Result<Self> {
99        let names: Vec<String> = names.into_iter().map(Into::into).collect();
100        if names.len() != self.n_treat() {
101            return Err(DmlError::DimensionMismatch(format!(
102                "{} names for {} treatments",
103                names.len(),
104                self.n_treat()
105            )));
106        }
107        self.treatment_names = names;
108        Ok(self)
109    }
110
111    pub fn n_obs(&self) -> usize {
112        self.y.len()
113    }
114
115    pub fn n_features(&self) -> usize {
116        self.x.ncols()
117    }
118
119    pub fn n_treat(&self) -> usize {
120        self.d.ncols()
121    }
122
123    pub fn y(&self) -> ArrayView1<'_, f64> {
124        self.y.view()
125    }
126
127    /// All treatments, shape `(n, k)`.
128    pub fn d(&self) -> ArrayView2<'_, f64> {
129        self.d.view()
130    }
131
132    /// Treatment `j`.
133    pub fn treatment(&self, j: usize) -> ArrayView1<'_, f64> {
134        self.d.column(j)
135    }
136
137    pub fn x(&self) -> ArrayView2<'_, f64> {
138        self.x.view()
139    }
140
141    pub fn z(&self) -> Option<ArrayView1<'_, f64>> {
142        self.z.as_ref().map(|z| z.view())
143    }
144
145    pub fn treatment_names(&self) -> &[String] {
146        &self.treatment_names
147    }
148
149    /// Covariates used when estimating the effect of treatment `j`: `x` plus
150    /// the other treatment columns. Borrowed when there is only one treatment.
151    ///
152    /// (`Cow` = "clone on write": either a borrowed view or an owned matrix.)
153    pub fn covariates_for(&self, j: usize) -> Cow<'_, Array2<f64>> {
154        if self.n_treat() == 1 {
155            return Cow::Borrowed(&self.x);
156        }
157        let others: Vec<usize> = (0..self.n_treat()).filter(|&k| k != j).collect();
158        let d_other = self.d.select(Axis(1), &others);
159        Cow::Owned(concatenate![Axis(1), self.x, d_other])
160    }
161
162    /// True when every value of treatment `j` is exactly 0 or 1.
163    pub fn is_binary_treatment(&self, j: usize) -> bool {
164        self.d.column(j).iter().all(|&v| v == 0.0 || v == 1.0)
165    }
166}
167
168/// Gather `v[idx]` into a new array.
169pub(crate) fn gather(v: ArrayView1<'_, f64>, idx: &[usize]) -> Array1<f64> {
170    idx.iter().map(|&i| v[i]).collect()
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use ndarray::array;
177
178    #[test]
179    fn rejects_mismatched_lengths() {
180        let err = DmlData::new(array![1.0, 2.0], array![0.0], array![[1.0], [2.0]]).unwrap_err();
181        assert!(matches!(err, DmlError::DimensionMismatch(_)));
182    }
183
184    #[test]
185    fn rejects_nan() {
186        let err = DmlData::new(
187            array![1.0, f64::NAN],
188            array![0.0, 1.0],
189            array![[1.0], [2.0]],
190        )
191        .unwrap_err();
192        assert!(matches!(err, DmlError::InvalidArgument(_)));
193    }
194
195    #[test]
196    fn detects_binary_treatment() {
197        let data = DmlData::new(array![1.0, 2.0], array![0.0, 1.0], array![[1.0], [2.0]]).unwrap();
198        assert!(data.is_binary_treatment(0));
199    }
200
201    #[test]
202    fn other_treatments_become_covariates() {
203        let d = array![[1.0, 10.0, 100.0], [2.0, 20.0, 200.0]];
204        let data = DmlData::with_treatments(array![0.0, 1.0], d, array![[5.0], [6.0]]).unwrap();
205        assert_eq!(data.treatment_names(), ["d0", "d1", "d2"]);
206        assert_eq!(
207            *data.covariates_for(1),
208            array![[5.0, 1.0, 100.0], [6.0, 2.0, 200.0]]
209        );
210        assert!(matches!(data.covariates_for(0), Cow::Owned(_)));
211    }
212
213    #[test]
214    fn instrument_length_checked() {
215        let data = DmlData::new(array![1.0, 2.0], array![0.0, 1.0], array![[1.0], [2.0]]).unwrap();
216        assert!(data.clone().with_instrument(array![1.0]).is_err());
217        assert!(data
218            .with_instrument(array![1.0, 0.0])
219            .unwrap()
220            .z()
221            .is_some());
222    }
223}