【强化学习】Actor-Ctiric算法基本原理和pytorch代码详解

前言

本文将介绍Actor-Critic算法基本原理和实现步骤,以及基于pytorch框架的代码实现详解。
本文代码实现的编程环境如下:
编辑器:VS Code
编译器:python 3.9.10
依赖:
gym==0.26.2
gym-notices==0.0.8
gymnasium==0.29.1
matplotlib==3.8.3
matplotlib-inline==0.1.6
numpy==1.24.1
opencv-contrib-python==3.4.16.59
opencv-python==3.4.16.59
pygame==2.5.2
torch==2.1.2+cu121
torchaudio==2.1.2+cu121
torchvision==0.16.2+cu121


一、简介

Actor-Critic算法是一种强化学习算法,它结合了策略优化方法(如Policy Gradient方法)和值函数方法(如Q-Learning)的优点。
AC算法主要有两个部分:Actor和Critic
Actor负责选择动作。Actor网络的输入是状态信息,输出是所有动作的选择概率,随后便可以根据输出的动作选择概率进行随机抽取。
Critic负责评估Actor选择的动作的好坏。Critic网络的输入是状态信息,输出是价值。
通过训练这两个部分来让智能体完成工作便是这个算法的目的。

二、基本原理

2.1 核心步骤概述

AC算法的基本工作流程分为以下几步:
1、策略网络根据游戏环境状态信息给出动作概率。
2、根据动作概率随机抽取一个动作作为这一步要做出的动作。
3、游戏环境根据动作做出相应动作,使得游戏环境进入下一个状态。
4、计算策略损失和价值损失,回传梯度进行更新。
基本步骤

注:以上是最基本的也是最核心的步骤,其余细节后续再考虑。

2.2 核心步骤基本原理

2.2.1 决策动作

决策动作有两步,一是要通过网络获得动作概率分布,二是要根据概率分布抽取动作
第一步需要一个决策用的神经网络,该网络可以自定义,也可以参考其他项目中采用的网络。且要注意输入输出的张量维度大小要与游戏环境要求的一致。记该网络为actor,当前状态为state。那么决策动作的过程就是将state当做输入放入网络actor当中运行一遍,即actor(state)。其返回的结果(输出),就是动作的概率分布。
第二步则是根据动作概率分布来进行随机动作。例如向左走和向右走的概率分别为[0.3, 0.7],这就意味着本次选择向左走的概率为30%,向右走的概率为70%。抽取结束后,便完成了决策动作。

2.2.2 游戏更新

将上一步选出的动作传入游戏当中,在游戏中操作出这一步,那么游戏环境就会从上一个状态迈向下一个状态,此即游戏状态更新。记下一个状态为next_state,后续的计算将会用到它。

2.2.3 损失回传

由于有两个网络需要更新,所以损失的计算分为两个部分。在AC算法中,这两个网络所采用的损失函数并不相同,需要分开计算。在计算完两个损失函数后,便可以调用backward()函数进行回传更新网络权重。

2.2.3.1 Actor网络损失函数

策略网络的损失函数如下:
loss = − ln ⁡ π ( a ∣ s ) ⋅ A ( s , a ) \text{loss} = -\ln\pi(a|s) \cdot A(s, a) loss=lnπ(as)A(s,a)

