Skip to main content

dml_core/
riesz.rs

1//! Linear functionals of the regression and their Riesz representers, for
2//! automatic debiased machine learning ([`AutoDml`](crate::AutoDml); Chernozhukov,
3//! Newey and Singh, 2022).
4//!
5//! The target is `θ = E[m(W; γ)]`, a mean of a linear functional `m` of the
6//! regression `γ(w) = E[Y | W = w]` with `W = (D, X)`. Its Riesz representer
7//! `α` satisfies `E[m(W; f)] = E[α(W) f(W)]` for all `f`, and the debiased
8//! score is `m(W; γ) + α(W)(Y − γ(W)) − θ`. Instead of deriving `α` by hand
9//! (as the propensity weights of the ATE), it is learned from the functional
10//! alone by minimising the Riesz loss `E[α(W)² − 2 m(W; α)]`.
11//!
12//! A functional is written as evaluations of a function at transformed data
13//! points: `m(W; f) = Σ_k c_k(W) · f(W_k)` ([`Term`]s), e.g. for the ATE
14//! `f(1, X) − f(0, X)`.
15
16use std::sync::Arc;
17
18use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
19
20use crate::error::{DmlError, Result};
21
22/// One evaluation in a functional: `weight ⊙ f(w)`, with `w` the data with
23/// transformed columns (column 0 is the treatment).
24#[derive(Debug, Clone)]
25pub struct Term {
26    pub w: Array2<f64>,
27    pub weight: Array1<f64>,
28}
29
30impl Term {
31    pub fn new(w: Array2<f64>, weight: Array1<f64>) -> Self {
32        Self { w, weight }
33    }
34
35    /// `f` at the transformed points, with constant weight `c`.
36    pub fn constant(w: Array2<f64>, c: f64) -> Self {
37        let n = w.nrows();
38        Self::new(w, Array1::from_elem(n, c))
39    }
40
41    /// The rows `idx` of this term.
42    pub fn rows(&self, idx: &[usize]) -> Term {
43        Term {
44            w: self.w.select(Axis(0), idx),
45            weight: idx.iter().map(|&i| self.weight[i]).collect(),
46        }
47    }
48}
49
50/// `m(W; f) = Σ_k weight_k ⊙ f(w_k)` for the terms of a functional.
51pub fn apply_terms(
52    terms: &[Term],
53    mut f: impl FnMut(ArrayView2<'_, f64>) -> Result<Array1<f64>>,
54) -> Result<Array1<f64>> {
55    let n = terms.first().map_or(0, |t| t.w.nrows());
56    let mut out = Array1::zeros(n);
57    for t in terms {
58        let v = f(t.w.view())?;
59        if v.len() != n {
60            return Err(DmlError::DimensionMismatch(
61                "functional term predictions".into(),
62            ));
63        }
64        out += &(&t.weight * &v);
65    }
66    Ok(out)
67}
68
69/// A linear functional `m(W; f)` of a function of `W = (D, X)`, given by the
70/// transformed data points at which `f` is evaluated (column 0 of `w` is the
71/// treatment).
72pub trait LinearFunctional: Send + Sync {
73    /// Label for summaries, e.g. `"ATE"`.
74    fn name(&self) -> String;
75
76    /// The terms of `m` at the rows of `w` (all observations; `w` has the
77    /// treatment in column 0 and the covariates after it).
78    fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>>;
79}
80
81fn with_treatment(w: ArrayView2<'_, f64>, d: impl Fn(usize, f64) -> f64) -> Array2<f64> {
82    let mut out = w.to_owned();
83    for (i, v) in out.column_mut(0).iter_mut().enumerate() {
84        *v = d(i, *v);
85    }
86    out
87}
88
89fn std_dev(v: ArrayView1<'_, f64>) -> f64 {
90    let mean = v.mean().unwrap_or(0.0);
91    (v.mapv(|x| (x - mean).powi(2)).mean().unwrap_or(0.0)).sqrt()
92}
93
94/// Average treatment effect of a binary treatment: `m = f(1, X) − f(0, X)`.
95#[derive(Debug, Clone, Copy, Default)]
96pub struct Ate;
97
98impl LinearFunctional for Ate {
99    fn name(&self) -> String {
100        "ATE".into()
101    }
102
103    fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>> {
104        if w.column(0).iter().any(|&d| d != 0.0 && d != 1.0) {
105            return Err(DmlError::InvalidArgument(
106                "the ATE needs a 0/1 treatment".into(),
107            ));
108        }
109        Ok(vec![
110            Term::constant(with_treatment(w, |_, _| 1.0), 1.0),
111            Term::constant(with_treatment(w, |_, _| 0.0), -1.0),
112        ])
113    }
114}
115
116/// Average derivative `E[∂γ(D, X)/∂D]` of a continuous treatment, by a central
117/// difference with step `h` (default `10⁻⁴` times the standard deviation of
118/// the treatment). Needs a regression that is smooth in the treatment (linear
119/// models, polynomials, neural networks); for trees use [`ShiftEffect`].
120#[derive(Debug, Clone, Copy, Default)]
121pub struct AverageDerivative {
122    pub step: Option<f64>,
123}
124
125impl AverageDerivative {
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    pub fn step(mut self, h: f64) -> Self {
131        self.step = Some(h);
132        self
133    }
134}
135
136impl LinearFunctional for AverageDerivative {
137    fn name(&self) -> String {
138        "average derivative".into()
139    }
140
141    fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>> {
142        let h = match self.step {
143            Some(h) => h,
144            None => 1e-4 * std_dev(w.column(0)),
145        };
146        if !(h.is_finite() && h > 0.0) {
147            return Err(DmlError::InvalidArgument(
148                "the derivative step must be positive (is the treatment constant?)".into(),
149            ));
150        }
151        Ok(vec![
152            Term::constant(with_treatment(w, |_, d| d + h), 0.5 / h),
153            Term::constant(with_treatment(w, |_, d| d - h), -0.5 / h),
154        ])
155    }
156}
157
158/// Average effect of shifting the treatment by `delta`: `m = f(D + δ, X) −
159/// f(D, X)`, divided by `δ` with [`per_unit`](Self::per_unit).
160#[derive(Debug, Clone, Copy)]
161pub struct ShiftEffect {
162    pub delta: f64,
163    pub per_unit: bool,
164}
165
166impl ShiftEffect {
167    pub fn new(delta: f64) -> Self {
168        Self {
169            delta,
170            per_unit: false,
171        }
172    }
173
174    /// Report the effect per unit of treatment (divided by `δ`).
175    pub fn per_unit(mut self, per_unit: bool) -> Self {
176        self.per_unit = per_unit;
177        self
178    }
179}
180
181impl LinearFunctional for ShiftEffect {
182    fn name(&self) -> String {
183        format!("shift effect (δ = {})", self.delta)
184    }
185
186    fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>> {
187        if !(self.delta.is_finite() && self.delta != 0.0) {
188            return Err(DmlError::InvalidArgument(
189                "the shift must be non-zero".into(),
190            ));
191        }
192        let c = if self.per_unit { 1.0 / self.delta } else { 1.0 };
193        let delta = self.delta;
194        Ok(vec![
195            Term::constant(with_treatment(w, |_, d| d + delta), c),
196            Term::constant(w.to_owned(), -c),
197        ])
198    }
199}
200
201/// Average effect of a policy that sets each unit's treatment to a given value:
202/// `m = f(d_new, X) − f(D, X)`.
203#[derive(Debug, Clone)]
204pub struct PolicyEffect {
205    pub treatment: Array1<f64>,
206}
207
208impl PolicyEffect {
209    /// The treatment each unit would receive under the policy.
210    pub fn new(treatment: Array1<f64>) -> Self {
211        Self { treatment }
212    }
213}
214
215impl LinearFunctional for PolicyEffect {
216    fn name(&self) -> String {
217        "policy effect".into()
218    }
219
220    fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>> {
221        if self.treatment.len() != w.nrows() {
222            return Err(DmlError::DimensionMismatch(
223                "the policy needs one treatment value per observation".into(),
224            ));
225        }
226        Ok(vec![
227            Term::constant(with_treatment(w, |i, _| self.treatment[i]), 1.0),
228            Term::constant(w.to_owned(), -1.0),
229        ])
230    }
231}
232
233/// A functional given by a closure returning its terms.
234#[derive(Clone)]
235pub struct CustomFunctional {
236    name: String,
237    #[allow(clippy::type_complexity)]
238    terms: Arc<dyn Fn(ArrayView2<'_, f64>) -> Result<Vec<Term>> + Send + Sync>,
239}
240
241impl CustomFunctional {
242    pub fn new(
243        name: impl Into<String>,
244        terms: impl Fn(ArrayView2<'_, f64>) -> Result<Vec<Term>> + Send + Sync + 'static,
245    ) -> Self {
246        Self {
247            name: name.into(),
248            terms: Arc::new(terms),
249        }
250    }
251}
252
253impl LinearFunctional for CustomFunctional {
254    fn name(&self) -> String {
255        self.name.clone()
256    }
257
258    fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>> {
259        let terms = (self.terms)(w)?;
260        for t in &terms {
261            if t.w.dim() != w.dim() || t.weight.len() != w.nrows() {
262                return Err(DmlError::DimensionMismatch(format!(
263                    "a functional term has shape {:?} and {} weights; expected {:?}",
264                    t.w.dim(),
265                    t.weight.len(),
266                    w.dim()
267                )));
268            }
269        }
270        Ok(terms)
271    }
272}
273
274// ---------------------------------------------------------------------------
275// Riesz representers
276// ---------------------------------------------------------------------------
277
278/// A learner for the Riesz representer `α` of a functional.
279pub trait RieszLearner: Send + Sync {
280    /// Fit on the rows `w` (treatment in column 0) and the functional's terms
281    /// at those rows.
282    fn fit(&mut self, w: ArrayView2<'_, f64>, terms: &[Term]) -> Result<()>;
283
284    /// `α` at the rows `w`.
285    fn predict(&self, w: ArrayView2<'_, f64>) -> Result<Array1<f64>>;
286
287    /// `m(W; α)` for the terms at some rows.
288    fn predict_functional(&self, terms: &[Term]) -> Result<Array1<f64>> {
289        apply_terms(terms, |w| self.predict(w))
290    }
291
292    /// An unfitted copy with the same settings.
293    fn fresh(&self) -> Box<dyn RieszLearner>;
294}
295
296/// Basis functions `b(w)` in which the Riesz representer is approximated.
297#[derive(Clone)]
298pub enum Dictionary {
299    /// `d^a · x^e` for treatment powers `a = 0, …, treatment_degree` and all
300    /// monomials `x^e` of the covariates up to total degree `degree`
301    /// (including the constant).
302    Polynomial {
303        degree: usize,
304        treatment_degree: usize,
305    },
306    /// Any basis: a closure from `w` (treatment in column 0) to the matrix of
307    /// basis functions.
308    #[allow(clippy::type_complexity)]
309    Custom(Arc<dyn Fn(ArrayView2<'_, f64>) -> Result<Array2<f64>> + Send + Sync>),
310}
311
312impl std::fmt::Debug for Dictionary {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        match self {
315            Dictionary::Polynomial {
316                degree,
317                treatment_degree,
318            } => write!(
319                f,
320                "Polynomial {{ degree: {degree}, treatment_degree: {treatment_degree} }}"
321            ),
322            Dictionary::Custom(_) => write!(f, "Custom(..)"),
323        }
324    }
325}
326
327impl Default for Dictionary {
328    fn default() -> Self {
329        Dictionary::Polynomial {
330            degree: 2,
331            treatment_degree: 1,
332        }
333    }
334}
335
336/// Exponent vectors of all monomials in `p` variables of total degree ≤ `degree`,
337/// in graded order (constant first).
338fn monomials(p: usize, degree: usize) -> Vec<Vec<usize>> {
339    let mut out = vec![vec![0; p]];
340    let mut last = vec![vec![0; p]];
341    for _ in 0..degree {
342        let mut next = Vec::new();
343        for e in &last {
344            // Increase variables at or after the last non-zero one (no duplicates).
345            let start = e.iter().rposition(|&k| k > 0).unwrap_or(0);
346            for j in start..p {
347                let mut f = e.clone();
348                f[j] += 1;
349                next.push(f);
350            }
351        }
352        out.extend(next.iter().cloned());
353        last = next;
354    }
355    out
356}
357
358impl Dictionary {
359    pub fn custom(
360        basis: impl Fn(ArrayView2<'_, f64>) -> Result<Array2<f64>> + Send + Sync + 'static,
361    ) -> Self {
362        Dictionary::Custom(Arc::new(basis))
363    }
364
365    /// The basis functions at the rows of `w`.
366    pub fn apply(&self, w: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
367        match self {
368            Dictionary::Polynomial {
369                degree,
370                treatment_degree,
371            } => {
372                let (n, cols) = w.dim();
373                if cols == 0 {
374                    return Err(DmlError::InvalidArgument("no treatment column".into()));
375                }
376                let monos = monomials(cols - 1, *degree);
377                let k = monos.len() * (treatment_degree + 1);
378                let mut b = Array2::zeros((n, k));
379                for i in 0..n {
380                    let row = w.row(i);
381                    let mut c = 0;
382                    for a in 0..=*treatment_degree {
383                        let da = row[0].powi(a as i32);
384                        for e in &monos {
385                            let mut v = da;
386                            for (j, &p) in e.iter().enumerate() {
387                                if p > 0 {
388                                    v *= row[j + 1].powi(p as i32);
389                                }
390                            }
391                            b[[i, c]] = v;
392                            c += 1;
393                        }
394                    }
395                }
396                Ok(b)
397            }
398            Dictionary::Custom(f) => {
399                let b = f(w)?;
400                if b.nrows() != w.nrows() {
401                    return Err(DmlError::DimensionMismatch(
402                        "the dictionary must return one row per observation".into(),
403                    ));
404                }
405                Ok(b)
406            }
407        }
408    }
409}
410
411/// How [`RieszLasso`] chooses its penalty.
412#[derive(Debug, Clone, Copy, PartialEq)]
413pub enum RieszPenalty {
414    /// Cross-validation on the Riesz loss over a grid of penalties.
415    CrossValidated,
416    /// A fixed penalty `r` (0: unpenalised minimum distance).
417    Fixed(f64),
418}
419
420/// The Lasso Riesz representer of Chernozhukov, Newey and Singh (2022):
421/// `α(w) = b(w)'ρ` with
422///
423/// ```text
424/// ρ = argmin  ρ'Ĝρ − 2M̂'ρ + 2r Σ_j ψ_j |ρ_j|,   Ĝ = E_n[b b'],  M̂ = E_n[m(W; b)],
425/// ```
426///
427/// the sample Riesz loss with a weighted ℓ1 penalty (`ψ_j = √Ĝ_jj`, constant
428/// basis functions unpenalised). The penalty is chosen by `cv`-fold
429/// cross-validation of the Riesz loss over `n_alphas` values from the smallest
430/// penalty that sets every coefficient to zero down to `eps` times it.
431#[derive(Debug, Clone)]
432pub struct RieszLasso {
433    dictionary: Dictionary,
434    penalty: RieszPenalty,
435    n_alphas: usize,
436    eps: f64,
437    cv: usize,
438    tol: f64,
439    max_iter: usize,
440    rho: Option<Array1<f64>>,
441    chosen: Option<f64>,
442}
443
444impl Default for RieszLasso {
445    fn default() -> Self {
446        Self {
447            dictionary: Dictionary::default(),
448            penalty: RieszPenalty::CrossValidated,
449            n_alphas: 30,
450            eps: 1e-3,
451            cv: 5,
452            tol: 1e-10,
453            max_iter: 100_000,
454            rho: None,
455            chosen: None,
456        }
457    }
458}
459
460/// Sample moments of the Riesz loss: `G = B'B / n`, `M = mean of m(W; b)`.
461struct Moments {
462    g: Array2<f64>,
463    m: Array1<f64>,
464}
465
466impl Moments {
467    fn new(b: ArrayView2<'_, f64>, mb: ArrayView2<'_, f64>) -> Self {
468        let n = b.nrows().max(1) as f64;
469        Self {
470            g: b.t().dot(&b) / n,
471            m: mb
472                .mean_axis(Axis(0))
473                .unwrap_or_else(|| Array1::zeros(b.ncols())),
474        }
475    }
476
477    /// Riesz loss `ρ'Gρ − 2M'ρ` (the sample mean of `α² − 2m(W; α)`).
478    fn loss(&self, rho: &Array1<f64>) -> f64 {
479        rho.dot(&self.g.dot(rho)) - 2.0 * self.m.dot(rho)
480    }
481}
482
483impl RieszLasso {
484    pub fn new() -> Self {
485        Self::default()
486    }
487
488    pub fn dictionary(mut self, dictionary: Dictionary) -> Self {
489        self.dictionary = dictionary;
490        self
491    }
492
493    pub fn penalty(mut self, penalty: RieszPenalty) -> Self {
494        self.penalty = penalty;
495        self
496    }
497
498    /// Number of penalties on the cross-validation grid (default 30).
499    pub fn n_alphas(mut self, n_alphas: usize) -> Self {
500        self.n_alphas = n_alphas;
501        self
502    }
503
504    /// Smallest penalty on the grid relative to the largest (default `1e-3`).
505    pub fn eps(mut self, eps: f64) -> Self {
506        self.eps = eps;
507        self
508    }
509
510    /// Cross-validation folds (default 5; observations are assigned in turn).
511    pub fn cv(mut self, cv: usize) -> Self {
512        self.cv = cv;
513        self
514    }
515
516    /// Coefficients `ρ` after fitting.
517    pub fn coef(&self) -> Option<&Array1<f64>> {
518        self.rho.as_ref()
519    }
520
521    /// The penalty used in the last fit.
522    pub fn alpha(&self) -> Option<f64> {
523        self.chosen
524    }
525
526    /// `B = b(w)` and `MB = Σ_k c_k ⊙ b(w_k)` for some rows.
527    fn design(&self, w: ArrayView2<'_, f64>, terms: &[Term]) -> Result<(Array2<f64>, Array2<f64>)> {
528        let b = self.dictionary.apply(w)?;
529        let mut mb = Array2::zeros(b.dim());
530        for t in terms {
531            let bk = self.dictionary.apply(t.w.view())?;
532            if bk.dim() != b.dim() {
533                return Err(DmlError::DimensionMismatch(
534                    "dictionary at transformed points".into(),
535                ));
536            }
537            mb += &(&bk * &t.weight.view().insert_axis(Axis(1)));
538        }
539        Ok((b, mb))
540    }
541
542    /// Penalty loadings `√G_jj`, zero for constant basis functions.
543    fn loadings(b: ArrayView2<'_, f64>, g: &Array2<f64>) -> Array1<f64> {
544        (0..b.ncols())
545            .map(|j| {
546                let col = b.column(j);
547                let first = col.first().copied().unwrap_or(0.0);
548                if col.iter().all(|&v| v == first) {
549                    0.0
550                } else {
551                    g[[j, j]].sqrt()
552                }
553            })
554            .collect()
555    }
556
557    /// Coordinate descent for `ρ'Gρ − 2M'ρ + 2r Σ ψ_j|ρ_j|` from `rho`.
558    fn solve(&self, mom: &Moments, psi: &Array1<f64>, r: f64, rho: &mut Array1<f64>) -> Result<()> {
559        let p = rho.len();
560        let mut grho = mom.g.dot(rho);
561        let scale = mom
562            .g
563            .diag()
564            .iter()
565            .fold(0.0_f64, |a, &v| a.max(v))
566            .max(1e-300);
567        for _ in 0..self.max_iter {
568            let mut max_step = 0.0_f64;
569            for j in 0..p {
570                let gjj = mom.g[[j, j]];
571                if gjj <= 0.0 {
572                    continue;
573                }
574                let z = mom.m[j] - (grho[j] - gjj * rho[j]);
575                // Unpenalised coordinates (ψ_j = 0) have no threshold, also for r = ∞.
576                let thr = if psi[j] > 0.0 { r * psi[j] } else { 0.0 };
577                let new = if z > thr {
578                    (z - thr) / gjj
579                } else if z < -thr {
580                    (z + thr) / gjj
581                } else {
582                    0.0
583                };
584                let delta = new - rho[j];
585                if delta != 0.0 {
586                    grho.scaled_add(delta, &mom.g.column(j));
587                    rho[j] = new;
588                    max_step = max_step.max(delta.abs() * gjj.sqrt());
589                }
590            }
591            if max_step <= self.tol * scale.sqrt().max(1.0) {
592                return Ok(());
593            }
594        }
595        Err(DmlError::InvalidArgument(
596            "the Riesz Lasso did not converge; increase the penalty or simplify the dictionary"
597                .into(),
598        ))
599    }
600
601    /// Unpenalised coefficients alone (penalty above `r_max`), and `r_max`.
602    fn null_fit(&self, mom: &Moments, psi: &Array1<f64>) -> Result<(Array1<f64>, f64)> {
603        let mut rho = Array1::zeros(psi.len());
604        self.solve(mom, psi, f64::INFINITY, &mut rho)?;
605        let grad = &mom.m - &mom.g.dot(&rho);
606        let r_max = (0..psi.len())
607            .filter(|&j| psi[j] > 0.0)
608            .map(|j| grad[j].abs() / psi[j])
609            .fold(0.0_f64, f64::max);
610        Ok((rho, r_max))
611    }
612
613    fn grid(&self, r_max: f64) -> Vec<f64> {
614        if r_max <= 0.0 {
615            return vec![0.0];
616        }
617        let k = self.n_alphas.max(1);
618        (0..k)
619            .map(|i| {
620                let t = if k == 1 {
621                    0.0
622                } else {
623                    i as f64 / (k - 1) as f64
624                };
625                r_max * self.eps.powf(t)
626            })
627            .collect()
628    }
629}
630
631impl RieszLearner for RieszLasso {
632    fn fit(&mut self, w: ArrayView2<'_, f64>, terms: &[Term]) -> Result<()> {
633        let (b, mb) = self.design(w, terms)?;
634        let n = b.nrows();
635        let mom = Moments::new(b.view(), mb.view());
636        let psi = Self::loadings(b.view(), &mom.g);
637        let r = match self.penalty {
638            RieszPenalty::Fixed(r) => {
639                if r.is_nan() || r < 0.0 {
640                    return Err(DmlError::InvalidArgument(
641                        "the penalty must be non-negative".into(),
642                    ));
643                }
644                r
645            }
646            RieszPenalty::CrossValidated => {
647                let (_, r_max) = self.null_fit(&mom, &psi)?;
648                let grid = self.grid(r_max);
649                let k = self.cv.max(2).min(n);
650                let mut cv_loss = vec![0.0; grid.len()];
651                for fold in 0..k {
652                    let train: Vec<usize> = (0..n).filter(|i| i % k != fold).collect();
653                    let test: Vec<usize> = (0..n).filter(|i| i % k == fold).collect();
654                    let tr = Moments::new(
655                        b.select(Axis(0), &train).view(),
656                        mb.select(Axis(0), &train).view(),
657                    );
658                    let te = Moments::new(
659                        b.select(Axis(0), &test).view(),
660                        mb.select(Axis(0), &test).view(),
661                    );
662                    let psi_tr = Self::loadings(b.select(Axis(0), &train).view(), &tr.g);
663                    let mut rho = Array1::zeros(psi.len());
664                    for (loss, &r) in cv_loss.iter_mut().zip(&grid) {
665                        self.solve(&tr, &psi_tr, r, &mut rho)?;
666                        *loss += te.loss(&rho) * test.len() as f64 / n as f64;
667                    }
668                }
669                let best = cv_loss
670                    .iter()
671                    .enumerate()
672                    .fold(
673                        (0, f64::INFINITY),
674                        |acc, (i, &l)| if l < acc.1 { (i, l) } else { acc },
675                    )
676                    .0;
677                grid[best]
678            }
679        };
680        // Warm path down to the chosen penalty.
681        let (mut rho, r_max) = self.null_fit(&mom, &psi)?;
682        for &rr in self.grid(r_max).iter().filter(|&&g| g > r) {
683            self.solve(&mom, &psi, rr, &mut rho)?;
684        }
685        self.solve(&mom, &psi, r, &mut rho)?;
686        self.rho = Some(rho);
687        self.chosen = Some(r);
688        Ok(())
689    }
690
691    fn predict(&self, w: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
692        let rho = self
693            .rho
694            .as_ref()
695            .ok_or_else(|| DmlError::InvalidArgument("RieszLasso is not fitted".into()))?;
696        let b = self.dictionary.apply(w)?;
697        if b.ncols() != rho.len() {
698            return Err(DmlError::DimensionMismatch(
699                "dictionary size changed".into(),
700            ));
701        }
702        Ok(b.dot(rho))
703    }
704
705    fn fresh(&self) -> Box<dyn RieszLearner> {
706        Box::new(Self {
707            rho: None,
708            chosen: None,
709            ..self.clone()
710        })
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use ndarray::array;
718
719    #[test]
720    fn monomials_count_and_order() {
721        let m = monomials(3, 2);
722        assert_eq!(m.len(), 10); // C(3 + 2, 2)
723        assert_eq!(m[0], vec![0, 0, 0]);
724        assert_eq!(m[1], vec![1, 0, 0]);
725        assert!(m.contains(&vec![0, 1, 1]));
726        let d = Dictionary::Polynomial {
727            degree: 1,
728            treatment_degree: 1,
729        };
730        let b = d.apply(array![[2.0, 3.0, 5.0]].view()).unwrap();
731        assert_eq!(b, array![[1.0, 3.0, 5.0, 2.0, 6.0, 10.0]]);
732    }
733
734    #[test]
735    fn unpenalised_fit_solves_the_normal_equations() {
736        // r = 0: ρ = G⁻¹ M.
737        let n = 200;
738        let w = Array2::from_shape_fn((n, 2), |(i, j)| {
739            if j == 0 {
740                ((i * 7) % 5) as f64 / 2.0
741            } else {
742                ((i * 3) % 11) as f64 / 5.0
743            }
744        });
745        let functional = ShiftEffect::new(0.5);
746        let terms = functional.terms(w.view()).unwrap();
747        let mut lasso = RieszLasso::new()
748            .dictionary(Dictionary::Polynomial {
749                degree: 1,
750                treatment_degree: 1,
751            })
752            .penalty(RieszPenalty::Fixed(0.0));
753        lasso.fit(w.view(), &terms).unwrap();
754        let (b, mb) = lasso.design(w.view(), &terms).unwrap();
755        let mom = Moments::new(b.view(), mb.view());
756        let rho = lasso.coef().unwrap();
757        let resid = mom.g.dot(rho) - &mom.m;
758        assert!(resid.iter().all(|v| v.abs() < 1e-8), "{resid}");
759    }
760
761    #[test]
762    fn saturated_ate_representer_is_the_inverse_propensity_weight() {
763        // Discrete X with cell dummies interacted with D: the unpenalised Riesz
764        // representer is D / π(x) − (1 − D) / (1 − π(x)) with cell shares π.
765        let n = 300;
766        let w = Array2::from_shape_fn((n, 2), |(i, j)| {
767            if j == 0 {
768                f64::from(((i * 7 + i / 3) % 5) < 2 + (i % 3) % 2)
769            } else {
770                (i % 3) as f64
771            }
772        });
773        let dict = Dictionary::custom(|w: ArrayView2<'_, f64>| {
774            Ok(Array2::from_shape_fn((w.nrows(), 6), |(i, k)| {
775                let (d, x) = (w[[i, 0]], w[[i, 1]]);
776                let arm = if k < 3 { d } else { 1.0 - d };
777                if x == (k % 3) as f64 {
778                    arm
779                } else {
780                    0.0
781                }
782            }))
783        });
784        let terms = Ate.terms(w.view()).unwrap();
785        let mut lasso = RieszLasso::new()
786            .dictionary(dict)
787            .penalty(RieszPenalty::Fixed(0.0));
788        lasso.fit(w.view(), &terms).unwrap();
789        let alpha = lasso.predict(w.view()).unwrap();
790        for c in 0..3 {
791            let rows: Vec<usize> = (0..n).filter(|&i| w[[i, 1]] == c as f64).collect();
792            let pi = rows.iter().map(|&i| w[[i, 0]]).sum::<f64>() / rows.len() as f64;
793            for &i in &rows {
794                let d = w[[i, 0]];
795                let expected = d / pi - (1.0 - d) / (1.0 - pi);
796                assert!(
797                    (alpha[i] - expected).abs() < 1e-8,
798                    "{} vs {expected}",
799                    alpha[i]
800                );
801            }
802        }
803    }
804
805    #[test]
806    fn cross_validated_penalty_is_on_the_grid_and_predicts_m() {
807        let n = 400;
808        let w = Array2::from_shape_fn((n, 3), |(i, j)| {
809            (((i + 1) * (j + 3) * 7919) % 1000) as f64 / 500.0 - 1.0
810        });
811        let terms = AverageDerivative::new().terms(w.view()).unwrap();
812        let mut lasso = RieszLasso::new();
813        lasso.fit(w.view(), &terms).unwrap();
814        assert!(lasso.alpha().unwrap() >= 0.0);
815        let m_alpha = lasso.predict_functional(&terms).unwrap();
816        assert_eq!(m_alpha.len(), n);
817        assert!(m_alpha.iter().all(|v| v.is_finite()));
818    }
819
820    #[test]
821    fn functional_terms() {
822        let w = array![[0.0, 1.0], [1.0, 2.0]];
823        let f = |w: ArrayView2<'_, f64>| Ok(w.column(0).mapv(|d| 3.0 * d) + w.column(1));
824        assert_eq!(
825            apply_terms(&Ate.terms(w.view()).unwrap(), f).unwrap(),
826            array![3.0, 3.0]
827        );
828        let shift = ShiftEffect::new(2.0).per_unit(true);
829        assert_eq!(
830            apply_terms(&shift.terms(w.view()).unwrap(), f).unwrap(),
831            array![3.0, 3.0]
832        );
833        let deriv = AverageDerivative::new().step(0.1);
834        let v = apply_terms(&deriv.terms(w.view()).unwrap(), f).unwrap();
835        assert!((v[0] - 3.0).abs() < 1e-12);
836        let policy = PolicyEffect::new(array![1.0, 1.0]);
837        assert_eq!(
838            apply_terms(&policy.terms(w.view()).unwrap(), f).unwrap(),
839            array![3.0, 0.0]
840        );
841        assert!(Ate.terms(array![[0.5, 1.0]].view()).is_err());
842    }
843}