Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

RustyML is a machine learning and deep learning library written entirely in Rust. It needs no external library to link against, no Python interpreter, and no FFI boundary to move arrays across. This guide is the hands-on companion to the library. It takes you from a fresh cargo new to training neural networks. It also covers tuning the parallel and serial thresholds inside the hot kernels, and shows what lands on disk when you serialize a model. This guide tracks RustyML 0.14. Every complete example compiles against that version with the full and show_progress features on, so what you read is what the compiler accepts.

What this guide is

The API reference on docs.rs is the source of truth for every signature, every trait bound, and every enum variant. This guide does not duplicate that reference. It explains the decisions a signature does not state. docs.rs lists the arguments to Adam::new. This guide explains which optimizer to choose. It explains why a per-call random_state overrides the global seed instead of the reverse. It also explains what happens when you load a saved network into a model whose layer shapes no longer match. The guide stays concrete, states an opinion where the evidence supports one, and names the pitfalls in specific estimators.

The classical-ML half of the crate follows one shape almost everywhere: construct, fit, predict. That is the same (&x, &y)-then-&x rhythm scikit-learn uses.

use rustyml::prelude::machine_learning::*;
use ndarray::array;

fn main() {
    // A tiny linear relationship: y = 3 * x
    let x = array![[1.0], [2.0], [3.0], [4.0]];
    let y = array![3.0, 6.0, 9.0, 12.0];

    // new(fit_intercept). The default solver is the exact closed form
    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    let predictions = model.predict(&x).unwrap();
    println!("predictions: {:?}", predictions);
}

The rest of this guide unpacks that pattern, one module at a time. Models share the Fit and Predict traits. Metrics always take (y_true, y_pred) in that order. A single set_global_seed call makes the whole crate deterministic.

Who it is for

This guide serves 2 kinds of readers. The first is a Rust developer who wants machine learning without leaving the Rust ecosystem. That reader needs no pip, no linked BLAS, and no unsafe FFI to audit. A cargo add command adds a crate that follows Rust’s rules on ownership, Send/Sync, and error handling. The second is an ML practitioner coming from Python, fluent in scikit-learn and Keras. That reader wants the same mental model in Rust. RustyML gives it: fit/predict methods and a Keras-style Sequential model built with .add(...) and .compile(...). Its confusion matrices and silhouette scores mean what they mean in scikit-learn. RustyML expresses these ideas as compiled, statically typed, parallel-by-default Rust. Where RustyML departs from scikit-learn or Keras, this guide states the difference and the reason for it.

You do not need prior experience with Rust numerical code. You should be comfortable reading Rust and running cargo. Data flows through ndarray arrays throughout the crate, so Working with ndarray covers that library before you meet it in every later chapter.

How the book is organized

The crate splits into 5 feature-gated modules: machine_learning, neural_network, utils, metrics, and math. It also has a domain-split prelude. The chapters follow that structure. Read the first chapter start to finish, then use the rest as reference material.

ChapterCoversMaps to
1. Getting StartedInstallation, feature flags, ndarray, your first end-to-end model, the prelude, error handlingthe on-ramp
2. Classical Machine LearningRegression, classification, clustering, dimensionality reduction, anomaly detectionmachine_learning
3. Neural NetworksThe Sequential model, dense/conv/recurrent layers, losses, optimizers, saving weightsneural_network
4. Data PreprocessingTrain/test splitting, standardization and normalization, label encodingutils
5. Model EvaluationRegression, classification, and clustering metricsmetrics
6. Math UtilitiesDistance metrics, matrix multiplication, deterministic parallel reductionsmath
7. Advanced TopicsReproducibility and seeds, model persistence internals, performance tuning, minimal buildscross-cutting

Chapters 2 and 3 cover the two large algorithm families and most of the crate’s surface area. Chapters 4 through 6 support those families. Preprocessing runs before a model, metrics run after it, and both rest on the same numerical primitives. Chapter 7 covers cross-cutting advanced topics. It explains how random_state resolves against the global seed, what the postcard-serialized bytes of a saved model contain, and how the runtime-tunable parallelism gates work. It also shows how to compile a build that pulls in only metrics or only math.

How to read it

Read Getting Started once, in order, from start to end. It is the only chapter that assumes you read the chapters before it. It installs the crate, sets up ndarray, and builds a complete model, so every later chapter can build on that shared base. Every chapter after Chapter 1 is reference material, and you can read those chapters in any order. Jump straight to Support Vector Machines, Optimizers, or Clustering Metrics as your problem demands. Follow the inline cross-links when one page depends on a concept from another page. If RustyML already compiles for you and a model already trains, Chapter 1 has done its job. Treat the rest of the guide as a manual: open the page you need.

Versions, feedback, and conventions

This edition tracks the current crate version, 0.14. RustyML is pre-1.0 and under active development. The API is stabilizing, but breaking changes can still land in minor releases. If a signature in this guide differs from what the compiler accepts, trust docs.rs for the exact version in use. Send bug reports, feature requests, and corrections to this guide to the GitHub repository. Issues and pull requests are welcome there.

This guide follows 2 conventions. First, a complete example is a self-contained program with a main function, a tiny inline dataset, and few iterations. Paste a complete example into a full-feature project and run it as written. A fragment or a signature is marked as such. Second, outside the leaf metrics and math modules, every fallible call returns RustymlResult<T>, an alias for Result<T, rustyml::error::Error> over a structured, matchable error enum. The examples reach for .unwrap() only to stay short. Error Handling shows what to write instead in real code.

1. Getting Started

RustyML is a machine learning and deep learning library written entirely in Rust: no BLAS to link, no Python runtime, no C++ bindings.

Prerequisites. You should be comfortable with cargo, Rust’s ownership and borrowing, and the basic supervised loop (train, predict, evaluate). No prior ndarray experience is required — 1.3 covers the part of it you need. Everything runs on Rust 1.89 or newer, with no system libraries to install.

1.1 What is RustyML: a short tour of what RustyML offers (classical ML estimators, a Keras-style neural-network stack, preprocessing, evaluation metrics), and an honest account of the trade-offs pure Rust brings.

1.2 Installation and Feature Flags: what each feature means and which ones you should turn on.

1.3 Working with ndarray: a short introduction to ndarray. Every matrix RustyML takes in or hands back is an ndarray type, so knowing how to use ndarray is unavoidable.

1.4 Your First End-to-End Model: building a complete model pipeline with RustyML, hands on.

1.5 The Prelude and Imports: how RustyML’s prelude modules let you pull names into scope quickly.

1.6 Error Handling: what RustyML’s error type is, and how you should handle failures.

1.1. What is RustyML

RustyML is a machine learning and deep learning library written entirely in Rust. It covers the full workflow a data-science project needs — data preprocessing, feature engineering, model training, and evaluation. It provides classical machine-learning estimators (linear models, decision trees, SVMs, clustering, dimensionality reduction, anomaly detection) as well as a Keras-style neural-network framework.

This guide documents version 0.14. The API is stabilizing, but minor releases can still introduce breaking changes, so pin a concrete version in Cargo.toml for production rather than tracking *. The authoritative API reference is at docs.rs/rustyml, and the source at github.com/SomeB1oody/RustyML.

1.1.1. Pure Rust, end to end

RustyML contains no C or C++ code: no BLAS to link, no LAPACK, no CUDA. That makes it highly portable, spares you from configuring a complicated environment by hand, and keeps you from hitting inscrutable errors at build time — which suits both production and newcomers. Most of the code is written in safe Rust, so its memory safety is guaranteed.

For performance, matrix multiplication goes through the pure-Rust gemmkit crate (reaching ndarray via the zero-copy gemmkit-ndarray adapter). It dispatches at run time to the widest SIMD the current CPU actually supports (AVX-512F, AVX2+FMA, NEON, wasm simd128, with a scalar fallback), and decides on its own whether a given product is worth threading and across how many workers — so you get excellent performance across very different hardware.

1.1.2. Parallelism

RustyML parallelizes its compute-heavy kernels with Rayon, but never blindly. Below a certain size the overhead of multithreading makes the parallel path slower than the serial one, so every kernel class has a calibrated size threshold and switches to the parallel path only once parallel is measurably faster. Those thresholds are not hardcoded constants: rustyml::tuning lets you override them at run time without a recompile, which matters most when you deploy the same binary to machines with very different core counts. See Performance Tuning and Parallelism for the details.

What the design buys you:

  • Parallel reductions are deterministic: the blocked fold sums in a fixed order no matter how many threads run it, so results do not drift as you scale cores.
  • Performance is predictable: no garbage-collection pauses, no JIT warmup, and no global interpreter lock serializing your threads.
  • Nearly every randomized component honors a global seed (see Reproducibility and Random Seeds), so a run reproduces across machines. The dimensionality reducers’ iterative eigensolvers are deliberately left out, because they converge to the same result whatever the seed is.

1.1.3. Features and modules

RustyML is split into five modules, each controlled by a Cargo feature (prelude is shared). Naming features lets you compile only the parts you use. machine_learning, neural_network, utils, and metrics all enable math automatically.

Feature / moduleWhat lives in it
machine_learningClassical machine-learning estimators
neural_networkThe Sequential model plus layers (Dense, convolution, pooling, recurrent, dropout, normalization), activations, optimizers (SGD, Adam, AdamW, RMSprop, AdaGrad), and losses
utilsPreprocessing (the StandardScaler family of scalers, label helpers such as to_categorical) and dataset splitting (train_test_split, train_test_split_stratified)
metricsEvaluation metrics for regression, classification (ConfusionMatrix, ROC AUC, log loss, …), and clustering (ARI, silhouette, …)
mathNumeric computation and gemmkit-backed matrix products

The default feature turns everything on. There is also a separate show_progress feature that draws training progress bars; see Installation and Feature Flags.

1.1.4. An API modeled on scikit-learn and Keras

Classical estimators follow scikit-learn, exposing methods like fit and predict; the neural network follows Keras’s Sequential model with its add / compile / fit / predict flow — which makes it easier to pick up if you know the Python data-science ecosystem. What changes is that data is ndarray arrays rather than NumPy (see Working with ndarray), and fallible calls return Result instead of raising exceptions.

Here is a classical machine-learning example; you can see the API shape follows scikit-learn:

use rustyml::prelude::machine_learning::*;
use ndarray::array;

fn main() {
    // new(fit_intercept); the default solver is the exact closed form
    let mut model = LinearRegression::new(true);

    let x = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]];
    let y = array![6.0, 9.0, 12.0];

    model.fit(&x, &y).unwrap();
    let predictions = model.predict(&x).unwrap();
    println!("predictions: {:?}", predictions);
}

And here is neural-network code, whose architecture follows Keras:

use rustyml::prelude::neural_network::*;
use ndarray::Array;

fn main() {
    // 4 samples, 8 input features, 1 output
    let x = Array::ones((4, 8)).into_dyn();
    let y = Array::ones((4, 1)).into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(8, 16, Activation::ReLU).unwrap())
        .add(Dense::new(16, 1, Activation::Linear).unwrap())
        .compile(
            Adam::new(0.001, 0.9, 0.999, 1e-8, 0.0).unwrap(),
            MeanSquaredError::new(),
        );

    model.summary(); // prints the architecture, just like Keras
    model.fit(&x, &y, 5).unwrap();

    let predictions = model.predict(&x).unwrap();
    println!("prediction shape: {:?}", predictions.shape());
}

The metrics keep the same design as scikit-learn too (every metric takes its arguments in (y_true, y_pred) order):

use rustyml::metrics::*;
use ndarray::array;

fn main() {
    let y_true = array![1.0, 0.0, 0.0, 1.0, 1.0];
    let y_pred = array![1.0, 0.0, 1.0, 1.0, 0.0];

    let cm = ConfusionMatrix::new(&y_true, &y_pred);
    println!("accuracy: {:.3}", cm.accuracy());
    println!("f1 score: {:.3}", cm.f1_score());
}

Unlike Python, RustyML’s error propagation wraps the result of a fallible call in Result<T, Error>, and you can match on it to handle each outcome separately (either it succeeded and gives you T, or it failed and gives you an Error). See Error Handling.

Hyperparameters are validated at the point they are supplied, and an illegal value is rejected on the spot. Configuration uses the builder pattern: an estimator names its essential hyperparameters in new, then layers optional settings through chained with_* methods, each validating what it receives (for example LinearRegression::new(true).with_regularization(..)?).

1.1.5. What pure Rust buys you

A RustyML program compiles to a single self-contained binary. There is no extra toolchain to install and no complicated environment to configure. Trained classical models and neural-network weights serialize to binary through save_to_path / load_from_path; see Model Persistence in Depth. And because there is no GC, no interpreter, and no warmup, latency is predictable.

1.1.6. Notes on scope

RustyML is CPU-only. There is no GPU or CUDA backend. The neural-network framework suits small-to-medium models and deep learning that sits close to classical ML; it is not for training large vision or language models. The classical machine_learning and utils estimators all take an f64 feature matrix, but the element type of what predict gives back varies by model, see the table in Working with ndarray. The neural-network stack works in f32, and its tensor type is Tensor = ArrayD<f32>. The framework does not build a dynamic autodiff graph the way PyTorch does.

1.2. Installation and Feature Flags

First you need a Rust toolchain installed locally — Rust 1.89 or newer, with cargo available. Check with:

rustc --version
cargo --version

The output should look something like:

rustc 1.96.0 (ac68faa20 2026-05-25)
cargo 1.96.0 (30a34c682 2026-05-25)

Note that RustyML turns on every feature by default. If you want to trim it down and enable only part of it, you can configure that by hand in Cargo.toml; that is covered further down, so there is nothing to worry about yet.

1.2.1. Adding RustyML to your project

If you have not created a project yet, make one with cargo:

cargo new my_project # replace my_project with whatever name you want

A successful run prints something like:

$ cargo new my_project
    Creating binary (application) `my_project` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

RustyML is easy to find on crates.io.

Open the project’s Cargo.toml and add this under [dependencies]:

[dependencies]
rustyml = "0.14"
ndarray = "0.17"
  • The first line means “use version 0.14 of the rustyml crate with its default features” (that is, with everything enabled), so all of RustyML is available to you.
  • The second line means “use version 0.17 of the ndarray crate”. You will almost always need ndarray as a direct dependency as well, because it is the crate RustyML depends on to carry your data.

Sometimes you do not need all of RustyML’s features, and you can pick a combination by adjusting the features field on that first dependency. A few common shapes:

# Omitting `features` means the default feature set (everything)
rustyml = "0.14"

# Just the neural-network framework (requires turning the default off)
rustyml = { version = "0.14", default-features = false, features = ["neural_network"] }

# Everything: machine_learning, neural_network, utils, metrics, math (equivalent to default)
rustyml = { version = "0.14", features = ["full"] }

# Everything, plus terminal progress bars during training
rustyml = { version = "0.14", features = ["full", "show_progress"] }

7.4 Minimal Builds and Modular Integration goes into trimming features in depth.

1.2.2. The feature matrix

Eight features control the build. default and full are bundles.

FeatureModuleContents
machine_learningrustyml::machine_learningRegression, classification, clustering, dimensionality reduction, anomaly detection
neural_networkrustyml::neural_networkKeras-style neural-network architecture: Sequential, layers, optimizers, losses
utilsrustyml::utilsStandardization, normalization, label encoding, train/test splitting
metricsrustyml::metricsEvaluation metrics for regression, classification, and clustering
mathrustyml::mathNumeric computation, gemmkit-backed matrix products, deterministic parallel reductions
defaultmachine_learning + neural_network + utils + metrics + mathEvery module, enabled when you name no features at all
fullmachine_learning + neural_network + utils + metrics + mathEvery module
show_progress(no module of its own)Terminal progress bars; see 1.2.6

Some things the table cannot show:

  • machine_learning, neural_network, utils, and metrics all enable math automatically. You never need to add math by hand alongside another module — only if the numeric module is all you want (see 6. Math Utilities).
  • rustyml::error (the unified Error type, see 1.6 Error Handling) and rustyml::random (global-seed control, see 7.1 Reproducibility and Random Seeds) appear whenever any of machine_learning, neural_network, or utils is on — but not in a metrics-only or math-only build, since those two leaf modules neither return RustymlResult nor consume randomness.
  • rustyml::tuning (runtime parallelism gates, see 7.3 Performance Tuning and Parallelism) is present no matter which feature you enable.
  • The rustyml::prelude module is always compiled, so you can always import through it; see 1.5 The Prelude and Imports.

1.2.3. Which third-party crates each feature pulls in

The table below shows which third-party libraries each RustyML feature needs. You do not need any of this to use the crate; it is here so you can see the dependency picture.

FeatureThird-party crates it activates
mathndarray, ahash, rayon, gemmkit-ndarray
machine_learningndarray, rayon, ndarray-rand, ahash, serde, postcard, thiserror, gemmkit-ndarray
neural_networkndarray, rayon, ndarray-rand, indicatif, serde, postcard, thiserror, gemmkit-ndarray
utilsndarray, rayon, ndarray-rand, ahash, serde, postcard, thiserror, gemmkit-ndarray
metricsndarray, ahash, rayon, gemmkit-ndarray
show_progressindicatif
CrateVersionWhat it does
ndarray0.17 + rayon feature + serde featureProvides the array types and their methods
rayon1.12Parallel computation
ndarray-rand0.16Seeds random initialization
ahash0.8 + serde featureFast hash maps behind label encoding
serde1.0 + derive featureSerializes and deserializes model weights
postcard1.1 + use-std featureStores and reads back model weights
thiserror2.0Derives the Error enum
indicatif0.18Draws progress bars
gemmkit0.1 (rayon parallelism on by default)Pure-Rust high-performance GEMM engine; decides serial vs. parallel and the worker count itself. Reached through the adapter, not named as a direct dependency
gemmkit-ndarray0.1 + epilogue featureZero-copy ndarray adapter (a transpose or strided slice needs no copy); epilogue fuses bias/activation into the product

The matrix-multiply backend lives in gemmkit rather than in RustyML. It exposes GEMMKIT_* environment variables for machine-specific tuning rather than general scheduling; see 7.3 Performance Tuning and Parallelism.

serde, postcard, and thiserror are the heavy dependencies. Dropping them (by staying on metrics and/or math) shrinks build time and the dependency count — for example:

rustyml = { version = "0.14", default-features = false, features = ["metrics"] }

1.2.4. A minimal end-to-end check

With the dependency written into Cargo.toml, put this in src/main.rs:

use rustyml::prelude::machine_learning::*;
use ndarray::array;

fn main() {
    // 3 samples, 2 features
    let x = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]];
    let y = array![6.0, 9.0, 12.0];

    // new(fit_intercept) -> Self; the default solver is exact OLS.
    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap(); // train the model

    // predict with the trained model
    let predictions = model.predict(&x).unwrap();
    println!("predicted {} values", predictions.len());
}

Run cargo run; the expected output is:

predicted 3 values

1.2.5. Pairing with ndarray

RustyML uses the array types ndarray provides, both on the way in and on the way out. The classical machine-learning module takes an Array2<f64> feature matrix and an Array1<f64> target vector; the neural network uses Tensor, an alias for ArrayD<f32> (note that ML and utilities are f64 while the network is f32). RustyML 0.14 builds against ndarray 0.17, so make sure the ndarray version in your Cargo.toml is 0.17.

RustyML does not re-export ndarray’s constructors, so building the arrays you feed in (array!, Array2::from_shape_vec, Array::ones, and friends) means calling into the ndarray crate. That is why it has to be a dependency in your Cargo.toml. 1.3 Working with ndarray covers the array-building patterns the estimators expect.

1.2.6. Using show_progress for training progress bars

show_progress adds no types and unlocks no module. With it enabled, the terminal shows a progress bar with elapsed time, position, and the current loss, and prints “Training completed” when it finishes; the mini-batch loop (fit_with_batches) shows the average loss over the batches of the epoch it is part-way through. That bar is not how you get the loss out — fit and fit_with_batches return a History carrying one loss per epoch whether or not the feature is on. What it buys you is watching the number move while a long run is still going, instead of reading it once the call returns.

Add "show_progress" to rustyml’s features in Cargo.toml:

rustyml = { version = "0.14", features = ["full", "show_progress"] }

Run cargo run again and you will see the bar (there is so little data in this example that you may not catch it moving):

[00:00:00] ######################################## 1000/1000 | Cost: 0.001324 | Max iterations | Iterations: 1000
predicted 3 values

It is best turned on only for interactive use.

1.2.7. MSRV

RustyML’s minimum supported Rust version is 1.89. On an older toolchain the resolver refuses the crate. rustup update stable updates the toolchain.

Beyond that there is nothing to configure — which is exactly RustyML’s advantage.

1.3. Working with ndarray

Every matrix type in RustyML comes from ndarray. If you want the more advanced parts of ndarray — broadcasting rules, the various iterators, linear-algebra helpers — read the official ndarray documentation.

RustyML 0.14 depends on ndarray 0.17. Your own Cargo.toml needs to use the same ndarray version:

[dependencies]
ndarray = "0.17"
rustyml = { version = "0.14", features = ["full"] }

1.3.1. f64 versus f32

In RustyML the elements of a classical machine-learning matrix are f64, while the neural-network stack’s tensor elements are f32.

The classical estimators (see machine_learning) take a two-dimensional Array2<f64> feature matrix laid out as samples by features (one row per sample), plus a one-dimensional Array1<_> target vector. The target’s element type varies by model:

ModelTarget (y)predict output
LinearRegressionArray1<f64>Array1<f64>
LogisticRegressionArray1<f64> (0.0 / 1.0)Array1<f64> (0.0 / 1.0)
DecisionTreeArray1<f64>Array1<f64>
SVC, LinearSVCArray1<f64>Array1<f64>
LDAArray1<i32>Array1<i32>
KNN<T>Array1<T> (T: Clone + Hash + Eq)Array1<T>
KMeans, MeanShift, DBSCAN(unsupervised)Array1<isize> (cluster ids, -1 is noise)
IsolationForest(unsupervised)Array1<i32> (-1 outlier / +1 inlier; scores come from score_samples)
PCA, KernelPCA(unsupervised)Array2<f64> (via transform)

The neural-network stack uses Tensor, which is a type alias:

pub type Tensor = ArrayD<f32>;

Tensor differs from the classical machine-learning matrices in two ways:

  • The element type is f32: half the precision, half the memory, and the width every deep-learning framework standardizes on, because neural networks are compute-bound and the extra throughput is worth a few bits of precision.
  • The dimensionality is dynamic: ArrayD (equivalently Array<f32, IxDyn>) carries its rank at run time rather than encoding it in the type. A wrong rank surfaces at run time as an Err; see 1.3.7.

1.3.2. Building arrays

Here are the common ways to build the ndarray matrix types:

use ndarray::{array, Array, Array1, Array2};

fn main() {
    // `array!`: the shape is inferred from the nesting — this is a 3x2 `Array2<f64>`
    let m = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
    assert_eq!(m.shape(), &[3, 2]);

    // `from_shape_vec`: (shape, flat row-major Vec)
    // The length must equal the product of the shape, or you get an Err
    let x = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
    assert_eq!(x.shape(), &[2, 3]);

    // `from_shape_fn`: give it a shape, plus a closure that produces each element
    let g = Array2::from_shape_fn((3, 2), |(i, j)| (i * 2 + j) as f64);
    assert_eq!(g[[2, 1]], 5.0);

    // `from_vec`: turn a `Vec<T>` into an `Array1<T>`
    let y = Array1::from_vec(vec![2.0, 4.0, 6.0]);
    // `from_iter`: fill an `Array1<T>` from an iterator
    let labels = Array1::from_iter(0..3i32);

    // Constant / all-zero / all-one fills, with the element type spelled out
    let zeros = Array2::<f64>::zeros((2, 2));
    let ones = Array2::<f64>::ones((2, 2));
    let filled = Array::from_elem((2, 2), 7.0f64);

    println!("{} {} {} {}", y.len(), labels.len(), zeros.sum(), ones.sum());
    println!("{}", filled.sum());
}

Most data arrives as nested Vecs, and that is when you reach for from_shape_vec. It reads the Vec in row-major order. Its return value is wrapped in a Result, and if you get an Err, the shape is almost certainly wrong.

Often you want to initialize a matrix randomly. The ndarray-rand crate provides Array::random for that, and you add it to your Cargo.toml:

[dependencies]
ndarray-rand = "0.16"
ndarray = "0.17"

Example:

use ndarray::Array;
use ndarray_rand::RandomExt; // the extension trait that gives ndarray's array types
                             // random construction — bring it into scope for `Array::random`
use ndarray_rand::rand_distr::Uniform; // samples uniformly between the given bounds

fn main() {
    let w = Array::random((4, 3), Uniform::new(-1.0, 1.0).unwrap());
    println!("{:?}", w.shape());
}

When you use RustyML, do not seed ndarray-rand’s generator by hand. RustyML has its own randomness management (set_global_seed, a per-model with_random_state, and Sequential::new_with_seed), so control randomness through RustyML’s API instead; see 7.1 Reproducibility and Random Seeds.

1.3.3. Inputs to the classical estimators

Here is the shape a typical supervised classical fit and predict signature takes:

pub fn fit<S1, S2>(&mut self, x: &ArrayBase<S1, Ix2>, y: &ArrayBase<S2, Ix1>) -> Result<&mut Self, Error>
where
    S1: Data<Elem = f64>,
    S2: Data<Elem = f64>,

Two things depart from it, both visible in the table in 1.3.1: LDA’s y is Data<Elem = i32> rather than f64, and the unsupervised estimators (KMeans, DBSCAN, MeanShift, PCA, KernelPCA) take the feature matrix alone, with no y argument at all.

ndarray has two kinds of borrow:

  • a reference to an owned array (&Array)
  • a view (ArrayView), which is what .view() and .slice(..) produce
  • Both are zero-copy, and RustyML accepts either. The parameter is a reference, so write &array or &array.view(); a bare array.view() does not type-check.

You also have to keep the feature matrix two-dimensional (Ix2) and the target vector one-dimensional (Ix1).

use rustyml::machine_learning::LinearRegression;
use ndarray::{s, Array1, Array2};

fn main() {
    let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
    let y = Array1::from_vec(vec![2.0, 4.0, 6.0, 8.0]);

    let mut model = LinearRegression::new(true);

    // A reference to the owned array
    model.fit(&x, &y).unwrap();

    // A reference to a view
    model.fit(&x.view(), &y.view()).unwrap();

    // A reference to a slice: train on the first three rows only
    // This subview is a borrow into `x`
    let x_head = x.slice(s![0..3, ..]);
    let y_head = y.slice(s![0..3]);
    model.fit(&x_head, &y_head).unwrap();
}

What .view() and .slice(..) really buy you is the ability to feed a subset of an array — a range of rows, a strided window, a single column reshaped — straight into fit or predict without first materializing that subset as a new owned array. To train on rows 0..800 of a 1000-row matrix, x.slice(s![0..800, ..]) is all it takes.

1.3.4. Inputs to the neural-network stack

The neural layers want ArrayD<f32>, but you almost never construct one directly. The usual path is to build a fixed-dimension array and then convert it to the dynamic dimension IxDyn. That conversion is a thin wrapper; it does not touch the data:

use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::layers::{Activation, Dense};
use rustyml::neural_network::optimizers::Adam;
use rustyml::neural_network::losses::MeanSquaredError;
use ndarray::Array2;

fn main() {
    // 4 samples, 2 features: build an `Array2<f32>` first, then convert to `ArrayD`
    let x = Array2::from_shape_vec(
        (4, 2),
        vec![0.0f32, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0],
    )
    .unwrap()
    .into_dyn(); // convert to a dynamic-dimension tensor
    let y = Array2::from_shape_vec((4, 1), vec![0.0f32, 1.0, 1.0, 0.0])
        .unwrap()
        .into_dyn(); // convert to a dynamic-dimension tensor

    let mut model = Sequential::new();
    model
        .add(Dense::new(2, 4, Activation::ReLU).unwrap())
        .add(Dense::new(4, 1, Activation::Linear).unwrap());
    model.compile(Adam::new(0.001, 0.9, 0.999, 1e-8, 0.0).unwrap(), MeanSquaredError::new());

    model.fit(&x, &y, 5).unwrap();
    let preds = model.predict(&x).unwrap();
    println!("prediction tensor shape: {:?}", preds.shape());
}

Output:

prediction tensor shape: [4, 1]

1.3.5. Input shapes

RustyML’s input format is one row per sample, one column per feature.

The tensor shapes for neural-network layers match Keras’s default (batch, height, width, channels). The channel axis is always the trailing one and the batch axis is always the leading one, with the spatial axes in between — so a Keras user should feel at home. Here are the exact layouts:

LayerInput tensorWeight tensorBiasOutput tensor
Conv1D[batch, length, Cin][k, Cin, filters][filters][batch, out_len, filters]
Conv2D[batch, height, width, Cin][kh, kw, Cin, filters][filters][batch, out_h, out_w, filters]
Conv3D[batch, depth, height, width, Cin][kd, kh, kw, Cin, filters][filters][batch, out_d, out_h, out_w, filters]
DepthwiseConv2D[batch, height, width, C][kh, kw, C, dm][C·dm][batch, out_h, out_w, C·dm]
SeparableConv2D[batch, height, width, Cin]depthwise [kh, kw, Cin, dm], pointwise [1, 1, Cin·dm, filters][filters][batch, out_h, out_w, filters]
MaxPooling{1,2,3}D, AveragePooling{1,2,3}D[batch, spatial…, C][batch, pooled…, C]
GlobalMaxPooling{1,2,3}D, GlobalAveragePooling{1,2,3}D[batch, spatial…, C][batch, C]
SpatialDropout{1,2,3}D[batch, spatial…, C]same shape as the input
BatchNormalization, GroupNormalization, InstanceNormalization[batch, spatial…, C]gamma/beta [C]same shape as the input

For example:

  • A batch of 8 single-channel 28×28 images is [8, 28, 28, 1]
  • A bank of 3×3 filters producing 16 feature maps has weight shape [3, 3, 1, 16]

Here is a code example:

use rustyml::neural_network::layers::{Activation, Conv1D};
use rustyml::neural_network::traits::Layer;
use ndarray::Array;

fn main() {
    // filters=2, kernel=3, input_shape=[batch, length, channels], stride=1
    let mut conv = Conv1D::new(2, 3, vec![1, 6, 1], 1, Activation::Linear).unwrap();

    // Channels last: [batch=1, length=6, channels=1]
    let input = Array::from_shape_vec((1, 6, 1), vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0])
        .unwrap()
        .into_dyn();

    let output = conv.forward(&input).unwrap();
    println!("conv output shape: {:?}", output.shape()); // [1, 4, 2]
}

The constructor’s input_shape argument records the [batch, length, channels] the layer is configured for. Passing a tensor whose rank or channel count disagrees is rejected at forward time. For the per-layer detail, see 3.5 Convolutional Layers and 3.6 Pooling Layers.

1.3.6. Practical advice

Real data rarely arrives as an Array2. The most common shape is a Vec<Vec<f64>>, one inner Vec per parsed CSV row. The conversion is to flatten it into a single row-major Vec<T> and hand it to from_shape_vec along with the (rows, cols) you counted:

use ndarray::Array2;

fn main() {
    // As if parsed from CSV: 3 rows, 2 columns
    let rows: Vec<Vec<f64>> = vec![
        vec![1.0, 2.0],
        vec![3.0, 4.0],
        vec![5.0, 6.0],
    ];
    let n_rows = rows.len();
    let n_cols = rows[0].len();

    // `flatten()` flattens the nested vectors in row-major order
    let flat: Vec<f64> = rows.into_iter().flatten().collect();
    let x = Array2::from_shape_vec((n_rows, n_cols), flat).unwrap();

    assert_eq!(x.shape(), &[3, 2]);
    println!("{:?}", x.row(0)); // [1.0, 2.0]
}

If your rows are ragged, flatten still flattens the nested vectors, but the length will not equal n_rows * n_cols and from_shape_vec returns an Err.

Other tools you will use:

  • The s! macro expresses ranges and strides
  • concatenate joins arrays along an existing axis
  • outer_iter (equivalently axis_iter(Axis(0))) walks each row as a one-dimensional view, which is how you iterate sample by sample

Code example:

use ndarray::{array, concatenate, s, Axis};

fn main() {
    let x = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]];

    // Slice: all rows, first two columns (drop a feature)
    let first_two_cols = x.slice(s![.., 0..2]);
    assert_eq!(first_two_cols.shape(), &[3, 2]);

    // Slice: a single row as a submatrix
    let middle = x.slice(s![1..2, ..]);
    assert_eq!(middle.shape(), &[1, 3]);

    // Concatenate two matrices along axis 0 (append rows)
    let more = array![[10.0, 11.0, 12.0]];
    let stacked = concatenate(Axis(0), &[x.view(), more.view()]).unwrap();
    assert_eq!(stacked.shape(), &[4, 3]);

    // Iterate each row as an `ArrayView1`
    // The sample-by-sample idiom
    for (i, row) in x.outer_iter().enumerate() {
        println!("row {i} sums to {}", row.sum());
    }
}

Turning raw labels into the Array1 a model wants is usually a map over your source data collected into an array. When a model or a loss expects one-hot targets rather than integer class ids, use RustyML’s to_categorical function, which turns an Array1<i32> of class ids into an Array2<f64> one-hot matrix:

use rustyml::utils::to_categorical;
use ndarray::Array1;

fn main() {
    // Map string labels to integer class ids, then into an `Array1<i32>`
    let raw = ["cat", "dog", "cat", "bird"];
    let ids: Array1<i32> = raw
        .iter()
        .map(|s| match *s {
            "cat" => 0,
            "dog" => 1,
            _ => 2,
        })
        .collect::<Vec<_>>()
        .into();

    // One-hot encode: 4 samples, 3 classes -> a (4, 3) `Array2<f64>`
    let onehot = to_categorical(&ids, None).unwrap();
    assert_eq!(onehot.shape(), &[4, 3]);
    println!("{:?}", onehot.row(1)); // [0.0, 1.0, 0.0]
}

Label encoding, its inverse, and the mapping-preserving variants are covered in full in 4.3 Label Encoding. Preprocessing your feature matrix with standardize and normalize is in 4.2 Standardization and Normalization.

1.3.7. Shape-mismatch errors

Two different error systems catch shape problems. Errors that happen while you are building an array come from ndarrayfrom_shape_vec returns ndarray’s ShapeError when the buffer length disagrees with the shape. Errors that happen while RustyML runs come back as RustyML’s own Error enum, which subdivides them further:

  • Error::DimensionMismatch { expected, found }: two scalar counts disagree, such as the feature count at predict time versus at fit time, or x.nrows() versus y.len()
  • Error::ShapeMismatch { expected, found }: two whole tensor shapes disagree, such as a gradient in the neural-network stack not matching the activation it flows into
  • Error::InvalidInput(_): the input’s rank is wrong, such as handing a 2-D tensor to a Conv1D that expects 3-D

The one you will hit most often is a feature-count mismatch: you train on p features and then call predict with a matrix that has a different number of columns. RustyML validates this and returns DimensionMismatch:

use rustyml::machine_learning::LinearRegression;
use rustyml::error::Error;
use ndarray::{Array1, Array2};

fn main() {
    // Train on 2 features
    let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 2.0, 3.0, 3.0, 4.0]).unwrap();
    let y = Array1::from_vec(vec![6.0, 9.0, 12.0]);
    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    // Predict with 3 features
    // The model returns an error
    let bad = Array2::from_shape_vec((1, 3), vec![4.0, 5.0, 6.0]).unwrap();
    match model.predict(&bad) {
        Err(Error::DimensionMismatch { expected, found }) => {
            println!("expected {expected} features, got {found}");
        }
        other => println!("unexpected: {other:?}"),
    }
}

Because Error is marked #[non_exhaustive], a match over it always needs a trailing _ => arm, which keeps your code compiling when new error variants are added. For the full error-handling story, see 1.6 Error Handling.

1.4. Your First End-to-End Model

1.4.1. The complete program

use ndarray::{Array1, Array2};
use rustyml::machine_learning::LogisticRegression;
use rustyml::metrics::{ConfusionMatrix, accuracy};
use rustyml::set_global_seed;
use rustyml::utils::StandardScaler;
use rustyml::utils::train_test_split::train_test_split;

fn main() {
    // Set the global seed for reproducibility
    set_global_seed(42);

    // Build a synthetic 2-class dataset
    let n_per_class = 30usize;
    let mut rows: Vec<f64> = Vec::with_capacity(2 * n_per_class * 2);
    let mut labels: Vec<f64> = Vec::with_capacity(2 * n_per_class);
    for i in 0..n_per_class {
        let a = (i % 6) as f64;
        let b = (i / 6) as f64;
        // Class 0 is centered near -0.6
        // Class 1 near +0.6
        rows.push(-0.6 + 0.3 * a);
        rows.push((-0.6 + 0.3 * b) * 100.0);
        labels.push(0.0);
        rows.push(0.6 + 0.3 * a);
        rows.push((0.6 + 0.3 * b) * 100.0);
        labels.push(1.0);
    }
    let n = labels.len();
    let x: Array2<f64> = Array2::from_shape_vec((n, 2), rows).unwrap();
    let y: Array1<f64> = Array1::from(labels);

    // Split into a training and a test set
    let (x_train, x_test, y_train, y_test) =
        train_test_split(x, y, Some(0.25), Some(42)).unwrap();

    // Standardize: fit the statistics on the training set only, then apply them to both splits
    let mut scaler = StandardScaler::new();
    let x_train_std = scaler.fit_transform(&x_train).unwrap();
    let x_test_std = scaler.transform(&x_test).unwrap();

    // Train
    let mut model = LogisticRegression::new(true, 0.5, 1000, 1e-6).unwrap();
    model.fit(&x_train_std, &y_train).unwrap();

    // Predict on the test set
    let preds: Array1<f64> = model.predict(&x_test_std).unwrap();

    // Evaluate
    let acc = accuracy(&y_test, &preds);
    let cm = ConfusionMatrix::new(&y_test, &preds);
    let (tp, fp, tn, fn_) = cm.get_counts();
    println!("test accuracy = {:.3}", acc);
    println!("TP={tp} FP={fp} TN={tn} FN={fn_}");
    println!("{}", cm.summary());
    println!("iterations run: {:?}", model.get_actual_iterations());

    // Save and load the model
    let path = "logreg_model.bin";
    model.save_to_path(path).unwrap();
    let loaded = LogisticRegression::load_from_path(path).unwrap();
    let preds_loaded = loaded.predict(&x_test_std).unwrap();
    println!("round-trip predictions identical: {}", preds == preds_loaded);
}

These println! calls produce a small report, laid out like this:

test accuracy = 0.XXX
TP=.. FP=.. TN=.. FN=..
Confusion Matrix:
+-----------------+--------------------+--------------------+
|                 | Predicted Positive | Predicted Negative |
+-----------------+--------------------+--------------------+
| Actual Positive | TP: ..             | FN: ..             |
| Actual Negative | FP: ..             | TN: ..             |
+-----------------+--------------------+--------------------+
... derived metrics ...
iterations run: Some(..)
round-trip predictions identical: true

1.4.2. Seeding for reproducibility

rustyml::set_global_seed takes a u64. It sets a thread-local seed, and every unseeded (random_state == None) component constructed afterwards on the same thread derives its RNG from it — the Keras-style global-seed model. See 7.1. Reproducibility and Random Seeds.

In this particular pipeline it changes none of the numbers. LogisticRegression::fit is plain full-batch gradient descent with no randomness at all (fitting twice on the same data gives bit-identical weights), and standardize is deterministic too. The only stochastic step is the shuffle inside train_test_split, and I pass it an explicit Some(42). Since a local seed, when set, outranks the global one, that step follows the local seed. Still, make a habit of calling set_global_seed at the top of your code.

1.4.3. The dataset and the label contract

The features are an Array2<f64> of shape (n_samples, n_features) and the labels are an Array1<f64>. The label element type is not incidental: LogisticRegression::fit requires y to be an f64 array whose values are exactly 0.0 or 1.0 (you can see the rule stated in its documentation), and anything else returns Error::InvalidInput. There is no LabelEncoder step folded into fit; if your labels are strings or arbitrary integers, you convert them yourself — see 4.3. Label Encoding.

1.4.4. Splitting the data

let (x_train, x_test, y_train, y_test) = train_test_split(x, y, Some(0.25), Some(42)).unwrap();

train_test_split consumes x and y, so clone them beforehand if you still need the originals afterwards. Passing None for test_size means 0.3 (a 70/30 split), and None for random_state means seed-from-entropy (or from the global seed, if one is set).

An empty dataset returns Error::EmptyInput, mismatched x/y lengths return Error::DimensionMismatch, a test_size outside (0, 1) is Error::InvalidParameter, and a dataset too small to split returns Error::InvalidInput. The function keeps at least one sample on each side: with ten rows and test_size = 0.99, the training side still has one row rather than zero.

On imbalanced data, a plain random split can push an entire class into the test set or into the training set. That is when train_test_split_stratified is the better choice, since it splits each class independently.

The split mechanics and the stratified variant are covered in 4.1. Train-Test Split.

1.4.5. Standardizing without leaking test information

Logistic regression is fit by gradient descent, and gradient descent cares about feature scale. The code above builds feature 2 a hundred times larger than feature 1, which lets feature 2 dominate the gradient, so each feature needs standardizing to zero mean and unit variance.

There are two ways to standardize:

  • Call the function standardize(data, axis): a stateless, one-shot transform that computes the mean and standard deviation from the array you pass it, returns a new standardized array, and keeps nothing.
  • Use the StandardScaler struct and its methods: it holds state, so fit computes the statistics and stores them, and transform applies the stored statistics to any array you give it later.

With the function, the common mistake is to call standardize once on x_train and again on x_test, which leaves the training and test sets on two different scales. Standardizing the whole matrix before splitting folds the test set’s mean and variance into the numbers the model trains on, which inflates your accuracy.

The correct approach is the StandardScaler struct and its methods — fit once on the training set, then transform everything:

use rustyml::utils::StandardScaler;

let mut scaler = StandardScaler::new();
let x_train_std = scaler.fit_transform(&x_train).unwrap(); // learns mean/std HERE, from train
let x_test_std = scaler.transform(&x_test).unwrap();       // applies the same statistics to test

Use fit/fit_transform on the training set only, and transform for everything else. The statistics are readable (scaler.get_mean(), get_scale()). The divisor is the population standard deviation (ddof = 0, matching scikit-learn’s StandardScaler).

The function is the right call when you do not need statistics to stay consistent, or when you need the Row/Global axes the struct does not cover:

use ndarray::array;
use rustyml::utils::standardize::{standardize, StandardizationAxis};

fn main() {
    let x = array![[1.0, 200.0], [3.0, 400.0], [5.0, 600.0]];
    let z = standardize(&x, StandardizationAxis::Column).unwrap();
    println!("standardized shape: {:?}", z.dim());
    println!("{:?}", z);
}

Output:

standardized shape: (3, 2)
[[-1.224744871391589, -1.224744871391589],
 [0.0, 0.0],
 [1.224744871391589, 1.224744871391589]], shape=[3, 2], strides=[2, 1], layout=Cc (0x5), const ndim=2

For the fuller tutorial, see 4.2. Standardization and Normalization.

1.4.6. Training the model

let mut model = LogisticRegression::new(true, 0.5, 1000, 1e-6).unwrap();
model.fit(&x_train_std, &y_train).unwrap();

LogisticRegression::new returns Result<Self, Error> because it validates its hyperparameters first and returns Error::InvalidParameter if they do not meet the requirements. You can also use LogisticRegression::default() for a model with default values (fit_intercept = true, learning_rate = 0.01, max_iter = 100, tol = 1e-4). Compared with those defaults, the learning rate and iteration budget passed to new here are both larger, because standardized features tolerate a bigger step. Regularization is off by default; enable it with .with_regularization(RegularizationType::L2(alpha)), whose return value is likewise wrapped in a Result (a negative or non-finite alpha returns an error). RustyML measures penalty strength the same way scikit-learn’s SGDClassifier/SGDRegressor does, while a LogisticRegression(C=c) corresponds to alpha = 1 / (c * n) for n training samples. The complete conversion table is in RegularizationType’s own documentation.

fit takes references to the feature matrix and the target vector. Its return value is wrapped in a Result to guard against:

  • the 0.0/1.0 label check
  • empty input (Error::EmptyInput)
  • mismatched x/y lengths (Error::DimensionMismatch)
  • non-finite feature values (Error::NonFinite). It also trips Error::NonFinite mid-loop if the weights or the loss overflow.

Iteration stops once the loss change between consecutive iterations falls below tol, or once the iteration count reaches max_iter. model.get_actual_iterations() tells you which of the two stopped it: if it equals max_iter, the model hit the iteration ceiling rather than converging, and you should raise max_iter or revisit the learning rate. For more on logistic regression, see 2.2. Logistic Regression.

1.4.7. Predicting

let preds: Array1<f64> = model.predict(&x_test_std).unwrap();

predict returns Result<Array1<f64>, Error>, each element a hard class label 0.0 or 1.0 (the sigmoid probability thresholded at 0.5). If you want the underlying probabilities rather than the labels, call predict_proba.

predict’s return value is likewise wrapped in a Result to guard against:

  • calling it before fit, which is Error::NotFitted
  • passing a feature count different from the one the model was trained on, which is Error::DimensionMismatch

1.4.8. Accuracy and the confusion matrix

let acc = accuracy(&y_test, &preds);
let cm = ConfusionMatrix::new(&y_test, &preds);
let (tp, fp, tn, fn_) = cm.get_counts();
println!("{}", cm.summary());

accuracy(y_true, y_pred) -> f64 computes accuracy, the fraction of matching labels. ConfusionMatrix::new(y_true, y_pred) builds the confusion matrix, and get_counts() returns (tp, fp, tn, fn). The confusion matrix takes hard labels only: every element of both arrays must be exactly 0.0 or 1.0, and anything else panics. If your labels use a different pair — the -1.0/+1.0 a margin classifier emits, say — use ConfusionMatrix::new_with_labels(y_true, y_pred, negative_label, positive_label), which corresponds to scikit-learn’s confusion_matrix(..., labels=[neg, pos]). Its methods are accuracy(), precision(), recall(), specificity(), f1_score(), balanced_accuracy(), and mcc(), and you can print summary() for the formatted table shown earlier. On imbalanced data, do not use raw accuracy — reach for balanced_accuracy() or mcc() instead.

For more classification metrics, see 5.2. Classification Metrics.

1.4.9. Saving and loading the model

model.save_to_path("logreg_model.bin").unwrap();
let loaded = LogisticRegression::load_from_path("logreg_model.bin").unwrap();

save_to_path(&self, path: &str) -> Result<(), Error> uses postcard to serialize the entire model — weights, intercept flag, learning rate, iteration count, regularization setting — into binary. load_from_path(path: &str) -> Result<Self, Error> reads it back. For the details, see 3.9. Saving and Loading Weights and 7.2. Model Persistence in Depth.

1.5. The Prelude and Imports

1.5.1. Three ways to bring names into scope

Glob the whole prelude

use rustyml::prelude::*;

This pulls everything into the current scope. It suits getting code written quickly, without having to look up where each item lives.

Glob a single prelude category

The prelude is split into four submodules, so you can import just what you need:

use rustyml::prelude::machine_learning::*; // classical estimators, traits, and shared enums
use rustyml::prelude::neural_network::*;   // Sequential, History, Tensor, layers, losses, optimizers
use rustyml::prelude::metrics::*;          // the evaluation-metric functions and types
use rustyml::prelude::utils::*;            // standardize, normalize, scalers, encoders, split

Use this when the file you are writing clearly belongs to a single domain.

Import by exact path

use rustyml::machine_learning::LinearRegression;
use rustyml::traits::{Fit, Predict};
use rustyml::metrics::r2_score;

Library code that has to be maintained long-term should prefer this style.

1.5.2. What the prelude re-exports

The prelude is a hand-picked list: it re-exports only the items you reach for often. The tables below show exactly what each prelude submodule re-exports.

Machine learning

GroupItems
Estimator traitsFit, Predict, Transform, FitTransform
Shared enumsDistanceCalculationMetric, RegularizationType, KernelType
RegressionLinearRegression, LeastSquaresSolver
Linear classificationLogisticRegression, generate_polynomial_features
NeighborsKNN, WeightingStrategy
TreesDecisionTree, DecisionTreeParams, Algorithm
SVMSVC, LinearSVC
Discriminant analysisLDA, DiscriminantSolver, Shrinkage
ClusteringKMeans, DBSCAN, MeanShift, estimate_bandwidth
DecompositionPCA, KernelPCA, EigenSolver, SVDSolver
ManifoldTSNE, TSNEMethod, Init
Anomaly detectionIsolationForest, Contamination

Neural network

GroupItems
TensorTensor (alias for ArrayD<f32>)
ModelSequential
Training historyHistory (one loss per epoch, what fit returns)
Core layersDense, Flatten, Activation
Activation layersLinear, ReLU, Sigmoid, Softmax, Tanh
ConvolutionConv1D, Conv2D, Conv3D, DepthwiseConv2D, SeparableConv2D, PaddingType
PoolingMaxPooling1D/2D/3D, AveragePooling1D/2D/3D, GlobalMaxPooling1D/2D/3D, GlobalAveragePooling1D/2D/3D
RecurrentSimpleRNN, LSTM, GRU
RegularizationDropout, SpatialDropout1D/2D/3D, GaussianDropout, GaussianNoise
NormalizationBatchNormalization, LayerNormalization, LayerNormalizationAxis, GroupNormalization, InstanceNormalization
LossesMeanSquaredError, MeanAbsoluteError, BinaryCrossEntropy, CategoricalCrossEntropy, SparseCategoricalCrossEntropy
OptimizersSGD, Adam, AdamW, RMSprop, AdaGrad

Note the exact casing on RMSprop (lowercase p). Activations come in two forms: one is the standalone layers ReLU/Softmax/Linear/Sigmoid/Tanh. The other is, in any layer that accepts an Activation enum, either picking a variant (Activation::ReLU, Activation::Softmax, Activation::Linear, Activation::Sigmoid, Activation::Tanh) or passing one of those standalone activation layers instead (they impl Layer and convert Into<Activation>).

For example, one of Dense::new’s parameters is activation: impl Into<Activation>, so you can pass either an Activation enum variant or a standalone activation layer: Dense::new(3, 8, Activation::ReLU) and Dense::new(3, 8, ReLU::new()) are equivalent.

Metrics

GroupItems
TypesConfusionMatrix, MulticlassConfusionMatrix, Average
Regressionmean_squared_error, root_mean_squared_error, mean_absolute_error, median_absolute_error, mean_absolute_percentage_error, r2_score, explained_variance_score
Classificationaccuracy, roc_auc, roc_curve, precision_recall_curve, average_precision, log_loss, cohen_kappa, top_k_accuracy
Clusteringadjusted_rand_index, adjusted_mutual_info, normalized_mutual_info, homogeneity_score, completeness_score, v_measure_score, fowlkes_mallows_score, silhouette_score, davies_bouldin_score, calinski_harabasz_score

Unlike the error-propagation design in the rest of the crate, the metric functions panic outright on an error, which keeps the module lightweight; see 5. Model Evaluation. Their argument order is (y_true, y_pred), matching scikit-learn.

Utilities

GroupItems
Scalingstandardize, StandardizationAxis, normalize, NormalizationAxis, NormalizationOrder, StandardScaler, MinMaxScaler, MaxAbsScaler, RobustScaler, Normalizer
Label encodingto_categorical, to_categorical_with_mapping, to_sparse_categorical
Splittingtrain_test_split, train_test_split_stratified
TraitsFit, Predict, Transform, FitTransform

1.5.3. Feature gates decide what the prelude contains

The rustyml::prelude module is always compiled, but each submodule only appears once the matching module feature is enabled. So use rustyml::prelude::* does not mean everything RustyML can do — it means everything the features you enabled can do. For what each feature contains, see 1.2 Installation and Feature Flags.

Enabled featureWhat rustyml::prelude::* contains
machine_learningthe classical estimators, traits, and shared enums
neural_networkSequential, History, Tensor, layers, losses, optimizers
metricsthe metric functions and the confusion-matrix types
utilsstandardize, normalize, the whole scaler family, encoders, split functions, the estimator traits
default (all five modules)every module
fullevery module

1.5.4. Using fully-qualified paths

To find where an item lives, docs.rs/rustyml is the place to look. Here is a lookup table for the main types:

Type / functionFully-qualified path
LinearRegression, LogisticRegressionrustyml::machine_learning::
KNN, DecisionTree, SVC, LinearSVC, LDArustyml::machine_learning::
KMeans, DBSCAN, MeanShiftrustyml::machine_learning::
PCA, KernelPCA, TSNE, IsolationForest, Contaminationrustyml::machine_learning::
Fit, Predict, Transform, FitTransformrustyml::traits:: (also re-exported under rustyml::machine_learning:: and rustyml::utils::)
DistanceCalculationMetricrustyml::machine_learning:: or rustyml::math::
Sequential, Historyrustyml::neural_network::sequential::
Tensorrustyml::neural_network::
Dense, Flatten, Activationrustyml::neural_network::layers::
Adam, SGD, AdamW, RMSprop, AdaGradrustyml::neural_network::optimizers::
MeanSquaredError, CategoricalCrossEntropy, and the restrustyml::neural_network::losses::
accuracy, mean_squared_error, r2_score, and the restrustyml::metrics::
ConfusionMatrix, MulticlassConfusionMatrix, Averagerustyml::metrics::
standardize, StandardizationAxisrustyml::utils::standardize::
StandardScaler, MinMaxScaler, MaxAbsScaler, RobustScaler, Normalizerrustyml::utils:: (defined in rustyml::utils::scaler::)
normalize, NormalizationAxis, NormalizationOrderrustyml::utils::normalize::
train_test_split, train_test_split_stratifiedrustyml::utils::train_test_split::
to_categorical, to_sparse_categorical, and the restrustyml::utils::label_encoding::
Error, RustymlResultrustyml::error::
set_global_seed, clear_global_seedrustyml:: (or rustyml::random::)

1.6. Error Handling

1.6.1. One error type

RustyML has exactly one error type, rustyml::error::Error. Every fallible operation in the crate returns Result<T, rustyml::error::Error>, which also has an alias:

pub type RustymlResult<T> = std::result::Result<T, Error>;

Error is not in the prelude, so the error machinery is imported separately with use rustyml::error::Error; and friends. These are Error’s variants:

VariantTriggerDisplay message ({} / to_string())
EmptyInput(String)An array, vector, or dataset was empty where data was requiredinput is empty: <what>
DimensionMismatch { expected, found }Two scalar counts disagreeddimension mismatch: expected <e>, found <f>
ShapeMismatch { expected, found }Two tensor shapes disagreed (a gradient vs. the activation it flows into)shape mismatch: expected [..], found [..]
NonFinite(String)A value in the data or produced by a computation was NaN / infnon-finite value (NaN or infinity) encountered in <where>
InvalidParameter { name, reason }A user-supplied hyperparameter was out of rangeinvalid parameter `<name>`: <reason>
InvalidInput(String)A validation failure with no more specific variant (bad rank, too few samples)invalid input: <msg>
NotFitted(&'static str)A method needing a trained model was called before fitmodel `<name>` has not been fitted; call `fit` before this operation
NotConverged(String)An iterative algorithm never met its convergence criterionfailed to converge: <msg>
Computation { context, source }A numerical breakdown, a violated invariant, or a wrapped foreign errorcomputation failed: <context>
NeuralNetwork(NnError)A neural-network-specific failureforwarded transparently from NnError
Tree(TreeError)A decision-tree-specific failureforwarded transparently from TreeError
Io(IoError)A filesystem or (de)serialization failureforwarded transparently from IoError

Note that DimensionMismatch compares scalar counts, such as a feature count or a vector length, while ShapeMismatch is about two whole tensor shapes disagreeing, which shows up mostly in the neural-network code.

Error is annotated #[non_exhaustive], which means a match over it must carry a wildcard _ => (or Err(e) =>) arm.

1.6.2. The domain sub-errors

Three of Error’s variants each wrap a smaller enum. Concerns that only apply to neural networks (layer state, weight shapes, compilation) and those that only apply to trees (classification versus regression) stay in their own enum.

NnError (at rustyml::neural_network::NnError) contains:

  • ForwardPassNotRun(&'static str)
  • WeightShape { name, expected, found }
  • NotCompiled(&'static str)
  • EmptyModel

Code example:

use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::layers::Dense;
use rustyml::neural_network::layers::activation::ReLU;
use rustyml::neural_network::NnError;
use rustyml::error::Error;
use ndarray::Array;

fn main() {
    let mut model = Sequential::new();
    model.add(Dense::new(4, 2, ReLU::new()).unwrap());

    let x = Array::ones((3, 4)).into_dyn();
    let y = Array::ones((3, 2)).into_dyn();

    // compile() was never called, so no optimizer or loss is configured yet
    match model.fit(&x, &y, 1) {
        Ok(_) => unreachable!("training should not have started"),
        Err(Error::NeuralNetwork(NnError::NotCompiled(missing))) => {
            println!("compile the model first: `{missing}` is not specified");
        }
        Err(e) => println!("unexpected: {e}"),
    }
}

TreeError (at rustyml::machine_learning::TreeError) has these two variants:

  • NotClassificationTree
  • CorruptStructure(&'static str)

Code example:

use rustyml::machine_learning::{Algorithm, DecisionTree, TreeError};
use rustyml::error::Error;
use ndarray::array;

fn main() {
    // A regression tree (is_classifier = false) has no per-class probabilities
    let tree = DecisionTree::new(Algorithm::CART, false).unwrap();
    let x = array![[1.0, 2.0]];

    match tree.predict_proba(&x) {
        Err(Error::Tree(TreeError::NotClassificationTree)) => {
            println!("predict_proba is classification-only");
        }
        other => println!("unexpected: {other:?}"),
    }
}

IoError (at rustyml::error::IoError) has four variants:

  • Std(std::io::Error) for filesystem failures
  • Serialization(postcard::Error) for the binary format (RustyML serializes with postcard)
  • ModelStructureMismatch(String) for when a loaded neural-network file does not match the target architecture (a different number of layers, a different layer type at some position, or a weight whose shape does not fit the target layer)
  • UnsupportedModelFormat(String) for when the file is not a RustyML model file at all, or its on-disk format version is not the one this build writes

Code example:

use rustyml::machine_learning::LinearRegression;
use rustyml::error::{Error, IoError};

fn main() {
    match LinearRegression::load_from_path("model_that_does_not_exist.bin") {
        Ok(_) => unreachable!("the file should not exist"),
        Err(Error::Io(IoError::Std(io_err))) => {
            // io_err is the underlying std::io::Error (kind NotFound here).
            println!("filesystem error: {io_err}");
        }
        Err(Error::Io(IoError::Serialization(e))) => {
            println!("the file exists but is not a valid model: {e}");
        }
        Err(e) => println!("unexpected: {e}"),
    }
}

For the serialization format and versioning, see 7.2. Model Persistence in Depth.

1.6.3. Matching on specific variants

The everyday failure is calling predict before fit, which returns Error::NotFitted carrying its own name as a &'static str:

use rustyml::machine_learning::LinearRegression;
use rustyml::error::Error;
use ndarray::array;

fn main() {
    // Constructed, but never fitted
    let model = LinearRegression::new(true);
    let x = array![[1.0, 2.0], [3.0, 4.0]];

    match model.predict(&x) {
        Ok(preds) => println!("{preds:?}"),
        Err(Error::NotFitted(name)) => {
            println!("`{name}` was not fitted; call fit() first");
        }
        Err(Error::DimensionMismatch { expected, found }) => {
            println!("wrong feature count: model wants {expected}, got {found}");
        }
        // `Error` is `#[non_exhaustive]`, so the wildcard arm is mandatory
        Err(e) => println!("other error: {e}"),
    }
}

The DimensionMismatch arm is there to show the pattern; this particular call actually triggers NotFitted. But feed a fitted model a matrix with the wrong number of columns and you take the second arm, with expected set to the feature count seen at fit time and found set to the one you passed to predict.

1.6.4. Propagating with ?

The whole crate uses a single error type, so failures anywhere in a pipeline can be returned as Error with nothing beyond Result and ?:

use rustyml::machine_learning::{LinearRegression, RegularizationType};
use rustyml::error::RustymlResult;
use ndarray::{array, Array1, Array2};

fn train_and_predict(x: &Array2<f64>, y: &Array1<f64>) -> RustymlResult<Array1<f64>> {
    // Every ? below lifts a rustyml::error::Error out of a fallible call
    let mut model = LinearRegression::new(true)
        .with_regularization(RegularizationType::L2(0.01))?;       // maybe InvalidParameter
    model.fit(x, y)?;                                              // maybe EmptyInput / DimensionMismatch / NonFinite
    let preds = model.predict(x)?;                                 // maybe NotFitted / DimensionMismatch
    Ok(preds)
}

fn main() {
    let x = array![[1.0], [2.0], [3.0]];
    let y = Array1::from_vec(vec![2.0, 4.0, 6.0]);

    match train_and_predict(&x, &y) {
        Ok(preds) => println!("got {} predictions", preds.len()),
        Err(e) => eprintln!("pipeline failed: {e}"),
    }
}

When you do need to report a foreign error (from the standard library or another crate) while folding it into this scheme and keeping its cause chain, reach for the Context extension trait (which has to be imported into scope). It is implemented for any Result<T, E> whose E is Send + Sync + 'static and implements std::error::Error, so it composes with ?. context takes the message eagerly, while with_context takes a closure that runs only on the error path — prefer the closure form whenever building the message allocates (anything with format!), so the success path never runs it:

use rustyml::error::{Context, Error, RustymlResult};

fn parse_threshold(raw: &str) -> RustymlResult<f64> {
    // A std ParseFloatError, wrapped together with our context as Error::Computation,
    // with its source() chain preserved for downcasting later.
    let value: f64 = raw
        .parse()
        .with_context(|| format!("parsing threshold from {raw:?}"))?;
    Ok(value)
}

fn main() {
    match parse_threshold("not-a-number") {
        Ok(v) => println!("threshold = {v}"),
        Err(Error::Computation { context, source }) => {
            println!("{context}");
            if let Some(cause) = source {
                println!("  caused by: {cause}");
            }
        }
        Err(e) => println!("unexpected: {e}"),
    }
}

The foreign error becomes the source of an Error::Computation, reachable through the standard std::error::Error::source() chain and downcastable back to its original concrete type without losing any information.

1.6.5. Eager validation

RustyML’s error-handling design is that anything taking a hyperparameter validates it eagerly and returns Result, rather than panicking on illegal input.

use rustyml::machine_learning::LinearRegression;
use rustyml::machine_learning::linear_model::LeastSquaresSolver;
use rustyml::error::Error;

fn main() {
    // learning_rate must be positive and finite
    // 0.0 returns an error
    match LinearRegression::new(true).with_solver(LeastSquaresSolver::GradientDescent {
        learning_rate: 0.0,
        max_iter: 1000,
        tol: 1e-6,
    }) {
        Ok(_) => unreachable!("a zero learning rate must not be accepted"),
        Err(Error::InvalidParameter { name, reason }) => {
            // bad parameter `learning_rate`: must be positive and finite, got 0
            println!("bad parameter `{name}`: {reason}");
        }
        Err(e) => println!("unexpected: {e}"),
    }
}

A few places still panic outright:

  • The functions in the metrics and math modules panic on an error instead of returning Result, which keeps those modules lightweight.
  • Whether an ndarray operation outside RustyML returns Result or panics is ndarray’s decision, and RustyML has no say in it.

2. Classical Machine Learning

Classical machine learning covers everything in RustyML that is not a neural network: linear models, trees, kernel methods, clustering, and dimensionality reduction. These algorithms train fast, need little data, and produce models you can inspect. Reach for them first. Move to Chapter 3 only when a problem needs a deep network. Every estimator in this chapter lives under rustyml::machine_learning and shares one small contract. Construct it with new, which validates its arguments and returns Result. LinearRegression::new is the one exception: it cannot fail, so it returns Self directly. Train the model with fit, and run inference with predict, unless the model reduces dimensionality. The dimensionality-reduction transformers use transform and fit_transform instead of predict. Learn this rhythm once, and it carries across all 14 models in this chapter.

Read Chapter 1 before this chapter. Read Working with ndarray first, since every model consumes an Array2<f64> feature matrix. Read Error Handling too, since constructors and fit/predict all return the crate’s Result. Chapter 4 covers encoding and scaling your features. Chapter 5 covers the accuracy, silhouette, and R^2 scores you use to judge these models.

Every model in this chapter follows the shape below, with only the details changing:

use rustyml::machine_learning::LinearRegression;
use ndarray::array;

fn main() {
    // construct -> fit -> predict, the rhythm every estimator repeats
    let mut model = LinearRegression::new(true);
    let x = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]];
    let y = array![6.0, 9.0, 12.0];
    model.fit(&x, &y).unwrap();

    let preds = model.predict(&array![[4.0, 5.0]]).unwrap();
    println!("prediction: {:?}", preds);
}

Each section stands alone, so jump straight to the model you need. Reading in order moves from the simplest estimators to the most involved.

Supervised learning predicts a target from labeled examples. Linear Regression fits a continuous target, with optional L1/L2 regularization and a choice of a gradient-descent or a closed-form solver. It is the best place to learn the fit/predict loop. Logistic Regression reuses that gradient machinery for binary classification. K-Nearest Neighbors skips training and classifies by proximity, with a selectable distance metric and weighting scheme. Decision Trees split the feature space into readable if/else rules, using the ID3, C4.5, or CART algorithm, with pruning. Support Vector Machines covers 2 models: a kernelized SVC (SMO solver) for curved boundaries, and a fast LinearSVC for wide, high-dimensional data. Linear Discriminant Analysis classifies and reduces dimensions at the same time, by modeling each class as a Gaussian with a shared covariance.

Clustering groups unlabeled data. KMeans partitions points into a fixed number of clusters, using k-means++ initialization. It is fast, and the usual default choice. DBSCAN finds clusters of any shape by density, and labels outliers as noise. It needs no cluster count in advance. Mean Shift also finds the number of clusters on its own, by climbing a density surface. Score all 3 methods with the clustering metrics.

Dimensionality reduction compresses features while it keeps their structure. Principal Component Analysis is the linear default for decorrelation and compression. Kernel PCA extends it to nonlinear structure, through RBF, polynomial, and other kernels. t-SNE embeds high-dimensional data into 2 or 3 dimensions, for visualization only. It learns no reusable projection. It exposes fit_transform alone, with no out-of-sample transform.

Anomaly detection stands as its own family. Isolation Forest scores how easily each point isolates under random splits, and flags outliers without needing any labels.

2.1. Linear Regression

LinearRegression fits a linear map y_hat = X * w + b by least squares. RustyML’s default path is the exact closed form, like scikit-learn’s LinearRegression. The coefficients agree with Python’s results to about 1e-15. Batch gradient descent is available on demand, for cases the closed form cannot serve.

This split shapes the whole page. The default estimator has no learning rate, no iteration budget, and no convergence tolerance to set. The 3 settings that do exist belong to the iterative solver, and they travel with it. The source for this page is src/machine_learning/linear_model/linear_regression.rs and its integration tests.

2.1.1. How the model is trained

Two strategies minimize the same objective. LeastSquaresSolver picks between them.

The closed form (LeastSquaresSolver::Normal, the default) solves the ridge system in one shot. With no penalty, this is plain OLS. It runs an SVD least-squares solve on the augmented design [Xc; sqrt(lambda) * I]. This returns the minimum-norm solution even when X^T * X is singular, for example with perfectly collinear columns or more features than samples. When fit_intercept is set, the solver mean-centers the features and target first, so the intercept stays out of the penalty. It then recovers the intercept as mean(y) - mean(X) * w. The closed form has nothing to iterate. get_actual_iterations() returns Some(0) after a normal-solver fit. That 0 signals that the fit took no gradient steps.

Gradient descent (LeastSquaresSolver::GradientDescent) initializes the weight vector w to zeros and the intercept b to zero, then repeats a full-batch update. Each iteration computes the prediction and residual over the whole training set, the scalar cost, the gradients, and the parameter step.

The cost is the mean squared error, halved, plus an optional penalty. Let n be the sample count and e = X * w + b - y be the residual vector. One iteration computes cost = dot(e, e) / (2 * n) + penalty. The penalty term is 0 with no regularization. It is alpha * sum(|w_j|) for L1. It is (alpha / 2) * dot(w, w) for L2. The prediction adds the intercept b only when fit_intercept is set. Regularization never penalizes the intercept, because it shrinks slopes, not the bias.

The gradients follow directly. The weight gradient is grad_w = (X^T * e) / n, plus alpha * w for L2. The intercept gradient is grad_b = sum(e) / n, or 0 when fit_intercept is false. The update step is plain gradient descent with a fixed learning_rate. It sets w to w - learning_rate * grad_w and b to b - learning_rate * grad_b. L1 does not appear in the gradient. The solver applies it after the step, through a proximal operator (see 2.1.6). Gradient descent here has no momentum, no adaptive rate, and no line search. The step size you set on the solver is the step size used on every iteration. This is why feature scaling matters so much (see 2.1.5).

3 numerical guards run inside the loop. If the cost, any gradient component, or any updated parameter becomes NaN or infinite, fit aborts immediately with Error::NonFinite rather than returning a garbage model. A divergent learning rate trips this guard within a handful of iterations, instead of silently producing inf coefficients. The closed form has its own version of the same check on the solution it computes.

The solver computes the residual sum of squares and the intercept gradient’s sum with deterministic blocked folds. The matrix-vector products, X * w and X^T * e, run in parallel above an internal size gate. Neither solver carries any randomness. 2 runs on the same machine with the same data produce identical coefficients. The determinism test asserts bit-identical predictions across 2 independently constructed models. For the parallel-reduction machinery behind this, see 7.3. Performance Tuning and Parallelism.

2.1.2. Constructing a model

The constructor takes one argument and is infallible:

pub fn new(fit_intercept: bool) -> Self
ParameterTypeMeaning
fit_interceptboolFit a bias term. When false, the fitted line passes through the origin and the stored intercept is exactly 0.0.

There is nothing else to validate here, which is why new returns Self rather than Result. The iteration settings that used to sit on the estimator now live inside the solver variant that uses them. with_solver validates them instead.

LinearRegression::default() is the same as LinearRegression::new(true). It sets fit_intercept = true, selects LeastSquaresSolver::Normal, and applies no regularization. The two constructors always build the same algorithm now. default() is the analogue of Python’s LinearRegression(), and it is exact.

Two builder methods refine a constructed model. Both consume and return self, so they chain. Both return Result, because both validate what they receive:

pub fn with_solver(self, solver: LeastSquaresSolver) -> Result<Self, Error>
pub fn with_regularization(self, regularization: RegularizationType) -> Result<Self, Error>

with_solver checks the selected variant’s payload. A non-positive or non-finite learning_rate or tol, or a max_iter of 0, yields Error::InvalidParameter with the offending field’s name. LeastSquaresSolver::Normal has no payload, so selecting it can never fail. with_regularization validates the penalty coefficient alpha the same way. It rejects a negative or non-finite alpha on the spot. The regularization enum is re-exported at rustyml::machine_learning::RegularizationType:

pub enum RegularizationType {
    L1(f64), // Lasso: penalty alpha * sum(|w_j|)
    L2(f64), // Ridge: penalty (alpha / 2) * dot(w, w)
}

Every hyperparameter and fitted quantity is readable through a getter. The fitted ones return Option, None until fit has run:

GetterReturnsMeaning
get_fit_intercept()boolWhether an intercept is fitted.
get_solver()LeastSquaresSolverNormal, or GradientDescent { learning_rate, max_iter, tol }.
get_regularization_type()Option<RegularizationType>None, L1(alpha), or L2(alpha).
get_coefficients()Option<&Array1<f64>>The fitted weight vector (by reference).
get_intercept()Option<f64>The fitted intercept.
get_actual_iterations()Option<usize>Iterations actually run during the last fit. Some(0) for the closed form.

There are no get_learning_rate, get_max_iterations, or get_tolerance accessors. Those numbers are fields of the solver variant. Read them back by matching on get_solver():

if let LeastSquaresSolver::GradientDescent { learning_rate, max_iter, tol } = model.get_solver() {
    println!("learning_rate = {learning_rate}, cap = {max_iter}, tol = {tol}");
}

There is deliberately no cost-history accessor either. The model stores the final iteration count, not the per-iteration cost curve. If you want to watch the cost descend live, build with the show_progress feature. Then fit renders a progress bar with the running cost and convergence counter. Otherwise get_actual_iterations() is the only window into what the optimizer did (see 2.1.7).

2.1.3. Fitting and predicting

The four data-facing methods are generic over ndarray’s storage type S: Data<Elem = f64>. This lets them accept owned arrays, views, and slices alike, without a copy:

pub fn fit<S1, S2>(&mut self, x: &ArrayBase<S1, Ix2>, y: &ArrayBase<S2, Ix1>) -> Result<&mut Self, Error>;
pub fn predict<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<f64>, Error>;
pub fn fit_predict<S1, S2>(&mut self, x: &ArrayBase<S1, Ix2>, y: &ArrayBase<S2, Ix1>) -> Result<Array1<f64>, Error>;
pub fn score<S1, S2>(&self, x: &ArrayBase<S1, Ix2>, y: &ArrayBase<S2, Ix1>) -> Result<f64, Error>;

x is a feature matrix with one sample per row and one feature per column. y is the target vector. fit returns &mut Self for chaining. predict returns the prediction vector. fit_predict runs fit then predict on the same data. score returns the coefficient of determination, R^2 (1 - SS_res / SS_tot). A score of 1.0 is perfect. A score of 0.0 matches a model that always predicts the mean. A negative score is worse than the mean. The generic Fit and Predict traits expose the same operations for code that works across estimator types. Their trait methods just forward to these inherent methods.

fit rejects an empty x with Error::EmptyInput. It rejects a y whose length differs from x’s row count with Error::DimensionMismatch. It rejects any NaN or infinite entry in x with Error::NonFinite. predict and score also return Error::NotFitted when you call them before training. predict checks that the column count matches the training data, again with Error::DimensionMismatch. All of these come from the crate’s unified Error type. See 1.6. Error Handling.

A complete run on a small multivariate problem, y = 2 * x1 + 3 * x2 + 1. Nothing is configured beyond the intercept, so this is exact OLS:

use ndarray::array;
use rustyml::machine_learning::LinearRegression;

fn main() {
    // Six samples spanning the feature space.
    let x = array![
        [1.0, 1.0],
        [2.0, 1.0],
        [1.0, 2.0],
        [3.0, 2.0],
        [2.0, 3.0],
        [4.0, 1.0],
    ];
    let y = array![6.0, 8.0, 9.0, 13.0, 14.0, 12.0];

    // fit_intercept = true, closed-form solver, no regularization.
    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    // Coefficients land on [2.0, 3.0] and the intercept on 1.0, to machine precision.
    println!("coefficients = {:?}", model.get_coefficients().unwrap());
    println!("intercept    = {}", model.get_intercept().unwrap());
    println!("iterations   = {}", model.get_actual_iterations().unwrap()); // 0

    // Predict on unseen rows: (1,1) -> 6.0, (2,3) -> 14.0.
    let preds = model.predict(&array![[1.0, 1.0], [2.0, 3.0]]).unwrap();
    println!("predictions  = {:?}", preds);

    // R^2 on the training data, 1.0 for exactly-linear data.
    println!("R^2          = {}", model.score(&x, &y).unwrap());
}

The closed form lands on the least-squares optimum rather than approaching it. The recovered slopes match to machine precision. Gradient descent, by contrast, only converges toward that solution. The tests assert its slopes stay within 3e-3 of the true values.

2.1.4. Choosing a solver: the normal equation vs. gradient descent

LeastSquaresSolver is a payload-carrying enum. Each variant owns exactly the settings it uses. You cannot pass a learning rate to the closed form, and you cannot leave one unset for the iterative path.

pub enum LeastSquaresSolver {
    Normal, // the default
    GradientDescent { learning_rate: f64, max_iter: usize, tol: f64 },
}

LeastSquaresSolver::Normal is the right choice for small to medium dense problems. This is why it is the default. It is exact, needs no scaling, and has no hyperparameters to tune.

Prefer gradient descent in 3 cases. First, when the dataset is large enough that the closed-form factorization, roughly O(n * p^2), costs too much. Second, when you want L1, since the closed form has no L1 solution. Third, when you match a scikit-learn or Keras workflow that also uses iterative optimization.

The Normal solver supports only no regularization or L2. Pairing it with an L1 penalty makes fit return Error::InvalidInput, because Lasso has no closed form. Use gradient descent for L1.

Selecting gradient descent and configuring it are one expression, and the result is a Result, because the payload is validated:

use ndarray::array;
use rustyml::machine_learning::LinearRegression;
use rustyml::machine_learning::linear_model::LeastSquaresSolver;

fn main() {
    // y = 3*x0 - 2*x1 + 5, exactly.
    let x = array![
        [1.0, 1.0],
        [2.0, 0.0],
        [0.0, 3.0],
        [4.0, 2.0],
        [3.0, 1.0],
        [1.0, 4.0],
    ];
    let y = array![6.0, 11.0, -1.0, 13.0, 12.0, 0.0];

    // The exact solution, in one step.
    let mut exact = LinearRegression::new(true);
    exact.fit(&x, &y).unwrap();

    // The iterative solution: the settings travel with the variant that uses them.
    let mut iterative = LinearRegression::new(true)
        .with_solver(LeastSquaresSolver::GradientDescent {
            learning_rate: 0.01,
            max_iter: 10_000,
            tol: 1e-9,
        })
        .unwrap();
    iterative.fit(&x, &y).unwrap();

    println!("exact     = {:?}", exact.get_coefficients().unwrap());     // ~ [3.0, -2.0]
    println!("iterative = {:?}", iterative.get_coefficients().unwrap()); // approaches the same
    println!("iterations: exact = {:?}, iterative = {:?}",
        exact.get_actual_iterations(), iterative.get_actual_iterations());
}

The two solvers agree numerically when they solve the same objective. The gradient-descent cost divides the data term by 2 * n but scales the L2 penalty by only alpha / 2. This makes the equivalent ridge penalty on the raw sum of squares equal to lambda = n * alpha. That value is exactly what the normal solver uses. The integration tests confirm that the closed-form L2 solution matches what gradient descent converges to, for the same alpha.

2.1.5. Standardize your features first

This section is about gradient descent. The closed form sidesteps this whole concern, which is one more reason it is the default.

Gradient descent with a single global learning rate depends on how well your feature scales match. Suppose one column ranges over the thousands and another sits near unit magnitude. The loss surface then becomes a steep, narrow valley. A learning rate small enough to avoid overshooting on the steep axis crawls along the shallow one, so convergence takes far more iterations. A learning rate large enough to make progress on the shallow axis diverges on the steep one and trips the finiteness guard. Standardizing each column to zero mean and unit variance makes the curvature roughly isotropic. Then one learning rate serves every direction, and you can safely use a larger one.

StandardScaler does exactly this. It remembers the training mean and standard deviation, so it transforms the test set with the training statistics, not the test set’s own. See 4.2. Standardization and Normalization for the full train and test workflow, and for the stateless standardize free function. The coefficients you read back are then in standardized units, not raw feature units.

use ndarray::array;
use rustyml::machine_learning::LinearRegression;
use rustyml::machine_learning::linear_model::LeastSquaresSolver;
use rustyml::utils::StandardScaler;

fn main() {
    // Column 0 lives in the thousands. Column 1 is near unit scale.
    let x_raw = array![
        [1000.0, 1.0],
        [2000.0, 3.0],
        [3000.0, 2.0],
        [4000.0, 5.0],
        [5000.0, 4.0],
    ];
    let y = array![10.0, 23.0, 32.0, 45.0, 54.0];

    // Zero mean, unit variance per column. The scaler keeps the statistics for later batches.
    let mut scaler = StandardScaler::new();
    let x = scaler.fit_transform(&x_raw).unwrap();

    // Isotropic features tolerate a much larger step than the raw data would.
    let mut model = LinearRegression::new(true)
        .with_solver(LeastSquaresSolver::GradientDescent {
            learning_rate: 0.1,
            max_iter: 5_000,
            tol: 1e-9,
        })
        .unwrap();
    model.fit(&x, &y).unwrap();

    println!("iterations   = {}", model.get_actual_iterations().unwrap());
    println!("R^2          = {}", model.score(&x, &y).unwrap());
    // These slopes are in standardized units.
    println!("coefficients = {:?}", model.get_coefficients().unwrap());
}

As a rule of thumb for the step size, start around 0.1 on standardized data. Back off by a factor of 10 if fit returns Error::NonFinite or the iteration count pins to max_iter. On raw, small-magnitude integer features, a rate near 0.01 is usually safe.

2.1.6. Regularization: L1 vs L2

L2 (RegularizationType::L2(alpha), ridge) adds (alpha / 2) * dot(w, w) to the cost and alpha * w to the weight gradient. It shrinks every coefficient smoothly toward zero, without forcing any coefficient to exactly zero. This suits correlated features, where you want a stable, low-variance fit. Ridge spreads weight across collinear columns instead of letting one grow too large. Both solvers support L2.

L1 (RegularizationType::L1(alpha), lasso) adds alpha * sum(|w_j|) to the cost. The solver applies it with a proximal step. After each gradient step, it soft-thresholds every weight by learning_rate * alpha. Any weight the data cannot justify lands on exactly 0.0 and stays there. This method is called ISTA, and it is what makes L1 a feature selector. An older sub-gradient form added alpha * sign(w) to the gradient instead. That form only approaches zero over time, so it never produced true sparsity. Either way, the intercept stays unpenalized. L1 needs the gradient-descent solver, because no closed form exists for it.

Setting alpha correctly matters most when you port a model from Python. Every estimator that takes RegularizationType minimizes a mean data term plus an undivided penalty:

L1:  (1 / n) * sum(loss) + alpha * ||w||_1
L2:  (1 / n) * sum(loss) + alpha * 0.5 * ||w||^2

This matches scikit-learn’s SGDRegressor and SGDClassifier objective exactly, so alpha transfers 1:1 from either one. The closed-form estimators use their own conventions, and those conventions differ from each other:

scikit-learnRustyML
Lasso(alpha=a)L1(a), identical objective. Both scale the data term by 1 / (2 * n)
Ridge(alpha=a)L2(a / n). scikit-learn’s Ridge does not divide its data term by n
SGDRegressor(alpha=a) / SGDClassifier(alpha=a)L1(a) or L2(a), no conversion needed
LogisticRegression(C=c)L1(1 / (c * n)) or L2(1 / (c * n))

Here n is the number of training samples. The Ridge row states the lambda = n * alpha relationship from 2.1.4, read in the other direction.

This snippet uses the exact solver, so the shrinkage is unambiguous. Ridge produces a strictly smaller coefficient norm than OLS on nearly-collinear data:

use ndarray::array;
use rustyml::machine_learning::{LinearRegression, RegularizationType};

fn main() {
    // Two nearly-collinear features make plain OLS coefficients large and unstable.
    let x = array![
        [1.0, 0.9],
        [2.0, 2.1],
        [3.0, 2.9],
        [4.0, 4.2],
        [5.0, 5.1],
    ];
    let y = array![1.0, 2.0, 3.0, 4.0, 5.0];

    // Both use the default closed-form solver.
    let mut ols = LinearRegression::new(true);
    ols.fit(&x, &y).unwrap();

    let mut ridge = LinearRegression::new(true)
        .with_regularization(RegularizationType::L2(1.0))
        .unwrap();
    ridge.fit(&x, &y).unwrap();

    let sq_norm =
        |m: &LinearRegression| m.get_coefficients().unwrap().iter().map(|c| c * c).sum::<f64>();
    println!("OLS   ||w||^2 = {}", sq_norm(&ols));
    println!("ridge ||w||^2 = {}", sq_norm(&ridge)); // strictly smaller
}

For L1, select gradient descent explicitly. The Normal solver rejects L1 with Error::InvalidInput. The crate’s own tests use 200 features, where column 0 carries the signal and the other 199 columns are pure noise. A small L1 penalty keeps the informative coefficient dominant and drives every noise coefficient to literal 0.0. Count the zeros with iter().filter(|c| **c == 0.0).count(). The tests assert exactly this behavior.

2.1.7. Convergence and diagnosing non-convergence

This section applies to LeastSquaresSolver::GradientDescent. The closed form has no notion of convergence. It either produces a solution or returns an error.

Convergence is not declared on a single small step. After each iteration, the optimizer compares the absolute change in cost against tol. A change below tol increments a counter. Training stops only after 3 consecutive iterations below tol. Any iteration whose cost change exceeds the threshold resets the counter to zero. This rule guards against a premature stop on a temporary plateau. The loop also stops unconditionally at max_iter.

After fit, compare get_actual_iterations() against the max_iter you configured. Read max_iter back by matching on get_solver(). If the actual count is strictly below the cap, the 3-in-a-row rule fired, and the model converged. If it equals the cap, the optimizer ran out of budget, and the fit may be underconverged.

The usual remedies, in order:

  1. Standardize the features (see 2.1.5).
  2. Raise max_iter.
  3. Raise the learning rate for faster descent, but watch for divergence.
  4. Loosen tol, if you do not need the last digit of precision.
  5. Drop the with_solver call, and use the exact closed-form answer instead.

Divergence is the other failure mode. A learning rate that is too large sends the cost, and then the parameters, to infinity. The in-loop finiteness guard converts that into Error::NonFinite within a few iterations, instead of returning coefficients filled with inf. A NonFinite error from fit, on clean and finite data, almost always means the learning rate is too high, or the features need scaling. The model keeps no per-iteration cost history. To watch the descent directly, build with the show_progress feature. Then fit renders the running cost and the k/3 convergence counter on each iteration.

2.1.8. Inspecting the fitted parameters

The getters let you read the fitted model without running inference again. get_coefficients() returns Option<&Array1<f64>>, a borrow, because the model owns its weights. get_intercept() and get_actual_iterations() return owned Option values instead. The solver’s settings come back inside the enum. Read them with a match or an if let.

use ndarray::array;
use rustyml::machine_learning::LinearRegression;
use rustyml::machine_learning::linear_model::LeastSquaresSolver;

fn main() {
    let x = array![[1.0], [2.0], [3.0], [4.0], [5.0]];
    let y = array![3.0, 5.0, 7.0, 9.0, 11.0]; // y = 2x + 1

    let mut model = LinearRegression::new(true)
        .with_solver(LeastSquaresSolver::GradientDescent {
            learning_rate: 0.01,
            max_iter: 10_000,
            tol: 1e-10,
        })
        .unwrap();
    model.fit(&x, &y).unwrap();

    // Weights come back by reference, so iterate without moving them out of the model.
    let coefs = model.get_coefficients().unwrap();
    for (j, w) in coefs.iter().enumerate() {
        println!("w[{j}] = {w:.6}");
    }

    // Intercept and iteration count are owned values.
    println!("intercept       = {:.6}", model.get_intercept().unwrap());
    let ran = model.get_actual_iterations().unwrap();
    println!("iterations run  = {ran}");

    // The iteration settings live in the solver variant.
    match model.get_solver() {
        LeastSquaresSolver::Normal => println!("closed form, nothing to tune"),
        LeastSquaresSolver::GradientDescent { learning_rate, max_iter, tol } => {
            println!("learning_rate   = {learning_rate}");
            println!("tolerance       = {tol}");
            println!("max_iter        = {max_iter}");
            println!("converged early = {}", ran < max_iter);
        }
    }

    // Regularization is readable whether or not the model has been fitted.
    println!("regularization  = {:?}", model.get_regularization_type());
}

When fit_intercept is false, get_intercept() returns Some(0.0) after fitting, by contract. The intercept is fixed at zero, not merely small. Reading it back as exactly 0.0 confirms that no bias term was learned.

2.1.9. Saving and loading a model

A fitted model serializes to a compact postcard binary blob. The blob includes the coefficients, the intercept, the hyperparameters, and the training metadata. save_to_path(&self, path: &str) writes it. load_from_path(path: &str) -> Result<Self, Error> reads it back. Both methods report I/O and serialization failures as Error::Io. A round trip reproduces predictions exactly, because the raw f64 coefficients stay bit for bit the same.

use ndarray::array;
use rustyml::machine_learning::LinearRegression;

fn main() {
    let x = array![[1.0], [2.0], [3.0], [4.0], [5.0]];
    let y = array![3.0, 5.0, 7.0, 9.0, 11.0];

    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();
    let before = model.predict(&array![[6.0]]).unwrap();

    // Persist to disk, then reload into a fresh instance.
    model.save_to_path("lr_model.bin").unwrap();
    let loaded = LinearRegression::load_from_path("lr_model.bin").unwrap();
    let after = loaded.predict(&array![[6.0]]).unwrap();

    println!("before = {:?}, after = {:?}", before, after); // identical

    std::fs::remove_file("lr_model.bin").unwrap();
}

The serialized layout changed when the iteration settings moved into the solver variant. What used to be 3 loose fields is now 1 payload. A blob written by an older version will not load. Re-fit and re-save any persisted models.

The file extension carries no meaning. The format is always postcard binary, whether you name the file .bin, .dat, or anything else. LinearRegression also derives Clone and Debug. An in-process copy is a plain model.clone(), and {:?} prints the full struct. For full details on cross-model persistence, including neural-network weights, see 7.2. Model Persistence in Depth.

For the classification counterpart built on the same gradient-descent machinery, continue to 2.2. Logistic Regression. For more regression scores you can compute on a fitted model’s predictions, see 5.1. Regression Metrics.

2.2. Logistic Regression

LogisticRegression is RustyML’s linear classifier for binary problems. It shares most of its machinery with LinearRegression: a weight vector, an optional intercept, gradient descent, and optional L1 or L2 penalties. It replaces the squared-error objective with the logistic loss. It also passes the linear score through a sigmoid. The output then reads as a probability. This model corresponds to scikit-learn’s sklearn.linear_model.LogisticRegression, stripped to the essentials. RustyML supports one binary class boundary and plain full-batch gradient descent. It has no built-in multiclass support. It also expresses regularization strength directly, not as the inverse C.

2.2.1. What the model actually optimizes

Each sample gets a linear score z = w * x (plus a bias when the model fits an intercept). The sigmoid sigmoid(z) = 1 / (1 + e^-z) maps that score into (0, 1). RustyML reads that value as the probability of the positive class. Training minimizes the mean binary cross-entropy between those probabilities and the labels. The optimizer is plain full-batch gradient descent. Every iteration computes the gradient over the entire training set, (1/n) * X^T * (sigmoid(X * w) - y). It then takes one step of size learning_rate against that gradient.

This choice has 2 consequences in practice. First, there is no stochastic sampling and no random initialization. Weights start at exactly zero, and the update is deterministic. 2 fits on the same data and hyperparameters produce bit-identical weights (the test suite verifies this). Second, full-batch gradient descent is a first-order method with one global step size. It is far more sensitive to feature scaling and to learning_rate than the quasi-Newton solvers (lbfgs, liblinear) that scikit-learn defaults to. This is the main behavioral difference from those solvers. It drives the practical advice in 2.2.6.

RustyML evaluates the loss in the numerically stable log-sum-exp form, instead of taking the log of a sigmoid. The formula is max(z, 0) - z * y + ln(1 + e^-|z|). Large-magnitude logits therefore do not overflow the loss computation. The weights can still overflow, which section 2.2.6 covers as a real failure mode. The per-iteration logits, gradient, and loss run through RustyML’s parallel GEMV and deterministic reduction primitives above their size gates. Results are therefore reproducible on a given machine. See 7.3. Performance Tuning and Parallelism for more.

2.2.2. Constructing the model

2 constructors exist. LogisticRegression::default() gives a reasonable starting point. LogisticRegression::new(...) sets every hyperparameter and validates each one up front.

use rustyml::machine_learning::LogisticRegression;

fn main() {
    // Defaults: fit_intercept = true, lr = 0.01, max_iter = 100, tol = 1e-4, no penalty
    let _a = LogisticRegression::default();

    // Explicit: new(fit_intercept, learning_rate, max_iterations, tolerance)
    let _b = LogisticRegression::new(true, 0.1, 1000, 1e-6).unwrap();
}

new returns Result<Self, Error>. It rejects an invalid hyperparameter immediately with Error::InvalidParameter, instead of letting it fail later at fit time.

ParameterTypeDefaultConstraint
fit_interceptbooltruenone
learning_ratef640.01strictly positive and finite
max_iterationsusize100at least 1
tolerancef641e-4strictly positive and finite

The defaults are deliberately conservative. learning_rate = 0.01 with only 100 iterations rarely converges, except on trivial, well-scaled data. Treat default() as a smoke test, not a production configuration. Most real fits need a larger learning_rate (after standardizing the data) and a max_iterations value in the thousands.

Every stored value has a getter. 2 of them are the model’s after-fit diagnostics:

GetterReturns
get_fit_intercept()bool
get_learning_rate()f64
get_max_iterations()usize
get_tolerance()f64
get_regularization_type()Option<RegularizationType>
get_actual_iterations()Option<usize> (iterations actually run, None before fit)
get_weights()Option<&Array1<f64>> (None before fit)

get_actual_iterations() gives the convergence check. Training stops when the loss change falls below tolerance between iterations, or when it reaches max_iterations. If the returned count equals max_iterations, assume the model did not converge to the tolerance. Raise max_iterations, raise learning_rate, or standardize the inputs instead of trusting that boundary.

2.2.3. The label contract: strictly 0 and 1

fit takes a feature matrix x (rows are samples, columns are features) and a target vector y. Both must be f64 arrays with the same storage type. The label domain is exact. Every entry of y must be 0.0 or 1.0. RustyML rejects anything else, such as 0.5, 2.0, or -1.0, with Error::InvalidInput. This matches the label convention used by SVC and LinearSVC, which also predict 0.0 and 1.0. If your labels use a different encoding, map them to {0, 1} first. For string or categorical labels, encode them first with 4.3. Label Encoding. Pick which class is positive, the 1, on purpose. That choice defines what precision, recall, and the probability output mean.

use ndarray::array;
use rustyml::machine_learning::LogisticRegression;

fn main() {
    // Logical AND, encoded as {0.0, 1.0}
    let x_train = array![[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]];
    let y_train = array![0.0, 0.0, 0.0, 1.0];

    let mut model = LogisticRegression::new(true, 0.5, 500, 1e-7).unwrap();
    model.fit(&x_train, &y_train).unwrap();

    let preds = model.predict(&x_train).unwrap(); // Array1<f64> with values in {0.0, 1.0}
    println!("predictions: {:?}", preds);
    println!("iterations:  {:?}", model.get_actual_iterations());
}

When fit_intercept is true, the model prepends the bias as weight index 0. get_weights() then returns n_features + 1 values. When fit_intercept is false, it returns exactly n_features values. This indexing matters when you inspect the weights, because the regularizer treats the intercept specially (see below).

2.2.4. Predicting: hard labels versus probabilities

RustyML has 3 prediction entry points. The distinction between hard labels and probabilities is the part most people get wrong.

  • predict(&x) -> Result<Array1<f64>, Error> returns hard class labels, 0.0 or 1.0. It thresholds the positive-class probability at 0.5.
  • predict_proba(&x) -> Result<Array1<f64>, Error> returns the raw positive-class probability in (0, 1). It gives one value per sample, the output of the sigmoid.
  • fit_predict(&mut self, x, y) runs fit, then predict on the same x, for a quick check on the training set.

predict is exactly predict_proba with a fixed >= 0.5 cutoff. The model has no threshold parameter. That fixed cutoff works for balanced problems, but the 0.5 boundary is a modeling choice, not a rule. When a false positive costs more or less than a false negative, or the classes are imbalanced, call predict_proba and set your own threshold.

use ndarray::array;
use rustyml::machine_learning::LogisticRegression;

fn main() {
    let x_train = array![[-3.0], [-2.0], [-1.0], [1.0], [2.0], [3.0]];
    let y_train = array![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];

    let mut model = LogisticRegression::new(true, 0.3, 500, 1e-7).unwrap();
    model.fit(&x_train, &y_train).unwrap();

    let x_test = array![[-0.5], [0.5]];
    let proba = model.predict_proba(&x_test).unwrap(); // positive-class probability
    let default = model.predict(&x_test).unwrap();     // 0.5 threshold -> Array1<f64> of {0.0, 1.0}

    // A stricter operating point: only call positive when p >= 0.8
    let strict: Vec<i32> = proba.iter().map(|&p| if p >= 0.8 { 1 } else { 0 }).collect();

    println!("proba:   {:?}", proba);
    println!("default: {:?}", default);
    println!("strict:  {:?}", strict);
}

All 3 methods validate the input against the trained feature count, excluding the implicit bias column. Calling an unfitted model returns Error::NotFitted. A wrong number of columns returns Error::DimensionMismatch. A NaN or infinite entry returns Error::NonFinite. Pass features without a manual bias column. The model adds and removes that column internally to match how it trained.

2.2.5. Regularization

By default there is no penalty. This departs from scikit-learn, whose default is L2. Add a penalty with the builder method with_regularization. It consumes and returns the model, so it chains directly off new:

use ndarray::array;
use rustyml::machine_learning::{LogisticRegression, RegularizationType};

fn main() {
    let x = array![
        [-4.0, -3.0], [-3.0, -4.0], [-2.0, -1.0],
        [ 2.0,  1.0], [ 3.0,  4.0], [ 4.0,  3.0],
    ];
    let y = array![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];

    let mut plain = LogisticRegression::new(true, 0.1, 2000, 1e-8).unwrap();
    plain.fit(&x, &y).unwrap();

    let mut ridge = LogisticRegression::new(true, 0.1, 2000, 1e-8)
        .unwrap()
        .with_regularization(RegularizationType::L2(5.0))
        .unwrap();
    ridge.fit(&x, &y).unwrap();

    // L2 norm of the feature weights, skipping the (unpenalized) bias at index 0
    let feature_norm = |m: &LogisticRegression| {
        m.get_weights().unwrap().iter().skip(1).map(|w| w * w).sum::<f64>()
    };
    println!("no penalty: {:.4}", feature_norm(&plain));
    println!("L2(5.0):    {:.4}", feature_norm(&ridge));
}

The f64 inside each variant is the penalty strength alpha. It must be non-negative and finite (alpha = 0 is accepted and equals no penalty). RegularizationType::L2(alpha) (ridge) adds alpha * 0.5 * ||w||^2 to the loss. It shrinks weights smoothly toward zero. RegularizationType::L1(alpha) (lasso) adds alpha * ||w||_1. It drives individual weights to exactly zero, giving a sparse model useful for feature selection.

That “exactly zero” result is literal. RustyML does not fold L1 into the gradient as alpha * sign(w). A sub-gradient step only approaches zero, so that approach gives no sparsity, however long it runs. Instead, the optimizer takes its ordinary gradient step and then applies a proximal step. Every feature weight is soft-thresholded by learning_rate * alpha, so a weight the data cannot support lands on 0.0 and stays there. This method is ISTA. It is what makes L1 an actual feature selector. Count the zeros with w.iter().skip(1).filter(|v| **v == 0.0).count().

3 implementation details change how you pick alpha:

  • The intercept is never penalized. When the model fits an intercept, the penalty gradient starts at feature index 1, so the bias stays free to move. This is the standard, correct choice. The regularizer should not fight the model’s ability to shift its decision boundary. Skip index 0 when you compare weight norms, as the example does.
  • The penalty is not divided by the sample count. The data term is the mean log-loss, but RustyML adds the penalty as the absolute value alpha * R(w). So alpha keeps a fixed meaning no matter how many rows the data has. Replicating a dataset leaves the regularized optimum unchanged (the test suite checks this invariance). Here, a larger alpha means more regularization, the opposite of scikit-learn’s inverse C.
  • alpha transfers 1:1 from scikit-learn’s SGD estimators. It needs conversion from the rest. The objective above, mean data term plus undivided penalty, matches SGDClassifier exactly. An alpha tuned there moves over untouched. From LogisticRegression(C=c), use alpha = 1 / (c * n), where n is the number of training samples. The full conversion table lives on RegularizationType, shared with LinearRegression.

2.2.6. Standardization, class imbalance, and separable data

3 failure modes are common enough to name here.

Standardize your features. The optimizer is full-batch gradient descent with one global learning_rate. Features on very different scales therefore converge at very different rates. The step that suits a feature ranging over [0, 1] is far too small for one ranging over [0, 10000]. Training then crawls, and the loss plateaus before it reaches tolerance within max_iterations. Center and scale the data first with the tools in 4.2. Standardization and Normalization. This step gives the largest gain in both convergence speed and numerical stability. It also lets you use a useful learning_rate, such as 0.1 to 1.0, instead of the cautious default.

There is no class weighting. The model has no class_weight parameter, and predict fixes its threshold at 0.5. On imbalanced data, it can learn to predict only the majority class and still report a high accuracy. Use 2 defenses. Threshold predict_proba yourself, at an operating point chosen from a precision-recall or ROC analysis. Also evaluate with metrics that imbalance does not fool, such as balanced_accuracy, mcc, or roc_auc from 5.2. Classification Metrics, instead of raw accuracy.

Perfectly separable data makes the unregularized maximum likelihood estimate diverge. When a hyperplane separates the classes cleanly, the likelihood maximizes by pushing the weight norm toward infinity, since the probabilities saturate at 0 and 1. Unregularized gradient descent then keeps growing the weights the longer it runs. In practice, max_iterations and tolerance cut training off, and the classification stays correct. The weights, and so the probabilities, still become arbitrary in magnitude and poorly calibrated. A large learning_rate on large-magnitude separable inputs can also overflow the weight update completely. RustyML catches this with an in-loop Error::NonFinite guard, instead of silently returning NaN. Adding even a small L2 penalty bounds the optimum, keeps the probabilities meaningful, and removes the overflow risk. This is the main reason to keep a penalty on by default in production.

2.2.7. Polynomial features for non-linear boundaries

The decision boundary is linear in the feature space you give it. Non-linear problems therefore need a richer space. generate_polynomial_features(&x, degree) expands each row into all monomials up to degree. For 2 features at degree 2, this gives the 5 columns [x1, x2, x1^2, x1*x2, x2^2], with no constant column (the intercept already supplies that). Fit on the expansion, and predict on the same expansion.

use ndarray::array;
use rustyml::machine_learning::{LogisticRegression, generate_polynomial_features};

fn main() {
    // Inner ring = class 0, outer ring = class 1: not linearly separable in (x1, x2)
    let x = array![
        [ 1.0, 0.0], [0.0,  1.0], [-1.0, 0.0], [0.0, -1.0],
        [ 5.0, 0.0], [0.0,  5.0], [-5.0, 0.0], [0.0, -5.0],
    ];
    let y = array![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];

    // 2 features, degree 2 -> [x1, x2, x1^2, x1*x2, x2^2]
    let x_poly = generate_polynomial_features(&x, 2);
    assert_eq!(x_poly.ncols(), 5);

    let mut model = LogisticRegression::new(true, 0.01, 3000, 1e-7).unwrap();
    model.fit(&x_poly, &y).unwrap();

    // The x1^2 + x2^2 term makes the two rings linearly separable
    let preds = model.predict(&x_poly).unwrap();
    println!("{:?}", preds);
}

The column count grows combinatorially with both feature count and degree. 3 features at degree 3 already yields 19 columns. Use this tool only for a handful of features and a low degree. Reach for a kernel method, such as SVC, when the expansion gets large.

2.2.8. A worked example with evaluation

This example fits on a small 2-feature set, then evaluates it with the classification metrics from Chapter 5. predict already returns hard {0.0, 1.0} labels as an Array1<f64>. That is exactly what ConfusionMatrix::new requires. It thresholds nothing itself, so it rejects a probability vector instead of binarizing it silently. roc_auc works the other way. It wants a boolean truth vector alongside the continuous predict_proba scores, because ranking is the whole point.

use ndarray::{array, Array1};
use rustyml::machine_learning::LogisticRegression;
use rustyml::metrics::{ConfusionMatrix, accuracy, roc_auc};

fn main() {
    let x_train = array![
        [-2.0, -1.5], [-1.5, -2.0], [-1.0, -0.5], [-2.5, -1.0], [-0.5, -1.0],
        [ 2.0,  1.5], [ 1.5,  2.0], [ 1.0,  0.5], [ 2.5,  1.0], [ 0.5,  1.0],
    ];
    let y_train = array![0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0];

    let mut model = LogisticRegression::new(true, 0.5, 500, 1e-7).unwrap();
    model.fit(&x_train, &y_train).unwrap();

    // Already hard {0.0, 1.0} labels, which is what ConfusionMatrix::new requires
    let preds = model.predict(&x_train).unwrap();

    let cm = ConfusionMatrix::new(&y_train, &preds);
    println!("{}", cm.summary());
    println!("accuracy:  {:.3}", accuracy(&y_train, &preds));

    // ROC AUC ranks the probabilities, so it needs the raw scores, not the 0/1 labels
    let truth: Array1<bool> = y_train.mapv(|v| v > 0.5);
    let scores = model.predict_proba(&x_train).unwrap();
    println!("ROC AUC:   {:.3}", roc_auc(&truth, &scores));
}

ConfusionMatrix::summary() prints the counts alongside accuracy, balanced accuracy, precision, recall, specificity, F1, and MCC in one table. It gives a fast way to check a binary classifier. Feeding predict_proba into roc_auc, instead of the thresholded labels, makes the AUC a threshold-independent measure of ranking quality. It answers how well the model orders positives above negatives, regardless of where you later set the cutoff. The output above depends on the data. Here is the shape to expect:

Confusion Matrix:
+-----------------+--------------------+--------------------+
| ...             | Predicted Positive | Predicted Negative |
...
Performance Metrics:
- Accuracy:          <0.0..1.0>
- ...
accuracy:  <0.0..1.0>
ROC AUC:   <0.0..1.0>

2.2.9. Persistence and reproducibility

A trained model serializes to a compact postcard binary through save_to_path and load_from_path. This binary carries the weights, hyperparameters, and iteration count. A round-trip is byte-exact, so a loaded model’s predictions match the original’s predictions exactly.

use ndarray::array;
use rustyml::machine_learning::LogisticRegression;

fn main() {
    let x = array![[-2.0, 1.0], [-1.0, -1.0], [1.0, 1.0], [2.0, -1.0]];
    let y = array![0.0, 0.0, 1.0, 1.0];

    let mut model = LogisticRegression::new(true, 0.3, 500, 1e-7).unwrap();
    model.fit(&x, &y).unwrap();

    let path = "logreg_model.bin";
    model.save_to_path(path).unwrap();
    let loaded = LogisticRegression::load_from_path(path).unwrap();

    assert_eq!(model.predict(&x).unwrap(), loaded.predict(&x).unwrap());

    std::fs::remove_file(path).unwrap();
    println!("round-trip OK");
}

Fitting has no randomness anywhere, so reproducibility comes free. There is no seed to set, unlike the sampling-based models in this chapter. 2 fits on identical data and hyperparameters yield identical weights. This is what makes the persistence round-trip exact. See 7.1. Reproducibility and Random Seeds for where seeds do matter. See 7.2. Model Persistence in Depth for the serialization format and its cross-version limits. If you build with the show_progress feature, fit also renders a live progress bar with the running loss. This bar is a convenient way to watch for the non-convergence and divergence behaviors described above.

2.3. K-Nearest Neighbors

K-Nearest Neighbors (KNN) does almost no work at training time. Instead, it does most of its work at prediction time. RustyML implements it as KNN<T>, a generic classifier over any label type T that supports hashing and equality comparison.

The API resembles scikit-learn’s KNeighborsClassifier, with a few RustyML-specific differences. The label type is generic, not limited to integers. The distance metric is one DistanceCalculationMetric enum, shared with DBSCAN and the clustering metrics. Tie-breaking is deterministic, based on the order labels first appear. A separate entry point runs prediction in parallel.

2.3.1. Lazy Learning: Where the Cost Lives

KNN is the purest lazy learner in the crate. Its fit method does almost no learning. It validates the input, copies the training feature matrix, and encodes the labels into compact usize indices, which makes voting a cheap integer operation. Unlike logistic regression or a decision tree, KNN does not compress the training data into weights or a tree of splits. predict does all the real work.

This deferral has a real cost. There is no trained model to consult, so classifying one query means measuring its distance to every training row and keeping the k smallest. On the brute-force path, the distance stage costs O(n_train * n_test * d) for n_test queries against n_train training rows in d dimensions. Each query also needs an O(n_train) partial selection step to pull out the k nearest values. This step uses Quickselect through select_nth_unstable, not a full O(n_train log n_train) sort. Memory use is O(n_train * d), because the entire training set stays in memory for the life of the model. The training set is the model. You accept this tradeoff to get a non-parametric classifier with no training phase and no assumptions about the shape of the decision boundary.

Two consequences follow. First, prediction latency scales with the size of the training set. KNN that runs fast on a few thousand rows can become the bottleneck on a few hundred thousand. Second, geometry decides accuracy at query time. This is why the distance metric and feature scaling, covered later in this page, matter more here than in almost any other model.

2.3.2. Constructing a Classifier

The constructor takes only k. Every other setting has a default value. You set these values through chained builder methods:

// Core surface (from src/machine_learning/neighbors/knn.rs)
pub fn new(k: usize) -> Result<Self, Error>;                       // Err if k == 0
pub fn with_weighting_strategy(self, s: WeightingStrategy) -> Self;
pub fn with_metric(self, m: DistanceCalculationMetric) -> Result<Self, Error>; // validates Minkowski p

pub fn fit<S1, S2>(&mut self, x: &ArrayBase<S1, Ix2>, y: &ArrayBase<S2, Ix1>)
    -> Result<&mut Self, Error>;
pub fn predict<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<T>, Error>;
pub fn predict_parallel<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<T>, Error>; // T: Sync + Send
pub fn fit_predict<S1, S2>(&mut self, x: &..., y: &...) -> Result<Array1<T>, Error>;

new returns Error::InvalidParameter when k == 0. This is the one failure every caller can hit during construction. with_metric can also fail, because it validates the Minkowski order, covered in the next section. with_weighting_strategy cannot fail and returns Self directly. A fully specified builder chain therefore ends with ? or .unwrap() on the metric call. This matches the order used throughout the test suite.

The two enums that parameterize the model:

ParameterTypeVariantsDefault
WeightingWeightingStrategyUniform, DistanceUniform
MetricDistanceCalculationMetricEuclidean, Manhattan, Minkowski(f64)Euclidean

KNN::<T>::default() gives you k = 5, Uniform weighting, and Euclidean distance. These are the same defaults you get from calling new(5) and changing nothing else. Read the stored configuration back with get_k, get_weighting_strategy, get_metric, and get_x_train. get_x_train returns Option<&Array2<f64>>, which is None before you call fit.

The label type T is fully generic. Any type that is Clone + Hash + Eq works, so integer class codes, String labels, or your own enum all work. fit encodes the labels it sees into indices, in first-appearance order, and stores the reverse map. predict decodes the indices back to the original T. Feed in Array1<String>, and you get Array1<String> back. KNN is a classifier only. The crate has no KNN regressor. For neighbor-averaged regression, build it yourself on the distance primitives in Chapter 6.1.

Here is a full example. It uses both the sequential and parallel entry points:

use ndarray::array;
use rustyml::machine_learning::{DistanceCalculationMetric, KNN, WeightingStrategy};

fn main() {
    let x_train = array![
        [1.0, 2.0],
        [2.0, 3.0],
        [3.0, 4.0],
        [6.0, 6.0],
        [7.0, 7.0],
        [8.0, 8.0],
    ];
    let y_train = array![0, 0, 0, 1, 1, 1];

    let mut knn = KNN::new(3)
        .unwrap()
        .with_weighting_strategy(WeightingStrategy::Uniform)
        .with_metric(DistanceCalculationMetric::Euclidean)
        .unwrap();

    knn.fit(&x_train, &y_train).unwrap();

    let x_test = array![[1.5, 2.5], [7.5, 7.0]];
    let seq = knn.predict(&x_test).unwrap();
    let par = knn.predict_parallel(&x_test).unwrap();

    assert_eq!(seq, par); // deterministic: both paths agree exactly
    println!("k = {}", knn.get_k());
    println!("predictions: {:?}", seq);
}

fit checks for the mistakes that would otherwise cause a panic at predict time. It returns Error::EmptyInput for a zero-row x. It returns Error::NonFinite if x holds a NaN or an infinity. It returns Error::DimensionMismatch when y.len() differs from x.nrows(). It returns Error::InvalidInput when the training set has fewer samples than k. For example, you cannot ask for 5 neighbors from 3 points. predict and predict_parallel add Error::NotFitted when you call them before fit. They also return EmptyInput, DimensionMismatch for a query with the wrong feature count, and NonFinite for a query matrix with NaN or infinite values. See Error Handling for the full Error enum.

fit_predict fits the model, then predicts on the same training matrix. With k = 1, it returns the training labels unchanged. Each point’s nearest neighbor is itself, at distance zero. This makes fit_predict useful as a sanity check, but useless as an accuracy estimate. For a real estimate of generalization, hold out data with Train-Test Split and score it with the classification metrics.

2.3.3. Distance Metrics and the Minkowski Order

The metric decides what counts as nearest. RustyML exposes 3 metrics through one enum, shared across the library. Euclidean (L2) is the straight-line default. Manhattan (L1) sums the absolute differences between coordinates. Use Manhattan when features are independent axes measured in different units, or when you want robustness against a single outlying coordinate. Minkowski(p) generalizes both metrics. p = 1 reduces to Manhattan, and p = 2 reduces to Euclidean, exactly. The test suite asserts these equalities on shared data. Intermediate or larger values of p interpolate and extrapolate the shape of the unit ball.

with_metric validates the Minkowski order. It returns Error::InvalidParameter if p < 1 or p is not finite. This is a real constraint, not a style choice. Orders below 1 violate the triangle inequality, so the result is no longer a valid metric. Such an order would also break the pruning logic of the kd-tree index, described later in this page. The bare distance function minkowski_distance_row panics on p < 1. Routing through with_metric turns that panic into a recoverable Err you can handle. Minkowski(2.0) is legal, and numerically identical to Euclidean. Prefer the Euclidean variant when you want L2. Euclidean enables a matrix-multiply fast path, covered in section 2.3.7, that the general Minkowski code does not have.

use ndarray::array;
use rustyml::machine_learning::{DistanceCalculationMetric, KNN, WeightingStrategy};

fn main() {
    let x_train = array![[3.0, 0.0], [0.0, 4.0]];
    let y_train = array![0, 1];

    let mut knn = KNN::new(1)
        .unwrap()
        .with_weighting_strategy(WeightingStrategy::Uniform)
        .with_metric(DistanceCalculationMetric::Minkowski(3.0))
        .unwrap();
    knn.fit(&x_train, &y_train).unwrap();

    // Under L3: dist((0,3),(3,0)) = 54^(1/3) ~= 3.78 > dist((0,3),(0,4)) = 1
    let x_test = array![[0.0, 3.0]];
    println!("{:?}", knn.predict(&x_test).unwrap()); // nearest is (0,4) -> class 1
}

Chapter 6.1, Distance Metrics covers the metric abstraction in more depth, including the comparable-distance trick that lets the spatial index skip the final root.

2.3.4. Weighting Strategies and Tie-Breaking

After KNN finds the k neighbors, WeightingStrategy decides how their labels combine into one prediction.

Uniform is a plain majority vote. Each of the k neighbors contributes one vote to its class, and the class with the most votes wins. Distance weights each neighbor by 1.0 / distance, so a neighbor twice as close counts twice as much. Use distance weighting when k is large enough that the neighbor set reaches genuinely dissimilar points. The far points still vote, but their influence decays. Distance weighting also lowers the sensitivity of the result to the exact value of k.

Distance weighting has one edge case that the implementation handles explicitly. A query that coincides with a training point sits at distance zero, and 1.0 / 0.0 is infinity. To avoid that, the code checks for exact matches first. If any of the k neighbors sits at distance exactly 0.0, only those exact-match neighbors vote, by count, and KNN ignores the rest. This makes an exact hit behave like a lookup, which is almost always what you want.

use ndarray::array;
use rustyml::machine_learning::{DistanceCalculationMetric, KNN, WeightingStrategy};

fn main() {
    let x_train = array![[0.0, 0.0], [10.0, 0.0]];
    let y_train = array![0, 1];

    let mut knn = KNN::new(2)
        .unwrap()
        .with_weighting_strategy(WeightingStrategy::Distance)
        .with_metric(DistanceCalculationMetric::Euclidean)
        .unwrap();
    knn.fit(&x_train, &y_train).unwrap();

    // Both points are always in the k=2 set. The nearer one wins the weighted vote.
    let x_test = array![[1.0, 0.0], [9.0, 0.0]];
    println!("weighted: {:?}", knn.predict(&x_test).unwrap()); // [0, 1]

    // Exact match short-circuits the 1/0 problem: it votes by count, not weight.
    let x_exact = array![[0.0, 0.0]];
    println!("exact:    {:?}", knn.predict(&x_exact).unwrap()); // [0]
}

RustyML breaks ties with a deliberate, documented rule, not an arbitrary one. A tie happens when 2 classes have equal vote counts under Uniform, or equal summed weight under Distance. The winner is the class with the smallest encoded index. That index is not the smallest label value. It is the order in which fit first saw each label. For example, if your training targets show label 7 before label 3, 7 encodes to index 0 and wins ties against 3. Ties are broken deterministically and reproducibly, but the resolution depends on the order of the training rows. Reordering your data can flip a tied prediction. This same determinism is what lets predict and predict_parallel guarantee identical results.

2.3.5. Choosing k

k is the setting that changes behavior the most. It acts as a direct bias-variance dial. A small k, down to the extreme of k = 1, gives a low-bias, high-variance classifier. Its decision boundary hugs the data and follows every wrinkle, including mislabeled points and noise. A large k averages over a wider neighborhood. This lowers variance but raises bias. Push k far enough, and the model drifts toward always predicting the most common class overall. It then starts to erase small but real minority regions. A common starting point is a k near the square root of the training-set size. Tune it against a validation split. There is no substitute for measuring the result.

The classic advice to use an odd k for binary classification is about ties. RustyML’s tie-break is deterministic, so an even k never causes an error. A 50/50 split resolves by first-encounter order, but that resolution can feel arbitrary, and it depends on the order of your data. An odd k keeps binary votes from ever landing on a tie. Distance weighting also reduces this problem, because exact ties in summed real-valued weights are unlikely. Remember also the hard floor from section 2.3.2. fit rejects any k greater than the number of training samples.

This example makes the variance concrete. It places a single mislabeled point inside the class-0 region, then places a query right next to it. At k = 1, the noise wins. At k = 3 and k = 5, the surrounding genuine class-0 points outvote it:

use ndarray::array;
use rustyml::machine_learning::{DistanceCalculationMetric, KNN, WeightingStrategy};

fn main() {
    // Two clean clusters plus one mislabeled point at (2.5, 0): it sits inside
    // the class-0 region but carries the class-1 label.
    let x_train = array![
        [0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0],     // class 0
        [10.0, 0.0], [11.0, 0.0], [12.0, 0.0], [13.0, 0.0], // class 1
        [2.5, 0.0],                                         // noise, class 1
    ];
    let y_train = array![0, 0, 0, 0, 1, 1, 1, 1, 1];

    let x_test = array![[2.4, 0.0]]; // right next to the noisy point

    for k in [1usize, 3, 5] {
        let mut knn = KNN::new(k)
            .unwrap()
            .with_weighting_strategy(WeightingStrategy::Uniform)
            .with_metric(DistanceCalculationMetric::Euclidean)
            .unwrap();
        knn.fit(&x_train, &y_train).unwrap();
        let pred = knn.predict(&x_test).unwrap();
        println!("k = {k}: prediction = {}", pred[0]);
    }
}

The prediction flips from the noisy label to the correct one as k grows:

k = 1: prediction = 1
k = 3: prediction = 0
k = 5: prediction = 0

This single-point sensitivity at k = 1 is the high-variance failure mode. Increasing k trades it for a smoother, more biased boundary.

2.3.6. Feature Scaling Is Not Optional

This mistake causes more failures than any other, so it gets its own section. KNN ranks neighbors by raw distance, and every metric here sums per-coordinate differences. Suppose one feature ranges over the thousands and another ranges over [0, 1]. The large-range feature then dominates the distance, and the small-range feature becomes invisible, regardless of which one actually carries the label. Unlike a linear model, which can learn a small coefficient for a large-scale feature, KNN has no coefficients to compensate. You must scale the features yourself before you call fit.

The example below encodes the label entirely in a small-range column. A large-range column carries no information about the label. On raw features, the large column decides the nearest neighbor, and the prediction is wrong. Standardizing with the training set’s per-column mean and standard deviation, applied to both train and query data, puts the informative column on equal footing. The prediction is then right:

use ndarray::{array, Axis};
use rustyml::machine_learning::KNN;

fn main() {
    // Column 0 spans the ~1000s and is uninformative. Column 1 in {0, 10} carries the label.
    let x_train = array![
        [1000.0, 0.0],  // class 0
        [3000.0, 0.0],  // class 0
        [1050.0, 10.0], // class 1
        [3050.0, 10.0], // class 1
    ];
    let y_train = array![0, 0, 1, 1];

    // Column 1 = 9.0 says class 1. Column 0 = 1010.0 is closest to a class-0 row.
    let x_test = array![[1010.0, 9.0]];

    let mut raw = KNN::new(1).unwrap();
    raw.fit(&x_train, &y_train).unwrap();
    let raw_pred = raw.predict(&x_test).unwrap();

    // Standardize with statistics computed on the TRAINING set, applied to both.
    let mean = x_train.mean_axis(Axis(0)).unwrap();
    let std = x_train.std_axis(Axis(0), 0.0);
    let x_train_s = (&x_train - &mean) / &std;
    let x_test_s = (&x_test - &mean) / &std;

    let mut scaled = KNN::new(1).unwrap();
    scaled.fit(&x_train_s, &y_train).unwrap();
    let scaled_pred = scaled.predict(&x_test_s).unwrap();

    println!("raw features:  {:?}", raw_pred);    // dominated by column 0 -> [0]
    println!("standardized:  {:?}", scaled_pred); // respects column 1  -> [1]
}

The example scales the data by hand, to stay self-contained. It still follows correct statistical practice. The mean and standard deviation come from the training data only, then apply to the query. The code never re-estimates them on the test set. In a real pipeline, use the crate’s standardize helper, or normalize, instead of writing this by hand. Fit the transform on the training set, then apply that same transform to new data. Fitting the transform again on the test set leaks information. Standardization to zero mean and unit variance is the usual choice. Min-max normalization is the alternative when you need features bounded to a fixed range.

2.3.7. Sequential vs Parallel Prediction, the kd-tree, and the Euclidean Fast Path

RustyML gives you 2 prediction entry points. predict runs sequentially and works for any label type. predict_parallel spreads the per-query work across a Rayon pool. Use predict_parallel for large query batches. It requires T: Sync + Send. Both methods build any shared index once, up front, on a single thread, before the queries run. predict_parallel then parallelizes over the test rows. The tie-break is deterministic, so the two paths return bit-identical label arrays. The test suite checks this across Uniform, Distance, and large-k configurations. You can develop with predict, then switch to predict_parallel for throughput, without changing a single result.

Under the hood, the search takes one of 2 routes. In low dimensions, at most 8 features, predict builds a kd-tree over the training data on first use, and caches it. The kd-tree gives average-case neighbor lookups that beat scanning every row. Above 8 features, the tree stops pruning effectively. This is the curse of dimensionality, where nearly every point becomes roughly equidistant from every other. Above that limit, the code falls back to a brute-force scan, and the full O(n_train * n_test * d) cost from section 2.3.1 applies. This 8-feature ceiling comes from calibration on one data shape, not from a universal law. Clustered data and different dataset sizes shift the actual crossover point. It remains the fixed threshold that the current implementation uses.

The brute-force Euclidean case gets a dedicated optimization. Squared Euclidean distance expands to ||x||^2 + ||t||^2 - 2 * x . t. The only per-pair term left is the dot product x . t, which is a matrix multiply. RustyML precomputes the training rows’ squared norms once and shares them across every query. It then computes the cross terms through the gemmkit matrix-multiply backend. This backend blocks the work to keep it cache-resident on large training sets. It also switches between a per-row GEMV swarm and a tiled GEMM, depending on whether the training matrix still fits in the shared L3 cache. Manhattan and Minkowski have no such algebraic shortcut. They fall back to a plain per-pair metric scan. This is another reason to prefer the Euclidean variant when you want L2. The kd-tree rebuilds lazily, and KNN drops it whenever you call fit again. A refitted model therefore never serves stale neighbors. See Performance Tuning and Parallelism for more on the parallelism gates and tuning.

2.3.8. Persistence

KNN<T> serializes with save_to_path and load_from_path when T is Serialize + Deserialize, as i32 and String are. Persistence writes the compact postcard binary format, regardless of the file extension you choose. It stores exactly what defines the model: k, the weighting strategy, the metric, the training matrix, and the label encoding. The kd-tree is not serialized. It is marked #[serde(skip)] and rebuilds lazily on the loaded model’s first predict call. A reloaded classifier therefore produces predictions identical to the original, with no extra work from you.

use ndarray::array;
use rustyml::machine_learning::{DistanceCalculationMetric, KNN};

fn main() {
    let x_train = array![
        [0.0, 0.0], [1.0, 0.0], [2.0, 0.0],
        [10.0, 0.0], [11.0, 0.0], [12.0, 0.0],
    ];
    let y_train = array![0, 0, 0, 1, 1, 1];

    let mut knn = KNN::new(3)
        .unwrap()
        .with_metric(DistanceCalculationMetric::Manhattan)
        .unwrap();
    knn.fit(&x_train, &y_train).unwrap();

    let path = "knn_model.bin";
    knn.save_to_path(path).unwrap();

    // Rebuilds its kd-tree lazily on first predict. k, metric, and labels are restored.
    let loaded = KNN::<i32>::load_from_path(path).unwrap();

    let x_test = array![[0.5, 0.0], [11.5, 0.0]];
    assert_eq!(
        knn.predict(&x_test).unwrap(),
        loaded.predict(&x_test).unwrap()
    );
    println!("round-trip predictions match");

    std::fs::remove_file(path).unwrap();
}

A KNN model carries its entire training set, so the serialized file grows with n_train * d. Persistence here saves your data plus a little metadata, not a handful of learned parameters. If model size matters to you, that alone is a reason to consider a parametric classifier instead. Model Persistence in Depth covers the format and its guarantees.

KNN<T> also implements the crate’s shared Fit and Predict traits. These traits are re-exported from machine_learning, and defined in crate::traits. Fit takes the training data as an (x, y) tuple. You will normally call the inherent fit, predict, and predict_parallel methods shown throughout this page. The traits exist so that generic code can treat every estimator the same way.

2.4. Decision Trees

A decision tree splits the feature space into axis-aligned boxes with a chain of if/else tests. Each box gets one constant prediction: a class label (or class distribution) for classification, or the mean target for regression. RustyML packs this into one DecisionTree type. You choose the algorithm with the Algorithm parameter: ID3, C45, or CART.

In scikit-learn, you pick DecisionTreeClassifier or DecisionTreeRegressor, then pick a criterion with a criterion= string. RustyML makes the algorithm the top-level choice instead. Each Algorithm variant bundles the impurity measure, the split-selection rule, and the categorical-split policy together.

A numeric feature always splits on a binary threshold. A sample with feature <= t goes left. The algorithm changes only how RustyML scores thresholds and whether categorical columns get multi-way branches.

DecisionTree lives under rustyml::machine_learning. It follows the new -> fit -> predict contract from Classical Machine Learning.

2.4.1. Choosing an algorithm: ID3, C4.5, CART

The Algorithm enum has exactly 3 variants. The differences are not cosmetic. Each variant changes what the tree can do and how it scores a split.

AlgorithmTasksClassification impuritySplit scoreCategorical columns
ID3classification onlyShannon entropyinformation gain (raw impurity decrease)multi-way (one branch per value)
C45classification onlyShannon entropygain ratio (gain / split information)multi-way
CARTclassification and regressionGini for classification, MSE for regressionraw impurity decreasebinary only

This table gives 2 consequences. First, only CART supports regression. DecisionTree::new(Algorithm::ID3, false) and DecisionTree::new(Algorithm::C45, false) fail right away with Error::InvalidInput, not at fit time. The constructor is the fast-fail gate for this check.

Second, the C4.5 gain ratio fixes a bias in ID3 toward high-cardinality features. Information gain favors a feature with many distinct values. In the extreme, a unique-per-row ID column scores a perfect gain but does not generalize at all. C4.5 divides the gain by the split’s intrinsic information to penalize a split that fans out into many thin branches. When the intrinsic information falls to zero (a single-branch partition), C4.5 rejects the split. Gain ratio is the safer default when a dataset mixes categorical columns of very different cardinality.

For plain numeric data with no categorical columns, all 3 algorithms reduce to the same greedy binary-threshold search. They tend to agree on the result. Use CART unless you need entropy-based scoring or multi-way categorical branches.

2.4.2. Classification: construct, fit, predict

The classifier path is new(algorithm, true). Labels must be non-negative integers encoded as f64. They must be dense from 0. RustyML infers the number of classes as max(label) + 1. A label set like {0, 5} allocates 6 classes, with 4 of them empty and unused. Encode your labels to 0..k-1 before you fit (see Label Encoding).

use rustyml::machine_learning::{Algorithm, DecisionTree};
use ndarray::array;

fn main() {
    // 2 features, 3 classes, split cleanly along feature 0
    let x = array![
        [0.0, 0.0],
        [0.1, 0.0],
        [0.2, 0.1],
        [10.0, 1.0],
        [10.1, 1.0],
        [20.0, 2.0],
        [20.1, 2.0],
    ];
    let y = array![0.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0];

    let mut tree = DecisionTree::new(Algorithm::CART, true)
        .unwrap()
        .with_max_depth(5)
        .with_random_state(42);
    tree.fit(&x, &y).unwrap();

    let x_test = array![[0.05, 0.0], [10.2, 1.0], [20.2, 2.0]];
    let labels = tree.predict(&x_test).unwrap(); // Array1<f64>, 1 label per row
    let proba = tree.predict_proba(&x_test).unwrap(); // Array2<f64>

    println!("labels: {:?}", labels);
    println!("proba shape: {:?}", proba.shape()); // [3, 3] = (n_samples, n_classes)
    println!("n_classes: {:?}", tree.get_n_classes()); // Some(3)
}

predict returns an Array1<f64> of labels. predict_proba returns an Array2<f64>. Each row is the leaf’s empirical class distribution, so it sums to 1.0. The argmax of each row agrees with the matching predict label.

On a pure leaf, the distribution is one-hot. On an impure leaf, it holds the exact class frequencies of the training samples that reached it. An impure leaf appears only when you stop growth early (see the next section).

DecisionTree also has 2 single-sample methods: predict_one(&[f64]) -> f64 and predict_proba_one(&[f64]) -> Vec<f64>. fit_predict fits the tree, then predicts on the same training matrix in 1 call.

Calling predict_proba on a regression tree is a runtime error. It returns Error::Tree(TreeError::NotClassificationTree).

2.4.3. Regression with CART

Set is_classifier = false to switch the tree to MSE impurity. MSE impurity is the population variance of a node’s targets. Each leaf predicts the mean of its training targets.

This rule explains the staircase output of a regression tree. Predictions are piecewise-constant, with 1 plateau per leaf. A regression tree never extrapolates beyond the training range.

use rustyml::machine_learning::{Algorithm, DecisionTree};
use ndarray::array;

fn main() {
    // Step function: the target jumps from 1 to 10 somewhere between x = 2 and x = 10
    let x = array![[0.0], [1.0], [2.0], [10.0], [11.0], [12.0]];
    let y = array![1.0, 1.0, 1.0, 10.0, 10.0, 10.0];

    // Only CART supports regression
    let mut tree = DecisionTree::new(Algorithm::CART, false).unwrap();
    tree.fit(&x, &y).unwrap();

    // Each query lands in a leaf and gets that leaf's mean target
    let preds = tree.predict(&array![[1.5], [11.5]]).unwrap();
    println!("{:?}", preds); // [1.0, 10.0], the 2 leaf means
}

A non-CART regressor request fails at construction time. This check is the only place RustyML validates the algorithm and task combination eagerly:

// ID3 and C4.5 are classification-only, so this fails fast:
let err = DecisionTree::new(Algorithm::ID3, false).unwrap_err();
// -> Error::InvalidInput("Only CART algorithm is supported for regression tasks")

2.4.4. Hyperparameters and overfitting control

A tree grown with no limits keeps splitting until every leaf is pure (classification) or holds a single sample (regression). That tree memorizes the training set, noise included. This is the classic decision-tree failure mode.

The 5 growth parameters below are the full toolbox for trading training fit against generalization. Four of them are pre-pruning stopping rules that apply during growth. RustyML does no post-pruning. It has no ccp_alpha-style cost-complexity pruning, so all overfitting control happens before training starts.

BuilderField / typeDefaultConstraint
with_max_depthmax_depth: Option<usize>None (unlimited)infallible, returns Self
with_min_samples_splitmin_samples_split: usize2>= 2, else InvalidParameter
with_min_samples_leafmin_samples_leaf: usize1>= 1, else InvalidParameter (also must be <= min_samples_split)
with_min_impurity_decreasemin_impurity_decrease: f640.0non-negative and finite, else InvalidParameter
with_random_staterandom_state: Option<u64>Noneinfallible, returns Self

The bounded setters return Result. Use .unwrap() or ? after them in a chain. with_max_depth and with_random_state return Self directly, with no Result.

One rule spans both min_samples_leaf and min_samples_split. Since you set them independently, no single builder can enforce it. min_samples_leaf must not exceed min_samples_split. RustyML checks this constraint at fit time and returns Error::InvalidParameter on a bad pairing. A bad pairing fails when you train, not when you build the tree.

Each parameter rejects a candidate split for a different reason:

  • max_depth caps the number of edges on any root-to-leaf path. Some(0) forces the root to be a leaf. That leaf predicts the global majority class (or global mean), a useful sanity baseline.
  • min_samples_split stops a node from splitting when it holds fewer samples than this value. It ends recursion early and yields a shallower tree.
  • min_samples_leaf constrains the split search, not just the final pick. RustyML never considers a threshold that would leave a child below this minimum. Instead of collapsing the node into a leaf, the tree falls back to the best split whose children both satisfy the floor. This matches scikit-learn semantics. It is the parameter people misread most often: a rare category or a lone outlier does not throw away an otherwise good split.
  • min_impurity_decrease rejects a split whose impurity decrease, scaled by N_t / N_total, falls below the threshold. N_t / N_total is the fraction of all training samples that reach the node. This node-weight scaling follows the scikit-learn convention. A large impurity drop deep in the tree, where few samples remain, counts for less than the same drop near the root.

The next example contrasts an overfit tree with a constrained one, on data with 2 deliberately mislabeled points. The unconstrained tree grows extra depth to isolate the noise and reaches 100% training accuracy. The constrained tree stays shallow and keeps every leaf populated, so it does not fit the noise:

use rustyml::machine_learning::{Algorithm, DecisionTree, Node, NodeType};
use ndarray::{array, Array1, Array2};

// Longest root-to-leaf path in edges (a bare leaf is depth 0)
fn depth(node: &Node) -> usize {
    match &node.node_type {
        NodeType::Leaf { .. } => 0,
        NodeType::Internal { .. } => {
            let mut d = 0;
            if let Some(l) = &node.left {
                d = d.max(depth(l));
            }
            if let Some(r) = &node.right {
                d = d.max(depth(r));
            }
            if let Some(children) = &node.children {
                for c in children.values() {
                    d = d.max(depth(c));
                }
            }
            1 + d
        }
    }
}

fn train_accuracy(tree: &DecisionTree, x: &Array2<f64>, y: &Array1<f64>) -> f64 {
    let preds = tree.predict(x).unwrap();
    let correct = preds
        .iter()
        .zip(y.iter())
        .filter(|(p, t)| (*p - *t).abs() < 0.5)
        .count();
    correct as f64 / y.len() as f64
}

fn main() {
    // Underlying rule: x <= 4 -> class 0, x >= 5 -> class 1.
    // 2 labels violate it: x = 1 and x = 8 are flipped noise.
    let x = array![[0.0], [1.0], [2.0], [3.0], [4.0], [5.0], [6.0], [7.0], [8.0], [9.0]];
    let y = array![0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0];

    // Unconstrained: grows until pure, memorizing the noise
    let mut overfit = DecisionTree::new(Algorithm::CART, true).unwrap();
    overfit.fit(&x, &y).unwrap();

    // Constrained: 1 split deep, every leaf must retain >= 2 samples
    let mut constrained = DecisionTree::new(Algorithm::CART, true)
        .unwrap()
        .with_max_depth(1)
        .with_min_samples_leaf(2)
        .unwrap();
    constrained.fit(&x, &y).unwrap();

    println!(
        "unconstrained: depth {}, train acc {:.3}",
        depth(overfit.get_root().unwrap()),
        train_accuracy(&overfit, &x, &y)
    );
    println!(
        "constrained:   depth {}, train acc {:.3}",
        depth(constrained.get_root().unwrap()),
        train_accuracy(&constrained, &x, &y)
    );
}

The unconstrained tree reports a deeper structure and perfect training accuracy. The constrained tree has a single split, with 2 errors it deliberately keeps. On unseen data, the shallow tree is the one you want.

Real tuning sweeps these parameters against a validation split (see Train-Test Split). Score the result with the classification metrics.

2.4.5. Categorical features, missing values, and constant columns

RustyML has no separate categorical dtype. Every feature is an f64 column. You declare which columns hold discrete category codes with set_categorical_features(vec![...]). This is a &mut self setter. Call it between new and fit.

Under ID3 or C45, a declared column splits multi-way, with 1 child branch per distinct value. A multi-way split solves a pattern that a single numeric threshold cannot, such as “class 1 only when the code equals 1”. CART is binary by construction and ignores the declaration completely. On a CART tree, marking a column categorical does nothing. RustyML gives no warning.

use rustyml::machine_learning::{Algorithm, DecisionTree};
use ndarray::array;

fn main() {
    // 1 categorical feature (codes 0/1/2). Class depends on the code, not on any
    // single numeric cut: 0 -> class 0, 1 -> class 1, 2 -> class 0.
    let x = array![[0.0], [0.0], [1.0], [1.0], [2.0], [2.0]];
    let y = array![0.0, 0.0, 1.0, 1.0, 0.0, 0.0];

    let mut tree = DecisionTree::new(Algorithm::C45, true).unwrap();
    tree.set_categorical_features(vec![0]); // treat column 0 as categorical
    tree.fit(&x, &y).unwrap();

    // Each distinct code becomes its own branch, so the pattern is learned exactly
    println!("train preds: {:?}", tree.predict(&x).unwrap());

    // A category never seen in training routes to the node's fallback leaf: no error
    println!("unseen code 99 -> {:?}", tree.predict(&array![[99.0]]).unwrap());
}

RustyML canonicalizes category values by rounding to 6 decimals. 1.0000001 and 1.0000002 collapse into the same branch, while 1.0 and 2.0 stay distinct. Encode your codes as clean integers stored in f64, to avoid surprises.

At predict time, an unseen category cannot match any branch. It falls through to a stored fallback leaf, the majority prediction of the parent node’s training samples. You always get a valid prediction, never an error.

Even under ID3 or C45, min_samples_leaf still guards categorical splits. RustyML keeps a split as long as at least 2 branches each meet the leaf floor. One rare category with too few samples does not discard the whole multi-way split.

Missing values. RustyML has no NaN routing. fit and predict both check every input for finiteness. Any NaN or infinity in the feature matrix returns Error::NonFinite. Impute or drop missing entries before training (see Data Preprocessing).

Constant columns. A constant column is harmless. A numeric split is valid only between 2 distinct feature values. A column that never varies gives no candidate threshold, so RustyML skips it. A declared categorical column with fewer than 2 distinct values likewise gives no split. A constant feature costs a little search time but never corrupts the tree.

2.4.6. Inspecting the fitted tree

A fitted tree is easy to inspect. generate_tree_structure() returns a ready-to-print ASCII rendering of the tree: splits, thresholds, leaf classes, and probability vectors. It returns Error::NotFitted when you call it before training.

use rustyml::machine_learning::{Algorithm, DecisionTree};
use ndarray::array;

fn main() {
    let x = array![[0.0], [1.0], [2.0], [3.0]];
    let y = array![0.0, 0.0, 1.0, 1.0];

    let mut tree = DecisionTree::new(Algorithm::CART, true).unwrap();
    tree.fit(&x, &y).unwrap();

    print!("{}", tree.generate_tree_structure().unwrap());
    println!("features seen: {}", tree.get_n_features());
}

For programmatic inspection, get_root() -> Option<&Node> returns the raw tree. Node is public, with fields node_type, left, right, and children (an AHashMap<String, Box<Node>> for multi-way categorical nodes). NodeType is either Internal { feature_index, threshold, categories } or Leaf { value, class, probabilities }.

The depth helper in 2.4.4 walks this structure. You can walk it too, to extract feature-importance statistics or export the tree to another format.

The remaining getters are plain accessors. They are get_algorithm(), get_is_classifier(), get_n_features(), get_n_classes() (None for regression), get_parameters() (a Copy DecisionTreeParams), and get_categorical_features().

2.4.7. Determinism and seeding

Growth is greedy and deterministic, with one exception. When 2 or more candidate splits tie at the exact same selection score, the tree must break the tie.

With random_state = None and no crate-wide seed set, tie-breaking is deterministic. The last tied candidate wins. Repeated fits on the same data give a bit-for-bit identical tree. You do not need a seed for reproducibility, even on data with ties.

Set with_random_state(seed) to pick a uniformly random tied candidate from a seeded stream instead. The same seed reproduces the same tree. Different seeds may pick different, equally-scoring features.

use rustyml::machine_learning::{Algorithm, DecisionTree};
use ndarray::array;

fn main() {
    // Features 0 and 1 are identical columns, so their best splits tie exactly
    let x = array![[0.0, 0.0], [0.0, 0.0], [1.0, 1.0], [1.0, 1.0]];
    let y = array![0.0, 0.0, 1.0, 1.0];

    let fit_seeded = |seed: u64| {
        let mut t = DecisionTree::new(Algorithm::CART, true)
            .unwrap()
            .with_random_state(seed);
        t.fit(&x, &y).unwrap();
        t.generate_tree_structure().unwrap()
    };

    // Same seed -> identical tree, even though ties are broken at random
    assert_eq!(fit_seeded(7), fit_seeded(7));
    println!("seed 7 is reproducible");
}

random_state = Some(seed) uses that seed on its own and ignores any global seed. A tree left at random_state = None instead draws its tie-breaking randomness from the thread-local global stream, when one is active. Call set_global_seed(s) (paired with clear_global_seed()) to make a whole pipeline of unseeded models reproducible together.

Reproducibility and Random Seeds covers the mechanics and the reasoning behind routing all randomness through one seed.

2.4.8. Errors

Tree-specific failures live in the TreeError enum, re-exported as rustyml::machine_learning::TreeError. You reach it through the crate-wide Error::Tree variant. It is #[non_exhaustive] and has 2 members.

NotClassificationTree returns when you call predict_proba or predict_proba_one on a regression tree. CorruptStructure(&'static str) guards an invariant violation. A normally fitted and used model never triggers it. It protects tree traversal against a hand-built or otherwise broken node graph.

Every other error comes from the shared error surface in Error Handling:

  • InvalidInput: bad labels, too few samples, or zero features.
  • InvalidParameter: an out-of-range builder value, or the min_samples_leaf > min_samples_split cross-check.
  • NonFinite: NaN or infinity in features.
  • NotFitted: returned before training.
  • EmptyInput: empty training or prediction data.
  • DimensionMismatch { expected, found }: a prediction matrix with the wrong feature count.
use rustyml::machine_learning::{Algorithm, DecisionTree, TreeError};
use rustyml::error::Error;
use ndarray::array;

fn main() {
    let x = array![[0.0], [1.0], [2.0], [10.0], [11.0], [12.0]];
    let y = array![1.0, 1.0, 1.0, 10.0, 10.0, 10.0];

    let mut reg = DecisionTree::new(Algorithm::CART, false).unwrap();
    reg.fit(&x, &y).unwrap();

    // A regression tree has no class probabilities to report
    match reg.predict_proba(&x) {
        Err(Error::Tree(TreeError::NotClassificationTree)) => {
            println!("predict_proba is classification-only");
        }
        other => panic!("unexpected: {:?}", other),
    }
}

2.4.9. Persistence

A fitted DecisionTree serializes with save_to_path and load_from_path. These methods write and read the compact postcard binary format through serde. The whole structure round-trips, including the AHashMap children of multi-way categorical nodes. A loaded model reproduces predictions exactly.

You can name the file anything you want. The content is binary regardless of the file extension.

use rustyml::machine_learning::{Algorithm, DecisionTree};
use ndarray::array;

fn main() {
    let x = array![[0.0], [1.0], [2.0], [10.0], [11.0], [12.0]];
    let y = array![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];

    let mut tree = DecisionTree::new(Algorithm::CART, true).unwrap();
    tree.fit(&x, &y).unwrap();

    tree.save_to_path("dt_model.bin").unwrap();
    let loaded = DecisionTree::load_from_path("dt_model.bin").unwrap();

    // Predictions survive the round trip bit-for-bit
    assert_eq!(tree.predict(&x).unwrap(), loaded.predict(&x).unwrap());
    println!("round trip OK");

    std::fs::remove_file("dt_model.bin").unwrap();
}

A failed read, a failed write, or a corrupt payload returns Error::Io. See Model Persistence in Depth for the format’s guarantees and version considerations.

2.4.10. Complexity and parallelism

Growing a node sorts each feature’s values once, then sweeps them with running impurity statistics. A node over n samples costs O(n_features * n log n). For a reasonably balanced tree, this compounds to roughly O(n_features * n log^2 n) overall. This is the standard cost of a CART-style learner. It is why a wide dataset (many features) dominates the training budget.

Prediction is a root-to-leaf walk, at O(depth) per sample. The trained tree does not store its own depth. The parallel gate instead assumes a walk of about 16 nodes as a stand-in value.

RustyML runs 2 independent rayon parallelizations, once the work clears a calibrated gate. During fit, the per-feature split search runs in parallel when n_samples * n_features clears the sort-scan gate. During predict or predict_proba, per-sample traversal runs in parallel when the sample count clears the tree-traversal gate.

A small problem stays single-threaded, to avoid parallel overhead. You do nothing to opt in. These gates pick a strategy only. They never change the result. Performance Tuning and Parallelism covers how to tune the thresholds.

2.5. Support Vector Machines

A support vector machine finds the decision boundary that sits as far as possible from the nearest points of each class. This is the maximum-margin hyperplane. RustyML ships 2 implementations that reach this goal from opposite directions.

SVC is the kernelized version. It lifts the data into a higher-dimensional space through a kernel function and finds a linear boundary there. Back in the original space, that boundary becomes curved. LinearSVC skips the kernel. It fits a straight hyperplane by minimizing hinge loss with stochastic gradient descent.

Both models are strictly binary classifiers, with no built-in one-vs-rest wrapper. Both use the same {0.0, 1.0} label domain, so you can swap one for the other without a rewrite of the target array. Past that shared goal, the 2 models differ in solver, regularization, and cost. The rest of this page helps you pick the right one for a problem.

2.5.1. When to reach for which

The choice comes down to 2 numbers: the sample count (n_samples) and the feature count (n_features). It also depends on whether a straight line can separate the classes.

SVC builds an n_samples x n_samples kernel matrix, also called the Gram matrix, before training starts. The Sequential Minimal Optimization (SMO) solver then works over that matrix. The matrix sets a hard limit: it costs O(n_samples^2) in memory and O(n_samples^2 * n_features) to build. At 10,000 samples, the Gram matrix alone takes about 800 MB of f64 values. At 260,000 samples, it would need hundreds of gigabytes.

SVC fits well when the boundary is truly nonlinear and n_samples stays modest, from hundreds to a few thousand rows. It does not scale to large datasets. That limit comes from the algorithm, not from this implementation.

LinearSVC never forms a kernel matrix. Each epoch runs a few matrix-vector passes over the data. That costs O(n_samples * n_features) in time and only O(n_features) in memory for the weight vector. It scales linearly in both dimensions, which makes it the default choice for wide, high-dimensional, or large-sample problems. Text classification with tens of thousands of sparse features is the classic case.

The catch is that LinearSVC can only fit a linear boundary. If the classes are not linearly separable, no amount of training fixes that. Use SVC with a kernel instead.

SVCLinearSVC
Boundarylinear or nonlinear (via kernel)linear only
SolverSequential Minimal Optimization (dual)minibatch SGD (primal)
Label domain0.0 / 1.00.0 / 1.0
MemoryO(n_samples^2) Gram matrixO(n_features) weights
Scales with n_samplespoorly, the n^2 memory walllinearly
Regularization knobC (bounds the dual variables)L1 / L2 penalty with strength lambda
Losshinge (solved in the dual)Hinge or SquaredHinge

Watch for one trap. If you need only a linear boundary on a large dataset, do not use SVC with KernelType::Linear. That choice still pays the full O(n_samples^2) Gram-matrix cost, for a problem LinearSVC solves in linear memory. SVC with a linear kernel earns its cost only on small data, where you want the exact max-margin dual solution.

Both estimators are sensitive to feature scale. The RBF kernel measures squared Euclidean distance, and the SGD in LinearSVC takes fixed-size steps in feature space. Standardize your columns first (see Standardization and Normalization), unless they already share a range.

Both estimators also share one hard rule for y: every entry must be exactly 0.0 or 1.0. Any other value, such as a -1.0 from a textbook derivation, a 2.0, or a stray 0.5, causes an Error::InvalidInput at fit. Neither estimator remaps labels silently. If your labels are strings or arbitrary integers, run them through Label Encoding first.

2.5.2. SVC: kernels and the SMO solver

Construct an SVC with SVC::new(kernel, regularization_param, tol, max_iter). This call validates the arguments and returns a Result. Set the seed for reproducible training in a separate builder step, with with_random_state.

ParameterTypeMeaningValidation
kernelKernelTypekernel function (see below)n/a
regularization_param (C)f64trades margin width against training errormust be positive and finite
tolf64KKT stopping tolerance for SMOmust be positive and finite
max_iterusizeiteration cap for the SMO outer loopmust be non-zero

C is the regularization knob. It sets the upper bound on each dual coefficient, 0 <= alpha <= C. A large C lets the solver push the alphas high, fitting every training point with a narrow margin that tolerates few violations. A small C keeps the alphas small, widening the margin and accepting more slack. This is the opposite of the lambda intuition from linear models: a larger C means less regularization. Any non-positive or non-finite value fails at construction with Error::InvalidParameter.

SVC::default() gives an RBF kernel with gamma = 0.1, C = 1.0, tol = 0.001, and max_iter = 1000. This is a reasonable start when you have no prior information, though gamma almost always needs tuning.

Training runs Sequential Minimal Optimization (SMO). It repeatedly picks a pair of dual variables that violate the Karush-Kuhn-Tucker (KKT) conditions. It optimizes that pair analytically, holding the rest fixed. This repeats until the whole set satisfies the KKT conditions within tol, or until training hits the iteration cap.

The dual problem uses +1/-1 targets, because the y_i * y_j products in its objective only make sense with a sign. fit converts your {0.0, 1.0} column to +1/-1 once, on entry, and keeps that signed form internal. predict maps the sign back to {0.0, 1.0}. You never pass +1/-1 labels in, and, with one exception noted below, you never get them out either.

The Gram matrix and the initial error cache can build in parallel. The SMO inner loop stays sequential on purpose, so the optimization path stays reproducible for a given seed.

This example runs the whole construct-fit-predict loop with a linear kernel, on data a straight line separates cleanly:

use rustyml::machine_learning::{KernelType, SVC};
use ndarray::array;

fn main() {
    // Class 1 sits upper-right. Class 0 sits lower-left. A line separates them.
    let x = array![
        [2.0, 2.0], [3.0, 2.0], [2.0, 3.0], [3.0, 3.0],
        [-2.0, -2.0], [-3.0, -2.0], [-2.0, -3.0], [-3.0, -3.0],
    ];
    let y = array![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];

    let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, 1000)
        .unwrap()
        .with_random_state(42);
    svc.fit(&x, &y).unwrap();

    // predict emits labels in {0.0, 1.0}, the same domain fit received
    let preds = svc.predict(&x).unwrap();
    println!("predictions: {:?}", preds);

    // decision_function returns the raw signed distance to the hyperplane
    let scores = svc.decision_function(&x).unwrap();
    println!("scores: {:?}", scores);
}

predict sets the threshold at zero. It maps >= 0.0 to 1.0 and everything else to 0.0. decision_function hands back the raw signed score. A positive score means the class-1.0 side, and its size shows how far the point sits from the boundary. Use decision_function when you want a confidence-like ordering instead of a hard label. There is also fit_predict, which fits and then predicts on the same matrix in 1 call.

The kernel zoo and the gamma coefficient

KernelType is the same enum that Kernel PCA uses, so learning it here pays off twice. It has 5 variants.

VariantK(x, y)Fields
Linearx*ynone
Poly { degree, gamma, coef0 }(gamma*x*y + coef0)^degreedegree: u32, gamma: Gamma, coef0: f64
RBF { gamma }exp(-gamma*||x-y||^2)gamma: Gamma
Sigmoid { gamma, coef0 }tanh(gamma*x*y + coef0)gamma: Gamma, coef0: f64
Cosine(x*y) / (||x|| * ||y||)none

RBF is the default kernel and the one to try first for nonlinear data. It is a smooth, local similarity measure that only needs gamma tuned. Poly adds explicit interaction terms up to degree, but high degrees overflow quickly. A degree-400 polynomial on modestly large inputs pushes the decision value to infinity, which surfaces as Error::NonFinite at predict time.

Sigmoid mimics a two-layer network, but it is not positive-definite for all parameters, so it can behave erratically. Cosine measures angle rather than distance. It guards against zero vectors by returning 0.0 for them.

The gamma field is not a bare f64. It is a Gamma enum with 3 variants, matching scikit-learn’s 'scale', 'auto', and explicit choices:

  • Gamma::Value(v): an explicit coefficient you supply.
  • Gamma::Scale: resolved at fit time to 1 / (n_features * X.var()), where X.var() is the population variance of all entries of the training matrix.
  • Gamma::Auto: resolved at fit time to 1 / n_features.

Scale and Auto are data-dependent, so they stay placeholders until fit sees the data. fit resolves them once and stores the concrete value. So a call to get_kernel() after fit returns a KernelType whose gamma is now a Gamma::Value. Scale fails with Error::InvalidInput if the data has zero variance, for example when every feature is constant, because the formula would divide by zero.

gamma controls how far a single training point’s influence reaches. A large gamma makes the RBF bumps tight and the boundary wiggly, a fast route to overfitting. A small gamma makes the bumps broad and the boundary smooth. It is the parameter you will tune most.

RBF on a problem no line can split

A kernel exists to separate data that a hyperplane cannot. Two concentric rings are the canonical example: an inner ring of one class, surrounded by an outer ring of the other. No straight line divides them, and a linear kernel is helpless here. The RBF kernel, which scores points by proximity, classifies every one correctly.

use rustyml::machine_learning::{Gamma, KernelType, SVC};
use ndarray::array;

fn main() {
    // Inner ring (radius 1) is class 1, outer ring (radius 5) is class 0.
    let x = array![
        [1.0, 0.0], [-1.0, 0.0], [0.0, 1.0], [0.0, -1.0],
        [5.0, 0.0], [-5.0, 0.0], [0.0, 5.0], [0.0, -5.0],
    ];
    let y = array![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];

    let mut svc = SVC::new(
        KernelType::RBF { gamma: Gamma::Value(0.5) },
        10.0,   // C
        1e-3,   // tol
        5000,   // max_iter, nonlinear problems need more SMO passes
    )
    .unwrap()
    .with_random_state(42);
    svc.fit(&x, &y).unwrap();

    let preds = svc.predict(&x).unwrap();
    let correct = preds.iter().zip(y.iter()).filter(|&(p, t)| p == t).count();
    println!("RBF accuracy on rings: {}/{}", correct, y.len());
}

Swap KernelType::RBF { .. } for KernelType::Linear on this dataset, and the classifier can no longer place every point on the right side. The rings are not linearly separable. That is exactly the failure mode a kernel exists to fix. Nonlinear problems also generally need a higher max_iter than linear ones, because the SMO solver has more support vectors to settle.

Inspecting the fitted model

After fit, SVC exposes the pieces of the trained model through getters. This is more than most estimators in this guide offer. The support vectors are the only training rows that matter to prediction, the ones whose dual coefficient came out non-zero. You can read them back directly:

use rustyml::machine_learning::{Gamma, KernelType, SVC};
use ndarray::array;

fn main() {
    let x = array![
        [2.0, 2.0], [3.0, 2.0], [2.0, 3.0], [3.0, 3.0],
        [-2.0, -2.0], [-3.0, -2.0], [-2.0, -3.0], [-3.0, -3.0],
    ];
    let y = array![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];

    let mut svc = SVC::new(
        KernelType::RBF { gamma: Gamma::Value(0.5) },
        5.0, 1e-3, 1000,
    )
    .unwrap()
    .with_random_state(42);
    svc.fit(&x, &y).unwrap();

    // Only rows with a non-zero alpha survive as support vectors.
    let support_vectors = svc.get_support_vectors().unwrap();
    let alphas = svc.get_alphas().unwrap();
    let labels = svc.get_support_vector_labels().unwrap();

    println!(
        "kept {} of {} rows as support vectors",
        support_vectors.nrows(),
        x.nrows()
    );
    println!("dual coefficients (alphas): {:?}", alphas);
    println!("support-vector labels (SMO's internal +/-1): {:?}", labels);
    println!("bias: {:?}", svc.get_bias());
    println!("SMO iterations actually run: {:?}", svc.get_actual_iterations());
    println!("resolved kernel: {:?}", svc.get_kernel());
}

get_support_vectors returns Option<&Array2<f64>>. get_alphas and get_support_vector_labels return Option<&Array1<f64>>. get_bias returns Option<f64>. All 4 are None before fit and Some after.

get_support_vector_labels is the one crack in the wall around the dual. It hands back the internal +1/-1 encoding, not the {0.0, 1.0} you handed to fit. So a -1.0 in that array denotes class 0.0. The label stays signed because predict folds it straight into the alpha_i * y_i coefficients. Threshold it yourself if you want the caller’s domain back.

get_actual_iterations reports how many SMO outer passes actually ran, always a number in [1, max_iter]. That count tells you whether the solver converged or hit the cap. A count equal to max_iter is a hint to raise the cap or loosen tol. get_kernel returns the resolved kernel. That is how you read back the concrete gamma that Gamma::Scale or Gamma::Auto produced.

Training can find no support vectors at all, for instance with single-class data where there is nothing to separate. In that case, fit returns Error::NotConverged instead of a degenerate all-zero model.

2.5.3. LinearSVC: hinge loss in the primal

LinearSVC solves the primal problem directly with minibatch stochastic gradient descent, minimizing hinge loss plus a regularization penalty. Construct it with LinearSVC::new(max_iter, learning_rate, penalty, fit_intercept, tol).

ParameterTypeMeaningValidation
max_iterusizemaximum epochsmust be non-zero
learning_ratef64SGD step sizemust be positive and finite
penaltyRegularizationTypeL1(lambda) or L2(lambda)lambda must be non-negative and finite
fit_interceptboolwhether to learn a bias termn/a
tolf64convergence tolerance on parameter changemust be positive and finite

The labels are the same 0.0/1.0 that SVC takes. The story underneath is the same too: hinge loss needs signed targets, so fit remaps them to -1/+1 on entry. The difference is that nothing on LinearSVC’s public surface ever leaks that encoding back. The fitted state is a weight vector and a bias, and both live in feature space, not label space.

use rustyml::machine_learning::{LinearSVC, RegularizationType};
use ndarray::array;

fn main() {
    // The same 0.0 / 1.0 labels SVC takes. No remapping between the two.
    let x = array![
        [-5.0, 0.0], [-6.0, 0.0], [-7.0, 0.0], [-4.0, 0.0],
        [5.0, 0.0], [6.0, 0.0], [7.0, 0.0], [4.0, 0.0],
    ];
    let y = array![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];

    let mut model = LinearSVC::new(
        5000,                        // max_iter
        0.01,                        // learning_rate
        RegularizationType::L2(0.1), // penalty
        true,                        // fit_intercept
        1e-5,                        // tol
    )
    .unwrap()
    .with_random_state(42);
    model.fit(&x, &y).unwrap();

    let preds = model.predict(&x).unwrap();
    let scores = model.decision_function(&x).unwrap();
    println!("labels:  {:?}", preds);
    println!("scores:  {:?}", scores);
    println!("weights: {:?}", model.get_weights().unwrap());
    println!("bias:    {:?}", model.get_bias().unwrap());
    println!("stopped after {:?} epochs", model.get_actual_iterations());
}

fit shapes training with 2 details. It picks the minibatch size automatically, as clamp(n_samples / 10, 32, 512). You do not set that size. Each epoch shuffles the sample order before it slices the data into minibatches. This is why the seed matters (see below).

fit checks convergence on the root-mean-square change in the weights and bias between epochs. Training stops early once that change drops below tol. The default Loss::Hinge keeps a constant-size gradient near the margin, so a fixed learning rate often leaves the weights oscillating instead of settling. The example above shows this: it reaches the full 5000-epoch cap instead of stopping early. Loss::SquaredHinge has a gradient that shrinks to zero near the margin. Training with that loss usually stops well under max_iter, as the example below shows.

Left unchecked, a runaway learning_rate can push the weights to non-finite values. fit catches that and reports Error::NonFinite mid-training instead of returning garbage. Use a smaller learning_rate or stronger regularization to fix it.

Watch one threshold difference from SVC. LinearSVC::predict maps a decision value > 0.0 to class 1.0, and everything else, including exactly 0.0, to class 0.0. SVC maps >= 0.0 to 1.0 instead. Because both models share the same label domain, this is a real behavioral difference, not a bookkeeping detail. The 2 models break a zero score toward opposite classes.

decision_function here returns x * weights + bias. With fit_intercept = false, the bias stays exactly 0.0, and the score is a pure dot product.

Penalty and loss

RegularizationType is L1(lambda) or L2(lambda). L2, or ridge, applies a weights *= (1 - learning_rate * lambda) shrink at each step. This keeps all weights small but non-zero. L1, or lasso, applies a constant, sign-based subgradient pull that drives the weights of irrelevant features toward zero. Unlike LinearRegression and LogisticRegression, LinearSVC applies this pull as a plain subgradient step, not a proximal (soft-thresholding) step. So a weight rarely lands on exactly 0.0, though it still lands close to it.

If a feature carries no signal, a strong L1 penalty shrinks its weight closer to zero than it shrinks an informative feature’s weight. This helps when you suspect that many columns are noise. lambda = 0.0 is legal. It disables the penalty entirely.

2 builder methods extend the estimator past what new sets. with_loss switches between Loss::Hinge, the default, max(0, 1 - y*f(x)), and Loss::SquaredHinge, max(0, 1 - y*f(x))^2. Loss::SquaredHinge penalizes margin violations quadratically and is differentiable everywhere. That smoother objective lets some datasets converge more cleanly.

with_learning_rate_decay turns on an inverse-scaling schedule, where the effective rate at epoch t is learning_rate / (1 + decay * t). This lets SGD settle closer to the optimum, instead of hovering a fixed step away from it. It returns a Result, because the decay must be non-negative and finite.

use rustyml::machine_learning::{LinearSVC, Loss, RegularizationType};
use ndarray::array;

fn main() {
    let x = array![
        [-5.0, 0.0], [-6.0, 0.0], [-7.0, 0.0], [-4.0, 0.0],
        [5.0, 0.0], [6.0, 0.0], [7.0, 0.0], [4.0, 0.0],
    ];
    let y = array![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];

    let mut model = LinearSVC::new(10_000, 0.01, RegularizationType::L1(0.5), true, 1e-6)
        .unwrap()
        .with_loss(Loss::SquaredHinge)
        .with_learning_rate_decay(0.001)  // returns Result and checks the decay
        .unwrap()
        .with_random_state(0);
    model.fit(&x, &y).unwrap();

    println!("penalty: {:?}", model.get_penalty());
    println!("loss:    {:?}", model.get_loss());
    println!("preds:   {:?}", model.predict(&x).unwrap());
}

The getters mirror SVC’s: get_weights (Option<&Array1<f64>>), get_bias, get_penalty, get_loss, get_learning_rate, get_learning_rate_decay, get_tolerance, get_max_iterations, get_actual_iterations, and get_random_state. The weight vector length always matches n_features. So get_weights doubles as a feature-importance readout, once your columns share a common scale. LinearSVC is the linear-classifier cousin of Logistic Regression. The difference is the loss: hinge instead of log-loss. Hinge cares only about points near or across the margin, so it tends to give a boundary that ignores confidently-correct points entirely.

2.5.4. Reproducibility, persistence, and errors

Both estimators are non-deterministic by default. Both take a fixed seed through with_random_state(u64). For SVC, the seed drives the SMO working-set fallback, the randomized offset used when scanning for a second alpha to optimize. The same seed reproduces the same support vectors, bias, and predictions, bit for bit.

For LinearSVC, the seed drives the per-epoch minibatch shuffle. That fixes the exact sequence of gradient steps, and so the final weights too. Different seeds can land on different solutions, especially for LinearSVC on data where the shuffle order matters. Set the seed whenever you need runs to be comparable. See Reproducibility and Random Seeds for how this fits the crate-wide seeding story.

use rustyml::machine_learning::{Gamma, KernelType, SVC};
use ndarray::array;

fn main() {
    let x = array![
        [2.0, 2.0], [3.0, 2.0], [2.0, 3.0], [3.0, 3.0],
        [-2.0, -2.0], [-3.0, -2.0], [-2.0, -3.0], [-3.0, -3.0],
    ];
    let y = array![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];

    let train = || {
        let mut svc = SVC::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 5.0, 1e-3, 1000)
            .unwrap()
            .with_random_state(42);
        svc.fit(&x, &y).unwrap();
        svc.predict(&x).unwrap()
    };

    // Same seed, same data -> identical predictions.
    assert_eq!(train(), train());
    println!("same seed reproduces the model exactly");
}

Both models implement save_to_path and load_from_path. These methods serialize the entire fitted state to a compact postcard binary blob. That state is the support vectors and alphas for SVC, or the weights and bias for LinearSVC, plus every hyperparameter.

You can choose any file extension. The format stays binary regardless of the name. A round-trip reproduces predictions and decision scores exactly. This is the shallow end of Model Persistence in Depth.

use rustyml::machine_learning::{Gamma, KernelType, SVC};
use ndarray::array;
use std::fs::remove_file;

fn main() {
    let x = array![
        [2.0, 2.0], [3.0, 2.0], [2.0, 3.0], [3.0, 3.0],
        [-2.0, -2.0], [-3.0, -2.0], [-2.0, -3.0], [-3.0, -3.0],
    ];
    let y = array![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];

    let mut svc = SVC::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 5.0, 1e-3, 1000)
        .unwrap()
        .with_random_state(42);
    svc.fit(&x, &y).unwrap();

    svc.save_to_path("svc_model.bin").unwrap();      // postcard binary
    let loaded = SVC::load_from_path("svc_model.bin").unwrap();

    assert_eq!(svc.predict(&x).unwrap(), loaded.predict(&x).unwrap());
    println!("loaded model reproduces the original's predictions");

    remove_file("svc_model.bin").unwrap();
}

Every fallible entry point returns the crate’s Error (see Error Handling). The variants you will meet in practice:

WhenErrorTrigger
newError::InvalidParameternon-positive C/learning_rate/tol, zero max_iter, negative lambda, non-finite anything
fitError::InvalidInputa label outside {0.0, 1.0} (same rule for both models), or Gamma::Scale on zero-variance data
fitError::EmptyInput / Error::DimensionMismatchempty matrix, or y.len() != x.nrows()
fitError::NotConvergedSVC found no support vectors, for example with single-class data
fitError::NonFinitekernel matrix or weights went non-finite during training
predict / decision_functionError::NotFittedcalled before fit
predict / decision_functionError::DimensionMismatchinput feature count != training feature count
predict / decision_functionError::NonFinitea decision value overflowed, for example with a high-degree Poly kernel
load_from_pathError::Iomissing file or corrupt/incompatible bytes

The label domain still trips people, just not in the direction the SMO literature suggests. Both models want 0.0/1.0, so a y copied from a textbook derivation or from another library’s signed convention causes an InvalidInput at fit. Neither model converts it silently. Encode your labels into {0.0, 1.0} before training. After that, the only +1/-1 values you will meet are whatever get_support_vector_labels hands back.

2.6. Linear Discriminant Analysis

Linear Discriminant Analysis (LDA) does 2 jobs from a single fit. First, it works as a generative classifier. It models each class as a Gaussian with its own mean and a covariance shared across all classes. It then applies Bayes’ rule to pick the most probable label. Second, it works as a supervised dimensionality reducer. It projects the features onto the directions that separate the classes best. RustyML’s LDA gives both results from one fit call. predict, decision_function, and predict_proba serve the classifier. transform and fit_transform serve the projection. This page covers the API and the in-house linear algebra behind it. It also covers the Gaussian assumptions the model relies on, and what happens when the covariance matrix goes singular.

2.6.1. What the crate implements

RustyML implements LDA under rustyml::machine_learning::{LDA, Shrinkage, DiscriminantSolver}. The prelude also re-exports these types (see the prelude). The model takes an f64 feature matrix and i32 labels. LDA is a classifier, so labels are integer class ids, not floating-point values. The table below lists the API surface:

MethodSignature (abridged)Returns
LDA::newnew(n_components: usize)Result<LDA, Error>
LDA::defaultdefault()LDA (auto components)
with_solverwith_solver(DiscriminantSolver)LDA
with_shrinkagewith_shrinkage(Shrinkage)Result<LDA, Error>
fitfit(&x, &y) where y: &Array1<i32>Result<&mut LDA, Error>
predictpredict(&x)Result<Array1<i32>, Error>
decision_functiondecision_function(&x)Result<Array2<f64>, Error>
predict_probapredict_proba(&x)Result<Array2<f64>, Error>
transformtransform(&x)Result<Array2<f64>, Error>
fit_transformfit_transform(&x, &y)Result<Array2<f64>, Error>

The crate does not implement QDA (quadratic discriminant analysis). It offers only the shared-covariance, linear-boundary variant. The getters get_classes, get_priors, get_means, get_overall_mean, and get_projection return None before fit runs. After fit runs, each getter returns its value wrapped in Some. Checking any of these getters tells you whether the model is trained.

The solver enum used to carry the plain name Solver. That name collided with the linear models’ own Solver enum. After a prelude glob import, Solver::GradientDescent resolved to the wrong enum and failed to compile. Both enums now carry names that match what they select. This crate uses DiscriminantSolver here and LeastSquaresSolver in the linear models.

2.6.2. A first classifier

Start with 3 well-separated clusters in 2D. LDA::default() resolves the component count automatically. You do not need to set a component count when you only want labels.

use ndarray::array;
use rustyml::machine_learning::LDA;

fn main() {
    // 3 tight, well-separated clusters, 3 samples each.
    let x = array![
        [1.0, 1.0], [1.5, 0.8], [0.8, 1.2],
        [5.0, 5.0], [5.2, 4.8], [4.8, 5.2],
        [9.0, 1.0], [9.2, 0.8], [8.8, 1.2],
    ];
    let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];

    let mut lda = LDA::default();
    lda.fit(&x, &y).unwrap();

    let preds = lda.predict(&x).unwrap();
    println!("predictions: {:?}", preds);

    // Per-class discriminant scores (n_samples, n_classes) and softmax posteriors.
    let scores = lda.decision_function(&x).unwrap();
    let proba = lda.predict_proba(&x).unwrap();
    println!("scores shape:   {:?}", scores.shape());
    println!("proba shape:    {:?}", proba.shape());
    println!("class order:    {:?}", lda.get_classes().unwrap());
}

predict, decision_function, and predict_proba share a single core computation. For a sample x, LDA computes a linear discriminant score for each class. The formula is score_j(x) = x * Sigma^-1 * mu_j - 0.5 * mu_j * Sigma^-1 * mu_j + ln(prior_j). Here Sigma is the shared covariance, mu_j is the class mean, and prior_j is the class frequency in the training set. decision_function returns these raw scores as an (n_samples, n_classes) matrix. predict takes the per-row argmax of the scores and maps it back to the original label. predict_proba applies a numerically stable row-wise softmax to the same scores, so each row sums to 1. The column order of decision_function and predict_proba follows get_classes(). get_classes() sorts the labels in ascending order. Do not assume this order matches the order labels first appeared in y.

The priors are pure class frequencies (n_class / n_samples). For this balanced set, each prior is 1/3. Class imbalance changes this. The ln(prior_j) term shifts the boundary toward the rarer class, the way a Bayes classifier should. To get equal priors, balance the training set. The LDA constructor has no priors= override.

2.6.3. Supervised projection and the n_classes - 1 ceiling

The projection side is what distinguishes LDA from PCA. LDA finds directions that maximize between-class scatter relative to within-class scatter. Only n_classes - 1 such directions carry non-zero separability, because the between-class scatter matrix has rank at most n_classes - 1. The crate enforces this limit. The usable component count is min(n_classes - 1, n_features), resolved at fit time. LDA::default() (or n_components = None) takes that maximum. LDA::new(k) requests exactly k components and fails at fit if k exceeds the cap.

A 2D scatter plot therefore needs at least 3 classes. 2 classes give only 1 discriminant axis. fit_transform trains the model and projects the data in a single call:

use ndarray::array;
use rustyml::machine_learning::{DiscriminantSolver, LDA, Shrinkage};

fn main() {
    let x = array![
        [1.0, 1.0], [1.5, 0.8], [0.8, 1.2],
        [5.0, 5.0], [5.2, 4.8], [4.8, 5.2],
        [9.0, 1.0], [9.2, 0.8], [8.8, 1.2],
    ];
    let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];

    // 3 classes -> at most 2 discriminant axes. Project straight to 2D.
    let mut lda = LDA::new(2)
        .unwrap()
        .with_solver(DiscriminantSolver::Eigen)
        .with_shrinkage(Shrinkage::Auto)
        .unwrap();

    let coords = lda.fit_transform(&x, &y).unwrap(); // (9, 2)
    println!("embedding shape: {:?}", coords.shape());
    for (row, &label) in coords.outer_iter().zip(y.iter()) {
        println!("class {label}: ({:.3}, {:.3})", row[0], row[1]);
    }

    // The projection matrix maps n_features -> n_components.
    let w = lda.get_projection().unwrap();
    println!("projection shape: {:?}", w.shape()); // [2, 2]
}
embedding shape: [9, 2]
... one line per sample: class <label>: (<axis 1>, <axis 2>) ...
projection shape: [2, 2]

The projection matrix W has shape (n_features, n_components). transform computes (x - xbar) * W. Here xbar is the mean of the whole training matrix. This value matches scikit-learn’s xbar_, and get_overall_mean() reads it back. RustyML matches scikit-learn’s (X - xbar_) @ scalings_ formula exactly. That match brings 2 details, and both changed recently. First, the projection is centered by the training mean. A transformed training set sits around the origin, not around the raw feature values. Second, the axes keep the scale the whitening produced. RustyML does not renormalize the axes to unit L2 norm. That scale is not cosmetic. It gives the projected data unit within-class covariance, the property the whole construction targets. A projection read from an older, unit-norm axis now shows a different offset and a different scale.

RustyML orders the columns by class separability, with the largest generalized eigenvalue first. The first component is therefore the single most discriminative axis. This ordering helps if you later want a 1D reduction from the same fit. A discriminant axis and its negation separate the classes equally well. The sign of any column is therefore arbitrary. Treat the orientation of a plotted axis as meaningless. Do not read directionality into it.

transform and fit_transform perform the same reduction step. fit_transform(&x, &y) runs fit(&x, &y) followed by transform(&x) in a single call.

2.6.4. Solvers and the in-house linear algebra

DiscriminantSolver selects how RustyML inverts the shared covariance for the classifier’s scoring coefficients Sigma^-1 * mu_c. This is an important and easy-to-miss fact. The solver choice only affects predict, decision_function, and predict_proba. The transform projection does not depend on the solver. RustyML always derives it from a symmetric eigendecomposition of the within-class covariance. Switching solvers changes the decision boundary’s numerics. It never changes the 2D embedding.

DiscriminantSolverHow it scoresWhen to use it
SVD (default)SVD pseudo-inverse of Sigma, small singular values truncatedGeneral default. Handles rank deficiency well
EigenSymmetric eigendecomposition inverse, tiny eigenvalues zeroedWell-conditioned covariance. Comparable to SVD
LSQRIterative Paige-Saunders least squares, forms no explicit inverseVery high dimension, where an explicit inverse wastes memory

All 3 solvers reach identical labels on well-separated data. They differ only in how they handle an ill-conditioned Sigma. SVD and Eigen both build a pseudo-inverse. This pseudo-inverse discards any direction whose singular value or eigenvalue falls below a relative tolerance. A rank-deficient covariance therefore degrades gracefully instead of failing. LSQR avoids the inverse entirely. It solves each Sigma * coef_c = mu_c system iteratively. This choice saves memory when n_features is large.

The linear algebra is entirely in-house pure Rust. RustyML carries no LAPACK or nalgebra dependency (see prefer in-house numerics). The symmetric eigendecomposition uses Householder tridiagonalization followed by the implicit-shift QL iteration, the classic EISPACK tred2 and tql2 pair. The SVD uses a one-sided Jacobi method that computes small singular values to high relative accuracy. LSQR uses the Golub-Kahan bidiagonalization with running Givens rotations. Each method is deterministic to machine precision on the small-to-mid covariance matrices LDA produces. That determinism is why LDA needs no random seed.

2.6.5. Shrinkage and covariance regularization

With few samples per feature, the raw sample covariance is a noisy estimate. Its inverse amplifies that noise. Shrinkage pulls Sigma toward a scaled identity matrix. This trades a small amount of bias for a large reduction in variance. Shrinkage has 2 forms:

  • Shrinkage::Auto: the Ledoit-Wolf closed-form optimal intensity. RustyML computes it from the data, with no tuning knob to set.
  • Shrinkage::Manual(alpha): an explicit alpha in [0, 1]. A value of 0 applies no shrinkage. A value of 1 shrinks fully to the identity target. with_shrinkage validates alpha and returns Error::InvalidParameter if it falls outside [0, 1] or is not finite. This validation is why with_shrinkage returns a Result.

2 subtleties matter here. First, RustyML always adds a tiny fixed diagonal ridge to the covariance, about 1e-6 times the average variance. It adds this ridge regardless of the shrinkage choice, only to keep the factorizations stable. Shrinkage::Manual(0.0) and no shrinkage therefore produce identical results. Second, shrinkage here is independent of the solver, and this is a real divergence from scikit-learn. scikit-learn refuses shrinkage with its svd solver. RustyML applies shrinkage to the covariance before any solver runs. DiscriminantSolver::SVD with Shrinkage::Auto is therefore valid, and often the best combination. Choose Shrinkage::Auto as the default whenever you have collinear features or a thin sample-to-feature ratio.

2.6.6. Assumptions, and the symptoms when they break

LDA’s optimality rests on 2 assumptions. Each class follows a Gaussian distribution. All classes share one covariance matrix. The shared covariance is what makes the decision boundary linear, because the quadratic terms cancel. When these assumptions hold, LDA is data-efficient. It estimates a single covariance from all classes pooled together, instead of a separate covariance per class.

When the assumptions break, the symptoms are specific. Say the classes have genuinely different covariances, a condition called heteroscedastic data. One class forms a tight blob, and another forms a broad cloud. The true Bayes boundary is then quadratic. LDA’s forced-linear boundary systematically misclassifies samples in the region where the spreads differ. The wide class tends to dominate territory near the boundary. The crate has no QDA as an alternative. For strongly heteroscedastic data, use a non-linear classifier instead, such as a decision tree, an SVM with an RBF kernel, or a KNN classifier. A class that is multimodal or clearly non-Gaussian can also break the projection, even when the classifier still produces usable labels. Check a transform scatter plot before you trust the reduction.

2.6.7. Singular covariance and high dimensions

This section covers what happens when features are collinear, or when n_features exceeds n_samples, so the covariance becomes singular. RustyML does not raise a dedicated singular-matrix error. The crate places no guard on n_features against n_samples. The only sample-count checks are that n_samples must exceed n_classes, and each class needs at least 2 samples. A rank-deficient covariance gets absorbed in 2 ways. The always-on diagonal ridge nudges the covariance toward invertibility. The SVD and Eigen pseudo-inverse, and the whitening step inside the projection, zero out any direction whose eigenvalue falls below a relative tolerance. This projects onto the covariance’s non-null subspace instead of dividing by a value near 0.

use ndarray::array;
use rustyml::machine_learning::LDA;

fn main() {
    // The third column equals the sum of the first two, so the feature
    // matrix is rank-deficient. The shared covariance is therefore
    // singular.
    let x = array![
        [1.0, 2.0, 3.0],
        [1.5, 2.5, 4.0],
        [2.0, 3.0, 5.0],
        [6.0, 5.0, 11.0],
        [6.5, 4.5, 11.0],
        [7.0, 5.5, 12.5],
    ];
    let y = array![0, 0, 0, 1, 1, 1];

    // No singular-matrix error: the ridge and pseudo-inverse absorb the
    // rank deficiency.
    let mut lda = LDA::default();
    lda.fit(&x, &y).unwrap();

    let coords = lda.transform(&x).unwrap(); // (6, 1): 2 classes -> 1 component
    assert!(coords.iter().all(|v| v.is_finite()));
    println!("reduced to {:?}, all finite", coords.shape());
}

The projection has 1 remaining failure mode: Error::Computation, with context set to “Discriminant direction norm too small for stable projection”. RustyML raises this error only when a selected discriminant axis collapses to a near-zero vector, relative to the largest axis. The guard became relative once RustyML stopped renormalizing the axes to unit length. This failure happens when you request more components than the data can support in its degenerate subspace. Treat it as a hard numerical failure, not a validation slip. Reduce n_components, or regularize harder, to fix it. For n_features greater than n_samples, or for heavily collinear data, do not rely on the fixed 1e-6 ridge alone. Turn on Shrinkage::Auto instead. It conditions the covariance far better than the minimal stabilizer, and it makes the difference between a usable projection and one dominated by noise directions.

2.6.8. LDA versus PCA versus logistic regression

3 related methods are worth a direct comparison, because choosing among them is usually the real question.

Supervised?Boundary / axesAssumptionsComponent ceiling
LDAYes (uses labels)Directions of max class separationGaussian classes, shared covariancen_classes - 1
PCANo (ignores labels)Directions of max total varianceNone (just second moments)n_features
Logistic regressionYesLinear decision boundary onlyNone on class shape(not a reducer)

PCA maximizes variance without ever looking at y. Its top components can therefore align with the direction that separates classes least, if that is where the spread lives. LDA maximizes separation directly, but it is capped at n_classes - 1 axes. Use LDA for a supervised low-dimensional view, or for a fast generative classifier, when the classes are roughly Gaussian. Use PCA for unsupervised compression, or when you need more than n_classes - 1 dimensions.

Against logistic regression, the split is generative versus discriminative. LDA models the class-conditional densities and applies Bayes’ rule. Logistic regression models P(y | x) directly and makes no assumption about the shape of each class. When the Gaussian and equal-covariance assumptions genuinely hold, LDA converges to a good boundary from fewer samples. When they do not hold, logistic regression is the more dependable linear classifier, because it never assumed a class shape. A common approach keeps both models and lets held-out accuracy decide, measured with the classification metrics.

2.6.9. Errors, persistence, and reproducibility

The error surface follows the crate-wide error handling conventions. LDA::new(0) returns Error::InvalidParameter, because components must be positive. An out-of-range Shrinkage::Manual also returns InvalidParameter, caught at build time. Structural problems surface at fit. An empty feature matrix, or a matrix with 0 feature columns, returns Error::EmptyInput. Fewer than 2 classes, n_samples not greater than n_classes, a class with only 1 sample, and an over-large n_components all return Error::InvalidInput. A non-finite entry in x returns Error::NonFinite. An x/y length mismatch returns Error::DimensionMismatch. At predict or transform time, an empty input matrix also returns Error::EmptyInput, a wrong feature count returns Error::DimensionMismatch, and calling an unfitted model returns Error::NotFitted("LDA").

use ndarray::array;
use rustyml::error::Error;
use rustyml::machine_learning::{LDA, Shrinkage};

fn main() {
    // Manual shrinkage must lie in [0, 1]. RustyML rejects 1.5 when you
    // build the model.
    match LDA::new(1).unwrap().with_shrinkage(Shrinkage::Manual(1.5)) {
        Err(Error::InvalidParameter { name, .. }) => println!("rejected parameter: {name}"),
        _ => unreachable!(),
    }

    // n_components is only bounded once the class count is known, so an over-large
    // request survives the constructor and fails at fit.
    let x = array![
        [1.0, 1.0], [1.5, 0.8], [0.8, 1.2],
        [5.0, 5.0], [5.2, 4.8], [4.8, 5.2],
    ];
    let y = array![0, 0, 0, 1, 1, 1]; // 2 classes -> max_components = 1
    let mut lda = LDA::new(2).unwrap();
    match lda.fit(&x, &y) {
        Err(Error::InvalidInput(msg)) => println!("fit rejected: {msg}"),
        _ => unreachable!(),
    }
}

A fitted LDA serializes with save_to_path and load_from_path. Both methods use the postcard binary format. The .bin extension is only a filename convention. The format stays binary regardless of the extension you choose. The round trip restores the classes, the priors, the means, the overall training mean, the projection, and the cached scoring coefficients. A loaded model predicts and transforms identically, with no re-fit needed. The overall mean is a field the format gained when transform started centering the projection. Blobs saved by an older version of RustyML will not load. Re-fit and re-save the model with the current version. See model persistence in depth for the format details.

use ndarray::array;
use rustyml::machine_learning::LDA;
use std::fs;

fn main() {
    let x = array![
        [1.0, 1.0], [1.5, 0.8], [0.8, 1.2],
        [5.0, 5.0], [5.2, 4.8], [4.8, 5.2],
        [9.0, 1.0], [9.2, 0.8], [8.8, 1.2],
    ];
    let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];

    let mut lda = LDA::new(2).unwrap();
    lda.fit(&x, &y).unwrap();
    let before = lda.predict(&x).unwrap();

    let path = "lda_model.bin";
    lda.save_to_path(path).unwrap();
    let loaded = LDA::load_from_path(path).unwrap();
    let after = loaded.predict(&x).unwrap();

    assert_eq!(before, after);
    fs::remove_file(path).unwrap();
    println!("round-trip predictions identical");
}

LDA is fully deterministic. It draws no random numbers, so the same data yields the same projection, the same coefficients, and the same labels on every run. It needs no seed to set, unlike the stochastic models in reproducibility and random seeds. The only run-to-run freedom is the arbitrary sign of each discriminant axis. That sign is a mathematical property of eigenvectors, not a sign of nondeterminism. Fitting parallelizes the per-class statistics and the final label scan, once the work clears the crate’s calibrated threshold. Parallelism never changes the result. It only changes how fast the result arrives. See performance tuning and parallelism for more on this topic.

2.7. KMeans Clustering

KMeans partitions samples into a fixed number of clusters. It alternates between 2 steps: it assigns each point to its nearest centroid, then it recomputes each centroid as the mean of its members. RustyML’s implementation is a parallel Lloyd’s algorithm with k-means++ seeding and best-of-n restarts. If you know scikit-learn’s sklearn.cluster.KMeans, most of that mental model applies here too. The one default that differs on purpose is n_init. See 2.7.1 for the reason.

2.7.1. How the estimator works

One call to fit runs the whole procedure n_init times. It keeps the run with the lowest inertia. Within a run, centroids start from k-means++ seeding. The first center is a uniformly random sample.

Each later center is drawn from the remaining points. The probability is proportional to the squared distance to the nearest center already chosen (the classic D^2 roulette wheel). This spreads the initial centers out, and it gives Lloyd’s iteration a better starting point than uniform random seeding. If every candidate’s squared distance is zero, the implementation picks that center uniformly at random instead. This case happens only when all points duplicate the already-chosen centers.

After seeding, each iteration does 2 things. The assignment step assigns every point to the nearest centroid. The update step replaces each centroid with the mean of its assigned points.

Iteration stops when the centroids stop moving. The convergence test compares the summed squared shift of all centroids against a variance-scaled tolerance. The threshold equals the mean per-feature population variance of the data, multiplied by tol. So tol is a relative tolerance, not an absolute distance. This matches the convention scikit-learn uses. A converged solution is a fixed point: every centroid equals the mean of its assigned points.

A fit that runs out of budget, instead of converging, gets one extra assignment pass before it returns. The Lloyd loop labels points against the current centroids, then installs the updated centroids. So stopping at max_iterations would leave labels and inertia describing the old centroids, while get_centroids reports the new ones. predict(x) would then disagree with get_labels(). The final pass re-assigns points against the centroids the model actually stores. scikit-learn re-runs its final E-step for the same reason.

The one deliberate difference from scikit-learn is the restart count. n_init defaults to 10 here. scikit-learn’s n_init='auto' is 1 for k-means++. This is not an oversight. scikit-learn’s default rests on its greedy k-means++, which draws 2 + ln(k) candidates per center and keeps the best. So one seeding already has low variance there.

RustyML uses plain k-means++: a single D^2 draw per center. Restarts exist to compensate for that higher-variance seeding. Pass with_n_init(1) for exact scikit-learn parity. Expect the fitted result to change when you do. Restarts derive their seeds deterministically from random_state, so a seeded fit stays reproducible.

2.7.2. Constructing a model

The constructor takes 3 positional parameters and validates them eagerly. An invalid configuration fails at construction, not at fit.

pub fn new(n_clusters: usize, max_iterations: usize, tolerance: f64) -> Result<Self, Error>
ParameterPositionTypeMeaning
n_clusters1stusizeNumber of clusters k to form. Must be greater than 0.
max_iterations2ndusizeUpper bound on Lloyd’s iterations within each restart. Must be greater than 0.
tolerance3rdf64Relative convergence tolerance (scaled by feature variance). Must be positive and finite.

Any violation returns Error::InvalidParameter: n_clusters == 0, max_iterations == 0, or a tolerance that is zero, negative, NaN, or infinite. Note the asymmetry with the data-validation errors. An out-of-range hyperparameter returns InvalidParameter. A non-finite value in the data returns NonFinite instead. This distinction is a crate-wide convention.

KMeans::default() gives you n_clusters = 8, max_iterations = 300, tolerance = 1e-4, n_init = 10, and no seed. This configuration is always valid.

The 2 remaining settings are builder steps. Each one consumes the instance and returns it, so you can chain them after new. with_random_state(seed) cannot fail. with_n_init(n) returns Result, because 0 restarts triggers Error::InvalidParameter:

let mut km = KMeans::new(3, 300, 1e-4)
    .unwrap()
    .with_n_init(1)          // scikit-learn parity, returns Result
    .unwrap()
    .with_random_state(42);  // returns Self

Without a seed, k-means++ draws from entropy, and each fit produces a different partition. With a seed, the fit is reproducible. See 2.7.6 for details. This reproducibility covers every restart too. Each restart derives its own sub-seed deterministically from the seed you set.

2.7.3. Fitting, predicting, and reading results

3 methods drive the model. They differ in what they return and what they mutate.

fit(&mut self, data) trains the model in place and returns Result<&mut Self, Error>. It computes and stores the centroids, the training-set labels, the inertia, and the iteration count. All 4 values describe the winning restart.

predict(&self, data) assigns each row of a new matrix to its nearest fitted centroid. It returns an owned Array1<isize>. It borrows self immutably and does not change any stored state, so you can call it as many times as you want after one fit.

The labels use a signed type, not an unsigned one. This lets every clustering estimator in the crate share one label type. DBSCAN and Mean Shift both use -1 for noise, and the shared type feeds the metrics in 5.3. Clustering Metrics without a conversion. k-means itself never returns a negative label.

fit_predict(&mut self, data) calls fit, then returns a clone of the training labels. Use it when you want only the labels for the data you just trained on. Use fit plus predict instead when you want to score fresh points.

The fitted state is exposed through getters that return None before fit:

GetterReturnsNotes
get_centroids()Option<&Array2<f64>>Shape (n_clusters, n_features). Matches scikit-learn’s cluster_centers_.
get_labels()Option<&Array1<isize>>Training-set assignments. Matches scikit-learn’s labels_.
get_inertia()Option<f64>Sum of squared distances to the nearest centroid. Matches scikit-learn’s inertia_. Always consistent with get_centroids(), whether the fit converged or not.
get_actual_iterations()Option<usize>Iterations the winning restart ran, 1..=max_iterations. Matches scikit-learn’s n_iter_.
get_n_clusters() / get_max_iterations() / get_tolerance() / get_n_init() / get_random_state()plain values / Option<u64>Echo back the configuration.

fit reports 3 data errors. It returns Error::EmptyInput when the matrix has zero rows. It returns Error::NonFinite when any value is NaN or infinite. It returns Error::InvalidInput when there are fewer samples than clusters, because you cannot place k centroids with fewer than k points.

predict adds 2 more errors. It returns Error::NotFitted when called before fit. It returns Error::DimensionMismatch when the feature count does not match the training data. It also runs the same empty-input and non-finite checks as fit.

2.7.4. Clustering three blobs end to end

3 tight, well-separated blobs make a standard sanity check. Any correct k=3 run must give every blob its own cluster. This example uses a small, deterministic dataset, with no random numbers in the data itself, and a fixed seed. It runs instantly, and the structure of the result is predictable.

use rustyml::machine_learning::KMeans;
use ndarray::{array, Array2};

fn main() {
    // 3 blobs of 5 points, centered at (0,0), (10,0), (5,10).
    let data: Array2<f64> = array![
        [-0.05,  0.03], [ 0.04, -0.02], [ 0.01,  0.05], [-0.03, -0.04], [ 0.02,  0.01],
        [ 9.95,  0.03], [10.04, -0.02], [10.01,  0.05], [ 9.97, -0.04], [10.02,  0.01],
        [ 4.95, 10.03], [ 5.04,  9.98], [ 5.01, 10.05], [ 4.97,  9.96], [ 5.02, 10.01],
    ];

    let mut km = KMeans::new(3, 300, 1e-4).unwrap().with_random_state(42);
    km.fit(&data).unwrap();

    let labels = km.get_labels().unwrap();
    let centroids = km.get_centroids().unwrap();
    println!("labels:    {:?}", labels);
    println!("centroids: {:?}", centroids);
    println!("inertia:   {:.6}", km.get_inertia().unwrap());
    println!("iters:     {}", km.get_actual_iterations().unwrap());

    // Score fresh points sitting on each blob's true center.
    let new_points = array![[0.0, 0.0], [10.0, 0.0], [5.0, 10.0]];
    let predicted = km.predict(&new_points).unwrap();
    println!("new-point labels: {:?}", predicted);
}

The exact cluster indices are arbitrary. k-means numbers clusters by discovery order, so the seed decides which blob becomes cluster 0. The structure of the result stays fixed:

labels:    the 5 points of each blob share one index. The 3 blobs get 3 distinct indices
           (some permutation of 0, 1, 2).
centroids: shape (3, 2). Rows sit within about 0.1 of (0,0), (10,0), (5,10), in some order.
inertia:   a small positive f64 (sum of squared point-to-centroid distances).
iters:     a handful, well below max_iterations.
new-point labels: each test point maps to the same cluster index as the blob it sits on.

Cluster indices depend on the permutation, so never compare labels across 2 separately fitted models by equality. Instead, use a permutation-invariant metric, such as the adjusted Rand index in 5.3. Clustering Metrics.

2.7.5. Choosing k

k-means cannot tell you k. You must supply it. 2 techniques narrow down a good value, and RustyML gives you the raw material for both.

The elbow method plots inertia against k. Inertia falls monotonically as k grows, because more centroids can only reduce the sum of squared distances. At k = n it reaches zero. Look for the elbow, the point where the drop flattens out. Read inertia directly from get_inertia.

The silhouette score is more decisive, because it has an interior optimum instead of a monotone trend. For each point, it measures how much closer that point sits to its own cluster than to the nearest other cluster. It averages these values to a score in [-1, 1], where higher is better. The metrics module provides it as silhouette_score. It takes the feature matrix, the labels, and a DistanceCalculationMetric. It is defined only for 2..=n-1 distinct clusters, so it cannot score k = 1.

use rustyml::machine_learning::KMeans;
use rustyml::metrics::silhouette_score;
use rustyml::math::DistanceCalculationMetric;
use ndarray::{array, Array2};

fn main() {
    let data: Array2<f64> = array![
        [-0.05,  0.03], [ 0.04, -0.02], [ 0.01,  0.05], [-0.03, -0.04], [ 0.02,  0.01],
        [ 9.95,  0.03], [10.04, -0.02], [10.01,  0.05], [ 9.97, -0.04], [10.02,  0.01],
        [ 4.95, 10.03], [ 5.04,  9.98], [ 5.01, 10.05], [ 4.97,  9.96], [ 5.02, 10.01],
    ];

    println!(" k   inertia   silhouette");
    for k in 1..=5usize {
        let mut km = KMeans::new(k, 300, 1e-4).unwrap().with_random_state(42);
        let labels = km.fit_predict(&data).unwrap();
        let inertia = km.get_inertia().unwrap();
        if k >= 2 {
            let s = silhouette_score(&data, &labels, DistanceCalculationMetric::Euclidean);
            println!("{k:2}  {inertia:8.4}   {s:7.4}");
        } else {
            println!("{k:2}  {inertia:8.4}      (n/a)");
        }
    }
}

On these 3 clean blobs, inertia drops sharply from k = 1 to k = 3, then flattens. The silhouette score peaks at k = 3. Both point at the true structure. Real data is messier, so the silhouette’s interior maximum tends to give a clearer signal. See 5.3. Clustering Metrics for the full list of metrics, including Davies-Bouldin and Calinski-Harabasz, which give independent second opinions.

2.7.6. Reproducible clusters: local seeds and the global seed

k-means++ is randomized, so an unseeded model produces a different partition on every fit. There are 2 ways to pin it down, and they combine in a predictable way.

The local seed is set with with_random_state(seed). It is self-contained: it seeds that estimator’s initialization RNG directly. It ignores any global state, and it never affects other components.

Each of the n_init restarts derives its own sub-seed from it, through a fixed mixing step, instead of by advancing one shared RNG. So a restart’s seeding does not depend on how much randomness the earlier restarts consumed. 2 models built with the same local seed, and fitted on the same data, produce identical centroids, labels, and inertia. The iteration count matches too, down to the last bit.

The global seed, set with rustyml::random::set_global_seed(seed), fixes every unseeded randomized component built and fitted on the same thread afterward. This includes k-means, the neural-network initializers, train_test_split, and more, all from one call. This mirrors Keras’ global-seed behavior. An unseeded k-means model draws an independent sub-seed from the global stream at fit time. To reproduce a run, set the global seed again before you re-fit.

2 caveats matter here. The global seed is thread-local, so set it on the thread that fits your models. Unseeded components also consume the stream in fit order, so their reproducibility depends on that order. A with_random_state seed avoids both problems. This is why it is the right tool for pinning a single estimator.

use rustyml::machine_learning::KMeans;
use rustyml::random::{set_global_seed, clear_global_seed};
use ndarray::{array, Array2};

fn main() {
    let data: Array2<f64> = array![
        [0.0, 0.0], [0.1, 0.0], [0.0, 0.1],
        [10.0, 0.0], [10.1, 0.0], [10.0, 0.1],
        [5.0, 10.0], [5.1, 10.0], [5.0, 10.1],
    ];

    // Local seed: identical results, independent of any global state.
    let mut a = KMeans::new(3, 300, 1e-4).unwrap().with_random_state(42);
    let mut b = KMeans::new(3, 300, 1e-4).unwrap().with_random_state(42);
    a.fit(&data).unwrap();
    b.fit(&data).unwrap();
    assert_eq!(a.get_labels().unwrap(), b.get_labels().unwrap());

    // Global seed: reset it before each fit to reproduce an unseeded run.
    set_global_seed(7);
    let mut c = KMeans::new(3, 300, 1e-4).unwrap();
    c.fit(&data).unwrap();
    let labels_c = c.get_labels().unwrap().clone();

    set_global_seed(7);
    let mut d = KMeans::new(3, 300, 1e-4).unwrap();
    d.fit(&data).unwrap();
    assert_eq!(&labels_c, d.get_labels().unwrap());

    clear_global_seed();
}

This determinism holds because the parallel arithmetic is designed for reproducibility, not just for speed. See 2.7.8 for how. For the full seeding model across the crate, see 7.1. Reproducibility and Random Seeds.

2.7.7. Failure modes and gotchas

Empty clusters. When Lloyd’s assignment leaves a centroid with no points, RustyML does not drop the cluster. It also does not leave a stale centroid in place. Each iteration reseeds every empty cluster with the single point farthest from its own assigned centroid, the point that contributes most to inertia. This keeps the model at exactly n_clusters centroids, and moves it out of the degenerate state on the next pass. So get_centroids always returns n_clusters rows. On pathological data, such as heavy duplication or k close to n, the labels can still resolve to fewer than k distinct values.

Sensitivity to feature scale. k-means minimizes Euclidean distance. So a feature measured in thousands dominates one measured in fractions, and the clustering effectively ignores the small-scale feature. There is no built-in standardization. Standardize your data first, with the tools in 4.2. Standardization and Normalization, whenever your features sit on different scales. This is the most common reason a k-means result looks wrong.

Sensitivity to outliers. Centroids are plain means, so a few extreme points can drag them off the dense region of their cluster and inflate inertia. If your data has outliers, either trim them, or use a density-based method that treats them as noise. 2.8. DBSCAN labels outliers explicitly. 2.9. Mean Shift finds modes without a preset k.

Non-globular clusters. k-means partitions space into Voronoi cells around the centroids. So it can recover only roughly convex, similarly sized blobs. Concentric rings or elongated manifolds defeat it, regardless of k. That geometry is what DBSCAN and the manifold methods in 2.12. t-SNE are for.

Too few samples. Requesting more clusters than you have points returns Error::InvalidInput. It does not silently clamp k. If k depends on your data, check n_clusters <= data.nrows() before you fit.

2.7.8. Parallelism and performance

The assignment step is the cost center. It compares every point against every centroid, each iteration, so this is where the parallelism lives. Each iteration computes all point-to-centroid projections as one parallel matrix product (see 6.2. Matrix Multiplication). It then finds each point’s nearest centroid with a short arg-min scan.

The centroid update accumulates each point’s row into its cluster’s running sum. It does this as a deterministic blocked reduction (see 6.3. Parallel Reductions). The k-means++ seeding parallelizes its distance passes the same way.

The word “deterministic” matters here. Each of these stages parallelizes only above a calibrated work threshold. The reduction always sums in a fixed block order, not in thread-arrival order. This is why the parallel path gives the same answer as the serial path. It is also why a seeded fit is reproducible on one machine, regardless of how many threads rayon uses.

Floating-point addition is not associative. Summing in whatever order the threads happen to finish would leak the thread count into the result. Across different machines or build targets, tiny last-bit differences in the arithmetic backend are still possible. Within one machine, a fixed seed gives a fixed answer.

For small inputs, everything runs single-threaded. This avoids rayon’s coordination overhead on data that does not need it. 3 thresholds control the crossover. The f64 scan gate covers the arg-min pass. The f64 sum gate covers the centroid-sum accumulation. The f64 cheap-map gate covers the division of each centroid by its cluster size.

The scan and sum gates live in rustyml::tuning::reduction. The cheap-map gate lives in rustyml::tuning::elementwise. Move them if you are clustering unusual shapes and want to shift a threshold. Most users never touch them. 7.3. Performance Tuning and Parallelism covers these knobs.

Building with the show_progress feature draws a live inertia and iteration bar during fit. This helps on large datasets, and it costs nothing when the feature is off.

2.7.9. Saving and loading a fitted model

KMeans derives serde’s Serialize and Deserialize. The generated save_to_path and load_from_path methods persist the whole model, including the centroids, labels, hyperparameters, and metadata, to a compact postcard binary blob. The file extension does not matter, because the format is always binary postcard. A loaded model predicts identically to the original, down to the last bit of the centroids.

The serialized layout gained the n_init field when restarts were added to the crate. So a blob written by an older version will not load. Re-fit the model and save it again instead.

use rustyml::machine_learning::KMeans;
use ndarray::{array, Array2};
use std::fs::remove_file;

fn main() {
    let data: Array2<f64> = array![
        [0.0, 0.0], [0.1, 0.0], [0.0, 0.1],
        [10.0, 0.0], [10.1, 0.0], [10.0, 0.1],
        [5.0, 10.0], [5.1, 10.0], [5.0, 10.1],
    ];

    let mut km = KMeans::new(3, 300, 1e-4).unwrap().with_random_state(42);
    km.fit(&data).unwrap();
    km.save_to_path("kmeans_model.bin").unwrap();

    let loaded = KMeans::load_from_path("kmeans_model.bin").unwrap();
    let original = km.predict(&data).unwrap();
    let restored = loaded.predict(&data).unwrap();
    assert_eq!(original, restored);

    remove_file("kmeans_model.bin").unwrap();
}

I/O and deserialization problems surface as Error::Io. A missing file on load is the common case. See 7.2. Model Persistence in Depth for the persistence format, versioning concerns, and how it works with the rest of the crate.

2.8. DBSCAN

DBSCAN (Density-Based Spatial Clustering of Applications with Noise) groups points that sit in dense regions. It labels the rest as noise. Unlike KMeans, DBSCAN does not need the number of clusters up front. It finds clusters of any shape, not only round blobs, and it marks outliers explicitly. In exchange, you set 2 density parameters, eps and min_samples, instead of k. This page covers the density model RustyML implements, a method to pick eps, what predict does, and the cost of the O(n^2) algorithm.

2.8.1. The density model: core, border, and noise points

DBSCAN assigns every training point one of 3 roles. The 2 parameters eps (the neighborhood radius) and min_samples (the density threshold) decide the role.

A point’s neighborhood is every point within eps of it. The boundary is inclusive, so a point at distance exactly eps counts as a neighbor. RustyML’s neighborhood query also counts the point itself, since its distance to itself is 0, and 0 is always <= eps. This fact sets the meaning of min_samples:

RoleDefinition in this implementation
Core pointIts neighborhood has at least min_samples points, counting itself. So at least min_samples - 1 other points lie within eps.
Border pointNot a core point, but within eps of a core point. It joins that core point’s cluster.
Noise pointNeither core nor border. Labeled -1.

So min_samples counts the query point itself, the same way scikit-learn counts it. If you count a neighbor as only another nearby point, not the point itself, subtract 1 from min_samples. With min_samples = 2, any point with 1 other point within eps becomes a core point.

Clusters form by density connectivity. fit starts at an unvisited core point, claims it, then floods through the neighborhoods of core points and absorbs every point it reaches. A border point joins the cluster but does not extend the flood, because fit does not expand a border point’s neighborhood. Any point the flood never reaches stays -1.

2 facts follow from this design. First, fit has no random number generator, so the clustering is fully deterministic. fit processes points in ascending row order, and each neighbor list comes back sorted by index. The same input always produces the same labels and the same cluster ids. Second, when a border point sits within reach of 2 clusters, the cluster whose flood reaches the point first claims it. Because fit processes rows in order, that is the cluster with the lower id. This resolves the classic DBSCAN border ambiguity in a deterministic way instead of leaving it undefined.

2.8.2. Constructing and configuring the estimator

The constructor takes the 2 density parameters and validates them:

pub fn new(eps: f64, min_samples: usize) -> Result<Self, Error>
pub fn with_metric(self, metric: DistanceCalculationMetric) -> Result<Self, Error>

new returns Error::InvalidParameter if eps is non-positive or non-finite, or if min_samples is 0. The distance metric defaults to Euclidean. Change it with with_metric, which also returns Result because it validates the Minkowski order (see section 2.8.4). Default::default() gives eps = 0.5, min_samples = 5, and Euclidean. These numbers are placeholders. They are not good defaults for your data.

ParameterTypeMeaningValidation
epsf64Neighborhood radius, in the metric’s unitsmust be positive and finite
min_samplesusizeNeighborhood size (including self) for a core pointmust be > 0
metricDistanceCalculationMetricDistance functionMinkowski p must be >= 1 and finite

Getters expose the stored state after construction: get_epsilon, get_min_samples, and get_metric. Once the model is fitted, it also exposes get_labels() -> Option<&Array1<isize>> and get_core_sample_indices() -> Option<&Array1<usize>>. See Error Handling for how the error variants map to real failures.

2.8.3. Fitting and reading the labels

fit runs the clustering and stores the result. fit_predict does the same and also returns the label array. get_labels reads the stored labels after that. Labels have type Array1<isize>. Cluster ids run 0, 1, 2, ... in discovery order, and -1 marks noise. The signed isize type lets DBSCAN store noise in the same array as the cluster ids, with no separate mask.

use rustyml::machine_learning::DBSCAN;
use ndarray::Array2;

fn main() {
    // Two tight blobs plus one isolated point.
    let data = Array2::from_shape_vec(
        (9, 2),
        vec![
            0.0, 0.0, 0.1, 0.0, 0.0, 0.1, 0.1, 0.1, // blob A near the origin
            10.0, 10.0, 10.1, 10.0, 10.0, 10.1, 10.1, 10.1, // blob B near (10, 10)
            5.0, 5.0, // noise: far from both blobs
        ],
    )
    .unwrap();

    let mut dbscan = DBSCAN::new(0.5, 2).unwrap();
    let labels = dbscan.fit_predict(&data).unwrap();

    let n_clusters = labels.iter().filter(|&&l| l >= 0).map(|&l| l).max().map_or(0, |m| m + 1);
    let n_noise = labels.iter().filter(|&&l| l == -1).count();
    println!("labels     = {:?}", labels);
    println!("clusters   = {}", n_clusters);
    println!("noise pts  = {}", n_noise);

    // core_sample_indices holds only the rows that qualified as core points, sorted ascending.
    let cores = dbscan.get_core_sample_indices().unwrap();
    println!("core rows  = {:?}", cores);
}

Blob A becomes cluster 0, because fit discovers it first. Blob B becomes cluster 1. The lone point at (5, 5) stays -1. With min_samples = 2 and 4 points per blob, every blob point is a core point. So core_sample_indices is [0,1,2,3,4,5,6,7], and the noise row is absent.

labels     = [0, 0, 0, 0, 1, 1, 1, 1, -1], shape=[9], strides=[1], layout=CFcf (0xf), const ndim=1
clusters   = 2
noise pts  = 1
core rows  = [0, 1, 2, 3, 4, 5, 6, 7], shape=[8], strides=[1], layout=CFcf (0xf), const ndim=1

fit validates the input before it does any work. A zero-row matrix gives Error::EmptyInput. Any NaN or infinite value in the data gives Error::NonFinite. This shows the split of responsibility. A bad hyperparameter, such as a non-finite eps, gives InvalidParameter at construction. A bad value in the data gives NonFinite at fit time.

2.8.4. Distance metrics

with_metric accepts the 3 variants of DistanceCalculationMetric: Euclidean (the default, L2), Manhattan (L1), and Minkowski(p) (the general Lp norm). Minkowski(2.0) gives the same labels as Euclidean. Minkowski(1.0) gives the same labels as Manhattan. Use the named variants for L1 or L2. Reserve Minkowski for a fractional or higher order.

use rustyml::machine_learning::{DBSCAN, DistanceCalculationMetric};
use ndarray::array;

fn main() {
    let data = array![
        [0.0, 0.0], [0.1, 0.0], [0.0, 0.1],
        [5.0, 5.0], [5.1, 5.0], [5.0, 5.1],
    ];

    let mut dbscan = DBSCAN::new(0.5, 2)
        .unwrap()
        .with_metric(DistanceCalculationMetric::Manhattan)
        .unwrap();
    let labels = dbscan.fit_predict(&data).unwrap();
    println!("{:?}", labels); // two clusters, no noise
}

The constructor rejects a Minkowski order below 1 (including 0.5) or a non-finite order. Such an order breaks the triangle inequality, and that would break the neighborhood test. Changing the metric changes the units of eps, not only its meaning. The same points span a larger Manhattan distance than a Euclidean distance, so an eps tuned for one metric is wrong for another. Retune eps after every metric change. See Distance Metrics for the metric definitions and their tradeoffs.

2.8.5. Choosing eps: the k-distance heuristic

eps is the parameter people set wrong most often. This section gives a concrete method to pick it, instead of trial and error. For each point, measure the distance to its k-th nearest neighbor. Take k = min_samples, and count the point itself, so index 0 is the point at distance 0. Sort all the k-distances in ascending order and look at the curve. Points inside a dense cluster have a small k-distance. Noise points have a large one. The curve stays flat and low across the clustered points, then bends sharply upward at the “knee” as it starts to hit outliers. The k-distance at that knee makes a good eps value. It is large enough to connect real clusters, and small enough to leave outliers isolated.

This sorting step mirrors what a KNN query returns. You can compute the k-distances directly with the public metric dispatcher:

use rustyml::machine_learning::DistanceCalculationMetric;
use ndarray::array;

fn main() {
    // Dense cluster of 5 points plus 2 scattered outliers.
    let data = array![
        [0.0, 0.0], [0.2, 0.1], [0.1, 0.2], [0.3, 0.0], [0.0, 0.3],
        [4.0, 4.0], [8.0, 1.0],
    ];

    let metric = DistanceCalculationMetric::Euclidean;
    let min_samples = 3usize; // k for the k-distance graph
    let n = data.nrows();

    // k-distance of each point: the distance to its k-th nearest neighbor (self included).
    let mut k_dists: Vec<f64> = (0..n)
        .map(|i| {
            let mut d: Vec<f64> = (0..n)
                .map(|j| metric.distance(data.row(i), data.row(j)))
                .collect();
            d.sort_by(|a, b| a.partial_cmp(b).unwrap());
            d[min_samples - 1] // index 0 is self (distance 0)
        })
        .collect();

    k_dists.sort_by(|a, b| a.partial_cmp(b).unwrap());
    // Read the curve from left to right. The sharp rise near the end marks the knee.
    println!("sorted k-distances: {:?}", k_dists);
}

The flat prefix of the printed curve corresponds to the clustered points. Pick eps where the curve turns up. Reserve min_samples for the density floor. A common starting point is 2 * n_features. Raise it for noisy data, because more required neighbors means stricter noise rejection. Lower it toward n_features + 1 for clean, low-dimensional data. RustyML counts the point itself, so min_samples = 1 makes every point a core point. That produces 0 noise points, which is rarely what you want.

2.8.6. What predict does, and why it is not classic DBSCAN

Textbook DBSCAN has no predict method for new points. This gap is fundamental, not an oversight. Cluster membership in DBSCAN is transductive. A point’s label depends on the density of the whole neighborhood. Adding a new point to the data could turn it into a core point. It could also join 2 separate clusters into one, or shift where a border falls. There is no way to label a new point correctly without a new density analysis over the combined set.

RustyML still offers a predict method. It does something narrower and cheaper than a new density analysis. This section states exactly what it does:

use rustyml::machine_learning::DBSCAN;
use ndarray::array;

fn main() {
    let train = array![
        [0.0, 0.0], [0.1, 0.0], [0.0, 0.1], [0.1, 0.1],
        [10.0, 10.0], [10.1, 10.0], [10.0, 10.1], [10.1, 10.1],
    ];
    let mut dbscan = DBSCAN::new(0.5, 2).unwrap();
    dbscan.fit(&train).unwrap();

    // Assign each new point to the cluster of its nearest core point, if within eps.
    let queries = array![
        [0.05, 0.05],   // inside blob A  -> 0
        [10.05, 10.05], // inside blob B  -> 1
        [5.0, 5.0],     // far from all cores -> noise (-1)
    ];
    let preds = dbscan.predict(&queries).unwrap();
    println!("{:?}", preds); // preds is [0, 1, -1], one label per query.
}

predict finds each query’s nearest core point. It searches the core points saved during fit, not the full training set. It returns that core point’s cluster label if the query is within eps. Otherwise, it returns -1. predict picks the single nearest core point. It does not check every core point within eps. The eps gate is inclusive. predict never creates a new cluster, never promotes a query to a core point, and never runs the density flood again. So predict(x) does not give the same result as adding x to the training data and calling fit again. Treat predict as a fast, approximate way to assign held-out points to clusters fit already found. Call fit again on the enlarged set to get true DBSCAN semantics.

Calling predict before fit gives Error::NotFitted. A feature-count mismatch gives Error::DimensionMismatch. A non-finite query value gives Error::NonFinite. Empty input returns an empty array.

2.8.7. Two rings where KMeans fails

Shape is the main reason to choose DBSCAN over KMeans. KMeans partitions space with straight boundaries around k centroids, so it can only carve out convex, roughly round regions. DBSCAN follows density, so it can trace any shape. Two concentric rings show the difference clearly. The rings are not linearly separable around their shared center. KMeans cuts straight through both rings, while DBSCAN walks each ring as a connected chain.

use rustyml::machine_learning::{DBSCAN, KMeans};
use ndarray::Array2;

fn main() {
    // Build 2 concentric rings: inner radius 1, outer radius 4.
    let mut coords: Vec<f64> = Vec::new();
    let inner = 12usize;
    for k in 0..inner {
        let t = k as f64 / inner as f64 * std::f64::consts::TAU;
        coords.push(t.cos());
        coords.push(t.sin());
    }
    let outer = 28usize;
    for k in 0..outer {
        let t = k as f64 / outer as f64 * std::f64::consts::TAU;
        coords.push(4.0 * t.cos());
        coords.push(4.0 * t.sin());
    }
    let data = Array2::from_shape_vec((inner + outer, 2), coords).unwrap();

    // eps covers each ring's neighbor spacing but not the >= 3-unit gap between rings.
    let mut dbscan = DBSCAN::new(1.2, 2).unwrap();
    let db = dbscan.fit_predict(&data).unwrap();
    let db_clusters = db.iter().filter(|&&l| l >= 0).map(|&l| l).max().map_or(0, |m| m + 1);
    let db_noise = db.iter().filter(|&&l| l == -1).count();

    // KMeans with k = 2 (seeded for reproducibility).
    let mut km = KMeans::new(2, 100, 1e-4).unwrap().with_random_state(0);
    let km_labels = km.fit_predict(&data).unwrap();
    // km_inner0 and km_outer0 count how KMeans cluster 0 splits across the 2 rings.
    let km_inner0 = (0..inner).filter(|&i| km_labels[i] == 0).count();
    let km_outer0 = (inner..inner + outer).filter(|&i| km_labels[i] == 0).count();

    println!("DBSCAN: {} clusters, {} noise", db_clusters, db_noise);
    println!("KMeans cluster 0: {} inner-ring + {} outer-ring points", km_inner0, km_outer0);
}

DBSCAN recovers the 2 rings exactly. It finds 2 clusters and 0 noise points, with the inner ring as one label and the outer ring as the other. KMeans splits the plane with a line through the origin. So each of its 2 clusters mixes inner-ring and outer-ring points. KMeans cannot represent a ring at all.

DBSCAN: 2 clusters, 0 noise
KMeans cluster 0: 6 inner-ring + 14 outer-ring points

See Clustering Metrics to score these results with ground-truth labels or an intrinsic metric.

2.8.8. Cost, indexing, and parallelism

Naive DBSCAN runs in O(n^2) time. Every point runs a region query, and a brute-force region query scans all n points. RustyML lowers the constant with a kd-tree. During fit, RustyML builds one kd-tree over the data and answers each region query in about O(log n) average time. This only applies when the data has at most 8 features (DBSCAN_KD_TREE_MAX_DIMS). Above 8 dimensions, a kd-tree stops pruning well, because of the curse of dimensionality (almost every point looks “far” from every other point). So fit falls back to a brute-force scan. Clustering stays correct on that path, only slower. Reduce dimensionality first with PCA or t-SNE before you use high-dimensional data. This speeds up the index, and Euclidean neighborhoods lose meaning in high dimensions regardless of the index.

The brute-force region query runs in parallel across neighbors with rayon. This happens when the scan work (n_samples * n_features) clears a calibrated element gate (262_144 by default). The cluster-expansion loop stays sequential, because it is a flood-fill. So parallelism speeds up each region scan, not the overall control flow. predict runs in parallel over query points when n_queries * n_core_points * n_features clears the same gate. You can tune these gates through the crate::tuning facade, with no recompile needed. See Performance Tuning and Parallelism for how, and Parallel Reductions for the gate mechanism. As a guardrail, if a pathological dataset produces isize::MAX clusters, fit returns Error::Computation instead of an overflow in the label counter.

DBSCAN gives you clusters of any shape, automatic outlier detection, and no k to guess. In exchange, it costs quadratic time in the worst case. The kd-tree lowers this cost in low dimensions but does not remove it. DBSCAN is also sensitive to eps when clusters have different densities. A single global eps cannot fit both a dense cluster and a sparse cluster at once.

2.8.9. Persistence

A fitted DBSCAN serializes with save_to_path and load_from_path. Both methods use the compact postcard binary format. The saved data includes the hyperparameters and the fitted state that predict needs: the stored core points and their labels. A reloaded model predicts the same labels, with no need to see the original training set again.

use rustyml::machine_learning::DBSCAN;
use ndarray::array;

fn main() {
    let data = array![
        [0.0, 0.0], [0.1, 0.0], [0.0, 0.1], [0.1, 0.1],
        [10.0, 10.0], [10.1, 10.0], [10.0, 10.1], [10.1, 10.1],
    ];
    let mut dbscan = DBSCAN::new(0.5, 2).unwrap();
    dbscan.fit(&data).unwrap();

    let path = "dbscan_model.bin";
    dbscan.save_to_path(path).unwrap();

    let loaded = DBSCAN::load_from_path(path).unwrap();
    let preds = loaded.predict(&array![[0.05, 0.05], [10.05, 10.05], [5.0, 5.0]]).unwrap();
    println!("{:?}", preds); // preds is [0, 1, -1], the same labels fit produced.

    std::fs::remove_file(path).unwrap();
}

See Model Persistence in Depth for the format details, versioning caveats, and how persistence interacts with the rest of the crate.

2.9. Mean Shift

Mean Shift is a clustering algorithm. Use it when you do not know how many clusters the data holds, and you do not want to guess a number. Unlike KMeans, which needs k up front, Mean Shift finds the number of clusters from the density of the data. It needs only 1 input from you: the bandwidth. The algorithm moves every seed point uphill on the density surface until it stops at a mode.

The modes that survive become the cluster centers. Their count comes from the data, not from you. This freedom comes at a price: the bandwidth. It is the only setting that controls everything. A wrong bandwidth is the only way to get a bad result.

RustyML exposes the algorithm through MeanShift and a free function, estimate_bandwidth. Both re-export from rustyml::machine_learning. The kernel, the merge rule, the noise label, and the bandwidth estimator all match scikit-learn 1.9.0. On a reference 10-point dataset, the cluster centers, their numbering, and every label match scikit-learn’s output exactly.

2.9.1. Mode seeking with a flat kernel

Picture a kernel density estimate laid over your points. Each sample adds a small bump. Where samples accumulate, the bumps stack into peaks.

Mean Shift places a candidate center at a seed location. It replaces the center with the mean of the data around it. That mean point lies in the direction where density increases. The center climbs to the nearest peak, a mode, over repeated iterations. Repeat the process from many seeds to find every mode.

The kernel decides which points weight that mean, and this detail decides the algorithm’s behavior. RustyML uses the flat kernel, the same kernel scikit-learn uses. For a center c, every point within bandwidth of it counts once. Every point outside it counts zero. The next center is the plain mean of the points inside the ball: mean{ x_i : ||c - x_i|| <= bandwidth }.

The window is a hard ball with radius bandwidth. The number of points it holds at convergence is the mode’s intensity. The merge phase below ranks modes by this intensity.

An earlier version of this crate used a Gaussian kernel weighted over the whole dataset. RustyML removed the Gaussian kernel instead of keeping it as an option. It has no scikit-learn counterpart, so nothing validates it. It also does not produce the window point count the merge rule needs. MeanShift has one kernel, and it has no kernel parameter.

At a very small bandwidth, a shifted center’s ball can end up empty. A naive fix divides by zero and collapses the center to the origin. That would add a false cluster at (0, 0, ...), unrelated to any real point. RustyML avoids this. It leaves the center where it is and stops iterating. This matches the early exit scikit-learn takes for an empty neighborhood.

A point with no neighbors becomes its own mode. Because of this rule, a bandwidth that shrinks toward zero degrades gracefully. The result becomes one cluster per isolated point, not meaningless output.

After every seed converges, MeanShift removes duplicate modes with scikit-learn’s method: intensity-ordered greedy suppression. First, it ranks the converged modes by how many points their window held, from most to least. Then it walks that order. It keeps each mode and discards every other mode within one bandwidth of it.

A kept center is a true density mode. An earlier version averaged the suppressed modes into the kept center instead. That approach dragged the center off the density peak. It also made the result depend on the order in which the pass processed the seeds.

The number of surviving centers is the cluster count. This count comes entirely from the data and the bandwidth. You never state the cluster count directly. Each input sample gets the label of its nearest surviving center.

This 2-phase structure, first converge many seeds and then merge them, explains why a bandwidth that is slightly too large still gives clean results. Even when seeds converge to slightly different spots, the merge step still combines them.

2.9.2. Constructing a MeanShift

MeanShift::new takes the bandwidth and returns a Result. A bandwidth that is not positive and finite is a usage error. RustyML does not clamp it silently:

let ms = MeanShift::new(2.0)?            // the only required argument
    .with_max_iter(300)?                 // returns Result, validates > 0
    .with_tolerance(1e-4)?               // returns Result, validates positive and finite
    .with_bin_seeding(true)              // returns Self, infallible toggle
    .with_cluster_all(true);             // returns Self, infallible toggle

The split in return types is deliberate, and it is easy to miss. The 2 convergence setters validate their argument and return Result<Self, Error>. They need the ? operator. The 2 boolean toggles cannot fail and return Self. You can chain them directly.

MeanShift::default() equals new(1.0) with every other setting at its default. This is convenient for a first look. It is rarely the bandwidth you actually want. See 2.9.4 for more.

ParameterConstructor / setterDefaultMeaning
bandwidthnew(bandwidth)none (required)Radius of the flat kernel’s window. Also the merge radius and the outlier cutoff. Must be positive and finite.
max_iterwith_max_iter300Iteration cap per seed. Caps the worst case when a seed never reaches tol. Must be non-zero.
tolwith_tolerance1e-3Convergence threshold. A seed stops once its shift is shorter than this value. Must be positive and finite.
bin_seedingwith_bin_seedingfalseReduce the seed set by binning the space onto a grid (see 2.9.5).
cluster_allwith_cluster_alltrueAssign every point to a cluster. Set to false to label far-away points -1.

Invalid arguments come back as Error::InvalidParameter from new, with_max_iter, and with_tolerance. This is the same error type described in 1.6. Error Handling.

2.9.3. Fitting, predicting, and reading the results

fit takes a 2-D array, one sample per row. It runs the algorithm and returns &mut Self. predict maps new points to the learned centers and returns Array1<isize>. fit_predict does both, and returns the training labels directly.

The labels are signed because -1 is reserved for noise, the same convention DBSCAN and scikit-learn use. This convention lets the output of any clustering estimator feed any metric in 5.3. Clustering Metrics without a conversion. After fit, getters expose everything the model found.

use ndarray::Array2;
use rustyml::machine_learning::MeanShift;

fn main() {
    // 2 tight blobs: 5 points near (0, 0) and 5 near (20, 20).
    let data = Array2::from_shape_vec(
        (10, 2),
        vec![
            -0.1, 0.0, 0.1, 0.0, 0.0, -0.1, 0.0, 0.1, 0.0, 0.0, // blob A
            19.9, 20.0, 20.1, 20.0, 20.0, 19.9, 20.0, 20.1, 20.0, 20.0, // blob B
        ],
    )
    .unwrap();

    let mut ms = MeanShift::new(2.0).unwrap();
    let labels = ms.fit_predict(&data).unwrap();

    let centers = ms.get_cluster_centers().unwrap();
    println!("clusters found: {}", centers.nrows()); // emerges from the data: 2
    println!("labels: {:?}", labels);
    println!("samples per center: {:?}", ms.get_n_samples_per_center().unwrap());
    println!("iterations run: {}", ms.get_actual_iterations().unwrap());
}

The getters fall into 2 groups: results and echoed settings. get_cluster_centers returns Option<&Array2<f64>>, one row per cluster. get_labels returns Option<&Array1<isize>>. get_n_samples_per_center returns Option<&Array1<usize>>, the number of input samples assigned to each center. With cluster_all = true, these counts sum to the sample count. With cluster_all = false, the -1 outliers are excluded from the sum.

get_actual_iterations returns Option<usize>, the largest iteration count over all seeds. Use it to tell whether the run converged or hit max_iter. All 4 of these getters return None before fit and Some after.

The remaining getters, get_bandwidth, get_max_iterations, get_tolerance, get_bin_seeding, and get_cluster_all, simply read back the configuration.

predict has 4 failure modes. Calling it before fit returns Error::NotFitted. Passing an empty array returns Error::EmptyInput. Passing points whose feature count differs from the training data returns Error::DimensionMismatch. Passing data with NaN or infinite values returns Error::NonFinite. None of these errors are recoverable by retrying, so treat them as programming errors that surface at runtime.

2.9.4. Bandwidth: the hyperparameter that decides everything

Everything about a Mean Shift run flows from the bandwidth. It sets the radius of the flat kernel’s window. It also sets the merge radius, and the cutoff for what counts as an outlier when cluster_all is off. The bandwidth controls how far each seed can see, how strongly nearby modes collapse into one, and where noise begins.

A bandwidth too small causes over-segmentation. Seeds cannot reach across a cluster’s own spread, and modes multiply. In the extreme case, every well-separated point becomes its own cluster. A bandwidth too large causes under-segmentation instead. Distant blobs pull on each other until their modes drift together, and the merge step fuses them into one. Eventually the whole dataset becomes a single cluster.

No default bandwidth works for arbitrary data. The correct value is a length scale in the units of your features.

When you have no prior estimate, estimate_bandwidth gives a data-driven starting point. It takes the data and an optional quantile (default 0.3). It also takes an optional n_samples subsample size (default: all rows, clamped to the dataset size) and an optional random_state.

It computes k = max(1, floor(n * quantile)). Then it measures each point’s distance to its (k - 1)-th nearest neighbor, and returns the mean of those distances. This is a local-density statistic. It answers how far a typical point is from the edge of its own neighborhood, which is exactly what a bandwidth needs to be.

The (k - 1) term reproduces scikit-learn’s off-by-one behavior, because its neighbor query counts the query point itself. The result agrees with scikit-learn 1.9.0 to within 1e-14. A neighborhood of one point yields 0.0, the same result scikit-learn gives.

An earlier version returned a quantile of the whole pairwise-distance distribution instead. That is a global spread measure. It runs far larger than a bandwidth should on clustered data. It collapsed everything into a single cluster. If you tuned a bandwidth against the old estimator, re-estimate it now.

A quantile of 0.3 gives a typical short-to-medium neighborhood radius. This value tends to land near the intra-cluster scale. Larger quantiles bias toward larger bandwidths and fewer clusters.

use ndarray::Array2;
use rustyml::machine_learning::{MeanShift, estimate_bandwidth};

fn main() {
    // 3 well-separated blobs, 12 tight points each.
    let mut v: Vec<f64> = Vec::new();
    for (cx, cy) in [(0.0, 0.0), (10.0, 0.0), (5.0, 9.0)] {
        for k in 0..12u32 {
            v.push(cx + ((k * 7) % 5) as f64 * 0.05 - 0.1);
            v.push(cy + ((k * 3) % 5) as f64 * 0.05 - 0.1);
        }
    }
    let data = Array2::from_shape_vec((36, 2), v).unwrap();

    // A reasonable starting point straight from the data.
    let bw = estimate_bandwidth(&data, Some(0.3), None, Some(0)).unwrap();
    println!("estimated bandwidth: {:.3}", bw);

    // Sweep: small over-segments, large collapses everything into one.
    for bandwidth in [0.05_f64, 0.5, 3.0, 30.0] {
        let mut ms = MeanShift::new(bandwidth).unwrap();
        ms.fit(&data).unwrap();
        let k = ms.get_cluster_centers().unwrap().nrows();
        println!("bandwidth {bandwidth:>5} -> {k} clusters");
    }
}

The cluster count moves with the bandwidth in the direction you would expect. Because the exact counts depend on the data, treat the following as the shape of the output, not literal numbers:

estimated bandwidth: <small positive value>
bandwidth  0.05 -> many clusters      (blobs fragment; over-segmentation)
bandwidth   0.5 -> one cluster per blob
bandwidth     3 -> one cluster per blob
bandwidth    30 -> a single cluster   (all blobs merged; under-segmentation)

Follow a simple workflow. Call estimate_bandwidth once. Fit at that value. Look at the cluster count. Then increase the bandwidth if you got too many clusters, or decrease it if you got too few. Validate the choice with a metric that does not need ground-truth labels, such as the silhouette score in 5.3. Clustering Metrics.

estimate_bandwidth measures distances directly. Standardize your features first (see 4.2. Standardization and Normalization) to make the estimate meaningful when feature scales differ.

2.9.5. Bin seeding, cluster_all, and the outlier label

By default, every input point is a seed. This is the most thorough option. As 2.9.7 explains, it is also why fitting is deterministic. On dense datasets, using every point as a seed is wasteful, because thousands of seeds inside one blob all climb to the same mode.

with_bin_seeding(true) fixes this. It quantizes the feature space onto a grid whose cells have side length bandwidth, then keeps 1 representative seed per occupied cell. Fewer seeds mean fewer uphill walks and a faster fit.

The cost is a coarser, approximate seeding. A mode whose basin never contains a grid representative can be missed. Bin seeding is worth the trade when the seed loop dominates the run time and the data is dense enough to fill whole cells. On small or sparse data, it saves little, and it can only cost resolution.

cluster_all decides what happens to points that do not really belong to any mode. With the default true, MeanShift forces every point to its nearest center. Labels always fall in 0..n_clusters, and there is no concept of noise. Set it to false, and any point farther than one bandwidth from every center gets the label -1. This is scikit-learn’s noise value, the same value DBSCAN uses. The rule applies to the training labels from fit and to fresh points from predict.

The -1 label replaced an older sentinel value equal to n_clusters. Downstream code that counted distinct labels read that old sentinel as a real extra cluster. If your code compares a label against the cluster count, change it to label < 0.

use ndarray::Array2;
use rustyml::machine_learning::MeanShift;

fn main() {
    let data = Array2::from_shape_vec(
        (10, 2),
        vec![
            -0.1, 0.0, 0.1, 0.0, 0.0, -0.1, 0.0, 0.1, 0.0, 0.0,
            19.9, 20.0, 20.1, 20.0, 20.0, 19.9, 20.0, 20.1, 20.0, 20.0,
        ],
    )
    .unwrap();

    let mut ms = MeanShift::new(2.0).unwrap().with_cluster_all(false);
    ms.fit(&data).unwrap();

    // (10, 10) sits ~14 units from both blobs, far beyond the bandwidth of 2.0.
    let probe = Array2::from_shape_vec((1, 2), vec![10.0, 10.0]).unwrap();
    let pred = ms.predict(&probe).unwrap();

    if pred[0] < 0 {
        println!("outlier: label {}", pred[0]); // -1
    } else {
        println!("assigned to cluster {}", pred[0]);
    }
}

When you enable cluster_all = false, test for label < 0. Do not assume labels are dense. A naive labels.iter().max() no longer tells you the cluster count if every point happens to be noise. get_cluster_centers().unwrap().nrows() gives the reliable count.

2.9.6. Cost, convergence, and parallelism

Mean Shift has quadratic cost. Consider this before you run it on a large dataset. Each seed’s iteration touches all n points in d dimensions, to check which points fall inside the window and to average them. One seed costs O(iterations * n * d). With the default seeding, every point is a seed, so there are n seeds. A full fit costs roughly O(iterations * n^2 * d).

This is the same asymptotic class as DBSCAN’s pairwise scan. It is considerably heavier than KMeans’ O(iterations * n * k * d), whose k is usually far below n. Bin seeding cuts the seed count from n to the number of occupied cells. This reduces the constant, but it does not change the quadratic per-iteration term.

The convergence bound is max_iter (default 300). A seed stops early once its shift falls below tol. get_actual_iterations reports the largest count any seed needed. A value pinned at max_iter signals that some seed never settled.

The implementation runs in parallel only when doing so helps. In fit, the per-seed uphill walks are independent. They run across a Rayon pool once the total work (seeds times samples times features) clears RustyML’s calibrated scan-class gate. This gate defaults to 262,144 element-operations, and you can adjust it through crate::tuning. Below that gate, the loop stays serial, because the overhead of forking is not worth it.

Inside each seed, the implementation casts the weighted-mean computations as matrix-vector products. It deliberately keeps those serial when the seed axis alone already fills the pool. This avoids nested Rayon forks that would compete with each other. predict parallelizes its nearest-center scan under the same gate, keyed on samples times clusters times features.

For most datasets, you get parallelism automatically, without changing any setting. The tuning knobs, and the reasoning behind these thresholds, live in 7.3. Performance Tuning and Parallelism.

2.9.7. Reproducibility and persistence

Fitting a MeanShift is deterministic. It takes no random seed. With the default seeding, it starts from every point. With bin seeding, it starts from a fixed grid representative per cell. Neither approach draws from a random number generator, so two fits on identical data produce byte-identical centers and labels.

This is a real convenience over KMeans, whose centroid initialization is randomized and needs a seed to reproduce. See 7.1. Reproducibility and Random Seeds for the broader picture.

The only place randomness enters this module is estimate_bandwidth, and only when you ask it to subsample. If n_samples is smaller than the dataset, estimate_bandwidth shuffles indices to pick the subset. Pass a fixed random_state when you need the estimate itself to be reproducible. Requesting all rows, the default for n_samples, removes the randomness entirely, because there is nothing to sample.

A fitted model serializes to a compact postcard binary through save_to_path, and restores with load_from_path. The saved file carries the centers, labels, hyperparameters, and training metadata. A reloaded model predicts identically without a re-fit.

use ndarray::Array2;
use rustyml::machine_learning::MeanShift;

fn main() {
    let data = Array2::from_shape_vec(
        (6, 2),
        vec![0.0, 0.0, 0.1, 0.1, -0.1, 0.0, 10.0, 10.0, 10.1, 9.9, 9.9, 10.0],
    )
    .unwrap();

    let mut ms = MeanShift::new(2.0).unwrap();
    ms.fit(&data).unwrap();

    let path = "mean_shift_model.bin";
    ms.save_to_path(path).unwrap();
    let restored = MeanShift::load_from_path(path).unwrap();

    let before = ms.predict(&data).unwrap();
    let after = restored.predict(&data).unwrap();
    assert_eq!(before, after); // identical after the round trip

    std::fs::remove_file(path).unwrap();
    println!("round-trip predictions match");
}

One caveat applies to model files saved before the kernel and merge rule changed. The centers inside those files came from the old Gaussian-kernel run. A reloaded model clusters differently from a fresh fit on the same data. This happens silently, because nothing about the file announces which algorithm produced it. Re-fit the model and save it again.

Persist the model when the fit is expensive and the data is stable. Downstream services can then load and predict cheaply. 7.2. Model Persistence in Depth covers the format details and versioning caveats.

2.10. Principal Component Analysis

Principal Component Analysis (PCA) finds an orthonormal set of directions in feature space. These directions are the principal axes. The first axis captures the most variance in the data. The second axis is orthogonal to the first and captures the most of the remaining variance. Each later axis follows the same pattern.

Projecting the data onto the top k axes gives the k-dimensional linear subspace that keeps the most variance. This is the same subspace that gives the smallest squared reconstruction error. The subspace that keeps the most variance also discards the least information. This dual property is why PCA is a common first step to compress, denoise, or plot high-dimensional data.

RustyML’s PCA mirrors sklearn.decomposition.PCA. Fit it on a feature matrix. Use transform to get scores. Use inverse_transform to map scores back to feature space.

RustyML’s PCA differs from scikit-learn’s PCA in 3 ways. First, it centers the data but never scales it. The values you feed it matter more than in a pipeline that standardizes for you.

Second, the solver setting is an enum named SVDSolver, with 3 concrete strategies. The right choice depends on the number of features and the number of components you want.

Third, every factorization runs on hand-written pure Rust. There is no LAPACK and no BLAS. This makes the tradeoffs between solvers concrete rather than theoretical.

2.10.1. The surface you work with

PCA and SVDSolver live under rustyml::machine_learning::decomposition. Both types are also available through the prelude. The model is unsupervised. It takes an f64 feature matrix with samples as rows and features as columns. It uses no labels.

// Construction. n_components must be > 0, checked here.
PCA::new(n_components: usize) -> Result<PCA, Error>
PCA::default()                                         // n_components = 2, Full solver
pca.with_svd_solver(solver: SVDSolver) -> PCA          // builder, consumes and returns self

// Learning and projecting. fit takes &mut self.
pca.fit(&x)                -> Result<&mut PCA, Error>
pca.transform(&x)          -> Result<Array2<f64>, Error>   // (n_samples, n_components)
pca.fit_transform(&x)      -> Result<Array2<f64>, Error>
pca.inverse_transform(&scores) -> Result<Array2<f64>, Error> // (n_samples, n_features)

// Fitted state. mean, components, variances, and the sample/feature counts are None before
// fit, Some(&...) after. n_components and svd_solver are always available.
pca.get_mean()                     -> Option<&Array1<f64>>   // per-feature centering mean
pca.get_components()               -> Option<&Array2<f64>>   // (n_components, n_features), rows are axes
pca.get_explained_variance()       -> Option<&Array1<f64>>
pca.get_explained_variance_ratio() -> Option<&Array1<f64>>
pca.get_singular_values()          -> Option<&Array1<f64>>
pca.get_n_components() -> usize
pca.get_svd_solver()   -> SVDSolver
pca.get_n_samples()    -> Option<usize>
pca.get_n_features()   -> Option<usize>

The constructor checks only that n_components > 0. The harder bound, n_components <= min(n_samples, n_features), is checked at fit time, because it depends on the data. Use the getters to check whether the model is fitted. Each one returns None until a successful fit call sets it.

get_components returns the axes as rows of an (n_components, n_features) matrix. This is the same layout as scikit-learn’s components_. So transform is centered data times components.T, and inverse_transform is scores times components.

This example runs the full loop on a small 2-D data set and keeps 1 component:

use ndarray::array;
use rustyml::machine_learning::decomposition::PCA;

fn main() {
    let x = array![
        [2.5, 2.4],
        [0.5, 0.7],
        [2.2, 2.9],
        [1.9, 2.2],
        [3.1, 3.0],
        [2.3, 2.7],
    ];

    // fit_transform needs &mut self. It runs fit, then transform, on the same data.
    let mut pca = PCA::new(1).unwrap();
    let scores = pca.fit_transform(&x).unwrap();

    println!("scores shape: {:?}", scores.shape()); // [6, 1]
    println!("mean:         {:?}", pca.get_mean().unwrap());
    println!("component:    {:?}", pca.get_components().unwrap());
    println!("variance:     {:?}", pca.get_explained_variance().unwrap());
    println!("ratio:        {:?}", pca.get_explained_variance_ratio().unwrap());
    println!("singular:     {:?}", pca.get_singular_values().unwrap());
}

fit_transform(&x) is not a fused shortcut that gives a different result. It calls fit, then transform, on the same matrix. This is identical to running the two steps by hand. Use the two-step form to project a different matrix, for example new samples, through an already-fitted model. fit returns &mut Self, so you can chain a getter call directly onto it. The normal path, though, is to read state back with the get_* methods.

2.10.2. It centers, it does not scale

Before it decomposes the data, fit subtracts the per-feature mean and stores it in get_mean(). This is the only preprocessing step fit runs. It does not divide by the standard deviation. It does not whiten the data. It does not touch the units of the columns.

transform reuses the stored training mean to center new samples. inverse_transform adds the mean back. The mean is part of the fitted model, not a value computed fresh on each call.

Skipping this step is the most common cause of a misleading PCA result. Variance is not scale-invariant. Converting a feature from meters to millimeters multiplies its values by 1000, so its variance grows by 1000^2, a million times. PCA then assigns its first component to that one column.

The fix is to standardize the features before fitting. Standardizing puts every column on unit variance, so PCA compares them on equal footing. Use standardize with StandardizationAxis::Column. The next example shows the difference:

use ndarray::array;
use rustyml::machine_learning::decomposition::PCA;
use rustyml::utils::standardize::{standardize, StandardizationAxis};

fn main() {
    // Feature 0 uses tiny units. Feature 1 uses huge units. They carry the same
    // information, but very different variance.
    let x = array![
        [0.1, 1000.0],
        [0.2, 1005.0],
        [0.3, 1002.0],
        [0.4,  998.0],
        [0.5, 1010.0],
    ];

    let mut raw = PCA::new(2).unwrap();
    raw.fit(&x).unwrap();
    println!("raw ratios:          {:?}", raw.get_explained_variance_ratio().unwrap());

    let xs = standardize(&x, StandardizationAxis::Column).unwrap();
    let mut scaled = PCA::new(2).unwrap();
    scaled.fit(&xs).unwrap();
    println!("standardized ratios: {:?}", scaled.get_explained_variance_ratio().unwrap());
}

On the raw data, the first ratio is close to 1.0. Column 1’s numeric spread swamps the signal from column 0, so PC1 points almost exactly along column 1. After column standardization, the two features contribute about equally. The ratios then reflect the real correlation structure between them.

Whether to standardize is a modeling decision. If the features already share the same meaningful unit, for example pixel intensities, centering alone is enough. Make this decision on purpose, because rustyml does not make it for you. Statisticians describe this choice as PCA on the covariance matrix versus PCA on the correlation matrix. Standardizing first is the same as running PCA on the correlation matrix.

2.10.3. Choosing a solver, and the pure-Rust machinery behind it

SVDSolver selects how PCA computes the components. The name is not fully accurate. Only 1 of the 3 variants literally forms an SVD. The interface stays the same across all 3 variants. Whichever solver you choose, you get the same component rows, explained variances, and singular values. Each solver computes them through a different route, with a different cost and a different accuracy.

pub enum SVDSolver {
    Full,             // default: exact eigendecomposition of the covariance matrix
    Randomized(u64),  // randomized range finder, seeded by the u64
    PowerIteration,   // deflated power iteration for the top-k eigenpairs
}
VariantWhat it computesUse it when
Full (default)Builds the d x d covariance X^T X / (n - 1) and factors it exactly (Householder tridiagonalization, then implicit-shift QL).Small to mid-sized data, roughly under 10,000 samples and features. Accurate to near machine precision. The safe default choice.
Randomized(seed)Sketches X into a k-wide random subspace, runs a couple of subspace iterations, and takes a small SVD in the reduced space. Never builds the full d x d covariance.Large, wide data (10,000+ features) where speed matters and a small approximation error is acceptable. The seed makes the random sketch reproducible.
PowerIterationForms the covariance, then extracts only the top k eigenpairs through power iteration with Hotelling deflation, instead of a full eigendecomposition.You need only a few components (k far smaller than d) and want to skip the full O(d^3) eigensolve.

The cost of each solver follows a clear pattern. Full and PowerIteration both build the dense d x d covariance. Both pay O(n * d^2) to form it and O(d^2) memory to hold it. The difference is what happens next. Full runs a complete O(d^3) eigendecomposition.

PowerIteration extracts k eigenpairs one at a time, using a bounded number of matrix-vector products for each, and deflates each one before finding the next. This wins on compute time when k is far smaller than d.

Randomized never builds a d x d matrix at all. Its working set is the n x k sketch and the k x d reduced projection. This makes it the memory-frugal choice when d is large. On small problems, all 3 solvers agree to several digits. The approximate solvers only show their speed advantage, or their numerical drift, once the matrices grow large.

use ndarray::array;
use rustyml::machine_learning::decomposition::{PCA, SVDSolver};

fn main() {
    let x = array![
        [2.5, 2.4],
        [0.5, 0.7],
        [2.2, 2.9],
        [1.9, 2.2],
        [3.1, 3.0],
        [2.3, 2.7],
        [2.0, 1.6],
        [1.0, 1.1],
    ];

    for solver in [SVDSolver::Full, SVDSolver::Randomized(42), SVDSolver::PowerIteration] {
        let mut pca = PCA::new(1).unwrap().with_svd_solver(solver);
        pca.fit(&x).unwrap();
        let sigma = pca.get_singular_values().unwrap();
        println!("{:?}: sigma_1 = {:.6}", solver, sigma[0]);
    }
}

PowerIteration and Randomized expose only a small part of their internal settings. PowerIteration has no public setting for iteration count or tolerance. Internally, it runs up to 1000 iterations per component, to a 1e-6 eigenvalue tolerance, with a fixed seed. None of these values is tunable through the public API.

PowerIteration can also fail to converge. This can happen when the data has less effective rank than the number of components you request. For example, duplicate columns or an exact linear dependency between features can cause this. When the deflation step runs out of variance to extract, fit returns Error::NotConverged. Full and Randomized never fail this way, because neither depends on a per-component convergence check.

Randomized exposes only its seed. The oversampling amount and the number of subspace iterations are fixed. So the full configuration surface for PCA is n_components plus with_svd_solver. This is small on purpose.

All 3 solvers run on the crate’s own machine_learning::linalg module. This module reimplements symmetric eigendecomposition, SVD (one-sided Jacobi), and QR (modified Gram-Schmidt) directly on ndarray arrays. There is no LAPACK dependency to install, link, or vendor. This matches the pure-Rust numerics approach the rest of the crate takes. The covariance and projection multiplies use the parallel gemmkit backend.

2.10.4. Reading the variance and choosing n_components

After a fit, 3 vectors describe how the variance spreads across the kept axes. get_singular_values returns the singular values, in descending order, of the centered data. get_explained_variance returns each singular value squared, divided by (n - 1). This is the actual variance along each component, in the units of the original data.

get_explained_variance_ratio divides each of those values by the total variance of the centered data. That total is the full trace, summed over all features, not just the ones you kept. So each entry in the ratio is the fraction of the overall variance that component explains.

This denominator choice matters. If you keep fewer components than features, the ratios sum to less than 1. The shortfall is exactly the variance you discarded.

This property makes the ratio the right tool for choosing n_components. The standard method uses a scree plot and a cumulative rule. Fit the full-rank decomposition once. Read the cumulative ratio. Keep the smallest number of axes that clears a variance target, for example 90%, 95%, or 99%.

use ndarray::array;
use rustyml::machine_learning::decomposition::PCA;

fn main() {
    // 8 samples, 3 features. Feature 2 is almost a linear echo of feature 0,
    // so the data is effectively low-rank.
    let x = array![
        [1.0, 0.2, 1.19],
        [2.0, 0.1, 2.09],
        [3.0, 0.4, 3.38],
        [4.0, 0.2, 4.19],
        [5.0, 0.5, 5.48],
        [6.0, 0.3, 6.29],
        [7.0, 0.1, 7.09],
        [8.0, 0.6, 8.58],
    ];

    // Fit the full decomposition (n_components = min(n_samples, n_features) = 3),
    // then decide how many axes to actually keep.
    let mut pca = PCA::new(3).unwrap();
    pca.fit(&x).unwrap();

    let ratio = pca.get_explained_variance_ratio().unwrap();
    let mut cumulative = 0.0;
    for (i, r) in ratio.iter().enumerate() {
        cumulative += r;
        println!("PC{}: individual = {:.4}, cumulative = {:.4}", i + 1, r, cumulative);
    }

    // Smallest k whose cumulative ratio clears 95%.
    let mut acc = 0.0;
    let mut k = ratio.len();
    for (i, r) in ratio.iter().enumerate() {
        acc += r;
        if acc >= 0.95 {
            k = i + 1;
            break;
        }
    }
    println!("keep {} component(s) to retain >= 95% variance", k);
}

One detail matters here. You fit once at full rank to read the spectrum. Then refit with the chosen k if you want the smaller projection. Refitting is cheap, and it gives you a model whose transform outputs exactly k columns.

If refitting is expensive for the data, use a shortcut instead. The first k component rows of the full fit are the same axes as a k-component fit would produce. The only difference is the sign convention, covered in the next section. So you can slice the full fit rather than refit it. Do not pick k from a single ratio in isolation. Read the cumulative curve instead, to see where the returns flatten out.

2.10.5. Reconstruction and inverse_transform

inverse_transform maps scores back to feature space, using the formula reconstructed = scores * components + mean. When you keep all min(n_samples, n_features) components, the round-trip is lossless, up to floating-point error. When you keep fewer components, the reconstruction is the orthogonal projection of the data onto the retained subspace. The residual is the variance in the axes you dropped. Measuring that residual is a direct way to put a number on the cost of compression.

use ndarray::array;
use rustyml::machine_learning::decomposition::PCA;

fn main() {
    let x = array![
        [1.0, 0.2, 1.19],
        [2.0, 0.1, 2.09],
        [3.0, 0.4, 3.38],
        [4.0, 0.2, 4.19],
        [5.0, 0.5, 5.48],
        [6.0, 0.3, 6.29],
    ];

    // Keep a single axis, then round-trip back into the original 3-D feature space.
    let mut pca = PCA::new(1).unwrap();
    pca.fit(&x).unwrap();

    let scores = pca.transform(&x).unwrap();                     // (6, 1)
    let reconstructed = pca.inverse_transform(&scores).unwrap(); // (6, 3)

    // Frobenius norm of the residual = the variance thrown away with PC2 and PC3.
    let err: f64 = (&reconstructed - &x).iter().map(|d| d * d).sum::<f64>().sqrt();
    println!("scores shape:         {:?}", scores.shape());
    println!("reconstruction shape: {:?}", reconstructed.shape());
    println!("reconstruction error: {:.6}", err);
}

Watch the shape contract, because it reverses transform. transform takes n_features columns and returns n_components columns. inverse_transform takes n_components columns and returns n_features columns. Pass it a matrix whose column count does not match n_components, and you get Error::DimensionMismatch, not a silent broadcast.

The sign fix, covered in the next section, flips a whole axis together with its scores. This makes reconstruction invariant to it. The product scores * components stays the same, whether or not an axis was negated. A sign flip never corrupts a round-trip.

2.10.6. Determinism: sign convention and seeds

Eigenvectors and singular vectors are only defined up to sign. -v spans the same axis as v. scikit-learn users know this as the reason PC signs sometimes flip between runs or between library versions.

RustyML fixes the sign after the decomposition, so the result stays deterministic. Each component row is negated, if needed, so its largest-magnitude loading becomes non-negative. As a result, all 3 solvers agree on the orientation of every axis on the same data. Repeated runs of any one solver also agree. The sign stays stable, and fit is reproducible with no extra step.

This has 2 consequences. First, the sign convention is specific to rustyml. It keys off the component vectors themselves, not off the U factor the way scikit-learn’s svd_flip does. So a component’s sign may differ from what scikit-learn prints for the same data. This difference is cosmetic. The axis, the variance it explains, and every reconstruction stay identical.

Second, the only real source of run-to-run variation is SVDSolver::Randomized. Its random sketch is seeded by the u64 you pass. The same seed gives bit-identical output. A different seed gives a slightly different approximate subspace. Full and PowerIteration carry no external randomness. PowerIteration seeds its own starting vector internally, with a fixed value.

For exact reproducibility across a pipeline, see Reproducibility and Random Seeds. The only lever here is the Randomized seed.

A fitted PCA serializes with save_to_path and load_from_path. These methods write and read the whole model (mean, components, variances, and singular values) as compact postcard binary. The file extension does not matter. The bytes stay postcard format regardless of the file name. A reloaded model transforms data identically to the original model:

pca.save_to_path("pca_model.bin")?;
let loaded = PCA::load_from_path("pca_model.bin")?;
let scores = loaded.transform(&x_new)?; // identical to the pre-save model

For the mechanics, including versioning concerns and when the binary format is safe for long-term storage, see Model Persistence in Depth.

2.10.7. Errors and edge cases

PCA validates its input and returns typed errors instead of panicking. The .unwrap() calls in these examples keep the code short. Do not use .unwrap() this way in production code. The table below lists the errors you can hit in practice, plus the one that is specific to the PowerIteration solver:

SituationError variant
PCA::new(0)Error::InvalidParameter
n_components > min(n_samples, n_features) at fitError::InvalidParameter
Empty feature matrix at fitError::EmptyInput
Fewer than 2 samples at fitError::InvalidInput
NaN or Inf in the input, at fit or transformError::NonFinite
transform or inverse_transform before fitError::NotFitted
transform given the wrong feature countError::DimensionMismatch
inverse_transform given the wrong score-column countError::DimensionMismatch
SVDSolver::PowerIteration fails to converge, for example when the data has less effective rank than n_componentsError::NotConverged

The NotConverged case is specific to PowerIteration. Full and Randomized never raise it, because neither depends on a strict per-component convergence check.

The 2-sample minimum is not arbitrary. Variance needs an n - 1 denominator, so a single row has nothing to decompose. The n_components ceiling of min(n_samples, n_features) is the rank bound. You cannot extract more orthogonal directions than the data spans.

Asking for more raises a parameter error, instead of a silently truncated result. rustyml checks this bound against the data, not against the model alone. So the same PCA::new(5) instance can succeed on a 100 x 20 matrix and fail on a 3 x 4 one.

PCA is linear by construction. It can only find directions that are linear combinations of the input features. Structure that lives on a curved manifold stays invisible to it. When a scree plot does not flatten, and reconstruction stays poor at every k, this is usually a signal to move to a nonlinear method.

Kernel PCA applies the same variance-maximizing idea in a kernel feature space. t-SNE is the tool for 2-D visualization of nonlinear neighborhood structure. If the goal is to separate labeled classes, rather than to capture raw variance, use the supervised counterpart, Linear Discriminant Analysis. It projects the data toward class separation instead of toward total spread.

2.11. Kernel PCA

KernelPCA runs PCA in an implicit feature space. It does not decompose the covariance of your data. Instead, it decomposes a centered kernel (Gram) matrix. This finds nonlinear structure that a linear projection cannot see.

Kernel PCA works with pairwise kernel values between samples. Its working object is an n x n matrix, not a d x d matrix. This fact alone drives the memory cost, the solver choice, and the main limitation: Kernel PCA has no inverse_transform.

This page is based on src/machine_learning/decomposition/kernel_pca.rs, the shared kernel types in src/machine_learning/types.rs, and the integration tests in tests/machine_learning/kernel_pca.rs.

The kernel machinery, the KernelType enum and the Gamma coefficient, is the same code that drives Support Vector Machines. Kernel PCA reuses this code unchanged. It does not define its own kernel types.

2.11.1. The kernel trick, and why plain PCA cannot see rings

PCA finds the directions of maximum variance in the input space. This works well when the structure is linear. It fails when the structure is not linear.

The classic failure case is 2 concentric rings. The inner ring has radius 0.5. The outer ring has radius 3.0. Radius alone separates the 2 classes perfectly. No straight line separates them, so no linear projection can either.

Feed these points to PCA, and the leading components capture only the angular spread of the outer ring. The radial information that distinguishes the classes never appears.

The kernel trick maps each point x through a nonlinear feature map phi(x). This maps x into a much higher-dimensional space. Ordinary PCA then runs in that space. In this lifted space, the rings can become linearly separable.

Kernel PCA never builds phi(x) directly. This is the trick. PCA in the lifted space needs only inner products of the form phi(xi) * phi(xj). A kernel function K(xi, xj) computes this inner product directly, without building phi.

For the RBF kernel, K(x, y) = exp(-gamma * ||x - y||^2). Its implicit feature space has infinite dimensions. Each kernel value is still a single scalar, and you can compute it directly.

The RBF value depends only on the distance between 2 points. This encodes the radial structure that plain PCA discards. This is why the RBF kernel splits the 2 rings. 2.11.7 has a runnable example.

2.11.2. Double centering: the non-obvious core

PCA in the lifted space needs centered features: phi_centered(xi) = phi(xi) - (1/n) * sum_k phi(xk). You cannot subtract this mean directly, because you never have phi in hand.

The object Kernel PCA actually decomposes is the matrix of inner products of the centered features. You can write these inner products entirely in terms of the raw kernel matrix K. Expanding phi_centered(xi) * phi_centered(xj) gives the double-centering identity:

Kc[i, j] = K[i, j] - row_means[i] - row_means[j] + overall_mean

row_means[i] is the mean of row i of K. overall_mean is the mean of the whole matrix. In matrix form, this is Kc = H * K * H. H is the centering matrix H = I - (1/n) * J. I is the identity matrix, and J is the n x n all-ones matrix.

This is called double centering because H applies on both sides of K. It subtracts the row mean and the column mean, then adds the grand mean back, so the formula does not remove it twice. A custom Kernel PCA implementation often misses that + overall_mean term. Missing it biases every projection.

fit implements this. It computes the per-row means and the overall mean of the training kernel matrix (kernel_means). It then rewrites each entry in place as K[i,j] - row_mean[i] - row_mean[j] + overall_mean (center_kernel_matrix).

H * K * H has a direct consequence. Every row of Kc sums to zero, so every column of the resulting projection has a mean of zero. The test test_centering_training_output_has_near_zero_column_means checks that each projected component averages within 1e-9 of zero.

New points need a different, asymmetric formula. Many naive implementations give up here and refuse out-of-sample transforms.

When you project a new sample, its cross-kernel row against the training set needs the training statistics, not its own. center_cross_kernel_matrix subtracts the training row means and the mean of the new row itself, then adds the training overall mean. RustyML implements this, so transform on unseen data works correctly. See 2.11.6.

2.11.3. Constructing the estimator

The constructor takes the kernel and the number of components. It validates both and returns a Result:

pub fn new(kernel: KernelType, n_components: usize) -> Result<Self, Error>
pub fn with_eigen_solver(self, eigen_solver: EigenSolver) -> Self
ParameterTypeMeaning
kernelKernelTypeThe kernel function and its parameters. RustyML validates it up front. See 2.11.4.
n_componentsusizeHow many leading components to keep. Must be > 0. At fit time, it must also be <= n_samples.

n_components == 0 gets rejected right away, with Error::InvalidParameter naming the field. The relationship n_components <= n_samples cannot be checked at construction time, because the sample count is not known yet. fit enforces it instead, again with Error::InvalidParameter. A failed fit leaves the model untouched. The test test_fit_n_components_greater_than_n_samples_returns_invalid_parameter confirms the fitted-state getters stay None after a failed fit. You never end up with a half-mutated estimator.

The eigen solver defaults to EigenSolver::Dense. Set it with the chaining builder with_eigen_solver. The Default implementation gives you an RBF kernel with gamma = 0.1, n_components = 2, and the dense solver:

use rustyml::machine_learning::decomposition::kernel_pca::{EigenSolver, KernelPCA};
use rustyml::machine_learning::{Gamma, KernelType};
use ndarray::array;

fn main() {
    // 6 points in 2-D. RBF kernel, keep 2 components.
    let x = array![
        [1.0, 0.0],
        [0.0, 1.0],
        [-1.0, 0.0],
        [0.0, -1.0],
        [2.0, 0.5],
        [-0.5, 2.0],
    ];

    let mut kpca = KernelPCA::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 2)
        .unwrap()
        .with_eigen_solver(EigenSolver::Dense);

    let projected = kpca.fit_transform(&x).unwrap();
    assert_eq!(projected.nrows(), 6);
    assert_eq!(projected.ncols(), 2);

    // Fitted state is exposed through getters.
    println!("kept {} components", kpca.get_n_components());
    println!("training samples: {:?}", kpca.get_n_samples()); // Some(6)
    let eigenvalues = kpca.get_eigenvalues().unwrap();
    println!("leading eigenvalue: {}", eigenvalues[0]);
}

The getters mirror the internal state. get_kernel, get_n_components, and get_eigen_solver return by value. get_n_samples and get_n_features return Option<usize> (None before fitting). get_eigenvalues and get_eigenvectors return Option<&Array1<f64>> and Option<&Array2<f64>>.

The stored eigenvectors are the columns from the centered kernel matrix’s eigendecomposition. These are the per-sample coefficients, traditionally written alpha, with shape n_samples x n_components. They are not the input-space directions that PCA gives you. Kernel PCA has no meaningful loading vector to inspect. This is the flip side of working in an implicit space.

2.11.4. Kernels and choosing gamma

KernelType has 5 variants, shared with SVC:

VariantFormulaParameters
LinearK(x, y) = x*ynone
Poly { degree, gamma, coef0 }(gamma*x*y + coef0)^degreedegree: u32 (> 0), gamma: Gamma, coef0: f64
RBF { gamma }`exp(-gamma*
Sigmoid { gamma, coef0 }tanh(gamma*x*y + coef0)gamma: Gamma, coef0: f64
Cosine`(x*y) / (

Linear reduces Kernel PCA back to ordinary PCA, up to the centering convention. Use it only as a baseline. RBF is the default. Use RBF when you suspect nonlinear, distance-based structure, like the rings.

Poly captures polynomial interactions. Cosine normalizes away magnitude and keeps only direction. This helps with high-dimensional sparse data.

Sigmoid is not a true (Mercer) kernel. Its centered Gram matrix can be indefinite. This interacts with the eigenvalue handling in 2.11.6.

Constructor validation is strict, and specific to each kernel. Poly requires degree > 0, a positive finite gamma, and a finite coef0. RBF requires a positive finite gamma. Sigmoid only requires its parameters to be finite. gamma = 0 is accepted for Sigmoid (test test_new_sigmoid_gamma_zero_accepted), because a zero coefficient is a legitimate, if degenerate, sigmoid. Any violation returns Error::InvalidParameter, naming the field.

The gamma coefficient has type Gamma. It is either an explicit value or a data-dependent rule resolved at fit time:

Gamma variantResolves toUse when
Gamma::Value(v)vYou have a specific bandwidth in mind.
Gamma::Scale1 / (n_features * Var(X))A default that adapts to feature spread (scikit-learn’s 'scale').
Gamma::Auto1 / n_featuresA simpler 1/d rule (scikit-learn’s 'auto').

fit resolves Scale and Auto once, using the training data’s variance and feature count. It stores the resolved value, so the training matrix and every later transform call use the same coefficient. Gamma::Scale fails with Error::InvalidInput if the data has zero variance (all-constant features), because the formula divides by it.

For the RBF kernel, gamma is an inverse squared bandwidth: gamma = 1 / (2 * sigma^2). A large gamma (small bandwidth) makes the kernel see only near neighbors. The Gram matrix approaches the identity matrix, every point looks maximally distinct, and the projection overfits noise. A small gamma (large bandwidth) makes every pair look similar. The Gram matrix approaches a constant matrix, and the leading component captures nothing.

The useful range sits where the typical value of gamma * ||x - y||^2, for nearby points, is close to 1. A practical starting point is Gamma::Scale. From there, decrease gamma if the projection looks like noise. Increase gamma if distinct clusters merge together. Kernel PCA is unsupervised, so it has no built-in cross-validation for this choice. The concentric-rings separability metric in the test suite (class_separability) is the kind of downstream signal you can tune against.

2.11.5. Eigen solvers: exact versus iterative

EigenSolver selects how RustyML extracts the top n_components eigenpairs of the centered kernel matrix. All 3 solvers are pure-Rust, in-house implementations. The crate does not depend on a second linear-algebra library for this step (nalgebra remains only a dev-dependency, for test cross-checks).

VariantStrategyBest for
Dense (default)Full symmetric eigendecomposition (Householder tridiagonalization, then implicit-shift QL), the classic EISPACK/JAMA algorithm pair. Takes the leading pairs after the full decomposition.Small to mid-sized kernel matrices, where you can afford the full O(n^3) decomposition.
LanczosKrylov-subspace iteration with full reorthogonalization. Reduces the problem to a small tridiagonal problem and solves that exactly with the same dense solver.A few leading components of a large kernel matrix.
PowerIterationPower iteration with Hotelling deflation, one component at a time.The simplest iterative option. Use it when Lanczos is more than you need.

The solver choice affects speed and the numerical path, not the result. Dense, Lanczos, and PowerIteration agree on the leading eigenvalues. They produce projections that are identical up to a per-column sign flip. The tests confirm this directly: test_eigensolver_dense_vs_lanczos_agree and test_eigensolver_dense_vs_power_iteration_agree compare column norms (sign-agnostic) and the top eigenvalue, to tolerances of 1e-5 and 1e-4. The sign ambiguity comes from the eigenvectors themselves, not from a bug. Fix the sign yourself downstream if you need a canonical one.

The iterative solvers do not save memory. All 3 solvers operate on the same n x n centered kernel matrix, which must exist in full before any decomposition starts. Lanczos and PowerIteration skip the O(n^3) cost of a full decomposition, when n_components is far smaller than n_samples. Neither one avoids the O(n^2) matrix itself. 2.11.8 covers that limit.

// A few components from a large kernel matrix: skip the full O(n^3) decomposition.
let kpca = KernelPCA::new(KernelType::RBF { gamma: Gamma::Scale }, 3)
    .unwrap()
    .with_eigen_solver(EigenSolver::Lanczos);

Kernel PCA carries no randomness in any solver. There is no seed to set. 2 runs on the same data and machine produce bit-identical output. The test test_determinism_dense_solver asserts exact equality (assert_allclose(..., 0.0)). This differs from t-SNE, whose stochastic initialization needs seeding.

2.11.6. Fitting, transforming, and out-of-sample projection

The 3 entry points are inherent methods. KernelPCA also implements the crate’s Fit, Transform, and FitTransform traits. These traits forward to the inherent methods, so generic code can treat KernelPCA like PCA:

pub fn fit<S>(&mut self, x: &ArrayBase<S, Ix2>) -> Result<&mut Self, Error>
pub fn transform<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array2<f64>, Error>
pub fn fit_transform<S>(&mut self, x: &ArrayBase<S, Ix2>) -> Result<Array2<f64>, Error>

fit needs at least 2 samples. 1 row returns Error::InvalidInput. 0 rows return Error::EmptyInput. Non-finite input returns Error::NonFinite.

fit resolves gamma, builds and centers the training kernel matrix, extracts the eigenpairs, and stores everything a later transform needs. This includes a copy of the full training matrix, which every transform call reuses.

transform projects any matrix with the same feature count as the training data, including data the model has never seen. It builds the cross-kernel matrix between the new points and the stored training samples. It centers this matrix with the training statistics (see 2.11.2), then projects it onto the stored eigenvectors.

The projected coordinate on component k is (Kc * v_k) / sqrt(lambda_k). This 1/sqrt(lambda) scaling turns raw eigenvectors into properly normalized principal components. Calling transform before fit returns Error::NotFitted. A feature-count mismatch returns Error::DimensionMismatch.

use rustyml::machine_learning::decomposition::kernel_pca::KernelPCA;
use rustyml::machine_learning::{Gamma, KernelType};
use ndarray::array;

fn main() {
    let x_train = array![
        [1.0, 0.0],
        [0.0, 1.0],
        [-1.0, 0.0],
        [0.0, -1.0],
        [2.0, 0.5],
        [-0.5, 2.0],
        [1.5, -1.5],
        [-2.0, 1.0],
    ];

    // Default solver is Dense. No builder call needed.
    let mut kpca = KernelPCA::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 2).unwrap();
    kpca.fit(&x_train).unwrap();

    // Points the model has never seen, same feature count.
    let x_new = array![[0.3, 0.3], [1.8, -1.2]];
    let projected_new = kpca.transform(&x_new).unwrap();
    assert_eq!(projected_new.nrows(), 2);
    assert_eq!(projected_new.ncols(), 2);
    println!("{projected_new:?}");
}

fit_transform is a shortcut. It fits the model, then transforms the same data. The test test_fit_transform_equals_fit_then_transform confirms it matches the 2-call path to 1e-10.

fit_transform is a convenience, not an optimization. Internally, it just calls fit, then transform, on the same matrix. transform rebuilds the kernel matrix from scratch either way, so the cost is the same as calling both separately. Use the 2-step form when you need to project a different matrix through an already-fitted model.

For a proper Mercer kernel on distinct points, the centered Gram matrix is positive semidefinite. Every kept eigenvalue is strictly positive (test test_eigenvalues_are_positive_after_fit also confirms they come out sorted in descending order).

Kernel PCA does not hard-fail on non-positive eigenvalues, though. A centered Gram matrix is only positive semidefinite up to round-off error. Non-Mercer kernels, like Sigmoid, can produce genuinely negative trailing eigenvalues. fit only rejects non-finite eigenvalues (NaN or Inf maps to Error::Computation), instead of rejecting the whole fit. Any component whose eigenvalue is not meaningfully positive, below a relative 1e-12 * lambda_max threshold, gets a projection scale of 0.0. This zeroes that column instead of producing Inf or NaN.

This keeps the requested n_components dimensionality, while quietly discarding degenerate directions. The test test_fit_indefinite_kernel_negative_eigenvalue_is_tolerated drives this path with a Sigmoid kernel. It checks that the offending column comes out all zeros, and the rest stays finite.

2.11.7. Worked example: separating concentric rings

This is the case plain PCA cannot handle. There are 2 rings, radially separable but linearly entangled. Run the RBF kernel, and the 2 classes land in distinguishable regions of component space:

use rustyml::machine_learning::decomposition::kernel_pca::KernelPCA;
use rustyml::machine_learning::{Gamma, KernelType};
use ndarray::Array2;
use std::f64::consts::PI;

fn main() {
    // Inner ring r = 0.5, outer ring r = 3.0. No line separates them.
    let n = 12;
    let mut data: Vec<f64> = Vec::new();
    for i in 0..n {
        let a = 2.0 * PI * i as f64 / n as f64;
        data.push(0.5 * a.cos());
        data.push(0.5 * a.sin());
    }
    for i in 0..n {
        let a = 2.0 * PI * i as f64 / n as f64;
        data.push(3.0 * a.cos());
        data.push(3.0 * a.sin());
    }
    let x = Array2::from_shape_vec((2 * n, 2), data).unwrap();

    let mut kpca = KernelPCA::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 2).unwrap();
    let proj = kpca.fit_transform(&x).unwrap();

    // The RBF kernel encodes radial distance, so the rings separate along a component.
    let inner_mean: f64 = (0..n).map(|i| proj[[i, 0]]).sum::<f64>() / n as f64;
    let outer_mean: f64 = (n..2 * n).map(|i| proj[[i, 0]]).sum::<f64>() / n as f64;
    println!("inner-ring mean of component 0: {inner_mean:.4}");
    println!("outer-ring mean of component 0: {outer_mean:.4}");
    println!("gap between ring means: {:.4}", (inner_mean - outer_mean).abs());
}

Swap KernelType::RBF { .. } for KernelType::Linear, and the gap between the ring means collapses. The linear projection is dominated by the outer ring’s angular variation. It never encodes the radius. The test test_rbf_separates_radial_clusters_better_than_linear makes this quantitative, with a Fisher-style separability score. It asserts that the RBF projection beats the linear one by a comfortable margin.

2.11.8. What Kernel PCA cannot do

Kernel PCA has no inverse_transform. Plain PCA has one. You can map a low-dimensional code back to the input space, because the projection is a linear map with a clean transpose.

Kernel PCA cannot do this. This is not an oversight. It is the pre-image problem. A projected point lives in the implicit feature space. To invert it, you need an input x whose feature map phi(x) lands at that location.

For most kernels, RBF above all, the feature map is nonlinear, infinite-dimensional, and not surjective. An arbitrary point in feature space usually has no exact pre-image. It has only approximate ones, found through a separate nonlinear optimization.

RustyML does not ship that approximation. Kernel PCA is strictly a forward, one-way projection. Use it for visualization, for denoising by projection, or as a nonlinear feature stage that feeds a downstream classifier. Do not use it for reconstruction.

The Gram matrix is O(n^2). That is the real ceiling. fit builds an n x n matrix of f64 values. Memory grows as 8 * n^2 bytes, regardless of feature count. This is roughly 800 MB at n = 10,000, and 3.2 GB at n = 20,000.

Time is worse. Building the matrix is O(n^2 * d), through a single parallel GEMM. The dense eigendecomposition is O(n^3). Switching to Lanczos or PowerIteration trims the decomposition cost, when you need only a handful of components. Nothing removes the O(n^2) matrix itself.

In practice, Kernel PCA is comfortable into the low thousands of samples. It starts to hurt in the tens of thousands. Past that point, subsample a representative set to fit on, then transform the rest (each new batch pays O(m * n * d) for this). Or use a method that never forms the full kernel matrix.

Every transform call also carries the training set with it. The projection is defined relative to the stored training samples, so transform rebuilds an m x n cross-kernel matrix for m new points. PCA’s transform cost is independent of the training size. Kernel PCA’s transform cost scales with n forever. Budget for it.

The parallel machinery starts automatically above internal size gates, keyed on the element count of the kernel matrix. Roughly, the centering scans parallelize once n^2 clears a few hundred thousand elements. The elementwise centering parallelizes once it clears a few million elements. The kernel GEMM has its own FLOPs gate. You do not configure any of this per call. See 7.3. Performance Tuning and Parallelism for the tunable gates.

2.11.9. Persistence

KernelPCA derives Serialize and Deserialize, and exposes the standard pair:

pub fn save_to_path(&self, path: &str) -> Result<(), Error>
pub fn load_from_path(path: &str) -> Result<Self, Error>

Serialization uses the compact postcard binary format. The .bin, .dat, or any other extension in the path is just a filename. The bytes are always binary.

A round-tripped model reproduces transform output exactly. The test test_save_load_round_trip asserts equality to 1e-12.

Check what gets serialized. A fitted Kernel PCA stores the entire training matrix, along with the eigenvectors and the centering statistics, because transform needs all of it. The saved file grows with your training set. This is another consequence of the same O(n^2)/stored-samples design. Remember this before you persist a model fit on a large corpus.

use rustyml::machine_learning::decomposition::kernel_pca::KernelPCA;
use rustyml::machine_learning::{Gamma, KernelType};
use ndarray::array;
use std::fs;

fn main() {
    let x = array![
        [1.0, 0.0],
        [0.0, 1.0],
        [-1.0, 0.0],
        [0.0, -1.0],
        [2.0, 0.5],
        [-0.5, 2.0],
        [1.5, -1.5],
        [-2.0, 1.0],
    ];

    let mut kpca = KernelPCA::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 2).unwrap();
    kpca.fit(&x).unwrap();
    let before = kpca.transform(&x).unwrap();

    let path = "kpca_model.bin";
    kpca.save_to_path(path).unwrap();
    let loaded = KernelPCA::load_from_path(path).unwrap();
    let after = loaded.transform(&x).unwrap();

    assert_eq!(before.shape(), after.shape());
    fs::remove_file(path).unwrap();
}

A missing file surfaces as Error::Io (test test_load_from_nonexistent_path_returns_io_error). For the general error taxonomy, see 1.6. Error Handling. For persistence patterns across the crate, see 7.2. Model Persistence in Depth.

2.12. t-SNE

TSNE is RustyML’s implementation of t-distributed Stochastic Neighbor Embedding (t-SNE). It is a common method to turn high-dimensional data into a 2-D or 3-D plot. TSNE lives in machine_learning::manifold. Unlike the linear reducers in 2.10. Principal Component Analysis and 2.11. Kernel PCA, it learns no reusable projection. It embeds only the points you give it.

If you know scikit-learn, this is sklearn.manifold.TSNE. The API mirrors it on purpose. It exposes a perplexity, a learning_rate, an n_iter, a choice between an exact and a Barnes-Hut gradient, and PCA or random initialization.

2.12.1. What t-SNE is for, and what it is not

t-SNE is a visualization tool. Its only job is to place similar high-dimensional points near each other on a low-dimensional canvas. This lets a human eye see the cluster structure in the data. Most misuse of t-SNE comes from treating it as a general-purpose dimensionality reducer. It is not one.

The design makes this restriction concrete. In the estimator traits, PCA and Kernel PCA implement both Transform (project new, unseen data through a fitted model) and FitTransform. TSNE implements only FitTransform. It has no transform method, no stored fitted state, and no way to add a point to an existing embedding. The embedding coordinates are the optimization variables themselves, not the output of a learned function.

Use PCA to project new samples later. Use t-SNE to plot the data you already have. The 2 tools complement each other. A common pipeline runs PCA first and feeds the result to t-SNE (see 2.12.9).

The inherent method takes &self and returns a fresh embedding, so it never mutates the model. You can call it directly, or through the FitTransform trait. The trait method takes &mut self. It just forwards to the inherent method.

2.12.2. How t-SNE works

t-SNE measures similarity twice. It measures once in the original space and once in the embedding. Then it moves the embedding until the two agree.

In the high-dimensional space, t-SNE builds a set of pairwise affinities. For each point i, it centers a Gaussian on that point. It converts squared distances to the neighbors of i into conditional probabilities p_{j|i}. Near points get a high probability. Far points get a probability close to zero.

The width of the Gaussian is not fixed. RustyML solves it per point, with a binary search over sigma. The search matches the entropy of each point’s neighbor distribution to a target you set. That target is the perplexity. Perplexity reads as an effective neighbor count, roughly how many neighbors each point should feel.

RustyML runs the search for up to 50 bisection steps, to a tolerance of 1e-5. The self-distance always maps to exactly zero, so a point is never its own neighbor. The conditional probabilities are then symmetrized into joint probabilities p_ij. These joint probabilities sum to 1 over all pairs.

In the embedding, t-SNE uses a different, heavier-tailed kernel. This kernel is the Student-t distribution with 1 degree of freedom, where q_ij is proportional to (1 + ||y_i - y_j||^2)^-1. This is the trick that gives the “t” its name, and it fixes the crowding problem. A Gaussian in 2-D cannot make room for all the points that were moderately distant neighbors in, say, 50-D.

There is simply not enough area, and everything collapses into a blob. The t-distribution’s fat tail lets moderately similar points sit far apart in the map, without paying much probability cost. Clusters separate cleanly instead of crushing together.

The 2 distributions are matched by minimizing the Kullback-Leibler divergence KL(P || Q), with gradient descent on the embedding coordinates. KL divergence is asymmetric on purpose. It heavily penalizes placing a high-p (truly near) pair far apart in the map. It barely penalizes placing a low-p (truly far) pair close together. That asymmetry is why t-SNE preserves local neighborhoods faithfully, and treats global distances as expendable. It also drives all the plot-reading caveats in 2.12.8.

2.12.3. Constructing a model

TSNE::new takes the 4 core hyperparameters. It validates them up front. On a bad value, it returns Error::InvalidParameter instead of failing later at fit time.

use ndarray::array;
use rustyml::machine_learning::manifold::t_sne::{TSNE, TSNEMethod};

fn main() {
    // Two loose groups of points in 3-D feature space.
    let x = array![
        [0.0, 0.0, 0.0],
        [0.2, 0.1, -0.1],
        [-0.1, 0.2, 0.1],
        [0.1, -0.2, 0.0],
        [5.0, 5.0, 5.0],
        [5.2, 4.9, 5.1],
        [4.8, 5.1, 4.9],
        [5.1, 5.0, 4.8],
    ];

    // new(n_components, perplexity, learning_rate, n_iter) -> Result<TSNE, Error>.
    let tsne = TSNE::new(2, 3.0, 200.0, 300)
        .unwrap()
        .with_method(TSNEMethod::Exact)
        .unwrap();

    // fit_transform takes &self and returns an (n_samples, n_components) array.
    let embedding = tsne.fit_transform(&x).unwrap();
    assert_eq!(embedding.shape(), &[8, 2]);
    println!("embedding shape: {:?}", embedding.shape());
}

The constructor’s validation rules are narrow but strict:

ParameterTypeConstraintOn violation
n_componentsusizegreater than 0 (use 2, or 3 for a rotatable plot)InvalidParameter
perplexityf64strictly positive and finiteInvalidParameter
learning_ratef64strictly positive and finiteInvalidParameter
n_iterusizegreater than 0InvalidParameter

TSNE::default() gives new(2, 30.0, 200.0, 1000), with PCA initialization and Barnes-Hut. This is a reasonable starting point for a real dataset, not a toy-sized one. The builder methods set everything else. Each builder returns Self for chaining, except with_method. with_method returns Result, because Barnes-Hut must validate its angle and its dimensionality:

BuilderSetsDefault
with_method(TSNEMethod) returns Resultexact or Barnes-Hut gradientBarnes-Hut when n_components is 3 or less, else Exact
with_init(Init)Init::PCA or Init::RandomInit::PCA
with_random_state(u64)seed for the random-init pathNone
with_min_grad_norm(f64)early-stopping gradient threshold1e-7

Every stored field has a matching getter: get_n_components, get_perplexity, get_learning_rate, get_n_iter, get_random_state, get_init, get_method, and get_min_grad_norm. Use these to check how a chain of builder calls resolved.

2.12.4. Exact versus Barnes-Hut

TSNEMethod sets the cost tradeoff. RustyML’s default is not the exact method. This differs from what many users assume.

TSNEMethod::Exact builds the full dense n x n joint-probability matrix. It computes the gradient over every pair on each iteration. This costs O(n^2) per step, in both time and memory. TSNEMethod::Exact supports any n_components.

TSNEMethod::BarnesHut { angle } keeps the affinities sparse. Each point talks only to its k = ceil(3 * perplexity) + 1 nearest neighbors. It summarizes the repulsive forces with a space-partitioning tree. This gives roughly O(n log n) per iteration.

The angle (theta) is in [0, 1) and trades accuracy for speed. A larger angle opens tree cells sooner, so it runs faster but coarser. The value 0.5 is the standard balance. The tree lives in the embedding space, so Barnes-Hut supports only n_components of 3 or less.

TSNE::new picks Barnes-Hut with angle = 0.5 whenever n_components is 3 or less. This covers every visualization case. Otherwise it falls back to Exact. with_method enforces the same constraints. It rejects an angle outside [0, 1) with InvalidParameter. It also rejects Barnes-Hut paired with more than 3 components, with the same error.

use ndarray::array;
use rustyml::machine_learning::{TSNE, TSNEMethod};

fn main() {
    let x = array![
        [0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0],
        [8.0, 8.0], [9.0, 8.0], [8.0, 9.0], [9.0, 9.0],
    ];

    // Default for 2 components: Barnes-Hut with angle 0.5.
    let bh = TSNE::new(2, 3.0, 200.0, 300).unwrap();
    assert_eq!(bh.get_method(), TSNEMethod::BarnesHut { angle: 0.5 });

    // Coarser, faster tree.
    let coarse = TSNE::new(2, 3.0, 200.0, 300)
        .unwrap()
        .with_method(TSNEMethod::BarnesHut { angle: 0.8 })
        .unwrap();

    // Exact O(n^2) gradient, the only option once n_components > 3.
    let exact = TSNE::new(2, 3.0, 200.0, 300)
        .unwrap()
        .with_method(TSNEMethod::Exact)
        .unwrap();

    for model in [bh, coarse, exact] {
        let emb = model.fit_transform(&x).unwrap();
        assert_eq!(emb.ncols(), 2);
    }
}

The Barnes-Hut gradient is scaled to match the exact path. The factor of 4 is folded in. The same learning_rate fits both methods, so you can switch between them without retuning. Both methods are bit-reproducible. The tree build is deterministic. The normalizer is summed in a fixed order, so the result does not depend on thread scheduling.

2.12.5. Perplexity and the sample-count rules

Perplexity is the hyperparameter that most changes the shape of your plot. It sets the effective neighbor count that each point calibrates its Gaussian to. A low perplexity emphasizes local structure and fractures the data into many small islands. A high perplexity blends larger neighborhoods and can smear distinct clusters together.

Values from 5 to 50 cover almost every use. The right value rises as your dataset grows.

There are 2 limits on perplexity, and they differ. fit_transform, not TSNE::new, enforces the first one. perplexity must be strictly less than the number of samples, or fit_transform returns InvalidParameter.

The second limit is about Barnes-Hut sparsity, not correctness. The Barnes-Hut path keeps k = ceil(3 * perplexity) + 1 neighbors per point, capped at n - 1. Some t-SNE implementations reject a perplexity above roughly n / 3 as an error. RustyML does not. Past that point, the neighbor cap has already saturated at n - 1, so every point already lists every other point as a neighbor. The embedding stays correct, but Barnes-Hut then loses its usual speed advantage over Exact for that data.

The enforced perplexity < n check is the one that matters, and it is the same for both methods. This is also why t-SNE on a handful of points gives little insight. With 20 samples, the enforced rule already caps perplexity below 20. A run near that ceiling has little real neighbor structure to find, and the clusters in the plot are mostly noise.

use ndarray::array;
use rustyml::error::Error;
use rustyml::machine_learning::{TSNE, TSNEMethod};

fn main() {
    let x = array![[0.0, 0.0], [1.0, 1.0], [2.0, 0.5]]; // 3 samples

    // perplexity must be < n_samples.
    // Here 3.0 is not < 3.
    let model = TSNE::new(2, 3.0, 200.0, 100)
        .unwrap()
        .with_method(TSNEMethod::Exact)
        .unwrap();

    match model.fit_transform(&x) {
        Err(Error::InvalidParameter { .. }) => {
            println!("perplexity too large for this sample count");
        }
        other => panic!("expected InvalidParameter, got {other:?}"),
    }
}

2.12.6. Initialization, seeding, and reproducibility

Init chooses the starting point for optimization. This choice affects reproducibility.

Init::PCA is the default. It starts the embedding from the top principal components of the input, rescaled to a small spread. When PCA succeeds, this method is deterministic and ignores random_state. The same data always gives the same starting layout, and so the same final embedding. It also gives the optimizer a well-spread starting layout. This is why Init::PCA is the default.

PCA init can fail in 2 cases. The input has fewer features than n_components, or its leading component is degenerate (zero spread). When either happens, Init::PCA falls back to the same random init that Init::Random uses. That fallback does consult random_state (or the global seed), so determinism returns with it.

Init::Random starts from tiny random noise. It seeds this noise through the crate’s central RNG. Apart from the PCA fallback above, this is the only path that consults random_state. Set a seed with with_random_state, and the run becomes bit-reproducible. Leave it unset, and the start comes from entropy, so every run differs.

use ndarray::array;
use rustyml::machine_learning::{Init, TSNE, TSNEMethod};
use rustyml::set_global_seed;

fn main() {
    let x = array![
        [0.0, 0.0], [1.0, 0.5], [0.5, 1.0],
        [6.0, 6.0], [7.0, 6.5], [6.5, 7.0],
    ];

    // Explicit per-model seed: reproducible regardless of any global seed.
    let seeded = TSNE::new(2, 2.0, 200.0, 250)
        .unwrap()
        .with_init(Init::Random)
        .with_random_state(42)
        .with_method(TSNEMethod::Exact)
        .unwrap();
    let e1 = seeded.fit_transform(&x).unwrap();
    let e2 = seeded.fit_transform(&x).unwrap();
    assert_eq!(e1, e2); // bit-identical

    // An unseeded random-init model becomes reproducible only because
    // this code sets the thread-local global seed first.
    set_global_seed(7);
    let unseeded = TSNE::new(2, 2.0, 200.0, 250)
        .unwrap()
        .with_init(Init::Random)
        .with_method(TSNEMethod::Exact)
        .unwrap();
    let _emb = unseeded.fit_transform(&x).unwrap();
}

The random_state field feeds make_rng. Every randomized component in the crate resolves its seed through this same path. An explicit Some(seed) is independent and never touches the global stream. A None seed defers to whatever set_global_seed set on the current thread.

This has 2 practical effects. With the default PCA init, the seed does not matter. Do not expect with_random_state to change anything, unless you also switch to Init::Random. The parallel reductions in the optimizer are also order-stable. Because of this, a fixed configuration reproduces bit for bit on a given machine, no matter the thread count. Chapter 7.1. Reproducibility and Random Seeds covers the full mechanics, including the thread-local nature of the global seed.

2.12.7. Inside the optimizer

The gradient descent loop is not plain SGD. Its phases explain most of what n_iter and learning_rate do.

The first 250 iterations run under early exaggeration (or all iterations, if n_iter is smaller). During this phase, t-SNE multiplies every joint probability p_ij by 12. This inflates the attractive forces. Tight clusters pull together and carve out empty space between groups before the layout settles. When the phase ends, the exaggeration factor drops back to 1, and the map fine-tunes.

If n_iter is too low, the run may stop inside the exaggeration phase. This leaves an over-contracted embedding that has not yet fine-tuned. This is one reason the defaults use 1000 iterations. Real runs rarely want fewer than a few hundred iterations.

Momentum follows the same schedule. It is 0.5 during exaggeration and 0.8 afterward. This gives the refinement phase more inertia through the flat parts of the KL landscape. Each coordinate also has an adaptive gain (Jacobs’ delta-bar-delta). This gain grows when the gradient keeps its sign, and decays when the step oscillates. The gain is floored at 0.01.

So learning_rate sets only a base step size. The effective per-parameter rate adapts as the run proceeds. After every step, the embedding is re-centered on the origin, to stop it from drifting. This is why the output columns always come back with a mean near zero.

min_grad_norm controls early stopping. After the exaggeration phase ends, the loop checks the largest absolute gradient entry on each iteration. It stops as soon as that value drops below the threshold (the default is 1e-7). This saves iterations once the map has converged. The loop skips this check during exaggeration, because the inflated gradients there would never trip it. Set min_grad_norm to 0.0 to disable early stopping and always run the full n_iter.

// The full builder surface, for reference.
let tsne = TSNE::new(2, 30.0, 200.0, 1000)?   // n_components, perplexity, lr, n_iter
    .with_init(Init::PCA)                       // or Init::Random
    .with_random_state(0)                       // only affects Init::Random
    .with_method(TSNEMethod::Exact)?            // or BarnesHut { angle }
    .with_min_grad_norm(0.0);                   // disable early stopping

Enable the show_progress feature (1.2. Installation and Feature Flags) to make the loop compute and show the live KL divergence on each iteration. Without the feature, the loop skips that pass entirely. It costs nothing in a production build.

2.12.8. Reading a t-SNE plot

t-SNE optimizes local neighborhoods, and it discards global geometry. Because of this, the plot misrepresents several things. Read it with that in mind.

The distance between 2 clusters means nothing. 2 blobs that land far apart on the canvas are not more different than 2 blobs that land close together. The gap between clusters is an artifact of the KL objective and the optimizer’s random walk. It is not a measurement.

The same holds for cluster size. How spread out a cluster’s points look is not a measure of its true variance. Dense regions get inflated, and sparse regions get compressed, to keep local neighborhoods intact. Never treat a between-cluster distance or a cluster diameter from a t-SNE map as a quantitative result.

The embedding depends on initialization, and the optimization is non-convex. So a single run is only 1 sample from a distribution of possible layouts. Run several. Vary the seed under Init::Random, or sweep perplexity across values such as 5, 30, and 50. Trust only the structure that survives across runs. A cluster that appears at one perplexity and dissolves at another is a likely artifact, not a finding.

Do not feed t-SNE coordinates into a downstream model as features. They are a picture, not a reusable representation. There is no transform, so they do not apply to new data. They also change across runs, and carry no meaning as distances. For reusable, projectable features, use PCA instead.

A common and valid workflow runs the reverse order. Cluster in the original space, for example with KMeans. Then color a t-SNE plot by the cluster label, to check the clustering by eye.

2.12.9. Cost, scaling, and preprocessing wide data

Both methods pay an O(n^2) setup cost, once, up front. This setup computes pairwise distances and calibrates the per-point sigmas. Barnes-Hut still runs an O(n^2) neighbor search before its cheaper O(n log n) iterations start. So the per-iteration cost differs, O(n^2) for Exact against O(n log n) for Barnes-Hut. Neither method escapes the quadratic setup term. The exact path also needs a full n x n matrix in memory.

In round numbers, exact t-SNE stays comfortable into the low thousands of points. Beyond that, Barnes-Hut lets you push toward tens of thousands of points. Let the default method choice handle this.

The other lever is the width of your data. The distance computation costs O(n^2 * d), for d input features. High-dimensional Euclidean distances are also noisy. The standard remedy reduces wide data to about 50 dimensions with PCA first, then runs t-SNE on that. This helps both speed and quality.

This step differs from Init::PCA. Init::PCA only uses the top 2 or 3 components, to seed the starting layout. Here, PCA compresses the input itself, before t-SNE ever runs.

use ndarray::array;
use rustyml::machine_learning::{PCA, TSNE, TSNEMethod};

fn main() {
    // 10 samples, 8 raw features (imagine hundreds in a real problem).
    let x = array![
        [0.10, 0.21, 0.05, 0.30, 0.11, 0.02, 0.22, 0.14],
        [0.12, 0.19, 0.08, 0.28, 0.09, 0.05, 0.20, 0.10],
        [0.09, 0.23, 0.03, 0.31, 0.13, 0.01, 0.24, 0.16],
        [0.11, 0.20, 0.06, 0.29, 0.10, 0.03, 0.21, 0.12],
        [0.80, 0.70, 0.90, 0.10, 0.60, 0.85, 0.15, 0.75],
        [0.82, 0.68, 0.88, 0.12, 0.62, 0.83, 0.17, 0.73],
        [0.78, 0.72, 0.91, 0.09, 0.58, 0.87, 0.14, 0.77],
        [0.81, 0.69, 0.89, 0.11, 0.61, 0.84, 0.16, 0.74],
        [0.40, 0.45, 0.42, 0.55, 0.38, 0.47, 0.52, 0.44],
        [0.42, 0.43, 0.44, 0.53, 0.36, 0.49, 0.50, 0.46],
    ];

    // Step 1: compress with PCA (here to 4 dims, about 50 for wide data).
    let mut pca = PCA::new(4).unwrap();
    let reduced = pca.fit_transform(&x).unwrap();

    // Step 2: run t-SNE on the compact representation.
    let tsne = TSNE::new(2, 3.0, 200.0, 300)
        .unwrap()
        .with_method(TSNEMethod::Exact)
        .unwrap();
    let embedding = tsne.fit_transform(&reduced).unwrap();
    assert_eq!(embedding.shape(), &[10, 2]);
}

The precompute pass and the per-iteration passes switch to RustyML’s parallel primitives once the pairwise work is large enough. The GEMM calls parallelize on their own. The reductions stay in a fixed order, so the result does not depend on thread count. To tune throughput, see 7.3. Performance Tuning and Parallelism, which covers the gating thresholds.

2.12.10. Errors from fit_transform

Construction validates the 4 hyperparameters. The rest of the checks run at fit_transform time. All of them return the crate-wide Error type:

ConditionError variant
Zero rowsEmptyInput
A NaN or infinite entry in the inputNonFinite
Fewer than 2 samplesInvalidInput
perplexity not strictly less than the sample countInvalidParameter

Every failure is a typed Error, not a panic. You can match on the cause and react. Retry with a smaller perplexity, clean non-finite rows, or stop, as the example above shows.

2.13. Isolation Forest

IsolationForest is the unsupervised anomaly detector in RustyML.

Most anomaly detectors model where normal data lives, then flag points that fall outside that region. IsolationForest inverts this approach. It uses the fact that anomalies are few and different. A tree of random axis-aligned splits isolates an anomaly into its own leaf after only a few cuts. A point buried in a dense cluster needs many more cuts before the tree isolates it. RustyML averages this cut count over a forest of trees, then normalizes the result into an anomaly score.

This estimator matches sklearn.ensemble.IsolationForest, the same subsampling-based ensemble from Liu, Ting, and Zhou. The API now lines up with scikit-learn method for method. score_samples, decision_function, and predict mean what they mean in Python, including the sign of the scores. If you port code written against an older RustyML, read Section 2.13.4 first. Both the score sign and the method names changed there.

2.13.1. The isolation principle and the anomaly score

Each tree grows on a random subsample of the data. The build step repeats one simple move. It picks a feature at random. It picks a split threshold at random, between that feature’s minimum and maximum value in the current node. It sends points below the threshold to the left child and the rest to the right child.

The tree recurses until a node holds 1 point. It also stops when every value of the chosen feature is equal in that node, because there is nothing left to split. It stops too when it hits the depth cap. A point far from the bulk of the data needs only a handful of random cuts to isolate. A point in the middle of a dense cluster needs many more cuts.

The tree structure that records this stays minimal. An isolation tree does not need class counts or impurity. It only needs where it split and how many points reached each leaf:

pub enum IsolationTree {
    Leaf { size: usize },
    Internal {
        feature: usize,
        threshold: f64,
        left: Box<IsolationTree>,
        right: Box<IsolationTree>,
    },
}

The raw signal for a sample is its path length, h(x). This is the number of edges from the root to the leaf where the sample lands. A tree has a depth cap, so a leaf can still hold several points the tree did not fully separate. The path length adds a correction for the subtree that would have continued below that leaf. A leaf at depth d holding size points contributes d + c(size) to the path length.

c(n) estimates the average extra depth needed to isolate n points. c(n) is also the normalization constant. It equals the expected path length of a failed search in a binary search tree of n points:

c(n) = 2 * H(n-1) - 2 * (n-1) / n, where H(m) is the m-th harmonic number.

RustyML computes H(m) exactly, as a running sum, when n <= 50. Above that, it switches to the asymptotic form ln(m) + gamma + 1 / (2 * m). Here gamma is the Euler-Mascheroni constant, and m = n - 1. This approximation keeps the error under 1e-3. RustyML pins 2 edge cases: c(n) = 0 when n <= 1, and c(2) = 1. The final score for a sample averages h(x) over every tree, then applies Liu et al.’s formula, negated:

s(x) = -2^(-E[h(x)] / c(n)).

The score lands in the range [-1, 0). A lower score means more anomalous. This is the reverse of the earlier RustyML convention.

A short average path is easy to isolate, so it is anomalous. It drives the score toward -1. A long average path is hard to isolate, so it is normal. It drives the score toward 0.

The negation puts the score on the usual convention where negative marks the rejected class. It also makes score_samples numerically identical to scikit-learn’s version.

The neutral point is s = -0.5. At this point, a sample’s expected path length equals c(n), so its isolation cost matches the tree average. Liu et al. call this the “no distinct anomaly” regime. Contamination::Auto uses -0.5 as its cutoff. Treat -0.5 as no signal, not as confirmed normal. A point earns “normal” only when it scores clearly above -0.5.

The c(n) in the denominator uses n = sample_size. This is the realized per-tree subsample size, covered in the next 2 sections. It is not max_samples. This distinction matters when your dataset is smaller than max_samples, because normalizing by the wrong n would shift every score.

RustyML’s test suite checks the closed form directly. It fits identical points with max_samples >= n_rows. Every score comes out exactly -0.5, because E[h(x)] = c(sample_size) cancels the exponent.

2.13.2. Constructing the forest

IsolationForest has 2 entry points. IsolationForest::default() gives the standard configuration. IsolationForest::new(n_estimators, max_samples) sets the 2 structural parameters. It validates them and returns Result<Self, Error>. 3 builder methods refine the result further. Each one consumes and returns self, so you can chain them:

impl IsolationForest {
    pub fn new(n_estimators: usize, max_samples: usize) -> Result<Self, Error>;
    pub fn with_max_depth(self, max_depth: usize) -> Result<Self, Error>;
    pub fn with_random_state(self, seed: u64) -> Self;
    pub fn with_contamination(self, contamination: Contamination) -> Result<Self, Error>;
}
use rustyml::machine_learning::IsolationForest;

fn main() {
    // Standard config: 100 trees, subsample of 256 rows each,
    // depth auto = ceil(log2(256)) = 8, no seed.
    let _a = IsolationForest::default();

    // Explicit config, with a seed and a hand-set depth cap that overrides the auto value.
    let forest = IsolationForest::new(100, 256)
        .unwrap()
        .with_max_depth(10)
        .unwrap()
        .with_random_state(42);

    assert_eq!(forest.get_n_estimators(), 100);
    assert_eq!(forest.get_max_samples(), 256);
    assert_eq!(forest.get_max_depth(), 10);
    assert_eq!(forest.get_random_state(), Some(42));
}

new rejects n_estimators == 0 or max_samples == 0. It returns Error::InvalidParameter, naming the field that failed. with_max_depth(0) rejects the same way.

Note one asymmetry. An explicit depth of 0 is invalid. The auto-computed depth can still be 0. For example, max_samples == 1 gives ceil(log2(1)) = 0. This is fine, because a subsample of 1 point has nothing to split anyway.

ParameterSet byDefaultConstraint
n_estimatorsnew / default100greater than 0
max_samplesnew / default256greater than 0
max_depthauto or with_max_depthceil(log2(max_samples)) = 8greater than 0 if set explicitly
random_statewith_random_stateNone (entropy-seeded)any u64
contaminationwith_contaminationContamination::Autoa Fraction(c) must be finite and in (0.0, 0.5]

Every field has a getter. get_n_estimators, get_max_samples, get_max_depth, get_random_state (returns Option<u64>), and get_contamination work right after construction. 4 more getters make sense only after fitting: get_n_features, get_sample_size, get_offset (returns Option<f64>, the resolved score cutoff), and get_trees (returns Option<&Vec<IsolationTree>>, None until fitted).

Understand the auto depth before you override it. ceil(log2(max_samples)) estimates the height of a balanced tree over the subsample. Anomalies are roughly isolated by that height already. Splitting deeper mostly separates points inside dense normal clusters. The c(size) leaf correction already accounts for that effect.

A bigger max_depth buys almost no accuracy, and it costs more build time. Leave max_depth on auto, unless you have a specific reason to change it.

2.13.3. Subsampling, sample_size, and why 256

Isolation Forest makes an unusual design choice. Each tree trains on a small random subsample, not on the whole dataset. This is a feature, not a shortcut. RustyML draws sample_size = min(max_samples, n_rows) rows for each tree, without replacement, through a partial Fisher-Yates shuffle.

The default max_samples = 256 comes directly from the original paper. Small subsamples defeat 2 failure modes of density-based detectors. Swamping happens when normal points near a cluster of anomalies start to look anomalous. Masking happens when a dense clump of anomalies hides its own members from each other. Both problems get worse as each tree sees more data, because large samples let anomalies form their own mini-clusters. Those mini-clusters are no longer easy to isolate.

A 256-row subsample is large enough to profile the shape of the normal region. It is also small enough that anomalies stay sparse and stay easy to isolate. A bigger subsample mostly buys higher build cost and worse detection quality. That is why max_samples is a subsample budget, not a “use more data” knob.

This design has 2 consequences. First, when your dataset has fewer rows than max_samples, sample_size clamps to n_rows. Every tree then sees the whole dataset. The forest still works, because each tree still differs through its random splits. It just no longer subsamples. fit checks for this case and never panics.

Second, normalization uses c(sample_size), not c(max_samples). RustyML reads sample_size back from the fitted model instead of assuming max_samples. get_sample_size() reports the realized value after fit.

2.13.4. Fitting and reading scores

fit takes a 2-D f64 array, where rows are samples and columns are features. It returns Result<&mut Self, Error>. fit records n_features and sample_size, builds every tree, then resolves the contamination rule into a stored score cutoff, the offset. Scoring then exposes 4 surfaces. Each one shifts or thresholds the surface before it, in a chain:

pub fn fit<S>(&mut self, x: &ArrayBase<S, Ix2>) -> Result<&mut Self, Error>
where S: Data<Elem = f64> + Send + Sync;

pub fn score_samples<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<f64>, Error>
where S: Data<Elem = f64>;                  // 1 score per row, in [-1, 0). Lower means more anomalous

pub fn score_sample(&self, sample: &[f64]) -> Result<f64, Error>;   // 1 row, as a slice

pub fn decision_function<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<f64>, Error>
where S: Data<Elem = f64>;                  // score_samples(x) minus offset. Negative marks an outlier

pub fn predict<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<i32>, Error>
where S: Data<Elem = f64>;                  // sign of decision_function: {-1 outlier, +1 inlier}

Each name means exactly what it means in scikit-learn. score_samples gives the raw anomaly score. decision_function shifts that score so zero marks the decision boundary. predict gives the sign of the decision value. A strictly negative value gives -1. Everything else gives +1, so a sample that lands exactly on the cutoff counts as an inlier.

Pick the method the task needs. Use score_samples to rank samples, set your own cutoff, or feed a downstream calibrator. Use decision_function for a signed margin. Use predict for a hard {-1, +1} decision.

Earlier RustyML versions differ here in 2 ways. Both changes alter results silently, instead of failing to compile.

First, the scores are now negated. The old scale was [0, 1], where a higher score meant more anomalous. The new scale is [-1, 0), where a lower score means more anomalous. Flip every comparison that ranks or thresholds a score.

Second, the per-sample slice method is now called score_sample (singular). It replaces the old anomaly_score method. The old batch form, where predict returned scores, is gone. The old predict_labels(&x, contamination) method is gone too. score_samples replaces the first. predict, together with the fitted contamination rule, replaces the second.

score_sample scores one point at a time, for a live stream or an ad-hoc query. It takes a &[f64] slice instead of a matrix. fit_predict runs fit, then predict, on the same matrix. It returns labels, the standard “label the training set” convenience for unsupervised models.

use ndarray::array;
use rustyml::machine_learning::IsolationForest;

fn main() {
    let train = array![[0.0, 0.0], [0.1, 0.0], [0.0, 0.1], [0.1, 0.1], [30.0, 30.0]];
    let mut forest = IsolationForest::new(80, 32).unwrap().with_random_state(1);
    forest.fit(&train).unwrap();

    // Score points one at a time through the slice API.
    let normal = forest.score_sample(&[0.05, 0.05]).unwrap();
    let weird = forest.score_sample(&[30.0, 30.0]).unwrap();
    assert!(weird < normal); // lower score means more anomalous

    // The batch form, and the same values shifted by the fitted cutoff.
    let scores = forest.score_samples(&train).unwrap();
    let decision = forest.decision_function(&train).unwrap();
    let offset = forest.get_offset().unwrap(); // -0.5 under Contamination::Auto
    assert!((decision[0] - (scores[0] - offset)).abs() < 1e-12);
}

Every entry point validates its input and returns a typed Error. fit returns Error::EmptyInput on zero rows. fit returns Error::NonFinite on any NaN or infinite value. score_samples also checks Error::DimensionMismatch, when the feature count differs from training, and Error::NotFitted, before fit runs.

decision_function and predict propagate those same errors unchanged. score_sample checks only Error::NotFitted and Error::DimensionMismatch for its single slice. It does not check the slice for NaN or infinite values.

The batch calls accept any ArrayBase backing. This includes non-contiguous views, such as a transposed array. You do not need to copy .t() into a fresh buffer before scoring.

2.13.5. A worked example: injecting outliers into a blob

This is the standard sanity check for an anomaly detector. Build a tight cluster of inliers. Drop in a few points that clearly do not belong. Confirm that the forest ranks those points at the top.

use ndarray::Array2;
use rustyml::machine_learning::{Contamination, IsolationForest};

fn main() {
    // 20 inliers in a tight blob near (5, 5)...
    let mut rows: Vec<f64> = Vec::new();
    for i in 0..20 {
        let t = i as f64;
        rows.push(5.0 + 0.15 * t.sin());
        rows.push(5.0 + 0.15 * t.cos());
    }
    // ...plus 3 injected outliers far from the cluster.
    for &(x, y) in &[(30.0, 30.0), (-20.0, 40.0), (50.0, -10.0)] {
        rows.push(x);
        rows.push(y);
    }
    let n = rows.len() / 2; // 23 rows
    let data = Array2::from_shape_vec((n, 2), rows).unwrap();

    // A 15% contamination budget, resolved into a fixed cutoff at fit time.
    let mut forest = IsolationForest::new(100, 256)
        .unwrap()
        .with_random_state(42)
        .with_contamination(Contamination::Fraction(0.15))
        .unwrap();
    let labels = forest.fit_predict(&data).unwrap();
    let scores = forest.score_samples(&data).unwrap();

    // The 3 injected rows (indices 20..23) should score well below the blob.
    for i in (n - 3)..n {
        println!("outlier row {i}: score {:.3}", scores[i]);
    }

    let flagged: Vec<usize> = labels
        .iter()
        .enumerate()
        .filter(|&(_, &l)| l == -1)
        .map(|(i, _)| i)
        .collect();
    println!("cutoff (offset): {:.3}", forest.get_offset().unwrap());
    println!("flagged as outliers (-1): {flagged:?}");
}

The 3 outlier points land near the bottom of the score range. The blob rows sit close to zero:

outlier row 20: score -0.735
outlier row 21: score -0.779
outlier row 22: score -0.788
cutoff (offset): -0.419
flagged as outliers (-1): [2, 20, 21, 22]

Note what the cutoff is, and what it is not. Contamination::Fraction(0.15) sets the offset to the 15th percentile of the training scores. It separates off roughly the lowest 15% of samples. Here that is 4 rows out of 23, 1 more than the 3 rows actually injected.

The exact count depends on where the scores fall. It does not depend on a fixed ceil(0.15 * n), so an inlier can get swept in. This is what contamination means, and it motivates the next section.

2.13.6. Turning scores into labels: contamination

contamination is a rule you set on the builder. It is not an argument to a prediction call. fit resolves it into a single stored number, the offset:

pub enum Contamination {
    Auto,          // the paper's cutoff: offset = -0.5
    Fraction(f64), // offset = the 100*c-th percentile of the TRAINING scores. c is in (0.0, 0.5]
}

Contamination::Auto is the default. It pins the cutoff at -0.5, the score where a sample isolates exactly as fast as the forest average. Contamination::Fraction(c) instead reads the cutoff from the training scores, at the 100*c-th percentile. It uses NumPy’s linear interpolation for that percentile. This choice makes get_offset() equal scikit-learn’s offset_ numerically. It does more than flag a comparable number of rows.

A Fraction value must be finite and inside (0.0, 0.5]. Otherwise, with_contamination returns Error::InvalidParameter. The upper bound of 0.5 encodes the assumption that anomalies are the minority.

Resolving the cutoff at fit time is the whole point of this design. The rule becomes model state. A sample gets the same label whether you score it alone, in a slice, or in the full batch.

The earlier design worked differently. It took a quantile of whatever batch it received, so it was transductive. A single-row call always came back -1. Splitting a test set in half changed its labels.

Changing the rule after fitting requires a new call to fit, because RustyML computes the offset from the training scores.

Contamination is still a budget, not a discovery. You tell the model what proportion of the training data you expect to be anomalous. fit places the cutoff accordingly, whether or not that many anomalous points actually exist. If you overestimate, the cutoff sweeps in normal points as false positives, as the worked example showed. If you underestimate, the cutoff labels real anomalies as inliers. The model cannot know the true rate, so there is no way around this tradeoff.

When you have ground-truth labels for a validation set, sweep the fraction. Pick the value that best trades precision against recall for your cost structure. The tools in 5.2. Classification Metrics apply here, if you treat -1 as the positive class.

When you do not have ground truth, prefer the raw score_samples values. Threshold them against a level you can justify. For example, use a percentile from a clean reference window or an absolute score cutoff. Do not just commit to a fixed proportion. If your idea of “outlier” is really “sparse region,” rather than “few and different,” compare against DBSCAN, which labels low-density points as noise.

2.13.7. Seeding and reproducible forests

An unseeded forest draws its randomness from entropy, so 2 fits differ. Pass with_random_state(seed) to make the whole forest reproducible. The same seed and the same data give bit-identical scores.

use ndarray::array;
use rustyml::machine_learning::IsolationForest;

fn main() {
    let data = array![[0.0, 0.0], [0.5, 0.5], [1.0, 1.0], [2.0, 2.0], [50.0, 50.0]];

    let mut a = IsolationForest::new(30, 20).unwrap().with_random_state(13);
    a.fit(&data).unwrap();
    let sa = a.score_samples(&data).unwrap();

    let mut b = IsolationForest::new(30, 20).unwrap().with_random_state(13);
    b.fit(&data).unwrap();
    let sb = b.score_samples(&data).unwrap();

    assert_eq!(sa, sb); // bit-identical, regardless of thread scheduling
}

This determinism is stronger than it looks, and the reason matters for the parallel build described in the next section. RustyML does not share a single RNG across the trees. Tree i gets its own generator, seeded with seed.wrapping_add(i). Each tree’s seed depends only on its index.

So the forest comes out identical whether the trees build serially or across rayon threads, in any order the scheduler picks. Parallelism can never change the result. This is a deliberate contrast with the neural-network components in this crate, where reproducibility can depend on order.

You can fix randomness globally instead of passing a seed to every constructor. rustyml::set_global_seed seeds an unseeded forest from the process-global, thread-local, random stream:

use ndarray::array;
use rustyml::machine_learning::IsolationForest;
use rustyml::set_global_seed;

fn main() {
    set_global_seed(2026);
    let data = array![[0.0, 0.0], [0.5, 0.5], [1.0, 1.0], [50.0, 50.0]];

    // No with_random_state: per-tree seeds derive from the global stream.
    let mut forest = IsolationForest::new(30, 16).unwrap();
    let _labels = forest.fit_predict(&data).unwrap();
}

An explicit with_random_state always wins over the global seed. It also never consumes the global stream, so mixing the two stays predictable. The global seed is thread-local. Set it on the same thread that builds the model. See 7.1. Reproducibility and Random Seeds for the full seed-resolution rules.

2.13.8. Parallel training and prediction

Training and scoring both parallelize through rayon. A gate keeps tiny workloads serial, to avoid fork/join overhead. fit builds trees across a into_par_iter once n_estimators reaches its threshold of 10 trees. The default 100-tree forest always trains in parallel. Each tree is an independent subsample-and-build step, with its own index-seeded RNG. The work parallelizes cleanly, with no shared mutable state and no effect on the result.

score_samples parallelizes over rows once the estimated traversal work clears the calibrated tree-traversal gate. That work estimate is samples times trees times average path length. Below the gate, small batches score serially. decision_function and predict inherit this behavior, because they are thin wrappers over score_samples. score_sample always runs serially, because it handles only a single sample.

Isolation Forest scales well on wide forests and large scoring batches. Training cost is roughly n_estimators * sample_size * log(sample_size), and it is embarrassingly parallel across trees. Per-sample scoring is O(n_estimators * tree_depth), and it is independent across rows. See 7.3. Performance Tuning and Parallelism to tune the gates or study the cost model.

2.13.9. Saving and loading a forest

A fitted forest serializes to a compact postcard binary, through save_to_path and load_from_path. Every RustyML estimator exposes this same pair of methods. The whole state travels: every tree, the hyperparameters, n_features, sample_size, and the fitted offset. A reloaded model scores and labels identically to the original, byte for byte.

use ndarray::array;
use rustyml::machine_learning::IsolationForest;

fn main() {
    let data = array![[0.0, 0.0], [0.1, 0.1], [0.2, 0.0], [10.0, 10.0]];
    let mut forest = IsolationForest::new(50, 32).unwrap().with_random_state(7);
    forest.fit(&data).unwrap();
    let before = forest.score_samples(&data).unwrap();

    let path = "isolation_forest_model.bin";
    forest.save_to_path(path).unwrap();

    let loaded = IsolationForest::load_from_path(path).unwrap();
    let after = loaded.score_samples(&data).unwrap();

    assert_eq!(before, after); // scoring survives the round trip exactly
    std::fs::remove_file(path).unwrap();
}

load_from_path returns Error::Io when the file is missing, or when the bytes are not a valid serialized forest. RustyML persists sample_size, so a loaded model keeps the correct c(n) normalization, even though it never sees the training data again. Its scores stay on the same scale as before you saved it.

One migration note applies. The stored offset changed sign along with the scores. A forest saved by an older version resolves its cutoff on the wrong side of zero. Re-fit and re-save any persisted forest. For versioning and format details across the crate, see 7.2. Model Persistence in Depth.

3. Neural Networks

RustyML ships a small, Keras-shaped deep-learning framework, written in pure Rust. You stack layers into a Sequential model, compile it with an optimizer and a loss, and call fit/predict. Every tensor that flows through the framework is a Tensor, which is just ndarray::ArrayD<f32>. It is single-precision, has a dynamic rank, and runs with no GPU and no autograd tape. Each layer implements forward and backward by hand, and hands its parameters to the optimizer through a flat view. This makes the whole framework deterministic and easy to debug. If you have used Keras, you will recognize the shape of the API. The differences are strict f32 precision, explicit input dimensions, and Result-returning constructors, and this chapter explains them.

Read Chapter 1 before this chapter. Read Working with ndarray and Installation and Feature Flags too, since the neural_network feature gates this whole module. Read Error Handling as well, since layer and loss constructors return Result. A model has this end-to-end shape:

use rustyml::neural_network::{
    sequential::Sequential,
    layers::{Activation, Dense},
    optimizers::Adam,
    losses::MeanSquaredError,
};
use ndarray::Array;

fn main() {
    let x = Array::ones((8, 4)).into_dyn(); // 8 samples, 4 features
    let y = Array::ones((8, 1)).into_dyn(); // 8 samples, 1 target

    let mut model = Sequential::new();
    model
        .add(Dense::new(4, 16, Activation::ReLU).unwrap())
        .add(Dense::new(16, 1, Activation::Linear).unwrap())
        .compile(
            Adam::new(0.001, 0.9, 0.999, 1e-8, 0.0).unwrap(),
            MeanSquaredError::new(),
        );

    model.fit(&x, &y, 5).unwrap();
    let preds = model.predict(&x).unwrap();
    println!("prediction shape: {:?}", preds.shape());
}

The Sequential Model is the container that turns a pile of layers into a trainable network. It owns the training loop, the optimizer, and the loss, and exposes add, compile, fit, train_batch, evaluate, predict, summary, and weight save/load. It also holds the batch-shuffle seed (set_seed) and the learning-rate pair (learning_rate / set_learning_rate). Read this section first, even if you need only one specific layer.

Dense Layers and Activations covers the fully connected layer, the main layer of tabular models and the output stage of most networks. It also covers the Activation enum (ReLU, Sigmoid, Tanh, Softmax, Linear), which you fold into a layer or use as a standalone layer. Read this section second. Everything after it assumes you know how a Dense layer declares its input_dim and units.

Loss Functions is the objective half of compile. It covers mean squared error and mean absolute error for regression, and binary, categorical, and sparse-categorical cross-entropy for classification. Read this section closely. Its averaging conventions differ on purpose: some average per element, others average per prediction site, and switching between them quietly rescales your effective learning rate. CategoricalCrossEntropy and SparseCategoricalCrossEntropy take a from_logits flag that changes whether you need a Softmax on the output. BinaryCrossEntropy has no such flag, and always expects a probability in (0, 1).

Optimizers is the update half of compile. It covers SGD with momentum, Adam, AdamW, RMSprop, and AdaGrad. This section also covers clip-by-global-norm (global_clipnorm), coupled versus decoupled weight decay, and mid-training learning-rate scheduling. Sections 3.1 through 3.4 together give you a complete, trainable feed-forward network.

The remaining sections add specialized layers. All of them plug into the same Sequential. Convolutional Layers provides 1D/2D/3D convolution, plus depthwise and separable variants for spatial data. Pooling Layers provides parameter-free max and average downsampling, plus their global variants. Recurrent Layers covers SimpleRNN, LSTM, and GRU for sequences. Regularization and Normalization Layers covers dropout (including spatial dropout), Gaussian noise, and batch, layer, group, and instance normalization. These layers depend on mode: they behave differently in fit than in predict, and the model switches the mode for you.

Saving and Loading Weights closes the chapter. save_to_path and load_from_path persist weights only, in postcard binary format. They do not persist the architecture. Rebuild the identical layer stack in code, then load the weights into it. Read this section once you have a model worth keeping. See Model Persistence in Depth for the format details and version caveats.

3.1. The Sequential Model

Sequential is the container that turns a stack of layers into a trainable model. The Keras workflow transfers almost without change. You call new() for an empty model, add() layers in order, compile() it with an optimizer and a loss, then call fit() and predict().

RustyML differs from Keras in 3 ways. Training is full-batch by default. There is no callback machinery, so you write your own loops for early stopping and learning-rate schedules. A width mismatch between adjacent layers surfaces as a panic instead of a Result value.

Sequential lives in rustyml::neural_network::sequential. The layers, optimizers, and losses that plug into it each have their own page (3.2, 3.4, 3.3).

3.1.1. The lifecycle at a glance

The 5 calls below are the entire public surface you need to train a network. Each builder method returns &mut Self, so add and compile chain together. fit returns Result<History, Error>, with one loss value per epoch. predict returns Result<Tensor, Error>.

let mut model = Sequential::new();
model
    .add(/* a layer */)
    .add(/* another layer */)
    .compile(/* optimizer */, /* loss */);
model.summary();
let history = model.fit(&x, &y, epochs)?; // epochs: u32 (history.loss() returns one f32 per epoch)
let y_hat = model.predict(&x_new)?;       // Tensor = ArrayD<f32>

The prelude exports everything on this page: Sequential, History, Dense, Activation, every optimizer and loss, and the Tensor alias. One glob import covers every example below:

use rustyml::prelude::*; // Sequential, History, Dense, Activation, Adam, SGD, losses, Tensor

Every tensor in the framework has type Tensor, an alias for ndarray::ArrayD<f32>. It has a dynamic rank and always holds f32 values. Call .into_dyn() to convert a statically-ranked array, such as Array2 or Array3, into IxDyn before it enters a layer. RustyML has no f64 path. The whole stack uses single precision for cache and SIMD performance.

3.1.2. Building the model: new and add

Sequential::new() creates an empty model with no optimizer, no loss, and no shuffle seed. add takes any L: 'static + Layer by value, boxes it as Box<dyn Layer>, and appends it to the model:

pub fn add<L: 'static + Layer>(&mut self, layer: L) -> &mut Self;

add consumes the layer, so you construct and move it in one expression: model.add(Dense::new(2, 8, Activation::ReLU).unwrap()). Layer constructors can fail on their own. For example, a Dense layer with a zero dimension returns Error::InvalidParameter. That is why .unwrap() appears on the layer constructor, not on add.

add does no cross-layer validation. It never checks that a layer’s input width matches the previous layer’s output width. A Box<dyn Layer> exposes no such contract at insertion time. A mismatch surfaces only when data flows through the model. See 3.1.8. Keras’s functional API works the opposite way: it fails when you connect incompatible layers while you build the graph.

The Layer trait, briefly

You do not need to implement Layer to use Sequential. Reading its contract explains what fit and predict call. The core methods are:

pub trait Layer: std::any::Any + Send + Sync {
    fn forward(&mut self, input: &Tensor) -> Result<Tensor, Error>;   // training pass, caches state for backward
    fn predict(&self, input: &Tensor) -> Result<Tensor, Error>;       // eval pass, takes &self, writes no caches
    fn backward(&mut self, grad_output: &Tensor) -> Result<Tensor, Error>;
    fn param_count(&self) -> TrainingParameters;
    fn output_shape(&self) -> String;
    fn layer_type(&self) -> &str;
    // parameters(), layer_type(), output_shape(), set_training_if_mode_dependent() have defaults
}

Layer has 2 forward paths instead of 1. forward(&mut self, ...) runs during training, taking &mut self to stash the intermediate tensors each layer needs for its backward pass. predict(&self, ...) runs during inference, taking &self, writing no caches, and putting mode-dependent layers, such as dropout and batch normalization, into their inference behavior. The Layer trait requires Send + Sync. Because predict borrows &self, a layer can serve concurrent inference calls with no lock. This split means predict never disturbs training state, and it costs less than a training forward pass.

Backward propagation is pure math. It does not sanitize NaN or Inf values on purpose. A non-finite gradient propagates and surfaces at the next forward pass or as a NaN loss, instead of being masked silently.

3.1.3. compile: wiring the optimizer and loss

pub fn compile<O, LFunc>(&mut self, optimizer: O, loss: LFunc) -> &mut Self
where O: 'static + Optimizer, LFunc: 'static + Loss;

compile stores the optimizer and loss as trait objects. It returns &mut Self so it chains off the last add. That is all it does. It runs no shape inference and allocates no weights. Each layer allocates its own weights in its constructor, using Xavier/Glorot initialization.

Training is the only thing that needs compile. fit returns Error::NeuralNetwork(NnError::NotCompiled(_)) if the optimizer or the loss is missing. predict never reads the optimizer or the loss, so it works on an uncompiled model. evaluate sits between the two: it needs a loss to score with, but no optimizer to step with. This split matters for serving. After you load weights into a fresh architecture, you can call predict right away without a compiled optimizer.

Pick the optimizer and loss from 3.4 and 3.3.

Each loss family normalizes its value in a different way. MeanSquaredError, MeanAbsoluteError, and BinaryCrossEntropy average over every element. CategoricalCrossEntropy sums over the trailing class axis, then averages over the prediction sites. A prediction site is a sample for a [batch, classes] target. For a channels-last convolutional softmax head that predicts 1 class per pixel, a prediction site is a pixel. The divisor is then batch * height * width.

Switching loss families rescales the gradient magnitude, which changes the effective learning rate. Re-tune the step size after you change the loss.

3.1.4. summary: reading the architecture

summary() prints a Keras-style table to stdout. Use it to check the wiring and the parameter count before you spend epochs training the model. Here is the table for a Dense(2 -> 8, ReLU) layer followed by a Dense(8 -> 2, Softmax) layer:

Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ dense (Dense)                   │ (None, 8)              │            24 │
│ dense_1 (Dense)                 │ (None, 2)              │            18 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 42 (168 B)
 Trainable params: 42 (168 B)
 Non-trainable params: 0 (0 B)

RustyML generates layer names per type, in Keras style. The first Dense layer is named dense, the next is dense_1, and so on. None in the output shape is the batch dimension. It stays unknown until data arrives.

Parameter counts come from each layer’s param_count() method. A Dense(in -> out) layer reports in * out + out parameters, the weight matrix plus the bias vector. So 2 * 8 + 8 = 24, and 8 * 2 + 2 = 18. The byte figures assume 4 bytes per f32.

Layers split into 3 groups. Trainable parameters count toward “Trainable”, frozen parameters count toward “Non-trainable”, and parameter-free layers, such as activations and pooling, contribute 0. summary borrows &self and never changes the model, so call it at any time, before or after training.

3.1.5. fit and the training loop

pub fn fit(&mut self, x: &Tensor, y: &Tensor, epochs: u32) -> Result<History, Error>;

fit is full-batch. Each epoch runs exactly one gradient step over the entire x and y you provide. There is only one batch, so nothing is shuffled, and the model never reads its shuffle seed. epochs counts these full-dataset steps. Keras’s fit, by contrast, splits data into mini-batches and shuffles every epoch by default. Use fit_with_batches instead if one gradient step per epoch converges too slowly for your dataset. Also use it if a single forward pass does not fit your memory budget (see below).

Each epoch, fit runs one train_batch call. The subsection below uses this same public, single-step method to build custom loops. train_batch performs these steps, in order:

  1. Forward through every layer in training mode.
  2. Compute the scalar loss.
  3. Compute the loss gradient with respect to the output.
  4. Advance the optimizer’s global step once. This lets Adam advance its bias-correction timestep once per step, not once per layer.
  5. Backpropagate through the layers in reverse, so each layer stashes its parameter gradients.
  6. Apply clip-by-global-norm, if the optimizer requests it (see Optimizer::global_clipnorm).
  7. Update every layer’s parameters.

step() runs before the per-layer update() calls. This order keeps step-dependent optimizers correct across multiple layers.

fit returns a History. Its entire API is loss(): one f32 per epoch, in epoch order. epochs = 0 gives an empty slice. Each entry is the mean per-sample loss measured during the epoch, before that epoch’s own update. Every batch contributes the loss from the forward pass that ran before that batch’s own weight update. So each entry describes the weights the model held while the epoch ran, never the weights the epoch ends with.

Treating the last entry as the trained model’s final loss is wrong in both directions. While training converges, the entry reads above the truth, because the epoch’s own updates already improved on the weights it measured. Once the step size overshoots, the entry reads below the truth, because those same updates made things worse. This is Keras’s convention, not an accident of the loop. evaluate (see below) reports the loss of the model you hold right now.

The show_progress feature only adds a display. It is not the only way to read the loss. When it is on, fit renders a live progress bar. The bar shows the current epoch’s loss with 6 decimal places. fit_with_batches renders a similar bar that tracks the running average loss as its batches complete:

[dependencies]
rustyml = { version = "0.14", features = ["neural_network", "show_progress"] }
[00:00:00] ████████████████████████████████████████ 400/400 | Loss: <current loss>

Without the feature, training runs silently. The returned History holds the same numbers either way, so your code never depends on whether the feature is on.

Mini-batch training with fit_with_batches

pub fn fit_with_batches(&mut self, x: &Tensor, y: &Tensor, epochs: u32, batch_size: usize)
    -> Result<History, Error>;

fit_with_batches is the mini-batch loop. It reshuffles the sample order at the start of every epoch, then trains on fixed-size chunks. The shuffle makes this the one place where the model’s seed matters. Set it with Sequential::new_with_seed(seed) or set_seed(seed) for a reproducible shuffle order (see 7.1). This seed governs only the shuffle. It does not affect weight initialization, which each layer seeds through its own with_random_state call.

A batch_size of 0, or one larger than the dataset, returns Error::InvalidParameter. A batch_size equal to n_samples degenerates to a single full-batch step per epoch, matching fit.

Its History entries mean what fit’s entries mean, except for one refinement that shows only when batch_size does not divide the dataset evenly. Each batch contributes to the epoch loss in proportion to its sample count, not as 1 vote per batch. So a short trailing batch pulls the epoch figure less than a full batch does. This makes each entry exactly the dataset-wide mean per-sample loss, which matches what Keras reports. Keras’s loss metric accumulates every batch with sample_weight = batch_size, and a plain mean over batches would not match that. A test pins both the weighting and the before-the-update timing against numbers taken from Keras 3.15: tests/neural_network/sequential.rs::test_batch_losses_and_epoch_mean_match_keras.

use ndarray::Array;
use rustyml::prelude::*;

fn main() {
    let x = Array::ones((8, 3)).into_dyn();
    let y = Array::zeros((8, 1)).into_dyn();

    // Seed the per-epoch shuffle so the run is reproducible.
    let mut model = Sequential::new_with_seed(0);
    model
        .add(Dense::new(3, 6, Activation::ReLU).unwrap())
        .add(Dense::new(6, 1, Activation::Sigmoid).unwrap())
        .compile(
            SGD::new(0.05, 0.9, false, 0.0).unwrap(),
            BinaryCrossEntropy::new(),
        );

    // 4 mini-batches of 2 samples per epoch, reshuffled every epoch.
    let history = model.fit_with_batches(&x, &y, 5, 2).unwrap();
    assert_eq!(history.loss().len(), 5);

    // External LR schedule: read the step size, halve it, write it back. The optimizer keeps
    // its momentum buffers across the change.
    let lr = model.learning_rate().unwrap();
    model.set_learning_rate(lr * 0.5);
    let resumed = model.fit_with_batches(&x, &y, 5, 2).unwrap();

    // Training resumes where it left off rather than restarting.
    assert!(resumed.loss()[0] < *history.loss().last().unwrap());
    assert_eq!(model.predict(&x).unwrap().shape(), &[8, 1]);
}

set_learning_rate, used above, is the hook for external schedules such as step decay or warmup. It retunes the step size in place. The optimizer keeps all of its accumulated state, such as momentum buffers and Adam moments, across the change. It does nothing if the model has not been compiled.

learning_rate() is its read half. It returns None on an uncompiled model. This read-scale-write pattern avoids keeping a second copy of the rate beside the model, which could drift out of sync with the optimizer. Unlike the optimizer constructors, set_learning_rate validates nothing. learning_rate() returns exactly the value you set, even a zero or a negative rate.

Writing the loop yourself: train_batch and evaluate

pub fn train_batch(&mut self, x: &Tensor, y: &Tensor) -> Result<f32, Error>;
pub fn evaluate(&self, x: &Tensor, y: &Tensor) -> Result<f32, Error>;

train_batch is the single step that both fit variants build on. It is public. Any other epoch structure is a loop you write, instead of a fork of the library. Examples include curriculum ordering, a per-step schedule, or a probe between steps.

The whole of x is the batch. Nothing is split, nothing is shuffled, and mode-dependent layers run in training mode. The returned f32 is the loss from the forward pass, measured before this call’s own update. This is the number fit records for each epoch, and the value that fit_with_batches averages, weighted by sample count, into each History entry. Keras calls this method train_on_batch.

train_batch validates its own inputs instead of trusting the caller to have done it. So calling it on an uncompiled model returns a NotCompiled error, not a panic.

evaluate is the other half. It runs one inference-mode forward pass over the whole of x and scores it with the compiled loss. It updates nothing: no gradients, no parameters, and no batch-norm running statistics. It borrows &self, so scoring a model between training steps cannot disturb it. It also draws from no random number generator, so calling it inside a fit_with_batches loop cannot perturb the shuffle stream.

Layers behave exactly as they do in predict. Dropout and noise layers act as the identity, and batch normalization reads its running statistics. So on a model with such layers, evaluate and the number fit recorded for the same data disagree. Training-mode dropout inflates the number fit records. evaluate gives the more accurate estimate of the two.

Together, train_batch and evaluate turn early stopping, learning-rate schedules, and checkpoint selection into ordinary code you write. The loop below takes one full-batch step at a time. It scores the model it holds after each step. It halves the step size after 10 steps without an improvement, and stops once the schedule runs out:

use ndarray::Array;
use rustyml::prelude::*;

fn main() {
    // 8 points of y = 2x + 1.
    let x = Array::from_shape_vec((8, 1), (0..8).map(|i| i as f32 / 8.0).collect::<Vec<_>>())
        .unwrap()
        .into_dyn();
    let y = x.mapv(|v| 2.0 * v + 1.0);

    let mut model = Sequential::new();
    model
        .add(Dense::new(1, 8, Activation::Tanh).unwrap().with_random_state(0))
        .add(Dense::new(8, 1, Activation::Linear).unwrap().with_random_state(0))
        .compile(SGD::new(0.1, 0.9, false, 0.0).unwrap(), MeanSquaredError::new());

    let start = model.evaluate(&x, &y).unwrap();
    let (mut best, mut stale, mut previous) = (start, 0, start);

    for _ in 0..500 {
        // The step reports the loss it started from. This stack has no dropout, so that
        // number is the previous `evaluate` measured again, one step stale.
        let during = model.train_batch(&x, &y).unwrap();
        assert!((during - previous).abs() < 1e-5);

        // This one scores the weights the step produced.
        let after = model.evaluate(&x, &y).unwrap();
        previous = after;

        if after < best - 1e-6 {
            best = after;
            stale = 0;
            continue;
        }

        // 10 steps without progress: halve the step size, read from the optimizer instead of
        // a copy kept here. Once it is that small, there is nothing left to try.
        stale += 1;
        if stale == 10 {
            let lr = model.learning_rate().unwrap();
            if lr < 1e-4 {
                break;
            }
            model.set_learning_rate(lr * 0.5);
            stale = 0;
        }
    }

    assert!(best < start / 100.0);
}

3.1.6. predict: forward-only inference

pub fn predict(&self, x: &Tensor) -> Result<Tensor, Error>;

predict runs the inference forward path (Layer::predict) through every layer and returns the output Tensor. It borrows &self, allocates no backward caches, and puts mode-dependent layers into inference behavior. Dropout is disabled, and batch normalization uses its running statistics, which is the correct behavior for serving. predict is deterministic: 2 calls on the same input return identical tensors. Unlike training and evaluation, predict does not need compile.

The result shape is whatever the last layer emits, (batch, output_dim) for a Dense tail. Input must match each layer’s expectations. A Dense layer requires a 2D (batch, features) tensor and returns Error::InvalidInput for anything else.

3.1.7. A complete example: learning XOR

XOR is the smallest problem that a linear model cannot solve. It is the classic proof that a hidden layer does real work. This example uses 2 Dense layers: a Tanh hidden layer and a Softmax head over 2 classes. Training with Adam and categorical cross-entropy separates the classes cleanly. The targets are one-hot: class 0 is [1, 0], and class 1 is [0, 1]. Seeding both layers’ weight initialization with with_random_state(0) makes the run reproducible.

use ndarray::Array;
use rustyml::prelude::*;

fn main() {
    // XOR inputs, shape (4, 2).
    let x = Array::from_shape_vec((4, 2), vec![0.0_f32, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0])
        .unwrap()
        .into_dyn();

    // One-hot targets: XOR is class 1 for (0,1) and (1,0), class 0 otherwise.
    let y = Array::from_shape_vec((4, 2), vec![1.0_f32, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(2, 8, Activation::Tanh).unwrap().with_random_state(0))
        .add(Dense::new(8, 2, Activation::Softmax).unwrap().with_random_state(0))
        .compile(
            Adam::new(0.1, 0.9, 0.999, 1e-8, 0.0).unwrap(),
            CategoricalCrossEntropy::new(false),
        );

    model.summary();
    let history = model.fit(&x, &y, 400).unwrap();
    assert!(*history.loss().last().unwrap() < history.loss()[0] / 100.0);

    let preds = model.predict(&x).unwrap();
    for i in 0..4 {
        // argmax over the 2 class probabilities
        let class = if preds[[i, 0]] >= preds[[i, 1]] { 0 } else { 1 };
        println!("row {i} -> class {class}  probs [{:.3}, {:.3}]", preds[[i, 0]], preds[[i, 1]]);
    }
}

After 400 full-batch epochs, the network assigns the 2 true XOR rows to class 1 and the 2 false rows to class 0. Each probability sits at or near 1.000. The History records that descent. Its last epoch sits more than 2 orders of magnitude below its first, which is what the assertion checks. The CategoricalCrossEntropy::new(false) argument tells the loss that the head already produces probabilities, from the Softmax layer, so the loss does not apply its own log-softmax. Pass true only when your last layer emits raw logits.

3.1.8. Errors, shape checking, and the mismatch panic

fit, fit_with_batches, and train_batch validate the model and the inputs before touching any layer. The checks run in this order:

  1. An optimizer is present.
  2. A loss is present.
  3. The model has at least 1 layer.
  4. The inputs have a batch axis.
  5. The inputs are not empty.
  6. x and y agree on batch size.

evaluate runs the same checks, except for the optimizer check, since only a parameter update needs an optimizer. The table below maps each failure to its error variant. All of them are rustyml::error::Error values:

SituationReturned error
fit/fit_with_batches/train_batch before compileError::NeuralNetwork(NnError::NotCompiled("optimizer"))
evaluate before compileError::NeuralNetwork(NnError::NotCompiled("loss function"))
training, evaluating, or predict on a model with no layersError::NeuralNetwork(NnError::EmptyModel)
rank-0 x or y (a scalar tensor, so no batch axis)Error::InvalidInput(_)
empty x or yError::EmptyInput(_)
x and y disagree on batch size (rows)Error::DimensionMismatch { .. }
fit_with_batches with batch_size == 0 or > n_samplesError::InvalidParameter { .. }
non-2-D input into a Dense layerError::InvalidInput(_)

The rank-0 row needs an explanation. A 0-dimensional tensor is not an empty one. It holds exactly 1 element, so is_empty does not reject it, and the batch-axis index that follows has nothing to read. This used to panic. Now it returns InvalidInput.

These are recoverable Result values. The next example shows the compile requirement. It shows how training and evaluation each demand something different from compile, and how predict needs neither:

use ndarray::Array;
use rustyml::error::Error;
use rustyml::neural_network::NnError;
use rustyml::prelude::*;

fn main() {
    let x = Array::ones((4, 2)).into_dyn();
    let y = Array::ones((4, 1)).into_dyn();

    let mut model = Sequential::new();
    model.add(Dense::new(2, 1, Activation::Linear).unwrap());

    // fit needs an optimizer and a loss. Without compile, it fails fast and names the first
    // thing it found missing.
    match model.fit(&x, &y, 1) {
        Err(Error::NeuralNetwork(NnError::NotCompiled(missing))) => assert_eq!(missing, "optimizer"),
        other => panic!("expected NotCompiled, got {other:?}"),
    }

    // evaluate updates nothing, so it asks only for the loss it scores with.
    match model.evaluate(&x, &y) {
        Err(Error::NeuralNetwork(NnError::NotCompiled(missing))) => {
            assert_eq!(missing, "loss function")
        }
        other => panic!("expected NotCompiled, got {other:?}"),
    }

    // predict, by contrast, never needs compile: it is forward-only.
    assert_eq!(model.predict(&x).unwrap().shape(), &[4, 1]);
}

One case is a genuine gap in the Result-based error handling. A dimension mismatch between adjacent layers is not a Result. It is a panic. add never checks that consecutive layers agree. So a Dense(2 -> 4) layer feeding a Dense(8 -> 2) layer builds without complaint. The inconsistency surfaces only when data reaches the second layer’s matrix multiply, on the first call to fit or predict:

let mut model = Sequential::new();
model
    .add(Dense::new(2, 4, Activation::ReLU).unwrap())    // emits 4 columns
    .add(Dense::new(8, 2, Activation::Softmax).unwrap()); // expects 8, a mismatch

let x = Array::ones((3, 2)).into_dyn();
let _ = model.predict(&x); // panics inside the GEMM, does not return Err
thread 'main' panicked at gemmkit-ndarray-0.1.2/src/fused.rs:85:5:
assertion `left == right` failed: gemmkit-ndarray: A.cols (4) != B.rows (8)
  left: 4
 right: 8

This panic comes from the matrix-product backend, not from RustyML. Dense::forward hands the product straight to gemmkit-ndarray, so the assertion that fires belongs to the backend. The path in the message is a crates.io registry path, not a path in this repository. This is expected behavior, not a sign of an internal bug.

Treat inter-layer widths as a build-time invariant that you must enforce. Each layer’s input_dim must equal the previous layer’s units. A mismatch is a programming error, not bad input, so it aborts instead of returning an ordinary Error. Get the widths right in the constructors, which is what summary checks for you.

Then this panic never fires. Get them wrong, and the first data that flows through the model reveals it. The panic message names the 2 extents that disagree: here, the 4 columns the first layer emits against the 8 rows the second layer expects.

Each building block has its own page from here. See Dense layers and activations, loss functions, optimizers, the convolutional and recurrent layers, and saving and loading weights.

3.2. Dense Layers and Activations

Dense is the main layer of a feedforward network. It applies a linear map, input * W + b, then an elementwise nonlinearity. RustyML fuses that nonlinearity into the layer itself. It does not treat the nonlinearity as a separate stage.

This page covers the constructor and its fused-activation design. It also covers the exact initialization scheme and the five activations, with their gradients and failure modes. It covers the GEMM-backed compute path and how to read or inject weights. It assumes you have already read the Sequential model page. The layers here are what you stack inside a Sequential model.

3.2.1. The fused-activation design

The constructor takes the activation as its third argument, not as a separate layer:

pub fn new(
    input_dim: usize,
    units: usize,
    activation: impl Into<Activation>,
) -> Result<Dense, Error>

Internally the layer stores an Activation value. This is a plain Copy enum with 5 variants. It is not a generic type parameter Dense<A>, and it is not a Box<dyn Activation>. That choice is deliberate. A generic parameter would monomorphize Dense 5 ways.

It would also force weight deserialization to probe every Dense<A> pairing to find the concrete type on disk. A trait object would add an indirection to every elementwise call. The runtime enum keeps Dense a single concrete type. The persistence layer can then downcast every saved layer to exactly one struct (see Saving and Loading Weights). The activation math stays a pure, stateless function, and the layer calls it inside its own forward and backward passes.

Keras makes the same fusion choice, with Dense(units, activation="relu"). RustyML has no implicit “no activation”. You must always pass one. To get a pure linear layer, pass Activation::Linear, the identity. This is the equivalent of Keras’ activation=None. Every example below that ends in a regression head does exactly this.

Fusing is the right default. RustyML also provides the activations as standalone layers (ReLU, Sigmoid, Tanh, Softmax, Linear). Use these when you need something between the linear map and the nonlinearity. A normalization layer is the most common case (see Regularization and Normalization Layers). The two forms are equivalent:

// These two stacks compute the same thing.
model.add(Dense::new(64, 32, Activation::ReLU).unwrap());          // fused: one cached output

model.add(Dense::new(64, 32, Activation::Linear).unwrap())         // split: identity Dense ...
     .add(ReLU::new());                                            // ... then a standalone ReLU

Prefer the fused form. It caches a single activated tensor instead of two. The backward pass then differentiates the activation in terms of that cached output (see 3.2.5). Use the split form only when a layer must sit between W*x + b and the nonlinearity.

3.2.2. Constructing and sizing a layer

Dense::new(input_dim, units, activation) returns Result<Dense, Error>. Both dimensions must be non-zero. Passing 0 for either yields Error::InvalidParameter (see Error Handling). input_dim is the number of features per row. units is the number of neurons, and therefore the output width.

The parameter count is input_dim * units + units: one weight per (input, output) pair, plus one bias per output. param_count() reports it as TrainingParameters::Trainable(n). output_shape() renders (None, units), where None is the dynamic batch dimension, mirroring Keras’ summary. A Dense(4, 3, ...) therefore holds a 4 x 3 weight matrix and a 1 x 3 bias. That is 12 + 3 = 15 trainable scalars.

Input must be a 2-D tensor of shape (batch, input_dim). A 1-D or 3-D input is rejected with Error::InvalidInput, rather than being silently reshaped. If you are feeding the output of a convolutional or recurrent stack, flatten it to 2 dimensions first. The following program constructs a layer, inspects it, and injects known weights:

use ndarray::Array2;
use rustyml::neural_network::layers::layer_weight::LayerWeight;
use rustyml::neural_network::layers::{Activation, Dense, TrainingParameters};
use rustyml::neural_network::traits::Layer;

fn main() {
    // A 4 -> 3 dense layer with a fused ReLU activation.
    let mut dense = Dense::new(4, 3, Activation::ReLU).unwrap();

    // 4*3 weights + 3 bias = 15 trainable scalars.
    assert_eq!(dense.param_count(), TrainingParameters::Trainable(15));
    println!("output shape: {}", dense.output_shape()); // (None, 3)

    // Read the freshly initialized parameters without cloning them.
    match dense.get_weights() {
        LayerWeight::Dense(w) => {
            println!("weight {:?}, bias {:?}", w.weight.shape(), w.bias.shape());
        }
        _ => unreachable!(),
    }

    // Inject known weights. The shapes are validated against the layer's config.
    let weights =
        Array2::from_shape_vec((4, 3), (0..12).map(|v| v as f32).collect::<Vec<f32>>()).unwrap();
    let bias = Array2::zeros((1, 3));
    dense.set_weights(weights, bias).unwrap();
}

set_weights checks both shapes and returns Error::NeuralNetwork(NnError::WeightShape) on a mismatch. A (3, 3) weight for a 4 -> 3 layer, or a (1, 4) bias, is rejected rather than truncated.

3.2.3. Weight initialization

Weights use Xavier/Glorot uniform initialization. Each element is drawn from Uniform(-limit, +limit), where limit = sqrt(6 / (input_dim + units)). Biases start at exactly zero. This is the fan-in-plus-fan-out Glorot scheme. It matches Keras’ Dense default exactly (glorot_uniform kernel, zeros bias).

RustyML applies Glorot regardless of the activation. It does not switch to He/Kaiming initialization for ReLU layers, even though He is the textbook match for rectifiers. For shallow nets this rarely matters. For a deep ReLU stack, early convergence may be slightly slower than a He-initialized equivalent. If that happens, initialize with set_weights from your own scaled draw.

Initialization draws through the crate’s shared RNG. By default the seed comes from the process-global seed if one is set, otherwise from entropy. Two runs then give different weights unless you fix the randomness. There are 2 ways to make it reproducible. Set a thread-local global seed with rustyml::random::set_global_seed(...) before you construct the model. This also fixes dropout masks and the fit-time batch shuffle.

You can instead seed one layer explicitly, with Dense::new(...)?.with_random_state(seed). This re-runs Glorot with that seed and leaves the global stream untouched. The full seeding model is in Reproducibility and Random Seeds. It also explains why an explicit per-layer seed does not change the seeds handed to unseeded layers.

3.2.4. The five activations

Activation has exactly 5 variants. The crate has no LeakyReLU, ELU, GELU, or Swish. Each variant’s backward pass is expressed in terms of the activated output a = f(x), not the pre-activation x. The layer caches that output, not the input.

ActivationForward f(x)Backward (given upstream g, output a)Output range
ReLUmax(0, x)pass g where a > 0, else 0[0, inf)
Sigmoid1 / (1 + e^-x)g * a * (1 - a)(0, 1)
Tanhtanh(x)g * (1 - a^2)(-1, 1)
Softmaxshifted exp, row-normalizeda_i * (g_i - sum_j(a_j * g_j)) (row Jacobian)simplex, each row sums to 1
Linearxpass g through(-inf, inf)

ReLU is the default hidden-layer choice. It is cheap, and it does not saturate on the positive side. Its failure mode is the dead neuron. The derivative is 0 for x <= 0, so a neuron whose pre-activation is negative for every example in the batch gets zero gradient.

It never updates, and it stays off for good. A high learning rate makes this worse. It pushes neurons into the dead region early. Glorot init has no leaky variant. Your options are a smaller learning rate and well-scaled inputs, or, if you inject your own weights, a better initial scale.

Sigmoid squashes values to (0, 1). Its derivative a * (1 - a) peaks at 0.25 when a = 0.5, and it decays toward zero as the output saturates. Stacking sigmoids in a deep hidden path throttles gradients: the classic vanishing-gradient problem. Use Sigmoid for a single binary output, paired with binary cross-entropy, or as a gate. Do not use it as a deep hidden nonlinearity. At extreme inputs, f32 saturates it to exactly 0.0 or 1.0.

Tanh maps to (-1, 1). Unlike sigmoid, it is zero-centered, which tends to make hidden-layer optimization better behaved. Its gradient 1 - a^2 still reaches 1 near the origin. It saturates the same way sigmoid does at the tails. It is the natural bounded, zero-centered hidden activation, and the recurrent layers use it internally.

Softmax turns a row of logits into a probability distribution over the last axis. The forward pass subtracts each row’s maximum before it takes the exponent. This makes the largest term exp(0) = 1, and the sum always >= 1. That shift makes softmax overflow-proof and shift-invariant: adding a constant to every logit leaves the output unchanged.

Softmax needs at least a 2-D input. A 1-D tensor returns Error::InvalidInput. Its backward pass is the true Jacobian-vector product across the row, not an elementwise multiply, and each gradient row sums to zero.

Softmax belongs on the output layer of a classifier, paired with the correct loss configuration. Mid-network, softmax is mechanically valid: the Jacobian backward is correct, so a Dense(..., Softmax) buried in the stack still trains. It is almost never what you want, though. It collapses the representation onto a simplex, discards all magnitude information, and saturates worse than ReLU or tanh.

Linear is the identity. Its gradient is 1, and its output is unbounded. Use it for regression heads, and whenever the loss function expects raw logits.

The loss function must match the activation on the output head. There are 2 correct pairings for multi-class classification. Mixing them silently corrupts training:

  • Softmax head + CategoricalCrossEntropy::new(false). The final Dense emits probabilities. The loss consumes probabilities. Correct.
  • Linear head + CategoricalCrossEntropy::new(true). The final Dense emits raw logits. The loss applies a numerically stable log-softmax internally, and returns the fused (softmax(z) - y) gradient in one step. Also correct, and more numerically stable.

The 2 pairings produce the same gradient mathematically. The fused from_logits = true path avoids the intermediate -y/p division and the separate softmax step. It therefore degrades more gracefully when a predicted probability is tiny. Do not mix the two pairings.

A softmax head with from_logits = true applies softmax twice. A linear head with from_logits = false feeds raw logits to a loss that expects probabilities. Loss details live in Loss Functions.

// More stable alternative to a Softmax head: emit logits, and fuse the softmax into the loss.
model
    .add(Dense::new(8, 3, Activation::Linear).unwrap())    // raw logits, NOT probabilities
    .compile(optimizer, CategoricalCrossEntropy::new(true)); // from_logits = true

The activations are pure math, with no NaN/Inf sanitization. A NaN propagates through untouched. tanh saturates to 1 or -1 at large inputs. ReLU of a large negative input is 0. A NaN anywhere in a softmax row contaminates the whole row through the normalizer. Non-finite values surface downstream, as a NaN loss, not at the activation step.

3.2.5. Forward, backward, and the GEMM path

The forward pass is activation(input * W + b). It runs as a single call into the gemmkit backend. The product, the per-column bias, and, for ReLU, the activation, all run in one pass. The bias and activation apply in the kernel’s epilogue while the output tile is still in registers.

The fused result matches the unfused product plus scalar activation bit for bit, with one exception. The fused ReLU epilogue maps a NaN pre-activation to 0.0. The standalone ReLU activation used elsewhere in the crate instead propagates NaN. A NaN pre-activation already means a diverged model. The cached-output ReLU derivative then treats that 0.0 like a dead unit. It backpropagates a zero gradient there, instead of propagating the NaN further back.

Whether the product threads is gemmkit’s decision, not the layer’s. It compares batch * input_dim * units against a work gate, 589,824 by default. That count is the raw product, not a FLOP count with a factor of 2. Below the gate, the product stays on one thread. Above it, the worker count ramps with the work, rather than moving straight to the full machine.

Small layers in a tight loop stay serial by design, because the per-call dispatch would dominate. These products also nest safely inside an outer parallel region. The backend is described in full in Matrix Multiplication, and the tunable gates in Performance Tuning and Parallelism.

The elementwise activation that follows has its own, separate parallelism gate. ReLU is a memory-bound “cheap map” whose crossover sits at 4,000,000 elements. At any practical layer size, it runs serial. Sigmoid, Tanh, and Softmax are exp-dominated, and go parallel above 131,072 elements. Moving these gates only trades serial for parallel. The results are identical, and every product is run-to-run deterministic on the same machine.

forward caches the input and the activated output for the backward pass. predict is the eval-mode twin. It computes the same values, but writes no caches. The backward pass first differentiates the activation using the cached output.

It then computes 3 quantities. The weight gradient is input^T * grad, a GEMM. The bias gradient is the column-sum over the batch. The input gradient is grad * W^T, another GEMM.

Calling backward before forward returns Error::NeuralNetwork(NnError::ForwardPassNotRun). An upstream gradient whose shape does not match the cached output returns Error::ShapeMismatch. Both are errors, never panics.

3.2.6. Reading and setting weights

There is no standalone weights() / bias() getter. The accessor is get_weights() from the Layer trait. It returns a LayerWeight enum. For a dense layer, that is LayerWeight::Dense(DenseLayerWeight { weight, bias }), where weight and bias are Cow<Array2<f32>> borrowed from the live layer, with no clone.

weight has shape (input_dim, units), and bias has shape (1, units), as the construction example in 3.2.2 shows. To write parameters, use set_weights(weights, bias), which validates both shapes. This same LayerWeight enum is the on-disk weight format. Anything you can read here is what round-trips through save and load, in Saving and Loading Weights.

3.2.7. Two worked models

A regression net ends in a Linear head, and trains against mean squared error. The hidden layer fuses ReLU. The output layer fuses Linear, because a regression target is unbounded:

use ndarray::Array;
use rustyml::neural_network::layers::{Activation, Dense};
use rustyml::neural_network::losses::MeanSquaredError;
use rustyml::neural_network::optimizers::SGD;
use rustyml::neural_network::sequential::Sequential;

fn main() {
    // 4 samples, 3 features, 1 continuous target each.
    let x = Array::from_shape_vec(
        (4, 3),
        vec![0.0, 0.1, 0.2, 1.0, 0.9, 0.8, 0.2, 0.1, 0.0, 0.9, 1.0, 0.8],
    )
    .unwrap()
    .into_dyn();
    let y = Array::from_shape_vec((4, 1), vec![0.3, 2.7, 0.3, 2.7])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(3, 8, Activation::ReLU).unwrap()) // hidden, fused ReLU
        .add(Dense::new(8, 1, Activation::Linear).unwrap()) // regression head: identity
        .compile(SGD::new(0.05, 0.9, false, 0.0).unwrap(), MeanSquaredError::new());

    model.fit(&x, &y, 20).unwrap();

    let preds = model.predict(&x).unwrap();
    println!("prediction shape: {:?}", preds.shape()); // [4, 1]
}

A classifier ends in a Softmax head over one-hot targets, paired with CategoricalCrossEntropy::new(false), because the head emits probabilities:

use ndarray::Array;
use rustyml::neural_network::layers::{Activation, Dense};
use rustyml::neural_network::losses::CategoricalCrossEntropy;
use rustyml::neural_network::optimizers::Adam;
use rustyml::neural_network::sequential::Sequential;

fn main() {
    // 4 samples, 3 features, 2 classes (one-hot targets).
    let x = Array::from_shape_vec(
        (4, 3),
        vec![0.0, 0.1, 0.2, 1.0, 0.9, 0.8, 0.1, 0.0, 0.2, 0.8, 1.0, 0.9],
    )
    .unwrap()
    .into_dyn();
    let y = Array::from_shape_vec((4, 2), vec![1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(3, 8, Activation::ReLU).unwrap())
        .add(Dense::new(8, 2, Activation::Softmax).unwrap()) // probability head
        .compile(
            Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(),
            CategoricalCrossEntropy::new(false),
        );

    model.fit(&x, &y, 20).unwrap();

    let probs = model.predict(&x).unwrap();
    // Each row is a distribution over the 2 classes, summing to 1.
    println!("class-probability shape: {:?}", probs.shape()); // [4, 2]
}

Swap the head to Activation::Linear, and the loss to CategoricalCrossEntropy::new(true). This classifier then trains on the more stable fused-logits path, and predict produces raw scores instead of probabilities. Which optimizer to compile with, and how learning rate and momentum interact with the dead-neuron and saturation behavior above, is the subject of Optimizers.

3.3. Loss Functions

A loss function is the scalar objective the training loop drives toward zero. It is also the source of the first gradient that flows backward through the network. RustyML ships 5 losses in rustyml::neural_network::losses. MeanSquaredError and MeanAbsoluteError handle regression. BinaryCrossEntropy handles binary and multi-label classification. CategoricalCrossEntropy and SparseCategoricalCrossEntropy handle mutually exclusive multi-class problems. Each loss is a small, unit-like struct that implements the Loss trait. This lets every loss compose the same way inside a Sequential model. You can also call a loss directly, for evaluation or for a hand-rolled training loop.

All 5 losses carry no per-example state. They are cheap to construct and to copy (Debug + Clone + Copy + PartialEq). They differ in the shapes they accept, how they normalize, and how they stay numerically stable at the edges of their domains. This page states those facts straight from the implementation, so you know what a loss does before you compile it into a model.

3.3.1. The Loss trait and averaging conventions

Every loss implements two methods:

pub trait Loss {
    fn compute_loss(&self, y_true: &Tensor, y_pred: &Tensor) -> Result<f32, Error>;
    fn compute_grad(&self, y_true: &Tensor, y_pred: &Tensor) -> Result<Tensor, Error>;
}

Tensor is an alias for ndarray::ArrayD<f32>, a dynamically dimensioned f32 array. Note the argument order: y_true comes first, then y_pred. Both methods return a Result. Shape and label validation happen up front, instead of the code panicking mid-computation. See Error Handling for the error types. compute_grad returns the gradient of compute_loss with respect to the predictions. The two are always exactly consistent, so the gradient is not an approximation.

The losses do not all normalize the same way, on purpose. The regression and binary losses average over every element of the tensor (y.len()), and treat each output as an independent target. CategoricalCrossEntropy instead sums over the trailing class axis and averages over the prediction sites, the product of every axis before it. For the usual [batch, classes] target, that divisor is the batch size. For the [batch, height, width, classes] output of a channels-last Conv2D softmax head, the divisor is batch * height * width, 1 prediction per pixel. This matches Keras’ default sum_over_batch_size reduction. SparseCategoricalCrossEntropy accepts rank-2 predictions only, so its divisor is always the batch size. Switching a model from, for example, MeanSquaredError to CategoricalCrossEntropy rescales the gradient magnitude by roughly the number of classes. This changes the effective learning rate. If a learning rate that worked well before suddenly diverges or stalls after you change the loss, check the rescaling first. Adjust the rate on the optimizer to compensate.

LossNormalizes overDivisor
MeanSquaredErrorevery elementy.len()
MeanAbsoluteErrorevery elementy.len()
BinaryCrossEntropyevery elementy.len()
CategoricalCrossEntropyclass axis summed, prediction sites averagedproduct of the leading axes (batch, or batch * height * width at rank 4)
SparseCategoricalCrossEntropyclass axis summed, batch averagedy.shape()[0] (rank-2 only)

The input shapes also differ. Getting them wrong is the most common cause of a rejected fit call. The table below lists every contract, checked against each loss’s validation code.

Lossy_true shapey_pred shapeConstraints
MeanSquaredErroranyidentical to y_trueshapes must match exactly
MeanAbsoluteErroranyidentical to y_trueshapes must match exactly
BinaryCrossEntropyany (values 0.0/1.0)identical to y_true (probabilities in (0,1))shapes must match exactly
CategoricalCrossEntropy[..., classes] one-hotidentical to y_trueat least 2-D, non-empty. The last axis is the class axis
SparseCategoricalCrossEntropy[batch, 1] integer-valued[batch, num_classes]y_pred exactly 2-D. Labels fall in 0..num_classes

3.3.2. Mean Squared Error

MSE is the default loss for regression. The forward value is mean((y_pred - y_true)^2) over all elements. The gradient is 2 * (y_pred - y_true) / n, where n is the total element count. The factor of 2 comes from the derivative of the squared term. RustyML folds that factor into the gradient, not into the loss, so compute_grad is the exact gradient of compute_loss.

Because the error term is squared, MSE weights large residuals quadratically. A prediction that is off by 10 contributes 100 times more to the loss than one off by 1. MSE is the right choice when large errors are truly worse and your targets cluster around the true value in a roughly Gaussian way. MSE is the wrong choice when your data has heavy-tailed noise or outliers that you do not want the model to fit closely.

use rustyml::neural_network::Tensor;
use rustyml::neural_network::losses::MeanSquaredError;
use rustyml::neural_network::traits::Loss;
use ndarray::Array;

fn main() {
    let mse = MeanSquaredError::new();

    let y_true: Tensor = Array::from_shape_vec(vec![2, 2], vec![1.0_f32, 2.0, 3.0, 4.0])
        .unwrap()
        .into_dyn();
    let y_pred: Tensor = Array::from_shape_vec(vec![2, 2], vec![1.0_f32, 3.0, 5.0, 4.0])
        .unwrap()
        .into_dyn();

    let loss = mse.compute_loss(&y_true, &y_pred).unwrap();
    let grad = mse.compute_grad(&y_true, &y_pred).unwrap();

    println!("MSE loss: {loss:.4}");        // mean of squared diffs
    println!("grad shape: {:?}", grad.shape());
}

If the two shapes differ, both methods return Err(Error::ShapeMismatch { .. }). This stops an ndarray broadcast panic from escaping. For the metrics you report after training, such as R2 and explained variance, see Regression Metrics. The loss here is the training objective, not the evaluation report.

3.3.3. Mean Absolute Error

MAE is the loss that tolerates outliers better than MSE. The forward value is mean(|y_pred - y_true|), so residuals contribute linearly, not quadratically. An outlier that is off by 10 adds 10 times as much to the loss as one off by 1, not 100 times. Use MAE when your targets contain outliers that you do not want to dominate training.

The gradient needs more care. The derivative of |x| is sign(x). The MAE gradient is +1/n where the prediction is above the target and -1/n where it is below. At an exact tie, where y_pred == y_true, RustyML returns 0.0 instead of an arbitrary +1/n or -1/n. This is a deliberate subgradient choice. A perfect prediction gives a true zero gradient. The kink at zero is real. The gradient size does not shrink as the prediction approaches the target, unlike MSE, whose gradient scales with the residual. Plain SGD on MAE can therefore oscillate around the optimum. Pairing MAE with an adaptive optimizer that scales steps by gradient history reduces this oscillation in practice.

The sign logic has a final branch. It returns f32::NAN for any residual that is neither greater than 0, less than 0, nor equal to 0. This branch only fires for a NaN residual. Finite inputs never reach it. If a NaN value has already entered your predictions, MAE does not convert it to a clean number. This matches the library’s policy: surface non-finite values instead of hiding them.

use rustyml::neural_network::Tensor;
use rustyml::neural_network::losses::{MeanAbsoluteError, MeanSquaredError};
use rustyml::neural_network::traits::Loss;
use ndarray::Array;

fn main() {
    // 4 clean points, plus 1 large outlier in the last position.
    let y_true: Tensor = Array::from_shape_vec(vec![5], vec![1.0_f32, 2.0, 3.0, 4.0, 5.0])
        .unwrap()
        .into_dyn();
    let y_pred: Tensor = Array::from_shape_vec(vec![5], vec![1.0_f32, 2.0, 3.0, 4.0, 15.0])
        .unwrap()
        .into_dyn();

    let mse = MeanSquaredError::new();
    let mae = MeanAbsoluteError::new();

    // The 10-unit error adds about 100/5 to MSE, but only about 10/5 to MAE.
    // The outlier dominates MSE. It does not dominate MAE.
    println!("MSE: {:.4}", mse.compute_loss(&y_true, &y_pred).unwrap());
    println!("MAE: {:.4}", mae.compute_loss(&y_true, &y_pred).unwrap());
}

3.3.4. Binary Cross-Entropy

BinaryCrossEntropy fits problems where each output is an independent yes/no decision. This covers 1 binary label per sample, or several independent binary labels per sample (multi-label classification). The forward value is mean(-[y * ln(p) + (1 - y) * ln(1 - p)]) over every element. Here, y is a 0.0/1.0 label and p is a predicted probability. Because it averages over every element, a multi-label output of shape [batch, labels] averages over batch * labels. This is the conventional mean binary cross-entropy.

Get 2 things right. First, the label format: y_true must hold 0.0 or 1.0 floats, not class indices or logits. Second, y_pred must be a probability in (0, 1). This loss has no logits mode. You must squash your network’s output into (0, 1) yourself, in practice with a Sigmoid final layer (see Dense Layers and Activations). This is a real difference from Keras. Keras’ BinaryCrossentropy accepts a from_logits flag. RustyML’s does not, so the Sigmoid layer is mandatory, not optional.

A prediction of exactly 0.0 or 1.0 could make ln(0) and division by zero produce NaN or Inf. To prevent this, the loss clips every probability into [1e-7, 1 - 1e-7] before it takes a log, in both the forward and gradient paths. Consider a prediction of exactly 1.0 for a positive label, a fully confident and correct case. It yields a tiny but finite loss (about 1e-7) and a finite gradient, not a zero loss and a NaN gradient.

use rustyml::neural_network::Tensor;
use rustyml::neural_network::losses::BinaryCrossEntropy;
use rustyml::neural_network::traits::Loss;
use ndarray::Array;

fn main() {
    let bce = BinaryCrossEntropy::new();

    // Labels are 0.0 or 1.0. Predictions are probabilities in (0, 1).
    let y_true: Tensor = Array::from_shape_vec(vec![4], vec![0.0_f32, 1.0, 1.0, 0.0])
        .unwrap()
        .into_dyn();
    let y_pred: Tensor = Array::from_shape_vec(vec![4], vec![0.1_f32, 0.9, 0.8, 0.2])
        .unwrap()
        .into_dyn();

    println!("BCE loss: {:.4}", bce.compute_loss(&y_true, &y_pred).unwrap());

    // Even at the boundary, clipping keeps the loss finite.
    let hard_pred: Tensor = Array::from_shape_vec(vec![4], vec![0.0_f32, 1.0, 1.0, 0.0])
        .unwrap()
        .into_dyn();
    let loss = bce.compute_loss(&y_true, &hard_pred).unwrap();
    assert!(loss.is_finite());
    println!("BCE at boundary predictions: {loss:.6}");
}

3.3.5. Categorical Cross-Entropy

CategoricalCrossEntropy handles mutually exclusive multi-class classification with one-hot targets. The last axis is always the class axis. Every axis before it indexes an independent prediction site. y_true and y_pred of shape [batch, classes] give 1 prediction per sample. The [batch, height, width, classes] output of a channels-last Conv2D softmax head gives 1 prediction per pixel, the per-pixel segmentation case. The loss sums the cross-entropy over the class axis. It then divides by the total number of prediction sites, the product of the leading axes. That divisor is batch at rank 2, and batch * height * width at rank 4.

The loss also requires at least a 2-D, non-empty input. A 1-D input is rejected with Error::InvalidInput. A 1-D tensor has no leading axis, which would leave a divisor of 1 and a softmax over the only axis present. An empty input is rejected with Error::EmptyInput.

The constructor takes 1 boolean argument, from_logits. It controls 2 distinct modes:

CategoricalCrossEntropy::new(false) // y_pred is already a probability distribution
CategoricalCrossEntropy::new(true)  // y_pred is raw logits, the loss applies softmax internally

With from_logits = false, the default, y_pred should be a probability distribution along its last axis, the output of a Softmax layer. Following Keras, the loss first renormalizes each row with y_pred / sum(y_pred, axis=-1). Only then does it clip into [1e-7, 1 - 1e-7], giving -sum(y_true * ln(q_clipped)) / sites.

A softmax row already sums to 1, so this division does not change the loss value. The division is still part of what compute_grad differentiates. The gradient is (sum(y_true) - y_true / q_clipped) / (row_sum * sites). The whole bracket is divided by the row sum. Written in terms of the unnormalized p, this is (sum(y_true) / row_sum - y_true / p) / sites. That is the familiar -y/p term, plus a term that stays constant across each row. A softmax backward pass cancels any row-constant term, so a softmax head trains the same either way. The extra term only matters when this loss reads a head that is not a softmax.

The renormalization has 1 practical benefit. A head whose rows do not quite sum to 1 gets scored as the distribution it implies, instead of being penalized for its scale.

With from_logits = true, the loss treats y_pred as raw, unnormalized logits. It applies softmax itself, using a numerically stable log-softmax. Prefer this mode for training, for 2 reasons.

The first reason is stability. Computing softmax(z) and then ln(...) naively overflows exp for large logits. It also loses precision when it takes the log of an already-clipped near-zero probability. The internal path instead subtracts each prediction site’s maximum before it exponentiates, so exp never overflows. It then computes log_softmax = z - logsumexp(z) directly, so it never logs a clipped probability. This normalization runs within 1 prediction site, never across sites. The pixels of a rank-4 conv head therefore do not compete with each other for probability mass.

The second reason is efficiency. The gradient with respect to the logits collapses to the fused form (softmax(z) - y_true) / sites. This form is cheaper to compute. It also avoids the -y/p division, which explodes when p is near 0.

This has 1 practical consequence. The gradient in logits mode is with respect to the logits. Your network’s final layer must therefore output logits, not probabilities. Use a Linear final activation, with no Softmax layer. If you keep a Softmax layer and also pass from_logits = true, the model applies softmax twice, which corrupts training.

use rustyml::neural_network::Tensor;
use rustyml::neural_network::losses::CategoricalCrossEntropy;
use rustyml::neural_network::traits::Loss;
use ndarray::Array;

fn main() {
    // Probability mode: y_pred must already be a softmax distribution.
    let cce_probs = CategoricalCrossEntropy::new(false);
    let y_true: Tensor = Array::from_shape_vec(
        vec![3, 3],
        vec![1.0_f32, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
    )
    .unwrap()
    .into_dyn();
    let probs: Tensor = Array::from_shape_vec(
        vec![3, 3],
        vec![0.8_f32, 0.1, 0.1, 0.2, 0.7, 0.1, 0.1, 0.2, 0.7],
    )
    .unwrap()
    .into_dyn();
    println!("CCE (probs): {:.4}", cce_probs.compute_loss(&y_true, &probs).unwrap());

    // Logits mode: y_pred is raw logits, and the loss applies softmax internally.
    let cce_logits = CategoricalCrossEntropy::new(true);
    let logits: Tensor = Array::from_shape_vec(vec![1, 3], vec![1.0_f32, 2.0, 0.5])
        .unwrap()
        .into_dyn();
    let label: Tensor = Array::from_shape_vec(vec![1, 3], vec![0.0_f32, 1.0, 0.0])
        .unwrap()
        .into_dyn();
    let loss = cce_logits.compute_loss(&label, &logits).unwrap();
    let grad = cce_logits.compute_grad(&label, &logits).unwrap();
    // grad is the fused (softmax(logits) - one_hot) / prediction sites.
    println!("CCE (logits): {loss:.4}, grad shape {:?}", grad.shape());
}

3.3.6. Sparse Categorical Cross-Entropy

SparseCategoricalCrossEntropy computes the same loss as CategoricalCrossEntropy, but it takes integer class labels instead of one-hot vectors. y_true is a [batch, 1] tensor of class indices, stored as f32 and rounded to the nearest integer. y_pred is a [batch, num_classes] tensor of probabilities or logits. The tests confirm the equivalence. For the same predictions, the sparse loss on integer labels and the dense CCE on the matching one-hot encoding agree to floating-point round-off. Their gradients agree too.

The shape rules are strict. Without them, a malformed label would cause an opaque index-out-of-bounds panic. y_pred must be exactly 2-D. A 3-D prediction is rejected with Error::InvalidInput. y_true must be exactly [batch, 1]. A 1-D label vector or a [batch, 2] shape is rejected. This is a real difference from Keras, whose sparse categorical crossentropy takes a 1-D [batch] label. Each label must be finite, non-negative, and strictly less than num_classes. Negative or out-of-range labels come back as Error::InvalidInput. A batch-size mismatch between labels and predictions comes back as Error::DimensionMismatch. Validation happens once, up front. It extracts a Vec<usize> of class indices before any arithmetic runs.

The memory savings grow with the number of classes. A dense one-hot target is [batch, num_classes] of f32, or batch * num_classes * 4 bytes, almost all of it zeros. The sparse label is [batch, 1], or batch * 4 bytes. This is a num_classes-fold reduction on the target tensor, and you never build the one-hot matrix at all. For a vocabulary of tens of thousands of classes, this is the difference between a trivial label tensor and one larger than the predictions themselves. Use the sparse loss when your labels are naturally integers and num_classes is large. Use the dense CCE when you already have one-hot, or soft, targets.

The from_logits flag works the same way as it does for dense CCE. When false, the loss expects per-row probabilities. It renormalizes each row before the clip, exactly as CategoricalCrossEntropy does, so it scores an unnormalized head as the distribution it implies. The row-normalizer is also part of what compute_grad differentiates. Because of this, every class picks up a shared 1 / (batch * row_sum) term in the gradient, not only the labeled class. A softmax head cancels this shared term in its backward pass, the same way it does for dense CCE. When true, the loss expects logits and applies the same stable log-softmax. The fused gradient is softmax with 1 subtracted at the true class, divided by the batch.

The probability path also sums the per-sample losses serially, instead of with a parallel reduction. This keeps the reported loss from drifting with thread scheduling. See Reproducibility and Random Seeds.

use rustyml::neural_network::Tensor;
use rustyml::neural_network::losses::SparseCategoricalCrossEntropy;
use rustyml::neural_network::traits::Loss;
use ndarray::Array;

fn main() {
    let scce = SparseCategoricalCrossEntropy::new(false);

    // Labels are class indices in a [batch, 1] column, not one-hot rows.
    let y_true: Tensor = Array::from_shape_vec(vec![3, 1], vec![0.0_f32, 1.0, 2.0])
        .unwrap()
        .into_dyn();
    let y_pred: Tensor = Array::from_shape_vec(
        vec![3, 3],
        vec![0.8_f32, 0.1, 0.1, 0.2, 0.7, 0.1, 0.1, 0.2, 0.7],
    )
    .unwrap()
    .into_dyn();

    let loss = scce.compute_loss(&y_true, &y_pred).unwrap();
    let grad = scce.compute_grad(&y_true, &y_pred).unwrap();
    println!("SCCE loss: {loss:.4}");        // equals dense CCE on the one-hot equivalent
    println!("grad shape: {:?}", grad.shape());

    // RustyML rejects out-of-range and negative labels, instead of wrapping them silently.
    let bad: Tensor = Array::from_shape_vec(vec![3, 1], vec![0.0_f32, 1.0, 9.0])
        .unwrap()
        .into_dyn();
    assert!(scce.compute_loss(&bad, &y_pred).is_err());
}

3.3.7. Pairing activations with losses

The final-layer activation and the loss form a matched pair. Mismatch them, and you either feed the loss the wrong domain (probabilities where it wants logits, or the reverse), or you apply softmax twice. The table below lists the correct combinations, and why each one works.

TaskFinal activationLossWhy they pair
RegressionLinearMeanSquaredError / MeanAbsoluteErroroutputs are unbounded reals, so squared or absolute error is the natural objective and needs no squashing
Binary / multi-labelSigmoidBinaryCrossEntropysigmoid maps each logit independently into (0, 1). BCE takes probabilities and has no logits mode, so the sigmoid is required
Multi-class, probability modeSoftmaxCategoricalCrossEntropy::new(false) / SparseCategoricalCrossEntropy::new(false)softmax normalizes the row into a distribution, which is exactly what these losses expect
Multi-class, logits mode (preferred)LinearCategoricalCrossEntropy::new(true) / SparseCategoricalCrossEntropy::new(true)the loss applies a stable softmax internally and returns the fused gradient. Adding a Softmax layer here would apply softmax twice

The distinction between the two classification rows matters. Sigmoid plus BinaryCrossEntropy fits independent binary outputs, where a sample can belong to several labels at once. Softmax plus CategoricalCrossEntropy fits mutually exclusive classes, where the probabilities across classes sum to 1. Do not use softmax where the labels are independent. Do not use sigmoid where the labels compete.

The model below builds the preferred multi-class setup: a Linear output feeding CategoricalCrossEntropy in logits mode. It has no Softmax layer, because the loss applies the softmax itself.

use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::layers::{Activation, Dense};
use rustyml::neural_network::optimizers::Adam;
use rustyml::neural_network::losses::CategoricalCrossEntropy;
use ndarray::Array;

fn main() {
    // 4 samples, 3 features -> 3 classes.
    let x = Array::from_shape_vec(
        vec![4, 3],
        vec![
            0.1_f32, 0.2, 0.7, 0.9, 0.1, 0.0, 0.2, 0.8, 0.1, 0.7, 0.2, 0.1,
        ],
    )
    .unwrap()
    .into_dyn();
    // One-hot targets: classes 2, 0, 1, 0.
    let y = Array::from_shape_vec(
        vec![4, 3],
        vec![
            0.0_f32, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0,
        ],
    )
    .unwrap()
    .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(3, 8, Activation::ReLU).unwrap())
        .add(Dense::new(8, 3, Activation::Linear).unwrap()); // logits, no Softmax

    model.compile(
        Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(),
        CategoricalCrossEntropy::new(true), // from_logits = true
    );

    model.fit(&x, &y, 5).unwrap();
    let preds = model.predict(&x).unwrap();
    println!("prediction shape: {:?}", preds.shape());
}

3.3.8. Numerical stability and edge behavior

3 mechanisms keep these losses finite at the edges of their domains. Each guards a different failure.

Probability clipping guards the cross-entropy losses whenever they take probabilities as input. This covers BinaryCrossEntropy, and CategoricalCrossEntropy / SparseCategoricalCrossEntropy in from_logits = false mode. Every probability is clamped into [1e-7, 1 - 1e-7] before any ln. A prediction of exactly 0.0 or 1.0 therefore produces a large but finite loss and a finite gradient, instead of NaN or Inf.

The clip guards against log(0). It does not gate the gradient. Unlike Keras’ autodiff, compute_grad evaluates at the clipped probability. It does not zero out clipped positions. Loss and gradient agree everywhere inside the interval.

For the categorical losses, the row renormalization happens before the clip. A row that sums to zero still yields a non-finite result, exactly as it does in Keras. The tests check this directly: fully confident predictions and worst-case boundary predictions both stay finite.

The stable log-softmax guards the logits paths. Instead of forming softmax(z) and logging it, the loss subtracts each prediction site’s maximum before it exponentiates. It then computes log_softmax = z - logsumexp(z) directly. This makes from_logits = true more than a convenience. It is a more reliable way to train a classifier. It never overflows exp. It never logs a clipped probability. It returns the well-conditioned fused gradient softmax(z) - y. Prefer this mode when you have a choice.

Nothing sanitizes NaN. MAE’s sign branch returns f32::NAN for a NaN residual, and a NaN prediction propagates through the other losses too. A non-finite value stays non-finite, by design, so divergence surfaces loudly instead of hiding. To tame large but finite gradients before they diverge, clip on the optimizer with clip-by-global-norm, instead of clamping inside the loss.

After training, to score a model, use the dedicated evaluators in Classification Metrics and Regression Metrics. A loss is tuned to be a smooth training signal, not a human-readable report.

3.4. Optimizers

An optimizer in RustyML turns gradients into parameter updates. A layer’s backward pass stashes a gradient for every trainable tensor. The optimizer then walks those tensors and moves each one downhill. You pick an optimizer when you call compile on a Sequential model. You also pick a loss function at the same time. After that, fit drives the optimizer for you.

RustyML has 5 optimizers: SGD, Adam, AdamW, RMSprop, and AdaGrad. All 5 live in rustyml::neural_network::optimizers. The prelude re-exports them. Every constructor returns Result<Self, Error> because it checks its hyperparameters first. Each example below ends its constructor call with .unwrap(), or with real error handling.

The algorithms match the ones in Keras and PyTorch. 2 things differ. The constructors take positional arguments. No argument has a default value. Gradient clipping and weight decay are also part of the optimizer itself, not the training loop.

3.4.1. The optimizer interface and how updates flow

Every optimizer implements the Optimizer trait. The trait has 5 methods, and you use all of them. The training loop calls 3 of the methods for you. A learning-rate schedule reads and writes the other 2, learning_rate and set_learning_rate (see 3.4.8):

pub trait Optimizer {
    fn step(&mut self);                                         // once per batch
    fn update(&mut self, layer: &mut dyn Layer, grad_scale: f32); // once per layer
    fn global_clipnorm(&self) -> Option<f32>;                        // clip threshold, or None
    fn learning_rate(&self) -> f32;                            // current step size
    fn set_learning_rate(&mut self, learning_rate: f32);       // scheduling hook
}

The training loop calls step exactly once per batch, before it touches any layer. It then calls update once per layer. A stateful optimizer advances its notion of time inside step. Adam and AdamW increment a bias-correction timestep there. SGD, RMSprop, and AdaGrad only use step to rewind an internal cursor.

This is why Adam advances its timestep once per batch, not once per layer. A hand-rolled training loop must call step once per batch, not once per layer or once per parameter. Calling it more often breaks Adam’s bias-correction math without any error message.

update receives a grad_scale factor from the training loop. The loop computes grad_scale from global_clipnorm (see below). When clipping is off, grad_scale is 1.0. Inside update, the optimizer asks the layer for its parameters through layer.parameters(). This call returns a flat ParamGrad for each trainable tensor. A ParamGrad holds a mutable value slice, a matching grad slice, and a decays: bool flag.

The decays flag keeps weight decay honest. Weight matrices and convolution or recurrent kernels carry decays = true. Biases and normalization gamma and beta carry decays = false. RustyML never decays a parameter with decays = false, no matter what weight_decay you set. Layers set this flag, and optimizers respect it. You do not manage it yourself.

Layers expose parameters as flat &mut [f32] slices. Because of this, a single per-element kernel handles any tensor shape. Each kernel also switches to a Rayon parallel path once a tensor crosses an element-count threshold (see 7.3).

An optimizer keys its per-parameter state by the position at which a layer yields each tensor. The parameter order must therefore stay stable across steps, and it does. A given optimizer instance belongs to one model. Do not share an optimizer instance across models.

3.4.2. SGD

SGD::new(learning_rate, momentum, nesterov, weight_decay) // all f32 except nesterov: bool

SGD is plain stochastic gradient descent. It supports optional momentum, Nesterov acceleration, and decoupled weight decay. With momentum = 0.0, the update is the textbook form:

param -= lr * grad

Set momentum > 0.0 to accumulate a per-parameter velocity buffer. You can also add the Nesterov look-ahead step:

v    = momentum * v + grad
step = grad + momentum * v   (nesterov = true)   or   v   (nesterov = false)
param -= lr * step

0.9 is the conventional momentum value. nesterov only matters when momentum is non-zero. The weight_decay here is decoupled, the SGDW formulation. Before the gradient step, SGD shrinks weight tensors by param *= (1 - lr * weight_decay), independent of the gradient. AdamW uses this same decoupling, applied here to SGD.

use rustyml::prelude::*;
use rustyml::neural_network::sequential::Sequential;
use ndarray::Array;

fn main() {
    // Tiny inline regression: y = 2*x
    let x = Array::from_shape_vec((4, 1), vec![0.5_f32, 1.0, 1.5, 2.0])
        .unwrap()
        .into_dyn();
    let y = Array::from_shape_vec((4, 1), vec![1.0_f32, 2.0, 3.0, 4.0])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(1, 1, Activation::Linear).unwrap())
        // learning_rate, momentum, nesterov, weight_decay
        .compile(SGD::new(0.05, 0.9, true, 0.0).unwrap(), MeanSquaredError::new());

    let before = model.predict(&x).unwrap();
    model.fit(&x, &y, 10).unwrap();
    let after = model.predict(&x).unwrap();
    println!("shapes: {:?} -> {:?}", before.shape(), after.shape());
}

Use SGD with momentum when you want fine control and predictable behavior. Its dynamics are well understood. Its single moving average is cheap to keep. SGD with momentum still sets the generalization standard that other methods get measured against.

The cost is sensitivity to the learning rate. A rate too large makes training diverge. A rate too small makes training slow. Adam removes much of this sensitivity.

3.4.3. Adam

Adam::new(learning_rate, beta1, beta2, epsilon, weight_decay) // all f32

Adam keeps 2 per-parameter moving averages. The first moment, m, tracks the mean gradient. The second moment, v, tracks the mean squared gradient. Adam uses both to give every parameter its own adaptive step size. The full update, run per element, is:

t += 1                                  (advanced once per batch, in step())
m = beta1*m + (1 - beta1)*grad
v = beta2*v + (1 - beta2)*grad^2
m_hat = m / (1 - beta1^t)               <- bias correction
v_hat = v / (1 - beta2^t)               <- bias correction
param -= lr * m_hat / (sqrt(v_hat) + epsilon)

Bias correction matters because m and v start at zero. On the first few steps, this pulls both moments toward zero. The pull is strong for v, because beta2 = 0.999 keeps v close to zero at t = 1.

Dividing by (1 - beta^t) rescales the moments. At t = 1, the denominator 1 - beta1 cancels the (1 - beta1) factor already in m. This recovers the raw gradient. As t grows, beta^t approaches 0, and the correction fades to a no-op.

This is why the timestep advances inside step, once per batch, and not inside update. With full-batch fit, t equals the epoch count. With fit_with_batches, t advances once per mini-batch. The timestep saturates instead of overflowing. Extremely long training runs therefore stay well defined.

Adam adds epsilon outside the square root. RMSprop and AdaGrad (3.4.5 and 3.4.6) add their epsilon inside it. This looks like an inconsistency, but it is deliberate. Keras splits epsilon placement the same way. RustyML matches Keras optimizer by optimizer, instead of forcing one form on all 3.

As a result, epsilon is not on the same scale in every optimizer. Read the scale note under RMSprop before you carry an epsilon value from one optimizer to another.

Adam’s weight_decay implements classic coupled L2 regularization. Adam folds weight_decay * param into the gradient before the moment update. The penalty therefore flows through m and v, and the adaptive 1 / (sqrt(v_hat) + epsilon) denominator rescales it. This coupling is usually not what you want. See AdamW, next, for the alternative.

With weight_decay = 0.0, the coupling has no effect, and Adam and AdamW become byte-for-byte identical. RustyML keeps the coupling in Adam on purpose. This is a deliberate divergence from Keras 3.

Keras 3’s base optimizer applies decoupled decay to every optimizer. This makes Adam(weight_decay=x) and AdamW(weight_decay=x) numerically the same in Keras. RustyML instead follows PyTorch. In PyTorch, torch.optim.Adam(weight_decay=) is coupled, and AdamW’s is not. This keeps the 2 names genuinely distinct in RustyML.

use rustyml::prelude::*;
use rustyml::neural_network::sequential::Sequential;
use ndarray::Array;

fn main() {
    // Tiny inline regression: y = x0 + 2*x1
    let x = Array::from_shape_vec((4, 2), vec![0.0_f32, 1.0, 1.0, 0.0, 1.0, 1.0, 2.0, 1.0])
        .unwrap()
        .into_dyn();
    let y = Array::from_shape_vec((4, 1), vec![2.0_f32, 1.0, 3.0, 4.0])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(2, 8, Activation::ReLU).unwrap())
        .add(Dense::new(8, 1, Activation::Linear).unwrap())
        // learning_rate, beta1, beta2, epsilon, weight_decay
        .compile(Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(), MeanSquaredError::new());

    model.fit(&x, &y, 50).unwrap();

    let preds = model.predict(&x).unwrap();
    println!("prediction shape: {:?}", preds.shape());
}

Adam::new(0.001, 0.9, 0.999, 1e-8, 0.0) is the default choice. Use this configuration when you are not sure which optimizer to pick. It tolerates a wide range of learning rates. It converges quickly on messy loss surfaces. It needs almost no tuning to start training.

3.4.4. AdamW

AdamW::new(learning_rate, beta1, beta2, epsilon, weight_decay) // identical signature to Adam

AdamW runs the same moment math and bias correction as Adam. The only difference is where weight decay enters the update. This difference matters as soon as you turn decay on. AdamW is decoupled, the Loshchilov and Hutter formulation.

AdamW shrinks the weight directly, param *= (1 - lr * weight_decay), before an ordinary Adam step. The decay never touches m or v. The adaptive denominator never divides it.

This distinction is not cosmetic. In Adam’s coupled scheme, the weight_decay * param term rides through the second moment v. A parameter that has seen large gradients gets a large v and a large denominator. It therefore gets less effective decay than a quiet parameter. Regularization strength ends up tangled with each weight’s gradient history. This is the opposite of the uniform shrink that weight decay is supposed to give.

AdamW severs this link. Every weight decays by the same (1 - lr * weight_decay) factor, regardless of its gradients. For this reason, AdamW generalizes better. It is the standard choice when you care about regularizing a model.

The practical rule is this: use AdamW for any non-zero weight decay with an adaptive optimizer, not Adam’s weight_decay. Use plain Adam, with weight_decay = 0.0, when you apply no regularization at all. At weight_decay = 0.0, the 2 optimizers run the same algorithm. There is no reason to prefer Adam over AdamW, except habit.

use rustyml::prelude::*;
use rustyml::neural_network::sequential::Sequential;
use ndarray::Array;

fn main() {
    let x = Array::from_shape_vec(
        (4, 3),
        vec![0.0_f32, 1.0, 2.0, 1.0, 0.0, 1.0, 2.0, 1.0, 0.0, 1.0, 1.0, 1.0],
    )
    .unwrap()
    .into_dyn();
    let y = Array::from_shape_vec((4, 1), vec![1.0_f32, 0.5, 0.5, 0.75])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(3, 16, Activation::ReLU).unwrap())
        .add(Dense::new(16, 1, Activation::Linear).unwrap())
        // decoupled weight_decay = 0.01
        .compile(
            AdamW::new(0.01, 0.9, 0.999, 1e-8, 0.01).unwrap(),
            MeanSquaredError::new(),
        );

    model.fit(&x, &y, 30).unwrap();
    println!("trained: {:?}", model.predict(&x).unwrap().shape());
}

3.4.5. RMSprop

RMSprop::new(learning_rate, rho, epsilon, weight_decay) // all f32

The type name is RMSprop, with a lowercase p. RMSprop keeps one moving average of squared gradients per parameter. It normalizes each step by the square root of that average:

cache = rho*cache + (1 - rho)*grad^2
param -= lr * grad / sqrt(cache + epsilon)

rho is the squared-gradient decay rate, conventionally 0.9. It is RMSprop’s answer to AdaGrad’s main flaw. cache is an exponential moving average, not a running sum. Because of this, cache forgets old gradients and never grows without bound. The effective step size therefore stabilizes, instead of decaying to zero.

You can think of RMSprop as Adam without the first moment. It gives adaptive per-parameter scaling, but no momentum and no bias correction. Its weight_decay is decoupled, in the AdamW style, and applies to weight tensors before the adaptive step. RMSprop suits recurrent networks and non-stationary objectives. On most other problems, Adam covers the same ground.

epsilon goes inside the root here, matching Keras’ sqrt(velocity + epsilon). This placement is not cosmetic. It decides the scale on which epsilon is measured. cache accumulates squared gradients.

An epsilon added before the root therefore lives on the grad^2 scale. An epsilon added after the root, as in Adam above and in PyTorch’s RMSprop, lives on the grad scale. The 2 scales correspond roughly as eps_inside = eps_outside^2.

Keras’ default of 1e-7 inside the root gives the same guard as 3.2e-4 outside it. Do not reuse the same epsilon value in both forms. It is off by orders of magnitude in the other form. AdaGrad, below, also uses the inside form.

3.4.6. AdaGrad

AdaGrad::new(learning_rate, epsilon, weight_decay) // all f32, no rho or beta

AdaGrad accumulates squared gradients and never forgets them:

accumulator += grad^2
param -= lr * grad / sqrt(accumulator + epsilon)

The accumulator only grows. sqrt(accumulator) therefore only ever increases. The effective learning rate, lr / sqrt(accumulator + epsilon), decays monotonically toward zero. This is AdaGrad’s defining property, and it cuts both ways.

For convex problems and sparse features, this decay is a benefit. A rarely-seen parameter keeps a large step. A frequently-updated parameter anneals automatically. The decay to zero also gives clean convergence guarantees.

For a deep network trained over many steps, this decay is a drawback. The step size shrinks until learning effectively stalls. RMSprop’s decay factor and Adam’s second-moment average exist to fix exactly this problem.

learning_rate here is genuinely an initial rate, typically 0.01. It sets a ceiling, and the accumulator only pulls the effective rate down from there. Its epsilon, like RMSprop’s, sits inside the root, so it is on the squared-gradient scale. Weight decay, as with the other optimizers, is decoupled and applies to weights only.

3.4.7. Parameter validation

Every constructor validates its hyperparameters before it returns. A bad hyperparameter therefore fails at construction time, with Error::InvalidParameter, instead of producing NaN values well into training. The table below lists the rules, taken directly from the validators:

ParameterAccepted valuesApplies to
learning_ratepositive and finite (> 0)all five
momentumnon-negative and finite (>= 0)SGD
nesterovany bool (never validated)SGD
beta1, beta2in [0, 1) and finiteAdam, AdamW
rhoin [0, 1) and finiteRMSprop
epsilonpositive and finite (> 0)Adam, AdamW, RMSprop, AdaGrad
weight_decaynon-negative and finite (>= 0)all five
global_clipnormpositive and finite (> 0)all five (via with_global_clipnorm)

The decay-rate ranges are half-open on purpose. beta1 = 0.0 and rho = 0.0 are valid, an inclusive lower bound. 1.0 is invalid, an exclusive upper bound. A decay of 1.0 would freeze the moving average and never take in a new gradient.

SGD has no epsilon, because it never divides by an adaptive denominator. 0.0 is valid for momentum and weight_decay. This is how you turn off those features. 0.0 is invalid for learning_rate and epsilon.

epsilon is validated the same way for all 4 adaptive optimizers, but it does not mean the same thing in each. RMSprop and AdaGrad add epsilon inside the square root, on the squared-gradient scale. Adam and AdamW add it outside, on the gradient scale. A value that is sane for one pair is roughly the square, or the square root, of a sane value for the other pair.

use rustyml::neural_network::optimizers::{AdaGrad, Adam, AdamW, RMSprop, SGD};
use rustyml::error::Error;

fn main() {
    // learning_rate must be positive and finite
    assert!(matches!(
        SGD::new(0.0, 0.0, false, 0.0),
        Err(Error::InvalidParameter { .. })
    ));
    // beta1 must be in [0, 1): 1.0 is out of range
    assert!(matches!(
        Adam::new(0.001, 1.0, 0.999, 1e-8, 0.0),
        Err(Error::InvalidParameter { .. })
    ));
    // epsilon must be positive and finite
    assert!(matches!(
        RMSprop::new(0.01, 0.9, 0.0, 0.0),
        Err(Error::InvalidParameter { .. })
    ));
    // weight_decay must be non-negative and finite
    assert!(matches!(
        AdaGrad::new(0.01, 1e-8, -0.1),
        Err(Error::InvalidParameter { .. })
    ));
    // global_clipnorm must be positive and finite
    assert!(matches!(
        AdamW::new(0.001, 0.9, 0.999, 1e-8, 0.0)
            .unwrap()
            .with_global_clipnorm(0.0),
        Err(Error::InvalidParameter { .. })
    ));

    // A fully valid configuration, clipping enabled
    let opt = Adam::new(0.001, 0.9, 0.999, 1e-8, 0.0).unwrap();
    let _clipped = opt.with_global_clipnorm(1.0).unwrap();
    println!("validation checks passed");
}

3.4.8. Gradient clipping and learning-rate scheduling

Gradient clipping is off by default. Enable it through a consuming builder, with_global_clipnorm, present on all 5 optimizers. This is clip-by-global-norm, not per-element clamping. The training loop sums the squared gradients across every tensor in the model and takes the global L2 norm.

If the global norm exceeds your threshold, the loop scales every gradient by a single factor, max_norm / global_norm, before the update. This one uniform factor preserves the descent direction exactly. Per-element clamping would bend that direction instead. A non-finite global norm is deliberately left unscaled, so a genuine divergence still shows up as NaN, instead of being masked. This clipping is the recommended way to tame large but finite gradients, for example in RNNs or deep stacks. The backward pass itself applies no clamping.

The name matches Keras’ global_clipnorm exactly. If you port a Keras model that sets clipnorm instead, note the difference. Keras’ clipnorm renormalizes each variable’s gradient independently. Once more than one tensor is over the limit, this points in a different direction entirely. RustyML has only the global form. A clipnorm threshold from Keras therefore cannot carry over unchanged.

Learning-rate scheduling uses a read and write pair, learning_rate and set_learning_rate. The model exposes them as Sequential::learning_rate, which returns Option<f32> and is None until you compile, and as Sequential::set_learning_rate. Call the setter between epochs or batches to retune the step size. This preserves all accumulated state, such as Adam’s moments, SGD’s velocities, and RMSprop’s cache. You retune the same optimizer instance. You do not reset it.

RustyML has no built-in scheduler object. You write the schedule as a plain loop instead. The getter exists so that loop does not need its own copy of the learning rate. A separate copy would go stale the moment anything else retunes the optimizer.

The getter returns whatever was last written. Unlike the constructors, set_learning_rate validates nothing. A zero or negative rate is therefore stored and read back unchanged, not rejected.

use rustyml::prelude::*;
use rustyml::neural_network::sequential::Sequential;
use ndarray::Array;

fn main() {
    let x = Array::from_shape_vec((4, 2), vec![0.0_f32, 1.0, 1.0, 0.0, 1.0, 1.0, 2.0, 1.0])
        .unwrap()
        .into_dyn();
    let y = Array::from_shape_vec((4, 1), vec![2.0_f32, 1.0, 3.0, 4.0])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(2, 8, Activation::ReLU).unwrap())
        .add(Dense::new(8, 1, Activation::Linear).unwrap())
        .compile(
            Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0)
                .unwrap()
                .with_global_clipnorm(1.0) // clip the global gradient norm to 1.0
                .unwrap(),
            MeanSquaredError::new(),
        );

    // Manual step decay: halve the LR each block. The optimizer keeps all state
    for _ in 0..3 {
        model.fit(&x, &y, 10).unwrap();
        // The optimizer holds the rate, so read it back instead of shadowing it
        let lr = model.learning_rate().unwrap();
        model.set_learning_rate(lr * 0.5);
    }

    assert_eq!(model.learning_rate(), Some(0.01_f32 / 8.0));
}

3.4.9. Per-layer state, memory, and persistence

Each optimizer allocates its state lazily. It creates one buffer per parameter tensor, sized to that tensor, the first time update reaches it. The buffers are indexed by the order in which layers yield parameters. If a tensor’s length changes at a given position, RustyML resets that buffer to match.

This is why the 2-layer convergence tests check correct buffer allocation across layers. The optimizer’s state must line up with the parameters it shadows.

The memory cost is the number and size of those buffers:

OptimizerPer-parameter stateExtra memory vs. parameters
SGD, momentum = 0.0none0x
SGD, momentum > 0.0velocity1x
RMSpropsquared-gradient cache1x
AdaGradsquared-gradient accumulator1x
Adam, AdamWfirst moment m + second moment v2x

For a model with P weights in f32, Adam and AdamW carry roughly 2 * 4 * P bytes of optimizer state. This is on top of the parameters and their gradients themselves. This is the price of adaptivity. It is also why a model that trains fine under SGD can run out of memory under Adam. Plain SGD carries no extra state. On a tight memory budget, this can be the deciding factor.

This state lives entirely in the optimizer instance. RustyML does not persist it. Sequential::save_to_path serializes layer architecture and weights only. It explicitly excludes the optimizer and the loss. After load_from_path, you must compile a fresh optimizer. Its moments, velocities, and timestep all start from zero.

Reloading weights and resuming training therefore restarts Adam’s bias-correction warmup. This is usually harmless. It matters if you checkpoint mid run. See 3.9. Saving and Loading Weights and 7.2. Model Persistence in Depth for the full persistence story.

3.4.10. Choosing an optimizer

Start with Adam, using 0.001, 0.9, 0.999, 1e-8, 0.0. Adam is the least fussy optimizer about learning rate. It gets almost any model training. This is what you want while you are still exploring the architecture.

Use SGD with momentum, momentum = 0.9 and nesterov = true, when you want fine control and are willing to tune the learning rate. Its generalization is the reference standard. Its state is cheap. Its behavior is easy to predict.

Use AdamW with a non-zero weight_decay whenever regularization is the goal. Its decoupled decay is the correct way to regularize an adaptive optimizer. Prefer it over Adam’s coupled weight_decay in every case where decay is on.

RMSprop is a good fallback for recurrent networks and non-stationary problems. AdaGrad performs well on convex, sparse-feature problems, where its decaying step size is an asset rather than a liability. Avoid AdaGrad for long deep-network runs, where that same decay stalls learning.

One subtlety cuts across all of these choices. The effective learning rate is tied to your loss function’s averaging convention. Switching between a per-element loss and a per-prediction-site loss changes the gradient magnitude. Per-element losses include MSE, MAE, and binary cross-entropy. A per-prediction-site loss is categorical cross-entropy. This change in magnitude also changes the step size.

See the averaging note in 3.3. Loss Functions for more on this. If you change the loss, you will likely need to retune the learning rate, whichever optimizer you chose.

3.5. Convolutional Layers

RustyML ships 5 convolutional layers: Conv1D, Conv2D, Conv3D, DepthwiseConv2D, and SeparableConv2D. All 5 live in rustyml::neural_network::layers, and the layers glob re-exports them. They share 1 design. A layer struct holds the weights, the bias, the activation, and the forward and backward caches. The layer delegates the actual numerics for the plain convolutions to a single dimension-generic engine.

If you know Keras, you already know the layouts and weight shapes here: tensors are channels-last, and kernels carry their taps first. 1 difference catches new users early. You pass the full input shape into the constructor. This lets the layer size its weights up front, instead of inferring them lazily on the first batch.

This page covers the layouts, the constructor and padding rules, and the im2col plus GEMM engine behind the standard convolutions. It also covers the depthwise and separable factorization with its cost math, and the error types the layers return.

3.5.1. Tensor Layouts and the Layer Family

Every convolution here is channels-last. The spatial axes follow the batch axis, and the channel axis comes last. A Conv2D reads [batch, height, width, channels] and writes [batch, out_height, out_width, filters]. This is the Keras and TensorFlow NHWC convention.

A tensor built for Keras needs no permutation. If you build inputs by hand with ndarray, put the channels last. The weight tensor uses Keras’ kernel shape too: the kernel taps come first, then the input channels, then the filters.

That is not only an interface choice. With the channel axis innermost, a kernel tap at a given output position is Cin contiguous floats. This lets the engine build its im2col matrix from run copies, instead of a scalar gather. It also lets the flat weight matrix line up with the im2col matrix without any permutation.

LayerInput tensorWeight tensorBiasOutput tensor
Conv1D[N, L, Cin][k, Cin, F][F][N, L', F]
Conv2D[N, H, W, Cin][kh, kw, Cin, F][F][N, H', W', F]
Conv3D[N, D, H, W, Cin][kd, kh, kw, Cin, F][F][N, D', H', W', F]
DepthwiseConv2D[N, H, W, C][kh, kw, C, dm][C*dm][N, H', W', C*dm]
SeparableConv2D[N, H, W, Cin]depthwise [kh, kw, Cin, dm], pointwise [1, 1, Cin*dm, F][F][N, H', W', F]

Every shape in the table matches Keras, so a kernel exported from Keras drops in without a permutation. DepthwiseConv2D and SeparableConv2D need more explanation. DepthwiseConv2D has no filters argument at all. It applies depth_multiplier kernels to each input channel and emits C * dm channels. Input channel c’s multiplier m lands at output channel c * dm + m.

SeparableConv2D holds 2 weight tensors because it fuses 2 convolutions into 1 layer. The depthwise stage emits its channels in that same c * dm + m order. This means the pointwise weight’s rows already match the depthwise output, so the layer needs no repacking between the stages. Section 3.5.4 covers this in more detail.

The forward math is cross-correlation, the same convention Keras and PyTorch use. The engine does not flip the kernel. The bias is added last, once per filter, after the multiply-accumulate step. Weights start from Xavier/Glorot uniform bounds. Biases start at zero.

The following example sets the weights by hand and runs 1 forward pass. It shows the layout and the Valid-padding output size.

use ndarray::{Array, Array1, Array4};
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;

fn main() {
    // Channels-last: [batch, height, width, channels].
    let mut layer = Conv2D::new(1, (2, 2), vec![1, 4, 4, 1], (1, 1), Activation::Linear).unwrap();

    // Weights are [kernel_h, kernel_w, channels, filters]. Bias is [filters].
    let weights = Array4::from_elem((2, 2, 1, 1), 1.0f32);
    let bias = Array1::zeros(1);
    layer.set_weights(weights, bias).unwrap();

    let pixels: Vec<f32> = (1..=16).map(|v| v as f32).collect();
    let x = Array::from_shape_vec((1, 4, 4, 1), pixels).unwrap().into_dyn();

    let out = layer.forward(&x).unwrap();
    // Valid padding: (4 - 2)/1 + 1 = 3 along each spatial axis -> [1, 3, 3, 1].
    assert_eq!(out.shape(), &[1, 3, 3, 1]);
    // An all-ones 2x2 kernel sums each window: the first is 1 + 2 + 5 + 6 = 14.
    assert_eq!(out[[0, 0, 0, 0]], 14.0);
    println!("output shape: {:?}", out.shape());
}

3.5.2. Constructors, Padding, and Output Shapes

The constructors take the hyperparameters as positional arguments. The kernel and stride argument shape tracks the rank. Conv1D uses a scalar kernel_size and stride. The 2D and 3D layers take tuples instead.

SeparableConv2D inserts a depth_multiplier argument before the activation. DepthwiseConv2D has no filters argument. Like Keras, it derives its output width from the input, and it takes its depth multiplier through a builder method.

Conv1D::new(filters, kernel_size: usize, input_shape: Vec<usize>, stride: usize, activation) -> Result<Conv1D, Error>
Conv2D::new(filters, kernel_size: (usize, usize), input_shape: Vec<usize>, strides: (usize, usize), activation) -> Result<Conv2D, Error>
Conv3D::new(filters, kernel_size: (usize, usize, usize), input_shape: Vec<usize>, strides: (usize, usize, usize), activation) -> Result<Conv3D, Error>
DepthwiseConv2D::new(kernel_size: (usize, usize), input_shape: Vec<usize>, strides: (usize, usize), activation) -> Result<DepthwiseConv2D, Error>
DepthwiseConv2D::with_depth_multiplier(self, depth_multiplier: usize) -> Result<DepthwiseConv2D, Error>   // builder, defaults to 1
SeparableConv2D::new(filters, kernel_size: (usize, usize), input_shape: Vec<usize>, strides: (usize, usize), depth_multiplier: usize, activation) -> Result<SeparableConv2D, Error>

activation takes impl Into<Activation>. You can pass an Activation variant (Activation::ReLU, Activation::Sigmoid, Activation::Tanh, Activation::Softmax, Activation::Linear), or a standalone activation layer such as ReLU::new(). The input_shape you supply is the full expected input, including the batch dimension. Only input_shape[1..] (the channels and spatial extents) sizes the weights. The batch value you write is informational only. See 3.2. Dense Layers and Activations for more about the activation set.

3 builder methods refine a constructed layer. Each one consumes and returns self, so you can chain them. with_padding(PaddingType) switches the padding mode. with_random_state(u64) re-runs the Xavier initialization deterministically from a seed. Call it before you assign custom weights or start training (see 7.1. Reproducibility and Random Seeds).

set_weights(...) installs weights and a bias that you control. set_weights checks every array against the layer’s expected shape. Note that DepthwiseConv2D::set_weights takes an Array1 bias. SeparableConv2D::set_weights takes 3 arrays instead: depthwise weights, pointwise weights, then bias.

Padding uses the PaddingType enum, which has 2 variants: Valid (the default) and Same. It sets the output size as follows:

  • Valid applies no padding. It computes output values only where the kernel fully overlaps the input. The formula is out = (in - k) / stride + 1 (integer floor division), on each spatial axis. Every input axis must be at least the kernel size.
  • Same zero-pads the borders so out = ceil(in / stride). At stride == 1 this keeps the spatial size exactly. At a larger stride, the output is the input length divided by the stride, rounded up. The layer splits the total padding evenly and puts the extra cell on the trailing edge (pad_before = pad_total / 2). This matches TensorFlow’s SAME padding.

The following numbers show the rule in practice. A [N, 8, 8, 1] input through a 3x3 kernel gives 3 results. Valid padding at stride 1 gives (8-3)/1+1 = 6, so the output is [N, 6, 6, F]. Same padding at stride 1 gives 8, so the output is [N, 8, 8, F]. Same padding at stride 2 gives ceil(8/2) = 4. A Conv1D over length 6, with kernel 3 and stride 2, under Valid padding, gives (6-3)/2+1 = 2.

Conv3D applies the same rule on depth, height, and width, each on its own. model.summary() prints these output shapes and the per-layer parameter counts. Use it to check a stack before you train it. See 3.1. Sequential Model for the full description of summary.

3.5.3. The im2col + GEMM Engine

Conv1D, Conv2D, and Conv3D do not each carry their own loop nest. A plain convolution is the same operation at every rank. Only the number of spatial axes changes. All 3 layers delegate their forward and backward numerics to 1 implementation in convolution_engine.rs.

This implementation is generic over the spatial rank R = ndim - 2. The layer wrapper keeps the public API, the weight storage, the activation, and the caches. The engine does the arithmetic.

The engine uses im2col plus GEMM, the same strategy the major frameworks use. For the forward pass, it gathers each output window into a row. This forms an [out_plane, k_plane*Cin] matrix, whose columns align with the flat weight matrix [k_plane*Cin, F]. A single matrix multiply then produces [out_plane, F], and the engine adds the bias per filter.

Under the channels-last layout, this gather is a run of Cin-wide copy_from_slice calls, not a scalar-at-a-time walk. The product lands directly on a contiguous slab of the output, with no scatter. Trading a 6-deep loop nest for a matrix multiply lets the layer use the crate’s tuned in-house GEMM, instead of a naive triple loop. See 6.2. Matrix Multiplication for more on that GEMM.

The backward pass runs 2 GEMMs per batch item. One computes the weight gradient. The other computes the input-gradient columns, which the engine then scatters back (col2im) into the input-gradient tensor.

The engine gates parallelism on estimated FLOPs, not on element counts. A 7x7x512 convolution and a 3x3x3 convolution can share the same output-element count, but their costs differ by a wide margin. The forward gate compares 2 * batch * F * out_plane * Cin*k against CONV_PARALLEL_MIN_FLOPS (default 4,000,000, tunable at runtime through rustyml::tuning).

Below this threshold, the forward pass runs serial. Above it, the forward pass parallelizes over (batch item, output-position block) tasks. This lets a single large image fill every core, even at batch == 1. Each task builds its own im2col block and runs its own GEMM into a disjoint output region.

The backward pass parallelizes over batch items. It reduces the weight and bias partials in batch order, which keeps results bit-reproducible across runs on the same machine. It also routes each item’s GEMMs through a switch. The switch keeps them parallel while the batch is too short to fill the thread pool. It flips them to serial once the batch alone saturates the pool. This way, a batch task never forks rayon again inside its own GEMM.

The parallel path and the serial path return the same numbers, so you rarely need to touch the gate. If you profile a workload that sits just under the threshold, see 7.3. Performance Tuning and Parallelism for how to move it.

3.5.4. Depthwise and Separable Convolutions

A standard convolution mixes across channels and across space in 1 step. Every output channel is a weighted sum over all input channels and all kernel taps. That coupling is where the parameters live. The weight tensor holds F * Cin * kh * kw values.

Depthwise separable convolution factors this operation into 2 cheaper stages. The first stage is a depthwise convolution that filters each input channel on its own (spatial mixing only, no cross-channel mixing). The second stage is a pointwise 1x1 convolution that recombines the channels (cross-channel mixing only, no spatial extent). This is the idea behind MobileNet and Xception. RustyML exposes both stages.

DepthwiseConv2D is the first stage alone. It carries depth_multiplier kernels of size kh x kw for each input channel, and emits C * depth_multiplier output channels. This is why it has no filters argument, exactly as in Keras. It does no channel recombination, so it cannot mix channels on its own. In practice, you almost always pair it with a 1x1 convolution downstream.

depth_multiplier defaults to 1. Set it with with_depth_multiplier, which returns a Result because 0 is rejected. SeparableConv2D takes its own depth_multiplier as a positional argument. It expands the intermediate channel count to Cin * depth_multiplier, before the pointwise stage collapses it back to filters.

The parameter counts are the point of this factorization. Consider Cin input channels, F output filters, and a kh x kw kernel:

  • Standard Conv2D: F * Cin * kh * kw + F.
  • DepthwiseConv2D: C * dm * kh * kw + C * dm (at the default dm = 1, this is C * kh * kw + C).
  • SeparableConv2D: dm * Cin * kh * kw (depthwise) + F * Cin * dm (pointwise) + F (bias).

Ignoring the bias term, the separable-to-standard ratio is 1/F + 1/(kh*kw). The savings grow with both the filter count and the kernel area. Each parameter costs 1 multiply-accumulate per output position, so this same ratio is also the compute (FLOP) ratio.

As an example, take a 64-filter 3x3 convolution over 3 channels. The standard layer has 64*3*3*3 + 64 = 1792 parameters. The separable equivalent has 27 + 192 + 64 = 283 parameters, about 6.3 times fewer. A depthwise-only layer over those 3 channels has just 30 parameters. The following program builds all 3 layers and checks the counts.

use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;

fn count(p: TrainingParameters) -> usize {
    match p {
        TrainingParameters::Trainable(n) | TrainingParameters::NonTrainable(n) => n,
        TrainingParameters::NoTrainable => 0,
    }
}

fn main() {
    let input_shape = vec![1, 32, 32, 3]; // [batch, H, W, channels]

    // Standard 64-filter 3x3 convolution over 3 input channels.
    let standard =
        Conv2D::new(64, (3, 3), input_shape.clone(), (1, 1), Activation::ReLU).unwrap();

    // Separable equivalent: depthwise (depth_multiplier = 1) then a pointwise 1x1 to 64 filters.
    let separable =
        SeparableConv2D::new(64, (3, 3), input_shape.clone(), (1, 1), 1, Activation::ReLU).unwrap();

    // Depthwise-only emits C * depth_multiplier = 3 channels and does no cross-channel mixing.
    let depthwise = DepthwiseConv2D::new((3, 3), input_shape, (1, 1), Activation::ReLU).unwrap();

    println!("standard  Conv2D params: {}", count(standard.param_count()));
    println!("separable Conv2D params: {}", count(separable.param_count()));
    println!("depthwise Conv2D params: {}", count(depthwise.param_count()));

    assert_eq!(count(standard.param_count()), 1792); // 64*3*3*3 + 64
    assert_eq!(count(separable.param_count()), 283); // 27 + 192 + 64
    assert_eq!(count(depthwise.param_count()), 30); // 3*3*3 + 3
}

Use these layers when channel counts run high and you want to cut both parameters and compute. Wide feature maps are one example. A model meant to run on modest hardware is another. In exchange, you accept a modeling tradeoff: the factored form is strictly less expressive than a full convolution, for the same F and kernel.

Their engine differs from the standard layers’ engine. DepthwiseConv2D uses a direct loop nest, because the channels are independent and im2col buys little here. It parallelizes over (batch item, output row) tasks. Output rows are disjoint, so this split needs no merge. It gates on NAIVE_CONV_PARALLEL_MIN_FLOPS (default 1,000,000).

SeparableConv2D runs its depthwise stage through this same naive path. It then routes its pointwise 1x1 stage back through the shared im2col plus GEMM engine. A 1x1 convolution is exactly a per-position cross-channel matrix multiply.

3.5.5. Building a Small CNN

Convolutions emit rank-3, rank-4, or rank-5 tensors, but a Dense classifier head needs a rank-2 [batch, features] matrix. Flatten bridges the two. Flatten::new(input_shape: Vec<usize>) builds a parameter-free layer that reshapes [batch, ...] into [batch, product-of-the-rest]. It accepts 3D, 4D, or 5D input at forward time. You give it the shape of the tensor entering it, which is the convolution’s output shape. It then works out the flattened feature count.

The next example builds a complete stack. A Conv2D runs over synthetic single-channel 8x8 images. Flatten then feeds the result into a Dense regression head. The Sequential model trains the stack for a few epochs.

use ndarray::{Array2, Array4};
use rustyml::neural_network::layers::*;
use rustyml::neural_network::losses::*;
use rustyml::neural_network::optimizers::*;
use rustyml::neural_network::sequential::Sequential;

fn main() {
    // Synthetic "images": 6 samples, 8x8, 1 channel.
    let mut x = Array4::<f32>::zeros((6, 8, 8, 1));
    for n in 0..6 {
        for i in 0..8 {
            for j in 0..8 {
                x[[n, i, j, 0]] = ((n + i + j) as f32 * 0.1).sin();
            }
        }
    }
    let x = x.into_dyn();

    // 3 regression targets per sample.
    let y = Array2::<f32>::from_shape_fn((6, 3), |(n, k)| (n as f32) * 0.01 + (k as f32) * 0.1)
        .into_dyn();

    let mut model = Sequential::new();
    model
        // [6, 8, 8, 1] -> Conv2D(4 filters, 3x3, Valid) -> [6, 6, 6, 4]
        .add(
            Conv2D::new(4, (3, 3), vec![6, 8, 8, 1], (1, 1), Activation::ReLU)
                .unwrap()
                .with_random_state(42),
        )
        // [6, 6, 6, 4] -> Flatten -> [6, 144]
        .add(Flatten::new(vec![6, 6, 6, 4]).unwrap())
        // [6, 144] -> Dense -> [6, 3]
        .add(Dense::new(6 * 6 * 4, 3, Activation::Linear).unwrap().with_random_state(7))
        .compile(SGD::new(0.01, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());

    model.summary();
    model.fit(&x, &y, 3).unwrap();

    let pred = model.predict(&x).unwrap();
    assert_eq!(pred.shape(), &[6, 3]);
    println!("prediction shape: {:?}", pred.shape());
}

The Dense input dimension is not a guess. It is the flattened feature count 6 * 6 * 4 = 144, which you can read directly from the convolution’s output shape. Get it wrong, and the Dense layer’s matrix multiply rejects the batch. summary() prints the shape at each stage and the parameter budget. This is the fastest way to catch a mis-sized head:

Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ conv2d (Conv2D)                 │ (6, 6, 6, 4)           │            40 │
│ flatten (Flatten)               │ (None, 144)            │             0 │
│ dense (Dense)                   │ (None, 3)              │           435 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 475 (1900 B)
 Trainable params: 475 (1900 B)
 Non-trainable params: 0 (0 B)

Insert a pooling layer between the convolution and the flatten step, if you want to shrink the spatial extent before the dense head. Save the trained stack with the tools in 3.9. Saving and Loading Weights.

3.5.6. Errors and How to Avoid Them

The convolution layers validate their configuration closely. They return typed errors instead of a panic on bad input. The table below lists the common cases and their error variants.

ConditionError
filters is 0, for Conv1D, Conv2D, Conv3D, or SeparableConv2D (DepthwiseConv2D has no filters argument)Error::InvalidParameter
A kernel dim or a stride is 0Error::InvalidParameter
depth_multiplier == 0 (SeparableConv2D::new, DepthwiseConv2D::with_depth_multiplier)Error::InvalidParameter
input_shape wrong rank, zero channels, or smaller than the kernel (constructor)Error::InvalidInput
forward handed a tensor of the wrong rankError::InvalidInput
Valid convolution whose runtime spatial dim is below the kernelError::InvalidInput
DepthwiseConv2D runtime channel count differs from the declared channel countError::DimensionMismatch
set_weights shape does not matchError::NeuralNetwork(NnError::WeightShape)
backward called before forwardError::NeuralNetwork(NnError::ForwardPassNotRun)

2 of these error paths need more explanation. The engine catches a kernel larger than the input, under Valid padding, at 2 points. It checks at construction, against the declared input_shape. It checks again at forward time, against the actual tensor. The engine computes in - k in usize. Rather than let that computation underflow, it returns InvalidInput.

A channel mismatch is a recoverable error only on DepthwiseConv2D. This layer checks the runtime channel count directly, and returns DimensionMismatch when the count does not match. The standard Conv1D, Conv2D, and Conv3D layers size their weight matrix from the channel count in the declared input_shape. They do not check this count again on every forward call. You must always supply the channel count you declared. Treat the declared channel count as a contract.

The following program exercises these recoverable paths:

use ndarray::Array;
use rustyml::error::Error;
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;

fn main() {
    // 1. Kernel larger than the declared input under Valid padding: rejected at construction.
    let too_big = Conv2D::new(1, (3, 3), vec![1, 2, 2, 1], (1, 1), Activation::Linear);
    assert!(matches!(too_big, Err(Error::InvalidInput(_))));

    // 2. A zero depth multiplier is rejected by the builder rather than panicking.
    let bad_dm = DepthwiseConv2D::new((2, 2), vec![1, 4, 4, 2], (1, 1), Activation::Linear)
        .unwrap()
        .with_depth_multiplier(0);
    assert!(matches!(bad_dm, Err(Error::InvalidParameter { .. })));

    // 3. A runtime tensor smaller than the kernel: Valid geometry returns an error, not a panic.
    let mut conv = Conv2D::new(1, (3, 3), vec![1, 5, 5, 1], (1, 1), Activation::Linear).unwrap();
    let small = Array::ones((1, 2, 5, 1)).into_dyn(); // height 2 < kernel 3
    assert!(matches!(conv.forward(&small), Err(Error::InvalidInput(_))));

    // 4. DepthwiseConv2D turns a runtime channel mismatch into a recoverable error.
    let mut dw =
        DepthwiseConv2D::new((2, 2), vec![1, 4, 4, 2], (1, 1), Activation::Linear).unwrap();
    let wrong_channels = Array::ones((1, 4, 4, 3)).into_dyn(); // 3 channels, layer expects 2
    assert!(matches!(
        dw.forward(&wrong_channels),
        Err(Error::DimensionMismatch { .. })
    ));

    println!("all error paths behaved as documented");
}

The ForwardPassNotRun error most often surprises you during training. The backward pass reads caches that forward writes. Call backward on a fresh layer, or twice in a row without a forward call between them. Either way, the layer returns this variant instead of reading stale state.

Inside Sequential, the framework handles this order for you. You meet this error only when you drive layers by hand. See 1.6. Error Handling for the full error taxonomy and the smart constructors behind these variants.

3.6. Pooling Layers

Pooling downsamples a feature map. It summarizes each local window with a single number. The spatial dimensions shrink, and the channel count stays the same. Pooling is the cheap counterpart to the convolutions in 3.5. Convolutional Layers. It has no weights and no matrix multiply. It only reduces a sliding window to one number.

RustyML ships pooling as a family of 12 layers. All 12 layers use one engine that works for any spatial rank. This page covers the constructors, the output shapes, and the difference between max and average pooling. It also covers how gradients route backward, and what “no learnable parameters” means for summary() and model persistence.

3.6.1. The family: 2 reductions, 3 ranks, plus global variants

Every pooling layer picks one of 2 reductions, max or average. It applies that reduction at one of 3 spatial ranks: 1D, 2D, or 3D. Each combination also has a “global” variant that collapses the whole spatial extent at once. That is 2 x 3 x 2 = 12 concrete types. All 12 types reduce to 4 functions in a private pooling_engine.

The engine derives the spatial rank at run time from input.ndim() - 2. One loop serves 1D, 2D, and 3D. The only per-layer differences are the reduction kind, and how the public pool_size/strides tuples flatten into slices.

Tensors follow the channels-last convention [batch, spatial..., channels], the same convention the convolution layers use. A 1D layer takes a 3D tensor, a 2D layer takes a 4D tensor, and a 3D layer takes a 5D tensor. Windowed pooling keeps the rank and shrinks the spatial axes. Global pooling drops the spatial axes and returns [batch, channels].

The channel axis sits innermost, so a whole position reduces at once. The window geometry (the bounds checks, and the count of real elements a Same-padded average divides by) is worked out once per output position. Every channel shares that same geometry.

LayerInput tensorWindow controlOutput
MaxPooling1D / AveragePooling1D[N, L, C]new(pool_size, input_shape) + with_stride[N, L', C]
MaxPooling2D / AveragePooling2D[N, H, W, C]new((ph, pw), input_shape) + with_strides[N, H', W', C]
MaxPooling3D / AveragePooling3D[N, D, H, W, C]new((pd, ph, pw), input_shape) + with_strides[N, D', H', W', C]
GlobalMaxPooling{1,2,3}Drank 3 / 4 / 5new()[N, C]
GlobalAveragePooling{1,2,3}Drank 3 / 4 / 5new()[N, C]

One naming point differs by rank. The 1D windowed layers expose with_stride, a single usize. The 2D and 3D layers expose with_strides, a tuple. This matches the shape of pool_size at each rank.

3.6.2. Constructors, pool size, stride, and padding

Windowed layers take the pool window and the declared input shape. For example: MaxPooling2D::new((2, 2), vec![batch, height, width, channels]). The constructor checks eagerly that the window fits inside the declared spatial dimensions. 2 builder methods override the defaults. Both are optional:

  • with_stride / with_strides sets the step between windows. If you never call it, the stride defaults to the pool size. This gives non-overlapping windows, the same default Keras uses. Pass a smaller stride for overlapping windows.
  • with_padding sets PaddingType::Valid (the default, no padding) or PaddingType::Same. This is the same PaddingType enum the convolution layers use.

Global layers take no arguments at all: GlobalMaxPooling2D::new(). There is no window, stride, or padding to configure, because the window is the whole spatial plane.

// Windowed 2D max pooling, 3x3 window, stride 2, Same padding:
let layer = MaxPooling2D::new((3, 3), vec![1, 32, 32, 16])
    .unwrap()
    .with_strides((2, 2))
    .unwrap()
    .with_padding(PaddingType::Same);

// Global 2D average pooling needs nothing:
let head = GlobalAveragePooling2D::new();

Construction validates the declared input_shape. output_shape() reports this declared shape. The forward pass itself only checks the tensor’s rank. Inside a Sequential model, the actual spatial dimensions come from the upstream layer at run time.

Keep the runtime spatial dimensions at least as large as the pool window. The engine computes output sizes with unsigned arithmetic. A plane smaller than the window causes an arithmetic underflow, instead of a clean error.

Every constructor validates its inputs and returns Result, so the failure modes are explicit. See 1.6. Error Handling for the error taxonomy:

ConditionError
input_shape has the wrong rank (e.g. 3D given to a 2D layer)Error::DimensionMismatch
any input_shape dimension is zero (including batch or channels)Error::InvalidInput
a pool dimension is zero, or exceeds the corresponding input dimensionError::InvalidParameter
a stride is zero (from with_stride/with_strides)Error::InvalidParameter

The zero-batch and zero-channel checks are deliberate. An earlier version let a [0, 1, 4, 4] shape pass the constructor. That shape only failed later, at the first forward pass. Validation now rejects it at construction time, where the stack trace points at the real problem.

3.6.3. Output-shape formulas

For Valid padding, each spatial axis shrinks by the same formula the convolution layers use. The division floors, because the trailing remainder is dropped:

out = (in - pool) / stride + 1

For Same padding, the output rounds up to ceil(in / stride). The engine pads symmetrically, with the extra cell on the trailing edge. This matches the convolution engine. Global pooling ignores padding and window size. It always produces [batch, channels].

output_shape() returns these sizes as a formatted string. Windowed layers can compute their output shape immediately, because they store input_shape at construction. Global layers return "Unknown" until a forward pass has run, because they only learn the input shape when a tensor flows through. This difference shows up directly in summary().

use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;

fn main() {
    // Windowed layers know their output shape at construction, from `input_shape`.
    let mp = MaxPooling2D::new((2, 2), vec![1, 6, 6, 3]).unwrap();
    println!("MaxPooling2D:     {}", mp.output_shape()); // (1, 3, 3, 3)

    let ap = AveragePooling1D::new(2, vec![1, 6, 1]).unwrap();
    println!("AveragePooling1D: {}", ap.output_shape()); // (1, 3, 1)

    // Global layers reduce every spatial axis to one value per channel, but only report a
    // concrete shape once a forward pass has cached the input shape.
    let mut gap = GlobalAveragePooling2D::new();
    println!("before forward:   {}", gap.output_shape()); // Unknown

    let x = Array::from_elem(ndarray::IxDyn(&[3, 5, 5, 4]), 1.0f32);
    let out = gap.forward(&x).unwrap();
    assert_eq!(out.shape(), &[3, 4]);
    println!("after forward:    {}", gap.output_shape()); // (3, 4)
}

Same padding is not only a shape adjustment. The padded cells are virtual. The forward and backward passes skip out-of-bounds positions instead of substituting zeros. This matters for averaging. An edge window divides by the count of real, in-bounds elements, not by the window area.

This is Keras count_include_pad=False behavior. Same-padded average pooling does not dilute edges toward zero.

use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;

fn main() {
    // 3x3 input, 2x2 window, stride 2. Valid padding drops the last row and column.
    // Same padding rounds the output up to ceil(3/2) = 2. The trailing windows see only
    // their in-bounds cells, because the padding is virtual.
    let x = Array::from_shape_vec((1, 3, 3, 1), (1..=9).map(|v| v as f32).collect())
        .unwrap()
        .into_dyn();

    let mut max_same = MaxPooling2D::new((2, 2), vec![1, 3, 3, 1])
        .unwrap()
        .with_strides((2, 2))
        .unwrap()
        .with_padding(PaddingType::Same);
    let m = max_same.forward(&x).unwrap();
    assert_eq!(m.shape(), &[1, 2, 2, 1]);
    // [[max(1,2,4,5), max(3,6)], [max(7,8), 9]] = [[5, 6], [8, 9]]
    assert_eq!(m.iter().copied().collect::<Vec<_>>(), vec![5.0, 6.0, 8.0, 9.0]);

    let mut avg_same = AveragePooling2D::new((2, 2), vec![1, 3, 3, 1])
        .unwrap()
        .with_strides((2, 2))
        .unwrap()
        .with_padding(PaddingType::Same);
    let a = avg_same.forward(&x).unwrap();
    // Averages divide by the count of REAL cells, not the window area (count_include_pad = False):
    // [[(1+2+4+5)/4, (3+6)/2], [(7+8)/2, 9/1]] = [[3.0, 4.5], [7.5, 9.0]]
    assert_eq!(a.iter().copied().collect::<Vec<_>>(), vec![3.0, 4.5, 7.5, 9.0]);
}

3.6.4. Max versus average: semantics and when each helps

Both reductions run over the same window. They differ only in what they keep. Max pooling records the single largest activation and discards the rest. It acts as a detector for “did this feature fire anywhere in the window”, and it tolerates translation. Average pooling keeps the mean. It preserves the overall magnitude of the region, and it smooths instead of selecting.

use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;

fn main() {
    // The same 4x4 plane, pooled with a non-overlapping 2x2 window (stride defaults to 2).
    let data: Vec<f32> = (0..16).map(|v| v as f32).collect();
    let x = Array::from_shape_vec((1, 4, 4, 1), data).unwrap().into_dyn();

    let mut max_pool = MaxPooling2D::new((2, 2), vec![1, 4, 4, 1]).unwrap();
    let m = max_pool.forward(&x).unwrap();
    // window maxima: [[5, 7], [13, 15]]
    assert_eq!(m.iter().copied().collect::<Vec<_>>(), vec![5.0, 7.0, 13.0, 15.0]);

    let mut avg_pool = AveragePooling2D::new((2, 2), vec![1, 4, 4, 1]).unwrap();
    let a = avg_pool.forward(&x).unwrap();
    // window means: [[2.5, 4.5], [10.5, 12.5]]
    assert_eq!(a.iter().copied().collect::<Vec<_>>(), vec![2.5, 4.5, 10.5, 12.5]);
}

Use max pooling inside the convolutional stack of a discriminative model. It keeps the strongest response through downsampling, and the exact position in the window does not matter. Use average pooling when magnitude matters more than peaks. Use the global average reduction at the end of a network. Averaging the whole plane gives a stable, smooth summary per channel.

2 engine details matter in edge cases. Max pooling breaks ties toward the first (lowest-index) maximum, using a strict > comparison. Max pooling also propagates NaN on purpose. Once a NaN enters a window, it wins and stays there. This matches PyTorch and TensorFlow, rather than dropping the NaN silently.

3.6.5. Gradient routing: backprop through pooling

Pooling has no parameters to update. It still must route the upstream gradient back to the inputs that produced its output. The 2 reductions route this gradient differently.

Max pooling is winner-takes-gradient. During the forward pass, each output records the flat index of the input element it picked. This index is the “arg-max”, and the layer caches it. During backward, each upstream gradient scatters to that one position. Every other input in the window gets zero. When windows overlap and the same input wins more than one window, the contributions add up.

Average pooling instead spreads each output gradient evenly across its window. Every in-bounds element of the window receives grad / count. Overlaps add up here too.

Global max pooling routes each channel’s gradient to its single arg-max element. Global average pooling spreads each channel’s gradient evenly across the whole plane, as grad / spatial_size.

use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;

fn main() {
    let data: Vec<f32> = (0..16).map(|v| v as f32).collect();
    let x = Array::from_shape_vec((1, 4, 4, 1), data).unwrap().into_dyn();
    let grad = Array::ones((1, 2, 2, 1)).into_dyn();

    // Max pooling: each window's gradient reaches only the winning cell (here the 4 maxima
    // at flat indices 5, 7, 13, 15). Every other cell receives 0.
    let mut max_pool = MaxPooling2D::new((2, 2), vec![1, 4, 4, 1]).unwrap();
    max_pool.forward(&x).unwrap();
    let gmax = max_pool.backward(&grad).unwrap();
    let expected_max = vec![
        0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0,
    ];
    assert_eq!(gmax.iter().copied().collect::<Vec<_>>(), expected_max);

    // Average pooling: each window's gradient is spread evenly over its cells (1.0 / 4 = 0.25).
    let mut avg_pool = AveragePooling2D::new((2, 2), vec![1, 4, 4, 1]).unwrap();
    avg_pool.forward(&x).unwrap();
    let gavg = avg_pool.backward(&grad).unwrap();
    assert!(gavg.iter().all(|&g| (g - 0.25).abs() < 1e-6));
}

Because max pooling depends on the arg-max cache, the split between forward and predict matters. forward (training mode) records the cache. predict (inference mode, &self) writes no cache, as the Layer trait documents. So backward only works after forward. Calling backward first, or calling it after a cache-free predict, returns Error::NeuralNetwork(NnError::ForwardPassNotRun(_)).

Inside Sequential::fit, this never happens, because training always runs forward first. You trip this error only when you drive a bare layer by hand. Average pooling caches only the input shape, because it needs the geometry, not the values. Global average pooling also caches only the shape. The “run forward before backward” contract is the same across all 12 layers.

3.6.6. Global pooling as a modern head

A traditional convolutional classifier ends with Flatten, followed by a large Dense layer. This design ties the network to one exact input resolution. It also puts most of the parameters into that last matrix.

Global average pooling replaces that head with a reduction that has no parameters. Network-in-Network and ResNet made this design common. The layer collapses each channel to its mean, so each channel yields one feature. A small Dense classifier follows the pooling layer. The pooling step has no parameters. It cannot overfit. This gives the head free regularization. The classifier’s input width depends only on the channel count, not on H x W.

This size independence is real, but only for the pooling layer by itself. GlobalAveragePooling2D validates only that its input is 4D. So the same pooling layer accepts any height and width at run time. The rest of the model stack does not share this property. RustyML’s Dense fixes its input_dim at construction. The upstream convolution layers also pin their input_shape. So a saved model still expects one consistent resolution, end to end.

The payoff of the global-pooling head is the parameter-free reduction, resistant to overfitting, and the tidy [N, C] -> Dense interface. It does not give automatic variable-resolution inference.

use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::layers::*;
use rustyml::neural_network::optimizers::*;
use rustyml::neural_network::losses::*;
use ndarray::Array;

fn main() {
    // A small classifier head: Conv -> pool -> global pool -> Dense.
    let x = Array::from_shape_fn((2, 8, 8, 1), |(b, i, j, _)| {
        (i + j) as f32 * 0.1 + b as f32
    })
    .into_dyn();
    let y = Array::ones((2, 3)).into_dyn();

    let mut model = Sequential::new();
    model
        .add(Conv2D::new(4, (3, 3), vec![2, 8, 8, 1], (1, 1), Activation::ReLU).unwrap()) // -> [2, 6, 6, 4]
        .add(MaxPooling2D::new((2, 2), vec![2, 6, 6, 4]).unwrap())                        // -> [2, 3, 3, 4]
        .add(GlobalAveragePooling2D::new())                                               // -> [2, 4]
        .add(Dense::new(4, 3, Activation::ReLU).unwrap())                                 // -> [2, 3]
        .compile(RMSprop::new(0.001, 0.9, 1e-8, 0.0).unwrap(), MeanSquaredError::new());

    model.summary();
    model.fit(&x, &y, 2).unwrap();

    let prediction = model.predict(&x).unwrap();
    assert_eq!(prediction.shape(), &[2, 3]);
}

The summary() call above runs before fit. This is when the global-pooling behavior from 3.6.3 appears. Its output-shape column reads Unknown, because no tensor has flowed through the layer yet. The windowed layers, in contrast, already report concrete shapes:

Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ conv2d (Conv2D)                 │ (2, 6, 6, 4)           │            40 │
│ maxpooling2d (MaxPooling2D)     │ (2, 3, 3, 4)           │             0 │
│ globalaveragepooling2d (GlobalAveragePooling2D) │ Unknown                │             0 │
│ dense (Dense)                   │ (None, 3)              │            15 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 55 (220 B)
 Trainable params: 55 (220 B)
 Non-trainable params: 0 (0 B)

Both pooling rows show 0 in the Param # column. Neither one adds to any of the 3 totals. Call summary() again after fit, or after any forward pass, and the global-pooling row settles to (2, 4).

3.6.7. No learnable parameters: what that means for persistence

Pooling layers report TrainingParameters::NoTrainable. They expose LayerWeight::Empty. The optimizer skips them, because they yield no ParamGrad entries. This is why they never appear in the trainable or non-trainable totals above. It is also why they add nothing to a saved file’s size.

Persistence is where “no parameters” has a real consequence. save_to_path writes each layer’s type name, its reported output shape, and its weights, as metadata. For a pooling layer, the weights are LayerWeight::Empty. So the file stores nothing of substance for that layer.

load_from_path does not reconstruct the architecture. You must rebuild the model with the same layers first, then load the weights. The load walks the layers position by position. It checks that the layer count matches. It also checks that each saved type string equals the type at the same index, before it applies any weights. A pooling layer has no weights to restore, but it must still occupy its exact position, with its exact type. Otherwise the load fails with Error::Io(IoError::ModelStructureMismatch).

You cannot drop a MaxPooling2D from the rebuilt model to save space. The structural checkpoint depends on that layer being present.

use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::layers::*;
use rustyml::neural_network::optimizers::*;
use rustyml::neural_network::losses::*;
use ndarray::Array;

fn main() {
    let x = Array::from_elem((2, 4, 4, 3), 0.5f32).into_dyn();
    let y = Array::ones((2, 2)).into_dyn();

    let mut model = Sequential::new();
    model
        .add(GlobalAveragePooling2D::new())
        .add(Dense::new(3, 2, Activation::ReLU).unwrap())
        .compile(RMSprop::new(0.001, 0.9, 1e-8, 0.0).unwrap(), MeanSquaredError::new());
    model.fit(&x, &y, 2).unwrap();
    model.save_to_path("pool_head.bin").unwrap();

    // Rebuild the same architecture, then load. The pooling layer carries no weights.
    // It must still occupy the same position, with the same type, for the structure check to pass.
    let mut restored = Sequential::new();
    restored
        .add(GlobalAveragePooling2D::new())
        .add(Dense::new(3, 2, Activation::ReLU).unwrap());
    restored.load_from_path("pool_head.bin").unwrap();

    let out = restored.predict(&x).unwrap();
    assert_eq!(out.shape(), &[2, 2]);

    std::fs::remove_file("pool_head.bin").unwrap();
}

See 3.9. Saving and Loading Weights and 7.2. Model Persistence in Depth for the full serialization story. That story covers the postcard format, why the optimizer and loss are not saved, and how weight shapes are checked on load.

3.6.8. Performance and cost

The engine avoids extra allocations. It reads the whole input as one contiguous &[f32]. Its unit of work is a single output position. For each window tap, a serial loop folds channels adjacent floats into a channels-wide accumulator. The engine then assembles the output with one from_shape_vec call, instead of writing scalars one at a time.

Cost scales with output positions, times window volume, times channels. Overlapping windows (a stride smaller than the pool size) do proportionally more work than the non-overlapping default. Global pooling is the cheapest case. It runs one linear scan per batch item.

Forward work runs in parallel with rayon, split over (batch item, block of output positions). Each block writes a disjoint output slab. Splitting on positions, rather than on the batch alone, keeps every thread busy even when batch == 1.

Backward work splits differently, over (batch item, channel slab). A slab that owns channels [j0, j1) touches only input addresses in that range, modulo channels. So the scatter needs no halo, no merge step, and no atomic operations.

Both passes go parallel only after the estimated total work clears a threshold. That estimate is batch * output_positions * channels * window_volume element operations. The threshold, POOL_PARALLEL_MIN_OPS, defaults to 12,000, and you can override it through the tuning module. The gate counts total taps, not task count. This keeps the gate honest, whether the work sits in a few wide-channel positions or in many narrow ones. Small tensors run in serial, to avoid paying the rayon task overhead for no benefit.

See 7.3. Performance Tuning and Parallelism to move that gate and measure the effect.

One warning carries over from the Layer contract. Backward is pure math, and it does not sanitize non-finite values. Max pooling also propagates NaN on purpose. So a NaN in a pooling input travels straight through, unchanged. Control large but finite gradients with optimizer-level global-norm clipping. Do not expect pooling to clamp them.

3.7. Recurrent Layers

RustyML ships 3 recurrent layers: SimpleRNN, LSTM, and GRU. They live under rustyml::neural_network::layers::recurrent, and the prelude re-exports them. All 3 share one contract. Each layer consumes a 3D sequence tensor, runs a left-to-right recurrence over the time axis, and returns the final hidden state.

If you know Keras, think of these layers as SimpleRNN, LSTM, and GRU with return_sequences=False fixed on. That fixed setting affects how you stack the layers. Read section 3.7.7 before you build a deep recurrent network.

3.7.1. The input/output contract

Every recurrent layer expects a 3D input tensor with shape (batch_size, timesteps, features). It produces a 2D output (batch_size, units). The features axis must equal the input_dim you passed to the constructor. units is the hidden width you set. The recurrence consumes the timestep axis, so it does not appear in the output. Only the last hidden state, h_T, survives.

use ndarray::Array;
use rustyml::neural_network::layers::activation::Tanh;
use rustyml::neural_network::layers::recurrent::SimpleRNN;
use rustyml::neural_network::traits::Layer;

fn main() {
    // input_dim = 4 features per timestep, units = 3 hidden neurons
    let rnn = SimpleRNN::new(4, 3, Tanh::new()).unwrap();

    // (batch = 2, timesteps = 5, features = 4)
    let x = Array::zeros((2, 5, 4)).into_dyn();

    // predict runs the recurrence without recording backward caches
    let out = rnn.predict(&x).unwrap();

    // The timestep axis is gone: only the last hidden state survives.
    println!("output shape: {:?}", out.shape()); // [2, 3] == (batch, units)
}
output shape: [2, 3]

The 3 layers share one constructor signature: new(input_dim, units, activation) -> Result<Self, Error>. The activation argument takes impl Into<Activation>. You can pass an Activation enum variant, for example Activation::Tanh, or a thin layer wrapper such as Tanh::new(), ReLU::new(), Sigmoid::new(), Linear::new(), or Softmax::new(). Every wrapper converts to the same enum. See 3.2. Dense Layers and Activations for the full activation catalog.

new returns Error::InvalidParameter when input_dim or units is 0. RustyML has no return_sequences, return_state, bidirectional, or in-cell dropout option. Each layer always returns the last state, always runs forward in time, and always uses one dense implementation.

A 2D or 4D input is a hard error, not a silent reshape. forward and predict return Error::InvalidInput for any input that is not 3D. The layer caches its forward activations for the backward pass. Calling backward before forward returns Error::NeuralNetwork(NnError::ForwardPassNotRun("SimpleRNN")), or "LSTM" or "GRU" for those layers. See 1.6. Error Handling for how these error variants work together.

RustyML differs from Keras in one way. The activation argument controls only the candidate or output nonlinearity, not the gates. In SimpleRNN, this activation applies to every hidden state. In LSTM and GRU, it applies to the candidate, and in LSTM, it also applies to the cell state before the output gate.

The gates always use sigmoid. RustyML has no separate recurrent_activation option like Keras has. The gate nonlinearity is fixed. Tanh is the default activation, and almost every published architecture uses it.

3.7.2. SimpleRNN and why gated cells exist

SimpleRNN is the textbook Elman recurrence. It starts from a zero hidden state. Each timestep mixes the current input with the previous hidden state, using 2 weight matrices and 1 bias:

h_0 = 0
h_t = activation( x_t @ W + h_{t-1} @ U + b )     for t = 1..T
output = h_T

Here, W is the input kernel (input_dim, units). U is the recurrent kernel (units, units). b is the bias (1, units). @ is a matmul that runs over the batch dimension.

RustyML initializes W with Xavier/Glorot uniform, and U with an orthogonal matrix (Gram-Schmidt). The orthogonal recurrent kernel is deliberate. It keeps the state transition norm-preserving at initialization. This is a cheap way to delay the vanishing-gradient problem described next.

That problem is vanishing gradients, and its counterpart, exploding gradients. Backpropagating from h_T to h_1 multiplies the upstream gradient by a fresh Jacobian at every step. That step is roughly grad_{t-1} = (activation'(h_t) * grad_t) @ U^T. Chaining T of those steps raises a matrix to the T-th power. If the matrix’s effective magnitude is below 1, the gradient decays exponentially, and the network cannot learn dependencies more than a few steps back. If the magnitude is above 1, the gradient explodes instead.

The orthogonal U keeps the U^T factor norm-preserving, and tanh' <= 1 keeps the product bounded. Even so, the product still tends toward zero over long sequences. This is why SimpleRNN works only for short sequences, up to a few dozen steps, and fails at long-range dependencies. LSTM and GRU exist to solve this problem.

3.7.3. LSTM: an additive memory highway

LSTM adds a second state, the cell state c_t. Its update is additive. LSTM uses 3 sigmoid gates to decide what to write, what to keep, and what to read. RustyML stores the 4 weight blocks fused side by side. The column order matches Keras, [input | forget | cell | output], written [i | f | g | o]:

i_t = sigmoid( x_t @ W_i + h_{t-1} @ U_i + b_i )     input gate  (how much candidate to write)
f_t = sigmoid( x_t @ W_f + h_{t-1} @ U_f + b_f )     forget gate (how much old cell to keep)
g_t = act(     x_t @ W_g + h_{t-1} @ U_g + b_g )     candidate   ("cell gate")
o_t = sigmoid( x_t @ W_o + h_{t-1} @ U_o + b_o )     output gate (how much cell to expose)
c_t = f_t * c_{t-1} + i_t * g_t                      cell state  (additive update)
h_t = o_t * act(c_t)                                 hidden state

Here * is elementwise multiplication. act is the configurable activation, Tanh by default, applied to both the candidate and the cell state. The key line is c_t = f_t * c_{t-1} + i_t * g_t. Its gradient with respect to c_{t-1} is just f_t, an elementwise multiply with no repeated matmul.

When the forget gate is open, meaning f is close to 1, the cell state carries the gradient backward with almost no loss. This is a near-identity highway, and the additive term feeds into it. Memory persists, and gradient flows, because the dominant path is addition, not repeated matrix multiplication.

RustyML initializes the forget-gate bias to 1.0, and every other bias to zero. This gives the memory highway a head start. Before training shapes the gates, f starts biased open, so the cell state, and its gradient, survive from the first epoch. The integration test lstm_forget_bias_is_one_not_zero checks this behavior. LSTM has 4 gates’ worth of parameters: param_count = 4 * (input_dim * units + units * units + units).

3.7.4. GRU: the same idea with merged gates

GRU keeps the additive-blend idea from LSTM. It folds the input and forget gates into a single update gate, and it drops the separate cell state. GRU needs only 3 weight blocks instead of 4. RustyML stores them fused, in the order [update | reset | candidate], written [z | r | h]. This order matches Keras:

z_t = sigmoid( x_t @ W_z + h_{t-1} @ U_z + b_z )          update gate
r_t = sigmoid( x_t @ W_r + h_{t-1} @ U_r + b_r )          reset gate
n_t = act( x_t @ W_h + (r_t * h_{t-1}) @ U_h + b_h )      candidate
h_t = z_t * h_{t-1} + (1 - z_t) * n_t                     hidden state

The update gate z_t runs a convex blend. When z is close to 1, the layer copies the previous hidden state through unchanged. This acts as a gradient highway, the same way a closed LSTM forget gate does. When z is close to 0, the layer replaces the previous state with the fresh candidate.

This is Keras’ convention. Some write-ups use the complement, so a z value ported from one of those needs flipping. The tests gru_update_gate_one_keeps_previous_hidden and gru_update_gate_zero_takes_the_candidate check these 2 extremes. The test gru_fused_kernel_first_block_is_the_update_gate checks the column order.

Note precisely where the reset gate acts. RustyML computes r_t * h_{t-1} before the candidate’s recurrent matmul, as (r_t * h_{t-1}) @ U_h. This matches the original Cho et al. formulation, which is Keras’ reset_after=False. It differs from the CuDNN reset_after=True variant, which applies the reset after the matmul and needs 2 biases per gate. RustyML uses a single bias per gate here.

GRU’s param_count = 3 * (input_dim * units + units * units + units). This is 3 quarters of an LSTM of the same width. In practice, GRU trains a little faster and matches LSTM on many tasks. LSTM sometimes performs better when a task needs a long, precisely controlled memory. Both layers initialize their input kernels with Xavier/Glorot, using the per-gate fan input_dim + units, not the fused width. Both layers initialize each gate’s recurrent block as an independent orthogonal matrix.

3.7.5. Weights, shapes, and setting them by hand

The trainable tensors and their shapes:

Layerkernelrecurrent_kernelbiasfused column blocks
SimpleRNN(input_dim, units)(units, units)(1, units)none
LSTM(input_dim, 4 * units)(units, 4 * units)(1, 4 * units)[i | f | g | o]
GRU(input_dim, 3 * units)(units, 3 * units)(1, 3 * units)[z | r | h]

Fusing every gate into one matrix is not just cosmetic. It lets the input projection and the recurrent projection run as one large GEMM per timestep, instead of one GEMM per gate. This gives a real cache and SIMD benefit. Use get_weights() to inspect the live arrays. It returns a LayerWeight::{SimpleRNN,LSTM,GRU} value, carrying borrowed kernel, recurrent_kernel, and bias fields:

use rustyml::neural_network::layers::activation::Tanh;
use rustyml::neural_network::layers::layer_weight::LayerWeight;
use rustyml::neural_network::layers::recurrent::LSTM;
use rustyml::neural_network::traits::Layer;

fn main() {
    // input_dim = 4, units = 8. with_random_state makes the init reproducible.
    let lstm = LSTM::new(4, 8, Tanh::new()).unwrap().with_random_state(42);

    match lstm.get_weights() {
        LayerWeight::LSTM(w) => {
            // All 4 gates are fused side by side: width == 4 * units.
            println!("kernel           {:?}", w.kernel.shape()); // [4, 32]
            println!("recurrent_kernel {:?}", w.recurrent_kernel.shape()); // [8, 32]
            println!("bias             {:?}", w.bias.shape()); // [1, 32]
        }
        _ => unreachable!(),
    }
}
kernel           [4, 32]
recurrent_kernel [8, 32]
bias             [1, 32]

Call with_random_state(seed) for reproducible initialization. It re-runs the kernel and recurrent-kernel draws deterministically, and the forget-bias-1.0 rule still applies. Without a seed, RustyML seeds the weights from the global seed or from entropy. See 7.1. Reproducibility and Random Seeds.

To install weights by hand, for example when you port from another framework or unit-test an exact recurrence, every layer has set_weights(kernel, recurrent_kernel, bias). This method takes the fused matrices directly. LSTM and GRU also have set_gate_weights(...). It accepts one (kernel, recurrent_kernel, bias) triple per gate, and concatenates the triples into the fused layout for you.

The per-gate argument order is (input, forget, cell, output) for LSTM, 12 arrays in total. For GRU it is (reset, update, candidate), 9 arrays in total. Any shape mismatch returns Error::NeuralNetwork(NnError::WeightShape { .. }). When you save or load a whole model, these arrays serialize as-is. See 3.9. Saving and Loading Weights.

3.7.6. BPTT and the cost model

Training uses backpropagation through time with a full unroll. There is no truncation window. On the forward pass, the layer caches everything the backward pass needs. SimpleRNN stores every hidden state, with h_0 = 0 prepended. LSTM also stores the cell states, activation(c_t), and all 4 gate activations per timestep. GRU stores the reset and update gates, the candidate, and the r_t * h_{t-1} product.

predict passes None for these caches and skips the recording and its clones. This is why inference costs less than a training forward call. Memory scales linearly with timesteps. Long sequences cost RAM, not just time.

backward walks the timesteps in reverse. It expects a 2D upstream gradient (batch, units), the gradient of the loss with respect to the final hidden state. This is the only gradient the layer needs, because the final state is the only thing the layer emitted. At each step, backward computes the per-timestep pre-activation gradient. It threads grad_h, and grad_c for LSTM, back to the previous step. This part is inherently sequential over time.

backward then batches the weight-gradient reductions. It collapses the per-timestep dz values over (batch, timesteps), so the kernel and bias gradients each fall out of a single large GEMM. The recurrent-kernel gradient does too, except for GRU, which needs 2 GEMMs for its 2 distinct recurrent inputs. RustyML stores gradients with replace semantics, and does not clip them inside the layer. If you need gradient clipping, use an optimizer that offers it. See 3.4. Optimizers.

The performance shape is sequential over the time axis, and parallel over the batch and fused gate axis. The input projection x @ W does not depend on the recurrence. RustyML computes it once, as a single batched GEMM across all timesteps, up front. Only the h_{t-1} @ U term must run step by step. Each of those steps is itself a batch-parallel GEMM.

GRU saves a little more work here. It fuses the reset and update recurrent projections into one GEMM, because both gates read h_{t-1}. Only the candidate’s recurrent projection stays separate, because its input r_t * h_{t-1} depends on the freshly computed reset gate.

Every matmul goes to the gemmkit backend (see 6.2. Matrix Multiplication). gemmkit sizes its own parallelism from the amount of work. Wide layers and large batches get thread parallelism automatically. Small layers stay serial, to avoid overhead. A timestep’s fused gate projection always applies its bias in the same pass as the product. Activation fusion into that pass happens only for SimpleRNN with ReLU, not for LSTM, GRU, or any other activation.

The practical result: more sequences, meaning a larger batch, parallelize well. More timesteps do not, because that axis is a serial dependency chain. 7.3. Performance Tuning and Parallelism covers the thread-pool settings.

3.7.7. Stacking and building a model

A recurrent layer returns only the last hidden state, a 2D (batch, units) tensor. You cannot feed that output straight into another recurrent layer. A recurrent layer needs a 3D (batch, timesteps, features) input, and it rejects a 2D tensor with Error::InvalidInput. RustyML has no return_sequences option, so a layer cannot emit a per-timestep sequence. This means deep stacks of recurrent layers, in the Keras sense, are not possible here. Plan around this limitation.

The standard pattern uses a recurrent layer as a sequence encoder, followed by a dense head that maps the final state to your targets. This composes cleanly. The recurrent layer turns (batch, timesteps, features) into (batch, units), and Dense consumes exactly that 2D shape.

The example below learns a real sequence task: predict the sum of a length-3 scalar sequence. It uses an LSTM encoder, a Dense readout, Adam, and mean-squared error. It converges in a few hundred full-batch epochs. Recall from 3.1. The Sequential Model that fit runs one full-batch gradient step per epoch:

use ndarray::Array;
use rustyml::neural_network::sequential::Sequential;
use rustyml::prelude::*;

fn main() {
    // 4 sequences, 3 timesteps, 1 feature. Target = sum of the 3 scalars.
    let x = Array::from_shape_vec(
        (4, 3, 1),
        vec![
            0.1, 0.2, 0.1, // sum 0.4
            0.3, 0.1, 0.2, // sum 0.6
            0.0, 0.2, 0.2, // sum 0.4
            0.2, 0.2, 0.1, // sum 0.5
        ],
    )
    .unwrap()
    .into_dyn();
    let y = Array::from_shape_vec((4, 1), vec![0.4, 0.6, 0.4, 0.5])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        // Recurrent feature extractor: (batch, 3, 1) -> (batch, 16)
        .add(LSTM::new(1, 16, Tanh::new()).unwrap().with_random_state(42))
        // Read the last hidden state out to a single scalar.
        .add(Dense::new(16, 1, Linear::new()).unwrap())
        .compile(
            Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(),
            MeanSquaredError::new(),
        );

    model.fit(&x, &y, 300).unwrap();

    let pred = model.predict(&x).unwrap();
    println!("target      : {:?}", y.as_slice().unwrap());
    println!("prediction  : {:?}", pred.as_slice().unwrap());
}

After 300 epochs, the 4 predictions land within a few percent of [0.4, 0.6, 0.4, 0.5]. The LSTM has learned to accumulate the sequence, and the dense head reads the accumulator out. Swap LSTM for GRU or SimpleRNN, and the same code still compiles and trains. On this short sequence, all 3 layer types converge. This is because the vanishing-gradient advantage of the gated cells shows up only on long sequences.

model.summary() prints each recurrent layer’s output shape as (None, units). The None stands for the dynamic batch dimension. For anything larger than a toy dataset, use fit_with_batches instead of fit, so each epoch takes several mini-batch steps and reshuffles the data.

3.8. Regularization and Normalization Layers

This page covers the layers that reshape the signal in the network instead of learning a mapping. These are dropout and its spatial variants, the 2 Gaussian noise layers, and the 4 normalization layers (batch, layer, group, instance). They all live in rustyml::neural_network::layers::regularization for one reason. Each layer behaves differently in training mode than in inference mode, and the crate factors that split into one shared mechanism. Learn that mechanism first, because the rest of the page is only detail. Skip it, and batch normalization in particular can quietly compute the wrong numbers.

Every layer here plugs into a Sequential model with the same .add(...) call, just like Dense Layers and Activations. Every constructor returns a Result. See Error Handling for the reason.

Import everything with use rustyml::neural_network::layers::*;. This re-exports Dropout, SpatialDropout1D/2D/3D, GaussianNoise, GaussianDropout, BatchNormalization, LayerNormalization (with its LayerNormalizationAxis), GroupNormalization, and InstanceNormalization. As everywhere in this crate, a Tensor is ndarray::ArrayD<f32>. It uses single precision and a dynamic rank.

3.8.1. Training mode versus inference mode

Every mode-dependent layer carries a private training: bool flag. It exposes 2 ways to run forward. The Layer trait defines both methods:

fn forward(&mut self, input: &Tensor) -> Result<Tensor, Error>;   // records caches, honors the flag
fn predict(&self, input: &Tensor) -> Result<Tensor, Error>;       // always eval, writes no caches

forward takes &mut self. It reads the training flag and stores whatever the backward pass needs: masks, batch statistics, or the noise draw. predict takes &self. It always runs the inference path and records nothing. This is why a compiled model can serve concurrent requests across threads. Flip the flag with set_training_if_mode_dependent(is_training) (the trait method) or the inherent set_training(is_training). Layers that do not depend on the mode, such as Dense, activations, and pooling, inherit a no-op for both methods.

Inside a Sequential model, you rarely touch this flag yourself. fit and fit_with_batches set every layer to training mode and call forward. predict and evaluate call each layer’s predict method instead. Watch for one trap: calling forward by hand for inference. A BatchNormalization layer whose flag is still true computes batch statistics from your test batch and changes its running averages. For anything that is not a training step, call model.predict(...), or set the flag to false. The table below shows what each layer family does in each mode:

Layerforward in training modeforward in inference / predictbackward in inference mode
Dropout, SpatialDropout*drop a fraction, rescale survivorsidentity (pass through unchanged)gradient passed through
GaussianNoiseadd N(0, stddev^2)identitygradient passed through
GaussianDropoutmultiply by N(1, sigma)identitygradient passed through
BatchNormalizationnormalize with batch stats, update running statsnormalize with running statsgradient passed through
LayerNormalization, GroupNormalization, InstanceNormalizationnormalize with stats from the current inputsame stats from the current inputgradient passed through

Note the bottom row. Layer, group, and instance normalization compute their statistics from the current input in every mode. Their forward output is therefore identical whether the flag is on or off, and predict equals forward bit for bit. Only its backward pass depends on the mode: in inference mode it returns the upstream gradient unchanged instead of computing the real gradient. Batch normalization is the only layer here that keeps state (running mean and variance). It is the only one that computes something different between the 2 modes.

This split also explains why one model can report 2 different losses. The per-epoch figures in the History from fit and fit_with_batches come from training-mode forward passes. In training mode, dropout zeros a fraction of the activations and rescales the survivors. Batch norm normalizes with each batch’s own statistics and folds them into its running averages. evaluate runs the inference path instead: dropout is the identity, and batch norm reads the running statistics without changing them. On a model that holds either layer, the 2 numbers do not agree, and neither number is wrong. The training figure is the loss of a deliberately handicapped network, measured before that batch’s own update. evaluate scores the network you actually hold. Use evaluate to select checkpoints and to stop training early. Call it at any point in training. It borrows &self, updates nothing, and draws no random numbers. So it cannot consume a dropout mask, and it cannot shift the shuffle order of the run it measures.

3.8.2. Dropout

Dropout::new(rate, input_shape) builds the classic layer. rate is the fraction of units to zero. It must lie in [0.0, 1.0] inclusive, or the call returns Error::InvalidParameter. input_shape is checked against every input, but only for its rank and its per-sample axes. Axis 0 is the batch axis. It varies with whoever calls forward, so the check skips it. A layer declared vec![4, 8] therefore takes a batch of any size, but still rejects rows that are not 8 wide. The wildcard vec![] turns the shape check off entirely.

The implementation is inverted dropout. During a training forward, it samples a uniform mask and keeps each unit with probability 1 - rate. It then scales the survivors by 1 / (1 - rate), which keeps the expected activation unchanged. That scaling is the whole point: inference becomes a pure identity operation (predict returns the input unchanged), and serving code needs no rescaling step. The 2 boundary values short-circuit this logic. rate == 0.0 is the identity. rate == 1.0 zeros every unit.

use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;

fn main() {
    // 50% rate, expecting [4, 8] inputs. Seed the mask so this run is reproducible.
    let mut dropout = Dropout::new(0.5, vec![4, 8]).unwrap().with_random_state(7);
    let input = Array::ones((4, 8)).into_dyn();

    // Training: about half the units become 0, the survivors become 1/(1-0.5) = 2.0.
    dropout.set_training_if_mode_dependent(true);
    let train_out = dropout.forward(&input).unwrap();
    let kept = train_out.iter().filter(|&&v| v != 0.0).count();
    println!("kept {kept}/{} units, each rescaled to 2.0", input.len());

    // Inference: inverted dropout makes the layer the identity.
    dropout.set_training_if_mode_dependent(false);
    assert_eq!(dropout.forward(&input).unwrap(), input);

    // predict() is the eval path regardless of the flag, and never caches a mask.
    assert_eq!(dropout.predict(&input).unwrap(), input);
}

The mask is drawn from a per-layer StdRng. By default Dropout::new seeds it from the global seed (or from OS entropy if none is set). with_random_state(seed) re-seeds it deterministically. Because the RNG advances with each draw, 2 consecutive training forward calls produce different masks. Seeding fixes the sequence across process runs, not equality between calls. See Reproducibility and Random Seeds for how the global seed threads through every randomized component. Dropout has no trainable parameters. Calling backward before forward (so no mask was cached) returns NnError::ForwardPassNotRun. In inference mode or at rate == 0.0, the backward simply passes the gradient through.

3.8.3. Spatial dropout for feature maps

Plain dropout is a poor fit for convolutional feature maps. Adjacent pixels in a channel correlate strongly, so zeroing scattered individual elements removes almost no information. A dropped pixel’s value is nearly recoverable from its neighbors, so the regularizing effect washes out. SpatialDropout1D, SpatialDropout2D, and SpatialDropout3D fix this by dropping an entire channel at a time. When a channel drops, all of its spatial positions go to zero together, which forces the network to avoid depending on any single feature map. This mirrors Keras’ SpatialDropout* layers.

The 3 layers differ only in the expected rank. All 3 use the channels-last layout. SpatialDropout1D expects a 3-D (batch, length, channels) input. SpatialDropout2D expects a 4-D (batch, height, width, channels) input. SpatialDropout3D expects a 5-D (batch, depth, height, width, channels) input. A wrong rank returns Error::InvalidInput.

Internally, each layer samples one keep/drop value per (batch, channel) pair: a tiny [batch, channels] mask. It applies the same 1 / (1 - rate) inverted-dropout scale across the whole channel, so the layer never builds a full-size mask. Construction, seeding with with_random_state, the boundary behaviors, and the ForwardPassNotRun contract match plain Dropout. The error even names the concrete layer. A premature backward call on a SpatialDropout2D layer returns ForwardPassNotRun("SpatialDropout2D").

use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;

fn main() {
    // (batch=1, height=4, width=4, channels=8): whole channels drop as a unit.
    let mut sd = SpatialDropout2D::new(0.5, vec![1, 4, 4, 8]).unwrap().with_random_state(3);
    sd.set_training_if_mode_dependent(true);

    let input = Array::ones((1, 4, 4, 8)).into_dyn();
    let out = sd.forward(&input).unwrap();

    // Every position within a channel shares 1 value: 0.0 (dropped) or 2.0 (kept & rescaled).
    for c in 0..8 {
        let first = out[[0, 0, 0, c]];
        assert!((0..4).all(|h| (0..4).all(|w| out[[0, h, w, c]] == first)));
        println!("channel {c}: all 16 spatial positions hold {first}");
    }
}

3.8.4. Gaussian noise and Gaussian dropout

GaussianNoise and GaussianDropout regularize by injecting noise instead of zeroing values. GaussianNoise::new(stddev, input_shape) is additive. During training it adds zero-mean N(0, stddev^2) noise: output = input + noise. This is a data-augmentation style of regularization. It perturbs inputs without changing their expected value, but it does inflate their variance. With a large enough stddev, a positive input can become negative. stddev must be non-negative and finite. Construction rejects a negative, NaN, or Inf value. This check is deliberate: without it, the sampler would panic on the first forward call. The backward pass is a pure pass-through in every mode, because the noise does not depend on the input, so d(x + noise)/dx = 1. It caches nothing and never returns ForwardPassNotRun.

GaussianDropout::new(rate, input_shape) is multiplicative. It is the closer analogue to plain dropout. It multiplies each input by a sample from N(1, sigma), where sigma = sqrt(rate / (1 - rate)). The mean-1 noise leaves E[output] = input. This is the same expectation-preserving idea as inverted dropout, but it uses continuous multipliers instead of hard zeros. Here rate must be in [0.0, 1.0), exclusive of 1, because sigma diverges as rate approaches 1. Unlike GaussianNoise, this layer caches the exact noise draw, so its backward pass can reuse it. Because y = x * noise, dx = grad * noise. A backward call before any training forward call returns ForwardPassNotRun. Both layers are the identity at inference, and also when stddev or rate is 0. Neither layer has trainable parameters.

use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;

fn main() {
    let input = Array::from_elem((2, 4), 3.0_f32).into_dyn();

    // Additive: output = input + N(0, 0.5^2). Mean preserved, variance added.
    let mut noise = GaussianNoise::new(0.5, vec![2, 4]).unwrap().with_random_state(1);
    noise.set_training_if_mode_dependent(true);
    let noisy = noise.forward(&input).unwrap();

    // Multiplicative: output = input * N(1, sqrt(rate/(1-rate))). E[output] stays at input.
    let mut gdrop = GaussianDropout::new(0.3, vec![2, 4]).unwrap().with_random_state(1);
    gdrop.set_training_if_mode_dependent(true);
    let scaled = gdrop.forward(&input).unwrap();

    println!("additive[0] = {}, multiplicative[0] = {}", noisy[[0, 0]], scaled[[0, 0]]);

    // Both are the identity at serving time.
    noise.set_training_if_mode_dependent(false);
    gdrop.set_training_if_mode_dependent(false);
    assert_eq!(noise.forward(&input).unwrap(), input);
    assert_eq!(gdrop.forward(&input).unwrap(), input);
}

3.8.5. The normalization layers at a glance

The 4 normalization layers share one skeleton. Each layer subtracts a mean and divides by a standard deviation computed over some set of axes. It then applies a learnable per-channel affine transform: gamma * x_normalized + beta. gamma initializes to ones, and beta initializes to zeros. The optimizer treats both as trainable parameters, marked no-decay, so weight decay skips them. Normalizing scale and shift should not get pulled toward zero. Every layer also takes an epsilon value (typically 1e-5), added under the square root for numerical stability. This epsilon also makes a zero-variance input produce a finite all-zero output instead of a NaN. What separates the 4 layers is only which axes the statistics reduce over. Consider an input shaped [N, ...spatial, C], with batch N leading and channels C trailing:

LayerMean/variance reduced overOne statistic perDepends on batch?gamma/beta length
BatchNormalizationbatch N (and all spatial)channelyesC
LayerNormalizationthe normalized axis (last, by default)everything but that axisnonormalized axis size
GroupNormalizationa group of channels + spatial, per sample(sample, group)noC
InstanceNormalizationspatial only, per sample and channel(sample, channel)noC

Use the “depends on batch?” column as the practical guide for choosing a layer. Batch norm couples samples together through shared statistics. This makes it effective, but also fragile at small batch sizes. The other 3 layers normalize each sample independently, so batch size does not affect them. All 4 layers preserve the input shape. The shape guards below are not optional. Group and instance normalization require rank 3 or higher (a 2-D input returns Error::InvalidInput). Batch norm accepts rank 2 and higher.

3.8.6. BatchNormalization

BatchNormalization::new(input_shape, momentum, epsilon) takes the shape first, then the 2 scalars. input_shape must be non-empty, or the call returns Error::EmptyInput. momentum must be in [0.0, 1.0]. epsilon must be positive. Dimension 0 is the batch. The last dimension is the channel or feature axis. The per-channel parameters have length input_shape.last().

For a 2-D [N, C] input, this is ordinary per-feature batch norm. For a rank-3-or-higher [N, ...spatial, C] input, the statistics reduce over the batch and every spatial position. This gives 1 mean, variance, gamma, and beta per channel: “spatial” batch norm, matching Keras’ axis=-1 default. Both cases run the same code path. Because the channel axis is innermost, [N, ...spatial, C] already is the [M, C] matrix the per-channel folds read. Collapsing the leading axes reinterprets the same bytes instead of reshaping them.

A 1-D input_shape, such as vec![4], is a special case with no channel axis. It uses length-1 scalar parameters broadcast over the whole input.

The state that makes batch norm mode-dependent is a pair of running statistics. During a training forward call, the layer first normalizes with the current batch’s mean and variance. It then updates the running statistics as running = running * momentum + batch * (1 - momentum). This is the Keras convention. A high momentum, such as 0.99, weights the history heavily and moves the running estimate slowly. PyTorch uses the opposite convention: its momentum weights the new batch. A value copied directly from PyTorch code produces the opposite behavior here.

momentum == 0.0 discards history entirely, so the running statistics equal the last batch’s. momentum == 1.0 freezes the running statistics at their initial values (mean 0, variance 1). This quietly turns eval-mode normalization into a near-identity operation, usually by mistake. At inference, forward and predict normalize with the running statistics instead of the batch. This is exactly why feeding a test batch through forward in training mode corrupts the model.

Placement relative to the activation matters. The fused-activation design of Dense shapes your options. The original batch-norm paper places it before the nonlinearity. Dense::new(.., .., Activation::ReLU) folds the activation into the linear layer. So “BN before activation” needs a linear Dense layer, then BatchNormalization, then a separate activation layer:

use rustyml::neural_network::layers::activation::relu::ReLU;
// Dense(linear) -> BatchNorm -> ReLU  (BN before the nonlinearity, paper ordering)
model
    .add(Dense::new(4, 8, Activation::Linear).unwrap())
    .add(BatchNormalization::new(vec![6, 8], 0.99, 1e-5).unwrap())
    .add(ReLU::new());

A common alternative applies the activation first, then normalizes. This is just Dense::new(.., .., Activation::ReLU) followed by BatchNormalization. Both orderings train fine. Pick one and stay consistent. The example below shows a full runnable model, using the simpler activation-in-the-Dense form:

use rustyml::neural_network::layers::*;
use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::optimizers::Adam;
use rustyml::neural_network::losses::MeanSquaredError;
use ndarray::Array;

fn main() {
    // The 6 in the BatchNormalization shape [6, 8] is not enforced. Only the trailing 8 is.
    let x = Array::ones((6, 4)).into_dyn();
    let y = Array::ones((6, 1)).into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(4, 8, Activation::ReLU).unwrap())
        .add(BatchNormalization::new(vec![6, 8], 0.9, 1e-5).unwrap())
        .add(Dropout::new(0.3, vec![6, 8]).unwrap())
        .add(Dense::new(8, 1, Activation::Linear).unwrap())
        .compile(
            Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(),
            MeanSquaredError::new(),
        );

    // fit() runs the training path (batch stats, running-stat updates, dropout on).
    // predict() runs the eval path (running stats, dropout as identity).
    model.fit(&x, &y, 3).unwrap();
    println!("prediction shape: {:?}", model.predict(&x).unwrap().shape());
}

The leading dimension of input_shape records a batch size, but the layer does not check it. A BatchNormalization layer declared for [6, 8] accepts a batch of any size. So fit_with_batches trains at any batch_size, including the short final chunk left over when the dataset does not divide evenly. That short chunk does not skew the epoch’s reported loss either. fit_with_batches weights each batch by the number of samples it holds, instead of giving every batch one vote. So the History entry stays the dataset-wide mean per-sample loss, however the split lands. This matches the accounting Keras uses.

The check does enforce the rank and every per-sample axis. So an [n, 16] input, or an input of the wrong rank, still returns Error::ShapeMismatch. This also means the vec![] wildcard is not the way to make a layer survive mini-batching. Use it only when the per-sample shape itself varies.

You can also inject known weights directly with set_weights(gamma, beta, running_mean, running_var). A shape mismatch returns NnError::WeightShape. This is how a loaded model restores its inference statistics. See the next section and Saving and Loading Weights.

3.8.7. LayerNormalization

LayerNormalization::new(input_shape, epsilon) normalizes across features within each sample. It has no batch coupling and no running statistics. This is exactly why it is the normalization of choice for recurrent models. It also suits any setting where the batch is small, variable, or size 1. Batch norm’s statistics get noisy or meaningless with a handful of samples. Layer norm is unaffected, because every sample stands alone. Its forward output is the same in training and inference, so predict equals forward. Only its backward pass depends on the mode.

By default, it normalizes the last (feature) dimension. with_normalized_axis(...) changes this. It takes a LayerNormalizationAxis value: Default (the last axis), Custom(axis) for a single other axis, or Multiple(vec![...]) to normalize jointly over several axes at once. Multiple is Keras-style: the statistics span the combined elements of those axes, and gamma/beta become 1-D over their product. Changing the axis resizes gamma and beta to match, so call this method before you assign weights. The layer rejects an empty, duplicated, or out-of-bounds axis list.

A non-trailing Custom axis still works correctly, but it runs on a slower strided path instead of the fused row path. A Multiple list whose axes are not already trailing and in order also works correctly. It first transposes the input to bring the normalized axes together, runs the fused row path, then transposes the result back. Both cases cost more than the default trailing-axis path, but neither changes the result.

use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;

fn main() {
    let input = Array::from_shape_vec((2, 4), vec![1.0, 3.0, 5.0, 7.0, 2.0, -2.0, 0.0, 4.0])
        .unwrap()
        .into_dyn();

    // Default: each row (the last axis) is normalized to mean 0, variance 1.
    let mut ln = LayerNormalization::new(vec![2, 4], 1e-5).unwrap();
    let out = ln.forward(&input).unwrap();

    // Custom(0): normalize down each column (across the batch axis) instead.
    let mut ln_cols = LayerNormalization::new(vec![2, 4], 1e-5)
        .unwrap()
        .with_normalized_axis(LayerNormalizationAxis::Custom(0))
        .unwrap();
    let out_cols = ln_cols.forward(&input).unwrap();

    println!("row-norm {:?}, col-norm {:?}", out.shape(), out_cols.shape());

    // Statistics come from the current input, so predict() reproduces forward() exactly.
    assert_eq!(ln.predict(&input).unwrap(), out);
}

3.8.8. GroupNormalization and InstanceNormalization

These 2 layers fill the middle ground for convolutional models where the batch is too small for batch norm to work well. Examples include detection and segmentation backbones, and generative models. GroupNormalization::new(input_shape, num_groups, epsilon) splits the channels into num_groups contiguous groups and normalizes within each group, per sample. With 1 group, it becomes layer norm over all channels. With as many groups as channels, it becomes instance norm. InstanceNormalization::new(input_shape, epsilon) normalizes each (sample, channel) plane on its own. This is the standard choice for style transfer, because it strips per-instance contrast while leaving batch relationships alone.

Both layers require rank 3 or higher. Both take the channel axis as the trailing axis, like every other spatial layer. Like layer norm, both carry no running statistics and are mode-independent in the forward direction. Their per-group mean and variance come from a single pass over the data. The 2 accumulators gather sums of deviations from a value taken out of the data itself. This lets the variance fall out without a second walk of the sample. It also avoids the catastrophic cancellation that a plain E[x^2] - E[x]^2 formula would hit when the mean dwarfs the spread.

Instance norm is group norm with num_groups set to the channel count, and the crate implements it that way. The 2 layers produce identical output when configured to match:

use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;

fn main() {
    // [batch=1, positions=4, channels=4]
    let input = Array::from_shape_vec((1, 4, 4), (0..16).map(|v| v as f32).collect::<Vec<_>>())
        .unwrap()
        .into_dyn();

    // InstanceNorm normalizes every (sample, channel) plane independently...
    let mut inn = InstanceNormalization::new(vec![1, 4, 4], 1e-5).unwrap();
    let out_in = inn.forward(&input).unwrap();

    // ...which is exactly GroupNorm with 1 group per channel.
    let mut gn = GroupNormalization::new(vec![1, 4, 4], 4, 1e-5).unwrap();
    let out_gn = gn.forward(&input).unwrap();

    let max_diff = out_in
        .iter()
        .zip(out_gn.iter())
        .map(|(a, b)| (a - b).abs())
        .fold(0.0_f32, f32::max);
    println!("max |InstanceNorm - GroupNorm(groups=channels)| = {max_diff}");
    assert!(max_diff < 1e-6);
}

One divisibility rule matters for GroupNormalization: num_groups must evenly divide the channel count. The crate checks this at forward time, not in the constructor. So a mismatched layer builds without error, but fails on its first forward call with Error::InvalidParameter. GroupNormalization::new also catches num_groups == 0 at construction. Both constructors catch an empty input_shape (Error::EmptyInput) and a non-positive or non-finite epsilon (Error::InvalidParameter) up front. Both layers accept set_weights(gamma, beta).

3.8.9. Seeding, determinism, and what gets saved

Every layer on this page also shares 2 further properties. The first is randomness. Only the dropout and Gaussian layers draw random numbers. Each draws from its own StdRng, seeded through the crate’s global-seed machinery. new seeds from the global seed or from entropy. with_random_state(seed) pins the seed. The normalization layers are fully deterministic. Even the internal parallel and serial thresholds these layers use are bit-for-bit invariant: the parallel path produces the same result as the serial path. So enabling threads never changes your numbers. Threading is a performance knob only, covered in Performance Tuning and Parallelism. Seed everything together through Reproducibility and Random Seeds.

The second property is persistence. It has one asymmetry to remember before you save a model. The dropout and noise layers hold no trainable parameters. They serialize as empty, and the crate does not save their RNG state. This is harmless, because these layers are the identity at inference anyway. LayerNormalization, GroupNormalization, and InstanceNormalization serialize only their gamma and beta values. This is complete, because they recompute statistics from each input. BatchNormalization is the exception. Its weight record carries gamma, beta, and running_mean and running_var. Inference uses those running statistics. If the crate dropped them, a loaded model would normalize with garbage values. Because the crate persists them, a saved and reloaded batch-norm model predicts identically to the original. See Saving and Loading Weights and Model Persistence in Depth for the mechanics of the round trip.

3.9. Saving and Loading Weights

RustyML persists a trained Sequential model with exactly 2 methods: save_to_path and load_from_path. These methods are narrow by design. Keras’ model.save() writes a self-describing bundle that rebuilds the graph, the compiler config, and the optimizer state. RustyML does not do this. RustyML saves only the layer weights, and the file does not carry enough information to rebuild a model. Loading takes 2 steps. First, you build the identical layer stack in code. Second, you load the saved arrays into that stack. This section describes what the file stores, why the boundary sits there, the failure modes, and workflow recipes for a weights-only model.

3.9.1. What actually gets written to disk

save_to_path walks the layers. For each layer, it records a small metadata tag plus the layer’s weights. It then serializes the whole vector with postcard into a compact binary blob, and a buffered writer writes that blob to disk. load_from_path reads the file back, deserializes it, checks that the model you built matches the file, and applies the arrays layer by layer. The signatures:

pub fn save_to_path(&self, path: impl AsRef<std::path::Path>) -> RustymlResult<()>;
pub fn load_from_path(&mut self, path: impl AsRef<std::path::Path>) -> RustymlResult<()>;

Both methods accept any type that implements AsRef<Path>: &str, String, Path, or PathBuf. The "model.bin" literals in the examples below are only the common case. The file extension has no effect on the format, since postcard writes raw bytes no matter what name you give the file. save_to_path uses File::create, which truncates and overwrites the file, so saving again to the same path replaces the previous checkpoint. This behavior fits a “keep only the best” loop.

The per-layer metadata serves one purpose: validation, not reconstruction. Each layer contributes a type-name string, for example "Dense" or "Conv2D", and an output-shape string. These strings are validation tags, not a recipe for building a layer. The file does not record a layer’s activation function. It does not record epsilon, momentum, stride, kernel size, dilation, or group count. You cannot hand load_from_path an empty Sequential and get back a working model. RustyML calls this format “weights-only” for that reason. The architecture lives in your source code, and the file carries only the numbers that fill it.

Persisted in the fileNot persisted
Per-layer type-name tag (validated on load)Activation functions, hyperparameters (epsilon, momentum, stride, …)
Per-layer output-shape tag (informational only)Optimizer and its accumulated state (Adam moments, SGD momentum)
Every layer’s weight arrays (see 3.9.2)Loss function and compile state
BatchNormalization running mean and varianceFit-time shuffle seed, training-mode flag

3.9.2. The LayerWeight enum: per-layer payloads

The on-disk weight format is a Rust enum, LayerWeight<'a>. It has one variant for each supported layer type, plus an Empty variant for layers with no parameters. Each variant wraps a small struct that stores its arrays as Cow. Cow lets one type serve both directions. When saving, Sequential::get_weights borrows the live arrays (Cow::Borrowed), with no clone. When loading, load_from_path deserializes into owned arrays (Cow::Owned). The enum uses serde’s default, externally tagged representation, because postcard is not self-describing and needs the discriminant written out explicitly.

Each variant’s payload determines whether a round-trip is exact:

VariantPayload
Denseweight (in, out), bias (1, out)
SimpleRNNkernel, recurrent_kernel, bias
LSTM / GRUfused kernel, recurrent_kernel, bias (gate blocks [i|f|g|o] / [z|r|h])
Conv1D / Conv2D / Conv3Dconvolution weight kernel and bias
SeparableConv2Ddepthwise weight kernel, pointwise weight kernel, and bias
DepthwiseConv2Ddepthwise weight kernel and bias (no pointwise kernel)
BatchNormalizationgamma, beta, running_mean, running_var
LayerNormalization / InstanceNormalization / GroupNormalizationgamma, beta only
Emptynothing (Dropout, pooling, flatten, pure activation layers)

This difference among the normalization layers is correct by design. BatchNormalization accumulates running statistics during training and uses them at inference. Those 2 arrays are part of the trained state, so they must survive serialization. If a reload dropped them, the model would normalize eval-mode inputs with default statistics and produce wrong output. Layer, instance, and group normalization compute their statistics from the current input on every forward pass, so they hold no running state. gamma and beta are all these layers need to save. 3.9.4 shows the BatchNormalization case in detail.

3.9.3. A complete round-trip

The full loop has 5 steps: build, train briefly, save, rebuild the same stack, and load. The last step confirms that the restored model predicts the same values. Note the make_arch function. It defines the architecture once, and both the live model and the reload target call it. This habit is the most useful practice for weights-only persistence, because it guarantees that the two stacks cannot drift apart.

use ndarray::Array;
use rustyml::error::Error;
use rustyml::neural_network::Tensor;
use rustyml::neural_network::layers::activation::linear::Linear;
use rustyml::neural_network::layers::dense::Dense;
use rustyml::neural_network::losses::MeanSquaredError;
use rustyml::neural_network::optimizers::SGD;
use rustyml::neural_network::sequential::Sequential;

// Define the architecture once. Reuse it for the live model and the reload target.
fn make_arch() -> Sequential {
    let mut m = Sequential::new();
    m.add(Dense::new(4, 3, Linear::new()).unwrap())
        .add(Dense::new(3, 2, Linear::new()).unwrap());
    m
}

fn main() -> Result<(), Error> {
    let x: Tensor = Array::from_shape_vec((2, 4), vec![0.1f32, 0.2, 0.3, 0.4, -0.1, -0.2, -0.3, -0.4])
        .unwrap()
        .into_dyn();
    let y: Tensor = Array::from_shape_vec((2, 2), vec![1.0f32, 0.0, 0.0, 1.0])
        .unwrap()
        .into_dyn();

    let mut model = make_arch();
    model.compile(SGD::new(0.01, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());
    model.fit(&x, &y, 5)?;
    let before = model.predict(&x)?;

    // Save weights, then rebuild the identical stack and load into it.
    let path = "roundtrip_demo.bin";
    model.save_to_path(path)?;

    let mut restored = make_arch();
    restored.load_from_path(path)?;
    let after = restored.predict(&x)?;

    // The two prediction tensors must agree element-wise.
    let max_diff = (&after - &before)
        .mapv(f32::abs)
        .iter()
        .cloned()
        .fold(0.0f32, f32::max);
    println!("max abs difference after round-trip: {max_diff:e}");
    assert!(max_diff < 1e-6);

    std::fs::remove_file(path).unwrap();
    Ok(())
}

The round-trip is exact, not approximate. postcard stores each f32 losslessly, and load_from_path writes the arrays straight into the layers, so the reloaded model runs the same computation, bit-for-bit. The 1e-6 tolerance in the example is defensive slack, not a hedge against drift.

3.9.4. Normalization running statistics survive the round-trip

BatchNormalization’s inference path reads running_mean and running_var. This test trains those statistics away from their defaults, saves the model, and reloads the weights into a fresh, untrained model. It then checks that eval-mode predictions still match. They do. The running arrays travel with the BatchNormalization variant.

use ndarray::Array;
use rustyml::neural_network::Tensor;
use rustyml::neural_network::layers::regularization::normalization::batch_normalization::BatchNormalization;
use rustyml::neural_network::losses::MeanSquaredError;
use rustyml::neural_network::optimizers::SGD;
use rustyml::neural_network::sequential::Sequential;

fn make_arch() -> Sequential {
    let mut m = Sequential::new();
    m.add(BatchNormalization::new(vec![4, 3], 0.9, 1e-5).unwrap());
    m
}

fn main() {
    let x: Tensor = Array::from_shape_vec(
        (4, 3),
        vec![0.5f32, -1.0, 2.0, 1.5, 0.2, -0.7, -1.2, 0.8, 1.1, 0.3, -0.4, 0.9],
    )
    .unwrap()
    .into_dyn();

    // Train so running_mean / running_var move away from their initial values.
    let mut model = make_arch();
    model.compile(SGD::new(0.001, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());
    model.fit(&x, &x, 8).unwrap();
    let before = model.predict(&x).unwrap(); // eval mode uses the running stats

    let path = "batchnorm_demo.bin";
    model.save_to_path(path).unwrap();

    let mut restored = make_arch(); // fresh: running stats at their defaults
    restored.load_from_path(path).unwrap();
    let after = restored.predict(&x).unwrap();

    let max_diff = (&after - &before)
        .mapv(f32::abs)
        .iter()
        .cloned()
        .fold(0.0f32, f32::max);
    println!("running-stat round-trip max abs difference: {max_diff:e}");
    assert!(max_diff < 1e-6);

    std::fs::remove_file(path).unwrap();
}

If the file did not save the running statistics, after would differ sharply from before. The fresh model’s defaults do not match the 8 epochs of accumulated batch statistics.

3.9.5. What is not saved, and why it bites

The optimizer, its accumulated state, the loss function, and the entire compile configuration stay behind. This is the biggest difference from Keras. Keras’ default save format bundles the optimizer, so fit can resume training without a gap. In RustyML, loading gives you weights on a blank model. The optimizer and loss fields are None, so 2 consequences follow.

First, you must call compile again before the model can fit or train. Prediction works right away, because predict needs no optimizer. Second, resuming training after a load restarts the optimizer from zero. This point is easy to miss. Adam’s first-moment and second-moment estimates reset to zero. SGD’s momentum buffer resets too. Adam’s bias-correction timestep starts over at 1. For a few fine-tuning steps, this reset causes no harm. For a long training run split across a save and load, the first post-load steps take larger, less-damped updates than an uninterrupted run would take. The loss curve shows a temporary bump. This bump is a persistence artifact, not a data problem. To pause and resume long training without this discontinuity, keep the process alive instead of saving to disk and reloading. RustyML has no API to serialize optimizer moments. The fit-time shuffle seed is also not saved. Re-set it with set_seed after loading, if you want the resumed shuffle to be reproducible.

3.9.6. Error variants on load

Every failure surfaces as Error::Io(...) (see 1.6. Error Handling). There are exactly 4 variants, and each one maps to a distinct cause of failure:

ErrorCause
IoError::StdThe file could not be opened/read (missing path, permissions) or written
IoError::UnsupportedModelFormatThe file does not carry this build’s magic tag and format version. It is not a RustyML model, or a release with a different on-disk weight layout wrote it (see 3.9.8)
IoError::SerializationThe bytes are not valid postcard for the expected schema (corruption or truncation after a well-formed header)
IoError::ModelStructureMismatchThe model you built does not match the file. The cause is a wrong layer count, a wrong layer type at some position, or a weight shape that disagrees with the target layer

The load path checks the header first, so these 4 errors follow an order. A file that is not a model at all never reaches the postcard decoder. A file from an incompatible release never reaches the structural checks.

ModelStructureMismatch is the error you hit most often while you develop a model. RustyML raises it in 3 places: a layer-count check, a per-position type-name check, and a shape check during weight application. This third case is less obvious. A shape disagreement from a layer’s set_weights call gets wrapped into the same variant. For example, a Dense::new(2, 2, ...) file loaded into a Dense::new(3, 3, ...) target passes the count and type checks. It then fails on shape, still as ModelStructureMismatch. The message string tells you which check failed. Matching the error is straightforward:

use rustyml::error::{Error, IoError};
use rustyml::neural_network::layers::activation::linear::Linear;
use rustyml::neural_network::layers::dense::Dense;
use rustyml::neural_network::sequential::Sequential;

fn main() {
    // Save a 1-layer model.
    let mut saved = Sequential::new();
    saved.add(Dense::new(2, 2, Linear::new()).unwrap());
    let path = "mismatch_demo.bin";
    saved.save_to_path(path).unwrap();

    // Rebuild with the wrong layer count and try to load it.
    let mut wrong = Sequential::new();
    wrong
        .add(Dense::new(2, 2, Linear::new()).unwrap())
        .add(Dense::new(2, 2, Linear::new()).unwrap());

    match wrong.load_from_path(path) {
        Err(Error::Io(IoError::ModelStructureMismatch(msg))) => {
            println!("rejected as expected: {msg}");
        }
        Err(other) => panic!("unexpected error: {other:?}"),
        Ok(()) => panic!("load must not succeed on a structure mismatch"),
    }

    std::fs::remove_file(path).unwrap();
}

The type-name check compares strings. It catches common mistakes: a Conv layer where the file has a Dense layer, or a missing or extra layer. It does not catch a hyperparameter change that leaves the type name and weight shapes unchanged. Two Dense layers with the same shapes but different activations load without complaint, because the activation is not on disk. This gap is the direct cost of storing metadata tags instead of a full architecture, and the reason the make_arch function discipline matters.

3.9.7. The postcard format: size, speed, portability

postcard is a compact, non-self-describing binary format. Non-self-describing means the stream carries no field names. It writes only the values, in declaration order. This design keeps the files small. It also ties each file to the exact struct and enum layout of the crate version that wrote it. File size is easy to predict. Each parameter is an f32, 4 bytes. A small fixed overhead adds the array dimensions (varint-encoded), the enum discriminants (1 byte each for these small enums), and the short metadata strings. The Total params: N (N*4 B) line that Sequential::summary prints is therefore a close upper estimate of the file size. A 100,000-parameter model takes about 400 KB. Serialization runs as a single linear pass with one buffered write, so I/O time, not CPU time, dominates the cost.

The format is portable across machines. postcard defines its own byte order instead of dumping native-endian memory. So a checkpoint written on one architecture deserializes correctly on another, regardless of the host’s endianness. This portability needs no conversion step and no per-platform variant. The format is not portable across crate versions. That limit is the subject of the next section.

3.9.8. Workflow recipes

Checkpoint the best model. Train in short rounds. Score the model with evaluate after each round, and overwrite a single file whenever the score improves. save_to_path truncates the file, so it always holds the best weights found so far. The final in-memory model may have overfit past the best point, so discard it and use the reload instead. Score with evaluate, not with the last entry of the History that fit returns. A history entry is the loss measured during the epoch, on a forward pass taken before that epoch’s own weight update. So the last entry describes weights the model no longer holds, and a rule based on it would checkpoint a round late. evaluate runs one inference-mode forward pass over the data and scores it with the compiled loss. It updates nothing: no gradients, no parameters, and no BatchNormalization running statistics. A selection rule built on evaluate therefore cannot change the training it measures.

use ndarray::Array;
use rustyml::error::Error;
use rustyml::neural_network::Tensor;
use rustyml::neural_network::layers::activation::linear::Linear;
use rustyml::neural_network::layers::dense::Dense;
use rustyml::neural_network::losses::MeanSquaredError;
use rustyml::neural_network::optimizers::SGD;
use rustyml::neural_network::sequential::Sequential;

fn make_arch() -> Sequential {
    let mut m = Sequential::new();
    m.add(Dense::new(4, 3, Linear::new()).unwrap())
        .add(Dense::new(3, 2, Linear::new()).unwrap());
    m
}

fn main() -> Result<(), Error> {
    let x: Tensor = Array::from_shape_vec((2, 4), vec![0.1f32, 0.2, 0.3, 0.4, -0.1, -0.2, -0.3, -0.4])
        .unwrap()
        .into_dyn();
    let y: Tensor = Array::from_shape_vec((2, 2), vec![1.0f32, 0.0, 0.0, 1.0])
        .unwrap()
        .into_dyn();

    let mut model = make_arch();
    model.compile(SGD::new(0.05, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());

    let path = "best.bin";
    let mut best = f32::INFINITY;
    for round in 0..10 {
        model.fit(&x, &y, 2)?;
        let val = model.evaluate(&x, &y)?;
        if val < best {
            best = val;
            model.save_to_path(path)?; // overwrites the previous checkpoint
            println!("round {round}: new best {val:.6}, checkpoint written");
        }
    }

    // best.bin holds the lowest-loss weights, not necessarily the final ones.
    let mut deployed = make_arch();
    deployed.load_from_path(path)?;
    // `evaluate` needs the compiled loss. It never touches the optimizer passed here.
    deployed.compile(SGD::new(0.05, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());
    assert_eq!(deployed.evaluate(&x, &y)?, best);

    std::fs::remove_file(path).unwrap();
    Ok(())
}

The final check uses exact equality, not a tolerance. The reloaded weights are bit-for-bit the same as the saved ones, and evaluate is deterministic on a model with no dropout. So the score of the restored checkpoint must equal the score that caused RustyML to write it.

Transfer weights between programs. A training binary builds the architecture, trains the model, and calls save_to_path. A separate serving binary builds the identical architecture and calls load_from_path. Share the make_arch function through a common module or crate, so the two programs cannot disagree on the architecture. The type-name and count checks catch a drift, but only after a failed load. A shared constructor catches the same drift at compile time. The serving binary never needs to call compile, since it only calls predict.

Manage versions across crate upgrades. Before the file header existed, a stale checkpoint could fail silently. The file header now prevents this. Every saved model opens with a magic tag and a format version, and load_from_path validates both before it decodes anything else. So a checkpoint written by a release with a different weight layout fails right away with IoError::UnsupportedModelFormat. Without this check, the load could parse the file into arrays that look correct but hold wrong values. The structural checks that run after the header compare layer counts, type names, and weight extents. A stale file can satisfy all 3 checks by coincidence. For example, a square convolution kernel keeps its extents when its axes are permuted. A Dense weight shape stays the same no matter what tensor layout produced the input feeding it.

This guard depends on developer discipline. RustyML bumps the version whenever a weight container’s tensor layout, rank, or field order changes. A change that gets a version bump is caught. A change that someone forgot to bump is not caught, because postcard is still non-self-describing and nothing else in the file is labeled. For a checkpoint you need to reload weeks or versions later, pin the RustyML version in Cargo.toml. After a deliberate upgrade, re-run training, or load the file under the old version and re-save it under the new one. Do not trust an old file against new code. The header makes a version failure loud and immediate instead of silent. For more detail on the persistence machinery, see 7.2. Model Persistence in Depth. That section covers the get_weights inspection path, the SerializableSequential wrapper, and how RustyML applies weights back through downcasting.

4. Data Preprocessing

Raw data almost never arrives in the shape a model wants. Feature columns can span very different scales. Class labels can come as strings or as non-consecutive integers. You also need an honest held-out set before you fit anything. This chapter covers the preprocessing helpers in rustyml::utils: splitting, standardizing, normalizing, and label encoding. They all live behind the utils feature flag, and operate directly on the ndarray arrays your models already consume.

If you come from scikit-learn, note this module’s shape. Most of it is plain functions, not stateful fit/transform estimators. Each call computes its statistics from the array you pass it, and returns a fresh array. The exceptions are the scalers: StandardScaler, MinMaxScaler, MaxAbsScaler, RobustScaler, and Normalizer. These do carry the fit/transform contract across a train/test boundary. Each stores what it learned from the training matrix, so later batches go through the identical map. Either way, the ordering discipline is yours. Split your data first, then fit the scaling. This keeps anything derived from test rows out of training. Every function returns Result<_, Error> and validates its input first. Each function rejects an empty dataset, a shape mismatch, or a non-finite value, where that check applies. Read Error Handling if you have not.

Read the sections in pipeline order. Split first (4.1). Scale the training features next (4.2). Then encode labels, if your model or loss needs one-hot targets (4.3). The example below runs that exact sequence:

use ndarray::{Array1, array};
use rustyml::utils::train_test_split::train_test_split;
use rustyml::utils::standardize::{StandardizationAxis, standardize};
use rustyml::utils::label_encoding::to_categorical;

fn main() {
    // Six samples, two features on very different scales, two classes.
    let x = array![
        [1.0, 20.0],
        [2.0, 21.0],
        [3.0, 19.0],
        [4.0, 22.0],
        [5.0, 18.0],
        [6.0, 23.0],
    ];
    let y: Array1<i32> = array![0, 1, 0, 1, 0, 1];

    // 1. Split first, so scaling never sees the test rows.
    let (x_train, _x_test, y_train, _y_test) =
        train_test_split(x, y, Some(0.34), Some(42)).unwrap();

    // 2. Standardize each feature column to zero mean, unit variance.
    let x_train = standardize(&x_train, StandardizationAxis::Column).unwrap();

    // 3. One-hot encode the integer labels for a categorical loss.
    let y_train = to_categorical(&y_train, None).unwrap();

    println!("x_train shape: {:?}", x_train.shape());
    println!("y_train shape: {:?}", y_train.shape());
}

4.1. Train-Test Split

Train-Test Split carves your feature matrix and labels into disjoint training and test subsets. This lets evaluation numbers reflect unseen data. train_test_split does a plain random partition. train_test_split_stratified splits each class independently, so both sides keep the same label proportions. Choose the stratified variant whenever a class is rare, since a plain split can drop that class from one side entirely. test_size defaults to 0.3. The label array is generic over its element type. Passing a random_state seed makes the shuffle reproducible (see Reproducibility and Random Seeds).

4.2. Standardization and Normalization

Standardization and Normalization are 2 different rescalings that people often confuse. standardize computes z-scores. It subtracts the mean, then divides by the standard deviation, so each feature ends up with zero mean and unit variance. normalize rescales each row or column to unit norm, under an L1, L2, Max, or custom Lp order.

Each operation also has a stateful form. A stateful form remembers its training statistics, so a test split or a single live sample gets scaled by the numbers the model trained under. The stateful forms are StandardScaler and Normalizer. MinMaxScaler (bounded range), MaxAbsScaler (magnitude only, keeping structural zeros), and RobustScaler (median and IQR, so outliers do not set the scale) join them.

Column standardization is the everyday choice before any distance-based or gradient-based model. KNN, SVM, PCA, and neural networks all misbehave when features live on different scales. Per-row unit-norm normalization instead suits direction-only vectors, such as TF-IDF rows compared by cosine similarity. Both operations handle a degenerate lane instead of dividing by a vanishing scale. standardize forces the divisor to 1.0, so a constant feature centers to zeros. normalize leaves a near-zero lane exactly as it is.

4.3. Label Encoding

Label Encoding converts between the integer labels a dataset ships with and the one-hot matrices a categorical loss expects. to_categorical turns a column of non-negative i32 labels into a one-hot matrix. to_categorical_with_mapping does the same for arbitrary hashable labels, strings included, and also returns the label-to-index map, so you can decode later. to_sparse_categorical runs the inverse. It reduces a one-hot or softmax-probability matrix to the per-row argmax, as i32 labels. Use to_sparse_categorical to turn a network’s softmax output back into predicted classes. If you know Keras, this trio matches its to_categorical. One-hot targets are exactly what Categorical Cross-Entropy consumes.

4.1. Train-Test Split

Before you fit a model, you split your data into a training set and a test set. The module rustyml::utils::train_test_split gives you 2 functions for this. train_test_split makes a plain random partition. train_test_split_stratified makes a partition that keeps each class in the same proportion on both sides. Both functions are deterministic under a fixed seed. Both take ownership of your arrays, so you can pass the returned partitions straight to a model. This page explains when to use each function, the exact contract each one follows, and the common mistakes people make.

4.1.1. Why hold out data at all

A model measured only on its own training data gives a false sense of quality. The model can memorize the training rows. Examples include a decision tree grown deep enough, a k-nearest-neighbors classifier with k = 1, and an over-parameterized network. Such a model reports near-perfect accuracy that collapses on new data. The metric that matters is generalization: performance on data from the same distribution that the model never saw during fitting. The only honest way to estimate generalization is to set some data aside before training and keep the fitting process away from it.

That is the entire job of a train/test split. The training set is what fit learns from. The test set stands in for future data. Use it exactly once, at the end, to score the finished model. If you tune anything against the test set, such as a hyperparameter, a threshold, or a feature choice, it stops being held out. It starts to leak into your model, and your reported score drifts back toward the optimistic training-set number. When you need to tune settings, carve out a third slice, a validation set, and keep the test set sealed. Section 4.1.7 shows this pattern.

One related leak is common enough to flag here. Any statistic you compute over the whole dataset before splitting has already seen the test rows. Examples include a feature mean and standard deviation for standardization, a min or max value for normalization, and a label vocabulary. Fit those transforms on the training partition only, then apply them to the test partition. Split first, transform second.

4.1.2. The train_test_split signature

pub fn train_test_split<A: Clone>(
    x: Array2<f64>,
    y: Array1<A>,
    test_size: Option<f64>,
    random_state: Option<u64>,
) -> Result<TrainTestSplit<A>, Error>;

pub fn train_test_split_stratified<A: Clone + Eq + Hash>(
    x: Array2<f64>,
    y: Array1<A>,
    test_size: Option<f64>,
    random_state: Option<u64>,
) -> Result<TrainTestSplit<A>, Error>;

pub type TrainTestSplit<A> = (Array2<f64>, Array2<f64>, Array1<A>, Array1<A>);
ParameterTypeMeaning
xArray2<f64>Feature matrix, shape (n_samples, n_features), taken by value
yArray1<A>Labels, length n_samples. The element type A is generic
test_sizeOption<f64>Fraction of samples for the test set. None means 0.3
random_stateOption<u64>Seed for the shuffle. None defers to the global seed or entropy

The label type is generic. Plain train_test_split requires only A: Clone, so i32, usize, f64, and &str labels all work. The crate tests exercise i32 and &str labels directly. Stratification groups rows by class, so it tightens the bound to A: Clone + Eq + Hash. Integers and string slices satisfy that bound. Raw f64 labels do not, because floats do not implement Eq or Hash. This is a good reason to encode class labels as integers before you stratify. See label encoding.

The return value is a 4-tuple in the order (x_train, x_test, y_train, y_test). Both feature matrices come first, then both label vectors. This matches the order of scikit-learn’s X_train, X_test, y_train, y_test, so code ported from Python keeps the same order. Note 3 differences from scikit-learn. The default test_size here is 0.3, not scikit-learn’s 0.25. There is no shuffle flag, because the split always shuffles. For time-series data that needs contiguous, order-preserving slices, do not use this function. Slice the arrays yourself instead. Stratification is a separate function, not a stratify= argument.

You can reach these functions 3 ways. Use the fully qualified path rustyml::utils::train_test_split::train_test_split. Use the flattened path rustyml::utils::{train_test_split, train_test_split_stratified}. Or use the prelude with use rustyml::prelude::*;. The examples on this page use the fully qualified module path.

4.1.3. A basic split

use ndarray::{Array1, Array2};
use rustyml::utils::train_test_split::train_test_split;

fn main() {
    let x = Array2::from_shape_vec(
        (10, 2),
        vec![
            0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0,
            15.0, 16.0, 17.0, 18.0, 19.0,
        ],
    )
    .unwrap();
    let y = Array1::from(vec![0, 1, 0, 1, 0, 1, 0, 1, 0, 1]);

    let (x_train, x_test, y_train, y_test) =
        train_test_split(x, y, Some(0.3), Some(42)).unwrap();

    // round(10 * 0.3) = 3 test rows, the remaining 7 are training rows.
    assert_eq!(x_train.nrows(), 7);
    assert_eq!(x_test.nrows(), 3);
    assert_eq!(y_train.len(), 7);
    assert_eq!(y_test.len(), 3);
    println!("train {} / test {}", x_train.nrows(), x_test.nrows());
}

The test set size is round(n_samples * test_size). For 10 samples at 0.3, this gives 3 test rows and 7 training rows. Rows stay aligned: x[i] and y[i] always land in the same partition. Every output row matches exactly one input row. No row is duplicated across the two sides, and no row is dropped. So x_train.nrows() + x_test.nrows() always equals n_samples.

x comes back as Array2<f64> and y comes back as Array1<A>. The partitions feed directly into a model’s fit and predict methods. Most estimators take the feature matrix and labels by reference.

model.fit(&x_train, &y_train)?;
let predictions = model.predict(&x_test)?;
// score `predictions` against `y_test` with a metric from Chapter 5.

See your first end-to-end model for a complete pipeline and classification metrics for scoring the held-out predictions.

4.1.4. Why shuffling matters

Real datasets are rarely stored in random order. Exports are often sorted by label, by timestamp, or by collection batch. Suppose you took the first 70% of an iris-style file as training data. You might train on 2 species and test on a third species the model never saw. That is a guaranteed failure, and it says nothing about the model. Shuffling before slicing breaks this structure, so both partitions become representative samples of the same distribution.

train_test_split always shuffles the row indices before it splits. You never need to pre-sort your data. You cannot turn shuffling off. The only setting you control is the seed. This makes the function unsuitable for problems where order carries meaning. One example is forecasting, where the test set must come strictly after the training set in time. For those problems, slice the arrays by hand instead of using this function.

4.1.5. Stratification and imbalanced classes

Random shuffling gives each row an equal chance of landing in the test set. It does not guarantee that a rare class appears on both sides. Consider a fraud-detection dataset with a negative-to-positive ratio of 8 to 1. A plain split can, by chance, put every positive example into training and leave the test set with none. The test set then cannot measure fraud detection at all, because it holds no positive examples to score against. The smaller the minority class is relative to test_size, the more likely this outcome becomes.

train_test_split_stratified fixes this by splitting each class independently. It groups the row indices by label, in first-appearance order, so the result stays deterministic for a given seed. It shuffles within each group and applies test_size to each group separately. It clamps the result so every class keeps at least 1 sample on each side. This preserves the per-class proportions of the input in both partitions.

use ndarray::{Array1, Array2};
use rustyml::utils::train_test_split::train_test_split_stratified;

fn main() {
    // 8 samples of class 0 and 2 samples of class 1 (a 4:1 imbalance).
    let x = Array2::from_shape_fn((10, 1), |(i, _)| i as f64);
    let mut labels = vec![0i32; 8];
    labels.extend(vec![1i32; 2]);
    let y = Array1::from(labels);

    let (_x_train, _x_test, y_train, y_test) =
        train_test_split_stratified(x, y, Some(0.3), Some(42)).unwrap();

    let count = |a: &Array1<i32>, c: i32| a.iter().filter(|&&l| l == c).count();

    // The minority class survives on both sides. This is guaranteed, not luck.
    assert!(count(&y_train, 1) >= 1, "class 1 must remain in train");
    assert!(count(&y_test, 1) >= 1, "class 1 must remain in test");
    println!(
        "test set: {} of class 0, {} of class 1",
        count(&y_test, 0),
        count(&y_test, 1)
    );
}

Here, the majority class contributes round(8 * 0.3) = 2 test rows. The minority class contributes round(2 * 0.3) = 1 test row, which the clamp also holds inside the allowed range of [1, class_size - 1]. Both classes appear on both sides, every time, for every seed. On a balanced dataset, stratification simply reproduces the requested ratio for each class. For example, 6 samples per class at test_size = 0.5 gives 3 test rows and 3 train rows for each class.

One structural difference matters here. Stratification concatenates each class’s test slice and each class’s train slice in turn. So the returned rows are ordered in class blocks: all of class 0, then all of class 1, and so on. This differs from the global shuffle that plain train_test_split produces. Within a class, the order is shuffled, but the classes themselves are not interleaved. This block order has no effect on an estimator that shuffles internally, such as the Sequential network, which shuffles minibatches each epoch. If you feed the labels to an order-sensitive process that does not reshuffle, keep the class blocks in mind.

Use stratification when the label is categorical and the classes are uneven. For a balanced regression target or roughly even classes, the plain split works well and is simpler to use.

4.1.6. Reproducible splits: random_state vs the global seed

A stable test set matters more than it might seem. If the split changes on every run, your reported accuracy varies for reasons that have nothing to do with the model. You can no longer tell a real improvement from split noise. Fix the seed.

The direct control is random_state. Passing Some(seed) makes the shuffle fully reproducible and independent of everything else. An explicit seed uses exactly that value and never touches the crate’s global seed stream.

use ndarray::{Array1, Array2};
use rustyml::utils::train_test_split::train_test_split;

fn main() {
    let x = Array2::from_shape_fn((20, 3), |(i, j)| (i + j) as f64);
    let y = Array1::from_iter(0..20i32);

    let a = train_test_split(x.clone(), y.clone(), Some(0.25), Some(42)).unwrap();
    let b = train_test_split(x, y, Some(0.25), Some(42)).unwrap();

    // Same seed -> byte-identical partitions.
    assert_eq!(a.0, b.0); // x_train
    assert_eq!(a.1, b.1); // x_test
    assert_eq!(a.2, b.2); // y_train
    assert_eq!(a.3, b.3); // y_test
    println!("reproduced a split of {} training rows", a.0.nrows());
}

If random_state is None, the split falls back to the thread-local global seed set by rustyml::set_global_seed. Reproducibility and random seeds covers the full rules. One edge case catches people specifically with splits:

use ndarray::{Array1, Array2};
use rustyml::set_global_seed;
use rustyml::utils::train_test_split::train_test_split;

fn main() {
    // Fix the whole run's randomness up front.
    set_global_seed(123);

    let x = Array2::from_shape_fn((12, 2), |(i, j)| (i + j) as f64);
    let y = Array1::from_iter(0..12i32);

    // random_state = None derives its seed from the global stream set above.
    let (x_train, x_test, _, _) = train_test_split(x, y, None, None).unwrap();
    println!("train {} / test {}", x_train.nrows(), x_test.nrows());
}

A single set_global_seed call makes the whole program run reproducible, as long as you construct the randomized components in the same order each run. Each unseeded consumer draws a fresh sub-seed from the global stream, in construction order. The global stream advances on every draw. If you call train_test_split(.., None, None) twice under one set_global_seed, the two calls receive different sub-seeds. So the two calls produce different splits. To get the same split every time, seed it explicitly with Some(seed). This is the recommended approach for anything you re-run and compare. Alternatively, reset the global seed before the call. An explicit Some seed never consumes the global stream. So you can safely pin your split with Some(42) and still let your model draw its weights from a program-wide set_global_seed. Neither call disturbs the other.

4.1.7. Train / validation / test with two splits

There is no dedicated 3-way split function. You build one by calling train_test_split twice. First, peel off the sealed test set. Then split what remains into training and validation sets. Each call takes its arrays by value and returns owned arrays, so the remainder flows straight into the second call with no cloning.

use ndarray::{Array1, Array2};
use rustyml::utils::train_test_split::train_test_split;

fn main() {
    let x = Array2::from_shape_fn((20, 2), |(i, j)| (i + j) as f64);
    let y = Array1::from_iter(0..20i32);

    // 1) Peel off the test set: 20% of 20 -> 4 rows, 16 remain.
    let (x_rest, x_test, y_rest, y_test) =
        train_test_split(x, y, Some(0.2), Some(42)).unwrap();

    // 2) Split the remaining 16 into train and validation: 25% -> 4 val, 12 train.
    let (x_train, x_val, y_train, y_val) =
        train_test_split(x_rest, y_rest, Some(0.25), Some(7)).unwrap();

    assert_eq!(x_train.nrows(), 12);
    assert_eq!(x_val.nrows(), 4);
    assert_eq!(x_test.nrows(), 4);
    println!(
        "train {} / val {} / test {}",
        y_train.len(),
        y_val.len(),
        y_test.len()
    );
}

Watch the arithmetic here. The second test_size is a fraction of the remaining rows, not of the original count. Peeling off 20% and then taking 25% of the rest gives a 60/20/20 split of the whole dataset, not 55/25/20. Seed both calls, with the same value or different values, since the 2 calls are independent. This makes the 3-way partition reproducible end to end. When the target is a categorical class, use train_test_split_stratified for both stages. This keeps the class balance intact through both cuts.

4.1.8. Edge cases and error handling

Both functions validate their inputs first. Both return rustyml::error::Error, so failures are typed values you can match on, rather than panics. See error handling. The table below lists every failure case.

ConditionError variantNotes
n_samples == 0Error::EmptyInputPayload "dataset"
x.nrows() != y.len()Error::DimensionMismatch { expected, found }expected is the row count, found the label count
test_size <= 0.0 or >= 1.0Error::InvalidParameter { name, reason }name is "test_size". Both bounds are exclusive
n_samples == 1 (plain split)Error::InvalidInputCannot form both a train and a test set from one row
A class with fewer than 2 samples (stratified)Error::InvalidInputEvery class must land on both sides

The test_size bounds are strictly exclusive. 0.0 and 1.0 are both rejected, and so are negative values and values above 1.0. Either endpoint would leave one partition empty. Inside the valid range, the computed test count is clamped to [1, n_samples - 1]. So no legal test_size ever produces an empty side. For example, with 10 samples, test_size = 0.99 rounds to 10 and clamps to 9 test rows, leaving 1 row for training. With test_size = 0.01, the count rounds to 0 and clamps up to 1 test row. A dataset of 2 samples is a special case, handled before any rounding. That split is always 1 train row and 1 test row, regardless of test_size.

Stratification adds 1 hard requirement: every class needs at least 2 samples, 1 for each side. A singleton class raises Error::InvalidInput. It is not silently dropped. scikit-learn raises the same kind of failure for its least-populated class. The plain split has no such rule, so a class can legally appear on only 1 side. That is exactly the risk stratification removes.

use ndarray::{Array1, Array2};
use rustyml::error::Error;
use rustyml::utils::train_test_split::{train_test_split, train_test_split_stratified};

fn main() {
    // test_size must lie strictly inside (0, 1).
    let x = Array2::from_shape_fn((5, 2), |(i, j)| (i + j) as f64);
    let y = Array1::from_iter(0..5i32);
    match train_test_split(x, y, Some(1.0), Some(42)) {
        Err(Error::InvalidParameter { name, .. }) => {
            assert_eq!(name, "test_size");
            println!("rejected test_size = 1.0 on parameter `{name}`");
        }
        other => panic!("expected InvalidParameter, got {other:?}"),
    }

    // A stratified split needs >= 2 samples in every class.
    let x2 = Array2::from_shape_fn((5, 1), |(i, _)| i as f64);
    let y2 = Array1::from(vec![0i32, 0, 1, 1, 2]); // class 2 is a singleton
    match train_test_split_stratified(x2, y2, Some(0.3), Some(42)) {
        Err(Error::InvalidInput(msg)) => {
            println!("stratified split rejected a singleton class: {msg}");
        }
        other => panic!("expected InvalidInput, got {other:?}"),
    }
}

Error is #[non_exhaustive], so a match over it needs a trailing arm. Here, the other => arm also checks that the expected variant came back. In production code, return the error with ? instead of using panic. This lets the error propagate up to the code that handles failures for the whole pipeline.

4.2. Standardization and Normalization

RustyML provides 2 operations that make numbers comparable in different ways. Standardization recenters and rescales each feature so it has zero mean and unit variance (a z-score). Normalization rescales each sample so its vector norm equals 1. The crate exposes both as stateless free functions, rustyml::utils::standardize and rustyml::utils::normalize.

Each call to these functions computes its statistics from the array you hand it, and returns a new array. The function stores no training mean between calls. A user who knows scikit-learn should note this difference before using them on a test split.

RustyML also provides stateful counterparts in rustyml::utils: StandardScaler, MinMaxScaler, MaxAbsScaler, RobustScaler, and Normalizer. These are fit/transform objects. They store what they learn from the training matrix, and reapply it to every later batch.

The choice between the free function and a scaler depends on whether a second batch needs the same scaling. If it does, for example a test split, a validation fold, or a single sample at inference, use a scaler. Section 4.2.5 covers this in full.

Read 4.1. Train-Test Split first. The leakage trap on this page applies only relative to a split.

4.2.1. Standardize and normalize are different operations

The 2 functions answer different questions. They are almost never interchangeable. A min-max scaler that squashes features into [0, 1] is a third, separate thing. RustyML provides it as MinMaxScaler among the stateful scalers, not here. normalize scales by a vector norm (L1, L2, Max, or Lp), not by feature range. standardize produces z-scores.

standardizenormalize
What it makes uniformeach feature’s mean and varianceeach sample’s length (norm)
Typical axisColumn (per feature)Row (per sample)
Output guaranteecolumn mean 0, population variance 1lane norm equals 1
Couples across samples?Yes. A column’s mean and standard deviation depend on every row.Depends on the axis. Row: no. Column or Global: yes.
scikit-learn analogueStandardScaler, or the stateless standardizeNormalizer, or the stateless normalize
Divisor for a degenerate lane1.0 (a constant column becomes zeros)1.0 (a near-zero lane stays as is)

The “couples across samples” row decides whether a leakage problem can occur at all. Section 4.2.5 returns to this point.

4.2.2. Standardization (z-score)

standardize is a single generic free function. It works on an f64 array of any dimension:

pub fn standardize<S, D>(
    data: &ArrayBase<S, D>,
    axis: StandardizationAxis,
) -> Result<Array<f64, D>, Error>
where
    S: Data<Elem = f64>,
    D: Dimension;

The function borrows the input. It returns a new owned array of the same shape. The original array is never mutated.

StandardizationAxis has 3 variants. The mapping to physical axes is the reverse of what “row” and “column” suggest at first glance:

  • Column standardizes along Axis(0). Each lane is one column, so this gives per-feature z-scores. Use this for the usual layout, where samples are rows and features are columns.
  • Row standardizes along the last axis. Each lane is one row. Use this when a sample is itself a signal, and its internal scale needs normalizing. This is rarely what tabular features need.
  • Global flattens the whole array. It standardizes the flattened array as one dataset.

For an N-D array with N greater than 2, Row operates on the last axis and Column operates on the second-to-last axis. Row and Column fail on a 1-D array, because it has only one axis. This returns Error::InvalidInput. Global works on any rank, including 1-D.

The divisor is the population standard deviation: sqrt(variance), with variance divided by n, not n - 1. This matches scikit-learn’s StandardScaler, which also uses ddof=0. Z-scores from RustyML match a ported scikit-learn pipeline.

The mean and variance come from a single numerically stable Welford pass. Above an internal size gate, the pass merges in parallel. Results stay the same across runs on the same machine. RustyML does not guarantee a bit-for-bit match across machines or thread counts (see 7.3. Performance Tuning and Parallelism).

use ndarray::{array, Axis};
use rustyml::utils::standardize::{standardize, StandardizationAxis};

fn main() {
    // Rows are samples. Columns are features on different scales.
    let x = array![
        [1.0, 2000.0],
        [2.0, 3000.0],
        [3.0, 4000.0],
        [4.0, 5000.0],
    ];

    // Standardize each feature (column) to zero mean, unit variance.
    let z = standardize(&x, StandardizationAxis::Column).unwrap();
    println!("shape: {:?}", z.shape()); // [4, 2]

    // Every column now has a population mean near 0 and a population variance near 1.
    for (j, col) in z.axis_iter(Axis(1)).enumerate() {
        let n = col.len() as f64;
        let mean = col.sum() / n;
        let var = col.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
        println!("feature {j}: mean={mean:.3e} var={var:.3}");
    }
}

4.2.3. Normalization (unit norm)

normalize takes an axis and a norm order. It divides each lane by its norm, so the lane’s norm becomes 1:

pub fn normalize<S, D>(
    data: &ArrayBase<S, D>,
    axis: NormalizationAxis,
    order: NormalizationOrder,
) -> Result<Array<f64, D>, Error>
where
    S: Data<Elem = f64>,
    D: Dimension;

NormalizationAxis mirrors the standardization variants. Row is the last axis. Column is the second-to-last axis. Global flattens the whole array. The same 1-D restriction applies to Row and Column. NormalizationOrder picks which norm to use:

VariantNormA lane [3, 4] becomes
L1sum of absolute values[3/7, 4/7]
L2Euclidean (sqrt of sum of squares)[0.6, 0.8]
Maxlargest absolute value (infinity norm)[0.75, 1.0]
Lp(p)(sum |x|^p)^(1/p), custom pdepends on p

Lp(1.0) and Lp(2.0) equal L1 and L2 exactly. The dedicated variants exist because they avoid the powf call. Dividing by a positive norm never flips a value’s sign, so [-3, 4] under L2 becomes [-0.6, 0.8].

The most common use is per-sample L2 normalization (Row, L2). It projects each sample onto the unit sphere, so only the sample’s direction matters, not its magnitude. This is what a cosine-similarity or dot-product model needs.

The row-wise case also has an object form, Normalizer, covered in section 4.2.5. It performs the same arithmetic, wrapped in the estimator contract.

use ndarray::array;
use rustyml::utils::normalize::{normalize, NormalizationAxis, NormalizationOrder};

fn main() {
    let x = array![[3.0, 4.0], [1.0, 2.0]];

    // Scale each row (sample) to unit L2 length. Row 0 becomes [0.6, 0.8].
    let l2 = normalize(&x, NormalizationAxis::Row, NormalizationOrder::L2).unwrap();
    for row in l2.rows() {
        let norm = row.iter().map(|v| v * v).sum::<f64>().sqrt();
        println!("row L2 norm = {norm:.6}");
    }

    // Same data under the Max (infinity) norm. Each row's largest absolute value becomes 1.
    let mx = normalize(&x, NormalizationAxis::Row, NormalizationOrder::Max).unwrap();
    println!("Max-normalized: {mx:?}");

    // A custom Lp order. p must be positive and finite, or the call returns Error::InvalidParameter.
    let lp = normalize(&x, NormalizationAxis::Row, NormalizationOrder::Lp(3.0)).unwrap();
    println!("Lp(3)-normalized: {lp:?}");
}

4.2.4. Which models need scaling

Scaling does not help every model. Whether it matters depends on how the model measures the data. Model families fall into 3 groups.

Model familyMembersScaling matters?Why
Distance-basedKNN, KMeans, DBSCAN, Mean Shift, SVC with RBFCriticalThe highest-variance feature dominates Euclidean distance. A feature measured in thousands drowns one measured in units.
Gradient-basedLinear Regression, Logistic Regression, neural networksImportantDifferent feature scales stretch the loss surface into a long, narrow valley. Gradient descent oscillates and needs a small learning rate to stay stable.
Variance-basedPCA, Kernel PCA, t-SNEImportantComponents chase the axes of largest variance. An unscaled feature can define the first component only because its units are larger.
Tree-basedDecision Trees, Isolation ForestNot neededSplits are thresholds on one feature at a time. Any monotonic rescaling produces the same tree, so scaling wastes effort.

The distance-based row is the clearest case. See 6.1. Distance Metrics for background. These models sum squared per-feature differences, so a feature’s contribution scales with its variance. Standardize the columns, and every feature enters the distance on equal footing.

For the gradient-based family, standardization is the cheapest way to let a neural network train at a stable learning rate. Trees need no scaling at all. Standardizing before fitting a DecisionTree changes nothing about the result.

standardize (per-feature) and normalize (per-sample) are not substitutes for each other. Distance-based and gradient-based models almost always need column standardization. Row normalization fits a different case: sample magnitude is noise, and only direction carries signal. Text term-frequency vectors are a common example.

4.2.5. Reusing training statistics: the scaler family

The free function invites one particular mistake. Every call to standardize recomputes the statistics from the array you hand it. This has 2 distinct consequences. Conflating them is where mistakes happen.

First, the leakage trap. Calling standardize(&x_full, Column) on the whole dataset before splitting computes the per-feature mean and variance over rows that later land in the test set. The training transform absorbs information about the test distribution. The validation score becomes optimistic. The fix is a matter of order: split first, then scale.

Second, inconsistent transforms. Even after splitting first, calling standardize(&x_test, Column) scales the test set by the test set’s own column statistics. This is a different linear map than the one applied to the training data. A model trained on train-scaled features then sees test features scaled by different numbers. This degrades every distance-based and gradient-based model, without any error to flag it. It is also impossible at real inference time, because a single incoming sample has no meaningful per-column standard deviation of its own.

The same discipline solves both problems. Compute the statistics once, on the training matrix only, and apply those frozen numbers to every later batch. StandardScaler holds these numbers for you, with the same fit/transform contract as scikit-learn’s:

use rustyml::utils::StandardScaler;

let mut scaler = StandardScaler::new();
scaler.fit(&x_train)?;                       // Learn mean and std from training rows only.
let x_train_z = scaler.transform(&x_train)?; // Or call fit_transform(&x_train) in one step.
let x_test_z = scaler.transform(&x_test)?;   // Same numbers, applied to the test split.

fit and transform split the responsibilities. fit is the only method that looks at the data statistically. transform only applies what fit stored.

Once fitted, the scaler is a fixed linear map. Hand it one row, a thousand rows, or the training matrix again. Every value passes through the same (x - mean) / scale. You are responsible for the order: split before you fit, and never fit again on anything but training data.

use ndarray::{array, Array1};
use rustyml::utils::StandardScaler;
use rustyml::utils::train_test_split::train_test_split;

fn main() {
    let x = array![
        [1.0, 100.0],
        [2.0, 150.0],
        [3.0, 200.0],
        [4.0, 250.0],
        [5.0, 300.0],
        [6.0, 350.0],
    ];
    let y = Array1::from(vec![0, 1, 0, 1, 0, 1]);

    // 1. Split first, before touching any feature value.
    let (x_train, x_test, _y_train, _y_test) =
        train_test_split(x, y, Some(0.34), Some(42)).unwrap();

    // 2. Fit on the training matrix only. fit_transform returns the scaled training data.
    let mut scaler = StandardScaler::new();
    let x_train_z = scaler.fit_transform(&x_train).unwrap();

    // 3. The test split goes through the stored training statistics.
    let x_test_z = scaler.transform(&x_test).unwrap();

    // 4. A single live sample works the same way (the free function cannot do this).
    let one_sample = scaler.transform(&array![[2.5, 175.0]]).unwrap();

    println!("train mean: {:?}", scaler.get_mean().unwrap());
    println!("train scale: {:?}", scaler.get_scale().unwrap());
    println!("x_train_z shape: {:?}", x_train_z.shape());
    println!("x_test_z shape:  {:?}", x_test_z.shape());
    println!("one sample:      {one_sample:?}");
}

What StandardScaler holds and what it offers

Method / accessorWhat it doesscikit-learn
fit(&x)Learns and stores the per-feature mean and scale, discarding any previous fitfit
transform(&x)Applies the stored (x - mean) / scale. Returns a new arraytransform
fit_transform(&x)Both, in one call (the training-matrix entry point)fit_transform
inverse_transform(&x)Maps standardized values back to the original unitsinverse_transform
partial_fit(&x)Merges another batch into the statistics instead of replacing thempartial_fit
get_mean() / get_var() / get_scale()The learned statistics, None before fitmean_ / var_ / scale_
get_n_samples_seen() / get_n_features()How many rows the statistics cover, and their widthn_samples_seen_ / n_features_in_
with_mean(bool) / with_std(bool)Builder switches for centering and scalingconstructor arguments
save_to_path / load_from_pathPersists the fitted scaler (postcard binary)pickle

partial_fit helps when the training set does not fit in memory. Feed it one chunk at a time. The per-feature moments merge (Chan et al.) into the same result a single fit over the concatenation would give.

inverse_transform matters whenever an answer needs the original units: recovering a reconstruction, or reversing the scale on a target before fitting a regressor.

The scaler is part of the trained pipeline, not throwaway state. A model saved without its scaler becomes unusable, because nothing records what divided the model’s inputs. Save both:

scaler.save_to_path("scaler.bin")?;
model.save_to_path("model.bin")?;

// At serving time
let scaler = StandardScaler::load_from_path("scaler.bin")?;
let model = LinearRegression::load_from_path("model.bin")?;
let prediction = model.predict(&scaler.transform(&incoming)?)?;

2 error paths exist here. Calling transform on an unfitted scaler returns Error::NotFitted("StandardScaler"). Handing it a matrix whose column count differs from the fitted one returns Error::DimensionMismatch. In a dynamically typed pipeline, a feature-order bug like this can silently corrupt predictions. Here, it is a typed error instead.

The scaler rejects non-finite input exactly as the free function does, with Error::NonFinite. This is a deliberate difference from scikit-learn. scikit-learn’s scaler treats NaN as missing data and skips it. RustyML requires you to impute or drop missing values first.

The scaler also implements the crate’s shared Fit, Transform, and FitTransform traits. It composes with generic code written over any transformer:

use rustyml::traits::{Fit, Transform};

Fit::fit(&mut scaler, &x_train)?;
let z = Transform::transform(&scaler, &x_test)?;

The rest of the family

StandardScaler is the default choice, not the only one. 4 siblings share its entire contract: the same fit, transform, and fit_transform methods, the same NotFitted and DimensionMismatch guards, and the same save_to_path. They differ only in what they learn:

ScalerMaps each feature byLearnsReach for it when
StandardScaler(x - mean) / stdmean, variancethe default for distance- and gradient-based models
MinMaxScaler(x - min) / (max - min), then into a target rangemin, maxa component needs bounded inputs
MaxAbsScalerx / max(|x|)max magnitudezero has to keep meaning “absent”
RobustScaler(x - median) / IQRmedian, quantile spreadthe data has outliers you will not remove
Normalizereach sample divided by its own normnothing but the feature countonly a sample’s direction carries signal

MinMaxScaler squashes each feature onto [0, 1] by default. with_feature_range(-1.0, 1.0) retargets it, even on an already-fitted scaler, because the extrema stay stored and only the destination moves.

2 behaviors matter here. First, a later batch that exceeds the training extrema lands outside the range. This is the correct result. It shows that production data has drifted past what the scaler trained on. Pass with_clip(true) when a downstream component requires bounded input. Clipping is lossy, and inverse_transform cannot undo it.

Second, MinMaxScaler is the most outlier-sensitive of the 3 per-feature scalers here. One extreme training value stretches the denominator and squeezes every other sample into a small part of the range. A constant feature has no range at all, so its divisor becomes 1.0, and the column lands flat on feature_range.0.

MaxAbsScaler divides by max(|x|) and never shifts the data. A zero stays exactly zero, and every sign survives. This matters for count vectors, one-hot blocks, or TF-IDF rows, where zero means “absent”. Min-max scaling shifts the data, which turns those structural zeros into an arbitrary nonzero value. Training values land in [-1, 1]. An all-zero feature keeps a divisor of 1.0 and stays zero.

RobustScaler replaces the mean and standard deviation with the median and the interquartile range. Both are order statistics, so an extreme value changes them barely at all. By contrast, one outlier drags StandardScaler’s mean and inflates its standard deviation. An outlier also stretches MinMaxScaler’s denominator, squeezing the rest of the data into a small part of the range. Use RobustScaler for data with outliers you do not plan to remove.

with_quantile_range(10.0, 90.0) widens the spread it measures, covering more of the distribution and reacting more to the tails. This call discards any fitted statistics. The scaler read the stored quantiles at the old positions, and they cannot move without the training data.

2 limits apply here. There is no partial_fit, because quantiles do not merge across batches the way moments and extrema do. Robustness also needs enough samples. With only 4 rows, the 75th percentile interpolates straight into the outlier. This is the quantile rule working as designed, not a failure. The output has no fixed range and no unit variance, only a comparable middle.

Normalizer is the row-wise scaler in the family. A sample’s norm depends on that sample alone, so fit learns nothing but the feature count. It exists so row normalization shares the same estimator contract as the rest of the family. A mis-shaped batch then produces a typed error instead of a silent wrong answer.

Normalizer takes the same NormalizationOrder as the free function. It has no inverse_transform. Dividing by the norm discards the magnitude, and there is no way to recover it. For the Column or Global axes, or for N-D arrays, call normalize directly instead.

use ndarray::array;
use rustyml::utils::normalize::NormalizationOrder;
use rustyml::utils::{MaxAbsScaler, MinMaxScaler, Normalizer};

fn main() {
    let x_train = array![[1.0, 0.0, -4.0], [2.0, 3.0, 0.0], [3.0, 0.0, 2.0]];
    let x_test = array![[4.0, 1.0, -1.0]];

    // Bounded to [0, 1] by the training extrema. The test row exceeds them on purpose.
    let mut min_max = MinMaxScaler::new();
    let train_scaled = min_max.fit_transform(&x_train).unwrap();
    println!("train (min-max): {train_scaled:?}");
    println!("test  (min-max): {:?}", min_max.transform(&x_test).unwrap()); // > 1.0 in col 0

    // Clipped variant: same map, clamped into the range.
    let mut clipped = MinMaxScaler::new().with_clip(true);
    clipped.fit(&x_train).unwrap();
    println!("test  (clipped): {:?}", clipped.transform(&x_test).unwrap());

    // Magnitude only: the zeros in columns 1 and 2 stay exactly zero.
    let mut max_abs = MaxAbsScaler::new();
    println!("train (max-abs): {:?}", max_abs.fit_transform(&x_train).unwrap());

    // Per-sample direction. Each row ends up with L2 norm 1.
    let mut normalizer = Normalizer::new(NormalizationOrder::L2).unwrap();
    println!("train (rows):    {:?}", normalizer.fit_transform(&x_train).unwrap());
}

The next example makes robust scaling visible against a column with an outlier:

use ndarray::array;
use rustyml::utils::{RobustScaler, StandardScaler};

fn main() {
    // 2 identical features, except column 1's last value is an outlier.
    let x = array![
        [1.0, 1.0], [2.0, 2.0], [3.0, 3.0], [4.0, 4.0], [5.0, 5.0],
        [6.0, 6.0], [7.0, 7.0], [8.0, 8.0], [9.0, 1000.0],
    ];

    let mut robust = RobustScaler::new();
    robust.fit(&x).unwrap();
    let mut standard = StandardScaler::new();
    standard.fit(&x).unwrap();

    // Robust: both columns get the same center and scale. The outlier enters neither.
    println!("robust center: {:?}", robust.get_center().unwrap()); // [5, 5]
    println!("robust scale:  {:?}", robust.get_scale().unwrap());  // [4, 4]

    // Standard: the single extreme value dominates column 1's mean and std.
    println!("mean:  {:?}", standard.get_mean().unwrap());  // [5, ~115]
    println!("scale: {:?}", standard.get_scale().unwrap()); // [~2.6, ~313]
}

MinMaxScaler and MaxAbsScaler also support partial_fit. Their extrema merge exactly, so batches give the same answer as one big fit. Every per-feature scaler here has inverse_transform.

The crate does not yet provide the quantile and power transforms (QuantileTransformer, PowerTransformer), or RobustScaler’s unit_variance option. That option needs an inverse normal CDF the crate does not implement. For those cases, compute the statistics yourself, and apply them with the same freeze-on-train discipline.

When the free function is still the right call

standardize still has a place. It is the right tool whenever there is no second batch that needs consistent scaling:

  • One-shot exploration. You are looking at one matrix, not training anything.
  • Row or Global axes, or N-D arrays. StandardScaler is a 2-D, per-feature transformer, exactly like scikit-learn’s. Row-wise and whole-array standardization live only in the free function.
  • Transforms that do not couple samples. Row normalization scales each sample by its own norm alone. normalize(&x_train, Row, L2) and normalize(&x_test, Row, L2) stay consistent by construction, because there is no cross-sample statistic to leak or freeze. Column and Global normalization do couple samples, and have no scaler object. To apply them consistently across a split, compute the norms yourself.

For a transform the family does not cover, for example a quantile or power transform, the same discipline applies. Compute the statistics on the training matrix, keep them, and apply those same numbers to every later batch by hand.

4.2.6. Edge cases: constant features, zeros, and non-finite input

Constant feature (zero variance). A column whose values are all equal has variance 0. Dividing by sqrt(0) directly yields NaN. standardize detects this case with a magnitude-relative, variance-based bound (the Chan-Golub-LeVeque error bound at machine precision), and sets the divisor to 1.0. The centered values, already near 0, then map to exactly 0.0, with no NaN or Inf. Other columns stay untouched.

The detection is variance-based, not an exact equal-to-zero test. A genuinely tiny but real spread, for example 2 values differing by 1e-8, still counts as a real feature, and standardize scales it normally. Only spread that sits within floating-point noise gets flattened to a constant.

StandardScaler follows this same rule. A constant column’s get_scale() entry reads 1.0. This is also why inverse_transform cannot recover such a feature: every value comes back as the training mean.

MinMaxScaler, MaxAbsScaler, and RobustScaler guard the same failure with the coarser test scikit-learn uses for them. A divisor below 10 * f64::EPSILON becomes 1.0. This lands a constant column on feature_range.0 under min-max, and on zero under the other 2.

Near-zero lane in normalization. RustyML treats a lane whose norm falls below 10 * f64::EPSILON as having norm 1.0, and leaves it exactly as is. A zero or near-zero vector has no direction to rescale, so dividing it would only produce NaN. An all-zero row stays all-zero. Global normalization of an all-zero array also stays all-zero.

Non-finite and degenerate input. Both functions validate the input before doing any work. They surface typed errors (see 1.6. Error Handling):

ConditionError variant
Empty arrayError::EmptyInput
Any NaN/Inf in the inputError::NonFinite
Row/Column axis on a 1-D arrayError::InvalidInput
normalize with Lp(p), p <= 0 or non-finiteError::InvalidParameter
A norm accumulation overflowing to non-finiteError::NonFinite
use ndarray::array;
use rustyml::error::Error;
use rustyml::utils::normalize::{normalize, NormalizationAxis, NormalizationOrder};
use rustyml::utils::standardize::{standardize, StandardizationAxis};

fn main() {
    // Constant column: zero variance. The divisor is forced to 1.0, so the centered
    // values collapse to exactly 0.0 with no NaN or Inf.
    let constant = array![[3.0, 1.0], [3.0, 3.0], [3.0, 5.0]];
    let z = standardize(&constant, StandardizationAxis::Column).unwrap();
    println!("constant-column result: {z:?}");
    assert!(z.iter().all(|v| v.is_finite()));

    // Zero row under normalization: norm below 10*f64::EPSILON is treated as 1.0, so
    // the near-zero lane is left exactly as is instead of dividing by close to 0.
    let with_zero_row = array![[3.0, 4.0], [0.0, 0.0]];
    let n = normalize(&with_zero_row, NormalizationAxis::Row, NormalizationOrder::L2).unwrap();
    println!("zero-row preserved: {n:?}");

    // Non-finite input is rejected up front, before any scaling happens.
    let bad = array![[1.0, f64::NAN], [3.0, 4.0]];
    match standardize(&bad, StandardizationAxis::Column) {
        Err(Error::NonFinite(_)) => println!("rejected non-finite input, as expected"),
        other => panic!("expected NonFinite, got {other:?}"),
    }
}

Because non-finite input is rejected up front, neither function can produce a NaN from clean data. If missing values are encoded as NaN, impute or drop them before scaling. Neither function passes them through silently.

4.2.7. Performance and determinism notes

Both functions clone the input once and never mutate it. They rescale in place on the clone.

The heavy statistics run per lane in sequence. This includes the Welford mean and variance pass for standardization, and the per-lane norm for normalization. Once the element count clears an internally calibrated gate, rayon parallelizes the work across lanes. The 2 levels of parallelism never nest.

Global standardization uses a deterministic blocked parallel reduction. It reproduces the same result across runs on the same machine, though not necessarily bit-for-bit across different machines or thread counts. For datasets small enough to fit in cache, the sequential path runs instead, with no threading overhead. None of this needs manual tuning.

A fitted StandardScaler runs on the same machinery, minus the statistics step. transform makes a single fused pass per row over the stored vectors. Scaling inside a tight loop costs 1 pass, instead of the Welford sweep the free function repeats on every call. That difference, not the parallelism, explains the time saved.

StandardScaler::fit_transform on a 2-D array is bit-for-bit identical to standardize(&x, Column). Both share the same Welford pass and the same constant-feature rule, so switching between them never changes a number.

See 7.3. Performance Tuning and Parallelism for the gate model. See 4.3. Label Encoding for the next preprocessing step.

4.3. Label Encoding

Classifiers in RustyML never see your string classes. A softmax head emits a probability per column. A cross-entropy loss reads either a one-hot row or an integer index. Every metric in Chapter 5 counts integer class ids. Label encoding is the translation layer between the labels you have ("cat", "spam", 42) and the 2 numeric shapes the training code accepts. RustyML gives you 3 free functions for this. They live in rustyml::utils::label_encoding. RustyML re-exports them at rustyml::utils and through the prelude.

If you know scikit-learn, forget the stateful LabelEncoder or OneHotEncoder pattern, the one that calls fit once and transform many times. RustyML has no encoder object and nothing to persist. Each function is a pure transformation of the array you pass it. This design keeps the API small and thread-safe. It also moves 1 task onto you: you must hold the label-to-index mapping yourself. You need that mapping to decode predictions and to apply the same scheme to new data. The rest of this page shows how to do that.

4.3.1. The API surface: 3 stateless functions

These 3 functions are the entire API. There is no LabelEncoder struct, no fit, transform, or inverse_transform method, and no separate ordinal encoder. The surface stays flat on purpose.

FunctionInputOutputPurpose
to_categorical&ArrayBase<S, Ix1> where S: Data<Elem = i32>, Option<usize>Result<Array2<f64>, Error>Turns consecutive integer labels into a one-hot matrix
to_categorical_with_mapping&[T] where T: Clone + Eq + Hash, Option<usize>Result<(Array2<f64>, AHashMap<T, usize>), Error>Turns arbitrary labels (strings, sparse integers) into a one-hot matrix, plus the mapping used
to_sparse_categorical&ArrayBase<S, Ix2> where S: Data<Elem = f64>Result<Array1<i32>, Error>Turns one-hot or probability rows into integer labels through argmax

2 type facts matter now, because they cause most of the friction later. First, to_categorical accepts only i32 labels. to_categorical_with_mapping accepts any type that satisfies Clone + Eq + Hash, so it also accepts &str, String, u8, or an enum. Second, the one-hot matrix comes back as Array2<f64> and the decoded labels as Array1<i32>. The neural-network stack uses Tensor = ArrayD<f32>, so it needs f32 instead. This f64 to f32 cast is a required step. It is easy to forget. Section 4.3.5 covers it.

4.3.2. Integer labels to one-hot: to_categorical

Use to_categorical when your labels are already consecutive integers, 0..n_classes. It builds an (n_samples, n_classes) matrix. Each row has a single 1.0, in the column named by the label.

use ndarray::array;
use rustyml::utils::to_categorical;

fn main() {
    let labels = array![0i32, 1, 2, 1, 0];

    // num_classes = None infers the width from max_label + 1 = 3.
    let onehot = to_categorical(&labels, None).unwrap();
    assert_eq!(onehot.shape(), &[5, 3]);

    // Pin a wider width so train / validation / test share a column layout even
    // when a split happens to miss the last class. Extra columns are all-zero.
    let padded = to_categorical(&labels, Some(4)).unwrap();
    assert_eq!(padded.shape(), &[5, 4]);
    println!("{onehot:?}");
}

The num_classes argument is the reason this function needs its own section. Passing None infers the width from max_label + 1. This is convenient, but it is dangerous across a train/test split. If the test fold contains no example of the highest class, to_categorical(&test_labels, None) produces a matrix with 1 column fewer than the training matrix. Every downstream shape check then rejects it. Set num_classes to the true class count on every split, so the layouts stay aligned. Widening is cheap, because the surplus columns are simply zero. This fix solves a common shape mismatch between the validation targets and the output layer.

RustyML rejects 2 kinds of input instead of silently mangling them. Both surface as typed errors:

use ndarray::array;
use rustyml::error::Error;
use rustyml::utils::to_categorical;

fn main() {
    // A negative label cannot index a one-hot column.
    let bad = array![0i32, -1, 2];
    assert!(matches!(
        to_categorical(&bad, None),
        Err(Error::InvalidInput(_))
    ));

    // num_classes narrower than max_label + 1 would drop a class.
    let labels = array![0i32, 1, 2];
    assert!(matches!(
        to_categorical(&labels, Some(2)),
        Err(Error::InvalidParameter { .. })
    ));
    println!("error paths behave as documented");
}

A negative label produces Error::InvalidInput, because it has no valid column. A num_classes value smaller than max_label + 1 produces Error::InvalidParameter, because it would truncate a real class. An empty input array is not an error. It returns shape (0, 1), with 1 class by default, so the matrix stays 2D.

4.3.3. Arbitrary labels to one-hot: to_categorical_with_mapping

Real datasets rarely arrive as 0..n. They arrive as "cat", "dog", "bird", or as non-consecutive integer ids like 10, 20, 30. to_categorical_with_mapping handles these in 1 pass. It assigns each distinct label a column index, in first-seen order. It one-hot encodes against that assignment. It then returns both the matrix and the AHashMap<T, usize> it built.

use rustyml::utils::to_categorical_with_mapping;

fn main() {
    let labels = vec!["cat", "dog", "bird", "dog", "cat"];
    let (onehot, mapping) = to_categorical_with_mapping(&labels, None).unwrap();

    assert_eq!(onehot.shape(), &[5, 3]);
    // First-seen order fixes the column assignment.
    assert_eq!(mapping["cat"], 0);
    assert_eq!(mapping["dog"], 1);
    assert_eq!(mapping["bird"], 2);
    println!("{mapping:?}");
}

The contract is first-seen order, not sorted order. "cat" is column 0 because it appears first, not because it sorts first. This matters for reproducibility. The same slice always yields the same mapping, but 2 datasets that introduce classes in a different order produce different column assignments. That is why the function returns the mapping instead of discarding it. The mapping is the only record of what each column means. Keep it. You need it to decode predictions and, if you encode more data later, to reproduce the same layout. See 4.3.7. The num_classes argument works as it does in to_categorical. None uses the unique-label count. Some(n) pads to a wider matrix, and returns an error if n is smaller than the number of distinct labels.

One edge case differs from to_categorical. An empty slice returns shape (0, 0) with an empty mapping, because zero unique labels infer a class count of zero. to_categorical on an empty array instead returns (0, 1). Neither result is wrong. If you branch on the column count of an empty batch, check which function produced it.

4.3.4. Decoding predictions and round-trips: to_sparse_categorical

to_sparse_categorical runs the inverse direction. It takes a 2D matrix and reduces each row to the index of its largest value. Feed it a strict one-hot matrix, and it recovers the original integer labels. Feed it a softmax probability matrix, and it returns the predicted class per sample. That second use is the common one. It turns a model’s predict output into class ids.

use ndarray::array;
use rustyml::utils::{to_categorical, to_sparse_categorical};

fn main() {
    let original = array![0i32, 1, 2, 1, 0];
    let one_hot = to_categorical(&original, None).unwrap();
    let recovered = to_sparse_categorical(&one_hot).unwrap();
    assert_eq!(recovered, original);
    println!("round-trip ok: {recovered:?}");
}

2 behaviors matter here. Ties resolve to the first (lowest) index. When 2 columns share the row maximum, the function picks the earlier one. This matches NumPy’s argmax, not max_by, which would keep the last. A non-finite value anywhere in the matrix triggers Error::NonFinite up front. The per-row comparison is then total, and it never treats NaN as a winner or a loser. A NaN in your probabilities is a bug in the model output. This function reports it instead of hiding it.

For the string case, the round trip needs 1 more step. to_sparse_categorical recovers only indices, because it has never seen your labels. Invert the mapping yourself, to get from index back to label:

use rustyml::utils::{to_categorical_with_mapping, to_sparse_categorical};
use std::collections::HashMap;

fn main() {
    let labels = vec!["cat", "dog", "bird", "dog", "cat"];
    let (one_hot, mapping) = to_categorical_with_mapping(&labels, None).unwrap();

    // Decode to class indices, then invert the mapping to recover the strings.
    let idx = to_sparse_categorical(&one_hot).unwrap();
    let inverse: HashMap<usize, &str> = mapping.iter().map(|(&k, &v)| (v, k)).collect();
    let recovered: Vec<&str> = idx.iter().map(|&i| inverse[&(i as usize)]).collect();

    assert_eq!(recovered, labels);
    println!("{recovered:?}");
}

Build the index to label inverse once, and reuse it. This is the standard way to report predictions in their original labels. Section 4.3.5 closes with this pattern.

4.3.5. Picking the target format: one-hot vs sparse integer

Multi-class classification in the neural-network stack offers 2 losses. Your choice of loss sets the encoding you feed to fit. The 2 losses are numerically equivalent: same forward value, same gradient. They differ only in how the target is stored. See 3.3. Loss Functions for the loss side of this.

CategoricalCrossEntropySparseCategoricalCrossEntropy
Target shape[batch, num_classes] one-hot[batch, 1] integer class id
Build it withto_categorical (+ cast f64 to f32)reshape codes to a column, cast to f32
Target storageO(batch x classes)O(batch)
from_logits flagyesyes

CategoricalCrossEntropy wants the full one-hot matrix. to_categorical returns f64, but Tensor is f32. So the .mapv(|v| v as f32) cast is mandatory. Skip it, and the types will not match your f32 feature matrix:

use ndarray::array;
use rustyml::neural_network::{
    layers::{Activation, Dense},
    losses::CategoricalCrossEntropy,
    optimizers::Adam,
    sequential::Sequential,
};
use rustyml::utils::to_categorical;

fn main() {
    let x = array![
        [5.1f32, 3.5, 1.4, 0.2],
        [4.9, 3.0, 1.4, 0.2],
        [6.2, 3.4, 5.4, 2.3],
        [5.9, 3.0, 5.1, 1.8],
        [6.0, 2.2, 4.0, 1.0],
        [5.5, 2.4, 3.8, 1.1],
    ]
    .into_dyn();
    let labels = array![0i32, 0, 2, 2, 1, 1];

    // One-hot, then bridge f64 -> f32 and into the dynamic-dim Tensor shape.
    let y = to_categorical(&labels, None)
        .unwrap()
        .mapv(|v| v as f32)
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(4, 8, Activation::ReLU).unwrap())
        .add(Dense::new(8, 3, Activation::Softmax).unwrap())
        .compile(
            Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(),
            CategoricalCrossEntropy::new(false),
        );

    model.fit(&x, &y, 5).unwrap();
    let preds = model.predict(&x).unwrap();
    println!("prediction shape: {:?}", preds.shape()); // [6, 3]
}

SparseCategoricalCrossEntropy skips the one-hot step entirely. It reads the class id directly from a [batch, 1] tensor. There is no matrix to build. You just reshape your integer codes into a column, and cast to f32. With 2 classes this saves little. With thousands of classes, such as word vocabularies or product catalogs, the one-hot matrix is almost all zeros. The sparse form then saves both memory and the allocation:

use ndarray::{array, Axis};
use rustyml::neural_network::{
    layers::{Activation, Dense},
    losses::SparseCategoricalCrossEntropy,
    optimizers::Adam,
    sequential::Sequential,
};

fn main() {
    let x = array![
        [5.1f32, 3.5, 1.4, 0.2],
        [4.9, 3.0, 1.4, 0.2],
        [6.2, 3.4, 5.4, 2.3],
        [5.9, 3.0, 5.1, 1.8],
        [6.0, 2.2, 4.0, 1.0],
        [5.5, 2.4, 3.8, 1.1],
    ]
    .into_dyn();

    // Sparse targets: integer class ids as a [batch, 1] f32 column. No one-hot.
    let labels = array![0i32, 0, 2, 2, 1, 1];
    let y = labels.mapv(|v| v as f32).insert_axis(Axis(1)).into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(4, 8, Activation::ReLU).unwrap())
        .add(Dense::new(8, 3, Activation::Softmax).unwrap())
        .compile(
            Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(),
            SparseCategoricalCrossEntropy::new(false),
        );

    model.fit(&x, &y, 5).unwrap();
    println!("target shape fed to fit: {:?}", y.shape()); // [6, 1]
}

The sparse target needs shape [batch, 1]. insert_axis(Axis(1)) turns the length-batch label vector into a column. SparseCategoricalCrossEntropy validates this shape. It rejects a bare [batch] vector, a negative or non-finite label, and any label >= num_classes. Each case gets a descriptive error instead of an out-of-bounds panic. A non-integer label is not rejected. 1.6 rounds silently to class 2 through .round(). Encode class ids as exact integers to avoid this.

The full loop for a string-labeled dataset ties this together. Encode with a mapping, train, predict, and decode back to the original labels for reporting:

use ndarray::{array, Ix2};
use rustyml::neural_network::{
    layers::{Activation, Dense},
    losses::CategoricalCrossEntropy,
    optimizers::Adam,
    sequential::Sequential,
};
use rustyml::utils::{to_categorical_with_mapping, to_sparse_categorical};
use std::collections::HashMap;

fn main() {
    let x = array![[0.1f32, 0.2], [0.9, 0.8], [0.15, 0.25], [0.85, 0.95]].into_dyn();
    let raw = vec!["cat", "dog", "cat", "dog"];

    // Encode targets, keep the mapping, and size the output layer from it.
    let (y_f64, mapping) = to_categorical_with_mapping(&raw, None).unwrap();
    let y = y_f64.mapv(|v| v as f32).into_dyn();
    let n_classes = mapping.len();

    let mut model = Sequential::new();
    model
        .add(Dense::new(2, 8, Activation::ReLU).unwrap())
        .add(Dense::new(8, n_classes, Activation::Softmax).unwrap())
        .compile(
            Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(),
            CategoricalCrossEntropy::new(false),
        );
    model.fit(&x, &y, 5).unwrap();

    // Predict -> f32 probabilities -> f64 2D -> argmax indices -> original labels.
    let probs = model.predict(&x).unwrap();
    let probs_2d = probs.mapv(|v| v as f64).into_dimensionality::<Ix2>().unwrap();
    let class_ids = to_sparse_categorical(&probs_2d).unwrap();

    let inverse: HashMap<usize, String> =
        mapping.iter().map(|(k, &v)| (v, k.to_string())).collect();
    let predicted: Vec<String> = class_ids
        .iter()
        .map(|&i| inverse[&(i as usize)].clone())
        .collect();
    println!("predicted labels: {predicted:?}");
}

The 2 type conversions on the return path match the cast on the way in. predict yields f32 in the dynamic ArrayD shape. to_sparse_categorical wants a 2D f64 array. So you cast to f64, and fix the dimensionality to Ix2, before decoding. Sizing the output layer from mapping.len(), instead of a fixed constant, keeps the network in step with the encoding.

4.3.6. Ordinal encoding: when integer codes lie

Integer codes are convenient, and that convenience hides a real trap. For a classification target fed to a cross-entropy loss, the code is just an identity. The loss looks up which column is the true one. It never compares 2 against 1 as magnitudes. So SparseCategoricalCrossEntropy treats class 2 as a bare integer safely. The trap is using the same integer codes to encode a categorical input feature, for a model that does arithmetic on its inputs. Linear and logistic regression, SVMs, and every distance-based method, such as KNN or KMeans, read those codes as numbers on a line. With red=0, green=1, blue=2, the model reads green as exactly between red and blue, and blue as twice green. Those relationships are fabricated. A linear model still fits a coefficient to them, and generalizes the fiction.

The fix is to one-hot the categorical feature, so no false order or spacing survives. Every category becomes its own axis, mutually equidistant:

use ndarray::{array, concatenate, Axis};
use rustyml::utils::to_categorical;

fn main() {
    let price = array![[10.0f64], [12.0], [11.0]];
    let color_code = array![0i32, 2, 1]; // red, blue, green

    // WRONG for a linear / distance model: the codes invent an order and spacing.
    let color_ordinal = color_code.mapv(|c| c as f64).insert_axis(Axis(1));
    let ordinal = concatenate(Axis(1), &[price.view(), color_ordinal.view()]).unwrap();
    assert_eq!(ordinal.shape(), &[3, 2]);

    // RIGHT: one-hot so red, green, blue are mutually equidistant.
    let color_onehot = to_categorical(&color_code, None).unwrap();
    let encoded = concatenate(Axis(1), &[price.view(), color_onehot.view()]).unwrap();
    assert_eq!(encoded.shape(), &[3, 4]); // price + 3 color columns
    println!("{encoded:?}");
}

A bare integer code is fine as a feature in 2 cases. The first case is a genuinely ordinal category, where the integers respect its order, such as small=0, medium=1, large=2. The order the model reads is then real, though the spacing between the values is still an assumption. The second case is a decision tree or tree ensemble. These models split on thresholds instead of multiplying features by weights. So they tolerate arbitrary integer codes far better than a linear model does, at the cost of needing deeper splits to isolate individual categories. For every linear or distance-based model with a nominal feature, use one-hot encoding. The extra columns are the price of not lying to the model about geometry. One-hot columns pair naturally with the scalers in 4.2. Standardization and Normalization. The indicator columns are already 0 or 1, and you usually leave them as-is while you scale the continuous features.

4.3.7. Unseen categories and the stateless model

These functions are stateless, so the scikit-learn question about an unseen category at transform time has a different answer here. There is no fit step and no stored encoder. A fresh call to to_categorical_with_mapping on new data builds a new mapping from whatever that data contains. With first-seen ordering, that new mapping can assign different columns than the mapping your model trained against. Re-encoding your inference data from scratch is the real risk. It is silent, because it produces a valid-looking matrix that means something different.

The correct pattern is to encode once, keep the returned mapping, and look labels up against that saved mapping at inference time, instead of re-encoding. Remember 1 sharp edge: indexing the map with mapping[key] panics if the key is absent. This is exactly the unseen-category case. Use .get instead, so a novel label becomes a value you handle, not a crash:

use rustyml::utils::to_categorical_with_mapping;

fn main() {
    let train = vec!["red", "green", "blue"];
    let (_matrix, mapping) = to_categorical_with_mapping(&train, None).unwrap();

    // At inference you hold the mapping yourself and look labels up.
    let incoming = ["green", "purple"]; // "purple" was never seen at fit time
    for label in incoming {
        match mapping.get(label) {
            Some(&idx) => println!("{label} -> class {idx}"),
            None => println!("{label} -> UNSEEN, route to a fallback"),
        }
    }
    // Indexing panics on a missing key, so prefer `.get`:
    // let _ = mapping["purple"]; // would panic: key not found
}

What you do with an unseen label is a modeling decision. RustyML leaves it to you. You can drop the row. You can route it to a reserved “unknown” column, by training with an explicit num_classes set 1 wider than the observed classes. Or you can reject the request. RustyML does not invent an “unknown” bucket for you. This is the honest choice: a category the model never trained on has no learned representation. Folding it silently into an existing class would only hide that fact.

The mapping is an ordinary AHashMap. Persisting it is your job too, because it is not part of the model weights that save and load handle. A model reloaded without its label mapping can still emit column indices. Nothing can then turn those indices back into "cat" and "dog". Serialize the mapping alongside the weights, since serde handles HashMap directly. You then have a complete, reproducible pipeline. See 7.2. Model Persistence in Depth for the broader story.

5. Model Evaluation

A trained model is only as trustworthy as the number you judge it by. The wrong number hides real failures. Plain accuracy looks excellent on a fraud dataset that is 99% negative, even when the model catches nothing. This chapter covers RustyML’s metrics module. These are the array-to-scalar scoring functions that turn raw predictions into the diagnostics you report. Everything here lives under rustyml::metrics, gated by the metrics feature, which full turns on. The module is re-exported flat, so mean_squared_error is reachable as both metrics::mean_squared_error and metrics::regression::mean_squared_error. Pull the whole set into scope with use rustyml::prelude::metrics::*;.

Two conventions run through the entire module. First, arguments follow the order (y_true, y_pred), ground truth first. That order does not matter for the symmetric scores (MSE, MAE, accuracy). It does change the result for r2_score, ConfusionMatrix::new, and roc_auc, so get the order right by habit. Second, unlike the estimators in Classical Machine Learning, which return the crate’s Error, these functions panic on a precondition violation. A mismatched length or an empty input always triggers a panic, instead of returning a Result. A NaN score also triggers a panic in most functions, but r2_score and explained_variance_score treat a NaN as data instead, as Regression Metrics explains. The module is a lightweight leaf of pure functions. It mirrors ndarray’s own dimension-mismatch behavior. See Error Handling for how that choice differs from the rest of the crate.

use ndarray::array;
use rustyml::metrics::{accuracy, mean_squared_error, r2_score};

fn main() {
    // Regression: (y_true, y_pred), ground truth first
    let y_true = array![3.0, -0.5, 2.0, 7.0];
    let y_pred = array![2.5, 0.0, 2.0, 8.0];
    println!("MSE = {:.4}", mean_squared_error(&y_true, &y_pred));
    println!("R^2 = {:.4}", r2_score(&y_true, &y_pred));

    // Classification: exact-match accuracy over integer labels stored as f64
    let labels = array![0.0, 1.0, 1.0, 0.0];
    let preds = array![0.0, 1.0, 0.0, 0.0];
    println!("accuracy = {:.4}", accuracy(&labels, &preds));
}

The Regression Metrics section covers the continuous-target scores: mean_squared_error and its root root_mean_squared_error, mean_absolute_error, the outlier-resistant median_absolute_error, mean_absolute_percentage_error, and the 2 variance-explained scores r2_score and explained_variance_score. Choose between them with care. r2_score lets a NaN propagate, so corrupt data surfaces loudly. explained_variance_score instead skips non-finite samples silently, and ignores a constant prediction bias. That behavior is convenient, until it hides a real problem.

The Classification Metrics section is the largest, because label problems rarely reduce to one number. It covers the binary ConfusionMatrix. You build a ConfusionMatrix from hard 0/1 labels that you threshold yourself, and it panics on anything else. Its counts derive accuracy, precision, recall, specificity, F1, MCC, and balanced accuracy. The section also covers MulticlassConfusionMatrix, with macro, micro, and weighted aggregation through the Average enum. It covers the standalone accuracy, roc_auc, average_precision, log_loss, cohen_kappa, and top_k_accuracy functions, plus the roc_curve and precision_recall_curve threshold sweeps. Watch the input types. Some functions take bool labels with f64 scores. Others take usize class indices with a probability matrix.

The Clustering Metrics section splits along one important line. Extrinsic metrics (adjusted_rand_index, normalized_mutual_info, adjusted_mutual_info, homogeneity, completeness, V-measure, and fowlkes_mallows_score) compare a clustering against ground-truth labels. Intrinsic metrics (silhouette_score, davies_bouldin_score, calinski_harabasz_score) instead score a clustering from feature geometry alone, for when no ground truth exists. silhouette_score takes a DistanceCalculationMetric from Distance Metrics. This lets you evaluate under the same distance you used to cluster.

Read the sections in order. They all inherit the (y_true, y_pred) order and the panic convention from above, and 5.1 sets the tone for the rest. First train a model, with Classical Machine Learning or Neural Networks. Then hold out a test set with Train-Test Split. This chapter gives you the most after those 2 steps. A metric only means something on data the model never saw during fitting. The one hard prerequisite is a working grasp of Working with ndarray, since every function here consumes and returns ndarray types.

5.1. Regression Metrics

Regression metrics turn a vector of predictions and a vector of ground-truth targets into one scalar. That scalar states how good the fit is. RustyML groups these metrics under rustyml::metrics. The category module rustyml::metrics::regression re-exports them flat, so you reach any of them as rustyml::metrics::mean_squared_error. You can also reach them through the prelude with use rustyml::prelude::*;.

This page covers the 7 regression functions the crate ships. It explains the math behind each function, how each function reacts to outliers, and how argument order changes the result. It also shows how to read the functions together after you fit a Linear Regression model. The function names and the (y_true, y_pred) argument order match scikit-learn. RustyML diverges from scikit-learn in 2 ways. It panics instead of returning a Result (see Section 5.1.6), and it does not provide adjusted_r2 (see Section 5.1.2).

5.1.1. The 7 functions and their signatures

Every regression metric has the same shape. It takes 2 one-dimensional arrays and returns one f64 value. The functions are generic over ndarray storage. y_true and y_pred can each be an owned array (Array1<f64>) or a view (ArrayView1<f64>), for example a .column(j) slice of a 2-D array. Both arrays must hold f64 values. There is no f32 overload.

// where S1: Data<Elem = f64>, S2: Data<Elem = f64>
pub fn mean_squared_error<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn root_mean_squared_error<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn mean_absolute_error<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn median_absolute_error<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn mean_absolute_percentage_error<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn r2_score<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn explained_variance_score<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;

The argument order is (y_true, y_pred): ground truth first, predictions second. This matches scikit-learn and the clustering metrics’ (labels_true, labels_pred) order. For 4 of the 7 functions (MSE, RMSE, MAE, MedAE), the order does not matter. Each of these builds on abs(y_true - y_pred) or (y_true - y_pred)^2, and both forms are symmetric. MAPE’s denominator uses only the first argument, so MAPE is order-sensitive too (see Section 5.1.5).

Order matters a great deal for the 2 variance-explained scores. r2_score and explained_variance_score normalize by the spread of the first argument. Swapping the arguments does not raise an error. It silently returns a different, wrong number. This is the most common way to corrupt a regression evaluation. Section 5.1.5 shows the cost of this mistake.

FunctionDefinitionUnitsBest valueTypical range
mean_squared_error (MSE)mean of (y_true - y_pred)^2squared target units0.0[0, infinity)
root_mean_squared_error (RMSE)sqrt(MSE)target units0.0[0, infinity)
mean_absolute_error (MAE)mean of abs(y_true - y_pred)target units0.0[0, infinity)
median_absolute_error (MedAE)median of abs(y_true - y_pred)target units0.0[0, infinity)
mean_absolute_percentage_error (MAPE)mean of abs(y_true - y_pred) / max(abs(y_true), eps)fraction (multiply by 100 for percent)0.0[0, infinity)
r2_score (R^2)1 - SSE / SSTdimensionless1.0(-infinity, 1.0]
explained_variance_score (EVS)1 - Var(resid) / Var(y_true)dimensionless1.0(-infinity, 1.0]

5.1.2. What each metric measures and when to trust it

MSE averages the squared error. Squaring makes every residual positive, so the argument order does not matter. Squaring also weights a large residual more than a small one. An error of 4 contributes 16. 4 errors of 1 contribute 4 in total.

This quadratic weighting is why most optimizers minimize MSE as a loss function. It also makes MSE hypersensitive to outliers, because one bad sample can dominate the whole number. The unit of MSE is the square of the target unit, so MSE is hard to interpret directly. A statement like “the error is 0.02 squared dollars” carries no clear meaning.

RMSE is sqrt(MSE). Taking the square root returns the number to the target’s own units, so you can say “the typical error is about 0.15 dollars”. MSE is never negative, so the square root always exists. RMSE keeps the quadratic emphasis on large errors, so it stays outlier-sensitive.

Report RMSE when large mistakes cost disproportionately more and you want the answer in real units. RMSE is never smaller than MAE, and the 2 are equal only when every error has the same size. A large gap between RMSE and MAE signals that a few big residuals inflate RMSE.

MAE averages the absolute error. Each sample contributes in proportion to its own error, not to the square of the error. This makes MAE less sensitive to outliers than RMSE, and MAE stays in the target’s units. Use MAE when every unit of error costs the same and you do not want a few extreme points to steer the score.

MedAE takes the median of the absolute errors instead of the mean. The median ignores the size of the tail completely. Up to half the samples can be arbitrarily wrong without moving the median. This makes MedAE the metric in the set that outliers affect the least.

Use MedAE on data with heavy-tailed noise or known bad records. MedAE says nothing about the tail it ignores. Do not report MedAE alone when the large errors are the ones that matter.

Ranked by outlier sensitivity, from most to least reactive: MSE and RMSE (squared), then MAE (linear), then MedAE (rank-based, least affected). MAPE does not fit this ranking. MAPE reweights errors by the size of the true value, not by the size of the error.

MAPE is the mean of the per-sample relative errors: abs(y_true - y_pred) / max(abs(y_true), eps), with eps set to f64::EPSILON. The result is a fraction. Multiply it by 100 to state it as a percent. MAPE is scale-free. That property matters when targets span several orders of magnitude, because a fixed absolute error means different things at different scales.

Two behaviors are worth watching. First, the denominator uses abs(y_true), so a negative target works normally. A y_true value at or near zero gets floored at f64::EPSILON instead of causing a division by zero. This floor makes that sample’s term explode to about 10^13, which drags up the whole mean. Treat a MAPE in the trillions as a sign that some y_true value is zero, not as a sign the model failed.

Second, MAPE is not symmetric. Under-prediction is bounded at 100 percent, but over-prediction has no upper bound. As a result, MAPE quietly rewards a model that predicts values on the low side.

R^2 is the coefficient of determination. The formula is 1 - SSE / SST. SSE = sum((y_pred - y_true)^2) is the residual sum of squares. SST = sum((y_true - mean(y_true))^2) is the total variance of the targets around their own mean. R^2 states what fraction of the target’s variance the model explains, compared to the baseline of always predicting the mean. A value of 1.0 marks a perfect fit.

A value of 0.0 means the model is no better than predicting mean(y_true). R^2 has no lower bound. It goes negative whenever SSE is greater than SST, that is, whenever the model is worse than the constant-mean baseline. A negative R^2 is a real and meaningful signal. It usually has one of 3 causes: a mis-specified model, evaluation on data from a different distribution than the model trained on, or swapped arguments. Because SST comes from y_true alone, R^2 is not symmetric in its arguments (see Section 5.1.5).

When y_true is constant, the ratio is undefined. RustyML follows scikit-learn here: it returns 1.0 for an exact fit and 0.0 otherwise, so a constant target never produces a NaN. RustyML decides constancy by comparing the values to each other, not by testing SST against a threshold. An earlier version used an absolute 1e-10 threshold on the unnormalized sum of squares. That threshold reported a false 1.0 for a genuinely varying target with a small spread, for example [1e-6, 2e-6, 3e-6], whose SST is 2e-12. A test for exact SST == 0.0 fails in the other direction, because computing the mean does not round-trip every constant value exactly.

EVS, the explained variance score, replaces the residual sum of squares in R^2 with the variance of the residuals. The formula is 1 - Var(y_true - y_pred) / Var(y_true). Subtracting the residual mean before squaring means a constant prediction bias does not lower the score.

Say a model is always off by exactly +1. Its residuals have zero variance, so EVS equals 1.0, even though the predictions are systematically wrong. R^2 correctly penalizes that same bias. The gap EVS - R^2 measures how biased the predictions are. Consider an unbiased fit, for example any model with an intercept fitted by least squares on its own training data. Its residual mean is close to 0, so EVS is close to R^2.

RustyML does not provide an adjusted-R^2 function. Plain R^2 never decreases when you add a feature, so it cannot compare models with a different number of features. Compute the adjustment yourself if you need it. The formula is adj = 1 - (1-r2)*(n-1)/(n-p-1), where n is the sample count and p is the predictor count.

5.1.3. Choosing a metric for model selection

Report at least one scale-aware error metric together with one variance-explained score. A single number hides too much. Use RMSE when large errors cost disproportionately more and you want the answer in the target’s units. Use MAE when errors scale linearly with cost and you distrust a few extreme points. Use MedAE when the data has known bad records or heavy tails and you want a number the tail cannot move. Use MAPE only when targets are strictly positive and comparable across scales, and never when a target can be zero.

Use R^2 to state how good a model is in a dimensionless way. Use EVS instead when you want to factor out a constant bias. For hyperparameter search and cross-model comparison, pick one metric up front and hold it fixed. Comparing model A’s RMSE against model B’s MAE has no meaning. Always compute these metrics on a held-out split (see Train-Test Split). Overfitting the training set trivially minimizes every metric on this page.

5.1.4. A worked example

This example fits a Linear Regression model on 5 noisy points, predicts on the same inputs, and computes every metric on this page. LinearRegression uses its default closed-form solver here. That solver is exact and instant, with no learning rate or iteration count to tune, so the example stays deterministic and fast. The metric calls stay the same no matter how the predictions were produced.

use ndarray::array;
use rustyml::machine_learning::LinearRegression;
use rustyml::metrics::{
    explained_variance_score, mean_absolute_error, mean_absolute_percentage_error,
    mean_squared_error, median_absolute_error, r2_score, root_mean_squared_error,
};

fn main() {
    // 5 samples, 1 feature. Roughly y = 2x, with a little measurement noise.
    let x = array![[1.0], [2.0], [3.0], [4.0], [5.0]];
    let y_true = array![2.1, 3.9, 6.2, 7.8, 10.1];

    // Closed-form ordinary least squares: exact, no hyperparameters, runs instantly.
    let mut model = LinearRegression::new(true);
    model.fit(&x, &y_true).unwrap();

    let y_pred = model.predict(&x).unwrap();

    // Ground truth first, predictions second. Order matters for R^2 and EVS.
    println!("MSE   = {:.5}", mean_squared_error(&y_true, &y_pred));
    println!("RMSE  = {:.5}", root_mean_squared_error(&y_true, &y_pred));
    println!("MAE   = {:.5}", mean_absolute_error(&y_true, &y_pred));
    println!("MedAE = {:.5}", median_absolute_error(&y_true, &y_pred));
    println!("MAPE  = {:.5}", mean_absolute_percentage_error(&y_true, &y_pred));
    println!("R2    = {:.5}", r2_score(&y_true, &y_pred));
    println!("EVS   = {:.5}", explained_variance_score(&y_true, &y_pred));

    // A fitted LinearRegression can also give you R^2 directly, without predict():
    println!("score = {:.5}", model.score(&x, &y_true).unwrap());
}

These are the values the program prints, rounded. The closed-form solver makes the output deterministic, so the same input always prints the same numbers.

MSE   ~ 0.021       (squared y-units)
RMSE  ~ 0.146       (y-units)
MAE   ~ 0.136       (y-units)
MedAE ~ 0.130       (y-units)
MAPE  ~ 0.026       (fraction -> ~2.6%)
R2    ~ 0.997
EVS   ~ 0.997
score ~ 0.997

Read together, these numbers tell a consistent story. R^2 is about 0.997, so the fitted line explains almost all the variance in y. RMSE is about 0.15 and MAE is about 0.14, both in the units of y, which ranges from 2 to 10. These 2 values confirm that the typical miss is about a seventh of a unit, small relative to the spread of y. RMSE sits just above MAE, so no single residual dominates the error. MedAE is close to MAE, another sign that the errors are evenly sized rather than tail-heavy.

MAPE is about 2.6 percent, which states the same accuracy in a scale-free way. EVS matches R^2 at this precision. A least-squares fit with an intercept produces residuals with a mean close to 0, so there is no bias left for EVS to forgive. model.score(&x, &y_true) reproduces r2_score(&y_true, &y_pred), because LinearRegression computes R^2 internally using the same definition. Use score for convenience on a fitted model. Use the free function r2_score when you score predictions from anything else, for example a neural network, a KNN regressor, or an external model.

5.1.5. The argument-order trap

r2_score and explained_variance_score normalize by the variance of their first argument. Calling r2_score(&y_pred, &y_true) by mistake does not raise an error. It computes a well-formed but wrong number. The example below scores the same 2 arrays both ways. It also shows R^2 going negative for a genuinely bad model.

use ndarray::array;
use rustyml::metrics::r2_score;

fn main() {
    let a = array![1.0, 2.0, 4.0];
    let b = array![2.0, 3.0, 4.0];

    // Swapping the arguments changes R^2, because SST comes only from the first array.
    println!("r2_score(a, b) = {:.4}", r2_score(&a, &b)); // ~ 0.5714  (= 4/7)
    println!("r2_score(b, a) = {:.4}", r2_score(&b, &a)); // 0.0000

    // A model whose predictions run opposite to the truth is worse than predicting
    // the mean, so R^2 goes negative. This is a real signal, not an error condition.
    let y_true = array![1.0, 2.0, 3.0];
    let y_pred = array![3.0, 2.0, 1.0];
    println!("negative R²    = {:.4}", r2_score(&y_true, &y_pred)); // -3.0000
}

The symmetric metrics (MSE, RMSE, MAE, MedAE, and the numerator of MAPE) are immune to this problem, because they only ever see y_true - y_pred. MAPE is the exception among the error metrics. Its denominator uses the first argument, so mean_absolute_percentage_error is order-sensitive too. One habit prevents all of this. Always pass ground truth first, and name your variables y_true and y_pred, so a swap is visible at the call site.

5.1.6. Input validation, panics, and NaN behavior

The model APIs in Chapter 2 return Result<_, Error> (see Error Handling). The metrics module works differently. It panics on a precondition violation instead of returning an error. This matches how ndarray itself panics on a dimension mismatch.

All 7 functions run the same check first. The lengths must be equal, and the inputs must not be empty. The length check runs before the emptiness check, so a mismatch is reported even when one side is empty. The panic messages mirror the crate’s Error wording:

dimension mismatch: expected 3, found 2
input is empty: y_true and y_pred

You cannot use ? to propagate a panic. Validate the lengths in your own code before you call these functions. Wrap the call in std::panic::catch_unwind if you need to recover from a panic. In practice, you control the lengths of the arrays you pass in, for example a predict output and its matching target. The panics never fire when the lengths already match.

The functions handle non-finite input inconsistently, by design. This matters when your data may contain NaN or inf. r2_score uses plain sums. A single non-finite value in either array propagates through the sum, so the result is NaN. This surfaces corrupt data loudly instead of hiding it, which is usually what you want.

explained_variance_score takes the opposite approach. Its variance helper silently skips non-finite samples and averages over the finite subset. A few bad entries leave a normal-looking score computed from the rest. This behavior is convenient, but it can mask a data problem. Clean your inputs first if a dropped sample would matter.

The error metrics MSE, RMSE, and MAE also propagate NaN through their sums. MedAE sorts with total_cmp, which orders NaN deterministically instead of panicking.

Two edge cases round this out. When y_true is constant, R^2 and EVS would divide by zero. R^2 returns 1.0 only for an exact fit and 0.0 otherwise. EVS returns 1.0 whenever the residuals have zero variance, even with a constant offset, and 0.0 otherwise. Neither path produces a NaN.

RustyML reads constancy off the values themselves, not from a tolerance on the variance. This choice keeps a target with a genuinely tiny spread from being mistaken for a constant and scored a false 1.0.

For MAPE, a y_true entry of zero does not cause a division by zero. It floors the denominator at f64::EPSILON instead, so that sample’s term explodes and drags up the mean with it. Treat an extremely large MAPE as a sign that y_true contains a zero, not as a verdict on the model.

For the companion metrics on the other 2 task families, see Classification Metrics and Clustering Metrics.

5.2. Classification Metrics

Classification metrics live in rustyml::metrics, next to the regression metrics and the clustering metrics. Every function and type on this page is re-exported flat from that module, so use rustyml::metrics::{accuracy, ConfusionMatrix, roc_auc}; is all the import you need. The interface splits along a single axis that confuses people coming from scikit-learn. Some entry points take hard class labels, some take a decision threshold, and some take raw probabilities or scores. Getting the label representation wrong is the most common mistake with this module.

5.2.1. The module’s conventions, and how it signals errors

The estimators in Chapter 2 return Result<_, Error>. The functions in this module do not. They panic on a precondition violation instead. This is deliberate. The classification functions here are pure array -> scalar code that pulls in only ndarray and ahash. On a shape mismatch, the module panics the same way ndarray does, instead of returning the crate’s error type.

Every function checks 2 preconditions: equal length and non-empty input. A violation panics with dimension mismatch: expected N, found M or input is empty: .... This wording matches the crate’s Error variants on purpose. A metric is not the place to recover from bad input. If y_true and y_pred have different lengths, the bug is upstream. The panic surfaces it at the call site, instead of returning a misleading 0.0.

Arguments are always (y_true, y_pred), ground truth first. Order does not matter for the symmetric metrics, such as accuracy. For ConfusionMatrix::new and roc_auc, argument order decides which array counts as truth. Keep y_true first as a habit.

The module uses 3 label representations, listed in the table below. The compiler enforces them, but the panic messages do not explain the design.

Entry pointy_true element typeprediction / score typescope
accuracyf64 (discrete labels)f64 (discrete labels)binary or multi-class
ConfusionMatrix::newf64 hard labelsf64 hard labelsbinary only
ConfusionMatrix::new_with_labelsf64, an explicit label pairf64, an explicit label pairbinary only
roc_auc, roc_curve, average_precision, precision_recall_curveboolf64 scoresbinary only
MulticlassConfusionMatrix::newusizeusizemulti-class
log_loss, top_k_accuracyusizef64 probability matrixmulti-class
cohen_kappausizeusizemulti-class

5.2.2. Accuracy, and why it lies under imbalance

The free function accuracy(&y_true, &y_pred) returns the fraction of exactly matching labels. It compares each pair within f64::EPSILON. This makes it built for discrete class labels stored as f64, such as 0.0, 1.0, and 2.0. It works the same way for binary and multi-class problems. The comparison is symmetric, so swapping the arguments changes nothing.

Do not feed accuracy probabilities. It does no thresholding, so 0.87 and 1.0 count as a mismatch. Threshold probabilities yourself first. ConfusionMatrix does not do this for you either. It takes only hard 0.0/1.0 labels and panics on anything else.

Accuracy has a real weakness: it gives a poor summary of imbalanced data. The module offers 2 more honest metrics for that case. Consider a screening problem with 100 samples and only 5 positives. A model that predicts the negative class for every sample scores 95% accuracy. It also catches 0 of the cases that matter.

use rustyml::metrics::{accuracy, ConfusionMatrix};
use ndarray::Array1;

fn main() {
    // Imbalanced problem: 100 samples, only 5 positives.
    let mut truth = vec![0.0f64; 100];
    for t in truth.iter_mut().take(5) {
        *t = 1.0;
    }
    let y_true = Array1::from(truth);
    // A model that always predicts the majority (negative) class.
    let y_pred = Array1::from(vec![0.0f64; 100]);

    println!("accuracy:          {:.3}", accuracy(&y_true, &y_pred)); // ~0.95
    let cm = ConfusionMatrix::new(&y_true, &y_pred);
    println!("recall:            {:.3}", cm.recall());            // 0.0 (catches nothing)
    println!("balanced accuracy: {:.3}", cm.balanced_accuracy()); // 0.5 (chance level)
    println!("MCC:               {:.3}", cm.mcc());               // 0.0 (no correlation)
}

balanced_accuracy averages recall and specificity, so a majority-class predictor is pinned at 0.5 no matter how skewed the classes are. The Matthews correlation coefficient (mcc) goes further. It folds all 4 cells of the confusion matrix into a single correlation in [-1, 1]. It reads 0 for this degenerate model, because there is no association between prediction and truth at all. These 2 numbers show whether 95% accuracy is actually good.

5.2.3. The binary confusion matrix

ConfusionMatrix is a small Copy struct holding 4 counts: true positives, false positives, true negatives, and false negatives. Every scalar it exposes is derived from those 4 numbers. ConfusionMatrix::new(&y_true, &y_pred) takes 2 f64 arrays of hard labels. Every entry must be exactly 0.0 or 1.0. Nothing is binarized. A probability, an unbounded decision-function score, or a -1/+1 label makes the constructor panic instead of converting the value silently.

scikit-learn’s confusion_matrix also requires hard labels, for the same reason. Threshold the scores before the call. This also forces a clear choice of where the cutoff sits.

An earlier version of ConfusionMatrix::new binarized both arguments at a hardcoded 0.5. This silently corrupted a probabilistic ground truth. It also cut an unbounded score at a meaningless point, and it counted NaN as negative. Some code may depend on that old behavior. Add an explicit mapv(|p| if p >= 0.5 { 1.0 } else { 0.0 }) before the call to restore it.

For a different label pair, such as the -1/+1 an outside margin classifier emits, use ConfusionMatrix::new_with_labels(&y_true, &y_pred, negative_label, positive_label). This is the binary form of scikit-learn’s labels=[neg, pos]. RustyML’s own SVC and LinearSVC predict 0.0/1.0, so plain new already fits them. The 2 arguments accept independent storage types. Mixing an owned array with a view is fine, so ConfusionMatrix::new(&y_test, &model.predict(&x)?.view()) compiles.

use ndarray::array;
use rustyml::metrics::ConfusionMatrix;

fn main() {
    // Hard 0/1 labels: 5 real positives, 3 real negatives.
    let y_true = array![1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0];
    let y_pred = array![1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
    let cm = ConfusionMatrix::new(&y_true, &y_pred);

    let (tp, fp, tn, fn_) = cm.get_counts();
    println!("TP={tp} FP={fp} TN={tn} FN={fn_}"); // TP=3 FP=1 TN=2 FN=2
    println!("accuracy    {:.3}", cm.accuracy());
    println!("precision   {:.3}", cm.precision());   // 3/4
    println!("recall      {:.3}", cm.recall());      // 3/5
    println!("specificity {:.3}", cm.specificity()); // 2/3
    println!("f1          {:.3}", cm.f1_score());
    print!("{}", cm.summary());
}

get_counts() returns the raw (tp, fp, tn, fn) tuple. The derived accessors each return an f64: accuracy, error_rate (exactly 1 - accuracy), precision, recall, specificity, f1_score, mcc, and balanced_accuracy. Each metric has its own convention for a zero denominator, chosen on purpose:

  • precision and recall return 0.0 when the denominator is empty (no positive predictions, or no actual positives).
  • specificity returns 1.0 when there are no actual negatives. This 0/0 case counts as nothing to get wrong.
  • mcc returns 0.0 when any marginal sum is zero, because the coefficient is undefined there.

These conventions match the per-class conventions in the multi-class matrix, so the two stay consistent.

summary() renders the matrix and all 8 derived metrics as a formatted table, with each metric to 4 decimal places. It is meant for logs and notebooks. Do not parse the string. Treat it as output for a human reader.

Confusion Matrix:
+-----------------+--------------------+--------------------+
|                 | Predicted Positive | Predicted Negative |
+-----------------+--------------------+--------------------+
| Actual Positive | TP: 3              | FN: 2              |
| Actual Negative | FP: 1              | TN: 2              |
+-----------------+--------------------+--------------------+

Performance Metrics:
- Accuracy:          0.6250
- Balanced Accuracy: 0.6333
...

5.2.4. Precision, recall, and the tradeoff

Precision and recall answer different questions, and choosing which one to optimize is a domain decision, not a statistical one. Precision (TP / (TP + FP)) measures how many of the flagged samples are real. Recall (TP / (TP + FN)) measures how many of the real cases the model catches. The 2 metrics pull against each other, because both depend on the decision threshold. A lower threshold flags more samples: recall rises, because it misses fewer real cases, but precision falls, because more flags are false alarms. A higher threshold reverses the tradeoff.

In medical screening, a false negative can be fatal. You tune for high recall there and accept the false alarms that a follow-up test filters out. In fraud review, every flag costs an analyst’s time and annoys a legitimate customer. Precision matters more there, so you tolerate missing some fraud rather than drowning the team in false positives. No threshold is correct in the abstract. Only the threshold that matches the cost of the 2 error types is right.

f1_score combines precision and recall into their harmonic mean, 2PR / (P + R). The choice of the harmonic mean over the arithmetic mean is the point. The arithmetic mean of precision 1.0 and recall 0.0 is a flattering 0.5. The harmonic mean is 0.0 there, because it is dominated by its smaller input. F1 rewards a classifier only when both precision and recall are high, which fits a classifier that must avoid both false alarms and missed detections. When precision and recall are both 0, the crate returns 0.0 instead of dividing by 0.

F1 weights the 2 errors equally. When the errors are not equally costly, report precision and recall separately. Pick the threshold deliberately, instead of chasing the highest F1 score.

Precision, recall, and F1 are all methods on the confusion matrix: cm.precision(), cm.recall(), cm.f1_score(). There are no free precision_score(y_true, y_pred) functions, the way scikit-learn has them. The matrix walks the data once, then answers each question from 4 integers. A per-metric free function would recount the whole array on every call instead. Porting a scikit-learn script means collapsing a run of *_score calls into a single ConfusionMatrix, then reading the numbers off it.

Two more single-number summaries sit alongside them, also as methods on the matrix. cm.balanced_accuracy() is the mean of the 2 classes’ recalls. It is the honest counterweight to accuracy’s behavior under imbalance. A 9-to-1 classifier that reads 0.9 accuracy reads 0.5 here, the score a coin flip deserves. cm.mcc() is the Matthews correlation coefficient, a correlation between the true and the predicted labeling that runs from -1, through 0 at chance, to +1. It climbs only when all 4 cells of the matrix look good, which makes it the hardest of these numbers to inflate.

use ndarray::array;
use rustyml::metrics::ConfusionMatrix;

fn main() {
    let y_true = array![0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0];
    let y_pred = array![0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0];

    // One pass builds the matrix. Every metric after that just reads the counts.
    let cm = ConfusionMatrix::new(&y_true, &y_pred);
    println!("precision          {:.3}", cm.precision());
    println!("recall             {:.3}", cm.recall());
    println!("f1                 {:.3}", cm.f1_score());
    println!("balanced accuracy  {:.3}", cm.balanced_accuracy());
    println!("mcc                {:.3}", cm.mcc());

    // TP=3, FP=1, TN=3, FN=1: precision = 3/4 and recall = 3/4, so F1 is 3/4 too.
    let (tp, fp, tn, fn_) = cm.get_counts();
    assert_eq!((tp, fp, tn, fn_), (3, 1, 3, 1));
    assert!((cm.f1_score() - 0.75).abs() < 1e-12);
}

5.2.5. Threshold-free evaluation: ROC AUC and PR curves

Everything above uses a single fixed threshold. Sometimes you want to evaluate the ranking a model produces, independent of where you eventually cut it. ROC AUC and the precision-recall curve give you that view. These functions take bool labels (true for the positive class) and f64 scores. The scores can be probabilities or any monotone decision value.

roc_auc(&labels, &scores) returns the area under the ROC curve, computed with the Mann-Whitney U statistic. This gives it a clean interpretation: it is the probability that a randomly chosen positive sample scores above a randomly chosen negative one. A score of 1.0 is a perfect ranking, and a score of 0.5 is a coin flip. A score below 0.5 means the model ranks samples backwards. Tied scores receive their average rank. A model whose scores are all equal therefore lands at exactly 0.5, instead of a result that depends on array order.

AUC does not need a threshold, and it does not depend on class balance. Its weakness shows on heavily imbalanced data. A large negative class can make the curve look good there, while precision at any usable threshold stays poor. This is why you also check average precision.

average_precision(&labels, &scores) is the area under the precision-recall curve, computed as the precision-weighted sum of recall increments. On imbalanced problems, it gives a more honest headline number, because its baseline is the positive rate, not a fixed 0.5. It does not get an easy boost from an easy negative class.

use ndarray::array;
use rustyml::metrics::{average_precision, precision_recall_curve, roc_auc, roc_curve};

fn main() {
    // Scores for 6 samples. `true` marks the positive class.
    let labels = array![true, false, true, false, true, false];
    let scores = array![0.95, 0.4, 0.7, 0.3, 0.6, 0.2];

    println!("ROC AUC:      {:.3}", roc_auc(&labels, &scores));
    println!("avg precision {:.3}", average_precision(&labels, &scores));

    // Full sweep: (fpr, tpr, thresholds), all equal length, starting at the (0,0) origin.
    let (fpr, tpr, thresholds) = roc_curve(&labels, &scores);
    println!("ROC points: {}", fpr.len());
    println!("first tpr={:.2} fpr={:.2}", tpr[0], fpr[0]);
    assert_eq!(thresholds[0], f64::INFINITY); // the origin classifies nothing as positive

    // precision/recall carry 1 extra closing point beyond the thresholds,
    // and run in ascending-threshold / descending-recall order.
    let (precision, recall, pr_thresholds) = precision_recall_curve(&labels, &scores);
    assert_eq!(precision.len(), pr_thresholds.len() + 1);
    assert_eq!(precision[precision.len() - 1], 1.0); // closing point: recall 0, precision 1
    assert_eq!(recall[recall.len() - 1], 0.0);
}

roc_curve returns (fpr, tpr, thresholds) as 3 equal-length Array1<f64> arrays. There is 1 point per distinct score, in decreasing order, prefixed with the (0, 0) origin. The origin’s threshold is f64::INFINITY, the only value that classifies nothing as positive. Unlike a finite max_score + 1.0, infinity stays distinguishable from the top real threshold even when scores are large (1e17 + 1.0 == 1e17). This matches scikit-learn.

One difference remains, and it is deliberate. RustyML always returns the full sweep, so it keeps every collinear interior point. scikit-learn’s default, drop_intermediate=True, discards those points instead. RustyML’s point count can therefore be larger, while the curve itself, and roc_auc, stay identical. Integrating this curve with the trapezoid rule reproduces roc_auc exactly.

precision_recall_curve returns (precision, recall, thresholds) in the opposite order. Thresholds ascend, so recall descends along the arrays. The final (precision = 1, recall = 0) closing point sits at the low-recall end, where it belongs. precision and recall are therefore 1 element longer than thresholds. Watch for that off-by-1 difference when you zip the arrays. scikit-learn makes the same ordering distinction between the 2 curve functions, and the output matches element for element.

An earlier version appended the closing point at the high-recall end. This left recall monotone in neither direction. Code that depends on a particular orientation needs a check against the current behavior.

All 4 ranking functions reject NaN scores with a panic (scores must not contain NaN). This is not pedantry. f64::total_cmp sorts NaN as the most extreme value, so a stray NaN would silently count as the most confident prediction and corrupt the ranking. roc_auc and roc_curve also require at least 1 positive and 1 negative label. average_precision and precision_recall_curve require at least 1 positive label. A single-class input gives a degenerate curve, so it panics instead of returning a meaningless number.

These functions are binary-only. The crate has no one-vs-rest or macro-averaged multi-class AUC. For per-class ROC on a multi-class problem, binarize each class, then call roc_auc once per class.

5.2.6. The multi-class confusion matrix and averaging

For more than 2 classes, use MulticlassConfusionMatrix. It takes usize labels for both truth and predictions. Its class axis is the sorted union of every label seen in either input. A class that appears only in the predictions (a hallucinated class) still gets a row and a column. matrix() exposes the full K x K count grid as an ArrayView2<usize>, with rows indexed by true class and columns by predicted class. labels() gives the label at each index, and n_classes() gives the dimension.

use ndarray::array;
use rustyml::metrics::{Average, MulticlassConfusionMatrix};

fn main() {
    let y_true = array![0usize, 1, 2, 2, 1, 0, 2];
    let y_pred = array![0usize, 2, 2, 2, 1, 0, 1];
    let cm = MulticlassConfusionMatrix::new(&y_true, &y_pred);

    println!("classes:  {:?}", cm.labels());            // [0, 1, 2]
    println!("support:  {:?}", cm.support());           // true-sample count per class
    println!("accuracy: {:.3}", cm.accuracy());
    println!("recall:   {:?}", cm.per_class_recall());

    // Aggregation strategy is an explicit argument, not a hidden default.
    println!("macro F1:    {:.3}", cm.f1(Average::Macro));
    println!("micro F1:    {:.3}", cm.f1(Average::Micro));
    println!("weighted F1: {:.3}", cm.f1(Average::Weighted));

    // For the per-class numbers, read per_class_*. They return a Vec<f64> in label order.
    println!("per-class F1: {:?}", cm.per_class_f1());

    print!("{}", cm.summary());              // count grid + per-class report
}

The per-class views, per_class_precision, per_class_recall, and per_class_f1, return a Vec<f64> in label order. They use the same zero-denominator convention as the binary matrix: 0.0 for a class that is never predicted or never true. support() returns the number of ground-truth samples per class. The weighted average uses that count.

The aggregated precision, recall, and f1 methods each take an Average argument. This is where the macro, micro, and weighted distinction matters:

  • Average::Macro is the unweighted mean of the per-class scores. Every class counts equally, regardless of its size, so a rare but important class is not drowned out by a common one. Report this on imbalanced multi-class problems.
  • Average::Weighted weights each per-class score by that class’s support. It measures performance on a typical sample, and it tracks accuracy more closely than macro averaging does.
  • Average::Micro pools the counts across all classes before it computes the metric. This type supports only single-label classification: exactly 1 predicted class per sample. There, micro precision, micro recall, and micro F1 collapse to the same value: accuracy. The implementation returns accuracy directly for Micro. Micro F1 and accuracy are the same number by construction in a single-label setting. A different reported value is a mistake.

Those 3 variants are the whole set. There is no counterpart to scikit-learn’s average="binary". For a single class’s one-vs-rest number, index into per_class_precision, per_class_recall, or per_class_f1 by label position. cm.labels() gives that exact order.

summary() prints the count grid, followed by a scikit-learn-style per-class report with a macro avg and weighted avg footer. It sizes the table to the labels and counts present. This is also the type’s only reporting entry point. There is no free-function version.

5.2.7. Probability-based and agreement metrics

3 more functions complete the module. They take probabilities or paired labelings, not a confusion matrix.

log_loss(&y_true, &y_prob) computes multi-class cross-entropy: y_true holds each sample’s true class index as a usize. y_prob is an Array2<f64> with 1 row per sample and 1 column per class. Only the probability assigned to the true class contributes to the score. Each row is renormalized to sum to 1 before scoring, so a row that is not already a normalized distribution is still handled consistently. The selected probability is then clamped away from 0 and 1, so the logarithm stays finite. A confidently wrong prediction therefore gets a large but finite penalty instead of +inf.

Lower is better.

top_k_accuracy(&y_true, &y_prob, k) counts a sample as correct if its true class is among the k highest-probability classes. A class ties into the top-k set if fewer than k classes are strictly more probable. A boundary tie therefore counts in the sample’s favor. Report this metric when a correct answer within the top 5 classes is an acceptable bar. It panics if k == 0, if a label is out of range for the probability columns, or if y_prob contains NaN. A NaN true-class probability would defeat the p > true_prob comparison and miscount the sample as a hit.

cohen_kappa(&y_true, &y_pred) measures agreement between 2 labelings, corrected for chance. The formula is (p_o - p_e) / (1 - p_e). Here, p_o is the observed agreement (accuracy), and p_e is the agreement expected from the marginal label frequencies alone. It runs from -1, through 0 at chance, to 1 at perfect agreement. It shows whether the accuracy is actually better than a model that guesses in proportion to the class frequencies. That is a sharper question than raw accuracy answers on skewed data.

use ndarray::array;
use rustyml::metrics::{cohen_kappa, log_loss, top_k_accuracy};

fn main() {
    let y_true = array![0usize, 1, 2];
    // Row i = predicted class distribution for sample i.
    let y_prob = array![
        [0.8, 0.1, 0.1],
        [0.1, 0.7, 0.2],
        [0.2, 0.2, 0.6],
    ];

    println!("log loss:  {:.3}", log_loss(&y_true, &y_prob));    // lower is better
    println!("top-2 acc: {:.3}", top_k_accuracy(&y_true, &y_prob, 2));

    // cohen_kappa compares 2 hard labelings, not probabilities.
    let y_pred = array![0usize, 1, 1];
    println!("kappa:     {:.3}", cohen_kappa(&y_true, &y_pred));
}

5.2.8. End-to-end: evaluating a logistic regression classifier

This section puts the metrics together with the logistic regression model from Chapter 2. LogisticRegression::predict already returns hard {0.0, 1.0} labels as an Array1<f64>, exactly what ConfusionMatrix and accuracy want. The only conversion left is the bool label array that roc_auc needs, alongside the predict_proba scores.

use ndarray::{array, Array1};
use rustyml::machine_learning::LogisticRegression;
use rustyml::metrics::{accuracy, roc_auc, ConfusionMatrix};

fn main() {
    // 2 well-separated clusters in 2-D feature space.
    let x_train = array![
        [1.0, 1.0], [1.5, 2.0], [2.0, 1.5],
        [6.0, 5.0], [5.5, 6.5], [6.5, 5.5]
    ];
    let y_train = array![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];

    let mut model = LogisticRegression::new(true, 0.5, 500, 1e-6).unwrap();
    model.fit(&x_train, &y_train).unwrap();

    // Held-out test set with known labels.
    let x_test = array![[1.2, 1.4], [2.2, 1.8], [5.8, 6.0], [6.2, 5.2]];
    let y_test = array![0.0, 0.0, 1.0, 1.0];

    // predict -> hard {0.0, 1.0} labels, ready for the label-based metrics.
    let y_pred: Array1<f64> = model.predict(&x_test).unwrap();

    println!("accuracy: {:.3}", accuracy(&y_test, &y_pred));

    let cm = ConfusionMatrix::new(&y_test, &y_pred);
    print!("{}", cm.summary());

    // Ranking quality from the raw probabilities: bool labels + f64 scores.
    let scores = model.predict_proba(&x_test).unwrap();
    let labels = y_test.mapv(|v| v >= 0.5);
    println!("ROC AUC: {:.3}", roc_auc(&labels, &scores));
}

This data is cleanly separable, so the model classifies the test set perfectly, and every metric reads 1.0. This is a useful check that the pipeline works, but the interesting decisions happen elsewhere. To study the precision/recall tradeoff on a real classifier, feed the predict_proba output into roc_curve and precision_recall_curve from section 5.2.5. Inspect the whole sweep, instead of committing to the model’s built-in 0.5 threshold.

This is the difference between reporting a single accuracy number and reporting the chosen operating point together with the error it accepts. The second report is the one that survives review. When labels arrive as strings or categories rather than 0.0/1.0, convert them first with label encoding. This gives them the f64 or usize form these metrics expect.

5.3. Clustering Metrics

Clustering has no residual to square and no confusion matrix, unlike regression and classification. A clustering never names its groups. It only splits the samples into parts. This fact shapes every metric on this page. External metrics compare your partition against a ground-truth partition. Internal metrics score the geometry of the partition alone. Both kinds must ignore the names of the clusters. RustyML’s clustering metrics live in rustyml::metrics::clustering. They are re-exported flat from rustyml::metrics, and also through the prelude. One line, use rustyml::metrics::*;, brings every function below into scope.

Like the rest of the metrics leaf module, these functions panic on precondition violations. Examples are mismatched lengths, empty input, and an out-of-range cluster count. They do not return the crate’s Error type. This is a deliberate choice. The metrics module depends only on ndarray and ahash. It mirrors ndarray’s own rule of panicking on a dimension mismatch, instead of adding the error machinery from 1.6. Error Handling. Validate your labels before you pass them to a metric, if a panic would be fatal in your program.

5.3.1. Two families of metric

The external metrics take two label arrays, labels_true and labels_pred, both of type isize. They measure how well the predicted partition reproduces the reference partition. Every metric on this page takes isize labels, the same type scikit-learn’s labels_ uses. This lets any clustering estimator in the crate feed any metric on this page. KMeans, Mean Shift, and DBSCAN all return Array1<isize>, so no cast is ever needed. DBSCAN’s and Mean Shift’s -1 noise label still needs attention (see 5.3.7), but it needs no type conversion. The internal metrics take the feature matrix x plus one label array. They score the partition against the geometry of the data, with no ground truth at all. Use internal metrics when you cluster unlabeled data, the usual case. Use external metrics when you have a reference partition and want to benchmark an algorithm against it.

FunctionFamilyInputsRangePerfect scoreChance-corrected
adjusted_rand_indexexternaltwo isize label arrays[-0.5, 1.0]1.0yes
adjusted_mutual_infoexternaltwo isize label arrays[-1.0, 1.0] typ.1.0yes
normalized_mutual_infoexternaltwo isize label arrays[0.0, 1.0]1.0no
v_measure_scoreexternaltwo isize label arrays[0.0, 1.0]1.0no
homogeneity_scoreexternaltwo isize label arrays[0.0, 1.0]1.0no
completeness_scoreexternaltwo isize label arrays[0.0, 1.0]1.0no
fowlkes_mallows_scoreexternaltwo isize label arrays[0.0, 1.0]1.0no
silhouette_scoreinternalx, labels, metric[-1.0, 1.0]1.0 (higher better)n/a
davies_bouldin_scoreinternalx, labels>= 0.00.0 (lower better)n/a
calinski_harabasz_scoreinternalx, labels>= 0.0higher bettern/a

All the external metrics are symmetric except 2. Swapping labels_true and labels_pred leaves the score unchanged for the rest. The 2 exceptions are homogeneity_score and completeness_score. They are duals of each other, so swapping the arguments swaps the two scores. Their harmonic mean, v_measure_score, is symmetric again.

5.3.2. Why label matching is the wrong tool

Classification metrics give you the instinct to line up two label vectors element by element and count the matches. This instinct fails for clustering, because cluster identifiers are arbitrary. Suppose one run labels a group 0 and another run labels the same group 7. Both describe the same partition. An element-wise “accuracy” still reports total disagreement. This is not a rare edge case. It happens every time you rerun KMeans with a different seed, because KMeans numbers its clusters by initialization order.

The metrics on this page avoid the problem. They work from the contingency table of the two labelings: how many samples fall into each (true cluster, predicted cluster) cell. They also work from pair-agreement counts derived from that table. Both stay unchanged under any renaming of the clusters. The example below swaps the names of the two clusters in a partition. A naive accuracy score collapses to 0. ARI and NMI stay pinned at 1.0.

use ndarray::array;
use rustyml::metrics::{adjusted_rand_index, normalized_mutual_info};

fn main() {
    let truth = array![0isize, 0, 1, 1];
    // Same partition, cluster names swapped: {0,1} -> label 1, {2,3} -> label 0.
    let relabeled = array![1isize, 1, 0, 0];

    // Element-wise "accuracy" collapses to 0 even though the grouping is identical.
    let naive_accuracy = truth
        .iter()
        .zip(relabeled.iter())
        .filter(|&(a, b)| a == b)
        .count() as f64
        / truth.len() as f64;

    let ari = adjusted_rand_index(&truth, &relabeled);
    let nmi = normalized_mutual_info(&truth, &relabeled);

    println!("naive accuracy = {naive_accuracy}"); // 0.0
    println!("ARI = {ari}, NMI = {nmi}"); // 1.0, 1.0

    assert_eq!(naive_accuracy, 0.0);
    assert!((ari - 1.0).abs() < 1e-12);
    assert!((nmi - 1.0).abs() < 1e-12);
}

This permutation invariance means you never need to solve an assignment problem before scoring, such as Hungarian matching of predicted clusters to true classes. The metric already handles that step.

5.3.3. External metrics and chance correction

A subtler trap exists. A metric can be permutation-invariant and still mislead you, because random labelings do not score 0. The Rand index counts the fraction of the C(n, 2) sample pairs where two partitions agree. Agreement means both partitions group the pair together, or both partitions separate the pair. The expected value of the Rand index under random labeling is not 0. It increases as you add clusters. A bare Rand index of 0.7 means nothing on its own. The adjusted Rand index subtracts that expected value and rescales the result. Independent labelings then score about 0.0. A perfect match scores 1.0. Agreement that is systematically worse than random can go negative, down to about -0.5.

Mutual information has the same problem, in a sharper form. MI keeps rising as you cut the data into more clusters. It reaches the full entropy when every sample forms its own cluster. Comparing raw MI across candidate values of K is therefore meaningless. adjusted_mutual_info subtracts the expected mutual information (EMI). RustyML computes EMI exactly, under a hypergeometric model of random partitions with the same cluster sizes. It evaluates every binomial coefficient in log space, from a shared log-factorial table. AMI is therefore the mutual-information analogue of ARI. It scores about 0.0 for independent labelings, 1.0 for identical ones, and occasionally a small negative value.

normalized_mutual_info does not correct for chance. It only rescales MI into [0.0, 1.0]. It divides MI by the arithmetic mean of the two clusterings’ entropies, (H_true + H_pred) / 2. This normalizer is a fixed convention in RustyML. There is no average_method switch like the one in scikit-learn. With this normalizer, NMI is numerically identical to v_measure_score, the harmonic mean of homogeneity and completeness. This gives a rule of thumb. When the two clusterings have different numbers of clusters, use adjusted_rand_index or adjusted_mutual_info. Reserve NMI and V-measure for comparisons at a fixed K, where the uncorrected bias stays constant and cancels out.

The three functions share one signature. Only the meaning of the score differs.

pub fn adjusted_rand_index<S>(labels_true: &ArrayBase<S, Ix1>, labels_pred: &ArrayBase<S, Ix1>) -> f64
where S: Data<Elem = isize>;
// identical for adjusted_mutual_info and normalized_mutual_info

The degenerate cases differ between these functions. This matters when a clustering trivially puts every point in one cluster. adjusted_rand_index returns 1.0 when there are fewer than 2 samples, because there are no pairs to disagree on. It also returns 1.0 when its normalizer vanishes. adjusted_mutual_info returns 1.0 when its own normalizer is degenerate. normalized_mutual_info returns 0.0 whenever either partition has a single cluster, because zero entropy makes its denominator zero. Do not read these constants as quality judgments. They are only the defined values for inputs where the ratio is 0/0.

use ndarray::array;
use rustyml::metrics::{adjusted_mutual_info, adjusted_rand_index, normalized_mutual_info};

fn main() {
    let labels_true = array![0isize, 0, 1, 1, 2, 2];
    let labels_pred = array![0isize, 0, 1, 2, 1, 2]; // one true cluster split in two

    // Verified against the closed forms: ARI = 1/6 ~= 0.167, AMI = 1/6, NMI ~= 0.579.
    println!("ARI = {:.4}", adjusted_rand_index(&labels_true, &labels_pred));
    println!("AMI = {:.4}", adjusted_mutual_info(&labels_true, &labels_pred));
    println!("NMI = {:.4}", normalized_mutual_info(&labels_true, &labels_pred));

    // Independent labelings: ARI and AMI hit their chance-corrected floor of about -0.5.
    // NMI also reaches 0 here, because MI is exactly 0 for independent partitions.
    let a = array![0isize, 0, 1, 1];
    let b = array![0isize, 1, 0, 1];
    assert!((adjusted_rand_index(&a, &b) - (-0.5)).abs() < 1e-9);
    assert!((adjusted_mutual_info(&a, &b) - (-0.5)).abs() < 1e-9);
    assert!(normalized_mutual_info(&a, &b).abs() < 1e-12);
}

The remaining external metrics add detail. They do not replace ARI or AMI. homogeneity_score asks whether each predicted cluster is pure, meaning it contains only one true class. completeness_score is its dual: it asks whether each true class stays in one cluster. v_measure_score is the harmonic mean of the two. fowlkes_mallows_score is the geometric mean of pairwise precision and recall. All 4 metrics range over [0.0, 1.0]. All 4 are based on entropy or pair ratios, so none of them are chance-corrected. The same fixed-K caveat from above applies to all 4.

5.3.4. The silhouette score

Clustering usually gives you no ground truth. The silhouette score is the main tool for this case. For each sample, it computes a, the mean distance to the other members of the sample’s own cluster. It also computes b, the mean distance to the members of the nearest other cluster. It combines the two into this formula.

s = (b - a) / max(a, b)

The score s runs from -1 to +1. A score of -1 means the sample sits closer to a neighboring cluster than to its own, so it is likely misassigned. A score of 0 means the sample lies on the boundary between clusters. A score of +1 means a tight own cluster and distant neighbors, so the sample is well clustered. silhouette_score returns the mean of s over all samples. Two edge cases matter in practice. A sample that is the only member of its cluster contributes 0, because there is no a to compute. When every point coincides, so a = b = 0 everywhere, the score is 0 rather than NaN.

pub fn silhouette_score<S1, S2>(
    x: &ArrayBase<S1, Ix2>,
    labels: &ArrayBase<S2, Ix1>,
    metric: DistanceCalculationMetric,
) -> f64
where S1: Data<Elem = f64> + Sync, S2: Data<Elem = isize>;

The metric argument goes through DistanceCalculationMetric, the same dispatch point the estimators use. Euclidean, Manhattan, and Minkowski(p) all work. The metric genuinely changes the result, not just its label. Pass DistanceCalculationMetric::Euclidean for the conventional silhouette. The enum has its own Default value, but this function does not use it. You must name the metric yourself.

silhouette_score panics in 4 cases. The row count of x differs from the length of labels. The input is empty. The number of distinct clusters falls outside 2..=n_samples - 1. A single cluster has no b to compute, and a partition of all singletons has no a to compute. The fourth case is passing Minkowski(p) with p < 1. The internal davies_bouldin_score and calinski_harabasz_score enforce the same length, non-empty, and cluster-count bounds. Neither function takes a metric parameter, so the Minkowski(p) check does not apply to them.

use ndarray::array;
use rustyml::math::DistanceCalculationMetric;
use rustyml::metrics::silhouette_score;

fn main() {
    // 2 tight, well-separated 2-D clusters (not collinear, so the metric matters).
    let x = array![[0.0, 0.0], [0.0, 1.0], [10.0, 10.0], [10.0, 11.0]];
    let labels = array![0isize, 0, 1, 1];

    let euclidean = silhouette_score(&x, &labels, DistanceCalculationMetric::Euclidean);
    let manhattan = silhouette_score(&x, &labels, DistanceCalculationMetric::Manhattan);
    println!("euclidean silhouette = {euclidean:.4}");
    println!("manhattan silhouette = {manhattan:.4}");

    assert!(euclidean > 0.8 && euclidean <= 1.0);
    assert!(manhattan > 0.8 && manhattan <= 1.0);
    // Different metrics genuinely give different scores on these points.
    assert!((euclidean - manhattan).abs() > 1e-3);
}

5.3.5. Cost and the parallel fill

The silhouette score has a cost for its thoroughness. Computing every a and b needs the distance from each sample to every other sample. For n samples in d dimensions, this computation is inherently O(n^2 * d), quadratic in the number of points. davies_bouldin_score and calinski_harabasz_score cost much less, because they only touch centroids. davies_bouldin_score runs an O(n) scan of each point against its own centroid, plus an O(k^2) loop over centroid pairs. calinski_harabasz_score runs a single O(n) scan, with no pairwise centroid loop at all. The silhouette’s quadratic cost dominates on any dataset of real size. This is where RustyML spends most of its engineering effort.

Two techniques keep the silhouette’s cost manageable. First, the implementation never builds the full n x n distance matrix. It accumulates a compact dist_to_cluster[[i, c]] table instead, holding the total distance from sample i to each cluster c. Memory use is O(n * k), not O(n^2). Second, the implementation uses the symmetry d(i, j) = d(j, i). It scans only the upper triangle of the distance matrix, which halves the number of metric evaluations compared to a full scan. This halving matters more as the metric gets more expensive. Manhattan is cheapest. Euclidean adds a square root. Minkowski(p) adds a powf call and costs the most.

Above a work threshold, the upper-triangle fill runs in parallel. The gate is measured in scanned elements, scan_work = n * n * d. When scan_work reaches SILHOUETTE_PARALLEL_MIN_ELEMS (default 262_144), the rows are dealt round-robin into rayon’s current_num_threads() buckets. Row i does n - 1 - i pair evaluations, so round-robin dealing balances the buckets better than a contiguous split would. Each bucket folds into its own accumulator, and the buckets are then summed in a fixed order. This fixed grouping makes the parallel result reproducible on the same machine across runs. The parallel result equals the serial fill numerically, though not always bit-for-bit. Below the gate, the serial path runs and matches a full scan bit-for-bit. A dedicated benchmark covers this fill.

cargo bench --bench silhouette

If the default crossover point is wrong for your hardware or data shape, change it at runtime. Use the tuning facade, rustyml::tuning::metrics::set_silhouette(value) and get_silhouette(). 7.3. Performance Tuning and Parallelism covers this mechanism and the reasoning behind parallel gates in general.

5.3.6. Picking K with a silhouette sweep

KMeans needs the number of clusters, K, chosen before it runs. The silhouette score is the best-known tool for choosing K. Fit the model for each candidate K. Score the resulting partition. Keep the K with the highest mean silhouette. KMeans returns its labels as Array1<isize>, which feeds silhouette_score directly, with no conversion.

use ndarray::array;
use rustyml::machine_learning::KMeans;
use rustyml::math::DistanceCalculationMetric;
use rustyml::metrics::silhouette_score;

fn main() {
    // 3 compact, well-separated blobs, 4 points each.
    let x = array![
        [0.0, 0.0], [0.2, 0.1], [0.1, 0.2], [0.0, 0.3],
        [5.0, 5.0], [5.2, 5.1], [5.1, 5.2], [5.0, 5.3],
        [0.0, 5.0], [0.2, 5.1], [0.1, 5.2], [0.0, 5.3],
    ];

    let mut best_k = 0usize;
    let mut best_score = f64::NEG_INFINITY;

    for k in 2..=5 {
        let labels = KMeans::new(k, 100, 1e-4)
            .unwrap()
            .with_random_state(42) // fixed seed for a reproducible sweep
            .fit_predict(&x)
            .unwrap();

        // silhouette_score needs 2..=n-1 distinct clusters. Skip a fit that collapsed a cluster.
        let mut distinct = labels.to_vec();
        distinct.sort_unstable();
        distinct.dedup();
        if distinct.len() < 2 {
            continue;
        }

        let s = silhouette_score(&x, &labels, DistanceCalculationMetric::Euclidean);
        println!("k = {k}: silhouette = {s:.4}");
        if s > best_score {
            best_score = s;
            best_k = k;
        }
    }

    println!("best k = {best_k} (silhouette = {best_score:.4})");
    assert_eq!(best_k, 3); // the 3 real blobs win
}

The fixed with_random_state(42) call makes the sweep reproducible. A different seed can move the KMeans initialization, and at the margin, it can change the winning K. See 7.1. Reproducibility and Random Seeds. The distinct.len() < 2 guard matters for a specific reason. KMeans can return an empty cluster, which would drop the distinct count below the silhouette’s lower bound and cause a panic. Skipping such a fit is cheaper and clearer than catching the panic. For a faster sweep on large n, run davies_bouldin_score (lower is better) or calinski_harabasz_score (higher is better) instead. davies_bouldin_score costs an O(n) scan plus an O(k^2) loop over centroid pairs. calinski_harabasz_score costs a single O(n) scan. Both avoid the silhouette’s quadratic cost, at the price of a coarser view of cluster shape that looks only at centroids.

5.3.7. Handling DBSCAN’s noise sentinel

Labels from DBSCAN, or from Mean Shift with cluster_all = false, type-check against every metric on this page without a cast. Both the estimators and the metrics use isize. This removes the mechanical friction, but not the semantic problem. None of these functions has any notion of a noise sentinel. Each distinct label value counts as a full cluster, so a -1 becomes an artificial noise cluster. For the silhouette score, this means the scattered noise points get scored as if they formed a real group. This is almost never what you want. (scikit-learn’s silhouette score behaves the same way.)

The clean approach is to drop the noise rows before scoring. Subset both x and the labels down to the points DBSCAN actually clustered.

use ndarray::{array, Array1, Axis};
use rustyml::machine_learning::DBSCAN;
use rustyml::math::DistanceCalculationMetric;
use rustyml::metrics::silhouette_score;

fn main() {
    // 2 dense blobs plus one far-flung outlier.
    let x = array![
        [0.0, 0.0], [0.1, 0.0], [0.0, 0.1], [0.1, 0.1],
        [5.0, 5.0], [5.1, 5.0], [5.0, 5.1], [5.1, 5.1],
        [50.0, 50.0], // noise
    ];

    // eps = 1.0, min_samples = 3: each blob is a core cluster, the outlier is noise (-1).
    let labels = DBSCAN::new(1.0, 3).unwrap().fit_predict(&x).unwrap();

    // Keep only clustered rows (label >= 0). No cast: the labels are already isize.
    let keep: Vec<usize> = labels
        .iter()
        .enumerate()
        .filter(|&(_, &l)| l >= 0)
        .map(|(i, _)| i)
        .collect();

    let x_clustered = x.select(Axis(0), keep.as_slice());
    let labels_clustered = Array1::from_iter(keep.iter().map(|&i| labels[i]));

    let s = silhouette_score(
        &x_clustered,
        &labels_clustered,
        DistanceCalculationMetric::Euclidean,
    );
    println!("silhouette over non-noise points = {s:.4}"); // ~1.0, 2 clean blobs
    assert!(s > 0.9);
}

Filtering answers the usual question: how well separated are the points that formed real clusters. It also hides how much data was discarded, so report the noise fraction next to the score. An alternative is to keep the noise points under their own -1 label. This is defensible only for the external metrics. ARI, AMI, and NMI will then compare a noise class against your ground truth like any other cluster. This is a coherent evaluation, though a strict one. The silhouette score does not support this alternative. A diffuse cloud of noise is not a cluster, and scoring it as one only muddies the result.

6. Math Utilities

The math module is the numeric base the rest of RustyML stands on. It holds the pairwise distance kernels, the gemm-backed matrix products, and the deterministic parallel reductions that every estimator, neural-network layer, and metric calls underneath. Most of the time you use these primitives without naming them. KNN reaches for a distance kernel. A Dense layer reaches for a GEMM. A variance computation reaches for a blocked reduction. RustyML exports the callable primitives, so you can use them directly when you build something the higher-level API does not cover. The tunable primitives expose knobs you can fit to your hardware. The module compiles under the math feature. Any of machine_learning, neural_network, utils, or metrics turns math on transitively, and full includes it too. If you use RustyML at all, these primitives are already built. See Installation and Feature Flags.

One theme runs through all 3 sections. These primitives run in parallel, but stay reproducible. Each one decides serial-versus-rayon by comparing a work estimate against a calibrated threshold. The reductions never let that choice change the result. A reduction is bit-identical either way. The matrix products reproduce run to run, though a flipped strategy can move the last few bits (see 6.2.4). That property is what makes the knobs in Performance Tuning and Parallelism safe to move. It also backs the guarantees in Reproducibility and Random Seeds. Be comfortable with ndarray’s Array1/Array2 and views before you read on. Working with ndarray covers what you need. Read the sections in order. 6.1 and 6.3 give you functions you can call today. 6.2 is mostly context for a backend you invoke indirectly.

6.1. Distance Metrics

Distance Metrics covers 3 allocation-free per-row kernels: squared_euclidean_distance_row, manhattan_distance_row, and minkowski_distance_row. It also covers the DistanceCalculationMetric enum layered on top of them. DistanceCalculationMetric is the single dispatcher that KNN, DBSCAN, and the silhouette score all share. It turns the choice of metric into a runtime value, instead of a hard-coded match. The kernels are the fast path, and the Euclidean one deliberately skips the square root. The enum is the convenient path. Read this section first. It is the most directly usable part of the chapter.

use ndarray::array;
use rustyml::math::{DistanceCalculationMetric, squared_euclidean_distance_row};

fn main() {
    let a = array![0.0_f64, 0.0];
    let b = array![3.0_f64, 4.0];

    // The raw kernel returns the *squared* distance. It never takes the root.
    println!("squared: {}", squared_euclidean_distance_row(&a, &b)); // 25.0

    // The dispatcher takes the root and lets the metric vary at runtime.
    let metric = DistanceCalculationMetric::Euclidean;
    println!("euclidean: {}", metric.distance(a.view(), b.view())); // 5.0
}

6.2. Matrix Multiplication

Matrix Multiplication explains the gemmkit backend behind every dense product in the library. It covers how gemmkit picks between shape-specialized routes. It covers how gemmkit decides, on its own, whether a product is worth threading and how wide to thread it. It also covers why a matrix-vector product gets its own cost class. It explains what it means for a result to stay bit-for-bit identical, no matter how many workers ran it. You rarely call this layer by name. It sits under the linear models and the dense and convolution layers. This section explains the strategy more than an API to call. It also names the knobs that are yours to turn. One is the caller-side tiling policy in rustyml::tuning::matmul. The other is the backend’s own GEMMKIT_* knobs, re-exported through it. Read this section when a model’s throughput matters to you.

6.3. Parallel Reductions

Parallel Reductions covers det_reduce and det_reduce_range. These are blocked folds. They give a sum, a dot product, or a per-bucket accumulator the same bits, whether they run on one thread or on all of them. A bare par_iter().sum() reorders its float additions however rayon happens to steal work. These helpers instead cut the input into fixed DET_REDUCE_BLOCK-sized chunks. The grouping, and therefore the rounding, never depends on scheduling. Reach for these helpers whenever you write your own parallel numeric loop, and want a result that does not drift between runs or thread counts.

6.1. Distance Metrics

Distance is a basic idea behind most classical machine learning. k-nearest neighbors ranks candidates by distance. DBSCAN grows a cluster by testing distance against a threshold. Silhouette scoring averages distances. k-means minimizes a squared distance.

RustyML defines all of this in one small module, crate::math::distance. The module holds the only definition of “how far apart are two points”. Every metric-aware estimator shares the same dispatcher. This page documents the public surface: what exists, what does not exist, and where the numerical shortcuts live.

The module has two layers. The bottom layer holds 3 free functions. These are allocation-free kernels, and each works on one pair of vectors at a time. The top layer is DistanceCalculationMetric, a small enum. The enum names a metric and dispatches to the kernels.

Estimators store the enum, not a function pointer, because the enum is Copy, it supports serde serialization, and a match on it costs little. Use the kernels directly to build your own nearest-neighbor logic. Use the enum to get the same metric abstraction the library uses.

6.1.1. The 3 row kernels

All 3 kernels are re-exported from rustyml::math. Their names matter: there is no function named euclidean_distance_row. The Euclidean kernel is named squared_euclidean_distance_row. It returns the sum of squared differences and never takes a square root.

This is not an oversight. It is the whole design. Callers that need only ordering, such as finding the nearest point, testing a radius, or finding the closest centroid, never need the root. Taking a sqrt for every pair would waste work. To get the true Euclidean distance, take the root yourself, or use the dispatcher.

pub fn squared_euclidean_distance_row<S1, S2>(x1: &ArrayBase<S1, Ix1>, x2: &ArrayBase<S2, Ix1>) -> f64;
pub fn manhattan_distance_row<S1, S2>(x1: &ArrayBase<S1, Ix1>, x2: &ArrayBase<S2, Ix1>) -> f64;
pub fn minkowski_distance_row<S1, S2>(x1: &ArrayBase<S1, Ix1>, x2: &ArrayBase<S2, Ix1>, p: f64) -> f64;
// where S1: Data<Elem = f64>, S2: Data<Elem = f64>

The input types accept both views and slices. Each function takes a reference to a 1-D ndarray array. Each function is generic over storage, through S: Data<Elem = f64>. An owned Array1<f64>, a borrowed ArrayView1<f64>, and a row of a matrix, such as &data.row(i), all work without change.

You do not copy a row into a Vec<f64> first. The element type is fixed at f64. There is no f32 path. S1 and S2 are independent type parameters, so the two arguments can use different storage types. You can compare an owned query vector against a matrix row without a problem.

None of the 3 functions panics by itself on a length mismatch. ndarray’s Zip requires equal lengths and panics there if you break that rule. Treat equal dimensionality as a precondition that you must keep true.

use ndarray::array;
use rustyml::math::{
    manhattan_distance_row, minkowski_distance_row, squared_euclidean_distance_row,
};

fn main() {
    let a = array![1.0, 2.0, 3.0];
    let b = array![4.0, 6.0, 8.0];

    // Squared L2. No square root taken. Take the root yourself for the metric distance.
    let sq = squared_euclidean_distance_row(&a, &b);
    let euclidean = sq.sqrt();

    let l1 = manhattan_distance_row(&a, &b);
    let l3 = minkowski_distance_row(&a, &b, 3.0);

    // Minkowski is a superset: p = 1 recovers Manhattan, p = 2 recovers Euclidean.
    let mink1 = minkowski_distance_row(&a, &b, 1.0);
    let mink2 = minkowski_distance_row(&a, &b, 2.0);
    assert!((mink1 - l1).abs() < 1e-12);
    assert!((mink2 - euclidean).abs() < 1e-12);

    println!("sq={sq} l2={euclidean} l1={l1} l3={l3}");
}

minkowski_distance_row is the general form. It sums |a_i - b_i|^p over every coordinate, then raises the total to the power 1/p. It is the only one of the 3 functions that can panic. It panics when p is less than 1.0, or when p is NaN. The panic message ends with the value you passed:

invalid parameter `p`: Minkowski order must be at least 1.0, got 0.5

Orders below 1.0 are rejected because they do not form a metric. The triangle inequality fails for those orders. The kd-tree pruning described in 6.1.5 depends on the triangle inequality. An index that relies on it would return a wrong answer for those orders, not just an odd one.

The raw kernel does not reject p = f64::INFINITY. Infinity is not less than 1.0, and it is not NaN, so the guard passes. The function then computes a degenerate powf(inf) expression, not the Chebyshev (L-infinity) limit. Do not pass an infinite order and expect that limit. Section 6.1.4 shows that the estimator builders reject this case. The raw kernel does not.

6.1.2. The DistanceCalculationMetric dispatcher

DistanceCalculationMetric is the configurable-metric layer. It has 3 variants, and Euclidean is the Default:

VariantMeaningDispatches to
EuclideanL2 norm (default)squared_euclidean_distance_row(...).sqrt()
ManhattanL1 normmanhattan_distance_row(...)
Minkowski(f64)general p-norm, p carried inlineminkowski_distance_row(..., p)

The variant is the whole configuration. Minkowski carries its p value as its own payload. A metric is therefore a self-contained value, with no separate parameter to keep in sync elsewhere.

The enum derives Clone, Copy, PartialEq, and Default. Under the machine_learning or utils feature, it also derives Serialize and Deserialize. This makes the enum easy to store in a struct field. It also means the enum survives model persistence (see 7.2. Model Persistence in Depth).

Two methods on DistanceCalculationMetric are public. The first is distance(&self, a: ArrayView1<f64>, b: ArrayView1<f64>) -> f64. It is the single source of truth for metric dispatch. Every metric-aware estimator calls it, instead of writing its own match over the variants. It takes ArrayView1<f64> by value, not by reference. ArrayView1 is Copy, so you can pass v.view() or a matrix row directly, and reuse the same view across many calls without cloning.

The second public method is within(&self, a, b, threshold) -> bool. It answers whether distance(a, b) <= threshold, without computing the actual distance first. It compares instead in the metric’s root-free space, for example sq_dist <= threshold * threshold for Euclidean. This mapping is monotonic on non-negative values, so the result always matches a plain comparison, but it skips the sqrt. Prefer within over distance(...) <= r for a radius query, for this reason.

use ndarray::array;
use rustyml::math::DistanceCalculationMetric;

fn main() {
    let a = array![0.0, 0.0];
    let b = array![3.0, 4.0];

    let euclidean = DistanceCalculationMetric::Euclidean;
    let manhattan = DistanceCalculationMetric::Manhattan;
    let minkowski = DistanceCalculationMetric::Minkowski(3.0);

    // ArrayView1<f64> is Copy, so the same view is reused across calls.
    assert_eq!(euclidean.distance(a.view(), b.view()), 5.0); // sqrt(9 + 16)
    assert_eq!(manhattan.distance(a.view(), b.view()), 7.0); // 3 + 4

    // `within` compares in root-free space: 5^2 <= 5^2 is true, 5^2 <= 4.9^2 is false.
    assert!(euclidean.within(a.view(), b.view(), 5.0));
    assert!(!euclidean.within(a.view(), b.view(), 4.9));

    let _ = minkowski.distance(a.view(), b.view());
}

You can import the dispatcher from 2 equivalent places. Its home is rustyml::math::DistanceCalculationMetric. A re-export for ML users is rustyml::machine_learning::DistanceCalculationMetric. Both names refer to the same type. Use whichever path matches the module you already import from.

6.1.3. Metric properties and the Minkowski order

All 3 metrics share several properties. Each is non-negative and symmetric: d(a, b) == d(b, a). Each is zero only when the two vectors are identical. Each satisfies the triangle inequality, for the orders the library allows. These properties make the kd-tree pruning in 6.1.5 correct. The estimators depend on them.

The Minkowski order p moves between the named metrics. It controls how much a single large coordinate gap dominates the result. At p = 1, you get Manhattan distance. Each axis then contributes its raw absolute difference. A diagonal move costs the sum of the 2 legs.

At p = 2, you get Euclidean distance, the ordinary straight-line distance. As p grows past 2, the largest single-axis difference dominates the sum more and more. Each difference is raised to the power p, so a larger p favors the biggest gap. The metric then behaves more like the largest coordinate difference alone. That behavior is the Chebyshev limit, also called the L-infinity limit.

RustyML does not offer that limit as a usable metric. There is no Chebyshev variant. Minkowski(f64::INFINITY) is not a valid configuration for the estimators (see 6.1.4). To get behavior close to the largest-coordinate-difference limit, pick a large finite p instead. Treat the result as an approximation, not the true limit.

Fractional orders between 1 and 2, such as Minkowski(1.5), are valid metrics. They sit between city-block and straight-line geometry. Use them to soften Euclidean’s sensitivity to outliers, without moving all the way to Manhattan distance.

6.1.4. Which estimators accept which metrics

2 estimators let you choose a metric. Both use the same pattern: a with_metric builder that returns Result. It returns Result because it validates the Minkowski order at the moment you set it.

EstimatorHow to set the metricMetrics honored
KNN.with_metric(...)? builderEuclidean, Manhattan, Minkowski(p >= 1, finite)
DBSCAN.with_metric(...)? builderEuclidean, Manhattan, Minkowski(p >= 1, finite)
silhouette_scoremetric function argumentEuclidean, Manhattan, Minkowski(p >= 1)

with_metric is stricter than the raw kernel. It rejects Minkowski(p) when p < 1.0, or when p is not finite. In that case, it returns Error::InvalidParameter (see 1.6. Error Handling). It does not defer the failure to a panic at fit time. This is why with_metric returns Result: the default constructors never fail on the metric, but overriding it can fail. The failure then appears immediately, at the builder call.

Both estimators default to Euclidean. You only call with_metric when you want a different metric.

use ndarray::array;
use rustyml::machine_learning::neighbors::{KNN, WeightingStrategy};
use rustyml::math::DistanceCalculationMetric;

fn main() {
    let x_train = array![[0.0, 0.0], [10.0, 0.0], [0.0, 10.0]];
    let y_train = array![0_i32, 1, 1];

    let mut knn = KNN::<i32>::new(1)
        .unwrap()
        .with_weighting_strategy(WeightingStrategy::Uniform)
        .with_metric(DistanceCalculationMetric::Manhattan)
        .unwrap();
    knn.fit(&x_train, &y_train).unwrap();

    let x_test = array![[0.5, 0.0], [9.5, 0.0]];
    let preds = knn.predict(&x_test).unwrap();
    println!("{preds:?}");

    // An order below 1 is not a metric. The builder rejects it before any work happens.
    let bad = KNN::<i32>::new(1)
        .unwrap()
        .with_metric(DistanceCalculationMetric::Minkowski(0.5));
    assert!(bad.is_err());
}

KMeans and MeanShift do not take a metric parameter. Both call squared_euclidean_distance_row directly, so both are Euclidean-only by construction. k-means minimizes the within-cluster squared L2 distance, by definition. Swapping the metric would break the centroid-update math, so there is no option to change it. DBSCAN is the estimator to use for non-Euclidean clustering.

Passing a metric to silhouette_score lets you evaluate a clustering under the same geometry you used to build it. For example, score a DBSCAN result that used Manhattan distance with DistanceCalculationMetric::Manhattan. This keeps the evaluation consistent with the clustering.

use ndarray::array;
use rustyml::math::DistanceCalculationMetric;
use rustyml::metrics::silhouette_score;

fn main() {
    // Two well-separated blobs.
    let x = array![
        [0.0, 0.0], [0.2, 0.1], [0.1, -0.2],
        [10.0, 10.0], [10.1, 9.8], [9.9, 10.2],
    ];
    let labels = array![0_isize, 0, 0, 1, 1, 1];

    let s_euclidean = silhouette_score(&x, &labels, DistanceCalculationMetric::Euclidean);
    let s_manhattan = silhouette_score(&x, &labels, DistanceCalculationMetric::Manhattan);
    println!("euclidean={s_euclidean} manhattan={s_manhattan}");
}

6.1.5. Squared distances and the comparable-space optimization

squared_euclidean_distance_row exposes the squared-distance shortcut as a public function. The library also uses the same shortcut internally, through a private idea called comparable space. Every metric has a monotonic, root-free form: square for Euclidean, the power p for Minkowski, and the identity for Manhattan. This transform is monotonic on non-negative inputs.

Some decisions depend only on ordering. Examples include which point is nearer, whether a point falls inside a radius, and which point is the k-th closest. Any such decision can run in comparable space, and it never pays the cost of a root.

The internal kd-tree does exactly this. KNN and DBSCAN use it automatically, in low dimensions. It stores each candidate distance in comparable space. It prunes branches using a per-axis lower bound, in that same space. It converts a comparable-space value back to a true distance only for the few results it actually returns. within (see 6.1.2) is the public tip of this idea.

The practical lesson applies to your own code too. When you rank points or set a threshold, rather than report a distance to a person, stay in squared space. Comparing squared_euclidean_distance_row values is correct for deciding which point is closer. It is also strictly cheaper than comparing the square roots. Call .sqrt() only at the point where a real distance leaves your loop.

6.1.6. Worked example: a pairwise distance matrix

The kernels are all you need to build a full pairwise-distance matrix, for your own nearest-neighbor logic. Every metric here guarantees 2 structural facts: the matrix is symmetric, and its diagonal is zero. Compute each unordered pair once, then mirror the value across the diagonal, and leave the diagonal at zero. This halves the number of distance evaluations. Inside the hot double loop, work in squared space, and take the root once per entry. Skip the root entirely if you only need ordering downstream.

use ndarray::{Array2, array};
use rustyml::math::squared_euclidean_distance_row;

fn main() {
    let data = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
    let n = data.nrows();

    // Symmetric with a zero diagonal: fill only the upper triangle, then mirror.
    let mut dist = Array2::<f64>::zeros((n, n));
    for i in 0..n {
        for j in (i + 1)..n {
            // Rows pass by reference with no copy. The sqrt runs once per entry.
            let d = squared_euclidean_distance_row(&data.row(i), &data.row(j)).sqrt();
            dist[[i, j]] = d;
            dist[[j, i]] = d;
        }
    }

    // For one query, skip the matrix and scan instead:
    let query = data.row(0);
    let nearest = (1..n)
        .min_by(|&a, &b| {
            // Compare in squared space. The root is not needed to pick the minimum.
            let da = squared_euclidean_distance_row(&query, &data.row(a));
            let db = squared_euclidean_distance_row(&query, &data.row(b));
            da.total_cmp(&db)
        })
        .unwrap();

    println!("matrix=\n{dist:?}\nnearest to row 0: {nearest}");
}

The min_by half of the example is the real point. A full O(n^2) matrix is the wrong tool when you query only a few points. For a single nearest-neighbor lookup, scan once in O(n) and never touch a root. Build the full matrix only when a downstream algorithm consumes all of it, such as hierarchical clustering or an MDS embedding. Do not build it just to find one neighbor.

6.1.7. Performance, SIMD, and parallelism

Each kernel runs in a single pass, with no intermediate allocation. squared_euclidean_distance_row and manhattan_distance_row each fold one ndarray::Zip over the two inputs. minkowski_distance_row does the same, but adds one powf call per element, plus one final powf(1.0 / p) call. All 3 functions carry the #[inline] attribute.

Cost per call is linear in the dimensionality d. Euclidean and Manhattan cost one subtraction, plus one multiply or one absolute value, per element. Both are cheap. Minkowski costs one transcendental powf call per element, which makes it noticeably slower. Prefer Euclidean or Manhattan when either fits your need. Avoid Minkowski(2.0) or Minkowski(1.0), which compute the same numbers through the slower path.

These kernels contain no hand-written SIMD code, and there is no f32 path. The Zip loops are tight and branch-free, except in Minkowski. An optimizing compiler can autovectorize code of this shape. Nothing in the source forces vectorization, though, so do not assume a specific instruction set.

A single distance call is entirely serial, with no rayon call inside any kernel. This is the right choice: one row is not enough work to justify the cost of thread dispatch. Parallelism instead lives one level up, at the caller. silhouette_score splits its pairwise scan across the rayon pool, above a tunable element threshold. KNN offers a predict_parallel method, and DBSCAN parallelizes its neighborhood queries.

If your own pairwise-matrix loop is the bottleneck, parallelize its outer loop over rows with rayon yourself. The kernels are pure functions and are Send-safe, so this composes cleanly. For the broader picture of when parallelism pays off, and how to tune the gates, see 7.3. Performance Tuning and Parallelism. For the sibling numeric primitives this module ships alongside, see 6.2. Matrix Multiplication and 6.3. Parallel Reductions.

6.2. Matrix Multiplication

Every dense layer forward pass and every recurrent timestep reduce to 1 operation: a matrix product. So do linear-model predictions and the pairwise projections inside KNN and t-SNE. RustyML does not route these through ndarray’s .dot().

RustyML delegates them to gemmkit, a pure-Rust GEMM engine. The crate reaches gemmkit through its zero-copy gemmkit-ndarray adapter. The math feature names only the adapter, which brings the engine with it. RustyML keeps only a thin layer of its own code in src/math/matmul.rs.

This page explains 4 things. It explains what the backend does. It explains why RustyML uses it instead of .dot(). It explains how the backend picks serial versus parallel execution, and how wide it goes when it runs parallel. It explains the only part you can change: the runtime tuning surface under rustyml::tuning::matmul.

The crate’s own matmul entry points are pub(crate). You cannot call dot_par from your own code. The estimators reach gemmkit_ndarray directly. They do not go through any type or function that RustyML re-exports.

You can understand the behavior. It decides how fast your models run. You can also retune the thresholds for your machine. If you need a matrix product in your own code, use ndarray’s .dot() (see 1.3. Working with ndarray). Do not use this backend directly.

6.2.1. What the backend is, and why it is internal

There are 2 layers here, and keeping them apart helps. The engine is gemmkit. It computes C <- alpha*A*B + beta*C over strided views. It selects its instruction set at run time. It does its own packing and blocking, and it owns every scheduling decision.

gemmkit-ndarray is a thin adapter. It reads the data pointer and the strides straight out of an ArrayBase<S, Ix2>, and forwards them to the engine. It copies nothing, for a C-order view, an F-order view, a general-stride view, or a negatively-strided view.

This adapter is already the right call-site API. So RustyML’s layers and estimators call it directly. They use gemmkit_ndarray::dot for an allocating product, on the backend’s automatic scheduling. They use gemmkit_ndarray::gemm where the caller owns the output buffer. They use gemmkit_ndarray::gemm_fused where a bias and an activation ride along in the same pass.

src/math/matmul.rs holds, in its own words, “the crate’s few additions to the gemmkit backend”. There are exactly 4 items:

ItemVisibilityWhat it is
dot_par(a, b, par)pub(crate)allocating A @ B with an explicit gemmkit_ndarray::Parallelism (plain dot always uses the automatic default)
matvec(a, x, par)pub(crate)matvec with Array1 operands, wraps x as a [k, 1] column, which gemmkit reroutes to its GEMV path
gemm_chunk_rows(row_len)pub, #[doc(hidden)]gemm_chunk_elems() / row_len, clamped to [16, 4096] rows
cache_resident::<T>(rows, cols)pub, #[doc(hidden)]whether rows * cols * size_of::<T>() is under cache_resident_max_bytes()

The first 2 items are generic over T: gemmkit_ndarray::GemmScalar. In RustyML’s build, this means exactly f32 and f64. gemmkit also supports f16 and bf16 under an optional half feature, and i8 under an int8 feature. RustyML turns on neither, so there is no half precision and no integer matmul here.

The only non-default feature RustyML does turn on is epilogue, requested on gemmkit-ndarray for the fused path. If you need f16, bf16, or i8 support, write your own code directly against gemmkit.

The last 2 items are not products at all. They are the caller-side tiling policy for estimators that would otherwise have to materialize a pairwise projection too large to hold at once. They are technically reachable as rustyml::math::matmul::gemm_chunk_rows and ::cache_resident. But #[doc(hidden)] means the crate gives no stability guarantee for them. Treat them as internal, and use the 6.2.5 knobs that govern them instead.

Here is where each part gets called:

  • Dense::forward runs a single gemm_fused call. It fuses the linear product, the per-column bias, and the ReLU activation into 1 pass.
  • Dense::backward runs 2 plain dot calls. The first computes the weight gradient. The second computes the input gradient.
  • SimpleRNN, LSTM, and GRU project their inputs once with dot. Each timestep then fuses its recurrent projection with gemm_fused. GRU drops to plain gemm where it writes into a slice of a larger buffer.
  • The im2col convolution engine fuses the per-filter bias into its forward GEMM. Its 2 backward GEMMs route through dot_par. The per-item products go serial once the batch fan alone fills the thread pool.
  • LinearRegression, LogisticRegression, LinearSVC, and SVC use matvec for predictions and gradients. So do the power iteration and the one-sided Jacobi iteration in machine_learning::linalg and in LDA. LDA also builds its scatter matrix with dot_par.
  • PCA, kernel PCA, KMeans, and the kernel-matrix code in machine_learning::types use dot.
  • KNN, t-SNE, and MeanShift use cache_resident and gemm_chunk_rows. These functions choose between a per-row GEMV swarm and a tiled GEMM for a pairwise projection.

You already use this backend when you call any of these models. You do not call it by name.

The products go through pub(crate) functions over a private dependency. You cannot call them directly, and you should not try to work around this. RustyML re-exports only gemmkit’s tuning module. You cannot name a Parallelism value through the public API.

Build your layers and estimators on the public API, and you get this backend for free. Write your own linear algebra, and you use ndarray instead. The knobs in 6.2.5 are the only public surface. They change behavior globally, with no recompile needed.

6.2.2. Why not ndarray’s .dot()

ndarray’s .dot() in a default build uses the matrixmultiply crate. This is a pure-Rust GEMM, and it works well. Use it in your own code. It is not the right choice for a training loop that calls it millions of times across many shapes.

matrixmultiply is not a naive scalar kernel. It selects a microkernel at run time, based on the CPU features it detects. These are FMA plus AVX2, AVX, or SSE2 on x86-64, and NEON on aarch64. The claim that gemmkit vectorizes while .dot() does not is false. The real difference comes from 3 things: the shape-specialized routes, the fused epilogue, and threading. ndarray does not enable matrixmultiply’s optional threading feature, so .dot() in this build runs on 1 thread only.

Shape-specialized routes. gemmkit does not run a single blocked algorithm. It picks between several routes by shape. These include a dedicated matrix-vector path, an in-place path for a shallow k that skips packing, and another path for a small m and n. A single general kernel computes these shapes correctly, but slowly. A training loop is full of exactly these shapes.

The fused epilogue. gemm_fused applies the per-column bias and the activation inside the kernel, while the output tile still sits in registers. .dot() has no such feature. The same Dense::forward, written against .dot(), needs 3 passes over the output: the product, the bias, and the activation. This path needs only 1 pass. 6.2.4 records the guarantee that makes this safe: the fused result is bit for bit equal to the unfused sequence.

Threading. matrixmultiply’s threading feature sits behind ndarray’s own opt-in matrixmultiply-threading feature, which RustyML does not turn on. gemmkit threads on its own, and it decides for itself whether threading is worth it. 6.2.3 covers that decision in full.

Operand strides pass straight through to the kernel. This is a convenience, not an advantage over .dot(), since .dot() also handles strides well. The adapter accepts any ArrayBase<S, Ix2> with S: Data. This includes an owned Array2, an ArrayView2, a transpose (a.t()), a non-contiguous slice, and even a negatively-strided view. Nothing gets copied or physically transposed first. A transposed view is just a swapped pair of strides, and the engine reads arbitrary strides directly.

This matters because the backward pass is full of transposed operands. dot(&input.t(), &grad_upstream) is the weight-gradient pattern in Dense. A .dot()-based path would need to copy these into contiguous buffers, or lose the fused stride handling. The tests in src/math/matmul.rs confirm this. A .t() operand and an s![..;2, ..] row-strided slice both feed the correct strides to the kernel. Both match an independent reference product.

The old hand-rolled backend could not do 2 things that gemmkit now does. The fused epilogue is the first of these. The second is that gemmkit’s blocking and job order do not depend on the worker count. This independence turns the reproducibility statement in 6.2.4 from a hedge into a promise.

6.2.3. How gemmkit schedules a product

RustyML makes exactly 1 scheduling decision per call site, and this decision is binary. It passes Parallelism::Rayon(0), which means “decide for yourself”. Or it passes Parallelism::Serial, which means “this thread is already inside a rayon region, so do not fork again”. The second form matters. The convolution engine’s backward pass and MeanShift’s seed loop both use it. It is about not forking twice, and never about correctness.

Everything past that choice belongs to gemmkit. This covers serial versus parallel, the worker count, the pool the work runs in, and whether the shape even takes a bandwidth-bound route.

A gemmkit knob resolves in priority order. A per-call argument, such as the Parallelism request, beats a programmatic set_* call. A set_* call beats a GEMMKIT_* environment variable. An environment variable beats the compiled default. Each environment variable is read once, on the knob’s first access, and then cached for the rest of the process.

A set_* call stores its value unconditionally. Once anything in the process calls a setter, the matching environment variable has no effect for the rest of the run. This is why RustyML never calls a setter on your behalf. A GEMMKIT_* value that fails to parse as a non-negative integer warns once on stderr, and then falls back to the compiled default. A typo in a performance profile never crashes the process.

The work gate. parallel_threshold is the serial-versus-parallel crossover. Its default is 48 * 48 * 256, or 589,824. This gate compares the m * n * k product, not FLOPs, so there is no factor of 2 anywhere. Read the units carefully, because an earlier version of this page compared FLOPs instead. A problem below the gate runs on 1 thread, no matter how many workers you requested.

This band holds the tiny GEMMs: RNN and LSTM timesteps, and small dense layers called in tight loops. Keeping them serial is the correct choice, not laziness. Dispatching work onto a thread pool costs more than the multiply itself.

The worker ramp. Above the gate, the automatic path does not grab every core at once. par_mnk_per_worker defaults to 2,000,000 on native targets. It sets how much extra m * n * k work each additional worker needs before the product widens by 1. The target worker count is mnk / par_mnk_per_worker, floored at 1 and capped by the core count and the job count.

The ramp is based on work, not on dimension, because the measured optimum tracks total work rather than linear size. gemmkit’s own calibration, from a Ryzen 9950X, shows this. A 128^3 product (about 2e6) runs fastest serial. A 192^3 product (about 7e6) wants 2 or 3 workers. A 384^3 product (about 5.7e7) already wants all 32 hardware threads. No single stride along 1 dimension fits both ends of this curve.

The pool tiers. An earlier version of this page said the backend kept no thread pool of its own. That is no longer true. pool_classes builds persistent, exact-fit private rayon pools in tiers. The tiers halve down from half the machine width: 1 tier is width/2, 2 tiers add width/4, and 3 tiers add width/8. The automatic worker count snaps to the smallest tier that still holds it.

The reason is rayon’s fork-join tax. This tax scales with a pool’s slack, which is its width minus the workers actually doing work, not with the worker count alone. 8 workers in an 8-wide pool beat the same 8 workers inside a 32-wide global pool, by a wide margin.

The tier pools are built once and reused warm. They are not rebuilt per call. A value of 0 disables them entirely. The default is arch-split: 2 tiers on x86-64, 1 tier on aarch64, and 0 tiers on every other target, pending on-device validation.

If the calling thread is already a rayon worker, for example inside a nested GEMM or your own installed pool, gemmkit skips the tiers. It runs inside the current pool instead. This is why these products compose cleanly inside an outer parallel region. They do not stack a second pool on top of yours.

Matvecs are their own cost class. gemmkit detects the m == 1 or n == 1 shape and takes a dedicated, bandwidth-bound path instead of the general driver. matmul::matvec exists to present an Array1 as the [k, 1] column that triggers this path. This path does not consult parallel_threshold at all.

It stays serial below a byte floor, gemv_parallel_bytes, which defaults to 0 (meaning “derive it from the cache size”). The derived floor is 1 core’s private L2. Below it, the touched data is L2-resident, that core already sees the full L2 bandwidth, and splitting the work only adds fork-join overhead with no DRAM bandwidth to win back.

Above the floor, the worker count climbs a ladder as the touched bytes grow. Its rungs are the same exact-fit pool tiers the general driver uses, and it climbs 1 tier per gemv_tier_step factor of bytes above the floor. A matvec that only just clears the floor therefore gets the narrowest tier, not the full memory-parallel width. gemv_thread_cap overrides the ladder: a non-zero value is the width verbatim, pinned flat at every size. Both default to 0 for auto mode.

gemv_axpy_par_min_rows adds a shape-specific guard on top. A column-major matvec keeps its rows on 1 worker below that output-row count, because the output-row axis is the inner memory axis there, and cutting it gives every worker a strided walk over the whole matrix. A row-major matrix is unaffected, because its workers own whole k-contiguous rows. RustyML’s operands are row-major, so matvec never consults this guard.

A last knob, gemv_threshold, caps how large the vector side may be before the shape falls back to the general driver. Its default is usize::MAX - 1, effectively unbounded. In practice, a gemv-shaped problem always takes the gemv path, unless you lower this knob yourself.

A dozen more knobs sit behind these: kc, rhs_pack_threshold, the lhs_pack_* family, small_k_threshold, small_mn_dim, prefetch_min_bytes, and others. This page does not list them, because such a table would go out of date quickly. They are documented on gemmkit’s own docs.rs page. Each knob is reachable through rustyml::tuning::matmul::backend. The gemmkit-tune autotuner sweeps them for you, on your target machine.

This note applies to all of them. gemmkit’s reference machines are a Ryzen 9950X (x86-64) and an M4 Max (aarch64). Any knob whose crossover depends on architecture carries a separate default for each, split by cfg(target_arch). Unless stated otherwise, the numbers quoted on this page are the x86-64 values.

6.2.4. Determinism and reproducibility

An earlier version of this page said results were reproducible on the same machine, but not necessarily bit for bit. That claim no longer holds, and you should discard it.

The old hedge existed because the crate’s own row-split wrapper gave each block a different m. The kernel’s internal k-blocking depended on m, so the summation order moved with the thread count. That row split is gone, and the hedge went with it. src/math/matmul.rs now documents a direct promise:

gemmkit’s blocking and job order do not depend on the worker count. For a fixed machine and configuration, the same product reproduces the same result bit for bit, no matter how many threads ran it. The result also repeats from run to run. Fused epilogues (bias and activation) are bitwise identical to the plain product followed by the same scalar map.

This is not aspirational. The module’s own test suite checks every part of it.

  • dot_par_thread_count_independent_f64 runs a 96^3 shape, a 256 x 64 x 64 shape, and a thin-k 64 x 8192 x 64 shape. It runs each shape serially, then at Rayon(2), Rayon(4), Rayon(8), Rayon(16), and Rayon(32). It asserts to_bits() equality across every arm. The thin-k shape is there because it is the shape most likely to tempt a split-k reduction, which would break this property.
  • dot_par_thread_count_independent_f32 runs the same check for f32.
  • matvec_serial_and_auto_agree_bitwise covers the bandwidth-bound gemv path. Each output element there is reduced over the whole of k on 1 worker.
  • dot_run_to_run_deterministic and matvec_run_to_run_deterministic cover repeat calls on the same machine.
  • gemm_fused_bias_relu_bitwise_matches_unfused checks that gemm_fused, with a Bias::PerCol and an Activation::Relu, equals a plain dot followed by the same scalar add-and-clamp, bit for bit. This is what makes fusing the bias and the ReLU into a Dense forward pass a free optimization, not a numerical trade-off.

The words fixed machine and configuration still carry weight. A different CPU picks a different SIMD width, and so a different accumulation layout. Changing a knob can also change the blocking. Cross-machine bit-equality is still not promised, and no threaded BLAS promises it either.

Within 1 binary on 1 machine, though, the worker count is no longer a variable you must reason about. For a training run you want to replay later, that is the part that matters. For the seeding side of reproducibility, such as weight initialization, shuffles, and dropout masks, see 7.1. Reproducibility and Random Seeds.

The deterministic reductions in 6.3. Parallel Reductions give a stricter guarantee. They produce the same result by construction, independent of the machine, not merely independent of the worker count.

6.2.5. Tuning the gates: the public surface

This is the part you can call directly. It has 2 layers. The serial-versus-parallel decision belongs to the gemmkit backend, as 6.2.3 describes. The per-dtype FLOPs gates that the crate used to hand-roll and expose are gone.

rustyml::tuning::matmul is available with the math feature, and so under full. It still owns the caller-side tiling policy. It also re-exports the backend’s own knobs, so you never need a direct gemmkit dependency.

The re-export goes through gemmkit-ndarray, the adapter RustyML actually calls, and not through a gemmkit dependency of its own. This matters if you add gemmkit to your own Cargo.toml regardless. The knobs are process-global atomics, so cargo resolving your gemmkit to a different version than the adapter’s would give you a second copy, and a set_* call on it would have no effect on RustyML’s products. Going through rustyml::tuning::matmul::backend cannot land on the wrong copy.

Function pairDefaultControls
get_chunk_elems / set_chunk_elems33,554,432element budget for 1 row-chunk of a tiled product
get_cache_resident_max_bytes / set_cache_resident_max_bytes67,108,864cache-resident size threshold, set to your machine’s shared L3
matmul::backend::*see gemmkitevery backend knob, each with a matching GEMMKIT_* environment variable

cache_resident_max_bytes is the knob you are most likely to change. Set it to your actual shared L3 size. The default, 64 MiB, is a guess, and the band around it is not calibrated.

For the serial-versus-parallel crossover, use matmul::backend. set_parallel_threshold gates on the m * n * k product. set_gemv_threshold gates the matvec path. Every backend knob also reads from a GEMMKIT_* environment variable. The gemmkit-tune autotuner can emit a full machine profile, so you rarely need to pick numbers by hand.

Set these knobs once at startup, before the hot loop starts. They are global, and they apply to the whole process. Calling a backend set_* function from RustyML silences the matching GEMMKIT_* environment variable, for the rest of the process. This would override a profile you had set through the environment. That is why RustyML never sets these knobs for you.

See 7.3. Performance Tuning and Parallelism for the full rationale, the calibration workflow, and how these knobs compose with the reduction and elementwise gates.

6.2.6. When parallelism pays, and how to measure it

The gates encode where threading helps. The shape sweep in benches/benchmarks/matmul_kernels.rs shows this. Run it with cargo bench --bench matmul_kernels. Dense::forward is 1 fused GEMM call and nothing else. The bias and the activation run inside the kernel’s epilogue, not as extra passes. The sweep times 6 shapes, labeled batch x in_features x out_features, which is m x k x n:

  • The 4 square-ish rungs are small_256x256x256, medium_512x1024x1024, big_1024x2048x2048, and huge_2048x2048x2048. They walk the worker ramp from end to end. Even the smallest is 16,777,216 in m*n*k, about 28 times the work gate, so none of them is a serial case. This ladder shows the ramp handing out more workers as the work grows. It also shows the pool tiers dropping out at the top, once a problem is large enough to want the full machine width.
  • wide_256x256x8192 is the wide-n case. There is an abundance of independent output columns here, so the work splits with no trouble at all. This is the easy shape for any threaded GEMM.
  • thin_256x8192x256 is the interesting shape. Its name means thin in k’s neighbors, not thin overall: m and n are both 256, while k is 8192. This is a deep-k product. A common intuition says a skinny shape must be bandwidth-bound, but this shape is firmly compute-bound: about 1.07 GFLOP against about 17 MB of operands. It is the shape where depth-blocking decisions matter most, which is why the sweep includes it.

2 regimes stay outside this bench on purpose.

A genuine matvec never appears in it, because a Dense forward is never one. A matvec leaves the general driver entirely, for gemmkit’s gemv path. It gates instead on a byte floor derived from 1 core’s private L2, then climbs a worker ladder as the bytes it touches grow, because DRAM saturates at far fewer workers than a machine has logical cores. This path is bandwidth-bound, so extra cores start to pay off far earlier there than in a compute-bound GEMM, which can better amortize thread dispatch.

Sub-gate products are also absent: RNN and LSTM timesteps, and small dense layers. The smallest shape in this sweep already sits well above the gate. If you profile an RNN and see rayon overhead dominating, do not lower parallel_threshold. Those products stay serial by design, and the overhead comes from somewhere else.

You can compare serial and parallel execution on your own machine, for a fixed product, by toggling the gate around it. The example below exercises the backend through a public Dense layer, and times the same product both ways. Treat the printed numbers as a sketch, not a benchmark, since a single call is noisy. For real figures, use the criterion bench above, which warms up and repeats.

use ndarray::Array;
use rustyml::neural_network::layers::{Activation, Dense};
use rustyml::neural_network::traits::Layer;
use rustyml::tuning::matmul;
use std::time::Instant;

fn main() {
    // A Dense forward is 1 backend GEMM: input (batch, in_features) @ weights (in_features, units).
    let (batch, fin, fout) = (256usize, 256usize, 256usize);
    let mut layer = Dense::new(fin, fout, Activation::ReLU)
        .unwrap()
        .with_random_state(42);
    let x = Array::from_elem((batch, fin), 0.5f32).into_dyn();

    // The backend gates on the m*n*k product, not on FLOPs. There is no factor of 2.
    let work = batch * fin * fout;
    println!(
        "backend parallel gate = {}; this product = {} (parallel: {})",
        matmul::backend::parallel_threshold(),
        work,
        work >= matmul::backend::parallel_threshold()
    );

    let warm = layer.forward(&x).unwrap();
    assert_eq!(warm.shape(), &[batch, fout]);

    // Force this exact product serial by lifting the gate just above its work count.
    let saved = matmul::backend::parallel_threshold();
    matmul::backend::set_parallel_threshold(work + 1);
    let t0 = Instant::now();
    for _ in 0..20 {
        let _ = layer.forward(&x).unwrap();
    }
    let serial = t0.elapsed() / 20;

    // Restore the gate so the same product now takes the parallel strategy.
    matmul::backend::set_parallel_threshold(saved);
    let t1 = Instant::now();
    for _ in 0..20 {
        let _ = layer.forward(&x).unwrap();
    }
    let parallel = t1.elapsed() / 20;

    println!("serial  ~ {serial:?} / forward");
    println!("parallel ~ {parallel:?} / forward");
}

The gate and the work count are fixed by the defaults and the shape, so those print exactly. The timings are machine-dependent, so the output below shows their shape and kind rather than numbers:

backend parallel gate = 589824; this product = 16777216 (parallel: true)
serial  ~ <duration> / forward
parallel ~ <duration> / forward

Watch the second call to set_parallel_threshold, which restores the saved value. A programmatic setter permanently shadows the matching GEMMKIT_PARALLEL_THRESHOLD environment variable. So a snippet like this pins the knob in code for the rest of the process, even after it “restores” it. This is harmless here, because the restored value is the one the process started with. It is still a reason not to scatter setters through a library.

Do not be surprised if the parallel run is not faster, on a small product like this or on a machine with few cores. That is the whole point of the gate, and it is why the defaults keep sub-gate products serial. Scale batch, fin, and fout up to the benchmark’s larger shapes, and the parallel arm pulls ahead.

If you want to write your own matrix product instead of routing through a layer, use ndarray. This is deliberately outside the backend:

use ndarray::array;

fn main() {
    // RustyML's matmul entry points are crate-internal. Your own matmul code uses ndarray's `.dot()`.
    let a = array![[1.0_f64, 2.0, 3.0], [4.0, 5.0, 6.0]]; // 2x3
    let b = array![[1.0_f64, 0.0], [0.0, 1.0], [1.0, 1.0]]; // 3x2
    let c = a.dot(&b); // 2x2
    assert_eq!(c, array![[4.0, 5.0], [10.0, 11.0]]);
    println!("A.dot(B) shape = {:?}", c.shape());
}

Build your models on the public layers and estimators, and you get gemmkit for free. This includes its runtime ISA dispatch, its work-based scheduling and pool tiers, its fused epilogues, and its worker-count-independent numerics. Nothing needs configuration.

When a specific machine wants different crossovers, use the gates in 6.2.5. See 7.3. Performance Tuning and Parallelism for a deeper treatment. For the distance kernels alongside this backend, see 6.1. Distance Metrics. For the reductions that share its parallel machinery, see 6.3. Parallel Reductions.

6.3. Parallel Reductions

rustyml::math::reduction does not ship a sum() or a mean() function. It ships 2 generic fold combinators, det_reduce and det_reduce_range, plus 1 constant, DET_REDUCE_BLOCK. The crate builds every parallel reduction in the library on top of these 2 functions. Examples include the sum of squared errors in linear regression, the global gradient norm for clip-by-global-norm, and the one-pass Welford moments in standardization. Other examples include the k-means inertia and the logistic log-loss. These functions exist to solve a problem that ordinary parallel summation cannot: a result that does not depend on the thread count.

6.3.1. What the module exposes

The public surface has 3 items. The module sits behind the math feature. Every other feature (machine_learning, neural_network, utils, metrics) pulls in the math feature. So these 3 items are always available when RustyML compiles (see 1.2. Installation and Feature Flags).

ItemSignature (elided bounds)Role
DET_REDUCE_BLOCKpub const DET_REDUCE_BLOCK: usize = 16_384Fixed block size, in elements, that sets the grouping
det_reducefn det_reduce<T, A, F, M>(slice: &[T], parallel: bool, fold_block: F, merge: M, identity: A) -> AFolds a slice in fixed blocks
det_reduce_rangefn det_reduce_range<A, F, M>(n: usize, parallel: bool, fold_block: F, merge: M, identity: A) -> AFolds the index range 0..n in fixed blocks

The full trait bounds appear below. The compiler error messages about them are hard to read without this context.

pub fn det_reduce<T, A, F, M>(slice: &[T], parallel: bool, fold_block: F, merge: M, identity: A) -> A
where
    T: Sync,
    A: Send,
    F: Fn(&[T]) -> A + Sync + Send,   // serial fold over 1 block
    M: Fn(A, A) -> A,                 // combines 2 partial results
{ /* ... */ }

fold_block reduces 1 block to a partial result of the accumulator type A. merge combines 2 partial results. identity is the value returned for an empty input, and it also seeds the final combine.

fold_block must be Sync + Send, because rayon can call it from any worker. This bound applies even when you pass parallel = false, since the bounds sit on the type, not on the flag. merge needs neither bound, because it always runs on 1 thread, in block order.

A can be anything Send: a scalar, a tuple such as (sum, sum_of_squares), a Welford triple, or an array of per-bucket sums.

det_reduce_range runs the same algorithm over an index range instead of a slice. Use it for reductions that read several arrays at once, or that index rows of a matrix. Its fold_block receives a Range<usize> instead of a &[T].

The module ships no sum wrapper on purpose. Below the parallel threshold, a 1-line slice.iter().sum() is already the right tool. Above the threshold, the caller almost always wants to fuse a map into the same pass. Examples include a square, an exp, or a distance function. Fusing beats building a separate intermediate array. The fold interface, not a fixed reduction, keeps that fusion at the call site.

6.3.2. Why naive parallel summation is non-deterministic

Floating-point addition is not associative. (a + b) + c and a + (b + c) can round to different f64 values. This is not a hardware bug. It is the definition of rounding to 53 bits after every operation. A sum that runs left to right on 1 thread has a fixed order, so the result is reproducible. Parallel execution removes that fixed order.

A bare slice.par_iter().sum::<f64>(), or fold().reduce(), splits the work adaptively. Rayon’s work-stealing scheduler decides which worker folds which sub-range. It also decides the order in which the partial sums combine.

A run on a machine with 4 idle cores produces 1 grouping. The same input with RAYON_NUM_THREADS=1 produces another grouping. 2 runs on a busy 16-core machine can disagree, because a thread got preempted at a different moment. Every one of these results is a correct sum of the same numbers. They only round differently, typically in the last few ULPs.

For a lot of numerical code, this jitter is harmless. For a machine-learning library, it is corrosive. A loss value that wobbles in its low bits can make an early-stopping check fire on a different iteration across runs. A gradient norm that depends on the thread count can make clip-by-global-norm clip each run slightly differently. It can also make 2 runs of the same fit produce 2 different models.

Reproducibility is a first-class promise in RustyML (see 7.1. Reproducibility and Random Seeds). A scheduler-dependent reduction breaks that promise, no matter how carefully you seed the RNG.

6.3.3. The blocked algorithm

The fix takes the grouping away from the scheduler and fixes it to a constant. det_reduce cuts the input into fixed DET_REDUCE_BLOCK-element chunks. It folds each chunk serially with your fold_block. It collects the per-block partial results in block order. Then it merges them left to right with your merge. The 2 paths, parallel and serial, differ only in how the blocks run:

if parallel {
    let parts: Vec<A> = slice.par_chunks(DET_REDUCE_BLOCK).map(fold_block).collect();
    parts.into_iter().fold(identity, merge)          // merge in block order
} else {
    slice.chunks(DET_REDUCE_BLOCK).map(fold_block).fold(identity, merge)
}

The key detail is that rayon’s par_chunks(...).collect::<Vec<_>>() is an indexed parallel iterator. No matter how work-stealing distributes the blocks across threads, the collected Vec comes back in the original block order. So the reduction tree is a pure function of the input length and DET_REDUCE_BLOCK. That tree defines which elements land in which block, and in what order the blocks combine. The tree does not depend on the thread count, on scheduling, or on the parallel flag. Both paths fold the same 16 384-element blocks in the same order, and they combine the blocks the same way.

det_reduce_range does the identical thing over n.div_ceil(DET_REDUCE_BLOCK) index blocks. Block b covers the range b * BLOCK .. ((b + 1) * BLOCK).min(n).

This makes the parallel argument a pure performance hint. It never changes which numbers get added in which order. It only decides whether the blocks run on the rayon pool or in a plain sequential loop. The crate’s own tests check this with a bitwise == comparison, not an epsilon, across empty, sub-block, exactly-1-block, and ragged multi-block lengths. So on a given build, the 2 paths are bit-for-bit identical. Varying RAYON_NUM_THREADS cannot change the result.

The module docs still note that results are not always bit-for-bit reproducible. That caveat covers cross-machine and cross-build differences. Examples include a different libm sin, an FMA contraction toggled by a different target CPU, or a different rounding inside your own fold_block. It does not cover the thread count, which the blocking fixes completely.

DET_REDUCE_BLOCK is 16 384 because that size sits near the measured throughput plateau. On a 4.2 million element f64 sum of squares, the measured speedup rises from about 14x at a 1024-element block. It peaks at about 18x at a 32 768-element block. It then falls to about 15x at a 65 536-element block. It falls further, to about 11x, at a 262 144-element block. Too few blocks remain there to balance across cores.

The equivalent f32 benchmark, with an f64 accumulator, peaks higher, at about 21x, with a 65 536-element block. 16 384 sits close to both peaks: about 3 percent under the f64 peak, and about 8 percent under the f32 peak. It works well for both element types without a separate constant for each.

The constant counts elements, not bytes, and every element type shares it. A block of 16 384 f32 values is 64 KB. A block of 16 384 f64 values is 128 KB. Both sizes sit comfortably on the plateau for their element type.

The block size defines the grouping, so it is part of the reproducibility surface. Changing it changes the deterministic result in its low bits. For that reason, DET_REDUCE_BLOCK is a const, not a runtime knob.

6.3.4. Accuracy is a side effect, not the goal

Blocking was chosen for determinism. It also improves accuracy as a side effect. This applies to both paths. The serial path also chunks into blocks, so even parallel = false is not a naive whole-array left fold.

The worst-case rounding error of summing n floats left to right grows linearly in n. It follows roughly (n - 1) * eps * S, where eps is the machine epsilon and S is the sum of the absolute input values.

det_reduce uses a 2-level scheme. Each block folds b = 16 384 terms serially. Then the partial results from ceil(n / b) blocks fold serially. The error bound becomes roughly (b + n / b) * eps * S.

For a 4.2 million element sum, that bound is about (16 384 + 256) * eps. The naive bound for the same sum is about 4.2 million * eps. The blocked bound is about 250x tighter in the worst case. The improvement holds whether the blocks ran in parallel or in sequence.

This scheme is flat blocking plus a serial merge, not a full pairwise (log n) summation tree. In practice, the accumulator width matters more than the tree shape. global_grad_norm reduces f32 gradients into an f64 accumulator inside fold_block, so that squared-gradient sum stays in f64 end to end. This accumulator choice improves accuracy more than the blocking does. Blocking sets the deterministic floor. A wide accumulator is the right choice when accuracy itself is the concern.

6.3.5. When the parallel path turns on

det_reduce does not decide parallel. The caller passes that flag. Inside the crate, a calibrated size gate produces that boolean value. Below roughly 1 block, there is nothing to parallelize. An input shorter than 16 384 elements is a single block, so forking it onto rayon only adds join overhead.

The gates live in rustyml::tuning::reduction. Each gate is shared per cost class, rather than defined per call site:

Gate (getter in tuning::reduction)DefaultGuards
get_sum_f64262 144f64 sum-style reductions (SSE, Welford moments, k-means inertia)
get_sq_sum_f3265 536f32 to f64 square-sum for clip-by-global-norm
get_scan_f64262 144short f64 per-row scans (arg-min, distance scans)
get_exp_reduce32 768the exp-heavy logistic log-loss reduction

Every call site follows the same pattern: it compares a work metric against gate() and passes the result as the flag. Most sites use slice.len() for that metric. The k-means centroid accumulation instead uses n_samples * n_features, since that product is the real element count its blocked fold walks. The gate moves the crossover point, but it never touches correctness, because the blocked fold gives the same answer on either side.

Each gate has a matching setter, such as set_sum_f64 or set_sq_sum_f32, to tune the crossover for different hardware. 7.3. Performance Tuning and Parallelism covers the mechanics and the calibration process. The exp-reduction gate sits lowest, at 32 768, because each element there pays for an exp and an ln. Parallelism amortizes sooner there than for a plain add.

6.3.6. Using them in your own code

The minimal call computes a fused sum of squares over a Vec<f64> and stays serial:

use rustyml::math::reduction::det_reduce;

fn main() {
    let data: Vec<f64> = (0..1_000).map(|i| (i as f64).sin()).collect();
    let sum_sq = det_reduce(
        &data,
        false, // performance hint: small input, stay serial
        |block| block.iter().map(|&x| x * x).sum::<f64>(),
        |a, b| a + b,
        0.0,
    );
    println!("sum of squares = {sum_sq}");
}

2 details need attention here. First, det_reduce takes a &[T], so the data must be a contiguous slice. An ndarray array yields a contiguous slice only through as_slice(), and that method returns None for a view that is not in standard layout. The crate’s own idiomatic pattern reduces through det_reduce on the contiguous fast path, and it falls back to ndarray’s serial kernel otherwise. This pattern gates the flag on the size class:

use ndarray::Array1;
use rustyml::math::reduction::det_reduce;
use rustyml::tuning::reduction::get_sum_f64;

fn main() {
    let v: Array1<f64> = (0..10_000).map(|i| i as f64).collect();
    let sum = match v.as_slice() {
        Some(slice) => det_reduce(
            slice,
            slice.len() >= get_sum_f64(),
            |block| block.iter().sum::<f64>(),
            |a, b| a + b,
            0.0,
        ),
        None => v.sum(), // non-contiguous: ndarray's serial fold
    };
    println!("sum = {sum}");
}

Second, the accumulator does not have to be a scalar. That is the reason the module exposes the fold instead of a fixed reduction. A single pass can return both the sum and the sum of squares, enough for a mean and a variance. Use a tuple accumulator with a tuple merge:

use rustyml::math::reduction::det_reduce;

fn main() {
    let data: Vec<f64> = (0..10_000).map(|i| (i as f64).sin()).collect();
    let (sum, sum_sq) = det_reduce(
        &data,
        false,
        |block| block.iter().fold((0.0f64, 0.0f64), |(s, sq), &x| (s + x, sq + x * x)),
        |(sa, sqa), (sb, sqb)| (sa + sb, sqa + sqb),
        (0.0, 0.0),
    );
    let n = data.len() as f64;
    let mean = sum / n;
    let variance = sum_sq / n - mean * mean;
    println!("mean = {mean}, variance = {variance}");
}

Some reductions need to read more than 1 array at once, for example a dot product, a distance accumulation, or a per-row pick. For these cases, use det_reduce_range and index inside the block:

use rustyml::math::reduction::det_reduce_range;

fn main() {
    let xs: Vec<f64> = (0..5_000).map(|i| i as f64).collect();
    let ys: Vec<f64> = (0..5_000).map(|i| (i as f64).cos()).collect();
    let dot = det_reduce_range(
        xs.len(),
        false,
        |range| range.map(|i| xs[i] * ys[i]).sum::<f64>(),
        |a, b| a + b,
        0.0,
    );
    println!("dot = {dot}");
}

det_reduce versus ndarray’s .sum()

ndarray’s .sum(), .dot(), and .mean() are serial and single-threaded. Their internal grouping is their own, so it will not generally match det_reduce’s blocking bit-for-bit. For small arrays, ndarray is the right choice. It is shorter to write, it needs no closures, and below the gate det_reduce would run serially anyway, with more setup code.

Use det_reduce when 3 conditions hold. The buffer is large and contiguous. The reduction should run in parallel. The parallel result must be reproducible. ndarray does not offer that combination. Even with ndarray’s rayon feature, a bare parallel sum is scheduler-dependent.

det_reduce is also the right tool when you want to fuse a map into the reduction, or to accumulate something richer than a scalar. Use ndarray by default, for convenience and small data. Switch to det_reduce at the point where you were about to write par_iter().sum() and you need the answer to stay stable. See 1.3. Working with ndarray for the interop details. See 4.2. Standardization and Normalization for a real Welford reduction built on this fold.

6.3.7. Verifying determinism across thread counts

You can test this claim from outside the crate. The following program reduces 2 million values on the rayon path and prints the sum at full precision:

use rustyml::math::reduction::det_reduce;

fn main() {
    let data: Vec<f64> = (0..2_000_000).map(|i| (i as f64 * 0.001).sin()).collect();
    let sum = det_reduce(
        &data,
        true, // force the parallel path
        |block| block.iter().sum::<f64>(),
        |a, b| a + b,
        0.0,
    );
    // Full-precision print so a low-bit difference would show
    println!("{sum:.17e}");
}

Build the program once. Then run it under different thread counts by setting RAYON_NUM_THREADS, which caps rayon’s global pool:

RAYON_NUM_THREADS=1 ./target/release/demo
RAYON_NUM_THREADS=2 ./target/release/demo
RAYON_NUM_THREADS=8 ./target/release/demo

All 3 runs print the identical 17-digit mantissa. Every run folds the same 16 384-element blocks and merges them in the same block order, regardless of how many workers carried them. Setting the flag to false does not change the output either. A version that swaps the body for data.par_iter().sum::<f64>() behaves differently. Under different RAYON_NUM_THREADS values, and on a large enough input, that version prints sums that differ in their last digits. This is the exact failure mode that det_reduce closes.

The related numeric primitives share the same determinism discipline. See 6.1. Distance Metrics, 6.2. Matrix Multiplication, and the broader 6.0. Math Utilities overview.

7. Advanced Topics

By this point, you can train every estimator and network in the crate. You can split and scale data, and read off metrics. This chapter covers the cross-cutting concerns that surface once a model leaves your editor: a test suite, a benchmark, or someone else’s binary. It covers making a run repeat exactly, and moving a trained model between processes. It also covers matching the parallel kernels to your hardware, and stripping the dependency tree down to what you actually compile. None of this changes what a model computes. All of it changes whether you can trust the result, ship it, and afford it.

These sections assume you have worked through Getting Started, and have trained at least one model from Classical Machine Learning or Neural Networks. The sections are largely independent, so read them in any order. Start with 7.1, though. Reproducibility is what makes the persistence round-trips and performance comparisons in later sections verifiable at all.

7.1. Reproducibility and Random Seeds

Every randomized component draws its RNG through one resolver. The list includes weight initialization, dropout and noise masks, the Sequential minibatch shuffle, k-means centroids, SVC/LinearSVC, MeanShift, Isolation Forest, train_test_split, and t-SNE. A single set_global_seed(seed) call, on the current thread, fixes them all together. Reproducibility and Random Seeds covers 3 things. First, the three-way resolution between a per-model random_state: Option<u64>, the thread-local global seed, and OS entropy. Second, why an explicit local seed never perturbs the seeds handed to unseeded components. Third, the thread-locality trap under --test-threads=1. This section underpins the deterministic splits in Train-Test Split.

use rustyml::set_global_seed;

fn main() {
    // Fix every unseeded draw on this thread before constructing any model.
    set_global_seed(42);
}

7.2. Model Persistence in Depth

save_to_path and load_from_path serialize a trained model to a compact postcard binary. Model Persistence in Depth goes past the happy path. It explains what actually lands in the bytes: the fitted parameters and hyperparameters, not your dataset. It explains why loading a neural network reconstructs weights into an architecture you rebuild by hand, rather than restoring the graph itself. It explains how a layer-count or weight-shape disagreement surfaces as IoError::ModelStructureMismatch, instead of silently loading garbage. This section pairs with Saving and Loading Weights, which covers the network-specific save/load mechanics in full.

7.3. Performance Tuning and Parallelism

Every parallel kernel chooses serial-versus-rayon, and for GEMM, which parallel strategy, by comparing a work estimate against a calibrated threshold. Those thresholds are tuned on the maintainer’s machine, not yours. Performance Tuning and Parallelism shows how the rustyml::tuning facade overrides each gate at runtime, through a single relaxed atomic store. The gates include the GEMM/GEMV FLOP crossovers, the elementwise and reduction element counts, the conv/pool/norm gates, and the tree gates. You can retune any of them for your core count and cache size, without a recompile. A gate only selects an execution strategy. It never changes what is computed. See Matrix Multiplication and Parallel Reductions for the kernels these gates govern.

7.4. Minimal Builds and Modular Integration

The crate splits into feature-gated modules: machine_learning, neural_network, utils, metrics, and the shared math core. A project that only needs k-means never compiles the neural-network stack, or its indicatif progress-bar dependency. Minimal Builds and Modular Integration maps which feature pulls in which dependencies. It also covers what the default, full, and show_progress flags turn on. It shows how to drop RustyML into an existing pipeline as one module among many. Installation and Feature Flags covers a first pass over this same material.

7.1. Reproducibility and Random Seeds

Every component with randomness in RustyML draws it through one chokepoint, the crate-level random module. A single seed can pin an entire experiment. The rules are simple, but 2 consequences differ from NumPy’s process-global np.random behavior. These consequences are order-sensitivity and thread-locality. RustyML supports both scikit-learn’s per-estimator random_state style and Keras’ global keras.utils.set_random_seed style at the same time. This page explains how the two interact.

7.1.1. The 2 public entry points

The public surface is 2 free functions, re-exported at the crate root:

pub fn set_global_seed(seed: u64);
pub fn clear_global_seed();

set_global_seed installs a seed for the calling thread. clear_global_seed removes it and restores entropy-based behavior. There is no getter and no global object to pass around. You do not construct a per-call RNG yourself.

Instead, each component takes an Option<u64> seed, usually as a .with_random_state(seed) builder method or a random_state argument. An internal resolver reconciles that value against the global seed. Call set_global_seed once, before you construct the models whose randomness you want to fix. Everything downstream then becomes reproducible too.

2 internal resolvers reconcile a component’s seed with the global one. make_rng(random_state) always returns a concrete RNG. It falls back to OS entropy when no seed applies. Almost every component uses it.

make_rng_opt(random_state) returns Option<StdRng> instead. It yields None when neither a local nor a global seed is in effect. That None means no randomization was requested. The DecisionTree uses this second form (see 2.4. Decision Trees). This keeps split tie-breaking fully deterministic unless you ask for randomness. With make_rng_opt, an unseeded tree never randomizes ties, not even from entropy.

7.1.2. The 3-way seed resolution rule

Given a component’s random_state: Option<u64> and the thread-local global seed, the rule resolves as follows:

Component random_stateGlobal seed set?Result
Some(seed)eitherUse seed directly. The global stream is ignored and left untouched.
NoneyesDerive an independent sub-seed by advancing the global stream one step.
NonenoSeed from OS entropy. Not reproducible.

The following code shows the mechanism:

match random_state {
    Some(seed) => StdRng::seed_from_u64(seed),           // independent, global untouched
    None => match global_seed_rng {
        Some(global) => StdRng::seed_from_u64(global.next_u64()), // sub-seed from the stream
        None => StdRng::from_rng(&mut rng()),            // no seed anywhere: OS entropy
    },
}

The 2 branches are asymmetric. An explicit Some(seed) builds its RNG from that number alone. It never calls next_u64() on the global stream. A None under a global seed consumes one draw from the stream instead.

The global seed acts like a generator of sub-seeds. It hands them out in the order that unseeded draws request them. An explicit Some seed resolves without touching the stream at all. Whether this makes a whole component inert depends on when the component draws. Section 7.1.3 explains when a component is inert.

7.1.3. Order-sensitivity and when a seed is really inert

This rule has 2 consequences that matter in practice. One of them is a common trap.

The first consequence is order-sensitivity. Unseeded components draw their sub-seeds from the shared stream in the order they ask for them, so their reproducibility depends on construction order. Build model A, then model B, and each gets a specific sub-seed. Build B, then A instead, and the sub-seeds swap between them. This matches Keras’ global-seed behavior, and a global seed reproduces a run only when the construction sequence also stays the same.

The second consequence is inertness. Here you must be precise about what is inert. The resolver guarantees that make_rng(Some(seed)) never calls next_u64() on the global stream. An explicit seed consumes nothing from it. Whether that makes a whole component inert depends on when the component actually draws. RustyML groups components into 2 families:

  • Deferred-draw estimators: KMeans, SVC, LinearSVC, IsolationForest, DecisionTree, t-SNE, train_test_split, and the Sequential shuffle seed. These store random_state as a plain field and resolve it exactly once, inside fit. For these, inertness holds end to end. Constructing one with .with_random_state(s) touches nothing at build time. Fitting it uses seed s without advancing the global stream. So splicing one into a pipeline leaves every unseeded estimator’s sub-seed untouched.
  • Eagerly-initialized NN layers: Dense, the dropout and noise layers, and the convolutional and recurrent layers. These initialize their weights or masks at construction, through make_rng(None), which draws one sub-seed from the global stream. .with_random_state(s) is a post-hoc re-initialization. It overwrites the layer’s own weights, but the sub-seed that Dense::new already pulled is gone for good. A seeded layer still advances the global stream by exactly one. Its construction, not its seededness, fixes the sub-seeds of the layers built after it.

The eagerly-initialized family causes most errors. The program below splices a .with_random_state(999) layer between 2 unseeded layers. The layer after it shifts, because the spliced layer’s Dense::new consumed a sub-seed on the way in:

use ndarray::Array2;
use rustyml::neural_network::Tensor;
use rustyml::neural_network::layers::Activation;
use rustyml::neural_network::layers::dense::Dense;
use rustyml::neural_network::traits::Layer;
use rustyml::{clear_global_seed, set_global_seed};

fn row() -> Tensor {
    Array2::from_shape_vec((1, 4), vec![0.5, -1.0, 2.0, 0.25])
        .unwrap()
        .into_dyn()
}

fn max_abs_diff(a: &Tensor, b: &Tensor) -> f32 {
    a.iter()
        .zip(b.iter())
        .map(|(x, y)| (x - y).abs())
        .fold(0.0_f32, f32::max)
}

fn main() {
    let x = row();

    // Run A: 2 consecutive unseeded layers draw sub-seed #1 then sub-seed #2.
    set_global_seed(42);
    let a1 = Dense::new(4, 3, Activation::Linear).unwrap();
    let a2 = Dense::new(4, 3, Activation::Linear).unwrap();

    // Run B: identical, except a .with_random_state(999) layer is spliced in between.
    set_global_seed(42);
    let b1 = Dense::new(4, 3, Activation::Linear).unwrap();
    let _seeded = Dense::new(4, 3, Activation::Linear)
        .unwrap()
        .with_random_state(999);
    let b2 = Dense::new(4, 3, Activation::Linear).unwrap();
    clear_global_seed();

    let (pa1, pa2) = (a1.predict(&x).unwrap(), a2.predict(&x).unwrap());
    let (pb1, pb2) = (b1.predict(&x).unwrap(), b2.predict(&x).unwrap());

    // The first unseeded layer is unaffected: same seed, same construction position.
    assert_eq!(max_abs_diff(&pa1, &pb1), 0.0);

    // 2 consecutive unseeded layers differ: the stream advanced between them.
    assert!(max_abs_diff(&pa1, &pa2) > 1e-4);

    // b2 does NOT match a2. The spliced layer's Dense::new drew a sub-seed before
    // with_random_state re-initialized it, so b2 received sub-seed #3, not #2.
    assert!(max_abs_diff(&pa2, &pb2) > 1e-4);

    println!("layer construction advances the global stream, seeded or not");
}

The lesson applies to both families. To make a run survive refactoring that inserts or reorders stochastic components, give each one its own explicit random_state. Do not rely on the global stream’s ordering.

For a deferred-draw estimator, an explicit seed decouples it completely. For an eagerly-initialized layer, an explicit seed fixes that layer’s own weights. You must also keep the construction order stable between your seed call and each draw. The global seed suits a fixed, linear pipeline. Explicit per-component seeds are the reliable choice.

7.1.4. Thread-local semantics

The global seed lives in a thread_local! cell. This keeps set_global_seed lock-free and free of contention. It also has a hard consequence. The seed only affects the thread that called set_global_seed. Set it on the same thread that constructs your models, and everything works. Move construction to another thread, and the seed becomes invisible there.

This matters in 3 settings.

Spawned threads and async runtimes. A worker thread you start with std::thread::spawn, or a task inside tokio or rayon, starts with no global seed. It falls back to entropy. Call set_global_seed at the top of each worker. A better option is to give each component an explicit random_state, since that is thread-independent by construction.

Internal parallelism. Some estimators build their RNGs on the calling thread before they parallelize. KMeans builds a fresh k-means++ RNG once per restart, always before any parallel work. n_init defaults to 10. So a global seed reaches every restart.

IsolationForest is the exception. It constructs one RNG inside each per-tree closure. Once n_estimators >= 10, those closures run on Rayon worker threads through into_par_iter().

On the None path, each worker calls the resolver. The worker finds no thread-local global, because the seed lives on your thread, not on the worker’s thread. It falls back to entropy. A global seed alone does not make a parallel IsolationForest reproducible.

The explicit path avoids this problem. .with_random_state(s) gives tree i the seed s + i. It does not reference any thread-local state. So it reproduces identically, no matter which worker runs which tree.

For any component that builds randomness on worker threads, use an explicit random_state instead of the global seed. See 7.3. Performance Tuning and Parallelism for when parallelism starts.

The test harness. Rust’s default test harness spawns a fresh thread per test. Each test therefore starts with the global seed unset, which gives clean isolation. Under cargo test -- --test-threads=1, every test instead runs on one shared thread. A test that calls set_global_seed then leaks that seed into every later test that expected unseeded (entropy) behavior. Clear the seed afterward with a drop guard, so it clears even on panic. The crate’s own integration tests use this pattern:

#[must_use]
pub struct GlobalSeedGuard;

impl GlobalSeedGuard {
    pub fn set(seed: u64) -> Self {
        rustyml::set_global_seed(seed);
        GlobalSeedGuard
    }
}

impl Drop for GlobalSeedGuard {
    fn drop(&mut self) {
        rustyml::clear_global_seed(); // runs even on panic/unwind
    }
}

Bind it to a variable, for example let _seed = GlobalSeedGuard::set(123);. This keeps it alive for the test body and clears it on the way out. An unbound call, GlobalSeedGuard::set(123);, drops immediately. It clears the seed before you use it. This is why the type carries #[must_use].

7.1.5. What draws randomness across the crate

Everything below routes through the same resolver. One global seed covers all of it on the constructing thread, with the parallelism exception from the previous section. Each row also lists the per-component override. Use that override when you want independence from construction order or from the calling thread.

ComponentHow to seed itReached by the global seed?Notes
NN layer weight init (Dense, conv, recurrent, …).with_random_state(seed)YesRe-runs Xavier/Glorot uniform init. Call it before training. See 3.2.
Dropout / spatial dropout / gaussian noise masks.with_random_state(seed)YesThe mask RNG belongs to the layer. See 3.8.
Sequential minibatch shuffle.set_seed(seed) or Sequential::new_with_seed(seed)Yes (seed field defaults to None)Only affects fit_with_batches. Does not touch layer weights. See 3.1.
KMeans (k-means++ init).with_random_state(seed)YesRNG rebuilt on the calling thread once per k-means++ restart (n_init defaults to 10). See 2.7.
SVC / LinearSVC.with_random_state(seed)YesWorking-set selection (SVC) and minibatch shuffling (LinearSVC). See 2.5.
estimate_bandwidth (Mean Shift helper)estimate_bandwidth(x, quantile, n_samples, Some(seed))YesRandomness is in the subsampling. MeanShift::fit itself, including bin seeding, is deterministic. See 2.9.
IsolationForest.with_random_state(seed)Not on the parallel pathExplicit seed required for reproducibility once n_estimators >= 10. Per-tree seed is seed + i. See 2.13.
Decision tree split tie-breaking.with_random_state(seed)YesUses make_rng_opt. Ties are randomized only when a seed is in effect, otherwise fully deterministic.
t-SNE random init.with_random_state(seed) and .with_init(Init::Random)YesThe default Init::PCA is deterministic and ignores random_state. See 2.12.
train_test_split / train_test_split_stratifiedrandom_state: Option<u64> argumentYesControls the index shuffle. See 4.1.

2 rows need a closer look. The t-SNE seed does nothing on the default code path. Init::PCA initializes the embedding from the top principal components, and this is deterministic. So random_state only matters after you switch to .with_init(Init::Random).

Mean Shift is often mistaken for a seeded estimator. The MeanShift struct has no random_state field at all. The only draw in that module is the optional subsampling inside the free estimate_bandwidth helper. You call that helper yourself to pick a bandwidth.

Intentional exclusions

Not every pseudo-random draw in the crate routes through this module. Only draws with a lasting effect on the result do. The pca and kernel_pca dimensionality reducers are left out on purpose.

Their iterative eigensolvers (power iteration, Lanczos) seed a random starting vector with a fixed constant. These methods converge to the same eigenvectors regardless of the starting vector. So the seed is observationally inert. It only pins an otherwise-arbitrary eigenvector sign. Using global state here would make that sign choice less reproducible, for no benefit.

Randomized SVD (pca’s SVDSolver::Randomized(u64)) takes its seed inside the public solver variant. The caller always pins it explicitly. There is no None path for the global seed to fill. The general rule: route a draw through this module only when it makes a pseudo-random choice that changes the result.

7.1.6. What a seed does not freeze

A seed makes the pseudo-random choices reproducible. It does not make every part of a run bit-identical.

Floating-point reduction order is a separate axis. Summing a vector or a matrix product in parallel can produce results that differ in the last bits from a serial sum. Floating-point addition is not associative, and this has nothing to do with seeding. For bit-stable numeric results, use the parallelism controls, not the seed. See 6.3. Parallel Reductions and 7.3. Performance Tuning and Parallelism.

Cross-machine bit-identity is not guaranteed. With the same seed, 2 machines make identical random choices. Differences in floating-point rounding, SIMD width, thread count, and the BLAS-free matmul backend’s reduction order can still make the final weights differ in low-order bits. The same seed gives the same sequence of decisions. It does not give byte-for-byte identical floats across architectures.

A seed does not survive a thread hop, and it does not persist across a save or load by itself. Reloading a trained model from disk gives you its frozen weights, not the RNG stream that produced them. If you continue training a reloaded model, set the seed again on the current thread. See 7.2. Model Persistence in Depth.

7.1.7. Recipes

Reproducible experiment template

For a single, linear construction sequence on one thread, call set_global_seed once at the top. This is the simplest way to make an entire pipeline reproducible. Leave every component unseeded (random_state == None), and let each one draw its sub-seed from the stream in order. This includes the Sequential shuffle, whose seed field defaults to None and is therefore covered by the global seed too:

use ndarray::Array2;
use rustyml::neural_network::Tensor;
use rustyml::neural_network::layers::Activation;
use rustyml::neural_network::layers::dense::Dense;
use rustyml::neural_network::losses::MeanSquaredError;
use rustyml::neural_network::optimizers::SGD;
use rustyml::neural_network::sequential::Sequential;
use rustyml::set_global_seed;

fn t2(rows: usize, cols: usize, data: Vec<f32>) -> Tensor {
    Array2::from_shape_vec((rows, cols), data).unwrap().into_dyn()
}

fn main() {
    // One call up front. Every unseeded draw below derives from this, in order.
    set_global_seed(2026);

    #[rustfmt::skip]
    let x = t2(4, 4, vec![
        0.5, -1.0,  2.0,  0.25,
        1.0,  0.0, -0.5,  1.5,
       -2.0,  0.5,  1.0, -1.0,
        0.25, 2.0, -1.5,  0.0,
    ]);
    let y = t2(4, 1, vec![1.0, 0.0, -1.0, 0.5]);

    let mut model = Sequential::new(); // no per-layer seeds, no explicit set_seed
    model
        .add(Dense::new(4, 3, Activation::ReLU).unwrap())   // sub-seed #1
        .add(Dense::new(3, 1, Activation::Linear).unwrap()) // sub-seed #2
        .compile(SGD::new(0.05, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());

    // batch_size < n_samples exercises the per-epoch shuffle (sub-seed #3).
    model.fit_with_batches(&x, &y, 5, 2).unwrap();

    let p = model.predict(&t2(1, 4, vec![0.5, -1.0, 2.0, 0.25])).unwrap();
    println!("prediction shape: {:?}", p.shape());
}

Run this program twice, and the trained weights come out byte-identical. The construction order, 2 layer inits then the shuffle RNG, pulls the same 3 sub-seeds from the 2026 stream each time. Reorder those .add calls, and the sub-seed assignment changes. That is the order-sensitivity from section 7.1.3, in concrete form.

Per-component random_state overrides

Set random_state directly on each component when you want it pinned independently of construction order and of which thread runs it. This is the reliable choice for library code, parallel estimators, and code you refactor often. Explicit seeds ignore the global stream entirely. This also lets you reproduce a single component while leaving the rest free:

use ndarray::{Array1, Array2, array};
use rustyml::prelude::*;

fn main() {
    // 2 well-separated blobs, 6 samples, 2 features.
    let x: Array2<f64> = array![
        [0.0, 0.0], [0.2, 0.1], [0.1, -0.2],
        [5.0, 5.0], [5.2, 4.9], [4.8, 5.1],
    ];
    let y: Array1<usize> = array![0, 0, 0, 1, 1, 1];

    // Same random_state => same index shuffle => same split, every run.
    let split_a = train_test_split(x.clone(), y.clone(), Some(0.5), Some(42)).unwrap();
    let split_b = train_test_split(x.clone(), y.clone(), Some(0.5), Some(42)).unwrap();
    assert_eq!(split_a.0, split_b.0); // x_train identical
    assert_eq!(split_a.2, split_b.2); // y_train identical

    // Same random_state => same k-means++ init => same labels, independent of the above.
    let labels_1 = KMeans::new(2, 100, 1e-4)
        .unwrap()
        .with_random_state(7)
        .fit_predict(&x)
        .unwrap();
    let labels_2 = KMeans::new(2, 100, 1e-4)
        .unwrap()
        .with_random_state(7)
        .fit_predict(&x)
        .unwrap();
    assert_eq!(labels_1, labels_2);

    println!("split and k-means both reproducible under explicit seeds");
}

You can mix the two styles freely. A common pattern uses set_global_seed for the ambient defaults. It adds an explicit random_state on the one estimator you need to hold fixed, while you sweep everything else. Explicit seeds are inert against the global stream. So pinning that one estimator does not disturb the sub-seeds every other component receives.

7.2. Model Persistence in Depth

RustyML gives you 2 ways to persist a model. Both write the same wire format, but they diverge in one important way. A classical estimator serializes the entire model: its hyperparameters, its learned parameters, and its training metadata. Loading it back gives you a ready-to-predict object, with no extra work. A neural network serializes weights only. You rebuild the architecture in code, then load the arrays back into it.

Both paths write postcard, a compact, non-self-describing binary format. This one choice explains why the files are small and why only Rust can read them. It also explains why loading a file across a version boundary is a hazard.

The 2 paths handle that hazard differently. A neural-network file starts with a magic tag and a format version. The loader checks both first, so an incompatible release fails loudly. A classical file has no header at all, so you manage the version boundary yourself.

This page is the low-level companion to 3.9. Saving and Loading Weights. Section 3.9 teaches the neural-network workflow. This page takes both paths down to the byte level. It covers the failure surface for both paths. It also covers operational patterns such as versioning, atomic writes, and interop. The 2 convenience methods do not give you these for free.

7.2.1. Two APIs, one format

The 2 subsystems expose different method signatures on purpose. Treating them as the same method causes problems.

// Classical ML models. The `model_save_and_load_methods!` macro in lib.rs generates these:
impl LinearRegression {
    pub fn save_to_path(&self, path: &str) -> Result<(), rustyml::error::Error>;
    pub fn load_from_path(path: &str) -> Result<Self, rustyml::error::Error>;
}

// Sequential neural network:
impl Sequential {
    pub fn save_to_path(&self, path: impl AsRef<std::path::Path>) -> rustyml::error::RustymlResult<()>;
    pub fn load_from_path(&mut self, path: impl AsRef<std::path::Path>) -> rustyml::error::RustymlResult<()>;
}

Three differences matter here. First, the classical save_to_path and load_from_path take &str only. They do not take impl AsRef<Path>. A PathBuf argument needs an explicit .to_str().unwrap() call, or the code fails to compile. The neural-network methods accept any AsRef<Path>.

Second, the classical load_from_path is an associated function. It returns an owned Self. There is nothing to load data into, because the whole model comes from the file. Sequential::load_from_path works differently. It takes &mut self and changes a model you already built.

Third, RustymlResult<()> is an alias for Result<(), Error>. So the error type is the same on both paths. Every failure is a variant of the crate’s unified Error.

The macro that generates the classical pair lives in lib.rs as model_save_and_load_methods!. It applies, unchanged, to 13 types in the machine_learning module: LinearRegression, LogisticRegression, KNN, DecisionTree, SVC, LinearSVC, LDA, KMeans, DBSCAN, MeanShift, PCA, KernelPCA, and IsolationForest. It also applies to 5 scalers in the utils module: MaxAbsScaler, MinMaxScaler, Normalizer, RobustScaler, and StandardScaler. Every one of these 18 types gets the same 2 methods, with the same behavior.

7.2.2. Classical ML: the whole model on disk

The macro body is short. save_to_path calls postcard::to_allocvec(self), then writes the bytes through a buffered writer. load_from_path reads the file, then calls postcard::from_bytes::<Model>(&bytes).

The model struct derives Serialize and Deserialize, so every field travels with it. For LinearRegression, that includes coefficients, intercept, fit_intercept, the post-fit n_iter, regularization_type, and the solver field. The solver field is a LeastSquaresSolver. When you select gradient descent, that same enum also carries the learning_rate, max_iter, and tol settings.

Classical persistence has no separate “config” and “weights” split. That split matters a great deal on the neural-network side, but it does not exist here. This is why a loaded model is ready to use right away.

load_from_path gives you back an object you cannot tell apart from the one you trained. You do not need to call fit again. You do not need to set any parameter again. There is no compile step.

use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

fn main() {
    let x = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 2.0, 1.0, 3.0, 5.0, 4.0, 3.0]).unwrap();
    let y = Array1::from_vec(vec![5.0, 5.0, 13.0, 11.0]);

    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    let path = "lr_whole_model.bin";
    model.save_to_path(path).unwrap();

    // Load returns a model ready to use, with no re-fit, no rebuild, and no compile step.
    let restored = LinearRegression::load_from_path(path).unwrap();

    // The round trip preserves both the learned parameters and the hyperparameters.
    assert_eq!(restored.get_solver(), model.get_solver());
    assert_eq!(restored.get_coefficients().unwrap().len(), 2);

    let probe = Array2::from_shape_vec((1, 2), vec![2.0, 4.0]).unwrap();
    let a = model.predict(&probe).unwrap();
    let b = restored.predict(&probe).unwrap();
    println!("live vs restored prediction gap: {:e}", (a[0] - b[0]).abs());

    std::fs::remove_file(path).unwrap();
}

If you know scikit-learn’s pickle or joblib, the mental model matches. The whole estimator makes a round trip. Two real differences remain.

First, postcard is not pickle. It runs no code when it loads a file. A hostile file cannot trigger arbitrary code execution. The only risk is malformed or mismatched bytes.

Second, postcard carries no class identity. It has no __module__ field and no version stamp. The next sections cover what that costs you.

7.2.3. Neural networks: weights, and the enum that carries them

Section 3.9 covers the neural-network path from end to end. This section covers the mechanism underneath it.

Sequential::save_to_path builds a SerializableSequential { magic, format_version, layers: Vec<SerializableLayer> }. Each SerializableLayer pairs a LayerInfo { layer_type, output_shape } metadata tag with a LayerWeight<'a> value, taken from layer.get_weights().

The 2 leading u32 values form the file header: MODEL_MAGIC ("RMLM") and MODEL_FORMAT_VERSION. They come first on purpose. A file written before this header existed starts with its layer count instead. The loader reads that small integer where the tag belongs, and rejects the file. This happens before the loader parses far enough to apply even 1 weight.

The enum after the header holds the on-disk weight format:

pub enum LayerWeight<'a> {
    Dense(DenseLayerWeight<'a>),
    SimpleRNN(SimpleRNNLayerWeight<'a>),
    LSTM(LSTMLayerWeight<'a>),
    GRU(GRULayerWeight<'a>),
    Conv1D(Conv1DLayerWeight<'a>),
    Conv2D(Conv2DLayerWeight<'a>),
    Conv3D(Conv3DLayerWeight<'a>),
    SeparableConv2D(SeparableConv2DLayerWeight<'a>),
    DepthwiseConv2D(DepthwiseConv2DLayerWeight<'a>),
    BatchNormalization(BatchNormalizationLayerWeight<'a>),
    LayerNormalization(LayerNormalizationLayerWeight<'a>),
    InstanceNormalization(InstanceNormalizationLayerWeight<'a>),
    GroupNormalization(GroupNormalizationLayerWeight<'a>),
    Empty, // no trainable parameters: Dropout, pooling, flatten, pure activation layers
}

Two design choices matter here. First, each per-layer struct stores its arrays as Cow. One type serves both directions. get_weights borrows the live arrays with Cow::Borrowed, so saving clones nothing. Loading fills Cow::Owned arrays instead, used as LayerWeight<'static>.

Second, the enum uses serde’s default representation, called externally tagged. It writes an explicit variant tag before the payload. Postcard is non-self-describing, so without this tag it could not tell a Dense payload from a Conv2D one.

The file deliberately leaves out any constructor information. It has no activation function, no epsilon, no momentum, no stride, no kernel size, no dilation, and no group count. Those settings live in your source code, not in the file. The layer_type and output_shape strings in LayerInfo are validation tags, not a build recipe. This is exactly why loading needs a pre-built model, ready to receive weights.

A pre-built model lets the neural-network loader do something the classical loader cannot do: validate structure. load_from_path checks the layer count. It checks each layer’s layer_type string against the model you built. Inside apply_weights_to_layer, it downcasts each layer to its concrete type, then calls set_weights. This step also catches a shape disagreement. Any of these checks that fails raises Error::Io(IoError::ModelStructureMismatch), with a message that names the problem.

The classical macro has no such guard. It deserializes bytes straight into the target struct, with no check at all. Loading a LinearRegression file into KMeans::load_from_path is not rejected by any type check. Postcard reads the bytes by position only.

You get a Serialization error in the common case, where the 2 layouts disagree enough. In the unlucky case, the bytes happen to fit the other layout, and you get a model that looks valid but means nothing. For a classical model, the file name or an outside label is the only thing that stops you from making this mistake.

7.2.4. The postcard wire format

Postcard is a minimal binary format. One property governs everything about it: postcard is non-self-describing.

The bytes hold values in field order, and nothing else. There are no field names, no type names, no schema, and no length-prefixed section you could skip over. Serializing a LinearRegression does not write the string "learning_rate" anywhere. It writes the 8 bytes of the f64 value, at the exact position where learning_rate sits inside the serialized LeastSquaresSolver.

This is the whole reason the files stay small. It is also the reason they are fragile. Reading the bytes back correctly depends on the reading side having the exact same type layout as the writing side.

The per-type encoding matters when you reason about file size, or when you debug a broken file:

Rust typepostcard encodingbytes
boolsingle byte, 0 or 11
f32 (neural-network weights)fixed width, little endian4
f64 (classical parameters)fixed width, little endian8
usize / u64 (for example max_iter, or a length)LEB128 varint1 to 10
enum variant (Solver, LayerWeight)varint discriminant1 (plus the payload)
Option<T>1 tag byte (0 = None, 1 = Some)1 (plus T if Some)
String (a layer type tag)varint length, then UTF-8 bytesvaries
ndarray Array (through serde)small version and shape header, then a varint element count, then the elementsvaries

Floats use a fixed width. An array of N values costs N x 4 bytes for f32, or N x 8 bytes for f64. Add a small shape header and a length prefix on top. The round trip stays lossless, not lossy.

Integers and lengths use varints, so a small count costs 1 byte. Postcard applies no compression and no alignment padding.

The format’s small size is also its fragility. Nothing in the file carries a label. Suppose a struct’s layout drifts between the version that wrote the file and the version that reads it. A field might get added, removed, reordered, or given a new type.

Deserializing that file has no name left to check against. The likely result is an “unexpected end of input” error, or a bad-tag Serialization error. The dangerous result is a silent misparse, where the drifted layout still happens to consume the same number of bytes.

This is not a bug in postcard. It is the price you pay for a small file. It is also why versioning, covered later on this page, is your job, not the format’s job.

Several classical estimators changed their on-disk layout in this release. LinearRegression folded 3 separate iteration settings into the solver payload. KMeans gained a new n_init field. LDA gained a new field for the overall training mean. A file saved by an older version of any of these 3 types fails to load. Re-fit the model, then save it again.

MeanShift changed its layout too, in a way that also changes meaning. Its labels field changed type, from usize to isize. An unassigned point’s label changed meaning as well: it used to equal the cluster count, and now it is -1. On top of that, the cluster centers themselves now come from a different algorithm. An old MeanShift file, if it still loads at all, holds centers computed under the old Gaussian kernel, not the current flat kernel.

IsolationForest changed only in meaning, not in layout. Its offset field keeps the same type and the same position, but the stored number now carries the opposite sign. Re-fit and re-save MeanShift and IsolationForest models too, even where the shapes still line up.

7.2.5. File size: a back-of-envelope you can trust

Postcard applies no compression and no framing, so you can predict a file’s size to within a few bytes.

For a classical model, the learned arrays dominate the payload. A LinearRegression with p features costs about p x 8 bytes for its coefficients, plus a few dozen bytes for scalar hyperparameters and headers.

For a neural network, add up (weight_elements + bias_elements) x 4 bytes across every layer, since neural-network weights use f32. Add a small amount for each layer’s type and shape strings.

A Dense(784 -> 128) layer has 784 x 128 + 128 = 100,480 parameters. That is about 392 KB. The same layer in a classical, f64-based world would cost twice as much. Measure the size directly instead of estimating it:

use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

fn main() {
    let n_features = 8usize;
    let n_samples = 20usize;
    let x = Array2::from_shape_fn((n_samples, n_features), |(i, j)| (i + j) as f64 * 0.1);
    let y = Array1::from_shape_fn(n_samples, |i| i as f64);

    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    let path = "lr_size_probe.bin";
    model.save_to_path(path).unwrap();

    let on_disk = std::fs::metadata(path).unwrap().len();
    // One f64 per coefficient is the dominant term. Everything else is scalars and small headers.
    let coefficient_bytes = (n_features * std::mem::size_of::<f64>()) as u64;
    println!("file: {on_disk} bytes, coefficient payload ~= {coefficient_bytes} bytes");

    std::fs::remove_file(path).unwrap();
}

In practice, this makes postcard checkpoints cheap to keep in bulk. A loop that saves every epoch and keeps the best model costs only kilobytes per checkpoint, for a small model. The limit on how often you save a checkpoint becomes disk-write speed, not file size.

For a large convolutional stack, the f32 weights dominate the file, and the file size tracks the parameter count closely. This makes capacity planning simple: count the parameters, then multiply by 4.

7.2.6. What loading actually rejects

Every persistence failure surfaces as Error::Io(...). Exactly 4 shapes exist underneath it.

IoError::Std wraps a std::io::Error. This covers a missing path, a permissions problem, or a read or write that failed.

IoError::UnsupportedModelFormat applies to neural networks only. It comes from the header check: the magic tag or the format version does not match this build.

IoError::Serialization wraps a postcard::Error. This means the bytes are not valid postcard for the layout the code expects, because of corruption, truncation, or a schema that no longer matches.

IoError::ModelStructureMismatch also applies to neural networks only, as section 7.2.3 covers. Classical models have no counterpart to it.

Two From implementations in error.rs let the ? operator lift a raw std::io::Error into IoError::Std, and a raw postcard::Error into IoError::Serialization. This mapping works the same way in both subsystems. Both Error and IoError carry #[non_exhaustive]. Always add a trailing catch-all arm when you match them.

use rustyml::machine_learning::*;
use rustyml::error::{Error, IoError};

fn main() {
    // This file holds bytes that are not valid postcard for a LinearRegression.
    let junk = "corrupt_lr.bin";
    std::fs::write(junk, b"\xff\xff\xff not a model").unwrap();

    match LinearRegression::load_from_path(junk) {
        Ok(_) => println!("unexpected success"),
        Err(Error::Io(IoError::Serialization(e))) => println!("bad bytes -> Serialization: {e}"),
        Err(Error::Io(IoError::Std(e))) => println!("io failure: {e}"),
        Err(e) => println!("other: {e}"),
    }
    std::fs::remove_file(junk).unwrap();

    // A missing path surfaces as IoError::Std, not Serialization.
    match LinearRegression::load_from_path("no_such_model_9f3a.bin") {
        Err(Error::Io(IoError::Std(e))) => println!("missing file -> Std: {e}"),
        other => println!("unexpected: {other:?}"),
    }
}

The neural-network test suite checks these mappings against the real API. A path that does not exist yields IoError::Std. A wrong magic tag or format version yields IoError::UnsupportedModelFormat. Corrupt bytes behind a well-formed header yield IoError::Serialization. A layer-count, layer-type, or weight-shape disagreement yields IoError::ModelStructureMismatch.

Classical models have a gap in that list: no structural error exists to catch. Serialization becomes the only signal that a file is wrong, and it is not a reliable one. If a corrupt classical file happens to deserialize anyway, load_from_path returns Ok. Build your own integrity check to guard against that. The next 2 sections build one.

7.2.7. Versioning across RustyML releases

Postcard files carry no version stamp. RustyML makes no promise that a model struct’s field layout stays stable across releases.

Add a hyperparameter to LinearRegression. Reorder a field. Change a field’s type. Any of these turns a file from an older version into bad input for the newer one. The usual result is a Serialization failure. Occasionally, the result is a silent misparse instead.

RustyML ships no built-in migration path. Adopt these 2 habits so this never surprises you in production.

First, pin the exact rustyml version that writes your long-lived checkpoints. This stops a routine cargo update from silently changing the on-disk layout under a directory of saved models:

[dependencies]
rustyml = { version = "=0.14.0", features = ["full"] }

Second, write a version sidecar. This is a small companion file, saved next to the model, that records the format identity. Refuse to load the model when the sidecar does not match what your binary expects. This turns a silent misparse into a loud, early error.

Note who needs the sidecar most. A Sequential file carries its own magic tag and format version, so the neural-network path already fails loudly across an incompatible release. There, the sidecar only adds your own schema identity on top. The classical macro path has no header at all. For those models, the sidecar is your whole defense.

use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

// Bump this value every time you upgrade the rustyml dependency that writes your checkpoints.
const CHECKPOINT_FORMAT: &str = "rustyml-0.14";

fn main() {
    let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 2.0, 3.0, 3.0, 4.0]).unwrap();
    let y = Array1::from_vec(vec![6.0, 9.0, 12.0]);

    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    let model_path = "sidecar_model.bin";
    let version_path = "sidecar_model.bin.version";
    model.save_to_path(model_path).unwrap();
    std::fs::write(version_path, CHECKPOINT_FORMAT).unwrap();

    // On load, check the recorded format string before deserializing.
    let recorded = std::fs::read_to_string(version_path).unwrap();
    if recorded != CHECKPOINT_FORMAT {
        panic!("checkpoint written by {recorded}, this binary expects {CHECKPOINT_FORMAT}");
    }
    let restored = LinearRegression::load_from_path(model_path).unwrap();
    println!("loaded {} coefficients", restored.get_coefficients().unwrap().len());

    std::fs::remove_file(model_path).unwrap();
    std::fs::remove_file(version_path).unwrap();
}

Make the version string specific enough to matter. Use the rustyml version at minimum. Add your own schema counter too, if you wrap models inside a larger record. The sidecar costs almost nothing to write. It turns the format’s worst failure mode, silent wrong numbers, into a panic you catch during testing.

7.2.8. Atomic checkpoints

Both save_to_path methods call File::create, which truncates the target file right away. Suppose the process crashes, the disk fills up, or something kills the training loop partway through the write. You are left with a truncated file, at the exact path your restart logic tries to load. Truncation causes a Serialization error at best. At worst, it gives you a silently short array.

The standard defense is write-then-rename. Serialize the model to a temporary path on the same file system, then call std::fs::rename to move it over the final path. Rename works atomically on POSIX file systems. A reader sees either the complete old file or the complete new file, and never a half-written one.

use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

/// Saves through a temporary file, then an atomic rename. A crash during the
/// write never leaves a half-written checkpoint at `final_path`.
fn save_atomically(model: &LinearRegression, final_path: &str) -> std::io::Result<()> {
    let tmp_path = format!("{final_path}.tmp");
    model
        .save_to_path(&tmp_path)
        .expect("serialize to temp file");
    std::fs::rename(&tmp_path, final_path)
}

fn main() {
    let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 2.0, 3.0, 3.0, 4.0]).unwrap();
    let y = Array1::from_vec(vec![6.0, 9.0, 12.0]);

    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    let path = "atomic_model.bin";
    save_atomically(&model, path).unwrap();

    let restored = LinearRegression::load_from_path(path).unwrap();
    println!("intercept present after atomic save: {}", restored.get_intercept().is_some());

    std::fs::remove_file(path).unwrap();
}

Keep the temporary file on the same file system as the destination. A rename across file systems is not atomic, and it falls back to a copy plus a delete.

Pair this with the sidecar from section 7.2.7. Rename the model file first, then rename the version file second. A torn write then leaves the version sidecar pointing at the previous, complete model, instead of at a broken new one.

For a loop that keeps only the best checkpoint, an atomic replace also means your best-so-far file is never missing, even for a moment.

7.2.9. Crossing the language boundary

Postcard belongs to the Rust ecosystem. No maintained Python or R reader exists for it. Even if one did, the non-self-describing bytes would need a hand-written schema that mirrors RustyML’s exact struct layout, and that layout changes between versions.

Treat a .bin checkpoint as a RustyML-to-RustyML artifact, and nothing else. When another tool needs to read a trained model, do not try to parse the postcard file. Instead, export the parameters through the getters, into a portable format the other tool already reads. Classical models expose everything you need for this: LinearRegression::get_coefficients and get_intercept, KMeans::get_centroids, and matching accessors on the other estimators.

use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

fn main() {
    let x = Array2::from_shape_vec(
        (3, 3),
        vec![1.0, 0.0, 2.0, 0.0, 1.0, 1.0, 2.0, 2.0, 0.0],
    )
    .unwrap();
    let y = Array1::from_vec(vec![4.0, 3.0, 6.0]);

    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    // Pull the learned parameters out through the getters, then write portable CSV.
    // A Python, R, or spreadsheet consumer can read this directly. Postcard plays no part.
    let coefficients = model.get_coefficients().expect("model is fitted");
    let intercept = model.get_intercept().unwrap_or(0.0);

    let mut csv = String::from("term,value\n");
    for (i, c) in coefficients.iter().enumerate() {
        csv.push_str(&format!("x{i},{c}\n"));
    }
    csv.push_str(&format!("intercept,{intercept}\n"));

    print!("{csv}");
}

The same pattern works elsewhere. Dump the getter output as CSV, for a spreadsheet or for pandas read_csv. Or assemble it into a JSON object that your service already reads.

For a neural network, loop over Sequential::get_weights(), then write each LayerWeight variant’s arrays out, layer by layer. The arrays are ndarrays, so .iter() plus your own formatter does the job.

This costs you 2 things: the compactness of postcard, and the exactness guarantee of a same-format round trip. Keep the postcard file as your canonical checkpoint, for reloading back into RustyML. Treat the exported CSV or JSON as a one-way view for other tools only.

Remember what the getters do and do not include when you export. A classical export captures the fitted parameters, but unlike the postcard file, it drops the hyperparameters and the metadata. A neural-network export holds weights only, for the same reasons section 3.9 explains.

For 2 related operational topics, see the neighboring pages. 7.1. Reproducibility and Random Seeds covers reseeding a model after a load, so a resumed shuffle stays reproducible. 7.3. Performance Tuning and Parallelism covers the throughput of writing many checkpoints under parallelism.

7.3. Performance Tuning and Parallelism

RustyML parallelizes with rayon, and only rayon. It does not link BLAS, and it does not use OpenMP. Every kernel in the crate runs on rayon’s global pool, except one.

The matrix-product backend, gemmkit, is the exception. gemmkit keeps a small number of persistent, private rayon pools, each sized to a fraction of the machine width. It installs the smallest pool that still holds every worker a product needs, so the fork-join step sees no idle slack.

These pools are still rayon pools. gemmkit builds each one once and reuses it warm. gemmkit skips its own pools when it already runs on another rayon pool. It falls back to the ambient pool when no tier is wide enough.

So the claim “rayon and only rayon” stays true. The claim “no thread pool of its own” does not. Every hot loop that can spread across cores does so, but none of them forks rayon without a check first.

Each parallel kernel sits behind a gate: a work estimate compared against a calibrated threshold. A small input stays on 1 core. The tuning module moves these gates at runtime. The shipped defaults may not fit your CPU. Recalibrate them without guesswork.

If you know scikit-learn’s n_jobs or Keras’ intra_op_parallelism_threads, note a difference. RustyML has no single knob. The crossover between “serial is faster” and “rayon is faster” differs by kernel and by element width.

7.3.1. The parallelism model: rayon behind a gate

Rayon is not free. Handing a task to the pool costs a fork, a join, and, for a reduction, some collecting. For a large matrix product or a million-element exp map, this overhead disappears into the work. For a 32x32 GEMM inside 1 LSTM timestep, or a ReLU over a few thousand activations, the fork/join step is the runtime. There the parallel version loses to a single core running at memory bandwidth.

So every gated kernel first estimates its own work. A convolution counts FLOPs. A map or a reduction counts elements. A tree walk counts node visits. Pooling counts window taps. Only when that estimate clears a threshold calibrated to the crossover point does the kernel use rayon.

RustyML does not gate the matrix product itself. Scheduling there belongs entirely to gemmkit. 6.2. Matrix Multiplication covers the backend and the small amount the crate adds to it. The shape of gemmkit’s decision differs from RustyML’s own gates.

First, a work gate, parallel_threshold, defaults to 48 * 48 * 256 = 589_824. It compares the m * n * k product, not a FLOP count. There is no factor of 2 to remember. Below the gate, the product runs on 1 thread, no matter how many workers the caller requested.

Above the gate, the worker count ramps with the total work, not with any single dimension. gemmkit assigns 1 worker per par_mnk_per_worker (2,000,000 by default) of m * n * k. The count is floored at 1 and capped by the core count and the job count. Exact crossover numbers are machine-specific. Inspect the current values through tuning::matmul::backend, or measure your own machine with the gemmkit-tune autotuner (see 7.3.5).

gemmkit then snaps the ramped width to one of the persistent pool tiers described above. Rayon’s fork-join tax scales with a pool’s slack, its width minus the workers doing real work. An 8-worker product in an exact 8-wide pool beats the same product in the 32-wide global pool by a wide margin.

A matvec shape (m == 1 or n == 1) leaves this path for a dedicated bandwidth-bound one. It stays serial below a byte floor derived from the machine’s L2 cache size, then splits across a worker cap sized to memory bandwidth.

RustyML’s own kernels all run on the global rayon pool, so they compose safely when nested. A parallel outer loop that calls into a gated convolution or reduction does not oversubscribe the machine. The inner work nests into the same pool instead of spawning a second wave of threads.

The matrix product behaves the same way from the outside. gemmkit checks whether it already runs on a rayon worker. If so, it stays in the caller’s pool instead of installing a private tier. This is also why the crate’s internal call sites can force a product serial (Parallelism::Serial) when they already sit inside a parallel region. That choice avoids forking twice. It has nothing to do with correctness.

7.3.2. What a gate changes, and what it never changes

The following contract from the tuning module docs makes tuning safe:

A gate only picks an execution strategy. It never changes what the code computes. The elementwise and reduction gates give the same result serial or parallel. The matrix-product scheduling lives in the gemmkit backend (see the matmul submodule). Its results reproduce on the same machine for a fixed configuration regardless of worker count. The matmul gates kept here only shape caller-side tiling. Retuning a gate does not change any result.

That single paragraph holds 2 different guarantees. The elementwise maps (ReLU, sigmoid, scaling, normalization) are embarrassingly parallel. Each output element is independent. Serial and parallel give bit-identical results, so moving the gate cannot change a single bit.

The reductions (sums of squares, Welford moments, clip-by-global-norm) are trickier. Floating-point addition is not associative. A naive rayon sum would group its partial sums by work-stealing, and give a different rounding on every run. RustyML avoids this with the deterministic blocked fold in crate::math::reduction. The fold cuts the input into fixed-size blocks. The grouping depends only on a compile-time block size, never on the thread count or the gate.

So above a reduction gate, the parallel path still matches the serial result. Moving the gate never changes it.

The matrix product used to make a weaker promise. The old hand-rolled row split changed the block height with the thread count, and so changed the summation order. It no longer does. gemmkit’s blocking and job order do not depend on the worker count. For a fixed machine and configuration, the same product reproduces the same result bit-for-bit, no matter how many threads ran it. Re-running it is also deterministic.

The crate’s tests check this claim, at the following scope. For f64, a forced-serial product is compared on to_bits() against the same product forced onto 2, 4, 8, 16, and 32 workers. The check covers a square shape, a shallow-k shape, and a deep-k shape. For f32 the sweep is narrower: 2, 4, and 32 workers over 2 shapes.

The matvec check is narrower still. It compares forced-serial against automatic scheduling on 1 shape, with no worker sweep. A separate test compares a fused bias+ReLU epilogue bitwise against a plain product followed by the same scalar map.

Together these tests are good evidence for the guarantee. They are not an exhaustive proof of it, and the guarantee itself belongs to the backend, not to the tests. The matmul path is no longer the weak link. On 1 machine, it reproduces as well as the reductions do.

The caveat is the “fixed machine and configuration” clause. A different CPU picks a different SIMD width. A changed backend knob can change the blocking. Cross-machine bit-equality is still not a promise.

The deterministic reductions in 6.3 stay the stricter guarantee. They give the same answer by construction. They do not depend on the backend happening to block the same way on 2 machines.

The practical result stays the same: you can retune any gate freely, and your model’s outputs will not move. A gate is a performance knob, never a numerical one. For exact reproducibility across machines, see 7.1. Reproducibility and Random Seeds instead.

7.3.3. The tuning module: the runtime override surface

Every gate is a process-global AtomicUsize, initialized to its calibrated default. The tuning module is the facade that makes every gate discoverable. For each gate, it exposes a set_*(usize) function and a get_*() -> usize function, grouped into submodules by kernel family. Setters and getters are plain functions. Read the defaults your build shipped with like this:

use rustyml::tuning;

fn main() {
    // RustyML's own matmul knobs: the caller-side tiling policy, and nothing else.
    println!("chunk elems:   {}", tuning::matmul::get_chunk_elems());
    println!("cache bytes:   {}", tuning::matmul::get_cache_resident_max_bytes());

    // The product's scheduling knobs belong to the backend, reached through the alias.
    println!("mnk gate:      {}", tuning::matmul::backend::parallel_threshold());
    println!("mnk/worker:    {}", tuning::matmul::backend::par_mnk_per_worker());
    println!("pool tiers:    {}", tuning::matmul::backend::pool_classes());

    // Elementwise maps and deterministic reductions (element counts).
    println!("exp map f32:   {}", tuning::elementwise::get_exp_map_f32());
    println!("cheap map f64: {}", tuning::elementwise::get_cheap_map_f64());
    println!("sum f64:       {}", tuning::reduction::get_sum_f64());
    println!("exp reduce:    {}", tuning::reduction::get_exp_reduce());

    // Tree walks, conv/pool engines, normalization, metrics.
    println!("tree visits:   {}", tuning::tree::get_traversal_min_visits());
    println!("conv flops:    {}", tuning::conv::get_parallel_min_flops());
    println!("pool ops:      {}", tuning::pool::get_parallel_min_ops());
    println!("gn param grad: {}", tuning::norm::get_gn_param_grad());
    println!("silhouette:    {}", tuning::metrics::get_silhouette());
}

The matmul submodule has a distinct shape. It owns 2 gates of its own, plus backend, a pub use gemmkit_ndarray::tuning re-export. Every GEMMKIT_* knob is reachable through a set_*/getter pair, with no direct gemmkit dependency in your Cargo.toml. The re-export forwards through the adapter on purpose: the knobs are process-global atomics, so a set_* call made on a separately resolved second gemmkit would write a copy that the adapter never reads. The getters under backend use bare names, for example parallel_threshold(), not the get_-prefixed style. That naming is gemmkit’s own, not RustyML’s facade.

Here is the full surface, with the shipped default and the unit each gate compares against:

tuning::matmul::DefaultGated on
set_/get_chunk_elems33_554_432element budget for one row-chunk of a tiled product (KNN, t-SNE, MeanShift)
set_/get_cache_resident_max_bytes67_108_864shared-L3 size (bytes) for the per-row-GEMV-swarm vs. tiled-GEMM decision
backend::*see gemmkitthe whole GEMMKIT_* surface, re-exported: the product’s work gate, worker ramp, pool tiers, packing and blocking knobs
tuning::elementwise::DefaultGated on
set_/get_cheap_map_f324_000_000f32 memory-bound maps (ReLU, dropout mask), element count
set_/get_exp_map_f32131_072f32 exp-dominated maps (sigmoid, tanh, softmax)
set_/get_spatial_dropout_scale4_194_304spatial-dropout per-channel scale
set_/get_fused_slice1_000_000fused multi-slice optimizer updates
set_/get_cheap_map_f644_000_000f64 memory-bound maps (centering, scaling, normalization)
set_/get_exp_map_f6465_536f64 exp-dominated maps (logistic sigmoid, RBF/Sigmoid kernels)
tuning::reduction::DefaultGated on
set_/get_sq_sum_f3265_536f32 square-sum (clip-by-global-norm), element count
set_/get_sum_f64262_144f64 sum-style reductions (sum of squares, Welford)
set_/get_scan_f64262_144short f64 row scans (KMeans arg-min, LDA, DBSCAN/MeanShift distance scans), total elements scanned
set_/get_exp_reduce32_768logistic-loss exp-reduction
tuning::tree::DefaultGated on
set_/get_traversal_min_visits262_144DecisionTree/IsolationForest predict, total node visits
set_/get_sort_scan_min_elems8_192DecisionTree split-search, total sorted elements (node_samples * features)
tuning::conv:: / tuning::pool::DefaultGated on
conv::set_/get_parallel_min_flops4_000_000im2col+GEMM convolution engine, estimated FLOPs
conv::set_/get_naive_parallel_min_flops1_000_000naive depthwise/separable convolution, estimated FLOPs
pool::set_/get_parallel_min_ops12_000pooling engine, estimated element-ops

The normalization layers add 7 more gates under tuning::norm::: set_/get_batch_norm, set_/get_bn_col_stats, set_/get_bn_plane_stats, set_/get_ln_row, set_/get_ln_col_stats, set_/get_gn_row, and set_/get_gn_param_grad. All 7 ship at 262_144. The clustering metric adds 1 more, tuning::metrics::set_/get_silhouette, also at 262_144.

A gate whose feature is not compiled in is simply not there. tuning::conv, tuning::pool, and tuning::norm need neural_network. tuning::tree needs machine_learning. tuning::metrics needs metrics. tuning::matmul and reduction::exp_reduce need math.

The elementwise gates split in 2: the f32 half needs neural_network, and the f64 half needs machine_learning or utils. Build with only the modules you use (see 1.2. Installation and Feature Flags and 7.4. Minimal Builds and Modular Integration). The irrelevant knobs then vanish.

This list is short by design. RustyML owns 2 matmul knobs and about 2 dozen kernel gates. Every one is a threshold on a work estimate.

There is no per-dtype matrix-product gate any more. f32 and f64 GEMM used to have separate crossovers here. They no longer do, because the backend gates on m * n * k, and the element width is the backend’s business, not yours. If a matrix product runs with the wrong parallelism on your machine, look in backend, not in this table.

7.3.4. Why the shipped defaults may be wrong for your machine

Most of these defaults were measured on the maintainer’s hardware. The tuning module docs record the machine: an AMD Ryzen 9 9950X with 16 cores, 32 threads, and 64 MiB L3. The few defaults that were not measured there are worse, not better. cache_resident_max_bytes is an educated guess. pool_parallel_min_ops is deliberately parked away from its measured bracket, for a reason given in 7.3.5.

A serial/parallel crossover is not a universal constant. It is a ratio: how fast 1 core runs the kernel, against how much fork/join overhead rayon adds. Both ends move with the machine. More cores lower the per-core share of a fixed problem, so the parallel side needs a bigger problem to break even. Faster single-thread SIMD raises the serial baseline. A larger L3 keeps more of a matrix resident, and this shifts where the tiled-GEMM-versus-GEMV-swarm decision flips.

The gate tied most obviously to one machine is cache_resident_max_bytes. The source documents it as: set this to the machine’s actual shared-L3 size. The default of 64 MiB is a guess at a typical L3. The band around it has not been calibrated.

The backend’s knobs carry the same caveat, one level down, and gemmkit is direct about it. par_mnk_per_worker defaults to 2,000,000, and pool_classes defaults to 2 tiers on x86. Both defaults come from that same Zen5 9950X, 32 hardware threads over 16 physical cores. There the 2 tiers are width/4 (8 workers) and width/2 (16 workers, the physical core count).

The aarch64 arm of pool_classes defaults to 1 tier instead. That tier was measured on an M4 Max (14 cores, 10 performance and 4 efficiency, no SMT). There the 1 tier is width/2 (7 workers).

Those are 2 real machines, not a model of yours. A knob whose crossover depends on the architecture ships with a cfg(target_arch)-split default. Each side is calibrated on its own reference machine. On any third architecture, the pool tiers default to off, pending on-device validation. Measure your own machine with the gemmkit-tune autotuner (see 7.3.5), and apply the result as a GEMMKIT_* profile.

Your CPU may differ a lot from the reference machines. Examples: an 8-core laptop against a 32-core workstation, half the L3, or an Apple-silicon NEON target against AVX-512. In that case, the defaults stay in the right neighborhood but miss the optimum.

For most workloads the difference is small. The elementwise and reduction gates sit so far out that, at typical sizes, those kernels run serial regardless. The backend knobs matter only when matrix products dominate your runtime. Recalibrate after you measure that they do. Do not recalibrate on principle alone.

7.3.5. Recalibrating: the two tools, then the setters

Recalibration splits along the same line as the knobs, and each half uses a different tool. The matrix product belongs to gemmkit, so retune it with gemmkit’s autotuner. Everything else belongs to RustyML, so retune it with RustyML’s calibration bench.

To retune the product, install and run the backend’s sweeper on the target machine:

cargo install gemmkit-tune
gemmkit-tune

The sweeper runs on the machine it targets and emits a profile of GEMMKIT_* environment variables, ready to source. This is the deployment path, and it is the good one. Environment variables retune an already-built binary with no recompile, so 1 artifact can carry a different profile per host.

The programmatic equivalent is tuning::matmul::backend::set_parallel_threshold(..) and its siblings. Check the precedence rule before you use it. A knob resolves in this order: a per-call argument, then a programmatic set_* call, then a GEMMKIT_* variable, then the compiled default. A set_* call stores its value unconditionally. So once any code in the process calls a setter, the matching environment variable is dead for the rest of that process. RustyML never calls a setter on your behalf, for exactly this reason. Doing so would silently override a profile you had sourced.

A GEMMKIT_* value that fails to parse is not silently ignored either. The first access warns once on stderr and falls back to the default. It never panics.

For everything else, RustyML ships the calibration bench the maintainer used. It runs under cargo bench with harness = false, and prints straight to stdout:

# Elementwise / reduction / tree / conv / pool / normalization crossovers.
# Prints the tables and rewrites benches/calibrations/RESULTS.md.
cargo bench --bench parallel_gates

It needs the machine_learning and neural_network features. Its submodules cover the convolution engine, pooling, the elementwise and reduction kernels, the tree walks, and the normalization layers. For each kernel class, it forces the serial and the parallel implementation on either side of that class’s gate. It walks a ladder of shapes and reports the speedup at each rung.

Here is a real excerpt from the checked-in benches/calibrations/RESULTS.md. It was regenerated on 2026-07-26, on the 9950X at 32 rayon threads:

## conv engine FLOPs gate (CONV_PARALLEL_MIN_FLOPS), batch == 1

| shape | work (FLOPs) | serial (us) | parallel (us) | speedup |
|---|---:|---:|---:|---:|
| conv 3c->8f 16px k3   |     84672 |   12.1 |  25.2 | 0.48x |
| conv 8c->16f 32px k3  |   2073600 |   83.2 |  82.3 | 1.01x |
| conv 16c->32f 32px k3 |   8294400 |  109.2 |  85.0 | 1.29x |
| conv 32c->64f 64px k3 | 141705216 | 1437.6 | 298.2 | 4.82x |

**Takeaway:** crossover between 2073600 and 8294400 FLOPs.

Read the table the way its takeaway line does. The gate constant belongs at the crossover, with a small safety margin toward serial. A 2x penalty on a small tensor, to win 5% on a medium one, is a bad trade. The ladder makes this asymmetry visible. At 84,672 FLOPs, the parallel path runs at half the speed of serial. At 8.3M FLOPs, it is only 1.29x faster.

There is a trap here. The bench reports 1 work-estimate number per rung, but the serial cost of a kernel is not always a function of that number alone. POOL_PARALLEL_MIN_OPS stayed at 12,000 on purpose, even though the tool’s own bracket for it said 25K to 49K window taps.

Serial pooling speed depends strongly on the channel count. Window geometry gets amortized across channels. So the 25,088-tap 1x28x28x32 rung runs 14.4 us serial, while the smaller 16,384-tap 64x16x16x1 rung runs 76.7 us. Tap count alone does not predict serial cost.

Following the bracket would have pushed that 16K-tap shape back to serial. That would have given up a measured 45 us saving, only to avoid the 7 us loss the 12,288-tap 1x64x64x3 shape currently pays. Calibration output is evidence, not an instruction. Check which rungs of the ladder your workload actually sits on before you move a constant to match a bracket.

Apply the numbers at program start, before any real work touches a gate. The atomics are process-global, and every kernel reads them live:

use ndarray::{Array1, Array2};
use rustyml::machine_learning::LinearRegression;
use rustyml::tuning;

fn main() {
    // RustyML's own gates, from a `parallel_gates` run on this machine.
    tuning::matmul::set_cache_resident_max_bytes(32 * 1024 * 1024); // this CPU's real L3
    tuning::conv::set_parallel_min_flops(6_000_000);
    tuning::pool::set_parallel_min_ops(30_000);
    tuning::reduction::set_scan_f64(131_072);

    // The backend's scheduling knobs, only if you are not shipping a GEMMKIT_* profile:
    // a setter shadows the matching env var for the rest of the process.
    tuning::matmul::backend::set_parallel_threshold(2_000_000);
    tuning::matmul::backend::set_par_mnk_per_worker(4_000_000);

    // Each store is one relaxed atomic write. Read it back to confirm it took.
    assert_eq!(tuning::conv::get_parallel_min_flops(), 6_000_000);
    assert_eq!(tuning::matmul::backend::parallel_threshold(), 2_000_000);

    // Same API, same numbers. Only the serial/parallel strategy shifted.
    let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
    let y = Array1::from_vec(vec![3.0, 5.0, 7.0, 9.0, 11.0]);
    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();
    let preds = model.predict(&x).unwrap();
    println!("prediction: {:.3}", preds[0]);
}

RustyML’s own gates have no config file and no environment variable. The override API is the whole mechanism there. GEMMKIT_* variables reach only the backend. Setters are global and last for the life of the process. Call them once, in main or in a OnceLock-guarded init, rather than per model.

To see where threading actually pays off on your machine before you touch anything, run cargo bench --bench matmul_kernels.

7.3.6. Controlling rayon itself

The gates decide whether to go parallel. Rayon decides how wide. RustyML’s own kernels use rayon’s global pool and never build one of their own, so the standard rayon controls apply to them unchanged. The exception is the backend’s private tier pools, covered 2 paragraphs down.

The simplest control is the RAYON_NUM_THREADS environment variable, read when the pool is first touched:

RAYON_NUM_THREADS=8 ./my_program

For programmatic control, build the global pool once, before any rustyml call reaches it. rustyml does not re-export rayon, so add rayon as your own dependency (rayon = "1") to do this:

fn main() {
    rayon::ThreadPoolBuilder::new()
        .num_threads(8)
        .build_global()
        .unwrap(); // build_global fails if the pool was already initialized

    // ... rustyml calls now run on an 8-thread pool ...
}

One interaction here catches people by surprise. Shrinking the ambient pool does not rescale the matrix product. gemmkit sizes its worker count from the work, m * n * k divided by par_mnk_per_worker, and caps it by the machine’s core count. It reads that core count once, from std::thread::available_parallelism(), and caches it for the process. It never consults rayon::current_num_threads().

gemmkit’s pool tiers derive from that same cached machine width. So RAYON_NUM_THREADS=8 on a 32-thread box does not narrow gemmkit. gemmkit still ramps toward 32 workers. It still snaps to tiers of 8 and 16, in its own private pools, with no regard for your 8-wide global pool.

Your pool reasserts itself only at the top of the range. Once a product is large enough to want the full 32 workers, no tier is wide enough to hold it. The work then falls back to the ambient pool, for example 32 jobs over your 8 threads. To make the product narrower, raise backend::set_par_mnk_per_worker to demand more work per worker, or source a GEMMKIT_* profile. Do not resize the rayon pool for this.

RustyML’s own gates have the mirror problem. They are fixed numbers, calibrated at 1 thread count (32, on the 9950X, per RESULTS.md). They read nothing at all about the pool. They just compare a work estimate to a constant.

If you halve the pool, the crossover where parallel starts to win moves up in practice. Each remaining core now carries a bigger share, so the fork/join overhead pays off later. Yet conv::parallel_min_flops still holds its original value. The gate is not wrong, only no longer optimal.

Recalibrate with parallel_gates under the pool size you plan to deploy, or accept that the defaults assume the calibration machine’s thread count. The rule from both halves: the pool size is a resource limit, not a tuning parameter. Nothing rescales itself when you change it.

Oversubscription is the failure mode to watch for. RustyML’s kernels nest into 1 global pool. gemmkit deliberately stays in the caller’s pool instead of installing a tier, whenever it finds itself already on a rayon worker. So rustyml calls inside a rayon region stay safe.

The danger is building 2 sources of parallelism outside that pool. One example is an OS thread pool. Another is a std::thread::spawn fan-out. A third is a second rayon pool, built with ThreadPoolBuilder::build() instead of build_global. In each case, every worker independently calls into rustyml. Now each worker’s gated kernels each try to fill the global pool, and you get threads * threads contention.

If you already parallelize at the application level, give rustyml a smaller pool. Or keep your outer fan-out serial per item, and let rustyml’s own gates spread each item’s work. Do not stack pools.

7.3.7. Why the gate reads are free: relaxed atomics

Every gate read on a hot path is a single relaxed atomic load. Every setter is a single relaxed store. There is no lock, no fence, no contention. This design is deliberate. It makes runtime-tunable gates cost nothing more than the compile-time constants they replaced.

The reasoning comes from the macro that generates them. The gate only selects a strategy and never changes a result, so it needs no stronger memory ordering. A relaxed load has no ordering obligation to satisfy. On every mainstream architecture, it compiles to an ordinary load with no barrier. So a kernel that checks flops >= conv_parallel_min_flops() before every convolution pays almost nothing for the indirection.

The flip side of Relaxed is that a set_* call does not synchronize with in-flight kernels. Say you change a gate from 1 thread while another thread is mid-computation. Then no guarantee exists about which reads see the old value, and which see the new. This is harmless, because the gate only picks serial versus parallel, and both paths compute the same result. A mid-run flip at worst makes 1 kernel pick a slightly suboptimal strategy. It never gives a wrong answer.

Still, set gates at startup rather than while under load. This keeps behavior predictable, and every kernel sees 1 consistent policy.

7.3.8. What not to tune, and a sane workflow

Reach for the gates last, not first. Before you touch any of them, measure end to end. Time the actual fit, predict, or training loop you care about, and find where the time goes.

9 times out of 10, the answer is not a mis-set gate. More often it is a feature build with more modules than you need, dragging in code you never call. Or it is an f64 pipeline where f32 would halve the memory traffic. Or a model gets refit in a loop when it could fit once. Or the problem is simply small enough that it runs serial by design, and no gate change touches it.

The elementwise and reduction gates sit especially far out. At ordinary preprocessing and layer sizes, those kernels run serial no matter what you set. Moving cheap_map_f64 does nothing for a standardization over 10,000 rows and 20 features. That is 200,000 elements, still an order of magnitude below its 4,000,000 gate.

When profiling does point at parallelism, tune in this order.

First, confirm the pool is the size you expect, with rayon::current_num_threads(). A wrong RAYON_NUM_THREADS, or an accidental second pool, dwarfs the effect of any gate.

Second, if matrix products dominate, run cargo install gemmkit-tune on the deploy machine, and source the GEMMKIT_* profile it emits. For most classical-ML and dense-network work, this step gives the biggest gain, and it costs no recompile. Set the values as an environment profile, not as backend::set_* calls, unless you have a reason to hard-code them. A setter permanently shadows the environment variable, and takes the deployment knob away from whoever runs the binary.

Third, if you lean on the tiled-product paths (KNN, t-SNE, MeanShift), set cache_resident_max_bytes to your CPU’s real shared-L3 size.

Take those 3 steps first. Only then, check whether parallel_gates shows your kernels crossing over at a size that differs from the shipped default. If it does, adjust the elementwise, reduction, tree, conv/pool, or normalization gates. Change 1 gate at a time. Re-measure the end-to-end number, and keep the change only if it helped. Retuning never changes your results, so the only cost of a wrong guess is the time spent tuning a gate that was never the bottleneck.

7.4. Minimal Builds and Modular Integration

Most of this guide treats RustyML as a framework. You build a Sequential. You fit a KMeans. It owns the pipeline end to end. RustyML does not require this.

The crate splits into 5 feature-gated modules. Each module compiles on its own. Any single module works as a standalone toolbox, in a system that has no other knowledge of RustyML. Use metrics alone to score predictions from PyTorch weights ported to candle. Use math alone for the distance and reduction primitives. Use utils alone to standardize and split a dataset before you hand it to another learner.

This page shows how to build these slim profiles, what each one costs, and how the Cargo feature system can undo the trimming without warning. Read 1.2. Installation and Feature Flags first. This page covers leaf builds and the dependency graph, not the full default build.

7.4.1. The feature graph and what each profile pulls in

The crate defines 5 module features: machine_learning, neural_network, utils, metrics, and math. It also defines 1 aggregate feature, full, and 1 orthogonal switch, show_progress. The default feature set is full, so it enables all 5 modules.

Every module feature enables math, and math enables the numeric backend crates without condition. math names ndarray, ahash, rayon, and gemmkit-ndarray, and the adapter brings the gemmkit engine with it. So no RustyML build exists without those 5 crates. Enabling any module feature adds all of them. The heavier features add serialization and RNG machinery on top. Cargo.toml’s optional-dependency list gives this per-feature dependency set:

Optional depmathmetricsutilsmachine_learningneural_network
ndarray 0.17yesyesyesyesyes
ahashyesyesyesyesyes (via math)
rayonyesyes (via math)yesyesyes
gemmkit-ndarray (epilogue)yesyes (via math)yes (via math)yes (via math)yes (via math)
gemmkit (not direct, via gemmkit-ndarray)yesyesyesyesyes
ndarray-randnonoyesyesyes
serdenonoyesyesyes
postcardnonoyesyesyes
thiserrornonoyesyesyes
indicatifnonononoyes

Look closely at 2 rows in this table. metrics looks like the lightest leaf, and its dependency count is small. But it routes through math, so it still compiles rayon and the whole gemmkit matrix-multiply backend. This includes the gemmkit-ndarray adapter, with the epilogue feature the neural-network layers need. This holds even though a metric like mean_squared_error never multiplies a matrix. You pay this build-time cost for the math edge, and Cargo does not prune it away just because a build never calls it.

The other row to watch is indicatif. The neural_network feature lists it as a hard dependency, so it compiles whenever neural networks are on. Most of the code that uses it sits behind the separate show_progress feature gate, so neural_network without show_progress compiles indicatif but never calls it. show_progress gates progress bars for most iterative estimators in machine_learning. Examples include KMeans, DBSCAN, MeanShift, PCA, KernelPCA, LDA, IsolationForest, LinearRegression, LogisticRegression, SVC, LinearSVC, DecisionTree, and TSNE. The same feature also gates the neural-network training loop, and it pulls indicatif on its own edge, separate from neural_network.

Only 2 profiles stay genuinely slim: math and metrics. Both skip serde, postcard, ndarray-rand, thiserror, and indicatif entirely. Every feature from utils upward adds the serialization stack, because those modules carry state you can persist (see 7.2. Model Persistence in Depth) and use randomized initialization.

7.4.2. A metrics-only build: scoring predictions from anything

metrics is the most reusable slice of the crate. Its functions are pure array -> scalar maps. They hold no model state, run no training, and take no ownership of the pipeline. This makes them a scoring layer for predictions from any other system. Declare metrics with the default stack turned off:

[dependencies]
rustyml = { version = "0.14", default-features = false, features = ["metrics"] }
ndarray = "0.17"

default-features = false matters here. It is not a cosmetic detail. The default feature set is ["full"], every module in the crate. Leave the default on, and the build re-enables all 5 stacks next to metrics. That defeats the point of a slim build.

Turn the default off, and the build compiles metrics, its math dependency, and the 5 backend crates. It drops the serialization stack and indicatif.

Metric functions take (y_true, y_pred), ground truth first. Unlike the rest of the crate, they panic instead of returning a Result. This is a deliberate design choice for this tier. metrics is a leaf that lists only ndarray and ahash directly. rayon and the gemmkit pair still come from math, as the table above shows.

metrics does not even compile the crate’s error module, because that module needs machine_learning, neural_network, or utils, and none of those are enabled here. On a length mismatch or an empty input, a metric function panics with a message that mirrors the crate’s error wording. This matches how ndarray itself panics on a shape mismatch. Treat a metrics call as an assertion over arrays you already validated, not as a boundary that can fail gracefully.

use ndarray::Array1;
use rustyml::metrics::{ConfusionMatrix, mean_squared_error, r2_score, roc_auc};

fn main() {
    // Predictions from another system, as plain Vecs.
    let y_true = Array1::from_vec(vec![3.0, -0.5, 2.0, 7.0]);
    let y_pred = Array1::from_vec(vec![2.5, 0.0, 2.0, 8.0]);

    println!("MSE = {}", mean_squared_error(&y_true, &y_pred));
    println!("R2  = {}", r2_score(&y_true, &y_pred));

    // Binary classification: hard labels through a confusion matrix.
    let labels = Array1::from_vec(vec![1.0, 0.0, 0.0, 1.0, 1.0]);
    let preds = Array1::from_vec(vec![1.0, 0.0, 1.0, 1.0, 0.0]);
    let cm = ConfusionMatrix::new(&labels, &preds);
    println!("F1 = {:.3}, accuracy = {:.3}", cm.f1_score(), cm.accuracy());

    // Ranked scores through AUC. Labels are `bool` here, scores are `f64`.
    let truth = Array1::from_vec(vec![false, true, false, true]);
    let scores = Array1::from_vec(vec![0.1, 0.4, 0.35, 0.8]);
    println!("AUC = {}", roc_auc(&truth, &scores));
}

Look at the type signatures the API fixes. roc_auc needs labels: bool and scores: f64. The label vector is a true boolean, not a 0.0/1.0 float column. ConfusionMatrix::new requires labels and predictions that are already exactly 0.0 or 1.0, and it panics on any other value. It does not threshold a probability for you.

5.1. Regression Metrics, 5.2. Classification Metrics, and 5.3. Clustering Metrics list everything this build can reach. The silhouette score, silhouette_score, is the only place metrics leans on rayon, for a parallel pairwise-distance fill. That is why rayon still compiles even in this profile.

7.4.3. A math-only build: the numeric primitives

math is the floor of the crate: the shared primitives every estimator calls. As a standalone build, its public surface is narrower than its internal code. You can import and call 3 pairwise distance functions: squared_euclidean_distance_row, manhattan_distance_row, and minkowski_distance_row. You can also call the DistanceCalculationMetric dispatcher, re-exported at rustyml::math::*, and the deterministic reductions det_reduce and det_reduce_range under rustyml::math::reduction.

The tiling-strategy helpers gemm_chunk_rows and cache_resident, under rustyml::math::matmul, are not part of this surface. They carry #[doc(hidden)] as crate-internal policy hooks with no stability guarantee, and docs.rs does not show them. To influence tiling, use the tuning::matmul knobs instead: set_/get_chunk_elems and set_/get_cache_resident_max_bytes. The backend’s own scheduling knobs are reachable through tuning::matmul::backend, a re-export of gemmkit_ndarray::tuning, or as GEMMKIT_* environment variables. Neither gemm_chunk_rows nor cache_resident reaches them.

The GEMM/GEMV matrix product itself is not public. Layers and estimators call the gemmkit adapter directly. The matmul module adds 2 entry points on top of it, dot_par and matvec, and both stay crate-internal. The module exposes only the sizing helpers publicly. It never exposes a public matmul(a, b) entry point.

6.2. Matrix Multiplication describes that engine as internal. For a standalone matrix product, call ndarray’s .dot() directly instead. A math-only build is, in practice, a distances-and-reductions build.

use ndarray::array;
use rustyml::math::reduction::det_reduce;
use rustyml::math::{DistanceCalculationMetric, squared_euclidean_distance_row};

fn main() {
    // Pairwise distance primitives take 1-D array references and return f64.
    let a = array![1.0_f64, 2.0, 3.0];
    let b = array![4.0_f64, 6.0, 8.0];
    println!("squared L2 = {}", squared_euclidean_distance_row(&a, &b));

    // The configurable dispatcher takes views and returns a scalar. It matches
    // once over the variant.
    let metric = DistanceCalculationMetric::Minkowski(3.0);
    println!("L3 = {}", metric.distance(a.view(), b.view()));

    // A deterministic blocked reduction. The `true`/`false` flag is only a
    // performance hint. Both paths fold the same fixed-size blocks in the same order.
    let data: Vec<f64> = (0..10_000).map(|i| i as f64).collect();
    let sum = det_reduce(
        &data,
        true,
        |block| block.iter().copied().sum::<f64>(),
        |x, y| x + y,
        0.0,
    );
    println!("sum = {}", sum);
}

Use det_reduce instead of a bare par_iter().sum() for reproducibility. A work-stealing parallel sum groups its partial floats by whatever the scheduler decided at run time. The last rounding bit then drifts between runs and thread counts. det_reduce fixes the grouping into DET_REDUCE_BLOCK-sized chunks, folded in index order.

So the parallel flag chooses only where the blocks run, never what they compute. This property is the whole point of the module. It connects to the seeding story in 7.1. Reproducibility and Random Seeds.

A math-only build has 1 consequence to remember. math is not part of the prelude. The prelude re-exports machine_learning, metrics, neural_network, and utils, and has no math category. So use rustyml::prelude::* imports nothing in a math-only build.

You must use rustyml::math::... paths directly instead. This holds no matter which other features are on. The distance and reduction primitives stay namespaced and never flatten into the prelude. Contrast this with 1.5. The Prelude and Imports.

7.4.4. A utils-only build: preprocessing in a data pipeline

utils is the preprocessing slice: standardization, normalization, label encoding, and train/test splitting. It works as a data-prep stage that feeds a learner from another library. Unlike metrics and math, this is a heavier profile. It adds serde, postcard, ndarray-rand, and thiserror on top of the backend crates.

It adds ndarray-rand because the splitter shuffles with a seedable RNG. It adds serde and postcard because a fitted StandardScaler persists through the same save_to_path/load_from_path pair the models use. utils also compiles the error module, gated on utils among other features. Unlike the metrics tier, these functions return Result<_, Error> instead of panicking.

The crate-root traits module also survives in this profile. StandardScaler implements Fit, Transform, and FitTransform. A utils-only build still gets the estimator contract, even with machine_learning off.

[dependencies]
rustyml = { version = "0.14", default-features = false, features = ["utils"] }
ndarray = "0.17"
use ndarray::{Array1, Array2};
use rustyml::utils::StandardScaler;
use rustyml::utils::normalize::{NormalizationAxis, NormalizationOrder, normalize};
use rustyml::utils::standardize::{StandardizationAxis, standardize};
use rustyml::utils::train_test_split::train_test_split;

fn main() {
    let x = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
    let y = Array1::from_vec(vec![0i32, 1, 0, 1]);

    // Per-feature z-scores. Column axis standardizes each feature independently.
    let z = standardize(&x, StandardizationAxis::Column).unwrap();
    println!("standardized shape = {:?}", z.dim());

    // Per-row unit L2 norm.
    let n = normalize(&x, NormalizationAxis::Row, NormalizationOrder::L2).unwrap();
    println!("first row = {:?}", n.row(0));

    // Split consumes its inputs. Args are (x, y, test_size, random_state).
    let (x_train, x_test, y_train, y_test) = train_test_split(x, y, Some(0.25), Some(42)).unwrap();
    println!("train {} / test {} rows", x_train.nrows(), x_test.nrows());
    let _ = (y_train, y_test);

    // Fit the scaling on the training rows. Hand the frozen statistics to
    // another library, or save them next to the model that consumes the features.
    let mut scaler = StandardScaler::new();
    let x_train_z = scaler.fit_transform(&x_train).unwrap();
    let x_test_z = scaler.transform(&x_test).unwrap();
    println!("scaled {} train / {} test rows", x_train_z.nrows(), x_test_z.nrows());
    scaler.save_to_path("scaler.bin").unwrap();
}

train_test_split takes x and y by value. It moves them into the shuffled partition. The Some(42) seed makes the split reproducible. Pass None to use the global seed instead.

4.1. Train-Test Split, 4.2. Standardization and Normalization, and 4.3. Label Encoding cover each transform in detail. In a utils-only build, the prelude is populated with the utils category. So use rustyml::prelude::* works here. Importing the specific standardize and normalize submodules, as shown above, still keeps each call site clear about where the function lives.

7.4.5. default-features = false and the gotchas that follow

Turning the default off makes a slim build slim. It also removes more than the modules you left out, without warning. 3 things vanish here, and people often trip on them.

The default estimator and layer stack disappears. default = ["full"] enables all 5 modules. A build that turns the default off and asks only for metrics has no LinearRegression, no Sequential, and no KMeans. This is clear in hindsight. In practice it surfaces as a confusing “cannot find Sequential in rustyml” error, when someone copies a snippet from 3.1. The Sequential Model into a metrics-only crate.

The prelude shrinks to match. rustyml::prelude always compiles, but each category inside it is feature-gated. With default-features = false, features = ["metrics"], use rustyml::prelude::* brings in only the metrics items. With features = ["math"], it brings in nothing, because math has no prelude category. A missing model type after a glob import of the prelude almost always comes from this.

The error and random modules go missing under some feature sets. Both need machine_learning, neural_network, or utils, not metrics or math. So rustyml::error::Error, rustyml::random::set_global_seed, and the top-level set_global_seed/clear_global_seed re-exports do not exist in a metrics-only or math-only build. This is consistent, not a bug. The metrics tier panics instead of returning Error. The distance and reduction primitives are stateless and deterministic, so they need no RNG to seed.

The tuning module, in contrast, is available in every profile, because it is gated on any of the 5 module features. Which gate setters it exposes still narrows with the feature set. A metrics-only build gets tuning::metrics::set_silhouette and the math-gated reduction and matmul knobs, and nothing for the neural-network layers. See 7.3. Performance Tuning and Parallelism for what those knobs do.

7.4.6. Feature unification across a workspace

This is the failure mode that undoes careful slimming. Cargo unifies features across the entire dependency graph, per crate, per build. Consider this case. Your binary depends on rustyml with features = ["metrics"], default-features = false. Some other crate in the same build might also depend on rustyml with features = ["full"]. This could be a workspace sibling, a transitive dependency, or a dev-dependency in the same compilation.

Cargo then compiles 1 rustyml, with the union of every requested feature. Your “metrics-only” build quietly becomes a full build. gemmkit, indicatif, and the whole estimator stack all come along. Your Cargo.toml line alone cannot stop this.

The same unification applies to default-features. The default is additive and sticky. Cargo disables it only if every dependency edge onto rustyml, in the resolved graph, sets default-features = false. A single edge that omits this setting re-enables full, all 5 modules, for the whole graph. Setting default-features = false is a claim about 1 edge. It is not a claim about the whole build.

The consequences are concrete. Do not rely on a slim feature set for correctness. Never gate your own code on an assumption that serde, for example, is absent, because a sibling crate can add it back in. Slim builds are a best-effort optimization for the leaf case, a standalone binary or a workspace where you control every edge. They are not a guarantee. When you need a minimal artifact, verify what actually compiled instead of trusting the manifest:

# Which features did rustyml actually resolve to in this build?
cargo tree -e features -i rustyml

# Which crates got pulled in at all? Check for gemmkit and indicatif.
cargo tree | grep -E 'gemmkit|indicatif|ndarray-rand|serde'

cargo tree -i rustyml, the inverse view, shows every crate that depends on rustyml, and with which features. Use it to find the sibling that re-enabled full.

7.4.7. docs.rs shows the whole crate, not your build

RustyML’s Cargo.toml sets [package.metadata.docs.rs] all-features = true. The rendered docs at https://docs.rs/rustyml build with every feature on. Whatever slim profile you compile, the documentation you read describes the union of all features.

There is a second, sharper problem. The crate does not annotate items with #[doc(cfg(...))] feature badges. So on docs.rs, an item like Sequential or set_global_seed shows no marker for which feature gates it. The page reads as though the whole surface exists without condition.

Together, these 2 facts create a trap. You can read a function on docs.rs, call it, and get a “cannot find” error. The cause is that your feature set does not include its module. The feature table in 1.2. Installation and Feature Flags, and the per-feature dependency table above, are the ground truth for what compiles under which flag. docs.rs is the ground truth for what the API looks like when everything is on. Keep these 2 jobs separate.

7.4.8. Living next to candle, burn, and other ndarray consumers

You usually run a slim RustyML build because the modeling happens elsewhere. This might be a candle or burn network, or a tract-loaded ONNX graph. You want RustyML for 1 job: scoring, preprocessing, or a distance kernel. The integration seam is data. Matching the ndarray version matters most.

RustyML pins ndarray = "0.17.2". To the compiler, an Array1<f64> from ndarray 0.17 and an Array1<f64> from ndarray 0.16 are 2 different types from 2 different crates. Cargo compiles both versions into the graph without complaint. A value from one version then fails to pass to a function that expects the other. The type error reads as though 2 identical types are incompatible, because, semantically, they are 2 types.

When you combine RustyML with another crate that also uses ndarray in its public API, align both crates on 0.17. Otherwise you will fight duplicate-version clashes. Run cargo tree | grep ndarray to check right away whether 2 versions resolved.

candle and burn avoid this problem. They do not expose ndarray at all, and instead use their own tensor types. This makes the interop cleaner, because there is no version to align.

You cross the boundary through plain slices. Get predictions out of the other framework as a Vec<f32> or Vec<f64>. Copy them into an ndarray array once, and score them with RustyML. The copy costs real time, but it runs once at the boundary. It also keeps the 2 type systems from ever needing to agree.

use ndarray::Array1;
use rustyml::metrics::{mean_absolute_error, r2_score};

// Stands in for a model in another framework, such as candle, burn, or tract.
// Any of them can hand back predictions as a slice of f32.
fn external_model_predict(inputs: &[f32]) -> Vec<f32> {
    inputs.iter().map(|&x| 2.0 * x + 1.0).collect()
}

fn main() {
    let inputs = [0.0f32, 1.0, 2.0, 3.0];
    let raw_preds = external_model_predict(&inputs);

    // Cross the boundary once. Copy into ndarray f64, the dtype every metric expects.
    let y_pred: Array1<f64> = raw_preds.iter().map(|&v| v as f64).collect();
    let y_true = Array1::from_vec(vec![1.0, 3.0, 5.0, 7.2]);

    println!("MAE = {}", mean_absolute_error(&y_true, &y_pred));
    println!("R2  = {}", r2_score(&y_true, &y_pred));
}

Notice the dtype conversion inside the copy. RustyML’s metrics operate on f64, while candle and burn inference typically runs in f32. Widening the type at the boundary, in the same pass as the Vec-to-ndarray copy, is the cheapest place to pay for it.

A final caution relates to feature unification. If you add both RustyML and a large modeling framework to the same workspace, run cargo tree -e features -i rustyml afterward. Big frameworks sometimes add RustyML-adjacent utility crates that re-enable features you thought you turned off. The slim-build exercise is only worth doing if you confirm it survived the whole graph.