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

资讯详情

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

稀疏奖励下的HER算法深度解析:原理、实现与DDPG实战

稀疏奖励下的HER算法深度解析:原理、实现与DDPG实战

1. 项目概述与核心思路

1.1 为什么起名“hindsight”——后见之明背后的强化学刁问题

先聊一个让我印象很深的场景。大概一年多前,我做机械臂抓取任务的强化学习训练,环境是稀疏奖励的典型:机械臂只有在指尖距离目标点小于某个阈值的那一刻,才能拿到1分,其余几千步都是0分。那时候我用的还是普通的DQN变体,结果不用想也知道——reward一直趴在0上,loss曲线倒是跌得很欢,但智能体本质上一无所获。模型的探索完全变成了瞎蒙,几千个episode跑下来,成功率不到2%。

那时候我真正体会到什么叫“稀疏奖励下的绝望”。所谓稀疏奖励,不是困难,而是信息真空。智能体做了一整幕的操作,只得到一个0,它完全不知道自己哪一步做得对、哪一步做得错。这种反馈信号稀疏到几乎等于没有,策略梯度计算出来的方向基本是噪声。

后来我接触到了Hindsight Experience Replay(HER,事后经验回放),才明白项目名“hindsight”的精髓所在。这个算法的核心思想非常反直觉:如果目标没达成,那就假装达成了,然后重新构造一段“如果这是目标,刚才的动作就是正确路径”的经验,把它扔进经验池供后续学习。

说白了,就是让智能体学会从失败中提炼有效信息——用后见之明给失败的经历贴上成功者的标签。这不是一个普通的trick,而是在稀疏奖励环境下显著提升样本效率最实用的手段之一。OpenAI在2017年发布HER论文时展示的成果是:在多个稀疏奖励连续控制任务上,HER能让普通DDQN/DDPG达到和密集奖励版本相当的训练效果,有些任务甚至更好。

1.2 这个项目的核心价值与技术栈选型

我选择的复现路线是基于PyTorch + Gymnasium,算法主干是DDPG(Deep Deterministic Policy Gradient),在此基础上引入HER。选DDPG+Hero这个组合,原因有三个。

第一个原因,HER的论文原始实验就是搭配DDPG做off-policy训练,经验池回放机制天然契合。DDPG作为确定性策略梯度算法,它的Critic网络评估的是状态-动作价值,而HER重标注出来的假经历、假目标,正好可以提供给Critic去拟合“目标导向状态下的价值映射”。

第二个原因是连续控制任务用DDPG比PPO更容易搭配HER调试。PPO是on-policy算法,HER重标注需要频繁采样历史经验,和PPO的实时更新节奏冲突明显。而DDPG off-policy特性允许它从旧数据反复学习,重标注经验的价值可以得到充分利用。

第三个原因是工程实现相对简单。DDPG总共就Actor和Critic两个网络,不需要像SAC那样维护温度参数和多个Q网络,出问题的时候排查路径更短。对于只想验证HER思路的开发者,DDPG+HED是性价比最高的起点。

整篇博客我会从环境搭建讲到算法细节,再给出一份可以跑通的完整代码,最后整理我在实际训练中踩过的一组坑。如果你正在做机器人控制、游戏AI、推荐系统冷启动等稀疏反馈场景,这篇文章应该能帮你省掉大量试错时间。

2. 技术原理深度拆解:HER为什么能奏效

2.1 稀疏奖励场景下的样本效率瓶颈

先定义清楚问题。假设我们有一个目标条件化任务,状态空间是s,动作空间是a,目标空间是g。每一步,智能体观察到的state是拼接了当前目标g的,记为[S, g]。环境在每个episode结束时给出一个reward,只有全部目标达成才有1,否则是0。这就是我前面说的信息真空场景。

