第一次看到Hindsight Experience Replay(HER)这个名字,我就觉得这篇论文把“hindsight(后见之明)”用得太妙了。在目标条件强化学习(goal-conditioned RL)里,最折磨人的往往不是算法本身多复杂,而是稀疏奖励:机器人爪子伸了半天,始终够不到目标,奖励永远刷一个-1,loss曲线比心电图还平,训练跑一晚上等于白跑。HER的核心,就是教会智能体学会“虽然这次没达成原定目标,但换个角度看,我确实做到了一些事情”,并把这些经历作为有效经验存下来。这篇文章我会从原理开始,讲到目标重标记策略、PyTorch最小实现,再把我踩过的坑和调试经验一并交代清楚。适合被稀疏奖励搞到崩溃的强化学习新手,也适合想在机器人任务里落地HER的工程同学。
1. 后见之明怎么救强化学习:稀疏奖励困境与HER的基本盘
1.1 被奖励困住的智能体:goal-conditioned RL的真实痛点
先明确我们讨论的问题场景。目标条件强化学习,指的是策略不仅依赖当前状态,还额外接收一个“目标”作为输入。比如让机械臂把积木放到桌上某个圆圈里,目标g就是圆圈的位置坐标;让小车导航到地图上的某个点,目标g就是那个点的坐标。策略要学的是π(a|s, g),目标是最大化累计奖励,而奖励函数怎么设计,直接决定了这个任务能不能学出来。
很多教学demo喜欢用“密集奖励”,比如每一步都返回当前位置与目标点的负距离,距离越近奖励越大。这类奖励信号平滑、连续,哪怕智能体一开始偏离目标很远,也能从数值变化中感知到“靠近了”或“远离了”,学习过程相对温和。但真实问题上,密集奖励往往拿不到。机械臂上可能没有传感器能告诉你“现在还差0.31米”,只有视觉判定“积木在圈内”还是“不在圈内”。于是奖励只能做成稀疏二值:达到了给0,没达到给-1。
这种设定下,智能体就像蒙着眼睛在操场上找一个足球。如果一开始离球太远,无论怎么做都拿不到正向反馈,策略梯度完全没有方向,探索也退化成无效抖动。更麻烦的是,连续动作空间里的随机噪声很难精准“撞”上目标,而目标空间又往往是高维的。我见过太多人在仿真环境里复现某个算法,在Pendulum这类简单控制任务上一切正常,一换到FetchReach、FetchPush这类机械臂环境,立刻原形毕露:奖励全是-1,Q值不动,success rate永远挂零。
这就是稀疏奖励问题的核心:不是智能体“笨”,而是它根本没有拿到任何“有用的教训”。失败轨迹确实产生了,但这些轨迹里只包含“我失败了”这一个信息,没有包含“如果我当时换个目标,这些动作其实是对的”这种可学习信号。传统经验回放把这类轨迹当成废料丢掉,HER恰恰就看中了这些废料。
1.2 “事后诸葛亮”为什么不是坏事:HER的核心思路一句话讲透
HER的想法其实特别朴素,一句话就能说清楚:既然这条轨迹没有达成原来的目标g,那不如把它重新标记成一个已经达成的目标g′,然后当作一条“成功经验”存进经验池。
机械臂没抓到A点,但最终停在了B点。从“抓A点”这个目标看,这一集彻底失败;但如果把目标临时改成B点,那这一整条轨迹就是一条教科书级的“成功轨迹”:每一步都在朝着B点移动,最终也确实到了B点。于是我们可以给这条路轨迹配上正奖励,丢给Q网络学习。这个操作叫作目标重标记(goal relabeling)。
为什么这样有效?因为它踩中了一个关键事实:环境的动力学通常和目标无关。不管目标是要把积木放到A点还是B点,机械臂的物理规律完全一样:关节转了10度,末端就朝某个方向移动多少。那么“为了到达B点,我该怎么做”这个经验,对于“为了到达A点”的任务依然有很强的参考价值。它告诉了Q函数一个泛化结论:沿着这条路线走,至少能到达B点,而B点与A点之间可能只差一个平移变换,很多运动模式是共通的。
形式化一点说,标准经验回放存储的transition是(s, a, s′, r, g)。HER额外生成一个重标记目标g′,通常是该轨迹中未来某个时刻的状态,然后重新计算r′ = reward(s′, g′),把(s, a, s′, r′, g′)也作为一条经验放入缓冲池。注意,这里不是“修改原始经验”,而是“额外补充一条新经验”。这个细节很多人第一次都会搞错,后面我会单独拆开讲。
用生活类比就是:你带着地图找一家想去的餐馆,结果走错了路,误打误撞进了一家口碑不错的店。从“找到原定餐馆”这个目标看,你彻底失败了;但如果把目标重标记为“找到这家店”,那你刚才的路线就是完全正确的路线,值得保存下来,下次还能用。HER把这个朴素的“复盘思维”量化成了算法,让失败轨迹全部变成教材。
2. 目标重标记策略:final、future、random怎么选,关键参数怎么定
2.1 三种重标记策略对比:直接换目标或采样未来状态
知道了重标记的大方向,接下来要解决的是:重标记目标g′到底从哪来?HER论文里给出了几种策略,工程上最常用的是这三种:
| 策略 | g′的来源 | 特点 |
|---|---|---|
| final | 轨迹最后一个状态s_T | 实现最简单,同一轨迹内所有transition都重标记为同一个目标,样本多样性有限 |
| future | 对轨迹里每个transition,从它之后的未来状态中随机采样一个s_m(m > t) | 最常用,多样性好,信息密度高 |
| random | 从环境中随机采样一个状态作为目标 | 实现简单,但目标可能物理不可达,效果通常最差 |
先说final策略。整条轨迹结束后,把最后一个状态当作目标,那么对这条轨迹里的每一个transition,都判断“当前动作是否接近终点状态”。这个策略的好处是零成本、好理解,坏处也很明显:一条轨迹里所有重标记样本的目标完全一样,Q函数接收到的都是“向同一个位置前进”的样本,变化太少,很容易过拟合到这条轨迹的特定走向。
random策略听起来更“通用”,实际很坑。因为你随机采样的目标大概率是环境中一个和当前轨迹毫无关系的状态,可能物理上根本到不了。比如机械臂可达范围就那么一小块,你随机从整个状态空间里采一个远在天边的点当目标,重算出来的奖励几乎全是-1,那和没做HER有什么区别?所以实战中我基本不用random。
future策略是目前的主流,也是我唯一的推荐。做法是:对于轨迹中的每个transition(s_t, a_t, s_{t+1}),从t之后的状态里随机采样一个s_m作为g′,判断s_{t+1}是否达到该目标。这里有个关键物理约束:m必须大于t。因为你选的是“未来”的状态来当作“事后目标”,如果选一个已经滑过去的历史状态当目标,时序就混乱了,重标记出来的奖励也基本全是-1,毫无学习价值。future策略好在哪?它对同一段轨迹的不同transition会采样出不同目标,样本多样性大幅提升;同时越是靠后的状态,与当前transition的s_{t+1}越接近,重算后“达到目标”的概率越高,正样本比例上去了,学习信号自然更密集。
2.2 k值、回放比例与奖励函数:四个参数定生死
把future策略写成代码只需要十几行,但真正决定HER能不能出效果的,是几个看起来不起眼的参数。我把它整理成一张速查表,下面逐个解释:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| k(每条transition额外重标记数) | 4 | 原文推荐值,太小样本不足,太大原始经验被冲淡 |
| 奖励函数 | 稀疏二值(达到0,未达到-1) | 密集距离奖励会让HER增益大幅缩水 |
| 目标判定阈值ε | 视任务而定,如Fetch环境0.05 | 阈值决定“成功”的判定标准,不宜过大 |
| 回放比例 | 原始样本保留,额外再生成k条HER样本 | 务必保留原始经验,不能拿HER样本顶替全部 |
先说k值。future策略里的k代表每条原始transition额外生成多少条重标记样本。比如一条transition原本是一条经验,设置k=4后,它变成1条原始经验+4条HER经验,总计5条。k太小,HER的经验扩充力度不够,稀疏奖励问题依旧明显;k太大,经验池被HER样本淹没,智能体会过度关注“重标记目标”,反倒忘了真正的目标任务。我见过有人把k设成20,结果智能体在重标记目标上“自嗨”,原始目标却一直学不会。k=4是经过大量实验验证的经验起点,一般不用改。
奖励函数这一点尤其重要。HER的增益建立在二值稀疏奖励上:判断s′是否接近g′,接近给0,否则给-1。如果你原本的奖励函数是负距离-||s−g||这种密集形式,那么重标记后虽然也能算出一个值,但HER带来的边际收益很小。为什么?因为密集奖励已经提供了一部分梯度信号,重标记的“把失败轨迹变成成功轨迹”的优势体现不出来。更糟的是,密集奖励的Q值尺度波动大,训练稳定性和二值奖励差很多。所以,你要用HER,就老老实实把奖励函数改成二值判定。
目标判定阈值ε也需要单独说。判断“是否达到目标”不能要求完全等于gt + 1e-10,要给一个容忍范围。Fetch系列环境通常取0.05米,也就是末端执行器与目标点的欧氏距离小于5厘米就算成功。这个阈值不要拍脑袋设得太大,否则会出现很多“假成功”的HER样本,Q函数学到错误结论;也不要设得太小,否则正样本比例过低,训练依然艰难。
回放比例是新手最容易忽略的暗坑。很多人在实现HER时,把batch里所有transition都替换成了HER版本,结果原始目标信息完全消失。训练时智能体只见过“实际到达过的状态”当目标,真正的任务目标g反而没有样本去学。正确的做法是:原始transition一定保留,额外的k个HER样本作为补充。我在代码里习惯让原始样本与HER样本各占一半,也就是每条原始transition配1条HER样本,或者按原文配4条HER样本但批量足够大时原始样本占比依然可观。这个比例是训练稳定性与对外解决问题能力之间的平衡点。
3. 手写HER最小实现:从回放缓冲区到训练循环
3.1 重写ReplayBuffer:把整条轨迹存下来是前提
如果你之前写过标准DQN或DDPG,肯定熟悉那种“单条transition存入循环队列”的回放缓冲区。但HER不能用那种结构,因为future策略需要知道“当前transition之后的状态序列”。一条transition单独存在buffer里,根本不知道它属于哪条episode,更拿不到后续状态。所以第一步就是改造存储结构:从“存单条transition”升级为“存完整episode”。
from collections import deque import random class EpisodeBuffer: def __init__(self, capacity): self.buffer = deque(maxlen=capacity) def push(self, episode): # episode: list of (s, a, s_next, done, g_original) self.buffer.append(episode) def sample_episodes(self, batch_size): # 随机抽batch_size条完整轨迹 return random.sample(self.buffer, batch_size)这个结构本身很简单,但有一个工程代价要提前说:存整条episode比存单条transition占内存得多。在机器人仿真环境里,一条episode可能有几百步,每一步要存状态、动作、下一状态、奖励、目标、done标记,如果状态是图像或高维向量,内存压力会直线上升。我用过的容量配置是1e5条episode左右,再大会吃紧。实际训练时可以把episode长度控制在一个合理上限,或者在采样后立即释放不需要的中间变量。
还有一个细节:一条episode里,每步transition的原始目标g在整条轨迹中通常是固定不变的(目标条件任务里目标不会中途换)。所以你不需要每一步都存一份完整的g,可以在episode层面单独存一份,采样时再广播。但这个优化比较次要,新手可以先不做,把逻辑写清楚最重要。
3.2 重标记伪代码与关键实现:future策略的采样逻辑
接下来是最核心的部分:对一条完整episode做future策略重标记。我建议写一个专门的重标记函数,输入一条episode和对应的transition索引,返回原始transition + 额外生成的k个HER transition。
import numpy as np def reward(s_next, goal, threshold=0.05): # 二值奖励:达到目标给0,否则-1 dist = np.linalg.norm(s_next - goal) return 0.0 if dist <= threshold else -1.0 def relabel_transition(episode, t, k=4, threshold=0.05): T = len(episode) s, a, s_next, done, g_original = episode[t] # 原始transition一定保留 original_transition = (s, a, reward(s_next, g_original), s_next, done, g_original) # 额外生成k条HER transition extra_transitions = [] for _ in range(k): # 从当前步之后的未来状态中采样目标 m = random.randint(t + 1, T - 1) g_prime = episode[m][0] # 取未来状态作为目标 r_prime = reward(s_next, g_prime, threshold) extra_transitions.append((s, a, r_prime, s_next, done, g_prime)) return [original_transition] + extra_transitions这里有三个细节值得反复确认,都是我调试时踩过坑的地方。
第一,m的采样范围必须是[t+1, T),绝不能包含t。很多人一时手快写成了random.randint(t, T-1),导致目标就是当前轨迹里的当前状态,重算出来的奖励几乎全为-1,HER等于没做。我在代码里直接写t + 1,从物理逻辑上杜绝这个问题。
第二,g_prime取的是episode[m][0],也就是未来某个时间步的状态s_m,而不是episode里那个时间步的目标g_m。有些同学把这两者搞混,拿“未来的原始目标”来当重标记目标,那就完全失去HER的语义了。HER的定义就是“用实际到达的状态作为新目标”,不是“用未来的任务目标”。
第三,计算r_prime时比较的是当前transition的下一个状态s_next与g_prime的距离,不是拿s_m和g_prime比。因为这条transition表达的是“在s_t做了动作a,转移到了s_next”,这个步骤是否成功,只看s_next有没有达到目标。哪怕这条轨迹到最后确实接近了s_m,那也是一个逐步累积的结果,你只能在这一步的“即时距离”上给奖励。
训练时的batch组装逻辑也很直接:从EpisodeBuffer中随机抽batch_size条episode,对每条episode的每个transition调用relabel_transition,得到若干transition,再统一打平组成一个batch喂给网络。如果担心内存,可以限制每条episode最多保留的额外样本数,或者对过长episode做截断采样。
3.3 接入TD3的实操细节与超参推荐
HER不是独立算法,它必须叠加在某个基础的强化学习算法之上。离散动作空间可以接DQN系列,连续动作空间我推荐TD3或SAC。这里以TD3为例,讲几个接入时最容易出问题的细节。
第一个是网络输入构造。TD3的Actor和Critic都需要同时接收状态s和目标g,最简单的做法是直接拼接:把state和goal在特征维度上concat成一个长向量,作为网络的第一层输入。比如7维状态+7维目标,输入维度就是14。如果你的目标只是状态的一个子集(比如机械臂任务里目标只有末端位置3维),那就只拼接对应维度,不要硬把整个状态都拼上去,否则网络要自己学“哪些维度与目标相关”,收敛会变慢。
第二个是batch size分配。假设k=4,那你一条原始transition会变成5条样本。我一般设整体batch size为256,其中原始样本约64条,HER样本约192条。如果在采样器里直接按比例分配,训练更稳定。不要让全batch都是HER样本,否则原始目标会逐渐被“遗忘”。
第三个是TD3自带的延迟更新、目标策略平滑噪声、双Q网络这些机制,完全不需要因为HER而改动。HER只影响经验来源和奖励计算,不碰策略更新公式。下面是一份我跑Fetch系列环境时验证过的配置表:
| 参数 | 值 | 备注 |
|---|---|---|
| 基础算法 | TD3 | 连续控制综合表现稳定 |
| 学习率 | 3e-4 | Actor和Critic统一使用 |
| batch size | 256 | 原始64 + HER 192 |
| k | 4 | future策略重标记数 |
| γ(折扣因子) | 0.98 | 机器人任务常用,略低有利于短期导向 |
| τ(目标网络更新率) | 0.005 | TD3默认 |
| policy delay | 2 | TD3默认 |
| 探索噪声 | N(0, 0.1) | 动作加噪 |
| 网络结构 | [256, 256] | ReLU激活 |
最后一点非常重要:评估阶段不要使用HER重标记。HER只是训练阶段的“数据增广”,在真正部署或评估时,输入必须是当前状态和原始任务目标,输出也必须是确定性策略(不加探索噪声)。我在跑对比实验时专门写了两个循环:训练循环里用带噪声的动作和HER重标记样本,评估循环里关闭噪声、关闭重标记,只统计原始目标下的成功率。这样你看到的曲线才是真实的算法效果。
4. 训不出效果?常见问题与排障实录
4.1 我踩过的五个坑:从奖励重算到目标归一化
我自己在复现HER时没少掉进坑里,有些坑回头一看特别低级,但当时真的能让人debug到怀疑人生。这里列五个最典型的,给各位提前排雷。
第一个坑:奖励函数没重算。有人觉得HER就是“换一个目标存经验”,于是直接把原始transition里的奖励r原封不动地塞进HER样本。这是致命的。对于重标记目标g′,原始奖励r完全失去意义。必须用r′ = reward(s_next, g′)重新计算。我一般把奖励计算写成一个独立函数,任何地方都不能绕过它。
第二个坑:原始经验被全部丢弃。我前面反复强调要保留原始样本,但真有人会在组装batch时图省事,只保留HER样本。这样做的后果就是训练初期看起来Q值涨得飞快,但成功率一动不动。因为智能体只会“做事后诸葛亮”,它所学到的都是在已到达状态之间的路径,一旦换回原始目标g就懵了。每一批数据里至少要混入20%~50%的原始样本。
第三个坑:future采样范围写错。m的取值范围必须是(t+1, T),如果取了t,目标就是当前状态本身,奖励几乎全为-1;如果取了历史状态,时序逻辑更混乱。我建议在代码里直接加上assert m > t,防止静默出错。
第四个坑:状态和目标没归一化。Fetch环境里,目标坐标可能分布在[-1.5, 1.5]之间,而关节角度量级可能在[-3, 3],如果直接拼接输入,某些维度数值范围过大,MLP的梯度更新会被大数值维度主导。我通常把状态和目标统一缩放到[-1, 1]区间,量纲差异消除后,训练稳定性和收敛速度都能明显改善。
第五个坑:目标空间过大时直接硬跑。如果你的任务目标是“整个状态空间里任意一点”,而可达空间又很大,HER重标记出来的目标也五花八门,学习难度仍然很大。遇到这种情况,先把目标限制在一个可达子集里,比如机械臂末端附近的3D球体区域,等baseline跑通后再逐步扩大。这相当于给HER做了一件“课程学习”的外衣,效果常常比盲目全空间训练好得多。
4.2 如何确认HER真的生效:训练曲线的正确看法
训练跑起来了,怎么看它有没有真生效?很多人只盯着loss曲线,但loss下降不一定代表策略变好。我自己更相信成功率曲线。
最标准的做法是:同一个环境、同一个随机种子,跑两个版本——纯TD3和TD3+HER,然后画累计训练步数与success rate的对比曲线。如果HER版本的成功率曲线明显更早抬升、峰值更高,说明HER确实在起作用。如果两条曲线几乎一样,那你的HER实现里大概率有bug,重点检查奖励重算和future采样范围。
除了成功率,我还习惯观察Q值的走向。TD3有两个Critic网络,可以分别记录它们的输出均值。在HER生效时,Q值应当稳步上升,因为越来越多的重标记“成功样本”在告诉网络“这条路径是有价值的”。如果Q值长期停在-1附近不涨,说明重标记产生的正样本比例太低,或者原始经验被HER样本淹没导致任务目标被遗忘。前者调大ε或调整奖励判定,后者调低k、提高原始样本比例。
还有一个常见的假象:loss降了、Q值也涨了,但success rate纹丝不动。这种时候要怀疑是不是HER样本太“容易”,导致Q网络过度乐观——它学会了在重标记目标上获得正奖励,却对真实目标判断无能为力。我的处理办法是:把k从4降到2,同时把原始样本比例提高到50%以上,观察成功率是否回升。如果回升,说明确实过拟合了HER样本;如果仍然不涨,再检查目标归一化和奖励阈值。
4.3 提速技巧:向量化奖励、批量重放、评估分离
HER本身不复杂,但工程实现如果太粗糙,训练速度会慢到让人没耐心。分享几个我用下来的提速经验。
奖励计算务必向量化。千万别在for循环里用Python逐条计算s_next与g′的距离,尤其是batch size动辄几百、一条episode又有几百步时,纯Python循环会拖慢训练数倍。正确做法是先把一批transition堆成numpy或torch tensor,一次性计算距离矩阵,再批量套阈值生成奖励。代码更短,速度提升明显。
组装batch时尽量走批量重放路线。从EpisodeBuffer抽到一批episode后,不要每一条episode单独forward一次网络,而是把所有重标记后的transition全部堆叠成一个大tensor,一次forward完成前后向传播。现代GPU对大批量矩阵运算的加速非常可观。
评估阶段必须独立成环。训练时为了探索需要加噪声,为了HER需要重标记,但评估时这两项都不能要。我习惯在训练循环里每n步跑一次评估:关闭动作噪声,actor输出确定性动作,环境按原始目标计算成功率,然后把结果写入日志。这样你随时能看到策略的真实水平,而不是被训练曲线迷惑。
另外,如果环境支持多进程并行采样,尽量用vectorized env或者subproc env。HER需要的是多样化的失败轨迹,并行环境可以一次性带来好几条不同走向的episode,采样效率比单环境高出一大截。我在FetchPickAndPlace上测试过,8个并行环境能把数据采集时间压到原来的1/5左右。
结尾
我个人用过不少处理稀疏奖励的方案,HER是第一个让我觉得“原来失败数据还能这么用”的算法。它不复杂,核心代码加一起不到一百行,但真正落地时,细节决定成败——奖励重算、目标采样范围、原始样本比例、目标归一化,每一步都踩过坑。如果你正在跟某个机器人任务的稀疏奖励死磕,建议先把原理吃透,再开一版带日志的对比实验,看到success rate曲线抬头的那一下,你会觉得之前所有的debug都是值得的。另一个小技巧:HER之后还能搭配课程学习或自动目标生成,但先把k=4的baseline跑稳,比什么都重要。