
1. 内容整体设计与思路拆解1.1 稀疏奖励到底难在哪做强化学习的这几年我翻来覆去遇到的最典型问题不是网络结构不够深不是算力不够而是奖励信号根本传不回来。以机械臂抓取为例你给它一个稀疏的二元奖励——抓到了给 1没抓到给 0——那它在一局 50 步的 episode 里前面 49 步拿到的全是 0。梯度在反向传播时要么消失要么干脆没有可用的方向信息策略网络只能原地打转。这种场景下随机探索几乎不可能碰运气碰到成功的状态。机械臂末端离目标差 1 厘米和差 30 厘米对稀疏奖励来说毫无区别智能体得不到任何你更接近了的提示。我曾经在一个倒立摆控制任务上试过纯稀疏奖励训练了 200 万步成功率纹丝不动。这不是超参问题而是方案问题在稀疏奖励面前常规 off-policy 算法的经验池里全是无效经验学什么都不可能有进展。hindsight 这个项目最初的动机就是解决这个问题。它不是一个新算法而是一套对经验池做事后重标注的通用技巧既然你没能达成原定目标那至少你达成了一些别的状态那就把那些状态当作目标去学。这套思路在机器人操作、导航、游戏等领域都非常实用凡是目标条件化的任务都可以直接用。1.2 hindsight 的核心洞察把没做到变成做到了HERHindsight Experience Replay的直觉我在博客里喜欢用一个比喻你让一个小孩投篮规定必须投进篮筐才算成功那小孩练一天可能一个球都进不了完全没有进步反馈。但如果换个说法——不管球进了没有只要你落点比上一次更接近篮筐就算完成了一次任务那每投一次球都是有效训练样本。在强化学习里这个更接近的目标怎么来答案是事后从轨迹里找。一条失败的轨迹智能体虽然没有到达原始目标 g但它在每一步都到达了某个实际位置 agachieved goal。我们把 ag 当作新的目标重新计算这条转移样本的奖励——因为智能体确实到达了 ag所以新奖励是 1。这样原本的零奖励样本就被洗成了正奖励样本经验池立刻有了有效梯度。关键点在于这套机制只对**目标条件化goal-conditioned**的策略有效。因为策略的输入是当前状态, 目标目标变了策略依旧能处理——它学习的是从任意状态到达任意目标的通用映射而不是从固定起点到固定终点的单一技能。这正好符合机器人操作的真实需求目标位置千变万化不可能为每个目标单独训练一个策略。1.3 为什么我最终选了 HER 而不是其他方案做这个项目之前我对比过几条常见路线。第一种是奖励塑形reward shaping手动设计距离势函数让奖励变得稠密。问题在于势函数设计极其依赖领域知识而且塑形不当会诱导智能体钻奖励漏洞比如绕着目标附近反复转圈刷分。第二种是课程学习curriculum learning从简单的初始分布逐步过渡到困难分布。思路没问题但自动化程度低每换一个任务都要重新设计课程并且课程推进得太快依旧会失败。第三种是内部动机intrinsic motivation比如 ICM、RND通过给新奇的状态额外奖励来鼓励探索。这类方法在探索维度确实有效但它解决的是探索不充分的问题而不是经验复用的问题两者可以共存但 HER 的思路更直接。我最后选 HER 的理由有三一是不需要任何领域知识目标从轨迹里自动提取二是算法无关能直接叠加在 DDPG、TD3、SAC 上三是实现成本极低核心代码不到一百行。对于一个要快速验证想法的小项目来说这是性价比最高的方案。2. 核心细节解析与实操要点2.1 目标条件化策略输入输出怎么设计HER 的载体是目标条件化策略所以第一步是把普通 Actor-Critic 改造成能接受目标的版本。最常见的做法非常简单把观测向量和目标向量直接拼接塞进同一个网络。以机械臂推箱子为例观测通常包括机械臂关节角度、末端执行器位置、箱子当前位置目标就是期望的箱子位置。那么网络输入是[obs(8维), goal(3维)] 11维输出是动作向量。Critic 的输入同样是这个拼接向量再加上动作向量。这里有两个实操要点。第一个是目标向量必须和观测里的对应量处于同一坐标系。比如观测里箱子位置用的是相对于世界坐标的笛卡尔坐标那目标也必须用同样的坐标不能一个用相对坐标一个用绝对坐标否则 relabel 出来的已达目标和原目标语义不一致网络会学疯。第二个是归一化要一起做。obs 和 goal 拼接之后再做标准化比分别处理更稳我在后面常见问题里还会专门展开。2.2 重标注的完整流程HER 的核心操作就发生在经验池写入的那一刻。假设一个 episode 收集了 T 步每条转移样本长这样(s, a, r, s, g, ag)其中 ag 是 s 时刻实际达到的目标。原始流程里我们直接把这条样本丢进经验池r 大概率是 0。HER 的做法是在写入前按一定概率her_ratio把这条样本复制一份替换掉里面的 g 和 r。替换的新目标从同一个 episode 中未来时刻的实际达到目标里随机抽一个然后重新计算奖励。如果新目标就是那条转移里实际到达的位置奖励就是 1。我实现时的伪代码如下每步写入一条原始样本再以概率写一条重标注样本def store_episode(self, episode): T len(episode) for t in range(T): trans episode[t] # 原始样本照常入库 self.buffer.append(trans) # 以概率做一次事后重标注 if np.random.random() self.her_ratio: future_goals [e[achieved_goal] for e in episode[t1:]] if len(future_goals) 0: new_goal episode[-1][achieved_goal] else: new_goal future_goals[np.random.randint(0, len(future_goals))] self.buffer.append({ obs: trans[obs], action: trans[action], reward: sparse_reward(trans[next_obs], new_goal), next_obs: trans[next_obs], goal: new_goal })注意这一步是在收集阶段完成的不是采样阶段。也就是说重标注后的样本会和原始样本一起长期存在缓冲区内供后续无数次梯度更新使用。每次只有一条未来样本被挑中未来取样的数量由future_k控制常见的取值是 4意思是每条转移最多考虑未来 4 条状态作为候选目标池。2.3 四种目标采样策略怎么选原论文里给了四种从轨迹中挑选替代目标的策略我逐个试过这里给一个直接的对比策略目标来源优点缺点适用场景final只取 episode 最后一步的实际目标简单、稳定样本多样性差短 episode 的启蒙实验random从整个 episode 里随机取一个实际目标目标分布广与当前转移的渐进关系弱探索早期future从当前时刻之后的实际目标里取目标与当前状态保持时序相关性训练最稳实现略复杂绝大多数连续控制任务推荐episode从整个 episode 里取但排除当前时刻之前的介于 whole 和 future 之间效果一般不太推荐我在项目里默认用future并且把候选窗口限制在当前时刻之后的 4 步内。为什么 future 效果好因为从当前状态到稍后的实际到达状态这个映射天然是一段真实发生过的短程轨迹学起来方差小。如果从 episode 头部随机抽一个目标那条转移对应的从当前状态直接到达目标可能跨度极大梯度方向噪声大训练初期很容易震荡。2.4 关键超参数与经验值HER 看似只引入两个超参数——her_ratio和future_k但这俩对训练影响非常大。我跑下来比较稳的配置是her_ratio0.8future_k4。有些项目用 0.5 也能跑但在任务目标本身比较难达成比如需要多项子任务同时满足时0.8 能更快填充正的样本。另外由于重标注使经验池的样本分布偏离原始策略的分布经验池容量要适度放大。我在 FetchReach 类任务上用 100 万容量在更复杂的推箱子任务上用到 200 万效果明显更好。容量太小会让重标注样本被频繁挤掉等于白做。提示重标注的目标是从未来选出来的这要求每条 episode 必须先完整收集完毕再写入经验池。千万不能一边 rollout 一边写入否则未来状态还不存在relabel 无从谈起。3. 实操过程与核心环节实现3.1 环境与指标设计以推箱子任务为例我不太喜欢一上来就端整整个仿真环境所以项目里先用一个玩具任务验证 HER 的有效性再迁移到连续控制。第一个环境是Bit Flipping状态是一个 N 位二进制向量目标是指定一个 N 位向量动作是翻转其中一位稀疏奖励为状态和目标完全相等给 1否则给 0。这个任务空间小可以穷举验证逻辑正确性。第二步才是连续控制我用 OpenAI Gym 里的 FetchReach 风格的推箱子变体7 自由度机械臂观测包括末端位置、箱子位置、目标位置。我这里用精简版的观测向量obs机械臂末端三维位置 箱子三维位置 其他关节信息goal期望箱子位置achieved_goal每步实时读取的箱子位置奖励函数是稀疏版本的def sparse_reward(next_obs, goal, threshold0.05): distance np.linalg.norm(next_obs[achieved_goal] - goal) return 1.0 if distance threshold else 0.0记录指标只盯两个一个是平均成功率当前 episode 结束时 achieved_goal 与 goal 的距离是否小于阈值另一个是平均回报稀疏奖励下的累计值。经验告诉我平均回报容易虚高成功率才是硬指标。3.2 HER 回放缓冲区的实现缓冲区是整个项目最核心的部分。我这里给出一个可以直接抄走的实现完整支持 future 策略和多 episode 存储。import numpy as np from collections import deque class HERBuffer: def __init__(self, capacity1000000, her_ratio0.8, future_k4): self.buffer deque(maxlencapacity) self.her_ratio her_ratio self.future_k future_k def store_episode(self, episode): episode: list of dict每个 dict 包含 obs / action / reward / next_obs / goal / achieved_goal T len(episode) for t in range(T): trans episode[t] self.buffer.append(trans) if np.random.random() self.her_ratio: horizon min(T, t 1 self.future_k) future_indices range(t 1, horizon) candidates [episode[i][achieved_goal] for i in future_indices] if len(candidates) 0: new_goal episode[-1][achieved_goal] else: new_goal candidates[np.random.randint(len(candidates))] new_reward sparse_reward(trans[next_obs], new_goal) relabeled dict(trans) relabeled[goal] new_goal relabeled[reward] new_reward self.buffer.append(relabeled) def sample(self, batch_size): indices np.random.choice(len(self.buffer), batch_size, replaceFalse) batch [self.buffer[i] for i in indices] return { obs: np.array([b[obs] for b in batch]), action: np.array([b[action] for b in batch]), reward: np.array([b[reward] for b in batch]), next_obs: np.array([b[next_obs] for b in batch]), goal: np.array([b[goal] for b in batch]), }这里有个细节future_k的实现我用了min(T, t 1 future_k)而不是直接取t1到t1future_k是为了处理 episode 尾部不够取的问题。每次 relabel 时随机抽一个候选而不是取平均保持样本多样性。3.3 策略网络与训练主循环策略网络我用了最基础的 DDPG 结构因为 HER 对算法没有特殊要求DDPG 好调试、易解释。Actor 输入拼接后的[obs, goal]输出连续动作Critic 输入[obs, goal, action]输出 Q 值。import torch import torch.nn as nn import torch.optim as optim class Actor(nn.Module): def __init__(self, dim_obs, dim_goal, dim_action, hidden256): super().__init__() self.net nn.Sequential( nn.Linear(dim_obs dim_goal, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, dim_action), nn.Tanh() ) def forward(self, obs, goal): return self.net(torch.cat([obs, goal], dim-1)) class Critic(nn.Module): def __init__(self, dim_obs, dim_goal, dim_action, hidden256): super().__init__() self.net nn.Sequential( nn.Linear(dim_obs dim_goal dim_action, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 1) ) def forward(self, obs, goal, action): return self.net(torch.cat([obs, goal, action], dim-1))训练主循环的每一轮大致是先用当前策略 rollout 一整条 episode交给 HERBuffer 存储然后从缓冲区里采一个 batch做一次标准的 DDPG 更新。def train_one_round(): buffer.store_episode(rollout(actor)) batch buffer.sample(128) obs torch.tensor(batch[obs]).float() action torch.tensor(batch[action]).float() reward torch.tensor(batch[reward]).float().unsqueeze(1) next_obs torch.tensor(batch[next_obs]).float() goal torch.tensor(batch[goal]).float() # Critic 更新 next_action target_actor(next_obs, goal) target_q reward gamma * target_critic(next_obs, goal, next_action) current_q critic(obs, goal, action) critic_loss F.mse_loss(current_q, target_q.detach()) # Actor 更新 actor_loss -critic(obs, goal, actor(obs, goal)).mean() # 软更新双目标网络 soft_update(critic, critic_target, tau0.05) soft_update(actor, actor_target, tau0.05)3.4 训练效果怎么看我在这套实现上跑 Bit FlippingN8时普通 DDPG 在 2000 个 episode 内成功率基本是 0%验证了稀疏奖励的绝望加上 HER 之后大约 400 个 episode 成功率冲到 90% 以上。在 FetchReach 变体上HER 大约在 3 万步左右开始出现成功的轨迹8 万步后成功率稳定在 80% 上下。看训练曲线时别只盯 loss 曲线因为 DDPG 的 critic loss 在 HER 下经常会反弹这是重标注样本分布不均带来的正常现象。真正要盯的是 rollout 时的实时成功率。我还习惯每 100 个 episode 手动跑一次测试 rollout测试时关闭探索噪声用确定性策略评估这样得到的是无偏的指标。4. 常见问题与排查技巧实录4.1 训练曲线一路躺平怎么排查这是我在 hindsight 项目里被问得最多的场景加上 HER 之后训练曲线还是平的怎么办。按我的排查顺序来第一先验证重标注样本到底有没有进缓冲区。很多新手把 relabel 写在了采样时而不是存储时导致每次采样现算目标结果经验池里根本存不下历史目标等于没做。你可以打印 buffer 里 reward 等于 1 的样本占比如果不到 5%说明 relabel 逻辑没生效。第二检查achieved_goal 的取值是否正确。如果你的观测里没有实时暴露这个值需要额外从环境状态里提取。我踩过一个大坑把最终目标当成了每步实际到达目标结果 relabel 出的新目标和原目标完全一样奖励还是零。第三确认稀疏奖励的阈值。threshold 设得太小比如 0.01relabel 出来的样本大概率仍然拿不到 1正样本比例依旧过低。我在推箱子任务里用 0.05 比较合适你可以根据任务尺度适当放宽先让正样本数量跑起来再慢慢收紧。4.2 HER 和不同 RL 算法的配合要点HER 虽然是算法无关的但在实践中有明显的偏好。它最适合 off-policy 算法比如 DDPG、TD3、SAC因为重标注本质上是在改写历史样本而 off-policy 方法允许反复利用旧数据正好契合。我这里给一个经验参考表算法配合 HER 的感受备注DDPG最容易跑通结构简单适合学习入门TD3更稳定Q 值低估明显缓解推荐生产级使用SAC熵项和 HER 能互补探索更好但调参压力更大PPO不推荐直接配合on-policy 策略分布偏移严重需要额外处理PPO 和 HER 配合时有一个基本的逻辑冲突PPO 要求当前采样的数据分布接近当前策略但 HER 重标注出的样本相当于想象中的经验分布早就偏了。强行混用会导致 PPO 的 importance ratio 严重失衡。如果必须用 on-policy 路线建议把 HER 用作预训练阶段的数据增强再切回正常 PPO 微调而不是全程混用。4.3 常见问题速查表现象可能原因排查/解决方案正样本占比过低her_ratio 太小或 future_k 候选窗口太短调大到 0.8 / 4训练发散loss NaN状态或目标没归一化对 obs、goal 分别做标准化成功率忽高忽低只靠 ckpt 没做确定性测试关闭探索噪声单独测试 rollout重标注样本占内存过多buffer 容量过大且全部 relabel控制 her_ratio 或增大容量上限学得快但后期卡住relabel 全选近处目标适当混入 random 策略拓展目标覆盖阈值太小导致 reward 恒零稀疏奖励阈值过严调大 threshold观察正样本向量的分布4.4 一个容易被忽略的坑目标空间的归一化这个坑值得单独写一节。我在项目初期把 obs 和 goal 分别归一化到 [-1, 1]想着这样对网络友好。结果发现 rollout 成功率极不稳定debug 了三天最终定位到问题归一化统计量是全局的但 relabel 出的目标可能落在统计区间的边缘甚至之外。举个例子原始目标只分布在 x∈[-0.2, 0.2]但 relabel 目标来自实际轨迹可能落在 x0.5超出了归一化时见过的范围。网络对这个区间完全没有泛化能力即便它已经达到过这个目标输入分布一变照样失败。我的解决方案是把归一化分成两路obs 用 run-time 统计量实时更新goal 同时用同样的统计量归一化但保留原始坐标系下的阈值判断。也就是说计算奖励时用未归一化的原始距离训练网络时才用归一化向量。这样既保证了网络输入稳定又避免了阈值判断被归一化缩放扭曲。5. 写在最后几点实操体会做 hindsight 这个项目之前我总觉得稀疏奖励任务无解只能靠堆算力、拼命调 reward shaping 的权重。跑通 HER 之后最大的感受是很多时候问题的解法不在更复杂的模型而在怎么把已有的经验用好。事后重标注这个思路朴素得近乎废话但恰恰解决了强化学习里最要命的信噪比问题。我个人在使用中最推荐的组合是 TD3 HER future 策略这是我在连续控制任务上试过最稳的配置。如果你在机器人操作、目标导航或者任何带 goal 的任务上被稀疏奖励折磨建议按本文的流程先搭一个最简实现跑通再逐步复杂化。还有一个小技巧relabel 出来的目标不要只从未来状态里抽偶尔混一点随机状态能明显缓解目标覆盖不足的问题我项目里最后的方案是 80% 用 future、20% 用 random综合效果比纯 future 更稳。这套代码我后来在几个不同类型的任务上复用每次只需要替换观测解析和目标度量函数其余完全不动。希望这篇文章能帮你少走我当初踩过的那些坑。