Transformer与强化学习结合:立直麻将AI从监督学习到自对弈微调实战
简介面向深度学习与游戏AI研究者的立直麻将agent项目将Transformer强大的序列建模能力与强化学习决策框架相结合用于处理立直麻将中的复杂牌面信息与长期战略规划。资源压缩包共49个文件、772KB左右主要包含Python训练脚本、C引擎核心、头文件配置、说明文档及模型结构定义并完整覆盖监督学习、PPO自对弈、数据加载与结果可视化等关键环节。目前已有297人学习适合具备一定深度学习和强化学习基础、希望复现或扩展麻将AI算法的开发者。通过该资源可获得基于satori-master的立直麻将智能体实现框架包括牌效分析、何切问题数据集、自对弈环境搭建以及训练日志与分布图表生成方法有助于快速理解TransformerRL在实际博弈任务中的落地路径也可为机器人控制等更广泛的序贯决策问题提供参考。1. 立直麻将为什么需要Transformer与强化学习在立直麻将里一次出牌的选择远不是“把手里最没用那张打掉”那么简单。牌河里的每一张牌都在改变对手听牌的可能性宝牌指示牌决定了你是否值得放弃速度等待改良对手的一次副露就会让安全牌的优先级整体重排。这类决策带有明显的长程依赖前几巡打出去的现物可能在第十巡成为唯一放铳的理由。传统基于规则或蒙特卡洛搜索的方案在面对136张牌组合、副露变化与立直宣告叠加出的状态空间时很快就撑不住了。这也是Transformer和强化学习在这个场景里被同时看中的原因。Transformer的自注意力机制可以把手牌、牌河、副露、宝牌这些分散信息放进同一个上下文里建模不再受限于局部视野强化学习则让agent通过大量自对弈去试错在“和牌”和“放铳”的真实反馈里调整策略。satori-master这套代码走的正是两阶段路线先拿牌效题和何切题做监督学习预训练再用PPO做自对弈微调。下面按这条路径拆开讲从状态编码、模型结构、训练脚本一直落到验证与排错。2. 牌局状态编码把一手牌变成Transformer能消化的输入2.1 为什么不用单条序列而用“牌卡片”做输入麻将的原始手牌并不存在像文本那样明确的词序。你把手牌按万筒条排好这只是一种阅读习惯不代表出牌优先级同一手牌拆成面子候选可以有好几种读法。直接用定长向量把34种牌的枚数压进去又会丢失“这手牌哪几张是搭子、哪几张是孤张”的局部结构信息。我一般会把一副手牌拆成若干张“牌卡片”每张卡片对应一个候选牌特征行特征行由三部分拼起来这张牌本身的类别、它在手牌里的剩余张数、以及整局可见牌的统计。这样Transformer的每一行输入就是一张牌的完整上下文注意力机制负责去学“这张牌和哪些牌放在一起有牌效”。下面是常用的特征字段划分。特征组维度含义牌型one-hot34该行对应的牌型万/筒/索/风/三元手牌枚数4手牌中该牌实际剩余张数牌河可见统计34所有玩家牌河副露中该牌已见张数宝牌相关信息6宝牌指示牌数量、里宝牌数量、赤宝牌标记场况特征8巡数、自风、场风、立直状态、副露次数等每行拼起来后一张候选牌对应几十维向量整手牌进入模型时就是一个“可变长度”的集合。相比定长向量输入这种表示保留了牌与牌之间的并行关系也给后续策略头输出“打哪张”提供了直接对齐的功能。2.2 手写一个轻量级Transformer编码器satori-master的model.py结构不复杂核心就是TransformerEncoder加策略头和价值头。手写一个最小可用版本import torch import torch.nn as nn class RiichiAgent(nn.Module): def __init__(self, feat_dim96, d_model256, nhead8, num_layers4): super().__init__() self.proj nn.Sequential( nn.Linear(feat_dim, d_model), nn.ReLU(), nn.Linear(d_model, d_model), ) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforward1024, dropout0.1, batch_firstTrue, ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.policy_head nn.Sequential( nn.Linear(d_model, 512), nn.ReLU(), nn.Linear(512, 34), # 34种切牌动作 ) self.value_head nn.Sequential( nn.Linear(d_model, 128), nn.ReLU(), nn.Linear(128, 1), ) def forward(self, x, maskNone): # x: [batch, num_tiles, feat_dim] h self.proj(x) h self.encoder(h, src_key_padding_maskmask) g h.mean(dim1) # 全局状态向量 logits self.policy_head(g) value self.value_head(g) return logits, value这里feat_dim对应上面特征表的维度输入形状是[batch, tiles, feat_dim]tiles会按场况动态变化。策略头输出34维对应打出一张万、筒、索、风、元牌的索引。self.encoder内部每一层都会做多头自注意力模型因此有机会学会“某张牌的安全度不只看它本身还看牌河里已经出现过什么”这类跨位置推理。src_key_padding_mask用来遮挡填充位避免多余牌型干扰注意力计算。2.3 为什么不选CNN或LSTM立直麻将里有两类信息特别吃全局视野。一类是需要跨牌河判断的“放铳风险”比如某张字牌在牌河里的出现顺序、被谁打过另一类是搭子之间的“重叠”比如手里同时有四五万和三五万打哪张都要考虑剩余张数的联动。CNN受限于卷积核大小堆叠很多层才能看到远处LSTM虽然能处理序列但串行计算慢而且把牌河按时间展开后长局末段的牌河信息会稀释早期关键信号。Transformer的注意力机制天然把任意两处位置连起来这也是我选择它能省掉大量特征工程的原因。3. 第一阶段用牌效题和何切300问做监督预训练3.1 训练样本怎么构建牌效.txt和何切300问.txt是两份现成的监督信号来源。牌效题通常给一手牌正确答案是“切哪张后向听数下降或牌效最高”何切300问则是带场景的判断题问题里会包含牌河、宝牌、巡目等附加信息。它们本质上都是“状态到最优动作”的样本正好用来训练Transformer的policy头。我会先把这两份文本解析成统一格式一手牌、副露、牌河、宝牌指示、巡目加上答案索引。spider.py如果用来抓取牌谱结果还可以继续扩充数据集但第一版直接用这两份资料就够启动。注意一点牌效题如果只有手牌没有牌河特征里的牌河统计需要用随机缺失来模拟让模型不至于对空特征产生过拟合。3.2 SL训练脚本的核心逻辑SL.py是单卡训练脚本SL_ddp.py是基于DistributedDataParallel的多卡版本。训练循环的核心代码大致如下for epoch in range(start_epoch, total_epochs): for batch in train_loader: logits, _ model(batch) loss F.cross_entropy(logits, batch[action]) optimizer.zero_grad() loss.backward() grad_norm torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() if step % 100 0: print(fepoch {epoch} step {step} loss {loss.item():.4f} grad_norm {grad_norm:.4f})这里的损失函数是动作索引的交叉熵动作索引对应34种切牌。clip_grad_norm_限制梯度范数到5.0用来避免Transformer深层反传时梯度爆炸我看log.txt时习惯同时盯loss和grad_norm如果grad_norm持续大于10通常意味着学习率太大或特征里有数值异常。3.3 val_acc和val_loss怎么解读训练结束会在验证集上计算val_acc和val_lossplot.py会画出两张图存成images/val_acc.png和images/val_loss.png。val_acc在立直麻将里通常不会像图像分类那么高因为很多牌型存在多个等价切牌答案只是其中一种。一个可参考的节奏是val_loss持续下降而val_acc出现波动说明模型在收敛val_acc先升后降但val_loss还在降就要怀疑过拟合。我踩过一个典型坑忘记把宝牌指示特征加进训练样本时val_acc掉7到8个百分点。这是因为何切300问里有大量涉及宝牌取舍的题目缺了宝牌信号的模型只能靠频率猜准确率上限被锁死。所以做监督学习时特征完整性比模型宽度更优先。4. 第二阶段行为克隆到PPO自对弈微调4.1 为什么还需要强化学习阶段监督学习阶段的模型学会了“从静态视角看哪张牌效率最高”但立直麻将里的每一次出牌都在影响别人。主动进攻会提高放铳率过度防守会让和牌速度变慢这些代价没法在牌效题里体现。强化学习阶段就是让模型和它对局用真实对局结果来校准策略。常见做法是先在SL模型基础上初始化PPO再把策略进一步训练这样比从头RL少跑很多局。4.2 自对弈数据如何产生和存储selfplay_worker.py的角色是持续开对局生成经验。每个worker会周期性拉取最新模型权重独自运行一局完整的麻将把每一步的状态、动作、动作概率、价值和局末奖励打包成一个episode推进ReplayBuffer。这个设计的核心是让训练和采样解耦避免GPU训练时还要等游戏模拟。def selfplay_loop(engine, model, buffer, device): state engine.reset() episode [] while not engine.terminal: probs, value model.policy_value(state) action sample_action(probs) next_state, reward engine.step(action) log_prob torch.log(probs[action]) episode.append((state, action, log_prob, value, reward)) state next_state returns discount_rewards([r for _, _, _, _, r in episode], gamma0.99) for (s, a, lp, v, _), g in zip(episode, returns): buffer.push(s, a, lp, g)注意这里把整局奖励按折扣累计到每一步而不是只给和牌那一手。因为立直麻将里“前面打的每一张牌都可能决定最后的结果”以整局视角分配奖励比单步奖励更稳定。实际项目中ReplayBuffer还要存旧策略的log_prob否则PPO更新时无法计算新旧策略比例。4.3 ppo_discrete_main.py里的核心更新ppo_discrete_main.py的主循环从buffer里取样计算当前模型对旧样本的动作概率与旧概率做比值再用裁剪目标约束更新幅度。核心代码for batch in replay_buffer.sample(batch_size): logits, values model(batch) log_probs F.log_softmax(logits, dim-1).gather(1, batch[actions]) old_log_probs batch[old_log_probs] advantages batch[returns] - values.detach() ratio (log_probs - old_log_probs).exp() surr1 ratio * advantages surr2 torch.clamp(ratio, 1 - clip_eps, 1 clip_eps) * advantages policy_loss -torch.min(surr1, surr2).mean() value_loss F.mse_loss(values.squeeze(-1), batch[returns]) entropy_loss -log_probs.mean() loss policy_loss value_coef * value_loss entropy_coef * entropy_loss optimizer.zero_grad() loss.backward() optimizer.step()这里clip_eps通常取0.2表示新策略相对旧策略的单次更新幅度不超过20%。优势用“累计回报减去当前价值预估”本质是看这一步相对均值是更好还是更坏。熵项用来阻止策略过早变成确定性输出避免模型死循环在某一手打法上。训练时我会同时盯log.txt里的approx_kl如果单次更新KL超过0.03说明clip没有兜住需要调低学习率。4.4 奖励函数设计自对弈阶段的奖励直接决定了agent的麻将风格不同事件我给过的权重如下事件奖励值设计意图和牌1.0以最终胜负为核心信号自摸额外0.5鼓励追求高打点而非单纯副露快攻放铳-1.0强化防守意识压制盲目进攻立直0.03鼓励做出有回报的进攻动作副露-0.01对降低门清收益的副露做微惩罚每巡小惩罚-0.0005让agent倾向更快完成手牌这个表里立直奖励很小因为立直本身是高风险动作如果给大了agent会无脑立直。每巡小惩罚则是为了对抗“永远防守等流局”的消极策略。实际训练时可以用view_dist.py看动作分布确认立直和副露的比例没有失衡。5. 验证与排错从run_test.py和label_dist看策略有没有长歪5.1 用完整对局跑统计而不是只看准确率单步动作准确率高不等于会打麻将尤其是防守决策这种“正确时没反馈、错误时放铳”的场景。run_test.py的用法是加载训练好的weights跑若干整局输出和牌率、平均打点、放铳率、平均巡数python run_test.py --weights weights/best.pth --episodes 500 --seed 2024--episodes建议至少500局立直麻将方差很大几十局的结果没有统计意义。报告里我关注的指标优先级是放铳率低于20%、和牌率高于22%、平均打点超过6000。如果放铳率太高大概率是防守训练不足回看是不是奖励表里放铳惩罚太小。5.2 view_dist.py看动作分布发现隐藏病征view_dist.py会把模型在验证集上的动作分布画成label_dist.png。这个图能暴露三类问题一是动作熵接近0模型永远只打固定牌型基本是过拟合二是立直动作占比过高说明立直奖励给多了三是某一种牌的打出概率异常高常见于训练数据不平衡比如何切300问里字牌题偏少。我在训练中期会固定跑一次分布跟SL阶段的分布做对比看RL是不是把策略带偏了。5.3 自对弈版本漂移一个容易被忽略的细节自对弈worker和训练主进程之间是异步的worker拿到的权重可能落后好几轮。如果buffer里一半数据来自旧策略、一半来自新策略PPO估算的优势就会混入不同版本的噪声表现为loss反复震荡。我一般会在replaybuffer里记录每个episode对应的模型版本号采样时限制单次batch中旧版数据比例不超过20%。另一个做法是定期清空部分buffer让模型更多在当前策略分布上学习。这两个技巧能让kl散度和clip_frac稳定不少。最后补一个实盘小技巧把训练好的模型放到测试环境里用同一把牌分别跑SL权重和RL微调后的权重对比两版在放铳判断上的差异。如果RL版本面对危险牌能主动放弃进攻说明自对弈阶段的奖励函数起效了如果它反而更激进回查放铳惩罚和相关场况特征是否真正传到了价值头。本文还有配套的精品资源点击获取