π ( a ∣ s ) π(a∣s) π(as)就是策略函数在s状态下做出a动作的概率。一般来说,这里的策略函数就是指策略网络。而 ln ⁡ π ( a ∣ s ) \ln\pi(a|s) lnπ(as)就是将这个概率求对数。
A ( s , a ) A(s, a) A(s,a)是优势函数,其公式是一个时序差分,是真实价值和预测价值的差。具体公式如下:
A ( s , a ) = Q ( s , a ) − V ( s ) = R + γ ⋅ V ( s ′ ) − V ( s ) A(s, a) = Q(s, a) - V(s) = R + \gamma \cdot V(s') - V(s) A(s,a)=Q(s,a)V(s)=R+γV(s)V(s)

Q Q Q表示真实价值,也是 s s s状态下做出动作 a a a的价值,根据上述等式可以轻易发现 Q ( s , a ) = R + γ ⋅ V ( s ′ ) Q(s, a) = R + \gamma \cdot V(s') Q(s,a)=R+γV(s)
公式当中的符号表示的意义:
1、 R R R表示 s s s状态下做出 a a a动作后得到的奖励。
2、 γ \gamma γ表示折扣因子,用于计算累积折扣回报,即未来的预期收益。在无改进的AC算法中,这里的未来回报只计算后一步的。折扣因子越大,表示越重视未来的回报,一般取0.9~0.99。
3、 V ( s ′ ) V(s') V(s)表示 s s s做出 a a a后的下一个状态 s ′ s' s的价值。

V V V表示预测价值。预测价值表示的价值函数计算得出的价值。一般来说,这里的价值函数就是指价值网络。 V ( s ) V(s) V(s)就表示 s s s状态时,价值网络输出的价值。

注:价值函数通常被定义为在给定状态下,智能体按照其策略执行动作所能获得的预期回报。因为它的意义在于表示进入该状态后准备去决策动作的价值,而不是进入该状态并选择了特定动作的价值,所以价值函数的输入是不需要将决策的动作也一并放入的。也即状态的价值,而非状态下动作的价值。

将公式合并得最终公式:
loss = − ln ⁡ π ( a ∣ s ) ⋅ ( R + γ ⋅ V ( s ′ ) − V ( s ) ) \text{loss} = -\ln\pi(a|s) \cdot (R + \gamma \cdot V(s') - V(s)) loss=lnπ(as)(R+γV(s)V(s))

在使用该公式时,需要额外注意真实价值部分的 V ( s ′ ) V(s') V(s)。当游戏在 s s s状态时便已经结束,那么 V ( s ′ ) V(s') V(s)应当为0。

2.3.3.2 Critic网络损失函数

价值网络的损失函数如下:
loss = M S E ( Q ( s , a ) , V ( s ) ) \text{loss} = MSE(Q(s, a), V(s)) loss=MSE(Q(s,a),V(s))

该公式的意思是计算真实价值和预测价值的均方差。均方差公式如下:
M S E ( Y , Y ^ ) = 1 n ∑ i = 1 n ( y i − y ^ i ) 2 MSE(Y, \hat{Y}) = \frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2 MSE(Y,Y^)=n1i=1n(yiy^i)2

将公式合并得最终公式:
loss = M S E ( Q ( s , a ) , V ( s ) ) = 1 n ∑ i = 1 n ( R i + γ ⋅ V i ( s ′ ) − V i ( s ) ) 2 \text{loss} = MSE(Q(s, a), V(s)) = \frac{1}{n} \sum_{i=1}^{n} (R_i + \gamma \cdot V_i(s') - V_i(s))^2 loss=MSE(Q(s,a),V(s))=n1i=1n(Ri+γVi(s)Vi(s))2

如果训练时是每一步训练一次,即batch size = 1,那么可以将公式简化为:
loss = M S E ( Q ( s , a ) , V ( s ) ) = 1 n ( R + γ ⋅ V ( s ′ ) − V ( s ) ) 2 \text{loss} = MSE(Q(s, a), V(s)) = \frac{1}{n} (R + \gamma \cdot V(s') - V(s))^2 loss=MSE(Q(s,a),V(s))=n1(R+γV(s)V(s))2

注:均方差中有一个除以n的操作,也就是求平均值,在实际应用中也可以去掉。

三、详细设计

3.1 模块概述

AC算法的实现分为以下几个部分:
1、游戏环境:AC算法应用的环境,本文将以CartPole-v1游戏为例。
2、Actor和Critic的网络模型:两个网络模型的设计和实现。
3、智能体模块:装载了两个模型的智能体,可以做出决策动作和更新网络的操作。
4、强化学习模块:主要实现了智能体在游戏环境中进行训练的功能,还可以附带显示游戏环境、绘制得分图表、测试模型等功能。

3.2 游戏环境

游戏环境一般是需要自行编写的,不过这里主要是为了实现AC算法,所以采用了gym库当中的CartPole-v1游戏环境。
代码如下:

env = gym.make("CartPole-v1", render_mode="rgb_array")

这样就完成了游戏环境的搭建。其中render_mode是游戏环境显示的模式,如果选择human模式则会每次都显示游戏过程,选择rgb_array模式就可以自己控制显示的时间。在rgb_array模式下,想要让游戏环境显示出来时,调用以下代码即可:

cv2.imshow("env", env.render())
cv2.waitKey(1)

3.3 网络模型

由于采用的游戏很简单,所以采用的网络结构也很简单。以下是两个网络的定义:

from torch import nn

class PolicyNet(nn.Module):
    def __init__(self, n_states, n_hiddens, n_actions):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_states, n_hiddens),
            nn.ReLU(),
            nn.Linear(n_hiddens, n_actions),
            nn.Softmax(dim=1)
        )

    def forward(self, x):
        """前向传播
        """
        return self.net(x)


class ValueNet(nn.Module):
    def __init__(self, n_states, n_hiddens):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_states, n_hiddens),
            nn.ReLU(),
            nn.Linear(n_hiddens, 1)
        )

    def forward(self, x):
        """前向传播
        """
        return self.net(x)

可以看到,策略网络和价值网络都是全部由全连接层构成的神经网络。AC算法中默认采用的激活函数为ReLU函数。策略网络的输入和输出神经元个数分别对应游戏环境的状态长度n_states和游戏中可以做出的操作数n_actions。价值网络的输入和策略网络一致,输出是一个值,表示当前状态的价值。这两个网络结构都很简单,前向传播也没有什么特别的,直接将x放入计算得到结果返回即可。

3.4 智能体

智能体应当包括的功能有决策动作和更新模型。

3.4.1 构造函数

智能体的属性有模型所需的维度参数、模型、优化器、折扣因子和设备。
其中维度参数是需要游戏环境的属性的,所以需要在实例化智能体的时候传入参数来确定。折扣因子也可以传入参数修改。当然,学习率等信息也可以放入参数来确定,这里是直接写到优化器参数中了。
模型的装载应当分为两种方式。一是在已经有训练好的模型时装载训练好的模型;另一种则是直接创建一个未经训练的模型。这里将装载模型单另为一个函数。

以下为构造函数的实现:

def __init__(self, n_states, n_actions, n_hiddens, gamma=0.9):
    # 游戏环境的状态数和动作数
    self.n_states = n_states
    self.n_actions = n_actions
    self.n_hiddens = n_hiddens
    # 折扣因子
    self.gamma = gamma
    
    # 设备
    self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    # 模型
    self.actor, self.critic = self.load_model()
    # 策略网络的优化器
    self.actor_optimizer = torch.optim.Adam(self.actor.parameters(), lr=0.001)
    # 价值网络的优化器
    self.critic_optimizer = torch.optim.Adam(self.critic.parameters(), lr=0.01)

def load_model(self):
    """加载模型
    """
    actor = PolicyNet(self.n_states, self.n_hiddens, self.n_actions)
    critic = ValueNet(self.n_states, self.n_hiddens)
    # 有训练过的模型就加载, ROOT是一个全局变量, 表示文件所在目录
    if os.path.exists(ROOT / "actor.pth"):
        actor.load_state_dict(torch.load(ROOT / "actor.pth"))
    if os.path.exists(ROOT / "critic.pth"):
        critic.load_state_dict(torch.load(ROOT / "critic.pth"))
    return actor.to(self.device), critic.to(self.device)

3.4.2 决策动作

决策动作就是将当前状态信息放入策略网络中,以输出的概率为基础随机一个动作。
实现代码如下:

def take_action(self, state):
    """决策动作
    """
    # 维度变换numpy[n_states]->[1,n_sates]->tensor
    state = torch.tensor(state[np.newaxis, :]).to(self.device)
    # 动作价值函数, 当前状态下各个动作的概率
    probs = self.actor(state)
    # 创建以probs为标准类型的数据分布
    action_dist = torch.distributions.Categorical(probs)
    # 随机选择一个动作
    action = action_dist.sample().item()
    return action

传入的state是游戏环境的状态。在本文的案例当中,若state不经过任何处理的话,是一个长度为4的向量。这样是无法传入网络当中当做输入的,所以先提升一个维度。提升维度并不只有上面代码中的一种方法,还可以用这种方法:

state = torch.tensor(state).view(1, -1)

这种提升维度方法可以达到相同的效果。view函数的作用是将一个张量(tensor)整形为给定参数的样式,假如是参数为-1,就将剩余的都放入该维度。例如存在一个张量t = [1, 2, 3, 4, 5, 6, 7, 8],那么t.view(4, 2)的效果与t.view(-1, 2)t.view(4, -1)一致,都将整形成一个4行2列的张量。

整形结束后,就将状态放入网络中运行,得到的结果就是动作概率。如果自行编写随机抽样的比较复杂的,需要一定的工作量,但torch有已经封装好的类,可以方便进行抽样操作。其中Categorical类就是一个概率分布类,将probs放入实例化后得到的概率分布与probs一致,可以通过调用action_dist.probs来查看概率分布,会发现与probs确实是一致的。

对实例化的action_dist进行抽样便可以得到一个动作,因为需要一个整型值,所以要用item()将其提取出来。
最后返回结果即可。

3.4.3 更新模型

更新模型分为以下几步:
1、整理训练数据集,使得格式符合网络输入标准。
2、计算预测价值、真实价值、时序差分和动作概率的对数。
3、计算策略和价值网络损失函数。
4、更新两个网络。

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_value = self.critic(states)
    # 目标的当前状态的价值 = 当前状态的奖励 + 下一个状态的价值 * (1 - 是否终止)
    td_target = rewards + self.gamma * self.critic(next_states) * (1 - dones)
    # 时序差分误差 = 目标的当前状态的价值 - 预测的当前状态的价值
    td_delta = td_target - td_value
    
    # 对每个状态对应的动作价值用log函数
    log_probs = torch.log(self.actor(states).gather(1, actions))
    # 策略梯度损失
    actor_loss = torch.mean(-log_probs * td_delta.detach())
    # 值函数损失, 预测值和目标值之间
    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()

接下来结合代码讲解每一步操作。
1、整理数据集格式。每一个都应该整理成[样本1, 样本2, ...]的格式,且每个样本都是一个至少一维的张量。state在CartPole-v1游戏当中是一个长度为4的向量,若有n个样本(状态),就应该将其整形成(n, 4)大小的张量。类似的,actionsrewardsdones应该为(n, 1)大小;next_state应该和state格式一致。
2、计算预测价值、真实价值、时序差分和动作概率的对数。
1)预测价值就是价值网络输出结果,即 V ( s ) V(s) V(s)
2)真实价值需要套用 Q ( s , a ) Q(s, a) Q(s,a)公式,而这里就体现了之前在2.3.3.1结尾所说的要注意 V ( s ′ ) V(s') V(s)是否为0的问题。假如已经结束了,即done==True==1,那么 V ( s ′ ) V(s') V(s)就应当为0;当done==False==0时,便可以正常计算。
3)时序差分。即 A ( s , a ) A(s, a) A(s,a),其值等于 Q ( s , a ) − V ( s ) Q(s, a) - V(s) Q(s,a)V(s)
4)动作概率的对数。首先要通过策略网络得出所有动作的概率,随后按照之前在游戏当中的选择,重新选择这些动作的概率,最后对这些概率求对数即可。其中得出所有动作概率很简单,直接执行actor(state)即可。随后需要拿出之前选择的动作对应的概率,这时可以使用gather()函数来操作。gather()函数的作用是按照给定参数从张量中拿取元素,并返回一个张量。函数有两个参数需要确定,第一个参数表示从哪个维度进行抽取(维度是从0算起的),第二个参数表示在对应维度抽取元素的序列。例如存在一个张量t = [[[0, 1], [2, 3]], [[4, 5], [6, 7]]],又存在一个抽取序列张量idx_t = [[[0], [1]], [[1], [0]]],这时执行t.gather(2, idx_t),将会得到结果ret = [[[0], [3]], [[5], [6]]]。可以看出,维度为2,那么取的元素将是最内层的,第一个0对应第一个元素0。第二个1对应3,因为是对应位置([2, 3])的序列1对应元素。其他也一样,依次类推即可。最后将概率取出后对概率求对数即可。
3、计算损失值。套用2.3.3.1和2.3.3.2所介绍的损失函数即可。其中detach()的作用是为了避免其参与梯度的计算,而是当做一个纯粹的数值去使用。使用mean()函数的作用是取平均值,原因是本文采用的更新模型的策略是每一轮游戏结束后更新一次模型,这时传入的信息将会是本轮游戏所有的信息,而非走了一步的信息。所以这里采用mean()函数将其变为一个标量,才能够进行损失回传。当然,这里还可以采用其他方式计算,例如累和。
4、更新网络。每一个网络的更新都需要经历梯度清零、损失回传和参数更新三步。分别调用zero_grad()backward()step()即可。

3.5 强化学习

强化学习部分主要功能是训练智能体,可以在该部分附加一些其他功能,例如显示训练游戏过程,让智能体玩一局游戏,绘制训练结果图标等。

3.5.1 构造函数

构造函数必要的属性是游戏环境和智能体,其他可以根据功能需求添加。

def __init__(self, game="CartPole-v1") -> None:
    # 游戏环境
    self.env = gym.make(game, render_mode="rgb_array")
    self.n_states = self.env.observation_space.shape[0]
    self.n_actions = self.env.action_space.n
    self.n_hiddens = 32
    # 智能体
    self.agent = ActorCritic(self.n_states, self.n_actions, self.n_hiddens)
    # 分数列表
    self.scores = []

在实例化游戏环境之后,可以调用self.env.observation_space.shape[0]获取状态的形状信息,调用self.env.action_space.n获取动作总数。self.n_hiddens表示网络隐藏层的大小。之后就可以用这些大小信息实例化智能体。分数列表是用来记录训练过程中每一局智能体的最终得分的,为了方便绘制训练结果图标。

3.5.2 训练模型

训练模型的每一回合都有以下几个步骤:
1、重置游戏环境、重置各种相关变量。
2、每一回合游戏中的每一步都进行决策动作、环境更新、数据记录、更新状态、累积回合奖励,还可以选择性的显示游戏环境。
3、一轮游戏结束后进行模型更新、记录分数、绘制图表,选择性的保存模型。

def train(self, episodes=2000):
    """训练
    """
    for episode in tqdm(range(episodes), desc="训练进度"):
        # 重置环境
        state = self.env.reset()[0]
        done = False
        episode_score = 0
        # 存放数据集的字典
        transition_dict = defaultdict(list)

        while not done:
            # 动作选择
            action = self.agent.take_action(state)
            # 环境更新
            next_state, reward, terminated, truncated, _  = self.env.step(action)
            done = terminated or truncated
            # 保存每个时刻的信息
            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_score += reward

            # 显示环境
            if episode > 1000:
                self.show_env()

        # 模型更新
        self.agent.update(transition_dict)
        # 记录分数
        self.scores.append(episode_score)
        # 绘制分数曲线
        self.draw_scores()

        # 保存模型
        if episode % 100 == 0:
            # 平均分比以前高就保存
            if len(self.scores) <= 100 or np.mean(self.scores[-100:]) > np.mean(self.scores[: -100]):
                torch.save(self.agent.actor, ROOT / "actor.pth")
                torch.save(self.agent.critic, ROOT / "critic.pth")

1、首先要重置游戏环境,调用reset()将会把游戏重置,该函数的返回值第一个是状态信息,第二个是一个字典,记录了一些辅助信息。这里需要的状态信息,所以取[0]号元素。然后重置结束标志符、单局游戏得分和记录数据集的字典。
2、接下来只要游戏没有结束,就进行以下操作:
1)让智能体决策动作,即调用take_action()函数,将会获得一个动作action
2)将动作放入游戏环境,调用step(action),游戏环境便会向前一步。其返回值包括下一个状态信息、奖励、是否结束、是否截断和一些信息。可以用到的只有前四项。
3)结束和截断都被判定为当前回合结束,即done==True
4)将必要的信息保存入数据集字典当中。
5)更新状态和得分。
6)(可选)显示环境。这里是在训练1000回合后开始显示游戏环境,来监视智能体的游戏水平。
3、模型更新所采用的策略是每次游戏结束后再更新,具体更新方法参考3.4.3。模型更新亦可采用每一步都更新一次,那么就需要修改数据集的读取方式了。记录分数并绘制分数曲线直接调用相应函数,绘制分数曲线后续再讲解细节。保存模型可以自行修改保存策略。这里的策略是每100回合游戏结束后,如果最近的100轮游戏平均分比以往高就保存。

3.5.3 显示游戏环境

显示游戏环境很简单,env.render()rgb_array模式下会返回一张图,只需要调用cv2库中的imshow()函数就可以显示这张图了。waitKey()可以设置延迟,让图片不会立马消失。

def show_env(self):
    """显示环境
    """
    cv2.imshow("env", self.env.render())
    cv2.waitKey(1)

3.5.4 绘制分数图表

绘制分数曲线函数将绘制每轮游戏的分数和滑动窗口平均分数。

def draw_scores(self):
    """绘制分数曲线
    """
    # 打开同步
    plt.ion()

    # 创建画布
    plt.figure(1)
    # 清空画布
    plt.clf()
    # 绘制标题和坐标轴
    plt.title("Training...")
    plt.xlabel("Episode")
    plt.ylabel("Duration")

    # 转换为tensor
    durations_t = torch.tensor(self.scores, dtype=torch.float)
    # 绘制分数曲线
    plt.plot(durations_t.numpy())
    # 绘制滑动窗口的平均值
    if len(durations_t) >= 100:
        means = durations_t.unfold(0, 100, 1).mean(1).view(-1)
        means = torch.cat((torch.zeros(99), means))
        plt.plot(means.numpy())
        
    # 显示画布
    plt.pause(0.001)
    plt.show()

