电脑把一步棋评为胜势,而它自己的最佳变化里有将杀

10 月 2 日,我们打开 AB-JChess 对抗赛的第 108 盘,想用它说明皮卡鱼如何判断翻子。皮卡鱼执黑,把 d10 的暗子翻开走到 e9,自评领先 18.7 个兵。而在它自己的最佳变化里,红方将杀。正确的搜索不可能在自己的最佳变化里看到将杀,还把局面评为胜势。

只有 e9 上是士,才能挡住车在 f10 的将杀,而在它可能是的 11 个棋子里,士只有 1 个。翻出来是车,黑方下一步就被将死。我们当天就把那些例子从那篇文章里删掉了。这步棋让我们找到了第一个错误,继续查下去又发现了第二个。

错误一:对黑方来说,平均取了最好的那个棋子

电脑考虑一步翻子时,会对暗子可能是的每一种棋子各走一次这步棋,分别打分,再按每种棋子剩余的数量加权平均。一步翻子如果翻出车就赢、翻出兵就被将死,分数应该介于两者之间。两个错误破坏了这个平均。

取平均之前,代码会把每个分数换算到某一方的视角。它根据一个从未被赋值的设置来判断是哪一方,所以总是当成红方。对红方的翻子,这样没有问题。对黑方的翻子,分数就是反的:一条本该在结果相差很大时取最差结果的规则,取了最好的结果。

颜色翻转在 misc.h 第 360 到 366 行,由 search.cpp 第 1310 行设置:它判断走棋方是不是“先手方”,而这个字段从来没有被赋值。第 108 盘是黑方翻子,所以它的分数经过了这次翻转。

错误二:中途放弃的分数被当成精确分数取平均

我们在翻子时打印了电脑可能翻出的每种棋子的分数。在一盘对抗赛对局里,四种棋子中有三种会被将死:

搜索时视为搜索分数
棋子 1,被将死738
棋子 2,被将死738
棋子 3,被将死738
棋子 4792
平均值745
738 是搜索窗口的下沿。每个被将死的棋子返回的都是这个下沿,而电脑把它当成真实分数取了平均。

引擎为了节省时间,一旦知道某步棋不如目前最好的一步,就会放弃继续搜索它,返回一个界限,意思是“最多这么多,或者更差”。如果接下来只是比较大小,这样做是安全的。但这个电脑把这些界限当成精确分数放进了平均,于是把一步四种棋子里有三种会被将死的翻子评为胜势。每种棋子都在上一层着法的窗口内搜索,见 search.cpp 第 1462 行。

错误二与错误一互不相关:红黑双方都会受影响,而 AB-JChess 有错误二,却没有错误一。

单是错误二,就在 AB-JChess 那篇文章的第一盘棋里藏住了一个将杀。电脑执红,把 b3 的暗子翻开走到 d3。如果这个棋子是兵或相,黑炮立刻在 h1 将死。

b3 若是占比对红方
车0.4胜
兵0.4被将死
相0.2被将死
旧版电脑的评分+8.6
按占比加权约 −3.0
占比是该棋子在 b3 可能是的棋子中按数量所占的份额。评分以兵为单位,从红方角度计,来自旧版电脑的一次全新搜索。

如实加权的话,是黑方占优。修复后的电脑看到了这个将杀,改走仕从 e2 到 f3,也就是 AB-JChess 推荐的着法。

修复去掉了翻转,并对中途放弃的棋子重新搜索

两个错误都在我们复制的皮卡鱼揭棋分支里。简化后,旧代码是这样给翻子打分的:

# Before (simplified)
for piece in pieces_it_could_be:
    # bug two: cut short, this returns the window's edge
    s = -search(position_with(piece), window)
    # bug one: set from a field nobody fills in
    if mover_is_black:
        s = -s
    scores.add(s, weight = count[piece])
v = weighted_average(scores)
# for Black, "min" was really the best
if v - min(scores) > MAX_SPREAD:
    v = min(scores)
# After
for piece in pieces_it_could_be:
    s = -search(position_with(piece), window)
    # bug two fixed: only a bound, search again
    if at_root and s <= window.low:
        s = -search(position_with(piece), no_lower_limit)
    scores.add(s, weight = count[piece])
