use crate::error::{Result, TemporalError};
use scirs2_core::linalg::solve_ndarray;
use scirs2_core::ndarray::{Array1, Array2};
#[derive(Debug, Clone)]
pub struct LoessOptions {
pub bandwidth_fraction: f64,
pub degree: u8,
pub robustness_iterations: usize,
pub weights: Option<Vec<f64>>,
}
impl Default for LoessOptions {
fn default() -> Self {
Self {
bandwidth_fraction: 2.0 / 3.0,
degree: 1,
robustness_iterations: 0,
weights: None,
}
}
}
impl LoessOptions {
#[must_use]
pub fn new(bandwidth_fraction: f64) -> Self {
Self {
bandwidth_fraction,
..Self::default()
}
}
#[must_use]
pub fn with_degree(mut self, degree: u8) -> Self {
self.degree = degree;
self
}
#[must_use]
pub fn with_robustness_iterations(mut self, iterations: usize) -> Self {
self.robustness_iterations = iterations;
self
}
#[must_use]
pub fn with_weights(mut self, weights: Vec<f64>) -> Self {
self.weights = Some(weights);
self
}
}
pub fn loess_smooth_1d(x: &[f64], y: &[f64], options: &LoessOptions) -> Result<Vec<f64>> {
if x.len() != y.len() {
return Err(TemporalError::invalid_parameter(
"x/y",
format!(
"length mismatch: x has {} elements, y has {}",
x.len(),
y.len()
),
));
}
if let Some(w) = &options.weights
&& w.len() != y.len()
{
return Err(TemporalError::invalid_parameter(
"weights",
format!(
"length mismatch: weights have {} elements, y has {}",
w.len(),
y.len()
),
));
}
let n = y.len();
if n < 2 {
return Err(TemporalError::insufficient_data(
"Loess requires at least two points",
));
}
let frac = options.bandwidth_fraction.clamp(0.0, 1.0);
let k = ((frac * n as f64).ceil() as usize).clamp(1, n);
let mut robustness = vec![1.0_f64; n];
let mut fitted = vec![0.0_f64; n];
for iter in 0..=options.robustness_iterations {
for (i, &target) in x.iter().enumerate() {
fitted[i] = fit_at_target(
x,
y,
target,
k,
&options.weights,
&robustness,
options.degree,
);
}
if iter < options.robustness_iterations {
let residuals: Vec<f64> = y
.iter()
.zip(fitted.iter())
.map(|(yi, fi)| yi - fi)
.collect();
let mut abs_residuals: Vec<f64> = residuals.iter().map(|r| r.abs()).collect();
abs_residuals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = if abs_residuals.is_empty() {
0.0
} else {
abs_residuals[abs_residuals.len() / 2]
};
let scale = 6.0 * median;
for (idx, r) in residuals.iter().enumerate() {
robustness[idx] = if scale <= 0.0 {
1.0
} else {
let u = r / scale;
if u.abs() >= 1.0 {
0.0
} else {
let v = 1.0 - u * u;
(v * v).clamp(0.0, 1.0)
}
};
}
}
}
Ok(fitted)
}
#[must_use]
pub fn loess_smooth_indexed(values: &[f64], window: usize, degree: u8) -> Vec<f64> {
let n = values.len();
if n == 0 {
return Vec::new();
}
if n == 1 {
return values.to_vec();
}
let k = window.max(1).min(n);
let xs: Vec<f64> = (0..n).map(|i| i as f64).collect();
let weights = vec![1.0_f64; n];
let robustness = vec![1.0_f64; n];
let mut out = Vec::with_capacity(n);
for (i, &target) in xs.iter().enumerate() {
let _ = i;
out.push(fit_at_target(
&xs,
values,
target,
k,
&Some(weights.clone()),
&robustness,
degree,
));
}
out
}
fn fit_at_target(
x: &[f64],
y: &[f64],
target: f64,
k: usize,
weights: &Option<Vec<f64>>,
robustness: &[f64],
degree: u8,
) -> f64 {
let n = x.len();
let mut indices: Vec<usize> = (0..n).collect();
indices.sort_by(|&a, &b| {
let da = (x[a] - target).abs();
let db = (x[b] - target).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
});
let neighbour_count = k.min(n);
let neighbour_idx = &indices[..neighbour_count];
let mut max_dist = 0.0_f64;
for &i in neighbour_idx {
let d = (x[i] - target).abs();
if d > max_dist {
max_dist = d;
}
}
let bandwidth = if max_dist == 0.0 { 1.0 } else { max_dist };
let mut local_x = Vec::with_capacity(neighbour_count);
let mut local_y = Vec::with_capacity(neighbour_count);
let mut local_w = Vec::with_capacity(neighbour_count);
let mut total_weight = 0.0_f64;
for &i in neighbour_idx {
let tri = tricube_weight((x[i] - target).abs(), bandwidth);
let explicit = weights.as_ref().map(|ws| ws[i]).unwrap_or(1.0).max(0.0);
let robust = robustness[i].max(0.0);
let w = tri * explicit * robust;
local_x.push(x[i]);
local_y.push(y[i]);
local_w.push(w);
total_weight += w;
}
if total_weight <= 0.0 {
return local_y.iter().sum::<f64>() / local_y.len() as f64;
}
weighted_polynomial_fit_local(&local_x, &local_y, &local_w, degree, target)
}
#[must_use]
pub fn tricube_weight(distance: f64, bandwidth: f64) -> f64 {
if bandwidth <= 0.0 {
return if distance == 0.0 { 1.0 } else { 0.0 };
}
let u = (distance / bandwidth).abs();
if u >= 1.0 {
0.0
} else {
let cube = 1.0 - u * u * u;
let res = cube * cube * cube;
res.max(0.0)
}
}
#[must_use]
pub fn weighted_polynomial_fit_local(
xs: &[f64],
ys: &[f64],
weights: &[f64],
degree: u8,
target: f64,
) -> f64 {
let n = xs.len();
if n == 0 {
return 0.0;
}
let total_w: f64 = weights.iter().copied().filter(|w| *w > 0.0).sum();
if total_w <= 0.0 {
return ys.iter().sum::<f64>() / n as f64;
}
let weighted_mean: f64 = xs
.iter()
.zip(ys.iter())
.zip(weights.iter())
.map(|((_, y), w)| w * y)
.sum::<f64>()
/ total_w;
if degree == 0 {
return weighted_mean;
}
let dx: Vec<f64> = xs.iter().map(|x| x - target).collect();
let active_count = weights.iter().filter(|w| **w > 0.0).count();
if active_count <= degree as usize {
return weighted_mean;
}
let first_active = dx
.iter()
.zip(weights.iter())
.find(|(_, w)| **w > 0.0)
.map(|(d, _)| *d)
.unwrap_or(0.0);
let all_same = dx
.iter()
.zip(weights.iter())
.filter(|(_, w)| **w > 0.0)
.all(|(d, _)| (*d - first_active).abs() < f64::EPSILON);
if all_same {
return weighted_mean;
}
let p = degree as usize + 1;
let mut xtwx = Array2::<f64>::zeros((p, p));
let mut xtwy = Array1::<f64>::zeros(p);
for ((d, y), w) in dx.iter().zip(ys.iter()).zip(weights.iter()) {
if *w <= 0.0 {
continue;
}
let mut powers = Vec::with_capacity(p);
let mut acc = 1.0_f64;
for _ in 0..p {
powers.push(acc);
acc *= *d;
}
for r in 0..p {
for c in 0..p {
xtwx[[r, c]] += w * powers[r] * powers[c];
}
xtwy[r] += w * powers[r] * y;
}
}
match solve_ndarray(&xtwx, &xtwy) {
Ok(beta) => beta[0],
Err(_) => weighted_mean,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tricube_basic() {
assert!((tricube_weight(0.0, 1.0) - 1.0).abs() < 1e-12);
assert!((tricube_weight(1.0, 1.0)).abs() < 1e-12);
assert!(tricube_weight(2.0, 1.0) == 0.0);
assert!(tricube_weight(0.5, 1.0) > 0.0);
}
#[test]
fn weighted_polynomial_fit_local_constant() {
let xs = vec![0.0, 1.0, 2.0, 3.0];
let ys = vec![5.0; 4];
let ws = vec![1.0; 4];
let v = weighted_polynomial_fit_local(&xs, &ys, &ws, 1, 1.5);
assert!((v - 5.0).abs() < 1e-12);
}
#[test]
fn weighted_polynomial_fit_local_linear() {
let xs = vec![0.0, 1.0, 2.0, 3.0];
let ys = vec![1.0, 3.0, 5.0, 7.0];
let ws = vec![1.0; 4];
let v = weighted_polynomial_fit_local(&xs, &ys, &ws, 1, 1.5);
assert!((v - 4.0).abs() < 1e-9);
}
}