第 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 给了你更多选择——但也意味着你需要更主动地管理。

random 模块与 numpy 随机种子管理映射 random.seed(n) 全局 Random 实例 random.random() 等函数 单一线程安全需加锁 np.random.seed(n) 全局 RandomState np.random.rand() 等函数 (不推荐新代码使用) RandomState(seed) Generator(PCG64(seed)) rng.random() 等独立流 推荐方式:隔离、可复现、线程独立 关键对比:random 模块只有一条全局路径;numpy 提供两条路径 左侧旧路径用 np.random.seed(),右侧新路径用 Generator(bitgen, seed)

我的实践习惯:新项目一律用 numpy.random.Generator 配合 PCG64 或 Philox 算法。Philox 在多进程并行时能保证每个进程产生不重叠的序列,非常适合大规模分布式模拟。random 模块我只在一些简单脚本或兼容性要求高的场合才用。

说到底,选哪个完全看场景。如果你只需要生成几万个随机数做演示,random 模块够用。但一旦涉及科学计算、并行模拟、精确复现,请直接用 numpy 的 Generator 体系,并为你的每个随机流显式管理种子。