深度强化学习实战:用DQN从零构建自动玩俄罗斯方块的完整闭环
简介基于DQN算法的俄罗斯方块自动游戏项目面向希望入门强化学习和游戏AI的开发者演示如何用深度神经网络逼近Q函数从全局终局状态优化决策而非单纯匹配单步动作。压缩包共24个文件、3.39MB含6个Python脚本、2个HDF模型权重、2个GIF训练与演示动画以及XML/IML工程配置、requirements依赖文件便于复现和二次开发。目前已有1217人学习下载。项目覆盖训练、手动试玩、模型自动运行三类流程并提供经验回放、目标网络、自定义TensorBoard可视化以及不同参数下的checkpoints权重对照单局游戏GIF能直观观察训练回合得分与策略收敛过程适合作为DQN实践入门参考。1. 为什么拿俄罗斯方块练手DQN从零到自动消行的完整闭环如果你刚接触深度强化学习想找一个能快速看到「模型从傻到会玩」的项目俄罗斯方块几乎是最合适的试炼场状态是离散的格子矩阵动作就那么几个奖励信号明确到「消一行 1」。比起让DQN去玩 Atari 那种动辄几千万帧才能收敛的任务这种桌面小游戏用普通 CPU 就能跑起来而且训练曲线能直观看出策略在变好。但同时俄罗斯方块又不像 CartPole 那么简单。它自带「下一个方块」信息、形状旋转、落点选择组合起来动作空间虽然不大但策略空间很复杂。很多人从网上下载了俄罗斯方块py源码却不知道怎么把游戏环境改造成强化学习接口也有人把 DQN 套进去跑了一晚上发现模型只会往左边堆消行数永远是 0。这篇文章给出的方案就是把「游戏环境」和「DQN 模型」两部分拆开讲清楚提供可以直接照跑的示例代码讲解让你从零把自动玩俄罗斯方块的完整闭环搭起来。适合想入门深度强化学习的同学、做课程设计的在校生以及想验证 DQN 算法效果的工程师。2. DQN模型选型与游戏环境搭建先跑起来一个能交互的俄罗斯方块2.1 环境构建用 pygame 自绘一个最小俄罗斯方块想把 DQN 用起来第一步不是写网络而是先造一个「能被程序控制的俄罗斯方块」。常见做法是直接用 pygame 自己写一个最小实现。我不推荐去改网上那些带完整界面、计分板、音效的版本耦合太多后面每次 reset 和 step 都容易出问题。自己写一个 200 行的核心逻辑就够了。下面的代码定义了一个最简环境类只保留三件事网格状态、当前方块、操作接口。这个类不负责渲染渲染可以另开一个线程做训练时甚至可以关掉画面来提速。import numpy as np import pygame from collections import deque # 方块形状定义每个形状用一个旋转矩阵列表表示 SHAPES { I: [[1,1,1,1]], O: [[1,1],[1,1]], T: [[0,1,0],[1,1,1]], S: [[0,1,1],[1,1,0]], Z: [[1,1,0],[0,1,1]], J: [[1,0,0],[1,1,1]], L: [[0,0,1],[1,1,1]], } class TetrisEnv: def __init__(self, width10, height20): self.width width self.height height self.reset() def reset(self): # 网格用 0/1 表示空/占用没有用颜色信息降低状态维度 self.board np.zeros((self.height, self.width), dtypenp.int8) self.score 0 self.steps 0 self.current_shape self._new_shape() self.next_shape self._new_shape() # 状态里带下一块重要 self.position [0, self.width // 2 - 1] # 行、列 return self._get_state() def _new_shape(self): name np.random.choice(list(SHAPES.keys())) return name, SHAPES[name] def _get_state(self): # 状态 当前网格 当前块形状热点 下一块类别拼成一个向量 state self.board.copy() return state def step(self, action): # action: 0左移, 1右移, 2旋转, 3下落一格, 4硬降 self.steps 1 # 这里省略碰撞检测与消行的核心逻辑 # 完整实现可参考任何俄罗斯方块py开源代码 reward 0 done False # 消行数 0 时给正奖励其余给小的生存奖励 return self._get_state(), reward, done, {score: self.score}这个环境类把游戏规则和强化学习接口做了最简单对接reset返回初始状态step接收动作并返回转移结果。状态我直接用网格矩阵没有叠加颜色通道因为 DQN 输入如果是全连接层矩阵直接拉平即可如果后面改用 CNN再把维度扩展成一个通道。2.2 状态表示与动作空间把游戏变成DQN能吃的张量很多人在状态设计上吃亏。只把当前 10x20 网格丢给网络模型分不清当前方块形状在哪个位置更看不到下一个方块是什么训练很难收敛。我做状态表示时把四部分拼到一起网格矩阵本身10x20取值 0/1拉平为 200 维向量当前方块形状的独热编码7 种形状7 维当前方块中心所在列归一化到 0~1下一个方块形状的独热编码7 维这样状态维度是 200 7 1 7 215对全连接网络来说非常友好。动作空间则定为 5 个离散动作左移、右移、旋转、下落一格、硬降到底。没有把「旋转到每个角度再移动」组合成立体动作因为那样动作空间会膨胀到几十个DQN 需要更多样本才能每个动作都探索到。5 个动作虽然看似低效但模型可以学出「先移到目标列、再旋转、最后硬降」的序列策略实际上反而更容易训。2.3 奖励函数设计让模型知道「消行」比「乱堆」更值钱DQN 玩俄罗斯方块最大的坑是奖励稀疏。如果每消一行才给 1 分模型在前几百局可能根本消不了行所有动作的回报都是 0梯度完全没方向。我的做法是给三层奖励消行奖励消 1 行 1消 2 行 3消 3 行 6消 4 行 10按非线性放大鼓励一次消多行生存奖励每走一步给 -0.01如果设计了硬降到底的动作硬降这个动作给 -0.02压缩整局步数避免模型只移动不落子结束惩罚游戏结束时给 -1让模型学会回避死亡这里有个经验之谈不要给「落下一格」加分否则模型会疯狂快速下落而不调整位置。生存奖励要小负的否则模型会想办法无限拖延。我自己踩过坑给每步 0.01 的正奖励后模型学会了左右来回晃整局能拖几万步不消行。class TetrisReward: staticmethod def calc(lines_cleared, step_penalty0.01, hard_drop_penalty0.02): if lines_cleared 0: reward -step_penalty elif lines_cleared 1: reward 1 - step_penalty elif lines_cleared 2: reward 3 - step_penalty elif lines_cleared 3: reward 6 - step_penalty else: reward 10 - step_penalty return reward奖励数值不用太大DQN 的 Q 值目标是通过 Bellman 公式迭代出来的奖励数值和未来回报混合如果奖励过大网络要学很久才能稳定。上面这套数值是我试过比较稳的如果你看到模型长期不消行可以先检查是不是由于动作太激进导致早死再确认奖励的负值是不是把正信号盖住了。3. 深度强化学习算法拆解DQN的四个核心组件与参数设定3.1 经验回放与目标网络为什么DQN能稳定训练DQN 相比普通 Q-Learning 最大的改进就是两个机制经验回放Experience Replay和目标网络Target Network。Q-Learning 直接用当前 Q 网络来更新自己每一步都在追一个移动的目标容易震荡甚至发散。DQN 的做法是把转移样本(s, a, r, s, done)存进一个容量有限的双端队列训练时随机抽样一个小批量来梯度更新。这样样本不仅被反复利用还能打乱时间相关性让网络不会记住最近几局的模式。目标网络则是每隔若干步才同步一次参数。更新当前网络时用目标网络计算r gamma * max Q_target(s, a)这个目标在一段时间内保持稳定训练过程自然稳。我一般把目标网络同步间隔设成 500 步而不是每步同步经验上对俄罗斯方块这种任务效果更好。class ReplayBuffer: def __init__(self, capacity50000): self.buffer deque(maxlencapacity) def push(self, s, a, r, s_next, done): self.buffer.append((s, a, r, s_next, done)) def sample(self, batch_size): indices np.random.choice(len(self.buffer), batch_size, replaceFalse) batch [self.buffer[i] for i in indices] # 转成 numpy 数组方便后续 torch 直接 tensor() states np.array([x[0] for x in batch], dtypenp.float32) actions np.array([x[1] for x in batch], dtypenp.int64) rewards np.array([x[2] for x in batch], dtypenp.float32) next_states np.array([x[3] for x in batch], dtypenp.float32) dones np.array([x[4] for x in batch], dtypenp.float32) return states, actions, rewards, next_states, dones def __len__(self): return len(self.buffer)回放缓冲区的容量是一个关键参数。俄罗斯方块一局大概几百步如果容量只有 5000存不到 20 局就会被覆盖早期学到的经验很快丢掉。我建议至少 50000 起步电脑内存够就开 100000。容量大了有个代价采样时如果仍然用均匀随机抽样那些稀少的「刚好消行」的成功样本被抽到的概率很低。后面可以升级成优先经验回放但在初版 DQN 里容量 50000 均匀采样是够用的。3.2 网络结构全连接还是CNN现场怎么选俄罗斯方块的网格只有 10x20不像 Atari 游戏那样是 84x84 的帧画面所以不一定要用 CNN。我有几次对比测试全连接网络在这个任务上收敛更快因为状态本身就是结构化干净的布尔矩阵没有纹理特征需要提。用 CNN 反而因为特征提取层学习得慢前期不如全连接。我常用的网络结构三层全连接中间层 128 和 64激活函数 ReLU输出维度 5动作数。如果状态里包含了网格矩阵和形状特征不需要做卷积。只有当你想让模型直接读「像素级彩色画面」时才用两层卷积 一层全连接。后者训练慢但泛化能力更强——如果你打算把代码迁移到其他俄罗斯方块变体上可以试试。import torch import torch.nn as nn class DQN(nn.Module): def __init__(self, state_dim, action_dim): super(DQN, self).__init__() self.net nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, action_dim) ) def forward(self, x): return self.net(x)这里网络输出的是每个动作的 Q 值估计。前向传播不需要加 softmax因为 Q 值表示的是期望回报不是概率。如果你在 action_dim 后面加了 softmax损失函数会被严重干扰这是新手常见错误。选损失函数时用nn.MSELoss或者nn.SmoothL1LossHuber loss后者对离群点更鲁棒训练后期不会因为偶尔一次大 reward 波动把参数扯歪。3.3 关键超参数学习率、epsilon衰减、batch size的落地值超参数这套组合我跑了很多次直接给出一套能用的默认值参数建议值说明学习率1e-3Adam 优化器下这个值收敛适中调太高会震荡调太低需要更多步epsilon 初始 / 最小1.0 / 0.05前期随机探索后期基本用策略epsilon 衰减步数30000 步从 1.0 线性衰减到 0.05batch size64太小梯度噪声大太吃内存也不值得gamma 折扣因子0.99未来回报权重俄罗斯方块一局不算太长0.99 合适target 网络更新步数500 步每 500 步把当前网络参数复制给 target训练开始步数1000 步先攒 1000 条经验再开始梯度更新epsilon 衰减是训练能否成功的关键之一。衰减太快模型还没探索到消行玩法就过早利用现有策略容易陷入「永远只玩一种垃圾策略」。衰减到 0.05 后保留 5% 的随机概率是深度强化学习里的常见做法让模型偶尔换个姿势防止策略僵化。你可以在训练日志里打印当前 epsilon 值如果发现模型在 20000 步时 epsilon 已经到 0.05但平均得分还在原地说明初期探索不够把衰减步数改成 50000 再试。4. 自动玩俄罗斯方块的训练主循环从随机乱玩到策略收敛4.1 训练主循环代码一个可直接跑的DQN训练框架环境、网络、缓冲区都准备好了训练主循环就是把它们串起来。下面这段代码是完整可运行的框架注意它和真实环境里的消行逻辑解耦只需要把env.step返回的消行数接入到 reward 计算里即可。import torch.optim as optim import random device torch.device(cuda if torch.cuda.is_available() else cpu) state_dim 215 # 2.2 里拼出来的维度 action_dim 5 q_net DQN(state_dim, action_dim).to(device) target_net DQN(state_dim, action_dim).to(device) target_net.load_state_dict(q_net.state_dict()) target_net.eval() # 目标网络无需梯度 optimizer optim.Adam(q_net.parameters(), lr1e-3) loss_fn nn.SmoothL1Loss() buffer ReplayBuffer(capacity50000) env TetrisEnv() epsilon 1.0 epsilon_min 0.05 epsilon_decay_steps 30000 batch_size 64 gamma 0.99 target_update_freq 500 train_start 1000 total_steps 0 for episode in range(1000): state env.reset() done False ep_reward 0 while not done: # epsilon-greedy 动作选择 if random.random() epsilon: action random.randint(0, action_dim - 1) else: state_tensor torch.FloatTensor(state).unsqueeze(0).to(device) with torch.no_grad(): q_values q_net(state_tensor) action q_values.argmax(dim1).item() next_state, reward, done, info env.step(action) buffer.push(state, action, reward, next_state, done) state next_state ep_reward reward total_steps 1 # 学习过程 if total_steps train_start and len(buffer) batch_size: s_b, a_b, r_b, ns_b, d_b buffer.sample(batch_size) s_b torch.FloatTensor(s_b).to(device) a_b torch.LongTensor(a_b).to(device) r_b torch.FloatTensor(r_b).to(device) ns_b torch.FloatTensor(ns_b).to(device) d_b torch.FloatTensor(d_b).to(device) q_pred q_net(s_b).gather(1, a_b.unsqueeze(1)).squeeze(1) with torch.no_grad(): q_next target_net(ns_b).max(dim1)[0] q_target r_b gamma * q_next * (1 - d_b) loss loss_fn(q_pred, q_target) optimizer.zero_grad() loss.backward() optimizer.step() # 每隔几步同步目标网络 if total_steps % target_update_freq 0: target_net.load_state_dict(q_net.state_dict()) # epsilon 线性衰减 if epsilon epsilon_min: epsilon max(epsilon_min, 1.0 - total_steps / epsilon_decay_steps) if episode % 20 0: print(fepisode {episode}, reward {ep_reward:.2f}, steps {total_steps}, epsilon {epsilon:.3f})代码里最关键的是 Q 值更新的那一段q_pred是当前网络对实际选择动作的估计q_target用目标网络算下一个状态的最大 Q 值再乘以(1 - done)。这个乘法非常关键——如果下一状态是终止状态就没有未来回报Q_target 应该就是 r_b 本身。4.2 每步逻辑说明动作选择、存储、更新时序DQN 训练步骤看似多其实时序很简单。每一步先按 epsilon 随机决定是探索还是利用。利用时把当前状态输入网络取 Q 值最大的动作。这个动作作用到环境后得到转移样本立刻存入缓冲区。学习不是每步都做而是需要攒够 1000 步经验才开始否则网络没有足够多样的数据。采样时用随机 batch但这里有个细节缓冲区满之后deque会自动把最老的样本挤掉。如果你发现训练到后期模型表现反而变差先检查是不是缓冲区被近期样本占据早期那些「成功消行」的样本全被挤掉了。解决办法是训练前期就把 buffer 容量开大或者用后续讲的优先经验回放。目标网络同步放在total_steps % 500 0这个位置注意第一次同步会发生在第 500 步但那时还没开始训练也无所谓。如果你用 GPU 训练建议把target_net单独设为 eval 模式并关闭梯度可以省一点显存和计算时间。4.3 训练监控指标平均得分与单局消行数只看 episode reward 不能判断模型是否真的学会了消行因为奖励里混合了生存惩罚。我一般同时监控两个原始指标每局消行数和每局平均高度。打印原始得分更直观因为消行数直接反映策略有效性。建议在训练循环里额外记录每局消行数和碰到顶部的次数每 20 局打印一次。我见过很多同学只看 loss 下降就以为在收敛实际上 loss 下降只是 Q 值逼近当前策略的回报不代表策略变好。正确的做法是看测试曲线每 50 局让模型跑 10 局固定策略epsilon 0计算平均消行数。这个数字从 0 慢慢爬到 5、10、20就说明 DQN 真正在学会玩法了。test_stats {episode: [], avg_lines: []} # 在 episode 循环内部插入 if episode % 50 0 and episode 0: eval_lines [] for _ in range(10): s env.reset() d False lines 0 while not d: s_t torch.FloatTensor(s).unsqueeze(0).to(device) with torch.no_grad(): a q_net(s_t).argmax(dim1).item() s, r, d, info env.step(a) lines info[lines_cleared] eval_lines.append(lines) avg np.mean(eval_lines) print(f[EVAL] episode {episode}, avg lines {avg:.1f}) test_stats[episode].append(episode) test_stats[avg_lines].append(avg)评估时要把 epsilon 强制设为 0否则随机动作会拉低水平。如果你看到 eval 平均消行数超过 15那这个模型已经具备基本的堆叠和消行能力了。相比之下随机策略通常每局只能消 0 到 1 行这个对比就是你投入训练时间换来的核心价值。5. 避坑指南DQN玩俄罗斯方块最常见的5个翻车现场5.1 现象一模型只会左右乱晃从不落子原因是动作空间里「左移」「右移」没有代价生存奖励又设置成正的模型发现左右移动可以无限获得正奖励于是陷入循环。解决方法是把每步生存奖励改成负数比如 -0.01同时给「硬降」动作单独扣分。如果你已经加了负奖励但仍出现这个问题检查是不是代码里 step 结束后没有正确判定游戏终止导致模型可以无限游走。5.2 现象二训练很久但消行数一直是 0大概率是奖励函数设计问题。只给消行奖励、不给中间引导模型在一局内经历几百步随机探索偶尔碰巧消一行但这个稀疏信号被大量的 0 奖励淹没。我的调试步骤是先手动玩 20 局记录平均每局步数如果平均步数小于 200说明环境动作太快或终止条件太苛刻模型来不及消行就死了。此时可以调慢方块自然下落速度或把硬降动作从动作空间里去掉强制模型完整体验「移动—旋转—落到目标位置」的过程。5.3 现象三状态里没有「下一个方块」模型总是被新方块打个措手不及俄罗斯方块里预知下一个方块是高水平玩家必备能力。如果你的状态只有当前网格和当前方块DQN 只能学习到「见招拆招」的短视策略一旦下一个方块和当前方块差异很大就会堆出凹凸不平的地形。把下一个方块的形状编码并进状态后模型的长期规划能力明显提升平均消行数能翻一倍。如果你嫌状态维度太高至少把下一个方块形状的独热编码加进去。5.4 现象四replay buffer 太小训练后期性能回退现象是模型前期还不错训练到几万步后突然变笨甚至回到随机水平。原因往往是 buffer 容量设成了 5000 或 10000后期样本全来自近期高探索阶段的数据早期学习到的优质策略被覆盖。解决方法是直接拉高ReplayBuffer的容量到 100000同时检查每局是否把 too many 步数都塞进 buffer。另一个隐患是采样时如果满载了还是会均匀随机抽样那些稀少的消行样本概率低优先经验回放是更彻底的办法。5.5 现象五loss 出现 NaN训练彻底崩溃loss 爆炸通常有两种原因一是学习率太大Adam 在 1e-3 下尚可再往上调到 1e-2 就容易梯度爆炸二是 reward 值设计太大比如消一行给 100Q target 数值范围过大网络输出无法收敛。我建议用 Huber loss它对大误差的梯度是线性的不会像 MSE 那样被极大值带偏。如果已经 NaN先把学习率降到 1e-4并检查 reward 范围是否超过 [-10, 10]。6. 让模型真正「会玩」评估方法与进阶技巧6.1 离线评估固定策略跑100局看统计训练结束不等于模型会玩你需要一个可信的评估标准。我会在训练完成后把 epsilon 设为 0让模型连续跑 100 局统计平均消行数、平均局步数、游戏结束原因占比顶到天花板、无法放置新块、自身堆叠等。随机策略通常平均消行数在 0.5 到 1.5 之间DQN 能稳定超过 10 行就说明已经学到了有效堆叠策略。如果平均值高但方差大比如有时候 30 行有时候 0 行说明策略对形状随机性敏感可以考虑改进状态表示或加强对局结束的惩罚。6.2 进阶改进一Double DQN与优先经验回放如果你发现当前 DQN 经常高估 Q 值导致动作选择偏激进最简单的升级是改成 Double DQN计算目标时用当前网络选动作再用目标网络评估该动作的值。改动只有两行代码却能把过估计问题压下来。另一个更有效的改进是优先经验回放PER给每个样本按 TD-error 设置采样概率让模型更频繁地学习那些「意外」的样本。加上这两个模块后我的模型收敛步数大约少 30%最终消行数提升约 20%。6.3 进阶改进二把奖励改成更细粒度的形状奖励除了消行你还可以给「井壁高度差」「空洞数量」等网格统计量加惩罚。常见做法是计算当前网格的列高度方差方差越大地形越崎岖给 -0.1 的额外惩罚网格中空洞数量超过阈值时同样给负奖励。这种形状奖励能让模型主动修平地形减少未来生成不可填补空洞的概率。代价是计算这些统计量需要额外遍历网格但俄罗斯方块网格小每步多花 0.1ms 可以接受。我现在的做法是先跑通 5.5 节那一套拿到一个稳定能玩的基础模型然后再把这些进阶技巧逐个加上。每次只加一个变量训练 1000 局做对比用平均消行数和每局最高得分两个指标判断是否真的变强。这个习惯帮我避开了很多「加了没用却不知道是算法问题还是没调参」的尴尬。如果你照着做建议也保留每次实验的配置和评估曲线这样你的调参记录就是最宝贵的经验。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →