训练机械臂抓东西,反馈一路全是0,你就能体会到什么才是真正的hindsight——事后聪明。我去年在仿真环境里跑一个七轴机械臂抓取任务,连续三个通宵,奖励曲线纹丝不动,一次正反馈都没出现过。后来把思路换成后见之明:既然当前目标够不到,那就把我们实际到达过的状态重新当成“目标”再学一遍,那些看似废掉的失败轨迹突然就有了价值。这套东西在强化学习里叫Hindsight Experience Replay(HER),专门用来砸稀疏奖励的场子。这篇文章是我实际项目里的完整记录,适合正在做稀疏奖励任务、被goal-conditioned策略逼到怀疑人生的同行,也适合刚接触HER、想搞懂它到底在做什么的新手。
1. "hindsight"是个什么思路:失败经验也是黄金
1.1 稀疏奖励问题的困境
先复现一下最让人抓狂的场面。你设计了一个目标条件强化学习任务,机械臂需要把方块推到指定位置。环境只给一种奖励:方块和目标位置的距离小于某个阈值,奖励1;否则奖励0。这类奖励不是没有信息量,而是信息量实在太稀疏。一百万步里,智能体可能一次成功都没碰到过,那么整个轨迹的经验都是“这不合适”,没有任何“这么做更好”的梯度,策略就会一直原地打转。
这个问题和考生蒙大题的逻辑是一个路数。如果平时只做满分或零分两种批改,并且满分标准是“在月球上写对答案”,那考十辈子也得不到一次正向反馈,自然谈不上改进。稀疏奖励的难点不是模型收敛慢,而是探索完全失去了方向。很多任务看似简单,实际上动作维度高、初始状态随机,用随机策略偶尔命中目标的概率低到可以忽略。
传统的破解方式无非是奖励塑形(reward shaping),人为加一些中间指标,比如“离目标越近奖励越高”。但奖励塑形需要大量领域知识,而且规则设计不好会诱导智能体钻空子,做出一些在指标上好看、实际却不可用的诡异行为。我见过不少人把精力耗在调reward shape上,最后发现环境稍微改一下就要从头再来,这根本不具备泛化能力。
1.2 事后聪明为什么能改变学习信号
HER的想法非常反直觉:既然没抓到目标,那就把“当前实际抓到的东西”当目标重新学一遍。比如机械臂想把方块推到A点,结果一路猛推把方块停在了B点。这个过程中,机械臂其实完成了一次“把方块推到B点”的任务。如果我们把这次轨迹的目标改写成B点,这次失败的轨迹就变成了一条成功的演示。这就是后见之明,站在事后看到了这条轨迹真正的价值。
放到学习信号上说,原来的轨迹在goal A下全是负样本(reward=0);重标成goal B后,轨迹内至少有一部分状态已经满足goal B的条件,于是这些状态会得到正向奖励,策略开始知道在“目标是B”的情况下做出什么动作是有用的。等积累了足够多“各种实际位置当目标”的经验,智能体对目标空间的覆盖会越来越密,当它再次面对真正的目标A时,即使在执行过程中没有立刻到达A,也能利用大量过去“接近并到达某一点”的经验,逐渐调整策略。
这种做法的本质是换了个角度看数据,而不是换个奖励函数。它没有人为编造与任务无关的中间奖励,所有奖励依然来自环境真实反馈,只是我们把目标字段做了重新标记。所以在很多稀疏奖励benchmark上,HER比精心设计的reward shaping更稳,因为它不需要你有任务先验,几乎可以插到任何目标条件算法里当外挂。
2. Hindsight Experience Replay 的核心设计与细节
2.1 从“目标达成”到“目标接近”的转换
要把HER落实到代码里,第一件事是把问题定义成多目标强化学习的标准形式。我们有一个状态s,一个动作a,一个目标g,策略是π(a|s,g),价值函数是Q(s,a,g)。环境的每个transition包含五样东西:当前状态s_t、动作a_t、奖励r_t、下一状态s_{t+1}、目标g。同时为了重标注,我们最好还要从状态里提取一个“已达成目标”achieved goal。
比如抓取任务里,achieved goal就是当前机械臂末端位置,或者说方块位置。目标g是一个和achieved goal同维度的向量。原始的稀疏奖励可以写成:
def compute_reward(achieved_goal, desired_goal, threshold=0.05): distance = np.linalg.norm(achieved_goal - desired_goal, axis=-1) return (distance < threshold).astype(np.float32)HER的套路是,在真实目标g之外,另找一个新目标g',用这个g'重新计算这条transition的奖励,然后把重标后的transition存进经验池。关键点在于:state和action不变,变的只是“目标”这个输入字段和对应的奖励。这相当于告诉模型:在这样一条状态下,你要达成的是这个新目标。
这里有一个很多新手容易绕晕的地方:HER不是把reward变得更稠密,而是把“成功”的机会变多。因为目标换成实际到达过的状态后,轨迹中更有可能出现“当前状态达到目标”的时刻,正样本的比例因此提高。如果之前一万步都见不到一个正反馈,重标后可能一百步就能见到好几个,学习的效率自然上去了。
2.2 为什么future策略是首选:k个额外目标的采样逻辑
重标目标的来源有好几种策略,原论文里对比过final、episode、random和future。核心问题是:给定一条轨迹,我们要从哪些状态中挑选新目标?
- final:只用轨迹最终状态作为新目标。
- episode:用轨迹中所有状态作为候选,随机选一个。
- random:从整个状态空间随机采样一个目标。
- future:从当前时间步之后的某个状态中采样一个目标。
实际跑下来,future策略是最稳、最常见的。原因在于它不存在“时间穿越”的问题。如果选一个t=1时刻之前的状态当目标,那t=1时刻还没到达过这个目标,这个“成功”就是不真实的,容易给策略传递错误信号。future策略选的是未来才出现的状态,意味着轨迹确实走到了那个状态,而在此之前的所有transition都是在朝这个目标靠近的路上,训练出来的变化过程是符合因果关系的。
具体操作里有一个超参数k,作用是为一个transition额外生成多少个重标样本。通常取k=4。也就是说,轨迹里的每一步都会额外采样4个来自未来的状态作为候选目标,加上原来的原始目标,这条transition会被写进buffer最多5次。k不是越大越好。k太大,经验池里全是“事后聪明”的数据,原始目标被淹没;k太小时,正样本增幅又不够明显。论文和一些复现实验里,k=4是性价比很高的点,但如果你环境的状态维度特别高,或者未来轨迹特别长,可以尝试k=2或k=6,这一步值得做一次线性搜索。
我在自己项目里经常遇到一个问题:一条episode可能很短,比如只有二三十步。这样future候选集不够大,采样会有偏差。解决办法是把k设置成min(k, len(future_indices)),至少保证不因越界报错,另外不要replace=False导致采样数量不够,处理这个边界逻辑是很多隐藏bug的来源。
2.3 目标重标注的完整流程
把HER放进agent里的流程可以拆成五步:
- 智能体在环境中跑一个episode,记录每一步的s_t、a_t、achieved_goal_t、goal_t。
- episode结束后,对每一步t,先保留原始transition (s_t, a_t, r_t, done, goal_t) 进buffer。
- 找到t之后的所有未来时间步索引,即[t+1, T]。
- 从这个候选集合中随机挑min(k, len(candidate))个索引。
- 对每个索引f,取出achieved_goal_t作为新目标g',用compute_reward(achieved_goal_t, g')计算新奖励,生成一条新transition (s_t, a_t, r_t', done, g'),同样进buffer。
注意第五步里,我们重新计算的奖励针对的是当前状态s_t和当前动作a_t下的achieved goal,不是future状态自身。很多人会在这里写错,把future状态的achieved goal当成当前transition的奖励目标去算,导致正样本满天飞,策略基本被污染。
整个流程看起来简单,我第一次实现时却犯了几个低级错误。最典型的是在采样future索引时没有排除当前步,导致把当前状态当成未来目标重复添加;另一个是把done原样复制到重标样本里,导致target网络在计算TD时就出错了。这些细节点不写进paper,但会直接影响你的训练曲线长成什么样。
3. 实操:在PyTorch里把HER接到DDPG上
3.1 环境、状态与目标定义
我用的是类似OpenAI Fetch的仿真环境,机械臂末端需要把立方体推到目标位置。状态s包含七个部分:机械臂关节角度、关节角速度、末端位置、末端线速度、方块位置、方块线速度、还有相对位置。目标goal一般只需要方块目标位置,三维向量。achieved goal也取方块当前三维位置。
输入到actor和critic的观测,通常做法是直接把state和goal拼成一个向量。对于图像输入的任务,处理方式会不一样,需要把目标编码成额外通道,但核心逻辑不变。网络结构没有花活,三层MLP,每层256个神经元,中间用ReLU,actor输出层用了tanh限制动作范围。
一个我特别想强调的坑:state和goal归一化。Fetch环境里位置在0到1左右,速度却可能到几,直接拼接会让critic在计算时对位置维度的敏感度下降。我第一次直接在原始尺度上跑,效果很差。后面把state和goal都做了clip和scale,让每个维度基本落在[-1, 1]区间,学习效率一下子提了上来。
3.2 HindsightReplayBuffer代码实现
下面给一个我实际用过的简化版缓冲类,可以直接抄进项目里调整。
import numpy as np from collections import deque class HindsightReplayBuffer: def __init__(self, capacity, k=4, threshold=0.05): self.capacity = capacity self.k = k self.threshold = threshold self.buffer = deque(maxlen=capacity) def compute_reward(self, achieved_goal, desired_goal): distance = np.linalg.norm(achieved_goal - desired_goal) return 1.0 if distance < self.threshold else 0.0 def add_episode(self, episode): # episode: list of dict, key include # 'state', 'action', 'reward', 'done', 'achieved_goal', 'goal' length = len(episode) for t, trans in enumerate(episode): # 原始样本一定要保留 self.buffer.append({ 'state': trans['state'], 'action': trans['action'], 'reward': trans['reward'], 'done': trans['done'], 'goal': trans['goal'] }) future_indices = list(range(t + 1, length)) if not future_indices: continue sample_num = min(self.k, len(future_indices)) sampled_ids = np.random.choice(future_indices, size=sample_num, replace=False) for fidx in sampled_ids: # 新目标来自未来某个时刻的achieved_goal g_prime = episode[fidx]['achieved_goal'] # 重算奖励:用当前时刻的achieved_goal new_reward = self.compute_reward(trans['achieved_goal'], g_prime) self.buffer.append({ 'state': trans['state'], 'action': trans['action'], 'reward': new_reward, 'done': trans['done'], 'goal': g_prime })这个缓冲区有两个地方还能继续优化。一是episode本身如果很长,可以不用全部存下来,直接用滑动窗口保留最近若干条轨迹;二是为了减少数据相关性,最好在add_episode之前对轨迹做一下随机裁剪或者截断。不过这些都不是核心,先把基础逻辑跑对再说。
3.3 训练循环与超参选择
训练循环和普通DDPG基本一样,只是采样时buffer里每条数据带有不同的goal。actor输入是拼接后的state-goal向量,critic输入是state-goal-action向量。每次从buffer采样一批数据,计算TD误差:
q_target = reward + gamma * (1 - done) * target_critic(next_state, target_actor(next_state, next_goal), next_goal)这个公式看起来简单,但有地方需要单独说:next_goal必须和当前样本里的goal一致,不能随意换。我当时写代码图方便,从buffer里取batch后直接把每个样本的goal都替换成了“该样本自己的未来目标”,结果导致transition与goal完全不匹配,模型越训越飘。正确做法是严格保持同一个sample内部goal的一致性。
训练里另一件事是探索噪声。DDPG对动作加OU噪声效果一般,后来我用高斯噪声加衰减,效果更稳定。初始噪声标准差设为0.3,每隔一定episode乘0.98,让策略前期充分探索,后期逐渐收敛。
超参方面,我把我常用的配置列在下面:
| 参数 | 数值 | 备注 |
|---|---|---|
| actor学习率 | 1e-4 | 太低收敛慢,太高不稳定 |
| critic学习率 | 1e-3 | 相对可以大一点 |
| gamma | 0.98 | 任务步数短,衰减不用太强 |
| tau | 0.05 | 软更新稍快,适合仿真 |
| buffer容量 | 1e6 | 注意内存占用 |
| k | 4 | 每个transition额外生成4个重标样本 |
| batch size | 256 | 目标条件任务建议大batch |
| 奖励阈值 | 0.05 | 需要根据任务尺度调整 |
这组参数不一定在所有环境都最优,但作为起点已经足够。跑的时候记得先每个epoch采样一条episode,做一次HER重标,再更新4到8次网络,这个比例对稳定训练很重要。
4. 跑实验踩过的五个坑与排查实录
4.1 稀疏奖励阈值没调,HER也白搭
阈值太大会让“成功”变得太廉价,智能体只要稍微靠近一点就算达到目标,那它学到的东西就很粗糙;阈值太小会让正样本依然稀缺,HER的优势被削弱。我一开始在抓取任务里把阈值设成0.02,方块初始离目标有0.5,几乎没有一个重标样本能落到阈值范围内,曲线照样是平的。后来改成0.05,正样本比例明显上升,训练曲线才开始有反应。
调整阈值没有万能公式,我个人的习惯是先统计一下HER重标后正样本所占比例,最好维持在5%到30%之间。如果低于1%,说明太严格;如果高于50%,说明太宽松,策略没必要精细逼近目标。
4.2 状态和目标没做归一化,critic学不动
这个问题我在3.1里提过,但它是真的值得单独拿出来再骂一遍的坑。critic接收state-goal-action作为输入,如果state里的速度和位置数值相差两个数量级,那网络前向传播时某些维度会天然拥有更大影响,梯度更新就会失衡。再加上HER里不同重标目标的数值范围变化很大,归一化不好很容易让critic对目标的泛化能力变得极差。
解决也不复杂,状态和目标统一做clip,再除以预设范围。我甚至见过有人直接用整个buffer的均值和方差做running normalisation,效果也不错。关键是要把归一化参数稳定下来,别每一条episode都动态重算。
4.3 重标样本比例过高,原始目标被淹没
HER的核心是“额外”提供hindsight样本,不是替代原始经验。如果你为了增加正样本,把k调得过大,或者end episode后反复把同一条轨迹重标几十次,buffer里绝大多数样本都是事后聪明。这时候策略对“如何达成真实目标”反而不敏感,因为真实目标下的正样本占比太低,它学成了“只管走向去过的地方”。
我做过一个对比实验,k从0到16递增,在同一个任务上,k=4效果最好,k=8和k=16反而回退。原因是纯重标样本太多了。如果你发现训练后期策略在测试时老往轨迹曾经走过的位置跑,而不去够真正的新目标,大概率就是重标比例出了问题。建议至少保留原始transition作为20%以上的buffer成分。
4.4 和SAC搭配时熵系数不稳定
HER+此类off-policy算法组合有一个隐性矛盾。HER的数据分布不是平稳的,重标操作让buffer里大量样本的目标分布和真实初始目标分布相差很大,SAC的熵系数自动调整在这类数据下极其容易波动。我一开始直接用SAC默认的auto alpha,结果训练到中期alpha掉到接近零,策略退化成和DDPG差不多的确定性策略,丢掉SAC本来该有的探索能力。
后面我的处理是给alpha设置一个下限,比如0.01,同时把学习率调低。如果你没有特殊需求,直接用DDPG或者TD3加HER会更省心,SAC加HER需要多花时间盯熵系数收敛情况。
4.5 原目标一定要保留,否则策略走偏
这一点和前面对应,但还有更具体的表现。某些实现里,新手会把原transition直接丢弃,只保存重标目标下的transition,理由是“反正在训练中也要采样”。这是非常危险的做法。原目标才是最终评测目标,如果训练数据全部是各种各样的hindsight目标,策略会彻底从“解决原任务”偏向“探索状态下曾经出现的各种局面”。测试时一评测,成功率几乎为零。
所以add_episode里必须把原始transition也写进buffer。我有时候会额外做一个小动作:对原始目标样本给予更高的采样权重,这样即使k设得稍大,策略也不会忽视真实目标。虽然这会引入一点采样偏差,但实测对最终成功率有正面帮助。
| 问题 | 表现 | 排查思路 | 解决办法 |
|---|---|---|---|
| 奖励阈值过小 | 正样本率极低 | 打印HER重标后reward=1的占比 | 放大阈值,观察5%到30%区间 |
| 特征量纲不平衡 | 训练曲线大震荡 | 查看state各维度标准差 | 统一归一化到[-1,1] |
| 重标样本过多 | 测试时总走老路 | 统计buffer中原样本占比 | k降到2-4,或加原始样本采样权重 |
| alpha不稳定 | SAC逐步不探索 | 打印alpha曲线 | 设alpha下限或改用DDPG/TD3 |
| 原目标被丢弃 | 训练loss正常但测试为0 | 检查buffer中goal的分布 | 原transition必须保留 |
5. 从仿真到实机,以及什么情况下别用HER
5.1 仿真训练完,实机上“Hindsight目标”要重新审
HER在仿真里表现得越激进,迁移到实机时越要小心。原因是HER会在目标空间中凭空生成很多极端情况,比如从未来某个方块位置学到的轨迹,可能要求机械臂突然加速或经过一个在物理世界里不现实的路径。仿真里这些轨迹可能允许,但实机会因为动力学限制完全执行不了。
所以我的习惯是:仿真里训练完成后,做一次目标空间的可视化。把所有HER重标样本出现过的目标在空间里画出来,看看是否和真实任务的目标分布有明显偏差。如果有,就在实机微调时只保留原始目标的真实轨迹,重标样本全部丢弃,再跑几十个epoch的RL微调。这个策略虽然浪费了一部分仿真成果,但能避免实机上的安全隐患。
5.2 结合域随机化和更多hindsight变体
HER并不孤立存在,它和domain randomization几乎是天作之合。域随机化改变物体的摩擦系数、方块质量、目标位置范围,而HER让这些随机环境中的失败轨迹都变成可用经验。两者一叠,训练出的策略在实机上的成功率会比单独使用其中一个高很多。
也有后续工作扩展了hindsight思路,比如hindsight planning,它在模型预测控制里用后见之明动态规划目标,还有自动在目标空间进行采样的变体。如果你已经掌握了基础HER,再去接触这些延伸不会陌生,因为它们共享同一条核心思想:现实世界里没有完全废掉的轨迹,只有还没被你重新定义的目标。
5.3 判断该不该上HER的三个条件
不是所有项目都需要HER。我建议你先问自己三个问题:第一个,任务是不是目标条件的,也就是“有没有一个显式的goal字段”。如果没有,HER根本没有可以替换的对象。第二个,奖励是不是稀疏的。如果环境本身已经有稠密距离奖励,而且效果还不错,那HER带来的提升可能很有限,反而增加代码复杂度。第三个,任务是不是存在多种可能的达成状态。如果任务只有唯一解,比如棋类游戏只有一种合法目标,那后见之明几乎没有发挥空间,因为失败状态里根本不包含“替代目标”。
HER最适合的场景就是机器人操作、物体搬运、路径规划这类“目标是一个连续空间中的位置或状态”的问题。在这些场景里,失败轨迹往往已经包含了大量“接近某个目标点”的信息,重标价值很高。反之,如果你的任务里目标是非空间化的离散属性,比如图像分类标签,那这套方法基本派不上用场。
从吃到第一口HER的红利到现在,我最大的感受是:在强化学习项目里遇到稀疏奖励,千万别急着加reward shaping,先试试hindsight。它可能不是最强的算法,但绝对是最能改变你对失败经验认知的思路。每次看到训练曲线从一条直线变成一条带坡度的斜线,我都觉得那次“失败到怀疑人生”的经历没白熬。现在再遇到难搞的任务,我脑子里第一反应不是“怎么设计奖励”,而是“换个目标看这些数据”,这个习惯就是HER留给我的礼物。