免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

【RustyML入门】7.1. 可复现性与随机种子

【RustyML入门】7.1. 可复现性与随机种子 7.1. 可复现性与随机种子RustyML 里每个带随机性的组件都通过同一个关口取随机数crate 级别的random模块。一个种子就能锁定整场实验。规则本身很简单但有两条推论和 NumPy 进程全局的np.random不一样那就是顺序敏感和线程局部。RustyML 同时提供 scikit-learn 那种按估计器设置的random_state也提供 Keras 那种全局的keras.utils.set_random_seed。本页说明这两者如何相互配合。7.1.1. 两个公开入口整个公开接口就是两个自由函数在 crate 根部重新导出pub fn set_global_seed(seed: u64); pub fn clear_global_seed();set_global_seed为调用它的线程装上一个种子。clear_global_seed把它移除恢复到基于熵的行为。没有 getter也没有需要到处传递的全局对象。你不需要自己构造按次调用的 RNG。取而代之每个组件接收一个Optionu64种子通常表现为.with_random_state(seed)构建器方法或者random_state参数。内部的解析器会把这个值和全局种子协调起来。在构造那些想固定随机性的模型之前先调用一次set_global_seed。这样一来下游的一切也都随之变得可复现。两个内部解析器负责协调组件的种子和全局种子。make_rng(random_state)总是返回一个具体的 RNG。没有种子生效时它会回退到操作系统熵。几乎每个组件都用它。make_rng_opt(random_state)则返回OptionStdRng。当局部种子和全局种子都不生效时它给出None。这个None意味着没有人请求随机化。DecisionTree用的正是这第二种形式见 2.4. 决策树。这让分裂时的平局处理在你没有要求随机性时保持完全确定。用了make_rng_opt一棵未播种的树完全不会对平局做随机化连熵都不会用上。7.1.2. 三路种子解析规则给定一个组件的random_state: Optionu64和线程局部的全局种子规则的解析方式如下组件的random_state是否设置了全局种子结果Some(seed)任意直接使用seed。忽略全局流也不去动它。None是让全局流前进一步派生出一个独立的子种子。None否从操作系统熵播种。不可复现。下面的代码展示了这个机制match random_state { Some(seed) StdRng::seed_from_u64(seed), // 独立不动全局 None match global_seed_rng { Some(global) StdRng::seed_from_u64(global.next_u64()), // 从流里取子种子 None StdRng::from_rng(mut rng()), // 到处都没种子操作系统熵 }, }两个分支并不对称。显式的Some(seed)只根据那个数字构建 RNG。它从不在全局流上调用next_u64()。而全局种子之下的None会从流里消耗一次抽取。全局种子的行为就像一台子种子发生器它按未播种抽取请求它们的先后顺序把子种子分发出去。显式的Some种子完全不碰这条流就能完成解析。这是否会让整个组件都变得惰性取决于组件在什么时候抽取。7.1.3 节会说明组件何时才算真正惰性。7.1.3. 顺序敏感以及种子何时真正惰性这条规则有两个推论在实践中要紧。其中一个是常见的陷阱。第一个推论是顺序敏感。未播种的组件按请求的先后顺序从共享流里取子种子因此它们能否复现取决于构造顺序。先建模型 A 再建模型 B各自会拿到一个特定的子种子。换成先建 B 再建 A两个子种子就会对调。这正是 Keras 全局种子的行为也意味着全局种子只能复现那些构造顺序同样相同的运行。第二个推论是惰性这里你必须精确说清什么才算惰性。解析器保证make_rng(Some(seed))绝不在全局流上调用next_u64()。显式种子不会从流里消耗任何东西。但这是否让整个组件都惰性取决于组件究竟在何时抽取。RustyML 把组件分成两大类延迟抽取的估计器KMeans、SVC、LinearSVC、IsolationForest、DecisionTree、t-SNE、train_test_split以及Sequential的 shuffle 种子。它们把random_state存成一个普通字段只在fit内部解析一次。对它们而言惰性自始至终成立。用.with_random_state(s)构造时不碰任何东西。拟合时用种子s不会推进全局流。所以把这样一个估计器插进流水线其他每个未播种估计器的子种子都不受影响。急切初始化的 NN 层Dense、dropout 和噪声层以及卷积层和循环层。它们在构造时就通过make_rng(None)初始化各自的权重或掩码这会从全局流里抽走一个子种子。.with_random_state(s)是一次事后的重新初始化。它会覆盖该层自己的权重但它的Dense::new已经取走的那个子种子再也回不来了。一个已播种的层照样会把全局流推进整整一步。决定它之后所建各层子种子的是它的构造而不是它有没有播种。急切初始化这一类最容易出错。下面的程序在两个未播种的层之间插进一个.with_random_state(999)的层看看它之后那个层如何发生偏移原因是被插进来的层的Dense::new在进场路上消耗了一个子种子usendarray::Array2;userustyml::neural_network::Tensor;userustyml::neural_network::layers::Activation;userustyml::neural_network::layers::dense::Dense;userustyml::neural_network::traits::Layer;userustyml::{clear_global_seed,set_global_seed};fnrow()-Tensor{Array2::from_shape_vec((1,4),vec![0.5,-1.0,2.0,0.25]).unwrap().into_dyn()}fnmax_abs_diff(a:Tensor,b:Tensor)-f32{a.iter().zip(b.iter()).map(|(x,y)|(x-y).abs()).fold(0.0_f32,f32::max)}fnmain(){letxrow();// 运行 A两个连续的未播种层依次取走子种子 #1 和子种子 #2。set_global_seed(42);leta1Dense::new(4,3,Activation::Linear).unwrap();leta2Dense::new(4,3,Activation::Linear).unwrap();// 运行 B完全相同只是中间插进了一个 .with_random_state(999) 的层。set_global_seed(42);letb1Dense::new(4,3,Activation::Linear).unwrap();let_seededDense::new(4,3,Activation::Linear).unwrap().with_random_state(999);letb2Dense::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());// 第一个未播种层不受影响同样的种子同样的构造位置。assert_eq!(max_abs_diff(pa1,pb1),0.0);// 两个连续的未播种层各不相同它们之间流前进了一步。assert!(max_abs_diff(pa1,pa2)1e-4);// b2 与 a2 不匹配。被插入层的 Dense::new 在 with_random_state 重新初始化// 它之前就取走了一个子种子所以 b2 拿到的是子种子 #3而不是 #2。assert!(max_abs_diff(pa2,pb2)1e-4);println!(layer construction advances the global stream, seeded or not);}两类的结论是一样的想让一次运行在插入或重排随机组件的重构中存活下来就给每个组件配上它自己的显式random_state。不要依赖全局流的顺序。对延迟抽取的估计器来说显式种子能把它彻底解耦。对急切初始化的层来说显式种子固定的是该层自己的权重此外你还必须保证从播种调用到每次抽取之间层的构造顺序保持稳定。固定、线性的流水线适合用全局种子。真正稳健的选择是给每个组件配上显式种子。7.1.4. 线程局部语义全局种子存放在一个thread_local!单元里。这让set_global_seed无锁也不会有争用。但它也带来一个硬性后果。种子只影响调用了set_global_seed的那个线程。在构造模型的同一个线程上设置它一切正常。把构造挪到另一个线程种子在那里就变得不可见了。这一点在三种场景下要紧。派生线程与异步运行时。你用std::thread::spawn启动的 worker 线程或者tokio、rayon里的任务一开始都没有全局种子会回退到熵。在每个 worker 顶部调用set_global_seed。更好的办法是给每个组件一个显式random_state因为它天生与线程无关。内部并行。有些估计器会在并行化之前先在调用线程上把 RNG 构建好。KMeans每次重启都会新建一个 k-means RNG并且总是发生在任何并行工作之前。n_init默认是 10。所以全局种子每次重启都够得着。IsolationForest是个例外。它在每棵树的闭包内部各构造一个 RNG。一旦n_estimators 10这些闭包就会通过into_par_iter()跑在 Rayon 的 worker 线程上。走None路径时每个 worker 都会调用解析器。worker 找不到线程局部的全局种子因为种子在你的线程上不在 worker 的线程上于是它回退到熵。单靠一个全局种子并不能让并行的IsolationForest可复现。显式路径正是为了绕开这个问题而设计的。.with_random_state(s)给第i棵树种子s i。它不引用任何线程局部状态所以无论哪个 worker 跑哪棵树结果都能一模一样地复现。凡是在 worker 线程上构建随机性的组件都用显式random_state别用全局种子。并行何时开始起作用见 7.3. 性能调优与并行。测试框架。Rust 默认的测试框架会为每个测试派生一个全新线程因此每个测试起步时全局种子都是未设置的这带来了干净的隔离。可一旦用cargo test -- --test-threads1所有测试就会改成跑在同一个共享线程上。某个调用了set_global_seed的测试会把那个种子泄漏给之后每一个本该走未播种熵行为的测试。之后要用一个 drop guard 把种子清掉这样即使 panic 也能正常清理。crate 自己的集成测试用的正是这个模式#[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(); // 即使 panic/展开也会运行 } }把它绑到一个变量上比如let _seed GlobalSeedGuard::set(123);。这样它就能活过整个测试主体并在退出时清理。一个没绑定的调用GlobalSeedGuard::set(123);会立即析构。它会在你用上种子之前就把种子清掉。这就是为什么这个类型带着#[must_use]。7.1.5. 整个 crate 中哪些地方会抽取随机数下面列出的一切都走同一个解析器。在构造它们的那个线程上一个全局种子就能全部覆盖但要记得上一节里关于并行的例外。每一行还列出了按组件的覆盖方式。想摆脱构造顺序或调用线程的束缚时就用这个覆盖。组件如何播种全局种子能否到达说明NN 层权重初始化Dense、卷积、循环…….with_random_state(seed)能重跑 Xavier/Glorot 均匀初始化。要在训练之前调用。见 3.2。Dropout / 空间 dropout / 高斯噪声掩码.with_random_state(seed)能掩码 RNG 归该层所有。见 3.8。Sequential小批量 shuffle.set_seed(seed)或Sequential::new_with_seed(seed)能种子字段默认为None只影响fit_with_batches。不碰层的权重。见 3.1。KMeansk-means 初始化.with_random_state(seed)能RNG 在调用线程上按 k-means 的每次重启重新构建n_init默认为 10。见 2.7。SVC/LinearSVC.with_random_state(seed)能工作集选择SVC与小批量 shuffleLinearSVC。见 2.5。estimate_bandwidthMean Shift 辅助函数estimate_bandwidth(x, quantile, n_samples, Some(seed))能随机性在于子采样。MeanShift::fit本身包括 bin seeding是确定的。见 2.9。IsolationForest.with_random_state(seed)并行路径上不能一旦n_estimators 10要复现就必须用显式种子。每棵树的种子是seed i。见 2.13。决策树分裂平局处理.with_random_state(seed)能使用make_rng_opt只有种子生效时才对平局做随机化否则完全确定。t-SNE随机初始化.with_random_state(seed)且.with_init(Init::Random)能默认的Init::PCA是确定的会忽略random_state。见 2.12。train_test_split/train_test_split_stratifiedrandom_state: Optionu64参数能控制索引 shuffle。见 4.1。有两行值得再看一眼。t-SNE的种子在默认代码路径上什么也不做。Init::PCA用最大的几个主成分来初始化嵌入这是确定的。所以只有当你切换到.with_init(Init::Random)之后random_state才有意义。Mean Shift 常被误认为是带种子的估计器。MeanShift结构体压根没有random_state字段。那个模块里唯一的抽取是自由函数estimate_bandwidth内部那次可选的子采样。而这个辅助函数是你自己调用来挑选带宽的。有意排除在外的部分并不是 crate 里每一次伪随机抽取都会经过这个模块只有那些对结果有持久影响的才会。pca和kernel_pca这两个降维器是有意排除在外的。它们的迭代特征求解器幂迭代、Lanczos用一个固定常数去播种随机的起始向量。这类方法无论起始向量取什么都会收敛到同一组特征向量。所以这个种子在可观测的意义上是惰性的。它只固定了本来任意的特征向量符号。把它接进全局状态只会让这个符号选择更不可复现没有任何好处。随机化 SVDpca的SVDSolver::Randomized(u64)把种子放在公开的求解器变体里接收。调用方总是显式固定它根本没有留给全局去填的None路径。总的规则是只有当一次抽取会做出改变结果的伪随机选择时才让它经过这个模块。7.1.6. 种子固定不了什么种子让伪随机选择可复现但它并不能让一次运行的每个部分都逐位相同。浮点归约顺序是另一个维度。并行地对一个向量求和或者做矩阵乘积结果的最后几位可能跟串行求和不同。浮点加法不满足结合律这跟播种毫无关系。如果你需要逐位稳定的数值结果请用并行相关的控制项去调而不是种子。见 6.3. 并行归约 和 7.3. 性能调优与并行。跨机器的逐位一致没有保证。同一个种子下两台机器会做出同样的随机选择。但浮点舍入、SIMD 宽度、线程数以及不依赖 BLAS 的矩阵乘法后端在归约顺序上的差异仍可能让最终权重在低位上不同。同一个种子给出的是同一串决策它不会给你跨架构逐字节相同的浮点数。种子既跨不过线程跳转也不会自己越过保存或加载的边界。从磁盘重新加载一个训练好的模型得到的是它冻结的权重而不是产生这些权重的 RNG 流。如果你要在重新加载的模型上继续训练请在当前线程上重新设定种子。见 7.2. 深入模型持久化。7.1.7. 实用范式可复现实验模板对于单线程上一条线性的构造序列在开头调用一次set_global_seed。这是让整条流水线可复现最简单的办法。把每个组件都留成未播种random_state None让它们按顺序从流里取各自的子种子。这也包括Sequential的 shuffle它的种子字段默认为None因此同样归全局种子管usendarray::Array2;userustyml::neural_network::Tensor;userustyml::neural_network::layers::Activation;userustyml::neural_network::layers::dense::Dense;userustyml::neural_network::losses::MeanSquaredError;userustyml::neural_network::optimizers::SGD;userustyml::neural_network::sequential::Sequential;userustyml::set_global_seed;fnt2(rows:usize,cols:usize,data:Vecf32)-Tensor{Array2::from_shape_vec((rows,cols),data).unwrap().into_dyn()}fnmain(){// 开头调用一次。下面每次未播种的抽取都按顺序从它派生。set_global_seed(2026);#[rustfmt::skip]letxt2(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,]);letyt2(4,1,vec![1.0,0.0,-1.0,0.5]);letmutmodelSequential::new();// 没有按层种子也没有显式 set_seedmodel.add(Dense::new(4,3,Activation::ReLU).unwrap())// 子种子 #1.add(Dense::new(3,1,Activation::Linear).unwrap())// 子种子 #2.compile(SGD::new(0.05,0.0,false,0.0).unwrap(),MeanSquaredError::new());// batch_size n_samples 会触发每个 epoch 的 shuffle子种子 #3。model.fit_with_batches(x,y,5,2).unwrap();letpmodel.predict(t2(1,4,vec![0.5,-1.0,2.0,0.25])).unwrap();println!(prediction shape: {:?},p.shape());}跑两遍这个程序训练出的权重是逐字节相同的。构造顺序——两次层初始化然后是 shuffle 的 RNG——每次都从2026这条流里取走同样的三个子种子。你一旦重排那几个.add调用子种子的分配就会变化这就是 7.1.3 节里顺序敏感的具体体现。按组件的random_state覆盖当你想让每个组件都独立于构造顺序、也独立于哪个线程在运行它时就直接在每个组件上设置random_state。对库代码、并行估计器以及你经常重构的代码来说这都是稳健的选择。显式种子完全无视全局流这也是你在放开其余组件的同时、单独复现某一个组件的办法usendarray::{Array1,Array2,array};userustyml::prelude::*;fnmain(){// 两团彼此分得很开的点6 个样本2 个特征。letx:Array2f64array![[0.0,0.0],[0.2,0.1],[0.1,-0.2],[5.0,5.0],[5.2,4.9],[4.8,5.1],];lety:Array1usizearray![0,0,0,1,1,1];// 相同的 random_state 相同的索引 shuffle 每次运行都是相同的划分。letsplit_atrain_test_split(x.clone(),y.clone(),Some(0.5),Some(42)).unwrap();letsplit_btrain_test_split(x.clone(),y.clone(),Some(0.5),Some(42)).unwrap();assert_eq!(split_a.0,split_b.0);// x_train 相同assert_eq!(split_a.2,split_b.2);// y_train 相同// 相同的 random_state 相同的 k-means 初始化 相同的标签与上面无关。letlabels_1KMeans::new(2,100,1e-4).unwrap().with_random_state(7).fit_predict(x).unwrap();letlabels_2KMeans::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);}两种风格可以随意混用。一种常见的写法是用set_global_seed给环境定下默认值再在你需要固定住的那个估计器上加一个显式的random_state同时对其余一切做扫描。显式种子对全局流是惰性的所以固定这一个估计器不会扰动其他每个组件拿到的子种子。
返回列表