Skip to main content

tsorder_core/
robust.rs

1//! Robust scale and autocovariance estimation.
2//!
3//! * [`qn`] — the Q_n scale estimator of Rousseeuw & Croux (1993), computed by
4//!   the O(n log n) algorithm of Croux & Rousseeuw (1992). Conventions
5//!   (consistency constant and finite-sample corrections) follow R's
6//!   `robustbase::Qn`, so the two agree to full double precision; note the
7//!   published papers print the constant as 2.2219, a digit transposition of
8//!   the correct 1/(sqrt(2) Φ⁻¹(5/8)) = 2.21914… (Ma & Genton 2000 print
9//!   2.2191).
10//! * [`robust_autocovariance`] — the Q_n-based autocovariance of Ma & Genton
11//!   (2000, eq. 4): `γ̂_Q(h) = ¼ [Q²_{n−h}(u+v) − Q²_{n−h}(u−v)]` with
12//!   `u = (X_1..X_{n−h})`, `v = (X_{h+1}..X_n)`. Breakdown point 25 %, the
13//!   maximum possible for an autocovariance estimator.
14//! * [`shrink_to_psd`] — γ̂_Q is not guaranteed positive semi-definite; this
15//!   applies the shrinking repair (Rousseeuw & Molenberghs 1993): scale the
16//!   off-diagonal lags by the largest `1−λ` that makes the Toeplitz matrix
17//!   PSD (checked by Cholesky, found by bisection). λ = 0 on clean data.
18
19use crate::linalg::SquareMatrix;
20use crate::{check_input, Error, Result};
21
22/// Consistency constant: 1/(sqrt(2) Φ⁻¹(5/8)), rounded as in `robustbase`.
23const QN_CONSTANT: f64 = 2.21914;
24
25/// Q_n scale estimator with Gaussian consistency constant and the
26/// finite-sample corrections used by `robustbase::Qn` (Croux & Rousseeuw 1992,
27/// updated constants). `Q_n = d_n · 2.21914 · {|x_i − x_j|; i<j}_(k)` with
28/// `k = C(h,2)`, `h = ⌊n/2⌋+1`. Returns 0 for n < 2.
29pub fn qn(x: &[f64]) -> Result<f64> {
30    let n = x.len();
31    if n < 2 {
32        return Ok(0.0);
33    }
34    if x.iter().any(|v| !v.is_finite()) {
35        return Err(Error::NonFinite);
36    }
37    let raw = qn_raw(x);
38    // Finite-sample corrections, robustbase conventions.
39    let corr = match n {
40        2 => 0.399356,
41        3 => 0.99365,
42        4 => 0.51321,
43        5 => 0.84401,
44        6 => 0.6122,
45        7 => 0.85877,
46        8 => 0.66993,
47        9 => 0.87344,
48        10 => 0.72014,
49        11 => 0.88906,
50        12 => 0.75743,
51        _ => {
52            let nf = n as f64;
53            let c = if n % 2 == 1 {
54                1.60188 + (-2.1284 - 5.172 / nf) / nf
55            } else {
56                3.67561 + (1.9654 + (6.987 - 77.0 / nf) / nf) / nf
57            };
58            1.0 / (c / nf + 1.0)
59        }
60    };
61    Ok(corr * QN_CONSTANT * raw)
62}
63
64/// The raw k-th order statistic of the pairwise distances, `k = C(h,2)`,
65/// computed in O(n log n) time and O(n) space (Croux & Rousseeuw 1992, after
66/// Johnson & Mizoguchi 1978): select inside the implicit matrix
67/// `U(i,j) = y_(i) − y_(n+1−j)` of the sorted values, narrowing per-row
68/// candidate windows against weighted-median trial values.
69/// Indices below are 1-based to mirror the published algorithm.
70fn qn_raw(x: &[f64]) -> f64 {
71    let n = x.len();
72    let h = n / 2 + 1;
73    let k = h * (h - 1) / 2;
74    let mut y = vec![0.0]; // y[1..=n] = sorted x
75    {
76        let mut s = x.to_vec();
77        s.sort_unstable_by(f64::total_cmp);
78        y.extend_from_slice(&s);
79    }
80    let mut left = vec![0usize; n + 1];
81    let mut right = vec![0usize; n + 1];
82    for i in 1..=n {
83        left[i] = n - i + 2; // row 1 starts empty (left = n+1)
84        right[i] = n;
85    }
86    let nonpos = n * (n + 1) / 2; // entries on/above the anti-diagonal are <= 0
87    let knew = k + nonpos;
88    let mut nl = nonpos;
89    let mut nr = n * n;
90    let mut work: Vec<f64> = Vec::with_capacity(n);
91    let mut weight: Vec<i64> = Vec::with_capacity(n);
92    let mut p = vec![0usize; n + 1];
93    let mut q = vec![0usize; n + 1];
94    while nr - nl > n {
95        // Trial value: weighted high median of the rows' middle candidates.
96        work.clear();
97        weight.clear();
98        for i in 2..=n {
99            if left[i] <= right[i] {
100                let w = right[i] - left[i] + 1;
101                let jh = left[i] + w / 2;
102                work.push(y[i] - y[n + 1 - jh]);
103                weight.push(w as i64);
104            }
105        }
106        let trial = weighted_high_median(&mut work, &mut weight);
107        // p[i] = #{j : U(i,j) < trial}; staircase merge over the sorted rows,
108        // the j counter persisting across rows (that is what makes it O(n)).
109        let mut j = 0usize;
110        for i in (1..=n).rev() {
111            while j < n && y[i] - y[n - j] < trial {
112                j += 1;
113            }
114            p[i] = j;
115        }
116        // q[i] − 1 = #{j : U(i,j) <= trial}.
117        let mut j = n + 1;
118        for i in 1..=n {
119            while j > 2 && y[i] - y[n + 2 - j] > trial {
120                j -= 1;
121            }
122            q[i] = j;
123        }
124        let sum_p: usize = p[1..].iter().sum();
125        let sum_q: usize = q[1..].iter().map(|v| v - 1).sum();
126        if knew <= sum_p {
127            right[1..=n].copy_from_slice(&p[1..=n]);
128            nr = sum_p;
129        } else if knew > sum_q {
130            left[1..=n].copy_from_slice(&q[1..=n]);
131            nl = sum_q;
132        } else {
133            return trial;
134        }
135    }
136    // Few enough candidates left: gather and select directly.
137    work.clear();
138    for i in 2..=n {
139        for jj in left[i]..=right[i] {
140            work.push(y[i] - y[n - jj + 1]);
141        }
142    }
143    select_kth(&mut work, knew - nl - 1)
144}
145
146/// k-th smallest (0-based) by sorting; candidate sets here are O(n).
147fn select_kth(a: &mut [f64], k: usize) -> f64 {
148    a.sort_unstable_by(f64::total_cmp);
149    a[k]
150}
151
152/// Weighted high median: the smallest `a[j]` such that the total weight of
153/// `{a[i] <= a[j]}` exceeds half the total weight. Consumes its inputs.
154fn weighted_high_median(a: &mut Vec<f64>, w: &mut Vec<i64>) -> f64 {
155    let wtotal: i64 = w.iter().sum();
156    let mut wrest: i64 = 0;
157    loop {
158        let nn = a.len();
159        let mut buf = a.clone();
160        let trial = select_kth(&mut buf, nn / 2); // high median of candidates
161        let mut wleft = 0i64;
162        let mut wmid = 0i64;
163        for (v, wi) in a.iter().zip(w.iter()) {
164            if *v < trial {
165                wleft += wi;
166            } else if *v == trial {
167                wmid += wi;
168            }
169        }
170        if 2 * (wrest + wleft) > wtotal {
171            // Answer lies strictly below the trial.
172            let mut ka = 0;
173            for i in 0..nn {
174                if a[i] < trial {
175                    a[ka] = a[i];
176                    w[ka] = w[i];
177                    ka += 1;
178                }
179            }
180            a.truncate(ka);
181            w.truncate(ka);
182        } else if 2 * (wrest + wleft + wmid) > wtotal {
183            return trial;
184        } else {
185            let mut ka = 0;
186            for i in 0..nn {
187                if a[i] > trial {
188                    a[ka] = a[i];
189                    w[ka] = w[i];
190                    ka += 1;
191                }
192            }
193            a.truncate(ka);
194            w.truncate(ka);
195            wrest += wleft + wmid;
196        }
197    }
198}
199
200/// Ma & Genton (2000, eq. 4) Q_n-based autocovariances for lags `0..=max_lag`:
201/// `γ̂_Q(0) = Q²_n(x)` and `γ̂_Q(h) = ¼ [Q²_{n−h}(u+v) − Q²_{n−h}(u−v)]` with
202/// `u = (X_1..X_{n−h})`, `v = (X_{h+1}..X_n)`. Location-invariant and scale-
203/// equivariant; 25 % temporal breakdown point. **Not** guaranteed PSD — pass
204/// the result through [`shrink_to_psd`] before Levinson/Yule–Walker use.
205///
206/// Caveat observed in simulation: an outlier in `x_t` contaminates the pair
207/// vectors at every lag `h ≥ 1` but only one entry at lag 0, so under additive
208/// outliers the *ratio* `γ̂_Q(h)/γ̂_Q(0)` is biased upward at every lag, which
209/// easily breaks positive definiteness. For autocorrelations prefer
210/// [`robust_autocorrelation`], whose shared denominator cancels that bias.
211pub fn robust_autocovariance(x: &[f64], max_lag: usize) -> Result<Vec<f64>> {
212    check_input(x, max_lag + 2)?;
213    let n = x.len();
214    let mut r = Vec::with_capacity(max_lag + 1);
215    let q0 = qn(x)?;
216    r.push(q0 * q0);
217    let mut s = Vec::with_capacity(n);
218    let mut d = Vec::with_capacity(n);
219    for h in 1..=max_lag {
220        s.clear();
221        d.clear();
222        for i in 0..n - h {
223            s.push(x[i] + x[i + h]);
224            d.push(x[i] - x[i + h]);
225        }
226        let qs = qn(&s)?;
227        let qd = qn(&d)?;
228        r.push(0.25 * (qs * qs - qd * qd));
229    }
230    Ok(r)
231}
232
233/// Ma & Genton's (2000) robust autocorrelations for lags `0..=max_lag`:
234/// `ρ̂_Q(h) = [Q²(u+v) − Q²(u−v)] / [Q²(u+v) + Q²(u−v)]`, bounded in [−1, 1]
235/// by construction and independent of the consistency constant. Numerator and
236/// denominator share the same contaminated pairs, so the additive-outlier bias
237/// that affects `γ̂_Q(h)/γ̂_Q(0)` largely cancels (this is what
238/// [`crate::Statistic::PacfRobust`] feeds into Levinson).
239pub fn robust_autocorrelation(x: &[f64], max_lag: usize) -> Result<Vec<f64>> {
240    check_input(x, max_lag + 2)?;
241    let n = x.len();
242    let mut r = Vec::with_capacity(max_lag + 1);
243    r.push(1.0);
244    let mut s = Vec::with_capacity(n);
245    let mut d = Vec::with_capacity(n);
246    for h in 1..=max_lag {
247        s.clear();
248        d.clear();
249        for i in 0..n - h {
250            s.push(x[i] + x[i + h]);
251            d.push(x[i] - x[i + h]);
252        }
253        let qs = qn(&s)?;
254        let qd = qn(&d)?;
255        let denom = qs * qs + qd * qd;
256        if denom <= 0.0 {
257            return Err(Error::Singular);
258        }
259        r.push((qs * qs - qd * qd) / denom);
260    }
261    Ok(r)
262}
263
264/// Shrinking repair for a possibly non-PSD autocovariance sequence
265/// (Rousseeuw & Molenberghs 1993): return `r'` with `r'(0) = r(0)` and
266/// `r'(h) = (1−λ) r(h)`, λ the smallest value in [0, 1] (up to bisection
267/// tolerance) such that the Toeplitz matrix of `r'` stays positive definite
268/// with a relative ridge margin of `1e-3 · r(0)` — the margin keeps estimated
269/// sequences away from the PD boundary, where Levinson would report spurious
270/// reflection coefficients near ±1. On a sequence that already satisfies the
271/// margin this returns the input unchanged (λ = 0).
272pub fn shrink_to_psd(r: &[f64]) -> Result<Vec<f64>> {
273    if r.is_empty() || r[0] <= 0.0 {
274        return Err(Error::Singular);
275    }
276    let ridge = 1e-3 * r[0];
277    let is_pd = |lam: f64| -> bool {
278        let m = r.len();
279        let mut g = SquareMatrix::zeros(m);
280        for i in 0..m {
281            for j in 0..m {
282                let v = if i == j {
283                    r[0] - ridge
284                } else {
285                    (1.0 - lam) * r[i.abs_diff(j)]
286                };
287                g.set(i, j, v);
288            }
289        }
290        g.cholesky().is_ok()
291    };
292    if is_pd(0.0) {
293        return Ok(r.to_vec());
294    }
295    let (mut lo, mut hi) = (0.0f64, 1.0f64);
296    for _ in 0..40 {
297        let mid = 0.5 * (lo + hi);
298        if is_pd(mid) {
299            hi = mid;
300        } else {
301            lo = mid;
302        }
303    }
304    let lam = hi.min(1.0);
305    let mut out = r.to_vec();
306    for v in out.iter_mut().skip(1) {
307        *v *= 1.0 - lam;
308    }
309    Ok(out)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    #[allow(clippy::excessive_precision)] // literals carry R's full printed precision
318    fn qn_matches_robustbase_fixtures() {
319        // Reference values from R: robustbase::Qn (set.seed(1); rnorm(10)).
320        let x = [
321            -0.62645381074233242,
322            0.18364332422208227,
323            -0.83562861241004716,
324            1.59528080213779155,
325            0.32950777181536051,
326            -0.82046838411801548,
327            0.48742905242848528,
328            0.73832470512921722,
329            0.57578135165349209,
330            -0.30538838715635602,
331        ];
332        let q = qn(&x).unwrap();
333        assert!((q - 0.82314495445682179).abs() < 1e-14, "qn = {q}");
334        // 1:8 — raw order statistic is exactly 2, uncorrected Qn = 2 * 2.21914.
335        let y: Vec<f64> = (1..=8).map(|v| v as f64).collect();
336        assert!((qn_raw(&y) - 2.0).abs() < 1e-15);
337        let q8 = qn(&y).unwrap();
338        assert!((q8 - 2.9733369204).abs() < 1e-9, "qn(1:8) = {q8}");
339        // n = 200 with three gross outliers.
340        let mut z: Vec<f64> = (1..=200).map(|v| v as f64).collect();
341        z[9] = 1000.0;
342        z[49] = -500.0;
343        z[119] = 800.0;
344        let q200 = qn(&z).unwrap();
345        assert!((q200 - 63.190583283547305).abs() < 1e-10, "qn = {q200}");
346    }
347
348    #[test]
349    fn qn_brute_force_agreement_and_equivariance() {
350        // O(n log n) path vs direct order statistic on pseudo-random data.
351        let mut s = 7u64;
352        let x: Vec<f64> = (0..101)
353            .map(|_| {
354                s = s
355                    .wrapping_mul(6364136223846793005)
356                    .wrapping_add(1442695040888963407);
357                (s >> 11) as f64 / (1u64 << 53) as f64
358            })
359            .collect();
360        let n = x.len();
361        let h = n / 2 + 1;
362        let k = h * (h - 1) / 2;
363        let mut dists = Vec::new();
364        for i in 0..n {
365            for j in i + 1..n {
366                dists.push((x[i] - x[j]).abs());
367            }
368        }
369        dists.sort_unstable_by(f64::total_cmp);
370        assert!((qn_raw(&x) - dists[k - 1]).abs() < 1e-15);
371        // Scale equivariance and shift invariance.
372        let y: Vec<f64> = x.iter().map(|v| 3.0 * v + 50.0).collect();
373        let (qx, qy) = (qn(&x).unwrap(), qn(&y).unwrap());
374        assert!((3.0 * qx - qy).abs() < 1e-12 * qy);
375    }
376
377    #[test]
378    fn robust_acov_ignores_gross_outliers() {
379        // AR(1)-ish deterministic check: on clean data γ̂_Q(1)/γ̂_Q(0) should be
380        // positive and sizeable; adding gross outliers must not destroy it.
381        let mut s = 3u64;
382        let mut x = vec![0.0f64; 2000];
383        for t in 1..2000 {
384            s = s
385                .wrapping_mul(6364136223846793005)
386                .wrapping_add(1442695040888963407);
387            let u = (s >> 11) as f64 / (1u64 << 53) as f64 - 0.5;
388            x[t] = 0.7 * x[t - 1] + u;
389        }
390        let clean = robust_autocovariance(&x, 3).unwrap();
391        let rho_clean = clean[1] / clean[0];
392        let mut dirty = x.clone();
393        for t in (0..2000).step_by(97) {
394            dirty[t] += if t % 2 == 0 { 50.0 } else { -50.0 };
395        }
396        let rob = robust_autocovariance(&dirty, 3).unwrap();
397        let rho_rob = rob[1] / rob[0];
398        assert!(
399            (rho_rob - rho_clean).abs() < 0.1,
400            "{rho_clean} vs {rho_rob}"
401        );
402    }
403
404    #[test]
405    fn shrink_leaves_psd_untouched_and_repairs_non_psd() {
406        let ok = [1.0, 0.5, 0.25];
407        assert_eq!(shrink_to_psd(&ok).unwrap(), ok.to_vec());
408        // r(1) = 0.9, r(2) = -0.9 is not a valid autocovariance sequence.
409        let bad = [1.0, 0.9, -0.9];
410        let fixed = shrink_to_psd(&bad).unwrap();
411        assert!(fixed[1] < 0.9 && fixed[1] > 0.0);
412        let l = crate::levinson::levinson(&fixed).unwrap();
413        for k in &l.pacf {
414            assert!(k.abs() < 1.0, "pacf {k}");
415        }
416    }
417}