【論文系列】PPO知識點梳理+程式碼 (盡我可能細緻通俗解釋!)

泪水下的笑靥發表於2024-12-09

零、題記

強化學習的知識很瑣碎,我自己當初學的很吃力,特此整理部落格,以便後人批判性學習………………
這篇部落格一方面為了記錄當前所整理的知識點,另一方面PPO演算法實在是太重要了,不但要從理論上理解它到底是怎樣實現的,還需要從程式碼方面進行學習,這裡我就通俗的將這個知識點進行簡單的記錄,用來日後自己的回顧和大家的交流學習。
下面均是我自己個人見解,如有不對之處,歡迎評論區指出錯誤!

一. 公式推導

這裡簡要交代PPO的演算法原理及思想過程,主要記錄自己的筆記,公式記錄比較詳細,我這裡就不再贅述了,後面程式碼會緊緊貼合前面的內容,並且會再次提到一些細節。
image

image

image

image

image

image

image

image

image

image

 好到這裡就是PPO的基本思想和RL的前期鋪墊工作了,這就是理論,脫離實踐的理論永遠也沒辦法好好理解,那麼下面我們來看看程式碼部分。

二 程式碼公示

程式碼選擇的是 動手學強化學習
下面我將逐一刨析整個程式碼的全部過程,之後大家可以將這些過程遷移到Issac Gym中進行學習和思考。

1 task_PPO.py檔案

點選檢視程式碼
import gym
import torch
import torch.nn.functional as F
import numpy as np
import matplotlib.pyplot as plt
import rl_utils


class PolicyNet(torch.nn.Module):
    def __init__(self, state_dim, hidden_dim, action_dim):
        super(PolicyNet, self).__init__()
        self.fc1 = torch.nn.Linear(state_dim, hidden_dim)
        self.fc2 = torch.nn.Linear(hidden_dim, action_dim)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        return F.softmax(self.fc2(x), dim=1)


class ValueNet(torch.nn.Module):
    def __init__(self, state_dim, hidden_dim):
        super(ValueNet, self).__init__()
        self.fc1 = torch.nn.Linear(state_dim, hidden_dim)
        self.fc2 = torch.nn.Linear(hidden_dim, 1)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        return self.fc2(x)


class PPO:
    ''' PPO演算法,採用截斷方式 '''
    def __init__(self, state_dim, hidden_dim, action_dim, actor_lr, critic_lr,
                 lmbda, epochs, eps, gamma, device):
        self.actor = PolicyNet(state_dim, hidden_dim, action_dim).to(device)
        self.critic = ValueNet(state_dim, hidden_dim).to(device)
        self.actor_optimizer = torch.optim.Adam(self.actor.parameters(),
                                                lr=actor_lr)
        self.critic_optimizer = torch.optim.Adam(self.critic.parameters(),
                                                 lr=critic_lr)
        self.gamma = gamma
        self.lmbda = lmbda
        self.epochs = epochs  # 一條序列的資料用來訓練輪數
        self.eps = eps  # PPO中截斷範圍的引數
        self.device = device

    def take_action(self, state):
        state = torch.tensor([state], dtype=torch.float).to(self.device)
        probs = self.actor(state)
        action_dist = torch.distributions.Categorical(probs)
        action = action_dist.sample()
        return action.item()

    def update(self, transition_dict):
        states = torch.tensor(transition_dict['states'],
                              dtype=torch.float).to(self.device)
        actions = torch.tensor(transition_dict['actions']).view(-1, 1).to(
            self.device)
        rewards = torch.tensor(transition_dict['rewards'],
                               dtype=torch.float).view(-1, 1).to(self.device)
        next_states = torch.tensor(transition_dict['next_states'],
                                   dtype=torch.float).to(self.device)
        dones = torch.tensor(transition_dict['dones'],
                             dtype=torch.float).view(-1, 1).to(self.device)
        td_target = rewards + self.gamma * self.critic(next_states) * (1 -
                                                                       dones)
        td_delta = td_target - self.critic(states)
        advantage = rl_utils.compute_advantage(self.gamma, self.lmbda,
                                               td_delta.cpu()).to(self.device)
        old_log_probs = torch.log(self.actor(states).gather(1,
                                                            actions)).detach()

        for _ in range(self.epochs):
            log_probs = torch.log(self.actor(states).gather(1, actions))
            ratio = torch.exp(log_probs - old_log_probs)
            surr1 = ratio * advantage
            surr2 = torch.clamp(ratio, 1 - self.eps,
                                1 + self.eps) * advantage  # 截斷
            actor_loss = torch.mean(-torch.min(surr1, surr2))  # PPO損失函式
            critic_loss = torch.mean(
                F.mse_loss(self.critic(states), td_target.detach()))
            self.actor_optimizer.zero_grad()
            self.critic_optimizer.zero_grad()
            actor_loss.backward()
            critic_loss.backward()
            self.actor_optimizer.step()
            self.critic_optimizer.step()

if __name__ == "__main__":
    #初始化引數
    actor_lr = 1e-3
    critic_lr = 1e-2
    num_episodes = 500
    hidden_dim = 128
    gamma = 0.98
    lmbda = 0.95
    epochs = 10
    eps = 0.2
    device = torch.device("cpu")
    env_name = "CartPole-v1"
    env = gym.make(env_name)
    env.reset(seed=0)
    torch.manual_seed(0)
    state_dim = env.observation_space.shape[0]
    action_dim = env.action_space.n
    #初始化智慧體
    agent = PPO(state_dim, hidden_dim, action_dim, actor_lr, critic_lr, lmbda,
                epochs, eps, gamma, device)
    #開始訓練
    return_list = rl_utils.train_on_policy_agent(env, agent, num_episodes)

    episodes_list = list(range(len(return_list)))
    plt.plot(episodes_list, return_list)
    plt.xlabel('Episodes')
    plt.ylabel('Returns')
    plt.title('PPO on {}'.format(env_name))
    plt.show()

    mv_return = rl_utils.moving_average(return_list, 9)
    plt.plot(episodes_list, mv_return)
    plt.xlabel('Episodes')
    plt.ylabel('Returns')
    plt.title('PPO on {}'.format(env_name))
    plt.show()

2 rl_utils.py檔案

點選檢視程式碼
from tqdm import tqdm
import numpy as np
import torch
import collections
import random


class ReplayBuffer:
    def __init__(self, capacity):
        self.buffer = collections.deque(maxlen=capacity)

    def add(self, state, action, reward, next_state, done):
        self.buffer.append((state, action, reward, next_state, done))

    def sample(self, batch_size):
        transitions = random.sample(self.buffer, batch_size)
        state, action, reward, next_state, done = zip(*transitions)
        return np.array(state), action, reward, np.array(next_state), done

    def size(self):
        return len(self.buffer)


def moving_average(a, window_size):
    cumulative_sum = np.cumsum(np.insert(a, 0, 0))
    middle = (cumulative_sum[window_size:] - cumulative_sum[:-window_size]) / window_size
    r = np.arange(1, window_size - 1, 2)
    begin = np.cumsum(a[:window_size - 1])[::2] / r
    end = (np.cumsum(a[:-window_size:-1])[::2] / r)[::-1]
    return np.concatenate((begin, middle, end))


def train_on_policy_agent(env, agent, num_episodes):
    return_list = []
    for i in range(10):
        with tqdm(total=int(num_episodes / 10), desc='Iteration %d' % i) as pbar:
            for i_episode in range(int(num_episodes / 10)):
                episode_return = 0
                transition_dict = {'states': [], 'actions': [], 'next_states': [], 'rewards': [], 'dones': []}
                state = env.reset()
                done = False
                while not done:
                    action = agent.take_action(state)
                    next_state, reward, done, _ = env.step(action)
                    transition_dict['states'].append(state)
                    transition_dict['actions'].append(action)
                    transition_dict['next_states'].append(next_state)
                    transition_dict['rewards'].append(reward)
                    transition_dict['dones'].append(done)
                    state = next_state
                    episode_return += reward
                return_list.append(episode_return)
                agent.update(transition_dict)
                if (i_episode + 1) % 10 == 0:
                    pbar.set_postfix({'episode': '%d' % (num_episodes / 10 * i + i_episode + 1),
                                      'return': '%.3f' % np.mean(return_list[-10:])})
                pbar.update(1)
    return return_list


