ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

PyTorch DQN实战:训练AI玩转俄罗斯方块

PyTorch DQN实战:训练AI玩转俄罗斯方块 简介这份资源是一套基于PyTorch训练强化学习俄罗斯方块AI的毕业设计代码合辑面向深度学习初学者、毕业设计学生以及游戏AI爱好者。压缩包内文件共七份包含三个Python脚本分别负责DQN模型构建、训练循环与环境交互、两份Markdown文档说明实现思路与使用指南、一份txt依赖清单以及一份演示GIF整体大小约21.54MB便于快速下载与部署。已有九百四十五人学习代码覆盖环境模拟、经验回放缓冲区、Q网络更新与目标网络同步等关键模块能帮助读者直观理解Q-Learning与DQN在游戏场景中的落地过程。配合演示动画与文档说明可显著降低复现门槛是一份兼具教学与实践价值的毕业设计参考。1. 为什么用DQN训练AI打俄罗斯方块俄罗斯方块并不是一个“简单”的强化学习环境。尽管规则只有7种方块和10x20的棋盘但棋盘可能布局的组合数远超传统Q表能维护的量级再叠加当前方块类型与旋转姿态状态空间接近天文数字。用PyTorch构建的DQNDeep Q-Network把状态-动作价值函数压缩成一次神经网络的前向传播从原理上绕开了Q表存储和查询的瓶颈。这份代码合辑里的AITetris.py跑通了一条完整的训练链路俄罗斯方块游戏环境类、ReplayBuffer经验回放、目标网络延迟同步、ε-greedy探索调度以及模型权重保存与可视化demo。适合正在做毕业设计、想用真实游戏理解DQN收敛过程、或者需要一套能改造为其他小游戏AI框架的开发者。下面直接从网络结构、状态编码、训练循环和调参技巧四条线拆开讲。2. DQN网络结构与状态特征编码2.1 从Q表到DQN为什么神经网络能拟合价值函数经典Q-Learning维护一张Q[s][a]表每行是一个状态每列是一个动作表格的值就是该状态-动作对的期望回报。这种方案在迷宫、寻路这类状态可穷举的环境里工作得很好但俄罗斯方块棋盘有200个格子仅棋盘占用组合就有2^200量级以现有存储根本不可能建表。更重要的是Q表的更新只波及当前被访问的条目相邻状态的更新互不共享信息学习效率极低。DQN用参数化的神经网络Q(s, a; θ)替代了查表。网络输入是状态特征向量输出是各候选动作的Q值估计。由于神经网络具有泛化能力当状态相近时输出也相近这恰好符合俄罗斯方块这类环境中“相似局面有相似价值”的直觉。PyTorch里搭一个基础DQN网络只需要几十行代码import torch import torch.nn as nn import torch.nn.functional as F class DQN(nn.Module): 两层隐藏层的MLP网络输入状态特征输出动作Q值 def __init__(self, input_dim, hidden_dim128, num_actions6): super(DQN, self).__init__() self.fc1 nn.Linear(input_dim, hidden_dim) self.fc2 nn.Linear(hidden_dim, hidden_dim) self.fc3 nn.Linear(hidden_dim, num_actions) def forward(self, x): x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) return self.fc3(x)input_dim由第2.2节的特征编码方案决定num_actions对应AI可选的动作数。输出层不接激活函数因为Q值没有取值范围限制可能为正也可能为负输出层套Sigmoid或Tanh反而会限制Q值的表达空间。隐藏层维度在128到256之间选择即可我一般先用128起步训练效果不理想再调大到256。这里注意不需要在forward里调用softmax。Q值的含义是“长期累积奖励的期望”不同动作的Q值可以很接近甚至相等不需要归一化成概率分布。很多初学者在这里会混淆分类网络的输出层与DQN的输出层前者需要softmax得到概率后者只需要线性输出。2.2 状态特征编码棋盘、方块与位置的三段式拼接DQN的效果上限很大程度由状态特征决定。AITetris.py实践下来比较可靠的特征方案是把状态拆成三段并拼接棋盘占用矩阵、当前方块的类型与旋转姿态、下一个方块的预告类型。棋盘是一个20x10的0/1矩阵1表示该格子已被占用。将矩阵按行展平得到200个特征值。当前方块的信息用one-hot编码方块类型有7种I、O、T、S、Z、J、L占7位旋转姿态有4种0到3占4位共11位。下一方块只需编码类型占7位。加起来218维。import numpy as np def encode_state(board, piece_id, rotation, next_piece_id): # board: 20x10 的0/1二维数组 board_flat board.flatten().astype(np.float32) piece_code np.zeros(11, dtypenp.float32) piece_code[piece_id] 1.0 # 方块类型 one-hot piece_code[7 rotation] 1.0 # 旋转姿态 one-hot next_code np.zeros(7, dtypenp.float32) next_code[next_piece_id] 1.0 # 下一方块 one-hot return np.concatenate([board_flat, piece_code, next_code])全部特征值都是0或1天然处于同一量纲不需要额外的归一化过程这是这个编码方案的一个隐藏优点。如果你的网络输入包含连续值特征比如方块当前坐标则建议统一缩放到[-1, 1]避免某一维的特征值过大压制其他维度的梯度贡献。我在实验中发现如果把棋盘展平前先计算“空洞数”“最大列高”“行洞交叉数”这类高级特征加入向量DQN的收敛速度会明显加快。但作为毕业设计演示保持原始棋盘编码更直观——训练曲线可以清晰地展示从盲目探索到学会堆叠的策略演变过程。2.3 动作空间裁剪从19种底层操作到6个离散动作原始俄罗斯方块的键盘操作一共有左移、右移、左旋转、右旋转、软降、硬降等大类不同类型的方块旋转次数还不一样如果把这些原语操作直接作为动作空间DQN要学的策略粒度太细训练效率很低。一个被反复验证的做法是把动作抽象到更高层。AITetris.py用一个6维离散动作空间左移一格、右移一格、顺时针旋转、软降一格、硬降到底、原地等待。这里前4个是细粒度操作硬降是执行到落定为止的宏操作。虽然策略空间不是最小的但能完整覆盖从移动、旋转到落定的所有选择且实现简单。# 动作编号与含义映射 ACTIONS { 0: move_left, # 左移一格 1: move_right, # 右移一格 2: rotate, # 顺时针旋转90度 3: soft_drop, # 向下移动一格 4: hard_drop, # 直接落到底部 5: stay # 不操作等待下一步 }如果你想让AI学得更快可以进一步把动作定义为“当前方块旋转到某姿态后放置到某列”。例如10列乘以4种姿态得到约40个候选落点过滤掉越界的后还剩30个左右把这30个作为动作编号。这样网络不再需要学习“先移动再旋转再落下”的组合逻辑直接输出目标落点我个人在改造版本中使用这样的方案收敛到稳定消行的episode数大约能减少三分之一。代价是动作空间变大需要更多的采样来覆盖每一个列与姿态的组合。选择哪种动作空间取决于你的目标演示DQN原理用6维底层动作就好追求消行效果和训练速度就上高层落点动作。3. 游戏环境封装与奖励函数设计3.1 环境接口reset、step、render三件套强化学习训练循环要求环境和智能体之间有统一接口。AITetris.py把俄罗斯方块的游戏逻辑封装成TetrisEnv类对外暴露reset、step、render三个方法。内部维护棋盘、当前方块、下一方块、分数和终止标志。参照OpenAI Gym的接口规范写后续想换PPO或SAC算法也只需要微调。class TetrisEnv: def __init__(self, width10, height20): self.width width self.height height self.board np.zeros((height, width), dtypenp.int8) def reset(self): 清空棋盘并从随机状态开始 self.board.fill(0) # 比重新分配更高效 self.score 0 self.current_piece self._random_piece() self.next_piece self._random_piece() self.done False return self._get_observation() def step(self, action): 执行动作返回 (下一个状态, 奖励, 是否终止) if action 0: self._move(-1, 0) # 左移 elif action 1: self._move(1, 0) # 右移 elif action 2: self._rotate() # 顺时针旋转 elif action 3: self._move(0, 1) # 软降一格 elif action 4: self._hard_drop() # 硬降到底 if self._is_landed(): # 方块已落定 self._lock_piece() # 固定到棋盘 lines self._clear_lines() reward self._compute_reward(lines) self._spawn_next_piece() else: reward 0 return self._get_observation(), reward, self.done代码里所有底盘操作都封装成私有方法主循环感知不到游戏逻辑细节这是把DQN代码和游戏代码解耦的关键。reset返回的观测即为当前状态step返回的done状态在方块落定且新方块无处可放时置为True。3.2 奖励函数设计稀疏信号的密化策略俄罗斯方块天然的奖励信号只有消行而消行是一个低频事件尤其对训练初期的AI来说可能几百步都不会消一行这就是典型的稀疏奖励问题。如果只用消行分数做奖励DQN的前期学习几乎没有梯度信号。解决思路是密化奖励——给那些“促成消行”的中间动作一个小的正反馈给“制造死局”的中间动作一个小的惩罚。一个实用性很强的奖励配置是方块成功落定1。消一行10消两行30消三行60消四行100。落定后棋盘空洞数比之前增加-5。游戏结束-50。def _compute_reward(self, lines_cleared): reward 1.0 # 落定基础奖励 if lines_cleared 0: reward [0, 10, 30, 60, 100][min(lines_cleared, 4)] holes_now self._count_holes() if holes_now self._prev_hole_count: reward - 5.0 # 空洞数增加惩罚 self._prev_hole_count holes_now return reward空洞数增加即时惩罚了这个动作——一旦某次落定导致某个格子的下方变成空格且该格被占用这里就产生了永远无法消除的隐患。AI会逐渐学会避免将方块落成“桥洞”形状。空洞惩罚的权重是关键约束设计过大超过-10会让AI过于保守明明能消四行的操作也不敢做过小则AI对空洞不敏感经常堆出高塔然后撞顶。3.3 奖励缩放与折扣因子的配合DQN的损失函数直接对Q值做MSE回归如果奖励数值范围过大TD目标的尺度也会很大反向传播的梯度跟着放大参数更新一步就可能把网络权重推出有效区域。因此需要把奖励缩放到一个合适的量纲。我实测一组比较稳的缩放配置是事件原始奖励缩放后方块落定10.5消一行102.0消两行304.0消三行606.0消四行1008.0空洞增加-5-1.0游戏结束-50-2.0缩放系数取2到5之间核心目标是让大部分奖励值落在[-3, 8]之间Q值估计的方差就不会爆炸。这里有个经验判断标准如果在训练日志里看到loss在1e-3以下持续震荡大概率是奖励尺度过小模型“看不上”这些奖励信号如果loss在10以上抖动则是奖励尺度太大。折扣因子γ决定了AI多远视。γ0.95意味着未来第10步的收益在当前计算时只保留0.59的影响γ0.99则保留0.90的影响。俄罗斯方块这类需要预判消行位置的环境建议γ0.99。在实际训练中我很少同时调整γ和奖励缩放系数一次只动一个变量否则很难判断哪个参数导致了效果变化。4. 经验回放与训练循环实现4.1 经验回放缓冲区打破时间连续性强化学习的数据与监督学习有一个本质差异交互样本是时序相关的。第t步的状态和第t1步的状态几乎一样如果把连续N步的样本直接喂给网络做梯度下降更新方向会严重偏向最近这一局游戏的局势导致灾难性遗忘。经验回放的做法是建立一个样本池先把交互数据存进去训练时随机采样一个batch抹掉时间相关性。from collections import deque import random class ReplayBuffer: def __init__(self, capacity100_000): self.buffer deque(maxlencapacity) def push(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 torch.tensor(np.stack([b[0] for b in batch]), dtypetorch.float32) actions torch.tensor([b[1] for b in batch], dtypetorch.long).unsqueeze(1) rewards torch.tensor([b[2] for b in batch], dtypetorch.float32).unsqueeze(1) next_states torch.tensor(np.stack([b[3] for b in batch]), dtypetorch.float32) dones torch.tensor([b[4] for b in batch], dtypetorch.float32).unsqueeze(1) return states, actions, rewards, next_states, dones def __len__(self): return len(self.buffer)deque的maxlen参数达到上限后新样本会从队首挤出最老样本。容量设到10万条对俄罗斯方块这种单局几百步的环境能覆盖数百局的完整经验。random.sample是均匀随机采样每条经验被抽中的概率与它存入时间无关这正是打破相关性所需要的。4.2 ε-贪心探索与调度策略DQN训练初期网络的Q值输出基本是噪声。如果每次都选Q值最大的动作优先级只会被随机初始化牵着走AI永远不会尝试消行这个高收益动作。ε-贪心策略解决了探索与利用的平衡以概率ε选择随机动作以概率1-ε选择当前Q值最大的动作。def select_action(state, policy_net, epsilon): if random.random() epsilon: return random.randrange(6) # 随机探索均匀采样动作 else: with torch.no_grad(): q_values policy_net(state.unsqueeze(0)) return q_values.argmax().item() # 每个episode结束后衰减 epsilon 1.0 epsilon_min 0.01 epsilon_decay 0.995 for episode in range(num_episodes): # ... 运行episode ... epsilon max(epsilon_min, epsilon * epsilon_decay)epsilon_decay0.995意味着每跑完一个episodeε缩小到原来的0.995。300个episode后ε约0.22AI大约每5步有一步在探索1000个episode后ε接近0.01基本纯利用。如果方块下落速度快导致episode很短可以改用按步数衰减每步乘0.9995这样不会因episode长度变化影响探索进度。4.3 训练主循环与目标网络参数同步DQN有一个数学推导层面的隐患如果只用同一个网络计算当前Q值和下一状态的目标Q值更新参数会同时改变目标值等于在追赶一个会动的靶子训练容易震荡甚至发散。目标网络target_net就是用来固定“靶子”的——它从policy_net复制一份参数在若干步内保持不变每隔一段时间整体同步一次。def train_step(policy_net, target_net, optimizer, memory, batch_size32, gamma0.99): states, actions, rewards, next_states, dones memory.sample(batch_size) # 当前状态-动作的Q值 q_values policy_net(states).gather(1, actions) # 下一状态的最大Q值由目标网络计算不参与梯度回传 with torch.no_grad(): next_q target_net(next_states).max(1, keepdimTrue)[0] td_target rewards gamma * next_q * (1 - dones) loss nn.functional.mse_loss(q_values, td_target) optimizer.zero_grad() loss.backward() optimizer.step()gather(1, actions)把策略网络输出的[batch, 6]张量中每个样本实际执行动作对应的Q值取出来形成[batch, 1]向量。td_target里的(1 - dones)是关键细节终局状态没有下一状态next_q应为0否则会把终局后的虚构奖励也算进去。目标网络同步间隔我一般设1000步每1000步把policy_net的state_dict整体拷贝给target_net。主循环整合代码如下for episode in range(num_episodes): state env.reset() while not env.done: action select_action(state, policy_net, epsilon) next_state, reward, done env.step(action) memory.push(state, action, reward, next_state, done) state next_state if len(memory) batch_size: train_step(policy_net, target_net, optimizer, memory) total_steps 1 if total_steps % 1000 0: target_net.load_state_dict(policy_net.state_dict()) epsilon max(epsilon_min, epsilon * epsilon_decay)注意memory.length不足batch_size的episode不触发训练这是为了避免在小样本上反复拟合导致过拟合。注意target_net的参数在同步前必须处于无梯度计算模式。torch.no_grad()只是为了节省显存和速度不影响正确性但忘了加会让反向传播的消耗翻倍。5. 调参实战让俄罗斯方块AI稳定刷分5.1 训练状态监控指标跑训练时不要只看loss。loss下降不等于策略变好因为DQN的loss衡量的是TD误差而TD误差会因为奖励缩放、折扣因子等设置变化而改变绝对值。我习惯额外记录三个指标最近100个episode的平均分数、平均消行数、以及Q值的均值。平均分数反映策略优劣Q值均值反映价值估计是否合理。正常曲线是Q值均值逐渐上升并趋稳如果Q值一路涨但分数不变说明AI可能发现了某个获取伪奖励的路径——检查奖励函数是否有能被钻的空子。5.2 超参推荐取值与调试顺序超参推荐范围调参提示学习率1e-4 ~ 5e-4优先调整项震荡就减半batch_size32 ~ 64越小越易震荡越大越稳但更慢γ0.95 ~ 0.99缺长线预判能力就增大目标网络同步间隔500 ~ 2000步默认1000loss发散就调小ReplayBuffer容量5万 ~ 20万内存可控范围内尽量大隐藏层维度128 ~ 256218维输入配128起步足够我自己的调试顺序是先用学习率1e-4、batch 32、γ0.99跑500个episode看曲线再不理想就调整隐藏层宽度最后才去动奖励函数中的空洞惩罚权重。避免同时改多个参量否则出了问题无法定位。另外别忘了PyTorch环境要稳定conda里装好cuda版pytorch后加上cudatoolkitCPU也能跑但这个场景下训练速度会慢几十倍游戏逻辑在Python层已经吃掉不少开销了。跑通代码再装GPU版也来得及把torch.device(cuda if torch.cuda.is_available() else cpu)写到开头就行。5.3 模型验证与演示训练结束后把epsilon设为0走纯贪婪模式跑100个episode统计平均分。AITetris.py里提供了一个demonstration程序加载保存的.pth权重文件并可视化AI的行为。除了看分数之外我还喜欢做一个扰动测试给网络输入加上均值0、标准差0.01的高斯噪声观察分数跌幅。跌得厉害通常意味着策略过拟合了训练分布此时可以加大ReplayBuffer容量或降低epsilon_min值增加探索空间。如果发现偶尔一局分数特别高、其他局都很低不用急着调参——DQN本身的Q值估计存在方差这种波动属于正常范围。真正要关注的是100个episode的平均趋势线是否在提升。跑完训练的模型权重直接torch.save保存即可演示时load_state_dict加载后必须加.eval()关闭dropout等训练态层。提示保存模型时别只存state_dict把input_dim、hidden_dim、num_actions也一并存成json。模型结构变了再load旧权重会报shape不匹配这个坑我已经踩过多次。本文还有配套的精品资源点击获取
返回列表