Skip to main content

fdars_core/boosting_regression/
stability.rs

1//! FDboost-style stability selection over boosting base-learners (REG-06-05).
2//!
3//! Wraps `boost_fosr` with a subsampling loop: B resamples of ⌊n/2⌋ rows (without
4//! replacement), aggregating per-base-learner selection frequencies. Base-learners
5//! with frequency ≥ π_thr are declared "stable". The PFER bound is reported as an
6//! informational diagnostic.
7//!
8//! # Algorithm
9//!
10//! For each resample `b = 0..B`:
11//! 1. Draw ⌊n/2⌋ distinct row indices without replacement, seeded by
12//!    `seed.wrapping_add(b)` (per-replicate isolation → deterministic + parallel-safe).
13//! 2. Fit `boost_fosr` on the subsample.
14//! 3. Mark every base-learner that appears in the boosting path (`selected_learners`).
15//!
16//! Selection frequency `π̂[j] = (# resamples selecting j) / B`. The stable set is
17//! `{ j : π̂[j] ≥ π_thr }`. The Meinshausen–Bühlmann per-family-error-rate bound is
18//! `E[V] ≤ q² / ((2·π_thr − 1)·p)` where `q` is the mean per-subsample selection count.
19//!
20//! # References
21//!
22//! Meinshausen & Bühlmann (2010). Stability Selection. *JRSS-B*, 72(4).
23//! Hofner et al. (2015). Controlling false discoveries in high-dimensional situations:
24//! Boosting with stability selection. *The R Journal*, 7(1).
25//!
26//! # Divergences from stabs (R package)
27//!
28//! Uses subsampling ⌊n/2⌋ without replacement (Meinshausen-Bühlmann default).
29//! Selection criterion: base-learner appears in `selected_learners` at any iteration.
30//! Seeded per replicate for full reproducibility.
31
32use super::boost_fosr::boost_fosr;
33use super::{BoostingConfig, StabilityConfig, StabilityResult};
34use crate::error::FdarError;
35use crate::iter_maybe_parallel;
36use crate::matrix::FdMatrix;
37use rand::Rng;
38#[cfg(feature = "parallel")]
39use rayon::iter::ParallelIterator;
40
41/// Copy the given rows of `src` into a new (|indices| × ncols) matrix.
42fn subsample_rows(src: &FdMatrix, indices: &[usize]) -> FdMatrix {
43    let ncols = src.ncols();
44    let mut out = FdMatrix::zeros(indices.len(), ncols);
45    for (dst_i, &src_i) in indices.iter().enumerate() {
46        for j in 0..ncols {
47            out[(dst_i, j)] = src[(src_i, j)];
48        }
49    }
50    out
51}
52
53/// FDboost-style stability selection.
54///
55/// Runs `stab_config.n_resamples` subsamples of size ⌊n/2⌋ (without replacement),
56/// fits `boost_fosr` on each subsample, and aggregates per-base-learner selection
57/// frequencies across resamples.
58///
59/// # Arguments
60///
61/// * `data` — Functional response Y (n × m_t).
62/// * `predictors` — Scalar predictor matrix (n × p). One base-learner per column.
63/// * `argvals` — Response grid evaluation points (length m_t).
64/// * `boost_config` — [`BoostingConfig`] for the inner `boost_fosr` calls.
65/// * `stab_config` — [`StabilityConfig`] controlling resamples, threshold, and seed.
66///
67/// # Returns
68///
69/// [`StabilityResult`] with per-learner selection frequencies, the stable set at
70/// `pi_thr`, the PFER bound, and the number of resamples used.
71///
72/// # Errors
73///
74/// [`FdarError::InvalidDimension`] on shape problems (including too-small subsamples);
75/// [`FdarError::InvalidParameter`] on out-of-range config; propagates any
76/// [`FdarError`] from the inner `boost_fosr` fits.
77#[must_use = "expensive computation whose result should not be discarded"]
78pub fn stability_selection(
79    data: &FdMatrix,
80    predictors: &FdMatrix,
81    argvals: &[f64],
82    boost_config: &BoostingConfig,
83    stab_config: &StabilityConfig,
84) -> Result<StabilityResult, FdarError> {
85    let (n, m_t) = data.shape();
86    let p = predictors.ncols();
87
88    // ---- Validation --------------------------------------------------------
89    if m_t == 0 || predictors.nrows() != n {
90        return Err(FdarError::InvalidDimension {
91            parameter: "data/predictors",
92            expected: format!("m_t > 0 and predictors.nrows() == n (n={n})"),
93            actual: format!("m_t={m_t}, predictors.nrows()={}", predictors.nrows()),
94        });
95    }
96    if p == 0 {
97        return Err(FdarError::InvalidDimension {
98            parameter: "predictors",
99            expected: "at least 1 predictor column".to_string(),
100            actual: "0 columns".to_string(),
101        });
102    }
103    if argvals.len() != m_t {
104        return Err(FdarError::InvalidDimension {
105            parameter: "argvals",
106            expected: format!("length == data.ncols() = {m_t}"),
107            actual: format!("length = {}", argvals.len()),
108        });
109    }
110    if stab_config.n_resamples == 0 {
111        return Err(FdarError::InvalidParameter {
112            parameter: "n_resamples",
113            message: "must be >= 1".to_string(),
114        });
115    }
116    if !(stab_config.pi_thr > 0.5 && stab_config.pi_thr <= 1.0) {
117        return Err(FdarError::InvalidParameter {
118            parameter: "pi_thr",
119            message: format!("must be in (0.5, 1.0], got {}", stab_config.pi_thr),
120        });
121    }
122    let half = n / 2;
123    if half < 3 {
124        return Err(FdarError::InvalidDimension {
125            parameter: "data",
126            expected: "n >= 6 so that ⌊n/2⌋ >= 3 (minimum for boost_fosr)".to_string(),
127            actual: format!("n={n} → ⌊n/2⌋={half}"),
128        });
129    }
130
131    let b_count = stab_config.n_resamples;
132
133    // ---- Resample loop (deterministic per replicate, parallel-safe) --------
134    let per_resample: Vec<Vec<bool>> = iter_maybe_parallel!(0..b_count)
135        .map(|b| -> Result<Vec<bool>, FdarError> {
136            let mut rng = crate::helpers::seed_for_thread(stab_config.seed, b);
137            // Sample `half` distinct row indices without replacement via a partial
138            // Fisher–Yates shuffle (first `half` slots hold the sample).
139            let mut idx: Vec<usize> = (0..n).collect();
140            for i in 0..half {
141                let j = rng.gen_range(i..n);
142                idx.swap(i, j);
143            }
144            let sub = &idx[..half];
145            let sub_data = subsample_rows(data, sub);
146            let sub_pred = subsample_rows(predictors, sub);
147            let fit = boost_fosr(&sub_data, &sub_pred, argvals, boost_config)?;
148            let mut selected = vec![false; p];
149            for &j in &fit.selected_learners {
150                if j < p {
151                    selected[j] = true;
152                }
153            }
154            Ok(selected)
155        })
156        .collect::<Result<Vec<Vec<bool>>, FdarError>>()?;
157
158    // ---- Aggregate ---------------------------------------------------------
159    let mut counts = vec![0usize; p];
160    let mut total_selected = 0usize; // Σ_b (#unique learners selected in resample b)
161    for sel in &per_resample {
162        for (j, &s) in sel.iter().enumerate() {
163            if s {
164                counts[j] += 1;
165                total_selected += 1;
166            }
167        }
168    }
169    let selection_freq: Vec<f64> = counts.iter().map(|&c| c as f64 / b_count as f64).collect();
170    let stable_set: Vec<usize> = (0..p)
171        .filter(|&j| selection_freq[j] >= stab_config.pi_thr)
172        .collect();
173
174    // q = mean per-subsample selection count; PFER = q² / ((2·π_thr − 1)·p)
175    let q = total_selected as f64 / b_count as f64;
176    let pfer_bound = (q * q) / ((2.0 * stab_config.pi_thr - 1.0) * p as f64);
177
178    Ok(StabilityResult {
179        selection_freq,
180        stable_set,
181        pi_thr: stab_config.pi_thr,
182        pfer_bound,
183        n_resamples: b_count,
184    })
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::test_helpers::uniform_grid;
191    use std::f64::consts::PI;
192
193    fn default_boost() -> BoostingConfig {
194        BoostingConfig {
195            mstop: 5,
196            nu: 0.3,
197            nbasis: 8,
198            order: 4,
199            lfd_order: 2,
200            lambda: 1.0,
201            ncomp_x: 3,
202            seed: 0,
203        }
204    }
205
206    fn default_stab() -> StabilityConfig {
207        StabilityConfig {
208            n_resamples: 30,
209            pi_thr: 0.6,
210            seed: 20260824,
211        }
212    }
213
214    /// Predictor 0 strongly drives Y(t) = x0·sin(π t); predictors 1..p are unrelated.
215    fn make_signal_dataset(n: usize, m: usize, p: usize) -> (FdMatrix, FdMatrix, Vec<f64>) {
216        let argvals = uniform_grid(m);
217        let mut pred = vec![0.0f64; n * p];
218        for i in 0..n {
219            // strong predictor
220            pred[i] = -1.0 + 2.0 * i as f64 / (n - 1).max(1) as f64;
221            // unrelated predictors (deterministic, uncorrelated with the signal)
222            for j in 1..p {
223                pred[i + j * n] = ((i as f64 * (1.7 + j as f64) + j as f64 * 0.9).sin()) * 0.8;
224            }
225        }
226        let predictors = FdMatrix::from_column_major(pred.clone(), n, p).unwrap();
227
228        let mut y = vec![0.0f64; n * m];
229        for (t_idx, &tv) in argvals.iter().enumerate() {
230            let beta = (PI * tv).sin();
231            for i in 0..n {
232                let x0 = pred[i];
233                let noise = 0.01 * ((i as f64 * 1.23 + t_idx as f64 * 0.71).sin());
234                y[i + t_idx * n] = x0 * beta + noise;
235            }
236        }
237        (
238            FdMatrix::from_column_major(y, n, m).unwrap(),
239            predictors,
240            argvals,
241        )
242    }
243
244    #[test]
245    fn stability_selects_strong_signal() {
246        let (data, predictors, argvals) = make_signal_dataset(50, 15, 4);
247        let result = stability_selection(
248            &data,
249            &predictors,
250            &argvals,
251            &default_boost(),
252            &default_stab(),
253        )
254        .unwrap();
255        assert_eq!(result.selection_freq.len(), 4);
256        // The strong predictor is selected far more often than the unrelated ones.
257        for j in 1..4 {
258            assert!(
259                result.selection_freq[0] > result.selection_freq[j],
260                "strong predictor freq {} must exceed noise predictor {j} freq {}",
261                result.selection_freq[0],
262                result.selection_freq[j]
263            );
264        }
265        assert!(
266            result.stable_set.contains(&0),
267            "strong predictor must be in the stable set (freq={})",
268            result.selection_freq[0]
269        );
270    }
271
272    #[test]
273    fn stability_freqs_in_range() {
274        let (data, predictors, argvals) = make_signal_dataset(40, 12, 3);
275        let result = stability_selection(
276            &data,
277            &predictors,
278            &argvals,
279            &default_boost(),
280            &default_stab(),
281        )
282        .unwrap();
283        assert!(result
284            .selection_freq
285            .iter()
286            .all(|&f| (0.0..=1.0).contains(&f)));
287        assert!(result.pfer_bound.is_finite() && result.pfer_bound >= 0.0);
288    }
289
290    #[test]
291    fn stability_is_deterministic_under_seed() {
292        let (data, predictors, argvals) = make_signal_dataset(44, 10, 4);
293        let r1 = stability_selection(
294            &data,
295            &predictors,
296            &argvals,
297            &default_boost(),
298            &default_stab(),
299        )
300        .unwrap();
301        let r2 = stability_selection(
302            &data,
303            &predictors,
304            &argvals,
305            &default_boost(),
306            &default_stab(),
307        )
308        .unwrap();
309        assert_eq!(r1.selection_freq, r2.selection_freq);
310        assert_eq!(r1.stable_set, r2.stable_set);
311        assert_eq!(r1.pfer_bound, r2.pfer_bound);
312    }
313
314    #[test]
315    fn stability_errors_on_invalid_params() {
316        let (data, predictors, argvals) = make_signal_dataset(30, 10, 3);
317        let mut bad_pi = default_stab();
318        bad_pi.pi_thr = 0.4; // must be > 0.5
319        assert!(
320            stability_selection(&data, &predictors, &argvals, &default_boost(), &bad_pi).is_err()
321        );
322        let mut bad_b = default_stab();
323        bad_b.n_resamples = 0;
324        assert!(
325            stability_selection(&data, &predictors, &argvals, &default_boost(), &bad_b).is_err()
326        );
327    }
328
329    #[test]
330    fn stability_errors_on_tiny_n() {
331        let (data, predictors, argvals) = make_signal_dataset(4, 8, 2);
332        // ⌊4/2⌋ = 2 < 3 → error
333        assert!(stability_selection(
334            &data,
335            &predictors,
336            &argvals,
337            &default_boost(),
338            &default_stab()
339        )
340        .is_err());
341    }
342}