🏠 总目录📚 本教程 10 · 随机数种子与可复现 ← →
📑 本页目录(点开跳转)

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))

关键信息

legacy seed(42) 两次 -> [51 92 14 71 60] / [51 92 14 71 60] 一致: True
default_rng(42) 两次 -> [ 8 77 65 43 43] / [ 8 77 65 43 43] 一致: True
同一个种子 42,两代 API 给的数一样吗: False

⚠️⚠️ 两代 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))

关键信息

seed(42) 直接抽 -> [51 92 14]
seed(42) 中间被抽了一次 -> [92 14 71]
Generator 中间被抽了一次 -> [ 8 77 65] 和不被打扰时一致: True

⭐ 看清楚这个错位:本来该拿到 [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 各自 seed(42) -> [7270, 7270, 7270, 7270]
什么都不做(第一次) -> [999, 7120, 5449, 4607]
什么都不做(第二次) -> [7388, 6308, 6543, 8111]
SeedSequence(42).spawn(4) -> [4974, 746, 7031, 8994]
再跑一次 -> [4974, 746, 7031, 8994]

三种做法,三种结局:

做法 独立吗 可复现吗 结果
每个 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)

关键信息

原始
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
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]]
permutation 返回新数组,原数组不动: [0 1 2 3 4 5] -> [3 2 5 4 0 1]
shuffle 原地改: [3 2 5 4 0 1]
函数 二维上干什么 改不改原数组
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) 的话,那些倍数就没法在你的机器上复现了

✅ 检查点

  1. np.random.seed(42) 和 np.random.default_rng(42) 抽出来的数一样吗?各是什么?
  2. 一份代码前半段 np.random.seed(42)、后半段 default_rng(42),会发生什么?
  3. seed(42) 之后被第三方函数抽走一个数,你拿到的三个数会变成什么?Generator 会不会有这个问题?
  4. 四个 worker 各自 np.random.seed(42) 会得到什么?这和操作系统(fork 还是 spawn)有关吗?
  5. 上一题那个坑「什么都不做」反而更好吗?好在哪、差在哪?
  6. 正确的多进程种子模板是哪三步?关键在哪一步?
  7. rng.permutation(m, axis=1) 和 rng.permuted(m, axis=1) 在二维上有什么区别?
  8. 打乱样本 X 和标签 y,为什么不能各 shuffle 一次?
  9. 训练到第 30 个 epoch 挂了,想续跑并接上原来的随机序列,光存种子够吗?
  10. 换个种子模型指标涨了 0.3%,该怎么解读?
👀 答案
  1. 不一样。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)。
  2. 💀 播的那个种子对后半段一点作用都没有。两半从来没连上过,你会一直在查「为什么设了种子结果还是变」。规矩:一个项目只用一套。
  3. 从 [51 92 14] 变成 [92 14 71] ——整体往后挪了一位。Generator 不会:实测中间被 np.random.rand() 抽了一次,g.integers 照样给 [8 77 65],因为状态在对象里。
  4. [7270, 7270, 7270, 7270] ——四个完全一样。⚠️ 和操作系统无关:这组是在 Windows(spawn,子进程从零启动)上跑出来的。成因只有一句:worker 里写死了常数种子。
  5. 一半好一半差。「什么都不做」四个 worker 确实各不相同(实测两次跑给出完全不同的两组),但不可复现。最糟的是「设了种子还设成同一个」——既没多样性,又让你相信自己可复现。
  6. ① 主进程 ss = np.random.SeedSequence(42) ② 主进程 children = ss.spawn(n_workers) ③ worker 里 rng = np.random.default_rng(children[i])。关键在「种子在主进程分好再发下去」。worker 自己算种子(如用 pid)能拿到独立性但丢掉可复现性。
  7. 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]])。
  8. 💀 因为两次 shuffle 用的是随机序列的不同段,两边顺序不一样,标签和样本就对不上了,而且不报错。正确做法:idx = rng.permutation(len(X)),然后 X[idx]、y[idx]。
  9. 不够。种子只能从头开始。要接上得存 g.bit_generator.state(一个 dict,键是 ['bit_generator', 'has_uint32', 'state', 'uinteger'],bit_generator 是 PCG64),可以直接 JSON 化塞进 checkpoint。
  10. ⚠️ 当噪声看。种子不是超参数。正确做法是跑多个种子报均值和标准差;如果改进只在某一个种子上成立,那它不是改进。设种子的目的是让差异可归因,不是让数字好看。

🛑 可以停在这里

⚡ 走神救援

「可复现」站内有三层,本章只做 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

打卡记录保存在你的浏览器里,首页能看到总进度