iT邦幫忙

2024 iThome 鐵人賽

DAY 8
0

大竹七段為什麼不早下這一著呢?我作為觀戰者,也等得不耐煩,覺著有點奇怪,最後產生了疑竇。他分明是故意不走嘛。他是嘔氣還是耍花招呢?這樣胡亂猜疑,也是有其理由的。

-- <名人>,川端康成著,葉渭渠譯

將棋當中最重要的就是做出決定。只有自己必須要從多樣的選點之中做出「就是這一手了」的決定。在攻破對方的王將與自己的王將被擒之間搖擺著的嚴酷局面,總之還是必須要負擔風險。這種情況下所做出的決斷,會顯露出一個人的本質。

-- <捨棄的力量>,羽生善治著,拙譯(目前無官方中譯版)

承昨日,我們導入了 Action,這麼一來,我們可以維持 Board 這個最大層級的資料結構在遊戲系統的大多數時候維持內容不變,從而在一個標準回合自始至終,只需要維護一個 Action 物件。然而我們仍然不算是解決了隨機代理人持續試誤回溯造成的效能低落的問題。

理論上,我們可以期待未來能夠訓練出一個對於這個遊戲有足夠概念的代理人,而它將隨著知識的長進逐漸有效率,不會一直愚蠢試誤。但,如果我們在那之前還需要和我們的隨機猴子相處好一段日子,我們將需要耗費可觀的時間與資源才有機會達到那個境界。雖然是題外話,但當我決定這個系列將以這種個別主題形式、而非過去習慣的依時間排序的寫作方式進行的時候,我心中最直接想到的大主題就是今天的內容。不是因為它的複雜性,而是整個 DeltaPathogen 專案裡我曾經以不同的理由迴避這些實作,但最後卻仍然在這個演進的遊戲系統框架之中被迫接受我需要實作這些功能的事實。這種反客為主的感覺,令人苦笑。

遊戲系統在面對試誤 -- 回復的迴圈時,需要將不可行的著手濾除,否則它豈不是會有機會一直進行無效的嘗試?但,當一個隨機猴子或很笨的代理人無路可走時,出錯的是它中間拐錯了彎嗎?還是羅盤階段選到了不可能走得出來的路徑?還是其實走得出來,只是棋盤角色二選一的情況也選錯了?該如何有效地做這個濾除的動作呢?這些是技術問題,但我想也該先陳述一下責任問題。

我的看法是這樣:下錯棋,大部份是玩家的責任。玩家不懂核心機制、沒有理解規則,所以錯誤。這個錯誤是相對於正確的,而不是相對於的。所以遊戲系統提供錯誤的理由(我們會在後續篇章描述狀態碼),玩家應該要承擔學習和理解的責任。但是,隨機猴子無論如何沒辦法承擔什麼責任,而且我一樣需要它參與在我的實驗之中且表現足夠有效率。

延伸參考,所謂的責任,最嚴重的就像是江戶時代日本將棋禮儀無口肚臍血溜まり那樣,對人棋局指指點點、妄加議論的人被斬首放置在棋盤上。次一些的就像是棄權之類,但這對於 AI 來說根本沒有對應的概念。

於是我受夠了。我決定讓玩家在標準回合開始時,就在遊戲系統內計算出所有可行的候選步數。之後的每一個階段無論代理人提供怎麼樣的座標選擇,遊戲伺服器就可以從系統端取得 candidate 資訊,快速決定是否需要接受這個著手。這個功能後來也持續證明它的方便性。

candidate 本身

設計理念是,以一個假想的羅盤階段選點出發,它所能夠引導的任何標準回合。之所以沒有連羅盤階段都決定,是因為我懷疑這樣會在執行階段耗費太多時間。

解析一下:一個標準回合中,最多選擇的階段,就是羅盤階段,少的時候雖然可能沒得選,但是多的時候,圍繞敵方標記的九宮格往往都很有可能是合法著手,所以在這個階段有 7、8 個選點一點都不奇怪。之後,棋盤階段,就一律只有兩個選點:選角色就是人界或冥界、走棋盤因為必須遵守最短路徑規則,所以也最多只有兩個方向可以選。再之後,標記階段,視路徑長度而定。

