強化學習實戰 | 表格型Q-Learning玩井字棋(二)

埠默笙聲聲聲脈發表於2021-12-09

強化學習實戰 | 表格型Q-Learning玩井字棋(一)中,我們構建了以Game() 和 Agent() 類為基礎的框架,本篇我們要讓agent不斷對弈,維護Q表格,提升棋力。那麼我們先來盤算一下這幾個問題:

  • Q1:作為陪練的一方,策略上有什麼要求嗎?
  • A1:有,出棋所導致的狀態要完全覆蓋所有可能狀態。滿足此條件下,陪練的棋力越強(等同於環境越嚴苛),agent訓練的效果越好。AlphaGo的例子告訴我們,陪練的策略也是可以分階段調整的:前期先用人類落子的預測模型當陪練,中後期讓agent自我博弈。在井字棋的例子中,環境較簡單,可以直接讓agent自我博弈,採用 ε-greedy 策略(貪心地選擇Q值最大的動作執行,並以 ε 的概率試探其他的動作)即可實現可能狀態的覆蓋。
  • Q2:採用自我博弈的方式,也就意味著,在陪練動作前,也要呼叫Q表格,是嗎?
  • A2:是,不僅是呼叫,如果當前狀態不在Q表格中,還要往Q表格中新增狀態,否則無法將執行 ε-greedy 策略。
  • Q3:而且陪練動作之後,還要更新Q表格?
  • A3:是。
  • Q4:環境的定義是以一方的視角分配獎勵的,對於陪練來說,不能簡單地呼叫Q表格進行決策吧?假設agent是藍方,陪練是紅方,直接用Q表進行決策,那麼紅方就是以自身落藍字進行考慮的,所考慮的狀態完全是非法的——例如場上僅有一個藍子,此刻又是待落藍子。
  • A4:對,不能簡單呼叫!在陪練動作前,要把視角翻轉——如果當前狀態是 [1, -1, 0, 0, 0, 0, 0, 0, 1],翻轉就是把當前狀態視作 [-1, 1, 0, 0, 0, 0, 0, 0, -1],再考慮自身如何落藍子。

再回想一下Q-Learning演算法:

細節也就逐漸清晰了,我們要實現的目標如下:

  1. 維護記錄藍方上一狀態,動作及獎勵的變數組 lastState_bluelastAction_bluelastReward_blue;維護記錄紅方上一狀態,動作及獎勵的變數組 lastState_red,lastAction_redlastReward_red。(要時刻注意,一方行動之後的狀態並不是自身的後繼狀態,而是進入對手的新狀態,只有當自身再次行動時,此時的狀態才是後繼狀態:S0blue → A0blue → S0red → A0red → S1blue  → A1blue  → S1red →  A1red  → S2blue  …,Q表中的狀態數是4520,大於這個數字說明程式碼一定是哪裡寫錯了) 
  2. 構建一個 ε-greedy 策略函式 epsilon_greedy(env),並區分藍/紅方,紅方(陪練)動作時,把當前狀態翻轉。
  3. 構建一個往Q表格新增狀態的函式 addNewState(env), 並區分藍/紅方,紅方呼叫時,把當前狀態翻轉。  
  4. 構建一個更新Q表格狀態價值的函式 updateQtable(env),可以選擇鎖定藍方視角:藍方行動前呼叫,也可以選擇藍方和紅方視角下都呼叫(紅方呼叫時需要翻轉狀態),可以想到,這樣的雙向呼叫在相同輪次內更新的狀態更多。另一個加快更新的方法是考慮等價的棋局,翻轉或旋轉運動可以創造等價的7個棋局(見下圖),分別是:旋轉90°,旋轉180°,旋轉270° ,垂直翻轉,水平翻轉,旋轉90°+垂直翻轉,旋轉90°+水平翻轉。雙向更新+等價棋局同步更新,這樣我們就能在一輪對局中更新 2×8=16 個Q值,大大提高了更新速度。  

 秉著“先跑通,再優化”的信條,先實現藍紅兩方的雙向更新,等價棋局更新這個任務就下次一定啦。整體程式碼如下:

強化學習實戰 | 表格型Q-Learning玩井字棋(二)
import gym
import random
import time

# 檢視所有已註冊的環境
# from gym import envs
# print(envs.registry.all()) 

def str2tuple(string): # Input: '(1,1)'
    string2list = list(string)
    return ( int(string2list[1]), int(string2list[4]) ) # Output: (1,1)

