2. Python 内置 random 模块详解
Python 的 random 模块,看起来简单,但坑不少。我刚接触它时觉得「不就是个随机数吗?」后来在线上数据采样时踩过一个大坑——同一套种子,两次跑出来的样本居然不一样。从那以后,我才认真看了它的源码。
这一章,我把 random 模块的核心机制、关键函数、种子绑定规则,以及我工作中积累的避坑手法,统统讲清楚。你理解了它,后面的第三方库复用思路就通了。
2.1 random 模块的底层机制
random 使用的是 梅森旋转算法(Mersenne Twister),具体是 MT19937 变体。这个算法周期极长(2^19937-1),统计学质量也不错,日常实验够用。但注意,它不适用于密码学——安全性不够,别拿它生成 token 或密钥。
种子(seed)是算法的初始状态。一旦你调用了 random.seed(42),整个全局生成器的状态就被重新初始化。之后每次调用 random.random() 都会按顺序从状态链中取一个值。状态是完全确定的,所以只要种子一样,调用序列一样,结果就能复现。
举个最简单的例子:
import random
random.seed(2024)
print(random.random()) # 0.325006...
print(random.random()) # 0.685001...
# 重新设种子,再从头开始
random.seed(2024)
print(random.random()) # 0.325006... 与第一次完全一样
看到没?两次设同一种子,输出完全相同。这就是复现性的基础。但要注意,种子只影响当前全局的 Random 实例。如果你创建了自己的 random.Random(seed) 实例,它们之间互不影响。
关键认知: random 模块内部维护了一个全局的 Random 对象。所有顶层函数(random.random()、random.randint() 等)都是通过这个全局对象调用。你调用 random.seed() 就是在重置这个全局对象的内部状态。
2.2 核心函数与种子绑定关系
不同函数本质上是从同一个状态链中取值,再经过不同的变换得到的。我整理了一张表,列出最常用的函数、它们的输出依赖什么、以及种子如何影响它们。
| 函数 | 返回值范围 | 底层依赖 | 种子影响方式 |
|---|---|---|---|
random() | [0.0, 1.0) | 直接取状态值 | 直接,第 n 次调用对应第 n 个值 |
randint(a, b) | [a, b] 整数 | 由 random() 换算 | 同上,换算公式固定 |
uniform(a, b) | [a, b) 浮点 | 由 random() 换算 | 同上 |
choice(seq) | 序列中元素 | 由 random() 换算索引 | 同上,序列不变时结果固定 |
shuffle(seq) | 打乱原序列 | 消耗多个 random() 值 | 种子固定,打乱顺序固定 |
sample(pop, k) | 长度为 k 的列表 | 多次随机取值 | 完全受种子控制 |
gauss(mu, sigma) | 正态分布浮点 | Box-Muller 变换,消耗 2 个 random() | 种子决定两个基础值 |
你想想看,既然所有函数都依赖 random(),那么种子的作用范围就是整个调用序列。比如:
import random
random.seed(1)
a = random.randint(1, 100) # 消耗 1 个随机数
b = random.random() # 消耗 1 个
c = random.choice(['x','y']) # 再消耗 1 个
# 下一次调用将是第 4 个随机数
调用顺序一变,结果就全变了。这个点非常容易踩坑——插一句代码,复现性就断掉。
2.3 常见陷阱与避坑指南
我曾经在写单元测试时,先调了 random.shuffle 打乱列表,然后又在中间插了一段 random.randint 用来生成模拟数据。结果测试时死活复现不了,后来发现多调用了一次 random(),顺序完全错位了。从那以后我规定团队:实验代码里不要混用不同模块的随机函数,除非你能精确控制调用序列。
避坑点一:不要依赖模块加载顺序
别人导入你的模块时,如果也调了 random.seed(),全局状态会被覆盖。我建议:要么用 random.Random(seed) 创建独立实例,要么在最入口处统一设种子,后续所有模块都从全局 random 取数。
避坑点二:多线程下种子会乱
random 模块不是线程安全的。两个线程同时调用 random.random(),内部状态可能被交叉访问,导致结果不可复现。我习惯每个线程自己创建一个 random.Random(seed + thread_id) 实例,互不干扰。
避坑点三:seed() 的可选参数
random.seed(a=None, version=2),当 a 为 None 时,系统会从 os.urandom() 采一个随机种子。这样每次启动都不一样,适合生产环境但不适合复现性实验。记得显式传一个整数。
我的个人经验: 写实验框架时,我会将种子作为 config.py 里的一个常量,所有依赖随机数的操作都通过这个种子初始化。如果中间调试需要临时加随机数,我也会手动调整种子,保持后面对齐。
2.4 实战:搭建可复现随机实验框架
我把上面说的集中成一个模板,你以后直接套用就行。
import random
class RandomExperiment:
def __init__(self, seed=42):
self.seed = seed
self._rng = random.Random(seed)
self.call_count = 0
def randint(self, a, b):
self.call_count += 1
return self._rng.randint(a, b)
def shuffle(self, lst):
self.call_count += 1
self._rng.shuffle(lst)
def sample(self, pop, k):
self.call_count += 1
return self._rng.sample(pop, k)
def reset(self, new_seed=None):
if new_seed is not None:
self.seed = new_seed
self._rng = random.Random(self.seed)
self.call_count = 0
# 用法
exp = RandomExperiment(seed=1234)
data = [1,2,3,4,5,6,7,8,9,10]
exp.shuffle(data)
selected = exp.sample(data, 3)
print(selected) # 每次运行一致
print(exp.call_count) # 2
这个模板的好处是:独立实例,内部计数,随时 reset。你在 Jupyter Notebook 里调试时,再也不怕不小心多跑一个单元格破坏顺序了。
核心原则: 你在实验代码中插入或删除任何一句随机数调用,都会改变后续所有随机数的取值。所以,先写好完整调用序列,再固定种子。不要边写边测边改随机数数量。