今天聊聊我在复现强化学习经典算法时的一些实操心得,这里选的项目是用PyTorch复现Double DQN,让AI自己学会玩打砖块(Breakout)。关于PyTorch、Double DQN、强化学习、AI这几个词,但凡在深度学习圈子里混过的应该都不陌生——它们是入门强化学习绕不开的组合。我选择打砖块作为实验环境的一个很直接的原因是:这个游戏的状态空间和动作空间都足够简单,但又不至于简单到一眼就能看穿,视觉特征、时间序列、稀疏奖励它都占全了。换句话说,你在这个环境上调通的训练流程,换到更复杂的游戏上依然成立。
这篇内容不会只停留在原理层面,重点放在三件事:为什么Double DQN能解决普通DQN的高估问题、训练过程中如何设计网络和回放机制、以及我在调参时踩过的一些坑和对应的排查思路。代码和参数都是可直接复制运行的版本,适合已经有PyTorch基础但不熟悉强化学习的读者,也适合那些在训练中奖励不涨、反复发散然后怀疑人生的朋友。
1. 项目背景与整体设计思路
1.1 为什么拿打砖块当强化学习的第一站
打砖块这游戏,规则一句话就能说完:挡板接球,球反弹上去撞砖块,砖块碎了得分,没接住球就丢一条命,命没了游戏结束。看起来比围棋简单了不知道多少个量级,但对AI来说它其实一点都不“简单”。你要让智能体学会的不是“看到砖块就往右走”,而是从连续的像素帧里推断出球的速度和方向,提前落到球的落点位置等着接球。这涉及时间维度的信息整合,也涉及长短期目标的权衡,是很典型的POMDP(部分可观测马尔可夫决策过程)问题。
选Breakout还有一个现实原因:训练成本相对可控。和Atari里那些复杂游戏相比,Breakout的状态转移模式比较规则,动作也只有4个(无操作、发射、右移、左移),用一块普通的消费级显卡(比如我用的3060Ti)就能在可接受的时间内跑出不错的效果。如果一上来就拿Dota 2或者星际争霸练手,那不光代码复杂,连环境配置都要折腾掉半条命。对于想验证算法想法、熟悉训练调试流程的人来说,Breakout就是个“既能练手又能练脑”的理想场地。
另外,从学习曲线来看,强化学习的核心知识点——状态表示、动作选择策略、奖励信号设计、经验回放、目标网络、探索与利用的平衡——在这个项目里全都用得上,而且每一项都能直观地看到它对训练结果的影响。比如你调低学习率,训练曲线可能变慢但更稳;你把目标网络更新频率调得太勤,Q值就可能开始震荡。这种直观的因果反馈,在复杂环境里反而不容易观察到。
1.2 Double DQN相比DQN解决了什么问题
聊Double DQN之前得先说说普通DQN的毛病。DQN的基本思路是用神经网络近似Q函数,通过贝尔曼方程不断逼近真实的价值函数,更新目标由当前奖励 + 折扣的未来最大Q值构成。问题在于,这个“最大值”的引入天生带有正向偏差——在估计存在噪声的情况下,取最大值会倾向于选中那个被高估的动作。也就是说,系统会越来越乐观地认为某个动作比它实际表现更好,最终导致Q值膨胀,策略随之偏离最优解。
Double DQN的修正方式非常直接:把“选择动作”和“评估动作价值”这两个环节解耦。在选择下一步动作的时候用当前主网络(online net)来挑选最优动作,但计算目标值时用目标网络(target net)去评估这个动作的Q值。这样即使某个动作被主网络高估了,目标网络给出的评估也是独立的,不会因为主网络的噪声而同步膨胀。你可以理解为:一个人负责做决定,另一个人负责给决定打分,两者分开,作弊的空间就小了。
这么做不会增加太多计算负担,代码改动也就几行,但效果上通常比原生DQN稳定不少。在我自己的实验里,同样的超参数配置下,普通DQN在Breakout上的Q值会慢慢涨到一个离谱的数字(比如几千),而策略却还是很蠢;Double DQN的Q值虽然也偏高,但涨幅温和得多,且与真实回报的相关性明显更强。
1.3 技术选型:为什么用PyTorch加gymnasium
现在的强化学习框架其实不少,像TensorFlow系、JAX系还有各种高度封装的库(比如Stable-Baselines3),但我个人还是推荐用PyTorch自己手写核心逻辑。原因很简单:手写一遍你会真正理解每个模块为什么存在。Stable-Baselines3一个接口就把训练流程全跑通了,新手很容易陷入“只会改参数”的瓶颈,一旦遇到bug完全不知道从哪下手。而用PyTorch从零搭起来,你对网络结构、梯度流向、损失函数、更新时机都会有体感,排查问题时也能做到“哪里有毛病一目了然”。
环境方面我用的是gymnasium,这是原来的OpenAI Gym维护的继任版本,接口干净,Atari环境的支持也很完善。现在很多教程还在用老版gym,接口差异会导致一些代码跑不通,这点后面讲环境配置的时候我会专门提醒。
为什么要选PyTorch而不是别的框架?对我来说最大的理由是它的动态图机制。强化学习里网络结构经常需要微调,比如在输入里增加辅助信息、给loss加正则项,动态图环境下写起来就像写普通Python代码一样自然,不需要关心静态图编译的约束。调试体验也是我比较看重的一点,PyTorch的报错信息通常能直接定位到哪一行张量计算出了问题,这对反复试错调参的阶段来说太重要了。
2. 核心原理拆解:DQN与Double DQN
2.1 DQN是怎么让神经网络学会玩游戏的
DQN把Q-Learning和深度神经网络结合,核心创新其实就三个:经验回放、目标网络、用卷积神经网络直接处理原始像素。这三个东西缺了任何一个,整个训练过程都会崩。先说经验回放,它的做法是把智能体与环境互动产生的数据(状态、动作、奖励、下一状态)存到一个内存池里,训练时随机采样一小批数据来更新网络,而不是按时间顺序一条条地用。
为什么要这么做?因为时序相邻的数据是高度相关的。比如打砖块时,好几帧内的画面变化很连续,如果你按顺序学,网络会反复看到相似的数据,导致训练不稳定;随机采样则可以打破这种相关性,让训练更像是在独立同分布的数据上学习,收敛过程稳得多。
目标网络解决的是“追着移动靶心打”的问题。如果你每步都直接更新网络并用它来生成训练目标,那目标值每次都在变,网络会追着一个不断移动的目标跑,很难收敛。所以做法是额外维护一份目标网络的副本,参数定期(比如每10000步)才从主网络同步一次。这样在一段时间内,训练目标相对固定,学习过程就稳定了很多。
至于卷积网络处理像素,这个思路算是对“输入是图像”这一类环境最常见的解法。CNN天然具备局部感知和参数共享的特性,能提取出画面中的关键空间特征,比如球的位置、挡板的位置、砖块的分布。把连续4帧画面拼在一起输入网络,还能让AI感知到运动趋势,这就像你看视频要连续好几帧才能判断球的飞行方向一样。
2.2 Double DQN的两步走:先选动作再算Q值
刚才提到普通DQN的目标值计算是r + γ * max_a Q(s', a; θ⁻),这里max本质上被拆成了两步:先用目标网络计算所有动作的Q值,再取最大值。问题在于,如果目标网络对某个动作的Q值估计偏高,那这个偏高就会被max直接选中并放大。如果换成Double DQN,目标值的计算变成了r + γ * Q(s', argmax_a Q(s', a; θ); θ⁻),你先用主网络θ在所有动作里挑出一个最优动作,再去目标网络θ⁻里查这个动作对应的Q值。
为什么要用主网络来选动作、用目标网络来算值?因为主网络实时更新,对当前策略的反映更准确;目标网络相对滞后,给出的评估更稳定。两者参数错开,就减少了“高估自激”的可能。你可以把这个理解成:一个经验丰富的老手负责判断哪个方案更好,另一个沉稳的审计师负责给这个方案做预算评估,两个角色不重叠,就不会出现“自己给自己批预算”的虚高问题。
一开始看到这个改动我也有点怀疑,就两行代码的事,真能有那么大的区别吗?从实验结果来看,答案是肯定的。在Breakout上,同样的网络结构和超参,普通DQN在训练后期Q值会冲到上千,但平均回报只有一两百;而Double DQN的Q值曲线和真实回报曲线的走势基本一致,没有出现Q值远远甩开实际表现的情况。这也是为什么后来很多算法(比如Dueling DQN、Rainbow)都沿用了Double的机制——它是一个几乎没有副作用的小改动,换成任何项目都不亏。
2.3 打砖块观测状态的处理:从原始像素到张量
环境返回的原始图像尺寸是210x160x3的RGB像素值,如果你直接扔给神经网络,计算量不小,而且很多信息对决策来说是冗余的(比如上下方的黑色区域,以及背景的装饰元素)。所以标准的预处理流程是这样的:先转成灰度图,丢掉颜色信息;然后裁剪掉画面顶部和底部的无用区域,只保留游戏主体部分;最后缩放到84x84的小尺寸,再把像素值归一化到0到1之间。
在打砖块这个环境里,有个关键细节需要注意:单帧画面无法体现球的速度和方向。球在静止画面中的位置无法推断它下一帧会飞向哪里,所以必须把连续的4帧画面叠加成一个状态输入。我的实现是用一个deque容器保存最近4帧的灰度图,每次环境返回新帧就把它压进去、弹出最老的那一帧,最后用torch.stack沿通道维度拼成(4, 84, 84)的张量作为网络输入。
另外,原始环境的观测值还要加上一步很重要的一致性处理:如果球恰好卡在一个来回弹跳的状态,画面可能在两帧之间完全一样,这时候如果直接用前后帧差作为运动特征,可能会判断出错。所以更稳妥的做法是尽量让网络自己从多帧堆叠里学运动特征,不要在预处理器里做太激进的差分,否则容易丢失原始信息。
3. 实操过程与完整代码
3.1 环境安装与预处理细节
如果要跟着我的环境复现,你需要的依赖主要是这几个:PyTorch、gymnasium、ale-py。在Windows或Linux上创建conda环境后执行:
conda create -n dqn python=3.10 -y conda activate dqn pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install gymnasium[atari] gymnasium[accept-rom-license] ale-py很多人在Atari环境上卡住,是因为ale-py只是加载器,还需要一套ROM文件。安装了gymnasium[accept-rom-license]之后会自动下载合法的Atari ROM包,这个包兼容ALE的版权许可,不会出现ROM缺失的问题。如果你用的是很老的gym版本,很多教程里还可能让你手动去下载某个rom文件路径,现在有了官方自动下载方式,省事不少。
环境创建我推荐用这样的配置:
import gymnasium as gym from gymnasium.wrappers import AtariPreprocessing, FrameStack env = gym.make( "ALE/Breakout-v5", frameskip=1, repeat_action_probability=0.0, full_action_space=False, ) env = AtariPreprocessing(env, noop_max=30, frame_skip=4, terminal_on_life_loss=True, grayscale_obs=True, grayscale_new_shape=(84, 84)) env = FrameStack(env, num_stack=4)关于frameskip参数说明一下:在gymnasium里,如果设置了frame_skip=4且frameskip=1,意味着环境内部每次执行一个动作会持续4帧,这样能显著提升训练速度。设置repeat_action_probability=0.0是为了关闭环境的随机性(Atari环境默认有5%的概率随机重复动作),这对复现和调试更友好。terminal_on_life_loss=True是把“丢一条命”也当作一次episode终止信号,这个设计对Breakout这种多生命游戏尤其重要——它能让AI更快感知到“失误”的后果,而不是把注意力分散到整局游戏的最终结束上。
处理完的环境输出是84x84的灰度图,FrameStack之后得到的观测shape为(4, 84, 84),直接就能送进PyTorch的卷积网络。
3.2 神经网络结构和初始化
网络结构我沿用了DQN论文里的经典CNN设计,针对84x84的输入做三组卷积再加两个全连接层。具体代码如下:
import torch import torch.nn as nn class DQN(nn.Module): def __init__(self, input_shape, n_actions): super().__init__() self.conv = nn.Sequential( nn.Conv2d(input_shape[0], 32, kernel_size=8, stride=4), nn.ReLU(), nn.Conv2d(32, 64, kernel_size=4, stride=2), nn.ReLU(), nn.Conv2d(64, 64, kernel_size=3, stride=1), nn.ReLU(), ) self.flatten = nn.Flatten() conv_out_size = self._get_conv_out(input_shape) self.fc = nn.Sequential( nn.Linear(conv_out_size, 512), nn.ReLU(), nn.Linear(512, n_actions), ) self._initialize_weights() def _get_conv_out(self, shape): o = self.conv(torch.zeros(1, *shape)) return int(torch.numel(o)) def _initialize_weights(self): for module in self.modules(): if isinstance(module, nn.Conv2d): nn.init.kaiming_normal_(module.weight, nonlinearity="relu") module.bias.data.zero_() elif isinstance(module, nn.Linear): nn.init.kaiming_normal_(module.weight, nonlinearity="relu") module.bias.data.zero_() def forward(self, x): return self.fc(self.flatten(self.conv(x)))_get_conv_out这个方法是我特别写来动态计算卷积层之后张量尺寸的,这样就不用手动算84x84经过三层卷积会变成多少维。初始化的部分我用了kaiming初始化,这对ReLU激活函数来说是一个比较合适的默认选择。如果初始化效果不好,训练初期的梯度很容易消失或者爆炸,表现为第一个1000步里loss就出现NaN。
理论上这个网络可以做得更复杂,比如加残差连接、加BatchNorm,但对Breakout这种输入很简单的问题,过大的模型反而会导致过拟合和训练时间增长。我在实验中用的这个配置已经能跑到相当不错的成绩了,核心目的不是堆算力,而是把训练流程跑通。
3.3 经验回放池与训练主循环
经验回放池是整个训练流程里容易被忽略但极其重要的部分。我的实现里用一个双端队列存储最近的经验,满了就弹出最旧的。新增经验时把dtype压成float32或uint8尽量节省内存,采样时用random.sample随机抽取一个batch。
import random from collections import deque import numpy as np class ReplayBuffer: def __init__(self, capacity): self.buffer = deque(maxlen=capacity) def push(self, state, action, reward, next_state, done): self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) states, actions, rewards, next_states, dones = zip(*batch) return ( np.array(states, dtype=np.float32), np.array(actions, dtype=np.int64), np.array(rewards, dtype=np.float32), np.array(next_states, dtype=np.float32), np.array(dones, dtype=np.uint8), ) def __len__(self): return len(self.buffer)经验池的容量要在占用内存和样本多样性之间取平衡。我这边设的是50万条,每条经验包含4帧84x84的灰度图,内存占用大概在几个GB级别,一般PC可以跑。如果内存吃紧,可以降到20万条,但样本多样性会下降,尤其到了训练后期,可能采样到大量重叠状态,影响更新效果。
训练主循环的代码我精简过,保留的每个环节都是跑通实验的必要部分。注意看epsilon衰减和目标网络同步的位置:
from tqdm import tqdm from torch import optim import torch.nn.functional as F def train(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") net = DQN((4, 84, 84), env.action_space.n).to(device) target_net = DQN((4, 84, 84), env.action_space.n).to(device) target_net.load_state_dict(net.state_dict()) target_net.eval() buffer = ReplayBuffer(capacity=500_000) optimizer = optim.Adam(net.parameters(), lr=1e-4) epsilon = 1.0 epsilon_min = 0.01 epsilon_decay = 0.99995 gamma = 0.99 batch_size = 32 target_update_freq = 10_000 step_count = 0 for episode in range(2000): state, _ = env.reset() state = np.array(state, dtype=np.float32) / 255.0 episode_reward = 0 done = False while not done: if np.random.random() < epsilon: action = env.action_space.sample() else: with torch.no_grad(): state_t = torch.as_tensor(state, dtype=torch.float32, device=device).unsqueeze(0) q_vals = net(state_t) action = q_vals.argmax(dim=1).item() next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated next_state = np.array(next_state, dtype=np.float32) / 255.0 # 奖励压缩 reward = np.clip(reward, -1.0, 1.0) buffer.push(state, action, reward, next_state, done) state = next_state episode_reward += reward step_count += 1 if len(buffer) > 10_000: states, actions, rewards, next_states, dones = buffer.sample(batch_size) states = torch.as_tensor(states, device=device) actions = torch.as_tensor(actions, device=device).unsqueeze(1) rewards = torch.as_tensor(rewards, device=device).unsqueeze(1) next_states = torch.as_tensor(next_states, device=device) dones = torch.as_tensor(dones, device=device).unsqueeze(1) q_values = net(states).gather(1, actions) # Double DQN 核心:用主网络选动作,目标网络评估 with torch.no_grad(): next_actions = net(next_states).argmax(dim=1, keepdim=True) next_q_values = target_net(next_states).gather(1, next_actions) expected_q_values = rewards + gamma * next_q_values * (1 - dones) loss = F.smooth_l1_loss(q_values, expected_q_values) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(net.parameters(), max_norm=10) optimizer.step() if step_count % target_update_freq == 0: target_net.load_state_dict(net.state_dict()) epsilon = max(epsilon_min, epsilon * epsilon_decay) print(f"Episode {episode}, Reward: {episode_reward:.2f}, Epsilon: {epsilon:.3f}") if __name__ == "__main__": train()这里有几个细节值得单独说。第一,我用了F.smooth_l1_loss(也就是Huber Loss)而不是MSE。Huber Loss的好处是当预测和目标的差值很大的时候,梯度是一个常数,不会导致单批数据把网络参数带飞。第二,梯度裁剪clip_grad_norm_设置了最大范数10,这是防止梯度爆炸的一个保险丝,尤其是在训练初期或者环境给出极端奖励信号时,能让训练稳定性提高很多。第三,奖励压缩到[-1, 1],这个对DQN训练极为关键,否则reward的幅度变化过大会让Q值的目标也在一个动态范围里震荡,网络很难学稳。
你可能注意到,我在训练早期(buffer长度小于10000)就跳过更新,这就是标准的warmup阶段,目的是先攒一些经验,避免在经验池近乎空的时候采样出高度重复的数据,同时也能让初始阶段的探索经验不至于被立即用过量的更新淹没。经验池的采样更新比例也很重要,按照我这里的配置,大概每与环境交互4步就会有一次梯度更新,这个频率比较适中。如果你发现训练太快发散,可以增大batch_size或降低学习率;如果训练太慢,可以把更新频率提高。
3.4 训练指标监控与评估方式
训练过程中,光看episode奖励是不够的,因为它波动非常大——一个episode可能因为球的方向导致一两分钟都是垃圾操作,但偶尔一次好运气也能拿高分。为了更稳定地判断模型是否在变好,我每跑5000步就会用当前网络在epsilon=0.01的纯贪婪策略下评估5到10个episode,取平均分作为评估指标。评估的时候必须关梯度、开inference模式,不然推理会占用不必要的显存和计算时间。
我通常还会实时记录两个额外的曲线:当前Q值的均值,以及当前loss的值。Q值均值如果出现持续上涨但奖励不涨,那多半是高估问题在恶化;loss如果出现那种找不到原因的突然飙升,要检查是不是奖励信号里混入了很大的异常值,或者网络出现了NaN。合理的训练曲线形态是:loss在几千步内快速下降到一个低位然后缓慢波动;Q值中位数缓慢上升;评估奖励在某一个临界点突然起飞,这往往是AI“顿悟”了接球技巧的标志。
打砖块这个游戏的奖励曲线前期会非常难看,初始阶段可能每一个episode只有0到20分,基本全靠瞎碰。这个阶段最容易让人放弃,但实际上是在收集探索经验。只要配置正确,通常训练到30到50万步的时候,评估奖励会开始有上升趋势。如果超过100万步还是完全纹丝不动,那基本可以确定是网络结构或超参数出了问题,别硬等。
3.5 调参心得与参数速查表
直接上结论,以下几个超参数对最终效果的影响最大,我也在打砖块环境上做了对比实验。
| 参数名 | 我的推荐值 | 影响说明 |
|---|---|---|
| 学习率 lr | 1e-4 | 太大训练发散,太小收敛极慢。尝试过5e-5,效果略稳但明显变慢;2.5e-4则容易在训练后期出现Q值震荡 |
| 折扣因子 gamma | 0.99 | 强化学习里最常用的值,换到其他Atari环境基本不用改 |
| 目标网络更新频率 | 10000步 | 太勤(1000步)会让网络过于追逐最新参数,失去稳定性;太疏(50000步)则可能让目标值长期失准 |
| 经验池容量 | 500000 | 越大越稳定但内存压力大,如果显存和内存都有限可以降一半 |
| batch size | 32 | 加大到64训练会更稳,但对变参敏感,小数据量下反而可能变差 |
| epsilon 衰减系数 | 0.99995 | 从1.0衰减到0.01大概需要90万步,保证前期有充分的探索 |
| 奖励裁剪 | [-1, 1] | 对训练稳定性帮助极大,几乎是必需项 |
| 梯度裁剪 | max_norm=10 | 防止单次更新幅度过大 |
关于学习率,我遇到过最典型的场景是:从1e-4改成5e-4,前5000步QLoss确实下降很快,但到了20万步左右奖励突然停滞,然后Q值曲线开始向上发散。这说明学习率太高造成的过拟合和高估在后期暴露出来了。所以现在我的习惯是先按1e-4跑一个基线,等到奖励曲线出现平台期,再考虑用学习率退火(比如在训练后期降到2e-5)来微调。
epsilon衰减系数同样需要关注。衰减太快会让AI过早进入“只利用不探索”的状态,后期会陷入局部最优;衰减太慢则会让前期充满无效探索,训练时间拖长。我用的0.99995指数衰减,相当于每1000步epsilon衰减到原来的约0.95,整体衰减曲线比较平滑。
4. 常见问题与修复实录
4.1 训练半天奖励不涨,到底哪里出了问题
这是我被问得最多的一个问题。奖励不涨的排查顺序在我这里基本是固定的。第一步先看loss,如果loss能正常下降,说明网络还在学,问题很可能出在奖励信号或探索策略上面。第二步看Q值,如果Q值在上涨但奖励不涨,高估问题大概率存在,可以尝试切换到Double DQN的写法(排除是不是误用成了普通DQN)。第三步看epsilon,如果exploration已经降到了0.01以下,而AI还没学会有效策略,说明它已经被困在了局部最优里,这时候只能回退到较早的检查点或者调大epsilon重新探索。
如果loss从一开始就不降,那问题大概率出在网络结构或数据流上。常见的坑包括:输入数据没有归一化导致梯度异常、卷积输出维度计算错误导致forward直接报错、或者奖励数值类型不对(比如整型溢出了)。我建议在训练前写个小的smoke test,单独跑几批数据让网络更新一次,确认loss在下降、梯度没有NaN,再开始长训练。
4.2 环境API差异:老教程为什么跑不通
我写这篇代码时用的gymnasium版本已经和几年前的gym API有了明显的差异。最典型的变化是环境重置接口:老版本是state = env.reset(),gymnasium里是state, info = env.reset();奖励的done也被拆成了terminated和truncated两个布尔值,分别表示“游戏真正结束”和“因为超过步数限制而截断”。如果你照着老教程敲代码,经常会感谢这两个小改动换来的一堆报错。
还有一点是关于Atari环境的可复现性。新版gymnasium的Atari环境在frameskip和repeat_action_probability上有很多预设的变体,frameskip=1, repeat_action_probability=0配合AtariPreprocessing里的frame_skip=4是我调出来的最可控组合。如果你在别的教程里看到BreakoutNoFrameskip-v4之类的环境名,那也差不多是一个意思。我的经验是,为了保持训练信号的确定性,repeat_action_probability务必设为0,至少在你验证算法阶段要这样,否则每次跑出来的结果偏差会很大,调参时你根本分不清是算法变好了还是随机噪声导致的效果变化。
4.3 训练过程中的数值稳定性问题
训练过程中我最怕的不是loss高,而是突然出现NaN。这种问题一旦出现,整个训练进程基本就报废了。最常见的触发点有两个:一是输入图像里出现了非法值(比如直接从uint8转float32时没有归一化,导致像素值到了几百的级别,卷积之后数值爆炸);二是奖励信号里偶尔混入了极大值,配合较大的学习率,梯度步长直接冲出了有效范围。
建议的防御措施有三个:输入数据在进入网络前必须统一除以255归一化;reward做裁剪;梯度裁剪一定要加。这三招如果都做了,NaN出现的概率会大大降低。如果还是出现NaN,可以在每步优化前检查一次参数的数值:torch.isnan(net.fc[2].weight).any(),看到底是哪个层先出了问题,再针对性地调整初始化方式或学习率。
灰度化和归一化也不要做得太随意。灰度化能显著降低输入维度,省内存省计算,而且颜色信息对打砖块这个游戏没有额外决策价值,因为砖块的颜色和得分的关系在这个环境中并不影响最优策略。归一化到0到1区间,卷积网络的权重初始化才能在一个合理的范围内工作,否则第一层卷积输出可能过大,ReLU后一片死区,梯度传不下去。
5. 实测效果与后续扩展
5.1 训练曲线解读:烧了50万步才看清的规律
我自己的环境大概跑了120万步。直观的观察是:前30万步基本属于“瞎打”阶段,评估奖励始终在个位数徘徊;到40到60万步之间会有一个明显的跃升,AI开始懂得左右移动去接球;再往后就是策略精修阶段,分数逐步上升但偶尔会有回撤。
关于这个“顿悟时刻”,其实挺有意思的。强化学习里高维状态下的策略往往是非线性的,前期网络一直在调节参数逼近价值函数,但价值函数的形状平滑度不够,一旦某个关键神经元被“激活”,策略会发生相变式的突变,对应到游戏表现就是突然会玩了。这也是为什么不能因为早期几百步的成绩差就直接放弃,给模型足够的时间去探索才是关键。
我还统计了Double DQN和普通DQN在同配置下的对比:普通DQN在50万步时评估奖励大约在30分上下,但Q值已经到了一两百;Double DQN同阶段评估奖励五六十,Q值六七十。Q值和真实奖励之间的偏差差距非常直观地说明了Double机制在抑制高估方面的价值。
训练达到100万步之后,我碰到的新问题是过拟合:模型会逐渐固化在某一条固定的挡板移动模式里,一旦球的角度偏离常规范围就反应不过来。解决办法是适度加入随机动作噪声(比如把epsilon最低值从0.01提升到0.05),或者对环境的初始状态做更强的扰动。这种边际上的小改动常常能再抬一抬成绩。
5.2 从Double DQN还能往哪走
代码跑通之后,这个框架就是你的试验田了。顺着同样的训练架构,你可以很自然地加入几个改进。第一个是Dueling DQN,网络从输出单路Q值改成输出状态价值和动作优势值两条支路,这样做能让网络在感知到某个状态本身“很危险”或“赚到了”的时候更快地传递学习信号。第二个是Prioritized Experience Replay,让网络更新时更频繁地采样那些学习价值高的经验,训练效率会有明显提升。第三个是NoisyNet,把epsilon-greedy的探索改成在网络的参数上直接注入可学习的噪声,减少人工调探索率的痛苦。
这些改进的方向都建立在“你已经理解DQN怎么工作”的基础上。而手写了这套代码之后,再去看Rainbow等算法的论文,你会产生完全不同的感觉:论文里那些改进项的动机和实现细节,在你脑中会直接对应到具体的代码模块上,而不只是抽象的数学符号。这个认知升级,恰恰是手写复现最大的收获。
5.3 最后分享一个训练中的小习惯
在训练流程之前,我一定会先把torch.manual_seed(0)、np.random.seed(0)以及环境的seed都固定下来,并且在代码里记录下配置文件的哈希值。这不是为了追求“可复现”这个词本身,而是因为在强化学习里存在太多随机性来源,如果seed不固定,你根本分不清参数改动是产生了真实影响,还是纯粹的随机波动。
另外,我强烈建议你隔一段时间就把当前模型参数保存为一个带步数标识的checkpoint,比如dqn_500k.pt、dqn_1000k.pt。训练后期如果发现策略退化或者想尝试新的微调方案,随时都可以回退到之前还不错的版本继续调整,而不用从头再跑一遍。这个习惯帮我省下过好几个晚上的重训时间。
其实写到这里,我觉得这套项目最吸引人的地方在于,它完美地把深度学习、概率论、优化理论、工程调试揉在了一起,每一个环节都可能成为瓶颈,但每一个瓶颈也都对应着一条可以深挖的学习路径。如果你能把这份代码调到自己满意的效果,那你对强化学习的理解绝对不会只停留在“会调库”的层面。继续跑下去,你会踩到更多新坑,但也会慢慢建立起属于自己的那套实验直觉。