纯Python实现Q-Learning强化学习:从Q表到贝尔曼方程的完整代码实战

发布时间:2026/10/25 21:06:10
纯Python实现Q-Learning强化学习:从Q表到贝尔曼方程的完整代码实战

简介:这份资源是面向强化学习入门者与Python开发者的Q Learning算法完整实现包,帮助读者在无法预知全部状态与动作的环境下,通过构建Q表并迭代更新,让智能体逐步学到最优策略。内容围绕Q函数更新公式展开,涵盖学习率、折扣因子、epsilon-greedy探索策略等关键概念,并给出从初始化Q表到多轮episode收敛的完整流程,适合想动手理解强化学习原理、需要可运行代码参考的读者。压缩包共5个文件,包含py源码、md说明、pdf报告、license及临时文件,整体约852KB,其中py文件承载核心算法实现,md与pdf用于梳理思路与实验记录。目前已有1108人学习下载,读者可据此掌握Q表字典结构设计、状态离散化处理与训练调参思路,并借助gym环境如CartPole-v1快速验证算法效果,形成从理论公式到代码落地的完整认知。

1. 从一张 Q 表说起:这个 200 行不到的强化学习包,为什么值得你花一晚上跑通

很多人第一次接触强化学习,是从 DQN、PPO 这些深度强化学习算法入门的,结果环境配了三天,CUDA 版本对不上,最后连一个能收敛的 demo 都没跑出来。其实强化学习最朴素的思想——用一张表记录「在什么状态下做什么动作最划算」——根本不需要神经网络。这份python实现Q Learning强化学习(完整代码).zip走的就是这条最朴素的路线:用纯 Python 加 NumPy,把 Q-Learning 从环境建模、Q 表更新、ε-greedy 探索到训练收敛的完整链路摊开给你看。它适合两类人:一类是刚学完 Python 基础、想找一个能真正跑起来、能改参数的强化学习入门项目;另一类是已经用过深度强化学习框架、但想回头把表格型方法(Tabular RL)的底层逻辑补扎实的从业者。整个包不依赖 Gym、不依赖 PyTorch,解压就能跑,这一点在动辄要装十几个依赖的今天反而成了稀缺品。

2. Q-Learning 的数学骨架:贝尔曼方程到底在代码里长什么样

2.1 时序差分更新与那张被反复覆盖的 Q 表

Q-Learning 的核心就一个公式,但很多人背得出公式却写不对代码。公式是:

Q(s, a) ← Q(s, a) + α · [r + γ · max Q(s', a') − Q(s, a)]

这里的每一项在代码里都有明确对应。Q(s, a)是当前状态动作对的估值,α是学习率(代码里通常叫alpha或lr),r是执行动作后环境给的即时奖励,γ是折扣因子(gamma),max Q(s', a')是下一状态所有动作里最大的那个估值。方括号里那一整块叫 TD 误差(时序差分误差),它衡量的是「我原来的估计」和「实际走一步之后看到的更好估计」之间的差距。

关键点在于:Q-Learning 是 off-policy 的。它在更新时用的是max Q(s', a'),也就是假设下一步一定选最优动作,而不管实际探索时用的是不是 ε-greedy 随机动作。这个细节决定了它和 SARSA 的分道扬镳——SARSA 用的是实际执行的那个动作的 Q 值。如果你在代码里把max换成实际动作对应的 Q 值,算法就悄悄变成了 SARSA,收敛行为会不一样。这是新手最容易在改代码时踩的坑。

Q 表本身就是一个二维数组,行是状态数,列是动作数。状态空间小的时候(比如几百个格子),这张表用 NumPy 数组存完全够用;状态一多,表就爆炸,这也是为什么后来有了 DQN 用神经网络来拟合这张表。但理解表格版本,是理解后面一切函数逼近方法的前提。

2.2 用 NumPy 初始化 Q 表和超参数

下面这段是典型的 Q 表初始化写法,我按常见实现整理出来,你可以对照包里的代码看结构是否一致:

import numpy as np # 状态数:比如一个 5x5 的网格世界,共 25 个状态 n_states = 25 # 动作数:上下左右 4 个动作 n_actions = 4 # Q 表初始化为全零,也可以用小随机数打破对称 Q_table = np.zeros((n_states, n_actions)) # 超参数 alpha = 0.1 # 学习率,太大震荡,太小收敛慢 gamma = 0.9 # 折扣因子,越接近 1 越看重长远奖励 epsilon = 1.0 # 初始探索率,从完全随机开始 epsilon_min = 0.01 epsilon_decay = 0.995 episodes = 1000 # 训练轮数

