📑 本页目录(点开跳转)
10 · 随机数、种子与可复现
⏱ 64 分钟 | ⭐ 四个 worker 各自 np.random.seed(42),抽出来的「随机数」四个一模一样 —— 而且它和操作系统无关,Windows 上照样复现
🎯 一句话
np.random.seed(42) 播的是一个全局状态,进程内谁都能动它、进程间又没法让它们各不相同。
现代 API(np.random.default_rng / SeedSequence.spawn)把状态收进对象里,一次解决这两个问题。
🧩 一、先把三块地让出去
「可复现」这个词太大,站内有三处在讲它,各管一层:
| 说的是 | 在哪讲 | 内容 |
|---|---|---|
| 训练可复现清单 | ML 基础 · 11 · 训练调试手册 | set_seed() 要同时播 random / numpy / torch / torch.cuda,加上 cuDNN 的确定性开关 |
| 系统级可复现 | 模型上线之后 · 17 · 版本回溯与可复现 | git commit + data_hash + 环境快照 —— 那里 seed 只是元数据字典里的一个字段 |
| ⭐ 随机数 API 本身 | 本章 | Generator / SeedSequence / spawn,以及全局 np.random.seed 到底错在哪 |
⭐ 本章是那两处的地基:它们都告诉你「要设种子」,但都没说设了之后为什么还是不一样。
Kaggle 竞赛方法论 · 04 · 超参数调优与工程实践 里那句「设置 Python、NumPy、PyTorch 的随机种子确保实验可复现」是那个板块唯一一次提到 NumPy —— ⭐ 给了指令没给机制,本章补的正是机制那一半。
🧩 二、两代 API:同一个种子,给的数不一样
import numpy as np
np.random.seed(42); a1 = np.random.randint(0, 100, 5)
np.random.seed(42); a2 = np.random.randint(0, 100, 5)
print("legacy seed(42) 两次 ->", a1, "/", a2, " 一致:", np.array_equal(a1, a2))
b1 = np.random.default_rng(42).integers(0, 100, 5)
b2 = np.random.default_rng(42).integers(0, 100, 5)
print("default_rng(42) 两次 ->", b1, "/", b2, " 一致:", np.array_equal(b1, b2))
print("同一个种子 42,两代 API 给的数一样吗:", np.array_equal(a1, b1))
关键信息
⚠️⚠️ 两代 API 各自可复现,但互相之间不可复现。 底层的比特发生器不是同一个(遗留那套是 Mersenne Twister,现代默认是 PCG64),生成算法也不同。
💀 这一条会以最讨厌的方式咬人:一份代码里前半段用 np.random.seed(42) 播种、后半段用 default_rng(42) 建 Generator,播的那个种子对后半段一点作用都没有。改半天「为什么结果还是变」,其实两半从来就没连上过。
⭐ 规矩:一个项目里只用一套。 遇到混用的老代码,把 np.random.seed(42) 那半改掉,别指望它们对齐。
🧩 三、💀 全局状态:任何人都能动它
上一节看到的还只是「不一致」,真正的问题是遗留 API 的状态是全局的。
import numpy as np
np.random.seed(42)
c1 = np.random.randint(0, 100, 3)
np.random.seed(42)
np.random.rand() # 假装这是某个第三方函数
c2 = np.random.randint(0, 100, 3)
print("seed(42) 直接抽 ->", c1)
print("seed(42) 中间被抽了一次 ->", c2)
g = np.random.default_rng(42); d1 = g.integers(0, 100, 3)
g = np.random.default_rng(42); np.random.rand(); d2 = g.integers(0, 100, 3)
print("Generator 中间被抽了一次 ->", d2, " 和不被打扰时一致:", np.array_equal(d1, d2))
关键信息
⭐ 看清楚这个错位:本来该拿到 [51 92 14],中间被别人抽了一个数,你就整体往后挪了一位,拿到 [92 14 71]。
💀 「别人」是谁,你根本控制不了:sklearn 的某个默认 random_state=None 的估计器、某个数据增强库、某个 pandas 的 sample()、甚至你自己两周前加的一行调试代码。换个版本、多导入一个包,序列就变了。
⭐ Generator 免疫:状态在 g 这个对象里,全局 np.random 怎么折腾都碰不到它。
这就是「把状态从全局搬进对象」的全部理由——和面向对象那些大道理无关,纯粹是因为全局状态没法保证独占。
🧩 四、💀💀 招牌坑:四个 worker,同一个种子
import numpy as np
from multiprocessing import Pool
def worker_fixed_seed(i): # 每个 worker 各自写死同一个种子
np.random.seed(42)
return int(np.random.randint(0, 10000))
def worker_no_seed(i): # 什么都不做
return int(np.random.randint(0, 10000))
def worker_spawned(seed_seq): # 从一颗种子派生出互相独立的子种子
return int(np.random.default_rng(seed_seq).integers(0, 10000))
if __name__ == "__main__": # Windows 上 spawn 必须有这一行
with Pool(4) as p:
print("每个 worker 各自 seed(42) ->", p.map(worker_fixed_seed, range(4)))
with Pool(4) as p:
print("什么都不做(第一次) ->", p.map(worker_no_seed, range(4)))
with Pool(4) as p:
print("什么都不做(第二次) ->", p.map(worker_no_seed, range(4)))
children = np.random.SeedSequence(42).spawn(4)
with Pool(4) as p:
print("SeedSequence(42).spawn(4) ->", p.map(worker_spawned, children))
children = np.random.SeedSequence(42).spawn(4)
with Pool(4) as p:
print("再跑一次 ->", p.map(worker_spawned, children))
结果对照
三种做法,三种结局:
| 做法 | 独立吗 | 可复现吗 | 结果 |
|---|---|---|---|
每个 worker np.random.seed(42) |
💀 不独立 | 可复现 | [7270, 7270, 7270, 7270] ——四个完全一样 |
| 什么都不做 | 独立 | ❌ 不可复现 | 两次跑给出完全不同的两组 |
⭐ SeedSequence(42).spawn(4) |
✅ 独立 | ✅ 可复现 | [4974, 746, 7031, 8994],再跑一次一模一样 |
⚠️⚠️ 注意这个坑和操作系统无关。 常见的说法是「fork 让子进程继承了父进程的随机状态」,那确实是 Linux/macOS 上的另一种撞法;⭐ 但上面这一组是在 Windows(spawn,子进程从零启动)上跑出来的,照样四个一样。
真正的成因只有一句:worker 函数里写死了一个常数种子。子进程怎么创建、继不继承状态,全都不重要——每个 worker 都从同一个种子出发,当然走到同一个地方。
💀 它为什么难发现:四个 worker 做数据增强,各自「随机」裁剪、翻转、加噪声——结果四份增强完全相同。训练不报错,loss 照常下降,只是你的数据多样性只有你以为的四分之一。
⚠️ 这一条也是「什么都不做反而更安全」的少数场景之一:不设种子至少四个 worker 是不同的,只是没法复现。最糟的组合是「设了种子,还设成同一个」——既没多样性,又让你相信自己是可复现的。
🛑 读到这里可以停 —— 前半章讲完了(约 22 分钟)。 后半章还有:
SeedSequence和spawn:既独立又可复现 ·Generator的 API:改了名字的和改了行为的 · 存档读档:把「随机到哪儿了」存下来 · 五条规矩 回来的时候不用重读,直接从下一节接着看就行。
🧩 五、SeedSequence 和 spawn:既独立又可复现
SeedSequence 做的事只有一件:把一颗种子按一棵树展开成任意多颗互相独立的子种子。
import numpy as np
parent = np.random.default_rng(42)
kids = parent.spawn(3) # ⭐ Generator 自己就有 spawn
print("parent.spawn(3) 各抽一个:", [int(k.integers(0, 10000)) for k in kids])
ss = np.random.SeedSequence(42) # 也可以直接从 SeedSequence 出发
print("SeedSequence(42).spawn(3):",
[int(np.random.default_rng(c).integers(0, 10000)) for c in ss.spawn(3)])
要点
parent.spawn(3) 各抽一个: [4974, 746, 7031]
SeedSequence(42).spawn(3): [4974, 746, 7031]
⭐ 两种写法等价,rng.spawn(n) 是简写。而且和上一节 spawn(4) 的前三个完全一致——派生是确定的、和你要几个无关。
⭐ 正确的多进程模板只有三行:
| 步骤 | 代码 | 在哪执行 |
|---|---|---|
| ① 主进程建一颗种子 | ss = np.random.SeedSequence(42) |
主进程 |
| ② 派生 n 个子种子 | children = ss.spawn(n_workers) |
主进程 |
| ③ 每个 worker 用分到的那颗建 Generator | rng = np.random.default_rng(children[i]) |
worker |
⚠️ 关键是「种子在主进程分好再发下去」,而不是「让每个 worker 自己想办法」。worker 自己算种子(比如用 os.getpid())能拿到独立性,但丢掉可复现性——进程号每次都不一样。
⚠️ PyTorch 的 DataLoader 有它自己的一套 worker 种子机制(worker_init_fn、每个 worker 拿到独立的 base_seed)。⭐ 那套机制解决的是同一个问题,但参数怎么调是 AI 基础设施 · 22 · 数据管线与存储 的题,本章只讲 NumPy 这一侧。
🧩 六、Generator 的 API:改了名字的和改了行为的
import numpy as np
rng = np.random.default_rng(42)
print("integers (右端默认不含)", rng.integers(0, 10, 5))
print("integers endpoint=True ", rng.integers(0, 10, 5, endpoint=True))
print("random [0,1) ", np.round(rng.random(3), 4))
print("normal ", np.round(rng.normal(0, 1, 3), 4))
print("choice 无放回 ", rng.choice(10, 4, replace=False))
print("permutation (返回新的) ", rng.permutation(8))
a = np.arange(8)
rng.shuffle(a); print("shuffle (原地改) ", a)
| 调用与约定 | 本次输出 |
|---|---|
| integers (右端默认不含) | [0 7 6 4 4] |
| integers endpoint=True | [9 0 7 2 1] |
| random [0,1) | [0.9756 0.7611 0.7861] |
| normal | [-0.0168 -0.853 0.8794] |
| choice 无放回 | [1 6 8 7] |
| permutation (返回新的) | [1 0 5 4 3 6 7 2] |
| shuffle (原地改) | [6 1 2 7 3 5 0 4] |
名字对照表(遇到老代码照这个改):
遗留(np.random.*) |
现代(rng.*) |
备注 |
|---|---|---|
seed(42) |
rng = np.random.default_rng(42) |
⭐ 从「播种」变成「建对象」 |
randint(0, 10) |
integers(0, 10) |
⭐ 新的多一个 endpoint= 参数,可以含右端 |
rand(3, 4) |
random((3, 4)) |
⚠️ 形状要写成元组,不是两个位置参数 |
randn(3) |
standard_normal(3) |
—— |
choice / shuffle / permutation |
同名 | 行为一致 |
RandomState(42) |
Generator(PCG64(42)) |
老代码里 RandomState 仍可用,只是不再推荐 |
⭐ permutation / shuffle / permuted 三兄弟,二维上才分得清:
import numpy as np
m = np.arange(12).reshape(3, 4)
print("原始\n", m)
print("permutation(m, axis=1) 整列一起搬(各行同一套顺序)\n",
np.random.default_rng(0).permutation(m, axis=1))
print("permuted(m, axis=1) 每行独立打乱\n",
np.random.default_rng(0).permuted(m, axis=1))
a = np.arange(6)
b = np.random.default_rng(0).permutation(a)
print("\npermutation 返回新数组,原数组不动:", a, "->", b)
c = np.arange(6)
np.random.default_rng(0).shuffle(c)
print("shuffle 原地改:", c)
关键信息
| 函数 | 二维上干什么 | 改不改原数组 |
|---|---|---|
permutation(m, axis=1) |
整列一起搬,三行用同一套顺序 | ❌ 返回新的 |
permuted(m, axis=1) |
⭐ 每行独立打乱,三行顺序各不相同 | ❌ 返回新的 |
shuffle(m, axis=1) |
整列一起搬 | ⭐ 原地改 |
💀 打乱样本和标签时,只能用 permutation 拿下标再一起索引,绝不能对 X 和 y 各 shuffle 一次——那样两边的顺序不一样,标签就和样本对不上了,而且不报错。
🧩 七、存档读档:把「随机到哪儿了」存下来
import numpy as np
g = np.random.default_rng(0)
g.random(100) # 先烧掉一些
state = g.bit_generator.state # ⭐ 这就是全部状态,一个 dict
x1 = g.random(3)
g.bit_generator.state = state # 读档
x2 = g.random(3)
print("存档后重放,两次一致:", np.array_equal(x1, x2), np.round(x1, 6))
print("state 的键:", sorted(state.keys()), "| bit_generator:", state["bit_generator"])
要点
存档后重放,两次一致: True [0.479988 0.232373 0.801881]
state 的键: ['bit_generator', 'has_uint32', 'state', 'uinteger'] | bit_generator: PCG64
⭐ 用途:训练跑到第 30 个 epoch 挂了,想从检查点续跑并且接上原来的随机序列——光存种子不够(种子只能从头开始),得存 bit_generator.state。
⚠️ 这个 dict 可以直接 JSON 化存进 checkpoint 元数据里。系统级怎么组织这些元数据(和 git commit、data_hash 一起)是 模型上线之后 · 17 的题。
🚦 八、五条规矩
| # | 规矩 | 为什么 |
|---|---|---|
| 1 | ⭐ 一个项目只用一套 API | 两代 API 同种子给的数不同([51 92 14 71 60] vs [8 77 65 43 43]),混用等于没播种 |
| 2 | ⭐ 函数里不要建 Generator,把 rng 当参数传进去 |
函数内 default_rng(42) 意味着每次调用都重放同一段序列 |
| 3 | ⭐ 多进程一律 SeedSequence(seed).spawn(n),在主进程分好再发下去 |
worker 自己算种子会丢掉可复现性;写死常数种子会丢掉独立性 |
| 4 | ⚠️ 别把种子当超参数调 | 换种子换出来的 0.3% 提升是噪声。⭐ 正确做法是跑多个种子报均值和标准差 |
| 5 | ⚠️ 能复现 ≠ 结果可信 | 固定种子只保证「同一份代码跑两遍一样」。⭐ 模型是不是真的更好,得看换种子之后还成不成立 |
⭐ 第 4、5 条合起来是本章最容易被忽略的一半:设种子的目的是让差异可归因(改了代码之后的变化确实来自代码),不是让数字好看。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| ML 基础 · 11 · 训练调试手册 | ⭐ 那里有一份 set_seed() 训练可复现清单(random / numpy / torch / torch.cuda 一起播、cuDNN 确定性开关)。本章是它的地基:它告诉你要播哪几个,这里告诉你播了之后为什么在多进程下还是不管用 |
| 模型上线之后 · 17 · 版本回溯与可复现 | 那里的可复现是系统级的:git commit + data_hash + 环境快照,seed 只是元数据里的一个字段。⭐ 第七节存下来的 bit_generator.state 正是该塞进那份元数据的东西 |
| Kaggle 竞赛方法论 · 04 · 超参数调优与工程实践 | 那里写着「设置 Python、NumPy、PyTorch 的随机种子确保实验可复现」——⭐ 给了指令没给机制。本章第四节那个「四个 worker 全是 7270」就是照着做了还是不复现的典型 |
| AI 基础设施 · 22 · 数据管线与存储 | PyTorch DataLoader 的 num_workers 怎么配、worker 拿到什么种子,在那一章。⭐ 它解决的正是第四节这个问题,但那是框架层的解法,本章只讲 NumPy 这一侧 |
| 11 · 从 NumPy 到 PyTorch | 下一章讲 NumPy 用户跨到 torch 时会踩的四个语义陷阱。⚠️ 随机数是第五个隐性差别:torch.manual_seed 和 np.random.default_rng 是两套完全独立的状态,播一个不影响另一个 |
| 07 · 把循环改写成数组运算 | 那一章每段代码都以 rng = np.random.default_rng(0) 开头。⭐ 读完本章你就知道那不是装饰——换成 np.random.seed(0) 的话,那些倍数就没法在你的机器上复现了 |
✅ 检查点
np.random.seed(42)和np.random.default_rng(42)抽出来的数一样吗?各是什么?- 一份代码前半段
np.random.seed(42)、后半段default_rng(42),会发生什么? seed(42)之后被第三方函数抽走一个数,你拿到的三个数会变成什么?Generator会不会有这个问题?- 四个 worker 各自
np.random.seed(42)会得到什么?这和操作系统(fork 还是 spawn)有关吗? - 上一题那个坑「什么都不做」反而更好吗?好在哪、差在哪?
- 正确的多进程种子模板是哪三步?关键在哪一步?
rng.permutation(m, axis=1)和rng.permuted(m, axis=1)在二维上有什么区别?- 打乱样本
X和标签y,为什么不能各shuffle一次? - 训练到第 30 个 epoch 挂了,想续跑并接上原来的随机序列,光存种子够吗?
- 换个种子模型指标涨了 0.3%,该怎么解读?
👀 答案
- 不一样。
np.random.seed(42)+randint(0,100,5)给[51 92 14 71 60],default_rng(42).integers(0,100,5)给[8 77 65 43 43]。底层比特发生器不同(Mersenne Twister vs PCG64)。 - 💀 播的那个种子对后半段一点作用都没有。两半从来没连上过,你会一直在查「为什么设了种子结果还是变」。规矩:一个项目只用一套。
- 从
[51 92 14]变成[92 14 71]——整体往后挪了一位。Generator不会:实测中间被np.random.rand()抽了一次,g.integers照样给[8 77 65],因为状态在对象里。 [7270, 7270, 7270, 7270]——四个完全一样。⚠️ 和操作系统无关:这组是在 Windows(spawn,子进程从零启动)上跑出来的。成因只有一句:worker 里写死了常数种子。- 一半好一半差。「什么都不做」四个 worker 确实各不相同(实测两次跑给出完全不同的两组),但不可复现。最糟的是「设了种子还设成同一个」——既没多样性,又让你相信自己可复现。
- ① 主进程
ss = np.random.SeedSequence(42)② 主进程children = ss.spawn(n_workers)③ worker 里rng = np.random.default_rng(children[i])。关键在「种子在主进程分好再发下去」。worker 自己算种子(如用 pid)能拿到独立性但丢掉可复现性。 permutation(m, axis=1)整列一起搬,三行用同一套顺序(实测[[2,0,1,3],[6,4,5,7],[10,8,9,11]]);permuted(m, axis=1)每行独立打乱([[2,0,1,3],[7,6,5,4],[9,11,8,10]])。- 💀 因为两次
shuffle用的是随机序列的不同段,两边顺序不一样,标签和样本就对不上了,而且不报错。正确做法:idx = rng.permutation(len(X)),然后X[idx]、y[idx]。 - 不够。种子只能从头开始。要接上得存
g.bit_generator.state(一个 dict,键是['bit_generator', 'has_uint32', 'state', 'uinteger'],bit_generator是PCG64),可以直接 JSON 化塞进 checkpoint。 - ⚠️ 当噪声看。种子不是超参数。正确做法是跑多个种子报均值和标准差;如果改进只在某一个种子上成立,那它不是改进。设种子的目的是让差异可归因,不是让数字好看。
🛑 可以停在这里
⚡ 走神救援
「可复现」站内有三层,本章只做 NumPy 的随机数 API(训练清单归 ML 基础,系统级归模型上线之后)。
⚠️ 两代 API 各自可复现,互相之间不可复现:老的
np.random.seed和新的default_rng底层是两种发生器,同样的种子给出完全不同的数。💀 混用等于没播种。💀 遗留 API 的状态是全局的,谁都能动:中间被第三方抽走一个数,你后面拿到的整个序列就挪了一位。而「别人」可能是 sklearn 的某个估计器、某个增强库、或者你自己两周前加的调试行。⭐
Generator免疫,因为状态在对象里。⭐ 招牌坑:多个 worker 各自
np.random.seed(42),于是它们生成的随机数完全一样。⚠️⚠️ 这和操作系统无关——在 spawn(子进程从零启动)的平台上照样一样,真正的成因是 worker 里写死了常数种子,不是 fork 继承了状态。⚠️ 它难发现是因为不报错:几个 worker 做数据增强给出几份相同结果,loss 照常下降,只是多样性打了折。⭐ 对照之下「什么都不做」是随机但不可复现,最糟的组合恰恰是「设了种子、还设成同一个」。
⭐ 正确模板三步:主进程建一个
SeedSequence→spawn(n)分出 n 个子种子 → 每个 worker 用自己那个建default_rng。关键在种子是在主进程分好再发下去的——这样既独立又可复现。API 对照里最容易绊人的两个:
randint→integers(多了endpoint=),rand(3,4)→random((3,4))(⚠️ 形状要写成元组)。
下一节 👉 11-从NumPy到PyTorch.md