def train_off_policy_agent(env, agent, num_episodes, replay_buffer, minimal_size, batch_size):
    return_list = []
    for i in range(10):
        with tqdm(total=int(num_episodes / 10), desc='Iteration %d' % i) as pbar:
            for i_episode in range(int(num_episodes / 10)):
                episode_return = 0
                state = env.reset()
                done = False
                while not done:
                    action = agent.take_action(state)
                    next_state, reward, done, _ = env.step(action)
                    replay_buffer.add(state, action, reward, next_state, done)
                    state = next_state
                    episode_return += reward
                    if replay_buffer.size() > minimal_size:
                        b_s, b_a, b_r, b_ns, b_d = replay_buffer.sample(batch_size)
                        transition_dict = {'states': b_s, 'actions': b_a, 'next_states': b_ns, 'rewards': b_r,
                                           'dones': b_d}
                        agent.update(transition_dict)
                return_list.append(episode_return)
                if (i_episode + 1) % 10 == 0:
                    pbar.set_postfix({'episode': '%d' % (num_episodes / 10 * i + i_episode + 1),
                                      'return': '%.3f' % np.mean(return_list[-10:])})
                pbar.update(1)
    return return_list


def compute_advantage(gamma, lmbda, td_delta):
    td_delta = td_delta.detach().numpy()
    advantage_list = []
    advantage = 0.0
    for delta in td_delta[::-1]:
        advantage = gamma * lmbda * advantage + delta
        advantage_list.append(advantage)
    advantage_list.reverse()
    return torch.tensor(advantage_list, dtype=torch.float)

3 開始刨析

(1)先從主函式執行開始,其他的遇到了我們再來分析

image
首先是對函式的一些變數進行初始化,都包括Adam最佳化器的學習率,episode的長度,隱藏層的維數,獎勵函式的係數\(\gamma\)值,GAE的\(\lambda\)值,epoch的長度(本程式碼表示的是進行梯度反向傳播的輪數),eps表示PPO中截斷範圍clip函式的引數(-eps~+eps),device是什麼裝置(原始碼是cuda,這裡大家可以更改下,因為我是用cpu跑的),然後下面的就是程式碼的一些初始化了,我這裡不再贅述。
image
這哥倆就表述:前者是觀測值的數量(比如讀取的電機這時的角度、力矩、電機此時速度、機器人此時尤拉角、base線速度等【扯遠了】),後者是動作空間的數量(比如輸出的電機該咋轉的角度,比如一隻A1有12個電機,那麼輸出的action的維數就是12,代表著每一個電機最後應該轉多少角度,往哪裡轉)

(2)初始化智慧體

image

這時我們呼叫PPO函式,然後我們來看一下PPO函式具體都幹了些什麼。(這裡就是起了這個名字,不要誤會它在這個函式里把所有PPO演算法都實現了)
image

我們下面一步一步來看。
image

a.第一個地方

這裡是對Actor-Critic演算法的復現,定義了兩個神經網路,這兩個神經網路具體長什麼樣子呢?我們來看看:
image

PolicyNet是Actor,相當於前面我們提到的Policy Improvement,簡稱PI,是策略改善的網路,最終我們希望輸入一個狀態,我們就知道該使用什麼樣子的action,黑盒明白吧。
image

ValueNet是Critic,對Actor的動作做評估,進而更新state value或者action value的值。網路結構我也大致畫一下:
image
其實也就相當於前面去掉了softmax,至於為什麼呢?
你看哈,actor網路的作用是選擇一個動作進行輸出,所以我們使用softmax進行分類,理論上選擇讓累計獎勵更大的動作作為較大機率的輸出(greedy action),所以這裡用了一個分類器。

b.第二個地方

由於需要對兩個網路進行反向傳播,所以這裡定義了兩個Adam最佳化器進行。
普通咱一般用的是這種:
image

Adam最佳化器是這樣子:(如果比較感興趣的話,這篇部落格個人感覺講的比較好!)
image

程式碼裡的lr就是咱前面提到的學習率,一般都是10的負幾次方。

c.第三個地方就沒啥說的了,幾個賦值

記住以後再看torch的程式碼,python的程式碼,一般很喜歡用class的形式,這時你就把self看成不同函式之間變數的搬運工,只要有定義self.xxxx你就要知道一般這東西是在別處定義的,或者別處也能用得到的,這一點不是很絕對,但至少對初學者來說很實用。

