低 sims 下棋类游戏强化学习如何逃出双盲期

低 sims 下棋类游戏强化学习如何逃出双盲期

本文以一个五子棋 CNN 自对弈项目的真实调参经历为案例。 项目采用 30 sims 低算力管线(参考项目: https://github.com/zhoukangyang233/Gomoku),开局就撞上典型的"双盲期": 模型不会防守、对局极短、胜率看似暴涨实则空转——这个均衡持续了 约 1200 轮才被打破,随后约百轮模型达到中等 AI 强度 (完整实测时间线见 §4.7)。 本文记录双盲期的定义、成因、诊断指标,以及逃出它的全部已知手段。


1. 什么是双盲期

定义:自对弈强化学习的冷启动阶段——对局双方(同一模型的自我复制) 策略都弱到数据分布中不含任何有意义的对抗行为(防守、战术、终局 经营),模型从这样的数据里学不到进步方向,形成自我强化的低水平均衡。

双盲期的"双"指对局双方同时失明:

text
复制代码
模型弱 → 搜索树里没有战术信号(双方都不堵、不攻) → 产生的训练数据里没有"防守收益"的正例 → 模型学不会防守 → 模型依然弱

现象特征(run_1 双盲期实测)

特征实测值说明
对局极短自对弈 ~3.5 s/100 局(C++ MCTS + 4 worker),折合每局仅 ~10 手搜索双方都不堵,谁先随手连五谁赢
胜率暴涨假象arena 对随机 base 99:1对手是纯随机的 base 模型,赢它不需要会下棋
value 在学、policy 原地val value 0.088→0.029,val policy 0.088→0.14(恶化)终局胜负信号先教会 value 头;policy 无信号可学
人机试玩不会防守玩家做活三,AI 视而不见policy 头不认为堵截重要

关键区分:双盲期 ≠ 训练失败。value 头在进步(终局 z 信号永远存在), 它是逃逸的种子;但 policy 头的行为改进要等数据分布先改变——这就是 为什么双盲期只能"熬 + 疏导",不能"调一两个参数立刻解决"。 run_1 的双盲均衡实际延续到 r1100 前后才被打破(§4.7 时间线), "熬"的时长要有心理预期。


2. 明知有双盲期,为什么还选低 sims

2.1 算力经济学:单轮标签精度 vs 总轮次

自对弈训练的棋力来源可以分解为:

text
复制代码
总学习信号量 ≈ 总轮次 × 每轮数据量 × 每样本信息密度

高 sims 与低 sims 在训练前期生成的数据质量差别很大:前者 预算充足,访问分布和树内 Q 都有分辨力,即使处于双盲期,标签里 也有可学的东西;后者每个子节点只摊到寥寥几次访问,前期产生的 标签基本是噪声。低 sims 方案的全部设计,就是在接受这个前期 数据质量劣势的前提下,用标签生成机制补回信息密度(§4.5), 用总轮次补回学习信号总量。

2.2 标签质量不来自 sims 数,而来自标签生成机制

低 sims 的核心赌注:标签质量靠"层次重分配"机制保证,而不是靠堆 sims。30 sims 的原始访问分布确实是噪声(每个子节点 1-3 次访问, 分辨不出好坏),但层次重分配把树内 Q 的对比信号注入 policy 标签, 让低 visits 数据变得可用(详见 §4.5)。

2.3 三支柱缺一不可

参考实现的配方是三件事绑定:

  1. 30 sims(低算力 → 高轮次)
  2. 全节点收集 + 层次重分配标签(低 visits 数据可用)
  3. 小网络 32ch/5blk(容量匹配噪声标签;128ch 大网络会在标签 噪声上过拟合,且训练段计算量 19 倍于小网络)

只搬其中两根支柱(例如用大网络配 30 sims)会得到最差组合:本项目 低 sims 运行首次启动时单轮 8 分钟(大网络吃不动 28 万全收样本), 改成 32ch 后实测全程平均约 22 秒/轮(自对弈 ~6 秒 + 训练 ~15 秒), 1380 轮共 8.4 小时,逃逸点 r1200 折合不到 8 小时算力。

2.4 代价要有预期

低 sims 的代价就是双盲期更长:搜索看不到战术序列(30 sims 树深 ~3 层且每节点 1-3 visits 无分辨力),战术能力只能等网络从数据里 "背会"棋形——这需要海量对局。这是设计内风险,不是 bug。


3. 双盲期的量化诊断

不要靠肉眼感觉"模型好弱",用这三个指标:

3.1 对局长度(最灵敏的涌现指标)

防守涌现 = 双方开始互堵 = 对局拉锯变长。自对弈耗时/轮会随防守 涌现而变长——耗时上升不是坏事,是行为改变的信号。

run_1 实测(前置条件:C++ MCTS 后端 + 4 worker;换纯 Python 后端 单步耗时会成倍变慢,以下绝对数值不可直接对比):双盲期自对弈 ~3.3-3.6 s/100 局,逃逸后台阶式上升到稳定 ~7-8 s/100 局不再回落 (对局从随手连五变成互堵拉锯)。注意 r400-600 也出现过一次耗时冲高(~9.6 s)随后回落——平台期的单次 冲高不算数,台阶式上升且不回落才是涌现。

3.2 arena 对 base 的胜率曲线

对随机 base 99% 是假进步(随机对手不设防)。有意义的信号是 对 champion 的胜率能否持续区分:如果每次 arena 都在 50-60% 震荡且晋升不断,说明模型在代际进步;连续多轮 45-50% 停滞则警惕。

3.3 value/policy 的分离演化

双盲期标志性形态:value loss 下降 + policy loss 停滞或恶化。 value 头从终局信号学(有监督),policy 头从访问分布学(无信号)。 这条分离曲线是双盲期的指纹——value 降得动说明管线正常。

但run_1 有个意外发现:val policy loss 全程没有"掉头向下" (0.088 起步,r1379 反而 0.12),模型照样逃逸了。原因:层次重分配 标签是移动靶——value 头变准 → 树内 Q 变准 → policy 标签分布 本身在变锐,KL 对一个越来越难的目标不降反升。所以低 sims + 重分配 管线里,policy loss 不能当逃逸判据;逃逸确认要靠 §3.1 的耗时台阶、 §3.2 的 arena 持续区分和人机实测。


4. 逃出双盲期的条件

以下手段按"重要性 × 容易被忽视"排序。前三个是数据多样性来源, 后三个是标签信号来源。

4.1 随机开局——数据覆盖的中盘注入

纯空盘自对弈的问题:开局访问分布几乎纯先验(网络没见过空盘), 30 sims 下 policy 标签退化成"先验 top-k 摊平",且所有对局开局段 高度相似(数据冗余)。随机预落子把对局起点直接推进到中盘,局面 结构丰富,重分配标签的信息密度高得多。

两个关键参数:

python
复制代码
# 配置文件 opening_max_moves = 20 # 预落子手数上限(均匀随机 0~20) opening_empty_prob = 0.3 # 30% 概率纯空盘开局(保开局覆盖)

生成方式要点:每个候选开局独立采样 + 用网络估值筛选"最均势" 局面(避免一开局就是必胜/必败局,那种对局的 value 标签没有 区分度):

python
复制代码
# 工具模块(节选,真实实现) def generate_opening_boards(model, num_boards, ...): """随机预落子生成开局候选,批量估值后挑最均势的。""" candidates = [] for _ in range(num_boards): # 每个候选独立采样—— board = board_mod.new_board() # 千万不要共享同一个排列! num_moves = random.randint(0, Config.opening_max_moves) empty = [(i, j) for i in range(bs) for j in range(bs)] moves = random.sample(empty, num_moves) color = 1 for r, c in moves: board[r][c] = color color = -color candidates.append(board) # 批量估值,挑 |value| 最小(最均势)的且未终局的 values = evaluate_batch(model, candidates) picked = [b for b, v in zip(candidates, values) if evaluation_status(b) == 0] # 跳过已终局局面 picked.sort(key=lambda b: abs(value_of(b))) # 越均势越靠前 return picked[:num_boards]

反面教材(真实踩坑):参考实现的开局生成把 shuffle 放在循环外、 所有候选共享同一排列前缀——1000 个候选实际只有 ~11 种不同局面。 它靠无数轮硬堆掩盖了这个 bug,新实现不要模仿。

4.2 温度调度——前期的行为多样性

温度控制落子采样对访问分布的"锐化"程度。双盲期的访问分布本身 就是摊平的(低 sims 无分辨力),高温采样 = 按先验近似均匀探索, 让"更好的一手"有机会被随机执行并赢下对局——数据里才会出现 进步的正例。

python
复制代码
# 配置文件 temp_high_moves = 12 # 前 12 手高温(开局+早中盘探索) temp_high = 1.0 # 按访问分布比例采样 temp_low = 0.2 # 之后低温(接近贪心,保证对局质量)
python
复制代码
# 落子采样:温度 0 = 贪心;温度 >0 = 按 probs^(1/T) 重加权采样 def calc_next_move(board, probs, temperature=0): valid = [(i, j, probs[i][j]) for i in range(bs) for j in range(bs) if board[i][j] == 0] if temperature == 0: return max(valid, key=lambda x: x[2])[:2] moves = [(i, j) for i, j, _ in valid] p = np.array([p for _, _, p in valid], dtype=np.float64) p = p ** (1.0 / temperature) p = p / p.sum() return moves[np.random.choice(len(moves), p=p)]

参考实现用另一种思路——每局随机温度(0~0.8 均匀),让整个 数据集覆盖从贪心到乱下的行为谱。两种都行,固定分段更可控, 逐局随机更多样。

4.3 根节点 Dirichlet 噪声——搜索内的先验探索

MCTS 根节点的先验混入 Dirichlet 噪声,保证低 visits 阶段搜索 不会完全被先验锁死:

python
复制代码
# 配置文件 dirichlet_alpha = 0.1 # α 越小噪声越集中(少数着法拿到大噪声) noise_eps = 0.25 # 根先验 = 0.75*prior + 0.25*noise
python
复制代码
# MCTS 根节点初始化(概念实现) noise = np.random.dirichlet([alpha] * len(children)) for i, child in enumerate(children): child.prior = (1 - eps) * child.prior + eps * noise[i]

参考实现用了更彻底的思路——每个展开节点的先验都叠加小幅 高斯噪声(归一化 policy + N(0, 0.01) 抖动),而非只在根节点注入。 两者目的一致:防止搜索被先验锁死。根节点方案更干净(树内先验 与 Q 不被扰动),全节点方案探索更散、实现更简单。

低 sims 下的特殊注意:30 sims 摊在 20 个子节点上,每个 1-3 visits, visits 噪声淹没 prior 差异——高温采样的行为近似按先验随机。 这不是缺陷而是特性:它保证了双盲期的行为多样性。真正的问题是 §4.5 要解决的"标签信号从哪来"。

4.4 PUCT 探索常数

c_puct 控制"先验偏好 vs 访问价值"的平衡。双盲期 Q 全是噪声, PUCT 退化为按先验分配 visits——这是正常的,不要在双盲期去调 c_puct(没有任何可靠信号告诉你怎么调)。等 arena 开始有区分度 后再考虑微调。

python
复制代码
# 节点选择:PUCT = Q + c_puct * prior * sqrt(父visits) / (1 + visits) score = child.q + c_puct * child.prior * math.sqrt(parent.visits) / (1 + child.visits)

4.5 层次重分配标签——低 sims 可行的核心机制

这是整个低 sims 方案的支点。30 sims 的原始访问分布是噪声,直接 当 policy 标签等于教模型学噪声。层次重分配的做法:用子节点的 Q 值对比纠正先验的盲区,把访问概率重新分配给"树认为更好"的 分支

python
复制代码
# 层次重分配(节选简化) def reassign_policy(mcts_root_state, prior, ...): """prior + Q 层次重分配:纠正 prior 盲区。 原始访问分布 visits=1~3 无分辨力;但每个子节点下方的树内 Q 是连续信号——"这个分支背后藏着好局面"能被 Q 读出来。 """ children = mcts_root_state.children if not children: return prior # 1. 每个孩子的"修正分数" = Q 归一化后与先验混合 q = np.array([children[i].w_sum / max(children[i].visits, 1) for i in range(len(children))]) q = (q - q.min()) / (q.ptp() + 1e-8) # 归一到 [0,1] pri = prior[valid_moves].copy() pri = pri / (pri.sum() + 1e-8) # 2. 重分配:Q 高的分支吸收 Q 低分支的部分概率 weight = 0.5 * pri + 0.5 * q * pri / (pri.mean() + 1e-8) ... return new_policy

配合全节点收集 + 相对权重,低 visits 节点不用丢弃——用权重 表达"这个节点有多可信":

python
复制代码
# 节点收集:visits 门槛设 0(全收),可信度进权重 weight = math.sqrt(visits / num_sims) # visits=1/30 → 0.18 # value = 树内 Q(内部节点自举),终局节点用真实胜负 z

双盲期里重分配的 Q 也全是噪声——它救不了双盲期本身。它的 价值在于:一旦 value 头开始从终局信号学到东西,Q 变准的瞬间 重分配标签立刻变准,policy 头不需要等数据分布先改变。这是 把逃逸正反馈链的长度砍半的关键设计。

4.6 value 头:逃逸的正反馈引擎

双盲期唯一可靠的监督信号是终局胜负 z——对局总会结束,胜负 永远真实。逃逸链条:

text
复制代码
终局 z → value 头学会"哪些局面会输" → 树内 Q 变准 → 重分配 policy 标签变准 → policy 头学会堵活三(先从 Q 读出来的) → 对局开始互堵、拉长 → 数据里出现真正的战术序列 → policy 直接从访问分布也能学 → 正循环

诊断双盲期时盯住 val value loss:它在降说明引擎在转。value 先于 policy 改善是正常时序(低 sims 运行实测:val value 0.088→0.029 而 policy 原地)。但"引擎在转"不等于"马上逃逸"——run_1 value 头降了上千 轮才等来逃逸。

4.7 耐心与数据量门槛(附完整逃逸时间线)

防守是模式识别问题:32ch CNN 需要"见过足够多活三/冲四被惩罚" 的对局才能形成形状记忆。run_1 逃逸时累计约 12 万局(r1200 × 100 局/轮),与参考实现的 20 万局同一数量级——门槛真实存在, 管线优化只是把它压低,没有取消它。

run_1 实测时间线(每轮 100 局,单轮 ~22 秒;C++ MCTS 后端 + 4 worker):

阶段轮次自弈耗时/轮arena 表现状态
双盲期r0~99~3.6 s频繁晋升(对手是弱 base,假进步)对局极短,随手连五
长平台r100~7993.6→9.6→5.5 s 冲高回落晋升频繁但幅度小行为在变,战术未成型
深平台r800~1049~3.4 s胜率 50% 无区分,两次 LR 跳回全程最像"失败"的阶段
逃逸启动r1050~11493.5→5.0 s第三次跳回后场均 82.5%,连续大胜晋升突破
巩固r1150~1379稳定 7-8 s持续 54-63% 震荡晋升达到中等 AI 强度

三点教训:

  1. 深平台段(r800~1049,胜率 50% 无区分 + 耗时回落到双盲水平) 是全程最像"该止损"的阶段——但它恰恰是逃逸前夜。数据量门槛 没到之前,所有指标都可以很难看。
  2. 逃逸波与第三次 LR 跳回(r1050 重置回 1e-4)强相关:平台期被 周期性搅动后,突破发生在新周期的下降段(r1100-1149 场均 82.5%)。 LR 状态机的"谷底 + arena 驱动跳回"在逃逸叙事里是配角但关键。
  3. 逃逸的确认信号按实际作用排序:arena 持续区分 > 自对弈耗时 台阶 > 人机实测。val policy loss 全程未掉头向下(§3.3 移动靶 效应),没有成为判据。

5. 失败判据与回退策略

低 sims 不是信仰,是下注。设好止损线:

信号判定动作
r1000+(约 10 万局)自对弈仍 <30 手、无耗时台阶双盲期未逃逸sims 提到 100-200 重开(树深翻倍,战术可搜出)。注意 r300 远不足以判定——run_1 r300 时仍双盲,r1200 自然逃逸
val value 也不降管线故障查标签/权重/学习率,与 sims 无关
单轮耗时超预算 2 倍+数据量失控检查节点/局是否膨胀(随机模型摊平暂态),考虑 collect_min_frac>0
arena 长期对 champion 无区分进步停滞检查 LR 状态机是否在谷底滞留过久(run_1 深平台 ~250 轮无区分,跳回后即突破,"长期"请以千轮尺度计)

回退不是失败:参考实现的三支柱在其数据规模上成立,换算到不同 算力预算/网络规模时,sims 的最优值本来就该重新标定。


6. 代码落位速查

如果你要在自己的实现里安放这些机制,典型落位如下:

模块内容
配置模块全部探索参数(c_puct/温度/开局/噪声/收集门槛)
自对弈工具模块随机开局生成、层次重分配标签、全节点收集与权重
搜索模块MCTS 主循环、根噪声、批量推理
训练脚本LR 调度器(余弦+谷底+arena 跳回)、训练主循环

7. 一句话总结

双盲期不是要"解决"的问题,而是要"穿越"的阶段:用随机开局和 温度保证数据多样性不塌缩,用层次重分配保证低 sims 标签可用, 用终局信号喂活 value 头点燃逃逸正反馈,然后用对局长度和 arena 曲线监控穿越进度——剩下的交给总轮次(run_1 实测: 1200 轮、约 12 万局、8 小时消费级显卡算力)。

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
鱼友3743
下载 APP