
去年我在复现一篇论文实验时碰到了一个诡异的问题对方项目里明明写了np.random.seed(42)数据预处理流程也完整给出可我一跑结果和论文里贴的图总是差那么一点。更离谱的是同一个脚本我连续跑三次三次结果都不一样。排查了一整天才发现根源不在算法而在随机数——他代码里混用了两套 NumPy 随机数 API一部分走老接口np.random.seednp.random.rand另一部分走新接口np.random.default_rng()。这两套接口在 NumPy 内部使用完全不同的伪随机算法同一把种子撒下去生出来的数字序列天差地别。这就是我想写这篇东西的原因。很多人在日常科学计算、机器学习实验里都会用到 NumPy 的随机数功能但真正理解这套 API 设计逻辑的人不多。你以为seed(42)就能原地复现其实背后牵涉到伪随机数生成器的状态管理、新旧 API 的算法差异、以及并行场景下随机流的独立性问题。这篇文章我想把“生成随机数”这件事从底层原理到并行实战完整拆一遍尤其是那些不翻源码根本踩不出来的坑尽量都给你趟平。不管你是刚接触 NumPy 的新手还是已经在做大规模并行模拟的熟手看完应该都能重新审视自己的随机数用法。1. 伪随机到底是怎么“随机”出来的1.1 从真随机到伪随机为什么计算机需要用“骰子公式”先说个最基础的问题计算机里的随机数本质上都不是真随机。真随机数通常来自物理过程的熵比如芯片热噪声、放射性衰变、你的鼠标移动间隔量子随机数发生器也是这个路子。但这类熵源的问题是不可复现。你不可能让同一个物理过程再发生一遍也就没法让两个完全相同的实验产生相同的数据流。这在科学计算里是灾难——论文审稿人要求你提供可复现实验你说“我依赖物理熵源”那基本等于自杀。所以实际生产中我们用的是伪随机数生成器PRNGPseudo-Random Number Generator。它不是从自然界采样的数字而是用一个确定性的数学递推公式从初始状态不断算出后续的数字。只要初始状态相同公式每一步都确定那么输出序列就完全一致。这就是“伪”的含义看起来杂乱无章、统计上近似均匀随机但实际上每一步都是严格确定的。1.2 NumPy 的随机数状态机种子、状态与序列NumPy 的随机数系统本质上就是一个状态机。你可以把生成器想象成一台无限长的出票机它内部保存一个很大的状态数组每次你让它“出一张数字”它就根据当前状态推算出下一个数字并同时更新自己的内部状态。这个状态数组是保存记忆的所以同一台出票机连续出的数字是互相关联的但统计上看起来又足够独立。在旧版 NumPy 中全局随机状态由numpy.random模块统一管理。我们可以用get_state直接看到这个状态的庐山真面目import numpy as np np.random.seed(42) state np.random.get_state() print(state[0]) # 算法名称比如 MT19937 print(state[1].shape) # 状态数组MT19937 通常是一个 624 维的数组这里的关键点在于seed(42)做的事情本质上是把生成器的内部状态初始化成一个由 42 派生的特定值。同一个种子意味着同一个初始状态后续调用rand、randn、randint时只要调用顺序不变生成的序列就完全一样。我经常用下面这段代码跟别人证明“伪随机可复现”这件事np.random.seed(42) a1 np.random.rand(5) np.random.seed(42) a2 np.random.rand(5) np.random.set_state(state) # 把状态恢复到 seed(42) 之后的那个时刻 a3 np.random.rand(5) print(np.allclose(a1, a2)) # True print(np.allclose(a1, a3)) # True你可以把seed理解为“把骰子复位到出厂状态”而set_state更狠它是把骰子恢复到任意一个历史时刻。后者在断点续跑时极其有用后文会专门展开。1.3 一个常见误解seed 不是“一整盒骰子”而是“骰子的初始位置”很多初学者会误以为seed是某种随机数池子的编号——seed 一样随机数“池子”一样。这个理解其实还行但容易带来一个错误预期以为两个进程只要设同一个 seed就能产出完全相同的随机数序列。这在单线程、串行调用下成立但在并行或乱序调用下就崩了。因为生成器的下一个数字依赖当前状态而当前状态会随着调用次数和调用内容变化。一旦两个进程分别独立调用且调用顺序不同、中间插了别的操作哪怕初始 seed 相同后续序列也会迅速分道扬镳。所以后面讲并行时你会看到想并行复现不能简单沿用串行思路。2. 新老 API 之争RandomState 与 Generator 到底该用哪个2.1 老接口全局随机状态与 MT19937NumPy 最经典的老接口就是np.random.seednp.random.rand/randn/randint底层算法是梅森旋转 MT19937。这套接口从上世纪九十年代用到现在统治了 Python 科学计算将近三十年几乎所有老教程、老代码都是这么写的。但它有两个绕不开的问题。第一全局状态。np.random.seed修改的是 NumPy 模块级全局状态这意味着只要你调用一次你的整个进程内所有用到np.random的地方——不管是自己写的函数还是另一个依赖 NumPy 的第三方库——都会被影响。想象你正在调试某个深度学习代码中间某个库偷偷调用了一次np.random你的“可复现实验”马上就变得不可复现了。第二算法陈旧。MT19937 虽然统计性质依然优良而且周期长达 2^19937 - 1但在生成速度、并行分发能力、以及对高阶随机性检验的应对上已经被更新的算法甩开一截。新算法如 PCG64、Philox 等不仅在速度上有明显提升还提供了更优雅的并行随机流管理手段。2.2 新接口default_rng 与 Generator 对象从 NumPy 1.17 开始官方推荐的新随机数 API 是np.random.default_rng()。它返回一个Generator对象而不是继续操作全局状态。核心用法是rng np.random.default_rng(42) x rng.random(5) # [0, 1) 均匀分布 y rng.integers(0, 10, 5) # 随机整数 z rng.normal(0, 1, 5) # 标准正态分布这个对象的每一个方法都是独立的随机流。你可以在不同模块、不同类里分别创建各自的Generator互不干扰。更重要的是它默认使用的算法从 MT19937 换成了 PCG64生成速度明显更快统计检验也更强。下面这张表是我自己整理的常用函数对照方便从老接口迁移功能老接口RandomState新接口Generator设置全局种子np.random.seed(42)没有全局种子default_rng(42)创建独立对象[0,1) 均匀分布np.random.rand(3)rng.random(3)标准正态分布np.random.randn(3)rng.standard_normal(3)指定均值和方差的正态分布np.random.normal(0, 1, 3)rng.normal(0, 1, 3)随机整数np.random.randint(0, 10, 3)rng.integers(0, 10, 3)从数组抽样np.random.choice(arr, 3)rng.choice(arr, 3)打乱数组np.random.shuffle(arr)rng.shuffle(arr)随机排列np.random.permutation(10)rng.permutation(10)2.3 同一种子、两套API结果为什么对不上这是最容易踩的坑之一。很多人在老代码里用np.random.seed(42)然后在某个角落又调用了np.random.default_rng(42)以为两边的 42 是同一个“随机起点”。但实际运行结果完全不同。原因有两层第一底层算法不同。老接口的 MT19937 和新接口默认的 PCG64 是两种完全不同的数学模型。种子 42 在两种算法中展开出来的初始状态毫无可比性后续序列自然南辕北辙。第二老接口维护的是全局状态而新接口是独立对象。即便你硬把新接口的底层算法改成 MT19937比如default_rng传一个MT19937(42)的 bit generator它依然拥有独立的状态副本不会和老接口共享全局状态。所以我的建议很明确新代码一律用default_rng老代码如果要保留完全一致的结果就别混用新接口如果决定迁移到新接口就要接受随机序列整体变化这个事实。2.4 版本升级引发的经典报错module numpy has no attribute float我在搜索相关热词时看到不少人在问这类问题比如AttributeError: module numpy has no attribute float。这虽然不是随机数 API 直接导致的语法错误但背后的逻辑和这次版本迭代同源——NumPy 在持续清理老接口。np.float这种别名是在 NumPy 1.24 里被正式移除的同理还有np.int、np.bool等。如果你的老项目还在用这些别名升级到新版 NumPy 后立刻会报错。这和随机数 API 的变迁规律一样NumPy 官方每出一个新版本就会强化一批新接口、废弃一批老接口。你去年写好的随机数代码今年 New API 已经全面铺开老代码跑不出来很正常。应对方式也很简单先pip install numpy装到固定版本然后逐行排查兼容性别偷懒。3. 可复现实验的种子管理从 seed 到状态保存3.1 全局种子 vs 局部 Generator谁污染了你的实验“可复现”是科学计算的底线。但全局种子最大的问题就是“污染不可控”。我举一个真实例子有一次我在跑一个模拟实验脚本里第一行就设置了np.random.seed(2022)然后我用sklearn.model_selection.train_test_split切分数据集结果每次跑切出来的数据都一样。看似可复现了对吧可后来我在同一个脚本里引入了一个新的可视化库它内部竟然调用了np.random来生成初始点。我的全局随机状态被这个外部库拨动了一下后续所有“看似无关”的随机数序列全部改变。实验结果也跟着变。这就是全局状态的麻烦你管不住别人的代码动你的状态。相比之下default_rng的局部对象就干净得多。每个模块、每个函数可以持有自己的生成器互不干扰。我在项目里的约定是所有涉及随机数的模块入口处都接收一个Generator对象或者自己内部创建。def my_simulation(n, genNone): if gen is None: gen np.random.default_rng() return gen.normal(0, 1, n)这种做法既保证了灵活性又不会被迫使用全局状态。3.2 保存与恢复生成器状态断点续跑不再玄学真正的可复现最好做到“任意时刻都能原地复活”。如果实验跑了一半想接着跑或者调试时需要反复回到中间某个状态全局 seed 就只能从头再来但Generator可以选择只在当前状态下继续。新接口的全局状态保存很简单rng np.random.default_rng(42) # 跑一段 x1 rng.normal(0, 1, 1000) # 保存当前状态 snapshot rng.bit_generator.state # 继续跑 x2 rng.normal(0, 1, 1000) # 恢复状态后再跑一次应该和 x2 完全一致 rng.bit_generator.state snapshot x3 rng.normal(0, 1, 1000) print(np.allclose(x2, x3)) # True这个bit_generator.state是一个字典里面保存了生成器的完整内部状态比如当前指针位置、状态数组等。把它存成文件或 JSON就能实现跨进程、跨时间点的断点恢复。我在做蒙特卡洛模拟时会把每轮模拟结束后的 state 存下来如果中途超时或进程被 kill就直接从最近的快照恢复不需要从头重新跑。3.3 多实验种子分配从简单加法到 SeedSequence多实验场景下最朴素的想法是seed base_seed i每个实验用不同整数当种子。这在串行小规模实验里够用但在并行场景下很容易踩坑因为base 1和base 2在同一个生成器上产生的序列可能高度相关或者更糟——某些随机数算法的相邻种子之间相关性明显。NumPy 提供的SeedSequence就是用来解决这个问题的。它可以把一个主种子派生出一组统计上相互独立的子种子而不用你手动去“凑种子”ss np.random.SeedSequence(2024) child_seeds ss.spawn(8) rngs [np.random.default_rng(s) for s in child_seeds] for r in rngs: # 每个 r 都是独立、不相关的随机流 pass底层原理是SeedSequence会维护一个熵池通过哈希和混合过程把主种子扩展成多个子种子保证不同子种子之间几乎不共享信息。这套机制是并行随机数管理的地基后面第四部分详细展开。4. 并行计算场景下的随机数为什么同一种子也会翻车4.1 并行场景里最常见的两个错误做法等到真正进入并行科学计算随机数的麻烦才真正显现。错误一每个 worker 都设同一个np.random.seed(42)。这样每个 worker 拿到的随机序列完全一样各 worker 的输出几乎完全相同浪费了并行的意义而且统计上也极度偏差——相当于样本被重复复制了 N 份。错误二手动给每个 worker 设不同 seed比如seed base rank。看似解决了重复问题但不同种子之间在统计上并不保证独立尤其在 MT19937 这类算法里相邻种子产生的序列可能存在相关性。这种“不严格独立”在蒙特卡洛模拟里是致命的可能导致方差估计偏差。我见过一个实际翻车案例一个金融风险评估系统把任务切给 8 个进程采用seed 1000 rank的方式跑了上百万次模拟结果方差明显低于理论值。最后排查发现就是因为不同 worker 的随机序列之间存在隐藏相关性风险事件总是被“覆盖”掉了。4.2 NumPy 官方解法SeedSequence 独立生成器正确做法是让每个 worker 持有一个独立的Generator且这些Generator的随机流在统计上保证独立。NumPy 官方推荐的组合方式就是SeedSequence default_rngseed_seq np.random.SeedSequence(2024) worker_seeds seed_seq.spawn(n_workers) worker_rngs [np.random.default_rng(s) for s in worker_seeds]这里spawn生成的是SeedSequence对象你可以直接传给default_rng。每个子SeedSequence都是独立的熵池生成的随机流之间相关性极低从原理上规避了“手凑种子”的不确定性。如果你需要更深一层的控制还可以在创建子序列时传入不同的参数实现“嵌套派生”root np.random.SeedSequence(2024) child_seq root.spawn(1)[0] grandchild_seqs child_seq.spawn(4)这种树形结构在分层并行中非常实用比如先按实验批次分一层再按 batch 内的并行 worker 分一层。4.3 一个真正可复现的并行模拟示例下面我给出一个完整的multiprocessing并行模拟示例你可以直接抄作业。假设我们要跑 100 万次独立蒙特卡洛模拟计算某个函数的期望值现在把它分到 4 个进程。import numpy as np import multiprocessing as mp def worker(simulate_count, seed_seq): 每个进程独立创建自己的随机数生成器 rng np.random.default_rng(seed_seq) total 0.0 for _ in range(simulate_count): samples rng.random(10) # 一次模拟使用10个随机数 total np.mean(samples) return total def main(): base_seed 2024 n_workers 4 total_sims 1_000_000 # 生成独立的子种子 root np.random.SeedSequence(base_seed) child_seqs root.spawn(n_workers) per_worker total_sims // n_workers with mp.Pool(n_workers) as pool: results pool.starmap(worker, [(per_worker, cs) for cs in child_seqs]) total sum(results) avg total / total_sims print(模拟平均值:, avg) if __name__ __main__: main()核心思路主进程只负责用SeedSequence派生子种子每个子进程拿到属于自己的SeedSequence后独立创建Generator。这样每个进程的随机流独立且不复用。4.4 并行可复现的边界条件需要特别提醒并行可复现不是“任何情况下都能精确复现”。它有一个前提——每个进程的任务划分方式固定worker 数量和任务分配逻辑不变。举个反例如果你用进程池每次pool.map的任务顺序不是严格保证的或者你用的并行库比如某些分布式框架动态调度任务那即使随机流独立结果也可能因任务执行顺序不同而无法精确复制。所以在并行可复现这件事上我的经验是固定 seed、固定 worker 数量、固定任务分配逻辑三步缺一不可。如果你要跨环境复现最好再固定 NumPy 版本、Python 版本甚至操作系统位数因为浮点运算在不同 CPU、不同指令集下最后几位可能有微小差异。5. 实战坑点与性能细节版本、速度与机器学习实操5.1 从安装版本说起不同 NumPy 版本的随机数差异聊到这块很多人会问“numpy 到底是什么为什么我装的版本跑出来的结果和别人不一样”这其实是随机数复现话题里最容易被忽略的一环。我见过太多人在社区抱怨同一个随机种子在numpy 1.19.5和numpy 2.2.5下面生成的数据不同。这不是 bug而是因为新版本里随机数底层实现有变化甚至新老 API 混用导致的差异。所以如果你要做严肃的可复现实验第一步就是锁定环境版本。推荐在你自己的项目里创建虚拟环境然后固定安装版本python -m venv venv source venv/bin/activate pip install numpy2.2.5这里我特别提一下“ubuntu安装numpy 2.2.5”这个场景。Ubuntu 系统自带老版本 NumPy 的情况很常见如果你直接pip install numpy可能遇到系统包冲突所以我个人更建议用venv或conda把环境隔离开再安装指定版本。另外老项目如果还在用numpy 1.19.5那套老接口升级到 2.x 时除了要注意np.random的 API 变化还要检查类似np.float、np.int这类被移除的别名否则会碰到开头说的module numpy has no attribute float报错。5.2 随机数性能实测PCG64 与 MT19937 差了多远性能在大多数业务代码里不是关键因素但在蒙特卡洛模拟、数据增强、大规模随机抽样这些场景里就成了实打实的瓶颈。我自己跑过一组简单的性能对比测试供参考生成 2000 万个 [0,1) 随机数耗时秒老接口np.random.rand(20_000_000)约 0.18新接口np.random.default_rng().random(20_000_000)约 0.11新接口指定Philox算法约 0.15PCG64 在均匀分布上比 MT19937 快了大概 30%-60%整数生成上差距更大。如果在 Python 层面循环调用这个优势会被循环开销稀释收益不大但如果你一次性生成大数组新接口的优势非常明显。值得一提的是很多时候性能瓶颈不在生成本身而在于你随手写的 Python 循环。正确的做法是用rng.random(1000)一次性生成一个批次而不是for _ in range(1000): rng.random()两者的速度差距可以到百倍级别。5.3 机器学习里的随机数操作train_test_split、shuffle 与交叉验证机器学习里到处都是随机数。标准化的scikit-learnpipeline 一般长这样import numpy as np; import pandas as pd; import matplotlib.pyplot as plt; from sklearn.model_selection import train_test_split。这也是我最常看见各种随机数坑的地方。第一个坑train_test_split的random_state和 NumPy 不共享状态。train_test_split内部用的是 scikit-learn 自己管理的随机数生成器虽然是基于 NumPy 的RandomState但你不设random_state参数它就每次重新随机切分和你在外面设没设np.random.seed没关系。所以想让切分可复现必须显式传random_state。第二个坑rng.shuffle与np.random.shuffle的关系。老接口洗牌是就地操作而且返回None新接口rng.shuffle也是就地操作但很多人误以为它会返回新数组。正确写法是rng.shuffle(arr)之后arr本身就是打乱后的。第三个坑交叉验证中的shuffleTrue如果忘了设random_state每次交叉验证的数据划分都不一样模型评估结果自然不稳定。更隐蔽的是在深度学习中如果你用DataLoader等框架它们的随机种子往往独立于 NumPy需要单独设置。5.4 调试小技巧用受控随机数制造测试数据最后分享一个我习惯用的调试方法先用固定种子的随机数造一批已知形状的测试数据再配合 NumPy 的广播机制快速验证代码逻辑。rng np.random.default_rng(0) A rng.normal(size(100, 50)) B rng.normal(size(50, 20)) C A B # (100, 20)每次调试时我都用固定的seed0、seed1切几个不同的随机数据集这样既能复现 bug又能覆盖不同尺寸的测试场景。如果想验证一个操作是否符合预期也可以生成一个小规模随机数组手动算出期望值再和大规模随机结果对比。比如你想验证np.mean(rng.random(1000000))是否接近 0.5固定种子跑出来接近即可多跑几个种子看波动范围是否符合统计预期。我的实测体会前面讲了很多技术细节最后聊一点我自己的组织经验。我现在所有项目里都会维护一个“随机数规范”凡是可复现的实验代码入口处统一用default_rng创建独立生成器不许碰全局np.random多进程并行时一律用SeedSequence.spawn派生子种子实验参数里永远记录四个东西——NumPy 版本、Python 版本、主种子、worker 数量。这个规范不是一开始就有的而是被“结果对不上”“复现不了”“并行结果飘”这些破事教训出来的。如果你正被这些问题困扰我强烈建议你从今天开始把手里的随机数代码统一到新 API 上来。过程可能会有些阵痛比如老代码迁移后结果变了、同事用老接口跑的结果和新接口对不上但这是从“能用”走向“可信”的必经之路。而且别小看这一步等到你需要跟别人结对排查一个“概率性 bug”或者要把实验分发到集群上并行跑时你就会知道一份干净的随机数管理代码能省下多少个周五下午。