
做强化学习落地的人多多少少都会碰到这种尴尬局面模型跑了几天成功率曲线纹丝不动。不是代码写错了是奖励太稀疏了。机械臂伸过去推箱子转了几百个回合连一块积木都没碰到目标位置于是智能体一个正反馈都没收到。这种场景下我第一个会想到的办法就是hindsight——事后经验回放Hindsight Experience Replay, HER。它像一个“事后诸葛亮”把每次失败的轨迹都重新标注成一条可用的成功样本。这篇文章是我基于HER做的一个稀疏奖励机械臂推箱子项目的完整复盘从原理、代码到踩坑一次讲清楚适合正在和稀疏奖励搏斗的RL工程师也适合刚接触多目标强化学习、想搞清楚HER到底在做什么的初学者。1. 项目背景稀疏奖励任务为什么会把强化学习逼到死角1.1 一个“要么成功要么白干”的典型场景我用的是OpenAI Gym里的FetchSlide环境任务设定非常简单一个七自由度机械臂桌面上有一块滑行物体给定一个目标位置坐标机械臂要把这个物体推到目标点。每一步环境返回当前状态如果物体最终停在目标区域附近奖励1否则奖励0。问题出在这个“否则奖励0”上。目标区域就一小块机械臂和物体初始位置完全随机纯靠随机探索去命中那个小目标概率低得可以忽略。我跑标准DDPG做对照组几十万步训练下来成功率始终贴着0%。不是算法实现的问题是经验池里的样本几乎全部是零奖励梯度信号偶尔出现一次也会被淹没在海量无信息样本里。这种场景在真实工程里太常见了。机器人插拔、积木堆叠、物体搬运甚至自动驾驶变道很多任务都没有中间过程奖励。游戏里那种每走一步都有得分的密集反馈在真实物理任务里反而是少数。一旦任务变成“要么成功要么白干”传统强化学习的样本效率就会断崖式下跌。用数学一点的话来说当奖励函数几乎处处为0、只在某个测度极小的集合上取正值时随机策略采样到的正样本数量趋近于零策略梯度估计的方差会大得离谱甚至根本没有有效梯度可学。这不是调参能解决的问题需要从经验构造层面换思路。1.2 从goal-conditioned policy到“事后目标”一个想法如何救场要理解HER先要理解目标条件强化学习goal-conditioned RL。普通强化学习里智能体输入是状态s输出动作a而在目标条件强化学习里输入里多了一个目标g策略变成π(a|s, g)奖励也变成r(s, g, a)。换句话说智能体被训练成“指哪打哪”给定不同目标都能执行相应动作。HER的关键洞察非常朴素你有一条完整轨迹虽然它没能满足原始目标g但它一定满足某个“事后目标g”。把g替换成目标重放经验这条轨迹对“完成g”这个任务来说就是一次完整的成功能拿到1奖励。拿考试类比最容易理解。你想考100分结果考了78分。从“考100分”这个目标看你是失败的。但从“达到78分这个水平”来看你无疑是成功的。如果把这次经验标注成“考到78分成功的例子”下次复习就能抓住哪些题做对了、哪些分是怎么拿到的。多试几次从78到85再到92慢慢逼近100。HER就是这么干的不断从实际达到的状态里找“小成功”让目标一点点逼近真实目标。这也是hindsight这个词的妙处。人在事后总能找到理由说“我早该料到”但这种后见之明往往被认为是没用的。HER偏偏把这种后见之明变成了训练信号——过去失败的轨迹在事后目标下摇身一变成了宝贵的成功样本。2. Hindsight的核心原理把失败轨迹重写成有效样本2.1 三个关键步骤拆解HER每次拿到一条完整episode会做三件事。第一步保留原始经验。把(s_t, a_t, r_t, s_{t1})原样存入经验池目标仍是原始g奖励按原始规则计算。这一步保证原始任务的目标分布不会被破坏。第二步选择替代目标g。从当前轨迹里挑一个“实际达到过的状态”最常用的就是轨迹最终状态把这个状态当作新的目标。这里有个隐含假设最终状态是这条轨迹“能走到的极限”拿它当目标重标记出来的奖励信号是最丰富的。第三步重标记并追加存储。用g重新计算每一步的奖励再把重标记后的transition追加进经验池。假设奖励函数是稀疏二值的def compute_sparse_reward(achieved_goal, desired_goal, tolerance0.05): distance np.linalg.norm(achieved_goal - desired_goal, axis-1) return 1.0 if distance tolerance else 0.0用g替换目标后因为轨迹最终状态确实达到了g重标记轨迹的最后一步奖励为1最后几步也可能因为距离足够近而拿到1。于是原来一整条全0轨迹变成了尾部带正信号的轨迹。“差一点就成功”的经验被有效地利用起来了。从数据结构上看重标记后的transition要改三个字段desired_goal、reward、done。其中done是最容易被忽略的——如果重标记后的状态已经达成目标那么这个transition是不是应该标记为终止必须一致更新不然后续的Q学习和自举更新会出错。这个坑我后面会专门讲。2.2 替代目标的选择策略final、future、episode、randomHER论文和社区实现里一般有四种选择替代目标的策略我整理了一个对比表。策略选目标方法优点缺点我的建议final取轨迹最后一个状态作为g实现零成本目标一定可达信号稳定目标多样性低可能过拟合单点先跑这个future当前时间步之后k步内的状态作为g目标更多样长轨迹任务中学习更快k需要调太大目标太远和final做对比episode同一条轨迹里随机挑一个状态目标覆盖范围广随机性强但离当前状态可能太远不建议单独用random从全局经验池里随机挑一个状态多样性最强目标通常太远稀疏问题缓解有限谨慎使用先说final。这是最省事也最稳妥的起点因为轨迹的实际最终状态天然是可达的重标记后的目标是“触手可及”的学习信号最强。我大部分项目都是先用final跑通再考虑是否换future。future策略的逻辑是对某一步t从t1到tk1之间随机取一个未来状态当目标。这样做的好处是目标不再局限于终点整条轨迹不同阶段都有对应的“局部目标”目标分布更丰富。但k的取值很关键k太小目标太近k太大目标又太远稀疏问题可能复现。我在FetchSlide上试过k3效果不错但这和任务的长度与状态空间尺度强相关换任务要重新扫。episode和random策略在原理上也有意义但实践中我很少单独用。它们的共同问题是随机目标很可能离当前状态很远重标记后大量的transition依然是0奖励等于白做。如果想把它们用起来通常要混合final或future一起用比如80%的时间用future20%的时间用final效果才比较可控。2.3 一次重标记的完整计算过程为了说清楚我直接走一遍具体流程。假设轨迹长度为T50原始目标是坐标点[1.0, 2.0, 0.0]。这条轨迹跑完之后物体最终停在[0.3, 0.4, 0.1]。在FetchSlide环境里轨迹最后一步的achieved_goal就是这个[0.3, 0.4, 0.1]。算法把g [0.3, 0.4, 0.1]写入desired_goal然后对每一步重新算reward最后一步achieved_goal恰好等于g距离为0reward1倒数第二步achieved_goal离g可能只有0.02如果小于容差0.05reward也1更早的几步机械臂还在别处和g的距离超过0.05reward0。于是原来一条全部是0奖励的轨迹在尾部出现了正奖励段。这段正奖励对应的动作方向就是“把物体推向目标方向”的正确动作策略能从中学到东西了。细心的读者会发现重标记的目标和原始目标分布是完全不同的。这会不会让策略学偏实际上不会因为HER同时保留了原始经验重标记经验只是扩大了经验池中正样本的比例并没有替代原始目标。训练时网络从经验池里采样两种目标都看得到最终学会的是“无论给什么目标都能向目标方向运动”这正好是目标条件策略需要的泛化能力。顺便说一句HER一般搭配off-policy算法使用因为重标记经验是在episode结束后才生成的和当前行为的策略分布之间存在偏移on-policy算法在理论上会受到偏差影响。DDPG、TD3、SAC这类带经验池的算法都是天然合适的载体。我项目里选的是DDPG接下来详细说。3. 实战项目基于HER的机械臂推箱子实现3.1 环境配置与算法选型环境我用的是Gym里的FetchSlide-v1这个环境的observation结构非常友好明确区分了三块内容observation是机械臂状态和物体的部分信息desired_goal是目标任务坐标achieved_goal是当前物体实际坐标。有了achieved_goalHER重标记时直接取它就行不需要自己额外做状态估计。算法选型上我为什么选DDPG而不是PPO原因有两个。第一HER的核心是在经验池里“造假”成功样本必须要有一个能吃下这些重标记经验的大容量经验池DDPG天然满足。PPO这类on-policy算法每一轮训练完就扔掉旧数据重标记经验的利用率很低。第二DDPG在连续控制任务上实现简单、调试成本低。当然用TD3或SAC替换也完全没问题只需要额外处理目标网络更新频率和熵正则收益会更稳定。我这次为了控制变量先用DDPG。网络结构上actor和critic都是两层MLP每层256个神经元激活函数用ReLU。critic在隐藏层后面我加了LayerNorm这一点后面会解释为什么重要。其余配置如下经验池容量1M条transitionbatch size256actor和critic学习率1e-3Adam优化器探索噪声Ornstein-Uhlenbeck过程σ0.2HER替代目标策略final每条轨迹额外生成4份重标记样本折扣因子γ0.98。这里想特别提一下n_extra_goals4这个参数。它表示每条原始轨迹除了自身之外额外生成4份替代目标版本的经验。网络每次训练时从经验池里采样这4份重标记经验会被均匀采到保证正样本比例不会太低。如果设成0HER就没有效果设得太大经验池会被重标记样本淹没原始目标分布被稀释也不行。3.2 核心函数实现hindsight replayHER的核心逻辑浓缩在“如何给一段轨迹生成额外目标并重标记”这个函数里。我把项目里用的核心实现简化后贴出来顺便注释了关键地方。import numpy as np def hindsight_replay(trajectory, her_ratio0.8, strategyfuture, future_k3): trajectory: list of dict每个元素包含 obs, achieved_goal, action, reward, done, next_obs, next_achieved_goal 返回重标记后的额外transition列表 horizon len(trajectory) extra [] for t, item in enumerate(trajectory): # 根据her_ratio决定这条transition要不要额外做一次目标重标 if np.random.uniform() her_ratio: continue # 选取新目标 if strategy final: new_goal trajectory[-1][achieved_goal] elif strategy future: upper min(t future_k 1, horizon) future_idx np.random.randint(t 1, upper) new_goal trajectory[future_idx][achieved_goal] else: # episode new_goal trajectory[np.random.randint(horizon)][achieved_goal] # 用新目标重算奖励和done new_reward compute_sparse_reward(item[achieved_goal], new_goal) new_done 1.0 if new_reward 0 else 0.0 extra.append({ obs: concat_goal(item[obs], new_goal), action: item[action], reward: new_reward, done: new_done, next_obs: concat_goal(item[next_obs], new_goal), }) return extra这个函数有几个细节必须注意。第一concat_goal要把目标g拼接到observation末尾。策略网络输入本质上还是s和g的拼接向量所以重标记时不仅要把新的desired_goal写进transition还要把它拼到obs和next_obs里去让网络能感知到新目标。我项目里的做法是obs维度原始状态维度目标维度统一用numpy.concatenate拼接。第二compute_sparse_reward在稀疏奖励下就是简单的距离阈值判断。Fetch环境默认容差是0.05保持二值逻辑即可。有些实现会在零奖励基础上加一个小的负惩罚来防止agent“偷懒不动”但HER的核心逻辑不受影响二值奖励反而是最干净的表达。第三new_done必须和new_reward保持一致。如果奖励为1说明这个transition对应的下一状态已经实现了目标应该标记为done1。如果不改donecritic在自举更新时会把Q值从“已成功”状态继续往后推学出来的Q函数就错位了。这是HER最隐蔽的坑我后面会展开说。训练循环里HER的介入点在episode结束之后。伪代码如下for episode in range(max_episodes): trajectory [] obs env.reset() goal env.goal # 采样一个原始目标 for t in range(horizon): action actor(concat_goal(obs, goal)) exploration_noise next_obs, reward, done, info env.step(action) trajectory.append(make_transition(obs, info[achieved_goal], action, reward, done, next_obs)) obs next_obs # HER核心入口先入库原始经验再入库重标记经验 replay_buffer.add(trajectory) for extra_trans in hindsight_replay(trajectory, strategyfuture, future_k3): replay_buffer.add(extra_trans) # 然后正常做actor-critic更新 train_actor_critic(replay_buffer, batch_size256)实际项目里我会把her_ratio设成0.8也就是每条transition有80%的概率额外生成一份重标记样本。轨迹长度为50时一条episode大约能生成40份额外transition经验池很快就能攒起足够多的正样本。3.3 训练结果与基线对比我在FetchSlide上跑了两组实验一组是标准DDPG无HER一组是DDPGHER。训练步数设定为100万步每10万步做一次评估评估时关闭探索噪声每个评估回合重新随机初始化环境。无HER那一组成功率曲线几乎就是一条0%水平线。这倒是意料之中但也让我确认了一件事在极端稀疏奖励下DDPG这种off-policy算法确实学不到东西。加了HER之后大约到30到40万步成功率开始出现明显上升50到60万步时成功率能稳定在40%到60%之间训练到100万步时最终稳定在70%左右。注意这个数字只是我这一次实验的结果随机种子换一下浮动会很大但趋势是完全稳定的HER把原本完全学不动的任务变成了真正可学的任务。时间开销上HER带来的额外计算量很小。每条episode多生成4份重标记轨迹核心操作只是算向量距离对整体训练开销可以忽略不计。真正的成本是经验池容量变大以及训练时多采到了一些重标记样本但这些成本跟“学不动”的浪费比起来完全值得。4. 实操中的坑调试实录与问题排查速查4.1 训练刚开始Q值就崩先从归一化找原因第一次跑HER我遇到的最棘手问题是critic loss疯涨成功率曲线像锯齿一样跳动。我一开始怀疑是HER重标记后的样本分布有问题后来查来查去发现罪魁祸首是观测值范围不统一。FetchSlide的observation里机械臂关节角度范围在[-π, π]之间物体位置坐标范围大概在[0, 1.2]之间目标坐标也是类似的范围。把这些维度直接拼成一个向量目标维度的数值尺度明显偏小而在critic里计算Q值时目标偏差项的梯度会被角度维度的波动淹没训练自然不稳定。解决方案有两个一个是对observation和goal的各维度做归一化另一个是在网络里加LayerNorm。我最后选择在critic隐藏层之后加LayerNorm效果立竿见影loss立刻降下来了。之所以不用BatchNorm是因为经验池里的样本来自不同历史阶段的策略统计特性不断漂移BatchNorm在RL里很容易出问题。这个坑也提醒我HER的重标记会让经验分布发生偏移网络结构上一定要预留足够强的归一化能力否则HER带来的正样本反而会被数值问题抵消。4.2 忘记重标记done一个隐蔽的开发陷阱我前面反复提到done要跟着new_goal一起变这个坑我实际踩过而且踩得很深。当时实现里写了new_reward compute_sparse_reward(...)但done直接沿用了原始transition的done。结果是训练时成功率曲线看起来不错一关探索噪声做测试就全线崩溃。原因很微妙。如果某个transition重标记后奖励为1、但done还是0critic在自举更新时就会认为这个状态的Q值不止是当前奖励1还要加上未来的折扣回报γ * Q(s, g)。而实际上这个状态已经是成功状态未来回报应该是0。这样累积下来Q函数在目标附近出现错误的高峰和凹坑策略一旦按照Q函数贪心选动作就会选到那个“虚假高价值”的方向测试自然崩。这个现象特别有欺骗性因为训练时有探索噪声在“兜底”噪声掩盖了策略对Q函数的依赖一旦测试关掉噪声策略完全按Q输出动作问题就彻底暴露了。修复就一行代码new_done 1.0 if new_reward 0 else 0.0。但这一行值很多个小时的调试时间。4.3 测试阶段策略“失智”探索噪声也要管另一个困扰我很久的问题训练曲线明明很好测试成功率却忽高忽低这周跑出来60%下周跑出来20%完全没法解释。排查到最后问题出在探索噪声上。DDPG训练时用OU噪声在动作上加扰动但如果噪声方差在整个训练过程中都保持一开始的大小训练后期会把actor已经收敛的动作输出全部盖掉。测试时一关噪声策略输出和训练时看过的大不相同等于换了个策略在跑。解决思路有两个。第一训练后期逐步衰减OU噪声方差从0.2降到0.05让策略逐渐适应“无噪声的干净动作”。第二做对比实验时固定随机种子确认成功率波动不是随机性造成的。现在很多代码库直接用高斯噪声加衰减或者干脆改用SAC的熵正则思路都是同一个训练与测试的action分布要尽量对齐。这个坑其实和HER没有直接关系但它在我做HER实验时严重干扰了我对结果的判断。建议所有做RL实验的同学先固定种子把基线跑稳再动HER不然你根本分不清成功率变化是算法的功劳还是噪声的锅。4.4 问题排查速查表我把踩过的坑整理成一个速查表方便大家对照排查。现象可能原因排查思路critic loss疯涨、成功率曲线锯齿状observation各维度尺度不统一缺少归一化检查obs各维度范围给critic加LayerNorm训练正常、测试崩溃done标志没有跟着重标记一起更新核对new_done与new_reward是否一致测试成功率忽高忽低OU噪声方差过大且没有衰减逐步衰减噪声固定种子复测学习曲线长期0%HER没生效或extra sample没进经验池确认hindsight_replay的返回值确实被add进replay buffer成功率卡在50%上不去目标分布太单一final策略的多样性不足切换future策略扫描k参数5. 从推箱子到更多场景HER的适用边界5.1 机器人操作类任务最经典也是最好用的场景HER最经典的落地场景就是机械臂操作。Fetch系列的推箱子、滑动、抓取方块堆叠物体搬运这些任务天然满足HER的两个前提目标可以定义为状态空间里的一个点成功判定可以写成距离阈值。在这些任务里HER带来的训练效率提升是数量级的。原本可能需要百万级样本才能学会的抓取加上HER后可能只要十万级样本就有明显效果。原因也好理解抓取任务中“差一点就成功”的状态太常见了机械臂可能只是偏了几个厘米这些经验在传统RL里全是负样本但在HER眼里全是宝贵的正样本。如果项目里要上真实机械臂有一点要特别注意HER重标记的目标直接来自轨迹的实际状态这就要求状态估计足够准。真实场景里视觉定位难免有噪声如果替代目标根本不在真实可达状态流形上重标记的经验就会失真。安全做法是先在仿真里把HER调通再迁移到真实机械臂同时把感知模块单独做好。5.2 离线强化学习里的数据增广HER在离线强化学习里也有很好的应用。离线数据集往往包含大量“失败轨迹”直接训练offline算法时这些轨迹全被当成负样本或低价值样本数据集的价值被浪费了。用HER重标记之后原本失败的轨迹可以补充出大量“接近成功”的目标。比如数据集里有一条机器人尝试抓取但没抓稳的轨迹原始标签是失败但用轨迹末端物体的位置作为替代目标重新标注这条轨迹就是一个成功的“把物体推到某个位置”的演示。策略见过更多“再进一步就能成功”的样例学习出来的行为会更可靠。这个思路后来派生出一系列目标条件模仿学习和数据增强的方法但核心始终是hindsight的直觉只要有一条轨迹它必然对某些目标是成功的这些目标就是天然的学习信号。5.3 什么时候别迷信HER虽然HER很香但也不是所有稀疏奖励问题都能靠它解决。如果一个任务根本没有清晰的目标状态HER就无从下手。比如“画面里出现一张桌子”这种语义目标你需要一个状态向量来当目标如果只能靠图像判断重标记就没法直接做。另一个问题是目标维度极高的情况比如目标是一整张图片采样到的未来状态大概率落在低概率流形上重标记后的目标分布会逐渐偏离原始任务目标学习效果反而变差。我个人的项目体会是在能用状态向量表示目标、且成功判定能用距离阈值表达的任务里遇到稀疏奖励先试HER十次有八次有效。但千万别把它当万能药有些任务用分层强化学习或者额外加课程学习效果会好得多。判断标准很简单重标记出来的“成功样本”是否仍然合理、是否还能指向原始目标的达成路径如果是就放心用如果不是果断换思路。最后分享一个我实际用下来的感受。HER的代码量真的很少核心就一个重标记函数难的是理解为什么改一下目标就能让整条学习曲线从0%走到70%。我踩过所有坑之后最大的收获是经验应该是可重写的资产而不是既定事实。这个思路后来在我处理其他数据增强问题时也一直在用。如果你正在被稀疏奖励折磨建议别急着加复杂算法先找个Fetch环境跑一版基础HER亲眼看看成功率从0涨起来的过程比看任何理论讲解都管用。