1use crate::error::{RillError, ensure_finite_target};
14use crate::traits::{OnlineBinaryClassifier, OnlineRegressor, Transformer};
15
16#[derive(Debug, Clone)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub struct RegressionPipeline<T, M> {
20 transformer: T,
21 model: M,
22}
23
24impl<T, M> RegressionPipeline<T, M>
25where
26 T: Transformer,
27 M: OnlineRegressor,
28{
29 pub fn new(transformer: T, model: M) -> Result<Self, RillError> {
34 if transformer.output_dim() != model.feature_count() {
35 return Err(RillError::DimensionMismatch {
36 expected: model.feature_count(),
37 actual: transformer.output_dim(),
38 });
39 }
40 Ok(Self { transformer, model })
41 }
42
43 pub fn transformer(&self) -> &T {
45 &self.transformer
46 }
47
48 pub fn model(&self) -> &M {
50 &self.model
51 }
52
53 pub fn learn_transactional(&mut self, features: &[f64], target: f64) -> Result<(), RillError>
60 where
61 T: Clone,
62 M: Clone,
63 {
64 let mut next_transformer = self.transformer.clone();
65 let mut next_model = self.model.clone();
66 next_transformer.update(features)?;
67 let transformed = next_transformer.transform(features)?;
68 next_model.learn(&transformed, target)?;
69 self.transformer = next_transformer;
70 self.model = next_model;
71 Ok(())
72 }
73}
74
75impl<T, M> OnlineRegressor for RegressionPipeline<T, M>
76where
77 T: Transformer,
78 M: OnlineRegressor,
79{
80 fn feature_count(&self) -> usize {
81 self.transformer.input_dim()
82 }
83
84 fn samples_seen(&self) -> u64 {
85 self.transformer.samples_seen()
86 }
87
88 fn predict(&self, features: &[f64]) -> Result<f64, RillError> {
89 let transformed = self.transformer.transform(features)?;
90 self.model.predict(&transformed)
91 }
92
93 fn learn(&mut self, features: &[f64], target: f64) -> Result<(), RillError> {
94 ensure_finite_target(target)?;
95 self.transformer.update(features)?;
96 let transformed = self.transformer.transform(features)?;
97 self.model.learn(&transformed, target)
98 }
99
100 fn reset(&mut self) {
101 self.transformer.reset();
102 self.model.reset();
103 }
104}
105
106#[derive(Debug, Clone)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109pub struct ClassificationPipeline<T, M> {
110 transformer: T,
111 model: M,
112}
113
114impl<T, M> ClassificationPipeline<T, M>
115where
116 T: Transformer,
117 M: OnlineBinaryClassifier,
118{
119 pub fn new(transformer: T, model: M) -> Result<Self, RillError> {
121 if transformer.output_dim() != model.feature_count() {
122 return Err(RillError::DimensionMismatch {
123 expected: model.feature_count(),
124 actual: transformer.output_dim(),
125 });
126 }
127 Ok(Self { transformer, model })
128 }
129
130 pub fn transformer(&self) -> &T {
132 &self.transformer
133 }
134
135 pub fn model(&self) -> &M {
137 &self.model
138 }
139
140 pub fn learn_transactional(&mut self, features: &[f64], target: bool) -> Result<(), RillError>
142 where
143 T: Clone,
144 M: Clone,
145 {
146 let mut next_transformer = self.transformer.clone();
147 let mut next_model = self.model.clone();
148 next_transformer.update(features)?;
149 let transformed = next_transformer.transform(features)?;
150 next_model.learn(&transformed, target)?;
151 self.transformer = next_transformer;
152 self.model = next_model;
153 Ok(())
154 }
155}
156
157impl<T, M> OnlineBinaryClassifier for ClassificationPipeline<T, M>
158where
159 T: Transformer,
160 M: OnlineBinaryClassifier,
161{
162 fn feature_count(&self) -> usize {
163 self.transformer.input_dim()
164 }
165
166 fn samples_seen(&self) -> u64 {
167 self.transformer.samples_seen()
168 }
169
170 fn predict_proba(&self, features: &[f64]) -> Result<f64, RillError> {
171 let transformed = self.transformer.transform(features)?;
172 self.model.predict_proba(&transformed)
173 }
174
175 fn learn(&mut self, features: &[f64], target: bool) -> Result<(), RillError> {
176 self.transformer.update(features)?;
177 let transformed = self.transformer.transform(features)?;
178 self.model.learn(&transformed, target)
179 }
180
181 fn reset(&mut self) {
182 self.transformer.reset();
183 self.model.reset();
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::metrics::Mae;
191 use crate::models::{LinearRegression, LinearRegressionConfig};
192 use crate::optim::{Optimizer, SgdConfig};
193 use crate::preprocessing::StandardScaler;
194 use crate::traits::Metric;
195 use rand::SeedableRng;
196
197 #[test]
198 fn pipeline_predict_does_not_update_transformer() {
199 let d = 2;
200 let scaler = StandardScaler::new(d).unwrap();
201 let model = LinearRegression::new(
202 d,
203 LinearRegressionConfig {
204 optimizer: Optimizer::sgd(d, SgdConfig::default()).unwrap(),
205 loss: Default::default(),
206 },
207 )
208 .unwrap();
209 let mut pipe = RegressionPipeline::new(scaler, model).unwrap();
210
211 let _ = pipe.predict(&[1.0, 2.0]).unwrap();
212 assert_eq!(pipe.transformer().samples_seen(), 0);
213
214 pipe.learn(&[1.0, 2.0], 3.0).unwrap();
215 assert_eq!(pipe.transformer().samples_seen(), 1);
216 }
217
218 #[test]
219 fn failed_pipeline_learn_does_not_mutate_either_stage() {
220 let scaler = StandardScaler::new(1).unwrap();
221 let model = LinearRegression::new(
222 1,
223 LinearRegressionConfig {
224 optimizer: Optimizer::sgd(1, SgdConfig::default()).unwrap(),
225 loss: Default::default(),
226 },
227 )
228 .unwrap();
229 let mut pipe = RegressionPipeline::new(scaler, model).unwrap();
230
231 assert!(pipe.learn_transactional(&[1.0], f64::NAN).is_err());
232 assert_eq!(pipe.transformer().samples_seen(), 0);
233 assert_eq!(pipe.model().samples_seen(), 0);
234 }
235
236 #[test]
237 fn pipeline_dimension_mismatch_rejected() {
238 let scaler = StandardScaler::new(3).unwrap();
239 let model = LinearRegression::new(
240 2,
241 LinearRegressionConfig {
242 optimizer: Optimizer::sgd(2, SgdConfig::default()).unwrap(),
243 loss: Default::default(),
244 },
245 )
246 .unwrap();
247 assert!(RegressionPipeline::new(scaler, model).is_err());
248 }
249
250 #[test]
251 fn pipeline_learns_linear_relation() {
252 let d = 2;
253 let scaler = StandardScaler::new(d).unwrap();
254 let model = LinearRegression::new(
255 d,
256 LinearRegressionConfig {
257 optimizer: Optimizer::sgd(
258 d,
259 SgdConfig {
260 learning_rate: 0.05,
261 l2: 0.0,
262 },
263 )
264 .unwrap(),
265 loss: Default::default(),
266 },
267 )
268 .unwrap();
269 let mut pipe = RegressionPipeline::new(scaler, model).unwrap();
270 let mut mae = Mae::default();
271
272 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(11);
273 for _ in 0..500 {
274 let x1 = rand::Rng::gen_range(&mut rng, 0.0..1.0);
275 let x2 = rand::Rng::gen_range(&mut rng, 0.0..1.0);
276 let y = 3.0 * x1 + 2.0 * x2;
277 let pred = pipe.predict(&[x1, x2]).unwrap();
278 mae.update(y, pred).unwrap();
279 pipe.learn(&[x1, x2], y).unwrap();
280 }
281 let final_mae = mae.value().unwrap();
282 assert!(final_mae < 1.0, "final MAE too high: {final_mae}");
283 }
284}