深度增強學習DDPG(Deep Deterministic Policy Gradient)算法源碼走讀

原文鏈接:https://blog.csdn.net/jinzhuojun/article/details/82556127

本文是基於OpenAI推出deep reinforcement learning算法集baselines。之前寫過該項目的環境setup介紹《常用增強學習實驗環境 I (MuJoCo, OpenAI Gym, rllab, DeepMind Lab, TORCS, PySC2)》以及其中的另一重要算法-PPO算法走讀《深度增強學習PPO(Proximal Policy Optimization)算法源碼走讀》,因此對項目本身就不再囉嗦。關於DDPG算法在DRL發展歷史中的角色以及大體介紹之前在《深度增強學習(DRL)漫談 - 從AC(Actor-Critic)到A3C(Asynchronous Advantage Actor-Critic)》一文中也瞎扯過一些,也不重複了。 本文主要focus在DDPG算法的代碼實現學習上。DDPG算法描述可見論文《Continuous control with deep reinforcement learning》中Algorithm 1。

環境setup好後,執行下面命令即可以開始訓練:

python3 -m baselines.ddpg.main

你可能會碰到下面錯誤:

gym.error.DeprecatedEnv: Env HalfCheetah-v1 not found (valid versions include ['HalfCheetah-v2'])

沒關係,把HalfCheetah-v1改成HalfCheetah-v2就行。可能由於Gym升級導致。

Gym中渲染時可能出現這個錯誤:ERROR: GLEW initalization error: Missing GL version。根據https://blog.csdn.net/gsww404/article/details/80636676,執行以下命令即可(如果裝的是Nvidia的卡):

export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libGLEW.so:/usr/lib/nvidia-384/libGL.so

接下去開始看代碼實現流程。首先從文件main.py開始。這裏主要解析參數。如果使用MPI的話確保只有第一個進程調用logger.configure()函數來初始化日誌。這裏基於mpi4py來使用MPI進行多進程加速。比如可以執行下面命令起兩個進程來訓練:

mpirun -np 2 python3 -m baselines.ddpg.main

緊接着進入到主要函數run()中。

def run(env_id, seed, ...):
    env = gym.make(env_id)
    env = bench.Monitor(env, ...)
    # env.action_space.shape爲(6,)
    nb_actions = env.action_space.shape[-1]
    
    # 往參數加入噪聲來鼓勵exploration。默認參數noise-type爲adaptive-param_0.2,
    # 因此這裏創建AdaptiveParamNoiseSpec()。
    param_noise = AdaptiveParamNoiseSpec()
    
    # 構建replay buffer。
    memory = Memory(limit=int(1e6)...)
    # 構建Actor和Critic網絡。實現在models.py中。
    critic = Critic(layer_norm=layer_norm)
    actor = Actor(nb_actions, layer_norm=layer_norm)
    
    training.train(env=env, ...)

AdaptiveParamNoiseSpec的實現在noise.py文件中。noise.py中實現了三種添加噪聲的方式,詳細可參見論文《Parameter Space Noise for Exploration》。

  • AdaptiveParamNoiseSpec: 詳見論文P3中Adaptive noise scaling一節,先定義擾動和非擾動動作空間策略的距離,然後根據這個距離是否高於指定閥值(代碼中的desired_action_stddev)來調節參數噪聲的大小(代碼中的current_stddev)。
  • NormalActionNoise: 根據給定的均值和方差從正態分佈中採樣。
  • OrnSteinUhlenbeckActionNoise: 使用Ornstein-Uhlenbeck process採樣。這也是原版DDPG中用於動作探索時用的方法。

第一種爲參數噪聲方法,即通過在策略神經網絡的參數中加入噪聲,進而達到對該網絡產生的動作加入噪聲的效果;後兩種爲動作噪聲方法,即直接對網絡輸出的動作加噪聲。

初始化代碼中的Memory就是replay buffer,實現在memory.py文件,它用以實現experience replay。Experience replay之前應用在DQN算法過,被證明可以有效減少樣本時序上的關聯,從而提高訓練的穩定性。它的結構主要就是一系列的RingBuffer(大小默認爲100W),分別存儲當前和下一觀察狀態、動作、回報和是否終結信息。它的兩個主要函數append()和sample()分別用於往這個buffer中添加數據,和從中進行採樣(一個batch)。

訓練的過程在training.py文件中,先來看其初始化過程:

def train(env, ...):
    # 實現位於ddpg.py中。
    agent = DDPG(actor, critic, memory, ...)
        ...
        # 創建actor和critic的目標網絡(target network)。.
        target_actor = copy(actor)
        self.target_actor = target_actor
        target_critic = copy(critic)
        self.target_critic = target_critic
        
        ...
        if self.param_noise is not None:
            # 參數加噪,詳見《Parameter Space Noise for Exploration》。
            self.setup_param_noise(normalized_obs0)
        # 創建actor和critic網絡優化器。
        self.setup_actor_optimizer()
        self.setup_critic_optimizer()
        if self.normalize_returns and self.enable_popart:
            # 目標歸一化,詳見《Learning values across many orders of magnitude》。
            self.setup_popart() 
        # 用於在線網絡(或稱主網絡)向目標網絡同步參數。
        self.setup_target_network_updates()
        

setup_param_noise()函數中主要處理參數加噪。首先它把actor網絡拷貝一份到param_noise_actor。當打開參數加噪選項時,訓練過程中要得到動作就是從該網絡中獲得。使用這個加噪處理過的actor網絡得到擾動後動作的TF操作爲perturbed_actor_tf。接下去,get_perturbed_actor_updates()函數對於perturbed_actor中的所有變量添加一個正態分佈的噪聲,其中的標準差參數來自於前面提到的param_noise_stddev,最後把這些更新操作返回放到perturb_policy_ops當中。此外,爲了學習加噪時所用標準差,還需要將actor網絡拷貝一份,稱爲adaptive_param_noise_actor,這個actor網絡主要用於計算經加噪後輸出的擾動動作與原動作的距離(用L2範數爲測度),這個距離用來調節加噪時的標準差參數。

函數setup_actor_optimizer()和setup_critic_optimizer()分別用來創建actor和critic網絡的優化器。這裏主要分別實現論文中DDPG算法中actor的參數梯度(sampled policy gradient):

θμJ1NiaQ(s,aθQ)s=si,a=μ(si)θμμ(sθμ)si \nabla_{\theta^\mu} J \approx \frac{1}{N} \sum_{i} \nabla_a Q(s,a | \theta^Q)|_{s=s_i, a= \mu(s_i)} \nabla_{\theta^\mu} \mu(s|\theta^\mu) |_{s_i}
和critic的參數梯度:
θQL=θQ[1NiyiQ(si,aiθQ))2] \nabla_{\theta^Q} L = \nabla_{\theta^Q} [\frac{1}{N} \sum_i y_i - Q(s_i, a_i | \theta^Q))^2 ]
其中yi=ri+γQ(si+1,μ(si+1θμ)θQ)y_i = r_i + \gamma Q'(s_{i+1}, \mu'(s_{i+1} | \theta^{\mu'}) | \theta^{Q'})。以actor網絡優化器爲例,通過flatgrad()函數先求actor_loss對actor網絡中可學習參數的梯度,然後進行clip再flatten,返回存於actor_grads。actor_optimizer是類型爲MpiAdam的優化器,創建時傳入Adam所需參數。這個MpiAdam與傳統Adam優化器相比主要區別是分佈式的。因爲訓練在多個進程中同時進行,因此在其參數更新函數update()中,它先會用Allreduce()函數將所有局部梯度匯成全局梯度,然後按Adam的規則更新參數。

