ferrolearn_core/pipeline.rs
1//! Dynamic-dispatch pipeline for composing transformers and estimators.
2//!
3//! A [`Pipeline`] chains zero or more transformer steps followed by a final
4//! estimator step. Calling [`Fit::fit`] on a pipeline fits each step in
5//! sequence, producing a [`FittedPipeline`] that implements [`Predict`].
6//!
7//! The pipeline is generic over the float type `F`, supporting both `f32`
8//! and `f64` data. All steps in a pipeline must use the same float type.
9//! The type parameter defaults to `f64` for backward compatibility.
10//!
11//! ## REQ status (per `.design/core/pipeline.md`, mirrors `sklearn/pipeline.py` @ 1.5.2)
12//!
13//! ferrolearn's `Pipeline` is a minimal subset of sklearn's: sequential
14//! transformer fit→transform chaining + a single final estimator's fit/predict.
15//!
16//! | REQ | Status | Evidence |
17//! |---|---|---|
18//! | REQ-1 (fit→transform chaining + final predict) | SHIPPED | `Fit for Pipeline` (fit each transformer, transform, fit final estimator) mirrors `Pipeline._fit` (`pipeline.py:406`); `Predict for FittedPipeline` mirrors `Pipeline.predict` (`pipeline.py:599`). Non-test consumers: `impl PipelineEstimator for GaussianNB in gaussian.rs`, `impl PipelineEstimator for BernoulliNB in bernoulli.rs`, `impl PipelineTransformer for KernelPCA in kernel_pca.rs`. (critic: fit-then-transform ≡ sklearn fused fit_transform to ≤1.1e-14 on KernelPCA.) |
19//! | REQ-2 (no-final-estimator rejected at fit) | SHIPPED | `Fit for Pipeline` returns `FerroError::InvalidParameter` when the estimator slot is unset; matches sklearn requiring a final predictor for `.predict` (`available_if` at `pipeline.py:549`). |
20//! | REQ-3 (fit_transform/transform/predict_proba/decision_function/score) | SHIPPED | `Pipeline::fit_transform` (`Fit::fit` then `transform_through`) mirrors `Pipeline.fit_transform` (`pipeline.py:489`); `FittedPipeline::{transform, predict_proba, decision_function, score}` run the private `transform_through` loop (`pipeline.py:599-600`/`:719-720`/`:768-769`/`:999-1000`) then delegate to the final estimator. `predict_proba`/`decision_function`/`score` forward to the new default-`Err` trait methods `predict_proba_pipeline`/`decision_function_pipeline`/`score_pipeline` on `FittedPipelineEstimator` (the `available_if(_final_estimator_has(...))` analog, `pipeline.py:674`/`:731`/`:960`); `transform` returns the transformer-prefix output (sklearn raises `AttributeError` for a non-transformer-final `transform`, `:858`). Non-test consumer: `impl FittedPipelineEstimator for FittedGaussianNBPipeline in gaussian.rs` overrides `predict_proba_pipeline` (→ `predict_proba`) + `score_pipeline` (→ `score`). Live-oracle verification: `gaussian_pipeline_predict_proba_score_match_sklearn` (StandardScaler+GaussianNB pipeline matches sklearn `predict_proba`/`score`/`transform`) + core `test_pipeline_fit_transform_equals_transform`/`test_pipeline_predict_proba_and_score_override`/`test_pipeline_predict_proba_default_is_err`. |
21//! | REQ-4a (named_steps / `__getitem__` int+str+slice) | SHIPPED | `Pipeline::{named_steps, get_step, get_step_by_name, named_step, into_slice}` + `FittedPipeline::{named_steps, get_step, get_step_by_name, named_step}` over the existing `transforms`/`estimator` storage; mirror `Pipeline.named_steps` (`pipeline.py:325` `return Bunch(**dict(self.steps))`), integer/string/slice `Pipeline.__getitem__` (`pipeline.py:298-318`). A step is returned as a `PipelineStepRef`/`FittedPipelineStepRef` enum (the heterogeneous-`(name, obj)`-list analog, since a ferrolearn step is EITHER a `PipelineTransformer` OR a `PipelineEstimator`). `into_slice` consumes `self` (the trait-object steps are not `Clone`, so the new sub-pipeline MOVES the contiguous range, vs sklearn's shallow object-sharing copy `:310`). Non-test consumer: pub API on the grandfathered `Pipeline`/`FittedPipeline` boundary types (S5). Live-oracle verification (R-CHAR-3, sklearn 1.5.2): `test_pipeline_named_steps_match_sklearn`, `test_pipeline_get_step_*`, `test_pipeline_into_slice_*`. |
22//! | REQ-4b (get_params / set_params `<step>__<param>` nested protocol) | NOT-STARTED | blocker #362. The `PipelineTransformer`/`PipelineEstimator` trait objects expose NO `get_params`/reflection method, so the `_BaseComposition._get_params`/`_set_params` nested addressing (`pipeline.py:216`/`:237`) is not implementable without first adding a per-step reflection trait (e.g. `fn get_params(&self) -> BTreeMap<String, ParamValue>` on the step traits). Concrete blocker: a `get_params`/reflection method on the step traits. |
23//! | REQ-5a (passthrough steps) | SHIPPED | `PassthroughTransformer`/`FittedPassthroughTransformer in pipeline.rs` are a reusable identity transformer (`impl PipelineTransformer` `fit_pipeline` → `Box::new(FittedPassthroughTransformer)`; `transform_pipeline(&self, x) → Ok(x.clone())`), the Rust analog of sklearn's `'passthrough'`/`None` step (`sklearn/pipeline.py:251`/`:266` `_validate_steps` allows it, `:275-290` `_iter(filter_passthrough=True)` skips it so `Xt` passes through, `:337` it stays visible in `named_steps`). ferrolearn types the transformer/estimator split, so the no-op IS a concrete identity transformer placed in the chain — no `filter_passthrough` loop branch needed (the step is genuinely identity). Non-test production consumer: the pub `Pipeline::passthrough_step` builder (the `('name','passthrough')` analog, delegating to `transform_step` with a `PassthroughTransformer`), plus the pub API on the `pub mod pipeline` surface (S5 — same boundary as `Pipeline`/`FeatureUnion`, not crate-root re-exported). Live-oracle (R-CHAR-3, sklearn 1.5.2): `Pipeline([('p','passthrough')]).fit(X).transform(X) == X`; passthrough before/after a transformer == the transformer alone; the step appears in `named_steps`/`steps`. Pinned by `test_passthrough_step_is_identity`, `test_passthrough_before_transformer_is_noop`, `test_passthrough_after_transformer_is_noop`, `test_passthrough_step_appears_in_step_names`, `test_passthrough_transformer_standalone_identity`, `test_passthrough_transformer_f32`. |
24//! | REQ-5b (memory caching) | NOT-STARTED | blocker #363. No `memory=`/`check_memory`/`fit_transform_one_cached` transformer caching (`sklearn/pipeline.py:388-390`); requires a joblib disk-cache substrate with no ferrolearn analog yet. |
25//! | REQ-6 (fit_params / metadata routing) | NOT-STARTED | blocker #364. |
26//! | REQ-7 (make_pipeline auto-naming helper) | NOT-STARTED | blocker #365 (`pipeline.py:1220`). |
27//! | REQ-8 (FeatureUnion) | SHIPPED | `FeatureUnion`/`FittedFeatureUnion` in `pipeline.rs`: `impl Fit<Array2<F>, ()> for FeatureUnion` fits each named sub-transformer on the SAME `x` (mirrors `FeatureUnion.fit` fitting every transformer on `X`, `pipeline.py:1643`/`:1681`) recording each output width; the fit also validates transformer-name uniqueness up front (mirrors `_validate_transformers` → `_validate_names`, `pipeline.py:1523-1525` → `sklearn/utils/metaestimators.py:81-83`): a duplicate name returns `FerroError::InvalidParameter` (sklearn's `ValueError: Names provided are not unique` analog) instead of fitting; `impl Transform<Array2<F>> for FittedFeatureUnion` transforms `x` through each and horizontally concatenates the column blocks left-to-right in list order (mirrors `FeatureUnion.transform` → `_hstack`, `pipeline.py:1770`/`:1812` `np.hstack(Xs)`); `FittedFeatureUnion::get_feature_names_out` prefixes each block's positional `x{j}` with `{name}__` (the `verbose_feature_names_out=True` default, `pipeline.py:1567`/`:1608-1616`). Non-test consumer: the pub API on the `pub mod pipeline` surface (S5 — the same boundary the grandfathered `Pipeline`/`FittedPipeline` types live on; neither is crate-root re-exported). Live-oracle (sklearn 1.5.2): `FeatureUnion([('ss',StandardScaler()),('mm',MinMaxScaler())])` on `[[1,2],[3,4],[5,6]]` → `(3,4)` with column blocks `[ss|mm]` and names `['ss__x0','ss__x1','mm__x0','mm__x1']`. NOT-STARTED (no ferrolearn analog yet): `transformer_weights` per-output scaling (`pipeline.py:1369`), the `'drop'`/`'passthrough'` sentinels (`:1530`/`:1563`), `n_jobs` parallelism (`:1360`), metadata routing (`:1859`), `verbose_feature_names_out=False` non-prefixed mode (`:1618-1641`), and the ferray substrate (typed on `ndarray::{Array1,Array2}`). |
28//! | REQ-9 (ferray substrate) | NOT-STARTED | blocker #367 — data flow typed on `ndarray::{Array1,Array2}`; cascades (R-SUBSTRATE-4). |
29//!
30//! acto-critic verdict: NO DIVERGENCE FOUND in the implemented surface (chaining,
31//! y-threading, estimator-only predict, and the REQ-3 apply methods
32//! — `fit_transform`/`transform`/`predict_proba`/`decision_function`/`score` —
33//! all match the live sklearn oracle; `transform` over a non-transformer-final
34//! pipeline returns the transformer-prefix output, the structural analog of
35//! sklearn's `available_if(_can_transform)` `AttributeError`). Two states only
36//! per goal.md R-DEFER-2.
37//!
38//! # Examples
39//!
40//! ```
41//! use ferrolearn_core::pipeline::{Pipeline, PipelineTransformer, PipelineEstimator};
42//! use ferrolearn_core::{Fit, Predict, FerroError};
43//! use ndarray::{Array1, Array2};
44//!
45//! // A trivial identity transformer for demonstration.
46//! struct IdentityTransformer;
47//!
48//! impl PipelineTransformer<f64> for IdentityTransformer {
49//! fn fit_pipeline(
50//! &self,
51//! x: &Array2<f64>,
52//! _y: &Array1<f64>,
53//! ) -> Result<Box<dyn FittedPipelineTransformer<f64>>, FerroError> {
54//! Ok(Box::new(FittedIdentity))
55//! }
56//! }
57//!
58//! struct FittedIdentity;
59//!
60//! impl FittedPipelineTransformer<f64> for FittedIdentity {
61//! fn transform_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
62//! Ok(x.clone())
63//! }
64//! }
65//!
66//! // A trivial estimator that predicts the first column.
67//! struct FirstColumnEstimator;
68//!
69//! impl PipelineEstimator<f64> for FirstColumnEstimator {
70//! fn fit_pipeline(
71//! &self,
72//! _x: &Array2<f64>,
73//! _y: &Array1<f64>,
74//! ) -> Result<Box<dyn FittedPipelineEstimator<f64>>, FerroError> {
75//! Ok(Box::new(FittedFirstColumn))
76//! }
77//! }
78//!
79//! struct FittedFirstColumn;
80//!
81//! impl FittedPipelineEstimator<f64> for FittedFirstColumn {
82//! fn predict_pipeline(&self, x: &Array2<f64>) -> Result<Array1<f64>, FerroError> {
83//! Ok(x.column(0).to_owned())
84//! }
85//! }
86//!
87//! // Build and use the pipeline.
88//! use ferrolearn_core::pipeline::FittedPipelineTransformer;
89//! use ferrolearn_core::pipeline::FittedPipelineEstimator;
90//!
91//! let pipeline = Pipeline::new()
92//! .transform_step("scaler", Box::new(IdentityTransformer))
93//! .estimator_step("model", Box::new(FirstColumnEstimator));
94//!
95//! let x = Array2::<f64>::zeros((5, 3));
96//! let y = Array1::<f64>::zeros(5);
97//!
98//! let fitted = pipeline.fit(&x, &y).unwrap();
99//! let preds = fitted.predict(&x).unwrap();
100//! assert_eq!(preds.len(), 5);
101//! ```
102
103use ndarray::{Array1, Array2};
104use num_traits::Float;
105
106use crate::dataset::check_consistent_length;
107use crate::error::FerroError;
108use crate::traits::{Fit, Predict, Transform};
109
110// ---------------------------------------------------------------------------
111// Trait-object interfaces for pipeline steps
112// ---------------------------------------------------------------------------
113
114/// An unfitted transformer step that can participate in a [`Pipeline`].
115///
116/// Implementors must be able to fit themselves on `Array2<F>` data and
117/// return a boxed [`FittedPipelineTransformer`].
118///
119/// The type parameter `F` is the float type (`f32` or `f64`).
120pub trait PipelineTransformer<F: Float + Send + Sync + 'static>: Send + Sync {
121 /// Fit this transformer on the given data.
122 ///
123 /// # Errors
124 ///
125 /// Returns a [`FerroError`] if fitting fails.
126 fn fit_pipeline(
127 &self,
128 x: &Array2<F>,
129 y: &Array1<F>,
130 ) -> Result<Box<dyn FittedPipelineTransformer<F>>, FerroError>;
131}
132
133/// A fitted transformer step in a [`FittedPipeline`].
134///
135/// Transforms `Array2<F>` data, producing a new `Array2<F>`.
136pub trait FittedPipelineTransformer<F: Float + Send + Sync + 'static>: Send + Sync {
137 /// Transform the input data.
138 ///
139 /// # Errors
140 ///
141 /// Returns a [`FerroError`] if the input shape is incompatible.
142 fn transform_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError>;
143}
144
145/// An unfitted estimator step that serves as the final step in a [`Pipeline`].
146///
147/// Implementors must be able to fit themselves on `Array2<F>` data and
148/// return a boxed [`FittedPipelineEstimator`].
149pub trait PipelineEstimator<F: Float + Send + Sync + 'static>: Send + Sync {
150 /// Fit this estimator on the given data.
151 ///
152 /// # Errors
153 ///
154 /// Returns a [`FerroError`] if fitting fails.
155 fn fit_pipeline(
156 &self,
157 x: &Array2<F>,
158 y: &Array1<F>,
159 ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError>;
160}
161
162/// A fitted estimator step in a [`FittedPipeline`].
163///
164/// Produces `Array1<F>` predictions from `Array2<F>` input.
165///
166/// The three delegating methods below — `predict_proba_pipeline`,
167/// `decision_function_pipeline`, `score_pipeline` — mirror the way sklearn's
168/// `Pipeline` forwards to the final estimator's `predict_proba` /
169/// `decision_function` / `score` (`sklearn/pipeline.py:675`, `:731`, `:961`).
170/// scikit-learn gates each pipeline method on the final estimator actually
171/// having the attribute via `available_if(_final_estimator_has(...))`
172/// (`sklearn/pipeline.py:674`, `:731`, `:960`); a final estimator that lacks
173/// the method raises `AttributeError`. ferrolearn cannot express
174/// `available_if` over a trait object, so each method ships a DEFAULT impl that
175/// returns [`FerroError::InvalidParameter`] (the closest analog of sklearn's
176/// `AttributeError`). A concrete estimator that DOES support the operation
177/// overrides the corresponding method.
178pub trait FittedPipelineEstimator<F: Float + Send + Sync + 'static>: Send + Sync {
179 /// Generate predictions for the input data.
180 ///
181 /// # Errors
182 ///
183 /// Returns a [`FerroError`] if the input shape is incompatible.
184 fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError>;
185
186 /// Class-probability estimates for the input data, shape
187 /// `(n_samples, n_classes)`.
188 ///
189 /// Mirrors the final-estimator delegation of `Pipeline.predict_proba`
190 /// (`sklearn/pipeline.py:721`: `self.steps[-1][1].predict_proba(Xt)`).
191 ///
192 /// # Errors
193 ///
194 /// The default implementation returns [`FerroError::InvalidParameter`] —
195 /// the analog of sklearn raising `AttributeError` when the final estimator
196 /// has no `predict_proba`. Estimators that support probability estimates
197 /// override this method.
198 fn predict_proba_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
199 let _ = x;
200 Err(FerroError::InvalidParameter {
201 name: "predict_proba".into(),
202 reason: "the final estimator of this pipeline does not support predict_proba".into(),
203 })
204 }
205
206 /// Confidence scores (decision function) for the input data, shape
207 /// `(n_samples, n_classes)` (or `(n_samples,)` for binary, per the
208 /// estimator's contract).
209 ///
210 /// Mirrors the final-estimator delegation of `Pipeline.decision_function`
211 /// (`sklearn/pipeline.py:772`: `self.steps[-1][1].decision_function(Xt)`).
212 ///
213 /// # Errors
214 ///
215 /// The default implementation returns [`FerroError::InvalidParameter`] —
216 /// the analog of sklearn raising `AttributeError` when the final estimator
217 /// has no `decision_function`. Estimators that expose a decision function
218 /// override this method.
219 fn decision_function_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
220 let _ = x;
221 Err(FerroError::InvalidParameter {
222 name: "decision_function".into(),
223 reason: "the final estimator of this pipeline does not support decision_function"
224 .into(),
225 })
226 }
227
228 /// Score the final estimator on `(x, y)`, returning a single scalar
229 /// (e.g. mean accuracy for a classifier, R² for a regressor).
230 ///
231 /// Mirrors the final-estimator delegation of `Pipeline.score`
232 /// (`sklearn/pipeline.py:1004`: `self.steps[-1][1].score(Xt, y)`).
233 ///
234 /// # Errors
235 ///
236 /// The default implementation returns [`FerroError::InvalidParameter`] —
237 /// the analog of sklearn raising `AttributeError` when the final estimator
238 /// has no `score`. Estimators that support scoring override this method.
239 fn score_pipeline(&self, x: &Array2<F>, y: &Array1<F>) -> Result<F, FerroError> {
240 let _ = (x, y);
241 Err(FerroError::InvalidParameter {
242 name: "score".into(),
243 reason: "the final estimator of this pipeline does not support score".into(),
244 })
245 }
246}
247
248// ---------------------------------------------------------------------------
249// Pipeline (unfitted)
250// ---------------------------------------------------------------------------
251
252/// A named transformer step in an unfitted pipeline.
253struct TransformStep<F: Float + Send + Sync + 'static> {
254 /// Human-readable name for this step.
255 name: String,
256 /// The unfitted transformer.
257 step: Box<dyn PipelineTransformer<F>>,
258}
259
260/// A borrowed reference to a single step of an unfitted [`Pipeline`].
261///
262/// sklearn's `Pipeline.steps` is a flat list of `(name, obj)` tuples where
263/// every `obj` is duck-typed; `Pipeline.__getitem__` with an integer or string
264/// returns that single `obj` (`sklearn/pipeline.py:298-318`). ferrolearn encodes
265/// the transformer/estimator distinction in the type system, so a "step" is
266/// EITHER a [`PipelineTransformer`] OR a [`PipelineEstimator`]. This enum is the
267/// heterogeneous-step analog: the variant tells the caller which kind of step
268/// they reached, mirroring sklearn returning the underlying object.
269pub enum PipelineStepRef<'a, F: Float + Send + Sync + 'static> {
270 /// A transformer step (an intermediate step of the pipeline).
271 Transformer(&'a dyn PipelineTransformer<F>),
272 /// The final estimator step.
273 Estimator(&'a dyn PipelineEstimator<F>),
274}
275
276/// A dynamic-dispatch pipeline that composes transformers and a final estimator.
277///
278/// Steps are added with [`transform_step`](Pipeline::transform_step) and the
279/// final estimator is set with [`estimator_step`](Pipeline::estimator_step).
280/// The pipeline implements [`Fit<Array2<F>, Array1<F>>`](Fit) and produces
281/// a [`FittedPipeline`] that implements [`Predict<Array2<F>>`](Predict).
282///
283/// All intermediate data flows as `Array2<F>`. The type parameter defaults
284/// to `f64` for backward compatibility.
285pub struct Pipeline<F: Float + Send + Sync + 'static = f64> {
286 /// Ordered transformer steps.
287 transforms: Vec<TransformStep<F>>,
288 /// The final estimator step (name + estimator).
289 estimator: Option<(String, Box<dyn PipelineEstimator<F>>)>,
290}
291
292impl<F: Float + Send + Sync + 'static> Pipeline<F> {
293 /// Create a new empty pipeline.
294 ///
295 /// # Examples
296 ///
297 /// ```
298 /// use ferrolearn_core::pipeline::Pipeline;
299 /// let pipeline = Pipeline::<f64>::new();
300 /// ```
301 pub fn new() -> Self {
302 Self {
303 transforms: Vec::new(),
304 estimator: None,
305 }
306 }
307
308 /// Add a named transformer step to the pipeline.
309 ///
310 /// Transformer steps are applied in the order they are added, before
311 /// the final estimator step.
312 #[must_use]
313 pub fn transform_step(mut self, name: &str, step: Box<dyn PipelineTransformer<F>>) -> Self {
314 self.transforms.push(TransformStep {
315 name: name.to_owned(),
316 step,
317 });
318 self
319 }
320
321 /// Add a named `'passthrough'` (identity no-op) transformer step.
322 ///
323 /// This is the ergonomic analog of an sklearn `('name', 'passthrough')` step:
324 /// a transformer that leaves the running data unchanged but is still a real,
325 /// named step (visible in [`step_names`](Pipeline::step_names) /
326 /// [`named_steps`](Pipeline::named_steps)). It delegates to
327 /// [`transform_step`](Pipeline::transform_step) with a
328 /// [`PassthroughTransformer`], so a passthrough step placed anywhere in the
329 /// chain is a genuine no-op — fitting skips it and transforming passes `Xt`
330 /// through unchanged, mirroring sklearn's `_iter(filter_passthrough=True)`
331 /// dropping `'passthrough'` (`sklearn/pipeline.py:289`) while
332 /// `named_steps`/`__getitem__` still show it (`:337`).
333 #[must_use]
334 pub fn passthrough_step(self, name: &str) -> Self {
335 self.transform_step(name, Box::new(PassthroughTransformer::<F>::new()))
336 }
337
338 /// Set the final estimator step.
339 ///
340 /// A pipeline must have exactly one estimator step. Setting a new
341 /// estimator replaces any previously set estimator.
342 #[must_use]
343 pub fn estimator_step(mut self, name: &str, estimator: Box<dyn PipelineEstimator<F>>) -> Self {
344 self.estimator = Some((name.to_owned(), estimator));
345 self
346 }
347
348 /// Add a named step to the pipeline using the builder pattern.
349 ///
350 /// This is a convenience method that accepts either a transformer or
351 /// an estimator. The final step added via this method that is an
352 /// estimator becomes the pipeline's estimator. This provides the
353 /// `Pipeline::new().step("scaler", ...).step("clf", ...)` API.
354 #[must_use]
355 pub fn step(self, name: &str, step: Box<dyn PipelineStep<F>>) -> Self {
356 step.add_to_pipeline(self, name)
357 }
358
359 /// Fit the pipeline and return both the [`FittedPipeline`] and the data
360 /// after every transformer step has been applied.
361 ///
362 /// This mirrors `Pipeline.fit_transform` (`sklearn/pipeline.py:489-547`):
363 /// `Xt = self._fit(X, y)` fits each transformer on the running `Xt` and
364 /// applies it, then the result is the transformed data. sklearn ALSO calls
365 /// the final estimator's `fit_transform`/`fit().transform()` when the final
366 /// step is itself a transformer (`:540-547`); ferrolearn's final slot is a
367 /// non-transformer estimator, so — like its [`FittedPipeline::transform`] —
368 /// `fit_transform` returns the data after the transformer prefix, with the
369 /// estimator still fit (as in `fit`). The returned `Array2<F>` equals
370 /// [`FittedPipeline::transform`] applied to the same `x` (fit-then-transform
371 /// ≡ sklearn's fused `fit_transform`, established for REQ-1).
372 ///
373 /// # Errors
374 ///
375 /// Returns [`FerroError::InvalidParameter`] if no estimator step was set
376 /// (delegates to [`Fit::fit`]). Propagates any errors from individual step
377 /// fitting or transforming.
378 pub fn fit_transform(
379 &self,
380 x: &Array2<F>,
381 y: &Array1<F>,
382 ) -> Result<(FittedPipeline<F>, Array2<F>), FerroError> {
383 let fitted = self.fit(x, y)?;
384 let transformed = fitted.transform_through(x)?;
385 Ok((fitted, transformed))
386 }
387
388 /// Number of steps in the pipeline (transformer steps plus the final
389 /// estimator, if set).
390 ///
391 /// Mirrors `Pipeline.__len__` (`sklearn/pipeline.py:292-296`:
392 /// `return len(self.steps)`).
393 #[must_use]
394 pub fn len(&self) -> usize {
395 self.transforms.len() + usize::from(self.estimator.is_some())
396 }
397
398 /// Returns `true` if the pipeline has no steps at all.
399 #[must_use]
400 pub fn is_empty(&self) -> bool {
401 self.len() == 0
402 }
403
404 /// Returns the names of all steps (transformers, then the estimator if set)
405 /// in pipeline order.
406 ///
407 /// Mirrors the key ordering of `Pipeline.named_steps`
408 /// (`sklearn/pipeline.py:325`: `Bunch(**dict(self.steps))` keyed by step
409 /// name in `steps` order).
410 #[must_use]
411 pub fn step_names(&self) -> Vec<&str> {
412 let mut names: Vec<&str> = self.transforms.iter().map(|s| s.name.as_str()).collect();
413 if let Some((name, _)) = &self.estimator {
414 names.push(name.as_str());
415 }
416 names
417 }
418
419 /// Access every step by its name, in pipeline order, as a
420 /// `(name, step)` list.
421 ///
422 /// This is the trait-object analog of sklearn's `Pipeline.named_steps`,
423 /// which returns a `Bunch(**dict(self.steps))` — a name→step mapping
424 /// (`sklearn/pipeline.py:325`). Every step (each transformer, then the final
425 /// estimator if set) is reachable by its construction name. ferrolearn
426 /// returns an ordered `Vec` of `(name, PipelineStepRef)` rather than a hash
427 /// map so the pipeline order is preserved and the heterogeneous
428 /// transformer/estimator kinds are distinguishable.
429 #[must_use]
430 pub fn named_steps(&self) -> Vec<(&str, PipelineStepRef<'_, F>)> {
431 let mut steps: Vec<(&str, PipelineStepRef<'_, F>)> = self
432 .transforms
433 .iter()
434 .map(|s| {
435 (
436 s.name.as_str(),
437 PipelineStepRef::Transformer(s.step.as_ref()),
438 )
439 })
440 .collect();
441 if let Some((name, est)) = &self.estimator {
442 steps.push((name.as_str(), PipelineStepRef::Estimator(est.as_ref())));
443 }
444 steps
445 }
446
447 /// Look up a single step by name.
448 ///
449 /// This is the string-key arm of sklearn's `Pipeline.__getitem__`
450 /// (`sklearn/pipeline.py:317`: `return self.named_steps[ind]`), which raises
451 /// `KeyError` for an unknown name; ferrolearn returns `None` (R-CODE-2: no
452 /// panic).
453 #[must_use]
454 pub fn named_step(&self, name: &str) -> Option<PipelineStepRef<'_, F>> {
455 if let Some(ts) = self.transforms.iter().find(|s| s.name == name) {
456 return Some(PipelineStepRef::Transformer(ts.step.as_ref()));
457 }
458 match &self.estimator {
459 Some((est_name, est)) if est_name == name => {
460 Some(PipelineStepRef::Estimator(est.as_ref()))
461 }
462 _ => None,
463 }
464 }
465
466 /// Get the step at position `index` (0-based, transformer steps first then
467 /// the final estimator).
468 ///
469 /// This is the integer arm of sklearn's `Pipeline.__getitem__`
470 /// (`sklearn/pipeline.py:313-318`: `name, est = self.steps[ind]; return
471 /// est`), which raises `IndexError` out of range; ferrolearn returns `None`
472 /// (R-CODE-2: no panic).
473 #[must_use]
474 pub fn get_step(&self, index: usize) -> Option<PipelineStepRef<'_, F>> {
475 let n_transforms = self.transforms.len();
476 if index < n_transforms {
477 return Some(PipelineStepRef::Transformer(
478 self.transforms[index].step.as_ref(),
479 ));
480 }
481 if index == n_transforms
482 && let Some((_, est)) = &self.estimator
483 {
484 return Some(PipelineStepRef::Estimator(est.as_ref()));
485 }
486 None
487 }
488
489 /// Look up a single step by name (alias of [`named_step`](Pipeline::named_step)).
490 ///
491 /// Provided for symmetry with [`get_step`](Pipeline::get_step); mirrors the
492 /// string arm of `Pipeline.__getitem__` (`sklearn/pipeline.py:317`).
493 #[must_use]
494 pub fn get_step_by_name(&self, name: &str) -> Option<PipelineStepRef<'_, F>> {
495 self.named_step(name)
496 }
497
498 /// Build a sub-pipeline from the contiguous step range `[start, end)`,
499 /// consuming `self`.
500 ///
501 /// This is the slice arm of sklearn's `Pipeline.__getitem__`
502 /// (`sklearn/pipeline.py:307-312`): `pipe[a:b]` returns
503 /// `Pipeline(self.steps[a:b], ...)` — a new pipeline over the contiguous
504 /// step range. sklearn slicing supports only a step of 1
505 /// (`:308-309`, otherwise `ValueError`); a contiguous Rust range is the step-1
506 /// analog by construction.
507 ///
508 /// The sliced steps are addressed in the unified order
509 /// (transformer steps `0..n_transforms`, then the estimator at
510 /// `n_transforms` if set), matching [`get_step`](Pipeline::get_step). A slice
511 /// that includes the estimator index keeps it as the final estimator; a slice
512 /// of only transformer indices yields an estimator-less pipeline (valid to
513 /// build, errors only at `fit` — mirroring sklearn, where `pipe[:k]` for a
514 /// transformer-only range is a `Pipeline` that simply lacks `.predict`).
515 ///
516 /// # Divergence from sklearn
517 ///
518 /// sklearn's slice is a SHALLOW copy that shares the underlying estimator
519 /// objects with the original pipeline (`sklearn/pipeline.py:303-305`). The
520 /// ferrolearn step trait objects are not `Clone`, so this method MOVES the
521 /// selected boxed steps into the new pipeline and therefore consumes `self`.
522 /// Slicing a [`FittedPipeline`] is NOT implemented for the same reason (the
523 /// fitted step trait objects are not `Clone`); it is NOT-STARTED under
524 /// blocker #362.
525 ///
526 /// Out-of-range bounds CLAMP and `start > end` yields an empty pipeline —
527 /// Python list-slice semantics, mirroring sklearn `Pipeline.__getitem__`'s
528 /// slice arm which slices `self.steps[ind]` (`pipeline.py:307-312`): an
529 /// ordinary Python slice never raises on out-of-range bounds (#2235). So
530 /// `into_slice(0, 100)` on 3 steps → all 3, `into_slice(5, 100)` → empty,
531 /// `into_slice(2, 1)` → empty. This is a TOTAL function (it cannot fail).
532 #[must_use]
533 pub fn into_slice(self, start: usize, end: usize) -> Pipeline<F> {
534 let n_steps = self.len();
535 // Python slice clamping: `end` past the length is clamped to the length;
536 // a `start >= end` (incl. start past the length) yields an empty range
537 // via the `idx >= start && idx < end` filter below.
538 let end = end.min(n_steps);
539
540 let Pipeline {
541 transforms,
542 estimator,
543 } = self;
544 let n_transforms = transforms.len();
545
546 let mut new_transforms = Vec::new();
547 let mut new_estimator = None;
548 for (idx, ts) in transforms.into_iter().enumerate() {
549 if idx >= start && idx < end {
550 new_transforms.push(ts);
551 }
552 }
553 // The estimator (if set) sits at unified index `n_transforms`.
554 if let Some(est) = estimator
555 && n_transforms >= start
556 && n_transforms < end
557 {
558 new_estimator = Some(est);
559 }
560
561 Pipeline {
562 transforms: new_transforms,
563 estimator: new_estimator,
564 }
565 }
566}
567
568impl<F: Float + Send + Sync + 'static> Default for Pipeline<F> {
569 fn default() -> Self {
570 Self::new()
571 }
572}
573
574impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<F>> for Pipeline<F> {
575 type Fitted = FittedPipeline<F>;
576 type Error = FerroError;
577
578 /// Fit the pipeline by fitting each transformer step in order, then
579 /// fitting the final estimator on the transformed data.
580 ///
581 /// Each transformer is fit on the current data, then the data is
582 /// transformed before being passed to the next step.
583 ///
584 /// Before fitting any step, the pipeline validates that `x` and `y` have a
585 /// consistent number of samples via
586 /// [`check_consistent_length`](crate::dataset::check_consistent_length),
587 /// mirroring scikit-learn's `Pipeline.fit`, which runs every step through
588 /// input validation (`check_X_y` → `check_consistent_length`,
589 /// `sklearn/utils/validation.py:1320`) and rejects `X`/`y` with mismatched
590 /// `n_samples` before fitting (`sklearn/pipeline.py:406` `_fit`). A pipeline
591 /// therefore rejects inconsistent `X`/`y` up front rather than failing
592 /// inside a step's `fit_pipeline`.
593 ///
594 /// # Errors
595 ///
596 /// Returns [`FerroError::InvalidParameter`] if no estimator step was set, or
597 /// [`FerroError::ShapeMismatch`] if `x.nrows() != y.len()`. Propagates any
598 /// errors from individual step fitting or transforming.
599 fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedPipeline<F>, FerroError> {
600 if self.estimator.is_none() {
601 return Err(FerroError::InvalidParameter {
602 name: "estimator".into(),
603 reason: "pipeline must have a final estimator step".into(),
604 });
605 }
606
607 // sklearn validates X/y sample-count consistency before fitting any
608 // step (`check_consistent_length`, `sklearn/utils/validation.py:1320`).
609 check_consistent_length(x.nrows(), y.len())?;
610
611 let mut current_x = x.clone();
612 let mut fitted_transforms = Vec::with_capacity(self.transforms.len());
613
614 // Fit and transform each transformer step.
615 for ts in &self.transforms {
616 let fitted = ts.step.fit_pipeline(¤t_x, y)?;
617 current_x = fitted.transform_pipeline(¤t_x)?;
618 fitted_transforms.push(FittedTransformStep {
619 name: ts.name.clone(),
620 step: fitted,
621 });
622 }
623
624 // Fit the final estimator on the transformed data.
625 let (est_name, est) = self.estimator.as_ref().unwrap();
626 let fitted_est = est.fit_pipeline(¤t_x, y)?;
627
628 Ok(FittedPipeline {
629 transforms: fitted_transforms,
630 estimator: (est_name.clone(), fitted_est),
631 })
632 }
633}
634
635// ---------------------------------------------------------------------------
636// FittedPipeline
637// ---------------------------------------------------------------------------
638
639/// A named fitted transformer step.
640struct FittedTransformStep<F: Float + Send + Sync + 'static> {
641 /// Human-readable name for this step.
642 name: String,
643 /// The fitted transformer.
644 step: Box<dyn FittedPipelineTransformer<F>>,
645}
646
647/// A borrowed reference to a single step of a [`FittedPipeline`].
648///
649/// The fitted analog of [`PipelineStepRef`]: a fitted step is EITHER a
650/// [`FittedPipelineTransformer`] (an intermediate step) OR the
651/// [`FittedPipelineEstimator`] (the final step). Returned by the
652/// `FittedPipeline` `named_steps` / `get_step` / `named_step` accessors, the
653/// fitted analog of sklearn's `Pipeline.__getitem__` over a fitted pipeline
654/// (`sklearn/pipeline.py:298-318`).
655pub enum FittedPipelineStepRef<'a, F: Float + Send + Sync + 'static> {
656 /// A fitted transformer step.
657 Transformer(&'a dyn FittedPipelineTransformer<F>),
658 /// The fitted final estimator step.
659 Estimator(&'a dyn FittedPipelineEstimator<F>),
660}
661
662/// A fitted pipeline that chains fitted transformers and a fitted estimator.
663///
664/// Created by calling [`Fit::fit`] on a [`Pipeline`]. Implements
665/// [`Predict<Array2<F>>`](Predict), producing `Array1<F>` predictions.
666pub struct FittedPipeline<F: Float + Send + Sync + 'static = f64> {
667 /// Fitted transformer steps, in order.
668 transforms: Vec<FittedTransformStep<F>>,
669 /// The fitted estimator (name + estimator).
670 estimator: (String, Box<dyn FittedPipelineEstimator<F>>),
671}
672
673impl<F: Float + Send + Sync + 'static> FittedPipeline<F> {
674 /// Returns the names of all steps (transformers + estimator) in order.
675 pub fn step_names(&self) -> Vec<&str> {
676 let mut names: Vec<&str> = self.transforms.iter().map(|s| s.name.as_str()).collect();
677 names.push(&self.estimator.0);
678 names
679 }
680
681 /// Number of steps in the fitted pipeline (every transformer step plus the
682 /// final estimator).
683 ///
684 /// Mirrors `Pipeline.__len__` (`sklearn/pipeline.py:292-296`). A
685 /// `FittedPipeline` always has exactly one final estimator (the type
686 /// guarantees it), so this is never zero.
687 #[must_use]
688 pub fn len(&self) -> usize {
689 self.transforms.len() + 1
690 }
691
692 /// Always `false`: a fitted pipeline always has at least its final
693 /// estimator step.
694 #[must_use]
695 pub fn is_empty(&self) -> bool {
696 false
697 }
698
699 /// Access every fitted step by its name, in pipeline order, as a
700 /// `(name, step)` list.
701 ///
702 /// The fitted analog of sklearn's `Pipeline.named_steps`
703 /// (`sklearn/pipeline.py:325`: `Bunch(**dict(self.steps))`) — every fitted
704 /// step (each transformer, then the final estimator) is reachable by its
705 /// construction name, in pipeline order.
706 #[must_use]
707 pub fn named_steps(&self) -> Vec<(&str, FittedPipelineStepRef<'_, F>)> {
708 let mut steps: Vec<(&str, FittedPipelineStepRef<'_, F>)> = self
709 .transforms
710 .iter()
711 .map(|s| {
712 (
713 s.name.as_str(),
714 FittedPipelineStepRef::Transformer(s.step.as_ref()),
715 )
716 })
717 .collect();
718 steps.push((
719 self.estimator.0.as_str(),
720 FittedPipelineStepRef::Estimator(self.estimator.1.as_ref()),
721 ));
722 steps
723 }
724
725 /// Look up a single fitted step by name.
726 ///
727 /// The fitted analog of the string arm of `Pipeline.__getitem__`
728 /// (`sklearn/pipeline.py:317`); returns `None` for an unknown name (R-CODE-2:
729 /// no panic, vs sklearn's `KeyError`).
730 #[must_use]
731 pub fn named_step(&self, name: &str) -> Option<FittedPipelineStepRef<'_, F>> {
732 if let Some(ts) = self.transforms.iter().find(|s| s.name == name) {
733 return Some(FittedPipelineStepRef::Transformer(ts.step.as_ref()));
734 }
735 if self.estimator.0 == name {
736 return Some(FittedPipelineStepRef::Estimator(self.estimator.1.as_ref()));
737 }
738 None
739 }
740
741 /// Get the fitted step at position `index` (0-based, transformer steps
742 /// first then the final estimator).
743 ///
744 /// The fitted analog of the integer arm of `Pipeline.__getitem__`
745 /// (`sklearn/pipeline.py:313-318`); returns `None` out of range (R-CODE-2: no
746 /// panic, vs sklearn's `IndexError`).
747 #[must_use]
748 pub fn get_step(&self, index: usize) -> Option<FittedPipelineStepRef<'_, F>> {
749 let n_transforms = self.transforms.len();
750 if index < n_transforms {
751 return Some(FittedPipelineStepRef::Transformer(
752 self.transforms[index].step.as_ref(),
753 ));
754 }
755 if index == n_transforms {
756 return Some(FittedPipelineStepRef::Estimator(self.estimator.1.as_ref()));
757 }
758 None
759 }
760
761 /// Look up a single fitted step by name (alias of
762 /// [`named_step`](FittedPipeline::named_step)).
763 ///
764 /// Mirrors the string arm of `Pipeline.__getitem__`
765 /// (`sklearn/pipeline.py:317`).
766 #[must_use]
767 pub fn get_step_by_name(&self, name: &str) -> Option<FittedPipelineStepRef<'_, F>> {
768 self.named_step(name)
769 }
770
771 /// Run `x` through every fitted transformer step in order, returning the
772 /// fully transformed data (the data the final estimator sees).
773 ///
774 /// This is the shared `for ...: Xt = transform.transform(Xt)` loop of
775 /// sklearn's `Pipeline.predict` / `predict_proba` / `decision_function` /
776 /// `score` (`sklearn/pipeline.py:599-600`, `:719-720`, `:768-769`,
777 /// `:999-1000`), which run the data through every non-final transformer
778 /// before delegating to the final estimator.
779 ///
780 /// # Errors
781 ///
782 /// Propagates any [`FerroError`] from an individual transformer step.
783 fn transform_through(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
784 let mut current_x = x.clone();
785 for ts in &self.transforms {
786 current_x = ts.step.transform_pipeline(¤t_x)?;
787 }
788 Ok(current_x)
789 }
790
791 /// Apply every fitted transformer step to `x`, returning the transformed
792 /// data without invoking the final estimator.
793 ///
794 /// This mirrors `Pipeline.transform` (`sklearn/pipeline.py:863-904`) for the
795 /// *transformer-final* case. sklearn gates `transform` on
796 /// `_can_transform` (`:858`): it is only available when the final step is
797 /// itself a transformer, in which case it runs the data through ALL steps
798 /// including the last (`for _, name, transform in self._iter(): Xt =
799 /// transform.transform(Xt)`). When the final step is a non-transformer
800 /// estimator (e.g. `GaussianNB`), sklearn raises `AttributeError`
801 /// (`'Pipeline' has no attribute 'transform'`, verified against the live
802 /// 1.5.2 oracle).
803 ///
804 /// ferrolearn's [`FittedPipeline`] structurally separates the transformer
805 /// steps from a single non-transformer estimator slot (the estimator is
806 /// reached via [`predict_pipeline`](FittedPipelineEstimator::predict_pipeline),
807 /// not `transform_pipeline`). Therefore `transform` applies exactly the
808 /// transformer steps and returns the data the final estimator would see —
809 /// equivalent to sklearn's transformer-final `transform` over the
810 /// transformer prefix. The estimator slot is never a transformer, so there
811 /// is no "transform the final step too" branch to mirror.
812 ///
813 /// # Errors
814 ///
815 /// Propagates any [`FerroError`] from a transformer step (e.g. a feature
816 /// count mismatch).
817 pub fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
818 self.transform_through(x)
819 }
820
821 /// Transform `x` through every fitted transformer step, then return the
822 /// final estimator's class-probability estimates, shape
823 /// `(n_samples, n_classes)`.
824 ///
825 /// Mirrors `Pipeline.predict_proba` (`sklearn/pipeline.py:716-721`): run the
826 /// data through every non-final transformer, then
827 /// `self.steps[-1][1].predict_proba(Xt)`.
828 ///
829 /// # Errors
830 ///
831 /// Propagates transformer-step errors; returns [`FerroError::InvalidParameter`]
832 /// if the final estimator does not support `predict_proba` (sklearn's
833 /// `AttributeError` analog).
834 pub fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
835 let xt = self.transform_through(x)?;
836 self.estimator.1.predict_proba_pipeline(&xt)
837 }
838
839 /// Transform `x` through every fitted transformer step, then return the
840 /// final estimator's decision-function scores.
841 ///
842 /// Mirrors `Pipeline.decision_function` (`sklearn/pipeline.py:767-774`): run
843 /// the data through every non-final transformer, then
844 /// `self.steps[-1][1].decision_function(Xt)`.
845 ///
846 /// # Errors
847 ///
848 /// Propagates transformer-step errors; returns [`FerroError::InvalidParameter`]
849 /// if the final estimator does not support `decision_function` (sklearn's
850 /// `AttributeError` analog).
851 pub fn decision_function(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
852 let xt = self.transform_through(x)?;
853 self.estimator.1.decision_function_pipeline(&xt)
854 }
855
856 /// Transform `x` through every fitted transformer step, then return the
857 /// final estimator's score on `(Xt, y)` (e.g. mean accuracy for a
858 /// classifier).
859 ///
860 /// Mirrors `Pipeline.score` (`sklearn/pipeline.py:997-1004`): run the data
861 /// through every non-final transformer, then
862 /// `self.steps[-1][1].score(Xt, y)`. ferrolearn does not yet thread
863 /// `sample_weight` (sklearn's optional third argument, `:961`); that is part
864 /// of the metadata-routing surface (REQ-6, blocker #364).
865 ///
866 /// # Errors
867 ///
868 /// Propagates transformer-step errors; returns [`FerroError::InvalidParameter`]
869 /// if the final estimator does not support `score` (sklearn's
870 /// `AttributeError` analog).
871 pub fn score(&self, x: &Array2<F>, y: &Array1<F>) -> Result<F, FerroError> {
872 let xt = self.transform_through(x)?;
873 self.estimator.1.score_pipeline(&xt, y)
874 }
875}
876
877impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedPipeline<F> {
878 type Output = Array1<F>;
879 type Error = FerroError;
880
881 /// Generate predictions by transforming the input through each fitted
882 /// transformer step, then calling predict on the fitted estimator.
883 ///
884 /// # Errors
885 ///
886 /// Propagates any errors from transformer or estimator steps.
887 fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
888 let current_x = self.transform_through(x)?;
889 self.estimator.1.predict_pipeline(¤t_x)
890 }
891}
892
893// ---------------------------------------------------------------------------
894// PipelineStep: unified interface for the `.step()` builder method
895// ---------------------------------------------------------------------------
896
897/// A trait that unifies transformers and estimators for the
898/// [`Pipeline::step`] builder method.
899///
900/// Implementors of [`PipelineTransformer`] and [`PipelineEstimator`]
901/// automatically get a blanket implementation of this trait via the
902/// wrapper types [`TransformerStepWrapper`] and [`EstimatorStepWrapper`].
903///
904/// For convenience, use [`as_transform_step`] and [`as_estimator_step`]
905/// to wrap your types.
906pub trait PipelineStep<F: Float + Send + Sync + 'static>: Send + Sync {
907 /// Add this step to the pipeline under the given name.
908 ///
909 /// Transformer steps are added as intermediate transform steps.
910 /// Estimator steps are set as the final estimator.
911 fn add_to_pipeline(self: Box<Self>, pipeline: Pipeline<F>, name: &str) -> Pipeline<F>;
912}
913
914/// Wraps a [`PipelineTransformer`] to implement [`PipelineStep`].
915///
916/// Created by [`as_transform_step`].
917pub struct TransformerStepWrapper<F: Float + Send + Sync + 'static>(
918 Box<dyn PipelineTransformer<F>>,
919);
920
921impl<F: Float + Send + Sync + 'static> PipelineStep<F> for TransformerStepWrapper<F> {
922 fn add_to_pipeline(self: Box<Self>, pipeline: Pipeline<F>, name: &str) -> Pipeline<F> {
923 pipeline.transform_step(name, self.0)
924 }
925}
926
927/// Wraps a [`PipelineEstimator`] to implement [`PipelineStep`].
928///
929/// Created by [`as_estimator_step`].
930pub struct EstimatorStepWrapper<F: Float + Send + Sync + 'static>(Box<dyn PipelineEstimator<F>>);
931
932impl<F: Float + Send + Sync + 'static> PipelineStep<F> for EstimatorStepWrapper<F> {
933 fn add_to_pipeline(self: Box<Self>, pipeline: Pipeline<F>, name: &str) -> Pipeline<F> {
934 pipeline.estimator_step(name, self.0)
935 }
936}
937
938/// Wrap a [`PipelineTransformer`] as a [`PipelineStep`] for use with
939/// [`Pipeline::step`].
940///
941/// # Examples
942///
943/// ```
944/// use ferrolearn_core::pipeline::{Pipeline, as_transform_step};
945/// // Assuming `my_scaler` implements PipelineTransformer<f64>:
946/// // let pipeline = Pipeline::new().step("scaler", as_transform_step(my_scaler));
947/// ```
948pub fn as_transform_step<F: Float + Send + Sync + 'static>(
949 t: impl PipelineTransformer<F> + 'static,
950) -> Box<dyn PipelineStep<F>> {
951 Box::new(TransformerStepWrapper(Box::new(t)))
952}
953
954/// Wrap a [`PipelineEstimator`] as a [`PipelineStep`] for use with
955/// [`Pipeline::step`].
956///
957/// # Examples
958///
959/// ```
960/// use ferrolearn_core::pipeline::{Pipeline, as_estimator_step};
961/// // Assuming `my_model` implements PipelineEstimator<f64>:
962/// // let pipeline = Pipeline::new().step("model", as_estimator_step(my_model));
963/// ```
964pub fn as_estimator_step<F: Float + Send + Sync + 'static>(
965 e: impl PipelineEstimator<F> + 'static,
966) -> Box<dyn PipelineStep<F>> {
967 Box::new(EstimatorStepWrapper(Box::new(e)))
968}
969
970// ---------------------------------------------------------------------------
971// PassthroughTransformer: the `'passthrough'` step analog (identity no-op)
972// ---------------------------------------------------------------------------
973
974/// A no-op transformer step: fit does nothing and transform returns its input
975/// unchanged.
976///
977/// This is the ferrolearn analog of scikit-learn's `'passthrough'` (and `None`)
978/// pipeline step. In sklearn, a `Pipeline` step whose object is the string
979/// `'passthrough'` (or `None`) is a transformer that is *skipped* during
980/// fit/transform — `_iter(filter_passthrough=True)` drops it
981/// (`sklearn/pipeline.py:275-290`), so the running `Xt` passes through unchanged
982/// — yet the step is still visible in `named_steps` / `steps` / `__getitem__`
983/// (`sklearn/pipeline.py:337`: `"passthrough" if estimator is None else
984/// estimator`). The net behavior is identity: `Pipeline([('p','passthrough')])
985/// .fit(X).transform(X) == X` (verified against the live 1.5.2 oracle).
986///
987/// ferrolearn encodes the transformer/estimator distinction in the type system
988/// (there is no untyped `steps` list to hold a sentinel string), so rather than a
989/// `filter_passthrough` branch in the fit/transform loop, the passthrough step is
990/// a concrete, reusable *identity transformer*: its `fit_pipeline` is a no-op and
991/// its [`FittedPassthroughTransformer::transform_pipeline`] returns `x.clone()`.
992/// Placed anywhere in a [`Pipeline`] it leaves the running data unchanged and
993/// still appears in [`Pipeline::step_names`] / [`Pipeline::named_steps`], exactly
994/// matching sklearn's observable contract. The ergonomic builder
995/// [`Pipeline::passthrough_step`] adds one under a given name (the `('name',
996/// 'passthrough')` analog).
997///
998/// The type parameter `F` is the float type (`f32` or `f64`), defaulting to
999/// `f64` to match the rest of this module.
1000///
1001/// # Examples
1002///
1003/// ```
1004/// use ferrolearn_core::pipeline::{PassthroughTransformer, FittedPipelineTransformer};
1005/// use ferrolearn_core::pipeline::PipelineTransformer;
1006/// use ndarray::{Array1, Array2};
1007///
1008/// let p = PassthroughTransformer::<f64>::new();
1009/// let x = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1010/// let y = Array1::<f64>::zeros(2);
1011/// let fitted = p.fit_pipeline(&x, &y).unwrap();
1012/// // Identity: transform returns the input unchanged.
1013/// assert_eq!(fitted.transform_pipeline(&x).unwrap(), x);
1014/// ```
1015pub struct PassthroughTransformer<F: Float + Send + Sync + 'static = f64> {
1016 /// `PassthroughTransformer` holds no state; the marker ties the no-op to the
1017 /// float type `F` so it slots into an `F`-typed [`Pipeline`].
1018 _marker: core::marker::PhantomData<F>,
1019}
1020
1021impl<F: Float + Send + Sync + 'static> PassthroughTransformer<F> {
1022 /// Create a new passthrough (identity) transformer.
1023 ///
1024 /// # Examples
1025 ///
1026 /// ```
1027 /// use ferrolearn_core::pipeline::PassthroughTransformer;
1028 /// let p = PassthroughTransformer::<f64>::new();
1029 /// ```
1030 #[must_use]
1031 pub fn new() -> Self {
1032 Self {
1033 _marker: core::marker::PhantomData,
1034 }
1035 }
1036}
1037
1038impl<F: Float + Send + Sync + 'static> Default for PassthroughTransformer<F> {
1039 fn default() -> Self {
1040 Self::new()
1041 }
1042}
1043
1044impl<F: Float + Send + Sync + 'static> PipelineTransformer<F> for PassthroughTransformer<F> {
1045 /// Fitting a passthrough step does nothing (there are no parameters to learn);
1046 /// it yields a [`FittedPassthroughTransformer`] whose transform is the
1047 /// identity. Mirrors sklearn skipping a `'passthrough'` step at fit
1048 /// (`_iter(filter_passthrough=True)`, `sklearn/pipeline.py:289`), so the
1049 /// running `Xt` is unaffected.
1050 fn fit_pipeline(
1051 &self,
1052 _x: &Array2<F>,
1053 _y: &Array1<F>,
1054 ) -> Result<Box<dyn FittedPipelineTransformer<F>>, FerroError> {
1055 Ok(Box::new(FittedPassthroughTransformer::new()))
1056 }
1057}
1058
1059/// The fitted half of a [`PassthroughTransformer`]: an identity transform.
1060///
1061/// [`transform_pipeline`](FittedPassthroughTransformer::transform_pipeline)
1062/// returns its input unchanged, the fitted analog of sklearn's skipped
1063/// `'passthrough'` step leaving the running `Xt` unchanged
1064/// (`sklearn/pipeline.py:275-290`).
1065pub struct FittedPassthroughTransformer<F: Float + Send + Sync + 'static = f64> {
1066 /// No fitted state; the marker ties the identity transform to `F`.
1067 _marker: core::marker::PhantomData<F>,
1068}
1069
1070impl<F: Float + Send + Sync + 'static> FittedPassthroughTransformer<F> {
1071 /// Create a new fitted passthrough (identity) transformer.
1072 #[must_use]
1073 pub fn new() -> Self {
1074 Self {
1075 _marker: core::marker::PhantomData,
1076 }
1077 }
1078}
1079
1080impl<F: Float + Send + Sync + 'static> Default for FittedPassthroughTransformer<F> {
1081 fn default() -> Self {
1082 Self::new()
1083 }
1084}
1085
1086impl<F: Float + Send + Sync + 'static> FittedPipelineTransformer<F>
1087 for FittedPassthroughTransformer<F>
1088{
1089 /// Return the input unchanged (identity).
1090 ///
1091 /// This is the no-op that makes a passthrough step transparent: the data the
1092 /// next step (or final estimator) sees is exactly what entered. Matches
1093 /// sklearn's `'passthrough'` net behavior `Pipeline([('p','passthrough')])
1094 /// .transform(X) == X` (live 1.5.2 oracle).
1095 fn transform_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1096 Ok(x.clone())
1097 }
1098}
1099
1100// ---------------------------------------------------------------------------
1101// FeatureUnion (unfitted)
1102// ---------------------------------------------------------------------------
1103
1104/// A composite transformer that fits multiple named sub-transformers on the
1105/// SAME input and horizontally concatenates their outputs.
1106///
1107/// This is the ferrolearn analog of scikit-learn's `sklearn.pipeline.FeatureUnion`
1108/// (`sklearn/pipeline.py:1329`). Where a [`Pipeline`] chains transformers
1109/// *sequentially* (each transformer sees the previous one's output),
1110/// `FeatureUnion` applies every transformer *in parallel* to the same `X`, then
1111/// concatenates the results column-wise: the output width is the sum of each
1112/// sub-transformer's output width, and the columns appear left-to-right in the
1113/// order the transformers were added (mirrors `FeatureUnion.transform` →
1114/// `_hstack` `np.hstack(Xs)`, `sklearn/pipeline.py:1770`/`:1812`).
1115///
1116/// `FeatureUnion` reuses the [`PipelineTransformer`] / [`FittedPipelineTransformer`]
1117/// trait objects already used by [`Pipeline`], so any transformer usable in a
1118/// pipeline is usable in a feature union.
1119///
1120/// The type parameter `F` is the float type (`f32` or `f64`), defaulting to
1121/// `f64` to match the rest of this module.
1122///
1123/// # Divergence from scikit-learn
1124///
1125/// This is the core fit / transform / hstack / `get_feature_names_out` subset.
1126/// `transformer_weights` (per-transformer output scaling,
1127/// `sklearn/pipeline.py:1369`), the `'drop'` / `'passthrough'` sentinels
1128/// (`:1530`/`:1563`), `n_jobs` parallelism (`:1360`), metadata routing (`:1859`),
1129/// and `verbose_feature_names_out=False` (`:1618`) are NOT implemented (REQ-8
1130/// NOT-STARTED scope). The data substrate is `ndarray`, not yet ferray.
1131///
1132/// # Examples
1133///
1134/// ```
1135/// use ferrolearn_core::pipeline::{
1136/// FeatureUnion, PipelineTransformer, FittedPipelineTransformer,
1137/// };
1138/// use ferrolearn_core::{Transform, FerroError};
1139/// use ndarray::{Array1, Array2};
1140///
1141/// // A transformer that returns its input unchanged.
1142/// struct Identity;
1143/// impl PipelineTransformer<f64> for Identity {
1144/// fn fit_pipeline(
1145/// &self,
1146/// _x: &Array2<f64>,
1147/// _y: &Array1<f64>,
1148/// ) -> Result<Box<dyn FittedPipelineTransformer<f64>>, FerroError> {
1149/// Ok(Box::new(FittedIdentity))
1150/// }
1151/// }
1152/// struct FittedIdentity;
1153/// impl FittedPipelineTransformer<f64> for FittedIdentity {
1154/// fn transform_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
1155/// Ok(x.clone())
1156/// }
1157/// }
1158///
1159/// use ferrolearn_core::Fit;
1160/// let union = FeatureUnion::<f64>::new()
1161/// .with_transformer("a", Box::new(Identity))
1162/// .with_transformer("b", Box::new(Identity));
1163/// let x = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1164/// let fitted = union.fit(&x, &()).unwrap();
1165/// // Two identity transformers → output width 2 + 2 = 4.
1166/// let out = fitted.transform(&x).unwrap();
1167/// assert_eq!(out.dim(), (2, 4));
1168/// assert_eq!(fitted.get_feature_names_out(), vec!["a__x0", "a__x1", "b__x0", "b__x1"]);
1169/// ```
1170pub struct FeatureUnion<F: Float + Send + Sync + 'static = f64> {
1171 /// Ordered named transformers, all fit on the same input.
1172 transformer_list: Vec<(String, Box<dyn PipelineTransformer<F>>)>,
1173}
1174
1175impl<F: Float + Send + Sync + 'static> FeatureUnion<F> {
1176 /// Create a new empty feature union.
1177 ///
1178 /// Sub-transformers are added with
1179 /// [`with_transformer`](FeatureUnion::with_transformer). An empty union fits
1180 /// successfully and transforms to a `(n_samples, 0)` matrix (the empty
1181 /// `np.hstack` analog).
1182 ///
1183 /// # Examples
1184 ///
1185 /// ```
1186 /// use ferrolearn_core::pipeline::FeatureUnion;
1187 /// let union = FeatureUnion::<f64>::new();
1188 /// assert_eq!(union.n_transformers(), 0);
1189 /// ```
1190 #[must_use]
1191 pub fn new() -> Self {
1192 Self {
1193 transformer_list: Vec::new(),
1194 }
1195 }
1196
1197 /// Add a named transformer to the union using the builder pattern.
1198 ///
1199 /// Mirrors an entry of sklearn's `transformer_list`
1200 /// (`sklearn/pipeline.py:1348`). Transformers are applied in the order they
1201 /// are added; their outputs are concatenated left-to-right.
1202 #[must_use]
1203 pub fn with_transformer(mut self, name: &str, t: Box<dyn PipelineTransformer<F>>) -> Self {
1204 self.transformer_list.push((name.to_owned(), t));
1205 self
1206 }
1207
1208 /// Returns the names of all sub-transformers, in union order.
1209 ///
1210 /// Mirrors the key order of sklearn's `named_transformers`
1211 /// (`sklearn/pipeline.py:1478`: `Bunch(**dict(self.transformer_list))`).
1212 #[must_use]
1213 pub fn transformer_names(&self) -> Vec<&str> {
1214 self.transformer_list
1215 .iter()
1216 .map(|(name, _)| name.as_str())
1217 .collect()
1218 }
1219
1220 /// Number of sub-transformers in the union.
1221 #[must_use]
1222 pub fn n_transformers(&self) -> usize {
1223 self.transformer_list.len()
1224 }
1225}
1226
1227impl<F: Float + Send + Sync + 'static> Default for FeatureUnion<F> {
1228 fn default() -> Self {
1229 Self::new()
1230 }
1231}
1232
1233impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, ()> for FeatureUnion<F> {
1234 type Fitted = FittedFeatureUnion<F>;
1235 type Error = FerroError;
1236
1237 /// Fit every sub-transformer on the SAME input `x`.
1238 ///
1239 /// Mirrors `FeatureUnion.fit` (`sklearn/pipeline.py:1643`), which fits each
1240 /// transformer in `transformer_list` independently on the full `X` (every
1241 /// transformer sees the same data, unlike the sequential `Pipeline`). The
1242 /// per-transformer output width is recorded at fit time (by transforming `x`
1243 /// once) so that `get_feature_names_out` can size each column block.
1244 ///
1245 /// # `y` handling
1246 ///
1247 /// sklearn's `FeatureUnion` threads `y` to each sub-transformer's `fit`
1248 /// (`sklearn/pipeline.py:1681`/`_fit_one`), but feature-union transformers are
1249 /// unsupervised and ignore it. ferrolearn's [`PipelineTransformer::fit_pipeline`]
1250 /// requires an `Array1<F>` target, so this impl passes an empty
1251 /// `Array1::zeros(0)` — the union's own `Fit` target type is `()` (it takes no
1252 /// supervised target), and the empty array is the no-target sentinel handed to
1253 /// each unsupervised sub-transformer.
1254 ///
1255 /// # Errors
1256 ///
1257 /// Propagates any [`FerroError`] from an individual sub-transformer's
1258 /// `fit_pipeline` or its width-probing `transform_pipeline`.
1259 fn fit(&self, x: &Array2<F>, _y: &()) -> Result<FittedFeatureUnion<F>, FerroError> {
1260 // Validate transformer-name uniqueness BEFORE fitting any sub-transformer,
1261 // mirroring `FeatureUnion._validate_transformers` → `_validate_names`
1262 // (`sklearn/pipeline.py:1523-1525` → `sklearn/utils/metaestimators.py:81-83`),
1263 // which sklearn runs on every fit/fit_transform: `if len(set(names)) !=
1264 // len(names): raise ValueError("Names provided are not unique: {names!r}")`.
1265 // R-DEV-2 (user-API ABI / exception parity): a duplicate name is a
1266 // deliberate `ValueError`, so ferrolearn rejects it at fit with the
1267 // closest analog, `FerroError::InvalidParameter`.
1268 let names: Vec<&str> = self
1269 .transformer_list
1270 .iter()
1271 .map(|(name, _)| name.as_str())
1272 .collect();
1273 let mut seen = std::collections::HashSet::with_capacity(names.len());
1274 if !names.iter().all(|name| seen.insert(*name)) {
1275 return Err(FerroError::InvalidParameter {
1276 name: "transformer_list".into(),
1277 reason: format!("Names provided are not unique: {names:?}"),
1278 });
1279 }
1280
1281 // Reject any name containing the reserved `__` separator, mirroring the
1282 // THIRD clause of `_validate_names`
1283 // (`sklearn/utils/metaestimators.py:91-95`): `invalid_names = [name for
1284 // name in names if "__" in name]; if invalid_names: raise
1285 // ValueError("Estimator names must not contain __: got {0!r}")`. `__` is
1286 // reserved for the nested-parameter addressing protocol
1287 // (`<step>__<param>`), so it is forbidden anywhere in a step name (a
1288 // single `_` is fine). sklearn runs this AFTER the uniqueness clause; we
1289 // match that order. R-DEV-2 (exception parity): a deliberate `ValueError`,
1290 // mapped to the closest analog `FerroError::InvalidParameter`. (The MIDDLE
1291 // clause — names colliding with constructor-arg params,
1292 // `metaestimators.py:84-90` — has no ferrolearn analog: `FeatureUnion`
1293 // exposes no `get_params` params, so it is intentionally not mirrored.)
1294 let invalid_names: Vec<&str> = names
1295 .iter()
1296 .copied()
1297 .filter(|name| name.contains("__"))
1298 .collect();
1299 if !invalid_names.is_empty() {
1300 return Err(FerroError::InvalidParameter {
1301 name: "transformer_list".into(),
1302 reason: format!("Estimator names must not contain __: got {invalid_names:?}"),
1303 });
1304 }
1305
1306 // FeatureUnion sub-transformers are unsupervised; sklearn passes `y`
1307 // through but the transformers ignore it (`sklearn/pipeline.py:1681`).
1308 // The empty target is the no-supervision sentinel for `fit_pipeline`.
1309 let empty_y: Array1<F> = Array1::zeros(0);
1310
1311 let mut fitted = Vec::with_capacity(self.transformer_list.len());
1312 let mut n_features_per = Vec::with_capacity(self.transformer_list.len());
1313
1314 for (name, transformer) in &self.transformer_list {
1315 let fitted_t = transformer.fit_pipeline(x, &empty_y)?;
1316 // Probe the output width once at fit so feature-name prefixing and
1317 // the hstack column layout know each block's size.
1318 let out = fitted_t.transform_pipeline(x)?;
1319 n_features_per.push(out.ncols());
1320 fitted.push((name.clone(), fitted_t));
1321 }
1322
1323 Ok(FittedFeatureUnion {
1324 fitted,
1325 n_features_per,
1326 })
1327 }
1328}
1329
1330// ---------------------------------------------------------------------------
1331// FittedFeatureUnion
1332// ---------------------------------------------------------------------------
1333
1334/// A fitted [`FeatureUnion`]: each named sub-transformer is fitted, and the
1335/// per-transformer output width is recorded for feature-name prefixing and the
1336/// horizontal-concatenation column layout.
1337///
1338/// Created by calling [`Fit::fit`] on a [`FeatureUnion`]. Implements
1339/// [`Transform<Array2<F>>`](Transform) producing the horizontally concatenated
1340/// `Array2<F>`.
1341pub struct FittedFeatureUnion<F: Float + Send + Sync + 'static = f64> {
1342 /// Fitted sub-transformers, in union order.
1343 fitted: Vec<(String, Box<dyn FittedPipelineTransformer<F>>)>,
1344 /// The output column count of each sub-transformer, in union order
1345 /// (recorded at fit). The total output width is the sum of these.
1346 n_features_per: Vec<usize>,
1347}
1348
1349impl<F: Float + Send + Sync + 'static> FittedFeatureUnion<F> {
1350 /// Returns the names of all fitted sub-transformers, in union order.
1351 #[must_use]
1352 pub fn transformer_names(&self) -> Vec<&str> {
1353 self.fitted.iter().map(|(name, _)| name.as_str()).collect()
1354 }
1355
1356 /// Number of fitted sub-transformers in the union.
1357 #[must_use]
1358 pub fn n_transformers(&self) -> usize {
1359 self.fitted.len()
1360 }
1361
1362 /// Total output width: the sum of every sub-transformer's output column
1363 /// count. Equals the number of columns in [`Transform::transform`]'s output.
1364 #[must_use]
1365 pub fn n_features_out(&self) -> usize {
1366 self.n_features_per.iter().sum()
1367 }
1368
1369 /// Output feature names, one per output column, in concatenation order.
1370 ///
1371 /// For each sub-transformer named `name` with output width `w`, this emits
1372 /// `"{name}__x0" .. "{name}__x{w-1}"`, then moves on to the next
1373 /// transformer's block. This mirrors `FeatureUnion.get_feature_names_out`
1374 /// with the default `verbose_feature_names_out=True`
1375 /// (`sklearn/pipeline.py:1567`/`:1608-1616`): sklearn prefixes each
1376 /// sub-transformer's own feature name with `"{name}__"`.
1377 ///
1378 /// ferrolearn's [`PipelineTransformer`] trait objects do not expose their own
1379 /// per-output feature names, so the positional default `x{j}` is used as the
1380 /// suffix — this is sklearn's `OneToOneFeatureMixin` positional default
1381 /// (`['x0','x1',...]`), which is exactly what `StandardScaler` /
1382 /// `MinMaxScaler` and other column-preserving transformers produce. So a union
1383 /// of two such transformers named `ss`/`mm` over 2-column input yields
1384 /// `['ss__x0','ss__x1','mm__x0','mm__x1']`, matching the live oracle.
1385 #[must_use]
1386 pub fn get_feature_names_out(&self) -> Vec<String> {
1387 let mut names = Vec::with_capacity(self.n_features_out());
1388 for ((name, _), &width) in self.fitted.iter().zip(self.n_features_per.iter()) {
1389 for j in 0..width {
1390 names.push(format!("{name}__x{j}"));
1391 }
1392 }
1393 names
1394 }
1395}
1396
1397impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for FittedFeatureUnion<F> {
1398 type Output = Array2<F>;
1399 type Error = FerroError;
1400
1401 /// Transform `x` through every fitted sub-transformer and horizontally
1402 /// concatenate the results.
1403 ///
1404 /// Mirrors `FeatureUnion.transform` (`sklearn/pipeline.py:1770`): each
1405 /// transformer transforms the same `x`, then `self._hstack(Xs)`
1406 /// (`np.hstack`, `:1812`/`:1820`) concatenates the outputs column-wise. The
1407 /// output has shape `(n_samples, sum_of_widths)` and the columns appear in
1408 /// transformer order: block 0 is the first transformer's full output, block 1
1409 /// the second's, and so on. An empty union transforms to a `(n_samples, 0)`
1410 /// matrix (the empty-`np.hstack` analog).
1411 ///
1412 /// # Errors
1413 ///
1414 /// Propagates any [`FerroError`] from an individual sub-transformer. Returns
1415 /// [`FerroError::ShapeMismatch`] if a sub-transformer's output does not have
1416 /// `n_samples == x.nrows()` rows (the hstack requires row-aligned blocks).
1417 fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1418 let n_rows = x.nrows();
1419
1420 // Transform `x` through each sub-transformer, collecting the blocks and
1421 // their widths. Validate each block is row-aligned before any copy.
1422 let mut blocks: Vec<Array2<F>> = Vec::with_capacity(self.fitted.len());
1423 let mut total_width = 0usize;
1424 for (name, transformer) in &self.fitted {
1425 let block = transformer.transform_pipeline(x)?;
1426 if block.nrows() != n_rows {
1427 return Err(FerroError::ShapeMismatch {
1428 expected: vec![n_rows, block.ncols()],
1429 actual: vec![block.nrows(), block.ncols()],
1430 context: format!(
1431 "FeatureUnion transformer `{name}` produced {} rows, expected {n_rows} \
1432 (every sub-transformer output must be row-aligned for hstack)",
1433 block.nrows()
1434 ),
1435 });
1436 }
1437 total_width += block.ncols();
1438 blocks.push(block);
1439 }
1440
1441 // Allocate the concatenated output and copy each block into its
1442 // contiguous column range, left-to-right (bounds-safe: `col_offset` and
1443 // each block width are derived from the blocks just collected).
1444 let mut out = Array2::<F>::zeros((n_rows, total_width));
1445 let mut col_offset = 0usize;
1446 for block in &blocks {
1447 let width = block.ncols();
1448 for r in 0..n_rows {
1449 for c in 0..width {
1450 out[[r, col_offset + c]] = block[[r, c]];
1451 }
1452 }
1453 col_offset += width;
1454 }
1455
1456 Ok(out)
1457 }
1458}
1459
1460// ---------------------------------------------------------------------------
1461// Tests
1462// ---------------------------------------------------------------------------
1463
1464#[cfg(test)]
1465mod tests {
1466 use super::*;
1467
1468 // -- Test fixtures -------------------------------------------------------
1469
1470 /// A trivial transformer that doubles all values.
1471 struct DoublingTransformer;
1472
1473 impl PipelineTransformer<f64> for DoublingTransformer {
1474 fn fit_pipeline(
1475 &self,
1476 _x: &Array2<f64>,
1477 _y: &Array1<f64>,
1478 ) -> Result<Box<dyn FittedPipelineTransformer<f64>>, FerroError> {
1479 Ok(Box::new(FittedDoublingTransformer))
1480 }
1481 }
1482
1483 struct FittedDoublingTransformer;
1484
1485 impl FittedPipelineTransformer<f64> for FittedDoublingTransformer {
1486 fn transform_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
1487 Ok(x.mapv(|v| v * 2.0))
1488 }
1489 }
1490
1491 /// A trivial estimator that sums each row.
1492 struct SumEstimator;
1493
1494 impl PipelineEstimator<f64> for SumEstimator {
1495 fn fit_pipeline(
1496 &self,
1497 _x: &Array2<f64>,
1498 _y: &Array1<f64>,
1499 ) -> Result<Box<dyn FittedPipelineEstimator<f64>>, FerroError> {
1500 Ok(Box::new(FittedSumEstimator))
1501 }
1502 }
1503
1504 struct FittedSumEstimator;
1505
1506 impl FittedPipelineEstimator<f64> for FittedSumEstimator {
1507 fn predict_pipeline(&self, x: &Array2<f64>) -> Result<Array1<f64>, FerroError> {
1508 let sums: Vec<f64> = x.rows().into_iter().map(|row| row.sum()).collect();
1509 Ok(Array1::from_vec(sums))
1510 }
1511 }
1512
1513 // -- f32 test fixtures ---------------------------------------------------
1514
1515 /// A trivial f32 transformer that doubles all values.
1516 struct DoublingTransformerF32;
1517
1518 impl PipelineTransformer<f32> for DoublingTransformerF32 {
1519 fn fit_pipeline(
1520 &self,
1521 _x: &Array2<f32>,
1522 _y: &Array1<f32>,
1523 ) -> Result<Box<dyn FittedPipelineTransformer<f32>>, FerroError> {
1524 Ok(Box::new(FittedDoublingTransformerF32))
1525 }
1526 }
1527
1528 struct FittedDoublingTransformerF32;
1529
1530 impl FittedPipelineTransformer<f32> for FittedDoublingTransformerF32 {
1531 fn transform_pipeline(&self, x: &Array2<f32>) -> Result<Array2<f32>, FerroError> {
1532 Ok(x.mapv(|v| v * 2.0))
1533 }
1534 }
1535
1536 /// A trivial f32 estimator that sums each row.
1537 struct SumEstimatorF32;
1538
1539 impl PipelineEstimator<f32> for SumEstimatorF32 {
1540 fn fit_pipeline(
1541 &self,
1542 _x: &Array2<f32>,
1543 _y: &Array1<f32>,
1544 ) -> Result<Box<dyn FittedPipelineEstimator<f32>>, FerroError> {
1545 Ok(Box::new(FittedSumEstimatorF32))
1546 }
1547 }
1548
1549 struct FittedSumEstimatorF32;
1550
1551 impl FittedPipelineEstimator<f32> for FittedSumEstimatorF32 {
1552 fn predict_pipeline(&self, x: &Array2<f32>) -> Result<Array1<f32>, FerroError> {
1553 let sums: Vec<f32> = x.rows().into_iter().map(|row| row.sum()).collect();
1554 Ok(Array1::from_vec(sums))
1555 }
1556 }
1557
1558 // -- Tests ---------------------------------------------------------------
1559
1560 #[test]
1561 fn test_pipeline_fit_predict() {
1562 let pipeline = Pipeline::new()
1563 .transform_step("doubler", Box::new(DoublingTransformer))
1564 .estimator_step("sum", Box::new(SumEstimator));
1565
1566 let x = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1567 let y = Array1::from_vec(vec![0.0, 1.0]);
1568
1569 let fitted = pipeline.fit(&x, &y).unwrap();
1570 let preds = fitted.predict(&x).unwrap();
1571
1572 // After doubling: [[2,4,6],[8,10,12]], sums: [12, 30]
1573 assert_eq!(preds.len(), 2);
1574 assert!((preds[0] - 12.0).abs() < 1e-10);
1575 assert!((preds[1] - 30.0).abs() < 1e-10);
1576 }
1577
1578 #[test]
1579 fn test_pipeline_f32_fit_predict() {
1580 let pipeline = Pipeline::<f32>::new()
1581 .transform_step("doubler", Box::new(DoublingTransformerF32))
1582 .estimator_step("sum", Box::new(SumEstimatorF32));
1583
1584 let x = Array2::from_shape_vec((2, 3), vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1585 let y = Array1::from_vec(vec![0.0f32, 1.0]);
1586
1587 let fitted = pipeline.fit(&x, &y).unwrap();
1588 let preds = fitted.predict(&x).unwrap();
1589
1590 assert_eq!(preds.len(), 2);
1591 assert!((preds[0] - 12.0).abs() < 1e-5);
1592 assert!((preds[1] - 30.0).abs() < 1e-5);
1593 }
1594
1595 #[test]
1596 fn test_pipeline_step_builder() {
1597 let pipeline = Pipeline::new()
1598 .step("doubler", as_transform_step(DoublingTransformer))
1599 .step("sum", as_estimator_step(SumEstimator));
1600
1601 let x = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1602 let y = Array1::from_vec(vec![0.0, 1.0]);
1603
1604 let fitted = pipeline.fit(&x, &y).unwrap();
1605 let preds = fitted.predict(&x).unwrap();
1606
1607 assert!((preds[0] - 12.0).abs() < 1e-10);
1608 assert!((preds[1] - 30.0).abs() < 1e-10);
1609 }
1610
1611 #[test]
1612 fn test_pipeline_rejects_inconsistent_x_y() {
1613 // sklearn's Pipeline.fit validates X/y consistency before fitting any
1614 // step (check_consistent_length, validation.py:1320): a mismatched
1615 // n_samples raises ValueError. Live oracle:
1616 // from sklearn.pipeline import Pipeline
1617 // from sklearn.preprocessing import StandardScaler
1618 // from sklearn.naive_bayes import GaussianNB; import numpy as np
1619 // p = Pipeline([("s", StandardScaler()), ("c", GaussianNB())])
1620 // try: p.fit(np.zeros((3,2)), np.zeros(4)); print("OK")
1621 // except ValueError: print("RAISE") # -> RAISE
1622 let pipeline = Pipeline::new()
1623 .transform_step("doubler", Box::new(DoublingTransformer))
1624 .estimator_step("sum", Box::new(SumEstimator));
1625 let x = Array2::<f64>::zeros((3, 2));
1626 let y = Array1::from_vec(vec![0.0, 1.0]); // len 2 != 3 rows
1627 let result = pipeline.fit(&x, &y);
1628 assert!(matches!(result, Err(FerroError::ShapeMismatch { .. })));
1629 }
1630
1631 #[test]
1632 fn test_pipeline_accepts_consistent_x_y() -> Result<(), FerroError> {
1633 // The guard must not reject well-formed X/y (live oracle: same Pipeline
1634 // with matching shapes -> OK).
1635 let pipeline = Pipeline::new()
1636 .transform_step("doubler", Box::new(DoublingTransformer))
1637 .estimator_step("sum", Box::new(SumEstimator));
1638 let x =
1639 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).map_err(|e| {
1640 FerroError::InvalidParameter {
1641 name: "x".into(),
1642 reason: e.to_string(),
1643 }
1644 })?;
1645 let y = Array1::from_vec(vec![0.0, 1.0]);
1646 let fitted = pipeline.fit(&x, &y)?;
1647 assert_eq!(fitted.predict(&x)?.len(), 2);
1648 Ok(())
1649 }
1650
1651 #[test]
1652 fn test_pipeline_no_estimator_returns_error() {
1653 let pipeline = Pipeline::new().transform_step("doubler", Box::new(DoublingTransformer));
1654
1655 let x = Array2::<f64>::zeros((2, 3));
1656 let y = Array1::from_vec(vec![0.0, 1.0]);
1657
1658 let result = pipeline.fit(&x, &y);
1659 assert!(result.is_err());
1660 }
1661
1662 #[test]
1663 fn test_pipeline_estimator_only() {
1664 let pipeline = Pipeline::new().estimator_step("sum", Box::new(SumEstimator));
1665
1666 let x = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1667 let y = Array1::from_vec(vec![0.0, 1.0]);
1668
1669 let fitted = pipeline.fit(&x, &y).unwrap();
1670 let preds = fitted.predict(&x).unwrap();
1671
1672 // No transform, just sum: [6, 15]
1673 assert!((preds[0] - 6.0).abs() < 1e-10);
1674 assert!((preds[1] - 15.0).abs() < 1e-10);
1675 }
1676
1677 #[test]
1678 fn test_fitted_pipeline_step_names() {
1679 let pipeline = Pipeline::new()
1680 .transform_step("scaler", Box::new(DoublingTransformer))
1681 .transform_step("normalizer", Box::new(DoublingTransformer))
1682 .estimator_step("clf", Box::new(SumEstimator));
1683
1684 let x = Array2::<f64>::zeros((2, 3));
1685 let y = Array1::from_vec(vec![0.0, 1.0]);
1686
1687 let fitted = pipeline.fit(&x, &y).unwrap();
1688 let names = fitted.step_names();
1689 assert_eq!(names, vec!["scaler", "normalizer", "clf"]);
1690 }
1691
1692 #[test]
1693 fn test_multiple_transform_steps() {
1694 // Two doublers in sequence should quadruple values.
1695 let pipeline = Pipeline::new()
1696 .transform_step("double1", Box::new(DoublingTransformer))
1697 .transform_step("double2", Box::new(DoublingTransformer))
1698 .estimator_step("sum", Box::new(SumEstimator));
1699
1700 let x = Array2::from_shape_vec((1, 2), vec![1.0, 1.0]).unwrap();
1701 let y = Array1::from_vec(vec![0.0]);
1702
1703 let fitted = pipeline.fit(&x, &y).unwrap();
1704 let preds = fitted.predict(&x).unwrap();
1705
1706 // 1.0 * 2 * 2 = 4.0 per element, sum of 2 elements = 8.0
1707 assert!((preds[0] - 8.0).abs() < 1e-10);
1708 }
1709
1710 #[test]
1711 fn test_pipeline_default() {
1712 let pipeline = Pipeline::<f64>::default();
1713 let x = Array2::<f64>::zeros((2, 3));
1714 let y = Array1::from_vec(vec![0.0, 1.0]);
1715 // Should error because no estimator.
1716 assert!(pipeline.fit(&x, &y).is_err());
1717 }
1718
1719 #[test]
1720 fn test_pipeline_is_send_sync() {
1721 fn assert_send_sync<T: Send + Sync>() {}
1722 // Pipeline itself is Send+Sync because it only stores
1723 // Send+Sync trait objects.
1724 assert_send_sync::<Pipeline<f64>>();
1725 assert_send_sync::<Pipeline<f32>>();
1726 assert_send_sync::<FittedPipeline<f64>>();
1727 assert_send_sync::<FittedPipeline<f32>>();
1728 }
1729
1730 // -- REQ-3: fit_transform / transform / predict_proba / decision_function /
1731 // score ---------------------------------------------------------------
1732
1733 /// An estimator that overrides the probability/decision/score delegations,
1734 /// proving the new default-Err trait methods can be overridden by a real
1735 /// final estimator (mirrors how `GaussianNB` does so in `gaussian.rs`).
1736 struct ProbaEstimator;
1737
1738 impl PipelineEstimator<f64> for ProbaEstimator {
1739 fn fit_pipeline(
1740 &self,
1741 _x: &Array2<f64>,
1742 _y: &Array1<f64>,
1743 ) -> Result<Box<dyn FittedPipelineEstimator<f64>>, FerroError> {
1744 Ok(Box::new(FittedProbaEstimator))
1745 }
1746 }
1747
1748 struct FittedProbaEstimator;
1749
1750 impl FittedPipelineEstimator<f64> for FittedProbaEstimator {
1751 fn predict_pipeline(&self, x: &Array2<f64>) -> Result<Array1<f64>, FerroError> {
1752 // Predict 1.0 when the row sum is positive, else 0.0.
1753 Ok(Array1::from_iter(
1754 x.rows()
1755 .into_iter()
1756 .map(|r| if r.sum() > 0.0 { 1.0 } else { 0.0 }),
1757 ))
1758 }
1759
1760 fn predict_proba_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
1761 // A deterministic two-column "probability" (sigmoid of row sum).
1762 let mut out = Array2::<f64>::zeros((x.nrows(), 2));
1763 for (i, r) in x.rows().into_iter().enumerate() {
1764 let p1 = 1.0 / (1.0 + (-r.sum()).exp());
1765 out[[i, 0]] = 1.0 - p1;
1766 out[[i, 1]] = p1;
1767 }
1768 Ok(out)
1769 }
1770
1771 fn score_pipeline(&self, x: &Array2<f64>, y: &Array1<f64>) -> Result<f64, FerroError> {
1772 let preds = self.predict_pipeline(x)?;
1773 let n = y.len();
1774 if n == 0 {
1775 return Ok(0.0);
1776 }
1777 let correct = preds
1778 .iter()
1779 .zip(y.iter())
1780 .filter(|(p, t)| (**p - **t).abs() < 1e-12)
1781 .count();
1782 Ok(correct as f64 / n as f64)
1783 }
1784 }
1785
1786 #[test]
1787 fn test_pipeline_fit_transform_equals_transform() -> Result<(), FerroError> {
1788 // fit_transform must return exactly what FittedPipeline::transform
1789 // returns on the same input (fit-then-transform ≡ fused fit_transform).
1790 let pipeline = Pipeline::new()
1791 .transform_step("double1", Box::new(DoublingTransformer))
1792 .transform_step("double2", Box::new(DoublingTransformer))
1793 .estimator_step("sum", Box::new(SumEstimator));
1794
1795 let x = ndarray::array![[1.0, 2.0], [3.0, 4.0]];
1796 let y = Array1::from_vec(vec![0.0, 1.0]);
1797
1798 let (fitted, xt) = pipeline.fit_transform(&x, &y)?;
1799 // Two doublers quadruple the data.
1800 let expected = x.mapv(|v| v * 4.0);
1801 assert_eq!(xt, expected);
1802 // transform() on the fitted pipeline matches fit_transform's output.
1803 let xt2 = fitted.transform(&x)?;
1804 assert_eq!(xt2, expected);
1805 Ok(())
1806 }
1807
1808 #[test]
1809 fn test_pipeline_transform_applies_only_transformer_steps() -> Result<(), FerroError> {
1810 // FittedPipeline::transform returns the data the estimator would see —
1811 // i.e. only the transformer prefix is applied, not the estimator.
1812 let pipeline = Pipeline::new()
1813 .transform_step("doubler", Box::new(DoublingTransformer))
1814 .estimator_step("sum", Box::new(SumEstimator));
1815 let x = ndarray::array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
1816 let y = Array1::from_vec(vec![0.0, 1.0]);
1817 let fitted = pipeline.fit(&x, &y)?;
1818 let xt = fitted.transform(&x)?;
1819 assert_eq!(xt, x.mapv(|v| v * 2.0));
1820 Ok(())
1821 }
1822
1823 #[test]
1824 fn test_pipeline_predict_proba_default_is_err() -> Result<(), FerroError> {
1825 // SumEstimator does not override predict_proba_pipeline → the default
1826 // Err (sklearn AttributeError analog) fires.
1827 let pipeline = Pipeline::new()
1828 .transform_step("doubler", Box::new(DoublingTransformer))
1829 .estimator_step("sum", Box::new(SumEstimator));
1830 let x = ndarray::array![[1.0, 1.0]];
1831 let y = Array1::from_vec(vec![0.0]);
1832 let fitted = pipeline.fit(&x, &y)?;
1833 assert!(matches!(
1834 fitted.predict_proba(&x),
1835 Err(FerroError::InvalidParameter { .. })
1836 ));
1837 assert!(matches!(
1838 fitted.decision_function(&x),
1839 Err(FerroError::InvalidParameter { .. })
1840 ));
1841 assert!(matches!(
1842 fitted.score(&x, &y),
1843 Err(FerroError::InvalidParameter { .. })
1844 ));
1845 Ok(())
1846 }
1847
1848 #[test]
1849 fn test_pipeline_predict_proba_and_score_override() -> Result<(), FerroError> {
1850 // ProbaEstimator overrides the delegations. The transformer doubles the
1851 // data; the proba estimator sees the doubled rows.
1852 let pipeline = Pipeline::new()
1853 .transform_step("doubler", Box::new(DoublingTransformer))
1854 .estimator_step("clf", Box::new(ProbaEstimator));
1855 let x = ndarray::array![[1.0], [-2.0]];
1856 let y = Array1::from_vec(vec![1.0, 0.0]);
1857 let fitted = pipeline.fit(&x, &y)?;
1858
1859 // Doubled rows: [2.0], [-4.0]. p1 = sigmoid(row sum).
1860 let proba = fitted.predict_proba(&x)?;
1861 assert_eq!(proba.dim(), (2, 2));
1862 for i in 0..2 {
1863 assert!((proba.row(i).sum() - 1.0).abs() < 1e-12);
1864 }
1865 let p1_row0 = 1.0 / (1.0 + (-2.0f64).exp());
1866 assert!((proba[[0, 1]] - p1_row0).abs() < 1e-12);
1867
1868 // Both rows predicted correctly → score 1.0.
1869 let s = fitted.score(&x, &y)?;
1870 assert!((s - 1.0).abs() < 1e-12);
1871 Ok(())
1872 }
1873
1874 // -- REQ-4a: named_steps / get_step / get_step_by_name / into_slice -------
1875
1876 fn is_transformer(r: &PipelineStepRef<'_, f64>) -> bool {
1877 matches!(r, PipelineStepRef::Transformer(_))
1878 }
1879 fn is_estimator(r: &PipelineStepRef<'_, f64>) -> bool {
1880 matches!(r, PipelineStepRef::Estimator(_))
1881 }
1882
1883 #[test]
1884 fn test_pipeline_named_steps_match_sklearn() {
1885 // sklearn: Pipeline([('a',StandardScaler()),('b',MinMaxScaler()),
1886 // ('c',GaussianNB())]).named_steps keys order
1887 // == ['a', 'b', 'c'] (live oracle, sklearn 1.5.2;
1888 // `named_steps = Bunch(**dict(self.steps))`, pipeline.py:325).
1889 // Every step is reachable by its construction name, in order.
1890 let pipeline = Pipeline::new()
1891 .transform_step("a", Box::new(DoublingTransformer))
1892 .transform_step("b", Box::new(DoublingTransformer))
1893 .estimator_step("c", Box::new(SumEstimator));
1894
1895 let named = pipeline.named_steps();
1896 let names: Vec<&str> = named.iter().map(|(n, _)| *n).collect();
1897 assert_eq!(names, vec!["a", "b", "c"]);
1898 // The two transformer steps are transformers; the final is the estimator.
1899 assert!(is_transformer(&named[0].1));
1900 assert!(is_transformer(&named[1].1));
1901 assert!(is_estimator(&named[2].1));
1902 // step_names() agrees with named_steps() key order.
1903 assert_eq!(pipeline.step_names(), names);
1904 // len() counts every step (3), matching sklearn len(pipe)==3.
1905 assert_eq!(pipeline.len(), 3);
1906 assert!(!pipeline.is_empty());
1907 }
1908
1909 #[test]
1910 fn test_pipeline_get_step_integer() {
1911 // sklearn: p[0] -> first step object, p[2] -> last (estimator);
1912 // p[10] -> IndexError (live oracle). ferrolearn returns None OOB.
1913 let pipeline = Pipeline::new()
1914 .transform_step("a", Box::new(DoublingTransformer))
1915 .transform_step("b", Box::new(DoublingTransformer))
1916 .estimator_step("c", Box::new(SumEstimator));
1917
1918 assert!(matches!(
1919 pipeline.get_step(0),
1920 Some(PipelineStepRef::Transformer(_))
1921 ));
1922 assert!(matches!(
1923 pipeline.get_step(1),
1924 Some(PipelineStepRef::Transformer(_))
1925 ));
1926 assert!(matches!(
1927 pipeline.get_step(2),
1928 Some(PipelineStepRef::Estimator(_))
1929 ));
1930 // Out of range -> None (sklearn raises IndexError).
1931 assert!(pipeline.get_step(3).is_none());
1932 assert!(pipeline.get_step(10).is_none());
1933 }
1934
1935 #[test]
1936 fn test_pipeline_get_step_by_name() {
1937 // sklearn: p['b'] -> the 'b' step; p['nope'] -> KeyError (live oracle).
1938 let pipeline = Pipeline::new()
1939 .transform_step("a", Box::new(DoublingTransformer))
1940 .transform_step("b", Box::new(DoublingTransformer))
1941 .estimator_step("c", Box::new(SumEstimator));
1942
1943 assert!(matches!(
1944 pipeline.get_step_by_name("b"),
1945 Some(PipelineStepRef::Transformer(_))
1946 ));
1947 assert!(matches!(
1948 pipeline.get_step_by_name("c"),
1949 Some(PipelineStepRef::Estimator(_))
1950 ));
1951 assert!(matches!(
1952 pipeline.named_step("a"),
1953 Some(PipelineStepRef::Transformer(_))
1954 ));
1955 // Unknown name -> None (sklearn raises KeyError).
1956 assert!(pipeline.get_step_by_name("nope").is_none());
1957 assert!(pipeline.named_step("nope").is_none());
1958 }
1959
1960 #[test]
1961 fn test_pipeline_into_slice() -> Result<(), FerroError> {
1962 // sklearn: p[0:2].steps names == ['a','b'] (a sub-Pipeline of the
1963 // contiguous range; pipeline.py:310). p[:1] == ['a']. p[:] == all.
1964 // p[1:1] == [] (empty). (live oracle, sklearn 1.5.2.)
1965 let build = || {
1966 Pipeline::new()
1967 .transform_step("a", Box::new(DoublingTransformer))
1968 .transform_step("b", Box::new(DoublingTransformer))
1969 .estimator_step("c", Box::new(SumEstimator))
1970 };
1971
1972 // [0, 2) -> first two transformer steps, no estimator.
1973 let sub = build().into_slice(0, 2);
1974 assert_eq!(sub.step_names(), vec!["a", "b"]);
1975 assert_eq!(sub.len(), 2);
1976
1977 // [0, 1) -> just the first step.
1978 let sub = build().into_slice(0, 1);
1979 assert_eq!(sub.step_names(), vec!["a"]);
1980
1981 // [0, 3) -> the whole pipeline (full range), estimator preserved.
1982 let sub = build().into_slice(0, 3);
1983 assert_eq!(sub.step_names(), vec!["a", "b", "c"]);
1984
1985 // [2, 3) -> just the estimator step.
1986 let sub = build().into_slice(2, 3);
1987 assert_eq!(sub.step_names(), vec!["c"]);
1988
1989 // Empty range -> empty pipeline.
1990 let sub = build().into_slice(1, 1);
1991 assert!(sub.step_names().is_empty());
1992 assert!(sub.is_empty());
1993
1994 Ok(())
1995 }
1996
1997 #[test]
1998 fn test_pipeline_into_slice_clamps_like_python() {
1999 // sklearn `Pipeline.__getitem__` slices `self.steps[ind]` (Python list
2000 // slice, `pipeline.py:310`): out-of-range bounds CLAMP, never raise
2001 // (#2235). Live oracle (sklearn 1.5.2, 2-step pipeline):
2002 // p[0:5].steps -> ['a','c'] (clamp); p[2:1] -> []; p[5:100] -> [].
2003 let build = || {
2004 Pipeline::new()
2005 .transform_step("a", Box::new(DoublingTransformer))
2006 .estimator_step("c", Box::new(SumEstimator))
2007 };
2008 // end past len (2) -> clamp to all.
2009 assert_eq!(build().into_slice(0, 5).step_names(), vec!["a", "c"]);
2010 // start > end -> empty.
2011 assert!(build().into_slice(2, 1).is_empty());
2012 // start past len -> empty.
2013 assert!(build().into_slice(5, 100).is_empty());
2014 }
2015
2016 #[test]
2017 fn test_pipeline_into_slice_transformer_only_still_fits_estimatorless() -> Result<(), FerroError>
2018 {
2019 // A slice dropping the estimator yields an estimator-less pipeline that
2020 // (like sklearn's transformer-only sub-pipeline) is valid to build but
2021 // errors at fit (matches REQ-2's no-estimator rejection).
2022 let pipeline = Pipeline::new()
2023 .transform_step("a", Box::new(DoublingTransformer))
2024 .estimator_step("c", Box::new(SumEstimator));
2025 let sub = pipeline.into_slice(0, 1);
2026 let x = Array2::<f64>::zeros((2, 2));
2027 let y = Array1::from_vec(vec![0.0, 1.0]);
2028 assert!(matches!(
2029 sub.fit(&x, &y),
2030 Err(FerroError::InvalidParameter { .. })
2031 ));
2032 Ok(())
2033 }
2034
2035 #[test]
2036 fn test_fitted_pipeline_named_steps_and_get_step() -> Result<(), FerroError> {
2037 // The accessors work on the FITTED pipeline too. Names match
2038 // construction order (sklearn named_steps on a fitted Pipeline).
2039 let pipeline = Pipeline::new()
2040 .transform_step("scaler", Box::new(DoublingTransformer))
2041 .transform_step("norm", Box::new(DoublingTransformer))
2042 .estimator_step("clf", Box::new(SumEstimator));
2043 let x = Array2::<f64>::zeros((2, 3));
2044 let y = Array1::from_vec(vec![0.0, 1.0]);
2045 let fitted = pipeline.fit(&x, &y)?;
2046
2047 let names: Vec<&str> = fitted.named_steps().iter().map(|(n, _)| *n).collect();
2048 assert_eq!(names, vec!["scaler", "norm", "clf"]);
2049 assert_eq!(fitted.len(), 3);
2050 assert!(!fitted.is_empty());
2051
2052 // get_step by integer.
2053 assert!(matches!(
2054 fitted.get_step(0),
2055 Some(FittedPipelineStepRef::Transformer(_))
2056 ));
2057 assert!(matches!(
2058 fitted.get_step(2),
2059 Some(FittedPipelineStepRef::Estimator(_))
2060 ));
2061 assert!(fitted.get_step(3).is_none());
2062
2063 // get_step_by_name / named_step.
2064 assert!(matches!(
2065 fitted.get_step_by_name("norm"),
2066 Some(FittedPipelineStepRef::Transformer(_))
2067 ));
2068 assert!(matches!(
2069 fitted.named_step("clf"),
2070 Some(FittedPipelineStepRef::Estimator(_))
2071 ));
2072 assert!(fitted.named_step("nope").is_none());
2073 Ok(())
2074 }
2075
2076 // -- REQ-5a: passthrough steps -------------------------------------------
2077
2078 #[test]
2079 fn test_passthrough_step_is_identity() -> Result<(), FerroError> {
2080 // Live oracle (sklearn 1.5.2):
2081 // from sklearn.pipeline import Pipeline; import numpy as np
2082 // X = np.array([[1.,2.],[3.,4.],[5.,6.]])
2083 // p = Pipeline([('p','passthrough')]).fit(X)
2084 // np.array_equal(p.transform(X), X) -> True
2085 // A pipeline whose only transformer is a passthrough step leaves X
2086 // unchanged. ferrolearn needs a final estimator slot to fit, so we add a
2087 // SumEstimator after; transform() (the transformer prefix) must equal X.
2088 let pipeline = Pipeline::new()
2089 .passthrough_step("p")
2090 .estimator_step("sum", Box::new(SumEstimator));
2091 let x = ndarray::array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
2092 let y = Array1::from_vec(vec![0.0, 1.0, 2.0]);
2093 let fitted = pipeline.fit(&x, &y)?;
2094 // transform() applies only the (passthrough) transformer prefix -> X.
2095 assert_eq!(fitted.transform(&x)?, x);
2096 Ok(())
2097 }
2098
2099 #[test]
2100 fn test_passthrough_before_transformer_is_noop() -> Result<(), FerroError> {
2101 // Live oracle (sklearn 1.5.2):
2102 // Pipeline([('pass','passthrough'),('ss',StandardScaler())]).fit(X)
2103 // .transform(X)
2104 // == Pipeline([('ss',StandardScaler())]).fit(X).transform(X) -> True
2105 // A passthrough BEFORE a real transformer is a no-op: the result equals
2106 // the transformer alone. ferrolearn analog: a passthrough before a
2107 // DoublingTransformer == the doubler alone.
2108 let with_pass = Pipeline::new()
2109 .passthrough_step("pass")
2110 .transform_step("dbl", Box::new(DoublingTransformer))
2111 .estimator_step("sum", Box::new(SumEstimator));
2112 let without_pass = Pipeline::new()
2113 .transform_step("dbl", Box::new(DoublingTransformer))
2114 .estimator_step("sum", Box::new(SumEstimator));
2115 let x = ndarray::array![[1.0, 2.0], [3.0, 4.0]];
2116 let y = Array1::from_vec(vec![0.0, 1.0]);
2117
2118 let a = with_pass.fit(&x, &y)?.transform(&x)?;
2119 let b = without_pass.fit(&x, &y)?.transform(&x)?;
2120 assert_eq!(a, b);
2121 // And it equals the doubler applied to X.
2122 assert_eq!(a, x.mapv(|v| v * 2.0));
2123 Ok(())
2124 }
2125
2126 #[test]
2127 fn test_passthrough_after_transformer_is_noop() -> Result<(), FerroError> {
2128 // Live oracle (sklearn 1.5.2):
2129 // Pipeline([('ss',StandardScaler()),('pass','passthrough')]).transform(X)
2130 // == Pipeline([('ss',StandardScaler())]).transform(X) -> True
2131 // A passthrough AFTER a real transformer is a no-op. ferrolearn analog:
2132 // doubler then passthrough == doubler alone.
2133 let with_pass = Pipeline::new()
2134 .transform_step("dbl", Box::new(DoublingTransformer))
2135 .passthrough_step("pass")
2136 .estimator_step("sum", Box::new(SumEstimator));
2137 let without_pass = Pipeline::new()
2138 .transform_step("dbl", Box::new(DoublingTransformer))
2139 .estimator_step("sum", Box::new(SumEstimator));
2140 let x = ndarray::array![[1.0, 2.0], [3.0, 4.0]];
2141 let y = Array1::from_vec(vec![0.0, 1.0]);
2142
2143 let a = with_pass.fit(&x, &y)?.transform(&x)?;
2144 let b = without_pass.fit(&x, &y)?.transform(&x)?;
2145 assert_eq!(a, b);
2146 assert_eq!(a, x.mapv(|v| v * 2.0));
2147 Ok(())
2148 }
2149
2150 #[test]
2151 fn test_passthrough_step_appears_in_step_names() -> Result<(), FerroError> {
2152 // Live oracle (sklearn 1.5.2):
2153 // p = Pipeline([('p','passthrough'),('ss',StandardScaler())]).fit(X)
2154 // list(p.named_steps.keys()) -> ['p', 'ss']
2155 // p['p'] -> 'passthrough' (still visible)
2156 // A passthrough step is a real, named step: it shows up in
2157 // step_names()/named_steps() in order, exactly like sklearn.
2158 let pipeline = Pipeline::new()
2159 .passthrough_step("p")
2160 .transform_step("dbl", Box::new(DoublingTransformer))
2161 .estimator_step("clf", Box::new(SumEstimator));
2162
2163 assert_eq!(pipeline.step_names(), vec!["p", "dbl", "clf"]);
2164 let named: Vec<&str> = pipeline.named_steps().iter().map(|(n, _)| *n).collect();
2165 assert_eq!(named, vec!["p", "dbl", "clf"]);
2166 // The passthrough step is a transformer-kind step (reachable by name).
2167 assert!(matches!(
2168 pipeline.named_step("p"),
2169 Some(PipelineStepRef::Transformer(_))
2170 ));
2171
2172 // And it survives onto the fitted pipeline's introspection.
2173 let x = Array2::<f64>::zeros((2, 2));
2174 let y = Array1::from_vec(vec![0.0, 1.0]);
2175 let fitted = pipeline.fit(&x, &y)?;
2176 assert_eq!(fitted.step_names(), vec!["p", "dbl", "clf"]);
2177 assert!(matches!(
2178 fitted.named_step("p"),
2179 Some(FittedPipelineStepRef::Transformer(_))
2180 ));
2181 Ok(())
2182 }
2183
2184 #[test]
2185 fn test_passthrough_transformer_standalone_identity() -> Result<(), FerroError> {
2186 // A standalone PassthroughTransformer: fit_pipeline + transform_pipeline
2187 // is the identity (the building block the no-op step is made of). This is
2188 // the pointwise restatement of sklearn's 'passthrough' == identity
2189 // (Pipeline([('p','passthrough')]).transform(X) == X, live 1.5.2).
2190 let p = PassthroughTransformer::<f64>::new();
2191 let x = ndarray::array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
2192 let y = Array1::from_vec(vec![0.0, 1.0]);
2193 let fitted = p.fit_pipeline(&x, &y)?;
2194 assert_eq!(fitted.transform_pipeline(&x)?, x);
2195 // Default constructs the same no-op.
2196 let fitted2 = PassthroughTransformer::<f64>::default().fit_pipeline(&x, &y)?;
2197 assert_eq!(fitted2.transform_pipeline(&x)?, x);
2198 // The fitted half also has a public constructor/Default.
2199 assert_eq!(
2200 FittedPassthroughTransformer::<f64>::new().transform_pipeline(&x)?,
2201 x
2202 );
2203 Ok(())
2204 }
2205
2206 #[test]
2207 fn test_passthrough_transformer_f32() -> Result<(), FerroError> {
2208 // f32 generic support: the identity no-op for f32 data.
2209 let pipeline = Pipeline::<f32>::new()
2210 .passthrough_step("p")
2211 .transform_step("dbl", Box::new(DoublingTransformerF32))
2212 .estimator_step("sum", Box::new(SumEstimatorF32));
2213 let x = ndarray::array![[1.0f32, 2.0], [3.0, 4.0]];
2214 let y = Array1::from_vec(vec![0.0f32, 1.0]);
2215 let fitted = pipeline.fit(&x, &y)?;
2216 // passthrough then doubler == doubler alone.
2217 assert_eq!(fitted.transform(&x)?, x.mapv(|v| v * 2.0));
2218 Ok(())
2219 }
2220
2221 #[test]
2222 fn test_passthrough_transformer_is_send_sync() {
2223 fn assert_send_sync<T: Send + Sync>() {}
2224 assert_send_sync::<PassthroughTransformer<f64>>();
2225 assert_send_sync::<PassthroughTransformer<f32>>();
2226 assert_send_sync::<FittedPassthroughTransformer<f64>>();
2227 assert_send_sync::<FittedPassthroughTransformer<f32>>();
2228 }
2229
2230 // -- REQ-8: FeatureUnion -------------------------------------------------
2231
2232 /// A transformer that returns its input columns unchanged (width-preserving,
2233 /// the OneToOneFeatureMixin shape — like sklearn's StandardScaler).
2234 struct IdentityTransformer;
2235
2236 impl PipelineTransformer<f64> for IdentityTransformer {
2237 fn fit_pipeline(
2238 &self,
2239 _x: &Array2<f64>,
2240 _y: &Array1<f64>,
2241 ) -> Result<Box<dyn FittedPipelineTransformer<f64>>, FerroError> {
2242 Ok(Box::new(FittedIdentityTransformer))
2243 }
2244 }
2245
2246 struct FittedIdentityTransformer;
2247
2248 impl FittedPipelineTransformer<f64> for FittedIdentityTransformer {
2249 fn transform_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
2250 Ok(x.clone())
2251 }
2252 }
2253
2254 /// A transformer that emits a single column: the row sum (width 1, regardless
2255 /// of input width). Used to exercise mixed-width hstack blocks.
2256 struct RowSumTransformer;
2257
2258 impl PipelineTransformer<f64> for RowSumTransformer {
2259 fn fit_pipeline(
2260 &self,
2261 _x: &Array2<f64>,
2262 _y: &Array1<f64>,
2263 ) -> Result<Box<dyn FittedPipelineTransformer<f64>>, FerroError> {
2264 Ok(Box::new(FittedRowSumTransformer))
2265 }
2266 }
2267
2268 struct FittedRowSumTransformer;
2269
2270 impl FittedPipelineTransformer<f64> for FittedRowSumTransformer {
2271 fn transform_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
2272 let sums: Vec<f64> = x.rows().into_iter().map(|r| r.sum()).collect();
2273 Array2::from_shape_vec((x.nrows(), 1), sums).map_err(|e| FerroError::InvalidParameter {
2274 name: "x".into(),
2275 reason: e.to_string(),
2276 })
2277 }
2278 }
2279
2280 #[test]
2281 fn test_feature_union_hstack_layout() -> Result<(), FerroError> {
2282 // sklearn (live, 1.5.2):
2283 // from sklearn.pipeline import FeatureUnion
2284 // from sklearn.preprocessing import StandardScaler, MinMaxScaler
2285 // import numpy as np
2286 // X = np.array([[1.,2.],[3.,4.],[5.,6.]])
2287 // fu = FeatureUnion([('ss',StandardScaler()),('mm',MinMaxScaler())]).fit(X)
2288 // fu.transform(X).shape -> (3, 4)
2289 // # columns = [ss_col0, ss_col1, mm_col0, mm_col1] (each transformer's
2290 // # full output, concatenated left-to-right in transformer_list order)
2291 // The hstack STRUCTURE is what's asserted here: two width-2 identity
2292 // transformers → a width-4 output whose column blocks are each
2293 // transformer's full output (here, the unchanged input twice). The block
2294 // layout (transformer 0's cols, then transformer 1's cols) IS sklearn's
2295 // _hstack ordering (pipeline.py:1812 np.hstack(Xs)).
2296 let union = FeatureUnion::<f64>::new()
2297 .with_transformer("a", Box::new(IdentityTransformer))
2298 .with_transformer("b", Box::new(IdentityTransformer));
2299 let x = ndarray::array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
2300
2301 let fitted = union.fit(&x, &())?;
2302 let out = fitted.transform(&x)?;
2303
2304 // Width = sum of widths = 2 + 2 = 4; rows preserved.
2305 assert_eq!(out.dim(), (3, 4));
2306 // Block 0 (cols 0..2) = transformer "a"'s output (== x).
2307 assert_eq!(out.slice(ndarray::s![.., 0..2]).to_owned(), x);
2308 // Block 1 (cols 2..4) = transformer "b"'s output (== x).
2309 assert_eq!(out.slice(ndarray::s![.., 2..4]).to_owned(), x);
2310 Ok(())
2311 }
2312
2313 #[test]
2314 fn test_feature_union_get_feature_names_out() -> Result<(), FerroError> {
2315 // sklearn (live, 1.5.2): the SAME union as above ->
2316 // list(fu.get_feature_names_out())
2317 // == ['ss__x0','ss__x1','mm__x0','mm__x1']
2318 // i.e. each transformer's positional output names ('x0','x1' — the
2319 // OneToOneFeatureMixin default for StandardScaler/MinMaxScaler) prefixed
2320 // by '{name}__' (verbose_feature_names_out=True default, pipeline.py:1608).
2321 // ferrolearn's identity transformers are the width-preserving analog, so
2322 // the NAMING semantics (prefix + positional x{j}) match exactly.
2323 let union = FeatureUnion::<f64>::new()
2324 .with_transformer("ss", Box::new(IdentityTransformer))
2325 .with_transformer("mm", Box::new(IdentityTransformer));
2326 let x = ndarray::array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
2327
2328 let fitted = union.fit(&x, &())?;
2329 assert_eq!(
2330 fitted.get_feature_names_out(),
2331 vec!["ss__x0", "ss__x1", "mm__x0", "mm__x1"]
2332 );
2333 // transformer_names() preserves union order; n_transformers/n_features_out.
2334 assert_eq!(fitted.transformer_names(), vec!["ss", "mm"]);
2335 assert_eq!(fitted.n_transformers(), 2);
2336 assert_eq!(fitted.n_features_out(), 4);
2337 Ok(())
2338 }
2339
2340 #[test]
2341 fn test_feature_union_single_transformer_width() -> Result<(), FerroError> {
2342 // sklearn (live, 1.5.2):
2343 // FeatureUnion([('ss',StandardScaler())]).fit(X).transform(X).shape
2344 // -> (3, 2) (single block == that transformer's width)
2345 // get_feature_names_out() -> ['ss__x0','ss__x1']
2346 let union =
2347 FeatureUnion::<f64>::new().with_transformer("ss", Box::new(IdentityTransformer));
2348 let x = ndarray::array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
2349
2350 let fitted = union.fit(&x, &())?;
2351 let out = fitted.transform(&x)?;
2352 assert_eq!(out.dim(), (3, 2));
2353 assert_eq!(out, x);
2354 assert_eq!(fitted.get_feature_names_out(), vec!["ss__x0", "ss__x1"]);
2355 Ok(())
2356 }
2357
2358 #[test]
2359 fn test_feature_union_mixed_widths() -> Result<(), FerroError> {
2360 // sklearn (live, 1.5.2) — a union whose transformers emit DIFFERENT
2361 // widths concatenates their blocks correctly. Oracle (StandardScaler
2362 // keeps 3 cols, PCA(1) emits 1):
2363 // X = np.array([[1.,2.,3.],[3.,4.,5.],[5.,6.,7.]])
2364 // fu = FeatureUnion([('ss',StandardScaler()),('pca',PCA(1))]).fit(X)
2365 // fu.transform(X).shape -> (3, 4) (3 + 1)
2366 // list(fu.get_feature_names_out())
2367 // -> ['ss__x0','ss__x1','ss__x2','pca__pca0']
2368 // ferrolearn analog: a width-3 identity + a width-1 row-sum transformer.
2369 // The STRUCTURE (block 0 width 3, block 1 width 1; total 4) is sklearn's.
2370 // (Names: ferrolearn uses the positional x{j} suffix for both blocks —
2371 // the documented OneToOneFeatureMixin default, since the trait objects
2372 // expose no per-output names.)
2373 let union = FeatureUnion::<f64>::new()
2374 .with_transformer("ident", Box::new(IdentityTransformer))
2375 .with_transformer("rowsum", Box::new(RowSumTransformer));
2376 let x = ndarray::array![[1.0, 2.0, 3.0], [3.0, 4.0, 5.0], [5.0, 6.0, 7.0]];
2377
2378 let fitted = union.fit(&x, &())?;
2379 let out = fitted.transform(&x)?;
2380 // 3 (identity) + 1 (row sum) = 4 columns.
2381 assert_eq!(out.dim(), (3, 4));
2382 // Block 0 == x (identity).
2383 assert_eq!(out.slice(ndarray::s![.., 0..3]).to_owned(), x);
2384 // Block 1 == row sums.
2385 let expected_sums = ndarray::array![[6.0], [12.0], [18.0]];
2386 assert_eq!(out.slice(ndarray::s![.., 3..4]).to_owned(), expected_sums);
2387 // Feature names reflect the per-block widths.
2388 assert_eq!(
2389 fitted.get_feature_names_out(),
2390 vec!["ident__x0", "ident__x1", "ident__x2", "rowsum__x0"]
2391 );
2392 Ok(())
2393 }
2394
2395 #[test]
2396 fn test_feature_union_empty() -> Result<(), FerroError> {
2397 // An empty union fits OK and transforms to a (n_samples, 0) matrix — the
2398 // ferrolearn analog of sklearn's empty-hstack branch
2399 // `if not Xs: return np.zeros((X.shape[0], 0))` (pipeline.py:1808).
2400 // (sklearn's PUBLIC FeatureUnion([]).fit raises at _validate_transformers'
2401 // `zip(*[])`, a Python-tuple-unpack artifact, not a numerical contract —
2402 // R-DEV-4: ferrolearn has no such unpack, and the empty-hstack shape is
2403 // the documented (n, 0) result.)
2404 let union = FeatureUnion::<f64>::new();
2405 let x = ndarray::array![[1.0, 2.0], [3.0, 4.0]];
2406 let fitted = union.fit(&x, &())?;
2407 let out = fitted.transform(&x)?;
2408 assert_eq!(out.dim(), (2, 0));
2409 assert!(fitted.get_feature_names_out().is_empty());
2410 assert_eq!(fitted.n_features_out(), 0);
2411 Ok(())
2412 }
2413
2414 #[test]
2415 fn test_feature_union_row_count_consistency() -> Result<(), FerroError> {
2416 // Every sub-output has n_rows == X.nrows(); the hstacked result preserves
2417 // the row count (live oracle: FeatureUnion outputs have X.shape[0] rows).
2418 let union = FeatureUnion::<f64>::new()
2419 .with_transformer("a", Box::new(IdentityTransformer))
2420 .with_transformer("b", Box::new(RowSumTransformer));
2421 let x = ndarray::array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]];
2422 let fitted = union.fit(&x, &())?;
2423 let out = fitted.transform(&x)?;
2424 assert_eq!(out.nrows(), x.nrows());
2425 Ok(())
2426 }
2427
2428 #[test]
2429 fn test_feature_union_f32() -> Result<(), FerroError> {
2430 // f32 generic support: same hstack layout for f32 data.
2431 let union = FeatureUnion::<f32>::new()
2432 .with_transformer("a", Box::new(IdentityTransformerF32))
2433 .with_transformer("b", Box::new(IdentityTransformerF32));
2434 let x = ndarray::array![[1.0f32, 2.0], [3.0, 4.0]];
2435 let fitted = union.fit(&x, &())?;
2436 let out = fitted.transform(&x)?;
2437 assert_eq!(out.dim(), (2, 4));
2438 assert_eq!(out.slice(ndarray::s![.., 0..2]).to_owned(), x);
2439 assert_eq!(out.slice(ndarray::s![.., 2..4]).to_owned(), x);
2440 Ok(())
2441 }
2442
2443 /// f32 identity transformer (width-preserving) for the f32 union test.
2444 struct IdentityTransformerF32;
2445
2446 impl PipelineTransformer<f32> for IdentityTransformerF32 {
2447 fn fit_pipeline(
2448 &self,
2449 _x: &Array2<f32>,
2450 _y: &Array1<f32>,
2451 ) -> Result<Box<dyn FittedPipelineTransformer<f32>>, FerroError> {
2452 Ok(Box::new(FittedIdentityTransformerF32))
2453 }
2454 }
2455
2456 struct FittedIdentityTransformerF32;
2457
2458 impl FittedPipelineTransformer<f32> for FittedIdentityTransformerF32 {
2459 fn transform_pipeline(&self, x: &Array2<f32>) -> Result<Array2<f32>, FerroError> {
2460 Ok(x.clone())
2461 }
2462 }
2463
2464 #[test]
2465 fn test_feature_union_is_send_sync() {
2466 fn assert_send_sync<T: Send + Sync>() {}
2467 assert_send_sync::<FeatureUnion<f64>>();
2468 assert_send_sync::<FeatureUnion<f32>>();
2469 assert_send_sync::<FittedFeatureUnion<f64>>();
2470 assert_send_sync::<FittedFeatureUnion<f32>>();
2471 }
2472}