尧图精选

强化学习稀疏奖励如何破解?Hindsight目标重标记原理与实战

🕒 发布时间:2026/10/1 4:12:20 📁 来源:尧图网络
在强化学习里摸爬滚打过的朋友应该都对“稀疏奖励”这四个字又爱又恨。爱是因为它贴近真实场景机器人不可能每一步都拿到奖励信号恨是因为它让训练过程变得极其煎熬智能体在空旷的奖励场里瞎转几十万步损失函数纹丝不动那种感觉就像在黑夜里没有手电筒找钥匙。我最早注意到hindsight这个思路是在读Her、也就是Hindsight Experience Replay那篇工作的时候。它把“后见之明”这个概念引入经验回放让智能体从自己的失败轨迹里也能学到东西——目标没达成没关系把你实际到达的位置重新当目标一样能产生学习信号。这篇博文围绕hindsight这一核心思想展开讲清楚它到底解决了什么问题、核心机制如何运作、怎么一步一步写进自己的训练代码里以及我在实际复现和调参过程中踩过的那些坑。不管你是刚入门强化学习的研究生还是在工程里被稀疏奖励折磨的工程师这篇内容都能给你一套可以直接落地的思路和代码骨架。我会尽量用说人话的方式拆解而不是堆公式。1. 为什么我们需要hindsight稀疏奖励下的学习困境1.1 奖励稀疏到底有多让人抓狂先聊一个最基本的场景你训练一个机械臂任务是“把方块推到桌面上红色圆圈的中心”。如果方块没有到达目标位置每一步奖励都是0只有最终精确到达才能拿到1。看起来很简单但智能体在初始阶段完全是随机探索机械臂大概率只会推着方块在桌面上转圈永远碰不到那个红色圆圈。于是训练日志里就出现了经典一幕reward曲线贴着0走完几千个episode好像模型根本没有在学习。这不是代码写错了而是稀疏奖励下典型的“credit assignment”难题——智能体不知道哪一步动作对最终结果有帮助因为整个轨迹里没有任何中间奖励来引导它。密集奖励需要人工设计reward function而reward shaping本身又是一门玄学设计得不好反而会诱导智能体钻漏洞。hindsight的核心洞察恰恰在这里我们手上其实不缺经验每一条失败轨迹都是宝贵数据。一个轨迹里“没有达到目标”是事实但“达到了另一个位置”也是事实。如果把这个实际到达的位置视为一个替代目标轨迹就从“失败样本”变成了“成功样本”。智能体可以学到为了走到状态s我当前这个动作序列是有效的。这就是后见之明的威力——站在事后视角把失败重新编码为成功。1.2 从HER到Hindsight一个朴素观察的逆袭很多人第一次接触hindsight是通过OpenAI的HER工作Hindsight Experience Replay这个名字直接把核心思想写在脸上了。它的操作非常朴素在采样经验用于训练时除了保留原始目标 g还额外把这个episode结束时的真实状态 s_T 当作替代目标重新计算 reward再和新目标一起塞进回放缓冲区。模型看到的不再是“目标没达成奖励0”而是“目标改成s_T之后这条轨迹奖励是1”。这个改动小到不能再小效果却出奇得好。在类似Fetch Robotics的机械臂任务里HER能在稀疏奖励环境下达到和密集奖励相当的成绩而普通DQN/DDPG在这类任务上基本学不出来。不过需要澄清一下2021年DeepMind发的那篇论文名字直接就叫Hindsight做的是一套无监督多任务强化学习框架。它比HER走得更远没有预先给定目标分布而是让智能体自己提出目标、自己尝试达成、然后用后见之明的思路把这些尝试变成训练信号。也就是说hindsight这个词在文献里有两种用法。一个是指HER那种“经验回放时的目标重标记技巧”另一个是指DeepMind提出的“以hindsight为核心思想的整个学习范式”。我这篇博文会以HER的目标重标记为主线因为它是理解一切变体的地基同时也会在后面延伸讲讲多任务版本的思路。2. hindsight的核心机制拆解目标重标记为什么有效2.1 关键设计重新理解“目标”这件事要理解hindsight得先重新审视强化学习里“目标”的语法含义。在稀疏奖励环境里轨迹通常定义为一个四元组(s, a, r, s)但它其实忽略了goal。带goal的强化学习更完整的形式是(s, g, a, r, s, g)其中g是当前目标g是转移后的目标一般gg。奖励r根据当前状态是否满足目标g来给出通常是个二值信号。这种设定下当我们拿到一条失败轨迹时它看起来没有任何学习价值。但如果把g换成另一个值情况就完全不同了。假设方块最终停在位置p而p不等于目标圆圈中心p_target。原始语义下奖励是0轨迹被丢弃或用于负向更新。hindsight的做法是额外构造一条新经验把g设置为p附近或者包含p的某个区域然后重新计算奖励。由于新目标对应智能体实际到达的状态奖励自然就是1。于是同一条动作序列被“复制粘贴”成了一条成功经验。你可能会问这样“造假”出来的经验会不会误导模型这是最常被问到的问题之一。实际实验中不会原因在于我们并不是拿假经验去覆盖真实经验而是把它作为补充数据。模型确实学到了一批“到达p状态的动作是好的”这对探索是有利的因为p状态正是当前策略可以稳定到达的状态。随着策略提升p也会不断靠近真实目标p_target最终模型自然学会逼近目标。这其实是一种隐式的课程学习——智能体不断给自己制造“难度递进”的训练样本。2.2 网络结构与损失函数的那些细节目标重标记不是改一行代码那么简单它牵扯到网络输入、损失函数、目标分布这几个方面。一般来说使用hindsight的actor-critic架构会把状态和目标拼接成一个联合输入。比如状态s是一个向量目标g也是一个向量策略网络的输入就是concat(s, g)。评论家网络也是一样输入concat(s, g, a)输出Q值。注意这里目标必须被编码成与状态具有可比性的向量表示比如Fetch机器人任务里目标就是物体的三维坐标和状态中的物体坐标维度相同。损失函数的改动不大还是标准的Bellman更新只是目标g参与计算value lossL E[(r γ * Q(s, g, a) - Q(s, g, a))^2]policy lossDDPG风格L -E[Q(s, g, μ(s, g))]关键的改动在于从回放缓冲区采样时我们会随机抽取一部分经验执行hindsight重标记。具体来说假设采样到一个batch的transitions我们选其中一定比例比如50%的transition把它们的g替换为episode结束时的实际状态s_T然后重新计算reward其余transition保持原样。用重标记后的transition和原始transition混合在一起更新网络模型就既能从真实目标经验里学习又能从替代目标经验里学习。2.3 HER和Hindsight的多任务延伸思路再往深一层DeepMind的Hindsight框架把目标重标记从一个“技巧”升级成了一种“范式”。它不再假设外部提供目标分布而是把智能体自己探索出的状态当作目标。训练过程里有一个goal-conditioned策略π(a|s, g)外加一个目标生成器通常可以用当前状态或者近期状态来采样新目标。训练时智能体先采样一个探索目标尝试在有限步内到达。到达了这条轨迹就是成功示范没到达就用终点状态做hindsight重标记变成一条“到达终点状态”的成功示范。这套流程不需要任何人工设计的reward完全靠智能体自己生成目标和学习信号算是把稀疏奖励问题消解在了学习机制内部。我在实验中的体会是hers和DeepMind那套框架各有适用场景。如果你已经有一个明确的目标分布HER直接用最简单粗暴的方式解决问题如果你的任务没有人定义目标比如“探索整个房间”或者“学会多种技能”那多任务hindsight框架更合适。下面几个小节我把代码层面如何实现讲透方便你直接迁移到自己的项目。3. 手写一个hindsight训练流程从回放缓冲区到训练主循环3.1 环境设定用二维点导航任务作为实验场理论聊再多不如跑一次代码。我选了一个非常轻量的实验环境二维连续空间中的点导航任务。智能体是一个质点状态是它的二维坐标s(x, y)动作是二维速度指令a(vx, vy)目标g是某个固定的坐标点比如(5, 5)。每步奖励是如果质点离目标的欧氏距离小于0.5则奖励1否则奖励0。如果距离小于0.1则认为episode完成。每个episode最大步数50步超出则截断。这个环境的好处是状态、动作、目标都是低维连续向量可以用很小的网络快速验证hindsight的有效性。而且你可以直观地看学习曲线使用hindsight之后智能体在几百个episode内应该能稳定接近目标而不用hindsight的话它大概率一直原地转圈。代码上我建议用一个字典来存储transition包含state、action、reward、next_state、goal、done这几个字段。整个回放缓冲区只需要多一个goal字段其余和普通经验回放没有本质区别。3.2 核心代码ReplayBuffer与hindsight重标记我直接用PyTorch写的关键部分回放缓冲区里专门加一个hindsight标记函数。注意这里为了演示我把逻辑写得比较展开实际工程里可以进一步向量化。import numpy as np import torch import random from collections import deque class ReplayBuffer: def __init__(self, capacity, hindsight_ratio0.5): self.buffer deque(maxlencapacity) self.hindsight_ratio hindsight_ratio def push(self, state, action, reward, next_state, goal, done): self.buffer.append({ state: state, action: action, reward: reward, next_state: next_state, goal: goal, done: done }) def sample(self, batch_size, episode_goalsNone): batch random.sample(self.buffer, batch_size) states np.array([t[state] for t in batch]) actions np.array([t[action] for t in batch]) rewards np.array([t[reward] for t in batch], dtypenp.float32) next_states np.array([t[next_state] for t in batch]) goals np.array([t[goal] for t in batch]) dones np.array([t[done] for t in batch], dtypenp.float32) # 随机选择一部分经验做hindsight重标记 if episode_goals is not None: idx np.random.choice(batch_size, int(batch_size * self.hindsight_ratio), replaceFalse) for i in idx: # 用episode实际到达的终点状态作为替代目标 new_goal episode_goals[i] goals[i] new_goal # 重标记后的奖励若下一状态离新目标足够近则视为成功 dist np.linalg.norm(next_states[i] - new_goal) rewards[i] 1.0 if dist 0.5 else 0.0 dones[i] 1.0 if dist 0.1 else 0.0 return (torch.tensor(states, dtypetorch.float32), torch.tensor(actions, dtypetorch.float32), torch.tensor(rewards, dtypetorch.float32), torch.tensor(next_states, dtypetorch.float32), torch.tensor(goals, dtypetorch.float32), torch.tensor(dones, dtypetorch.float32))这里有一个值得注意的地方episode_goals是什么因为一个episode里可能有多个transition共享同一个真实的episode终点状态s_T所以我在存储transition时不会单独记录episode_goals而是额外保存每个episode结束时的状态。采样时先从buffer里拿到一条transition再到对应episode的结尾状态数组里去查它的s_T。上面的简化代码为了让逻辑清晰我用episode_goals[i]直接表示第i条transition所属episode的终点状态。实际项目里你需要在push时附带一个episode_goal字段或者用一个dict维护episode_id到最终状态的映射。3.3 训练主循环Actor-Critic更新与hindsight融合接下来是训练主循环。为了快速验证效果我用的是一个简单的actor-critic结构类似DDPG但去掉了目标网络平滑的一些细节用了一个比较通俗的实现。你也可以用SAC思路完全一样只是损失函数不同。import torch.nn as nn import torch.optim as optim class Actor(nn.Module): def __init__(self, state_dim, goal_dim, action_dim, hidden256): super().__init__() self.net nn.Sequential( nn.Linear(state_dim goal_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim), nn.Tanh() ) def forward(self, s, g): x torch.cat([s, g], dim-1) return self.net(x) class Critic(nn.Module): def __init__(self, state_dim, goal_dim, action_dim, hidden256): super().__init__() self.net nn.Sequential( nn.Linear(state_dim goal_dim action_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 1) ) def forward(self, s, g, a): x torch.cat([s, g, a], dim-1) return self.net(x) def train_step(actor, critic, target_actor, target_critic, buf, optimizer_a, optimizer_c, batch_size256, gamma0.95): states, actions, rewards, next_states, goals, dones buf.sample(batch_size) # critic loss with torch.no_grad(): next_actions target_actor(next_states, goals) target_q rewards.unsqueeze(1) gamma * (1 - dones.unsqueeze(1)) * target_critic(next_states, goals, next_actions) current_q critic(states, goals, actions) critic_loss nn.MSELoss()(current_q, target_q) optimizer_c.zero_grad() critic_loss.backward() optimizer_c.step() # actor loss actor_loss -critic(states, goals, actor(states, goals)).mean() optimizer_a.zero_grad() actor_loss.backward() optimizer_a.step() # 软更新目标网络 for target_param, param in zip(target_actor.parameters(), actor.parameters()): target_param.data.copy_(0.005 * param.data (1 - 0.005) * target_param.data) for target_param, param in zip(target_critic.parameters(), critic.parameters()): target_param.data.copy_(0.005 * param.data (1 - 0.005) * target_param.data) return critic_loss.item(), actor_loss.item()这里有几个工程细节我特别想强调。第一在训练早期critic的Q值估计往往偏大因为重标记后的成功样本太多模型会误以为随便走两步就能成功。所以hindsight比率代码里的hindsight_ratio不能太高我一般取0.4到0.6之间太高会让策略变得过于乐观太低又会退化成普通的经验回放学不动。第二当使用hindsight时actor网络的输入目标有两种原始目标和替代目标。也就是说同一个batch里一部分transition的goal是真实目标另一部分是替代目标。actor需要学会的是“对任意给定的目标输出合适的动作”。如果环境里目标分布很广建议对g做归一化否则actor会对某些数值范围特别敏感导致泛化效果差。第三关于探索噪声的设置。hindsight实际上会大幅提升学习的样本效率但前提是智能体要有足够的探索拿到多样化的失败轨迹。如果噪声太小每次轨迹终点都聚在一个小范围hindsight重标记出来的目标多样性就低模型学到的都是“从一个起点到一个终点”的固定模式。我的经验是在训练初期使用较大的探索噪声比如高斯噪声标准差0.3左右后期再慢慢降低。3.4 训练结果怎么观察如何判断hindsight生效了训练跑起来之后不能只盯着reward看。在稀疏奖励环境下reward曲线可能长得很丑几百年不涨一下但内部其实已经在积累了。我常用的观察方式有三个第一个是成功率。每过若干episode我会关闭探索噪声让actor纯粹按照策略输出动作跑几十次测试统计成功次数。成功率从0%爬到100%的曲线比reward曲线干净得多它直观地告诉你“策略是不是真的学会了”。第二个是目标重标记的分布。我会定期打印出来替代目标的二维坐标分布看看它们是不是在逐渐向真实目标靠近。如果替代目标长时间聚集在初始位置附近说明探索不足如果替代目标已经覆盖了通往真实目标的路径那说明hindsight正在起作用。第三个是actor在验证阶段的轨迹形状。在二维导航任务里我可以直接绘制轨迹看看它是不是走了一条“先偏离再折返”的路径。早期hindsight训练出的策略往往会走一些奇怪的绕路因为它学的样本里包含大量失败轨迹重标记后的成功样本轨迹并不是最优的。随着训练推进轨迹会越来越直接。如果看到这种“先乱后直”的演化过程恭喜你hindsight在正常工作。4. 实操中的常见问题与调参避坑记录4.1 重标记的目标离当前状态太近导致价值估计失真这是我在复现时遇到的第一个大问题。刚开始我偷懒所有经验全部做hindsight重标记结果训练没多久critic的loss就开始异常震荡策略也越走越怪。后来分析发现重标记目标直接取episode终点的状态s_T但很多s_T和transition里的next_state几乎一样。也就是说模型看到一条“下一步就达到目标”的成功样本这对训练倒是没问题但长期下来所有训练样本的难度都太低智能体永远在学“从哪到哪”没有空间理解“下一步该怎么办才能更靠近目标”。解决方法是控制重标记比例并且不要让替代目标总是等于s_T。一个常见的改动是从当前episode后半段的多个状态里随机选一个作为替代目标而不仅仅用最终状态。这样重标记目标会覆盖更广的空间范围相当于给智能体提供了不同难度等级的目标学习效率会更高。另一个做法是结合her里的future策略在episode结束后的未来时间步里随机抽取一个状态作为目标这样重标记目标的分布更接近“智能体未来可达状态”的分布而不是终点附近的小邻域。4.2 hindsight与探索策略的配合噪声大小和衰减节奏很多人以为hindsight能弥补探索不足于是在一个小噪声下训练期望模型靠重标记也能学到东西。实际效果却很拉胯。原因很简单hindsight只是把已有轨迹变成训练样本如果轨迹本身都局限在一个小区域里重标记目标也就聚集在这个小区域里模型根本看不到真实目标的影子。我的习惯是在训练前期用一个相对较大的探索噪声甚至配合epsilon-greedy或者参数空间噪声让智能体在环境里横冲直撞积累大量覆盖不同区域的轨迹。当成功率开始向上抬头时再逐步降低噪声让策略收敛到比较稳定的行为。一个直观的经验判断方法如果你发现重标记目标的分布和初始状态分布高度重叠说明探索不够加大噪声如果重标记目标已经覆盖了目标附近区域说明可以开始收敛了。4.3 目标空间与状态空间的相对尺度敏感这个坑特别隐蔽。假设状态是二维坐标(x, y)范围在[0, 10]目标也是同一坐标系下的点。看起来完全没问题但我换个任务状态可能是关节角度目标可能是末端位置量纲完全不一样。如果把状态s和goal gi直接拼接输入网络网络必须自己学习这两个来源的尺度关系训练负担会明显加重。我的建议是在输入网络之前对s和g分别做归一化。最简单的做法是统计所有状态的最小最大值把s映射到[-1, 1]再统计所有目标的最小最大值也映射到[-1, 1]。经验上这比使用BatchNorm效果更稳因为BatchNorm在训练初期会引入额外的扰动。另外如果goal不是简单的状态子集而是更抽象的语义向量比如“把红色方块推到蓝色方块左边”就不要直接拿状态差值当作距离也不要只用欧氏距离判断“是否到达”。在实践中我见过不少项目用“到达判定函数”来判断成功这个函数要设计得足够平滑否则重标记后的reward_1会频繁开关导致critic的目标值剧烈波动训练很难稳定。4.4 回放缓冲区里的hindsight比例动态调节还有一个实操心得。静态的hindsight_ratio虽然简单但并不是最优的。训练早期原始目标经验极少有成功样本如果hindsight比例过低模型几乎只能从失败里学学得慢训练后期成功率上来了原始目标经验里的成功样本变多如果hindsight比例还很高模型会过度关注“容易达成的替代目标”在真实目标上的表现反而会退化。所以我现在一般会做一个动态调节根据最近1000个episode的成功率动态调整hindsight比例。成功率低的时候把hindsight_ratio调到0.7左右拉高学习信号成功率高的时候降到0.2到0.3让模型更多地从真实目标经验里学习。这个很简单的小技巧在我自己的实验里对收敛稳定性有明显改善。成功率区间建议hindsight比例说明0% - 10%0.6 - 0.7主要依赖重标记样本驱动学习10% - 40%0.4 - 0.5平衡真实目标与替代目标40%以上0.2 - 0.3减少替代目标干扰增强真实目标精度4.5 训练崩溃Q值过估计的监测与应对在actor-critic架构里使用hindsight之后Q值过估计的现象会更明显因为重标记样本制造了大量虚拟的“成功经验”模型很容易误以为很多状态下都能成功。我在训练时会在日志里打印平均Q值如果Q值一路走高而测试成功率却停滞不前那基本可以断定是过估计了。应对方法有几招。第一用Clip Double-Q Learning训练两个critic更新时取最小值这招对抑制过估计非常有效。第二降低hindsight比例。第三给critic加一个L2正则项或者对Q值做一个软约束。实话说前两招已经能解决绝大多数情况第三招我很少用因为超参数比较敏感调起来费时。如果用了双Q之后还是不稳定可以检查一下reward的scale。hindsight重标记的reward通常只有0和1如果真实目标经验里偶尔出现一个大奖赏比如任务里包含多个子目标reward尺度跨度过大会让critic很难收敛。这种情况建议对reward做clip或者归一化。5. 从单任务到多任务hindsight思想还能往哪走5.1 自动课程学习让智能体自己给自己出题说到扩展我一直觉得hindsight最迷人的地方不在HER本身而在它引出的“自动目标生成”思路。传统的课程学习需要人工设计一系列难度递增的目标这个设计过程非常痛苦而且容易引入偏置。hindsight则提供了一条自然的路径智能体自己探索出来的状态就是它当前能力范围内“最合适”的目标序列。实际操作上可以维护一个目标池池子里存放近期探索轨迹的终点状态。训练时从中采样一些目标作为actor的输入而不是只用真实目标。这样智能体先练习“移动到之前它实际去过的地方”再慢慢过渡到“移动到真实目标”。这个过程完全由数据驱动不需要任何课程设计。我在一个简单的连续控制任务上试过这种自动课程能让训练过程平滑很多不会出现那种“前期完全黑盒、后期突然开窍”的突变而是稳步提升。5.2 目标生成器与策略的联合训练往深走一步可以用一个条件VAE或者简单的MLP来生成目标而不是直接采样状态。目标生成器以当前状态作为输入输出下一个训练目标然后训练策略学着去达到这个目标。生成器和策略联合训练这个框架就是DeepMind那篇Hindsight的核心思路之一。我在复现时发现目标生成器需要特别小心它很容易崩塌——生成的目标永远与当前状态太接近策略不需要学习新技能就能轻松达成。为了防止这种崩塌我会给目标生成器的输出加一个最小距离约束保证生成的目标与当前状态之间至少间隔一定距离。同时对有“已经学会”的目标降低其采样概率强制生成器去探索新目标。这种“遗忘机制”在训练中很关键否则智能体只会重复已经掌握的技能不会持续进步。5.3 适用边界什么任务不适合hindsight也不能把hindsight吹上天它的适用性是有边界的。如果任务的状态空间巨大、目标之间差异悬殊比如图像像素级目标或者高维动作空间hindsight重标记后产生的替代目标分布通常很稀疏智能体面对的目标空间范围过大训练难度反而增大。这种情况下通常需要把状态和目标嵌入到一个语义空间里再做hindsight重标记。另外如果环境本身是不断变化的比如目标会移动、障碍物会突然出现hindsight重标记的目标可能与当前环境语义不匹配模型学到的经验会迅速过时。我也不建议在非平稳环境里直接用hindsight而是应该先通过某种方式稳定环境语义再做目标重标记。还有一种情况任务目标不是“到达某个状态”而是“保持某个状态”比如倒立摆智能体需要一直维持平衡。这种情况下hindsight重标记的目标定义起来就非常模糊替代目标往往没有意义。这类任务更适合用其他方法比如能量塑形或者actor-critic加状态空间的稀疏约束。6. 我个人实操中的一点体会代码跑通很简单真正把它用到复杂任务上才会体会到里面有很多微妙的内容。我最初是在一个模拟机械臂任务里尝试hindsight当时天真地以为只要把HER代码拷过来就能跑结果被一堆细节折磨了两个星期。后来静下心重新走了一遍从二维导航到机械臂的实验才慢慢摸清了它的脾气。现在我养成了一个近乎固执的习惯凡是遇到稀疏奖励问题先别急着设计复杂奖励函数而是先把hindsight写上用最朴素的目标重标记跑一遍。很多时候就这么一个简单改动就能把学习曲线从躺平拽起来。如果效果不理想再去调整探索策略、重标记比例和目标生成方式往往比一开始就上花哨方法要高效得多。对刚接触这个领域的朋友我给的最实在的建议是先别急着读满篇公式的论文自己实现一个二维导航环境把HER的代码一行一行写出来然后亲手把hindsight_ratio从0调到1看看学习曲线怎么变化。这个过程会比你读十篇论文都更能建立直觉。hindsight本质上是在提醒我们失败的经验里藏着大量可以被重新解读的珍珠关键是我们有没有换个视角看它们。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →