ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用DQN深度强化学习实现自动玩俄罗斯方块:环境搭建与训练调优

用DQN深度强化学习实现自动玩俄罗斯方块:环境搭建与训练调优 简介基于深度强化学习的俄罗斯方块DQN实战项目展示深度Q网络驱动智能体自动玩俄罗斯方块的完整过程。代理通过学习状态到最终得分的映射寻找最优终局组合并以神经网络逼近Q函数配合经验回放与目标网络稳定训练。代码按play.py、train.py、run_model.py区分手动操作、模型训练与自动游玩便于梳理DQN完整链路。资源共24个文件以6个Python脚本为主线另有gif演示动画、hdf模型权重、IDE配置和requirements.txt依赖说明整体3.39MB轻量易部署。目前已有1217人学习。包内含可直接复现的强化学习框架checkpoints内置多组不同轮次模型快照加载后直观对比智能体水平差异gif录屏呈现训练前后操作水准变化为扩展动作空间、调整网络结构等二次开发提供扎实起点。1. 为什么选 DQN 来自动玩俄罗斯方块一个值得复现的入门项目如果你正在学深度强化学习翻来覆去看 CartPole 和 MountainCar 的 demo 已经觉得不过瘾那基于深度强化学习的 DQN 模型实现自动玩俄罗斯方块是一个非常合适的中等难度落地项目。俄罗斯方块的动作空间、状态表示、奖励设计都可以自己控制调试起来比 Atari 游戏直观得多而且训练过程肉眼可见——AI 从乱放方块到能消行比任何指标曲线都更能让你确认“学进去了”。这个项目适合两类人一是刚学完 DQN 理论、想找一个非 toy 环境的读者二是想把自己的俄罗斯方块程序接上强化学习框架、顺便搞懂奖励函数怎么设计的开发者。下文会从 DQN 原理怎么在这个游戏上落地开始逐步拆到环境搭建、模型定义、训练循环、参数调优和踩坑记录。2. DQN 在俄罗斯方块上的设计取舍动作空间、状态表示与奖励塑形2.1 俄罗斯方块为什么是 DQN 的好载体非平稳性和延迟奖励DQN 的经典假设是马尔可夫决策过程也就是当前状态足够决定下一步的好坏。俄罗斯方块基本满足这一点但你很快会撞到两个有趣的问题第一当前局面加上当前方块并不能完全决定未来因为下一个方块是随机出现的第二消行的奖励是延迟的中间要经历若干个落子动作。这两点让它比 CartPole 难又比围棋简单非常适合用来理解 DQN 的几个核心机制。从 DQN 的角度看俄罗斯方块的挑战在于——每步动作只影响当前方块的位置和旋转但积分的来源是消行消行往往要攒好几行才发生延迟奖励明显。局面变化是离散的方块类型 7 种、列宽 10、行高 20状态空间远大于表格型 Q-Learning 能覆盖的范围。同一个局面下不同落点会带来完全不同的后续发展需要模型具备一定的“前瞻”能力这恰恰是神经网络拟合 Q 值的用武之地。所以这个项目不是简单把 DQN 套上去就能赢而是要解决状态怎么编码、奖励怎么给、经验池怎么采样三个核心问题。很多初学者在这三步上偷懒结果训练几千轮都不成长就误以为是 DQN 不行。2.2 动作空间设计从离散落点到完整动作序列先想清楚动作空间。俄罗斯方块每一帧能做的操作有左移、右移、旋转、加速下落、直接落底但如果把动作定义成“每一帧的按键组合”训练难度会大很多——智能体要学的是几十步的序列决策而每一步的收益信号又很稀疏。常见做法是把它改写成高层动作。我一般会把动作空间定义成“当前方块的落点位置 旋转姿态”。具体来说对当前 7 种方块枚举所有旋转形态4 种旋转状态再枚举每一列10 列过滤掉碰撞不合法的组合剩下的每一个合法落点就是一个离散动作。比如方块是长条 I可能只有 4 种姿态加若干列最终合法动作数量往往在 20 到 40 之间远小于按帧按键的空间。这样做的好处有两个第一动作空间小了DQN 输出的 Q 值数量从几十维降到几十维以内训练效率高第二智能体不需要学习“怎么把方块移动过去”只需要学习“落到哪里最好”把问题从运动控制简化成布局决策。代价是游戏的环境代码要做一次“动作展开”也就是把每个离散动作翻译成真实的按键序列和落底过程。动作展开的代码大致是这样def get_valid_actions(board, current_piece): 枚举当前方块所有合法落点。 board: 10x20 的二维数组1 表示已有方块0 表示空 current_piece: 包含 type 与 rotation 的字典 actions [] for rotation in range(4): shape rotate_piece(current_piece[type], rotation) for col in range(BOARD_WIDTH): row 0 # 找到该列能落下的最低位置 while not collides(board, shape, row, col): row 1 row - 1 # 回退到最后一个合法位置 if row 0: actions.append({ rotation: rotation, col: col, row: row, shape: shape }) return actions这段代码的逻辑很直接旋转状态有 4 种每种状态下尝试把方块放在每一列然后从上往下找第一个碰撞的位置它的上一行就是落地点。collides函数负责判断当前形状在指定行列是否和已有方块重叠或者是否超出边界。因为俄罗斯方块的物理规则就是只能落到底部或已有方块之上所以枚举所有落点之后智能体只需要从中选一个。参数上需要注意两点BOARD_WIDTH是固定值 10但如果你改高了游戏区域的宽度这个常量要跟着改rotate_piece的旋转规则要和你游戏环境里的保持一致最好直接用同一个函数否则会出现训练时能落、回放环境里落不了的“动作漂移”。2.3 状态表示三层特征还是原始像素状态表示是 DQN 项目里最影响训练结果的一环。俄罗斯方块有两种主流做法一种是直接给原始画面做灰度缩放像 Atari 那样把每一帧缩成 84x84 再堆叠 4 帧另一种是自己构造特征向量。我在实际项目中更推荐特征向量——原因很简单俄罗斯方块的视觉信息并不复杂真正的决策依据是“当前方块形状”、“当前局面是否有空洞”、“边缘平整度”这些高层特征。用原始像素意味着模型要自己从图像里学到这些概念对网络容量和训练数据量的要求高一个量级。一个我常用的特征向量包含这样几组参数特征组具体内容维度当前方块方块类型 one-hot、当前旋转状态 one-hot7 4局面高度每一列的高度、最高列高度、高度方差10 2空洞情况每列空洞数、总空洞数、最大空洞深度10 2平整度相邻列高度差绝对值之和1已消行数当前总消行数归一化后1进度指示当前行数 / 当前分数2总维度大概 40 左右。这个向量输入一个两层全连接网络效果已经可以超过多数手工策略。如果你想让模型有更强的泛化能力可以再叠加一个简化的棋盘编码——用20x10的二维矩阵展平拼在特征向量后面。关键在于状态表示里要把“当前方块”和“下一块方块”都包含进来。DQN 默认当前状态决定了未来收益但俄罗斯方块里下一个方块信息直接影响当前动作的收益估计。虽然严格来说这破坏了 MDP 假设但从工程上讲把下一块信息拼进特征是成本最低的补救方案。def get_state(game): 从游戏环境提取强化学习状态向量。 game 对象需要提供 board、current_piece、next_piece、score 等属性。 board game.board # 10x20 二维数组 column_heights [get_column_height(board, col) for col in range(10)] holes sum(get_column_holes(board, col) for col in range(10)) bumpiness sum(abs(column_heights[i] - column_heights[i1]) for i in range(9)) state [] # 当前方块编码 piece_type game.current_piece[type] # 0~6 piece_one_hot [0] * 7 piece_one_hot[piece_type] 1 state.extend(piece_one_hot) # 下一块方块编码 next_type game.next_piece[type] next_one_hot [0] * 7 next_one_hot[next_type] 1 state.extend(next_one_hot) # 列高特征 state.extend(column_heights) state.append(max(column_heights)) state.append(np.std(column_heights)) # 空洞与平整度 state.extend([get_column_holes(board, col) for col in range(10)]) state.append(holes) state.append(bumpiness) # 分数归一化 state.append(game.score / 10000.0) state.append(len(game.cleared_rows) / 20.0) return np.array(state, dtypenp.float32)这段代码里有一个容易被忽略的细节get_column_holes不是简单数空格而是从该列第一个有方块的格子开始往下数空格。如果整列都是空的空洞数记 0不能从顶部数到底不然每个初始局面都会带一堆噪声特征。这个函数的实现直接影响模型学习效率很多新手在这里写过“倒挂版本”。特征向量的归一化也要注意——分数和消行数这类数值跨度会越来越大如果不做归一化后阶段传入的值量级会压过列高和空洞特征导致网络梯度方向被分数主导。所以我在代码里把分数除以 10000、消行数除以 20让所有特征大致落在相近的数值范围内。2.4 奖励函数消行给大分惩罚要克制奖励函数是 DQN 项目中最玄学的部分之一。俄罗斯方块的直接奖励来源是消行但如果你只在消行时给奖励模型在绝大多数回合里拿到的都是 0学习信号太稀疏。常见的做法是拆成几个部分消行奖励每消 1 行给 10 分4 行Tetris额外翻倍给 100 分。落子小惩罚每落一个方块给 -1 的微小惩罚逼迫模型减少无意义的动作。列高惩罚落子后如果最高列高超过某个阈值比如 16 行给一个附加惩罚。空洞惩罚落子后总空洞数比之前增加按增量给惩罚。这里最需要克制的是空洞惩罚。很多初学者看到“空洞导致游戏失败”就给每个空洞 -5 的惩罚结果模型变得极度保守——宁可堆高右侧也不往中间落子制造空洞反而很快死掉。正确的做法是给“新增空洞数”一个温和惩罚比如每个新增空洞 -0.5同时给列高一个缓慢增长的惩罚让模型自己权衡“暂时堆高”和“制造空洞”哪个代价更小。奖励函数的代码写起来很直接但需要维护一个“上一状态”的缓存def calculate_reward(game, prev_board, prev_holes, cleared_rows): 根据游戏前后状态计算即时奖励。 prev_board: 该动作执行前的棋盘快照 prev_holes: 动作执行前的总空洞数 cleared_rows: 本次动作消掉的行数 reward 0.0 # 消行主奖励 if cleared_rows 0: reward cleared_rows * 10.0 if cleared_rows 4: reward 60.0 # Tetris 额外加成 # 落子惩罚 reward - 1.0 # 空洞增量惩罚 new_holes count_holes(game.board) hole_delta new_holes - prev_holes reward hole_delta * -0.5 # 列高超高惩罚 max_height get_max_height(game.board) if max_height 16: reward (max_height - 16) * -0.3 return reward奖励系数是项目里最值得花时间调的。我建议先把消行奖励固定然后单独调空洞惩罚系数用一组对比实验看平均消行数的变化趋势。系数调得太大会让模型不消行也不堆高太小则模型忽视空洞风险经常出现“消一行但埋下大空洞”的行为。3. 搭建可交互的俄罗斯方块环境从游戏逻辑到 Gym 风格接口3.1 自建环境还是改造开源实现做 DQN 项目环境代码的质量决定了后面所有调试的成本。俄罗斯方块的完整游戏逻辑包含方块生成、旋转、碰撞检测、消行判定、游戏结束判断几个模块。你可以选择改一个现成的 Python 俄罗斯方块也可以自己写一个精简版。我的建议是如果目标是强化学习优先自建一个不含图形界面的逻辑环境原因有三个。第一图形渲染会拖慢训练速度尤其当你用 Python 跑时pygame 的渲染开销比纯数组操作大一个数量级第二你需要环境提供“执行动作 - 返回状态、奖励、结束标志”的接口pygame 的实时循环模式要额外封装一层第三自建环境可以精确控制方块序列比如用随机数种子复现同一个局面这对调试 DQN 的 bug 至关重要。结构上分三层底层是棋盘和方块的数据结构中层是游戏规则逻辑上层是给 DQN 调用的reset、step接口。下面给出一个最小可用版本。3.2 定义棋盘、方块与碰撞检测import numpy as np BOARD_WIDTH 10 BOARD_HEIGHT 20 # 7 种方块每种是 4 个旋转状态的 2 维数组 SHAPES { I: [ [[1, 1, 1, 1]], [[1], [1], [1], [1]] ], O: [ [[1, 1], [1, 1]] ], T: [ [[0, 1, 0], [1, 1, 1]], [[1, 0], [1, 1], [1, 0]], [[1, 1, 1], [0, 1, 0]], [[0, 1], [1, 1], [0, 1]] ], # S, Z, J, L 类似此处省略 } def rotate_piece(piece_type, rotation): 返回指定方块在指定旋转角度下的形状矩阵 return [row[:] for row in SHAPES[piece_type][rotation % len(SHAPES[piece_type])]] def collides(board, shape, row, col): 判断 shape 放在 (row, col) 位置是否与现有方块冲突。 row 是从棋盘顶部开始计数的行号。 for r in range(len(shape)): for c in range(len(shape[0])): if shape[r][c] 0: continue board_r row r board_c col c if board_r BOARD_HEIGHT or board_c 0 or board_c BOARD_WIDTH: return True if board_r 0 and board[board_r][board_c] 1: return True return False这里的碰撞检测用了一个关键约定棋盘的行号 0 在顶部方块从顶部出现往下落是行号递增。collides返回True意味着这个位置不合法。注意board_r 0的判断它允许方块在完全进入棋盘之前就参与碰撞判定避免初始生成时误判为失败。SHAPES数据结构的坑在于旋转状态的数量不全是 4。O 型方块只有 1 个状态I 型其实也只有 2 个有效状态横、竖但为了统一枚举方便I 型可以写成 4 个重复状态或者len(SHAPES[piece_type])按实际长度走。如果你在rotate_piece里用rotation % 4给 O 型方块传 4 就会重复旋转虽然结果一样但会浪费一次动作枚举。我在实现里统一用len(SHAPES[piece_type])做取模这样方块自带正确的旋转状态数。3.3 游戏主逻辑下落、锁定与消行class TetrisEnv: def __init__(self, widthBOARD_WIDTH, heightBOARD_HEIGHT): self.width width self.height height self.board None self.score 0 self.current_piece None self.next_piece None self.done False self.reset() def reset(self, seedNone): 初始化棋盘和第一个方块 if seed is not None: np.random.seed(seed) self.board np.zeros((self.height, self.width), dtypenp.int8) self.score 0 self.done False self.current_piece self._spawn_piece() self.next_piece self._spawn_piece() return self._get_observation() def _spawn_piece(self): 随机生成一个方块类型 0~6 分别对应 I O T S Z J L piece_type int(np.random.randint(0, 7)) return {type: piece_type, rotation: 0} def step(self, action): 执行一个高层动作action 是 (row, col, rotation) 的落点信息。 返回 (state, reward, done, info) row, col, rotation action shape rotate_piece(self.current_piece[type], rotation) # 检查动作是否合法 if collides(self.board, shape, row, col): # 非法动作给大惩罚并结束本回合 return self._get_observation(), -10.0, True, {invalid: True} # 将方块写入棋盘 for r in range(len(shape)): for c in range(len(shape[0])): if shape[r][c] 1: self.board[row r][col c] 1 # 消行并更新分数 cleared self._clear_rows() if cleared 0: self.score cleared * 100 if cleared 4: self.score 200 # 生成下一块 self.current_piece self.next_piece self.next_piece self._spawn_piece() # 检查新方块是否能放下 if collides(self.board, rotate_piece(self.current_piece[type], 0), 0, self.width // 2 - 1): self.done True reward self._calculate_reward(cleared) return self._get_observation(), reward, self.done, {cleared: cleared} def _clear_rows(self): 消去满行返回消行数量 new_board [] cleared 0 for r in range(self.height): if np.all(self.board[r] 1): cleared 1 else: new_board.append(self.board[r]) for _ in range(cleared): new_board.insert(0, np.zeros(self.width, dtypenp.int8)) self.board np.array(new_board, dtypenp.int8) return cleared def _calculate_reward(self, cleared): 基础奖励消行 落子惩罚。复杂奖励在训练循环外层做 reward cleared * 10.0 if cleared 4: reward 60.0 reward - 1.0 return reward这个step接受的是已经算好的落点(row, col, rotation)你需要在上层用之前get_valid_actions枚举出的动作列表来生成候选动作。有一个容易被忽略的点消行后棋盘整体下移insert(0, ...)会改变后续方块的坐标参考系但俄罗斯方块本身的坐标本来就是相对棋盘顶部的所以不会有累积误差。_calculate_reward这里只写了基础版空洞惩罚和列高惩罚需要在训练循环外部算因为step内部拿不到动作执行前的棋盘快照。更优雅的做法是在step里保存 previous_board奖励计算放在同一个类中我这里是精简版。关于随机数种子reset(seed)里的np.random.seed对整个 numpy 全局生效在多进程或多环境并行训练时会产生种子冲突。如果后面要并行采集经验最好改成np.random.RandomState(seed)实例并传给环境。3.4 把高层动作翻译成实际落子动作选择的三种策略因为 DQN 输出的是“哪个落点最优”但真实游戏需要按帧移动方块中间需要一翻译层。常见做法有三种直接落底把方块旋转到目标姿态移动到目标列然后一路加速落到目标行。实现简单适合训练。模拟物理过程逐帧执行左/右移和旋转然后用上一节的高层动作枚举判断“当前这一步对应的最终落点”来校验过程。接近真实游戏但代码复杂。混合模式环境直接支持“放置”动作内部自己处理移动过程。我推荐第一种。原因很简单DQN 要学的是布局决策不是键盘操作。如果让智能体从键盘操作开始学训练步数要翻好几倍而且动作空间暴涨后 Q 值估计方差很大。把翻译层放进环境内部环境的step收到的依然是一个高层动作 ID棋盘变化完全确定。def place_piece_action(env, action_id): 把 DQN 输出的动作 ID 翻译成真实落点。 action_id: 0 到 len(valid_actions)-1 之间的整数 valid_actions get_valid_actions(env.board, env.current_piece) if action_id len(valid_actions): # 动作越界时退化为随机动作 action_id np.random.randint(len(valid_actions)) action valid_actions[action_id] return action[row], action[col], action[rotation]这里要特别小心动作越界的情况。DQN 的 Q 网络输出维度是固定大小的动作空间比如 40 个维度但不同方块的合法落点数量不同——O 型方块只有 20 多种I 型可能有 35 种。如果网络给的 action_id 对应的是非法落点你不能直接忽略它因为那样会导致训练时环境状态和 Q 值估计脱节。我见过很多实现直接拿argmax去执行结果某个动作根本不合法环境报错或原地不动经验池里攒了大量噪声样本。解决思路是要么把动作空间统一扩展到最大值并对非法位置设置-inf掩码要么在训练时把非法动作的 Q 值在计算 loss 之前直接遮掉后者更干净。4. 从零搭建 DQN 训练流程经验池、Q 网络与目标网络更新4.1 DQN 的两个核心机制为什么必须有经验池和目标网络DQN 相比传统 Q-Learning 的关键改动是用了两个网络和一块经验池。经验池解决的是样本相关性问题俄罗斯方块的状态序列高度相关前一步的落点直接决定了当前局面如果按时间顺序逐条学习模型会在局部状态里打转。经验池把历史样本随机采样相当于切断时间关联让网络每次更新都能看到不同局面下的不同决策。目标网络解决的是训练不稳定的问题Q-Learning 的更新公式里目标值用的是同一个网络的输出等于拿一个移动靶反复瞄准网络参数一变目标值也跟着变损失函数就永远收敛不了。目标网络每隔若干步才同步一次让目标值在一段时间内保持相对稳定。这两个机制不是可选项而是 DQN 能稳定工作的必要条件。俄罗斯方块状态多、奖励稀疏缺了经验池或目标网络你很可能看到训练了 2 万局分数还在 0 附近波动。4.2 经验池实现容量、批量采样与优先级from collections import deque import numpy as np class ReplayBuffer: def __init__(self, capacity50000): self.buffer deque(maxlencapacity) def push(self, state, action, reward, next_state, done): 存入一条经验deque 超容量自动丢弃最旧数据 self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size64): 随机采样一个批次 batch np.random.choice(len(self.buffer), batch_size, replaceFalse) states, actions, rewards, next_states, dones [], [], [], [], [] for idx in batch: s, a, r, ns, d self.buffer[idx] states.append(s) actions.append(a) rewards.append(r) next_states.append(ns) dones.append(d) return (np.array(states, dtypenp.float32), np.array(actions, dtypenp.int64), np.array(rewards, dtypenp.float32), np.array(next_states, dtypenp.float32), np.array(dones, dtypenp.float32)) def __len__(self): return len(self.buffer)经验池容量我一般取 5 万到 10 万。俄罗斯方块的一局回合可能有几百步10 万条经验大约覆盖几百局。容量太小会让采样集中在近期局面模型容易遗忘早期学到的东西太大则训练初期的随机样本长期占据池子梯度被噪声样本稀释。你可以观察不同容量下的训练曲线一般会看到一个明显的拐点。sample方法用的是均匀随机采样。在项目后期可以换成优先经验回放——把 TD 误差大的样本按概率多采这个放到最后一章说。初版用均匀采样就够先把主流程跑通再考虑优先级。4.3 Q 网络结构从小网络起步别一上来就堆深度俄罗斯方块的状态向量维度在 40 到 100 之间不需要特别深的网络。一个三层的全连接网络配合 ReLU 激活在多数情况下够用输入层接状态向量中间层 128 或 256 个神经元输出层维度等于动作空间大小。网络太深反而容易在小样本场景下过拟合训练不稳定。import torch import torch.nn as nn import torch.nn.functional as F class DQN(nn.Module): def __init__(self, input_dim, output_dim, hidden_dim128): 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, output_dim) def forward(self, x): x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) return self.fc3(x)这里有个参数细节输出层不要加激活函数因为 Q 值可以是负数。如果你的奖励函数包含落子惩罚很多动作对应的 Q 值天然是负的最后一层加 ReLU 会把梯度全部截断。输入维度要和状态生成函数保持一致。如果你前面套用了 2.3 节的特征向量那input_dim应该等于那个向量的长度。动作空间output_dim需要和枚举出来的合法动作数量对齐但不同方块的合法动作数不一样——我采用的做法是取所有方块中最大的合法动作数作为网络输出维度然后在训练时用一个 mask 把非法动作的 Q 值遮成-inf。这样模型输出维度固定不需要为每个方块单独建网络。4.4 训练主循环探索、学习与目标网络同步训练循环是整个项目的核心下面给出一个可以直接运行的最小版本def train_dqn(num_episodes5000, batch_size64, gamma0.99, epsilon_start1.0, epsilon_end0.01, epsilon_decay1000, target_update_freq500, replay_capacity50000, learning_rate1e-3, seed42): torch.manual_seed(seed) np.random.seed(seed) env TetrisEnv() state_dim get_state(env).shape[0] max_actions get_max_action_count() q_net DQN(state_dim, max_actions) target_net DQN(state_dim, max_actions) target_net.load_state_dict(q_net.state_dict()) target_net.eval() # 目标网络不参与梯度下降 optimizer torch.optim.Adam(q_net.parameters(), lrlearning_rate) loss_fn nn.SmoothL1Loss() replay ReplayBuffer(replay_capacity) epsilon epsilon_start total_steps 0 episode_rewards [] for ep in range(num_episodes): state env.reset() state get_state(env) total_reward 0.0 done False while not done: total_steps 1 # epsilon 指数衰减 epsilon epsilon_end (epsilon_start - epsilon_end) * \ np.exp(-total_steps / epsilon_decay) valid_actions get_valid_actions(env.board, env.current_piece) # 构建动作 mask action_mask np.zeros(max_actions, dtypenp.float32) for a in valid_actions: action_mask[a[action_id]] 1.0 # epsilon-greedy 探索 if np.random.rand() epsilon: action_id int(np.random.choice(len(valid_actions))) else: state_tensor torch.FloatTensor(state).unsqueeze(0) q_values q_net(state_tensor).detach().numpy()[0] # 非法动作的 Q 值设负无穷 q_values[action_mask 0] -np.inf action_id int(np.argmax(q_values)) # 执行动作 row, col, rotation place_piece_action(env, action_id) next_state_raw, reward, done, info env.step((row, col, rotation)) next_state get_state(env) replay.push(state, action_id, reward, next_state, done) state next_state total_reward reward # 经验足够后开始学习 if len(replay) batch_size: states, actions, rewards, next_states, dones replay.sample(batch_size) states torch.FloatTensor(states) actions torch.LongTensor(actions).unsqueeze(1) rewards torch.FloatTensor(rewards).unsqueeze(1) next_states torch.FloatTensor(next_states) dones torch.FloatTensor(dones).unsqueeze(1) current_q q_net(states).gather(1, actions) # 目标 Q 值用目标网络计算下一页面的最大 Q with torch.no_grad(): next_q target_net(next_states).max(1, keepdimTrue)[0] target_q rewards gamma * next_q * (1 - dones) loss loss_fn(current_q, target_q) optimizer.zero_grad() loss.backward() optimizer.step() # 周期性同步目标网络 if total_steps % target_update_freq 0: target_net.load_state_dict(q_net.state_dict()) episode_rewards.append(total_reward) if (ep 1) % 100 0: avg_reward np.mean(episode_rewards[-100:]) avg_cleared np.mean([env.score / 100 for _ in range(10)]) print(fEpisode {ep1}, Avg Reward: {avg_reward:.2f}, Epsilon: {epsilon:.3f}) return q_net, episode_rewards训练循环里最需要解释的是动作 mask 的处理。get_valid_actions需要返回每个动作在全局动作空间里的 ID而不是只在合法动作列表里的索引。如果你的环境里没有这个 ID可以在get_valid_actions里给每个动作分配一个action_id范围从 0 到max_actions-1。这样网络输出的q_values和动作空间维度天然对齐mask 操作才能生效。target_q的计算里有一个关键细节done标志的处理。当done为 1 时(1-dones)为 0目标 Q 值就等于即时奖励不再加后续折扣回报。如果漏掉这个处理游戏结束后的“幻想未来收益”会被持续计入梯度模型会倾向于选择那些让它更快死亡的路径——因为它以为死亡后还能继续获得收益。关于损失函数我选了SmoothL1LossHuber loss而不是 MSE。原因是俄罗斯方块的 Q 值范围变化很大MSE 对离群点过于敏感会出现个别大 TD 误差样本主导梯度方向、训练曲线剧烈震荡的情况。Huber loss 在误差较小时表现像 MSE误差大时退化为 L1对离群点更鲁棒。这个选择在奖励函数带惩罚项时尤其重要。epsilon_decay1000是另一个值得调的参数。它的含义是“步数衰减到 e 分之一”的速度。俄罗斯方块每个回合大约 200 到 500 步5000 回合下来总步数可能超过 100 万。如果你希望前期充分探索epsilon_decay可以调到 2000 到 5000反之如果希望模型尽快收敛到贪心策略可以调到 500。我一般会先用epsilon_decay2000跑通流程观察探索率曲线再做针对性调整。4.5 训练观测如何判断模型真的在学东西训练结束后不要只看总奖励。俄罗斯方块的奖励曲线因为探索噪声大前 500 回合的波动非常大直接看不出来趋势。更可靠的观测指标是以下三个每局平均消行数这是最贴近游戏目标的度量。每局平均存活步数反映模型能否持续应对当前局面。Q 值预测的平均幅度如果长时间不增长很可能是奖励信号出了问题。其中平均消行数是最直观的。如果前 1000 回合平均消行数在 2 到 5 之间徘徊说明模型要么还在探索要么奖励函数有问题如果 2000 回合后逐步上升到 10 以上说明 DQN 开始真正学到“消行”的行为模式。我习惯在训练过程中每隔 100 回合暂停评估一次——用epsilon0.01的近乎贪心策略跑 10 局记录平均消行数这样比实时训练曲线干净得多。5. DQN 俄罗斯方块避坑指南三个训练黑匣子的排查路径5.1 训练了 3000 个回合分数仍然在 0 附近波动现象每局平均分数长期低于 100模型几乎没有消行能力动作像是完全随机的。原因排查路径按可能性从高到低排列第一奖励函数有问题。检查奖励是否真的会在消行时触发——很多游戏逻辑错误会导致消行判定不生效或者cleared_rows计算错误模型从来没有收到过正奖励信号。第二epsilon 衰减过快。如果epsilon_decay设为 100模型在几千步内就完全转为贪心策略早期随机探索积累的经验根本不够支撑 Q 网络收敛。第三状态表示有问题。比如get_column_heights的行号方向和棋盘数据的行号方向相反特征向量里的“高度”实际是“深度”模型学到的是错误规律。解决先做一个“奖励函数单元测试”手动注入一个可消行的局面调用一次step确认reward值正确。然后把 epsilon 衰减调慢epsilon_decay从 100 改成 2000同时把经验池容量从 1 万提到 5 万。最后打印几组状态向量检查各特征的值域是否符合直觉——比如一个空棋盘的最高列高应为 0不可能是 20。5.2 模型学会了堆方块但永远不会消行现象智能体能持续在棋盘上放置方块游戏能撑几百步但总分数很低消行次数屈指可数。从行为上看它似乎故意避开了消行的位置。原因奖励函数里“落子惩罚”的绝对值设置得过高。我遇到过一个案例落子惩罚是 -2消一行奖励只有 5。模型发现要完成消一行至少需要落 4 到 5 个方块净收益是 5 - 2*4 -3不消行反而每次只亏 2。于是它学会了“不消行、尽量多撑几回合”的局部最优解。解决把消行奖励提高到 15 到 20给 4 行消行额外加成再加码同时把落子惩罚降低到 -0.5确保“消一行的净收益为正”。更稳妥的方式是直接用“每放置一个方块给固定小惩罚 消行高奖励”的比例检验如果某个布局需要 5 步才能消 1 行那么 5 步的累计惩罚要小于消行奖励的一半。5.3 Q 值爆炸或 loss 突然飙升到几千现象训练稳定一段后loss 突然从 1 以下跳到 1000 以上Q 值输出出现极大正值或负值然后模型彻底崩溃后续回合全是随机动作。原因最典型的是目标 Q 值计算中的done标志处理错误。如果done为 1 时没有归零后续折扣回报游戏结束时的 Q 值会变成reward gamma * next_q而next_state是重置后的初始局面初始局面的 Q 值如果是正的就形成正反馈循环Q 值不断膨胀。另一个常见原因是奖励函数中出现未归一化的分数累计。比如game.score从 0 增长到几十万直接作为特征或奖励时数值量级远超其他特征Q 网络的梯度更新变成“只看分数不看局面”。我建议奖励一直保持在 -10 到 50 的范围内如果发现绝对值超过 100先检查是不是把累计分数直接当成了奖励。解决在target_q计算处打印一批样本的dones值确认 0/1 分布正确把奖励归一化或裁剪到固定区间比如reward max(min(reward, 50), -10)。同时对 Q 网络的梯度做 clipnn.utils.clip_grad_norm_(q_net.parameters(), max_norm10)防止单步大梯度把网络参数推飞。5.4 训练速度慢到无法接受每 100 回合要跑 20 分钟现象环境交互和网络训练的循环很慢一个回合几百步每步都要调用 Python 层面的碰撞检测和消行判断整体耗时过高。原因俄罗斯方块环境是纯 Python 实现的数组操作而 DQN 训练需要数十万步交互Python 解释器的开销成为瓶颈。最常见的慢点有三个get_valid_actions在每步都会重新枚举所有方块姿态没有缓存旋转结果collides函数对每个格子都做二维循环没有用向量化操作经验池的sample使用np.random.choice加 Python 循环逐条取值批量转换效率低。解决第一步把SHAPES中所有旋转态预计算成列表避免在动作枚举时反复调用rotate_piece第二步用 numpy 的数组切片代替二维循环做碰撞检测第三步sample方法里用列表推导代替 Python 循环或者直接一次性从数组中采样。这些优化做完速度通常能提升 3 到 5 倍。如果还不够再考虑把环境交互放到多进程并行主进程只负责网络训练。6. 从“能跑”到“跑得好”Double DQN、优先经验回放与消行率的验证方法如果你已经把基础版 DQN 跑通下一步不是继续调参而是用三个成熟的技术点把模型往上推一层每个改动都很小但对俄罗斯方块这种动作空间小、奖励相对确定的任务效果明显。第一个改动是 Double DQN。标准 DQN 在计算目标 Q 值时直接用max(next_q)这会系统性地高估 Q 值——尤其在训练早期噪声会让某些动作的 Q 值被偶然抬高而高估又被持续放到目标值里形成偏差累积。Double DQN 的思路是用在线网络选择最优动作再用目标网络估计该动作的价值。改动只有一行with torch.no_grad(): next_actions q_net(next_states).argmax(1, keepdimTrue) next_q target_net(next_states).gather(1, next_actions) target_q rewards gamma * next_q * (1 - dones)这行代码替换掉原来的target_net(next_states).max(...)Q 值高估的问题就能明显缓解。俄罗斯方块里 Q 值高估的症状是模型对某些落点表现出“盲目自信”宁可选择那些曾经偶尔消过一行的位置也不选择更平稳的堆叠策略——Double DQN 能在保持稳定性的前提下让模型学得更快。第二个改动是优先经验回放。均匀采样假设所有经验价值相同但实际上一局游戏里的大多数样本都是“普通放置”只有少数样本导致消行或者游戏结束这些样本对训练更有价值。优先经验回放按 TD 误差的大小做加权采样class PrioritizedReplayBuffer(ReplayBuffer): def __init__(self, capacity50000, alpha0.6, beta0.4): super().__init__(capacity) self.priorities deque(maxlencapacity) self.alpha alpha self.beta beta def push(self, state, action, reward, next_state, done): super().push(state, action, reward, next_state, done) self.priorities.append(1.0) # 新样本给最大优先级 def sample(self, batch_size64): priorities np.array(self.priorities) probs priorities ** self.alpha probs / probs.sum() indices np.random.choice(len(self.buffer), batch_size, pprobs) batch [self.buffer[i] for i in indices] # Importance sampling 权重修正 total len(self.buffer) weights (total * probs[indices]) ** (-self.beta) weights / weights.max() # 组装 batch 后返回 (batch, weights, indices) return batch, weights, indicesalpha控制优先级的影响力alpha0.6是常见起点beta控制重要性采样权重训练后期要逐步升到 1.0。优先级数值可以用abs(td_error) 1e-6更新从 1.0 初始值开始。实现上要记住权重修正不能省否则高优先级样本的梯度偏差会让模型对稀有经验过拟合。如果你不打算引入这层复杂性把alpha设为 0它就退化成均匀采样。第三个改动是关于验证方法。训练的时候看奖励曲线评估的时候一定要用“消行率”这个指标。消行率 总消行数 / 已放置方块数。俄罗斯方块中一个回合的消行数受长度影响很大——死得早的回合消行数天然低不代表策略差。消行率则与回合长度无关能更公平地比较不同策略。我常用的验证流程是每训练 500 回合冻结当前模型用 epsilon0.01 跑 50 局统计这 50 局的平均消行率、平均最高列高、平均游戏长度。如果消行率在提升但游戏长度在缩短说明策略牺牲了稳健性换取消行需要回看奖励函数的惩罚项。从项目管理的角度我习惯把三种改进分开做先加 Double DQN确认没有性能回退再加优先经验回放观察收敛速度最后调奖励权重。一次改两个以上改动出了问题很难定位是哪个环节造成的。另外每次改动前把当前版本的配置参数存档——网络结构、各层维度、奖励系数、epsilon 衰减率这些参数的组合效果是黑匣子改回去的成本比记录高得多。这个项目做到最后你会发现最有收获的不是“AI 最终能通关”而是你真正理解了强化学习里那些“为什么”——为什么需要目标网络、为什么奖励要塑形、为什么探索要衰减。以后再做更复杂的控制项目你可以直接把俄罗斯方块这套环境接口和训练框架复用过去。希望帮到你祝训练顺利。本文还有配套的精品资源点击获取
返回列表