1. 离线强化学习与DP方法的核心定位1.1 为什么离线场景下DP类方法值得单独拿出来讲离线强化学习Offline RL这两年从学术圈往工业界渗透的速度明显加快原因很直接很多真实业务场景根本不允许你一边跑策略一边试错。医疗决策、金融交易、工业控制、推荐系统冷启动这些场景里每一次动作都有成本甚至不可逆。你不可能像在仿真环境里那样让智能体随便撞墙几千次再学乖。数据是历史沉淀下来的策略只能从这些固定数据里学学完再上线上线之后短期内也不一定有反馈闭环。在这个前提下DP-based方法Dynamic Programming-based重新回到视野中心。注意这里的DP不是指传统意义上需要完整环境模型的动态规划而是指一类以Bellman方程为骨架、通过值函数或Q函数迭代来逼近最优策略的方法族。它在离线RL里的代表包括FQIFitted Q-Iteration、BCQBatch-Constrained Q-learning、CQLConservative Q-Learning、IQLImplicit Q-Learning等。这些方法的共同特征是不依赖在线交互直接用固定数据集做值函数回归再从中提取策略。为什么单独讲DP-based因为离线RL的方法论大致分三条路一类是纯模仿学习Behavior Cloning及其变体一类是模型基方法学一个环境模型再规划还有一类就是DP-based。纯模仿受限于数据质量模型基方法在复杂高维场景下误差累积严重而DP-based在两者之间找到了一个相对平衡的位置——它既利用了Bellman最优性原理做长期回报传播又不需要显式建模环境动力学。对于大多数从在线RL转过来的人来说DP-based是最容易上手、也最容易和现有Q-learning框架对接的一条路。1.2 DP-based方法解决的核心痛点分布偏移与外推误差离线RL最要命的问题不是数据少而是分布偏移distribution shift。在线RL里智能体探索到的状态-动作分布和它当前策略的分布是同步演化的Q函数见到的样本和它要评估的策略基本匹配。但离线场景下数据集由某个行为策略behavior policy产生而你要学的最优策略很可能跑到数据集覆盖不到的区域去。这时候Q函数在那些没见过的动作上会给出什么值答案是它自己也不知道但函数逼近器会“自信地”外推出一个数通常还偏高。这就是外推误差extrapolation error。DP-based方法的核心设计几乎都在围绕这个问题做文章。BCQ的思路是给策略加一个约束让它在选动作时只能从数据集中出现过的动作附近选CQL则直接在Q值上做正则压低那些不在数据分布里的动作的Q值IQL更巧妙它干脆不显式查询分布外的动作用expectile回归在值函数层面做文章。这些方法的数学形式不同但底层逻辑一致在Bellman更新的每一步都要对“这个动作是否在数据支持范围内”保持敏感。我个人的经验是如果你不理解分布偏移这件事直接拿DDPG或SAC的代码改成离线版本大概率会得到一个Q值爆炸、策略退化的结果。这不是调参能救的是方法论层面的问题。1.3 适合阅读本文的读者画像与前置知识这篇文章面向的是已经了解基础强化学习概念、知道Q-learning和Bellman方程怎么回事、最好动手写过DQN或DDPG的读者。如果你连值函数和策略函数的区别都还不清楚建议先去补一下Sutton那本书的前几章或者找一个在线RL的入门项目跑通再说。离线RL不是入门级话题DP-based方法更是需要你对函数逼近、回归、采样偏差这些概念有基本直觉。前置知识清单马尔可夫决策过程MDP的基本定义状态、动作、转移、奖励、折扣因子Bellman最优方程和Bellman期望方程的区别时序差分TD学习的基本原理函数逼近神经网络回归的常见坑过拟合、外推、灾难性遗忘Python和PyTorch或JAX的基本使用如果你这些都没问题那接下来的内容会让你对离线RL里DP方法的全貌有一个能落地的理解。2. DP-based方法族的整体设计思路拆解2.1 从Bellman方程到离线值迭代一条主线串起来所有DP-based离线RL方法都可以追溯到同一个起点Bellman最优方程。Q*(s,a) r(s,a) γ * E_{s}[max_{a} Q*(s,a)]在线Q-learning的做法是用当前策略和环境交互采样(s,a,r,s)然后拿r γ * max_{a} Q(s,a)作为目标去更新Q(s,a)。离线场景下采样这一步没了你只有一堆固定的(s,a,r,s)四元组。那能不能直接拿这些四元组做同样的更新理论上可以但实践中会出问题——因为max操作会查询到数据集中不存在的动作导致Q值被高估然后这个高估通过Bellman备份不断传播放大。DP-based离线方法的设计空间就围绕“如何安全地做Bellman备份”展开。我把它拆成三个维度维度一约束方式。是在策略层面约束只选数据里有的动作还是在值函数层面约束压低分布外动作的Q值还是在更新目标上约束用保守估计替代max维度二值函数学习方式。是学Q函数然后隐式提取策略如CQL还是学V函数然后单独学策略如IQL还是学Q函数加一个显式的策略网络如BCQ维度三是否需要策略网络。有些方法如FQI只学Q函数策略是隐式的argmax有些方法需要额外训练一个策略网络来输出动作。这三个维度的不同组合就产生了不同的算法。下面我用一个表格把主流方法做个对照。方法约束层面学什么是否需要策略网络核心思想FQI无显式约束Q函数否直接拟合Bellman目标靠数据覆盖BCQ策略层面Q函数生成模型是用VAE约束动作在数据分布内CQL值函数层面Q函数否隐式正则化压低OOD动作Q值IQL值函数层面V函数Q函数是后处理Expectile回归避免查询OOD动作TD3BC策略层面Q函数策略是BC正则项约束策略偏离这张表建议你存下来后面看论文的时候可以随时对照。每个方法的设计选择都不是拍脑袋的背后都有明确的动机。2.2 值函数约束 vs 策略约束两条路线的取舍策略约束的思路很直观既然问题出在策略会选到数据分布外的动作那我直接限制策略的动作空间不就行了BCQ就是这么干的。它训练一个条件VAE输入状态s输出可能的行为动作分布然后策略网络只能在这个分布里采样。这样Q函数永远只被问到“数据里出现过的动作”的Q值外推误差自然就小了。但策略约束有个隐患如果数据集本身覆盖的动作空间很窄那策略被限制得太死学出来的东西可能还不如直接模仿行为策略。而且VAE的训练本身就不稳定在低维动作空间还好高维连续控制里调起来很痛苦。值函数约束走的是另一条路我不限制策略选什么动作但我让Q函数对分布外动作给出一个保守的低估值。CQL的核心就是这个思路。它在标准TD损失上加了一项正则α * (E_{a~μ}[Q(s,a)] - E_{a~π_β}[Q(s,a)])第一项是让Q值在某个宽分布μ下的期望变小第二项是让Q值在数据分布π_β下的期望变大。合起来的效果就是数据里有的动作Q值正常数据里没有的动作Q值被压低。这样即使策略去查询OOD动作得到的Q值也是低的不会误导策略。IQL则更极端它根本不查询OOD动作。它学一个V函数用expectile回归去逼近Q函数的某个上分位数然后Q函数只用数据里的(s,a)对来学。策略提取是最后一步用AWRAdvantage-Weighted Regression之类的加权回归从V和Q里导出。整个过程中max操作被完全绕开了。我实测下来的感受是CQL在大多数标准离线RL benchmark上表现稳定但超参数α对结果影响很大IQL调参更少训练更稳但在数据质量极差比如全是随机策略产生的数据时表现会下降BCQ在动作维度低的时候效果不错但VAE训练一旦崩了整个就崩了。2.3 离线DP方法的通用训练骨架不管你选哪个具体算法离线DP方法的训练流程都可以抽象成下面这个骨架数据准备把固定数据集整理成(s, a, r, s, done)的格式做归一化划分训练集和验证集。初始化Q网络可能还有V网络、策略网络目标网络可选优化器。循环迭代从数据集采样一个batch计算Bellman目标这里不同方法差异最大计算损失TD损失 正则项反向传播更新网络参数定期更新目标网络如果用的话策略提取训练结束后从Q函数或V函数中提取策略argmax、AWR、或单独训练策略网络。评估在仿真环境里跑固定数量的episode记录回报。这个骨架看起来简单但每一步都有坑。比如数据归一化如果你对奖励做了标准化评估的时候要把回报还原回去不然你看到的曲线是错的。再比如目标网络的更新频率离线场景下通常要比在线更慢因为数据分布不变化目标网络更新太快会导致训练不稳定。提示离线RL的实验里评估环节比在线RL更关键。因为训练过程中你没法通过环境交互来判断策略好坏只能靠验证集上的TD误差或Q值来间接监控。但TD误差低不代表策略好这一点要有心理准备。3. 核心细节解析与实操要点3.1 数据集的构建与预处理决定上限的一步离线RL里数据集的质量直接决定了你方法的上限。D4RL这个benchmark把数据集分了几档random随机策略、medium中等策略、medium-replay中等策略的重放缓冲、medium-expert中等和专家混合、expert专家策略。不同档位对算法的考验完全不同。我拿一个机械臂控制的任务举例。假设你手头有一批历史操作日志每条记录是关节角度、末端位姿、动作指令、下一时刻状态、奖励。在扔进算法之前你需要做几件事第一检查动作的覆盖范围。把所有动作拿出来看每个维度的分布。如果某个维度的动作值几乎全集中在0附近那说明行为策略在这个维度上几乎没有探索你的离线算法在这个维度上学不到什么东西。这时候要么补充数据要么在算法里加更强的约束。第二奖励的尺度。离线RL对奖励尺度比在线更敏感因为Bellman备份的误差会随着γ的幂次累积。如果奖励范围是[0, 1000]折扣因子0.99那Q值的量级会到10^5网络很难拟合。通常的做法是把奖励缩放到[-1, 1]或[0, 1]区间评估的时候再还原。第三终止状态的标注。很多历史日志里没有明确标注episode是否结束。如果你把终止状态当成普通状态处理Bellman目标里会多算一步未来回报导致Q值偏高。我的做法是如果两条记录之间的时间间隔超过某个阈值就认为前一条是终止状态。第四状态归一化。用训练集的均值和标准差做标准化验证集和测试集用同样的参数。这一点和普通监督学习一样但容易被忽略。import numpy as np class OfflineDataset: def __init__(self, states, actions, rewards, next_states, dones): self.states states self.actions actions self.rewards rewards self.next_states next_states self.dones dones # 计算归一化参数 self.state_mean states.mean(axis0) self.state_std states.std(axis0) 1e-6 self.reward_mean rewards.mean() self.reward_std rewards.std() 1e-6 def normalize(self): self.states (self.states - self.state_mean) / self.state_std self.next_states (self.next_states - self.state_mean) / self.state_std self.rewards (self.rewards - self.reward_mean) / self.reward_std def sample(self, batch_size): idx np.random.randint(0, len(self.states), sizebatch_size) return (self.states[idx], self.actions[idx], self.rewards[idx], self.next_states[idx], self.dones[idx])这段代码看起来简单但有几个细节值得说。state_std加1e-6是防止除零但如果某个维度的标准差本来就很小加1e-6之后归一化会把这个维度放大很多倍可能引入噪声。更稳妥的做法是设一个阈值标准差小于阈值的维度直接置零。奖励的归一化也要小心如果奖励分布有长尾用均值和标准差标准化后仍然会有极端值可以考虑用分位数裁剪。3.2 Q网络与V网络的结构设计不是越深越好离线RL的网络结构设计和在线RL有显著区别。在线RL里你可以用很深的网络因为环境交互会不断纠正错误。离线场景下网络容量太大反而容易过拟合到数据集的噪声上导致Q值在OOD区域外推得更离谱。我的经验是对于状态维度在几十到几百之间的任务3层MLP每层256个单元已经足够。激活函数用ReLU或GELU都行GELU在连续控制任务上略好一点。输出层不要加激活因为Q值可以是任意实数。如果你用CQL需要额外注意一点CQL的正则项里有一个log-sum-exp的计算涉及对多个动作样本的Q值做指数运算。如果Q值量级很大exp会溢出。解决办法是在log-sum-exp之前减去最大值这是标准技巧但自己实现的时候容易忘。import torch import torch.nn as nn class QNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim256): super().__init__() self.net nn.Sequential( nn.Linear(state_dim action_dim, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, 1) ) def forward(self, state, action): x torch.cat([state, action], dim-1) return self.net(x) class VNetwork(nn.Module): def __init__(self, state_dim, hidden_dim256): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, 1) ) def forward(self, state): return self.net(state)对于IQLV网络和Q网络的结构最好保持一致这样expectile回归的尺度不会差太多。另外IQL里通常用两个Q网络取最小值类似TD3的双Q技巧这对抑制过估计有帮助。注意离线RL里不要用BatchNorm。BatchNorm在训练时用batch统计量评估时用running mean但离线RL的评估环境分布可能和数据集分布有偏移BatchNorm会导致评估时行为异常。用LayerNorm或者干脆不用归一化层。3.3 损失函数的设计细节每一项都有存在的理由不同DP方法的损失函数差异很大但拆开看每一项都有明确的目的。我以CQL为例把损失函数拆解一下。CQL的总损失L L_TD α * L_CQL其中L_TD是标准TD损失L_TD E_{(s,a,r,s)~D}[(Q(s,a) - (r γ * E_{a~π}[Q_target(s,a)]))^2]注意这里的目标策略π可以是当前策略也可以是数据集的策略不同实现有差异。CQL原文用的是当前策略。L_CQL是保守正则L_CQL E_{s~D}[logsumexp_a Q(s,a)] - E_{(s,a)~D}[Q(s,a)]第一项是让Q值在所有动作上的log-sum-exp变小第二项是让Q值在数据动作上变大。合起来就是压低OOD动作的Q值。α的选取很关键。α太小约束不够Q值还是会爆炸α太大Q值被压得太狠策略变得过于保守。CQL原文建议α在1到10之间具体取决于任务。我的经验是先跑一个小的α比如0.5看Q值的量级是否稳定如果Q值还在涨就加大α。IQL的损失函数则是另一套逻辑L_V E_{(s,a)~D}[L_τ^2(Q_target(s,a) - V(s))]其中L_τ^2是expectile损失τ通常取0.7到0.9。这个损失的意思是V函数去逼近Q函数的某个上分位数而不是均值。为什么要用上分位数因为V函数的定义是E_{a~π}[Q(s,a)]但在离线场景下我们不想对OOD动作求期望所以用expectile回归来近似“在数据动作上的最大值”。L_Q E_{(s,a,r,s)~D}[(r γ * V(s) - Q(s,a))^2]注意这里没有max操作直接用V(s)作为目标。这就是IQL绕开OOD查询的关键。策略提取用AWRL_π -E_{(s,a)~D}[exp(β * (Q(s,a) - V(s))) * log π(a|s)]β是一个温度参数控制策略向高优势动作倾斜的程度。这些公式看起来多但核心思想就一句话每一步更新都要问自己这个操作会不会查询到数据分布外的动作如果会用什么方式把它拉回来4. 实操过程与核心环节实现4.1 环境搭建与依赖管理我假设你用PyTorchPython 3.8以上。依赖不多主要是pip install torch numpy gymnasium tqdm tensorboard如果你要跑D4RL的benchmark还需要pip install d4rl mujoco-py但mujoco-py的安装在某些系统上比较折腾需要先装MuJoCo的二进制文件设置环境变量。如果只是自己拿自定义数据做实验可以不用D4RL自己写一个简单的数据加载器就行。我建议用一个配置文件来管理超参数不要硬编码在代码里。用argparse或hydra都行。下面是一个简单的配置示例import argparse def get_args(): parser argparse.ArgumentParser() parser.add_argument(--algo, typestr, defaultcql, choices[cql, iql, bcq]) parser.add_argument(--state_dim, typeint, default17) parser.add_argument(--action_dim, typeint, default6) parser.add_argument(--hidden_dim, typeint, default256) parser.add_argument(--lr, typefloat, default3e-4) parser.add_argument(--batch_size, typeint, default256) parser.add_argument(--gamma, typefloat, default0.99) parser.add_argument(--tau, typefloat, default0.005) parser.add_argument(--alpha, typefloat, default1.0) # CQL parser.add_argument(--expectile, typefloat, default0.7) # IQL parser.add_argument(--beta, typefloat, default3.0) # IQL策略提取 parser.add_argument(--epochs, typeint, default1000) parser.add_argument(--steps_per_epoch, typeint, default1000) return parser.parse_args()学习率3e-4是一个比较安全的起点。离线RL里学习率太大会导致Q值震荡太小则收敛慢。如果你发现训练loss一直不降先检查学习率。4.2 CQL的完整实现与逐行解析下面是一个简化版的CQL实现我加了详细注释。import torch import torch.nn.functional as F import numpy as np class CQL: def __init__(self, state_dim, action_dim, args): self.args args self.q1 QNetwork(state_dim, action_dim, args.hidden_dim) self.q2 QNetwork(state_dim, action_dim, args.hidden_dim) self.q1_target QNetwork(state_dim, action_dim, args.hidden_dim) self.q2_target QNetwork(state_dim, action_dim, args.hidden_dim) self.q1_target.load_state_dict(self.q1.state_dict()) self.q2_target.load_state_dict(self.q2.state_dict()) self.policy PolicyNetwork(state_dim, action_dim, args.hidden_dim) self.q_optimizer torch.optim.Adam( list(self.q1.parameters()) list(self.q2.parameters()), lrargs.lr ) self.policy_optimizer torch.optim.Adam( self.policy.parameters(), lrargs.lr ) def update(self, batch): states, actions, rewards, next_states, dones batch # 计算目标Q值 with torch.no_grad(): next_actions, next_log_probs self.policy.sample(next_states) q1_next self.q1_target(next_states, next_actions) q2_next self.q2_target(next_states, next_actions) q_next torch.min(q1_next, q2_next) - self.args.alpha * next_log_probs target_q rewards self.args.gamma * (1 - dones) * q_next # 当前Q值 q1 self.q1(states, actions) q2 self.q2(states, actions) # TD损失 td_loss F.mse_loss(q1, target_q) F.mse_loss(q2, target_q) # CQL正则 batch_size states.shape[0] action_dim actions.shape[1] # 从策略中采样动作 random_actions, random_log_probs self.policy.sample( states, num_samples10 ) # 从均匀分布中采样动作 uniform_actions torch.rand( batch_size, 10, action_dim ) * 2 - 1 # 合并所有动作 all_actions torch.cat([random_actions, uniform_actions], dim1) # 计算所有动作的Q值 states_expanded states.unsqueeze(1).expand(-1, all_actions.shape[1], -1) q1_all self.q1(states_expanded.reshape(-1, states.shape[-1]), all_actions.reshape(-1, action_dim)) q2_all self.q2(states_expanded.reshape(-1, states.shape[-1]), all_actions.reshape(-1, action_dim)) q_all torch.min(q1_all, q2_all).reshape(batch_size, -1) # log-sum-exp cql_loss1 torch.logsumexp(q_all / self.args.alpha, dim1).mean() * self.args.alpha cql_loss2 -q1.mean() - q2.mean() cql_loss cql_loss1 cql_loss2 # 总损失 q_loss td_loss self.args.alpha * cql_loss self.q_optimizer.zero_grad() q_loss.backward() self.q_optimizer.step() # 策略更新 new_actions, log_probs self.policy.sample(states) q1_new self.q1(states, new_actions) q2_new self.q2(states, new_actions) q_new torch.min(q1_new, q2_new) policy_loss (self.args.alpha * log_probs - q_new).mean() self.policy_optimizer.zero_grad() policy_loss.backward() self.policy_optimizer.step() # 软更新目标网络 self._soft_update(self.q1, self.q1_target) self._soft_update(self.q2, self.q2_target) return td_loss.item(), cql_loss.item(), policy_loss.item() def _soft_update(self, source, target): for s_param, t_param in zip(source.parameters(), target.parameters()): t_param.data.copy_(self.args.tau * s_param.data (1 - self.args.tau) * t_param.data)这段代码里有几个容易出错的地方。第一logsumexp的数值稳定性。PyTorch的torch.logsumexp内部已经做了max减法所以直接用没问题。但如果你自己实现一定要先减max。第二策略采样的时候CQL原文用的是tanh-squashed Gaussianlog_prob需要做相应的修正。我这里简化了实际用的时候要注意。第三CQL的α在TD损失和CQL正则里都出现了但含义不同。TD损失里的α是策略熵的权重CQL正则里的α是保守程度的权重。有些实现会把这两个分开用不同的超参数。4.3 IQL的实现要点与策略提取IQL的实现比CQL简洁一些因为不需要从策略里采样动作。class IQL: def __init__(self, state_dim, action_dim, args): self.args args self.q1 QNetwork(state_dim, action_dim, args.hidden_dim) self.q2 QNetwork(state_dim, action_dim, args.hidden_dim) self.v VNetwork(state_dim, args.hidden_dim) self.policy PolicyNetwork(state_dim, action_dim, args.hidden_dim) self.q_optimizer torch.optim.Adam( list(self.q1.parameters()) list(self.q2.parameters()), lrargs.lr ) self.v_optimizer torch.optim.Adam(self.v.parameters(), lrargs.lr) self.policy_optimizer torch.optim.Adam( self.policy.parameters(), lrargs.lr ) def update(self, batch): states, actions, rewards, next_states, dones batch # 更新V函数 with torch.no_grad(): q1 self.q1(states, actions) q2 self.q2(states, actions) q torch.min(q1, q2) v self.v(states) diff q - v expectile_loss torch.where( diff 0, self.args.expectile * diff ** 2, (1 - self.args.expectile) * diff ** 2 ).mean() self.v_optimizer.zero_grad() expectile_loss.backward() self.v_optimizer.step() # 更新Q函数 with torch.no_grad(): next_v self.v(next_states) target_q rewards self.args.gamma * (1 - dones) * next_v q1 self.q1(states, actions) q2 self.q2(states, actions) q_loss F.mse_loss(q1, target_q) F.mse_loss(q2, target_q) self.q_optimizer.zero_grad() q_loss.backward() self.q_optimizer.step() # 策略提取AWR with torch.no_grad(): q1 self.q1(states, actions) q2 self.q2(states, actions) q torch.min(q1, q2) v self.v(states) adv q - v weights torch.exp(self.args.beta * adv) weights torch.clamp(weights, max100.0) # 防止权重爆炸 log_probs self.policy.log_prob(states, actions) policy_loss -(weights * log_probs).mean() self.policy_optimizer.zero_grad() policy_loss.backward() self.policy_optimizer.step() return expectile_loss.item(), q_loss.item(), policy_loss.item()IQL里expectile的τ取0.7到0.9之间。τ越大V函数越接近Q函数的最大值策略越激进τ越小V函数越接近均值策略越保守。我一般从0.7开始试如果策略表现太保守就调到0.8或0.9。AWR里的β控制策略向高优势动作倾斜的程度。β太大权重会集中在少数动作上策略过拟合β太小策略退化成行为克隆。通常取1到10之间。权重裁剪是必须的不然exp会溢出。提示IQL的策略提取是在Q和V训练完之后单独做的。你可以先训练Q和V若干轮冻结它们再训练策略。这样更稳定。如果同时训练策略的梯度会回传到Q网络可能破坏Q函数的保守性。4.4 训练监控与评估离线场景下怎么看训练好不好离线RL最难受的一点是你没法像在线RL那样每隔几轮就跑一下环境看回报。训练过程中你只能看loss和Q值。但loss低不代表策略好Q值高也不代表策略好——Q值可能是高估出来的。我通常监控这几个指标TD误差应该逐渐下降并稳定。如果TD误差一直震荡或上升说明Q值在发散需要降低学习率或加大CQL的α。Q值的均值和最大值Q值的均值应该和回报的量级匹配。如果你知道最优策略的回报大概在什么范围Q值的最大值不应该超过这个范围太多。如果Q值最大值是回报的10倍以上基本可以确定是高估了。策略的动作分布把策略在验证集状态上输出的动作拿出来看分布。如果动作全部集中在某个极端值比如tanh饱和到-1或1说明策略退化了。验证集上的Bellman误差在验证集上算TD误差如果训练集TD误差低但验证集TD误差高说明过拟合了。评估的时候在仿真环境里跑固定数量的episode通常10到100个记录平均回报和标准差。注意离线RL的评估方差通常比在线RL大因为策略可能在某些初始状态下表现很好在另一些状态下完全崩溃。多跑几个随机种子取平均。def evaluate(policy, env, num_episodes10): returns [] for _ in range(num_episodes): state, _ env.reset() episode_return 0 done False while not done: with torch.no_grad(): state_tensor torch.FloatTensor(state).unsqueeze(0) action policy.act(state_tensor) action action.squeeze(0).numpy() state, reward, terminated, truncated, _ env.step(action) done terminated or truncated episode_return reward returns.append(episode_return) return np.mean(returns), np.std(returns)5. 常见问题与排查技巧实录5.1 Q值爆炸与训练发散最常见也最致命Q值爆炸是离线DP方法最常遇到的问题。表现是训练初期Q值正常然后突然开始指数增长几千步之后变成NaN。原因通常是Bellman备份里的max操作查询到了OOD动作高估的Q值通过备份传播形成正反馈。排查思路检查CQL的α是否太小。如果α小于0.1约束基本不起作用。先把α调到1.0试试。检查目标网络的更新频率。如果τ太大比如0.1目标网络跟得太快Q值容易震荡。把τ降到0.001到0.005之间。检查奖励的尺度。如果奖励没有归一化Q值的量级会很大网络拟合困难。先把奖励缩放到[-1, 1]。检查网络初始化。最后一层的权重初始化不要太大用默认的Xavier或He初始化就行。梯度裁剪。在反向传播后加一个梯度裁剪把梯度范数限制在1.0以内。这不是根治办法但能防止训练直接崩掉。torch.nn.utils.clip_grad_norm_(self.q1.parameters(), max_norm1.0) torch.nn.utils.clip_grad_norm_(self.q2.parameters(), max_norm1.0)如果以上都试了还是爆炸那可能是数据集本身的问题。检查一下数据里有没有异常的奖励值比如某个样本的奖励是1e6或者状态里有没有NaN。5.2 策略退化学出来的策略还不如行为克隆有时候你会发现训练了半天策略的评估回报还不如直接用行为克隆。原因可能是约束太强。CQL的α太大或者BCQ的VAE约束太紧导致策略被限制在行为策略附近学不到新东西。解决办法是减小α或者放宽约束。策略提取有问题。IQL的AWR里β太小策略退化成行为克隆β太大策略过拟合到少数高优势动作。调β或者用更稳定的策略提取方法比如DDPGBC。数据质量太差。如果数据集全是随机策略产生的那任何离线RL方法都很难学到好策略因为数据里根本没有好策略的痕迹。这时候要么补充数据要么降低期望。训练不充分。离线RL通常需要比在线RL更多的梯度步数。我一般跑100万到500万步。如果只跑了几万步策略还没学好是正常的。5.3 超参数敏感性哪些参数值得花时间调离线RL的超参数比在线RL更敏感因为数据分布固定没有环境交互来纠正。我按重要性排个序超参数影响程度推荐范围调整策略学习率高1e-4 ~ 3e-4从3e-4开始发散就降CQL α高0.5 ~ 10从1.0开始Q值爆炸就加IQL expectile高0.7 ~ 0.9从0.7开始策略保守就加IQL β中1 ~ 10从3.0开始策略退化就加目标网络τ中0.001 ~ 0.01从0.005开始网络宽度中256 ~ 512从256开始过拟合就减batch size低256 ~ 1024从256开始折扣因子γ低0.99 ~ 0.999根据任务horizon选学习率和CQL的α是最值得花时间调的。我的做法是先固定其他参数用网格搜索跑学习率[1e-4, 3e-4, 1e-3]和α[0.5, 1.0, 5.0]看哪个组合的评估回报最高。通常跑3到5个种子取平均。5.4 常见问题速查表现象可能原因排查方法解决办法Q值爆炸OOD动作高估打印Q值最大值加大CQL α降低学习率策略退化约束太强/策略提取差对比BC基线减小α调β训练loss不降学习率太小/网络太小检查梯度范数加大学习率加宽网络评估回报方差大策略不稳定多跑几个种子增加评估episode数验证集TD误差高过拟合对比训练集TD误差减小网络加Dropout动作饱和策略输出极端值打印动作分布检查策略网络最后一层训练速度慢batch太大/网络太深看GPU利用率减小batch减层这张表建议打印出来贴在显示器旁边遇到问题先查表能省很多时间。6. 从DP方法到实际落地的几点个人体会6.1 离线RL不是万能药选对场景比选对算法更重要我见过不少团队一上来就想用离线RL解决所有问题结果折腾几个月发现还不如规则引擎。离线RL适合什么场景我的判断标准是你有大量历史数据且数据里包含了“好”的行为痕迹在线试错成本极高或不可行环境动态相对稳定不会在训练和部署之间发生剧烈变化你能接受策略在部署初期表现不如预期并有一套安全兜底机制如果数据里全是随机行为或者环境变化很快离线RL很难work。这时候不如先做数据治理或者用模仿学习加规则兜底。6.2 DP方法的工程化从实验代码到生产系统实验代码跑通和上线部署之间隔着一条鸿沟。工程化的时候要注意推理速度。Q网络和策略网络在训练时可以用大batch但推理时是单样本前向传播。如果网络太深推理延迟可能不满足实时要求。可以考虑量化或剪枝。输入分布偏移。部署时的状态分布可能和训练数据有偏移。需要监控输入状态的统计量如果偏离训练分布太远触发告警或回退到安全策略。版本管理。离线RL的策略更新是离线的每次更新都需要重新训练和评估。建议用模型注册表管理不同版本的策略记录每个版本的训练数据、超参数和评估结果。安全约束。在策略输出动作之后加一层安全过滤器。如果动作超出物理限制或安全范围直接裁剪或替换为安全动作。这层过滤器是最后一道防线不能省。6.3 后续可以往哪些方向扩展DP-based离线RL还在快速发展几个值得关注的方向多任务离线RL。用同一个模型处理多个任务共享表征减少对每个任务数据量的要求。离线到在线的微调。先用离线数据预训练再在线微调。这样既能利用历史数据又能在部署后继续改进。关键是设计好离线到在线的切换机制避免微调时性能骤降。基于Transformer的Q网络。最近有些工作用Transformer替代MLP做Q函数逼近在长序列决策任务上表现更好。但训练成本也更高需要权衡。不确定性量化。用集成或贝叶斯方法估计Q值的不确定性在不确定性高的区域更保守。这对安全关键场景很重要。我个人在实际操作中的体会是离线RL的落地难点往往不在算法本身而在数据、评估和工程化。算法选CQL还是IQL差距可能只有几个百分点但数据质量差一点或者评估方法不对结果可能差几倍。所以如果你刚开始做离线RL项目建议先把数据管道和评估流程搭好再花时间调算法。这个顺序反过来很容易在算法上浪费大量时间却看不到效果。最后再分享一个小技巧如果你不确定用哪个算法先用IQL跑一个baseline。IQL调参少训练稳虽然上限不一定最高但能快速给你一个可用的结果。有了baseline之后再试CQL或BCQ看能不能超过IQL。这样比一上来就调CQL的α要高效得多。