看到“hindsight”这个关键词,我第一反应就是 OpenAI 那篇《Hindsight Experience Replay》。如果你也在做机器人控制类任务,比如让机械臂把方块抓起来放进盒子里,你一定体会过稀疏奖励带来的绝望:环境只在你成功完成整个任务的那一刻才给出正向回报,其他所有尝试全部是零。普通 off-policy 算法硬训,跑个三百万步,成功率照样纹丝不动。而 hindsight 这招,本质上就是教会算法“事后复盘”:没抓到想要的方块,但抓到了旁边的杯子,那就把“去抓杯子”临时当成这次尝试的目标,于是这条失败经验立刻变成了一条有正奖励的样本。这篇博文会把 HER 的原理、采样策略、代码实现和我在 Gym 里踩过的坑一次讲透,适合正在做强化学习基线实验,或者被稀疏奖励折磨的读者。
1. 理解 HER 前的第一关:稀疏奖励到底有多“毒”
1.1 一个让 DDPG 彻底失效的任务
拿 OpenAI 的 FetchReach 举例。机械臂末端要从起点移动到一个随机生成的目标点,阈值是 0.05 米,动作空间是三维连续力。环境每一步都会调用compute_reward:如果末端离目标小于阈值,返回 0,否则返回 -1。每个 episode 最多 50 步,所以一条完全失败的轨迹总回报是 -50。
问题就在这:目标点是随机采样的,机械臂前几步基本都在乱飞,绝大多数 transition 的 reward 都是 -1。Critic 网络试图估计 Q 值,但所有失败样本的回报都差不多,它根本学不出“哪个动作更好”。Actor 网络则一直在输出随机动作,今天涨一点,明天跌一点,成功率曲线就是一条平坦的直线。我在第一次跑 DDPG 时给这个任务训了 100 轮,最终成功率 0%,围观的人都觉得我代码写错了,其实这就是稀疏奖励的日常。
相比之下,如果改用稠密奖励,比如每一步给负的欧氏距离,就算策略很差,critic 也能从“距离缩短了一点”里获得梯度,所以大家一开始都会下意识去做 reward shaping。但 reward shaping 是有代价的:你得手工设计势函数,而且设计不好会诱导策略钻空子,比如绕远路消磨时间。HER 提供了一条完全不同的路——不改奖励,改目标。
1.2 人是怎么用“后见之明”学习的
你会投篮吗?我第一次投三分,发力太大,球过了篮筐撞到篮板弹了回来。这个尝试失败了,但如果我重新定义目标为“把球打到篮板上沿”,那我其实成功了。强化学习里这叫 credit assignment 的问题:智能体已经做出一串动作,产生了实际后果,但我们因为“没有命中原始目标”而把这些动作全标成无效。后见之明就是把这些无效动作重新解释成对另一个目标的成功操作。
HER 的核心假设是:目标其实是一种条件变量。我们不需要让智能体在开始前就知道唯一正确的目标,而是让它在探索后说一句“我原本想达到的其实是它实际到达的那个状态”。对随机策略来说,任何轨迹都会以一定概率“成功”地碰到某个状态,只要这个状态可以被当成目标。这样,失败轨迹就被转化成了成功轨迹,而且这些成功轨迹是智能体靠自己的动作真实走出来的,不是假数据。
1.3 目标重标注的数学表达
假设一条轨迹里有个原始 transition:
- a:当前状态 (s_t)
- b:执行动作 (a_t)
- c:转移到 (s_{t+1})
- d:原始目标为 (g)
- e:奖励 (r_t = reward(s_{t+1}, g))
如果 (s_{t+1}) 没有达到 (g),那么 (r_t = -1),这条样本对学习没有帮助。HER 的做法是:从这条轨迹的后续状态中挑一个实际到达过的状态 (g'),比如第 (t') 步的机械臂末端位置,然后用 (g') 替换原始目标,重新计算奖励:
- (r_t' = reward(s_{t+1}, g'))
因为 (g') 确实是轨迹后面的真实状态,(s_{t+1}) 通常离 (g') 不会太远,因此 (r_t') 很容易变成 0(成功)。于是我们得到了一条新的 transition:
- ((s_t, a_t, s_{t+1}, g', r_t'))
这条样本可以被放进 replay buffer 给 off-policy 算法使用。为什么这个 trick 有效?因为任务是随机采样目标,但智能体实际到达的状态分布也是随机的,任何一条轨迹都可能对上某个“更容易的目标”。HER 相当于自动生成课程:先从容易达成的目标学起,再逐步逼近真实目标。
2. HER 的四个采样策略和关键超参数
2.1 future、final、episode、random 到底怎么选
从论文里可以找到四种重标定目标的采样策略,这里我把它们的区别整理成了一张表:
| 策略 | 新目标的来源 | 特点 | 适合场景 |
|---|---|---|---|
| final | 整个 episode 最后一个状态的 achieved_goal | 目标固定,简单,容易学,但可能离原始目标很远 | 快看看训练管道是否跑通 |
| future | 当前 transition 之后的某个状态的 achieved_goal | 目标可达性最强,难度适中,论文推荐 | 绝大多数稀疏奖励任务 |
| episode | 整个 episode 中随机一个状态的 achieved_goal | 比 final 更随机,覆盖范围广 | 目标状态分布复杂时 |
| random | 从已经见过的所有 achieved_goal 里随机选 | 依赖 buffer 的多样性,效果通常最差 | 当你有大量真实目标样本时 |
我自己的经验是,future 策略是默认首选。它选择 (j \geq i),也就是说新目标来自当前时间步之后的某个状态,这保证了“当前动作确实能把状态推向目标”这个因果方向是对的。final 策略常常让目标太“远”,试几次之后你会发现成功率上不去;random 策略则完全丢掉了 trajectory 的顺序信息,效果不稳定。
2.2 k 值:一条经验要重标定几次
k 表示每条原始 transition 额外生成几条 HER 样本。原始论文里试过 k=2、4、8,我复现下来的感受是:FetchPush 这类任务 k=4 就已经很稳,k=8 在训练后期会因为重标定样本过多,导致模型对真实目标“关注度不够”,反而出现成功率波动。
这里有个容易被忽略的点:k 只是每条 transition 生成的重标定样本数量。假如 k=8,那么 buffer 中每 1 条原始样本就对应 8 条 HER 样本,采样时遇到 HER 样本的概率高达 8/9。如果策略一直学“任意目标都能到达”,最后可能变成“为了到达而到达”,原始任务目标反而被稀释。所以我建议在实现里做一层保护:采样时强制 50% 的样本来自原始目标 transition,剩下 50% 来自 HER 样本。这样做比单纯调 k 更直观有效,也能缓解下面的 4.2 问题。
2.3 离线学习是 HER 的地基
HER 重标定是在历史轨迹上做的,这些轨迹并非当前策略在线产生,因此必须搭配 off-policy 算法。DDPG、TD3、SAC 都能用,而在线策略算法(比如 PPO、A2C)直接用 HER 会有很严重的分布漂移问题,因为重标定后的样本已经不服从当前策略的采样分布。
我见过有人直接用 PPO + HER,结果还不如不用 HER。这不是 HER 的错,而是算法特性不匹配。如果你非要在 on-policy 框架里用“后见之明”的思想,一般只能做成 reward shaping 或 goal relabeling 后再用重要度采样修正,复杂度会高很多。对于绝大多数场景,老老实实选 DDPG 或 TD3 就够了。
3. 实操手记:给 DDPG 加上 HER,在 Gym 上跑通 FetchPush
3.1 环境与代码结构准备
我用的是老版本的gym,环境名是FetchPush-v1。安装的时候注意,机器人相关的环境在gym[robotics]里面,需要额外安装mujoco。如果你环境装不上,至少可以减少一个维度,换成FetchReach-v1,这个环境更简单,适合验证代码逻辑。
FetchPush-v1的观测是一个字典,核心字段有三个:
observation:机械臂关节角、速度、物体位置等achieved_goal:当前实际实现的目标(比如方块位置)desired_goal:任务想要的目标
HER 之所以能实现,完全依赖于观测空间里区分achieved_goal和desired_goal。重标定时我们只需要改掉desired_goal,其他字段保持不变。所以,在设计自己的自定义环境时,一定要把“实际状态”和“期望目标”分到两个独立字段,否则后见之明无从下手。
3.2 重写 ReplayBuffer:核心就这么十几行
HER 的 replay buffer 和普通 DDPG 不一样,它需要按“episode”来存储,因为重标定必须知道整条轨迹的后续状态。下面是一份简化但能跑的核心代码:
import numpy as np class HERBuffer: def __init__(self, capacity=1000000, k=4, strategy='future'): self.capacity = capacity self.k = k self.strategy = strategy self.episodes = [] def _store(self, transition): if len(self.episodes) >= self.capacity: del self.episodes[0] self.episodes.append(transition) def add_episode(self, episode): length = len(episode) for i, trans in enumerate(episode): # 先保存原始 transition self._store(trans) # 再生成 k 条 HER transition for _ in range(self.k): if self.strategy == 'future': j = np.random.randint(i, length) elif self.strategy == 'final': j = length - 1 elif self.strategy == 'episode': j = np.random.randint(0, length) else: # random,目标从所有已经见过的真实目标中选,这里简化用整集 j = np.random.randint(0, length) new_goal = episode[j]['achieved_goal'] new_reward = self._compute_reward( trans['next_obs']['achieved_goal'], new_goal ) new_done = 1.0 if new_reward == 0.0 else 0.0 self._store({ 'obs': trans['obs']['observation'], 'action': trans['action'], 'reward': new_reward, 'next_obs': trans['next_obs']['observation'], 'done': new_done, 'goal': new_goal, }) def sample(self, batch_size): # 真正的实现里需要拼接 goal:最终输入是 concat(obs, goal) # 这里只给逻辑,不展开每一行拼接代码 transitions = np.random.choice(self.episodes, batch_size) return transitions这段代码有一点很重要:重标定后的 reward 是用实际转移到的 achieved_goal 和新目标算出来的,而不是用原始 reward。如果你忘了这一步,等于没有 HER。
3.3 奖励与 done 的正确重标定
在原版 Fetch 环境里,compute_reward返回 -1(失败)或 0(成功)。重标定时直接用同样的函数计算新 reward 即可。代码如下:
def _compute_reward(self, achieved_goal, desired_goal): # 二值稀疏奖励 return -1.0 if np.linalg.norm(achieved_goal - desired_goal) > 0.05 else 0.0但这里有一个隐蔽的坑:done 标志。环境返回的 done 表示原始任务是否成功,而重标定后的“成功”是假的。如果直接把new_done当作真实 episode 结束交给 Q-learning bootstrap,会让 critic 误以为智能体可以“控制何时结束”,导致价值函数失衡。
我在实验里比较过两种做法:
done = new_done:训练初期的确更容易看到 Q 值上涨,但容易高估价值,后期震荡。done = 0:训练稍慢,但更稳,最终成功率更高。
我现在的习惯是,HER 样本的 done 统一设成 0,原始目标样本仍然使用环境的 done。如果你用的是 TD3,它对 done 的敏感度相对低一些,用new_done也没太大问题;如果用的是原始 DDPG,建议保守一点。
3.4 一套可以照抄的训练配置
我经常用这套配置跑 FetchPush,成功率能稳定达到 90% 以上:
| 参数 | 取值 |
|---|---|
| 算法 | DDPG(或 TD3) |
| 策略结构 | Actor / Critic 都是 256 x 256,ReLU |
| 采样策略 | future |
| k 值 | 4 |
| replay buffer | 1e6 |
| batch size | 128 |
| actor / critic 学习率 | 1e-3 |
| 目标网络 soft update 系数 tau | 0.05 |
| 探索噪声 | OU noise 或高斯噪声,std=0.2 |
| 每轮 episode 数 | 50 |
| 每 episode 最大步数 | 50 |
| 每轮更新次数 | 40 |
训练流程是:每个 epoch 先跑 50 个 episode,把整条 episode 塞进 buffer,然后从这个 buffer 里采样 40 次去做梯度更新。我在 FetchPush 上跑大概 8 到 15 个 epoch 就能看到成功率从 0 跳到 80%,到了 25 个 epoch 左右稳定在 95% 上下。
4. 踩坑实录:HER 训练中的五个常见问题
4.1 训练半天不收敛,十有八九是 reward 没设计对
HER 最标准的奖励函数是二值稀疏奖励,即成功给 0,失败给 -1。这不是巧合,而是因为重标定后的新目标是从真实轨迹里抽出来的,如果 reward 是稠密的距离函数,那么“接近任意目标”也会获得高的奖励,会让 critic 对“是否真正达成目标”失去区分度。
我一开始就犯过这个错,把 reward 写成- distance,结果成功率一直卡在 10% 左右。后来改成二值奖励,同样超参,没几轮就上了 80%。如果你的任务不是二值奖励,至少也要保证 reward 函数是以“阈值”为核心的,比如0 / -1,而不是平滑的负距离。
4.2 重标定样本淹没原始目标
我前面提到 k=8 时 HER 样本占比过高,会导致策略“学偏”。最典型的表现是:策略确实学会了移动机械臂,但指定目标在左,它偏要去右,因为右侧在训练数据中出现的频率更高。
解决方法有两个:
- 把 k 降到 4 或 2;
- 在采样函数里固定分配比例,比如 50% 原始目标样本 + 50% HER 样本。
第二种方法更稳健,因为即使 k 调大,原始目标也不会被完全淹没。
4.3 Q 值发散和过高估计
HER 本身不解决 Q 值过高估计。DDPG 在 FetchPush 上训到中后期,我经常看到 critic loss 突然飙到几百,然后成功率为零。常见原因有两个:一个是done标志乱用导致价值回传过强,另一个是目标网络更新太快。
对付这个问题的组合拳是:
- 使用 TD3,双 critic 取 min,能大幅抑制过高估计;
- 或者把 tau 从 0.05 降到 0.005;
- critic 学习率从 1e-3 降到 3e-4;
- 加 gradient clipping,限制 critic 梯度的 L2 范数不超过 10。
在 HER 代码里调这些超参,比换一个复杂的 reward shaping 方案简单多了。
4.4 成功率曲线出现“U 型反转”
最讨厌的情况是:前期已经训到 80%,再跑几个 epoch 突然跌回 0。我踩过之后总结了背后的原因:replay buffer 中早期随机样本太多,随着训练推进,策略分布逐渐偏离这些旧样本,但 buffer 采样时仍会抽到大量旧数据,导致 critic 对当前策略的价值判断严重偏置。
缓解办法:
- 减少 buffer 容量,从 1e6 降到 2e5,让旧数据自然淘汰;
- 每个 epoch 增加更新次数,从 40 次调到 80 次,让策略更快跟上新数据;
- 或者给探索噪声加退火,训练后期让动作更确定,降低分布漂移。
如果你看到成功率呈锯齿状忽上忽下,基本就是 buffer 中旧数据比例太高,优先调整 buffer 容量。
4.5 快速定位表
这里整理一张速查表,帮你在训练过程中快速定位问题:
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 从一开始就收敛不了 | reward 不是二值、done 标志混乱 | 改稀疏 0/-1 奖励,HER 样本 done 置 0 |
| 学了一点就卡住 | k 太小、没有用 future 策略 | k 设为 4,策略切换为 future |
| 后期掉回零 | buffer 中旧样本占比太高 | 减小 buffer,增加更新次数 |
| Q 值爆炸 | 过高估计、目标网络更新过快 | 换 TD3,调低 tau 和学习率 |
| 目标被忽略 | HER 样本占比过高 | 采样时保底 50% 原始目标样本 |
4.6 独家小技巧
再分享一个网上文档里不常写的东西:HER 与随机目标采样联合使用时,最好做 goal normalization。Fetch 系列的 goal 是三维坐标,范围基本在 [-1, 1] 之间,问题不大;但如果你自定义环境的目标范围很大,比如 [0, 100],HER 重标定出的目标会严重偏离训练分布。我建议在把 goal 喂给网络之前,先减均值再除标准差,让目标分布在 0 附近。这个操作对收敛速度的提升非常明显。
5. 写在最后的一段个人体会
拿了 hindsight 这个名字做关键词写这篇文章,是因为我自己真的被它救过。去年做一个机械臂抓取项目,环境只在抓起并且放到托盘成功时才给 reward,我用稀疏奖励的 DDPG 训了两天,成功率纹丝不动。后来加了 HER,一天之内就看到了可用策略。我实际跑下来最稳定的一套组合是:future 策略 + k=4 + 二值奖励 + HER 样本 done 置 0 + 50% 原始目标保底。训练 FetchPush 约 20 轮就很稳,FetchPickAndPlace 需要 50 轮以上,但总算可以从容等待训练曲线上升,而不是干瞪眼。
总之真正给我留下印象的,不是 HER 这个技巧本身有多么花哨,而是它把“失败”变成了“训练资源”。如果你也在跟稀疏奖励对抗,不妨先别急着设计花哨的 reward shaping,给现有的 off-policy 算法加上 hindsight,也许第一天你就会看到不一样的成功率曲线。