简介:针对强化学习入门者与MATLAB算法研究者,提供一份Q-Learning源代码及详尽注释文档。案例设定为王子在16×1网格世界寻找公主,通过上下左右动作避开障碍与陷阱,完整演示环境建模、奖赏矩阵设计、epsilon-greedy策略和贝尔曼方程更新Q值等核心流程。压缩包只有1个doc文件,大小33KB,适合快速下载并对照代码逐行理解算法细节。目前已有1378人学习,特别适合想通过简单游戏实例掌握无模型强化学习决策原理的读者。资料内包含可移动矩阵、奖赏矩阵、折扣因子gamma、学习率epsilon的设置说明,训练循环与Q值更新步骤均有注释,读完后还能利用最终Q矩阵规划最优路径,为后续迁移到复杂任务打下扎实基础。
1. Q-Learning 到底是什么:一个 16 格的寻路游戏讲透无模型决策
先给结论:这份 MATLAB 源码不是工业级的强化学习框架,而是一个把 Q-Learning 核心逻辑压缩到 60 行以内的教学样本。它用一个 4×4 网格的王子救公主游戏,把状态、动作、奖赏、Q 值更新、ε-greedy 策略全部落地成可直接运行的代码。对刚接触强化学习的人,它比任何教科书伪代码都直观;对已经跑过 PyTorch 或 TensorFlow 的从业者,这份代码反而是最干净的"裸算法"参考——没有框架封装、没有分布式训练、没有经验回放,只有贝尔曼方程最朴素的表达。
适合三类人:一是想用 MATLAB 快速验证强化学习思路的研究生;二是准备面试强化学习岗位、需要手推 Q-Learning 流程的求职者;三是教《人工智能导论》需要可复现案例的教师。值得注意的是,这份代码 900 次 episode 的训练量对 16 个状态来说绰绰有余,但它刻意保留了可读性而不是性能优化,所以别拿它去跑大规模连续状态空间的任务。
2. 先建模再求解:可移动矩阵与奖赏矩阵的设计逻辑
2.1 环境建模:16 个格子的状态空间与动作边界
代码的核心数据结构是 16×16 的矩阵 T,T(i,j)=0 表示无法从 i 移动到 j,1 表示可移动。这个设计很有意思,它没有用常见的坐标偏移,而是用一维编号直接索引。4×4 网格中,每个格子的编号从上到下、从左到右依次是 1 到 16。四个基本动作被定义为:上移是 i-1、下移是 i+1、左移是 i-4、右移是 i+4。
边界条件的处理是这段代码最值得读的地方。上行动作检查 1、5、9、13 这四个编号,因为它们在每一列的最顶端,再往上就越界了。下行动作反过来,检查 4、8、12、16,这是每列的最底端。左行动作要求 i>4,右行动作要求 i<13,分别对应第一行和最后一行的边界。这种边界判断相比传统的 if-else 嵌套更紧凑,但它把网格拓扑硬编码进了编号规则,一旦改成更复杂的图结构(比如带环路的迷宫)就得重写整个矩阵。
障碍物设置在源码里用了两行极简的代码。T(:,[10,11,14])=0 把 10、11、14 三个位置的所有入边和出边全部切断,这里有个技术细节需要注意:该语句只设置入边为 0,如果要彻底隔离障碍物,还需要将障碍物位置的出边也置为 0。第 13 格的跳跃动作是另一个关键设计:T(13,:)=0 清空第 13 格的所有动作,然后单独设置 T(13,15)=1,相当于给王子一个从 13 直接跳到 15 的传送门。这种"清空再赋值"的写法写得很清晰,比逐个置 0 更不容易漏掉某个方向。
2.2 奖赏矩阵的参数玄学:为什么终点要设 10、陷阱要设 -5
R 矩阵的初始化逻辑是理解整个算法收敛性的钥匙。R=-1*ones(16) 给所有状态-动作对的即时奖赏都设为 -1,这代表"每走一步都有时间成本"。然后 R(:,6)=0 把第 6 格设为中性区域,R(:,7)=-5 把第 7 格设为陷阱,R(:,16)=10 把终点公主所在的位置设为最大正奖赏,R(13,15)=5 为跳跃动作设中间奖赏。
这里的奖赏设计有很强的教学意图。如果所有非目标状态的奖赏都是 -1,Q-Learning 会倾向于走最短路径,因为每一步的即时惩罚在累积。如果某些格子设 0,会引导 Agent 绕行这些区域(它们不惩罚但也不奖励,只作为过渡格)。-5 的陷阱惩罚比 -1 大得多,Agent 在训练初期会因为 Q 值尚不稳定而随机踩入,但后期会被有效避开。10 的终点奖赏设置为超过一切路径累积惩罚之和,否则 Agent 可能学到"不走到终点循环绕行"的投机策略,这在连续任务里是个常见翻车点,但在这里被 10 的绝对值压住了。
还有个隐藏参数容易被忽略:折扣因子 gamma=0.8。0.8 表示未来奖励折算到当前的值,越小越目光短浅,越接近 1 越有远见。代码取 0.8 偏保守,因为网格世界只有 16 个格子,最长路径也不会超过 15 步,0.8 的衰减足够把终点的 10 折算到沿途每个状态上。如果把 gamma 改成 0.1,王子在第 1 格时看到终点奖励折算后只有 10×0.1^14,几乎等于零,训练效果会显著退化。这个参数是整个算法中最敏感的超参,调试时优先动它。
% 定义可移动矩阵 T T = zeros(16); % T(i,j)=0 表示不可从 i 位置移动至 j 位置,1 则为可移动 for i = 1:16 % 上行动作 N:不能在第一列(1,5,9,13)执行 if not(ismember(i, [1, 5, 9, 13])) T(i, i - 1) = 1; end % 下行动作 S:不能在第四列(4,8,12,16)执行 if not(ismember(i, [4, 8, 12, 16])) T(i, i + 1) = 1; end % 左行动作 W:不能在第一行(1-4)执行 if i > 4 T(i, i - 4) = 1; end % 右行动作 E:不能在第四行(13-16)执行 if i < 13 T(i, i + 4) = 1; end end % 障碍物:10、11、14 三个格子不可进入 T(:, [10, 11, 14]) = 0; % 跳跃:从 13 可直接到达 15 T(13, :) = 0; T(13, 15) = 1;这段代码的逻辑很直白:先用双层循环补齐所有合法移动,再用两个赋值语句粗暴地砍掉障碍物和跳跃的例外。not(ismember(i, ...))的写法比逐列 if 判断更优雅,但在大规模网格下性能会下降,因为 ismember 是线性搜索。我一般习惯把边界编号预计算成集合(比如边界集合 B),然后if ~ismember(i, B),这样循环内只做一次集合查找。
2.3 奖赏矩阵的完整结构:从即时奖励到长期回报
奖赏矩阵的完整赋值在代码中通过连续矩阵切片完成。这里把关键的奖赏设计逻辑整理成表格,方便对照修改参数。
| 位置或动作 | 奖赏值 | 含义 | 修改建议 |
|---|---|---|---|
| 所有非特殊位置 | -1 | 每一步的时间成本 | 调成 -0.5 会鼓励绕远路,调成 -2 会更追求最短路径 |
| 第 6 列(R(:,6)=0) | 0 | 中性过渡区域 | 在不改变路径长度的前提下可以多设几个,训练会更快 |
| 第 7 列(R(:,7)=-5) | -5 | 陷阱惩罚 | 增大到 -10 会让 Agent 绕行更果断,但需要更多训练轮次 |
| 第 16 列(R(:,16)=10) | 10 | 终点奖赏 | 建议保持大于 10,否则可能出现循环绕行不结束 |
| 第 13 行到第 15 列(R(13,15)=5) | 5 | 跳跃动作的即时奖赏 | 这是传送门的代价,设为 5 说明抄近道有额外收益 |
R 矩阵和 T 矩阵的关系需要特别强调:T 决定哪里能走,R 决定走了以后划不划算。Q-Learning 的更新公式会同时受这两个矩阵影响,如果 T 中某条边可以走但 R 中对应位置是 -5,Agent 在探索初期会不断踩坑,直到 Q 值收敛后学会避开。这个"能走但不值得走"的对比,是整个强化学习里最难向新手讲清楚的部分,这份代码通过 7 号陷阱格非常直观地呈现出来了。
% 定义奖赏矩阵 R R = -1 * ones(16); R(:, 6) = 0; % 第 6 格为中性区域,无额外惩罚 R(:, 7) = -5; % 第 7 格为陷阱,大幅惩罚 R(:, 16) = 10; % 终点公主所在位置,最大奖赏 R(13, 15) = 5; % 跳跃动作的奖赏这段代码的执行顺序有讲究:先全矩阵赋 -1,再覆盖特殊位置。R(:, 6)=0是把第 6 列全部置零,这意味着从任意格子移动到第 6 格的即时奖赏都是 0,注意这里矩阵的行是当前状态、列是下一状态。理解这个方向的映射,是读懂后面 Q 值更新公式的前提,一旦搞反了会把整条训练过程都带偏。
3. epsilon-greedy 策略与 Q 值更新:从贝尔曼方程到 MATLAB 实现
3.1 训练循环结构:900 个 episode 里发生了什么
训练循环的核心代码不长,但信息密度极高。外层 900 次 episode 迭代,内层 while 循环持续到状态到达 16 为止。每次 episode 开始时,state=unidrnd(16)在 1 到 16 之间随机取整,然后while ismember(state,[10,11,14])把初始状态重新采样直到避开障碍物。这个做法在实际使用时要注意一个隐患:如果障碍物占比过高(比如超过 50%),随机重采样可能死循环。这个案例只有三个障碍格,重采样最多若干次就能跳出循环,但我在跑自己的仿真网格(20% 障碍)时确实遇到过卡死,后来改成了预生成可达状态列表再从中随机选。
内层循环每走一步就更新一次 Q 值。possible_actions=find(T(state,:)==1)找出当前状态下所有合法动作的索引,possible_Q=Q(state,possible_actions)取出这些动作对应的 Q 值。这里find返回的是动作编号(即下一状态的编号),整个代码用状态编号直接充当动作编号,简化了实现,但也让代码语义不够直观——对初学者容易造成困惑。
判断是否探索的判断条件是if rand()<epsilon,小于 0.3 就随机选动作,否则选 Q 值最大的动作。这段逻辑体现了 epsilon-greedy 策略的核心:以 epsilon 概率探索,否则利用。它最大的优点是简单且收敛性有理论保障,缺点是 epsilon 固定不变(石头策略),训练后期最优动作已经找到后仍然有 30% 的随机扰动,导致策略不稳定。如果要改进,常见做法是设置衰减因子,让 epsilon 从 0.9 逐渐下降到 0.01,这份代码没有做这个处理,是一个值得动手改的优化点。
3.2 Q 值更新公式:为什么这一行代码就是全部秘密
Q 值更新的核心语句是Q(state,action)=R(state,action)+gamma*max(Q(action,:))。这是贝尔曼方程最简洁的实现。拆开看:R(state,action)是当前状态执行动作得到的即时奖赏,max(Q(action,:))是在下一状态中所有可能动作的 Q 值最大值,即对未来回报的最优估计,gamma是折扣系数,把未来回报折算到当下。整个更新式表达的含义是:新的 Q 值 = 当前回报 + 折扣 × 下一状态的最优未来回报。
这行代码有个容易踩的坑:更新完成后state=action直接跳转到下一状态。如果action恰好是障碍物位置(理论上如果 R 和 T 矩阵不一致就会发生,比如 T 矩阵忘了清零某条边),程序不会报错,但 Q 值会沿着非法路径传播。判断 R 和 T 的一致性应该在做训练前就验证一次,一个简单方法是检查所有T(i,j)=1的位置对应的R(i,j)不为 NaN 且数值有界。
还有一处隐蔽的设计:max(Q(action,:))计算时把终点状态 16 也包括进去了。Q(16,:) 全为零,这意味着到达终点后未来奖励的估算是 0。这个设计是否合理取决于任务定义:如果是"到达终点即终止"的单幕任务,终点后没有未来回报,所以设为 0 完全合理。但如果是持续任务(到达终点后重置并继续),就需要给终点状态单独设计出口值。这个细节区分了回合制任务和连续任务,是实际工程里最容易忽略的边界。
% 初始化 Q 矩阵 Q = zeros(16); % 折扣因子 gamma,控制未来奖励的重要性 gamma = 0.8; % epsilon-greedy 策略的探索概率 epsilon = 0.3; % 训练循环 for episode = 1:900 % 随机化初始状态,确保不在障碍物上 state = unidrnd(16); while ismember(state, [10, 11, 14]) state = unidrnd(16); end % 内层循环:直到到达终点状态 16 while state ~= 16 % 获取当前状态下所有合法动作 possible_actions = find(T(state, :) == 1); % 获取对应 Q 值 possible_Q = Q(state, possible_actions); % epsilon-greedy 策略选择动作 if rand() < epsilon % 随机探索 choose = unidrnd(length(possible_actions)); action = possible_actions(choose); else % 利用:选 Q 值最大的动作 [~, index] = max(possible_Q); action = possible_actions(index); end % 核心:贝尔曼方程更新 Q 值 Q(state, action) = R(state, action) + gamma * max(Q(action, :)); % 状态转移 state = action; end % 每 100 个 episode 输出一次进度 if mod(episode, 100) == 0 fprintf('训练的次数为:%d\n', episode); end end disp('训练结束');这段代码中rand()<epsilon的判断是理解探索与利用平衡的关键。epsilon=0.3 表示每次决策有 30% 概率随机选动作,70% 概率选当前最优。这个比例在小型网格任务里效果不错,但在更复杂的任务中,固定 0.3 的探索率会让收敛速度变慢。我一般会在训练初期把 epsilon 设为 0.6 加速探索,然后每个 episode 乘以 0.995 衰减,到训练后期接近零,这样既能找到最优解又能稳定收敛。
3.3 策略评估与路径规划:训练后如何用 Q 矩阵
训练结束后代码进行了一次路径规划演示。start_0=1设定起点,path=start_0初始化路径向量。while path(end)~=16循环中,[~,next]=max(Q(start_0,:))找到起点行中 Q 值最大的动作,path=[path,next]把下一步加入路径,然后start_0=next更新当前位置。这段代码有个隐含假设:从起点出发一定能到达终点。如果 Q 矩阵还没完全收敛(比如训练次数不够),max(Q(start_0,:))可能返回一个 Q 值很小的动作,甚至循环到某个状态后卡在局部最优不来。
判断 Q 矩阵是否收敛的方法很简单:对每个状态,检查max(Q(state,:))是否在多次运行中保持稳定。更工程化的验证方法是设计一个独立测试集:随机取 50 个起始状态,分别用 Q 矩阵跑路径,统计平均步数和成功率。这份代码没有做这个步骤,我复现时一般会补上这段验证逻辑。
还有一个细节值得注意:max(Q(start_0,:))的语义是"选择 Q 值最大的动作",但如果两个动作 Q 值相同(比如对称位置),max 会取索引小的那个(MATLAB 的约定),这可能导致路径偏向某一侧。如果对路径多样性有要求,可以在 max 之前加randperm打乱同值动作的顺序,但这份代码为了演示简洁没有处理。
% 训练后寻找路径 start_0 = 1; % 设置起始点 path = start_0; % 初始化路径向量 while path(end) ~= 16 [~, next] = max(Q(start_0, :)); % 找当前状态 Q 值最大的动作 path = [path, next]; % 加入路径 start_0 = next; % 更新当前状态 end % 输出最优路径 disp(path);这段测试代码是整个算法最直接的落地验证。路径的每一步都是贪心选择当前状态的 Q 最大值,不涉及任何探索,因此得到的路径一定是"当前 Q 矩阵下的最优路径"。如果训练充分,这条路径就是全局最优路径;如果训练不足,路径会表现出明显的绕路或卡死,可以用来反推训练是否收敛。
4. 参数调节与避坑:我把这份代码跑挂了五次才摸清的三类问题
4.1 随机性陷阱:同样的代码两次运行结果不同
第一次跑这份代码时,我连续运行了两次,发现输出的一次路径偶有不同。原因很容易理解:源程序里涉及随机生成初始状态的 unidrnd 和随机探索 rand,每次实验产生的结果天然存在差异,但这份代码没有设定随机种子,导致每次运行产生的探索序列完全随机。
如果希望实验可复现,应该在脚本开头加上rng(0)或rng('default'),固定随机数生成器的种子。对论文实验来说,这是一条黄金法则:不给随机种子意味着审稿人无法复现你的结果。我从那以后每次跑强化学习代码第一行,都会强制写上rng(42),既保证可复现,又避免改代码调参时分不清是参数影响还是随机性影响。
4.2 终点 Q 值的自举陷阱:Q(16,:) 全是零意味着什么
细看 Q 更新公式会注意一个问题:当state=15且action=16(即走到终点)时,更新式为Q(15,16)=R(15,16)+gamma*max(Q(16,:))。由于Q(16,:)全部初始化为零,所以max(Q(16,:))=0,最终Q(15,16)=R(15,16)+0=10。这意味着到达终点后的未来回报被定义为 0,这个值是否合理完全取决于任务定义。
如果你在原有代码基础上把终点改造为"到达后自动跳转到起点并继续任务"(这是连续任务的标准玩法),那么Q(16,:)应该改为R(16,:)+gamma*max(Q(...))的某种设计,否则 Agent 会永远选择在终点停留,因为那里的 Q 值最高。这个"终点状态的值函数"设计是 Q-Learning 里区分回合任务与连续任务的分水岭,也是初学者最容易翻车的点。正确做法是预先定义好终点的后续状态和奖赏,而不是默认全零。
4.3 跳跃边界的维度问题:T(13,:)=0 会不会误伤合法动作
源码中T(13,:)=0; T(13,15)=1先把第 13 行的所有边清零,再单独打开 13 到 15 的边。这个顺序让我一开始忽略了它清空了 13 到 9、13 到 14(如果不在障碍物列表里)等合法方向。好在原代码里第 14 格本来就是障碍物,所以这里没出问题。但如果读者要复用到自己的网格,遇到 13 有原本到 14 的需求,就会被这行T(13,:)=0一起砍掉。
最稳妥的做法是显式地单独关掉不需要的边,而不是整行清零再补一根。例如T(13,[9,10,14])=0; T(13,15)=1的写法语义更清晰。我不知道源代码这样写是有意为之还是为了简洁,但从工程角度来说,这种"清空-再赋值"的模式在代码审查中属于高危操作,因为它极容易砍掉不该砍的边,从而改变环境拓扑。
4.4 障碍物状态初始化重采样的空转问题
while ismember(state,[10,11,14])的重采样循环在障碍物较少时没问题,但如果障碍物概率超过 50%(比如你改成一个 8×8 的网格,中间半块都是障碍),这个重采样循环的平均迭代次数会急剧增加,极端情况下接近死循环。更稳健的初始化方式是从合法状态集合中直接随机选一个:
valid_states = find(~ismember(1:16, [10, 11, 14])); state = valid_states(unidrnd(length(valid_states)));这段改进代码的思路是预计算所有合法状态的索引集,然后直接从中采样,根本不需要循环重试。对一个 16 格的网格,原代码的重采样效率尚可接受,但如果把状态空间扩大到上千格,每次 episode 都做重采样会浪费不少计算时间。若你的环境中障碍物比例较大,建议优先采用预计算合法状态集的方案。
4.5 epsilon 固定带来的策略抖动问题
源码固定 epsilon=0.3,训练后期最优策略已经出现,但仍有 30% 概率随机探索。这种抖动的表现是:明明沿着某条路能到终点,Agent 却隔三差五跳去陷阱格绕一圈。解决这个问题最常见的做法是线性衰减。
epsilon_start = 0.9; epsilon_end = 0.01; epsilon = epsilon_start; for episode = 1:900 epsilon = epsilon_start - (episode / 900) * (epsilon_start - epsilon_end); % 后续训练逻辑保持不变 end这段代码在每个 episode 开始时更新 epsilon,实现了从 0.9 到 0.01 的线性衰减。前期高探索率让 Agent 充分感知环境,后期低探索率让策略稳定收敛。实际使用中这份改进可以将最优路径的收敛速度提升一个档位。如果环境变更复杂,也可以尝试指数衰减或者基于 Q 值变化量动态调整 epsilon,但那属于进阶调参,需要更仔细的设计。
5. 把这份 MATLAB 代码用到真实任务:验证方法与三个进阶改动方向
5.1 训练进度可视化:画出 Q 值和路径长度随 episode 的收敛曲线
原代码每隔 100 个 episode 输出一次文本进度,但没有图形化展示。真实任务中我习惯把每次 episode 的步数记录下来,训练结束后画一条收敛曲线,能直观看到策略是否稳定、有没有发散风险。
steps_history = zeros(1, 900); for episode = 1:900 % 训练逻辑(与源码相同,但同时记录步数) step_count = 0; while state ~= 16 % 省略原动作选择和 Q 值更新逻辑 step_count = step_count + 1; end steps_history(episode) = step_count; end plot(1:900, steps_history); xlabel('Episode'); ylabel('Steps to Goal'); title('Q-Learning Convergence');这段可视化代码把每一步记录到steps_history里,理论上训练早期步数波动大(Agent 在随机探索),中后期步数应该单调下降并收敛到最优路径长度。如果曲线没有下降趋势,说明奖赏设置或 epsilon 调度有问题,应该回看第 4 章提到的参数坑。用图形来判断收敛比用文本输出直观得多,这是我复现任何强化学习算法的习惯动作。
补充一点,判断收敛还有个更严格的方式:把 Q 矩阵的差值范数norm(Q_new - Q_old, 'fro')作为监控指标,当该值低于某个阈值(比如 1e-3)时认为训练完成。这个指标能更精确地反映 Q 值是否还在变化,优于单纯看路径长度。
5.2 从网格到连续状态:把状态编号改成二维坐标
这份代码最大的简化在于状态是线性编号而非二维坐标。真实世界的状态往往有空间结构,比如机器人在 2D 平面上的位置。把这份代码升级成 2D 坐标的常见做法是维护一个状态索引映射表:
% 建立状态编号到坐标的映射 state_coords = zeros(16, 2); idx = 1; for row = 1:4 for col = 1:4 state_coords(idx, :) = [row, col]; idx = idx + 1; end end这段代码将 1 到 16 的编号映射为 4×4 网格上的(row, col)坐标。这样在修改障碍物或奖赏时不必再关心编号,而是直接用state_coords(11, :)获取第 11 格的坐标位置。这个映射表同样可以用于可视化,比如画网格热力图时通过坐标索引对应位置。
5.3 从 Q-Learning 到 SARSA:一个 if 的区别
Q-Learning 的更新用的是下一状态的最大 Q 值max(Q(action,:)),而 SARSA 用的是实际执行动作后的 Q 值。这两种算法的差别是理解 off-policy 和 on-policy 的关键。修改这份源码得到 SARSA 的做法是:在更新前先选好下一个动作,更新公式里替换掉最大值部分。
% SARSA 核心区别:更新时使用实际执行的动作对应的 Q 值 next_possible_actions = find(T(action, :) == 1); next_possible_Q = Q(action, next_possible_actions); if rand() < epsilon next_choose = unidrnd(length(next_possible_actions)); next_action = next_possible_actions(next_choose); else [~, next_index] = max(next_possible_Q); next_action = next_possible_actions(next_index); end Q(state, action) = R(state, action) + gamma * Q(action, next_action);这段代码把原来max(Q(action,:))替换为Q(action,next_action),其中next_action是基于当前 Q 值选出的动作(如果环境里没有明确的下一次决策,就用贪心选出的动作代替)。这样能保证更新的是实际遵循策略下的回报,符合 on-policy 的要求。这类 if 级改动往往是理解 off-policy(Q-Learning)与 on-policy(SARSA)差异的最佳练习,结束后你可以比较两种算法在这个网格任务中的收敛速度和最终路径差异,这种对比能让理论概念变得立体。
这段代码的改进让我形成了自己的习惯:拿到任何一份强化学习源码,第一件事是把训练逻辑和测试逻辑分开,第二件事是手动添加随机种子和收敛监控,第三件事是研究"如果终点状态的下一个状态不为空,Q 更新会怎么变"。这三步做完,源代码基本上就能真正做到"改得动、调得通、跑得明白"。
最后说一句,当时跑这份代码时,我在rng和max这两个点上各栽了一次跟头,从那以后我每次复现强化学习代码都强制走一遍检查随机种子、验证终点 Q 更新、预计算合法状态这三步,希望帮到你。
本文还有配套的精品资源,点击获取