setup_target_network_update()函數構建在線網絡更新目標網絡的操作。DeepMind在論文《Human-level control through deep reinforcement learning》中引入了目標網絡提高訓練的穩定性。目標網絡和在線網絡結構是一樣的,只是每過指定步後,在線網絡的參數會以θtarget=(1τ)θtarget+τθonline\theta_{target} = (1 - \tau) \theta_{target} + \tau \theta_{online}的形式拷貝給目標網絡。

再看訓練主循環(位於training.py文件):

def train(env, ...):
    ...
    agent.initialize(sess)
    agent.reset()
    obs = env.reset()
    ...
    for epoch in range(nb_epochs): # 500
        for cycle in range(nb_epoch_cycles): # 20
            for t_rollout in range(nb_rollout_steps): # 100
                # pi()函數位於ddpg.py,主要就是執行一把actor網絡,得到動作與Q值。
                action, q = agent.pi(obs, apply_noise=True, compute_Q=True)
                    action, q = self.sess.run([actor_tf, self.critic_with_actor_tf], ...)
                ...
                # 首先將actor網絡輸出的動作按env中動作空間進行scale,
                # 然後在env中按此動作執行一步,並獲得觀察、回報等信息。
                new_obs, r, done, info = env.step(max_action * action)
                ...
                # 將每一步的信息記錄到replay buffer中。
                epoch_actions.append(action)
                epoch_qs.append(q)
                agent.store_transition(obs, action, r, new_obs, done)
                    memory.append(obs0, action, reward, obs1, terminal1)
                obs = new_obs
                
                if done:
                    # episode結束,重置環境。
                    ...
                    agent.reset()
                    obs = env.reset()
                    
            # 訓練
            for t_train in range(nb_train_steps): # 50
                # batch_size = 64
                if memory.nb_entries >= batch_size and t_train % param_noise_adaption_interval == 0:
                    distance = agent.adapt_param_noise()
                    ...
                # 返回critic和actor網絡的loss。然後將它們分別存入epoch_critic_losses和epoch_actor_losses。
                cl, al = agent.train()
                ...
                agent.update_traget_net()
	                self.sess.run(self.target_soft_updates)
                
            # 評估
            for t_rollout in range(nb_eval_steps):
                eval_action, eval_q = agent.pi(...)
                eval_obs, eval_r, eval_done, eval_info = eval_env.step(max_action * eval_action)

上面流程中,先調用agent的initialize()函數進行初始化。

def initialize(self, sess):
    self.sess = sess
    self.sess.run(tf.global_variables_initializer())
    self.actor_optimizer.sync()
    self.critic_optimizer.sync()
    self.sess.run(self.target_init_updates)

除了TF中常規初始化操作外,接下來調用actor和critic優化器的sync()函數。由於這裏的優化器使用的是MpiAdam,sync()函數中會將網絡中參數進行flatten然後廣播給其它進程,其它進程會把這些參數unflatten到自己進程中的網絡中。最後執行target_init_updates操作將在線網絡中的參數拷貝到目標網絡中。

接下來的reset()函數主要是了添加動作噪聲。這裏假設使用的是參數加噪,執行perturb_policy_ops爲actor網絡參數添加噪聲。

然後開始訓練。這個訓練過程有幾層循環。最外層爲epoch,循環nb_epochs次。每次epoch最後會作一些統計。第二層循環爲cycle,循環nb_epoch_cycles次。這個循環的每次迭代中,主要分三步:

