7.4. 按需裁剪与模块化集成
本指南大部分时候都把 RustyML 当作一个框架来使用。你搭建一个 Sequential,拟合一个 KMeans,让它从头到尾接管整条流水线。RustyML 并不要求这样用。
这个 crate 拆成了 5 个由 feature 控制的模块,每个模块都能独立编译。任何一个模块都能当作独立工具箱使用,挂到一个对 RustyML 一无所知的系统上。单独引入 metrics,就能给移植到 candle 上的 PyTorch 权重产出的预测打分。单独引入 math,就能拿到距离和归约原语。单独引入 utils,就能在把数据集交给别的学习器之前先做标准化和划分。
本页说明如何构建这些精简组合、每一种到底要付出多少代价,以及 Cargo 的 feature 系统会怎样在不知不觉中把这种裁剪撤销掉。请先阅读安装与Feature配置。本页聚焦叶子构建和依赖图,而不是功能齐全的默认构建。
7.4.1. Feature 依赖图:每种组合会拉进什么
这个 crate 定义了 5 个模块 feature:machine_learning、neural_network、utils、metrics 和 math。它还定义了 1 个聚合 feature full,以及 1 个正交开关 show_progress。默认的 feature 组合就是 full,所以它会启用全部 5 个模块。
每个模块 feature 都会启用 math,而 math 又会无条件启用底层数值后端 crate。math 点名的是 ndarray、ahash、rayon 和 gemmkit-ndarray,gemmkit 引擎由适配器带进来。所以不存在不带这 5 个 crate 的 RustyML 构建。启用任意一个模块 feature,都会把它们全部带进来。更重的 feature 在此之上加的是序列化和 RNG 相关的机制。照着 Cargo.toml 里可选依赖的列表逐条看,每个 feature 对应的依赖集合是:
| 可选依赖 | math |
metrics |
utils |
machine_learning |
neural_network |
|---|---|---|---|---|---|
ndarray 0.17 |
是 | 是 | 是 | 是 | 是 |
ahash |
是 | 是 | 是 | 是 | 是(经由 math) |
rayon |
是 | 是(经由 math) |
是 | 是 | 是 |
gemmkit-ndarray(epilogue) |
是 | 是(经由 math) |
是(经由 math) |
是(经由 math) |
是(经由 math) |
gemmkit(非直接依赖,经由 gemmkit-ndarray) |
是 | 是 | 是 | 是 | 是 |
ndarray-rand |
否 | 否 | 是 | 是 | 是 |
serde |
否 | 否 | 是 | 是 | 是 |
postcard |
否 | 否 | 是 | 是 | 是 |
thiserror |
否 | 否 | 是 | 是 | 是 |
indicatif |
否 | 否 | 否 | 否 | 是 |
仔细看这张表里的 2 行。metrics 看起来是最轻的叶子,依赖数量也确实不多。但它要经过 math,所以照样会编译 rayon 和整个 gemmkit 矩阵乘法后端,这还包括 gemmkit-ndarray 适配器,以及神经网络各层需要的 epilogue feature。哪怕像 mean_squared_error 这样的指标压根不做矩阵乘法,这个结论依然成立。你为 math 这条依赖边实打实付出了这份编译时间开销,Cargo 不会因为某次构建没调用它就把它剪掉。
另一行值得留意的是 indicatif。neural_network feature 把它列为硬依赖,所以只要开了神经网络就会编译它。真正用到它的代码大多关在单独的 show_progress feature 门后面,所以开了 neural_network 却不开 show_progress,会编译出 indicatif 却从不调用它。show_progress 为 machine_learning 里大多数迭代式 estimator 的进度条提供开关。举例来说,KMeans、DBSCAN、MeanShift、PCA、KernelPCA、LDA、IsolationForest、LinearRegression、LogisticRegression、SVC、LinearSVC、DecisionTree 和 TSNE 都在其中。同一个 feature 也控制着神经网络训练循环,它通过自己独立的一条边把 indicatif 拉进来,和 neural_network 是分开的。
真正精简的组合只有 2 个:math 和 metrics。两者都完全不带 serde、postcard、ndarray-rand、thiserror 和 indicatif。从 utils 往上的每一个 feature 都会带上整套序列化栈,因为这些模块携带可持久化的状态(见深入模型持久化)并使用随机化初始化。
7.4.2. 只带 metrics 的构建:给任意来源的预测打分
metrics 是这个 crate 里复用性最高的一块。它的函数是纯粹的 array -> scalar 映射。它们不持有模型状态、不做训练、也不接管流水线。这让它们天然适合充当一个打分层,给任何别的系统产出的预测打分。声明 metrics 时把默认栈关掉:
toml
[dependencies]
rustyml = { version = "0.14", default-features = false, features = ["metrics"] }
ndarray = "0.17"
default-features = false 在这里很关键,不是可有可无的门面功夫。默认的 feature 组合是 ["full"],也就是 crate 里的每一个模块。留着默认不关,构建就会在 metrics 之外把全部 5 套栈都重新打开。这就违背了精简构建的初衷。
关掉默认后,构建会编译 metrics、它依赖的 math,以及那 5 个后端 crate。它甩掉的是整套序列化栈和 indicatif。
指标函数以 (y_true, y_pred) 为参数,真实值在前。和 crate 其余部分不同,它们在出错时 panic ,而不返回 Result。这是这一层刻意的设计取舍。metrics 是一个只直接列出 ndarray 和 ahash 的叶子。rayon 和 gemmkit 那一对仍然来自 math,如上表所示。
metrics 甚至连 crate 的 error 模块都不编译,因为那个模块需要 machine_learning、neural_network 或 utils,而这里一个都没启用。遇到长度不匹配或空输入时,指标函数会 panic,消息措辞照搬 crate 的错误文案。这和 ndarray 自己在形状不匹配时 panic 的做法一致。把一次 metrics 调用当成对已校验数组的一个断言,而不是一道可能优雅失败的边界。
rust
use ndarray::Array1;
use rustyml::metrics::{ConfusionMatrix, mean_squared_error, r2_score, roc_auc};
fn main() {
// 别的系统产出的预测,以普通 Vec 的形式给出。
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));
// 二分类:硬标签走混淆矩阵。
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());
// 排序分数走 AUC。这里标签是 `bool`,分数是 `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));
}
看一下 API 钉死的类型签名。roc_auc 要的是 labels: bool 和 scores: f64。标签向量是真正的布尔值,而不是 0.0/1.0 的浮点列。ConfusionMatrix::new 要求传入的标签和预测本来就必须恰好是 0.0 或 1.0,遇到别的值它会 panic。它不会替你把概率二值化。
回归指标、分类指标和聚类指标列出了这个构建里能触及的全部内容。轮廓系数 silhouette_score 是 metrics 唯一依赖 rayon 的地方,用来做并行的成对距离填充。这也是为什么即便在这个组合里 rayon 也照样会编译进来。
7.4.3. 只带 math 的构建:数值原语
math 是整个 crate 的地板,是每个 estimator 都会调用的共享原语。作为独立构建,它公开的接口比它内部代码的体量要窄。你能 import 并调用的是 3 个成对距离函数:squared_euclidean_distance_row、manhattan_distance_row 和 minkowski_distance_row。你还能调用 DistanceCalculationMetric 调度器(重新导出在 rustyml::math::*),以及 rustyml::math::reduction 下的确定性归约 det_reduce 和 det_reduce_range。
rustyml::math::matmul 下的分块策略辅助函数 gemm_chunk_rows 和 cache_resident 不属于这个公开接口。它们带着 #[doc(hidden)],是没有稳定性保证的 crate 内部策略钩子,docs.rs 上也不显示它们。要影响分块行为,请改用 tuning::matmul 的旋钮:set_/get_chunk_elems 和 set_/get_cache_resident_max_bytes。后端自己的调度旋钮可以通过 tuning::matmul::backend(对 gemmkit_ndarray::tuning 的重新导出)或 GEMMKIT_* 环境变量去够。gemm_chunk_rows 和 cache_resident 都够不到它们。
GEMM/GEMV 矩阵乘积本身并不公开。各层和 estimator 都是直接调用 gemmkit 适配器。matmul 模块在它之上补的 2 个入口 dot_par 和 matvec 都留在 crate 内部。这个模块对外只公开尺寸计算辅助函数,从不公开 matmul(a, b) 这样的入口。
矩阵乘法把那套引擎描述成一个内部引擎。要做一次独立的矩阵乘积,请直接调用 ndarray 的 .dot()。只带 math 的构建,实际上就是一个只有距离和归约的构建。
rust
use ndarray::array;
use rustyml::math::reduction::det_reduce;
use rustyml::math::{DistanceCalculationMetric, squared_euclidean_distance_row};
fn main() {
// 成对距离原语接收一维数组引用,返回 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));
// 可配置的调度器接收 view,返回标量。它对变体只做
// 一次匹配。
let metric = DistanceCalculationMetric::Minkowski(3.0);
println!("L3 = {}", metric.distance(a.view(), b.view()));
// 一次确定性的分块归约。`true`/`false` 这个标志只是
// 性能提示,两条路径都以相同顺序折叠相同大小的固定分块。
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);
}
选择 det_reduce 而不是一句裸的 par_iter().sum(),图的是可复现性。工作窃取式的并行求和,会按调度器在运行时的决定去给那些浮点部分和分组。于是末位舍入会在多次运行、不同线程数之间漂移。det_reduce 把分组固定成按下标顺序折叠的 DET_REDUCE_BLOCK 大小的块。
于是 parallel 标志只决定这些块在哪里跑,绝不影响它们算出什么。这个性质正是这个模块存在的全部意义。它和可复现性与随机种子里讲的设种子机制相互关联。
只带 math 的构建有 1 个后果需要记住。math 不 属于 prelude。prelude 重新导出的是 machine_learning、metrics、neural_network 和 utils,没有 math 这一类。所以在只带 math 的构建里,use rustyml::prelude::* 什么都导不进来。
你必须直接走 rustyml::math::... 的路径。这一点和你还开了哪些别的 feature 无关。距离和归约原语始终带命名空间,永远不会摊平进 prelude。与Prelude与模块导入形成对照。
7.4.4. 只带 utils 的构建:数据流水线里的预处理
utils 是预处理那一块:标准化、归一化、标签编码和训练/测试划分。它可以当作一个数据准备阶段,喂给来自另一个库的学习器。和 metrics、math 不同,这是一个更重的组合。它在后端 crate 之上又加了 serde、postcard、ndarray-rand 和 thiserror。
它带上 ndarray-rand,是因为划分器要用可设种子的 RNG 做洗牌。它带上 serde 和 postcard,是因为拟合好的 StandardScaler 走的正是模型那套 save_to_path/load_from_path。utils 还会编译 error 模块,该模块的门控之一就是 utils。和 metrics 那一层不同,这些函数返回的是 Result<_, Error>,而不是直接 panic。
crate 根部的 traits 模块在这个组合里同样存在。StandardScaler 实现了 Fit、Transform 和 FitTransform。即使关掉 machine_learning,只带 utils 的构建照样拿得到那套 estimator 约定。
toml
[dependencies]
rustyml = { version = "0.14", default-features = false, features = ["utils"] }
ndarray = "0.17"
rust
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]);
// 逐特征 z-score。Column 轴对每个特征独立做标准化。
let z = standardize(&x, StandardizationAxis::Column).unwrap();
println!("standardized shape = {:?}", z.dim());
// 逐行的单位 L2 范数。
let n = normalize(&x, NormalizationAxis::Row, NormalizationOrder::L2).unwrap();
println!("first row = {:?}", n.row(0));
// 划分会消耗输入。参数依次是 (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);
// 在训练行上拟合缩放,把冻结的统计量交给
// 另一个库,或者把它和消费这些特征的模型存在一起。
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 按值接收 x 和 y,它会把两者 move 进洗好牌的划分里。Some(42) 这个种子让划分可复现。传 None 则改用全局种子。
训练集与测试集划分、标准化与归一化和标签编码详细讲了每个变换。在只带 utils 的构建里,prelude 里是有内容的(带上了 utils 那一类),所以这里 use rustyml::prelude::* 能用。像上面那样从具体的 standardize 和 normalize 子模块导入,依然能让每个调用点清楚地表明函数出自哪里。
7.4.5. default-features = false 与随之而来的坑
关掉默认,是精简构建之所以精简的原因。但它在悄悄移除你没点名的模块之外,还会移除更多东西。这里有 3 样东西会消失,人们也常在它们上面栽跟头。
默认的 estimator 和层栈没了。 default = ["full"] 会启用全部 5 个模块。关掉默认、只要 metrics 的构建,里面没有 LinearRegression、没有 Sequential,也没有 KMeans。事后看这很清楚。但实际中它会表现为一句令人困惑的 "cannot find Sequential in rustyml" 报错,比如有人把Sequential模型里的片段抄进一个只带 metrics 的 crate。
prelude 也跟着缩水。 rustyml::prelude 总是会编译,但里面每一类都单独做 feature 门控。在 default-features = false, features = ["metrics"] 下,use rustyml::prelude::* 只会带进 metrics 那些项。换成 features = ["math"],它什么都带不进来,因为 math 没有对应的 prelude 类别。glob 导入 prelude 却发现模型类型不见了,几乎都是这个原因。
error 和 random 模块在某些 feature 组合下会缺席。 两者都需要 machine_learning、neural_network 或 utils,而不是 metrics 或 math。所以 rustyml::error::Error、rustyml::random::set_global_seed,以及顶层的 set_global_seed/clear_global_seed 重新导出,在只带 metrics 或只带 math 的构建里都不存在。这是一致的设计,不是 bug。metrics 那一层用 panic 而不是返回 Error。距离和归约原语无状态且确定,不需要 RNG 可设种子。
相比之下,tuning 模块在每一种组合里都可用,因为它的门控是 5 个模块 feature 中的任意一个。它暴露哪些 setter,仍然会随 feature 集合收窄。只带 metrics 的构建拿到的是 tuning::metrics::set_silhouette,以及受 math 门控的归约和 matmul 旋钮,神经网络各层的旋钮则一个都没有。这些旋钮各自的作用见性能调优与并行。
7.4.6. workspace 内的 feature 统一
这就是会把精心做的裁剪一笔勾销的失效模式。Cargo 会在整张依赖图上、按 crate、按构建统一 feature。设想这样一种情况:你的二进制以 features = ["metrics"], default-features = false 依赖 rustyml。同一次构建里的另一个 crate,也可能以 features = ["full"] 依赖 rustyml。这可能是一个 workspace 兄弟、一个传递依赖,或者同一次编译里的一个 dev-dependency。
Cargo 这时会编译出 1 个 rustyml,取所有请求 feature 的并集。你那个"只带 metrics"的构建,就这么悄无声息地成了 full 构建。gemmkit、indicatif 和整套 estimator 栈全都会跟着来。单靠你 Cargo.toml 里那一行,对此无能为力。
同样的统一规则也作用于 default-features。默认是叠加的,而且很黏。只有当解析出的依赖图里,指向 rustyml 的每一条依赖边都设了 default-features = false,Cargo 才会关闭它。只要有一条边漏掉这一项,就会为整张图重新打开 full,也就是全部 5 个模块。设了 default-features = false,说的是某一条边,而不是整个构建。
后果是很实在的。不要指望一个精简 feature 集合来保证正确性。绝不要给自己的代码做条件编译,假设比如 serde 不在,因为某个兄弟 crate 能把它重新拉回来。精简构建是针对叶子场景(一个独立二进制,或一个每条边都在你掌控之中的 workspace)的尽力而为的优化,不是一种保证。当你确实需要一个最小产物时,去核实到底编译进了什么,而不是轻信 manifest:
bash
# rustyml 在这次构建里实际解析成了哪些 feature?
cargo tree -e features -i rustyml
# 到底有哪些 crate 被拉了进来?检查一下有没有 gemmkit 和 indicatif。
cargo tree | grep -E 'gemmkit|indicatif|ndarray-rand|serde'
cargo tree -i rustyml(反向视图)会列出每一个依赖 rustyml 的 crate,以及它们各自请求的 feature。用它来找出那个把 full 重新打开的兄弟 crate。
7.4.7. docs.rs 展示的是整个 crate,不是你的构建
RustyML 的 Cargo.toml 设了 [package.metadata.docs.rs] all-features = true。https://docs.rs/rustyml 上渲染出的文档,是开着每一个 feature 构建出来的。不管你实际编译的是哪种精简组合,你读到的文档描述的都是全部 feature 的并集。
还有第二个更棘手的问题。这个 crate 没有给各项标注 #[doc(cfg(...))] 的 feature 徽章。所以在 docs.rs 上,像 Sequential 或 set_global_seed 这样的项不会显示任何标记来说明它受哪个 feature 门控。整个页面读起来就好像所有接口都无条件存在。
这两点合在一起,就会让你在 docs.rs 上读到一个函数、调用它,然后收获一个 "cannot find" 报错。原因就是你的 feature 集合里没有它所在的模块。安装与Feature配置里的 feature 表,以及上面那张按 feature 划分的依赖表,才是"什么 flag 下编译出什么"的权威依据。docs.rs 是"全部打开时 API 长什么样"的权威依据。把这两件事分开看。
7.4.8. 与 candle、burn 及其他 ndarray 使用者共处
跑一个精简的 RustyML 构建,通常是因为建模发生在别处。这可能是一个 candle 或 burn 网络,也可能是一张由 tract 加载的 ONNX 图。你想用 RustyML 做的是 1 件事:打分、预处理,或者一个距离内核。集成的接缝在数据上,对齐 ndarray 的版本才是最重要的事。
RustyML 锁定了 ndarray = "0.17.2"。在编译器看来,来自 ndarray 0.17 的 Array1<f64> 和来自 ndarray 0.16 的 Array1<f64> 是来自 2 个不同 crate 的 2 个不同类型。Cargo 会毫不犹豫地把两个版本都编进依赖图。某个版本的值随后就传不进期待另一个版本的函数。这个类型错误读起来就像 2 个一模一样的类型互不兼容,因为从语义上讲,它们确实是 2 个类型。
当你把 RustyML 和另一个在公开 API 里也用 ndarray 的 crate 组合到一起时,让两边都对齐到 0.17。否则你就得和版本重复的冲突缠斗。跑一下 cargo tree | grep ndarray,立刻就能看出是不是解析出了 2 个版本。
candle 和 burn 避开了这个问题。它们压根不对外暴露 ndarray,而是用自己的张量类型。这让互操作更干净,因为根本没有版本要对齐。
你通过普通的切片跨过这道边界。把预测从另一个框架里取出来,做成 Vec<f32> 或 Vec<f64>。一次性拷进一个 ndarray 数组,再用 RustyML 打分。这次拷贝要花真实的时间,但它只在边界上跑一遍。它也让这 2 套类型系统完全不需要彼此达成一致。
rust
use ndarray::Array1;
use rustyml::metrics::{mean_absolute_error, r2_score};
// 代表一个活在别的框架里的模型,比如 candle、burn 或 tract。
// 不管是哪一个,它都能把预测以 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);
// 一次性跨过边界。拷进 ndarray f64,这是每个指标都期待的 dtype。
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));
}
注意这次拷贝里顺带做的 dtype 转换。RustyML 的指标在 f64 上运算,而 candle 和 burn 的推理通常跑在 f32。把这次加宽放在边界上,和 Vec 到 ndarray 的拷贝同一遍完成,是付这笔代价最便宜的地方。
最后一个提醒和 feature 统一有关。如果你把 RustyML 和一个大型建模框架加进同一个 workspace,之后跑一下 cargo tree -e features -i rustyml。大型框架有时会带进一些和 RustyML 相邻的工具 crate,把你以为已经关掉的 feature 重新打开。精简构建这套功夫,只有在你确认它在整张图里活了下来之后,才算没白费。