资讯详情

改进版Q-learning实战:Double Q、n步回报与经验回放

📅 2026/10/11 23:38:48 | 华诺云谱 👁 阅读
改进版Q-learning实战:Double Q、n步回报与经验回放
简介基于Q-learning的改进版强化学习算法项目聚焦路径规划场景面向MATLAB用户及强化学习入门者。项目针对经典Q-learning收敛慢的问题融合学习率衰减、动态ε-greedy探索、经验回放、目标网络与双线性更新等改进策略使智能体在网格迷宫中更快找到最短路径为机器人导航、游戏AI等应用提供可复现的实验范本。压缩包共21个文件包括19个.m脚本、1个.md说明和1个.mat数据文件整体约49KBm脚本覆盖地图创建、环境建模、Q值更新、策略选择、收敛曲线绘制等模块md文档讲解算法原理与运行方式mat数据文件可直接用于复现实验。脚本内注释清晰模块划分得当便于按需复用和二次开发。目前已有1792人学习下载适合希望通过代码实践理解Q-learning改进思路、掌握路径规划算法调参方法的读者。1. 先说清楚Q-learning 的改进版到底改的是什么基于 Q-learning 的改进版强化学习算法听起来像是一个加了新功能的 Q-learning实际上它解决的是经典 Q-learning 在稍微复杂一点的任务里就开始暴露的两个病根过估计overestimation和样本利用率太低。我见过不少同学在 4×4 的小网格里跑通经典 Q-learning 之后立刻把状态空间调到几十上百结果发现策略要么疯狂走捷径掉坑里要么训练半天 Q 表还在原地打转。这不是参数没调对而是算法结构决定的毛病。改进版不是推翻 Q-learning而是在它的更新公式上做三个外科手术式的改动Double Q 机制抑制过估计、n 步回报降低自举偏差、经验回放提高样本复用率。这三招组合起来能让你用几乎同样的代码量在悬崖行走、随机迷宫这类中小型状态空间里拿到更稳的策略和更快的收敛速度。本文面向的是手上已经跑过一遍经典 Q-learning、现在想往实用方向走一步的读者也给还没动手的人一条可以直接复现的路。2. 从经典 Q-learning 到改进版三个最常被采用的改进方向2.1 经典 Q-learning 的更新公式和两个绕不开的缺陷经典 Q-learning 的更新式是Q(s, a) ← Q(s, a) α·[r γ·maxₐQ(s, a) − Q(s, a)]这里的关键操作是那个 max。更新当前状态动作对的价值时Q-learning 直接用下一状态的最大 Q 值当作“未来回报的估计”再配合当前奖励做加权。这种自举bootstrap方式让算法可以单步更新、不需要等到回合结束这是它高效的原因也是它出问题的根源。第一个缺陷是过估计。因为 max 是对 Q 表的估计值取最大值而估计本身有噪声max 操作会把正噪声挑出来导致 Q 值系统性偏高。在小状态空间里这个偏差不明显一旦状态动作对变多、样本变稀疏某些状态明明很危险Q 值却虚高不下。最典型的表现就是智能体在悬崖边反复试探它认为走下去的价值很高实际上一步就掉下去。第二个缺陷是样本利用率低。每个 transitions, a, r, s用完一次就被丢弃下一轮重新采样。如果环境随机性强或者探索策略用了高 epsilon大量样本花在重复体验上收敛速度自然慢。经典 Q-learning 在小网格里能忍因为一次完整训练也就几百步换到大一点的状态空间这个缺陷直接决定项目做不做得下去。改进版的核心思路就是分别针对这两个缺陷动刀同时不改动 Q-learning “表格驱动、逐步逼近最优策略”的基本框架。2.2 改进方向选型Double Q、n 步回报、经验回放为什么是标配三个改进方向里Double Q 治过估计经验回放治样本利用率n 步回报治自举偏差和高方差。它们各自独立又能组合使用。Double Q 的原理是维护两张独立的 Q 表Q₁ 和 Q₂。选动作时用两张表的和更新时则用其中一张表选出最优动作、用另一张表计算这个动作的价值。因为两张表用不同的样本子集更新它们的噪声方向不同选动作和估值这两个操作被拆开后过估计就被大幅削弱。这个改动在表格型实现里只需要多开一个 dict代价很小收益却很直接。经验回放是拿一个固定容量的缓冲队列存最近的 transition训练时从队列里随机抽一个小批量来更新而不是来一条就更新一条。这么做有两个好处一是同一批样本能被反复利用学习效率上去了二是打乱了样本的时间相关性训练过程更平稳。对小规模的表格型 Q-learning经验回放不是必需项但配上它之后同样一批 episode 能学到的东西明显更多。n 步回报则是在计算目标值时不再只往后看一步而是往后累积 n 步的真实奖励然后再在 n 步之后的位置做自举。这样既保留了自举的收敛速度又把一部分未来回报变成真实观测减小了偏差。n 越大信息越接近蒙特卡洛方差升高但偏差降低n 太小则和一步 Q-learning 差别不大。2.3 表格型环境里怎么选改进版适合什么状态空间如果你的状态空间是离散的、规模在几万以内比如网格世界、简单的库存控制、某个离散动作的调度问题用表格型改进版是最好的选择不用引入神经网络CPU 就能跑训练时间以秒计。改进版 Q-learning 在你已经跑通经典 Q-learning 的基础上改动量不超过几十行就能看到策略稳定性上一个台阶。状态空间大到表格装不下比如图像输入或者连续状态这时候改进版会作为 DQN 家族的思想基础把表格换成神经网络。但那是另一套工程体系需要目标网络、梯度下降、损失函数这些组件。本文的三招改进在深度版本里同样成立只是在表格型环境里更容易把每个开关的贡献看清楚。所以我建议先把表格型改进版吃透再去碰深度版本。3. 手写一个带开关的四合一改进版代码与参数怎么调3.1 悬崖行走环境与训练主流程为了验证改进效果需要一个过估计症状明显的环境。经典案例是悬崖行走一个 4 行 12 列的网格左下角是起点右下角是终点底部中间一段是悬崖。走一步正常格子奖励 −1掉进悬崖奖励 −100 并回到起点到达终点奖励 10 结算回合。因为悬崖边是最近路径过估计严重的智能体会倾向于贴着悬崖走然后频繁掉下去而 Double Q 的智能体则会更早学会绕远路保平安。我不用现成的第三方环境库方便你直接复制运行。下面这个类是完整可跑的环境代码只依赖 Python 标准库。import random from collections import deque class CliffWalkingEnv: def __init__(self, rows4, cols12, cliff_start1, cliff_end11, seedNone): self.rows rows self.cols cols self.cliff_start cliff_start self.cliff_end cliff_end self.action_space [0, 1, 2, 3] # 上、下、左、右 self.reset() if seed is not None: random.seed(seed) def reset(self): # 起点在左下角终点在右下角 self.state (self.rows - 1, 0) return self.state def is_cliff(self, row, col): return row self.rows - 1 and self.cliff_start col self.cliff_end def step(self, action): row, col self.state if action 0: # 上 row max(row - 1, 0) elif action 1: # 下 row min(row 1, self.rows - 1) elif action 2: # 左 col max(col - 1, 0) elif action 3: # 右 col min(col 1, self.cols - 1) next_state (row, col) if self.is_cliff(row, col): reward -100.0 done True next_state self.reset() elif next_state (self.rows - 1, self.cols - 1): reward 10.0 done True else: reward -1.0 done False self.state next_state return next_state, reward, done这段代码的要点是把“越界不动”和“掉悬崖回起点”两种语义区分开。很多人写网格环境时把越界和悬崖混为一谈导致智能体找不到边界信息Q 表学出来是乱的。奖励设置上终点给 10 而普通步给 −1是为了让智能体必须在“尽快”和“活着”之间权衡如果终点奖励给得太大智能体反而会忽略每一步的代价改进前后差异不明显。3.2 改进版 Q-learning 的完整实现Double Q、n 步回报与经验回放我把三个改进做成开关默认全开也提供一键关闭回到经典 Q-learning 的配置。核心是 QLearningAgent 类代码约一百行逻辑上每个开关独立方便你单独观察效果。class QLearningAgent: def __init__(self, env, lr0.1, gamma0.9, epsilon0.1, n_step5, buffer_size10000, batch_size32, use_double_qTrue, use_replayTrue, use_n_stepTrue, seedNone): self.env env self.lr lr self.gamma gamma self.epsilon epsilon self.n_step n_step self.buffer_size buffer_size self.batch_size batch_size self.use_double_q use_double_q self.use_replay use_replay self.use_n_step use_n_step # 经典 Q-learning 只用 q1Double Q 开两个表 self.q1 {} self.q2 {} self.buffer deque(maxlenbuffer_size) self.window deque(maxlenn_step) # n 步滑动窗口 if seed is not None: random.seed(seed) def get_q(self, state, use_q2False): q self.q2 if use_q2 else self.q1 if state not in q: q[state] {a: 0.0 for a in self.env.action_space} return q[state] def choose_action(self, state): if random.random() self.epsilon: return random.choice(self.env.action_space) # 选动作时看两张表的和避免单表偏向 q_sum {} for a in self.env.action_space: q_sum[a] self.get_q(state).get(a, 0.0) self.get_q(state, True).get(a, 0.0) return max(q_sum, keyq_sum.get) def _target_q(self, next_state, use_q2False): q self.get_q(next_state, use_q2) return max(q.values()) if q else 0.0 def update_from_transition(self, state, action, g_return, next_state, done): if self.use_double_q: # 先用两张表的和选最优动作再用另一张表估值 q_sum self.get_q(next_state).get(action, 0.0) 0.0 a_star self.choose_action(next_state) _ a_star # 占位选动作交给 choose_action # Double Q 标准做法Q1 选动作Q2 估值 # 这里简化为交替更新两张表 use_q2 random.random() 0.5 if use_q2: target g_return (0 if done else self.gamma ** self.n_step * self.get_q(next_state).get(self._best_by_sum(next_state), 0.0)) q2 self.get_q(next_state, True) q2[next_state][action] q2.get(next_state, {}).get(action, 0.0) self.lr * ( target - q2.get(next_state, {}).get(action, 0.0)) else: target g_return (0 if done else self.gamma ** self.n_step * self.get_q(next_state, True).get(self._best_by_sum(next_state), 0.0)) q1 self.get_q(next_state) q1[next_state][action] q1.get(next_state, {}).get(action, 0.0) self.lr * ( target - q1.get(next_state, {}).get(action, 0.0)) else: target g_return (0 if done else self.gamma ** self.n_step * self._target_q(next_state)) q self.get_q(next_state) q[next_state][action] q.get(next_state, {}).get(action, 0.0) self.lr * ( target - q.get(next_state, {}).get(action, 0.0)) def _best_by_sum(self, state): q_sum {} for a in self.env.action_space: q_sum[a] self.get_q(state).get(a, 0.0) self.get_q(state, True).get(a, 0.0) return max(q_sum, keyq_sum.get)老实说上面这段写复杂了实际项目里我不会这么绕。这个版本的 Double Q 实现不够优雅而且 next_state 和 action 的键值混用容易让新手看懵。更好的做法是把 Q 表的结构统一成 q1[(s, a)] 和 q2[(s, a)] 这种键值对更新逻辑按标准论文走选动作用 q1q2 的和更新时随机挑一张表用另一张表给目标值。class CleanQLearningAgent: def __init__(self, env, lr0.1, gamma0.9, epsilon0.1, n_step5, buffer_size10000, batch_size32, use_double_qTrue, use_replayTrue, use_n_stepTrue, seedNone): self.env env self.lr lr self.gamma gamma self.epsilon epsilon self.n_step n_step self.buffer_size buffer_size self.batch_size batch_size self.use_double_q use_double_q self.use_replay use_replay self.use_n_step use_n_step # 统一用 q[(s, a)] 结构double 模式拆成 q1 和 q2 self.q1 {} self.q2 {} self.buffer deque(maxlenbuffer_size) self.window deque(maxlenn_step) if seed is not None: random.seed(seed) def q(self, table, s, a): key (s, a) return table.get(key, 0.0) def best_action(self, s, use_sumTrue): best_a, best_v None, float(-inf) for a in self.env.action_space: if use_sum: v self.q(self.q1, s, a) self.q(self.q2, s, a) else: v self.q(self.q1, s, a) if v best_v: best_v, best_a v, a return best_a def update_from_transition(self, s, a, g_return, s_next, done): if not self.use_double_q: target g_return if not done: best_a self.best_action(s_next, use_sumFalse) target (self.gamma ** self.n_step) * self.q(self.q1, s_next, best_a) self.q1[(s, a)] self.q(self.q1, s, a) self.lr * (target - self.q(self.q1, s, a)) else: # 随机选一张表更新另一张表提供估值 if random.random() 0.5: target g_return if not done: best_a self.best_action(s_next, use_sumTrue) target (self.gamma ** self.n_step) * self.q(self.q2, s_next, best_a) self.q1[(s, a)] self.q(self.q1, s, a) self.lr * (target - self.q(self.q1, s, a)) else: target g_return if not done: best_a self.best_action(s_next, use_sumTrue) target (self.gamma ** self.n_step) * self.q(self.q1, s_next, best_a) self.q2[(s, a)] self.q(self.q2, s, a) self.lr * (target - self.q(self.q2, s, a))逻辑说明经典 Q-learning 模式只用 q1 表目标值的自举部分用同一张表选最优动作并取它的 Q 值。Double Q 模式随机抽一张表做更新另一张表负责估值选动作始终用两张表的和这样就把“选择”和“评估”分离了。代码里 done 判断必须在加自举项之前否则回合结束后的假 Q 值会被回传这是最常见的翻车点。接着看训练循环这里包含了 n 步回报窗口的落袋逻辑。def train(env, agent, episodes500, log_every50): rewards [] for ep in range(episodes): s env.reset() agent.window.clear() ep_reward 0.0 done False while not done: a agent.choose_action(s) s_next, r, done env.step(a) ep_reward r # 把这一步压入 n 步窗口 agent.window.append((s, a, r, s_next, done)) if agent.use_n_step: # 凑满 n 步或遇到终止状态才落袋生成一条样本 if len(agent.window) agent.n_step or done: s0 agent.window[0][0] a0 agent.window[0][1] g_return 0.0 for i, (_, _, ri, si, di) in enumerate(agent.window): g_return (agent.gamma ** i) * ri terminal_idx None for i, (_, _, _, _, di) in enumerate(agent.window): if di: terminal_idx i break if terminal_idx is not None: # 窗口内有终止状态n 步回报只算到终止状态为止 g_return sum((agent.gamma ** i) * agent.window[i][2] for i in range(terminal_idx 1)) done_flag True s_last agent.window[terminal_idx][3] else: done_flag False s_last agent.window[-1][3] if agent.use_replay: agent.buffer.append((s0, a0, g_return, s_last, done_flag)) else: agent.update_from_transition(s0, a0, g_return, s_last, done_flag) # 窗口左侧弹出的那一步已被消费移除 if len(agent.window) agent.n_step: agent.window.popleft() else: agent.window.clear() else: # 经典单步模式直接更新 agent.window.clear() agent.update_from_transition(s, a, r, s_next, done) # 如果开了经验回放每次都从 buffer 采样更新 if agent.use_replay and len(agent.buffer) agent.batch_size: batch random.sample(agent.buffer, agent.batch_size) for (bs, ba, bg, bs_next, bdone) in batch: agent.update_from_transition(bs, ba, bg, bs_next, bdone) s s_next rewards.append(ep_reward) if (ep 1) % log_every 0: avg sum(rewards[-log_every:]) / log_every print(fEpisode {ep 1}, avg reward: {avg:.2f}) return rewardsn 步窗口的实现有一个容易写错的细节窗口不一定能凑满 n 步就被 done 截断这时如果仍按 n 步折扣累加会把终止状态之后的“未来”也算进来。终止态之后没有 next_state 可说自举项必须清零直接以终止前的累计回报作为目标。上面对 terminal_idx 的处理就是为了处理这个边界情况。窗口落袋后还要清除已消费的条目很多人的代码卡在无限循环就是因为窗口没有正确弹出。3.3 三个参数的调节顺序先关掉哪个、后打开哪个改进版 Q-learning 的参数比经典版多了几个调参顺序搞反了容易把锅甩给算法。我一般按下面的顺序来。第一个是学习率 lr。表格型更新下lr 取值 0.050.3 都能收敛但 lr 过大配合 Double Q 时两张表的更新波动会被放大。建议先固定 lr0.1跑通开关组合后再动手。第二个是 epsilon。训练前期需要探索后期要把 epsilon 衰减下来不能一直用同一个值。常见做法是线性衰减前 30% 的 episode 用 0.1后 70% 线性降到 0.01。第三个是 n_step。n 值从 1 开始往上涨每次加 2 观察平均回报曲线。n 太大方差会变大在悬崖行走环境里 n5 已经能看到明显收益n10 反而更抖。参数表如下方便对照检查参数建议范围主攻方向调大时的影响lr0.050.3更新步长收敛变快但波动变大epsilon0.3→0.01 衰减探索利用率过大导致 Q 表震荡gamma0.90.99长期回报权重过大导致过估计回传n_step38偏差与方差折中过大导致方差升高buffer_size500050000样本多样性过小导致样本重复batch_size1664更新稳定性过大导致更新平滑但变慢4. 改进版 Q-learning 避坑指南五条血泪经验4.1 现象Q 值一路疯涨不收敛打开训练日志发现打印的 Q 值或平均回报在一个 episode 内从几十跳到几百甚至变成 inf。这通常不是算法发散而是目标值计算里维度没对齐。最常见的原因是更新公式中把“当前奖励”和“自举项”中间少乘了 gamma或者 done 状态的自举项没有清零把终止后的无效价值也加了进来。解决方法是把目标值的计算单独抽一个函数每次迭代打印十组 target 值人工对照检查 done 分支。另一个高发原因是 gamma 设成了 1.0 但训练轮数不够长期回报的贴现值在有限步内无法收敛。把 gamma 降到 0.9 以下试一次如果 Q 值立刻稳定基本就是这个问题。4.2 现象Double Q 打开后反而变慢有些同学反馈改进版开了 Double Q收敛速度肉眼可见地变慢甚至平均回报低于经典 Q-learning。原因在于 Double Q 的本质是把更新频率减半每一条样本只更新两张表之一相当于单张表的学习率打了对折。如果环境本身的过估计不严重换来的稳定性收益就抵不过更新频率的损失。解决方法是不要盲目全开改进。先用基线配置跑经典 Q-learning观察策略是否出现“贴着悬崖走然后掉下去”的症状。如果经典版本身就很稳定说明环境奖励设计可能消除了过估计的来源比如给终点奖励足够大这时候只开 n 步回报和回放就够了Double Q 关掉反而更好。4.3 现象n 步回报在回合边界上产生坏样本训练曲线出现周期性尖峰每隔固定 episode 平均回报骤降。检查生成的样本后发现 n 步累计回报把终点后的“下一个状态”也算了进来导致一些 transition 的目标值严重偏离真实。这个坑我在复现多轮后总结出根源n 步窗口在 episode 结束时没有强制清空窗口还残留上周期的样本和新 episode 的样本混在一起。解决方法是 episode 结束后立即把 window.clear()并且对窗口内的 done 状态做显式截断。我前面训练代码里 terminal_idx 那段就是干这个的。还有一点用经验回放时存进 buffer 的样本里必须带上 done_flag采样更新时再判断一次是否要加自举项不能只看当前批次的结束状态。4.4 现象经验回放采样乱序导致训练不稳定开启经验回放后前期曲线还行中后期开始剧烈抖动。排查后发现 buffer 容量只有 200而训练持续 500 个 episode早期样本早就被冲掉了。更隐蔽的问题是采样后不加区别地更新全部状态导致最近的探索样本被反复重放旧样本完全被覆盖。解决方法是把 buffer_size 提高到 10000 以上并设置“最小采样阈值”即 buffer 里的样本数超过 batch_size 的 10 倍才开始训练避免前几十个样本反复被抽。如果内存吃紧就缩短训练总步数而不是压缩 buffer。缓冲区里的样本在采样前最好做一次全量随机打乱避免同一时刻的 transition 聚在一起被抽中。4.5 现象随机种子不同结果完全不一样同样一组参数跑三次结果差别很大有时候能收敛到最优策略有时候策略稀烂。这是典型的随机性没有被管理环境的随机性、动作选择的随机性、经验回放采样的随机性三个随机源只要有一个没固定结果就无法复现。解决方法是在程序入口统一设置种子env 和 agent 各设置一次。如果把随机种子放进参数配置里每次跑实验启动前先确认一遍。我在调参过程中发现只设了 env 的种子、忘了给 agent 的 random.seed 传参是最容易犯的低级错误。种子固定之后改进版和经典版的对比才有说服力。5. 用同一起跑线验证改进效果一条命令出对比图5.1 三行代码对比四种开关组合改进版到底值不值得用不能靠感觉要用同一环境、同一随机种子、同一 episode 数下的累计回报曲线来说话。下面这个脚本把开关组合做成字典列表循环训练收集数据最后用 matplotlib 画平滑曲线。没有装 matplotlib 的环境可以把数据存成 csv 再手动导入。import matplotlib.pyplot as plt configs [ {name: classic, use_double_q: False, use_replay: False, use_n_step: False}, {name: double, use_double_q: True, use_replay: False, use_n_step: False}, {name: doublen, use_double_q: True, use_replay: False, use_n_step: True}, {name: full, use_double_q: True, use_replay: True, use_n_step: True}, ] for cfg in configs: env CliffWalkingEnv(seed42) agent CleanQLearningAgent(env, seed42, **cfg) rewards train(env, agent, episodes500) plt.plot(rewards, labelcfg[name]) plt.legend() plt.xlabel(episode) plt.ylabel(cumulative reward) plt.savefig(compare.png)这段代码的意义在于把变量控制住环境相同、起点相同、探索策略相同唯一的变量是四个开关的组合。跑出来的曲线里classic 如果长期徘徊在负数十而 full 能稳定爬到正数说明改进有效。如果两者差不多说明当前环境对过估计不敏感不需要上 Double Q别硬加。5.2 一个从表格型迁移到深度网络时保留的改进习惯我的一个习惯是把所有算法开关集中放在参数配置里而不是分散在代码各处。每次跑实验记录下用了哪几个开关、参数多少、平均回报多少存成一张表。这样从表格型迁到深度网络时这些开关对应的目标网络、优先经验回放、多步回报权重可以直接对应过来不需要重新理解一遍。另一个习惯是每轮训练只改一个开关。不要全开直接跑除非你想看整体上限。否则一旦结果不好你根本不知道是哪个开关拖了后腿。这个习惯帮我避免了很多“调参靠算命”的局面。改进版 Q-learning 的开关组合试过一轮之后你会对自己项目的状态空间特点理解得更清楚适合 n 步回报的环境说明短期奖励信息密度高适合经验回放的环境说明样本采集成本高。这种判断力才是改进版能沉淀下来的东西。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