anofox_ml_core/traits.rs
1use ndarray::{Array1, Array2};
2
3use crate::error::Result;
4use crate::float::Float;
5
6/// Supervised learning: fit on (X, y), produce a fitted model.
7///
8/// The type-state pattern ensures `predict` is only callable on `Self::Fitted`,
9/// making it a compile error to predict with unfitted parameters.
10pub trait Fit<F: Float> {
11 type Fitted;
12 fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<Self::Fitted>;
13}
14
15/// Unsupervised learning: fit on X only (e.g., scalers, PCA).
16pub trait FitUnsupervised<F: Float> {
17 type Fitted;
18 fn fit(&self, x: &Array2<F>) -> Result<Self::Fitted>;
19}
20
21/// Predict target values from input features.
22pub trait Predict<F: Float> {
23 fn predict(&self, x: &Array2<F>) -> Result<Array1<F>>;
24}
25
26/// Transform input features (e.g., scaling, encoding).
27pub trait Transform<F: Float> {
28 fn transform(&self, x: &Array2<F>) -> Result<Array2<F>>;
29}
30
31/// Reverse a transformation back to the original space.
32pub trait InverseTransform<F: Float> {
33 fn inverse_transform(&self, x: &Array2<F>) -> Result<Array2<F>>;
34}
35
36/// Produce a probability distribution over classes (classifier) or a posterior
37/// uncertainty (regressor). Output shape is `(n_samples, n_outputs)` where
38/// `n_outputs` is the number of classes for classifiers or 1 for the
39/// regressor variants (which return std-dev). The columns sum to 1 for
40/// classifiers.
41///
42/// Mirrors sklearn's `predict_proba` (and partially `predict_std` for
43/// probabilistic regressors like Bayesian Ridge / Gaussian Process).
44pub trait PredictProba<F: Float> {
45 fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>>;
46}
47
48/// Supervised fit with optional per-sample weights.
49///
50/// Mirrors sklearn's `fit(X, y, sample_weight=...)`. Estimators that support
51/// importance-weighted training implement this in addition to (or instead of)
52/// the unweighted [`Fit`] trait. When `sample_weight` is `None`, the result
53/// should be identical to `Fit::fit(x, y)`.
54pub trait FitWeighted<F: Float> {
55 type Fitted;
56 fn fit_weighted(
57 &self,
58 x: &Array2<F>,
59 y: &Array1<F>,
60 sample_weight: Option<&Array1<F>>,
61 ) -> Result<Self::Fitted>;
62}
63
64/// Incremental / online fit.
65///
66/// Mirrors sklearn's `partial_fit(X, y, classes=...)`. Calling `partial_fit`
67/// with `state = None` initialises a fresh fitted model on the batch; later
68/// calls with `state = Some(prev)` update the model in place from `prev`.
69///
70/// The `classes` argument is required on the first call for classifiers
71/// where the label space is not derivable from a single mini-batch.
72pub trait PartialFit<F: Float> {
73 type Fitted;
74 fn partial_fit(
75 &self,
76 state: Option<Self::Fitted>,
77 x: &Array2<F>,
78 y: &Array1<F>,
79 classes: Option<&[F]>,
80 ) -> Result<Self::Fitted>;
81}
82
83/// Unsupervised fit with optional per-sample weights.
84///
85/// Mirrors sklearn's `fit(X, sample_weight=...)` for unsupervised estimators
86/// such as `KMeans`, `GaussianMixture`, and density-style scalers. When
87/// `sample_weight` is `None`, the result must be identical to
88/// `FitUnsupervised::fit(x)`.
89pub trait FitUnsupervisedWeighted<F: Float> {
90 type Fitted;
91 fn fit_unsupervised_weighted(
92 &self,
93 x: &Array2<F>,
94 sample_weight: Option<&Array1<F>>,
95 ) -> Result<Self::Fitted>;
96}
97
98/// Log-probability output. Default implementation takes `log(predict_proba)`
99/// with an epsilon clamp to avoid log(0).
100///
101/// Mirrors sklearn's `predict_log_proba`.
102pub trait PredictLogProba<F: Float>: PredictProba<F> {
103 fn predict_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>> {
104 let p = self.predict_proba(x)?;
105 let eps = F::from_f64(1e-300).unwrap();
106 Ok(p.mapv(|v| if v < eps { eps.ln() } else { v.ln() }))
107 }
108}
109
110/// Real-valued decision scores per class, before the softmax/argmax. Mirrors
111/// `sklearn.base.ClassifierMixin.decision_function`. Shape `(n_samples, n_classes)`
112/// for multi-class (sklearn returns 1-D for binary; we always return 2-D for
113/// consistency).
114pub trait DecisionFunction<F: Float> {
115 fn decision_function(&self, x: &Array2<F>) -> Result<Array2<F>>;
116}
117
118/// Default scoring for regressors: R² (coefficient of determination).
119///
120/// Mirrors `sklearn.base.RegressorMixin.score`. Higher is better; 1.0 is
121/// perfect prediction, 0.0 means equivalent to predicting `y.mean()`.
122pub trait RegressorScore<F: Float>: Predict<F> {
123 fn score(&self, x: &Array2<F>, y: &Array1<F>) -> Result<F> {
124 let pred = self.predict(x)?;
125 let n = y.len();
126 if n != pred.len() {
127 return Err(crate::error::RustMlError::ShapeMismatch(format!(
128 "y len {} != pred len {}",
129 n,
130 pred.len()
131 )));
132 }
133 let y_mean = y.iter().fold(F::zero(), |acc, &v| acc + v) / F::from_usize(n).unwrap();
134 let mut rss = F::zero();
135 let mut tss = F::zero();
136 for (a, b) in y.iter().zip(pred.iter()) {
137 let r = *a - *b;
138 rss = rss + r * r;
139 let t = *a - y_mean;
140 tss = tss + t * t;
141 }
142 let tss_safe = if tss == F::zero() {
143 F::from_f64(1e-12).unwrap()
144 } else {
145 tss
146 };
147 Ok(F::one() - rss / tss_safe)
148 }
149}
150
151/// Default scoring for classifiers: accuracy.
152///
153/// Mirrors `sklearn.base.ClassifierMixin.score`.
154pub trait ClassifierScore<F: Float>: Predict<F> {
155 fn score(&self, x: &Array2<F>, y: &Array1<F>) -> Result<F> {
156 let pred = self.predict(x)?;
157 let n = y.len();
158 if n != pred.len() {
159 return Err(crate::error::RustMlError::ShapeMismatch(format!(
160 "y len {} != pred len {}",
161 n,
162 pred.len()
163 )));
164 }
165 let eps = F::from_f64(1e-9).unwrap();
166 let correct = y
167 .iter()
168 .zip(pred.iter())
169 .filter(|(a, b)| (**a - **b).abs() < eps)
170 .count();
171 Ok(F::from_usize(correct).unwrap() / F::from_usize(n).unwrap())
172 }
173}