14. 第 08 章:Dyna-Q 算法
本章讨论的是 Dyna-Q。它和前一章的 Sarsa、Q-learning 最大区别在于:Dyna-Q 不只从真实环境采样中学习,还会学习一个环境模型,再用这个模型生成“模拟经验”来继续更新 。
一句话:
Q-learning 只用真实经验更新;Dyna-Q 同时用真实经验和模型模拟经验更新。
什么是“模型”。
在强化学习里,模型通常指环境模型,也就是描述:
和:
也就是说,模型回答两个问题:
- 在状态 做动作 ,下一个状态 会是什么?
- 这一步能得到什么奖励 ?
如果模型已知,就可以用动态规划方法,比如策略迭代、价值迭代。
如果模型未知,但可以通过真实交互慢慢学出来,就进入 Dyna-Q 这类方法。
基于模型和无模型。
无模型强化学习直接从真实经验中学:
例如 Q-learning:
基于模型的强化学习会多做一步:用真实经验学习模型。
在确定性离散环境里,可以简单写成:
意思是:
我记住了:上次在 做 ,得到了奖励 ,并转移到了 。
之后即使不再真实访问环境,也可以从模型里拿出这条经验,模拟一次更新。
Dyna-Q 的核心思想。
Dyna-Q 把三件事结合起来:
- 真实交互:智能体在真实环境中执行动作。
- 直接学习:用真实经验做一次 Q-learning 更新。
- 模型规划:把真实经验存进模型,再从模型里抽样,做若干次模拟 Q-learning 更新。
流程可以理解成:
这就是 Dyna-Q 的名字里 Dyna 的含义:learning、planning、acting 被整合在一起。
Dyna-Q 伪代码。
初始化:
和模型:
每次真实交互时:
- 在当前状态 下,用 -贪婪策略选择动作 。
- 与真实环境交互,得到 。
- 用真实经验做一次 Q-learning 更新:
- 更新模型:
- 重复 次 Q-planning:
随机选一个曾经见过的状态动作对:
用模型查出:
再做一次模拟的 Q-learning 更新:
这里的 是 Q-planning 次数。
当:
Dyna-Q 就退化成普通 Q-learning。
Q-planning 是什么。
Q-planning 的本质是:
不真实走环境,而是从已经学到的模型里抽一条经验,然后像 Q-learning 一样更新。
普通 Q-learning 的一次更新来自真实世界:
Dyna-Q 的规划更新来自模型:
所以 Dyna-Q 能更充分地利用一次真实交互。
假设真实环境中只走了一步,得到一条经验:
普通 Q-learning 只更新一次。
Dyna-Q 会:
- 用这条真实经验更新一次。
- 把它存进模型。
- 之后可能反复从模型中抽到它,继续更新。
因此 Dyna-Q 的样本复杂度通常更低,即需要更少的真实环境交互。
代码结构。
这一章的代码仍然使用悬崖漫步环境。Dyna-Q 类比 Q-learning 多了两个核心成员:
self.n_planning = n_planning
self.model = dict()
其中:
n_planning:每次真实交互后,要做多少次模拟规划更新。model:环境模型,用字典保存过去见过的经验。
核心 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_)
这段代码的逻辑是:
- 先用真实经验更新一次 。
- 把真实经验存入模型。
- 从模型里随机抽旧经验。
- 用抽到的模拟经验继续更新 。
所以 Dyna-Q 不是只“存经验”,而是“存经验后,把经验当成模型的一部分,继续做规划更新”。
Dyna-Q 和 replay buffer 的区别。
Dyna-Q 和 replay buffer 很像,但不是一回事。
Replay buffer 保存真实历史经验:
训练时从 buffer 里抽样,本质上还是重复使用真实经验。
Dyna-Q 保存的是模型:
然后通过模型产生模拟经验。
在这一章的确定性悬崖环境里,model 看起来就像一个经验字典,所以它和 replay buffer 很接近。但概念上:
- replay buffer:保存真实发生过的数据。
- Dyna-Q 模型:学习“如果在 做 ,环境会怎么反应”。
如果环境是随机的,模型就不能只保存一个固定的 ,而要估计概率分布:
和奖励期望:
这时 Dyna-Q 和 replay buffer 的区别会更明显。
实验结果:planning 步数的影响。
实验中比较了:
其中 是每次真实交互后做多少次 Q-planning。
结果现象是:
- :退化成普通 Q-learning,收敛较慢。
- :每一步真实经验后多做 2 次模拟更新,收敛明显更快。
- :每一步真实经验后多做 20 次模拟更新,前期学习速度更快。
直觉上:
所以在模型准确时, 越大,真实样本的利用率越高。
但这并不表示 永远越大越好。原因有两个:
- 越大,计算量越大。
- 如果模型不准,模拟经验会带来错误更新。
本章实验中悬崖漫步是确定性环境,所以:
非常准确。因此增加 Q-planning 步数可以明显降低样本复杂度。
Dyna-Q 的优点和风险。
Dyna-Q 的优点:
- 真实环境交互次数更少。
- 可以把过去经验反复用于规划。
- 在模型准确时,收敛速度通常比普通 Q-learning 快。
Dyna-Q 的风险:
- 如果环境复杂,模型不容易学准。
- 如果状态连续,不能简单用字典保存模型。
- 如果状态转移随机,不能只记一个固定的 。
- 如果模型错了,planning 会不断放大错误。
所以 Dyna-Q 的关键不只是“多做更新”,而是:
模型是否足够准确,决定了模拟经验是否可靠。
小结。
Dyna-Q 是连接无模型强化学习和基于模型强化学习的重要算法。
普通 Q-learning:
Dyna-Q:
最重要的公式仍然是 Q-learning 更新:
Dyna-Q 只是把这个更新同时用于真实经验和模型生成的模拟经验。
本章要记住三句话:
- Dyna-Q = Q-learning + 模型学习 + Q-planning。
- 时,Dyna-Q 就是普通 Q-learning。
- 模型准确时,planning 可以减少真实采样;模型不准时,planning 可能传播错误。
这一章和前面章节的关系:
- 动态规划:模型已知,直接规划。
- Q-learning:模型未知,只用真实经验学习。
- Dyna-Q:模型未知,但边交互边学习模型,再用模型辅助规划。
15. 第 10 章:DQN 算法
DQN 是把 Q-learning 从表格推到神经网络的一步。DQN 的全称是 Deep Q-Network,意思是用深度神经网络来近似 Q-learning 里的动作价值函数。
前面表格型 Q-learning 用一张表保存:
但是当状态是连续的,或者状态维度很高时,表格就放不下了。DQN 的做法是:
其中 是神经网络参数。
一句话:
DQN = Q-learning + 神经网络函数近似 + 经验回放 + 目标网络。
为什么需要 DQN。
表格型 Q-learning 适合这种情况:
- 状态数量有限。
- 动作数量有限。
- 状态动作对数量不大。
比如悬崖漫步中,状态就是网格编号,动作只有上下左右,直接建表即可:
但如果状态是一张图片,例如:
就不可能给每种图片状态都建一行表。
CartPole 中状态虽然不是图片,但也是连续向量:
其中:
- :小车位置。
- :小车速度。
- :杆的角度。
- :杆尖端速度。
动作是离散的:
也就是向左或向右推小车。
DQN 适合处理:
状态连续或高维,但动作离散的问题。
从 Q 表到 Q 网络。
表格型 Q-learning 中,查表得到:
DQN 中,用神经网络输出动作价值。
如果动作是离散的,可以让网络只输入状态 ,一次性输出所有动作的价值:
以 CartPole 为例,状态维度是 4,动作数量是 2,所以网络可以写成:
输出两个数:
选择动作时,仍然可以用 -贪婪策略:
DQN 的 TD 目标。
DQN 继承 Q-learning 的思想。
表格型 Q-learning 的目标是:
DQN 把 换成神经网络:
但实际 DQN 会用目标网络 来计算目标:
如果当前样本已经终止,即 done=True,后面没有未来回报,所以:
可以合并写成:
其中:
DQN 的损失函数。
当前网络对这条样本的估计是:
TD 目标是:
所以损失函数就是均方误差:
展开就是:
训练 DQN,本质就是用梯度下降让:
靠近 TD 目标:
经验回放 Replay Buffer。
DQN 使用经验回放池保存 transition:
训练时不是只用刚刚发生的那一步,而是从 buffer 里随机抽一个 batch:
这样做有两个主要作用。
第一,打破时间相关性。
强化学习连续采样的数据往往高度相关:
彼此非常接近。如果神经网络总是按时间顺序训练,容易只拟合最近一段经历。
随机从 replay buffer 采样,可以让 batch 更接近独立同分布。
这里要注意:经验回放并不是严格地把强化学习样本变成独立同分布,而是让它更接近独立同分布。
原始交互数据是按时间连续产生的:
下一条马上就是:
第二条的起点就是第一条的终点,所以它们天然强相关。
例如 CartPole 中,连续几十帧可能都处在“杆稍微向右倾、小车正在向右移动”的相近状态。如果直接拿最近几十条 transition 训练网络,神经网络容易只拟合最近这一小段经历。
经验回放的做法是先把很多历史 transition 放进 buffer:
训练时随机抽样,而不是按时间顺序取:
随机抽样可能拿到:
这样一个 batch 中可能混合了不同 episode、不同时间段、不同状态区域的数据。时间相关性被削弱了,batch 更像监督学习中的随机小批量数据。
“同分布”也要谨慎理解。在某一时刻,样本都是从同一个 replay buffer 分布中抽出来的:
但 buffer 本身会不断更新,里面的数据也可能来自不同历史策略,所以它不是严格数学意义上的固定分布。更准确的说法是:
replay buffer 让训练 batch 更接近 iid,而不是严格变成 iid。
第二,提高样本效率。
一个 transition 不只用一次,而是可以被抽到很多次。
这和前面 Dyna-Q 的思想有相似处:都不把经验用完就扔。
但区别是:
- replay buffer 保存真实 transition。
- Dyna-Q 学习环境模型 ,再从模型中生成模拟 transition。
可以这样对比:
| 方法 | 保存的是什么 | 后续怎么用 | 是否 model-based |
|---|---|---|---|
| DQN replay buffer | 真实发生过的 transition | 从 buffer 随机抽真实旧经验训练 | 否 |
| Dyna-Q | 环境模型 | 用模型生成模拟 transition 再更新 | 是 |
DQN replay buffer 的流程是:
Dyna-Q 的流程是:
所以二者目标相似,都是提高样本利用率;但 replay buffer 仍然是 model-free,Dyna-Q 是 model-based。
在确定性离散环境中,Dyna-Q 的模型可能只是:
这时它看起来很像一个经验字典,所以和 replay buffer 特别像。
但如果环境是随机的,区别会更明显。同一个状态动作对:
可能有时到达 ,有时到达 。Replay buffer 会保存很多真实发生过的样本:
Dyna-Q 或其他 model-based 方法则需要学习概率模型:
再从这个模型中模拟下一个状态。
为什么 DQN 可以用旧经验。
DQN 基于 Q-learning,而 Q-learning 是 off-policy。
也就是说,行为策略可以是:
目标策略是:
因此 DQN 可以用旧策略采集的数据来更新当前 Q 网络。
这就是 replay buffer 能成立的关键原因:
DQN 不要求样本一定来自当前最新策略。
所以 DQN 通常是:
它仍然在线,因为训练时还在不断和环境交互采新数据;它也是 off-policy,因为学习目标是贪心策略,而采样时可以探索。
目标网络 Target Network。
如果直接用同一个网络计算当前估计和目标:
会有一个问题:你在更新 的时候,目标 也跟着变。
也就是说,神经网络一边追目标,目标一边跑。
这会造成训练不稳定。
DQN 的解决方法是使用两个网络:
- 当前网络 :负责被训练、计算当前估计。
- 目标网络 :负责计算 TD 目标。
当前估计:
目标值:
目标网络参数 不每一步更新,而是每隔 步从当前网络复制一次:
这样 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 中:
所以网络输入 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)
输出每个状态下所有动作的 值。
gather(1, actions)
表示只取实际执行过的动作对应的 值,也就是:
self.target_q_net(next_states).max(1)[0]
表示取下一状态中最大的动作价值:
训练流程。
DQN 训练过程可以概括为:
- 初始化当前 Q 网络 。
- 初始化目标 Q 网络 。
- 初始化 replay buffer。
- 智能体用 -greedy 和环境交互。
- 把 transition 存入 buffer:
- 当 buffer 中样本数量超过
minimal_size后,随机采样 batch。 - 计算 TD target。
- 用 MSE loss 更新当前 Q 网络。
- 每隔
target_update次,同步目标网络:
代码中的主要超参数是:
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
其中:
minimal_size:buffer 里至少有多少数据后才开始训练。batch_size:每次从 buffer 抽多少样本。target_update:目标网络多少次更新同步一次。
以图像为输入的 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 帧图像叠在一起作为输入。
为什么要叠多帧?
因为单张图片只能看到当前位置,看不到速度。连续几帧可以让网络推断运动方向和速度。
输出仍然是每个离散动作的价值:
DQN 和前面算法的关系。
和 Q-learning 的关系:
表格型 Q-learning:
DQN:
一个更新表格里的数,一个更新神经网络参数。
和 Dyna-Q 的关系:
- Dyna-Q 用模型生成模拟经验。
- DQN 用 replay buffer 重复利用真实经验。
- 二者都在提高样本利用率,但机制不同。
和动态规划的关系:
- 动态规划需要已知环境模型。
- DQN 不需要知道环境模型。
- DQN 只需要通过交互采样得到 transition。
容易混淆的点。
第一,DQN 不是适合所有连续问题。
DQN 适合:
如果动作也是连续的,无法简单计算:
因为动作空间无限大。这时通常需要 DDPG、SAC 等算法。
第二,replay buffer 不等于离线强化学习。
DQN 训练时通常还在继续和环境交互,所以它是 online RL。Replay buffer 只是保存旧数据并重复抽样。
第三,目标网络不是另一个智能体。
目标网络只是当前网络的旧版本,用来稳定 TD target。
第四,DQN 的 TD target 不对目标网络反向传播。
训练时优化的是当前网络 ,目标网络 只用于构造目标。
小结。
DQN 解决的是表格型 Q-learning 无法处理大规模或连续状态空间的问题。
它的核心结构是:
核心目标是:
核心损失是:
最重要的两个稳定技巧:
- 经验回放:打破样本相关性,提高样本利用率。
- 目标网络:固定一段时间的 TD target,减少训练震荡。
本章要记住三句话:
- DQN 是 Q-learning 的深度神经网络版本。
- DQN 用 replay buffer 解决样本相关和样本利用率问题。
- DQN 用 target network 解决 TD target 不稳定的问题。
16. 第 11 章:DQN 改进算法
普通 DQN 跑起来之后,最先暴露的两个问题是估值偏高和状态价值学习效率不高。这里看两个经典改进:
- Double DQN:缓解普通 DQN 的 值过高估计。
- Dueling DQN:把 网络结构拆成状态价值 和优势函数 两个分支。
这两个方法都不是从头发明新算法,而是在 DQN 基础上做小改动。
一句话:
Double DQN 改 TD target 的计算方式;Dueling DQN 改 Q 网络的结构。
普通 DQN 的问题:过高估计。
普通 DQN 的 TD 目标是:
其中 是目标网络。
注意这里有一个 :
它可以拆成两步:
- 选择下一状态下估计值最大的动作:
- 取这个动作的估计价值:
所以普通 DQN 实际上是:
问题在于:选动作和估价值都用了同一个网络 。
如果某个动作的估计值因为神经网络误差被高估了,那么 很容易选中它。于是 TD target 也会偏大:
也会偏大。
这样当前的 会被往偏大的目标上更新。这个偏大的 又会继续作为前面状态的更新目标,于是过高估计会逐步传播。
直觉上:
操作喜欢挑估计误差里“运气最好”的那个动作,所以容易把噪声当成真实价值。
Double DQN 的核心思想。
Double DQN 的核心是:
选动作和估价值不要用同一个网络。
普通 DQN:
Double DQN:
区别只在 里面:
- 普通 DQN:目标网络 选动作,也用目标网络 估价值。
- Double DQN:当前网络 选动作,用目标网络 估价值。
可以分成两步看:
先用当前网络选出下一状态的动作:
再用目标网络评价这个动作:
所以 Double DQN 的 TD target 是:
其中 是终止标记。若 episode 已结束,,目标就是:
Double DQN 为什么能缓解过高估计。
普通 DQN 的问题是:
哪个动作被高估,就更容易被 选中,然后这个高估值又直接进入 TD target。
Double DQN 把“选动作”和“估价值”分开。
即使当前网络 因为噪声选中了某个动作:
最终放进 TD target 的不是当前网络对它的估计,而是目标网络的估计:
两个网络参数不完全一样,误差不完全同步,因此一个网络中的正向误差不一定会被另一个网络同样高估。
所以 Double DQN 不是完全消除估计误差,而是减弱:
对正向噪声的偏好。
Double DQN 代码改动。
普通 DQN 计算下一状态最大 Q 值:
max_next_q_values = self.target_q_net(next_states).max(1)[0].view(-1, 1)
这表示:
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]
表示用当前网络选动作:
第二句:
self.target_q_net(next_states).gather(1, max_action)
表示用目标网络取这个动作的价值:
所以 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 动作是连续力矩:
但 DQN 只能处理离散动作,所以代码把连续动作离散成 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。因此合理的最大 值不应该明显大于 0。
如果训练中出现:
就说明发生了过高估计。
实验现象是:
- 普通 DQN 更容易出现 值大于 0。
- Double DQN 较少出现这种情况。
这说明 Double DQN 缓解了 值过高估计。
Dueling DQN 的核心思想。
Dueling DQN 解决的不是过高估计,而是 Q 网络结构的表达效率问题。
普通 DQN 直接输出每个动作的动作价值:
Dueling DQN 认为动作价值可以拆成两部分:
- 状态本身好不好:
- 在这个状态下,某个动作比平均水平好多少:
优势函数定义为:
所以:
这就是 Dueling DQN 的基本分解。
直觉上:
- :这个状态整体值不值得待。
- :在这个状态下,动作 相比其他动作有没有额外优势。
比如开车游戏中:
- 前方没有车时,向左、向右、保持直行差别可能不大,此时更重要的是状态本身是否安全, 更关键。
- 前方有车要超车时,不同动作差别很大,此时 更关键。
Dueling DQN 的分解不唯一问题。
如果直接写:
会出现不唯一。
假设把 加一个常数 :
同时把所有优势函数减去 :
那么:
也就是说,很多组不同的 和 都能表示同一个 。这会让训练不稳定。
为了解决这个问题,Dueling DQN 会给 加一个约束。
一种写法是减去最大优势:
实际实现中更常用的是减去平均优势:
这样可以让优势函数的均值为 0:
从而让 更像所有动作价值的平均水平:
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)
它输出每个动作的优势值:
价值分支:
self.fc_V = torch.nn.Linear(hidden_dim, 1)
它输出一个状态价值:
最后合成 Q 值:
Q = V + A - A.mean(1).view(-1, 1)
对应公式:
注意:A 的形状是:
V 的形状是:
PyTorch 会自动广播,把同一个 加到该状态下所有动作的优势值上。
Dueling DQN 为什么有用。
普通 DQN 更新某个 transition:
时,主要更新被执行动作 对应的:
其他动作的 不一定直接得到更新。
Dueling DQN 中,所有动作共享同一个状态价值分支 。只要这个状态被训练, 就会被更新,而 会影响该状态下所有动作的 :
所以它能更高效地学习“这个状态整体好不好”。
当动作空间较大,或者很多动作在某些状态下差别不明显时,Dueling DQN 的优势更明显。
Double DQN 和 Dueling DQN 的区别。
| 方法 | 改哪里 | 解决什么问题 | 核心公式 |
|---|---|---|---|
| Double DQN | TD target | 值过高估计 | |
| Dueling DQN | 网络结构 | 更好学习状态价值和动作差异 |
所以:
- Double DQN 是“目标值怎么算”的改进。
- Dueling DQN 是“网络怎么输出 Q 值”的改进。
二者可以结合使用。
过高估计的定量直觉。
章节扩展阅读中给了一个简化分析。
假设在某个状态 下,所有动作真实价值都一样:
神经网络估计有误差:
并且误差独立同分布于:
如果动作数量是 ,那么:
这说明动作数越多, 从噪声中挑到正向误差的概率越大,过高估计越严重。
推导核心是:令
则:
当 时:
所以:
这个结论不要求现实完全满足这些假设,它只是说明一个趋势:
动作越多,普通 DQN 的 操作越容易放大正向估计误差。
小结。
本章是在 DQN 基础上的改进。
普通 DQN:
Double DQN:
Dueling DQN:
本章要记住三句话:
- 普通 DQN 容易因为 操作产生 值过高估计。
- Double DQN 用当前网络选动作、目标网络估价值,从而缓解过高估计。
- Dueling DQN 把 Q 网络拆成 分支和 分支,让网络更高效地学习状态价值和动作差异。
17. 第 12 章:策略梯度算法
前面的 Q-learning、DQN、Double DQN、Dueling DQN 都属于基于价值的方法。从这里开始,重点转到基于策略的方法:不再先学一张 Q 表或 Q 网络,再从价值里挑动作,而是直接让策略本身变成可优化的函数。
一句话:
基于价值的方法先学 或 ,再从价值函数导出策略;基于策略的方法直接学习策略本身。
从 value-based 到 policy-based。
基于价值的方法学习的是:
然后通过:
得到策略。
比如 DQN 输出每个动作的 值:
再选最大动作。
策略梯度方法不这样做。它直接建模策略:
意思是:
在状态 下,以多大概率选择动作 。
对于离散动作,策略网络通常输出一个概率分布:
这些概率满足:
策略参数化。
策略梯度方法首先要让策略可导。
如果动作是离散的,可以让神经网络输出 logits,再经过 softmax 得到动作概率:
其中:
是神经网络输出的未归一化分数。
所以策略网络做的是:
DQN 的网络输出是动作价值;策略梯度的网络输出是动作概率。
优化目标。
策略梯度的目标是让策略的期望回报最大。
可以把目标函数写成:
其中 是由当前策略采样得到的一条轨迹:
整条轨迹的回报是:
策略梯度要做的是:
注意这里是梯度上升,因为目标是最大化回报。
在 PyTorch 中通常写成最小化负号形式:
用梯度下降最小化这个 loss,就等价于做梯度上升最大化回报。
策略梯度公式。
策略梯度定理的核心形式是:
直觉是:
- 如果某个动作带来的 高,就增加它的概率。
- 如果某个动作带来的 低,就降低它的概率。
因为:
表示“怎样调整参数才能让这个动作的概率变大”。
所以完整含义是:
为什么有 。
策略梯度常用 log-derivative trick:
因为:
所以:
这个技巧可以把“对采样概率求导”转成“对 log 概率求导”,从而得到:
而轨迹概率中,只有动作选择概率和策略参数有关:
环境转移概率:
不依赖 ,所以:
这就是 REINFORCE 更新里为什么要用:
而不是直接用:
REINFORCE 算法。
REINFORCE 是最经典的策略梯度算法。它用蒙特卡洛方法估计动作价值。
对于一条轨迹:
从时刻 开始的回报是:
REINFORCE 的梯度估计是:
对应参数更新:
如果 大,说明这一步之后的结果好,那么就增加当时动作的概率。
如果 小,说明这一步之后的结果差,那么就降低当时动作的概率。
REINFORCE 是 on-policy。
策略梯度公式里的期望来自当前策略:
所以 REINFORCE 必须用当前策略采样出来的轨迹来更新当前策略。
它不像 DQN 那样可以随便从 replay buffer 里反复抽旧策略数据。
所以 REINFORCE 是:
含义是:
- model-free:不学习环境模型。
- on-policy:用当前策略采样的数据更新当前策略。
- Monte Carlo:要等一条 episode 结束后,用完整回报 更新。
代码: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 对比:
- DQN 的网络输出 值,可以是任意实数。
- PolicyNet 输出动作概率,必须非负且和为 1。
所以最后用了:
F.softmax(..., dim=1)
假设 CartPole 有两个动作,网络输出可能是:
意思是:
- 以 的概率向左。
- 以 的概率向右。
代码:动作采样。
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)
得到动作概率:
torch.distributions.Categorical(probs)
表示根据这个离散概率分布构造采样器。
如果:
那么动作 0 有 概率被采样,动作 1 有 概率被采样。
这和 DQN 的 -greedy 不同:
- DQN:大多数时候选最大 的动作,少数时候随机探索。
- REINFORCE:动作本来就是按策略概率随机采样。
代码:更新策略。
核心更新代码:
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
如果从最后一步开始反向遍历,那么每一步的 正好是:
例如最后一步:
倒数第二步:
再前一步:
gather(1, action) 的作用是从所有动作概率中取出实际执行动作的概率:
所以:
log_prob = torch.log(self.policy_net(state).gather(1, action))
对应:
损失:
loss = -log_prob * G
对应:
用梯度下降最小化这个损失,相当于用梯度上升最大化:
所以如果 大,梯度会推动策略提高该动作概率。
为什么 REINFORCE 方差大。
REINFORCE 用完整轨迹回报 作为动作好坏的估计。
优点是:它的梯度估计无偏。
缺点是:方差很大。
因为同一个动作之后,后面发生的很多随机事件都会影响最终回报:
如果轨迹比较长, 的波动会很大,导致更新方向也抖动。
这就是后续 Actor-Critic 要解决的问题:
用 Critic 估计价值,降低纯蒙特卡洛回报带来的高方差。
策略梯度证明骨架。
从轨迹期望目标开始:
对 求梯度:
使用 log-derivative trick:
得到:
也就是:
轨迹概率为:
取 log:
只有策略项依赖 ,所以:
代回去:
进一步用从当前时刻往后的回报 替代整条轨迹回报,可得到 REINFORCE 常用形式:
这就是代码中:
loss = -log_prob * G
的理论来源。
小结。
本章从基于价值的方法转向基于策略的方法。
DQN 学的是:
策略梯度学的是:
REINFORCE 的核心公式是:
代码中的损失函数是:
本章要记住三句话:
- 策略梯度直接优化策略概率 。
- REINFORCE 用完整轨迹回报 来估计策略梯度,是 on-policy 的蒙特卡洛方法。
- REINFORCE 梯度无偏但方差大,这会引出后续 Actor-Critic。
18. 第 13 章:Actor-Critic 算法
这一章接在 REINFORCE 后面。REINFORCE 已经能直接优化策略:
但是它用完整回报 做更新信号:
问题是 来自一整条采样轨迹,随机性很强,所以方差大,训练容易抖。Actor-Critic 的核心想法是:仍然用 Actor 学策略,但再训练一个 Critic 来估计当前状态好不好,用更稳定的价值信号指导 Actor 更新。
Actor 和 Critic 分别做什么。
Actor 是策略网络,负责决定动作:
它输入状态 ,输出每个动作的概率,然后按这个概率采样动作。
Critic 是价值网络,负责评价状态:
它输入状态 ,输出一个标量,表示“从这个状态开始,未来大概能拿到多少回报”。
所以 Actor-Critic 的一句话理解是:
- Actor 负责“怎么做”。
- Critic 负责“做得好不好”。
- Actor 根据 Critic 的评价调整自己的动作概率。
和前面算法的关系。
DQN 是 value-based 方法。它学习动作价值:
然后通过最大化 来选动作:
REINFORCE 是 policy-based 方法。它不先学 表或 网络,而是直接学习策略:
Actor-Critic 仍然是 policy-based,因为最终被优化的是 Actor 的策略参数 。但它引入 Critic 来学习价值函数 ,用价值函数降低策略梯度的方差。
可以这样分:
| 方法 | 学什么 | 更新信号 | 是否需要等整条序列结束 |
|---|---|---|---|
| DQN | TD 目标 | 不需要 | |
| REINFORCE | 完整回报 | 通常需要 | |
| Actor-Critic | 和 | TD 误差 | 不需要 |
从策略梯度到 Actor-Critic。
上一章的策略梯度可以写成更一般的形式:
这里的 是“这次动作到底有多好”的评价信号。
在 REINFORCE 中,取:
也就是用从 时刻开始的完整回报评价动作。
Actor-Critic 想把这个评价信号换得更稳定一些。一个自然想法是:不要只看完整回报,而是看“这一步实际发生的结果,比 Critic 原来预期的好多少”。这个差值就是 TD 误差:
这里:
- 是 Critic 对当前状态的旧估计。
- 是这一步之后得到的新目标。
- 两者相减,就是“实际一步结果相对预期好不好”。
如果:
说明动作 带来的结果比 Critic 原来预期更好,所以 Actor 应该提高这个动作的概率。
如果:
说明动作 带来的结果比预期更差,所以 Actor 应该降低这个动作的概率。
因此 Actor 的损失可以写成:
这和 REINFORCE 的形式非常像:
区别只是把完整回报 换成了 TD 误差 。
为什么 TD 误差可以指导策略更新。
先看优势函数:
它表示:在状态 下,选择动作 比这个状态的平均水平好多少。
如果 ,说明动作 比平均动作好,应该增加概率。
如果 ,说明动作 比平均动作差,应该减少概率。
但真实的 通常不知道,所以用一步 TD 目标近似:
于是优势函数可以近似为:
这正是:
所以 Actor-Critic 本质上是在用 TD 误差近似优势函数,再用优势函数做策略梯度更新。
Critic 怎么学。
Critic 要学习状态价值:
它的监督目标来自贝尔曼方程:
所以代码中会构造 TD 目标:
其中 表示是否终止:
- 若 ,说明还没结束,要加上未来价值。
- 若 ,说明已经结束,未来价值为 。
Critic 的损失就是让当前估计靠近 TD 目标:
也就是代码里的均方误差。
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)
它输入状态,输出动作概率:
价值网络输出的是一个状态价值标量:
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)
它输入状态,输出:
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 的参数是分开的:
Actor 更新策略参数 ,Critic 更新价值参数 。
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()
流程是:
- 把状态转成张量。
- 输入 Actor,得到动作概率。
- 用
Categorical(probs)构造离散动作分布。 - 从这个分布中采样动作。
这不是取最大概率动作,而是按概率采样。这样可以保持探索。
例如 Actor 输出:
那么动作 更可能被选到,但动作 也仍然有机会被选到。
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 目标:
对应代码:
td_target = rewards + self.gamma * self.critic(next_states) * (1 - dones)
第二步,计算 TD 误差:
对应代码:
td_delta = td_target - self.critic(states)
第三步,取出实际执行动作的概率:
log_probs = torch.log(self.actor(states).gather(1, actions))
这里 self.actor(states) 输出每个状态下所有动作的概率,例如:
如果实际动作是:
那么 gather(1, actions) 会取出:
也就是每条样本中“真实被执行动作”的概率。
第四步,更新 Actor:
对应代码:
actor_loss = torch.mean(-log_probs * td_delta.detach())
这里的 .detach() 很重要。它表示:Actor 更新时,把 当成一个固定评价分数,不通过 反向更新 Critic。也就是说:
- Actor 只根据 Critic 给出的分数改策略。
- Critic 不在 Actor 的损失里被顺手更新。
第五步,更新 Critic:
对应代码:
critic_loss = torch.mean(
F.mse_loss(self.critic(states), td_target.detach()))
这里 td_target.detach() 表示把 TD 目标当成固定标签。Critic 的任务是让:
靠近:
为什么 Actor-Critic 不必等整条序列结束。
REINFORCE 需要完整回报:
所以通常要等一条 episode 结束,才能从后往前计算 。
Actor-Critic 用的是一步 TD 误差:
这个量只需要当前转移:
就能算出来。因此它可以边采样边更新,也可以把一批 transition 收集起来后更新。
方差和偏差的变化。
REINFORCE 的 来自真实采样回报,所以它更接近无偏估计,但方差大。
Actor-Critic 的 用到了 Critic 的估计:
这会引入一定偏差,因为 Critic 不一定准确。但它通常能显著降低方差,所以训练更稳定。
可以记成:
| 方法 | 评价信号 | 优点 | 缺点 |
|---|---|---|---|
| REINFORCE | 理论上更无偏 | 方差大,学得慢 | |
| Actor-Critic | 方差更小,可单步更新 | 依赖 Critic,可能有偏 |
Actor-Critic 是 on-policy 吗。
本章这个基础 Actor-Critic 通常是 on-policy。
原因是 Actor 的更新使用的是当前策略采样出来的数据:
然后用这批数据更新同一个 Actor。也就是说,采样策略和被优化的策略是同一个。
但 Actor-Critic 是一个框架,不是只有一种算法。后面很多算法也属于 Actor-Critic 思路,例如:
- A2C 和 A3C 通常是 on-policy。
- PPO 通常是 on-policy 或近似 on-policy。
- DDPG、TD3、SAC 通常是 off-policy,并且会使用 replay buffer。
和 DQN、REINFORCE 的直观对比。
如果只用一句话区分:
- DQN:先学会评价动作,再选最大价值动作。
- REINFORCE:直接让高回报轨迹中的动作概率变大。
- Actor-Critic:让 Critic 判断这一步比预期好不好,再让 Actor 调整动作概率。
用公式看:
DQN 更新:
REINFORCE 更新:
Actor-Critic 更新:
其中:
所以 Actor-Critic 可以看成:把 REINFORCE 里的完整回报 ,换成由 Critic 估计出来的 TD 误差 。
小结。
这一章要抓住一条主线:
REINFORCE 的问题是:
方差太大。
Actor-Critic 的改进是引入 Critic:
然后用 TD 误差:
替代完整回报 来指导 Actor:
Critic 自己通过 TD 目标学习:
本章最终可以记成三句话:
- Actor 负责输出策略 。
- Critic 负责估计价值 。
- Actor-Critic 用 TD 误差 替代 REINFORCE 的完整回报 ,从而降低方差,让策略学习更稳定。
参考链接: