Skip to main content

rill_ml/
pipeline.rs

1//! Static two-segment pipelines: transformer + model.
2//!
3//! The learning contract is fixed:
4//! - `predict(x)`: `transform(x)` → `model.predict()`. No state updates.
5//! - `learn(x, y)`: `transformer.update(x)` → `transform(x)` → `model.learn()`.
6//! - `learn_transactional` is the failure-atomic variant: neither stage is
7//!   committed unless all three operations succeed.
8//!
9//! The transformer never sees the target `y`, so there is no label leakage in
10//! the progressive-evaluation sense (the prediction for the current sample is
11//! produced *before* any state update).
12
13use crate::error::{RillError, ensure_finite_target};
14use crate::traits::{OnlineBinaryClassifier, OnlineRegressor, Transformer};
15
16/// A pipeline combining a transformer and a regressor.
17#[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    /// Create a new pipeline.
30    ///
31    /// Returns an error if the transformer's output dimension does not match
32    /// the model's feature count.
33    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    /// Borrow the transformer.
44    pub fn transformer(&self) -> &T {
45        &self.transformer
46    }
47
48    /// Borrow the model.
49    pub fn model(&self) -> &M {
50        &self.model
51    }
52
53    /// Learn one sample with all-or-nothing state changes.
54    ///
55    /// This clones both stages, applies the update to the clones, and commits
56    /// them only after every operation succeeds. Prefer this at reliability
57    /// boundaries; use [`OnlineRegressor::learn`] when avoiding the clone cost
58    /// is more important and both stages already provide atomic updates.
59    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/// A pipeline combining a transformer and a binary classifier.
107#[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    /// Create a new classification pipeline.
120    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    /// Borrow the transformer.
131    pub fn transformer(&self) -> &T {
132        &self.transformer
133    }
134
135    /// Borrow the model.
136    pub fn model(&self) -> &M {
137        &self.model
138    }
139
140    /// Learn one classification sample with all-or-nothing state changes.
141    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}