millwrightv0.1
A unified ML framework for Rust

fit. predict.
serve. watch.

proven Rust crates, assembled into one machine

scikit-learn stops at .predict(). Millwright ends at a served, drift-monitored ONNX model — and treats every step in between as one composable pipeline.

fit · transform · predict one Frame, every backend train → ONNX → serve → watch capabilities are cargo features Rust core · Python API
Read the guide → crates.io PyPI GitHub cargo add millwright
The through-line

The whole lifecycle, as pipeline stages.

Each stage is a first-class step powered by a crate you already own. Exploration sits up front — the Rust answer to pandas-profiling — and the last two stages are where Millwright runs past scikit-learn.

01 · INGEST

Load & frame

polars → Table → Frame
CSV/Parquet into a typed, dtype-aware Table, lowered to the numeric Frame.
02 · EXPLORE

Profile the data

Table → Profile
One call → stats, missingness, correlation, outliers, alerts — and a suggested pipeline.
03 · PREP

Preprocess

imbalance-rs
Impute · encode · scale · SMOTE — as pipeline transformers.
04 · SELECT

Search & validate

model-selection-rs · hyperopt-rs
Stratified CV, grid / random / Bayesian search over the pipeline.
05 · FIT

Train

linfa · smartcore
Any backend model, behind one Estimator contract.
06 · ASSESS

Evaluate

regression-diagnostics
Metrics, residual & calibration diagnostics, plotters-statistical reports.
07 · EXPLAIN

Interpret

shap-rs
SHAP values & permutation importance on the fitted pipeline.
08 · EXPORT

To ONNX

onnx-export-rs
One portable artifact — train in Rust, serve anywhere.
09 · OPERATE

Serve & monitor

axum · driftwatch
A /predict endpoint with live PSI drift on the request stream.
Architecture

A thin contract over proven engines.

Four layers. The public API never names a specific ndarray version, and the core stays small — every engine plugs in through the same four traits.

▲ you write this
Fluent APImillwright::prelude
ProfilePipelineGridSearchExplainerServerDriftMonitorRegistry
Core contractthe framework
Frame / Datasettrait Estimatortrait Transformertrait Predictortrait ProbaPredictorColumnTransformer
Backend adapters#[cfg(feature)]
smartcore ⇄linfa ⇄chronos-ts ⇄incremental-rs ⇄tract (onnx infer)
Enginesyour crates + the stack
linfasmartcorepolarsmodel-selection-rshyperopt-rsimbalance-rsshap-rsregression-diagnosticsonnx-export-rsdriftwatchchronos-tsplotters-statistical
▼ these keep shipping as standalone crates

The four traits

Object-safe so a Pipeline can hold a heterogeneous Vec<Box<dyn …>>. Everything composes because everything speaks the same contract.

  • Estimatorfit(&Dataset) → Fitted
  • Transformertransform(&Frame) → Frame
  • Predictorpredict(&Frame) → Array
  • ProbaPredictorpredict_proba(&Frame) → Frame

Params by path

Pipeline steps are addressable by name, so a search can tune any parameter anywhere in the chain — the scikit-learn "step__param" convention.

// tune the forest inside a 4-step pipeline
grid! {
  "scale__with_mean" => [true, false],
  "rf__max_depth"    => [4, 8, 16],
  "rf__n_trees"      => [100, 300],
}
The hard problem · solved by design

Two ndarray worlds, one Frame.

linfa pins ndarray 0.15; imbalance-rs and model-selection-rs use 0.16; smartcore has its own DenseMatrix. These cannot meet in one function signature. This is the reason a unified framework doesn't exist yet — so it's the first thing the design settles.

The framework owns the boundary type

Millwright's Frame is a contiguous f64 buffer + schema + optional target. The public API only ever speaks Frame — users are never locked to a version, exactly how pandas/NumPy sit under scikit-learn.

Frame { buf: Vec<f64>, shape, cols, target }

