简介:这套项目结合深度Q网络与TensorFlow框架,实现人工智能在“愤怒的小鸟”中自动调整发射角度与力度,面向具备Python基础、希望入门深度强化学习的开发者和游戏AI爱好者,提供了从环境交互、数据预处理到网络训练的完整工程示例。压缩包共54个文件,大小约23.54兆字节,包含Python源码、TensorFlow模型检查点与预训练网络权重,同时带有十八张流程示意图、一张运行演示动图和若干音频素材及配置文件,目录结构清晰,便于对照程序理解深度Q网络的完整训练流程。该项目已有1646人学习下载,在游戏人工智能入门案例中有较高参考价值。读者可以借助在线网络与目标网络、经验回放以及贪婪探索策略等关键模块,掌握从游戏状态输入到动作决策的闭环机制,并利用预训练模型快速观察训练效果,为后续改造或调优提供可直接落地的起点。
1. DQN 玩愤怒的小鸟:这份资源到底能让你跑通什么?
用 Python 和 TensorFlow 写一个 DQN 去玩愤怒的小鸟,听起来像玩具项目,但真把训练跑通后,你会发现这个项目把强化学习的核心流程全占了:状态编码、动作映射、奖励设计、经验回放。这份资源不是那种只给公式的讲解,而是可以落地运行的最小工程,它把愤怒的小鸟拆成一个离散动作的强化学习环境,再用 DQN 训练出一个会主动调整角度和力度的智能体。如果你刚跑通 CartPole,想找一个比它更有直观反馈、又比 Atari 工程负担小的项目,这堆代码正好卡在中间档位。适合需要快速复现 RL 实验的从业者,也适合拿来做课程设计框架的学生。
2. 环境与交互设计:把物理游戏变成强化学习黑匣子
2.1 为什么选 DQN:从 Q-Learning 到深度网络的边界
Q-Learning 的表格式实现只能应对几百个状态的小环境。愤怒的小鸟每一帧都是 480x320 甚至更大的画面,如果把每个像素组合看成状态,状态数比宇宙原子还多,Q 表完全放不下。DQN 的思路是用神经网络去近似 Q(s,a),输入是连续的状态,输出是每个离散动作的 Q 值,这样只要网络容量够,就可以承载高维视觉状态。这个项目里没有用 PPO 或 DDPG,原因很简单:模拟器是物理引擎,不可微,而 DQN 天然是 model-free,不需要环境梯度;同时动作本身是离散的,发射力度和角度可以被离散化成档位,DQN 直接输出各档位的 Q 值就够了。
另一个选型考虑是稳定性和调参成本。PPO 调起来 clip 系数、GAE lambda、学习率三个参数互相牵制,对视频游戏这种奖励延迟的环境,早期非常容易翻车。DQN 的参数套路比较固定:学习率、batch size、epsilon 衰减、目标网络更新频率,网上有大量可参考的取值,出问题时也容易定位。你要是想快速验证「DQN 能不能学会这游戏」,这份资源就是冲这个目的去的。
2.2 游戏环境封装:截屏、状态表示与动作离散化
把游戏变成强化学习环境,核心就是让训练循环只认识 reset 和 step。reset 负责开始新一局,返回初始状态;step 接收一个动作编号,执行发射并模拟一局,然后返回下一状态、奖励和是否结束。这一步不处理好,后面训练代码写得再漂亮也跑不动。我拆这份代码时最深的感受是:环境封装才是整个项目的黑匣子,网络结构反而是透明的。
状态表示上,常见做法是从屏幕里截取游戏主区域,先转灰度图,再缩放到 84x84。为什么不直接保留 RGB?因为颜色对「是否可以击中目标」几乎没有帮助,灰度图能砍掉三分之二输入维度,训练速度明显加快。为了表现小鸟位置和速度的变化,会把最近 4 帧堆叠在一起,相当于给网络一个迷你运动轨迹。
动作离散化则需要结合弹弓的物理特性。原始操作是鼠标拖拽,力度和角度都是连续的,直接建模成连续动作会让 DQN 失去优势。这里把拉弓力度分成 3 档,角度分成 5 档,组合成 15 个动作。3 档力度的意思是「轻、中、重」,5 档角度在 -45 到 45 度之间均匀分布。这样动作空间足够细,又不至于让 argmax 在相似动作之间反复横跳。如果你想快速验证网络能学会,先把动作数压到 5 个,等平均奖励稳定了再逐步放开。
提示:动作空间初始设计宁粗勿细,15 个动作已经是我认为的上限,再细分到 30 个以上,收敛时间会成倍变长。
2.3 搭建最小可运行环境的核心代码
下面这段是我按这个项目思路整理的环境简化版,去掉了物理模拟细节,只保留训练循环需要的接口:
import cv2 import numpy as np import pygame from collections import deque class AngryBirdEnv: """把愤怒的小鸟交互封装成 DQN 需要的 step 接口""" def __init__(self, action_space_size=15, frame_stack=4): pygame.init() self.screen = pygame.display.set_mode((480, 320)) self.frame_stack = frame_stack self.frames = deque(maxlen=frame_stack) self.actions = self._build_actions(action_space_size) def _build_actions(self, size): # 3 档力度 x 5 档角度,生成 (force, angle) 动作列表 actions = [] for i in range(size): force = 0.3 + 0.7 * (i % 3) / 2.0 angle = -45 + 15 * (i // 3) actions.append((force, angle)) return actions def reset(self): self._new_episode() state = self._preprocess(self._grab_screen()) for _ in range(self.frame_stack): self.frames.append(state) return np.stack(self.frames, axis=-1) def step(self, action_index): force, angle = self.actions[action_index] self._launch(force, angle) reward, done = self._simulate_until_done() next_state = self._preprocess(self._grab_screen()) self.frames.append(next_state) return np.stack(self.frames, axis=-1), reward, done def _preprocess(self, raw_image): gray = cv2.cvtColor(raw_image, cv2.COLOR_RGB2GRAY) resized = cv2.resize(gray, (84, 84), interpolation=cv2.INTER_AREA) return resized.astype(np.float32) / 255.0 def _new_episode(self): # 重置游戏场景,开始新的一局 pass def _launch(self, force, angle): # 模拟弹弓发射,设置力度和角度 pass def _simulate_until_done(self): # 运行物理模拟,直到所有小鸟发射完毕,返回累计奖励和是否结束 pass def _grab_screen(self): # 从 pygame 窗口截取当前游戏画面 return pygame.surfarray.array3d(self.screen)这段代码把游戏流程收敛成 reset 和 step 两个入口,DQN 训练循环不需要关心弹弓怎么拉。_build_actions 里第 13 行是动作生成的关键:i % 3 控制力度档位,i // 3 控制角度档位,这样能把 15 个动作均匀铺开。如果之后把 action_space_size 改成 5,循环会自动截断到前 5 个组合,不需要改其他代码。_preprocess 里有个容易忽略的细节:缩放到 84x84 前先转灰度,再把像素值除以 255 转成 float32,这一步能避免输入范围过大导致网络梯度波动。如果省略归一化,卷积层会在前几百步里产生非常大的 loss,这是很多新手第一次跑 DQN 翻车的原因。
frame_stack 用 deque(maxlen=4) 实现,step 里先取下一帧,再 append 到队列,然后 stack 成 (84, 84, 4) 的状态。这个形状和后面网络输入层必须严格一致,一旦写成 (4, 84, 84) 或忘记堆叠,网络会直接报维度错,或者更隐蔽地出现「看起来在训练但实际输入全是单帧重复」的问题。我一般会在 _preprocess 输出后用 assert state.shape == (84, 84, 4) 检查一次,尽早暴露问题。
如果你手头没有 pygame 模拟器,也可以把 _grab_screen 改成对真实游戏窗口截屏,用 win32 或 mss 库定时抓取固定区域。但这样会引入窗口遮挡、分辨率和屏幕刷新率三种额外噪声,我建议先跑通模拟器,确认训练逻辑没问题,再去接真实截屏源。
3. 网络结构与训练循环:参数设在哪、模型怎么收敛
3.1 网络结构与 TensorFlow 实现
输入状态是 84x84x4,输出是 15 个动作的 Q 值。网络结构不需要很复杂,两层卷积加一层全连接就能跑起来。第一个卷积核 8x8 步长 4,第二个 4x4 步长 2,第三个 3x3 步长 1,这是从 Atari DQN 原版缩出来的结构。为什么会这么设?因为 84x84 经过步长 4、2、1 作用后,特征图能稳定缩到很小,同时卷积核在不同尺度上既能捕捉小鸟轮廓,也能捕捉远处目标区域。
下面是构建网络的核心代码:
import tensorflow as tf def build_q_network(state_shape=(84, 84, 4), num_actions=15): inputs = tf.keras.Input(shape=state_shape) x = tf.keras.layers.Conv2D(32, 8, strides=4, activation='relu')(inputs) x = tf.keras.layers.Conv2D(64, 4, strides=2, activation='relu')(x) x = tf.keras.layers.Conv2D(64, 3, strides=1, activation='relu')(x) x = tf.keras.layers.Flatten()(x) x = tf.keras.layers.Dense(256, activation='relu')(x) outputs = tf.keras.layers.Dense(num_actions)(x) return tf.keras.Model(inputs, outputs)输出层不加激活函数,因为 Q 值的回归目标本身就不在有限区间内,如果加上 relu 或 sigmoid,等于强行限制输出范围,模型很难收敛到头。Dense(256) 也是可调的,我试过 128 和 512,128 会显得欠拟合,平均奖励在低档位徘徊;512 提升不明显,反而训练步数变长,所以 256 算是性价比比较高的中间档。
注意网络输入张量是 (batch, 84, 84, 4),batch 维度放在最前面。如果你习惯 PyTorch 的通道在前,用 TensorFlow 时会容易把维度顺序搞混。这里用 tf.keras 的默认 channels_last 格式,和 pygame 截屏返回的 HWC 顺序天然一致,不需要做转置。
3.2 经验回放与目标网络:训练稳定的关键
DQN 的样本来自同一局游戏的连续帧,如果不打破时间相关性,网络会被近期数据带偏,学到上一局的局部策略,下一局稍微变一下又忘掉。经验回放就是存一个大缓冲区,训练时随机抽一批历史样本,让每次梯度更新的分布尽量均匀。目标网络的作用则是避免「自己追自己」:如果用当前网络直接计算 Q 目标,会存在一个自举问题——每次更新目标也跟着变,像追着自己尾巴跑,容易出现发散。
经验回放缓冲区实现如下:
import random import numpy as np from collections import deque class ReplayBuffer: def __init__(self, capacity=20000): 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 个样本,按训练网络需要的格式打包 batch = random.sample(self.buffer, batch_size) states, actions, rewards, next_states, dones = zip(*batch) return (np.stack(states).astype(np.float32), np.array(actions, dtype=np.int32), np.array(rewards, dtype=np.float32), np.stack(next_states).astype(np.float32), np.array(dones, dtype=np.float32)) def __len__(self): return len(self.buffer)capacity 设 20000 在这个项目里够用。如果缓冲区太小,采样到的样本仍然高度相关,loss 曲线会像锯齿一样剧烈抖动;如果太大,早期被淘汰的劣质样本长期占用空间,模型学了半天还在被旧数据拖后腿。抽样用 random.sample 就行了,batch 32 个样本的随机抽取成本可以忽略。
目标网络更新采用硬更新,每 200 步把主网络的权重整体复制过去:
target_net = build_q_network() target_net.set_weights(q_net.get_weights()) def hard_update_target(step, update_freq=200): if step % update_freq == 0: target_net.set_weights(q_net.get_weights())这个 200 是我试过几个取值后定下的。设太小比如 50,目标网络跟得太紧,相当于没有稳定的 target,训练初期的 Q 值会来回震荡;设太大比如 1000,目标网络太陈旧,更新方向容易滞后。实际表现就是平均奖励曲线在某个区间卡住不动,过很久才突然跳变。你可以把它当成一个可调的更新频率参数,不需要改网络结构。
3.3 训练循环与超参数设置
训练循环是整份资源的骨架。先看整体流程,再逐个调参数:
env = AngryBirdEnv(action_space_size=15) q_net = build_q_network() buffer = ReplayBuffer(capacity=20000) optimizer = tf.keras.optimizers.Adam(learning_rate=1e-4) gamma = 0.99 epsilon = 1.0 epsilon_min = 0.05 decay_steps = 20000 batch_size = 32 total_steps = 100000 for step in range(1, total_steps + 1): state = env.reset() done = False episode_reward = 0 while not done: if random.random() < epsilon: action = random.randrange(env.action_space_size) else: q_values = q_net(state[np.newaxis, ...], training=False) action = int(tf.argmax(q_values[0]).numpy()) next_state, reward, done = env.step(action) buffer.push(state, action, reward, next_state, done) state = next_state episode_reward += reward epsilon = max(epsilon_min, epsilon - (1.0 - epsilon_min) / decay_steps) if len(buffer) >= batch_size: train_step(buffer, q_net, target_net, optimizer, gamma, batch_size) if step % 200 == 0: hard_update_target(step, update_freq=200) if done: recent_rewards.append(episode_reward)这里的几个超参数我给出参照,注意不要盲抄。gamma=0.99 表示未来奖励的折扣,值越大模型越看重长期结果,但也会让梯度在时间轴上拖得更长,训练初期不稳定。epsilon 从 1.0 线性衰减到 0.05,前 20000 步主要靠随机探索积累样本,之后逐步切换到利用学到的策略。decay_steps 太短会让模型还没见过足够多样的局面就停止探索,太长会让学生一直随机乱飞。
学习率这里我用 1e-4,比常规分类任务低一个量级。DQN 是自举训练,目标本身在变,学习率稍大一点就容易震荡。如果你发现 loss 稳定但不下降,试着把学习率调到 5e-5;如果训练曲线明显发散,先检查奖励裁剪和状态归一化,再考虑降学习率。
train_step 函数是每次梯度更新的核心:
def train_step(buffer, q_net, target_net, optimizer, gamma, batch_size): states, actions, rewards, next_states, dones = buffer.sample(batch_size) with tf.GradientTape() as tape: q_values = q_net(states, training=True) selected_q = tf.reduce_sum(q_values * tf.one_hot(actions, q_values.shape[-1]), axis=1) next_q = tf.reduce_max(target_net(next_states), axis=1) td_target = rewards + gamma * next_q * (1 - dones) loss = tf.reduce_mean(tf.square(td_target - selected_q)) grads = tape.gradient(loss, q_net.trainable_variables) optimizer.apply_gradients(zip(grads, q_net.trainable_variables))这段代码里,one_hot 会把动作索引变成 mask,乘以 q_values 后取 sum,等价于把当前动作对应的 Q 值挑出来。td_target 是目标值,其中 next_q 取自 target_net 而不是 q_net,这是 DQN 稳定训练的关键。如果这里错写成 q_net,训练几十步后 Q 值就可能无限膨胀。dones 通过 (1 - dones) 把终局样本的后续 Q 值置零,否则模型会错误地把一局结束后的估计值也算进去。
3.4 评估指标:用平均奖励而不是主观观察判断收敛
训练过程中最迷惑的事情是:单局奖励波动很大,有时候连续 10 局都是 0 分,突然一局 300 分,你根本不知道模型到底有没有变强。正确做法是维护一个滑动窗口,记录最近 100 局完整 episode 的奖励总和,然后打印平均值。这个平均值比单局结果稳定得多,适合用来判断训练是否进入平台期。
recent_rewards = deque(maxlen=100) # 每个 episode 结束后更新 if recent_rewards: avg = sum(recent_rewards) / len(recent_rewards) print(f"step={step} avg_100={avg:.2f} eps={epsilon:.3f}")这里用 sum 而不是 np.mean 没区别,但要注意 recent_rewards 是 deque 对象,直接 sum 求和不会弹出数据。很多人在这里踩坑:把 recent_rewards.clear() 每次聚合后清掉,导致滑动窗口永远只有一局。另一种常见错误是拿训练过程的 epsilon 轨迹里的奖励做评估,因为训练时探索噪声还在,即便模型已经学会,也会因为随机动作拉低平均分。我通常在每 5000 步固定做一次 20 局 greedy 评估:把 epsilon 设成 0.05,只从 Q 值最大的动作走,得到的平均奖励才是真实策略水平。
4. 避坑指南:把常见翻车点一次说清
4.1 状态预处理不对,网络永远学不懂
现象:训练了好几万步,loss 虽然一路走低,但滑动窗口里的平均奖励仍然是 0,打开模拟器看发射表现,小鸟每次都往同一个方向飞,完全没有调整的意思。这个问题在 DQN 项目里非常典型,也是最让新手困惑的「看起来在学,实际没学」状态。
原因:输入状态没有统一。最常见是直接把 RGB 原图丢进网络,像素范围 0-255,卷积层初始权重在中值附近,输入尺度相差几十倍,梯度更新被高数值像素主导。另一个常见问题是截屏区域包含了计分板、云朵、操作按钮等静止元素,模型花大量参数去拟合背景,真正的小鸟和目标只占输入的一小块区域。
解决:先确定一个固定的截屏区域,只框住弹弓到建筑群这一块,再转灰度、缩放到 84x84、归一化到 0-1。我一般会在 preprocess 函数里加一个断言,检查输出张量的形状和值范围,一旦异常直接定位。如果你用的是 pygame 模拟器,还要把窗口模式固定成非全屏并关闭自动缩放,否则窗口尺寸一变,截屏区域也跟着乱。
4.2 奖励太稀疏,模型完全没有反馈信号
现象:智能体像无头苍蝇,每局都只发射一次就结束,偶尔命中一次也看不出策略连续性。如果打印每个 episode 的 reward 列表,你会发现 95% 以上都是 0 或很小的负分,模型尝试了很多次也找不到稳定的正向反馈。
原因:这个游戏的默认计分方式下,大多数动作得到 0 分,只有砸中目标才给正奖励。DQN 是靠奖励信号做引导的,如果 99% 的样本奖励为 0,Q 值会被压到一个极端值,探索效率极低。更麻烦的是,愤怒的小鸟本身是物理模拟,动作与结果之间存在几十帧的延迟,模型很难把「这次发射」和「最终奖励」关联起来。
解决:我给每个非终止的动作加一个小的时间惩罚,比如 -0.01,让模型倾向于快速结束无效回合。更有效的是引入中间奖励:根据小鸟落地位置与最近目标的距离,给一个负奖励;如果距离比上一帧更近,给一个小正奖励。中间奖励的幅度要控制在正奖励总量的 10%~20%,太大模型会变成「只追距离,不管击杀」,最后奖励曲线看着上升,实际打靶表现却很拉胯。这个比例需要实际试,建议用 5000 步做一次快速对照实验再确认。
4.3 动作空间设太细,弹弓来回抖
现象:训练后期平均奖励不再上升,观察发射画面发现小鸟的力度和角度在两个相近档位之间反复切换,像在犹豫。回放数据还会发现,连续几步的动作索引经常是相邻值,比如动作 7 和动作 8 之间来回跳。
原因:角度步长太小,相邻动作的 Q 值很接近,argmax 的取舍会被微小噪声决定。这个问题在低帧率模拟里尤其明显,同一力度差一点,物理结果可能完全不同,网络不知道该押哪边。还有可能是 epsilon 衰减过快,模型过早进入利用阶段,还没把不同角度的效果摸清楚就开始贪心。
解决:把动作空间从 15 个先降成 5 个,力度分三档但角度只用 3 档,等模型稳定后,再把角度细分回去,用上一轮训练好的网络做初始化继续训练。你可以在 _build_actions 里把动作数量做成参数,这样对比实验就是改一个数字的事,不用改环境代码。另外,结束后记录一下动作分布,如果发现大量样本集中在某两个相邻动作,基本就能确定是动作空间设计问题。
4.4 loss 突然变成 NaN,整个模型废掉
现象:训练几万步一切正常,某次更新后 loss 打印出 nan,之后所有预测值都变成 nan,重启也没用。更讨厌的是这种问题不是每次都能稳定复现,有时候重启后又能跑很久。
原因:最常见是梯度爆炸。DQN 的 TD target 本身就由网络预测生成,如果 reward 范围没有被裁剪,或某些状态帧损坏,一次极端的大梯度就能把权重冲散。另一个来源是经验回放缓冲区里混入了坏数据:比如 pygame 窗口在渲染过程中被其他程序遮挡,截屏取到不完整的画面,这部分样本里的像素值会出现 0 或 255 的异常分布。
解决:第一,对奖励做裁剪,每个动作的 reward 控制在 [-1, 1],episode 总奖励不受限制,这样单步梯度不会过度放大。第二,在 Optimizer 上用 clipnorm:
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-4, clipnorm=10.0)clipnorm=10.0 表示梯度的 L2 范数超过 10 就按 10 缩放,这是比较宽松的裁剪策略,能挡住灾难性梯度,又不会影响正常收敛。第三,在 step 函数里对截屏结果做维度检查,如果出现 None 或者 shape 不对,直接重新抓取三次。如果问题仍然存在,可以把 MSE loss 换成 Huber loss,它对离群点更不敏感。
4.5 复现结果差异巨大,同一份代码两种结局
现象:同一份代码,同事机器上训练 3 万步就收敛,自己机器上跑 8 万步还在随机探索。或者同一台机器连续跑两次,一次平均奖励能到 200,另一次 20 都不到,怎么看都像玄学。
原因:除了随机种子没固定,最隐蔽的是 pygame 模拟器的物理帧率和训练循环没有解耦。不同 CPU 性能下,每局游戏的模拟时钟快慢不同,导致同样的 action_index 得到的物理轨迹不一样,模型学到的状态和动作关系被帧率干扰。还有一个间接影响:tensorflow 在 CPU 和 GPU 上的默认并行策略不同,同样 batch size 下的训练节奏也会有差异。
解决:让游戏循环按固定的物理步长推进,比如每 1/60 秒推进一次模拟,训练循环不直接控制帧率,只在固定时间步采集状态。同时在代码入口部分固定 Python 随机种子、numpy 随机种子、TensorFlow 随机种子。注意别只固定一个,三者的随机源是独立的,缺一个都会让结果可复现性变差。最后,跑对比实验时至少用三个随机种子各跑一遍,画平均曲线,不要拿单次结果说事。
5. 进阶技巧:用 Double DQN 和优先经验回放提升上限
普通 DQN 在计算 TD target 时用了 target_net 的最大 Q 值,这个 max 操作会带来系统性高估。愤怒的小鸟动作空间虽然只有 15,但高估会影响 argmax 动作选择,后期策略会变得犹豫。Double DQN 的改法很轻:用当前网络选动作,用目标网络给出这个动作的 Q 值。
# 普通 DQN next_q = tf.reduce_max(target_net(next_states), axis=1) # Double DQN:当前网络挑动作,目标网络打分 next_actions = tf.argmax(q_net(next_states), axis=1) next_q = tf.gather_nd(target_net(next_states), tf.stack([tf.range(batch_size), next_actions], axis=1))代码改动就两行,但实验里可以让 average_100 曲线下降得更晚、平台期更高。原因是把动作选择和 Q 值评估分离了,抑制了高估偏差。我一般会在 baseline 跑通后,立刻把训练循环改成这两行,再跑一组对照,看曲线是否整体上移。
优先经验回放则是对样本利用率做优化。普通随机采样把失败帧和命中帧平等对待,实际上那些 TD error 大的样本更值得学习。简化实现是给每个样本存一个 priority,采样时按 priority 做加权随机。操作步骤:push 时 priority 初始化成最大,每次 train_step 后把当前样本 priority 更新为该样本的 TD error 绝对值,采样时用 random.choices 的 weights 参数传入。这个改动比 Double DQN 略复杂,但在我试过的项目里收益很稳定,尤其是在奖励稀疏的游戏里。
验证建议:固定同一个随机种子,跑三组实验,baseline DQN、Double DQN、Double DQN + 优先回放,每 5000 步记录一次 greedy 评估平均奖励,最后画出三条曲线对比。这份资源如果没有自带训练好的权重,你可以用同一份代码自行产出权重,再用不同结构的网络调整网络初始化。加载权重前先打印 model.summary() 检查输入层形状,再加载 h5 文件,不然 shape 不匹配会白折腾。
从那以后,我每次调参都强制自己先保存一份 baseline 曲线,再改参数,不然凭感觉说「变好了」很容易被单局波动欺骗。这个习惯帮我少走了很多弯路,希望帮到你。
本文还有配套的精品资源,点击获取