1、打开同步。这一步是为了图标可以自动更新,否则就需要手动关闭上次的窗口才能绘制下一张图。
2、创建一个画布并清空,随后绘制标题与坐标轴。直接调用函数即可。
3、绘制分数曲线。将scores转化为tensor类型,方便后续操作。调用numpy()函数可以将tensor类型转化为array类型。然后调用plot()函数绘制分数曲线即可。
4、绘制滑动窗口平均分。这一步的意思就是绘制范围为(0, 100), (1, 101), (2, 102), ...的滑动窗口平均值。首先用unfold()函数提取出滑动窗口的内容。该函数第一个参数表示提取维度,第二个表示大小,第三个表示步长。例如存在一个张量t = [ 1, 2, 3, 4, 5],那么t.unfold(0, 3, 1)的返回值就是[[1, 2, 3], [2, 3, 4], [3, 4, 5]]mean()函数的参数表示维度,即对哪个维度求平均值。经过计算后便得出了窗口大小为100的滑动窗口平均值,但是前面99个值是没有的,这里直接设置为0。用cat()函数进行拼接。最后将其绘制即可。
5、绘制结束后,就应该显示画布了。pause()函数表示暂停,参数为暂停时长,该函数还有一个重要的功能,就是强制更新画布。在更新后调用show()进行显示即可。

