做强化学习的人,第一次看到 hindsight 这个词,大概率是从 OpenAI 那篇《Hindsight Experience Replay》的论文标题里。但它在英文世界里其实是个特别日常的词——事后聪明,马后炮。英语里有句老话叫 hindsight is 20/20,翻译过来就是“事后看谁都是千里眼”。
我最初被这个词击中,不是在读自然语言的时候,而是在实验室里折腾稀疏奖励任务被虐到怀疑人生的那段时间。机器人从零开始抓取一个物体,随机晃了几万个 step,得到的奖励始终是同一个负数,策略网路的梯度几乎没有任何有用方向。后来我把 HER 接进 experience replay,事情才开始变得可控。这篇文章就想把 hindsight 在强化学习里的这层“后见之明”聊透:它为什么能成,代码上怎么落地,以及我在实操中踩过哪些坑。
这篇东西适合谁看?如果你正在做机器人控制、多目标强化学习,或者被稀疏奖励问题折磨到头秃,那它至少能帮你少走几个月的弯路。就算你只是刚接触强化学习的小白,只要懂一点 Python 和基础的 DQN/SAC,也能跟着把 HER 的核心逻辑完整复现出来。
1. 整体设计拆解:HER 凭什么把失败变成经验
1.1 “目标条件策略”这个前提
要理解 HER,你得先接受一个前提:这里的强化学习任务不是“单目标”,而是“多目标条件任务”。策略输入不再只是状态 s,而是状态加上目标 g,写成 π(a|s, g)。奖励函数也带目标,写作 r(s, a, g)。
举个例子会直观很多。你在厨房做饭,桌上有鱼、青菜、鸡蛋。如果目标是“蒸鱼”,你看到鱼会去拿蒸锅;如果目标是“炒蛋”,你就会去拿碗和筷子。同一个状态,不同目标,最优动作完全不同。这种设定在机器人领域特别常见,比如机械臂要把物体推到指定位置,目标位置每次可能是不同的点。
目标条件策略最大的难点在于:你不仅要学会“从状态出发做动作”,还要学会“在不同目标之间泛化”。HER 的思路并不是直接去解这个难题,而是让训练数据本身变得更聪明一点。
1.2 稀疏奖励到底难在哪
常规强化学习依赖奖励信号做梯度回传。可一旦任务给的奖励非常稀疏,比如“只有达成最终目标才给 +1,其余每一步都是 0 或 -1”,那么大多数 transition 的奖励信息几乎为零。
我做过一个非常简单的 8x8 网格实验,起点在 (0,0),目标在 (7,7),动作是上下左右挪一格,每步有步数上限。用随机策略跑 50 步,能踩中目标格子的概率非常低,大约只有百分之几。这意味着什么?你用 DQN 去训,大部分 batch 里的 target 全都一样,Q 值根本学不到任何有区分度的东西。训练曲线从头到尾像一根平线,不是代码 bug,而是奖励稀疏得离谱。
这种场景下,传统的 reward shaping 可以缓解,但需要人来设计中间奖励,而且设计得不好会引入局部最优。HER 不想改奖励函数,它想改的是“数据怎么标”。
1.3 从“事后聪明”到算法设计
人类天生就有事后聪明这个毛病。考试砸了,你复盘的时候会说“我要是当时多看一眼那道题就好了”;项目上线出 bug,你也会说“我早知道应该先做压测”。但在强化学习里,这种反事实思维被 HER 变成了正经的算法工具。
核心逻辑一句话:一条没达到目标 g 的失败轨迹,如果换个角度看,它其实成功到达了某个后续状态。那这个“后续状态”就可以被视为一个替代目标 g',然后我们重新计算奖励,把这条原本毫无学习价值的轨迹,变成一条有正反馈的训练样本。
这就像教练看球员投篮录像,一个球没投进,但如果把篮筐自动移到球实际落点的位置,那这次出手动作其实是“标准”的。球员通过大量这样的“虚拟成功”经验,提高了对自身动作的控制力。 HER 的设计思路就是这样,它不改变环境动力学,只改变经验池里样本的“身份说明”。
2. 核心细节解析:目标重标注里的关键参数与公式
2.1 HER 的算法骨架
标准 HER 流程可以拆成四步。
第一步,用当前策略在某个目标 g 下跑完一条完整轨迹,存下每一步的 (s_t, a_t, r_t, s_{t+1}),以及这一局的目标 g。
第二步,把这条轨迹里的原始转换全部写入 replay buffer。注意,原始样本一定不能丢。HER 是在原始经验基础上做加法,不是拿替代样本替代原始样本。
第三步,从这条轨迹的“未来状态集合” {s_{t+1}, ..., s_T} 里,随机挑出 k 个状态。每个状态通过映射函数 φ 转换成替代目标 g'。这个 φ 取决于任务,比如在推箱子任务里,就是提取物体的位置坐标。
第四步,对每个替代目标 g',重新计算奖励:
r' = 0,如果 φ(s_{t+1}) 与 g' 的距离小于阈值 τ;否则 r' = -1。
然后把重标注后的样本 (s_t, a_t, r', s_{t+1}, g') 写进 buffer。
为什么这里几乎总是选“未来状态”而不是过去状态?因果一致性。当前动作只能影响之后的状态,不能影响之前的状态。你把一个已经发生过的过去状态当成目标来反推当前动作,相当于让策略为一个它根本推动不了的结果负责,这是逻辑悖论。而未来状态确实可以被后续动作逐步达到,所以“目标可达”在动力学上是成立的。
2.2 奖励选择与目标函数设计
稀疏奖励的常见姿势有两种,一种是“成功给 +1,失败给 0”,另一种是“成功给 0,失败给 -1”。两种都可以,但在 HER 里我更建议用后者,也就是每走一步都惩罚,成功终止时给 0。原因很简单:在步数有限的任务里,-1 惩罚会隐式地引导策略找最短路径,而 +1/0 的组合会让策略对“早点结束”不敏感。虽然这个差别在奖励足够稠密时才明显,但既然本来就缺奖励,为什么不把步数压力也塞进信号里?
这里有一个特别微妙的地方,也是新手容易忽略的:HER 重标注后,一条失败轨迹的尾部一定存在非零奖励样本。假设替代目标取到了轨迹最终状态 g' = φ(s_T),那么对最后一步转换 (s_{T-1}, a_{T-1}, s_T) 来说,φ(s_T) 和 g' 完全相等,奖励立刻变成 0。这个“天然存在的正样本”是整个算法能启动的火种。
实际实现里,替代目标不一定只取最终状态。OpenAI 论文里最常用的策略叫 future,即从当前转换之后的所有时间步里均匀采样 k 个状态作为目标。另一种是 final,只取最终状态。final 更保守,适合目标特别难达成、未来状态多样性过高的场景;future 更激进,能带来更多样化的训练信号,也是我实测下来最稳的方案。
2.3 关键超参数和那些没人写的坑
第一个超参数是 k,每个转换额外生成几个替代目标。OpenAI 的默认值是 4。k 太小,信号密度不够;k 太大,buffer 里重标样本太多,原始目标会被稀释,而且存储和训练开销都往上走。我自己的习惯是先设 4,等成功率爬到五成以上再试 8,观察曲线是否更稳,而不是一上来就堆数量。
第二个容易被低估的是目标判定阈值 τ。很多任务里判断“是否到达目标”用的是欧氏距离小于阈值,τ 大小的利害关系很大。τ 太大,明明离目标还有十万八千里也算成功,网络学到的策略会很糙;τ 太小,成功样本几乎不存在,HER 和白写没区别。建议从任务物理尺寸出发,比如机械臂抓取任务,我常用 0.02 到 0.05 米。网格世界反而好办,压到同格距离即可。
第三个坑跟状态表示有关。重标注后的替代目标是某个“状态”,但这个状态很可能包含速度、姿态等额外信息,不能直接当 goal 用。你需要一个 φ 函数,把原始状态映射到“目标空间”。比如状态里有机器人位置、物体位置、物体速度,目标空间通常只取物体位置。如果忘记做映射,直接把整个状态塞进 goal,网络要记住的东西会爆炸,训练大概率发散。
3. 实操过程与核心环节实现:一份可复现的 HER 代码
3.1 环境设计:最小可跑的网格推方块
为了把 HER 的机制讲明白,我建议你自己动手跑一个 8x8 的网格环境。起点固定 (0,0),目标固定在对角 (7,7),动作就是上下左右,撞边界不动,每步上限 50。
状态 obs:agent 的 (x, y) 目标 goal:目标格子的 (x, y) 每步奖励:0(到达目标)或 -1(没到)代码可以这样写:
import numpy as np class GridEnv: def __init__(self, size=8, max_steps=50): self.size = size self.max_steps = max_steps self.goal = (size - 1, size - 1) self.agent = (0, 0) self.steps = 0 def reset(self): self.agent = (0, 0) self.steps = 0 return np.array(self.agent, dtype=np.float32), np.array(self.goal, dtype=np.float32) def step(self, action): x, y = self.agent if action == 0: x = min(x + 1, self.size - 1) elif action == 1: x = max(x - 1, 0) elif action == 2: y = min(y + 1, self.size - 1) else: y = max(y - 1, 0) self.agent = (x, y) self.steps += 1 reached = (x, y) == self.goal reward = 0.0 if reached else -1.0 done = reached or self.steps >= self.max_steps return np.array(self.agent, dtype=np.float32), reward, done, {}环境本身非常简单,但完全能复现稀疏奖励的困境。你会看到不带 HER 的 DQN 训练过程像睡着了一样,成功率长时间在低位徘徊。
3.2 把 HER 写进经验回放:核心代码
真正关键的是经验池这边的重标注逻辑。我先把 HER 重标注函数单独拎出来,它接收一个 episode 的所有转换,返回原始样本和重标样本:
import numpy as np from collections import deque import random class ReplayBuffer: def __init__(self, capacity): self.buffer = deque(maxlen=capacity) def add(self, obs, action, reward, next_obs, goal): self.buffer.append((obs, action, reward, next_obs, goal)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) return batch def __len__(self): return len(self.buffer) def her_relabel(episode_trans, k=4, tau=0.5): """ episode_trans: list of (obs, action, reward, next_obs, goal) obs 和 goal 都是 (x, y) 的 numpy 数组 """ states = [t[0] for t in episode_trans] + [episode_trans[-1][3]] extra = [] for t_idx, (obs, action, reward, next_obs, goal) in enumerate(episode_trans): future_idx = list(range(t_idx + 1, len(states))) if not future_idx: continue chosen = np.random.choice( future_idx, size=min(k, len(future_idx)), replace=False ) for idx in chosen: alt_goal = states[idx] alt_reward = 0.0 if np.linalg.norm(next_obs - alt_goal) < tau else -1.0 extra.append((obs, action, alt_reward, next_obs, alt_goal)) return extra这里要解释几个我在实际开发里觉得特别容易错的地方。
states 数组的长度是 episode_trans 长度加一,因为要把每一步的 obs 都存下来,最后还要补一个最终 next_obs。如果你忘了补最后一帧,那么对最后一步转换做 HER 时,future_idx 会是空的,尾部天然正样本就丢了。
alternate goal 取自 states[idx],但奖励是拿 next_obs 和 alt_goal 比的。这里 next_obs 是 t_idx 这一步动作执行后的状态,idx 一定大于 t_idx,保证我们是在拿“未来的某个状态”当目标。有的刚上手的同学会手滑写成拿 obs 去和 alt_goal 比,那就完全变了味道,因为还没执行当前动作你当然到不了未来状态,所有样本立刻全部变成 -1。
接下来是训练主循环的示意。为了控制篇幅,我只写跟 HER 相关的核心部分:
buffer = ReplayBuffer(capacity=100000) env = GridEnv() policy = DQNPolicy(state_dim=4, action_dim=4) for episode in range(4000): obs, goal = env.reset() episode_trans = [] done = False while not done: # 这里把 obs 和 goal 拼接后输入网络 s = np.concatenate([obs, goal]) action = policy.select_action(s, epsilon=0.3) next_obs, reward, done, _ = env.step(action) episode_trans.append((obs, action, reward, next_obs, goal)) obs = next_obs # 原始样本先进 buffer for trans in episode_trans: buffer.add(*trans) # HER 重标样本进 buffer for trans in her_relabel(episode_trans, k=4, tau=0.5): buffer.add(*trans) # 每 10 个 episode 更新一次网络 if episode % 10 == 0 and len(buffer) > 256: batch = buffer.sample(256) losses = [] for obs, action, reward, next_obs, goal in batch: s = np.concatenate([obs, goal]) ns = np.concatenate([next_obs, goal]) target = reward + 0.98 * policy.target_q(ns).max() current = policy.q(s)[action] losses.append((current - target) ** 2) policy.update(np.mean(losses))注意一个细节:重标样本里的 goal 是 alt_goal,但 obs 和 next_obs 还是原来的。网络输入要把 obs 和 goal 拼在一起,因此重标样本天然地改变了网络的输入标签。如果你用的算法是 DDPG 或 SAC,同样要把目标拼进 actor 和 critic 的输入,而不是只在奖励层面做文章。
3.3 训练记录与调参观察
我在这个网格环境里跑过一组对比,记录比较稳定。无 HER 的 DQN 跑到 4000 个 episode,成功率在 3% 以下,基本是随机水平。上了 HER 之后,前 500 个 episode 同样看不出动静,但从 1000 个 episode 左右开始,训练曲线上开始出现明显的抬头;2500 个 episode 时成功率大概到了 45%,4000 个 episode 时能稳定在 78% 左右。
k 值的差异也很明显。k=8 时成功率爬升更快,1500 个 episode 就到了一个肉眼可见的高位,但中后期波动更大;k=4 更稳,只是前期稍微慢一点。另一个让我印象深刻的实验是把 τ 从 0.5 调小到 0.1,结果整个训练直接退化。原因不复杂,网格 8x8,坐标差 0.1 意味着两个格子几乎必须完全重合才算成功,原本能在轨迹尾部产生的正样本大幅减少,HER 也就失去了动力源。
我建议你动手跑的时候,先打印每一批 buffer 里“奖励为 0”的样本占比。这个数字在 HER 加持下会明显高于普通经验池。如果它始终低得可怜,先别调网络结构,回头检查 τ 和替代目标采样逻辑。
4. 常见问题与排查技巧实录:你的 HER 为什么没效果
4.1 问题速查表
我把实操中遇到的典型问题整理成了一个表格,解决问题的时候可以按图索骥。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 加了 HER 后成功率反而下降 | 目标没有拼进网络输入,或者只在 reward 里改目标,状态里看不到 goal | 检查 actor/critic 的输入是否都是 obs + goal |
| 训练初期 loss 剧烈震荡 | k 过大,替代目标分布太杂,梯度方向互相打架 | 先降 k 到 2 或 4,等策略有一定基础后再加大 |
| 重标样本的奖励几乎全为 -1 | τ 太严,成功判定几乎不可能满足 | 把 τ 调到任务物理尺寸对应的合理范围 |
| 正样本有了,但策略不泛化到新目标 | 训练中目标太单一,或者替代目标集中在最容易到达的状态 | 混入更多原始目标样本,或引入目标课程学习 |
| episode 很短时 HER 报错 | 未来索引为空 | 在 t_idx+1 超过 states 长度时跳过 |
| 训练曲线一段时间后突然崩掉 | buffer 中重标样本比例过高,原始目标被淹没 | 限制每个 episode 额外生成的 HER 样本数量 |
4.2 最容易出错的三个细节
第一个坑是把 HER 用在非目标条件的策略上。HER 要求策略输入里有目标 g,多目标环境里才成立。如果你在普通单目标任务里硬套 HER,替代目标没有任何载体,等于往经验池里塞了大量互不兼容的数据。判断方法很简单:看网络输入里有没有 goal,没有就别用。
第二个坑是关于替代目标索引错位。很多实现里会把“是否达到目标”写成 distance(next_obs, goal) < tau,但在 HER 样本里,goal 是未来某个状态,next_obs 是当前转换的下一个状态。如果代码里误把未来状态取成了 states[t_idx-1] 或者 states[t_idx],然后和 next_obs 比较,某些情况下也能碰出正样本,但位置错乱的样本会让策略学习到错误的因果关联,训练过程会变得极其不稳定。我的经验是,加一句断言:
assert idx > t_idx一旦 future 索引取到过去时间步,直接报错。这个小断言帮我省了很多调试时间。
第三个坑是 buffer 里原始样本和重标样本的比例。OpenAI 的实现会把原始样本全部保留,再额外塞入 HER 样本。我不建议为了扩大 HER 的效果把原始样本丢一半,那会让策略的注意力偏离真实目标分布。我试过一次仅保存 HER 样本的精简版,训练前期还行,后期对真实目标的成功率反而不如混合版。原因很直白,混合经验池相当于既让学生做“低难度但贴近失败”的修正题,又不让他忘掉考试原题。
5. 个人实操经验与可以继续扩展的方向
5.1 我的几条实测体会
HER 不是一个万能开关。它对“状态本身可以当目标”的任务效果极好,比如推动物体、到达位置、倒水高度这一类。但如果你要优化的目标是一个抽象评价指标,比如“报告写得好不好”“视频画面美不美”,你很难从状态里抽出一个物理量来当替代目标,这时候 HER 就不太好使。
另外,HER 跟 off-policy 算法是天作之合,DDPG、SAC、DQN 都能直接接。但如果你的主力算法是 PPO 这类 on-policy 方法,原生 HER 的“事后重标样本会破坏同策略分布”,我个人的建议是先换成 off-policy 的框架,或者至少在收集轨迹时保留更多同策略轨迹,再在 buffer 里加 HER 样本。
真实机器人部署是另一个让人头秃的环节。模拟器里 HER 重标样本的“实际状态”是完美的,真实系统总归有观测噪声和执行误差,替代目标可能并不像模拟器那样精确可达。我一般会在 sim2real 之后加一小段真实环境微调,并且降低 k 值,避免大量带噪声的未来状态把策略带偏。
5.2 可以继续玩的方向
HER 之后,有不少工作围绕“怎么选择更好的替代目标”展开。你可以不用均匀采样未来状态,而是学一个目标生成模型,生成那些当前策略“差一点就能完成”的目标,这种 Hindsight Goal Generation 思路在难度递增场景里表现更好。
也可以把它和课程学习结合起来。先让策略在“容易达成的状态”上猛刷 HER,等 Q 值估得差不多了,再逐步解锁更远的目标。实际操作时,我会把目标的生成范围从近到远分配给不同训练阶段,效果比一次全部铺开稳定很多。
如果你做的是机器人操作,还有一个进阶玩法:把 HER 和阶段化策略结合。比如机械臂推物块,光有 HER 可能只学会推一次,但加上“先移动到位,再推,再归位”的层次化目标分解,替代目标可以直接取子目标的完成状态,训练会顺很多。
最后分享一个我保存已久的调试技巧:每训练几百个 episode,把经验池里所有样本的目标画成散点图。你大概率会看到替代目标集中分布在一条轨迹附近,这是正常的;但如果散点图上几乎所有点都堆在状态的起点区域,说明策略已经退化到只在原地打转了。这时候别急着调网络,先确认是不是随机探索的噪声设置太小,或者 τ 判定得过于苛刻,把策略逼成了“宁可不做也不错”的状态。HER 的本意是让算法学会从失败里总结经验,但前提是它还能大胆地失败。