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

资讯详情

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

强化学习稀疏奖励困境?Hindsight Experience Replay目标重标注原理与PyTorch实战

强化学习稀疏奖励困境?Hindsight Experience Replay目标重标注原理与PyTorch实战

干我们强化学习这一行的,评估一个新算法好不好用,最直接的感受不是看论文里的曲线有多漂亮,而是你自己把代码写出来、放进环境里跑,看它是不是真的能解决你手头那个“死活学不出来”的任务。我为什么突然提到“hindsight”这个词?因为它几乎就是为这种困境量身定做的——后见之明。真正让我对这个概念产生信任的,是后来我接触 Hindsight Experience Replay(HER)算法时,发现它把一个非常朴素的直觉变成了一个极其有效的训练机制:如果这次没达到目标,那就把“这次经历”重新解释成“我本可以完成的目标”,让经验不再浪费。这篇文章,我想把我从原理到实践踩过的所有坑、调试过的所有参数,完整拆给你看。

我不是来给你复述摘要的。我会从稀疏奖励这个让无数人崩溃的课题说起,把 HER 的目标重标注机制讲透,然后直接放一份基于 PyTorch 的完整训练循环代码,再把我调试过程中遇到的问题整理成一份能直接拿去用的排查清单。你如果正在头痛“机器人控制学不动”“Flag 类游戏奖励太难触发”,或者你压根没听过 HER 但对稀疏奖励的困境感同身受,那这篇内容你应该能带走不少东西。

1. 项目核心:什么是“hindsight”的经验回放

1.1 一句话理解 HER

Hindsight Experience Replay,中文习惯叫“事后经验回放”,是 2017 年由 OpenAI 团队提出的一种强化学习训练策略。它的核心思想极其简单:当一个回合结束时,如果 agent 没有达到我们设定的目标,我们并不把这个回合当作“失败的废物”扔掉,而是把它重新标记为“agent 成功完成了一个新目标”,然后把这条新经验放回经验池里供后续学习。

这是典型的“事后诸葛亮”思路。目标没达成是事实,但这并不代表回合里没有学到东西。比如说你想让机械臂把积木推到坐标 A,结果它推到了坐标 B。在传统强化学习里,这个回合零分,所有 Q 值更新都指向“这个动作序列很失败”。但在 HER 的逻辑里,这个回合会被同时当作“目标 B 的成功案例”存进 Buffer——序列没变,动作没变,只有目标变了。下一次 agent 再被要求推积木到 A 时,它至少多了一条可以参照的轨迹:我是怎么一步步走到 B 的。

1.2 稀疏奖励问题:HER 出现前的死局

你只有先吃透稀疏奖励,才能真正理解 HER 的价值所在。所谓稀疏奖励,就是环境只在“任务真正完成”的那一刻才会给一个非零反馈,其余所有中间步骤,奖励都是 0。比如游戏《蒙特祖玛的复仇》,很多格子必须先踩机关再跳过去,踩错了直接死,但中间没有任何奖励信号告诉你“快接近了”。这种环境下,标准的 DQN 几乎学不动,因为随机探索碰到终点的概率低到可以忽略,整个经验池里一大半都是奖励为 0 的“垃圾样本”。

强化学习的本质是依靠奖励信号在样本空间里做“筛选”,筛选出那些带来正收益的动作序列。但稀疏奖励环境下正样本太少了,根本没有足够的梯度去引导策略逼近最优解。我见过不少入门者把这个问题归咎于“探索不足”,然后无脑调高 epsilon-random,结果就是 agent 在环境里乱撞,仍然一无所获。HER 解决的不是“探索多少”的问题,而是“如何让已有的探索样本不被浪费”的问题,这是完全不同的切入点。

1.3 为什么叫“后见之明”:一个做菜类比

理解“后见之明”最直观的类比是做菜。你在试做一道新菜,目标口味是“咸淡适中”,结果出锅后一尝,太咸了。这次尝试算不算失败?按原目标来说算,但它并不是没有产出价值——下次你想做“偏咸口”的菜时,这次经历就成了现成的模板。

