强化学习中的随机种子设置陷阱:不同环境初始化导致结果不可复现的工程应对,包括种子传播机制与随机数冲突处理
一、为啥要聊随机种子这么个小事
搞强化学习的人十有八九都遇到过这种窝火事:明明代码没改,参数没动,昨天跑出来的结果今天死活复现不了。你盯着终端里跳动的奖励曲线,心里默念“这代码成精了”。其实很大概率不是代码成精,而是随机种子设置这个看似不起眼的环节出了岔子。强化学习里到处是随机数:环境初始化、动作探索、神经网络权重初始化、经验回放采样……任何一个地方的随机种子没管好,结果就会像脱缰的野马。
你可能会说:“我明明在所有能想到的地方都设了 random.seed(42),怎么还是不行?”别急,这就是我们今天要扒的陷阱。生活里就像炒菜,盐和酱油都放了,但火候不对,菜的味道照样不一样。随机种子的传播机制和冲突处理,就是那个“火候”。
二、随机种子到底是个啥玩意
随机种子就像一本密码本的起始页码。电脑里的随机数其实是伪随机,给定一个种子,就能生成一串固定的“随机”序列。比如 Python 的 random 模块:
import random
# 设置种子
random.seed(123)
print(random.random()) # 0.052363598850944326
print(random.random()) # 0.08718667752263232
# 重新设置相同种子
random.seed(123)
print(random.random()) # 又是 0.052363598850944326
强化学里里用的随机数生成器可不止一个。你代码里用了 Python 的 random,用了 numpy 的 random,还用了 PyTorch 或 TensorFlow 的随机操作,甚至游戏环境(比如 Gym)自己也有内部随机源。每个生成器都需要单独设种子,而且它们之间的种子不能相互干扰。简单来说,你要像管三个孩子一样管好它们:给老大安排作业,给老二安排练琴,给老三安排画画,不能搞混。
三、种子传播机制:为啥设了一个种子却管不住所有随机数
3.1 不同库的随机种子要分开设
很多新手以为设了 random.seed(42) 就万事大吉了。但实际上,numpy 的随机数生成器是完全独立的。PyTorch 和 TensorFlow 又各有自己的随机数生成器。如果你用了 gym 环境,环境内部还有自己的随机数生成器(通常基于 numpy 或自己维护)。下面这个例子展示了如果不分开设,结果会乱成啥样:
import random
import numpy as np
# 只设了 Python 的随机种子
random.seed(42)
print(random.random()) # 0.6394267984578837
# numpy 的随机数没被影响
print(np.random.random()) # 每次不同,因为 numpy 没设种子
正确的做法是手动同步所有随机数生成器:
import random
import numpy as np
import torch
# 为每个库设置相同数值的种子
seed_val = 42
random.seed(seed_val)
np.random.seed(seed_val)
torch.manual_seed(seed_val) # PyTorch 的 CPU 随机种子
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed_val) # GPU 也需要设
3.2 环境初始化也吃种子
强化学习中的环境(比如 OpenAI Gym 的 CartPole)在每次 reset() 时可能会产生随机性——起始位置、速度等。Gym 环境其实内部使用了 numpy 的随机数生成器,但有一个大坑:环境有自己的 np_random 属性,它继承自一个全局的 numpy 随机状态,但如果你在创建环境之前没有设 numpy 种子,那么每次创建环境时它的内部随机状态都是未知的。更可怕的是,如果你在训练过程中调用了 env.seed(),这个操作只会影响环境内部的随机数生成器,而不会覆盖 numpy 的全局状态。下面代码展示了正确的做法:
import gym
import numpy as np
# 先设全局 numpy 种子
np.random.seed(42)
env = gym.make('CartPole-v1')
# 然后单独给环境设种子(会覆盖环境内部的 numpy 随机状态)
env.seed(42)
obs = env.reset() # 每次运行此代码,初始状态都一样
但注意:某些旧版 Gym 的 env.seed() 方法有 bug,它会修改全局 numpy 随机状态,从而影响后续其他随机操作。这就是所谓的“随机数冲突”。
四、随机数冲突处理:别让两个生成器抢同一个糖果罐
4.1 冲突的本质
随机数冲突指的是多个随机数生成器意外地共享了同一个内部状态,导致它们生成的序列相互关联或互相覆盖。比如你给 numpy 设了种子 42,然后调用 env.seed(42),环境可能会把 numpy 的全局随机状态重置为种子 42 对应的初始状态,这样一来,你之前在 numpy 上已经消耗的随机序列就丢失了,后面的 numpy 随机数又从头开始。这就像你用同一本密码本的两页分别给两个朋友写信,结果两封信的内容完全一样。
4.2 避免冲突的工程方法
一个好的做法是:为每个需要随机性的组件分配独立的种子,并且这些种子之间要有固定的偏移量。比如:
base_seed = 42
# 每个组件用 base_seed + 偏移量
random.seed(base_seed + 0)
np.random.seed(base_seed + 1)
torch.manual_seed(base_seed + 2)
env = gym.make('CartPole-v1')
env.seed(base_seed + 3)
这种方法确保了每个随机数生成器初始化时的“起点”完全不一样,避免了交叉影响。但还有一个细节:在训练循环中,你可能需要为每个 episode 重置环境,并且希望每个 episode 的随机性也不同,但总体可复现。这时可以每次重置前重新给环境设一个基于 episode 编号的种子:
import gym
import numpy as np
base_seed = 42
np.random.seed(base_seed)
env = gym.make('CartPole-v1')
for episode in range(3):
# 每个 episode 使用不同的种子,但基于 base_seed 衍生
episode_seed = base_seed * 100 + episode
env.seed(episode_seed)
obs = env.reset()
print(f"Episode {episode}, first obs: {obs}")
这样即便你反复运行整个脚本,每次 episode 的初始状态都一样,且不同 episode 之间互不重复。
五、实战示例:一个完整的可复现强化学习训练循环
下面我们用 Python 来写一个完整的 DQN 训练脚本片段,展示如何系统地管理随机种子,确保每次运行结果完全一致。我们使用 PyTorch、Gym 和 numpy。
import random
import numpy as np
import torch
import gym
# ------ 全局种子初始化 ------
def set_all_seeds(seed: int):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed) if torch.cuda.is_available() else None
torch.backends.cudnn.deterministic = True # 确保 cudnn 使用确定性算法
torch.backends.cudnn.benchmark = False # 关闭自动调优,保证可重复
SEED = 12345
set_all_seeds(SEED)
# ------ 环境创建与种子隔离 ------
env = gym.make('CartPole-v1')
# 给环境分配一个独立种子,不要与全局复用
env_seed = SEED + 1
env.seed(env_seed)
env.action_space.seed(env_seed + 2) # 动作空间有时也有随机性
# ------ 神经网络 & 优化器(随机权重初始化用 torch 种子已经覆盖) ------
class DQN(torch.nn.Module):
def __init__(self):
super().__init__()
self.fc = torch.nn.Sequential(
torch.nn.Linear(4, 128),
torch.nn.ReLU(),
torch.nn.Linear(128, 2)
)
def forward(self, x):
return self.fc(x)
policy_net = DQN()
# ------ 经验回放缓冲区(用 numpy 随机采样) ------
class ReplayBuffer:
def __init__(self, capacity=10000):
self.capacity = capacity
self.buffer = []
self.position = 0
def push(self, transition):
if len(self.buffer) < self.capacity:
self.buffer.append(None)
self.buffer[self.position] = transition
self.position = (self.position + 1) % self.capacity
def sample(self, batch_size):
# 注意:这里使用 numpy 随机数,种子已被全局设定
indices = np.random.choice(len(self.buffer), batch_size, replace=False)
batch = [self.buffer[i] for i in indices]
return batch
buffer = ReplayBuffer()
# ------ 训练循环(保证每个 episode 的环境种子) ------
episodes = 3
for ep in range(episodes):
# 每个 episode 重新设环境种子,但使用固定偏移量保证可复现
env.seed(env_seed + ep * 10) # 偏移量 = 10,避免重叠
state = env.reset()
done = False
total_reward = 0
while not done:
# 动作选择:探索时用 torch 随机数(已设种子)
if np.random.random() < 0.1: # 10% 随机探索
action = env.action_space.sample()
else:
with torch.no_grad():
q_values = policy_net(torch.FloatTensor(state).unsqueeze(0))
action = q_values.argmax().item()
next_state, reward, done, _ = env.step(action)
buffer.push((state, action, reward, next_state, done))
state = next_state
total_reward += reward
print(f"Episode {ep}: reward = {total_reward}")
# 如果 buffer 里有足够数据,可以采样训练(训练部分省略,但需要保证采样随机种子)
if len(buffer.buffer) >= 32:
batch = buffer.sample(32)
# 训练代码...
env.close()
注释说明:我们为环境种子使用了 env_seed + ep * 10,这样每个 episode 的初始状态不同,但整体脚本多次运行后每个 episode 的初始状态仍然相同。同时,全局的 numpy 种子、torch 种子都固定了,所以网络权重初始化、经验回放采样、动作探索中的随机数都同步。
六、应用场景与优缺点
应用场景
学术研究:你必须复现别人的论文结果,或者自己多次实验取平均值,种子管理是基本功。
调试与调参:当你发现某个超参数带来的效果变化不明时,固定种子能让你排除随机干扰,只看参数本身的影响。
产品化测试:在强化学习模型上线之前,你需要用同一组随机种子在测试环境中跑出稳定的指标,确保模型表现不是“运气好”。
优点
结果完全可复现,节省排错时间。
方便多人协作,大家能对同一份代码得到相同输出。
减少随机噪声对实验结论的误导。
缺点
过度依赖固定种子可能掩盖过拟合问题——如果测试集也是固定随机,模型可能侥幸记住了特定序列。
需要维护多个随机数生成器的种子列表,代码变得啰嗦。
某些环境或库内部可能有多层随机状态(比如 gym 包装器),你很难完全控制。
七、注意事项
版本差异:不同版本的 Gym、PyTorch 等库的随机数生成算法可能变化。即使你设了相同种子,升级库后结果也可能不一样。建议在实验脚本中记录依赖库的版本。
多线程 / 多进程:如果使用了 multiprocessing 或 torch.utils.data.DataLoader 的多 worker,每个 worker 会拷贝父进程的种子,导致它们生成相同的随机序列。你需要为每个 worker 单独分配不同种子(比如使用 worker_init_fn)。
CUDA 不确定性:GPU 上的某些操作(如原子加法、浮点运算顺序)本身具有不确定性,即使设了 torch.backends.cudnn.deterministic = True,也无法完全保证所有操作可复现。这时候可以尝试在 CPU 上运行调试。
不要重复设全局种子:在训练循环中频繁调用 np.random.seed() 会重置全局状态,可能破坏之前已经安排的随机序列。最好只在开头设一次,后续使用局部种子或偏移量。
环境内部种子传播:某些 Gym 环境(比如 Atari)内部使用 numpy.random.RandomState 对象,你需要通过 env.unwrapped.np_random.seed() 来直接访问。如果不放心,可以用 env.reset(seed=...) 直接指定。
八、总结
随机种子设置看起来是芝麻大点的事,但在强化学习这个充满了随机性的领域里,它就是那个决定结果能不能复现的关键螺丝。你得像管理家庭账单一样,给每个随机数生成器独立编号,并预留未来可能增加的生成器。最稳妥的做法是:用一个专门的函数把所有随机源一次性初始化,环境种子单独用偏移量,每次重置都用基于 episode 的衍生种子。这样一来,无论你跑多少次,只要代码和库版本不变,结果就纹丝不动。
当然,完全复现不等于科学正确。随机性在某些场景下反而是必要的——比如为了评估策略的泛化能力。但至少,当你想要揪出一个 bug,或者向同事证明你的改进有效时,能复现的实验结果就是你最硬的底气。把种子管理变成编码习惯,就像吃饭前洗手一样自然,你就离强化学习老司机又近了一步。