深度增強學習PPO(Proximal Policy Optimization)算法源碼走讀

原文地址:https://blog.csdn.net/jinzhuojun/article/details/80417179

OpenAI出品的baselines項目提供了一系列deep reinforcement learning(DRL,深度強化學習或深度增強學習)算法的實現。現在已經有包括DQN,DDPG,TRPO,A2C,ACER,PPO在內的近十種經典算法實現,同時它也在不斷擴充中。它爲對DRL算法的復現驗證和修改實驗提供了很大的便利。本文主要走讀其中的PPO(Proximal Policy Optimization)算法的源碼實現。PPO是2017年由OpenAI提出的一種DRL算法,它不僅有很好的performance(尤其是對於連續控制問題),同時相較於之前的TRPO方法更加易於實現。之前寫過一篇雜文《深度增強學習(DRL)漫談 - 信賴域(Trust Region)系方法》對其歷史、原理及相關方法做了簡單介紹,因此本文主要focus在代碼實現的學習瞭解上。

OpenAI baselines項目中對於PPO算法有兩個實現,分別位於ppo1和ppo2目錄下。其中ppo2是利用GPU加速的,官方號稱會快三倍左右,所以下面主要是看ppo2。對應論文爲《Proximal Policy Optimization Algorithms》,以下簡稱PPO論文。本文我們就以atari這個經典的DRL實驗場景爲例看一下大體流程。啓動訓練的命令在readme中有:

$ python3 -m baselines.ppo2.run_atari

這樣就開始訓練了,每輪參數更新後會打印出相關信息。如:

------------------------------------
| 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 |
------------------------------------

可以看到,入口爲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)

主函數中最後調用了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))

可以看到,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)]) 

創建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()

模型的構建也是最核心的部分。這塊要和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()

上面模型構造完了,接下來就是模型學習過程的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()

訓練結束,我們可以通過下面命令將訓練過程中的主要指標-Episode Rewards圖形化。可以用–dirs參數指定前面訓練時log所在目錄。

python3 -m baselines.results_plotter

可以看到,如期望地,隨着訓練的進行,學習到的策略使得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:])

可以看到當更新迭代500次時,算法已經能學習到一些遊戲的基本策略了,但仍不是很嫺熟。5條命基本在1000步內就會被幹光。
這裏寫圖片描述
當更新迭代5000次後,學到的策略比500次時已經成熟很多了,5條命在1000步內基本夠用。
這裏寫圖片描述
當更新迭代10000次後,基本已經玩得很溜了。試驗中1000步只損了一條命。
這裏寫圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章