hindsight,后见之明。如果你最近在折腾强化学习,尤其碰过那种“死活等不到正奖励”的稀疏奖励任务,这个词你绕不开。这里说的不是什么“早知道我当初就……”的人生感慨,而是 OpenAI 在 2017 年开源的一套经典到不能再经典的算法——Hindsight Experience Replay,简称 HER。它借用了同一个思考角度:既然没达到原来的目标,那就把你真正达到的状态重新定义成目标,于是每一条失败轨迹都变成了可学习、可复用的经验。这篇文章就把 HER 从思想到实现、从参数到踩坑完整拆开,适合正在调稀疏奖励任务的算法工程师,也适合刚把 DQN 跑通、想往更真实场景进发的同学。
1. 先搞懂 Hindsight 到底在解决什么问题
1.1 稀疏奖励是强化学习的“哑火”时刻
大多数强化学习算法能正常工作,依赖一条隐式假设:奖励信号要足够密集,或者至少能提供一个渐进的梯度方向。但真实场景恰恰相反,机器人伸手去抓一个物体、游戏里完成一个多阶段任务、推荐系统里用户完成一次转化,这些过程的奖励往往是“0 一直持续,直到最后一刻突然变成 1”。
在稀疏奖励下,标准 off-policy 算法会出现一种特别尴尬的局面:智能体拿着随机策略在环境里瞎逛,逛了几万步没拿到一次正奖励,回放缓冲区里全是奖励为 0 的样本。算法更新时看到的所有 data 都指向同一个结论——“做什么都无所谓”,于是策略网络越学越平,最后干脆躺平。你观察 loss 曲线,它可能一直在下降,但智能体依然什么都不会,这就是典型的“哑火”。
Hindsight Experience Replay 的设计初衷,就是专门收拾这种局面。它不要求你改环境、不要求你设计复杂的奖励塑形函数,而是在经验回放这个层面做文章,用“失败”数据自己生成“假想成功”数据。很多人第一次听到这个思路会反应“这不就是作弊吗”,其实它不是作弊,它是在重新定义什么叫做“学习信号”。
1.2 把失败重新解读成经验:HER 的核心思想
HER 适用的任务有一个共同特征:目标条件化,goal-conditioned。也就是说,环境有一个目标 g,智能体的观测里明确带了 desired_goal,同时每一步还能拿到当前已经达到的目标 achieved_goal。典型例子是机械臂抓取:目标是一个三维坐标,机械臂每一时刻的末端位置就是 achieved_goal。
传统 replay 里存的是形如 (s, a, r, s', done) 的 transition,HER 则把目标也存进去,变成 (s, a, r, s', g, done)。最关键的一步在采样之后:一条 episode 走完,发现原始目标 g 根本没达到,奖励全是负的,这时候 HER 会从这条 episode 里挑一些时刻,把原目标 g 替换成轨迹未来某个时刻实际达到的 achieved_goal,再按照新的目标重新计算奖励。一旦目标被替换成“已经发生过的事实”,奖励就不是全 -1 了,而是最后一次触达时给 0,中间步骤也会出现大量“差一点就成功”的相对信号。
你可能已经意识到,这意味着智能体的学习目标变了:它学会的不是“完成原任务”,而是“在任何给定目标下,都能从当前状态逼近目标”。原任务成了所有可能目标任务中的一个采样点。正是这种“对所有目标负责”的策略,让 HER 在训练早期就能从完全随机的经验里挖出梯度,而不是干瞪眼。
1.3 为什么它叫“后见之明”,不叫“事后诸葛亮”
中文语境里“事后诸葛亮”带点贬义,指的是事情结束之后谁都会说两句。但 HER 的 hindsight 是一个中性的认知偏差:人在复盘时会自然地用“最终结果”替代“当初目标”来评价自己的行为。比如你想打篮球投进某个角度,没投进但球碰巧弹出在另一个位置,你用这次弹跳轨迹练习了“从这个新位置出手”,下一次遇到类似位置就更从容。这就是后见之明在帮人创造课程,而不是在欺骗自己。
HER 把这种复盘逻辑机械化、数据化了。它不关心智能体原来想干什么,只关心它实际去了哪里、哪里是可达到的。只要一个状态在轨迹中真实出现过,它就可以被作为后续学习的“伪目标”回放给策略网络。这种方式有一个隐藏的好处:伪目标全部来自真实物理轨迹,不会像人工随机撒点那样超出环境动力学允许的范围。对机器人操作这类任务来说,这一点非常重要,因为脱离物理可行的目标根本不可能被学到。
2. 拆开 HER:目标重标记的细节与关键参数
2.1 进入 HER 的数据流:采样、重放、重标记
HER 不是一个一上来就能直接部署的独立算法,它是一个“外挂”,需要挂在任意 off-policy 算法下面,常见搭档是 DQN、DDPG、TD3 和 SAC。下面这一段是 HER 的数据流全貌,建议对照着代码理解。
当智能体在环境里跑完一条 episode,它会得到一串原始 transition,比如 50 步。普通的回放缓冲区直接把这 50 条塞进池子就结束了,HER 会在入池之前多做一件事:为每条原始 transition 额外伪造若干个“目标被替换后的 transition”。伪 transition 里的状态、动作、下一个状态、done 全部保持不变,只改目标,然后按照新目标重新计算奖励。伪 transition 跟原始 transition 一起进入回放缓冲区。之后采样时,训练算法根本不区分哪些是原始样本、哪些是伪造样本,一视同仁地更新网络。
这里有一个很容易忽略的点:奖励函数必须是“给定一个目标就能独立计算”的形式。如果你原来依赖环境自动返回 reward,那么没法重算;你需要把 reward 函数拆出来,比如基于 achieved_goal 和 desired_goal 的距离阈值,自己写一个 compute_reward。设计时要注意这个函数只依赖 s' 的 achieved_goal 和传入的 goal,不依赖动作历史,否则重标记就没有意义。
2.2 四种目标重标记策略怎么选
原文给出了四种替换新目标的策略,区别只在于“伪目标从哪里取”。
| 策略名 | 伪目标来源 | 特点与适用场景 |
|---|---|---|
| final | 整条 episode 最后一步的 achieved_goal | 实现简单、计算量最小,但目标分布单一,训练早期容易学不到多样化经验 |
| random | 整条 episode 里随机取一步的 achieved_goal | 增加了目标多样性,但会出现“先到 A 再到 B”这种不一致轨迹,噪声较大 |
| future | 同一条 episode 中当前时刻 t 之后某步的 achieved_goal | 最常用、效果最稳,保证伪目标在当前状态之后是可达的 |
| episode | 同一条 episode 中任意一步的 achieved_goal | 介于 random 和 future 之间,用的较少 |
实操里绝大多数项目直接选 future。它的逻辑是:当前状态 s_t 后面的某个未来状态 s_{t'} 一定是从 s_t 经过真实动作走出来的,所以“从 s_t 出发,朝着 s_{t'} 对应的 achieved_goal 前进”是一个物理合理、可逆推的学习信号,避免了 random 策略里那种时间顺序错乱带来的学习冲突。
future 策略里的 t' 也有讲究。原文在使用 future 时,是从 [t, T-1] 区间里均匀采样一个未来时间点(T 是 episode 总步数)。你也可以限制一个固定窗口,比如只从未来 10 步以内取,这在一些长 horizon 任务里更稳。具体窗口多长需要调,核心原则是:伪目标与当前状态距离既不能太近(信号太弱),也不能太远(和原目标差不多稀疏)。
2.3 k 值、未来步数与 reward shaping 怎么配
HER 原文里最核心的超参数是 k,表示每条轨迹额外生成多少条伪 transition。假设一条 episode 有 50 步,k=4,那么这 50 步会变成 50×(1+4)=250 条样本入池,其中 200 条是伪造的。
k 的取值直接决定伪样本量占总样本的比例。OpenAI 在 Fetch 系列机械臂任务上验证过 k=4 效果很好,我自己的经验是:任务越难、目标越稀疏,k 可以适当加大到 8~16;但 k 太大会导致回放缓冲区里伪造样本占绝对主导,原始目标样本被稀释,算法会过度善于“完成已经到达的目标”,却在原始目标上毫无动静。所以 k 不是越大越好,它本质上是“实际目标”经验和“事后目标”经验之间的平衡旋钮。
奖励塑形方面,HER 原文用的是最简单的二值奖励:距离小于阈值给 0,否则给 -1。很多同学一上来觉得二值奖励信息量太少,想手动加一个连续距离奖励。加是可以加,但要小心:一旦加了距离奖励,HER 未来策略的优化方向和原始任务的优化方向可能会产生冲突。我的建议是先用纯二值,把 HER 的效果跑出来再加连续项,并且用实验对比,别拍脑袋。
3. 落地实操:把 HER 装进你的 off-policy 算法里
3.1 环境侧准备:goal-conditioned 接口怎么改
在动任何算法代码之前,先确认你的环境是 goal-conditioned 的。标准 gym 接口里,观测通常是单一状态向量,但 goal-conditioned 环境需要同时提供当前状态、desired_goal、achieved_goal 三份信息。常见的做法是用字典观测,形如 obs = {'observation': ..., 'desired_goal': ..., 'achieved_goal': ...},gym 自带的 FetchReach 和 FetchPush 都是这种格式。
如果你的环境不是现成的,需要自己动手改。假设你有机器人仿真环境,状态里包含机械臂关节角和末端位置,你可以把末端位置单独抽出来做成 achieved_goal,再另外定义 desired_goal 为任务目标。步骤大致是:reset 时随机生成一个 desired_goal;step 时从状态向量里提取新的 achieved_goal;reward 用自定义函数计算并作为 step 返回值;done 是 reward 达到 0 的标志。还有一点很关键:每一步返回的 info 里必须带上 achieved_goal,因为 HER 的伪目标重标记要拿它做替换。如果 info 里丢了,整条经验就废了。
3.2 缓冲区改造:一条轨迹拆出N条经验的实现
下面给一个可直接参考的简化实现,核心逻辑就是入池前对整条 episode 做重标记。为了表达清晰,我用了最简单的 list 结构,实际工程里建议直接用高效的环形缓冲区。
import numpy as np def compute_reward(achieved_goal, desired_goal, threshold=0.05): # 二值奖励:到位给 0,否则 -1 dist = np.linalg.norm(achieved_goal - desired_goal, axis=-1) return (dist <= threshold).astype(np.float32) - 1.0 def relabel_episode(episode, k=4, strategy="future", threshold=0.05): """ episode: list of dicts,每条包含 obs, action, obs_next, done, achieved_goal 这里假设原始 reward 已经在 episode 里了,但重标记后会重新计算。 """ new_transitions = [] T = len(episode) for t, trans in enumerate(episode): # 1. 原始样本原样保留 new_transitions.append(trans) # 2. 额外生成 k 条伪样本 for _ in range(k): if strategy == "future": # 从 t 到 T-1 里均匀等概率取一个未来时间点 future_idx = np.random.randint(t, T) new_goal = episode[future_idx]["obs_next"]["achieved_goal"] elif strategy == "final": new_goal = episode[-1]["obs_next"]["achieved_goal"] else: idx = np.random.randint(0, T) new_goal = episode[idx]["obs_next"]["achieved_goal"] # 3. 基于新的目标计算新奖励 new_reward = compute_reward( trans["obs_next"]["achieved_goal"], new_goal, threshold ) # 4. 构造伪 transition,只替换 goal 和 reward fake = dict(trans) fake["obs"] = {**trans["obs"], "desired_goal": new_goal} fake["obs_next"] = {**trans["obs_next"], "desired_goal": new_goal} fake["reward"] = new_reward new_transitions.append(fake) return new_transitions这段代码有几个值得注意的边角细节。第一,obs 和 obs_next 都要替换 desired_goal,保证网络输入和目标一致,否则策略看到的 goal 和计算 reward 的 goal 对不上,训练直接乱套。第二,fake 里 done 标志保持原样,不要跟着新奖励改,HER 的终止条件是环境物理上的终止,不是目标是否达成,这个细节很多人会在实现时忽略。第三,如果是 off-policy 算法且使用 N 步回报,重标记后连续样本的 done 关系也要重新审视,一般建议先跑一步 TD 版本,别一上来就上复杂设定。
3.3 网络与训练配置:一个可以直接抄作业的配方
HER 在原文里配合的是 DDPG,后来大家发现 SAC+HER 更稳。网络结构上,状态和目标先各自过一层编码,再拼接进主网络,这是最常见的做法。你也可以直接把 observation 和 desired_goal 拼接成一个长向量输入,效果在低维目标空间里差别不大。
我提供一个基于 SAC+HER 的可复现配置,这套参数在 FetchReach 上大概 20 个 epoch 内能稳定解决,FetchPush 上大约需要 100~150 个 epoch:
- 策略网络:两层全连接,各自 512 个神经元,激活函数 ReLU
- Q 网络:两层全连接,各自 512 个神经元,输出层去掉激活
- 优化器:Adam,学习率 3e-4
- 折扣因子 gamma:0.98,不要贪心到 0.99,会把早年的稀疏奖励也拉得太重
- 目标网络更新:soft update,tau=0.05
- 回放缓冲区大小:1e6 条 transition
- 采样 batch size:1024,比普通 SAC 默认的 256 大,因为伪样本噪声大,batch 大一点梯度更稳
- 探索噪声:动作空间为连续时,初始高斯噪声 std=0.3,随着训练衰减到 0.05
- episode 长度:50 步,超过就强制终止
训练节奏上,我习惯每个 epoch 先采 50 个 episode,再更新 100 次网络。采样和更新的比例里隐含了 HER 的一个特性:因为伪造样本让有效信息密度大大增加,你不需要像普通稀疏奖励任务那样疯狂采样,反而要把更新次数拉高,让网络充分消化这些“事后目标”。如果你发现智能体早期行为变得很奇怪,可以试着把更新次数降一半,回头检查一下是不是伪样本占比过高导致原始目标被淹没。
4. 踩坑记录与参数调优实录
4.1 训练不收敛?先检查目标分布是否太窄
我第一回把 HER 跑在自研的机械臂仿真环境上,连续三天不收敛。后来把伪目标打出来看分布,发现整条轨迹的 achieved_goal 几乎挤在一个小球区域内,因为我的随机控制器前期只会让机械臂在一个很小的邻域里抖动。所有轨迹的伪目标都长得很像,等于自学了一门“如何完成同一个简单目标”的课程,对真实目标毫无泛化。
解决办法最直接的是加强探索:增大动作噪声、随机重置初始状态、甚至在早期用随机策略跑长一些。HER 的效果上限取决于经验池里状态覆盖的多样性。很多论文复现经验强调“HER 在好的探索策略下效果极好,在差的探索策略下几乎没提升”,就是这个原因。在采样阶段花时间把覆盖度拉起来,比在训练阶段反复调学习率有效得多。
4.2 HER 失效的几种典型情况
HER 不是万能灵药,我整理三种最典型的失效场景,对排查问题很有参考价值。
第一种,目标空间状态跟任务成功条件不对齐。比如你抓取物体,但 achieved_goal 定义成机械臂末端位置而不是物体位置,那么即便机械臂末端“到达”了伪目标,物体也没被抓起来,奖励函数给 0 就产生了虚假成功信号。这种问题通过修改 achieved_goal 定义解决,务必让目标充分描述任务成败。
第二种,环境奖励本身有依赖历史或隐藏信息的成分。HER 重新计算奖励的公式是 r(s, a, g) 或 r(s', g),一旦你的原始奖励函数依赖当前状态之外的信息(比如“是否执行了某动作序列的前置动作”),重标记后奖励就失真了。
第三种,目标空间维度高于可探索状态空间的有效维度。比如目标是一个 100 维像素图,而智能体只能控制 7 个关节角,可达到的状态只占目标空间里极小一块流形。HER 用欧氏距离计算伪目标奖励时,绝大多数伪目标都离流形很远,信号失去区分度。这种情况的出路是先把高维观测压缩成低维潜空间表示,在潜空间上做 HER,或者引入距离学习网络。
4.3 混合经验重放与后续扩展方向
HER 虽然是 2017 年的工作,但它带火了一系列围绕“目标重标记”的后续方法。现在的项目里我更推荐把 HER 和优先级经验回放结合:伪造样本不是同等重要的,有些伪目标距离原目标近、信息价值高,应该优先被采样。做法很简单,给每条 transition 维护一个 TD error,按它的绝对值计算采样概率,能明显加速 Fetch 类任务收敛。
另一个工程上的常用扩展是“目标切换器”。在训练过程中按比例调整伪目标的生成策略,前期多用 future 和 random 提供多样性,后期逐渐增加 final 的比例,让策略把注意力拉回到真实任务。这个思路有点类似课程学习的退火过程,在复杂工业场景里比固定单一策略更稳定。
我还会在上手新任务时先跑一个最小模板:环境允许的话,先拿 DDPG+HER 在 FetchReach 一类标准任务上验证代码正确性,再迁移到自己的环境。HER 算法本身不算复杂,但环境接口、奖励重算、目标对齐这些小细节一旦出错,症状都很像“算法不收敛”,排查起来非常消耗耐心。把这套验证流程固定下来,能少熬不少夜。