如果你做过机械臂抓取这类强化学习任务,大概率遇到过这种让人挠头的场景:环境跑了几十万步,奖励函数看着也没问题,智能体就是死活学不会,一直在原地打转。问题往往不在算法本身,而在于任务太稀疏——试了几千次才碰到一次成功,智能体根本拿不到正向反馈来调整策略。这时候很多人的第一反应是改奖励函数、加课程学习,或者直接上模仿学习。不过有一个思路非常巧妙且非常值得一试,那就是标题里的这个词,hindsight,也就是强化学习领域里大名鼎鼎的 Hindsight Experience Replay,简称 HER。
这个算法非常适合两类学习者:一是正在做机器人操作、导航类连续控制任务的工程师,二是被稀疏奖励问题折磨到最后只能靠手搓 reward shaping 的 RL 炼丹师。它能把你的失败经验重新变成有效训练数据,让智能体在近乎没有正反馈的情况下也能慢慢学会目标。这篇文章我会从算法本身的语义说起,把它的核心机制、代码实现、调参经验完整走一遍,尤其是那些论文里不会直接写清楚的坑,我都会摊开来讲。
1. 项目概述:hindsight 到底在解决什么问题
1.1 稀疏奖励环境下的“学不会”困局
先还原一个最典型的问题场景。假设你有一个七自由度机械臂,任务是抓住桌面上一个随机位置的小方块。整个 episode 结束后的奖励只看一件事:方块是否被抓起来了。抓起来了给正奖励,没抓到就一直是负分或者零分从头到尾。这在代码里实现起来非常干净,效果却非常残酷——在完全没有先验知识的随机策略下,机械臂连“靠近方块”都很难做到,更别说成功抓取了。一千次 rollout 里可能连一次正奖励都看不到,也就是说神经网络压根儿没收到过一条“做对了”的梯度信号。
这种就是典型的稀疏奖励问题。它跟奖励误导不一样,后者是智能体找到了作弊路径拿高分,前者是智能体连往哪个方向走都不知道。为了应对这种局面,业界的常规操作无非就几类:密集奖励就是把“距离”之类的中间量加进 reward 里,但人工设计痕迹太重,稍微复杂点的任务就很容易被钻空子或者导致策略局部最优;课程学习是在简单环境里先训练再逐步过渡到困难环境,但课程难度的自动编排本身就是个不简单的优化问题;模仿学习倒是能绕过奖励问题,但它需要演示数据,很多场景拿不到高质量的专家轨迹。
HER 提供了一种完全不同的视角:不当场修改奖励函数,也不引入外部示范,只改变利用经验数据的方式。
1.2 核心思路:失败经验如何变成成功数据
hindsight 这个词,日常语境里就是“事后看”,心理学上对应“后见之明偏差”。想想看,一个人事后复盘时总是容易觉得自己当初早该看出端倪,可当时就是看不出来。HER 这个算法做的事,恰好就是用这种“事后诸葛亮的视角”来重新审视失败轨迹。
往具体了说,当智能体没能实现原本设定的目标时,对这条失败轨迹不直接扔掉,而是重新给它安一个“事后目标”——也就是它实际上达到的那个状态。这样一来,轨迹的 reward 就不再是“失败”,而是“成功达成另一个目标”。比如机械臂没有抓到方块,但把方块推到了某个角落,那这条轨迹就可以被标记为“成功地把方块推到了那个角落”,然后放进经验池里当作一条有效经验来训练。这个过程叫作 goal relabeling,中文通常翻译成目标重标注。
这套操作在生活里也很好类比。一个小孩本来想投篮得分,结果球没进篮筐,但碰巧把球拨到了一个很刁钻的位置。你如果想让他学会投篮得分,这事确实不算成功;但如果你先把目标切换成“把球推到这个位置”,他刚才那一瞬间的动作就刚好是标准示范。多积累几次这种“歪打正着”,孩子至少先学会了拨球,再慢慢练瞄准,最终才可能升级到投篮。HER 就是这么一层一层给智能体搭台阶的。
1.3 为什么只有这类任务能套用 HER
不是所有强化学习任务都能用 HER。它有一个硬性前提,任务必须可以被描述成“达成某个目标”的形式,而且这个目标要显式地出现在状态里。具体说就是,状态量需要拆成两部分:一部分是环境本身的观测,比如机械臂的关节角、末端位置;另一部分是期望目标,比如要抓的方块的目标位置。此外环境里还必须有“当前已达到的目标”能够被观测出来,也就是 achieved goal,比如当前方块的真实位置。没有这个可得的目标状态,HER 就没法做重标注,因为你根本没有“事后目标”可以拿来替换原来的 goal。
这也是为什么 HER 天然的栖息地是机器人操作、目标导航这类任务,而不是像 Atari 游戏那种纯粹为了拿高分的场景。想套用 HER,先得确认环境状态里有没有目标信息,以及目标信息能不能半路改掉。
2. 核心机制拆解:目标重标注与经验回放
2.1 从“四元组”开始理解 HER 的数据流
大部分 off-policy 强化学习算法,比如 DQN、DDPG、SAC,训练时都是从 replay buffer 里取出一个四元组样本:当前状态 s、动作 a、奖励 r、下一时刻状态 s'。HER 在这个基础上改造了样本的构成。为了叙述清楚,这里把 s 拆成两个部分:观测 o 和目标 g。智能体的策略输入是 o 和 g 的拼接,也就是 π(a | o, g),所以每条 transition 实际要记为 (o_t, g_t, a_t, r_t, o_{t+1}, g_{t+1})。
HER 真正动刀的地方就在 g_t 和 reward r_t 这一尊一簇上。它允许你从“未来某个时刻实际达到的目标状态”里挑选一个新的目标 g' 来替换当前的 g_t。替换完成后,reward 也必须重新计算,不能用原来的 r_t,而是要根据 g' 来判断这一步是否成功。替换后的新 transition 会变成长这样:(o_t, g', a_t, r'(g'), o_{t+1}, g')。
请注意,g_{t+1} 也要被替换成 g',不能保留原来的目标不变。因为策略在下一时刻输入的目标也必须跟着变,否则网络输入和输出目标就错位了,智能体学到的会是一团浆糊。我第一次写这块代码时就没太注意 g_{t+1},结果训练曲线的诡异程度让我排查了整整两天。
2.2 目标重标注的完整形式化过程
为了让表述更精确,可以给 HER 的“事后目标”一个数学化的定义。假设原始任务有一个目标集合 G,每个 episode 开始时从 G 里采样一个真实目标 g。一个 episode 结束后,我们可以拿到一组真实的状态轨迹。重标注时的关键选择是:在轨迹的某个时刻 t 之后,选一个时刻 t',把 t' 时刻达到的状态(或状态的目标相关部分)记为新的目标 g'。
接下来看单步 transition。原始数据是 (o_t, g, a_t, r_t, o_{t+1}, g),其中 r_t 表示智能体在目标 g 下获得的奖励。重标注后变成 (o_t, g', a_t, r', o_{t+1}, g')。这里的 r' 用的是稀疏的、重新判断过的奖励,通常是一个指示函数,比如 1[dist(o_{t+1}, g') < δ],只有下一步真的接近了 g' 才给正奖励,否则就是负分或零分。
从形式上看,HER 并没有修改交互时的行为策略,智能体在当前 episode 中仍然朝着原始目标在探索。只是从经验池中采样学习时,历史数据被有意识地替换成了更可达的目标。这种“目标换一换、奖励重算一算”的做法,在数学上等于隐式地构造了一个多目标策略学习的课程,让价值函数先学会应对众多较简单的目标,再逐步泛化到真正困难的目标。
还有一层值得点透:HER 这类 relabeling 必须搭配 off-policy 算法,因为只有 off-policy 算法允许你用历史策略产生的数据来反复更新当前策略,而且这些数据经过篡改之后仍然符合“某个策略下交互得到”的约束——毕竟在正确的多目标形式下,数据 (o, g', a, r', o') 确实可以看作策略在目标 g' 下的行为记录。像 PPO 那种强 on-policy 的算法,训练集基本只能用当前策略新采的数据,HER 的重标注价值就会大打折扣,甚至引入严重方差。
2.3 四种采样策略对比与选择建议
重标注时,一个新的目标 g' 从哪个集合里抽取,直接决定算法的效果。原论文提出了四种方案,我直接把它们的逻辑和特点拉成一张表。
| 采样策略 | 目标来源 | 特点 | 适用场景 |
|---|---|---|---|
| final | 只取 episode 最后一个状态的目标部分 | 实现最简单,轨迹信息用得最少,偏向于塑造“往终点方向走”的意图 | 起点到终点结构相对单一、轨迹不太曲折的任务 |
| random | 从整个 replay buffer 里随机取一个状态 | 目标多样性最高,但可能挑出一个和当前状态毫无关系的目标,训练难度大 | 只在环境本身非常容易时才用,我基本不推荐 |
| episode | 从当前 episode 内随机取一个未来状态 | 目标来自同一条轨迹,状态分布更连贯,比 random 可控性高 | 中等复杂度任务可以逐步尝试 |
| future | 严格从当前 transition 时间步之后的未来状态里采样 | 在未来状态与当前过渡之间有真实因果联系,学习信号最干净 | 大部分机器人任务的第一选择 |
在实践里,future 基本是默认策略。原因也不难理解:t 时刻之后的状态是从当前状态开始演化产生的,把它当目标至少不会出现“目标完全够不着”的情况。由于未来状态与当前动作之间存在真实的时序依赖,目标 g' 与 o_t 之间的关联性强,critic 拟合起来相对容易。
关于重标注比例,论文和后续社区的实践通常给出一个经验值:对每条原始 transition,以大约 80% 的概率做 future 重标注,剩下 20% 保留原始目标。这个比例背后的逻辑是,完全不做重标注的话,经验池几乎没有正反馈信号;如果全部重标注,智能体可能只学会各种随手可达的目标,却忽略了真实目标本身的价值。80% 是我试过很多次之后觉得泛化最稳的数值。
3. 实操落地:把 HER 接进 DDPG 的完整流程
3.1 实验环境选型与改造思路
动手写代码前,先说环境。最容易跑通的是 OpenAI Gym 的 Fetch 系列环境,尤其是 FetchReach,它是最简单的入门级任务,HER 在它上面的表现几乎是秒收敛,适合验证你的代码链路有没有写错。如果你想自己造一个更可控的验证环境,我自己常用的做法是做一个 2D 点目标导航任务。
这个环境的规格很简单:智能体是二维平面上一个点,动作是它在一个维上的位移,范围 [-1, 1]。观测 o_t 是智能体当前位置坐标和当前速度,初始位置随机,目标 g 是一个随机出现的二维坐标。稀疏奖励规则是 dd = ||当前点位置 - 目标||,小于 0.05 就给 0,否则给 -1。说实话这个环境和抓取任务相比已经算很温和的,但如果没有 HER 的话,纯随机的点要想精准落进半径只有 0.05 的小圆盘里,概率非常低,训练起来就会观察到奖励始终卡在 -1 附近不动。
如果你想快速复现,直接用 gym.fetch 系列会省掉不少环境细节的坑。需要注意 Fetch 环境返回的 observation 字典里已经帮你拆好了 observation、achieved_goal、desired_goal 三个字段,HER 可以直接基于它写。自己造环境时,最忌讳的是把 achieved_goal 和 observation 混在一起喂网络,那样后续做重标注会非常痛苦。
3.2 网络结构与 reward 函数设计
DDPG 是 HER 最常见的基座算法,它由 actor 网络和 critic 网络组成。actor 输入是 observation 和 desired_goal 的拼接,输出一个连续动作。critic 输入是同样的拼接结果加上动作,输出一个 Q 值。为了训练稳定,两个网络都各有一个 target 网络,用软更新的方式同步参数。
一个容易忽略的点是输入特征的尺度。机械臂关节角、末端位置、目标位置往往量纲差异很大,如果不做归一化直接拼接,critic 的拟合难度会上升。我的做法是在环境层面就把所有观测和目标都标准化到 [0, 1] 或者 [-1, 1] 区间,然后再拼接。很多人在 Fetch 上复现 HER 效果不佳,第一步就该检查这里。
reward 函数和重标注逻辑是绑定的,不要单独把 reward 写成一个环境里不可变的值。计算逻辑可以抽象成独立函数,输入是 achieved_goal 和 desired_goal,输出是稀疏奖励。这样原始目标算一份奖励,重标注后的目标再算一份奖励,逻辑统一,不容易出 bug。具体代码片段如下:
def compute_reward(achieved_goal, desired_goal, threshold=0.05): dist = np.linalg.norm(achieved_goal - desired_goal, axis=-1) return -(dist > threshold).astype(np.float32)3.3 replay buffer 的 HER 采样核心代码
直接调库的话,OpenAI Baselines 的 her 模块是最经典的参考实现。不过为了讲清楚原理,我更喜欢把它的核心逻辑压缩成一个足够清晰的版本。首先,replay buffer 里存的不再是普通四元组,而是一个 episode 的完整轨迹,包含每一步的 observation、achieved_goal、action、reward、observation_next、achieved_goal_next、done。HER 的采样是在拿到了整条 episode 之后才做的,所以训练前得按 episode 为单位往里塞数据。
训练采样时,每次从 buffer 里抽出一个 batch 的 episode,然后从每个 episode 里再抽若干条 transition。对每条 transition,根据 2.3 节的比例决定走原始目标还是重标注目标。重标注逻辑如下:
def sample_her_transition(episode, t, strategy="future"): # episode: dict of lists, 长度为 T # t: 当前 transition 的时刻索引 if strategy == "future": # 从时刻 t+1 到 episode 末尾随机选一个未来时间点 future_t = np.random.randint(t + 1, len(episode["achieved_goal"])) new_goal = episode["achieved_goal"][future_t].copy() elif strategy == "final": new_goal = episode["achieved_goal"][-1].copy() elif strategy == "episode": any_t = np.random.randint(0, len(episode["achieved_goal"])) new_goal = episode["achieved_goal"][any_t].copy() else: raise NotImplementedError obs_t = episode["observation"][t] act_t = episode["action"][t] obs_next = episode["observation"][t + 1] reward = compute_reward(episode["achieved_goal"][t + 1], new_goal) new_obs_t = np.concatenate([obs_t, new_goal]) new_obs_next = np.concatenate([obs_next, new_goal]) return new_obs_t, act_t, reward, new_obs_next要注意两个小细节。一是 obs 和 goal 的拼接一定要在采样函数里统一完成,不要在环境里拼一次又到采样里拼一次,维度很容易错位。二是重标注后的 done 信号也要重新考虑,在稀疏任务里我一般就把 done 直接设成 0,避免过早结束 episode 导致正常探索被截断。
3.4 超参数配置与训练技巧
为了让整套流程跑起来,我给一套在 Fetch 系列和点导航上都验证过的参数,直接拿去用在大多数任务里能当起点。
| 超参数 | 推荐值 | 备注 |
|---|---|---|
| replay buffer 容量 | 1,000,000 | HER 对 buffer 大小非常敏感,绝对不能吝啬 |
| batch size | 256 | 太小会让重标注后的目标信号过于稀疏 |
| critic 学习率 | 1e-3 | 过大会导致 Q 值震荡,建议 Adam 优化器 |
| actor 学习率 | 1e-3 | 与 critic 保持一致即可 |
| gamma | 0.98 | Fetch 类任务我常用 0.98,导航任务 0.95 也行 |
| tau | 0.05 | target 网络软更新系数,比默认的 0.005 稍快 |
| HER 重标注比例 | 0.8 | 保留 0.2 的原始目标数据 |
| 采样策略 | future | 复杂任务只用 future,不要换 |
| 探索噪声 | OU 噪声或高斯噪声,幅度 0.2 | 训练后期可逐步衰减噪声 |
训练循环的大致节奏是,每个 episode 结束后立刻把整条轨迹塞进 buffer,每步或者每隔固定步数做一次 DDPG 更新。DDPG 更新时,从 buffer 里采样一批完整轨迹,通过上面这个函数得到新的 transition,再走标准的 critic 和 actor 梯度计算。为了保证 actor 崩溃得慢一点,我会给 actor 的梯度更新稍微加一点梯度裁剪,max norm 设为 0.5,稳定效果立竿见影。
4. 调参记录与避坑指南
4.1 训练不收敛:先怀疑这三处
我见过很多人兴冲冲地复现 HER,结果跑了几十万步智能体还在乱动,然后开始怀疑算法。其实大部分情况下问题都非常机械,按这三处排查基本能解决一大半。
第一处是 achieved_goal 的定义是否正确。点导航里,achieved_goal 就是当前位置坐标,简单直接,不容易错。但拓扑结构复杂一点的任务里,achieved_goal 往往来自某个物体的显式状态。比如推方块任务里,方块位置才是目标,机械臂末端位置不是。如果你错误地把末端位置当成了 achieved_goal,重标注出来的目标就会完全错位,智能体想学都不知道在学啥。
第二处是 reward 重算逻辑是否和新的目标同步。有些人的代码是从环境的 transition 里原样取 reward,忘了重标注后要重新调用 compute_reward,导致一条 transition 上标注的目标是新目标、奖励却是旧目标的。这种数据对 critic 来说完全自相矛盾,表现就是 Q 值一会儿上天一会儿入地,批评家直接精神分裂。
第三处是拼接顺序。我在 3.3 节里专门强调 obs 和 goal 要统一拼接,很多人用的是环境给出的 dict,却各自在网络的输入层里分别处理,然后目标在某个模块里被替换了,图省事没同步,最后网络输入维度的不同位置对应不同的语义信息。这种 bug 很难从训练曲线里看出来,只能靠细致地打印每一步的样本形状和目标数值来排查。
4.2 HER 特有的几个坑
排查完上面三处,如果还不收敛,那大概率碰到了 HER 特有的几个坑。
第一个坑是 replay buffer 太小。HER 实际上在利用海量历史失败状态来丰富目标分布,如果 buffer 只有十万容量,很多早期探索到的状态还没来得及被重新利用就被挤掉了,目标多样性受限,训练很难往前推进。我自己在点导航任务里对比过,buffer 从十万提到一百万,收敛步数几乎下降了一个数量级。这个差异非常直观,所以宁可把内存多分一点给 buffer,也别省。
第二个坑是重标注比例过高。如果你试图把每条 transition 都重标注成未来目标,actor 很容易退化成“只会追逐近在咫尺的目标”的短视策略,最终表现是真实目标任务上一直无法突破。我建议固定住 0.8 这个比例别来回调,要么先不动,要动的话也在 0.6 到 0.9 之间小幅试。
第三个坑是只在 episode 结束后才做重标注。HER 的重标注是在采样阶段发生在整条轨迹上的,而不是交互中进行。有些初学者贪图方便把重标注逻辑塞进了环境 step 里,一 episode 走一步就换一次目标,等于人为把原始任务改成了不断漂移目标的追赶游戏,策略输出会非常毛糙。
4.3 一个小型对比实验的经验记录
为了更直观地展示 HER 的威力,我拿自定义的 2D 点导航任务做了一组对比。环境是 100 个随机起点和随机目标,没有 HER 的 DDPG 跑 50 万步,成功率大概是 12%,基本靠运气。同样条件下加上 HER,future 策略,重标注比例 0.8,大约 12 万步左右成功率就到了 95% 以上。
我又单独对比了 final 和 future 两种采样策略。final 在点导航这种单峰任务里表现也不差,大概 20 万步能到 90%。但在一个更曲折的迷宫式导航任务里,final 明显力不从心,因为 episode 终点通常跟中途状态差距很大,用它做目标会导致探索信号不连续。future 几乎是把最终收敛步数砍半。这组对比说明了一个事实:future 的“时序因果”特性是它稳定好用的根本原因,不是玄学。
5. 边界与扩展:HER 适合什么、不适合什么
5.1 不适合 HER 的场景
HER 不是万灵丹,有些场景硬套反而会误导设计。首先是完全没有目标概念的任务。比如让机械臂学一个灵巧操作动作序列,整个过程没有显式的目标状态,只有“完成整套动作”这种高层判定,HER 无法找到可替换的目标向量,自然无法应用。
其次是目标状态极难达到且没有中间状态可用的任务。例如让智能体生成一段特定频率的信号,失败时状态和目标差异是全方位的,未来状态跟真实目标之间的插值意义很弱,重标注出来的目标依然几乎不可达,HER 的课程作用就基本消失了。
还有一种情况是奖励本身并非稀疏,已经做了很精细的 shaping,加 HER 意味着你的 reward 函数会被反复重算,反而跟 shaping 逻辑纠缠不清。这个时候应该做减法而不是加法。我自己遇到过类似项目,后来选择关掉 shaping,纯跑 HER 加稀疏奖励,效果反而更干净。
5.2 可以继续扩展的方向
HER 的后续发展空间其实很大,它在多目标强化学习里更像是一种基础数据增强手段。常做的扩展方向有几个:一是把 HER 和 SAC 结合,SAC 比 DDPG 更稳,搭配 future 采样在不少机器人任务上是目前的主流组合;二是把它和 meta learning 叠加,让智能体在多个任务上快速适应新目标,HER 生成的大量目标数据天然适合做元训练的支撑;三是把 HER 的逻辑延伸到 skill 发现上,先用重标注找到各种可达状态,再聚类成技能,这件事已经有人在做了。
这些方向本质上都在利用同一个洞察:失败经验里不只有失败,换一个角度总能挖出价值。个人在实际操作中的体会是,复现 HER 这件事花半天就能跑通,但真正把它的思想用透、用对,需要你对自己任务里的状态结构和目标结构有足够清晰的认识。这也是为什么我一直建议新手先在小环境里把 HER 的每个环节都打印出来看一遍,再上真实机械臂平台,这样后面踩的坑会少很多。
最后分享一个小技巧:如果你在一开始就计划用 HER,别等到训练不收敛时才想起来加,应该从环境设计阶段就把 observation、achieved_goal、desired_goal 三个字段拆好,哪怕当前任务还在用普通 DDPG 也能把数据格式先对齐。后期切 HER 时只要改几行采样逻辑,不需要重构整个训练链路。这种提前留接口的习惯,能让你省下大量的返工时间。