hindsight 这个词,英文词典里的解释是“后见之明”,翻译得更接地气一点就是“事后诸葛亮”。我第一次看到这个标题的时候,脑子里同时跳出两条线:一条是认知心理学里的 hindsight bias,讲的是人总喜欢在事情结局揭晓后,说“我早就知道会这样”;另一条是强化学习圈子里的 Hindsight Experience Replay,一个专门用来对付稀疏奖励问题的算法。说实话,如果你也是做 RL 的,这四个字母大概率绕不开后一条线。这篇博文我想把这两条线拧在一起聊:先说说为什么“事后聪明”这个人类普遍存在的判断偏差,会被 DeepMind 的研究者改造成一套训练算法,再详细拆解 HER 的原理、工程实现、采样策略变体,以及我实际复现时踩过的坑。适合两类人看:一类是刚接触强化学习、被稀疏奖励折磨得训练不动的同学,另一类是想把 HER 集成到自己项目里、但不想只背概念想上手实操的工程师。内容不要求你有很深的 RL 基础,我会把环境搭建、代码结构、超参调节这些环节都讲透。
1. 先从“事后诸葛亮”说起:hindsight 的双重身份
1.1 一个认知偏差,为什么能变成算法名
“事后聪明”在现实生活里通常带着点贬义。考试考完了你才想起来正确答案,项目上线出了问题你才说早就觉得架构不对。但在强化学习算法设计里,这种“事后聪明”反而是宝贝。想想智能体在环境里做任务,如果它尝试了一百次都失败了,这一百条轨迹里其实隐藏着大量信息,只不过这些信息没有被利用起来。
HER 的核心思路非常简单粗暴:一条失败的轨迹,如果我把“目标”偷换成它实际达到的那个状态,那这条轨迹立刻变成了一条成功轨迹。举个例子,机械臂想抓取一个放在桌上的杯子,目标是移动到杯子的位置,结果它推到了旁边的另一个位置。对原始目标来说,这次移动是失败的,reward 是 0;但如果我把目标临时改成“推到那个旁边位置”,那么这次移动其实就是精准命中了新目标,reward 变为 1。算法这样做不是自欺欺人,而是为了给策略网络提供更密集的学习信号,让模型先学会“如何到达一个指定位置”,再去学“如何到达原始目标位置”。
这种把“失败经验重新解释”的机制,本质上就是利用了 hindsight。人类在做复盘的时候也经常用这个方式:虽然项目没达到预期,但过程中掌握了新的工具、理清了一些逻辑、积累了几条可复用的经验。HER 把这个经验逻辑形式化了,变成了一个稳定提升样本效率的算法。所以这个标题起得非常妙,psychology 和 machine learning 在一个词上碰出了火花。
1.2 我看“hindsight”第一眼时想的两个方向
我看到这个标题,第一反应是先确认它是概念探讨还是代码项目。如果是概念探讨,那重点应该放在认知偏差的形成机制、对决策质量的影响,以及如何在团队协作里避免“事后偏见”;如果是代码项目,那重点就是 HER 的算法推导、环境适配、训练调参。但实际情况里,两者并不冲突——理解认知偏差能帮你从直觉层面理解 HER 为什么要这样设计,理解 HER 的细节又能反哺你对“经验如何被重复利用”这件事的认知。
所以在写这篇文章时,我不打算把它限制在纯理论或者纯代码的框框里。我会从“为什么需要 HER”开始,把稀疏奖励这个最核心的痛点讲清楚,然后拆解算法架构,再给出我在 gym 环境里完整复现 HER 的实操过程,包括环境搭建、核心代码结构、训练技巧和常见问题排查。这样你读完不仅能知道 HER 是什么,还能直接在项目里把它跑起来。
2. 稀疏奖励下的困境与 HER 的核心解题思路
2.1 稀疏奖励为什么难:以 FetchSlide 为例
要理解 HER 的价值,必须先理解稀疏奖励环境有多让人头疼。以机器人操作中最经典的一类环境为例:机械臂需要把一个滑块推到桌面上的某个目标区域。这个动作链包含接近滑块、接触滑块、施加合适方向的力、控制滑行距离,最后停在目标区。整个链路里任何一个环节出了偏差,滑块就飞到未知方向,任务失败,reward 为 0。只有最终滑块停在目标区,才给一个 +1 的奖励。这种设计就是典型的稀疏奖励。
在这种环境下,传统的强化学习算法(比如 DQN、DDPG、PPO)会遇到一个致命问题:奖励信号太稀薄了,智能体很难从随机探索中找到一个有正奖励的样本。你可以想象一下,在一个庞大的状态空间里随机撒点,绝大多数尝试都得不到任何反馈,梯度信号几乎为零,价值网络的估计都是瞎猜。我见过不少新手训练这类任务,跑了二十万步,replay buffer 里一条正 reward 的样本都没有,loss 曲线平平无奇,这种情况下任何策略优化算法都没戏。
很多人的第一反应是给 reward 函数增加“过程奖励”:离目标越近,reward 越高。这确实能缓解稀疏问题,但代价是引入了人工设计的 bias——你其实是在告诉智能体“怎样走路是对的”,遇到复杂任务时这种引导往往设不好,甚至会让智能体学会刷分而不是完成任务。HER 提供了一条不一样的路:不改变原始 reward 结构,只改变“数据怎么用”,让智能体从失败中学到信息,这是更通用、更难被 hack 的解法。
2.2 核心 trick:把失败经验重新标注
HER 的全称是 Hindsight Experience Replay,属于 experience replay 技术的一个变种。普通 replay buffer 里存的是 (s, a, r, s') 这样的转移样本,HER 额外引入了一个“目标”概念,把样本变成 (s, a, r, s', g)。其中 g 是当前这段经验对应的目标。
对每一条经验,原始的 g 可能是原始任务目标(比如滑块推到坐标 (1.5, 0.5) 处),而这次执行实际没达到这个 g,所以 reward 是 0。HER 的做法是:在训练时,额外从这条轨迹里挑一个“实际上达到了的状态”,把它作为新的目标 g',然后用 g' 重新计算 reward。由于智能体确实到达了 g',这次“重新标注”的经验就有了明确的成功信号,可以直接扔进 replay buffer 参与梯度更新。
注意,这个操作不是简单地把目标改了,而是整条经验都变了语义。原本是一条“失败轨迹”,重新标注后变成了一条“成功轨迹”——虽然这个成功后面对的是随手抓来的替代目标,但对策略网络来说,它学到的是一个非常有用的映射:“当我想要到达状态 X 时,刚才那串动作组合是有效的。”目标条件策略天然支持这种训练方式,因为它的输入里本身就包含 goal,学会了到达各种 goal,再去执行真正的 goal 时,能力就能迁移过去。
2.3 目标条件策略:不仅要学动作,还要学“目标”
提到 HER,就必须提目标条件策略(goal-conditioned policy)。普通的策略网络输入只有状态,输出动作;目标条件策略网络的输入是状态加目标,输出动作。它的目标是学会一个条件分布:给定当前状态 s 和期望目标 g,输出能让环境到达 g 的动作。
这个设计是 HER 的前提。因为你只有把“目标”作为输入放进网络,才能在训练时随意替换目标。HER 相当于做了一个数据增强:对同一条轨迹,不仅用原始目标训练一次,还用多个“事后目标”训练多次。这样样本利用率大幅提升,尤其是稀疏奖励环境里,正样本的数量直接翻好几倍。
在工程上,目标通常会拼接到观测向量里。以 Fetch 系列环境为例,观测值由三部分组成:机械臂本身的状态、物体位置、目标位置。具体到代码里,通常是把 desired goal 直接 concat 到 observation 上,这样策略网络和价值网络都同时看到状态和目标。有一点要特别注意:HER 在重标注时需要用到“实际达到的目标”(achieved goal),这个值一般由环境给出,比如 Fetch 环境里的 info['achieved_goal'],而不是简单地把观测的最后几位取出来,因为观测里还带了其他状态信息。这个细节初学的时候特别容易弄错,我后面会专门展开。
3. 实操:在 gym 环境里复现 HER 的完整过程
3.1 环境准备与依赖安装
先说环境。如果只是想把 HER 的逻辑跑通,我建议不要一上来就挑战 FetchSlide、FetchPickAndPlace 这种高难度任务。我自己最开始是从一个自定义的 2D 点目标环境入手的,一个点在平面上移动,目标是到达某个位置,奖励只有一个稀疏的 success 信号。这种环境状态维度低,速度快,能让你把注意力放在算法逻辑上,而不是被环境配置和仿真速度搞到心态崩溃。
如果你想直接在三行代码内体验 HER 的效果,可以选 gymnasium 加上 MuJoCo 的 Fetch 系列环境。但说实话,MuJoCo 的环境安装有时候会让人怀疑人生,版本配不对,渲染黑屏,各种glfw报错。我的建议是:想快速验证算法逻辑,先跑 mini-grid 或自定义 point env;想做真实感和有挑战性的评估,再上 Fetch,但预留足够的耐心和时间调环境。
依赖方面,核心其实只需要三样:PyTorch 或 TensorFlow 二选一、gym/gymnasium、numpy。强化学习算法本身我用的是 DDPG(Deep Deterministic Policy Gradient),因为 HER 的论文原版就是搭配 DDPG 用的,它天然适合连续动作空间,而且 off-policy 的属性让它可以配合超大 replay buffer 去抽样本。你当然可以换 SAC,尤其是动作空间维度更高、对探索效率要求更高的场景,SAC 往往更稳,但核心的 HER 重标注逻辑完全通用,跟算法本体解耦得很干净。
3.2 核心代码的结构拆解
HER 的代码结构其实不难,核心就两块:一块是训练主循环,另一块是重标注函数。先看重标注的核心逻辑,这是我理解整个 HER 最关键的 20 行:
def hindsight_relabel(obs, action, reward, next_obs, achieved_goal, new_goal): new_reward = compute_reward(achieved_goal, new_goal, reward_type='sparse') new_done = is_success(new_reward) return { 'obs': obs, # 原始观测 'action': action, # 原始动作 'reward': new_reward, # 用新目标重新计算reward 'next_obs': next_obs, 'new_goal': new_goal, # 新的替代目标 'done': new_done, 'achieved_goal': achieved_goal, } def compute_reward(achieved_goal, desired_goal, reward_type='sparse'): distance = np.linalg.norm(achieved_goal - desired_goal, axis=-1) if reward_type == 'sparse': return (distance < threshold).astype(np.float32) else: return -distance # dense reward,调试用这段代码里的new_goal是从哪来的?这是 HAR 采样策略的核心,我后面会讲四种策略。这里先给一个最常见的做法:在同一条轨迹里,从当前时间步之后的任意一个时间步里随机采样一个 achieved goal 作为新目标。
训练主循环的整体流程大概是这样的:
for epoch in range(n_epochs): episode_buffer = [] obs = env.reset() for t in range(max_steps): action = policy.select_action(obs, exploration_noise) next_obs, reward, done, info = env.step(action) # 关键点:存档时要单独取出achieved_goal transition = (obs, action, reward, next_obs, info['achieved_goal']) episode_buffer.append(transition) obs = next_obs # HER重标注:每条经验用替代目标再生成多条经验 for i, transition in enumerate(episode_buffer): replay_buffer.add(transition, goal=original_goal) for _ in range(future_k): # future_k一般取4 new_goal = sample_future_goal(episode_buffer, i) replay_buffer.add(relabel(transition, new_goal)) # 从replay buffer里采样batch训练policy for _ in range(n_updates): batch = replay_buffer.sample(batch_size) policy.update(batch)这里有一个非常关键的细节:achieved_goal必须实时从环境 info 里拿出来存起来。很多新手直接把next_obs截一段当 achieved_goal,这在某些环境里是碰巧能用的,但碰到复杂机械臂环境,观测里还包含关节角度、速度等其他信息,截取的位置不对,目标就会被污染,训练直接废掉。我这里强烈建议,只要环境提供了info['achieved_goal'],就老老实实用它。
3.3 超参数选择与训练效果观察
我在实际操作中验证过一组比较稳的超参配置,不敢说最优,但至少能让你少走很多弯路。先把表格放在这里,后面逐条解释:
| 参数 | 推荐值 | 备注 |
|---|---|---|
| replay buffer 容量 | 1e6 | 越大越稳,但吃内存 |
| batch size | 256 | 小了容易震荡 |
| actor 学习率 | 1e-3 | Adam 优化器 |
| critic 学习率 | 1e-3 | 有时 critic 需更小,1e-4 更稳 |
| gamma | 0.98 | 短horizon任务 |
| tau(target网络软更新) | 0.05 | 不要太小,HER本身学习信号密集 |
| future_k | 4 | 每条经验额外生成4条替代目标经验 |
| 探索噪声 | 高斯噪声,std=0.2 | 或者OU噪声 |
训练效果怎么观察?我习惯同时看四个指标:平均 episode 成功率、critic loss、actor loss、以及 replay buffer 里的正样本比例。这里特别注意:critic loss 降不下来不一定是坏事,因为 HER 重标注出来的很多“成功经验”对应的目标是随机的替代目标,critic 本质上在学一个复杂的目标分布,loss 维持在高位是正常的。真正要盯的指标是两个:成功率曲线是否稳步上扬,以及正样本比例是否随着训练推进变得不再一边倒。如果成功率一直为 0,先别急着调网络结构,先检查 replay buffer 里的正样本比例,如果连重标注之后的经验都是 99% 的负样本,那说明 achieved_goal 的抽取大概率出了问题,或者替代目标采样策略选得太差。
4. 采样策略变体与网络设计的细节
4.1 HER 的四种采样策略:final / future / random / episode
HER 论文里最容易被忽略但实际影响巨大的部分,不是“重新标注”本身,而是“怎么挑新目标”。论文里一共提出了四种策略:
final:从整条轨迹里只取最后实际到达的状态作为替代目标。这个方案最简单,但对于长轨迹,最后状态和轨迹中前段的行为关联很弱,重标注出的经验质量参差不齐。future:从当前时间步之后的某几个时间步里,随机采样一个状态作为替代目标。这是最常用也是我推荐作为默认策略的方案,因为它保证了“目标”确实是在这段行为之后达到的,而且同一轨迹可以采样出多个不同目标,样本多样性比 final 高很多。论文里 future 策略每个 transition 会额外采样 4 个新目标(也就是future_k=4)。random:从整个 replay buffer 或当前轨迹里随机采样一个状态作为目标。这个方案逻辑上最“自由主义”,但坏处显而易见:随机一个状态可能跟你刚才的动作根本没有关系,重新标注后的经验在语义上不成立。episode:从当前轨迹里随机选一个状态,相当于 future 的无条件版本,它对当前时间步没有限制,选到的状态可能在动作发生之前,也可能在之后。
我实测下来的结论是:大部分情况下future最稳,episode次之,random除非环境非常特殊否则不建议用。原因也不难理解:HER 的本质是“从这段经历里找出一个我其实做到了的事情,然后告诉网络‘如果你以后想做这件事,刚才这套动作就是对的’”。如果目标选的是这段行为之后才达到的状态,那么这个说法是成立的;如果目标选在这段行为之前就已经存在的状态,那网络学到的映射就有因果倒置的嫌疑,容易引入噪声。
4.2 网络结构与 reward 函数怎么配
HER 本身和信息网络没有强绑定关系,但它对“观测空间怎么组织”有要求。上面提到过,输入是拼接了目标的状态,但目标在拼接前是否要做归一化,这是一个我踩过坑的细节。拿 Fetch 环境举例,机械臂末端坐标可能是小数,而关节角度可能是几十上百的数量级,如果你直接 concat 不做归一化,actor 和 critic 的输入尺度差异巨大,训练很容易变得极其不稳定。建议对输入 obs 和 goal 分别做标准化,减均值除标准差,至少在状态维度跨度大时要做到这一点。
reward 函数的写法也要配套。我在计算目标条件 reward 时,用得最多的是稀疏形式:如果距离小于一个阈值就返回 1,否则返回 0。这个阈值在 Fetch 环境里通常是 0.05 左右。阈值设得太大,明明没到目标附近也算成功,策略会学得很懒;设得太小,成功率永远上不去,动作精度要求过高。
有一种调试技巧值得分享:如果训练死活不收敛,可以把 reward 暂时换成 dense 的(-distance),让算法先生成足够多的正反馈,观察策略能不能学到大致靠近目标的行为;等策略有雏形了,再切回 sparse 形式做精细收敛。这个技巧在调试复杂环境时帮我省了大量时间。
4.3 训练不稳定时的排查清单
如果你照着上面的结构写完代码,跑起来发现效果不好,那大概率不是算法过时了,而是某个实现细节出了偏差。我总结了一份排查清单,按优先级排序:
- 先检查 achieved_goal 的抽取:打印一条完整 transition,人工判断 achieved_goal 是不是真的是物体/末端的实际坐标。
- 再检查 replay buffer 里的 reward 分布:如果重标注之后依然几乎全是 0,检查 new_goal 的采样是不是从轨迹未来时刻采的。
- 检查目标是否 concat 到 obs 里:policy 输入维度变了没有,actor 和 critic 是否用了同样的拼接方式。
- 检查 target 网络更新的幅度:tau 设得太小,target 更新太慢,价值估计滞后严重;设得太大,训练震荡。
- 检查探索噪声的尺度:噪声太大策略动作崩,太小探索不够。Fetch 类环境建议 std 先取 0.2,然后根据成功率曲线微调。
这份清单是我踩了无数坑之后总结出来的,每一步都曾真实让我“确认过眼神,是白忙活了半天”的经历。
5. 踩坑实录与个人心得
5.1 我复现时踩过的几个坑
第一个大坑就是 achieved_goal 的获取。我第一次写 HER 时,为了图省事,直接用了obs['observation'][:3]当作末端位置,结果训练了好几天成功率纹丝不动。后来拿脚本把 obs 一段段拆开对比 info 里的 achieved_goal,才发现环境返回的 observation 里既包含机械臂关节角又包含线速度角速度,前面几位根本不是末端坐标。这个发现直接让我理解了“环境 info 字段永远是第一手真相”的道理。
第二个坑是 future 策略里采样目标时的索引问题。我一开始写的版本是在整条轨迹里任意采样,没有限制“必须在当前步之后”。结果训练时正样本非常多,但成功率依然低,后来才意识到目标状态和动作序列之间缺乏时序因果性。改成只能在t+1到T之间采样之后,效果立竿见影。
第三个坑跟批量训练有关:我在同一个 batch 里混入了原始目标和替代目标的样本,但 critic 的 target value 计算里没有正确区分当前观测对应的 goal 是哪个。这会导致价值估计错乱,loss 剧烈震荡。正确做法是每条样本自始至终带着自己的goal字段,训练时代码上确保观测拼接的目标和用来计算 reward 的目标是同一个值,绝不能在图里只传一次 goal 让整个 batch 共用。
第四个坑是环境重置问题。HER 强调“同一条轨迹内部采样”,所以一条 episode 内部的 transition 必须保存在一个完整的 buffer 里,等整条轨迹跑完再做重标注。我最初偷懒,边跑边往 replay buffer 里塞数据,结果 future 策略采样时根本没等到后面的状态,全采成当前步之前的状态了。逻辑上正确的方式是:先用一个 episode_buffer 存好完整轨迹,结束后统一做重标注,再把原始经验和重标注经验一起加入全局 replay buffer。
5.2 从算法名字到工程思维的几点体会
回到标题本身,hindsight 这个命名真的取得很好。“后见之明”在人类决策里通常是负面标签,但在强化学习里,它代表一种把事后信息编码成训练信号的能力。算法不会因为失败而羞愧,它只会把失败经验重新组装成有价值的数据。这让我联想到工程里经常说的“复盘”和“事后分析”,每一次失败的系统都可能藏着下一次成功的表征,关键看你有没有能力把它抽出来用上。
从实操角度看,HER 给我最大的启发不是“目标替换”这个 trick 本身,而是“如何从数据语义层面做增强”。很多人遇到稀疏奖励第一反应是改 reward 函数,或者加课程学习,但 HER 的思路完全绕开了“人工设置奖励形状”这条路,它改变的是经验数据的目标标签,让模型在一个更密集的监督信号下训练。这种思路放到更广的机器学习场景里也成立:数据不够时,不一定要去采集新数据,换个角度重新标注旧数据往往能榨出意想不到的价值。
我个人在实际操作中还有一个体会:遇到这种有清楚论文背景的算法,不要只跑通开源代码就觉得自己会了。一定要手写一遍重标注逻辑,哪怕写出来的版本性能差一点——这个过程的收益在于能让你彻底明白每条经验从原始形态到重标注形态之间,到底发生了怎样的语义变化。如果你也想在自己的项目里用 HER,我建议你先从做一个小型 gridworld 开始,把四种采样策略全部实现一遍,观察它们各自的效率差异。这个过程花不了太多时间,但它能给你建立极强的直觉,后面再上 Fetch 这类重环境,你会发现自己调参和排错的速度快得很明显。