millwrightthe guide
The guide

Data in, monitored
service out.

the hands-on tutorial · one data model, one contract, one pipeline

The design brief is the why. This is the how: a run through the whole lifecycle — from a raw table of f64 to a served, drift-monitored model — where every capability is a cargo feature over one crate. Every snippet below is real API, mirrored from the runnable programs in examples/.

Step 01 · install

Pull only what you need.

default is a lean, useful core — the smartcore backend, preprocessing, model selection, and ensembles. Everything past it is feature-gated: a serving binary never compiles SHAP; a notebook never compiles axum.

# Cargo.toml — the default core
millwright = "0.1"

# just the spine
millwright = { version = "0.1", default-features = false, features = ["smartcore-backend"] }

# the whole lifecycle
millwright = { version = "0.1", features = ["full"] }

One import brings the framework into scope, and the lifecycle stages map to features:

FeatureAdds
smartcore-backenddefaultRandomForest · LinearRegression
preprocessingdefaultSmote · RandomOverSampler (imputers/scalers/encoders are core)
model-selectiondefaultKFold · StratifiedKFold · GridSearch · RandomSearch · metrics
ensembledefaultVoting · Bagging · Stacking
edaTable (polars CSV/Parquet ingest) · Profile (typed EDA)
linfa-backendKMeans · GaussianMixture · Dbscan · Pca
hpoBayesSearch (TPE) over a SearchSpace
diagnosticsOLS Diagnostics: VIF · residuals · Cook's distance
explainExplainer (SHAP) · permutation_importance
calibrationPlattScaling · IsotonicRegression · reliability_curve
anomalyMahalanobis · KnnScore outlier detectors
vizROC / residual SVG figures
onnxexport_onnx · InferenceModel (tract)
registryversioned model Registry
monitorDriftMonitor (PSI)
serveServer — POST /predict, GET /metrics
timeseriesAutoArima forecaster
incrementalIncrementalLinear (partial_fit)
automlAutoML search
pythonthe pip install millwright package
On this page
The front of the lifecycle

Ingest & explore: Table and Profile.

Behind the eda feature, a polars-backed Table reads real CSV/Parquet — strings, categories, dates, booleans, nulls — and a Profile reports it and drafts the preprocessing. Frame stays the numeric boundary; Table is the typed world in front of it.

let table = Table::from_csv("customers.csv")?;   // or ::from_parquet(…)

// a typed profile — not just an HTML blob
let profile = Profile::of_with_target(&table, "churned")?;
println!("{}", profile.summary());
for alert in profile.alerts() {
    println!("{alert}");   // "[city] categorical (3 levels) → OneHotEncoder"
}
profile.to_html("eda_report.html")?;             // a shareable report

Because Millwright owns EDA and the pipeline, the profile drafts the starting preprocessing from its own findings — the loop scikit-learn can't close:

// lower the typed table to the numeric world
let train = table.into_dataset("churned")?;   // categoricals encoded, nulls → NaN

// EDA drafts the pipeline; you just add the model
let mut pipe = profile.suggest_pipeline()        // impute · encode · scale, from the alerts
    .estimator("rf", RandomForest::new());
pipe.fit(&train)?;

cargo run --example explore --features "eda smartcore-backend"

Step 02 · data

The boundary type: Frame and Dataset.

Everything the public API speaks is a Frame: a contiguous, row-major f64 buffer plus a schema. It is what lets a linfa model and a smartcore DenseMatrix meet in one signature without your code ever naming their array versions — each backend converts Frame ⇄ its native type inside the adapter only. A Dataset pairs a frame with a target.

use millwright::prelude::*;

let x = Frame::from_rows(
    vec![vec![0.0, 0.1], vec![0.4, 0.2], vec![9.0, 9.1], vec![9.4, 8.7]],
    vec!["a".into(), "b".into()],
)?;
assert_eq!(x.shape(), (4, 2));        // (rows, cols)

