第 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 / sims | MCTS 模拟次数 | 一次落子前从根反复搜索多少条路径 |
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。
页面不绑定任何版本文件名,只读固定的 channels/stable.json;
模型仓根据完整同预算循环、双颜色外部对手谱和高预算回归门禁晋升生产模型。
fast 与 deep 通道另行保留专项模型,历史权重均可回滚。
因此以后换模型不需要再修改本章或 game-old。
本章精读的不是框架仓里不存在的伪代码。完整 C++ 源码在独立仓库
chenxuan520/alphazero-gomoku:
git clone https://github.com/chenxuan520/alphazero-gomoku →
cd alphazero-gomoku →
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release →
cmake --build build -j → ./bin/test_az。
规则、ResNet、MCTS、自对弈、训练器、门禁、4509 项测试和 JS/C++ parity probe 都在。
网络不会直接“拍脑袋落子”:它先给搜索方向和局面价值,树搜索再验证后果。
2. 环境:15×15 棋盘、胜负与 4 平面编码
环境是 src/game/gomoku.*。动作 a 是 0–224 的行优先格子编号;
黑棋记 +1、白棋记 −1、空位 0。自由规则下横/竖/两条斜线任一方向五连或长连即胜,没有禁手。
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
CheckWin从刚落下的棋子向两边数 4 个方向,累计 ≥5 即胜。- 未终局才交换行棋方;这点决定 MCTS 终局价值的符号约定。
网络始终站在当前该走棋的一方看棋盘,因此同一个绝对棋盘轮到黑/白时输入不同:
| 平面 | 形状 | 含义 | 为什么需要 |
|---|---|---|---|
| 0 | 15×15 | 当前行棋方的棋子 | 统一黑白视角,同一网络两色共用 |
| 1 | 15×15 | 对方棋子 | 识别威胁与包围关系 |
| 2 | 15×15 | 上一手 one-hot | 标记刚发生的局部变化 |
| 3 | 15×15 | 黑走全 1,白走全 0 | 保留先后手信息 |
方形棋盘有 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 组件:BatchedConv2D、BatchNorm2D、
ResidualBlock2D、FloatLinear。没有 PyTorch/TensorFlow/ONNX。
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
- 4 个残差块共享棋形特征;残差连接让深层网络更好训练(第 13 章)。
- 策略头输出 225 个 raw logits,经合法手 mask + softmax 后得到先验 P(s,a)。
- 价值头输出当前待行方的预期胜负:赢接近 +1,输接近 −1,和棋接近 0。
| 部分 | 真实配置 | 作用 |
|---|---|---|
| Stem | 4→32,3×3 | 把棋盘平面提成局部棋形特征 |
| Trunk | 4 blocks,32ch | 反复组合进攻/防守棋形 |
| Policy | 2ch→450→225 | 给每个落点先验 |
| Value | 1ch→225→64→1 | 评估局面最终胜负 |
| 总参数 | 191,853 | 冠军权重约 770KB |
191,853 个参数是怎么算出来的
“19 万参数”不是看文件大小猜的。每个卷积权重按 [Cout,Cin,Kh,Kw],
每个全连接按 [out,in];BN 的可训练量是每通道一组 γ/β。逐块展开:
| 部分 | 计算 | 序列化参数 |
|---|---|---|
| Stem | 32×4×3×3 + bias32 + BN(32×2) | 1,248 |
| 单个残差块 | 2×[32×32×3×3 + bias32 + BN64] | 18,624 |
| 4 个残差块 | 18,624×4 | 74,496 |
| Policy head | 1×1 Conv/BN 70 + FC(450×225+225) | 101,545 |
| Value head | 1×1 Conv/BN 35 + FC(225×64+64) + FC(64×1+1) | 14,564 |
| 合计 | 1,248+74,496+101,545+14,564 | 191,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 改写成一次仿射:
这也是为什么复制网络时只复制“可训练参数”不够:BN running statistics 不是梯度参数,却直接决定推理输出。 漏复制它们时,worker 会拿 mean=0/variance=1 做归一化,整批自对弈标签都可能漂掉。
4. MCTS:PUCT 如何把“直觉”变成“算过”
每个树节点是一个局面,每条边是一个候选落子。搜索反复执行选择 → 扩展/评估 → 回传。 选择时不只看当前平均胜率 Q,还给网络先验高但尚未充分搜索的动作一个探索奖励:
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;
}
- 未访问边还没有自己的 Q,先借父节点价值作 FPU(First Play Urgency,首次访问估计);本项目最终设
fpu=0,避免早期价值平坦时把新分支压得过低、锁死探索。 - 每向上一层就换行棋方,价值符号必须翻转。这曾是项目里最致命的 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 | 一次网络前向 |
| 回传 | 叶子价值 v | N+=1,W+=±v,Q=W/N | 路径长度 |
搜 600 次不等于“往前看 600 手”,而是生成 600 条由当前 PUCT 策略选择的路径。树会把预算集中在少数有希望的分支; 根某条边的 N 越大,说明它在反复搜索验证后仍值得投入。
为什么 217 个合法点会让 100 sims 隐性报废
空盘附近如果直接展开全部空格,中前盘可能有 200 多条边。100 次模拟意味着至少一半边永远 N=0, 根访问分布 π 不是“深思熟虑后的策略”,而只是“哪些边碰巧被访问过”。更糟的是,一手必杀也可能根本没展开。
半径 2 剪枝只问“这个空点附近有没有棋子”,不问它是活三、冲四还是好棋;因此没有把手写棋力灌进网络。 但它确实编码了一个五子棋工程先验:离所有棋子很远的点短期不会形成连线。动作生成是这类 CPU 版 AlphaZero 的命脉。
根访问次数为什么能当训练目标
网络原始策略 P 只经历一次前向;访问分布 π 则融合了多次落子、叶子价值和终局反杀。 训练网络逼近 π,相当于把一次昂贵搜索“蒸馏”进下一代网络。下一轮搜索从更好的 P 出发,又能得到更好的 π—— 这就是“网络教搜索,搜索再教网络”的具体落点。
5. 自对弈怎样自动产生 (s,π,z)
监督学习需要人给“正确走法”;AlphaZero 让 MCTS 自己产标签。每一步先搜索,把根节点访问次数归一化成 π; 棋局结束后,把最终胜负从当时待行方视角写回每个状态成为 z。
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
- 输入状态在落子前编码,永远是当前待行方视角。
- π 不是网络原始 softmax,而是搜索后的访问分布——搜索在教网络。
- z 来自对局结果:真实终局胜 +1、负 −1、和 0;若达到 200 手上限仍未终局,工程上截断并按和棋 z=0 记录。没有人工胜负标签。
一盘棋结束后,怎样把结果回填给几十个旧状态
自对弈过程中先暂存每一步的输入、π 和“当时待行方”。终局才知道 winner,于是倒回整条轨迹: 若样本当时轮到最终胜方,z=+1;轮到败方,z=−1;和棋或 200 手截断,z=0。 同一盘黑胜棋里,黑走状态和白走状态的 z 会正负交替——这正是当前行棋方视角的意义。
五子棋棋盘有 225 格,理论上可以继续;项目为控制弱网早期的超长乱战,在 200 手截断并标 z=0。 这是工程近似,不是棋规。模型变强后平均局长降到几十手,截断占比自然下降。
6. Replay Buffer、增广与策略价值损失
Buffer 是固定容量环形队列,样本为 4×225 输入 + 225 维 π + 一个 z。 最终配置保留 20 万状态:60% 按近期指数偏置采样,40% 全局均匀,既跟上新策略又不忘旧局面。
五子棋的难点不是常见开局,而是“对手已经形成三/四连,只剩唯一挡点”的长尾。项目没有去问旧 AI 正确答案,而是重组自己的历史经验:
- Hard-negative mining:对手已有四连,或败局中对手已有三连的状态进入 hard set;每个 batch 约 30% 从中采样。
- 残局做种:约 30% 自对弈从 replay 的必防状态继续,但 π 和 z 仍由新一轮 MCTS/终局自己产生。
- 8 对称:每次随机旋转/镜像,同步变换棋盘与 π。
所有状态、π 与 z 都来自模型自己的历史自对弈;没有人类棋谱、没有旧站 AI 落子、没有手写棋形分数。但 hard set 用手写“三/四连检测器”挑哪些错题多复习,属于领域特定 curriculum,不是对 AlphaGo Zero“只给规则”配方的逐字复刻。
一条 Sample 占多少内存
Sample 是定长结构:4×225 个输入 float、225 个 π float、1 个 z float,合计 1126 个 float32:
一盘自对弈平均会留下几十个状态,因此累计到万局级时,20 万容量很快装满。
装满后 write_pos 环形覆盖最老样本,内存和文件大小保持不再增长;训练规模继续增加时,
变化的是池中经验的新旧分布,不是文件无限膨胀。
60% 新经验 + 40% 全历史
策略每轮都变,十几小时前冷网产生的数据可能已经过时;若只均匀抽样,大量旧错误会稀释新信号。
SampleIndicesRecency 以 60% 概率按指数年龄抽近期样本(平均 age≈8000),其余 40% 均匀抽全池:
前者让网络跟上当前策略,后者保留旧棋形覆盖,避免只记得最近打法。
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
}
- 先混合近期/全历史。
- 再把 batch 尾部约 30% 替换成必防长尾。
- 最后对输入和 π 做同一个旋转/镜像,保证标签不变形。
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)判定真正瓶颈。
自对弈分布一直变,损失下降不保证对所有风格都更强。项目保存每 5 轮快照,并用旧站七种考官做全谱测评;代际相性曾出现“这代打穿宗师(MCTS+NN),下一代却输大师(小型神经网络+启发式)”的剧烈振荡。
一轮训练(iteration)的真实顺序
训练器不是“边下边改网络”。同一轮 80 局自对弈必须使用同一份固定权重,否则同一个缓存条目和树价值会在半局中失效。 一轮完整顺序如下:
| 顺序 | 动作 | 权重是否变化 | 日志 phase |
|---|---|---|---|
| 1 | 扫描 20 万经验池,生成难例集合 | 否 | hard_set |
| 2 | 48 个线程合计完成 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 格棋盘,少任何输入平面都会产生“假命中”。
断点文件能恢复什么,不能恢复什么
latest.net:网络结构、所有权重、BN running mean/variance。latest.opt:AdamW 的 step、第一矩、第二矩。latest.state:iteration 与 global step。buffer.bin:ring buffer 的 size、write_pos 和样本;每 10 轮保存一次。
所以宕机后不会丢模型和优化器,但最多损失 9 轮尚未落盘的经验池新样本;随机数发生器(RNG)状态也未保存, 恢复后不会逐 bit 复现原轨迹。这叫恢复到“最近耐久状态”,不是时间倒流。
为什么还要保存每 5 轮快照
冠军门禁只回答“候选模型能否打赢当前冠军”,并不保证它面对所有外部风格都更好。
训练中真的出现过某代打穿“宗师(MCTS+NN)”、下一代却被“大师(小型神经网络+启发式)”零封的情况。
因此 iter5.net / iter10.net / ... 这些定期快照都被保留,最终用七种对手风格全谱扫分挑综合冠军,
而不是盲信刚训练出的候选模型或单链冠军。
| 书中名称 | 旧站内部编号 | 主要机制 | 用来检查什么 |
|---|---|---|---|
| 基础 | L1 | 基础棋形启发式 | 能否识别直接连线与简单防守 |
| 防御 | L2 | 偏防守启发式 | 能否打穿保守堵点策略 |
| 进攻 | L3 | 偏进攻启发式 | 能否挡住快速冲三/冲四 |
| 专家 | L4 | depth-2 minimax | 应对浅层确定性搜索 |
| 大师 | L5 | 小型 MLP + 启发式 | 应对网络先验与棋形混合风格 |
| 老师 | L6 | depth-3 αβ + 强评估 | 应对宽搜索和强战术估值 |
| 宗师 | L7 | 200-sim MCTS + NN prior + 强叶子评估 | 应对搜索与网络混合对手 |
L1–L7 只保留在这张映射表里,方便和原始评测日志对照;正文后续统一用名称。
8. 真实训练曲线:一次完整训练怎样走上正轨
下图是结构化日志里的完整策略损失(policy loss)原始点,黄/粉线是 5/15 轮滑动均值,绿色竖线是当前冠军晋级。 这条曲线不是修饰过的示意图:每一点对应一轮完整训练。早期配方是 40 局 + 80 次优化更新, 后期稳定配方才是 80 局 + 200 次更新;随机初始化时 225 个动作近似均匀,理论交叉熵 ln(225)≈5.42,第一条完训日志是 5.2797。
前期机制修复带来阶梯式下降;后期缓慢逼近平台。模型档案保留 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 模拟次数 | 模型执白战绩 | 胜率 | 解释 |
|---|---|---|---|
| 24 | 0胜 / 20负 | 0% | 试玩速度快,战术深度不足 |
| 48 | 0胜 / 20负 | 0% | 仍未跨过关键搜索深度 |
| 96 | 20胜 / 0负 | 100% | 跨过该对手的战术阈值 |
这不是说“96 永远比 48 强一倍”,而是某些杀法必须搜到足够深才能显现。网页 12/24/48 是交互档, 96/120 是高预算档;正式验收仍用每步 600–800 次模拟。模型权重没变,搜索预算就能让结果从全败跳到全胜。
长期训练怎样避免“凭感觉停机”
自动平台方案把停机条件写成三条硬规则:
- 连续至少 30 个完整训练轮没有当前冠军晋级;
- 最近 15 轮策略损失的 5 轮滑动均值不再实质下降,整体斜率接近 0;
- 同一冻结候选连续 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. 七个真坑:为什么“能运行”离“能学会”很远
- 动作数量远大于模拟次数:217 个合法点分 100 次模拟,多数边 0 次访问,π 变噪声。候选点剪枝是训练能成立的前提。
- 终局符号错一层:价值按待行方视角,回传逐层翻转;少翻/多翻一次会把必胜教成必败。
- 首次访问估计(FPU)过度悲观:早期 Q 接近平坦,访问过的第一条边永久领先,搜索锁死。
- 假随机考官:均匀评估器再走 MCTS 时并不随机,并列取最大值会固定挑左上;必须直接随机合法手。
- 批归一化运行统计漏复制:并行线程只有可训练参数,推理均值/方差仍是初始值,所有评估都会漂。
- 缓存键漏上一手:输入明明包含上一手平面,缓存只看棋盘会把不同输入当成同一状态;浏览器移植的逐值对齐审查才把它揪出来。
- 只看刚训练出的候选模型:不同代际对手相性剧烈摆动;最终必须冻结快照对七种风格做全谱扫分。
项目当前有 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 |
| 代际相性 | 这一代赢宗师、下一代输大师 | 评估样本太少 | 多快照大样本全谱确认风格振荡 |
强化学习最危险的不是报错,而是程序照跑、指标也有数、但含义已经退化。 假随机、训练分布外(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 万参数 | 更宽更深的残差网络 |
| 设备 | 单机多核 CPU | TPU/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、网络宽度、候选点和门禁口径,用真实曲线和对局证明你的判断。