#[derive(Debug, Clone, PartialEq)]
pub struct Candidate {
    pub lockdown: Lockdown,
    pub character: Coord,
    pub trajectory: Vec<Coord>,
}

lockdown 是專屬於醫療方使用的羅盤階段輔助資訊,因為在封城期間,醫療方佔據城市中心掌握秩序,可以旋轉羅盤,挪動疫病方的標記到對自己有利的位置。承前,假想一個選點之後,就會依據它來計算可行的 candidate,同下面這一段 code,發生在稍早決定了羅盤選點之後,

        let mut possible_route: Vec<Vec<Direction>> = Vec::new();
        let is_lockdown = g.turn == Camp::Doctor && c == ORIGIN;
        self.find_route(&mut possible_route, is_lockdown);

find_route 函數會將所有可能的路徑計算出來。之後

        for (i, pr) in possible_route.iter().enumerate() {
            ...
            self.traverse(g, pr, lockdown_type, None);
        }

跳過關於封城的邏輯描述,這裡的核心在 traverse 函數。這可以說是我把疫途遊戲核心映射到 Rust 程式碼的嘗試當中,最複雜也最醜陋的部份。給定 g 遊戲盤面,還有每一個可能的路徑。需要特別澄清的是,這裡說 route 可能會讓人誤會,至少我在寫作此文並回顧程式碼的時候就誤會了。在棋盤上,合法的路徑應該是在同一個世界移動的路徑,但是這是 traverse 的工作;possible_route 裡面紀錄的只是單純組合數學上的展開,比方說如果羅盤階段結束,決定了接下來可走的方向是兩個上、一個右,那麼 pr 就會是展開的上上右、上右上、右上上。route 意義上不太符合。

traverse 函數非常難理解,但我簡單點評一下其中的關鍵:

        'next_character: for c in ca.iter() {
            ...
            temp_trajectory.push(ctemp);
            r_clone.reverse();
            'new_dir: while let Some(d) = r_clone.pop() {
                ctemp = ctemp + &d;
                while ctemp.in_boundary() {
                    ...
                }
                continue 'next_character;
            ....

ca 在函數早期確認這次的遍歷需要顧慮特定角色,或是兩個角色都要考慮,在這裡使用 'next_character 的具名迴圈,總之整個迴圈內部的邏輯對於不同的角色是一致的。之所以要具名,是因為在內部走一走如果走不通,就可以決定直接在這個迴圈的層級重來,也就是如果人界的角色走不通這個上右上,那就直接換冥界角色試試的意思。回顧一下,傳入的 pr(可能的路徑,方向的序列,如上右上),在這裡有 r 或是 r_clone 等等的別名,其中差異不需深究;更重要的是 ctemp 用來紀錄座標,每走通一個方向,就會計算出對應的 ctemp,並檢查它的合法性,合法就會紀錄到一個暫時路徑 temp_trajectory。這個暫時路徑一開始就從所選擇的角色 c 計算出它的座標,並在 temp_trajectory.push(ctemp); 紀錄下這個合法路徑的第一個座標,也就是所選定的角色原始所在之處。

所謂走一走方向,在程式碼中我定義了作用在座標物件上的加法,所以像是 ctemp = ctemp + &d; 這行,他的意思是(右手邊的)ctemp 座標加上剛從可能的路徑中取出來(let Some(d) = r_clone.pop())的方向 &d 之後,即可以產生一個新的座標(左手邊的)ctemp。這個加法定義在 grid_coord.rs 裡面,茲舉一小段:

impl Add<&Direction> for Coord {
    type Output = Coord;

    fn add(self, d: &Direction) -> Coord {
        match *d {
            Direction::Up => Coord {
                x: self.x,
                y: self.y - 1,
            },
...

in_boundary() 就是邊界檢查而已,包在一個新的迴圈裡面是因為,我們稍早的移動未必會落在同樣的世界,所以內部還會持續沿著 &d 方向移動,直到該角色落腳在同一個世界為止,所以還會持續移動。而如果因為任何理由,計算出來的座標 ctemp 因為沒有在合法棋盤範圍內而自然離開這個迴圈,就只好觸發 continue 'next_character;,從最上層的選擇角色開始。

while ctemp.in_boundary() 迴圈內部的邏輯簡化後是這樣:

                    match g.env.get(&ctemp) {
                        Some(x) => {
                            if *x == *w {
                                temp_trajectory.push(ctemp);
                                continue 'new_dir;
                            } else {
                                ctemp = ctemp + &d;
                                continue;
                            }
                        }
                        None => {
                            break 'new_dir;
                        }

g.env 遊戲盤面中取得的 *x 資訊是這個座標的世界(人間或冥界),而 *w 是這個角色所屬的世界,所以當進到這個 if,就已經確定這個 ctemp 是一個合法的行進,因此加到 temp_trajectory 裡面,並且可以嘗試下一個方向(continue 'new_dir;);反之,如果世界不同,那就該繼續行進。另外,如果已經計算到一個座標,竟然無法提取出世界的資訊,那也很明顯就是先前的計算,得出了一個不在棋盤合法範圍的值,只好離開 'new_dir 迴圈,畢竟也沒有什麼資格再從可能的方向列表中選擇方向出來走了。

最後的部份,是在 'new_character 迴圈內,但在 'new_dir 迴圈之後,代表的是走完了,或不走了的部份的處理,

            if r_clone.is_empty() {
                ...
                // Yes, we find a real viable route here
                let mut candidate = Candidate::new();
                candidate.character = *c;
                candidate.lockdown = ld;
                candidate.trajectory = temp_trajectory;
                self.candidate.push(candidate);
            }

如果可能的方向列表r_clone)被清空了,那就表示先前的邏輯把它走完了,成功了。這個路線也就可以紀錄成一個候選標準回合。反之,就無事可作,如果有下一個角色可以試,就試試吧。

後來的延伸 1:random_move

可能系列文後續還會提到一個早期的神經網路實驗時需要的準備,就是需要評估任何給定的盤面的勝率。如果只是用隨機猴子連線,當然也可以,但當時我也準備了另外這條路,隨機產生標準回合、自動遊玩。這個函數只需要補齊一個前置作業。稍早提到,candidate 機制是給定羅盤階段已經完成為前提,所以這裡需要補完前面這一段:

        for i in -MAP_OFFSET.x + 1..MAP_OFFSET.x {
            for j in -MAP_OFFSET.y..=MAP_OFFSET.y {
                coord_candidate.push(Coord::new(i, j));
            }
        }
        for j in -MAP_OFFSET.y + 1..MAP_OFFSET.y {
            coord_candidate.push(Coord::new(-MAP_OFFSET.x, j));
            coord_candidate.push(Coord::new(MAP_OFFSET.x, j));
        }

第一個雙層迴圈是包了 5x3 的區域((-2, -1)~(2, 1)),第二個迴圈則是納入上面的三個與下面的三個。之後直接使用 self.add_map_step 觸發。

後來的延伸 2:QUERY

前幾日介紹的隨機猴子也以這個機制進化了,不需要反覆試誤,而是從伺服器端會在接收到 QUERY 特殊指令的時候,傳回候選選點。

後來的延伸 3:斷線即加速

撰寫延伸 1 的時候提醒了我,很多實驗的 baseline 應該可以直接用 random_move 生成?比方說是要檢驗新的疫病方的模型訓練得如何,那醫療方可以接隨機猴子去對局,理論上也可以在伺服器端動一些手腳,讓它知道怎麼用 random_move 方法來生成醫療方的回合就好。

這個想法直接造成了這個改動。在初步測試的時候也非常興奮,在我當時的工作筆記上紀錄的最誇張的一種情境下,對局雙方使用 Query 機制強化的隨機猴子需要兩分鐘的對局時間,但是雙方改用 random_move,只需要 0.3 秒。這是 400 倍的加速啊!!!

同上述引用的 731 行處,原本在遭遇斷線的時候,就是直接用 panic 來中斷程式;現在,則可以透過小巧的 nc -zv 127.0.0.1 6241 & nc -zv 127.0.0.1 3698 製造出瞬間連線(TCP 交握完成,讓 coord_server 伺服器脫離等待的狀態)加上直接斷線(nc 指令 -z 不會傳送任何資料,也因此不會影響到遊戲系統的任何狀態)的效果。這當然是懶人作法,但要為了這個新增更多組態或是命令列指令,我也不甚願意。

總之,啟用之後,還是發現了一些問題。為了將這個自動生成隨機步的功能正式化上了一些 patch 修補之後,本來就想直接投入實戰,但投入了之後才悲傷的發現,我自己記憶體用得太兇,竟然兇到會熱當死當,就是系統 OOM 也未必能夠把整台電腦恢復回來的程度;這樣強制關機三次之後,我就很少在大規模的對局模擬之中使用這套機制了,雖然真的很快,但,還是得限制一下 Memory 的上限,或是應該挖掘出 leak 的部份怎麼回給系統。

目前狀況

承前日,殘局譜訓練出來的效果還不錯。目前做了三組互相對照:

  1. 以一般蒙地卡羅模擬對局所收集的 180000 次著手做訓練集,再以殘局譜 12000 次著手做驗證集。
  2. 以殘局譜 180000 次著手做訓練集,再以一般蒙地卡羅模擬對局所收集的 12000 次著手做驗證集。
  3. 兩個集合都採用兩種來源 1:1 的混合的結果。

對應的訓練曲線與測試評估曲線如圖(紅色是 1,淺藍是 2,深藍色是 3):

https://ithelp.ithome.com.tw/upload/images/20240908/2010352449NHTIIdAw.png

https://ithelp.ithome.com.tw/upload/images/20240908/20103524Qm0FF034Ek.png

暫且岔題。由於參賽首日,一時興起,總覺得自己囤積文章直接貼出的行為不甚踏實,因此新增目前狀況的每日更新欄位,讓時間軸略顯錯亂。這後果就是,有些後續才能夠介紹到的概念不得不提前使用,但又還無法詳細解說的狀況。在此致歉。若讀者有任何疑問,盡可在下方留言,作為我後續發文的參考,或是若情況允許,我也可以直接回應。

然後,由於目前我都針對疫病方在訓練,所以最真實的評估方法就是與醫療方的代理人對局。當然,我目前也只有隨機猴子可以代勞。因此有上述三個代理人(全部都是從頭訓練的,沒有延續之前的訓練結果)與醫療隨機猴子的對局,以及雙方隨機對局的第四種對戰組合當作參考。由於我每一個訓練回合都會儲存模型權重,所用來對局的權重是出現在測試評估曲線低點的版本的模型。

這四組實驗,採用不存在訓練對局當中的設置局面,共 100 組設置,展開 300 場的對局。醫療方的勝率如下:

  1. 雙方隨機的基準對局:55.67%。我想這也是我亟欲與設計師交流想法的一個現象,就是在我現在只有隨機或是能力趨近隨機的自動代理人,而讓他們自己對局的情況下,往往我所觀察到的是,醫療方的勝率是比較高的。這與設計師團隊在規則中的平衡修正方向,是不一致的,然而,我相信設計師團隊是收集了諸多玩家回饋而做出的平衡修正。這也導致一個有趣的推論,即隨機的遊玩無法反應有策謀的代理人(如抽象棋愛好者)的分佈。雖然這是理所當然的,但能夠見諸實證,還是很有趣的。除了我的觀察與設計師的規則修正的兩組對照之外,我自己也會在後續介紹一個遊戲系統的更動,如何導致隨機對局的結果之不同。
  2. 一般訓練、殘局驗證:52.33%。疫病方看起來是強一點點,但是 3% 往往還在誤差範圍內。慚愧學藝不精,統計學實在應該好好建立一下概念...
  3. 殘局訓練、一般驗證:46.67%。振奮!其實,試圖執行 AlphaZero 強化迴圈至今,一直沒有看過這樣的效果。所以現在也開始逸離原先設定的 AlphaZero 目標了,但我想,做出一個有點能力的代理人還是比較重要的結果。
  4. 混合訓練、混合驗證:48.00%。也是很不錯的樣子!

之後的目前狀況小節,也還是得持續煩惱每日的進展。該做的事情還很多。


上一篇
定義 `Action` 物件
下一篇
狀態碼 (1/2 ),以及原地踏步
系列文
DeltaPathogen:國產雙人不對稱抽象棋「疫途」之桌遊 AI 實戰30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言