在这种设定下,普通强化学习的样本效率低到令人发指。智能体在一次性动作序列中,哪怕瞎蒙到了99%正确的位置,只要最后一步偏了,reward就是0。而这0分无法告诉它“你前面的99%其实是对的”。于是它只能继续用随机探索去撞大运,step数量爆炸式增长。

我想用一个生活化类比解释HER的思路。好比一个人学投篮,如果只有“进球得一分,不进零分”这一个反馈信号,他可能要很久才能发现自己罚球线站位的重要性。但如果每次投篮之后,教练给他说:“假如此时把篮筐移到你出手的落点,你这一球就进了”——这句话虽然改变不了真实比赛结果,但提供了关于“出手弧度”和“力量控制”的有价值反馈。HER做的正是这件事:人为构造“虚拟的篮筐落点”,把失败的轨迹转变成一条可学习的成功路径。

具体到数学表达,HER对每一条真实轨迹中的每一个transition,以一定概率选择一个额外目标g',这个g'可以是该轨迹中未来某个时刻实际到达的状态。然后用这个g'重新构造transition (s, g', a, r'(s, g'), s')。因为g'是实际到达过的状态,r'往往为1,这段经验就可以告诉智能体:“你刚才的动作,在那个虚拟目标下,是对的。”

2.2 HER的四种目标选择策略对比

HER论文中提出了四种选择替代目标的策略,我在代码里全部实现了,实测下来差异很大,单独列个表方便对照。

策略类型选择逻辑优点缺点
final用轨迹终点的状态作为虚拟目标实现最简单,目标变化大轨迹早期step的虚拟目标过于遥远
future用轨迹中当前时刻之后某个状态作为目标目标与当前状态更有连续性需要额外存储整条轨迹
episode用同一条episode中的随机状态做目标目标多样性更好可能选到过于困难的目标
random从经验池随机采样状态做目标实现最简单但效果不稳定虚拟目标与当前状态完全无关

我在多个实验中的体感排序是future > episode > final >> random。future策略的优势在于它天然保证了虚拟目标在时间上的可达性——因为它是当前时刻之后某个真实到过的状态,所以从这个状态往前看,动作序列本身就构成了一条可行路径。这比random策略凭空构造目标靠谱得多。

2.3 重标注概率对训练的影响

HER还引入一个超参数:重标注概率K。意思是,每次从经验池中采样一条transition时,以概率K为其构造虚拟目标;以1-K的概率保留原始目标。K=0时算法退化为普通DDPG,K=1时所有样本都被重标注。

我把K从0到1扫了一遍,观察到一个有趣的规律:K=0.8左右时性能最好,K=1时训练初期后期反而会震荡。原因也好理解:如果全部样本都被重标注,演员网络学到的行为就完全围绕“假目标”优化,真实目标反而被稀释了。记得把K当成一个调节真实目标和虚拟目标学习比例的旋钮,而不是一个“越大越好”的参数。

3. 核心实现细节与工程架构设计

3.1 环境搭建与目标条件化处理

我选择FetchReach作为基准环境,任务要求机械臂把末端执行器移动到随机生成的目标点。这个环境动作维度是4维(3D位置控制+1个夹爪开关),状态空间是25维,目标空间是3维。它最大的吸引力在于:观察空间里包含了大量冗余信息(物体位置、相对位置、夹爪状态),能考验模型对输入特征的选择能力。

环境由一个关键函数reset()控制。注意,Gymnasium版本里目标是以goal字段单独返回的,不是和observation拼接在一起的。很多初学者在这里踩坑,直接在observation上拼接goal会导致维度混乱。我采用的办法是:在环境外部封装一个GoalEnvWrapper,把observation和goal拼成一个向量作为神经网络输入。

