
别小看“hindsight”这个词。它字面意思是“后见之明”往深了说是一套改变了我做强化学习RL项目思路的方法论。我今年大半年都在跟机器人抓取和机械臂控制死磕最大的收获不是跑通了多少个demo而是把Hindsight Experience Replay后见经验回放简称HER真正用到了工程里。先说结论如果你正在做多步决策、稀疏奖励或者机器人操作类任务HER几乎是目前让智能体从“完全学不动”到“勉强能收敛”的最实用技巧之一没有花哨的网络结构纯粹是对样本的重新理解。这篇博客我打算从原理、数学细节、PyTorch实现到训练踩坑完整还原我自己的实操过程你会看到我是怎么一步步把HER从论文搬到代码里的。我一开始看到HER论文的时候第一反应也是“这玩意儿不就是换了个奖励函数吗”后来自己搭环境、调参、看loss曲线才意识到它的核心并不在奖励本身而在于重新定义“什么算完成”。这篇内容适合三类人正在做机械臂/抓取控制但模型死活不收敛的RL工程师刚入门强化学习想找一份能直接参考的复现教程的研究生以及所有对“智能体如何从失败中学习”这件事感兴趣的技术爱好者。接下来我不会讲太多分布式训练、大规模并行那些花活全部内容都聚焦在单机、单环境、能跑通的朴素实践上。1. 为什么一个“事后诸葛”能让强化学习起死回生1.1 认知心理学里的“后见之明”和RL里的HER有什么关系先聊一个生活中的场景。你开车去一个陌生的商场导航把你导到一个死胡同你只能倒出来换条路。事后你回想起来其实在第一个路口就应该左转当时怎么就跟着导航走了呢。这种“事后我才知道正确做法”的体验就是hindsight bias——后见之明偏差。HER算法的起名灵感正是来自于此。在强化学习里一个机械臂的任务是“把方块推到目标位置”。很多时候它推了半天方块没到目标点于是每一步的奖励都是-1。在纯粹的稀疏奖励设定下这个智能体学不到任何梯度信号策略完全随机游走。HER的核心想法特别朴素虽然这次没推到目标位置但至少方块到达了另一个位置那我把“另一个位置”重新认定为这次尝试的目标这不就有了一条“成功”的经验吗这听起来有点像自己骗自己但背后是有严谨逻辑的。智能体在探索过程中产生的状态转移本身包含了大量关于“如何操控物体”的动态信息。这些信息和“是否达成既定目标”是解耦的。HER把“目标”这个变量从固定值变成了可重标注的变量从而把大量原本被判死刑的失败轨迹救活了。从损失函数的角度看相当于在训练数据分布里主动增加了更多高价值、非稀疏的样本让Q函数或策略函数有梯度可循。1.2 稀疏奖励任务为什么是强化学习的“癌症”你要理解HER的价值必须先理解稀疏奖励为什么难搞。我举个最直观的例子用一个6自由度的机械臂去推一个桌面上的小方块目标位置是0.5, 0.2, 0.1。如果方块最终位置和目标位置的欧氏距离小于5厘米奖励为0否则奖励为-1。在这种设定下机械臂一开始是完全随机的动作。假设动作空间连续每个动作的精度要求很高那么随机策略下能撞到5厘米误差范围内的概率可能连万分之一都不到。智能体跑完一整条episode收到的所有reward全是-1。这会导致什么所有(s, a, r, s)样本的回报几乎一样价值函数无法区分哪个状态更好策略梯度方差巨大训练loss直接横盘。用生活类比就是你让一个完全没学过英语的人去做高考完形填空他每道题都蒙对答案的时候发现自己全错没有任何一道题能告诉他“哪个选项更接近正确答案”。他做完一百套卷子水平还是零。RL智能体也一样稀疏奖励下它根本得不到“部分正确”的反馈。这时候有两个救援方向一个是设计距离奖励、势函数奖励之类的稠密奖励把“离目标近一点”这个信息纳入reward另一个就是HER不修改reward而是修改目标本身。稠密奖励的问题是容易引入局部最优比如机械臂学会了往目标方向伸但忽略了绕开障碍物因为撞到障碍物在距离度量上可能也是“进步”。HER相比之下更干净它不诱导策略走捷径而是纯粹增加有效样本量。2. 核心原理拆解目标重标注是怎么把失败样本变成金矿的2.1 一个清晰的目标重标注流程HER的核心操作发生在经验回放阶段。假设一个episode的轨迹是(s0, g, a0, r0, s1, a1, r1, s2, ..., sT, aT, rT)其中g是原始目标r_t是根据g计算的奖励。正常情况下这一整段轨迹会作为一条经验存入replay buffer。HER的做法是在存储经验时除了保留原始目标再额外生成k个“虚拟目标”。最常见的选择是取episode最后几步里智能体实际到达过的状态s_t把它作为新的目标g。然后重新计算一整条轨迹的奖励序列也就是把每一步的奖励变成“智能体是否到达了s_t”而不是“是否到达了原始g”。这些重标注后的轨迹也存进buffer里。我拿我实际跑的推方块任务举例。原始目标g是0.65, 0.40, 0.03结果episode跑完方块掉到桌子边上最终位置是0.20, 0.70, 0.03。如果我不做HER这条轨迹就是一个纯失败样本。做了HER之后我取出最终状态s_T把目标改成0.20, 0.70, 0.03重新沿着时间步计算奖励——注意这时候在轨迹的最后一步智能体确实“到达”了新目标所以奖励是0前面步骤因为没到达奖励是-1。这条轨迹不再是“全失败”它变成了一个包含“最终成功”信息的样本。这里有一个值得注意的细节一个episode往往不止只重标注一个目标。论文里最常见的做法是取episode中随机k个状态作为额外目标一般k取4或8。这样做的好处是一条50步的轨迹可以一次性生成多条不同目标的成功/失败混合样本极大提升样本利用率。对我自己的项目来说k4已经能让FetchReach环境的成功率在十万步内从0爬到90%以上没必要盲目加大k导致buffer爆炸。2.2 重标注后样本到底给网络带来了什么信息很多人会问重标注之后的样本和原来的样本在梯度上到底有什么区别我拆开来说。对于DDPG这类基于Q函数的算法原本的loss是让Q(s, a)去拟合r gamma * max_a Q(s, a)。在原始稀疏奖励下几乎所有样本的r都是-1Q函数学出来在整个状态空间里都差不多“这个动作不好那个动作也不好”没有任何区分度。经过HER重标注后一部分样本变为了“最终会成功”的样本。这些样本在轨迹最后一步的r0而且时序差分误差是非零的Q函数会学到“在某些状态下某些动作会导向成功”。更重要的一层是HER通过重标注让同一个状态转移(s_t, a_t, s_{t1})同时出现在多条不同目标的经验中。这意味着Q函数不再只针对一个固定的目标学习而是针对大量不同的目标学习。用工程的话说就是目标空间被充分采样了Q函数变得平滑泛化性大幅提升。如果你用的是PPO这类on-policy算法HER同样能插进去因为它本质上是改replay buffer里的数据分布而PPO要配合经验回放的话需要注意重要性采样权重避免分布偏移太厉害。我个人建议HER最舒服的搭配还是off-policy算法DDPG、TD3、SAC都不错后面我会给出一套我实际大量使用的组合。2.3 损失函数层面HER改了什么这里我用一个公式帮大家把HER的改动看清楚。原始DDPG的Q函数loss是L E[(Q(s, a|g) - (r(g) gamma * max_a Q(s, a|g)))^2]注意这里的g是固定的原始目标。加入HER后对于重标注样本来讲g变成了gr(g)也变成了r(g)。所以loss变成了L E[(Q(s, a|g) - (r(g) gamma * max_a Q(s, a|g)))^2]换句话说网络结构、优化器、超参都不用动唯一的变化就是输入给Q函数的目标从g变成了g。而g来自每个episode实际到达的状态数量远远大于原始成功样本。这个改动虽然表象简单但对梯度质量的影响是质的。为什么不直接修改reward函数让“离哪个目标近就给正奖励”呢因为那样做策略很容易钻奖励函数的空子。比如距离奖励如果采用L2范数机械臂可能会找到一条“先把方块推到桌子边缘让距离快速下降”的路径但这和真正的“推到目标位置”完全不是一回事。HER不修改真实环境的奖励它只是改变了经验样本的“目标标签”因此不会引入这种奖励黑客问题。3. 实操过程从零搭建一个HER训练系统3.1 我的开发环境和代码结构我在这个项目里用的是Python 3.9、PyTorch 2.0.1、mujoco-py 2.1环境是gymnasium-robotics的FetchReach-v1。如果读者用的是老版本的gym注意接口差异FetchReach的observation_keys在部分版本里命名不太一样。代码目录结构我推荐这样组织简单清晰适合自己研究和扩展到新任务her_project/ ├── envs.py # 环境封装兼容gym接口 ├── her_buffer.py # HER重标注缓冲区的核心实现 ├── networks.py # Actor/Critic网络定义 ├── ddpg_agent.py # DDPG算法主体集成HER ├── train.py # 训练入口 ├── evaluate.py # 评估脚本跑成功率 └── config.py # 超参数集中管理我强烈不建议把强化学习算法写成一个几百行的巨型脚本。因为调参的时候你很快就会需要改buffer策略、改奖励函数、改目标采样方式混合在一起的代码会让你改一行崩三处。我自己早期就是这么干的后来花了一个下午把所有逻辑拆开发现调试效率直线上升。3.2 HER缓冲区核心代码实现这是整个项目里最值得仔细看的部分我直接贴出我精简后仍能完整运行的核心代码并逐段解释为什么这么写。import numpy as np import random class HerBuffer: def __init__(self, capacity, k_future4, reward_funcNone): capacity: 缓冲区最大容量 k_future: 每个episode额外使用的未来状态作为目标的数量 reward_func: 奖励函数计算(state, goal, next_state) - reward self.capacity capacity self.k_future k_future self.reward_func reward_func self.buffer [] self.pos 0 def store_episode(self, episode): episode: dict包含 obs, actions, next_obs, goals每个都是list 这里假设goals是每个时间步的目标Fetch环境里goal通常不随时间变化 obs episode[obs] next_obs episode[next_obs] actions episode[actions] goals episode[goals] # 原始目标通常整条episode的目标都一样取第一个 original_goal goals[0] # 额外目标随机选择k_future个未来时间步的next_obs # 注意这里选的是next_obs因为下一个状态才是“实际到达的位置” future_states [next_obs[t] for t in range(len(next_obs))] future_indices random.sample(range(len(next_obs)), min(self.k_future, len(next_obs))) extra_goals [future_states[i] for i in future_indices] # 把所有目标合并原始目标 额外重标注目标 all_goals [original_goal] extra_goals for goal in all_goals: for t in range(len(obs)): reward self.reward_func(obs[t], goal, next_obs[t]) done (t len(obs) - 1) self._store_transition(obs[t], actions[t], reward, next_obs[t], goal, done) def _store_transition(self, obs, action, reward, next_obs, goal, done): transition (obs, action, reward, next_obs, goal, done) if len(self.buffer) self.capacity: self.buffer.append(transition) else: # 环形缓冲区覆盖最旧的数据 self.buffer[self.pos] transition self.pos (self.pos 1) % self.capacity def sample(self, batch_size): batch random.sample(self.buffer, batch_size) obs np.array([t[0] for t in batch]) actions np.array([t[1] for t in batch]) rewards np.array([t[2] for t in batch], dtypenp.float32) next_obs np.array([t[3] for t in batch]) goals np.array([t[4] for t in batch]) dones np.array([t[5] for t in batch], dtypenp.float32) return obs, actions, rewards, next_obs, goals, dones这段代码里最关键的设计是store_episode。我在实际项目里并不是一次性把episode里的所有transition都拆开存储而是先收集整个episode然后在episode层面做目标重标注之后再把每个目标对应的整条轨迹拆成单独transition存进去。为什么先收集episode再重标注因为HER是在完整轨迹层面操作的你必须知道这局最后走到了哪里才能决定虚拟目标是什么。如果实时逐transition地存储就无法做future goal的重标注了。另外我选择只随机取k_future个未来状态作为额外目标而不是取全部未来状态。论文里有一个future strategy就是从之后的时间步里随机取一些状态作为目标。我刚开始用的时候也纠结过“多取点不是信息更丰富吗”后来一跑发现取全部会占用巨大内存而且很多重复的状态比如机械臂停在某个位置不动的时候连续几步的next_obs几乎一样会让目标分布失真。k_future4是我的够用值如果你想压榨性能可以先从8试起观察buffer的多样性和训练速度。3.3 DDPG网络和训练主循环的完整适配有了HER缓冲区之后DDPG本身的改动其实很小。我这里给出一个关键点的解释而不是贴全部千行代码。首先是网络输入的变化。原始DDPG的Actor输入是state输出是action。在面向目标的版本里Actor输入必须加上goal即actor(state, goal)。Critic输入是state、action、goal。我用的网络结构是class Actor(nn.Module): def __init__(self, state_dim, goal_dim, action_dim): super().__init__() self.fc1 nn.Linear(state_dim goal_dim, 256) self.fc2 nn.Linear(256, 256) self.fc3 nn.Linear(256, action_dim) def forward(self, state, goal): x torch.cat([state, goal], dim-1) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) return torch.tanh(self.fc3(x)) class Critic(nn.Module): def __init__(self, state_dim, goal_dim, action_dim): super().__init__() self.fc1 nn.Linear(state_dim goal_dim action_dim, 256) self.fc2 nn.Linear(256, 256) self.fc3 nn.Linear(256, 1) def forward(self, state, action, goal): x torch.cat([state, goal, action], dim-1) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) return self.fc3(x)这里有个容易踩的坑如果把goal拼在state后面要保证state和goal的维度比例不要差太大。FetchReach里state是10维机械臂关节角、末端位置、物体位置goal是3维目标位置拼接后13维比值还算合理。如果state是100维goal是3维那goal信息很容易被淹没建议在进入网络前对goal做特征增强或者单独过一层MLP再拼接。我当时在另一个高维状态任务上就遇到过这个问题加了独立的goal encoder才解决。训练主循环里我每跑完一个episode就触发一次her_buffer.store_episode(episode)。注意是按episode触发不是按step触发。然后每步都从buffer里sample一个batch做一次梯度更新。这里有一个细节HER特别吃buffer容量因为重标注后的样本数量是原始样本的好几倍buffer很快就满了。我把capacity设成了10万条transition大约能存800个episode的样本。太小的buffer会丢旧经验导致训练不稳定。3.4 超参数配置和训练结果分析我最终稳定使用的超参如下这个配置在FetchReach-v1上大概16万步就能到90%以上的成功率一张RTX 3060训练不到一小时超参数设置值说明优化器AdamActor和Critic都用Actor学习率1e-3太高容易震荡Critic学习率1e-3和Actor保持一致折扣因子gamma0.98FetchReach任务步数短gamma不用太大tau软更新0.05目标网络更新系数replay buffer容量100000HER需要大bufferk_future4未来状态重标注数噪声标准差0.2训练时动作加高斯噪声每个episode最大步数50FetchReach默认batch size256越大越稳定关于gamma的选择我多说一句。Fetch类任务每个episode只有50步且奖励以-1和0为主。gamma取0.98和0.99的区别真的不大因为50步之后就是terminated远期奖励的折现影响很小。但在长horizon任务比如机械臂连续搬运或者移动机器人导航这类几百步甚至上千步的任务里gamma必须往0.99以上调否则智能体很难学到长期规划。训练的时候我同时开了一个evaluate线程每500步对当前策略做20次评测记录成功率。我自己跑出来的成功率曲线大概是这样的前2万步成功率几乎为0loss曲线抖动3万步到5万步之间开始有零星成功8万步之后曲线陡增一度跳到85%12万步之后缓慢爬升到92%之后基本持平。如果你看到自己的曲线在5万步之前完全不涨不要慌这是正常的。HER的威力往往在中后期才体现因为一开始buffer里有效的重标注样本还不够多。4. 常见问题与排查技巧实录4.1 k_future设多大才算合适k_future这个参数真的见过很多人乱调。我一开始把它设成1发现学得很慢后来设成10训练是快了一点但buffer的存储压力陡增相同容量下只能存更少的episode导致覆盖面反而变差。从直观上讲k_future决定了每个episode能产生多少条“带有目标多样性”的新轨迹。它和episode长度是正相关的。episode有50步k_future4意味着一个episode被扩成了5条轨迹也就是原本5倍的样本。如果k_future太大比如10扩成11条轨迹但很多轨迹之间因为目标相近梯度信号高度冗余收益会边际递减。我的建议是先照搬论文用的4跑一个基准如果样本利用率偏低、曲线长尾严重再调到8如果发现训练速度下降优先减少k_future而不是增加buffer容量因为大buffer会拖慢随机采样的速度。4.2 重标注目标应该选观测状态还是任务目标这里有个容易混淆的地方。FetchReach环境里obs包含achieved_goal实际到达的位置和desired_goal期望位置。HER重标注时新目标g应该用achieved_goal而不是obs里的机械臂关节状态之类的东西。我在实现里用next_obs[t]作为未来状态具体取的是环境返回的achieved_goal字段。有的环境封装会把achieved_goal单独拆出来有的直接塞在observation里。务必搞清楚你的环境返回里哪些维度代表“实际完成度”。我用过一个自建环境把物体坐标和关节角全拼一起结果重标注的目标把关节角也算进去了训练出来的策略疯狂乱动最后排查半天发现是目标维度取错了。一个稳妥的经验是重标注目标只取实际任务相关的空间位置、姿态等信息不要把跟任务完成度无关的维度塞进去。如果拿捏不准直接打印几个样本的obs和achieved_goal肉眼对比一下是最快的。4.3 HER可以和SAC或TD3搭配使用吗当然可以。我自己的项目里把HER和DDPG搭配是最稳定的因为DDPG的网络更新简单、超参敏感度低。但如果你想用SAC需要额外注意熵系数alpha的调节。SAC本身倾向于探索和HER的“重标注给更多成功样本”叠加后容易导致策略过于激进表现为前期探索过头、后期成功率震荡。我的做法是给alpha设一个小一点的初始值比如0.05并且开启自动调节后每1000步clip一下上限防止熵权重过大。TD3和HER的搭配我个人也很推荐。TD3比DDPG多了一个target policy smoothing和延迟更新能显著减少Q值过高估计。在推开门、旋转阀门这类需要精准控制的任务里TD3HER的成功率比DDPGHER高出10%以上。缺点是TD3的训练更慢因为每次更新要做两次Critic前向。对于实验性质的快速验证我还是先用DDPG确认环境没问题再切换到TD3。4.4 训练不稳定的几个典型症状和对策排错这块我整理了一个速查表基本覆盖了我在实践中遇到的高频问题现象可能原因处理方式loss直接NaN学习率太高或reward数值溢出调低学习率至1e-4检查reward是否出现inf成功率始终为0loss也不降重标注目标维度取错打印buffer里的目标对比环境achieved_goal早期成功率涨得飞快后期暴跌噪声太大策略被扰动破坏降低动作噪声标准差或加噪声衰减buffer很快满了训练越来越慢k_future设太大调回4或8扩大capacity或换成按比例淘汰旧数据评测很好实机完全不行sim-to-real gap在仿真里加随机化摩擦系数、物体质量、初始位置抖动尤其要提醒的是HER不是万能药。如果你的环境本身就是稠密奖励比如每一步都有精确的距离反馈那HER带来的收益非常有限甚至可能因为重标注目标的引入让策略学到一些“奇奇怪怪”的捷径。我自己就见过一个倒水任务加了HER之后策略学会了先把杯子里的水洒掉一半因为“未来状态”重标注降低了完成难度。这种时候建议把HER的额外目标比例调低或者只在特定阶段开启。写在最后的几句唠叨玩HER这段时间我最大的感悟是强化学习里很多看似神奇的效果拆开看都只是一些朴素想法在工程上的极致放大。HER的内核就是“失败里也有可学的部分只是你换了一双眼睛去看它”。不要把它当成一个需要复杂推导的算法把它当成一种数据增强手段你会更好理解它为什么能work。最后再分享一个小技巧。如果你在调试自己的环境可以先用一个纯随机策略跑几十个episode然后用HER buffer去训练一个价值函数看看Q值是否有区分度。如果连随机数据重标注后Q值都学不出任何规律说明你的状态表征和目标定义之间可能有断档这时候先别急着调算法先回去检查observation和goal的空间是否对齐。我就是靠这个办法避免了很多次“训练一晚上第二天发现是环境bug”的惨剧。这个项目后续我打算把HER扩展到多智能体协作的场景去让两个机械臂互相作为“未来状态生成器”理论上应该能缓解多智能体稀疏奖励的问题。如果你也在玩HER或者类似的样本回放策略欢迎在评论区聊聊你踩过的坑。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。