Skip to content

RL工程实践

强化学习算法的理论公式通常很简洁,但从公式到可运行、可复现的代码之间,隔着大量的工程细节。这些细节在论文中往往一笔带过,却对最终性能影响巨大。本笔记系统整理RL工程中最核心的实践知识:从数据收集的Buffer设计,到训练稳定性的Normalization技巧,到分布式采样架构,到日志监控与调参指南。


Rollout机制

什么是Rollout

在on-policy方法(A2C、PPO等)中,Rollout 是指用当前策略 \(\pi_\theta\) 与环境交互,收集一批经验数据的过程。每次Rollout后,数据被用于更新策略,然后丢弃(或在PPO中复用K个epoch后丢弃)。

一次Rollout的数据量由两个参数决定:

\[ \text{Rollout Size} = N_{\text{envs}} \times T_{\text{horizon}} \]

其中 \(N_{\text{envs}}\) 是并行环境数量,\(T_{\text{horizon}}\) 是每个环境中收集的步数。

Rollout Buffer vs Replay Buffer

这两者是on-policy和off-policy方法各自的数据存储机制,本质上完全不同:

对比维度 Rollout Buffer Replay Buffer
使用算法 A2C, PPO (on-policy) DQN, SAC (off-policy)
数据来源 当前策略 \(\pi_\theta\) 历史策略的混合
存储方式 固定大小,每次Rollout后清空 循环队列,持续存储
数据复用 用完即弃(或K个epoch) 反复采样,高度复用
典型大小 \(N \times T\)(几千到几万) \(10^5 \sim 10^6\) 个transition
采样方式 顺序遍历(打乱成mini-batch) 随机采样

Rollout Buffer的数据结构

一个典型的Rollout Buffer存储以下字段:

class RolloutBuffer:
    states:     ndarray  # (N*T, obs_dim)   观测状态
    actions:    ndarray  # (N*T, act_dim)   执行的动作
    rewards:    ndarray  # (N*T,)           即时奖励
    dones:      ndarray  # (N*T,)           是否终止
    log_probs:  ndarray  # (N*T,)           log π_old(a|s)
    values:     ndarray  # (N*T,)           V_old(s)
    advantages: ndarray  # (N*T,)           GAE优势估计(后计算)
    returns:    ndarray  # (N*T,)           目标回报(后计算)

其中 log_probsvalues 是在数据收集时用旧参数计算的,在后续的策略更新中作为"参考值"使用。advantagesreturns 在Rollout完成后通过GAE反向递推计算。

Episode Truncation与Padding

在并行环境中,不同环境的episode长度不同。当某个环境的episode结束时,有两种处理方式:

方式1: 自动重置(Auto-reset)

大多数Gym/Gymnasium的向量化环境使用这种方式。当一个环境的episode结束时,自动重置并返回新episode的初始状态。此时需要注意:

  • done=True 的下一个状态 s' 属于新episode,不应用于Bootstrap
  • GAE计算中需要用 (1 - done) 来"切断"跨episode的传播
  • 需要记录真正的"最终观测"(terminal observation),因为auto-reset后返回的 s' 已经是新episode的初始状态

方式2: Truncation(截断)

当环境由于达到最大步数限制而终止时(区别于自然结束),这称为Truncation。Truncation时episode并没有真正结束,因此需要用 \(V(s_{\text{last}})\) 来Bootstrap:

\[ \delta_{T-1} = r_{T-1} + \gamma V(s_T) - V(s_{T-1}) \quad \text{(truncated, 不乘 (1-done))} \]

在Gymnasium中,truncatedterminated 是分开的信号,需要分别处理。


经验回放 (Replay Buffer)

经验回放是off-policy方法的核心组件。DQN之所以能稳定训练,很大程度上归功于经验回放打破了数据的时序相关性。

基础Replay Buffer

