
1. 我为什么想聊hindsight一个让强化学习不再那么“健忘”的思路如果在深度强化学习里泡过一阵子你一定听过“稀疏奖励”这四个字带来的痛苦机械臂要抓取一个杯子跑了几个小时绝大多数回合连个正反馈都拿不到智能体就像一只在黑暗房间里反复撞墙的猫既不知道目标在哪也不知道自己离目标有多近。第一次听说 hindsight 这个词时我第一反应是心理学里那个经典概念——事后聪明偏差当结果已经发生之后人们总觉得“我早就知道会这样”。但放进强化学习的语境里hindsight 被赋予了一种完全不同的、非常实用的含义我们能不能像“事后复盘”一样把一条没有达成原始目标的轨迹重新解释成一条完成了另一个目标的成功轨迹这个思路后来变成了一个相当有名的方法Hindsight Experience Replay缩写 HER。我在自己的机器人抓手项目里第一次落地它时效果可以说非常震撼——同样的稀疏奖励环境原本用普通经验回放怎么都学不会改成 HER 之后大概两百万步就出现了明显的策略雏形。也正是那一次经历让我意识到 hindsight 不只是一个算法名字更是一种训练理念的转变我们不再天真地扔掉所有“失败”的样本而是把它们重命名、重标号变成经验池里可以反复利用的弹药。这篇文章我想把它拆开讲清楚。先摸清 hindsight 到底在解决什么问题再从数学上理解 HER 的目标重标定机制然后我会给出可以直接照着写的伪代码最后把我调参时踩过的坑和几条实际经验一并整理出来。适合读这篇文章的人很明确正在做目标条件强化学习、稀疏奖励下跑不动或者准备复现机器人操作任务的读者。这里没有玄学每一步都能落到代码上。2. 从“目标没达成”到“目标达成了”HER 的核心机制与公式拆解2.1 为什么只记录“真实目标”会浪费大量经验先看最基础的目标条件强化学习Goal-Conditioned RL设置。智能体有一个期望实现的目标 g每一步根据观测 s 做出动作 a环境返回新的状态 s 和一个奖励 r R(s, a, g)。传统的经验回放会把 (s, a, r, s, g) 存进 replay buffer然后从中随机采样来更新 Q 网络或策略网络。问题出在这个 r 上。很多实际任务里奖励是稀疏的、二值的只有当前状态 s 与目标 g 的距离小于阈值时 r1其余时刻 r0。在这种设定下一次 roll out 里面可能会产生几百上千个转移样本但其中绝大多数样本的奖励都是零。无论你从 buffer 里怎么采样智能体看到几乎全是“这么做也不行那么做也不行”梯度信号要么全为零要么噪声极大。这就像一个人做题每一道题都只知道“错了”却不被告知“哪一步错了、离正确解差多远”那他只能不断重复错误的尝试。普通经验回放只是把样本原封不动存下来它没办法创造新的奖励信号。所以我们需要换一种视角这条轨迹虽然没达到我想要的目标 g但它达到了某个状态 g。如果把 g 当成目标那么这条轨迹里大量的转移其实是非常标准的“成功示范”。这就是 hindsight 的出发点——我们手里其实掌握着大量潜在正样本只是需要主动把它们重新贴上标签。2.2 重标定一个最简单的例子机械臂手指定位为了把这个机制讲具体拿一个最简单的 2D 机械臂任务举例。假设机械臂二维末端坐标是 (x, y)目标是末端到达一个固定位置 g (1.0, 0.5)。某次尝试中机械臂从头到尾都没有碰到目标最终停在了 s_T (0.8, 0.2)。按照普通经验回放这条轨迹中的每条样本都是“失败样本”不值得特别关注。但如果我们把目标 g 临时替换成 g s_T (0.8, 0.2) 呢接下来再回放这条轨迹时前面每一步动作最终都成功把末端带到了 (0.8, 0.2)也就是说从“达成目标 g”的角度看这些动作是成功的每一步后面都跟着一个正奖励。只要我们改写目标整条轨迹的奖励就从一串 0 变成了一连串 1。在实际实现中我们不会真的改环境里的目标而是在采样 minibatch 时动态修改样本的 goal 字段并重新计算对应奖励。这里有一个关键点你要有一个可复用的奖励函数 reward_func(achieved_goal, desired_goal)它能根据任意目标计算奖励是否达成。有了它重标定就是一行函数调用的事。这也是为什么在做目标条件强化学习时我强烈建议把“状态向量”和“目标向量”分开存储而不是只存一个拼接好的大向量。2.3 HER 采样策略的形式化描述更严谨一点HER 的做法可以归纳成下面的流程每玩完一整条 episode得到轨迹 τ (s_0, a_0, s_1, a_1, ..., s_T)。原始目标 g 对应的所有转移样本照常存入 buffer。额外从同一个 episode 中选取若干个新的目标 g生成额外的转移样本并存入同一个 buffer。标准 Q-learning 或 policy gradient 更新时从 buffer 里随机采样即可不需要任何特殊标记。实际问题在于新目标 g 该怎么选这涉及到采样策略最常见的几种包括final直接把 episode 最后一个状态 s_T 作为新目标整条轨迹共享同一个新目标。future针对轨迹中的每个时间步 t从 t 之后的某个状态 s_{t}t t取出来作为新目标。这保证“同一个 episode 里后来的状态”能成为前面动作的目标逻辑上更自然。episode从整个 episode 中随机抽取一个状态作为新目标不考虑时间先后。random从任意其他 episode 的目标里随机选一个。实践中最常用的是future 策略 final 策略搭配使用。我自己的经验是每个 episode 原始轨迹存一份之后再额外采样 2 到 4 条 future 目标重写轨迹同时保留一条 final 重写轨迹整体训练效果最稳。具体原因在第四部分会再展开。用公式来表示假设从原始转移样本集合中选出一条样本 (s_t, a_t, s_{t1}, r_t, g)然后用新的目标 g 把它改写成 (s_t, a_t, s_{t1}, R(s_{t1}, g), g)。因为 g 通常是该 episode 里实际到达过的状态所以 R(s_{t1}, g) 很容易等于 1也就是说我们人为制造了大量正向样本。Q 函数的学习目标从“学习单个目标的成功路径”变成了“学习通往可达状态的成功路径”覆盖范围自然大幅扩大。3. 在代码里落地 HER环境改造、回放缓冲与采样器设计3.1 环境接口目标条件化必须拆开状态与目标从开源生态的经验来看用 OpenAIGym 的 Fetch 系列环境入门 HER 是最顺的。这些环境统一使用GoalEnv接口返回的 observation 是一个字典至少包含三个字段observation机械臂完整状态向量包括关节角度、速度、末端位置等。achieved_goal当前实际到达的目标状态比如末端坐标。desired_goal本次 episode 真正希望到达的目标状态。HER 之所以能在这些环境上表现出色核心原因就是这个接口把“观测”和“目标”分开了。如果环境返回的是单纯的数组那么 relabel 时你还需要自己维护一个状态到目标的映射稍不注意就会把目标维度和状态维度搞混训练出来的 Q 函数根本没意义。所以如果你准备在自己的任务里加入 HER第一步就是改造环境定义好状态向量、目标向量的维度和物理含义然后写一个公用的compute_reward(achieved_goal, desired_goal)函数。哪怕是自定义的仿真环境也要对齐这个格式。3.2 HER 回放缓冲区的核心实现网上有不少开源实现但直接抄过来不一定能理解。我在这里给出一份足够简单、逻辑清晰的伪代码可以当成自己项目的骨架。这个实现参照了经验回放的常见写法但额外增加了 HER 特有的 relabel 部分。import numpy as np from collections import deque class HERBuffer: def __init__(self, capacity, env, future_k4, strategyfuture): self.buffer deque(maxlencapacity) self.env env self.future_k future_k self.strategy strategy def add_episode(self, episode): # episode 包含 [obs, achieved, act, rew, done] # obs, achieved, act, rew 都是按时间步对齐的列表 self.buffer.append(episode) # 原始目标样本直接保留无需额外处理 def sample_batch(self, batch_size): batch [] for _ in range(batch_size): episode np.random.choice(self.buffer) t np.random.randint(len(episode[act])) obs episode[obs][t] achieved episode[achieved][t] act episode[act][t] desired episode[desired_goal] # 原目标 reward self.env.compute_reward(achieved, desired) # 是否对这条样本做 relabel if np.random.rand() 0.5: if self.strategy future: future_idx np.random.randint(t 1, len(episode[achieved])) new_goal episode[achieved][future_idx] elif self.strategy final: new_goal episode[achieved][-1] else: new_goal episode[achieved][np.random.randint(len(episode[achieved]))] desired new_goal reward self.env.compute_reward(achieved, desired) # 构造转移样本 (s, g, a, r, s, done) s_prime episode[obs][t 1] if t 1 len(episode[obs]) else episode[obs][-1] batch.append({ obs: obs, goal: desired, act: act, reward: reward, obs_next: s_prime, done: done }) return batch注意上面这段代码里我在正常采样之后以 50% 的概率对样本做 relabel。实际项目中我更倾向于一次性生成两份样本一份是原始目标一份是从该 episode 的后续状态里挑出来的新目标然后在采样时把它们混在一起而不是动态做二选一。这样能保证 buffer 里原始目标和 relabel 目标的比例始终稳定。你完全可以把代码里的概率参数改成一个比值配置让你自己更舒服地调节。3.3 训练主循环把 HER 嵌入 DDPG 或 SACHER 本身不依赖于某个特定强化学习算法它属于经验回放层面的技巧只要能训 Q 函数的算法都能用。我最早是在 DDPG 上跑通的后来迁移到 SAC 也很顺利。主循环的大致结构如下for epoch in range(total_epochs): # 收集一个 episode obs env.reset() episode_obs, episode_achieved, episode_act, episode_rew [], [], [], [] while not done: action policy.select_action(obs) next_obs, achieved_goal, reward, done, info env.step(action) episode_obs.append(obs[observation]) episode_achieved.append(achieved_goal) episode_act.append(action) obs next_obs # 把 episode 存入 HERBuffer her_buffer.add_episode({ obs: episode_obs, achieved: episode_achieved, act: episode_act, desired_goal: env.env.goal, }) # 更新若干次 for _ in range(updates_per_epoch): batch her_buffer.sample_batch(batch_size) sac.update(batch)之所以每个 episode 之后要做多轮更新是因为 HER 相当于从同一条轨迹里“榨”出更多学习信号更新频率高一些有助于网络快速吸收这些信号。但也不是越多越好我在第四部分会讲更新步数和样本冗余之间的关系。3.4 为什么这种“事后注标”能加速学习从直观层面看HER 让智能体学到的不只是“如何接近原始目标”而是“如何接近任何可达状态”。因为在每条 episode 里智能体已经去过后面那些状态所以这些目标天然是可达的。相比原始目标空间里那些随机采样的、可能永远无法达到的目标用真实访问过的状态做目标避免了目标分布与状态分布严重不匹配的问题。从路径层面来说HER 给智能体提供了大量“正面示范”。普通稀疏奖励下策略探索到的正样本可能只占全部样本的千分之一HER 通过重标定让这个比例可能直接到 30% 到 50%。Q 函数对“状态-动作对能否成功导向某个目标”的估计变得非常稳定策略网络不再只靠运气来更新。这也是为什么很多机器人任务里 HER 能成为稀疏奖励环境的默认配置不夸张地说它把“试错”变成了“试而后教”。4. 让 HER 真正跑起来的调参经验与踩坑记录4.1 future 与 final 到底该用多少比例我复现 HER 时最先踩的坑就是把所有额外重标定都放在 future 上完全不用 final。结果训练前期还不错到中后期却开始不稳成功率卡在一个不高不低的位置。后来我把 final 重新加回来也就是除了每个时间步的 future 目标还给整条 episode 生成一条以最后状态为目标的重写轨迹整体稳定度立刻上来了。原因听起来很简单future 重标定的目标是从身后选一个状态可能导致某一步的动作被要求去完成一个“太近”的目标如果训练后期 buffer 里这种近距离目标占比过高策略会过于保守只学会原地小修小补。final 则强迫策略学习在整条轨迹尺度上把状态导引到终点两者正好互补。一般在实现里我会对每个 episode 生成 K 组 future 重写样本与 1 组 final 重写样本K 取 2 到 4 之间。如果你的算法是 SAC我建议从 K4 开始调如果是 DDPG可以考虑 K2因为 DDPG 对样本分布更敏感一些。再细化一点future 也并不是只要从 t1 到 T 随机抽就行。还有一个常用技巧是给抽样索引不同权重让离当前时间步越近的状态被抽中的概率越大。这样会让重写目标更稠密能有效提高早期学习的信号密度。我通常先用均匀采样跑通再换成指数衰减权重采样第二个版本往往能在同样步数内多涨 5% 到 10% 的成功率。4.2 奖励函数太宽会学出“伪成功”稀硫奖励环境并不一定意味着 reward 函数一定要用严格的二值判定。很多人在做自定义环境时会把判定阈值设得过于宽松比如只要末端和目标的欧氏距离小于 0.5 就算成功。如果任务空间本身只有几厘米尺度这个阈值已经算“放宽成功条件”了。问题在于阈值太宽会让大量状态被误判为正样本HER 重标定时也很容易把这些边缘状态当成可达目标被回放的“成功”会让策略变得非常模糊明明离目标还很远Q 值却已经很高。我建议在写compute_reward时先用环境自带的标准阈值跑通整个流程后续再根据实际效果去调整。不要为了让训练早点看到正向信号就盲目放宽阈值那样只会让策略学会“偷懒式”靠近而不是真正完成任务。如果你发现成功率上不去第一反应不该是放宽阈值而是检查 relabel 目标和原始目标的分布是否重叠得过多。4.3 目标空间维度高HER 会失效吗这是一个很常见的困惑。HER 并不要求目标一定是低维向量但对高维目标比如 3D 空间位置加四元数姿态来说直接重标定的效率会大打折扣。核心原因是在高维空间中同一 episode 里后面时刻到达的状态与前面时刻的状态差异可能非常大把它直接当成目标会让 Q 函数的输入分布过于分散网络难以拟合。遇到这种情况我一般会做两步处理。第一步是在进入网络之前把目标向量与状态向量都做归一化比如减均值除以标准差避免某个维度自动主导第二步是把轨迹分成若干段只让同一年段内的状态互相做 future 重标定而不是全段打散。这个分段式 HER 虽然没有原文里那么“正宗”但在实际项目里非常管用视觉目标、六自由度位姿目标都可以这样处理。4.4 回放样本的非平稳性缓冲区比例要稳定HER 重标定的样本和原始样本对策略的诱导方向是不太一样的原始目标样本在告诉智能体“追着固定目标走”relabel 样本则在告诉智能体“去往真实可达状态”。这两种信号的强度如果随训练改变很容易让整个训练变得非平稳。举个例子如果刚开始 buffer 里 relabel 样本占多数策略会学会探索各种状态等探索变多buffer 里的真实目标状态分布也在变化如果比例不稳定Q 函数就会忽左忽右。所以我强烈建议你在实现 HER 时每次 episode 结束后固定生成同样数量的原始样本与重标定样本并且在采样 minibatch 时也固定两者的比例不要完全交给随机数去决定。比如你可以把一个 minibatch 拆成 50% 原始样本、50% relabel 样本。这听起来像一个小细节但对整个训练曲线的平滑度影响巨大。4.5 一定要小心把“最后一步状态”当成未来目标final 策略重写整条轨迹时最后一个状态就是 episode 的终止状态。如果你的环境里 episode 是因为“超时”而终止最后一个状态可能根本不代表任务成功甚至可能是机械臂卡在某处、小球掉到界外这种异常状态。此时如果你把异常状态作为目标重标定就会向 buffer 注入大量不合理的“成功样本”让 Q 函数学习去追求无效目标。解决办法有两个一是重写规则里排除某些非法状态——比如机械臂关节翻转、物体脱离操作区等二是在 final 重标定时不要用最后一个时间步而是选择“离初始期望目标最近”的那个状态作为新目标。这样既保留了 hindsight 的精神又避开了异常终止状态。我在做仿真推箱子任务时就是靠这招把失败率从 30% 压到 5% 以内的这种实操层面的修正是论文里不会写到的。5. 从算法到思维hindsight 的通用扩展价值HER 在机器人任务上的成功让我越来越觉得 hindsight 是一种可以推而广之的方法论而不仅是一个强化学习技巧。你最熟悉的场景可能是在训练无人车变道、游戏 AI 通关、甚至推荐系统冷启动时都会反复遇到“目标难以达成”的情况。把失败转换经验的能力本质上是一种更高效的数据利用方式。举一个我最近在做的触觉感知任务机械臂需要把一张纸对齐到桌面某个角落。原始目标是一个绝对坐标点但由于纸张在抓取中会滑动绝对坐标目标几乎永远不可能精确达到。后来我把目标从“绝对位置”改成“相对位置”用 HER 重标定每个 episode 的实际滑动轨迹模型很快就学到了“无论纸滑到哪都能重新调整抓取角度”的策略。它的价值在于不是给模型规定最终位置而是让模型学会“无论当前在什么状态都能找到通往可达目标的路径”这比单纯拟合一个目标点强大得多。甚至在非强化学习领域同样能看到这个思想的影子。比如数据增强里的 CutMix、Mixup本质上也是在利用“已知结果”反向构造更多训练样本自监督学习里的 contrastive learning经常会把当前样本本身作为正样本和后见之明共享着“把自己实际看到的状态视为可接受结果”的逻辑。文本生成任务里模型生成了一段不太符合标准答案的句子但如果把它当作新范文去 reframe照样能学到语义相关性——这类技术通常被包装成各种新词但内核就是在用 hindsight 视角重新标注已有经验。这种思维对我来说还有一个更朴素的意义调试模型时不要急着否定一条看起来失败的轨迹先问一句“这条轨迹实际上达到了什么”。当我把这个问题应用到项目复盘时往往能发现训练曲线虽然没涨但某些子目标已经悄悄实现了接下来该做的不是推倒重来而是给这些子目标单独建一个 buffer让它们反哺后来的训练。这和工程里做日志分析也是一个逻辑一次线上故障虽然没跑到预设的业务目标但它如果触发了某个监控指标这条路径本身就是一个珍贵样本值得保留下来作为异常定位的“目标”。回到开头那句话hindsight 之所以让我着迷是因为它改变了我看待训练过程的方式失败不是一个应该被删除的记录而是一个已经被某个意外目标标记好的成功经验。技术上落地它只需要改几十行代码但思想上理解它却能让你在更多任务里做出更好的设计决策。如果你正在被稀疏奖励折磨我觉得最值得做的事不是去堆更复杂的网络结构而是先试试给自己的经验池加一双“后见之明”的眼睛。