use csv::StringRecord;
use gam_data::{EncodedDataset, encode_recordswith_inferred_schema};
use gam_linalg::matrix::LinearOperator;
use gam_models::fit_orchestration::{FitConfig, FitResult, fit_from_formula};
use gam_solve::gaussian_reml::gaussian_reml_closed_form;
use gam_terms::smooth::build_term_collection_design;
use gam_test_support::reference::{Column, run_r};
use ndarray::{Array1, Array2};
use rand::SeedableRng;
use rand::rngs::StdRng;
use rand_distr::{Distribution, Normal, Uniform};
fn make_sin_dataset(freq: f64, sigma: f64, n: usize, seed: u64) -> EncodedDataset {
let mut rng = StdRng::seed_from_u64(seed);
let ux = Uniform::new(0.0, 1.0).expect("uniform");
let noise = Normal::new(0.0, sigma).expect("normal");
let mut x: Vec<f64> = (0..n).map(|_| ux.sample(&mut rng)).collect();
x.sort_by(|a, b| a.partial_cmp(b).unwrap());
let two_pi_f = 2.0 * std::f64::consts::PI * freq;
let y_noisy: Vec<f64> = x
.iter()
.map(|&t| (two_pi_f * t).sin() + noise.sample(&mut rng))
.collect();
let headers = ["x", "y"].into_iter().map(String::from).collect();
let rows: Vec<StringRecord> = x
.iter()
.zip(y_noisy.iter())
.map(|(a, b)| StringRecord::from(vec![a.to_string(), b.to_string()]))
.collect();
encode_recordswith_inferred_schema(headers, rows).expect("encode sin dataset")
}
fn make_null_dataset(n: usize, sigma: f64, seed: u64) -> EncodedDataset {
let mut rng = StdRng::seed_from_u64(seed);
let noise = Normal::new(0.0, sigma).expect("normal");
let x: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64 - 1.0)).collect();
let y: Vec<f64> = (0..n).map(|_| noise.sample(&mut rng)).collect();
let headers = ["x", "y"].into_iter().map(String::from).collect();
let rows: Vec<StringRecord> = x
.iter()
.zip(y.iter())
.map(|(a, b)| StringRecord::from(vec![a.to_string(), b.to_string()]))
.collect();
encode_recordswith_inferred_schema(headers, rows).expect("encode null dataset")
}
fn max_dev_from_mean(v: &[f64]) -> f64 {
let mean = v.iter().sum::<f64>() / v.len() as f64;
v.iter().map(|x| (x - mean).abs()).fold(0.0_f64, f64::max)
}
fn max_abs_err(yhat: &[f64], y: &[f64]) -> f64 {
yhat.iter()
.zip(y.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0, f64::max)
}
fn amp_pp(v: &[f64]) -> f64 {
v.iter().cloned().fold(f64::MIN, f64::max) - v.iter().cloned().fold(f64::MAX, f64::min)
}
fn mgcv_diag(data: &EncodedDataset, x_test: &[f64], k: usize) -> (f64, f64, Vec<f64>) {
let x_col = data.column_map()["x"];
let y_col = data.column_map()["y"];
let mut x_all = data.values.column(x_col).to_vec();
x_all.extend_from_slice(x_test);
let mut y_all = data.values.column(y_col).to_vec();
y_all.extend(std::iter::repeat_n(0.0, x_test.len()));
let mut is_train = vec![1.0; data.values.nrows()];
is_train.extend(std::iter::repeat_n(0.0, x_test.len()));
let r = run_r(
&[
Column::new("x", &x_all),
Column::new("y", &y_all),
Column::new("is_train", &is_train),
],
&format!(
r#"
suppressPackageStartupMessages(library(mgcv))
train <- df[df$is_train > 0.5, ]
grid <- df[df$is_train < 0.5, ]
m <- gam(y ~ s(x, bs = "ds", k = {k}, m = c(2, 0)), data = train, method = "REML")
emit("edf", as.numeric(sum(m$edf)))
emit("sp", as.numeric(m$sp))
emit("fitted", as.numeric(predict(m, newdata = grid)))
"#
),
);
(
r.vector("edf")[0],
r.vector("sp")[0],
r.vector("fitted").to_vec(),
)
}
#[test]
fn zz_measure_duchon_sin8_lambda_gap() {
let data = make_sin_dataset(8.0, 0.10, 240, 11);
let x_test: Vec<f64> = (0..400).map(|i| 0.001 + 0.998 * i as f64 / 399.0).collect();
let y_truth: Vec<f64> = x_test
.iter()
.map(|t| (2.0 * std::f64::consts::PI * 8.0 * t).sin())
.collect();
let x_col = data.column_map()["x"];
let y_col = data.column_map()["y"];
let ncols = data.headers.len();
for (label, body, k) in [
("duchon-default", "duchon(x)", 40usize),
("duchon-centers50", "duchon(x, centers=50)", 50),
] {
let cfg = FitConfig {
family: Some("gaussian".to_string()),
..FitConfig::default()
};
let FitResult::Standard(fit) =
fit_from_formula(&format!("y ~ {body}"), &data, &cfg).expect("fit")
else {
panic!("standard")
};
let prod_loglam = fit.fit.log_lambdas.to_vec();
let prod_lam = fit.fit.lambdas.to_vec();
let prod_edf = fit.fit.edf_total();
let prod_score = fit.fit.reml_score;
let n_t = x_test.len();
let mut mt = Array2::<f64>::zeros((n_t, ncols));
for (i, &t) in x_test.iter().enumerate() {
mt[[i, x_col]] = t;
}
let test_design =
build_term_collection_design(mt.view(), &fit.resolvedspec).expect("test design");
let prod_pred = test_design.design.apply(&fit.fit.beta).to_vec();
let prod_max = max_abs_err(&prod_pred, &y_truth);
let n_tr = data.values.nrows();
let mut mtr = Array2::<f64>::zeros((n_tr, ncols));
for i in 0..n_tr {
mtr[[i, x_col]] = data.values[[i, x_col]];
}
let train_design =
build_term_collection_design(mtr.view(), &fit.resolvedspec).expect("train design");
let x_dense = train_design.design.to_dense();
let p = x_dense.ncols();
let mut s = Array2::<f64>::zeros((p, p));
for bp in &train_design.penalties {
let r = bp.col_range.clone();
for (li, gi) in r.clone().enumerate() {
for (lj, gj) in r.clone().enumerate() {
s[[gi, gj]] += bp.local[[li, lj]];
}
}
}
let nulldim: usize = train_design.nullspace_dims.iter().sum();
let y_tr = data.values.column(y_col).to_owned();
let cf = gaussian_reml_closed_form(x_dense.view(), y_tr.view(), s.view(), None, None)
.expect("closed form");
let xt = test_design.design.to_dense();
let cf_beta = Array1::from(cf.coefficients.to_vec());
let cf_pred = xt.dot(&cf_beta).to_vec();
let cf_max = max_abs_err(&cf_pred, &y_truth);
let (mgcv_edf, mgcv_sp, mgcv_pred) = mgcv_diag(&data, &x_test, k);
let mgcv_max = max_abs_err(&mgcv_pred, &y_truth);
eprintln!(
"\n===== [{label}] p={p} nulldim={nulldim} n_penalties={} truth_amp_pp=2.0 =====",
train_design.penalties.len()
);
eprintln!(
" PROD gam : log_lambda={prod_loglam:?} lambda={prod_lam:?}\n edf={prod_edf:?} reml_score={prod_score:.4} max_err={prod_max:.4} amp_pp={:.4}",
amp_pp(&prod_pred)
);
eprintln!(
" CF gam : rho={:.4} lambda={:.4e} edf={:.3} reml_score={:.4} max_err={cf_max:.4} amp_pp={:.4}",
cf.rho,
cf.lambda,
cf.edf,
cf.reml_score,
amp_pp(&cf_pred)
);
eprintln!(
" MGCV ds k={k}: sp={mgcv_sp:.4e} edf={mgcv_edf:.3} max_err={mgcv_max:.4} amp_pp={:.4}",
amp_pp(&mgcv_pred)
);
}
}
#[test]
fn zz_measure_duchon_null_recovery_lambda_gap() {
let n = 300usize;
let sigma = 1.0;
let data = make_null_dataset(n, sigma, 98_765);
let x_col = data.column_map()["x"];
let y_col = data.column_map()["y"];
let ncols = data.headers.len();
let m = 201usize;
let x_test: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
let cfg = FitConfig {
family: Some("gaussian".to_string()),
..FitConfig::default()
};
let FitResult::Standard(fit) =
fit_from_formula("y ~ duchon(x, k=20)", &data, &cfg).expect("null fit")
else {
panic!("standard")
};
let prod_loglam = fit.fit.log_lambdas.to_vec();
let prod_lam = fit.fit.lambdas.to_vec();
let prod_edf = fit.fit.edf_total();
let prod_score = fit.fit.reml_score;
let mut mt = Array2::<f64>::zeros((m, ncols));
for (i, &t) in x_test.iter().enumerate() {
mt[[i, x_col]] = t;
}
let test_design =
build_term_collection_design(mt.view(), &fit.resolvedspec).expect("null test design");
let prod_pred = test_design.design.apply(&fit.fit.beta).to_vec();
let prod_dev = max_dev_from_mean(&prod_pred);
let n_tr = data.values.nrows();
let mut mtr = Array2::<f64>::zeros((n_tr, ncols));
for i in 0..n_tr {
mtr[[i, x_col]] = data.values[[i, x_col]];
}
let train_design =
build_term_collection_design(mtr.view(), &fit.resolvedspec).expect("null train design");
let x_dense = train_design.design.to_dense();
let p = x_dense.ncols();
let mut s = Array2::<f64>::zeros((p, p));
let mut per_block: Vec<(usize, usize)> = Vec::new(); for bp in &train_design.penalties {
let r = bp.col_range.clone();
per_block.push((r.len(), 0));
for (li, gi) in r.clone().enumerate() {
for (lj, gj) in r.clone().enumerate() {
s[[gi, gj]] += bp.local[[li, lj]];
}
}
}
let nulldim: usize = train_design.nullspace_dims.iter().sum();
let y_tr = data.values.column(y_col).to_owned();
let cf = gaussian_reml_closed_form(x_dense.view(), y_tr.view(), s.view(), None, None)
.expect("closed form");
let xt = test_design.design.to_dense();
let cf_beta = Array1::from(cf.coefficients.to_vec());
let cf_pred = xt.dot(&cf_beta).to_vec();
let cf_dev = max_dev_from_mean(&cf_pred);
eprintln!(
" SEED-PROBE : certified diagonal(summed-λ) rho={:.4} broadcast to {} blocks",
cf.rho,
train_design.penalties.len(),
);
let (mgcv_edf, mgcv_sp, mgcv_pred) = mgcv_diag(&data, &x_test, 20);
eprintln!(
"\n===== [null-recovery] p={p} nulldim={nulldim} (affine {{1,x}} expects 2) n_penalties={} block_cols={:?} penalty_nullspace_dims={:?} sigma={sigma} =====",
train_design.penalties.len(),
per_block.iter().map(|(c, _)| *c).collect::<Vec<_>>(),
train_design.nullspace_dims,
);
eprintln!(
" PROD gam : log_lambda={prod_loglam:?}\n lambda={prod_lam:?}\n edf={prod_edf:?} reml_score={prod_score:.4} max_dev_from_mean={prod_dev:.4} (fails at >=0.25)",
);
eprintln!(
" CF gam : rho={:.4} lambda={:.4e} edf={:.3} reml_score={:.4} max_dev={cf_dev:.4}",
cf.rho, cf.lambda, cf.edf, cf.reml_score,
);
eprintln!(
" MGCV ds k=20: sp={mgcv_sp:.4e} edf={mgcv_edf:.3} max_dev={:.4} (target: collapses, edf~2.35 dev~0.041)",
max_dev_from_mean(&mgcv_pred),
);
}