Skip to main content

fdars_core/
smoothing.rs

1//! Smoothing functions for functional data.
2//!
3//! This module provides kernel-based smoothing methods including
4//! Nadaraya-Watson, local linear, and local polynomial regression.
5
6use crate::error::FdarError;
7use crate::slice_maybe_parallel;
8#[cfg(feature = "parallel")]
9use rayon::iter::ParallelIterator;
10
11/// Gaussian kernel function.
12fn gaussian_kernel(u: f64) -> f64 {
13    (-0.5 * u * u).exp() / (2.0 * std::f64::consts::PI).sqrt()
14}
15
16/// Epanechnikov kernel function.
17fn epanechnikov_kernel(u: f64) -> f64 {
18    if u.abs() <= 1.0 {
19        0.75 * (1.0 - u * u)
20    } else {
21        0.0
22    }
23}
24
25/// Tri-cube kernel function (used by R's loess()).
26fn tricube_kernel(u: f64) -> f64 {
27    let abs_u = u.abs();
28    if abs_u < 1.0 {
29        (1.0 - abs_u.powi(3)).powi(3)
30    } else {
31        0.0
32    }
33}
34
35/// Get kernel function by name.
36fn get_kernel(kernel_type: &str) -> fn(f64) -> f64 {
37    match kernel_type.to_lowercase().as_str() {
38        "epanechnikov" | "epan" => epanechnikov_kernel,
39        "tricube" | "tri-cube" => tricube_kernel,
40        _ => gaussian_kernel,
41    }
42}
43
44/// Nadaraya-Watson kernel smoother.
45///
46/// # Arguments
47/// * `x` - Predictor values
48/// * `y` - Response values
49/// * `x_new` - Points at which to evaluate the smoother
50/// * `bandwidth` - Kernel bandwidth
51/// * `kernel` - Kernel type ("gaussian" or "epanechnikov")
52///
53/// # Returns
54/// Smoothed values at x_new
55///
56/// # Errors
57/// Returns [`FdarError::InvalidDimension`] if `x` is empty, `x_new` is empty,
58/// or `x` and `y` have different lengths.
59/// Returns [`FdarError::InvalidParameter`] if `bandwidth` is not positive.
60///
61/// # Examples
62///
63/// ```
64/// use fdars_core::smoothing::nadaraya_watson;
65///
66/// let x: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
67/// let y: Vec<f64> = x.iter().map(|&xi| (xi * 6.0).sin()).collect();
68/// let smoothed = nadaraya_watson(&x, &y, &x, 0.1, "gaussian").unwrap();
69/// assert_eq!(smoothed.len(), 20);
70/// assert!(smoothed.iter().all(|v| v.is_finite()));
71/// ```
72pub fn nadaraya_watson(
73    x: &[f64],
74    y: &[f64],
75    x_new: &[f64],
76    bandwidth: f64,
77    kernel: &str,
78) -> Result<Vec<f64>, FdarError> {
79    let n = x.len();
80    if n == 0 {
81        return Err(FdarError::InvalidDimension {
82            parameter: "x",
83            expected: "non-empty slice".to_string(),
84            actual: "empty".to_string(),
85        });
86    }
87    if y.len() != n {
88        return Err(FdarError::InvalidDimension {
89            parameter: "y",
90            expected: format!("length {n} (matching x)"),
91            actual: format!("length {}", y.len()),
92        });
93    }
94    if x_new.is_empty() {
95        return Err(FdarError::InvalidDimension {
96            parameter: "x_new",
97            expected: "non-empty slice".to_string(),
98            actual: "empty".to_string(),
99        });
100    }
101    if bandwidth <= 0.0 {
102        return Err(FdarError::InvalidParameter {
103            parameter: "bandwidth",
104            message: format!("must be positive, got {bandwidth}"),
105        });
106    }
107
108    let kernel_fn = get_kernel(kernel);
109
110    Ok(slice_maybe_parallel!(x_new)
111        .map(|&x0| {
112            let mut num = 0.0;
113            let mut denom = 0.0;
114
115            for i in 0..n {
116                let u = (x[i] - x0) / bandwidth;
117                let w = kernel_fn(u);
118                num += w * y[i];
119                denom += w;
120            }
121
122            if denom > 1e-10 {
123                num / denom
124            } else {
125                0.0
126            }
127        })
128        .collect())
129}
130
131/// Local linear regression smoother.
132///
133/// # Arguments
134/// * `x` - Predictor values
135/// * `y` - Response values
136/// * `x_new` - Points at which to evaluate the smoother
137/// * `bandwidth` - Kernel bandwidth
138/// * `kernel` - Kernel type
139///
140/// # Returns
141/// Smoothed values at x_new
142///
143/// # Errors
144/// Returns [`FdarError::InvalidDimension`] if `x` is empty, `x_new` is empty,
145/// or `x` and `y` have different lengths.
146/// Returns [`FdarError::InvalidParameter`] if `bandwidth` is not positive.
147///
148/// # Examples
149///
150/// ```
151/// use fdars_core::smoothing::local_linear;
152///
153/// let x: Vec<f64> = (0..30).map(|i| i as f64 / 29.0).collect();
154/// let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
155/// let smoothed = local_linear(&x, &y, &x, 0.2, "gaussian").unwrap();
156/// assert_eq!(smoothed.len(), 30);
157/// // Local linear should fit linear data well in the interior
158/// assert!((smoothed[15] - (2.0 * x[15] + 1.0)).abs() < 0.1);
159/// ```
160pub fn local_linear(
161    x: &[f64],
162    y: &[f64],
163    x_new: &[f64],
164    bandwidth: f64,
165    kernel: &str,
166) -> Result<Vec<f64>, FdarError> {
167    let n = x.len();
168    if n == 0 {
169        return Err(FdarError::InvalidDimension {
170            parameter: "x",
171            expected: "non-empty slice".to_string(),
172            actual: "empty".to_string(),
173        });
174    }
175    if y.len() != n {
176        return Err(FdarError::InvalidDimension {
177            parameter: "y",
178            expected: format!("length {n} (matching x)"),
179            actual: format!("length {}", y.len()),
180        });
181    }
182    if x_new.is_empty() {
183        return Err(FdarError::InvalidDimension {
184            parameter: "x_new",
185            expected: "non-empty slice".to_string(),
186            actual: "empty".to_string(),
187        });
188    }
189    if bandwidth <= 0.0 {
190        return Err(FdarError::InvalidParameter {
191            parameter: "bandwidth",
192            message: format!("must be positive, got {bandwidth}"),
193        });
194    }
195
196    let kernel_fn = get_kernel(kernel);
197
198    Ok(slice_maybe_parallel!(x_new)
199        .map(|&x0| {
200            // Compute weighted moments
201            let mut s0 = 0.0;
202            let mut s1 = 0.0;
203            let mut s2 = 0.0;
204            let mut t0 = 0.0;
205            let mut t1 = 0.0;
206
207            for i in 0..n {
208                let u = (x[i] - x0) / bandwidth;
209                let w = kernel_fn(u);
210                let d = x[i] - x0;
211
212                s0 += w;
213                s1 += w * d;
214                s2 += w * d * d;
215                t0 += w * y[i];
216                t1 += w * y[i] * d;
217            }
218
219            // Solve local linear regression
220            let det = s0 * s2 - s1 * s1;
221            if det.abs() > 1e-10 {
222                (s2 * t0 - s1 * t1) / det
223            } else if s0 > 1e-10 {
224                t0 / s0
225            } else {
226                0.0
227            }
228        })
229        .collect())
230}
231
232/// Accumulate weighted normal equations (X'WX and X'Wy) for local polynomial fit.
233fn accumulate_weighted_normal_equations(
234    x: &[f64],
235    y: &[f64],
236    x0: f64,
237    bandwidth: f64,
238    p: usize,
239    kernel_fn: impl Fn(f64) -> f64,
240) -> (Vec<f64>, Vec<f64>) {
241    let n = x.len();
242    let mut xtx = vec![0.0; p * p];
243    let mut xty = vec![0.0; p];
244
245    for i in 0..n {
246        let u = (x[i] - x0) / bandwidth;
247        let w = kernel_fn(u);
248        let d = x[i] - x0;
249
250        for j in 0..p {
251            let w_dj = w * d.powi(j as i32);
252            for k in 0..p {
253                xtx[j * p + k] += w_dj * d.powi(k as i32);
254            }
255            xty[j] += w_dj * y[i];
256        }
257    }
258
259    (xtx, xty)
260}
261
262/// Solve a linear system using Gaussian elimination with partial pivoting.
263/// Returns the solution vector, or a zero vector if the system is singular.
264/// Gaussian elimination with partial pivoting (forward pass).
265/// Find the row with the largest absolute value in column `col` at or below the diagonal.
266fn find_pivot(a: &[f64], p: usize, col: usize) -> usize {
267    let mut max_idx = col;
268    for j in (col + 1)..p {
269        if a[j * p + col].abs() > a[max_idx * p + col].abs() {
270            max_idx = j;
271        }
272    }
273    max_idx
274}
275
276/// Swap two rows in both the matrix `a` and the RHS vector `b`.
277fn swap_rows(a: &mut [f64], b: &mut [f64], p: usize, row_a: usize, row_b: usize) {
278    for k in 0..p {
279        a.swap(row_a * p + k, row_b * p + k);
280    }
281    b.swap(row_a, row_b);
282}
283
284/// Subtract a scaled copy of the pivot row from all rows below it.
285fn eliminate_below(a: &mut [f64], b: &mut [f64], p: usize, pivot_row: usize) {
286    let pivot = a[pivot_row * p + pivot_row];
287    for j in (pivot_row + 1)..p {
288        let factor = a[j * p + pivot_row] / pivot;
289        for k in pivot_row..p {
290            a[j * p + k] -= factor * a[pivot_row * p + k];
291        }
292        b[j] -= factor * b[pivot_row];
293    }
294}
295
296fn forward_eliminate(a: &mut [f64], b: &mut [f64], p: usize) {
297    for i in 0..p {
298        let max_idx = find_pivot(a, p, i);
299        if max_idx != i {
300            swap_rows(a, b, p, i, max_idx);
301        }
302
303        if a[i * p + i].abs() < 1e-10 {
304            continue;
305        }
306
307        eliminate_below(a, b, p, i);
308    }
309}
310
311/// Back substitution for an upper-triangular system.
312fn back_substitute(a: &[f64], b: &[f64], p: usize) -> Vec<f64> {
313    let mut coefs = vec![0.0; p];
314    for i in (0..p).rev() {
315        let mut sum = b[i];
316        for j in (i + 1)..p {
317            sum -= a[i * p + j] * coefs[j];
318        }
319        if a[i * p + i].abs() > 1e-10 {
320            coefs[i] = sum / a[i * p + i];
321        }
322    }
323    coefs
324}
325
326fn solve_gaussian(a: &mut [f64], b: &mut [f64], p: usize) -> Vec<f64> {
327    forward_eliminate(a, b, p);
328    back_substitute(a, b, p)
329}
330
331/// Solve a linear system Ax = b via Gaussian elimination with partial pivoting.
332///
333/// Public wrapper for use by other modules (e.g., `fregre_basis_cv`).
334/// `a` is a p×p matrix in row-major order, `b` is the RHS vector of length p.
335/// Both are modified in place.
336pub fn solve_gaussian_pub(a: &mut [f64], b: &mut [f64], p: usize) -> Vec<f64> {
337    solve_gaussian(a, b, p)
338}
339
340/// Local polynomial regression smoother.
341///
342/// # Arguments
343/// * `x` - Predictor values
344/// * `y` - Response values
345/// * `x_new` - Points at which to evaluate the smoother
346/// * `bandwidth` - Kernel bandwidth
347/// * `degree` - Polynomial degree
348/// * `kernel` - Kernel type
349///
350/// # Returns
351/// Smoothed values at x_new
352///
353/// # Errors
354/// Returns [`FdarError::InvalidDimension`] if `x` is empty, `x_new` is empty,
355/// or `x` and `y` have different lengths.
356/// Returns [`FdarError::InvalidParameter`] if `bandwidth` is not positive.
357pub fn local_polynomial(
358    x: &[f64],
359    y: &[f64],
360    x_new: &[f64],
361    bandwidth: f64,
362    degree: usize,
363    kernel: &str,
364) -> Result<Vec<f64>, FdarError> {
365    let n = x.len();
366    if n == 0 {
367        return Err(FdarError::InvalidDimension {
368            parameter: "x",
369            expected: "non-empty slice".to_string(),
370            actual: "empty".to_string(),
371        });
372    }
373    if y.len() != n {
374        return Err(FdarError::InvalidDimension {
375            parameter: "y",
376            expected: format!("length {n} (matching x)"),
377            actual: format!("length {}", y.len()),
378        });
379    }
380    if x_new.is_empty() {
381        return Err(FdarError::InvalidDimension {
382            parameter: "x_new",
383            expected: "non-empty slice".to_string(),
384            actual: "empty".to_string(),
385        });
386    }
387    if bandwidth <= 0.0 {
388        return Err(FdarError::InvalidParameter {
389            parameter: "bandwidth",
390            message: format!("must be positive, got {bandwidth}"),
391        });
392    }
393    if degree == 0 {
394        return nadaraya_watson(x, y, x_new, bandwidth, kernel);
395    }
396
397    if degree == 1 {
398        return local_linear(x, y, x_new, bandwidth, kernel);
399    }
400
401    let kernel_fn = get_kernel(kernel);
402    let p = degree + 1; // Number of coefficients
403
404    Ok(slice_maybe_parallel!(x_new)
405        .map(|&x0| {
406            let (mut xtx, mut xty) =
407                accumulate_weighted_normal_equations(x, y, x0, bandwidth, p, kernel_fn);
408            let coefs = solve_gaussian(&mut xtx, &mut xty, p);
409            coefs[0]
410        })
411        .collect())
412}
413
414/// k-Nearest Neighbors smoother.
415///
416/// # Arguments
417/// * `x` - Predictor values
418/// * `y` - Response values
419/// * `x_new` - Points at which to evaluate the smoother
420/// * `k` - Number of neighbors
421///
422/// # Returns
423/// Smoothed values at x_new
424///
425/// # Errors
426/// Returns [`FdarError::InvalidDimension`] if `x` is empty, `x_new` is empty,
427/// or `x` and `y` have different lengths.
428/// Returns [`FdarError::InvalidParameter`] if `k` is zero.
429pub fn knn_smoother(x: &[f64], y: &[f64], x_new: &[f64], k: usize) -> Result<Vec<f64>, FdarError> {
430    let n = x.len();
431    if n == 0 {
432        return Err(FdarError::InvalidDimension {
433            parameter: "x",
434            expected: "non-empty slice".to_string(),
435            actual: "empty".to_string(),
436        });
437    }
438    if y.len() != n {
439        return Err(FdarError::InvalidDimension {
440            parameter: "y",
441            expected: format!("length {n} (matching x)"),
442            actual: format!("length {}", y.len()),
443        });
444    }
445    if x_new.is_empty() {
446        return Err(FdarError::InvalidDimension {
447            parameter: "x_new",
448            expected: "non-empty slice".to_string(),
449            actual: "empty".to_string(),
450        });
451    }
452    if k == 0 {
453        return Err(FdarError::InvalidParameter {
454            parameter: "k",
455            message: "must be at least 1".to_string(),
456        });
457    }
458
459    let k = k.min(n);
460
461    Ok(slice_maybe_parallel!(x_new)
462        .map(|&x0| {
463            // Compute distances
464            let mut distances: Vec<(usize, f64)> = x
465                .iter()
466                .enumerate()
467                .map(|(i, &xi)| (i, (xi - x0).abs()))
468                .collect();
469
470            // Partial sort to get k nearest
471            distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
472
473            // Average of k nearest neighbors
474            let sum: f64 = distances.iter().take(k).map(|(i, _)| y[*i]).sum();
475            sum / k as f64
476        })
477        .collect())
478}
479
480/// Compute smoothing matrix for Nadaraya-Watson.
481///
482/// Returns the smoother matrix S such that y_hat = S * y.
483///
484/// # Errors
485/// Returns [`FdarError::InvalidDimension`] if `x` is empty.
486/// Returns [`FdarError::InvalidParameter`] if `bandwidth` is not positive.
487pub fn smoothing_matrix_nw(x: &[f64], bandwidth: f64, kernel: &str) -> Result<Vec<f64>, FdarError> {
488    let n = x.len();
489    if n == 0 {
490        return Err(FdarError::InvalidDimension {
491            parameter: "x",
492            expected: "non-empty slice".to_string(),
493            actual: "empty".to_string(),
494        });
495    }
496    if bandwidth <= 0.0 {
497        return Err(FdarError::InvalidParameter {
498            parameter: "bandwidth",
499            message: format!("must be positive, got {bandwidth}"),
500        });
501    }
502
503    let kernel_fn = get_kernel(kernel);
504    let mut s = vec![0.0; n * n];
505
506    for i in 0..n {
507        let mut row_sum = 0.0;
508        for j in 0..n {
509            let u = (x[j] - x[i]) / bandwidth;
510            s[i + j * n] = kernel_fn(u);
511            row_sum += s[i + j * n];
512        }
513        if row_sum > 1e-10 {
514            for j in 0..n {
515                s[i + j * n] /= row_sum;
516            }
517        }
518    }
519
520    Ok(s)
521}
522
523// ─── Cross-Validation for Kernel Smoothers ──────────────────────────────────
524
525/// CV criterion type for bandwidth selection.
526///
527/// Marked `#[non_exhaustive]` so future criteria can be added without a breaking
528/// change; match on it with a wildcard arm.
529#[derive(Debug, Clone, Copy, PartialEq)]
530#[non_exhaustive]
531pub enum CvCriterion {
532    /// Leave-one-out cross-validation (R's `CV.S`).
533    Cv,
534    /// Generalized cross-validation (R's `GCV.S`).
535    Gcv,
536    /// Akaike information criterion for the smoother:
537    /// `AIC = n·ln(RSS/n) + 2·tr(S)`, with the smoother-matrix trace `tr(S)` as
538    /// the effective degrees of freedom (the same trace GCV uses).
539    Aic,
540}
541
542/// Result of bandwidth optimization.
543#[derive(Debug, Clone, PartialEq)]
544pub struct OptimBandwidthResult {
545    /// Optimal bandwidth.
546    pub h_opt: f64,
547    /// Criterion used.
548    pub criterion: CvCriterion,
549    /// Criterion value at optimal h.
550    pub value: f64,
551}
552
553/// LOO-CV score for a kernel smoother (R's `CV.S`).
554///
555/// Computes the leave-one-out CV score by zeroing the diagonal of the
556/// smoothing matrix, re-normalizing rows, and computing mean squared error.
557///
558/// # Arguments
559/// * `x` — Predictor values
560/// * `y` — Response values
561/// * `bandwidth` — Kernel bandwidth
562/// * `kernel` — Kernel type ("gaussian", "epanechnikov", "tricube")
563///
564/// # Returns
565/// Mean squared LOO prediction error, or `INFINITY` if inputs are invalid.
566pub fn cv_smoother(x: &[f64], y: &[f64], bandwidth: f64, kernel: &str) -> f64 {
567    let n = x.len();
568    if n < 2 || y.len() != n || bandwidth <= 0.0 {
569        return f64::INFINITY;
570    }
571
572    // Get the smoother matrix S
573    let mut s = match smoothing_matrix_nw(x, bandwidth, kernel) {
574        Ok(s) => s,
575        Err(_) => return f64::INFINITY,
576    };
577
578    // Zero the diagonal → S_cv (LOO smoother)
579    for i in 0..n {
580        s[i + i * n] = 0.0;
581    }
582
583    // Re-normalize each row so it sums to 1
584    for i in 0..n {
585        let row_sum: f64 = (0..n).map(|j| s[i + j * n]).sum();
586        if row_sum > 1e-10 {
587            for j in 0..n {
588                s[i + j * n] /= row_sum;
589            }
590        }
591    }
592
593    // Compute y_hat = S_cv * y, then MSE
594    let mut mse = 0.0;
595    for i in 0..n {
596        let y_hat: f64 = (0..n).map(|j| s[i + j * n] * y[j]).sum();
597        let resid = y[i] - y_hat;
598        mse += resid * resid;
599    }
600    mse / n as f64
601}
602
603/// GCV score for a kernel smoother (R's `GCV.S`).
604///
605/// Computes `(RSS / n) / (1 - tr(S) / n)²`.
606///
607/// # Arguments
608/// * `x` — Predictor values
609/// * `y` — Response values
610/// * `bandwidth` — Kernel bandwidth
611/// * `kernel` — Kernel type
612///
613/// # Returns
614/// GCV score, or `INFINITY` if inputs are invalid or denominator is near zero.
615pub fn gcv_smoother(x: &[f64], y: &[f64], bandwidth: f64, kernel: &str) -> f64 {
616    let n = x.len();
617    if n < 2 || y.len() != n || bandwidth <= 0.0 {
618        return f64::INFINITY;
619    }
620
621    let s = match smoothing_matrix_nw(x, bandwidth, kernel) {
622        Ok(s) => s,
623        Err(_) => return f64::INFINITY,
624    };
625
626    // y_hat = S * y
627    let mut rss = 0.0;
628    for i in 0..n {
629        let y_hat: f64 = (0..n).map(|j| s[i + j * n] * y[j]).sum();
630        let resid = y[i] - y_hat;
631        rss += resid * resid;
632    }
633
634    // trace(S) = sum of diagonal
635    let trace_s: f64 = (0..n).map(|i| s[i + i * n]).sum();
636
637    let denom = 1.0 - trace_s / n as f64;
638    if denom.abs() < 1e-10 {
639        f64::INFINITY
640    } else {
641        (rss / n as f64) / (denom * denom)
642    }
643}
644
645/// AIC score for a kernel smoother.
646///
647/// Computes `AIC = n·ln(RSS/n) + 2·tr(S)`, where `RSS` and `tr(S)` come from the
648/// same Nadaraya–Watson smoother matrix `S` that [`gcv_smoother`] uses. The
649/// effective degrees of freedom is the trace of the smoother matrix (the same
650/// hat-matrix trace GCV divides by), so AIC and GCV share their df definition
651/// but combine it differently: AIC applies an additive `2·tr(S)` penalty to the
652/// log residual variance instead of GCV's multiplicative `(1 − tr(S)/n)⁻²`.
653///
654/// # Arguments
655/// * `x` — Predictor values
656/// * `y` — Response values
657/// * `bandwidth` — Kernel bandwidth
658/// * `kernel` — Kernel type ("gaussian", "epanechnikov", "tricube")
659///
660/// # Returns
661/// AIC score, or `INFINITY` if inputs are invalid (n < 2, length mismatch, or
662/// non-positive bandwidth), matching [`gcv_smoother`]'s guards.
663pub fn aic_smoother(x: &[f64], y: &[f64], bandwidth: f64, kernel: &str) -> f64 {
664    let n = x.len();
665    if n < 2 || y.len() != n || bandwidth <= 0.0 {
666        return f64::INFINITY;
667    }
668
669    let s = match smoothing_matrix_nw(x, bandwidth, kernel) {
670        Ok(s) => s,
671        Err(_) => return f64::INFINITY,
672    };
673
674    // y_hat = S * y  →  RSS
675    let mut rss = 0.0;
676    for i in 0..n {
677        let y_hat: f64 = (0..n).map(|j| s[i + j * n] * y[j]).sum();
678        let resid = y[i] - y_hat;
679        rss += resid * resid;
680    }
681
682    // trace(S) = sum of diagonal — same hat-matrix trace GCV uses as df
683    let trace_s: f64 = (0..n).map(|i| s[i + i * n]).sum();
684
685    let n_f = n as f64;
686    // Standard smoother AIC: n·ln(RSS/n) + 2·tr(S).
687    n_f * (rss / n_f).max(1e-300).ln() + 2.0 * trace_s
688}
689
690/// Bandwidth optimizer for kernel smoothers (R's `optim.np`).
691///
692/// Grid search over evenly-spaced bandwidths, selecting the one that
693/// minimizes the specified criterion (CV, GCV, or AIC).
694///
695/// # Arguments
696/// * `x` — Predictor values
697/// * `y` — Response values
698/// * `h_range` — Optional `(h_min, h_max)`. Defaults to `(h_default / 5, h_default * 5)`
699///   where `h_default = (x_max - x_min) / n^0.2`.
700/// * `criterion` — CV or GCV
701/// * `kernel` — Kernel type
702/// * `n_grid` — Number of grid points (default: 50)
703///
704/// # Examples
705///
706/// ```
707/// use fdars_core::smoothing::{optim_bandwidth, CvCriterion};
708///
709/// let x: Vec<f64> = (0..25).map(|i| i as f64 / 24.0).collect();
710/// let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
711/// let result = optim_bandwidth(&x, &y, None, CvCriterion::Gcv, "gaussian", 20);
712/// assert!(result.h_opt > 0.0);
713/// assert!(result.value.is_finite());
714/// ```
715pub fn optim_bandwidth(
716    x: &[f64],
717    y: &[f64],
718    h_range: Option<(f64, f64)>,
719    criterion: CvCriterion,
720    kernel: &str,
721    n_grid: usize,
722) -> OptimBandwidthResult {
723    let n = x.len();
724    let n_grid = n_grid.max(2);
725
726    // Determine search range
727    let (h_min, h_max) = match h_range {
728        Some((lo, hi)) if lo > 0.0 && hi > lo => (lo, hi),
729        _ => {
730            let x_min = x.iter().copied().fold(f64::INFINITY, f64::min);
731            let x_max = x.iter().copied().fold(f64::NEG_INFINITY, f64::max);
732            let h_default = (x_max - x_min) / (n as f64).powf(0.2);
733            let h_default = h_default.max(1e-10);
734            (h_default / 5.0, h_default * 5.0)
735        }
736    };
737
738    let score_fn = match criterion {
739        CvCriterion::Cv => cv_smoother,
740        CvCriterion::Gcv => gcv_smoother,
741        CvCriterion::Aic => aic_smoother,
742    };
743
744    let mut best_h = h_min;
745    let mut best_score = f64::INFINITY;
746
747    for i in 0..n_grid {
748        let h = h_min + (h_max - h_min) * i as f64 / (n_grid - 1) as f64;
749        let score = score_fn(x, y, h, kernel);
750        if score < best_score {
751            best_score = score;
752            best_h = h;
753        }
754    }
755
756    OptimBandwidthResult {
757        h_opt: best_h,
758        criterion,
759        value: best_score,
760    }
761}
762
763// ─── kNN CV Functions ───────────────────────────────────────────────────────
764
765/// Result of kNN k-selection by cross-validation.
766#[derive(Debug, Clone, PartialEq)]
767pub struct KnnCvResult {
768    /// Optimal k (number of neighbors).
769    pub optimal_k: usize,
770    /// CV error for each k tested (index 0 = k=1).
771    pub cv_errors: Vec<f64>,
772}
773
774/// Global LOO-CV for kNN k selection (R's `knn.gcv`).
775///
776/// For each candidate k, computes LOO prediction error using a
777/// kernel-weighted kNN estimator with Epanechnikov kernel.
778///
779/// # Arguments
780/// * `x` — Predictor values
781/// * `y` — Response values
782/// * `max_k` — Maximum k to test (tests k = 1, 2, …, max_k)
783pub fn knn_gcv(x: &[f64], y: &[f64], max_k: usize) -> KnnCvResult {
784    let n = x.len();
785    let max_k = max_k.min(n.saturating_sub(1)).max(1);
786
787    // Precompute sorted distances from each point to all others
788    let mut sorted_neighbors: Vec<Vec<(usize, f64)>> = Vec::with_capacity(n);
789    for i in 0..n {
790        let mut dists: Vec<(usize, f64)> = (0..n)
791            .filter(|&j| j != i)
792            .map(|j| (j, (x[j] - x[i]).abs()))
793            .collect();
794        dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
795        sorted_neighbors.push(dists);
796    }
797
798    let mut cv_errors = Vec::with_capacity(max_k);
799
800    for k in 1..=max_k {
801        let mut mse = 0.0;
802        for i in 0..n {
803            let neighbors = &sorted_neighbors[i];
804            // Bandwidth: midpoint between k-th and (k+1)-th NN distances
805            let d_k = if k <= neighbors.len() {
806                neighbors[k - 1].1
807            } else {
808                neighbors.last().map_or(1.0, |x| x.1)
809            };
810            let d_k1 = if k < neighbors.len() {
811                neighbors[k].1
812            } else {
813                d_k * 2.0
814            };
815            let h = (d_k + d_k1) / 2.0;
816            let h = h.max(1e-10);
817
818            // Epanechnikov kernel weighted prediction
819            let mut num = 0.0;
820            let mut den = 0.0;
821            for &(j, dist) in neighbors.iter().take(k) {
822                let u = dist / h;
823                let w = epanechnikov_kernel(u);
824                num += w * y[j];
825                den += w;
826            }
827            let y_hat = if den > 1e-10 { num / den } else { y[i] };
828            mse += (y[i] - y_hat).powi(2);
829        }
830        cv_errors.push(mse / n as f64);
831    }
832
833    let optimal_k = cv_errors
834        .iter()
835        .enumerate()
836        .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
837        .map_or(1, |(i, _)| i + 1);
838
839    KnnCvResult {
840        optimal_k,
841        cv_errors,
842    }
843}
844
845/// Local (per-observation) LOO-CV for kNN k selection (R's `knn.lcv`).
846///
847/// For each observation, independently selects the best k by minimizing
848/// the absolute LOO prediction error at that point.
849///
850/// # Arguments
851/// * `x` — Predictor values
852/// * `y` — Response values
853/// * `max_k` — Maximum k to test
854///
855/// # Returns
856/// Vector of per-observation optimal k values (length n).
857pub fn knn_lcv(x: &[f64], y: &[f64], max_k: usize) -> Vec<usize> {
858    let n = x.len();
859    let max_k = max_k.min(n.saturating_sub(1)).max(1);
860
861    let mut per_obs_k = vec![1usize; n];
862
863    for i in 0..n {
864        // Sort neighbors by distance (excluding self)
865        let mut neighbors: Vec<(usize, f64)> = (0..n)
866            .filter(|&j| j != i)
867            .map(|j| (j, (x[j] - x[i]).abs()))
868            .collect();
869        neighbors.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
870
871        let mut best_k = 1;
872        let mut best_err = f64::INFINITY;
873
874        for k in 1..=max_k {
875            // Simple kNN average of k nearest neighbors
876            let sum: f64 = neighbors.iter().take(k).map(|&(j, _)| y[j]).sum();
877            let y_hat = sum / k as f64;
878            let err = (y[i] - y_hat).abs();
879            if err < best_err {
880                best_err = err;
881                best_k = k;
882            }
883        }
884        per_obs_k[i] = best_k;
885    }
886
887    per_obs_k
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893    use crate::test_helpers::uniform_grid;
894
895    // ============== Nadaraya-Watson tests ==============
896
897    #[test]
898    fn test_nw_constant_data() {
899        let x = uniform_grid(20);
900        let y: Vec<f64> = vec![5.0; 20];
901
902        let y_smooth = nadaraya_watson(&x, &y, &x, 0.1, "gaussian").unwrap();
903
904        // Smoothing constant data should return constant
905        for &yi in &y_smooth {
906            assert!(
907                (yi - 5.0).abs() < 0.1,
908                "Constant data should remain constant"
909            );
910        }
911    }
912
913    #[test]
914    fn test_nw_linear_data() {
915        let x = uniform_grid(50);
916        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
917
918        let y_smooth = nadaraya_watson(&x, &y, &x, 0.2, "gaussian").unwrap();
919
920        // Linear data should be approximately preserved (with some edge effects)
921        for i in 10..40 {
922            let expected = 2.0 * x[i] + 1.0;
923            assert!(
924                (y_smooth[i] - expected).abs() < 0.2,
925                "Linear trend should be approximately preserved"
926            );
927        }
928    }
929
930    #[test]
931    fn test_nw_gaussian_vs_epanechnikov() {
932        let x = uniform_grid(30);
933        let y: Vec<f64> = x
934            .iter()
935            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
936            .collect();
937
938        let y_gauss = nadaraya_watson(&x, &y, &x, 0.1, "gaussian").unwrap();
939        let y_epan = nadaraya_watson(&x, &y, &x, 0.1, "epanechnikov").unwrap();
940
941        // Both should produce valid output
942        assert_eq!(y_gauss.len(), 30);
943        assert_eq!(y_epan.len(), 30);
944
945        // They should be different (different kernels)
946        let diff: f64 = y_gauss
947            .iter()
948            .zip(&y_epan)
949            .map(|(a, b)| (a - b).abs())
950            .sum();
951        assert!(
952            diff > 0.0,
953            "Different kernels should give different results"
954        );
955    }
956
957    #[test]
958    fn test_nw_invalid_input() {
959        // Empty input
960        assert!(nadaraya_watson(&[], &[], &[0.5], 0.1, "gaussian").is_err());
961
962        // Mismatched lengths
963        assert!(nadaraya_watson(&[0.0, 1.0], &[1.0], &[0.5], 0.1, "gaussian").is_err());
964
965        // Zero bandwidth
966        assert!(nadaraya_watson(&[0.0, 1.0], &[1.0, 2.0], &[0.5], 0.0, "gaussian").is_err());
967
968        // Empty x_new
969        assert!(nadaraya_watson(&[0.0, 1.0], &[1.0, 2.0], &[], 0.1, "gaussian").is_err());
970    }
971
972    // ============== Local linear tests ==============
973
974    #[test]
975    fn test_ll_constant_data() {
976        let x = uniform_grid(20);
977        let y: Vec<f64> = vec![3.0; 20];
978
979        let y_smooth = local_linear(&x, &y, &x, 0.15, "gaussian").unwrap();
980
981        for &yi in &y_smooth {
982            assert!((yi - 3.0).abs() < 0.1, "Constant should remain constant");
983        }
984    }
985
986    #[test]
987    fn test_ll_linear_data_exact() {
988        let x = uniform_grid(30);
989        let y: Vec<f64> = x.iter().map(|&xi| 3.0 * xi + 2.0).collect();
990
991        let y_smooth = local_linear(&x, &y, &x, 0.2, "gaussian").unwrap();
992
993        // Local linear should fit linear data exactly (in interior)
994        for i in 5..25 {
995            let expected = 3.0 * x[i] + 2.0;
996            assert!(
997                (y_smooth[i] - expected).abs() < 0.1,
998                "Local linear should fit linear data well"
999            );
1000        }
1001    }
1002
1003    #[test]
1004    fn test_ll_invalid_input() {
1005        assert!(local_linear(&[], &[], &[0.5], 0.1, "gaussian").is_err());
1006
1007        assert!(local_linear(&[0.0, 1.0], &[1.0, 2.0], &[0.5], -0.1, "gaussian").is_err());
1008    }
1009
1010    // ============== Local polynomial tests ==============
1011
1012    #[test]
1013    fn test_lp_degree1_equals_local_linear() {
1014        let x = uniform_grid(25);
1015        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1016
1017        let y_ll = local_linear(&x, &y, &x, 0.15, "gaussian").unwrap();
1018        let y_lp = local_polynomial(&x, &y, &x, 0.15, 1, "gaussian").unwrap();
1019
1020        for i in 0..25 {
1021            assert!(
1022                (y_ll[i] - y_lp[i]).abs() < 1e-10,
1023                "Degree 1 should equal local linear"
1024            );
1025        }
1026    }
1027
1028    #[test]
1029    fn test_lp_quadratic_data() {
1030        let x = uniform_grid(40);
1031        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1032
1033        let y_smooth = local_polynomial(&x, &y, &x, 0.15, 2, "gaussian").unwrap();
1034
1035        // Local quadratic should fit quadratic data well in interior
1036        for i in 8..32 {
1037            let expected = x[i] * x[i];
1038            assert!(
1039                (y_smooth[i] - expected).abs() < 0.1,
1040                "Local quadratic should fit quadratic data"
1041            );
1042        }
1043    }
1044
1045    #[test]
1046    fn test_lp_invalid_input() {
1047        // Zero degree delegates to Nadaraya-Watson (not zeros)
1048        let result =
1049            local_polynomial(&[0.0, 1.0], &[1.0, 2.0], &[0.5], 0.1, 0, "gaussian").unwrap();
1050        let nw = nadaraya_watson(&[0.0, 1.0], &[1.0, 2.0], &[0.5], 0.1, "gaussian").unwrap();
1051        assert_eq!(result, nw);
1052
1053        // Empty input
1054        assert!(local_polynomial(&[], &[], &[0.5], 0.1, 2, "gaussian").is_err());
1055    }
1056
1057    // ============== KNN smoother tests ==============
1058
1059    #[test]
1060    fn test_knn_k1_nearest() {
1061        let x = vec![0.0, 0.5, 1.0];
1062        let y = vec![1.0, 2.0, 3.0];
1063
1064        let result = knn_smoother(&x, &y, &[0.1, 0.6, 0.9], 1).unwrap();
1065
1066        // k=1 should return the nearest neighbor's y value
1067        assert!((result[0] - 1.0).abs() < 1e-10, "0.1 nearest to 0.0 -> 1.0");
1068        assert!((result[1] - 2.0).abs() < 1e-10, "0.6 nearest to 0.5 -> 2.0");
1069        assert!((result[2] - 3.0).abs() < 1e-10, "0.9 nearest to 1.0 -> 3.0");
1070    }
1071
1072    #[test]
1073    fn test_knn_k_equals_n_is_mean() {
1074        let x = vec![0.0, 0.25, 0.5, 0.75, 1.0];
1075        let y = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1076        let expected_mean = 3.0;
1077
1078        let result = knn_smoother(&x, &y, &[0.5], 5).unwrap();
1079
1080        assert!(
1081            (result[0] - expected_mean).abs() < 1e-10,
1082            "k=n should return mean"
1083        );
1084    }
1085
1086    #[test]
1087    fn test_knn_invalid_input() {
1088        assert!(knn_smoother(&[], &[], &[0.5], 3).is_err());
1089
1090        assert!(knn_smoother(&[0.0, 1.0], &[1.0, 2.0], &[0.5], 0).is_err());
1091    }
1092
1093    // ============== Smoothing matrix tests ==============
1094
1095    #[test]
1096    fn test_smoothing_matrix_row_stochastic() {
1097        let x = uniform_grid(10);
1098        let s = smoothing_matrix_nw(&x, 0.2, "gaussian").unwrap();
1099
1100        assert_eq!(s.len(), 100);
1101
1102        // Each row should sum to 1 (row stochastic)
1103        for i in 0..10 {
1104            let row_sum: f64 = (0..10).map(|j| s[i + j * 10]).sum();
1105            assert!(
1106                (row_sum - 1.0).abs() < 1e-10,
1107                "Row {} should sum to 1, got {}",
1108                i,
1109                row_sum
1110            );
1111        }
1112    }
1113
1114    #[test]
1115    fn test_smoothing_matrix_invalid_input() {
1116        assert!(smoothing_matrix_nw(&[], 0.1, "gaussian").is_err());
1117
1118        assert!(smoothing_matrix_nw(&[0.0, 1.0], 0.0, "gaussian").is_err());
1119    }
1120
1121    #[test]
1122    fn test_nan_nw_no_panic() {
1123        let x = vec![0.0, 0.25, 0.5, 0.75, 1.0];
1124        let mut y = vec![0.0, 1.0, 2.0, 1.0, 0.0];
1125        y[2] = f64::NAN;
1126        let result = nadaraya_watson(&x, &y, &x, 0.3, "gaussian").unwrap();
1127        assert_eq!(result.len(), x.len());
1128        // NaN should propagate but not panic
1129    }
1130
1131    #[test]
1132    fn test_n1_smoother() {
1133        // Single data point
1134        let x = vec![0.5];
1135        let y = vec![3.0];
1136        let x_new = vec![0.5];
1137        let result = nadaraya_watson(&x, &y, &x_new, 0.3, "gaussian").unwrap();
1138        assert_eq!(result.len(), 1);
1139        assert!(
1140            (result[0] - 3.0).abs() < 1e-6,
1141            "Single point smoother should return the value"
1142        );
1143    }
1144
1145    #[test]
1146    fn test_duplicate_x_smoother() {
1147        // Duplicate x values
1148        let x = vec![0.0, 0.0, 0.5, 1.0, 1.0];
1149        let y = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1150        let x_new = vec![0.0, 0.5, 1.0];
1151        let result = nadaraya_watson(&x, &y, &x_new, 0.3, "gaussian").unwrap();
1152        assert_eq!(result.len(), 3);
1153        for v in &result {
1154            assert!(v.is_finite());
1155        }
1156    }
1157
1158    // ============== CV smoother tests ==============
1159
1160    #[test]
1161    fn test_cv_smoother_linear_data() {
1162        let x = uniform_grid(30);
1163        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1164        let cv = cv_smoother(&x, &y, 0.2, "gaussian");
1165        assert!(cv.is_finite());
1166        assert!(cv >= 0.0);
1167        assert!(cv < 1.0, "CV error for smooth linear data should be small");
1168    }
1169
1170    #[test]
1171    fn test_cv_smoother_invalid() {
1172        assert_eq!(cv_smoother(&[], &[], 0.1, "gaussian"), f64::INFINITY);
1173        assert_eq!(
1174            cv_smoother(&[0.0, 1.0], &[1.0, 2.0], -0.1, "gaussian"),
1175            f64::INFINITY
1176        );
1177    }
1178
1179    #[test]
1180    fn test_gcv_smoother_linear_data() {
1181        let x = uniform_grid(30);
1182        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1183        let gcv = gcv_smoother(&x, &y, 0.2, "gaussian");
1184        assert!(gcv.is_finite());
1185        assert!(gcv >= 0.0);
1186    }
1187
1188    #[test]
1189    fn test_gcv_smoother_invalid() {
1190        assert_eq!(gcv_smoother(&[], &[], 0.1, "gaussian"), f64::INFINITY);
1191    }
1192
1193    #[test]
1194    fn test_optim_bandwidth_returns_valid() {
1195        let x = uniform_grid(30);
1196        let y: Vec<f64> = x
1197            .iter()
1198            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
1199            .collect();
1200
1201        let result = optim_bandwidth(&x, &y, None, CvCriterion::Gcv, "gaussian", 20);
1202        assert!(result.h_opt > 0.0);
1203        assert!(result.value.is_finite());
1204        assert_eq!(result.criterion, CvCriterion::Gcv);
1205    }
1206
1207    #[test]
1208    fn test_optim_bandwidth_cv_vs_gcv() {
1209        let x = uniform_grid(25);
1210        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1211
1212        let cv_result = optim_bandwidth(&x, &y, None, CvCriterion::Cv, "gaussian", 20);
1213        let gcv_result = optim_bandwidth(&x, &y, None, CvCriterion::Gcv, "gaussian", 20);
1214
1215        assert!(cv_result.h_opt > 0.0);
1216        assert!(gcv_result.h_opt > 0.0);
1217    }
1218
1219    #[test]
1220    fn test_optim_bandwidth_custom_range() {
1221        let x = uniform_grid(20);
1222        let y: Vec<f64> = x.to_vec();
1223        let result = optim_bandwidth(&x, &y, Some((0.05, 0.5)), CvCriterion::Cv, "gaussian", 10);
1224        assert!(result.h_opt >= 0.05);
1225        assert!(result.h_opt <= 0.5);
1226    }
1227
1228    // ============== AIC smoother tests ==============
1229
1230    #[test]
1231    fn test_aic_smoother_matches_hand_computed() {
1232        // Small fixture; recompute AIC = n·ln(RSS/n) + 2·tr(S) from the same
1233        // smoother matrix and assert equality.
1234        let x = vec![0.0, 0.25, 0.5, 0.75, 1.0];
1235        let y = vec![0.1, 0.4, 0.35, 0.8, 1.2];
1236        let bandwidth = 0.3;
1237        let kernel = "gaussian";
1238
1239        let s = smoothing_matrix_nw(&x, bandwidth, kernel).unwrap();
1240        let n = x.len();
1241        let mut rss = 0.0;
1242        for i in 0..n {
1243            let y_hat: f64 = (0..n).map(|j| s[i + j * n] * y[j]).sum();
1244            let resid = y[i] - y_hat;
1245            rss += resid * resid;
1246        }
1247        let trace_s: f64 = (0..n).map(|i| s[i + i * n]).sum();
1248        let n_f = n as f64;
1249        let expected = n_f * (rss / n_f).max(1e-300).ln() + 2.0 * trace_s;
1250
1251        let got = aic_smoother(&x, &y, bandwidth, kernel);
1252        assert!(
1253            (got - expected).abs() < 1e-12,
1254            "got={got}, expected={expected}"
1255        );
1256    }
1257
1258    #[test]
1259    fn test_aic_smoother_invalid_inputs() {
1260        // n < 2, length mismatch, non-positive bandwidth → INFINITY.
1261        assert_eq!(aic_smoother(&[0.0], &[1.0], 0.3, "gaussian"), f64::INFINITY);
1262        assert_eq!(
1263            aic_smoother(&[0.0, 1.0], &[1.0], 0.3, "gaussian"),
1264            f64::INFINITY
1265        );
1266        assert_eq!(
1267            aic_smoother(&[0.0, 1.0], &[1.0, 2.0], 0.0, "gaussian"),
1268            f64::INFINITY
1269        );
1270    }
1271
1272    #[test]
1273    fn test_optim_bandwidth_aic_matches_brute_force_grid() {
1274        // optim_bandwidth with AIC must return the argmin (first-minimum tie
1275        // break) of the AIC over the exact same bandwidth grid it searches.
1276        let x = uniform_grid(25);
1277        let y: Vec<f64> = x
1278            .iter()
1279            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
1280            .collect();
1281        let kernel = "gaussian";
1282        let n_grid = 20;
1283
1284        // Replicate optim_bandwidth's default grid range.
1285        let n = x.len();
1286        let x_min = x.iter().copied().fold(f64::INFINITY, f64::min);
1287        let x_max = x.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1288        let h_default = ((x_max - x_min) / (n as f64).powf(0.2)).max(1e-10);
1289        let (h_min, h_max) = (h_default / 5.0, h_default * 5.0);
1290
1291        let mut best_h = h_min;
1292        let mut best_score = f64::INFINITY;
1293        for i in 0..n_grid {
1294            let h = h_min + (h_max - h_min) * i as f64 / (n_grid - 1) as f64;
1295            let score = aic_smoother(&x, &y, h, kernel);
1296            if score < best_score {
1297                best_score = score;
1298                best_h = h;
1299            }
1300        }
1301
1302        let result = optim_bandwidth(&x, &y, None, CvCriterion::Aic, kernel, n_grid);
1303        assert_eq!(result.criterion, CvCriterion::Aic);
1304        assert_eq!(result.h_opt, best_h);
1305        assert_eq!(result.value, best_score);
1306    }
1307
1308    #[test]
1309    fn test_optim_bandwidth_aic_diverges_from_gcv() {
1310        // AIC and GCV share the same df (tr(S)) but combine it differently:
1311        // GCV's multiplicative (1 − tr/n)⁻² penalty blows up as the bandwidth
1312        // shrinks (df → n), whereas AIC's additive 2·tr penalty is far milder.
1313        // On noisy data over a range that reaches small bandwidths, AIC
1314        // under-smooths (picks a smaller h) while GCV bottoms out at a larger h
1315        // — proving the AIC path is genuinely distinct, not a GCV alias.
1316        let n = 50usize;
1317        let x: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64 - 1.0)).collect();
1318        // Deterministic LCG noise for a reproducible divergence.
1319        let mut seed = 999u64;
1320        let mut lcg = || {
1321            seed = seed
1322                .wrapping_mul(6364136223846793005)
1323                .wrapping_add(1442695040888963407);
1324            ((seed >> 11) as f64) / ((1u64 << 53) as f64) - 0.5
1325        };
1326        let y: Vec<f64> = x
1327            .iter()
1328            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin() + 0.5 * lcg())
1329            .collect();
1330        let kernel = "gaussian";
1331        let range = Some((0.01, 0.5));
1332
1333        let aic = optim_bandwidth(&x, &y, range, CvCriterion::Aic, kernel, 60);
1334        let gcv = optim_bandwidth(&x, &y, range, CvCriterion::Gcv, kernel, 60);
1335        assert!(aic.h_opt.is_finite() && gcv.h_opt.is_finite());
1336        assert_ne!(
1337            aic.h_opt, gcv.h_opt,
1338            "AIC and GCV selected the same bandwidth; expected divergence"
1339        );
1340        // AIC's weaker penalty under-smooths relative to GCV here.
1341        assert!(
1342            aic.h_opt < gcv.h_opt,
1343            "expected AIC to pick a smaller bandwidth than GCV: aic={}, gcv={}",
1344            aic.h_opt,
1345            gcv.h_opt
1346        );
1347    }
1348
1349    #[test]
1350    fn test_gcv_cv_unchanged_by_aic_addition() {
1351        // Guard: adding the Aic variant must not perturb existing GCV/CV paths.
1352        let x = uniform_grid(25);
1353        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1354        let kernel = "gaussian";
1355        let h = 0.2;
1356        // Recompute the classic formulas directly and compare to the public fns.
1357        let n = x.len();
1358        let s = smoothing_matrix_nw(&x, h, kernel).unwrap();
1359        let mut rss = 0.0;
1360        for i in 0..n {
1361            let y_hat: f64 = (0..n).map(|j| s[i + j * n] * y[j]).sum();
1362            rss += (y[i] - y_hat).powi(2);
1363        }
1364        let trace_s: f64 = (0..n).map(|i| s[i + i * n]).sum();
1365        let denom = 1.0 - trace_s / n as f64;
1366        let expected_gcv = (rss / n as f64) / (denom * denom);
1367        assert!((gcv_smoother(&x, &y, h, kernel) - expected_gcv).abs() < 1e-12);
1368    }
1369
1370    // ============== kNN CV tests ==============
1371
1372    #[test]
1373    fn test_knn_gcv_returns_valid() {
1374        let x = uniform_grid(20);
1375        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1376
1377        let result = knn_gcv(&x, &y, 10);
1378        assert!(result.optimal_k >= 1);
1379        assert!(result.optimal_k <= 10);
1380        assert_eq!(result.cv_errors.len(), 10);
1381        for &e in &result.cv_errors {
1382            assert!(e.is_finite());
1383            assert!(e >= 0.0);
1384        }
1385    }
1386
1387    #[test]
1388    fn test_knn_gcv_constant_data() {
1389        let x = uniform_grid(15);
1390        let y = vec![5.0; 15];
1391        let result = knn_gcv(&x, &y, 5);
1392        // For constant data, error should be near zero for any k
1393        for &e in &result.cv_errors {
1394            assert!(e < 0.01, "Constant data: CV error should be near zero");
1395        }
1396    }
1397
1398    #[test]
1399    fn test_knn_lcv_returns_valid() {
1400        let x = uniform_grid(15);
1401        let y: Vec<f64> = x.to_vec();
1402
1403        let result = knn_lcv(&x, &y, 5);
1404        assert_eq!(result.len(), 15);
1405        for &k in &result {
1406            assert!(k >= 1);
1407            assert!(k <= 5);
1408        }
1409    }
1410
1411    #[test]
1412    fn test_knn_lcv_constant_data() {
1413        let x = uniform_grid(10);
1414        let y = vec![3.0; 10];
1415        let result = knn_lcv(&x, &y, 5);
1416        assert_eq!(result.len(), 10);
1417        // For constant data, all k values should give zero error
1418        // so k=1 is optimal (first tested)
1419        for &k in &result {
1420            assert!(k >= 1);
1421        }
1422    }
1423
1424    // ============== Tricube kernel tests ==============
1425
1426    #[test]
1427    fn test_tricube_kernel_values() {
1428        // At u=0, tricube should be 1.0
1429        let k0 = tricube_kernel(0.0);
1430        assert!((k0 - 1.0).abs() < 1e-10, "tricube(0) should be 1.0");
1431
1432        // At |u| >= 1, tricube should be 0.0
1433        assert_eq!(tricube_kernel(1.0), 0.0, "tricube(1) should be 0");
1434        assert_eq!(tricube_kernel(-1.0), 0.0, "tricube(-1) should be 0");
1435        assert_eq!(tricube_kernel(2.0), 0.0, "tricube(2) should be 0");
1436
1437        // At u=0.5, should be positive and less than 1
1438        let k05 = tricube_kernel(0.5);
1439        assert!(k05 > 0.0 && k05 < 1.0, "tricube(0.5) should be in (0, 1)");
1440    }
1441
1442    #[test]
1443    fn test_nw_tricube_constant_data() {
1444        let x = uniform_grid(20);
1445        let y = vec![5.0; 20];
1446
1447        let y_smooth = nadaraya_watson(&x, &y, &x, 0.2, "tricube").unwrap();
1448
1449        for &yi in &y_smooth {
1450            assert!(
1451                (yi - 5.0).abs() < 0.1,
1452                "Tricube NW: constant data should remain constant"
1453            );
1454        }
1455    }
1456
1457    #[test]
1458    fn test_nw_tricube_linear_data() {
1459        let x = uniform_grid(50);
1460        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1461
1462        let y_smooth = nadaraya_watson(&x, &y, &x, 0.2, "tricube").unwrap();
1463
1464        // Interior points should be approximately correct
1465        for i in 10..40 {
1466            let expected = 2.0 * x[i] + 1.0;
1467            assert!(
1468                (y_smooth[i] - expected).abs() < 0.3,
1469                "Tricube NW: linear trend should be approximately preserved at i={i}"
1470            );
1471        }
1472    }
1473
1474    #[test]
1475    fn test_nw_tricube_vs_gaussian() {
1476        let x = uniform_grid(30);
1477        let y: Vec<f64> = x
1478            .iter()
1479            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
1480            .collect();
1481
1482        let y_gauss = nadaraya_watson(&x, &y, &x, 0.15, "gaussian").unwrap();
1483        let y_tri = nadaraya_watson(&x, &y, &x, 0.15, "tricube").unwrap();
1484
1485        assert_eq!(y_gauss.len(), y_tri.len());
1486
1487        // Both should produce valid output
1488        assert!(y_tri.iter().all(|v| v.is_finite()));
1489
1490        // They should be different
1491        let diff: f64 = y_gauss.iter().zip(&y_tri).map(|(a, b)| (a - b).abs()).sum();
1492        assert!(
1493            diff > 0.0,
1494            "Gaussian and tricube kernels should give different results"
1495        );
1496    }
1497
1498    #[test]
1499    fn test_nw_tricube_vs_epanechnikov() {
1500        let x = uniform_grid(30);
1501        let y: Vec<f64> = x
1502            .iter()
1503            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
1504            .collect();
1505
1506        let y_epan = nadaraya_watson(&x, &y, &x, 0.15, "epanechnikov").unwrap();
1507        let y_tri = nadaraya_watson(&x, &y, &x, 0.15, "tricube").unwrap();
1508
1509        // Both compact support kernels should produce finite output
1510        assert!(y_epan.iter().all(|v| v.is_finite()));
1511        assert!(y_tri.iter().all(|v| v.is_finite()));
1512
1513        // Should differ since kernel shapes are different
1514        let diff: f64 = y_epan.iter().zip(&y_tri).map(|(a, b)| (a - b).abs()).sum();
1515        assert!(
1516            diff > 0.0,
1517            "Epanechnikov and tricube should give different results"
1518        );
1519    }
1520
1521    #[test]
1522    fn test_ll_tricube_constant_data() {
1523        let x = uniform_grid(20);
1524        let y = vec![3.0; 20];
1525
1526        let y_smooth = local_linear(&x, &y, &x, 0.2, "tricube").unwrap();
1527
1528        for &yi in &y_smooth {
1529            assert!(
1530                (yi - 3.0).abs() < 0.1,
1531                "Tricube LL: constant should remain constant"
1532            );
1533        }
1534    }
1535
1536    #[test]
1537    fn test_ll_tricube_linear_data() {
1538        let x = uniform_grid(30);
1539        let y: Vec<f64> = x.iter().map(|&xi| 3.0 * xi + 2.0).collect();
1540
1541        let y_smooth = local_linear(&x, &y, &x, 0.2, "tricube").unwrap();
1542
1543        // Local linear should fit linear data well in the interior
1544        for i in 5..25 {
1545            let expected = 3.0 * x[i] + 2.0;
1546            assert!(
1547                (y_smooth[i] - expected).abs() < 0.2,
1548                "Tricube LL: should fit linear data well at i={i}"
1549            );
1550        }
1551    }
1552
1553    #[test]
1554    fn test_ll_tricube_vs_gaussian() {
1555        let x = uniform_grid(30);
1556        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1557
1558        let y_gauss = local_linear(&x, &y, &x, 0.15, "gaussian").unwrap();
1559        let y_tri = local_linear(&x, &y, &x, 0.15, "tricube").unwrap();
1560
1561        assert_eq!(y_gauss.len(), y_tri.len());
1562        assert!(y_tri.iter().all(|v| v.is_finite()));
1563
1564        let diff: f64 = y_gauss.iter().zip(&y_tri).map(|(a, b)| (a - b).abs()).sum();
1565        assert!(
1566            diff > 0.0,
1567            "Gaussian and tricube local linear should differ"
1568        );
1569    }
1570
1571    #[test]
1572    fn test_lp_tricube_quadratic() {
1573        let x = uniform_grid(40);
1574        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1575
1576        let y_smooth = local_polynomial(&x, &y, &x, 0.15, 2, "tricube").unwrap();
1577
1578        // Local quadratic with tricube should fit well in interior
1579        for i in 8..32 {
1580            let expected = x[i] * x[i];
1581            assert!(
1582                (y_smooth[i] - expected).abs() < 0.15,
1583                "Tricube LP: should fit quadratic data at i={i}"
1584            );
1585        }
1586    }
1587
1588    #[test]
1589    fn test_get_kernel_tricube_aliases() {
1590        // "tricube" and "tri-cube" should both resolve to the tricube kernel
1591        let k1 = get_kernel("tricube");
1592        let k2 = get_kernel("tri-cube");
1593
1594        let test_val = 0.5;
1595        assert!(
1596            (k1(test_val) - k2(test_val)).abs() < 1e-15,
1597            "Both tricube aliases should give the same result"
1598        );
1599    }
1600
1601    #[test]
1602    fn test_smoothing_matrix_tricube() {
1603        let x = uniform_grid(10);
1604        let s = smoothing_matrix_nw(&x, 0.2, "tricube").unwrap();
1605
1606        assert_eq!(s.len(), 100);
1607
1608        // Each row should sum to 1 (row stochastic)
1609        for i in 0..10 {
1610            let row_sum: f64 = (0..10).map(|j| s[i + j * 10]).sum();
1611            assert!(
1612                (row_sum - 1.0).abs() < 1e-10,
1613                "Tricube: row {} should sum to 1, got {}",
1614                i,
1615                row_sum
1616            );
1617        }
1618    }
1619
1620    #[test]
1621    fn test_cv_smoother_tricube() {
1622        let x = uniform_grid(30);
1623        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1624        let cv = cv_smoother(&x, &y, 0.2, "tricube");
1625        assert!(cv.is_finite());
1626        assert!(cv >= 0.0);
1627    }
1628
1629    #[test]
1630    fn test_optim_bandwidth_tricube() {
1631        let x = uniform_grid(25);
1632        let y: Vec<f64> = x
1633            .iter()
1634            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
1635            .collect();
1636
1637        let result = optim_bandwidth(&x, &y, None, CvCriterion::Gcv, "tricube", 20);
1638        assert!(result.h_opt > 0.0);
1639        assert!(result.value.is_finite());
1640    }
1641}