第一次在技术群里看到hindsight这个词时,我以为是某个复盘工具或者日志回溯产品。真正点进论文才发现,它是强化学习领域绕不开的一个算法——Hindsight Experience Replay(后见经验回放,简称 HER)。今天这篇文章就把它彻底拆开:从它解决的稀疏奖励问题,到目标替换的核心机制,再到我从零复现和调参过程中踩过的坑。如果你正在做机器人控制、策略优化,或者单纯被"奖励永远给不出来"折磨过,这篇应该能省下你不少时间。
1. hindsight在AI圈指什么?——从单词到稀疏奖励难题
1.1 一个单词,多副面孔
hindsight直译过来是"后见之明",日常语境里就是事后复盘。但在机器学习圈子里,这个名字被好几个项目用过:有做数据库时间旅行的,有做日志回溯的,而最出圈的是强化学习里的 HER 算法,全称 Hindsight Experience Replay。OpenAI 在 2017 年发表了那篇论文,讲的就是怎么让智能体从失败的经历里学到东西。这篇文章核心讲 HER,后面所有内容都围绕它展开。
很多人第一次看到"后见经验回放"这个中文翻译会有点懵。其实拆开看并不复杂:hindsight是指你回头看某条轨迹的时候,会忍不住想"如果当时目标是另一个就好了";experience replay是强化学习里经典的"经验回放"技术,也就是把智能体经历过的状态转换存到缓冲区里,训练时随机抽样。HER 就是在这个回放机制上做了一个关键改动,让原本毫无学习信号的失败轨迹也能变成有价值的训练数据。
1.2 稀疏奖励问题:强化学习的高压锅时刻
强化学习的基本套路是智能体在环境里不断试错,靠奖励信号调整策略。问题是,大多数教学 Demo 里的奖励都很"密",每做一步就有反馈,算法当然跑得欢。真实场景根本不是这样:机械臂抓取物体,只有最后把物体放到目标位置才给一个正奖励;下棋只有终局胜负才有回报;外卖调度要等整个配送链路跑完才能知道收益。这种情况下,绝大多数尝试的奖励都是零,甚至全程是负一。
没有奖励信号,算法就没有梯度,策略就一直在原地打转。我把这种状态比作"高压锅时刻":环境一直在运行,训练日志一直在输出,但 loss 曲线死水一潭,成功率永远为零。你用随机策略去推一万步,能碰上几次正向奖励都算烧了高香。这也是为什么稀疏奖励问题长期被视为强化学习落地的一道硬门槛。
1.3 为什么普通经验回放救不了场
有人可能会说,那用经验回放复用样本不就行了?这里要澄清一个关键点:经验回放解决的是样本相关性,不是样本有效性。DQN 时代提出的 replay buffer 是把历史 transition 存起来,训练时随机抽一批,避免连续样本之间的强相关性导致网络震荡。但 buffer 里存的全是"没到目标"的失败样本时,抽样一万次,奖励还是负一,算法依然学不到任何东西。
有个生活化的类比很贴切:稀疏奖励下的试错就像准备一场只有期末考的课程。密奖励等于随堂测验,错了老师立刻指出;稀疏奖励是你苦学一学期,最后只看一眼成绩单,连哪道题错了都不知道。经验回放是让你把平时做的练习反复做,但练习题本身没有标注对错,你做一万遍也提升不了期末成绩。问题的根源不在"练习量"不够,而在"有效反馈"缺失。HER 的思路就是反过来,把这些没有标注的练习题,自己动手补上标注。
2. HER核心机制拆解:把失败变成经验
2.1 一次目标替换的完整演示
HER 最有创造性的地方,是把"人类后见之明"这个认知偏差变成了算法。先看一个具体的二维导航例子。假设一个点从初始位置出发,目标是地图右上角,但跑了整整 50 步,最后停在地图左下角。这显然是一次失败轨迹,每个状态的奖励都是 -1。
传统做法是整条轨迹丢进 buffer 当废料。HER 说且慢,既然智能体最后停在了sT,那sT本身为什么不能作为目标?我们把这条轨迹里原始目标全部替换成g' = sT,然后重新计算奖励。你会发现一个有意思的现象:这条轨迹从头到尾,正好是"从起点成功走到 sT"的完整演示。最后一步当然到达了目标,奖励是 0,中间几步虽然没到,但路径本身是连贯的,学习信号瞬间从"全是负一"变成了"有梯度可寻"。
原始轨迹: s0, a0, s1, a1, ... , sT (原始目标 g,全程未达,奖励恒为 -1) 重标定后: s0, a0, s1, a1, ... , sT (替代目标 g' = sT,最后一步成功,奖励为 0)这里有个细节容易被新手忽略:HER 并不是只把最后一步改成成功就完事,而是将轨迹里所有的goal全部替换成g'。因为目标条件策略的输入是(状态, 目标),你只改最后一步而前面不给新目标,网络根本不知道"我这 50 步到底在往哪走"。必须整条轨迹都使用同一个替代目标,这条数据才自洽。
2.2 替代目标怎么选:四种采样策略
既然目标可以替换,那替换成什么就很有讲究了。OpenAI 论文里给出了四种策略,这里我逐一讲清楚,并且会说明我实际实验后的感受。
final:直接取轨迹结束状态sT。这是最简单也最稳定的方式,官方实践里经常默认推荐。episode:从同一轨迹内随机抽一个状态作为目标。样本多样性比 final 好,但和轨迹本身的走向耦合较深。random:从整个回放缓冲区里随机抽一个状态。发散性最强,但噪声也最大。future:从当前时刻之后的状态里随机抽一个。也就是"以后来的事实"作为目标,兼顾了因果性和多样性。
论文里的超参k指的是每个 transition 额外采样多少个替代目标。默认k=4,意思是原始轨迹的一个转移(s, a, s_next)除了保留原始目标那份样本外,还会再生成 4 份带不同替代目标的样本,总共塞进 buffer 5 份。这个"复制多份"的操作很重要,它让一条失败轨迹被反复利用,而替代目标的不同又带来了样本多样性。
2.3 为什么HER只改数据不改算法
HER 最让我欣赏的一点,是它没有引入任何新的策略优化公式,而是纯粹把 replay buffer 的输出加工了一下。它的输入是 off-policy 算法采样出的 transition,输出是替换过goal和reward的新 transition,然后再喂给 DQN、DDPG、SAC、TD3 这类算法正常训练。外层算法代码一行都不用动。
这种"只改数据不改算法"的设计带来了巨大的工程收益。首先它是可插拔的,你可以把 HER 封装成一个 replay buffer 模块,想用就用、想换就换。其次它容易和其他技巧叠加,后面接 layer normalization、population-based training 都不会有冲突。最后调试也方便,如果训练出问题,可以先关掉 HER 看原始算法是否有 bug,再单独排查重标定逻辑。
要注意的是 HER 天然绑定 off-policy 算法。因为重标定后的样本来自行为策略,当前策略更新后再去用这批数据,分布已经偏移,DQN 这类基于 Q 值的学习能容忍,DDPG/SAC 这类 actor-critic 也能吸收;但 PPO 这类 on-policy 算法直接吃重标定数据就会有偏差。如果你在 PPO 上套 HER,先想清楚这个分布偏移问题。
3. 落地实现:手写HER与用现成库跑通
3.1 最小实现思路与伪代码
理解机制后自己动手写一个最小实现,是彻底掌握 HER 最有效的方式。核心其实就两件事:一个能存整条轨迹的 buffer,一段在 episode 结束后执行的重标定循环。
replay = HERBuffer() for episode in range(episodes): g = sample_goal() # 采样一个原始目标 s = env.reset(goal=g) traj = [] while True: a = policy.get_action(s, g) # 目标条件策略 s_next, r, done = env.step(a) traj.append((s, a, s_next, g))# 先存原始目标 s = s_next if done: break # 后见之明重标定 for i, (s, a, s_next, g_orig) in enumerate(traj): # 原始目标也保留一份,防止策略漂移 replay.push(s, a, s_next, g_orig, compute_reward(s_next, g_orig)) # 每个 transition 额外生成 k 个替代目标 for _ in range(k): g_hat = choose_goal(goal_selection_strategy, traj, replay, i) r_hat = compute_reward(s_next, g_hat) replay.push(s, a, s_next, g_hat, r_hat)这段伪代码里有三个地方必须注意,都是我实际写过才明白的。
第一,必须等整个 episode 结束之后再统一做重标定,不能边跑边标。因为future策略需要索引当前时刻之后的状态,流式处理拿不到未来信息。第二,原始目标那份样本一定要保留,否则训练过度偏向替代目标分布,真实任务的成功率反而不升反降。第三,k=4意味着每个过渡会被复制成 5 份,buffer 容量设置时要把这个放大系数算进去,不然你以为存了 100 万步,实际有效覆盖只有 20 万步的真实转移。
3.2 用stable-baselines3开箱即用
理解了原理,真正做项目时我会直接用成熟实现。stable-baselines3的HerReplayBuffer已经做得相当完善,下面给一套可以本地直接运行的配置。先写一个简单的二维导航环境,目标是让一个点在[-5, 5]的方形区域内走到随机目标点,到达距离 0.5 以内即算成功。
import numpy as np import gymnasium as gym from gymnasium import spaces class SimpleNavigationEnv(gym.Env): def __init__(self): super().__init__() self.observation_space = spaces.Dict({ "observation": spaces.Box(-5, 5, shape=(2,), dtype=np.float32), "desired_goal": spaces.Box(-5, 5, shape=(2,), dtype=np.float32), "achieved_goal": spaces.Box(-5, 5, shape=(2,), dtype=np.float32), }) self.action_space = spaces.Box(-1.0, 1.0, shape=(2,), dtype=np.float32) self.max_steps = 50 self._step = 0 def reset(self, seed=None, options=None): super().reset(seed=seed) self._step = 0 self.state = self.np_random.uniform(-5, 5, size=2).astype(np.float32) self.goal = self.np_random.uniform(-5, 5, size=2).astype(np.float32) return self._get_obs(), {} def step(self, action): self._step += 1 self.state = np.clip(self.state + action, -5, 5).astype(np.float32) obs = self._get_obs() reward = self.compute_reward(self.state, self.goal, None) terminated = bool(reward == 0.0) truncated = self._step >= self.max_steps return obs, float(reward), terminated, truncated, {} def _get_obs(self): return { "observation": self.state.copy(), "desired_goal": self.goal.copy(), "achieved_goal": self.state.copy(), } def compute_reward(self, achieved_goal, desired_goal, info): return float(np.linalg.norm(achieved_goal - desired_goal) <= 0.5) - 1.0环境准备好了,下面配置带 HER 的 SAC 模型。注意MultiInputPolicy是必须的,因为 GoalEnv 的观测是一个包含observation、desired_goal、achieved_goal三个字段的字典。
from stable_baselines3 import SAC from stable_baselines3.common.her import HerReplayBuffer model = SAC( "MultiInputPolicy", SimpleNavigationEnv(), replay_buffer_class=HerReplayBuffer, replay_buffer_kwargs=dict( n_sampled_goal=4, goal_selection_strategy="future", online_sampling=True, max_episode_length=50, ), learning_starts=1000, buffer_size=100_000, batch_size=256, gamma=0.95, tau=0.05, train_freq=1, gradient_steps=1, policy_kwargs=dict(net_arch=[256, 256, 256]), verbose=1, ) model.learn(total_timesteps=300_000) model.save("sac_her_navigation")如果不想自己写环境,也可以直接用gymnasium-robotics里的 Fetch 系列环境,比如FetchReach-v3、FetchPush-v3,这些是机械臂操作的标准测试场景。唯一要注意的是它们需要 MuJoCo 物理引擎,虽然现在 MuJoCo 已经免费,但安装配置还是要花点时间。新手我建议先用上面的自定义环境跑通逻辑,再上复杂环境。
3.3 训练配置推荐与超参数解析
超参数这个东西,不同任务差异很大,但 HER 有几个参数是共通的,我整理成了一张表,方便你对照实验。
| 参数 | 推荐值 | 说明 |
|---|---|---|
n_sampled_goal | 4 | 每个 transition 额外生成几个替代目标。太大增加计算开销,太小样本多样性不足 |
goal_selection_strategy | future | 多数连续控制任务上 future 综合效果最好,final 最省事 |
online_sampling | True | 在轨迹实时生成时做重标定,等价于 batch 内用未来状态做目标;设 False 则按 archive 模式回放 |
max_episode_length | 与 env 一致 | 必须显式传给 buffer,否则它不知道 episode 边界,future 索引会越界 |
batch_size | 256 | 较大 batch 对目标条件学习有帮助 |
gamma | 0.95~0.99 | 任务步数越少,gamma 可以越低;长任务建议接近 1 |
net_arch | [256, 256, 256] | 目标条件和普通状态拼接后输入维度变大,三层全连接是稳妥起点 |
buffer_size | 100_000 起 | 记得这个数字会被重标定放大 k 倍 |
有一点我在调参时印象很深:online_sampling这个参数不是所有版本都有,早期版本的 SB3 里没有,你需要自己去源码里确认。它的含义是,重标定时使用的"未来状态"是来自当前正在采集的轨迹,还是来自已经存入 buffer 的旧轨迹。打开之后,buffer 里的样本时效性更强,效果通常会更好。
4. 实测效果与调参心得
4.1 稀疏奖励下的效果对比
我在上面那个简单的二维导航任务上做过一组对照实验,对比标准 SAC 和 SAC + HER 的表现。任务本身很简单,但因为奖励完全稀疏,随机策略的成功率极低。标准 SAC 跑了 30 万步,成功率曲线几乎是贴地的一条线,个别跑动的 seed 能偶尔碰出几次成功,整体学习不到稳定的策略。加上 HER 之后,大约 15 万步开始出现连续成功,30 万步时成功率稳定在 70% 以上。
这不是 HER 有多神奇的玄学,而是它把一条失败轨迹里的"