class Game():
    def __init__(self, env):
        self.INTERVAL = 0 # 行動間隔
        self.RENDER = False # 是否顯示遊戲過程
        self.first = 'blue' if random.random() > 0.5 else 'red' # 隨機先後手
        self.currentMove = self.first
        self.env = env
        self.agent = Agent()
    
    def switchMove(self): # 切換行動玩家
        move = self.currentMove
        if move == 'blue': self.currentMove = 'red'
        elif move == 'red': self.currentMove = 'blue'
    
    def newGame(self): # 新建遊戲
        self.first = 'blue' if random.random() > 0.5 else 'red'
        self.currentMove = self.first
        self.env.reset()
        self.agent.reset()
    
    def run(self): # 玩一局遊戲
        self.env.reset() # 在第一次step前要先重置環境,不然會報錯
        while True:
            print(f'--currentMove: {self.currentMove}--')
            self.agent.updateQtable(self.env, self.currentMove, False)
            
            if self.currentMove == 'blue':
                self.agent.lastState_blue = self.env.state.copy()
            elif self.currentMove == 'red':
                self.agent.lastState_red = self.agent.overTurn(self.env.state) # 紅方視角需將狀態翻轉
                
            action = self.agent.epsilon_greedy(self.env, self.currentMove)
            if self.currentMove == 'blue':
                self.agent.lastAction_blue = action['pos']
            elif self.currentMove == 'red':
                self.agent.lastAction_red = action['pos']
            
            state, reward, done, info = self.env.step(action)   
            if self.currentMove == 'blue':
                self.agent.lastReward_blue = reward
            elif self.currentMove == 'red':
                self.agent.lastReward_red = -1 * reward
            
            if done: self.agent.updateQtable(self.env, self.currentMove, True)
            
            if self.RENDER: self.env.render()
            self.switchMove()
            time.sleep(self.INTERVAL)
            if done:
                self.newGame()
                if self.RENDER: self.env.render()
                time.sleep(self.INTERVAL)
                break
                    
class Agent():
    def __init__(self):
        self.Q_table = {}
        self.EPSILON = 0.05
        self.ALPHA = 0.5
        self.GAMMA = 1 # 折扣因子
        self.lastState_blue = None
        self.lastAction_blue = None
        self.lastReward_blue = None
        self.lastState_red = None
        self.lastAction_red = None
        self.lastReward_red = None
    
    def reset(self):
        self.lastState_blue = None
        self.lastAction_blue = None
        self.lastReward_blue = None
        self.lastState_red = None
        self.lastAction_red = None
        self.lastReward_red = None
    
    def getEmptyPos(self, env_): # 返回空位的座標
        action_space = []
        for i, row in enumerate(env_.state):
            for j, one in enumerate(row):
                if one == 0: action_space.append((i,j)) 
        return action_space
        
    def randomAction(self, env_, mark): # 隨機選擇空格動作
        actions = self.getEmptyPos(env_)
        action_pos = random.choice(actions)
        action = {'mark':mark, 'pos':action_pos}
        return action
    
    def overTurn(self, state): # 翻轉狀態
        state_ = state.copy()
        for i, row in enumerate(state_):
            for j, one in enumerate(row):
                if one != 0: state_[i][j] *= -1
        return state_
    
    def addNewState(self, env_, currentMove): # 若當前狀態不在Q表中,則新增狀態
         state = env_.state if currentMove == 'blue' else self.overTurn(env_.state) # 如果是紅方行動則翻轉狀態
         if str(state) not in self.Q_table:
             self.Q_table[str(state)] = {}
             actions = self.getEmptyPos(env_)
             for action in actions:
                 self.Q_table[str(state)][str(action)] = 0
        
    def epsilon_greedy(self, env_, currentMove): # ε-貪心策略
        state = env_.state if currentMove == 'blue' else self.overTurn(env_.state) # 如果是紅方行動則翻轉狀態
        Q_Sa = self.Q_table[str(state)]
        maxAction, maxValue, otherAction = [], -100, [] 
        for one in Q_Sa:
            if Q_Sa[one] > maxValue:
                maxValue = Q_Sa[one]
        for one in Q_Sa:
            if Q_Sa[one] == maxValue:
                maxAction.append(str2tuple(one))
            else:
                otherAction.append(str2tuple(one))
        
        try:
            action_pos = random.choice(maxAction) if random.random() > self.EPSILON else random.choice(otherAction)
        except: # 處理從空的otherAction中取值的情況
            action_pos = random.choice(maxAction) 
        action = {'mark':currentMove, 'pos':action_pos}
        return action
    
    
    def updateQtable(self, env_, currentMove, done_):
        
        judge = (currentMove == 'blue' and self.lastState_blue is None) or \
                (currentMove == 'red' and self.lastState_red is None)
        if judge: # 邊界情況1:若agent無上一狀態,說明是遊戲中首次動作,那麼只需要新增狀態就好 無需更新Q值
            self.addNewState(env_, currentMove)
            return
                
        if done_: # 邊界情況2:若當前狀態S_是終止狀態,則無需把S_新增至Q表格中,並直接令maxQ_S_a = 0
            S = self.lastState_blue  if currentMove == 'blue' else self.lastState_red
            a = self.lastAction_blue if currentMove == 'blue' else self.lastAction_red 
            R = self.lastReward_blue if currentMove == 'blue' else self.lastReward_red
            print('lastState S:\n', S)
            print('lastAction a: ', a)
            print('lastReward R: ', R)
            maxQ_S_a = 0
            self.Q_table[str(S)][str(a)] = (1 - self.ALPHA) * self.Q_table[str(S)][str(a)] \
                                            + self.ALPHA * (R + self.GAMMA * maxQ_S_a)
            print('Q(S,a) = ', self.Q_table[str(S)][str(a)])
            return
          
        # 其他情況下:Q表無當前狀態則新增狀態,否則直接更新Q值
        self.addNewState(env_, currentMove)
        S_ = env_.state if currentMove == 'blue' else self.overTurn(env_.state)
        S = self.lastState_blue  if currentMove == 'blue' else self.lastState_red
        a = self.lastAction_blue if currentMove == 'blue' else self.lastAction_red 
        R = self.lastReward_blue if currentMove == 'blue' else self.lastReward_red
        Q_S_a = self.Q_table[str(S_)]
        maxQ_S_a = -100 
        for one in Q_S_a:
            if Q_S_a[one] > maxQ_S_a:
                maxQ_S_a = Q_S_a[one]
        print('lastState S:\n', S)
        print('State S_:\n', S_)
        print('lastAction a: ', a)
        print('lastReward R: ', R)
        self.Q_table[str(S)][str(a)] = (1 - self.ALPHA) * self.Q_table[str(S)][str(a)] \
                                        + self.ALPHA * (R + self.GAMMA * maxQ_S_a)
        print('Q(S,a) = ', self.Q_table[str(S)][str(a)])
        print('\n')
                                            
