02 · pipelines & models

Four traits. One pipeline.
Every backend.

Everything composes because everything speaks the same contract. Compose preprocessing and a model into one object, tune any parameter by path, cross-validate the whole thing, and combine models across backends.

The contract

Four object-safe traits.

Object-safe means a Pipeline can hold a heterogeneous Vec<Box<dyn …>>. A blanket Model ties Estimator + Predictor together; a blanket Evaluate gives every predictor a .evaluate(&test).

TraitMethodMeaning
Transformerfit(&mut, &Frame) → transform(&Frame)learn column stats, reshape features
Estimatorfit(&mut, &Dataset)learn a model from features + target
Predictorpredict(&Frame) → Vec<f64>one prediction per row
ProbaPredictorpredict_proba(&Frame) → Frameclass probabilities

Specialized traits cover the shapes that don't fit the supervised mould: Clusterer, Forecaster, PartialFit, and Balancer.

Compose

Pipelines: compose, then tune by path.

A Pipeline is named transformer steps plus one final estimator, as a single object that is itself a Model. Steps are addressable by name, so you tune a parameter deep in the chain by path — the scikit-learn "step__param" convention. Pipelines nest.

let mut pipe = Pipeline::new()
    .step("scale", StandardScaler::new())
    .estimator("rf", RandomForest::new());

pipe.set_param("rf__n_trees", ParamValue::Int(50))?;   // tune by path
pipe.set_param("rf__max_depth", ParamValue::Int(4))?;

pipe.fit(&train)?;                // fit transforms, then the estimator
let preds = pipe.predict(&x)?;   // replays fitted transforms, then the model

cargo run --example spine

Preprocess & balance

Impute, scale, encode, resample.

The core transformers need no extra dependencies — SimpleImputer, StandardScaler, MinMaxScaler, OneHotEncoder, plus Winsorize, PowerTransform, and ColumnTransformer. Balancers (feature preprocessing) are train-time only — they resample during fit and are skipped at predict time.

let pipe = Pipeline::new()
    .step("impute", SimpleImputer::median())   // or ::mean() / ::constant(0.0)
    .step("scale",  StandardScaler::new())      // or MinMaxScaler::new()
    .step("encode", OneHotEncoder::infer())      // or ::columns(["city"])
    .balance(Smote::new().k_neighbors(3).random_state(0))  // train-time only
    .estimator("rf", RandomForest::new());

// different treatment per column group
let pre = ColumnTransformer::new()
    .add(PowerTransform::yeo_johnson(), ["income"])   // de-skew
    .add(Winsorize::new(), ["age"]);                  // clip outliers
Cross-validate & search

One search API, three strategies.

Search runs over the entire pipeline, cross-validated, tuning by path. RandomSearch swaps the grid for random draws; with hpo, BayesSearch runs TPE and returns the same SearchResult.

use millwright::grid;

let search = GridSearch::new(pipe, grid! { "rf__max_depth" => [2, 4, 8] })
    .cv(StratifiedKFold::new(4))
    .scoring(Metric::F1)
    .fit(&train)?;

println!("best F1 = {:.3}", search.best_score());
let preds = search.predict(&probe)?;   // the refit best model

cargo run --example workflow · cargo run --example backends --features "linfa-backend hpo"

Models

A forest, a line, and real probabilities.

The smartcore backend supplies RandomForest and LinearRegression. The core LogisticRegression is a native binary classifier with a genuine predict_proba — the framework's first real ProbaPredictor, available without any backend feature.

let mut clf = LogisticRegression::new().epochs(500);
clf.fit(&train)?;
let proba = clf.predict_proba(&test)?;   // one column per class
Combine

Ensembles — even across backends.

Because every model is a Predictor, combining models is just another Predictor that holds several — no new machinery, and it works across backends. An ensemble is an Estimator, so you can search a member straight through it.

// soft (mean class-vote share) vote across two forests
let mut vote = Voting::soft()
    .add("rf_shallow", RandomForest::new().max_depth(2))
    .add("rf_deep",    RandomForest::new().max_depth(8));
vote.fit(&train)?;

// stacking: a meta-learner on leak-free out-of-fold base predictions
let mut stack = Stacking::meta(RandomForest::new().n_trees(50))
    .base("rf",  RandomForest::new().n_trees(30))
    .base("knn", RandomForest::new().max_depth(3))
    .cv(StratifiedKFold::new(4));   // folds from the CV engine → leak-free
stack.fit(&train)?;
A second backend

linfa — through the same Frame.

The linfa-backend feature adds unsupervised models through the same boundary type — the proof that the two-ndarray-worlds problem is settled by design. Clusterers implement a Clusterer contract; Pca is a Transformer.

let mut km = KMeans::new(2);
km.fit(&x)?;
println!("k-means labels: {:?}", km.predict(&x)?);

let dbscan = Dbscan::new(3).tolerance(1.0);
println!("dbscan: {:?}", dbscan.fit_predict(&x)?);

let mut pca = Pca::new(1);
let reduced = pca.fit_transform(&x)?;   // a Frame with fewer columns