在 強化學習實戰 | 表格型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演算法:
細節也就逐漸清晰了,我們要實現的目標如下:
- 維護記錄藍方上一狀態,動作及獎勵的變數組 lastState_blue,lastAction_blue,lastReward_blue;維護記錄紅方上一狀態,動作及獎勵的變數組 lastState_red,lastAction_red,lastReward_red。(要時刻注意,一方行動之後的狀態並不是自身的後繼狀態,而是進入對手的新狀態,只有當自身再次行動時,此時的狀態才是後繼狀態:S0blue → A0blue → S0red → A0red → S1blue → A1blue → S1red → A1red → S2blue → …,Q表中的狀態數是4520,大於這個數字說明程式碼一定是哪裡寫錯了)
- 構建一個 ε-greedy 策略函式 epsilon_greedy(env),並區分藍/紅方,紅方(陪練)動作時,把當前狀態翻轉。
- 構建一個往Q表格新增狀態的函式 addNewState(env), 並區分藍/紅方,紅方呼叫時,把當前狀態翻轉。
- 構建一個更新Q表格狀態價值的函式 updateQtable(env),可以選擇鎖定藍方視角:藍方行動前呼叫,也可以選擇藍方和紅方視角下都呼叫(紅方呼叫時需要翻轉狀態),可以想到,這樣的雙向呼叫在相同輪次內更新的狀態更多。另一個加快更新的方法是考慮等價的棋局,翻轉或旋轉運動可以創造等價的7個棋局(見下圖),分別是:旋轉90°,旋轉180°,旋轉270° ,垂直翻轉,水平翻轉,旋轉90°+垂直翻轉,旋轉90°+水平翻轉。雙向更新+等價棋局同步更新,這樣我們就能在一輪對局中更新 2×8=16 個Q值,大大提高了更新速度。
秉著“先跑通,再優化”的信條,先實現藍紅兩方的雙向更新,等價棋局更新這個任務就下次一定啦。整體程式碼如下:
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
測試
先跑個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值。