Adapters convert at the edge only

Each backend adapter converts Frame ⇄ its native type inside the adapter — an O(n) copy over a row-major buffer, with a zero-copy fast path where layout and version already agree. The version war never reaches user code.

frame.as_nd15() · as_nd16() · as_dense() → used only in adapters
What it feels like

Data in, monitored service out.

One fluent chain that would today be a dozen crates and a hundred lines of glue. This is the whole pitch in one screen.

use millwright::prelude::*;

// 0 — look before you leap: one call profiles the whole frame to HTML
Profile::of(&train)?.to_html("eda_report.html")?;   // polars-backed EDA

// 1 — compose a pipeline: preprocessing + a model, one object
let pipe = Pipeline::new()
    .step("impute", SimpleImputer::median())
    .step("encode", OneHot::infer())
    .step("scale",  StandardScaler::new())
    .balance(Smote::default())              // imbalance-rs · train-time only
    .estimator("rf", RandomForest::new());

// 2 — search & cross-validate the whole pipeline
let model = GridSearch::new(pipe, grid! { "rf__max_depth" => [4, 8, 16] })
    .cv(StratifiedKFold::new(5))        // model-selection-rs
    .scoring(Metric::F1)
    .fit(&train)?;

// 3 — assess & explain
let report = model.evaluate(&test)?;         // metrics + regression-diagnostics
let shap   = model.explain(Explainer::kernel(), &test)?;   // shap-rs

// 4 — ship it: one ONNX artifact, served, with drift on every request
model.export_onnx("churn.onnx")?;           // onnx-export-rs

Server::from_onnx("churn.onnx")             // tract inference
    .with_monitor(DriftMonitor::psi(&train)) // driftwatch
    .route("/predict")
    .serve("0.0.0.0:8080").await?;         // axum

The timeseries feature swaps the estimator for a chronos-ts auto-ARIMA forecaster behind the same fit/predict contract; incremental swaps .fit() for .partial_fit() over batches that never fully load into memory.

Spotlight · exploration

One call profiles the data — and drafts the pipeline.

Profile::of(&frame) is the Rust answer to ydata-profiling, with a twist only a framework that owns the whole lifecycle can pull off: it returns a typed analysis (not just an HTML blob), renders a shareable report, and hands back a suggested preprocessing pipeline to start from.

What it computes

Overview

Shape, dtypes, memory, duplicate rows, and overall missingness at a glance.

Per-column

Numeric: mean/std, quartiles, skew & kurtosis, zeros, distinct, histogram. Categorical: mode, frequencies, cardinality.

Missingness

Per-column nulls, a missingness matrix, and whether columns tend to go missing together.

Correlations

Pearson & Spearman matrices with high-|r| pairs flagged — an early read on multicollinearity.

Outliers

IQR and z-score flags per numeric column, with counts — ready to winsorize or robustly scale.

Target relationship

Classification: class balance & per-feature split by class. Regression: feature-vs-target strength.

What it returns

Typed fields you can branch on in code — the HTML report is just one renderer over them.

struct Profile {
  overview:     Overview,        // shape · dtypes · dups · missing
  columns:      Vec<ColumnProfile>, // Numeric|Categorical|Datetime
  missingness:  Missingness,      // nulls + co-missing map
  correlations: CorrMatrix,        // Pearson+Spearman, flagged
  target:       Option<TargetProfile>,
  alerts:       Vec<Alert>,       // the actionable summary
}

Renderers: .to_html(path) · .summary() (text) · .alerts(). Scales past memory — Profile::of also accepts a lazy/streaming frame via polars.

Alerts that map to steps

Because Millwright owns EDA and the pipeline, every data-quality alert names the preprocessing that answers it.

AlertSuggested step
High missingnessSimpleImputer
High-cardinality categoryTargetEncoder
Constant / zero-varianceDrop
Correlated pair · |r| > .95drop one · flag VIF
Skewed / heavy-tailedPowerTransform
Class imbalanceSmote
Outliers (IQR)Winsorize

The loop scikit-learn can't close

scikit-learn profiles nothing and proposes nothing; ydata-profiling profiles but stops at a report. Millwright turns the profile into a running head start:

let profile = Profile::of(&train)?;          // full EDA, one call
profile.to_html("eda_report.html")?;         // a shareable report

for alert in profile.alerts() {              // the data-quality summary, typed
    println!("{alert}");
    // income: 12% missing → impute · city: 41 levels → target-encode · target 4:1 → balance
}

// EDA drafts the starting pipeline — you just add the model
let pipe = profile.suggest_pipeline()        // imputers · encoders · scalers · SMOTE
    .estimator("rf", RandomForest::new());
Spotlight · ensembles

Combine models — even across backends.

Because every model is a Predictor, combining them is just another Predictor that holds several — no new machinery. And the trait is backend-agnostic, so a linfa model, a smartcore forest, and a chronos-ts forecaster can sit in one ensemble. scikit-learn can only ensemble scikit-learn.

Four ways to combine

Voting

Hard (majority) or soft (mean-probability) vote over several fitted models — the quickest lift over any single one.

Stacking

A meta-learner trained on the base models' out-of-fold predictions — leak-free, because the CV engine supplies the folds.

Bagging

Bootstrap-resample, fit a base estimator per sample (in parallel over rayon), aggregate — and it works for any estimator, not just trees.

Boosting

SAMME adaptive boosting: fit weak learners in sequence, each reweighted toward the last round's mistakes, then an alpha-weighted vote.

Composition, not configuration

// soft-vote across three different model families → one Predictor
let vote = Voting::soft()
    .add("lr",  LogisticRegression::new())
    .add("rf",  RandomForest::new())
    .add("svc", Svc::rbf());

// stack: a meta-learner on leak-free out-of-fold base predictions
let stack = Stacking::meta(LogisticRegression::new())
    .base("rf",  RandomForest::new())
    .base("knn", Knn::k(15))
    .cv(StratifiedKFold::new(5));       // model-selection-rs

// bag any estimator — bootstrap resamples fit in parallel, then aggregate
let bag = Bagging::of(Svc::rbf()).n_estimators(50);

// boost weak stumps — SAMME adaptive boosting
let boost = Boosting::of(RandomForest::new().max_depth(1)).n_estimators(100);

// an ensemble IS an estimator — tune a member straight through it
let model = GridSearch::new(stack, grid! { "rf__max_depth" => [8, 16] })
    .cv(StratifiedKFold::new(5)).fit(&train)?;

Free by construction

  • No new crate. Voting, stacking, and bagging are pure composition over the four traits — they live in the core and are always on.
  • Cross-backend. The unified Predictor is what lets a linfa, a smartcore, and a chronos-ts model vote together — the one thing scikit-learn structurally cannot do.
  • Leak-free stacking. Out-of-fold predictions come from the same model-selection-rs CV engine, so the meta-learner never sees a base model's own training rows.
  • Still just a model. Ensembles are Estimators — pipeline-able, searchable per member, ONNX-exportable, SHAP-explainable.

Native ensembles — RandomForest, ExtraTrees, GradientBoosting — arrive from the backends as ordinary estimators, tunable and pipeline-able like anything else.

Spotlight · automl

The framework, pointed at itself.

Everything above — profiling, preprocessing, cross-validation, hyperparameter search, ensembling — is exactly what an AutoML engine needs. So Millwright's AutoML isn't a bolt-on: it's the framework orchestrating its own parts. Point it at a dataset and a budget; get back the best deployable pipeline and a leaderboard.

What it searches

Preprocessing

Imputation, encoding, scaling strategies — seeded by the Profile's alerts, not brute-forced blind.

Model zoo

Linear · logistic · KNN · SVM · naive Bayes · forests — across backends, all behind the one Estimator contract.