一、執行rollout指定步數(nb_rollout_steps)。這部分的主要作用是採集訓練樣本。

  1. 調用agent.pi()根據當前的觀察狀態ss得到agent的動作aa。這裏實際是做了actor網絡(這個網絡的參數是加噪的)的inference得到動作aa,然後根據觀察狀態和動作通過critic網絡得到Q(s,a)Q(s,a)的估計。
  2. 如果是當前爲MPI的rank 0進程且渲染選項打開,則調用env.render()畫出當前狀態。
  3. 調用env.step讓agent在Gym運行環境中執行上面步驟1中選取的動作aa,並返回下一觀察狀態,回報和episode是否結束等信息。
  4. 把這次狀態轉移中的各項信息(s,s,a,r,done)(s, s', a, r, done)通過agent.store_transition()存到replay buffer中。留作之後訓練使用。
  5. 如果Gym中執行後發現episode結束,則將episode相關信息記錄後調用agent.reset()和env.reset()。它們分別對actor網絡進行參數加噪,和Gym運行環境的重置。

二、訓練更新參數。訓練nb_train_steps輪。每一輪中:

  1. 當已有sample能夠填滿一個batch,且執行param_noise_adaption_interval步訓練,調用agent.adapt_param_noise()函數。這個函數主要用來自適應參數加噪時用的標準差。前面提到在setup_param_noise()函數中創建了adaptive_actor_tf,這時候就會對它進行參數加噪,然後基於sample batch中的觀察狀態得到其動作,再與原始actor網絡輸出動作求距離。求出距離後,調用param_noise(即AdaptiveParamNoiseSpec)的adapt()函數調整參數加噪的標準差。
  2. 執行agent.train()學習網絡參數。
# 先從replay buffer中取batch_size大小的樣本。
batch = self.memory.sample(batch_size=self.batch_size)
# 根據選項判斷是否要用Pop-Art算法對critic網絡輸出(即Q函數值)進行normalization。
# 這裏因爲默認都爲false,所以先忽略。
if self.normalize_returns and self.enable_popart:
    ...
else
    # 先以觀察狀態obs1爲輸入通過目標critic網絡得到Q函數估計值,
    # 然後考慮回報通過差分更新公式得到目標Q函數值。
    target_Q = self.sess.run(self.target_Q, feed_dict={
        self.obs1: batch['obs1'],
        self.rewards: batch['rewards'],
        self.terminals1: batch['terminals1'].astype('float32'),
    })
# Get all gradients and perform a synced update.
ops = [self.actor_grads, self.actor_loss, self.critic_grads, self.critic_loss]
# 計算actor和critic網絡的參數梯度以及loss計算。
actor_grads, actor_loss, critic_grads, critic_loss = self.sess.run(ops, feed_dict={
    self.obs0: batch['obs0'],
    self.actions: batch['actions'],
    self.critic_target: target_Q,
})
# 執行參數的更新。如果是通過MPI多進程訓練,這裏調用Allreduce將各個進程
# 中actor和critic的梯度先收集起來求平均,然後使用Adam優化方式更新參數。
self.actor_optimizer.update(actor_grads, stepsize=self.actor_lr)
self.critic_optimizer.update(critic_grads, stepsize=self.critic_lr)
return critic_loss, actor_loss

3 . 記錄actor和critic網絡的loss,最後調用agent.update_target_net()函數進行DDPG算法中在線網絡到目標網絡的參數更新:
θQ=τθQ+(1τ)θQ\theta^{Q'} = \tau \theta^Q + (1-\tau) \theta^{Q'}
θμ=τθμ+(1τ)θμ\theta^{\mu'} = \tau \theta^\mu + (1 - \tau) \theta^{\mu'}

三、評估當前模型。大體過程是在Gym環境中執行nb_eval_steps步,每一步動作通過actor網絡得到(這時不用參數加噪聲,因爲不是訓練),最後統計相關信息,如episode中reward的累積,與Q函數值等。

整個算法大體的流轉如下圖:
這裏寫圖片描述

另外,可以看到,在上述實現中,還整合了其它幾篇論文中的idea:

《Learning values across many orders of magnitude》

在Atari中,回報會被clip到特定範圍。這種做法有利於讓多個遊戲使用同一算法,但是對回報進行clipping會產生不同的行爲。自適用歸一化(adaptive normalization)能移除這種領域相關的啓發,且不降低效果。對於輸入和層輸出的歸一化已經研究得比較多了,但對目標的歸一化研究得並不多。文中對目標 YtY_t進行仿射變換:Y~t=Σt1(Ytμt)\tilde{Y}_t = \Sigma^{-1}_t (Y_t - \mu_t)。如果記gg爲未歸一化函數,而ff爲歸一化函數,對於輸入xx的未歸一化近似可寫成f(x)=Σg(x)+μf(x) = \Sigma g(x) + \mu。損失函數可以通過g(Xt)g(X_t)和歸一化目標Y~t\tilde{Y}_t來定義。但是,這裏似乎又引入了scale Σ\Sigma和shift μ\mu兩個超參數,比較麻煩。於是,文中提出可以根據單獨的目標函數來更新這兩個參數。也就是說,將歸一化參數的學習從函數的學習分離出來,這樣就需要同時達到兩個性質:

  • (ART) 更新scale Σ\Sigma和shift μ\mu使得Σ1(Yμ)\Sigma^{-1}(Y - \mu)是正規化的。
  • (POP) 當改變scale和shift時保持unnormalized function的輸出一致。

Pop-Art算法全稱"Preserving Outputs Precisely, while Adaptivey Rescaling Targets"。

《Parameter Space Noise for Exploration》

我們知道,reinforcement learning中一個非常重要的問題就是探索問題。一種直觀上的做法就是在策略中加擾動。這可以分兩類:1) 在策略網絡的輸出動作上直接加噪聲。2) 往策略網絡的參數中加噪聲。在上面項目中noise.py文件中實現的三種加噪方法中,normal和OrnsteinUhlenbeck方法(來自論文《On the theory of the brownian motion》)屬於第一種,adaptive-param屬於第二種。這篇文章的中心之一就是往參數加噪的做法能產生更有效的探索。

