Mistboard 的斗兽棋引擎没有「冲穴竞速」这个概念。它给局面打分的方式是子力加上每个棋子向敌方兽穴推进的奖励,从不问奔向自己兽穴的那个跑者是否先到。它要等搜索抵达兽穴才知道,大约十二着之外。

这看着像个明显的缺口,起因是一局对弈中机器人把鼠来回挪动,而敌方的鼠径直走了进来,于是我开了个 issue。我花了一天想补上它。三条路线,全部无效或为负。下面是测量的结果,以及我会从中保留的东西。

引擎已经会做的事

值得先分开说,因为它框定了后面的一切。引擎完全理解胜利条件:抵达兽穴被计为将死,在候选着法中排在最前,并在静止搜索中被搜索。它缺的是预见力,不是理解力。

它照样能赢,因为斗兽棋由子力主导,而评估函数对子力读得很准。在进入兽穴的那一刻,胜方的中位子力领先 20 分,大约一只猫。只有 4.7% 的对局是由落后一方赢下的。

没有这个概念时它怎么下

本研究的第 10 局只有四着长,却比下面任何表格都更好地回答了这个问题。

斗兽棋的中部是三行河。d 路是唯一横跨其上的陆桥。虎只能纵向跳河,绝不横向,所以站在那条走廊里的虎几乎无处可去。

斗兽棋局面:红方的虎孤身在 d 路陆桥上,两侧是河,蓝方的象正在它上方 第 20 着,蓝方行棋。红色方格是刚走的一着:红方的虎吃掉了 d6 的狼,现在站在走廊里。蓝方的象就在它上方一格, 且等级高于它。 第 20 着时红方在 d5 的虎有两个 合法着法。向前或向后,都仍在走廊里。它选择向前,吃掉了 那只狼,这让蓝方损失 40 分,并把虎停在了蓝方象的旁边。

蓝方的象等级高于虎,所以这看起来像是送礼。蓝方没有立刻吃掉它,这正是别人要我解释的那一着。引擎的理由是虎逃不掉:象在 d7 时虎唯一的着法是 d5,而从 d5 它唯一的着法是 d4,被一个等级高于它的棋子沿着单车道走廊一路逼下去。抓它要花一个先手去收已经到手的东西。蓝方走了一步免费的改善着法,两着之后在 d5 而不是 d6 吃掉了虎。两条路线到达的子力完全相同。

决定这局的四着:

41  Blue  elephant d7 -> d6    steps into the corridor after the tiger
42  Red   lion     c3 -> c7    jumps into the square the elephant just left
43  Blue  elephant d6 -> d5    takes the tiger, up a piece
    Red's lion is now three moves from a den Blue cannot defend

那只虎是诱饵。不是弃子:它本来就几乎被困死,而吃狼的得分是 +10,对比几乎所有替代着法的 +6,这是四分的差距而不是一个计划。它在离场路上做的事,是把唯一等级高于狮的棋子拉到 d 路上。第 20 着时狮的跳河得分是 -195,因为 c7 紧邻 d7 的蓝方象。两着之后同样的跳跃就赢了,因为象走开了。

蓝方在这段结束时领先 13 分,然后输了。

把这个计划复述一遍,听起来像是引擎理解了冲穴竞速。它并没有。在那个评估函数里,无论这个项目之前还是之后,都没有任何冲穴竞速项。它搜索了五百万个节点,这条线就出来了。整个结果就是这样:issue 想装上的那种行为已经在那里了,由搜索深度而不是知识产生。

基准率:4.7% 的对局,而且要在发布强度下测

172 局自我对弈,使用生产预算每着五百万节点。

终局类型 占比
重复和棋 52.9%
进入兽穴 26.2%
无进展和棋 20.9%
真正的竞速(胜方在十着之前持平或落后) 4.7%

同一套测试框架在 200k 节点下报告 22% 的竞速。棋力更弱会把这个数字大约放大五倍。便宜的测量本来就能做,而它会在「值得多干活」的方向上错 5 倍。

余量:搜索加深 12 倍只换来两着

对每一局竞速,我在败方的对局中用二分查找找出每种预算最早能识别出必败的那一着,然后取差值。

差距 竞速局数
0 着 4
2 着 3
4 着 1

六千万节点对五百万节点,更深的搜索识别出败势的时间中位数只早两着。在那个视野之前两者贴得很近,只在最后一两着才分开。

这个数字本该终结这个项目。如果十二倍的搜索只换回两着,那么这些信息并不是摆在那里等一个更聪明的评估函数去挖出来。

路线 A:搜索延伸,免费版与付费版

不实现就先探测。测试框架的策略是:只要有棋子处在敌方兽穴 R 格以内,就给这一方更大的节点预算,这对任何真实延伸都是严格的上界。