(3)開始訓練

image
我們轉到對應的函式檔案去看看(ctrl+滑鼠左鍵)
image
可以說這個函式是這個程式碼最核心的部分,也是PPO演算法最核心的部分,我們來看看咋實現的。

a.第一話

image
首先迴圈10次,每一輪咱訓練50個episodes,一個episode就包含著很多個\(<state,action>\)對(這個對長度不一定,可能隨時停止,也可能儘可能探索到最大的episode_max長度),最後10x50等於500,所有episodes都執行完畢,此處對應程式碼:num_episodes = 500
然後是用tqdm定義一個進度條(這個不是重點,知道就行)
初始化變數\(transition_dict\),這個變數包含5個值:現在的狀態states,執行的動作actions,執行完到下一個時刻的狀態next_states,獎勵函式rewards,以及這一時刻執行完的結束標誌done(結束為1,否則為0,記住後面程式碼裡要用到)

b.第二話

接著上面的程式碼再繼續分析
image

I)take actions

image
可以看到首先將輸入的state轉換為tensor,然後因為actor網路的最後一層是softmax函式,所以透過actor網路輸出兩個執行兩個動作可能性的大小,然後透過action_dist = torch.distributions.Categorical(probs) action = action_dist.sample()根據可能性大小進行取樣最後得到這次選擇動作1進行返回。

image

II)env.step(action)

按照採取的動作,和環境進行互動,返回到達下一時刻狀態的值、獎勵函式以及一個episode是否結束的結束標誌done。

III)transition_dist

然後將這些返回的值裝進這個字典裡面,有點像\(replay buffer\)我個人感覺。
然後更新下一時刻的狀態值,這個episode的獎勵函式也將累加episode_return += reward
然後透過return_list.append(episode_return)記錄下這一整個episode的總的累計獎勵R,用來plt繪圖吧,這個就類似TensorBoard的觀測輸出了。

IV)agent.update(...)

agent.update(transition_dict)到了這個函式。
這個函式說明了我們如何利用這些變數進行更新呢?
首先先從transition_dict取出變數存入tsnsor中便於計算
image

然後定義了TD Target:
image
image
然後是TD error
image
image
最後這倆就一起說了
image
前一個是計算GAE
advantage表示的是某狀態下采取某動作的優勢值,也就是咱前面提到的PPO演算法公式中的A:
image
image
old_log_probs則表示的是舊策略下某個狀態下采取某個動作的機率值的對數值。對應下面PPO演算法公式中的clip函式中分母:
image
然後這個函式下面就老有意思了:
image
首先第一部分: log_probs = torch.log(self.actor(states).gather(1, actions))
求對數值,然後下面新舊相減:
ratio = torch.exp(log_probs - old_log_probs)
這不就是\(e^{[ln(a)-ln(b)]} = \frac{a}{b}嘛\)
其實它就是在計算這個:
image

然後下面的surr1和surr2起初我也看不懂,後來看看演算法自然而然就明白是幹嘛的了:
image
這倆其實就是用前面的ratio分別相乘來作為下圖的兩個值,結果再求最小值,講約束條件納入公式裡面,這就是PPO的核心!!!
image
這其實就已經寫完了actor的損失函式了,也就是策略的損失函式了。至於critic的損失函式,是在求td target和critic(states)的MSE,均方誤差。
image
然後清零Adam,梯度反向傳播,更新。。迴圈epoch=10次進行引數的神經網路引數的更新,每一次相當於收集了50個episodes。

c.

                if (i_episode + 1) % 10 == 0:
                    pbar.set_postfix({'episode': '%d' % (num_episodes / 10 * i + i_episode + 1),
                                      'return': '%.3f' % np.mean(return_list[-10:])})
                pbar.update(1)

更新進度條,跟咱要學的PPO沒關係。

d.

return return_list返回這個獎勵函式表。用來最後的輸出評估:

(4)記錄

大家可能對整個流程比較糊塗,這裡簡單的做一個梳理:
image

下面引用自其他佬的部落格:
image

三、Acknowledge

感謝這些大佬的部落格和思路分享,才讓我漸漸理解整個程式碼思想。
鳴謝1
鳴謝2
鳴謝3

相關文章