RL工程实践
强化学习算法的理论公式通常很简洁,但从公式到可运行、可复现的代码之间,隔着大量的工程细节。这些细节在论文中往往一笔带过,却对最终性能影响巨大。本笔记系统整理RL工程中最核心的实践知识:从数据收集的Buffer设计,到训练稳定性的Normalization技巧,到分布式采样架构,到日志监控与调参指南。
Rollout机制
什么是Rollout
在on-policy方法(A2C、PPO等)中,Rollout 是指用当前策略 \(\pi_\theta\) 与环境交互,收集一批经验数据的过程。每次Rollout后,数据被用于更新策略,然后丢弃(或在PPO中复用K个epoch后丢弃)。
一次Rollout的数据量由两个参数决定:
其中 \(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_probs 和 values 是在数据收集时用旧参数计算的,在后续的策略更新中作为"参考值"使用。advantages 和 returns 在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:
在Gymnasium中,truncated 和 terminated 是分开的信号,需要分别处理。
经验回放 (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的预测偏差大,因此从中学习的空间更大。
优先级定义:
其中 \(\delta_i = r_i + \gamma \max_{a'} Q(s_i', a') - Q(s_i, a_i)\) 是TD误差,\(\epsilon > 0\) 是一个小常数,防止优先级为零(永远不被采样)。
采样概率:
\(\alpha \in [0, 1]\) 控制优先级的强度:\(\alpha = 0\) 退化为均匀采样,\(\alpha = 1\) 完全按优先级采样。
重要性采样修正: 优先级采样改变了数据分布,如果不修正,会引入偏差。PER使用重要性采样权重来修正梯度:
其中 \(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(滑动均值和标准差),对每个观测做标准化:
Running Statistics更新(Welford's online algorithm):
注意事项:
- 评估时使用训练过程中累积的统计量,不再更新
- 可以对归一化后的值做Clipping(如限制在 \([-10, 10]\))防止极端值
- Stable Baselines3中通过
VecNormalizewrapper实现
Reward Normalization
问题: 不同环境的奖励尺度差异巨大。Atari游戏的奖励可能是 \(\{-1, 0, +1\}\),而MuJoCo任务的奖励可能在 \([-100, 100]\)。
方法1: Reward Scaling
用奖励的Running Std来缩放(注意:不减去均值,因为减去均值会改变任务的性质——把"获得正奖励"变成"偏离均值"):
实际上更常见的做法是对回报 \(G_t\) 做归一化:用 \(G_t\) 的running std来缩放奖励:
方法2: Reward Clipping
直接将奖励截断到某个范围,如 \([-1, 1]\) 或 \([-5, 5]\)。DQN的Atari论文就使用了 \(\text{clip}(r, -1, 1)\)。
- 优点:简单暴力,适用于奖励只有方向信息重要的情况
- 缺点:丢失了奖励的幅度信息
Advantage Normalization
在每个mini-batch内对优势值做标准化(Per-batch Standardization):
为什么有效:
- 使优势值约一半为正、一半为负,梯度信号更平衡
- 使梯度的尺度与优势值的尺度解耦,降低学习率敏感性
- 在不同训练阶段自动调整信号强度
注意: 这会引入轻微的偏差(因为改变了优势的均值),但在实践中影响可忽略不计,稳定性的收益远大于偏差的代价。
并行采样
数据收集是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实现
梯度聚合方式:
- 集中式: Worker发送经验到Learner,Learner统一计算梯度
- 分布式: 每个Worker计算本地梯度,全局AllReduce聚合
- 异步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 → 更新步长太大,需减小学习率或增加epochclip_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. 可视化一切
录制策略行为的视频
画出值函数的热力图
检查动作分布是否合理