1use 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#[derive(Debug, Clone)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub struct LinearRegressionConfig {
19 pub optimizer: Optimizer,
21 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#[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 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 pub fn weights(&self) -> &[f64] {
98 &self.weights
99 }
100
101 pub const fn intercept(&self) -> f64 {
103 self.intercept
104 }
105
106 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 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 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 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}