
“hindsight”——后见之明。英文里常说 hindsight is 20/20意思是事后看一切都清清楚楚。做强化学习这几年我对这个词体会最深的地方反而不在日常复盘而在一个名字就叫 Hindsight Experience Replay 的算法里。它把我一直以来的困惑——稀疏奖励下智能体死活学不会——用一句话点破了一条轨迹如果没达成原定目标那就换个目标再看它它很可能就是一条完美的成功轨迹。这个项目就是我基于 HER 做的一次完整复现和调参实录。我用它跑通了 OpenAI Gym 里的 Fetch 系列机器人操作环境从最简单的 FetchReach 一路做到 FetchPickAndPlace核心结论一句话就能概括在不做任何奖励塑形的前提下HER 能把“成功率几乎为 0”的训练曲线直接拉到 90% 以上。如果你正在被稀疏奖励问题折磨或者想找一个适合练手的连续控制强化学习项目这篇文章应该能给你省下不少弯路。我会把目标重标注的原理、四种采样策略的取舍、实现细节和踩坑记录都摊开来讲所有代码思路都是可以直接抄走的。1. 项目定位一个小事后视角如何盘活稀疏奖励有个场景做 RL 的人都熟你设计了一个机器人抓取任务机械臂要接近物体、抓住它、再放到目标位置。你给环境的奖励很简单——成功就给 1没成功就给 0。结果训练了几十万步成功率纹丝不动智能体像瘫痪了一样。问题就出在“稀疏奖励”上在漫长轨迹里绝大多数状态都是零奖励智能体根本不知道哪个动作离目标更近于是梯度信号彻底消失。1.1 稀疏奖励的本质学习信号被抽干了稀疏奖励环境的典型特征是“只有终局才有评价”。这里可以打个不太严谨但很好懂的比方一场考试不告诉你每道题对错只有总分及格与否。你要想通过刷题来提升成绩几乎无从下手因为没有任何中间反馈告诉你“这道题这么做对不对”。强化学习的核心是最大化累计奖励如果一条十步的轨迹只有最后一步才可能获得奖励那么前面九步的 (状态, 动作) 全部带零奖励——对 Q 网络来说这些转移唯一学到的东西就是“当前状态动作带来的收益为 0”完全丧失了方向性。我最初接触这类问题时试过很多“民间偏方”加大探索噪声、把奖励做成连续的距离函数、调整折扣因子……效果都不理想。尤其手工奖励塑形虽然能把曲线推上去但它是最容易埋雷的方案。你设计的距离奖励函数里每一项权重都需要调稍微不均衡就会诱导智能体学会“假动作”——比如只顾着把手伸到物体旁边但根本不抓取因为你的塑形奖励让“靠近物体”本身变得太诱人了。1.2 传统经验回放为什么救不了场经验回放Experience Replay本身是 DQN 时代的经典技巧把历史转移样本存进大缓冲区随机采样更新网络好处是打破时间相关性、提高样本利用率。在奖励密集的环境里这套组合拳很有效但在稀疏奖励环境里回放缓冲区里攒下的绝大多数都是失败的“垃圾样本”。你用这些样本来反复更新 Q 网络学到的是“所有动作都一样差”。再怎么加大回放比例也只是把“无信号”重复更多遍而已不会凭空产生学习信号。当时我甚至怀疑过是否要转用策略梯度类算法比如 PPO。但这类 on-policy 算法在机器人操作任务里样本效率更低每一次参数更新后旧的探索数据就报废了在真实物理环境或高成本仿真里完全跑不动。所以问题的关键不是换算法而是想办法让每一条“失败轨迹”都能榨出正面的训练信号。1.3 HER 的切入点把失败轨迹重新定性HER 的做法听起来像耍赖轨迹结束之后我们“事后”看一眼智能体实际到达的状态然后把“目标”改写成这个实际状态这条原本失败的轨迹瞬间就变成了一条成功轨迹。比如抓取任务里机械臂没把物体放到指定位置 B但它在某个时刻把物体运到了位置 C——那我们就把这条轨迹的目标改成 C观察它是不是真的达成了 C。如果是这条轨迹的后续步骤就获得了正奖励可以用于学习。这就是“后见之明”在算法里的具体化智能体当时的动作序列虽然没有达成原目标但它展示了一套能把某个中间状态变成现实的有效策略。我们用这套轨迹去教导 Q 网络——“当目标恰好是这个状态时这样的动作序列是好的”。无数条失败轨迹的最终状态各不相同合在一起就构成了一大批“伪成功数据”。需要强调的是这套思路有一个严格前提算法必须是 off-policy 的。因为我们改写了轨迹的目标和奖励却保留了原来的动作序列这等于在用另一个分布下产生的数据去训练当前策略。DDPG、DQN、SAC、TD3 这类 off-policy 算法天然吃这种数据而 PPO 这类 on-policy 算法会用“重要性采样比例”硬生生修正分布差距重标注后的数据基本派不上用场。2. 核心机制拆解目标重标注与采样策略的取舍HER 的原理一句话能讲完但落地时魔鬼全在细节里。最关键的环节是“轨迹存储时如何选虚拟目标”以及“选好目标后奖励和 done 怎么重新计算”。很多复现失败的项目都是卡在这一步。2.1 k 次重标注一条轨迹当多条用实际操作时我们不是简单地把每条轨迹的目标替换成最终状态就完了。论文里的标准做法是一条轨迹采集完成后从中随机抽取 k 个后续时间步的状态作为额外目标再把原始目标也保留一份。也就是说一条轨迹会被拆成 (k1) 份独立样本存入回放缓冲区。k 就是论文里的那个回放比例参数最常用的值是 4。k 值决定了样本量的放大倍数。取 4 意味着同样数量的环境交互能产生 5 倍于普通经验回放的训练样本这对于稀疏奖励环境来说简直是雪中送炭。但 k 也不是越大越好我实测过 k8 的时候缓冲区里伪成功样本比例过高网络收敛速度反而略有下降。这个后面在调参部分会详细说。2.2 四种重标注策略对比论文提出了四种选择虚拟目标的方式我复现时把它们的区别整理成了一张表策略虚拟目标来源特点适用场景final轨迹最终状态最简单稳定目标容易达成的简单任务future当前时间步之后的某个随机状态样本多样性强最常用大多数机器人操作任务episode同一条轨迹中随机一个状态目标可能与当前状态重叠难度偏低可以作为 baseline 对比random整个训练过程中的随机状态难度高几乎学不动一般不建议直接用我在复现时把 future 作为默认策略效果确实最稳。它的直觉是从轨迹当前时刻往后的任意一个状态都可以作为“未来可能达成的目标”。这样选择的虚拟目标既贴近轨迹的真实走向又因为随机性保留了足够的探索覆盖范围。而 final 策略虽然简单但每条轨迹只有一个虚拟目标样本多样性不足在 FetchPickAndPlace 这类多阶段任务里很容易陷入局部最优。episode 策略我试过一次因为随机选择的状态可能出现在当前时间步之前导致目标“太好达成”训练出来的策略泛化性偏差。2.3 最容易翻车的点奖励和 done 都要跟着重标很多第一次写 HER 的人都以为只需把 (s, g, a, r, s) 里的 g 换成虚拟目标就行这是个典型的隐蔽错误。目标一旦变了这条转移的奖励必须重新判定在新目标下如果下一状态 s 真的与虚拟目标足够接近奖励就给 0或正值否则仍然是稀疏惩罚。同时如果这一判定成立done 标志也要置为 True因为“这一条伪轨迹已经完成了任务”后续动作不再有意义。这个细节的重要性怎么强调都不过分。如果只换目标不换奖励Q 网络会学到“任意动作在新目标下都没奖励”重标注就白做了如果只看奖励不纠正 done时序差分更新会把一条伪成功轨迹的后续动作也当成有效经验导致目标价值被高估训练末期出现严重的策略震荡。我见过不少开源实现就是栽在这两个标志位的不同步上。以下是训练时单条轨迹重标注并入库的核心伪代码片段我按自己的习惯做了简化和注释def hindsight_store(episode_transitions, achieved_states, original_goal, k4): episode_transitions: list of (s, a, r, s_next, done, goal) achieved_states: 轨迹中每个时间步实际到达的状态集合 # 原始目标样本先入库 replay_buffer.add_all(episode_transitions) # 从轨迹中选 k 个虚拟目标 # future 策略只允许选择当前时间步之后的状态 for _ in range(k): virtual_goal random.choice(achieved_states) new_transitions [] for t, (s, a, _, s_next, _, _) in enumerate(episode_transitions): # 在新目标下重算稀疏奖励与 done reward 0.0 if is_goal_reached(s_next, virtual_goal) else -1.0 done is_goal_reached(s_next, virtual_goal) # 注意这里存的是新目标不是原目标 new_transitions.append((s, a, reward, s_next, done, virtual_goal)) if done: # 伪轨迹提前结束后续状态不再使用 break replay_buffer.add_all(new_transitions)注意代码里那个if done: break。虚拟目标是从轨迹后续状态里选的所以很可能在某个时间步就已经达成了。如果达成了还继续把后面的转移存进缓冲区人为制造了“轨迹成功后又变失败”的矛盾信号网络会被带偏。这个细节在我早期复现里没有处理训练曲线一度非常诡异后面排查了很久才发现是这儿的问题。3. 实操要点关键参数与训练调校复现 HER 并不难但要复现到“论文级别”的成功率参数调校比想象中更考验耐心。这一部分我会按自己的实验流程来写重点说算法选型、网络结构、目标空间处理和几个关键超参数的实测感受。3.1 算法底座为什么我选 DDPG 而不是 SACHER 的核心价值在于“给 off-policy 算法提供有效样本”所以底层算法可以自由搭配。我最终选择 DDPG 主要有三个考虑。第一DDPG 是 actor-critic 结构适合连续动作空间Fetch 系列的机械臂控制输出就是四维连续力矩/位置增量DQN 这类离散动作算法根本用不了。第二DDPG 的结构比 SAC 简单网络少训练稳定性的变量更容易控制我复现的初衷就是先把 HER 本身跑透不希望底层算法引入太多额外不确定性。第三在稀疏奖励任务上 DDPGHER 有大量公开实验数据可对照方便我在每步实验里确认自己没有跑偏。如果你追求更高样本效率换成 SAC 完全可以HER 的重标注逻辑完全不变。我的建议是如果你是第一次上手先用 DDPG 把 HER 的主干逻辑跑通再考虑换 SAC 锦上添花。3.2 网络输入状态和目标拼在一起Fetch 环境里智能体观测通常包含机械臂末端位置、物体位置、相对目标的距离等。HER 要求我们显式区分“状态”和“目标”。实现时我的输入是状态和目标直接拼接成一个向量喂给 Q 网络和策略网络。import torch.nn as nn class Actor(nn.Module): def __init__(self, obs_dim, goal_dim, action_dim): super().__init__() self.fc1 nn.Linear(obs_dim goal_dim, 256) self.fc2 nn.Linear(256, 256) self.mu nn.Linear(256, action_dim) def forward(self, obs, goal): x torch.cat([obs, goal], dim-1) x torch.relu(self.fc1(x)) x torch.relu(self.fc2(x)) return torch.tanh(self.mu(x))有一个新手容易踩的坑没有把目标拼接进网络输入而是把目标信息当成奖励的一部分写进环境。这样做等于让网络只从标量奖励里间接推断目标信息量严重不足顶层特征根本无法区分“不同目标下应该采取不同策略”。我在最初实验时就走过这条路训练出来的机械臂只会做同一个动作模式换个目标位置就彻底失灵。正确做法是让目标作为输入特征直接参与网络前向传播让网络自己去学习“目标位置变化和动作输出之间的关系”。3.3 关键超参数表下面这组参数是我在多个环境上试过都比较稳的组合可以直接作为起点参数取值说明目标重标注比例 k4每条轨迹额外生成 4 条重标注样本虚拟目标采样策略future取当前时间步之后的随机状态回放缓冲区大小1e6要能容纳足够多的重标注样本每次更新 batch 数40每步环境交互后更新 40 次网络batch size512偏大的 batch 能让重标注样本更稳定actor 学习率1e-3使用 Adam 优化器critic 学习率1e-3使用 Adam 优化器折扣因子 gamma0.98Fetch 任务轨迹短不用太大探索噪声OU 噪声或高斯噪声标准差 0.2高斯噪声更省事目标判定阈值0.05 米与官方环境一致看这个表你会发现每次更新的网络次数非常多40 次更新对一条环境交互而言是很大的比例。这是 HER 的刻意设计重标注本来就是靠多倍样本堆出来的所以训练时要把样本利用率拉满。如果算力紧张可以降到 20 次但训练曲线会明显变慢。3.4 奖励函数设计二值奖励就够HY 最大的好处就是让你摆脱对奖励塑形的依赖。在 Fetch 系列里我只用了最简单的方式机械臂末端或物体与目标的距离小于 0.05 米奖励 0否则奖励 -1。全程没有距离惩罚、没有动作惩罚、没有分阶段奖励。有些人会担心这样学习效率太低实际跑下来完全不用担心。HER 靠重标注制造的海量伪成功样本天然弥补了稀疏二值奖励缺乏梯度的缺陷。手工塑形奖励反而可能引入偏差比如你设计了一个“越靠近目标奖励越高”的函数智能体可能会学到停在目标附近但不完成任务的最优解因为这样既拿到接近奖励又避免了操作失败惩罚。这类“奖励黑客”行为我见过不止一次。4. 复现全流程与实验记录这一部分我会把完整的复现过程写下来从环境搭建到实验结果对比。整个过程我用的是 MuJoCo 物理引擎和 OpenAI Gym 的 Fetch 环境硬件只是一张消费级显卡整个训练大概跑了一天多。4.1 环境准备与基线设定首先是环境版本。Fetch 系列环境对 MuJoCo 版本有兼容要求建议直接用 Gymnasium 里维护的新版本接口避免老版本接口在 Python 3.8 下报各种弃用警告。安装依赖时注意不要手滑装错版本我之前因为 MuJoCo 版本过新导致模型加载失败白折腾了一个下午。基线实验我做了两个一个完全没有 HER纯 DDPG另一个是 HERDDPG。两者使用完全相同的网络结构和超参数唯一的变量就是是否开启目标重标注。这样的对照实验很有必要否则你很难判断训练曲线的提升到底来自 HER 还是来自其他调参运气。4.2 核心模块实现Future 策略与缓冲区入库在 2.3 节我已经给出了重标注的核心伪代码这里补一下实际训练主循环的骨架。每个 episode 结束后先提取轨迹里实现过的状态集合和转移列表然后同时完成“原始样本入库”和“重标注样本入库”。def train_one_epoch(env, agent, replay_buffer, k4, noise_std0.2): episode_transitions [] achieved_states [] obs, _ env.reset() goal obs[desired_goal] obs obs[observation] done False while not done: action agent.select_action(obs, goal, noise_stdnoise_std) next_obs, reward, terminated, truncated, info env.step(action) done terminated or truncated episode_transitions.append((obs, action, reward, next_obs, done, goal)) achieved_states.append(info[achieved_goal]) obs next_obs if done: break hindsight_store(episode_transitions, achieved_states, goal, kk) for _ in range(40): batch replay_buffer.sample(512) agent.update(batch)现实里还需要处理 truncated 和 terminated 的区分环境因为超时被截断时轨迹不完整done 置 True 会导致价值估计偏差所以我在重标注入库时做了专门处理只有真正达成虚拟目标才置 done超时截断一律不当作成功。这个小细节对末端阶段收敛很关键。4.3 实验曲线解读三个环境的差异FetchReach 是最简单的任务机械臂只需要把末端移动到目标点。HERDDPG 大约 10 万步就已经能稳定达到接近 100% 的成功率。这个环境适合拿来验证整套代码逻辑是否正确因为耗时短改任何参数都能快速看到结果。FetchPush 要稍微复杂一些机械臂需要把桌面上的物体推到目标位置。因为存在物体和机械臂之间的接触动力学前半段训练曲线会有较长的“平台期”大约在 30 万步之前成功率一直是 0然后是突然的上升。这种现象在稀疏奖励任务里很典型很多人会在平台期误判为训练失败提前终止实际上只要网络没有发散再坚持一下曲线就会起来。FetchPickAndPlace 是三个环境里最难的机械臂要抓起物体再放到目标点。我实验里大约 120 万步才突破 20% 成功率最终稳定在 80% 左右。这个任务对目标重标注的依赖最大因为“抓起物体”是一个中间技能如果不用事后视角纯靠稀疏奖励几乎不可能学会这种多阶段操作。后期我又把 k 提高到 6成功率还能再往上走几个点。4.4 对比实验HER 开关的效果差异我的对比实验里没有 HER 的 DDPG 在三个环境上全部“报废”FetchReach 跑 20 万步成功率仍为 0FetchPush 和 FetchPickAndPlace 直到我终止训练都在 0 附近徘徊。而加了 HER 之后同样的网络、同样的探索噪声全部环境都能跑出有效学习信号。这个对照组直观地说明了 HER 的价值它做的不是“改进网络”而是“改造数据”。网络架构和优化器都没变只是数据里多了重标注目标的伪成功样本训练就从“什么都学不到”变成了“稳定收敛”。5. 踩坑记录与问题排查实录复现 HER 的过程不可能一帆风顺。我把自己遇到过、且在实际群里也看到别人反复踩的坑整理成了这一节每条都附带了排查方式和最终解决方案。5.1 重标注目标导致训练方差爆炸第一次把 k 调到 8 时我观察到训练前期的成功率曲线抖动非常剧烈甚至有时候已经收敛到 60% 又突然跌回 10%。复盘后我意识到问题不在网络不稳定而在重标注比例过高。k8 意味着缓冲区里伪目标样本占比极大这些样本的目标分布和真实智能体正在探索的目标分布严重不一致相当于频繁切换训练任务任何网络都会被晃晕。解决办法是降回 k4同时把训练目标函数里的 critic 更新频率适当降低给网络多一点时间适应新数据。如果你在项目里必须用更大的 k可以考虑把伪成功样本的优先级稍微调低让它们不要过于拥挤地在每次 batch 里集中出现。5.2 缓冲区里的“旧目标污染”另一类隐蔽问题是HER 把重标注样本和原始样本存在同一个缓冲区但不同时间点采样的样本目标分布差异很大。前期智能体还没有学会任何技能时重标注样本的目标大多是随机位置后期智能体已经会抓取时新样本的目标则贴近真实任务区域。旧的随机目标样本如果一直留在缓冲区里会拖慢后期收敛速度。一个简单有效的做法是限制缓冲区里重标注样本的保存时间或分两个缓冲区分别存原始样本和重标注样本采样时按比例混合。我后来在工程实现里采用了后者效果不错而且可配置性更强方便对不同目标分布做独立控制。5.3 训练中途 loss 飞到 NaN这个坑与 HER 本身无关但在 MuJoCo 环境里尤其常见当机械臂末端和物体接触时物理仿真可能出现瞬时不稳定导致观测值里出现极大数值。这些极大数值经过网络前向传播和梯度反传很容易把权重变成 NaN。排查时要先确认 NaN 到底是网络结构问题还是输入数据问题。我的经验是如果在训练脚本里加一条观测值范围检查发现 NaN 之前 env.step 返回的观测里就已经有 inf那基本就是物理仿真的问题。解决办法是给观测值做 clip强制限制在合理范围比如机械臂位置和速度都限制在 [-10, 10] 以内。轻微 clip 不会影响学习精度却能极大提升训练稳定性。5.4 常见问题速查表现象可能原因检查方式与解决训练曲线长期为 0目标没有拼进网络输入检查 actor/critic 输入维度前期抖动后期掉坑缓冲区伪目标比例过高降低 k或分区缓冲采样后期收敛但不够高done 标志没有跟着重算检查重标注样本的 done 字段loss 为 NaN观测值包含 inf对观测值做 clip 和合法范围检查成功率反弹慢超时截断被当成成功区分 terminated 和 truncated换随机种子效果差异大模型敏感度偏高固定随机种子或调整目标判定阈值6. 个人体会与后续扩展方向HER 这个项目给我最大的启发其实已经超出了算法本身。“事后视角”这个思路在现实工程里同样好用代码出了 bug与其盯着报错信息反复试不如先复盘一下“实际发生的状态”和“预想中的状态”到底差在哪里。很多问题在事后看都是清晰的这句话是真的。在强化学习的框架里HER 也和另一个经典概念“课程学习”很契合。因为虚拟目标是从轨迹实际状态里抽出来的天然构成了一条从易到难的课程早期智能体只会做随机动作它的“事后目标”就停留在很简单的状态随着能力提升事后目标也自动变得更接近真实任务目标。你可以把这个特性理解成一种无需人工设计的自动课程这一点特别适合前端训练冷启动。我后来用同样的思路做过一个扩展实验把 HER 的重标注逻辑从单智能体搬到一个多智能体协作场景里让一个智能体的“失败轨迹”成为另一个智能体的“演示教材”效果意外地不错。如果你有兴趣建议也从 FetchReach 起步先把这套机制吃透再往自己的任务上迁移。最后分享一个我后来一直沿用的习惯每次跑 HER 实验之前先跑一个 10 万步的微型实验把目标重标注代码里“奖励与 done 是否同步更新”这个逻辑单独写个单元测试。这个检查只需要几分钟却能避免绝大多数训练几个小时后才发现方向错误的惨案。别问我怎么知道要这么做的。