env = gym.make('TicTacToeEnv-v0')
game = Game(env)
for i in range(100000):
    print('episode', i)
    game.run()
Q_table = game.agent.Q_table
View Code

測試

先跑個10萬局遊戲,看看大體的趨勢對不對。

專案1:檢視Q表格的狀態數

比4520要小——這是可以理解的,因為agent有1- ε的大概率選擇Q值高的動作,僅有 ε的概率(我設定的是5%)會嘗試別的動作。要訪問到餘下的狀態,可以加大 ε 讓agent多嘗試,或是乾脆讓陪練隨機動作,或是設定等價棋局同步更新以提高訓練速度。

專案2:檢視初始狀態

每個動作的Q值應該都差不多,而且應該呈現出對稱性

與預測不完全一致,畢竟(0,0)與(2,2)動作後是等價局面,二者Q值應該接近,但也可以接受,畢竟沒有進行等價棋局的同步更新。

專案3:檢視早期狀態

早期狀態應該能顯示出某些動作具有相對低的Q值——“走這一步你大概就輸了”。

 因為有學習率(學習率小於1。若大於1,Q值會發散)的存在,Q值只會無限接近1,這裡顯示成了1,說明無限接近到float也顯示不出來了,(1,0)是必勝的一步,而選擇(1,1)約等於自殺。

專案4:檢視中後期的狀態

中後期的狀態應該能顯示出某些動作是必贏 / 必輸的。

“走哪兒都輸”:

 “走這一步,必贏”:

 走(0,0)確實是必贏的,你看出來了嗎?

小結

測試過後,大體的趨勢沒有問題,但我在翻查Q表格的時候發現,其實很多狀態都沒有更新,Q表格也沒有覆蓋所有合法狀態。還有,所有Q值都是正的——原因是當遊戲結束時,我只給勝方更新Q值,而沒有給敗方懲罰。雖然不影響決策,但顯然不好,這等同於將平局與戰敗一視同仁。這些問題,就留到下一節來解決吧!

說一說踩的坑

  • 本以為翻轉狀態可以直接優雅地 state =  -1 * state,但轉換成字元判定時卻遇到了問題,因為numpy.array的元素 0 * -1 = - 0,轉換成字串後 '0' 不等於 '-0'!解決方法:只更改 1 和 -1。
  • 深拷貝與淺拷貝的問題,self.agent.lastState_blue = self.env.state,不是直接賦值,而是賦予了 self.env.state 的引用,所以 self.agent.lastState_blue 的值是變化的,然後導致一系列的錯誤。解決方法:self.agent.lastState_blue = self.env.state.copy()。
  • 有兩個邊界情況要注意,一是首次動作時沒有上一狀態;二是動作後若遊戲結束,要直接更新Q值。

相關文章