第 25 章 · 代码实战

AlphaZero 实战:让五子棋 AI 从零自学

第 23 章把监督学习跑通了,第 24 章把生成模型跑通了; 最后一章代码实战,把第 12 章的强化学习升级成一套完整的 AlphaZero 式系统: 15×15 五子棋、策略价值残差网络、PUCT MCTS、自对弈、Replay Buffer、并行训练和新旧冠军门禁。 全程不用人类棋谱、不用旧 AI 教落子;网络从规则和输赢里自己学会进攻、防守与搜索。

源码位置说明:本章项目不在 deeplearning 主仓的 src/ 下; 完整可构建源码位于 chenxuan520/alphazero-gomoku。 下文的 src/game/...src/mcts/... 都是相对这个独立仓库根目录而言。

读完这一章,你会明白

  • 为什么 15×15 五子棋不可能再用 Q 表,必须上神经网络 + 搜索;
  • 4 个棋盘平面如何编码“当前行棋方视角”,策略头和价值头分别学什么;
  • PUCT 如何把网络先验 P、搜索价值 Q 和访问次数 N合在一起;
  • MCTS 怎样生成比网络原始策略更强的 π,终局胜负怎样变成 z;
  • Replay Buffer、8 对称增广、hard mining、残局做种如何保持 π/z 来自自对弈,同时引入领域课程先验;
  • 48 个 worker、缓存、checkpoint、门禁对打如何组成一条可长期运行的训练线;
  • 真实训练从 policy loss 5.42 降到 2.7 左右时经历过哪些致命 bug 和性能取舍;
  • 浏览器如何直接解析 C++ 二进制权重,本地完成 ResNet 前向与 PUCT MCTS。
代码/日志里的词本章怎么读含义
iteration一轮训练自对弈一批棋局 + 多次梯度更新 + 可选门禁
worker并行自对弈线程每个线程持有独立网络副本,同时产棋
simulations / simsMCTS 模拟次数一次落子前从根反复搜索多少条路径
batch小批量样本一次反向传播共同处理多少个局面
latest / best候选模型 / 当前冠军刚训练出的网络与门禁保留的最强网络
gate代际门禁候选打赢冠军达到阈值才准晋级
policy/value loss策略/价值损失分别衡量落子分布和胜负估计误差
hard mining / recency难例重采样 / 近期偏置采样多复习危险局面,并提高近期经验比例

1. 先下一盘:真实冠军模型,浏览器本地推理

先别看公式。下面不是手写规则、不是远程服务器返回一步棋,而是发布产物里的真实 19.2 万参数冠军网络: 页面下载约 770KB 的 C++ .net 文件,JavaScript 直接解析 Conv/BN/残差块参数, 在你的浏览器里完成策略价值前向和 PUCT MCTS。默认你执白棋、48 次模拟;可选 12/24/96/120, 也可以在“自定义”里输入 1–800。模拟数越高通常越强,每步等待也近似线性增加。AlphaZero 先落天元。 浏览器会在真实落子后复用对应搜索子树;状态行只有继承到正访问数时才显示“复用 N visits”。

模型、网络结构、完整训练档案与损失曲线原始点见 deeplearning-model / alphazero-gomoku。网页前向已和 C++ 在 4 个局面逐值对齐,最大原始打分(logit)误差 2.67e-5。

试玩永远读取 stable 通道

页面不绑定任何版本文件名,只读固定的 channels/stable.json; 模型仓根据完整同预算循环、双颜色外部对手谱和高预算回归门禁晋升生产模型。 fastdeep 通道另行保留专项模型,历史权重均可回滚。 因此以后换模型不需要再修改本章或 game-old。

完整源码可独立复现

本章精读的不是框架仓里不存在的伪代码。完整 C++ 源码在独立仓库 chenxuan520/alphazero-gomoku: git clone https://github.com/chenxuan520/alphazero-gomokucd alphazero-gomokucmake -S . -B build -DCMAKE_BUILD_TYPE=Releasecmake --build build -j./bin/test_az。 规则、ResNet、MCTS、自对弈、训练器、门禁、4509 项测试和 JS/C++ parity probe 都在。

棋盘状态 s4×15×15 平面
Policy-Value ResNetp + v
PUCT MCTS选择/扩展/回传
落子访问次数最多

网络不会直接“拍脑袋落子”:它先给搜索方向和局面价值,树搜索再验证后果。

2. 环境:15×15 棋盘、胜负与 4 平面编码

环境是 src/game/gomoku.*。动作 a 是 0–224 的行优先格子编号; 黑棋记 +1、白棋记 −1、空位 0。自由规则下横/竖/两条斜线任一方向五连或长连即胜,没有禁手。

src/game/gomoku.cpp · 落子与终局(精简)
board_[action] = current_player_;
++move_count_;
last_action_ = action;

if (CheckWin(row, column))
  result_ = current_player_;                 1
else if (move_count_ == 225)
  result_ = DRAW;
else
  current_player_ = -current_player_;        2
  1. CheckWin 从刚落下的棋子向两边数 4 个方向,累计 ≥5 即胜。
  2. 未终局才交换行棋方;这点决定 MCTS 终局价值的符号约定。

网络始终站在当前该走棋的一方看棋盘,因此同一个绝对棋盘轮到黑/白时输入不同:

平面形状含义为什么需要
015×15当前行棋方的棋子统一黑白视角,同一网络两色共用
115×15对方棋子识别威胁与包围关系
215×15上一手 one-hot标记刚发生的局部变化
315×15黑走全 1,白走全 0保留先后手信息
8 种对称,一盘棋变八盘

方形棋盘有 4 次旋转 × 可选镜像 = 8 个等价局面。训练抽样时同时变换输入平面和策略 π,不制造假标签,却把数据利用率放大 8 倍。

为什么必须统一成“待行方视角”

如果固定用“黑棋是我、白棋是对手”编码,同一个网络要同时学习两套语义:黑走时 v>0 表示黑优, 白走时又得把符号反过来。当前实现把待行方棋子永远放 plane 0,对方永远放 plane 1, 于是价值头只需学习一句话:从现在该走棋的人看,这个局面最后能赢吗?

棋局结果记录样本时轮到谁目标 z含义
黑胜黑走+1待行方最终获胜
黑胜白走−1待行方最终失败
白胜白走+1同一价值语义
和棋/200 手截断任意0不偏向任何一方
上一手不是“装饰平面”

网络输入包含 last-move plane,所以相同棋盘、不同上一手是两个不同输入。 一次浏览器移植审查发现旧 EvalCache key 只含行棋方与棋盘,会错误复用另一个上一手的输出; 实测两种输入的某些 logits 可差到 0.781。现在 key 已改成“行棋方 + 上一手 + 棋盘”,并新增碰撞单测。

3. 策略价值 ResNet:一个身体,两个脑袋

网络完全使用项目从手写 C++ 框架复制出的 Float32 组件:BatchedConv2DBatchNorm2DResidualBlock2DFloatLinear。没有 PyTorch/TensorFlow/ONNX。

4×15×15
3×3 Conv + BN + ReLU32 通道
4 × Residual Block两层 3×3 Conv
Policy Head225 logits
Value Headtanh → [−1,1]
lib/cnn/policy_value_resnet.cpp · PolicyValueResNet::Forward(精简)
trunk = ReLU(BN(stem_conv(input)));
for (block : blocks)
  trunk = block.Forward(trunk);                1

policy = ReLU(BN(policy_conv(trunk)));
policy_logits = policy_fc(Flatten(policy));     2

value = ReLU(BN(value_conv(trunk)));
hidden = ReLU(value_hidden(Flatten(value)));
v = tanh(value_output(hidden));                 3
  1. 4 个残差块共享棋形特征;残差连接让深层网络更好训练(第 13 章)。
  2. 策略头输出 225 个 raw logits,经合法手 mask + softmax 后得到先验 P(s,a)。
  3. 价值头输出当前待行方的预期胜负:赢接近 +1,输接近 −1,和棋接近 0。
部分真实配置作用
Stem4→32,3×3把棋盘平面提成局部棋形特征
Trunk4 blocks,32ch反复组合进攻/防守棋形
Policy2ch→450→225给每个落点先验
Value1ch→225→64→1评估局面最终胜负
总参数191,853冠军权重约 770KB

191,853 个参数是怎么算出来的

“19 万参数”不是看文件大小猜的。每个卷积权重按 [Cout,Cin,Kh,Kw], 每个全连接按 [out,in];BN 的可训练量是每通道一组 γ/β。逐块展开:

部分计算序列化参数
Stem32×4×3×3 + bias32 + BN(32×2)1,248
单个残差块2×[32×32×3×3 + bias32 + BN64]18,624
4 个残差块18,624×474,496
Policy head1×1 Conv/BN 70 + FC(450×225+225)101,545
Value head1×1 Conv/BN 35 + FC(225×64+64) + FC(64×1+1)14,564
合计1,248+74,496+101,545+14,564191,853

所有卷积实际都设 use_bias=false,但 v1 序列化格式仍保存了 291 个全零 bias 槽位; 真正参与训练的参数是 191,562。模型还额外保存 582 个 BN running mean/variance, 所以文件总共 192,435 个 float32 + 72 个向量长度前缀 + 64 字节头 = 770,380 bytes。

推理时 BN 为什么可以“折进卷积”

训练时 BN 要按 batch 统计均值/方差;推理时 running mean/variance 已固定,可把每通道 BN 改写成一次仿射:

yc = γc(xc−μc)/√(σ²c+ε)+βc = acxc+bc a=γ/√(σ²+ε), b=β−μa;C++ 的 ForwardAffine 和浏览器 JS 都走这条推理路径

这也是为什么复制网络时只复制“可训练参数”不够:BN running statistics 不是梯度参数,却直接决定推理输出。 漏复制它们时,worker 会拿 mean=0/variance=1 做归一化,整批自对弈标签都可能漂掉。

4. MCTS:PUCT 如何把“直觉”变成“算过”

每个树节点是一个局面,每条边是一个候选落子。搜索反复执行选择 → 扩展/评估 → 回传。 选择时不只看当前平均胜率 Q,还给网络先验高但尚未充分搜索的动作一个探索奖励:

score = Q(s,a) + cpuct · P(s,a) · √N(s) / [1 + N(s,a)] Q 是搜索经验;P 是网络直觉;N 越小探索奖励越大
src/mcts/mcts.cpp · 选择与价值回传(精简)
q = edge.n > 0 ? edge.w / edge.n : parent_q - fpu;
u = c_puct * edge.prior * sqrt(parent.n) / (1 + edge.n);
edge = argmax(q + u);                         1

value = leaf_value;
for (path from leaf to root) {
  value = -value;                             2
  edge.n += 1; edge.w += value;
}
  1. 未访问边还没有自己的 Q,先借父节点价值作 FPU(First Play Urgency,首次访问估计);本项目最终设 fpu=0,避免早期价值平坦时把新分支压得过低、锁死探索。
  2. 每向上一层就换行棋方,价值符号必须翻转。这曾是项目里最致命的 bug 之一。

搜索并不把 225 个空点全展开。除空盘只搜天元外,只保留距离已有棋子 Chebyshev 半径 2 内的空点,通常约 25–40 个。 这是纯动作空间剪枝,没有人工棋形评分。它解决了早期“217 个动作分 100 次模拟,一半边从未访问”的 π 噪声灾难。

训练和比赛的搜索不一样

训练在根节点混入 Dirichlet 噪声并在前 6 手按访问分布采样。内部 latest-vs-best 门禁关闭噪声,但仍沿用前 6 手采样、之后贪心;网页试玩则从第一手起始终选访问次数最多的落子。

一条 simulation 到底走了什么

一次 MCTS simulation 不是“网络前向一次就完了”。它从根局面复制出临时棋盘,沿 PUCT 最大边一层层落子, 直到碰到终局或尚未展开的叶子。若是叶子,网络只评估一次,然后把 v 沿整条路径回传:

阶段读什么写什么主要成本
选择每条边 P/Q/N路径 edge 列表遍历候选边
落子临时棋盘下一局面规则/胜负判断
扩展4×15×15 输入候选边和先验 P一次网络前向
回传叶子价值 vN+=1,W+=±v,Q=W/N路径长度

搜 600 次不等于“往前看 600 手”,而是生成 600 条由当前 PUCT 策略选择的路径。树会把预算集中在少数有希望的分支; 根某条边的 N 越大,说明它在反复搜索验证后仍值得投入。

为什么 217 个合法点会让 100 sims 隐性报废

空盘附近如果直接展开全部空格,中前盘可能有 200 多条边。100 次模拟意味着至少一半边永远 N=0, 根访问分布 π 不是“深思熟虑后的策略”,而只是“哪些边碰巧被访问过”。更糟的是,一手必杀也可能根本没展开。

平均每边访问数 ≈ simulations / candidate actions 100 / 217 ≈ 0.46 次;剪到 25–40 个候选后约 2.5–4 次,搜索才真正有比较能力

