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 步全都没有奖励,是大概率事件。
问题在于:经验回放里存进去的全是 $r=0$ 的 transition。Q 网络拟合的是一个处处为 0 的函数,梯度信号完全消失。反向传播回去,actor 根本不知道该往哪个方向调整策略。这不是网络容量不够,也不是训练步数不够,而是监督信号本身就不存在。你拿一堆"没发生任何事情"的数据去训练,模型只能告诉你"不管怎样,价值都是 0"。
这也是为什么很多人第一次接触目标导向 RL 时会产生一种错觉:算法代码明明没写错,训练 loss 也在下降,但评估时成功率纹丝不动。Loss 下降是很正常的——Q 网络在拼命把一切输出压向 0,因为它看到的所有数据都是 0 奖励。
1.3 手工奖励塑形不是免费午餐
很多人的第一反应是:奖励稀疏,那我给他一个稠密奖励不就行了?比如按距离给负奖励,越近得分越高。
这个思路在简单任务上确实有效,但有两个隐患。第一,奖励塑形引入了人工设计的先验,你的奖励函数可能隐含了错误的"最优路径假设"。比如你用"靠近目标"作为奖励,机械臂学会的是把手伸向目标,但抓取动作需要先在目标上方调整姿态再闭合夹爪,这个"先靠近"的动作反而会误导策略进入局部最优。第二,也是更根本的问题:很多任务你根本设计不出好的稠密奖励。比如让机械臂把物体推到指定位置,中间过程的"好"与"坏"怎么量化?推偏了 0.1 算多差?这些都需要领域知识,而且换个任务就得重新设计。
HER(Hindsight Experience Replay,事后经验回放)解决的就是这个问题:不去设计更聪明的奖励函数,而是去改造已有的失败数据,让稀疏奖励也能产生学习信号。
2. HER的核心洞察:把"失败"重写成一堂"成功课"
2.1 事后视角:把失败轨迹改写成成功轨迹
HER 是 Andrychowicz 等人在 2017 年提出的算法。它的核心思想用一个生活例子讲非常清楚:你开车按导航去朋友家,结果错过了路口,最后停在一个商场门口。这个行程对于"去朋友家"这个目标来说是失败的,毫无学习价值;但如果把目标改成"去商场",你刚才走的这条路线堪称完美,完全可以记下来当作一次成功导航经验。
强化学习里同理。某个 episode 里,原始目标 $g$ 没达成,但智能体最终到达了一个实际状态 $s'_T$。如果我们把这个实际到达的状态重新标记成一个"新目标" $g' = s'_T$,那么这条轨迹就变成了一条成功的示范轨迹——它完美演示了"如何从起点到达 $s'_T$"。
具体到 transition 级别就是:原本存储在缓冲区里的一条数据是 $(s_t, a_t, s_{t+1}, g, r=0)$,重标记后变成 $(s_t, a_t, s_{t+1}, g', r'=1)$。奖励 $r'$ 根据 $|s_{t+1} - g'|$ 重新计算,只要 $s_{t+1}$ 和 $g'$ 足够近,重标记后的奖励就是 1。
这样一来,一条完全失败的真实轨迹,可以产生一大批"成功"的训练样本。Q 网络终于看到了正样本,终于有了梯度方向,actor 也开始知道"往某个方向走是有价值的"。
2.2 四种目标重标记策略怎么选
HER 原文里对比了四种重标记策略,这也是复现时最需要弄清楚的地方:
| 策略 | 对第 t 步 transition 的新目标来源 | 特点 |
|---|---|---|
| final | 本 episode 最终到达的状态 | 实现最简单,样本方差大 |
| future | 从 $t+1$ 到 episode 结束的状态中随机取 k 个 | 与当前状态时间上相关,效果通常最好 |
| episode | 从整个 episode 所有状态中随机取一个 | 比 final 更丰富,但可能取到与当前无关的状态 |
| random | 从目标分布 $G$ 中随机采样 | 相当于不重标记,baseline |
我的实测经验是:对于大多数操作类任务(机械臂推、抓、放),future 策略明显优于 final。原因在于 future 策略给每个 transition 挑的新目标都是"未来某个时刻能真实到达的状态",这意味着重标记出来的成功样本在物理上是可实现的,不是凭空捏造的。而 final 策略把所有 transition 都指向同一个终点,样本多样性差,学到的是"无论如何最终走到那里",中间过程的目标信息被浪费了。
实现 future 策略时有个细节:对于第 $t$ 步,采样范围是 $[t+1, 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_{t+1})$ 本身不依赖于 goal,goal 只影响奖励和终止条件。我们只是把"这个 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, threshold=0.5, max_steps=50, bound=5.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, dtype=np.float32) self.goal = np.random.uniform(-self.bound, self.bound, size=2).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 的最大区别全在回放缓冲区里。普通回放缓冲区存的是单条 transition,HER 缓冲区必须按 episode 存,因为重标记需要用到整局的状态序列。
from collections import deque import numpy as np class HERReplayBuffer: def __init__(self, capacity=1000000, strategy="future", future_k=4, relabel_ratio=1.0, threshold=0.5): self.capacity = capacity self.strategy = strategy self.future_k = future_k self.relabel_ratio = relabel_ratio self.threshold = threshold self.buffer = deque(maxlen=capacity) 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": # 从t+1到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, size=2).astype(np.float32) raise ValueError(f"unknown 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, replace=False) batch = [self.buffer[i] for i in idx] return { "obs": np.array([b["obs"] for b in batch], dtype=np.float32), "goal": np.array([b["goal"] for b in batch], dtype=np.float32), "action": np.array([b["action"] for b in batch], dtype=np.float32), "next_obs": np.array([b["next_obs"] for b in batch], dtype=np.float32), "next_goal": np.array([b["goal"] for b in batch], dtype=np.float32), "reward": np.array([b["reward"] for b in batch], dtype=np.float32), "done": np.array([b["done"] for b in batch], dtype=np.float32), }上面这段代码里有几个细节比算法本身更容易踩坑,我单独说一下。
第一个是"原样保留"和"重标记样本"同时入库。有些实现只存重标记后的样本,这会丢掉原始目标的信息,导致策略对所有目标都倾向于"走过去再说",而不是真正区分不同目标。我一般把原始 transition 和重标记 transition 都存进去,两者数量大致 1:1,这是 HER 原文 PyTorch 版实现的做法,也是效果最稳的做法。
第二个是 done 的处理。这是新人最容易写错的地方:如果重标记后的 transition 到达了新目标,那么这个 transition 必须标记为终止。原因很简单——Q 目标的贝尔曼公式里,终止状态的 target 就是即时奖励本身,不再加折现后的未来价值。如果你不把 done 置 True,Q 网络会认为"这一步成功之后还有后续回报",导致价值估计出偏差。反过来,如果 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_dim=2, goal_dim=2, action_dim=2, hidden=256): 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_dim=2, goal_dim=2, action_dim=2, hidden=256): 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_dim=2, goal_dim=2, action_dim=2, gamma=0.98, tau=0.05, actor_lr=1e-3, critic_lr=1e-3, noise_scale=0.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(), lr=actor_lr) self.critic_opt = torch.optim.Adam(self.critic.parameters(), lr=critic_lr) def select_action(self, obs_dict, noise=True): obs = torch.tensor(obs_dict["observation"], dtype=torch.float32).unsqueeze(0) goal = torch.tensor(obs_dict["desired_goal"], dtype=torch.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, size=self.action_dim), -1.0, 1.0) return action def update(self, batch): obs = torch.tensor(batch["obs"], dtype=torch.float32) goal = torch.tensor(batch["goal"], dtype=torch.float32) action = torch.tensor(batch["action"], dtype=torch.float32) next_obs = torch.tensor(batch["next_obs"], dtype=torch.float32) next_goal = torch.tensor(batch["next_goal"], dtype=torch.float32) reward = torch.tensor(batch["reward"], dtype=torch.float32).unsqueeze(1) done = torch.tensor(batch["done"], dtype=torch.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_episodes=2000, batch_size=128, updates_per_episode=20): 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(f"episode {episode}, success_rate {success_rate:.3f}") return success_history def evaluate(env, agent, num_episodes=10): success = 0 for _ in range(num_episodes): obs_dict = env.reset() for _ in range(env.max_steps): action = agent.select_action(obs_dict, noise=False) # 测试时必须关噪声 obs_dict, reward, done, info = env.step(action) if done: success += 1 break return success / num_episodes这里 updates_per_episode 设 20 是 DDPG+HER 的常见选择。原论文里用了 40 次 actor 更新和 40 次 critic 更新的配比,但我们任务简单,20 次就够。如果你发现学得太慢或缓冲区里数据量增长太快,可以按需调。
4. 实测对比:开HER与关HER之间隔着一个数量级
4.1 超参数配置说明
我在这个 2D 环境上跑对比实验时用的配置如下,方便你直接照着复现:
| 参数 | 值 | 说明 |
|---|---|---|
| 环境 | PointEnv | 2D到达,阈值0.5,最大步数50 |
| 重标记策略 | future | future_k=4 |
| relabel_ratio | 1.0 | 每个transition都额外生成一个重标记样本 |
| 缓冲区容量 | 1000000 | HER需要比较大的缓冲区 |
| batch_size | 128 | 采样数量 |
| gamma | 0.98 | 折扣因子,任务短所以不用太高 |
| actor/critic lr | 1e-3 | Adam |
| tau | 0.05 | 目标网络软更新系数 |
| 探索噪声 | 0.2 | 高斯噪声 |
| updates_per_episode | 20 | 每局结束后更新次数 |
| 总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 个 episode,HER 版本的训练成本只是多了一次重标记计算,几乎没有额外开销,但效果是数量级的差异。
这个结果和 HER 原论文的结论是一致的。论文里在 FetchReach 这种相对简单的到达任务上,HER 很快就能达到接近 100% 的成功率;到了 FetchPush、FetchSlide 这类更复杂的操作任务,HER 依然是很多基线方法里表现最稳的。所以说这不是我这套环境特有的现象,而是 HER 在目标导向稀疏奖励任务里的普适优势。
4.3 复现过程中我踩过的四个坑
第一坑:done 标志算错。这个前面已经重点说过,我再强调一遍。很多人实现 HER 缓冲区时,直接沿用真实 episode 的 done 字段,重标记后不重新计算。结果就是,一条"本来失败、重标记成功"的轨迹,最后一步的 done 还是 False,Q target 里多了一项折现未来价值,把"成功"这个信号稀释掉了。检查方法很简单:单独打印一批重标记样本,看看"reward=1 且 done=False"的数据占比是不是异常高。
第二坑:奖励阈值和终止条件不一致。环境里判断成功用的是阈值 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 用的是"历史到达过的状态"当目标,但有些可达状态是重复的,导致目标分布不够多样。HGG(Hindsight Goal Generation)这类方法会学习一个目标生成器,主动生成"难度适中且多样化"的 hindsight 目标,在复杂操作任务上比原生 HER 效果好不少。
二是与优先级经验回放结合。HER 生成了大量重标记样本,但并不是每条样本价值相同。有些样本(比如"刚好压线成功"的边界状态)对 Q 函数的学习更有帮助,通过 TD-error 或 novelty 给样本打分,按优先级采样,能进一步提升样本效率。
三是结合示范数据。如果你手里有一些专家轨迹,把它们也放进缓冲区里做重标记,可以给 HER 提供"更高质量的 hindsight goal"。这在真实机器人场景里很常见——专家示范往往覆盖了关键状态空间区域,能让策略更快学到近似的操作习惯。
四是把 HER 融入课程学习。先用简单的目标分布训练,再逐步扩大目标范围,让智能体沿着"易到难"的路径学习。HER 的重标记机制天然和课程学习兼容,两者结合在长 horizon 任务上经常有惊喜。
最后说一个我在实际项目中养成的习惯:每次跑新的目标导向任务,先用 HER + DDPG 跑一版最简实现,确认学习信号真的能从稀疏奖励里传出来,再根据任务特点去加各种增强。很多人一上来就堆 SAC、自动调温度、加各种 trick,出了问题都不知道是哪一环没对。先把 HER 这条主线跑通,你就有了一个可对照的 baseline,后面无论是换算法还是加模块,心里都有底。