逻辑说明:Q_table用np.zeros初始化是最常见的做法,因为初始时我们对任何状态动作对都没有偏好。alpha取 0.1 是一个稳妥的起点,如果你发现训练曲线剧烈抖动,先把它降到 0.05 试试。gamma取 0.9 意味着大约看未来 10 步的回报,网格世界这类任务通常 0.9 到 0.99 之间都合理。epsilon从 1.0 开始,保证前期充分探索,然后按epsilon_decay逐步衰减到epsilon_min,这个衰减策略直接决定了「探索」和「利用」的平衡点。

参数怎么改:如果你的环境奖励稀疏(比如走到终点才给奖励),gamma要调高到 0.95 以上,否则远处的奖励传不回来;如果训练几百轮就收敛了但效果不稳定,把epsilon_decay调慢一点,比如 0.999,让探索持续更久。

2.3 ε-greedy 策略与训练主循环

训练循环是整份代码的心脏,结构如下:

for episode in range(episodes): state = env.reset() # 每轮重置环境,回到起点 done = False while not done: # ε-greedy:以 epsilon 概率随机探索,否则选 Q 值最大的动作 if np.random.rand() < epsilon: action = np.random.randint(n_actions) else: action = np.argmax(Q_table[state]) next_state, reward, done = env.step(action) # 贝尔曼更新:注意用的是 max,这是 off-policy 的标志 best_next = np.max(Q_table[next_state]) td_target = reward + gamma * best_next td_error = td_target - Q_table[state, action] Q_table[state, action] += alpha * td_error state = next_state # 每轮结束衰减 epsilon if epsilon > epsilon_min: epsilon *= epsilon_decay

逻辑说明:np.random.rand() < epsilon这一行控制探索概率,注意是「小于」而不是「大于」,写反了会变成前期几乎不探索。np.argmax(Q_table[state])在 Q 值全为零时会返回索引 0,这意味着初始阶段如果 epsilon 衰减太快,智能体会一直往同一个方向撞墙,这是很多人训练不收敛的隐藏原因。td_target和td_error拆开写是为了方便你打印调试,实际部署时可以合并成一行。

参数说明:env.reset()和env.step()是环境接口,包里的环境实现决定了状态怎么编号、奖励怎么给。如果你要换成自己的环境,只要保证reset返回一个整数状态、step返回(next_state, reward, done)三元组即可。done标志位必须在到达终止状态或超过最大步数时置为 True,否则会死循环。

3. 把代码跑起来:环境搭建、训练与结果验证

3.1 运行环境与依赖确认

这份代码对环境的宽容度很高,Python 3.6 以上都能跑,核心依赖只有 NumPy。如果你机器上还没装,一条命令解决:

pip install numpy

如果你用的是 Python 3.8 或更新的版本,NumPy 默认版本就够用,不需要指定版本号。有些同学的环境里 NumPy 版本过旧导致np.random.rand行为异常,这种情况先升级:

pip install --upgrade numpy

解压后的目录结构通常是主程序文件加一个环境定义文件,直接python 主文件名.py就能启动训练。如果你在 VS Code 里跑,注意选对解释器,别用系统自带的 Python 2,否则print语句会直接报语法错误。

3.2 训练过程观察与收敛判断

跑起来之后,终端一般会打印每轮的累计奖励或步数。判断有没有收敛,看两个信号:一是每轮步数是否稳定下降到接近最优路径长度,二是累计奖励是否趋于平稳。如果跑了 1000 轮步数还在大幅波动,大概率是超参数没调好,按下面的顺序排查:

先看epsilon衰减是不是太快,导致后期几乎不探索,卡在局部最优;再看alpha是不是太大,Q 值在目标附近来回跳;最后看gamma是不是太低,导致智能体只顾眼前奖励。我一般会在训练循环里每 100 轮打印一次平均步数,这样趋势一目了然。

if episode % 100 == 0: print(f"Episode {episode}, epsilon={epsilon:.3f}, avg_steps={np.mean(recent_steps):.1f}")

这段打印代码建议你自己加上,包里不一定有,但它能帮你快速判断训练状态,比盯着单轮结果有用得多。

3.3 用贪心策略验证学到的 Q 表

训练完之后,把epsilon设为 0,跑几轮纯贪心策略,看智能体能不能稳定走到终点:

