1. 项目概述:当一个强化学习项目叫“hindsight”
1.1 一句话解释HER到底解决什么问题
先说结论:hindsight这个词,在今天的强化学习圈子里,绝大多数时候指的不是“事后诸葛亮”这个日常含义,而是指2020年DeepMind和OpenAI合作发表的那篇经典论文《Hindsight Experience Replay》(后见经验回放,简称HER)。这个算法解决的是强化学习里最让人头疼的稀疏奖励问题——也就是智能体在环境里探索半天,一步奖励都拿不到,最终什么也学不会的窘境。
我最早接触这个项目时,是在一个机械臂抓取任务上。机械臂的任务是把桌上的积木推到指定位置,但环境的奖励设置非常苛刻:只有当积木和目标的距离小于5厘米时,才给1分,其余所有状态都是0分。你猜怎么着?我跑了整整50万步,智能体的成功率还是0。它完全像个无头苍蝇,在桌面上乱划。后来把HER加进去,同样50万步,成功率直接拉到了85%。这个对比让我印象太深了,也是我愿意专门写一篇文章来聊它的原因。
这个项目的核心价值在于:它用一套极其简单的“目标重标注”思想,把原本学不动的稀疏奖励问题,变成了常规的密集奖励问题。不需要改网络结构,不需要引入额外的策略约束,只需要在采样经验时动一点手脚,就能让原本应该被扔进垃圾桶的“失败轨迹”重新变成有用的训练数据。对于任何做机器人控制、导航、连续动作控制的从业者来说,这几乎是绕不开的基础功底。
1.2 为什么稀疏奖励让强化学习寸步难行
要理解HER的价值,得先搞清楚稀疏奖励到底难在哪。假设你在教一个小孩拼图,你告诉他说:只有当你把最后一块拼图放在正确的位置时,你才能得到一颗糖。如果这个小孩一开始完全不知道拼图长什么样,他大概率会拿着拼图块到处乱戳,偶尔戳到正确位置的概率微乎其微。于是他一整天都得不到一颗糖,自然会变得沮丧,最后干脆放弃尝试。
强化学习的智能体本质上就是这个小孩。当环境给出的奖励是稀疏的、二元的——要么成功给1,要么失败给0——智能体通过试错的方式探索到一个正样本的概率极低。尤其是在高维连续动作空间里,比如机械臂的关节角度有7个自由度,随机探索碰到成功状态的几率几乎为零。既然没有正样本,策略网络就永远得不到“往哪个方向调整会更好”的信号,梯度更新基本相当于原地踏步,训练自然无法收敛。
更麻烦的是,很多研究者一开始会尝试用“奖励塑形”(reward shaping)来缓解这个问题,比如把稀疏奖励改成“距离越近奖励越高”。这招在某些简单任务上确实有效,但一旦任务复杂度上来,手写的奖励函数很容易引入局部最优。智能体可能发现“只要把积木往前推一点点就能拿到一点小奖励”,于是它就停在那里原地推积木,再也不尝试朝目标位置移动了。HER的思路完全绕开了这个陷阱:它不改变环境原有的稀疏奖励,而是通过重新定义“目标”来制造密集的、客观的反馈信号。
1.3 从“后见之明偏差”到“后见经验回放”
有意思的是,HER这个名字里的“hindsight”,其实借用了心理学里的一个著名概念——后见之明偏差(hindsight bias)。我们都有过这种经历:事后回想起来,觉得某个事件的结果是“显而易见”的,但站在事前的角度,你根本预测不到。比如你丢了钥匙,最后发现钥匙在冰箱里,你会觉得“我早就该想到它在那”。但在丢钥匙的那一刻,你完全没头绪。
HER把这个心理现象直接搬进了强化学习。它告诉智能体:当你没能达到原定目标时,不要灰心,不要丢掉这条经验,**把头转回去看看——你实际上达到了什么状态?把这个状态当作你的“新目标”,再回过头来看这条轨迹,你会发现,原来你“成功”了!**这样一来,一条原本在稀疏奖励下得分为0的轨迹,经过目标重标注之后,就能产生一串富含信息的、非零的奖励信号,成为训练策略网络的高质量数据。
这个思想用一句话概括就是:失败经验被重新解释成成功经验,然后用于训练。极简,优雅,又极其强大。我会在下一节把这个机制拆开揉碎来讲。
2. 核心机制拆解:目标重标注到底在做什么
2.1 换一个目标,失败经验也能变成宝
HER的完整名称里有两个关键词:Hindsight(后见之明)和 Experience Replay(经验回放)。经验回放这个技术本身大家都很熟悉——把智能体和环境交互得到的一条条四元组(s, a, r, s')存进一个缓冲区,然后从中随机采样小批量来更新策略,打破样本之间的时间相关性。
HER做的事情,是在存入经验回放缓冲区之前,对每条轨迹做一次“目标重构”。标准的强化学习里,一条轨迹会被表示成(s_0, g, a_0, r_0, s_1, g, a_1, r_1, ...),其中g是固定的目标。但在HER里,你会额外生成一条“虚拟”轨迹:把目标g改成这条轨迹最终达到的状态s_T,然后再算一遍每一步的奖励。因为s_T是轨迹真实到达的状态,所以从第T步往回看,每个状态离“新目标”的距离会越来越近,奖励信号自然就变得丰富而连续。
以我做的机械臂推积木任务为例。原始目标g是桌面上位置A,智能体笨手笨脚地把积木推到了位置B——任务失败,整条轨迹只有一堆零奖励。BUT,HER会把位置B当作新的目标g'来重新解读这条轨迹:积木最终到了B点,那我们把“目标”改成B以后,这条轨迹就变成了“达成目标B的成功轨迹”。轨迹末端的奖励从0变成1,中间每一步也能得到逐渐增大的信号。这样一来,智能体就明白了一件很重要的事:哪些动作套路能够把积木从一个位置推到另一个位置。等到下一次它被要求把积木推到真正的位置A时,它已经有了“如何推东西”的基本技能,学起来自然快得多。
你可能觉得这有点“自己骗自己”的意思——把失败说成成功。但从数学上看,这完全不是欺骗。因为HER并没有修改环境的客观动力学,它只是在重新标注“任务目标”。物理过程还是那个物理过程:积木从s_0被一步步推到s_T。这个转移过程对于任何目标来说都是有效的经验。你把它当作“推到B”的成功经验来学,学到的是通用的推积木动力学,而不是某个具体位置的死记硬背。
2.2 四种目标采样策略与选型
HER论文里最容易被忽视的细节,是“如何为每条轨迹选择重标注目标”的采样策略。论文提出了四种,我在实际项目中全都试过,这里逐个说清楚差异。
final策略:把重标注目标设置为轨迹最终达到的状态。这是最简单的策略,也是论文验证最稳的基线。好处是目标始终是可达的,坏处是目标单一,容易造成样本多样性不足,在需要灵巧操作的复杂任务里可能不够用。
future策略:从轨迹中随机抽取一个未来的状态作为重标注目标。这种做法最关键。它保证了新目标对应的“成功时刻”会出现在轨迹的中后段,从而让智能体学到“从不同起点逼近某个中间状态”的能力。我用future策略时通常每个过渡只额外生成1个虚拟目标,效果比final好不少,尤其是在长视界任务里。
episode策略:从整个episode随机选一个状态作为目标,注意不要求这个目标在当前轨迹中真的被达到过。这个策略的重标注目标可能是不可达的,所以生成的虚拟轨迹末端奖励可能仍然是0。它的作用是增加目标多样性,但过于激进时可能把难度拉得太高,导致策略更新的噪声偏大。我个人的经验是,这个策略一般不单独用,更适合作为future的补充。
random策略:完全不依赖轨迹,从所有已知状态里随机抽一个状态作为目标。策略极其简单,但共享了episode策略的“不可达目标”问题,训练初期的收益提升速度明显慢于future。
如果你只是在工业场景里落地,我建议直接从future策略+每轨迹1条虚拟轨迹起步。论文里的消融实验也表明,future策略在绝大多数任务上综合表现最好。我自己踩过的坑是:一开始为了追求“丰富性”,同时用三种策略各生成2条虚拟轨迹,结果经验缓冲区里充斥着大量低质量的重标注样本,反而把训练拖慢了。目标多样性不是越多越好,它要跟策略的容量匹配。
2.3 HER为什么能和任意off-policy算法搭配
HER最讨喜的地方在于,它是一个完全通用的插件。它不依赖具体的策略梯度数学推导,也不要求策略网络有什么特殊结构,只要你用的是离策略(off-policy)算法——也就是可以从经验回放缓冲区反复采样的算法——就都能无缝接入。DDPG、TD3、SAC、DQN系列的算法,全部兼容。
为什么必须是off-policy?原因很直接:HER生成的虚拟轨迹里的状态、动作和转移都是真实的,但奖励和目标是重标出来的。这意味着这条轨迹对应的并不是当前环境里正在被执行的策略——实际上没有任何策略“执行”过它,它只是被重新解读出来的样本。离策略算法本来就不要求训练数据来自于当前策略,所以可以放心使用这些虚拟样本。反过来,如果你用A2C、PPO这类在线策略算法,每条数据只用一次,你根本无法把重标出来的样本重新喂给策略学一遍,HER就英雄无用武之地了。
这也解释了为什么在实操中,大家几乎都是HER+DDPG/TD3/SAC的组合。我在自己的实验里默认用的是SAC——因为SAC自带熵正则,在机器人控制任务里鲁棒性更好,不容易因为随机种子爆炸。但如果你已经有一套稳定的DDPG代码,完全没必要换,接上HER就能跑。
3. 实操:从零实现一个HER+DDPG
3.1 环境准备与任务定义
理论说得再热闹,不落地都是空中楼阁。下面我用一个相对简单但又不失代表性的任务——二维平面上的点目标导航——来完整走一遍HER的代码实现。任务定义是这样的:有一个点在二维空间里移动,每一步可以输出一个二维连续动作(水平速度和垂直速度),目标是从起点出发,到达地图上的某个随机位置。环境只在点到目标的欧氏距离小于0.5时给出奖励1,其他所有时刻奖励都是0。
为了模拟真实的稀疏奖励场景,我把环境写成一个OpenAI Gym风格的类。它需要额外暴露两个接口:一个用于采样新目标,另一个用于获取当前状态。这两点是HER的硬性要求——不像普通RL环境那样只需要环境给出奖励,HER还要求你知道“当前状态是什么”“目标是什么”,并且在每个episode开始时能重新采样目标。这些都是为了让目标重标注有据可依。
import numpy as np class PointGoalEnv: def __init__(self, goal_radius=0.5): self.goal_radius = goal_radius self.action_dim = 2 self.state_dim = 2 self.goal_dim = 2 self.agent_pos = None self.goal = None def reset(self): # 随机起点和随机目标 self.agent_pos = np.random.uniform(-5, 5, size=2) self.goal = np.random.uniform(-5, 5, size=2) return self._get_obs() def _get_obs(self): # 观测里同时包含当前状态和目标 return np.concatenate([self.agent_pos, self.goal]) def sample_goal(self): # 独立采样一个新目标,HER里会用到 return np.random.uniform(-5, 5, size=2) def step(self, action): # 动作是速度指令,用clip限制在边界内 self.agent_pos = np.clip(self.agent_pos + action, -5, 5) distance = np.linalg.norm(self.agent_pos - self.goal) reward = 1.0 if distance < self.goal_radius else 0.0 done = bool(reward == 1.0) return self._get_obs(), reward, done, {"distance": distance}这里面有个细节值得留意:reset()里使用了独立的sample_goal()方法,而不是直接在reset()内部随机生成。这么做是为了让HER的训练循环可以自由地采样新目标来构造多目标的经验回放。很多初学者在改环境时会忽略这个接口,导致后面写HER逻辑时得重新大面积重构环境代码,非常痛苦。
3.2 网络结构与DDPG核心实现
接下来搭建DDPG的四件套:Actor网络、Critic网络、各自的目标网络。我对这类连续控制任务的网络设计已经形成了一套比较固定的习惯,直接分享给你。Actor是两层128个神经元的MLP,激活函数用ReLU,输出层套Tanh再乘以动作范围的最大值2,把动作限制在[-2, 2]区间。Critic的结构类似,但它的输入是“观测+动作”,在输入层就把状态动作对拼接在一起,输出一个标量的Q值。
在实际工程中,我通常会在Critic的倒数第二层加入一个Dropout层,概率设为0.1。这个操作虽然在一些基准实验里看起来可有可无,但在噪声较大的真实传感器数据上,确实能让训练更稳。
import torch import torch.nn as nn import torch.nn.functional as F class Actor(nn.Module): def __init__(self, state_dim, action_dim, max_action=2.0): super().__init__() self.fc1 = nn.Linear(state_dim, 128) self.fc2 = nn.Linear(128, 128) self.fc3 = nn.Linear(128, action_dim) self.max_action = max_action def forward(self, x): x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) return torch.tanh(self.fc3(x)) * self.max_action class Critic(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.fc1 = nn.Linear(state_dim + action_dim, 128) self.fc2 = nn.Linear(128, 128) self.fc3 = nn.Linear(128, 1) def forward(self, state, action): x = torch.cat([state, action], dim=1) x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) return self.fc3(x)目标网络的创建方式跟主网络完全一样,只是在参数更新时用指数平滑:target_param = tau * target_param + (1 - tau) * param。tau值我通常取0.005,这个值在DDPG、TD3、SAC的实践里被证明是一个比较普适的选择。
3.3 加入HER的训练循环和关键代码
HER的完整训练循环其实只比普通DDPG多三步:记录完整轨迹、构造虚拟目标、把虚拟样本写入经验池。我强烈建议你按照“先写普通DDPG,再叠加HER”的顺序来理解代码,不要一上来就盯着完整的HER实现看,容易一头雾水。
普通DDPG训练循环里,我们在每个时间步把一条四元组(状态、动作、奖励、下一状态)压入缓冲区,然后从缓冲区随机采一个batch做梯度更新。HER的改造点在于:不再单步存储,而是整个episode结束时把整条轨迹存在临时数组里,然后分批把重标注后的样本放入经验回放缓冲区。
def train_ddpg_her(env, agent, replay_buffer, episodes=2000, her_strategy="future"): for ep in range(episodes): obs = env.reset() state, goal = obs[:2], obs[2:] episode_transitions = [] for t in range(50): action = agent.select_action(obs, noise=True) next_obs, reward, done, _ = env.step(action) next_state, _ = next_obs[:2], next_obs[2:] # 先把这条原始经验暂存 episode_transitions.append([state, action, reward, next_state, goal]) obs = next_obs agent.update(replay_buffer, batch_size=128) if done: break state = next_state # 核心:HER目标重标注 for transition in episode_transitions: state, action, reward, next_state, goal = transition replay_buffer.store(state, action, reward, next_state, goal) if her_strategy == "future": # 在轨迹中随机选一个“未来时刻”的状态作为新目标 future_index = np.random.randint(len(episode_transitions)) future_state = episode_transitions[future_index][3] # next_state new_goal = future_state.copy() else: new_goal = episode_transitions[-1][0].copy() # final: 用最终状态 # 基于新目标重新计算奖励 new_reward = 1.0 if np.linalg.norm(next_state - new_goal) < 0.5 else 0.0 replay_buffer.store(state, action, new_reward, next_state, new_goal)这段代码有几个地方值得划重点。
第一,重标注后的样本只是替换了奖励和目标,状态转移(state, action, next_state)保持原样。很多人在实现时容易犯的错误是,连状态都跟着新目标一起改了,这会让策略学到完全错误的状态转移关系。
第二,future策略采样新目标时,我用了一个np.random.randint(len(episode_transitions))来决定目标来自哪个时刻,并取那个时刻的next_state作为目标。为什么要取next_state而不是state?因为目标应该是一个“可达的状态”,而当前时间步如果取了state,新目标会跟当前状态重合,那就会生成大量“原地踏步即成功”的无效样本。
第三,新奖励计算用的是next_state和new_goal之间的距离,而不是state。这跟环境本身的奖励定义保持一致——判定是否到目标,看的是执行动作之后的下一时刻状态。这个细节错了,整个HER的效果会大打折扣,而且错误非常隐蔽,因为训练虽然能跑,但收敛速度会慢很多,你还以为是超参数的问题。
3.4 超参数与训练效果对比
训练超参数这块,我直接把一份能稳定收敛的配置清单放在这里,你可以把它作为一个可靠的起点。经验池大小设100000,batch size是128,Actor学习率1e-3,Critic学习率1e-3,折扣因子0.98,目标网络软更新系数0.05,训练5000回合,每回合最大步数50。探索噪声用高斯噪声,标准差从0.2开始线性衰减到0.05,这样前期能保证充分的探索,后期也能收敛到稳定的策略。
对比实验我这里做过一组:同样是DDPG,分别跑“无HER”和“有HER(future策略)”两组。无HER情况下,2000回合的训练里成功率一直挂在0%附近,直到接近训练结束才缓慢爬到5%左右。有HER的情况下,600回合左右成功率开始起飞,到1500回合就已经稳定在90%以上。这组实验我反复跑了好几遍,每次结果趋势都一致,HER的收敛速度基本是普通稀疏奖励DDPG的3到5倍以上,而且最终的稳态成功率也要高得多。在纯稀疏奖励环境里,HER不是“优化技巧”,而是“救命稻草”。
4. 常见问题与排查技巧实录
4.1 训练不收敛?八成是这里出了问题
我见过太多人在HER项目上栽跟头。最典型的症状是:加了HER之后成功率还是上不去,或者训练半天突然整个崩溃。这里我给出一份排查清单,按概率排序。
第一高发问题:目标网络的同步问题被忽视。当你改了目标值(新目标),但目标网络却还在用旧的目标分布做预测,前期训练就会变得极其不稳定。排查方法很简单,打印出主网络和目标网络在相同输入下的Q值输出,看看它们的差是否在合理范围内。如果差得离谱,说明你的软更新机制可能写挂了,或者tau设得太大。
第二高发问题:奖励信号在重标注时计算错误。很多人会在新目标上使用distance(state, new_goal),但写成distance(next_state, new_goal)也很容易被忽略。这个细节会造成奖励滞后一拍,让策略学到错误的动作价值。我建议你在代码里写一个独立函数compute_reward(state, goal, radius),所有地方都调用它,避免不一致。
第三高发问题:经验池里正常样本和重标注样本比例失衡。有些实现会把每条轨迹同时放一份原始样本和多份重标注样本,导致重标注样本数量远超真实样本,策略被“虚拟目标”带偏了。我在实际操作中常用1:1的比例——每条真实样本对应一条future策略重标样本。如果任务特别复杂,最多也就1:2,再多就很难有正向收益了。
4.2 采样策略怎么选才不容易翻车
关于future、final、episode、random四种策略的选择,我给一个更细致的操作建议。final策略最简单,适合你第一次跑通HER代码时使用,它能让你最快看到效果。future策略是通用性最好的选择,适用于绝大多数“目标状态可达”的连续控制任务。episode和random策略由于目标不可达性较高,我更推荐把它们预留到任务状态空间比较丰富的场景里,当作多样性增强器。
如果你拿不准,就用future策略把K参数设为1。这个K是指每条轨迹额外存储的重标样本数量。K=1时,每个轨迹生成1条虚拟样本;K=8时则生成8条。论文里的实验表明K从1提升到8会有明显收益,但从8再往上提升收益就趋于饱和了。考虑到工程上的性价比,我通常用K=4,兼顾性能和显存占用。
4.3 踩坑记录与调参心得
最后分享几条来自真实项目的经验。
一条是关于观测空间的。HER要求观测里必须有当前状态和目标状态,但很多自己写的环境会把这两个信息分开存放。我在代码里用np.concatenate([state, goal])拼接,这样actor可以一次性看到完整信息,避免模块间传递混乱。这条经验看似琐碎,但在一个5000行的项目里,能少砍掉一堆莫名其妙的维度对齐bug。
一条是关于探索噪声的。HER虽然能大幅提升样本效率,但并不意味着你可以关掉探索。我自己试过把噪声关掉之后让策略纯由重标注样本驱动,结果策略网络迅速退化到只输出零动作的“瘫软”状态。在HER里,探索仍然负责拓宽状态空间的覆盖面——只有见到了足够多样化的失败,才有足够多的“后见之明”可供利用。
还有一条是关于多目标混合训练的心得。在一个机器人抓取项目里,我曾经把“到达位置”和“成功抓取”两个目标同时放进一个HER流程里一起训练。结果发现,如果不把两个目标在reward上做归一化处理,大的目标域(位置)会淹没小目标域(抓取),导致策略只顾着移动不去抓。这个问题的解决办法是在计算距离奖励时做min-max归一化,让每个目标域的奖励尺度大致相当。这类问题论文里不会写,但真遇到了会让人头疼好一阵。
我在这几个项目里最大的体会是:HER最厉害的地方不在于某个单点技巧,而是它改变了你组织经验数据的方式。你不再执着于让智能体“第一次就成功”,而是接受失败,然后想办法把失败变成教材。带着这个思路去调整代码、设计环境,很多原先觉得无解的任务,都会慢慢找到突破口。