Skip to main content

millwright/
traits.rs

1//! The trait contract.
2//!
3//! Everything composes because everything speaks these traits, and they are
4//! **object-safe**, so a [`Pipeline`](crate::pipeline::Pipeline) can hold a
5//! heterogeneous `Vec<Box<dyn Transformer>>` and a `Box<dyn Model>`.
6//!
7//! Four traits are the core — the whole supervised lifecycle rides on them:
8//!
9//! | trait | shape |
10//! |-------|-------|
11//! | [`Transformer`] | `transform(&Frame) -> Frame` |
12//! | [`Estimator`]   | `fit(&Dataset)` |
13//! | [`Predictor`]   | `predict(&Frame) -> Vec<f64>` |
14//! | [`ProbaPredictor`] | `predict_proba(&Frame) -> Frame` |
15//!
16//! A blanket [`Model`] ties `Estimator + Predictor` together. A few specialized
17//! traits cover the shapes that don't fit the supervised mould:
18//! [`Clusterer`] (unsupervised labels), [`Forecaster`] (time series),
19//! [`PartialFit`] (out-of-core), and [`Balancer`] (train-time resampling).
20
21use crate::error::{Error, Result};
22use crate::frame::{Dataset, Frame};
23
24/// A hyperparameter value, addressable by path (the `"step__param"`
25/// convention). Kept deliberately small for the spine.
26#[derive(Clone, Debug, PartialEq)]
27pub enum ParamValue {
28    Int(i64),
29    Float(f64),
30    Bool(bool),
31}
32
33impl From<i64> for ParamValue {
34    fn from(v: i64) -> Self {
35        ParamValue::Int(v)
36    }
37}
38impl From<i32> for ParamValue {
39    fn from(v: i32) -> Self {
40        ParamValue::Int(v as i64)
41    }
42}
43impl From<usize> for ParamValue {
44    fn from(v: usize) -> Self {
45        ParamValue::Int(v as i64)
46    }
47}
48impl From<f64> for ParamValue {
49    fn from(v: f64) -> Self {
50        ParamValue::Float(v)
51    }
52}
53impl From<bool> for ParamValue {
54    fn from(v: bool) -> Self {
55        ParamValue::Bool(v)
56    }
57}
58
59impl ParamValue {
60    /// Interpret as an integer, accepting an integral float.
61    pub fn as_i64(&self) -> Result<i64> {
62        match self {
63            ParamValue::Int(i) => Ok(*i),
64            ParamValue::Float(f) if f.fract() == 0.0 => Ok(*f as i64),
65            other => Err(Error::Param(format!("expected an integer, got {other:?}"))),
66        }
67    }
68
69    /// Interpret as a float.
70    pub fn as_f64(&self) -> Result<f64> {
71        match self {
72            ParamValue::Float(f) => Ok(*f),
73            ParamValue::Int(i) => Ok(*i as f64),
74            other => Err(Error::Param(format!("expected a float, got {other:?}"))),
75        }
76    }
77
78    /// Interpret as a boolean.
79    pub fn as_bool(&self) -> Result<bool> {
80        match self {
81            ParamValue::Bool(b) => Ok(*b),
82            other => Err(Error::Param(format!("expected a bool, got {other:?}"))),
83        }
84    }
85}
86
87/// Learns parameters from a frame, then maps `Frame -> Frame`.
88///
89/// A transformer is fitted in place with `&mut self`, which keeps the trait
90/// object-safe so pipelines can own `Box<dyn Transformer>` steps.
91///
92/// The [`TransformerClone`] supertrait lets a boxed transformer be cloned, so a
93/// search can re-fit a fresh copy of a pipeline on every CV fold.
94pub trait Transformer: TransformerClone + Send + Sync {
95    /// A short, stable name for diagnostics.
96    fn name(&self) -> &'static str;
97
98    /// Learn any parameters needed to transform (means, encodings, …).
99    fn fit(&mut self, frame: &Frame) -> Result<()>;
100
101    /// Map an input frame to an output frame using the fitted parameters.
102    fn transform(&self, frame: &Frame) -> Result<Frame>;
103
104    /// Fit then transform in one pass. Override for a cheaper combined path.
105    fn fit_transform(&mut self, frame: &Frame) -> Result<Frame> {
106        self.fit(frame)?;
107        self.transform(frame)
108    }
109
110    /// If (once fitted) this transformer is an affine map
111    /// `y = (x - shift) / scale` per column, return `(shift, scale)`.
112    ///
113    /// Scalers implement this so a [`Pipeline`](crate::pipeline::Pipeline) can be
114    /// folded into a single ONNX graph. Non-affine transformers return `None`.
115    fn as_affine(&self) -> Option<(Vec<f64>, Vec<f64>)> {
116        None
117    }
118
119    /// This transformer as an ONNX graph [`Prefix`](crate::onnx::Prefix), so a
120    /// pipeline can splice it in front of the estimator on export. Defaults to
121    /// the affine map from [`as_affine`](Self::as_affine); non-affine steps that
122    /// are still ONNX-expressible (e.g. imputers) override this. `None` means
123    /// "not ONNX-exportable".
124    #[cfg(feature = "onnx")]
125    fn onnx_prefix(&self) -> Option<crate::onnx::Prefix> {
126        self.as_affine()
127            .map(|(shift, scale)| crate::onnx::Prefix::Affine { shift, scale })
128    }
129
130    /// Set a hyperparameter by name. Unknown names are an error.
131    fn set_param(&mut self, name: &str, _value: ParamValue) -> Result<()> {
132        Err(Error::Param(format!(
133            "{} has no parameter '{name}'",
134            self.name()
135        )))
136    }
137}
138
139/// Clone support for boxed transformers (the object-safe half of `Clone`).
140pub trait TransformerClone {
141    /// Clone `self` into a fresh box.
142    fn clone_box(&self) -> Box<dyn Transformer>;
143}
144
145impl<T> TransformerClone for T
146where
147    T: Transformer + Clone + 'static,
148{
149    fn clone_box(&self) -> Box<dyn Transformer> {
150        Box::new(self.clone())
151    }
152}
153
154impl Clone for Box<dyn Transformer> {
155    fn clone(&self) -> Self {
156        self.clone_box()
157    }
158}
159
160/// Fits a model on a labelled [`Dataset`].
161pub trait Estimator: Send + Sync {
162    /// A short, stable name for diagnostics.
163    fn name(&self) -> &'static str;
164
165    /// Fit the model on features + target.
166    fn fit(&mut self, dataset: &Dataset) -> Result<()>;
167
168    /// Set a hyperparameter by name. Unknown names are an error.
169    fn set_param(&mut self, name: &str, _value: ParamValue) -> Result<()> {
170        Err(Error::Param(format!(
171            "{} has no parameter '{name}'",
172            self.name()
173        )))
174    }
175
176    /// Build this estimator's ONNX graph, if it supports export. Overridden by
177    /// backends that are ONNX-exportable; the default reports the estimator is
178    /// not exportable.
179    #[cfg(feature = "onnx")]
180    fn to_onnx_proto(&self) -> Result<onnx_export_rs::proto::ModelProto> {
181        Err(Error::Backend(format!(
182            "{} is not ONNX-exportable",
183            self.name()
184        )))
185    }
186}
187
188/// Produces point predictions for a frame.
189pub trait Predictor: Send + Sync {
190    /// Predict one value per row.
191    fn predict(&self, frame: &Frame) -> Result<Vec<f64>>;
192}
193
194/// Produces class-probability predictions.
195///
196/// The returned [`Frame`] has one column per class.
197pub trait ProbaPredictor: Predictor {
198    fn predict_proba(&self, frame: &Frame) -> Result<Frame>;
199}
200
201/// A time-series forecaster: fit on a one-dimensional series, then predict the
202/// next `steps` values. A *different data shape* than the row/target contract,
203/// so it has its own trait (as clustering does).
204pub trait Forecaster {
205    /// A short, stable name for diagnostics.
206    fn name(&self) -> &'static str;
207
208    /// Fit the forecaster on a historical series.
209    fn fit(&mut self, series: &[f64]) -> Result<()>;
210
211    /// Forecast the next `steps` values beyond the fitted history.
212    fn forecast(&self, steps: usize) -> Result<Vec<f64>>;
213}
214
215/// An out-of-core estimator: learn from a stream of batches that never fully
216/// load into memory. `partial_fit` updates the model with one batch at a time;
217/// the estimator predicts through the usual [`Predictor`] contract.
218pub trait PartialFit {
219    /// A short, stable name for diagnostics.
220    fn name(&self) -> &'static str;
221
222    /// Update the model with one batch of `(features, target)`.
223    fn partial_fit(&mut self, batch: &Dataset) -> Result<()>;
224}
225
226/// An unsupervised cluster model: fit on features alone (no target), then
227/// assign each row a cluster label.
228///
229/// This is the contract for inductive clusterers (k-means, GMM) that can label
230/// unseen data. Transductive methods that only label their training data (e.g.
231/// DBSCAN) expose a `fit_predict` inherent method instead.
232pub trait Clusterer {
233    /// A short, stable name for diagnostics.
234    fn name(&self) -> &'static str;
235
236    /// Learn the clustering from the feature frame.
237    fn fit(&mut self, frame: &Frame) -> Result<()>;
238
239    /// Assign each row of `frame` a cluster label.
240    fn predict(&self, frame: &Frame) -> Result<Vec<f64>>;
241}
242
243/// A fittable, predicting model — the shape a pipeline's final step must have.
244///
245/// Blanket-implemented for anything that is an [`Estimator`], a [`Predictor`],
246/// and `Clone`, so backends never implement it directly. The `Clone` bound (via
247/// [`ModelClone`]) lets a search re-fit fresh copies across CV folds and lets
248/// bagging/stacking clone their base estimators.
249pub trait Model: Estimator + Predictor + ModelClone {}
250impl<T: Estimator + Predictor + Clone + 'static> Model for T {}
251
252/// Clone support for boxed models (the object-safe half of `Clone`).
253pub trait ModelClone {
254    /// Clone `self` into a fresh box.
255    fn clone_box(&self) -> Box<dyn Model>;
256}
257
258impl<T> ModelClone for T
259where
260    T: Model + Clone + 'static,
261{
262    fn clone_box(&self) -> Box<dyn Model> {
263        Box::new(self.clone())
264    }
265}
266
267impl Clone for Box<dyn Model> {
268    fn clone(&self) -> Self {
269        self.clone_box()
270    }
271}
272
273/// A train-time resampler: given features and a target, produce a rebalanced
274/// `(Frame, target)`. Unlike a [`Transformer`], a balancer runs **only during
275/// `fit`** — never at predict time — because it changes the row set (e.g. SMOTE
276/// synthesises minority-class rows).
277pub trait Balancer: BalancerClone + Send + Sync {
278    /// A short, stable name for diagnostics.
279    fn name(&self) -> &'static str;
280
281    /// Resample `(features, target)` into a rebalanced pair.
282    fn fit_resample(&self, features: &Frame, target: &[f64]) -> Result<(Frame, Vec<f64>)>;
283}
284
285/// Clone support for boxed balancers.
286pub trait BalancerClone {
287    /// Clone `self` into a fresh box.
288    fn clone_box(&self) -> Box<dyn Balancer>;
289}
290
291impl<T> BalancerClone for T
292where
293    T: Balancer + Clone + 'static,
294{
295    fn clone_box(&self) -> Box<dyn Balancer> {
296        Box::new(self.clone())
297    }
298}
299
300impl Clone for Box<dyn Balancer> {
301    fn clone(&self) -> Self {
302        self.clone_box()
303    }
304}