半径 2 剪枝只问“这个空点附近有没有棋子”,不问它是活三、冲四还是好棋;因此没有把手写棋力灌进网络。 但它确实编码了一个五子棋工程先验:离所有棋子很远的点短期不会形成连线。动作生成是这类 CPU 版 AlphaZero 的命脉。

根访问次数为什么能当训练目标

网络原始策略 P 只经历一次前向;访问分布 π 则融合了多次落子、叶子价值和终局反杀。 训练网络逼近 π,相当于把一次昂贵搜索“蒸馏”进下一代网络。下一轮搜索从更好的 P 出发,又能得到更好的 π—— 这就是“网络教搜索,搜索再教网络”的具体落点。

5. 自对弈怎样自动产生 (s,π,z)

监督学习需要人给“正确走法”;AlphaZero 让 MCTS 自己产标签。每一步先搜索,把根节点访问次数归一化成 π; 棋局结束后,把最终胜负从当时待行方视角写回每个状态成为 z

src/train/self_play.cpp · 一局数据如何产生(伪代码对应真实实现)
while (!game.terminal()) {
  encode(game, sample.state);                  1
  mcts.Search(game, network, visits);
  sample.pi = normalize(visits);               2
  action = opening ? sample(sample.pi) : argmax(sample.pi);
  remember(player_to_move, sample);
  game.Apply(action);
}
for (sample : trajectory)
  sample.z = result_from(sample.player);        3
  1. 输入状态在落子前编码,永远是当前待行方视角。
  2. π 不是网络原始 softmax,而是搜索后的访问分布——搜索在教网络。
  3. z 来自对局结果:真实终局胜 +1、负 −1、和 0;若达到 200 手上限仍未终局,工程上截断并按和棋 z=0 记录。没有人工胜负标签。
当前网络
MCTS 自对弈生成 s,π,z
Replay Buffer最多 20 万状态
梯度更新

一盘棋结束后,怎样把结果回填给几十个旧状态

自对弈过程中先暂存每一步的输入、π 和“当时待行方”。终局才知道 winner,于是倒回整条轨迹: 若样本当时轮到最终胜方,z=+1;轮到败方,z=−1;和棋或 200 手截断,z=0。 同一盘黑胜棋里,黑走状态和白走状态的 z 会正负交替——这正是当前行棋方视角的意义。

200 手截断不是规则和棋

五子棋棋盘有 225 格,理论上可以继续;项目为控制弱网早期的超长乱战,在 200 手截断并标 z=0。 这是工程近似,不是棋规。模型变强后平均局长降到几十手,截断占比自然下降。

6. Replay Buffer、增广与策略价值损失

Buffer 是固定容量环形队列,样本为 4×225 输入 + 225 维 π + 一个 z。 最终配置保留 20 万状态:60% 按近期指数偏置采样,40% 全局均匀,既跟上新策略又不忘旧局面。

L = −Σa π(a|s) log pθ(a|s) + 2[zvθ(s)]² 策略头模仿 MCTS 访问分布;价值头拟合终局;本次 value loss 权重设为 2

五子棋的难点不是常见开局,而是“对手已经形成三/四连,只剩唯一挡点”的长尾。项目没有去问旧 AI 正确答案,而是重组自己的历史经验:

标签是 Zero,课程采样带领域先验

所有状态、π 与 z 都来自模型自己的历史自对弈;没有人类棋谱、没有旧站 AI 落子、没有手写棋形分数。但 hard set 用手写“三/四连检测器”挑哪些错题多复习,属于领域特定 curriculum,不是对 AlphaGo Zero“只给规则”配方的逐字复刻。

一条 Sample 占多少内存

Sample 是定长结构:4×225 个输入 float、225 个 π float、1 个 z float,合计 1126 个 float32:

1126 × 4 bytes = 4504 bytes / state; 4504 × 200,000 = 900,800,000 bytes ≈ 859 MiB 这就是 buffer.bin 接近 860MB 的原因;它存的是局面,不是 20 万盘棋

一盘自对弈平均会留下几十个状态,因此累计到万局级时,20 万容量很快装满。 装满后 write_pos 环形覆盖最老样本,内存和文件大小保持不再增长;训练规模继续增加时, 变化的是池中经验的新旧分布,不是文件无限膨胀。

60% 新经验 + 40% 全历史

