PyTorch DQN 实战:让 AI 自动玩俄罗斯方块
简介一份以PyTorch训练AI自动玩俄罗斯方块的毕业设计项目面向强化学习与深度学习入门者完整展示了DQN和Q-Learning算法在游戏场景中的落地过程。压缩包共含7个文件包含3个Python脚本分别用于环境模拟、模型训练与主流程控制、2个Markdown说明文档覆盖项目介绍与代码讲解、1个依赖清单文件和1个演示gif整体大小21.54MB文件结构清晰便于按需查阅。目前已有945人学习下载项目代码围绕环境模拟、Q网络构建、经验回放缓冲区、训练循环及目标网络更新等关键模块展开注释与文档配合演示动画可以直观理解AI从随机操作到逐步学会堆叠消行的过程。项目特别适合毕业设计、课程实践或游戏AI入门下载解压后即可运行学习能有效节省环境配置和算法梳理的时间。1. PyTorch 训练 AI 玩俄罗斯方块这个毕业设计到底放到桌面上是什么把一个“人玩的俄罗斯方块”变成“AI 自动玩的俄罗斯方块”工作量不在游戏本身而在把游戏改造成强化学习能理解的接口再用 PyTorch 训练一个能记住长期收益的 Q 网络。很多同学拿到那套“含游戏代码的 AITetris 合辑”之后第一反应是把游戏跑起来实际更有效的路径是从环境接口开始读代码再去看网络结构和训练循环。“AI 自动玩小游戏”这类课题的验收标准通常有三档能稳定运行不崩、消行数明显高于随机按键、训练曲线有可解释的上升趋势。下面按这个顺序把俄罗斯方块建模、DQN 训练、参数调试和最终验收四段路的做法讲清楚新手能照着复现熟手也能对比自己项目里的边界处理。2. 俄罗斯方块建模状态张量、动作空间与合法动作掩码2.1 把 20×10 的网格变成 PyTorch 能吃的状态俄罗斯方块的棋盘固定是 20 行 10 列这个尺寸比 Atari 的 210×160 画面小很多所以状态表示不用走复杂的图像预处理。最常见做法是把棋盘直接展平成一维数组再接上当前方块的类型信息组成一个固定长度的特征向量。import numpy as np PIECE_TYPES 7 # I, O, T, S, Z, J, L def build_state(grid, current_piece_id): # grid: shape (20, 10) 的二维数组0 表示空1 表示已固定方块 grid_flat grid.astype(np.float32).flatten() # 当前方块类型做成 one-hot占 7 维 piece_vec np.zeros(PIECE_TYPES, dtypenp.float32) piece_vec[current_piece_id] 1.0 return np.concatenate([grid_flat, piece_vec])这里把位置坐标和旋转角度先省略是为了让状态维度固定。20×10 的网格加 7 维 one-hot总共 207 个浮点数这样的输入用全连接网络就能学。如果后面想把方块横纵坐标也放进状态要注意坐标在不同旋转下取值范围不同进入特征之前要做归一化否则网络会拿原始坐标直接拟合增加训练难度。网格里每个位置只有空和非空两种状态flatten 之后顺序是固定的训练和评估的环境只要用同一个状态构造函数结果才放得在一起比。2.2 动作空间为什么不能用“左移 / 右移 / 旋转”直接当输出常见误用是把动作定义成左移、右移、软降、硬降、旋转五种按键然后让网络输出 5 个 Q 值。问题在于俄罗斯方块是按帧推进的同一帧按一次左移和连续按五次左移效果完全不同而 Q 网络的输出本身不包含“按几秒”的信息。真正稳定的做法是自定义离散动作每个动作指定“当前方块旋转到哪个朝向、放到哪一列”由环境一次性把方块放到目标位置并结算。def legal_actions_for(piece, grid): # 返回所有能放下的 (rotation, col) 组合 actions [] for rot in range(4): for col in range(10): if can_place(piece, rot, col, grid): actions.append((rot, col)) return actions这样动作总数最多 40 个实战中每个局面合法动作只有几个到十几个。相比“按键响应”环境变成“一次决策落到终态”回合里的步数明显变短奖励结算也清晰了。动作空间里的每个元素都对应一个明确的放置结果网络学习时不需要去理解“按住方向键”这种连续过程这正是这类小游戏 AI 代码合辑里最值得先看的部分。2.3 step 接口与回合终止条件把游戏代码和训练代码解耦最省事的办法是照着 Gym 的接口习惯写一个 Environment 类只暴露 reset、step、render 三个方法。代码合辑里通常已经有一套 pygame 写的游戏主循环改造时要把“图形刷新”和“逻辑推进”分开。AI 模式只走逻辑不刷新窗口训练速度能快几倍。class TetrisEnv: def reset(self): self.grid np.zeros((20, 10), dtypenp.int8) self.score 0 return build_state(self.grid, self._next_piece_id()) def step(self, action_index): rot, col self._action_table[action_index] reward, info self._apply_action(rot, col) done self._is_game_over() next_state build_state(self.grid, self._current_id()) return next_state, reward, done, infostep 返回的四个值建议按表 2-1 对齐后面接 DQN 训练时能直接套常规模板。返回值含义在 DQN 里的用途next_state放置完成后的新状态存入经验池作为下一轮网络输入reward本次动作获得的数值奖励计算目标 Q 值与损失函数done游戏是否结束网格堆到顶决定经验里是否使用 bootstrappinginfo消行数、当前高度等辅助指标画训练曲线和统计消行分布一个重要细节是 done 的判定时机。俄罗斯方块的游戏结束判定是“新方块出现的位置被占住”而不是“某些行满”或“计分器溢出”。step 里做判断时要放在消除行之后、生成下一个方块之前否则会把“消行后仍能继续”的局面误判成终局。训练中如果发现 game over 频率异常高且分数很低优先检查这个先后顺序而不是急着调超参。2.4 非法动作直接掩码过滤别用负奖励动作空间选完还有一个经典坑网络输出的动作可能落在当前局面不合法的组合上。处理方案有两种一种是在奖励里对非法动作扣分让网络慢慢避开另一种是为每个状态生成合法动作掩码在选动作前把非法动作的 Q 值强制改成极小值。毕业设计阶段建议直接用掩码。def masked_q_values(net, state, legal_actions): q net(state) # shape (1, action_total) mask torch.full_like(q, -1e9) for i in legal_actions: mask[0, i] q[0, i] return mask用掩码而不是扣分原因是俄罗斯方块很多局面下合法动作不超过五个非法动作占绝大多数。如果靠负奖励去学网络要花大量样本区分“动作本身非法”和“动作合法但结果差”这两种情况在同一批数据里难以解耦。掩码直接把搜索范围限定在可行解里收敛会快很多。这个做法在 DQN 类离散控制任务里是成熟方案写进设计说明书也没有争议。3. 用 PyTorch 搭 DQN网络结构、经验回放与训练循环3.1 为什么选 DQN 而不是策略梯度或 TD3俄罗斯方块的动作输出是离散的选列和旋转天然适合价值函数类方法。策略梯度能做但需要完整回合结束后才能回传优势估计而俄罗斯方块一个回合可能持续几百步方差会非常大。TD3 是连续控制算法直接求解连续动作空间对这个任务属于绕远路勉强用会平白多出动作裁剪和噪声注入的麻烦。DQN 的核心思路是学一个 Q 函数在状态 s 下执行动作 a之后一直按最优策略走能拿到的累计奖励期望是多少。选 Q 值最大的动作执行就能得到一组合理策略。PyTorch 基础框架在这一块的搭配也有现实优势。只要装好 Anaconda新建虚拟环境配 PyTorch 环境是两条命令的事先创建 Python 3.10 的 conda 环境再安装 CPU 版或对应 CUDA 版的 torch无论 Windows 还是 Ubuntu 都能跑。CPU 版训练这个规模的小游戏完全扛得住只是收敛慢一些GPU 版主要差在每千步的训练耗时网络定义和训练循环代码不用改。3.2 Q 网络MLP 足够还是上卷积棋盘只有 20×10很多毕业设计作者会直接写三层 MLP输入 207 维。这个选择在纯 DQN 里完全够用因为俄罗斯方块不存在“识别物体轮廓”这种图像语义特征本身就是结构化网格。要注意网格是稀疏的方块落地后大部分区域是 0把所有值直接喂进去会让网络大量注意力落在无意义位置。一个折中方案是加一层浅卷积把棋盘局部结构先提出来再接全连接网络容量只增加少量参数训练收敛却更稳。import torch.nn as nn class DQN(nn.Module): def __init__(self, input_dim, action_total, hidden_dim256): super().__init__() self.shared nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), ) self.q_head nn.Linear(hidden_dim, action_total) def forward(self, x): return self.q_head(self.shared(x))这段结构把特征提取和 Q 值输出拆开后面换 Double DQN 时只需要把 q_head 改成两个输出头。hidden_dim 取 256 对 10×20 的规模通常够用取 1024 只会拖慢训练不会提高精度。激活函数用 ReLU 就够不需要 BatchNorm训练批大小通常 32 或 64加归一化层反而会引入 batch 内样本相关性问题。3.3 经验回放与目标网络DQN 能稳定工作的关键不在网络层数而在两个工程组件经验回放和固定目标网络。经验回放把每一步的状态、动作、奖励、下一状态、结束标志存进一个循环队列训练时随机抽一批样本打破连续样本之间的时间相关性。目标网络是参数冻结的 Q 网络副本每隔一定局数把当前网络参数复制过去用来计算目标值。from collections import deque import random class ReplayBuffer: def __init__(self, capacity100_000): self.buffer deque(maxlencapacity) def store(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 (torch.FloatTensor(states), torch.LongTensor(actions), torch.FloatTensor(rewards), torch.FloatTensor(next_states), torch.FloatTensor(dones))目标值计算分两种情况如果 done 为 1说明回合结束目标直接等于本次奖励否则用“本次奖励 gamma × 目标网络在下一状态的最大 Q 值”来估计。这就是贝尔曼方程在 DQN 里的落地形式。目标网络的存在让目标值在一个短周期内保持稳定当前网络才追得到目标。两个组件的常见参数和调节方向见表 3-1。组件常用参数调参方向经验池容量100000 步太小则样本重复、过拟合太大则新样本被淹没目标网络同步周期每 500 局太长目标僵化太短失去稳定性意义批大小64越大梯度越稳但训练前期样本不足时算得慢折扣因子 gamma0.95回合偏长时 0.99 会让早期方块权重过高3.4 训练循环主框架训练主循环里每一步从当前状态选动作环境返回下一步状态和奖励存入经验池。经验池样本够数后开始梯度更新每隔固定局数同步一次目标网络。optimizer torch.optim.Adam(net.parameters(), lr1e-4) for episode in range(2000): state env.reset() episode_reward 0 while True: legal legal_actions_for(env.current_piece(), env.grid) action select_action(net, state, legal, epsilon) next_state, reward, done, info env.step(action) replay.store(state, action, reward, next_state, done) state next_state episode_reward reward if len(replay.buffer) 1024: states, actions, rewards, next_states, dones replay.sample(64) q_values net(states).gather(1, actions.unsqueeze(1)).squeeze() with torch.no_grad(): next_q target_net(next_states).max(1).values target rewards gamma * next_q * (1 - dones) loss nn.MSELoss()(q_values, target) optimizer.zero_grad() loss.backward() optimizer.step() if done: break if episode % 500 0: target_net.load_state_dict(net.state_dict())select_action 在 epsilon 大于随机阈值时走贪婪逻辑否则从合法动作里随机抽一个。epsilon 从 1.0 线性衰减到 0.05 是常规做法衰减维度要挂在局数上而不是步数上回合制任务按局更稳定。训练循环里有三个容易写错的地方。一是 gather 之后 q_values 的形状是 (batch_size, 1)要先 squeeze 再和 target 对齐否则 MSE 会按二维形状广播计算。二是目标网络初次同步要在进入训练前做一次不然 target_net 的初始参数是随机值前几百次更新的目标全是噪声。三是经验池不满 1024 之前不要开始梯度更新拿几组样本反复算loss 前期很容易发散到 NaN。4. 奖励函数设计与超参调试从乱按到消行的关键参数4.1 奖励塑形只给消行分AI 学不会避坑很多同学第一版只写两行消一行奖励 1 分游戏结束扣 1 分。结果训练几千局之后AI 学会的是把方块随意堆在中间等三行一起消的运气局。原因在于稀疏奖励让每个动作的因果链过长Q 网络很难把“放在边缘”和“避免 20 步之后的死亡”关联起来。正确做法是把奖励塑形成每个动作都能获得反馈的密集信号。def shaped_reward(cleared_lines, height, holes, bumpiness, prev_height, step_penalty0.01): reward 0.0 # 消行是主目标四行连消要给额外加成 if cleared_lines 1: reward 1.0 elif cleared_lines 2: reward 3.0 elif cleared_lines 3: reward 6.0 elif cleared_lines 4: reward 10.0 # 高度增长过快要扣分鼓励 AI 保持场地平整 reward - max(height - prev_height, 0) * 0.1 # 空洞是后期炸局的主要来源扣重一点 reward - holes * 0.3 # 凹凸不平会增加后续放块难度轻罚 reward - bumpiness * 0.05 # 每步固定扣分逼着 AI 尽快决策 reward - step_penalty return reward这里的关键是系数之间要拉开量级。消一行是 1每个空洞扣 0.3一个动作最多产生三四个新空洞扣分上限和加分在一个量级网络才能学到“避免空洞”的隐含规则。如果系数全设成 1空洞扣分远超消行加分AI 会变得极端保守反过来消行加 5 分、空洞只扣 0.01网络又回到无脑堆中路的策略。空洞数计算方法是逐列扫网格某一列某个位置是空格、但下方存在已固定方块就计一个空洞。4.2 超参表一份能跑通的小型配置训练 2000 局以内要把效果跑出来参数配置比网络结构更敏感。表 4-1 这组参数在 10×20 标准棋盘、CPU 单机上可以复现稳定的消行结果适合作为基线。参数值说明学习率1e-4偏小Q 值量纲本来就小调太大会震荡折扣因子0.95回合 30~100 步0.95 适合中短回报epsilon 起始1.0前几百局全随机探索epsilon 结束0.052000 局内线性衰减到该值每局最大步数300防止 AI 靠左右横移刷时长经验池容量100000约能覆盖 2000 局的半数样本目标网络更新每 500 局和 3.4 节代码保持一致每局最大步数这一行最容易忽略。AI 如果一直不放方块或者软降不落底单局可以无限拖长经验池里会堆满“什么都没发生”的低价值样本。限制步数后训练脚本记录的局数和真实放置次数会有明显关系真实步数除以局数如果小于 20说明动作空间转换没生效AI 可能在大量输出同一个非法动作。4.3 奖励曲线不涨时先查这三个位置训练过程中 loss 下降不等于 AI 变强loss 收敛而分数不涨是 DQN 的常态。遇到这种情况不要急着改网络先检查三个地方。第一render 渲染函数是否在训练时被调用pygame 窗口刷新会把单步耗时拉高几十倍训练进度看起来停滞其实是探索速度太慢。第二训练里每个 step 的 reward 之和跟游戏内 score 的换算是否一致很多游戏代码里消行显示分是 100、300、500、800训练奖励是另一套小数值混用会导致梯度剧烈抖动。第三epsilon 衰减是挂在局数维度还是步数维度按局衰减更符合回合制任务的语义。超参没有大问题时可以优先加两个改进。Double DQN 用当前网络选动作、目标网络估计值把 Q 值过估计压下来或者把目标网络更新频率从 500 局改成 100 局看稳定性变化。两个改动都只涉及十几行代码适合写进毕业设计的对比实验部分。4.4 环境侧的隐藏炸弹游戏代码改动越少越容易踩环境侧的坑。比如 pygame 按键事件在主循环里的读取时机不对导致动作实际执行延迟一帧再比如 grid 是二维 numpy 数组还是 Python list会影响 flatten 后的顺序。代码合辑里如果带有 C 语言风格的游戏逻辑迁移到 Python 时最容易漏掉方块形状数组的旋转方向顺时针和逆时针旋转的索引变换在 4×4 矩阵里写法不同。建议把所有方块旋转规则统一封装成 rot 0 到 3 的绝对形状而不是每步相对旋转这样状态里记录的 rotation 才能和动作空间完全一致。验收时用同一个初始种子分别跑一遍“手写按键脚本”和“AI 自动挡”对比固定步数后的网格形状就能定位这类偏差。5. 加载权重自动玩模型保存、固定种子评估与可视化验收5.1 用固定种子跑 100 局交验收数据训练好的网络不能只截图展示“能玩几局”要给出可复现的评估数字。常规做法是固定随机种子让环境在相同条件下连续跑 100 局记录平均分、平均消行数、最高分和回合长度。注意固定种子要同时作用在 pygame 的随机和 numpy 的随机上这两个随机源是独立的。python eval.py --weights weights/best_dqn.pt --episodes 100 --seed 2024评估脚本里不要覆盖游戏的视觉刷新纯逻辑跑 100 局通常只要几十秒。输出结果建议同时打印平均分和消行数因为训练奖励是塑形后的值和游戏显示分未必一比一对应消行数更能反映策略的真实水平。5.2 把 .pt 权重恢复到演示程序训练脚本里保存模型时建议同时保存网络参数和超参配置方便答辩时换一台机器也能直接加载。加载时最常见的坑是 load_state_dict 之后忘记 net.eval()导致推理时 dropout 或 BatchNorm 行为异常。model DQN(state_dim, action_total) model.load_state_dict(torch.load(weights/best_dqn.pt, map_locationcpu)) model.eval()map_locationcpu 让模型在只有 CPU 的演示机上也能跑。如果保存时用了 GPU加载后第一轮推理会报设备不匹配这句参数要优先写上。演示程序里建议把状态构造函数和训练时保持一致不要顺手在 build_state 里改了维度否则权重维度对不上。5.3 可视化对战的三种崩溃预防自动玩演示最常见的三个事故。一是方块形状数组越界访问通常发生在旋转后列坐标偏出边界时没有提前做合法性检查渲染前要对旋转后的形状逐格判断边界。二是渲染线程和逻辑线程并发访问同一块 grid导致画面闪烁把渲染塞回主循环而不是开独立线程。三是模型输出的合法动作列表为空导致卡死此时应捕获异常并调用一次硬降处理而不是直接结束游戏。演示前先在渲染模式下按固定种子跑 3 局确认不会中途崩出再进入录屏和答辩展示环节。本文还有配套的精品资源点击获取