爲了實現參數空間噪聲,可以將高斯噪聲添加到參數空間θ~=θ+N(0,σ2I)\tilde{\theta} = \theta + \mathcal{N}(0, \sigma^2 I)。參數空間噪聲需要我們挑選合適的scale σ\sigma,而這個因子一方面高度依賴特定網絡結構,另一方面又是隨時間變化的。Adaptive scaling的思想就是每過K步更新這個scale參數σk\sigma_k,即如果擾動策略與非擾動策略的距離小於閾值,則讓該參數變大,否則變小(見論文公式8)。

另外,論文還提到evolutionary strategies(ES)方法也採用了parameter perturbation的思想,但這篇論文提出ES忽略了時域信息,因而需要更多的樣本。

《Layer normalization》

深度學習訓練中一個很大的問題是一層中的梯度和上一層的輸出強相關,這會帶來covariate shift問題。流行的batch normalization方法主要用於克服該問題,從而可以減少訓練時間。它對每個隱藏單元的輸入作歸一化。理論上這個歸一化是相對於整個訓練集的,但實際中往往不現實,因此是拿mini-batch來近似。遺憾的是,它有些限制:

  • 不適用於RNN。用於RNN時需要對每一步都保存單獨的統計。但如果測試集中出現比訓練集中任意樣本都長的序列就玩不轉了。
  • 對mini batch的大小有要求,如不能用於batch爲1的情況(總共就一個歸一化也沒意義)。

而Layer normalization的做法是在同一層中做歸一化,從而讓同層神經元輸入擁有相同的均值和方差。從而也避免了上面的問題。

如果要將訓練的結果可視化,可以將render-eval和evaluation兩個選項設爲True。在訓練的一開始,策略還未學習,基本無法前進,而且過一會就撲街了。
這裏寫圖片描述
遺憾的是,到了第180步左右,算法學習到了一種很2B的前進方法。。。
這裏寫圖片描述
而且之後似乎在這條路上越走越遠。當到第300步左右時,就變這樣了。。。
這裏寫圖片描述
要想穩定學習到理想的策略,還是有段路要走。

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