聊到 NumPy 的 random 模块,很多人第一反应是np.random.rand()和np.random.seed(42)这对固定组合。这两个函数在十年前写起来没什么问题,但放到今天,尤其是当你开始用 NumPy 2.x,并且代码里出现多进程、并行计算或者机器学习训练流程的时候,这套老写法会带来一系列隐蔽又头疼的问题。我见过不少项目,跑单进程的时候一切正常,一上多进程结果每个 worker 生成的数据几乎一模一样——原因往往就出在随机数这块。
这篇文章我想从原理到实战,把 NumPy 的 random 模块完整拆一遍。内容包括新版Generator接口和旧版RandomState的差异、SeedSequence的种子链机制、常用概率分布函数的参数细节、在多线程和多进程环境下正确使用随机数的方法,以及几个我实际踩过的坑。无论你是刚接触 NumPy 的新手,还是已经在写数据分析或算法代码的老手,这篇文章都能帮你把随机数这块拼图补完整。
1. 从 np.random.seed(42) 说起:全局随机状态到底做了什么
1.1 全局种子的机制
np.random.seed(42)这个调用的本质,是把 NumPy 内部那个全局的RandomState对象重新初始化了一遍。RandomState内部维护着一段长长的状态数组,伪随机数生成算法(默认是梅森旋转算法,MT19937)每次从这个状态里绞出一些位数,再经过处理变成你看到的浮点数或整数。你给同一个种子,状态就回到同一起点,之后产生的随机数序列也完全一致。
这个设计在最早期很好用:写脚本的时候,全局只需要一个随机数来源,所有人都调同一套函数,种子固定就万事大吉。像这样:
import numpy as np np.random.seed(42) print(np.random.rand(3)) # 每次运行结果都一样:[0.37454012 0.95071431 0.73199394]问题是,"全局只有一个随机状态"这个假设,在现代 Python 代码里越来越不成立了。
1.2 全局状态的三个风险
第一个风险是隐式耦合。假设你写了一个工具函数,内部调用了np.random.seed(0)想固定结果,那这个函数执行完之后,整个进程的随机状态都被你改了。同一个进程里其他代码想生成"真正随机"的数,结果被你干扰,可能产生完全可预测的序列。你管这叫固定,别人管这叫事故。
第二个风险是线程安全问题。RandomState不是线程安全的,多个线程同时调用np.random.rand()这类全局函数,底层共享同一个状态对象,会产生竞争条件。结果就是:每次跑出来的序列不确定、难以复现,甚至可能在某些极端情况下抛出异常。
第三个风险是代码可测试性差。如果你的算法函数直接依赖np.random模块的全局状态,那想对这个函数做单元测试就很麻烦。你必须在测试里小心翼翼地设置和恢复全局种子,否则测试之间会互相污染。
我见过最头疼的一种情况:
sklearn之类的库内部还在用全局随机状态,你的代码也在用,两边互相踩。最后只能在每次调用前反复设 seed,治标不治本。
1.3 用 default_rng 替代全局状态
NumPy 1.17 之后,官方推荐的写法彻底变了。你现在应该创建一个独立的随机数生成器对象:
import numpy as np rng = np.random.default_rng(42) print(rng.random(3)) # [0.77395605 0.43887844 0.85859792]default_rng(seed)返回的是一个Generator实例。这个对象自带状态,你想传递就传递,想并行就各搞各的,不再动任何全局状态。你可以把它当成一个参数传进函数,函数内部想生成多少随机数完全可控,不再依赖外部环境的"隐藏状态"。
2. 新版 Generator 与旧版 RandomState:不只是换个名字
2.1 算法换代:从 MT19937 到 PCG64
RandomState底层用的是梅森旋转算法,这个算法当年很经典,但放到今天看有几个弱点:它的状态很大(约 2.5 KB),想要快速跳跃(jump ahead)比较麻烦,而且生成速度已经落后于新一代算法。Generator默认使用的是 PCG64 算法,全称 Permuted Congruential Generator,生成速度快,统计性质更好,占用状态更小。
PCG64 有一个很实用的特性:它的内部状态空间极大,你可以方便地生成大量的独立随机流。这个特性在多进程场景下特别好用,后面我会专门展开。如果你对底层实现没兴趣,只需要记住一个结论:新代码优先使用default_rng(),不要再用np.random.seed+np.random.xxx这套旧组合。
2.2 API 差异对照表
我整理了一份常用函数对照表,方便你迁移旧代码。左边是旧版RandomState/ 全局函数,右边是新的Generator方法:
| 场景 | 旧写法(RandomState) | 新写法(Generator) |
|---|---|---|
[0,1)均匀分布浮点数 | np.random.rand(3, 4) | rng.random((3, 4)) |
[0,1)均匀分布浮点数(旧式函数) | np.random.random_sample(3) | rng.random(3) |
| 指定区间均匀分布 | np.random.uniform(0, 1, 10) | rng.uniform(0, 1, 10) |
| 标准正态分布 | np.random.randn(3, 4) | rng.standard_normal((3, 4)) |
| 指定均值和标准差的正态分布 | np.random.normal(0, 1, 10) | rng.normal(0, 1, 10) |
随机整数[low, high) | np.random.randint(0, 10, 5) | rng.integers(0, 10, 5) |
随机整数[low, high]含 high | np.random.randint(0, 10+1, 5) | rng.integers(0, 11, 5) |
| 打乱数组(原地) | np.random.shuffle(x) | rng.shuffle(x) |
| 随机排列(返回新数组) | np.random.permutation(10) | rng.permutation(10) |
| 随机抽样 | np.random.choice(arr, 5) | rng.choice(arr, 5) |
注意几个细节变化:
rand和randn在Generator里没有对应的同名方法,你要用random或standard_normal传入 shape 元组。randint变成integers,语义不变,都是左闭右开区间。旧代码里如果你习惯用np.random.randint(0, 10 + 1)来取包含 10 的整数,迁移的时候要格外小心。random_sample这个名字在新接口里彻底消失了,统一用random。
2.3 为什么新接口更像"对象导向"
其实不光是命名变化,更重要的是设计哲学变了。旧版是一堆全局函数,你需要一个看不见摸不着的"全局状态"配合使用;新版是显式的Generator对象,你可以把它当作一个"随机数工厂"传来传去。
我举个例子。你想写一个模拟掷骰子的函数,并且要求可复现:
def roll_dice(rng, n_times): """用传入的 rng 而不是全局随机状态""" return rng.integers(1, 7, size=n_times) rng_a = np.random.default_rng(2024) rng_b = np.random.default_rng(2024) print(roll_dice(rng_a, 5)) print(roll_dice(rng_b, 5)) # 两个调用输出完全一致,因为种子一样这里函数只依赖传入的rng,不碰任何全局状态。想测试就测试,想复用就复用,逻辑非常干净。
3. SeedSequence 与可复现性:随机数背后是有"种子链"的
3.1 可复现的底层逻辑
很多人对"可复现"的理解就是"固定种子"。这没毛病,但理解可以再深一层。伪随机数生成器本质是一个确定性函数:给定当前状态,输出一个数并转移到下一个状态。种子决定了初始状态,所以同一个种子必然产生同一个序列。
NumPy 在中间加了一层SeedSequence,它负责把用户提供的种子(整数、数组甚至字符串)加工成生成器算法的初始状态。它的设计目标有两个:一是让不同的种子即使很接近(比如 1 和 2)也能产生统计上完全不同的随机流;二是能够从同一个种子派生出大量独立且互不关联的子种子序列,这就给并行计算提供了基础。
3.2 SeedSequence 能接收什么
default_rng()的 seed 参数比你想象中灵活。除了常见的整数,你还可以传一个整数数组,或者SeedSequence实例本身:
import numpy as np # 整数种子 rng1 = np.random.default_rng(42) # 数组种子,适合把多个维度的信息组合起来 rng2 = np.random.default_rng([2024, 5, 20]) # 直接传入 SeedSequence ss = np.random.SeedSequence(42) rng3 = np.random.default_rng(ss)传数组有个好处:当你需要一组可复现但彼此不同的随机流时,可以用同一个基础种子配合不同编号来生成。比如你开了一组实验,每个实验是一个种子加实验编号,这样即使并行跑也能轻松复现任意一个实验。
3.3 spawn 的用途
SeedSequence有一个很关键的spawn方法,它能从当前种子派生出多个相互独立的子种子。这种派生是确定性的,也就是说你每次调用spawn(4)得到的 4 个子种子都一样。
base_seed = np.random.SeedSequence(2024) child_seeds = base_seed.spawn(4) print(child_seeds) # [SeedSequence(1670774665), SeedSequence(3411480020), ...] # 每个子种子都生成自己独立的 rng rngs = [np.random.default_rng(s) for s in child_seeds]这在多进程场景中价值极大。你不用再手工选择 4 个幸运数字当种子,而是从一个基础种子派生出一整组互不干扰的种子。后面我会展示完整的多进程用法。
4. 常用概率分布函数盘点:均匀、正态只是冰山一角
4.1 常用分布一览
NumPy 内置了一整套概率分布函数,基本涵盖了工程和科研里的常见需求。下面这张表是我平时用得最频繁的:
| 分布类型 | Generator 方法 | 常用参数含义 | 典型应用 |
|---|---|---|---|
| 均匀分布 | rng.uniform(low, high, size) | 下限、上限 | 随机初始化、模拟位置 |
| 标准正态 | rng.standard_normal(size) | 均值0、方差1 | 生成噪声、Z-score数据 |
| 正态分布 | rng.normal(loc, scale, size) | 均值、标准差 | 模拟测量误差、权重初始化 |
| 对数正态 | rng.lognormal(mean, sigma, size) | 对数均值、对数标准差 | 股票价格、收入分布 |
| 指数分布 | rng.exponential(scale, size) | 尺度参数(期望) | 排队论、事件间隔 |
| 泊松分布 | rng.poisson(lam, size) | 平均发生次数 | 计数数据、事故次数 |
| 二项分布 | rng.binomial(n, p, size) | 试验次数、成功概率 | 命中率模拟 |
| Beta 分布 | rng.beta(a, b, size) | 两个形状参数 | 贝叶斯先验 |
| Gamma 分布 | rng.gamma(shape, scale, size) | 形状参数、尺度参数 | 可靠性分析、降雨量 |
| 卡方分布 | rng.chisquare(df, size) | 自由度 | 假设检验 |
| 多元正态 | rng.multivariate_normal(mean, cov, size) | 均值向量、协方差矩阵 | 多维特征采样 |
4.2 参数容易搞混的几个函数
第一个容易踩雷的是rng.normal(loc, scale)。很多从 MATLAB 转过来的人会下意识写rng.normal(mean, std),这没问题,但要清楚scale是标准差而不是方差。如果你需要方差为 4 的正态分布噪声,应该写rng.normal(0, 2, size),不是rng.normal(0, 4, size)。
第二个是rng.lognormal。它接收的两个参数不是最终分布的直接均值和标准差,而是"对数之后"的均值和标准差。比如你模拟一只股价,日收益率取对数后服从均值 0.001、标准差 0.02 的正态分布,那股价倍数就是np.exp(rng.normal(0.001, 0.02, size)),或者直接用rng.lognormal(0.001, 0.02, size)。
第三个是rng.binomial(n, p)的参数顺序。写的是试验次数n在前,成功概率p在后,别和某些库写成(p, n)搞混。如果你要模拟 100 个人里每个人在 10 次射击中射中靶心的次数,且单次命中概率是 0.3,那就是rng.binomial(10, 0.3, size=100)。
4.3 生成批量数据的 size 参数
新手很容易纠结:size 参数到底传整数还是元组?
size=10表示一维数组,10 个元素。size=(3, 4)表示二维数组,3 行 4 列。size=(2, 3, 4)表示三维数组。
一个实用技巧:当你要给批量数据每个样本加独立噪声时,直接传入和样本形状一致的size元组即可。比如你有一个形状为(batch_size, features)的特征矩阵X,想给它加高斯噪声:
noise = rng.normal(0, 0.01, size=X.shape) X_noisy = X + noise这里size传的是X.shape,NumPy 会按照广播规则自动对齐维度,非常省事。
5. 打乱与抽样:shuffle、permutation、choice 的正确打开方式
5.1 打乱:shuffle 与 permutation 的区别
这是入门者最容易踩坑的点。rng.shuffle(x)是原地操作,也就是直接修改传入的数组,返回None。rng.permutation(x)则是返回一个新的打乱后的数组,原数组不变。
arr = np.arange(10) rng.shuffle(arr) print(arr) # 原数组被改了 arr2 = np.arange(10) new_arr = rng.permutation(arr2) print(arr2) # 原数组没变 print(new_arr) # 打乱后的新数组如果你只是想把arr1打乱后赋值给arr2,千万不能用arr2 = rng.shuffle(arr1),因为shuffle返回的是None,你把None赋过去了。必须写成rng.shuffle(arr1); arr2 = arr1.copy(),或者干脆用permutation。
对于多维数组,shuffle默认只打乱第一维,也就是行之间的顺序,行内元素顺序不变。permutation也可以传入一个整数n,表示生成0到n-1的一个随机排列。
5.2 choice 抽样:有放回与无放回
rng.choice(a, size, replace, p)是个非常实用的函数,尤其是replace参数。replace=True表示有放回抽样,同一个元素可以被抽中多次;replace=False表示无放回抽样,抽出来的元素不会重复。
我举个例子。你从 100 个客户里随机抽 30 个发优惠券,显然每个人只能抽到一次,这就是无放回抽样:
customers = np.arange(100) selected = rng.choice(customers, size=30, replace=False) print(selected)而有放回抽样的经典场景是自助法(Bootstrap)。你要从样本里重采样出和原样本一样大小的新数据集,用replace=True:
bootstrap_sample = rng.choice(data, size=len(data), replace=True)p参数可以指定每个元素被抽中的概率。默认是均匀概率,但如果你要做重要性采样,或者模拟某个有偏的过程,就需要传一个与a等长的概率数组,并且概率之和要等于 1。
5.3 数据科学里的联合打乱
训练机器学习模型的时候,你经常要把特征矩阵X和标签向量y一起打乱,保证特征和标签的对应关系不变。最简洁的办法是生成一组随机索引,然后用这组索引同时操作两个数组:
idx = rng.permutation(len(X)) X_shuffled = X[idx] y_shuffled = y[idx]用permutation而不是先各自shuffle,就是为了保证两者打乱的顺序一致。如果你用的是 Pandas DataFrame,也可以先用rng.permutation生成索引,再.iloc[idx]。
这里有个隐蔽的坑:如果你先后对
X和y分别调用shuffle,两者的打乱顺序完全不同,数据就废了。一定记得用统一索引方案。
6. 三个高频实战场景:验证理论是否真的好用
6.1 蒙特卡洛估算圆周率
这个例子非常经典,也是我用起来最顺手的验证方法。原理是在一个边长为 2 的正方形内随机撒点,统计落在内切圆内的点数占比,这个比例乘以 4 就是 π 的近似值。
rng = np.random.default_rng(42) n_points = 1_000_000 # 生成 [-1, 1] 范围内的 x 和 y 坐标 x = rng.uniform(-1, 1, size=n_points) y = rng.uniform(-1, 1, size=n_points) inside = x ** 2 + y ** 2 <= 1.0 pi_estimate = 4 * inside.sum() / n_points print(pi_estimate) # 理论上是 3.14159 左右,具体数值取决于种子和点数注意我用了rng.uniform而不是rng.random,因为前者可以直接指定范围。这个例子也说明了蒙特卡洛方法的本质:用大量随机采样来逼近解析解。换成更复杂的积分问题,代码结构完全一样,只是函数内部变了。
6.2 模拟带噪声的线性数据
做回归实验或者演示算法的时候,我们需要一批"看起来真实"的数据。通常做法是在线性关系上叠加高斯噪声:
rng = np.random.default_rng(2024) n = 200 x = rng.uniform(0, 10, size=n) true_slope = 2.5 true_intercept = -0.8 noise = rng.normal(0, 1.5, size=n) y = true_intercept + true_slope * x + noise这里的noise标准差选了 1.5,意味着数据点在直线附近的离散程度大约在 ±3 个标准差以内。如果你想让噪声更大,就把标准差调大。这个代码同时也展示了rng.uniform、rng.normal和广播机制的配合。
6.3 训练集/验证集划分
在没有train_test_split的场合,你可以用rng.permutation自己写一个划分:
rng = np.random.default_rng(7) n = len(X) idx = rng.permutation(n) split = int(0.8 * n) train_idx = idx[:split] val_idx = idx[split:] X_train, y_train = X[train_idx], y[train_idx] X_val, y_val = X[val_idx], y[val_idx]随机划分保证了训练集和验证集分布上的随机性,避免因为数据本身有序而产生偏差。这也是交叉验证里最常见的基础操作。如果你用rng.choice(..., replace=False)也能做,但生成索引的方式更直观,也更方便后续对齐样本。
7. 多线程与多进程环境下的随机数陷阱
7.1 多线程:不要共享同一个 rng
Generator对象和RandomState一样,也不是线程安全的。多个线程同时调用同一个rng.random(),会出现状态竞争,轻则结果不可复现,重则报错。正确做法是每个线程创建自己独立的rng。
这里的种子怎么选才合理?如果你给每个线程固定同一个种子,那所有线程产生的随机序列会一模一样,这往往不是你想要的。如果你不给种子,默认会从系统熵源取随机种子,那又无法精确复现整个多线程实验。最佳实践是先创建一个基础SeedSequence,然后spawn出每个线程的独立子种子:
base_seed = np.random.SeedSequence(2024) child_seeds = base_seed.spawn(thread_count) thread_rngs = [np.random.default_rng(s) for s in child_seeds]7.2 多进程:每个 worker 用独立种子
多进程场景更复杂。如果你在 Linux 上通过multiprocessing.Pool启动多个 worker,并且每个 worker 函数内部都用同一个全局种子创建rng,那所有 worker 生成的随机数会完全一样。
有个经典错误,我见过不少人犯:
# 错误的示范 def worker(_): rng = np.random.default_rng(2024) return rng.random(3) with multiprocessing.Pool(4) as pool: results = pool.map(worker, range(4)) # 四个结果完全相同,因为种子一样你要的是每个 worker 有不同的随机流,但整体又能复现。解决方案就是前面提到的SeedSequence.spawn:
import multiprocessing as mp import numpy as np def init_worker(seed): global rng rng = np.random.default_rng(seed) def worker(_): return rng.random(3) if __name__ == "__main__": base_seed = np.random.SeedSequence(2024) child_seeds = base_seed.spawn(4) with mp.Pool(4, initializer=init_worker, initargs=(child_seeds,)) as pool: results = pool.map(worker, range(4)) print(results)这里我用Pool的initializer给每个 worker 注入不同的种子。这样所有 worker 的随机流互不相关,但只要你固定了基础种子2024,整次运行的结果就是可复现的。
7.3 Pool 里的正确写法的小提示
child_seeds是一个SeedSequence的列表,但 Python 的多进程在传递参数时会对initargs做序列化。如果你担心SeedSequence对象传不进子进程,可以把每个子种子先转成整数:
child_seeds = base_seed.spawn(4) child_seeds_int = [s.generate_state(1)[0] for s in child_seeds]其实SeedSequence本身可以比较方便地转成整数种子。不同项目里我习惯用两种方式,如果 worker 数量不多,直接传整数种子最省心;如果 worker 数量很大,还是建议用带padding的方式生成更长的状态数组,进一步降低种子之间相关的可能性。
8. 版本迁移中我踩过的坑:NumPy 2.x 下的一些习惯
8.1 混用全局种子与新 Generator 的复现陷阱
这是我最有体会的一个坑。有一段时间我在旧代码里写着np.random.seed(42),后面又在某个函数里用了np.random.default_rng(42),天真地以为这两者是同一个随机流。结果发现完全不对。
np.random.seed(42)重设的是那个全局RandomState的状态,而np.random.default_rng(42)内部用SeedSequence(42)去初始化一个Generator实例。两者用的算法不同(MT19937 vs PCG64),状态初始化方式也不同,所以即使种子一样,产生的数字序列也完全不同。
正确习惯是:整个项目里统一使用一种风格。新代码我只用default_rng,旧代码如果一时改不动,我就明确区分"全局随机状态"和"局部 rng 对象",绝不在同一段逻辑里混用。
8.2 方法名和参数范围的变化
早期用np.random.randint(0, 10)的人,迁移到rng.integers(0, 10)时很容易忽略一个细节:新接口的high参数同样是开区间,也就是不包含上限。如果你之前习惯用+1来补足上限,那现在还是得保留这个习惯。
还有np.random.choice里的p参数,在老接口里如果不是归一化的概率,通常会帮你隐式归一化。但新接口我遇到过概率之和略大于 1 导致报错的边界情况。所以现在无论哪个接口,我传p之前都会先做一次归一化:
p = np.array([1, 2, 3], dtype=float) p = p / p.sum()8.3 一个具体的数据分析踩坑经历
之前做一次模拟实验,脚本第一次跑出来 A 结果,第二次跑出来 B 结果,当时我还以为算法有 bug。后来定位到原因是代码里在导入模块的过程中无意调用了np.random.seed(),导致后续所有全局随机函数都受影响。模块导入这个环节几乎没有人会去查,但它确实会悄悄改变全局状态。
换到default_rng之后,这个问题再也没出现过。因为每个函数拿到的rng都是显式创建的,谁也不会干扰谁。这也让我意识到,NumPy 官方强调新接口,不只是 API 审美问题,更是一种"随机状态的显式化传递"的工程实践。
最后分享一个我一直在用的小技巧:凡是涉及随机数的算法代码,我都把seed作为一个显式参数传到函数里,默认值设成None。这样做的好处是,别人调用时想复现就把种子传进来,不想复现就让它从系统熵源取一个随机种子。这不仅让代码更灵活,也让整个随机过程在项目里变得可控、可测、可解释。
def generate_synthetic_data(n, seed=None): rng = np.random.default_rng(seed) x = rng.uniform(0, 10, size=n) y = 3.0 * x + rng.normal(0, 1.0, size=n) return x, yNumPy 的 random 模块看起来简单,但背后的细节够挖很久。希望这篇拆解能帮你在写随机数相关代码时少踩几个坑,把随机性真正变成手里可控的工具。