ARTICLE DETAIL

资讯详情

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

解决稀疏奖励难题:Hindsight Experience Replay核心原理与实践

解决稀疏奖励难题:Hindsight Experience Replay核心原理与实践 盯着TensorBoard里那条平得像死鱼一样的reward曲线我一度以为是代码写错了。环境是对的动作空间是对的连梯度都没有爆但智能体就是学不会把方块推到目标点。后来我才意识到这不是代码问题是问题本身的问题——任务太难了难到智能体根本碰不到目标连一次正奖励都见不到它又从哪知道该往哪走呢。这个场景就是典型的稀疏奖励sparse reward问题。我当时的解法就是标题里这个hindsight。它不是一个花哨的框架也不是什么新模型而是一套思路**既然当前这次尝试失败了我能不能把这次失败的轨迹重新标记成另一个“成功”的轨迹来学习**这听起来有点自欺欺人但在强化学习里这个思路带来的收益非常直观——把没用过的失败经验重新变成有价值的学习材料。这篇文章我会把hindsight的核心原理、实现细节、踩过的坑都摊开讲清楚适合正在做RL项目、被稀疏奖励折磨得头疼的工程师也适合想理解HER到底怎么工作的学生。1. 从一次“失败”的训练说起hindsight想解决什么问题1.1 稀疏奖励让模型“撞了南墙也不回头”的元凶先还原一下当时的场景。我在做一个机械臂推方块的任务目标是把方块从起始位置推到一个特定的坐标点。动作空间是连续的每一步给4个维度的控制信号。环境本身不复杂但关键在奖励设计上——只有方块和目标点的距离小于某个阈值时才会给一个1的奖励其他情况全是0。问题就在这里。机械臂刚开始完全是随机打转方块可能被推到位置A但目标点其实在位置B。整个episode里智能体连一次正奖励都拿不到那value网络拿什么去学习梯度回传的时候所有的样本都是“失败”的Q值估计没有任何正向锚点网络很快就会收敛到一个什么都不干的策略上。我从这个失败里得到一个很直接的体会**稀疏奖励的本质不是难而是信息量不足。**你不是不能学而是没有可学的正样本。一个算法能不能在这种环境下起步取决于它能不能在完全没有外部正反馈的情况下自己制造出“可学习”的信号。1.2 现有方法的局限为什么奖励塑形和课程学习都不够很多人第一反应是奖励塑形reward shaping。给每一步加一个距离惩罚让智能体至少有个方向感。这个方法确实有效但很依赖人对任务的先验理解。你设一个距离惩罚就得调权重调阈值换一个任务可能又要重调。而且奖励塑形搞不好会引入局部最优——智能体学会“靠近”目标但不会“推到位”。课程学习curriculum learning也是常见解法。先学简单的目标点再逐步加大难度。问题是课程学习需要一个“难度评估器”你得先知道哪些目标简单、哪些难。这个评估本身又是一个工程。还有一个思路是更聪明的探索策略比如基于熵的探索、随机网络蒸馏RND之类。这些方法在部分场景里确实有效但实现复杂度一下就上去了而且对连续控制任务探索效率的提升有时候并不够。hindsight的出发点完全不一样。它不去增加探索也不去改奖励函数而是改变“目标”的标记方式。它默认一个简单的道理这次没推到目标B但至少推到了位置A。如果我把“位置A”当作这次episode的真正目标那这整条轨迹就是一条成功轨迹。用这种方法你每个失败的episode都能拆出至少一条“成功”的样本。这个思路简单到让人拍大腿但它把稀疏奖励问题直接转化成了稠密奖励问题。2. HER核心设计拆解把“后见之明”变成算法2.1 思想来源人如何从失败里学东西hindsight在学术上有个正式的名字Hindsight Experience Replay后见之明经验回放出自ICLR 2018的一篇论文。名字起得很好因为它模仿的正是人类的“事后复盘”能力。举一个现实例子。你打篮球投篮第一次出手偏右了。这时候你不会把这个动作当成“完全无用的失败数据”丢进垃圾桶。你会想“如果篮筐在偏右一点的位置我这个球就进了。”下次出手的时候你会下意识调整力度和角度。HER把这个过程算法化了**它是把没达到的原始目标放在一边转而用“实际达到的状态”作为替代目标goal重新生成一条学习样本。**这个替代目标在事后看来是已经实现了的所以reward的标签一定是正反馈训练信号又从稀疏变回稠密了。需要注意的是HER不是为了欺骗智能体。它在训练阶段使用“事后”目标来生成学习信号但在评估阶段考核的仍然是智能体在原始目标上的表现。这很重要我知道有些人一开始容易误解。2.2 目标重新标注goal relabeling的完整流程整个流程可以拆成几个关键步骤。第一步跑一个episode。你先让智能体按当前策略和环境交互记录完整的轨迹包括每个时间步的状态、动作、以及episode结束时的最终状态。第二步判断这个episode是否达成了原始目标。如果达成就直接存成成功样本如果没达成进入重标注流程。第三步选定一个“替代目标”g。这个g可以是这段轨迹里某一个时间步的实际状态甚至是最终状态。第四步重算奖励。用环境自带的奖励函数但传入的是g而不是原始目标g。因为状态s本来就是从这条轨迹里来的所以你得到的奖励一定是正向的。第五步把重标注后的样本存进replay buffer包括状态、动作、替代目标g、新计算出来的奖励、以及下一个状态。这一步看起来容易但实际实现的时候有不少门道。最核心的一点是替代目标必须是“给定当前策略下实际可达的状态”。你随便选一个理想的、但是没达到过的状态当目标奖励虽然算成了正向但训练出来的策略毫无意义因为它学的是怎么去一个根本去不了的地方。2.3 三种采样策略的取舍final / future / episodeHER论文里给出了几种替代目标的取法实践下来各有优劣。第一种是final策略。直接把当前episode的最后一个状态当作替代目标。这是最简单也最暴力的方式。好处是样本利用率高一条失败轨迹就可以产生一条成功样本。坏处是最后一个状态不一定有代表性。如果智能体中途经过了一个很好的位置但最后又跑偏了那用最终状态来学反而学歪了。第二种是future策略。从当前时间步之后的某些时间步里随机选一个状态当作替代目标。这种策略能覆盖更多样的状态分布。论文里的默认实现是episode结束后从未来时间步中抽取k个状态作为目标。这个方法我最终一直在用因为它兼顾了多样性和稳定性。第三种是episode策略。随机从当前episode里选任意一个状态当作目标。这个方法覆盖最广但问题也明显——很多被选中的目标跟当前轨迹的“状态流”并不在一个consistent的路径上学起来会有点飘。我的实际经验是**如果你想要复现性好、调试容易就用future策略并且k值设成4或8。**如果你想要最高样本效率、但又不怕实验不稳定可以试试finalfuture混合。2.4 哪些算法能和HER无缝结合HER不是独立算法它是经验回放层的一个改造。所以它必须寄生在一个off-policy算法上。我自己试过DQN、DDPG、TD3、SAC都能接。原理上**只要这个算法用replay buffer就可以把HER的“目标重标注”逻辑塞进去。**区别就是当你处理连续动作时critic要额外把goal拼进输入向量里。不能接的是PPO和大部分on-policy算法。因为PPO使用的是当前策略采样的数据而且要计算重要性权重。如果你的“成功样本”来自其他目标下的轨迹那这个重要性比值的计算复杂度就非常高了。强行用HERPPO典型的得不偿失。如果项目里用的是SAC我推荐的做法是先把SAC跑通再叠加HER。这样分工清楚问题出在哪一层也能快速定位。千万不要一上来就把SAC和HER全加上如果训练失败你根本不知道是哪个模块炸的。3. 从零实现hindsight一个PyTorch版的核心模块3.1 项目结构与依赖我用的环境是gym的FetchReach但为了方便调试我先自己写了一个更简单的bit-flipping环境。Bit-Flipping是一个二进制的目标达成问题状态是一个n维的0/1向量动作是翻转某一位目标是让状态等于目标向量。这个环境是HER论文里的标准验证场景逻辑简单适合先确认代码没有bug。依赖方面很清爽PyTorch、gym、numpy就够了。如果你想跑机械臂环境再加mujoco和dm_control。项目结构大概长这样hindsight/ ├── envs/ │ ├── bit_flip.py │ └── fetch.py ├── agents/ │ ├── dqn.py │ └── sac.py ├── her/ │ ├── buffer.py │ ├── relabel.py │ └── trainer.py ├── config.py └── run.py其实不需要把所有文件都铺开先把核心的her模块写好其他部分能跑就行。3.2 replay buffer改造不只是存s,a,r,s平时写DQNreplay buffer只需要存五元组。但用了HER之后必须多存两个东西一个是每个时间步的实际到达状态另一个是原始目标。先说一下我最终的buffer结构设计。import numpy as np from collections import deque class HERBuffer: def __init__(self, capacity, max_episode_len): self.capacity capacity self.max_episode_len max_episode_len self.buffer deque(maxlencapacity) def store_episode(self, episode): r把整条episode按时间步存起来稍后统一处理 # episode里包含: # obs: (T, obs_dim) # actions: (T, action_dim) # achieved_states: (T, state_dim) 实际到达的状态 # desired_goal: (goal_dim,) self.buffer.append(episode) def sample(self, batch_size): r随机抽batch转换成训练格式 episodes np.random.choice(len(self.buffer), batch_size, replaceFalse) obs_batch [] action_batch [] reward_batch [] next_obs_batch [] done_batch [] for ep_idx in episodes: ep self.buffer[ep_idx] t np.random.randint(0, ep[obs].shape[0] - 1) obs_batch.append(np.concatenate([ep[obs][t], ep[desired_goal]], axis-1)) action_batch.append(ep[actions][t]) reward 1.0 if np.allclose(ep[achieved_states][t1], ep[desired_goal], atol0.01) else 0.0 reward_batch.append(reward) next_obs_batch.append(np.concatenate([ep[obs][t1], ep[desired_goal]], axis-1)) done_batch.append(float(reward)) return (np.array(obs_batch), np.array(action_batch), np.array(reward_batch), np.array(next_obs_batch), np.array(done_batch))这里有个容易搞混的细节。原来的buffer是存单条transition现在必须改成先存整条episode再在sample的时候拆成transition。因为目标重标注需要用到完整轨迹的未来状态信息不整条存下来没法操作。第二个细节是obs和goal通过concatenate拼在一起。对DQN这样的算法输入直接是拼接后的向量。对SAC和TD3来说actor输入通常是stategoalcritic输入是stategoalaction这一点后面会单独讲。3.3 目标重新标注的代码实现目标重标注是整个项目最核心的一块代码并不复杂难的是把逻辑理清楚。我直接贴一份基于future策略的实现。def relabel_episode(episode, k4): r 对一条未达成原始目标的episode做目标重标注。 用future策略从t1到T之间随机抽k个状态作为替代目标。 T episode[obs].shape[0] original_goal episode[desired_goal] achieved episode[achieved_states] # 检查原始目标是否已经达成 if np.allclose(achieved[-1], original_goal, atol0.01): return [episode] # 已经成功保留原样本 new_episodes [] for _ in range(k): # 随机选一个未来时间步 future_t np.random.randint(1, T) fake_goal achieved[future_t] # 构造新的episode new_ep dict() new_ep[obs] episode[obs] new_ep[actions] episode[actions] new_ep[achieved_states] achieved new_ep[desired_goal] fake_goal new_ep[future_t] future_t new_episodes.append(new_ep) return new_episodes这段代码看着短但里面有几个逻辑重点。第一fake_goal是从achieved_states里取的。这保证了这个目标在当前episode里真的被达到过所以重标后的奖励一定是正向的。第二新的episode和原episode共享同样的obs和actions改变的只有desired_goal。这意味着同样的行为序列现在被解读成“在朝着fake_goal这个目标前进”。第三返回的是一个列表因为future策略里会生成k个不同的fake_goal。这个函数返回后就要把它和环境原始奖励函数结合起来。在FetchReach这类环境里reward可以由环境本身计算也可以自己写一个通用公式def compute_reward(achieved_goal, desired_goal, tol0.05): r距离阈值判断是否达成目标 d np.linalg.norm(achieved_goal - desired_goal) return float(d tol)注意一点Reward函数应该是关于achieved_goal和desired_goal的而不是关于原始obs的。否则换一个环境你就得重写一遍逻辑。3.4 和DQN结合的训练循环有了buffer和重标注模块剩下就是标准的DQN训练循环。我直接贴核心片段。for episode_idx in range(num_episodes): obs env.reset() episode { obs: [], actions: [], achieved_states: [], desired_goal: obs[desired_goal].copy(), } done False while not done: action agent.select_action(obs) next_obs, reward, done, info env.step(action) episode[obs].append(obs[observation]) episode[actions].append(action) episode[achieved_states].append(obs[achieved_goal]) obs next_obs if done: break # episode收尾把最后的achieved状态也补上 episode[achieved_states].append(obs[achieved_goal]) episode[obs] np.array(episode[obs]) episode[actions] np.array(episode[actions]) episode[achieved_states] np.array(episode[achieved_states]) # 判断原始目标是否成功 original_success np.allclose(episode[achieved_states][-1], episode[desired_goal], atol0.05) # 存入buffer前先做relabel if not original_success: new_eps relabel_episode(episode, k4) else: new_eps [episode] for ep in new_eps: her_buffer.store_episode(ep) # 从buffer里sample训练 if len(her_buffer.buffer) 1000: batch her_buffer.sample(batch_size256) agent.update(batch)如果你写上这个训练循环会发现一个很有意思的现象训练早期agent的consol奖励可能很低但如果看her_data里的成功率会发现它很快就上去了。因为被你重标过的样本其实对agent来说都是“容易的成功”。这个观察很重要它说明HER在学习前期是有明显的加速作用的。3.5 性能优化与经验心得第一不要一个episode只生成一条重标样本。k值设成4到8可以让每个episode产生多条不同目标的样本经验数据的多样性会有质的提升。第二重标后的样本同样不能全塞进buffer。要适当混入一点“原始目标样本”不然策略会偏向于学习那些容易达成的fake goal而对真实目标关心不够。我的做法是在每次sample时一半从原始样本里抽一半从重标样本里抽。第三我强烈建议一开始先用Bit-Flipping环境调试。它收敛快、可复现几分钟就能看出算法是否正常。把逻辑跑通之后再换到FetchReach这种连续控制环境调参能省掉大量排查时间。4. 实验调参让hindsight从“能跑”到“能打”4.1 小规模验证环境前面提到bit-flipping这里再多说几句。这是一个很好的单元测试环境。假设状态是一个长度为10的二进制向量动作就是翻转任意一位。目标向量也是一个二进制向量如果当前状态和目标一致奖励为1否则为0。这个环境天然适合验证HER因为随机策略几乎不可能一次就匹配上完整的目标向量绝大多数episode都是“失败”的正好可以让重标机制发挥作用。我在这个环境里的初始配置如下。参数值状态维度10episode长度20DQN隐藏层[128, 128]learning rate3e-4buffer容量1e6batch size256gamma0.98k重标个数4每episode训练次数1在这个配置下一般几千个episode之后HER版本就能稳定达到90%以上的成功率。而不用HER的DQN基本卡在20%以下因为很少能恰好蒙对完整的二进制向量。4.2 关键超参数k、gamma、buffer size、future采样比例调参这件事每个环境都有区别但有几条经验是通用的。k值的含义前面已经提过是每条“失败”episode生成的替代目标个数。k值太小多样性不够学习慢k值太大相似样本太多反而会稀释有效信息。我个人实践中k4到k8的效果差别不大低于2效果差距就很明显。gamma对HER的影响容易被忽略。因为重标后的样本通常来自一个较长轨迹的中间段如果gamma太小未来奖励的权重会被砍得太低Q值估计会偏低进而影响学习速度。我在FetchReach上试过gamma从0.98提高到0.995之后收敛速度有明显提升。当然环境本身没有长期依赖问题的话gamma太大也可能引入偏差。buffer size是越来越容易被忽略的参数。HER依赖经验回放而且因为每个episode会生成多个重标样本buffer的容量如果太小早期那些“困难样本”很容易被新的成功样本挤出去。保留足够的buffer容量有助于稳定训练。还有一个不太起眼但很关键的比例参数重标样本和原始样本的混合比例。我通常的做法是50%原始样本50%重标样本。如果你发现策略过于激进总在尝试一些不切实际的goal可以适当降低重标样本比例到30%。反过来说如果学习慢就提高到70%。4.3 与SAC/PPO等算法组合时注意点先说结论建议优先用SACHER而不是DQNHER。DQN做离散动作控制没问题但连续控制任务上DDPG和TD3的稳定性都一般SAC是综合最优的。SAC和HER结合时主要改动在state输入上。# SAC中state和goal的拼接方式 obs_with_goal np.concatenate([obs[observation], obs[desired_goal]], axis-1) achieved_with_goal np.concatenate([obs[observation], obs[achieved_goal]], axis-1) # critic输入 critic_input np.concatenate([obs_with_goal, action], axis-1)这里有个经典陷阱很多人在采样时会用achieved_goal作为目标来拼接后续obs但忘记actor输出的动作实际上是在原始goal下产生的。HER的重标不改变动作序列只改变goal的标记。所以同一个transition在重标前后的obs和next_obs都必须和同一个fake_goal拼接不能一边用原始goal一边用fake_goal。这个bug跑起来不会报错但loss曲线会很奇怪而且训练效果会很差。至于PPO我前面已经说了不推荐。它的on-policy机制和HER的重标样本在数学上是不太兼容的。如果非要用策略梯度类方法可以考虑离线RL里的CQL或者IQL它们对off-policy数据的容忍度更好。4.4 我的实验结果与配置折腾了几周之后我最终在FetchReach上得到一组比较稳定的配置。参数值算法SAC环境FetchReach-v1目标维度3三维坐标状态维度10关节角位置速度等k8gamma0.995batch size512buffer容量2e6采样比例原始:重标 1:1每episode更新次数2优化器Adamlr3e-4训练episode数2e4在这个配置下纯SAC大概需要15000个episode才勉强能看到成功率上升而SACHER在6000到8000个episode时成功率已经超过80%。而且HER版本的成功率曲线更平滑不会出现那种周期性崩掉的情况。调参过程中最大的体会是**HER是一个“数据利用率放大器”但它不会帮你解决actor和critic自身的不稳定。**如果你的基础算法本身就有问题加不加HER都一样翻车。所以我的调参顺序永远是先跑通基础算法再叠加HER最后再调k和采样比例。5. 实战中常见的坑与排查方法5.1 目标重标注后奖励计算对不上这是我遇到过最多的问题表现是你用fake_goal算出来的reward明明是1但训练时Q值怎么都上不去。排查方向很简单抓一个epoch样本出来手动打印一下achieved_goal、desired_goal和reward。我当年就是靠这句话解决的——**reward函数必须要基于achieved_goal和desired_goal的差值来判断而不能用原始obs里的某个分量来判断。**因为obs里面往往同时包含observation和achieved_goal两部分如果你取错下标拿的是observation里的坐标重标之后reward就永远对不上了。5.2 训练初期崩溃后期爆炸早期崩溃最常见的诱因是重标样本都是“容易成功”的样本Q值被抬得太高了导致训练初期策略变得特别激进一直尝试那些不太可能的动作。解决方法是两个方向一是降低重标样本比例把原始样本比例提到70%二是给Q值更新加一个clip或者降低学习率。后期爆炸的原因通常是buffer里的样本分布偏移太严重。随着策略变好真实目标下的成功样本变多了这些新样本会挤压掉早期重标样本导致replay buffer的分布突然剧变。应对办法是设置一个稳定的k值不要频繁调整同时确保原始样本和重标样本的比例在整个训练过程中保持不变。5.3 采样分布偏移问题这个更隐蔽。HER的重标样本本质上是“事后诸葛亮”它们分布在整个状态空间里但agent当前策略真正访问到的状态范围往往只是其中的一小块。如果重标样本占比太高训练出来的策略会过度拟合到那些“容易实现”的状态上在真实任务上表现反而变差。我用的一个额外技巧是在采样时给重标样本加一个“难度过滤”。只选择那些和当前目标距离在一定范围内的fake_goal忽略那些过渡偏离的。这个过滤实现起来就几行代码但能明显改善真实目标上的成功率。def filter_fake_goal(achieved_states, fake_goal, max_dist): dist np.linalg.norm(achieved_states[-1] - fake_goal) return dist max_dist5.4 终极排查清单我把这几年折腾的经验压缩成了一张排查清单每次训练出问题就逐条过。现象排查项解决方向reward曲线完全不动检查relabel后的样本reward是否为1打印样本确认fake_goal取自achieved_statesreward上去了但成功率低重标样本比例过高降低重标比例到30%-40%训练后期突然崩溃buffer分布偏移固定采样比例增加buffer容量连续控制效果差actor/critic输入拼接错误检查obs和goal拼接维度是否一致收敛太慢k值太小/gamma太小k提高到8gamma提高到0.995高维目标不work目标是图像等复杂结构考虑用对比学习或者目标编码还有一条额外的建议**多用tensorboard记录不同维度的指标别只盯reward。**把原始目标成功率、重标样本比例、Q值均值、Q值标准差分开记录。这样一旦出了问题你至少能判断是探索问题还是标记问题。结尾回头复盘这个项目我发现hindsight真正的价值不光是提供了一种算法更重要的是它改变了我思考RL问题的方式。以前遇到稀疏奖励我总想着怎么设计更好的reward怎么引导探索但其实你完全可以换个角度重新审视已经发生的事情把“没够到目标”变成“够到了另一个位置”。这个思路在强化学习之外也适用很多项目卡住不是因为不够努力而是因为一直在用错误的目标去评判过程。最后分享一个实用技巧。如果你在跑自己的机器人环境或者仿真实环境可以把HER的重标目标数量k做成动态调整——前期k大一点让数据多一点后期k小一点让训练稳定一点。这个小改动看起来不起眼但在我的几个环境里都能带来5%到10%的成功率提升。希望这篇文章能让你少踩几个我没绕过去的坑。
返回列表