1use std::borrow::Cow;
2
3use ndarray::{concatenate, Array1, Array2, ArrayView1, ArrayView2, Axis};
4
5use crate::error::{DmlError, Result};
6
7#[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 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 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 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 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 pub fn d(&self) -> ArrayView2<'_, f64> {
129 self.d.view()
130 }
131
132 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 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 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
168pub(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}