做强化学习落地的人,多多少少都会碰到这种尴尬局面:模型跑了几天,成功率曲线纹丝不动。不是代码写错了,是奖励太稀疏了。机械臂伸过去推箱子,转了几百个回合,连一块积木都没碰到目标位置,于是智能体一个正反馈都没收到。这种场景下,我第一个会想到的办法,就是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_{t+1})原样存入经验池,目标仍是原始g,奖励按原始规则计算。这一步保证原始任务的目标分布不会被破坏。
第二步,选择替代目标g'。从当前轨迹里挑一个“实际达到过的状态”,最常用的就是轨迹最终状态,把这个状态当作新的目标。这里有个隐含假设:最终状态是这条轨迹“能走到的极限”,拿它当目标,重标记出来的奖励信号是最丰富的。
第三步,重标记并追加存储。用g'重新计算每一步的奖励,再把重标记后的transition追加进经验池。假设奖励函数是稀疏二值的:
def compute_sparse_reward(achieved_goal, desired_goal, tolerance=0.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、random
HER论文和社区实现里,一般有四种选择替代目标的策略,我整理了一个对比表。
| 策略 | 选目标方法 | 优点 | 缺点 | 我的建议 |
|---|---|---|---|---|
| final | 取轨迹最后一个状态作为g' | 实现零成本,目标一定可达,信号稳定 | 目标多样性低,可能过拟合单点 | 先跑这个 |
| future | 当前时间步之后k步内的状态作为g' | 目标更多样,长轨迹任务中学习更快 | k需要调,太大目标太远 | 和final做对比 |
| episode | 同一条轨迹里随机挑一个状态 | 目标覆盖范围广 | 随机性强,但离当前状态可能太远 | 不建议单独用 |
| random | 从全局经验池里随机挑一个状态 | 多样性最强 | 目标通常太远,稀疏问题缓解有限 | 谨慎使用 |
先说final。这是最省事也最稳妥的起点,因为轨迹的实际最终状态天然是可达的,重标记后的目标是“触手可及”的,学习信号最强。我大部分项目都是先用final跑通,再考虑是否换future。
future策略的逻辑是:对某一步t,从t+1到t+k+1之间随机取一个未来状态当目标。这样做的好处是目标不再局限于终点,整条轨迹不同阶段都有对应的“局部目标”,目标分布更丰富。但k的取值很关键,k太小目标太近,k太大目标又太远,稀疏问题可能复现。我在FetchSlide上试过k=3效果不错,但这和任务的长度与状态空间尺度强相关,换任务要重新扫。
episode和random策略在原理上也有意义,但实践中我很少单独用。它们的共同问题是:随机目标很可能离当前状态很远,重标记后大量的transition依然是0奖励,等于白做。如果想把它们用起来,通常要混合final或future一起用,比如80%的时间用future,20%的时间用final,效果才比较可控。
2.3 一次重标记的完整计算过程
为了说清楚,我直接走一遍具体流程。
假设轨迹长度为T=50,原始目标是坐标点[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',距离为0,reward=1;
- 倒数第二步,achieved_goal离g'可能只有0.02,如果小于容差0.05,reward也=1;
- 更早的几步,机械臂还在别处,和g'的距离超过0.05,reward=0。
于是原来一条全部是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_goal,HER重标记时直接取它就行,不需要自己额外做状态估计。
算法选型上,我为什么选DDPG而不是PPO?原因有两个。
第一,HER的核心是在经验池里“造假”成功样本,必须要有一个能吃下这些重标记经验的大容量经验池,DDPG天然满足。PPO这类on-policy算法每一轮训练完就扔掉旧数据,重标记经验的利用率很低。
第二,DDPG在连续控制任务上实现简单、调试成本低。当然,用TD3或SAC替换也完全没问题,只需要额外处理目标网络更新频率和熵正则,收益会更稳定。我这次为了控制变量,先用DDPG。
网络结构上,actor和critic都是两层MLP,每层256个神经元,激活函数用ReLU。critic在隐藏层后面我加了LayerNorm,这一点后面会解释为什么重要。其余配置如下:
- 经验池容量:1M条transition;
- batch size:256;
- actor和critic学习率:1e-3,Adam优化器;
- 探索噪声:Ornstein-Uhlenbeck过程,σ=0.2;
- HER替代目标策略:final,每条轨迹额外生成4份重标记样本;
- 折扣因子γ:0.98。
这里想特别提一下n_extra_goals=4这个参数。它表示每条原始轨迹除了自身之外,额外生成4份替代目标版本的经验。网络每次训练时,从经验池里采样,这4份重标记经验会被均匀采到,保证正样本比例不会太低。如果设成0,HER就没有效果;设得太大,经验池会被重标记样本淹没,原始目标分布被稀释,也不行。
3.2 核心函数实现:hindsight replay
HER的核心逻辑浓缩在“如何给一段轨迹生成额外目标并重标记”这个函数里。我把项目里用的核心实现简化后贴出来,顺便注释了关键地方。
import numpy as np def hindsight_replay(trajectory, her_ratio=0.8, strategy="future", future_k=3): """ 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对应的下一状态已经实现了目标,应该标记为done=1。如果不改done,critic在自举更新时会把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, strategy="future", future_k=3): replay_buffer.add(extra_trans) # 然后正常做actor-critic更新 train_actor_critic(replay_buffer, batch_size=256)实际项目里,我会把her_ratio设成0.8,也就是每条transition有80%的概率额外生成一份重标记样本。轨迹长度为50时,一条episode大约能生成40份额外transition,经验池很快就能攒起足够多的正样本。
3.3 训练结果与基线对比
我在FetchSlide上跑了两组实验,一组是标准DDPG(无HER),一组是DDPG+HER。训练步数设定为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还是0,critic在自举更新时就会认为这个状态的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涨起来的过程,比看任何理论讲解都管用。