1use super::dist::f_sf;
12use super::TestResult;
13use crate::error::FdarError;
14use crate::scalar_on_function::FregreLmResult;
15
16pub fn flm_f_test(fit: &FregreLmResult) -> Result<TestResult, FdarError> {
42 let p = fit.ncomp;
43 let n = fit.residuals.len();
44 let r2 = fit.r_squared;
45
46 if p == 0 {
47 return Err(FdarError::InvalidParameter {
48 parameter: "fit.ncomp",
49 message: "flm_f_test requires ncomp >= 1 (at least one FPC parameter)".to_string(),
50 });
51 }
52 if n <= p + 1 {
54 return Err(FdarError::InvalidParameter {
55 parameter: "fit",
56 message: format!(
57 "degenerate degrees of freedom: n - p - 1 = {} - {} - 1 <= 0",
58 n, p
59 ),
60 });
61 }
62 if !r2.is_finite() || r2 >= 1.0 {
63 return Err(FdarError::InvalidParameter {
64 parameter: "fit.r_squared",
65 message: format!(
66 "r_squared must be finite and < 1.0 for a well-defined F statistic, got {r2}"
67 ),
68 });
69 }
70
71 let d1 = p as f64;
72 let d2 = (n - p - 1) as f64;
73 let f_stat = (r2 / d1) / ((1.0 - r2) / d2);
74 let p_value = f_sf(f_stat, d1, d2);
75
76 Ok(TestResult {
77 statistic: f_stat,
78 p_value,
79 n_perm: 0,
80 })
81}
82
83pub fn flm_gof_test(fit: &FregreLmResult) -> Result<TestResult, FdarError> {
125 const Q: usize = 3;
127
128 let e = &fit.residuals;
129 let yhat = &fit.fitted_values;
130 let n = e.len();
131
132 if yhat.len() != n {
133 return Err(FdarError::InvalidParameter {
134 parameter: "fit",
135 message: format!(
136 "residuals ({}) and fitted_values ({}) must have equal length",
137 n,
138 yhat.len()
139 ),
140 });
141 }
142 if n <= Q + 1 {
145 return Err(FdarError::InvalidParameter {
146 parameter: "fit",
147 message: format!(
148 "degenerate degrees of freedom for GoF: n = {n} <= {}",
149 Q + 1
150 ),
151 });
152 }
153 if e.iter().chain(yhat.iter()).any(|v| !v.is_finite()) {
154 return Err(FdarError::InvalidParameter {
155 parameter: "fit",
156 message: "residuals / fitted_values contain non-finite values".to_string(),
157 });
158 }
159
160 let mean_yhat = yhat.iter().sum::<f64>() / n as f64;
163 let var_yhat = yhat.iter().map(|&v| (v - mean_yhat).powi(2)).sum::<f64>() / n as f64;
164 if var_yhat <= 1e-30 {
165 return Err(FdarError::InvalidParameter {
166 parameter: "fit.fitted_values",
167 message: "fitted values are (numerically) constant; GoF design is rank-deficient"
168 .to_string(),
169 });
170 }
171 let sd_yhat = var_yhat.sqrt();
172
173 let ncoef = Q + 1;
175 let mut x: Vec<[f64; 4]> = Vec::with_capacity(n);
176 for &yh in yhat {
177 let z = (yh - mean_yhat) / sd_yhat;
178 x.push([1.0, z, z * z, z * z * z]);
179 }
180
181 let mut xtx = [[0.0f64; 4]; 4];
183 let mut xte = [0.0f64; 4];
184 for (row, &ei) in x.iter().zip(e.iter()) {
185 for a in 0..ncoef {
186 xte[a] += row[a] * ei;
187 for b in 0..ncoef {
188 xtx[a][b] += row[a] * row[b];
189 }
190 }
191 }
192
193 let coef = match solve_linear(xtx, xte, ncoef) {
195 Some(c) => c,
196 None => {
197 return Err(FdarError::InvalidParameter {
198 parameter: "fit.fitted_values",
199 message: "GoF auxiliary design is singular (rank-deficient fitted values)"
200 .to_string(),
201 });
202 }
203 };
204
205 let mut rss_full = 0.0;
207 for (row, &ei) in x.iter().zip(e.iter()) {
208 let pred = (0..ncoef).map(|a| coef[a] * row[a]).sum::<f64>();
209 rss_full += (ei - pred).powi(2);
210 }
211
212 let mean_e = e.iter().sum::<f64>() / n as f64;
215 let rss_restricted = e.iter().map(|&ei| (ei - mean_e).powi(2)).sum::<f64>();
216
217 let df_num = Q as f64; let df_den = (n - ncoef) as f64; if rss_full <= 1e-30 || rss_restricted <= 1e-30 {
222 return Ok(TestResult {
224 statistic: 0.0,
225 p_value: 1.0,
226 n_perm: 0,
227 });
228 }
229
230 let f_stat = ((rss_restricted - rss_full) / df_num) / (rss_full / df_den);
231 let f_stat = f_stat.max(0.0);
232 let p_value = f_sf(f_stat, df_num, df_den);
233
234 Ok(TestResult {
235 statistic: f_stat,
236 p_value,
237 n_perm: 0,
238 })
239}
240
241fn solve_linear(a: [[f64; 4]; 4], b: [f64; 4], n: usize) -> Option<[f64; 4]> {
245 let mut m = a;
246 let mut rhs = b;
247 for col in 0..n {
248 let mut pivot = col;
250 let mut best = m[col][col].abs();
251 for r in (col + 1)..n {
252 if m[r][col].abs() > best {
253 best = m[r][col].abs();
254 pivot = r;
255 }
256 }
257 if best < 1e-12 {
258 return None;
259 }
260 if pivot != col {
261 m.swap(pivot, col);
262 rhs.swap(pivot, col);
263 }
264 let diag = m[col][col];
265 for r in (col + 1)..n {
266 let factor = m[r][col] / diag;
267 for c in col..n {
268 m[r][c] -= factor * m[col][c];
269 }
270 rhs[r] -= factor * rhs[col];
271 }
272 }
273 let mut sol = [0.0f64; 4];
275 for i in (0..n).rev() {
276 let mut s = rhs[i];
277 for c in (i + 1)..n {
278 s -= m[i][c] * sol[c];
279 }
280 sol[i] = s / m[i][i];
281 }
282 Some(sol)
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::matrix::FdMatrix;
289 use crate::scalar_on_function::fregre_lm;
290 use crate::test_helpers::uniform_grid;
291
292 fn noise(seed: &mut u64) -> f64 {
294 *seed = seed
295 .wrapping_mul(6_364_136_223_846_793_005)
296 .wrapping_add(1_442_695_040_888_963_407);
297 let z = (*seed >> 33) as f64 / (1u64 << 31) as f64; z - 1.0
299 }
300
301 fn make_curves(n: usize, argvals: &[f64], amps: &[f64], seed: u64) -> FdMatrix {
303 let m = argvals.len();
304 let mut s = seed;
305 let mut mat = FdMatrix::zeros(n, m);
306 for i in 0..n {
307 for (j, &t) in argvals.iter().enumerate() {
308 let base = amps[i] * (2.0 * std::f64::consts::PI * t).sin();
309 mat[(i, j)] = base + 0.05 * noise(&mut s);
310 }
311 }
312 mat
313 }
314
315 #[test]
316 fn f_test_rejects_genuine_effect() {
317 let argvals = uniform_grid(40);
318 let n = 40;
319 let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
322 let data = make_curves(n, &argvals, &s, 7);
323 let mut s = 1234u64;
324 let y: Vec<f64> = (0..n)
325 .map(|i| 3.0 * amps[i] + 0.1 * noise(&mut s))
326 .collect();
327
328 let fit = fregre_lm(&data, &y, None, 3).unwrap();
329 let res = flm_f_test(&fit).unwrap();
330 assert!(
331 res.p_value < 0.05,
332 "genuine functional effect should reject H0, got p={} (F={})",
333 res.p_value,
334 res.statistic
335 );
336 }
337
338 #[test]
339 fn f_test_fails_to_reject_null_effect() {
340 let argvals = uniform_grid(40);
341 let n = 40;
342 let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
346 let data = make_curves(n, &argvals, &s, 71);
347 let mut s = 98765u64;
348 let y: Vec<f64> = (0..n).map(|_| 5.0 + noise(&mut s)).collect();
349
350 let fit = fregre_lm(&data, &y, None, 3).unwrap();
351 let res = flm_f_test(&fit).unwrap();
352 assert!(
353 res.p_value > 0.20,
354 "null-effect fit should fail to reject H0, got p={} (F={}, R²={})",
355 res.p_value,
356 res.statistic,
357 fit.r_squared
358 );
359 }
360
361 #[test]
362 fn f_test_guards_degenerate_df() {
363 let argvals = uniform_grid(20);
366 let n = 5;
367 let amps: Vec<f64> = (0..n).map(|i| 1.0 + i as f64).collect();
368 let data = make_curves(n, &argvals, &s, 3);
369 let mut s = 5u64;
370 let y: Vec<f64> = (0..n)
371 .map(|i| 2.0 * amps[i] + 0.1 * noise(&mut s))
372 .collect();
373 let fit = fregre_lm(&data, &y, None, 4).unwrap();
374 assert!(
375 matches!(flm_f_test(&fit), Err(FdarError::InvalidParameter { .. })),
376 "degenerate df must return Err"
377 );
378 }
379
380 #[test]
381 fn gof_fails_to_reject_well_specified() {
382 let argvals = uniform_grid(50);
383 let n = 60;
384 let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
385 let data = make_curves(n, &argvals, &s, 21);
386 let mut s = 55u64;
388 let y: Vec<f64> = (0..n)
389 .map(|i| 2.0 * amps[i] + 0.05 * noise(&mut s))
390 .collect();
391 let fit = fregre_lm(&data, &y, None, 3).unwrap();
392 let res = flm_gof_test(&fit).unwrap();
393 assert!(
394 res.p_value > 0.10,
395 "well-specified linear FLM should not be flagged, got p={} (F={})",
396 res.p_value,
397 res.statistic
398 );
399 }
400
401 #[test]
402 fn gof_rejects_mis_specified() {
403 let argvals = uniform_grid(50);
404 let n = 60;
405 let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
406 let data = make_curves(n, &argvals, &s, 22);
407 let mut s = 66u64;
410 let y: Vec<f64> = (0..n)
411 .map(|i| 4.0 * amps[i] * amps[i] + 0.05 * noise(&mut s))
412 .collect();
413 let fit = fregre_lm(&data, &y, None, 3).unwrap();
414 let res = flm_gof_test(&fit).unwrap();
415 assert!(
416 res.p_value < 0.05,
417 "mis-specified (nonlinear) FLM should be flagged, got p={} (F={})",
418 res.p_value,
419 res.statistic
420 );
421 }
422
423 #[test]
424 fn gof_guards_degenerate_df() {
425 let argvals = uniform_grid(20);
427 let n = 4;
428 let amps: Vec<f64> = (0..n).map(|i| 1.0 + i as f64).collect();
429 let data = make_curves(n, &argvals, &s, 9);
430 let mut s = 4u64;
431 let y: Vec<f64> = (0..n).map(|i| amps[i] + 0.1 * noise(&mut s)).collect();
432 let fit = fregre_lm(&data, &y, None, 2).unwrap();
433 assert!(matches!(
434 flm_gof_test(&fit),
435 Err(FdarError::InvalidParameter { .. })
436 ));
437 }
438}