简介:这份资源面向强化学习入门者与需要动手复现经典算法的学习者,以Matlab代码加中文注释的形式,完整呈现Q-Learning在无模型决策场景中的实现思路。案例把算法放进16×1网格世界:王子可上下左右移动,需绕开障碍与陷阱区域,最终抵达第16格的公主处,借此讲清可移动矩阵、奖赏矩阵、Q矩阵初始化、折扣因子与epsilon-greedy策略选择等关键环节。压缩包仅33KB,含1个doc文件,将源代码与逐行注释集中编排,便于对照阅读训练循环、Q值贝尔曼更新及训练结束后的最优路径回溯过程。资源已有1380人学习,适合希望从零理解强化学习试错学习机制、并借助注释快速跑通小规模实验的读者参考。
1. 十六个格子里跑通强化学习:一份能直接运行的 Q-Learning 源码
第一次拿到这份Q-Learning 源代码及注释(matlab).doc,我以为是篇讲理论的文档,打开才发现是一段能直接粘进 MATLAB 跑起来的完整脚本。它把强化学习里最经典的 Q-Learning 塞进了一个 16 格的一维网格世界:王子从任意非障碍格出发,上下左右移动,避开陷阱、绕开障碍,最终走到第 16 格的公主那里拿 +10 奖励。整份代码不到百行,却把环境建模、奖励矩阵、Q 表更新、epsilon-greedy 策略、路径回溯全串了一遍。适合两类人:一是刚接触强化学习、想找个最小可运行例子把公式跑成数字的学生;二是带课或做 Demo、需要一份能当场演示“智能体怎么学会走路”的开发者。下面我按“能干什么 → 怎么跑 → 怎么改”拆开讲。
2. 环境建模与矩阵设计:16 格世界是怎么被翻译成矩阵的
2.1 可移动矩阵 T:把“能不能走”变成 0/1 查表
这份代码最值得先看的是它没有用任何对象或类,而是用两个 16×16 的矩阵把整个环境描述完了。第一个是T,行是当前状态,列是目标状态,T(i,j)=1表示能从 i 走到 j。四个方向的判断全靠索引算术:上减 1、下加 1、左减 4、右加 4,再用边界条件卡住越界。
clear % 定义可移动矩阵 T T = zeros(16); % T(i,j)=0 表示不可从 i 位置移动至 j 位置,1 则为可移动 for i = 1:16 % 上行动作 N if ~ismember(i, [1,5,9,13]) T(i, i-1) = 1; end % 下行动作 S if ~ismember(i, [4,8,12,16]) T(i, i+1) = 1; end % 左行动作 W if i > 4 T(i, i-4) = 1; end % 右行动作 E if i < 13 T(i, i+4) = 1; end end % 定义不可到达区域(障碍物) T(:, [10,11,14]) = 0; % 定义跳跃:13 号格只能跳到 15 T(13, :) = 0; T(13, 15) = 1;这段循环的四个if就是四个动作的合法性判定。ismember(i,[1,5,9,13])挡住第一列往上走,ismember(i,[4,8,12,16])挡住第四列往下走,i>4和i<13分别挡住最左和最右。注意这里用的是 1 到 16 的一维编号,但实际布局是 4×4,所以“左/右”对应的是 ±4 而不是 ±1,这是读代码时最容易看岔的地方。后面三行是手工打补丁:先把 10、11、14 三列整列封死当障碍,再把 13 号格的所有出边清空、只留一条到 15 的“跳跃”边。这种写法很土,但改起来极快——想加障碍就再补一行T(:,[x])=0。
2.2 奖励矩阵 R 与 Q 表初始化
环境连通性有了,接下来是“走对走错给多少钱”。R同样是 16×16,R(state,action)就是这一步的即时奖励。代码里默认全 -1,第 6 列给 0,第 7 列给 -5,第 16 列给 +10,13 到 15 的跳跃给 +5。
% 定义奖赏矩阵 R R = -1 * ones(16); R(:, 6) = 0; % 第 6 列奖励为 0 R(:, 7) = -5; % 第 7 列是陷阱,惩罚 -5 R(:, 16) = 10; % 到达 16 号格(公主)奖励 +10 R(13, 15) = 5; % 13 跳到 15 额外奖励 +5 % Q 矩阵初始化为 0,表示对每个状态-动作价值一无所知 Q = zeros(16); % 折扣因子 gamma gamma = 0.8; % 策略选择概率(探索率)epsilon epsilon = 0.3;这里有个细节值得说:R(:,16)=10是整列赋值,意味着任何状态只要动作指向 16 都能拿 10 分,而R(:,7)=-5同理。这种“按目标列给奖励”的建模方式,本质上是把奖励挂在转移上而不是状态上,和标准 Q-Learning 的R(s,a)定义是对齐的。gamma=0.8表示未来奖励打八折,epsilon=0.3表示三成概率随机乱走、七成按当前最优走。这两个参数直接决定收敛快慢,后面避坑章节会专门讲它们翻车的场景。
3. 训练主循环拆解:epsilon-greedy 与贝尔曼更新怎么落地
3.1 回合初始化与障碍规避
训练外层是 900 个 episode,每个回合随机挑一个起点,但如果随机到 10、11、14 这三个障碍格,就用while重新抽,直到落在合法格上。
for episode = 1:900 % 随机化初始状态 state = unidrnd(16); % 1 到 16 之间随机取整数 while ismember(state, [10,11,14]) state = unidrnd(16); % 直到状态不在障碍格上 end % 只要没到终点 16 就继续走 while state ~= 16 % 找出当前状态下所有可行动作 possible_actions = find(T(state,:) == 1); possible_Q = Q(state, possible_actions); if rand() < epsilon % 探索:随机选一个可行动作 choose = unidrnd(length(possible_actions)); action = possible_actions(choose); else % 利用:选当前 Q 值最大的动作 [~, index] = max(possible_Q); action = possible_actions(index); end % 贝尔曼更新 Q(state, action) = R(state, action) + gamma * max(Q(action, :)); % 状态转移 state = action; end if mod(episode, 100) == 0 disp('-------------------') fprintf('训练的次数为:%d\n', episode) end end disp('训练结束')find(T(state,:)==1)拿到的是当前格所有合法去向的列索引,possible_Q再把这些去向对应的 Q 值取出来。rand()<epsilon是探索分支,unidrnd(length(...))在合法动作里随机挑一个;否则走max分支拿 Q 最大的那个。更新那一行是整份代码的心脏:Q(state,action) = R(state,action) + gamma * max(Q(action,:)),即“即时奖励 + 折扣后的下一状态最大 Q 值”。注意这里用的是Q(action,:)而不是Q(state,:),因为action同时充当了下一状态——这是这份代码把“动作”和“下一状态”合并成同一个编号的巧妙之处,也是读的时候最容易懵的地方。
3.2 路径回溯:训练完怎么把 Q 表变成一条路线
训练结束后,代码从起点 1 出发,反复查Q里当前格 Q 值最大的去向,把路径拼成一个向量,直到走到 16。
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)这段是纯贪心解码,不做任何探索。max(Q(start_0,:))返回列索引,也就是下一格编号。跑通后path会打印出一串数字,比如1 5 6 ... 16之类,具体取决于训练结果。这里有个隐含前提:Q 表必须已经收敛到每个可达格都有明确的“最优去向”,否则贪心可能走进死胡同或绕圈。如果打印出的路径长度异常或卡住,基本就是训练不充分,回到上一节调episode和epsilon即可。
4. 跑起来与调参:从零复现到看见路径的完整步骤
4.1 最小复现步骤
这份代码是纯脚本,不依赖任何工具箱,MATLAB 任意较新版本都能跑。新建一个.m文件,把第 2、3 章的代码按顺序粘进去,直接 F5 运行即可。运行时会每 100 个 episode 打印一次进度,最后打印训练结束和路径向量。如果你用的是 Octave,unidrnd属于统计工具箱函数,可能不存在,需要自己替换成randi:
% Octave / 无统计工具箱时的替代写法 % state = unidrnd(16); 替换为: state = randi(16); % choose = unidrnd(length(possible_actions)); 替换为: choose = randi(length(possible_actions));randi(n)返回 1 到 n 的均匀随机整数,语义和unidrnd一致,替换后行为不变。这一步是很多人第一次跑就报Undefined function 'unidrnd'的原因,先改这两处再谈调参。
4.2 关键参数怎么调
| 参数 | 代码中的值 | 作用 | 调大后的效果 | 调小后的效果 |
|---|---|---|---|---|
gamma | 0.8 | 未来奖励折扣 | 更看重长远,收敛慢但路径更优 | 短视,容易只盯眼前奖励 |
epsilon | 0.3 | 探索概率 | 探索多,前期乱走多 | 利用多,可能卡在局部最优 |
episode | 900 | 训练回合数 | 更充分,耗时增加 | 可能没收敛,路径异常 |
| 障碍列 | [10,11,14] | 封死的格子 | 加更多障碍,连通性变差 | 减少障碍,问题变简单 |
我一般会先把epsilon从 0.3 往 0.1 压,看路径是否还稳定;再把episode加到 2000 观察 Q 表是否还在变。如果 900 回合后路径偶尔绕远,多半是epsilon偏大导致 Q 值被随机探索搅动,适当降一点再跑。
4.3 怎么确认它真的学到了
别只看最后那行路径。训练中途可以加一句把 Q 表某个格的最大值打出来,观察它是否随 episode 单调上升并趋于稳定:
% 在训练循环内、每 100 回合打印一次 1 号格的最大 Q 值 if mod(episode, 100) == 0 fprintf('episode %d, maxQ(1,:)=%.3f\n', episode, max(Q(1,:))); end如果这个值在几百回合后基本不再变化,说明 Q 表收敛了,此时回溯出的路径才可信。反之如果它还在大幅跳动,说明训练不够或epsilon太大,路径只是当前快照,换个随机种子可能就变了。
5. 避坑与排查:这份代码最容易翻车的五个地方
5.1 现象:路径打印出来绕圈或长度爆炸
原因:Q 表没收敛,贪心回溯在几个格之间来回跳。常见于episode太小或epsilon太大,Q 值还没稳定就被拿来解码。解决:把episode提到 2000 以上,epsilon降到 0.1 左右重跑;或者在回溯时加一个访问计数,超过 16 步就判定失败并打印 Q 表片段排查。
5.2 现象:报错Undefined function 'unidrnd'
原因:unidrnd是统计工具箱函数,基础 MATLAB 或 Octave 里没有。解决:按 4.1 节把两处unidrnd换成randi,语义完全等价,不需要装额外工具箱。
5.3 现象:起点随机到障碍格导致死循环
原因:while ismember(state,[10,11,14])只挡了这三个格,但如果后续你手动加了新障碍列却忘了同步这个列表,随机起点可能落在新障碍上,而该格T全 0,possible_actions为空,unidrnd(0)直接报错。解决:把障碍列抽成一个变量obstacles=[10,11,14],初始化和T封死都用它,改一处即可。
5.4 现象:改了奖励后路径完全不变
原因:R是按列赋值的,R(:,16)=10覆盖的是整列,如果你在后面又写了R(5,16)=...之类的单点赋值,顺序不对会被整列覆盖掉。解决:所有单点奖励写在整列赋值之后,或者干脆先建R再逐条改,跑之前disp(R)抽查几行确认数值符合预期。
5.5 现象:13 号格进去就出不来
原因:代码里T(13,:)=0; T(13,15)=1;把 13 号格做成单向跳跃,但如果训练中某条路径从 15 反向走到 13(取决于你是否给 15 加了回边),就会卡住。解决:确认T(15,13)是否为 0;这份原始代码里 15 到 13 没有边,所以安全,但二次开发加边时务必检查双向连通性,否则智能体会被困在单向通道里。
6. 二次开发改造:把 16 格 Demo 扩成能讲清楚的项目
想把这份脚本改成一个能演示、能扩展的小项目,我建议从三个方向动手,每个都给出可验证的改法。
第一个方向是环境参数化。现在障碍列、跳跃边、奖励全是硬编码,改起来容易漏。把obstacles、jump_from、jump_to、goal抽成脚本顶部的配置区,T和R的构建全部引用这些变量。改完后用两组不同配置各跑一次,确认路径随障碍变化而合理绕行,就算改对了。
第二个方向是训练过程可视化。把每回合的步数记进一个数组,训练结束后画出来:
steps_per_episode = zeros(1, 900); % 在 while state~=16 循环外记起点,循环内累加步数 % 训练结束后: plot(1:900, steps_per_episode); xlabel('episode'); ylabel('steps'); title('每回合步数变化');如果曲线整体下行并趋于平稳,说明学习有效;如果一直高位震荡,回去查epsilon和gamma。这张图比任何文字都更能让人信服“它真的在学”。
第三个方向是换解码策略做对比。现在回溯是纯贪心,你可以加一个“带随机扰动的贪心”或“按 Q 值概率采样”的解码,把两种路径长度打出来对比。改的时候注意保持Q不变,只换解码逻辑,这样对比才干净。
| 改造点 | 改动位置 | 验证方法 |
|---|---|---|
| 参数化环境 | 脚本顶部配置区 +T/R构建段 | 两组配置路径不同且合理 |
| 步数曲线 | 训练循环内计数 + 循环后绘图 | 曲线下行趋稳 |
| 解码对比 | 回溯段替换为两种策略 | 打印两种路径长度对比 |
血泪经验是:每次只改一个维度,改完立刻跑一次看路径,别一口气把环境、奖励、解码全换了再跑——那时候路径不对,你根本不知道是哪一处改崩的。从那以后我每次动这类 Q 表代码,都强制先跑原始配置留一份基准路径,再逐项改、逐项比。希望这份拆解能帮你把这份源码真正跑起来、改下去。
本文还有配套的精品资源,点击获取