简介这是一份以Matlab编写的Q-Learning强化学习算法源代码及逐段注释文档适合初学者通过“王子寻找公主”的16×1网格游戏直观理解无模型条件下的决策流程。代码完整覆盖环境建模、可移动矩阵与奖赏矩阵设定、贝尔曼方程更新、ε-greedy策略选择等核心环节并包含训练循环、进度输出与最优路径规划实现可直接运行并观察Q矩阵从全零到收敛的迭代过程。环境中的障碍物与陷阱均通过矩阵明确标记王子可从任意非障碍位置出发在反复试错中学习状态-动作价值代码注释对每一段逻辑做了拆分便于理清状态、动作和奖励之间的映射关系。资源共1个doc文件压缩包仅33KB轻量便携适合对照代码逐行学习已有1378人浏览/学习可用于强化学习入门、课程实验或算法原理快速复盘适合本科生及开发者参考。1. 拿到一份 Q-Learning 源代码及注释(matlab).doc先别急着跑拿到一份 Q-Learning 源代码及注释(matlab).doc 时你以为最难的下一步是运行它。实际上一运行问题才刚露头Q 表要么全零不动要么训练几十个回合就冲到一个离谱的大数翻注释能看懂每一行但不知道哪里该改、动了之后会不会更糟。这类文档最典型的困境就在这里——它把 Q-Learning 的骨架写得很清楚却没告诉你怎么把骨架接到自己的环境里。这篇笔记就沿着标题里那份文档的典型组织方式先讲清楚 Q-Learning 在 MATLAB 里为什么长这样再逐段拆解主循环和注释最后落到离散化、调参、可视化自检这条可复现路径上。适合要用 MATLAB 做路径规划、调度或课程设计但第一次接触强化学习的从业者和学生。2. 从贝尔曼方程到 Q 表用 MATLAB 拆开 Q-Learning 的运行机制要读懂任何一份 Q-Learning 源码第一件事都不是读代码而是先把 Q 表是什么想明白。Q 表的大小由环境决定状态数 nS 是行数动作数 nA 是列数。拿一个 5×5 网格地图举例位置状态一共 25 个动作集合是“上、下、左、右”4 个那么 Q 表就是 25×4 的矩阵。训练结束后你在某个状态里取该行最大值所在的列索引那就是策略在这个状态下会选择的动作。这个矩阵本身就是智能体对“在状态 s 执行动作 a 到底能换来多少长期回报”的经验估计。2.1 无模型学习的“无”到底指什么初读 Q-Learning 最容易产生的一个误解是它像动态规划一样先要知道状态转移概率 p(s|s,a)。它不是。Q-Learning 属于无模型的时序差分方法它不需要写出任何转移概率矩阵只需要在每个 step 里真的执行一次动作、观察下一个状态和一个即时奖励然后对 Q 表做一次自举更新。所谓自举就是拿“当前估计的目标值”去更新“当前估计”所以它天然适合那些只有仿真器、没有解析模型的任务。判断你手头的问题适不适合用 Q-Learning标准很简单你能不能构造一个 step 接口输入是一个动作输出是“下一个状态 即时奖励 是否结束”。能构造出来Q-Learning 就能在里面学构造不出来比如状态连续且无法离散、动作维度极高那就要考虑换函数逼近类方法而不是在 Q 表上硬扛。2.2 把贝尔曼更新式写成三行 MATLAB 代码Q-Learning 的核心公式只有一个几乎所有源码的主循环都在反复执行它Q(s,a) ← Q(s,a) α · [ r γ · max Q(s,a) − Q(s,a) ]这个式子读起来绕拆成 MATLAB 代码只有三行。下面这段是典型的写法也是绝大多数注释文档里会出现的样子qTarget reward gamma * max(Q(stateNext, :)); % 期望的长期回报估计 qDiff qTarget - Q(state, action); % 当前估计与目标估计的差 Q(state, action) Q(state, action) alpha * qDiff; % 沿误差方向走一小步第一行里的 max(Q(stateNext, :)) 是在“下一个状态”的所有动作里取最大 Q 值意思是“到了下一步智能体只盯着最好的那个动作来估计价值”这也是 Q-Learning 被称为 off-policy 的原因它更新的不是当前正在执行的那个策略而是假设“后续都按最优动作走”的价值。第二行算出来的 qDiff 是时序差分误差也就是这句更新真正想消除的偏差。第三行用 alpha 控制每次修正的步长alpha 太大容易震荡太小学得慢。理解这个更新式之后再看任何 Q-Learning 代码你都不会再迷路代码里一定有一个地方在算 max(Q(stateNext,:))有一个地方在乘 alpha你只要顺着这三个变量去找就能定位到整个学习的核心。2.3 Q 表用矩阵还是容器nS×nA 的零矩阵最省事在 MATLAB 里表示 Q 表最直接的做法就是建一个 nS×nA 的双精度零矩阵。MATLAB 的矩阵索引天然支持“状态编号做行、动作编号做列”不需要自己写复杂的数据结构。下面是初始化代码nS 100; % 状态总数由离散化后的状态空间决定 nA 4; % 动作数上下左右 Q zeros(nS, nA); % Q表全零初始化全零初始化是很多入门文档的默认选择因为它给了每个动作同样的起点智能体完全靠探索去发现差异。也有人偏爱随机初始化比如让 Q 表元素落在 [-0.01, 0.01] 之间目的是打破“所有状态-动作对完全一样”的对称性在某些稀疏奖励任务里会帮助探索。我自己的习惯是先全零起步如果发现探索效率太低再改成小幅随机初始化这样便于控制变量。当 nS × nA 超过百万级别时Q 表的内存和训练时间都会成为瓶颈这时常见做法是换用神经网络做 Q 值近似而不是继续扩表。但在入门和中小规模任务里矩阵是成本最低、最不容易出错的选择也最适合用来验证算法本身。3. 读懂那份 Q-Learning 源代码主循环的逐段注释这类文档里的 Q-Learning 主体函数组织方式大同小异外层循环管训练轮次episode内层循环管一个 episode 里的每一步step。把这两个循环的职责分清代码就不会读乱。外层循环负责“重开一局”内层循环负责“在这局里走到底”。更新 Q 表的位置一定在内层epsilon 衰减的位置一定在外层这个规律几乎适用所有入门实现。3.1 episode 与 step 两层循环的分工下面这段是 Q-Learning 主函数的常见结构注释按我自己的习惯重新整理过for ep 1:cfg.episodes state env.reset(); % 每个新episode把智能体放回起点 totalR 0; for step 1:cfg.maxSteps [action, Q] epsilonGreedyAction(Q, state, cfg.epsilon); [stateNext, reward, done] env.step(action); % Q-Learning 核心更新注意在拿到stateNext之后立即做 qTarget reward cfg.gamma * max(Q(stateNext, :)); Q(state, action) Q(state, action) ... cfg.alpha * (qTarget - Q(state, action)); state stateNext; totalR totalR reward; if done break; % 本轮结束跳出内层循环 end end episodeReturn(ep) totalR; % epsilon 在这里衰减作用在整个episode的策略选择上 cfg.epsilon max(cfg.epsilonMin, cfg.epsilon * cfg.epsilonDecay); end代码里的 done break 很多人会漏看。它的意义是智能体一旦进入终止状态比如到达目标、掉进陷阱这局就结束不用再走完 maxSteps。如果不加这个 break智能体在终止状态之后还会继续随机游走学到的东西会被自己产生的垃圾经验污染。另外注意 epsilon 衰减放在外层循环末尾而不是内层因为探索率是按“局”调整的不需要在一个 episode 内部中途变化。3.2 epsilon-greedy 选动作探索与利用的代码平衡动作选择是 Q-Learning 里最常被忽略、也最容易写错的地方。经典做法是 epsilon-greedy以 epsilon 的概率随机探索以 1−epsilon 的概率选择当前 Q 值最大的动作。实现代码一般长这样function [a, Q] epsilonGreedyAction(Q, s, eps) nA size(Q, 2); if rand() eps a randi([1, nA]); % 探索随机选一个动作 else [~, a] max(Q(s, :)); % 利用取当前最大Q值对应的动作 end end这段代码有两个细节值得说。第一rand() 产生的是 [0,1) 均匀随机数用它和 eps 比较eps1 时必然探索eps0 时完全贪婪语义很干净。第二max 函数在 MATLAB 里返回两个输出第二个输出是该行最大值的第一个出现位置也就是动作索引。如果 Q 表某一行全零max 会返回索引 1这会让智能体在一开始特别偏爱第一个动作所以在多动作任务里我一般会把探索率初始值设到 0.8 以上让前期大部分动作都来自随机探索避免这种偏好被固化。3.3 回报曲线与收敛判断episodeReturn 数组不是摆设很多源码里都会有一个变量用来记录每个 episode 的总奖励但注释往往只写“记录回报”没告诉你它该被怎么用。这个数组是判断训练是否收敛的重要依据。你需要做的第一件事就是在训练结束后直接画出来plot(episodeReturn); xlabel(Episode); ylabel(Total Return);如果曲线整体趋势向上说明智能体在进步如果曲线上升后在某个水平附近来回波动说明策略基本稳定再加大训练轮次也不会带来本质提升。如果曲线一直是平的且贴近某个低值那问题一定出在奖励设计或探索率上而不是训练轮次不够。把这段话记在心里你在调参时就能少走很多弯路。4. 把源码改造成自己的环境离散化、奖励与三个必调参数标题里的文档大概率自带一个示例环境比如网格迷宫或简易小车。你真正要做的是把它替换成自己的任务。替换过程有三个必经步骤状态离散化、奖励设计、参数调整。每一步都有容易忽略的坑下面逐个展开。4.1 连续坐标离散化gridSize 与 stateIndex 映射如果你要处理的是连续坐标比如一个 2D 平面上的移动机器人第一件事就是把连续坐标切成格子。常见做法是把每个维度等分然后用线性索引把二维坐标压成一维状态编号。下面是我惯用的映射函数function s stateIndex(x, y, xLim, yLim, nx, ny) % x,y : 连续坐标 % xLim : x轴范围如 [0, 10] % yLim : y轴范围 % nx, ny : x和y方向各切成多少格 ix floor((x - xLim(1)) / (xLim(2) - xLim(1)) * nx) 1; iy floor((y - yLim(1)) / (yLim(2) - yLim(1)) * ny) 1; ix max(min(ix, nx), 1); % 防止越界 iy max(min(iy, ny), 1); s (iy - 1) * nx ix; % 行优先展开成一维索引 end这里有个关键参数 nx 和 ny它们决定状态空间的粒度。nx10、ny10 就是 100 个状态Q 表是 100×4。格子切得越细策略越精细但训练需要的数据量也随之增加。格子切得太粗智能体可能在一个格子里反复横跳策略表现很粗糙。我的建议是先从 8×8 或 10×10 起步跑通之后再逐步加细而不是一开始就切 50×50。4.2 学习率 α 与折扣因子 γ一组能直接落地的参数区间α 和 γ 是 Q-Learning 里最常调的两个参数。下表是我在离散任务里的常用取值区间可以当作起点但不是万能公式。参数常见取值范围对训练的影响α学习率0.1 ~ 0.3越大单步更新越猛收敛快但易震荡越小越稳但需要更多 episodeγ折扣因子0.9 ~ 0.99越接近 1 越看重长远收益适合长期规划任务越小越短视ε探索率1.0 起步衰减到 0.05~0.1前期探索、后期利用衰减太快会让 Q 表学不全α 的取值要看环境随机性。如果 step 接口的返回奖励带有随机噪声α 超过 0.3 很容易让 Q 表跟着噪声乱跳。反之如果环境完全确定α 可以适当加大到 0.5 也没问题。γ 的取值要看任务目标目标是走到远处某个终点γ 设 0.95 以上目标是尽快完成某一步操作γ 设 0.9 左右就可以。还有一种常见调法训练中期把 α 从 0.3 逐步降到 0.05前期学得快后期微调得稳。4.3 奖励塑形稀疏奖励环境学不动用中间奖励扶一把如果环境只在终点给一个 1 奖励其他位置都是 0Q-Learning 会学得极慢甚至完全学不动。原因很简单智能体随机游走很长时间都碰不到一次“正反馈”Q 表缺乏梯度和信号。常见做法是引入奖励塑形也就是给中间过程加一些“引导性”的小奖励。比如实现一个 step 的奖励逻辑r -0.04; % 每走一步小惩罚逼着少绕路 if isGoal(stateNext) r r 1.0; % 到达目标额外加大奖励 elseif isObstacle(stateNext) r r - 0.5; % 撞墙给惩罚 elseif dist(stateNext) dist(state) r r 0.02; % 离目标更近给微小正反馈 end这里的思路是在稀疏的大奖之外叠加一个稠密的小奖励信号让智能体每一步都能感受到“变好还是变坏”。但要注意塑形奖励的幅度不能太大否则会喧宾夺主智能体为了攒小奖励而偏离真正目标。一般我会把小奖励的数量级控制在大奖励的十分之一以内这样既能引导又不至于扭曲目标。5. Q-Learning 避坑记录训练不收敛的 4 个现场与排查方法这一章是血泪经验。Q-Learning 代码逻辑就那么几行但跑起来不收敛是常态。下面 4 个问题是我在多个模拟项目里都见过的典型翻车现场每条都按“现象 → 原因 → 解决”来写。5.1 Q 表全零探索停了地图根本没跑全现象训练结束后Q 表大部分元素还是 0episodeReturn 曲线是一条平的低线智能体反复在起点附近打转。原因epsilon 初始值设了 1.0但衰减率设成了每 episode 乘 0.9结果 20 个 episode 之后 epsilon 就掉到了 0.12智能体过早进入“完全利用”阶段。此时 Q 表里大部分状态-动作对根本没被访问过永远不会被更新策略被锁定在最初那几个随机动作上。解决把 epsilon 的衰减拉长。常见做法是让 epsilon 在训练总轮次的 30%~50% 阶段再衰减到 0.1比如总共 1000 个 episode前 300 个 episode 内保持 epsilon 不低于 0.5。还有一种更省心的方式是按线性衰减而不是几何衰减eps 1 - (ep / cfg.episodes) * 0.9; % 从1线性降到0.15.2 Q 值发散发成天文数字α 太大配合 γ 太接近 1现象某个状态-动作对的 Q 值在几百个 episode 内从 0 涨到几千甚至几万其他值还都正常训练曲线出现明显尖峰。原因γ0.99、α0.5 的组合最容易出这个问题。目标值 qTarget reward gamma×max(Q(stateNext,:)) 本身在训练初期就很不稳定α 又大每一步都在往一个移动靶上猛跳误差被反复放大。特别是如果状态转移存在随机性目标值抖动会更明显。解决把 α 降到 0.1~0.2γ 降到 0.9~0.95先让训练稳定下来再看收敛速度。另外可以在更新前后打印 qDiff 的绝对值如果最大值持续增长说明参数搭配有问题而不是训练轮数不够。if abs(qDiff) 10 mod(ep, 50) 0 fprintf(Episode %d, State %d, Action %d, qDiff %.2f\n, ... ep, state, action, qDiff); end5.3 训练慢到怀疑人生逐状态循环里的重复计算太多现象状态数一多MATLAB 跑一个 500 个 episode 的训练要几分钟到几十分钟完全没法快速验证调参想法。原因很多人把推理、更新、边界判断都写在内层循环里每一步都重复计算相同的东西。比如每次都重新算 dist(state)但状态索引没变或者每一步都调用一次匿名函数句柄MATLAB 的函数调用开销被放大了几十倍。解决先把不随 step 变化的量提到循环外面算好存成数组。比如把所有状态的目标距离预先算一遍distTable precomputeDistAllStates(gridMap); % 循环外一次性算好 % 内层循环里直接查表不再调用dist函数 r stepReward(stateNext, distTable);从我的实测经验来看把 dist 计算改成查表之后训练时间通常能有 30%~50% 的下降。如果状态空间超过几万还可以考虑把 Q 表更新向量化一次性更新一批状态的动作但入门阶段先把重复计算消除是最划算的优化。5.4 两次训练结果差一个量级随机种子没固定现象同一个脚本、同一组参数连续跑两次episodeReturn 曲线形状差不多但最终 Q 表偏差很大甚至策略都不一样。原因rand() 和 randi() 依赖全局随机数流。MATLAB 每次启动时随机种子不同导致每次训练探索轨迹完全不同。这在带随机性的环境里很正常但如果参数对比实验也得不出可重复结论那就是玄学调参。解决在训练脚本最开头固定随机种子保存中间结果rng(42); % 固定随机种子保证结果可复现 % 每个episode结束定期保存Q表防止训练中断或需要回溯 if mod(ep, 200) 0 save(sprintf(Q_checkpoint_%04d.mat, ep), Q); end固定种子不是为了让训练一次就对而是为了让你在改参数时能“控制变量”确认某一项改动到底带来了什么影响。同时在多组参数对比时各组用同一个种子才算公平对比。6. 让学习过程看得见Q 表热力图与回报滑动均值自检6.1 用 imagesc 把 Q 表画成热力图训练过程一目了然训练结束之后光看数值矩阵很难发现策略规律。最直接的做法是用 imagesc 把 Q 表画成热力图行是状态、列是动作颜色越亮代表该动作在这一状态下的估计价值越高。一行代码就能输出figure; imagesc(Q); % Q是nS×nA矩阵 colorbar; set(gca, YDir, normal); xlabel(Action Index); ylabel(State Index); title(Trained Q-Table Heatmap);看这张图有一个很实用的技巧如果地图里的状态有空间含义把状态索引按网格形状 reshape 回二维然后分动作画出四张热力图就能看出“在地图哪个区域偏好哪个动作”。我习惯的做法是把四个动作对应的 Q 值列分别 reshape 成网格尺寸再用 subplot 排成一张大图。这样可以快速发现策略是否在某个区域反复横跳比如“在走廊口不停左右摇摆”这类问题光看一维曲线很难定位但看空间热力图一眼就能看出来。6.2 回报滑动均值与三条判断规则episodeReturn 本身波动很大直接画出来很难判断趋势。用 movmean 做滑动平均是一个成本极低但收益很高的自检习惯smoothReturn movmean(episodeReturn, 50); % 50个episode的滑动均值 figure; plot(smoothReturn, LineWidth, 1.5); xlabel(Episode); ylabel(Smoothed Total Return);判断是否收敛我一般就看三个现象曲线持续上升后进入平台说明训练已经收敛再加轮次收益有限曲线在平台期后突然大幅下坠说明训练不稳定大概率是 α 过大或奖励设计有问题曲线从一开始就躺在低值区那基本可以确认是探索不足或奖励过稀疏先回去改这两个方向比继续加训练轮次有效。我现在的习惯是任何 Q-Learning 脚本至少要保证三个输出Q 表、episodeReturn 曲线、最终策略。训练之前先固定随机种子训练完之后先看热力图再看滑动均值曲线最后才看数值。以前我有段时间只盯着“最后能不能到终点”这一件事结果就是调了一整天参数仍然不知道问题出在奖励还是探索上。后来把这三步养成习惯再难的调参任务也能一步步定位到具体环节。希望这个自检流程也能帮你少走调参的弯路希望帮到你。本文还有配套的精品资源点击获取