
简介面向强化学习入门者与Python开发者的Q Learning学习材料围绕部分可观测环境中通过交互逐步优化策略的问题展开帮助读者把Q表、奖励、折扣因子、学习率与探索-利用平衡等概念落到可运行代码上。压缩包共5个文件含Python实现脚本、Markdown说明、PDF实验报告及许可证等整体约852KB结构轻量适合按代码与实验报告两条线索对照阅读。已有1111人学习下载。读者可从中获得Q Learning的完整实现路径包括Q表初始化、epsilon-greedy动作选择、Q值更新公式的代码表达、按回合训练与终止条件控制也能借助报告理解参数调整对收敛效果的影响在gym的倒立摆等环境中复现实验并观察学习率与折扣因子的作用。材料还提示离散状态可直接编码、连续状态需离散化后再建表适合课程实验、自学练手与算法入门参考。1. 别急着调参先看清这份 QLearning 强化学习代码能替你解决什么有个场景我印象很深某开发者照着一篇博客写了三十行 QLearning 强化学习代码训练到第 300 局智能体还在迷宫左上角来回撞墙奖励曲线像心电图。问题不在算法而在他手里没有一份能跑通、能打印中间量、能对着改的完整实现。这份 python 实现 Q Learning 的完整代码就是干这个的一个 5×5 格子迷宫加 Q 表、ε-greedy 探索、时序差分更新和训练曲线全部摊开在几个文件里没有框架黑匣子。适合两类人刚学完贝尔曼方程想找个能跑的载体或者手上有离散状态的小决策问题想拿表格法先做个基线。它不解决高维连续控制但能让你把 Q 值一行行看明白。2. 技术选型与目录结构numpy matplotlib 撑起的极简强化学习骨架2.1 为什么这个场景不需要重型框架Q Learning 是表格型算法它全部的「模型」就是一张二维表。5×5 的地图展开成 25 个状态上下左右 4 个动作整张 Q 表是 25×4 个 float64连内存占用都可以忽略。这种规模上神经网络或者通用强化学习框架等于给自行车装飞机发动机能跑但你调试时看不到任何中间量Q 值涨没涨、涨在哪一格全靠猜。我一般会坚持两条依赖只留 numpy 和 matplotlib环境、智能体、训练脚本拆成独立文件。第一条保证别人 clone 下来两条命令就能跑不用为了看一眼 Q 表去装一堆依赖第二条保证出问题时能定位——奖励不涨是环境写错了还是更新公式写错了文件边界就是责任边界。超参数统一收进config.py。这个习惯是被坑出来的alpha 写在 agent 里、episodes 写在训练循环里、epsilon 衰减硬编码在 while 里做三组对照实验要改四个文件改漏一个就得到一组毫无意义的曲线。用 dataclass 收口之后跑实验只是换一个 Config 实例。# config.py所有可调项集中在一处方便做对照实验 from dataclasses import dataclass dataclass class Config: episodes: int 500 # 训练总局数 max_steps: int 200 # 单局最大步数防止智能体无限兜圈 alpha: float 0.1 # 学习率新经验覆盖旧经验的幅度 gamma: float 0.9 # 折扣因子看多远 eps_start: float 1.0 # 初始探索率开局几乎纯随机 eps_end: float 0.05 # 最终探索率留一点随机性兜底 eps_decay_episodes: int 300 # 探索率从 start 线性降到 end 所需局数 seed: int 42 # 随机种子保证可复现这里的每个字段都会在后面被直接引用。alpha调到 0.5 以上会出现明显的 Q 值震荡因为单次经验占比过高旧估计被冲掉gamma取 0.9 意味着大约看 10 步以内的回报5×5 迷宫最优路径 8 步刚好够用eps_end不给 0 是我自己的习惯留 5% 随机能在环境有微小变化时不至于彻底卡死。2.2 目录结构与文件职责代码包解开后大致是这么一套结构我按职责重新梳理了一遍你拿到手可以对照着看qlearning_grid/ ├── config.py # 超参数与随机种子 ├── env.py # 5x5 格子环境对外只暴露 reset / step ├── agent.py # Q 表、ε-greedy 选动作、TD 更新、模型存取 ├── train.py # 训练主循环、日志打印、Q 表落盘 ├── visualize.py # 奖励曲线、步数曲线、策略箭头图 ├── requirements.txt # numpy matplotlib └── ckpt/ └── q_table.npy # 训练产物可视化脚本直接读它文件核心内容二次开发时的改动频率env.py地图、状态编码、奖励规则、终止判定高换任务基本都要改agent.pyQ 表初始化、动作选择、更新公式低算法本身很少动train.py主循环、epsilon 衰减、日志中加日志、加评估会改config.py全部超参数高调参必改visualize.py曲线与策略图低这个划分的关键点是env.py完全不认识 Q 表agent.py完全不认识地图。后面你想把格子迷宫换成仓库拣货路径、换成简单博弈只要新环境满足同样的接口agent 那侧一个字都不用动。2.3 环境接口约定reset / step / done 三件套很多人写 Q Learning 写到一半卡住不是因为公式是因为环境和算法糊在一个文件里状态怎么表示、奖励什么时候给全混在循环中。把接口固定成三个方法问题立刻清晰# env.py 中对外的三个契约方法 class GridWorld: def reset(self): 回到起点返回初始状态一维整数索引 self.pos self.start self.steps 0 return self._encode(self.pos) def step(self, action): 执行一个动作返回 (下一状态, 即时奖励, 是否终止) ... def render(self): 打印当前地图和智能体位置调试用 ...reset()每次返回的都是同一个整数起点状态这是有意的迷宫起点固定训练时每局从同一个地方开始曲线才有可比性。step()的返回值顺序固定为「状态、奖励、终止标志」训练循环里就永远是一行解包不会出现把 reward 和 done 位置写反、结果训练几十局发现奖励一直是 1 和 0 的低级事故。done用布尔值而不是 0/1 浮点是为了让更新公式里写(1 - done)时语义明确。到了 agent 那侧再转 float职责分开读代码的人一眼知道这个标志从哪来、到哪去。3. 核心模块拆解一个 episode 从采样到 Q 表更新的完整链路3.1 地图与状态编码把二维坐标压成一维索引地图直接写成字符数组改起来最直观S起点、G终点、#障碍、.通路。5×5 的地图手写五行字符串谁都能在十秒内看懂哪里是墙。# env.py地图与状态编码 import numpy as np ACTIONS [(-1, 0), (1, 0), (0, -1), (0, 1)] # 上 下 左 右 MAP [ S..#., .#.#., .#..., ...#., #...G, ] class GridWorld: def __init__(self, max_steps200): self.map MAP self.n_rows, self.n_cols len(self.map), len(self.map[0]) self.start (0, 0) self.goal (self.n_rows - 1, self.n_cols - 1) self.n_states self.n_rows * self.n_cols # 25 self.n_actions len(ACTIONS) # 4 self.max_steps max_steps self.pos, self.steps self.start, 0 def _encode(self, pos): # 二维坐标 - 一维索引Q 表才能直接用整数下标访问 return pos[0] * self.n_cols pos[1] def step(self, action): dr, dc ACTIONS[action] r, c self.pos[0] dr, self.pos[1] dc self.steps 1 if not (0 r self.n_rows and 0 c self.n_cols) or self.map[r][c] #: r, c self.pos # 撞墙或越界原地不动 reward -1.0 # 给个明显负奖励让它长记性 elif (r, c) self.goal: reward 10.0 # 到终点给大正奖励 else: reward -0.1 # 每走一步小惩罚鼓励最短路径 self.pos (r, c) done self.pos self.goal or self.steps self.max_steps return self._encode(self.pos), reward, done_encode是整个项目里最短但最关键的函数。状态从(row, col)元组变成row * n_cols col的整数之后Q 表可以用np.zeros((25, 4))直接建出来可视化时又能reshape(n_rows, n_cols)画回格子图。如果偷懒用元组当索引要么报错要么得用字典存 Q 值后面所有向量化操作全部失效。奖励设计上每一步 -0.1 是「时间成本」撞墙 -1 是「违规成本」终点 10 是「目标收益」。三个数的大小关系决定了策略形状撞墙惩罚必须显著大于普通步惩罚否则智能体会发现贴着墙来回蹭比绕路便宜。max_steps是保险丝防止某次策略退化后单局跑到天荒地老。3.2 ε-greedy探索与利用的那条分界线Q 表刚初始化时全是 0纯贪心会永远选 0 号动作上智能体连第一步都迈不出去。ε-greedy 就是给这个死局开的口子以 ε 的概率随机选以 1-ε 的概率选当前最优。# agent.pyQ 表与动作选择 class QAgent: def __init__(self, n_states, n_actions, alpha, gamma): self.q_table np.zeros((n_states, n_actions)) # 初始 Q 值全 0 self.n_actions n_actions self.alpha, self.gamma alpha, gamma def choose_action(self, state, epsilon): # 探索随机试一个动作保证每个动作都有被采样到的机会 if np.random.rand() epsilon: return np.random.randint(self.n_actions) # 利用取当前 Q 值最大的动作平局随机打散避免固定偏好 0 号 q self.q_table[state] best_actions np.flatnonzero(q q.max()) return int(np.random.choice(best_actions))np.flatnonzero(q q.max())这一行是很多人会忽略的细节。直接np.argmax在 Q 值全 0 时会永远返回下标 0等于给「向上」这个动作一个隐形的初始偏好。5×5 迷宫里起点在左上角向上是撞墙智能体会先花几十局把这条路走废。打散平局之后前期采样更均匀收敛明显快一些。epsilon不放在 agent 内部维护而是每局由训练循环算好传进来这样衰减策略想改成线性、指数还是分段都只改训练脚本一处。3.3 贝尔曼更新四行代码里的时序差分整个强化学习的核心就这一段。Q 值表示「在状态 s 做动作 a之后一直按最优策略走能拿到的期望回报」更新规则是用实际拿到的奖励加上下一状态的最优估值去逼近当前估计。# agent.py时序差分更新 def learn(self, state, action, reward, next_state, done): # 终止状态没有之后bootstrap 项必须砍掉否则 Q 值会无限膨胀 best_next np.max(self.q_table[next_state]) target reward self.gamma * best_next * (1.0 - done) # 用学习率 alpha 把估计值往 target 挪一小步 td_error target - self.q_table[state, action] self.q_table[state, action] self.alpha * td_error参数逐个说reward是这一步环境给的即时回报gamma是折扣因子0.9 表示 10 步之后的 1 分只值 0.35 分best_next是下一状态所有动作里的最大 Q 值体现「之后按最优走」(1.0 - done)是终止掩码到终点时 target 直接等于 reward不再往后看。alpha控制这次经验能改写多少旧估计取 0.1 意味着新经验占一成。td_error单独提出来不只是为了可读性。它是训练是否正常的体温计我在train.py里会累计每局的 |td_error| 均值正常情况应该是前期大、后期趋近于 0如果一直不降基本可以断定奖励设计或者终止判定有问题。3.4 训练主循环epsilon 衰减与收敛判定主循环把前面三块拼起来每局做三件事重置环境、按 epsilon-greedy 采样、逐步更新 Q 表。# train.py训练主循环节选 for episode in range(cfg.episodes): state env.reset() total_reward, steps 0.0, 0 # 线性衰减从 eps_start 均匀降到 eps_end ratio min(1.0, episode / cfg.eps_decay_episodes) epsilon cfg.eps_start (cfg.eps_end - cfg.eps_start) * ratio while True: action agent.choose_action(state, epsilon) next_state, reward, done env.step(action) agent.learn(state, action, reward, next_state, done) state next_state total_reward reward steps 1 if done: break rewards.append(total_reward) lengths.append(steps) if (episode 1) % 50 0: print(fep {episode1:4d} | eps {epsilon:.3f} | freward {np.mean(rewards[-50:]):7.2f} | steps {np.mean(lengths[-50:]):6.1f})ratio用min(1.0, ...)截断保证 300 局之后 epsilon 稳定停在eps_end不会继续往下掉到负数。日志打的是最近 50 局的均值而不是单局值因为 Q Learning 的单局回报方差极大一次运气好撞到终点就能让曲线冒出个尖峰看单局值容易误判收敛。收敛判定我给的是经验阈值最近 50 局平均步数稳定在 8 到 10 之间最优路径 8 步平均奖励稳定在 8 附近终点 10 减去路径上的步惩罚就认为学到了。不必追求 Q 表完全不动epsilon 还有 5% 的随机性环境永远会有抖动。4. 跑起来从 pip install 到奖励曲线和策略热力图4.1 依赖与入口两条命令跑通训练代码包的依赖极少requirements.txt就两行。我习惯用虚拟环境隔离避免和系统里的 numpy 版本打架numpy1.24 matplotlib3.7cd qlearning_grid python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install -r requirements.txt # 训练 500 局指定种子便于复现 python train.py --episodes 500 --seed 42 # 训练完读 ckpt/q_table.npy画曲线和策略图 python visualize.py --ckpt ckpt/q_table.npytrain.py支持三个常用参数--episodes覆盖总训练局数--seed固定随机种子--save指定 Q 表保存路径。全部 500 局在普通笔记本上十几秒跑完日志每 50 局打一行。训练结束后ckpt/q_table.npy是一个 25×4 的浮点数组用np.load就能直接读不需要反序列化任何自定义对象。4.2 超参数取值表alpha、gamma、epsilon 怎么定新手最容易在这里翻车抄了一份参数换个地图就不收敛了。下面这张表是我在格子类任务上反复用过的取值区间可以直接当起点。参数建议取值调大的后果调小的后果alpha0.10.05~0.3收敛快但 Q 值震荡策略反复横跳学得极慢500 局不够gamma0.90.8~0.99看得远但稀疏奖励下难收敛短视宁可原地磨蹭也不去远处终点eps_start1.0—前期探索不足容易困在偶然路径eps_end0.050.01~0.1后期一直乱走曲线压不下去环境稍有变化就彻底卡死eps_decay_episodes总局数的 60%探索太久浪费时间衰减过快Q 表还没铺开就停止探索max_steps最优路径长度 × 20训练变慢兜圈子局拖时间还没走到终点就被截断学不到目标奖励有一组关系值得单独记gamma和max_steps要匹配。gamma 0.99 意味着看 100 步之后的回报如果 max_steps 只有 50那折扣后的远端奖励几乎为 0智能体根本感受不到终点的存在怎么训都不动。4.3 结果怎么读奖励曲线、步数曲线与策略箭头图visualize.py输出三样东西。第一张是奖励曲线我会把原始曲线画成浅色再叠一条 50 局滑动平均的粗线# visualize.py滑动平均让趋势从噪声里浮出来 def moving_average(x, window50): if len(x) window: return np.array(x, dtypefloat) kernel np.ones(window) / window return np.convolve(x, kernel, modevalid)np.convolve的modevalid表示只保留完全重叠的部分输出长度是len(x) - window 1所以横轴要对应地切掉前window-1个点否则曲线会整体左移看着像提前收敛。第二张是策略图把 Q 表每一行取 argmax 还原成动作再在格子上画箭头# visualize.pyQ 表 - 策略箭头图 policy np.argmax(q_table, axis1).reshape(env.n_rows, env.n_cols) arrows {0: (0, -1), 1: (0, 1), 2: (-1, 0), 3: (1, 0)} # 对应 上下左右 for r in range(env.n_rows): for c in range(env.n_cols): if env.map[r][c] #: continue dr, dc arrows[policy[r, c]] ax.arrow(c, r, dc * 0.3, dr * 0.3, head_width0.1, colortab:blue)注意ax.arrow里的坐标顺序是(x, y)而地图是(row, col)所以横轴传c、纵轴传r方向也反过来写。这个坑我踩过画出来的图整张转了 90 度箭头全部指向错误方向一开始还以为是策略没学对。正常收敛的结果长这样前 50 局平均奖励在 -20 以下智能体到处乱撞150 局左右曲线抬头300 局后稳定在 8 上下平均步数从 200 的截断值降到 8 到 9 步。策略图上是一条从左上角绕过障碍、贴着中间通道到右下角的连续箭头链没有环。5. 避坑与排查Q Learning 跑不出结果的 5 个典型现场5.1 奖励与终止信号类问题现象一训练越久单局步数越长总奖励反而更高但智能体从来不到终点。原因给每一步都设了正奖励比如每步 1。绕圈 200 步能拿 200 分走到终点游戏结束只拿 10 分理性智能体当然选择永远不结束。这是奖励设计里最经典的错误跟算法实现无关。 解决步奖励改成 0 或负数-0.01 到 -1终点给显著更大的正奖励同时用max_steps硬截断。判断标准很简单——如果「最优策略」在数值上比「什么都不做」还差奖励一定设计错了。现象二Q 值越训越大终点附近数值涨到几百上千策略却开始绕开终点。原因更新时漏了(1 - done)。终止状态没有后继但仍然拿max Q(next_state)去做 bootstrap于是目标值里包含了自己形成自激放大训练越久越离谱。 解决target 写成reward gamma * max_q * (1.0 - done)并确保done是布尔或 0/1 浮点别写成None或者字符串那样1 - done直接抛类型错误。现象三智能体学会了走终点但路径明显绕远怎么训都不优化。原因撞墙惩罚太轻甚至没有。贴着障碍物来回蹭只损失普通步惩罚绕远和走直线的代价一样Q 表就没有动力去区分。 解决撞墙给一个显著更重的负奖励本例是 -1 对 -0.1十倍关系并且撞墙时位置不变。位置不变这点要确认如果撞墙还能穿过去地图上的#就形同虚设。5.2 探索策略与数值类问题现象四前几十局就「学会」了一条能到终点的路之后步数死活降不下去。原因epsilon 衰减太快或者干脆设成 0。Q 表初始全 0 时贪心永远选 0 号动作智能体沿着一条偶然走通的路径不断强化再也没机会发现更短的路线。 解决eps_start设 1.0衰减周期给到总训练局数的六成左右eps_end留 0.05 不归零同时把 argmax 的平局随机打散。判断是否探索不足有个土办法看前 50 局的路径分布如果所有局走的路线一模一样探索肯定不够。现象五同样的代码跑两次曲线差出一大截怀疑实现有 bug。原因随机种子没固定np.random的全局状态被前一次运行影响如果reset()里还随机了起点差异会更大。 解决在train.py入口统一np.random.seed(cfg.seed)环境内部如果要用随机单独持有一个np.random.RandomState(seed)实例不要和全局状态混用。这样同一份配置能稳定复现做参数对比才有意义。6. 二次开发把 5×5 迷宫换成你自己的任务想把这套代码用到自己的问题上改动点其实只有三处顺序别搞反。第一处是环境契约。新环境只要提供reset()返回整数状态、step(action)返回(next_state, reward, done)agent.py和train.py一行都不用动。我一般先写一个假环境故意让它在三步后必到终点跑通整条链路再填真实逻辑这样能把「环境写错」和「算法写错」彻底分开。第二处是状态表示。表格法的硬约束是状态数必须可控一般控制在几千到几万以内。如果你的状态是连续的比如位置是浮点、速度是浮点最省事的做法是分桶离散化# 连续状态离散化把每个维度切成 n_bins 段再拼成一维索引 def discretize(obs, low, high, n_bins10): ratios (obs - low) / (high - low) idx np.clip((ratios * n_bins).astype(int), 0, n_bins - 1) state 0 for i in idx: state state * n_bins i # 混合进制编码保证唯一 return statelow和high是每个维度的物理范围n_bins是分档数。维度一多状态数就指数爆炸三维各 10 档就是 1000 个状态四维就是 1 万再往上就该考虑函数逼近了那是另一个话题。第三处是验证。改完之后别只看曲线好不好看加一个确定性评估关掉探索epsilon0跑 100 局统计成功率和平均步数。我在train.py末尾加了一段# 贪心评估epsilon0跑 100 局看真实策略水平 success, total_steps 0, 0 for _ in range(100): s env.reset() for _ in range(cfg.max_steps): a int(np.argmax(agent.q_table[s])) s, r, done env.step(a) total_steps 1 if done and r 0: success 1 break print(f成功率 {success}% | 平均步数 {total_steps / 100:.1f})成功率不到 95% 就别急着调 alpha先回去看奖励和终止条件。还有一个我很推荐的技巧如果地图的最短路径长度是已知的本例是 8 步把它写成断言训练完比对贪心步数超了就报警。这比盯曲线靠谱得多。从那以后我每次改完环境都强制先跑一遍这个确定性评估再看训练曲线——曲线会被探索噪声骗贪心评估不会。希望帮到你。本文还有配套的精品资源点击获取