ARTICLE DETAIL

资讯详情

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

基于Transformer和强化学习的立直麻将AI实战:状态编码与PPO训练

基于Transformer和强化学习的立直麻将AI实战:状态编码与PPO训练 简介基于Transformer与强化学习的立直麻将智能体训练项目面向希望在游戏AI、深度强化学习方向进行实战的开发者。项目将自注意力机制与PPO等强化学习算法相结合覆盖监督学习预训练、自对弈仿真、模型评估等完整流程可用于研究麻将AI策略或迁移到其他序列决策问题。压缩包共49个文件以Python训练与推理脚本、C引擎源码、头文件为主辅以模型权重、图表、配置说明和Markdown文档整体约771KB。其中包含SL监督学习模块、RL模块中的ppo_discrete与selfplay_worker以及游戏加载器、数据集处理、view_dist可视化等脚本并附有val_loss、val_acc等训练曲线图便于理解模型优化过程。目前已有297人学习下载代码结构清晰、可读性强适合有一定深度学习基础、想复现麻将智能体并深入强化学习实战的开发者参考。1. 立直麻将 agent 的价值在于把不确定性变成可计算的问题立直麻将大概是牌类游戏里对强化学习最不友好的场景之一34 种牌、四家手牌完全不可见、一局里既有进攻和了又有防守弃和并且最终排名按半庄得点计算而不仅仅是单局胜率。你会发现直接套用 AlphaZero 那套 MCTS策略网络的思路并不好用因为对局中的信息隐藏程度远高于围棋而规则层面的役种和振听限制又把合法动作集切割得七零八落。正因如此「基于 Transformer 模型 强化学习训练」才值得专门拿出来做Transformer 负责把不完整信息下可观察到的牌河、副露、宝牌指示牌组织成有序序列强化学习则负责在巨大的不确定空间里学习「该攻还是该守」。这篇文章面向已经跑过基础强化学习项目、想往更复杂牌类 agent 上走的工程师我会把状态编码、模型结构、奖励设计、训练管线逐一拆开并给出可以直接参考的代码和参数设定。整体思路在立直麻将和国标麻将、甚至斗地主里都能复用差别只在特征细节。2. 状态编码把牌局转换成 Transformer 能读懂的序列2.1 立直麻将需要编码哪些信息为什么不能直接喂向量很多从斗地主或简单卡牌项目转过来的做法是把手牌做成 34 维 one-hot 计数再把对手牌数、场风、自风拼成一个扁平向量丢给 MLP。这个做法在立直麻将里不够用的原因很直接立直麻将的决策高度依赖时序信息——牌河的顺序决定振听判断和筋牌计算副露的先后影响役种进度宝牌指示牌的翻开时机影响攻防判断。扁平向量天然丢掉序列关系所以这里必须引入序列建模。常见做法是把每一巡的可见信息组织成一个 token 序列然后交给 Transformer 做自注意力。具体而言我会把手牌、副露、牌河、宝牌指示、自风场风、供托、立直状态、巡目、剩余牌数全部映射成带类型的 token 序列。每个 token 包含两个部分类型编号对应这是手牌中的哪一张这是哪一家的牌河这是宝牌指示牌和牌面编号。Transformer 的输入向量就是类型 embedding 与牌面 embedding 相加再叠上位置编码。2.2 一套可直接实现的特征组织方案下面给出一套我在实战中常用的 token 序列组织方式覆盖立直麻将主要决策要素。这里以天凤风格的半庄为例宝牌按常规定义不考虑三人麻将所有差异。import torch import torch.nn as nn # 牌种 0-33 对应 1m..9m, 1p..9p, 1s..9s, 东南西北白发中 # 红宝牌单独用 34-36 表示便于模型区分普通五万/五筒/五索 class MahjongStateTokenizer(nn.Module): def __init__(self, d_model256, max_tokens128): super().__init__() self.type_embed nn.Embedding(10, d_model) # 0手牌 1副露 2自家牌河 3下家牌河 4对家牌河 5上家牌河 6宝牌指示 7里宝牌指示 8手牌切出候选 9特殊标记 self.tile_embed nn.Embedding(37, d_model) # 0-33 普通牌, 34-36 赤宝牌 self.pos_embed nn.Embedding(max_tokens, d_model) def forward(self, type_ids, tile_ids, positions): # type_ids: (B, T) 每个位置的类型编号 # tile_ids: (B, T) 每个位置的牌面编号 # positions: (B, T) 每个位置的绝对位置 return self.type_embed(type_ids) self.tile_embed(tile_ids) self.pos_embed(positions)这段代码的逻辑是把序列中每个 token 的三重信息相加。type embedding 让模型区分「同样是一张 5m出现在手牌里还是牌河里含义完全不同」tile embedding 表示牌面数值pos embedding 记录全局次序让注意力有时间观念。参数上 d_model 取 256 在 6 层 Transformer 下参数量约 3000 万对单个 GPU 数据并行训练比较合适max_tokens 需要覆盖整局可能出现的 token 数量我设定 128 基本够用若想包含所有候选打牌则要扩到 192。提示牌河 token 一定要区分家。如果只是把四家牌河拼成一个序列模型很容易混淆信息来源训练开始时策略熵会长期居高不下。2.3 序列结构设计手牌、牌河、宝牌的排列顺序一个常见问题是手牌、副露、牌河这些 token 应该按什么顺序拼接我实验下来效果比较稳的方案是手牌在前之后是副露再按自家、下家、对家、上家的顺序排牌河最后是宝牌指示与场况标记。手牌放前面的理由是自注意力虽然是全局的但残差连接和位置编码会让靠近前面的 token 在后续层中有略高的显著度而手牌是决策的主依据理应占据这个位置。位置编码这里不直接用正余弦而是用可学习的位置 embedding因为立直麻将序列长度不是固定的不同巡目、不同副露数会产生不同长度可学习位置编码对长度泛化压力更小。如果你用旋转位置编码RoPE要注意对「牌河次序差异」的敏感性可能反而过高因为同一巡内各家手牌的绝对顺序其实存在人为约定的成分。目前来看APEX 和部分开源项目里的做法也偏向可学习位置编码RoPE 更适合纯语言建模场景。这里还有一个坑副露的手牌展示顺序不能打破。副露组牌本身有顺子/刻子/杠三种形态但内部顺序其实是约定俗成的摆法这在 token 化时只需要把三张牌按固定顺序编码不要尝试重排。重排会让模型无法稳定识别副露的具体形式对役种判断的准确率下降明显。3. Transformer 如何建模牌河与手牌关系从注意力到策略网络3.1 为什么是 Transformer 而不是 LSTM 或纯 CNN立直麻将的每一步决策需要同时综合三件事当前的牌效哪一种切牌能更快听牌、对手的攻防意图牌河是否在染手、是否已经立直、以及宝牌带来的期望收益修正。这三者的共同点是都需要跨位置对比看某一张手牌是否安全要拿它与牌河里的每一张牌比较——有没有同巡、有没有振听、是不是现物判断对手是否在追立直要看他最近几巡的切牌速度变化。序列模型里 Transformer 的自注意力天然就是「全序列两两对比」这一点比 LSTM 的顺序递推高效也比 CNN 的局部感受野更灵活。3.2 一个可跑的 Transformer 策略与价值网络下面是简化但可运行的策略-价值网络定义直接兼容 PPO 训练循环。import torch import torch.nn as nn import math class PolicyValueTransformer(nn.Module): def __init__(self, d_model256, nhead8, num_layers6, dropout0.1): super().__init__() self.tokenizer MahjongStateTokenizer(d_modeld_model) self.segment_embed nn.Embedding(5, d_model) # 区分 0手牌/副露 1牌河 2宝牌 3场况 4动作候选 encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforward1024, dropoutdropout, batch_firstTrue, norm_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.policy_head nn.Linear(d_model, 34) # 先输出 34 种基本切牌动作 self.value_head nn.Linear(d_model, 1) def forward(self, state, action_mask): # state: dict, 包含 type_ids, tile_ids, positions, segments x self.tokenizer(state[type_ids], state[tile_ids], state[positions]) x x self.segment_embed(state[segments]) x self.encoder(x) # 取手牌首 token 或全局表示 pooled x[:, 0, :] logits self.policy_head(pooled) logits logits.masked_fill(action_mask 0, float(-inf)) value self.value_head(pooled) return logits, value这段代码里值得说明的是 segment embedding 的必要性单靠 type embedding 已经区分了信息类型但 type 数有限加上 segment 可以让模型更稳定地把「牌河」「宝牌」「动作候选」划分成三个会话组减少注意力在跨组对比时的混淆。action_mask 直接把不可选动作的 logits 置为负无穷例如当前手牌没有的牌、切出会导致振听或本源无法切出的牌这一步必须在模型输出后处理而不是在状态编码阶段硬过滤否则模型输入分布会偏移。3.3 动作空间切牌只是基础还要覆盖立直、副露与杠立直麻将的动作空间如果只建模「切哪张牌」那 agent 永远不会主动立直也不会碰吃杠策略上限非常低。完整动作空间主要包括切牌 34 种、立直、碰、吃、暗杠、加杠、荣和、自摸、拔北再加 Pass/继续。实际实现中我建议把它们拆成两层第一层决定是否进入副露/杠/荣和等事件响应。第二层在事件响应内选择具体牌。用两层既保持了主策略的稳定性也方便加入规则约束。下面给出一张我在训练时使用的动作编号表。动作类别具体动作输出维度说明切牌34 种基本切牌 切赤宝牌37赤宝牌单独编号立直立直宣言1只有门前清且非副露状态下可选副露碰、吃、明杠、加杠、暗杠5具体组合由子策略选择和了荣和、自摸2仅当手牌满足和牌条件其他Pass / 继续1用于副露提示或流局判断动作空间的分层会影响 PPO 的 loss 计算如果模型直接输出一个 46 维的摊平动作那么「立直」「碰」「吃」这些不同性质的动作会被放在同一个 softmax 中竞争这既不合理也会拖慢收敛。常见做法是主策略只输出切牌 立直 Pass副露和和了由规则引擎根据手牌合法性直接触发这样训练初期 agent 不会因为乱副露而快速放炮。4. 强化学习框架PPO 主训练循环与奖励塑形4.1 为什么选 PPO 而不是 DQN 或 A2C立直麻将的动作空间是离散的且每巡动作数不同DDPG 之类连续动作算法不适合DQN 的样本效率在完全对手模型缺失的情况下偏低A2C 对学习率的敏感度高训练不稳定。PPO 的 clipped surrogate objective 天然容忍一定程度的策略偏移并且在自对弈环境下每轮数据分布变化大PPO 能通过重要性采样把旧策略数据的使用上限控制住。实际项目中表现最稳定的组合是PPO GAE 计算 advantage 已有离线牌谱做 warm start。4.2 奖励塑形直接按最终得点训练为什么不行这是所有麻将 agent 项目里最值得花时间的部分。如果只看半庄最终得点一局要打 8 个东场奖励信号稀疏到几乎无法学习中间任何一个放炮都可能抵消之前积累的收益模型只能学到「越低风险越好」的消极策略——也就是全程弃和。立直麻将 agent 必须引入中间奖励。奖励项数值范围触发条件设计动机和了奖励0.3 ~ 1.0荣和/自摸一次鼓励进攻放铳惩罚-0.3 ~ -0.6自己切牌点炮抑制无脑进攻副露前进0.05碰/吃/杠后向听数下降奖励有效副露立直奖励0.1立直宣言鼓励积极立直听牌奖励0.05巡末仍未和牌但已听牌缓解稀疏问题宝牌相关0.02 × 宝牌数和了结算时引导宝牌利用率流局惩罚-0.05 ~ -0.2中途弃和或流局避免消极游戏这里有一个需要注意的平衡奖励幅度不要设置得过于精细否则 agent 会「刷奖励」——比如反复为了 0.05 的副露奖励去做毫无收益的碰导致手牌结构被破坏。我的经验是中间奖励的总量控制在最终胜负奖励能量的 20% 以内以最终结果为主。4.3 PPO 更新代码与关键超参数def ppo_update(model, optimizer, batch): states, actions, old_log_probs, returns, advantages batch logits, values model(states) dist torch.distributions.Categorical(logitslogits) log_probs dist.log_prob(actions) ratio torch.exp(log_probs - old_log_probs) clip_eps 0.2 surr1 ratio * advantages surr2 torch.clamp(ratio, 1.0 - clip_eps, 1.0 clip_eps) * advantages policy_loss -torch.min(surr1, surr2).mean() value_loss 0.5 * ((returns - values) ** 2).mean() entropy_loss -0.01 * dist.entropy().mean() loss policy_loss value_loss entropy_loss optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5) optimizer.step() return loss.item()PPO 更新中三个关键参数是 clip_eps0.2、GAE lambda0.95、entropy 系数0.01。clip_eps 控制策略更新幅度设太小则学习慢太大则自对弈起手阶段容易崩GAE lambda 决定了优势估计对多步回报的依赖程度立直麻将一巡内的短期回报信息有限lambda 偏低反而会让模型只盯着眼前收益我把 lambda 放在 0.95 附近entropy 项用于保持探索度麻将动作空间大且合法动作众多完全不做熵正则会在训练早期收敛到「总是切 a」这种局部最优。提示在使用 GAE 时values必须是已经被detach的旧价值网络输出否则在同一个 batch 上反复更新会产生价值偏差累积训练曲线会出现周期性失控。5. 从离线牌谱到在线自对弈训练数据、收敛信号与排错5.1 用 iql 离线强化学习做冷启动再用自对弈微调这一节是可复现性最强的部分。从头跑 PPO 自对弈的问题是前几千局 agent 只会乱切策略熵高、样本质量差等于在浪费大量 GPU 算力。比较稳妥的路线是先用天凤或雀魂牌谱解析成标准型数据做一轮离线强化学习预训练再切到在线自对弈。iqlImplicit Q-Learning在这里的优势是它不需要执行策略去采在线样本能从固定牌谱数据里学到相对保守的策略。实现上我习惯在 PPO 之前先用 iql 训 20~30 万 step把策略的随机切牌行为压到接近人类中低段位水平然后再启 PPO 自对弈。这样 PPO 起点的策略熵可能是 2.5 而不是 4.0收敛速度快很多。牌谱解析要把 tky 格式转成状态序列至少包含以下字段。牌谱字段说明对齐难点mode三人/四人、东/南场决定宝牌数和牌种范围dan段位过滤过高段位样本避免超出策略覆盖kyoku局数决定场风与自风tehais初始手牌注意是否包含红宝牌tsumo每一巡摸牌需要与切牌配对sutehai切牌记录用于构建牌河fuuro副露记录谁碰/吃/杠、哪几张牌hora和了信息奖符、点数、翻数ryukyoku流局信息听牌与否5.2 训练中要看哪些指标来判断 agent 是否真的在变强把训练日志打到 tensorboard 里最好每 2000 局记录一次。我关注的指标按优先级排序如下平均顺位和平均得点这是最终目标但噪声大短期意义有限。放铳率一个 agent 如果放铳率持续高于 18%说明它还没学会防守。人类中段位大约在 12%~15%。立直率如果立直率长期低于 12%说明奖励塑形里立直奖励权重不足或者模型学成了纯弃和策略。策略熵从 4.0 下降到 1.5 以下是正常轨迹如果卡在 2.5 降不下去大概率是状态特征里缺少了关键区分信息。平均有效动作率也就是最终切牌动作是否在合法动作集中这个指标接近 100% 才说明动作 mask 生效正常。训练命令上没有太多花活关键是保证数据流是异步的避免取样卡住python train.py \ --algo ppo \ --resume_from checkpoint/step_300000.pt \ --env riichi_env \ --num_workers 16 \ --batch_size 4096 \ --clip_range 0.2 \ --gae_lambda 0.95 \ --learning_rate 3e-4 \ --reward_shaping 0.15reward_shaping是中间奖励所占能量比例取 0.15 表示中间奖励最多贡献总回报的 15%这样既不会稀疏到学不动也不会喧宾夺主导致只刷短期奖励。num_workers在这里不是采样线程数而是同时进行自对弈游戏环境的进程数每一个 worker 独立跑一桌并回传轨迹段PPO 侧只做 update这种方案能明显提高池利用率。5.3 常见训练崩溃点非法动作、奖励爆炸与策略坍缩训练中最常遇到的崩溃有三个。第一动作编号越界或动作 mask 失效常见原因是牌谱里存在「切赤宝牌」与「切普通五万」未区分导致索引错位。解决方法是把赤宝牌固定映射到 34~36同时用单元测试验证每一张手牌都能映射到唯一动作索引。第二奖励爆炸如果对「宝牌相关」奖励项设计成每一次摸牌或副露都加分模型会疯狂副露刷宝牌数reward 曲线会直线上升但 agent 实际牌效极差。第三策略坍缩训练过程中如果 entropy 掉到 0.1 以下模型几乎不再探索此时要立即调高 entropy 系数或降低学习率否则后续数据分布无法纠正。6. 进阶技巧用规则先验修正动作概率加速安全攻防决策立直麻将 agent 在真正对局里最大的问题并不是算不出牌效而是防守决策的硬约束太强。纯强化学习模型对「这张牌是不是现物」「上家是不是立直后追切」「振听是否成立」这类规则性判断有时并不稳定而这些又直接决定放铳率。一个我验证有效的技巧是在模型输出后叠加规则先验修正分布。具体做法是把安全牌判定作为额外 logit 偏置在模型输出的 logits 上进行调整而不是直接覆盖模型决策。def apply_safety_bias(logits, legal_actions, safe_tiles, penalty-2.0): # logits: (34,) 模型输出的切牌 logits # legal_actions: 当前合法切牌集合 # safe_tiles: 规则判定的安全牌例如现物、立直家切过一次的牌 adjusted logits.clone() for tile in range(34): if tile not in legal_actions: adjusted[tile] -float(inf) elif tile in safe_tiles: adjusted[tile] 0.3 # 给安全牌小幅正向偏置 elif tile not in safe_tiles and any_opponent_riichi: adjusted[tile] penalty return adjusted这个做法的核心是用一个小的 margin 引导模型倾向安全牌而不是强行把安全牌概率置为 1因为安全牌的牌效往往较差——过度偏向防守同样会输。penalty-2.0是在至少有一家立直且该牌完全不是安全牌时触发的值如果两家立直可以把 penalty 加深到 -4.0让模型除非听牌且牌效极高否则不会轻易打出去。规则先验的另一个应用是立直判断。很多 agent 早期不敢立直因为立直后不能改变手牌一旦放铳损失翻倍。这里我会用规则做一条硬约束手牌没有宝牌、没有断幺九、没有门清自摸机会时直接屏蔽立直动作。这种约束不是靠修正概率而是直接作用在动作 mask 上效率更高也更稳定。对 agent 的最终验证我会固定一个随机种子、固定 500 个种子对局把 agent 同时跑成三家自己的训练 agent 占一家统计各项指标是否与训练日志一致。如果训练日志显示放铳率已经压到 14% 但固定种子验证却到了 20%这说明训练集中存在牌谱选择偏差需要回到数据侧补充防守场景更丰富的对局。把线装好之后剩下的事情就是让它同时坐上四个座位看它在「自己是亲家、已经立直、场上两家明显在做染手」时会不会在无宝牌听牌的情况下依然选择直接进攻——那一刻它的选择曲线就是你整个训练管线的质量认证。本文还有配套的精品资源点击获取
返回列表