把这个类比搬回强化学习:agent 的每一次尝试,哪怕没有达成最初命令的目标,都完整记录了一条从初始状态到某个实际结果的轨迹。HER 做的事就是把这些轨迹“重新贴标签”,把实际达到的状态当作本次轨迹的假定目标。这样一来,agent 便能从每一次失败的经历中,提取出“如何达成某个实际结果”的正向经验。经验池里的有效样本密度被立刻增大,学习效率自然就上来了。

2. Hindsight 背后的算法设计:目标重标注

2.1 目标条件强化学习的标准设定

要进入 HER,先得了解它的载体——目标条件强化学习(Goal-Conditioned RL)。在这个设定里,环境被描述为一个马尔可夫决策过程,但在常规状态之外,多了一个维度:目标 g。在每个 episode 开始,环境会向 agent 下达一个目标 g,agent 不仅要观测当前状态 s,还要知道目标是什么,并据此决策。奖励函数不再是固定的 r(s,a),而是依赖于当前状态与目标 g 的比较,比如r = -[d(s, g) < epsilon],只有距离小于阈值才奖励 0,否则为 -1。

写代码时,最常见的做法是把目标和状态拼成一个向量,然后喂给神经网络。比如机械臂环境里状态 s 通常包含关节角度、末端位置等,目标 g 是期望的末端位置。网络输入就是 [s; g] 的拼接。这个做法简单但有效,也是我见到的绝大多数 HER 代码的实现方式。当然你也可以设计更复杂的双塔结构,但对大部分环境来说没必要。

2.2 Why(为什么需要重标注目标)

核心矛盾在于:原始目标下的正样本极其稀少。假设 episode 长度为 50,随机探索的成功率是 1%,那平均每个成功 episode 需要 100 个 episode 才能等来一次。而经验池要容纳足够多成功样本,训练时间就会成倍拉长。更坏的是,稀疏奖励下那些零奖励的样本中,其实包含了一部分“实现了某个实际目标”但没被识别的轨迹。HER 选择把掩盖在失败外表下的成功挖掘出来。

这一招对 Q 函数的学习尤其关键。Q 函数的核心任务是在给定状态 s 和动作 a 时,预测期望回报。如果目标改为“实际达到的状态 s_t+k”,那么这一回合中从这个状态往前看的所有状态-动作对,它们的回报都不是 -1 而是 0。于是 Q 函数可以学到:在接近目标状态时,这些动作可能会带来非负回报。这种就近监督的密度,比依赖稀疏全局成功信号的监督密度高几个数量级。

2.3 重标注的时机与策略

重目标并不是对回放池里的每一条经验都做,而是有选择性的。通常在一个 episode 结束后,从这一条轨迹中随机抽取 k 条额外经验,每条经验再将“实际达到的最终状态”作为新的目标,构造成新的样本,与原样本一起存入经验池。抽取 k 这个超参数,论文里默认是 4。也有一些做法是随机抽取轨迹中的某一个未来状态作为目标,而不是只用终点状态。

我在项目里通常用“future”策略,也就是把当前轨迹线中未来某个时刻达到的状态当作新的目标。这么做的好处是,它天然产生了一条连贯的、从过去到未来状态可达成的轨迹,Q 函数训练起来更稳定。我也试过用“final”策略——一律用轨迹终点作为目标——效果在某些环境中差不多,但因为目标分布太集中,会损失多样性,部分场景下训练容易早熟。

2.4 重新标注后的样本要如何参与学习

你以为把重标注的样本丢进 Buffer 就万事大吉了,其实还差一环。HER 必须与 off-policy 算法搭配使用,比如 DQN、DDPG、TD3、SAC。原因在于,HER 依赖一个大规模的 replay buffer 来反复抽取历史经验,并且这些经验的目标已经被改写,它们的“策略分布”已经和历史策略不一致了。on-policy 算法(如 PPO)需要按当前策略收集新数据来更新,没办法直接吃这些“过去时”的样本,强行用会引发严重的分布偏移。

我现在大多数工作流里都是 HER + TD3 或 HER + SAC 的组合。TD3 在连续控制任务上表现非常稳定,SAC 在需要熵调节的任务中更好调参。如果你做离散动作,HER + DQN 也完全可行,只是要注意不要设太小的 replay buffer,否则重标注样本就没有足够的存放空间。

