第 4 章:random 模块与 numpy 随机种子的区别归纳总结
讲真,很多初学者写随机数代码时,都会觉得 random.seed() 和 np.random.seed() 就是一回事。我在项目里见过不下十次这种用法:先用 random 库生成几个数,又切到 numpy 生成一批,发现结果对不上,就开始怀疑人生。
它们确实都叫「设种子」,但背后的状态管理、线程安全、性能表现完全不同。这一章我们彻底掰开揉碎,看看区别在哪。
1. 种子设置的根本差异
先说最直观的差异:调用方式。
import random
import numpy as np
# random 模块:全局种子
random.seed(42)
print(random.random()) # 0.6394267984578837
# numpy 模块:全局种子
np.random.seed(42)
print(np.random.rand()) # 0.3745401188473625
你看,同样种子 42,输出的第一个数完全不同。为什么?因为底层算法不一样。random 模块用 C 实现的 Mersenne Twister,numpy 早期也是 MT19937,但内部状态矩阵大小和初始化方式有差异。
我曾经踩过一个坑:在单元测试里同时用了两个库,以为种子一致就能复现,结果折腾了半小时才发现两套随机流完全独立。
核心区别:random 模块和 numpy 各有各的全局随机数生成器,种子互不影响。你设了 random 的种子,numpy 那边依然是未初始化状态。
2. 全局状态 vs 局部状态
这是最容易被忽视的差异。random 模块只有一个全局的 Random 实例,调用 random.seed() 就是重置这个全局实例的状态。而 numpy 的 np.random.seed() 也是操作一个全局的 RandomState 对象。
但 numpy 提供了更灵活的方案:你可以创建独立的 RandomState 或 Generator 对象,彼此隔离。
import numpy as np
# 创建两个独立的随机数生成器
rng1 = np.random.RandomState(42)
rng2 = np.random.RandomState(99)
print(rng1.rand(3)) # [0.37454012 0.95071431 0.73199394]
print(rng2.rand(3)) # [0.67074908 0.57181424 0.11387473]
# 全局种子不影响它们
np.random.seed(1)
print(rng1.rand()) # 依然输出 0.5986584841970366(基于rng1自己的状态)
random 模块也可以创建独立实例:my_rand = random.Random(42),只不过很多文档没重点强调,新手习惯直接调模块级函数。
我的建议:任何需要复现的项目,都显式创建独立生成器对象。别依赖全局 seed,尤其是当你调用第三方库时,鬼知道它在内部改了全局种子没有。
3. 多线程与多进程下的行为
多线程场景下,random 模块的全局实例是线程不安全的。如果你多个线程共享同一个 random.Random 对象,会出现竞争条件,产生的随机数序列不可预测,复现性直接崩了。
numpy 的 np.random.RandomState 同样是线程不安全的。好在 numpy 1.17 之后主推 Generator 和 BitGenerator 架构,推荐给每个线程创建独立的 Generator 实例。
import numpy as np
from numpy.random import Generator, PCG64
# 每个线程单独一个生成器
thread_rngs = [Generator(PCG64(s)) for s in [42, 43, 44]]
# 每个生成器的序列完全独立,复现性有保证
至于多进程,要注意 fork 后的子进程会复制父进程的随机数生成器状态,如果不手动重新设种子,多个子进程可能会产生相同的随机序列。我之前写并行蒙特卡洛时,曾吃过这个亏——8 个进程跑出来一模一样的结果,白白浪费了算力。
避坑指南:多进程下,如果你用 np.random.seed() 设了全局种子,然后在 fork 之后直接调用随机函数,每个子进程拿到的第一个随机数是相同的。正确做法是在每个子进程入口处用进程 ID 或时间戳重新设种子。
4. 性能对比
单个数生成时,random 模块比 numpy 快一点(C 语言级别,函数调用开销小)。但 numpy 真正强的是批量生成——一次性生成 100 万个随机数,比用循环调 random 快两个数量级。
import random, numpy as np, time
# random 模块生成 100 万个数
t0 = time.time()
data = [random.random() for _ in range(1_000_000)]
print(time.time() - t0) # 约 0.15 秒
# numpy 批量生成
t0 = time.time()
data = np.random.rand(1_000_000)
print(time.time() - t0) # 约 0.008 秒
所以在科学计算里,几乎没人用 random 模块做大规模随机采样,性能差距太明显。
核心对比一览
| 对比项 | random 模块 | numpy.random |
|---|---|---|
| 默认算法 | Mersenne Twister (C 实现) | MT19937(可切换为 PCG64 等) |
| 种子设置方式 | random.seed(n) 全局 | np.random.seed(n) 全局 / 独立 RandomState、Generator |
| 状态隔离 | 只能通过创建 Random 实例隔离 | 原生支持独立对象,推荐用 Generator |
| 线程安全 | 不推荐共享同一实例 | RandomState 不安全,Generator 需显式隔离 |
| 批量性能 | 差(循环生成) | 极好(矢量化) |
| 复现性 | 单一流,简单直接 | 多对象需分别设种子,更灵活但也容易乱 |
一张图看懂区别
下面这个 SVG 把两个模块的种子管理方式画出来了。你会发现 random 模块只有一条全局路径,而 numpy 给了你更多选择——但也意味着你需要更主动地管理。
我的实践习惯:新项目一律用 numpy.random.Generator 配合 PCG64 或 Philox 算法。Philox 在多进程并行时能保证每个进程产生不重叠的序列,非常适合大规模分布式模拟。random 模块我只在一些简单脚本或兼容性要求高的场合才用。
说到底,选哪个完全看场景。如果你只需要生成几万个随机数做演示,random 模块够用。但一旦涉及科学计算、并行模拟、精确复现,请直接用 numpy 的 Generator 体系,并为你的每个随机流显式管理种子。