use super::*;
#[derive(Debug, Clone, Copy)]
pub struct CrossFitConfig {
pub k_folds: usize,
pub seed: u64,
}
impl CrossFitConfig {
pub fn five_fold(seed: u64) -> Self {
CrossFitConfig { k_folds: 5, seed }
}
}
#[derive(Debug, Clone)]
pub struct KFoldAssignment {
fold_of_row: Vec<usize>,
k_folds: usize,
}
impl KFoldAssignment {
pub fn new(n: usize, k_folds: usize, seed: u64) -> Result<Self, String> {
if k_folds < 2 {
return Err(format!("KFoldAssignment: need k_folds ≥ 2, got {k_folds}"));
}
if n < k_folds {
return Err(format!(
"KFoldAssignment: need n ≥ k_folds, got n={n} k={k_folds}"
));
}
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&row| {
splitmix64(seed ^ (row as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15))
});
let mut fold_of_row = vec![0usize; n];
for (rank, &row) in order.iter().enumerate() {
fold_of_row[row] = rank % k_folds;
}
Ok(KFoldAssignment {
fold_of_row,
k_folds,
})
}
pub fn k_folds(&self) -> usize {
self.k_folds
}
pub fn held_out(&self, fold: usize) -> Vec<usize> {
(0..self.fold_of_row.len())
.filter(|&row| self.fold_of_row[row] == fold)
.collect()
}
pub fn complement(&self, fold: usize) -> Vec<usize> {
(0..self.fold_of_row.len())
.filter(|&row| self.fold_of_row[row] != fold)
.collect()
}
}
fn splitmix64(x: u64) -> u64 {
gam_linalg::utils::splitmix64_hash(x)
}
#[derive(Debug, Clone)]
pub struct CrossFitReport {
pub naive: f64,
pub cross_fit: f64,
pub per_fold: Vec<f64>,
pub optimism: f64,
}
pub fn cross_fit_scalar<S, D, E>(
n: usize,
config: CrossFitConfig,
discover: D,
evaluate: E,
) -> Result<CrossFitReport, String>
where
D: Fn(&[usize]) -> Result<S, String>,
E: Fn(&S, &[usize]) -> Option<f64>,
{
let all_rows: Vec<usize> = (0..n).collect();
let full_structure = discover(&all_rows)?;
let naive = evaluate(&full_structure, &all_rows)
.ok_or_else(|| "cross_fit_scalar: naive artifact undefined on full data".to_string())?;
let folds = KFoldAssignment::new(n, config.k_folds, config.seed)?;
let mut per_fold = Vec::with_capacity(config.k_folds);
for f in 0..config.k_folds {
let train = folds.complement(f);
let test = folds.held_out(f);
if train.is_empty() || test.is_empty() {
continue;
}
let structure = discover(&train)?;
if let Some(v) = evaluate(&structure, &test) {
if v.is_finite() {
per_fold.push(v);
}
}
}
if per_fold.is_empty() {
return Err("cross_fit_scalar: every fold's held-out artifact was undefined".to_string());
}
let cross_fit = per_fold.iter().sum::<f64>() / per_fold.len() as f64;
Ok(CrossFitReport {
naive,
cross_fit,
optimism: naive - cross_fit,
per_fold,
})
}
pub fn fit_principal_subspace(
data: ArrayView2<'_, f64>,
q: usize,
) -> Result<(Array1<f64>, Array2<f64>), String> {
if data.iter().any(|value| !value.is_finite()) {
return Err("fit_principal_subspace: data must be finite".to_string());
}
let rows = (0..data.nrows()).collect::<Vec<_>>();
fit_subspace(data, &rows, q)
}
pub(crate) fn fit_subspace(
data: ArrayView2<'_, f64>,
rows: &[usize],
q: usize,
) -> Result<(Array1<f64>, Array2<f64>), String> {
let p = data.ncols();
let n = rows.len();
if n == 0 || p == 0 {
return Err("fit_subspace: empty selection".to_string());
}
let q = q.min(n).min(p);
if q == 0 {
return Err("fit_subspace: q resolved to 0".to_string());
}
let mut mean = Array1::<f64>::zeros(p);
for &r in rows {
for c in 0..p {
mean[c] += data[[r, c]];
}
}
mean.mapv_inplace(|v| v / n as f64);
let mut centered = Array2::<f64>::zeros((n, p));
for (i, &r) in rows.iter().enumerate() {
for c in 0..p {
centered[[i, c]] = data[[r, c]] - mean[c];
}
}
let (_u, _s, vt) = centered
.svd(false, true)
.map_err(|e| format!("fit_subspace: SVD failed: {e:?}"))?;
let vt = vt.ok_or_else(|| "fit_subspace: SVD returned no Vt".to_string())?;
let rank = vt.nrows();
let take = q.min(rank);
let basis = vt.slice(s![0..take, ..]).to_owned();
Ok((mean, basis))
}
pub(crate) fn subspace_reconstruction_ev(
data: ArrayView2<'_, f64>,
rows: &[usize],
mean: ArrayView1<'_, f64>,
basis: ArrayView2<'_, f64>,
) -> Option<f64> {
let p = data.ncols();
if rows.is_empty() || mean.len() != p || basis.ncols() != p {
return None;
}
let n = rows.len();
let mut target = Array2::<f64>::zeros((n, p));
let mut fitted = Array2::<f64>::zeros((n, p));
for (i, &r) in rows.iter().enumerate() {
let mut coeff = Array1::<f64>::zeros(basis.nrows());
for (b, dir) in basis.rows().into_iter().enumerate() {
let mut acc = 0.0;
for c in 0..p {
acc += (data[[r, c]] - mean[c]) * dir[c];
}
coeff[b] = acc;
}
for c in 0..p {
target[[i, c]] = data[[r, c]];
let mut recon = mean[c];
for (b, dir) in basis.rows().into_iter().enumerate() {
recon += coeff[b] * dir[c];
}
fitted[[i, c]] = recon;
}
}
reconstruction_explained_variance(target.view(), fitted.view())
}
pub(crate) struct SelectedRegression {
pub cols: Vec<usize>,
pub intercept: f64,
pub coef: Vec<f64>,
}
pub(crate) fn fit_selected_regression(
x: ArrayView2<'_, f64>,
y: ArrayView1<'_, f64>,
rows: &[usize],
q: usize,
) -> Result<SelectedRegression, String> {
let p = x.ncols();
let n = rows.len();
if n == 0 || p == 0 {
return Err("fit_selected_regression: empty selection".to_string());
}
let q = q.min(p).min(n.saturating_sub(1)).max(1);
let mut xbar = vec![0.0_f64; p];
let mut ybar = 0.0_f64;
for &r in rows {
ybar += y[r];
for c in 0..p {
xbar[c] += x[[r, c]];
}
}
ybar /= n as f64;
for v in xbar.iter_mut() {
*v /= n as f64;
}
let mut score = vec![0.0_f64; p];
let mut yvar = 0.0_f64;
for &r in rows {
let dy = y[r] - ybar;
yvar += dy * dy;
for c in 0..p {
score[c] += (x[[r, c]] - xbar[c]) * dy;
}
}
let mut xvar = vec![0.0_f64; p];
for &r in rows {
for c in 0..p {
let dx = x[[r, c]] - xbar[c];
xvar[c] += dx * dx;
}
}
let mut order: Vec<usize> = (0..p).collect();
order.sort_by(|&a, &b| {
let ca = score[a].abs() / (xvar[a].sqrt() * yvar.sqrt()).max(f64::MIN_POSITIVE);
let cb = score[b].abs() / (xvar[b].sqrt() * yvar.sqrt()).max(f64::MIN_POSITIVE);
cb.partial_cmp(&ca).unwrap_or(std::cmp::Ordering::Equal)
});
let cols: Vec<usize> = order.into_iter().take(q).collect();
let d = cols.len() + 1;
let mut xtx = Array2::<f64>::zeros((d, d));
let mut xty = Array1::<f64>::zeros(d);
for &r in rows {
let mut drow = vec![0.0_f64; d];
drow[0] = 1.0;
for (j, &c) in cols.iter().enumerate() {
drow[j + 1] = x[[r, c]];
}
for a in 0..d {
xty[a] += drow[a] * y[r];
for b in 0..d {
xtx[[a, b]] += drow[a] * drow[b];
}
}
}
for a in 0..d {
xtx[[a, a]] += 1e-10 * (1.0 + xtx[[a, a]].abs());
}
let chol = xtx
.cholesky(Side::Lower)
.map_err(|e| format!("fit_selected_regression: normal-equation Cholesky: {e:?}"))?;
let beta = chol.solvevec(&xty);
Ok(SelectedRegression {
cols,
intercept: beta[0],
coef: beta.iter().skip(1).copied().collect(),
})
}
pub(crate) fn selected_regression_r2(
x: ArrayView2<'_, f64>,
y: ArrayView1<'_, f64>,
rows: &[usize],
fit: &SelectedRegression,
) -> Option<f64> {
if rows.is_empty() {
return None;
}
let n = rows.len();
let ybar = rows.iter().map(|&r| y[r]).sum::<f64>() / n as f64;
let mut ssr = 0.0_f64;
let mut sst = 0.0_f64;
for &r in rows {
let mut pred = fit.intercept;
for (j, &c) in fit.cols.iter().enumerate() {
pred += fit.coef[j] * x[[r, c]];
}
let e = y[r] - pred;
ssr += e * e;
let dm = y[r] - ybar;
sst += dm * dm;
}
if sst > f64::MIN_POSITIVE && ssr.is_finite() && sst.is_finite() {
Some(1.0 - ssr / sst)
} else {
None
}
}
pub fn cross_fit_reconstruction_ev(
data: ArrayView2<'_, f64>,
config: CrossFitConfig,
q: usize,
) -> Result<CrossFitReport, String> {
let n = data.nrows();
cross_fit_scalar(
n,
config,
|train| fit_subspace(data, train, q),
|(mean, basis), test| subspace_reconstruction_ev(data, test, mean.view(), basis.view()),
)
}
pub fn cross_fit_selected_forecast_r2(
x: ArrayView2<'_, f64>,
y: ArrayView1<'_, f64>,
config: CrossFitConfig,
q: usize,
) -> Result<CrossFitReport, String> {
let n = x.nrows();
if y.len() != n {
return Err(format!(
"cross_fit_selected_forecast_r2: x has {n} rows but y has {}",
y.len()
));
}
cross_fit_scalar(
n,
config,
|train| fit_selected_regression(x, y, train, q),
|fit, test| selected_regression_r2(x, y, test, fit),
)
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::{Array1, Array2};
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
#[test]
fn folds_are_balanced_and_partition() {
let n = 103;
let k = 5;
let folds = KFoldAssignment::new(n, k, 42).unwrap();
let mut seen = vec![false; n];
let mut sizes = vec![0usize; k];
for f in 0..k {
let held = folds.held_out(f);
let comp = folds.complement(f);
assert_eq!(held.len() + comp.len(), n, "held+comp must cover all rows");
for &r in &held {
assert!(!seen[r], "row {r} in two folds");
seen[r] = true;
sizes[f] += 1;
}
assert!(comp.iter().all(|&r| !folds.held_out(f).contains(&r)));
}
assert!(seen.iter().all(|&s| s), "every row assigned");
let lo = *sizes.iter().min().unwrap();
let hi = *sizes.iter().max().unwrap();
assert!(hi - lo <= 1, "fold sizes must differ by ≤ 1, got {sizes:?}");
}
#[test]
fn selection_cross_fit_is_zero_on_noise_and_naive_is_inflated() {
let n = 300;
let p = 60;
let q = 5;
let mut rng = StdRng::seed_from_u64(2024);
let mut x = Array2::<f64>::zeros((n, p));
for v in x.iter_mut() {
*v = rng.random_range(-1.0..1.0);
}
let mut y = Array1::<f64>::zeros(n);
for v in y.iter_mut() {
*v = rng.random_range(-1.0..1.0); }
let report = cross_fit_scalar(
n,
CrossFitConfig::five_fold(7),
|train| fit_selected_regression(x.view(), y.view(), train, q),
|fit, test| selected_regression_r2(x.view(), y.view(), test, fit),
)
.unwrap();
println!(
"[optimism/noise] naive R²={:.4} cross_fit R²={:.4} optimism={:.4} per_fold={:?}",
report.naive, report.cross_fit, report.optimism, report.per_fold
);
assert!(
report.naive > 0.05,
"naive selected R² should be inflated on noise, got {}",
report.naive
);
assert!(
report.cross_fit < 0.02,
"cross-fit R² on noise must be ≈ 0 (not positive), got {}",
report.cross_fit
);
assert!(
report.optimism > 0.05,
"optimism should be clearly positive on noise, got {}",
report.optimism
);
}
#[test]
fn selection_cross_fit_recovers_signal() {
let n = 400;
let p = 60;
let q = 5;
let mut rng = StdRng::seed_from_u64(51);
let mut x = Array2::<f64>::zeros((n, p));
for v in x.iter_mut() {
*v = rng.random_range(-1.0..1.0);
}
let mut y = Array1::<f64>::zeros(n);
for i in 0..n {
y[i] = 1.5 * x[[i, 3]] - 2.0 * x[[i, 17]]
+ 1.0 * x[[i, 42]]
+ 0.05 * rng.random_range(-1.0..1.0);
}
let report = cross_fit_scalar(
n,
CrossFitConfig::five_fold(5),
|train| fit_selected_regression(x.view(), y.view(), train, q),
|fit, test| selected_regression_r2(x.view(), y.view(), test, fit),
)
.unwrap();
assert!(
report.cross_fit > 0.9,
"cross-fit must keep high R² on real signal, got {}",
report.cross_fit
);
assert!(
report.optimism.abs() < 0.05,
"optimism must be small when the structure is real, got {}",
report.optimism
);
}
#[test]
fn subspace_reconstruction_optimism_is_positive_on_noise() {
let n = 400;
let p = 20;
let q = 6;
let mut rng = StdRng::seed_from_u64(2024);
let mut data = Array2::<f64>::zeros((n, p));
for v in data.iter_mut() {
*v = rng.random_range(-1.0..1.0);
}
let report = cross_fit_scalar(
n,
CrossFitConfig::five_fold(7),
|train| fit_subspace(data.view(), train, q),
|(mean, basis), test| {
subspace_reconstruction_ev(data.view(), test, mean.view(), basis.view())
},
)
.unwrap();
let chance = q as f64 / p as f64;
println!(
"[optimism/recon] naive EV={:.4} cross_fit EV={:.4} optimism={:.4} chance q/p={:.4}",
report.naive, report.cross_fit, report.optimism, chance
);
assert!(
report.optimism > 0.05,
"reconstruction optimism (naive − cross_fit) should be positive on noise, got {}",
report.optimism
);
assert!(
(report.cross_fit - chance).abs() < 0.08,
"cross-fit reconstruction EV should sit near the q/p={chance} chance floor, got {}",
report.cross_fit
);
assert!(
report.naive > report.cross_fit,
"naive EV must exceed cross-fit EV (optimism), naive={} cross_fit={}",
report.naive,
report.cross_fit
);
}
#[test]
fn subspace_cross_fit_recovers_true_ev_on_signal() {
let n = 500;
let p = 24;
let r_true = 3;
let q = 3;
let mut rng = StdRng::seed_from_u64(99);
let mut loadings = Array2::<f64>::zeros((r_true, p));
for v in loadings.iter_mut() {
*v = rng.random_range(-1.0..1.0);
}
let mut data = Array2::<f64>::zeros((n, p));
for i in 0..n {
let mut scores = [0.0_f64; 3];
for s in scores.iter_mut() {
*s = rng.random_range(-2.0..2.0);
}
for c in 0..p {
let mut v = 0.05 * rng.random_range(-1.0..1.0);
for (k, &sc) in scores.iter().enumerate() {
v += sc * loadings[[k, c]];
}
data[[i, c]] = v;
}
}
let report = cross_fit_scalar(
n,
CrossFitConfig::five_fold(5),
|train| fit_subspace(data.view(), train, q),
|(mean, basis), test| {
subspace_reconstruction_ev(data.view(), test, mean.view(), basis.view())
},
)
.unwrap();
assert!(
report.cross_fit > 0.95,
"cross-fit must recover the true high EV on real signal, got {}",
report.cross_fit
);
assert!(
report.optimism.abs() < 0.02,
"optimism must be small when the structure is real, got {}",
report.optimism
);
}
fn planted_subspace(n: usize, p: usize, q: usize, noise: f64, seed: u64) -> Array2<f64> {
let mut rng = StdRng::seed_from_u64(seed);
let basis: Array2<f64> = Array2::from_shape_fn((q, p), |(k, j)| {
((k * 31 + j * 17) as f64 * 0.7).sin()
});
Array2::from_shape_fn((n, p), |(i, j)| {
let mut v = 0.0;
for k in 0..q {
let score = ((i * 13 + k * 7) as f64 * 0.31).cos();
v += score * basis[[k, j]];
}
v + noise * (rng.random::<f64>() - 0.5)
})
}
#[test]
fn optimism_collapses_when_the_structure_is_real() {
let data = planted_subspace(400, 12, 3, 0.02, 11);
let report = cross_fit_reconstruction_ev(data.view(), CrossFitConfig::five_fold(7), 3)
.expect("well-posed cross-fit");
assert!(
report.naive > 0.99,
"planted subspace should be reconstructed in-sample, got {}",
report.naive
);
assert!(
report.optimism.abs() < 0.01,
"real structure generalizes: optimism {} should be ~0",
report.optimism
);
}
#[test]
fn optimism_is_visible_when_the_subspace_is_mostly_selection() {
let data = planted_subspace(40, 16, 0, 1.0, 23);
let report = cross_fit_reconstruction_ev(data.view(), CrossFitConfig::five_fold(5), 10)
.expect("well-posed cross-fit");
assert!(
report.optimism > 0.05,
"fitting 10 directions to 40 noise rows must show optimism, got {} \
(naive {}, cross_fit {})",
report.optimism,
report.naive,
report.cross_fit
);
assert!(
report.cross_fit < report.naive,
"held-out cannot beat in-sample here"
);
}
#[test]
fn cross_fit_report_carries_every_fold_it_scored() {
let data = planted_subspace(120, 8, 2, 0.1, 3);
let report = cross_fit_reconstruction_ev(data.view(), CrossFitConfig::five_fold(2), 2)
.expect("well-posed cross-fit");
assert_eq!(report.per_fold.len(), 5, "one held-out score per fold");
let mean = report.per_fold.iter().sum::<f64>() / report.per_fold.len() as f64;
assert!(
(mean - report.cross_fit).abs() < 1e-12,
"cross_fit must be the mean of the folds it reports, not a separate number"
);
assert!(
(report.optimism - (report.naive - report.cross_fit)).abs() < 1e-12,
"optimism must be naive - cross_fit"
);
}
}