3. 基于 PyTorch 的 HER 实现:从回放池到训练循环

3.1 环境选择与前置准备

实操之前,得有一个能在本地快速模拟的环境。我推荐用 OpenAI 早期发布的gym-fetch系列,其中FetchReach是最简单的一个:机械臂需要把末端移动到随机目标点。奖励定义为 -1(未达到阈值距离)或 0(达到距离阈值)。这个环境天然适合验证 HER,因为目标空间连续、动作连续、干扰少。

先安装依赖:

pip install gymnasium pip install gymnasium-robotics pip install torch

FetchReach-v2里有一个非常容易踩的坑:新版 gymnasium 的环境 reset 后返回的是(obs, info)元组,而老版代码通常写obs = env.reset()。如果你直接照抄老教程,十有八九会在dict解包时报TypeError。我的做法是统一封装一个接口函数,把 obs 里的observation、desired_goal、achieved_goal字段分别取出来。

3.2 关键代码:目标重标注回放池

这是 HER 的核心数据结构,它不只存储原始经验,还能在采样时“就地生成”重标注样本。我直接上一份可运行的简化版回放池:

import numpy as np from collections import deque import random class HindsightReplayBuffer: def __init__(self, capacity, k=4, strategy="future"): self.capacity = capacity self.k = k self.strategy = strategy self.buffer = deque(maxlen=capacity) def add_episode(self, episode): # episode: list of transitions # 每个 transition: (obs, achieved_goal, desired_goal, action, reward, next_obs, next_achieved_goal, done) self.buffer.extend(episode) # 原始经验全部入池 # 额外重标注经验 for _ in range(self.k): idx = random.randint(0, len(episode) - 1) transition = episode[idx] if self.strategy == "future": future_idx = random.randint(idx, len(episode) - 1) new_goal = episode[future_idx][1].copy() # achieved_goal elif self.strategy == "final": new_goal = episode[-1][1].copy() else: raise ValueError("Unknown strategy") new_reward = 0.0 if np.linalg.norm(transition[1] - new_goal) < 0.05 else -1.0 new_transition = ( transition[0], transition[1], new_goal, transition[3], new_reward, transition[5], transition[6], True if new_reward == 0.0 else False, ) self.buffer.append(new_transition) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) obs = np.array([t[0] for t in batch]) ach = np.array([t[1] for t in batch]) goal = np.array([t[2] for t in batch]) act = np.array([t[3] for t in batch]) rew = np.array([t[4] for t in batch]) nxt_obs = np.array([t[5] for t in batch]) nxt_ach = np.array([t[6] for t in batch]) done = np.array([t[7] for t in batch]) return obs, ach, goal, act, rew, nxt_obs, nxt_ach, done

注意上面我把obs和achieved_goal分开存了,因为重标注只需要替换desired_goal即可,状态主体不需要动。实际喂进网络的是一个拼接向量obs = np.concatenate([obs, goal], axis=-1),这一步可以在训练循环里临时拼,不必提前塞进 Buffer,节省内存。

3.3 训练主循环与网络结构

我用的策略网络是标准的 MLP,隐含层[256, 256],激活函数 ReLU,输出层直接映射到动作范围[-1, 1],使用 tanh 做输出限制。下面是我训练 FetchReach 时用的简化版 DDPG 风格代码,我只写了关键部分:

import torch import torch.nn as nn import torch.optim as optim class Actor(nn.Module): def __init__(self, obs_dim, action_dim): super().__init__() self.net = nn.Sequential( nn.Linear(obs_dim + goal_dim, 256), nn.ReLU(), nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, action_dim), nn.Tanh(), ) def forward(self, s, g): x = torch.cat([s, g], dim=-1) return self.net(x) # 训练循环核心片段 for episode in range(max_episodes): obs, info = env.reset() episode_buffer = [] while True: with torch.no_grad(): action = actor(torch.FloatTensor(obs["observation"]).unsqueeze(0), torch.FloatTensor(obs["desired_goal"]).unsqueeze(0)) action = action.numpy().squeeze(0) # 简单加探索噪声 action += np.random.normal(0, 0.1, size=action.shape) action = np.clip(action, -1, 1) next_obs, reward, terminated, truncated, info = env.step(action) transition = (obs["observation"], obs["achieved_goal"], obs["desired_goal"], action, reward, next_obs["observation"], next_obs["achieved_goal"], terminated) episode_buffer.append(transition) obs = next_obs if terminated or truncated: break replay_buffer.add_episode(episode_buffer) if len(replay_buffer.buffer) > 1000: for _ in range(40): s, a, g, r, ns, na, done = replay_buffer.sample(256) # 拼状态 s = torch.FloatTensor(np.concatenate([s, g], axis=-1)) ns = torch.FloatTensor(np.concatenate([ns, g], axis=-1)) a = torch.FloatTensor(a) r = torch.FloatTensor(r).unsqueeze(-1) done = torch.FloatTensor(done).unsqueeze(-1) # ... 此处省略 critic / actor 的反向传播更新

这个简略代码只是为了展示结构。实际运行还要补齐 Critic 网络和两个目标网络,不然 DDPG 会有严重的 Q 值过估计问题。我的建议是:第一步先只做“目标重标注+普通 Q 学习”,用 TD3 固定代码模板,这样可以少踩一半的坑。

3.4 参数设置参考表

我跑 FetchReach 时用得比较稳的一组参数,直接贴出来给你参考:

参数取值说明
Replay Buffer 容量100000太小会存不下较多重标注样本
每 episode 重标注样本数 k4OpenAI 论文的默认值
重标注策略future随机取未来状态
Batch Size256偏大,但更稳
Actor 学习率1e-3不确定时先保持 1e-3
Critic 学习率1e-3可以试着降到 1e-3 以下
探索噪声N(0, 0.1)简单高斯噪声,够用
目标网络软更新系数0.05TD3 常用 0.005,深调时看稳定性

这套参数下,FetchReach 通常 100 个 episode 左右就能看到成功率明显上升。如果你发现 10 个 episode 过去 loss 纹丝不动,第一优先检查你的环境观测维度和目标维度是否对齐,其次检查 Buffer 里重标注样本是否有被正确存储。

4. Hindsight 使用中的常见坑与排错实录

4.1 为什么我加了 HER 反而比普通方法还烂

这个我见过太多次了,很多人把 HER 当作“强化学习的万能药”,以为只要把经验池换成 HindsightReplayBuffer,问题立即解决。实际上 HER 只有在目标条件环境 + 稀疏奖励下优势才明显。如果你的环境本身就具备稠密奖励(比如每一步都有基于距离的负奖励),强行加 HER 反而会引入大量目标被重标成“无意义状态”的噪声,策略容易变得犹豫不决。

解决方法:先检查你的奖励信号。如果奖励是连续稠密的,还用不上 HER;如果奖励绝大多数时候是 -1,只有极少数为 0,那 HER 才有发挥空间。

4.2 目标重标注为什么会导致训练震荡

有朋友反映,我按照论文实现了 HER,结果训练前几轮还行,后面性能反而下降。这多半是“非平稳目标分布”造成的。随着 agent 越来越强,它实际达到的状态分布会持续变化,重标注出的新目标也会跟着漂移。如果 Buffer 里的样本不区分新旧,学习器就会同时被新分布和旧分布拉扯,Q 函数非常容易震荡。

我在实践中用两个办法压制这个问题:一是增大 Buffer 容量的同时,降低单步更新次数,避免同一个小批次里反复出现过于陈旧的目标;二是定期微调目标网络参数,让 Q 目标在软更新下逐步逼近,而不是一步跳跃。这本质上是 TD3 稳定训练的常规操作,但与 HER 叠加时尤其需要留意。

4.3 从 obs 到 goal 的拼接顺序会影响收敛

这个坑相当隐蔽,而且论文里绝对不会提:concat的字段顺序在某些环境的观测里会影响特征分布。比如 Fetch 环境的observation长度为 25,desired_goal长度为 3,如果你把 goal 拼在 obs 后面,那么网络前几维的位置信息就会被 goal 的高频变化淹没。我没做归一化时,这种影响肉眼可见,训练曲线抖动得很厉害。

