Skip to main content

solow_stats/
oaxaca.rs

1//! Blinder-Oaxaca decomposition of the mean gap between two groups.
2//!
3//! [`OaxacaBlinder`] decomposes the difference in the mean of an outcome between
4//! two groups (defined by a binary column of the design matrix) into components
5//! attributable to differences in covariate *endowments* and differences in the
6//! estimated *coefficients*. Both the classic three-fold decomposition
7//! (endowments / coefficients / interaction) and the two-fold "pooled"
8//! decomposition (explained / unexplained) are provided, mirroring the reference
9//! `stats.oaxaca.OaxacaBlinder`.
10//!
11//! Given two groups `f` (first) and `s` (second) with covariate means
12//! `x̄_f`, `x̄_s` and fitted OLS coefficients `β_f`, `β_s`, the three-fold
13//! decomposition writes the gap `ȳ_f − ȳ_s` as
14//!
15//! ```text
16//! endowments   = (x̄_f − x̄_s) · β_s
17//! coefficients =  x̄_s · (β_f − β_s)
18//! interaction  = (x̄_f − x̄_s) · (β_f − β_s)
19//! ```
20//!
21//! and the two-fold decomposition (with a non-discriminatory coefficient vector
22//! `β*`) as
23//!
24//! ```text
25//! explained   = (x̄_f − x̄_s) · β*
26//! unexplained =  x̄_f · (β_f − β*) + x̄_s · (β* − β_s)
27//! ```
28//!
29//! Closed-form OLS underlies every quantity, so the effects reproduce the
30//! reference to machine precision.
31
32use ndarray::{Array1, Array2};
33use solow_core::{Error, Result};
34use solow_regression::LinearModel;
35
36/// Weighting scheme for the non-discriminatory coefficient vector `β*` used by
37/// the two-fold ("pooled") decomposition. Mirrors the reference
38/// `two_fold_type` options.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub enum TwoFoldType {
41    /// `β*` from OLS on the full sample including the group indicator; the
42    /// indicator's coefficient is dropped before applying `β*`.
43    Pooled,
44    /// `β*` from OLS on the full sample *excluding* the group indicator
45    /// (the "Neumark" pooled model).
46    Neumark,
47    /// Cotton (1988) sample-size weighting:
48    /// `β* = (n_f β_f + n_s β_s) / (n_f + n_s)`.
49    Cotton,
50    /// Reimers 50/50 weighting: `β* = ½ (β_f + β_s)`.
51    Reimers,
52    /// User-supplied weight `w` on the larger-mean group:
53    /// `β* = w β_f + (1 − w) β_s`.
54    SelfSubmitted(f64),
55}
56
57/// Two-fold ("pooled") Blinder-Oaxaca decomposition of the mean gap.
58#[derive(Debug, Clone)]
59pub struct TwoFold {
60    /// Effect attributable to differences in coefficients (and the constant),
61    /// i.e. the part *not* explained by the covariate endowments.
62    pub unexplained: f64,
63    /// Effect attributable to differences in covariate endowments.
64    pub explained: f64,
65    /// The raw mean gap `ȳ_f − ȳ_s` (non-negative after the group swap).
66    pub gap: f64,
67}
68
69/// Three-fold Blinder-Oaxaca decomposition of the mean gap.
70#[derive(Debug, Clone)]
71pub struct ThreeFold {
72    /// Endowments (characteristics) effect `(x̄_f − x̄_s) · β_s`.
73    pub endowments: f64,
74    /// Coefficients effect `x̄_s · (β_f − β_s)`.
75    pub coefficients: f64,
76    /// Interaction effect `(x̄_f − x̄_s) · (β_f − β_s)`.
77    pub interaction: f64,
78    /// The raw mean gap `ȳ_f − ȳ_s`.
79    pub gap: f64,
80}
81
82/// Blinder-Oaxaca decomposition model.
83///
84/// `endog` is the outcome, `exog` the design matrix, and `bifurcate` the column
85/// index of the binary group indicator. With `hasconst = true` the design is
86/// assumed to already contain a constant; with `hasconst = false` a constant is
87/// appended to each group's covariate matrix (matching the reference, which
88/// appends rather than prepends). The two groups are ordered so the first group
89/// has the larger outcome mean (the reference `swap=True` behaviour), making the
90/// reported gap non-negative.
91#[derive(Debug, Clone)]
92pub struct OaxacaBlinder {
93    bifurcate: usize,
94    hasconst: bool,
95    /// Design with the bifurcate column removed (used for the Neumark pooled fit).
96    neumark: Array2<f64>,
97    /// Full design (used for the pooled fit).
98    exog: Array2<f64>,
99    /// Full outcome vector.
100    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
110/// Append a constant column of ones at the end of `x` (`prepend=False`).
111fn 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
121/// Mean of each column of `x`.
122fn 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
135/// Drop column `col` from `x`.
136fn 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
152/// Select the rows of `x` whose index appears in `rows`.
153fn 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    /// Build the decomposition model.
179    ///
180    /// `bifurcate` is the column index of the binary group indicator in `exog`.
181    /// The indicator must take exactly two distinct values.
182    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        // Unique group values, ascending (np.unique). Require exactly two.
197        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        // Row index sets for the two groups.
209        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        // The reference fixes `len_f`/`len_s` from the *initial* group ordering
217        // (before any swap) and reuses them in the Cotton weighting, so we
218        // capture them here, prior to the swap below.
219        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        // swap=True (reference default): order so the first group has the larger
225        // outcome mean, making the gap non-negative.
226        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        // Group covariate matrices: rows of the group, bifurcate column deleted.
234        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    /// The non-negative mean gap `ȳ_f − ȳ_s`.
269    pub fn gap(&self) -> f64 {
270        self.gap
271    }
272
273    /// Fitted first-group coefficients `β_f`.
274    pub fn f_params(&self) -> &Array1<f64> {
275        &self.f_params
276    }
277
278    /// Fitted second-group coefficients `β_s`.
279    pub fn s_params(&self) -> &Array1<f64> {
280        &self.s_params
281    }
282
283    /// First-group covariate means `x̄_f`.
284    pub fn exog_f_mean(&self) -> &Array1<f64> {
285        &self.exog_f_mean
286    }
287
288    /// Second-group covariate means `x̄_s`.
289    pub fn exog_s_mean(&self) -> &Array1<f64> {
290        &self.exog_s_mean
291    }
292
293    /// Non-discriminatory coefficient vector `β*` for the given weighting.
294    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                // Drop the bifurcate coefficient.
299                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    /// Two-fold ("pooled") decomposition with the requested weighting scheme.
317    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    /// Three-fold decomposition (endowments / coefficients / interaction).
333    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    /// Whether the design was supplied with a constant (`hasconst`).
345    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        // [const, group, x]
357        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}