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

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.