v = weighted_average(scores)
if v - min(scores) > MAX_SPREAD:
    v = min(scores)

删掉颜色翻转,修复了错误一;对返回界限值的棋子重新搜索,修复了错误二。两处改动都在一次提交,28 行里。

修复后的电脑对翻子的估值接近实际,棋力也基本不变

我们用新旧两版电脑重新搜索了 AB-JChess 对抗赛中决定皮卡鱼输棋的 71 步,再让修复后的电脑和旧版对下。

走法数量旧版电脑修复后的电脑
决定输棋71163
对照3000
每版电脑把多少步评得比实际好至少五个兵;每步都用单线程、2M 节点全新搜索。对照是皮卡鱼赢下的对局中的 30 步。
修复后的电脑局数胜-负-和Elo
只修颜色20095-95-10
已上线:修颜色,根节点重新搜索600264-300-36−21 [−48, +6]
已上线,每步 4M 节点20095-97-8−3 [−51, +44]
所有地方都重新搜索128约 −150
修复后的电脑对旧版,除注明外每步 1M 节点。胜-负-和是修复后电脑的战绩;方括号里是 Elo 区间。

修复后的电脑对翻子的估值接近实际,棋力在误差范围内没有变化,也许略弱一点:两个区间都包含零。错误二的修复只覆盖电脑正在选择的那一步。在更深的搜索里,旧的平均方式依然存在,因为在所有地方都重新搜索要损失大约 150 Elo。

AB-JChess 也有错误二,但很少有影响

AB-JChess 基于皮卡鱼,也用同样的方式对翻子取平均,所以它也有错误二。在决定它输给我们电脑的 60 步翻子中,它只高估了 2 步。

为什么 9 月对这种取平均的测试不能作数

9 月,我们测试过用对每种棋子分别搜索来替代这种取平均,结果保留了取平均。那次测试时错误一还在,所以执黑时,测试归功于“退回最差结果”的那一步,实际上取的是最好结果,那次的结论不能作数。

这个想法来自一份外部对该引擎源码的审查:iwestlin/jieqi-ai。7 月,取平均曾选了一步真实胜率 64% 的翻子,放弃了胜率 97% 的稳妥着法。每项测试都让改动后的引擎对阵已上线的引擎,轮换执色,由裁判程序发放暗子,双方引擎都看不到暗子。

# Shipped: inside the search, each time a move turns over a piece
v = weighted_average(s[piece], weight = count[piece])
if v - min(s) > MAX_SPREAD:
    v = min(s)              # fall back to the worst case

# Replacement: at the root, when the top move is a reveal
for move in top_four_moves:
    v[move] = average(win[move][piece] for piece in pieces_it_could_be)
play(argmax(v))
piece:暗子可能是的每一种棋子。s[piece]:暗子是该棋子时的搜索分数。count[piece]:该棋子还有几个未翻开。MAX_SPREAD:平均值最多能比最差结果高出多少。win[move][piece]:单独搜索得出的胜率。
改动局数胜-负-和得分率95% 误差用时
分别搜索10030-56-140.370±0.092.2×
分别搜索,更长用时5017-26-70.410±0.137.0×
修复评估漏洞20091-101-80.475±0.071.0×
修复评估漏洞并重新调校20096-93-110.507±0.071.0×
得分是改动后的引擎对已上线引擎的得分,和棋算半分,所以 0.500 表示没有变化。用时是已上线引擎用时的倍数。

分别搜索在 2.2 倍用时下明显输了,在 7 倍用时下得分仍低于五五开。此后 AB-JChess 在 400 盘中以 248-136-16 战胜了这个引擎,现在是最强的揭棋电脑。

最后两行是另一个错误,与取平均无关:同一份审查发现,评估函数从未计入其中一方的暗子。我们当时修了它,再用 10,000 对 SPSA 对局重新调校它涉及的 43 项参数。两项改动都没有超出误差范围:200 盘的 ±0.07 大约是 ±50 Elo。

挑战揭棋电脑揭棋规则