在路径规划这个方向上摸爬滚打了几年,我发现一个很有意思的现象:很多人一上来就直奔各种号称“最强”的算法,结果环境稍微改一改,性能掉得难看得要命。反倒是那些最基础的东西,吃透了之后哪儿都能用。Q-learning就是这种,看着简单,但真把它用在随机生成的迷宫上,踩过的坑、调参的心得,足够写一篇长文了。
这篇文章我就用一次实际的实验来展开,环境是Matlab,任务是让一个智能体从方形迷宫左上角出发,避开随机障碍物,找到到达右下角的最短路径。全程围绕Q-learning和ε-greedy策略这两个核心展开,从迷宫怎么生成、奖励函数怎么设、训练循环怎么写,到训练不收敛、路径绕远、矩阵维度报错这些常见问题的排查思路,都会聊到。
先说这个项目是什么。它是一个典型的强化学习入门级实战:状态空间是迷宫网格点,动作空间是上、下、左、右四个方向的移动,环境是一个每轮随机生成障碍物的方形迷宫。智能体通过不断试错,学习一个从“状态-动作”到累积回报的映射表,也就是Q表。训练完成后,直接查询Q表就能得到一条从起点到终点的路径。
适合谁来参考呢?如果你刚开始接触强化学习,或者做过路径规划但想换个思路,又或者想在Matlab里搭一个方便可视化的实验平台,这篇都适配。基础部分我会讲得比较细,有经验的朋友可以直接跳到训练循环和问题排查那一段。
1. 内容整体设计与思路拆解
1.1 为什么选Q-learning而不是深度强化学习
这是我经常被问到的问题。其实答案很直接:问题够简单,没必要上重武器。随机迷宫规模不大,比如10×10、15×15,状态总数最多几百个状态乘以4个动作,用矩阵就能存下Q值,训练速度快,调试方便,每一步发生了什么都可以清清楚楚地打印出来。换成深度网络,一是杀鸡用牛刀,二是一旦不收敛,你很难定位是网络结构的问题、奖励设计的问题还是探索策略的问题,调试成本成倍上升。
强化学习领域有一条“奥卡姆剃刀”原则:优先用最简单的算法去验证你的环境建模和奖励设计是否正确。只有Q-learning效果不理想,才考虑用DQN或更复杂的算法。事实证明,在方形迷宫这个场景下,Q-learning配ε-greedy已经能稳定收敛到接近最优的策略,完全够用。
1.2 迷宫环境的核心设计逻辑
环境设计是整个项目的骨架,设计得好,训练事半功倍。我用的是标准的方形网格迷宫,逻辑上就是N×N的二维网格,每个单元格有几种状态:空白(可以走)、障碍物(不能走)、起点(左上角)、终点(右下角)。每一轮训练开始前,程序会随机选择一部分格子设为障碍物,然后用这些信息初始化迷宫矩阵。
有个细节要特别注意:障碍物比例是环境设计中最关键的参数之一。设太低了,比如5%,迷宫几乎畅通无阻,智能体随便走都能到终点,学到的策略无意义;设太高了,比如40%,起点和终点之间可能被障碍物完全隔断,智能体永远找不到终点,训练直接失败。我实测下来,10×10的迷宫,障碍物比例控制在10%到20%,难度和收敛速度比较平衡。另外,随机生成障碍物后必须做一个连通性检查,确保起点和终点之间至少有一条可行路径,否则这轮生成的数据就是废数据,会严重干扰训练。
这里用的是一个带起点和终点的格子世界,本质上属于离散状态空间,非常适合Q-learning直接建表求解。
1.3 奖励函数与目标设计
奖励设计是这个项目里最容易被低估的环节。很多人上来就写:到达终点奖励+1,碰壁处罚-1,其他情况奖励0,然后训练出来发现智能体在原地打转,或者永远学不会避开障碍物。问题不在于算法,在于奖励信号太稀疏,智能体在庞大的状态空间里漫无目的地探索,运气差一点,几千个回合都撞不到终点一次。
我用的方案是分层的稀疏奖励:
- 到达终点:+100
- 撞上障碍物或试图走出边界:-10
- 每移动一步:-1
每步-1这个设计很关键,它给智能体一个“时间成本”的暗示,促使它寻找最短路径。不然的话,只要最终能到终点,绕多远都行,路径质量会很差。
还有一个细节值得拎出来说:撞墙的惩罚要和到达终点的奖励匹配。如果惩罚只有-1,智能体可能更倾向于直接撞墙也不愿意多绕路。合理的量级关系是“一次撞墙的代价应该小于绕远路的总代价,但大于单步成本”,这需要根据你实际迷宫的大小去试。我试过10×10的迷宫,-10和+100是比较合适的一组值。如果迷宫改成20×20,可能要把惩罚调成-20甚至更高,因为路径长度变长了,智能体对每一步的“厌恶感”需要更强才能抑制绕路和撞墙行为。
2. 核心算法拆解与关键参数选择
2.1 Q-learning更新公式与代码结构
Q-learning的核心更新公式是:
Q(s, a) ← Q(s, a) + α [R + γ max Q(s', a') - Q(s, a)]
直观理解:当前状态s下做了动作a,得到一个立即回报R,到达新状态s',然后查看s'下所有动作的最大Q值,用它和当前Q值之间的差值,按学习率α做一次更新。这个公式最大的特点是在线策略(off-policy),更新后的Q值并不依赖实际采取的动作,而是依赖于最优动作的估计值Q(s', a')。把旧的Q值朝“实际回报+未来最优估计”方向拉动,这就是Q-learning的训练机制。
在Matlab里,这段代码的核心表达很直观:
% 核心更新代码 next_q_max = max(Q(next_state, :)); Q(current_state, action) = Q(current_state, action) + alpha * (reward + gamma * next_q_max - Q(current_state, action));这四行代码是整个训练循环的灵魂。训练的本质就是反复执行“选择动作-执行动作-观察回报和下一状态-更新Q值”这个闭环,跑上几千回合,Q表自然收敛。
2.2 ε-greedy策略与探索-利用权衡
ε-greedy是整个算法中控制“探索-利用平衡”的关键策略,核心逻辑是:智能体以ε的概率随机探索,以1-ε的概率选择当前Q值最大的动作(即利用已有经验)。
用大白话说,这就是一个“冒险”和“保守”之间的选择题。只利用不探索,智能体很容易困在局部最优:第一次撞到一条能到终点的路之后就再也不走别的路,错过更好的路径;只探索不利用,经验积累永远无法沉淀下来,学到后来还是在瞎逛。ε-greedy就是给这个困境一个简单的出口:大部分时间走已知最好的路,小部分时间跳出去看看有没有更好的路。
% 动作选择代码 if rand() < epsilon action = randi([1, 4]); % 随机探索 else [~, action] = max(Q(current_state, :)); % 利用已知最优 end这里有两个值得一提的经验:
其一,ε初始值不宜太高,0.1是一个不错的起点。太高了智能体长期处于“半随机”状态,收敛极慢;太低了又容易错过最优路径。
其二,ε可以做衰减。训练初期允许智能体大胆探索,训练后期逐渐降低随机性,让Q表的利用占据主导优势,收敛会更稳定。衰减方式我比较推荐线性衰减,比如每100回合乘以0.95,可以算作一种简易调度。实测下来,效果比固定ε更稳定,尤其是障碍物密度较高时,早期探索不够,后期很难找到一条完整路径。
2.3 学习率和折扣因子的取值与调试
这两个参数,一个管“新信息覆盖旧信息的速度”,一个管“智能体对未来的重视程度”,放在一块调试是有讲究的。
学习率 α:来自Q表的更新幅度。α=1时,每次都完全信任新信息,过渡频繁;α=0时,什么也学不到。常用区间是0.1到0.5。我初版的配置是α=0.5,收敛速度快,但曲线波动明显;后来调到0.1,训练时间长了,但Q值曲线更光滑、更稳定。带随机障碍物的环境,推荐α=0.1~0.2,随机性大,步子迈小一点更稳。
折扣因子 γ:表示未来奖励在当前时刻的价值。γ=0时,只看当前回报;γ接近1时,长远规划能力更强。迷宫问题通常需要规划多步路径,γ不能太低,我常用0.9到0.95。再高会导致训练初期的Q值传递非常缓慢,每一步的奖励都要传递很远;再低则智能体过于短视,路径经常绕路。最直观的判断方式是看训练结束后导出的路径:如果路径明显绕远路,先检查γ是否偏低。
回合数:我初期设置了500回合,发现10×10的迷宫能收敛但路径不稳定;5000回合则能稳定输出最优路径。至于具体多少回合合适,看你的迷宫大小。一个经验公式是:状态数乘以动作数,再乘以10到50倍。10×10的迷宫状态规模是100×4=400,5000回合刚好在10倍左右,实测比较充足。
3. 实操过程与Matlab完整实现
3.1 迷宫生成与边界处理
迷宫生成的输入参数很简单,行数N、障碍物比例p。核心逻辑是生成一个N×N的全零矩阵,随机选择一定数量的格子设置为1(障碍物),然后强制设置起点和终点为通路。
function maze = create_maze(N, obstacle_ratio) maze = zeros(N, N); num_obstacles = round(obstacle_ratio * N * N); indices = randperm(N * N, num_obstacles); maze(indices) = 1; maze(1) = 0; % 起点 (1,1) maze(N * N) = 0; % 终点 (N,N) end这里有一个很多人忽略的坑:randperm生成的障碍物索引是线性的,也就是把N×N矩阵拉成一维向量后去索引。如果不做转换,到后面使用状态编号时会非常混乱。所以我在生成迷宫后还会额外维护一个二维索引映射关系,确保状态编号一致。
连通性检查用深度优先搜索实现:
function connected = check_connectivity(maze) N = size(maze, 1); visited = false(N, N); stack = [1, 1]; while ~isempty(stack) [x, y] = deal(stack(end, 1), stack(end, 2)); stack(end, :) = []; if visited(x, y), continue; end visited(x, y) = true; if x == N && y == N connected = true; return; end % 四个方向压栈 for dir = 1:4 [nx, ny] = move_point(x, y, dir); if nx > 0 && nx <= N && ny > 0 && ny <= N && ~maze(nx, ny) && ~visited(nx, ny) stack(end+1, :) = [nx, ny]; end end end connected = false; end这段代码的核心在于栈的压入和弹出操作维护了一个搜索路径,能快速判定起点到终点是否“可达”,而不仅仅是用随机概率去赌迷宫生成得是否合理。我实测大约有超过70%的随机生成迷宫是连通的,如果不检查,剩下那些失败迷宫会让整个训练过程的收敛曲线很难看。
3.2 状态与动作空间映射
这个维度我一开始没处理干净,吃过亏。Q表要用线性索引来存,迷宫的每个格子是二维坐标(x, y),必须建立一个明确的映射公式。
state_index = (x - 1) * N + y; % 二维坐标转一维索引动作的编号我规定为:1-上,2-下,3-左,4-右。移动函数:
function [nx, ny, is_valid] = move_point(x, y, action) switch action case 1, nx = x - 1; ny = y; case 2, nx = x + 1; ny = y; case 3, nx = x; ny = y - 1; case 4, nx = x; ny = y + 1; end is_valid = nx >= 1 && nx <= N && ny >= 1 && ny <= N && maze(nx, ny) == 0; end3.3 训练循环与Q表收敛判断
训练循环的结构决定整个算法的稳定性。我来看一段核心训练代码的完整结构:
Q = zeros(N * N, 4); alpha = 0.1; gamma = 0.9; epsilon = 0.1; max_episodes = 5000; for episode = 1:max_episodes state = 1; % 起点状态索引 is_done = false; while ~is_done % 选择动作 if rand() < epsilon action = randi(4); else [~, action] = max(Q(state, :)); end % 执行动作,获取下一状态和奖励 q_next = get_next_state(state, action); % 返回更新后的状态索引和是否有效移动 [next_state, reward, is_done] = get_transition(state, action); % Q-learning更新 max_next = max(Q(next_state, :)); Q(state, action) = Q(state, action) + alpha * (reward + gamma * max_next - Q(state, action)); state = next_state; end % 尝试线性衰减探索率 if mod(episode, 100) == 0 epsilon = max(epsilon * 0.95, 0.01); end end这里get_transition函数的逻辑要包含:合法移动、撞墙惩罚、到达终点判断三个环节,返回下一状态索引、回报、是否结束三项内容。将这三部分集成到一个函数里,训练主体的逻辑会保持清晰,方便后续调试时打印状态变化。
训练结束后,Q表就是策略本身。提取路径时,从起点反复选择Q值最大的动作进入下一步,同时记录坐标,直到到达终点:
state = 1; path = [1, 1]; while state ~= N * N [~, action] = max(Q(state, :)); [nx, ny] = move_point(state_to_coord(state, N), action); path(end + 1, :) = [nx, ny]; state = coord_to_state(nx, ny, N); end这段路径提取逻辑是验证算法是否学好的最直观方式。如果提取出来的路径存在撞墙或绕远路,要么是训练不充分,要么是参数设计有问题,可以对照下面的排查章节逐项检查。
3.4 可视化设计与实验观察
Matlab在这方面的天然优势是可视化非常方便,可视化对于调试策略的效果无与伦比。我画了两张图:
第一张是Q表热力图。每个格子的颜色深浅表示该状态下最大Q值的大小。训练初期,只有终点及其附近的状态有点颜色;训练后期,颜色从终点逐渐向外扩散。通过观察这张图的扩散速度,可以直观判断奖励传递是否顺畅。
第二张是训练过程中的累积回报曲线。横轴是回合数,纵轴是每回合获取的总回报。正常收敛的曲线应该是一个有波动但整体下降的趋势——前期因为探索多、惩罚多,总回报低;后期开始找到终点,负惩罚减少,曲线上升;最后平稳,策略趋于稳定。如果曲线一直在低位震荡或者突然跳变,大概率是迷宫生成或奖励函数出了问题。
4. 常见问题与排查技巧实录
4.1 训练不收敛:奖励函数的数量级不对
这是我在调试过程中遇到的第一个大坑,也是最典型的问题。训练了2000回合,Q表还是一团乱,路径提取出来完全不可用。排查后发现,终点奖励是+100,撞墙是-10,但其实每走一步的延迟惩罚只有-0.1,量级太小。智能体对“绕路”这件事几乎没有感知,到终点的路径越绕越远。
调整的方式很简单:把每步惩罚从-0.1改成-1,量级和撞墙惩罚、终点奖励匹配到同一个数量级。修改后收敛速度明显加快,路径质量也随之提升。奖励函数设置时一个重要的检验标准是:终点奖励应该明显大于路径上的累计惩罚。假设最短路径长度为20步,累计步数惩罚就是-20,终点+100仍然占主导。这种量级关系要认真算一遍,不要凭感觉。
4.2 智能体原地打转:ε衰减太慢
有一次测试发现,智能体已经到终点附近了,却开始原地转圈,始终进不去终点。每次在终点周围反复选择随机动作,即使进入终点会获得+100的奖励,它还是在附近的几个状态之间打转。
定位到原因:ε=0.1固定不变,探索率太高,有10%的随机动作会打断已经形成的策略,进入终点附近后又容易被随机试探拉走。而且10×10迷宫的状态空间很小,高探索率的负面影响被放大了。我按100回合为单位做线性衰减后,这个问题基本消失。衰减率以0.95为初始值观察着调,如果500回合后ε仍然大于0.05,说明衰减太慢,可以加速。
但这里有一个副作用需要说明:ε衰减过快也不行,比如100回合从0.1猛降到0.01,智能体过早进入“纯利用”模式,后续根本没有机会体验不同的路径,策略很容易停留在次优解。衰减速度请根据迷宫大小调整,10×10的迷宫线性衰减是合适的,更大的迷宫建议用指数衰减。
4.3 撞墙行为反复出现:动作选择没有限制非法动作
训练过程中需要一个很关键的判断:动作是执行了越界或撞墙,算无效动作,还是直接禁止这个动作?
我一开始的做法是:如果撞墙,就给予-10惩罚,但不改变状态,直接进入下一回合。这种做法在数学上是正确的,但实际操作下来容易让智能体“反复撞墙”来惩罚自己。有一种解释是:当Q表还未收敛时,Q值本身带有噪声,撞墙后状态不变、Q值被降低,反而让智能体在某次随机探索中选择同一个墙位置去“擦除”一个之前误判过高的Q值,这在统计上能帮助探索,但在实际运行中就是反复撞墙,路径质量极其难看。
解决方法:一旦move_point判断为非法移动,直接将奖励置为-10并结束该回合(也就是说非法移动被当作一次失败的尝试),而不是让智能体停留在原地重新选择。很多人可能觉得这个改动会降低到终点的训练次数,但实测下来收敛反而更快,因为智能体对“非法动作”形成强惩罚记忆,而不是把时间浪费在原地转圈。
4.4 迷宫随机性导致训练曲线抖动:固定随机种子
在做对比实验时,我发现同样的参数,换了迷宫后结果差异非常大。这不是算法的锅,而是环境随机性导致的。如果每次训练都用不同的迷宫,那么Q表学习的是“平均多种迷宫”的策略,而不是单一迷宫下的最优策略,“稳定收敛”就无从谈起。
不同需求有不同的应对方案:
- 如果目的是固定一个迷宫测试最优路径,那训练前要固定随机种子。
- 如果目的是测试算法在不同迷宫的泛化能力,那每个迷宫跑10次取平均结果统计,不要拿单次结果下结论。
Matlab里的random_seed = rng(master_seed)这样管理种子,可以保证每次实验重现性。这件事对调试极其重要,否则你很难区分:这个改进到底是因为参数调好了,还是碰巧生成了一座简单迷宫。
4.5 矩阵维度报错与索引越界:状态映射不统一
我遇到过很多次维度报错,几乎都出在“二维坐标”和“一维索引”混用上。Matlab里二维索引和一维索引之间可以隐式切换,很多初学者不留意,就会在一个函数里传入二维坐标,在另一个函数里却使用一维索引,报错提示却指向不明。
我的习惯是写两个工具函数,coord_to_state和state_to_coord,并确保训练循环里的状态变量只用一种形式。如果一定要混用,至少加上明确的转换注释。另外,动作数组的长度应该是4,对应动作编号1-4,Q表初始化时Q = zeros(NN, 4),这两个维度必须匹配,任何一处写成NN*4都会直接导致Q表索引计算错误。
4.6 常见问题速查表
| 故障现象 | 产生原因 | 解决方案 |
|---|---|---|
| 训练2000回合仍未收敛 | 奖励量级不匹配,每步惩罚太小 | 将每步惩罚调整到-1量级 |
| 智能体原地打转 | ε值固定且偏高 | 采用衰减策略,后期ε低于0.05 |
| 路径绕远路 | 折扣因子γ偏低 | 把γ调整为0.9以上 |
| 反复撞墙 | 非法移动后仍留在原地继续选择 | 撞墙后直接结束回合 |
| 训练曲线剧烈波动 | 每回合迷宫重新生成,环境差异大 | 固定随机种子或多次实验取均值 |
| Q表维度错误 | 一维索引和二维坐标混用 | 统一状态映射函数,分别封装转换逻辑 |
| 障碍物堵死全部路径 | 障碍物比例过高 | 添加连通性检查,不通过就重新生成 |
5. 实验扩展与后续优化思路
基础版的Q-learning迷宫跑通之后,有很多可以继续深挖的方向,这里说两个我实际尝试过的。
第一个是障碍物的动态变化。基础版是固定的一张迷宫训练到最后,扩展版可以让障碍物每隔几回合重新生成一次。这种设定更接近真实环境的动态性,也更能体现Q-learning的适应能力。但注意,迷宫变化后旧的Q表需要一定的回合数来适应新环境,训练回合数要相应增加。实测结果显示,障碍物重新生成频率太高,Q表几乎无法收敛;每100回合变化一次是勉强能接受的上限。
第二个是动作空间的扩展。基础版只有上、下、左、右四个方向,如果改成八方向(增加斜向移动),状态的转移矩阵会变得更复杂,但路径规划会更灵活。可以明显感觉到智能体走出来的路径更贴近直觉,路径长度也更短。不过代价是Q表的规模从N²×4变为N²×8,训练回合数大概要多出40%左右。
如果你还想更进一步,可以考虑将Q-learning接入外部仿真环境,比如用Matlab的接口连接一个机器人仿真平台,将迷宫渲染成真实地图,智能体控制轨迹的每一步都对应一个动作输出。这种做法的工程量主要在环境接口的对接上,算法本身不用改。但也提醒一句:真实环境有噪声、有延迟,Q-learning的收敛可能没有仿真环境那么好,必要的话可以回退到带衰减的ε策略,并加大训练量。
6. 一些个人经验与总结
Q-learning能解的迷宫问题看似简单,但它背后涉及的探索与利用、奖励建模、环境与算法关系等一系列思考,可以平行迁移到很多强化学习应用场景中。我在后面接触的一些项目中,比如简化版调度、游戏AI、推荐策略初始验证,都沿用了这套“先建模、简算法、重调试”的思路。
最后分享三个我反复强调的经验:
第一,调参前先调bug。如果你发现不管怎么设置参数,训练结果都差不多,那大概率不是参数的错,而是环境或代码逻辑有隐藏错误。先打印状态转移日志,确认每一步的转移是否符合预期,再动参数。
第二,Q表收敛与否,不能只看有没有到终点。路径长度才是更敏感的指标。我见过一种情况:智能体每次都能到终点,但路径长度是最优路径的两倍,因为每步-1的惩罚不够强,绕远路并未被有效压制。检查路径长度的变化曲线,比只看总回报直观得多。
第三,先把迷宫变小。不要一开始就用50×50的迷宫调试,那是自找麻烦。先在6×6、8×8的小网格上跑通整个流程,再逐渐加大迷宫规模。小迷宫训练速度快、可视化直观,定位问题极其高效。
如果你正在学习强化学习,或想用一个具体项目来验证理论,这个迷宫实验是一个非常不错的起点。把Q-learning的更新公式写成代码容易,但把奖励设计、环境建模、参数调试这些细节真正串起来,才会对算法本身产生更实际的认知。希望这篇能帮你少踩一些坑。