☰
OPAL:原语发现与动作抽象加速离线强化学习长程决策
2026/10/26 21:37:47 网站建设 项目流程

做离线强化学习的人大概都遇到过这种局面:手里攒了几十万条轨迹,训练曲线看着挺稳,一上真机跑长任务就露馅,智能体在原地打转,或者走到一半开始做无效动作,奖励信号稀疏得像撒胡椒面。很多时候问题不在Q函数估得准不准,而在决策的时间尺度没选对。OPAL(Offline Primitive Discovery for Accelerating Offline Reinforcement Learning)就是冲着这个痛点来的:它不直接在原始动作空间上做离线RL,而是先从离线数据里把“动作原语”抠出来,构造一个短片段级别的抽象空间,再在这个空间上做决策。

这篇东西我按自己复现和理解的路子来写,讲清楚三件事:OPAL到底在解决什么、它的两阶段流水线每一环的关键实现细节、以及我在实际动手时踩到的那些坑。适合已经写过一版离线RL训练脚本、想往长程稀疏奖励任务上再推一步的读者;完全没接触过离线RL的朋友也能看懂大框架,只是代码那部分可能要补一点基础。核心关键词就那几个:离线强化学习、原语发现、动作抽象、长程任务、分层决策,下面会反复围绕它们展开。

1. 长程任务里离线RL到底卡在哪

1.1 数据分布与决策频率的三重错位

先把这个问题的物理图像讲清楚。离线RL的标准设定是:给定一个固定的数据集,里面是一堆 (s, a, r, s') 转移,策略只能从这个数据集里学,不能再去环境里试。这套设定本身没问题,问题出在长程任务上,也就是需要连续做几十上百步才有一次奖励反馈的那种任务。

第一个错位是时间尺度错位。数据集里的动作是按控制频率采的,比如10Hz,一步一个动作。但任务的有效决策单元可能是“走到门口”这种持续两三秒的宏观行为。你在10Hz的粒度上做Q学习,等于让算法自己去发现“连续20步左转再直走”这个组合是有意义的,而稀疏奖励根本给不了足够的监督信号把这个组合固化下来。

第二个错位是覆盖度错位。离线数据往往由若干条人类演示或混合策略采出来,轨迹和轨迹之间的中间状态几乎没有交集。原始动作空间里,每条轨迹周围的局部邻域很窄,Q函数在这些窄带之间没法平滑插值,一偏离数据分布估值就崩。

第三个错位是信用分配错位。奖励在时间上高度延迟,一个成功的终止状态可能要归因到前面几十步的某个关键动作。TD更新在这么长的链路上衰减得非常厉害,尤其是当折扣因子设得比较大的时候,方差会炸。

这三个错位叠在一起,表现出来就是:训练损失降得很好看,评估回报纹丝不动,或者偶尔蹦出一个高分但完全不可复现。我在几个导航和操作类任务上都见过这个现象,改网络宽度、改学习率基本没用,因为根子不在优化上。

1.2 OPAL的问题定位:把决策粒度抬上去

OPAL的思路很朴素:既然原始动作空间太细,那就先造一个粗一点的决策空间。具体做法是从离线轨迹里切出一段一段的短片段,用生成模型把这些片段压成一个隐变量,这个隐变量就叫原语(primitive)。一个原语对应一段固定长度、动作连贯的短行为,比如“向前小跑两步”“机械臂上抬一小段”。

有了原语之后,原始的长程任务就被重构成了一个时间尺度放大的新MDP:状态还是原来的状态,但动作变成了“选哪个原语”,单步跨度从1步变成H步。这个新MDP的horizon缩短了H倍,稀疏奖励的信用分配链路也跟着缩短H倍。这就是标题里“Accelerating”的实际含义——不是训练速度快了多少倍,而是把有效决策长度压短,让离线RL算法能在一个信息密度更高的空间里工作。

这个定位很重要,因为它决定了OPAL的定位边界:它是一个前置的数据加工层,不是一个新的离线RL算法。你后面接BCQ、接CQL、接TD3+BC都行,OPAL只负责把状态动作序列变成原语序列。理解这一点,看后面的实现就不会跑偏。

1.3 和分层RL、技能发现的关系

容易混淆的是把它和分层强化学习、技能发现混为一谈。区别在于:传统分层RL大多是在线学的,高层策略和低层技能一起在环境里滚;技能发现那一类工作也常常依赖环境的交互来发现有用技能。OPAL是纯离线的,原语完全从静态数据里挖,挖完就冻结,不和环境发生任何交互。

另一个区别是原语本身不需要“有意义”。很多技能发现方法会强调学出来的技能要有语义可解释性,OPAL不在乎这个,它只要原语能重建动作序列、能覆盖数据分布就够了。语义是高层策略在训练中自己赋予的,你事后可视化可能会发现某些隐变量维度对应着“向左偏移”,但这只是副产品,不是优化目标。

这个设计选择带来的好处是训练稳定、可复现性好;代价是原语的质量完全受限于数据覆盖,数据里没有的行为模式,原语库里也不会有。后面第6节会专门讲这个边界。

2. 整体架构与关键设计取舍

2.1 两阶段流水线:先学分段,再学决策

OPAL的完整流程拆成两个完全独立的阶段,中间用一个冻结的模型隔开。第一阶段是原语发现:拿离线轨迹切成固定长度的片段,训练一个条件变分自编码器,编码器吃“起始状态 + 后续H步动作序列”,输出一个隐变量z;解码器吃“起始状态 + z”,重建那H步动作。第二阶段是原语级策略学习:把数据集里的每条轨迹按同样的方式切成原语序列,每个原语配一个聚合奖励,然后在这个序列上用任意离线RL算法学一个高层策略π(z|s)。

两阶段之间是硬隔离的:第一阶段的参数在第二阶段完全冻结,梯度不回流。这个选择不是偷懒,而是有明确理由的。如果让梯度回流,高层策略的优化目标会反过来拉扯原语表示,很容易退化成“原语只编码对当前任务有用的那一小部分信息”,泛化性直接没了。分开训还有个工程上的好处:原语模型可以离线一次性训好、存档、复用,换任务时只重训高层,迭代成本低得多。

下面是我实际用的整体数据流,画成伪代码比文字清楚:

离线数据集 D = {(s_t, a_t, r_t, s_{t+1})} │ ├─► 阶段一:片段采样 → 训练 primitive VAE → 冻结 │ └─► 阶段二:用冻结的 VAE 重标注数据集 每条轨迹 → [(s_0, z_0, R_0), (s_H, z_1, R_1), ...] → 离线RL 训练 π_high(z | s) │ ▼ 部署:s_t → π_high → z → decoder(s_t, z) → a_t ... a_{t+H-1}

2.2 原语级MDP怎么构造

这一步是整个方法里最容易被做错的地方,我单独拎出来讲。原始MDP是 (S, A, P, R, γ)。原语级MDP记作 (S, Z, P_z, R_z, γ_z),四个要素的重定义方式如下。

动作空间Z就是隐变量空间,通常取8到16维的连续向量,高斯先验。状态空间保持不变,仍然是原始观测,这一点很关键——不做状态抽象,避免额外一层信息损失。

转移P_z是“在状态s执行原语z之后到达的状态分布”。严格说这个分布得靠环境才知道,离线设定下我们只能从数据里采样近似,也就是数据集中真实发生过的那些 (s, z, s') 三元组。

奖励R_z是原语执行期间H步奖励的聚合。这里有两种聚合方式:直接求和,或者按原始折扣因子做折扣求和。我倾向用折扣求和,因为它和原始的优化目标更一致:

R_z(s, z) = Σ_{i=0}^{H-1} γ^i · r_{t+i}

折扣因子γ_z必须重新标定,这是很多人会漏掉的一步。原始MDP里γ=0.99对应大约100步的有效horizon;如果原语长度H=10,那么原语级MDP里应该用 γ_z = γ^H = 0.99^10 ≈ 0.904,才能让有效horizon保持在同样的物理时长。要是偷懒继续用0.99,有效horizon会被拉到1000步原语,相当于一万步原始动作,价值估计的方差会大到没法看。这个换算我建议直接写进配置文件,用公式算,别手填。

2.3 为什么不做端到端分层训练

有人会问,既然原语和策略是耦合的,一起训不是更好吗。我的实测结论是:在离线设定下,端到端非常难收敛。原因有三个。

第一,原语模型的重建损失和高层策略的回报损失量级差了好几个数量级,联合训练时需要精细的权重调整,而这个权重对不同任务几乎不通用。

第二,高层策略的梯度通过z传回编码器时,会把隐空间往“对当前任务高回报”的方向拉扯,导致隐空间塌缩——原本多样的原语表示被压缩成少数几个对当前奖励敏感的方向。表现出来就是原语多样性指标断崖下跌,高层策略前期学得飞快,后期彻底僵住。

第三,调试成本高。两阶段分开,哪一阶段出问题一目了然;端到端出问题,你很难判断是表示学坏了还是策略学坏了。工程上,可调试性本身就是一种性能。

所以我的建议是老老实实两阶段,把第一阶段的模型存下来,做好版本管理。后面如果要做微调,也只在高层策略上做,别回头动原语模型。

3. 原语学习器的实现细节

3.1 片段采样与边界处理

第一步是把连续的轨迹切成H步的片段。切法有两种,各有取舍。

一种是固定滑窗:以步长1滑动,每H步切一段。好处是样本量最大化,一条长度1000的轨迹能切出近1000个片段(边缘截断处理),对数据量少的任务尤其重要。坏处是相邻片段高度重叠,样本之间强相关,训练时batch内的有效多样性下降,容易过拟合到某些局部模式。

另一种是关键点对齐切分:先算每步的状态变化幅度或动作突变程度,在变化剧烈的位置下刀,让片段边界落在“行为切换点”上。这样切出来的片段内部动作更连贯,重建误差更低。代价是需要额外一步预处理,而且阈值不好定。

我实际用的是一种折中:先算一个简单的边界分数,比如相邻状态的欧氏距离 d_t = ||s_{t+1} - s_t||,然后在局部极值处优先切;剩下的空白区域再用滑窗补齐。这样既有边界对齐的好处,又不会浪费数据。边界分数阈值的定法很简单,取全数据集d_t的80分位数作为候选点,前20%的剧烈变化处优先做边界。

还有一个必须处理的细节是尾部不足H步的片段。直接丢掉会损失数据,尤其是短轨迹任务。我的做法是padding加mask,重建损失只在有效步上算。别用零填充然后忘了mask,那样模型会学出“动作末尾都是零”这种假模式。

3.2 条件VAE的结构与损失

原语模型本质上是一个条件变分自编码器。编码器输入 [s_t, a_{t:t+H}] 展平,输出均值和对数方差;解码器输入 [s_t, z],输出H步动作。条件在起始状态s_t上,这是必须的,因为同一个动作序列在不同的起始状态下意义完全不同。

网络结构我一般用两层256的MLP,激活用ReLU,输出层动作加tanh把范围压到[-1,1]。隐变量维度取8到16,具体看任务的自由度:动作维度低的任务(比如二维导航)8维够用,高自由度机械臂可以上16维。

下面是核心代码,可以直接跑:

import torch import torch.nn as nn class PrimitiveEncoder(nn.Module): def __init__(self, state_dim, act_dim, seg_len, z_dim, hidden=256): super().__init__() in_dim = state_dim + act_dim * seg_len self.body = nn.Sequential( nn.Linear(in_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), ) self.mu_head = nn.Linear(hidden, z_dim) self.logvar_head = nn.Linear(hidden, z_dim) def forward(self, s0, a_seq): x = torch.cat([s0, a_seq.flatten(1)], dim=-1) h = self.body(x) return self.mu_head(h), self.logvar_head(h) class PrimitiveDecoder(nn.Module): def __init__(self, state_dim, act_dim, seg_len, z_dim, hidden=256): super().__init__() self.seg_len, self.act_dim = seg_len, act_dim self.body = nn.Sequential( nn.Linear(state_dim + z_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), ) self.out = nn.Linear(hidden, act_dim * seg_len) def forward(self, s0, z): h = self.body(torch.cat([s0, z], dim=-1)) a = torch.tanh(self.out(h)) return a.view(-1, self.seg_len, self.act_dim)

损失函数是标准的ELBO,重建项加KL项:

def opal_loss(s0, a_seq, enc, dec, beta=0.05, mask=None): mu, logvar = enc(s0, a_seq) logvar = torch.clamp(logvar, -8.0, 4.0) std = torch.exp(0.5 * logvar) z = mu + std * torch.randn_like(std) a_rec = dec(s0, z) rec = ((a_rec - a_seq) ** 2).sum(-1) # (B, H) if mask is not None: rec = rec * mask rec = rec.sum() / mask.sum().clamp(min=1.0) else: rec = rec.mean() kl = -0.5 * (1 + logvar - mu.pow(2) - logvar.exp()).sum(-1).mean() return rec + beta * kl, rec.detach(), kl.detach()

有个细节要注意:logvar做clamp。不加这个,训练后期方差会一路往下掉,KL项爆炸,梯度直接把表示毁掉。clamp到[-8, 4]是我试出来比较稳的区间,不同任务可以微调。

3.3 防退化:三个必须加的约束

原语学习最常见的失败模式是后验坍塌:编码器输出和输入无关的常数,解码器干脆忽略z,直接从s_t预测一个平均动作序列。这时候重建损失看起来还行(因为平均序列已经能拿到不错的基线),但原语完全没学到东西。三个约束我每次都会加。

第一个是KL项的warmup。前2000步β设成0,之后线性升到目标值,通常是0.05左右。这让模型先专注把重建做好,再逐步引入先验约束,避免早期就被KL压死。

第二个是“z和s_t的信息分工”。如果没有约束,解码器会倾向于完全靠s_t来重建动作,z变成摆设。一个有效做法是在训练时随机把z替换成从先验采样的噪声,让解码器必须依赖z的信息才能重建准确。这本质上是在做信息瓶颈的软约束。

第三个是重建目标里加入状态预测的辅助项。原始动作序列在某些任务里区分度不高(比如小幅抖动的动作),只看动作重建,隐变量学不出有意义的差异。加一项“从s_t和z预测H步之后的状态”作为辅助损失,能显著提升z的判别性。辅助项权重取0.1到0.5之间。

这三个约束加上,后验坍塌基本不会出现。判断标准也很直接:训练中期看一下z和动作序列的互信息估计,或者简单点,固定s_t采样不同的z,看解码出来的动作序列方差。如果方差接近零,说明又塌了。

4. 高层策略的训练与部署

4.1 折扣因子与奖励聚合的重新标定

前面提过γ_z = γ^H,这里补上完整计算和实操。假设原始γ=0.99,H=10,那么γ_z = 0.99^10 ≈ 0.9044,四舍五入写0.904。如果H改成5,γ_z ≈ 0.951;H改成20,γ_z ≈ 0.818。

这个数字不是可选的调参项,它直接决定价值估计的有效视野。我用一个导航任务举过例子:H=10但γ_z填0.99,结果高层策略学会了一直选同一个“向前冲”的原语,因为价值看得太远,任何短期行为都被未来的长尾回报稀释了。改成0.904之后,策略立刻开始区分不同原语的价值,学会在岔路口换原语。

奖励聚合那边也有个坑。如果用折扣求和 R_z = Σ γ^i r_{t+i},注意这里用的γ是原始的γ,不是γ_z。混用的话奖励会被双重折扣,量级偏小,Q值整体偏低,策略会变得过于保守。

还有一个更隐蔽的问题:如果任务里有终局奖励(比如到达目标给+1),而这个奖励发生在片段内部而非结尾,那这个原语对应的R_z会包含这+1,高层策略学到的就是“选这个原语然后继续”而不是“选这个原语就结束”。解决办法是把包含终止状态的片段单独标记,做一个终止标志位传给高层策略的MDP,让训练时正确处理bootstrap。

4.2 高层策略的算法选型

理论上任何离线RL算法都能接。实际选型要看数据结构和任务特性,我列一下我试过的组合和结论。

高层算法适用情况实测表现
行为克隆(BC)数据质量高、几乎全成功轨迹最稳,但学不出超越数据的策略
BCQ数据混合、动作维度低保守性好,长任务上回报上限偏低
CQL数据混合、需要超出数据的策略综合最均衡,我的默认选择
TD3+BC数据覆盖较全、奖励密集训练快,稀疏奖励下容易退化

原语空间的动作维度通常在8到16之间,比原始动作空间低,分布外动作的绝对量也更小,所以保守型算法的约束压力没那么大。我一般默认上CQL,把保守系数调到中等偏小,然后看评估曲线再调。

有一个经验:先跑行为克隆定个基线。因为原语序列的决策步数少,BC在原语空间的性能往往比在原始动作空间好一大截。如果BC都已经能跑通任务,说明原语发现这一环质量过关,再去上离线RL才有意义。如果BC都学不会,问题大概率在第一阶段,别在第二阶段死磕。

4.3 推理部署与闭环执行

部署时最容易翻车的地方是开环执行。也就是说,你选出z之后,把解码器吐出的H步动作原封不动执行完。这个做法在执行初期还行,一旦状态稍微偏离训练分布,后面几步的动作就全错,而且错得没法纠正。

正确做法是闭环重规划,或者用滚动执行。两种模式我都在用:

第一种是每步重规划:每一步都用当前实际状态s_t重新查一次高层策略选z,然后解码器只取第0步动作执行。这样每步都在闭环,鲁棒性最好,代价是每步都要前向一次高层网络加一次解码器,计算量上去了。适合控制频率低的任务。

第二种是执行k步再重规划,k取H的1/3到1/2。平衡了计算量和闭环性。k太大就退化成开环,太小就浪费了原语的时间抽象。我一般取k=3(H=10时)。

还有个细节:重规划的时候高层策略会重新采样z,有可能在相邻两步选出完全不同的原语,导致动作突变。缓解办法是给z加一个时间平滑项,或者干脆在重规划时做贪心选择(取Q值最大的z而非采样)。上线初期建议用贪心加低执行频率,稳了再放开。

另外,如果原语空间用的是连续隐变量,采样出来的z可能在训练分布外。部署时要对z做范围裁剪,比如限制在±2σ内,或者用编码器对数据集中实际原语编码得到的均值方差做归一化。这一步不做,偶尔会出现动作幅度离谱的尖峰。

5. 常见问题排查与调参速查

5.1 训练阶段的三类典型故障

第一类:重建损失降不下去。通常不是模型容量问题,而是片段采样的方式不对。检查一下切出来的片段是不是横跨了行为切换点,比如从“走”切成“走+停”。如果是,把H调小一点,或者用上边界对齐切分。还有一种可能是动作的尺度差异大,比如某个关节范围是[-1,1]而另一个是[-0.01,0.01],归一化没做的话,大尺度维度主导了损失。

第二类:训练损失降了但隐变量没区分度。这就是前面说的后验坍塌,按照那三个约束逐个检查。有个快速诊断法:把数据集的片段全过一遍编码器,得到z的分布,算一下各维度的方差,如果有超过一半的维度方差小于0.01,基本可以确认坍塌。

第三类:训练过程震荡。通常是KL权重太大或者学习率太高。把β降到0.01试试,学习率从3e-4降到1e-4。另外batch size太小也会震荡,原语训练建议至少256,样本不够的话用梯度累积。

5.2 上线阶段的三类典型故障

第一类:离线评估好、上线就差。这几乎总是开环执行导致的。先确认是不是每步或每k步重规划,再看z有没有做范围裁剪。

第二类:智能体反复执行同一个原语。原因可能在高层策略的价值估计塌缩,所有z的Q值都差不多。检查γ_z是否设对了,奖励聚合的量级是否正常。还有一种可能是原语库本身多样性不足,回到第一阶段查隐变量方差。

第三类:动作抖动、不平滑。这是重规划频率太高加上贪心选择导致的。把重规划间隔调大,或者对相邻两步的z做指数平滑:z_t = α·z_new + (1-α)·z_prev,α取0.6左右。

下面是完整的问题速查表,可以直接贴在工位上:

现象最可能原因处理动作
重建损失卡住片段跨行为切换点 / 动作未归一化缩短H、边界切分、逐维归一化
隐变量无区分度后验坍塌KL warmup、z随机替换、加状态预测辅助损失
训练震荡β过大 / 学习率过高β降到0.01、lr降到1e-4、batch加到256
离线好上线差开环执行改成每步或每3步重规划
反复执行同一原语γ_z设错 / 原语多样性不足按γ^H重算折扣、查隐变量方差
动作抖动重规划过频 + 采样加大间隔、改贪心、z做指数平滑
尖峰动作z超出训练分布z裁剪到±2σ、按数据集统计归一化

5.3 调参顺序与超参基线

调参顺序很重要,我的建议是严格按阶段来,别交叉调。第一阶段先只盯重建损失,把它压到合理范围(动作维度的MSE在0.01量级以下,具体看动作尺度);然后看隐变量方差,确认没坍塌;最后才看辅助任务的表现。这三步都过了再去碰第二阶段。

第二阶段先跑BC定基线,再上离线RL。离线RL的调参优先级是:折扣因子 → 保守系数 → 学习率 → 网络宽度。折扣因子排第一是因为它错了后面全错。

我的默认超参基线供参考:

参数取值说明
原语长度 H10控制频率10Hz左右时约1秒
隐变量维度8(低维任务)/ 16(高维)看动作自由度
编码器/解码器隐藏层2层×256MLP足够
KL权重 β0.05warmup 2000步后用
原语模型学习率3e-4Adam
batch size256样本少用梯度累积
高层折扣 γ_zγ^H ≈ 0.904必须按公式算
高层算法CQL保守系数中等偏小
重规划间隔 k3H=10时
状态预测辅助损失权重0.2提升z判别性

6. 适用边界:什么任务值得上OPAL

6.1 三类明显受益的场景

第一类是稀疏奖励的长程任务。这是最典型的受益场景。原始空间里要几百步才有一次奖励,信用分配几乎不可行;原语化之后决策步数压到几十步,同样的离线RL算法能学出东西来。判断标准很简单:如果你的任务平均成功轨迹长度超过200步,且奖励只在终止时给,那基本值得试。

第二类是动作维度高、但行为模式重复的任务。比如多关节操作,原始动作空间十几维,但实际有效的行为组合就那么几种。原语把动作空间压到8维左右,同时保留了“动作序列”的时序结构,相当于做了一个降维加时序抽象,数据效率提升明显。

第三类是演示数据为主、探索严重不足的数据集。纯演示数据的特点是轨迹质量高但覆盖窄,原始空间里策略很容易走出数据分布。原语级的抽象把邻近的轨迹片段聚到一起,相当于变相扩大了有效覆盖范围。

6.2 三类不划算的场景

第一类是短horizon、密集奖励的任务。这种任务里原始离线RL本来就工作得很好,上原语化反而引入了额外的表示误差和信息损失,性能可能下降。我试过一个几十步就到终点的任务,原语化和不原语化的最终回报差不多,但工程复杂度翻了一倍,不值。

第二类是动作精度要求极高的任务。原语解码是开环生成序列,重建误差会累积,对精度敏感的任务(比如精细装配)不友好。这类任务更适合在原语之上再加一层闭环修正,或者干脆放弃原语化。

第三类是数据量极小的情况。原语模型本身也要参数量,几千条短轨迹训出来的VAE大概率欠拟合,隐变量学不到东西。经验阈值是至少几万条转移,且轨迹长度足够切出足够的片段,才值得上。

还有一个隐性成本要说清楚:OPAL引入了额外的一层超参和一个需要单独维护的模型。如果你的团队迭代频繁、任务经常换,维护一个冻结的原语模型其实不轻松——换任务往往意味着要重训原语,因为动作分布变了。我现在的做法是把原语模型和数据集绑定做版本管理,任务不变就不动,只重训高层,这样成本还能接受。

最后分享一个我踩过最久的坑:一开始我总想着把原语长度H调大,觉得H越大抽象层级越高、加速越明显。实际上H超过15之后,重建误差会快速上升,解码出来的动作序列后半段基本失真,高层策略学到的原语执行出来和预期完全不是一回事。H的选择要和数据的动作平滑度匹配,动作变化越剧烈,H就得越小。判断方法很土但有效:拿固定H训一版,看重建误差随H的变化曲线,在误差开始明显上扬之前的位置取H,我一般在8到12之间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询