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}