use crate::error::{RillError, ensure_finite_target};
#[cfg(feature = "serde")]
use crate::persistence::ValidateState;
use crate::traits::{OnlineBinaryClassifier, OnlineRegressor, Transformer};
use crate::weighted::{WeightedOnlineBinaryClassifier, WeightedOnlineRegressor, validate_weight};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RegressionPipeline<T, M> {
transformer: T,
model: M,
}
impl<T, M> RegressionPipeline<T, M>
where
T: Transformer,
M: OnlineRegressor,
{
pub fn new(transformer: T, model: M) -> Result<Self, RillError> {
if transformer.output_dim() != model.feature_count() {
return Err(RillError::DimensionMismatch {
expected: model.feature_count(),
actual: transformer.output_dim(),
});
}
Ok(Self { transformer, model })
}
pub fn transformer(&self) -> &T {
&self.transformer
}
pub fn model(&self) -> &M {
&self.model
}
pub fn learn_transactional(&mut self, features: &[f64], target: f64) -> Result<(), RillError>
where
T: Clone,
M: Clone,
{
let mut next_transformer = self.transformer.clone();
let mut next_model = self.model.clone();
next_transformer.update(features)?;
let transformed = next_transformer.transform(features)?;
next_model.learn(&transformed, target)?;
self.transformer = next_transformer;
self.model = next_model;
Ok(())
}
}
impl<T, M> OnlineRegressor for RegressionPipeline<T, M>
where
T: Transformer,
M: OnlineRegressor,
{
fn feature_count(&self) -> usize {
self.transformer.input_dim()
}
fn samples_seen(&self) -> u64 {
self.transformer.samples_seen()
}
fn predict(&self, features: &[f64]) -> Result<f64, RillError> {
let transformed = self.transformer.transform(features)?;
self.model.predict(&transformed)
}
fn learn(&mut self, features: &[f64], target: f64) -> Result<(), RillError> {
ensure_finite_target(target)?;
self.transformer.update(features)?;
let transformed = self.transformer.transform(features)?;
self.model.learn(&transformed, target)
}
fn reset(&mut self) {
self.transformer.reset();
self.model.reset();
}
}
impl<T, M> WeightedOnlineRegressor for RegressionPipeline<T, M>
where
T: Transformer + Clone,
M: WeightedOnlineRegressor + Clone,
{
fn learn_weighted(
&mut self,
features: &[f64],
target: f64,
weight: f64,
) -> Result<(), RillError> {
validate_weight(weight)?;
ensure_finite_target(target)?;
if weight == 0.0 {
let _ = self.transformer.transform(features)?;
return Ok(());
}
let mut next_transformer = self.transformer.clone();
let mut next_model = self.model.clone();
next_transformer.update(features)?;
let transformed = next_transformer.transform(features)?;
next_model.learn_weighted(&transformed, target, weight)?;
self.transformer = next_transformer;
self.model = next_model;
Ok(())
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ClassificationPipeline<T, M> {
transformer: T,
model: M,
}
impl<T, M> ClassificationPipeline<T, M>
where
T: Transformer,
M: OnlineBinaryClassifier,
{
pub fn new(transformer: T, model: M) -> Result<Self, RillError> {
if transformer.output_dim() != model.feature_count() {
return Err(RillError::DimensionMismatch {
expected: model.feature_count(),
actual: transformer.output_dim(),
});
}
Ok(Self { transformer, model })
}
pub fn transformer(&self) -> &T {
&self.transformer
}
pub fn model(&self) -> &M {
&self.model
}
pub fn learn_transactional(&mut self, features: &[f64], target: bool) -> Result<(), RillError>
where
T: Clone,
M: Clone,
{
let mut next_transformer = self.transformer.clone();
let mut next_model = self.model.clone();
next_transformer.update(features)?;
let transformed = next_transformer.transform(features)?;
next_model.learn(&transformed, target)?;
self.transformer = next_transformer;
self.model = next_model;
Ok(())
}
}
impl<T, M> OnlineBinaryClassifier for ClassificationPipeline<T, M>
where
T: Transformer,
M: OnlineBinaryClassifier,
{
fn feature_count(&self) -> usize {
self.transformer.input_dim()
}
fn samples_seen(&self) -> u64 {
self.transformer.samples_seen()
}
fn predict_proba(&self, features: &[f64]) -> Result<f64, RillError> {
let transformed = self.transformer.transform(features)?;
self.model.predict_proba(&transformed)
}
fn learn(&mut self, features: &[f64], target: bool) -> Result<(), RillError> {
self.transformer.update(features)?;
let transformed = self.transformer.transform(features)?;
self.model.learn(&transformed, target)
}
fn reset(&mut self) {
self.transformer.reset();
self.model.reset();
}
}
impl<T, M> WeightedOnlineBinaryClassifier for ClassificationPipeline<T, M>
where
T: Transformer + Clone,
M: WeightedOnlineBinaryClassifier + Clone,
{
fn learn_weighted(
&mut self,
features: &[f64],
target: bool,
weight: f64,
) -> Result<(), RillError> {
validate_weight(weight)?;
if weight == 0.0 {
let _ = self.transformer.transform(features)?;
return Ok(());
}
let mut next_transformer = self.transformer.clone();
let mut next_model = self.model.clone();
next_transformer.update(features)?;
let transformed = next_transformer.transform(features)?;
next_model.learn_weighted(&transformed, target, weight)?;
self.transformer = next_transformer;
self.model = next_model;
Ok(())
}
}
#[cfg(feature = "serde")]
impl<T, M> ValidateState for RegressionPipeline<T, M>
where
T: Transformer + ValidateState,
M: OnlineRegressor + ValidateState,
{
fn validate_state(&self) -> Result<(), RillError> {
self.transformer.validate_state()?;
self.model.validate_state()?;
if self.transformer.output_dim() != self.model.feature_count() {
return Err(RillError::InvalidState(format!(
"regression pipeline dimension mismatch: transformer output_dim {} != model feature_count {}",
self.transformer.output_dim(),
self.model.feature_count()
)));
}
Ok(())
}
}
#[cfg(feature = "serde")]
impl<T, M> ValidateState for ClassificationPipeline<T, M>
where
T: Transformer + ValidateState,
M: OnlineBinaryClassifier + ValidateState,
{
fn validate_state(&self) -> Result<(), RillError> {
self.transformer.validate_state()?;
self.model.validate_state()?;
if self.transformer.output_dim() != self.model.feature_count() {
return Err(RillError::InvalidState(format!(
"classification pipeline dimension mismatch: transformer output_dim {} != model feature_count {}",
self.transformer.output_dim(),
self.model.feature_count()
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metrics::Mae;
use crate::models::{LinearRegression, LinearRegressionConfig};
use crate::optim::{Optimizer, SgdConfig};
use crate::preprocessing::StandardScaler;
use crate::traits::Metric;
use rand::SeedableRng;
#[test]
fn pipeline_predict_does_not_update_transformer() {
let d = 2;
let scaler = StandardScaler::new(d).unwrap();
let model = LinearRegression::new(
d,
LinearRegressionConfig {
optimizer: Optimizer::sgd(d, SgdConfig::default()).unwrap(),
loss: Default::default(),
},
)
.unwrap();
let mut pipe = RegressionPipeline::new(scaler, model).unwrap();
let _ = pipe.predict(&[1.0, 2.0]).unwrap();
assert_eq!(pipe.transformer().samples_seen(), 0);
pipe.learn(&[1.0, 2.0], 3.0).unwrap();
assert_eq!(pipe.transformer().samples_seen(), 1);
}
#[test]
fn failed_pipeline_learn_does_not_mutate_either_stage() {
let scaler = StandardScaler::new(1).unwrap();
let model = LinearRegression::new(
1,
LinearRegressionConfig {
optimizer: Optimizer::sgd(1, SgdConfig::default()).unwrap(),
loss: Default::default(),
},
)
.unwrap();
let mut pipe = RegressionPipeline::new(scaler, model).unwrap();
assert!(pipe.learn_transactional(&[1.0], f64::NAN).is_err());
assert_eq!(pipe.transformer().samples_seen(), 0);
assert_eq!(pipe.model().samples_seen(), 0);
}
#[test]
fn weighted_pipeline_is_atomic_and_zero_weight_is_noop() {
let scaler = StandardScaler::new(1).unwrap();
let model = LinearRegression::new(
1,
LinearRegressionConfig {
optimizer: Optimizer::sgd(1, SgdConfig::default()).unwrap(),
loss: Default::default(),
},
)
.unwrap();
let mut pipe = RegressionPipeline::new(scaler, model).unwrap();
pipe.learn_weighted(&[2.0], 3.0, 0.0).unwrap();
assert_eq!(pipe.transformer().samples_seen(), 0);
assert_eq!(pipe.model().samples_seen(), 0);
pipe.learn_weighted(&[2.0], 3.0, 2.0).unwrap();
assert_eq!(pipe.transformer().samples_seen(), 1);
assert_eq!(pipe.model().samples_seen(), 1);
let before_transformer = pipe.transformer().clone();
let before_model = pipe.model().clone();
assert!(pipe.learn_weighted(&[f64::NAN], 3.0, 1.0).is_err());
assert_eq!(
pipe.transformer().samples_seen(),
before_transformer.samples_seen()
);
assert_eq!(pipe.model().weights(), before_model.weights());
}
#[test]
fn pipeline_dimension_mismatch_rejected() {
let scaler = StandardScaler::new(3).unwrap();
let model = LinearRegression::new(
2,
LinearRegressionConfig {
optimizer: Optimizer::sgd(2, SgdConfig::default()).unwrap(),
loss: Default::default(),
},
)
.unwrap();
assert!(RegressionPipeline::new(scaler, model).is_err());
}
#[test]
fn pipeline_learns_linear_relation() {
let d = 2;
let scaler = StandardScaler::new(d).unwrap();
let model = LinearRegression::new(
d,
LinearRegressionConfig {
optimizer: Optimizer::sgd(
d,
SgdConfig {
learning_rate: 0.05,
l2: 0.0,
},
)
.unwrap(),
loss: Default::default(),
},
)
.unwrap();
let mut pipe = RegressionPipeline::new(scaler, model).unwrap();
let mut mae = Mae::default();
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(11);
for _ in 0..500 {
let x1 = rand::Rng::gen_range(&mut rng, 0.0..1.0);
let x2 = rand::Rng::gen_range(&mut rng, 0.0..1.0);
let y = 3.0 * x1 + 2.0 * x2;
let pred = pipe.predict(&[x1, x2]).unwrap();
mae.update(y, pred).unwrap();
pipe.learn(&[x1, x2], y).unwrap();
}
let final_mae = mae.value().unwrap();
assert!(final_mae < 1.0, "final MAE too high: {final_mae}");
}
}