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.
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).
| Trait | Method | Meaning |
|---|---|---|
| Transformer | fit(&mut, &Frame) → transform(&Frame) | learn column stats, reshape features |
| Estimator | fit(&mut, &Dataset) | learn a model from features + target |
| Predictor | predict(&Frame) → Vec<f64> | one prediction per row |
| ProbaPredictor | predict_proba(&Frame) → Frame | class probabilities |
Specialized traits cover the shapes that don't fit the supervised mould: Clusterer, Forecaster, PartialFit, and Balancer.
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
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
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"
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
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)?;
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