電腦把一步棋評為勝勢,而它自己的最佳變化裡有將殺
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。