“hindsight”——后见之明。英文里常说 hindsight is 20/20,意思是事后看一切都清清楚楚。做强化学习这几年,我对这个词体会最深的地方反而不在日常复盘,而在一个名字就叫 Hindsight Experience Replay 的算法里。它把我一直以来的困惑——稀疏奖励下智能体死活学不会——用一句话点破了:一条轨迹如果没达成原定目标,那就换个目标再看它,它很可能就是一条完美的成功轨迹。
这个项目就是我基于 HER 做的一次完整复现和调参实录。我用它跑通了 OpenAI Gym 里的 Fetch 系列机器人操作环境,从最简单的 FetchReach 一路做到 FetchPickAndPlace,核心结论一句话就能概括:在不做任何奖励塑形的前提下,HER 能把“成功率几乎为 0”的训练曲线直接拉到 90% 以上。如果你正在被稀疏奖励问题折磨,或者想找一个适合练手的连续控制强化学习项目,这篇文章应该能给你省下不少弯路。我会把目标重标注的原理、四种采样策略的取舍、实现细节和踩坑记录都摊开来讲,所有代码思路都是可以直接抄走的。
1. 项目定位:一个小事后视角如何盘活稀疏奖励
有个场景做 RL 的人都熟:你设计了一个机器人抓取任务,机械臂要接近物体、抓住它、再放到目标位置。你给环境的奖励很简单——成功就给 +1,没成功就给 0。结果训练了几十万步,成功率纹丝不动,智能体像瘫痪了一样。问题就出在“稀疏奖励”上:在漫长轨迹里绝大多数状态都是零奖励,智能体根本不知道哪个动作离目标更近,于是梯度信号彻底消失。
1.1 稀疏奖励的本质:学习信号被抽干了
稀疏奖励环境的典型特征是“只有终局才有评价”。这里可以打个不太严谨但很好懂的比方:一场考试不告诉你每道题对错,只有总分及格与否。你要想通过刷题来提升成绩,几乎无从下手,因为没有任何中间反馈告诉你“这道题这么做对不对”。强化学习的核心是最大化累计奖励,如果一条十步的轨迹只有最后一步才可能获得奖励,那么前面九步的 (状态, 动作) 全部带零奖励——对 Q 网络来说,这些转移唯一学到的东西就是“当前状态动作带来的收益为 0”,完全丧失了方向性。
我最初接触这类问题时试过很多“民间偏方”:加大探索噪声、把奖励做成连续的距离函数、调整折扣因子……效果都不理想。尤其手工奖励塑形,虽然能把曲线推上去,但它是最容易埋雷的方案。你设计的距离奖励函数里每一项权重都需要调,稍微不均衡就会诱导智能体学会“假动作”——比如只顾着把手伸到物体旁边但根本不抓取,因为你的塑形奖励让“靠近物体”本身变得太诱人了。
1.2 传统经验回放为什么救不了场
经验回放(Experience Replay)本身是 DQN 时代的经典技巧:把历史转移样本存进大缓冲区,随机采样更新网络,好处是打破时间相关性、提高样本利用率。在奖励密集的环境里这套组合拳很有效,但在稀疏奖励环境里,回放缓冲区里攒下的绝大多数都是失败的“垃圾样本”。你用这些样本来反复更新 Q 网络,学到的是“所有动作都一样差”。再怎么加大回放比例,也只是把“无信号”重复更多遍而已,不会凭空产生学习信号。
当时我甚至怀疑过是否要转用策略梯度类算法,比如 PPO。但这类 on-policy 算法在机器人操作任务里样本效率更低,每一次参数更新后旧的探索数据就报废了,在真实物理环境或高成本仿真里完全跑不动。所以问题的关键不是换算法,而是想办法让每一条“失败轨迹”都能榨出正面的训练信号。
1.3 HER 的切入点:把失败轨迹重新定性
HER 的做法听起来像耍赖:轨迹结束之后,我们“事后”看一眼智能体实际到达的状态,然后把“目标”改写成这个实际状态,这条原本失败的轨迹瞬间就变成了一条成功轨迹。比如抓取任务里机械臂没把物体放到指定位置 B,但它在某个时刻把物体运到了位置 C——那我们就把这条轨迹的目标改成 C,观察它是不是真的达成了 C。如果是,这条轨迹的后续步骤就获得了正奖励,可以用于学习。
这就是“后见之明”在算法里的具体化:智能体当时的动作序列虽然没有达成原目标,但它展示了一套能把某个中间状态变成现实的有效策略。我们用这套轨迹去教导 Q 网络——“当目标恰好是这个状态时,这样的动作序列是好的”。无数条失败轨迹的最终状态各不相同,合在一起就构成了一大批“伪成功数据”。
需要强调的是,这套思路有一个严格前提:算法必须是 off-policy 的。因为我们改写了轨迹的目标和奖励,却保留了原来的动作序列,这等于在用另一个分布下产生的数据去训练当前策略。DDPG、DQN、SAC、TD3 这类 off-policy 算法天然吃这种数据,而 PPO 这类 on-policy 算法会用“重要性采样比例”硬生生修正分布差距,重标注后的数据基本派不上用场。
2. 核心机制拆解:目标重标注与采样策略的取舍
HER 的原理一句话能讲完,但落地时魔鬼全在细节里。最关键的环节是“轨迹存储时如何选虚拟目标”,以及“选好目标后奖励和 done 怎么重新计算”。很多复现失败的项目都是卡在这一步。
2.1 k 次重标注:一条轨迹当多条用
实际操作时,我们不是简单地把每条轨迹的目标替换成最终状态就完了。论文里的标准做法是:一条轨迹采集完成后,从中随机抽取 k 个后续时间步的状态作为额外目标,再把原始目标也保留一份。也就是说,一条轨迹会被拆成 (k+1) 份独立样本存入回放缓冲区。k 就是论文里的那个回放比例参数,最常用的值是 4。
k 值决定了样本量的放大倍数。取 4 意味着同样数量的环境交互,能产生 5 倍于普通经验回放的训练样本,这对于稀疏奖励环境来说简直是雪中送炭。但 k 也不是越大越好,我实测过 k=8 的时候缓冲区里伪成功样本比例过高,网络收敛速度反而略有下降。这个后面在调参部分会详细说。
2.2 四种重标注策略对比
论文提出了四种选择虚拟目标的方式,我复现时把它们的区别整理成了一张表:
| 策略 | 虚拟目标来源 | 特点 | 适用场景 |
|---|---|---|---|
| final | 轨迹最终状态 | 最简单,稳定 | 目标容易达成的简单任务 |
| future | 当前时间步之后的某个随机状态 | 样本多样性强,最常用 | 大多数机器人操作任务 |
| episode | 同一条轨迹中随机一个状态 | 目标可能与当前状态重叠,难度偏低 | 可以作为 baseline 对比 |
| random | 整个训练过程中的随机状态 | 难度高,几乎学不动 | 一般不建议直接用 |
我在复现时把 future 作为默认策略,效果确实最稳。它的直觉是:从轨迹当前时刻往后的任意一个状态,都可以作为“未来可能达成的目标”。这样选择的虚拟目标既贴近轨迹的真实走向,又因为随机性保留了足够的探索覆盖范围。而 final 策略虽然简单,但每条轨迹只有一个虚拟目标,样本多样性不足,在 FetchPickAndPlace 这类多阶段任务里很容易陷入局部最优。episode 策略我试过一次,因为随机选择的状态可能出现在当前时间步之前,导致目标“太好达成”,训练出来的策略泛化性偏差。
2.3 最容易翻车的点:奖励和 done 都要跟着重标
很多第一次写 HER 的人都以为只需把 (s, g, a, r, s') 里的 g 换成虚拟目标就行,这是个典型的隐蔽错误。目标一旦变了,这条转移的奖励必须重新判定:在新目标下,如果下一状态 s' 真的与虚拟目标足够接近,奖励就给 0(或正值),否则仍然是稀疏惩罚。同时,如果这一判定成立,done 标志也要置为 True,因为“这一条伪轨迹已经完成了任务”,后续动作不再有意义。
这个细节的重要性怎么强调都不过分。如果只换目标不换奖励,Q 网络会学到“任意动作在新目标下都没奖励”,重标注就白做了;如果只看奖励不纠正 done,时序差分更新会把一条伪成功轨迹的后续动作也当成有效经验,导致目标价值被高估,训练末期出现严重的策略震荡。我见过不少开源实现就是栽在这两个标志位的不同步上。
以下是训练时单条轨迹重标注并入库的核心伪代码片段,我按自己的习惯做了简化和注释:
def hindsight_store(episode_transitions, achieved_states, original_goal, k=4): """ episode_transitions: list of (s, a, r, s_next, done, goal) achieved_states: 轨迹中每个时间步实际到达的状态集合 """ # 原始目标样本先入库 replay_buffer.add_all(episode_transitions) # 从轨迹中选 k 个虚拟目标 # future 策略:只允许选择当前时间步之后的状态 for _ in range(k): virtual_goal = random.choice(achieved_states) new_transitions = [] for t, (s, a, _, s_next, _, _) in enumerate(episode_transitions): # 在新目标下重算稀疏奖励与 done reward = 0.0 if is_goal_reached(s_next, virtual_goal) else -1.0 done = is_goal_reached(s_next, virtual_goal) # 注意:这里存的是新目标,不是原目标 new_transitions.append((s, a, reward, s_next, done, virtual_goal)) if done: # 伪轨迹提前结束,后续状态不再使用 break replay_buffer.add_all(new_transitions)注意代码里那个if done: break。虚拟目标是从轨迹后续状态里选的,所以很可能在某个时间步就已经达成了。如果达成了还继续把后面的转移存进缓冲区,人为制造了“轨迹成功后又变失败”的矛盾信号,网络会被带偏。这个细节在我早期复现里没有处理,训练曲线一度非常诡异,后面排查了很久才发现是这儿的问题。
3. 实操要点:关键参数与训练调校
复现 HER 并不难,但要复现到“论文级别”的成功率,参数调校比想象中更考验耐心。这一部分我会按自己的实验流程来写,重点说算法选型、网络结构、目标空间处理和几个关键超参数的实测感受。
3.1 算法底座:为什么我选 DDPG 而不是 SAC
HER 的核心价值在于“给 off-policy 算法提供有效样本”,所以底层算法可以自由搭配。我最终选择 DDPG 主要有三个考虑。第一,DDPG 是 actor-critic 结构,适合连续动作空间,Fetch 系列的机械臂控制输出就是四维连续力矩/位置增量,DQN 这类离散动作算法根本用不了。第二,DDPG 的结构比 SAC 简单,网络少,训练稳定性的变量更容易控制,我复现的初衷就是先把 HER 本身跑透,不希望底层算法引入太多额外不确定性。第三,在稀疏奖励任务上 DDPG+HER 有大量公开实验数据可对照,方便我在每步实验里确认自己没有跑偏。
如果你追求更高样本效率,换成 SAC 完全可以,HER 的重标注逻辑完全不变。我的建议是:如果你是第一次上手,先用 DDPG 把 HER 的主干逻辑跑通,再考虑换 SAC 锦上添花。
3.2 网络输入:状态和目标拼在一起
Fetch 环境里智能体观测通常包含:机械臂末端位置、物体位置、相对目标的距离等。HER 要求我们显式区分“状态”和“目标”。实现时我的输入是状态和目标直接拼接成一个向量,喂给 Q 网络和策略网络。
import torch.nn as nn class Actor(nn.Module): def __init__(self, obs_dim, goal_dim, action_dim): super().__init__() self.fc1 = nn.Linear(obs_dim + goal_dim, 256) self.fc2 = nn.Linear(256, 256) self.mu = nn.Linear(256, action_dim) def forward(self, obs, goal): x = torch.cat([obs, goal], dim=-1) x = torch.relu(self.fc1(x)) x = torch.relu(self.fc2(x)) return torch.tanh(self.mu(x))有一个新手容易踩的坑:没有把目标拼接进网络输入,而是把目标信息当成奖励的一部分写进环境。这样做等于让网络只从标量奖励里间接推断目标,信息量严重不足,顶层特征根本无法区分“不同目标下应该采取不同策略”。我在最初实验时就走过这条路,训练出来的机械臂只会做同一个动作模式,换个目标位置就彻底失灵。正确做法是让目标作为输入特征直接参与网络前向传播,让网络自己去学习“目标位置变化和动作输出之间的关系”。
3.3 关键超参数表
下面这组参数是我在多个环境上试过都比较稳的组合,可以直接作为起点:
| 参数 | 取值 | 说明 |
|---|---|---|
| 目标重标注比例 k | 4 | 每条轨迹额外生成 4 条重标注样本 |
| 虚拟目标采样策略 | future | 取当前时间步之后的随机状态 |
| 回放缓冲区大小 | 1e6 | 要能容纳足够多的重标注样本 |
| 每次更新 batch 数 | 40 | 每步环境交互后更新 40 次网络 |
| batch size | 512 | 偏大的 batch 能让重标注样本更稳定 |
| actor 学习率 | 1e-3 | 使用 Adam 优化器 |
| critic 学习率 | 1e-3 | 使用 Adam 优化器 |
| 折扣因子 gamma | 0.98 | Fetch 任务轨迹短,不用太大 |
| 探索噪声 | OU 噪声或高斯噪声,标准差 0.2 | 高斯噪声更省事 |
| 目标判定阈值 | 0.05 米 | 与官方环境一致 |
看这个表你会发现每次更新的网络次数非常多,40 次更新对一条环境交互而言是很大的比例。这是 HER 的刻意设计:重标注本来就是靠多倍样本堆出来的,所以训练时要把样本利用率拉满。如果算力紧张,可以降到 20 次,但训练曲线会明显变慢。
3.4 奖励函数设计:二值奖励就够
HY 最大的好处就是让你摆脱对奖励塑形的依赖。在 Fetch 系列里我只用了最简单的方式:机械臂末端或物体与目标的距离小于 0.05 米,奖励 0;否则奖励 -1。全程没有距离惩罚、没有动作惩罚、没有分阶段奖励。
有些人会担心这样学习效率太低,实际跑下来完全不用担心。HER 靠重标注制造的海量伪成功样本,天然弥补了稀疏二值奖励缺乏梯度的缺陷。手工塑形奖励反而可能引入偏差:比如你设计了一个“越靠近目标奖励越高”的函数,智能体可能会学到停在目标附近但不完成任务的最优解,因为这样既拿到接近奖励又避免了操作失败惩罚。这类“奖励黑客”行为我见过不止一次。
4. 复现全流程与实验记录
这一部分我会把完整的复现过程写下来,从环境搭建到实验结果对比。整个过程我用的是 MuJoCo 物理引擎和 OpenAI Gym 的 Fetch 环境,硬件只是一张消费级显卡,整个训练大概跑了一天多。
4.1 环境准备与基线设定
首先是环境版本。Fetch 系列环境对 MuJoCo 版本有兼容要求,建议直接用 Gymnasium 里维护的新版本接口,避免老版本接口在 Python 3.8+ 下报各种弃用警告。安装依赖时注意不要手滑装错版本,我之前因为 MuJoCo 版本过新导致模型加载失败,白折腾了一个下午。
基线实验我做了两个:一个完全没有 HER,纯 DDPG;另一个是 HER+DDPG。两者使用完全相同的网络结构和超参数,唯一的变量就是是否开启目标重标注。这样的对照实验很有必要,否则你很难判断训练曲线的提升到底来自 HER 还是来自其他调参运气。
4.2 核心模块实现:Future 策略与缓冲区入库
在 2.3 节我已经给出了重标注的核心伪代码,这里补一下实际训练主循环的骨架。每个 episode 结束后,先提取轨迹里实现过的状态集合和转移列表,然后同时完成“原始样本入库”和“重标注样本入库”。
def train_one_epoch(env, agent, replay_buffer, k=4, noise_std=0.2): episode_transitions = [] achieved_states = [] obs, _ = env.reset() goal = obs["desired_goal"] obs = obs["observation"] done = False while not done: action = agent.select_action(obs, goal, noise_std=noise_std) next_obs, reward, terminated, truncated, info = env.step(action) done = terminated or truncated episode_transitions.append((obs, action, reward, next_obs, done, goal)) achieved_states.append(info["achieved_goal"]) obs = next_obs if done: break hindsight_store(episode_transitions, achieved_states, goal, k=k) for _ in range(40): batch = replay_buffer.sample(512) agent.update(batch)现实里还需要处理 truncated 和 terminated 的区分:环境因为超时被截断时,轨迹不完整,done 置 True 会导致价值估计偏差,所以我在重标注入库时做了专门处理,只有真正达成虚拟目标才置 done,超时截断一律不当作成功。这个小细节对末端阶段收敛很关键。
4.3 实验曲线解读:三个环境的差异
FetchReach 是最简单的任务,机械臂只需要把末端移动到目标点。HER+DDPG 大约 10 万步就已经能稳定达到接近 100% 的成功率。这个环境适合拿来验证整套代码逻辑是否正确,因为耗时短,改任何参数都能快速看到结果。
FetchPush 要稍微复杂一些,机械臂需要把桌面上的物体推到目标位置。因为存在物体和机械臂之间的接触动力学,前半段训练曲线会有较长的“平台期”,大约在 30 万步之前成功率一直是 0,然后是突然的上升。这种现象在稀疏奖励任务里很典型,很多人会在平台期误判为训练失败提前终止,实际上只要网络没有发散,再坚持一下曲线就会起来。
FetchPickAndPlace 是三个环境里最难的,机械臂要抓起物体再放到目标点。我实验里大约 120 万步才突破 20% 成功率,最终稳定在 80% 左右。这个任务对目标重标注的依赖最大,因为“抓起物体”是一个中间技能,如果不用事后视角,纯靠稀疏奖励几乎不可能学会这种多阶段操作。后期我又把 k 提高到 6,成功率还能再往上走几个点。
4.4 对比实验:HER 开关的效果差异
我的对比实验里,没有 HER 的 DDPG 在三个环境上全部“报废”:FetchReach 跑 20 万步成功率仍为 0,FetchPush 和 FetchPickAndPlace 直到我终止训练都在 0 附近徘徊。而加了 HER 之后,同样的网络、同样的探索噪声,全部环境都能跑出有效学习信号。这个对照组直观地说明了 HER 的价值:它做的不是“改进网络”,而是“改造数据”。网络架构和优化器都没变,只是数据里多了重标注目标的伪成功样本,训练就从“什么都学不到”变成了“稳定收敛”。
5. 踩坑记录与问题排查实录
复现 HER 的过程不可能一帆风顺。我把自己遇到过、且在实际群里也看到别人反复踩的坑整理成了这一节,每条都附带了排查方式和最终解决方案。
5.1 重标注目标导致训练方差爆炸
第一次把 k 调到 8 时,我观察到训练前期的成功率曲线抖动非常剧烈,甚至有时候已经收敛到 60% 又突然跌回 10%。复盘后我意识到问题不在网络不稳定,而在重标注比例过高。k=8 意味着缓冲区里伪目标样本占比极大,这些样本的目标分布和真实智能体正在探索的目标分布严重不一致,相当于频繁切换训练任务,任何网络都会被晃晕。
解决办法是降回 k=4,同时把训练目标函数里的 critic 更新频率适当降低,给网络多一点时间适应新数据。如果你在项目里必须用更大的 k,可以考虑把伪成功样本的优先级稍微调低,让它们不要过于拥挤地在每次 batch 里集中出现。
5.2 缓冲区里的“旧目标污染”
另一类隐蔽问题是:HER 把重标注样本和原始样本存在同一个缓冲区,但不同时间点采样的样本目标分布差异很大。前期智能体还没有学会任何技能时,重标注样本的目标大多是随机位置,后期智能体已经会抓取时,新样本的目标则贴近真实任务区域。旧的随机目标样本如果一直留在缓冲区里,会拖慢后期收敛速度。
一个简单有效的做法是限制缓冲区里重标注样本的保存时间,或分两个缓冲区分别存原始样本和重标注样本,采样时按比例混合。我后来在工程实现里采用了后者,效果不错,而且可配置性更强,方便对不同目标分布做独立控制。
5.3 训练中途 loss 飞到 NaN
这个坑与 HER 本身无关,但在 MuJoCo 环境里尤其常见:当机械臂末端和物体接触时,物理仿真可能出现瞬时不稳定,导致观测值里出现极大数值。这些极大数值经过网络前向传播和梯度反传,很容易把权重变成 NaN。
排查时要先确认 NaN 到底是网络结构问题还是输入数据问题。我的经验是:如果在训练脚本里加一条观测值范围检查,发现 NaN 之前 env.step 返回的观测里就已经有 inf,那基本就是物理仿真的问题。解决办法是给观测值做 clip,强制限制在合理范围,比如机械臂位置和速度都限制在 [-10, 10] 以内。轻微 clip 不会影响学习精度,却能极大提升训练稳定性。
5.4 常见问题速查表
| 现象 | 可能原因 | 检查方式与解决 |
|---|---|---|
| 训练曲线长期为 0 | 目标没有拼进网络输入 | 检查 actor/critic 输入维度 |
| 前期抖动,后期掉坑 | 缓冲区伪目标比例过高 | 降低 k,或分区缓冲采样 |
| 后期收敛但不够高 | done 标志没有跟着重算 | 检查重标注样本的 done 字段 |
| loss 为 NaN | 观测值包含 inf | 对观测值做 clip 和合法范围检查 |
| 成功率反弹慢 | 超时截断被当成成功 | 区分 terminated 和 truncated |
| 换随机种子效果差异大 | 模型敏感度偏高 | 固定随机种子,或调整目标判定阈值 |
6. 个人体会与后续扩展方向
HER 这个项目给我最大的启发,其实已经超出了算法本身。“事后视角”这个思路在现实工程里同样好用:代码出了 bug,与其盯着报错信息反复试,不如先复盘一下“实际发生的状态”和“预想中的状态”到底差在哪里。很多问题在事后看都是清晰的,这句话是真的。
在强化学习的框架里,HER 也和另一个经典概念“课程学习”很契合。因为虚拟目标是从轨迹实际状态里抽出来的,天然构成了一条从易到难的课程:早期智能体只会做随机动作,它的“事后目标”就停留在很简单的状态;随着能力提升,事后目标也自动变得更接近真实任务目标。你可以把这个特性理解成一种无需人工设计的自动课程,这一点特别适合前端训练冷启动。
我后来用同样的思路做过一个扩展实验:把 HER 的重标注逻辑从单智能体搬到一个多智能体协作场景里,让一个智能体的“失败轨迹”成为另一个智能体的“演示教材”,效果意外地不错。如果你有兴趣,建议也从 FetchReach 起步,先把这套机制吃透,再往自己的任务上迁移。
最后分享一个我后来一直沿用的习惯:每次跑 HER 实验之前,先跑一个 10 万步的微型实验,把目标重标注代码里“奖励与 done 是否同步更新”这个逻辑单独写个单元测试。这个检查只需要几分钟,却能避免绝大多数训练几个小时后才发现方向错误的惨案。别问我怎么知道要这么做的。