第一个发现,在任何 Elo 之前:触发条件并不稀疏。

半径 触发的着数占比
2 23%
3 43%
4 72%

斗兽棋的棋盘很小,兽穴又在底线上。在 R=4,也就是要早早看到竞速所需的半径下,这个延伸不过是「到处都多搜十二倍」,所以「精准手术」这个说法从一开始就是错的。

在 R=2,每组 300 对换色对局:

组别 Elo 节点数对比对照组
兽穴附近 12 倍深度,免费给予 +45 3.3x
预算中性,基础 3.7M + 兽穴附近 10M -15 1.04x
预算中性,基础 1.1M + 兽穴附近 20M -12 0.98x

同一个想法,同一份代码,符号相反。引擎的五百万节点由玩家愿意等多久决定,所以真实的延伸变不出深度:它只能从搜索的其余部分里拿。免费那一组测的是额外算力。跟兽穴没有任何关系。

我之所以跑预算中性那一组,是因为 +45 看着太好了。就此停手,会为一个价值略低于零的特性换来一周的工作量。

两组都只花了一个下午,因为两者都不需要动引擎。这个延伸只是测试框架的一条策略,根据棋盘条件改变节点预算。对于任何「让它更懂 X」形式的提案,先免费给它完美的 X,是弄清 X 到底有没有意义的最便宜办法。

路线 B:这个评估项本身

还是做了,因为搜索延伸失败的理由并不适用于评估项。延伸要花节点;评估项每个叶子几乎不花什么。

这是当时的评估函数。在棋盘上循环一遍,每个棋子独立贡献:

for idx in 0..N {
    let v = VAL[role_of(code) as usize];
    let friendly = color_of(code) == me;
    s += if friendly { 2 * v } else { -2 * v };

    // Advancement toward the relevant den: the enemy's for our pieces, ours for theirs.
    let target = if friendly { enemy_den } else { own_den };
    let dist = manhattan(idx as u8, target);
    let advance = if dist <= 1 { 400 } else { (16 - dist) * 3 };
    s += if friendly { advance } else { -advance };
}

那个推进项正是评估函数对竞速视而不见的原因,原因在于对称性。我的跑者离你兽穴三格得 +39。你的跑者离我兽穴三格得 -39。双方都在冲刺得出的数字接近零,于是局面在最该被判定的那一刻读起来风平浪静。

所以这个项不能是又一个按棋子计的奖励。它必须是每方一个结论,只算一次,把两个跑者互相比较:

for side in [me, 1 - me] {
    // Nearest enemy runner to this side's den, and this side's nearest defender
    // to its own trap ring.
    let (mut att, mut def) = (99, 99);
    for idx in 0..N { /* ...scan once... */ }

    if att > RACE_RADIUS { continue; }   // no race here: contribute nothing

    // The side to move gets one free step in the race for its own den.
    let margin = att - def + i32::from(side == me);
    let pen = if margin <= 0 { RACE_LOST }
              else if margin == 1 { RACE_TIGHT }
              else { 0 };
    score += if side == me { -pen } else { pen };
}

防守方的距离量到陷阱环,而不是兽穴。那才是正确的拦截区,因为兽穴的三个相邻格就是它的三个陷阱,而站在陷阱上的棋子可以被任何防守棋子吃掉,不论等级。走进你兽穴的象必须穿过一个你的鼠能吃掉它的格子。

在它专为之设计的局面中,它改变了 17% 的选择着法,并在等节点下花掉约 17% 的实际耗时。每个权重 300 对换色对局,在计入这个开销之前:

权重 Elo
10 -3
25 +9

无效,而且是在宽松的那个方向上。

我会保留什么

把预算固定住,否则你测的就是预算。实验的免费版本是最省事的搭法,而它会递给你一个自信的正数。这项改动本该从哪里拿资源,就从那里拿,再看结果能否存活。

这个陷阱一天里坑了我两次。同一个下午的和棋厌恶(contempt)扫描显示决定性对局从 32% 升到 50%,而这正是更高的 contempt 按构造必然产生的结果,因为自我对弈扫描中双方都用同一设置。只有让两个数值互相对抗,才能说明它是否有代价。结果没有,所以那一项发布了。

评估函数的缺口是真实的,诊断也正确。只是它背后什么也没有。在同一批对局中找到的两个常数,一个是过早结束对局的无吃子和棋计时,一个是接近零的和棋厌恶,把斗兽棋从四分之一的对局以胜负收场提升到约一半,而这两者都没教引擎任何东西。那个 issue 要的是理解。理解从来不是瓶颈。

这些对局是公开的,上面那个局面也是。分析棋盘跑的是同一个引擎,所以如果你认为蓝方该吃那只虎,可以让它把这条线走完。