摘要

  • MistyBanqi 是一个开源的暗棋(中国暗棋)引擎,与我用作基准的那个开源引擎大致势均力敌。闭源程序更强。
  • 自我对弈说一项搜索改动值 +77 Elo;对上外部引擎却毫无价值。后续每一项改动都改用那个引擎来衡量。
  • 外部基准发现炮和车的数值被弄反了。评估上收益最大的一项是:能吃掉某个棋子的对方棋子离开棋盘后,该棋子的价值随之上升,这让整套改动对基准的胜率提升达到 +16.6%。
  • 一次低成本的 AlphaZero 测试对上手工调参的引擎,胜率最高只到 35%,所以付费的大规模训练暂时搁置。

我做了 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(红方)以十子对两子领先,却因三次重复局面成和。两个问题都出在它对和棋的理解上。在生产环境里,它只拿到当前局面,没有历史,所以它对自己正走进的重复局面一无所知。而它的评估对兑现胜势没有任何奖励,于是一个遥遥领先的局面和一个已经赢下的局面得分差不多;它没有理由推进进程,只会来回挪子。

修正的做法是把真实对局的着法历史接进搜索,于是引擎能看到重复局面将至,领先时避开它,只在落后时去寻求它(一个小的蔑视值设定)。这值约 +53 Elo。另有一道防护处理最难看的情形:引擎先丢掉一个子被吃,然后还是接受和棋,这比直接接受和棋严格更糟。那一处看起来像蔑视值的 bug,但在蔑视值为零时也能复现;真正的原因是评估把这次亏子的吃子算得比和棋值高出一丝。

病症二:任由自己的将/帅被追猎

兵、炮和对方的将/帅都能吃掉将/帅,其中兵最廉价,所以一个失守的将/帅处境很危险。看一个对方的兵沿 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 程序做过可复现的正面较量;学习型网络是下一段攀升,等哪天风险排除说它值得付费。

贯穿这里每一节的模式是:整个项目中最便宜、最高杠杆的决定,是选择衡量什么。自我对弈得分、胜率、丢将/帅的比率、风险排除门限,每一次挑一件能看见我真正在意的东西的量具,作用都大于对引擎本身的任何改动。

资源: