从真实对局中挖掘象棋残局题
象棋没有公开的谜题语料库。国际象棋有数百万个从真实对局中挖掘出来、公开且免费的局面;而网上能找到的象棋谜题多是人工拟制的残局排局,那是另一回事,数量也少得多。
于是我做了一个挖掘器。它读取真实对局,找出人们走错的着,保留那些恰好只有一着能取胜的局面。目前它在 Mistboard 上提供 1415 道谜题,另有一份十二个局面的选辑可以逐着演练。
下面这道是今天的题目,直接取自该语料库。在棋盘上走着法即可;框内的尝试不计入评分,所以不必有压力。
接下来讲的是这样一道题是怎么造出来的,以及用这种方式建成的语料库最终呈现出什么样貌。
对局来源
对局来自 ElephantChess,该站以 GPL-3.0 许可按月发布自己站内的匿名化对局数据。业余对局,这一点很关键:高手失误的频率不足以构成供给。每个来源都标注许可状态,来源未获许可的谜题一律不予发布。
每次运行在花费任何引擎算力之前先冻结其对局清单,按等级分、用时规则、结果和对局长度分层抽样,以免全都是快棋。运行一旦开始就不再追加,这正是它可复现的原因。
两轮筛查,以及拆分为何划算
整套做法的经济性来自一个不对称:廉价的那一轮跑遍每盘对局的每个局面,昂贵的那一轮只跑过关的那些。
廉价那一轮重放对局,在第 8 着之后的每个局面停下,以 60000 节点向 Pikafish 询问最佳两着,大致相当于 10 到 14 层深度。
当实际走出的着法比引擎的最佳着至少差 250 个百分兵值,且留下的局面对另一方至少领先 250 时,这个局面成为候选。一个让局面回归均势的失误不是谜题;没有东西可找。
有一个细节把这一轮的开销减半。判断一着需要该局面走前和走后的评估值,只要扫描保持顺序,两者其实都已在手,因为所走之着的价值等于下一个局面分值的取反:
for (let ply = minPly; ply < moveCount; ply += 1) {
const pre = scans[ply]; // the best that was available
const post = scans[ply + 1]; // what they left behind, opponent's view
if (pre === null || post === null) continue;
if (Math.abs(pre) >= decidedCp) continue; // already decided: no tactic
if (post < winCp) continue; // solver must end up winning
const playedCp = -post; // the move, in their own terms
const swing = pre - playedCp;
if (swing < swingCp) continue; // a mistake, but a small one
candidates.push({ ply, swingCp: swing, preBestCp: pre, postBestCp: post });
}
每个局面一次搜索而不是两次,而这一轮要触及每盘对局的每个局面。这个减半使扫描整个语料库变得可承受:每一千盘对局约六核心小时,大致相当于每发布一道谜题一核心分钟。
昂贵那一轮随后把每个候选以 20 层深度、600000 节点交还给引擎,预算是前者的十倍,并且只递交一个裸 FEN,不带着法历史。同一个局面,没有上下文,引擎便无法依赖它刚做过的搜索。
什么让它只有一个答案
解答是一着一着搭起来的,每一着都必须自身唯一最佳。整段照搬引擎的主要变例行不通:主要变例只是引擎在一次搜索中偏好的一条路线,它说明不了第三着是否唯一。一个找到了不同第三着却被告知走错的解题者,等于被骗了。
唯一性也不是百分兵值差距。相差 50 个百分兵值的两着都没问题,硬要判定其中之一反而惩罚了选对的解题者。让一着成为那个答案的,是所有替代着都错:要么把胜势送掉,要么赢得的物质明显更少。
function classifySolverMove(best, second) {
// Mate saturates both centipawns and win%, so mates get their own rule:
// unique only when this is the strictly fastest forced mate.
if (mates(best)) {
if (!second || !mates(second)) return { unique: true, reason: 'fastest-mate' };
return best.mate < second.mate
? { unique: true, reason: 'fastest-mate' }
: { unique: false, reason: 'mate-not-unique' };
}
if (winRate(best.scoreCp) < 0.8) return { unique: false, reason: 'best-not-winning' };
if (!second) return { unique: true, reason: 'only-move' };
const gapCp = best.scoreCp - second.scoreCp;
if (gapCp < 200) return { unique: false, reason: 'near-tie' };
// The runner-up is wrong if it gives the win away outright...
if (winRate(second.scoreCp) <= 0.6) return { unique: true, reason: 'runner-up-loses-win' };
// ...or if it still wins, but wins a whole piece less.
if (gapCp >= 250) return { unique: true, reason: 'material-gap' };
return { unique: false, reason: 'alternative-still-good' };
}
它默认拒绝。凡是关卡无法区分的一律丢弃,原因记录在候选上,因此每次拒绝事后都可审计。
第二轮在 22 层深度、不设节点上限的条件下复核过关者,运行在另一个进程中,且不知道第一轮的判定。其中约 6% 没能站住。同一个引擎在不同预算下两次运行之间的这种分歧率,正是必须保留第二轮的理由:一个引擎在一个深度上,不能作为它自身判定的真理来源。
给还没人解过的着法线路评分
一条线路可以经过验证、唯一,却仍然是道差题,决定优劣的是难度。
朴素的评分是将死步数,这对整个语料库只能给出四个不同取值。真正的评分沿着解答走一遍,给那些让局面难以看出的因素打分:安静的首着加分,吃子减分,未收回的弃子最多加 200,防守方应着的数量则可加可减。无法被反吃的吃子扣分最重,并按吃到的物质缩放,因为盘面上最容易被发现的东西就是一个无根子:
// Only the solver's first move, and only while the game is still running: a
// capture that MATES also leaves the opponent no legal moves, and reading that
// as "nothing can recapture" would penalise every mating capture in the corpus.
if (index === 0 && captured && state.status.type === 'playing') {
const recaptures = getLegalMoves(state).filter((reply) => reply.to === move.to);
if (recaptures.length === 0) freeCaptureCp = MATERIAL_CP[captured.role];
}
那道保护判断才是值得借用的部分:这个检查的显然写法是错的,而且错得能通过你想得到的每一项测试,因为造成将死的吃子同样会让对方无合法应着。
有一类局面被扣下而不是评分。解题者原本就已多赢一个马以上,而且答案是吃掉一个无保护的子,这样的谜题在任何难度下都教不了什么,而评分只能决定把题目展示给谁。
语料库最终的样貌
从选辑中取三章,各对应其中一项数字。
三分之二的谜题首着不吃任何子。如果你找战术时先扫吃子着法,也就是我们大多数人的做法,那你多数时间都在看错的那三分之一棋盘。下面这道出自四月广东对山东的团体赛,整条线路中双方哪里都没吃子,最后仍以将死告终:
只有约十分之一涉及送子。弃子是人们记得住的战术,所以我原以为占比会更大。在真实棋手之间的真实对局里,取胜之着通常只是普通一着。这里车横越棋盘到 c1,再踏入 f1 任对方吃,马在 d1 成杀:
还有 40% 根本不以将死收尾。它们以解题者单纯取得胜势收尾,而这正是将杀型直觉容易漏掉的一类。下面的取胜之着是帅走一步:红帅从 e2 走到 e1,不吃子,不叫将,三着之后的兑换让红方多出 650 个百分兵值。
| 已提供谜题数 | 1,415 |
| 难度区间 | 1000 至 2600,479 个不同取值 |
| 首着不吃子 | 约三分之二 |
| 以非将死方式收尾 | ~40% |
| 涉及送子 | ~10% |
一道谜题究竟是什么
多数胜势局面都有好几个取胜着法,而这正是它们被淘汰的原因:挖掘器找到的东西里有三分之一仅因这一条就出局。一道谜题是只有一个答案的局面,深到找出它需要费力,稳到一小时后更强的引擎依然认同。十个失误里有九个不合格,这才是没人手头有一个大型象棋谜题库的真正原因:供给难题不在于找到失误,而在于其中几乎没有一个是谜题。
去下一下
语料库免费,无需账号。选辑是十二个挖掘出的局面,每个附一条说明它展示了什么;训练器提供全部 1415 道,带难度评分。