A book-length, runnable introduction to machine learning in Rust — 20+ parts,
40+ chapters spanning ndarray/polars foundations, EDA & ETL, model evaluation,
regression & classification, clustering, trees & ensembles, multithreading,
optimization & gradient descent, AutoML, explainability, persistence & serving,
monitoring, time series, larger-than-memory data, anomaly detection, calibration,
and an end-to-end capstone. Every chapter is a live Rust notebook — its code is
compiled and run by the evcxr kernel to produce
the plots and results you see — published as a
Jupyter Book from the notebooks in book/.
The guide ships with the portable Rust Jupyter environment that runs it: a
Dockerized JupyterLab + evcxr setup with the ML crates
pre-warmed into the image, so the whole thing runs with the only host dependency
being Docker — no local Rust or Jupyter install.
make up # start JupyterLab at http://localhost:8888The chapters appear in the guide/ folder in the JupyterLab file browser —
open any one, pick the Rust kernel, and run its cells. Prefer the rendered
static site (with all outputs embedded)? make book builds it to
book/_build/html — see The book.
The runtime under the guide (also usable on its own for any Rust data-science / ML notebook work):
| Component | Purpose |
|---|---|
Rust 1.90 (pinned) |
Toolchain (>= 1.85 required by evcxr) |
evcxr_jupyter |
Rust Jupyter kernel |
| JupyterLab | Notebook UI |
jupytext |
Git-friendly .ipynb ⇄ .md pairing |
nbconvert + pandoc |
Export to HTML / PDF / script |
Pre-warmed crates (compiled into the image cache so first use is instant):
ndarray, polars, linfa, linfa-clustering, linfa-trees, linfa-linear,
linfa-logistic, linfa-reduction, smartcore, plotters, argmin (plus the
addenda crates: rayon, tpe, automl, rust_shap, augurs, axum, and more —
see the crate reference).
Honest answer: it depends on the layer. The wins are large and well-documented for data wrangling, and more nuanced for model training. Where Rust doesn't clearly beat Python, this section says so.
polars is the standout. On the PDS-H benchmark suite (derived from TPC-H),
Polars' own published results put it ~an order of magnitude faster than Dask
and PySpark, and dramatically faster than pandas — up to ~30× on the most
join-heavy queries, with 3–10× being typical in everyday workloads. It also
used roughly 63% of the energy pandas needed on large frames.
| Workload | Reported result | Source |
|---|---|---|
| PDS-H (TPC-H-derived) queries | Polars ≈ order of magnitude faster than Dask/PySpark; up to ~30× vs pandas | pola.rs benchmarks |
| Large-frame processing energy | Polars ≈ 63% of pandas' energy | pola.rs energy benchmark |
Why the gap: pandas' hash-join is single-threaded, while Polars uses a multithreaded hash-join with predicate/projection pushdown over Arrow columnar memory. (Note: PDS-H results aren't official TPC-H numbers and depend heavily on hardware — the figures above came from a 96-vCPU cloud box. Treat them as directional, not guarantees.)
- Compiled + no GIL. Rust compiles to native code and has no Global Interpreter Lock, so CPU-bound work parallelizes across cores for free. Python leans on C extensions (NumPy, scikit-learn) precisely to escape these limits — with Rust you're already in the fast layer, no FFI boundary.
- Predictable memory, no GC pauses. Ownership gives tight, deterministic memory use — the basis for the energy result above.
- Single-binary deployment. A trained model compiles into one static binary with no Python runtime, interpreter version, or dependency environment to ship — often the deciding factor for embedded / serverless / edge inference.
- Type & memory safety end-to-end. The compiler catches shape/type mistakes before runtime, across the whole data-to-model pipeline.
For the modeling crates (linfa, smartcore) the speed story is not a
slam-dunk, and this guide won't pretend otherwise: scikit-learn's estimators are
already backed by C/Cython/BLAS, so they're fast. The Rust case for these is
less "it's X times faster" and more deployment, safety, memory footprint, and
staying in one language end-to-end. The ecosystem is also younger and thinner
than Python's — the book's later chapters flag this explicitly, crate by crate.
Bottom line: reach for Rust here when you want a fast, parallel data pipeline and a self-contained deployable artifact — not on the assumption that every model trains faster than scikit-learn.
Sources: pola.rs benchmarks · pola.rs energy benchmark
- Docker (with the Compose plugin —
docker compose) makeis optional; every target maps to a plaindocker composecommand if you'd rather run those directly.- Memory: give Docker a decent chunk of RAM (≥ 6–8 GB recommended). The
build compiles the whole crate set at once; on a memory-starved Docker VM this
can OOM and crash the engine (a
Bus error (core dumped)mid-build). The pre-warm caps parallelism (PREWARM_JOBS, default 4) to keep this in check — lower it further on tight machines:docker compose build --build-arg PREWARM_JOBS=2.
make build # docker compose build
make up # start JupyterLab, prints http://localhost:8888Then open http://localhost:8888 and launch the Rust kernel. Open
example.ipynb and run all cells to smoke-test every pre-warmed crate.
Stop it with:
make downNo make? The equivalents:
docker compose build
docker compose up -d
docker compose downBy default the server runs tokenless for convenience on a local machine.
To require a token, set JUPYTER_TOKEN before starting:
JUPYTER_TOKEN=mysecret make up
# then browse to http://localhost:8888/?token=mysecretJUPYTER_TOKEN is read natively by Jupyter Server. Leave it empty for no auth.
The host ./notebooks directory is mounted into the container at /workspace.
Anything you create in JupyterLab lands there on your host — persistent,
portable, and git-trackable. The container itself is disposable.
Two named volumes persist state across restarts:
cargo-registry— crates you add later (via a new:dep) that weren't in the pre-warm set, so they don't re-download every restart.sccache-cache— the compilation cache (see below), seeded on first run from the pre-warmed cache baked into the image.
evcxr recompiles a notebook's dependencies from scratch, in a throwaway build
directory, on every kernel session. So baking crates into the image at build
time only caches their downloads — not their compiled form — which on its
own saves almost nothing at notebook time. To make the pre-warm actually pay
off, the image routes every rustc call through
sccache (RUSTC_WRAPPER), a persistent
compilation cache. The pre-warm step populates it during the build, so the
first time you use a pre-warmed crate in a notebook it's a cache hit (measured
~5-10x faster on the heavy crates) instead of a cold compile.
The .ipynb file is already the most portable artifact — it opens on any
machine with Jupyter + the evcxr kernel, or back in this container elsewhere.
For other formats (NOTEBOOK is relative to notebooks/):
make export-html NOTEBOOK=example.ipynb # -> notebooks/example.html
make export-script NOTEBOOK=example.ipynb # -> notebooks/example.rs (reference log)
make export-pdf NOTEBOOK=example.ipynb # needs the PDF build (see below)
make pair NOTEBOOK=example.ipynb # create/refresh a .md twin (ipynb,md)jupytext pairing keeps a plain-text .md version of a notebook alongside
the .ipynb, so git diffs are readable and notebooks can be edited as text.
Run make pair once per notebook; JupyterLab then keeps both in sync on save.
Note:
export-scriptdumps the Rust cells as a flat file. It is not directly compilable — evcxr cells aren't a single crate — but it's a useful readable log of the code.
Windows / Git Bash users: MSYS rewrites the container path
/workspace/...into a host path before Docker sees it, which breaks theexport-*/pairtargets. Prefix the command withMSYS_NO_PATHCONV=1, e.g.MSYS_NO_PATHCONV=1 make export-html NOTEBOOK=example.ipynb. This does not affect Linux/macOS hosts.
PDF needs texlive-xetex (~1GB+), so it's behind a build arg and off by
default. Build the PDF-capable image with:
make build-pdf # docker compose build --build-arg INCLUDE_PDF_EXPORT=true-
Add a pinned
:depline toprewarm.evcxr. -
Either rebuild the image (
make build) to bake it in, or warm the running container without a full rebuild:make rebuild-cache # re-runs prewarm.evcxr inside the container
Keep the crate list in prewarm.evcxr and the README table in sync.
The book/ directory is a Jupyter Book: the
full guide, written as executable notebooks that are compiled and run at build
time so the published pages show real Rust outputs (printed results and
plotters charts). The structure and full chapter order live in
book/_toc.yml.
Two ways to read it:
make up, then in JupyterLab open the guide/ folder. Chapters are laid out
by part (01-foundations/, 02-regression/, … 13-anomaly-detection/,
14-model-calibration/). Open any notebook, pick the Rust kernel, and run it.
guide/ is the live book/ source mounted into the workspace, so it's
always in sync — edit a chapter here and make book picks it up.
make up # start the container (once)
make book # jupyter-book build /book --all -> book/_build/htmlThen open book/_build/html/index.html. On Windows/Git Bash prefix with
MSYS_NO_PATHCONV=1 (same reason as the export targets above).
make book re-executes every chapter (execute_notebooks: force in
book/_config.yml) so output always matches current code. If
per-chapter Rust recompiles make this too slow, switch that setting to cache.
A workflow at .github/workflows/deploy-book.yml
builds the image, runs jupyter-book build inside it, and deploys book/_build/html
to Pages on every push to main (PRs build a preview without deploying). To turn
it on:
- In
book/_config.yml, setrepository.urlto your repo. - Push to GitHub, then in Settings → Pages set Source: GitHub Actions.
The CI image build compiles evcxr + all crates (~20+ min) each run unless you add layer caching. The AutoML chapter's
automlcrate is git-sourced, which adds fetch/compile time.
- Create a notebook in the right
book/NN-part/folder (Rust kernel). - Add its path to
book/_toc.yml. - Pair it:
make sync-notebooks(keeps a.mdtwin for readable diffs). - Open a PR — CI executes and previews it.
make sync-notebooks runs jupytext --sync over every chapter, keeping each
.ipynb paired with a MyST .md. Commit both; PR diffs then read as Markdown
instead of giant JSON blobs.
-
A fresh
git clone+make build && make upshould reach a working JupyterLab with the Rust kernel and zero other host setup. -
Multi-arch: to confirm the image builds for both Intel and Apple Silicon:
docker buildx build --platform linux/amd64,linux/arm64 .This environment was authored/tested on
amd64; the Dockerfile uses only arch-neutral base images and packages, but treatarm64as untested until you run the buildx check on your side.
- Per-cell compile latency is inherent to evcxr: each cell that introduces new code is compiled. Pre-warming removes the dependency compile cost, not the incremental per-cell cost. This is a property of the tool, not something this setup can fully hide.
export-scriptoutput is a reference log, not a buildable crate (see above).