TRPO 笔记
请注意,《工程数学》中的某些内容是本文的前置知识。
代码实践
https://github.com/boyu-ai/Hands-on-RL/blob/main/%E7%AC%AC11%E7%AB%A0-TRPO%E7%AE%97%E6%B3%95.ipynb
我们以车杆(CartPole)环境为例。
view(-1) 和 cat
在数学推导中,我们通常把神经网络的所有参数 $\theta$ 当作一个超大的、一维的列向量。例如公式里的梯度 $g$ 和海森向量积 $H \cdot v$,这里的 $v$ 和 $g$ 都是一维向量。
但是,在 PyTorch 的底层实现中,神经网络的参数是以多个不同形状的张量(Tensor)构成的列表。比如:
- 第一层权重矩阵可能是
[64, 3]的矩阵 - 第一层偏置可能是
[64]的向量 - 第二层权重矩阵可能是
[2, 64]的矩阵
1 | kl_grad = torch.autograd.grad(kl, |
调用 torch.autograd.grad(kl, self.actor.parameters()) 时,返回的 kl_grad 并不是一个一维向量,而是一个元组(Tuple),里面包含了与每个参数形状完全相同的梯度张量。
为了让后续的数学运算(比如与向量 $v$ 做点积 torch.dot)能够成立,我们必须把这些零散的矩阵和向量全部“拍平”并“拼接”成一个一维大向量:
grad.view(-1):它的作用是展平(Flatten)。无论传入的grad是几维矩阵,.view(-1)都会把它强行变成一个一维数组。torch.cat([...]):它的作用是拼接(Concatenate)。把刚才展平的所有一维数组,首尾相连,拼成一个真正代表网络所有参数梯度的一维超大向量。
line_search() 详细解析
- 保存现场:将当前网络参数展平成一维向量
old_para,并计算出更新前的目标函数值old_obj。 - 循环尝试(最多 15 次):
coef = self.alpha**i:alpha通常是一个小于 1 的数(比如 0.5)。随着循环次数i的增加,coef会按指数衰减(1, 0.5, 0.25, 0.125…)。new_para = old_para + coef * max_vec:尝试走出不同缩放比例的步长。第一次尝试走满 100% 的步长,第二次尝试走 50%,以此类推。
- 加载并在虚拟网络中测试:
- 为了不破坏真正的策略网络,代码用
copy.deepcopy复制了一个影子网络new_actor。 vector_to_parameters是刚才cat的逆操作:把一维向量拆解回各个矩阵的形状,并塞进new_actor中。
- 为了不破坏真正的策略网络,代码用
- 双重条件检验:用影子网络重新在当前状态下计算策略分布,并检查两个条件:
new_obj > old_obj:改善条件,目标函数必须真实地得到提升。kl_div < self.kl_constraint:约束条件,新旧策略的 KL 散度必须严守边界限制。
- 返回结果:
- 如果找到某个
coef满足上述双重条件,立刻“见好就收”,返回这个成功的参数new_para。 - 如果循环了 15 次步长都缩得极小了还是不满足条件,说明这次更新方向太差,宁可不更新,直接返回
old_para(原地踏步)。
- 如果找到某个
+ 1e-8 的作用
这是一个常见的工程技巧。
在计算步长因子 max_coef 时,分母包含了 torch.dot(descent_direction, Hd)。理论上因为海森矩阵是正定的,这个点积应该大于 0。但在计算机浮点数的有限精度下,如果更新量极小,这个点积可能会被算成 0,甚至是极微小的负数(比如 -1e-15)。
- 如果分母是 0,除法会报错或产生
NaN(Not a Number)。 - 如果分母是负数,套上
torch.sqrt()求平方根也会产生NaN。
加上一个极其微小的正数 1e-8,可以保证根号下的值永远为正,程序不会崩溃。
td_delta.cpu() 中的 .cpu()
在深度学习中,数据可能存在于内存(CPU)或者显存(GPU / self.device)中。
states、rewards、td_delta之前为了交给神经网络计算,已经被送到了 GPU 上(.to(self.device))。- 但是,接下来代码调用了自定义的
compute_advantage()函数。这类优势函数的计算(通常是 GAE 计算)往往包含时序上的依赖关系(比如倒序的for循环累加),或者底层使用了类似于scipy.signal.lfilter等不支持 GPU 张量的库。 - 所以,必须先用
.cpu()把td_delta从显存“拉回”到主机的 CPU 内存中,让compute_advantage计算完毕后,再通过.to(self.device)把算好的优势值重新送回显存,供下一步网络更新使用。
特定案例对比
对应的代码见于:
- https://github.com/boyu-ai/Hands-on-RL/blob/main/%E7%AC%AC11%E7%AB%A0-TRPO%E7%AE%97%E6%B3%95.ipynb 后半部分
- https://github.com/boyu-ai/Hands-on-RL/blob/main/%E7%AC%AC8%E7%AB%A0-DQN%E6%94%B9%E8%BF%9B%E7%AE%97%E6%B3%95.ipynb
根据代码库中的运行日志,我们可以看到非常明显的对比:
- DQN/DDQN:在
Pendulum-v0环境中,仅仅训练了 200 个 episode,平均回报就达到了 -293.374。 - TRPO (连续动作):在相同的环境中,训练了 2000 个 episode,最终平均回报才达到 -296.363。TRPO 花费了 10 倍的交互次数,才达到与 DDQN 相近的分数。
造成这种现象的原因,恰好揭示了这两种算法在底层逻辑上的核心差异。主要有以下几个原因:
- Off-policy vs On-policy
- DDQN 是离线策略算法:它使用了一个经验回放池(Replay Buffer)。智能体在环境中探索产生的数据会被存起来,在后续的训练中被反复随机抽取用于更新网络。这使得 DDQN 的样本利用率极高。
- TRPO 是在线策略算法:它只能使用当前策略采集的数据来更新网络。一旦网络参数更新,这批数据就会被直接丢弃,下次更新必须重新去环境中交互收集新数据。这就导致了 TRPO 这种策略梯度方法天生“极其吃样本”(Sample Inefficient),需要海量的 episode 才能收敛。
- 动作空间的“降维打击”
- 在第 8 章的 DDQN 代码中,为了处理
Pendulum-v0连续环境,代码巧妙地将一维的连续动作暴力离散化成了 11 个离散动作 (action_dim = 11)。对于 DDQN 来说,从 11 个确定的选项中挑出一个最优值是一件非常简单且容易收敛的任务。 - 在第 11 章的 TRPO 中,模型直面的是连续动作空间。策略网络需要同时去拟合动作的高斯分布的均值和标准差。从零开始在连续空间中摸索并建立准确的概率分布,其探索难度远大于在 11 个离散动作中做选择。
- 在第 8 章的 DDQN 代码中,为了处理
- TRPO 的“保守”天性
- 我们在推导 TRPO 的时候提到过,它的核心思想是 “信赖域(Trust Region)”。它通过 KL 散度严格限制了每次策略更新的步长,绝对不许迈大步,以此来保证单调递增的稳定性。
- 相比之下,DDQN 等基于值函数的方法在拟合 Q 目标时,可以跨越很大的梯度步长快速向最优解逼近。
TRPO 真的不如 DDQN 吗?
在 Pendulum-v0 这种低维度、简单环境下,通过离散化使用 DDQN 确实是性价比最高、见效最快的方法。
但是,如果环境变得复杂呢?
假设我们在做机械臂控制,动作空间是 6 维的连续力矩。
- 如果使用 DDQN 并离散化:每个维度分 11 份,总动作数就是 $11^6 = 1,771,561$ 种。输出层需要一百多万个神经元,DDQN 会瞬间崩溃,这就是著名的维度灾难(Curse of Dimensionality)。
- 如果使用 TRPO(或后来的 PPO):由于它原生支持连续动作,输出层仍然只需要输出 6 个均值和 6 个标准差(共 12 个神经元),依然可以稳定训练。
因此,DDQN 赢在了简单任务的效率,而 TRPO 赢在了应对高维连续控制任务的上限与稳定性。