策略每轮都变,十几小时前冷网产生的数据可能已经过时;若只均匀抽样,大量旧错误会稀释新信号。 SampleIndicesRecency 以 60% 概率按指数年龄抽近期样本(平均 age≈8000),其余 40% 均匀抽全池: 前者让网络跟上当前策略,后者保留旧棋形覆盖,避免只记得最近打法。

src/train/trainer.cpp · 一个 batch 怎样组出来(精简)
buffer.SampleIndicesRecency(batch, rng,
                            /*recent=*/0.6,
                            /*mean_age=*/8000, indices);   1

for (30% batch slots)
  indices[tail] = random(hard_indices);         2

for (sample : batch) {
  symmetry = random(0..7);
  TransformPlanes(state, symmetry);
  TransformPolicy(pi, symmetry);                3
}
  1. 先混合近期/全历史。
  2. 再把 batch 尾部约 30% 替换成必防长尾。
  3. 最后对输入和 π 做同一个旋转/镜像,保证标签不变形。

7. 并行训练、缓存、断点续训与门禁

最终训练使用 48 个并行自对弈线程(worker),每个持有独立网络副本;同一轮权重固定。 64 分片局面缓存用“行棋方 + 上一手 + 棋盘”作完整键,命中后直接复用策略概率和价值估计,用内存换 CPU。 一次移植审查还发现旧缓存键漏了“上一手”平面,导致同盘不同上一手错误碰撞;修复后旧日志里的 50%–56% 命中率不再当作有效性能成绩。

阶段最终配置产物/作用
自对弈80 局 / 48 线程 / 每步 600 次 MCTS 模拟新增轨迹与 (s,π,z)
训练200 次梯度更新 × 每批 128 状态AdamW 更新候选模型
快速探针vs 真随机 20 局防止训练完全退化
代际门禁每 5 轮候选 vs 当前冠军 20 局通过才覆盖冠军
保存每轮保存网络/优化器/轮数,每 10 轮保存经验池进程挂掉可断点续训

断点文件(checkpoint)不只有权重:latest.net + latest.opt + latest.state + buffer.bin。 网络/Adam/轮数每轮保存,Replay Buffer 每 10 轮保存,可恢复到最近一次耐久状态; 随机数发生器没有序列化,因此不是逐 bit 复现。训练器之外还有保活脚本(watchdog)与平台期监控器(plateau monitor)判定真正瓶颈。

门禁比“loss 下降”更重要

自对弈分布一直变,损失下降不保证对所有风格都更强。项目保存每 5 轮快照,并用旧站七种考官做全谱测评;代际相性曾出现“这代打穿宗师(MCTS+NN),下一代却输大师(小型神经网络+启发式)”的剧烈振荡。

一轮训练(iteration)的真实顺序

训练器不是“边下边改网络”。同一轮 80 局自对弈必须使用同一份固定权重,否则同一个缓存条目和树价值会在半局中失效。 一轮完整顺序如下:

顺序动作权重是否变化日志 phase
1扫描 20 万经验池,生成难例集合hard_set
248 个线程合计完成 80 局selfplay_done
3抽小批量样本做 200 次 AdamW 更新train_done
4保存候选网络/优化器/轮数固定快照文件
5每 5 轮测随机对手 + 候选对冠军门禁后可能覆盖冠军gate
6每 10 轮落盘经验池文件

48 个并行线程为什么每人一份网络

项目底层算子带训练缓存,全局线程池也不适合多个外层游戏并发重入。因此每个自对弈线程都初始化一份 PolicyValueResNet,再从主网络复制权重和 BN 的运行均值/方差,内部 thread_num=1。 外层 48 局并行比“一张网络内部开 48 线程、游戏串行”吞吐更高,也隔离了前向临时状态。

同轮还共享一个 64 分片局面缓存(EvalCache)。不同对局开局常走到相同局面,命中时连网络前向都省掉。 但缓存只能在权重固定期间使用;一轮训练更新后必须整体丢弃。后来审查还发现缓存键必须包含 行棋方 + 上一手位置 + 225 格棋盘,少任何输入平面都会产生“假命中”。

断点文件能恢复什么,不能恢复什么

所以宕机后不会丢模型和优化器,但最多损失 9 轮尚未落盘的经验池新样本;随机数发生器(RNG)状态也未保存, 恢复后不会逐 bit 复现原轨迹。这叫恢复到“最近耐久状态”,不是时间倒流。

为什么还要保存每 5 轮快照

冠军门禁只回答“候选模型能否打赢当前冠军”,并不保证它面对所有外部风格都更好。 训练中真的出现过某代打穿“宗师(MCTS+NN)”、下一代却被“大师(小型神经网络+启发式)”零封的情况。 因此 iter5.net / iter10.net / ... 这些定期快照都被保留,最终用七种对手风格全谱扫分挑综合冠军, 而不是盲信刚训练出的候选模型或单链冠军。

书中名称旧站内部编号主要机制用来检查什么
基础L1基础棋形启发式能否识别直接连线与简单防守
防御L2偏防守启发式能否打穿保守堵点策略
进攻L3偏进攻启发式能否挡住快速冲三/冲四
专家L4depth-2 minimax应对浅层确定性搜索
大师L5小型 MLP + 启发式应对网络先验与棋形混合风格
老师L6depth-3 αβ + 强评估应对宽搜索和强战术估值
宗师L7200-sim MCTS + NN prior + 强叶子评估应对搜索与网络混合对手

L1–L7 只保留在这张映射表里,方便和原始评测日志对照;正文后续统一用名称。

8. 真实训练曲线:一次完整训练怎样走上正轨

下图是结构化日志里的完整策略损失(policy loss)原始点,黄/粉线是 5/15 轮滑动均值,绿色竖线是当前冠军晋级。 这条曲线不是修饰过的示意图:每一点对应一轮完整训练。早期配方是 40 局 + 80 次优化更新, 后期稳定配方才是 80 局 + 200 次更新;随机初始化时 225 个动作近似均匀,理论交叉熵 ln(225)≈5.42,第一条完训日志是 5.2797。

AlphaZero 五子棋截至 iter440 的 policy/value loss 曲线与拟合

前期机制修复带来阶梯式下降;后期缓慢逼近平台。模型档案保留 CSV 原始点与拟合 JSON,可自行重画。

阶段关键变化结果
冷启动阶段全盘动作/符号/首次访问估计问题棋力不涨,策略损失在高位爬行
搜索修复阶段候选点半径2π 目标恢复有效,loss 阶梯式下降
搜索加深阶段每步模拟 200→500,开局采样窗口 12→6,探索系数 0.8搜索看见反杀,开始会防守
长尾强化阶段难例重采样 / 近期偏置 / 残局做种逐步填补必防局面
正式验收阶段固定快照 + 全谱大样本对战7 档全部达到验收标准

为什么训练规模必须是“万局级”

主线不是几百盘速成:iter440 收口时累计33,920盘去重自对弈(含重启实际35,000盘)。 每盘会留下几十个状态,所以 20 万容量的 Replay Buffer 会装满并持续环形覆盖。 关键不是死记一个总盘数,而是理解为什么要不断刷新数据分布:网络变强后会走进旧网络从未见过的新局面, 新局面又必须重新搜索、重新产生 π 和 z。

训练阶段机制变化可观察结果
机制排错候选剪枝、终局符号、首次访问估计必杀单测恢复,损失开始有效下降
基础棋力形成提高搜索模拟数、缩短开局采样窗口平均局长缩短,开始稳定战胜随机
防守长尾难例重采样 / 近期偏置对手三四连状态价值逐渐转负
课程强化从历史必防残局继续自对弈开始击穿基础/进攻型考官
全谱选模保存快照并对七种对手风格统一测评识别代际相性,选出综合冠军
正式验收高搜索预算、大样本对战所有档位达到预设标准

大网络为什么反而不划算

方案参数量48 路聚合前向一次冷启动 iteration结论
32 通道 × 4 残差块19.2 万约 884 次评估/秒约 10–50 分钟(配方随阶段变化)采用
64 通道 × 6 残差块56.3 万约 189 次评估/秒共享机器下约 75–90 分钟放弃

参数只增到 2.9 倍,吞吐却降到约 1/4.7;而 AlphaZero 还要把前向放进数百次 MCTS 模拟中。 CPU 预算固定时,小网络高频迭代、更多自对弈和更深搜索,比“大网络但一天只进化几代”更划算。

搜索预算存在明显棋力阈值

固定发布冠军、只测最强的宗师对手(MCTS+小型神经网络)、模型执白 20 局, 仅改变每步 MCTS 模拟次数,得到一个非常尖锐的结果:

MCTS 模拟次数模型执白战绩胜率解释
240胜 / 20负0%试玩速度快,战术深度不足
480胜 / 20负0%仍未跨过关键搜索深度
9620胜 / 0负100%跨过该对手的战术阈值

这不是说“96 永远比 48 强一倍”,而是某些杀法必须搜到足够深才能显现。网页 12/24/48 是交互档, 96/120 是高预算档;正式验收仍用每步 600–800 次模拟。模型权重没变,搜索预算就能让结果从全败跳到全胜。

长期训练怎样避免“凭感觉停机”

自动平台方案把停机条件写成三条硬规则:

  1. 连续至少 30 个完整训练轮没有当前冠军晋级;
  2. 最近 15 轮策略损失的 5 轮滑动均值不再实质下降,整体斜率接近 0;
  3. 同一冻结候选连续 3 个全谱窗口,对基础/防御/进攻/专家/大师/老师/宗师七种对手,黑棋都 10/10、白棋都至少 8/10。

这三条把“模型似乎已经不错”拆成可检查条件:代际不再进步、拟合曲线不再下降、外部对手也稳定过线。 实际主线由用户在 iter440 基于实测收益人工收口:iter415 对 iter360 在48 sims 下23:27、600 sims下25:25;iter440 gate 10:10,policy/value loss 为 2.6492/1.1161,后期下降没有转化成稳定棋力提升。完整理由与逐轮记录见模型仓 training/TRAINING_NOTES.md

9. 七个真坑:为什么“能运行”离“能学会”很远

  1. 动作数量远大于模拟次数:217 个合法点分 100 次模拟,多数边 0 次访问,π 变噪声。候选点剪枝是训练能成立的前提。
  2. 终局符号错一层:价值按待行方视角,回传逐层翻转;少翻/多翻一次会把必胜教成必败。
  3. 首次访问估计(FPU)过度悲观:早期 Q 接近平坦,访问过的第一条边永久领先,搜索锁死。
  4. 假随机考官:均匀评估器再走 MCTS 时并不随机,并列取最大值会固定挑左上;必须直接随机合法手。
  5. 批归一化运行统计漏复制:并行线程只有可训练参数,推理均值/方差仍是初始值,所有评估都会漂。
  6. 缓存键漏上一手:输入明明包含上一手平面,缓存只看棋盘会把不同输入当成同一状态;浏览器移植的逐值对齐审查才把它揪出来。
  7. 只看刚训练出的候选模型:不同代际对手相性剧烈摆动;最终必须冻结快照对七种风格做全谱扫分。

项目当前有 4509 项自动检查,覆盖规则、满盘和棋、编码、上一手 cache key、8 对称、MCTS 必杀与访问分布、严格树预算、崩溃安全 checkpoint、教师策略不控制行为、policy-only 冻结与优化器恢复。真正耗时的不是写类,而是证明每个约定都没暗中反号或退化。

表面症状容易误判成最终证据
候选过宽损失在 5.2 附近爬行、必杀偶尔漏掉网络太小候选剪枝后同网损失立刻阶梯下降
终局符号活四局面 value 读成 −0.83价值头没学会统一待行方视角后必杀单测 4/4
FPU 锁死第一条边长期吃满访问策略先验过尖FPU=0 后访问分布恢复展开
假随机模型莫名其妙对随机 0:20训练完全失败真实随机评估立即变 19:1/20:0
BN 漏复制并行副本输出与主网络漂移并发浮点误差复制运行统计后逐值一致
缓存漏上一手相同棋盘偶发错误复用缓存只是加速,不影响结果两种上一手平面的原始打分最大差 0.781
代际相性这一代赢宗师、下一代输大师评估样本太少多快照大样本全谱确认风格振荡
调 RL 先证明评估器是真的

强化学习最危险的不是报错,而是程序照跑、指标也有数、但含义已经退化。 假随机、训练分布外(OOD)静态探针、CPU 拥塞超时都曾伪装成“模型很弱”。先用规则单测、必杀局面、真实随机、逐值对齐 证明测量工具可信,再根据指标改算法。

10. 从 AlphaZero 到 AlphaGo Zero

本项目和 AlphaGo Zero 的核心闭环一致:统一策略价值网络 + MCTS + 纯自对弈 + 新旧门禁。 差距主要是工程规模:19 万参数/CPU/15×15 对比数百万到数亿参数/TPU/19×19,以及搜索批处理、异步 actor-learner、树复用、推理服务等基础设施。

关键不是“把模型做大”

64ch×6block 的 56 万参数实验每轮慢 4 倍、总体进化反而更慢。CPU 预算下,小网络高频迭代 + 更深 MCTS + 正确数据闭环比盲目堆参数更强。