Hyperparameters

TPE / Bayesian search per candidate (hyperopt-rs), every fit scored by the CV engine.

Auto-ensemble

Stack the top-k candidates into a final blend — the auto-sklearn move, using the ensemble core.

Point it at data, get a pipeline

let result = AutoML::classifier()
    .budget(Budget::trials(200))          // or Budget::minutes(10)
    .metric(Metric::F1)
    .cv(StratifiedKFold::new(5))
    .parallel()                           // search fans out over rayon
    .fit(&train)?;

println!("{}", result.leaderboard());     // ranked pipelines + scores
let best = result.best_pipeline();         // the winning Pipeline (if not an ensemble)

// …and it flows straight into the rest of the lifecycle
best.explain(Explainer::kernel(), &test)?;
best.export_onnx("model.onnx")?;          // deployable — unlike a TPOT object

More than a wrapper

  • Seeded, not blind. The search starts from Profile::suggest_pipeline() — EDA's findings prune the space before a single model is fit.
  • A deployable artifact. auto-sklearn and TPOT hand you a Python object; Millwright's winner is an ONNX-exportable, servable, monitorable pipeline.
  • Its own parts. No separate AutoML crate to trust — it reuses model-selection-rs, hyperopt-rs, and the ensemble core you already use by hand.
  • Budgeted & parallel. Cap it by trials or wall-clock; candidates evaluate across cores and the leaderboard fills in live.
Spotlight · interop

Rust core, Python API.

scikit-learn's users live in Python — so to stand toe to toe, Millwright ships a first-class Python package: the Polars playbook, a Rust engine behind a Pythonic API. Write the pipeline in Python, run it at Rust speed, pass pandas or NumPy straight in, and get an ONNX model out.

The same pipeline, from Python

import millwright as mw

train = mw.Frame.from_pandas(df)             # or from_numpy / from_rows / a mw.Table
mw.Profile.of(train).to_html("eda.html")     # automated EDA, in Rust

pipe = (mw.Pipeline()
    .step("scale", mw.StandardScaler())
    .estimator("rf", mw.RandomForest(n_trees=200, max_depth=8)))

pipe.fit(train, y_train)
pipe.evaluate(test, y_test)                  # {"accuracy": ..., "f1": ...}
pipe.explain(test)                           # SHAP feature ranking (shap-rs)
pipe.export_onnx("churn.onnx")               # one portable ONNX artifact

How it fits

  • pyo3 bindings. pip install millwright — a Pythonic pipeline over the Rust core, shipped as an abi3 wheel. Reads numpy arrays and pandas DataFrames directly.
  • The whole lifecycle. Dtype-aware EDA (mw.Table / mw.Profile), grid + stratified-CV search (mw.GridSearch), SHAP (explain) and ONNX export — the same engines as Rust, in one wheel.
  • ONNX both directions. Export a Millwright pipeline to ONNX for any serving stack — and consume a scikit-learn / PyTorch model (exported to ONNX, run through tract) as a pipeline step via mw.OnnxModel.
  • One codebase, not a fork. The Python API is a thin binding over the same Rust types — no duplicated logic, no drift. It lives behind the python feature.
Spotlight · mlops

A model isn't done when it's trained.

The moment a model serves traffic you need to know what produced it — which data, which pipeline, which metrics — and to roll back when something slips. scikit-learn tracks none of that. Millwright's Registry versions the whole artifact and closes the loop back to retraining.

What a version records

Artifact

The fitted pipeline and its ONNX export, content-addressed so identical models dedupe.

Lineage

Data hash, config, random seed, git commit — enough to reproduce the exact model later.

Metrics

Held-out and CV scores travel with the version, so any two are comparable at a glance.

Reference

The training distribution the drift monitor watches live traffic against — not a guess.

Register, serve, roll back

