A unified ML framework for Rust — ten crates, one lifecycle.
- Tutorial:
GUIDE.md— the hands-on walk through the whole lifecycle (also as a page:guide.html). - Design brief: the why, at https://millwright-rs.dev/.
"Ten crates" is the ecosystem this project assembles —
plotters-statistical,model-selection-rs,imbalance-rs,regression-diagnostics,hyperopt-rs,shap-rs,driftwatch,onnx-export-rs,incremental-rs,chronos-ts— riding the establishedsmartcore/linfa/polarsstack.
fit · transform · predict · Pipeline end to end over a real backend:
Frame/Dataset— the contiguous, row-majorf64boundary type (src/frame.rs).- The four traits — object-safe
Transformer,Estimator,Predictor,ProbaPredictor, plus a blanketModel(src/traits.rs). - The first backend — a smartcore adapter (
RandomForest,LinearRegression) convertingFrame → DenseMatrixat the edge only (src/backends/smartcore.rs). Pipeline— named steps + a final model,"step__param"addressing; pipelines nest (src/pipeline.rs).
- Preprocessing (
src/transform.rs, core):SimpleImputer,StandardScaler,MinMaxScaler,OneHotEncoder,Winsorize(clip outliers),PowerTransform(Yeo-Johnson),ColumnTransformer(per-subset transforms), and the supervisedTargetEncoder. - Balancing (
src/balance.rs, viaimbalance-rs):Smote,RandomOverSampleras train-timeBalancers —Pipeline::balance(...), applied only duringfit. - Model selection (
src/selection.rs, viamodel-selection-rs):KFold/StratifiedKFold, aMetricenum (accuracy, F1, MAE, MSE, RMSE, R²), andGridSearch/RandomSearchover a whole pipeline, tuned by path.grid!macro included. - Ensembles (
src/ensemble.rs, core):Voting(hard/soft),Bagging, and leak-freeStackingriding the same CV engine — allModels themselves, so they compose, tune, and nest.
- The second backend (
src/backends/linfa.rs, vialinfa, featurelinfa-backend):KMeans,GaussianMixture,Dbscan(as a newClusterercontract) andPca(as aTransformer) — each convertingFrame → ndarrayat the edge, proving the boundary conversion against a whole other engine. - Bayesian search (
src/selection.rs, viahyperopt-rs, featurehpo):BayesSearchruns TPE search over aSearchSpaceand returns the sameSearchResultas grid/random search — one search API, three strategies.
- Evaluation reports (
src/evaluate.rs, core):model.evaluate(&test)bundles task-appropriate metrics into aReport(accuracy/precision/recall/F1 or MAE/MSE/RMSE/R²). - Regression diagnostics (
src/diagnostics.rs, viaregression-diagnostics, featurediagnostics):Diagnostics::of(&data)runs OLS and exposessummary(), R², per-column VIF, residuals, and Cook's distance. - Explainability (
src/explain.rs, viashap-rs, featureexplain):model.explain(&Explainer::kernel(), &frame)gives per-row SHAP values and global importance, pluspermutation_importance(...). - Report figures (
src/viz.rs, viaplotters-statistical, featureviz):viz::roc_svg(...)andviz::residuals_svg(...)render self-contained SVGs (pure-Rust backend, no system fonts). - Calibration (
src/calibration.rs, core, featurecalibration):PlattScaling/IsotonicRegressionturn raw scores into calibrated probabilities, withreliability_curvefor a reliability diagram. - Anomaly detection (
src/anomaly.rs, core, featureanomaly):MahalanobisandKnnScoreunsupervised outlier scorers.
- ONNX export (
src/onnx.rs, viaonnx-export-rs, featureonnx):model.export_onnx(path)forRandomForest(ONNX-ML tree ensemble) andLinearRegression; whole-pipeline export folds affine scalers into the estimator's graph as one.onnx. - Inference (via
tract, featureonnx):InferenceModel::load(path)loads and runs any ONNX file. tract executes the linear/affine/pipeline graphs (a full round-trip); tree-ensemble ONNX-ML artifacts run in external runtimes like onnxruntime. - Python bindings (
src/python.rs, viapyo3, featurepython): aPipelineclass over the same Rust core, built with maturin into an abi3 wheel.
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 engineBuild the Python module (from a virtualenv):
maturin develop --features python- Registry (
src/registry.rs, featureregistry):Registry::local(path)versions a model's ONNX artifact, content-addressed (identical models dedupe), with metadata + reference distribution, movable tags, androllback. - Drift monitor (
src/monitor.rs, viadriftwatch, featuremonitor):DriftMonitor::psi(reference)watches the prediction stream —observe+reportgive live PSI and a drift verdict. - Server (
src/serve.rs, viaaxum, featureserve):Server::from_onnxexposesPOST /predict(validated) over the tract runtime; with a monitor attached, every request feeds it andGET /metricsreports drift.
Server::from_onnx(reg.onnx_path("churn", "prod")?)?
.route("/predict")
.with_monitor(DriftMonitor::psi(&reference)?)
.serve("0.0.0.0:8080").await?;Same contract, different data shapes — each gets its own trait.
- Time series (
src/backends/chronos.rs, viachronos-ts, featuretimeseries):AutoArimaimplements aForecaster—fit(&series)thenforecast(steps). - Out-of-core (
src/backends/incremental.rs, viaincremental-rs, featureincremental):IncrementalLinearimplementsPartialFit+Predictor—partial_fit(&batch)learns one batch at a time.
These two crates pin ndarray 0.15 while the rest of the stack uses 0.16;
Cargo links both, and the boundary conversion happens only inside these
adapters — the "two ndarray worlds" the design settles, now exercised for real.
- AutoML (
src/automl.rs, featureautoml):AutoML::classifier()/regressor()searches preprocessing × model × hyperparameters under aBudget(trials or minutes), auto-ensembles the top candidates, and returns a ranked leaderboard plus the best fitted model. No new crate — it orchestrates the model-selection, ensemble, and backend machinery already built. A single-pipeline winner flows straight intoexport_onnx, so unlike a TPOT object the result deploys.
let result = AutoML::classifier()
.budget(Budget::trials(40))
.metric(Metric::F1)
.cv(StratifiedKFold::new(5))
.fit(&train)?;
println!("{}", result.leaderboard());
result.export_onnx("model.onnx")?; // deployablePin, prove, document — owning the one real risk of assembling young, single-author engine crates.
- Exact-version pins (
Cargo.toml): every engine — the ecosystem crates plus the smartcore and linfa families — is pinned to an exact=x.y.z, so a straycargo updatecan't 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. - Golden-output tests (
tests/golden.rs): lock the numeric behaviour of the engines on fixed inputs — exact for the deterministic paths (OLS, affine transforms, metric formulas), well-separated class labels for the stochastic ones. An engine bump that moves a number shows up as a diff. - Feature-matrix CI (
.github/workflows/ci.yml):fmt,clippy -D warnings, docs, an MSRV (1.80) build, and the test suite across the feature matrix — from--no-default-featuresthrough each feature tofull, plus Windows/macOS on the default install and a maturin wheel for Python. - The tutorial (
GUIDE.md+guide.html): the design brief's lifecycle, re-cast as a hands-on guide.
The front of the lifecycle, behind the eda feature (via polars).
Table(src/table.rs): a dtype-aware, polars-backed table —Table::from_csv/from_parquetread real string/categorical/datetime/null columns. It lowers to the numeric world:table.to_frame()andtable.into_dataset("target")(categoricals label-encoded, nulls →NaN), soFramestays the numeric boundary everything else already speaks.Profile(src/profile.rs):Profile::of(&table)returns a typed EDA — overview, per-column numeric/categorical profiles, missingness, Pearson correlations (high-|r| pairs flagged), IQR outliers, and target relationship (class balance or feature-target correlation). It renders a self-containedto_html(path)report, listsalerts()that name the fix, and — the loop scikit-learn can't close —suggest_pipeline()drafts the preprocessing from those findings; you just add the model.
let table = Table::from_csv("customers.csv")?;
let profile = Profile::of_with_target(&table, "churned")?;
profile.to_html("eda.html")?;
let train = table.into_dataset("churned")?;
let mut pipe = profile.suggest_pipeline() // impute · encode · scale, from the alerts
.estimator("rf", RandomForest::new());
pipe.fit(&train)?;use millwright::grid;
use millwright::prelude::*;
let pipe = Pipeline::new()
.step("impute", SimpleImputer::median())
.step("scale", StandardScaler::new())
.balance(Smote::new()) // train-time only
.estimator("rf", RandomForest::new());
let search = GridSearch::new(pipe, grid! { "rf__max_depth" => [4, 8, 16] })
.cv(StratifiedKFold::new(5))
.scoring(Metric::F1)
.fit(&train)?;
println!("best F1 = {:.3}", search.best_score());
let preds = search.predict(&test)?;Run the end-to-end examples:
cargo run --example spinecargo run --example explore --features "eda smartcore-backend"cargo run --example workflowcargo run --example backends --features "smartcore-backend linfa-backend hpo"cargo run --example insight --features "smartcore-backend diagnostics explain viz"cargo run --example portability --features "smartcore-backend onnx"cargo run --example operations --features "smartcore-backend onnx registry monitor serve"cargo run --example specialized --features "timeseries incremental"cargo run --example automl --features "smartcore-backend automl onnx"The default toolchain is MSVC. If a Unix link.exe (e.g. from Git/Laragon) is
ahead of MSVC's on PATH, linking fails with an "extra operand" error. Build
from a Developer Command Prompt / PowerShell for VS 2022, or run vcvars64.bat
first, so the MSVC linker is found before the shadowing one.
Phases 0–8 are done — the full lifecycle plus 1.0 hardening (exact-version pins,
a committed lockfile, golden-output tests, and a feature-matrix CI). The design
brief lays out the arc; the tutorial (GUIDE.md) is the how.