一、事故现场:同事的模拟结果对不上
今天早上同事微信找我,语气很崩溃:“我跑了两遍模拟,结果怎么不一样?”他明明在脚本开头写了 set.seed(123),按理说应该完全一样。我过去看他的脚本,发现他执行完一大段随机抽样之后,才慢悠悠地写下 set.seed(123)。这就好比你想用钥匙锁门,结果锁已经跳开了,你再把钥匙插进去有什么用呢?
随机种子这个函数,它的工作位置非常讲究:必须放在所有随机操作之前,而且要放在你想固定的那一步之前。放得晚了,前面已经产生了随机数,后面的种子自然管不到前面的事。很多人以为“写了就没事”,其实真正决定成败的是那行代码站在哪里。今天这篇文章,就想把这个不起眼但特别坑的话题聊透。
二、随机种子到底是个啥
先说一个基础概念:计算机里的随机数,大多不是真正的随机,而是用数学公式“推演”出来的。你给一个起点,它照着一套规则往下算,算出来一串看起来很乱、其实顺序固定的数字。这个起点就是“种子”。你用 set.seed(42) 之后,R 就知道:“好,以后每次我要随机数时,都从 42 这个起点开始按固定规则给我。”
可以把它想象成一条特别长的数字河,河水一直在流。set.seed 是数字河上的一个码头,你从哪个码头上船,就决定了你接下来会看到哪段河景。如果你在河上漂了很久才选码头,那么前面的河景是没法固定下来的。
2.1 同一个种子,两次完全相同
看一个最简单的例子:
# 技术栈:R
# 设置种子后生成 3 个均匀分布随机数
set.seed(2024)
runif(3) # 第一次跑:0.31661245 0.30269337 0.15904600
只要你在 runif 前面写了 set.seed(2024),这个脚本每次运行,结果都是同一串数字。那些数字看着“随机”,其实是被种子和算法锁死的剧本。如果你把种子写在 runif 后面,剧本就从半截开始,前面部分没固定,后面部分自然也没法从头复现。
2.2 种子不是总开关
很多人以为写了 set.seed,下面所有随机都固定。不是这样的。set.seed 只决定下一次随机数从哪里开始。你再看看这个例子:
# 技术栈:R
set.seed(1)
a <- rnorm(2) # 生成两个标准正态随机数
set.seed(1) # 重新设置同样的种子
b <- rnorm(2) # 又得到完全相同的一对
print(all(a == b)) # TRUE,因为种子让序列重新开始了
这个例子说明:种子是可以“重置”的,不是一次性永久生效。正因为这个特性,如果你在循环里不小心重复设置了同一个种子,就会让每次循环拿到一模一样的随机数,这往往是很多诡异 bug 的源头。
三、四个最容易踩的坑
3.1 把种子放在随机操作之后
这是最常见的错误。很多人的习惯是:先写功能代码,跑通了发现结果每次不一样,才想起来“哦,我忘了设置随机种子”,然后随手在文件末尾补了一行 set.seed。已经太晚了。
# 技术栈:R
# 错误写法:sample 先执行,set.seed 后执行
x <- 1:10
d <- sample(x, replace = TRUE) # 随机抽取,已经发生了
set.seed(888) # 设置种子的位置太靠后,救不回来了
d # 每次运行都不一样
# 正确写法:set.seed 放在随机操作之前
set.seed(888)
x <- 1:10
d <- sample(x, replace = TRUE) # 每次运行,d 都固定
你看,代码就差了这么几行,性质完全不一样。种子必须提前站在那里,才能掌控后面的随机过程。
3.2 循环里重复设置同一个种子
这个坑最隐蔽,也最容易让人挠头。有人想对随机结果做 100 次模拟,于是把 set.seed(123) 写进了 for 循环里面。结果每次循环都回到同一个起点,100 次模拟实际上用的是同一个随机样本,最后算出的均值、方差、置信区间全是错的。
# 技术栈:R
# 错误示范:循环内部每次 set.seed(123)
set.seed(123)
means_wrong <- numeric(4)
for (i in 1:4) {
set.seed(123) # 每次循环都强制回到同一个起点
means_wrong[i] <- mean(runif(10))
}
means_wrong
# 四个值一模一样:0.4475 0.4475 0.4475 0.4475
# 正确示范:循环外只设置一次种子
set.seed(123)
means_right <- numeric(4)
for (i in 1:4) {
means_right[i] <- mean(runif(10))
}
means_right
# 四个值各有不同,而且每次运行固定
循环外种一次,随机数会像接力一样一直传下去:第一次循环用掉几个随机数,第二次循环接着用后面的,绝对不会重复。这才是模拟的正确姿势。
3.3 并行计算中种子放错位置
并行计算是另一个“重灾区”。比如你用 parallel::mclapply 跑多核任务,只在主进程外面写了一个 set.seed,但是真正干活的函数内部没有做任何处理。因为多核任务会从主进程复制状态,可能出现每个核都拿到相同的随机序列,或者结果和核数有关,让整个可复现性彻底泡汤。
# 技术栈:R
library(parallel)
# 错误:种子在并行调用外面,worker 内部没有种
set.seed(1)
bad <- mclapply(1:3, function(i) rnorm(1), mc.cores = 2)
# 正确:种子在 worker 函数内部,且每个任务用不同种子
good <- mclapply(1:3, function(i) {
set.seed(100 + i) # 每个 worker 用独立种子,位置在随机数之前
rnorm(1)
}, mc.cores = 2)
并行环境里,你不仅要保证“有种子”,还要保证“不同 worker 的种子互不干扰”。否则就算结果能复现,也可能复现的是一个错误的“相同序列”,那可比没有种子更危险。
3.4 数据预处理时种子写晚了
做机器学习的时候,我们经常需要打乱数据,再划分训练集和测试集。很多人把数据清洗、合并、去重这些步骤写了一大堆,最后才想起来加种子。可问题是,打乱数据这个操作本身依赖随机数,种子必须出现在打乱之前,而不是在打乱之后。
# 技术栈:R
# 错误:清洗中间有随机操作,set.seed 却放在最后
df <- mtcars
df$row <- 1:nrow(df)
df <- df[sample(1:nrow(df)), ] # 打乱顺序,这是随机操作!
set.seed(2024) # 太晚了,打乱结果每次不同
# 正确:把 set.seed 放在整个随机操作链条的最前面
set.seed(2024)
df <- mtcars
df$row <- 1:nrow(df)
df <- df[sample(1:nrow(df)), ] # 现在打乱顺序固定了
记住,随机种子不是“事后补救药”,而是“事前定位仪”。所有随机操作,都必须排在线程之后。
四、怎么放种子才安全
4.1 核心铁律:先种后随机
一句话就能记住:使用随机数之前,先设置种子。不需要记什么复杂规则,你只要在每一个随机过程之前,确认 set.seed 已经执行过就行。
4.2 循环外种一次,让随机数流顺流而下
需要做多组随机数的时候,比如蒙特卡洛模拟,你只需要在整个循环之前设置一次种子。后面不管你循环多少次,每次拿到的都是随机数流中的不同片段,整体固定,不会重复。
4.3 并行时给每个 worker 独立种子
并行场景中,可以在 worker 函数内部根据任务索引设置不同种子,比如 set.seed(i)。更高级的做法是使用 R 自带的并行随机数接口,例如 clusterSetRNGStream,它能够生成独立且可复现的随机数流。核心思想就是:每个并行任务都拥有自己的起点,彼此不撞车。
4.4 关联技术:RNGkind 与 RNGversion
set.seed 不是唯一决定随机结果的因素,随机数生成算法也会影响。R 默认使用 Mersenne-Twister 算法,但你也可以切换成 L'Ecuyer-CMRG 等。算法不同,相同种子生成的数字也不同。所以严格的可复现实验,需要连算法一起固定。
# 技术栈:R
# 固定随机数生成算法
set.seed(42, kind = "Mersenne-Twister")
runif(5)
如果你担心 R 版本更新导致随机数算法默认值变化,可以用 RNGversion("3.6.0") 这类函数把随机数引擎锁到某个版本。这些设置同样必须放在随机数调用之前,一句话:你越精细,结果越稳。
4.5 一个完整的正反案例
我平时很喜欢用这个例子来说明“循环内设种子”有多坑。问题是这样的:把 1 到 10 这十个数字随机打乱,重复 1000 次,平均每个位置有多少个数字刚好留在原位?理论上答案是 1 左右。
# 技术栈:R
# 错误写法:循环内每次重新设置同一个种子
set.seed(1)
res_wrong <- numeric(1000)
for (i in 1:1000) {
set.seed(1) # 位置错误:每次都从同一个起点开始
perm <- sample(1:10, 10) # 每次都得到同一个排列
res_wrong[i] <- sum(perm == 1:10)
}
mean(res_wrong) # 得到一个固定常数,完全体现不出随机性
# 正确写法:循环外设置一次种子
set.seed(1)
res_right <- numeric(1000)
for (i in 1:1000) {
perm <- sample(1:10, 10) # 位置正确:只有第一次从种子 1 开始
res_right[i] <- sum(perm == 1:10)
}
mean(res_right) # 稳定在 1 附近,符合数学期望
错误写法下,1000 次模拟等于只做了一次,所有结果完全相同。你看,位置不对,不仅坑了自己,还可能得出错误科学结论。
4.6 用“哨兵随机数”检查种子位置
有时候你不太确定自己有没有放对位置,可以在关键随机操作前后各生成一个随机数,当作“哨兵”。
# 技术栈:R
set.seed(999)
marker1 <- runif(1) # 哨兵 1:记录进入关键步骤前的随机状态
df <- df[sample(1:nrow(df)), ] # 这里做了随机打乱
marker2 <- runif(1) # 哨兵 2:记录打完乱之后的随机状态
print(c(marker1, marker2))
如果每次运行,marker1 和 marker2 都一样,说明随机种子已经把整段随机过程固定住了;如果它们飘忽不定,那就要往前找找,肯定有某个随机操作跑到了种子的前面。
五、应用场景、优点与缺点
5.1 典型应用场景
随机种子广泛用于科学研究里的统计模拟、机器学习里的训练测试集划分、A/B 测试里的用户分组、游戏开发里的随机地图生成等。几乎所有依赖随机数的分析,都需要一条清晰的可复现路径。
5.2 优点
设置好随机种子,最直接的好处是别人拿到你的代码,能跑出和你一模一样的结果。这样便于排查问题、对比模型效果,也能在论文里把“随机”这个词变成“可复现的随机”。
5.3 缺点
随机种子也有让人误解的地方。它只展示了一条随机路径,并不代表所有可能的随机情况。固定种子之后,你观察到的随机波动只是无数可能中的一种,可能会给你虚假的“稳定感”。另外,在并行环境中管理种子相当费神,位置稍有偏差,结果就会变成一串重复的假随机。
六、注意事项
- 不要依赖系统时间作为唯一随机种子,也不要随便写个固定的数字就以为万事大吉,必须确认它的位置确实管住了目标随机过程。
- 如果你写了多个
set.seed,注意别把同一个种子放在循环体里,除非你刻意想让每次迭代一模一样。 - 换 R 版本、换操作系统、换随机数算法,都可能改变结果。做严谨分析时,最好在报告里写清楚版本、种子以及随机数算法。
- 并行任务中,每个 worker 都需要独立种子,并且要保证不同 worker 的随机数序列不会重叠。
- 代码评审时看到
set.seed,第一反应应该是检查它相对于随机操作的位置,而不是只看“有没有写”。
七、总结
set.seed 看起来就是一个简单的函数,可位置放错,整个模拟就会变成“薛定谔的实验”——每次打开脚本,结果都不一样。理解它的本质,就是把这串随机数映射到一个固定起点,然后让这个起点在恰当的位置站好。循环、并行、数据预处理、机器学习划分,这些场景都要格外小心。记住一句话:先种,后随机。每一条独立的随机路径,都要有自己清晰且独立的起点。这样,你的分析才能经得起别人重跑,也经得起自己下个星期回头看。
评论
围绕“随机种子设置陷阱:依赖随机过程的分析中set.seed位置不当导致结果不可复现”参与讨论