尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从Q-learning到CQL:离线强化学习值函数方法进阶指南

从Q-learning到CQL:离线强化学习值函数方法进阶指南 1. 从Q-learning到CQL一条被低估的进阶路线很多人学强化学习路径是这样的先看几篇科普觉得“智能体自己试错学策略”很酷然后直接冲进PPO、SAC的代码里调包跑通一个CartPole或者HalfCheetah看到曲线上升就觉得自己会了。再往后想深入发现论文里的数学看不懂离线强化学习Offline RL的代码跑不通面试被问到“为什么Q-learning会高估”时只能含糊其辞。我自己也走过这条弯路。2019年前后我在做一个工业场景下的决策优化项目需要让模型从历史日志里学策略不能在线交互。当时第一反应是用DQN结果训练几轮就崩了Q值直接飞到1e6量级策略完全不可用。后来才意识到问题出在离线数据和在线学习的根本矛盾上——这就是CQLConservative Q-Learning要解决的核心问题。这篇文章想做的事情很明确把从Q-learning到CQL这条线串起来讲清楚。不是泛泛地介绍“强化学习是什么”而是聚焦在值函数方法这条主线上把每个阶段的核心动机、数学原理、代码实现和踩坑经验都摊开来讲。适合已经了解强化学习基本概念状态、动作、奖励、策略但想真正搞懂“为什么需要CQL”“CQL到底在做什么”的读者。如果你正在做离线强化学习相关的项目或者准备面试深度强化学习岗位这篇内容应该能帮你省下不少翻论文和debug的时间。整条路线我打算这样走先从Q-learning的表格方法讲起理解值迭代的本质然后看DQN怎么用神经网络逼近Q函数以及它带来的两个经典问题——高估偏差和经验回放的相关性接着聊CQL为什么要在Q值上做“保守估计”它的数学形式怎么推导出来最后给一个可运行的CQL实现框架和调参经验。中间会穿插大量实操细节比如目标网络怎么设、保守系数α怎么调、离线数据集怎么构建这些都是文档里不会写但实际项目中一定会遇到的东西。2. Q-learning一切值函数方法的起点2.1 表格Q-learning的核心机制与更新公式Q-learning的本质可以用一句话概括用一个表格记录“在状态s下做动作a未来能拿到多少累计奖励”然后不断用实际观测到的奖励去修正这个估计。它的更新公式是整个值函数方法的基石Q(s, a) ← Q(s, a) α [r γ · max Q(s, a) - Q(s, a)]这个公式里有四个关键要素每个都值得展开说。α是学习率控制新信息覆盖旧估计的程度通常取0.1到0.01之间。γ是折扣因子决定未来奖励的权重越接近1表示越看重长期收益常见取值0.95到0.99。r是即时奖励就是执行动作a后环境给的反馈。max Q(s, a)是下一状态的最大Q值代表“如果接下来选最优动作能拿到的最好结果”。我第一次手写Q-learning是在一个网格世界GridWorld环境里。5x5的格子起点左上角终点右下角每走一步奖励-1走到终点奖励10。用纯Python写核心代码不到30行import numpy as np n_states 25 n_actions 4 # 上下左右 Q np.zeros((n_states, n_actions)) alpha 0.1 gamma 0.95 epsilon 0.1 for episode in range(1000): s 0 # 起点 while s ! 24: # 终点 if np.random.rand() epsilon: a np.random.randint(n_actions) else: a np.argmax(Q[s]) s_next, r env_step(s, a) Q[s, a] alpha * (r gamma * np.max(Q[s_next]) - Q[s, a]) s s_next跑完1000个episode后把Q表reshape成5x5每个格子的最大值就是该状态的最优值。你会发现离终点越近的格子值越高形成一条清晰的“梯度走廊”。这个可视化结果非常直观地展示了值迭代在做什么——信息从终点向起点反向传播。注意Q-learning是off-policy算法也就是说它学习的策略max Q和执行探索的策略ε-greedy可以不同。这个性质在后来离线强化学习里变得极其重要因为离线场景下你根本无法用当前策略去采样。2.2 为什么Q-learning会高估最大化偏差的数学根源Q-learning有一个被讨论了二十多年的问题最大化偏差Maximization Bias。这个问题在表格方法里还不明显一旦换成函数逼近就会放大到灾难级别。根源在于更新公式里的max操作。假设在某个状态s下真实Q值是[1.0, 1.0, 1.0]但估计值因为噪声变成了[1.2, 0.9, 1.1]。max操作会选中1.2这个被高估的动作然后把这个高估的值反向传播到前一个状态。每一步都这样传播误差就会指数级累积。我用一个简单实验验证过在GridWorld里给奖励加高斯噪声然后对比Q-learning和Double Q-learning的表现。Q-learning在500个episode后Q值平均高估了约30%而Double Q-learning用两个独立的Q表一个选动作一个评估的高估幅度控制在5%以内。这个问题的数学本质是E[max(X1, X2, ..., Xn)] ≥ max(E[X1], E[X2], ..., E[Xn])。只要估计有方差max就会引入正偏差。在表格方法里每个状态-动作对的估计相对独立偏差有限但在DQN里所有状态共享一个神经网络一个地方的高估会通过梯度传播污染整个网络。2.3 表格方法的局限与函数逼近的必要性表格Q-learning能用的前提是状态空间离散且有限。我那个5x5网格只有25个状态Q表也就25x4。但现实问题呢一个机械臂的关节角度是连续的一张游戏画面是84x84x3的像素状态数量是天文数字。你不可能为每个状态单独存一个Q值。更麻烦的是泛化。表格方法里状态1学到的经验完全不会帮助状态2。但在实际问题中相似的状态应该有相似的Q值。比如自动驾驶里“前方10米有车”和“前方11米有车”的决策应该几乎一样但表格方法会把它们当成完全无关的两个状态。这就是DQN要解决的问题用神经网络Q(s, a; θ)来逼近Q函数参数θ在所有状态间共享从而实现泛化。但神经网络的引入也带来了新的挑战——训练不稳定、样本相关性强、目标值非平稳。DQN用两个关键技术来应对经验回放和目标网络。3. DQN当神经网络遇上Q-learning3.1 经验回放与目标网络DQN的两大支柱DQN的核心思想在2013年的NIPS论文里就定型了2015年Nature版本做了完善。它的创新不在于“用神经网络”而在于让神经网络训练稳定的两个工程技巧。经验回放Experience Replay解决的是样本相关性问题。在在线学习中连续采样的样本高度相关相邻帧几乎一样直接用来做SGD会导致梯度方向单一网络容易遗忘之前的经验。DQN的做法是把(s, a, r, s, done)五元组存进一个容量10万到100万的buffer里每次训练随机采样一个batch。这样样本近似独立同分布梯度更新更稳定。目标网络Target Network解决的是目标值非平稳问题。如果计算TD目标时用的Q网络和正在更新的Q网络是同一个那么每次更新都会改变目标值形成“追自己尾巴”的循环。DQN的做法是维护一个参数为θ⁻的目标网络每隔C步比如10000步从主网络复制一次参数。这样在一段时间内目标值是固定的训练更稳定。# DQN核心训练循环简化版 for step in range(total_steps): # 与环境交互存经验 action epsilon_greedy(Q_net, state) next_state, reward, done env.step(action) replay_buffer.push(state, action, reward, next_state, done) # 每4步训练一次 if step % 4 0 and len(replay_buffer) batch_size: batch replay_buffer.sample(batch_size) # 用目标网络计算TD目标 with torch.no_grad(): next_q target_net(batch.next_state).max(dim1)[0] td_target batch.reward gamma * next_q * (1 - batch.done) # 当前网络计算预测值 current_q Q_net(batch.state).gather(1, batch.action) loss F.mse_loss(current_q, td_target) optimizer.zero_grad() loss.backward() optimizer.step() # 定期同步目标网络 if step % target_update_freq 0: target_net.load_state_dict(Q_net.state_dict())这两个技巧看起来简单但缺一不可。我试过去掉目标网络CartPole在200个episode后Q值就爆炸了去掉经验回放训练曲线震荡得根本看不出趋势。3.2 Double DQN与Dueling DQN两个必知的改进原始DQN在Atari游戏上能打但在一些精细任务上表现不稳定。后续有两个改进几乎成了标配。Double DQN直接针对最大化偏差。它的改动极小计算TD目标时用主网络选动作用目标网络评估值。公式从max Q_target(s, a)变成Q_target(s, argmax Q_main(s, a))。这个改动让Q值估计更准确在Atari上平均提升了约10%的分数。# Double DQN的TD目标计算 with torch.no_grad(): # 主网络选动作 best_actions Q_net(batch.next_state).argmax(dim1, keepdimTrue) # 目标网络评估 next_q target_net(batch.next_state).gather(1, best_actions).squeeze() td_target batch.reward gamma * next_q * (1 - batch.done)Dueling DQN则从网络结构入手。它把Q值分解成两部分状态价值V(s)和动作优势A(s, a)即Q(s, a) V(s) A(s, a) - mean(A(s, ·))。这样做的好处是在很多状态下动作的选择其实不影响结果比如赛车游戏里直道上的微小转向网络可以专注于学习V(s)而A(s, a)只需要在关键时刻区分动作。实测下来Dueling结构在动作冗余度高的任务里提升明显。我在一个机器人抓取任务里对比过Dueling DQN的样本效率比原始DQN高了约40%达到相同成功率需要的交互步数从50万降到了30万。3.3 DQN的调参经验与常见崩溃原因DQN的调参有几个关键点我按重要性排序参数推荐范围影响踩坑经验学习率1e-4 ~ 5e-4太大导致Q值爆炸太小收敛慢用Adam不要用SGD回放buffer大小10万 ~ 100万太小样本相关太大内存吃紧优先保证多样性batch size32 ~ 256太小梯度噪声大太大更新慢32是安全起点目标网络更新频率1000 ~ 10000步太频繁不稳定太慢学得慢从10000开始调ε衰减1.0到0.01线性或指数探索不足会陷入局部最优前10%步数完成衰减奖励裁剪[-1, 1]防止Q值量级失控几乎所有Atari任务都用最常见的崩溃原因是Q值爆炸。表现是loss突然变成NaN或者Q值在几轮内从几十飞到几千。排查思路先检查奖励是否做了归一化再看学习率是不是太大最后确认目标网络有没有正常更新。我遇到过一次原因是目标网络的load_state_dict写在了优化器更新之前导致目标网络和主网络完全同步等于没有目标网络。另一个隐蔽的坑是死亡三元组Deadly Triad函数逼近 自举bootstrapping 离线策略。这三者同时出现时TD学习可能发散。DQN恰好三者都占所以训练不稳定是常态不是异常。理解这一点很重要——不要指望DQN像监督学习那样平滑收敛。4. 从在线到离线为什么需要CQL4.1 离线强化学习的核心矛盾分布偏移离线强化学习的设定是你有一个固定的数据集D里面是某个行为策略π_β产生的轨迹你不能与环境交互只能从这个数据集里学策略。这个设定在医疗、金融、工业控制里非常常见因为在线试错成本太高甚至不可能。但这里有一个根本矛盾。Q-learning的更新公式里有max Q(s, a)这个max是在所有动作上取的。如果某个动作a在数据集里从没出现过它的Q值就是网络随便猜的可能很高也可能很低。如果恰好猜得很高max就会选中它然后这个错误的高估会反向传播导致策略去选一个从未验证过的动作。这就是分布偏移Distribution Shift学习到的策略π选择的状态-动作分布和数据集D的分布不一致。在在线学习里你可以去实际执行这个动作拿到真实奖励来修正但在离线学习里你只能眼睁睁看着Q值飞走。我做过一个实验在HalfCheetah的离线数据集上跑标准DQN把在线交互去掉只从buffer采样结果Q值在5000步后开始指数增长到20000步时已经到1e8量级策略完全失效。这不是调参能解决的是算法本身的缺陷。4.2 CQL的核心思想保守估计Q值CQLConservative Q-Learning的解决思路非常直接既然问题出在Q值被高估那我就在训练时主动压低Q值让学到的Q函数成为真实Q值的下界。这样即使有分布外的动作它的Q值也不会被高估到离谱的程度。具体怎么做CQL在标准TD损失上加了一个正则项L_CQL α · E_{s~D} [log Σ_a exp(Q(s, a)) - E_{a~π_β(s)}[Q(s, a)]] L_TD这个正则项的含义是压低所有动作的Q值第一项同时抬高数据集中实际出现过的动作的Q值第二项。两者相减效果是让数据集内动作的Q值相对更高数据集外动作的Q值相对更低。α是保守系数控制这个正则的强度。我第一次看到这个公式时觉得有点绕后来用一句话总结就清楚了CQL在教Q函数“不要对没见过的动作太乐观”。这就像你招聘时对没有工作经验的候选人不要仅凭面试表现就给高薪要保守一点。4.3 CQL与Q-learning、DQN的关系梳理把这三个放在一起看演进逻辑就很清晰了算法核心改动解决的问题遗留问题Q-learning表格存储Q值max更新无模型、off-policy状态空间有限DQN神经网络逼近 经验回放 目标网络高维状态、泛化离线场景下分布偏移CQLTD损失 保守正则项离线数据下的Q值高估保守系数需调参CQL不是推翻DQN而是在DQN的框架上加了一个“安全阀”。你可以把CQL理解成“带保守约束的DQN”。它的网络结构、经验回放、目标网络这些组件和DQN完全一样只是损失函数多了一项。这个设计的好处是实现成本低。如果你已经有DQN的代码改成CQL只需要改损失函数和加一个α参数。我在项目里从DQN迁移到CQL核心代码改动不到50行。5. CQL实操从零实现一个离线强化学习流程5.1 环境搭建与离线数据集构建要跑CQL首先得有一个离线数据集。最方便的方式是用D4RLDatasets for Deep Data-Driven Reinforcement Learning这个基准库它提供了MuJoCo、AntMaze、Adroit等环境的预收集数据。安装很简单pip install gymnasium pip install d4rl # 注意d4rl对gym版本有要求建议用gym 0.21如果要用自己的数据需要构建成标准格式一个字典包含observations、actions、rewards、next_observations、terminals五个numpy数组。我建议至少收集10万条transition太少的话CQL的保守估计会过于保守学不到有效策略。# 构建离线数据集的基本结构 dataset { observations: np.array(obs_list), # shape: (N, obs_dim) actions: np.array(action_list), # shape: (N, act_dim) rewards: np.array(reward_list), # shape: (N,) next_observations: np.array(next_obs_list), terminals: np.array(done_list) # shape: (N,) }注意离线数据集的质量直接决定CQL的上限。如果数据是随机策略产生的CQL也只能学到随机策略附近的保守策略。实践中最好用中等水平以上的策略收集数据或者混合多种策略的数据。5.2 CQL损失函数的代码实现与参数解释CQL的损失函数是核心我用PyTorch写一个完整版本import torch import torch.nn.functional as F def cql_loss(Q_net, target_net, batch, alpha1.0, gamma0.99): Q_net: 主Q网络输出shape (batch, n_actions) target_net: 目标Q网络 batch: 包含observations, actions, rewards, next_observations, terminals alpha: 保守系数 obs batch[observations] actions batch[actions].long() rewards batch[rewards] next_obs batch[next_observations] terminals batch[terminals] # 标准TD损失 with torch.no_grad(): next_q target_net(next_obs).max(dim1)[0] td_target rewards gamma * next_q * (1 - terminals) current_q Q_net(obs).gather(1, actions.unsqueeze(1)).squeeze() td_loss F.mse_loss(current_q, td_target) # CQL保守正则项 q_all Q_net(obs) # (batch, n_actions) # logsumexp压低所有动作 logsumexp_q torch.logsumexp(q_all, dim1) # 数据集内动作的Q值 data_q q_all.gather(1, actions.unsqueeze(1)).squeeze() # 正则项logsumexp - data_q cql_reg (logsumexp_q - data_q).mean() total_loss td_loss alpha * cql_reg return total_loss, td_loss.item(), cql_reg.item()α的选择是最关键的调参。α太小0.1保守不足Q值还是会高估α太大10过于保守策略会变得极度保守甚至退化成只选数据集中出现最多的动作。根据CQL原论文和我的实测MuJoCo环境上α取1.0到5.0比较合适AntMaze这种稀疏奖励任务可以取到10.0。我一般这样调先用α1.0跑一轮看Q值和真实回报的差距。如果Q值明显高于实际回报比如高2倍以上就增大α如果策略表现比数据集里的行为策略还差就减小α。5.3 训练流程与关键超参数配置完整的CQL训练流程和DQN几乎一样只是损失函数换了。我给出一个可运行的训练循环# 初始化 Q_net QNetwork(obs_dim, act_dim, hidden256) target_net QNetwork(obs_dim, act_dim, hidden256) target_net.load_state_dict(Q_net.state_dict()) optimizer torch.optim.Adam(Q_net.parameters(), lr3e-4) # 超参数 alpha 2.0 gamma 0.99 batch_size 256 target_update_freq 1000 total_steps 500000 for step in range(total_steps): batch dataset.sample(batch_size) loss, td_loss, cql_reg cql_loss(Q_net, target_net, batch, alpha, gamma) optimizer.zero_grad() loss.backward() # 梯度裁剪防止Q值爆炸 torch.nn.utils.clip_grad_norm_(Q_net.parameters(), max_norm10.0) optimizer.step() if step % target_update_freq 0: target_net.load_state_dict(Q_net.state_dict()) if step % 10000 0: # 评估策略 eval_return evaluate(Q_net, env) print(fStep {step}, TD Loss: {td_loss:.3f}, fCQL Reg: {cql_reg:.3f}, Eval Return: {eval_return:.1f})几个关键配置梯度裁剪是必须的CQL的logsumexp项容易产生大梯度目标网络更新频率建议1000到5000步比DQN更频繁因为离线数据下目标值变化更剧烈学习率用3e-4比DQN稍大因为离线数据量固定需要更快收敛。5.4 评估与调参如何判断CQL是否正常工作CQL训练过程中光看loss是不够的。我一般监控三个指标第一Q值和真实回报的比值。在数据集上采样一批轨迹计算实际累计回报再计算Q网络对初始状态的估计值。健康的CQL应该让Q值略低于真实回报保守估计比值在0.7到1.0之间。如果Q值是真实回报的2倍以上说明α太小如果Q值只有真实回报的0.3以下说明α太大。第二策略在数据集上的动作分布。把学到的策略在数据集的状态上跑一遍看它选的动作和数据集里实际动作的重合度。如果重合度低于30%说明策略偏离数据太远可能不可靠如果高于90%说明策略太保守没有学到新东西。理想范围是50%到80%。第三评估回报的稳定性。CQL的一个优势是训练稳定评估回报应该平滑上升然后收敛。如果评估回报剧烈震荡检查α是不是太大导致策略在保守和激进之间反复横跳。实操心得我习惯在训练前先用行为策略跑一遍数据集记录平均回报作为baseline。CQL的目标是超过这个baseline但不要超过太多超过太多说明可能过拟合了数据集里的偶然高回报。在HalfCheetah上medium数据集的行为策略回报约4000CQL通常能到4500到5000如果跑到6000以上就要警惕了。6. 常见问题与排查技巧实录6.1 Q值不收敛或爆炸的排查思路这是CQL最常见的问题我整理了一个排查清单现象可能原因排查方法解决方案Q值持续上升不收敛α太小打印Q值均值看是否超过真实回报2倍增大α到5.0或10.0Q值突然变NaN梯度爆炸检查梯度范数加梯度裁剪降低学习率Q值震荡剧烈目标网络更新太频繁看target loss曲线增大target_update_freqQ值整体偏低α太大对比Q值和真实回报减小α到0.5或0.1部分状态Q值异常高数据集中有异常高回报检查reward分布裁剪reward到合理范围我遇到过一次Q值爆炸排查了半天发现是数据集的reward没有归一化某些transition的reward是1000多而大部分是0.1量级。神经网络对这种量级差异很敏感导致Q值估计失控。后来把所有reward除以最大值归一化到[-1, 1]问题就解决了。6.2 离线数据质量对CQL效果的影响CQL的效果高度依赖数据质量。D4RL把数据集分成几类random随机策略、medium中等策略、medium-replay中等策略的回放、medium-expert中等和专家混合、expert专家策略。我的实测结果random数据集CQL只能学到略好于随机的策略因为数据里几乎没有有效信息。medium数据集CQL能稳定超过行为策略10%到20%这是最常用的设定。medium-expertCQL能接近专家水平但需要调大α防止过拟合中等数据。expert数据集CQL表现最好但要注意不要过拟合α可以取小一点。如果数据里包含大量低质量轨迹CQL的保守估计会把这些低质量动作的Q值也压低导致学到的策略过于保守。解决办法是做数据过滤先用行为克隆BC训一个策略用它给数据集里的轨迹打分过滤掉得分最低的30%。6.3 CQL与IQL、TD3BC的选型对比离线强化学习不止CQL一个选择IQLImplicit Q-Learning和TD3BC也是常用方法。我做一个横向对比算法核心思路优势劣势适用场景CQL保守Q值正则理论保证强稳定α调参敏感通用尤其稀疏奖励IQL用expectile回归避免查询OOD动作无需调α实现简单对数据覆盖要求高数据质量较好的场景TD3BCTD3加行为克隆正则简单连续控制强需要调BC系数连续控制数据中等我的经验是如果数据质量中等以上IQL最省心如果数据里有大量次优轨迹CQL更稳如果是连续控制任务且数据量充足TD3BC是快速baseline的好选择。CQL的优势在于理论清晰出了问题容易定位适合作为第一个离线RL算法来学习和调试。6.4 从仿真到真实场景的迁移注意事项把CQL用到真实场景有几个仿真里不会遇到的问题状态表示的一致性。仿真里状态是干净的向量真实场景里传感器有噪声、有延迟、有缺失。我建议在训练时给状态加高斯噪声做数据增强噪声标准差取真实传感器噪声的1.5倍。动作空间的约束。仿真里动作可以任意取真实执行器有物理限制。CQL学到的策略可能输出超出范围的动作需要在部署时做裁剪但裁剪会改变策略分布。更好的做法是在训练时就把动作限制在可行范围内用tanh激活函数输出。奖励函数的对齐。仿真里的奖励是精心设计的真实场景的奖励往往稀疏且延迟。如果直接把仿真奖励函数搬到真实场景CQL可能学不到有效策略。我一般先用少量真实数据做奖励塑形reward shaping再跑CQL。安全约束。离线RL的一个优势是可以在部署前充分评估但CQL学到的策略仍然可能在未见过的状态上做出危险动作。我建议部署时加一个安全层如果状态超出数据集覆盖范围用密度估计判断就回退到行为策略。7. 一些个人体会和后续方向从Q-learning一路走到CQL我最大的体会是强化学习的每个算法都是在解决前一个算法的遗留问题没有银弹。Q-learning解决了无模型控制但受限于表格DQN解决了高维状态但在离线场景下崩溃CQL解决了离线分布偏移但引入了保守系数调参的新问题。理解这条演进线比单独记住每个算法的公式重要得多。如果你已经跑通了CQL下一步可以看两个方向。一是IQL它用expectile回归绕开了对OOD动作的查询实现更简洁在很多任务上表现不输CQL。二是基于模型的离线RL比如MOPO、MOReL它们先学一个环境模型再用模型生成数据做规划样本效率更高但模型误差的累积是个难点。代码方面我建议不要一上来就用d3rlpy这种高度封装的库先手写一遍CQL的损失函数和训练循环把每个张量的shape和梯度流向搞清楚。我当初就是手写了一遍之后才真正理解为什么logsumexp能压低Q值、为什么需要梯度裁剪。手写版本跑通后再用d3rlpy做大规模实验效率会高很多。最后分享一个调试小技巧在CQL训练初期把α设成0先让TD损失正常下降确认网络结构和数据管道没问题然后再逐步增大α观察Q值的变化。这样可以把“代码bug”和“算法调参”两个问题分开排查省下大量时间。
返回列表