去年我在复现一篇论文实验时碰到了一个诡异的问题:对方项目里明明写了np.random.seed(42),数据预处理流程也完整给出,可我一跑,结果和论文里贴的图总是差那么一点。更离谱的是,同一个脚本我连续跑三次,三次结果都不一样。排查了一整天才发现,根源不在算法,而在随机数——他代码里混用了两套 NumPy 随机数 API:一部分走老接口np.random.seed+np.random.rand,另一部分走新接口np.random.default_rng()。这两套接口在 NumPy 内部使用完全不同的伪随机算法,同一把种子撒下去,生出来的数字序列天差地别。
这就是我想写这篇东西的原因。很多人在日常科学计算、机器学习实验里都会用到 NumPy 的随机数功能,但真正理解这套 API 设计逻辑的人不多。你以为seed(42)就能原地复现,其实背后牵涉到伪随机数生成器的状态管理、新旧 API 的算法差异、以及并行场景下随机流的独立性问题。这篇文章我想把“生成随机数”这件事从底层原理到并行实战完整拆一遍,尤其是那些不翻源码根本踩不出来的坑,尽量都给你趟平。不管你是刚接触 NumPy 的新手,还是已经在做大规模并行模拟的熟手,看完应该都能重新审视自己的随机数用法。
1. 伪随机到底是怎么“随机”出来的
1.1 从真随机到伪随机:为什么计算机需要用“骰子公式”
先说个最基础的问题:计算机里的随机数,本质上都不是真随机。
真随机数通常来自物理过程的熵,比如芯片热噪声、放射性衰变、你的鼠标移动间隔,量子随机数发生器也是这个路子。但这类熵源的问题是:不可复现。你不可能让同一个物理过程再发生一遍,也就没法让两个完全相同的实验产生相同的数据流。这在科学计算里是灾难——论文审稿人要求你提供可复现实验,你说“我依赖物理熵源”,那基本等于自杀。
所以实际生产中我们用的是伪随机数生成器(PRNG,Pseudo-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 老接口:全局随机状态与 MT19937
NumPy 最经典的老接口就是np.random.seed+np.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, gen=None): 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_rng:
seed_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 numpy==2.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.15 |
PCG64 在均匀分布上比 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本身就是打乱后的。
第三个坑:交叉验证中的shuffle=True,如果忘了设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)每次调试时,我都用固定的seed=0、seed=1切几个不同的随机数据集,这样既能复现 bug,又能覆盖不同尺寸的测试场景。
如果想验证一个操作是否符合预期,也可以生成一个小规模随机数组,手动算出期望值,再和大规模随机结果对比。比如你想验证np.mean(rng.random(1000000))是否接近 0.5,固定种子跑出来接近即可,多跑几个种子看波动范围是否符合统计预期。
我的实测体会
前面讲了很多技术细节,最后聊一点我自己的组织经验。我现在所有项目里都会维护一个“随机数规范”:凡是可复现的实验,代码入口处统一用default_rng创建独立生成器,不许碰全局np.random;多进程并行时一律用SeedSequence.spawn派生子种子;实验参数里永远记录四个东西——NumPy 版本、Python 版本、主种子、worker 数量。
这个规范不是一开始就有的,而是被“结果对不上”“复现不了”“并行结果飘”这些破事教训出来的。如果你正被这些问题困扰,我强烈建议你从今天开始,把手里的随机数代码统一到新 API 上来。过程可能会有些阵痛,比如老代码迁移后结果变了、同事用老接口跑的结果和新接口对不上,但这是从“能用”走向“可信”的必经之路。
而且别小看这一步,等到你需要跟别人结对排查一个“概率性 bug”,或者要把实验分发到集群上并行跑时,你就会知道,一份干净的随机数管理代码能省下多少个周五下午。