1use std::borrow::Cow;
2
3use ndarray::{concatenate, Array1, Array2, ArrayView1, ArrayView2, Axis};
4
5use crate::error::{DmlError, Result};
6
7#[derive(Debug, Clone)]
20pub struct DmlData {
21 y: Array1<f64>,
22 d: Array2<f64>,
23 x: Array2<f64>,
24 z: Option<Array2<f64>>,
25 t: Option<Array1<f64>>,
26 s: Option<Array1<f64>>,
27 external: Option<crate::model::ExternalPredictions>,
28 clusters: Option<Vec<Array1<i64>>>,
29 treatment_names: Vec<String>,
30}
31
32fn all_finite<'a>(mut values: impl Iterator<Item = &'a f64>) -> bool {
33 values.all(|v| v.is_finite())
34}
35
36impl DmlData {
37 pub fn new(y: Array1<f64>, d: Array1<f64>, x: Array2<f64>) -> Result<Self> {
39 let n = d.len();
40 let d = d.into_shape_with_order((n, 1)).expect("1-D to (n, 1)");
41 Self::with_treatments(y, d, x)
42 }
43
44 pub fn with_treatments(y: Array1<f64>, d: Array2<f64>, x: Array2<f64>) -> Result<Self> {
46 let n = y.len();
47 if n < 2 {
48 return Err(DmlError::InvalidArgument(
49 "need at least 2 observations".into(),
50 ));
51 }
52 if d.nrows() != n || x.nrows() != n {
53 return Err(DmlError::DimensionMismatch(format!(
54 "y has {n} rows, d has {}, x has {}",
55 d.nrows(),
56 x.nrows()
57 )));
58 }
59 if d.ncols() == 0 {
60 return Err(DmlError::InvalidArgument(
61 "need at least one treatment".into(),
62 ));
63 }
64 if !all_finite(y.iter()) || !all_finite(d.iter()) || !all_finite(x.iter()) {
65 return Err(DmlError::InvalidArgument(
66 "data contains NaN or infinite values".into(),
67 ));
68 }
69 let treatment_names = if d.ncols() == 1 {
70 vec!["d".to_string()]
71 } else {
72 (0..d.ncols()).map(|j| format!("d{j}")).collect()
73 };
74 Ok(Self {
75 y,
76 d,
77 x,
78 z: None,
79 t: None,
80 s: None,
81 external: None,
82 clusters: None,
83 treatment_names,
84 })
85 }
86
87 pub fn with_instrument(self, z: Array1<f64>) -> Result<Self> {
89 let n = z.len();
90 self.with_instruments(z.into_shape_with_order((n, 1)).expect("1-D to (n, 1)"))
91 }
92
93 pub fn with_instruments(mut self, z: Array2<f64>) -> Result<Self> {
96 if z.nrows() != self.n_obs() {
97 return Err(DmlError::DimensionMismatch(format!(
98 "z has {} rows, expected {}",
99 z.nrows(),
100 self.n_obs()
101 )));
102 }
103 if z.ncols() == 0 {
104 return Err(DmlError::InvalidArgument(
105 "need at least one instrument".into(),
106 ));
107 }
108 if !all_finite(z.iter()) {
109 return Err(DmlError::InvalidArgument(
110 "instrument contains NaN or infinite values".into(),
111 ));
112 }
113 self.z = Some(z);
114 Ok(self)
115 }
116
117 pub fn with_time_indicator(mut self, t: Array1<f64>) -> Result<Self> {
120 if t.len() != self.n_obs() {
121 return Err(DmlError::DimensionMismatch(format!(
122 "t has {} rows, expected {}",
123 t.len(),
124 self.n_obs()
125 )));
126 }
127 if !is_binary(t.view()) {
128 return Err(DmlError::InvalidArgument(
129 "time indicator must contain only 0 and 1".into(),
130 ));
131 }
132 self.t = Some(t);
133 Ok(self)
134 }
135
136 pub fn with_selection_indicator(mut self, s: Array1<f64>) -> Result<Self> {
141 if s.len() != self.n_obs() {
142 return Err(DmlError::DimensionMismatch(format!(
143 "s has {} rows, expected {}",
144 s.len(),
145 self.n_obs()
146 )));
147 }
148 if !is_binary(s.view()) {
149 return Err(DmlError::InvalidArgument(
150 "selection indicator must contain only 0 and 1".into(),
151 ));
152 }
153 self.s = Some(s);
154 Ok(self)
155 }
156
157 pub fn with_clusters(self, cluster: Array1<i64>) -> Result<Self> {
160 self.set_clusters(vec![cluster])
161 }
162
163 pub fn with_two_way_clusters(self, first: Array1<i64>, second: Array1<i64>) -> Result<Self> {
165 self.set_clusters(vec![first, second])
166 }
167
168 fn set_clusters(mut self, clusters: Vec<Array1<i64>>) -> Result<Self> {
169 if clusters.iter().any(|c| c.len() != self.n_obs()) {
170 return Err(DmlError::DimensionMismatch(
171 "cluster variables need one entry per observation".into(),
172 ));
173 }
174 self.clusters = Some(clusters);
175 Ok(self)
176 }
177
178 pub fn clusters(&self) -> Option<&[Array1<i64>]> {
180 self.clusters.as_deref()
181 }
182
183 pub fn without_covariates(&self, drop: &[usize]) -> Result<Self> {
186 let p = self.n_features();
187 if drop.is_empty() || drop.iter().any(|&c| c >= p) {
188 return Err(DmlError::InvalidArgument(format!(
189 "benchmarking columns must be a non-empty subset of the {p} covariates"
190 )));
191 }
192 let keep: Vec<usize> = (0..p).filter(|c| !drop.contains(c)).collect();
193 Ok(Self {
195 x: self.x.select(Axis(1), &keep),
196 external: None,
197 ..self.clone()
198 })
199 }
200
201 pub fn with_treatment_names<S: Into<String>>(
203 mut self,
204 names: impl IntoIterator<Item = S>,
205 ) -> Result<Self> {
206 let names: Vec<String> = names.into_iter().map(Into::into).collect();
207 if names.len() != self.n_treat() {
208 return Err(DmlError::DimensionMismatch(format!(
209 "{} names for {} treatments",
210 names.len(),
211 self.n_treat()
212 )));
213 }
214 self.treatment_names = names;
215 Ok(self)
216 }
217
218 pub fn n_obs(&self) -> usize {
219 self.y.len()
220 }
221
222 pub fn n_features(&self) -> usize {
223 self.x.ncols()
224 }
225
226 pub fn n_treat(&self) -> usize {
227 self.d.ncols()
228 }
229
230 pub fn y(&self) -> ArrayView1<'_, f64> {
231 self.y.view()
232 }
233
234 pub fn d(&self) -> ArrayView2<'_, f64> {
236 self.d.view()
237 }
238
239 pub fn treatment(&self, j: usize) -> ArrayView1<'_, f64> {
241 self.d.column(j)
242 }
243
244 pub fn x(&self) -> ArrayView2<'_, f64> {
245 self.x.view()
246 }
247
248 pub fn z(&self) -> Option<ArrayView1<'_, f64>> {
250 self.z
251 .as_ref()
252 .filter(|z| z.ncols() == 1)
253 .map(|z| z.column(0))
254 }
255
256 pub fn instruments(&self) -> Option<ArrayView2<'_, f64>> {
258 self.z.as_ref().map(|z| z.view())
259 }
260
261 pub fn n_instruments(&self) -> usize {
262 self.z.as_ref().map_or(0, |z| z.ncols())
263 }
264
265 pub fn time(&self) -> Option<ArrayView1<'_, f64>> {
267 self.t.as_ref().map(|t| t.view())
268 }
269
270 pub fn with_external_predictions(
273 mut self,
274 external: crate::model::ExternalPredictions,
275 ) -> Self {
276 self.external = Some(external);
277 self
278 }
279
280 pub fn external_predictions(&self) -> Option<&crate::model::ExternalPredictions> {
281 self.external.as_ref()
282 }
283
284 pub fn selection(&self) -> Option<ArrayView1<'_, f64>> {
286 self.s.as_ref().map(|s| s.view())
287 }
288
289 pub fn treatment_names(&self) -> &[String] {
290 &self.treatment_names
291 }
292
293 pub fn covariates_for(&self, j: usize) -> Cow<'_, Array2<f64>> {
298 if self.n_treat() == 1 {
299 return Cow::Borrowed(&self.x);
300 }
301 let others: Vec<usize> = (0..self.n_treat()).filter(|&k| k != j).collect();
302 let d_other = self.d.select(Axis(1), &others);
303 Cow::Owned(concatenate![Axis(1), self.x, d_other])
304 }
305
306 pub fn is_binary_treatment(&self, j: usize) -> bool {
308 is_binary(self.d.column(j))
309 }
310}
311
312pub(crate) fn is_binary(v: ArrayView1<'_, f64>) -> bool {
314 v.iter().all(|&x| x == 0.0 || x == 1.0)
315}
316
317pub(crate) fn gather(v: ArrayView1<'_, f64>, idx: &[usize]) -> Array1<f64> {
319 idx.iter().map(|&i| v[i]).collect()
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use ndarray::array;
326
327 #[test]
328 fn rejects_mismatched_lengths() {
329 let err = DmlData::new(array![1.0, 2.0], array![0.0], array![[1.0], [2.0]]).unwrap_err();
330 assert!(matches!(err, DmlError::DimensionMismatch(_)));
331 }
332
333 #[test]
334 fn rejects_nan() {
335 let err = DmlData::new(
336 array![1.0, f64::NAN],
337 array![0.0, 1.0],
338 array![[1.0], [2.0]],
339 )
340 .unwrap_err();
341 assert!(matches!(err, DmlError::InvalidArgument(_)));
342 }
343
344 #[test]
345 fn detects_binary_treatment() {
346 let data = DmlData::new(array![1.0, 2.0], array![0.0, 1.0], array![[1.0], [2.0]]).unwrap();
347 assert!(data.is_binary_treatment(0));
348 }
349
350 #[test]
351 fn other_treatments_become_covariates() {
352 let d = array![[1.0, 10.0, 100.0], [2.0, 20.0, 200.0]];
353 let data = DmlData::with_treatments(array![0.0, 1.0], d, array![[5.0], [6.0]]).unwrap();
354 assert_eq!(data.treatment_names(), ["d0", "d1", "d2"]);
355 assert_eq!(
356 *data.covariates_for(1),
357 array![[5.0, 1.0, 100.0], [6.0, 2.0, 200.0]]
358 );
359 assert!(matches!(data.covariates_for(0), Cow::Owned(_)));
360 }
361
362 #[test]
363 fn several_instruments() {
364 let data = DmlData::new(array![1.0, 2.0], array![0.0, 1.0], array![[1.0], [2.0]]).unwrap();
365 let two = data
366 .clone()
367 .with_instruments(array![[1.0, 0.5], [0.0, 2.0]])
368 .unwrap();
369 assert_eq!(two.n_instruments(), 2);
370 assert!(two.z().is_none());
371 assert_eq!(two.instruments().unwrap().dim(), (2, 2));
372 let one = data.with_instrument(array![1.0, 0.0]).unwrap();
373 assert_eq!(one.z().unwrap().to_vec(), vec![1.0, 0.0]);
374 }
375
376 #[test]
377 fn time_indicator_must_be_binary() {
378 let data = DmlData::new(array![1.0, 2.0], array![0.0, 1.0], array![[1.0], [2.0]]).unwrap();
379 assert!(data.clone().with_time_indicator(array![0.0, 2.0]).is_err());
380 assert!(data.clone().with_time_indicator(array![1.0]).is_err());
381 assert!(data
382 .with_time_indicator(array![0.0, 1.0])
383 .unwrap()
384 .time()
385 .is_some());
386 }
387
388 #[test]
389 fn instrument_length_checked() {
390 let data = DmlData::new(array![1.0, 2.0], array![0.0, 1.0], array![[1.0], [2.0]]).unwrap();
391 assert!(data.clone().with_instrument(array![1.0]).is_err());
392 assert!(data
393 .with_instrument(array![1.0, 0.0])
394 .unwrap()
395 .z()
396 .is_some());
397 }
398}