// version a trained pipeline — artifact + ONNX + metrics + lineage
let v = Registry::local("./models")
    .register("churn", &model)?
    .tag("prod");                        // a movable pointer

// serve straight from the registry; the monitor uses the stored reference
Server::from_registry("churn", "prod")
    .with_monitor(DriftMonitor::from_registry(&v))
    .serve("0.0.0.0:8080").await?;

// when drift fires: retrain on the recorded lineage — or revert in one line
Registry::local("./models").rollback("churn", "prod")?;

Closing the loop

  • Content-addressed. A version is the hash of its artifact; a tag like prod is just a pointer you can move or revert without copying anything.
  • Reproducible. Data hash + config + seed + commit is enough to rebuild the exact model — the thing "it worked yesterday" usually can't.
  • Monitored against truth. Drift compares live traffic to the version's own stored training distribution, so alerts mean something.
  • The retrain loop. When drift fires, the lineage is right there to retrain on fresh data — and the previous version is one rollback away. That's the loop scikit-learn leaves as homework.
"As features" — exactly as you asked

Pull only what you need.

Every capability is a cargo feature over one crate. default is a lean, useful core; full lights up the whole lifecycle. A serving binary need never compile SHAP; a notebook need never compile axum.

FeatureCrateAdds
smartcore-backenddefaultsmartcoreKNN · NB · SVM · trees · forests · linear
preprocessingdefaultimbalance-rsimpute · scale · encode · SMOTE transformers
model-selectiondefaultmodel-selection-rsstratified/group/time CV · grid · random
ensembledefaultcorevoting · stacking · bagging meta-estimators — compose any Predictors, across backends
edapolarsdtype-aware Table + automated Profile report: stats · missingness · correlation · outliers
linfa-backendlinfak-means · DBSCAN · GMM · PCA (via boundary conversion)
hpohyperopt-rs · tpeBayesian / TPE hyperparameter search
automlhyperopt-rs · model-selection-rsautomated preprocessing + model + HPO search with an auto-ensembled, deployable winner
diagnosticsregression-diagnosticsVIF · residual tests · influence · summary()
explainshap-rsSHAP values · permutation importance
calibrationcoreprobability calibration (Platt · isotonic) · reliability curves · CalibratedClassifier
anomalycoreoutlier detection: Mahalanobis · kNN score (Isolation Forest as the ecosystem matures)
vizplotters-statisticalROC · calibration · residual · learning-curve charts
onnxonnx-export-rs · tractexport trained pipelines · load & run ONNX
serveaxum · tokioHTTP inference server + input validation
monitordriftwatch · tracingPSI / data & prediction drift · metrics endpoint
registrycore · serdeversioned model registry: pipeline + ONNX + metadata + reference distribution
timeserieschronos-tsARIMA / auto-ARIMA forecasters · stationarity
incrementalincremental-rsout-of-core partial_fit pipelines
pythonpyo3pip install millwright — a Pythonic pipeline over the Rust core, reading numpy / pandas directly

full = [every feature above] · default = ["smartcore-backend", "preprocessing", "model-selection", "ensemble"]

Honest boundaries

What it is — and isn't.

Design commitments

  • Thin facade. The core is Frame + four traits. Every engine is an adapter; the god-crate temptation is resisted by construction.
  • ONNX is the artifact. The trained thing is portable and backend-agnostic — the training engine is an implementation detail by Phase 4.
  • The contract is stable; backends churn. Commit to the traits in 0.1; let young crates evolve behind them. The framework becomes their stability layer.

Non-goals & risks

  • Not a numerics kernel. No new linear algebra — it orchestrates proven implementations.
  • Not GPU/distributed in v1. CPU + rayon parallelism; scale-out is a later story, flagged not faked.
  • Dependency maturity. Ten young single-author crates underneath — mitigated by exact-version pins and a feature-matrix CI, but it is the real risk to own.
  • Conversion cost. The two-ndarray bridge copies; measured, with zero-copy fast paths where layout allows.