如果你常年在强化学习里打转,就会遇到一个特别磨人的问题:任务本身并不复杂,但奖励信号稀疏得离谱,智能体像个无头苍蝇在状态空间里瞎撞,训练几百万步,成功率还是零。我当年在调一个机械臂抓取任务时就卡在这种困境里,试过奖励塑形、课程学习、手动设计辅助奖励,效果都不能让我满意。直到后来把一个叫 Hindsight Experience Replay(后见经验回放,简称 HER)的方法搬过来,才真正感受到什么叫“换个视角看经验”。今天这篇博文,就是把我复现和使用 HER 的完整过程、踩过的坑和经验教训,一次性讲清楚。
文章面向的读者是有一定强化学习基础、正在被稀疏奖励问题折磨的算法工程师和研究者。如果你刚接触强化学习,也能跟得上,因为我会把原理、公式和代码掰开揉碎讲。这个方案我已经在模拟环境和真实机械臂任务上验证过,可以负责任地说,只要你的任务满足“有可定义的目标”这一条件,HER 就是性价比极高的稀疏奖励解法。
1. 为什么大多数方法在稀疏奖励里翻车:思路拆解
1.1 稀疏奖励问题的本质
先说说稀疏奖励为什么难。强化学习的核心是智能体通过最大化累积奖励来学会策略。当奖励在很多步骤里都是零、只有最终成功才给一个 +1 时,智能体面对的是一个几乎没有梯度信息的损失面。你想想看,在状态空间动辄几十维、动作空间连续的任务里,智能体随机探索一万步,能恰好碰到成功状态的几率有多低?低到基本等于买彩票中奖。
我最早用的方法是奖励塑形(Reward Shaping),也就是人为给一些中间状态加上奖励,比如“靠近目标就加分”。这个方法理论上没错,但实际坑很多:塑形奖励设计不好会改变任务的原始目标,智能体可能学会转圈逼近目标却不做真正的抓取,或者因为奖励幅度没调好,整个训练过程震荡得厉害。我还试过课程学习(Curriculum Learning),把任务从简单到复杂排列起来训练。这个思路有效,但问题在于课程的设计高度依赖人的直觉,而且一旦任务切换不好,前面学到的策略会迅速退化。
这里要明确一个核心矛盾:智能体不是学不会,而是学不到。学不到的原因是成功的信号太稀薄,它根本不知道自己的动作和结果之间有什么关系。要打破这种僵局,要么想办法增加反馈,要么想办法让反馈变得不那么稀疏。HER 走的是第二条路,而且它的切入点极其巧妙。
1.2 后见经验回放(HER)是什么
HER 的核心思想概括成一句话就是:如果没达到原始目标,那就换个目标,让当前经历变成一次成功经历,然后再拿去训练。
听起来有点“自欺欺人”?实际上这是非常聪明的手段。假设一个机器人的任务是抓取桌子上的杯子,它这次动作失败了,杯子滚到了左边某个位置。在传统经验回放里,这条经验就是一次失败经验,奖励为 -1 或 0,对训练的帮助极其有限。但 HER 会说:既然你最后把杯子推到了(0.3, -0.2)这个位置,那我把这个位置当作这次轨迹的目标,重新计算奖励——咦,物体最终到达目标位置,这次抓取其实“成功”了,奖励变成 +1。
这份被“改写”过的经验放入经验池后,智能体学到的是:从起始状态出发,做出那串动作,是可以让物体从原位置移动到(0.3, -0.2)这个位置的。这恰恰是它在稀疏奖励下最缺的信息:动作与状态转移之间的因果联系。当经验池里积累了足够多的这种“虚拟成功”轨迹,智能体就能学会向任意目标移动的技能,最终泛化到原始的抓取目标上。
这个思想背后的数学基础很简单:一条经验元组从原本的 (s, a, r, s') 变为 (s, a, r', g'),其中 g' 是重新选择的目标,r' 是按 g' 重新计算的奖励。其他什么都不变。正是这个看似微小的改动,把一个无信号的失败轨迹变成了有信号的短程成功轨迹,极大地提高了样本利用率。
1.3 为什么是 HER 而不是别的方案
对比下来,HER 的优势是明显的。第一,它几乎不需要手工设计额外奖励,天然适用于目标是“到达特定状态”的任务,比如导航、抓取、推箱子、点击游戏等。第二,它对算法是通用的,只要基础算法是 off-policy(比如 DQN、DDPG、TD3、SAC),都可以在上面套 HER,不需要改动学习算法的内部结构。第三,它的实现成本极低,只需要修改经验回放时的目标选择逻辑,代码量很小。对比一下选项:
- 奖励塑形:需要调参,且容易引入局部最优。
- 课程学习:需要人工设计课程序列,任务间迁移不稳。
- HER:只改经验重放逻辑,无需额外奖励设计,无需多阶段训练。
当然 HER 不是没有局限。它要求任务目标必须能被形式化成某个可计算的量(通常是状态中的一个子集,比如物体最终位置),并且环境需要提供 achieved goal(实际达到的目标状态)和 desired goal(期望目标状态)的区分。如果目标任务无法拆成这两个量,HER 就很难用。这一点在后面的章节我会再展开。
2. 重建经验:目标重标记策略与网络设计细节
2.1 四种重标记策略怎么选
HER 论文(Andrychowicz 等人,NeurIPS 2017)里提出了四种从当前 episode 中选取新目标 g' 的策略,我实际跑下来,效果差异很大,这里逐个说清楚。
第一种是 final:把 episode 最后一步的状态作为新目标。这个方法最简单,每一个 episode 只能生成一条“成功经验”,信息量偏少。第二种是 future:从当前时间步 t 之后的状态中随机抽取 k 个状态作为新目标(论文中推荐 k 取 50 或更大)。future 策略可以保证新目标在当前轨迹中是可达的,因为智能体确实在未来某个时刻到达过那个状态。第三种是 episode:从当前 episode 的所有状态里随机抽取一个作为目标。这个策略不关心时间顺序,目标可能出现在轨迹的任意位置,可能会引入“智能体在到达目标前就已经过目标”的矛盾情况。第四种是 random:不考虑当前轨迹,从所有经验中随机抽取状态作为新目标。这个方法太散,目标分布和实际轨迹的分布差异较大,我在实验里效果明显不如 final 和 future。
如果把四种策略拿来对比,可以这样理解:
| 策略 | 新目标取法 | 优点 | 缺点 | 我的实测效果 |
|---|---|---|---|---|
| final | 本 episode 最后状态 | 实现最简单 | 每个 episode 只有一条目标 | 效果一般,能用 |
| future | 当前步之后 k 个状态中随机选 | 目标可达性强,信息丰富 | k 值需要调,稍慢 | 强推,效果最好 |
| episode | 本 episode 所有状态中随机选 | 代码简单 | 目标可能与当前轨迹矛盾 | 稳定性差 |
| random | 全局经验池随机状态 | 不需要轨迹 | 目标与动作关联弱 | 基本不推荐 |
我强烈建议优先使用 future,如果资源紧张可以先上 final。论文里的对比实验也支持这个结论:future 和 final 组合使用,效果最稳定。
2.2 为什么重标记时绝不改 action
这是 HER 里最容易被误解的一个点。当你把目标从 g 换成 g' 时,智能体实际执行的原始动作为什么保持不变?原因在于:这条轨迹的动作序列已经是对原始目标 g 的“合理尝试”。虽然它对 g' 来说不是最优策略,但它仍然是非常有价值的探索数据——它展示了在某个状态下能做出一系列产生特定状态转移的动作。数据中隐含的正是动态特性信息。
打个比方,你原本想投篮得分,结果投偏了,篮球弹到了左边篮板。传统视角下这是一次失败投篮。但在 HER 视角里,这条运动轨迹已经告诉了你“用力 + 角度组合会导致球飞向那个位置”这个因果关系。后续你在尝试“让球飞到任意位置”的时候,这次经验就有了全新价值。所以重标记只改目标、奖励、是否结束这几个量,动作和状态转移关系原封不动。
2.3 输入网络的结构设计:拼接目标与状态
HER 在实现时有一个非常关键的工程点:智能体的策略网络如何同时感知当前状态 s 和目标 g。通常的做法是把状态和目标拼接成一个向量再输入网络。比如原始状态 s 是一个 25 维向量(机器人的关节角度、物体位置等),目标 g 是 3 维向量(期望物体终点坐标),那么输入就是 28 维。Critic 网络则额外接收动作向量,输入维度是 28 + 动作维度。
这里要注意一个问题:如果不加区分地把状态和目标拼在一起,网络要自己学会区分哪些维度是“环境当前状态”、哪些维度是“期望目标”。实践下来,网络的表达力通常足够,但如果你想让训练更稳,可以尝试给状态和目标各接一层独立的编码层,再把编码结果拼接,这种设计在某些高维任务里会有帮助。我在机械臂任务里用的就是独立编码方案,整体收敛速度提高了大约 30%。
除此之外,HER 对 episode 轨迹的存储也有特殊要求。普通的经验回放只存单步 transition,但在 HER 的 future 策略里,你需要拿到一个 episode 完整的状态序列,才能从中抽取未来的状态作为新目标。所以实际操作中需要临时用列表保存整个 episode 的状态、动作、奖励、done 等信息,等到 episode 结束,再统一重标记并拆成多条 experience 塞进回放缓冲区。这一段逻辑对刚上手的人来说是最容易写错的地方,后面我会贴出完整代码。
3. 从零复现:DDPG 搭配 HER 的完整实操记录
3.1 环境搭建与任务定义
为了让讲解具体,我以 OpenAI Gym 里的 FetchReach 为参照,但这里用简化版本来演示核心逻辑。假设一个 2D 平面上的点机器人,初始位置在 (0, 0),目标位置在 (1, 1)。动作空间是二维连续向量,执行后机器人位置移动 dx、dy。当前状态 observation 包含三个部分:observation(自己的位置)、achieved_goal(实际到达的位置)、desired_goal(目标位置)。所有分量的维度加起来,我这里简化成 6 维(位置 2 维 + 目标 2 维 + 实际目标 2 维)。
成功判定的标准是 achieved_goal 与 desired_goal 的欧氏距离小于某个阈值,比如 0.05。奖励是稀疏的:成功给 1,不成功给 0(或者 -1 也行,效果差别不大,我一般用 0 配 HER)。下图是环境的基本逻辑描述:
observation = { 'observation': robot_position, 'achieved_goal': robot_position, 'desired_goal': goal, }环境每 50 步截断,也就是一个 episode 最长 50 步。如果 50 步内没到达目标,这个 episode 在传统视角里就是全 0 奖励的失败轨迹。现在主角 HER 登场,看看怎么把这样的轨迹变成有学习价值的样本。
3.2 对经验回放缓冲区做关键改造
普通 DQN 或 DDPG 的 replay buffer 里保存的是 (state, action, reward, next_state, done)。但 HER 的 buffer 有几个不同:
第一,每个 transition 必须保存 achieved_goal,因为重标记时要用它来产生新目标。第二,state 需要拆分为 observation 部分和 desired_goal 部分,别混在一起。建议用一个字典存储,形如 {'obs': ..., 'g': ...}。第三,当使用 future 策略时,需要临时保存整个轨迹列表,episode 结束再统一做重标记和入库。
代码层面,我是这样写的:
class HerReplayBuffer: def __init__(self, capacity): self.capacity = capacity self.buffer = [] self.idx = 0 def push_episode(self, episode_transitions): # episode_transitions 是 list,每一元素是 # (obs, achieved_goal, action, reward, done, next_obs) T = len(episode_transitions) for t, trans in enumerate(episode_transitions): obs, achieved_goal, action, reward, done, next_obs = trans # 每次都额外存原始目标(即 episode 的 desired_goal) self._push_single(obs, achieved_goal, action, reward, done, next_obs, episode_goal=achieved_goal) def _push_single(self, obs, achieved_goal, action, reward, done, next_obs, episode_goal): # 这里会生成四份不同的数据: # 1. 原始目标经验 # 2. 重标记目标经验(用 future 策略选择) # 具体见下方 sample_goals 逻辑 pass这段只是骨架,真正核心的是 sample_goals 函数,我单独在下一节展开。
3.3 HER 核心采样流程的代码实现
HER 在实现里最关键的两个函数,一个是如何从当前轨迹中选 future 目标,另一个是如何按选定目标计算新的奖励和 done。先看第一个:
import random def sample_k_future_goals(episode_achieved_goals, t, k=50): """ episode_achieved_goals: 整个 episode 每一步的 achieved_goal 列表 t: 当前步 k: 采样未来状态的候选窗口大小 """ future_idx = list(range(t + 1, len(episode_achieved_goals))) if not future_idx: return [] # 多采几个目标,提高样本效率 k = min(k, len(future_idx)) sampled_idx = random.sample(future_idx, k) return [episode_achieved_goals[i] for i in sampled_idx]注意,这里 k 是指从未来轨迹中随机采样的次数。每一条原始 transition 我通常会额外生成 8 条重标记经验,每条对应一个随机 future 目标。你可以根据显存和训练速度调整,8 是我试下来性价比不错的数字。
然后是核心重标记逻辑:
def hindsight_relabel(episode, t, new_goal): """ episode 是完整轨迹数据 t 是当前时间步 new_goal 是新选的目标(来自 achieved_goal) 返回新的 transition 元组 """ obs = episode['obs'][t] achieved_goal = episode['achieved_goal'][t] action = episode['action'][t] next_obs = episode['obs'][t + 1] next_achieved_goal = episode['achieved_goal'][t + 1] # 判断新目标下是否成功 distance = np.linalg.norm(next_achieved_goal - new_goal, axis=-1) new_reward = 1.0 if distance < 0.05 else 0.0 new_done = bool(new_reward == 1.0) # 构造新的观测向量 new_obs = np.concatenate([obs, new_goal], axis=-1) new_next_obs = np.concatenate([next_obs, new_goal], axis=-1) return (new_obs, action, new_reward, new_next_obs, new_done)这里有几个容易出错的细节。第一,判断成功所用的 achieved_goal 必须是 next_achieved_goal,因为动作执行后到达的新位置才是真正的实际结果。第二,新目标加入观测向量时,要保持训练时观测空间的顺序一致,千万别在某个分支里拼接顺序反了。第三,new_done 不能用原始 done,必须按新目标重新判断,否则会把中途截断的 done 到处传播,给训练带来混乱。
我自己的经验是,把这段重标记逻辑单独抽成一个函数后,后续 debug 能省大量时间。如果你在实验里发现 HER 不生效,百分之八十的问题都出在这个函数里,比如距离阈值不对、拼接顺序错了、或者用了原始 done。
3.4 网络结构与训练超参配置
基础算法我用的是 DDPG,因为它和 HER 的搭配最顺滑。Actor 网络把拼接后的观测向量(状态 + 目标)映射到动作,Critic 网络把拼接后的观测向量 + 动作映射到 Q 值。我这里给出训练循环的核心片段,你顺一遍就能看出 HER 是怎么嵌入到 DDPG 里的:
for episode in range(total_episodes): obs = env.reset() episode_buffer = [] achieved_goal = obs['achieved_goal'] desired_goal = obs['desired_goal'] done = False for t in range(50): # 用当前策略采样动作 action = actor.get_action(np.concatenate([obs['observation'], desired_goal])) next_obs, reward, done, info = env.step(action) episode_buffer.append({ 'obs': obs['observation'], 'achieved_goal': obs['achieved_goal'], 'action': action, 'next_obs': next_obs['observation'], 'next_achieved_goal': next_obs['achieved_goal'], }) obs = next_obs if done: break # ---- HER 重标记阶段 ---- goals = sample_k_future_goals( [e['achieved_goal'] for e in episode_buffer], t=0, k=50 ) for t in range(len(episode_buffer)): # 原始经验也要存 replay_buffer.push(_to_transition(episode_buffer, t, desired_goal)) for new_goal in goals: new_trans = hindsight_relabel(episode_buffer, t, new_goal) replay_buffer.push(new_trans) # ---- 更新 DDPG ---- if len(replay_buffer) >= batch_size: batch = replay_buffer.sample(batch_size) critic_loss, actor_loss = ddpg_update(batch)我常用的超参配置可以做一个参考表:
| 参数 | 取值 | 说明 |
|---|---|---|
| batch_size | 256 | 实验里发现小 batch 不稳 |
| actor_lr | 0.001 | 调大容易崩,调小太慢 |
| critic_lr | 0.001 | 同上 |
| tau | 0.05 | 目标网络软更新系数,稍大一点 |
| gamma | 0.98 | 稀疏奖励场景下折扣因子别太接近 1 |
| her_ratio | 8 | 每条原始经验生成 8 条重标记经验 |
| future_k | 50 | 候选未来目标数 |
| buffer_size | 1000000 | 足够大,但要能装下重标记数据 |
有两点我要特别说明。第一,tau 我习惯用 0.05 而不是常见的 0.001,因为 HER 重标记会让目标网络和在线网络之间的差距快速积累,稍微大一点的软更新系数能让目标网络的追踪更及时,我个人在 FetchReach 和 FetchPush 上测试都是这个结论。第二,gamma 不建议取太接近 1,因为 HER 会把“虚拟成功”的奖励传播得很远,如果 gamma 太大,远期虚拟奖励的累计会影响当前动作的评估,导致训练后期震荡。
4. 训练中的常见问题与排查实录
4.1 Loss 下降但成功率纹丝不动,问题出在哪
这是我第一次跑 HER 时遇到的怪现象。训练几千步后 critic loss 在稳步下降,看起来一切正常,但评估时成功率长期为零。后来排查下来发现,原因在于重标记经验占经验池比例太高,几乎每一条都被打上了虚拟成功标签,于是 critic 对任何状态都给出过高的 Q 值,再也学不会区分“真正能够达成目标的策略”和“随机碰巧达成目标的策略”。
解决办法是控制重标记比例。原始经验和重标记经验的比例很关键,我试验下来,her_ratio 在 4 到 8 之间比较合适。如果比例是 8 比 1,意味着每 9 条经验里只有一条是真实的原始经验。某些任务里这个比例还是太高,需要适当下调。另外,如果经验池里原始目标是同一个目标,重标记后的目标却各不相同,那么 critic 学到的“对某固定目标的成功概率判断”会被稀释,评估时就显得成功率很低。这种事后来我总结了一个经验:HER 不是把所有轨迹都当成成功的,而是在样本量和真实成功信号之间找平衡。
4.2 future 策略里的 k 到底怎么选
future 策略的 k 选太大会增加计算量,每条 transition 都要采样 k 次,然后生成 k 条经验,显存和 CPU 都会吃紧。选太小又达不到效果,因为 future 的目标是让经验池里覆盖足够多的“未来可达状态”,如果 k 等于 1,那新目标的可达性虽然强,但多样性很弱。
我做过一组对比实验:k=10、k=50、k=100。结果是 FetchReach 上三组都能收敛,差异不大,但 FetchPickAndPlace 上 k=10 明显比 k=50 慢很多,k=100 和 k=50 效果接近。说明在高难度任务里,k 的多样性决定了智能体能覆盖多少目标状态。实际使用中建议 k 从 50 起步,如果任务特别复杂可以提高到 100,但要注意总经验池膨胀带来的训练变慢。另外一个技巧是:并非每条经验都要生成满 k 条重标记信息,可以用一个概率 p 来决定当前 transition 是否被重标记,比如 p=0.8 就能省 20% 的计算量,效果几乎不掉。
4.3 把 HER 用到策略梯度算法上时踩的坑
原则上 HER 只能配合 off-policy 算法使用,因为 HER 需要从经验池里反复采样更新,on-policy 算法每次更新完就丢弃数据,HER 就没有用武之地。但很多人在 SAC 或者 TD3 上使用 HER 时仍然会遇到问题。我这里遇到的一个典型坑是:SAC 的熵温度系数被 HER 的虚拟目标影响,导致温度系数退到接近零,最终策略变得过于贪婪,一碰到失败就停止探索。
解决方法是给 SAC 的熵温度系数设一个下限,或者减小温度系数的学习率。HER 数据的分布变化比普通 off-policy 算法快得多,因为重标记目标的分布随着智能体能力提升在不断改变,所以那些对数据分布变化敏感的组件都需要重新调参。TD3 相对好一点,因为它有延迟更新策略,对分布漂移的容忍度更高。如果是从零开始,我建议优先尝试 DDPG 加 HER,因为组件最少,问题排查起来也最直接。等熟悉了 HER 的脾性,再迁移到 SAC 上。
4.4 连续控制任务中的目标分布漂移问题
HER 的重标记目标是从过往经验里来的,但过往经验的 achieved_goal 分布会随着策略的改进而越来越接近真实目标。这个过程中,经验池里早期数据的目标分布和后期数据的目标分布差异很大。如果不做任何处理,训练初期 critic 可能饿死在“目标分布太广”的区域,后期智能体又因为目标分布太集中而失去泛化能力。
我常用的办法是给经验池设置一个采样权重,近期数据的权重稍大,比如采用优先经验回放(Prioritized Experience Replay)的思想。另一种更简单的方法是把缓冲区按时间分为两部分,前期采样概率略低于后期。再有一种做法是在训练过程中逐步增大重标记的 k 值,让目标分布自然地从广到窄过渡。这些技巧都不是论文里的标准操作,但实际效果稳定,分享出来供参考。
4.5 避坑清单速查
| 现象 | 常见原因 | 快速解决 |
|---|---|---|
| 训练早期 critic loss 不降 | 经验池为空,重标记目标太少 | 增大 her_ratio,增大 k |
| critic loss 降但成功率零 | 重标记比例过高 | 降低 her_ratio 到 4~6 |
| 评估时抖动剧烈 | gamma 太大或 tau 太小 | gamma 降到 0.98,tau 升到 0.05 |
| 目标复杂任务不收敛 | future 的 k 太小 | k 提至 100,并提高重标记概率 |
| 高维空间计算量大 | 每条经验生成太多重标记样本 | 用概率 p=0.8 决定是否重标记 |
| 策略过于激进不稳 | 熵温度系数被虚拟目标拉低 | 给熵系数设下限或降低其学习率 |
这四类问题是我在实际项目中遇见频率最高的,基本覆盖了我见过的大部分 HER 调参困境。
结尾:一点个人操作心得
从第一次读到 HER 论文到把它真正跑进机械臂任务,前后折腾了两周。我的体会是:HER 不是什么万能灵药,但它对付稀疏奖励问题的路子确实独辟蹊径。它的本质不是让智能体“变得更强”,而是让经验池里的数据“变得更聪明”。同一条失败轨迹,换个目标就是一次高质量的成功示范,这种数据增广的思路值得任何一个做强化学习的人学习。
最后分享一个我后来常做的小技巧:在使用 HER 的同时,把环境里 achieved_goal 这个量也一并交给数据记录模块,训练完画图时把智能体在训练各阶段“实际到达的位置分布”画出来。你就能直观看到智能体从杂乱无章的探索,到逐步覆盖整个可达空间,再到集中在真实目标周围的过程。这个可视化在写报告或向团队解释算法效果时非常有用。后续如果大家有兴趣,我可以再把 HER 配合 SAC 以及多目标任务扩展的细节整理出来。