资讯详情

Q-Learning入门:Q表、Bellman更新与训练调参实战

📅 2026/11/2 14:07:22 | 华诺云谱 👁 阅读
Q-Learning入门:Q表、Bellman更新与训练调参实战
简介面向强化学习入门者与算法实践者的 Q Learning 完整实现代码包围绕“智能体如何在与环境交互中逐步逼近最优策略”这一核心问题给出可运行的 Python 版本并涉及离散状态表示与连续状态离散化等常见处理思路。压缩包共 5 个文件约 852KB以 .py 代码为主体配合 .md 说明文档和 .pdf 实验报告构成学习闭环另含 license 与临时文件体积轻量便于本地跑通与二次修改。代码覆盖 Q 表初始化、epsilon-greedy 动作选择、Q 值更新公式中各参数含义以及 episode 循环、学习率与探索率衰减等关键环节并示例用 gym 的 CartPole 环境做验证报告与说明可辅助理解状态设计与收敛过程。目前已有 1111 人学习适合想从公式落到代码、排错与调参的中初级读者。1. 一个 zip 里的 Q-Learning为什么它值得作为强化学习的第一块敲门砖很多人第一次碰强化学习都是从一张 Q 表开始的。这个 zip 里放的正是这样一份 Python 实现不依赖深度学习框架不要求 GPU核心逻辑几十行就能让智能体在网格里学会绕开陷阱走到终点。它解决的关键问题是让你在没有黑盒的前提下完整看清「状态-动作价值」是怎么被一次次试错填满的。适合刚学完 Python 语法、想亲手跑通第一个强化学习闭环的人也适合拿它当教学骨架、往上叠自己环境的老手。看完代码再动手改两个参数Q-Learning 就不再是什么抽象名词了。2. 拆包看结构Q 表的形状与一次 Bellman 更新的算法拿到这类 Python 强化学习工程先别急着python train.py。我把解压后的目录大致扫一遍通常能归到四块内容环境定义、Q 表与更新逻辑、训练主循环、结果可视化或回放。这四块对应的是「问题怎么描述」「知识存哪」「怎么学」「学成什么样」。顺序搞反了前面跑得再顺后面调参也是瞎调。2.1 四个模块各管什么常见的组织方式是按职责拆文件而不是按「第几步」拆。下面这张表是我拆包时习惯先对一遍的清单少哪块基本能预判到后面会卡在哪。模块典型内容缺了会怎样环境定义网格尺寸、障碍/陷阱/终点坐标、动作集合、奖励规则奖励全靠猜收敛与否无从判断Q 表与更新Q数组或字典、Bellman 更新、ε-greedy 选动作逻辑散在循环里改一处崩一片训练循环episode 循环、步数上限、探索率衰减、日志跑起来没有中间量出问题看不到可视化/回放学习曲线、贪心策略走一遍格子只能看数字无法肉眼确认策略对不对拆解顺序上我一般先看环境定义因为奖励函数定了这个任务的「难度上限」再看 Q 表的数据结构和更新那几行这是整个算法的心脏训练循环反而不急它只是把前两块反复调用。很多人反过来先盯着 for 循环看半天结果连状态编号怎么映射到格子都没搞明白。这里有个容易被忽略的点状态是整数编号还是元组坐标直接决定 Q 表用数组还是字典。网格世界一般把 (row, col) 压成row * n_cols col好处是np.zeros((n_states, n_actions))一句就能建表索引是连续的整数argmax也快。如果状态是连续的观测值压不成有限整数就只能先离散化或者换函数逼近这是第 4 章要展开的事。2.2 Q 表的本质把「状态-动作」的价值存成一张查表Q-Learning 的核心假设很朴素状态空间有限动作空间有限那么每个 (s, a) 对子的长期回报都可以单独存一个数。这张表就是 Q 表Q[s][a]读作「在状态 s 下先做动作 a之后一直按最优策略走能拿到的折扣累计奖励」。用 numpy 建表是最常见的做法形状是(n_states, n_actions)import numpy as np n_states 16 # 4x4 网格格子按 row*4col 编号 0..15 n_actions 4 # 0上 1下 2左 3右 # 全零初始化最常用也最容易掩盖 bug Q np.zeros((n_states, n_actions)) # 乐观初始化把初值抬高逼智能体先去试没走过的动作 # Q np.full((n_states, n_actions), 1.0) print(Q.shape) # (16, 4) print(Q[5]) # 状态 5 下四个动作的当前估值初始全是 0.0逻辑说明Q的每一行是一个状态每一列是一个动作行数乘列数就是这张表的总参数量。对 4x4 网格来说只有 64 个数训练几秒钟就能填满这也是 Q-Learning 适合入门的直接原因。参数说明n_states必须是整数编号的最大值加一编号从 0 开始连续中间不能跳号否则会多出一批永远访问不到的死行白白占内存还干扰argmax的调试。初始化方式值得单独说两句。全零初始化简单直接但有个副作用所有动作的初始估值一样argmax永远返回 0 号动作早期探索完全靠 ε-greedy 的随机分支撑着。乐观初始化把所有初值设成正数智能体第一次走到某个状态时会发现「实际回报比预期低」于是挨个去试其他动作探索效率更高。代价是奖励为负的任务里乐观初值会让早期回报曲线特别难看容易误判成「学崩了」。用字典建表则是另一种常见写法键是状态或状态-动作对值是一个列表或单个浮点数。好处是不需要预先知道状态总数坏处是键必须是可哈希的元组可以列表不行——这个坑第 5 章会细讲。2.3 一次更新到底改了什么把 Bellman 方程拆成四则运算Q-Learning 的更新式写出来是这样的Q(s,a) ← Q(s,a) α [r γ·max Q(s,·) − Q(s,a)]。教科书上看着唬人其实拆开就是「新估值 老估值 学习率 × 差距」。差距那一项叫 TD 误差衡量的是「这一步的实际收获」和「我原先的估计」差了多少。拿具体数字走一遍。假设智能体在状态 5选了动作 3向右走到了状态 6这一步拿到 −1 的惩罚且状态 6 不是终点alpha 0.1 gamma 0.9 s, a, r, s_next, done 5, 3, -1.0, 6, False q_sa Q[s, a] # 当前估值0.0 target r if done else r gamma * Q[s_next].max() # 目标值 -1.0 0.9*0.0 -1.0 td_error target - q_sa # -1.0 - 0.0 -1.0 Q[s, a] q_sa alpha * td_error # 0.0 0.1*(-1.0) -0.1逻辑说明target是「即时奖励 折扣后的未来最优估值」done为真时未来项必须砍掉因为终止状态之后没有下一步了。参数说明alpha0.1决定了这次更新只把估值往目标值挪 10%不是直接赋值。这一点很关键——直接赋值alpha1.0在随机环境里会让 Q 值剧烈震荡等于每次都被最新一条样本洗脑。再看第二轮。假设后来智能体又从状态 6 走到了终点拿到 1而Q[6]全零那么target 1.0Q[6, a]变成 0.1。下一轮如果智能体在状态 5 再次向右到状态 6target -1.0 0.9 * 0.1 -0.91Q[5,3]从 −0.1 更新到 −0.181。可以看到终点附近的「好消息」是一格一格往回传的传得慢不快取决于gamma和访问频次。gamma0.9意味着大约 10 步以外的奖励只剩下三分之一权重gamma0.99则能传得更远但也更容易让估值发散。这个「奖励回传速度」的直觉比记住公式有用得多。3. 跑通第一个闭环训练主循环的骨架与参数怎么设3.1 环境准备与运行入口这类包一般只依赖 numpy画学习曲线会再加个 matplotlib。我习惯先建独立虚拟环境避免和机器上其他项目打架cd qlearning_demo # 解压后的目录 python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install numpy matplotlib python train.py逻辑说明前三行是隔离环境第四行装依赖最后一行跑主程序。参数说明如果你的包没有 argparse 之类的命令行解析配置项通常写在脚本顶部的常量区改完直接重跑想批量试参数最省事的办法是自己包一层 runner用for alpha in [0.05, 0.1, 0.3]把训练函数调多次把returns曲线一起画出来对比比一次一次手改快得多。跑之前先确认两件事一是随机种子有没有固定没固定的话同样的参数跑两次结果差很多你会误以为是参数问题二是训练日志有没有打印每个 episode 的总回报没有的话第一件事就是加一行print(ep, total)否则后面所有分析都是盲猜。这两件事花不了五分钟能省掉大量后悔药。3.2 主循环骨架把五个参数写成函数签名把训练逻辑收进一个函数参数全走签名是最容易复现的写法。下面这段可以直接抄环境对象只要提供reset()、step(a)、n_states、n_actions四个东西就能跑import numpy as np def train(env, episodes2000, alpha0.1, gamma0.95, eps_start1.0, eps_end0.05, eps_decay0.995, max_steps200, seed42): rng np.random.default_rng(seed) # 固定种子保证可复现 Q np.zeros((env.n_states, env.n_actions)) eps eps_start returns [] for ep in range(episodes): s env.reset() total 0.0 for _ in range(max_steps): # 步数上限防止死循环 # ε-greedy小概率随机探索大概率走当前最优 if rng.random() eps: a int(rng.integers(env.n_actions)) else: a int(np.argmax(Q[s])) s_next, r, done env.step(a) # 终止状态不 bootstrap这一行写错整个算法就废了 target r if done else r gamma * Q[s_next].max() Q[s, a] alpha * (target - Q[s, a]) s s_next total r if done: break eps max(eps_end, eps * eps_decay) # 探索率衰减但不低于下限 returns.append(total) return Q, returns逻辑说明外层循环是 episode内层是单局内的步。每一步做三件事——选动作、拿反馈、更新 Q 表。eps在每个 episode 结束时衰减一次不是每步衰减这个粒度差异对收敛影响不小按步衰减会让探索期短得离谱几百步就退化成纯贪心。参数说明episodes20004x4 网格通常 5002000 局足够太小的网格跑太多局只是在重复访问同样的状态。alpha0.1学习率控制单次更新幅度0.010.5 之间试环境越随机取值越小。gamma0.95折扣因子越接近 1 越看重长远回报稀疏奖励任务要调高。eps_start1.0开局纯随机先把状态空间踩一遍。eps_decay0.995每局乘一次2000 局后理论值约 4e-5被eps_end兜住。max_steps200单局硬上限防止策略不好时无限打转。max_steps这个参数最容易被当成可有可无。实际上它是个安全阀当 Q 值还没收敛、智能体在两个格子之间来回横跳时没有它这一局永远不结束整个训练卡死在第 17 局。我第一次跑网格任务就栽在这上面终端十分钟没输出还以为是算法太慢。3.3 ε-greedy 与学习率两组参数的联动关系参数不是独立调的。学习率和探索率有个隐含的配合关系探索率掉得太快Q 表还没踩全就停止了探索后面所有更新都基于残缺的经验学习率太大每次更新都往最新一条样本上贴探索踩到的噪声会被放大成「知识」。常见的衰减策略有三种选哪种要看训练预算策略写法适用场景副作用指数衰减eps * 0.995每局局数固定、环境稳定后期几乎不探索容易卡局部最优线性衰减eps - (start-end)/N想精确控制探索期长度需要提前知道总局数分段常数前 30% 局用 0.3之后用 0.05调试阶段快速看趋势切换点附近曲线会有台阶我一般先用分段常数快速跑通确认策略能学到东西再换成指数衰减做正式训练。原因很实际调试阶段你根本没耐心等 2000 局跑完前 200 局能看出方向对不对就够了。还有个大坑是「训练时用 ε-greedy评估时忘了关」。评估必须用纯贪心a argmax(Q[s])否则你测出来的成功率里混着随机动作的贡献数字好看但没有意义。第 6 章会给一段专门的评估代码把关掉探索这件事写死。4. 换个环境试试接口对齐、状态离散化与奖励塑形4.1 reset / step 的返回值要对齐想把这套代码迁到自己的问题上最小改动就是把环境换掉。Q-Learning 对环境的全部要求就两条能给一个初始状态能吃一个动作返回下一状态和奖励。写成类就是下面这样二十来行能直接替换掉包里的示例环境class GridWorld: 4x4 网格起点左上终点右下中间一个陷阱越界或踩陷阱即终止。 def __init__(self, size4, goal(3, 3), trap(1, 1)): self.size size self.goal goal self.trap trap self.n_states size * size self.n_actions 4 self.moves [(-1, 0), (1, 0), (0, -1), (0, 1)] # 上下左右 self.pos (0, 0) def reset(self): self.pos (0, 0) return self.pos[0] * self.size self.pos[1] def step(self, a): dr, dc self.moves[a] r, c self.pos[0] dr, self.pos[1] dc # 撞墙不动但仍然消耗一步并给惩罚 r min(max(r, 0), self.size - 1) c min(max(c, 0), self.size - 1) self.pos (r, c) if self.pos self.goal: return r * self.size c, 1.0, True if self.pos self.trap: return r * self.size c, -1.0, True return r * self.size c, -0.01, False # 每步小惩罚逼它走最短路逻辑说明reset返回状态编号step返回(下一状态, 奖励, 是否终止)三元组。参数说明moves的顺序必须和训练时的动作编号一一对应改顺序等于改了动作语义Q 表里学到的列全乱套-0.01的每步惩罚是塑造「最短路径」行为的关键去掉它智能体会学会绕远路反正不扣分。注意一个接口兼容问题新版环境接口的reset往往返回(obs, info)而不是单个观测step返回五元组(obs, reward, terminated, truncated, info)。包里的代码要是按三元组写的接上去直接报「too many values to unpack」。解决办法很简单写个适配层把五元组压成三元组done terminated or truncated别去改训练循环。4.2 连续状态怎么塞进 Q 表分箱离散化Q 表的行数是状态数状态一连续就没法建表。合格从业者的常见做法是先分箱把每个维度切成有限个区间再用区间编号组合成元组当键import numpy as np # 以倒立摆为例角度、角速度、位置、速度各切 9 箱 BINS [np.linspace(-0.21, 0.21, 9)[1:-1], np.linspace(-2.0, 2.0, 9)[1:-1], np.linspace(-0.5, 0.5, 9)[1:-1], np.linspace(-3.0, 3.0, 9)[1:-1]] def discretize(obs): # digitize 返回 0..8 的区间编号组合成可哈希的元组 return tuple(int(np.digitize(o, b)) for o, b in zip(obs, BINS)) Q {} # 字典建表按需增长 key discretize([0.05, -0.3, 0.01, 0.8]) Q.setdefault(key, np.zeros(2)) # 两个动作逻辑说明np.digitize返回每个维度落在哪个箱子里四维组合起来最多 9^4 ≈ 6500 个状态字典建表完全扛得住。参数说明linspace里去掉首尾各一个边界值是因为digitize的边界语义容易差一少一个边界就少一类「正好卡在箱边」的玄学问题。箱数不是越多越好9 箱已经能覆盖大部分控制任务切到 20 箱状态数涨到 16 万访问频次不够Q 表大面积空着学出来的策略还不如粗分箱稳。字典建表有个必须注意的点键一定要是元组。写成Q[[0.05, -0.3]]会直接抛TypeError: unhashable type: list这个报错信息很明确但第一次遇到照样懵。4.3 奖励塑形稀疏与稠密的取舍奖励函数决定了任务难度比任何超参数都重要。终点 1、其余 0 的稀疏奖励最「纯粹」但 4x4 网格上智能体随机游走撞到终点的概率大约只有百分之几几千局都学不出东西是常态。加上每步 −0.01 的稠密惩罚信号立刻就出来了因为「原地不动」和「往前走」的估值不再相同。塑形要有节制。常见错误是给「靠近终点」额外加分比如距离每缩小一格加 0.1。课上这么写没问题实际用起来智能体会学会在终点旁边来回蹭分永远不进去——它不是不会是算过了蹭分比拿 1 更划算。我一般只用两类塑形每步固定小惩罚逼效率和明确的终止奖励/惩罚定目标。中间过程不插额外奖励项。5. 常见问题排查Q 表不动、曲线震荡的五条血泪记录5.1 Q 表全是零训练日志一条直线现象跑了几百局returns全是同一个数打印Q.max()永远是 0.0。原因三种可能。一是奖励设计成「终点 1、其余全 0」随机探索撞不到终点没有任何非零反馈进表二是eps初始就是 0智能体从头到尾走同一个动作而那个动作恰好不通向终点三是更新语句写在if done:里面非终止步根本不更新。解决先打印Q.max()和Q.nonzero()[0].size确认有多少个格子被写过。是零就查奖励函数把每步惩罚加上去eps_start强制设成 1.0前几百局纯随机把状态踩一遍。这个排查顺序我固定走五分钟定位。5.2 回报曲线一路震荡越训越差现象前 300 局回报稳步上升之后开始剧烈抖动最终稳定在一个很低的水平。原因学习率过大alpha1.0那种直接赋值的写法 探索率衰减太快两个因素叠加。Q 值每次都被最新一条样本完全覆盖早期踩到的偶然高奖励被无限放大而后续探索不足纠正不回来。解决alpha降到 0.1 以下探索率衰减按 episode 而不是按 stepeps_end别设成 0留 0.05 让智能体始终保持一点点探索。另外检查一下是不是每局都重置了s忘了reset()会让状态从上局的终点开始训练数据和测试分布完全错位。5.3 学到了策略但路线绕远甚至原地打转现象成功率上去了但走的路径明显不是最短有时候在某个格子左右横跳十几步才动。原因每步惩罚太弱或者干脆没有。所有路径的累计回报差不了多少Q 值之间的差距在浮点精度附近argmax随便挑一个都行再加上max_steps设得很大绕圈子也不受惩罚。解决每步固定 −0.01 到 −1 之间的惩罚gamma适当调低到 0.9 左右让远期奖励的权重降下来短路径的优势才明显。max_steps收紧到「最短路径长度 × 3」左右绕路直接算失败。5.4 训练时表现很好评估一塌糊涂现象训练日志最后 100 局平均回报很高评估成功率却只有一半。原因评估时还在用 ε-greedy。训练和评估共用同一个select_action函数里面带着随机分支评估阶段的「成功率」里混着随机动作的运气成分。还有一个变体是训练时用Q的最大值评估时误用了Q的某一列。解决评估函数独立写只做argmax把随机数生成器完全隔离出去。评估至少跑 100 局取平均单局结果是噪声。5.5 同一个脚本跑两次结果完全不同现象参数一字没改两次运行的收敛速度差好几倍有时候一次能收敛一次不能。原因np.random或random没有固定种子环境里的随机初始化比如起点随机也没固定。Q-Learning 对样本顺序敏感不同的随机序列就是不同的训练数据。解决统一用rng np.random.default_rng(seed)并把rng作为参数传下去环境内部的随机也从这个rng派生不要各用各的全局随机。固定种子之后任何一次「感觉变好了」都能被验证。6. 进阶怎么验证它真学到了以及从 Q 表到网络的第一步6.1 用贪心回放和成功率说话训练完最忌讳只看损失曲线。判断一个 Q 表学没学到我固定做两件事看贪心策略实际走一遍和统计成功率。def evaluate(env, Q, episodes100, max_steps60): wins 0 for _ in range(episodes): s env.reset() for _ in range(max_steps): a int(np.argmax(Q[s])) # 纯贪心无探索 s, r, done env.step(a) if done: wins int(r 0) # 只有到终点才算成功 break return wins / episodes逻辑说明r 0是把「撞陷阱」和「到终点」区分开两个都是终止但含义相反。参数说明episodes100是为了压噪声max_steps60比训练时的上限更紧正好能筛出「学会了但绕远」的半吊子策略。跑出 0.9 以上的成功率再去看 Q 表本身每个状态行的最大值所在列连起来应该是一条指向终点的路径。对不上的话问题多半在奖励塑形不在算法。6.2 把 Q 表换成网络的最小改动状态一多Q 表就爆。下一步自然是函数逼近输入状态输出每个动作的估值。改动看着大其实只动两个地方——选动作时用网络前向代替查表更新时用一次梯度下降代替一次赋值。网络结构两三层全连接就够真正的难点在样本相关性连续采样的转移高度相关直接训练会震荡所以要加经验回放池和目标网络。这也是 DQN 相对 Q-Learning 的全部增量。我的建议是先在这份表格代码上把回放池手写一遍几十行一个deque加随机采样确认理解了再上框架不然出了问题是真查不出来。我踩过的最深的一个坑是早期为了方便把训练和评估写成了一个函数靠一个布尔参数切换。结果那个参数在重构时被漏传了一次一周的实验数据全部作废。从那以后我每次改完环境或奖励函数都强制先跑一遍固定种子的 200 局小实验把成功率和 Q 表非零行数打出来对照数字不对就不往下走。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