简介:这份资源是一套基于PyTorch训练强化学习俄罗斯方块AI的毕业设计代码合辑,面向深度学习初学者、毕业设计学生以及游戏AI爱好者。压缩包内文件共七份,包含三个Python脚本(分别负责DQN模型构建、训练循环与环境交互)、两份Markdown文档(说明实现思路与使用指南)、一份txt依赖清单以及一份演示GIF,整体大小约21.54MB,便于快速下载与部署。已有九百四十五人学习,代码覆盖环境模拟、经验回放缓冲区、Q网络更新与目标网络同步等关键模块,能帮助读者直观理解Q-Learning与DQN在游戏场景中的落地过程。配合演示动画与文档说明,可显著降低复现门槛,是一份兼具教学与实践价值的毕业设计参考。
1. 为什么用DQN训练AI打俄罗斯方块
俄罗斯方块并不是一个“简单”的强化学习环境。尽管规则只有7种方块和10x20的棋盘,但棋盘可能布局的组合数远超传统Q表能维护的量级,再叠加当前方块类型与旋转姿态,状态空间接近天文数字。用PyTorch构建的DQN(Deep 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_dim=128, num_actions=6): 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, dtype=np.float32) piece_code[piece_id] = 1.0 # 方块类型 one-hot piece_code[7 + rotation] = 1.0 # 旋转姿态 one-hot next_code = np.zeros(7, dtype=np.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, width=10, height=20): self.width = width self.height = height self.board = np.zeros((height, width), dtype=np.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目标的尺度也会很大,反向传播的梯度跟着放大,参数更新一步就可能把网络权重推出有效区域。因此需要把奖励缩放到一个合适的量纲。我实测一组比较稳的缩放配置是:
| 事件 | 原始奖励 | 缩放后 |
|---|---|---|
| 方块落定 | +1 | +0.5 |
| 消一行 | +10 | +2.0 |
| 消两行 | +30 | +4.0 |
| 消三行 | +60 | +6.0 |
| 消四行 | +100 | +8.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步的状态和第t+1步的状态几乎一样,如果把连续N步的样本直接喂给网络做梯度下降,更新方向会严重偏向最近这一局游戏的局势,导致灾难性遗忘。经验回放的做法是建立一个样本池,先把交互数据存进去,训练时随机采样一个batch,抹掉时间相关性。
from collections import deque import random class ReplayBuffer: def __init__(self, capacity=100_000): 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 = random.sample(self.buffer, batch_size) states = torch.tensor(np.stack([b[0] for b in batch]), dtype=torch.float32) actions = torch.tensor([b[1] for b in batch], dtype=torch.long).unsqueeze(1) rewards = torch.tensor([b[2] for b in batch], dtype=torch.float32).unsqueeze(1) next_states = torch.tensor(np.stack([b[3] for b in batch]), dtype=torch.float32) dones = torch.tensor([b[4] for b in batch], dtype=torch.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_decay=0.995意味着每跑完一个episode,ε缩小到原来的0.995。300个episode后ε约0.22,AI大约每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_size=32, gamma=0.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, keepdim=True)[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_size | 32 ~ 64 | 越小越易震荡,越大越稳但更慢 |
| γ | 0.95 ~ 0.99 | 缺长线预判能力就增大 |
| 目标网络同步间隔 | 500 ~ 2000步 | 默认1000,loss发散就调小 |
| ReplayBuffer容量 | 5万 ~ 20万 | 内存可控范围内尽量大 |
| 隐藏层维度 | 128 ~ 256 | 218维输入配128起步足够 |
我自己的调试顺序是:先用学习率1e-4、batch 32、γ=0.99跑500个episode看曲线,再不理想就调整隐藏层宽度,最后才去动奖励函数中的空洞惩罚权重。避免同时改多个参量,否则出了问题无法定位。另外别忘了PyTorch环境要稳定,conda里装好cuda版pytorch后加上cudatoolkit,CPU也能跑但这个场景下训练速度会慢几十倍,游戏逻辑在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不匹配,这个坑我已经踩过多次。
本文还有配套的精品资源,点击获取