“hindsight”这个词,放在强化学习圈子里,基本就是 Hindsight Experience Replay(事后经验回放)的代号。我第一次读那篇论文的时候,最大的感触是:一个困扰我很久的稀疏奖励问题,竟然被一个如此“反直觉”的思路给绕过去了。机械臂抓取、推箱子这类任务里,奖励信号稀少到几乎全是零,随机探索半天都碰不上一回成功,训练曲线像一条心电图上的直线。HER 的解法一句话就能概括:既然这次没做到预设目标,就别死盯着那个没完成的目标不放,把实际到达的终点“重新定义”成目标,然后把这整条轨迹当作成功样本喂回给智能体。这篇文章我会从算法原理一路聊到代码落地,把目标重标注策略、网络结构、训练参数和几个真实踩过的坑全部摊开,适合正在做机器人操作、稀疏奖励控制或多目标强化学习的朋友参考。
1. 从“事后视角”到可回放的样本:HER 到底解决了什么问题
1.1 强化学习里的“稀疏奖励死局”
先说一个典型场景。我要训练一个机械臂,任务是抓住桌面上的方块,移动到指定位置。环境给的奖励很吝啬:每一步只要没成功,就返回 -1,只有到达目标区域的那一步才给 +1 或者 0。整个 episode 可能持续 50 步,也就是说初始阶段几乎每一个样本的奖励都是负的,策略梯度算出来的期望几乎全被负值淹没,根本找不到向哪里调整才能提高收益。
换一种说法:奖励函数是“二值稀疏”的,成功区域在连续状态空间里只占一个微不足道的小球,随机初始化策略能碰到这个小球的概率低到可以忽略。没有命中就没有正信号,没有正信号梯度就基本无效,策略就一直原地打转。这不是某个具体算法的问题,DDPG、PPO、SAC 遇到这种环境都会集体歇菜,因为它们在样本层面缺少“正向反馈”的引导。
有人会想:那把奖励换成稠密的不行吗?比如用距离作为惩罚。可以,但有两个隐患。第一,稠密奖励需要精心设计距离度量,稍微定义错了,智能体会学到“手伸得很近但不碰物体”这种投机行为。第二,真实机器人场景里,很多任务根本没有现成的距离度量,你只能靠二值判定(比如物体是否进入目标框)。所以稀疏奖励问题本质上绕不开,必须在算法层面解决。
1.2 “目标可以事后补”这个反直觉设计
HER 的做法非常特别。假设机械臂运行了一条轨迹 τ,这条轨迹的真实目标是 g,但 episode 结束时智能体到达了状态 s_T,而这个 s_T 离 g 十万八千里,所以奖励全是负的。现在关键一步来了:我们把 g' = s_T 当作这条轨迹的新目标,然后用 g' 重新计算每一步的奖励。由于轨迹终点恰好等于 g',最后一步的奖励一定是正信号,整条轨迹也从“失败案例”变成了“成功案例”。
这里要理解为什么这个操作是合法的。在目标条件强化学习里,策略的形式是 π(s, g) → a,也就是说动作由“当前状态”和“目标”共同决定。目标 g 只是策略的输入,不参与环境的状态转移动力学。所以当我修改目标、修改奖励时,并没有改变任何一条物理状态转移的真实性,只是重新解释了“这条轨迹原本在尝试完成什么目标”。这相当于在说:我虽然没完成你交代的任务 A,但我完成了一件事 B,那么这条经验对学习“如何完成 B”就非常有价值。
更细节的一点是:目标重标注通常不是随便选一个状态,而是优先选择轨迹中“未来某个时刻真实到达的状态”。为什么?因为一条轨迹里的状态本身是因果链串起来的,后半段的动作确实把机械臂从状态 A 带到了状态 B,所以用 B 作为目标去给前半段打标签,网络学到的就是“一步步接近 B”的正确因果模式。如果随机选一个从未到达过的状态当目标,你等于在教智能体完成一个不存在于现实中的目标,样本就成了噪声。
1.3 它和普通经验回放、逆强化学习的本质区别
普通经验回放只是把历史数据存下来重复使用,解决的是样本利用率问题,但它没有改变“正样本稀少”这个事实。HER 则是在不改变状态转移的前提下,重新标记了轨迹的“成功属性”,把原本被判定为失败的大量数据改造成目标条件策略下的正样本,这是质变而不是量变。
逆强化学习是从专家演示中反推奖励函数,它依赖高质量示教数据。HER 完全不依赖专家,环境奖励函数也不动,它改的是“目标条件缓冲区内数据的目标标签”。站在经验回放的角度,HER 就像是给每一条失败轨迹贴上了一张新便签:“这条轨迹实际证明了‘把状态从 s 变到 s_T’是可完成的”。这种反事实标注逻辑,才是 HER 真正的核心创新。
2. 核心细节拆解:目标重标注策略怎么选、网络怎么设计
2.1 目标条件强化学习的基本框架
要理解 HER,先要把目标条件 MDP 的符号理清。环境里每个 episode 会采样一个目标 g∈G,通常 G 是状态空间的一个子集或某个特征空间的子集(比如物体位置坐标)。策略 π(a|s, g) 根据当前状态和目标决定动作。每一步,环境根据状态 s、动作 a、新状态 s' 和目标 g 返回奖励 r_g(s, a, s'),最常见的设定是稀疏二值形式:
r_g(s, a, s') = 1,如果 ||φ(s') - g|| < δ;否则为 0(或 -1)。
这里的 φ(s') 是状态到目标特征的映射,δ 是成功判定阈值。在 OpenAI Gym 的 Fetch 系列环境里,这个 φ(·) 就是 achieved_goal,也就是环境中实际达到的目标特征。所以 HER 里其实有一个隐藏前提:我们需要能随时拿到“当前状态对应的目标特征”,也就是 achieved_goal,否则没法重标注。
网络结构上,目标条件策略和价值函数都要把 goal 作为额外输入。早期做法是直接把 (s, g) 拼接起来喂给 MLP,效果基本够用。更复杂的做法是给 goal 单独设计特征提取分支,再和状态特征融合。对大部分任务来说,拼接就够了,真正的关键在于训练数据的分布,而不是网络有多花哨。
2.2 HER 四种重标注策略的对比
论文里给出了四种从轨迹中选择新目标的方式:
| 策略 | 新目标来源 | 优势 | 明显短板 |
|---|---|---|---|
| final | 轨迹终点 s_T | 简单稳定,把“失败轨迹”变成完整成功轨迹 | 目标多样性差,所有轨迹都指向各自的终点,容易让策略只学到“奔向终点” |
| future | 当前步 t 之后,随机选 K 个状态 s_j(j>t)作为目标 | 覆盖轨迹后半段多种中间状态,数据分布更丰富,论文推荐 | 实现稍复杂,K 值需要调参 |
| episode | 从同一条 episode 里随机选一个状态 | 目标分布覆盖整条轨迹 | 会选到“当前步之前”的状态,造成目标在时间上颠倒,因果混乱 |
| random | 从整个回放缓冲区的所有状态中随机选 | 目标最多样 | 与当前轨迹的状态分布差异大,大部分新目标距离过远,学习信号信噪比低 |
从我的实测经验看,final 策略在任务较简单时也能收敛,但训练曲线更抖;random 和 episode 策略在长 horizon 任务里经常直接哑火。future 策略是最稳的,因为它在“时间一致性”和“目标多样性”之间取到了平衡。每个原始转移配 K=4 个重标注转移,是论文里的常用配置,我复现时也基本沿用,没有遇到大问题。
2.3 网络的输入输出与目标编码
目标条件策略常见的设计是:actor 输入是拼接后的 (state, goal),输出动作;critic 输入是拼接后的 (state, goal, action),输出 Q 值。这里有几个容易被忽视的坑。
第一,目标特征必须和状态特征做同样的归一化。FetchPush 环境里,物体坐标和手爪坐标在 0~1 范围还行,但有些环境里角度、速度的量纲差异巨大,直接拼接会导致梯度被大数值维度主导。我的做法是把状态和目标统一 clip 到 [-1, 1],或者做 z-score 标准化。
第二,目标条件网络很容易把 goal“当成噪声”。原因是如果目标分布太散,网络会发现无论 goal 怎么变,Q 值平均一下也能混过去。future 策略之所以强,就是因为它会大量生成“和当前状态非常接近”的目标,迫使网络必须真正利用 goal 信息来区分价值高低,从而学到“距离目标越近 Q 值越高”这个隐含结构。
第三,目标不要只在训练初期变化太大。HER 里重标注目标每次都来自真实轨迹,所以目标的分布是随着训练逐渐演化的——一开始模型只会奔向随手可达的近距离目标,后来慢慢能完成更远的目标,这天然形成了一种自适应的课程学习。如果你把目标分布固定死,反而会破坏这个自举过程。
3. 从零实操:在 FetchPush 上用 DDPG+HER 训练一个机械臂
3.1 环境选择与观察空间解读
OpenAI Gym 的 Fetch 系列是 HER 最常见的验证环境,四个任务分别是 Reach、Push、PickAndPlace、Slide,难度递增。入门我推荐 FetchPush-v1,因为它动作维度和状态维度适中,训练速度快,能很快看到 HER 的效果。
进环境之后观察 space 是一个 dict,包含三个字段:
- observation:机械臂的关节角度、角速度、手爪位置、物体位置、物体相对手爪的位置等
- achieved_goal:当前实际到达的目标特征,在 Push 任务里就是物体的当前二维坐标
- desired_goal:本次 episode 想要到达的目标坐标
HER 重标注时,我们最关心的就是 achieved_goal。它的时序变化就是轨迹的“真实成就记录”,新的目标从这里面挑,新奖励也按新目标重新算。环境每一步会返回是不是成功,内部判定就是用 desired_goal 和 achieved_goal 的距离阈值。
3.2 DDPG+HER 的核心循环代码
我用 PyTorch 复现过一版最简实现,逻辑清晰,适合拿来当骨架改成自己的任务。先定义一个 episode 级别的缓冲区,注意这里和普通经验回放有个关键区别:必须按 episode 存,因为重标注需要用到一整条轨迹的 achieved_goal 序列。
class EpisodeBuffer: def __init__(self, capacity): self.capacity = capacity self.episodes = [] # 每个元素是 dict,包含 states, actions, achieved_goals, desired_goals self.idx = 0 def push(self, ep): if len(self.episodes) < self.capacity: self.episodes.append(ep) else: self.episodes[self.idx] = ep self.idx = (self.idx + 1) % self.capacity def sample_her_transitions(buffer, batch_size, k=4, strategy="future"): transitions = [] for _ in range(batch_size): ep = random.choice(buffer.episodes) t = random.randint(0, ep["states"].shape[0] - 1) # 原始转移先保存 for _ in range(k): if strategy == "future": # 从 t+1 到 episode 末尾之间随机选一个状态索引 j = random.randint(t + 1, ep["states"].shape[0]) new_goal = ep["achieved_goals"][j] elif strategy == "final": new_goal = ep["achieved_goals"][-1] else: raise NotImplementedError reward = compute_sparse_reward(ep["achieved_goals"][t + 1], new_goal) transitions.append({ "state": ep["states"][t], "action": ep["actions"][t], "next_state": ep["states"][t + 1], "goal": new_goal, "reward": reward }) return transitions需要注意,compute_sparse_reward 要基于下一时刻的 achieved_goal 与目标之间的差距:距离小于阈值给正奖励,否则给负奖励。在 Fetch 环境里,这个阈值一般是 0.05。这是一个容易被忽略的点:HER 里重标注并不是只改目标不改奖励,而是目标和奖励都要重算,否则时序就乱了。
训练主循环也不复杂,每收集完一个 episode,就丢进缓冲区,然后每次梯度更新时做一次 HER 采样:
for epoch in range(max_epochs): # 收集一个 episode obs = env.reset() ep_states, ep_actions, ep_achieved = [], [], [] for step in range(episode_len): action = actor.select_action(obs["observation"], obs["desired_goal"]) noise = 0.2 * np.random.randn(*action.shape) next_obs, reward, done, _ = env.step(action + noise) ep_states.append(obs["observation"]) ep_actions.append(action) ep_achieved.append(obs["achieved_goal"]) obs = next_obs if done: break ep_states.append(next_obs["observation"]) ep_achieved.append(next_obs["achieved_goal"]) buffer.push({"states": np.array(ep_states), "actions": np.array(ep_actions), "achieved_goals": np.array(ep_achieved)}) # 更新:每次至少采样一组 HER 转移做 DDPG 更新 transitions = sample_her_transitions(buffer, batch_size=128, k=4, strategy="future") update_ddpg(transitions)这里没有贴完整的 actor/critic 更新代码,但核心就是普通 DDPG 的三件套:critic 回归 TD 误差、actor 最大化 Q 值、目标网络软更新。HER 只是改变了“喂给 DDPG 的数据到底是什么”,算法本体的优化方式不需要大改。
3.3 关键参数配置参考
我复现时用过的配置如下,在 FetchPush-v1 上大约 30~50 万个时间步能看到成功率爬升,如果机器性能好可以更快:
| 参数 | 取值 | 备注 |
|---|---|---|
| 优化器 | Adam | actor 和 critic 都可用,lr 1e-3 |
| 折扣因子 γ | 0.98 | 稀疏奖励下偏小一点更好 |
| 回放缓冲区容量 | 2000 个 episode | 比普通回放池小很多,但按 episode 存 |
| HER 策略 | future, k=4 | 每个原始转移补 4 条重标转移 |
| 批量大小 | 128 | 太大太小的都试过,128 比较稳 |
| 探索噪声 | 高斯噪声,std=0.2 | 训练后期可以降到 0.1 |
| 目标网络更新 | τ=0.05 软更新 | 每步都更新 |
| episode 长度 | 50 | Fetch 环境默认值 |
这里有个经验之谈:HER 对回放缓冲区的大小没那么敏感,但对“按 episode 存储”这件事很敏感。如果你图省事,把转移一条条丢进普通回放池,再从中采样做重标注,会因为转移动态失去轨迹结构,future 策略根本没法实现,final 策略也会变得非常稀碎。所以一定得维护 episode 级的数据结构。
3.4 训练曲线怎么读
刚上手的时候,看到前几千步环境内成功率还是 0,很容易慌。但 HER 有个典型现象:回放缓冲区里的“重标转移”成功率会先涨,因为大量失败轨迹被贴上了成功标签,所以 critic 的 Q 值会先学会“区分接近目标和远离目标”。这时候环境内成功率仍然 0 是完全正常的,critic 正在构建对目标的距离感知能力。
我判断训练是不是真的在推进,通常会看两个额外指标:一是 actor 输出的动作是否开始随目标变化,二是用固定目标做测试时,机械臂末端是否在向目标方向移动(即便没进阈值)。只要这两点出现,说明 HER 的自举机制已经启动了,后面成功率爬坡只是时间问题。
4. 常见问题与排查技巧实录
4.1 训练完全不动,成功率一直为零
这个问题我遇到过不止一次。最典型的检查顺序是:
先确认环境本身在动。打印每一步的 achieved_goal,看手爪和物体坐标是否随时间变化。如果物体根本没动,那可能是随机种子或者动作空间理解错了。再看奖励函数:Fetch 环境里 reward 已经从 step 返回,但有一些封装过的环境会把奖励弄丢,如果你发现 reward 恒等于 0,那问题在环境层,不在算法层。
然后检查重标注是否真的在采样里生效。很多初版实现会有个隐蔽 bug:代码逻辑上重标了,但采样时用的是原始目标而非新目标。最直接的排查方法是在训练循环里打印一条 sample 出来的 (goal, reward),手动算一下:如果 goal 不等于 desired_goal 但 reward 却按新目标算对了,说明重标注生效。也可以临时关闭 HER,把 k 设成 0,看训练是否立刻崩溃——如果崩溃,说明之前能学完全是靠 HER,也侧面说明重标注逻辑是通的。
4.2 future 策略的 k 值和重标注比例怎么调
k=4 是论文的默认值,但不同任务的最佳值不一样。任务越复杂、horizon 越长,k 可以适当增大到 8 或 16,因为需要更多“中间态目标”的样本来铺满轨迹上的学习梯度。但也不是越大越好,k 太大会让缓冲区里同一原始轨迹的重复样本过多,多样性下降,训练变慢。
还有重标注比例的设置。论文里常见做法是每条原始转移都配 k 个重标转移,相当于重标注比例是 k:1。我自己试过“只重标注最后几步”的变体,效果很差,因为中间步骤缺少正样本引导,整个轨迹前面的状态还是“没有信号”的死区。所以一开始就全轨迹重标注,别只在尾部做文章。
4.3 目标与观察空间的归一化陷阱
我能踩这个坑踩得很深。有一次在自定义机械臂环境里用 HER,状态量包含关节角度(弧度)、末端速度(带负号)和物体像素坐标,数值范围从 -3 到 1000 都有。直接拼接进网络之后,前几轮训练 loss 直接变成 NaN。
解决方法很粗暴但有效:收集一批初始状态和目标,计算每个维度的 min、max,然后所有输入统一缩放到 [-1, 1]。注意这里不是简单除以某个数,而是要对 state 和 goal 分开统计,因为它们的范围可能完全不同。另外,如果观测里有“相对位置”和“绝对位置”两种特征,建议只保留一种,否则网络要额外学会做减法才,徒增负担。
4.4 从仿真到真机:目标重标注的方向偏差
仿真环境里你可以拿到完美的全局坐标,但真机上你通常只能拿到视觉估计或者相对位姿。HER 里常见的问题是:把“实际到达的终点状态”直接当目标,可能会学到一些不该学的行为。
举个例子:推物体任务里,如果只看手爪的位置,物体本身的位置变化才是真正的任务结果。如果重标注时把目标定义在手爪坐标上,策略可能学到“手爪随便移到某个位置都算成功”,完全忽略了物体。我自己在仿真里就见过这种走捷径的案例。真机部署时,建议只对“与任务意图强相关的特征”做重标注,比如物体最终位置,而不是把所有观测维度都纳入目标向量。另一个思路是增加约束项,在重标注奖励里加一个边界惩罚,防止策略把目标定义到不可行的区域。
5. 从算法到方法论:hindsight 的启示和延伸用法
5.1 为什么“失败样本转正”能大幅提高数据利用率
HER 的精髓可以这样理解:在普通强化学习里,一条轨迹只有对“预定的那个目标”产生正信号时才具有学习价值;而 HER 通过目标重标注,把同样一条轨迹对多个“反事实目标”同时赋予了学习价值。数据没有变多,但单位数据的有效信息量大幅上升。
打个生活中的比方:项目经理复盘一个失败的项目,如果只盯着“当初的目标没达成”,那这场复盘只剩教训;但如果换个角度,把过程中实际产出的成果当作“已经实现的目标”来归档,这个项目就对“如何做到类似产出”提供了完整的方法论。强化学习里这个类比完全成立——只是项目目标变成了 goal,成果变成了轨迹终点。
5.2 把 HER 思想延伸到离线学习和多任务学习
HER 不只是一种回放算法,更是一种数据构造哲学。在离线强化学习里,如果固定数据集里全是稀疏失败轨迹,直接训练策略几乎学不到东西。你可以用 HER 的方式给离线数据补充目标标签:对每一条转移,从它所属轨迹的未来状态里选一个新目标,重算奖励,再把原始轨迹和重标轨迹都放进训练集。这样离线数据的利用率会好很多。
在多任务和分层强化学习中,HER 里的“终点状态作为子目标”思路也经常用到。高层策略负责在事后从状态空间里挑一个有意义的子目标,底层策略负责完成它——这其实就是把 HER 的 future 重标注从训练技巧升级成了任务分解机制。再配合自动目标生成(比如从历史轨迹中聚类出常见终点),可以让智能体在无人工标注的情况下,自己发现可学会的中间阶段。
5.3 一个项目复盘的小例子
我以前做过一个调度系统改造,引入强化学习之后最大的问题就是“大多数 episode 都以超时失败告终”。一开始我只盯着成功率,整条日志几乎全是负样本,怎么看都觉得没救。后来受 HER 启发,我把失败 episode 里“实际完成的调度次数”当作新的目标重新标注了一部分数据,专门训练模型“如何在限时内完成尽量多的调度”。效果意外得好——模型确实学会了在资源受限时优先完成简单任务,而这个行为在原来的单一目标下是永远学不到的。
当然这种“转正”要谨慎:不是所有失败结果都能被当作有价值的目标。重新标注之后,你要用业务规则再审查一遍,确认这个“被实现的目标”本身是合理、通用、可复用的。如果只是为了凑正样本,把毫无意义的终点状态标成目标,那就是给模型灌输错觉,反而伤性能。
我实际做 HER 项目时,最直观的触动是第一次看到机械臂在稀疏奖励下,从“完全没希望”到突然出现向目标靠近的雏形,那个临界点往往来得比想象中快。所以我现在每做一个新的 RL 任务,都会先问自己一句:如果这个行为失败了,它实际完成了哪个有价值的目标?能回答清楚这个问题的环境,无论奖励多稀疏,都值得先试试 HER。等这条路跑通了,再往离线学习、分层决策甚至自动目标生成方向延伸,都是顺水推舟的事。