Data in, monitored
service out.
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/.
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:
| Feature | Adds |
|---|---|
| smartcore-backenddefault | RandomForest · LinearRegression |
| preprocessingdefault | Smote · RandomOverSampler (imputers/scalers/encoders are core) |
| model-selectiondefault | KFold · StratifiedKFold · GridSearch · RandomSearch · metrics |
| ensembledefault | Voting · Bagging · Stacking |
| eda | Table (polars CSV/Parquet ingest) · Profile (typed EDA) |
| linfa-backend | KMeans · GaussianMixture · Dbscan · Pca |
| hpo | BayesSearch (TPE) over a SearchSpace |
| diagnostics | OLS Diagnostics: VIF · residuals · Cook's distance |
| explain | Explainer (SHAP) · permutation_importance |
| calibration | PlattScaling · IsotonicRegression · reliability_curve |
| anomaly | Mahalanobis · KnnScore outlier detectors |
| viz | ROC / residual SVG figures |
| onnx | export_onnx · InferenceModel (tract) |
| registry | versioned model Registry |
| monitor | DriftMonitor (PSI) |
| serve | Server — POST /predict, GET /metrics |
| timeseries | AutoArima forecaster |
| incremental | IncrementalLinear (partial_fit) |
| automl | AutoML search |
| python | the pip install millwright package |
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"
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.
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.
| 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 |
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.
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.
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());
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"
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)?;
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
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(®)?; 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"
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.
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"
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"
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"
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.
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 straycargo updatecan 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.rslocks 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-featuresthrough each feature tofull— plus Windows/macOS, the runnable examples, a benchmark compile-check, acargo 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