
简介面向强化学习初学者与课程实验者的Python Q Learning实现包围绕智能体如何通过与环境交互构建并更新Q表展开帮助读者理解在未知状态与动作空间下逐步优化策略的基本流程。压缩包共5个文件以py智能体脚本为主辅以pdf实验报告、md说明文档及license许可文件等整体约852KB体量轻便便于本地阅读与复现实验。已有1111人学习下载可作为入门练习的参考材料。读者可重点查看Q表初始化、epsilon-greedy动作选择、学习率与折扣因子设置、奖励回传及episode终止条件等实现细节并结合报告和说明理解参数调整对收敛效果的影响对离散状态表示、探索与利用平衡以及CartPole等经典环境的Q Learning实验也能形成较直观的代码对照。若关注Q函数更新中的时序差分目标、max Q(s,a)取值和新状态回写顺序也可从脚本与报告中找到对应线索。1. Q 表收敛了但策略还在抖一张表凭什么能替代策略搜索很多人第一次用 Python 写 Q-Learning跑完几千回合看到平均回报曲线一路向上就以为大功告成。等到把 ε 关掉做一次纯贪心测试智能体却在终点前一格来回横跳甚至原地转圈。这不是代码写错了而是“Q 表收敛”和“策略可用”之间隔着一层容易被忽略的机制Q 值逼近的是贝尔曼最优解而行为策略是另一个被 ε、采样频率和初始化偏差污染过的函数。搞懂这层差别你才会明白 off-policy 的强化学习为什么能把“学”和“走”拆成两件事也才会知道那张表里每个数字到底代表什么。下面从一次最朴素的时序差分更新开始把 Q-Learning 从公式一路拆到能跑的实现。2. Q 值的本质一次 TD 更新到底在逼近什么2.1 贝尔曼最优方程Q(s,a) 是一个不动点不是一条规则把 Q-Learning 当成“记住哪步得分高”的经验规则是最常见、也最容易让人走偏的理解。严格地说Q(s,a) 是这样一个期望在状态 s 执行动作 a 之后按最优策略继续走下去所能拿到的折扣累计回报。写成递归形式就是Q*(s,a) E[ r(s,a) γ · max Q*(s,a) ]这个式子里有三层信息。r(s,a) 是即时回报代表“这一步立刻拿到什么”γ 是折扣因子控制未来的权重γ0 时智能体变成彻底短视的贪心鬼γ 越接近 1 越看重长远max 是对下一个状态所有动作取最大值它表达的是“从 s 开始我会选最好的那一步”。正是这个 max把原本需要在整个策略空间里搜索的问题压缩成了一个数值逼近问题。这才是“一张表能替代策略搜索”的真正原因。策略 π 是从状态到动作的映射它的搜索空间大小是 |A|^|S|网格稍微大一点就爆炸。而 Q 表只有 |S|×|A| 个实数从 Q 到策略的转换是确定的每个状态取 argmax 就行不需要任何搜索。值函数把所有关于“未来怎么走”的信息压缩进了当前这一步的数值里策略退化成了一次查表加比较。代价也很明确这张表要能列出来就要求状态空间离散、可枚举、规模可控。状态一旦是连续量比如位置坐标、传感器读数表就装不下了只能换成函数逼近那是另一个话题。提示因为 max 是非线性的贝尔曼最优方程不能像线性方程组那样直接求解。它是一个压缩映射的不动点折扣因子 γ1 保证算子以 γ 的比例收缩所以反复迭代必然收敛到唯一解——这就是值迭代的数学依据也是后面所有采样版本更新的合法性来源。2.2 时序差分为什么不等回合结束再算总账最直觉的做法是蒙特卡洛跑完一整回合把从 t 时刻起的真实回报 G_t r_t γr_{t1} γ²r_{t2} … 算出来拿它当目标去修正 Q(s,a)。这个估计是无偏的因为用的是真实发生的回报。问题是方差大得离谱——一局游戏里任何一次随机探索、任何一个偶然的分岔都会完整地反映到 G_t 里导致同一个 (s,a) 在不同回合的目标值可能差出好几倍。时序差分换了个思路不等回合结束走一步就更新目标值用 r γ·max Q(s,·) 近似。这个目标里混进了当前自己的估计值属于“自举”bootstrapping因此是有偏的但方差小得多因为只用了一步真实采样。这是个典型的偏差-方差权衡蒙特卡洛无偏但吵TD 有偏但稳而且能在线更新甚至能用在没有终止状态的持续任务上。Q-Learning 的更新式就一行import numpy as np def td_update(Q, s, a, r, s_next, done, alpha0.1, gamma0.99): 单步 Q-Learning 更新返回 (target, td_error) 便于观察收敛情况。 Q : shape(n_states, n_actions) 的 Q 表 s : 当前状态索引整数 a : 当前动作索引整数 r : 执行 a 后拿到的即时回报 s_next : 转移后的状态索引 done : 布尔值True 表示 s_next 是真正的终止状态没有未来 alpha : 学习率控制单次更新把 Q 往目标推多远 gamma : 折扣因子控制未来回报的权重 # 终止状态不能自举s_next 之后没有动作可选未来价值恒为 0 target r if done else r gamma * np.max(Q[s_next]) td_error target - Q[s, a] # TD 误差正值表示这一步比预期好 Q[s, a] alpha * td_error # 按学习率朝目标挪一步 return target, td_error三个参数的物理含义值得掰开说。alpha 是步长它决定了你在多大程度上相信“这一次的新证据”设成 1 就是完全覆盖旧值等价于把 Q 当查表用设成 0.1 则是滑动平均旧经验保留九成。在随机环境里alpha 太大表现为 Q 值来回震荡收敛不了太小表现为训练几万回合还在爬坡。gamma 是视野它和“每步惩罚”存在等价关系每步 -1 的惩罚配上 γ0.99差不多等价于每步 -0.01 加 γ1前者让智能体自发追求更短路径。td_error 则是排查问题的第一手指标它的绝对值如果在训练后期还不下降说明目标本身在漂移八成是环境回报没归一化或者 alpha 太激进。2.3 off-policy 的分水岭max 和“下一步实际动作”差在哪有个只差一行的算法叫 SARSA目标值是 r γ·Q[s, a]其中 a 是按当前 ε-greedy 策略真正选出来的下一个动作。Q-Learning 则是 r γ·max Q[s,·]直接对下一状态取最大完全不关心行为策略实际会选什么。这一行差别决定了两者的性格。SARSA 学的是“我当前这个带探索的策略到底值多少”属于 on-policyQ-Learning 学的是“不管我怎么乱走最优策略值多少”属于 off-policy。经典的悬崖行走场景里这个区别会非常直观Q-Learning 会贴着悬崖边缘走最短路径因为它默认下一步一定选最优动作SARSA 会绕远一点因为它知道自己有 ε 的概率随机乱踩贴着悬崖走迟早掉下去。维度Q-LearningSARSA目标值r γ·max Q(s,·)r γ·Q(s,a)策略关系off-policy行为与目标分离on-policy两者绑定探索期表现激进收敛到最优路径保守学会规避探索风险数据复用可用历史数据反复训练数据分布必须匹配当前策略收敛条件所有 (s,a) 无限次访问 步长衰减同左且策略需收敛off-policy 这个性质是 Q-Learning 最值钱的地方你可以用一份乱七八糟的旧数据、用带大量随机探索的行为策略去采样本学出来的仍然是贪心最优解。经验回放这类技术能成立根子就在这里。但反过来说正因为目标策略和行为策略不一致训练早期的 Q 值是在“用还没学会的估计去指导更早的估计”误差会被 max 算子放大这也是后面要聊的过估计问题的来源。3. 探索与利用ε-greedy 的衰减曲线为什么能决定成败3.1 ε-greedy 采的到底是什么分布ε-greedy 常被描述成“以 ε 的概率随机以 1-ε 的概率选最优”这个描述漏掉了一个要命的细节随机那一支是均匀分布在所有动作上的所以每个非最优动作被选中的概率其实是 ε/|A|而最优动作被选中的概率是 1-εε/|A|。这意味着即使 ε 很小最优动作也会被偶尔“误伤”反过来说早期当 Q 值全是 0 的时候argmax 会永远返回索引 0智能体实际上是在“固定动作 0 均匀随机”之间切换。这个平局问题在真实代码里非常常见。全零初始化的 Q 表第一次 argmax 返回 0 是必然的如果环境恰好对动作 0 有微弱偏好智能体会过早锁定它把其他动作饿死。解决办法是平局时随机打破import random import numpy as np def select_action(Q, s, eps, rng): ε-greedy 动作选择平局时随机打破避免固定偏向低索引动作。 if rng.random() eps: return rng.randrange(Q.shape[1]) # 探索所有动作等概率 q_row Q[s] best_idx np.flatnonzero(q_row q_row.max()) # 找出所有并列最大 return int(rng.choice(best_idx)) # 在并列项里随机挑一个rng 建议独立于环境的随机源。用一个显式传入的 random.Random(seed) 实例比直接用全局 random 好调试得多——环境内部的随机数消耗次数会因为动作序列不同而不同共用一个随机源会让“换个种子再跑一遍”变成玄学。注意如果 Q 表用乐观初始化全部设成较小的正值比如 0.1可以在训练早期制造“没去过的动作看起来都不错”的效果主动驱使智能体遍历所有动作。这是一种不依赖 ε 的探索手段缺点是 q_row.max() 在未访问状态上的含义变了队友读到代码时容易误解。3.2 ε 衰减太快和太慢是两种翻车方式ε 的衰减节奏直接决定样本分布。衰减太快的典型症状是前两百回合回报涨得飞快之后彻底躺平最后停在一条明显绕远的路径上——因为它还没来得及访问其他动作探索就被关掉了。衰减太慢则是另一种折磨回报曲线长期在一个不高的水平上原地抖动因为就算学到了最优策略也有大量步数被随机动作浪费掉平均回报看起来永远上不去。一个可用的判断标准不是“跑多少回合”而是“每个 (s,a) 被访问了多少次”。TD 更新需要一个状态-动作对反复被采样Q 值才可能从初始值逼近真值经验上每个被访问到的 (s,a) 至少要累积几十次更新衰减才有意义。可以先跑一轮纯随机策略统计状态访问频次再反推 ε 该在多少回合内降到底。两种常用的衰减写法import math def epsilon_schedule(ep, total_episodes, eps_start1.0, eps_end0.05, modelinear): 返回第 ep 回合使用的 epsilon。 linear : 前 80% 的回合线性降到 eps_end之后保持不变 exp : 指数衰减时间常数取总回合数的 1/10前期降得快、后期收得稳 if mode linear: ratio min(1.0, ep / (0.8 * total_episodes)) return eps_start (eps_end - eps_start) * ratio tau 0.1 * total_episodes # 时间常数控制衰减陡峭程度 return eps_end (eps_start - eps_end) * math.exp(-ep / tau)线性衰减的可预测性好参数含义直白适合作为基线指数衰减前期降得快能更快把预算集中到利用上但对 tau 这个参数比较敏感换个环境就得重新调。不管用哪种衰减都不该降到 0保留一个 0.01~0.05 的下限能让智能体持续采样对环境里偶然出现的概率性转移保持一点鲁棒性。另外衰减是按回合还是按步数推进这个选择也会影响结果。按回合更常用因为回合数直接对应“策略更新了多少轮”按步数推进在长回合环境里更平滑但会让训练总时长难以预估。我一般先按回合走等发现不同回合长度差异极大时比如有些回合十步结束有些走满两百步上限才换成按步。4. 主循环的实现细节Q 表的数据结构与更新顺序怎么定4.1 Q 表用 ndarray 还是 dict这个选择在代码里只体现为几行但它决定了状态规模的上限。ndarray 是稠密表示靠整数索引直接寻址连续内存取一行做 max 是一次向量化操作速度快得离谱代价是必须能给出状态总数网格 10×10×4 方向就是 400 行还行状态一旦带组合维度就撑不住了。dict 用元组做键状态稀疏时内存友好可以边跑边生成状态但取某个状态的 max 需要遍历所有动作默认值语义也得自己定义——未访问状态返回什么这是乐观初始化还是悲观初始化全在你怎么写 get。表示方式单次查询取 max内存适用规模ndarrayO(1) 索引缓存友好整行向量化固定状态可枚举行数万级以内dict tuple 键O(1) 哈希有常数开销需遍历动作随访问增长状态动态生成、稀疏dict 字符串键较慢需哈希字符串需遍历动作且要处理缺失随访问增长不推荐仅用于调试打印真正需要警惕的是把浮点数直接当键。连续状态不离散化就塞进 dict会导致每个状态几乎都是全新的——状态表无限膨胀而每个条目只被更新过一两次学不到任何东西。这种“训练看起来在跑日志一直在涨模型却什么都不会”的现象十有八九是键设计的问题。4.2 主循环的执行顺序更新用的必须是 s_next不是“下一步动作”Q-Learning 主循环的结构极其简单但顺序错了就会悄无声息地变成别的算法。正确的时序是观察 s → 选动作 a → 执行得到 (s_next, r, done) → 用 s_next 的 max 更新 Q(s,a) → 把 s 推进到 s_next → 判断回合是否结束。这里有三个易错点更新时如果先执行了 s s_next就会用新状态去更新新状态等于自己更新自己如果把行为策略选出的下一个动作传给更新函数那就变成了 SARSA如果在 done 之后还继续自举终止状态的 Q 值会被错误地抬高。一个完整可跑的骨架大概长这样import random import numpy as np class QLearningAgent: def __init__(self, n_states, n_actions, alpha0.1, gamma0.99, eps_start1.0, eps_end0.05, eps_decay0.995, seed0): self.Q np.zeros((n_states, n_actions)) self.n_actions n_actions self.alpha, self.gamma alpha, gamma self.eps, self.eps_end, self.eps_decay eps_start, eps_end, eps_decay self.rng random.Random(seed) # 独立随机源与环境随机数解耦 def act(self, s): if self.rng.random() self.eps: return self.rng.randrange(self.n_actions) q_row self.Q[s] return int(self.rng.choice(np.flatnonzero(q_row q_row.max()))) def learn(self, s, a, r, s_next, done): # 关键只看 s_next 的最大 Q 值不看行为策略实际会选什么 target r if done else r self.gamma * np.max(self.Q[s_next]) self.Q[s, a] self.alpha * (target - self.Q[s, a]) def train(env, agent, episodes3000, max_steps200, log_every200): history [] for ep in range(episodes): s env.reset() ep_return, steps, done 0.0, 0, False for _ in range(max_steps): # 硬上限防止策略卡死时回合永不结束 a agent.act(s) s_next, r, done env.step(a) agent.learn(s, a, r, s_next, done) # 先更新再推进状态 s s_next ep_return r steps 1 if done: break agent.eps max(agent.eps_end, agent.eps * agent.eps_decay) history.append((ep, ep_return, steps, agent.eps)) if ep % log_every 0: print(fep{ep:5d} return{ep_return:7.2f} steps{steps:3d} eps{agent.eps:.3f}) return history几个细节值得单独拎出来。max_steps 不是可选项是必需品一旦策略进入循环回合永远等不到 done训练会挂死在第一局日志一片空白很容易被误判成死循环 bug。ep_return 的累加位置要在 step 之后、break 之前否则最后一步的回报会丢。ε 的衰减放在回合循环里、而不是内层步循环里这样 ε 和“策略被更新了多少轮”对齐而不是和“走了多少步”对齐长回合环境的训练会更稳定。4.3 done、terminated 与 truncated终止状态到底能不能自举简单的环境只返回一个 done事情就简单done 为真目标值就是 r不自举。但现代的环境接口往往把终止拆成两种情况一种是真正的终止掉进陷阱、到达终点另一种是超时截断步数达到上限任务本身没结束。这两者在数学上完全不同——真正的终止状态没有未来价值为 0截断状态只是“我们不想再算了”它的未来价值依然存在。把这个区别揉在一起的后果是系统性的低估智能体在临近步数上限时的状态价值会被错误地压成即时回报学出来的策略在长任务上偏保守。正确写法是按 terminated 判断自举而不是按 done# terminatedTrue : 真正的终止状态未来价值为 0目标值只取 r # terminatedFalse : 包括 truncated 的情况s_next 仍有未来必须自举 target r if terminated else r gamma * np.max(Q[s_next])如果环境的接口只给了一个合并的 done那就得在读取时手动还原一般约定是 done 为真且步数没到上限才是真终止。这个判断写错训练曲线看起来只是“稍微低一点”不会有任何报错排查起来相当费劲。5. 避坑与排查五个让 Q 表白训的现场5.1 回报曲线一路涨关掉 ε 却原地打转现象训练日志里平均回报从 -50 涨到 -3看起来收敛得不错把 ε 设成 0 做一次评估智能体在终点前一格反复横跳或者干脆往反方向走。原因通常是二选一要么 Q 值还没收敛只是 ε 的持续随机动作偶尔帮它蒙到了终点平均回报被“蒙对”拉高了要么 argmax 遇到了大量并列最大值的状态平局处理不当智能体在两个动作间来回摇摆。解决分两步走先把 ε 固定为 0、把训练好的 Q 表原样拿来评估看评估回报和训练回报的差距——差得多就是探索在帮忙作弊得延长训练或放慢衰减再看看振荡位置对应的 Q 行如果两个动作的值只差 1e-6那是没收敛够如果值完全相等那就是平局问题补上随机打破平局的那几行。5.2 Q 值越训越大最后变成 inf 或者彻底失真现象打印 Q 表的最大值从个位数一路涨到几千几万训练后期回报剧烈震荡。原因基本绕不开三个gamma 接近或等于 1 又没有真实终止状态导致自举无限累加学习率 alpha 太大单次更新把估计推过头形成正反馈奖励量级没有归一化一步 1000 的奖励配 alpha0.5一次更新就把整张表带偏。解决先确认环境确实会返回终止且自举判断用的是 terminated 而不是被吞掉的 done把 alpha 降到 0.05 以下重跑一轮看是否还发散把奖励统一缩放到 [-1, 1] 区间终点 1、陷阱 -1、每步 -0.01 是个很稳的基线配置。5.3 智能体学会了绕圈回合永远不结束现象训练日志里 steps 一直贴在 max_steps 上限回报没涨反而更负智能体在几个格子间循环。原因是状态表示里丢了关键信息——比如环境有 4 个朝向但状态索引只用了位置没用朝向智能体就分不清“面朝哪”学不到转弯的动作价值另一种情况是每步有小额正奖励绕圈本身能刷分最优策略就变成了兜圈子。解决检查状态编码是否把影响转移的所有变量都包含进去了用“同一状态同一动作是否必然得到同一结果”做验证把每步奖励改成负数逼它缩短路径保留 max_steps 作为兜底但把它当成报警信号而不是解决方案。5.4 改了一行超参结果完全不可复现现象同 seed 跑两次结果不同改个日志频率甚至都能影响最终策略。原因是随机源耦合环境内部用全局 random智能体也用全局 random动作序列一变两边的随机数消耗顺序就全乱了numpy 的全局随机状态也是同理。解决把所有随机性显式收口给环境和智能体各传一个独立的随机源实例所有采样都走这个实例在训练开始处显式设定种子把 Q 表的初始化和环境的初始状态也纳入同一套种子管理。5.5 状态用浮点数当键表越训越大却什么都没学到现象跑了十万步Q 表的条目数逼近十万每个条目只被更新过一两次策略依然随机。原因是状态没有离散化每个连续的观测值组合都被当成新状态哈希键永远命中不了第二次。解决先做状态聚合把连续量按桶离散化位置除以格宽、速度按阈值分档或者手工设计几个有物理意义的离散特征离散化粒度要能保证“同一桶内的状态对动作的反应基本一致”一般拿状态访问频次的直方图来判断如果大部分状态都只出现一次粒度就太细了。6. 设计取舍max 算子的代价与一套可验证的自检流程Q-Learning 里最值得反复琢磨的设计是那个 max。它带来了 off-policy 的全部红利同时也带来了系统性偏差目标值用的是“下一步所有动作里的最大估计值”而估计本身是有噪声的取最大会自动挑中噪声中偏高的那一份于是 Q 值被系统性地高估。噪声越大、动作越多、样本越稀疏高估越严重。Double Q-Learning 的动机就在这里——用两张表分别负责“选动作”和“评估动作”把 max 的选择偏差和评估偏差拆开代价是内存翻倍、更新逻辑变复杂收敛也慢一些。要不要上取决于动作维度动作只有四五个的网格世界里高估往往被环境本身的回报结构盖过去动作维度几十上百时这个偏差就会实际影响策略质量。另一个取舍是表格法与函数逼近的边界。tabular 的适用范围很窄状态一多就废但它有一个别人给不了的好处可验证。小规模离散环境里值迭代跑几百轮就能拿到接近解析解的 Q 值把它当作真值去对比 Q-Learning 学出来的表能非常直观地看出采样版本在哪些状态上偏得厉害。我习惯在做任何复杂方法之前先用这个自检流程把环境和实现都验一遍def evaluate_greedy(env, agent, episodes100): 评估时把 ε 强制压成 0且不改动 Q 表内容。 saved_eps agent.eps agent.eps 0.0 # 关闭探索只看贪心策略的真实表现 returns [] for _ in range(episodes): s, done, total env.reset(), False, 0.0 while not done: s, r, done env.step(agent.act(s)) # act 内部 ε0等价于 argmax total r returns.append(total) agent.eps saved_eps # 恢复现场避免影响后续训练 mean sum(returns) / len(returns) spread (sum((x - mean) ** 2 for x in returns) / len(returns)) ** 0.5 return mean, spread评估时最容易被忽视的是方差的解读。如果 mean 不错但 spread 很大说明策略对初始状态或环境的随机转移很敏感根源往往在状态表示漏了信息而不是训练不够久。我的判断标准是连续三次独立评估的均值差异在 5% 以内策略才算真的稳只要有一轮明显掉下去就先怀疑状态编码和终止判断而不是急着调超参。还有一条血泪经验是关于 γ 和路径长度的。γ 太大、每步惩罚太小智能体学出的路径往往比最优路径长那么一两步看起来回报只差一点点实则是自举把远端价值传回来的速度变慢了——信息要在 Q 表里一格一格地往前传传满整条路径需要的更新次数和路径长度的平方成正比。路径一长训练回合数就得跟着涨这时候缩短视野、加大每步惩罚或者干脆把终端附近状态的 Q 值手工初始化都比干等着收敛划算。从那以后我每次动手写一个新的强化学习实现都会强制走一遍这套流程先拿值迭代算一份参考 Q 表再用贪心评估对比均值和方差最后才去看学习曲线长什么样。曲线好看但策略不听话是这类算法最常见的假象早点识破能省下好几天的调参时间。希望这些拆解能帮到你资源里的完整代码可以拿去对照着读把每一行和上面的机制对上号比看懂十篇教程都管用。本文还有配套的精品资源点击获取