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 里调试时,再也不怕不小心多跑一个单元格破坏顺序了。

random模块种子机制与函数调用关系图 random 模块种子机制与函数调用关系 random.seed(整数) 全局 Random 对象 (MT19937 状态链) random.random() random.randint(a,b) random.uniform(a,b) random.choice(seq) random.shuffle(lst) random.sample(pop,k) random.gauss(mu,σ) random.expovariate(λ) random.betavariate(α,β) 所有函数都从同一个状态链取值,调用顺序改变 → 所有后续结果偏移

核心原则: 你在实验代码中插入或删除任何一句随机数调用,都会改变后续所有随机数的取值。所以,先写好完整调用序列,再固定种子。不要边写边测边改随机数数量。