epsilon = 0 # 关闭探索,完全按 Q 表走 state = env.reset() done = False path = [state] while not done: action = np.argmax(Q_table[state]) state, reward, done = env.step(action) path.append(state) print("最优路径:", path)

逻辑说明:这段验证代码是检验训练成果的关键。如果路径长度明显短于随机策略,说明 Q 表学到了东西;如果路径还是绕来绕去,说明训练不充分或者奖励设计有问题。path列表记录了完整轨迹,你可以把它画在网格上直观检查。注意验证时一定要把epsilon置零,否则还会随机乱走,看起来像是没学会。

4. 避坑与排查:那些让 Q-Learning 不收敛的细节

4.1 现象:Q 值全部趋同或爆炸

原因:学习率alpha设置过大,或者奖励没有做归一化。如果每步奖励是 100 这种量级,TD 误差会非常大,Q 值更新幅度过猛直接发散。解决:把奖励缩放到 -1 到 1 之间,alpha降到 0.01 到 0.1 之间重新训练。这是血泪经验,很多人第一次跑就是被奖励量级坑了。

4.2 现象:智能体在原地打转,永远走不到终点

原因:epsilon衰减过快,前期没探索够,Q 表里大量状态动作对还是零,argmax永远返回索引 0。解决:把epsilon_decay调大(比如从 0.995 改成 0.999),或者设置一个探索预热期,前 100 轮强制epsilon=1.0。

4.3 现象:训练轮数加了一倍,效果反而变差

原因:过拟合到某条次优路径后,epsilon已经降到很低,智能体没有机会跳出局部最优。解决:不要一味增加轮数,而是检查epsilon_min是不是设得太小,留 0.05 左右的持续探索往往比 0.01 更稳。

4.4 现象:换了自己的环境后直接报索引越界

原因:env.reset()返回的状态编号超出了Q_table的行数,或者env.step()返回的next_state是负数。解决:在环境接口里加断言,确保状态编号在[0, n_states)范围内。这个黑匣子问题不打印出来很难发现,建议在step函数里加一行assert 0 <= next_state < n_states。

4.5 现象:训练时奖励正常,验证时路径完全不对

原因:验证代码里忘了把epsilon置零,或者验证时用的环境实例和训练时不是同一个(状态编号规则变了)。解决:验证前显式设置epsilon = 0,并确认环境初始化参数一致。

5. 进阶技巧:把 Q-Learning 用到你自己的场景里

5.1 状态离散化:连续状态怎么塞进 Q 表

Q 表只能处理离散状态,但很多实际问题的状态是连续的,比如小车的位置和速度。常见做法是做分桶(binning):把每个连续维度切成若干区间,用np.digitize把连续值映射成桶索引,再把多个维度的桶索引组合成一个整数状态。桶的数量要权衡,切太细 Q 表太大训练慢,切太粗精度不够。我一般先用 10 个桶起步,观察收敛情况再调整。

5.2 奖励塑形:稀疏奖励下的生存指南

如果只有终点给奖励,Q-Learning 在稍大的网格里几乎学不动。奖励塑形(reward shaping)是常用手段:每走一步给一个小的负奖励,靠近终点时给递增的正奖励。但要注意,塑形奖励设计不当会引入偏差,让智能体学会「刷奖励」而不是真正完成任务。一个稳妥的原则是:塑形奖励的总量不要超过最终目标奖励的量级。

5.3 从 Q 表到函数逼近的过渡信号

什么时候该放弃 Q 表?当你的状态数超过几万、内存开始吃紧,或者训练速度明显变慢时,就该考虑用神经网络来拟合 Q 值了,也就是 DQN。但不要急着上 DQN,先把这份表格版代码跑透,理解 TD 误差、经验回放(虽然表格版不一定用)、目标网络这些概念在表格场景下的对应物,后面看 DQN 代码会顺畅很多。

5.4 一个我常用的调试习惯

每次改完超参数或环境,我会先跑 50 轮,把 Q 表的最大值、最小值、非零元素占比打印出来。如果非零占比一直很低,说明探索不够;如果最大值和最小值差距极大,说明更新不稳定。这个习惯帮我省下了大量盲目调参的时间。从那以后我每次接手新的强化学习代码,都强制先跑一遍这个「体检」,再谈效果。希望这份 Q-Learning 代码包能帮你把强化学习的第一块基石踩实,后面再学深度强化学习,心里就有底了。

本文还有配套的精品资源,点击获取