数据结构: 一个固定容量的循环队列(Circular Buffer),存储transition \((s, a, r, s', \text{done})\)

Circular Buffer (容量 = C)

写入指针 →
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ t5│ t6│ t7│ t8│ t9│ t0│ t1│ t2│ t3│ t4│
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
                          ↑
                     最旧的数据(下次被覆盖)

核心操作:

  • 存储: 每一步交互后,将 \((s, a, r, s', \text{done})\) 写入当前位置,指针前移。满了后覆盖最旧的数据。
  • 采样: 从buffer中均匀随机采样一个mini-batch(通常32-256个transition)用于训练。

均匀采样的问题: 所有transition被采样的概率相同,但并非所有数据的"学习价值"相同。有些transition包含了大量新信息(如遇到罕见状态或犯了大错),有些则是"无聊"的常见转移。这引出了PER。

Prioritized Experience Replay (PER)

核心思想: 让"更有学习价值"的transition被采样的概率更高。"学习价值"用TD误差的绝对值来衡量——TD误差大意味着当前的价值估计对这个transition的预测偏差大,因此从中学习的空间更大。

优先级定义:

\[ p_i = |\delta_i| + \epsilon \]

其中 \(\delta_i = r_i + \gamma \max_{a'} Q(s_i', a') - Q(s_i, a_i)\) 是TD误差,\(\epsilon > 0\) 是一个小常数,防止优先级为零(永远不被采样)。

采样概率:

\[ P(i) = \frac{p_i^\alpha}{\sum_k p_k^\alpha} \]

\(\alpha \in [0, 1]\) 控制优先级的强度:\(\alpha = 0\) 退化为均匀采样,\(\alpha = 1\) 完全按优先级采样。

重要性采样修正: 优先级采样改变了数据分布,如果不修正,会引入偏差。PER使用重要性采样权重来修正梯度:

\[ w_i = \left( \frac{1}{C} \cdot \frac{1}{P(i)} \right)^\beta \]

其中 \(C\) 是buffer容量,\(\beta \in [0, 1]\) 控制修正强度。训练过程中 \(\beta\) 从某个初始值(如0.4)线性退火到1.0,确保训练后期完全消除偏差。

实际使用时,权重需要归一化:\(w_i \leftarrow w_i / \max_j w_j\),防止权重过大导致不稳定。

实现效率: 朴素实现需要 \(O(C)\) 时间来计算采样概率。实际中使用 Sum Tree(一种完全二叉树)数据结构,将采样和更新复杂度降至 \(O(\log C)\)

Sum Tree 结构 (存储优先级之和)

              [总和 = 42]
             /            \
          [29]             [13]
         /    \           /    \
       [13]   [16]      [3]   [10]
       / \    / \       / \    / \
      [3][10][12][4]   [1][2] [8][2]    ← 叶节点 = 各transition的优先级

Hindsight Experience Replay (HER)

适用场景: 目标条件强化学习(Goal-conditioned RL),即智能体需要达到某个指定目标 \(g\),策略为 \(\pi(a|s, g)\)

核心问题: 在稀疏奖励环境中(只有达到目标才有正奖励),智能体几乎永远得不到正反馈,学习无法启动。

核心思想: 即使智能体没有达到预定目标 \(g\),它到达的实际状态 \(s_T\) 本身也可以作为一个"事后目标"。通过将失败的经验重新标注为"以 \(s_T\) 为目标的成功经验",创造出额外的正样本。

HER的过程:

原始经验(目标 g = [5, 5],实际到达 [3, 2],奖励 = 0):
  (s₀, a₀, r=0, s₁, g=[5,5])
  (s₁, a₁, r=0, s₂, g=[5,5])
  ...
  (s_{T-1}, a_{T-1}, r=0, s_T=[3,2], g=[5,5])    ← 全是失败

HER重标注(用 g' = s_T = [3,2] 替换目标):
  (s₀, a₀, r=0, s₁, g'=[3,2])
  (s₁, a₁, r=0, s₂, g'=[3,2])
  ...
  (s_{T-1}, a_{T-1}, r=1, s_T=[3,2], g'=[3,2])   ← 最后一步"成功了"!

目标采样策略(Goal Sampling Strategy):

  • Final: 用episode最终到达的状态作为新目标
  • Future: 从当前时刻之后的某个状态随机选取作为新目标
  • Episode: 从整个episode中随机选取
  • Random: 从整个buffer中随机选取

实践中,Future 策略通常效果最好,因为它利用了更多的重标注数据。


Normalization技巧

Normalization是RL训练稳定性的重要保障。不同于监督学习中数据分布相对固定,RL中的状态分布、奖励分布、优势分布都在不断变化(因为策略在变)。

Observation Normalization

问题: 不同状态维度的尺度可能差异巨大。比如机器人的关节角度可能在 \([-\pi, \pi]\),而位置坐标可能在 \([-100, 100]\)

解决方案: 维护观测值的Running Mean和Running Std(滑动均值和标准差),对每个观测做标准化:

\[ s_{\text{norm}} = \frac{s - \mu_{\text{running}}}{\sigma_{\text{running}} + \epsilon} \]

Running Statistics更新(Welford's online algorithm):

\[ \mu_n = \mu_{n-1} + \frac{x_n - \mu_{n-1}}{n} \]
\[ M_n = M_{n-1} + (x_n - \mu_{n-1})(x_n - \mu_n) \]
\[ \sigma_n^2 = \frac{M_n}{n} \]

注意事项:

  • 评估时使用训练过程中累积的统计量,不再更新
  • 可以对归一化后的值做Clipping(如限制在 \([-10, 10]\))防止极端值
  • Stable Baselines3中通过 VecNormalize wrapper实现

Reward Normalization

问题: 不同环境的奖励尺度差异巨大。Atari游戏的奖励可能是 \(\{-1, 0, +1\}\),而MuJoCo任务的奖励可能在 \([-100, 100]\)

方法1: Reward Scaling

用奖励的Running Std来缩放(注意:不减去均值,因为减去均值会改变任务的性质——把"获得正奖励"变成"偏离均值"):

\[ r_{\text{scaled}} = \frac{r}{\sigma_{\text{running}}(r) + \epsilon} \]

实际上更常见的做法是对回报 \(G_t\) 做归一化:用 \(G_t\) 的running std来缩放奖励:

\[ r_{\text{scaled}} = \frac{r}{\sigma_{\text{running}}(G) + \epsilon} \]

方法2: Reward Clipping

直接将奖励截断到某个范围,如 \([-1, 1]\)\([-5, 5]\)。DQN的Atari论文就使用了 \(\text{clip}(r, -1, 1)\)

  • 优点:简单暴力,适用于奖励只有方向信息重要的情况
  • 缺点:丢失了奖励的幅度信息

Advantage Normalization

在每个mini-batch内对优势值做标准化(Per-batch Standardization):

\[ \hat{A}_t \leftarrow \frac{\hat{A}_t - \text{mean}(\hat{A})}{\text{std}(\hat{A}) + \epsilon} \]

为什么有效:

  1. 使优势值约一半为正、一半为负,梯度信号更平衡
  2. 使梯度的尺度与优势值的尺度解耦,降低学习率敏感性
  3. 在不同训练阶段自动调整信号强度

注意: 这会引入轻微的偏差(因为改变了优势的均值),但在实践中影响可忽略不计,稳定性的收益远大于偏差的代价。


并行采样

数据收集是on-policy RL的瓶颈。并行化采样可以显著提高吞吐量。

Vectorized Environments

SubprocVecEnv(多进程环境):

每个环境运行在一个独立的子进程中,通过管道(pipe)与主进程通信。

主进程 (策略推理 + 梯度更新)
    │
    ├──[pipe]──> 子进程 1: Env1.step(a1) → (s1', r1, done1)
    ├──[pipe]──> 子进程 2: Env2.step(a2) → (s2', r2, done2)
    ├──[pipe]──> 子进程 3: Env3.step(a3) → (s3', r3, done3)
    └──[pipe]──> 子进程 N: EnvN.step(aN) → (sN', rN, doneN)
  • 优点:真正的并行,利用多核CPU
  • 缺点:进程间通信有开销,序列化/反序列化数据
  • 适用:环境本身计算量大(如物理仿真、渲染)

DummyVecEnv(单进程环境):

所有环境在同一个进程中顺序执行,只是用向量化接口包装。

  • 优点:无通信开销,调试方便
  • 缺点:没有真正的并行
  • 适用:环境轻量级(如CartPole)、调试阶段

Async vs Sync采集

同步采集(Synchronous):

所有环境同时step,等所有环境都完成后再统一推理策略。这是A2C/PPO的标准模式。

时间 ──>

Env1: ████████░░░░████████░░░░    ← 快环境等慢环境
Env2: ████████████████████████    ← 慢环境
Env3: ██████░░░░░░██████░░░░░░
           ↑              ↑
        批量推理         批量推理
  • 优点:数据对齐,实现简单,策略推理可以批量化(GPU高效)
  • 缺点:受最慢环境拖累

异步采集(Asynchronous):

环境完成后立即发送数据,不等其他环境。A3C使用这种模式。

  • 优点:无等待,吞吐量最大化
  • 缺点:数据对齐困难,策略版本可能不一致(stale gradient问题)

Distributed Training

当需要极大规模的数据吞吐时,采样和训练可以分布到多台机器上。

典型架构:

┌─────────────────────────────────────────────────────┐
│                  Learner (GPU)                       │
│        接收数据 → 计算梯度 → 更新参数                   │
└──────────┬─────────────────────────┬────────────────┘
           │ 发送参数                │ 发送参数
     ┌─────▼──────┐           ┌─────▼──────┐
     │  Worker 1  │           │  Worker 2  │
     │  (CPU)     │           │  (CPU)     │
     │ N个环境     │           │ N个环境     │
     └────────────┘           └────────────┘
  • Worker 负责与环境交互收集数据,使用Learner发来的最新参数
  • Learner 负责梯度计算和参数更新
  • 通信通常通过共享内存、gRPC或ZMQ实现

梯度聚合方式:

  1. 集中式: Worker发送经验到Learner,Learner统一计算梯度
  2. 分布式: 每个Worker计算本地梯度,全局AllReduce聚合
  3. 异步SGD: Worker异步推送梯度(可能有stale gradient问题)

Logging与Evaluation

训练指标

RL训练中需要监控的核心指标:

基础指标:

指标 含义 正常范围/期望趋势
ep_return 单episode总回报 持续上升
ep_length 单episode长度 任务相关
fps 每秒处理的帧数 稳定

策略指标(PPO特有):

指标 含义 正常范围/期望趋势
policy_loss \(-L^{\text{CLIP}}\) 绝对值不宜太大
value_loss \(L^{VF}\) 持续下降
entropy \(H(\pi_\theta)\) 缓慢下降(不应骤降)
approx_kl 新旧策略的近似KL散度 \(< 0.02\)(通常)
clip_fraction 被clip的样本比例 \(0.1 \sim 0.3\)
explained_variance \(1 - \frac{\text{Var}(R - V)}{\text{Var}(R)}\) 接近1说明Critic好

关键告警信号:

  • entropy 骤降至接近0 → 策略过早收敛,需加大entropy系数
  • approx_kl 持续大于0.05 → 更新步长太大,需减小学习率或增加epoch
  • clip_fraction 接近0 → clipping没有生效,\(\epsilon\) 可能太大
  • clip_fraction 接近1 → 几乎所有样本都被clip,可能是学习率太大
  • explained_variance 持续为负 → Critic比随机猜还差,需检查网络架构或学习率

评估协议

训练时的回报和评估时的回报是不同的。训练时策略带有探索噪声(随机采样),评估时通常使用确定性策略

Deterministic vs Stochastic评估:

# 训练时(Stochastic)
action = policy.sample(obs)        # 从分布中采样

# 评估时(Deterministic)
action = policy.mean(obs)          # 取分布的均值/众数
# 离散动作空间: action = argmax π(a|s)
# 连续动作空间: action = μ(s)  (高斯策略的均值)

独立评估环境:

评估应使用独立的环境实例,与训练环境分离:

  • 评估环境的seed应与训练不同
  • 评估环境不应有Normalization wrapper(或使用训练统计量)
  • 每次评估运行多个episode(通常10-20个)取平均

Best Model Checkpointing:

if mean_eval_return > best_return:
    best_return = mean_eval_return
    save_model(policy_net, value_net, "best_model.pt")
  • 每隔固定步数(如每10次rollout)进行一次评估
  • 保存评估回报最高的模型
  • 同时保存最新模型(用于恢复训练)
  • 保存Normalization的统计量(Running mean/std),否则加载模型后无法正确预处理观测

常用工具

TensorBoard: PyTorch原生支持,通过 SummaryWriter 记录标量、直方图、图像等。Stable Baselines3默认使用。

Weights & Biases (W&B): 云端实验管理平台,自动记录超参数、指标、系统资源。支持实验对比、超参数搜索。

MLflow: 开源实验跟踪工具,支持本地部署,适合企业环境。

这三者的选择主要看个人偏好和团队需求。对于个人研究,TensorBoard足够;对于团队协作和大规模实验,W&B更方便;对于需要私有部署的企业,MLflow是首选。


调参指南

超参数敏感性

超参数 典型值 敏感性 调参建议
Learning Rate \(3 \times 10^{-4}\) 极高 先试 \(3 \times 10^{-4}\),不行再搜索 \([10^{-4}, 10^{-3}]\)
\(\gamma\)(折扣因子) 0.99 长期任务用0.99,短期任务可用0.95
\(\lambda\)(GAE) 0.95 0.95是安全的默认值
\(\epsilon\)(PPO clip) 0.2 几乎不需要调
Entropy Coeff \(c_2\) 0.01 探索不足时增大,收敛不了时减小
Value Coeff \(c_1\) 0.5 0.5或1.0都可以
Mini-batch Size 64-256 太小方差大,太大更新次数少
N Epochs (K) 4-10 太大导致over-fitting rollout数据
N Envs 8-128 越多越好(受限于CPU)
T Horizon 128-2048 太短GAE估计差,太长on-policy偏差大
Gradient Clip 0.5 0.5是安全默认值

最重要的超参数:Learning Rate。 如果只调一个超参数,就调学习率。其他超参数使用默认值通常就能得到不错的结果。

常见失败模式与调试

1. 回报不增长或极度波动

  • 检查奖励函数是否正确实现
  • 检查环境的 done 信号是否正确
  • 降低学习率
  • 增加并行环境数量(更多数据 = 更低方差)

2. 回报先升后降(Catastrophic Forgetting)

  • PPO:减小学习率,减少epoch数 \(K\),减小 \(\epsilon\)
  • 可能是环境的reward hacking——策略找到了exploit而非真正解决问题

3. 策略过早收敛到次优解

  • 增大entropy系数 \(c_2\)
  • 检查是否有足够的探索(增加随机性)
  • 考虑使用更大的网络

4. Critic的Explained Variance很低

  • 增大Critic的网络容量
  • 降低学习率(可能Critic更新太激进)
  • 检查observation normalization是否开启

5. 训练初期就崩溃(NaN或Inf)

  • 检查observation和reward的尺度,开启normalization
  • 降低学习率
  • 检查网络初始化
  • 确保 \(\log \pi\) 的计算不会出现 \(\log 0\)(加 \(\epsilon\)

6. 在MuJoCo上性能差

  • 确保使用continuous action space(Gaussian policy)
  • 检查action scaling(动作范围是否匹配环境)
  • MuJoCo通常需要更大的网络([256, 256]而非[64, 64])
  • 确保observation normalization开启

调试流程建议:

1. 先在简单环境上验证代码正确性
   CartPole (离散) → Pendulum (连续) → HalfCheetah (复杂连续)

2. 对照已知好的实现
   用SB3的PPO跑同样的环境和超参数,对比回报曲线
   如果SB3也不行,那是超参数问题;如果SB3行你的不行,那是代码bug

3. 逐步增加复杂度
   先关闭所有trick (no normalization, no clipping),确保基础训练循环正确
   再逐一加入trick,观察每个trick的影响

4. 可视化一切
   录制策略行为的视频
   画出值函数的热力图
   检查动作分布是否合理

评论 #