use std::fmt;
use crate::learner::StreamingLearner;
#[inline]
fn dot(a: &[f64], b: &[f64]) -> f64 {
irithyll_core::simd::simd_dot(a, b)
}
#[inline]
fn mat_vec(mat: &[f64], v: &[f64], n: usize) -> Vec<f64> {
debug_assert_eq!(mat.len(), n * n);
debug_assert_eq!(v.len(), n);
let mut result = vec![0.0; n];
for (i, res) in result.iter_mut().enumerate() {
let row_start = i * n;
let mut sum = 0.0;
for (j, &vj) in v.iter().enumerate() {
sum += mat[row_start + j] * vj;
}
*res = sum;
}
result
}
#[inline]
fn outer_subtract_scaled(p: &mut [f64], k: &[f64], px: &[f64], lambda: f64, n: usize) {
debug_assert_eq!(p.len(), n * n);
debug_assert_eq!(k.len(), n);
debug_assert_eq!(px.len(), n);
let inv_lambda = 1.0 / lambda;
for (i, &ki) in k.iter().enumerate() {
let row_start = i * n;
for (j, &pxj) in px.iter().enumerate() {
p[row_start + j] = (p[row_start + j] - ki * pxj) * inv_lambda;
}
}
}
#[derive(Clone)]
pub struct RecursiveLeastSquares {
weights: Vec<f64>,
p_matrix: Vec<f64>,
forgetting_factor: f64,
delta: f64,
n_features: Option<usize>,
samples_seen: u64,
running_mse: f64,
adaptive_forgetting: bool,
baseline_error: f64,
effective_forgetting_factor: f64,
}
impl RecursiveLeastSquares {
pub fn new(forgetting_factor: f64) -> Self {
Self::with_delta(forgetting_factor, 100.0)
}
pub fn with_delta(forgetting_factor: f64, delta: f64) -> Self {
Self {
weights: Vec::new(),
p_matrix: Vec::new(),
forgetting_factor,
delta,
n_features: None,
samples_seen: 0,
running_mse: 0.0,
adaptive_forgetting: true,
baseline_error: 0.0,
effective_forgetting_factor: forgetting_factor,
}
}
pub fn with_fixed_forgetting(forgetting_factor: f64, delta: f64) -> Self {
Self {
weights: Vec::new(),
p_matrix: Vec::new(),
forgetting_factor,
delta,
n_features: None,
samples_seen: 0,
running_mse: 0.0,
adaptive_forgetting: false,
baseline_error: 0.0,
effective_forgetting_factor: forgetting_factor,
}
}
#[inline]
pub fn weights(&self) -> &[f64] {
&self.weights
}
#[inline]
pub fn forgetting_factor(&self) -> f64 {
self.forgetting_factor
}
#[inline]
pub fn effective_forgetting_factor(&self) -> f64 {
self.effective_forgetting_factor
}
#[inline]
pub fn set_forgetting_factor(&mut self, ff: f64) {
self.forgetting_factor = ff.clamp(0.95, 1.0);
}
#[inline]
pub fn p_matrix(&self) -> &[f64] {
&self.p_matrix
}
#[inline]
pub fn delta(&self) -> f64 {
self.delta
}
#[inline]
pub fn n_features(&self) -> Option<usize> {
self.n_features
}
fn init(&mut self, d: usize) {
self.n_features = Some(d);
self.weights = vec![0.0; d];
self.p_matrix = vec![0.0; d * d];
for i in 0..d {
self.p_matrix[i * d + i] = self.delta;
}
}
fn quadratic_form_p(&self, features: &[f64]) -> f64 {
let d = self.weights.len();
let mut px = vec![0.0; d];
for (i, px_i) in px.iter_mut().enumerate() {
let row_start = i * d;
for (j, &fj) in features.iter().enumerate() {
*px_i += self.p_matrix[row_start + j] * fj;
}
}
features.iter().zip(px.iter()).map(|(xi, pi)| xi * pi).sum()
}
pub fn prediction_variance(&self, features: &[f64]) -> f64 {
if self.weights.is_empty() {
return f64::INFINITY;
}
let sigma2 = self.noise_variance();
let x_p_x = self.quadratic_form_p(features);
sigma2 * (1.0 + x_p_x)
}
pub fn prediction_std(&self, features: &[f64]) -> f64 {
self.prediction_variance(features).sqrt()
}
pub fn predict_interval(&self, features: &[f64], z: f64) -> (f64, f64, f64) {
let mean = self.predict(features);
let std = self.prediction_std(features);
(mean, mean - z * std, mean + z * std)
}
pub fn noise_variance(&self) -> f64 {
self.running_mse
}
fn reset_covariance(&mut self) {
if let Some(d) = self.n_features {
self.p_matrix.fill(0.0);
for i in 0..d {
self.p_matrix[i * d + i] = self.delta;
}
self.weights.fill(0.0);
self.running_mse = 0.0;
self.baseline_error = 0.0;
}
}
fn check_covariance_health(&mut self) -> bool {
if let Some(d) = self.n_features {
let mut max_diag: f64 = 0.0;
let mut has_nan = false;
for i in 0..d {
let diag = self.p_matrix[i * d + i];
if diag.is_nan() || !diag.is_finite() {
has_nan = true;
break;
}
if diag.abs() > max_diag {
max_diag = diag.abs();
}
if diag < 0.0 {
has_nan = true;
break;
}
}
if !has_nan {
has_nan = self.weights.iter().any(|w| !w.is_finite());
}
if has_nan || max_diag > 1e10 {
self.reset_covariance();
return true;
}
}
false
}
}
impl StreamingLearner for RecursiveLeastSquares {
fn train_one(&mut self, features: &[f64], target: f64, weight: f64) {
let d = features.len();
if self.n_features.is_none() {
self.init(d);
}
let n = self.n_features.unwrap();
debug_assert_eq!(d, n);
let residual = target - self.predict(features);
self.running_mse = 0.99 * self.running_mse + 0.01 * residual * residual;
let effective_ff = if self.adaptive_forgetting {
let abs_error = residual.abs();
self.baseline_error = 0.99 * self.baseline_error + 0.01 * abs_error;
let ratio = if self.baseline_error > 1e-15 {
(abs_error / self.baseline_error).clamp(0.1, 10.0)
} else {
1.0
};
let adjustment = 1.0 - 0.001 * (ratio - 1.0);
let adaptive = self.forgetting_factor * adjustment.clamp(0.99, 1.001);
adaptive.clamp(0.95, 1.0) } else {
self.forgetting_factor
};
self.effective_forgetting_factor = effective_ff;
let prediction = dot(&self.weights, features);
let alpha = (target - prediction) * weight.sqrt();
let px = mat_vec(&self.p_matrix, features, n);
let denom = (effective_ff + dot(features, &px)).max(1e-8);
let mut k = vec![0.0; n];
let inv_denom = 1.0 / denom;
for (ki, &pxi) in k.iter_mut().zip(px.iter()) {
*ki = pxi * inv_denom;
}
for (wi, &ki) in self.weights.iter_mut().zip(k.iter()) {
*wi += alpha * ki;
}
outer_subtract_scaled(&mut self.p_matrix, &k, &px, effective_ff, n);
self.check_covariance_health();
self.samples_seen += 1;
}
#[inline]
fn predict(&self, features: &[f64]) -> f64 {
if self.weights.is_empty() {
return 0.0;
}
dot(&self.weights, features)
}
#[inline]
fn n_samples_seen(&self) -> u64 {
self.samples_seen
}
fn reset(&mut self) {
self.weights.clear();
self.p_matrix.clear();
self.n_features = None;
self.samples_seen = 0;
self.running_mse = 0.0;
self.baseline_error = 0.0;
self.effective_forgetting_factor = self.forgetting_factor;
}
#[allow(deprecated)]
fn diagnostics_array(&self) -> [f64; 5] {
<Self as crate::learner::Tunable>::diagnostics_array(self)
}
#[allow(deprecated)]
fn adjust_config(&mut self, lr_multiplier: f64, lambda_delta: f64) {
<Self as crate::learner::Tunable>::adjust_config(self, lr_multiplier, lambda_delta);
}
#[allow(deprecated)]
fn readout_weights(&self) -> Option<&[f64]> {
let w = <Self as crate::learner::HasReadout>::readout_weights(self);
if w.is_empty() {
None
} else {
Some(w)
}
}
}
impl crate::learner::Tunable for RecursiveLeastSquares {
#[inline]
fn diagnostics_array(&self) -> [f64; 5] {
let d = self.weights.len();
let depth_sufficiency = if d > 0 && self.delta > 0.0 {
let trace: f64 = (0..d).map(|i| self.p_matrix[i * d + i]).sum();
(1.0 - trace / (self.delta * d as f64)).clamp(0.0, 1.0)
} else {
0.0
};
let effective_dof = if !self.weights.is_empty() {
let sq_sum: f64 = self.weights.iter().map(|w| w * w).sum();
sq_sum.sqrt() / (d as f64).sqrt()
} else {
0.0
};
[
0.0,
1.0 - self.forgetting_factor,
depth_sufficiency,
effective_dof,
self.running_mse.sqrt(),
]
}
#[inline]
fn adjust_config(&mut self, lr_multiplier: f64, _lambda_delta: f64) {
self.forgetting_factor = (self.forgetting_factor * lr_multiplier).clamp(1e-6, 1.0);
}
}
impl crate::learner::HasReadout for RecursiveLeastSquares {
#[inline]
fn readout_weights(&self) -> &[f64] {
&self.weights
}
}
impl fmt::Debug for RecursiveLeastSquares {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RecursiveLeastSquares")
.field("n_features", &self.n_features)
.field("forgetting_factor", &self.forgetting_factor)
.field(
"effective_forgetting_factor",
&self.effective_forgetting_factor,
)
.field("adaptive_forgetting", &self.adaptive_forgetting)
.field("delta", &self.delta)
.field("samples_seen", &self.samples_seen)
.field("running_mse", &self.running_mse)
.field("baseline_error", &self.baseline_error)
.field("weights", &self.weights)
.finish()
}
}
#[derive(Clone)]
pub struct StreamingPolynomialRegression {
rls: RecursiveLeastSquares,
degree: usize,
samples_seen: u64,
}
impl StreamingPolynomialRegression {
pub fn new(degree: usize, forgetting_factor: f64) -> Self {
assert!(degree >= 1, "polynomial degree must be >= 1, got {degree}");
Self {
rls: RecursiveLeastSquares::new(forgetting_factor),
degree,
samples_seen: 0,
}
}
#[inline]
pub fn degree(&self) -> usize {
self.degree
}
fn expand_features(&self, features: &[f64]) -> Vec<f64> {
let d = features.len();
let mut expanded = Vec::new();
expanded.extend_from_slice(features);
for deg in 2..=self.degree {
Self::enumerate_monomials(features, d, deg, &mut expanded);
}
expanded
}
fn enumerate_monomials(features: &[f64], d: usize, degree: usize, out: &mut Vec<f64>) {
let mut indices = vec![0usize; degree];
loop {
let mut val = 1.0;
for &idx in &indices {
val *= features[idx];
}
out.push(val);
let mut pos = degree - 1;
loop {
indices[pos] += 1;
if indices[pos] < d {
let v = indices[pos];
for idx in indices.iter_mut().take(degree).skip(pos + 1) {
*idx = v;
}
break;
}
if pos == 0 {
return; }
pos -= 1;
}
}
}
}
impl StreamingLearner for StreamingPolynomialRegression {
fn train_one(&mut self, features: &[f64], target: f64, weight: f64) {
let expanded = self.expand_features(features);
self.rls.train_one(&expanded, target, weight);
self.samples_seen += 1;
}
fn predict(&self, features: &[f64]) -> f64 {
let expanded = self.expand_features(features);
self.rls.predict(&expanded)
}
#[inline]
fn n_samples_seen(&self) -> u64 {
self.samples_seen
}
fn reset(&mut self) {
self.rls.reset();
self.samples_seen = 0;
}
fn diagnostics_array(&self) -> [f64; 5] {
[0.0; 5]
}
}
impl fmt::Debug for StreamingPolynomialRegression {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StreamingPolynomialRegression")
.field("degree", &self.degree)
.field("samples_seen", &self.samples_seen)
.field("rls", &self.rls)
.finish()
}
}
#[derive(Clone)]
pub struct LocallyWeightedRegression {
buffer_features: Vec<Vec<f64>>,
buffer_targets: Vec<f64>,
buffer_weights: Vec<f64>,
capacity: usize,
head: usize,
len: usize,
bandwidth: f64,
samples_seen: u64,
}
impl LocallyWeightedRegression {
pub fn new(capacity: usize, bandwidth: f64) -> Self {
assert!(capacity > 0, "capacity must be > 0, got {capacity}");
assert!(bandwidth > 0.0, "bandwidth must be > 0.0, got {bandwidth}");
Self {
buffer_features: Vec::with_capacity(capacity),
buffer_targets: Vec::with_capacity(capacity),
buffer_weights: Vec::with_capacity(capacity),
capacity,
head: 0,
len: 0,
bandwidth,
samples_seen: 0,
}
}
#[inline]
pub fn capacity(&self) -> usize {
self.capacity
}
#[inline]
pub fn bandwidth(&self) -> f64 {
self.bandwidth
}
#[inline]
pub fn buffer_len(&self) -> usize {
self.len
}
#[inline]
fn sq_dist(a: &[f64], b: &[f64]) -> f64 {
debug_assert_eq!(a.len(), b.len());
let mut sum = 0.0;
for i in 0..a.len() {
let d = a[i] - b[i];
sum += d * d;
}
sum
}
}
impl StreamingLearner for LocallyWeightedRegression {
fn train_one(&mut self, features: &[f64], target: f64, weight: f64) {
if self.len < self.capacity {
self.buffer_features.push(features.to_vec());
self.buffer_targets.push(target);
self.buffer_weights.push(weight);
self.len += 1;
self.head = self.len % self.capacity;
} else {
self.buffer_features[self.head] = features.to_vec();
self.buffer_targets[self.head] = target;
self.buffer_weights[self.head] = weight;
self.head = (self.head + 1) % self.capacity;
}
self.samples_seen += 1;
}
fn predict(&self, features: &[f64]) -> f64 {
if self.len == 0 {
return 0.0;
}
let two_bw_sq = 2.0 * self.bandwidth * self.bandwidth;
let mut weighted_sum = 0.0;
let mut weight_total = 0.0;
for i in 0..self.len {
let sq_d = Self::sq_dist(features, &self.buffer_features[i]);
let kernel_w = (-sq_d / two_bw_sq).exp();
let w = kernel_w * self.buffer_weights[i];
weighted_sum += w * self.buffer_targets[i];
weight_total += w;
}
if weight_total.abs() < 1e-15 {
return 0.0;
}
weighted_sum / weight_total
}
#[inline]
fn n_samples_seen(&self) -> u64 {
self.samples_seen
}
fn reset(&mut self) {
self.buffer_features.clear();
self.buffer_targets.clear();
self.buffer_weights.clear();
self.head = 0;
self.len = 0;
self.samples_seen = 0;
}
fn diagnostics_array(&self) -> [f64; 5] {
[0.0; 5]
}
}
impl fmt::Debug for LocallyWeightedRegression {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LocallyWeightedRegression")
.field("capacity", &self.capacity)
.field("len", &self.len)
.field("bandwidth", &self.bandwidth)
.field("samples_seen", &self.samples_seen)
.finish()
}
}
impl crate::automl::DiagnosticSource for RecursiveLeastSquares {
fn config_diagnostics(&self) -> Option<crate::automl::ConfigDiagnostics> {
let d = self.weights().len();
let depth_sufficiency = if d > 0 && self.delta > 0.0 {
let trace: f64 = (0..d).map(|i| self.p_matrix[i * d + i]).sum();
(1.0 - trace / (self.delta * d as f64)).clamp(0.0, 1.0)
} else {
0.0
};
let effective_dof = if d > 0 {
let sq_sum: f64 = self.weights.iter().map(|w| w * w).sum();
sq_sum.sqrt() / (d as f64).sqrt()
} else {
0.0
};
Some(crate::automl::ConfigDiagnostics {
depth_sufficiency,
effective_dof,
regularization_sensitivity: 1.0 - self.forgetting_factor(),
uncertainty: self.noise_variance().sqrt(),
..Default::default()
})
}
}
impl crate::automl::DiagnosticSource for StreamingPolynomialRegression {
fn config_diagnostics(&self) -> Option<crate::automl::ConfigDiagnostics> {
None
}
}
impl crate::automl::DiagnosticSource for LocallyWeightedRegression {
fn config_diagnostics(&self) -> Option<crate::automl::ConfigDiagnostics> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::learner::StreamingLearner;
#[test]
fn test_rls_creation() {
let rls = RecursiveLeastSquares::new(0.99);
assert_eq!(rls.n_samples_seen(), 0);
assert!(rls.weights().is_empty());
assert!((rls.forgetting_factor() - 0.99).abs() < 1e-15);
assert!((rls.predict(&[1.0, 2.0]) - 0.0).abs() < 1e-15);
}
#[test]
fn test_rls_simple_linear() {
let mut rls = RecursiveLeastSquares::new(1.0);
for i in 0..500 {
let x = i as f64 * 0.01;
let y = 2.0 * x + 1.0;
rls.train(&[x, 1.0], y);
}
assert_eq!(rls.n_samples_seen(), 500);
let w = rls.weights();
assert_eq!(w.len(), 2);
assert!(
(w[0] - 2.0).abs() < 0.05,
"expected w[0] ~ 2.0, got {}",
w[0]
);
assert!(
(w[1] - 1.0).abs() < 0.05,
"expected w[1] ~ 1.0, got {}",
w[1]
);
let pred = rls.predict(&[5.0, 1.0]);
assert!(
(pred - 11.0).abs() < 0.5,
"expected pred ~ 11.0, got {}",
pred
);
}
#[test]
fn test_rls_multivariate() {
let mut rls = RecursiveLeastSquares::new(1.0);
for i in 0..800 {
let x1 = (i as f64 * 0.037).sin();
let x2 = (i as f64 * 0.053).cos();
let y = x1 + 2.0 * x2;
rls.train(&[x1, x2], y);
}
let w = rls.weights();
assert!(
(w[0] - 1.0).abs() < 0.1,
"expected w[0] ~ 1.0, got {}",
w[0]
);
assert!(
(w[1] - 2.0).abs() < 0.1,
"expected w[1] ~ 2.0, got {}",
w[1]
);
}
#[test]
fn test_rls_forgetting() {
let mut rls = RecursiveLeastSquares::new(0.98);
for i in 0..500 {
let x = i as f64 * 0.01;
rls.train(&[x, 1.0], 1.0 * x + 0.0);
}
for i in 0..500 {
let x = i as f64 * 0.01;
rls.train(&[x, 1.0], 5.0 * x + 10.0);
}
let w = rls.weights();
assert!(
(w[0] - 5.0).abs() < 1.5,
"forgetting RLS should adapt slope toward 5.0, got {}",
w[0]
);
}
#[test]
fn test_rls_reset() {
let mut rls = RecursiveLeastSquares::new(1.0);
rls.train(&[1.0, 2.0], 5.0);
rls.train(&[3.0, 4.0], 7.0);
assert_eq!(rls.n_samples_seen(), 2);
assert!(!rls.weights().is_empty());
rls.reset();
assert_eq!(rls.n_samples_seen(), 0);
assert!(rls.weights().is_empty());
assert!((rls.predict(&[1.0, 2.0]) - 0.0).abs() < 1e-15);
}
#[test]
fn test_rls_trait_object() {
let rls = RecursiveLeastSquares::new(1.0);
let mut boxed: Box<dyn StreamingLearner> = Box::new(rls);
boxed.train(&[1.0, 1.0], 3.0);
boxed.train(&[2.0, 1.0], 5.0);
assert_eq!(boxed.n_samples_seen(), 2);
let pred = boxed.predict(&[1.0, 1.0]);
assert!(pred.is_finite());
boxed.reset();
assert_eq!(boxed.n_samples_seen(), 0);
}
#[test]
fn test_poly_quadratic() {
let mut poly = StreamingPolynomialRegression::new(2, 1.0);
for i in 0..600 {
let x = (i as f64 - 300.0) * 0.01;
poly.train(&[x], x * x);
}
let pred_at_2 = poly.predict(&[2.0]);
assert!(
(pred_at_2 - 4.0).abs() < 1.0,
"expected pred(2.0) ~ 4.0, got {}",
pred_at_2
);
let pred_at_neg1 = poly.predict(&[-1.0]);
assert!(
(pred_at_neg1 - 1.0).abs() < 1.0,
"expected pred(-1.0) ~ 1.0, got {}",
pred_at_neg1
);
}
#[test]
fn test_poly_expansion() {
let poly = StreamingPolynomialRegression::new(2, 1.0);
let expanded = poly.expand_features(&[3.0, 4.0]);
assert_eq!(
expanded.len(),
5,
"degree-2 expansion of 2 features should give 5"
);
assert!((expanded[0] - 3.0).abs() < 1e-12);
assert!((expanded[1] - 4.0).abs() < 1e-12);
assert!((expanded[2] - 9.0).abs() < 1e-12); assert!((expanded[3] - 12.0).abs() < 1e-12); assert!((expanded[4] - 16.0).abs() < 1e-12);
let poly3 = StreamingPolynomialRegression::new(3, 1.0);
let expanded3 = poly3.expand_features(&[2.0, 3.0]);
assert_eq!(
expanded3.len(),
9,
"degree-3 expansion of 2 features should give 9"
);
}
#[test]
fn test_poly_reset() {
let mut poly = StreamingPolynomialRegression::new(2, 1.0);
poly.train(&[1.0], 1.0);
poly.train(&[2.0], 4.0);
assert_eq!(poly.n_samples_seen(), 2);
poly.reset();
assert_eq!(poly.n_samples_seen(), 0);
assert!((poly.predict(&[1.0]) - 0.0).abs() < 1e-15);
}
#[test]
fn test_lwr_creation() {
let lwr = LocallyWeightedRegression::new(100, 0.5);
assert_eq!(lwr.n_samples_seen(), 0);
assert_eq!(lwr.capacity(), 100);
assert!((lwr.bandwidth() - 0.5).abs() < 1e-15);
assert_eq!(lwr.buffer_len(), 0);
assert!((lwr.predict(&[1.0]) - 0.0).abs() < 1e-15);
}
#[test]
fn test_lwr_constant() {
let mut lwr = LocallyWeightedRegression::new(200, 1.0);
for i in 0..200 {
let x = i as f64 * 0.1;
lwr.train(&[x], 42.0);
}
let pred = lwr.predict(&[5.0]);
assert!(
(pred - 42.0).abs() < 1e-6,
"constant target should predict ~42.0, got {}",
pred
);
}
#[test]
fn test_lwr_local() {
let mut lwr = LocallyWeightedRegression::new(200, 0.5);
for i in 0..100 {
let x = (i as f64 - 50.0) * 0.01;
lwr.train(&[x], 10.0);
}
for i in 0..100 {
let x = 10.0 + (i as f64 - 50.0) * 0.01;
lwr.train(&[x], 50.0);
}
let pred_a = lwr.predict(&[0.0]);
assert!(
(pred_a - 10.0).abs() < 5.0,
"prediction near cluster A should be ~10, got {}",
pred_a
);
let pred_b = lwr.predict(&[10.0]);
assert!(
(pred_b - 50.0).abs() < 5.0,
"prediction near cluster B should be ~50, got {}",
pred_b
);
}
#[test]
fn test_lwr_buffer_capacity() {
let mut lwr = LocallyWeightedRegression::new(5, 1.0);
for i in 0..8 {
lwr.train(&[i as f64], i as f64 * 10.0);
}
assert_eq!(lwr.n_samples_seen(), 8);
assert_eq!(lwr.buffer_len(), 5);
let pred = lwr.predict(&[7.0]);
assert!(
(pred - 70.0).abs() < 20.0,
"prediction at x=7 should be near 70, got {}",
pred
);
}
#[test]
fn test_lwr_reset() {
let mut lwr = LocallyWeightedRegression::new(100, 1.0);
for i in 0..50 {
lwr.train(&[i as f64], i as f64);
}
assert_eq!(lwr.n_samples_seen(), 50);
assert_eq!(lwr.buffer_len(), 50);
lwr.reset();
assert_eq!(lwr.n_samples_seen(), 0);
assert_eq!(lwr.buffer_len(), 0);
assert!((lwr.predict(&[1.0]) - 0.0).abs() < 1e-15);
}
#[test]
fn test_lwr_trait_object() {
let lwr = LocallyWeightedRegression::new(100, 1.0);
let mut boxed: Box<dyn StreamingLearner> = Box::new(lwr);
boxed.train(&[1.0], 10.0);
boxed.train(&[2.0], 20.0);
assert_eq!(boxed.n_samples_seen(), 2);
let pred = boxed.predict(&[1.5]);
assert!(pred.is_finite());
boxed.reset();
assert_eq!(boxed.n_samples_seen(), 0);
}
#[test]
fn confidence_intervals_narrow_with_data() {
let mut rls = RecursiveLeastSquares::new(0.01);
for i in 0..100 {
let x = i as f64 * 0.1;
rls.train(&[x], 2.0 * x + 1.0);
}
let var_100 = rls.prediction_variance(&[5.0]);
for i in 100..1000 {
let x = i as f64 * 0.1;
rls.train(&[x], 2.0 * x + 1.0);
}
let var_1000 = rls.prediction_variance(&[5.0]);
assert!(
var_1000 < var_100,
"variance should decrease with more data: {} vs {}",
var_1000,
var_100
);
}
#[test]
fn predict_interval_z_scaling() {
let mut rls = RecursiveLeastSquares::new(0.01);
for i in 0..200 {
let x = i as f64 * 0.05;
rls.train(&[x], x * x + 0.1); }
let (mean1, lo1, hi1) = rls.predict_interval(&[5.0], 1.0);
let (mean2, lo2, hi2) = rls.predict_interval(&[5.0], 2.0);
assert!((mean1 - mean2).abs() < 1e-12); let width1 = hi1 - lo1;
let width2 = hi2 - lo2;
assert!(
(width2 / width1 - 2.0).abs() < 0.01,
"width should scale with z: w1={}, w2={}",
width1,
width2
);
}
#[test]
fn noise_variance_reflects_residuals() {
let mut rls = RecursiveLeastSquares::new(0.01);
for i in 0..500 {
let x = i as f64 * 0.01;
rls.train(&[x], 3.0 * x);
}
let nv = rls.noise_variance();
assert!(
nv < 1.0,
"noise variance should be small for perfect data: {}",
nv
);
}
#[test]
fn prediction_bounds_are_finite() {
let mut rls = RecursiveLeastSquares::new(0.01);
rls.train(&[1.0], 2.0);
let (mean, lo, hi) = rls.predict_interval(&[1.0], 1.96);
assert!(mean.is_finite());
assert!(lo.is_finite());
assert!(hi.is_finite());
assert!(lo <= mean);
assert!(mean <= hi);
}
#[test]
fn adaptive_ff_stable_data_stays_near_base() {
let base_ff = 0.99;
let mut rls = RecursiveLeastSquares::new(base_ff);
for i in 0..500 {
let x = i as f64 * 0.01;
rls.train(&[x, 1.0], 2.0 * x + 1.0);
}
let eff = rls.effective_forgetting_factor();
let diff = (eff - base_ff).abs();
assert!(
diff < 0.005,
"stable data: effective_ff should stay near base {}, got {} (diff={})",
base_ff,
eff,
diff
);
}
#[test]
fn adaptive_ff_drops_on_sudden_shift() {
let base_ff = 0.99;
let mut rls = RecursiveLeastSquares::new(base_ff);
for i in 0..300 {
let x = i as f64 * 0.01;
rls.train(&[x, 1.0], x);
}
let ff_before_shift = rls.effective_forgetting_factor();
let mut min_ff_during_shift = base_ff;
for i in 0..50 {
let x = i as f64 * 0.01;
rls.train(&[x, 1.0], 10.0 * x + 50.0);
let eff = rls.effective_forgetting_factor();
if eff < min_ff_during_shift {
min_ff_during_shift = eff;
}
}
assert!(
min_ff_during_shift < ff_before_shift,
"effective_ff should drop during distribution shift: before={}, min_during={}",
ff_before_shift,
min_ff_during_shift
);
}
#[test]
fn adaptive_ff_recovers_after_shift() {
let base_ff = 0.99;
let mut rls = RecursiveLeastSquares::new(base_ff);
for i in 0..300 {
let x = i as f64 * 0.01;
rls.train(&[x, 1.0], x);
}
for i in 0..50 {
let x = i as f64 * 0.01;
rls.train(&[x, 1.0], 10.0 * x + 50.0);
}
let ff_after_shift = rls.effective_forgetting_factor();
for i in 0..500 {
let x = i as f64 * 0.01;
rls.train(&[x, 1.0], 10.0 * x + 50.0);
}
let ff_recovered = rls.effective_forgetting_factor();
let diff_from_base = (ff_recovered - base_ff).abs();
assert!(
ff_recovered > ff_after_shift || diff_from_base < 0.005,
"effective_ff should recover after shift stabilizes: after_shift={}, recovered={}, base={}",
ff_after_shift, ff_recovered, base_ff
);
}
#[test]
fn fixed_forgetting_does_not_adapt() {
let base_ff = 0.99;
let mut rls = RecursiveLeastSquares::with_fixed_forgetting(base_ff, 100.0);
for i in 0..200 {
let x = i as f64 * 0.01;
rls.train(&[x, 1.0], x);
}
assert!(
(rls.effective_forgetting_factor() - base_ff).abs() < 1e-15,
"fixed forgetting: effective_ff must equal base"
);
for i in 0..100 {
let x = i as f64 * 0.01;
rls.train(&[x, 1.0], 10.0 * x + 50.0);
}
assert!(
(rls.effective_forgetting_factor() - base_ff).abs() < 1e-15,
"fixed forgetting: effective_ff must equal base even after shift"
);
}
}