相同的是算法闭环,不同的是工程规模

维度本项目AlphaGo Zero / AlphaZero 级系统
棋盘/规则15×15 自由五子棋19×19 围棋 / 象棋 / 将棋
网络32 通道×4 残差块,19.2 万参数更宽更深的残差网络
设备单机多核 CPUTPU/GPU 集群
自对弈48 线程、80 局/轮大量异步对局进程持续产棋
推理每线程单样本前向集中批推理服务,合并叶子请求
搜索树严格有界子树复用,触顶 fresh 重建跨请求复用、批量扩展与集中推理
训练/自对弈一轮一轮同步交替对局生产者与训练器异步流水线
课程手写三/四连检测重采样原论文更接近仅规则自对弈

因此本项目不是在硬件规模上“复刻 AlphaGo”,而是把最关键的可验证闭环压进普通 CPU: 搜索改进策略、终局监督价值、网络反哺搜索、门禁控制代际。网页端更进一步,直接读取同一份 C++ 权重, 证明它不是后端接口包装出来的假 demo。

小结

  • AlphaZero 不需要逐步标签:MCTS 给 π,终局给 z,网络再反过来指导下一轮搜索。
  • 策略价值网络共享 ResNet trunk,两个头分别输出 225 落子 logits 与待行方价值。
  • PUCT 用 Q 利用已验证路线、用 P 探索网络看好的新路线;价值回传每层翻转视角。
  • Replay Buffer + 8 对称 + recency + hard mining 提高样本效率;残局做种仍不引入教师答案。
  • 代际门禁、版本化 checkpoint 三元组、保活和结构化日志让长训练可控。
  • 浏览器直接读取 C++ 权重并本地前向/MCTS;有界子树复用减少连续落子的重复搜索。
  • 这次真实训练证明:算法约定、评估口径和数据闭环,常比参数量更决定成败。

动手与思考

问题 1:为什么策略目标用 MCTS 访问分布 π,而不是最终实际落子 one-hot?

π 包含搜索对多个候选的相对判断,信息比单个动作丰富;训练网络拟合 π,等于把昂贵搜索结果蒸馏回一次前向。

问题 2:价值 z 为什么必须按“当时待行方”视角记录?

同一终局对黑白意义相反。状态编码也以待行方为己方;z 若不跟视角统一,同一种输入语义会同时收到正负冲突标签。

问题 3:候选点半径2是不是手写棋力?

不是。它不判断哪步更好,只排除离所有棋子很远、当前几乎不可能影响局面的点;候选内的先验与价值全部来自网络/MCTS。

问题 4:为什么训练要 Dirichlet 噪声,比赛却关闭?

训练要主动发现没走过的分支,否则数据分布会自我固化;比赛要发挥当前已学最强策略,随机噪声只会送出烂棋。

问题 5:为什么 loss 下降不能代替门禁对打?

loss 衡量对当前 replay 分布的拟合,而自对弈分布随策略变化;某代可能 loss 更低却忘了特定对手棋形。实战胜负才是最终指标。

问题 6:hard mining 为什么不算教师标签,又为什么不能说成“只给规则”?

它只从自己历史败局里多抽危险状态,答案 π/z 仍由新 MCTS 与终局自动产生,没有人类或旧 AI 告诉它正确挡点;但手写三/四连检测器决定了哪些状态多复习,所以课程采样确实加入了领域先验。

问题 7:浏览器版为什么能直接读 C++ .net?

序列化格式固定:64字节头 + 每个向量的 uint64 长度 + little-endian Float32。JS DataView 按同序读取,再按 C++ 张量布局前向。

问题 8:若算力翻倍,先加参数还是加 MCTS sims?

要测瓶颈。当前 56 万参数实验吞吐降约4倍,不划算;小网仍能继续降 loss,优先提高搜索质量/数据迭代频率更有效。只有小网稳定封顶才扩容。

主线实战收束 · 你已经跑通了

你从一个神经元出发,在三份真实实战中完成了监督学习(MNIST)、生成模型(mini-LM)和强化学习(AlphaZero)。最后这一步不只是“调用 AI”,而是让一个网络从规则出发,通过搜索和自对弈把自己一代代变强。

接下来最有价值的学习不是再抄一遍代码,而是改 sims、c_puct、网络宽度、候选点和门禁口径,用真实曲线和对局证明你的判断。

继续 · 机器学习全景图