let train = Dataset::new(x.clone(), vec![0.0, 0.0, 1.0, 1.0])?;
let _features = train.features();  // &Frame
let _target   = train.target();    // &[f64]

The task — classification vs. regression — is inferred from the target: an all-integral target is class labels, anything else is regression.

Step 03 · the contract

Four object-safe traits.

The whole core is four traits. Object-safe means a Pipeline can hold a heterogeneous Vec<Box<dyn …>> — everything composes because everything speaks the same contract.

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

A blanket Model ties Estimator + Predictor together; a blanket Evaluate gives every predictor a .evaluate(&test). You rarely name these — you compose the types that implement them.

Step 04 · 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.

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

That path addressing is exactly what lets a search reach any parameter anywhere in the chain. Pipelines nest, so a pipeline can be a step in another.

Step 05 · prepare

Preprocess & balance.

The core transformers need no extra dependencies. Balancers (feature preprocessing) are train-time only — they resample during fit and are skipped at predict time, so they never distort inference.

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());
Step 06 · select

Cross-validate & search — over the whole pipeline.

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 — one API, three strategies.

use millwright::grid;

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

for (params, score) in search.leaderboard() {
    println!("  {score:.3}  {params:?}");
}
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"

Step 07 · 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-probability) 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("rf2", RandomForest::new().max_depth(3))
    .cv(StratifiedKFold::new(4));   // folds from the CV engine → leak-free
stack.fit(&train)?;
Step 08 · 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
Step 09 · insight

Evaluate, diagnose, explain, visualize.

Any predictor scores itself on a labelled set (core). explain adds SHAP and permutation importance; diagnostics adds OLS VIF/residuals/influence; viz renders self-contained SVGs.

let mut rf = RandomForest::new().n_trees(60);
rf.fit(&train)?;
print!("{}", rf.evaluate(&test)?);   // accuracy / precision / recall / F1

// explain (feature = "explain")
let shap = rf.explain(&Explainer::kernel().nsamples(80), test.features())?;
let perm = permutation_importance(&rf, &test, 8, 0)?;

// diagnostics (feature = "diagnostics") · viz (feature = "viz")
let diag = Diagnostics::of(&reg)?;
println!("R² = {:.4}, VIF = {:?}", diag.r_squared(), diag.vif());
let auc = viz::roc_svg(test.target(), &scores, "roc.svg", (520, 420))?;

cargo run --example insight --features "diagnostics explain viz"

Step 10 · portability

ONNX in and out.

With onnx, any model — or a whole pipeline — exports to one .onnx file. Whole-pipeline export folds leading affine scalers into the estimator's graph: raw features in, predictions out. InferenceModel::load runs any ONNX file back through tract.

let mut pipe = Pipeline::new()
    .step("scale", StandardScaler::new())
    .estimator("lr", LinearRegression::new());
pipe.fit(&train)?;
let native = pipe.predict(&probe)?;

pipe.export_onnx("pipeline.onnx")?;                 // scaler + model, one graph
let model = InferenceModel::load("pipeline.onnx")?;
let via_onnx = model.predict(&probe)?;              // matches `native`

Linear/affine/pipeline graphs run inside tract for a full round-trip. A random forest exports to a valid ONNX-ML tree-ensemble artifact for external runtimes (onnxruntime); tract implements NN ops, not the ONNX-ML tree ops.

Step 11 · operate

Registry, drift, serving.

Where Millwright runs past where scikit-learn stops. The registry versions the ONNX artifact (content-addressed, with a reference distribution and movable tags); monitor watches the prediction stream for PSI drift; serve exposes a validated endpoint that feeds the monitor.

let reg = Registry::local("./models");
let v1 = reg.register("demand", &model, Metadata {
    metrics: vec![("r2".into(), 1.0)],
    reference: reference.clone(),   // the distribution drift watches against
    note: "baseline".into(),
})?;
reg.tag("demand", &v1.id, "prod")?;
let reverted = reg.rollback("demand", "prod")?;   // revert in one line

