稀疏奖励下的强化学习突破:HER事后经验回放原理与实战
1. 从“事后聪明”谈起为什么稀疏奖励让强化学习寸步难行我第一次意识到 hindsight 这个词在算法里居然这么值钱是在一个几乎让我想放弃的机械臂推块任务上。目标是让机械臂把一个方块推到指定位置听着简单但整个任务里没有中间奖励只有“推到目标位置1没推到0”。我跑了上千个 episode智能体一次都没碰到过目标奖励曲线清一色的 0模型完全学不动。后来我想到的是 Hindsight Experience Replay也就是事后经验回放讲的是如何让智能体从失败里挖掘出隐藏的“成功样本”。这个思路非常反直觉既然没能完成给定的目标那就干脆把实际到达的位置当成目标重新学一遍。它适合那些被稀疏奖励卡住的强化学习任务尤其是机械臂抓取、移动机器人导航这类目标条件化问题。我自己后来把整套复现代码的项目名起成了 hindsight也是因为这个名字太传神了——在真实世界我们习惯事后诸葛在强化学习里事后的重新解释反而是一把最锋利的钥匙。1.1 一个让我抓狂的机械臂任务我最初的理论基础其实很薄弱。拿到任务时环境是一个 6 自由度的机械臂观测包含关节角度、末端位置和速度目标是一个二维桌面坐标点。我用的算法是 DDPGreward 写得也标准末端位置和目标的距离小于 0.05 就返回 1否则返回 0。从第一轮开始数据流就出了问题——因为任务目标是在每次 episode 开始时随机抽的机械臂随机动作几乎不可能恰好落在那么小的一个圈里。跑了 500 个 episode经验池里大约有 5 万个 transition其中 reward1 的样本数是多少零。那时我才意识到这不是超参没调好而是问题的信息结构本身就不对。稀疏奖励下绝大多数样本给策略的反馈都是“没成功”而“没成功”对策略来说没有任何可区分的梯度方向。如果人类考试只给 0 分和 100 分且 100 分几乎不可能拿到你根本不知道自己是该多读书还是该多睡觉。强化学习也是如此失败样本之间没有信息差异Q 函数就学成了一片平滑的“零”。1.2 稀疏奖励的本质失败不产生学习信号深入一点说强化学习的核心是让智能体学会“在什么状态下做什么动作能获得高回报”。奖励信号必须回溯到每一对状态和动作上策略才能调整。但在稀疏奖励中成功路径上的绝大多数 transition 也带着 0 奖励只有最后一步成功的那一下才有一个 1。如果用蒙特卡洛回报那个 1 要沿着轨迹往回传播很远如果用时序差分只有一步步逼近目标状态时Q 值才会被逐步点亮。问题是一开始智能体根本没有机会“逼近目标状态”所以整个经验池里全是没有价值的失败轨迹。你可以把 Q 函数想象成一块黑布上的等高线。如果奖励只在某个极其狭窄的坐标点上出现这块布的绝大部分区域都是平的。梯度信号在那里不存在反向传播也就无从谈起。这也是为什么我在后来写项目笔记时把这一章起名为“失败不产生学习信号”——不是失败样本没用而是你还没找到正确的角度去使用它们。1.3 为什么随机探索救不了场有人可能会说大不了让智能体多随机探索几分钟总有一次能碰到目标吧。理论上确实如此但工程上完全不现实。目标空间如果是一个连续二维平面上的 0.05 半径小圆目标位置每次随机撒智能体动作又带噪声单次 episode 命中概率可能只有千分之一甚至更低。就算运气爆棚碰到了一次一条轨迹上 50 步里只有最后一步有正奖励前面的 49 步依然无法直接获得有效反馈这个正样本对 Q 网络的贡献也很弱。更麻烦的是很多机器人任务的目标空间不止二维还要考虑姿态、关节角度约束、碰撞规避。维度一高“随机碰运气”的命中概率会指数级下降。传统的 reward shaping 当然可以缓解但需要大量人工设计领域知识换个环境就要重新写一版。HER 的思路则完全绕开了这个坑它不试图改变奖励函数而是改变经验数据里的“目标标签”这在原理层面就要干净得多。2. Hindsight 的算法内核把“没做到”重写成“做到了”HER 的论文标题全称是 Hindsight Experience Replay作者是 OpenAI 的 Marcin Andrychowicz 等人2018 年发表在 NeurIPS 上。论文的核心贡献非常聚焦在 goal-conditioned 强化学习任务中把 episode 结束后已知的“真实状态结果”作为额外目标重新生成一批训练样本。这个方法不修改策略网络结构也不修改探索机制只改 replay buffer 里的数据却能让原本完全学不会的任务顺利收敛。2.1 核心直觉目标可以被事后重新定义想象你本来想去超市买酱油结果七拐八拐走到了一家药店。如果以“买酱油”为目标这趟行程是失败的但如果以“走到药店”为目标你刚才的每一步路线选择就变得完全正确。HER 用的是一个极其朴素的逻辑既然状态转移已经发生那么这些动作序列至少在“到达这个状态”这件事上是有因果效力的。我们可以把这段轨迹重新贴上一个新的目标标签——实际到达的状态——然后把它当作成功样本存入经验池。在强化学习中这个操作叫 goal relabeling。对于 episode 中的任意 transition (s, a, s)如果我们用 episode 后续某个状态 g 作为目标而这个 g 恰好等于 s那么这条 transition 就变成了一条“成功”的转移奖励为 1。即使 g 不等于 s只要 g 是该轨迹未来会到达的状态这条 transition 也是在朝目标靠近依然是有用的学习信号。2.2 目标重标记的完整数据流假设一个 episode 长度为 THindsight 在数据流上做的事可以分成三步。第一步正常采集策略按照原始目标 g 与环境交互得到一条轨迹轨迹中的每一条 transition 都记下 (obs, achieved_goal, action, reward, next_obs, next_achieved_goal, done, desired_goal)。这里 achieved_goal 和 desired_goal 必须显式拆开因为重标记时要替换的只是 desired_goal。第二步保留原始样本原始目标 g 对应的经验要原样存入回放池保证智能体不会忘记“玩家原本的任务”。这步很关键——如果只存重标记后的样本智能体最后学到的策略是“往随便哪个方向走都行”而不是“走向真正指定的目标”。第三步生成重标记样本对轨迹中的每个 transition从它未来的状态中额外抽取 k 个目标 g也就是 g ∈ {s_{t1}, s_{t2}, ..., s_T}然后用这个新目标重算奖励和终止标志再生成 k 条新的 transition 一并存入 replay buffer。用伪代码表达是这样的逻辑def relabel_episode(episode, k4): result [] T len(episode) for t, trans in enumerate(episode): result.append(trans) # 保留原始目标对应的样本 future_goals [ episode[i][achieved_goal] for i in range(t 1, T) ] if not future_goals: continue for _ in range(k): new_goal random.choice(future_goals) achieved trans[next_achieved_goal] new_reward compute_reward(achieved, new_goal) new_done check_success(achieved, new_goal) relabeled { obs: trans[obs], desired_goal: new_goal, action: trans[action], next_obs: trans[next_obs], next_achieved_goal: achieved, reward: new_reward, done: new_done, } result.append(relabeled) return result有一个细节需要解释为什么目标要从“未来状态”里抽而不是从整个轨迹里随便抽因为 t 时刻的动作只能影响未来不能影响过去。如果拿已经走过的历史状态当目标就会制造出“因果倒置”的样本告诉智能体“你过去那个动作导致了更早发生的状态”这完全是噪声。所以 future 策略才符合物理直觉。2.3 四种目标采样策略实际该怎么选论文里对比过四种策略我直接把它们整理成了一张表。策略目标来源实际效果适用场景final只取轨迹最后一个状态简单稳定但目标太单一每个 transition 都往终态靠多样性不足轨迹短、终点信息量大的场景future从当前时间步之后的状态中随机抽效果最好是默认主力大多数连续控制任务episode从整个轨迹任意状态中抽目标多样但包含过去状态有因果错误风险很少单独用random从回放池里随机抽其他 episode 的状态几乎没用目标与当前轨迹无关样本方差极大不推荐我在项目中第一个版本用的是 final训练结果也能提升但收敛速度明显偏慢。换成 future 策略后同样的环境同样的网络结构成功率曲线陡了很多。原因很简单以最终状态为目标时越靠前的 transition 离目标越远奖励仍然稀疏而以未来某个不远的状态为目标时大量 transition 直接变成成功样本Q 函数的高价值区域被迅速铺开学习效率自然就上来了。原论文里一般把未来目标数 k 设成 4也就是每个原始 transition 额外生成 4 条重标记样本。这个数不是随便拍的我后面会在调参部分具体讲它为什么不能乱调。2.4 奖励与终止条件必须同步重算HER 最容易被新手忽略的地方是重标记不能只改 desired_goal奖励和 done 也必须跟着重算。很多人在自己的实现里随便把旧的 reward 留下来结果 replay buffer 里出现大量“目标没达到但奖励为 1”的矛盾样本Q 网络直接被教坏。正确做法是写一个独立的 reward function。比如我的项目里用的是def compute_reward(achieved, desired, threshold0.05): dist np.linalg.norm(achieved - desired) return 1.0 if dist threshold else 0.0 def check_success(achieved, desired, threshold0.05): return np.linalg.norm(achieved - desired) threshold每次重标记时都基于新的 desired_goal 和 next_achieved_goal 重新计算这两项。这里还有一个容易踩的坑done 标志到底该不该随新目标改变。我的经验是该改。如果新目标就是 s_{t1}那么从 t 到 t1 这个转移已经成功把它当成一个终止 step 去更新 Q 值能加速目标达成事件的价值传播如果不改 done这个成功样本的回报会被后续失败步继续拖累。还有个代码层面的坑曾经让我排查了整整一个下午。如果你用 Python dict 保存 transition重标记时千万别直接修改原始 dict 的 desired_goal 字段否则原始样本也被污染了。正确做法是复制出一个新 dict。这个问题很隐蔽因为你可能只在某个字段上做了原地赋值训练初期看不太出来等跑了几万个 step 之后整个经验池的目标分布已经乱了。3. 从零实现 HER SAC 的最小可跑版本理论聊完直接上实操。我建议用 SAC 而不是 DDPG因为 SAC 自带熵正则在稀疏奖励和重标记目标混合的数据分布下更稳定。如果你想快速复现不需要装 MuJoCo直接写一个二维点到达任务就够了这个任务足够体现 HER 的效果。3.1 任务定义与观测空间设计我做了一个非常简单的环境二维平面上一个点状态由当前位置和速度组成动作是二维加速度。每个 episode 开始时随机设一个目标位置智能体有 50 步去接近它。成功条件是欧氏距离小于 0.05奖励只有 0 和 1没有中间奖励。这个环境的关键在于观测格式。我定义的观测不是单纯的状态向量而是把当前状态和原始目标拼在一起observation np.concatenate([state, desired_goal])state 里已经包含了 achieved_goal 的位置信息所以网络输入包含了当前状态和目标两部分。这里有个设计原则在 goal-conditioned 任务里desired_goal 和 achieved_goal 必须同时出现在观测里。如果你只给目标不给当前实际位置策略不知道自己在哪肯定学不会反过来只给当前实际位置不给目标策略也不知道要往哪去。3.2 代码结构网络、Buffer、训练循环我的项目代码结构很简单三个文件networks.py 放 actor 和 criticbuffer.py 放带重标记功能的回放池train.py 放训练主循环。网络部分actor 和 critic 都接受 concat(obs, goal) 之后的向量作为输入中间两层 256 维的 ReLU输出层分别输出动作均值和 log 标准差。SAC 的 critic 是双 Q 网络取两个 Q 的最小值来抑制过估计。回放池部分不用像标准 SAC 那样写得太复杂关键是把整条 episode 都保留下来等 episode 结束后统一做重标记。所以我额外设计了一个 segment buffer存完整轨迹满了或到了终止时刻就调用 relabel 函数然后把所有样本灌进大回放池。3.3 最重要的改造Episodic Replay 与 Relabel这一段是 HER 的核心我直接贴出关键代码。完整的 relabel 函数前面已经写过了这里重点展示训练主循环里它是怎么被嵌入 SAC 的。for episode in range(total_episodes): obs, info env.reset() desired_goal info[desired_goal] episode_transitions [] for step in range(max_steps): action actor.sample_action(np.concatenate([obs, desired_goal])) next_obs, reward, terminated, truncated, info env.step(action) achieved info[achieved_goal] next_achieved info[next_achieved_goal] episode_transitions.append({ obs: obs, desired_goal: desired_goal, achieved_goal: achieved, action: action, next_obs: next_obs, next_achieved_goal: next_achieved, reward: reward, done: terminated, }) obs next_obs if terminated or truncated: break relabeled relabel_episode(episode_transitions, k4) replay_buffer.extend(relabeled) # 正常SAC更新更新次数一般等于当前episode的transition数量 for _ in range(len(episode_transitions)): batch replay_buffer.sample(batch_size256) sac_update(batch)注意SAC 的更新频率我是按“当前 episode 步数”来的而不是每个环境 step 都更新一次。这样做的原因是HER 在 episode 结束后一次性往 buffer 里塞入大量重标记样本如果更新太快方差很大如果更新太慢训练进度又跟不上。按 episode 长度来更新样本利用率比较均衡。3.4 训练参数与源码级避坑我的核心参数如下可以直接拿去当初始值。参数值说明优化器Adamactor 和 critic 都用它actor learning rate1e-3太大会震荡太小收敛慢critic learning rate1e-3与 actor 保持一致通常没问题gamma0.98因为 episode 只有 50 步gamma 不用太大tau0.005软更新目标网络batch size256足够大减少重标记样本方差replay buffer size500k重标记会带来 5 倍数据膨胀buffer 不能太小future k4每个原始 transition 额外生成 4 条目标样本另一个我后来才注意到的坑是环境 step 返回的 info 里必须同时包含 achieved_goal 和 next_achieved_goal二者不能混用。我在早期实现里以为 achieved_goal 就是当前 obs 的位置结果发现如果状态里只有位置没有速度还好一旦状态包含速度你就要小心区分“位置目标”和“状态观测”。HER 重标记的目标通常只需要位置部分而不是完整状态向量。还有在计算 reward 的时候新目标 g 也可能取到了某个未来的位置但那个未来的状态离当前 transition 的 next_state 并不近。这种情况下 reward 还是 0这不是 bug反而是 HER 里“逐步趋近”的关键——它让智能体学着去逼近轨迹上的中间点而不是一步跳到终点。训练了大概 3000 个 episode 之后我的二维点任务成功率从 0 升到了 90% 以上。对照组不加重标记用同样的 SAC成功率始终在 3% 附近震荡。这就是最直观的效果证明。4. 调参实录HER 训练中的常见问题速查既然标题是 hindsight这个项目里最值得沉淀的其实是调试经验。HER 看起来只有短短十几行重标记逻辑实际用起来却有一堆隐藏细节。我把踩过的坑按照出现频率整理了一下做成一个速查表每个问题后面都附了排查思路。4.1 训练不收敛先查这五个点如果说只能留一个调试清单我会建议按顺序排查这五个点。第一obs、achieved_goal、desired_goal 三者的维度能不能对得上。HER 里有三种“目标相关”的数据搞混任何一个都会让模型学到完全错误的信息。我遇到过一次维度错配因为环境里目标位置是二维但我把速度也拼进了 achieved_goal导致重标记出来的 target 维度变成四维网络结构倒是能跑但 reward 始终乱套。第二重标记时是否污染了原始样本。前面说过dict 深拷贝的问题一定要重视。原始样本保留是 HER 能同时保持“定向完成任务”能力的根基一旦污染整个算法会退化成随机游走。第三网络是否真的把 goal 作为输入。有些 RL 框架里 actor 和 critic 的输入是自动构建的如果你只是把 goal 放在 info 里而不是 observation 里HER 改的数据根本传不进网络。第四奖励函数是否和新目标一致。重标记后的 reward 必须调用独立的 compute_reward不能复用原始 reward。这是最容易被忽视的。第五done 标志是否合理。如果 done 永远为 False成功样本的价值会被后续失败步冲淡如果 done 设得太激进比如以未来任意状态为目标都算 done又会产生许多虚假终止。我的标准是只有 next_achieved_goal 与 new_goal 的距离小于阈值时done 才为 True。4.2 future 采样的 k 值到底调多大原论文里的默认 k4我在实践中的体会是k 太小重标记样本数量不够HER 的优势不明显k 太大buffer 里真实目标样本和重标记样本的比例失衡智能体会过度优化“随便走向某个状态”反而忽略用户真正关心的原始目标。如果你问我实际项目里怎么调我的建议是先用 4观察成功率的上升速度如果发现过拟合到一个狭窄目标区域可以提高 k 到 8让重标记目标更多样如果发现原始目标一直完不成可以降到 2让真实目标样本占比更高。这个比例敏感度非常高我在一个机械臂推块任务里就发现k 从 4 调到 8 时成功率反而下降了大约 10 个百分点。4.3 目标怎么喂给网络拼接、差分还是双塔最常见的做法是把观测和目标 concat 起来这也是我一开始的做法。但对于连续控制任务差分输入往往更有效。所谓差分输入就是网络输入变成 concat(obs, achieved_goal - desired_goal)。直观上智能体只需要知道“我现在离目标差多少”而不是目标在绝对坐标系下的精确位置。这个改变在 2D 点任务上提升不大但在机械臂任务上能让学习速度明显加快。双塔结构也就是让观测和目标分别过两个编码器再融合通常用于高维图像目标普通低维向量下没有必要。我的建议是低维状态先用 concat如果收敛慢换差分输入试试高维视觉目标再考虑双塔或 FiLM 这类更复杂的融合结构。4.4 奖励阈值太小是“假成功”太大是“无信号”HER 虽然解决了稀疏奖励的信号问题但并不能完全消除你对奖励函数设计的责任。如果成功阈值定得太小比如在二维位置任务里把阈值设成 0.001那么重标记后大量的“成功样本”其实是系统误差范围内的抖动Q 函数会被虚假成功带偏。如果阈值定得太大比如 0.5那么几乎任何位置都算成功智能体学到的策略精度不够。经验法则是把阈值设为任务精度要求的 1/5 到 1/3。例如任务需要最终误差小于 0.05训练阈值可以放宽到 0.01 到 0.02 之间但要保证重标记样本里真正命中的比例不低于 20%。如果命中比例太低说明目标空间太大或者阈值太严格这时可以同时使用距离奖励比如 reward -distance 或 -max(0, distance - threshold)让 Q 函数学到距离的连续梯度。4.5 HER 与 SAC/DDPG 搭配时的隐藏坑SAC 和 DDPG 这类 off-policy 算法和 HER 天然搭配因为 HER 本身就是在 replay buffer 层面做数据增强适合离线更新的算法。但搭配起来时有两个坑要注意。第一个坑是 SAC 的熵系数。HER 重标记出来的样本里有一部分是“人为造出来的成功”数据分布和真实回放不一样如果熵系数太大策略会过分随机难以利用这些高质量样本如果太小策略又容易过早收敛到少数几个重标记目标上。我通常会把初始熵系数调小 10% 左右然后靠自动调节机制去平衡。第二个坑是 replay buffer 的容量。HER 会让 buffer 里的数据量膨胀 5 倍如果不增大容量旧样本很快被挤出原始目标的分布会被重标记目标淹没。我在项目里把 buffer 从标准的 100k 调到了 500k效果立竿见影。如果显存或内存有限至少也要保证未来目标样本、原始目标样本、其他较早经验的比例相对健康。5. HER 的边界与扩展思路HER 不是万能的但它背后“重新定义目标”的思想非常通用。理解它的适用范围能让你在遇到新问题时快速判断该不该用它以及怎么扩展它。5.1 不适合用 HER 的场景如果环境没有明确可观测的 achieved_goalHER 就没有用武之地。比如目标是“在下棋对局中获胜”你无法在每一步都拿到一个可采样的“棋局状态向量”作为可重定义目标。又比如目标是“让对话包含某个特定关键词”事后虽然能判断关键词有没有出现但如果你想用未来的对话状态作为目标维度太高、语义太复杂重标记出来的目标并不能提供有效学习信号。另一个典型反例是 on-policy 算法。HER 生成的目标样本分布和策略当前的行为分布不一致如果继续用原策略评估期望回报会引入偏差。PPO、A2C 这类算法不依赖 replay buffer强行在上面加 HER 会让重要性采样权重爆炸。所以 HER 基本只和 DDPG、TD3、SAC 这类 off-policy 算法配合。5.2 后续改进方向CHER 与目标生成HER 的一个天然缺点是它重标记的目标完全来自已经走过的轨迹如果轨迹本身质量很差重标记目标也集中在低价值区域。Curriculum HER也就是课程式 HER会动态选择每个 episode 的“困难程度”把目标从简单逐渐过渡到困难能进一步加速稀疏奖励任务的学习。还有一类思路是把目标当作可学习变量。比如 Goal Proposal Network 会训练一个生成器根据当前经验池主动提出“有一定难度、又在可达范围”的目标再把这些目标喂给 HER 重标记。这相当于把“事后聪明”升级成了“事前规划”理论上效率更高但也更复杂。5.3 对 goal-conditioned RL 的整体启发HER 最让我受益的地方并不是某个具体公式而是它的视角数据本身没有价值标签价值解读取决于我们给数据设定的目标。在 goal-conditioned 任务里智能体不需要只学习“如何达到指定目标”它还可以学习“如何达到自己曾经到过的任何状态”。这等于把一次尝试变成了多次学习机会。这种思想还能迁移到很多看似无关的场景。比如模仿学习里可以用执行轨迹中的中间状态来生成新演示在多任务强化学习里可以把一个任务的中间状态作为另一个任务的起点甚至在数据增强里HER 也提醒我不要只保留成功样本失败样本换个目标看可能就是高质量数据。我后来在真实项目中把同样的逻辑用在了机械臂“推方块到随机目标”的任务上。之前无论如何都学不会加 HER 之后训练曲线虽然还是会震荡但成功率总算稳定爬升。踩过这么多坑之后我最大的体会是真正重要的不是那几行重标记代码而是你能不能从失败里读出有价值的信息。这也是我把项目命名为 hindsight 的原因——事后聪明本来就是经验成长的正常方式。