【RustyML入门】7.4. 按需裁剪与模块化集成 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 个聚合 featurefull,以及 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 对应的依赖集合是:可选依赖mathmetricsutilsmachine_learningneural_networkndarray0.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适配器,以及神经网络各层需要的epiloguefeature。哪怕像mean_squared_error这样的指标压根不做矩阵乘法,这个结论依然成立。你为math这条依赖边实打实付出了这份编译时间开销,Cargo 不会因为某次构建没调用它就把它剪掉。另一行值得留意的是indicatif。neural_networkfeature 把它列为硬依赖,所以只要开了神经网络就会编译它。真正用到它的代码大多关在单独的show_progressfeature 门后面,所以开了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时把默认栈关掉:[dependencies] rustyml { version 0.14, default-features false, features [metrics] } ndarray 0.17default-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 调用当成对已校验数组的一个断言,而不是一道可能优雅失败的边界。usendarray::Array1;userustyml::metrics::{ConfusionMatrix,mean_squared_error,r2_score,roc_auc};fnmain(){// 别的系统产出的预测,以普通 Vec 的形式给出。lety_trueArray1::from_vec(vec![3.0,-0.5,2.0,7.0]);lety_predArray1::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));// 二分类:硬标签走混淆矩阵。letlabelsArray1::from_vec(vec![1.0,0.0,0.0,1.0,1.0]);letpredsArray1::from_vec(vec![1.0,0.0,1.0,1.0,0.0]);letcmConfusionMatrix::new(labels,preds);println!(F1 {:.3}, accuracy {:.3},cm.f1_score(),cm.accuracy());// 排序分数走 AUC。这里标签是 bool,分数是 f64。lettruthArray1::from_vec(vec![false,true,false,true]);letscoresArray1::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的构建,实际上就是一个只有距离和归约的构建。usendarray::array;userustyml::math::reduction::det_reduce;userustyml::math::{DistanceCalculationMetric,squared_euclidean_distance_row};fnmain(){// 成对距离原语接收一维数组引用,返回 f64。letaarray![1.0_f64,2.0,3.0];letbarray![4.0_f64,6.0,8.0];println!(squared L2 {},squared_euclidean_distance_row(a,b));// 可配置的调度器接收 view,返回标量。它对变体只做// 一次匹配。letmetricDistanceCalculationMetric::Minkowski(3.0);println!(L3 {},metric.distance(a.view(),b.view()));// 一次确定性的分块归约。true/false 这个标志只是// 性能提示,两条路径都以相同顺序折叠相同大小的固定分块。letdata:Vecf64(0..10_000).map(|i|iasf64).collect();letsumdet_reduce(data,true,|block|block.iter().copied().sum::f64(),|x,y|xy,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 约定。[dependencies] rustyml { version 0.14, default-features false, features [utils] } ndarray 0.17usendarray::{Array1,Array2};userustyml::utils::StandardScaler;userustyml::utils::normalize::{NormalizationAxis,NormalizationOrder,normalize};userustyml::utils::standardize::{StandardizationAxis,standardize};userustyml::utils::train_test_split::train_test_split;fnmain(){letxArray2::from_shape_vec((4,2),vec![1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0]).unwrap();letyArray1::from_vec(vec![0i32,1,0,1]);// 逐特征 z-score。Column 轴对每个特征独立做标准化。letzstandardize(x,StandardizationAxis::Column).unwrap();println!(standardized shape {:?},z.dim());// 逐行的单位 L2 范数。letnnormalize(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);// 在训练行上拟合缩放,把冻结的统计量交给// 另一个库,或者把它和消费这些特征的模型存在一起。letmutscalerStandardScaler::new();letx_train_zscaler.fit_transform(x_train).unwrap();letx_test_zscaler.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 findSequentialinrustyml” 报错,比如有人把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:# rustyml 在这次构建里实际解析成了哪些 feature?cargotree-efeatures-irustyml# 到底有哪些 crate 被拉了进来?检查一下有没有 gemmkit 和 indicatif。cargotree|grep-Egemmkit|indicatif|ndarray-rand|serdecargo 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。在编译器看来,来自ndarray0.17 的Array1f64和来自ndarray0.16 的Array1f64是来自 2 个不同 crate 的 2 个不同类型。Cargo 会毫不犹豫地把两个版本都编进依赖图。某个版本的值随后就传不进期待另一个版本的函数。这个类型错误读起来就像 2 个一模一样的类型互不兼容,因为从语义上讲,它们确实是 2 个类型。当你把 RustyML 和另一个在公开 API 里也用ndarray的 crate 组合到一起时,让两边都对齐到0.17。否则你就得和版本重复的冲突缠斗。跑一下cargo tree | grep ndarray,立刻就能看出是不是解析出了 2 个版本。candle和burn避开了这个问题。它们压根不对外暴露ndarray,而是用自己的张量类型。这让互操作更干净,因为根本没有版本要对齐。你通过普通的切片跨过这道边界。把预测从另一个框架里取出来,做成Vecf32或Vecf64。一次性拷进一个ndarray数组,再用 RustyML 打分。这次拷贝要花真实的时间,但它只在边界上跑一遍。它也让这 2 套类型系统完全不需要彼此达成一致。usendarray::Array1;userustyml::metrics::{mean_absolute_error,r2_score};// 代表一个活在别的框架里的模型,比如 candle、burn 或 tract。// 不管是哪一个,它都能把预测以 f32 切片的形式交回来。fnexternal_model_predict(inputs:[f32])-Vecf32{inputs.iter().map(|x|2.0*x1.0).collect()}fnmain(){letinputs[0.0f32,1.0,2.0,3.0];letraw_predsexternal_model_predict(inputs);// 一次性跨过边界。拷进 ndarray f64,这是每个指标都期待的 dtype。lety_pred:Array1f64raw_preds.iter().map(|v|vasf64).collect();lety_trueArray1::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 重新打开。精简构建这套功夫,只有在你确认它在整张图里活了下来之后,才算没白费。