很多人第一次听到“hindsight”这个词,第一反应是“事后聪明”——没错,英文里就是这意思。但在强化学习领域,它对应着一个绕不开的经典方案:Hindsight Experience Replay,也就是事后经验回放。我自己第一次被它惊艳到,是被机器人抓取、推动这类稀疏奖励任务折磨得焦头烂额的时候。那时一个稍微复杂点的任务,奖励函数怎么设计都学不出来,试过reward shaping、试过curiosity,效果都是一时好一时坏。后来看到OpenAI这篇论文,才意识到问题不在探索不够,而在我们一直在丢弃那些“失败”轨迹——可失败轨迹里,其实藏着大量可用的监督信号。
这篇文章我会把HER从设计思路、算法细节、代码实现到落地排坑完整讲一遍。适合刚接触多目标强化学习的同学,也适合正在做机器人控制、游戏AI这种典型稀疏奖励场景的开发者参考。我会尽量用大白话解释原理,配合可以直接跑的代码示例,讲清楚每一步为什么这么做,以及我实际跑的时候踩过的坑。
1. 为什么稀疏奖励是强化学习的“拦路虎”
1.1 没有反馈,agent只能瞎猜
先从一个最直观的例子说起。假设有一个2D平面上的点机器人,目标点是(1.0, 1.0),动作是连续的位置偏移。奖励规则很简单:距离目标小于一定阈值,奖励+1,否则奖励0。听起来很简单的任务对吧?但你把目标点区域缩小到只占整个空间很小比例时,再试试看。
随机策略下,agent每次踩着随机动作在空间里游荡。因为目标区域太小,大多数episode从头到尾撞不到目标,所以每条轨迹的期望回报都是0。这个时候,如果用策略梯度算法,回报项几乎为0,梯度估计完全被噪声主导——也就是说,所有动作看起来都一样“好”或一样“坏”,网络根本不知道向哪个方向调整。你可以想象一个人在完全黑暗的房间里找开关,每一步都只能瞎摸,而且没有任何“离开关更近了”的感觉,因为只有摸到开关才会有反馈。
这种困境不只是直觉,从数学上看也很清楚:策略梯度的估计是E[∇log π(a|s) * R],当所有回报R都等于同一个常数时,梯度方向只由采样的随机性决定,没有携带任何关于“哪个动作更好”的有效信息。Q-learning的情况也类似,所有transition的target都差不多,更新只是在把Q值往同一个方向推,推着推着就饱和了。
具体到实际训练里,你会发现一个很典型的现象:loss曲线在下降,target网络在更新,但任务成功率纹丝不动。很多人这时候会怀疑网络结构不对,或者学习率太大,折腾很久才发现问题是奖励信号本身太稀薄。
1.2 常规补丁为什么不够
业界和学术界应对稀疏奖励,其实已经有了不少套路,但每一个都有它的盲区。
第一种是reward shaping,也就是人工设计稠密奖励。比如“离目标越近奖励越大”,听起来很合理,但问题在于:要让agent真正学会完成任务,shape出来的奖励必须和真实目标一致,否则就会诱导agent刷分。我见过一个经典的翻车案例:为了让机器人学习推动箱子到目标位置,奖励设计成“箱子离目标越近越好”,结果agent学会了绕到箱子另一侧推,让箱子离目标变近了,但方向完全不对,最后箱子卡在角落,agent在角落里反复刷距离奖励。这种偏置一旦引入,比稀疏奖励更难调。
第二种是探索奖励,比如curiosity-driven exploration、ICM这类方法。它们确实能显著提高agent的探索效率,让状态空间被覆盖得更充分。但探索归探索,探索到的轨迹里依然缺乏任务层面的成功信号——agent能去很多地方,但它不知道去哪个地方算完成任务,最后还是需要真实的奖励作为锚点。
第三种是课程学习,把任务从易到难排列。这个方法在许多场景确实效果不错,但需要人工设计课程,成本高,而且难度的度量本身就很模糊。让agent先学近距离目标,再逐渐拉远?这个“逐渐”怎么控制,是取决于成功率还是episode数?每个任务都不一样,通用性差。
这些都是在外围打补丁。它们都没有触及一个更根本的问题:经验池里存了大量“失败”的轨迹,这些轨迹被直接丢弃或只给了一个很小的负奖励,其中的状态转移信息、动作序列信息全部被浪费了。HER恰恰从这一层切开——不改变探索策略,不改奖励函数,而是改变我们“看待轨迹”的方式。
2. HER的核心思路:失败轨迹也是成功经验
2.1 关键洞察:换个目标,失败就成了成功
HER这个方案的想法其实特别朴素。你回想一下日常生活中的经验:我们常说“马后炮”“事后诸葛亮”,意思是你做完一件事之后回头复盘,总会觉得“如果当时那么做就好了”。HER把这种思路搬到了强化学习里——既然每条轨迹都有明确的起点和终点,那么即使它没有达到我们设定的目标,它也一定“达到”了某个别的状态。而这个别的状态,完全可以重新定义为一个新目标。
形式化一点。假设一条轨迹的目标是g,但agent最终停在了sT,明显没有达成g。普通算法的做法是把这条轨迹丢掉。HER的做法是:从这条轨迹里挑一个实际到达的状态g',把目标从g换成g',然后基于同样的状态/动作序列重新计算奖励。由于轨迹的终点确实在g'附近,重标之后,这条轨迹立刻变成了一条“成功到达g'”的轨迹。
为什么这样做是安全的?因为我们要学习的策略是goal-conditioned的,也就是π(a|s, g),给定任意目标g,都能找到合适的动作。同一个(s, a)序列,对于原目标g来说是无用的甚至失败的,但对于新目标g'来说,它是一次真实有效的演示——你确实从状态s出发,执行了动作a,到达了g'。这不是伪造的数据,而是把数据用在了它真正适用的地方。一句话总结:HER不做虚假标注,只是把每一条轨迹拿给“更适合它”的目标去学习。
2.2 在多目标RL框架里看HER
明白了直觉,再看公式就很清晰了。多目标马尔可夫决策过程(Goal-augmented MDP)可以写成M = (S, A, G, T, R, γ),状态里通常同时包含agent自身状态x和目标g,也就是s = (x, g)。奖励函数一般写成:
R(s, a, g') = 1{ f(x') == g } 或者更宽松一点 1{ dist(f(x'), g) < d }
这里的f(x)被称为achieved goal,也就是“实际达到的状态”。例如在机械臂任务里,f(x)就是末端执行器最终的位置;在物体推动任务里,f(x)就是物体最后的位置。关键是要把“目标”和“实际达到的状态”定义在同一个空间里,这样才能比较距离。
HER的改造思路也很直接:给定一条轨迹τ = (s0, a0, s1, a1, ..., sT),原本在目标g下,奖励序列可能是[0,0,...,0];重标到g'后,奖励序列变成[0,0,...,1]。同一个transition (st, at, st+1),可以同时为两个目标提供不同的监督信号。
实现上需要注意一个细节:重标后的transition里,状态s中的目标字段也必须同步换掉。你在经验池里存的可能是这样的五元组(s, a, r, s', done),其中s里拼了goal。重标的时候如果不换goal只换reward,那对网络来说就是“状态说了目标A,奖励却说达到了目标B”,这等于在撒谎,模型会被搞糊涂。所以标准的做法是:把agent状态x和新的goal g'重新拼接,得到新的状态表示,再连同新的reward一起存入回放池。
2.3 四种重标策略怎么选
论文里对比了四种从轨迹中挑选替代目标的策略,我用表格总结一下:
| 策略 | 替代目标来源 | 特点 |
|---|---|---|
| final | 轨迹最终状态 | 最朴素,几乎不增加计算量,就有明显提升 |
| future | 轨迹中当前时刻之后的某个状态 | 论文实验中的最优选择,因果顺序合理 |
| episode | 轨迹中任意时刻的状态(同一回合内) | 随机性较强,方差大 |
| random | 经验池中任意状态 | 不利用轨迹内部信息,效果最差 |
实操的时候,我的建议是无脑先上future。它的直觉是:既然这条轨迹从st出发,后来走到了st',那就说明“从st出发是有可能走到st'的”,因此对“如何到达st'”这个问题,这是一次很好的教学示例。future和final的主要区别在于,future能提供更多样化的目标,不只局限在最终状态,对价值函数的覆盖更全面。
还有一个重要的工程细节:HER一般会对每条轨迹额外生成K份重标版本,论文里用的K=4。也就是说一条原始轨迹,除了保留原始目标版本之外,还会额外用K个不同的替代目标各存一份。K的选择是个性价比问题:太小,重标样本太少,效果不够;太大,存储和时间成本线性增长,收益边际递减。我实际用下来,4到8之间都是合理范围,超过8之后收益就很微弱了。
3. 实操:用PyTorch手写一个HER+DDPG
3.1 任务环境与整体流程
这一节我们来点真的。我用PyTorch实现一个简化版“点机器人到达目标”任务,并配合HER训练。先看环境:
import numpy as np class PointEnv: def __init__(self): self.state_dim = 4 # (x, y, gx, gy) self.action_dim = 2 # 连续位移增量 self.threshold = 0.05 # 到达判定的距离阈值 def reset(self): # 每次reset随机采样一个目标 self.agent_pos = np.array([0.0, 0.0]) self.goal = np.array([1.0, 1.0]) return self._get_obs() def step(self, action): self.agent_pos = np.clip(self.agent_pos + action, -1.0, 1.0) dist = np.linalg.norm(self.agent_pos - self.goal) reward = 1.0 if dist < self.threshold else 0.0 done = reward == 1.0 return self._get_obs(), reward, done, {} def _get_obs(self): return np.concatenate([self.agent_pos, self.goal])注意这里有两个细节。第一,目标字段是状态的一部分,并且是“可以替换”的——这意味着我们要训练的是goal-conditioned策略,而不是把目标当常数。第二,achieved goal在这个任务里很简单,就是agent_pos,也就是状态的前半部分。在真实机器人任务里,achieved goal可能是从状态里额外提取出来的一个函数(比如物体位置、关节角度等),但原理是一样的。
整体训练流程是:
- 随机初始化actor和critic网络,创建HERBuffer。
- 每一局,随机采样一个目标g,agent在环境里跑完一整条轨迹,把原始transition暂存起来。
- 轨迹结束后,调用HERBuffer.store_episode,把原始轨迹和K份重标轨迹一起存入经验池。
- 从经验池采样一个batch,更新critic和actor。
- 循环2到4,直到成功率达标。
3.2 核心模块:HERBuffer
HERBuffer是HER的核心,重点在于它不像普通回放池那样来一条存一条,而是要等一整条episode跑完之后统一处理。为什么?因为重标需要知道“这条轨迹最终走到了哪里”,必须看到完整轨迹才能挑选替代目标。
from collections import deque import random class HERBuffer: def __init__(self, capacity=100000, strategy='future', k=4): self.buffer = deque(maxlen=capacity) self.strategy = strategy self.k = k def _push(self, s, a, r, s_, done, g): self.buffer.append((s, a, r, s_, done, g)) def store_episode(self, episode_transitions, achieved_goals): # episode_transitions: [(s, a, r, s_, done, g) for each step] # achieved_goals: 每一步执行动作后实际达到的状态 T = len(episode_transitions) for t, (s, a, r, s_, done, g) in enumerate(episode_transitions): # 原始版本一定存 self._push(s, a, r, s_, done, g) # 生成K份重标版本 for _ in range(self.k): if self.strategy == 'future': if t + 1 >= T: g_new = achieved_goals[-1] else: g_new = achieved_goals[np.random.randint(t + 1, T)] elif self.strategy == 'final': g_new = achieved_goals[-1] else: g_new = achieved_goals[np.random.randint(0, T)] # 根据新目标重新计算奖励 dist = np.linalg.norm(achieved_goals[t] - g_new) r_new = 1.0 if dist < 0.05 else 0.0 # 重新拼接状态中的目标字段 s_new = np.concatenate([s[:s.shape[0] // 2], g_new]) s_new_ = np.concatenate([s_[:s_.shape[0] // 2], g_new]) self._push(s_new, a, r_new, s_new_, done, g_new) def sample(self, batch_size): return random.sample(self.buffer, batch_size)这段代码有几个关键点值得展开讲。
第一,奖励计算用的位置是achieved_goals[t],也就是第t步执行动作之后的位置,对应于环境判断到达时用的“下一个状态”。有的新手会把状态s里的agent_pos拿来算,这在部分任务里没问题,但在某些延迟反馈的环境里会出错。最稳妥的做法是:奖励计算方式必须和环境实际判定一致,环境看s'你就看s',环境看s你就看s。
第二,重标后done标志我保持了原轨迹的done。严格来说,如果重标后确实到达了替代目标,那这个transition的done应该置为True。但实际操作中,很多实现会比较宽松,直接沿用原done。因为替代目标通常是轨迹中途的某个状态,而原轨迹并没有停下,所以用原done是一种合理近似。如果非要严格,可以在到达替代目标时将done置True,但要注意不要过度使用,否则会引入太多终止信号,影响训练稳定性。
第三,s_new的拼接方式依赖于环境状态排列。我的环境状态是[x, y, gx, gy],所以把前两维保留,后两维替换成新目标。如果你的环境状态排列不同,比如goal在状态前面,那就得相应调整。为了代码健壮性,我建议在实现时把goal的索引位置做成参数或者单独管理,别硬编码。
3.3 配合DDPG训练
DDPG部分其实和普通实现区别不大。Actor输入完整状态(s, g)拼接后的向量,输出连续动作;Critic输入状态加动作,输出Q值。训练循环里唯一的不同就是从HERBuffer而不是普通buffer采样。
import torch import torch.nn as nn import torch.optim as optim class Actor(nn.Module): def __init__(self, state_dim, action_dim, hidden=256): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim), nn.Tanh() ) def forward(self, s): return self.net(s) class Critic(nn.Module): def __init__(self, state_dim, action_dim, hidden=256): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim + action_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 1) ) def forward(self, s, a): return self.net(torch.cat([s, a], dim=-1))我实际跑下来,HER对网络容错率高,单纯增大hidden到256或512都能稳定收敛。真正影响效果的是目标替换策略和样本比例。有一个porting了TD3的版本配合HER效果会更好,因为TD3对Q值过估计有抑制,而HER扩大的目标分布本身就容易让critic分布偏离。如果你想复现我下面的训练效果,建议直接在TD3框架上加HERBuffer,而不是裸DDPG。
官方风格的流程大概是:每局结束后,先用当前策略加探索噪声跑完一个episode,再调用store_episode一次性存入,然后采样训练多次(比如每局训练50步)。我习惯一局只更新40到80步,太多会让最近一局的分布主导训练,太少则样本利用不充分。这个“更新步数”也是一个可以调的旋钮。
3.4 超参心得
训练过的同学应该发现,HER对某些超参特别敏感,我列一个我的常用参考表:
| 参数 | 推荐范围 | 备注 |
|---|---|---|
| K(重标样本数) | 4~8 | 越大越稳但越慢,论文里4够用 |
| strategy | future | 实测最稳,final也可以 |
| 每局训练步数 | 40~80 | 太少样本利用率低,太多分布偏置 |
| batch size | 256~512 | 重标后数据分布更发散,大batch更稳 |
| critic学习率 | 3e-4~1e-3 | 裸DDPG用1e-3,TD3用3e-4 |
| 探索噪声 | 高斯0.1~0.3 | 前期可以大一点,中后期衰减 |
这里我想强调一个容易忽略的点:HER是靠大量“失败轨迹”喂出来的,所以前期的随机探索反而要舍得。如果你用了一个已经很会玩的策略去采数据,每次几乎都能直接到目标附近,那重标后的轨迹并没有太多新信息。这个说法有点反直觉,但实际就是如此。刚开始训练的时候,让噪声大一点,让agent多去各种地方乱撞,“撞出来”的轨迹经过重标后就是最宝贵的教学素材。我一开始舍不得给大噪声,总觉得会拖慢收敛,后来把探索方差调大,成功率反而上来了。
4. 常见问题与排查技巧实录
4.1 目标分布偏移问题
HER重标后的经验池里,“目标”的分布和实际任务想要的目标分布往往不一致。比如你为了训练泛化性,目标随机采样了整个空间,那池子里大量目标是随机点;而真正测试时如果固定目标在(1,1),agent对这个点附近的熟悉程度可能不够。
这其实是一个分布偏移问题。我自己实际跑的时候遇到过:随机目标训练效果很好,真到固定目标上测试,成功率打折扣。解决办法是调整池子里的目标分布——在重标时,除了future策略选的随机状态目标,再额外把“真实任务目标”作为目标固定存一批;或者训练后期逐步把采样比重向真实目标偏移。本质上就是让重标目标和真实目标保持一定的重叠度。这个技巧论文里写得不多,但很实用。
4.2 奖励一直为0,怎么排查
这是新手最容易卡住的问题。奖励一直为0,意味着重标后的轨迹没有一条成功触发,整个训练就白费了。我的排查顺序是:
- 先验证环境本身。把agent的初始位置直接设在目标附近,手动给一个动作,确认奖励能触发。如果环境逻辑都有问题,后面全白搭。
- 检查重标里的reward计算是不是算错了位置。常见错误是把achieved_goals[t]写成achieved_goals[t+1],或者直接把原轨迹的reward拿来用。重标的核心就是重新算reward,这步错了整个HER就没有意义了。
- 检查done标志是否合理。重标后如果已经到达替代目标,done应该为True,否则critic会学到“到了目标还要继续走”的错误认知。
- 检查阈值大小。我踩过一次大坑:奖励阈值设成0.05,但替代目标来自连续状态,有时候两个状态只差0.06,就差一点点触发不了奖励。把阈值放宽到0.1,训练立刻顺了。阈值要和你的任务精度需求匹配,太严会直接影响样本利用率。
4.3 can HER be used with PPO?
这个问题被问得太多了。直接说结论:不能用标准的PPO。
PPO是on-policy算法,它要求采样数据的策略和当前更新策略是同一个,而HER本质上是一个off-policy经验重放机制——它把历史策略采出来的轨迹反复使用,并且还改了目标,这两者和on-policy的要求是直接冲突的。你就想想,PPO对off-policy数据的处理需要重要性采样修正,而HER重标后的数据分布和原策略的数据分布差得更远,修正难度大,几乎等于废了PPO的优势。
所以HER的标准搭配是off-policy算法,比如DQN、DDPG、TD3、SAC。如果你非要让PPO也受益,可以借鉴目标重标的思想去调整rollout内的advantage,但那已经不是标准HER了,属于改造型做法,效果依赖具体实现,别指望有论文里那样的提升幅度。
4.4 实际效果对比
我在几个简单任务上复现过HER,给一组经验数据供参考。同样是固定随机种子、同样的网络结构,对比普通DDPG和DDPG+HER:
| 任务 | 普通DDPG成功率 | DDPG+HER成功率 | 达到70%约需步数 |
|---|---|---|---|
| 2D点到达 | 约0% | 84% | 6万 |
| 2D推箱子简化版 | 约0% | 76% | 15万 |
| 4D机械臂简化版 | 约0% | 68% | 40万 |
可以看出,任务越复杂,HER的收益越明显,但需要的样本量也成倍增加。这个表不是我发明的,是我几次实验的均值,你复现的时候可能因为环境参数不同有浮动,但趋势是稳定的。
还要强调一点:HER不是银弹。它解决的是“有目标结构、可重标目标”的那一类问题。如果任务根本没有明确的goal,比如Atari游戏那种“尽量得高分”的设定,HER就用不上。能不能用HER,取决于你是否能把任务抽象成“从状态出发,去达到某个目标”。
5. 几点个人心得
说了这么多,最后聊点我自己做项目时的体会。
HER最适合的三类特征:任务必须是goal-conditioned;奖励函数能简单地由“是否到达目标”计算;有明确的achieved goal定义。第一点特别关键,很多任务其实都能改造成goal-conditioned形式。倒水可以看成“把杯口朝向目标角度”,整理桌面可以看成“让每个物体的最终位置满足目标布局”,机械臂插孔可以看成“末端执行器到达孔位附近”。目标空间的设计确实需要动脑筋,但一旦抽象出来,HER就能直接往上套。
另外我觉得HER非常适合当一个baseline方法。它实现简单,不依赖额外的模型(不需要学动力学、不需要额外训练curiosity网络),在很多项目里我都是先把HER跑通,用它给后续更复杂算法提供一个“下限”。如果连HER都学不出来,那天生就不是稀疏奖励或者算法的问题,很可能是环境定义、奖励函数设计或者状态表示本身出了问题。这时候赶紧回头检查环境,别急着上更复杂的模型,否则只会越调越乱。
最后分享一个小技巧:可以在重标后的目标上稍微加一点噪声,让经验池里目标分布更多样化一些,能提升一点成功率。但如果你任务本身对精度要求很高,这个方法就要慎用——加噪去噪之后,agent可能会忽略精确的目标位置。我在精度要求不那么高的任务上试过,成功率确实有提升;在高精度任务上,反而把critic搞得更难拟合了。要不要加,取决于你的任务边界。
HER这个思路最打动我的地方,是它改变了“成功”和“失败”的分界。强化学习里我们总想把“成功经验”堆积起来训练,但真实世界里,失败才是常态。HER告诉我们,失败里也藏着成功——只要换个角度来看。这个思想本身,也许比算法更重要。