強化学習で倒立振子(棒を立て続ける)制御を実現する方法を実装・解説します。
本回ではQ学習(Q-learning)を使用します。
本記事では最初に倒立振子でやりたいことを説明し、その後、強化学習とQ学習について解説を行います。
最後に実装コードを示し、コードを解説します。
倒立振子(cartPole)とは
まずは動画をごらんください。
小学生のころ、ほうきを手のひらで立てて遊んだと思いますが、あれです。
一般に倒立振子問題と呼びます。
これを実行する環境が、Open AI GymというライブラリのCartPoleとして用意されています
今回はこのcartPoleを使用して、強化学習を勉強します。
Open AI Gymを利用するために、
1 | sudo pip install gym |
を実行しておきます(Ubuntu環境)。
やりたいこと
実装に移る前に、そもそもやりたいことを整理します。
まず倒立振子の状態(State)は
- カート位置 -2.4~2.4
- カート速度 -3.0~3.0
- 棒の角度 -41.8~41.8
- 棒の角速度 -2.0~2.0
の4変数で表されます。
棒が20.9度以上傾いたり、カート位置が±2.4以上移動すると失敗となります。
そして実際にとることができる行動(Action)は
- カートを右に押す
- カートを左に押す
の2通りです。
カートに対して右か左に加速度を与える操作を行います。
状態sに応じて、うまく行動aをとり、棒を立て続けることが目的です。
今回の場合200stepの間、立て続ければ成功です。
つまり
a_t=A(s_t) ※時刻tにおいて状態sのときに最適な行動aを返す関数A
を求めることがゴールとなります。
強化学習
前節で紹介したような問題を解くことを「強化学習」と呼びます。
強化学習は「教師あり学習」とも「教師なし学習」とも異なります。
もし各状態sでどの行動aをすれば良いよって教えてくれる正解データがあれば教師あり学習です。
ですが、そのような正解データはありません。
では教師なし学習かというと少し違います。
というのも、何施行か繰り返していて、棒が200step立ち続ければそれは成功であり、ある意味教師データのような存在を生み出すことができます。
このように、逐一の行動の正解は与えられていないが、最終的なゴールが与えれれていて、それを実現するための方法を学習する枠組みを強化学習と呼びます。
強化学習の解き方
強化学習で最適な行動を学習するには様々な手法があります。
本記事では最も代表的なQ学習(Q-learning)を解説・実装します。
Q学習では、各状態sで最適な行動aを与える関数A(s)を求める代わりに、各状態sで各行動aでこの先どの程度の報酬がトータルでもらえるのかR(t)で示す
行動価値関数 Q(s_t,a_t)=R(t)
を求めます。
R(t)が分かりにくいですが、これは時刻tで状態がs_tであった場合に、行動a_tをとった場合に、時刻t+1でもらえるであろう報酬r_{t+1}、そしてその後ももらえるであろう、r_{t+2}+・・・の合計を示す関数です。
※今は割引率は無視
実際には2次元の表で表され、行方向が様々な状態s、列方向がとりうる行動aになり、各マスにそれぞれの場合の報酬が格納されます。
そして、このQ関数で報酬が最大の行動a_tを取り続けるという作戦で、棒を立て続けます。
ここで「各状態」と「報酬」という2つの言葉がでてきました。
まず「各状態」から説明します。
今回の倒立振子で状態は、カートの位置など、4変数で表され、各変数は連続値です。
そのため、表を作るために離散化します。
本記事では各変数を6分割し、6^4の1296状態を定義します。
よってQ関数は[1296×2]の行列(表)で表されます。
(2は選択可能な行動で、右に押すか左に押すかを表します)
なお、Q関数で連続値を扱えるように、表ではなく、きちんと関数で表す方法もあります。
またQ関数をディープラーニング・ニューラルネットワークで示すDQN(Deep Q-Network)と呼ばれる方法もあります。
今回は簡単な表形式のQ関数を使用します。
つぎに「報酬」について説明します。
強化学習ではこの「報酬」が非常に重要な要素となっています。
強化学習は報酬を最大化する方向へQ関数を学習します。
そのため、「200ステップ立ち続ける」、もしくは「各ステップで立っている」と報酬を与えます。
一方で、こけたりすると、マイナスの報酬(罰則)を与えます。
この報酬が1試行(200step)を通して、最大化できるQ関数を学習します。
では実際にどうQ関数を学習するか説明します。
Q関数の学習方法
Q関数の学習方法はSARSAやモンテカルロ法、Q学習などがあります。
脳の研究では実際の生物の学習方法が、SARSAと類似しているという報告などもあります。
本記事ではQ学習を説明します。
例えばt=99で、a_99の行動をとり、t=100でこけたとします。
するとt=99での行動a_99はきっと悪かったから、Q(s_99, a_99)には悪い報酬を格納します。
※s_99は1296状態のどれか、a_99は右か左かに押す行動を示します。
ですが、t=99だけでなく、t=98での行動や状態もきっと悪かったと思われます。
t=98までは良かったのに、t=99での1回の行動でこけたとは思えません。
つまりQ(s_98, a_98)にも、こけたときの罰則(マイナスの報酬)を与えたいところです。
※t=98だけでなく、t=97以前にも
とはいえ、t=98や、それ以前の、t=97のQ(s_97, a_97)の気持ちになると、
「いや、ちょっと待てよ。
俺も悪いかもしれないよ。
でもQ(99)ほど俺が悪いなんて、ひどくね。
最後に倒したのはQ(99)であって、俺の後はまだ棒は立ってたわけだし・・・
ちょっとくらい勘弁してくれよ」
って気持ちです。
そこで、勘弁してあげるために、割引率γという変数を用意してあげます。
γは1より小さい値で、未来(t=99)での罰則がt=97までつながるときに、罰則を割り引いて与えます。
t=97の場合、罰則がγ^2だけ小さくなり、勘弁してあげます。
ここまで罰則(マイナスの報酬)的な書き方で書いてきましたが、プラスの報酬でも同じです。
※ちなみに脳科学では割引率γはセロトニンという神経修飾物質が関わっているのではという説があります。
このセロトニンが少ない人は極端に未来の報酬や罰則を割り引いて考えるため、長期的な計画が苦手で目先の利益で行動が決定されてしまうという報告があります。
以上の気持ちを実装してあげると、Q関数の学習は
Q(s_t, a_t) ← Q(s_t, a_t) + α(r_{t}+γMAX{Q(s_{t+1}, a_{t+1})} – Q(s_t, a_t))
と表されます。
MAX{Q(s_{t+1}, a_{t+1})}は、次の時間t+1から先にもらえる報酬合計の最大値です。
αは学習率です。更新の大きさを決定します。
(αは記憶の更新のようなものであり、人間の脳では、アセチルコリンと関係があるのではと言われています)
この式が言いたいことは、
時刻tで状態s_tであったときに、行動a_tを取ったときにその後得られる報酬の合計R(t)を与える関数Q(s_t, a_t)は、実際に時刻tでもらった報酬r_{t}と、そのさきにもらえるであろう報酬R(t+1)の最大値であるMAX{Q(s_{t+1}, a_{t+1})}に割引率γを掛けた値、の和で表される。
その値に近づくように少しずつ更新しよう♪
ってことです。
とはいえ、ここで問題が生じます。
時刻tで状態s_tであった場合に、いつもQ(t)が最大となるa_tを選択していては、一部のQ関数しか学習できない問題です。
これを、「探索と利用のジレンマ」と呼びます。
探索と利用のジレンマの解決策
「探索と利用のジレンマ」を解決する方法のひとつがε-greedy法です。
これは確率ε以下の場合はランダムなa_tを選択し、ε以上のときはQ(t)を最大化するa_tを利用するという方法です。
ただし、ずっと探索しているといつまでも行動が安定しないので、εはε_0*(1/episode)と表し、試行回数が増えるにしたがい、探索行動が減るようにするのが一般的です。
なお、(1-ε)を逆温度βと呼びます。
またこの逆温度βは脳ではノルアドレナリンによってコントロールされているのかという説があります。
以上の要素を踏まえて実装を行います。
実装には以下のサイトを参考にし、改変を加えています。
Q学習によるCartPoleの実装例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | # coding:utf-8 # [0]ライブラリのインポート import gym #倒立振子(cartpole)の実行環境 from gym import wrappers #gymの画像保存 import numpy as np import time # [1]Q関数を離散化して定義する関数 ------------ # 観測した状態を離散値にデジタル変換する def bins(clip_min, clip_max, num): return np.linspace(clip_min, clip_max, num + 1)[1:-1] # 各値を離散値に変換 def digitize_state(observation): cart_pos, cart_v, pole_angle, pole_v = observation digitized = [ np.digitize(cart_pos, bins=bins(-2.4, 2.4, num_dizitized)), np.digitize(cart_v, bins=bins(-3.0, 3.0, num_dizitized)), np.digitize(pole_angle, bins=bins(-0.5, 0.5, num_dizitized)), np.digitize(pole_v, bins=bins(-2.0, 2.0, num_dizitized)) ] return sum([x * (num_dizitized**i) for i, x in enumerate(digitized)]) |
[0] 最初に使用するライブラリをインポートします。
[1] Q関数を状態変数を離散化した表・テーブルで表現します。
cartPoleで観測した状態変数を離散値に変換するメソッドを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | # [2]行動a(t)を求める関数 ------------------------------------- def get_action(next_state, episode): #徐々に最適行動のみをとる、ε-greedy法 epsilon = 0.5 * (1 / (episode + 1)) if epsilon <= np.random.uniform(0, 1): next_action = np.argmax(q_table[next_state]) else: next_action = np.random.choice([0, 1]) return next_action # [3]Qテーブルを更新する関数 ------------------------------------- def update_Qtable(q_table, state, action, reward, next_state): gamma = 0.99 alpha = 0.5 next_Max_Q=max(q_table[next_state][0],q_table[next_state][1] ) q_table[state, action] = (1 - alpha) * q_table[state, action] +\ alpha * (reward + gamma * next_Max_Q) return q_table |
[2] 次の状態s(t+1)で右に動かすべきか、左に動かすべきか、Q関数の大きい方を選びます。
ただし、徐々に最適行動のみをとる、ε-greedy法にします。
基本的には報酬が最大となる行動を選択しますが、ときおりランダムな行動をとります。
[3] Q関数を更新するメソッドを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # [4]. メイン関数開始 パラメータ設定-------------------------------------------------------- env = gym.make('CartPole-v0') max_number_of_steps = 200 #1試行のstep数 num_consecutive_iterations = 100 #学習完了評価に使用する平均試行回数 num_episodes = 2000 #総試行回数 goal_average_reward = 195 #この報酬を超えると学習終了(中心への制御なし) # 状態を6分割^(4変数)にデジタル変換してQ関数(表)を作成 num_dizitized = 6 #分割数 q_table = np.random.uniform( low=-1, high=1, size=(num_dizitized**4, env.action_space.n)) total_reward_vec = np.zeros(num_consecutive_iterations) #各試行の報酬を格納 final_x = np.zeros((num_episodes, 1)) #学習後、各試行のt=200でのxの位置を格納 islearned = 0 #学習が終わったフラグ isrender = 0 #描画フラグ |
[4] ここからメインのプログラムが開始します。
はじめに各パラメータを定義します。
また状態を離散値にして、[1296×2]の行列(表)形式のQ関数を作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | # [5] メインルーチン-------------------------------------------------- for episode in range(num_episodes): #試行数分繰り返す # 環境の初期化 observation = env.reset() state = digitize_state(observation) action = np.argmax(q_table[state]) episode_reward = 0 for t in range(max_number_of_steps): #1試行のループ if islearned == 1: #学習終了したらcartPoleを描画する env.render() time.sleep(0.1) print (observation[0]) #カートのx位置を出力 # 行動a_tの実行により、s_{t+1}, r_{t}などを計算する observation, reward, done, info = env.step(action) # 報酬を設定し与える if done: if t < 195: reward = -200 #こけたら罰則 else: reward = 1 #立ったまま終了時は罰則はなし else: reward = 1 #各ステップで立ってたら報酬追加 episode_reward += reward #報酬を追加 # 離散状態s_{t+1}を求め、Q関数を更新する next_state = digitize_state(observation) #t+1での観測状態を、離散値に変換 q_table = update_Qtable(q_table, state, action, reward, next_state) # 次の行動a_{t+1}を求める action = get_action(next_state, episode) # a_{t+1} state = next_state #終了時の処理 if done: print('%d Episode finished after %f time steps / mean %f' % (episode, t + 1, total_reward_vec.mean())) total_reward_vec = np.hstack((total_reward_vec[1:], episode_reward)) #報酬を記録 if islearned == 1: #学習終わってたら最終のx座標を格納 final_x[episode, 0] = observation[0] break if (total_reward_vec.mean() >= goal_average_reward): # 直近の100エピソードが規定報酬以上であれば成功 print('Episode %d train agent successfuly!' % episode) islearned = 1 #np.savetxt('learned_Q_table.csv',q_table, delimiter=",") #Qtableの保存する場合 if isrender == 0: #env = wrappers.Monitor(env, './movie/cartpole-experiment-1') #動画保存する場合 isrender = 1 #10エピソードだけでどんな挙動になるのか見たかったら、以下のコメントを外す #if episode>10: # if isrender == 0: # env = wrappers.Monitor(env, './movie/cartpole-experiment-1') #動画保存する場合 # isrender = 1 # islearned=1; if islearned: np.savetxt('final_x.csv', final_x, delimiter=",") |
[5] メインルーチンです。
試行数のfor文と、各時間ステップのfor文のネストになっています。
状態s(t)でa(t)を実行し、観測状態s(t+1)を求めます。
そのときの棒が立っているかどうかで報酬r(t)を決定します。
報酬は、195ステップ立たずに終了したら-200の罰則の報酬を与えます。
こけずに立っていたら、+1の報酬を与えます。
その後、Q関数を更新し、次の行動a(t+1)を求め、状態s(t)を更新します。
最後は各ステップごとの情報と、試行終わりの情報を出力し、学習終了条件を満たしているか判定します。
以上のコードを実行すると、だいたい800試行で学習が収束し、棒がうまく立ちます。
上記コードを実行した結果をgifで示します。
40度以上傾くと終了します。
最初の10試行ではグダグダです。
100試行たつとちょっと、立てるようになります。
でもどんどん移動してこけます。
かわいいです。
そして800試行ほどで学習が終了し、200stepの間立ち続けることができました。
ここで、状態を各変数につき6分割で良いの?という疑問がわきます。
100分割くらいすればより細かい制御ができるかもしれませんが、学習には時間がかかります。
そして何より、現在time stepが固定されているので時間方向の分割性能を変えないで、状態ばかり細かく分割しても意味がありません。
そのため、4~6分割で十分となります。
最後に再度コードを全部掲載します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | # coding:utf-8 # [0]ライブラリのインポート import gym #倒立振子(cartpole)の実行環境 from gym import wrappers #gymの画像保存 import numpy as np import time # [1]Q関数を離散化して定義する関数 ------------ # 観測した状態を離散値にデジタル変換する def bins(clip_min, clip_max, num): return np.linspace(clip_min, clip_max, num + 1)[1:-1] # 各値を離散値に変換 def digitize_state(observation): cart_pos, cart_v, pole_angle, pole_v = observation digitized = [ np.digitize(cart_pos, bins=bins(-2.4, 2.4, num_dizitized)), np.digitize(cart_v, bins=bins(-3.0, 3.0, num_dizitized)), np.digitize(pole_angle, bins=bins(-0.5, 0.5, num_dizitized)), np.digitize(pole_v, bins=bins(-2.0, 2.0, num_dizitized)) ] return sum([x * (num_dizitized**i) for i, x in enumerate(digitized)]) # [2]行動a(t)を求める関数 ------------------------------------- def get_action(next_state, episode): #徐々に最適行動のみをとる、ε-greedy法 epsilon = 0.5 * (1 / (episode + 1)) if epsilon <= np.random.uniform(0, 1): next_action = np.argmax(q_table[next_state]) else: next_action = np.random.choice([0, 1]) return next_action # [3]Qテーブルを更新する関数 ------------------------------------- def update_Qtable(q_table, state, action, reward, next_state): gamma = 0.99 alpha = 0.5 next_Max_Q=max(q_table[next_state][0],q_table[next_state][1] ) q_table[state, action] = (1 - alpha) * q_table[state, action] +\ alpha * (reward + gamma * next_Max_Q) return q_table # [4]. メイン関数開始 パラメータ設定-------------------------------------------------------- env = gym.make('CartPole-v0') max_number_of_steps = 200 #1試行のstep数 num_consecutive_iterations = 100 #学習完了評価に使用する平均試行回数 num_episodes = 2000 #総試行回数 goal_average_reward = 195 #この報酬を超えると学習終了(中心への制御なし) # 状態を6分割^(4変数)にデジタル変換してQ関数(表)を作成 num_dizitized = 6 #分割数 q_table = np.random.uniform( low=-1, high=1, size=(num_dizitized**4, env.action_space.n)) total_reward_vec = np.zeros(num_consecutive_iterations) #各試行の報酬を格納 final_x = np.zeros((num_episodes, 1)) #学習後、各試行のt=200でのxの位置を格納 islearned = 0 #学習が終わったフラグ isrender = 0 #描画フラグ # [5] メインルーチン-------------------------------------------------- for episode in range(num_episodes): #試行数分繰り返す # 環境の初期化 observation = env.reset() state = digitize_state(observation) action = np.argmax(q_table[state]) episode_reward = 0 for t in range(max_number_of_steps): #1試行のループ if islearned == 1: #学習終了したらcartPoleを描画する env.render() time.sleep(0.1) print (observation[0]) #カートのx位置を出力 # 行動a_tの実行により、s_{t+1}, r_{t}などを計算する observation, reward, done, info = env.step(action) # 報酬を設定し与える if done: if t < 195: reward = -200 #こけたら罰則 else: reward = 1 #立ったまま終了時は罰則はなし else: reward = 1 #各ステップで立ってたら報酬追加 episode_reward += reward #報酬を追加 # 離散状態s_{t+1}を求め、Q関数を更新する next_state = digitize_state(observation) #t+1での観測状態を、離散値に変換 q_table = update_Qtable(q_table, state, action, reward, next_state) # 次の行動a_{t+1}を求める action = get_action(next_state, episode) # a_{t+1} state = next_state #終了時の処理 if done: print('%d Episode finished after %f time steps / mean %f' % (episode, t + 1, total_reward_vec.mean())) total_reward_vec = np.hstack((total_reward_vec[1:], episode_reward)) #報酬を記録 if islearned == 1: #学習終わってたら最終のx座標を格納 final_x[episode, 0] = observation[0] break if (total_reward_vec.mean() >= goal_average_reward): # 直近の100エピソードが規定報酬以上であれば成功 print('Episode %d train agent successfuly!' % episode) islearned = 1 #np.savetxt('learned_Q_table.csv',q_table, delimiter=",") #Qtableの保存する場合 if isrender == 0: #env = wrappers.Monitor(env, './movie/cartpole-experiment-1') #動画保存する場合 isrender = 1 #10エピソードだけでどんな挙動になるのか見たかったら、以下のコメントを外す #if episode>10: # if isrender == 0: # env = wrappers.Monitor(env, './movie/cartpole-experiment-1') #動画保存する場合 # isrender = 1 # islearned=1; if islearned: np.savetxt('final_x.csv', final_x, delimiter=",") |
以上、強化学習のQ学習を用いて倒立振子(cartPole)を制御する方法を紹介しました。
次回は、Q関数をディープラーニングで学習するDQNを紹介します。