millwright
A unified ML framework for Rust — ten crates, one lifecycle.
- Tutorial: the hands-on docs site at https://millwright-rs.dev/docs/
(source in
docs/; a shortGUIDE.mdhas the quickstart). - Contributing: see
CONTRIBUTING.md. - 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.
Status: Phases 0–8 — done
Phase 0 · the spine
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).
Phase 1 · prep & select — a real, tunable, ensemble-ready workflow
- 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/, 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.
Phase 2 · backends & HPO — two backends, one contract
- 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/, viahyperopt-rs, featurehpo):BayesSearchruns TPE search over aSearchSpaceand returns the sameSearchResultas grid/random search — one search API, three strategies.
Phase 3 · insight — trust the model, not just run it
- 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). - Probabilities (
src/logistic.rs, core):LogisticRegressionis a native, probability-capable classifier — the first realProbaPredictor. - Calibration (
src/calibration.rs, featurecalibration):PlattScaling/IsotonicRegressionandreliability_curve, plusCalibratedClassifier, which wraps anyProbaPredictorand returns calibrated probabilities. - Anomaly detection (
src/anomaly.rs, featureanomaly):MahalanobisandKnnScore, unified behind anOutlierDetectortrait.
Phase 4 · portability & Python — train once; run in Rust, Python, or any ONNX runtime
- 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, shipped on PyPI as an abi3 wheel.
=
# list[list[float]], list[float]
= # runs the Rust engine
To build from source (contributors), from a virtualenv: maturin develop --features python.
Phase 5 · operations — past where scikit-learn stops
- 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.
from_onnx?
.route
.with_monitor
.serve.await?;
Phase 6 · specialized — the long tail of real workloads
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.
Phase 7 · synthesis — auto-sklearn, but the output actually deploys
- 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 = classifier
.budget
.metric
.cv
.fit?;
println!;
result.export_onnx?; // deployable
Phase 8 · harden → 1.0 — a framework you can bet on
Pin, 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, and the test suite across the feature 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 = 1.95, dep-dictated) is enforced by cargo for consumers. - The tutorial (
GUIDE.md+guide.html): the design brief's lifecycle, re-cast as a hands-on guide.
Ingest & EDA — the lifecycle starts where the data does
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 = from_csv?;
let profile = of_with_target?;
profile.to_html?;
let train = table.into_dataset?;
let mut pipe = profile.suggest_pipeline // impute · encode · scale, from the alerts
.estimator;
pipe.fit?;
Quickstart
use grid;
use *;
let pipe = new
.step
.step
.balance // train-time only
.estimator;
let search = new
.cv
.scoring
.fit?;
println!;
let preds = search.predict?;
Run the end-to-end examples:
Building on Windows
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.
Roadmap
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.