// serve the prod artifact, watching for drift on every request
Server::from_onnx(reg.onnx_path("demand", "prod")?)?
    .route("/predict")
    .with_monitor(DriftMonitor::psi(&reference)?)
    .serve("0.0.0.0:8080").await?;         // POST /predict, GET /metrics

cargo run --example operations --features "onnx registry monitor serve"

Step 12 · specialized shapes

Time series & out-of-core.

Same contract, different data shapes — each gets its own trait. These two crates pin ndarray 0.15 while the rest of the stack uses 0.16; Cargo links both and converts only inside the adapters — the "two ndarray worlds," exercised for real.

// time series (feature = "timeseries")
let mut arima = AutoArima::new().max_p(3).max_q(3);
arima.fit(&series)?;                  // &[f64]
let forecast = arima.forecast(6)?;   // six steps ahead

// out-of-core (feature = "incremental") — never holds the whole set in memory
let mut model = IncrementalLinear::with_rate(0.05, 0.0);
for batch in batches {
    model.partial_fit(&batch)?;      // one batch at a time
}

cargo run --example specialized --features "timeseries incremental"

Step 13 · synthesis

AutoML — the framework, pointed at itself.

Profiling, preprocessing, CV, search, and ensembling are exactly what an AutoML engine needs — so AutoML is not a bolt-on, it is the framework orchestrating its own parts. Point it at data and a budget; get a leaderboard and the best deployable model.

let result = AutoML::classifier()      // or ::regressor()
    .budget(Budget::trials(20))         // or Budget::minutes(10)
    .metric(Metric::F1)
    .cv(StratifiedKFold::new(5))
    .seed(0)
    .fit(&train)?;

println!("{}", result.leaderboard());
println!("winner: {} (F1 = {:.3})", result.best_label(), result.best_score());
result.export_onnx("model.onnx")?;   // deployable — unlike a TPOT object

cargo run --example automl --features "automl onnx"

Step 14 · interop

Python — the same engine, a Pythonic API.

pip install millwright — a Pythonic API over the same Rust engine, shipped as an abi3 wheel built with maturin.

import millwright as mw

pipe = mw.Pipeline()
pipe.standard_scaler()
pipe.random_forest(n_trees=100, max_depth=8)

pipe.fit(rows, labels)          # list[list[float]], list[float]
preds = pipe.predict(rows)      # runs the Rust engine

pip install millwright

python is deliberately not part of full: pyo3's extension-module defers libpython symbols, so a plain cargo test can't link it. It is built and tested the way it ships — as a wheel.

Step 15 · bet on it

Reproducibility: pins, lockfile, golden tests, CI.

Millwright assembles young, single-author engine crates. That is its real risk, and Phase 8 owns it directly.

  • Exact-version pins. Every engine — the ecosystem crates plus the smartcore and linfa families — is pinned to an exact =x.y.z. A stray cargo update can never silently move a fragile engine under the stable trait contract. General infrastructure (serde, tokio, axum) stays on caret ranges to avoid forcing conflicts downstream.
  • Committed Cargo.lock. The whole ~300-package graph is reproducible; CI builds with --locked, so a drifted lockfile is a hard error.
  • Golden-output tests. tests/golden.rs locks the numeric behaviour of the engines on fixed inputs — exact for the deterministic paths, well-separated class labels for the stochastic ones. An engine bump that moves a number shows up as a diff.
  • Feature-matrix CI. fmt, clippy -D warnings, docs, and the tests across the whole matrix — from --no-default-features through each feature to full — plus Windows/macOS, the runnable examples, a benchmark compile-check, a cargo publish --dry-run, and a maturin wheel. The MSRV (rust-version) is enforced by cargo for consumers.
# run the whole suite yourself
cargo test --features full
cargo test --locked --no-default-features --features smartcore-backend