import gymnasium as gym import numpy as np from collections import deque class GoalEnvWrapper(gym.ObservationWrapper): def __init__(self, env): super().__init__(env) obs_space = env.observation_space goal_space = env.observation_space.spaces['goal'] # 观察空间是dict结构,提取维度 if hasattr(goal_space, 'shape'): goal_dim = np.prod(goal_space.shape) else: goal_dim = goal_space.n obs_dim = np.prod(obs_space.spaces['observation'].shape) self.observation_space = gym.spaces.Box( low=-np.inf, high=np.inf, shape=(obs_dim + goal_dim,), dtype=np.float32 ) def observation(self, obs): # 把原始dict观察转换为拼接向量 return np.concatenate([obs['observation'].flatten(), obs['goal'].flatten()])

这段代码的关键在于observation()函数的输入输出类型必须严格遵循Gymnasium规范。有时候我发现Gymnasium的新版本中goal字段不一定是Box类型,有可能是Dict,所以加了if hasattr判断。这个细节能帮你避开至少两小时的排错时间。

3.2 轨迹缓存与未来目标采样器

HER的核心逻辑在轨迹缓存器里。我不直接往普通回放缓冲区塞transition,而是先用一个TrajectoryBuffer完整记录一整条轨迹,等episode结束后再统一处理。

具体实现上,我用一个deque保存当前episode中的所有transition,每步格式是(s, a, r, s_next, done, goal)。episode结束后,遍历该轨迹中的每一个transition,根据future策略从当前时刻之后的状态中随机采样一个作为虚拟目标。这个“之后”的时间跨度我用一个参数future_step控制,默认为10步。

class HERSampler: def __init__(self, replay_buffer, strategy='future', k=0.8, future_step=10): self.replay_buffer = replay_buffer self.strategy = strategy self.k = k self.future_step = future_step def add_episode(self, episode): """episode: list of (s, a, r, s_next, done, goal)""" for i, (s, a, r, s_next, done, goal) in enumerate(episode): # 原始目标保留 self.replay_buffer.add(s, a, r, s_next, done, goal) # 以概率k重标注 if np.random.random() < self.k: virtual_goal = self._sample_goal(episode, i) # 判断虚拟目标是否达成,这里用环境自带的距离阈值 achieved = self._is_achieved(s_next, virtual_goal) virtual_reward = 0.0 if achieved else -1.0 self.replay_buffer.add(s, a, virtual_reward, s_next, done, virtual_goal) def _sample_goal(self, episode, current_idx): if self.strategy == 'future': # 只从当前时刻之后的state中采样 max_idx = min(len(episode), current_idx + self.future_step) if max_idx <= current_idx: return episode[current_idx][3] # 取s_next idx = np.random.randint(current_idx, max_idx) return episode[idx][3] # s_next对应的状态 elif self.strategy == 'final': return episode[-1][3] elif self.strategy == 'episode': idx = np.random.randint(0, len(episode)) return episode[idx][3]

这个实现里有几个容易出错的地方。第一个是虚拟目标和原始目标的reward计算逻辑不一致。原始目标的reward直接从环境中拿,而虚拟目标的reward必须用同一个_is_achieved函数判断。如果两边判定标准不一致,比如原始目标用环境的距离阈值,虚拟目标自己写一套,模型就会学到混乱的边界。

第二个值得强调的是,虚拟目标对应的done标志应当设为False。原因简单:虚拟目标不是真实环境的终点,后续还有真实轨迹要继续执行。如果这里误设为True,价值网络会错以为终止状态带来了不存在的终止收益。

3.3 网络结构与DDPG算法骨架

网络结构我采用经典的MLP三层全连接,Actor和Critic各有一个目标网络。Actor输入是拼接后的observation+goal,输出是tanh激活后的连续动作,输出范围控制在[-1, 1]。FetchReach环境的动作空间本来就是[-1, 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, hidden_dim=256): super().__init__() self.fc1 = nn.Linear(state_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, hidden_dim) self.fc3 = nn.Linear(hidden_dim, hidden_dim) self.action_out = nn.Linear(hidden_dim, action_dim) def forward(self, state): x = F.relu(self.fc1(state)) x = F.relu(self.fc2(x)) x = F.relu(self.fc3(x)) return torch.tanh(self.action_out(x)) class Critic(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim=256): super().__init__() self.fc1 = nn.Linear(state_dim + action_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, hidden_dim) self.fc3 = nn.Linear(hidden_dim, hidden_dim) self.q_out = nn.Linear(hidden_dim, 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)) x = F.relu(self.fc3(x)) return self.q_out(x)

