Day14 圍棋征子邏輯2
===
今天來補完昨天的征子邏輯特殊情況,其實對有在下棋的人來說可能也沒有那麼特殊,想看特別的歡迎看看[珍瓏題-明皇遊月宮勢](https://youtu.be/j8oSjZhyfUw?si=9vebunaGACN3e7Oz),從5分43秒左右開始是一個全棋盤的征子,特別有趣,講解者是我的好朋友,他的影片幹話很多,喜歡聽幹話的,歡迎訂閱邊學圍棋邊聽幹話XDD。
## 特殊情況
其實昨天看似沒問題的征子,還有一個小問題,當征子的路徑上有對方的棋子時,如下圖:
![](https://i.imgur.com/RaeOGY4.png)
![](https://i.imgur.com/6VJGOEv.png)
此時白並**不需要繼續逃跑,而是可以在 A 位吃掉黑棋**,也能將自己的氣變多,所以其實在防守方的走步要增加一個吃子的選項,這時候`stones`就能發揮作用了,不然昨天根本沒用到這個參數(不知道有沒有人有發現,感覺大家應該是都沒在看code的XDD),要**判斷自身周遭包圍自己的對方棋子有沒有只剩下1氣的,就可以將其吃掉**。
所以我們要將白棋的搜索範圍加大,不只是延長自己的氣,還要判斷自身周邊的所有黑棋,有沒有1氣的黑棋可以吃掉,畢竟吃棋也是延長氣的一種方式。
(有點像是在Threat Space Search中說到的,五子棋的活三迫著,對方的回應可以是去下死四,要記得把這些對方的反迫著加入搜索。)
## 實作
### 優化
昨天的`score` 就直接不用了,直接回傳True or False就好了,這樣會更乾淨一點,這邊新增了一個`get_new_board`用來更新棋盤狀態,不要改動原始棋盤,因為我們之後還要做一些額外的處理。
```python=
def is_ladder2(board, target, color):
if (board[target[0]][target[1]] == '.'):
return False
target_color = board[target[0]][target[1]] # 目標棋子顏色
opponent_color = 'X' if color == 'O' else 'O' # 對方顏色
stones, liberties = get_stones_and_liberties(board, target[0], target[1])
print_board(board)
# 進攻方 (max層)
if color != target_color:
if len(liberties) > 2:
return False # 目標大於2氣 失敗
if len(liberties) <= 1:
return True # 目標氣少於等於1 成功
for liberty in liberties:
new_board = get_new_board(board, liberty, color)
if is_ladder2(new_board, target, opponent_color):
return True
# 防守方 (min層)
if color == target_color:
if len(liberties) >= 2:
return False # 防守方有2個或以上的氣,逃脫成功
# TODO 找出1氣的對方棋子
for liberty in liberties:
new_board = get_new_board(board, liberty, color)
if is_ladder2(new_board, target, opponent_color):
return False
return True
return False
```
```python=
def get_new_board(board, move, color):
new_board = [row.copy() for row in board]
new_board[move[0]][move[1]] = color
return new_board
```
### 找出1氣的對方棋子
要找出吃子就要檢查對方的氣,要找出所有與我方棋子相鄰的敵方棋子的氣,看有沒有剩下1氣的棋子(先不考慮打劫的問題)。
```python=
for stone in stones:
for dx, dy in DIRECTIONS:
nx, ny = stone[0] + dx, stone[1] + dy
if is_in_bounds(nx, ny) and board[nx][ny] == opponent_color:
# 找到氣數為1的敵方棋子
_, opp_liberties = get_stones_and_liberties(board, nx, ny)
if len(opp_liberties) == 1:
# 如果發現有敵方棋子只有1個氣,直接返回 False
return False
```
這時又會再衍生出一個問題,如果有棋子被對方吃掉還能夠繼續征子嗎?
以上面的情況當然是不能,但如果是下圖的情況...
![](https://i.imgur.com/8YZg2jo.png)
就算白於 A 位提子...
![](https://i.imgur.com/QuMhMDo.gif)
黑棋仍然能將白棋給吃掉,但是此時要注意的是黑棋緊氣的方式,此時下圖 A 點是不能夠下的禁著點。
![](https://i.imgur.com/CGiXTVx.png)
禁著點:
因為黑方下在A點立刻就沒有氣了,就會馬上被提起來,在大部分的規則中這樣是被禁止的。但是如果有發生吃子的行為則是允許的(不考慮打劫的情況)。
### 加入吃子走步
所以我們不能在提子後就終止搜索,必須將吃子的走步加入搜索之中。
新增一個 `move` 用來存放所有逃跑走步,包含吃子走步。
```python=
# 防守方 (min層)
if color == target_color:
if len(liberties) >= 2:
return False # 防守方有2個或以上的氣,逃脫成功
moves = set(liberties)
# 找出與我方相鄰的敵方棋子,並且將1個氣的敵方棋子加入
for stone in stones:
for dx, dy in DIRECTIONS:
nx, ny = stone[0] + dx, stone[1] + dy
if is_in_bounds(nx, ny) and board[nx][ny] == opponent_color:
# 找到氣數為1的敵方棋子
_, opp_liberties = get_stones_and_liberties(board, nx, ny)
if len(opp_liberties) == 1:
moves.add(list(opp_liberties)[0])
for move in moves:
new_board = get_new_board(board, move, color)
if not is_ladder2(new_board, target, opponent_color):
return False
return True
```
自此我們就成功將吃子走步也給加入搜索了,但是用昨天最後給的範例跑出來,結果還是True,顯示征子成功,這是為什麼呢?
利用昨天提供的 `print_board` 將盤面給印出來會發現以下情況。
![image](https://hackmd.io/_uploads/SygFNcS0R.png)
這邊會發現死子沒有處理到,黑子被吃掉了但還是在棋盤上,導致算氣錯誤。
### 死子處理
這時前面新增的`get_new_board`這時就可以派上用場了,我們要判斷新增的走步,是否讓棋盤上有棋子沒「氣」了。
這邊還要注意到禁著點,進攻方在緊氣的時候是不能下在沒有氣且沒有吃子的地方(不考慮打劫的情況)。
```python=
def get_new_board(board, move, color):
new_board = [row.copy() for row in board]
new_board[move[0]][move[1]] = color
opponent_color = 'X' if color == 'O' else 'O'
captured = False # 用來檢查是否有吃子
def remove_if_no_liberties(x, y):
nonlocal captured
stones, liberties = get_stones_and_liberties(new_board, x, y)
if len(liberties) == 0:
for stone in stones: # 將所有無氣的棋子移除
new_board[stone[0]][stone[1]] = '.'
captured = True # 紀錄有吃子
# 檢查周圍四個方向,移除沒有氣的敵方棋子
for dx, dy in DIRECTIONS:
nx, ny = move[0] + dx, move[1] + dy
if is_in_bounds(nx, ny) and new_board[nx][ny] == opponent_color:
remove_if_no_liberties(nx, ny)
# 不考慮打劫的情況,只要有吃子就不算自殺。
stones, liberties = get_stones_and_liberties(new_board, move[0], move[1])
if len(liberties) == 0 and not captured: # 如果自己沒有氣且沒有吃子,則視為自殺
for stone in stones:
new_board[stone[0]][stone[1]] = '.'
return new_board
```
這樣修改完畢後,昨天最後的範例就可以被正確的判斷出來了,打劫的情況也加進去的話會過於複雜,都可以再寫一篇了,這樣我的主題大概會變成實作圍棋規則探討,所以還是到此為止吧。
## 結論
最一開始就連最頂級的圍棋AI如LeelaZero與絕藝等,都會有征子的bug(犯跑征子的低級失誤),征子對人類來說是相當簡單的直線計算,但其實它的深度是非常深的,如下圖,黑棋 A 要跑到底被白棋提光都已經快50手的事情了,就很容易產生Horizon Effect,如果程式沒有做特別的判斷應該都很難算這麼深。
![征子範例](https://hackmd.io/_uploads/S1nBAvH0R.jpg)
在KataGo出現後,將一些圍棋技巧的特徵加入訓練,詳細可見[KataGo論文](https://arxiv.org/pdf/1902.10565.pdf)中的 4.2 Game-specific Features,才將此狀況改善,詳細code在[nninnputs.cpp](https://github.com/lightvector/KataGo/blob/master/cpp/neuralnet/nninputs.cpp)中`iterLadders`,還有[board.cpp](https://github.com/lightvector/KataGo/blob/master/cpp/game/board.cpp)中的`searchIsLadderCaptured`,有興趣可以研究一下,可見其實征子也是很有學問的吧?!
而且我看了一下好像連Katago都沒有處理比較特別的征子情況,不過可能也是因為並不需要的關係吧。