建议做法是:无论是 obs 还是 goal,都先做hs标准化,或者至少用 RunningMeanStd 做基于统计量的归一化。目标空间与状态空间的量纲差距过大时,HER 的重标注逻辑不受影响,但神经网络训练就容易走向发散。我在 Fetch 类环境里把observation拆成observation[:20]当作低层向量,再和目标拼接,表现稳定不少。

4.4 replay buffer 里的生死成败:采样比例的细节

HER 原论文里提到,经验池里原始经验与重标注经验的比例大概是多少,很多人没在意。其实如果重标注样本太多,原始目标信息被稀释,agent 会越来越擅长完成“意外目标”,但忘记它原本该干什么了。反过来,如果重标注样本太少,又在关键技术场景中起不到监督作用。

我常用的经验比例是:原始经验占 60% 到 70%,重标注经验占 30% 到 40%。具体实现时,可以单独维护两个队列,或者用整段轨迹重标注后统一入池。我也建议你做一个实验:固定 seed,分别调 k 为 1、4、8,观察成功率曲线。k=4 不一定是你的最优解,但作为基线很稳。

5. 那些只有踩过坑才能沉淀下来的调试建议

5.1 评估指标不能只看平均奖励,还要看成功率

HER 的一个特点是:即使你的平均奖励一直在 -1 附近徘徊,成功率也可能在悄悄爬升。因为重标注样本的 reward 一样是 -1(离目标远时),它并不改变平均回报的表现。我必须提醒新人:一定要设置一个独立的success_rate指标,比如计算每个 episode 结束时距离是否小于 0.05。我见过有人盯着 loss 和 reward 判断“HER 无效”,结果换了评估角度后发现训练早已成功,白白推倒重来。

5.2 值得记录的超参数组合:少改多试

我做一个实验时的铁律是:一次只改一个变量。HER 涉及的超参数包括 k、重标注策略、buffer 容量、批大小、目标网络更新频率。如果你同时调三个,出现问题根本不知道是谁的锅。推荐组合:先保持 k=4、future 策略、容量 100k、批次 256,把基础算法调到稳定收敛,再动 HER 的部分参数。这样既能看到 HER 独立贡献,又不会把两个不稳定因素混在一起。

5.3 用确定性任务验证循环是否可信

如果你是从零手写代码,不要一上来就用复杂环境。先用FetchReach这类确定性较强、目标单一的任务验证“训练循环本身”是否正确。我的经验是:如果目标条件 RL 的回放池、目标重标注、动作噪声和环境 step 逻辑有 bug,在复杂环境里可能跑十几万帧都发现不了。但在 FetchReach 上,两三百个 episode 不见成功,就可以断言你写错了代码。

5.4 关于“后见之明”思想的后续扩展

HER 的思想并不局限于强化学习,它在模仿学习、离线强化学习甚至在大型语言模型的数据增强中也开始出现。你可以把它理解成一种通用的数据重标注策略:任何一条失败经验,只要你重新定义目标,它都能变成一条有用经验。这个思维模式让我在写很多机器学习任务的数据管线时非常受用,遇到稀缺正样本时,第一反应不是“怎么探索更多正样本”,而是“能否把已有负样本重新解释成正样本”。

6. 小结前的一点个人体会

我个人在实际操作中的体会是,HER 给我们的核心启发,不只是那个算法公式,而是“不要浪费经验”这个工程态度。跑强化学习实验,烧钱烧时间,每一帧样本都来之不易,更别说那些大量花费探索成本才采到的失败轨迹。事后重标注就像是在失败里挑出可以用的金子,它把意义上的失败变成了结构上的成功。

最后再分享一个小技巧:无论你最终选择哪种算法与 HER 搭配,我建议你在训练日志里直接打印“每条轨迹里被重标注的目标数”,而不是只看总入池样本数。很多时候这个数字能让你一眼看出策略是否在朝着更丰富的目标分布演化,比如当你的 agent 变得越来越擅长达成随机目标时,这个数字会稳定下来,这时你就知道已经是正式冲刺关键目标的阶段了。

返回列表