1use std::collections::BTreeMap;
4
5use rand::seq::SliceRandom;
6
7use crate::error::{PlsKitError, PlsKitResult};
8use crate::fit::{
9 FitOpts, IMPLICIT_MAX_MEAN_RATIO, KSpec, ParChoice, PreparedFit, check_fit_y_and_k,
10 pls1_fit_impl, pls1_fit_implicit,
11};
12use crate::linalg::{
13 col_row_subset, normalize_weights, standardize_apply_rows, standardize_rows,
14 standardize1_weighted,
15};
16use crate::sequential::{IncrementalSequenceOpts, SequentialArgs, run_incremental_sequence};
17use crate::signal_test::ConfirmatoryMethod;
18
19use faer::{Col, ColRef, Mat, MatRef};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Selector {
24 R2Se,
26 R2Max,
28 Bic,
34}
35
36#[derive(Debug, Clone, Copy)]
38#[allow(clippy::struct_excessive_bools)]
39pub struct FindKOptimalOpts {
40 pub selector: Selector,
42 pub n_folds: usize,
45 pub diagnostic: Option<ConfirmatoryMethod>,
50 pub n_perm: usize,
53 pub n_splits: usize,
56 pub force: bool,
60 pub pre_standardized: bool,
62 pub seed: Option<u64>,
64 pub verbose: bool,
66}
67
68impl Default for FindKOptimalOpts {
69 fn default() -> Self {
70 Self {
71 selector: Selector::R2Se,
72 n_folds: 5,
73 diagnostic: None,
74 n_perm: 1000,
75 n_splits: 50,
76 force: false,
77 pre_standardized: false,
78 seed: None,
79 verbose: false,
80 }
81 }
82}
83
84#[derive(Debug, Clone)]
86pub struct FindKOptimalOutput {
87 pub k_star: usize,
92 pub selector: String,
94 pub cv_scores: Option<BTreeMap<usize, f64>>,
96 pub cv_scores_se: Option<BTreeMap<usize, f64>>,
98 pub bic_scores: Option<BTreeMap<usize, f64>>,
100 pub pvalues: Option<Col<f64>>,
104 pub diagnostic: Option<String>,
108 pub seed: u64,
110 pub n_eff: f64,
112 pub stable_rank: Option<f64>,
118}
119
120impl FindKOptimalOutput {
121 pub fn k_to_fit(&self) -> PlsKitResult<usize> {
134 if self.k_star == 0 {
135 Err(PlsKitError::OptimalNoComponent)
136 } else {
137 Ok(self.k_star)
138 }
139 }
140}
141
142pub fn pls1_find_k_optimal(
175 x: MatRef<'_, f64>,
176 y: ColRef<'_, f64>,
177 k_max: usize,
178 weights: Option<ColRef<'_, f64>>,
179 opts: FindKOptimalOpts,
180) -> PlsKitResult<FindKOptimalOutput> {
181 crate::fit::with_thread_limit(|| find_k_optimal_impl(x, y, k_max, None, weights, opts))
182}
183
184pub fn spls1_find_k_optimal(
202 x: MatRef<'_, f64>,
203 y: ColRef<'_, f64>,
204 k_max: usize,
205 keep: usize,
206 weights: Option<ColRef<'_, f64>>,
207 opts: FindKOptimalOpts,
208) -> PlsKitResult<FindKOptimalOutput> {
209 crate::fit::with_thread_limit(|| {
210 crate::fit::validate_keep(keep, x.ncols())?;
211 find_k_optimal_impl(
213 x,
214 y,
215 k_max,
216 (keep < x.ncols()).then_some(keep),
217 weights,
218 opts,
219 )
220 })
221}
222
223#[allow(clippy::needless_pass_by_value)]
224#[allow(clippy::too_many_lines)]
225#[allow(clippy::type_complexity)]
226fn find_k_optimal_impl(
227 x: MatRef<'_, f64>,
228 y: ColRef<'_, f64>,
229 k_max: usize,
230 keep: Option<usize>,
231 weights: Option<ColRef<'_, f64>>,
232 opts: FindKOptimalOpts,
233) -> PlsKitResult<FindKOptimalOutput> {
234 let n = x.nrows();
235 if y.nrows() != n {
236 return Err(PlsKitError::DimensionMismatch {
237 x: (n, x.ncols()),
238 y: y.nrows(),
239 });
240 }
241 if k_max == 0 {
242 return Err(PlsKitError::InvalidArgument("k_max must be >= 1".into()));
243 }
244 let max_allowed = x.ncols();
245 if k_max > max_allowed {
246 return Err(PlsKitError::KExceedsMax {
247 k: k_max,
248 k_max: max_allowed,
249 });
250 }
251 if matches!(opts.diagnostic, Some(ConfirmatoryMethod::Score)) {
252 return Err(PlsKitError::InvalidArgument(
253 "score has no sequential variant".into(),
254 ));
255 }
256 if matches!(opts.diagnostic, Some(ConfirmatoryMethod::Auto)) {
257 return Err(PlsKitError::InvalidArgument(
258 "diagnostic does not accept 'auto'".into(),
259 ));
260 }
261 if matches!(opts.diagnostic, Some(ConfirmatoryMethod::RawPerm))
269 && n <= crate::sequential::SEQUENTIAL_RAW_PERM_N_FOLDS
270 {
271 return Err(PlsKitError::InvalidArgument(format!(
272 "diagnostic='raw_perm' uses {f}-fold CV and needs n > {f} (got n={n}): with \
273 n_folds >= n every validation fold is a single row, so the pooled CV R² is \
274 undefined",
275 f = crate::sequential::SEQUENTIAL_RAW_PERM_N_FOLDS
276 )));
277 }
278
279 let (w_norm, n_eff_val) = crate::fit::validate_weights_for_k(weights, n, k_max)?;
280
281 let (seed_used, mut rng) = crate::rng::resolve_seed(opts.seed)?;
282
283 let (k_star, cv_scores, cv_scores_se, bic_scores, selector_str): (
288 usize,
289 Option<BTreeMap<usize, f64>>,
290 Option<BTreeMap<usize, f64>>,
291 Option<BTreeMap<usize, f64>>,
292 &'static str,
293 ) = match opts.selector {
294 Selector::R2Se => {
295 let (k, scores, se) = select_cv(
296 x,
297 y,
298 k_max,
299 opts.n_folds,
300 true,
301 w_norm.as_ref().map(Col::as_ref),
302 keep,
303 &mut rng,
304 )?;
305 (k, Some(scores), se, None, "r2_se")
306 }
307 Selector::R2Max => {
308 let (k, scores, _) = select_cv(
309 x,
310 y,
311 k_max,
312 opts.n_folds,
313 false,
314 w_norm.as_ref().map(Col::as_ref),
315 keep,
316 &mut rng,
317 )?;
318 (k, Some(scores), None, None, "r2_max")
319 }
320 Selector::Bic => {
321 let (k, scores) = select_bic(
322 x,
323 y,
324 k_max,
325 w_norm.as_ref().map(Col::as_ref),
326 n_eff_val,
327 keep,
328 )?;
329 (k, None, None, Some(scores), "bic")
330 }
331 };
332
333 let (pvalues, diagnostic_str, stable_rank) = if let (Some(method), 0) =
334 (opts.diagnostic, k_star)
335 {
336 if matches!(method, ConfirmatoryMethod::SplitNb) {
340 let gate = crate::signal_test::resolve_split_nb(
341 x,
342 w_norm.as_ref().map(Col::as_ref),
343 n_eff_val,
344 opts.force,
345 );
346 (
347 Some(Col::zeros(0)),
348 Some(gate.method().as_str().to_owned()),
349 Some(gate.stable_rank),
350 )
351 } else {
352 (Some(Col::zeros(0)), Some(method.as_str().to_owned()), None)
353 }
354 } else if let Some(method) = opts.diagnostic {
355 let seq_args = SequentialArgs::defaults_for(method).ok_or_else(|| {
357 PlsKitError::InvalidArgument(format!("{} has no sequential variant", method.as_str()))
358 })?;
359 let seq_args = match seq_args {
360 SequentialArgs::RawPerm { .. } => SequentialArgs::RawPerm {
361 n_perm: opts.n_perm,
362 },
363 SequentialArgs::SplitNb { .. } => SequentialArgs::SplitNb {
364 n_splits: opts.n_splits,
365 force: opts.force,
366 },
367 SequentialArgs::SplitExact { .. } => SequentialArgs::SplitExact {
368 n_perm: opts.n_perm,
369 n_splits: opts.n_splits,
370 },
371 SequentialArgs::E => SequentialArgs::E,
372 SequentialArgs::Auto { .. } => {
373 unreachable!("`Some(Auto)` is rejected at the top of this function")
374 }
375 };
376 let r = run_incremental_sequence(
377 x,
378 y,
379 k_star,
380 w_norm.as_ref().map(Col::as_ref),
381 n_eff_val,
382 IncrementalSequenceOpts {
383 args: seq_args,
384 alpha: 0.05,
385 stop_early_override: true, pre_standardized: opts.pre_standardized,
387 seed: Some({
388 use rand::Rng;
389 rng.next_u64()
390 }),
391 verbose: opts.verbose,
392 keep,
393 },
394 )?;
395 (Some(r.pvalues), Some(r.method), r.stable_rank)
398 } else {
399 (None, None, None)
400 };
401
402 Ok(FindKOptimalOutput {
403 k_star,
404 selector: selector_str.to_owned(),
405 cv_scores,
406 cv_scores_se,
407 bic_scores,
408 pvalues,
409 diagnostic: diagnostic_str,
410 seed: seed_used,
411 n_eff: n_eff_val,
412 stable_rank,
413 })
414}
415
416#[derive(Debug, Clone, Copy)]
418#[allow(clippy::struct_excessive_bools)]
419pub struct FindKSequenceOpts {
420 pub test_method: ConfirmatoryMethod,
427 pub alpha: f64,
429 pub n_perm: usize,
431 pub n_splits: usize,
434 pub force: bool,
439 pub pre_standardized: bool,
441 pub seed: Option<u64>,
443 pub verbose: bool,
445}
446
447impl Default for FindKSequenceOpts {
448 fn default() -> Self {
449 Self {
450 test_method: ConfirmatoryMethod::Auto,
451 alpha: 0.05,
452 n_perm: 1000,
453 n_splits: 50,
454 force: false,
455 pre_standardized: false,
456 seed: None,
457 verbose: false,
458 }
459 }
460}
461
462#[derive(Debug, Clone)]
464pub struct FindKSequenceOutput {
465 pub k_star: usize,
467 pub pvalues: Col<f64>,
469 pub test_method: String,
472 pub alpha: f64,
474 pub seed: u64,
476 pub n_eff: f64,
478 pub stable_rank: Option<f64>,
485}
486
487impl FindKSequenceOutput {
488 pub fn k_to_fit(&self) -> PlsKitResult<usize> {
497 if self.k_star == 0 {
498 Err(PlsKitError::SequenceNoRejection { alpha: self.alpha })
499 } else {
500 Ok(self.k_star)
501 }
502 }
503}
504
505pub fn pls1_find_k_sequence(
523 x: MatRef<'_, f64>,
524 y: ColRef<'_, f64>,
525 k_max: usize,
526 weights: Option<ColRef<'_, f64>>,
527 opts: FindKSequenceOpts,
528) -> PlsKitResult<FindKSequenceOutput> {
529 crate::fit::with_thread_limit(|| find_k_sequence_impl(x, y, k_max, None, weights, opts))
530}
531
532pub fn spls1_find_k_sequence(
544 x: MatRef<'_, f64>,
545 y: ColRef<'_, f64>,
546 k_max: usize,
547 keep: usize,
548 weights: Option<ColRef<'_, f64>>,
549 opts: FindKSequenceOpts,
550) -> PlsKitResult<FindKSequenceOutput> {
551 crate::fit::with_thread_limit(|| {
552 crate::fit::validate_keep(keep, x.ncols())?;
553 find_k_sequence_impl(
558 x,
559 y,
560 k_max,
561 (keep < x.ncols()).then_some(keep),
562 weights,
563 opts,
564 )
565 })
566}
567
568#[allow(clippy::needless_pass_by_value)]
569#[allow(clippy::many_single_char_names)]
570fn find_k_sequence_impl(
571 x: MatRef<'_, f64>,
572 y: ColRef<'_, f64>,
573 k_max: usize,
574 keep: Option<usize>,
575 weights: Option<ColRef<'_, f64>>,
576 opts: FindKSequenceOpts,
577) -> PlsKitResult<FindKSequenceOutput> {
578 let n = x.nrows();
579 if y.nrows() != n {
580 return Err(PlsKitError::DimensionMismatch {
581 x: (n, x.ncols()),
582 y: y.nrows(),
583 });
584 }
585 if k_max == 0 {
586 return Err(PlsKitError::InvalidArgument("k_max must be >= 1".into()));
587 }
588 let max_allowed = x.ncols();
589 if k_max > max_allowed {
590 return Err(PlsKitError::KExceedsMax {
591 k: k_max,
592 k_max: max_allowed,
593 });
594 }
595
596 let (w_norm, n_eff_val) = crate::fit::validate_weights_for_k(weights, n, k_max)?;
597
598 let seq_args = SequentialArgs::defaults_for(opts.test_method).ok_or_else(|| {
599 PlsKitError::InvalidArgument(format!(
600 "test_method='{}' has no sequential variant",
601 opts.test_method.as_str()
602 ))
603 })?;
604 let seq_args = match seq_args {
605 SequentialArgs::RawPerm { .. } => SequentialArgs::RawPerm {
606 n_perm: opts.n_perm,
607 },
608 SequentialArgs::SplitNb { .. } => SequentialArgs::SplitNb {
609 n_splits: opts.n_splits,
610 force: opts.force,
611 },
612 SequentialArgs::SplitExact { .. } => SequentialArgs::SplitExact {
613 n_perm: opts.n_perm,
614 n_splits: opts.n_splits,
615 },
616 SequentialArgs::E => SequentialArgs::E,
617 SequentialArgs::Auto { .. } => SequentialArgs::Auto {
618 n_perm: opts.n_perm,
619 n_splits: opts.n_splits,
620 },
621 };
622 if let SequentialArgs::Auto { n_perm, n_splits } = seq_args {
626 crate::signal_test::check_split_exact_counts(Some(n_perm), n_splits)?;
627 if opts.force {
628 return Err(PlsKitError::InvalidArgument(
629 "force applies to test_method='split_nb' only, not 'auto'".into(),
630 ));
631 }
632 }
633 let r = run_incremental_sequence(
634 x,
635 y,
636 k_max,
637 w_norm.as_ref().map(Col::as_ref),
638 n_eff_val,
639 IncrementalSequenceOpts {
640 args: seq_args,
641 alpha: opts.alpha,
642 stop_early_override: false,
643 pre_standardized: opts.pre_standardized,
644 seed: opts.seed,
645 verbose: opts.verbose,
646 keep,
647 },
648 )?;
649 let k_star = r.last_significant_k.unwrap_or(0);
650 Ok(FindKSequenceOutput {
651 k_star,
652 pvalues: r.pvalues,
653 test_method: r.method,
656 alpha: opts.alpha,
657 seed: r.seed,
658 n_eff: n_eff_val,
659 stable_rank: r.stable_rank,
660 })
661}
662
663struct FoldData {
678 train_idx: Vec<usize>,
679 val_idx: Vec<usize>,
680 mean: Col<f64>,
683 scale: Col<f64>,
684 fro: f64,
685 mean_ratio: f64,
688 row_factor: Col<f64>,
692 ys_n: Col<f64>,
694 ys_tr: Col<f64>,
695 ys_val: Col<f64>,
696 train_w: Option<Col<f64>>,
697 val_w: Option<Col<f64>>,
698 blocks: Option<(Mat<f64>, Mat<f64>)>,
701 on_blocks: bool,
703}
704
705#[allow(clippy::many_single_char_names)]
706#[allow(clippy::similar_names)]
707fn prep_fold(
708 x: MatRef<'_, f64>,
709 y: ColRef<'_, f64>,
710 weights: Option<ColRef<'_, f64>>,
711 folds: &[Vec<usize>],
712 fi: usize,
713 val_idx: &[usize],
714) -> PlsKitResult<FoldData> {
715 let train_idx: Vec<usize> = folds
716 .iter()
717 .enumerate()
718 .filter(|(j, _)| *j != fi)
719 .flat_map(|(_, f)| f.iter().copied())
720 .collect();
721 let y_tr = col_row_subset(y, &train_idx);
722 let y_val = col_row_subset(y, val_idx);
723 let nv = y_val.nrows();
724
725 let train_w_full: Option<Col<f64>> = weights.map(|w| col_row_subset(w, &train_idx));
728 let train_w_norm: Option<Col<f64>> = train_w_full
729 .as_ref()
730 .and_then(|w| normalize_weights(w.as_ref()));
731 let train_wref = train_w_norm.as_ref().map(Col::as_ref);
732
733 let val_w_full: Option<Col<f64>> = weights.map(|w| col_row_subset(w, val_idx));
735 let val_w_norm: Option<Col<f64>> = val_w_full
736 .as_ref()
737 .and_then(|w| normalize_weights(w.as_ref()));
738
739 let n_tr = train_idx.len();
740 let fx = crate::linalg::fit_x_moments(
741 x,
742 Some(&train_idx),
743 train_wref,
744 crate::fit::resolve_par(ParChoice::Auto, n_tr, x.ncols(), 1),
745 );
746 let mean_ratio = fx.max_mean_ratio();
747 let (ys_tr, ym, ys) = standardize1_weighted(y_tr.as_ref(), train_wref);
748 let ys_val = Col::<f64>::from_fn(nv, |i| (y_val[i] - ym) / ys);
749
750 let (w_fit, _) = crate::fit::validate_and_normalize_weights(train_wref, n_tr, 1)?;
754 let sqw = w_fit.as_ref().map(|w| crate::linalg::sqrt_col(w.as_ref()));
755 let n = x.nrows();
756 let mut row_factor = Col::<f64>::zeros(n);
757 let mut ys_n = Col::<f64>::zeros(n);
758 for (i, &r) in train_idx.iter().enumerate() {
759 let f = sqw.as_ref().map_or(1.0, |s| s[i]);
760 row_factor[r] = f;
761 ys_n[r] = f * ys_tr[i];
762 }
763
764 Ok(FoldData {
765 train_idx,
766 val_idx: val_idx.to_vec(),
767 mean: fx.mean,
768 scale: fx.scale,
769 fro: fx.fro,
770 mean_ratio,
771 row_factor,
772 ys_n,
773 ys_tr,
774 ys_val,
775 train_w: train_w_norm,
776 val_w: val_w_norm,
777 blocks: None,
778 on_blocks: false,
779 })
780}
781
782impl FoldData {
783 fn ss_tot(&self) -> f64 {
786 let nv = self.ys_val.nrows();
787 let val_wref = self.val_w.as_ref().map(Col::as_ref);
788 let mean_val = match val_wref {
789 None => {
790 if nv > 0 {
791 (0..nv).map(|i| self.ys_val[i]).sum::<f64>() / nv as f64
792 } else {
793 0.0
794 }
795 }
796 Some(wv) => (0..nv).map(|i| wv[i] * self.ys_val[i]).sum::<f64>() / nv as f64,
797 };
798 match val_wref {
799 None => (0..nv).map(|i| (self.ys_val[i] - mean_val).powi(2)).sum(),
800 Some(wv) => (0..nv)
801 .map(|i| wv[i] * (self.ys_val[i] - mean_val).powi(2))
802 .sum(),
803 }
804 }
805
806 fn r2(&self, y_pred: &Col<f64>, ss_tot: f64) -> f64 {
809 let nv = self.ys_val.nrows();
810 let ss_res: f64 = match self.val_w.as_ref() {
811 None => (0..nv).map(|i| (y_pred[i] - self.ys_val[i]).powi(2)).sum(),
812 Some(wv) => (0..nv)
813 .map(|i| wv[i] * (y_pred[i] - self.ys_val[i]).powi(2))
814 .sum(),
815 };
816 if ss_tot > 0.0 {
817 1.0 - ss_res / ss_tot
818 } else {
819 0.0
820 }
821 }
822
823 fn fit_predict<F>(
837 &mut self,
838 x: MatRef<'_, f64>,
839 k: usize,
840 keep: Option<usize>,
841 coefs: F,
842 ) -> PlsKitResult<Option<Vec<Col<f64>>>>
843 where
844 F: Fn(&PreparedFit) -> Vec<Col<f64>>,
845 {
846 check_fit_y_and_k(x.ncols(), self.ys_tr.as_ref(), k, keep)?;
847 if self.on_blocks {
848 return self.fit_predict_on_copy(x, k, keep, coefs).map(Some);
849 }
850 let in_place = if self.mean_ratio <= IMPLICIT_MAX_MEAN_RATIO {
851 pls1_fit_implicit(
852 x,
853 self.ys_n.as_ref(),
854 crate::linalg::owned_col_slice(&self.mean),
855 crate::linalg::owned_col_slice(&self.scale),
856 Some(crate::linalg::owned_col_slice(&self.row_factor)),
857 k,
858 keep,
859 ParChoice::Auto,
860 self.fro,
861 self.mean_ratio,
862 self.train_idx.len(),
863 )
864 } else {
865 None
866 };
867 if let Some(fit) = in_place.filter(|f| f.coef.iter().all(|v| v.is_finite())) {
868 let par = crate::fit::resolve_par(ParChoice::Auto, x.nrows(), x.ncols(), k);
869 let preds = self.predict_in_place(x, &coefs(&fit), par);
870 if preds.iter().all(|p| p.iter().all(|v| v.is_finite())) {
871 return Ok(Some(preds));
872 }
873 }
874 Ok(None)
875 }
876
877 fn predict_in_place(
883 &self,
884 x: MatRef<'_, f64>,
885 coefs: &[Col<f64>],
886 par: faer::Par,
887 ) -> Vec<Col<f64>> {
888 let d = x.ncols();
889 let nv = self.val_idx.len();
890 let u = Mat::<f64>::from_fn(d, coefs.len(), |j, c| coefs[c][j] / self.scale[j]);
891 let off: Vec<f64> = (0..coefs.len())
892 .map(|c| (0..d).map(|j| self.mean[j] * u[(j, c)]).sum())
893 .collect();
894 let mut preds: Vec<Col<f64>> = (0..coefs.len()).map(|_| Col::<f64>::zeros(nv)).collect();
895 let block = crate::linalg::stream_block_width(d);
896 for (b, rows) in self.val_idx.chunks(block).enumerate() {
897 let i0 = b * block;
898 let blk = crate::linalg::row_subset(x, rows);
899 let z = crate::linalg::mat_mul(blk.as_ref(), u.as_ref(), par);
900 for (c, pred) in preds.iter_mut().enumerate() {
901 for i in 0..rows.len() {
902 pred[i0 + i] = z[(i, c)] - off[c];
903 }
904 }
905 }
906 preds
907 }
908
909 fn fit_predict_on_copy<F>(
916 &mut self,
917 x: MatRef<'_, f64>,
918 k: usize,
919 keep: Option<usize>,
920 coefs: F,
921 ) -> PlsKitResult<Vec<Col<f64>>>
922 where
923 F: Fn(&PreparedFit) -> Vec<Col<f64>>,
924 {
925 let train_wref = self.train_w.as_ref().map(Col::as_ref);
926 let (xs_tr, xs_val) = self.blocks.get_or_insert_with(|| {
927 let (xs_tr, mean, scale) = standardize_rows(x, &self.train_idx, train_wref, None);
928 let xs_val =
929 standardize_apply_rows(x, &self.val_idx, mean.as_ref(), scale.as_ref(), None);
930 (xs_tr, xs_val)
931 });
932 let m = pls1_fit_impl(
933 xs_tr.as_ref(),
934 self.ys_tr.as_ref(),
935 KSpec::Fixed(k),
936 train_wref,
937 FitOpts {
938 pre_standardized: true,
939 check_n_eff: false,
942 par: ParChoice::Auto,
945 keep,
946 },
947 )?;
948 let fit = PreparedFit {
949 t_scores: m.t_scores,
950 p_loadings: m.p_loadings,
951 w_star: m.w_star,
952 q_loadings: m.q_loadings,
953 coef: m.coef,
954 k_used: m.k_used,
955 };
956 Ok(coefs(&fit)
957 .iter()
958 .map(|c| crate::linalg::mat_vec(xs_val.as_ref(), c.as_ref(), faer::Par::Seq))
959 .collect())
960 }
961}
962
963fn sweep_coefs(fit: &PreparedFit) -> Vec<Col<f64>> {
966 (1..=fit.k_used)
967 .map(|k| {
968 crate::fit::pls1_coef_at_k(
969 &fit.w_star,
970 &fit.p_loadings,
971 &fit.q_loadings,
972 k,
973 faer::Par::Seq,
974 )
975 })
976 .collect()
977}
978
979fn cv_n_folds(n: usize, requested: usize) -> usize {
985 requested.min(n.saturating_sub(2)).max(2)
986}
987
988fn reject_cv_leave_one_out(n: usize, requested: usize, effective: usize) -> PlsKitResult<()> {
1007 if effective >= n {
1008 return Err(PlsKitError::InvalidArgument(format!(
1009 "cross-validation needs n >= 3 (got n={n}): the fold count (requested \
1010 {requested}, capped at max(2, n - 2) = {effective}) would make every validation \
1011 fold a single row (leave-one-out)"
1012 )));
1013 }
1014 Ok(())
1015}
1016
1017#[allow(clippy::too_many_arguments)]
1049#[allow(clippy::type_complexity)]
1050#[allow(clippy::many_single_char_names)]
1051fn cv_select<F>(
1052 x: MatRef<'_, f64>,
1053 y: ColRef<'_, f64>,
1054 weights: Option<ColRef<'_, f64>>,
1055 check_keep: Option<usize>,
1056 n_folds: usize,
1057 candidates: &[usize],
1058 fit_k: usize,
1059 use_1se: bool,
1060 rng: &mut crate::rng::Rng,
1061 fold_work: F,
1062) -> PlsKitResult<(usize, BTreeMap<usize, f64>, BTreeMap<usize, f64>)>
1063where
1064 F: Fn(&mut FoldData, &[usize]) -> PlsKitResult<Vec<Option<f64>>> + Sync,
1065{
1066 use crate::resample::{copy_bytes, fill_pending, fold_blocks_bytes, map_units};
1067 if first_component_exhausted(x, y, weights, check_keep)? {
1068 return Ok((0, BTreeMap::new(), BTreeMap::new()));
1069 }
1070 let (n, p) = (x.nrows(), x.ncols());
1071 let mut indices: Vec<usize> = (0..n).collect();
1072 indices.shuffle(rng);
1073 let folds = crate::linalg::fold_split(&indices, n_folds);
1074 let run = |fi: usize, cells: &[usize], on_blocks: bool| {
1075 let mut fd = prep_fold(x, y, weights, &folds, fi, folds[fi].as_slice())?;
1076 fd.on_blocks = on_blocks;
1077 fold_work(&mut fd, cells)
1078 };
1079
1080 let all: Vec<usize> = (0..candidates.len()).collect();
1083 let n_val = folds.iter().map(Vec::len).max().unwrap_or(0);
1084 let unit = cv_fold_bytes(p, fit_k, n_val);
1085 let rows: Vec<Vec<Option<PlsKitResult<f64>>>> =
1086 map_units(folds.len(), unit, |fi| match run(fi, &all, false) {
1087 Ok(row) => row.into_iter().map(|v| v.map(Ok)).collect(),
1088 Err(e) => vec![Some(Err(e))],
1089 });
1090
1091 let n_tr = n - folds.iter().map(Vec::len).min().unwrap_or(0);
1095 let fit_copy = if weights.is_some() {
1096 copy_bytes(n_tr, p)
1097 } else {
1098 0
1099 };
1100 let rows = fill_pending(
1101 rows,
1102 unit.saturating_add(fold_blocks_bytes(n, &folds, p))
1103 .saturating_add(fit_copy),
1104 |fi, pending| match run(fi, pending, true) {
1105 Ok(vals) => vals
1106 .into_iter()
1107 .map(|v| Ok(v.expect("a fit on the blocks always predicts")))
1108 .collect(),
1109 Err(e) => std::iter::once(Err(e))
1110 .chain(pending[1..].iter().map(|_| Ok(f64::NAN)))
1111 .collect(),
1112 },
1113 );
1114 let r2_matrix: Vec<Vec<f64>> = rows
1115 .into_iter()
1116 .map(|row| row.into_iter().collect::<PlsKitResult<Vec<f64>>>())
1117 .collect::<PlsKitResult<Vec<_>>>()?;
1118 Ok(aggregate_and_pick(&r2_matrix, candidates, use_1se))
1119}
1120
1121fn cv_fold_bytes(p: usize, fit_k: usize, n_val: usize) -> usize {
1132 use crate::resample::copy_bytes;
1133 crate::fit::pls1_fit_bytes(p, fit_k)
1134 .saturating_add(copy_bytes(p, fit_k.saturating_add(2)))
1135 .saturating_add(copy_bytes(
1136 crate::linalg::stream_block_width(p).min(n_val),
1137 p,
1138 ))
1139}
1140
1141fn aggregate_and_pick(
1148 r2_matrix: &[Vec<f64>],
1149 candidates: &[usize],
1150 use_1se: bool,
1151) -> (usize, BTreeMap<usize, f64>, BTreeMap<usize, f64>) {
1152 let mut cv_scores = BTreeMap::new();
1153 let mut cv_scores_se = BTreeMap::new();
1154 for (ci, &cand) in candidates.iter().enumerate() {
1155 let finite: Vec<f64> = r2_matrix
1156 .iter()
1157 .map(|row| row[ci])
1158 .filter(|v| v.is_finite())
1159 .collect();
1160 if finite.is_empty() {
1161 cv_scores.insert(cand, f64::NAN);
1162 cv_scores_se.insert(cand, f64::NAN);
1163 } else {
1164 let mean = finite.iter().sum::<f64>() / finite.len() as f64;
1165 let var = finite.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
1166 / (finite.len() - 1).max(1) as f64;
1167 let se = var.sqrt() / (finite.len() as f64).sqrt();
1168 cv_scores.insert(cand, mean);
1169 cv_scores_se.insert(cand, se);
1170 }
1171 }
1172 let best = *cv_scores
1173 .iter()
1174 .filter(|(_, v)| v.is_finite())
1175 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Less))
1176 .map_or(&candidates[0], |(c, _)| c);
1177 let pick = if use_1se {
1178 let threshold = cv_scores[&best] - cv_scores_se.get(&best).copied().unwrap_or(0.0);
1179 cv_scores
1180 .iter()
1181 .filter(|(_, v)| v.is_finite() && **v >= threshold)
1182 .map(|(c, _)| *c)
1183 .min()
1184 .unwrap_or(candidates[0])
1185 } else {
1186 best
1187 };
1188 (pick, cv_scores, cv_scores_se)
1189}
1190
1191#[allow(clippy::too_many_arguments)]
1192#[allow(clippy::many_single_char_names)]
1193#[allow(clippy::similar_names)]
1194#[allow(clippy::too_many_lines)]
1195#[allow(clippy::type_complexity)]
1196fn select_cv(
1197 x: MatRef<'_, f64>,
1198 y: ColRef<'_, f64>,
1199 k_max: usize,
1200 n_folds: usize,
1201 use_1se: bool,
1202 weights: Option<ColRef<'_, f64>>,
1203 keep: Option<usize>,
1204 rng: &mut crate::rng::Rng,
1205) -> PlsKitResult<(usize, BTreeMap<usize, f64>, Option<BTreeMap<usize, f64>>)> {
1206 let n = x.nrows();
1212 let requested_n_folds = n_folds;
1213 let n_folds = cv_n_folds(n, n_folds);
1214 reject_cv_leave_one_out(n, requested_n_folds, n_folds)?;
1215 let max_comp = k_max.min(n.saturating_sub(n_folds + 2)).max(1);
1216
1217 let fold_work = |fd: &mut FoldData, cells: &[usize]| -> PlsKitResult<Vec<Option<f64>>> {
1220 let Some(preds) = fd.fit_predict(x, max_comp, keep, sweep_coefs)? else {
1221 return Ok(vec![None; cells.len()]);
1222 };
1223 let ss_tot = fd.ss_tot();
1224 let mut row = vec![f64::NAN; max_comp];
1225 for (k, y_pred) in preds.iter().enumerate() {
1226 row[k] = fd.r2(y_pred, ss_tot);
1227 }
1228 Ok(cells.iter().map(|&c| Some(row[c])).collect())
1229 };
1230
1231 let candidates: Vec<usize> = (1..=max_comp).collect();
1232 let (k_star, cv_scores, cv_scores_se) = cv_select(
1233 x,
1234 y,
1235 weights,
1236 keep,
1237 n_folds,
1238 &candidates,
1239 max_comp,
1240 use_1se,
1241 rng,
1242 fold_work,
1243 )?;
1244 Ok((
1245 k_star,
1246 cv_scores,
1247 if use_1se { Some(cv_scores_se) } else { None },
1248 ))
1249}
1250
1251#[allow(clippy::many_single_char_names)]
1262fn select_bic(
1263 x: MatRef<'_, f64>,
1264 y: ColRef<'_, f64>,
1265 k_max: usize,
1266 weights: Option<ColRef<'_, f64>>,
1267 n_eff: f64,
1268 keep: Option<usize>,
1269) -> PlsKitResult<(usize, BTreeMap<usize, f64>)> {
1270 let m = pls1_fit_impl(
1271 x,
1272 y,
1273 KSpec::Fixed(k_max),
1274 weights,
1275 FitOpts {
1276 pre_standardized: false,
1277 check_n_eff: false,
1281 keep,
1282 ..FitOpts::default()
1283 },
1284 )?;
1285 if m.w_star.ncols() == 0 {
1288 return Ok((0, BTreeMap::new()));
1289 }
1290 let (ys, _, _) = standardize1_weighted(y, m.weights.as_ref().map(Col::as_ref));
1296 let mut e: Col<f64> = match m.weights.as_ref() {
1297 Some(w) => crate::fit::scale_col(ys.as_ref(), crate::linalg::sqrt_col(w.as_ref()).as_ref()),
1298 None => ys.clone(),
1299 };
1300 let mut best_k = 1;
1301 let mut best_bic = f64::INFINITY;
1302 let mut bic_scores = BTreeMap::<usize, f64>::new();
1303 let nv = ys.nrows();
1304 for k in 1..=m.k_used {
1305 let t = m.t_scores.col(k - 1);
1306 let q = m.q_loadings[k - 1];
1307 for i in 0..nv {
1308 e[i] -= q * t[i];
1309 }
1310 let ssr_w: f64 = (0..nv).map(|i| e[i] * e[i]).sum();
1311 let bic = n_eff * (ssr_w / n_eff).ln() + k as f64 * n_eff.ln();
1312 bic_scores.insert(k, bic);
1313 if bic < best_bic {
1314 best_bic = bic;
1315 best_k = k;
1316 }
1317 }
1318 Ok((best_k, bic_scores))
1319}
1320
1321fn first_component_exhausted(
1330 x: MatRef<'_, f64>,
1331 y: ColRef<'_, f64>,
1332 weights: Option<ColRef<'_, f64>>,
1333 keep: Option<usize>,
1334) -> PlsKitResult<bool> {
1335 let m = pls1_fit_impl(
1336 x,
1337 y,
1338 KSpec::Fixed(1),
1339 weights,
1340 FitOpts {
1341 pre_standardized: false,
1342 check_n_eff: false,
1345 keep,
1346 ..FitOpts::default()
1347 },
1348 )?;
1349 Ok(m.k_used == 0)
1350}
1351
1352fn keep_grid(n_features: usize) -> Vec<usize> {
1357 let mut grid = Vec::new();
1358 let mut v = 1usize;
1359 while v < n_features {
1360 grid.push(v);
1361 v = v.saturating_mul(2);
1362 }
1363 grid.push(n_features);
1364 grid
1365}
1366
1367#[derive(Debug, Clone, Copy)]
1369pub struct FindKeepOptimalOpts {
1370 pub n_folds: usize,
1372 pub seed: Option<u64>,
1374 pub verbose: bool,
1376}
1377
1378impl Default for FindKeepOptimalOpts {
1379 fn default() -> Self {
1380 Self {
1381 n_folds: 5,
1382 seed: None,
1383 verbose: false,
1384 }
1385 }
1386}
1387
1388#[derive(Debug, Clone)]
1390pub struct FindKeepOptimalOutput {
1391 pub keep_star: usize,
1397 pub k: usize,
1399 pub cv_scores: BTreeMap<usize, f64>,
1401 pub cv_scores_se: BTreeMap<usize, f64>,
1403 pub keep_grid: Vec<usize>,
1406 pub seed: u64,
1408 pub n_eff: f64,
1410}
1411
1412#[allow(clippy::needless_pass_by_value)]
1437#[allow(clippy::many_single_char_names)]
1438pub fn spls1_find_keep_optimal(
1439 x: MatRef<'_, f64>,
1440 y: ColRef<'_, f64>,
1441 k: usize,
1442 weights: Option<ColRef<'_, f64>>,
1443 opts: FindKeepOptimalOpts,
1444) -> PlsKitResult<FindKeepOptimalOutput> {
1445 crate::fit::with_thread_limit(|| find_keep_optimal_impl(x, y, k, weights, opts))
1446}
1447
1448#[allow(clippy::needless_pass_by_value)]
1450#[allow(clippy::many_single_char_names)]
1451pub(crate) fn find_keep_optimal_impl(
1452 x: MatRef<'_, f64>,
1453 y: ColRef<'_, f64>,
1454 k: usize,
1455 weights: Option<ColRef<'_, f64>>,
1456 opts: FindKeepOptimalOpts,
1457) -> PlsKitResult<FindKeepOptimalOutput> {
1458 let n = x.nrows();
1459 if y.nrows() != n {
1460 return Err(PlsKitError::DimensionMismatch {
1461 x: (n, x.ncols()),
1462 y: y.nrows(),
1463 });
1464 }
1465 let d = x.ncols();
1466 if k == 0 {
1467 return Err(PlsKitError::InvalidArgument("k must be >= 1".into()));
1468 }
1469 if k > d {
1470 return Err(PlsKitError::KExceedsMax { k, k_max: d });
1471 }
1472 crate::fit::check_finite_mat(x)?;
1473 crate::fit::check_finite_col(y)?;
1474 let (w_norm, n_eff_val) = crate::fit::validate_weights_for_k(weights, n, k)?;
1475
1476 let (seed_used, mut rng) = crate::rng::resolve_seed(opts.seed)?;
1477 let grid = keep_grid(d);
1478 if opts.verbose {
1479 eprintln!("spls1_find_keep_optimal: sweeping keep grid {grid:?} at fixed k={k}");
1480 }
1481
1482 let n_folds = cv_n_folds(n, opts.n_folds);
1483 reject_cv_leave_one_out(n, opts.n_folds, n_folds)?;
1484 let wref = w_norm.as_ref().map(Col::as_ref);
1485
1486 let fold_work = |fd: &mut FoldData, cells: &[usize]| -> PlsKitResult<Vec<Option<f64>>> {
1487 let ss_tot = fd.ss_tot();
1488 cells
1489 .iter()
1490 .map(|&gi| {
1491 let preds = fd.fit_predict(x, k, Some(grid[gi]), |fit| vec![fit.coef.clone()])?;
1492 Ok(preds.map(|p| fd.r2(&p[0], ss_tot)))
1493 })
1494 .collect()
1495 };
1496
1497 let (keep_star, cv_scores, cv_scores_se) = cv_select(
1502 x, y, wref, None, n_folds, &grid, k, true, &mut rng, fold_work,
1503 )?;
1504 let grid = if keep_star == 0 { Vec::new() } else { grid };
1506
1507 Ok(FindKeepOptimalOutput {
1508 keep_star,
1509 k,
1510 cv_scores,
1511 cv_scores_se,
1512 keep_grid: grid,
1513 seed: seed_used,
1514 n_eff: n_eff_val,
1515 })
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520 use super::*;
1521 use crate::fit::pls1_fit;
1522 use crate::linalg::standardize_weighted;
1523 use crate::test_support::synth;
1524 use faer::Mat;
1525 use rand::SeedableRng;
1526
1527 #[allow(clippy::many_single_char_names)]
1531 fn degenerate_ys(n: usize, d: usize, seed: u64) -> (Mat<f64>, [(&'static str, Col<f64>); 2]) {
1532 use rand::RngExt;
1533 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(seed);
1534 let x = Mat::<f64>::from_fn(n, d, |_, _| rng.random_range(-1.0..1.0));
1535 let e = Col::<f64>::from_fn(n, |_| rng.random_range(-1.0..1.0));
1536 let basis = crate::test_support::orthonormal_basis(
1538 Col::<f64>::from_fn(n, |_| 1.0).as_ref(),
1539 x.as_ref(),
1540 0.0,
1541 );
1542 let mut y_orth = e;
1543 crate::test_support::project_off(&basis, &mut y_orth);
1544 let y_const = Col::<f64>::from_fn(n, |_| 3.0);
1545 (x, [("constant", y_const), ("orthogonal", y_orth)])
1546 }
1547
1548 #[test]
1554 fn optimal_first_component_truncation_returns_k_star_zero() {
1555 let (x, ys) = degenerate_ys(60, 8, 5);
1556 for (name, y) in &ys {
1557 let m = pls1_fit(
1559 x.as_ref(),
1560 y.as_ref(),
1561 KSpec::Fixed(2),
1562 None,
1563 FitOpts::default(),
1564 )
1565 .unwrap();
1566 assert_eq!(m.k_used, 0, "{name}: premise");
1567 for selector in [Selector::R2Se, Selector::R2Max, Selector::Bic] {
1568 let opts = FindKOptimalOpts {
1569 selector,
1570 seed: Some(3),
1571 ..Default::default()
1572 };
1573 let dense = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 3, None, opts).unwrap();
1574 let sparse =
1575 spls1_find_k_optimal(x.as_ref(), y.as_ref(), 3, 4, None, opts).unwrap();
1576 for (path, r) in [("pls1", &dense), ("spls1", &sparse)] {
1577 let tag = format!("{name} {path} {selector:?}");
1578 assert_eq!(r.k_star, 0, "{tag}");
1579 assert_eq!(r.seed, 3, "{tag}");
1580 match selector {
1581 Selector::R2Se => {
1582 assert!(r.cv_scores.as_ref().unwrap().is_empty(), "{tag}");
1583 assert!(r.cv_scores_se.as_ref().unwrap().is_empty(), "{tag}");
1584 assert!(r.bic_scores.is_none(), "{tag}");
1585 }
1586 Selector::R2Max => {
1587 assert!(r.cv_scores.as_ref().unwrap().is_empty(), "{tag}");
1588 assert!(r.cv_scores_se.is_none(), "{tag}");
1589 assert!(r.bic_scores.is_none(), "{tag}");
1590 }
1591 Selector::Bic => {
1592 assert!(r.cv_scores.is_none(), "{tag}");
1593 assert!(r.bic_scores.as_ref().unwrap().is_empty(), "{tag}");
1594 }
1595 }
1596 assert!(r.pvalues.is_none() && r.diagnostic.is_none(), "{tag}");
1597 }
1598 }
1599 }
1600 }
1601
1602 #[test]
1606 fn k_to_fit_refuses_k_star_zero() {
1607 let (x, ys) = degenerate_ys(60, 8, 5);
1608 for (name, y) in &ys {
1609 let opt = pls1_find_k_optimal(
1610 x.as_ref(),
1611 y.as_ref(),
1612 3,
1613 None,
1614 FindKOptimalOpts {
1615 seed: Some(3),
1616 ..Default::default()
1617 },
1618 )
1619 .unwrap();
1620 assert_eq!(opt.k_star, 0, "{name}");
1621 let e = opt.k_to_fit().unwrap_err();
1622 assert!(matches!(e, PlsKitError::OptimalNoComponent), "{name}: {e}");
1623 assert_eq!(e.code(), "optimal_no_component");
1624
1625 let seq = pls1_find_k_sequence(
1626 x.as_ref(),
1627 y.as_ref(),
1628 3,
1629 None,
1630 FindKSequenceOpts {
1631 test_method: ConfirmatoryMethod::E,
1632 seed: Some(3),
1633 ..Default::default()
1634 },
1635 )
1636 .unwrap();
1637 assert_eq!(seq.k_star, 0, "{name}");
1638 let e = seq.k_to_fit().unwrap_err();
1639 assert!(
1640 matches!(e, PlsKitError::SequenceNoRejection { alpha } if alpha.to_bits() == 0.05_f64.to_bits()),
1641 "{name}: {e}"
1642 );
1643 assert_eq!(e.code(), "sequence_no_rejection");
1644 }
1645
1646 let (x, y) = synth(80, 5, 1, 5.0, 1);
1647 let opt = pls1_find_k_optimal(
1648 x.as_ref(),
1649 y.as_ref(),
1650 4,
1651 None,
1652 FindKOptimalOpts {
1653 seed: Some(7),
1654 ..Default::default()
1655 },
1656 )
1657 .unwrap();
1658 assert!(opt.k_star >= 1);
1659 assert_eq!(opt.k_to_fit().unwrap(), opt.k_star);
1660 let seq = pls1_find_k_sequence(
1661 x.as_ref(),
1662 y.as_ref(),
1663 3,
1664 None,
1665 FindKSequenceOpts {
1666 test_method: ConfirmatoryMethod::E,
1667 seed: Some(7),
1668 ..Default::default()
1669 },
1670 )
1671 .unwrap();
1672 assert!(seq.k_star >= 1);
1673 assert_eq!(seq.k_to_fit().unwrap(), seq.k_star);
1674 }
1675
1676 #[test]
1682 fn keep_optimal_first_component_truncation_returns_keep_star_zero() {
1683 let (x, ys) = degenerate_ys(60, 8, 5);
1684 for (name, y) in &ys {
1685 for k in [1, 2] {
1686 let r = spls1_find_keep_optimal(
1687 x.as_ref(),
1688 y.as_ref(),
1689 k,
1690 None,
1691 FindKeepOptimalOpts {
1692 seed: Some(3),
1693 ..Default::default()
1694 },
1695 )
1696 .unwrap();
1697 let tag = format!("{name} k={k}");
1698 assert_eq!(r.keep_star, 0, "{tag}");
1699 assert_eq!(r.k, k, "{tag}");
1700 assert_eq!(r.seed, 3, "{tag}");
1701 assert!(r.cv_scores.is_empty(), "{tag}");
1702 assert!(r.cv_scores_se.is_empty(), "{tag}");
1703 assert!(r.keep_grid.is_empty(), "{tag}");
1704 }
1705 }
1706 }
1707
1708 #[test]
1712 fn optimal_diagnostic_at_k_star_zero_is_empty() {
1713 let (x, ys) = degenerate_ys(20, 5, 9);
1715 let gate = crate::signal_test::split_nb_gate(x.as_ref(), None).unwrap();
1716 assert!(gate.fires);
1717 for (name, y) in &ys {
1718 for (method, force, expect, has_rank) in [
1719 (ConfirmatoryMethod::E, false, "e", false),
1720 (ConfirmatoryMethod::RawPerm, false, "raw_perm", false),
1721 (ConfirmatoryMethod::SplitNb, false, "split_exact", true),
1722 (ConfirmatoryMethod::SplitNb, true, "split_nb", true),
1723 ] {
1724 for selector in [Selector::Bic, Selector::R2Se] {
1725 let r = pls1_find_k_optimal(
1726 x.as_ref(),
1727 y.as_ref(),
1728 2,
1729 None,
1730 FindKOptimalOpts {
1731 selector,
1732 diagnostic: Some(method),
1733 force,
1734 n_perm: 20,
1735 n_splits: 5,
1736 seed: Some(7),
1737 ..Default::default()
1738 },
1739 )
1740 .unwrap();
1741 let tag = format!("{name} {selector:?} {method:?} force={force}");
1742 assert_eq!(r.k_star, 0, "{tag}");
1743 assert_eq!(r.pvalues.as_ref().unwrap().nrows(), 0, "{tag}");
1744 assert_eq!(r.diagnostic.as_deref(), Some(expect), "{tag}");
1745 assert_eq!(r.stable_rank.is_some(), has_rank, "{tag}");
1746 if has_rank {
1747 assert_eq!(
1748 r.stable_rank.unwrap().to_bits(),
1749 gate.stable_rank.to_bits(),
1750 "{tag}"
1751 );
1752 }
1753 }
1754 }
1755 }
1756 }
1757
1758 #[test]
1770 fn optimal_raw_perm_diagnostic_rejects_small_n_regardless_of_k_star() {
1771 for n in [3_usize, 4, 5] {
1772 let (x, y) = synth(n, 3, 1, 4.0, 5);
1773 let (_, ys) = degenerate_ys(n, 3, 5);
1774 for selector in [Selector::Bic, Selector::R2Se] {
1775 for (name, y) in [("signal", &y), (ys[0].0, &ys[0].1), (ys[1].0, &ys[1].1)] {
1776 let r = pls1_find_k_optimal(
1777 x.as_ref(),
1778 y.as_ref(),
1779 2,
1780 None,
1781 FindKOptimalOpts {
1782 selector,
1783 diagnostic: Some(ConfirmatoryMethod::RawPerm),
1784 n_perm: 20,
1785 seed: Some(7),
1786 ..Default::default()
1787 },
1788 );
1789 match r {
1790 Err(PlsKitError::InvalidArgument(msg)) => {
1791 assert!(msg.contains("n > 5"), "n={n} {selector:?} {name}: {msg}");
1792 }
1793 other => panic!(
1794 "n={n} {selector:?} {name}: expected InvalidArgument, got {other:?}"
1795 ),
1796 }
1797 }
1798 }
1799 }
1800 }
1801
1802 #[test]
1813 fn optimal_rejects_leave_one_out_at_n_eq_2() {
1814 let (x, y) = synth(2, 3, 1, 4.0, 6);
1815 for selector in [Selector::R2Se, Selector::R2Max] {
1816 let r = pls1_find_k_optimal(
1817 x.as_ref(),
1818 y.as_ref(),
1819 1,
1820 None,
1821 FindKOptimalOpts {
1822 selector,
1823 ..Default::default()
1824 },
1825 );
1826 assert!(
1827 matches!(r, Err(PlsKitError::InvalidArgument(_))),
1828 "{selector:?}: expected InvalidArgument for n = 2, got {r:?}"
1829 );
1830 }
1831 let r = spls1_find_keep_optimal(
1832 x.as_ref(),
1833 y.as_ref(),
1834 1,
1835 None,
1836 FindKeepOptimalOpts::default(),
1837 );
1838 assert!(
1839 matches!(r, Err(PlsKitError::InvalidArgument(_))),
1840 "find_keep: expected InvalidArgument for n = 2, got {r:?}"
1841 );
1842 }
1843
1844 #[test]
1851 fn split_nb_gate_agrees_across_entry_points_at_the_n_eff_floor() {
1852 let (x, y) = synth(26, 8, 1, 4.0, 3);
1853 let w = Col::<f64>::from_fn(26, |i| if i == 25 { 2.083_333_333_333_333 } else { 1.0 });
1854 assert!(
1857 !crate::signal_test::split_nb_gate(x.as_ref(), None)
1858 .unwrap()
1859 .fires
1860 );
1861 let gate = crate::signal_test::split_nb_gate(x.as_ref(), Some(w.as_ref())).unwrap();
1862 assert!(gate.n_eff < 25.0, "n_eff = {}", gate.n_eff);
1863 assert!(gate.fires);
1864
1865 let conf = crate::signal_test::pls1_confirmatory_test(
1866 crate::signal_test::ConfirmatoryTestInput::Raw {
1867 x: x.as_ref(),
1868 y: y.as_ref(),
1869 k: 1,
1870 weights: Some(w.as_ref()),
1871 },
1872 crate::signal_test::ConfirmatoryTestOpts {
1873 args: crate::signal_test::ConfirmatoryArgs::SplitNb {
1874 n_splits: 5,
1875 force: false,
1876 },
1877 seed: Some(1),
1878 ..Default::default()
1879 },
1880 )
1881 .unwrap();
1882 assert_eq!(conf.test_method, "split_exact");
1883 assert_eq!(
1884 conf.stable_rank.unwrap().to_bits(),
1885 gate.stable_rank.to_bits()
1886 );
1887
1888 let seq = pls1_find_k_sequence(
1889 x.as_ref(),
1890 y.as_ref(),
1891 2,
1892 Some(w.as_ref()),
1893 FindKSequenceOpts {
1894 test_method: ConfirmatoryMethod::SplitNb,
1895 n_perm: 20,
1896 n_splits: 5,
1897 seed: Some(1),
1898 ..Default::default()
1899 },
1900 )
1901 .unwrap();
1902 assert_eq!(seq.test_method, "split_exact");
1903 assert_eq!(seq.n_eff.to_bits(), gate.n_eff.to_bits());
1904 assert_eq!(
1905 seq.stable_rank.unwrap().to_bits(),
1906 gate.stable_rank.to_bits()
1907 );
1908
1909 let opt = pls1_find_k_optimal(
1910 x.as_ref(),
1911 y.as_ref(),
1912 2,
1913 Some(w.as_ref()),
1914 FindKOptimalOpts {
1915 selector: Selector::Bic,
1916 diagnostic: Some(ConfirmatoryMethod::SplitNb),
1917 n_perm: 20,
1918 n_splits: 5,
1919 seed: Some(1),
1920 ..Default::default()
1921 },
1922 )
1923 .unwrap();
1924 assert!(opt.k_star >= 1);
1925 assert_eq!(opt.diagnostic.as_deref(), Some("split_exact"));
1926 assert_eq!(
1927 opt.stable_rank.unwrap().to_bits(),
1928 gate.stable_rank.to_bits()
1929 );
1930 }
1931
1932 #[test]
1936 fn sequence_first_component_truncation_returns_k_star_zero() {
1937 let (x, ys) = degenerate_ys(60, 8, 5);
1938 for (name, y) in &ys {
1939 for method in [
1940 ConfirmatoryMethod::E,
1941 ConfirmatoryMethod::RawPerm,
1942 ConfirmatoryMethod::SplitExact,
1943 ConfirmatoryMethod::SplitNb,
1944 ] {
1945 let opts = FindKSequenceOpts {
1946 test_method: method,
1947 n_perm: 50,
1948 n_splits: 10,
1949 force: true,
1950 seed: Some(3),
1951 ..Default::default()
1952 };
1953 let dense = pls1_find_k_sequence(x.as_ref(), y.as_ref(), 3, None, opts).unwrap();
1954 let sparse =
1955 spls1_find_k_sequence(x.as_ref(), y.as_ref(), 3, 4, None, opts).unwrap();
1956 for (path, r) in [("pls1", &dense), ("spls1", &sparse)] {
1957 let tag = format!("{name} {path} {method:?}");
1958 assert_eq!(r.k_star, 0, "{tag}");
1959 assert_eq!(r.pvalues.nrows(), 3, "{tag}");
1960 assert!(r.pvalues[0] >= r.alpha, "{tag}: p1={}", r.pvalues[0]);
1961 assert!(r.pvalues[1].is_nan() && r.pvalues[2].is_nan(), "{tag}");
1962 }
1963 }
1964 }
1965 }
1966
1967 #[test]
1975 fn optimal_diagnostic_force_is_settable_from_public_opts() {
1976 let (x, y) = synth(20, 5, 1, 5.0, 1);
1977 let opts = |force: bool| FindKOptimalOpts {
1978 selector: Selector::Bic,
1979 diagnostic: Some(ConfirmatoryMethod::SplitNb),
1980 n_splits: 10,
1981 force,
1982 seed: Some(7),
1983 ..Default::default()
1984 };
1985 let rerouted = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 2, None, opts(false)).unwrap();
1986 assert_eq!(rerouted.diagnostic.as_deref(), Some("split_exact"));
1987 assert!(rerouted.stable_rank.is_some());
1988 let forced = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 2, None, opts(true)).unwrap();
1989 assert_eq!(forced.diagnostic.as_deref(), Some("split_nb"));
1990 for diagnostic in [Some(ConfirmatoryMethod::RawPerm), None] {
1991 let r = pls1_find_k_optimal(
1992 x.as_ref(),
1993 y.as_ref(),
1994 2,
1995 None,
1996 FindKOptimalOpts {
1997 diagnostic,
1998 n_splits: 10,
1999 n_perm: 50,
2000 seed: Some(7),
2001 ..Default::default()
2002 },
2003 )
2004 .unwrap();
2005 assert!(r.stable_rank.is_none(), "{diagnostic:?}");
2006 }
2007 }
2008
2009 #[test]
2013 fn sequence_reports_pvalues_n_eff_and_gate_rank() {
2014 let (x, y) = synth(80, 5, 1, 5.0, 1);
2015 let r = pls1_find_k_sequence(
2016 x.as_ref(),
2017 y.as_ref(),
2018 4,
2019 None,
2020 FindKSequenceOpts {
2021 test_method: ConfirmatoryMethod::SplitNb,
2022 n_splits: 30,
2023 alpha: 0.05,
2024 seed: Some(7),
2025 ..Default::default()
2026 },
2027 )
2028 .unwrap();
2029 assert_eq!(r.pvalues.nrows(), 4);
2030 assert_eq!(r.test_method, "split_nb");
2031 assert!((r.n_eff - 80.0).abs() < 1e-9);
2032 assert!(r.stable_rank.is_some());
2033 }
2034
2035 #[test]
2039 fn sequence_force_is_settable_from_public_opts() {
2040 let (x, y) = synth(20, 5, 1, 5.0, 1);
2041 let opts = |force: bool| FindKSequenceOpts {
2042 test_method: ConfirmatoryMethod::SplitNb,
2043 n_splits: 10,
2044 alpha: 0.05,
2045 force,
2046 seed: Some(7),
2047 ..Default::default()
2048 };
2049 let rerouted = pls1_find_k_sequence(x.as_ref(), y.as_ref(), 2, None, opts(false)).unwrap();
2050 assert_eq!(rerouted.test_method, "split_exact");
2051 let forced = pls1_find_k_sequence(x.as_ref(), y.as_ref(), 2, None, opts(true)).unwrap();
2052 assert_eq!(forced.test_method, "split_nb");
2053 assert_eq!(rerouted.stable_rank, forced.stable_rank);
2056 assert!(rerouted.stable_rank.is_some());
2057 }
2058
2059 #[test]
2064 fn sequence_auto_matches_the_resolved_method_at_the_same_seed() {
2065 let opts = |test_method| FindKSequenceOpts {
2066 test_method,
2067 n_perm: 19,
2068 n_splits: 4,
2069 seed: Some(5),
2070 ..Default::default()
2071 };
2072 for ((x, y), explicit, has_rank) in [
2073 (
2074 synth(60, 6, 1, 4.0, 2),
2075 ConfirmatoryMethod::SplitExact,
2076 false,
2077 ),
2078 (synth(300, 10, 1, 1.0, 3), ConfirmatoryMethod::SplitNb, true),
2079 ] {
2080 let run = |m| pls1_find_k_sequence(x.as_ref(), y.as_ref(), 3, None, opts(m)).unwrap();
2081 let mut got = run(ConfirmatoryMethod::Auto);
2082 let mut want = run(explicit);
2083 assert_eq!(got.test_method, explicit.as_str());
2084 assert_eq!(got.stable_rank.is_some(), has_rank, "{explicit:?}");
2085 got.stable_rank = None;
2086 want.stable_rank = None;
2087 assert_eq!(format!("{got:?}"), format!("{want:?}"), "{explicit:?}");
2088 }
2089 }
2090
2091 #[test]
2095 fn sequence_auto_rejects_bad_counts_and_force() {
2096 let (x, y) = synth(300, 10, 1, 1.0, 3);
2097 let base = FindKSequenceOpts {
2098 test_method: ConfirmatoryMethod::Auto,
2099 n_perm: 19,
2100 n_splits: 4,
2101 seed: Some(5),
2102 ..Default::default()
2103 };
2104 assert_eq!(
2105 pls1_find_k_sequence(x.as_ref(), y.as_ref(), 1, None, base)
2106 .unwrap()
2107 .test_method,
2108 "split_nb"
2109 );
2110 for (tag, opts) in [
2111 ("n_perm = 0", FindKSequenceOpts { n_perm: 0, ..base }),
2112 (
2113 "n_splits = 1",
2114 FindKSequenceOpts {
2115 n_splits: 1,
2116 ..base
2117 },
2118 ),
2119 (
2120 "force",
2121 FindKSequenceOpts {
2122 force: true,
2123 ..base
2124 },
2125 ),
2126 ] {
2127 let r = pls1_find_k_sequence(x.as_ref(), y.as_ref(), 1, None, opts);
2128 assert!(
2129 matches!(r, Err(PlsKitError::InvalidArgument(_))),
2130 "{tag}: {r:?}"
2131 );
2132 }
2133 }
2134
2135 #[test]
2139 fn score_and_auto_are_refused_where_they_have_no_meaning() {
2140 let (x, y) = synth(60, 5, 1, 4.0, 3);
2141 let optimal = |diagnostic| {
2142 pls1_find_k_optimal(
2143 x.as_ref(),
2144 y.as_ref(),
2145 3,
2146 None,
2147 FindKOptimalOpts {
2148 diagnostic: Some(diagnostic),
2149 ..Default::default()
2150 },
2151 )
2152 .map(|_| ())
2153 };
2154 let sequence = pls1_find_k_sequence(
2155 x.as_ref(),
2156 y.as_ref(),
2157 3,
2158 None,
2159 FindKSequenceOpts {
2160 test_method: ConfirmatoryMethod::Score,
2161 ..Default::default()
2162 },
2163 )
2164 .map(|_| ());
2165 for (row, got, want) in [
2166 (
2167 "optimal, diagnostic score",
2168 optimal(ConfirmatoryMethod::Score),
2169 "score has no sequential variant",
2170 ),
2171 (
2172 "sequence, test_method score",
2173 sequence,
2174 "test_method='score' has no sequential variant",
2175 ),
2176 (
2177 "optimal, diagnostic auto",
2178 optimal(ConfirmatoryMethod::Auto),
2179 "diagnostic does not accept 'auto'",
2180 ),
2181 ] {
2182 match got {
2183 Err(PlsKitError::InvalidArgument(msg)) => assert_eq!(msg, want, "{row}"),
2184 other => panic!("{row}: expected InvalidArgument, got {other:?}"),
2185 }
2186 }
2187 }
2188
2189 #[test]
2190 fn spls1_find_k_optimal_dense_endpoint_bit_parity() {
2191 let (x, y) = synth(80, 5, 1, 5.0, 1);
2194 for diagnostic in [None, Some(ConfirmatoryMethod::SplitExact)] {
2195 let opts = FindKOptimalOpts {
2196 selector: Selector::R2Se,
2197 diagnostic,
2198 n_perm: 99,
2199 seed: Some(7),
2200 ..Default::default()
2201 };
2202 let dense = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 4, None, opts).unwrap();
2203 let sparse = spls1_find_k_optimal(x.as_ref(), y.as_ref(), 4, 5, None, opts).unwrap();
2204 assert_eq!(dense.k_star, sparse.k_star, "{diagnostic:?}");
2205 assert_eq!(dense.seed, sparse.seed, "{diagnostic:?}");
2206 let (dm, sm) = (dense.cv_scores.unwrap(), sparse.cv_scores.unwrap());
2207 assert_eq!(dm.len(), sm.len());
2208 for (k, v) in &dm {
2209 assert_eq!(
2210 v.to_bits(),
2211 sm[k].to_bits(),
2212 "{diagnostic:?} cv_scores[{k}]"
2213 );
2214 }
2215 let bits =
2216 |p: Option<Col<f64>>| p.map(|p| p.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
2217 assert_eq!(
2218 bits(dense.pvalues),
2219 bits(sparse.pvalues),
2220 "{diagnostic:?} pvalues"
2221 );
2222 assert_eq!(
2223 dense.stable_rank.map(f64::to_bits),
2224 sparse.stable_rank.map(f64::to_bits),
2225 "{diagnostic:?} stable_rank"
2226 );
2227 }
2228 }
2229
2230 #[test]
2235 fn spls1_find_k_optimal_bic_runs_sparse() {
2236 let (x, y) = synth(60, 5, 2, 4.0, 3);
2237 let opts = FindKOptimalOpts {
2238 selector: Selector::Bic,
2239 seed: Some(13),
2240 ..Default::default()
2241 };
2242 let r = spls1_find_k_optimal(x.as_ref(), y.as_ref(), 4, 2, None, opts).unwrap();
2243 let bic = r.bic_scores.as_ref().expect("bic selector reports scores");
2244 let keys: Vec<usize> = bic.keys().copied().collect();
2245 assert!(
2246 !keys.is_empty() && keys == (1..=keys.len()).collect::<Vec<_>>(),
2247 "{keys:?}"
2248 );
2249 let argmin = bic
2250 .iter()
2251 .min_by(|a, b| a.1.total_cmp(b.1))
2252 .map(|(k, _)| *k)
2253 .unwrap();
2254 assert_eq!(r.k_star, argmin);
2255 let dense = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 4, None, opts).unwrap();
2256 let dense_bic = dense.bic_scores.unwrap();
2257 assert!(
2258 bic.iter()
2259 .any(|(k, v)| dense_bic.get(k).is_none_or(|d| d.to_bits() != v.to_bits())),
2260 "keep = 2 must change the BIC sweep"
2261 );
2262 }
2263
2264 #[test]
2267 fn spls1_find_k_entries_reject_bad_keep() {
2268 let (x, y) = synth(60, 5, 1, 4.0, 3);
2269 let keep = 9;
2270 let e = spls1_find_k_optimal(
2271 x.as_ref(),
2272 y.as_ref(),
2273 3,
2274 keep,
2275 None,
2276 FindKOptimalOpts::default(),
2277 );
2278 assert!(
2279 matches!(e, Err(PlsKitError::InvalidArgument(_))),
2280 "optimal keep={keep}: {e:?}"
2281 );
2282 let e = spls1_find_k_sequence(
2283 x.as_ref(),
2284 y.as_ref(),
2285 3,
2286 keep,
2287 None,
2288 FindKSequenceOpts::default(),
2289 );
2290 assert!(
2291 matches!(e, Err(PlsKitError::InvalidArgument(_))),
2292 "sequence keep={keep}: {e:?}"
2293 );
2294 }
2295
2296 #[test]
2297 fn spls1_find_k_sequence_dense_endpoint_bit_parity() {
2298 let (x, y) = synth(80, 5, 1, 5.0, 1);
2299 for test_method in [
2300 ConfirmatoryMethod::SplitNb,
2301 ConfirmatoryMethod::SplitExact,
2302 ConfirmatoryMethod::Auto,
2303 ] {
2304 let opts = FindKSequenceOpts {
2305 test_method,
2306 n_splits: 30,
2307 n_perm: 99,
2308 alpha: 0.05,
2309 seed: Some(7),
2310 ..Default::default()
2311 };
2312 let dense = pls1_find_k_sequence(x.as_ref(), y.as_ref(), 4, None, opts).unwrap();
2313 let sparse = spls1_find_k_sequence(x.as_ref(), y.as_ref(), 4, 5, None, opts).unwrap();
2314 assert_eq!(dense.k_star, sparse.k_star, "{test_method:?}");
2315 assert_eq!(dense.seed, sparse.seed, "{test_method:?}");
2316 assert_eq!(dense.test_method, sparse.test_method, "{test_method:?}");
2317 assert_eq!(
2318 dense.stable_rank.map(f64::to_bits),
2319 sparse.stable_rank.map(f64::to_bits),
2320 "{test_method:?} stable_rank"
2321 );
2322 for i in 0..4 {
2323 assert_eq!(
2324 dense.pvalues[i].to_bits(),
2325 sparse.pvalues[i].to_bits(),
2326 "{test_method:?} pvalues[{i}]"
2327 );
2328 }
2329 }
2330 }
2331
2332 #[test]
2333 fn keep_grid_powers_of_two_with_endpoints() {
2334 assert_eq!(keep_grid(1), vec![1]);
2335 assert_eq!(keep_grid(6), vec![1, 2, 4, 6]);
2336 assert_eq!(keep_grid(8), vec![1, 2, 4, 8]);
2337 assert_eq!(keep_grid(100), vec![1, 2, 4, 8, 16, 32, 64, 100]);
2338 }
2339
2340 #[test]
2346 fn spls1_find_keep_optimal_selects_sparsest_within_1se() {
2347 let (x, y) = synth(80, 6, 2, 1.0, 1);
2348 let r = spls1_find_keep_optimal(
2349 x.as_ref(),
2350 y.as_ref(),
2351 1,
2352 None,
2353 FindKeepOptimalOpts {
2354 seed: Some(7),
2355 ..Default::default()
2356 },
2357 )
2358 .unwrap();
2359 assert_eq!(r.keep_grid, vec![1, 2, 4, 6]);
2360 assert_eq!(r.k, 1);
2361 assert!((r.n_eff - 80.0).abs() < 1e-9);
2362 let best = r
2365 .cv_scores
2366 .iter()
2367 .filter(|(_, v)| v.is_finite())
2368 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
2369 .map(|(k, _)| *k)
2370 .unwrap();
2371 let threshold = r.cv_scores[&best] - r.cv_scores_se[&best];
2372 let expected = r
2373 .cv_scores
2374 .iter()
2375 .filter(|(_, v)| v.is_finite() && **v >= threshold)
2376 .map(|(k, _)| *k)
2377 .min()
2378 .unwrap();
2379 assert_ne!(
2380 best, r.keep_star,
2381 "fixture must separate the 1-SE pick from the argmax"
2382 );
2383 assert_eq!(r.keep_star, expected);
2384 assert!(r.keep_grid.contains(&r.keep_star));
2385 }
2386
2387 #[test]
2389 fn spls1_find_keep_optimal_rejects_k_above_n_features() {
2390 let (x, y) = synth(40, 5, 1, 4.0, 2);
2391 let e = spls1_find_keep_optimal(
2392 x.as_ref(),
2393 y.as_ref(),
2394 6,
2395 None,
2396 FindKeepOptimalOpts::default(),
2397 );
2398 assert!(matches!(e, Err(PlsKitError::KExceedsMax { .. })));
2399 }
2400
2401 #[allow(clippy::cast_precision_loss)]
2405 fn push_optimal(v: &mut Vec<f64>, r: &FindKOptimalOutput) {
2406 fn push_map(v: &mut Vec<f64>, m: Option<&BTreeMap<usize, f64>>) {
2407 match m {
2408 Some(m) => {
2409 v.push(m.len() as f64);
2410 v.extend(m.values());
2411 }
2412 None => v.push(-1.0),
2413 }
2414 }
2415 v.extend([r.k_star as f64, r.n_eff, r.stable_rank.unwrap_or(f64::NAN)]);
2416 push_map(v, r.cv_scores.as_ref());
2417 push_map(v, r.cv_scores_se.as_ref());
2418 push_map(v, r.bic_scores.as_ref());
2419 if let Some(p) = &r.pvalues {
2420 v.extend((0..p.nrows()).map(|i| p[i]));
2421 }
2422 }
2423
2424 #[test]
2435 #[allow(clippy::cast_precision_loss)]
2436 #[allow(clippy::many_single_char_names)]
2437 fn selection_agrees_across_layouts() {
2438 use crate::test_support::{Agree, assert_layout_agree, copy_free_families};
2439 for f in copy_free_families() {
2440 let w = f.w.as_ref().map(Col::as_ref);
2441 for pre in [false, true] {
2442 let (x, y) = f.inputs(pre);
2443 for (selector, diagnostic) in [
2444 (Selector::Bic, None),
2445 (Selector::Bic, Some(ConfirmatoryMethod::SplitExact)),
2446 (Selector::R2Se, None),
2447 (Selector::R2Se, Some(ConfirmatoryMethod::SplitNb)),
2448 ] {
2449 let opts = FindKOptimalOpts {
2450 selector,
2451 diagnostic,
2452 n_perm: 49,
2453 n_splits: 10,
2454 pre_standardized: pre,
2455 seed: Some(7),
2456 ..Default::default()
2457 };
2458 let what = format!("{selector:?} {} pre={pre} {diagnostic:?}", f.name);
2459 assert_layout_agree(&x, &what, Agree::Corpus(1e-10), |xv| {
2460 let mut v = Vec::new();
2461 push_optimal(&mut v, &pls1_find_k_optimal(xv, y.as_ref(), 3, w, opts)?);
2462 push_optimal(
2463 &mut v,
2464 &spls1_find_k_optimal(xv, y.as_ref(), 3, 3, w, opts)?,
2465 );
2466 Ok(v)
2467 })
2468 .unwrap();
2469 }
2470 let what = format!("keep_optimal {} pre={pre}", f.name);
2471 assert_layout_agree(&x, &what, Agree::Corpus(1e-10), |xv| {
2472 let r = spls1_find_keep_optimal(
2473 xv,
2474 y.as_ref(),
2475 2,
2476 w,
2477 FindKeepOptimalOpts {
2478 seed: Some(7),
2479 ..Default::default()
2480 },
2481 )?;
2482 let mut v = vec![r.keep_star as f64, r.n_eff];
2483 v.extend(r.cv_scores.values());
2484 v.extend(r.cv_scores_se.values());
2485 Ok(v)
2486 })
2487 .unwrap();
2488 }
2489 }
2490 }
2491
2492 #[test]
2496 fn cv_selection_does_not_depend_on_the_budget() {
2497 let (x, y) = crate::test_support::signal_data(48, 7, 101);
2498 let w = crate::test_support::test_weights(48);
2499 for weights in [None, Some(w.as_ref())] {
2500 let run = |b| {
2501 let r = crate::fit::with_budget(b, || {
2502 find_k_optimal_impl(
2503 x.as_ref(),
2504 y.as_ref(),
2505 4,
2506 None,
2507 weights,
2508 FindKOptimalOpts {
2509 selector: Selector::R2Se,
2510 seed: Some(7),
2511 ..Default::default()
2512 },
2513 )
2514 .unwrap()
2515 });
2516 format!("{r:?}")
2517 };
2518 assert_eq!(run(0), run(u64::MAX), "weighted={}", weights.is_some());
2519 }
2520 }
2521
2522 #[test]
2525 fn cv_select_runs_one_fold_at_a_time_at_budget_zero() {
2526 use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
2527 let (x, y) = crate::test_support::signal_data(48, 7, 101);
2528 let pool = rayon::ThreadPoolBuilder::new()
2529 .num_threads(4)
2530 .build()
2531 .unwrap();
2532 let peak_at = |budget| {
2533 let (live, peak) = (AtomicUsize::new(0), AtomicUsize::new(0));
2534 let (_, mut rng) = crate::rng::resolve_seed(Some(7)).unwrap();
2535 pool.install(|| {
2536 crate::fit::with_budget(budget, || {
2537 cv_select(
2538 x.as_ref(),
2539 y.as_ref(),
2540 None,
2541 None,
2542 5,
2543 &[1],
2544 1,
2545 false,
2546 &mut rng,
2547 |_, cells| {
2548 peak.fetch_max(live.fetch_add(1, SeqCst) + 1, SeqCst);
2549 std::thread::sleep(std::time::Duration::from_millis(5));
2551 live.fetch_sub(1, SeqCst);
2552 Ok(vec![Some(0.0); cells.len()])
2553 },
2554 )
2555 })
2556 })
2557 .unwrap();
2558 peak.load(SeqCst)
2559 };
2560 assert_eq!(peak_at(0), 1);
2561 assert!(peak_at(u64::MAX) >= 2);
2562 }
2563
2564 #[test]
2571 #[allow(clippy::many_single_char_names)]
2572 fn cv_select_counts_one_fold_fit() {
2573 let (n, p) = (16_usize, 7_usize);
2574 let (x, y) = crate::test_support::signal_data(n, p, 101);
2575 let w = crate::test_support::test_weights(n);
2576 let unit = cv_fold_bytes(p, 1, n / 4);
2577 let folds = crate::linalg::fold_split(&(0..n).collect::<Vec<_>>(), 4);
2578 let blocks = crate::resample::fold_blocks_bytes(n, &folds, p);
2579 for weights in [None, Some(w.as_ref())] {
2580 let fit_copy = if weights.is_some() {
2581 crate::resample::copy_bytes(n - n / 4, p)
2582 } else {
2583 0
2584 };
2585 let unit2 = unit + blocks + fit_copy;
2586 let (_, mut rng) = crate::rng::resolve_seed(Some(7)).unwrap();
2587 let (_, log) = crate::resample::with_batch_log(|| {
2588 crate::fit::with_budget(u64::try_from(2 * unit2).unwrap(), || {
2589 cv_select(
2590 x.as_ref(),
2591 y.as_ref(),
2592 weights,
2593 None,
2594 4,
2595 &[1],
2596 1,
2597 false,
2598 &mut rng,
2599 |fd, cells| Ok(vec![fd.on_blocks.then_some(0.0); cells.len()]),
2600 )
2601 .unwrap()
2602 })
2603 });
2604 let first = (2 * unit2 / unit).min(4);
2605 crate::resample::assert_batches(&log, 4, first, 1);
2606 crate::resample::assert_batches(&log, 4, 2, 1);
2607 }
2608 }
2609
2610 #[test]
2617 fn bic_sweep_matches_the_prediction_sweep() {
2618 use crate::test_support::{Agree, Family, assert_agree, copy_free_families, signal_data};
2619 let (b, yb) = signal_data(40, 2, 103);
2621 let xb = Mat::<f64>::from_fn(40, 6, |i, j| {
2622 (j + 1) as f64 * b[(i, j % 2)] - b[(i, 1 - j % 2)]
2623 });
2624 let mut families = copy_free_families();
2625 families.push(Family {
2626 name: "rank_two",
2627 x: xb,
2628 y: yb,
2629 w: None,
2630 });
2631 for f in families {
2632 let n = f.x.nrows();
2633 let (w_norm, n_eff) =
2634 crate::fit::validate_and_normalize_weights(f.w.as_ref().map(Col::as_ref), n, 4)
2635 .unwrap();
2636 let wr = w_norm.as_ref().map(Col::as_ref);
2637 for keep in [None, Some(3)] {
2638 let what = format!("{} keep={keep:?}", f.name);
2639 let (k_star, scores) =
2640 select_bic(f.x.as_ref(), f.y.as_ref(), 4, wr, n_eff, keep).unwrap();
2641 let (xs, _, _) = standardize_weighted(f.x.as_ref(), wr);
2642 let (ys, _, _) = standardize1_weighted(f.y.as_ref(), wr);
2643 let m = pls1_fit_impl(
2644 xs.as_ref(),
2645 ys.as_ref(),
2646 KSpec::Fixed(4),
2647 wr,
2648 FitOpts {
2649 pre_standardized: true,
2650 check_n_eff: false,
2651 keep,
2652 ..FitOpts::default()
2653 },
2654 )
2655 .unwrap();
2656 if f.name == "rank_two" {
2657 assert!(m.k_used > 0 && m.k_used < 4, "{what}: k_used {}", m.k_used);
2658 }
2659 assert!(scores.keys().copied().eq(1..=m.k_used), "{what}: keys");
2660 let want: Vec<f64> = (1..=m.k_used)
2661 .map(|k| {
2662 let coef = crate::fit::pls1_coef_at_k(
2663 &m.w_star,
2664 &m.p_loadings,
2665 &m.q_loadings,
2666 k,
2667 faer::Par::Seq,
2668 );
2669 let pred = crate::linalg::mat_vec(
2670 xs.as_ref(),
2671 coef.as_ref(),
2672 crate::fit::par_fixed(),
2673 );
2674 let ssr: f64 = (0..n)
2675 .map(|i| wr.map_or(1.0, |w| w[i]) * (pred[i] - ys[i]).powi(2))
2676 .sum();
2677 n_eff * (ssr / n_eff).ln() + k as f64 * n_eff.ln()
2678 })
2679 .collect();
2680 let got: Vec<f64> = scores.values().copied().collect();
2681 assert_agree(&got, &want, Agree::Corpus(1e-10), &what);
2682 let argmin = want
2683 .iter()
2684 .enumerate()
2685 .min_by(|a, b| a.1.total_cmp(b.1))
2686 .map_or(0, |(i, _)| i + 1);
2687 assert_eq!(k_star, argmin, "{what}");
2688 }
2689 }
2690 }
2691
2692 #[test]
2699 fn cv_selection_rejects_x_that_overflows_in_standardization() {
2700 for p in [6_usize, 200] {
2701 let (mut x, y) = crate::test_support::signal_data(40, p, 105);
2702 x[(0, 0)] = f64::MAX;
2703 x[(1, 0)] = f64::MAX;
2704 x[(2, 0)] = -f64::MAX;
2705 let opts = FindKOptimalOpts {
2706 selector: Selector::R2Se,
2707 seed: Some(7),
2708 ..Default::default()
2709 };
2710 let r = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 3, None, opts);
2711 assert!(
2712 matches!(r, Err(PlsKitError::NonFiniteInput)),
2713 "pls1 40x{p}: {r:?}"
2714 );
2715 let r = spls1_find_k_optimal(x.as_ref(), y.as_ref(), 3, 3, None, opts);
2716 assert!(
2717 matches!(r, Err(PlsKitError::NonFiniteInput)),
2718 "spls1 40x{p}: {r:?}"
2719 );
2720 let r = spls1_find_keep_optimal(
2721 x.as_ref(),
2722 y.as_ref(),
2723 2,
2724 None,
2725 FindKeepOptimalOpts {
2726 seed: Some(7),
2727 ..Default::default()
2728 },
2729 );
2730 assert!(
2731 matches!(r, Err(PlsKitError::NonFiniteInput)),
2732 "keep 40x{p}: {r:?}"
2733 );
2734 }
2735 }
2736
2737 #[test]
2747 fn cv_fold_scores_match_the_copy() {
2748 use crate::test_support::{Agree, assert_agree, copy_free_families, signal_data};
2749 let mut largest = 0.0_f64;
2750 let mut fell_back = 0_usize;
2751 let mut check = |name: &str, x: MatRef<'_, f64>, y: &Col<f64>, w: Option<&Col<f64>>| {
2752 let (n, p) = (x.nrows(), x.ncols());
2753 let (w_norm, _) =
2754 crate::fit::validate_and_normalize_weights(w.map(Col::as_ref), n, 1).unwrap();
2755 let wr = w_norm.as_ref().map(Col::as_ref);
2756 let (_, mut rng) = crate::rng::resolve_seed(Some(7)).unwrap();
2757 let mut idx: Vec<usize> = (0..n).collect();
2758 idx.shuffle(&mut rng);
2759 let folds = crate::linalg::fold_split(&idx, 5);
2760 let mut sweeps: Vec<(usize, Option<usize>)> = vec![(3, None)];
2761 sweeps.extend(keep_grid(p).into_iter().map(|kp| (2, Some(kp))));
2762 for fi in 0..folds.len() {
2763 for &(k, keep) in &sweeps {
2764 let what = format!("{name} fold {fi} k={k} keep={keep:?}");
2765 let mut fd = prep_fold(x, y.as_ref(), wr, &folds, fi, &folds[fi]).unwrap();
2766 let Some(got) = fd.fit_predict(x, k, keep, sweep_coefs).unwrap() else {
2767 assert!(
2768 keep.is_some(),
2769 "{what}: the dense sweep fell back to the blocks"
2770 );
2771 fell_back += 1;
2772 continue;
2773 };
2774 let want = fd.fit_predict_on_copy(x, k, keep, sweep_coefs).unwrap();
2775 let ss_tot = fd.ss_tot();
2776 let g: Vec<f64> = got.iter().map(|p| fd.r2(p, ss_tot)).collect();
2777 let v: Vec<f64> = want.iter().map(|p| fd.r2(p, ss_tot)).collect();
2778 assert_agree(&g, &v, Agree::Corpus(1e-10), &what);
2779 largest = g
2780 .iter()
2781 .zip(&v)
2782 .fold(largest, |m, (a, b)| m.max((a - b).abs()));
2783 }
2784 }
2785 };
2786 for f in copy_free_families() {
2787 check(f.name, f.x.as_ref(), &f.y, f.w.as_ref());
2788 }
2789 let (x, y) = signal_data(60, 2100, 104);
2790 let x_t = x.transpose().to_owned();
2791 check("wide_row_major", x_t.transpose(), &y, None);
2792 eprintln!(
2793 "cv_fold_scores_match_the_copy: largest |difference| {largest:e}, \
2794 keep-grid fits on the blocks {fell_back}"
2795 );
2796 }
2797
2798 #[test]
2805 #[allow(clippy::cast_precision_loss)]
2806 fn cv_fold_falls_back_to_the_blocks() {
2807 use crate::test_support::{copy_free_families, signal_data};
2808 let fams = copy_free_families();
2809 let offset = Mat::<f64>::from_fn(fams[1].x.nrows(), fams[1].x.ncols(), |i, j| {
2810 fams[1].x[(i, j)] + 1e6
2811 });
2812 let (b, yb) = signal_data(40, 2, 103);
2813 let rank_two = Mat::<f64>::from_fn(40, 6, |i, j| {
2814 (j + 1) as f64 * b[(i, j % 2)] - b[(i, 1 - j % 2)]
2815 });
2816 for (name, x, y, w, k) in [
2817 (
2818 "offset",
2819 offset.as_ref(),
2820 &fams[1].y,
2821 fams[1].w.as_ref(),
2822 3_usize,
2823 ),
2824 ("rank_two", rank_two.as_ref(), &yb, None, 4),
2825 ] {
2826 let n = x.nrows();
2827 let (w_norm, _) =
2828 crate::fit::validate_and_normalize_weights(w.map(Col::as_ref), n, 1).unwrap();
2829 let wr = w_norm.as_ref().map(Col::as_ref);
2830 let folds = crate::linalg::fold_split(&(0..n).collect::<Vec<_>>(), 5);
2831 let mut fd = prep_fold(x, y.as_ref(), wr, &folds, 0, &folds[0]).unwrap();
2832 if name == "offset" {
2833 assert!(
2834 fd.mean_ratio > IMPLICIT_MAX_MEAN_RATIO,
2835 "offset: ratio {}",
2836 fd.mean_ratio
2837 );
2838 }
2839 assert!(
2840 fd.fit_predict(x, k, None, sweep_coefs).unwrap().is_none(),
2841 "{name}: did not fall back"
2842 );
2843 fd.on_blocks = true;
2844 let got = fd.fit_predict(x, k, None, sweep_coefs).unwrap().unwrap();
2845 if name == "rank_two" {
2846 assert!(got.len() < k, "{name}: k_used {}", got.len());
2847 }
2848 }
2849 }
2850}