从零构建围棋AI:深度学习与蒙特卡洛树搜索实战解析

发布时间:2026/10/17 7:42:13
从零构建围棋AI:深度学习与蒙特卡洛树搜索实战解析
先算一笔账。19路围棋的合法局面数量大约在10的170次方量级这个数字比宇宙中的原子总数还要多出不少量级。传统搜索树和简单局面评估在这种复杂度面前基本是失效的这也是二十多年前程序员用绝对搜索算法做围棋程序时始终卡在业余低段的根本原因。深度学习出现后局面被压缩成一组有结构的特征神经网络通过海量对局逐步学会“什么位置重要”“哪里厚哪里薄”“哪些子需要保留”这种抽象判断围棋AI才真正跨过了那道坎。这一篇是系列的第二部分。第一篇已经讲了深度学习基础知识与围棋表示的基本思路包括怎么把19路棋盘变成张量、卷积网络为什么适合提取局部棋形特征以及一个最简单的局面胜率预测模型是怎么跑通的。这一篇我会直接拆开讲几个我觉得最值得折腾的部分怎么构造训练数据、怎么设计策略网络和价值网络、怎么把它们和蒙特卡洛树搜索串成完整的落子引擎以及训练过程中会遇到哪些坑。它既适合已经接触过PyTorch和卷积网络、想做一个完整围棋实验的开发者也适合对围棋AI原理感兴趣、想知道系统各部分如何配合的爱好者。整个实验成本不算高一张普通的消费级显卡就能启动但里面涉及到的数据处理、自对弈闭环和搜索对接几乎把深度学习中“从数据到决策”的完整链路都走了一遍。我管这个实验叫“模拟项目X”就是一个跑在本地的小型围棋评估引擎。下面所有内容都来自我实际折腾这个项目的记录。1. 整体设计策略、价值与搜索的三层分工1.1 为什么围棋必须依赖深度学习围棋的状态空间太大但棋盘的物理尺寸其实只有19乘19局部棋形相对有限。这就带来一个关键启发与其直接暴搜不如先让机器理解“棋形”。深度神经网络擅长做的恰恰是从高维原始输入中提取层次化特征低层看到邻近几子的连接、断点中层看到一块棋的眼位、气紧程度高层看到全局厚薄和势力范围。这个建模思路和人类棋手看棋很像只是机器用的是可微分的参数化函数。我在第一个版本里试过不用卷积直接用全连接网络吃整个棋盘展开后的361个坐标。那种做法在极小棋盘上还能跑放到19路上参数暴涨而且全连接没有平移不变性棋子换一块位置就要重新学泛化能力很差。后来切到卷积网络同样的数据量下棋力明显上了一个台阶。这说明围棋棋盘虽然对称但棋形特征可以在不同位置通用这正是卷积层存在的意义。1.2 策略网络和价值网络的职责拆分完整围棋系统里有两种不同性质的判断。第一类是“下一步该走哪”叫策略网络输出一个覆盖所有合法落点的概率分布第二类是“当前局面谁赢”叫价值网络输出一个接近胜率的标量值通常用1和-1表示黑胜和白胜。两个网络的输入几乎一样都是编码后的棋盘张量但输出头不同。我把它们比喻成两个人策略网络像一个有经验的棋手看一眼局面能快速列出几个候选点价值网络像一个局面判断师拿到棋谱后能说出“黑棋这里太薄胜率不到四成”。单独用策略网络只会模仿已有棋谱的风格单独用价值网络又缺乏在未知局面下的落子依据所以必须配合搜索。1.3 蒙特卡洛树搜索在整套架构中的位置蒙特卡洛树搜索简称MCTS是把两个网络连接成完整决策器的胶水。MCTS不直接用策略网络输出最高概率点而是在虚拟棋盘上反复推演每轮从当前局面出发沿着高潜力分支一路走到没展开的叶子节点用价值网络评估叶子再把结果一路回传给这条路径上的每个节点更新统计量。跑完足够多模拟后根节点的访问次数分布就是最终的落子概率。这套思路解决了一个核心问题神经网络会犯错搜索则相当于把错误分散到多次推演中。我早期只拿策略网络argmax落子棋力很不稳定经常在一个局部型里连续走错好几手加上MCTS以后虽然单步推理变慢但棋形明显合理胜率提升非常明显。2. 数据准备让棋盘变成神经网络能吃的特征2.1 训练样本从哪来构建围棋训练样本有三条主流来源。第一条是公开棋谱集里面每一盘棋的每个手数都对应一个局面和当时落子被称为策略网络的模仿学习数据。第二条是自对弈让当前版本的引擎自己跟自己下生成新对局然后从终局结果反推中间局面的胜负标签。第三条是增强式自我对抗让新旧版本互弈用胜负结果筛选出更强的那一方作为后续训练对象。我推荐从第一条起步因为自己生成的棋谱质量一开始很可能低得离谱神经网络从中学不到多少有效模式。先用现有棋谱集训练一个基础策略哪怕胜率只有百分之二三十它至少能提供合理的候选点和先验概率后续自对弈的质量才会慢慢提起来。我在模拟项目X里用的就是“小棋谱集预训练自对弈增量学习”的混合路线。2.2 棋盘特征编码设计网络不能直接吃棋盘的坐标数字要把19乘19的棋盘编码成多通道张量。最基础的做法是当前黑子位置一个通道、白子位置一个通道、当前轮到谁一个通道。但只给这三样不够因为网络无法感知“气”这种局部死活信息所以还需要把每个空点周围的气数、最近几步历史也编码进去。我在实用版本里采用了17通道设计前8个通道记录最近8步双方棋子的位置第9通道记录当前玩家是否执黑剩下8个通道记录最近8步中每一手棋位置的“高亮标记”。这个设计参考了公开论文里的做法但它并不复杂原理就是用历史序列让网络感知“哪些点刚被走过”“局面是怎么演化到这里的”。提示特征编码顺序必须保持一致。我踩过最狠的坑是训练时用了“黑子通道在前”推理时代码里却改成“白子通道在前”导致网络看到的是左右翻转的棋盘胜率直接崩到随机水平。2.3 数据增强与自对弈样本生成围棋棋盘天然具备4次旋转和2次镜像共8种等价变换。同一盘自对弈棋谱通过旋转镜像可以变成8盘等效棋谱等于白拿了8倍数据量。这个操作极其廉价但对模型泛化帮助巨大尤其是训练数据不足的时候。自对弈样本生成流程分为三步。第一步加载当前策略和价值网络第二步每盘棋由两个引擎实例互相对弈默认策略网络输出会被温度参数随机化避免每盘棋落点完全一样第三步对局结束后把每个局面连同最终胜负标成一组(statepiz)。这里的pi不是那一步实际落点而是MCTS搜索后的访问次数分布z是1或-1的最终结果。我在做数据增强时写过一个坐标映射函数核心逻辑其实很简单对任意坐标(xy)绕中心旋转90度后的映射是(19-1-yx)镜像则是(19-1-xy)。写的时候很容易弄混旋转方向最保险的方式是对一小批局面做手动核对打印几个关键点在变换前后的位置确认映射正确后再批量执行。3. 网络结构搭建一个能落地的评估引擎3.1 为什么选卷积残差网络围棋的局部棋形往往出现在不同位置比如一个“小飞挂角”既可能发生在左上角也可能发生在右下角卷积核的参数共享机制保证了同一套卷积核扫描整张棋盘时能识别出同类棋形。这比手写特征工程靠谱得多也比全连接网络参数规模小得多。但单纯加深卷积网络会遇到退化问题层数叠加后误差会变大不是因为过拟合而是深层网络难以优化。残差结构把输入和输出加在一起让梯度可以从后往前无损传播网络自然就能训练得更深。模拟项目X里我只用了10个残差块每层64个卷积核输出能力已经足够支撑基础棋力没必要一上来就堆到20层40层耗电不说小数据集还容易训不动。3.2 模型定义与关键参数以下是我在模拟项目X里使用的PyTorch模型结构核心理念是小而稳class ResBlock(torch.nn.Module): def __init__(self, channels): super().__init__() self.conv1 torch.nn.Conv2d(channels, channels, 3, padding1) self.bn1 torch.nn.BatchNorm2d(channels) self.conv2 torch.nn.Conv2d(channels, channels, 3, padding1) self.bn2 torch.nn.BatchNorm2d(channels) def forward(self, x): out torch.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) return torch.relu(out x) class MiniGoNet(torch.nn.Module): def __init__(self): super().__init__() self.input_conv torch.nn.Conv2d(17, 64, 3, padding1) self.blocks torch.nn.Sequential( *[ResBlock(64) for _ in range(10)] ) self.policy_conv torch.nn.Conv2d(64, 4, 1) self.policy_fc torch.nn.Linear(4 * 19 * 19, 362) self.value_conv torch.nn.Conv2d(64, 2, 1) self.value_fc1 torch.nn.Linear(2 * 19 * 19, 64) self.value_fc2 torch.nn.Linear(64, 1) def forward(self, x): x torch.relu(self.input_conv(x)) x self.blocks(x) policy torch.relu(self.policy_conv(x)) policy self.policy_fc(policy.view(policy.size(0), -1)) value torch.relu(self.value_conv(x)) value torch.relu(self.value_fc1(value.view(value.size(0), -1))) value torch.tanh(self.value_fc2(value)) return policy, value输入通道17第一个卷积层把棋盘从特征空间映射到64维紧接着10个残差块做深入特征提取之后分两个头策略头输出362维对应19乘19落点加一个过手点价值头输出1维经过tanh压缩到-1到1之间。输出用tanh而不是sigmoid是因为价值网络希望表达“黑棋胜率过半”和“白棋胜率过半”两种对称状态-1和1天然更好。3.3 落点输出与过手点的设计19路棋盘可落子点共有361个但围棋里还有一手“虚着”通常叫过手或pass所以策略头输出固定362维是行业通用做法。第一版我把过手点忘了结果引擎在官子阶段不知道该下在哪里只能乱点气紧的位置后面加上过手点配合树上搜索遇到无意义局面才能安静地结束对局。参数设置里有一个容易被忽略的细节批归一化层在训练时统计的是当前批次的均值方差在推理时用的则是累计均值方差。模型加载到推理端时一定要调用model.eval()把BN切到推理模式否则同样的棋盘每次预测的结果可能微有波动虽然幅度不大但对接MCTS后概率分布会被放大棋力会受到隐性影响。4. 训练闭环自对弈、损失函数与节奏控制4.1 损失函数怎么设计模拟项目X同时优化两个目标。策略头用交叉熵让模型输出分布尽量贴近搜索得到的访问次数分布价值头用均方误差让预测值尽量贴近对局最终结果。总损失是两个损失的加权和权重可以简单都设为1也可以按阶段调整。训练时样本标签不像分类任务那样是硬标签0/1而是软标签一个好的落点得分也许只有0.3另几个候选点也有0.1左右。这种设计非常重要因为围棋存在很多“差不多”的好点模型需要学习的是概率分布形状而不是武断地选一个最优手。policy_loss -torch.mean(torch.sum(pi_target * torch.log_softmax(policy_logits, dim1), dim1)) value_loss torch.mean((value_target - value_pred) ** 2) total_loss policy_loss value_loss注意如果用torch.log_softmax计算策略分布输入是策略头原始logits目标pi要先归一化到概率和等于1。训练时我习惯顺手打印一下policy_loss和value_loss各自的数值如果策略损失降得很快但价值损失纹丝不动多半是自对弈样本的终局标签噪声太大需要调整温度参数或增加模拟次数。4.2 训练流程与参数经验值我搭的训练循环分为三个阶段。第一阶段用公开棋谱预训练学习率从0.01开始用StepLR每10个epoch衰减0.5跑20到30个epoch让策略网络先有点基础。第二阶段固定价值网络不动只让策略网络配合MCTS生成少量自对弈棋谱观察棋谱质量是否稳定。第三阶段才开始完整的交替训练每生成一批自对弈数据训练网络5到10个epoch评估棋力后重新生成数据。小批量大小我习惯用256学习率在进入自对弈阶段后降到0.001左右。显卡紧张时batch size降到128也可以但学习率要相应下调否则收敛曲线会非常毛糙。训练轮数不必死板我判断依据是价值损失是否连续5个epoch不再显著下降以及自对弈对局里是否连续出现重复走子等异常行为。4.3 温度参数与探索节奏自对弈生成棋谱时不能每次都走策略概率最高的点否则会产生大量雷同样本模型会陷入自证循环。温度参数就是用来控制随机性的。温度高时概率分布被拉平模型偶尔会走冷门点温度低时分布变尖基本挑最高概率点走。训练初期我让温度保持在1.0左右明显提高样本多样性中期逐步降到0.5后期为了验证棋力再降到接近0。温度参数与MCTS的结合也很关键。搜索得到的根节点访问次数分布往往已经很平均如果再用高温度采样棋谱质量会明显下降。我的习惯是生成棋谱时设置温度0.7到1.0最终评估棋力时用温度0.05左右甚至直接取argmax。5. 蒙特卡洛树搜索对接从网络到完整落子引擎5.1 MCTS四步流程有了训练好的策略网络和价值网络接下来就是把搜索树架起来。MCTS的每一轮模拟分为四个阶段。选择阶段从根节点出发按PUCT公式逐层挑子节点扩展阶段在到达未展开节点时初始化它的所有可落子子节点评估阶段调用价值网络给出该局面的预测胜率回溯阶段把评估结果沿着访问路径回传更新路径上每个节点的访问次数和累计价值。function MCTS(root_state): for _ in range(num_simulations): node root_state while node is expanded: node best_child(node) if node not terminal: node.expand(policy_network(node.state)) leaf_value value_network(node.state) node.backup(leaf_value) return visit_distribution(root_state)伪代码里最核心的best_child就是按照PUCT公式算出来的下一节详细讲。5.2 PUCT公式与先验概率的作用选择子节点时不能只去访问价值高的节点否则搜索树会过早固定到一条分支上错过后期逆转。PUCT公式在传统UCB公式基础上加了一项策略先验( U(sa) Q(sa) c_{puct} \cdot P(sa) \cdot \frac{\sqrt{N(s)}}{1 N(sa)} )其中Q是平均价值P是策略网络给这个落点的先验概率N(s)是当前节点总访问次数N(sa)是被选子节点访问次数。先验概率高或访问次数少的节点会被优先探索访问次数多了之后探索项逐渐衰减决策回归到Q主导。这个公式相当于让搜索框定一个范围策略网络说“这有希望”搜索就在这个范围内反复推演如果推演出价值不高Q会惩罚这个分支搜索转向其他点。没有策略网络先验的纯MCTS在19路围棋上是没法用的分支因子太大搜索深度太浅。5.3 实测表现与参数选择我在模拟项目X里对比过几组参数。不搜索时棋力只相当于“会下但经常崩”的水平遇到复杂征子经常判断错误。把模拟次数加到200次后棋力明显上升但每个落点耗时已经到秒级。加到1600次后棋力提升开始放缓时间却线性增长。最终我选择400次作为训练阶段的默认值评估阶段用1600次。模拟次数不是越多越好。搜索本身依赖神经网络评估的准确性如果价值网络看错一个关键局部再多的模拟也会围绕错误判断打转。模拟项目X的实测结论是在训练初期与其盲目增加模拟次数不如先把训练数据搞干净、把损失函数稳定下来中期再逐步提高搜索次数棋力提升会更有效。6. 常见问题与排查技巧实录6.1 问题速查表现象可能原因排查方法训练损失不下降特征编码顺序错单独跑一次前向对比输入张量是否合理策略损失降但价值损失不降自对弈样本终局标签噪声太大检查胜负结果是否错误提高MCTS模拟次数棋力停在固定水平温度过高或过低调整温度观察自对弈棋谱多样性MCTS模块越搜越差策略网络先验过于尖锐降低c_puct或对先验做平滑处理推理时胜率预测波动大BN层未切到eval模式确保调用model.eval()网络反复下同一手数据重复度太高加大旋转镜像增强提高温度过手点出现概率异常训练样本缺少过手局面在终局附近人为构造过手样本6.2 训练陷入死局的三个原因第一个原因是数据单调。自对弈引擎如果一直用温度偏低的策略生成棋谱几千盘棋几乎都是同一个布局模型只能学会应对特定套路遇到新开局迅速崩盘。解决方法是定期引入公开棋谱做噪声注入或者在自对弈时前几十手强制走随机棋步。第二个原因是价值网络与策略网络互相放大错误。策略网络选了坏点价值网络又给了错误高分搜索会强化这个错误生成的全是糟糕棋谱下一轮训练继续被污染。我给的缓解方案是每隔一段时间用固定基线模型做对手只有击败基线才更新模型否则继续保留旧权重训练。第三个原因是评估指标失效。只看总胜率不看具体棋谱内容很容易陷入“对局质量低但统计上胜利率高”的陷阱。我最有效的做法是定期抽取几盘自对弈棋谱用简单规则检查手数、落点合法性、关键位置的重复率发现明显异常再回头找数据原因。6.3 几个能救命的检查习惯第一训练前单独写一个脚本做单步前向输入手工构造的简单局面手动验证策略网络输出哪些点概率高。这一步能同时发现特征编码错位、张量维度错误、通道顺序搞反等低级问题。第二自对弈生成完棋谱后先做一次标签校验。抽样100组(statepiz)人工确认pi中概率最高的点是不是那盘棋真实落点附近的点z是不是真实胜负。我见过的最贵的bug是胜负标签写反了整个训练白跑一周罪魁祸首竟然是生成棋谱时黑棋视角和白棋视角没对齐。第三保存模型权重时把训练参数、数据版本一起存成一个字典而不是只存state_dict。围棋数据的迭代依赖版本管理意识新一段时间后你可能完全记不清上一版用了哪些通道、多少模拟次数、哪批数据集没有版本信息就只能重新训练。第四MCTS对接初期先在一个简单测试局面里手动跑30次模拟打印每个节点的Q、P、N值确认数值与公式一致。别直接上百次模拟然后看输出那样只能看到结果看不到搜索过程是否合理。最后分享一个我自己的习惯每完成一轮训练我不急着看棋力测试而是先让模型和当前最优模型下两盘棋让两个模型体验差异肉眼可见。如果新模型明显下出更差的局部棋形就立刻回退而不是继续用新参数生成新的自对弈数据。机器学习项目里最大的时间浪费不是训练慢而是脏数据被一次次循环利用污染范围越滚越大。这套围棋系统的核心价值不在于复制某个高分AI的结果而在于让我完整走了一遍“特征设计-数据生成-网络训练-搜索决策-问题定位”的闭环每一步都能看到深度学习的实际行为这种体验比任何框架教程都直接。