电脑把一步棋评为胜势,而它自己的最佳变化里有将杀
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 |
| 棋子 4 | 792 |
| 平均值 | 745 |
引擎为了节省时间,一旦知道某步棋不如目前最好的一步,就会放弃继续搜索它,返回一个界限,意思是“最多这么多,或者更差”。如果接下来只是比较大小,这样做是安全的。但这个电脑把这些界限当成精确分数放进了平均,于是把一步四种棋子里有三种会被将死的翻子评为胜势。每种棋子都在上一层着法的窗口内搜索,见 search.cpp 第 1462 行。
错误二与错误一互不相关:红黑双方都会受影响,而 AB-JChess 有错误二,却没有错误一。
单是错误二,就在 AB-JChess 那篇文章的第一盘棋里藏住了一个将杀。电脑执红,把 b3 的暗子翻开走到 d3。如果这个棋子是兵或相,黑炮立刻在 h1 将死。
| b3 若是 | 占比 | 对红方 |
|---|---|---|
| 车 | 0.4 | 胜 |
| 兵 | 0.4 | 被将死 |
| 相 | 0.2 | 被将死 |
| 旧版电脑的评分 | +8.6 | |
| 按占比加权 | 约 −3.0 |
如实加权的话,是黑方占优。修复后的电脑看到了这个将杀,改走仕从 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 步,再让修复后的电脑和旧版对下。
| 走法 | 数量 | 旧版电脑 | 修复后的电脑 |
|---|---|---|---|
| 决定输棋 | 71 | 16 | 3 |
| 对照 | 30 | 0 | 0 |
| 修复后的电脑 | 局数 | 胜-负-和 | Elo |
|---|---|---|---|
| 只修颜色 | 200 | 95-95-10 | |
| 已上线:修颜色,根节点重新搜索 | 600 | 264-300-36 | −21 [−48, +6] |
| 已上线,每步 4M 节点 | 200 | 95-97-8 | −3 [−51, +44] |
| 所有地方都重新搜索 | 128 | 约 −150 |
修复后的电脑对翻子的估值接近实际,棋力在误差范围内没有变化,也许略弱一点:两个区间都包含零。错误二的修复只覆盖电脑正在选择的那一步。在更深的搜索里,旧的平均方式依然存在,因为在所有地方都重新搜索要损失大约 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))| 改动 | 局数 | 胜-负-和 | 得分率 | 95% 误差 | 用时 |
|---|---|---|---|---|---|
| 分别搜索 | 100 | 30-56-14 | 0.370 | ±0.09 | 2.2× |
| 分别搜索,更长用时 | 50 | 17-26-7 | 0.410 | ±0.13 | 7.0× |
| 修复评估漏洞 | 200 | 91-101-8 | 0.475 | ±0.07 | 1.0× |
| 修复评估漏洞并重新调校 | 200 | 96-93-11 | 0.507 | ±0.07 | 1.0× |
分别搜索在 2.2 倍用时下明显输了,在 7 倍用时下得分仍低于五五开。此后 AB-JChess 在 400 盘中以 248-136-16 战胜了这个引擎,现在是最强的揭棋电脑。
最后两行是另一个错误,与取平均无关:同一份审查发现,评估函数从未计入其中一方的暗子。我们当时修了它,再用 10,000 对 SPSA 对局重新调校它涉及的 43 项参数。两项改动都没有超出误差范围:200 盘的 ±0.07 大约是 ±50 Elo。