3.5.5 测试智能体

测试智能体就是让智能体去打一局游戏。过程很简单,其实就是将训练的过程省去,只剩下重置游戏环境、决策动作、更新游戏环境和显示游戏环境。所以这里就不再重复解释了。

def play_game(self):
    """打一局游戏
    """
    state = self.env.reset()[0]
    done = False
    while not done:
        self.show_env()
        action = self.agent.take_action(state)
        state, _, terminated, truncated, _ = self.env.step(action)
        done = terminated or truncated

四、代码实现

为了方便使用代码,这里的代码就不采用分文件的形式写了,全部代码都将集中在一个文件当中。以下为完整代码:

# 根路径
import sys
import pathlib
ROOT = pathlib.Path(__file__).resolve().parent
sys.path.append(ROOT)

# 引入依赖
import os
import cv2
import gym
import torch
import numpy as np
import matplotlib.pyplot as plt
from torch import nn
from tqdm import tqdm
from collections import defaultdict
from torch.nn import functional as F

class PolicyNet(nn.Module):
    def __init__(self, n_states, n_hiddens, n_actions):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_states, n_hiddens),
            nn.ReLU(),
            nn.Linear(n_hiddens, n_actions),
            nn.Softmax(dim=1)
        )

    def forward(self, x):
        """前向传播
        """
        return self.net(x)


class ValueNet(nn.Module):
    def __init__(self, n_states, n_hiddens):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_states, n_hiddens),
            nn.ReLU(),
            nn.Linear(n_hiddens, 1)
        )

    def forward(self, x):
        """前向传播
        """
        return self.net(x)


class ActorCritic:
    def __init__(self, n_states, n_actions, n_hiddens, gamma=0.9):
        # 游戏环境的状态数和动作数
        self.n_states = n_states
        self.n_actions = n_actions
        self.n_hiddens = n_hiddens
        # 折扣因子
        self.gamma = gamma

        # 设备
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        # 模型
        self.actor, self.critic = self.load_model()
        # 策略网络的优化器
        self.actor_optimizer = torch.optim.Adam(self.actor.parameters(), lr=0.001)
        # 价值网络的优化器
        self.critic_optimizer = torch.optim.Adam(self.critic.parameters(), lr=0.01)

    def load_model(self):
        """加载模型
        """
        actor = PolicyNet(self.n_states, self.n_hiddens, self.n_actions)
        critic = ValueNet(self.n_states, self.n_hiddens)
        # 有训练过的模型就加载
        if os.path.exists(ROOT / "actor.pth"):
            actor.load_state_dict(torch.load(ROOT / "actor.pth"))
        if os.path.exists(ROOT / "critic.pth"):
            critic.load_state_dict(torch.load(ROOT / "critic.pth"))
        return actor.to(self.device), critic.to(self.device)

    def take_action(self, state):
        """决策动作
        """
        # 维度变换numpy[n_states]->[1,n_sates]->tensor
        state = torch.tensor(state[np.newaxis, :]).to(self.device)
        # 动作价值函数, 当前状态下各个动作的概率
        probs = self.actor(state)
        # 创建以probs为标准类型的数据分布
        action_dist = torch.distributions.Categorical(probs)
        # 随机选择一个动作
        action = action_dist.sample().item()
        return action

    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_value = self.critic(states)
        # 目标的当前状态的价值 = 当前状态的奖励 + 下一个状态的价值 * (1 - 是否终止)
        td_target = rewards + self.gamma * self.critic(next_states) * (1 - dones)
        # 时序差分误差 = 目标的当前状态的价值 - 预测的当前状态的价值
        td_delta = td_target - td_value
        
        # 对每个状态对应的动作价值用log函数
        log_probs = torch.log(self.actor(states).gather(1, actions))
        # 策略梯度损失
        actor_loss = torch.mean(-log_probs * td_delta.detach())
        # 值函数损失, 预测值和目标值之间
        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()


class ReinforceLearning:
    def __init__(self, game="CartPole-v1") -> None:
        # 游戏环境
        self.env = gym.make(game, render_mode="rgb_array")
        self.n_states = self.env.observation_space.shape[0]
        self.n_actions = self.env.action_space.n
        self.n_hiddens = 32
        # 智能体
        self.agent = ActorCritic(self.n_states, self.n_actions, self.n_hiddens)
        # 分数列表
        self.scores = []

    def show_env(self):
        """显示环境
        """
        cv2.imshow("env", self.env.render())
        cv2.waitKey(1)

    def draw_scores(self):
        """绘制分数曲线
        """
        # 打开同步
        plt.ion()

        # 创建画布
        plt.figure(1)
        # 清空画布
        plt.clf()
        # 绘制标题和坐标轴
        plt.title("Training...")
        plt.xlabel("Episode")
        plt.ylabel("Duration")

        # 转换为tensor
        durations_t = torch.tensor(self.scores, dtype=torch.float)
        # 绘制分数曲线
        plt.plot(durations_t.numpy())
        # 绘制滑动窗口的平均值
        if len(durations_t) >= 100:
            means = durations_t.unfold(0, 100, 1).mean(1).view(-1)
            means = torch.cat((torch.zeros(99), means))
            plt.plot(means.numpy())
            
        # 显示画布
        plt.pause(0.001)
        plt.show()

    def train(self, episodes=2000):
        """训练
        """
        for episode in tqdm(range(episodes), desc="训练进度"):
            # 重置环境
            state = self.env.reset()[0]
            done = False
            episode_score = 0
            # 存放数据集的字典
            transition_dict = defaultdict(list)

            while not done:
                # 动作选择
                action = self.agent.take_action(state)
                # 环境更新
                next_state, reward, terminated, truncated, _  = self.env.step(action)
                done = terminated or truncated
                # 保存每个时刻的信息
                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_score += reward

                # 显示环境
                if episode > 1000:
                    self.show_env()

            # 模型更新
            self.agent.update(transition_dict)
            # 记录分数
            self.scores.append(episode_score)
            # 绘制分数曲线
            self.draw_scores()

            # 保存模型
            if episode % 100 == 0:
                # 平均分比以前高就保存
                if len(self.scores) <= 100 or np.mean(self.scores[-100:]) > np.mean(self.scores[: -100]):
                    torch.save(self.agent.actor.state_dict(), ROOT / "actor.pth")
                    torch.save(self.agent.critic.state_dict(), ROOT / "critic.pth")

    def play_game(self):
        """打一局游戏
        """
        state = self.env.reset()[0]
        done = False
        while not done:
            self.show_env()
            action = self.agent.take_action(state)
            state, _, terminated, truncated, _ = self.env.step(action)
            done = terminated or truncated


# 测试
if __name__ == "__main__":
    rl = ReinforceLearning()
    rl.train()

五、改进方向

本文所介绍的AC算法实现是最简单最朴素的实现,有很多地方可以改进,可以将一些更加贴合项目更加前沿的技术应用进去。
对于网络模型:
1、可以修改激活函数,让梯度更加符合要求。
2、加入batchnorm,加速训练。
3、修改网络结构,让网络有更好的性能。
对于智能体:
1、可以加入探索机制来决策动作。
对于训练:
1、可以修改优化器,调试出更好效果的优化器。
2、修改更新模型的策略,例如加入经验回放。
3、真实价值对未来价值的计算可以改进为n步TD。
4、将学习率改为自适应学习率。
还可以采用该算法的改进版本A2C和A3C。

六、其他相关问题

并不是所有的应用场景都像CartPole-v1这样简单,倒不如说大部分应用从场景都要比它复杂多得多。
例如简单的打飞机游戏就涉及到两个问题:
飞机打出去的子弹并不能够立刻得到击毁敌机的反馈(奖励),这个时候就需要采用延迟奖励的方法来获得奖励。这种方法需要跟踪每一个子弹,判断其最终的奖励是多少。
这种方法在自行编写的游戏当中还是较容易实现的。但如果这是一个封装好的游戏,想要通过游戏界面来训练就将困难不少,这时将需要计算机视觉来识别进行跟踪,将不能保证正确率达到100%,且可能需要额外训练识别跟踪的网络,工作量将大大提高。
总之,想要将强化学习算法应用到实际当中,往往需要结合其他技术来进行辅助才能达到需求。


参考资料

[1] 深度强化学习actor-critic模型解析,附pytorch完整代码(代码片段)
[2] 【强化学习】常用算法之一 “A3C”

  • 30
    点赞
  • 51
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值