
围棋这个题材在深度学习圈子里算是块硬骨头。它不像图像分类那样随便找个现成网络微调一下就能出结果也不像文本任务预训练模型拿来就能用。棋盘是19乘19的离散网格每一步落子都会改变全局的势力结构一手棋的价值要几十步之后才能体现出来。更麻烦的是围棋数据的形态很特殊——它既不是天然的图像也不是序列而是一种带全局依赖的图结构状态。我前前后后折腾过几套围棋神经网络从最早用全连接硬怼到后来老老实实按卷积残差网络的路子重写中间踩的坑足够写一本小册子。这篇内容就把整个设计过程摊开讲围棋数据该怎么编码成神经网络能吃的张量网络结构为什么必须是卷积而不是别的参数量和显存怎么手算训练时哪些指标值得盯以及那些只有真跑过一遍才会遇到的诡异问题。不管你是刚入门深度学习想找个有意思的练手项目还是已经做过一些视觉任务想换个领域试试这套思路都能直接拿去复现。1. 先想清楚围棋数据到底长什么样1.1 围棋不是图像但比图像更难啃很多人第一次做围棋模型脑子里第一反应是棋盘不就是个19乘19的图吗那不就是一张灰度图。这个类比只对了一半。棋盘确实是一个规则的二维网格落子位置天然对应像素坐标这是它像图像的地方。但图像里的像素之间是局部相关的相邻像素颜色接近边缘和纹理都可以靠局部感受野一步步拼出来。围棋完全不是这个逻辑左上角一块棋的死活可能取决于右下角一个劫材的存亡一条大龙能不能连回家要看三十手之前埋下的一个断点。这种长距离、全局性的依赖关系是图像任务里很少遇到的。还有一个更本质的区别图像的信息密度是均匀的每个像素都携带颜色信息围棋棋盘上绝大多数交叉点在绝大多数时刻是空的信息高度稀疏。你如果直接把空、黑、白编码成三个通道会发现模型大部分时间在学这里什么都没有这件废话。所以围棋的特征工程重点不在于还原画面而在于把那些对判断局势真正有用的隐性信息显式地喂给网络。我一开始没意识到这一点直接用了三个平面表示黑子、白子、空点训练出来的模型在开局阶段还行一进入中盘接触战就完全不会下胜率预测也飘得厉害。后来加了气数、征子关系、打劫状态这些特征平面同样的网络结构准确率直接往上跳了一截。这个经历让我彻底明白围棋神经网络的瓶颈前期几乎全在特征设计上而不在网络深度上。1.2 为什么全连接网络在这一步就出局了在动手写卷积之前我建议你先认真算一笔账看看全连接网络在围棋上到底要多少参数。棋盘19乘19等于361个点假设我们用最简单的三平面编码输入维度就是361乘3等于1083维。如果只做一个256维的隐藏层参数量是1083乘256约等于27.7万看起来还能接受。但问题在于这个隐藏层之后要输出361维的策略向量256乘361约等于9.2万也还行。可这套网络根本学不到东西因为它把每个交叉点当成独立输入左上角的棋子和右下角的棋子在网络眼里毫无空间关系。要让它感知空间关系就得加深加宽。假设隐藏层做到2048维输入层参数变成1083乘2048约222万输出层变成2048乘361约74万总共接近300万参数但效果依然很差。原因很直接全连接层没有平移不变性同一个三子连珠的形状出现在棋盘左上角和出现在右下角对网络来说是两组完全不同的权重必须分别学习。围棋里有大量可以平移、旋转的局部棋形这种重复学习是巨大的浪费。卷积层恰好解决了这个问题。一个3乘3的卷积核在整张棋盘上滑动权重共享不管棋形出现在哪里都用同一组参数去识别它。这就是为什么几乎所有围棋神经网络从最早的AlphaGo到后来的各种开源实现清一色都是卷积结构。在这个任务上卷积不是效果更好的选择而是唯一可行的选择。1.3 标签设计与训练目标的选择网络结构定了接下来要回答一个更实际的问题我们到底想让这个神经网络学会什么。最直接的目标是预测人类棋手的下一手也就是给每个局面配一个361维的标签正确落子位置为1其余为0用交叉熵损失去训练。这个任务叫策略网络好处是数据现成网上有大量职业和业余棋谱可以直接用标签不需要人工标注。但策略网络有个明显短板它只学会了像人一样下而人的棋本身有强弱之分业余棋谱里错误不少模型会把这些错误一并学进去。所以我更推荐同时训练一个价值头让它预测当前局面的胜率。价值头的标签来自对局结果黑胜为1白胜为0用均方误差。这样网络一边学下在哪里一边学现在谁好两个任务共享底层特征互相促进。实践中我发现加了价值头之后策略头的准确率反而也涨了一点因为这迫使网络去理解局势而不是死记棋形。如果你只想先跑通一个最小版本那就先做纯策略网络把流程走顺。等训练管线稳定了再回头加价值头。别一上来就搞双头加自对弈那是给自己找麻烦。2. 特征编码把一盘棋翻译成神经网络能吃的张量2.1 基础棋子平面与合法落子掩码编码的第一步是最朴素的用几个二值平面表示棋盘上的基本状态。我的习惯是至少四个平面。第一个平面表示当前落子方的棋子第二个平面表示对手的棋子第三个平面表示空点第四个平面表示该局面的合法落子位置。前三个是局面本身第四个是约束条件非常重要。因为围棋有禁着点规则打劫、自杀点都不能下如果训练的时候不告诉网络哪些点不能下模型会在这些位置上分配概率推理时还得手动屏蔽不如直接在输入里就说清楚。具体到实现一个局面用一个形状为4, 19, 19的浮点张量表示取值都是0或1。合法落子平面需要用围棋规则引擎算出来我一般用现成的规则库比如Python里的围棋规则实现传入当前局面和落子方返回一个布尔矩阵。这里有个容易忽略的细节合法落子必须考虑打劫规则也就是刚提掉对方一子后对方不能立刻回提。规则引擎一般会处理但你如果是自己写简化版务必确认这一条否则模型会学到一些非法但看起来合理的下法。注意合法落子平面是动态的每一手都要重新计算不能缓存复用。这一步的耗时在数据预处理阶段占比不低建议用多进程并行生成别让规则计算拖慢整个训练管线。2.2 历史局面与最后一手标记只给当前局面是不够的围棋是有时间维度的博弈。同样一个棋形上一手刚下在哪里直接决定了对手该关注哪片区域。所以我会额外加入最近几步的历史局面。常见的做法是保留最近8步的棋子平面也就是黑子白子各8个平面再加一个单独的平面标记最后一手的落子位置。这一手标记很关键它相当于给网络一个注意力锚点告诉它变化刚刚发生在这里。历史局面的顺序也有讲究。我把最近的局面放前面越久远的放后面网络在通道维度上更容易捕捉时间衰减。有人喜欢用单独的LSTM去处理历史序列我不太推荐因为棋盘状态的空间结构太强循环结构反而增加了训练难度且收益有限。直接用堆叠的平面让卷积自己去提取时序信息简单稳定。这里还有个工程上的坑一局棋开头的几步历史局面不足8步怎么办。我的做法是用全零平面填充同时把该平面是否有效这件事通过一个额外的掩码平面表达出来。如果你偷懒直接重复第一步的局面会让网络误以为开局阶段时间静止行为会变得很奇怪。这个细节很小但我见过好几份开源实现栽在这里。2.3 气、打吃、征子这些围棋语义特征到这一步基础局面和历史都齐了但网络的棋力还是有限。真正拉开差距的是围棋特有的语义特征。我最看重的几个是每块棋的气数、处于打吃状态的棋子位置、征子关系。气数平面可以粗略地用一个小卷积从棋子平面推出来但显式地算好直接喂进去能让网络少走很多弯路。打吃状态更是关键一盘棋里大量关键决策都和打吃、逃子有关。征子这个特征稍微麻烦一点。征子是围棋里一种连续打吃的吃子手法能不能征掉取决于征途上有没有对方的接应子。要算出完整的征子关系需要模拟多步落子计算量不小。我的折中方案是算一个简化版本对每个被打吃的棋子沿着它可能逃跑的方向推演4到6步判断是否存在被征掉的风险把结果落到一个平面上。这个近似版本虽然不完美但在中低段位的数据上已经能明显提升表现。这些语义平面加起来大概8到12个加上前面的棋子、历史、合法落子总输入通道数落在20到30之间。有人会问这么多特征网络自己学不出来吗。理论上够深够宽的网络确实能从原始棋子平面里学出来但那意味着几倍的训练数据和时间。显式特征等于把先验知识直接注入性价比极高。我在算力和数据都有限的情况下宁可多花时间做特征也不愿意堆网络深度。2.4 数据增强八种对称变换围棋棋盘的对称性对数据增强来说是个天赐礼物。棋盘是正方形具有8种对称变换4个旋转加上4个镜像。任何一局棋的任意一个局面经过这8种变换后依然是一个合法局面标签位置也跟着变换即可。这意味着你的有效数据量直接翻了8倍。实现上很简单把局面张量做旋转和翻转策略标签是361维的向量需要先reshape成19乘19的矩阵做同样的变换后再拉平。这一步千万别搞错我见过有人只变换了输入没变换标签模型训练时损失下降得特别慢排查了半天才发现是标签和输入对不上。最容易验证的方法是拿一个局面和它的变换版本分别过一遍网络输出应该也要满足对应的变换关系。import numpy as np def augment(state, policy, idx): # state: (C, 19, 19), policy: (361,) p policy.reshape(19, 19) if idx 4: state np.flip(state, axis2) p np.flip(p, axis1) idx - 4 state np.rot90(state, idx, axes(1, 2)) p np.rot90(p, idx) return np.ascontiguousarray(state), p.reshape(-1).copy()这段代码是我常用的版本先做镜像再做旋转顺序固定下来就不会乱。注意np.rot90返回的是视图最后一定要加ascontiguousarray和copy不然会踩到内存共享的坑训练时数据莫名其妙地被改动排查起来极其痛苦。3. 网络结构设计与参数量核算3.1 残差块骨架的搭建网络主干我采用的是卷积残差结构和图像领域的ResNet思路一致但有几个针对围棋的调整。第一层是一个3乘3卷积把输入通道数映射到主干通道数比如256。之后堆叠若干个残差块每个残差块包含两个3乘3卷积每个卷积后面接批归一化和ReLU激活最后做一次跳跃连接。主干输出再分两路一路进策略头一路进价值头。为什么用残差而不是普通的堆叠卷积。因为围棋需要很大的感受野才能看到全盘而感受野的增长速度取决于卷积层数。一个3乘3卷积让感受野增加2一个残差块包含两个3乘3卷积感受野增加4。棋盘的对角线长度大约是19乘1.414约等于27要让特征图上一个点的感受野覆盖全盘至少需要感受野达到27以上也就是大约7个残差块。但感受野够不等于信息利用充分实际上要到13个残差块甚至19个网络的判断才明显稳定。层数一多梯度消失和退化问题就来了残差连接就是解决这个的标准手段。实操心得残差块的第一个卷积之后我一般不加偏置因为紧跟着批归一化会把均值减掉偏置是冗余参数。省下来的参数不多但能少一点过拟合风险。3.2 通道数与感受野的取舍通道数是另一个要拍脑袋决定的参数。太少了表达能力不够太多了显存吃不住。我的经验值是256或者192前者适合有单张中端显卡的情况后者适合显存紧张时降级使用。真正决定棋力的往往是残差块的数量而不是通道数所以显存不够时优先砍通道别砍深度。感受野的计算值得亲手推一遍。假设有N个残差块每个贡献4的感受野增长加上第一层卷积的3总感受野大约是4N加3。要覆盖19乘19棋盘的最远两点距离约27需要4N加3大于等于27N大于等于6。这是理论下限实际建议N取13到20之间。我自己最后稳定在15个残差块感受野约63远超棋盘尺寸网络有足够余量去建立全局联系。再往深了堆当然还能涨点但收益递减非常明显。我做过对比从15加到20验证集上的策略准确率只提升了不到半个百分点训练时间却多了三分之一。所以别迷信越深越好找到性价比拐点就停。3.3 双头输出策略头与价值头主干输出的是一个形状为B, 256, 19, 19的特征图。策略头做的事情是把空间位置映射成落子概率。最简单的做法是用一个1乘1卷积把通道数降到1得到一个B, 1, 19, 19的平面再拉平成361维接softmax。这里我建议在1乘1卷积之前先加一个普通的3乘3卷积把通道数维持住让策略头有一点自己的非线性表达能力否则直接从主干抽一个通道出来表达力偏弱。价值头则要把整张棋盘的全局信息压缩成一个标量。做法是先经过几个卷积然后用全局平均池化把19乘19压成1乘1再通过两层全连接输出一个值最后用tanh把范围限制在负1到1之间对应黑胜到白胜。全局平均池化比直接展平接全连接好因为参数量小得多也不容易过拟合。两个头的损失要加权。我一般用策略损失为主价值损失乘一个0.3到0.5的系数。权重太大会让网络过度关注胜负而忽略具体下法太小则价值头学不动。这个系数需要根据验证集表现微调没有放之四海皆准的数值。3.4 参数量与显存占用的手算过程动手前先把账算清楚能省掉很多跑到一半爆显存的尴尬。以15个残差块、256通道为例。第一层卷积3乘3乘输入通道数乘256加256假设输入通道24那就是3乘3乘24乘256加256约等于5.5万。每个残差块两个3乘3卷积每个是3乘3乘256乘256加256等于590,080一个块约118万15个块约1770万。策略头和价值头加起来大概再几十万。总计大约1800万参数。这个规模算是相当克制权重文件不到80MB。再看显存。批大小取128特征图大小是128乘256乘19乘19乘4字节等于约47MB每层。前向传播时要保存中间激活用于反向传播15个残差块大约30个卷积层加上其他层粗略按40层算激活占用约1.9GB。再加上梯度、优化器状态如果用Adam每个参数要存两个状态约1.4亿字节即140MB和输入数据总共大概3到4GB。单张8GB显存的卡跑起来绰绰有余还能把批大小往上提到256。这个手算虽然粗糙但足够判断方案可行性比开跑之后再调参数高效得多。4. 训练流程与关键环节实现4.1 数据管道从SGF到可训练样本原始棋谱一般是SGF格式里面记录了每一手的位置。要变成训练样本需要按顺序重放整局棋每重放一手就用当时的局面和目标落子生成一个样本。这个过程叫局面抽取。一局平均200手左右的棋能抽出将近200个样本几万局棋就是几百万样本量级足够。我建议把抽取和训练解耦。先写一个离线脚本把SGF批量转成numpy或者HDF5格式的特征张量存到磁盘上。训练时只做读取和增强不走规则计算。这样做的好处是训练速度大幅提升而且数据可以反复使用。缺点是占用磁盘空间几百万样本按24, 19, 19的float16算大概每样本17KB一千万样本约170GB需要提前规划存储。数据读取用PyTorch的Dataset配合DataLoadernum_workers设成4到8pin_memory打开。增强操作放在Dataset的__getitem__里每次随机选一种对称变换。注意别在训练中途改变增强策略否则验证集指标会失去可比性。4.2 损失函数与优化器配置策略头用交叉熵损失但这里有个细节必须处理好非法落子位置要屏蔽掉。做法是在softmax之前把非法位置的logits设成一个很大的负数比如负1e9这样softmax之后它们的概率近似为零损失计算也不会被它们干扰。如果不做这一步模型会花大量精力去压低非法位置的概率浪费容量。价值头用均方误差。优化器我习惯用带动量的SGD而不是Adam动量0.9权重衰减1e-4。SGD在围棋这种任务上泛化性往往更好Adam前期收敛快但后期验证损失容易反弹。学习率初始值设0.01配合余弦退火衰减到接近零。批归一化的动量保持默认0.1即可。import torch import torch.nn as nn class PolicyValueNet(nn.Module): def __init__(self, in_ch24, ch256, blocks15): super().__init__() self.stem nn.Conv2d(in_ch, ch, 3, padding1, biasFalse) self.bn0 nn.BatchNorm2d(ch) self.blocks nn.ModuleList([ResBlock(ch) for _ in range(blocks)]) self.p_conv nn.Conv2d(ch, 2, 3, padding1, biasFalse) self.p_bn nn.BatchNorm2d(2) self.p_out nn.Conv2d(2, 1, 1) self.v_conv nn.Conv2d(ch, 1, 1) self.v_bn nn.BatchNorm2d(1) self.v_fc1 nn.Linear(19 * 19, 256) self.v_fc2 nn.Linear(256, 1) def forward(self, x, maskNone): x torch.relu(self.bn0(self.stem(x))) for b in self.blocks: x b(x) p torch.relu(self.p_bn(self.p_conv(x))) p self.p_out(p).flatten(1) if mask is not None: p p.masked_fill(mask 0, -1e9) p torch.log_softmax(p, dim1) v torch.relu(self.v_bn(self.v_conv(x))).flatten(1) v torch.relu(self.v_fc1(v)) v torch.tanh(self.v_fc2(v)) return p, v这段代码是我实际用过的骨架ResBlock就是标准的双卷积加跳跃连接。注意策略输出用的是log_softmax配合NLLLoss数值上比先softmax再取对数稳定。4.3 训练循环与学习率调度训练循环本身没什么花哨的但有几个监控点必须加上。第一个是策略准确率也就是top1预测是否命中真实落子。这个指标最直观开局阶段能到55%以上就算不错中盘会掉到40%左右这是正常的因为中盘选择多、随机性大。第二个是价值损失它应该平滑下降如果抖动剧烈说明学习率太大。第三个是梯度范数我习惯把它裁剪到1.0防止偶尔的梯度爆炸把训练带偏。学习率调度用余弦退火每个epoch更新一次。总epoch数根据数据量定几百万样本的话60到100个epoch比较合适。每个epoch结束后在验证集上跑一遍记录指标。验证集要从训练集里按对局切分不能按样本切分否则同一局棋的不同局面会同时出现在训练和验证里造成数据泄漏指标虚高得离谱。4.4 训练现场的记录与指标观察我强烈建议把训练日志写进TensorBoard或者类似的工具光看终端输出会漏掉很多信息。除了损失和准确率我还会记录每个残差块的梯度均值观察是否存在某些层梯度接近零的情况。如果发现靠前的层梯度长期偏小说明网络太深或者初始化有问题可以考虑加辅助损失把中间层的特征也接一个小策略头迫使浅层也参与学习。另一个值得盯的是输出的熵。如果策略输出的熵长期很低说明网络变得过度自信往往伴随过拟合如果熵一直很高说明网络没学到东西输出接近均匀分布。健康的训练过程里熵应该随着训练缓慢下降然后趋于稳定。这个指标不像准确率那么直观但异常时往往更早发出预警。5. 踩过的坑常见问题与排查技巧5.1 损失不下降的几种典型死法损失不动是最折磨人的情况因为可能的原因太多。我按排查顺序列一下。第一检查标签和输入的对应关系尤其是做了数据增强之后。方法很简单取一个batch把输入可视化出来手动确认标签位置确实在输入里是个合理的落子点。我就遇到过标签整体偏移一位的情况是因为reshape时行列搞反了这种错误不看图根本发现不了。第二检查合法落子掩码有没有正确传递到损失计算里。如果掩码全零所有logits都被设成负1e9softmax输出全零损失直接变成nan或者常数。第三检查学习率。0.01对SGD来说偏高如果batch很小很容易发散。可以先用0.001跑几十步看损失有没有下降趋势。第四确认输入特征没有全零平面。有时候特征生成脚本出错某个平面全是零网络输入信息量不足自然也学不动。5.2 过拟合与数据泄漏的隐蔽表现过拟合好识别训练损失降验证损失涨。但数据泄漏更隐蔽也更危险。最常见的泄漏源是按样本切分数据集同一局棋的前半段在训练集后半段在验证集。因为相邻局面高度相似模型等于在验证集上看到了训练数据的近邻指标会虚高十几个百分点。解决办法是按对局ID切分同一局棋的所有样本必须待在同一个集合里。另一个隐蔽的泄漏来自特征里的未来信息。比如你在计算某个特征时误用了整局棋的最终结果或者用了后续才产生的信息。这类错误在代码里很难肉眼发现建议对每个特征做单元测试用几个手工构造的简单局面验证特征值是否符合预期。5.3 对称性与数据增强不一致引发的怪现象这个坑我踩过一次印象深刻。当时训练损失正常下降验证损失也正常但模型推理时行为诡异同一个局面稍微平移一下输出的策略分布差异巨大。排查了很久才发现训练时的增强和推理时的预处理用了不同的坐标约定一个把第一行当顶部一个当底部导致镜像方向不一致。模型本身没问题是前后处理对不上。解决办法是统一坐标约定并写进文档所有涉及坐标转换的函数都调用同一份工具代码不允许各处手写。推理阶段不要做随机增强输入什么样就推理什么样如果要做对称集成就在输出端把8种变换的结果反变换回来再平均别在输入端乱动。5.4 问题速查表症状可能原因排查动作损失长期不降学习率过大、标签错位、掩码全零可视化一个batch的输入标签、降学习率到0.001试跑验证指标异常高数据泄漏、同一局样本跨集检查是否按对局ID切分损失出现nan梯度爆炸、log零、除零开启梯度裁剪、检查log_softmax前的掩码值推理结果随平移剧变前后处理坐标不一致统一坐标工具函数、比对输入张量策略熵长期偏高网络欠拟合、特征无效检查特征平面是否全零、增加残差块数量显存不足批大小过大、激活未释放降低批大小、开启混合精度、减少通道数提醒混合精度训练能省一半显存但批归一化层建议保持float32否则统计量会有精度损失。PyTorch的autocast默认会处理这一点别手动把整个模型转成half。6. 模型怎么验证从离线指标到真实棋力6.1 离线指标的局限策略准确率是最容易拿到的指标但它和真实棋力之间隔着不小的距离。一个准确率60%的模型未必比准确率55%的模型下得好因为它可能只是在开局常见定式上记得更牢中盘关键处的判断反而更差。我习惯把验证集按手数分段统计开局前30手、中盘30到150手、收官150手之后分别看准确率。如果开高、中低说明模型只会背定式中盘理解不足这时候要考虑增加语义特征或者加深网络而不是继续堆数据。价值头的表现可以用胜率预测的误差来衡量但同样有局限。我会额外做一件事把模型对某个局面的胜率预测和实际对局结果做相关性分析如果相关性低于0.5说明价值头基本没学到东西需要检查标签构造是否正确。6.2 与搜索结合的自对弈验证真正能说明棋力的是让模型和搜索算法结合跟旧版本模型对弈。做法是每个落子用蒙特卡洛树搜索搜索时用策略网络的输出作为先验用价值网络的输出作为叶节点评估跑几百次模拟后选择访问次数最多的点。然后让新模型和旧模型下几百盘统计胜率。胜率超过55%就可以认为新模型确实更强。这个验证流程计算量大不可能每训练一个epoch就做一次我一般每10个epoch做一次或者在验证损失连续不再改善时做一次。自对弈的局数不必太多200盘已经能给出统计上比较可信的结论。这里要注意对弈时双方要用同样的搜索参数否则比较不公平。我有一次图省事给新模型多跑了模拟次数结果胜率虚高白高兴一场。6.3 一些工程上的收尾经验最后分享几个实际操作里总结出来的小经验。第一模型检查点别只存最新的保留最近5个和验证最好的那个训练中断时可以回滚。第二随机种子固定下来包括Python、numpy、PyTorch和CUDA的否则复现实验会让人抓狂。第三特征生成和训练要分成两个独立的可执行流程中间用磁盘文件衔接这样调试特征时不用重跑训练调试训练时不用重算特征效率差好几倍。还有一点关于数据规模的体会。我从几万局棋加到几十万局棋验证指标涨得很快再从几十万加到几百万提升就非常有限了。数据量存在明显的边际递减与其无限扩充数据不如把精力放在特征质量和网络结构的小幅调整上。我个人的经验是几十万局高质量的职业棋谱配上设计合理的特征和15个残差块已经能训练出一个在业余段位里相当有战斗力的模型。再往上走靠的就不是堆料而是对围棋本身理解的深化了。