看到“hindsight”这个词,做过强化学习的老哥们可能最先想到的是“事后诸葛亮”这个日常翻译。但在RL圈子里,这个词背后站着一个相当能打的算法——Hindsight Experience Replay,事后经验回放,简称HER。我第一次认真啃这个算法的时候,正好卡在一个机械臂抓取项目上,奖励函数怎么设计都不收敛,靠HER直接盘活了整个训练过程。这篇文章就围绕这个项目,把事后经验回放这整套东西掰开揉碎讲清楚,包括它解决了什么问题、核心机制怎么生效、代码怎么落地,以及我在实操中踩过的几个坑。适合正在搞稀疏奖励任务、对Off-Policy算法有基础、但被奖励设计折磨到想骂人的朋友。
1. 什么是hindsight:从“事后诸葛亮”到一项正经算法
1.1 稀疏奖励环境下,RL为什么这么难
先说句实在话:强化学习最烦人的地方,不是网络结构设计,而是奖励函数。环境复杂一点之后,你给智能体设的奖励一旦不够密集,它基本上就是原地打转。想象一下你让一个小孩在完全黑暗的房间里找一把钥匙,唯一反馈是“找到钥匙才算成功”,其他任何动作都没有提示。小孩只能瞎摸,大概率摸到天荒地老也找不到。这就是稀疏奖励问题。
我之前做机械臂抓取的时候就是这个感觉。机械臂要从桌面抓一个方块放到目标位置,初始状态和目标位置相距几厘米,但智能体每一步的奖励几乎全是0,只有在精确到达目标时才拿到正反馈。训练了20万步,成功率还是0,奖励曲线一条直线。这种场景下,任何基于梯度的更新都很难生效,因为样本里几乎没有正向信号可供学习。
业内主流的应对方案无非这么几个:一是手动设计密集奖励,比如按距离给一个负奖励,离得越近,负得越少。但人工搓出来的奖励函数往往带偏见,智能体很容易钻空子,比如“手够近但没抓稳”也能骗到高分。二是用课程学习,从容易的目标逐步过渡到难目标,但任务一多,课程本身又是一门玄学。三是用模仿学习,需要有专家数据,很多场景下根本拿不到。HER走的是第四条路:不修奖励,不换任务,而是改变“经验本身”,把失败的轨迹重新解释成成功轨迹。思路清奇,但又非常合理。
1.2 核心思路:目标重标注怎么“变废为宝”
HER的思想用一个词概括就是“重标记”。它不把那些没达到目标的数据当作垃圾丢掉,而是换个角度去看:虽然没达到我指定的目标,但至少到达了某个位置。那我干脆把那个位置当作这次的目标来看待,这条轨迹不就变成了一条“成功轨迹”了吗?这就是典型的事后审视。
还是拿机械臂抓取举例。假设我设定的目标是“把方块放到桌面的右上角”,智能体实际操作的结果是“方块被推到了右下角”。按原始奖励函数,这条轨迹是失败的,奖励全0。HER的做法是:把目标从“右上角”改成“右下角”,然后重新计算奖励。因为最终方块确实到了右下角,所以按新目标来看,这条轨迹的每一步都能被解释为“朝目标靠近”,尤其是最后一步,直接获得成功奖励。再把这个目标重写过的轨迹丢进经验回放池,当成有效经验去训练。
这么一来,即使是完全随机的探索,也能不断产生带有正向奖励的样本。经验池里成功数据的比例大幅提升,价值网络和策略网络也有了稳定梯度。所谓“失败是成功之母”,在HER这里变成了一句工程方法论:每条失败轨迹都能被回收成一条合成成功轨迹。也正是因为这种“回过头来重新解释”的思路,项目代号才直接用了hindsight这个词。
1.3 项目定位与适用范围
在做完那个机械臂项目之后,我把HER整理成了一个通用的训练模块,后续好几个稀疏奖励任务都复用了同一套逻辑。这个项目本身定位很明确:不是要替代底层强化学习算法,而是作为一个经验回放层的增强组件,直接对接DDPG、TD3、SAC这类Off-Policy算法。使用时你只需要保证环境中能获取到“实际已达成的目标”这个字段,就能在几乎不改动原有训练代码的前提下,把稀疏奖励任务练起来。
这里有个关键前提:一定得是Off-Policy算法。因为在HER里,经验会被重新标注后反复用于更新,这天然要求算法能够使用历史数据。PPO这类On-Policy算法跑一轮就把数据扔掉了,HER根本发挥不了作用。另外,对于完全随机、毫无记忆力的奖励设计,HER也不是万能药,它只能缓解“奖励稀疏”,解决不了“奖励完全错误”。不过对于大多数机械控制、导航、抓取类任务,HER基本是首选组件,没有之一。
2. 核心机制拆解:目标重标注为什么成立
2.1 状态、目标、已达成目标三者怎么组织
要动手实现HER,首先得理解它依赖的数据结构。普通强化学习的每条经验长这样:状态、动作、奖励、下一个状态、是否结束。但HER要求在环境里额外暴露两个字段:目标(goal)和已达成目标(achieved_goal)。
这里的目标不是指“想把机械臂末端移动到坐标(0.5, 0.2, 0.3)这种向量”,而是一个和状态描述维度可对齐的张量。比如在机械臂环境里,目标就是目标位置的坐标,已达成目标就是当前方块实际位置的坐标。在导航任务里,目标就是终点经纬度,已达成目标就是智能体现在所在的位置。道理都一样:环境必须能够返回“当前智能体在目标空间中的实际落点”。
有了这两个字段,重标注的流程就很简单了。一条原始经验里的目标是goal,实际结果是achieved_goal。HER把新的目标new_goal设为achieved_goal(或者轨迹中未来某个时刻的achieved_goal),然后用同样的奖励函数重新计算奖励。注意,这里的奖励函数本身可以非常简单,比如“当已达成目标距离目标小于某个阈值时奖励为0,否则为-1”。因为重标注后,achieved_goal和new_goal完全匹配(或者距离为0),这条经验就会变成一条成功的正向样本。
2.2 重标注之后,奖励怎么重新计算
重标注的关键在于奖励函数必须是“目标条件”的。也就是说,奖励是根据当前状态下的已达成目标和目标计算出来的,不能是一成不变的常数。一个典型的目标条件奖励长这样:
def compute_reward(achieved_goal, desired_goal): # 返回一个标量奖励,距离越小奖励越高 dist = np.linalg.norm(achieved_goal - desired_goal, axis=-1) return -(dist > threshold).astype(np.float32)这个函数输入的是已达成目标和期望目标,输出奖励。注意,它跟当前观测状态无关,只和目标空间中的距离有关。这就是HER能够重写目标的数学基础:既然奖励只取决于goal和achieved_goal,那我换一个goal,奖励自然就变了。
实际操作中,真正存进经验池的那条重标数据长这样:保持状态、动作、下一状态、结束标志完全不变,只把目标从原来的goal改成new_goal,然后重新过一遍奖励函数,得到新的reward。这样一条数据从“失败”变成了“成功”,但它仍然是一条真实发生过的状态转移,只是换了一个解释角度。这个解释角度并不会破坏学习,因为策略本来就是在目标条件下做决策的,多学一种目标条件下的成功路径,对原始目标条件下的策略也是一种间接强化。
2.3 四条采样策略:final / future / episode / random
重标注不是什么状态都能拿来当目标,选哪些状态当新目标,对效果影响很大。论文里给了四种策略,我用大白话翻译一下,顺便说我实测下来的体感。
- final:整条轨迹结束后,只用最后一步的achieved_goal作为新目标,重写整条轨迹的所有transition。这是最简单粗暴的,适合那种“最终状态就是目标状态”的任务,比如打开门、把物体推到位。
- future:轨迹中某个时刻t的transition,从后面的时刻中随机抽一个状态的achieved_goal作为新目标。这是最推荐、实验效果也最好的策略。原因在于,从t到被抽中的未来时刻之间的那些步骤,天然就是一条“朝这个目标移动”的真实路径,状态转移逻辑是自洽的。
- episode:从整条轨迹的所有状态中随机抽一个作为新目标。这个随机范围更大,但可能抽到跟当前时刻距离很远的状态,造成“这条经验是在走向那个目标”这个假设不太成立。
- random:从整个经验池中随机抽一个状态作为新目标。这个我没有细测,因为感觉它已经脱离了轨迹内部的时间结构,一般论文里也比较少用,主要做对比。
我实际用下来,final和future是最稳的。final适合任务目标相对单一、终点状态明确的场景;future则适合所有场景,几乎是开箱即用。你现在去GitHub上翻很多现代机器人学习项目,默认配置基本都是future策略。
2.4 一个容易理解的例子:机械臂推方块
把上面这些机制串到机械臂推方块这个经典例子里再走一遍。环境目标是把桌面上的方块推到指定位置。状态是机械臂关节角和方块当前坐标,目标是目标坐标,已达成目标是方块当前坐标,动作是关节力矩。
智能体随机尝试了几百步,最终方块停在(0.3, 0.5),离目标位置(0.7, 0.5)差一大截。原始奖励全是-1(或者0)。HER进场:把目标改成(0.3, 0.5),重新计算奖励。那些“让方块从(0.1, 0.5)挪到(0.3, 0.5)”的transition现在被标记为“让方块从(0.1, 0.5)朝(0.3, 0.5)移动”,末端状态的奖励变成0(成功)。
这个重标后的轨迹进入经验池,DDPG的Critic网络从这个样本里学到一个信息:只要当前方块在(0.1, 0.5),目标是(0.3, 0.5),那么执行这个动作是好的。这些经验不断累积,Critic对“靠近目标”这件事的评估越来越准,Actor也跟着修正策略。训练几万步之后,智能体竟然开始表现出一种“知道要朝目标移动”的行为。其实是HER把随机探索中的有效片段给挖掘出来了。
3. 手把手实操:在gym环境里跑通HER
3.1 环境准备与依赖
先交代一下我复现时的环境:Ubuntu 20.04,Python 3.8,PyTorch 1.12,gym 0.21。如果你机器上有NVIDIA显卡,建议装好CUDA和cuDNN,训练速度能快好几倍。没有显卡也能跑,就是慢一些,毕竟网络规模不大,CPU也能跑通整个流程。
依赖清单很简单:numpy、torch、gym、opencv-python(后续可视化会用到)。如果你要跑机器人环境,还需要安装mujoco_py或gym的机器人套件。我这里为了让小白也能跑,用一个简单的二维点导航环境来做演示,不用装任何物理引擎,代码逻辑和HER完全一致。
3.2 核心代码:HER缓冲区与重标注逻辑
我自己在项目中是把HER封装成一个单独的缓冲层,不跟具体算法耦合。这样DDPG、TD3、SAC都能复用。核心是重标注的流程,伪代码长这样。
import numpy as np import random class HERBuffer: def __init__(self, capacity=100000, reward_func=None, k=4, strategy='future'): self.capacity = capacity self.reward_func = reward_func self.k = k self.strategy = strategy self.buffer = [] self.pos = 0 def add_episode(self, episode): # episode: list of dict,每个dict包含 # obs, action, reward, next_obs, done, goal, achieved_goal self._relabel_and_add(episode) def _relabel_and_add(self, episode): length = len(episode) for t in range(length): trans = episode[t] # 原始经验先直接入池 self._store(trans['obs'], trans['action'], trans['reward'], trans['next_obs'], trans['done'], trans['goal'], trans['achieved_goal']) # 再生成k条重标注经验 for _ in range(self.k): new_goal = self._sample_goal(episode, t, length) new_reward = self.reward_func(trans['achieved_goal'], new_goal) self._store(trans['obs'], trans['action'], new_reward, trans['next_obs'], trans['done'], new_goal, trans['achieved_goal']) def _sample_goal(self, episode, t, length): if self.strategy == 'final': return episode[-1]['achieved_goal'] elif self.strategy == 'future': # 从t+1到轨迹末尾中随机抽一个未来的已达成目标 future_idx = random.randint(t + 1, length - 1) return episode[future_idx]['achieved_goal'] elif self.strategy == 'episode': return random.choice(episode)['achieved_goal'] elif self.strategy == 'random': return random.choice(self.buffer)['achieved_goal'] def _store(self, *args): data = (args[0], args[1], args[2], args[3], args[4], args[5], args[6]) if len(self.buffer) < self.capacity: self.buffer.append(data) else: self.buffer[self.pos] = data self.pos = (self.pos + 1) % self.capacity def sample_batch(self, batch_size): batch = random.sample(self.buffer, batch_size) obs = np.array([x[0] for x in batch]) action = np.array([x[1] for x in batch]) reward = np.array([x[2] for x in batch]) next_obs = np.array([x[3] for x in batch]) done = np.array([x[4] for x in batch]) goal = np.array([x[5] for x in batch]) achieved_goal = np.array([x[6] for x in batch]) return obs, action, reward, next_obs, done, goal, achieved_goal这个缓冲区有个特点:它按照轨迹来存储,而不是按照transition来存储。原因前面说过,重标注需要知道整条轨迹的最终状态(至少是未来状态),所以一条轨迹必须先完整跑完,再整体入库。如果你用的是增量式经验池,在transition产生的瞬间就入池,那就没法拿到未来的achieved_goal,HER就失效了。
3.3 训练主循环:HER如何接入DDPG
算法层面直接用DDPG做演示。主循环逻辑特别清晰:跑一个回合,收集整条轨迹,调用add_episode,然后从经验池采样训练。这一步看起来简单,但要注意几个顺序上的细节。先看代码。
def train_her_ddpg(env, agent, her_buffer, episodes=2000, batch_size=128, warmup=1000): total_steps = 0 for episode_idx in range(episodes): obs_dict = env.reset() episode = [] done = False while not done: # obs_dict包含observation, achieved_goal, desired_goal obs = obs_dict['observation'] goal = obs_dict['desired_goal'] achieved = obs_dict['achieved_goal'] action = agent.select_action(obs, goal) # 策略输入包含观测和目标 next_obs_dict, reward, done, info = env.step(action) next_obs = next_obs_dict['observation'] next_achieved = next_obs_dict['achieved_goal'] episode.append({ 'obs': obs, 'action': action, 'reward': reward, 'next_obs': next_obs, 'done': done, 'goal': goal, 'achieved_goal': achieved, }) obs_dict = next_obs_dict total_steps += 1 if total_steps >= warmup: break # warmup策略请自行实现,这里只做示意 # 一个回合结束后才交给HER库 her_buffer.add_episode(episode) # 训练若干步 if total_steps >= warmup: agent.update(her_buffer, batch_size)注意一个细节:agent.select_action的输入除了状态,还要加上goal。也就是说,策略网络的输入是状态和目标拼起来的一个向量。HER重标后的经验,其目标已经被替换,所以策略网络看到的输入也必须是重标后的目标。在实现时,网络forward要支持一个额外的goal输入,而不是只吃状态。
还有一个容易出错的点:环境的done标志也要慎重处理。重标后的经验中,如果新的目标真的是轨迹最终状态,那么原本的done往往是1(例如任务超时或失败结束)。但当你把目标替换成最终状态后,这个done就变成一个“成功终止”的done,训练时不能把这个信号直接当作失败来处理。我的做法是在重标时将done保持不变,但同一时刻原始经验仍然在池中,原始done=1(针对原始目标就是失败),两者并存,让Critic自己学会区分。
3.4 超参数选择与训练节奏
HER相关超参数不多,但每一个都值得认真调。第一个是k,也就是每条原始轨迹生成多少条重标轨迹。论文里常用k=4,我的实测是k从1到8范围内,收益逐渐上升但边际效应递减。k太大会导致经验池被重标数据淹没,原始目标分布的样本比例降低,反而可能把“原始任务”给淹没掉。所以k=4是一个性价比很高的默认值。
第二个是采样策略。我强烈建议先从future开始。它兼顾了轨迹内部的时间一致性,而且在大多数任务里都比final更稳。final策略也不是没用,比如有些环境里目标空间非常大,随机挪动一下位置就天差地别,此时final让整条轨迹都指向同一个终点,稳定性反而更好。
第三个是经验池容量。HER会额外生成k倍的样本量,假设你原来设的经验池是10万,加了HER之后就相当于要装50万条transition。内存不大的话,建议直接把池容量再放大一倍,并且注意淘汰策略。我的缓冲类是循环覆盖式,旧的失败经验会被逐渐挤掉。这里有个反直觉的现象:经验池太小时,HER的优势反而发挥不出来,因为重标产生的多样性不足;但池子太大也有问题,老数据和新策略之间差异大,学习进展会变慢。我自己常用的配置是20万至50万条过渡数据的容量。
训练节奏上还有一个心得:warmup阶段很重要。HER虽然能把失败样本变成成功样本,但前提是得有足够的失败样本进入经验池。开局前几百步最好用随机策略或带噪声的策略去探索环境,先攒出几条像样的轨迹。等经验池里有了一定量的重标样本,再正式切换到Actor-Critic更新,效果会明显比一上来就更新稳。
4. 常见问题与排查技巧实录
4.1 奖励曲线平坦得像心电图,是哪里出了问题
这是遇到最多的现象。训练跑了几万步,奖励曲线几乎不动,更像是一条没有波动的水平线。我排查这个问题的第一反应是:检查经验池里有没有正样本。HER的意义就在于提供正样本,如果经验池里全部是负奖励,那说明重标模块压根没生效。
重点检查三个地方。第一个,环境是否返回了achieved_goal。很多自定义环境只给observation和reward,没有单独暴露achieved_goal字段,这时候重标无从谈起。第二个,奖励函数是否是目标条件式的。如果你的奖励函数完全不接受goal参数,那换目标之后奖励根本不会变,等于HER白做。第三个,重标后的经验是否真的入池了。我有时会写一个简单的统计脚本,定期打印池里奖励为0或正数的样本占比,如果一直是0%,那就说明逻辑断掉了。
4.2 成功率上去了但动作很奇怪,疑似奖励黑客
有一次训练机械臂抓取,成功率确实提上来了,但看回放视频发现机械臂的动作极其诡异,先把方块推离目标再绕回来。原因在于重标后的目标太密集,导致Critic把某些中间状态误判为成功状态,策略就走捷径绕弯。
这个问题的本质是奖励设计的粒度太粗。如果阈值距离设得过大,比如“方块离目标5厘米就算成功”,重标之后的“成功样本”其实并没有真正到达目标,只是在阈值范围内。解决办法是把阈值调小,同时检查训练过程的轨迹可视化。不要只看成功率曲线,要隔一段时间存一段真实执行轨迹,亲眼看机械臂在干什么。
4.3 重标注是否会动摇策略的稳定性
好问题。我自己也担心过:每次都把失败轨迹改成成功轨迹,会不会让策略学到错误的目标分布?后来仔细分析了才明白,重标并没有把“任意状态”都变成目标,它只是把“在单条轨迹中实际到达的某个状态”当作目标。目标空间中的样本量虽然增加了,但每个新目标都对应于一条真实存在的、可执行的状态转移路径。换句话说,重标没有凭空捏造物理学,它只是换了一个目标坐标,转移本身依然真实发生。
不过要注意,经验池里原始目标的经验比例要控制好。如果全池子都是重标数据,策略可能会对新目标分布过拟合,对原始目标泛化变差。所以我才强调k别取太大。每次新增一条原始轨迹,同时新增k条重标轨迹,k=4就是让重标样本占多数但原始样本仍然存在。这个比例在绝大多数任务里都比较好用。
4.4 HER什么时候真的不适合用
HER并不是银弹。有些场景下它完全发挥不了作用。首先是On-Policy算法,前面已经提过,不赘述。其次是目标空间太大且没有结构性,比如一个离散目标有1万个选项,实际去过的目标永远只是其中极小一部分,重标出来的新目标根本覆盖不到有效区域。第三是环境返回的achieved_goal噪声特别大,重标本身会放大噪声,此时可以考虑先对achieved_goal做滤波或平滑,再用于重标。
还有一类任务要小心:多阶段任务。比如“先开抽屉,再拿杯子,最后回到起点”,这种任务的目标空间不是一个简单的向量,而是一个序列。HER对整个序列做重标几乎不可能,因为它需要重写的是“目标序列”,不只是“一个目标向量”。遇到这种情况,更好的方案是拆成多个子任务,每个子任务单独用HER,或者用分层强化学习。
4.5 几个容易被忽略的工程小坑
最后分享几个代码层面容易踩的坑。一个是数据类型的坑:numpy数组维度不齐,目标拼接时维度出错,这类报错在调试阶段特别常见。建议统一用float32,并且在一个入口处做reshape。另一个是随机种子的坑:HER会随机抽取未来状态作为新目标,如果种子不固定,同一轮实验的结果偶然性会很大。复现实验时一定要设好np.random.seed、random.seed和torch.manual_seed。
还有一个很有意思的坑:重标后奖励函数中的done逻辑要重算。有些环境在超出某些边界时会提前终止,但重标后的目标可能恰恰在那个边界附近。如果沿用原来的done标志,相当于告诉Critic“到达那个位置就是终止”,策略可能因此回避那条路线。我在代码里的做法是:当重标后的目标来自轨迹内部某个状态时,把该状态的结束标志重置为False,除非已经到了时间上限。
5. 说句实在话:HER的边界和我踩过的坑
项目跑完回头再看,我最大的体会是:HER不是用来替代奖励设计的,它解决的是“奖励存在但太稀疏”这个特定问题。在设计一个新任务时,我还是会先花力气把奖励函数本身理清楚,尽量保证奖励和目标空间有一定相关性,然后再叠HER。把HER当饭后甜点可以,当正餐不行。
实操中的另一个体会是,HER算法的调试重点其实不在算法本身,而在经验比例。那个k参数,我一开始觉得越大越好,结果训练出来的策略有个明显的毛病:它对已经去过的大片状态空间都很熟,但一遇到没去过的新区域就手足无措。后来把k从8降到4,并且增加了原始目标轨迹的比例,整体探索性和最终成功率反而都提升了。这种“调参玄学”背后其实是有逻辑的:经验池像一家餐厅,如果菜谱全是重标菜,客户(策略)的味觉就被带偏了。
最后再给个实用小技巧:在做完HER训练后,用原始目标任务再做几千步微调。因为重标目标比较“讨巧”,最终部署时还是要回归真实目标。我通常的做法是,HER训练到成功率接近稳定之后,把重标机制关掉,只用原始目标奖励再训一小段时间,让策略从“会过关”变成“更准确地过关”。这一步往往会让最终成功率再涨个几个百分点,而且执行轨迹看起来也正常多了。这个做法不算复杂,但很多人会忽略,算是我在这个项目上最有价值的一条经验了。