Skip to content
Go back

强化学习学习记录(二):Dyna-Q、DQN 与 Actor-Critic

Edit page
49 min read

14. 第 08 章:Dyna-Q 算法

本章讨论的是 Dyna-Q。它和前一章的 Sarsa、Q-learning 最大区别在于:Dyna-Q 不只从真实环境采样中学习,还会学习一个环境模型,再用这个模型生成“模拟经验”来继续更新 QQ

一句话:

Q-learning 只用真实经验更新;Dyna-Q 同时用真实经验和模型模拟经验更新。

什么是“模型”。

在强化学习里,模型通常指环境模型,也就是描述:

P(ss,a)P(s'\mid s,a)

和:

R(s,a)R(s,a)

也就是说,模型回答两个问题:

如果模型已知,就可以用动态规划方法,比如策略迭代、价值迭代。

如果模型未知,但可以通过真实交互慢慢学出来,就进入 Dyna-Q 这类方法。

基于模型和无模型。

无模型强化学习直接从真实经验中学:

(s,a,r,s)更新价值函数或策略(s,a,r,s') \Rightarrow \text{更新价值函数或策略}

例如 Q-learning:

Q(s,a)Q(s,a)+α[r+γmaxaQ(s,a)Q(s,a)]Q(s,a)\leftarrow Q(s,a)+\alpha \left[ r+\gamma\max_{a'}Q(s',a')-Q(s,a) \right]

基于模型的强化学习会多做一步:用真实经验学习模型。

在确定性离散环境里,可以简单写成:

M(s,a)(r,s)M(s,a)\leftarrow (r,s')

意思是:

我记住了:上次在 ssaa,得到了奖励 rr,并转移到了 ss'

之后即使不再真实访问环境,也可以从模型里拿出这条经验,模拟一次更新。

Dyna-Q 的核心思想。

Dyna-Q 把三件事结合起来:

  1. 真实交互:智能体在真实环境中执行动作。
  2. 直接学习:用真实经验做一次 Q-learning 更新。
  3. 模型规划:把真实经验存进模型,再从模型里抽样,做若干次模拟 Q-learning 更新。

流程可以理解成:

真实经验{更新 Q更新模型 M用模型生成模拟经验继续更新 Q\text{真实经验} \rightarrow \begin{cases} \text{更新 }Q \\ \text{更新模型 }M \end{cases} \rightarrow \text{用模型生成模拟经验} \rightarrow \text{继续更新 }Q

这就是 Dyna-Q 的名字里 Dyna 的含义:learning、planning、acting 被整合在一起。

Dyna-Q 伪代码。

初始化:

Q(s,a)Q(s,a)

和模型:

M(s,a)M(s,a)

每次真实交互时:

  1. 在当前状态 ss 下,用 ϵ\epsilon-贪婪策略选择动作 aa
  2. 与真实环境交互,得到 r,sr,s'
  3. 用真实经验做一次 Q-learning 更新:
Q(s,a)Q(s,a)+α[r+γmaxaQ(s,a)Q(s,a)]Q(s,a)\leftarrow Q(s,a)+\alpha \left[ r+\gamma\max_{a'}Q(s',a')-Q(s,a) \right]
  1. 更新模型:
M(s,a)(r,s)M(s,a)\leftarrow (r,s')
  1. 重复 NN 次 Q-planning:

随机选一个曾经见过的状态动作对:

(sm,am)(s_m,a_m)

用模型查出:

(rm,sm)=M(sm,am)(r_m,s'_m)=M(s_m,a_m)

再做一次模拟的 Q-learning 更新:

Q(sm,am)Q(sm,am)+α[rm+γmaxaQ(sm,a)Q(sm,am)]Q(s_m,a_m)\leftarrow Q(s_m,a_m)+\alpha \left[ r_m+\gamma\max_{a'}Q(s'_m,a')-Q(s_m,a_m) \right]

这里的 NN 是 Q-planning 次数。

当:

N=0N=0

Dyna-Q 就退化成普通 Q-learning。

Q-planning 是什么。

Q-planning 的本质是:

不真实走环境,而是从已经学到的模型里抽一条经验,然后像 Q-learning 一样更新。

普通 Q-learning 的一次更新来自真实世界:

(s,a,r,s)真实环境(s,a,r,s')\sim \text{真实环境}

Dyna-Q 的规划更新来自模型:

(s,a,r,s)M(s,a,r,s')\sim M

所以 Dyna-Q 能更充分地利用一次真实交互。

假设真实环境中只走了一步,得到一条经验:

(s,a,r,s)(s,a,r,s')

普通 Q-learning 只更新一次。

Dyna-Q 会:

因此 Dyna-Q 的样本复杂度通常更低,即需要更少的真实环境交互。

代码结构。

这一章的代码仍然使用悬崖漫步环境。Dyna-Q 类比 Q-learning 多了两个核心成员:

self.n_planning = n_planning
self.model = dict()

其中:

核心 Q-learning 更新被单独写成:

def q_learning(self, s0, a0, r, s1):
    td_error = r + self.gamma * self.Q_table[s1].max() - self.Q_table[s0, a0]
    self.Q_table[s0, a0] += self.alpha * td_error

这和普通 Q-learning 一样。

Dyna-Q 真正多出来的是 update

def update(self, s0, a0, r, s1):
    self.q_learning(s0, a0, r, s1)
    self.model[(s0, a0)] = r, s1
    for _ in range(self.n_planning):
        (s, a), (r, s_) = random.choice(list(self.model.items()))
        self.q_learning(s, a, r, s_)

这段代码的逻辑是:

  1. 先用真实经验更新一次 QQ
  2. 把真实经验存入模型。
  3. 从模型里随机抽旧经验。
  4. 用抽到的模拟经验继续更新 QQ

所以 Dyna-Q 不是只“存经验”,而是“存经验后,把经验当成模型的一部分,继续做规划更新”。

Dyna-Q 和 replay buffer 的区别。

Dyna-Q 和 replay buffer 很像,但不是一回事。

Replay buffer 保存真实历史经验:

B={(s,a,r,s)}\mathcal{B}=\{(s,a,r,s')\}

训练时从 buffer 里抽样,本质上还是重复使用真实经验。

Dyna-Q 保存的是模型:

M(s,a)(r,s)M(s,a)\to(r,s')

然后通过模型产生模拟经验。

在这一章的确定性悬崖环境里,model 看起来就像一个经验字典,所以它和 replay buffer 很接近。但概念上:

如果环境是随机的,模型就不能只保存一个固定的 (r,s)(r,s'),而要估计概率分布:

P(ss,a)P(s'\mid s,a)

和奖励期望:

R(s,a)R(s,a)

这时 Dyna-Q 和 replay buffer 的区别会更明显。

实验结果:planning 步数的影响。

实验中比较了:

N=0,N=2,N=20N=0,\quad N=2,\quad N=20

其中 NN 是每次真实交互后做多少次 Q-planning。

结果现象是:

直觉上:

一次真实交互1+N 次价值更新\text{一次真实交互} \approx 1+N\text{ 次价值更新}

所以在模型准确时,NN 越大,真实样本的利用率越高。

但这并不表示 NN 永远越大越好。原因有两个:

  1. NN 越大,计算量越大。
  2. 如果模型不准,模拟经验会带来错误更新。

本章实验中悬崖漫步是确定性环境,所以:

M(s,a)=(r,s)M(s,a)=(r,s')

非常准确。因此增加 Q-planning 步数可以明显降低样本复杂度。

Dyna-Q 的优点和风险。

Dyna-Q 的优点:

Dyna-Q 的风险:

所以 Dyna-Q 的关键不只是“多做更新”,而是:

模型是否足够准确,决定了模拟经验是否可靠。

小结。

Dyna-Q 是连接无模型强化学习和基于模型强化学习的重要算法。

普通 Q-learning:

真实经验Q 更新\text{真实经验}\rightarrow Q\text{ 更新}

Dyna-Q:

真实经验{Q 更新M 更新模拟经验更新 Q\text{真实经验} \rightarrow \begin{cases} Q\text{ 更新}\\ M\text{ 更新}\\ \text{模拟经验更新 }Q \end{cases}

最重要的公式仍然是 Q-learning 更新:

Q(s,a)Q(s,a)+α[r+γmaxaQ(s,a)Q(s,a)]Q(s,a)\leftarrow Q(s,a)+\alpha \left[ r+\gamma\max_{a'}Q(s',a')-Q(s,a) \right]

Dyna-Q 只是把这个更新同时用于真实经验和模型生成的模拟经验。

本章要记住三句话:

这一章和前面章节的关系:

15. 第 10 章:DQN 算法

DQN 是把 Q-learning 从表格推到神经网络的一步。DQN 的全称是 Deep Q-Network,意思是用深度神经网络来近似 Q-learning 里的动作价值函数。

前面表格型 Q-learning 用一张表保存:

Q(s,a)Q(s,a)

但是当状态是连续的,或者状态维度很高时,表格就放不下了。DQN 的做法是:

Q(s,a)Qθ(s,a)Q(s,a)\approx Q_\theta(s,a)

其中 θ\theta 是神经网络参数。

一句话:

DQN = Q-learning + 神经网络函数近似 + 经验回放 + 目标网络。

为什么需要 DQN。

表格型 Q-learning 适合这种情况:

比如悬崖漫步中,状态就是网格编号,动作只有上下左右,直接建表即可:

QRS×AQ\in\mathbb{R}^{|\mathcal{S}|\times|\mathcal{A}|}

但如果状态是一张图片,例如:

84×84×484\times 84\times 4

就不可能给每种图片状态都建一行表。

CartPole 中状态虽然不是图片,但也是连续向量:

s=(x,x˙,θ,θ˙)s=(x,\dot{x},\theta,\dot{\theta})

其中:

动作是离散的:

a{0,1}a\in\{0,1\}

也就是向左或向右推小车。

DQN 适合处理:

状态连续或高维,但动作离散的问题。

从 Q 表到 Q 网络。

表格型 Q-learning 中,查表得到:

Q(s,a)Q(s,a)

DQN 中,用神经网络输出动作价值。

如果动作是离散的,可以让网络只输入状态 ss,一次性输出所有动作的价值:

Qθ(s)=[Qθ(s,a1),Qθ(s,a2),,Qθ(s,aA)]Q_\theta(s) = \left[ Q_\theta(s,a_1), Q_\theta(s,a_2), \dots, Q_\theta(s,a_{|\mathcal{A}|}) \right]

以 CartPole 为例,状态维度是 4,动作数量是 2,所以网络可以写成:

R4R2\mathbb{R}^4\to\mathbb{R}^2

输出两个数:

[Qθ(s,left),Qθ(s,right)]\left[ Q_\theta(s,\text{left}), Q_\theta(s,\text{right}) \right]

选择动作时,仍然可以用 ϵ\epsilon-贪婪策略:

a={随机动作,概率 ϵargmaxaQθ(s,a),概率 1ϵa= \begin{cases} \text{随机动作}, & \text{概率 }\epsilon\\ \arg\max_a Q_\theta(s,a), & \text{概率 }1-\epsilon \end{cases}

DQN 的 TD 目标。

DQN 继承 Q-learning 的思想。

表格型 Q-learning 的目标是:

r+γmaxaQ(s,a)r+\gamma\max_{a'}Q(s',a')

DQN 把 QQ 换成神经网络:

r+γmaxaQθ(s,a)r+\gamma\max_{a'}Q_\theta(s',a')

但实际 DQN 会用目标网络 QθQ_{\theta^-} 来计算目标:

y=r+γmaxaQθ(s,a)y= r+\gamma\max_{a'}Q_{\theta^-}(s',a')

如果当前样本已经终止,即 done=True,后面没有未来回报,所以:

y=ry=r

可以合并写成:

y=r+γ(1d)maxaQθ(s,a)y= r+\gamma(1-d)\max_{a'}Q_{\theta^-}(s',a')

其中:

d={1,终止0,未终止d= \begin{cases} 1, & \text{终止}\\ 0, & \text{未终止} \end{cases}

DQN 的损失函数。

当前网络对这条样本的估计是:

Qθ(s,a)Q_\theta(s,a)

TD 目标是:

y=r+γ(1d)maxaQθ(s,a)y= r+\gamma(1-d)\max_{a'}Q_{\theta^-}(s',a')

所以损失函数就是均方误差:

L(θ)=E(s,a,r,s,d)D[(Qθ(s,a)y)2]L(\theta) = \mathbb{E}_{(s,a,r,s',d)\sim\mathcal{D}} \left[ \left( Q_\theta(s,a)-y \right)^2 \right]

展开就是:

L(θ)=E[(Qθ(s,a)[r+γ(1d)maxaQθ(s,a)])2]L(\theta) = \mathbb{E} \left[ \left( Q_\theta(s,a) - \left[ r+\gamma(1-d)\max_{a'}Q_{\theta^-}(s',a') \right] \right)^2 \right]

训练 DQN,本质就是用梯度下降让:

Qθ(s,a)Q_\theta(s,a)

靠近 TD 目标:

yy

经验回放 Replay Buffer。

DQN 使用经验回放池保存 transition:

(s,a,r,s,d)(s,a,r,s',d)

训练时不是只用刚刚发生的那一步,而是从 buffer 里随机抽一个 batch:

{(si,ai,ri,si,di)}i=1B\{(s_i,a_i,r_i,s'_i,d_i)\}_{i=1}^B

这样做有两个主要作用。

第一,打破时间相关性。

强化学习连续采样的数据往往高度相关:

st,st+1,st+2s_t,s_{t+1},s_{t+2}

彼此非常接近。如果神经网络总是按时间顺序训练,容易只拟合最近一段经历。

随机从 replay buffer 采样,可以让 batch 更接近独立同分布。

这里要注意:经验回放并不是严格地把强化学习样本变成独立同分布,而是让它更接近独立同分布。

原始交互数据是按时间连续产生的:

(st,at,rt,st+1)(s_t,a_t,r_t,s_{t+1})

下一条马上就是:

(st+1,at+1,rt+1,st+2)(s_{t+1},a_{t+1},r_{t+1},s_{t+2})

第二条的起点就是第一条的终点,所以它们天然强相关。

例如 CartPole 中,连续几十帧可能都处在“杆稍微向右倾、小车正在向右移动”的相近状态。如果直接拿最近几十条 transition 训练网络,神经网络容易只拟合最近这一小段经历。

经验回放的做法是先把很多历史 transition 放进 buffer:

B={(s,a,r,s,d)}\mathcal{B}=\{(s,a,r,s',d)\}

训练时随机抽样,而不是按时间顺序取:

t,t+1,t+2,t+3t,t+1,t+2,t+3

随机抽样可能拿到:

37,810,52,1990,403,37,810,52,1990,403,\dots

这样一个 batch 中可能混合了不同 episode、不同时间段、不同状态区域的数据。时间相关性被削弱了,batch 更像监督学习中的随机小批量数据。

“同分布”也要谨慎理解。在某一时刻,样本都是从同一个 replay buffer 分布中抽出来的:

(s,a,r,s,d)B(s,a,r,s',d)\sim\mathcal{B}

但 buffer 本身会不断更新,里面的数据也可能来自不同历史策略,所以它不是严格数学意义上的固定分布。更准确的说法是:

replay buffer 让训练 batch 更接近 iid,而不是严格变成 iid。

第二,提高样本效率。

一个 transition 不只用一次,而是可以被抽到很多次。

这和前面 Dyna-Q 的思想有相似处:都不把经验用完就扔。

但区别是:

可以这样对比:

方法保存的是什么后续怎么用是否 model-based
DQN replay buffer真实发生过的 transition从 buffer 随机抽真实旧经验训练
Dyna-Q环境模型 M(s,a)M(s,a)用模型生成模拟 transition 再更新

DQN replay buffer 的流程是:

真实经验存入 buffer随机回放真实旧经验\text{真实经验} \rightarrow \text{存入 buffer} \rightarrow \text{随机回放真实旧经验}

Dyna-Q 的流程是:

真实经验学习模型 M模型生成模拟经验\text{真实经验} \rightarrow \text{学习模型 }M \rightarrow \text{模型生成模拟经验}

所以二者目标相似,都是提高样本利用率;但 replay buffer 仍然是 model-free,Dyna-Q 是 model-based。

在确定性离散环境中,Dyna-Q 的模型可能只是:

M(s,a)=(r,s)M(s,a)=(r,s')

这时它看起来很像一个经验字典,所以和 replay buffer 特别像。

但如果环境是随机的,区别会更明显。同一个状态动作对:

(s,a)(s,a)

可能有时到达 s1s'_1,有时到达 s2s'_2。Replay buffer 会保存很多真实发生过的样本:

(s,a,r1,s1),(s,a,r2,s2),(s,a,r_1,s'_1),\quad (s,a,r_2,s'_2),\dots

Dyna-Q 或其他 model-based 方法则需要学习概率模型:

P(ss,a)P(s'\mid s,a)

再从这个模型中模拟下一个状态。

为什么 DQN 可以用旧经验。

DQN 基于 Q-learning,而 Q-learning 是 off-policy。

也就是说,行为策略可以是:

ϵ-greedy\epsilon\text{-greedy}

目标策略是:

π(s)=argmaxaQ(s,a)\pi(s)=\arg\max_a Q(s,a)

因此 DQN 可以用旧策略采集的数据来更新当前 Q 网络。

这就是 replay buffer 能成立的关键原因:

DQN 不要求样本一定来自当前最新策略。

所以 DQN 通常是:

online RL+off-policy+replay buffer\text{online RL}+\text{off-policy}+\text{replay buffer}

它仍然在线,因为训练时还在不断和环境交互采新数据;它也是 off-policy,因为学习目标是贪心策略,而采样时可以探索。

目标网络 Target Network。

如果直接用同一个网络计算当前估计和目标:

y=r+γmaxaQθ(s,a)y= r+\gamma\max_{a'}Q_\theta(s',a')

会有一个问题:你在更新 θ\theta 的时候,目标 yy 也跟着变。

也就是说,神经网络一边追目标,目标一边跑。

这会造成训练不稳定。

DQN 的解决方法是使用两个网络:

当前估计:

Qθ(s,a)Q_\theta(s,a)

目标值:

y=r+γ(1d)maxaQθ(s,a)y= r+\gamma(1-d)\max_{a'}Q_{\theta^-}(s',a')

目标网络参数 θ\theta^- 不每一步更新,而是每隔 CC 步从当前网络复制一次:

θθ\theta^-\leftarrow\theta

这样 TD 目标在一段时间内比较稳定,训练会更稳。

DQN 代码结构。

这一章代码主要有三个组件。

第一,经验回放池:

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

这里 deque(maxlen=capacity) 表示先进先出。buffer 满了以后,最早的经验会被丢掉。

第二,Q 网络:

class Qnet(torch.nn.Module):
    def __init__(self, state_dim, hidden_dim, action_dim):
        super(Qnet, 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 self.fc2(x)

在 CartPole 中:

state_dim=4state\_dim=4 action_dim=2action\_dim=2

所以网络输入 4 维状态,输出 2 个动作价值。

第三,DQN 更新:

q_values = self.q_net(states).gather(1, actions)
max_next_q_values = self.target_q_net(next_states).max(1)[0].view(-1, 1)
q_targets = rewards + self.gamma * max_next_q_values * (1 - dones)
dqn_loss = torch.mean(F.mse_loss(q_values, q_targets))

其中:

self.q_net(states)

输出每个状态下所有动作的 QQ 值。

gather(1, actions)

表示只取实际执行过的动作对应的 QQ 值,也就是:

Qθ(s,a)Q_\theta(s,a)
self.target_q_net(next_states).max(1)[0]

表示取下一状态中最大的动作价值:

maxaQθ(s,a)\max_{a'}Q_{\theta^-}(s',a')

训练流程。

DQN 训练过程可以概括为:

  1. 初始化当前 Q 网络 QθQ_\theta
  2. 初始化目标 Q 网络 QθQ_{\theta^-}
  3. 初始化 replay buffer。
  4. 智能体用 ϵ\epsilon-greedy 和环境交互。
  5. 把 transition 存入 buffer:
(s,a,r,s,d)(s,a,r,s',d)
  1. 当 buffer 中样本数量超过 minimal_size 后,随机采样 batch。
  2. 计算 TD target。
  3. 用 MSE loss 更新当前 Q 网络。
  4. 每隔 target_update 次,同步目标网络:
θθ\theta^-\leftarrow\theta

代码中的主要超参数是:

lr = 2e-3
num_episodes = 500
hidden_dim = 128
gamma = 0.98
epsilon = 0.01
target_update = 10
buffer_size = 10000
minimal_size = 500
batch_size = 64

其中:

以图像为输入的 DQN。

如果状态是图像,不能再用简单全连接网络直接处理原始像素,通常使用卷积神经网络提取图像特征。

章节中给出的卷积 Q 网络结构是:

class ConvolutionalQnet(torch.nn.Module):
    def __init__(self, action_dim, in_channels=4):
        super(ConvolutionalQnet, self).__init__()
        self.conv1 = torch.nn.Conv2d(in_channels, 32, kernel_size=8, stride=4)
        self.conv2 = torch.nn.Conv2d(32, 64, kernel_size=4, stride=2)
        self.conv3 = torch.nn.Conv2d(64, 64, kernel_size=3, stride=1)
        self.fc4 = torch.nn.Linear(7 * 7 * 64, 512)
        self.head = torch.nn.Linear(512, action_dim)

这里 in_channels=4 的意思是:通常把最近 4 帧图像叠在一起作为输入。

为什么要叠多帧?

因为单张图片只能看到当前位置,看不到速度。连续几帧可以让网络推断运动方向和速度。

输出仍然是每个离散动作的价值:

Qθ(s,a1),Qθ(s,a2),,Qθ(s,aA)Q_\theta(s,a_1),Q_\theta(s,a_2),\dots,Q_\theta(s,a_{|\mathcal{A}|})

DQN 和前面算法的关系。

和 Q-learning 的关系:

DQN=Q-learning 的神经网络版本\text{DQN}=\text{Q-learning 的神经网络版本}

表格型 Q-learning:

Q(s,a)Q(s,a)+α[r+γmaxaQ(s,a)Q(s,a)]Q(s,a)\leftarrow Q(s,a)+\alpha \left[ r+\gamma\max_{a'}Q(s',a')-Q(s,a) \right]

DQN:

θθηθ(Qθ(s,a)[r+γ(1d)maxaQθ(s,a)])2\theta\leftarrow \theta-\eta\nabla_\theta \left( Q_\theta(s,a)- \left[ r+\gamma(1-d)\max_{a'}Q_{\theta^-}(s',a') \right] \right)^2

一个更新表格里的数,一个更新神经网络参数。

和 Dyna-Q 的关系:

和动态规划的关系:

容易混淆的点。

第一,DQN 不是适合所有连续问题。

DQN 适合:

连续状态+离散动作\text{连续状态}+\text{离散动作}

如果动作也是连续的,无法简单计算:

maxaQ(s,a)\max_{a'}Q(s',a')

因为动作空间无限大。这时通常需要 DDPG、SAC 等算法。

第二,replay buffer 不等于离线强化学习。

DQN 训练时通常还在继续和环境交互,所以它是 online RL。Replay buffer 只是保存旧数据并重复抽样。

第三,目标网络不是另一个智能体。

目标网络只是当前网络的旧版本,用来稳定 TD target。

第四,DQN 的 TD target 不对目标网络反向传播。

训练时优化的是当前网络 QθQ_\theta,目标网络 QθQ_{\theta^-} 只用于构造目标。

小结。

DQN 解决的是表格型 Q-learning 无法处理大规模或连续状态空间的问题。

它的核心结构是:

sQ网络[Q(s,a1),Q(s,a2),,Q(s,an)]s \xrightarrow{\text{Q网络}} \left[ Q(s,a_1),Q(s,a_2),\dots,Q(s,a_n) \right]

核心目标是:

y=r+γ(1d)maxaQθ(s,a)y= r+\gamma(1-d)\max_{a'}Q_{\theta^-}(s',a')

核心损失是:

L(θ)=(Qθ(s,a)y)2L(\theta)= \left( Q_\theta(s,a)-y \right)^2

最重要的两个稳定技巧:

本章要记住三句话:

16. 第 11 章:DQN 改进算法

普通 DQN 跑起来之后,最先暴露的两个问题是估值偏高和状态价值学习效率不高。这里看两个经典改进:

这两个方法都不是从头发明新算法,而是在 DQN 基础上做小改动。

一句话:

Double DQN 改 TD target 的计算方式;Dueling DQN 改 Q 网络的结构。

普通 DQN 的问题:过高估计。

普通 DQN 的 TD 目标是:

y=r+γmaxaQω(s,a)y= r+\gamma\max_{a'}Q_{\omega^-}(s',a')

其中 QωQ_{\omega^-} 是目标网络。

注意这里有一个 max\max

maxaQω(s,a)\max_{a'}Q_{\omega^-}(s',a')

它可以拆成两步:

  1. 选择下一状态下估计值最大的动作:
a=argmaxaQω(s,a)a^*= \arg\max_{a'}Q_{\omega^-}(s',a')
  1. 取这个动作的估计价值:
Qω(s,a)Q_{\omega^-}(s',a^*)

所以普通 DQN 实际上是:

maxaQω(s,a)=Qω(s,argmaxaQω(s,a))\max_{a'}Q_{\omega^-}(s',a') = Q_{\omega^-} \left( s', \arg\max_{a'}Q_{\omega^-}(s',a') \right)

问题在于:选动作和估价值都用了同一个网络 QωQ_{\omega^-}

如果某个动作的估计值因为神经网络误差被高估了,那么 max\max 很容易选中它。于是 TD target 也会偏大:

y=r+γmaxaQω(s,a)y= r+\gamma\max_{a'}Q_{\omega^-}(s',a')

也会偏大。

这样当前的 Q(s,a)Q(s,a) 会被往偏大的目标上更新。这个偏大的 QQ 又会继续作为前面状态的更新目标,于是过高估计会逐步传播。

直觉上:

max\max 操作喜欢挑估计误差里“运气最好”的那个动作,所以容易把噪声当成真实价值。

Double DQN 的核心思想。

Double DQN 的核心是:

选动作和估价值不要用同一个网络。

普通 DQN:

yDQN=r+γQω(s,argmaxaQω(s,a))y_{\text{DQN}} = r+\gamma Q_{\omega^-} \left( s', \arg\max_{a'}Q_{\omega^-}(s',a') \right)

Double DQN:

yDoubleDQN=r+γQω(s,argmaxaQω(s,a))y_{\text{DoubleDQN}} = r+\gamma Q_{\omega^-} \left( s', \arg\max_{a'}Q_{\omega}(s',a') \right)

区别只在 argmax\arg\max 里面:

可以分成两步看:

先用当前网络选出下一状态的动作:

a=argmaxaQω(s,a)a^* = \arg\max_{a'}Q_{\omega}(s',a')

再用目标网络评价这个动作:

Qω(s,a)Q_{\omega^-}(s',a^*)

所以 Double DQN 的 TD target 是:

y=r+γ(1d)Qω(s,argmaxaQω(s,a))y= r+\gamma(1-d) Q_{\omega^-} \left( s', \arg\max_{a'}Q_{\omega}(s',a') \right)

其中 dd 是终止标记。若 episode 已结束,d=1d=1,目标就是:

y=ry=r

Double DQN 为什么能缓解过高估计。

普通 DQN 的问题是:

哪个动作被高估,就更容易被 max\max 选中,然后这个高估值又直接进入 TD target。

Double DQN 把“选动作”和“估价值”分开。

即使当前网络 QωQ_\omega 因为噪声选中了某个动作:

a=argmaxaQω(s,a)a^*= \arg\max_{a'}Q_\omega(s',a')

最终放进 TD target 的不是当前网络对它的估计,而是目标网络的估计:

Qω(s,a)Q_{\omega^-}(s',a^*)

两个网络参数不完全一样,误差不完全同步,因此一个网络中的正向误差不一定会被另一个网络同样高估。

所以 Double DQN 不是完全消除估计误差,而是减弱:

max\max

对正向噪声的偏好。

Double DQN 代码改动。

普通 DQN 计算下一状态最大 Q 值:

max_next_q_values = self.target_q_net(next_states).max(1)[0].view(-1, 1)

这表示:

maxaQω(s,a)\max_{a'}Q_{\omega^-}(s',a')

Double DQN 改成两步:

max_action = self.q_net(next_states).max(1)[1].view(-1, 1)
max_next_q_values = self.target_q_net(next_states).gather(1, max_action)

第一句:

self.q_net(next_states).max(1)[1]

表示用当前网络选动作:

argmaxaQω(s,a)\arg\max_{a'}Q_\omega(s',a')

第二句:

self.target_q_net(next_states).gather(1, max_action)

表示用目标网络取这个动作的价值:

Qω(s,argmaxaQω(s,a))Q_{\omega^-} \left( s', \arg\max_{a'}Q_\omega(s',a') \right)

所以 DQN 和 Double DQN 的代码区别可以概括成:

if self.dqn_type == 'DoubleDQN':
    max_action = self.q_net(next_states).max(1)[1].view(-1, 1)
    max_next_q_values = self.target_q_net(next_states).gather(1, max_action)
else:
    max_next_q_values = self.target_q_net(next_states).max(1)[0].view(-1, 1)

后面的 loss 计算、反向传播、目标网络同步都和 DQN 一样。

倒立摆实验为什么能看过高估计。

本章用 Pendulum 倒立摆环境做实验。原始 Pendulum 动作是连续力矩:

a[2,2]a\in[-2,2]

但 DQN 只能处理离散动作,所以代码把连续动作离散成 11 个动作:

action_dim=11action\_dim=11

离散动作再映射回连续力矩:

def dis_to_con(discrete_action, env, action_dim):
    action_lowbound = env.action_space.low[0]
    action_upbound = env.action_space.high[0]
    return action_lowbound + (discrete_action / (action_dim - 1)) * (
        action_upbound - action_lowbound
    )

在这个环境中,最好的情况是倒立摆保持竖直,此时每步奖励最大约为 0。因此合理的最大 QQ 值不应该明显大于 0。

如果训练中出现:

maxaQ(s,a)>0\max_a Q(s,a)>0

就说明发生了过高估计。

实验现象是:

这说明 Double DQN 缓解了 QQ 值过高估计。

Dueling DQN 的核心思想。

Dueling DQN 解决的不是过高估计,而是 Q 网络结构的表达效率问题。

普通 DQN 直接输出每个动作的动作价值:

Q(s,a1),Q(s,a2),,Q(s,an)Q(s,a_1),Q(s,a_2),\dots,Q(s,a_n)

Dueling DQN 认为动作价值可以拆成两部分:

  1. 状态本身好不好:
V(s)V(s)
  1. 在这个状态下,某个动作比平均水平好多少:
A(s,a)A(s,a)

优势函数定义为:

A(s,a)=Q(s,a)V(s)A(s,a)=Q(s,a)-V(s)

所以:

Q(s,a)=V(s)+A(s,a)Q(s,a)=V(s)+A(s,a)

这就是 Dueling DQN 的基本分解。

直觉上:

比如开车游戏中:

Dueling DQN 的分解不唯一问题。

如果直接写:

Q(s,a)=V(s)+A(s,a)Q(s,a)=V(s)+A(s,a)

会出现不唯一。

假设把 V(s)V(s) 加一个常数 CC

V(s)=V(s)+CV'(s)=V(s)+C

同时把所有优势函数减去 CC

A(s,a)=A(s,a)CA'(s,a)=A(s,a)-C

那么:

V(s)+A(s,a)=V(s)+C+A(s,a)C=V(s)+A(s,a)=Q(s,a)V'(s)+A'(s,a) = V(s)+C+A(s,a)-C = V(s)+A(s,a) = Q(s,a)

也就是说,很多组不同的 VVAA 都能表示同一个 QQ。这会让训练不稳定。

为了解决这个问题,Dueling DQN 会给 AA 加一个约束。

一种写法是减去最大优势:

Q(s,a)=V(s)+A(s,a)maxaA(s,a)Q(s,a) = V(s)+A(s,a)-\max_{a'}A(s,a')

实际实现中更常用的是减去平均优势:

Q(s,a)=V(s)+A(s,a)1AaA(s,a)Q(s,a) = V(s)+A(s,a) - \frac{1}{|\mathcal{A}|} \sum_{a'}A(s,a')

这样可以让优势函数的均值为 0:

1AaA(s,a)=0\frac{1}{|\mathcal{A}|} \sum_{a}A(s,a)=0

从而让 V(s)V(s) 更像所有动作价值的平均水平:

V(s)1AaQ(s,a)V(s)\approx \frac{1}{|\mathcal{A}|} \sum_a Q(s,a)

Dueling DQN 代码结构。

Dueling DQN 主要改 Q 网络结构,不主要改更新公式。

代码中的 VAnet

class VAnet(torch.nn.Module):
    def __init__(self, state_dim, hidden_dim, action_dim):
        super(VAnet, self).__init__()
        self.fc1 = torch.nn.Linear(state_dim, hidden_dim)
        self.fc_A = torch.nn.Linear(hidden_dim, action_dim)
        self.fc_V = torch.nn.Linear(hidden_dim, 1)

    def forward(self, x):
        A = self.fc_A(F.relu(self.fc1(x)))
        V = self.fc_V(F.relu(self.fc1(x)))
        Q = V + A - A.mean(1).view(-1, 1)
        return Q

这里有三部分:

共享层:

self.fc1 = torch.nn.Linear(state_dim, hidden_dim)

优势分支:

self.fc_A = torch.nn.Linear(hidden_dim, action_dim)

它输出每个动作的优势值:

A(s,a1),A(s,a2),,A(s,an)A(s,a_1),A(s,a_2),\dots,A(s,a_n)

价值分支:

self.fc_V = torch.nn.Linear(hidden_dim, 1)

它输出一个状态价值:

V(s)V(s)

最后合成 Q 值:

Q = V + A - A.mean(1).view(-1, 1)

对应公式:

Q(s,a)=V(s)+A(s,a)1AaA(s,a)Q(s,a) = V(s)+A(s,a) - \frac{1}{|\mathcal{A}|}\sum_{a'}A(s,a')

注意:A 的形状是:

[batch_size,action_dim][batch\_size, action\_dim]

V 的形状是:

[batch_size,1][batch\_size,1]

PyTorch 会自动广播,把同一个 V(s)V(s) 加到该状态下所有动作的优势值上。

Dueling DQN 为什么有用。

普通 DQN 更新某个 transition:

(s,a,r,s)(s,a,r,s')

时,主要更新被执行动作 aa 对应的:

Q(s,a)Q(s,a)

其他动作的 Q(s,a)Q(s,a') 不一定直接得到更新。

Dueling DQN 中,所有动作共享同一个状态价值分支 V(s)V(s)。只要这个状态被训练,V(s)V(s) 就会被更新,而 V(s)V(s) 会影响该状态下所有动作的 QQ

Q(s,a)=V(s)+A(s,a)A(s)Q(s,a)=V(s)+A(s,a)-\overline{A}(s)

所以它能更高效地学习“这个状态整体好不好”。

当动作空间较大,或者很多动作在某些状态下差别不明显时,Dueling DQN 的优势更明显。

Double DQN 和 Dueling DQN 的区别。

方法改哪里解决什么问题核心公式
Double DQNTD targetQQ 值过高估计Qω(s,argmaxaQω(s,a))Q_{\omega^-}(s',\arg\max_{a'}Q_\omega(s',a'))
Dueling DQN网络结构更好学习状态价值和动作差异Q=V+AAQ=V+A-\overline{A}

所以:

二者可以结合使用。

过高估计的定量直觉。

章节扩展阅读中给了一个简化分析。

假设在某个状态 ss 下,所有动作真实价值都一样:

Q(s,ai)=V(s),iQ_*(s,a_i)=V_*(s),\quad \forall i

神经网络估计有误差:

ϵi=Qω(s,ai)V(s)\epsilon_i=Q_\omega(s,a_i)-V_*(s)

并且误差独立同分布于:

[1,1][-1,1]

如果动作数量是 mm,那么:

E[maxaQω(s,a)maxaQ(s,a)]=m1m+1\mathbb{E} \left[ \max_a Q_\omega(s,a)-\max_a Q_*(s,a) \right] = \frac{m-1}{m+1}

这说明动作数越多,max\max 从噪声中挑到正向误差的概率越大,过高估计越严重。

推导核心是:令

M=maxiϵiM=\max_i\epsilon_i

则:

P(Mx)=i=1mP(ϵix)=[F(x)]mP(M\le x) = \prod_{i=1}^mP(\epsilon_i\le x) = [F(x)]^m

ϵiU[1,1]\epsilon_i\sim U[-1,1] 时:

F(x)={0,x11+x2,1<x11,x>1F(x)= \begin{cases} 0, & x\le -1\\ \frac{1+x}{2}, & -1<x\le 1\\ 1, & x>1 \end{cases}

所以:

E[M]=11xm(1+x2)m112dx=m1m+1\mathbb{E}[M] = \int_{-1}^{1} x\cdot m \left( \frac{1+x}{2} \right)^{m-1} \cdot \frac{1}{2} dx = \frac{m-1}{m+1}

这个结论不要求现实完全满足这些假设,它只是说明一个趋势:

动作越多,普通 DQN 的 max\max 操作越容易放大正向估计误差。

小结。

本章是在 DQN 基础上的改进。

普通 DQN:

y=r+γ(1d)maxaQω(s,a)y= r+\gamma(1-d)\max_{a'}Q_{\omega^-}(s',a')

Double DQN:

y=r+γ(1d)Qω(s,argmaxaQω(s,a))y= r+\gamma(1-d) Q_{\omega^-} \left( s', \arg\max_{a'}Q_\omega(s',a') \right)

Dueling DQN:

Q(s,a)=V(s)+A(s,a)1AaA(s,a)Q(s,a) = V(s)+A(s,a) - \frac{1}{|\mathcal{A}|} \sum_{a'}A(s,a')

本章要记住三句话:

17. 第 12 章:策略梯度算法

前面的 Q-learning、DQN、Double DQN、Dueling DQN 都属于基于价值的方法。从这里开始,重点转到基于策略的方法:不再先学一张 Q 表或 Q 网络,再从价值里挑动作,而是直接让策略本身变成可优化的函数。

一句话:

基于价值的方法先学 QQVV,再从价值函数导出策略;基于策略的方法直接学习策略本身。

从 value-based 到 policy-based。

基于价值的方法学习的是:

Q(s,a)Q(s,a)

然后通过:

π(s)=argmaxaQ(s,a)\pi(s)=\arg\max_a Q(s,a)

得到策略。

比如 DQN 输出每个动作的 QQ 值:

Qθ(s,a1),Qθ(s,a2),,Qθ(s,an)Q_\theta(s,a_1),Q_\theta(s,a_2),\dots,Q_\theta(s,a_n)

再选最大动作。

策略梯度方法不这样做。它直接建模策略:

πθ(as)\pi_\theta(a\mid s)

意思是:

在状态 ss 下,以多大概率选择动作 aa

对于离散动作,策略网络通常输出一个概率分布:

πθ(s)=[πθ(a1s),πθ(a2s),,πθ(ans)]\pi_\theta(\cdot\mid s) = \left[ \pi_\theta(a_1\mid s), \pi_\theta(a_2\mid s), \dots, \pi_\theta(a_n\mid s) \right]

这些概率满足:

aπθ(as)=1\sum_a\pi_\theta(a\mid s)=1

策略参数化。

策略梯度方法首先要让策略可导。

如果动作是离散的,可以让神经网络输出 logits,再经过 softmax 得到动作概率:

πθ(ais)=exp(zi)jexp(zj)\pi_\theta(a_i\mid s) = \frac{\exp(z_i)} {\sum_j\exp(z_j)}

其中:

z=fθ(s)z=f_\theta(s)

是神经网络输出的未归一化分数。

所以策略网络做的是:

spolicy networkzsoftmaxπθ(s)s \xrightarrow{\text{policy network}} z \xrightarrow{\text{softmax}} \pi_\theta(\cdot\mid s)

DQN 的网络输出是动作价值;策略梯度的网络输出是动作概率。

优化目标。

策略梯度的目标是让策略的期望回报最大。

可以把目标函数写成:

J(θ)=Eτπθ[G(τ)]J(\theta)= \mathbb{E}_{\tau\sim\pi_\theta} \left[ G(\tau) \right]

其中 τ\tau 是由当前策略采样得到的一条轨迹:

τ=(s0,a0,r0,s1,a1,r1,)\tau=(s_0,a_0,r_0,s_1,a_1,r_1,\dots)

整条轨迹的回报是:

G(τ)=t=0TγtrtG(\tau)= \sum_{t=0}^{T}\gamma^t r_t

策略梯度要做的是:

θθ+αθJ(θ)\theta \leftarrow \theta+\alpha\nabla_\theta J(\theta)

注意这里是梯度上升,因为目标是最大化回报。

在 PyTorch 中通常写成最小化负号形式:

loss=logπθ(atst)Gt\text{loss} = -\log\pi_\theta(a_t\mid s_t)G_t

用梯度下降最小化这个 loss,就等价于做梯度上升最大化回报。

策略梯度公式。

策略梯度定理的核心形式是:

θJ(θ)=Eπθ[θlogπθ(as)Qπθ(s,a)]\nabla_\theta J(\theta) = \mathbb{E}_{\pi_\theta} \left[ \nabla_\theta\log\pi_\theta(a\mid s) Q^{\pi_\theta}(s,a) \right]

直觉是:

因为:

θlogπθ(as)\nabla_\theta\log\pi_\theta(a\mid s)

表示“怎样调整参数才能让这个动作的概率变大”。

所以完整含义是:

更新方向=让动作概率变大的方向×这个动作到底好不好\text{更新方向} = \text{让动作概率变大的方向} \times \text{这个动作到底好不好}

为什么有 logπ\log \pi

策略梯度常用 log-derivative trick:

θpθ(x)=pθ(x)θlogpθ(x)\nabla_\theta p_\theta(x) = p_\theta(x)\nabla_\theta\log p_\theta(x)

因为:

θlogpθ(x)=θpθ(x)pθ(x)\nabla_\theta\log p_\theta(x) = \frac{\nabla_\theta p_\theta(x)}{p_\theta(x)}

所以:

θpθ(x)=pθ(x)θpθ(x)pθ(x)\nabla_\theta p_\theta(x) = p_\theta(x) \frac{\nabla_\theta p_\theta(x)}{p_\theta(x)}

这个技巧可以把“对采样概率求导”转成“对 log 概率求导”,从而得到:

θJ(θ)=Eτπθ[G(τ)θlogPθ(τ)]\nabla_\theta J(\theta) = \mathbb{E}_{\tau\sim\pi_\theta} \left[ G(\tau) \nabla_\theta\log P_\theta(\tau) \right]

而轨迹概率中,只有动作选择概率和策略参数有关:

Pθ(τ)=p(s0)t=0Tπθ(atst)p(st+1st,at)P_\theta(\tau) = p(s_0) \prod_{t=0}^{T} \pi_\theta(a_t\mid s_t) p(s_{t+1}\mid s_t,a_t)

环境转移概率:

p(st+1st,at)p(s_{t+1}\mid s_t,a_t)

不依赖 θ\theta,所以:

θlogPθ(τ)=t=0Tθlogπθ(atst)\nabla_\theta\log P_\theta(\tau) = \sum_{t=0}^{T} \nabla_\theta\log\pi_\theta(a_t\mid s_t)

这就是 REINFORCE 更新里为什么要用:

logπθ(atst)\log\pi_\theta(a_t\mid s_t)

而不是直接用:

πθ(atst)\pi_\theta(a_t\mid s_t)

REINFORCE 算法。

REINFORCE 是最经典的策略梯度算法。它用蒙特卡洛方法估计动作价值。

对于一条轨迹:

s0,a0,r0,s1,a1,r1,,sTs_0,a_0,r_0,s_1,a_1,r_1,\dots,s_T

从时刻 tt 开始的回报是:

Gt=rt+γrt+1+γ2rt+2+G_t = r_t+\gamma r_{t+1}+\gamma^2r_{t+2}+\cdots

REINFORCE 的梯度估计是:

θJ(θ)t=0TGtθlogπθ(atst)\nabla_\theta J(\theta) \approx \sum_{t=0}^{T} G_t \nabla_\theta \log\pi_\theta(a_t\mid s_t)

对应参数更新:

θθ+αGtθlogπθ(atst)\theta \leftarrow \theta+ \alpha G_t \nabla_\theta \log\pi_\theta(a_t\mid s_t)

如果 GtG_t 大,说明这一步之后的结果好,那么就增加当时动作的概率。

如果 GtG_t 小,说明这一步之后的结果差,那么就降低当时动作的概率。

REINFORCE 是 on-policy。

策略梯度公式里的期望来自当前策略:

τπθ\tau\sim\pi_\theta

所以 REINFORCE 必须用当前策略采样出来的轨迹来更新当前策略。

它不像 DQN 那样可以随便从 replay buffer 里反复抽旧策略数据。

所以 REINFORCE 是:

model-free+on-policy+Monte Carlo\text{model-free}+\text{on-policy}+\text{Monte Carlo}

含义是:

代码:PolicyNet。

策略网络输入状态,输出动作概率。

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)

和 DQN 的 Qnet 对比:

所以最后用了:

F.softmax(..., dim=1)

假设 CartPole 有两个动作,网络输出可能是:

πθ(s)=[0.3,0.7]\pi_\theta(\cdot\mid s)=[0.3,0.7]

意思是:

代码:动作采样。

REINFORCE 不是直接取最大概率动作,而是按照概率分布采样:

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

其中:

probs = self.policy_net(state)

得到动作概率:

πθ(s)\pi_\theta(\cdot\mid s)
torch.distributions.Categorical(probs)

表示根据这个离散概率分布构造采样器。

如果:

probs=[0.3,0.7]probs=[0.3,0.7]

那么动作 0 有 30%30\% 概率被采样,动作 1 有 70%70\% 概率被采样。

这和 DQN 的 ϵ\epsilon-greedy 不同:

代码:更新策略。

核心更新代码:

def update(self, transition_dict):
    reward_list = transition_dict['rewards']
    state_list = transition_dict['states']
    action_list = transition_dict['actions']

    G = 0
    self.optimizer.zero_grad()
    for i in reversed(range(len(reward_list))):
        reward = reward_list[i]
        state = torch.tensor([state_list[i]], dtype=torch.float).to(self.device)
        action = torch.tensor([action_list[i]]).view(-1, 1).to(self.device)
        log_prob = torch.log(self.policy_net(state).gather(1, action))
        G = self.gamma * G + reward
        loss = -log_prob * G
        loss.backward()
    self.optimizer.step()

先从最后一步往前算回报:

G = self.gamma * G + reward

如果从最后一步开始反向遍历,那么每一步的 GG 正好是:

Gt=rt+γrt+1+γ2rt+2+G_t= r_t+\gamma r_{t+1}+\gamma^2r_{t+2}+\cdots

例如最后一步:

GT=rTG_T=r_T

倒数第二步:

GT1=rT1+γrTG_{T-1}=r_{T-1}+\gamma r_T

再前一步:

GT2=rT2+γrT1+γ2rTG_{T-2}=r_{T-2}+\gamma r_{T-1}+\gamma^2r_T

gather(1, action) 的作用是从所有动作概率中取出实际执行动作的概率:

πθ(atst)\pi_\theta(a_t\mid s_t)

所以:

log_prob = torch.log(self.policy_net(state).gather(1, action))

对应:

logπθ(atst)\log\pi_\theta(a_t\mid s_t)

损失:

loss = -log_prob * G

对应:

Lt(θ)=Gtlogπθ(atst)L_t(\theta)= -G_t\log\pi_\theta(a_t\mid s_t)

用梯度下降最小化这个损失,相当于用梯度上升最大化:

Gtlogπθ(atst)G_t\log\pi_\theta(a_t\mid s_t)

所以如果 GtG_t 大,梯度会推动策略提高该动作概率。

为什么 REINFORCE 方差大。

REINFORCE 用完整轨迹回报 GtG_t 作为动作好坏的估计。

优点是:它的梯度估计无偏。

缺点是:方差很大。

因为同一个动作之后,后面发生的很多随机事件都会影响最终回报:

Gt=rt+γrt+1+γ2rt+2+G_t= r_t+\gamma r_{t+1}+\gamma^2r_{t+2}+\cdots

如果轨迹比较长,GtG_t 的波动会很大,导致更新方向也抖动。

这就是后续 Actor-Critic 要解决的问题:

用 Critic 估计价值,降低纯蒙特卡洛回报带来的高方差。

策略梯度证明骨架。

从轨迹期望目标开始:

J(θ)=Eτπθ[G(τ)]=τPθ(τ)G(τ)J(\theta) = \mathbb{E}_{\tau\sim\pi_\theta} [G(\tau)] = \sum_\tau P_\theta(\tau)G(\tau)

θ\theta 求梯度:

θJ(θ)=τθPθ(τ)G(τ)\nabla_\theta J(\theta) = \sum_\tau \nabla_\theta P_\theta(\tau)G(\tau)

使用 log-derivative trick:

θPθ(τ)=Pθ(τ)θlogPθ(τ)\nabla_\theta P_\theta(\tau) = P_\theta(\tau) \nabla_\theta\log P_\theta(\tau)

得到:

θJ(θ)=τPθ(τ)θlogPθ(τ)G(τ)\nabla_\theta J(\theta) = \sum_\tau P_\theta(\tau) \nabla_\theta\log P_\theta(\tau) G(\tau)

也就是:

θJ(θ)=Eτπθ[G(τ)θlogPθ(τ)]\nabla_\theta J(\theta) = \mathbb{E}_{\tau\sim\pi_\theta} \left[ G(\tau) \nabla_\theta\log P_\theta(\tau) \right]

轨迹概率为:

Pθ(τ)=p(s0)t=0Tπθ(atst)p(st+1st,at)P_\theta(\tau) = p(s_0) \prod_{t=0}^{T} \pi_\theta(a_t\mid s_t) p(s_{t+1}\mid s_t,a_t)

取 log:

logPθ(τ)=logp(s0)+t=0Tlogπθ(atst)+t=0Tlogp(st+1st,at)\log P_\theta(\tau) = \log p(s_0) + \sum_{t=0}^{T} \log\pi_\theta(a_t\mid s_t) + \sum_{t=0}^{T} \log p(s_{t+1}\mid s_t,a_t)

只有策略项依赖 θ\theta,所以:

θlogPθ(τ)=t=0Tθlogπθ(atst)\nabla_\theta\log P_\theta(\tau) = \sum_{t=0}^{T} \nabla_\theta \log\pi_\theta(a_t\mid s_t)

代回去:

θJ(θ)=Eτπθ[G(τ)t=0Tθlogπθ(atst)]\nabla_\theta J(\theta) = \mathbb{E}_{\tau\sim\pi_\theta} \left[ G(\tau) \sum_{t=0}^{T} \nabla_\theta \log\pi_\theta(a_t\mid s_t) \right]

进一步用从当前时刻往后的回报 GtG_t 替代整条轨迹回报,可得到 REINFORCE 常用形式:

θJ(θ)t=0TGtθlogπθ(atst)\nabla_\theta J(\theta) \approx \sum_{t=0}^{T} G_t \nabla_\theta \log\pi_\theta(a_t\mid s_t)

这就是代码中:

loss = -log_prob * G

的理论来源。

小结。

本章从基于价值的方法转向基于策略的方法。

DQN 学的是:

Qθ(s,a)Q_\theta(s,a)

策略梯度学的是:

πθ(as)\pi_\theta(a\mid s)

REINFORCE 的核心公式是:

θJ(θ)t=0TGtθlogπθ(atst)\nabla_\theta J(\theta) \approx \sum_{t=0}^{T} G_t \nabla_\theta \log\pi_\theta(a_t\mid s_t)

代码中的损失函数是:

Lt(θ)=Gtlogπθ(atst)L_t(\theta)= -G_t \log\pi_\theta(a_t\mid s_t)

本章要记住三句话:

18. 第 13 章:Actor-Critic 算法

这一章接在 REINFORCE 后面。REINFORCE 已经能直接优化策略:

πθ(as)\pi_\theta(a\mid s)

但是它用完整回报 GtG_t 做更新信号:

Lt(θ)=Gtlogπθ(atst)L_t(\theta) = -G_t\log\pi_\theta(a_t\mid s_t)

问题是 GtG_t 来自一整条采样轨迹,随机性很强,所以方差大,训练容易抖。Actor-Critic 的核心想法是:仍然用 Actor 学策略,但再训练一个 Critic 来估计当前状态好不好,用更稳定的价值信号指导 Actor 更新。

Actor 和 Critic 分别做什么。

Actor 是策略网络,负责决定动作:

πθ(as)\pi_\theta(a\mid s)

它输入状态 ss,输出每个动作的概率,然后按这个概率采样动作。

Critic 是价值网络,负责评价状态:

Vω(s)V_\omega(s)

它输入状态 ss,输出一个标量,表示“从这个状态开始,未来大概能拿到多少回报”。

所以 Actor-Critic 的一句话理解是:

和前面算法的关系。

DQN 是 value-based 方法。它学习动作价值:

Qθ(s,a)Q_\theta(s,a)

然后通过最大化 QQ 来选动作:

a=argmaxaQθ(s,a)a=\arg\max_a Q_\theta(s,a)

REINFORCE 是 policy-based 方法。它不先学 QQ 表或 QQ 网络,而是直接学习策略:

πθ(as)\pi_\theta(a\mid s)

Actor-Critic 仍然是 policy-based,因为最终被优化的是 Actor 的策略参数 θ\theta。但它引入 Critic 来学习价值函数 Vω(s)V_\omega(s),用价值函数降低策略梯度的方差。

可以这样分:

方法学什么更新信号是否需要等整条序列结束
DQNQ(s,a)Q(s,a)TD 目标不需要
REINFORCEπθ(as)\pi_\theta(a\mid s)完整回报 GtG_t通常需要
Actor-Criticπθ(as)\pi_\theta(a\mid s)Vω(s)V_\omega(s)TD 误差 δt\delta_t不需要

从策略梯度到 Actor-Critic。

上一章的策略梯度可以写成更一般的形式:

θJ(θ)=Eπθ[t=0Tψtθlogπθ(atst)]\nabla_\theta J(\theta) = \mathbb{E}_{\pi_\theta} \left[ \sum_{t=0}^{T} \psi_t \nabla_\theta \log\pi_\theta(a_t\mid s_t) \right]

这里的 ψt\psi_t 是“这次动作到底有多好”的评价信号。

在 REINFORCE 中,取:

ψt=Gt\psi_t = G_t

也就是用从 tt 时刻开始的完整回报评价动作。

Actor-Critic 想把这个评价信号换得更稳定一些。一个自然想法是:不要只看完整回报,而是看“这一步实际发生的结果,比 Critic 原来预期的好多少”。这个差值就是 TD 误差:

δt=rt+γVω(st+1)Vω(st)\delta_t = r_t + \gamma V_\omega(s_{t+1}) - V_\omega(s_t)

这里:

如果:

δt>0\delta_t > 0

说明动作 ata_t 带来的结果比 Critic 原来预期更好,所以 Actor 应该提高这个动作的概率。

如果:

δt<0\delta_t < 0

说明动作 ata_t 带来的结果比预期更差,所以 Actor 应该降低这个动作的概率。

因此 Actor 的损失可以写成:

Lactor(θ)=logπθ(atst)δtL_{\text{actor}}(\theta) = - \log\pi_\theta(a_t\mid s_t) \delta_t

这和 REINFORCE 的形式非常像:

LREINFORCE(θ)=logπθ(atst)GtL_{\text{REINFORCE}}(\theta) = - \log\pi_\theta(a_t\mid s_t) G_t

区别只是把完整回报 GtG_t 换成了 TD 误差 δt\delta_t

为什么 TD 误差可以指导策略更新。

先看优势函数:

Aπ(s,a)=Qπ(s,a)Vπ(s)A^\pi(s,a) = Q^\pi(s,a) - V^\pi(s)

它表示:在状态 ss 下,选择动作 aa 比这个状态的平均水平好多少。

如果 Aπ(s,a)>0A^\pi(s,a)>0,说明动作 aa 比平均动作好,应该增加概率。

如果 Aπ(s,a)<0A^\pi(s,a)<0,说明动作 aa 比平均动作差,应该减少概率。

但真实的 Qπ(s,a)Q^\pi(s,a) 通常不知道,所以用一步 TD 目标近似:

Qπ(st,at)rt+γVω(st+1)Q^\pi(s_t,a_t) \approx r_t+\gamma V_\omega(s_{t+1})

于是优势函数可以近似为:

Aπ(st,at)rt+γVω(st+1)Vω(st)A^\pi(s_t,a_t) \approx r_t+\gamma V_\omega(s_{t+1})-V_\omega(s_t)

这正是:

δt=rt+γVω(st+1)Vω(st)\delta_t = r_t+\gamma V_\omega(s_{t+1})-V_\omega(s_t)

所以 Actor-Critic 本质上是在用 TD 误差近似优势函数,再用优势函数做策略梯度更新。

Critic 怎么学。

Critic 要学习状态价值:

Vω(s)V_\omega(s)

它的监督目标来自贝尔曼方程:

Vπ(st)rt+γVπ(st+1)V^\pi(s_t) \approx r_t+\gamma V^\pi(s_{t+1})

所以代码中会构造 TD 目标:

yt=rt+γ(1dt)Vω(st+1)y_t = r_t + \gamma(1-d_t)V_\omega(s_{t+1})

其中 dtd_t 表示是否终止:

Critic 的损失就是让当前估计靠近 TD 目标:

Lcritic(ω)=(Vω(st)yt)2L_{\text{critic}}(\omega) = \left( V_\omega(s_t)-y_t \right)^2

也就是代码里的均方误差。

Actor-Critic 的代码结构。

策略网络和 REINFORCE 中的策略网络基本一样:

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)

它输入状态,输出动作概率:

πθ(as)\pi_\theta(a\mid s)

价值网络输出的是一个状态价值标量:

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)

它输入状态,输出:

Vω(s)V_\omega(s)

Actor-Critic 类中有两个网络和两个优化器:

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)

这说明 Actor 和 Critic 的参数是分开的:

θω\theta \neq \omega

Actor 更新策略参数 θ\theta,Critic 更新价值参数 ω\omega

take_action:Actor 怎么选动作。

代码:

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

流程是:

  1. 把状态转成张量。
  2. 输入 Actor,得到动作概率。
  3. Categorical(probs) 构造离散动作分布。
  4. 从这个分布中采样动作。

这不是取最大概率动作,而是按概率采样。这样可以保持探索。

例如 Actor 输出:

πθ(s)=[0.2,0.8]\pi_\theta(\cdot\mid s) = [0.2,0.8]

那么动作 11 更可能被选到,但动作 00 也仍然有机会被选到。

update:一次更新到底发生了什么。

核心代码是:

td_target = rewards + self.gamma * self.critic(next_states) * (1 - dones)
td_delta = td_target - self.critic(states)
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()))

第一步,构造 TD 目标:

yt=rt+γ(1dt)Vω(st+1)y_t = r_t+\gamma(1-d_t)V_\omega(s_{t+1})

对应代码:

td_target = rewards + self.gamma * self.critic(next_states) * (1 - dones)

第二步,计算 TD 误差:

δt=ytVω(st)\delta_t = y_t - V_\omega(s_t)

对应代码:

td_delta = td_target - self.critic(states)

第三步,取出实际执行动作的概率:

log_probs = torch.log(self.actor(states).gather(1, actions))

这里 self.actor(states) 输出每个状态下所有动作的概率,例如:

[0.30.70.80.2]\begin{bmatrix} 0.3 & 0.7 \\ 0.8 & 0.2 \end{bmatrix}

如果实际动作是:

[10]\begin{bmatrix} 1\\ 0 \end{bmatrix}

那么 gather(1, actions) 会取出:

[0.70.8]\begin{bmatrix} 0.7\\ 0.8 \end{bmatrix}

也就是每条样本中“真实被执行动作”的概率。

第四步,更新 Actor:

Lactor=logπθ(atst)δtL_{\text{actor}} = - \log\pi_\theta(a_t\mid s_t) \delta_t

对应代码:

actor_loss = torch.mean(-log_probs * td_delta.detach())

这里的 .detach() 很重要。它表示:Actor 更新时,把 δt\delta_t 当成一个固定评价分数,不通过 δt\delta_t 反向更新 Critic。也就是说:

第五步,更新 Critic:

Lcritic=(Vω(st)yt)2L_{\text{critic}} = \left(V_\omega(s_t)-y_t\right)^2

对应代码:

critic_loss = torch.mean(
    F.mse_loss(self.critic(states), td_target.detach()))

这里 td_target.detach() 表示把 TD 目标当成固定标签。Critic 的任务是让:

Vω(st)V_\omega(s_t)

靠近:

yty_t

为什么 Actor-Critic 不必等整条序列结束。

REINFORCE 需要完整回报:

Gt=rt+γrt+1+γ2rt+2+G_t = r_t+\gamma r_{t+1}+\gamma^2 r_{t+2}+\cdots

所以通常要等一条 episode 结束,才能从后往前计算 GtG_t

Actor-Critic 用的是一步 TD 误差:

δt=rt+γVω(st+1)Vω(st)\delta_t = r_t+\gamma V_\omega(s_{t+1})-V_\omega(s_t)

这个量只需要当前转移:

(st,at,rt,st+1)(s_t,a_t,r_t,s_{t+1})

就能算出来。因此它可以边采样边更新,也可以把一批 transition 收集起来后更新。

方差和偏差的变化。

REINFORCE 的 GtG_t 来自真实采样回报,所以它更接近无偏估计,但方差大。

Actor-Critic 的 δt\delta_t 用到了 Critic 的估计:

Vω(st+1)V_\omega(s_{t+1})

这会引入一定偏差,因为 Critic 不一定准确。但它通常能显著降低方差,所以训练更稳定。

可以记成:

方法评价信号优点缺点
REINFORCEGtG_t理论上更无偏方差大,学得慢
Actor-Criticδt\delta_t方差更小,可单步更新依赖 Critic,可能有偏

Actor-Critic 是 on-policy 吗。

本章这个基础 Actor-Critic 通常是 on-policy。

原因是 Actor 的更新使用的是当前策略采样出来的数据:

atπθ(st)a_t\sim\pi_\theta(\cdot\mid s_t)

然后用这批数据更新同一个 Actor。也就是说,采样策略和被优化的策略是同一个。

但 Actor-Critic 是一个框架,不是只有一种算法。后面很多算法也属于 Actor-Critic 思路,例如:

和 DQN、REINFORCE 的直观对比。

如果只用一句话区分:

用公式看:

DQN 更新:

Q(st,at)Q(st,at)+α[rt+γmaxaQ(st+1,a)Q(st,at)]Q(s_t,a_t) \leftarrow Q(s_t,a_t) + \alpha \left[ r_t+\gamma\max_a Q(s_{t+1},a) - Q(s_t,a_t) \right]

REINFORCE 更新:

θJ(θ)tGtθlogπθ(atst)\nabla_\theta J(\theta) \approx \sum_t G_t \nabla_\theta \log\pi_\theta(a_t\mid s_t)

Actor-Critic 更新:

θJ(θ)tδtθlogπθ(atst)\nabla_\theta J(\theta) \approx \sum_t \delta_t \nabla_\theta \log\pi_\theta(a_t\mid s_t)

其中:

δt=rt+γVω(st+1)Vω(st)\delta_t = r_t+\gamma V_\omega(s_{t+1})-V_\omega(s_t)

所以 Actor-Critic 可以看成:把 REINFORCE 里的完整回报 GtG_t,换成由 Critic 估计出来的 TD 误差 δt\delta_t

小结。

这一章要抓住一条主线:

REINFORCEActor-Critic\text{REINFORCE} \quad\Longrightarrow\quad \text{Actor-Critic}

REINFORCE 的问题是:

GtG_t

方差太大。

Actor-Critic 的改进是引入 Critic:

Vω(s)V_\omega(s)

然后用 TD 误差:

δt=rt+γVω(st+1)Vω(st)\delta_t = r_t+\gamma V_\omega(s_{t+1})-V_\omega(s_t)

替代完整回报 GtG_t 来指导 Actor:

Lactor=logπθ(atst)δtL_{\text{actor}} = - \log\pi_\theta(a_t\mid s_t) \delta_t

Critic 自己通过 TD 目标学习:

Lcritic=(Vω(st)[rt+γ(1dt)Vω(st+1)])2L_{\text{critic}} = \left( V_\omega(s_t) - \left[ r_t+\gamma(1-d_t)V_\omega(s_{t+1}) \right] \right)^2

本章最终可以记成三句话:

参考链接:


Edit page
Share this post on:

Previous Post
强化学习学习记录(三):TRPO、PPO、DDPG 与 SAC
Next Post
强化学习学习记录(一):Bandit、MDP 与 TD