简介:这是一份基于深度Q网络(DQN)实现“愤怒的小鸟”智能体的Python/TensorFlow资源包,面向对强化学习、游戏AI及图像化环境决策感兴趣的开发者。包内共54个文件,包含Python源码、可加载的预训练模型与checkpoint/meta、游戏画面png截图、gif演示动画、ogg/wav音频素材以及PyCharm项目配置,压缩包约23.54MB;目录将训练日志、图片素材和网络权重分开存放,还保留了pyc编译产物,方便直接加载继续训练或按模块阅读。已有1646人学习下载。围绕MDP建模、经验回放、双网络结构与ε-贪婪策略等关键机制,资源展示了如何把游戏画面转为输入特征,并完成从调整角度和力度到击打猪堡的决策训练闭环;读者可据此复现DQN训练流程,理解目标网络同步、损失计算与探索利用权衡,并将同一套方法迁移到其他视觉决策任务。
1. 为什么让 DQN 去玩愤怒的小鸟,比跑 Atari 更值得一试
如果你刚从 CartPole 或者某个 Atari 游戏跑通 DQN,会觉得"让智能体弹弓打鸟"不过是换了个皮。真动手才发现不是这么回事:愤怒的小鸟和 Atari 最大的区别在于决策结构。Atari 的每个动作是离散的按键,而愤怒的小鸟一次完整的"射击"要同时决定发射角度和力度,且这个决定做完之后,接下来几秒的物理演化完全不受智能体控制。这意味着 DQN 面对的是一个"半马尔可夫"过程——动作是高维连续参数,奖励在延迟几秒后才出现,中间隔着弹道、碰撞、倒塌这些非平稳物理过程。标题里这个"愤怒的小鸟 DQN",本质上不是让智能体学会反应,而是让它学会"预判一次决策的长远后果"。这篇文章就把这条链路从头拆到尾:环境怎么搭、状态怎么表示、奖励怎么设计、训练怎么稳定、以及五个最容易翻车的坑。适合已经跑通基础 DQN(哪怕是在 CartPole 上)但想往真实物理游戏上迈一步的人,也适合准备做游戏 AI 毕业设计的同学找一条可复现的路径。
2. DQN 基础:从 Q-Learning 到深度网络的这次关键跨越
这一章不重新推导 DQN 的数学,只讲清楚三件事:为什么表格型 Q-Learning 在愤怒的小鸟上行不通、DQN 做了什么关键改动、以及一个能直接拿走的 PyTorch 实现骨架。代码跑通了,后面几章才知道该改哪里。
2.1 Q-Learning 的表格困境与 DQN 的解决思路
Q-Learning 的核心是维护一张 Q(s, a) 查找表,每次迭代更新表格里的一项:
Q(s, a) ← Q(s, a) + α [r + γ max_{a'} Q(s', a') − Q(s, a)]
问题在于:CartPole 的状态是 4 维浮点数,离散化成几十个格子还能塞进表里;愤怒的小鸟如果直接读画面,一帧 84×84 的灰度图就有 7000 多个像素,每个像素 0 到 255 取值,状态空间根本不可能枚举。就算你只取弹弓角度、小鸟位置、猪的位置这几个标量,角度和距离的组合也是连续的,表格会稀疏到绝大多数格子一辈子没被访问过。
DQN 的做法是用一个神经网络 Q(s, a; θ) 去逼近这张表,输入是状态,输出是每个动作的 Q 值。网络本质上在做泛化:见过相似的布局,就能对没见过的布局给出差不多的估计。训练目标变成了最小化时序差分误差:
L(θ) = E[(r + γ max_{a'} Q(s', a'; θ⁻) − Q(s, a; θ))²]
这里 θ⁻ 是目标网络的参数,和 θ 不是同一个,这正是下面要展开的稳定化设计。对愤怒的小鸟来说,用网络替代表格不仅解决了状态连续的问题,还让智能体有可能把"高弹道打碎上层结构"和"低弹道直接推塔"这两类策略在隐层特征里共享——桌面和 Atari 里很难看到这种迁移,但在物理堆叠游戏里很常见。
2.2 经验回放与目标网络:DQN 稳定训练的两根支柱
DQN 的两大关键改动,直接决定了训练能不能收敛。
第一个是经验回放(Experience Replay)。在线学习时,相邻 step 的样本高度相关,智能体在同一个场景里反复学,容易把某个局部策略学到过拟合。经验回放的做法是维护一个固定大小的环形缓冲区,把每次转移 (s, a, r, s', done) 存进去,训练时随机采样一个小批量。愤怒的小鸟里这个设计尤其重要:一次弹射产生一条很长的轨迹,如果按顺序学习,这一整条轨迹的样本都在同一个物理场景下,网络会被这条轨迹"带着跑"。随机采样则打散了相关性,让更新梯度近似独立同分布。
第二个是目标网络(Target Network)。注意 2.1 的损失函数里,max_{a'} Q(s', a'; θ⁻) 用的是目标网络的参数 θ⁻,而不是正在更新的 θ。如果不分开,每一步更新都在移动"目标"本身,loss 会震荡甚至发散——这是大家常说的训练不稳定的根源之一。常见做法是每隔固定步数把 θ 复制到 θ⁻,或者用软更新:θ⁻ ← τθ⁻ + (1−τ)θ。愤怒的小鸟这类奖励延迟的游戏中,目标网络更新的频率对稳定性非常敏感,我一般先用硬更新、每 1000 步同步一次起步,跑起来再调。
2.3 一个可运行的 DQN 核心类骨架
下面这个骨架是删掉了游戏交互的纯智能体部分,PyTorch 实现,可以直接套到任意环境上。重点看网络结构、回放缓冲区、以及每步训练的逻辑。
import torch import torch.nn as nn import torch.optim as optim import numpy as np import random from collections import deque class DQN(nn.Module): def __init__(self, state_dim, action_dim, hidden=128): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim) ) def forward(self, x): return self.net(x) class ReplayBuffer: def __init__(self, capacity=50000): self.buffer = deque(maxlen=capacity) def push(self, s, a, r, s_next, done): self.buffer.append((s, a, r, s_next, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) s, a, r, s_next, done = zip(*batch) return (np.array(s), np.array(a), np.array(r), np.array(s_next), np.array(done)) def __len__(self): return len(self.buffer) class Agent: def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99, buffer_size=50000, batch_size=64, target_update=1000): self.action_dim = action_dim self.gamma = gamma self.batch_size = batch_size self.target_update = target_update self.train_step = 0 self.eval_net = DQN(state_dim, action_dim) self.target_net = DQN(state_dim, action_dim) self.target_net.load_state_dict(self.eval_net.state_dict()) self.optimizer = optim.Adam(self.eval_net.parameters(), lr=lr) self.buffer = ReplayBuffer(buffer_size) def act(self, state, epsilon): if random.random() < epsilon: return random.randint(0, self.action_dim - 1) with torch.no_grad(): q = self.eval_net(torch.FloatTensor(state).unsqueeze(0)) return q.argmax(dim=1).item() def update(self): if len(self.buffer) < self.batch_size: return 0.0 s, a, r, s_next, done = self.buffer.sample(self.batch_size) s = torch.FloatTensor(s) a = torch.LongTensor(a).unsqueeze(1) r = torch.FloatTensor(r).unsqueeze(1) s_next = torch.FloatTensor(s_next) done = torch.FloatTensor(done).unsqueeze(1) q_current = self.eval_net(s).gather(1, a) with torch.no_grad(): q_next = self.target_net(s_next).max(dim=1, keepdim=True).values q_target = r + self.gamma * q_next * (1 - done) loss = nn.MSELoss()(q_current, q_target) self.optimizer.zero_grad() loss.backward() self.optimizer.step() self.train_step += 1 if self.train_step % self.target_update == 0: self.target_net.load_state_dict(self.eval_net.state_dict()) return loss.item()逻辑说明:act方法实现 ε-greedy 策略,以 ε 概率随机探索,其余时间取 eval_net 的输出最大值。update方法从回放缓冲区采样一个小批量,计算当前 Q 值和目标 Q 值之间的 MSE 损失。注意目标值计算用torch.no_grad()包住,确保梯度只回传到 eval_net。done标志乘在 Q_next 上,终止状态不再累加未来奖励。
参数说明:gamma是折扣因子,愤怒的小鸟建议设 0.95 到 0.99,太低会导致智能体只看眼前几步的伤害、放弃布局倒塌后的连锁收益;target_update控制目标网络同步频率,设大了训练慢,设小了容易震荡,1000 是个安全起步值;buffer_size至少 5 万,小鸟场景单次弹射轨迹长,缓冲区太小会把早期经验挤出去。这段骨架不包含状态预处理和奖励计算,那部分要看环境怎么定义,下一章展开。
3. 搭建愤怒的小鸟游戏环境:状态、动作与奖励设计
环境是 DQN 训练的地基。愤怒的小鸟没有现成的 OpenAI Gym 标准接口可用(社区里有第三方包装,但版本参差不齐),我一般直接自己封装一个。这一章讲清楚三个决定性问题:用什么环境、状态怎么给、奖励怎么定。
3.1 用模拟环境替代原版游戏的三种常见做法
第一种做法是玩原版游戏、截屏读像素。理论上最"原汁原味",实际上最坑:你需要处理窗口坐标、渲染延迟、不同分辨率的适配,而且原版物理引擎是个黑匣子,每次加载布局的随机性也大。除非你要做"纯视觉策略"的研究课题,否则我不推荐第一条路。
第二种做法是自建一个简化物理模拟器:用 PyGame 或者纯数学计算模拟弹弓、抛物线、碰撞和倒塌。常见做法是把小猪和木块简化成圆形或矩形碰撞体,小鸟发射出去后按重力加速度运动,碰到物体就扣血或击飞。这个方案可控性最好,你能拿到每个物体的精确位置和速度,奖励计算也方便。缺点是要自己写碰撞检测,物理表现不像原版那么丰富。
第三种做法是用社区现成的"愤怒的小鸟"模拟环境,比如基于 PyGame 的简化克隆版或者学术开源平台。这些环境封装了 Gym 风格的接口,但质量参差不齐,有的动作空间定义很怪,有的奖励设计不适合 DQN。我的建议是:如果你要做毕设或工程实验,选第二种自己封装;如果只是想快速验证 DQN 代码有没有 bug,选第三种拿来就跑。下面以第二种自建环境为例,因为它的每个接口你都清楚,出了玄学问题能自己查。
3.2 状态表示:怎么把画面变成 DQN 能吃的输入
DQN 的输入是状态向量。愤怒的小鸟有三种层次的状态表示:
第一种是屏幕像素。把画面缩放成 84×84 灰度图,堆叠最近 4 帧作为输入,像 Atari 那样。但这里有个问题:小鸟从发射到撞击往往持续几十帧,而真正决定结果的是发射那一瞬间的角度和力度。堆叠帧对"捕捉运动"有用,对"决定下一次发射"帮助有限,而且灰度图丢失了物体类型信息——木块、冰块、猪在灰度下可能长得一样。
第二种是物体级别的向量表示。把场景里每个物体的位置、类型、剩余血量、是否存活,以及弹弓位置、小鸟角度和力度,拼成一个固定长度的向量。比如 5 个物体,每个用一个 4 元组表示,加上弹弓状态 2 个值,状态维度就是 5×4+2=22。这种表示的好处是直接喂给 MLP 就行,不需要卷积网络,训练效率高;坏处是当你换关卡布局时,物体数量变了,向量长度就变了,需要填零补齐。
第三种是"迷你地图"式的栅格表示:把场景划分成网格,每个格子填上该区域有没有物体、是什么类型的编码。这种介于像素和向量之间,保留了空间结构又压缩了维度。我比较推荐第二种起步,先用最少的状态维度把 DQN 跑通,确认算法没问题后再升级成栅格表示。下面给出一个状态构造的代码片段:
class BirdState: def __init__(self, max_objects=8, grid_size=8): self.max_objects = max_objects self.grid_size = grid_size def build_vector(self, objects, slingshot_state): # objects: list of (x, y, type_id, hp, alive) # slingshot_state: (angle, power) in normalized [0, 1] vec = [] for obj in objects[:self.max_objects]: vec.extend(obj) # 每个物体贡献 5 个值 while len(vec) < self.max_objects * 5: vec.extend([0.0] * 5) # 物体不够则补零 vec.extend(slingshot_state) # 弹弓状态 2 个值 return np.array(vec, dtype=np.float32) def build_grid(self, objects, img_w, img_h): grid = np.zeros((self.grid_size, self.grid_size, 3), dtype=np.float32) for x, y, type_id, hp, alive in objects: if not alive: continue gx = int(x / img_w * self.grid_size) gy = int(y / img_h * self.grid_size) gx = min(gx, self.grid_size - 1) gy = min(gy, self.grid_size - 1) grid[gy, gx, type_id] = 1.0 # one-hot 类型编码 return grid逻辑说明:build_vector把场景里的物体属性拼成一维向量,物体不够时补零,保证状态维度恒定。build_grid把场景投影到 8×8 的栅格上,每个格子的三个通道分别对应木块、冰块、猪这三类物体,命中就置 1。这个 one-hot 编码保留了物体类型的空间分布,又比像素表示紧凑得多。
参数说明:max_objects=8是场景里最多物体数,超过的部分直接截断,实战里 8 个对简化关卡够用;grid_size=8决定栅格分辨率,太细会让状态维度爆炸,太粗丢空间细节。两个函数返回的数据类型都强制转成 float32,避免传入 PyTorch 时出现类型不匹配的报错。
3.3 动作空间与奖励塑形:让智能体一开始就知道往哪飞
动作空间是 DQN 落地时最容易拍脑袋的地方。愤怒的小鸟的原始动作是两个连续参数:角度和力度。DQN 输出的是离散动作,所以常规做法是把角度和力度分别离散化:角度分 10 档(比如 20° 到 70°),力度分 5 档(比如 30% 到 100%),组合成 50 个离散动作。50 个动作的 Q 值输出对 MLP 来说完全可行,但要注意动作数越多,探索效率越低——随机试 50 个动作找到一次有效射击的概率比试 5 个动作小得多。
更聪明的做法是先固定几个"经验角度":比如 30°、45°、60°,每个配 3 档力度,总共 9 个动作。这不是偷懒,而是符合人类玩这个游戏的策略——没人会去精准试 37.2°。动作空间的合理范围决定了探索效率,我见过有人一上来就 100 个动作,训练三天毫无起色,缩小到 12 个动作以后半天就出效果。
奖励设计是第二个关键点。只给"是否打死猪"的稀疏奖励,DQN 学起来极慢,因为中间过程没有任何梯度信号。常见做法是拆成多层奖励:
def compute_reward(info): r = 0.0 # 1. 对猪造成伤害,直接给正奖励 damage = info['pig_hp_before'] - info['pig_hp_after'] r += damage * 5.0 # 2. 造成任何物体位移或摧毁,给少量正奖励(探索信号) if info['objects_destroyed'] > 0: r += 1.0 * info['objects_destroyed'] # 3. 每发小鸟没碰到任何东西,给负奖励 if info['shot_total_miss']: r -= 2.0 # 4. 整关结束时,按剩余小猪数量给最终奖励 if info['episode_end']: r += 10.0 * info['pigs_killed_total'] r -= 0.5 * info['pigs_remaining'] # 没打死的猪,扣分 return r逻辑说明:第一项是主要学习信号,打掉猪的血量直接给正奖励,这一步能引导智能体把动作和"命中目标"关联起来。第二项是辅助探索信号,任何物体被摧毁都给奖励,哪怕打的是木块,因为木块倒塌可能砸到猪——这正是愤怒的小鸟的策略精髓。第三项惩罚打空枪。第四项在整关结束时按击杀数给一个较大的最终奖励,让智能体学会追求"通关"而不只是单发伤害。
参数说明:damage * 5.0里的 5.0 是奖励缩放系数。奖励绝对值过大会让 Q 值网络输出不稳定,过小会被探索噪声淹没。我的经验是让单发命中的奖励落在 0.5 到 2.0 区间,观察几轮训练后 reward 的平均值再微调系数。shot_total_miss是一个布尔标志,需要在仿真器里检测小鸟发射后有没有和任何物体发生碰撞,取巧的做法是看小鸟速度有没有显著下降。
奖励塑形是一把双刃剑:加太多辅助信号,智能体可能学会"为了拿摧毁奖励而乱打",却不会去瞄准猪。我见过一个训练结果,智能体学会只打最近的一块木块,因为那是每发都能稳定拿 1 分的最短路径。解决办法是把摧毁奖励的系数调低,或者只在摧毁的物体是"结构性关键方块"(比如支撑塔最底层的木块)时才给奖励。这个尺度的把控,基本就是看训练曲线反复调出来的,没有一劳永逸的参数组合。
4. 训练 DQN 打小鸟:完整训练循环与关键参数
环境封装好了,智能体的框架在第 2 章也搭好了,这一章把两者接起来:完整的训练主循环、四组关键超参数的调法、以及训练曲线的读法。
4.1 训练主循环的代码结构与参数说明
训练主循环的逻辑是:每回合重置环境——智能体根据当前状态选一个离散动作——动作映射成角度和力度——仿真器模拟弹射并返回奖励和新状态——存入回放缓冲区——每 N 步调用一次 Agent.update()。
env = BirdEnv() # 自建仿真环境 agent = Agent(state_dim=42, action_dim=12, lr=1e-3, gamma=0.99, batch_size=64, target_update=1000) epsilon_start, epsilon_end, epsilon_decay = 1.0, 0.05, 5000 episodes = 800 step_counter = 0 episode_rewards = [] for ep in range(episodes): state, done = env.reset(), False ep_reward = 0.0 steps_this_episode = 0 # 每回合最多发射 5 只鸟 while not done and steps_this_episode < 5: # epsilon 按训练步数衰减 epsilon = epsilon_end + (epsilon_start - epsilon_end) * \ np.exp(-step_counter / epsilon_decay) action = agent.act(state, epsilon) # 动作映射成 (angle, power) angle, power = env.action_to_params(action) next_state, reward, done = env.step(angle, power) # 存储转移,step 计数递增 agent.buffer.push(state, action, reward, next_state, done) state = next_state ep_reward += reward step_counter += 1 steps_this_episode += 1 # 每 4 步训练一次,提升训练频率 if step_counter % 4 == 0: loss = agent.update() episode_rewards.append(ep_reward) if (ep + 1) % 50 == 0: avg = np.mean(episode_rewards[-50:]) print(f"Episode {ep+1}, AvgReward={avg:.2f}, Epsilon={epsilon:.3f}, " f"Loss={loss:.4f}")逻辑说明:外层循环跑 800 个回合,每回合发射上限 5 只鸟(对应原版一关的鸟数)。epsilon按指数衰减公式从 1.0 降到 0.05,衰减速度由epsilon_decay控制。每 4 步调用一次agent.update(),这个频率是经验值——每步都更新的话,相邻更新之间的样本高度相关,且训练开销大;每 4 步更新能在样本利用率和训练稳定性之间取个平衡。
参数说明:state_dim=42对应第 3 章的最大 8 个物体 × 5 维 + 弹弓 2 维 = 42,如果你的向量表示不同,这个数字要跟着改——改了环境忘了改维度,是新手最常见的报错来源。action_dim=12对应 3 个角度 × 4 档力度。angle, power = env.action_to_params(action)是环境提供的映射函数,比如 action 0 映射到 (30°, 50%),这个映射关系要提前定好并打印出来检查,我踩过 action 索引写错导致智能体永远在试同一个角度的坑。
4.2 超参数怎么调:学习率、探索率衰减、批大小
超参数不是一个固定组合,而是跟着训练表现走。下面是四组参数的作用范围和我的调参习惯,用表格列出来方便对照:
| 参数 | 常见范围 | 作用 | 调参信号 |
|---|---|---|---|
| learning_rate | 1e-4 ~ 3e-3 | 控制梯度更新步长 | loss 震荡剧烈则调小;loss 不降则调大 |
| batch_size | 32 ~ 128 | 每次更新的样本量 | 太小梯度噪声大,太大训练慢且易过拟合近期样本 |
| gamma | 0.9 ~ 0.99 | 未来奖励的折扣 | 奖励延迟明显时调大;只看短期伤害时调小 |
| epsilon_decay | 3000 ~ 10000 | 探索衰减速度 | 前期学不动就放慢衰减,让智能体多试 |
学习率是最先要确认的。我在第 2 章的 Agent 里给的是 1e-3,这个值配合 Adam 优化器在大多数小规模强化学习任务里能跑,但如果你发现 loss 曲线像锯齿一样上下乱跳,先别怀疑代码 bug,把 lr 降到 3e-4 再看。愤怒的小鸟的奖励是事件驱动的(只有撞击时才返回非零奖励),梯度本身就稀疏,过大的学习率会让网络参数在每次非零奖励时被推过头。
探索率衰减是我最常调整的参数。epsilon_decay=5000意味着大约 5000 步之后 epsilon 降到 1/e 附近,如果一局平均 30 步,就是约 170 局之后探索率只剩三分之一。如果前期智能体总是乱打,你该做的是把epsilon_decay拉大到 10000,让它在更长的训练时间内保持高探索。反过来,如果训练后期智能体还在频繁随机动作、导致成绩不稳定,就把epsilon_end从 0.05 降到 0.01。
批大小的选择有个实际约束:你自建的环境单步仿真很快,但碰撞检测如果写得低效,batch_size 越大每次等待采样数据的时间越长。64 是稳妥值,32 能让训练更频繁地更新(因为采集 32 条样本比 64 条快),但梯度噪声更大。我一般先试 64,看 GPU/CPU 利用率决定要不要调。
4.3 训练过程的监控:loss、奖励曲线到底在看什么
训练不盯着曲线就等于瞎调参。我在第 4.1 的代码里每 50 回合打印一次平均奖励,这是最基本的监控。除此之外两个信号必须看:loss 的形态和奖励曲线的趋势。
loss 在 DQN 里不等于模型性能。我见过很多人看到 loss 降到 0.001 以为模型学好了,实际上是因为 epsilon 衰减到很低、智能体总是在重复同样的动作,Q_target 和 Q_current 都很稳定,loss 低只是因为"不学了"。反过来,loss 周期性升高不一定是坏事——可能是智能体探索到了新策略,Q 值需要重新调整。所以判断训练好坏的核心指标是平均奖励,不是 loss。
奖励曲线的读法要分段看。训练初期(前 100 回合),平均奖励应该在低位波动,因为 epsilon 高、动作基本随机,偶尔蒙中一次伤害会让点拔高。中期(100 到 400 回合),平均奖励应该有一个明显的上升台阶,这代表智能体学会了瞄准和发射力度控制。后期(400 回合以后),曲线会进入平台期,波动变小。如果 400 回合还没看到上升趋势,问题大概率不在 DQN 算法本身,而在环境接口或奖励定义——回到第 3 章去检查状态向量有没有传对、奖励有没有被 NaN 污染。
额外建议在训练时把每一次弹射的角度、力度、造成的伤害记录到 CSV 文件里。这样训练完你能分析出智能体偏好哪些角度档位,如果它把 90% 的弹射都集中在某一个角度,说明动作坍缩了,下一章的避坑指南会专门讲这个现象。
5. DQN 训练愤怒的小鸟避坑指南:5 个典型翻车现场
跑 DQN 打小鸟,最有价值的部分不是看到它学会通关,而是搞清楚它为什么学不会。这一章列五个我反复踩过的坑,每条按「现象 → 原因 → 解决」写清楚,照着排查能省你至少两天时间。
5.1 现象一:loss 降了但平均奖励纹丝不动
我见过最迷惑的情况:每 50 回合打印的 loss 从 2.3 一路降到 0.05,曲线漂亮得像教科书,但平均奖励始终在 0 附近徘徊。当时第一反应是奖励计算出了 bug,排查半天发现没有。
原因是奖励尺度的问题。如果奖励绝对值很小(比如单发命中只有 0.01),Q 值网络的输出会逐渐被压到接近 0,loss 当然小,但梯度对策略的引导作用微乎其微。另一种情况是奖励的方差太大:偶尔一次打出 50 分的暴击,其余全是 0,这种情况下 loss 的均值被少数大样本支配,网络学会了输出一个"中庸"的 Q 值,对应策略就是平庸地乱打。
解决方法是先调奖励缩放系数,让单发事件的奖励落在 0.5 到 2.0 区间;其次检查奖励分布,打印最近 100 次弹射的奖励值和方差,如果方差比均值大一个数量级,考虑对奖励做 clip 或取 log。还有个很实用的检查手段:用手动策略(固定 45°、80% 力度)跑 20 回合,如果手动策略的平均奖励比训练好的 DQN 高,说明是训练问题不是环境问题。
5.2 现象二:训练到一半奖励突然崩盘
这是一个更恼人的状况:训练前 200 回合平均奖励稳定上升,到了第 250 回合左右突然掉回负数,之后再也上不来。第一次遇到我还以为是环境随机性,重跑一次还是在类似位置崩盘。
原因是 Q 值过估计(overestimation)积累到一定程度后的集中爆发。DQN 的 max 操作天然会高估 Q 值,当某个状态的高估误差被回放缓冲区里的大量样本反复放大,网络会对一些"看起来好"的动作给出虚高的 Q 值,策略被带偏。目标网络虽然是缓解手段,但如果target_update太频繁(比如每 100 步同步一次),目标网络和 eval_net 差异不大,缓解效果就大打折扣。
解决方法是先降低目标网络更新频率,从 1000 步改成 2000 步甚至 5000 步;如果还是崩,就是 Double DQN——用 eval_net 选动作、target_net 算 Q 值,把选动作和估值分开,这个改动在代码里只加两行。再不行就调小学习率,给网络更保守的更新步伐。我最后用的是 Double DQN + 2000 步目标更新,崩盘问题再没出现过。
5.3 现象三:智能体只会往一个方向弹
训练结束后观察智能体的表现,发现它 90% 的弹射都是同一个角度、同一档力度,哪怕第一发已经没打中,第二发还是原样重来。从奖励曲线上看,它确实拿到了一些奖励,说明不是完全没学,只是策略塌缩到了一个次优解。
原因有两层:一是动作空间里某些动作的组合(比如 45°、80%)天然有更高的概率打中目标,而其他动作初始探索阶段命中率低,经验回放里这些"好动作"的样本占了多数,网络就被带偏了;二是 epsilon 衰减太快,智能体还没充分探索就把探索率降得很低,导致它没发现其他动作其实也能打。
解决方法是先给"探索"留出更长的时间:把epsilon_decay从 5000 提到 15000,让前 400 回合都保持较高的探索率。其次检查动作映射,看看是不是某些动作组合在物理上根本不可行(比如 20° 配 30% 力度根本飞不到任何物体),把这些废动作从动作空间里删掉。我用过一个取巧的技巧:在前 100 回合强制给动作加均匀分布的随机噪声,不管 epsilon 多少,这样能保证每个动作在训练早期都被试够次数。
5.4 现象四:环境重置太慢,训练一天没效果
自建 PyGame 环境的帧率问题很隐蔽。弹射后的物理仿真如果每帧都要渲染画面,训练速度会慢到让人怀疑人生。我跑过一个带完整渲染的环境,一局要 3 秒,训练 800 局就是 40 分钟,还不算中间渲染的消耗。
原因是把渲染和仿真耦合在一起了。DQN 训练根本不需要渲染画面(除非你的状态表示是像素),纯物理仿真用numpy计算每帧的位移和碰撞检测,速度比 PyGame 渲染快几个数量级。
解决方法是把环境改成 headless 模式:没有渲染调用,只有状态更新;如果一定要用 PyGame,把画面渲染放到独立的线程里,或者只在save_screenshot = True时渲染。另一个方法是向量化环境——同时跑多个简单关卡,每个关卡是一个独立环境实例,一次训练步同时采集多条经验,样本效率直接翻倍。我用 4 个并行环境训练,同样 800 局的时间缩短到了原来的四分之一,效果完全一致。
5.5 现象五:模型在训练场景能打,换个关卡就废
训练时用的关卡布局是固定的几块木块加两只猪,智能体练到平均 15 分,但把场景里木块位置一换,平均奖励直接跌到 3 分。这是典型的过拟合。
原因是状态表示里没有"布局"的泛化信号。固定布局下,每个物体的绝对坐标是固定的,网络完全可以记住"坐标 (120, 240) 有猪,往那打就行",而不需要学会理解"猪在木块后面,要先打支撑"。你看到智能体打得好,它记住的其实是坐标表,不是策略。
解决方法是做关卡随机化:每次 reset 时随机微调物体位置、数量、类型组合。我常用的做法是把物体位置在一定范围内随机抖动,并随机删掉 1 到 2 个无关木块。同时,在 state 里不要用绝对坐标,改成相对弹弓的偏移——(x - slingshot_x, y - slingshot_y),这样网络学到的是"目标在我右上方,角度应该大一些"这类可迁移的关系。做完这两步,换关卡的性能衰减从 80% 降到 30% 以内。
6. 从打小鸟到打一切:把 DQN 迁移到其他游戏的三步验证法
练到你能让 DQN 稳定打通简化版愤怒的小鸟关卡,这套代码就成了你自己的强化学习实验台,换游戏只是换环境接口和奖励函数的事。我常用的迁移流程是三步验证,每步都能快速定位问题是出在环境还是出在算法。
第一步,验证环境接口的合理性。新游戏的环境必须返回 (state, action_space, reward, done) 四件套,且 state 不能包含未来信息(否则就是作弊)。我会先写一个随机策略跑 100 回合,打印奖励的分布范围和回合结束条件——如果随机策略平均奖励已经是正数,说明奖励设计太慷慨,DQN 学出来的策略不一定比随机好多少,这样的环境没有验证价值。
第二步,验证单步学习有效。把新环境接到第 2 章的 Agent 上,关掉 epsilon 衰减(固定在 0.1),用固定布局训练 200 回合,看平均奖励能否超过随机策略。这一步只验证你的代码链路有没有断——奖励有没有传对、done 有没有及时触发、状态维度有没有匹配。如果随机策略 50 分、训练后还是 50 分,几乎肯定是环境状态里没有包含做出决策所需的信息。
第三步,逐步增加难度。固定布局跑通后,加入关卡随机化,再加连续事件的挑战(比如移动目标、风场干扰),每加一层难度就重跑一次完整训练,对比平均奖励的变化趋势。如果新难度下奖励曲线完全不上扬,优先检查是不是奖励信号被新机制稀释了,而不是急着改网络结构。
我自己的习惯是每次迁移前先打印一行环境摘要——状态维度、动作数量、奖励范围、回合平均步数,这些数字能让我在训练前就对"要不要调 epsilon_decay"有个预判,而不是等 200 回合跑了才发现参数不合适。这套流程我从愤怒的小鸟迁到过两个不同的物理堆叠游戏,每次都是三步走,成功率比我以前随缘调参高得多。
最后说一个教训:我最早做这个项目时,花了整整两周想用卷积网络直接读像素打小鸟,结果一直被训练不稳定折磨,后来退回到物体级向量表示,一天就把问题解决了。不是卷积网络不行,而是像素级方案需要更多的调参耐心和更长的训练预算。如果你是为了理解 DQN 而不是为了发论文,我强烈建议先用物体级向量跑通全流程,等拿到稳定成绩再考虑升级状态表示。希望这篇笔记能帮你在 AI 玩游戏这个方向上少走几天弯路,把你自己的 DQN 实验台搭起来。
本文还有配套的精品资源,点击获取