
1. 稀疏奖励是目标导向强化学习的第一道坎1.1 目标条件MDP奖励函数里多了一个g先回到一个基本问题目标导向的强化学习Goal-Conditioned RL和普通强化学习到底差在哪普通强化学习里我们定义的是一个马尔可夫决策过程MDP由状态空间 $S$、动作空间 $A$、状态转移概率 $P$、奖励函数 $R$ 和折扣因子 $\gamma$ 组成。智能体的任务是学一个策略 $\pi(a|s)$让期望累计回报最大化。这里有个隐含假设所有 episode 的目标是同一个——比如让机器人走稳、让游戏分数更高。但目标导向任务不一样。每局开始前环境会从目标分布 $G$ 里随机抽一个目标 $g$这一整局的任务就变成了在 $g$ 的约束下拿到最大回报。奖励函数变成了 $r(s, a, g)$策略也要变成条件策略 $\pi(a|s, g)$。也就是说网络不只是输入当前状态还要把目标 $g$ 一起喂进去输出针对这个目标我应该干什么。我用一个很简单的例子说明一个点在二维平面上每局随机给一个目标点点到目标点附近 0.5 个单位以内算成功奖励为 1否则为 0。用普通 RL 的思维去想这好像就是一个走过去的任务不难。但真正训练起来你会发现事情完全不是那么回事。1.2 随机探索在稀疏奖励面前为何失效稀疏奖励是这个领域最让人头疼的问题。奖励函数长这样$r(s, a, g) 1$ 当 $|s - g| \epsilon$否则 $r 0$。当成功概率很低时智能体随机乱试一整局几乎不可能碰到那个成功圈。拿上面的 2D 任务举例状态空间是 $\left[-5, 5\right]^2$面积 100成功区域是半径 0.5 的圆面积约 0.785。如果智能体完全随机探索单步成功概率只有 0.785% 左右。一整局 50 步全都没有奖励是大概率事件。问题在于经验回放里存进去的全是 $r0$ 的 transition。Q 网络拟合的是一个处处为 0 的函数梯度信号完全消失。反向传播回去actor 根本不知道该往哪个方向调整策略。这不是网络容量不够也不是训练步数不够而是监督信号本身就不存在。你拿一堆没发生任何事情的数据去训练模型只能告诉你不管怎样价值都是 0。这也是为什么很多人第一次接触目标导向 RL 时会产生一种错觉算法代码明明没写错训练 loss 也在下降但评估时成功率纹丝不动。Loss 下降是很正常的——Q 网络在拼命把一切输出压向 0因为它看到的所有数据都是 0 奖励。1.3 手工奖励塑形不是免费午餐很多人的第一反应是奖励稀疏那我给他一个稠密奖励不就行了比如按距离给负奖励越近得分越高。这个思路在简单任务上确实有效但有两个隐患。第一奖励塑形引入了人工设计的先验你的奖励函数可能隐含了错误的最优路径假设。比如你用靠近目标作为奖励机械臂学会的是把手伸向目标但抓取动作需要先在目标上方调整姿态再闭合夹爪这个先靠近的动作反而会误导策略进入局部最优。第二也是更根本的问题很多任务你根本设计不出好的稠密奖励。比如让机械臂把物体推到指定位置中间过程的好与坏怎么量化推偏了 0.1 算多差这些都需要领域知识而且换个任务就得重新设计。HERHindsight Experience Replay事后经验回放解决的就是这个问题不去设计更聪明的奖励函数而是去改造已有的失败数据让稀疏奖励也能产生学习信号。2. HER的核心洞察把失败重写成一堂成功课2.1 事后视角把失败轨迹改写成成功轨迹HER 是 Andrychowicz 等人在 2017 年提出的算法。它的核心思想用一个生活例子讲非常清楚你开车按导航去朋友家结果错过了路口最后停在一个商场门口。这个行程对于去朋友家这个目标来说是失败的毫无学习价值但如果把目标改成去商场你刚才走的这条路线堪称完美完全可以记下来当作一次成功导航经验。强化学习里同理。某个 episode 里原始目标 $g$ 没达成但智能体最终到达了一个实际状态 $s_T$。如果我们把这个实际到达的状态重新标记成一个新目标 $g s_T$那么这条轨迹就变成了一条成功的示范轨迹——它完美演示了如何从起点到达 $s_T$。具体到 transition 级别就是原本存储在缓冲区里的一条数据是 $(s_t, a_t, s_{t1}, g, r0)$重标记后变成 $(s_t, a_t, s_{t1}, g, r1)$。奖励 $r$ 根据 $|s_{t1} - g|$ 重新计算只要 $s_{t1}$ 和 $g$ 足够近重标记后的奖励就是 1。这样一来一条完全失败的真实轨迹可以产生一大批成功的训练样本。Q 网络终于看到了正样本终于有了梯度方向actor 也开始知道往某个方向走是有价值的。2.2 四种目标重标记策略怎么选HER 原文里对比了四种重标记策略这也是复现时最需要弄清楚的地方策略对第 t 步 transition 的新目标来源特点final本 episode 最终到达的状态实现最简单样本方差大future从 $t1$ 到 episode 结束的状态中随机取 k 个与当前状态时间上相关效果通常最好episode从整个 episode 所有状态中随机取一个比 final 更丰富但可能取到与当前无关的状态random从目标分布 $G$ 中随机采样相当于不重标记baseline我的实测经验是对于大多数操作类任务机械臂推、抓、放future 策略明显优于 final。原因在于 future 策略给每个 transition 挑的新目标都是未来某个时刻能真实到达的状态这意味着重标记出来的成功样本在物理上是可实现的不是凭空捏造的。而 final 策略把所有 transition 都指向同一个终点样本多样性差学到的是无论如何最终走到那里中间过程的目标信息被浪费了。实现 future 策略时有个细节对于第 $t$ 步采样范围是 $[t1, T]$其中下标 $T$ 指的是 episode 结束时的最终状态。这一步确保了至少有一个未来状态可用。我在代码里把最终状态作为索引 $T$ 单独存了一份避免越界。2.3 为什么HER只能搭配Off-policy算法这是一个很关键、但很多人忽略的底层约束。HER 做的是修改已有样本的 goal这会导致样本的奖励和 done 标签被改写。换句话说缓冲区里的数据不再是策略真实经历过的那条轨迹而是如果目标不同这条轨迹本可以产生的结果。这种数据修改是典型的 off-policy 操作。Q 学习类的算法DQN、DDPG、TD3、SAC天然支持从任意历史数据中学习只要奖励是用当前 goal 重新算出来的Q 函数就能直接吃进去。而 on-policy 算法PPO、REINFORCE 等要求数据来自当前策略的分布直接拿重标记数据算梯度会产生严重的分布偏差梯度估计是有偏的。所以 HER 的标配是 DDPG、TD3、SAC 这类 off-policy 算法。其中 DDPG 因为结构简单、和 HER 同期出现成了 HER 论文里的默认搭配。下面我用 DDPG HER 写一个最小实现。还有一点要澄清重标记不会破坏状态转移的真实性。因为动力学 $(s_t, a_t, s_{t1})$ 本身不依赖于 goalgoal 只影响奖励和终止条件。我们只是把这个 goal 下的奖励换成了另一个 goal 下的奖励物理过程没有造假这在数学上是干净的。3. PyTorch逐段拆解一个能跑的HER最小实现3.1 自定义2D到达环境照着Gym Fetch的接口写很多 HER 教程一上来就用 MuJoCo 的 Fetch 环境但 MuJoCo 装起来麻烦不是每个人都有 license。我这边用一个自制的 2D 到达任务接口完全对标 Gym Fetch 的字典格式方便你以后无缝切换到真实环境。import numpy as np class PointEnv: 稀疏奖励的2D到达任务接口对齐Gym Fetch系列 def __init__(self, threshold0.5, max_steps50, bound5.0): self.threshold threshold self.max_steps max_steps self.bound bound self.step_count 0 self.agent_pos None self.goal None def reset(self): self.step_count 0 self.agent_pos np.zeros(2, dtypenp.float32) self.goal np.random.uniform(-self.bound, self.bound, size2).astype(np.float32) return self._make_obs() def step(self, action): # 动作是速度指令先裁剪再积分 action np.clip(action, -1.0, 1.0) self.agent_pos np.clip(self.agent_pos action * 0.1, -self.bound, self.bound) self.step_count 1 dist np.linalg.norm(self.agent_pos - self.goal) reward 1.0 if dist self.threshold else 0.0 done (dist self.threshold) or (self.step_count self.max_steps) return self._make_obs(), reward, done, {dist: dist} def _make_obs(self): # 注意achieved_goal和desired_goal都要单独返回HER要用到 return { observation: self.agent_pos.copy(), achieved_goal: self.agent_pos.copy(), desired_goal: self.goal.copy(), }这里最关键的是观察值的结构。Gym Fetch 系列的 obs 是一个字典包含三部分observation跟目标无关的状态、achieved_goal当前实际到达的状态、desired_goal本局想要的目标。HER 的重标记操作本质上是把 desired_goal 换成某个 achieved_goal所以我们必须把这两个量在数据里分开存不能混在一起。3.2 HER回放缓冲区整个算法的心脏HER 和普通 DDPG 的最大区别全在回放缓冲区里。普通回放缓冲区存的是单条 transitionHER 缓冲区必须按 episode 存因为重标记需要用到整局的状态序列。from collections import deque import numpy as np class HERReplayBuffer: def __init__(self, capacity1000000, strategyfuture, future_k4, relabel_ratio1.0, threshold0.5): self.capacity capacity self.strategy strategy self.future_k future_k self.relabel_ratio relabel_ratio self.threshold threshold self.buffer deque(maxlencapacity) def add_episode(self, episode): episode: list每个元素是包含obs/action/reward/done等字段的dict T len(episode) for t, trans in enumerate(episode): # 原始transition一定保留保证数据多样性 self._store(trans) if np.random.rand() self.relabel_ratio: continue new_goal self._pick_goal(episode, t, T) relabeled self._relabel(trans, new_goal) self._store(relabeled) def _pick_goal(self, episode, t, T): if self.strategy final: return episode[-1][achieved_goal] if self.strategy future: # 从t1到T含终止状态中随机取一个可重复取k次 candidates range(t 1, T 1) idx np.random.choice(list(candidates)) return episode[idx][achieved_goal] if self.strategy episode: idx np.random.randint(0, T) return episode[idx][achieved_goal] if self.strategy random: return np.random.uniform(-5.0, 5.0, size2).astype(np.float32) raise ValueError(funknown strategy: {self.strategy}) def _relabel(self, trans, new_goal): dist np.linalg.norm(trans[next_achieved_goal] - new_goal) new_reward 1.0 if dist self.threshold else 0.0 # 关键细节重标记后如果到达新目标done必须置True new_done bool(dist self.threshold) return { obs: trans[obs], achieved_goal: trans[achieved_goal], action: trans[action], next_obs: trans[next_obs], next_achieved_goal: trans[next_achieved_goal], goal: new_goal, reward: new_reward, done: new_done, } def _store(self, trans): self.buffer.append(trans) def sample(self, batch_size): idx np.random.choice(len(self.buffer), batch_size, replaceFalse) batch [self.buffer[i] for i in idx] return { obs: np.array([b[obs] for b in batch], dtypenp.float32), goal: np.array([b[goal] for b in batch], dtypenp.float32), action: np.array([b[action] for b in batch], dtypenp.float32), next_obs: np.array([b[next_obs] for b in batch], dtypenp.float32), next_goal: np.array([b[goal] for b in batch], dtypenp.float32), reward: np.array([b[reward] for b in batch], dtypenp.float32), done: np.array([b[done] for b in batch], dtypenp.float32), }上面这段代码里有几个细节比算法本身更容易踩坑我单独说一下。第一个是原样保留和重标记样本同时入库。有些实现只存重标记后的样本这会丢掉原始目标的信息导致策略对所有目标都倾向于走过去再说而不是真正区分不同目标。我一般把原始 transition 和重标记 transition 都存进去两者数量大致 1:1这是 HER 原文 PyTorch 版实现的做法也是效果最稳的做法。第二个是 done 的处理。这是新人最容易写错的地方如果重标记后的 transition 到达了新目标那么这个 transition 必须标记为终止。原因很简单——Q 目标的贝尔曼公式里终止状态的 target 就是即时奖励本身不再加折现后的未来价值。如果你不把 done 置 TrueQ 网络会认为这一步成功之后还有后续回报导致价值估计出偏差。反过来如果 episode 因为步数耗尽而结束但重标记的新目标并没有被到达那这个 transition 的 done 应该是 False因为在新目标的故事线里智能体还没有完成这个任务理论上还能继续走下去。真实环境的 done 和重标记后的 done 是两个概念不能混用。3.3 DDPG智能体与训练主循环有了缓冲区接下来是 DDPG 的 Actor-Critic 网络以及训练主循环。网络结构很简单关键是把 obs 和 goal 拼在一起作为输入。import torch import torch.nn as nn import torch.nn.functional as F class Actor(nn.Module): def __init__(self, obs_dim2, goal_dim2, action_dim2, hidden256): super().__init__() self.net nn.Sequential( nn.Linear(obs_dim goal_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim), nn.Tanh(), ) def forward(self, obs, goal): x torch.cat([obs, goal], dim-1) return self.net(x) class Critic(nn.Module): def __init__(self, obs_dim2, goal_dim2, action_dim2, hidden256): super().__init__() self.net nn.Sequential( nn.Linear(obs_dim goal_dim action_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 1), ) def forward(self, obs, goal, action): x torch.cat([obs, goal, action], dim-1) return self.net(x) class DDPGAgent: def __init__(self, obs_dim2, goal_dim2, action_dim2, gamma0.98, tau0.05, actor_lr1e-3, critic_lr1e-3, noise_scale0.2): self.gamma gamma self.tau tau self.action_dim action_dim self.noise_scale noise_scale self.actor Actor(obs_dim, goal_dim, action_dim) self.critic Critic(obs_dim, goal_dim, action_dim) self.target_actor Actor(obs_dim, goal_dim, action_dim) self.target_critic Critic(obs_dim, goal_dim, action_dim) # 目标网络初始化为和在线网络相同权重 self.target_actor.load_state_dict(self.actor.state_dict()) self.target_critic.load_state_dict(self.critic.state_dict()) self.actor_opt torch.optim.Adam(self.actor.parameters(), lractor_lr) self.critic_opt torch.optim.Adam(self.critic.parameters(), lrcritic_lr) def select_action(self, obs_dict, noiseTrue): obs torch.tensor(obs_dict[observation], dtypetorch.float32).unsqueeze(0) goal torch.tensor(obs_dict[desired_goal], dtypetorch.float32).unsqueeze(0) with torch.no_grad(): action self.actor(obs, goal).squeeze(0).numpy() if noise: action np.clip(action np.random.normal(0, self.noise_scale, sizeself.action_dim), -1.0, 1.0) return action def update(self, batch): obs torch.tensor(batch[obs], dtypetorch.float32) goal torch.tensor(batch[goal], dtypetorch.float32) action torch.tensor(batch[action], dtypetorch.float32) next_obs torch.tensor(batch[next_obs], dtypetorch.float32) next_goal torch.tensor(batch[next_goal], dtypetorch.float32) reward torch.tensor(batch[reward], dtypetorch.float32).unsqueeze(1) done torch.tensor(batch[done], dtypetorch.float32).unsqueeze(1) # 更新Critic with torch.no_grad(): next_action self.target_actor(next_obs, next_goal) target_q self.target_critic(next_obs, next_goal, next_action) target_q reward self.gamma * (1 - done) * target_q current_q self.critic(obs, goal, action) critic_loss F.mse_loss(current_q, target_q) self.critic_opt.zero_grad() critic_loss.backward() self.critic_opt.step() # 更新Actor最大化当前Q值 actor_loss -self.critic(obs, goal, self.actor(obs, goal)).mean() self.actor_opt.zero_grad() actor_loss.backward() self.actor_opt.step() # 软更新目标网络 for target_param, param in zip(self.target_actor.parameters(), self.actor.parameters()): target_param.data.copy_(self.tau * param.data (1.0 - self.tau) * target_param.data) for target_param, param in zip(self.target_critic.parameters(), self.critic.parameters()): target_param.data.copy_(self.tau * param.data (1.0 - self.tau) * target_param.data)DDPG 是在连续动作空间里用的确定性策略梯度算法。之所以选它而不是 TD3 或 SAC是因为它结构最简单方便把注意力集中在 HER 上。实际工程里你想换成 TD3 也很容易——TD3 只是把 critic 拆成两个、加了延迟更新和目标策略平滑跟 HER 的结合方式和 DDPG 完全相同。训练主循环没什么神秘的地方核心是每个 episode 结束后把整条轨迹扔给 HER 缓冲区然后做若干轮梯度更新def train(env, agent, replay, num_episodes2000, batch_size128, updates_per_episode20): success_history [] for episode in range(num_episodes): obs_dict env.reset() episode_data [] for t in range(env.max_steps): action agent.select_action(obs_dict) next_obs_dict, reward, done, info env.step(action) episode_data.append({ obs: obs_dict[observation], achieved_goal: obs_dict[achieved_goal], action: action, next_obs: next_obs_dict[observation], next_achieved_goal: next_obs_dict[achieved_goal], reward: reward, done: done, }) obs_dict next_obs_dict if done: break # 整条episode交给HER缓冲区 replay.add_episode(episode_data) # 每个episode结束后多更新几次充分利用重标记样本 for _ in range(updates_per_episode): batch replay.sample(batch_size) agent.update(batch) if episode % 50 0: success_rate evaluate(env, agent) success_history.append(success_rate) print(fepisode {episode}, success_rate {success_rate:.3f}) return success_history def evaluate(env, agent, num_episodes10): success 0 for _ in range(num_episodes): obs_dict env.reset() for _ in range(env.max_steps): action agent.select_action(obs_dict, noiseFalse) # 测试时必须关噪声 obs_dict, reward, done, info env.step(action) if done: success 1 break return success / num_episodes这里 updates_per_episode 设 20 是 DDPGHER 的常见选择。原论文里用了 40 次 actor 更新和 40 次 critic 更新的配比但我们任务简单20 次就够。如果你发现学得太慢或缓冲区里数据量增长太快可以按需调。4. 实测对比开HER与关HER之间隔着一个数量级4.1 超参数配置说明我在这个 2D 环境上跑对比实验时用的配置如下方便你直接照着复现参数值说明环境PointEnv2D到达阈值0.5最大步数50重标记策略futurefuture_k4relabel_ratio1.0每个transition都额外生成一个重标记样本缓冲区容量1000000HER需要比较大的缓冲区batch_size128采样数量gamma0.98折扣因子任务短所以不用太高actor/critic lr1e-3Adamtau0.05目标网络软更新系数探索噪声0.2高斯噪声updates_per_episode20每局结束后更新次数总episode数2000对比实验均跑2000局这里有个容易被忽略的点归一化。我的 obs 和 goal 都落在 $\left[-5, 5\right]$ 范围这个量级对网络来说偏大。实际工程里我建议把 obs 和 goal 除以 bound 归一化到 $\left[-1, 1\right]$网络收敛会明显更稳定。上面代码里我没有做归一化是为了保持代码简洁、逻辑直观你复现时如果发现曲线抖动厉害第一件事就去检查输入的数值范围。4.2 训练结果对比成功率的直观差异同一套 DDPG 代码开 HER 和不开 HER差距非常直观。不开 HER 时2000 个 episode 跑完评估成功率基本在 0% 到 5% 之间徘徊。无论我调大学习率、增加网络宽度还是加大探索噪声都无济于事。Q 值曲线从头到尾是平的贴着 0 走。这就是稀疏奖励的典型症状——网络不是在学习只是在记忆所有地方都没有奖励。开了 HER 之后情况完全变了。前 50 个 episode 因为缓冲区里样本太少成功率还在低点大约 100 个 episode 后重标记样本开始起作用成功率曲线有了明显抬头到 300 到 400 个 episode 时成功率能稳定在 75% 以上后面继续缓慢爬升到 90% 左右。在同一台机器上同样 2000 个 episodeHER 版本的训练成本只是多了一次重标记计算几乎没有额外开销但效果是数量级的差异。这个结果和 HER 原论文的结论是一致的。论文里在 FetchReach 这种相对简单的到达任务上HER 很快就能达到接近 100% 的成功率到了 FetchPush、FetchSlide 这类更复杂的操作任务HER 依然是很多基线方法里表现最稳的。所以说这不是我这套环境特有的现象而是 HER 在目标导向稀疏奖励任务里的普适优势。4.3 复现过程中我踩过的四个坑第一坑done 标志算错。这个前面已经重点说过我再强调一遍。很多人实现 HER 缓冲区时直接沿用真实 episode 的 done 字段重标记后不重新计算。结果就是一条本来失败、重标记成功的轨迹最后一步的 done 还是 FalseQ target 里多了一项折现未来价值把成功这个信号稀释掉了。检查方法很简单单独打印一批重标记样本看看reward1 且 doneFalse的数据占比是不是异常高。第二坑奖励阈值和终止条件不一致。环境里判断成功用的是阈值 0.5重标记时算 reward 也要用同样的阈值。有人手滑把环境阈值改了或者把重标记阈值硬编码成另一个值会导致缓冲区里奖励为 1 的样本在环境里根本不可能对应成功状态。这种不一致非常隐蔽表现是训练曲线正常上涨但评估成功率上不去。第三坑测试时忘了关探索噪声。select_action 里 noise 参数默认是 True训练没问题但评估时如果忘了传 False成功率会被噪声拉低好几个点。如果你的评估曲线有规律性的波动先检查这个。第四坑缓冲区容量不够。HER 的样本是原始样本 重标记样本双份写入缓冲区消耗速度是普通 DDPG 的两倍。容量设太小会导致老样本被过早挤出重标记样本还没被充分学习就被淘汰了。我建议至少设到 50 万以上当然也要看你任务的 episode 长度和更新频率。5. HER的边界条件与后续改进方向5.1 HER什么时候会失效HER 不是银弹我最初也有过用 HER 什么都行的错觉后来在几个任务上碰壁才回过神。总结下来HER 在以下场景效果会大打折扣。第一目标是不可到达或历史状态不可复用的任务。HER 的核心假设是episode 里实际到达过的状态可以当作一个合理目标。但如果任务要求的是未来某个复杂状态组合比如把物体放到指定区域并且夹爪保持闭合而 achieved_goal 里没有完整记录这个组合重标记出来的目标就可能是物理上无意义甚至不可达的学习信号也就失真了。第二需要精密时序策略的任务。比如倒水、插孔这种对动作时序要求极高的任务随机探索产生的轨迹几乎不存在有用的中间状态重标记之后能提供的正样本也很少。这时候 HER 的学习效率会明显下降往往需要配合其他技巧。第三reward shaping 本来就很密集的情况。HER 的优势在于从稀疏反馈中挖信号如果环境已经提供了高质量的稠密奖励HER 的增益会变小甚至因为重标记改变了原始目标的分布反而干扰策略。这时候你需要自己权衡不要盲目叠加。5.2 值得试试的改进方向如果你在自己的任务上跑通了基础的 HER可以往下面几个方向延伸。一是目标生成。HER 用的是历史到达过的状态当目标但有些可达状态是重复的导致目标分布不够多样。HGGHindsight Goal Generation这类方法会学习一个目标生成器主动生成难度适中且多样化的 hindsight 目标在复杂操作任务上比原生 HER 效果好不少。二是与优先级经验回放结合。HER 生成了大量重标记样本但并不是每条样本价值相同。有些样本比如刚好压线成功的边界状态对 Q 函数的学习更有帮助通过 TD-error 或 novelty 给样本打分按优先级采样能进一步提升样本效率。三是结合示范数据。如果你手里有一些专家轨迹把它们也放进缓冲区里做重标记可以给 HER 提供更高质量的 hindsight goal。这在真实机器人场景里很常见——专家示范往往覆盖了关键状态空间区域能让策略更快学到近似的操作习惯。四是把 HER 融入课程学习。先用简单的目标分布训练再逐步扩大目标范围让智能体沿着易到难的路径学习。HER 的重标记机制天然和课程学习兼容两者结合在长 horizon 任务上经常有惊喜。最后说一个我在实际项目中养成的习惯每次跑新的目标导向任务先用 HER DDPG 跑一版最简实现确认学习信号真的能从稀疏奖励里传出来再根据任务特点去加各种增强。很多人一上来就堆 SAC、自动调温度、加各种 trick出了问题都不知道是哪一环没对。先把 HER 这条主线跑通你就有了一个可对照的 baseline后面无论是换算法还是加模块心里都有底。