
简介这份资源面向强化学习初学者与算法实践者聚焦Q学习与SARSA两种经典算法的代码实现帮助读者通过可运行脚本理解离策略与同策略更新的差异。压缩包共10个文件以8个MATLAB脚本.m为主另含1份教学演示文稿.pptx和1个参数数据文件.mat整体约337KB体积轻便便于本地调试与二次修改。脚本覆盖基础Q学习、基础SARSA、带资格迹的Q学习与SARSA、动作选择策略、收敛判断及参数配置等模块入口脚本串联主循环参数文件支持复用与结果保存。已有241人学习说明该实现具备一定参考价值。读者可借此对比两种算法的更新公式与策略行为观察学习率、折扣因子对收敛的影响并在此基础上扩展策略迭代或深度Q网络等进阶方向。1. 从一份“强化学习.rar”说起Q学习与SARSA到底怎么选很多人第一次接触强化学习都是从一份名字类似“强化学习.rar”的代码包开始的。解压之后里面大概率躺着两个文件一个跑 Q-learning一个跑 SARSA环境多半是 Cliff Walking 或者 FrozenLake。跑起来都能收敛曲线也都往上走于是问题来了——这俩到底差在哪为什么面试官总爱问“悬崖边上 Q-learning 和 SARSA 谁更怂”这个标题里的关键词其实很集中Q学习、SARSA、SARSA算法、强化学习Q算法。它们指向的是同一件事——用表格把状态-动作价值存下来通过不断试错把这张表填准。区别只在于“填表时参考的是下一步的最优动作还是下一步实际执行的动作”。这一个字的差别直接决定了智能体是激进还是保守。这篇笔记适合两类人一类是刚跑通第一个强化学习 demo、想搞清楚 Q-learning 和 SARSA 本质区别的入门者另一类是想把这套表格方法用到实际小规模决策问题比如简单调度、路径选择的工程师。我会从原理讲到代码再讲到参数怎么调、坑在哪最后给一个能直接复现的最小实现。2. Q学习与SARSA的原理分水岭on-policy 和 off-policy 到底差在哪2.1 一张表、一个贝尔曼方程两种更新方式Q学习Q-learning和SARSA都属于**时序差分Temporal DifferenceTD**方法核心都是维护一张 Q 表行是状态列是动作值表示“在状态 s 下执行动作 a之后一直按某个策略走能拿到的累计回报期望”。两者的更新公式长得几乎一样Q-learningQ(s,a) ← Q(s,a) α [ r γ · max Q(s,a) − Q(s,a) ]SARSAQ(s,a) ← Q(s,a) α [ r γ · Q(s,a) − Q(s,a) ]差别就在那个max。Q-learning 更新时用的是下一状态里价值最大的那个动作不管实际下一步走不走它。这意味着它学的是“最优策略”的价值哪怕当前行为策略还在乱撞。这就是off-policy——学习策略和行为策略可以不是同一个。SARSA 更新时用的是下一步实际执行的动作a 对应的 Q 值。它的名字就是五元组 (s, a, r, s, a) 的缩写。它学的是“当前正在执行的策略”的价值所以是on-policy。这个区别在悬崖行走Cliff Walking环境里体现得淋漓尽致。悬崖边有一条最优路径贴着走最快但一旦掉下去就扣大分。Q-learning 因为总是看 max会认为“贴着悬崖走”是最优的于是学出来的策略很激进训练过程中经常掉下去。SARSA 因为要考虑实际探索时的随机动作会意识到“贴着走万一随机一步就掉下去了”于是学出一条离悬崖远一点但更安全的路径。2.2 为什么说“Q学习学最优SARSA学安全”从数学上看Q-learning 的更新目标是r γ · max Q(s,a)这是贝尔曼最优方程的形式收敛到的是最优动作价值函数 Q*。SARSA 的更新目标是r γ · Q(s,a)其中 a 来自当前策略收敛到的是当前策略的动作价值函数 Qπ。这带来一个很实际的后果如果你用 ε-greedy 探索SARSA 会把探索带来的风险算进去。因为 a 是按 ε-greedy 选出来的有 ε 的概率选到随机动作SARSA 的 Q 值会反映这个随机性。而 Q-learning 的 max 操作直接忽略了探索的随机性它假设下一步总是选最好的。所以选型建议很直接环境风险高、探索代价大掉悬崖、撞车、超时用 SARSA 更稳。环境安全、只关心最终最优策略用 Q-learning 收敛更快更直接。如果行为策略固定且不探索比如用历史数据离线学习Q-learning 更合适。提示很多人以为 SARSA 比 Q-learning “差”其实只是目标不同。SARSA 在训练过程中的平均回报往往更高因为它学会了规避探索风险。2.3 用 Python 写一个最小可复现的 Q-learning 和 SARSA下面这段代码用同一个 Cliff Walking 环境分别跑 Q-learning 和 SARSA方便对比。环境用 OpenAI Gym 的CliffWalking-v0如果没有装 Gym可以用下面这个极简版环境替代。import numpy as np import random class CliffWalking: 4x12 网格起点左下终点右下底边中间是悬崖 def __init__(self): self.rows, self.cols 4, 12 self.start (3, 0) self.goal (3, 11) self.cliff [(3, c) for c in range(1, 11)] self.state self.start def reset(self): self.state self.start return self.state def step(self, action): r, c self.state # 0上 1右 2下 3左 if action 0: r max(r - 1, 0) elif action 1: c min(c 1, self.cols - 1) elif action 2: r min(r 1, self.rows - 1) elif action 3: c max(c - 1, 0) self.state (r, c) if self.state in self.cliff: return self.start, -100, False # 掉悬崖回起点 if self.state self.goal: return self.state, -1, True return self.state, -1, False这段环境代码定义了状态转移和奖励每走一步 -1掉悬崖 -100 并回到起点到终点结束。状态是 (行, 列) 元组动作是 0 到 3 四个方向。接下来是 Q-learning 和 SARSA 的训练循环def q_learning(env, episodes500, alpha0.5, gamma0.99, epsilon0.1): Q {} for _ in range(episodes): s env.reset() done False while not done: if s not in Q: Q[s] np.zeros(4) # ε-greedy 选动作 if random.random() epsilon: a random.randint(0, 3) else: a int(np.argmax(Q[s])) s_next, r, done env.step(a) if s_next not in Q: Q[s_next] np.zeros(4) # Q-learning 更新用 max td_target r gamma * np.max(Q[s_next]) * (not done) Q[s][a] alpha * (td_target - Q[s][a]) s s_next return Q def sarsa(env, episodes500, alpha0.5, gamma0.99, epsilon0.1): Q {} for _ in range(episodes): s env.reset() if s not in Q: Q[s] np.zeros(4) # 先选一个动作 if random.random() epsilon: a random.randint(0, 3) else: a int(np.argmax(Q[s])) done False while not done: s_next, r, done env.step(a) if s_next not in Q: Q[s_next] np.zeros(4) # 再选下一步实际要执行的动作 a if random.random() epsilon: a_next random.randint(0, 3) else: a_next int(np.argmax(Q[s_next])) # SARSA 更新用 Q(s, a) td_target r gamma * Q[s_next][a_next] * (not done) Q[s][a] alpha * (td_target - Q[s][a]) s, a s_next, a_next return Q关键区别在更新那一行Q-learning 用np.max(Q[s_next])SARSA 用Q[s_next][a_next]。注意 SARSA 在循环外先选一次动作循环内更新完再选下一步动作保证 (s, a, r, s, a) 五元组完整。参数说明alpha学习率0.1 到 0.5 之间比较稳太大震荡太小收敛慢。gamma折扣因子0.9 到 0.99越接近 1 越看重长期回报。epsilon探索率0.1 是常用起点可以随训练衰减。episodesCliff Walking 这种小环境 500 到 1000 轮足够。跑完之后可以打印策略你会看到 Q-learning 学出的路径贴着悬崖SARSA 学出的路径离悬崖一格。这就是 on-policy 和 off-policy 最直观的差别。3. 把Q学习和SARSA用到实际问题的参数调优与收敛判断3.1 学习率、折扣因子、探索率怎么设才不翻车表格型 Q 学习和 SARSA 的参数不多但每一个都能让训练结果天差地别。我一般按下面的顺序调学习率 alpha先试 0.1。如果回报曲线抖动厉害降到 0.05如果几百轮都不动升到 0.3 或 0.5。Cliff Walking 这种确定性环境0.5 也能收敛但随机性强的环境建议不超过 0.2。折扣因子 gamma0.9 是安全值。如果任务步数多、需要长远规划用 0.99。gamma 太接近 1 会让 Q 值膨胀配合大学习率容易发散。一个经验法则是gamma和alpha不要同时取大。探索率 epsilon固定 0.1 能跑但更好的做法是衰减。比如从 1.0 开始每轮乘 0.995最低降到 0.01。这样前期充分探索后期稳定利用。SARSA 对 epsilon 更敏感因为它的更新直接依赖实际动作epsilon 太大时学到的策略会过于保守。初始 Q 值全零就行。有些实现用乐观初始化比如全设成 1能鼓励早期探索但在 Cliff Walking 里容易让智能体一开始就冲悬崖。下面是一个带 epsilon 衰减的训练封装def train(env, algoq, episodes1000, alpha0.1, gamma0.99, eps_start1.0, eps_end0.01, eps_decay0.995): Q {} epsilon eps_start for ep in range(episodes): s env.reset() if s not in Q: Q[s] np.zeros(4) if algo sarsa: a (random.randint(0, 3) if random.random() epsilon else int(np.argmax(Q[s]))) done False while not done: if algo q: a (random.randint(0, 3) if random.random() epsilon else int(np.argmax(Q[s]))) s_next, r, done env.step(a) if s_next not in Q: Q[s_next] np.zeros(4) if algo q: target r gamma * np.max(Q[s_next]) * (not done) Q[s][a] alpha * (target - Q[s][a]) s s_next else: a_next (random.randint(0, 3) if random.random() epsilon else int(np.argmax(Q[s_next]))) target r gamma * Q[s_next][a_next] * (not done) Q[s][a] alpha * (target - Q[s][a]) s, a s_next, a_next epsilon max(eps_end, epsilon * eps_decay) return Q这段代码把两种算法合并到一个函数里用algo参数切换。注意 SARSA 分支里动作选择在循环内完成Q-learning 分支在循环开头选。epsilon 每轮衰减一次保证探索逐步减少。3.2 怎么判断收敛了三个可观测信号表格型方法的收敛判断比深度强化学习简单得多但也不能只看回报曲线。我一般同时看三个信号信号一Q 值变化量。每轮结束后统计所有状态-动作对的 Q 值变化绝对值之和如果连续几十轮都小于 1e-3说明表基本稳定了。信号二策略稳定性。把当前 Q 表对应的贪心策略提取出来如果连续多轮策略完全不变说明已经收敛到某个固定策略。信号三平均回报。滑动窗口比如最近 50 轮的平均回报不再上升且方差变小。注意 SARSA 的平均回报可能比 Q-learning 高但最终贪心策略的回报可能略低这是正常的。def check_convergence(Q, prev_Q, threshold1e-3): 比较两次 Q 表的差异 if prev_Q is None: return False diff 0.0 for s in Q: diff np.abs(Q[s] - prev_Q[s]).sum() return diff threshold这个函数在每轮结束后调用传入当前 Q 表和上一轮的副本。如果返回 True 并且持续几轮就可以停了。实际用的时候我会把 Q 表深拷贝一份存下来避免引用问题。注意不要用单轮回报判断收敛Cliff Walking 里掉一次悬崖回报就掉一大截单轮波动很大。一定要用滑动平均。3.3 从表格到函数逼近什么时候该换方法Q 学习和 SARSA 的表格版本有个硬限制状态和动作必须是离散且有限的。Cliff Walking 只有 48 个状态一张表轻松放下。但如果你要处理的状态有几千几万个或者状态是连续值比如位置、速度表格就爆了。这时候常见的做法是换函数逼近用神经网络来拟合 Q(s,a)。这就是 DQNDeep Q-Network的思路。但要注意DQN 对应的是 Q-learning 的 off-policy 路线而 SARSA 的 on-policy 版本对应的是类似 SARSA(λ) 或者 Actor-Critic 里的某些形式。判断标准很简单状态数 10^4动作数 100表格方法够用调试也简单。状态连续或状态数巨大上函数逼近。如果环境风险高又想用函数逼近可以考虑 Expected SARSA它用期望代替实际动作方差更小。我一般会先用表格版本在小规模问题上验证逻辑确认奖励设计和超参没问题再迁移到神经网络版本。这样出问题的时候能快速定位是算法逻辑错了还是网络训练的问题。4. 避坑与排查Q学习和SARSA训练中最容易翻车的五个地方4.1 现象Q 值越来越大最后变成 inf 或 nan原因学习率太大、折扣因子太接近 1、奖励没有归一化三者叠加导致 TD 目标不断放大。Q-learning 的 max 操作会加剧这个问题因为它总是选最大的那个值来更新。解决先把 alpha 降到 0.1 以下gamma 降到 0.95。如果奖励范围很大比如 -100 到 100把它缩放到 -1 到 1。另外检查终止状态的更新done为 True 时不应该再加gamma * max Q代码里要用(not done)乘一下。4.2 现象SARSA 学出来的策略特别怂绕远路原因epsilon 太大或者衰减太慢SARSA 把探索的随机性算进了 Q 值导致它认为“靠近悬崖的动作期望回报很低”。这其实是 SARSA 的特性不一定是 bug。解决如果确实想要更激进的策略把 epsilon 调小比如 0.05或者衰减更快。但要想清楚你是在训练阶段还是部署阶段训练时 SARSA 保守是好事部署时可以把 epsilon 设成 0用贪心策略执行。4.3 现象Q-learning 训练过程中频繁掉悬崖回报曲线剧烈震荡原因Q-learning 的 off-policy 特性决定了它学的是最优策略而行为策略还在探索。它认为贴着悬崖走最优但探索时随机一步就掉下去了。解决这是正常现象不用慌。可以调小 epsilon 减少随机探索或者用 SARSA 替代。如果坚持用 Q-learning看最终收敛后的贪心策略回报而不是训练过程中的平均回报。4.4 现象训练了几千轮Q 表还是全零或者几乎不变原因最常见的是状态表示有问题。比如状态是 numpy 数组直接当字典键会报错或者每次都是新键。另外检查奖励是不是一直是零或者done标志没正确设置导致每轮只走一步就结束。解决把状态转成可哈希的元组或字符串。打印几轮的状态、动作、奖励确认数据流是通的。检查环境的step返回值顺序是不是(next_state, reward, done)有些环境是(next_state, reward, done, info)别搞混。4.5 现象Q-learning 和 SARSA 代码几乎一样但结果差很多原因除了更新公式的 max 和实际动作的区别还要检查 SARSA 的动作选择时机。SARSA 必须在更新前就选好下一步动作 a并且这个 a 要实际执行。如果写成先更新再选动作或者选完动作不执行那就退化成 Q-learning 或者更糟。解决对照五元组 (s, a, r, s, a) 的顺序检查代码。SARSA 的循环结构应该是选 a → 执行得 r, s → 选 a → 更新 Q(s,a) → ss, aa。Q-learning 则是选 a → 执行得 r, s → 更新 Q(s,a) → ss。这个顺序不能乱。5. 进阶技巧用 Expected SARSA 折中以及一个验证收敛的小工具如果你觉得 Q-learning 太激进、SARSA 太保守Expected SARSA 是一个很好的折中。它更新时不用 max也不用实际动作而是用当前策略下所有动作的期望值Q(s,a) ← Q(s,a) α [ r γ · Σ π(a|s) Q(s,a) − Q(s,a) ]在 ε-greedy 策略下这个期望可以写成(1-ε) · max Q(s,a) ε · mean Q(s,a)这样既考虑了探索的随机性又不像 SARSA 那样完全依赖单次采样方差更小。代码改动很小def expected_sarsa_update(Q, s, a, r, s_next, done, alpha, gamma, epsilon): if done: target r else: q_next Q[s_next] n_actions len(q_next) # ε-greedy 下的期望值 greedy np.max(q_next) mean_q np.mean(q_next) expected (1 - epsilon) * greedy epsilon * mean_q target r gamma * expected Q[s][a] alpha * (target - Q[s][a])这个更新比 SARSA 稳定比 Q-learning 保守适合那些“既想学最优又怕探索翻车”的场景。我在做资源调度的小规模实验时经常用它作为默认选择。最后分享一个验证收敛的小习惯把每轮的 Q 表快照存下来训练结束后挑几个关键状态打印它们的 Q 值变化曲线。如果曲线在某个值附近震荡而不是持续上升或下降基本就是收敛了。这个习惯帮我省了很多“以为没收敛其实早收敛了”的后悔药。我自己的教训是不要一上来就调参先把环境、状态表示、更新公式这三样检查三遍。大部分翻车都不是算法问题而是状态没对齐或者 done 标志写错了。希望帮到你。本文还有配套的精品资源点击获取