use crate::error::{
RillError, checked_finite_add, checked_increment, ensure_finite, ensure_finite_target,
validate_features,
};
use crate::loss::RegressionLoss;
use crate::optim::Optimizer;
use crate::traits::OnlineRegressor;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LinearRegressionConfig {
pub optimizer: Optimizer,
pub loss: RegressionLoss,
}
impl Default for LinearRegressionConfig {
fn default() -> Self {
Self {
optimizer: Optimizer::sgd(1, Default::default()).expect("default optimizer"),
loss: RegressionLoss::default(),
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LinearRegression {
feature_count: usize,
weights: Vec<f64>,
intercept: f64,
optimizer: Optimizer,
loss: RegressionLoss,
samples_seen: u64,
}
impl LinearRegression {
pub fn new(feature_count: usize, config: LinearRegressionConfig) -> Result<Self, RillError> {
if feature_count == 0 {
return Err(RillError::EmptyFeatures);
}
if config.optimizer.param_count() != feature_count + 1 {
return Err(RillError::DimensionMismatch {
expected: feature_count + 1,
actual: config.optimizer.param_count(),
});
}
Ok(Self {
feature_count,
weights: vec![0.0; feature_count],
intercept: 0.0,
optimizer: config.optimizer,
loss: config.loss,
samples_seen: 0,
})
}
pub fn weights(&self) -> &[f64] {
&self.weights
}
pub const fn intercept(&self) -> f64 {
self.intercept
}
fn predict_inner(&self, features: &[f64]) -> Result<f64, RillError> {
validate_features(self.feature_count, features)?;
let dot = self.weights.iter().zip(features.iter()).try_fold(
0.0,
|sum, (&weight, &feature)| {
let term = weight * feature;
ensure_finite("linear prediction term", term)?;
checked_finite_add(sum, term, "linear prediction")
},
)?;
checked_finite_add(dot, self.intercept, "linear prediction")
}
}
impl OnlineRegressor for LinearRegression {
fn feature_count(&self) -> usize {
self.feature_count
}
fn samples_seen(&self) -> u64 {
self.samples_seen
}
fn predict(&self, features: &[f64]) -> Result<f64, RillError> {
self.predict_inner(features)
}
fn learn(&mut self, features: &[f64], target: f64) -> Result<(), RillError> {
validate_features(self.feature_count, features)?;
ensure_finite_target(target)?;
let next_samples = checked_increment(self.samples_seen, "linear regression sample")?;
let prediction = self.predict_inner(features)?;
let grad = self.loss.gradient(prediction, target);
ensure_finite("loss gradient", grad)?;
let grad_weights = features
.iter()
.map(|&feature| {
let gradient = grad * feature;
ensure_finite("weight gradient", gradient)?;
Ok(gradient)
})
.collect::<Result<Vec<_>, RillError>>()?;
let grad_intercept = grad;
self.optimizer.step(
&mut self.weights,
&mut self.intercept,
&grad_weights,
grad_intercept,
)?;
self.samples_seen = next_samples;
Ok(())
}
fn reset(&mut self) {
for w in &mut self.weights {
*w = 0.0;
}
self.intercept = 0.0;
self.optimizer.reset();
self.samples_seen = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::optim::{AdaGradConfig, SgdConfig};
use rand::SeedableRng;
fn make_sgd(lr: f64, l2: f64, d: usize) -> Optimizer {
Optimizer::sgd(
d,
SgdConfig {
learning_rate: lr,
l2,
},
)
.unwrap()
}
#[test]
fn predict_cold_start_returns_intercept() {
let model = LinearRegression::new(
2,
LinearRegressionConfig {
optimizer: make_sgd(0.1, 0.0, 2),
loss: RegressionLoss::default(),
},
)
.unwrap();
assert_eq!(model.predict(&[1.0, 2.0]).unwrap(), 0.0);
}
#[test]
fn learn_reduces_loss_on_linear_data() {
let mut model = LinearRegression::new(
2,
LinearRegressionConfig {
optimizer: make_sgd(0.05, 0.0, 2),
loss: RegressionLoss::default(),
},
)
.unwrap();
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(7);
let mut first_loss = 0.0;
let mut last_loss = 0.0;
for i in 0..500 {
let x1 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
let x2 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
let y = 2.0 * x1 - 0.5 * x2 + 1.0;
let pred = model.predict(&[x1, x2]).unwrap();
let l = crate::loss::SquaredError::loss(pred, y);
if i < 10 {
first_loss += l;
}
if i >= 490 {
last_loss += l;
}
model.learn(&[x1, x2], y).unwrap();
}
assert!(last_loss < first_loss, "loss should decrease");
assert!((model.weights()[0] - 2.0).abs() < 0.3);
assert!((model.weights()[1] + 0.5).abs() < 0.3);
assert!((model.intercept() - 1.0).abs() < 0.3);
}
#[test]
fn predict_does_not_update_state() {
let model = LinearRegression::new(
1,
LinearRegressionConfig {
optimizer: make_sgd(0.1, 0.0, 1),
loss: RegressionLoss::default(),
},
)
.unwrap();
let _ = model.predict(&[1.0]).unwrap();
assert_eq!(model.samples_seen(), 0);
}
#[test]
fn dimension_mismatch_rejected() {
let mut model = LinearRegression::new(
3,
LinearRegressionConfig {
optimizer: make_sgd(0.1, 0.0, 3),
loss: RegressionLoss::default(),
},
)
.unwrap();
assert!(model.predict(&[1.0, 2.0]).is_err());
assert!(model.learn(&[1.0, 2.0], 1.0).is_err());
}
#[test]
fn optimizer_feature_count_mismatch_rejected() {
let config = LinearRegressionConfig {
optimizer: make_sgd(0.1, 0.0, 3),
loss: RegressionLoss::default(),
};
assert!(LinearRegression::new(2, config).is_err());
}
#[test]
fn adagrad_works() {
let mut model = LinearRegression::new(
1,
LinearRegressionConfig {
optimizer: Optimizer::adagrad(
1,
AdaGradConfig {
learning_rate: 0.5,
l2: 0.0,
epsilon: 1e-8,
},
)
.unwrap(),
loss: RegressionLoss::default(),
},
)
.unwrap();
for _ in 0..200 {
model.learn(&[1.0], 5.0).unwrap();
}
assert!((model.predict(&[1.0]).unwrap() - 5.0).abs() < 0.5);
}
#[test]
fn reset_clears_state() {
let mut model = LinearRegression::new(
1,
LinearRegressionConfig {
optimizer: make_sgd(0.1, 0.0, 1),
loss: RegressionLoss::default(),
},
)
.unwrap();
model.learn(&[1.0], 5.0).unwrap();
model.reset();
assert_eq!(model.samples_seen(), 0);
assert_eq!(model.predict(&[1.0]).unwrap(), 0.0);
}
#[test]
fn non_finite_target_rejected() {
let mut model = LinearRegression::new(
1,
LinearRegressionConfig {
optimizer: make_sgd(0.1, 0.0, 1),
loss: RegressionLoss::default(),
},
)
.unwrap();
assert!(model.learn(&[1.0], f64::NAN).is_err());
}
#[test]
fn huber_loss_works() {
let mut model = LinearRegression::new(
1,
LinearRegressionConfig {
optimizer: make_sgd(0.1, 0.0, 1),
loss: RegressionLoss::Huber(crate::loss::HuberLoss::new(1.0).unwrap()),
},
)
.unwrap();
model.learn(&[1.0], 1.0).unwrap();
assert_eq!(model.samples_seen(), 1);
}
}