简介:基于深度强化学习的深度Q网络模型自动玩俄罗斯方块项目,面向强化学习入门者、游戏AI开发者,以及希望用深度网络解决高维状态空间问题的学习者。项目并非简单匹配动作,而是让代理借助深度Q网络算法寻找所有状态下最优的终局组合,通过经验回放与目标网络机制提升训练稳定性和样本利用率。代码拆分为人工试玩、模型训练、模型自动运行三个模块,并包含神经网络结构配置、模型权重存档、训练与单局游戏演示动画等,可完整观察从随机策略到智能落子的收敛轨迹。资源共二十四个文件,以Python源码为主,辅以工程配置、模型数据、演示动图等,包体约3.39MB,轻量便捷。已有一千二百余人学习下载,适合需要边读代码边实验、快速搭建深度强化学习游戏项目的开发者。
1. 把 DQN 塞进俄罗斯方块:为什么直接调库反而更难训练
俄罗斯方块可能是最适合检验深度强化学习入门水平的项目之一——它不像 CartPole 那样几十分钟就能收敛,也不像围棋那样需要海量算力,它刚好卡在“任务可定义、状态可描述、奖励可设计”的中间地带。我拆过不少 DQN 相关资源,结论很直接:能跑起来和能训练出明显进步是两回事,多数项目卡在奖励函数和状态表示上,而不是网络结构。
这份资源把 DQN 玩俄罗斯方块的完整链路拆开了:从游戏逻辑实现、状态编码、奖励塑造,到 Double DQN 的关键改进、经验回放和目标网络的工程细节,再到训练曲线的读法。适合两类人——一类是刚学完 DQN 理论、想找一个比 CartPole 更有挑战性的验证场景的初学者;另一类是已经跑过强化学习代码、想看看怎么用有限算力把稀疏奖励任务调稳的从业者。读完你应该能做到:拿到代码后能说清每段是干什么的,训练异常时知道先查哪个环节。
2. 环境搭建与状态表示:模型的输入决定了训练上限
2.1 游戏规则如何转成强化学习接口
在动手训练之前,先把环境定义搞清楚。标准的强化学习接口是env.step(action) -> (state, reward, done, info),俄罗斯方块要适配这个接口,核心是把游戏逻辑拆成离散的时间步。
常见的做法是把操作拆成「移动 + 旋转 + 下落」的组合。一个动作可以是左移一格、右移一格、旋转一次、硬降到底,或者什么都不做。这里有一个很关键的设计选择:是否允许「软降」——即每次只下移一格。如果只保留硬降,游戏节奏太快,智能体很难学到精细的落点控制;但把所有动作组合展开,动作空间又会膨胀。我见过比较合理的折中是:动作集合限定为左、右、旋转、硬降、软降五个基础动作,每次 step 执行一个动作,然后游戏内部自动推进一到两行。
代码实现如下:
class TetrisEnv: def __init__(self, width=10, height=20): self.width = width self.height = height self.board = [[0] * width for _ in range(height)] self.current_piece = self._spawn_piece() self.score = 0 self.lines_cleared = 0 self.done = False def step(self, action): # action: 0=左移, 1=右移, 2=旋转, 3=软降, 4=硬降 reward = 0 if action == 0: self._move_left() elif action == 1: self._move_right() elif action == 2: self._rotate() elif action == 3: self._soft_drop() elif action == 4: self._hard_drop() # 推进游戏逻辑,检查是否消行 cleared = self._clear_lines() reward += self._line_reward(cleared) if self._is_game_over(): self.done = True reward -= self._game_over_penalty() return self._get_state(), reward, self.done, {}step方法的返回值格式是强化学习框架的标准契约。每次动作后先计算消行并给奖励,再判断是否结束并给惩罚。这样做的好处是把「移动操作」和「游戏规则结算」解耦,后续修改奖励权重时不需要动游戏逻辑。
这里_line_reward(cleared)和_game_over_penalty()是训练效果的敏感点。如果只按消行数给奖励,智能体容易学会「堆高拿分」而不是「保持稳定」,所以很多实现会在奖励函数里加入「堆叠高度」和「空洞数」的惩罚项。这个设计直接决定了智能体的行为风格,不要用默认参数一跑了之。
2.2 网格状态编码:为什么不用原始图像
很多新手第一反应是直接把屏幕像素作为输入,交给卷积网络处理。这在 CartPole 这类游戏上可行,但俄罗斯方块有一个特殊性:当前方块和下一个方块的信息在像素层面容易被卷积网络当作噪声,而且像素输入对算力的消耗比网格状态高一个量级。
更稳的做法是构造一个「语义状态张量」。常见方案是把 board 区域、当前方块、下一个方块分别编码成 20×10 的二值矩阵,然后在通道维度上拼接成一个 20×10×3 的张量。当前方块的位置需要投影到网格坐标中,这样模型看到的其实是「已经落定的方块布局 + 当前操作中的方块 + 即将到来的方块」三个信息层。
def _get_state(self): # 通道0: 已落定方块 board_state = [[1 if cell else 0 for cell in row] for row in self.board] # 通道1: 当前方块投影 piece_state = [[0] * self.width for _ in range(self.height)] piece_pos = self.current_piece.get_grid_positions() for x, y in piece_pos: if 0 <= y < self.height and 0 <= x < self.width: piece_state[y][x] = 1 # 通道2: 下一个方块 next_state = [[0] * self.width for _ in range(self.height)] next_pos = self.next_piece.get_grid_positions() for x, y in next_pos: if 0 <= y < self.height and 0 <= x < self.width: next_state[y][x] = 1 return np.stack([board_state, piece_state, next_state], axis=0)三个通道各司其职:board 通道让模型知道当前的堆积形态,piece 通道告诉模型可控方块在哪,next 通道提供前瞻信息。使用网格状态而非原始像素,可以让模型更集中地关注布局结构,避免把算力浪费在渲染纹理和颜色上。
如果你希望模型具备更全局的规划能力,可以额外加入高度特征和空洞特征作为辅助输入,在特征拼接时和卷积输出合并。不过这在入门阶段不是必选项,先跑通基础版本,再决定是否添加。
2.3 下一个方块的投影方式
这里有一个我踩过坑的细节:下一方块的「投影」到底放在哪里。有人把下一方块直接放在棋盘顶部中间位置,这会导致模型无法区分「当前操作的方块」和「预览方块」,训练初期容易产生混乱的策略。
一般做法是将下一个方块投影到棋盘顶部偏左的位置,并在通道设计上保持与当前方块通道的严格区分。模型通过两个通道的相对位置差异来学习「当前控制」和「未来参考」的区别。如果你把两个通道的信息混在一起,奖励信号就变得模糊,训练收敛会明显变慢。
3. DQN 网络结构与训练机制:从 Q 表到经验回放
3.1 网络输出层为什么是动作数而不是 Q 值
传统 Q-learning 的做法是维护一张状态-动作值表,但俄罗斯方块的状态空间包含了棋盘的全部排列组合,用表格存储不现实。DQN 的核心思路是用神经网络拟合 Q 函数,输入是状态张量,输出是每个动作的预测 Q 值。
网络结构上,卷积层 + 全连接层是标准配置。输入 20×10×3 的网格张量,经过两层卷积提取局部空间特征,然后在全连接层展平并映射到动作空间维度。动作空间为 5 时,输出层就是 5 个神经元,每个神经元对应一个动作的估计 Q 值。
class DQN(nn.Module): def __init__(self, input_shape=(3, 20, 10), n_actions=5): super(DQN, self).__init__() self.conv1 = nn.Conv2d(input_shape[0], 32, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.fc1 = nn.Linear(64 * input_shape[1] * input_shape[2], 256) self.fc2 = nn.Linear(256, n_actions) def forward(self, x): x = F.relu(self.conv1(x)) x = F.relu(self.conv2(x)) x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) return self.fc2(x)最后一个全连接层不接激活函数,因为 Q 值本身可以是负数,不需要压缩到特定区间。训练时网络输出的是原始 logits,loss 计算时直接和 target Q 值做均方误差。
之所以输出层设计成动作数维度而不是单输出,是为了在训练时一次前向得到所有动作的 Q 值,e-greedy 策略选择动作时也只需要一次推理。这是 DQN 最基础的工程约定,几乎所有实现都沿用这个结构,区别主要在卷积层数和通道数。
3.2 经验回放为什么能稳定训练
强化学习训练不稳定的核心原因是样本之间存在时间相关性——相邻 step 的状态非常相似,如果按顺序学习,模型会频繁在局部区域震荡。经验回放的作用是打破这种时间相关性,做法是把每一步的(state, action, reward, next_state, done)存入一个固定容量的队列,训练时从中随机采样一个小批次。
队列容量一般取 10000 到 100000 之间。容量太小,采样的样本多样性不足,模型容易遗忘早期经验;容量太大,早期样本可能已经过时,策略改变后旧样本对当前训练的参考价值下降。
class ReplayBuffer: def __init__(self, capacity=50000): self.capacity = capacity self.buffer = deque(maxlen=capacity) def push(self, transition): self.buffer.append(transition) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) return zip(*batch)deque(maxlen=capacity)的一个隐含行为是队列满时自动丢弃最旧的样本。这个「自动淘汰」机制在策略快速变化期可能导致旧经验和新策略不一致,所以很多改进版本会加入优先经验回放,按 TD-error 的大小调整采样概率。
从经验看,至少要让 buffer 里积累到几万条样本后再开始训练,否则初始采样全是随机策略的交互数据,学习效率很低。你可以先让智能体用随机动作跑 5000 步热数据,再进入正式的「交互-学习」循环。
3.3 目标网络的同步节奏
DQN 的另一个经典不稳定来源是「移动目标」问题——网络既要预测 Q 值,又要作为 Q 值更新的目标,参数一变,目标也跟着变。解决方法是维护一份参数冻结的目标网络,每隔若干个 step 把在线网络的参数复制过去。
目标网络更新频率是一个需要调节的超参数。更新太频繁,目标移动快,训练不稳定;更新太慢,目标滞后严重,学习速度下降。一般的经验值是每 500 到 2000 步同步一次。在俄罗斯方块这种稀疏奖励场景,我倾向于取偏低频率——每 1000 步同步一次,让目标在一个稳定的尺度上持续较长时间。
# 每隔 target_update 步同步一次目标网络 if step_count % target_update == 0: target_net.load_state_dict(policy_net.state_dict())如果你用 Double DQN,计算 target Q 值的时候要区分动作选择网络和动作评估网络:用在线网络选择最佳动作,再用目标网络计算该动作的 Q 值。这样做的目的是消除 Q 值的高估偏差,在俄罗斯方块这种奖励稀疏的环境里,高估偏差会让模型误以为某些动作有很高收益,最终形成激进堆叠的策略。
4. 奖励函数设计与训练曲线:如何让智能体从乱动到会玩
4.1 稀疏奖励为什么难训
如果奖励只在消除一行时给 1 分,其他情况下给 0,那么一局游戏里可能只有几次正向奖励,大多数交互回合 reward 都是 0。这种情况下经验回放里大量样本的 reward 是 0,网络很难从这些中性样本中学习到有价值的信息。
更细的奖励设计通常包含三个部分:消行奖励、堆叠惩罚、回合结束惩罚。消行奖励可以按行数放大——消 4 行给额外加分;堆叠惩罚要关注当前棋盘的堆叠高度和空洞数量;回合结束惩罚用于告诉模型「这种局面不应该发生」。
def _line_reward(self, cleared): if cleared == 0: return 0 elif cleared == 1: return 1.0 elif cleared == 2: return 2.0 elif cleared == 3: return 5.0 elif cleared == 4: return 8.0 def _stack_penalty(self): # 计算当前棋盘最高列的高度均值 heights = [self._column_height(col) for col in range(self.width)] return -0.01 * sum(heights) / len(heights) def _hole_penalty(self): holes = self._count_holes() return -0.05 * holes经验上,消行奖励的数值不要给太大,否则智能体会发现「只要尽量消一行,不管整体局面多乱」的行为模式。堆叠惩罚和空洞惩罚的系数要小到不至于压过消行奖励,但又大到能阻止智体无脑堆高。这里有强烈的玄学成分,不同系数组合会训练出完全不同风格的智能体,我在调参时通常先固定消行奖励,再单独扫描堆叠惩罚的系数。
4.2 e-greedy 策略的衰减曲线
DQN 训练期间需要在探索和利用之间做权衡。训练初期采取高探索率,让智能体充分尝试各种操作;后期降低探索率,让模型更多地利用学到的策略。e-greedy 是最简单的方案,但衰减参数直接影响训练效果。
epsilon = max(epsilon_min, epsilon_start * (epsilon_decay ** episode)) # epsilon_start=1.0, epsilon_end=0.05, epsilon_decay=0.995这里epsilon_decay是每回合的衰减系数。0.995 意味着大约 500 回合后 epsilon 降到初始值的一半左右。如果衰减太快,模型还没充分探索就进入利用阶段,容易陷入局部最优——比如只学会把方块堆在两侧;衰减太慢则训练时间拉长,前期大量样本都是无意义的随机动作。
一个实用的观察方法是把每一步的 epsilon 值记录到日志里,训练结束后看探索率曲线是否平滑。如果曲线出现「跳水式」下跌,说明衰减系数偏大,调低到 0.998 再试。
4.3 训练日志与损失曲线怎么看
很多人在训练后只看最终奖励曲线,但奖励曲线在稀疏奖励环境下可能一直很低、没有明显上升趋势,这时候不代表训练失败了,而是奖励信号本身就被挤压到一个窄区间。更好的指标是 TD loss 的变化趋势,以及平均每局时长。
如果 loss 持续下降到某个平台后开始震荡,说明训练进入瓶颈期,可以尝试减小学习率或调整目标网络更新频率。如果 loss 始终不下降,问题大概率在状态表示或奖励设计,而不是网络结构。强化学习里的 loss 下降不像监督学习那样直接代表性能提升,但loss完全不降通常意味着 Q 值估计和目标值之间的差距没有被有效缩小,整个学习过程很可能是在空转。
我习惯同时记录两个量:滑动平均奖励和平均存活步数。如果平均存活步数在上升,说明智能体学会了「更久的生存策略」,即使得分不高也算有效学习。
5. 训练避坑与常见问题:我跑坏过的几个地方
5.1 模型只学会硬降,不学会调整落点
现象:训练完成后,智能体的行为看起来是「方块一下来就立刻硬降到底」,很少做水平移动和旋转,棋盘上堆积形态很差,消行率极低。
原因:硬降动作的 reward 和其他动作的 reward 在时间尺度上不一致。硬降能快速结束当前方块的操控,从而较快获得消行奖励;左移、右移和旋转需要多步操作才能等到奖励反馈,时间延迟更长,Q 值被稀释。如果松弛系数设置不当,模型会发现「速战速决」在当前环境下有更高的短期收益。
解决:给硬降动作加一个惩罚项,或者在奖励函数中显式降低硬降的收益。另一个做法是在动作空间中取消硬降,只保留软降——让方块在重力作用下自然下落,模型控制左右和旋转,这样每个动作的时间尺度更接近。我实际测试中,取消硬降后训练时间增加了约 30%,但行为质量明显好很多。
5.2 经验回放里全是垃圾样本,训练越跑越偏
现象:训练到中期,回放缓冲区里的样本大量来自早期随机策略——方块乱扔、马上结束。模型从这些样本中反复学习「坏行为」,策略变得越来越差。
原因:普通的deque(maxlen=capacity)按时间顺序淘汰最旧的样本,但早期随机策略产生的样本量占比极高,尤其是训练初期。随着训练推进,这些样本没有过期意识,还会被反复采样到。
解决:优先经验回放是一个系统性的解决方法——按 TD-error 给样本加权,误差大的样本采样概率更高。如果不打算换算法,一个临时的工程手段是:在训练开始后的前 N 个回合只做随机探索、不更新网络,等 buffer 积累到一定量再开启学习;或在训练中途清空 buffer 重新采集一轮数据。后者会丢一些信息,但比被垃圾样本带偏要好。
5.3 目标网络同步频率过低导致 Q 值爆炸
现象:训练过程中 TD loss 突然上升几个数量级,随后出现 NaN,整个模型参数变成无效值。
原因:目标网络更新太慢,Q 值估计持续偏离目标值,TD-error 持续累积,梯度过大导致数值溢出。另一个常见诱因是 reward 归一化没做好,如果奖励数值过大(例如消 4 行给 100 分),误差反向传播时梯度爆炸概率极高。
解决:检查 reward 的量级,确保大部分样本的 reward 在 [-1, 1] 区间内。同时对梯度做裁剪,torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)是常见的防护手段。目标网络更新频率如果低于 2000 步,先调到 1000 步试试。注意,DDQN 的 target Q 值计算方式已经能抑制一部分高估问题,但如果奖励量级本身不合理,任何算法都救不回来。
5.4 训练集和评估集行为差异极大
现象:训练结束后让智能体玩几局,发现得分还可以,但每局都很快出现「死局」——方块堆到顶部后无路可走,游戏结束。
原因:训练过程中的探索噪声让智能体偶尔做出次优动作,这些动作在评估阶段被排除后,策略的鲁棒性不足。更关键的是,俄罗斯方块有随机方块序列的问题,训练时遇到的序列分布和评估时的分布未必一致,模型可能过拟合了训练中常见的某些方块排列模式。
解决:评估阶段使用更保守的随机种子来生成方块序列,固定一批评估种子,每 10 个训练回合做一次评估,记录评估得分和训练得分。如果两者差距持续扩大,说明过拟合,可以降低探索率或增加经验回放多样化。也有人用「不做任何动作」作为 baseline 对比,看模型的策略是否真的比无为而治有效——这个对比很直观,很多模型训练得自我感觉良好,但一和 baseline 对比就露馅。
5.5 堆叠惩罚系数调得太大,模型变成「保守派」
现象:训练后期,智能体倾向于把方块堆在左右两侧、中间留空,从不主动填补中间区域,消行率很低,但游戏能持续很长时间。
原因:空洞惩罚系数设置过大,模型学会了「避免创造空洞」的策略,而消行带来的奖励不足以激励它冒险填补空洞。这种行为本质上是「两害相权取其轻」,模型在惩罚空间主导下退化成保守策略。
解决:缩小空洞惩罚系数的绝对值,让消行奖励的相对权重更大。一般来说,空洞惩罚系数乘以空洞数后的平均值,应当小于消行奖励的 30%。你可以打印一段训练日志,观察每个回合的 reward 拆解——消行奖励占比和惩罚占比各是多少,如果惩罚占到 80% 以上,说明奖励函数失衡了。
6. 让模型真正会玩:验证方法与进阶技巧
训练完成后下一步是验证。很多人直接看一眼最终奖励曲线就下结论,但奖励曲线在强化学习中是最容易被美化或误读的指标——滑动平均的窗口、探索率的残余影响都会干扰判断。更靠谱的验证方法是「冻结策略」:把 epsilon 设为 0,让模型纯贪心地选择动作,连玩 30 局,统计平均得分、平均存活步数和消行数。这个指标才是模型真实能力的体现,训练期间 epsilon 高时的表现是有水分的。
如果冻结策略后的表现不理想,先别急着调超参数,我一般会做一个「单一方块模式」的专项验证:只让游戏依次出现同一类型的方块,看模型是否学会了该方块的旋转和落点摆放逻辑。俄罗斯方块的 7 种方块行为差异很大,模型可能对其中 3 种很擅长、对另外 4 种很糟糕。这个专项验证能定位模型到底在哪个方块类型上失效,比盲目调参高效得多。
进阶方向通常有两个:在状态表示中加入更多高层特征——比如「当前最高列高度」「空洞总数」「悬崖数」,与卷积特征拼接后送入全连接层;另一个是升级到 Dueling DQN,把 Q 值拆分成状态价值和动作优势,让模型在「哪些状态本身好」和「哪些动作在这个状态下更好」之间分离学习。Dueling DQN 对俄罗斯方块尤其友好,因为游戏的很多状态下,大多数动作的 Q 值差异不大,拆分结构能减少无效更新。
我自己跑这个项目时最深刻的教训是:不要在一开始就追求完美奖励函数。先用最简陋的消行奖励跑通整个训练链路,确认代码没有 bug——这个阶段最容易发现环境逻辑错误和维度不匹配问题。等训练曲线正常更新了,再逐步添加堆叠惩罚和空洞惩罚,每次只加一项,观察行为变化。从那次以后,我每次训练强化学习模型都强制走一遍「最小可用环境 → 随机策略跑通 → 奖励函数逐项加码」的流程,避免了无数次奖励函数和代码 bug 纠缠不清的排查噩梦。
这份资源里的代码仓库节约了我大约三天的环境搭建时间,网络结构、经验回放、目标网络这些部件拿过来就能用,你要做的只是把奖励函数的系数按自己的需求调一调。希望帮到你。
本文还有配套的精品资源,点击获取