拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

强化学习中的HER:用后见之明经验回放破解稀疏奖励问题

强化学习中的HER:用后见之明经验回放破解稀疏奖励问题

这个项目叫hindsight,在强化学习领域,这个名字已经和一个算法深度绑定——Hindsight Experience Replay(HER,后见之明经验回放)。我第一次在论文里看到这个思路时,说实话是被惊艳到的:它把一堆“失败”的轨迹重新解释成了“成功”的训练样本,让智能体在没有密集奖励的情况下也能稳定学起来。这篇文章适合正在做机器人控制、操作任务、导航或任何带稀疏奖励RL任务的人,也适合刚入门强化学习、想搞懂目标条件策略的读者。我会把HER的原理拆开讲清楚,再给出可以直接照着写的实现代码,以及论文里不会写、只有实际训练才踩得到的那些坑。

1. 先搞清楚:HER到底解决了什么问题

1.1 稀疏奖励:强化学习最常见的“劝退”现场

很多RL任务写成MDP之后,奖励函数是“成功给1,失败给0”,或者更严格一点“成功给0,失败给-1”。听起来很公平,但真正拿算法去跑的时候,问题就大了。

比如机械臂推箱子到指定位置,箱子的初始位置随机,目标是桌面某个点。智能体刚开始完全随机探索,大概率是怎么推都推不到目标点的。于是整个经验回放缓冲区里装满了reward = -1的样本,没有一条正样本。Critic网络在这种数据上学出来的Q值,对任何(s, a)都差不多:都是负数,都是“没戏”。Actor根据Q值做梯度上升,得到的梯度方向基本是噪声,学不到任何有意义的动作。

这个现象在机器人操作、迷宫导航、棋类游戏等场景里特别常见。我之前调过一个机械臂任务,纯用DDPG跑了40万步,成功率一直是零,学习曲线就是一条水平线。不是代码bug,不是超参问题,就是稀疏奖励下的典型失学症状。

生活里类比一下:你想训练一个人投篮,但规则是“只有投进三分线外的篮筐才算得分”,其他所有出手都不给任何反馈。这个人随机扔了几百次,一次没进,那他根本不知道“我离篮筐近了一点”是好是坏,最后只能在原地乱扔。这和稀疏奖励下的RL是同一个困境。

1.2 传统解法为什么差一口气

面对稀疏奖励,业界最常见的应对手段大概是这么几类:

  • 手动设计密集奖励(Reward Shaping):比如“离目标越近奖励越高”。这个方法直接,但工程量大,而且很容易引入次优策略。你鼓励它靠近目标,它可能学会先撞过去再弹回来;你鼓励它避免碰撞,它可能干脆原地不动。
  • 课程学习(Curriculum Learning):从简单目标开始,逐步提高难度。这条路线在仿真里有效,但要额外设计课程策略,任务一复杂就说不清楚了。
  • 内在奖励(Intrinsic Motivation):用好奇心或状态新颖度作为额外奖励。思路不错,但在高维连续控制任务里很容易被“环境里的随机噪声”吸引注意力,导致学到一堆与任务无关的行为。

注意到没有,这些方法本质上都是在改“奖励信号”。没有人想过:能不能不改奖励,而是改“已经发生的经验”?HER就是从这个角度切入的。它不动奖励函数,不引入额外模块,只对经验回放缓冲区里的数据做“重新解释”,让失败轨迹的一部分变成正样本。

1.3 “后见之明”的直觉:失败经验也可以当正样本

“Hindsight”这个词的直觉其实特别简单。回到投篮的例子:一个人练习投篮,虽然没投进三分线,但他投出去的动作让篮球划出了一条漂亮的弧线,并且落到了篮板附近。这件事真的毫无价值吗?

换个角度看:如果目标不是“投进三分线”,而是“把篮球抛出这个弧线、落到篮板附近”,那这个动作就是完美的。也就是说,一个动作没有达成“你预设的目标”,但它一定达成了“某个实际发生的结果”。这个实际结果,在事后看来,完全可以当作一个正样本来学习。

HER的核心操作就是把这句话落到了数学上:一条经验原本是(s, a, r, s', g),其中g是智能体没有达成的“期望目标”;HER拿着实际到的状态,重新构造一个伪目标g' = achieved_goal,再把奖励按新目标重算一遍,得到(s, a, r', s', g')。因为这个伪目标确实达到了,所以新奖励r'是成功奖励,这条经验就从负样本变成了正样本。

我当时看到这个思路的第一反应是:这也太简单了,能有用吗?结果我自己实现了一遍,在Fetch环境上一试,效果是真的立竿见影。

2. HER的核心机制:经验是怎么被“改写”的

2.1 一条transition的四个角度

在具体写代码之前,先把HER处理的“对象”搞清楚。一条transition原本长这样:

(s, a, r, s_next, goal)
  • s:当前状态,比如机械臂关节角度、夹爪位置、箱子位置。
  • a:当前动作。
  • r:在目标goal下,执行动作a之后获得的奖励。
  • s_next:执行动作后的下一状态。
  • goal:当前episode的期望目标,比如“箱子最终位置”。

但在HER实现中,我们通常把环境返回的observation拆成三部分,而不是一个扁平向量:

observation -> state # 机器臂自身状态,比如关节角、线速度 -> achieved_goal # 实际达到的目标,比如夹爪当前坐标、箱子当前坐标 -> desired_goal # 用户给定的目标,比如夹爪期望坐标

这个拆分很重要。因为HER重标注的时候,需要把desired_goal替换成某个achieved_goal,再重新算奖励。如果observation是一团混在一起的向量,你根本不知道哪几个维度是“目标空间”,也就没法做替换。

HER改写经验时,具体操作是这样:

原始描述:

transition = { 'state': s, 'action': a, 'reward': sparse_reward(s_next, desired_goal), 'next_state': s_next, 'goal': desired_goal, }

HER改写后:

transition = { 'state': s, 'action': a, 'reward': sparse_reward(s_next, pseudo_goal), # 这个通常是成功奖励0 'next_state': s_next, 'goal': pseudo_goal, # 从同一episode未来某时刻的真实achieved_goal里采 }

注意,state和next_state没动,动作没动,真正改的只有两处:目标值和对应的奖励。这个看起来很小的改动,把“失败”数据的标签翻了个面。

2.2 为什么这种改写不是自欺欺人

第一次接触HER的人都会有个疑问:把目标换掉,这不就是自己骗自己吗?智能体明明没完成用户给定的任务,你却告诉它“你成功了”,这难道不会学歪?

这里面的关键不在于“骗”,而在于HER训练的是一个目标条件策略。目标条件策略的形式是π(a|s, g),意思是:给定当前状态s和一个目标g,策略输出动作a。它学的不是“从s出发到达某个固定目标”,而是“从s出发到达任意指定目标的能力”。

你想想,一个真正的目标条件策略,如果它能“推箱子到桌面上任何一个可达的位置”,那你给它当前桌面上任意一个点作为目标,它都应该能推。用户指定的目标“把箱子推到A点”,本质上也是这个目标空间里的一个点。所以用伪目标训练出来的行为,并不是无用功——它在教会策略“完成一个目标”的通用能力。当真实目标是A点的时候,策略调用的是同一套底层技能,只不过输入的目标向量不同而已。

还有一点很多人忽略:HER之所以能从伪目标中得到有效的学习信号,是因为伪目标不是凭空产生的,它来自这个episode中某个未来时刻真实到达的achieved_goal。这说明从当前时刻的状态出发,这个伪目标在物理上是可达的,并且轨迹里确实存在“通过某种动作到达它”的证据。策略学到的是“在这个状态下,执行这个动作,能靠近这个目标”,这完全是一条真实有效的信息,和自欺欺人沾不上边。

用一段话归纳:HER不改变环境动力学,不改变真实目标上的评估逻辑,它只是换了一个角度,把“没达成预期目标”的经验转化为“达成某个实际目标”的经验,让本来就存在的因果信息不被稀疏奖励浪费掉。

2.3 伪目标采样策略:final、future、episode怎么选

HER论文里给了好几种伪目标采样策略,代码实现时通常用一个参数切换:

  • final:只用episode结束时的最终状态作为伪目标。最朴素,但问题也明显——最终状态离得很远,中间很多动作和这个伪目标的因果关联特别弱。
  • random:从状态空间里随机抽一个状态当伪目标。这个最不推荐,因为随机目标大概率物理上不可达,你等于在教策略做一件根本做不到的事。
  • episode:从当前episode里随机抽一个状态作为伪目标。比random好一些,但同样存在“和目标因果无关”的问题。
  • future:从当前时间步之后的若干时间步里,挑一个状态的achieved_goal作为伪目标。这是实践证明最稳的选择。

为什么future最好?因为伪目标是“当前状态之后某个时刻真实到达过的位置”,这意味着从当前状态出发,这个位置在时间上是可达的,轨迹段和目标天然存在因果链条。比如机械臂第10步在A点,第35步到了B点,把B点当作第10步的伪目标,那条轨迹就是“从A点出发逼近B点”的有效证据。用final也能做到这一点,但final只能提供episode末端一个目标,样本多样性太差;future可以提供每个时间步之后的一批候选状态,数据量丰富得多。

实际操作中,我一般固定用future策略,超参数future_k取4,意思是在“未来时间步”里均匀抽4个不同的状态作为伪目标。这个值和强化学习里的batch size一样,不需要特别精细地调,4到8之间都行。

3. 手写一个HER:环境、算法与代码落地

3.1 环境选型与算法搭配

想快速复现HER效果,推荐用这几个环境,从简单到复杂:

环境特点难度
Bit Flippingn位二进制翻转,目标是把当前比特串翻转为目标比特串入门
FetchReach控制机械臂末端移动到目标点简单
FetchPush用机械臂把箱子推到目标位置中等
FetchPickAndPlace夹取物体并放到目标位置困难

这些环境在Gym/Gymnasium的Robotics系列里都有,reward的默认设置恰好是稀疏模式:到达目标给0,没到达给-1,非常适合演示HER。

算法选择上,HER本身不是策略优化算法,它只是一个经验重标注技巧。它必须搭配一个off-policy、带经验回放的算法才能发挥作用。最经典的搭配是DDPG,但我个人更推荐直接用TD3或者SAC,尤其是连续控制任务。原因很直接:DDPG对超参数敏感,Q值过估计问题在稀疏奖励下会被放大;TD3在DDPG基础上加了裁剪Q值和延迟更新,稳定性好很多,训练省心。

为什么必须是off-policy?因为HER要让“重标注后的经验”进入回放缓冲区,然后在更新时被反复采样。如果像PPO那样每次更新用完后丢掉数据,重标注的价值就大打折扣。所以你会看到,凡是和HER配合的算法,基本都是off-policy的actor-critic架构。

3.2 重标注模块的代码实现

下面这段代码是我在项目里的核心逻辑,去掉了一些工程细节,保留主干:

import numpy as np from collections import deque def sparse_reward(state, goal, tolerance=0.05): # state是实际状态/位置,goal是目标状态/位置 # 这里用的是欧氏距离判据 distance = np.linalg.norm(state - goal) return 0.0 if distance < tolerance else -1.0 def her_relabel(episode_trajectory, her_ratio=0.8, future_k=4): """ episode_trajectory: list of dict 每个dict包含 obs, action, reward, obs_next, achieved_goal, desired_goal 返回: 一组可以加入replay buffer的transition """ relabeled_transitions = [] for t, trans in enumerate(episode_trajectory): obs = trans['obs'] action = trans['action'] obs_next = trans['obs_next'] achieved_goal = trans['achieved_goal'] desired_goal = trans['desired_goal'] # 以一定概率保留原始目标 if np.random.rand() > her_ratio: relabeled_transitions.append({ 'obs': obs, 'action': action, 'obs_next': obs_next, 'goal': desired_goal, 'reward': sparse_reward(obs_next, desired_goal), }) continue # 从当前时间步之后的未来状态里,均匀采样future_k个伪目标 future_steps = np.arange(t + 1, len(episode_trajectory)) if len(future_steps) == 0: # 最后一帧只能保留原目标 relabeled_transitions.append({ 'obs': obs, 'action': action, 'obs_next': obs_next, 'goal': desired_goal, 'reward': sparse_reward(obs_next, desired_goal), }) continue sampled_indices = np.random.choice(future_steps, size=min(future_k, len(future_steps)), replace=False) for idx in sampled_indices: pseudo_goal = episode_trajectory[idx]['achieved_goal'] relabeled_transitions.append({ 'obs': obs, 'action': action, 'obs_next': obs_next, 'goal': pseudo_goal, 'reward': sparse_reward(obs_next, pseudo_goal), }) return relabeled_transitions

有几个细节值得说。

第一,obs_next和achieved_goal一定要对齐。很多环境里obs_next里就带着achieved_goal_next,你可以直接从obs_next里拆出来,不要自己去状态向量里硬算位置,容易对错索引。

第二,future_steps采样时要注意t+1可能越界。上面的代码用len(future_steps)==0做了保护,实际项目里最后一帧的状态基本已经到episode末尾,不值得再重标注。

第三,重标注入库时,state和next_state保持不变,变的只是goal和reward。这个逻辑一定要写正确,如果手滑把state也替换掉,训练就会彻底乱掉。

3.3 训练循环中的参数与流程设计

把HER塞进训练循环,整体流程大概是这样一个节奏:

replay_buffer = ReplayBuffer(capacity=1_000_000) for episode in range(max_episodes): obs, info = env.reset() trajectory = [] for step in range(max_steps): action = actor.select_action(obs, noise_scale=0.1) obs_next, reward, done, truncated, info = env.step(action) trajectory.append({ 'obs': obs.copy(), 'action': action.copy(), 'obs_next': obs_next.copy(), 'achieved_goal': obs_next['achieved_goal'].copy(), 'desired_goal': obs_next['desired_goal'].copy(), }) obs = obs_next if done or truncated: break # 关键:先重标注,再入库 transitions = her_relabel(trajectory, her_ratio=0.8, future_k=4) for trans in transitions: replay_buffer.add(trans) # 常规off-policy更新 for _ in range(num_updates): batch = replay_buffer.sample(batch_size=256) loss_critic, loss_actor = td3_update(batch)

训练过程中有一个参数值得反复强调:her_ratio。它控制“多少比例的经验会被重标注”。我实测下来,0.8左右效果比较好。如果设成1.0,所有经验都被改写成伪目标,缓冲区里几乎没有“用户真实目标”的样本,训练后期反而容易让策略淡忘真实目标分布;如果设成0.5以下,正样本比例太低,HER的优势体现不出来。0.8的意思是:80%的经验用伪目标学习,20%保留原目标,这样真实目标分布一直被保留在样本里。

future_k=4的意思是,对每条原始transition,额外生成4条重标注版本。这会显著增加回放缓冲区里“成功”样本的比例,但同样要注意:future_k太大会导致缓冲区里同一(s, a)对应一堆不同目标,经验冗余度变高,内存和算力都会浪费,我一般不超过8。

为了便于实验对比,我建议在训练时记录三个指标:

  • reward_before_her:原始reward的均值。
  • success_rate_pseudo:重标注后伪目标的“成功率”。
  • success_rate_true:用真实目标评估策略的真实成功率。

前两个用来观察训练动态,最后一个是唯一可信的评估指标。原因后面会专门讲。

4. 训练实测与调试记录

4.1 从Bit Flipping到FetchPush:效果差异

我自己在四个环境上都跑过对比实验,结论和论文里的趋势基本一致。Bit Flipping这个环境最简单,状态是20位二进制串,动作就是翻转某一位。不用HER,DDPG几乎学不到任何东西,因为随机翻转20位恰好命中目标的概率低到可以忽略;用了HER之后,策略在1万步内就能学会“朝目标位翻转”的技能,成功率稳定在95%以上。这个环境特别适合新手验证代码是否正确。

FetchReach比Bit Flipping复杂一点,但动作空间是4维连续,目标只控制末端位置。有趣的是,不用HER,只要多加探索噪声,DDPG偶尔也能摸到目标,但成功率波动特别大;而上了HER之后,成功率曲线平滑往上走,明显稳很多。

最典型的是FetchPush。这个环境需要机械臂把桌面上的箱子推到目标点,普通DDPG在2万多个episode里成功率一直是零,不管怎么调探索噪声都没用。同样配置只加HER,大概8000个episode左右成功率就开始抬头,15000个episode能到70%以上。我自己跑出来的曲线形状和论文里的趋势是一致的:前期会有一段“平台期”,看起来没什么进步,实际上critic正在缓慢积累目标条件价值信息,熬过这段之后成功率会突然起来。

这也是一个很重要的经验:HER训练初期的学习曲线往往不是线性上升,而是先平后涨。如果前期看到曲线不动就急着停掉,很可能会错过后面的大幅提升。

4.2 成功率评估的正确姿势

HER一个很容易踩的坑,是用“重标注后的奖励”来评估策略。有些人训练时看到回放缓冲区里奖励慢慢从-1变成0,就以为策略在进步。这其实是个错觉。

重标注后的奖励高,只能说明“伪目标被达到了”,不能说明“用户设定的真实目标被达到了”。比如机械臂每次都推到A点,但用户目标是B点,那么所有以A点作为伪目标的经验都会显示成功,可真实成功率依然是零。

正确的评估做法是:训练时定期冻结当前策略,在若干种随机初始条件下,让策略以“真实desired goal”作为输入,跑完整条轨迹,看看最终状态和真实目标的距离是否小于阈值,统计成功率。注意,评估时不要使用重标注,不要加探索噪声,动作直接用actor输出的确定性动作。

我在代码里就是这么处理的:

def evaluate(env, actor, episodes=10): success_count = 0 for _ in range(episodes): obs, _ = env.reset() done = False while not done: action = actor.select_action(dict_to_obs(obs), deterministic=True) obs, _, terminated, truncated, _ = env.step(action) done = terminated or truncated achieved_goal = obs['achieved_goal'] desired_goal = obs['desired_goal'] if np.linalg.norm(achieved_goal - desired_goal) < 0.05: success_count += 1 return success_count / episodes

这个成功率才是项目汇报时能拿出手的指标。

4.3 训练不收敛的排查清单

HER虽然好用,但绝不是加了就能稳定收敛。实际调试中,我遇到最多的训练异常和对应排查思路如下:

症状排查方向
学习曲线一直水平不动先确认经验回放里是否出现了伪目标的0奖励样本;再确认actor输入里是否真的拼接了goal
前期Q值特别大/震荡检查reward是否写反,检查state与goal是否归一化
真实成功率上去后又崩掉大概率是经验回放里真实目标样本太少,试试降低her_ratio
伪目标成功率很高但真实成功率很低说明策略过度适配伪目标分布,试试改future策略或降低future_k
训练时好时坏,一段稳定一段掉探索噪声可能过大,建议把噪声std从0.1调低或加衰减

排查有一个顺序技巧:先看reward是否变化,再看Q值是否合理,最后才调HER相关的超参数。很多时候问题根本不在HER上,而是底层的TD3/DDPG训练不稳定,比如critic和actor学习率失衡、目标网络更新节奏不对。HER只是经验层面的增强,不能替代算法本身的稳定性。

5. 使用HER的避坑清单与经验总结

5.1 伪目标不是随便抽的

这一点值得反复强调。伪目标必须来自“当前episode未来某时刻真实到达过的状态”,不要用random策略随便抽。你可能觉得random策略抽出来的目标更随机、更能覆盖状态空间,但训练时会让actor去逼近一堆物理上根本到不了的目标,Q函数在这种数据上会学到一堆标准差极大的错误预测,整个训练就崩了。

我测试过一次,在FetchPush上用random替代future,成功率直接掉到个位数。所以这条经验记住:伪目标要“从轨迹里来,到轨迹里去”,保持因果上的可到达性。

还有一个小技巧:如果future_k比较大,或者her_ratio偏高,同一批(s, a)会在缓冲区里出现很多份,只是goal不同。这是正常的,也不是问题,但对应的训练步数要同步加大,让critic有机会把“不同目标下的价值差异”学出来。

5.2 状态与目标一定要归一化

HER训练里最常见的一个隐形杀手是量纲差异。比如机械臂关节角度是0到1之间的数,盒子位置是0.1到2.0之间的数,物体朝向可能在-π到π之间。这些数值直接拼进state向量,Q函数的目标值会受个别大维度主导,小维度上的梯度几乎被淹没。

我的处理方式是把observation拆开后分别做归一化:

# 统计初始状态里的均值和方差 obs_mean, obs_std = compute_running_stats(env) # 对state、achieved_goal、desired_goal各做一次归一化 norm_state = (state - obs_mean['state']) / obs_std['state'] norm_achieved = (achieved_goal - obs_mean['achieved_goal']) / obs_std['achieved_goal'] norm_desired = (desired_goal - obs_mean['desired_goal']) / obs_std['desired_goal']

注意,achieved_goal和desired_goal属于同一个坐标空间,应该用同一组均值方差归一化,否则等价于做了一个线性变换,麻烦且没必要。我在写代码时一开始犯过这个错,把两个goal分开归一化,结果Q值怎么训都稳不下来。

5.3 别被回放数据的“假成功率”骗了

这条是给所有做RL人的忠告:训练日志里的mean reward不是用来判断成功率的指标,尤其在HER这种会对奖励做重写的算法里。我在一个项目里看到过一张“漂亮”的曲线,回放奖励从-1一路涨到0,demo视频里机械臂却根本推不到目标。原因就是eval脚本用的还是伪目标,策略被成功“骗”了。

所以,日志里至少要同时打印这三个值:

  • replay_reward_mean:回放缓冲区平均奖励,反映训练动态,不反映任务完成度。
  • pseudo_success_rate:重标注伪目标上的成功率,只在模型分析时有参考价值。
  • true_success_rate:真实目标上的成功率,唯一的任务评估指标。

如果线上/线下汇报,只用true_success_rate说话。

5.4 HER不是银弹,边界要清楚

HER有效是有前提的。第一个前提是任务必须能定义“achieved goal”,也就是说从状态里能提取出“实际达到的目标”。

比如机械臂任务,夹爪位置、箱子位置天然就是achieved goal;图像生成任务,目标是一张图,你很难从每次生成的图像里抽出一个“实际达到的目标”再重写奖励。第二个前提是目标空间不能太怪异,最好是具备连续性和可达性的空间,像推箱子这种目标在桌面上连续分布就非常适合HER;如果目标是“在三维空间里排序一串复杂物体”,HER还能用,但效果会大打折扣。

另外,HER解决的是“稀疏奖励下样本利用率低”的问题,不是“探索不够”的问题。如果任务本身探索空间极大,伪目标再怎么重写,也覆盖不了状态空间的重要区域,这时候该上的是更好的探索策略、课程学习或层次化方法,而不是死磕HER。

我个人的体会是,HER是一个非常优雅的“观点转换”:它把失败重新定义成另一种形式的成功,让每个episode里的数据都有了自己的用途。你可以把HER看成RL框架里的一个插件,而不是一个完整的算法。它和DDPG、TD3、SAC都能搭配使用,但对底层算法的稳定性有一定要求——底层算法稳住,HER才能发挥出最大的价值。

最后再分享一个小经验:训练后期如果发现真实成功率在高位反复震荡、掉点,可以尝试把her_ratio从0.8逐步降到0.5,甚至0.3。因为训练后期策略已经掌握基本技能,这时需要更多“真实目标”的样本来精修目标条件策略的泛化边界,减少伪目标对最终目标分布的干扰。在FetchPush上我用这个“衰减HER比例”的技巧,把最终成功率从68%提升到了81%左右,算是性价比很高的一步调参。

返回列表