Skip to main content

rill_ml/models/
linear_regression.rs

1//! Online linear regression using SGD or AdaGrad.
2//!
3//! The model learns `y ≈ w·x + b` incrementally, one sample at a time.
4//! Prediction is side-effect free; learning computes the gradient of the
5//! configured loss and applies one optimizer step.
6
7use crate::error::{
8    RillError, checked_finite_add, checked_increment, ensure_finite, ensure_finite_target,
9    validate_features,
10};
11use crate::loss::RegressionLoss;
12use crate::optim::Optimizer;
13use crate::traits::OnlineRegressor;
14
15/// Configuration for [`LinearRegression`].
16#[derive(Debug, Clone)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub struct LinearRegressionConfig {
19    /// The optimizer to use (SGD or AdaGrad).
20    pub optimizer: Optimizer,
21    /// The loss function (SquaredError or Huber).
22    pub loss: RegressionLoss,
23}
24
25impl Default for LinearRegressionConfig {
26    fn default() -> Self {
27        Self {
28            optimizer: Optimizer::sgd(1, Default::default()).expect("default optimizer"),
29            loss: RegressionLoss::default(),
30        }
31    }
32}
33
34/// Online linear regression model.
35///
36/// # Examples
37///
38/// ```
39/// use rill_ml::{
40///     models::{LinearRegression, LinearRegressionConfig},
41///     optim::{Optimizer, SgdConfig},
42///     loss::RegressionLoss,
43///     OnlineRegressor,
44/// };
45///
46/// let feature_count = 2;
47/// let mut model = LinearRegression::new(
48///     feature_count,
49///     LinearRegressionConfig {
50///         optimizer: Optimizer::sgd(feature_count, SgdConfig {
51///             learning_rate: 0.1,
52///             l2: 0.0,
53///         }).unwrap(),
54///         loss: RegressionLoss::default(),
55///     },
56/// ).unwrap();
57///
58/// let prediction = model.predict(&[1.0, 2.0]).unwrap();
59/// model.learn(&[1.0, 2.0], 3.0).unwrap();
60/// ```
61#[derive(Debug, Clone)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub struct LinearRegression {
64    feature_count: usize,
65    weights: Vec<f64>,
66    intercept: f64,
67    optimizer: Optimizer,
68    loss: RegressionLoss,
69    samples_seen: u64,
70}
71
72impl LinearRegression {
73    /// Create a new linear regression model.
74    ///
75    /// The optimizer's feature count must match `feature_count`.
76    pub fn new(feature_count: usize, config: LinearRegressionConfig) -> Result<Self, RillError> {
77        if feature_count == 0 {
78            return Err(RillError::EmptyFeatures);
79        }
80        if config.optimizer.param_count() != feature_count + 1 {
81            return Err(RillError::DimensionMismatch {
82                expected: feature_count + 1,
83                actual: config.optimizer.param_count(),
84            });
85        }
86        Ok(Self {
87            feature_count,
88            weights: vec![0.0; feature_count],
89            intercept: 0.0,
90            optimizer: config.optimizer,
91            loss: config.loss,
92            samples_seen: 0,
93        })
94    }
95
96    /// The learned weights.
97    pub fn weights(&self) -> &[f64] {
98        &self.weights
99    }
100
101    /// The learned intercept (bias).
102    pub const fn intercept(&self) -> f64 {
103        self.intercept
104    }
105
106    /// Compute the prediction `w·x + b` without updating state.
107    fn predict_inner(&self, features: &[f64]) -> Result<f64, RillError> {
108        validate_features(self.feature_count, features)?;
109        let dot = self.weights.iter().zip(features.iter()).try_fold(
110            0.0,
111            |sum, (&weight, &feature)| {
112                let term = weight * feature;
113                ensure_finite("linear prediction term", term)?;
114                checked_finite_add(sum, term, "linear prediction")
115            },
116        )?;
117        checked_finite_add(dot, self.intercept, "linear prediction")
118    }
119}
120
121impl OnlineRegressor for LinearRegression {
122    fn feature_count(&self) -> usize {
123        self.feature_count
124    }
125
126    fn samples_seen(&self) -> u64 {
127        self.samples_seen
128    }
129
130    fn predict(&self, features: &[f64]) -> Result<f64, RillError> {
131        self.predict_inner(features)
132    }
133
134    fn learn(&mut self, features: &[f64], target: f64) -> Result<(), RillError> {
135        validate_features(self.feature_count, features)?;
136        ensure_finite_target(target)?;
137        let next_samples = checked_increment(self.samples_seen, "linear regression sample")?;
138
139        let prediction = self.predict_inner(features)?;
140        let grad = self.loss.gradient(prediction, target);
141        ensure_finite("loss gradient", grad)?;
142
143        // gradient w.r.t. each weight w_i is grad * x_i
144        let grad_weights = features
145            .iter()
146            .map(|&feature| {
147                let gradient = grad * feature;
148                ensure_finite("weight gradient", gradient)?;
149                Ok(gradient)
150            })
151            .collect::<Result<Vec<_>, RillError>>()?;
152        let grad_intercept = grad;
153
154        self.optimizer.step(
155            &mut self.weights,
156            &mut self.intercept,
157            &grad_weights,
158            grad_intercept,
159        )?;
160        self.samples_seen = next_samples;
161        Ok(())
162    }
163
164    fn reset(&mut self) {
165        for w in &mut self.weights {
166            *w = 0.0;
167        }
168        self.intercept = 0.0;
169        self.optimizer.reset();
170        self.samples_seen = 0;
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::optim::{AdaGradConfig, SgdConfig};
178    use rand::SeedableRng;
179
180    fn make_sgd(lr: f64, l2: f64, d: usize) -> Optimizer {
181        Optimizer::sgd(
182            d,
183            SgdConfig {
184                learning_rate: lr,
185                l2,
186            },
187        )
188        .unwrap()
189    }
190
191    #[test]
192    fn predict_cold_start_returns_intercept() {
193        let model = LinearRegression::new(
194            2,
195            LinearRegressionConfig {
196                optimizer: make_sgd(0.1, 0.0, 2),
197                loss: RegressionLoss::default(),
198            },
199        )
200        .unwrap();
201        assert_eq!(model.predict(&[1.0, 2.0]).unwrap(), 0.0);
202    }
203
204    #[test]
205    fn learn_reduces_loss_on_linear_data() {
206        let mut model = LinearRegression::new(
207            2,
208            LinearRegressionConfig {
209                optimizer: make_sgd(0.05, 0.0, 2),
210                loss: RegressionLoss::default(),
211            },
212        )
213        .unwrap();
214        // y = 2*x1 - 0.5*x2 + 1
215        let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(7);
216        let mut first_loss = 0.0;
217        let mut last_loss = 0.0;
218        for i in 0..500 {
219            let x1 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
220            let x2 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
221            let y = 2.0 * x1 - 0.5 * x2 + 1.0;
222            let pred = model.predict(&[x1, x2]).unwrap();
223            let l = crate::loss::SquaredError::loss(pred, y);
224            if i < 10 {
225                first_loss += l;
226            }
227            if i >= 490 {
228                last_loss += l;
229            }
230            model.learn(&[x1, x2], y).unwrap();
231        }
232        assert!(last_loss < first_loss, "loss should decrease");
233        // weights should be approximately [2, -0.5]
234        assert!((model.weights()[0] - 2.0).abs() < 0.3);
235        assert!((model.weights()[1] + 0.5).abs() < 0.3);
236        assert!((model.intercept() - 1.0).abs() < 0.3);
237    }
238
239    #[test]
240    fn predict_does_not_update_state() {
241        let model = LinearRegression::new(
242            1,
243            LinearRegressionConfig {
244                optimizer: make_sgd(0.1, 0.0, 1),
245                loss: RegressionLoss::default(),
246            },
247        )
248        .unwrap();
249        let _ = model.predict(&[1.0]).unwrap();
250        assert_eq!(model.samples_seen(), 0);
251    }
252
253    #[test]
254    fn dimension_mismatch_rejected() {
255        let mut model = LinearRegression::new(
256            3,
257            LinearRegressionConfig {
258                optimizer: make_sgd(0.1, 0.0, 3),
259                loss: RegressionLoss::default(),
260            },
261        )
262        .unwrap();
263        assert!(model.predict(&[1.0, 2.0]).is_err());
264        assert!(model.learn(&[1.0, 2.0], 1.0).is_err());
265    }
266
267    #[test]
268    fn optimizer_feature_count_mismatch_rejected() {
269        let config = LinearRegressionConfig {
270            optimizer: make_sgd(0.1, 0.0, 3),
271            loss: RegressionLoss::default(),
272        };
273        assert!(LinearRegression::new(2, config).is_err());
274    }
275
276    #[test]
277    fn adagrad_works() {
278        let mut model = LinearRegression::new(
279            1,
280            LinearRegressionConfig {
281                optimizer: Optimizer::adagrad(
282                    1,
283                    AdaGradConfig {
284                        learning_rate: 0.5,
285                        l2: 0.0,
286                        epsilon: 1e-8,
287                    },
288                )
289                .unwrap(),
290                loss: RegressionLoss::default(),
291            },
292        )
293        .unwrap();
294        for _ in 0..200 {
295            model.learn(&[1.0], 5.0).unwrap();
296        }
297        assert!((model.predict(&[1.0]).unwrap() - 5.0).abs() < 0.5);
298    }
299
300    #[test]
301    fn reset_clears_state() {
302        let mut model = LinearRegression::new(
303            1,
304            LinearRegressionConfig {
305                optimizer: make_sgd(0.1, 0.0, 1),
306                loss: RegressionLoss::default(),
307            },
308        )
309        .unwrap();
310        model.learn(&[1.0], 5.0).unwrap();
311        model.reset();
312        assert_eq!(model.samples_seen(), 0);
313        assert_eq!(model.predict(&[1.0]).unwrap(), 0.0);
314    }
315
316    #[test]
317    fn non_finite_target_rejected() {
318        let mut model = LinearRegression::new(
319            1,
320            LinearRegressionConfig {
321                optimizer: make_sgd(0.1, 0.0, 1),
322                loss: RegressionLoss::default(),
323            },
324        )
325        .unwrap();
326        assert!(model.learn(&[1.0], f64::NAN).is_err());
327    }
328
329    #[test]
330    fn huber_loss_works() {
331        let mut model = LinearRegression::new(
332            1,
333            LinearRegressionConfig {
334                optimizer: make_sgd(0.1, 0.0, 1),
335                loss: RegressionLoss::Huber(crate::loss::HuberLoss::new(1.0).unwrap()),
336            },
337        )
338        .unwrap();
339        model.learn(&[1.0], 1.0).unwrap();
340        assert_eq!(model.samples_seen(), 1);
341    }
342}