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(crate) 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#[non_exhaustive]
544#[derive(Debug, Clone, PartialEq)]
545pub struct OptimBandwidthResult {
546    /// Optimal bandwidth.
547    pub h_opt: f64,
548    /// Criterion used.
549    pub criterion: CvCriterion,
550    /// Criterion value at optimal h.
551    pub value: f64,
552}
553
554/// LOO-CV score for a kernel smoother (R's `CV.S`).
555///
556/// Computes the leave-one-out CV score by zeroing the diagonal of the
557/// smoothing matrix, re-normalizing rows, and computing mean squared error.
558///
559/// # Arguments
560/// * `x` — Predictor values
561/// * `y` — Response values
562/// * `bandwidth` — Kernel bandwidth
563/// * `kernel` — Kernel type ("gaussian", "epanechnikov", "tricube")
564///
565/// # Returns
566/// Mean squared LOO prediction error, or `INFINITY` if inputs are invalid.
567pub fn cv_smoother(x: &[f64], y: &[f64], bandwidth: f64, kernel: &str) -> f64 {
568    let n = x.len();
569    if n < 2 || y.len() != n || bandwidth <= 0.0 {
570        return f64::INFINITY;
571    }
572
573    // Get the smoother matrix S
574    let mut s = match smoothing_matrix_nw(x, bandwidth, kernel) {
575        Ok(s) => s,
576        Err(_) => return f64::INFINITY,
577    };
578
579    // Zero the diagonal → S_cv (LOO smoother)
580    for i in 0..n {
581        s[i + i * n] = 0.0;
582    }
583
584    // Re-normalize each row so it sums to 1
585    for i in 0..n {
586        let row_sum: f64 = (0..n).map(|j| s[i + j * n]).sum();
587        if row_sum > 1e-10 {
588            for j in 0..n {
589                s[i + j * n] /= row_sum;
590            }
591        }
592    }
593
594    // Compute y_hat = S_cv * y, then MSE
595    let mut mse = 0.0;
596    for i in 0..n {
597        let y_hat: f64 = (0..n).map(|j| s[i + j * n] * y[j]).sum();
598        let resid = y[i] - y_hat;
599        mse += resid * resid;
600    }
601    mse / n as f64
602}
603
604/// GCV score for a kernel smoother (R's `GCV.S`).
605///
606/// Computes `(RSS / n) / (1 - tr(S) / n)²`.
607///
608/// # Arguments
609/// * `x` — Predictor values
610/// * `y` — Response values
611/// * `bandwidth` — Kernel bandwidth
612/// * `kernel` — Kernel type
613///
614/// # Returns
615/// GCV score, or `INFINITY` if inputs are invalid or denominator is near zero.
616pub fn gcv_smoother(x: &[f64], y: &[f64], bandwidth: f64, kernel: &str) -> f64 {
617    let n = x.len();
618    if n < 2 || y.len() != n || bandwidth <= 0.0 {
619        return f64::INFINITY;
620    }
621
622    let s = match smoothing_matrix_nw(x, bandwidth, kernel) {
623        Ok(s) => s,
624        Err(_) => return f64::INFINITY,
625    };
626
627    // y_hat = S * y
628    let mut rss = 0.0;
629    for i in 0..n {
630        let y_hat: f64 = (0..n).map(|j| s[i + j * n] * y[j]).sum();
631        let resid = y[i] - y_hat;
632        rss += resid * resid;
633    }
634
635    // trace(S) = sum of diagonal
636    let trace_s: f64 = (0..n).map(|i| s[i + i * n]).sum();
637
638    let denom = 1.0 - trace_s / n as f64;
639    if denom.abs() < 1e-10 {
640        f64::INFINITY
641    } else {
642        (rss / n as f64) / (denom * denom)
643    }
644}
645
646/// AIC score for a kernel smoother.
647///
648/// Computes `AIC = n·ln(RSS/n) + 2·tr(S)`, where `RSS` and `tr(S)` come from the
649/// same Nadaraya–Watson smoother matrix `S` that [`gcv_smoother`] uses. The
650/// effective degrees of freedom is the trace of the smoother matrix (the same
651/// hat-matrix trace GCV divides by), so AIC and GCV share their df definition
652/// but combine it differently: AIC applies an additive `2·tr(S)` penalty to the
653/// log residual variance instead of GCV's multiplicative `(1 − tr(S)/n)⁻²`.
654///
655/// # Arguments
656/// * `x` — Predictor values
657/// * `y` — Response values
658/// * `bandwidth` — Kernel bandwidth
659/// * `kernel` — Kernel type ("gaussian", "epanechnikov", "tricube")
660///
661/// # Returns
662/// AIC score, or `INFINITY` if inputs are invalid (n < 2, length mismatch, or
663/// non-positive bandwidth), matching [`gcv_smoother`]'s guards.
664pub fn aic_smoother(x: &[f64], y: &[f64], bandwidth: f64, kernel: &str) -> f64 {
665    let n = x.len();
666    if n < 2 || y.len() != n || bandwidth <= 0.0 {
667        return f64::INFINITY;
668    }
669
670    let s = match smoothing_matrix_nw(x, bandwidth, kernel) {
671        Ok(s) => s,
672        Err(_) => return f64::INFINITY,
673    };
674
675    // y_hat = S * y  →  RSS
676    let mut rss = 0.0;
677    for i in 0..n {
678        let y_hat: f64 = (0..n).map(|j| s[i + j * n] * y[j]).sum();
679        let resid = y[i] - y_hat;
680        rss += resid * resid;
681    }
682
683    // trace(S) = sum of diagonal — same hat-matrix trace GCV uses as df
684    let trace_s: f64 = (0..n).map(|i| s[i + i * n]).sum();
685
686    let n_f = n as f64;
687    // Standard smoother AIC: n·ln(RSS/n) + 2·tr(S).
688    n_f * (rss / n_f).max(1e-300).ln() + 2.0 * trace_s
689}
690
691/// Bandwidth optimizer for kernel smoothers (R's `optim.np`).
692///
693/// Grid search over evenly-spaced bandwidths, selecting the one that
694/// minimizes the specified criterion (CV, GCV, or AIC).
695///
696/// # Arguments
697/// * `x` — Predictor values
698/// * `y` — Response values
699/// * `h_range` — Optional `(h_min, h_max)`. Defaults to `(h_default / 5, h_default * 5)`
700///   where `h_default = (x_max - x_min) / n^0.2`.
701/// * `criterion` — CV or GCV
702/// * `kernel` — Kernel type
703/// * `n_grid` — Number of grid points (default: 50)
704///
705/// # Examples
706///
707/// ```
708/// use fdars_core::smoothing::{optim_bandwidth, CvCriterion};
709///
710/// let x: Vec<f64> = (0..25).map(|i| i as f64 / 24.0).collect();
711/// let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
712/// let result = optim_bandwidth(&x, &y, None, CvCriterion::Gcv, "gaussian", 20);
713/// assert!(result.h_opt > 0.0);
714/// assert!(result.value.is_finite());
715/// ```
716pub fn optim_bandwidth(
717    x: &[f64],
718    y: &[f64],
719    h_range: Option<(f64, f64)>,
720    criterion: CvCriterion,
721    kernel: &str,
722    n_grid: usize,
723) -> OptimBandwidthResult {
724    let n = x.len();
725    let n_grid = n_grid.max(2);
726
727    // Determine search range
728    let (h_min, h_max) = match h_range {
729        Some((lo, hi)) if lo > 0.0 && hi > lo => (lo, hi),
730        _ => {
731            let x_min = x.iter().copied().fold(f64::INFINITY, f64::min);
732            let x_max = x.iter().copied().fold(f64::NEG_INFINITY, f64::max);
733            let h_default = (x_max - x_min) / (n as f64).powf(0.2);
734            let h_default = h_default.max(1e-10);
735            (h_default / 5.0, h_default * 5.0)
736        }
737    };
738
739    let score_fn = match criterion {
740        CvCriterion::Cv => cv_smoother,
741        CvCriterion::Gcv => gcv_smoother,
742        CvCriterion::Aic => aic_smoother,
743    };
744
745    let mut best_h = h_min;
746    let mut best_score = f64::INFINITY;
747
748    for i in 0..n_grid {
749        let h = h_min + (h_max - h_min) * i as f64 / (n_grid - 1) as f64;
750        let score = score_fn(x, y, h, kernel);
751        if score < best_score {
752            best_score = score;
753            best_h = h;
754        }
755    }
756
757    OptimBandwidthResult {
758        h_opt: best_h,
759        criterion,
760        value: best_score,
761    }
762}
763
764// ─── kNN CV Functions ───────────────────────────────────────────────────────
765
766/// Result of kNN k-selection by cross-validation.
767#[non_exhaustive]
768#[derive(Debug, Clone, PartialEq)]
769pub struct KnnCvResult {
770    /// Optimal k (number of neighbors).
771    pub optimal_k: usize,
772    /// CV error for each k tested (index 0 = k=1).
773    pub cv_errors: Vec<f64>,
774}
775
776/// Global LOO-CV for kNN k selection (R's `knn.gcv`).
777///
778/// For each candidate k, computes LOO prediction error using a
779/// kernel-weighted kNN estimator with Epanechnikov kernel.
780///
781/// # Arguments
782/// * `x` — Predictor values
783/// * `y` — Response values
784/// * `max_k` — Maximum k to test (tests k = 1, 2, …, max_k)
785pub fn knn_gcv(x: &[f64], y: &[f64], max_k: usize) -> KnnCvResult {
786    let n = x.len();
787    let max_k = max_k.min(n.saturating_sub(1)).max(1);
788
789    // Precompute sorted distances from each point to all others
790    let mut sorted_neighbors: Vec<Vec<(usize, f64)>> = Vec::with_capacity(n);
791    for i in 0..n {
792        let mut dists: Vec<(usize, f64)> = (0..n)
793            .filter(|&j| j != i)
794            .map(|j| (j, (x[j] - x[i]).abs()))
795            .collect();
796        dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
797        sorted_neighbors.push(dists);
798    }
799
800    let mut cv_errors = Vec::with_capacity(max_k);
801
802    for k in 1..=max_k {
803        let mut mse = 0.0;
804        for i in 0..n {
805            let neighbors = &sorted_neighbors[i];
806            // Bandwidth: midpoint between k-th and (k+1)-th NN distances
807            let d_k = if k <= neighbors.len() {
808                neighbors[k - 1].1
809            } else {
810                neighbors.last().map_or(1.0, |x| x.1)
811            };
812            let d_k1 = if k < neighbors.len() {
813                neighbors[k].1
814            } else {
815                d_k * 2.0
816            };
817            let h = (d_k + d_k1) / 2.0;
818            let h = h.max(1e-10);
819
820            // Epanechnikov kernel weighted prediction
821            let mut num = 0.0;
822            let mut den = 0.0;
823            for &(j, dist) in neighbors.iter().take(k) {
824                let u = dist / h;
825                let w = epanechnikov_kernel(u);
826                num += w * y[j];
827                den += w;
828            }
829            let y_hat = if den > 1e-10 { num / den } else { y[i] };
830            mse += (y[i] - y_hat).powi(2);
831        }
832        cv_errors.push(mse / n as f64);
833    }
834
835    let optimal_k = cv_errors
836        .iter()
837        .enumerate()
838        .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
839        .map_or(1, |(i, _)| i + 1);
840
841    KnnCvResult {
842        optimal_k,
843        cv_errors,
844    }
845}
846
847/// Local (per-observation) LOO-CV for kNN k selection (R's `knn.lcv`).
848///
849/// For each observation, independently selects the best k by minimizing
850/// the absolute LOO prediction error at that point.
851///
852/// # Arguments
853/// * `x` — Predictor values
854/// * `y` — Response values
855/// * `max_k` — Maximum k to test
856///
857/// # Returns
858/// Vector of per-observation optimal k values (length n).
859pub fn knn_lcv(x: &[f64], y: &[f64], max_k: usize) -> Vec<usize> {
860    let n = x.len();
861    let max_k = max_k.min(n.saturating_sub(1)).max(1);
862
863    let mut per_obs_k = vec![1usize; n];
864
865    for i in 0..n {
866        // Sort neighbors by distance (excluding self)
867        let mut neighbors: Vec<(usize, f64)> = (0..n)
868            .filter(|&j| j != i)
869            .map(|j| (j, (x[j] - x[i]).abs()))
870            .collect();
871        neighbors.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
872
873        let mut best_k = 1;
874        let mut best_err = f64::INFINITY;
875
876        for k in 1..=max_k {
877            // Simple kNN average of k nearest neighbors
878            let sum: f64 = neighbors.iter().take(k).map(|&(j, _)| y[j]).sum();
879            let y_hat = sum / k as f64;
880            let err = (y[i] - y_hat).abs();
881            if err < best_err {
882                best_err = err;
883                best_k = k;
884            }
885        }
886        per_obs_k[i] = best_k;
887    }
888
889    per_obs_k
890}
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895    use crate::test_helpers::uniform_grid;
896
897    // ============== Nadaraya-Watson tests ==============
898
899    #[test]
900    fn test_nw_constant_data() {
901        let x = uniform_grid(20);
902        let y: Vec<f64> = vec![5.0; 20];
903
904        let y_smooth = nadaraya_watson(&x, &y, &x, 0.1, "gaussian").unwrap();
905
906        // Smoothing constant data should return constant
907        for &yi in &y_smooth {
908            assert!(
909                (yi - 5.0).abs() < 0.1,
910                "Constant data should remain constant"
911            );
912        }
913    }
914
915    #[test]
916    fn test_nw_linear_data() {
917        let x = uniform_grid(50);
918        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
919
920        let y_smooth = nadaraya_watson(&x, &y, &x, 0.2, "gaussian").unwrap();
921
922        // Linear data should be approximately preserved (with some edge effects)
923        for i in 10..40 {
924            let expected = 2.0 * x[i] + 1.0;
925            assert!(
926                (y_smooth[i] - expected).abs() < 0.2,
927                "Linear trend should be approximately preserved"
928            );
929        }
930    }
931
932    #[test]
933    fn test_nw_gaussian_vs_epanechnikov() {
934        let x = uniform_grid(30);
935        let y: Vec<f64> = x
936            .iter()
937            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
938            .collect();
939
940        let y_gauss = nadaraya_watson(&x, &y, &x, 0.1, "gaussian").unwrap();
941        let y_epan = nadaraya_watson(&x, &y, &x, 0.1, "epanechnikov").unwrap();
942
943        // Both should produce valid output
944        assert_eq!(y_gauss.len(), 30);
945        assert_eq!(y_epan.len(), 30);
946
947        // They should be different (different kernels)
948        let diff: f64 = y_gauss
949            .iter()
950            .zip(&y_epan)
951            .map(|(a, b)| (a - b).abs())
952            .sum();
953        assert!(
954            diff > 0.0,
955            "Different kernels should give different results"
956        );
957    }
958
959    #[test]
960    fn test_nw_invalid_input() {
961        // Empty input
962        assert!(nadaraya_watson(&[], &[], &[0.5], 0.1, "gaussian").is_err());
963
964        // Mismatched lengths
965        assert!(nadaraya_watson(&[0.0, 1.0], &[1.0], &[0.5], 0.1, "gaussian").is_err());
966
967        // Zero bandwidth
968        assert!(nadaraya_watson(&[0.0, 1.0], &[1.0, 2.0], &[0.5], 0.0, "gaussian").is_err());
969
970        // Empty x_new
971        assert!(nadaraya_watson(&[0.0, 1.0], &[1.0, 2.0], &[], 0.1, "gaussian").is_err());
972    }
973
974    // ============== Local linear tests ==============
975
976    #[test]
977    fn test_ll_constant_data() {
978        let x = uniform_grid(20);
979        let y: Vec<f64> = vec![3.0; 20];
980
981        let y_smooth = local_linear(&x, &y, &x, 0.15, "gaussian").unwrap();
982
983        for &yi in &y_smooth {
984            assert!((yi - 3.0).abs() < 0.1, "Constant should remain constant");
985        }
986    }
987
988    #[test]
989    fn test_ll_linear_data_exact() {
990        let x = uniform_grid(30);
991        let y: Vec<f64> = x.iter().map(|&xi| 3.0 * xi + 2.0).collect();
992
993        let y_smooth = local_linear(&x, &y, &x, 0.2, "gaussian").unwrap();
994
995        // Local linear should fit linear data exactly (in interior)
996        for i in 5..25 {
997            let expected = 3.0 * x[i] + 2.0;
998            assert!(
999                (y_smooth[i] - expected).abs() < 0.1,
1000                "Local linear should fit linear data well"
1001            );
1002        }
1003    }
1004
1005    #[test]
1006    fn test_ll_invalid_input() {
1007        assert!(local_linear(&[], &[], &[0.5], 0.1, "gaussian").is_err());
1008
1009        assert!(local_linear(&[0.0, 1.0], &[1.0, 2.0], &[0.5], -0.1, "gaussian").is_err());
1010    }
1011
1012    // ============== Local polynomial tests ==============
1013
1014    #[test]
1015    fn test_lp_degree1_equals_local_linear() {
1016        let x = uniform_grid(25);
1017        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1018
1019        let y_ll = local_linear(&x, &y, &x, 0.15, "gaussian").unwrap();
1020        let y_lp = local_polynomial(&x, &y, &x, 0.15, 1, "gaussian").unwrap();
1021
1022        for i in 0..25 {
1023            assert!(
1024                (y_ll[i] - y_lp[i]).abs() < 1e-10,
1025                "Degree 1 should equal local linear"
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn test_lp_quadratic_data() {
1032        let x = uniform_grid(40);
1033        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1034
1035        let y_smooth = local_polynomial(&x, &y, &x, 0.15, 2, "gaussian").unwrap();
1036
1037        // Local quadratic should fit quadratic data well in interior
1038        for i in 8..32 {
1039            let expected = x[i] * x[i];
1040            assert!(
1041                (y_smooth[i] - expected).abs() < 0.1,
1042                "Local quadratic should fit quadratic data"
1043            );
1044        }
1045    }
1046
1047    #[test]
1048    fn test_lp_invalid_input() {
1049        // Zero degree delegates to Nadaraya-Watson (not zeros)
1050        let result =
1051            local_polynomial(&[0.0, 1.0], &[1.0, 2.0], &[0.5], 0.1, 0, "gaussian").unwrap();
1052        let nw = nadaraya_watson(&[0.0, 1.0], &[1.0, 2.0], &[0.5], 0.1, "gaussian").unwrap();
1053        assert_eq!(result, nw);
1054
1055        // Empty input
1056        assert!(local_polynomial(&[], &[], &[0.5], 0.1, 2, "gaussian").is_err());
1057    }
1058
1059    // ============== KNN smoother tests ==============
1060
1061    #[test]
1062    fn test_knn_k1_nearest() {
1063        let x = vec![0.0, 0.5, 1.0];
1064        let y = vec![1.0, 2.0, 3.0];
1065
1066        let result = knn_smoother(&x, &y, &[0.1, 0.6, 0.9], 1).unwrap();
1067
1068        // k=1 should return the nearest neighbor's y value
1069        assert!((result[0] - 1.0).abs() < 1e-10, "0.1 nearest to 0.0 -> 1.0");
1070        assert!((result[1] - 2.0).abs() < 1e-10, "0.6 nearest to 0.5 -> 2.0");
1071        assert!((result[2] - 3.0).abs() < 1e-10, "0.9 nearest to 1.0 -> 3.0");
1072    }
1073
1074    #[test]
1075    fn test_knn_k_equals_n_is_mean() {
1076        let x = vec![0.0, 0.25, 0.5, 0.75, 1.0];
1077        let y = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1078        let expected_mean = 3.0;
1079
1080        let result = knn_smoother(&x, &y, &[0.5], 5).unwrap();
1081
1082        assert!(
1083            (result[0] - expected_mean).abs() < 1e-10,
1084            "k=n should return mean"
1085        );
1086    }
1087
1088    #[test]
1089    fn test_knn_invalid_input() {
1090        assert!(knn_smoother(&[], &[], &[0.5], 3).is_err());
1091
1092        assert!(knn_smoother(&[0.0, 1.0], &[1.0, 2.0], &[0.5], 0).is_err());
1093    }
1094
1095    // ============== Smoothing matrix tests ==============
1096
1097    #[test]
1098    fn test_smoothing_matrix_row_stochastic() {
1099        let x = uniform_grid(10);
1100        let s = smoothing_matrix_nw(&x, 0.2, "gaussian").unwrap();
1101
1102        assert_eq!(s.len(), 100);
1103
1104        // Each row should sum to 1 (row stochastic)
1105        for i in 0..10 {
1106            let row_sum: f64 = (0..10).map(|j| s[i + j * 10]).sum();
1107            assert!(
1108                (row_sum - 1.0).abs() < 1e-10,
1109                "Row {} should sum to 1, got {}",
1110                i,
1111                row_sum
1112            );
1113        }
1114    }
1115
1116    #[test]
1117    fn test_smoothing_matrix_invalid_input() {
1118        assert!(smoothing_matrix_nw(&[], 0.1, "gaussian").is_err());
1119
1120        assert!(smoothing_matrix_nw(&[0.0, 1.0], 0.0, "gaussian").is_err());
1121    }
1122
1123    #[test]
1124    fn test_nan_nw_no_panic() {
1125        let x = vec![0.0, 0.25, 0.5, 0.75, 1.0];
1126        let mut y = vec![0.0, 1.0, 2.0, 1.0, 0.0];
1127        y[2] = f64::NAN;
1128        let result = nadaraya_watson(&x, &y, &x, 0.3, "gaussian").unwrap();
1129        assert_eq!(result.len(), x.len());
1130        // NaN should propagate but not panic
1131    }
1132
1133    #[test]
1134    fn test_n1_smoother() {
1135        // Single data point
1136        let x = vec![0.5];
1137        let y = vec![3.0];
1138        let x_new = vec![0.5];
1139        let result = nadaraya_watson(&x, &y, &x_new, 0.3, "gaussian").unwrap();
1140        assert_eq!(result.len(), 1);
1141        assert!(
1142            (result[0] - 3.0).abs() < 1e-6,
1143            "Single point smoother should return the value"
1144        );
1145    }
1146
1147    #[test]
1148    fn test_duplicate_x_smoother() {
1149        // Duplicate x values
1150        let x = vec![0.0, 0.0, 0.5, 1.0, 1.0];
1151        let y = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1152        let x_new = vec![0.0, 0.5, 1.0];
1153        let result = nadaraya_watson(&x, &y, &x_new, 0.3, "gaussian").unwrap();
1154        assert_eq!(result.len(), 3);
1155        for v in &result {
1156            assert!(v.is_finite());
1157        }
1158    }
1159
1160    // ============== CV smoother tests ==============
1161
1162    #[test]
1163    fn test_cv_smoother_linear_data() {
1164        let x = uniform_grid(30);
1165        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1166        let cv = cv_smoother(&x, &y, 0.2, "gaussian");
1167        assert!(cv.is_finite());
1168        assert!(cv >= 0.0);
1169        assert!(cv < 1.0, "CV error for smooth linear data should be small");
1170    }
1171
1172    #[test]
1173    fn test_cv_smoother_invalid() {
1174        assert_eq!(cv_smoother(&[], &[], 0.1, "gaussian"), f64::INFINITY);
1175        assert_eq!(
1176            cv_smoother(&[0.0, 1.0], &[1.0, 2.0], -0.1, "gaussian"),
1177            f64::INFINITY
1178        );
1179    }
1180
1181    #[test]
1182    fn test_gcv_smoother_linear_data() {
1183        let x = uniform_grid(30);
1184        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1185        let gcv = gcv_smoother(&x, &y, 0.2, "gaussian");
1186        assert!(gcv.is_finite());
1187        assert!(gcv >= 0.0);
1188    }
1189
1190    #[test]
1191    fn test_gcv_smoother_invalid() {
1192        assert_eq!(gcv_smoother(&[], &[], 0.1, "gaussian"), f64::INFINITY);
1193    }
1194
1195    #[test]
1196    fn test_optim_bandwidth_returns_valid() {
1197        let x = uniform_grid(30);
1198        let y: Vec<f64> = x
1199            .iter()
1200            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
1201            .collect();
1202
1203        let result = optim_bandwidth(&x, &y, None, CvCriterion::Gcv, "gaussian", 20);
1204        assert!(result.h_opt > 0.0);
1205        assert!(result.value.is_finite());
1206        assert_eq!(result.criterion, CvCriterion::Gcv);
1207    }
1208
1209    #[test]
1210    fn test_optim_bandwidth_cv_vs_gcv() {
1211        let x = uniform_grid(25);
1212        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1213
1214        let cv_result = optim_bandwidth(&x, &y, None, CvCriterion::Cv, "gaussian", 20);
1215        let gcv_result = optim_bandwidth(&x, &y, None, CvCriterion::Gcv, "gaussian", 20);
1216
1217        assert!(cv_result.h_opt > 0.0);
1218        assert!(gcv_result.h_opt > 0.0);
1219    }
1220
1221    #[test]
1222    fn test_optim_bandwidth_custom_range() {
1223        let x = uniform_grid(20);
1224        let y: Vec<f64> = x.to_vec();
1225        let result = optim_bandwidth(&x, &y, Some((0.05, 0.5)), CvCriterion::Cv, "gaussian", 10);
1226        assert!(result.h_opt >= 0.05);
1227        assert!(result.h_opt <= 0.5);
1228    }
1229
1230    // ============== AIC smoother tests ==============
1231
1232    #[test]
1233    fn test_aic_smoother_matches_hand_computed() {
1234        // Small fixture; recompute AIC = n·ln(RSS/n) + 2·tr(S) from the same
1235        // smoother matrix and assert equality.
1236        let x = vec![0.0, 0.25, 0.5, 0.75, 1.0];
1237        let y = vec![0.1, 0.4, 0.35, 0.8, 1.2];
1238        let bandwidth = 0.3;
1239        let kernel = "gaussian";
1240
1241        let s = smoothing_matrix_nw(&x, bandwidth, kernel).unwrap();
1242        let n = x.len();
1243        let mut rss = 0.0;
1244        for i in 0..n {
1245            let y_hat: f64 = (0..n).map(|j| s[i + j * n] * y[j]).sum();
1246            let resid = y[i] - y_hat;
1247            rss += resid * resid;
1248        }
1249        let trace_s: f64 = (0..n).map(|i| s[i + i * n]).sum();
1250        let n_f = n as f64;
1251        let expected = n_f * (rss / n_f).max(1e-300).ln() + 2.0 * trace_s;
1252
1253        let got = aic_smoother(&x, &y, bandwidth, kernel);
1254        assert!(
1255            (got - expected).abs() < 1e-12,
1256            "got={got}, expected={expected}"
1257        );
1258    }
1259
1260    #[test]
1261    fn test_aic_smoother_invalid_inputs() {
1262        // n < 2, length mismatch, non-positive bandwidth → INFINITY.
1263        assert_eq!(aic_smoother(&[0.0], &[1.0], 0.3, "gaussian"), f64::INFINITY);
1264        assert_eq!(
1265            aic_smoother(&[0.0, 1.0], &[1.0], 0.3, "gaussian"),
1266            f64::INFINITY
1267        );
1268        assert_eq!(
1269            aic_smoother(&[0.0, 1.0], &[1.0, 2.0], 0.0, "gaussian"),
1270            f64::INFINITY
1271        );
1272    }
1273
1274    #[test]
1275    fn test_optim_bandwidth_aic_matches_brute_force_grid() {
1276        // optim_bandwidth with AIC must return the argmin (first-minimum tie
1277        // break) of the AIC over the exact same bandwidth grid it searches.
1278        let x = uniform_grid(25);
1279        let y: Vec<f64> = x
1280            .iter()
1281            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
1282            .collect();
1283        let kernel = "gaussian";
1284        let n_grid = 20;
1285
1286        // Replicate optim_bandwidth's default grid range.
1287        let n = x.len();
1288        let x_min = x.iter().copied().fold(f64::INFINITY, f64::min);
1289        let x_max = x.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1290        let h_default = ((x_max - x_min) / (n as f64).powf(0.2)).max(1e-10);
1291        let (h_min, h_max) = (h_default / 5.0, h_default * 5.0);
1292
1293        let mut best_h = h_min;
1294        let mut best_score = f64::INFINITY;
1295        for i in 0..n_grid {
1296            let h = h_min + (h_max - h_min) * i as f64 / (n_grid - 1) as f64;
1297            let score = aic_smoother(&x, &y, h, kernel);
1298            if score < best_score {
1299                best_score = score;
1300                best_h = h;
1301            }
1302        }
1303
1304        let result = optim_bandwidth(&x, &y, None, CvCriterion::Aic, kernel, n_grid);
1305        assert_eq!(result.criterion, CvCriterion::Aic);
1306        assert_eq!(result.h_opt, best_h);
1307        assert_eq!(result.value, best_score);
1308    }
1309
1310    #[test]
1311    fn test_optim_bandwidth_aic_diverges_from_gcv() {
1312        // AIC and GCV share the same df (tr(S)) but combine it differently:
1313        // GCV's multiplicative (1 − tr/n)⁻² penalty blows up as the bandwidth
1314        // shrinks (df → n), whereas AIC's additive 2·tr penalty is far milder.
1315        // On noisy data over a range that reaches small bandwidths, AIC
1316        // under-smooths (picks a smaller h) while GCV bottoms out at a larger h
1317        // — proving the AIC path is genuinely distinct, not a GCV alias.
1318        let n = 50usize;
1319        let x: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64 - 1.0)).collect();
1320        // Deterministic LCG noise for a reproducible divergence.
1321        let mut seed = 999u64;
1322        let mut lcg = || {
1323            seed = seed
1324                .wrapping_mul(6364136223846793005)
1325                .wrapping_add(1442695040888963407);
1326            ((seed >> 11) as f64) / ((1u64 << 53) as f64) - 0.5
1327        };
1328        let y: Vec<f64> = x
1329            .iter()
1330            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin() + 0.5 * lcg())
1331            .collect();
1332        let kernel = "gaussian";
1333        let range = Some((0.01, 0.5));
1334
1335        let aic = optim_bandwidth(&x, &y, range, CvCriterion::Aic, kernel, 60);
1336        let gcv = optim_bandwidth(&x, &y, range, CvCriterion::Gcv, kernel, 60);
1337        assert!(aic.h_opt.is_finite() && gcv.h_opt.is_finite());
1338        assert_ne!(
1339            aic.h_opt, gcv.h_opt,
1340            "AIC and GCV selected the same bandwidth; expected divergence"
1341        );
1342        // AIC's weaker penalty under-smooths relative to GCV here.
1343        assert!(
1344            aic.h_opt < gcv.h_opt,
1345            "expected AIC to pick a smaller bandwidth than GCV: aic={}, gcv={}",
1346            aic.h_opt,
1347            gcv.h_opt
1348        );
1349    }
1350
1351    #[test]
1352    fn test_gcv_cv_unchanged_by_aic_addition() {
1353        // Guard: adding the Aic variant must not perturb existing GCV/CV paths.
1354        let x = uniform_grid(25);
1355        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1356        let kernel = "gaussian";
1357        let h = 0.2;
1358        // Recompute the classic formulas directly and compare to the public fns.
1359        let n = x.len();
1360        let s = smoothing_matrix_nw(&x, h, kernel).unwrap();
1361        let mut rss = 0.0;
1362        for i in 0..n {
1363            let y_hat: f64 = (0..n).map(|j| s[i + j * n] * y[j]).sum();
1364            rss += (y[i] - y_hat).powi(2);
1365        }
1366        let trace_s: f64 = (0..n).map(|i| s[i + i * n]).sum();
1367        let denom = 1.0 - trace_s / n as f64;
1368        let expected_gcv = (rss / n as f64) / (denom * denom);
1369        assert!((gcv_smoother(&x, &y, h, kernel) - expected_gcv).abs() < 1e-12);
1370    }
1371
1372    // ============== kNN CV tests ==============
1373
1374    #[test]
1375    fn test_knn_gcv_returns_valid() {
1376        let x = uniform_grid(20);
1377        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1378
1379        let result = knn_gcv(&x, &y, 10);
1380        assert!(result.optimal_k >= 1);
1381        assert!(result.optimal_k <= 10);
1382        assert_eq!(result.cv_errors.len(), 10);
1383        for &e in &result.cv_errors {
1384            assert!(e.is_finite());
1385            assert!(e >= 0.0);
1386        }
1387    }
1388
1389    #[test]
1390    fn test_knn_gcv_constant_data() {
1391        let x = uniform_grid(15);
1392        let y = vec![5.0; 15];
1393        let result = knn_gcv(&x, &y, 5);
1394        // For constant data, error should be near zero for any k
1395        for &e in &result.cv_errors {
1396            assert!(e < 0.01, "Constant data: CV error should be near zero");
1397        }
1398    }
1399
1400    #[test]
1401    fn test_knn_lcv_returns_valid() {
1402        let x = uniform_grid(15);
1403        let y: Vec<f64> = x.to_vec();
1404
1405        let result = knn_lcv(&x, &y, 5);
1406        assert_eq!(result.len(), 15);
1407        for &k in &result {
1408            assert!(k >= 1);
1409            assert!(k <= 5);
1410        }
1411    }
1412
1413    #[test]
1414    fn test_knn_lcv_constant_data() {
1415        let x = uniform_grid(10);
1416        let y = vec![3.0; 10];
1417        let result = knn_lcv(&x, &y, 5);
1418        assert_eq!(result.len(), 10);
1419        // For constant data, all k values should give zero error
1420        // so k=1 is optimal (first tested)
1421        for &k in &result {
1422            assert!(k >= 1);
1423        }
1424    }
1425
1426    // ============== Tricube kernel tests ==============
1427
1428    #[test]
1429    fn test_tricube_kernel_values() {
1430        // At u=0, tricube should be 1.0
1431        let k0 = tricube_kernel(0.0);
1432        assert!((k0 - 1.0).abs() < 1e-10, "tricube(0) should be 1.0");
1433
1434        // At |u| >= 1, tricube should be 0.0
1435        assert_eq!(tricube_kernel(1.0), 0.0, "tricube(1) should be 0");
1436        assert_eq!(tricube_kernel(-1.0), 0.0, "tricube(-1) should be 0");
1437        assert_eq!(tricube_kernel(2.0), 0.0, "tricube(2) should be 0");
1438
1439        // At u=0.5, should be positive and less than 1
1440        let k05 = tricube_kernel(0.5);
1441        assert!(k05 > 0.0 && k05 < 1.0, "tricube(0.5) should be in (0, 1)");
1442    }
1443
1444    #[test]
1445    fn test_nw_tricube_constant_data() {
1446        let x = uniform_grid(20);
1447        let y = vec![5.0; 20];
1448
1449        let y_smooth = nadaraya_watson(&x, &y, &x, 0.2, "tricube").unwrap();
1450
1451        for &yi in &y_smooth {
1452            assert!(
1453                (yi - 5.0).abs() < 0.1,
1454                "Tricube NW: constant data should remain constant"
1455            );
1456        }
1457    }
1458
1459    #[test]
1460    fn test_nw_tricube_linear_data() {
1461        let x = uniform_grid(50);
1462        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1463
1464        let y_smooth = nadaraya_watson(&x, &y, &x, 0.2, "tricube").unwrap();
1465
1466        // Interior points should be approximately correct
1467        for i in 10..40 {
1468            let expected = 2.0 * x[i] + 1.0;
1469            assert!(
1470                (y_smooth[i] - expected).abs() < 0.3,
1471                "Tricube NW: linear trend should be approximately preserved at i={i}"
1472            );
1473        }
1474    }
1475
1476    #[test]
1477    fn test_nw_tricube_vs_gaussian() {
1478        let x = uniform_grid(30);
1479        let y: Vec<f64> = x
1480            .iter()
1481            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
1482            .collect();
1483
1484        let y_gauss = nadaraya_watson(&x, &y, &x, 0.15, "gaussian").unwrap();
1485        let y_tri = nadaraya_watson(&x, &y, &x, 0.15, "tricube").unwrap();
1486
1487        assert_eq!(y_gauss.len(), y_tri.len());
1488
1489        // Both should produce valid output
1490        assert!(y_tri.iter().all(|v| v.is_finite()));
1491
1492        // They should be different
1493        let diff: f64 = y_gauss.iter().zip(&y_tri).map(|(a, b)| (a - b).abs()).sum();
1494        assert!(
1495            diff > 0.0,
1496            "Gaussian and tricube kernels should give different results"
1497        );
1498    }
1499
1500    #[test]
1501    fn test_nw_tricube_vs_epanechnikov() {
1502        let x = uniform_grid(30);
1503        let y: Vec<f64> = x
1504            .iter()
1505            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
1506            .collect();
1507
1508        let y_epan = nadaraya_watson(&x, &y, &x, 0.15, "epanechnikov").unwrap();
1509        let y_tri = nadaraya_watson(&x, &y, &x, 0.15, "tricube").unwrap();
1510
1511        // Both compact support kernels should produce finite output
1512        assert!(y_epan.iter().all(|v| v.is_finite()));
1513        assert!(y_tri.iter().all(|v| v.is_finite()));
1514
1515        // Should differ since kernel shapes are different
1516        let diff: f64 = y_epan.iter().zip(&y_tri).map(|(a, b)| (a - b).abs()).sum();
1517        assert!(
1518            diff > 0.0,
1519            "Epanechnikov and tricube should give different results"
1520        );
1521    }
1522
1523    #[test]
1524    fn test_ll_tricube_constant_data() {
1525        let x = uniform_grid(20);
1526        let y = vec![3.0; 20];
1527
1528        let y_smooth = local_linear(&x, &y, &x, 0.2, "tricube").unwrap();
1529
1530        for &yi in &y_smooth {
1531            assert!(
1532                (yi - 3.0).abs() < 0.1,
1533                "Tricube LL: constant should remain constant"
1534            );
1535        }
1536    }
1537
1538    #[test]
1539    fn test_ll_tricube_linear_data() {
1540        let x = uniform_grid(30);
1541        let y: Vec<f64> = x.iter().map(|&xi| 3.0 * xi + 2.0).collect();
1542
1543        let y_smooth = local_linear(&x, &y, &x, 0.2, "tricube").unwrap();
1544
1545        // Local linear should fit linear data well in the interior
1546        for i in 5..25 {
1547            let expected = 3.0 * x[i] + 2.0;
1548            assert!(
1549                (y_smooth[i] - expected).abs() < 0.2,
1550                "Tricube LL: should fit linear data well at i={i}"
1551            );
1552        }
1553    }
1554
1555    #[test]
1556    fn test_ll_tricube_vs_gaussian() {
1557        let x = uniform_grid(30);
1558        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1559
1560        let y_gauss = local_linear(&x, &y, &x, 0.15, "gaussian").unwrap();
1561        let y_tri = local_linear(&x, &y, &x, 0.15, "tricube").unwrap();
1562
1563        assert_eq!(y_gauss.len(), y_tri.len());
1564        assert!(y_tri.iter().all(|v| v.is_finite()));
1565
1566        let diff: f64 = y_gauss.iter().zip(&y_tri).map(|(a, b)| (a - b).abs()).sum();
1567        assert!(
1568            diff > 0.0,
1569            "Gaussian and tricube local linear should differ"
1570        );
1571    }
1572
1573    #[test]
1574    fn test_lp_tricube_quadratic() {
1575        let x = uniform_grid(40);
1576        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1577
1578        let y_smooth = local_polynomial(&x, &y, &x, 0.15, 2, "tricube").unwrap();
1579
1580        // Local quadratic with tricube should fit well in interior
1581        for i in 8..32 {
1582            let expected = x[i] * x[i];
1583            assert!(
1584                (y_smooth[i] - expected).abs() < 0.15,
1585                "Tricube LP: should fit quadratic data at i={i}"
1586            );
1587        }
1588    }
1589
1590    #[test]
1591    fn test_get_kernel_tricube_aliases() {
1592        // "tricube" and "tri-cube" should both resolve to the tricube kernel
1593        let k1 = get_kernel("tricube");
1594        let k2 = get_kernel("tri-cube");
1595
1596        let test_val = 0.5;
1597        assert!(
1598            (k1(test_val) - k2(test_val)).abs() < 1e-15,
1599            "Both tricube aliases should give the same result"
1600        );
1601    }
1602
1603    #[test]
1604    fn test_smoothing_matrix_tricube() {
1605        let x = uniform_grid(10);
1606        let s = smoothing_matrix_nw(&x, 0.2, "tricube").unwrap();
1607
1608        assert_eq!(s.len(), 100);
1609
1610        // Each row should sum to 1 (row stochastic)
1611        for i in 0..10 {
1612            let row_sum: f64 = (0..10).map(|j| s[i + j * 10]).sum();
1613            assert!(
1614                (row_sum - 1.0).abs() < 1e-10,
1615                "Tricube: row {} should sum to 1, got {}",
1616                i,
1617                row_sum
1618            );
1619        }
1620    }
1621
1622    #[test]
1623    fn test_cv_smoother_tricube() {
1624        let x = uniform_grid(30);
1625        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1626        let cv = cv_smoother(&x, &y, 0.2, "tricube");
1627        assert!(cv.is_finite());
1628        assert!(cv >= 0.0);
1629    }
1630
1631    #[test]
1632    fn test_optim_bandwidth_tricube() {
1633        let x = uniform_grid(25);
1634        let y: Vec<f64> = x
1635            .iter()
1636            .map(|&xi| (2.0 * std::f64::consts::PI * xi).sin())
1637            .collect();
1638
1639        let result = optim_bandwidth(&x, &y, None, CvCriterion::Gcv, "tricube", 20);
1640        assert!(result.h_opt > 0.0);
1641        assert!(result.value.is_finite());
1642    }
1643}