1use ndarray::{Array1, Array2};
33use solow_core::{Error, Result};
34use solow_regression::LinearModel;
35
36#[derive(Debug, Clone, Copy, PartialEq)]
40pub enum TwoFoldType {
41 Pooled,
44 Neumark,
47 Cotton,
50 Reimers,
52 SelfSubmitted(f64),
55}
56
57#[derive(Debug, Clone)]
59pub struct TwoFold {
60 pub unexplained: f64,
63 pub explained: f64,
65 pub gap: f64,
67}
68
69#[derive(Debug, Clone)]
71pub struct ThreeFold {
72 pub endowments: f64,
74 pub coefficients: f64,
76 pub interaction: f64,
78 pub gap: f64,
80}
81
82#[derive(Debug, Clone)]
92pub struct OaxacaBlinder {
93 bifurcate: usize,
94 hasconst: bool,
95 neumark: Array2<f64>,
97 exog: Array2<f64>,
99 endog: Array1<f64>,
101 gap: f64,
102 len_f: usize,
103 len_s: usize,
104 exog_f_mean: Array1<f64>,
105 exog_s_mean: Array1<f64>,
106 f_params: Array1<f64>,
107 s_params: Array1<f64>,
108}
109
110fn add_constant_append(x: &Array2<f64>) -> Array2<f64> {
112 let (n, k) = x.dim();
113 let mut out = Array2::<f64>::zeros((n, k + 1));
114 out.slice_mut(ndarray::s![.., ..k]).assign(x);
115 for i in 0..n {
116 out[[i, k]] = 1.0;
117 }
118 out
119}
120
121fn col_means(x: &Array2<f64>) -> Array1<f64> {
123 let (n, k) = x.dim();
124 let mut m = Array1::<f64>::zeros(k);
125 for j in 0..k {
126 let mut s = 0.0;
127 for i in 0..n {
128 s += x[[i, j]];
129 }
130 m[j] = s / n as f64;
131 }
132 m
133}
134
135fn delete_col(x: &Array2<f64>, col: usize) -> Array2<f64> {
137 let (n, k) = x.dim();
138 let mut out = Array2::<f64>::zeros((n, k - 1));
139 let mut jj = 0;
140 for j in 0..k {
141 if j == col {
142 continue;
143 }
144 for i in 0..n {
145 out[[i, jj]] = x[[i, j]];
146 }
147 jj += 1;
148 }
149 out
150}
151
152fn select_rows(x: &Array2<f64>, rows: &[usize]) -> Array2<f64> {
154 let k = x.ncols();
155 let mut out = Array2::<f64>::zeros((rows.len(), k));
156 for (ii, &i) in rows.iter().enumerate() {
157 for j in 0..k {
158 out[[ii, j]] = x[[i, j]];
159 }
160 }
161 out
162}
163
164fn select_elems(v: &Array1<f64>, rows: &[usize]) -> Array1<f64> {
165 Array1::from_iter(rows.iter().map(|&i| v[i]))
166}
167
168fn mean(v: &Array1<f64>) -> f64 {
169 v.sum() / v.len() as f64
170}
171
172fn fit_params(endog: Array1<f64>, exog: Array2<f64>) -> Result<Array1<f64>> {
173 let res = LinearModel::ols(endog, exog)?.fit()?;
174 Ok(res.params)
175}
176
177impl OaxacaBlinder {
178 pub fn new(
183 endog: Array1<f64>,
184 exog: Array2<f64>,
185 bifurcate: usize,
186 hasconst: bool,
187 ) -> Result<Self> {
188 let n = endog.len();
189 if exog.nrows() != n {
190 return Err(Error::Shape("endog length != exog rows".into()));
191 }
192 if bifurcate >= exog.ncols() {
193 return Err(Error::Value("bifurcate column out of range".into()));
194 }
195
196 let bi_col: Vec<f64> = (0..n).map(|i| exog[[i, bifurcate]]).collect();
198 let mut uniq: Vec<f64> = bi_col.clone();
199 uniq.sort_by(|a, b| a.total_cmp(b));
200 uniq.dedup();
201 if uniq.len() != 2 {
202 return Err(Error::Value(
203 "bifurcate column must take exactly two distinct values".into(),
204 ));
205 }
206 let mut bi = [uniq[0], uniq[1]];
207
208 let mut rows_f: Vec<usize> = (0..n).filter(|&i| bi_col[i] == bi[0]).collect();
210 let mut rows_s: Vec<usize> = (0..n).filter(|&i| bi_col[i] == bi[1]).collect();
211
212 let endog_full = endog.clone();
213 let mut endog_f = select_elems(&endog_full, &rows_f);
214 let mut endog_s = select_elems(&endog_full, &rows_s);
215
216 let len_f = rows_f.len();
220 let len_s = rows_s.len();
221
222 let mut gap = mean(&endog_f) - mean(&endog_s);
223
224 if gap < 0.0 {
227 std::mem::swap(&mut rows_f, &mut rows_s);
228 std::mem::swap(&mut endog_f, &mut endog_s);
229 bi.swap(0, 1);
230 gap = mean(&endog_f) - mean(&endog_s);
231 }
232
233 let mut exog_f = delete_col(&select_rows(&exog, &rows_f), bifurcate);
235 let mut exog_s = delete_col(&select_rows(&exog, &rows_s), bifurcate);
236
237 let neumark = delete_col(&exog, bifurcate);
238 let (exog_full, neumark) = if hasconst {
239 (exog.clone(), neumark)
240 } else {
241 exog_f = add_constant_append(&exog_f);
242 exog_s = add_constant_append(&exog_s);
243 (add_constant_append(&exog), add_constant_append(&neumark))
244 };
245
246 let exog_f_mean = col_means(&exog_f);
247 let exog_s_mean = col_means(&exog_s);
248
249 let f_params = fit_params(endog_f, exog_f)?;
250 let s_params = fit_params(endog_s, exog_s)?;
251
252 Ok(OaxacaBlinder {
253 bifurcate,
254 hasconst,
255 neumark,
256 exog: exog_full,
257 endog,
258 gap,
259 len_f,
260 len_s,
261 exog_f_mean,
262 exog_s_mean,
263 f_params,
264 s_params,
265 })
266 }
267
268 pub fn gap(&self) -> f64 {
270 self.gap
271 }
272
273 pub fn f_params(&self) -> &Array1<f64> {
275 &self.f_params
276 }
277
278 pub fn s_params(&self) -> &Array1<f64> {
280 &self.s_params
281 }
282
283 pub fn exog_f_mean(&self) -> &Array1<f64> {
285 &self.exog_f_mean
286 }
287
288 pub fn exog_s_mean(&self) -> &Array1<f64> {
290 &self.exog_s_mean
291 }
292
293 fn t_params(&self, kind: TwoFoldType) -> Result<Array1<f64>> {
295 Ok(match kind {
296 TwoFoldType::Pooled => {
297 let full = fit_params(self.endog.clone(), self.exog.clone())?;
298 Array1::from_iter(
300 (0..full.len())
301 .filter(|&j| j != self.bifurcate)
302 .map(|j| full[j]),
303 )
304 }
305 TwoFoldType::Neumark => fit_params(self.endog.clone(), self.neumark.clone())?,
306 TwoFoldType::Cotton => {
307 let nf = self.len_f as f64;
308 let ns = self.len_s as f64;
309 &self.f_params * (nf / (nf + ns)) + &self.s_params * (ns / (nf + ns))
310 }
311 TwoFoldType::Reimers => (&self.f_params + &self.s_params) * 0.5,
312 TwoFoldType::SelfSubmitted(w) => &self.f_params * w + &self.s_params * (1.0 - w),
313 })
314 }
315
316 pub fn two_fold(&self, kind: TwoFoldType) -> Result<TwoFold> {
318 let tp = self.t_params(kind)?;
319 if tp.len() != self.f_params.len() {
320 return Err(Error::Shape("t_params dimension mismatch".into()));
321 }
322 let unexplained = self.exog_f_mean.dot(&(&self.f_params - &tp))
323 + self.exog_s_mean.dot(&(&tp - &self.s_params));
324 let explained = (&self.exog_f_mean - &self.exog_s_mean).dot(&tp);
325 Ok(TwoFold {
326 unexplained,
327 explained,
328 gap: self.gap,
329 })
330 }
331
332 pub fn three_fold(&self) -> ThreeFold {
334 let dmean = &self.exog_f_mean - &self.exog_s_mean;
335 let dparams = &self.f_params - &self.s_params;
336 ThreeFold {
337 endowments: dmean.dot(&self.s_params),
338 coefficients: self.exog_s_mean.dot(&dparams),
339 interaction: dmean.dot(&dparams),
340 gap: self.gap,
341 }
342 }
343
344 pub fn hasconst(&self) -> bool {
346 self.hasconst
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use ndarray::array;
354
355 fn toy() -> (Array1<f64>, Array2<f64>) {
356 let exog = array![
358 [1.0, 0.0, 1.0],
359 [1.0, 0.0, 2.0],
360 [1.0, 0.0, 3.0],
361 [1.0, 1.0, 1.0],
362 [1.0, 1.0, 2.0],
363 [1.0, 1.0, 3.0],
364 ];
365 let endog = array![1.0, 2.0, 3.0, 4.0, 5.5, 7.0];
366 (endog, exog)
367 }
368
369 #[test]
370 fn gap_is_nonnegative_and_decompositions_sum_to_gap() {
371 let (y, x) = toy();
372 let m = OaxacaBlinder::new(y, x, 1, true).unwrap();
373 assert!(m.gap() >= 0.0);
374
375 let tf = m.three_fold();
376 let s = tf.endowments + tf.coefficients + tf.interaction;
377 assert!((s - tf.gap).abs() < 1e-9, "three-fold sums to gap");
378
379 let two = m.two_fold(TwoFoldType::Pooled).unwrap();
380 assert!((two.unexplained + two.explained - two.gap).abs() < 1e-9);
381 }
382
383 #[test]
384 fn rejects_non_binary_group() {
385 let exog = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
386 let endog = array![1.0, 2.0, 3.0];
387 assert!(OaxacaBlinder::new(endog, exog, 1, true).is_err());
388 }
389}