简介:这份资源面向强化学习入门者与需要动手复现经典算法的学习者,以Matlab源代码加中文注释的形式,完整呈现Q-Learning在无模型决策场景中的实现思路。案例把16×1网格世界包装成王子寻找公主的游戏,通过可移动矩阵TT、奖赏矩阵R、全零初始化的Q矩阵,以及折扣因子gamma与epsilon-greedy策略,串起环境建模、动作选择、贝尔曼方程更新与最优路径回溯的完整流程,障碍物、陷阱负奖励与终点正奖励的设置也让试错学习过程更直观。资源包共1个doc文件,约33KB,代码与注释集中呈现,便于逐行对照理解算法细节。目前已有1380人学习下载,适合希望从公式走向可运行代码、并借助注释快速吃透Q值更新与路径规划逻辑的读者参考。
1. Q-Learning 的“无模型”到底省掉了什么:从一张 16×16 的矩阵说起
很多人第一次读 Q-Learning 代码,会觉得它“不像学习”——没有梯度、没有损失函数、没有反向传播,只有一行Q(state,action)=R(state,action)+gamma*max(Q(action,:))反复执行。直觉上这更像查表,而不是智能。但恰恰是这行查表式更新,把强化学习最核心的假设暴露得干干净净:它不需要知道环境的转移概率,也不需要知道奖励函数长什么样,只需要“走一步、看一眼奖励、回头改分数”。这份 MATLAB 源码用一个 16 格的一维网格世界把这件事压缩到了极致——16 个状态、4 个动作、一张可移动矩阵T、一张奖赏矩阵R、一张全零初始化的Q,900 轮训练后就能从任意起点规划出通往终点的路径。搞懂它为什么能 work,比背下贝尔曼方程更有价值,因为后面所有深度强化学习的坑,几乎都能在这几十行里找到原型。
2. 环境建模:为什么用矩阵 T 和 R 而不是写 if-else
2.1 可移动矩阵 T:把“物理规则”编码成邻接关系
源码开头没有直接写“向上走就减 1”这类判断,而是先构造一个 16×16 的T矩阵,T(i,j)=1表示可以从位置 i 移动到位置 j。这个选择不是随手写的,它把环境的拓扑结构从控制流里剥离出来,变成一份可查询的数据。
% 定义可移动矩阵 T T = zeros(16); for i = 1:16 % 上行动作 N:不在第一行才能上移 if not(ismember(i,[1,5,9,13])) T(i,i-1) = 1; end % 下行动作 S:不在最后一行才能下移 if not(ismember(i,[4,8,12,16])) T(i,i+1) = 1; end % 左行动作 W:编号大于 4 才能左移 if i > 4 T(i,i-4) = 1; end % 右行动作 E:编号小于 13 才能右移 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;这段代码里有两个细节值得停下来看。第一,T(i,i-1)这种“编号差 1 就是上下相邻”的写法,隐含了网格按行优先展开的假设:1 到 4 是第一行,5 到 8 是第二行,依此类推。所以“上移”是减 1,“下移”是加 1,“左移”是减 4,“右移”是加 4。一旦网格尺寸变了,这套偏移量就得重算,这是硬编码网格布局的代价,也是它简单的原因。
第二,障碍物和跳跃是在循环之后用整行/整列清零覆盖的。T(:,[10,11,14])=0把所有指向 10、11、14 的边全部切断,等于这三个格子从图里被摘掉;T(13,:)=0; T(13,15)=1则把 13 号格子的所有出边清空,只留一条通往 15 的“传送门”。这种“先建全图、再删边”的建模方式,比在循环里塞一堆if判断要清晰得多,改环境时只需要动覆盖那几行。
提示:
T是行随机矩阵的雏形——每行非零元素就是该状态下的合法动作集合。后面find(T(state,:)==1)正是靠它拿到动作空间,而不是靠固定的 1 到 4 编号。
2.2 奖赏矩阵 R:稀疏奖励与陷阱惩罚的编码
R的定义只有四行,但信息量很大:
R = -1 * ones(16); % 默认每步 -1 R(:,6) = 0; % 第 6 列奖励 0 R(:,7) = -5; % 第 7 列陷阱 -5 R(:,16) = 10; % 第 16 列终点 +10 R(13,15) = 5; % 13 跳到 15 额外 +5注意R是 16×16 的,R(state,action)取的是“从 state 走到 action 这个格子”的即时奖励。默认全 -1 意味着每走一步都在扣分,这会逼着智能体尽快到达终点,而不是在原地绕圈。R(:,16)=10让所有指向 16 的转移都拿 +10,R(:,7)=-5让所有指向 7 的转移都吃惩罚,R(13,15)=5则给那条传送门单独加码。
这里有个容易被忽略的设计动机:奖励是按“目标格子”定义的,不是按“动作方向”定义的。也就是说,从 3 号格向右走到 7 号格,拿的是R(3,7)=-5;从 11 号格向左走到 7 号格,拿的也是R(11,7)=-5。这种列式定义让“进入某个格子”的后果与来路无关,符合大多数网格世界的语义,也让矩阵赋值一行就能覆盖所有入边。
2.3 状态空间与动作空间的隐式定义
整份代码没有显式的states数组或actions数组,状态就是 1 到 16 的整数,动作就是“下一个格子的编号”。这种把动作等价于目标状态的写法,在网格世界里很自然,但换到连续控制或动作空间与状态空间不同构的场景就失效了。理解这一点,才能明白为什么后面的 DQN 要单独输出每个动作的 Q 值,而不是直接输出“下一个状态”。
3. Q 值更新:一行代码背后的贝尔曼最优方程
3.1 更新公式拆解:为什么是 R + γ·max Q
核心更新只有一行:
Q(state,action) = R(state,action) + gamma * max(Q(action,:));把它和标准贝尔曼最优方程对照:
$$Q^(s,a) = R(s,a) + \gamma \cdot \max_{a'} Q^(s',a')$$
其中 $s'$ 就是action(因为动作即下一状态)。这行代码做的是“用当前估计去更新当前估计”,属于自举(bootstrapping)。它没有等一整条轨迹结束再回传回报,而是每走一步就用下一状态的最大 Q 值来修正当前 Q 值。这正是 Q-Learning 被称为“时序差分(TD)控制”的原因。
gamma=0.8控制未来奖励的折扣。0.8 意味着 5 步之后的奖励权重只剩 0.8^5≈0.33,10 步之后只剩 0.107。这个值不能太接近 1,否则远处奖励的方差会拖慢收敛;也不能太小,否则智能体变成短视,只盯着眼前那点 -1。源码选 0.8 是个经验值,在 16 格的小世界里刚好能让 +10 的终点奖励“传”回起点,又不至于让更新震荡。
3.2 自举的代价:Q 值传播需要多少轮
因为初始Q全零,终点 16 的 +10 奖励不会立刻影响起点。它需要一轮一轮地“倒传”:第一次有智能体走到 16,Q(15,16)或Q(12,16)被更新成 10;下一轮,指向 15 的状态才能通过max(Q(15,:))拿到这个 10 的折扣值;再下一轮,更远的状态才被点亮。这种传播速度取决于折扣因子和探索路径,900 轮训练里前几百轮基本都在做这件事。
可以用一个简化推演看传播链:假设某轮从 12 出发,12 的合法动作里有 16(因为 12+4=16),那么Q(12,16)=R(12,16)+0.8*max(Q(16,:))=10+0=10。下一轮从 8 出发,8 可以到 12,Q(8,12)=-1+0.8*max(Q(12,:)),而Q(12,:)里最大的是 10,于是Q(8,12)=-1+8=7。再下一轮 4 到 8,Q(4,8)=-1+0.8*7=4.6。可以看到每退一格,值大约乘 0.8 再减 1,这就是折扣因子在“距离”上的体现。
3.3 epsilon-greedy:0.3 的探索率在赌什么
if rand() < epsilon choose = unidrnd(length(possible_actions)); action = possible_actions(choose); else [~,index] = max(possible_Q); action = possible_actions(index); endepsilon=0.3表示 30% 的概率随机走,70% 的概率走当前 Q 最大的方向。这个比例在训练初期很关键:如果 epsilon 太小,智能体可能一开始就卡在某个局部最优路径上反复走,永远发现不了 13 到 15 的传送门;如果太大,Q 值刚学到一点就被随机动作冲散,收敛慢。
源码里 epsilon 是固定值,没有衰减。这在 16 格的小环境里能跑通,但在稍大的环境里就是血泪经验:固定探索率会让最终策略一直带 30% 的随机抖动,路径规划阶段如果还用它,就会看到智能体明明学到了最优路却偶尔“发疯”。好在源码的路径提取阶段是纯贪心max(Q(start_0,:)),没有再用 epsilon,所以最终输出是确定的。
注意:
possible_Q = Q(state,possible_actions)取的是当前状态下所有合法动作的 Q 值,max只在这些合法动作里选。如果直接对整行Q(state,:)取 max,可能选到障碍物对应的列,那列 Q 值虽然可能是 0,但动作根本不可执行,这是新手改代码时最常见的翻车点。
4. 训练循环与路径提取:从随机游走到确定策略
4.1 回合制训练:为什么每轮要随机化初始状态
for episode = 1:900 state = unidrnd(16); while ismember(state,[10,11,14]) state = unidrnd(16); end while state ~= 16 ... end end每轮开始随机选一个非障碍起点,而不是固定从 1 出发。这个设计的目的在于让 Q 表对所有状态都获得足够的更新机会。如果每轮都从 1 开始,那么远离 1 的状态可能很少被访问,Q 值长期停留在零,路径提取时就会在这些区域做出错误决策。随机起点相当于一种粗粒度的“经验覆盖”策略。
内层while state ~= 16表示一轮持续到抵达终点为止,没有步数上限。这在 16 格环境里没问题,因为只要策略不是完全死锁,总能走到 16;但在有环、有吸收态的环境里,缺少最大步数限制会导致单轮训练卡死,这是把这份代码迁移到更大场景时必须补的第一道保险。
4.2 路径提取:贪心策略为什么能直接读出最优路
训练结束后,路径提取只有几行:
start_0 = 1; path = start_0; while path(end) ~= 16 [~,next] = max(Q(start_0,:)); path = [path,next]; start_0 = next; end每一步都在当前状态的 Q 行里取最大值对应的列,作为下一站。这里没有用T做合法性过滤,是因为训练充分后,非法动作的 Q 值不会被更新(它们从来不会被possible_actions选中),保持初始的 0,而合法动作的 Q 值经过训练后要么为正、要么是负但比 0 更“有信息”。不过严格来说,如果某个合法动作的 Q 值恰好也是 0,就可能和非法动作打平,max返回第一个索引,存在选错的风险。稳妥的写法是possible_actions = find(T(start_0,:)==1)后再取 max,这也是读这份源码时值得自己动手改一处的地方。
4.3 训练日志:每 100 轮输出一次的意义
mod(episode,100)==0时打印训练轮数,本身不参与计算,但它是观察收敛的窗口。如果跑起来发现 900 轮后路径还是绕远路,第一反应应该是看 Q 表里终点附近的值有没有被“点亮”,而不是盲目加轮数。常见的排查顺序是:先确认T的障碍物覆盖有没有误删合法边,再确认R的列赋值有没有把终点奖励盖掉,最后才怀疑 gamma 和 epsilon。
5. 避坑与排查:这份 Q-Learning 代码最容易翻车的几处
5.1 现象:路径走到障碍物上;原因:max 没做合法性过滤;解决:先 find 再取 max
前面提过,路径提取阶段直接max(Q(start_0,:))可能选到非法列。尤其在训练不充分时,某些合法动作 Q 值还是 0,和非法列的 0 打平,MATLAB 的max返回第一个最大值索引,很可能就是编号更小的非法格子。解决方式是把提取循环改成先possible = find(T(start_0,:)==1),再在Q(start_0,possible)里取 max,和训练阶段保持同一套动作空间。
5.2 现象:训练几百轮后 Q 值不收敛、来回震荡;原因:epsilon 固定且 gamma 偏大;解决:加衰减或调小 gamma
固定epsilon=0.3意味着每轮仍有 30% 的随机步,这些步产生的更新会把已经学好的 Q 值往回拉。在小环境里靠 900 轮的量能压住,但换大一点的环境就会看到 Q 值反复横跳。常见的改法是让 epsilon 随 episode 衰减,比如epsilon = max(0.05, 0.3 * (1 - episode/900)),前期多探索、后期多利用。gamma 如果设到 0.95 以上,远处奖励的折扣太小,传播链变长,收敛需要的轮数会成倍增加。
5.3 现象:某状态永远学不到值;原因:随机起点覆盖不到或该状态是死胡同;解决:检查 T 的入边
如果某个格子只有出边没有入边,或者它的所有入边都被障碍物覆盖误删,那么它永远不会被访问到,Q 行保持全零。排查方法是看T的列:sum(T(:,k))为 0 说明没有任何状态能到达 k。源码里T(:,[10,11,14])=0是整列清零,如果误写成T([10,11,14],:)=0,就会变成“从这些格子出不去”,语义完全不同,这是矩阵下标方向写反的经典翻车。
5.4 现象:终点奖励传不回来;原因:R 的列赋值被后续覆盖;解决:确认赋值顺序
R(:,16)=10必须在R=-1*ones(16)之后执行,否则会被全 -1 盖掉。同理R(13,15)=5要在列赋值之后,否则可能被R(:,15)的默认值覆盖。MATLAB 里矩阵赋值是顺序敏感的,读这份代码时要养成“从下往上检查覆盖关系”的习惯。
5.5 现象:单轮训练卡死;原因:while 没有步数上限;解决:加 max_steps 保护
while state ~= 16在存在环且策略尚未学好时,可能让智能体在两个状态间来回跳。虽然 16 格环境里概率很低,但一旦你把T改出环,就会遇到。加一个steps计数器,超过阈值就break,是迁移这份代码时的标准动作。
6. 设计取舍:为什么表格法在 16 格能跑通,换个场景就崩
这份源码最值得琢磨的取舍,是它把 Q 函数存成一张 16×16 的稠密矩阵。状态数 16、动作数最多 4,存储量可以忽略,更新一次是 O(动作数),查表是 O(1)。这种“用空间换确定性”的做法,在状态空间可枚举时是最优解——没有函数逼近的偏差,没有梯度噪声,收敛性有理论保证。
但它的边界也很清楚:Q 表的大小是 |S|×|A|,状态一旦变成连续值或者组合爆炸,矩阵就存不下。更隐蔽的问题是泛化——表格法对每个状态独立学习,状态 3 学到的经验不会自动迁移到状态 4,哪怕它们在网格上相邻、语义几乎一样。这就是为什么后来的函数逼近方法要用神经网络把 Q(s,a) 参数化,用参数共享来换泛化,代价是引入了不稳定性( deadly triad:函数逼近 + 自举 + 离策略)。
另一个取舍是“动作即下一状态”的建模。它让T和R都能用方阵表示,代码极简,但把动作空间和状态空间绑死了。真实场景里动作往往是“方向”而不是“目标格”,动作数固定为 4,状态数却可能上千,这时Q(state,action)的列数应该是 4 而不是 16。读这份代码时,我一般会提醒自己:矩阵的维度选择不是数学必然,而是环境建模的产物,换环境就要重画这张表。
验证这份实现是否真的学到了东西,最直接的办法不是看训练日志,而是把提取出的path打印出来,对照T手工走一遍,确认每一步都是合法边、没有踩到 7 号陷阱、最终落在 16。再进一步,可以固定随机种子跑多次,看路径是否稳定——如果每次路径差异很大,说明训练轮数不够或 epsilon 太高,Q 表还没收敛到确定策略。
从那以后我每次读这类表格型强化学习代码,都会先把T、R、Q三个矩阵的维度和赋值顺序在纸上画一遍,再跑路径提取做合法性校验,最后才去看训练曲线。这个习惯帮我省掉了不少“看起来收敛了其实在裸奔”的坑。希望帮到你。
本文还有配套的精品资源,点击获取