暗棋引擎调参:自对弈说涨了 77 Elo,真实数字是零
我做了 MistyBanqi,一个用 Rust 写的暗棋(中国暗棋)传统引擎。你可以 在 Mistboard 上和 MistyBanqi 对弈。它用的是 alpha-beta 加 Star1 expectiminimax、手工评估函数,对弈版本里没有神经网络。
这不是说它最强。暗棋里强的程序是 CLAP_CDC 和 DarkKnight,它们闭源而且更强。这篇是在我愿意花的预算内,我能做出的最强开源引擎的开发报告。有用的结论并不是某一项神奇的评估项,几乎所有进展都来自修正我衡量棋力的方式。
它的评估函数起点是 george0828Zhang 的开源 CDC 引擎, 之后我一直把那个引擎当作固定对手,用它来衡量每一次改动。MistyBanqi 现在水平不错,和这个基准大致持平,并且开源。
规则见 mistboard.com/rules/banqi,那里有可下的棋盘。这里唯一要紧的规则是:一着要么是普通走子或吃子,要么是一次翻棋,把一个暗子翻开,从未翻开的子力袋里随机露出一枚棋子。翻棋是随机事件,所以在普通 alpha-beta 之上,搜索里还带着机会节点,由 Star1 expectiminimax 处理;其余就是常规部件,置换表和静态搜索。所有给局面打分的部分都是手写的,而这正是下文的全部主题。
不要拿自己当标尺
我的第一次升级是常规搜索部件,置换表和重复局面检测。我用配对自对弈,拿它和自己引擎的早期版本比。它得分 60.9%,大约 +77 Elo,所有统计量都说这个提升是真的。
然后我把同一处改动拿去和参考引擎比。+77 Elo 没了,结果是持平。一个拿自己调参的机器人,优化的是打败自己的盲点,而那个自对弈数字衡量的是我引擎心目中的强对手,也就是我的引擎本身。于是我把那个引擎定为标尺,之后每一次改动都拿它做大规模配对对抗,每方几百局,在 Modal 上扇开到多个容器里跑,每次不到一小时出结果。暗棋和棋很多,几个百分点的优势需要这么多局才看得出来;我一开始用的 20 局比赛都是噪声。
调评估函数
诚实的标尺发现的第一件事,是我一直带着的一个 bug,在子力价值表里。下面是修正后的版本:
| 棋子 | 价值 |
|---|---|
| 将 | 30 |
| 炮 | 16(原为 12) |
| 士 | 14 |
| 象 | 11 |
| 车 | 9(原为 14) |
| 马 | 7 |
| 卒 | 4 |
炮和车的价值弄反了。炮靠隔一个炮架吃子,这让它成为盘面上战术威胁最大的棋子,尽管它在吃子等级里排得低;车只是普通的直线子。仅这一处修正本身就值几个百分点,占了评估改进的大部分。自对弈永远不可能告诉我这一点,因为我自己引擎的每个版本都带着同一张错的表;要靠外部对手才能暴露它。
更有意思的调参是:一枚棋子的价值取决于盘上还剩什么,而不是一个常数。参考引擎已经对将做了一个小版本的处理,将是卒唯一能吃的棋子:随着敌方卒被吃掉,它的价值上升,因为唯一威胁它的东西正在消失。我把这个思路推广到每一种棋子。每枚棋子都有一组能吃它的敌方棋子,随着这些棋子被兑掉,它的价值向「不可触碰」攀升。这一项叫自适应支配,是单项最大的评估改进,整套改动对参考引擎达到 +16.6%,负局减少了三分之二。即使我故意把它和旧的、错的价值表配在一起,它仍然给出 +8.7%,这说明它捕捉到的是真实的暗棋结构,而不是拟合某一个对手的数字。
整项只有十几行。对盘上每枚棋子,数一数还活着(在盘上或在袋里)能吃它的敌方棋子,然后按反比缩放一个加分:
// dom_val: a piece's value grows as the enemy pieces that can capture it
// disappear — toward "immortal" when its dominators are gone.
const DOM_K: f64 = 0.5;
for i in 0..NSQ {
let c = self.sq[i];
if !is_piece(c) { continue; }
let role = code_role(c);
let enemy = (1 - code_color(c)) as usize;
// living enemy pieces that can capture this role
// (role 5 = cannon, which screen-captures anything, so it always counts)
let mut dominators = 0;
for d in 0..7 {
if d == 5 || can_capture(d, role) { dominators += alive[enemy][d]; }
}
let bonus = values[role] * DOM_K / (1.0 + dominators as f64);
total += if code_color(c) == persp { bonus } else { -bonus };
}
敌方卒全被兑掉的将,或者吃它的棋子都已离盘的任何棋子,都会向「不可触碰」漂移,这正是固定价值表容纳不了的直觉。(完整函数)
棋力不在搜索更多
静态评估项到了平台期,于是我接着去攻搜索深度。有两件事拦住了我。
第一是翻棋。在翻棋节点上,引擎必须对这个暗子可能变成的每一种棋子取平均,所以一次翻棋就扇出十几个带权结果,而可翻的暗子又很多。这种机会分支让搜索树爆炸;在暗棋里你没法像下棋那样天真地搜得很深。(Star1 expectiminimax 会剪掉一些机会分支,有帮助,但不会让问题消失。)
第二点更重要:即使给更多节点,结果也不再变化。每着超过几十万节点之后,搜得更深并没有让它对参考引擎打得更好。瓶颈不是搜索深度,而是评估函数,也就是搜索落底时给局面打分的那个函数。于是我不再试图靠搜索堆出棋力,而是开始看棋。胜率只告诉你你在输,它从不告诉你该看哪一着。
病症一:赢定的棋走成和棋
这里有一局。逐着看一遍。
Misty(红方)子力十比二领先,却因三次重复局面成了和棋。两个问题都关乎它如何理解和棋。在生产环境里,它只拿到当前局面,没有历史,所以对自己正走进的重复局面完全看不见。而它的评估函数对兑现胜势没有任何奖励,所以一个赢得一塌糊涂的局面和一个已经真正赢下的局面打分差不多;它没有理由去取得进展,只是原地来回挪。
修正办法是把真实对局的着法历史串进搜索,这样引擎能看到重复局面要来,在领先时避开它,只在落后时才去寻求它(一个小的 contempt 设置)。这值大约 +53 Elo。另有一道单独的防护处理最丑的情况:引擎先把一枚棋子送进亏子的吃子里,然后还是接受和棋,这严格比直接和棋更差。那个看着像 contempt 的 bug,但在 contempt 为零时也能复现;真正原因是评估函数把那个亏子吃法打得比和棋值高出一丝。
病症二:它让自己的将被围猎
卒是唯一能吃将的棋子,所以一个松动的将确实危险。看一枚敌方卒沿 a 路把 Misty 的将猎下来。
Misty(这里是黑方)把将挪进了 a1 角。一枚红卒沿这一路推上来,将被堵死:它旁边的格子还是暗子,你不能走到暗子上,而将又不能吃卒。最后它一步合法着法都没有,原地冻住,28 着之后被吃掉。
救法就是棋手所说的透气:在猎手到来之前,翻开将旁边的一个暗子,给将开一条出路。我加了一项将的安全项,正好诱导出这种走法,把 Misty 丢掉自己将的频率从 35.5% 降到 26%。看看衡量这件事需要什么:对参考引擎的胜率几乎没动,因为它不猎将,而且这些对局 Misty 通常本来也赢。基准看不见这个修正,所以我不再依赖比分,而是直接测那个灾难本身。(我还发现自己在问引擎自己的评估函数,某个救法是否奏效。一个对危险视而不见的评估函数,没法给自己盲点的修正打分,所以我改成手工核查。)
这就是手工调参能达到的诚实边界。威胁是一场缓慢、安静、跨多着的行军,任何静态项都看不见,直到太晚,而这正是学习出来的评估函数所要承担的长视野判断。
最后一次衡量:这一级台阶值得花钱吗?
便宜的收益已经用光,两个病症都指向同一个修法:一个理解局面而不是在数数的评估函数。那就是学习出来的价值网络,也就是 CLAP_CDC 在这个项目上拿下计算机奥林匹克冠军所用的 AlphaZero 配方。
于是我搭了整条流水线(棋盘编码、一个小 ResNet、带机会节点的 MCTS、带门控的自对弈,让坏的一代不会毒害下一代),然后在为完整训练付钱之前,又做了一次衡量:一个便宜的本地去风险实验,看价值网络是否真能打过手工调参的引擎。
结果没有。我在笔记本的 GPU 上跑了这个去风险实验,三次运行里网络对 alpha-beta 引擎的胜率都卡在 35% 左右,始终没到能算改进的 55%。把搜索预算翻倍几乎没有变化,所以这不是对局太少的问题;最可能的原因是机会节点产生的价值目标噪声太大,以及网络太小。这些都可以修,但要测这些修正需要远超一台跑几小时就降频的笔记本的算力,而云 GPU 在这里也不是插上就能用:游戏逻辑是单线程 Python,快 GPU 只能饿着等它,除非把那条路径用 Rust 重写并放在一个批量推理服务后面。那是一两周的工作量,然后要几百到一两千美元来跑。就我手上的证据看,这是一场赌注,而不是一步稳妥的台阶,所以我还没走;更便宜的本地实验先做。
所以 MistyBanqi 是一个水平不错的 alpha-beta 引擎,不是什么最先进的主张,而这是经过衡量的选择,不是死路。我还没有对 CLAP_CDC、DarkKnight 或其他顶尖 CDC 程序做过可复现的对抗测试;学习网络是下一级台阶,等哪天去风险实验说它值得花钱。
这里每一节底下的共同模式是:整个项目里最便宜、杠杆最高的决定,是选择衡量什么。自对弈得分、胜率、丢将率、去风险门控,每一次挑一个能看见我真正在意的东西的量具,作用都大于对引擎本身的任何改动。
资源:
- 在 Mistboard 上和 MistyBanqi 对弈,或者读规则。
- misty-banqi:引擎本体,MIT 许可,alpha-beta + Star1 搜索以及上文的评估项。
- chinese-dark-chess-hw:我评估函数的起点,也是我用作固定基准的开源引擎。
- 引擎系列的更多内容:做一个斗兽棋引擎,更大棋盘上的完全信息表亲。