hindsight,直译过来就是“后见之明”,我这次手头一个项目偏偏就起了这么个名字。说实话,最初看到这个名字我第一反应是“数据处理里的事后镜像”,但真正做完以后,我发现它其实是强化学习里一个极具启发性的算法代称——Hindsight Experience Replay(事后经验回放,简称 HER)。这个项目主要是复现并改造 HER,用一套稀疏奖励的机械臂推方块任务验证了算法的效果。如果你正被“智能体怎么都拿不到第一次正奖励”这种问题折磨,或者翻来覆去调参仍看着 loss 纹丝不动,这篇东西应该能帮到你。
1. 项目思路拆解:为什么一个“事后诸葛亮”能训练好智能体
1.1 稀疏奖励到底难在哪
强化学习的核心信号就是奖励,但现实中很多任务压根不是每一步都给反馈的。以机械臂推方块到指定坐标为例,只有方块和目标点的距离小于某个阈值(比如 5 厘米)才会得到正向奖励,其他时候奖励要么是 0,要么是 -1。这种设计很常见,毕竟真实世界的抓取任务不可能每一步都有人告诉你“你距离目标又近了 0.3 毫米”。
问题在于,稀疏奖励让整个探索过程变成大海捞针。智能体一开始完全是随机动作,它几乎不可能恰好把方块推到目标位置。于是会出现一种让人崩溃的状态:训练了几万步,replay buffer 里存的 transition 全部是负奖励或者零奖励,价值网络根本学不到任何梯度信号,策略也永远在原地打转。这不是调参能解决的问题,而是学习信号本身缺失了。
1.2 “事后”经验的魔法:HER 的核心直觉
HER 提出的出发点特别简单:一次任务失败了,预设目标没达成,但这段轨迹里智能体其实经过了无数个“可达状态”。假设任务目标是挪到 (1, 1),结果方块最后停在 (0.7, 0.7),那这段回合是不是也没用?HER 说:别浪费,把这次轨迹的目标“重标”成 (0.7, 0.7),这条轨迹立刻就从“失败轨迹”变成“成功轨迹”了——因为方块最后恰好停在了目标点上。
这就像一场考试,你原本想拿满分,最后只考了 70 分。表面上是失败,但如果你重新定义“目标就是及格线 60 分”,那这次考试就是一次成功经验,它能告诉你哪些复习策略是有效的。HER 就是把“失败”轨迹换个视角变成“正样本”,再塞回经验回放池里去训练。
我在项目里第一次看到这个思路时,有一种“这么简单怎么没人早想到”的感觉。这个算法的提出者是 OpenAI 团队,2017 年左右放出来,随后被广泛用到机器人操作、导航这类目标达成任务中。它不需要修改网络结构,不需要改奖励函数,只需要在数据采集后做一步重标注,就能显著提升稀疏奖励场景下的收敛能力。
1.3 项目选型:为什么是 DDPG 搭配 HER
HER 本身不是一个独立的训练算法,它是一种“经验包装技术”,必须配合 off-policy 强化学习算法使用。这里有个根本原因:HER 的核心操作是改 reward、改 goal,然后把修改后的 transition 放入 replay buffer。如果是 on-policy 算法(比如 PPO、TRPO),样本用完一次就要丢掉,回放池根本留不下事后重标的数据,HER 就没有施展空间。
因此项目基座选择了 DDPG(Deep Deterministic Policy Gradient)。DDPG 本身是确定性策略的 actor-critic 结构,天然带一个经验回放池,和 HER 的组合几乎零嫁接成本。后来的 TD3、SAC 也能和 HER 配合,但 DDPG 实现简单、参数少,更适合用来验证 HER 的思想。我当时也考虑过直接用 SAC,但被“歇斯底里”的超参数劝退了,DDPG 做实验最省心:线程模型简单,训练流程里任何一个时刻插进去重标记逻辑都不违和。
实际效果也印证这个选择没错。同样的二维点导航任务,普通 DDPG 跑了 200 个 episode,成功率始终是 0%;加上 HER 之后,大约 120 个 episode 左右就开始出现连续成功的轨迹。有了这个直观对比,后面的实验就顺畅了。
2. HER 算法核心细节与实现要点
2.1 目标重标注的具体操作
HER 的算法流程不是“整个 episode 全改目标”,而是有策略地重标。它先正常采样一个 episode,存下状态序列、动作序列、奖励序列。episode 结束后,针对每条 transition,按照一定概率,选择一个“替代目标” g',然后用 (s_t, a_t, s_{t+1}, g') 这四个元素重新算一条 transition,再塞进 replay buffer。
这里有个容易被忽略的点:重标注时不仅要替换 goal,还要把 reward 一并重算。原轨迹里奖励可能是 0 或 -1,但换了一个更近的目标后,这个动作轨迹可能正好达成了新目标,奖励就变成 1。如果不重算奖励,等于告诉智能体“这一步动作很差”,但实际上在新目标定义下,这步动作就是完美的,信号就错了。
伪代码层面,我项目里核心的重标过程可以写成这样:
def relabel_transitions(episode, k=4): """ episode: dict, 包含 obs, act, next_obs, 以及原始 goal k: 为每条 transition 采样 k 个 future state 作为新目标 """ relabeled = [] horizon = len(episode["obs"]) for t in range(horizon): original_transition = { "obs": episode["obs"][t], "act": episode["act"][t], "next_obs": episode["next_obs"][t], "goal": episode["goal"], } # 保留原始目标,不能全改成替代目标 relabeled.append(original_transition) # 以一定概率从 future 状态中采样新目标 if np.random.rand() < 0.5: # 从 t 之后的某个时刻,随机挑 k 个未来状态作为新目标 future_indices = np.random.randint(t, horizon, size=k) for future_idx in future_indices: alt_goal = episode["next_obs"][future_idx] new_reward = compute_reward(episode["next_obs"][t], alt_goal) trans = { "obs": episode["obs"][t], "act": episode["act"][t], "next_obs": episode["next_obs"][t], "goal": alt_goal, "reward": new_reward, } relabeled.append(trans) return relabeled批注一下,compute_reward 必须和真实环境的奖励函数保持一致,只是传入的目标变成了替代目标。很多新手在这里会踩坑,奖励函数里写死了“目标必须在 episode 最开始设定”,重标注后所有奖励都不对,训练直接崩。
2.2 三种采样策略:future、final 和 episode
HER 的原始论文里给了三种替代目标的采样方式,项目里我应该把每一种都测一遍,才能体会它们的差异。
| 策略 | 替代目标来源 | 特点 | 适用场景 |
|---|---|---|---|
| final | 整个 episode 的最后一个状态 | 最简单,方差较大,如果最后状态距离较远,重标意义有限 | 计算资源少、任务 horizon 较短的场景 |
| future | 当前时刻 t 之后的某个状态 | 目标总是落在“当前轨迹还未到达但即将可能到达”的位置,和后续状态有因果关系,经验利用率高 | 大多数机器人控制任务 |
| episode | 从整个 episode 里均匀随机挑一个状态 | 目标可包含轨迹早期状态,方差适中,多样性好 | 需要更多探索多样性的任务 |
实测下来,final 策略实现最简单,但效果不稳定,因为很多 episode 的末状态可能是“卡在墙边不动”的无效状态,拿它做目标反而让策略学到一堆垃圾。episode 策略随机性太强,有时候会选中一个非常远的状态,相当于重标后还是负样本。
我最终选的是 future 策略,也就是上面代码里写的从 t 到 horizon 之间采样 K 个状态。论文里 K 一般取 4,每条 transition 以 0.5 的概率触发重标。这个参数不用调得太精细,基本能覆盖常见场景。有一点要注意:future 策略要求状态具备可达性假设,也就是智能体从某个时刻开始有能力达到 t 之后出现的状态,在仿真环境里这个假设基本成立。
2.3 网络输入与训练细节
DDPG + HER 不是简单地把 goal 作为一个外部变量塞进网络就行,具体的输入拼接方式会影响训练结果。我的做法是,策略网络(Actor)输入是 (state, goal) 拼接后的向量,输出是动作;价值网络(Critic)输入是 (state, goal, action) 拼接后的向量,输出一个 Q 值。
这里有个不太能忽视的细节:状态和目标的特征尺度要统一。很多仿真环境里状态是三维坐标 (x, y, z),范围在 [-1, 1] 之间,但有些自定义环境里 goal 可能是角度、速度或者拼了别的量纲,直接拼接会导致网络对某一维度过度敏感。我在项目里给状态和目标分别做了一次 normalization,统一映射到 [-1, 1] 区间,训练稳定性明显提高。
另外一个经验是,DDPG 本身的探索噪声不能省。常见做法是在动作上加高斯噪声,或者用 Ornstein-Uhlenbeck 噪声。HER 能够增加样本多样性,但如果没有探索噪声,智能体永远走同一条路径,重标目标也只是反复看到相似的状态,效果大打折扣。我的项目里使用标准差逐渐衰减的高斯噪声,从 0.2 线性降到 0.02,比固定噪声效果更好。
3. 实操复现:从搭建环境到训练收敛
3.1 环境准备与安装
如果要复现论文级的实验,最经典的选择是 OpenAI Gym 的 Robotics 系列环境,比如 FetchReach、FetchPush、FetchPickAndPlace。但这类环境依赖 MuJoCo 物理引擎,安装有点麻烦。我的建议是,第一轮跑通整个算法流程,直接用自定义的二维点到达任务就够了,省去物理引擎的安装折腾,还能把注意力全部集中在 HER 的重标逻辑上。
我项目里定义了一个简化环境:一个二维平面上有 100 个随机位置的目标点,智能体是一个质点,动作是二维速度,每步更新一次位置。任务目标是让质点与目标位置的距离小于 0.05,否则奖励为 0。这个环境 100 行以内的 numpy 代码就能写完,训练速度快,适合第一时间验证 HER 的实现正确性。
安装依赖也简单,一个 conda 环境就够了:
conda create -n hindsight python=3.9 conda activate hindsight pip install numpy torch gymnasium matplotlib如果后续要挑战 Fetch 系列,可以考虑用 gymnasium-robotics 配合新版 MuJoCo,安装包命令是:
pip install gymnasium-robotics用 MuJoCo 之前要注意,需要下载对应版本的 mujoco-py 或者直接安装 mujoco 的 Python 绑定,版本匹配问题容易让人心态爆炸。我建议第一轮项目先忍住,把二维环境玩明白再说。
3.2 关键超参数配置表
HER 算法很神奇的一点是,它对超参数的要求并不苛刻,但还是有几个值会直接影响结果。我整理了一份自己实测用的配置,方便抄作业:
| 参数名 | 取值 | 说明 |
|---|---|---|
| replay buffer 容量 | 1,000,000 | 足够大,否则未来状态采样很容易覆盖掉旧经验 |
| batch size | 256 | 不宜太小,重标后的样本多样性需要 batch 撑起来 |
| actor/critic 学习率 | 1e-3 | 使用 Adam 优化器 |
| 折扣因子 gamma | 0.98 | 任务长度 50 步,gamma 不用太接近 1 |
| K(future 采样数) | 4 | 每条 transition 生成 4 个新目标样本 |
| 重标概率 | 0.5 | 触发重标的概率,过高会让原始目标占比偏低 |
| 目标网络 soft update tau | 0.05 | DDPG 常用 0.001-0.005,这里实际项目里我用了 0.05,收敛更快 |
| 训练步数上限 | 2e5 | 二维任务大概 10 分钟内能看到效果 |
| 每 episode 最大长度 | 50 | 太短导致任务无法完成,太长导致训练变慢 |
这份配置在二维环境里效果很明显。如果换成 FetchPush 这类机械臂任务,K 可以保持 4,buffer 容量越大越好,训练步数要乘以 10 起步。不要照搬论文里的所有参数,论文是为了泛化对比,实际项目要根据任务长度和状态维度调整。
3.3 训练主循环与 HER 重标注代码实现
训练主循环最需要注意的一点:HER 重标不是实时进行的,而是在一个 episode 结束后统一处理。如果你的代码里每采样一条 transition 就把 state 塞进 replay buffer,那就无法做重标了,因为你需要知道 episode 后续的所有状态才能用 future 策略。所以正确做法是先开一个临时列表收集当前 episode 数据,结束后再统一清空进入 buffer。
我项目里的训练主循环大概长这样:
# 伪代码 memory = ReplayBuffer(capacity=1000000) for episode in range(2000): obs = env.reset() episode_buffer = [] for t in range(max_episode_steps): goal = obs["goal"] action = actor.select_action(obs["observation"], goal) + exploration_noise next_obs, reward, done, _ = env.step(action) episode_buffer.append({ "obs": obs["observation"], "act": action, "next_obs": next_obs["observation"], "goal": goal, "reward": reward, }) obs = next_obs if done: break # episode 结束后,先保存原始 transition for trans in episode_buffer: memory.store(trans["obs"], trans["act"], trans["rew"], trans["next_obs"], trans["goal"]) # 这里注意:episode 的临时 dict 需要在最后一并重标 # 重标逻辑 relabeled_buffer = relabel_episode(episode_buffer, k=4) for trans in relabeled_buffer: memory.store(trans["obs"], trans["act"], trans["rew"], trans["next_obs"], trans["goal"]) # 从 memory 里采样 batch 训练 DDPG for _ in range(40): # 每 episode 训练 40 次 batch = memory.sample(batch_size=256) ddpg_update(batch)这里有个很多人容易犯的错误:以为只要 replay buffer 里“有”重标后的 transition 就行,于是把所有原始 transition 重标一遍。我建议原始目标和替代目标之间保持 50% 以上的占比,原始目标能帮策略记住真正的任务分布,替代目标则是用来填补稀疏奖励的共享梯度。全改成替代目标,智能体确实会学会“如何到达某个状态”,但它会忘掉“如何到达预设目标”。
还有个工程细节:ReplayBuffer 的容量是按“transition(状态转移)”计数的,不是按 episode 计数。HER 的 future 策略里一次重标会生成 K 个新 transition,buffer 很容易被重标样本塞满。我实测后建议,buffer 里原始样本和重标样本的比例控制在 1:1 左右比较合理,如果感觉重标样本太多,可以降低重标触发概率到 0.3。
4. 训练中的坑与排查实录
4.1 训练不收敛、奖励一直为 0
先说最常见的现象:loss 在下降,但成功率一直是 0%,智能体就像无头苍蝇。这种时候先别怀疑 HER 没用,先去检查两件事。一是替代目标的范围是否合理。我犯过一次低级错误:自定义环境里目标坐标是无界的,重标时会选中一个极其远的 future state,相当于给智能体定义了一个“永远完不成的目标”,导致所有重标样本依然是负样本,HER 彻底失效。
解决方法是把目标范围限制在 episode 实际可达的区域内,或者更保险的方法是只从 next_obs 序列中采样,而不是从整个状态空间采样。
二是检查 reward 计算函数是否把 nxt_obs 和 goal 给组合对了。HER 的 reward 必须是基于“当前 next_obs 与替代 goal 的距离”来计算的,如果你写成了“当前 obs 距离替代 goal”,那每一步的奖励都滞后一个动作周期,策略学到的是错误的时序关系。这两类问题都是硬编码细节错误,比算法问题更隐蔽,半天排查不出来很正常。
4.2 重标注目标太多导致策略“精神分裂”
另一个典型情况是训练前期还行,后期成功率停滞甚至倒退。我观察下来,这通常是重标目标占总样本比例过高导致的。原因很好理解:如果 replay buffer 里大部分样本都是“来自轨迹不同位置的目标”,那策略会被迫去迎合一堆没出现过的目标。今天学的是去轨迹中段的位置,明天学的是去末位置,甚至同一个状态对应了好几个不同 goal 的样本,actor 的梯度方向被拉扯得乱七八糟。
解决办法有几招,实测下来都有效:第一,降低重标概率,从 0.5 降到 0.3,优先保证原始目标的样本量;第二,减少 K 值,K=2 就已经能满足大部分需求,K=4 适合状态空间特别大的任务;第三,在同一个 batch 中刻意保证原始目标和替代目标的比例是 1:1,而不是随机采样。第三招在工程上最直接,但不回归复现论文实验的话,第一招最省事。
实际上,我在项目里调好之后,K=4 和 0.5 的重标概率还是能稳定跑,但前提是 batch size 足够大(256+),让每个 batch 里原始目标不至于被淹没。如果 batch 只有 64,重标样本占比过高的问题会立刻暴露。
4.3 训练速度慢、显存占用异常
很多人忽略 HER 对内存的消耗。一条原始轨迹经过重标后会变成 1 + K 条 transition,K=4 意味着数据量直接扩大了 5 倍。如果 replay buffer 容量开 100 万,实际存储的物理数据可能是 500 万条以上。我之前在机器上跑 FetchPickAndPlace,buffer 没存多少 episode 内存就快满了,训练周期被磁盘交换拖慢。
解决方式是用“episode 级存储”代替“transition 级存储”。也就是说,replay buffer 只存整个 episode 的序列,训练时再从 episode 里动态采样、动态重标。这样做的好处很明显:原始数据和重标目标是惰性生成的,内存占用只与 episode 数量挂钩,而且每次训练时还能随机生成不同的重标目标,样本多样性反而更高。代价是实现复杂度高一点,但收益非常可观。我项目后期就是用这种实现,才把机械臂环境的训练时间压到了可接受范围。
这里还涉及到另一个调试小技巧——观察 replay buffer 里重标样本的 reward 分布。如果发现大量重标样本的 reward 都是 0(也就是“成功”),说明替代目标太容易了,策略可能只学到“投机取巧”的方式。反之如果绝大多数 reward 还是 -1,说明替代目标选得太刁钻,需要往 final 策略靠一靠。把 reward 分布打印出来,是诊断 HER 是否正常工作最直观的手段。
4.4 一份基于实操的调参速查表
最后整理一份我踩坑后调好的“问题-解法”对照表,希望能让你少走弯路。
| 症状 | 可能原因 | 项目中的调法 |
|---|---|---|
| 成功率一直为 0,reward 恒为 0 | 目标范围过大或 reward 计算错误 | 限制目标范围,检查 reward 使用的转移下标 |
| 训练初期正常,后期波动大 | 重标样本占缓冲过高 | 降低重标概率到 0.3,或降低 K 值 |
| 策略只向“一个方向”走 | 探索噪声太小 | 提高初始噪声幅值,或使用 OU 噪声 |
| 样本收集慢、buffer 满 | transition 级存储扩展了 5 倍 | 改 episode 级存储,训练时动态重标 |
| 替代目标全在轨迹末端 | future 策略采样偏向晚期 | 改为从完整 episode 均匀采样,或混合 final 策略 |
| 两个目标相距极远,训练震荡 | 状态归一化缺失 | 对 state 和 goal 分别做标准化 |
写在最后:我的两点体会
项目做到后期,我最大的感悟是:HER 这个算法真正厉害的地方不在于“事后重标”这个技巧本身,而在于它揭示了奖励信号缺失场景下的一种通用思考方式——不要只盯着预设目标,要善于从已有轨迹里“重新定义目标”。我在跑通二维环境后,甚至尝试把 HER 用在了超参搜索的日志分析里,把每一次失败的训练试错都看作一次“重标后的经验”,结果帮我排除掉了好几个无效超参区间。
最后再分享一个小技巧:就算你不做强化学习,把 HER 的“重标思想”用在日常工作复盘里也特别有意思。每次任务没达到预期目标,先别急着否定整个过程,问一句“这次经验如果换一个评价标准,哪些环节其实是成功的?”,往往能挖出不少被忽略的增量价值。这也是我为什么愿意把这个项目命名的含义写下来——hindsight 不只是算法名,更是一种值得迁移的思维习惯。