Skip to main content

solow_robust/
rlm.rs

1//! Robust linear regression by iteratively reweighted least squares (IRLS).
2//!
3//! [`Rlm`] mirrors the reference's robust linear model. The estimator minimizes
4//! `Σ ρ((yᵢ − xᵢ·β) / σ)` for a robust norm `ρ` by repeatedly solving a weighted
5//! least-squares problem with weights `w = ψ(z)/z`, re-estimating the scale `σ`
6//! at every step (`update_scale = true`).
7
8use ndarray::{Array1, Array2};
9use solow_core::error::{Error, Result};
10use solow_core::tools::{ensure_all_finite, ensure_all_finite_2d};
11use solow_distributions::norm_sf;
12use solow_linalg::{matrix_rank, pinv};
13
14use crate::norms::RobustNorm;
15use crate::scale::{mad, mad_c, HuberScale};
16
17/// The scale estimator used to standardize residuals between IRLS steps.
18#[derive(Clone, Copy, Debug, Default)]
19pub enum ScaleEst {
20    /// Median absolute deviation about zero (`scale_est='mad'`), the default.
21    #[default]
22    Mad,
23    /// Huber's proposal-2 scale (`scale_est=HuberScale()`).
24    Huber(HuberScale),
25}
26
27/// Convergence criterion for the IRLS loop.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum Conv {
30    /// The un-normalized M-estimator objective `Σ ρ(z)` (`conv='dev'`), default.
31    #[default]
32    Deviance,
33    /// The estimated coefficients (`conv='coefs'`).
34    Coefs,
35}
36
37/// A robust linear model awaiting estimation.
38///
39/// Construct with [`Rlm::new`], optionally configure the fit, and call
40/// [`Rlm::fit`].
41#[derive(Clone, Debug)]
42pub struct Rlm<N: RobustNorm> {
43    endog: Array1<f64>,
44    exog: Array2<f64>,
45    norm: N,
46    scale_est: ScaleEst,
47    conv: Conv,
48    update_scale: bool,
49    maxiter: usize,
50    tol: f64,
51}
52
53impl<N: RobustNorm> Rlm<N> {
54    /// A robust model with `endog = y`, `exog = X`, and criterion `norm`.
55    ///
56    /// Defaults match the reference: `scale_est='mad'`, `conv='dev'`,
57    /// `update_scale=true`, `maxiter=50`, `tol=1e-8`.
58    pub fn new(endog: Array1<f64>, exog: Array2<f64>, norm: N) -> Result<Self> {
59        if endog.len() != exog.nrows() {
60            return Err(Error::Shape("endog length != exog rows".into()));
61        }
62        ensure_all_finite(&endog.view(), "endog")?;
63        ensure_all_finite_2d(&exog.view(), "exog")?;
64        Ok(Rlm {
65            endog,
66            exog,
67            norm,
68            scale_est: ScaleEst::Mad,
69            conv: Conv::Deviance,
70            update_scale: true,
71            maxiter: 50,
72            tol: 1e-8,
73        })
74    }
75
76    /// Select the scale estimator (default [`ScaleEst::Mad`]).
77    pub fn scale_est(mut self, s: ScaleEst) -> Self {
78        self.scale_est = s;
79        self
80    }
81
82    /// Select the convergence criterion (default [`Conv::Deviance`]).
83    pub fn conv(mut self, c: Conv) -> Self {
84        self.conv = c;
85        self
86    }
87
88    /// Whether to re-estimate the scale every iteration (default `true`).
89    pub fn update_scale(mut self, u: bool) -> Self {
90        self.update_scale = u;
91        self
92    }
93
94    /// Maximum number of IRLS iterations (default `50`).
95    pub fn maxiter(mut self, m: usize) -> Self {
96        self.maxiter = m;
97        self
98    }
99
100    /// Convergence tolerance on the criterion (default `1e-8`).
101    pub fn tol(mut self, t: f64) -> Self {
102        self.tol = t;
103        self
104    }
105
106    /// Number of observations.
107    pub fn nobs(&self) -> usize {
108        self.endog.len()
109    }
110
111    /// Weighted least squares: `params = pinv(√w · X) · (√w · y)`.
112    ///
113    /// Residuals are formed from the original (unweighted) data, matching the
114    /// reference's `_MinimalWLS`.
115    fn wls(&self, weights: &Array1<f64>) -> Result<(Array1<f64>, Array1<f64>)> {
116        let (n, p) = self.exog.dim();
117        let mut wexog = Array2::<f64>::zeros((n, p));
118        let mut wy = Array1::<f64>::zeros(n);
119        for i in 0..n {
120            let s = weights[i].sqrt();
121            wy[i] = self.endog[i] * s;
122            for j in 0..p {
123                wexog[[i, j]] = self.exog[[i, j]] * s;
124            }
125        }
126        let (pinv_w, _sv) = pinv(&wexog)?;
127        let params = pinv_w.dot(&wy);
128        let resid = &self.endog - &self.exog.dot(&params);
129        Ok((params, resid))
130    }
131
132    fn estimate_scale(&self, resid: &[f64], df_resid: f64, nobs: f64) -> f64 {
133        match self.scale_est {
134            ScaleEst::Mad => mad(resid, mad_c(), Some(0.0)),
135            ScaleEst::Huber(h) => h.scale(df_resid, nobs, resid),
136        }
137    }
138
139    /// Estimate the model by IRLS.
140    pub fn fit(&self) -> Result<RlmResults> {
141        let (n, _p) = self.exog.dim();
142        let nobs = n as f64;
143        let rank = matrix_rank(&self.exog)?;
144        let df_resid = nobs - rank as f64;
145        let df_model = rank as f64 - 1.0;
146
147        // normalized_cov_params = pinv(X) · pinv(X)^T  (≈ (XᵀX)⁻¹).
148        let (pinv_x, _sv) = pinv(&self.exog)?;
149        let normalized_cov_params = pinv_x.dot(&pinv_x.t());
150
151        // Start from OLS (WLS with unit weights).
152        let ones = Array1::<f64>::ones(n);
153        let (mut params, mut resid) = self.wls(&ones)?;
154        let mut scale = self.estimate_scale(
155            resid
156                .as_slice()
157                .ok_or_else(|| Error::Value("resid must be contiguous".into()))?,
158            df_resid,
159            nobs,
160        );
161
162        // Convergence history for the deviance criterion (the default).
163        let mut crit_cur = self.deviance(&resid, scale);
164        let mut weights = Array1::<f64>::ones(n);
165        let mut iteration = 1usize;
166        let mut converged = false;
167
168        while iteration < self.maxiter {
169            if scale == 0.0 {
170                break;
171            }
172            // Robust weights from the current standardized residuals.
173            weights = resid.mapv(|r| self.norm.weights(r / scale));
174            let params_prev = params.clone();
175            let (new_params, new_resid) = self.wls(&weights)?;
176            params = new_params;
177            resid = new_resid;
178            if self.update_scale {
179                scale = self.estimate_scale(
180                    resid
181                        .as_slice()
182                        .ok_or_else(|| Error::Value("resid must be contiguous".into()))?,
183                    df_resid,
184                    nobs,
185                );
186            }
187            iteration += 1;
188
189            // Convergence test mirrors `_check_convergence`.
190            match self.conv {
191                Conv::Deviance => {
192                    let crit_prev = crit_cur;
193                    crit_cur = self.deviance(&resid, scale);
194                    if (crit_cur - crit_prev).abs() <= self.tol {
195                        converged = true;
196                        break;
197                    }
198                }
199                Conv::Coefs => {
200                    let d = (&params - &params_prev).mapv(f64::abs);
201                    let maxd = d.iter().cloned().fold(0.0_f64, f64::max);
202                    if maxd <= self.tol {
203                        converged = true;
204                        break;
205                    }
206                }
207            }
208        }
209
210        Ok(RlmResults::new(
211            self,
212            params,
213            resid,
214            weights,
215            scale,
216            normalized_cov_params,
217            df_model,
218            df_resid,
219            nobs,
220            iteration,
221            converged,
222        ))
223    }
224
225    /// The (un-normalized) M-estimator objective `Σ ρ(resid / scale)`.
226    fn deviance(&self, resid: &Array1<f64>, scale: f64) -> f64 {
227        resid.iter().map(|&r| self.norm.rho(r / scale)).sum()
228    }
229}
230
231/// The fitted result of an [`Rlm`].
232#[derive(Clone, Debug)]
233pub struct RlmResults {
234    /// Estimated coefficients.
235    pub params: Array1<f64>,
236    /// Robust standard errors (from the scaled covariance, cov type `H1`).
237    pub bse: Array1<f64>,
238    /// `params / bse`, treated as standard normal.
239    pub tvalues: Array1<f64>,
240    /// Two-sided p-values from the normal distribution.
241    pub pvalues: Array1<f64>,
242
243    /// Final robust scale estimate.
244    pub scale: f64,
245    /// Fitted values `X · params`.
246    pub fittedvalues: Array1<f64>,
247    /// Residuals `y − fittedvalues`.
248    pub resid: Array1<f64>,
249    /// Standardized residuals `resid / scale`.
250    pub sresid: Array1<f64>,
251    /// Robust IRLS weights from the final standardized residuals.
252    pub weights: Array1<f64>,
253
254    /// Model degrees of freedom `rank − 1`.
255    pub df_model: f64,
256    /// Residual degrees of freedom `nobs − rank`.
257    pub df_resid: f64,
258    /// Number of observations.
259    pub nobs: f64,
260
261    /// Scaled coefficient covariance matrix (cov type `H1`).
262    pub bcov_scaled: Array2<f64>,
263    /// Unscaled covariance `normalized_cov_params ≈ (XᵀX)⁻¹`.
264    pub bcov_unscaled: Array2<f64>,
265
266    /// Number of IRLS iterations performed.
267    pub iteration: usize,
268    /// Whether the IRLS loop converged within `maxiter`.
269    pub converged: bool,
270}
271
272impl RlmResults {
273    #[allow(clippy::too_many_arguments)]
274    fn new<N: RobustNorm>(
275        model: &Rlm<N>,
276        params: Array1<f64>,
277        resid: Array1<f64>,
278        weights: Array1<f64>,
279        scale: f64,
280        normalized_cov_params: Array2<f64>,
281        df_model: f64,
282        df_resid: f64,
283        nobs: f64,
284        iteration: usize,
285        converged: bool,
286    ) -> RlmResults {
287        let fittedvalues = model.exog.dot(&params);
288        let sresid: Array1<f64> = if scale == 0.0 {
289            Array1::zeros(resid.len())
290        } else {
291            resid.mapv(|r| r / scale)
292        };
293
294        // Robust covariance, cov type "H1":
295        //   k² · (Σψ²/df_resid · scale²) / ((Σψ'/nobs)²) · normalized_cov_params
296        // with k = 1 + (df_model+1)/nobs · var(ψ')/mean(ψ')².
297        // SAFETY: owned contiguous array (`sresid` from `mapv`/`zeros`).
298        let psi_d: Vec<f64> = model.norm.psi_deriv_arr(sresid.as_slice().unwrap_or(&[]));
299        let m = mean(&psi_d);
300        let var_psiprime = variance(&psi_d, m);
301        let k = 1.0 + (df_model + 1.0) / nobs * var_psiprime / (m * m);
302
303        // SAFETY: owned contiguous array (see above).
304        let psi: Vec<f64> = model.norm.psi_arr(sresid.as_slice().unwrap_or(&[]));
305        let ss_psi: f64 = psi.iter().map(|&v| v * v).sum();
306        let s_psi_deriv: f64 = psi_d.iter().sum();
307
308        let factor = k * k * (ss_psi * scale * scale / df_resid) / ((s_psi_deriv / nobs).powi(2));
309        let bcov_scaled = &normalized_cov_params * factor;
310
311        let p = params.len();
312        let mut bse = Array1::<f64>::zeros(p);
313        for i in 0..p {
314            bse[i] = bcov_scaled[[i, i]].sqrt();
315        }
316        let tvalues = &params / &bse;
317        let pvalues = tvalues.mapv(|z| 2.0 * norm_sf(z.abs()));
318
319        RlmResults {
320            params,
321            bse,
322            tvalues,
323            pvalues,
324            scale,
325            fittedvalues,
326            resid,
327            sresid,
328            weights,
329            df_model,
330            df_resid,
331            nobs,
332            bcov_scaled,
333            bcov_unscaled: normalized_cov_params,
334            iteration,
335            converged,
336        }
337    }
338
339    /// Two-sided confidence intervals for the coefficients at level `alpha`.
340    ///
341    /// Uses normal critical values (`tvalues` are treated as standard normal),
342    /// matching the reference's RLM result.
343    pub fn conf_int(&self, alpha: f64) -> Array2<f64> {
344        let q = solow_distributions::norm_ppf(1.0 - alpha / 2.0);
345        let p = self.params.len();
346        let mut ci = Array2::<f64>::zeros((p, 2));
347        for i in 0..p {
348            ci[[i, 0]] = self.params[i] - q * self.bse[i];
349            ci[[i, 1]] = self.params[i] + q * self.bse[i];
350        }
351        ci
352    }
353}
354
355fn mean(a: &[f64]) -> f64 {
356    a.iter().sum::<f64>() / a.len() as f64
357}
358
359/// Population variance (ddof = 0), matching `numpy.var`.
360fn variance(a: &[f64], m: f64) -> f64 {
361    a.iter().map(|&v| (v - m) * (v - m)).sum::<f64>() / a.len() as f64
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::norms::LeastSquares;
368    use ndarray::array;
369
370    #[test]
371    fn least_squares_norm_reduces_to_ols() {
372        // With the LeastSquares norm every weight is 1, so RLM == OLS.
373        let y = array![1.0, 2.0, 2.0, 3.0, 5.0, 4.0];
374        let x = array![
375            [1.0, 0.0],
376            [1.0, 1.0],
377            [1.0, 2.0],
378            [1.0, 3.0],
379            [1.0, 4.0],
380            [1.0, 5.0]
381        ];
382        let res = Rlm::new(y.clone(), x.clone(), LeastSquares)
383            .unwrap()
384            .fit()
385            .unwrap();
386        // Closed-form OLS via normal equations.
387        let xtx = x.t().dot(&x);
388        let (inv, _sv) = pinv(&xtx).unwrap();
389        let ols = inv.dot(&x.t().dot(&y));
390        for i in 0..2 {
391            assert!((res.params[i] - ols[i]).abs() < 1e-9);
392        }
393        assert!(res.weights.iter().all(|&w| (w - 1.0).abs() < 1e-15));
394    }
395
396    #[test]
397    fn degrees_of_freedom() {
398        let y = array![1.0, 2.0, 3.0, 4.0, 5.0];
399        let x = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0], [1.0, 4.0]];
400        let res = Rlm::new(y, x, LeastSquares).unwrap().fit().unwrap();
401        assert_eq!(res.nobs, 5.0);
402        assert_eq!(res.df_model, 1.0);
403        assert_eq!(res.df_resid, 3.0);
404    }
405}