做强化学习的朋友,心里多半都有同一个痛处:辛辛苦苦写好的环境,网络结构也算合理,训练起来却跟石头一样纹丝不动。尤其是机械臂抓取、机器人导航这类任务,环境几乎不给你任何中间反馈,大部分时间你拿到的奖励都是零,模型根本不知道往哪个方向走才算“变好”。这个问题在业内有个专门的名字,叫稀疏奖励问题。我自己调过大量稀疏奖励环境,最服气的一套解法,就是把 hindsight(后见之明)变成算法的一部分,具体技术叫 Hindsight Experience Replay,简称 HER。这篇博文不打算讲浮在表面的概念,而是把 HER 想解决什么问题、核心机制怎么运作、代码怎么落到你自己的项目里,以及我踩过的那些坑,一次性讲透。不管你刚接触强化学习,还是已经在调各种 off-policy 算法,这篇文章都值得你看完。
1. 为什么需要 Hindsight:稀疏奖励是强化学习的头号难题
1.1 什么是稀疏奖励:奖励函数里的“真空地带”
先搞清楚稀疏奖励到底指什么。在一个强化学习环境里,奖励函数定义了每个状态转移能拿到多少即时信号。以机械臂抓取任务为例,环境给机械臂一个目标位置,让它把积木推到那个位置。奖励只在推到位的那一刻变成 1,其余时候不管机械臂离目标多近,全部是 0。这就是典型的稀疏奖励环境,整个时间序列里只有极少数状态能拿到非零反馈。
传统稠密奖励环境下,智能体每走一步都能拿到一个梯度信号,哪怕策略很差,网络也能从连续的小误差里学会“哪个方向更好”。稀疏环境完全相反,随机探索几百条轨迹,可能连一条成功的都没有,所有样本回报都是 0,网络在反向传播时看不到任何信息,自然什么都学不到。智能体不是笨,而是缺乏一盏“灯塔”告诉它应该靠近哪个方向。
生活化的类比是,你在一个巨大迷宫里找唯一出口。只有站在出口时裁判才告诉你“对了”,其余所有拐弯、折返都完全没有反馈。你只能随机乱走,而随机撞到出口的概率低到可以忽略,于是永远学不到任何有用的走法。强化学习里的随机探索,跟迷宫处境几乎一样。
1.2 为什么传统手段很难救场
面对稀疏奖励,第一反应通常是设计更精细的奖励函数,也就是 reward shaping。比如机械臂靠近目标一点点就给个小分,或者手写势场引导它。这种做法看起来立竿见影,却经常搬起石头砸自己的脚:奖励设计得不够准确时,智能体会钻漏洞,用完全不符合任务意图的方式刷分。学界有个很形象的说法,叫 reward hacking。我见过不少项目,在 shaping 中加入“靠近目标加分”之后,机械臂学会了把积木甩到一边借势晃动来获取分数,离真正的目标越来越远。
另一种常见手段是课程学习,先把简单版本喂给智能体,逐步增加难度。课程学习本身有用,但难点在于很难自动判断每个学生的能力边界。节奏太快,模型跟不上;节奏太慢,浪费时间。模仿学习也能缓解探索问题,但它需要额外的专家示范数据,在很多真实场景里根本拿不到足够质量的演示。
这些手段不是不好,而是都额外引入了人工先验或外部数据。HER 的思路完全不同,它不改变环境,不引入额外数据,只改动一个环节:对已经发生但失败的轨迹,重新赋予另外一套“目标”。这种“事后重新注释”的做法,让它成了一个更干净、更通用、也更好落地的解法。
1.3 HER 恰好解决什么
HER 的全称是 Hindsight Experience Replay,2018 年由 OpenAI 团队提出,实验环境直接选了 Fetch 系列机械臂操作任务,效果很直观:在没有 reward shaping、没有课程学习的情况下,HER 搭配 DDPG 就能在这些稀疏奖励环境里训练出接近 100% 的成功率。这组实验在强化学习社区里引起了不小的震动,也让 HER 成了随后几年机器人操作任务的标配技巧。
不过需要说清楚一个边界:HER 并不是适用于所有稀疏奖励任务的万金油。它解决问题的核心前提,是任务具备“多目标属性”。也就是说,目标空间足够丰富,一条失败轨迹也能被看成“对某个其他目标来说成功”的轨迹。如果任务只有一个固定最终目标,状态空间又无法被提炼成目标形式,HER 就不那么直接。这也是我在后面章节里反复强调的选型判断标准。
2. Hindsight 核心思路:把“失败”重写成“经验”
2.1 后见之明:从失败轨迹里挖掘有效信息
HER 这个名字本身已经暗示了它的哲学:hindsight 的意思是后见之明。英文里有句谚语叫 hindsight is 20/20,意思是事情发生后回头看,一切变得清晰。放到强化学习里,意思是当一条轨迹已经结束,回看它的每一步,即便没有达成最初设定的目标,但它总归达成了某一种状态。这个“已经达成的状态”,完全可以当作这次探索获得的知识。
还是拿机械臂推积木举例。这一集给的目标是位置 A,机械臂最终把积木推到了位置 B,跟 A 差很远。按照标准经验回放逻辑,这条轨迹里的每个 transition 都有一个由原始目标算出来的零奖励,没有任何学习价值,于是被丢掉。但后见之明告诉我们:虽然没推到 A,但它确实把积木推到了 B。如果把这整条轨迹的目标改成 B,最后一步的奖励就不再是 0,而是 1。原本毫无价值的样本,瞬间变成一条“如何到达 B 位置”的高质量演示。
这就是目标重标注(goal relabeling)的核心操作。它做的不是修改现实,而是修改这条经验的目的地,让智能体从已经发生的失败里提炼“它到底擅长干什么”的信息。从数据利用效率来看,相当于不增加任何新样本的前提下,把一条失败轨迹变成多条对辅助目标有意义的学习轨迹。
2.2 目标重标注的数学直觉
在 goal-conditioned 强化学习框架里,通常用 (s, a, r, s', g) 表示一条 transition,其中 g 是当前 episode 设定的目标。HER 做的事情很直接:在 episode 结束后,采样一个重标目标 g',通常是轨迹最终状态或未来某一步的状态,然后重新调用环境的 reward function 计算 r' = R(s', g'),再把修正后的 transition (s, a, r', s', g') 放入 replay buffer。
这个过程从目标条件策略梯度的角度来看,HER 通过目标重标注人为提高了正样本的比例。它并不是在重新定义任务,而是在扩大有效样本的覆盖面。原本只有目标 A 的那一条轨迹拿非零奖励,重标之后,轨迹中靠近 B 的状态转移都获得了正反馈,等于把稀疏奖励投影成了一系列局部密集的反馈。
听起来有点像“自欺欺人”,但它恰恰是人类学习的重要机制:失败的经历不被复盘就只是噪音,被复盘之后才变成可迁移的经验。HER 就是希望用重标注机制自动化完成这种复盘。对算法而言,它的好处是不需要额外模型,不需要改环境,不需要手工定义中间子目标,所有重标目标都直接从采样轨迹里生成。
2.3 为什么能加快学习
HER 之所以能更快收敛,一方面是因为它显著缓解了探索信号缺失的问题,负样本的压力下降;另一方面,目标条件策略本身把“不同目标”的信息压进同一个模型,类似 multi-task learning。每一条轨迹能同时给多个目标提供监督,样本利用率成倍增长。
我试过在 FetchPush 环境里做对比实验:同一个 DDPG,只增加一层 HER 的重标注,训练曲线完全不一样。不用 HER 时,成功率在五千个 episode 内基本贴地飞行;用 HER 且 goal 空间是 4 维坐标的情况下,成功率大概三千个 episode 左右就开始抬头,最后稳定在 90% 以上。这不是玄学,而是数据效率上的数量级优势。对工程团队来说,这可能意味着本来要跑一个星期的实验,压缩到两三天。
3. HER 的算法细节与关键参数
3.1 四种重标注策略对比
实际使用 HER 时,重标目标的采样方式会极大影响效果。OpenAI 论文里给出了几种主流选项:final、future、episode 和 random。final 策略最简单,用整个 episode 的最终状态 s_T 作为所有 transition 的新目标。future 策略是在每条 transition 所在时刻 t 之后,随机挑一个未来时刻的状态作为新目标。episode 策略是从整条 episode 里随机挑一个状态。random 策略完全随机从状态空间里采一个状态做目标,通常只作为对照组。
初看这些策略差别不大,实测下来,最适合大多数机器人操作任务的是 future 策略。原因很直观:如果一个目标状态本来就是轨迹里未来几步的状态,那么从当前 s 到目标的轨迹很短,状态和目标之间的关联性更强;如果用 episode 很靠前的状态作为目标,当前状态离目标可能非常远,智能体很难合理预测,学习信号噪声变大。
| 策略 | 采样方式 | 优点 | 缺点 |
|---|---|---|---|
| final | 每集最终状态 | 简单稳定,容易实现 | 整个 episode 共用一个目标,信息重复 |
| future | 当前 transition 之后的某个状态 | 目标与当前接近,监督信号直接 | future 窗口太大会退化 |
| episode | 同集随机状态 | 覆盖范围广 | 距离可能过远,噪声大 |
| random | 随机状态 | 几乎无偏 | 多数目标与轨迹无关,学习效率低 |
实际实现中,future 窗口参数往往取 k=4 或者 k=8。我的习惯是先用 final 跑通流程,再用 future 调整性能。这个领域没有一劳永逸的最优解,必须结合任务的时序长度和状态空间维度做实验。尽快把对比实验做起来,用曲线说话,而不是靠感觉选参数。
3.2 重标数量 k 怎么选
HER 论文里一个关键操作是:每个 transition,除了它原始目标对应的样本之外,还要额外生成 k 个重标样本。这个 k 控制着 buffer 中辅助目标样本的比例。k 太小,HER 的优势不明显;k 太大,原始目标的信息会被稀释,智能体反而可能被“改判成功”的目标带偏。
我常用的默认值是 k=4,也就是每个 transition 写 1 份原始样本和 4 份重标样本进 buffer。目标空间维度较低(比如 2 到 4 维)时,k=4 通常表现不错;目标空间维度高一些,比如 8 维以上,我会把 k 提到 8,让更多辅助目标参与监督。还有的时候成功率后期上不去,问题可能就出在 k 太大,原始目标样本严重不足。可以观察 loss 曲线,actor loss 震荡非常剧烈,往往是重标比例过高造成的。
还有个容易被忽略的细节:重标时要尽量避免重复样本。如果 future 策略采到的状态和原始目标状态过于接近,这条重标样本的信息增量就很小。所以工程实现里,我会在采样重标目标时过滤掉与原始目标距离过近的状态,保证喂给模型的是真实可区分的不同任务。
3.3 结合 off-policy 算法的网络与权衡
HER 的一个重要限制是它只适用于 off-policy 算法。原因在于,重标后的经验会改变 buffer 里的奖励分布,on-policy 算法(比如 PPO)要求经验必须来自当前策略,回放旧轨迹本身就违背了它的前提。所以 HER 一般与 DDPG、TD3、SAC 或 DQN 家族搭配使用。
实现上,网络结构至少要支持 goal 作为额外输入。以 DDPG 为例,actor 网络输入是当前状态 s 和目标 g,输出动作 a;critic 网络输入是 s、a、g,输出 Q 值。这里的 goal 可以是一个子集状态,也可以是一个单独编码的向量。很多时候 goal 空间和状态空间单位不同,比如状态是关节角度,goal 是末端坐标,此时建议分别归一化,而不是直接拼接了事。
另一个实际问题是,目标条件的 reward function 必须与环境的真实 reward 保持一致。HER 重标时只是替换 goal,然后调用同一个 reward function,如果这个函数内部有随机成分或者依赖额外隐藏变量,重标结果可能不准确。所以我在做 HER 前,都会先写一个可复用的 compute_reward(goal, achieved) 函数,保证环境内部和 buffer 重标走同一条计算路径,避免出现两套奖励逻辑打架。
4. 实操实现:在 DDPG 上接入 HER
4.1 伪代码逻辑梳理
HER 接入看起来简单,但细节很容易让代码写得又臭又乱。先给一个清晰的整体框架:
buffer = ReplayBuffer() for episode in range(total_episodes): g = sample_goal() # 采样一个真实目标 s = env.reset() episode_trans = [] while not done: a = actor(s, g) + exploration_noise s_next, r, done, info = env.step(a) episode_trans.append((s, a, r, s_next, g)) s = s_next # 重标阶段 for t, (s, a, r, s_next, g) in enumerate(episode_trans): buffer.add(s, a, compute_reward(s_next, g), s_next, g) for _ in range(k): g_prime = sample_future_goal(episode_trans, t) r_prime = compute_reward(s_next, g_prime) buffer.add(s, a, r_prime, s_next, g_prime)这份伪代码有几个核心点。第一,所有 transitions 必须等 episode 结束后才能处理,因为 future 策略需要知道未来状态;第二,原始样本和重标样本可以放进同一 buffer,DDPG 采样时完全不需要区分来源;第三,compute_reward 保持一致。把这些点写清楚,后面调参才不会像无头苍蝇。
4.2 关键代码示例
我提供一个可运行的最小 HER 重标模块,方便直接嵌入训练脚本。
import numpy as np class HERBuffer: def __init__(self, capacity, k=4, strategy='future'): self.capacity = capacity self.k = k self.strategy = strategy self.data = [] def add_episode(self, transitions): # transitions: list of (s, a, r, s_next, g) n = len(transitions) for t, exp in enumerate(transitions): s, a, r, s_next, g = exp self.data.append((s, a, r, s_next, g)) # 额外 k 个重标样本 for _ in range(self.k): if self.strategy == 'future': # 从 t 之后的时刻随机采样 future_idx = np.random.randint(t, n) g_prime = transitions[future_idx][3] # s_next as goal elif self.strategy == 'final': g_prime = transitions[-1][3] else: idx = np.random.randint(0, n) g_prime = transitions[idx][3] r_prime = self.compute_reward(s_next, g_prime) self.data.append((s, a, r_prime, s_next, g_prime)) # 裁剪超容量 if len(self.data) > self.capacity: self.data = self.data[-self.capacity:] def sample(self, batch_size): idx = np.random.choice(len(self.data), batch_size, replace=False) batch = [self.data[i] for i in idx] return map(np.array, zip(*batch))这只是重标缓冲区的核心框架,实际项目里还需要处理状态归一化、目标是否使用观测子集等细节。但把 buffer 的职责和算法主体分开,训练脚本会清爽很多。
这里有个工程心态的建议:不要一上来就在复杂环境里写完整 HER,先用一个 toy 环境,比如 OpenAI Gym 的 FetchReach 或者 2D 导航任务,把自己写的 HER 模块跑通,再把代码迁移到主项目。很多同学直接在大环境上调试,一旦不收敛就分不清是环境问题还是算法问题。把最小可用链路打通,后续调参才谈得上有方向。
4.3 实验配置与调参经验
以 FetchPush 为例,我习惯用这样一组起始配置:
| 超参数 | 取值 |
|---|---|
| actor learning rate | 1e-3 |
| critic learning rate | 1e-3 |
| gamma | 0.98 |
| exploration noise | 0.2(高斯,随时间衰减) |
| target noise | 0.05 |
| batch size | 256 |
| replay buffer size | 1e6 |
| HER k | 4 |
| future 窗口 | 8 |
这组配置不是最优,而是稳妥。实际调参时,优先级最高的是探索噪声和 k 值。假如训练两千个 episode 成功率纹丝不动,先看噪声是不是太小,导致决策过于确定、探索不足;其次看重标目标是不是采样得太随意。噪声太大又会造成训练后期波动过大,所以我会给探索噪声设一个衰减系数,比如每 1000 episode 乘以 0.95,最后维持在 0.05 附近。
另外提一下并行数据采集。HER 本身很吃数据多样性,单环境跑比较慢。工程上我常用 8 到 16 个并行环境同时采集 episode,把数据汇聚到同一个 buffer。这样一来,一次训练能拿到的失败轨迹形态更多,辅助目标也更丰富,收敛速度提升相当明显。Fetch 系列环境单步执行稍慢,并行是最划算的加速手段。
5. 常见问题与避坑指南
5.1 问题速查表
把我在 HER 项目里遇到的高频问题整理成了一张表,遇到相似症状可以直接对照排查。
| 现象 | 可能原因 | 排查与处理 |
|---|---|---|
| 训练 2000+ episode 成功率仍为 0 | 探索噪声太小 / k 值偏小 | 调大噪声幅度,k 调到 8,并调长未来窗口 |
| reward 上升后又快速崩塌 | 学习率偏高 / target 更新过快 | 学习率降到 3e-4,加入 target soft update 平滑系数 |
| Q 值振荡明显 | 重标样本比例过高 | 减少 k 值,让原始目标样本占比回升 |
| 同一条轨迹塞满 buffer,多样性差 | 采集环境过少 / 策略太单一 | 并行采集,或在 buffer 中按 episode 去重 |
| 目标空间和状态空间量纲差异大 | 直接拼接导致网络难以拟合 | 分开归一化,或用 embedding 编码目标 |
表格里的每一项,我都实打实踩过至少一次。第一次在 FetchPickAndPlace 上跑 HER,成功率一直卡在 20% 上下浮动,最后发现是重标时 future 窗口取到了整个 episode 的末尾,导致目标经常离当前状态极远,模型学得糊里糊涂。把窗口限制在 t+8 步以内之后,效果立刻改观。这种“离得近的目标更容易学”的特性,其实跟人类学习的就近原则很像。
5.2 三条独家经验
第一,状态与目标的关系要想清楚。HER 对目标维度的可观测性很敏感。如果目标是一个需要估计的传感器值,重标时不要直接用估计值做 ground truth。稳妥的办法是把目标的物理量直接纳入环境接口,让 env 提供的 info 里包含 achieved goal,这样重标才不会被感知误差污染。我见过一个真实机械臂项目,因为 goal 来自视觉估计,HER 的辅助目标跟着抖动,模型始终无法稳定复现动作。
第二,训练时一定要记录“原始目标成功率”而不是重标成功率。有些团队复盘时误把重标后的假奖励上升当成性能提升,这是个很隐蔽的坑。我会在训练脚本里把两个指标分开记录:一个是在原始目标下评估的 rollout 成功率,一个是 replay buffer 里的平均奖励。只有前者代表任务真实完成度,后者只是内部优化信号。
第三,HER 和课程学习并不冲突。HER 本身已经能处理稀疏奖励,但遇到高维、困难的目标空间,把任务难度从近处往远处推进依然有效。我在 FetchPickAndPlace 上做过对比:HER 配合简单课程(先训练距离较近的目标)比纯 HER 早约 1.5 倍收敛。不过课程别设计得太刻意,否则 HER 的探索多样性会被限制住。
最后说点个人体会。跑通 HER 不需要高深理论,难的往往是那些看起来琐碎的细节:目标采样窗口多大、k 取几、奖励函数有没有分叉、成功率指标准不准。我调过这么多稀疏奖励任务后最大的感受是,算法名字听起来越“后见之明”,实际工程里越要脚踏实地。每次训练不收敛,与其急着换模型换环境,不如先回头看看 buffer 里那些重标过的样本是不是真的符合任务意图。HER 给了我们一个把失败变成经验的机会,怎么用好这个机会,还是得靠一遍遍跑实验、看曲线、抠细节。希望这篇文章能帮你少走一些我曾经绕过的路。