简介:这是一份以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 比较,eps=1 时必然探索,eps=0 时完全贪婪,语义很干净。第二,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,它们决定状态空间的粒度。nx=10、ny=10 就是 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 奖励,其他位置都是 0,Q-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 表大部分元素还是 0,episodeReturn 曲线是一条平的低线,智能体反复在起点附近打转。
原因: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 曲线、最终策略。训练之前先固定随机种子,训练完之后先看热力图,再看滑动均值曲线,最后才看数值。以前我有段时间只盯着“最后能不能到终点”这一件事,结果就是调了一整天参数仍然不知道问题出在奖励还是探索上。后来把这三步养成习惯,再难的调参任务也能一步步定位到具体环节。希望这个自检流程也能帮你少走调参的弯路,希望帮到你。
本文还有配套的精品资源,点击获取