简介:雅达利游戏Pong是深度强化学习算法验证的经典环境,具有离散动作空间和即时反馈的特点,很适合进行算法研究。该资源聚焦于此,系统实现了深度Q网络、近端策略优化、优势演员评论家、深度确定性策略梯度、异步优势演员评论家等多种经典与前沿算法,面向希望掌握DRL原理并对比算法表现的开发者与研究者。压缩包内共5个文件,包含两个Python源码、一个h5模型权重、一张结果对比图和一段gif演示动画,文件类型覆盖代码、模型、图表与动画,整体仅2.13MB,轻量简洁。运行源码可观察智能体在Pong中的学习曲线,动手调整网络与超参数,深入理解经验回放、目标网络、优势函数等核心机制。目前已有506人学习下载,可作为从理论到实践的优质参考。
1. 为什么是 Pong:多种深度强化学习算法从原理到实现的试金石
Pong 这个游戏规则简单到一句话就能说清:两个球拍,一个球,谁先到 21 分谁赢。但在深度强化学习领域,它近乎扮演着“试金石”的角色——界面干净、状态输入不需要复杂建模、奖励虽然稀疏但信号稳定,每一个算法在这里都会快速暴露它的真实性格:值函数方法收敛扎实但样本要求高,策略梯度方法上手快但对超参数敏感。这个标题所指向的工作,本质上是把 DQN、Double DQN、Dueling DQN、PPO 等主流深度强化学习算法放进完全相同的环境和状态管线里跑通、调优、并对比结果。适合正在入门 DR(尤其深度强化学习)核心算法的开发者,也适合拿课程设计或毕业设计题目做强化学习实现的同学。下面不推公式,给的是能一次跑通的环境搭建、预处理流程、训练循环、参数说明和避坑经验。
2. 搭好训练台:环境选择、状态预处理与最小可运行骨架
无论后面跑哪种算法,Pong 的训练入口都是一致的:一个 Atari 环境、一份统一的观测预处理、一个能反复产出样本的循环。这一章先把这个公共底座搭牢,后续换算法时只替换“学习更新”的部分。
2.1 环境选择:为什么固定用 PongNoFrameskip-v4
常见做法是直接用 Gymnasium(或早期 OpenAI Gym)提供的PongNoFrameskip-v4这个环境 ID。它有两个关键特点:第一,NoFrameskip表示环境不会自动做随机的帧跳帧,每一步返回的都是真实游戏帧,这给训练带来了可控的时序;第二,Atari 环境的观测是Box(210, 160, 3)的 RGB 图像,动作空间是Discrete(6),六个动作分别是 NOOP、FIRE、UP、DOWN、RIGHT、LEFT,但对 Pong 来说真正影响球拍的只有 UP 和 DOWN,其余动作不会产生有效位移。
import gymnasium as gym env = gym.make("PongNoFrameskip-v4", render_mode="rgb_array") print("观测空间:", env.observation_space) # Box(210, 160, 3) print("动作空间:", env.action_space) # Discrete(6) obs, info = env.reset() print("单帧观测形状:", obs.shape)逻辑说明:render_mode设为rgb_array可以在训练时不弹窗,只在必要时用env.render()抓帧保存为视频。env.reset()在 Gymnasium 中返回(obs, info)二元组,和旧版 Gym 只返回obs不同,初学很容易在这里踩一脚。观测空间是 210x160 的 RGB 图像,但网络吃的一般不是它原始的样子,需要预处理。
参数说明:如果本地环境较新,PongNoFrameskip-v4是默认注册的标准 ID;如果换用Pong-v0,环境内部会做随机帧跳,导致相邻观测之间的时序不稳定,对依赖精确位置感知的 DQN 类算法非常不友好。所以做算法对比时固定用NoFrameskip版本,帧跳逻辑由我们自己在外部统一实现,保证所有算法面对同样的环境动态。
2.2 状态预处理管线:灰度、裁剪、缩放与帧堆叠
网络吃的不是单帧图像,而是连续四帧的堆叠。原因很直观:Pong 的球速很快,单帧只能看到位置,看不到方向和速度;四帧拼在一起,卷积网络才能从像素位移中提取运动信息。这套预处理最早在 DQN 论文中被确定为标准做法:灰度化、裁剪黑边和计分板、缩放到 84x84、按通道堆叠连续帧并归一化。
import cv2 import numpy as np from collections import deque class AtariPreprocessor: def __init__(self, height=84, width=84, stack_size=4): self.height = height self.width = width self.stack_size = stack_size self.frames = deque(maxlen=stack_size) def reset(self, raw_frame): processed = self._process(raw_frame) for _ in range(self.stack_size): self.frames.append(processed) return self._stack() def step(self, raw_frame): processed = self._process(raw_frame) self.frames.append(processed) return self._stack() def _process(self, raw): # Pong 的计分板在画面上方,上下有黑边,裁掉更有利于网络聚焦球拍和球 gray = cv2.cvtColor(raw, cv2.COLOR_RGB2GRAY) crop = gray[34:194, :] resized = cv2.resize(crop, (self.width, self.height), interpolation=cv2.INTER_AREA) return resized.astype(np.float32) / 255.0 def _stack(self): # 返回形状 (4, 84, 84),通道维度放在最前 return np.stack(self.frames, axis=0)逻辑说明:deque(maxlen=4)保证每次 append 后永远只保留最近 4 帧,reset时用同一帧填充四次,避免回合起点处的观测缺失。裁剪范围[34:194, :]是常见经验值,Pong 的原始画面上下各有黑边,上方计分板大约占前 34 行,裁掉后既减少计算量,也防止网络被计分数字干扰。
参数说明:归一化到[0, 1]是浮点输入的标准做法;如果为了节省显存也可以归一化到[0, 255]的 uint8 存储,但送进网络时一般仍会转成浮点。缩放插值用INTER_AREA比INTER_LINEAR更适合缩小图像,能保留更多球和球拍的轮廓信息。
2.3 动作执行与最小训练骨架:frame skip 的细节
Atari 环境里一个常见的做法是执行一次动作后连续重复 4 帧再观测,这被称为 frame skip。它让智能体不能每一帧都改变方向,反而迫使策略更有连续性,同时大幅缩短训练步数。更细致的处理是在这 4 帧中取像素变化最大的一帧作为观测,可以避开某些游戏精灵闪烁带来的视觉抖动。
def skip_frame_step(env, action, skip=4): obs, total_reward, terminated, truncated = None, 0.0, False, False for _ in range(skip): obs, reward, terminated, truncated, info = env.step(action) total_reward += reward if terminated or truncated: break return obs, total_reward, terminated, truncated, info逻辑说明:累计奖励是因为一帧内可能同时发生得分与失分以外的奖励变化;Pong 每次只给 ±1 的回合奖励,但统一累加是 Atari 训练的标准习惯。提前跳出循环可以避免在回合结束时继续调env.step导致的异常。
参数说明:skip=4是 DQN 论文以来的默认值;增大 skip 会让训练步数更少,但球在画面中运动的颗粒度变粗,网络更难学到精细的接球时机;减小到 2 则训练成本变高。实际对比中发现,Pong 在 skip=4 下能稳定工作,不建议为了追求分辨率去调小它。
最小训练骨架就是把预处理、skip、回放、训练循环串起来:
replay = ReplayBuffer(capacity=100000) state = preprocessor.reset(env.reset()[0]) for step in range(total_steps): action = select_action(state, epsilon) # 探索/利用策略 raw_obs, reward, terminated, truncated, _ = skip_frame_step(env, action) next_state = preprocessor.step(raw_obs) replay.push(state, action, reward, next_state, terminated) if len(replay) > warmup_steps: # 填满后才开始学习 batch = replay.sample(batch_size) train_one_step(batch) state = next_state if terminated or truncated: state = preprocessor.reset(env.reset()[0])逻辑说明:warmup_steps是为了让回放缓冲区里先积累足够多样的样本,一般取 10000 步左右;过早开始学习会让网络反复拟合少量重复经验,严重拖慢收敛。epsilon在 DQN 类算法里负责探索,这一步先占位,具体衰减逻辑在下一章展开。
参数说明:回放缓冲区容量 100000 是 Pong 这类短回合游戏的一个合理起点。容量太小会让采样集中在最近经验上,破坏回放机制打破相关性的初衷;容量过大(比如 1M)在 Pong 上收益不大,反而增加采样内存。如果显存吃紧,可以先从 50000 起步看曲线。
3. 值函数路线:DQN、Double DQN 与 Dueling DQN 的实现与改动
值函数方法的核心是把“状态-动作”映射成一个数值,网络学习的是这个数值本身。在 Pong 上,DQN 是第一个能稳定玩出正分的主流算法,但原始 DQN 存在过估计和动作解耦问题,后面两个变体分别从不同角度补了短板。这一章先把公共网络写好,再逐一说明变体的改动量。
3.1 从 Q-Learning 到 DQN:三个关键机制先立住
DQN 相对传统 Q-Learning 有三个关键贡献:经验回放、目标网络、以及卷积网络直接吃图像。经验回放通过随机采样打破样本之间的时间相关性,让梯度更新更接近独立同分布假设;目标网络提供一份滞后更新的 Q 值作为回归目标,避免网络同时追赶自己和自己的目标带来的震荡;卷积网络则让状态输入从手工特征变成原始像素。没有这三个机制,DQN 在 Pong 上几乎是训不出来的。
需要说明选型理由:在 Pong 上,值函数方法比策略梯度方法更吃样本量,但优势是训练过程相对稳定,梯度更新的方差小。因此如果你的机器只有 CPU 或者显存很小,优先跑通 DQN 系列是更现实的选择。
import torch import torch.nn as nn import torch.nn.functional as F class BaseCNN(nn.Module): def __init__(self, in_channels=4): super().__init__() self.features = nn.Sequential( nn.Conv2d(in_channels, 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(), nn.Flatten(), ) # 用假输入推断卷积输出维度,避免手工计算 dummy = torch.zeros(1, in_channels, 84, 84) self.fc_in = self.features(dummy).size(1) def forward(self, x): return self.features(x) class DQN(BaseCNN): def __init__(self, in_channels=4, num_actions=6, dueling=False): super().__init__(in_channels) self.dueling = dueling if dueling: self.value_head = nn.Linear(self.fc_in, 1) self.advantage_head = nn.Linear(self.fc_in, num_actions) else: self.q_head = nn.Sequential( nn.Linear(self.fc_in, 512), nn.ReLU(), nn.Linear(512, num_actions), ) def forward(self, x): feat = self.features(x) if not self.dueling: return self.q_head(feat) value = self.value_head(feat) advantage = self.advantage_head(feat) # Dueling 的聚合方式:减去优势均值保证可辨识性 return value + advantage - advantage.mean(dim=1, keepdim=True)逻辑说明:dummy前向计算卷积输出维度的做法比硬编码 7x7 更稳,避免改输入尺寸时手算翻车。dueling=True时,网络把状态价值和优势分离:value_head输出一个标量描述当下状态整体好坏,advantage_head输出每个动作相对该状态的好坏。聚合时减去优势均值可以解决值函数不可辨识的问题,这是 Dueling DQN 的标准实现。
参数说明:卷积核从 8 到 4 再到 3,通道数从 32 到 64,是 Atari 环境最经典的配置,直接照搬即可。第一层卷积 stride=4 会把 84x84 直接压到 20x20,大幅降低计算量,但在 Pong 这种小球场景中球可能只有一两个像素,过大的 stride 会让小目标在特征图中消失。若发现网络总学不会追球,可以尝试把第一层 stride 改成 2,代价是训练变慢。
3.2 DQN 训练循环:经验回放、目标网络与 epsilon 衰减
DQN 的训练循环由三块拼装:采样动作时按 epsilon 概率随机探索,否则取网络输出的 argmax;每一步把刚产生的转移存入回放缓冲区;定期从回放采样 batch 更新网络。epsilon 的衰减策略直接影响探索质量,这一步的失败率很高。
def select_action(state, q_net, epsilon): if np.random.random() < epsilon: return env.action_space.sample() state_t = torch.tensor(state, dtype=torch.float32).unsqueeze(0) with torch.no_grad(): q_values = q_net(state_t) return q_values.argmax(dim=1).item() for step in range(1, total_steps + 1): # epsilon 线性衰减:前 100k 步从 1.0 降到 0.01,之后保持 if step < 100000: epsilon = 1.0 - step / 100000 * 0.99 else: epsilon = 0.01 action = select_action(state, q_net, epsilon) raw_obs, reward, terminated, truncated, _ = skip_frame_step(env, action) next_state = preprocessor.step(raw_obs) replay.push(state, action, reward, next_state, terminated) if len(replay) > 10000 and step % 4 == 0: batch_obs, batch_act, batch_rew, batch_next, batch_done = replay.sample(32) loss = compute_dqn_loss(batch_obs, batch_act, batch_rew, batch_next, batch_done, q_net, target_net) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(q_net.parameters(), 10.0) optimizer.step() if step % 10000 == 0: target_net.load_state_dict(q_net.state_dict()) if terminated or truncated: state = preprocessor.reset(env.reset()[0]) else: state = next_state逻辑说明:step % 4 == 0表示每 4 个环境步更新一次网络,这是 DQN 论文中常用的“训练频率稀疏化”技巧,减少相邻样本带来的相关性。clip_grad_norm_把梯度范数限制在 10.0,防止偶尔出现的离群样本把参数推出稳定区。目标网络每 10000 步硬同步一次,同步频率过大会让目标跟踪太紧失去稳定性,过小会让学习变慢。
参数说明:epsilon 衰减周期 100k 步是 Pong 的合理范围,但如果你发现智能体到后期仍然频繁乱动,可以把周期延长到 200k 步;反过来如果前期探索足够,网络已经开始接球,可以缩短到 50k 步。这里有个血泪经验:epsilon 从 1.0 线性降到 0.01 比指数衰减更稳,指数衰减前期降得太快会导致探索不足,表现为 reward 长时间停留在 -21(一次都不赢)。
3.3 Double DQN 与 Dueling DQN:各自的改动到底改在哪
Double DQN 解决的是过估计问题。原始 DQN 在计算下一状态目标时,直接用目标网络的 max 值,这会让 Q 估计系统性偏高。Double DQN 的改动只有一行逻辑:用在线网络选择动作,用目标网络评估该动作的值。
def compute_dqn_loss(obs, act, rew, next_obs, done, q_net, target_net, gamma=0.99): q_values = q_net(obs).gather(1, act.unsqueeze(1)).squeeze(1) with torch.no_grad(): if double_dqn: # 在线网络选动作,目标网络给值 next_action = q_net(next_obs).argmax(dim=1, keepdim=True) next_q = target_net(next_obs).gather(1, next_action).squeeze(1) else: # 原始 DQN:直接取目标网络的 max next_q = target_net(next_obs).max(dim=1)[0] target = rew + gamma * (1 - done.float()) * next_q return F.smooth_l1_loss(q_values, target)逻辑说明:原始 DQN 的max操作同时完成了“选动作”和“评估值”两件事,任何评估噪声都会被 max 放大。Double DQN 把动作选择交给实时更新的在线网络,目标网络只负责打分,两边的错误不会叠加。在 Pong 这类奖励对称的游戏中,过估计会让智能体高估某个动作的长期收益,表现为明明球已飞远还在原地上下抖动。
参数说明:smooth_l1_loss也就是 Huber loss,对离群目标值更鲁棒,比 MSE 更适合强化学习场景。如果换回 MSE,遇到奖励异常大的样本时 loss 会突然飙升,虽然 Pong 的奖励始终是 ±1,但累积回报的 bootstrap 值仍然可能较大。
Dueling DQN 的改动则集中在网络结构上,训练算法完全不变。它把 Q 值拆成状态价值和动作优势两部分,意味着当球的位置不值得采取任何不同动作时,优势分支可以趋近于零,而状态价值分支仍然能正常学习。这在 Pong 中体现在等待球弹回的场景:球在对方半场时,你做什么动作收益都差不多,Dueling 结构不会因为无意义动作的微小误差扰乱整体 Q 值。常见实现中,把 DQN 的dueling=True即可完成切换,其余超参数不需要动。
4. 策略梯度路线:REINFORCE 与 PPO 在 Pong 上的实现差异
值函数方法需要先学出一个 Q 值再导出策略,而策略梯度方法直接优化策略本身的参数,输入状态输出动作概率分布,并用实际回报调节概率高低。它最大的优点是天然支持随机策略——Pong 里偶尔带一点随机性会让接球更稳,而不是傻傻地只在两个固定动作间切换。
4.1 为什么在对比实验中要加上 PPO
只做 DQN 系列对比会带来一个盲区:值函数方法的收敛曲线相似度高,可能让你误以为所有强化学习算法的性格都一样。Pong 是少数几种策略梯度方法比 DQN 更快见正分的游戏之一,原因是它的奖励信号虽然稀疏但方差相对可控,策略梯度对每回合的回报直接做 credit assignment,不需要等 Q 值慢慢逼近真实值。把你的实验项目加上 PPO,对比结果才完整。
4.2 PPO 的实现要点:Actor-Critic、GAE 与 clipped surrogate
PPO 的核心是一个 Actor-Critic 网络:Actor 输出动作概率分布,Critic 输出状态价值,两者共享卷积特征提取层。更新时用 clip 机制限制单次更新的步长,防止策略在一步更新中变化过大导致训练崩掉。
class ActorCritic(BaseCNN): def __init__(self, in_channels=4, num_actions=6): super().__init__(in_channels) self.policy_head = nn.Linear(self.fc_in, num_actions) self.value_head = nn.Linear(self.fc_in, 1) def forward(self, x): feat = self.features(x) logits = self.policy_head(feat) value = self.value_head(feat).squeeze(-1) return logits, value def act(self, x, sample=True): logits, value = self.forward(x) dist = torch.distributions.Categorical(logits=logits) if sample: action = dist.sample() else: action = torch.argmax(logits, dim=1) log_prob = dist.log_prob(action) return action, log_prob, value逻辑说明:Categorical分布直接把 logits 转成动作概率,采样时按概率抽取,评估时取 argmax。act方法同时返回动作、对数概率和状态价值,三者都是后续更新所需的。共享特征层的好处是在 Pong 这种小图像任务中减少参数总量,坏处是特征需要同时服务策略和价值两个目标,训练初期可能出现两边互相拉扯;不过 Pong 的观测简单,共享层不会成为瓶颈。
参数说明:如果追求更强的分离性,可以分成两个独立的 CNN 分支,但 Pong 上没有必要,会白白增加显存占用。如果跑更复杂的游戏(比如需要长期规划的),再考虑分开。
GAE(广义优势估计)是 PPO 计算 advantage 的默认工具。它用 Critic 估算的价值构造一个比单步 TD 误差更平滑的优势信号:
def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): advantages = torch.zeros_like(rewards) gae = 0.0 for t in reversed(range(len(rewards))): if t == len(rewards) - 1: next_value = 0.0 else: next_value = values[t + 1] delta = rewards[t] + gamma * next_value * (1 - dones[t]) - values[t] gae = delta + gamma * lam * (1 - dones[t]) * gae advantages[t] = gae returns = advantages + values return advantages, returns逻辑说明:lam是 GAE 的衰减参数,取值 0.95 意味着优势信号在时间尺度上偏向“中程”,不完全依赖单步奖励也不完全依赖整回合回报。倒序遍历是 GAE 的经典实现,从回合末尾向开头累积,这样每个时间步的优势都包含了后续所有步的信息。注意当某一步done=True时,next_value视为 0,因为回合终止后不再有未来回报。
参数说明:gamma=0.99是大多数 Atari 任务的默认折现因子;lam偏大(0.99)会让优势更接近整回合蒙特卡洛回报,方差更大但偏差更小;偏小(0.9)则更接近单步 TD,方差小但可能出现奖励传播不及时的问题。Pong 上从 0.95 起步,不要一上来就动这个参数。
PPO 更新阶段的 clipped surrogate 是它稳定的关键:
def ppo_update(batch): obs, act, old_log_prob, adv, returns = batch logits, values = ac_net(obs) dist = torch.distributions.Categorical(logits=logits) new_log_prob = dist.log_prob(act) ratio = (new_log_prob - old_log_prob).exp() adv_normalized = (adv - adv.mean()) / (adv.std() + 1e-8) surr1 = ratio * adv_normalized surr2 = torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * adv_normalized policy_loss = -torch.min(surr1, surr2).mean() value_loss = F.mse_loss(values, returns) entropy_bonus = dist.entropy().mean() total_loss = policy_loss + 0.5 * value_loss - 0.01 * entropy_bonus optimizer.zero_grad() total_loss.backward() optimizer.step()逻辑说明:ratio是新旧策略下同一动作概率的比值,大于 1 说明新策略更愿意选这个动作,小于 1 则反之。clip 操作把 ratio 限制在 0.9~1.1 之间,目的是防止策略在单次更新中步子迈得太大。取min(surr1, surr2)保证策略只会在对 advantage 有利的方向被推大,不会因为一次偶然的高回报就激进地改变策略。entropy bonus 鼓励策略保持一定随机性,避免过早去过拟合单一动作模式。
参数说明:clip_eps=0.1是 PPO 论文的默认值,调大到 0.2 可以加快学习但风险增加。Pong 的任务比较稳,0.1 够用;如果你发现策略更新太慢,可以先调到 0.2 试一轮,观察 divergence 情况再决定。0.5 * value_loss和0.01 * entropy_bonus是常见的权重系数,不要随意调大 entropy 权重,否则策略会一直乱动学不会接球。
4.3 两条路线在 Pong 上的对比维度与选型建议
值函数路线和策略梯度路线在 Pong 上差异明显。从训练曲线看,DQN 通常要几十万步后才能稳定为正分,但一旦学会,策略动作非常果断;PPO 更常出现“先用大量随机探索积累回放,然后在几万步内突然学会”的跳跃式上升,且最终策略会保留一定随机性,让接球看起来更稳。下表给出你可以在实验报告中直接使用的对比维度:
| 对比维度 | DQN 系列 | PPO |
|---|---|---|
| 样本效率 | 较低,需要百万帧级 | 相对更高,单环境也能收敛 |
| 训练稳定性 | 依赖目标网络与回放缓冲 | 依赖 clip 系数与 GAE 系数 |
| 探索机制 | epsilon 线性衰减 | 概率分布的固有熵,回合随机性 |
| 实现复杂度 | 中等,需维护双网络 | 较高,需管理 rollout 与 GAE |
| 适合场景 | 环境交互成本低、离线学习 | 有并行环境或多个采样器时更划算 |
这条线的实现里面,最大的坑是存储和计算 rolloout 数据。PPO 需要保留当轮所有观测、动作、对数概率、价值、奖励、终止标志,打包成 batch 后再更新。如果你沿用 DQN 的逐样本训练逻辑而不做 mini-batch 周期更新,PPO 的 clip 机制就形同虚设。常见做法是写一个简单的 RolloutBuffer,容量为horizon(比如 128 步),满了一轮后统一更新,再清空继续采样。
5. 训练 Pong 避坑指南:5 个高频翻车现场与排查路径
只要你前后跑过 DQN 和 PPO,大概率会撞上下面这些现象。我把它们按“现象→原因→解决”梳理出来,遇到时直接对照排查,能省下大量调参时间。
5.1 现象:训练了很久,回合平均奖励一直是 -21
-21 意味着智能体每回合都输 21 个球,一场没赢。最常见原因是 epsilon 衰减太快,智能体在学会基本接球之前就进入纯利用模式。它只会重复几个固定动作,而固定动作又没能碰到球。
- 排查方式:打印每一步的 epsilon 值,看它在哪个时间点降到了 0.01 以下;回溯到该时间点前的训练步数,确认是否真的积累过足够多探索样本。
- 解决:把 epsilon 衰减周期从 100k 步拉长到 200k,或者改成分段线性衰减——前 20% 步从 1.0 降到 0.1,后 80% 从 0.1 慢慢降到 0.01。
5.2 现象:loss 不降反升,或出现 NaN
loss 上升通常不是“没学对”,而是优化过程不稳定。先看梯度范数是否异常,可以在 backward 后用grad_norm = sum(p.grad.norm() ** 2 for p in net.parameters()) ** 0.5打印出来。如果某几个 step 后梯度范数飙升到几千以上,说明是梯度爆炸。NaN 一般是学习率过高导致的数值溢出。
- 解决:给所有网络参数统一做
clip_grad_norm_(params, 10.0),并把学习率从 1e-4 降到 5e-5 重试。Pong 不是复杂的控制问题,学习率太高只会让 Q 值和策略分布震荡。
5.3 现象:智能体学会了“不动”——球过来时不迎球
这是典型的过拟合局部策略:在 Pong 里,不动有可能恰好在球弹回时接到,偶尔的成功会给网络一个正的 TD 误差,让它误以为不动是个好策略。
- 解决:检查奖励是否出现了正反馈被放大的情况。很多人会把奖励按自己的业务逻辑乘以 10,这在 Pong 里会让 TD 误差变得很大。保持环境原始 ±1 奖励,不要在 reward 上做手脚。
- 另一个解决方向是降低 epsilon 的最小值到 0.001,给策略保留极少量随机尝试,打破“不动”的僵局。
5.4 现象:episode 永远不结束,一个回合跑了很久
Pong 的回合会在某方先到 21 分时结束,但实际训练中你经常看到智能体在场地里来回击球,连续几万步都不结束。这不是模型出了问题,而是智能体已经学会了“接球并回击”,但还没学会“得分”。
- 处理:训练循环需要给单个 episode 设置最大步数上限,比如 20000 步,达到后强制
truncated=True并重置。注意区分terminated和truncated:两者在 Gymnasium 里都会返回True,但truncated意味着步数超限而非游戏结束,计算目标值时如果误把truncated当作终止,会把本应继续的未来回报人为清空,导致价值估计偏低。
5.5 现象:训练曲线抖动剧烈,偶发跑出正分但下个回合又跌回 -21
PPO 上尤其常见,偶尔一次高回合回报被记进策略更新,策略瞬间发生变化,之后连续输球。这是策略梯度方法在单环境采样下的天然高方差特性。
- 解决:对回合均值奖励做指数滑动平均后再画曲线,别直接画原始回合值;更新时对 advantage 做标准化(减去均值除以标准差),这能让 clip 范围内的梯度更稳定;如果还是抖得厉害,把
clip_eps从 0.1 降到 0.05,相当于给策略更新加更紧的限速。
6. 让训练结果可见:评估协议、奖励曲线与三分钟验证法
训练结束不代表实验完成,还差一个能让别人相信你结论的评估协议。我的习惯是固定三件事:随机种子、评估频率、以及评估时是否关闭探索。
def evaluate(env, preprocessor, net, episodes=10, render=False): total_rewards = [] for _ in range(episodes): state = preprocessor.reset(env.reset()[0]) ep_reward = 0.0 while True: logits, _ = net(torch.tensor(state, dtype=torch.float32).unsqueeze(0)) action = logits.argmax(dim=1).item() # DQN 或确定性评估 obs, reward, terminated, truncated, _ = skip_frame_step(env, action) state = preprocessor.step(obs) ep_reward += reward if terminated or truncated: break total_rewards.append(ep_reward) return np.mean(total_rewards), np.std(total_rewards)逻辑说明:评估时关掉 epsilon 概率、关掉策略采样,统一用 argmax 确定性动作,这样跑出来的分数才是纯策略水平,不掺杂随机性。每训练 10000 步跑一次 10 局评估,记录均值和标准差。
判断标准:Pong 的回合均分从 -21 起步。均值到 -10 左右说明学会了基本接球;到 -5 说明能稳定回击;到 0 以上说明有了得分能力;到 5 以上基本已经是能主动调度的水平。如果连续 5 次评估都在 0 以上且标准差低于 2,我认为可以停训;如果你的实验目的是对比算法,那应该在固定预算下对比,比如统一训 500k 帧,而不是“训到收敛”再比,否则每个算法的训练时长不对等,结果会有偏差。
小技巧:把训练过程中的前 20 个回合录成 GIF,比奖励曲线直观得多。你经常能从录像里看到 reward 曲线没暴露的问题——球在智能体面前飞过它却傻站着,多半是帧堆叠顺序错了;球被打出界后智能体还对着空气挥拍,说明它把“挥拍”和“得分”的因果关系学反了。这个“三分钟验证法”是我排查训练效果时最先用的手段。
我最后留一个自己的教训:有一次对比实验里,DQN 和 PPO 的预处理脚本、环境 ID、种子全部一样,唯一的区别是网络结构,但结果 PPO 遥遥领先。我一度以为 PPO 在 Pong 上天生占优,后来发现用户在跑 PPO 时无意间多加了环境并行,相当于多吃了 8 倍样本。所以——在强化学习里,先统一口径,再比较算法。希望帮到你。
本文还有配套的精品资源,点击获取