DDPG的关键更新公式只有一个,但值得反复琢磨:Critic的损失函数是TD误差的平方,即Q(s,a) - (r + γ * Q_target(s', a_target))的MSE。其中a_target是目标Actor网络在状态s'下的输出。训练Critic时,让这个TD误差最小化。

Actor的更新公式稍微绕一点:梯度方向是让Q值最大化。也就是说,给定一个状态s,我们希望动作a = μ(s)能让Critic的Q值输出最大。这个梯度是通过-Q(s, μ(s))对Actor参数求导得到的,用深度学习的自动求导机制实现。

def update_ddpg(actor, critic, target_actor, target_critic, replay_buffer, optimizer_actor, optimizer_critic, gamma=0.98, tau=0.05, batch_size=256): # 从经验池采样 states, actions, rewards, next_states, dones = replay_buffer.sample(batch_size) # 转为张量 states = torch.FloatTensor(states).to(device) actions = torch.FloatTensor(actions).to(device) rewards = torch.FloatTensor(rewards).unsqueeze(1).to(device) next_states = torch.FloatTensor(next_states).to(device) dones = torch.FloatTensor(dones).unsqueeze(1).to(device) # 1. 更新Critic with torch.no_grad(): next_actions = target_actor(next_states) target_q = target_critic(next_states, next_actions) y = rewards + (1 - dones) * gamma * target_q current_q = critic(states, actions) critic_loss = F.mse_loss(current_q, y) optimizer_critic.zero_grad() critic_loss.backward() optimizer_critic.step() # 2. 更新Actor actor_loss = -critic(states, actor(states)).mean() optimizer_actor.zero_grad() actor_loss.backward() optimizer_actor.step() # 3. 软更新目标网络 with torch.no_grad(): for param, target_param in zip(actor.parameters(), target_actor.parameters()): target_param.data.copy_(tau * param.data + (1 - tau) * target_param.data) for param, target_param in zip(critic.parameters(), target_critic.parameters()): target_param.data.copy_(tau * param.data + (1 - tau) * target_param.data) return critic_loss.item(), actor_loss.item()

这里有一个经常被忽略的陷阱:target_q的计算不需要梯度,所以务必包裹在with torch.no_grad()里。有些新手喜欢直接把target_q加入计算图,这会导致梯度流向目标网络,间接改变了目标网络的更新方式,效果差得离谱。

3.4 训练流程编排与超参选择

我把整个训练流程封装成如下格式,方便复现:

def train(env, her_sampler, actor, critic, ...): for episode in range(num_episodes): obs, info = env.reset() episode_transitions = [] episode_goal = obs['goal'].copy() for step in range(max_steps): state = wrapper.observation(obs) action = actor(torch.FloatTensor(state).unsqueeze(0)).cpu().detach().numpy().flatten() # 探索噪声 noise = np.random.normal(0, 0.2, size=action.shape) action = np.clip(action + noise, -1, 1) next_obs, reward, terminated, truncated, info = env.step(action) done = terminated or truncated episode_transitions.append(( wrapper.observation(obs), action.copy(), reward, wrapper.observation(next_obs), done, episode_goal )) obs = next_obs if done: break # 轨迹结束后调用HER重标注 her_sampler.add_episode(episode_transitions) # 训练若干轮 if len(her_sampler.replay_buffer) > 1000: for _ in range(10): update_ddpg(...)

超参数方面,我把自己的最佳实践整理成一个表格:

超参数推荐值解释
replay buffer size1e6经验池大一些,HER才能从中挖掘有效虚拟目标
batch size256目标条件化任务的批次大一点更稳定
gamma0.98折扣因子,稀疏奖励下偏小一点能缩短信用分配路径
tau0.05目标网络软更新系数,比默认0.005大,加速目标网络跟踪
actor learning rate1e-3过大会导致策略剧烈震荡
critic learning rate1e-3同上
HER probability K0.8详见前文对比实验
HER strategyfuture稳定性和探索效果最好

3.5 稀疏奖励下探索噪声的调节

探索策略和奖励函数一样重要,特别是在稀疏奖励环境下。DDPG的原始探索用的是奥恩斯坦-乌伦贝克噪声,但实际测试下来我用高斯噪声反而更稳。原因可能是FetchReach动作空间小,高斯噪声的随机扰动已经足够覆盖有效探索范围。

噪声的方差我采用衰减策略:初始0.3,每1000个episode指数衰减到0.05,之后保持恒定。这个衰减速率不能太快,否则智能体还没学会有效动作就早早陷入确定性策略,探索彻底停止;也不能太慢,否则后期微调阶段会被噪声干扰而无法收敛到精细动作。

我见过不少项目把探索噪声固定成0.1,然后在稀疏任务上怎么训都不收敛,最后归罪于HER算法没用。实际上,问题往往出在噪声方差太小,智能体根本碰不到那个罕见的成功状态,自然也就没有“后见之明”可用了。

4. 实操过程中踩过的坑与排错经验

4.1 回放缓冲区的“假经验”污染问题

这个坑在文献里很少被提及,但我实际训练中影响巨大。HER的虚拟目标确实能增加有效样本,但它本质上是在经验池中注入了大量“捏造”的数据。如果捏造的比重过高,Critic学到的价值函数会被这些虚拟经验主导,真实环境的价值分布反而被淹没。

具体表现是:训练过程中,Critic的loss一直在降,但agent的实际表现几乎停滞。原因是Critic拟合的对象是“真假参半”的数据分布,而真实任务只关心其中的真实部分。

解决手段是两层保险。第一,严格控制K=0.8,保证至少20%的真实经验始终在训练流中。第二,训练时采样可以做一个加权:真实经验被采样到的权重是虚拟经验的1.2倍。我用一个小字典记录每一条经验是否是虚拟的,采样时根据权重计算概率,效果比单纯调K更细腻。

class WeightedReplayBuffer: def __init__(self, capacity, real_weight=1.2): self.capacity = capacity self.real_weight = real_weight self.buffer = deque(maxlen=capacity) self.virtual_flag = deque(maxlen=capacity) # 记录每条经验来源 def add(self, transition, is_virtual): self.buffer.append(transition) self.virtual_flag.append(is_virtual) def sample(self, batch_size): weights = np.array([self.real_weight if not v else 1.0 for v in self.virtual_flag]) probabilities = weights / weights.sum() indices = np.random.choice(len(self.buffer), batch_size, p=probabilities) return [self.buffer[i] for i in indices]

4.2 目标空间归一化问题

Fetch系列环境的目标空间是3维坐标,范围在[0, 1]左右,但实际物体坐标分布不均匀。有些区域目标出现频率低,模型对那里的状态价值估计就会特别不准确。HER的虚拟目标是从真实访问过的状态中采样的,所以它天然存在倾向性:智能体越常访问的地方,虚拟目标越多;越少访问的地方,虚拟目标越稀缺。

问题在于,真实目标是由环境随机生成的,可能落在任何位置。如果智能体的虚拟目标长期集中在工作空间中央,它就无法学会处理边缘目标。

我的处理办法是在训练初期额外加一段随机动作采样,让智能体以纯随机策略运行几百个episode,把这些随机轨迹也输入HER重标注。这样经验池里的虚拟目标分布会覆盖更广的空间范围,为后续学习提供更好的状态覆盖度。代码就是在前面train函数里增加一个init_random_episodes=100的参数,循环执行时用np.random.uniform(-1, 1, size=action_dim)代替actor的确定性输出。

4.3 训练过程的可视化与诊断技巧

HER的收敛曲线和普通任务不太一样。普通任务一般看到reward曲线单调上升就说明在进步,但HER任务里因为大量虚拟目标的存在,平均reward会先快速上升到一个高位,然后略微下降稳定住。很多人看到这个下降就慌了,以为模型崩了,实际上这是模型从“依赖虚拟目标”转向“理解真实目标”的正常过渡。

正确的可视化策略是分两条曲线记录:一条是真实目标下的平均reward,另一条是虚拟目标下的平均reward。真实reward曲线持续上升说明算法真正在进步;虚拟reward曲线高说明HER在正常工作。如果虚拟reward也开始下降,那才是真的要排查问题了。

我还习惯在训练过程中定期评估策略:每50个episode让当前Actor跑20次真实测试,只计算真实目标成功率,不加入任何噪声。这个“干净评估法”最能反映模型真实水平,比训练过程中夹杂噪声的瞬时表现可靠得多。可以用tensorboard记录这两个指标,观察成功率的上升趋势来判断HER是否奏效。

4.4 一组常见问题速查表

结合我自己多次复现HER的经验,把新手最容易遇到的情况汇总一下:

现象可能原因排查手段
训练loss下降但成功率几乎为0虚拟经验比重过大,真实经验被淹没降低K到0.6,增加真实经验采样权重
训练初期reward就很高但很快垮掉虚拟目标选择策略不当,过度依赖虚拟目标求快从future切换为episode,或降低K
成功率曲线上下震荡严重探索噪声方差过大,优质策略被噪声破坏降低高斯噪声方差并加快衰减
目标靠近边缘时成功率骤降虚拟目标空间覆盖不足增加随机初始化episodes,扩充目标分布
训练到中途模型完全不动作动作输出被tanh饱和,噪声不足以跳出降低学习率,重置部分网络层

4.5 关于计算资源的实测心得

最后聊一个不算技术问题的技术问题:训练这套架构到底需要多少算力。FetchReach这个任务本身不大,我实测单张GTX 1660就能跑,一个episode大约200步,训练2000个episode就能看到成功率明显上升,整体耗时大约40分钟。

但如果你的目标是更复杂的任务,比如FetchPickAndPlace或者机械臂多阶段操作,情况就完全不一样。这类任务的状态维度更高,目标空间更大,单靠DDPG+HED直接跑,成功率上升会非常缓慢,需要配合课程学习(curriculum learning)或者分层强化学习才能取得理想效果。我的建议是,先在FetchReach这种玩具环境上把HER的参数和代码逻辑吃透,再迁移到复杂任务,否则一旦模型不收敛,你连问题出在HER还是环境上都分不清。

5. 写在最后的一点个人经验

因为我没有太多机会在真实机器人上做实验,所以我的经验基本集中在仿真环境。不过HER的核心优势在仿真中体现得非常明显:它不依赖任何额外的领域知识,只是从已有经验中换个角度“讲故事”。这种思想不仅适用于机器人控制,也能迁移到其他带目标条件化结构的问题上,比如推荐系统中的冷启动目标改写、自动驾驶中的轨迹规划失败重写等。

我在实际调试中最受启发的一个细节是:当模型跑通FetchReach之后,我试着把HER的虚拟目标从“未来的状态”改成“未来状态的某种降维特征”,发现效果依然不差。这说明HER的本质不是依赖于目标空间的精确几何,而是依赖于“构造一个智能体能够在策略上达到的虚拟目标”这一逻辑。理解到这一层,你就不会纠结于目标空间的维度大小,而是去想如何为你的任务构造合适的“虚拟成功标准”。

如果你正在被稀疏奖励问题折磨,不妨把训练日志甩开,先想想这个问题:如果把每次失败轨迹的终点当作成功目标重新学习,你的模型会不会已经掌握了足够多的知识?很多情况下,答案是肯定的。

返回列表