资讯动态

用Q-learning训练俄罗斯方块AI:从乱放到稳定消行的实践解析

发布时间:2026/10/18 9:28:41 来源:尧图企业网站定制
简介对强化学习与游戏智能体感兴趣的开发者可以借助 tetris-ai 项目直观看到一个俄罗斯方块机器人如何从随机尝试逐步成长为高分玩家。项目采用深度 Q 学习智能体先将随机移动产生的状态与奖励存入有限队列再在每局结束后随机采样回放记忆训练神经网络同时引入随时间衰减的探索变量使动作在利用与探索之间平衡从而找到获取更高分数的非贪心路径。整个训练过程由 4 个 Python 脚本完成分别负责深度 Q 网络代理、游戏逻辑、训练运行与日志管理结构清晰适合作为入门级强化学习项目的代码范本配套文档说明运行依赖另含结果曲线图与演示动图便于直观评估训练效果。资源包共 8 个文件压缩后仅 3.8MB轻量且易于阅读修改。目前已有 1187 人学习/下载希望从代码层面理解经验回放、探索率衰减和 Q 学习奖励设计的读者可在此基础上直接运行并继续二次开发。1. tetris-ai 项目速览一个不靠深度网络也能练出规律的强化学习机器人先说个反直觉的结论这个 tetris-ai 项目没走深度网络路线只靠一个几百维的状态向量加一张 Q 表就让 AI 从「随手乱放、一局活不到 30 步」练到「稳定消行、一局撑过上千步」。核心流程是典型 Q-learning打完一整局用倒推的累计回报更新 Q 值重复几千局。不需要 GPU不需要开局库本地一个 Python 脚本就能跑。它适合两类人刚把强化学习公式背完、想找环境练手的入门者以及做过游戏 AI、想对比学习型与规则型策略差别的开发者。状态编码、动作定义、奖励设计、参数调优每一步都落在看得见改得动的代码里。下载后在本地跑起来就能看着训练曲线从平线变成上扬的斜线。2. 核心架构拆解从棋盘编码到动作决策的完整链路2.1 项目结构一份代码是怎么组织的这种单机游戏 AI 项目的文件划分通常不是把所有东西塞进一个脚本而是按「环境、智能体、训练、评估」四块拆开。常见做法是这样tetris-ai/ ├── README.md # 运行说明、依赖清单、参数含义 ├── requirements.txt # numpy / pygame / matplotlib ├── tetris_env.py # 游戏环境棋盘、碰撞检测、消行、奖励 ├── agent.py # Q-learning 智能体策略选择、Q 表更新 ├── train.py # 训练主循环跑对局、收集回放、更新 Q 表 └── evaluate.py # 加载训练产物跑固定局数统计表现不同分发版可能把训练和评估合并进一个文件但核心分层基本一致。环境负责游戏规则智能体只负责「看到状态、做出选择、事后学习」三个动作训练脚本负责把两者串起来。这种分层的好处是想换策略时只改 agent.py想改游戏规则时只改 tetris_env.py互不干扰。拿到源码后跑起来的流程很短先装依赖然后直接训练。训练结束会输出一个模型文件评估脚本加载后以固定随机种子跑对局输出平均消行数和平均存活步数。如果只是好奇想看效果也可以直接跑评估脚本让 AI 先用随机参数「表演」一次原地死亡。2.2 状态编码228 维向量里到底放了什么强化学习的第一步不是写网络而是决定「拿什么描述当前局面」。这个项目做了一个很朴素的端到端选择把棋盘网格、当前方块、下一个方块全部铺平成向量直接交给 Q 表。核心代码大概长这样import numpy as np # board: 20 行 x 10 列的 0/1 矩阵1 表示该格已经有方块 def encode_state(board, cur_piece, next_piece): # 把棋盘压平这就是此刻的地形快照200 维 grid_flat board.flatten().astype(np.float32) # 当前方块7 种形状用 one-hot 编码避免数字大小误导 Q 值 cur_shape np.zeros(7, dtypenp.float32) cur_shape[cur_piece.shape_id] 1.0 # 当前方块横向位置10 列用 one-hot 编码 cur_col np.zeros(10, dtypenp.float32) cur_col[cur_piece.x] 1.0 # 旋转状态 0~3 也用 one-hot cur_rot np.zeros(4, dtypenp.float32) cur_rot[cur_piece.rotation] 1.0 # 下一个方块只编码类型给 7 维 next_shape np.zeros(7, dtypenp.float32) next_shape[next_piece.shape_id] 1.0 return np.concatenate([grid_flat, cur_shape, cur_col, cur_rot, next_shape]) # 最终向量长度200 7 10 4 7 228 维这个编码方案有几处值得说。第一网格直接用 flatten 而不是保留二维形状是因为 Q-learning 的 Q 表本质上是一张「状态到动作」的查表不管输入长成什么样最终都要变成可哈希的 key铺平后每一格都是一个独立的输入特征模型能学到的信息量和二维输入没有区别。第二所有离散属性都用 one-hot特别是方块形状如果用 0~6 的整数表示Q 表很容易学到「编号越大分数越高」这种根本不存在的规律这是新手最容易踩的坑。第三这里故意没把方块的 y 坐标编进去。同一个方块类型、同一个旋转、同一列不管它当前在棋盘上方多高的位置最终落点都是一样的决策应该只和「落在哪」有关和「现在飘在哪」无关。不编码 y 坐标反而让 Q 表的状态空间小了一个维度——这也是后面避坑章节里要展开的一个细节。2.3 动作空间与 Q 表查表不控制按键直接决定「放哪格」俄罗斯方块的原始操作是左移、右移、旋转、加速下落如果把这个当作动作空间Q-learning 的学习难度会大很多因为一个「正确摆放」需要好几步连续操作中间任何一步失误都拿不到奖励信用分配非常难。这个项目采取的做法是把动作抽象成「当前方块放在哪一列、什么旋转角度」选出后就一次性落到最低点。# 遍历 4 个旋转方向和 10 个横向位置过滤掉碰撞的剩下的就是可选动作 def get_legal_actions(env, piece): actions [] for rotation in range(4): for col in range(10): if env.is_valid(piece, rotation, col): actions.append({ rotation: rotation, col: col, drop: True, # 选好后直接软着陆到最低点 }) return actions这一步是整份代码的灵魂。动作空间从「按键序列的组合爆炸」压缩成「平均 20~40 个合法落点选择」Q-learning 是表格型算法离散动作可以直接取 argmax不需要像 DQN 那样输出连续动作或逐键决策。跑一遍就能明显感觉到训练初期的 AI 虽然乱选但至少每次都是合法落点不会出现「卡在半空不动」的死局。Q 表的查表逻辑也很直白拿状态和动作拼出一个 key查不到就返回 0查到就返回累积的 Q 值def choose_action(self, state, legal_actions): # epsilon 概率随机探索否则取 Q 值最大的动作 if np.random.random() self.epsilon: return np.random.choice(legal_actions) best_action, best_q None, float(-inf) for a in legal_actions: key (state.tobytes(), a[rotation], a[col]) q self.q_table.get(key, 0.0) if q best_q: best_q, best_action q, a return best_action这里state.tobytes()把 228 维浮点向量转成字节串作为 dict 的 key是 Python 里对 ndarray 做哈希的标准做法。有人会尝试直接把整个 ndarray 塞进 dict但 ndarray 默认不可哈希会直接抛异常也有人用tuple(state.flatten())能跑但每次查找都要转一次元组几百维的向量转起来很慢。用 tobytes 是性能和可读性之间的平衡。2.4 训练主干整局回放式 Q-learning这个项目训练时没有采取「每走一步立即更新 Q 值」的在线更新方式而是打完一整局后从终局倒推每一步的累计回报再批量更新。这一步是整个训练管线里最容易被忽略但最关键的设计。def train_one_episode(env, agent): trajectory [] # 记录 (state, action, reward) 三元组 state env.reset() done False while not done: actions env.get_legal_actions() action agent.choose_action(state, actions) next_state, reward, done env.step(action) trajectory.append((state, action, reward)) state next_state # 从终局倒推每一步的累计回报 当前奖励 gamma * 后续回报 G 0.0 for s, a, r in reversed(trajectory): G r agent.gamma * G agent.update_q(s, a, G) return len(trajectory) # 返回本局存活步数用来观察训练效果为什么整局回放比在线更新稳定俄罗斯方块的奖励信号本质上是稀疏的中间大多数步的奖励是 0 或很小的惩罚真正有区分度的信息集中在「消了几行」和「最终是否死亡」上。如果每走一步立刻更新前 100 步的 Q 值会被后面 100 步的噪声反复覆盖同一个状态在很多局里出现时新旧经验互相污染导致震荡。整局回放先把一条完整轨迹的回报全部算清楚再一次性写入 Q 表相当于每局只对这条轨迹做一轮稳定的批量学习。update_q的实现也很常规标准的 Q-learning 增量式更新def update_q(self, state, action, target): key (state.tobytes(), action[rotation], action[col]) old_q self.q_table.get(key, 0.0) # 新 Q 旧 Q alpha * (目标值 - 旧 Q) self.q_table[key] old_q self.alpha * (target - old_q)参数方面alpha控制新经验对旧 Q 值的修正力度gamma控制未来回报的折算。这里核心序列的时序是状态编码 → 动作枚举 → 查表选动作 → 执行一步 → 记录轨迹 → 终局回放更新。沿着这个链路读代码比从入口函数往下追要清晰得多。3. 奖励设计与参数调优怎么让 AI 从「乱放」练到「会消行」3.1 奖励函数消行加分、存活微罚、死亡重罚拿到一份强化学习代码先别急着跑先看它的奖励函数是怎么写的——因为奖励定义直接决定了 AI 会学出什么行为。这个项目的奖励函数是典型的三段式def get_reward(lines_cleared, terminated): if terminated: return -1.0 # 死亡最大惩罚一票否决 if lines_cleared 0: return lines_cleared # 消 1 行 1一次消 4 行 4 return -0.01 # 没消行也没死轻微时间惩罚逐条拆开看。死亡给 -1.0不是 -100也不是 0。给 0 的问题是 AI 完全不在乎死活反正活着和死了都一样它就会专注堆高然后消行最终把自己埋了给 -100 的问题是单步奖励的量级和它差距太大消一行的 1 在 -100 面前毫无意义AI 会变成「极度保守、一有风险就乱躲」的缩头乌龟。让死亡惩罚略大于消行奖励AI 会自然学到「先活着再消行」的优先级。每步 -0.01 的时间惩罚是个容易被忽略的小细节。如果没有这一项AI 会倾向于无限拖时间等一个「完美」的摆放时机虽然活着但效率极低。加了微小惩罚后每个动作都要付出一点代价等于在模型里内建了「尽快消行」的引导。这种惩罚的幅度不需要大只要让「拖时间」的期望回报低于「正常消行」即可。奖励的另一个隐藏作用是影响 Q 值尺度。奖励保持在 -1 到 4 的小区间Q 值也能维持在小范围训练时不容易出现数值爆炸。如果把消行奖励改成 100死亡惩罚改成 -1000Q 表里的值会变得非常大任何一个 epsilon 随机动作都可能带来巨大波动收敛就变成玄学。3.2 超参表每个参数改大会怎样、改小会怎样训练脚本里的关键参数通常是这几个它们的取值决定了训练曲线的形态参数作用典型值调大的影响调小的影响learning_rate alpha新经验对旧 Q 值的修正幅度0.1学习快但震荡剧烈稳定但收敛慢discount gamma未来回报的折算系数0.95更看重长远收益更看重眼前收益epsilon_start初始探索概率0.6前期更多随机尝试前期更依赖已有经验epsilon_end最低探索概率0.02后期仍在随机试错后期几乎纯贪心epsilon_decay每局衰减系数0.998探索期拉长过早转入利用replay_updates每局回放更新次数3单局经验利用更充分对单局经验过度拟合这里最值得说的是 epsilon 的退火节奏。常见做法是让探索率按epsilon max(epsilon_end, epsilon_start * decay_rate ** episode)衰减也就是每一局乘一次衰减系数。decay_rate 0.998 时前 1000 局探索率从 0.6 缓慢降到约 0.36之后继续递减大约 4000 局后触底到 0.02 并保持不变。这样安排的前 1000 局是「充分试错期」AI 会做出大量看起来愚蠢的摆放但正是这些愚蠢的样本让 Q 表覆盖到了各种可能出现的地形。replay_updates 是刚才整局回放里的更新次数。有些实现会把整段轨迹反复更新 10 次以上想让一条经验被「学透」实际上很容易过拟合到这一局上——同一批状态连续更新太多次Q 值会被单局轨迹的偏差主导下一局换个地形就崩了。3 次是我看到比较稳妥的取值既能利用完整轨迹信息又不会让单局经验喧宾夺主。3.3 怎么判断训练有没有效别只看一局消行数训练循环通常每 100 局打印一次平均存活步数和平均消行数。很多新手看到「第 100 局最高消了 4 行」就兴奋看到「第 200 局只消了 1 行」就以为崩了其实单局波动非常大——俄罗斯方块的地形随机性决定了单局成绩毫无参考价值。正确做法是维护一个滑动窗口取最近 100 局平均值。from collections import deque history deque(maxlen100) # 只保留最近 100 局的数据 for episode in range(5000): steps train_one_episode(env, agent) history.append(steps) if (episode 1) % 100 0: avg_steps sum(history) / len(history) print(fepisode {episode 1}, avg_steps{avg_steps:.1f}, epsilon{agent.epsilon:.3f})平均步数最开始的 200~500 局通常只有几十步因为 AI 在纯随机探索很容易把自己堆死之后会经历一个「缓慢爬坡期」曲线有波动但整体向上等到 epsilon 触底以后曲线会转向平稳此时继续训练对平均成绩的提升已经很小。正常的观察节奏是前 500 局是谷底1000~3000 局是主要提升段4000 局之后进入平台期。如果 3000 局之后平均步数还在振荡下降优先检查奖励函数和 epsilon 衰减是否被改坏而不是加训练轮数。4. 避坑实录训练俄罗斯方块 AI 最容易翻车的 4 个细节4.1 只有消行奖励AI 学会把自己活埋现象训练曲线前几百局消行数涨得很快随后断崖式下跌AI 频繁在场地中央堆起高柱然后死亡。原因奖励函数只写了lines_cleared * 1没加存活惩罚和死亡惩罚。对 AI 来说堆高然后消一行和稳步消行得到的奖励一样但前者更快于是它学到的策略变成了「疯狂堆高、一次消几行」的激进打法。消行数短期暴涨代价是地形越来越糟最终把自己活埋。解决把奖励改成前面 3.1 里的三段式。死亡给最大惩罚让 AI 把「活着」当作第一优先级每步加微小的负奖励让拖时间不值得消行奖励保留在 1~4 的量级。这一处改动通常能让训练曲线从「暴涨后崩盘」变成「平稳爬坡」。4.2 单局轨迹反复回放几十次灾难性遗忘现象某一局训练后平均成绩突然大幅下滑而且不是个例每隔几百局就出现一次「断崖」。原因把整局轨迹收集起来后用同一批数据循环更新了 20 次甚至 50 次。这一局里的特殊地形被反复强化Q 表被单局经验主导等下一局的地形风格一变之前学到的规律就被覆盖掉了——这就是强化学习里典型的灾难性遗忘。解决控制每局回放更新次数在 3 次左右。让一条轨迹被「学到」但不被「学死」保留对后续新局面的适应能力。如果发现训练后期仍有波动先减这个参数而不是去调 learning_rate。4.3 epsilon 退火没设下限AI 永远在随机试错现象训练到 5000 局AI 还是会时不时做出明显愚蠢的摆放比如把方块放到一个必死的位置。原因epsilon 只写了初始值和衰减系数没写最低值。从数学上它确实会逐渐趋近 0但在 5000 局这个量级它仍然衰减到 0.08 左右也就是说每 12 步决策就有一步是纯随机而这些随机步经常会把自己送走。很多实现里这个「并未归零的 epsilon」是隐藏杀手。解决显式设置epsilon_end 0.02并在代码里用max(epsilon_end, current_epsilon)保证下限。训练进入平台期后再把 epsilon 设成 0 跑一个评估过程对比「带探索」和「纯贪心」的差距通常纯贪心成绩会更好。4.4 状态里编码了 y 坐标Q 表维度爆炸现象训练速度极慢Q 表大小是预期的几十倍而且同一类地形反复出现却总是被当成不同状态处理。原因状态编码时把当前方块当前所在的 y 坐标也当成特征塞进了向量。同一个方块、同一个旋转、同一列在棋盘上方 12 格和 8 格时被编码成两个完全不同的状态但它们的最终落点是相同的、最优决策也是相同的。Q 表被迫为「同一个决策」维护多个条目训练数据被白白稀释。解决编码时只保留方块类型、旋转状态、横向列位置不编码 y。因为动作被抽象成「选好落点后直接软着陆」方块从哪个高度开始下落根本不影响结果。这也是前面 2.2 里故意不编 y 坐标的原因。改完后 Q 表规模会明显变小训练步数减半都不奇怪。提示排查这类问题有个通用手段——打印 Q 表条目数。如果训练了 3000 局但 Q 表条目数还在线性增长说明状态空间设计有冗余。合理情况下训练后期 Q 表条目数增长应该明显放缓。5. 二次开发把端到端 Q-learning 换成可解释的手工特征策略这个项目最值得动手改造的点是把 228 维的网格编码替换成几个业务指标。俄罗斯方块领域有一个非常经典的经验人类高手判断一个落点好不好看的不是整张棋盘而是几个关键数字——地形高度、空洞数量、起伏程度、以及能消几行。把 Q-learning 的输入从「网格像素」换成这些指标AI 的决策会变得肉眼可解释。def heuristic_score(board, action): # 模拟方块落下后的棋盘 board simulate_drop(board, action) # 四个经典特征 aggregate_height compute_aggregate_height(board) # 所有列高度之和 holes count_holes(board) # 被方块盖住的空洞数 bumpiness compute_bumpiness(board) # 相邻列高度差之和 lines lines_cleared(board) # 这一步能消几行 # 权重是经验值也可以反过来让 Q-learning 学这组权重 return (-0.51 * aggregate_height -0.76 * holes -0.18 * bumpiness 0.76 * lines)改造思路是把 agent 的choose_action从「查 Q 表」换成「对每个合法动作算启发式分数取最高分」。四个特征的权重不是随便拍的空洞的惩罚权重最高因为空洞一旦形成几乎无法弥补堆高次之因为堆高直接逼近死亡线地形起伏的惩罚最轻AI 可以容忍一定程度的坑洼来换取消行机会。这套权重在俄罗斯方块 AI 圈子里流传很广直接用就能达到接近简单 Q-learning 的效果。想验证改造效果固定随机种子跑双版本对比。任何一个带随机性的训练任务不固定种子就别谈对比这是我复现这个项目时最深刻的教训。def evaluate(agent, episodes1000, seed42): np.random.seed(seed) # 固定种子保证对比公平 total_lines 0 for _ in range(episodes): total_lines run_one_episode(agent) return total_lines / episodes对比的重心放在两个指标上平均消行数和高分段稳定性。手工特征版本通常起步就比随机版强但天花板可能不如训练充分的 Q-learningQ-learning 版本前期很弱却能通过几千局的回放逐步反超。跑完对比你会发现把小项目改造成特征工程的版本并不吃亏——它训练只要几秒钟行为又完全可解释调试时一眼就能看出 AI 下次会往哪放。从那以后我每次跑这种带随机性的游戏 AI 项目都强制走一遍「固定种子 双版本对比 指标可视化」的流程不然调参全靠玄学改对了还是改错了根本说不清。这个项目应该是所有强化学习 demo 里最适合拿来练这套方法论的一份代码代码量小、反馈直观、每个改动都能立刻在训练曲线上看到结果希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