深度增強學習PPO(Proximal Policy Optimization)演算法原始碼走讀
OpenAI出品的baselines專案提供了一系列deep reinforcement learning(DRL,深度強化學習或深度增強學習)演算法的實現。現在已經有包括DQN,DDPG,TRPO,A2C,ACER,PPO在內的近十種經典演算法實現,同時它也在不斷擴充中。它為對DRL演算法的復現驗證和修改實驗提供了很大的便利。本文主要走讀其中的PPO(Proximal Policy Optimization)演算法的原始碼實現。PPO是2017年由OpenAI提出的一種DRL演算法,它不僅有很好的performance(尤其是對於連續控制問題),同時相較於之前的TRPO方法更加易於實現。之前寫過一篇雜文
OpenAI baselines專案中對於PPO演算法有兩個實現,分別位於ppo1和ppo2目錄下。其中ppo2是利用GPU加速的,官方號稱會快三倍左右,所以下面主要是看ppo2。對應論文為《Proximal Policy Optimization Algorithms》,以下簡稱PPO論文。本文我們就以atari這個經典的DRL實驗場景為例看一下大體流程。啟動訓練的命令在readme中有:
$ python3 -m baselines.ppo2.run_atari
- 1
這樣就開始訓練了,每輪引數更新後會打印出相關資訊。如:
------------------------------------
| approxkl | 0.003101161 |
| clipfrac | 0.17260742 |
| eplenmean | 941 |
| eprewmean | 34.9 |
| explained_variance | 0.704 |
| fps | 981 |
| nupdates | 1653 |
| policy_entropy | 0.85041255 |
| policy_loss | -0.01297911 |
| serial_timesteps | 211584 |
| time_elapsed | 1.63e+03 |
| total_timesteps | 1692672 |
| value_loss | 0.036234017 |
------------------------------------
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
可以看到,入口為run_atari.py中的main():
def main():
# 實現位於common/cmd_util.py。它主要為parser新增幾個引數:
# 1) env:代表要執行atari中的哪個遊戲環境。預設為BreakoutNoFrameskip-v4,即“打磚塊”。
# 2) seed:隨機種子。預設為0。
# 3) num-timesteps:訓練的頻數。預設為10M次。
parser = atari_arg_parser()
# 通過引數選擇policy network的形式,實現在policies.py。預設為CNN。這裡有三種選擇:
# 1) CNN:相應函式為CnnPolicy()。發表於《Nature》上的經典DRL奠基論文《Human-level control through
# deep reinforcement learning》中使用的神經網路結構:conv->relu->conv->relu->conv->relu->
# fc->relu。注意它是雙頭網路,一頭輸出policy,一頭輸出value。
# 2) LSTM:相應函式為LstmPolicy()。它將CNN的輸出之上再加上LSTM層,這樣就結合了時間域的資訊。
# 3) LnLSTM:相應函式為LnLstmPolicy()。其它的和上面一樣,只是在構造LSTM層時添加了Layer normalization
# (詳見論文《Layer Normalization》)
parser.add_argument('--policy', help='Policy architecture', choices=['cnn', 'lstm', 'lnlstm'], default='cnn')
# 用剛才的構建的parser解析命令列傳入的引數。
args = parser.parse_args()
# 這個專案中實現了簡單的日誌系統。其中日誌所在目錄和格式可以用過OPENAI_LOGDIR和OPENAI_LOG_FORMAT兩個環境
# 變數控制。實現類Logger中主要有兩個字典:name2val和name2cnt。它們分別是名稱到值和計數的對映。
logger.configure()
# 這裡是開始正式訓練了。
train(args.env, num_timesteps=args.num_timesteps, seed=args.seed,
policy=args.policy)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
主函式中最後呼叫了train()函式進行訓練。
def train(env_id, num_timesteps, seed, policy):
# 首先是一坨和TensorFlow相關的環境設定,比如根據cpu核數設定並行執行緒數等。
...
# 構建執行環境。流程還比較長,下面會再詳細地理下。
env = VecFrameStack(make_atari_env(env_id, 8, seed), 4)
# 對應前面說的三種策略網路。用於根據引數選取相應的實現函式。
policy = {'cnn' : CnnPolicy, 'lstm' : LstmPolicy, 'lnlstm' : LnLstmPolicy}[policy]
# 使用PPO演算法進行學習。其中傳入的引數不少是模型的超引數。詳細可參見PPO論文中的Table 5。
ppo2.learn(policy=policy, env=env, nsteps=128, nminibatches=4,
lam=0.95, gamma=0.99, noptepochs=4, log_interval=1,
ent_coef=.01,
lr=lambda f : f * 2.5e-4,
cliprange=lambda f : f * 0.1,
total_timesteps=int(num_timesteps * 1.1))
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
可以看到,train()函式中比較重要的就是兩大塊:環境構建和模型引數學習。首先看看環境構建流程:
# make_atari_env()函式實現位於common/cmd_util.py。看函式名就知道主要就是建立atari環境。通過OpenAI gym
# 建立基本的atari環境後,還需要層層封裝。gym中提供了Wrapper介面,讓開發者通過decorator設計模式來改變環境中的設# 定。
def make_atari_env(env_id, num_env, ...): # 這裡的num_env為8,意味著會建立8個獨立的並行執行環境。
def make_env(rank):
def _thunk():
# 建立由gym構建的atari環境的封裝類。
env = make_atari(env_id):
# 通過OpenAI的gym介面建立gym環境。
env = gym.make(env_id)
# NoopResetEnv為gym.Wrapper的繼承類。每次環境重置(呼叫reset())時執行指定步隨機動作。
env = NoopResetEnv(env)
# MaxAndSkipEnd也是gym.Wrapper的繼承類。每隔4幀返回一次。返回中的reward為這4幀reward
# 之和,observation為最近兩幀中最大值。
env = MaxAndSkipEnd(env)
return env
# 每個環境選取不同的隨機種子,避免不同環境跑得都一樣。
env.seed(seed + rank)
# 實現在monitor.py中。Monitor為gym中Wrapper的繼承類,對環境Env進行封裝,主要添加了對
# episode結束時資訊的記錄。
env = Monitor(env, ...)
return wrap_deepmind(env, ...):
# 標準情況下,對於atari中的很多遊戲(比如這兒的打磚塊),命掉光了(如該遊戲有5條命)算episode
# 結束,環境重置。這個Wrapper的作用是隻要掉命就讓step()返回done,但保持環境重置的時機不變
#(仍然是命掉完時)。原註釋中說這個trick在DeepMind的DQN中用來幫助value的估計。
env = EpisodeicLifeEnv(env)
# 通過OpenCV將原始輸入轉成灰度圖,且轉成84 x 84的解析度。
env = WarpFrame(env)
# 將reward按正負值轉為+1, -1和0。
env = ClipRewardEnv(env)
...
return env
return _thunk
...
# 返回SubprocVecEnv物件。
return SubprocVecEnv([make_env(i + start_index) for i in range(num_env)])
- 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
建立num_env個元素(這裡為8)的陣列,每一個元素為一個函式閉包_thunk()。VecEnv實現在baselines/common/vec_env/__init__.py,它是一個抽象類,代表異步向量化環境。其中包括幾個重要的抽象函式: reset()用於重置所有環境,step_async()用於通知所有環境開始根據給定動作執行一步,step_wait()得到執行完的結果。step_wait()等待step_async()的結果。step()就是step_async() 加上step_wait()。而VecEnvWrapper也為VecEnv的繼承類,和gym中提供的Wrapper功能類似,如果要對VecEnv實現的預設行為做修改的話就可以利用它。
上面函式最後返回的SubprocVecEnv類為VecEnv的繼承類,它主要將上面建立好的函式放到各個子程序中去執行。在SubprocVecEnv實現類中,構造時傳入在子程序中執行的函式。通過Process建立子程序,並通過Pipe進行程序間通訊。make_atari_env()中建立SubprocVecEnv後,又立馬被VecFrameStack封裝了一把。VecFrameStack為VecEnvWrapper的實現類,實現在vec_frame_stack.py。在VecFrameStack的建構函式中,wos為gym環境中的原始狀態空間,維度為[84,84,1]。low和high分別為這些維度的最低和最高值。stackedobs就是把幾個環境的狀態空間疊加起來,即維度變為(8, 84, 84, 4)。8為環境個數,(84,84)為單幀狀態維度,也就是遊戲的螢幕輸出,4代表最近4幀(因為會用最近4的幀的遊戲畫面來作為網路模型的輸入)。
可以看到,除了正常的封裝外,還需要做一些比較tricky,比較靠經驗的處理。理論上我們希望這部分越少越好,因為越少演算法就越通用。然而現狀是這一塊tuning對結果的好壞可能產生比較大的影響。。。
好了,接下去就可以看看PPO演算法主體了。入口為ppo2.py的learn()函式。
# 首先一坨引數設定,仍然以run_atari.py為例。
nenvs = env.num_envs # 8
ob_space = env.observation_space # Box(84,84,4)
ac_space = env.action_space # Discrete(4)
nbatch = nenvs * nsteps # 1024 = 8 * 128。共8個並行環境,每個環境執行128步。即nbatch為單個batch中所有環境中執行的總步數。
nbatch_train = nbatch // nminibatches # 256 = 1024 / 4。nbatch_train為訓練時batch的大小。即1024步分4次訓練。
# make_model()函式是一個用於構造Model物件的函式。
make_model = lambda : Model(policy=policy, ob_space=ob_space, ...)
# 建立Model。
model = make_model()
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
模型的構建也是最核心的部分。這塊要和PPO論文配合起來看,否則容易暈。
class Model(object):
def __init__(self, *, policy, ob_space, ac_space, nbatch_act, nbatch_train,
nsteps, ent_coef, vf_coef, max_grad_norm):
# 用前面指定的網路型別構造兩個策略網路。act_model用於執行策略網路根據當前observation返回
# action和value等,即只做inference。train_model顧名思義主要用於引數的更新(模型的學習)。
# 注意這兩個網路的引數是共享的,因此train_model更新的引數可以體現在act_model上。假設使用默
# 認的CnnPolicy,其中的step()函式計算action, value function和action提供的資訊量;
# value()函式計算value。
# nbatch_act = 8,就等於環境個數nenvs。因為每一次都分別對8個環境執行,得到每個環境中actor的動作。
# 1為nsteps。其實在CNN中沒啥用,在LSTM才會用到(因為LSTM會考慮前nsteps步作為輸入)。
act_model = policy(sess, ob_space, ac_space, nbatch_act, 1, reuse=False)
h = nature_cnn(X) # 如前面所說,《Nature》上的網路結構打底。然後輸出policy和value。
pi = fc(h, 'pi', ...) # for policy
vf = fc(h, 'v') # for value function
# 根據action space建立相應的引數化分佈。如這裡action space是Discrete(4),那分佈
# 就是CategoricalPdType()。然後根據該分佈型別,結合網路輸出(pi),得到動作概率分
# 布CategoricalPd,最後在該分佈上取樣,得到動作a0。neglogp0即為該動作的自資訊量。
pdtype = make_pdtype()
pd = self.pdtype.pdfromflat(pi)
a0 = self.pd.sample()
neglogp0 = self.pd.neglogp(a0)
# 和構建action model類似,構建用於訓練的網路train_model。nbatch_train為256,因為是用於模型的學習,
# 因此和act_model不同,這兒網路輸入的batch size為256。
train_model = policy(sess, ob_space, ac_space, nbatch_train, nsteps, reuse=True)
# 建立一坨placeholder,這些是後面要傳入的。
A = train_model.pdtype.sample_placeholder([None]) # action
ADV = tf.placeholder(tf.float32, [None]) # advantage
R = tf.placeholder(tf.float32, [None]) # return
OLDNEGLOGPAC = tf.placeholder(tf.float32, [None]) # old -log(action)
OLDVPRED = tf.placeholder(tf.float32, [None]) # old value prediction
LR = tf.placeholder(tf.float32, []) # learning rate
CLIPRANGE = tf.placeholder(tf.float32, []) # clip range,就是論文中的epsilon。
neglogpac = train_model.pd.neglogp(A) # -log(action)
entropy = tf.reduce_mean(train_model.pd.entropy())
# 訓練模型提供的value預測。
vpred = train_model.vf
# 和vpred類似,只是與上次的vpred相比變動被clip在由CLIPRANGE指定的區間中。
vpredclipped = OLDVPRED + tf.clip_by_value(train_model.vf - OLDVPRED, - CLIPRANGE, CLIPRANGE)
vf_losses1 = tf.square(vpred - R)
vf_losses2 = tf.square(vpredclipped - R)
# V loss為兩部分取大值:第一部分是網路預測value值和R的差平方;第二部分是被clip過的預測value值
# 和return的差平方。這部分和論文中似乎不太一樣。主要目的應該是懲罰value值的過大更新。
vf_loss = .5 * tf.reduce_mean(tf.maximum(vf_losses1, vf_losses2))
# 論文中的probability ratio。把這裡的exp和log展開就是論文中的形式。
ratio = tf.exp(OLDNEGLOGPAC - neglogpac)
pg_losses = -ADV * ratio
pg_losses2 = -ADV * tf.clip_by_value(ratio, 1.0 - CLIPRANGE, 1.0 + CLIPRANGE)
# 論文公式(7),由於前面都有負號,這裡是取maximum.
pg_loss = tf.reduce_mean(tf.maximum(pg_losses, pg_losses2))
approxkl = .5 * tf.reduce_mean(tf.square(neglogpac - OLDNEGLOGPAC))
clipfrac = tf.reduce_mean(tf.to_float(tf.greater(tf.abs(ratio - 1.0), CLIPRANGE)))
# 論文公式(9),ent_coef, vf_coef分別為PPO論文中的c1, c2,這裡分別設為0.01和0.5。entropy為文中的S;pg_loss為文中的L^{CLIP}
loss = pg_loss - entropy * ent_coef + vf_loss * vf_coef
# 構建trainer,用於引數優化。
grads = tf.gradients(loss, params)
trainer = tf.train.AdamOptimizer(learning_rate=LR, max_grad_norm)
_train = trainer.apply_gradients()
- 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
上面模型構造完了,接下來就是模型學習過程的skeleton了。Runner類主要作為學習過程的組織協調者。
# Runnder是整個訓練過程的協調者。
runner = Runner(env=env, model=model, nsteps=nsteps,...)
# total_timesteps = 11000000, nbatch = 1024,因此模型引數更新nupdates = 10742次。
nupdates = total_timesteps // nbatch
for update in range(1, nupdates+1) # 對應論文中Algorithm的外迴圈。
obs, returns, masks, actions, values, ... = runner.run()
# 模型(上面的act_model)執行nsteps步。有8個環境,即共1024步。該迴圈對應論文中Algorithm的第2,3行。
for _ in range(self.nsteps):
# 執行模型,通過策略網路返回動作。
actions, values, self.states, ... = self.model.step(self.obs, self.status, ...)
# 通過之前建立的環境執行動作,得到observation和reward等資訊。
self.obs[:], rewards, self.dones, infos = self.env.step(actions)
# 上面環境執行返回的observation, action, values等資訊都加入mb_xxx中存起來,後面要拿來學習引數用。
mb_obs = np.asarray(mb_obs, dtype=self.obs.dtype)
mb_rewards = np.asarray(mb_rewards, dtype=np.float32)
mb_actions = np.asarray(mb_actions)
...
# 估計Advantage。對應化文中Algorithm的第4行。
for t in reversed(range(self.nsteps)):
# 論文中公式(12)。
delta = mb_rewards[t] + self.gamma * nextvalues * nextnonterminal - mb_values[t]
# 論文中公式(11)。
mb_advs[t] = lastgaelam = delta + self.gamma * self.lam * nextnonterminal * lastgaelam
mb_returns = mb_advs + mb_values # Return = Advantage + Value
return (*map(sf01, (mb_obs, mb_returns, mb_dones, mb_actions, mb_values, mb_neglogpacs)), mb_states, epinfos)
epinfobuf.extend(epinfos) # Gym中返回的info。
# 論文中Algorithm 1第6行。
if states is None: # nonrecurrent version
inds = np.arange(nbatch)
for _ in range(noptepochs): # epoch為4
np.random.shuffle(inds)
# 8個actor,每個執行128步,因此單個batch為1024步。1024步又分為4個minibatch,
# 因此單次訓練的batch size為256(nbatch_train)。
for start in range(0, nbatch, nbatch_train): # [0, 256, 512, 768]
end = start + nbatch_train
mbinds = inds[start:end]
slices = (arr[mbinds] for arr in (obs, ...))
# 將前面得到的batch訓練資料作為引數,呼叫模型的train()函式進行引數學習。
mblossvals.append(model.train(lrnow, cliprangenow, *slices))
# Advantage = Return - Value
advs = returns - values
# Normalization
advs = (advs - advs.mean()) / (advs.std() + 1e-8)
# cliprange是隨著更新的步數遞減的。因為一般來說訓練越到後面越收斂,每一步的差異也會越來越小。
# neglogpacs和values都是nbatch_train維向量,即shape為(256, )。
td_map = {train_mode.X:obs, A:actions, ADV:advs, R:returns, LR:lr,
CLIPRANGE:cliprange, OLDNEGLOGPAC:neglogpacs, OLDVPRED:values}
return sess.run([pg_loss, vf_loss, entropy, approxkl, clipfrac, _train], td_map)
else:
...
# 每過指定間隔列印以下引數。
if update % log_interval == 0 or update == 1:
ev = explained_variance(values, returns)
logger.logkv("serial_timesteps", update*nsteps)
logger.logkv("nupdates", update)
...
# 滿足條件時儲存模型。
if save_interval and (update % save_interval == 0 or update == 1) and logger.get_dir():
...
model.save(savepath)
env.close()
- 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
訓練結束,我們可以通過下面命令將訓練過程中的主要指標-Episode Rewards圖形化。可以用–dirs引數指定前面訓練時log所在目錄。
python3 -m baselines.results_plotter
- 1
可以看到,如期望地,隨著訓練的進行,學習到的策略使得agent能在一輪遊戲中玩得越來越久,一輪中的累積回報也就越來越大。
最後,是騾子是馬還是要出來溜溜才知道。下面指令碼用於將訓練產生的ckpt load起來,然後執行atari環境,執行策略網路產生的動作,並將過程渲染出來。引數為ckpt檔案路徑。
import gym
from gym import spaces
import multiprocessing
import joblib
import sys
import os
import numpy as np
import tensorflow as tf
from baselines.ppo2 import ppo2
from baselines.common.cmd_util import make_atari_env, atari_arg_parser
from baselines.common.atari_wrappers import make_atari, wrap_deepmind
from baselines.ppo2.policies import CnnPolicy
from baselines.common.vec_env.vec_frame_stack import VecFrameStack
def main(argv):
ncpu = multiprocessing.cpu_count()
config = tf.ConfigProto(allow_soft_placement=True,
intra_op_parallelism_threads=ncpu,
inter_op_parallelism_threads=ncpu)
config.gpu_options.allow_growth = True
sess = tf.Session(config=config)
env_id = "BreakoutNoFrameskip-v4"
seed = 0
nenvs = 1
nstack = 4
env = wrap_deepmind(make_atari(env_id))
ob_space = env.observation_space
ac_space = env.action_space
wos = env.observation_space
low = np.repeat(wos.low, nstack, axis=-1)
high = np.repeat(wos.high, nstack, axis=-1)
stackedobs = np.zeros((nenvs,)+low.shape, low.dtype)
observation_space = spaces.Box(low=low, high=high, dtype=env.observation_space.dtype)
vec_ob_space = observation_space
act_model = CnnPolicy(sess, vec_ob_space, ac_space, nenvs, 1, reuse=False)
with tf.variable_scope('model'):
params = tf.trainable_variables()
#load_path = '/tmp/openai-2018-05-27-15-06-16-102537/checkpoints/00030'
load_path = argv[0]
loaded_params = joblib.load(load_path)
restores = []
for p, loaded_p in zip(params, loaded_params):
restores.append(p.assign(loaded_p))
sess.run(restores)
print("model " + load_path + " loaded")
obs = env.reset()
done = False
for _ in range(1000):
env.render()
obs = np.expand_dims(obs, axis=0)
stackedobs = np.roll(stackedobs, shift=-1, axis=-1)
stackedobs[..., -obs.shape[-1]:] = obs
actions, values, states, neglogpacs = act_model.step(stackedobs)
print("%d, action=%d" % (_, actions[0]))
obs, reward, done, info = env.step(actions[0])
if done:
print("done")
obs = env.reset()
stackedobs.fill(0)
sess.close()
if __name__ == '__main__':
if (len(sys.argv)) != 2:
sys.exit("Usage: %s ckpt_path" % sys.argv[0])
if not os.path.exists(sys.argv[1]):
sys.exit("ckpt file %s not found" % sys.argv[1])
main(sys.argv[1:])
- 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
可以看到當更新迭代500次時,演算法已經能學習到一些遊戲的基本策略了,但仍不是很嫻熟。5條命基本在1000步內就會被幹光。 當更新迭代5000次後,學到的策略比500次時已經成熟很多了,5條命在1000步內基本夠用。 當更新迭代10000次後,基本已經玩得很溜了。試驗中1000步只損了一條命。