fdars_core/boosting_regression/
stability.rs1use 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
41fn 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#[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 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 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 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 let mut counts = vec![0usize; p];
160 let mut total_selected = 0usize; 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 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 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 pred[i] = -1.0 + 2.0 * i as f64 / (n - 1).max(1) as f64;
221 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 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; 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 assert!(stability_selection(
334 &data,
335 &predictors,
336 &argvals,
337 &default_boost(),
338 &default_stab()
339 )
340 .is_err());
341 }
342}