pub(crate) struct PSplineFit {
lo: f64,
hi: f64,
nseg: usize,
coef: Vec<f64>,
cov: Vec<Vec<f64>>,
edf: f64,
n: f64,
}
impl PSplineFit {
pub(crate) fn edf(&self) -> f64 {
self.edf
}
pub(crate) fn n(&self) -> f64 {
self.n
}
pub(crate) fn domain(&self) -> (f64, f64) {
(self.lo, self.hi)
}
pub(crate) fn predict(&self, x: f64) -> f64 {
let b = bspline_row(x, self.lo, self.hi, self.nseg);
b.iter().zip(&self.coef).map(|(a, c)| a * c).sum()
}
pub(crate) fn se(&self, x: f64) -> f64 {
let b = bspline_row(x, self.lo, self.hi, self.nseg);
let k = b.len();
let mut var = 0.0;
for i in 0..k {
if b[i] == 0.0 {
continue;
}
for j in 0..k {
var += b[i] * self.cov[i][j] * b[j];
}
}
var.max(0.0).sqrt()
}
}
pub(crate) fn fit(points: &[(f64, f64)]) -> Option<PSplineFit> {
let pts: Vec<(f64, f64)> = points
.iter()
.copied()
.filter(|(x, y)| x.is_finite() && y.is_finite())
.collect();
let (lo, hi) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |a, p| {
(a.0.min(p.0), a.1.max(p.0))
});
let mut distinct: Vec<f64> = pts.iter().map(|p| p.0).collect();
distinct.sort_by(f64::total_cmp);
distinct.dedup();
if distinct.len() < 4 || hi <= lo {
return None;
}
let nseg = (distinct.len() / 3).clamp(3, 20);
let k = nseg + 3; let basis = |x: f64| bspline_row(x, lo, hi, nseg);
let rows: Vec<Vec<f64>> = pts.iter().map(|p| basis(p.0)).collect();
let mut btb = vec![vec![0.0; k]; k];
let mut bty = vec![0.0; k];
for (r, p) in rows.iter().zip(&pts) {
for i in 0..k {
if r[i] == 0.0 {
continue;
}
bty[i] += r[i] * p.1;
for j in 0..k {
btb[i][j] += r[i] * r[j];
}
}
}
let mut dtd = vec![vec![0.0; k]; k];
for i in 0..k.saturating_sub(2) {
let d = [(i, 1.0), (i + 1, -2.0), (i + 2, 1.0)];
for &(a, va) in &d {
for &(b, vb) in &d {
dtd[a][b] += va * vb;
}
}
}
let n = pts.len() as f64;
let rss = |coef: &[f64]| -> f64 {
rows.iter()
.zip(&pts)
.map(|(r, p)| {
let f: f64 = r.iter().zip(coef).map(|(a, b)| a * b).sum();
(p.1 - f).powi(2)
})
.sum()
};
let mut best: Option<(f64, Vec<f64>, f64, f64, f64)> = None; for step in 0..=24 {
let lambda = 10f64.powf(-3.0 + step as f64 * 0.375); let a: Vec<Vec<f64>> = (0..k)
.map(|i| (0..k).map(|j| btb[i][j] + lambda * dtd[i][j]).collect())
.collect();
let Some(coef) = solve(&a, &bty) else {
continue;
};
let mut edf = 0.0;
for j in 0..k {
let col: Vec<f64> = (0..k).map(|i| btb[i][j]).collect();
if let Some(s) = solve(&a, &col) {
edf += s[j];
}
}
let denom = n - edf;
if denom <= 0.5 {
continue;
}
let r = rss(&coef);
let gcv = n * r / (denom * denom);
if gcv.is_finite() && best.as_ref().is_none_or(|b| gcv < b.0) {
best = Some((gcv, coef, edf, r, lambda));
}
}
let (_, coef, edf, rss_val, lambda) = best?;
let a: Vec<Vec<f64>> = (0..k)
.map(|i| (0..k).map(|j| btb[i][j] + lambda * dtd[i][j]).collect())
.collect();
let mut h = vec![vec![0.0; k]; k];
for j in 0..k {
let mut e = vec![0.0; k];
e[j] = 1.0;
let col = solve(&a, &e)?;
for i in 0..k {
h[i][j] = col[i];
}
}
let h_btb = matmul(&h, &btb, k);
let cov_unscaled = matmul(&h_btb, &h, k);
let sigma2 = rss_val / (n - edf).max(1.0);
let cov: Vec<Vec<f64>> = cov_unscaled
.iter()
.map(|row| row.iter().map(|v| v * sigma2).collect())
.collect();
Some(PSplineFit {
lo,
hi,
nseg,
coef,
cov,
edf,
n,
})
}
fn matmul(a: &[Vec<f64>], b: &[Vec<f64>], k: usize) -> Vec<Vec<f64>> {
let mut out = vec![vec![0.0; k]; k];
for i in 0..k {
for j in 0..k {
let mut s = 0.0;
for m in 0..k {
s += a[i][m] * b[m][j];
}
out[i][j] = s;
}
}
out
}
fn bspline_row(x: f64, lo: f64, hi: f64, nseg: usize) -> Vec<f64> {
let deg = 3;
let dx = (hi - lo) / nseg as f64;
let k = nseg + deg;
let knot = |j: usize| lo + (j as f64 - deg as f64) * dx;
let x = x.clamp(lo, hi - 1e-12 * (hi - lo).max(1.0));
let nk = nseg + 2 * deg + 1;
let mut b: Vec<f64> = (0..nk - 1)
.map(|j| {
if knot(j) <= x && x < knot(j + 1) {
1.0
} else {
0.0
}
})
.collect();
for d in 1..=deg {
for j in 0..nk - 1 - d {
let (t0, t1, t2) = (knot(j), knot(j + d), knot(j + d + 1));
let left = (x - t0) / (t1 - t0) * b[j];
let right = (knot(j + d + 1) - x) / (t2 - knot(j + 1)) * b[j + 1];
b[j] = left + right;
}
}
b.truncate(k);
b
}
fn solve(a: &[Vec<f64>], b: &[f64]) -> Option<Vec<f64>> {
let n = b.len();
let mut m: Vec<Vec<f64>> = a
.iter()
.zip(b)
.map(|(row, &v)| {
let mut r = row.clone();
r.push(v);
r
})
.collect();
for c in 0..n {
let p = (c..n).max_by(|&i, &j| m[i][c].abs().total_cmp(&m[j][c].abs()))?;
if m[p][c].abs() < 1e-12 {
return None;
}
m.swap(c, p);
let pivot = m[c].clone();
for row in m.iter_mut().skip(c + 1) {
let f = row[c] / pivot[c];
if f != 0.0 {
for (a, b) in row.iter_mut().zip(&pivot).skip(c) {
*a -= f * b;
}
}
}
}
let mut x = vec![0.0; n];
for r in (0..n).rev() {
let s: f64 = (r + 1..n).map(|j| m[r][j] * x[j]).sum();
x[r] = (m[r][n] - s) / m[r][r];
}
x.iter().all(|v| v.is_finite()).then_some(x)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basis_is_a_partition_of_unity() {
for x in [0.0, 0.3, 1.7, 4.99, 5.0] {
let s: f64 = bspline_row(x, 0.0, 5.0, 5).iter().sum();
assert!((s - 1.0).abs() < 1e-9, "x={x}: {s}");
}
}
#[test]
fn recovers_a_smooth_curve() {
let pts: Vec<(f64, f64)> = (0..200)
.map(|i| {
let x = i as f64 / 20.0;
let e = ((i * 7919) % 13) as f64 / 13.0 - 0.5;
(x, x.sin() + 0.1 * e)
})
.collect();
let f = fit(&pts).unwrap();
let err = (0..50)
.map(|i| {
let x = i as f64 / 49.0 * 9.95;
(f.predict(x) - x.sin()).abs()
})
.fold(0.0, f64::max);
assert!(err < 0.15, "max error {err}");
}
#[test]
fn too_few_points() {
assert!(fit(&[(0.0, 1.0), (1.0, 2.0), (2.0, 3.0)]).is_none());
assert!(fit(&[]).is_none());
}
#[test]
fn confidence_band_widens_outside_the_data_and_is_finite() {
let pts: Vec<(f64, f64)> = (0..60)
.map(|i| {
let x = i as f64 / 10.0;
let e = ((i * 37) % 7) as f64 / 7.0 - 0.5;
(x, x.sin() + 0.1 * e)
})
.collect();
let f = fit(&pts).unwrap();
let (lo, hi) = f.domain();
let mid = f.se((lo + hi) / 2.0);
let near_edge = f.se(lo + 0.01 * (hi - lo));
assert!(mid.is_finite() && mid >= 0.0);
assert!(near_edge.is_finite() && near_edge >= 0.0);
assert!(f.edf() > 0.0 && f.edf().is_finite());
assert!(f.n() == pts.len() as f64);
}
}