電腦把一步棋評為勝勢,而它自己的最佳變化裡有將殺

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
棋子 4792
平均值745
738 是搜尋窗口的下沿。每個被將死的棋子返回的都是這個下沿,而電腦把它當成真實分數取了平均。

引擎為了節省時間,一旦知道某步棋不如目前最好的一步,就會放棄繼續搜尋它,返回一個界限,意思是「最多這麼多,或者更差」。如果接下來只是比較大小,這樣做是安全的。但這個電腦把這些界限當成精確分數放進了平均,於是把一步四種棋子裡有三種會被將死的翻子評為勝勢。每種棋子都在上一層著法的窗口內搜尋,見 search.cpp 第 1462 行。

錯誤二與錯誤一互不相關:紅黑雙方都會受影響,而 AB-JChess 有錯誤二,卻沒有錯誤一。

單是錯誤二,就在 AB-JChess 那篇文章的第一盤棋裡藏住了一個將殺。電腦執紅,把 b3 的暗子翻開走到 d3。如果這個棋子是兵或相,黑炮立刻在 h1 將死。

b3 若是佔比對紅方
車0.4勝
兵0.4被將死
相0.2被將死
舊版電腦的評分+8.6
按佔比加權約 −3.0
佔比是該棋子在 b3 可能是的棋子中按數量所佔的份額。評分以兵為單位,從紅方角度計,來自舊版電腦的一次全新搜尋。

如實加權的話,是黑方佔優。修復後的電腦看到了這個將殺,改走仕從 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 步,再讓修復後的電腦和舊版對下。

走法數量舊版電腦修復後的電腦
決定輸棋71163
對照3000
每版電腦把多少步評得比實際好至少五個兵;每步都用單執行緒、2M 節點全新搜尋。對照是皮卡魚贏下的對局中的 30 步。
修復後的電腦對局數勝-負-和Elo
只修顏色20095-95-10
已上線:修顏色,根節點重新搜尋600264-300-36−21 [−48, +6]
已上線,每步 4M 節點20095-97-8−3 [−51, +44]
所有地方都重新搜尋128約 −150
修復後的電腦對舊版,除註明外每步 1M 節點。勝-負-和是修復後電腦的戰績;方括號裡是 Elo 區間。

修復後的電腦對翻子的估值接近實際,棋力在誤差範圍內沒有變化,也許略弱一點:兩個區間都包含零。錯誤二的修復只涵蓋電腦正在選擇的那一步。在更深的搜尋裡,舊的平均方式依然存在,因為在所有地方都重新搜尋要損失大約 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))
piece:暗子可能是的每一種棋子。s[piece]:暗子是該棋子時的搜尋分數。count[piece]:該棋子還有幾個未翻開。MAX_SPREAD:平均值最多能比最差結果高出多少。win[move][piece]:單獨搜尋得出的勝率。
改動對局數勝-負-和得分率95% 誤差用時
分別搜尋10030-56-140.370±0.092.2×
分別搜尋,更長用時5017-26-70.410±0.137.0×
修復評估漏洞20091-101-80.475±0.071.0×
修復評估漏洞並重新調校20096-93-110.507±0.071.0×
得分是改動後的引擎對已上線引擎的得分,和棋算半分,所以 0.500 表示沒有變化。用時是已上線引擎用時的倍數。

分別搜尋在 2.2 倍用時下明顯輸了,在 7 倍用時下得分仍低於五五開。此後 AB-JChess 在 400 盤中以 248-136-16 戰勝了這個引擎,現在是最強的揭棋電腦。

最後兩行是另一個錯誤,與取平均無關:同一份審查發現,評估函式從未計入其中一方的暗子。我們當時修了它,再用 10,000 對 SPSA 對局重新調校它涉及的 43 項參數。兩項改動都沒有超出誤差範圍:200 盤的 ±0.07 大約是 ±50 Elo。

挑戰揭棋電腦揭棋規則