请注意,《工程数学》中的某些内容是本文的前置知识。

代码实践

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
2
3
4
kl_grad = torch.autograd.grad(kl,
self.actor.parameters(),
create_graph=True)
kl_grad_vector = torch.cat([grad.view(-1) for grad in kl_grad])

调用 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() 详细解析

  1. 保存现场:将当前网络参数展平成一维向量 old_para,并计算出更新前的目标函数值 old_obj
  2. 循环尝试(最多 15 次)
    • coef = self.alpha**ialpha 通常是一个小于 1 的数(比如 0.5)。随着循环次数 i 的增加,coef 会按指数衰减(1, 0.5, 0.25, 0.125…)。
    • new_para = old_para + coef * max_vec:尝试走出不同缩放比例的步长。第一次尝试走满 100% 的步长,第二次尝试走 50%,以此类推。
  3. 加载并在虚拟网络中测试
    • 为了不破坏真正的策略网络,代码用 copy.deepcopy 复制了一个影子网络 new_actor
    • vector_to_parameters 是刚才 cat 的逆操作:把一维向量拆解回各个矩阵的形状,并塞进 new_actor 中。
  4. 双重条件检验:用影子网络重新在当前状态下计算策略分布,并检查两个条件:
    • new_obj > old_obj改善条件,目标函数必须真实地得到提升。
    • kl_div < self.kl_constraint约束条件,新旧策略的 KL 散度必须严守边界限制。
  5. 返回结果
    • 如果找到某个 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)中。

  • statesrewardstd_delta 之前为了交给神经网络计算,已经被送到了 GPU 上(.to(self.device))。
  • 但是,接下来代码调用了自定义的 compute_advantage() 函数。这类优势函数的计算(通常是 GAE 计算)往往包含时序上的依赖关系(比如倒序的 for 循环累加),或者底层使用了类似于 scipy.signal.lfilter 等不支持 GPU 张量的库。
  • 所以,必须先用 .cpu()td_delta 从显存“拉回”到主机的 CPU 内存中,让 compute_advantage 计算完毕后,再通过 .to(self.device) 把算好的优势值重新送回显存,供下一步网络更新使用。

特定案例对比

112-5.png

对应的代码见于:

根据代码库中的运行日志,我们可以看到非常明显的对比:

  • DQN/DDQN:在 Pendulum-v0 环境中,仅仅训练了 200 个 episode,平均回报就达到了 -293.374
  • TRPO (连续动作):在相同的环境中,训练了 2000 个 episode,最终平均回报才达到 -296.363。TRPO 花费了 10 倍的交互次数,才达到与 DDQN 相近的分数。

造成这种现象的原因,恰好揭示了这两种算法在底层逻辑上的核心差异。主要有以下几个原因:

  1. Off-policy vs On-policy
    • DDQN 是离线策略算法:它使用了一个经验回放池(Replay Buffer)。智能体在环境中探索产生的数据会被存起来,在后续的训练中被反复随机抽取用于更新网络。这使得 DDQN 的样本利用率极高
    • TRPO 是在线策略算法:它只能使用当前策略采集的数据来更新网络。一旦网络参数更新,这批数据就会被直接丢弃,下次更新必须重新去环境中交互收集新数据。这就导致了 TRPO 这种策略梯度方法天生“极其吃样本”(Sample Inefficient),需要海量的 episode 才能收敛。
  2. 动作空间的“降维打击”
    • 在第 8 章的 DDQN 代码中,为了处理 Pendulum-v0 连续环境,代码巧妙地将一维的连续动作暴力离散化成了 11 个离散动作 (action_dim = 11)。对于 DDQN 来说,从 11 个确定的选项中挑出一个最优值是一件非常简单且容易收敛的任务。
    • 在第 11 章的 TRPO 中,模型直面的是连续动作空间。策略网络需要同时去拟合动作的高斯分布的均值标准差。从零开始在连续空间中摸索并建立准确的概率分布,其探索难度远大于在 11 个离散动作中做选择。
  3. 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 赢在了应对高维连续控制任务的上限与稳定性