ARTICLE DETAIL

资讯详情

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

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

稀疏奖励下的HER算法深度解析:原理、实现与DDPG实战 1. 项目概述与核心思路1.1 为什么起名“hindsight”——后见之明背后的强化学刁问题先聊一个让我印象很深的场景。大概一年多前我做机械臂抓取任务的强化学习训练环境是稀疏奖励的典型机械臂只有在指尖距离目标点小于某个阈值的那一刻才能拿到1分其余几千步都是0分。那时候我用的还是普通的DQN变体结果不用想也知道——reward一直趴在0上loss曲线倒是跌得很欢但智能体本质上一无所获。模型的探索完全变成了瞎蒙几千个episode跑下来成功率不到2%。那时候我真正体会到什么叫“稀疏奖励下的绝望”。所谓稀疏奖励不是困难而是信息真空。智能体做了一整幕的操作只得到一个0它完全不知道自己哪一步做得对、哪一步做得错。这种反馈信号稀疏到几乎等于没有策略梯度计算出来的方向基本是噪声。后来我接触到了Hindsight Experience ReplayHER事后经验回放才明白项目名“hindsight”的精髓所在。这个算法的核心思想非常反直觉如果目标没达成那就假装达成了然后重新构造一段“如果这是目标刚才的动作就是正确路径”的经验把它扔进经验池供后续学习。说白了就是让智能体学会从失败中提炼有效信息——用后见之明给失败的经历贴上成功者的标签。这不是一个普通的trick而是在稀疏奖励环境下显著提升样本效率最实用的手段之一。OpenAI在2017年发布HER论文时展示的成果是在多个稀疏奖励连续控制任务上HER能让普通DDQN/DDPG达到和密集奖励版本相当的训练效果有些任务甚至更好。1.2 这个项目的核心价值与技术栈选型我选择的复现路线是基于PyTorch Gymnasium算法主干是DDPGDeep Deterministic Policy Gradient在此基础上引入HER。选DDPGHero这个组合原因有三个。第一个原因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思路的开发者DDPGHED是性价比最高的起点。整篇博客我会从环境搭建讲到算法细节再给出一份可以跑通的完整代码最后整理我在实际训练中踩过的一组坑。如果你正在做机器人控制、游戏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的概率保留原始目标。K0时算法退化为普通DDPGK1时所有样本都被重标注。我把K从0到1扫了一遍观察到一个有趣的规律K0.8左右时性能最好K1时训练初期后期反而会震荡。原因也好理解如果全部样本都被重标注演员网络学到的行为就完全围绕“假目标”优化真实目标反而被稀释了。记得把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, highnp.inf, shape(obs_dim goal_dim,), dtypenp.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, strategyfuture, k0.8, future_step10): 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输入是拼接后的observationgoal输出是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_dim256): 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_dim256): 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], dim1) 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, gamma0.98, tau0.05, batch_size256): # 从经验池采样 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, sizeaction.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拟合的对象是“真假参半”的数据分布而真实任务只关心其中的真实部分。解决手段是两层保险。第一严格控制K0.8保证至少20%的真实经验始终在训练流中。第二训练时采样可以做一个加权真实经验被采样到的权重是虚拟经验的1.2倍。我用一个小字典记录每一条经验是否是虚拟的采样时根据权重计算概率效果比单纯调K更细腻。class WeightedReplayBuffer: def __init__(self, capacity, real_weight1.2): self.capacity capacity self.real_weight real_weight self.buffer deque(maxlencapacity) self.virtual_flag deque(maxlencapacity) # 记录每条经验来源 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, pprobabilities) return [self.buffer[i] for i in indices]4.2 目标空间归一化问题Fetch系列环境的目标空间是3维坐标范围在[0, 1]左右但实际物体坐标分布不均匀。有些区域目标出现频率低模型对那里的状态价值估计就会特别不准确。HER的虚拟目标是从真实访问过的状态中采样的所以它天然存在倾向性智能体越常访问的地方虚拟目标越多越少访问的地方虚拟目标越稀缺。问题在于真实目标是由环境随机生成的可能落在任何位置。如果智能体的虚拟目标长期集中在工作空间中央它就无法学会处理边缘目标。我的处理办法是在训练初期额外加一段随机动作采样让智能体以纯随机策略运行几百个episode把这些随机轨迹也输入HER重标注。这样经验池里的虚拟目标分布会覆盖更广的空间范围为后续学习提供更好的状态覆盖度。代码就是在前面train函数里增加一个init_random_episodes100的参数循环执行时用np.random.uniform(-1, 1, sizeaction_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或者机械臂多阶段操作情况就完全不一样。这类任务的状态维度更高目标空间更大单靠DDPGHED直接跑成功率上升会非常缓慢需要配合课程学习curriculum learning或者分层强化学习才能取得理想效果。我的建议是先在FetchReach这种玩具环境上把HER的参数和代码逻辑吃透再迁移到复杂任务否则一旦模型不收敛你连问题出在HER还是环境上都分不清。5. 写在最后的一点个人经验因为我没有太多机会在真实机器人上做实验所以我的经验基本集中在仿真环境。不过HER的核心优势在仿真中体现得非常明显它不依赖任何额外的领域知识只是从已有经验中换个角度“讲故事”。这种思想不仅适用于机器人控制也能迁移到其他带目标条件化结构的问题上比如推荐系统中的冷启动目标改写、自动驾驶中的轨迹规划失败重写等。我在实际调试中最受启发的一个细节是当模型跑通FetchReach之后我试着把HER的虚拟目标从“未来的状态”改成“未来状态的某种降维特征”发现效果依然不差。这说明HER的本质不是依赖于目标空间的精确几何而是依赖于“构造一个智能体能够在策略上达到的虚拟目标”这一逻辑。理解到这一层你就不会纠结于目标空间的维度大小而是去想如何为你的任务构造合适的“虚拟成功标准”。如果你正在被稀疏奖励问题折磨不妨把训练日志甩开先想想这个问题如果把每次失败轨迹的终点当作成功目标重新学习你的模型会不会已经掌握了足够多的知识很多情况下答案是肯定的。
返回列表