Q-Learning表格法实战:从贝尔曼方程到ε-greedy收敛
简介面向强化学习入门者与需要动手复现经典算法的开发者这份资源用 Python 完整实现 Q Learning重点解决在无法预知全部状态与动作空间时如何构建并更新 Q 表、如何用 epsilon-greedy 平衡探索与利用的问题。压缩包共 5 个文件以 py 脚本为核心辅以 md 说明、pdf 报告及 license 等配套文件整体约 852KB体量轻便便于直接阅读与运行。已有 1111 人学习下载说明其在同类入门材料中具有一定参考价值。代码围绕 Q 函数更新公式展开涵盖 Q 表初始化、动作选择、奖励回传与学习率、折扣因子调节等关键环节并涉及用 gym 的 CartPole 环境做验证的思路读者可据此理解状态表示与离散化处理快速搭建自己的实验流程并对照报告梳理算法细节。1. Q-Learning 的“玄学收敛”为什么一张表就能学会走路很多人第一次跑 Q-Learning最困惑的不是公式而是那种“玄学”感明明只维护了一张表没有神经网络、没有梯度、没有反向传播智能体却在几百个回合后突然像开了窍路径越走越短。更反直觉的是这张表初始时全是零它对世界一无所知却能在一次次撞墙、掉坑、绕远路之后把“在哪个格子往哪走”这件事记下来。这背后没有魔法只有一个极其朴素的信念一个状态动作的价值等于即时奖励加上“之后能拿到的最好价值”的折扣。整份资源就是围绕这个信念用纯 Python 把表格、探索、更新、收敛这条链路完整跑通。搞懂它你就搞懂了几乎所有强化学习算法的祖先——后面那些深度 Q 网络、策略梯度无非是把这张表换成了函数逼近。而“为什么一张表能 work”这个问题恰恰是理解强化学习底层机制最好的入口。2. 贝尔曼方程落地Q 表到底在存什么2.1 从“价值”这个词说起Q-Learning 里最容易被含糊带过的概念就是 Q 值。很多人背得出更新公式却说不清 Q(s,a) 到底代表什么。我一般会这么理解它是对“在状态 s 下先执行动作 a然后一直按最优策略走到结束累计能拿到的折扣奖励”的估计。注意两个关键词——先执行 a之后按最优走。这意味着 Q 值不是对某条固定轨迹的打分而是对“这个起手式好不好”的打分。折扣因子 γ 决定了智能体有多短视γ 接近 0它只在乎下一步的即时奖励γ 接近 1它愿意为长远收益等待。这个定义直接来自贝尔曼最优方程Q*(s,a) r γ · max Q*(s, a)左边是当前状态动作的真实最优价值右边是即时奖励加上下一状态里最好动作的价值打折。这个等式之所以成立是因为“最优”具有递归结构如果你从 s 开始已经走的是最优路线那么从 s 出发的最优路线就是“走一步到 s然后接上那条最优路线”。Q-Learning 做的事情本质上就是用采样到的 (s, a, r, s) 四元组一次次把等式右边算出来去逼近左边的估计值。表里存的不是“正确答案”而是这个等式在当前经验下的一个不断修正的近似。2.2 Q 表的数据结构选择既然是表格法第一个工程决策就是表怎么存。状态和动作都是离散的最自然的实现是用嵌套字典外层键是状态内层键是动作值是浮点 Q 值。相比用二维数组字典的好处是状态不必是连续整数可以是元组坐标、字符串编码甚至任意可哈希对象这让同一份代码能直接套到网格世界、迷宫、简单博弈上。代价是查表有哈希开销状态空间大时会吃内存——这也正是后来必须转向函数逼近的根本原因。# Q 表state - {action: q_value} # 用 defaultdict 省去“状态首次出现要手动建内层字典”的样板代码 from collections import defaultdict class QTable: def __init__(self, actions, alpha0.1, gamma0.9): self.q defaultdict(lambda: {a: 0.0 for a in actions}) self.actions actions self.alpha alpha # 学习率新经验覆盖旧估计的幅度 self.gamma gamma # 折扣因子未来奖励的衰减权重 def value(self, state, action): return self.q[state][action] def best(self, state): # 返回 (最优动作, 最优Q值)用于更新时的 max 项和决策时的贪心选择 items self.q[state].items() return max(items, keylambda kv: kv[1])这段代码里有两个细节值得说。第一defaultdict的工厂函数在状态第一次被访问时自动生成全零的动作字典这保证了“未见过状态”的 Q 值默认为 0符合“一无所知”的初始假设。第二best方法同时返回动作和值是因为更新公式需要的是 max 的数值而决策需要的是 argmax 的动作两者复用同一段逻辑能避免不一致。学习率 α 控制每次更新往目标值靠多少α1 表示完全用新经验覆盖α 很小表示保守地慢慢挪。实践中 α 取 0.1 到 0.5 之间比较常见太大容易震荡太小收敛慢得让人怀疑人生。2.3 更新公式的时序拆解把更新公式拆成时序能看清它为什么是“离线”的。所谓 off-policy指的是更新时用的 max 项来自“假设下一步选最优动作”而不是智能体实际选的那个动作。也就是说哪怕智能体这一步因为探索随机走了一个烂动作它在更新当前 Q 值时仍然按“下一步会走最好”来估算未来收益。这个设计让 Q-Learning 可以边探索边学最优策略行为策略和目标策略解耦。代价是它在某些随机环境下会高估 Q 值——因为 max 操作对噪声正偏这也是后来 Double Q-Learning 要修的问题。常见的实现思路是先算目标值target r gamma * max_q_next再算误差td_error target - q_current最后q_current alpha * td_error。这个 td_error 就是时序差分误差整个强化学习的更新几乎都围绕它转。3. 探索与利用的拉锯ε-greedy 为什么不能一路贪心3.1 纯贪心的死局如果智能体从一开始就按当前 Q 值最大的动作走会发生什么初始 Q 全是 0所有动作并列最大它可能永远只走第一个动作撞墙撞到天荒地老永远发现不了旁边那条能拿奖励的路。这就是纯贪心的死局没有探索就没有新经验没有新经验Q 值永远不更新Q 值不更新策略永远不变。这个循环一旦锁死再多的回合数也是白跑。血泪经验是很多人调参时把探索率设得太低看到 loss 不降就以为是学习率问题其实是智能体压根没去过关键状态。3.2 ε-greedy 的机制与衰减策略ε-greedy 用一个小概率 ε 强制随机选动作剩下 1-ε 的概率走贪心。它的精妙之处在于简单到不需要任何额外状态却能保证每个动作都有被尝试的机会。但 ε 怎么设是有讲究的固定 ε 会让策略永远带随机性学到的 Q 值对应的策略和实际执行策略不一致ε 衰减则让智能体早期多探索、后期多利用逐渐收敛到贪心策略。常见的实现思路是线性衰减或指数衰减比如从 1.0 降到 0.05。import random def epsilon_greedy(qtable, state, epsilon): # 以 epsilon 概率随机探索否则走当前最优 if random.random() epsilon: return random.choice(qtable.actions) # 探索打破贪心死局 action, _ qtable.best(state) return action def decay_epsilon(episode, total_episodes, start1.0, end0.05): # 线性衰减前期大胆试错后期稳定利用 ratio min(episode / (total_episodes * 0.8), 1.0) return start (end - start) * ratio这里有个容易翻车的点衰减曲线的前 80% 回合用来降 ε后 20% 保持最低探索率。如果一路衰减到 0智能体在后期完全贪心一旦早期学到的 Q 值有偏差就再也没有机会纠正。保留一个小的 ε 下限相当于给策略留了后悔药。另一个细节是random.choice应该在所有动作上均匀采样而不是只在“看起来还行”的动作里选——探索的意义恰恰在于尝试那些当前评估很差的动作因为评估本身可能就是错的。3.3 探索不足与探索过度的症状对照调参时怎么判断 ε 设得对不对看两条曲线回合总奖励和每回合步数。探索不足的典型症状是总奖励长期卡在一个较低平台步数居高不下智能体反复走同一条死路探索过度的症状是奖励波动极大偶尔能拿到高分但稳定不下来因为每次都在随机乱走。健康的曲线应该是奖励逐步抬升、步数逐步下降中间允许有小幅回撤。如果跑了上千回合还是原地踏步先别怀疑算法把 ε 下限调高、把衰减放慢往往立刻见效。4. 一个回合怎么跑训练循环的源码级拆解4.1 环境交互的骨架Q-Learning 的训练循环本质是一个双层循环外层是回合episode内层是单回合内的步步交互直到到达终止状态或触发步数上限。每一步做四件事选动作、执行动作拿反馈、用四元组更新 Q 表、转移到下一状态。这个骨架简单到可以手写但每个环节都有坑。def train(env, qtable, episodes1000, max_steps200): rewards_history [] for ep in range(episodes): state env.reset() # 每回合重置到起点 epsilon decay_epsilon(ep, episodes) total_reward 0.0 for step in range(max_steps): action epsilon_greedy(qtable, state, epsilon) next_state, reward, done env.step(action) # 核心更新目标值 即时奖励 折扣后的下一状态最优价值 _, max_q_next qtable.best(next_state) target reward qtable.gamma * max_q_next td_error target - qtable.value(state, action) qtable.q[state][action] qtable.alpha * td_error state next_state total_reward reward if done: break # 到达终止状态本回合结束 rewards_history.append(total_reward) return rewards_history这段代码里最值得盯的是done的处理。终止状态的 Q 值不应该再往后传播未来收益因为游戏已经结束了。上面写法里终止状态的best会返回 0因为没被更新过所以target reward gamma * 0 reward恰好等价于“终止状态无未来”。但如果环境在终止后还允许访问 next_state 并给它赋了非零 Q 值就会引入错误的自举。稳妥的做法是在done为真时直接把 target 设为 reward不乘 gamma。这个细节在简单网格里可能看不出差别换到有多个终止状态的环境就会暴露。4.2 学习率与折扣因子的耦合α 和 γ 不是独立的。γ 越大未来奖励的权重越高目标值的方差也越大此时 α 应该相应调小否则每次更新都被高方差的远期估计带偏。反过来γ 很小的时候目标值主要由即时奖励决定方差小α 可以大一些加快收敛。一个实用的起点是 γ0.9、α0.1然后根据奖励曲线的抖动程度微调。如果曲线剧烈震荡先降 α如果智能体明显短视、不肯为远处的奖励绕路先升 γ。这两个参数调反了会出现“越调越乱”的假象。4.3 收敛判据怎么看表格法不像神经网络有 loss 可以盯判断收敛主要看策略是否稳定。一个可操作的判据是连续若干回合的最优动作序列不再变化且回合奖励的滑动平均进入平台期。更严格一点可以监控 Q 表的最大变化量当所有状态的 max |td_error| 都小于某个阈值时说明贝尔曼方程已经基本被满足。但要注意Q 值收敛不等于策略最优——在存在多个等价最优路径的环境里Q 值可能还在缓慢调整而策略早已稳定。所以实践中最靠谱的还是看奖励曲线加人工抽查几条轨迹。5. 避坑与排查那些让 Q 表学不会的常见问题5.1 现象奖励一直是 0Q 表纹丝不动原因通常是奖励设计有问题。如果只有到达终点才给 1其余全是 0那么在前若干回合智能体根本到不了终点所有 Q 值更新都是 0表永远是零策略永远是随机。解决方法是加中间引导奖励比如每走一步给一个小的负值鼓励走近路或者对靠近终点的状态给塑形奖励。但塑形要克制给太多会让智能体学会“刷步数”而不是真正到达目标。5.2 现象训练后期表现突然崩掉这多半是 ε 衰减到 0 之后策略完全贪心而早期 Q 值里的偏差再也没机会被纠正。也可能是学习率在后期仍然偏大导致 Q 值在最优值附近来回跳。解决办法是保留 ε 下限比如 0.05并在后期把 α 调小让更新更稳。我一般会在训练后段把 α 乘一个衰减系数效果比固定 α 好不少。5.3 现象同一份代码换个环境就不收敛常见原因是状态表示不一致。比如网格坐标有时是 (row, col)有时是 row*widthcol如果 reset 和 step 返回的编码方式不同Q 表会把同一个物理状态当成两个不同状态学到的经验被割裂。排查方法是打印前几个回合的 state 和 next_state确认编码统一、可哈希、且终止状态能被正确识别。另一个隐蔽原因是动作集合的顺序在不同地方不一致导致 argmax 选出的动作和实际执行的动作对不上。5.4 现象内存暴涨或运行越来越慢字典实现的 Q 表在状态空间大时会持续膨胀尤其是状态里混入了连续值或时间戳。如果发现内存曲线一路上扬先检查状态是否被无意中做成了无限集合。解决思路是离散化状态、限制状态编码的粒度或者干脆换成数组实现并预分配。表格法适合的是状态数可控的离散问题硬套到高维连续环境上慢和爆内存是必然的这时候该考虑的是函数逼近而不是继续调参。6. 表格法的边界什么时候该放下这张表Q-Learning 用一张表能 work前提是状态动作对可以被枚举。这个前提在网格世界、简单卡牌、小型博弈里成立但一旦状态维度上去表的规模是指数爆炸的。假设每个维度离散成 100 个取值5 个维度就是 100 亿个状态字典再省也存不下。这不是实现问题是表格法的方法论边界它用“记住每一个具体情形”来替代“归纳规律”而现实问题需要的是后者。理解这一点才能明白为什么深度 Q 网络要用神经网络去逼近 Q 函数——不是表格不好是表格的存储假设撑不住了。但反过来表格法恰恰是验证你对强化学习理解是否到位的试金石。因为表里的每个数字你都能打印出来看每次更新你都能手算核对贝尔曼方程有没有被正确实现、探索策略有没有生效、奖励设计有没有漏洞全都暴露在明面上。我一般建议先用表格法在一个小环境里把整条链路跑通、把曲线调顺再去碰函数逼近。跳过这一步直接上神经网络出了问题你根本分不清是算法错、超参错还是环境错黑匣子里全是玄学。这份资源的价值也在这里它把 Q 表、ε-greedy、时序差分更新、训练循环这几块用纯 Python 串成一个可运行、可打印、可手算核对的完整实现。没有框架封装没有隐藏的状态管理每一行逻辑都摊开在你面前。想真正搞懂 Q-Learning 为什么能收敛、更新公式里每个符号在代码里对应哪一步拿这份代码跑一遍、改改参数、打印 Q 表看它怎么变比读十篇公式推导都管用。需要的话按标题里的文件名去取这份完整代码从最小的网格环境开始把上面这些坑一个个踩过去你对强化学习底层机制的理解会扎实得多。希望帮到你。本文还有配套的精品资源点击获取