ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python从零实现Q-Learning迷宫求解器:强化学习序贯决策入门实战

Python从零实现Q-Learning迷宫求解器:强化学习序贯决策入门实战

1. 从迷宫出发:为什么用Q-Learning做序贯决策入门

很多人第一次接触强化学习,脑子里冒出来的画面就是机器人走迷宫。这个直觉其实非常准——迷宫问题天然具备序贯决策的全部要素:状态明确、动作有限、奖励稀疏、需要多步之后才能知道当初的选择对不对。而Q-Learning恰好是理解这一切最合适的切入点,它不需要环境模型,不需要梯度传播,一张表格就能把"在什么状态下做什么动作最划算"这件事说清楚。

这篇内容要聊的,就是怎么用Python从零实现一个Q-Learning迷宫求解器。我会把上一篇自主寻优的思路延续下来,重点讲清楚从"盲目试错"到"有策略地试错"这一步是怎么跨过去的。适合已经写过一些Python、对numpy不陌生、但强化学习还没真正上手的朋友。如果你之前看过一些理论推导但不知道怎么落地,这篇就是帮你把公式变成可运行代码的那座桥。

核心关键词就几个:Q-Learning、序贯决策、迷宫、自主寻优、强化学习实例。整篇内容围绕它们展开,不跑偏。

先说清楚这个实例最终能做什么:给定一个用二维网格表示的迷宫,智能体从起点出发,通过反复尝试,最终学会一条稳定到达终点的路径。整个过程不需要人告诉它怎么走,它自己撞墙、自己回头、自己积累经验。代码量不大,但麻雀虽小五脏俱全,Q表更新、探索与利用平衡、奖励设计、收敛判断这些关键环节一个不少。

我见过不少人学强化学习卡在两点上:一是数学符号太多,看懂了公式但不知道对应代码里哪一行;二是跑通了代码但不知道为什么参数要那么设。这篇会重点解决这两个问题,每个参数我都会说清楚它的作用以及调整它会带来什么后果。你照着敲一遍,再自己改改参数观察变化,基本就能把Q-Learning的核心逻辑吃透。

2. 迷宫环境设计与Q-Learning核心逻辑拆解

2.1 迷宫怎么建:网格、状态与动作的映射关系

迷宫本质上是一个离散状态空间。我习惯用一个二维数组来表示,0代表可走的路,1代表墙,2代表终点,3代表起点。比如一个5x5的迷宫长这样:

maze = [ [3, 0, 1, 0, 0], [0, 0, 1, 0, 1], [0, 0, 0, 0, 1], [0, 1, 1, 0, 0], [0, 0, 0, 0, 2] ]

每个格子就是一个状态。状态总数是行数乘以列数,这里就是25个。但墙的格子智能体永远走不进去,所以实际有效的状态少一些。不过为了编码方便,我通常还是按全部格子来建Q表,墙对应的那些行永远不会被更新,浪费一点内存但逻辑简单很多。

动作空间有四个:上、下、左、右。可以用0、1、2、3来表示,也可以用元组(-1,0)、(1,0)、(0,-1)、(0,1)来表示位移。我倾向于用字典把动作编号和位移对应起来,这样后面写代码可读性更好:

actions = { 0: (-1, 0), # 上 1: (1, 0), # 下 2: (0, -1), # 左 3: (0, 1) # 右 }

这里有个细节要注意:动作编号和位移的对应关系一旦定下来就不要改,因为Q表是按动作编号索引的。如果你中途改了映射,之前训练的结果就全乱了。我在早期就犯过这个错,调了半天以为算法有问题,结果是动作映射写反了。

状态转移规则也很直接:当前位置加上动作位移,如果超出边界或者是墙,就留在原地不动,同时给一个负奖励作为惩罚。如果到达终点,给一个大的正奖励,并且结束当前回合。其他情况每走一步给一个小的负奖励,这个设计是为了让智能体尽快找到终点,而不是在原地绕圈。

2.2 Q表到底存了什么:从状态动作对到价值估计

Q表是Q-Learning的核心数据结构。它的维度是[状态数, 动作数],每个元素Q[s][a]表示"在状态s下执行动作a所能获得的长期累积奖励的估计值"。注意这里说的是长期累积奖励,不是即时奖励。这是Q-Learning和贪心策略最本质的区别——它考虑的是长远利益。

初始化的时候,Q表全部置零。这意味着智能体一开始对所有状态动作对的价值一无所知,认为它们都一样好。随着不断尝试,Q值会逐渐分化,好的动作Q值升高,差的动作Q值降低。

Q表的更新公式是整个算法的灵魂:

Q(s,a) = Q(s,a) + α * [r + γ * max(Q(s',a')) - Q(s,a)]

这个公式看起来简单,但每一项都有明确的含义。α是学习率,控制新信息覆盖旧信息的程度。γ是折扣因子,衡量未来奖励的重要性。r是即时奖励。max(Q(s',a'))是下一个状态中最大的Q值,代表智能体对未来的最优估计。

我第一次看到这个公式的时候,最困惑的是为什么要用max。后来想明白了:Q-Learning是异策略(off-policy)算法,它在更新时假设下一步会采取最优动作,而不管实际采取的是什么动作。这个特性让它可以大胆探索而不影响学习效果,也是它比SARSA更常用的原因之一。

2.3 探索与利用的平衡:ε-贪心策略的实操细节

如果智能体永远选择当前Q值最高的动作,它可能会陷入局部最优,永远发现不了更好的路径。如果它永远随机选择,那学到的Q表就没有意义。所以需要在探索和利用之间找一个平衡。

ε-贪心策略是最常用的方法:以ε的概率随机选择一个动作,以1-ε的概率选择当前Q值最高的动作。ε通常从一个较大的值开始,比如1.0,然后逐渐衰减到一个较小的值,比如0.01。

我一般用指数衰减:

epsilon = epsilon * decay_rate

decay_rate通常设在0.99到0.999之间。衰减太快,智能体还没来得及探索就停止学习了;衰减太慢,后期还在大量随机动作,Q表难以收敛。这个参数需要根据具体问题调,没有万能值。

还有一个细节:ε不应该衰减到0。保留一点点随机性可以让智能体在环境有微小变化时仍能适应。我通常把下限设在0.01到0.05之间。

2.4 奖励函数设计:稀疏奖励下的信号引导

迷宫问题的奖励非常稀疏——只有到达终点才有正奖励,其他时候都是0或者小的负值。这种稀疏奖励会让学习变得很慢,因为智能体很难把最终的成功和前面几十步的选择关联起来。

我的做法是给每一步一个小的负奖励,比如-0.01。这样智能体有动力尽快到达终点,而不是无限绕圈。撞墙给一个稍大的负奖励,比如-0.1,让它学会避开墙。终点给+1.0。

奖励的绝对值大小会影响学习速度。如果步进惩罚太大,智能体可能会变得过于冒险;如果太小,它可能不在乎绕路。我试过-0.01、-0.05、-0.1几个值,-0.01在5x5到10x10的迷宫上表现比较均衡。

还有一个技巧是给终点附近的格子一些小的正奖励,形成梯度引导。但这会改变问题的最优解,需要谨慎使用。在标准迷宫问题里,我一般不加这个。

3. 完整代码实现与逐行解析

3.1 环境类的封装:状态重置与步进函数

我习惯把迷宫环境封装成一个类,这样代码结构清晰,也方便后续替换成其他环境。核心方法有两个:reset()和step()。

reset()把智能体放回起点,返回初始状态。step(action)执行动作,返回(next_state, reward, done)。done表示是否到达终点或达到最大步数。

import numpy as np import random class MazeEnv: def __init__(self, maze): self.maze = np.array(maze) self.rows, self.cols = self.maze.shape self.start = tuple(np.argwhere(self.maze == 3)[0]) self.goal = tuple(np.argwhere(self.maze == 2)[0]) self.state = self.start self.max_steps = 200 self.steps = 0 def reset(self): self.state = self.start self.steps = 0 return self.state def step(self, action): self.steps += 1 dr, dc = actions[action] nr, nc = self.state[0] + dr, self.state[1] + dc if nr < 0 or nr >= self.rows or nc < 0 or nc >= self.cols: next_state = self.state reward = -0.1 elif self.maze[nr][nc] == 1: next_state = self.state reward = -0.1 else: next_state = (nr, nc) if next_state == self.goal: reward = 1.0 else: reward = -0.01 self.state = next_state done = (next_state == self.goal) or (self.steps >= self.max_steps) return next_state, reward, done

这里有个容易忽略的点:状态用元组表示而不是用单个整数。用元组可以直接作为字典的键,也可以直接索引二维数组。如果要用整数表示状态,需要做一个线性映射:state_id = row * cols + col。两种方式都可以,元组更直观,整数更省内存。小迷宫用元组完全没问题。

max_steps的设置也很重要。如果没有这个限制,智能体可能在两个格子之间无限来回走,永远不结束。200步对于5x5迷宫足够了,更大的迷宫需要相应增加。

3.2 Q-Learning训练主循环:从随机试错到策略收敛

训练主循环的结构很固定:外层循环控制回合数,内层循环控制每回合的步数。每走一步,更新Q表,然后进入下一个状态。

def train(env, episodes=1000, alpha=0.1, gamma=0.9, epsilon=1.0, epsilon_min=0.01, decay=0.995): n_states = env.rows * env.cols n_actions = 4 q_table = np.zeros((n_states, n_actions)) for episode in range(episodes): state = env.reset() state_id = state[0] * env.cols + state[1] done = False while not done: if random.random() < epsilon: action = random.randint(0, 3) else: action = np.argmax(q_table[state_id]) next_state, reward, done = env.step(action) next_state_id = next_state[0] * env.cols + next_state[1] best_next = np.max(q_table[next_state_id]) td_target = reward + gamma * best_next * (1 - done) td_error = td_target - q_table[state_id][action] q_table[state_id][action] += alpha * td_error state_id = next_state_id epsilon = max(epsilon_min, epsilon * decay) return q_table

这段代码里有几个关键点值得展开说。

第一,(1 - done)这个因子。当done为True时,说明已经到达终点,没有下一个状态了,所以未来的价值应该为0。如果不乘这个因子,智能体会认为到达终点后还能继续获得价值,导致Q值虚高。

第二,状态ID的转换。我在循环外面把元组转成整数,循环内部一直用整数索引,避免重复转换。这个细节在状态多的时候能省不少时间。

第三,epsilon的衰减放在回合结束后,而不是每一步之后。按步衰减也可以,但按回合衰减更符合直觉——每个回合代表一次完整的尝试。

3.3 策略提取与路径可视化:验证学习效果

训练完成后,Q表就是最终的学习成果。提取策略很简单:每个状态选择Q值最大的动作。

def extract_policy(q_table, env): policy = {} for r in range(env.rows): for c in range(env.cols): if env.maze[r][c] == 1: continue state_id = r * env.cols + c best_action = np.argmax(q_table[state_id]) policy[(r, c)] = best_action return policy def visualize_path(env, policy): state = env.start path = [state] visited = set() visited.add(state) for _ in range(env.max_steps): if state == env.goal: break action = policy.get(state) if action is None: break dr, dc = actions[action] next_state = (state[0] + dr, state[1] + dc) if next_state in visited: print("检测到循环,策略未完全收敛") break visited.add(next_state) path.append(next_state) state = next_state return path

可视化的时候我用简单的字符画,把路径上的格子标出来。这样不用装matplotlib也能看到效果。如果要更直观,可以用matplotlib画热力图,把每个格子的最大Q值画出来,颜色越深表示价值越高。

我实测下来,5x5的迷宫大概200到500个回合就能收敛,10x10的需要2000到5000个回合。收敛的判断标准是连续多个回合都能在较少的步数内到达终点。

3.4 参数调优实战:学习率、折扣因子与探索率的配合

参数调优是Q-Learning最需要经验的部分。我把我常用的参数范围和调整逻辑整理成表:

参数常用范围作用调大后果调小后果
α学习率0.01-0.5新信息覆盖旧信息的程度学习快但不稳定学习慢但平滑
γ折扣因子0.8-0.99未来奖励的重要性重视长远利益短视
ε初始值0.5-1.0初始探索程度探索充分但初期慢初期快但可能错过最优
ε衰减率0.99-0.999探索减少的速度探索时间长收敛快但可能不充分
步进惩罚-0.01到-0.1鼓励快速到达过于冒险不在乎绕路

我一般先用α=0.1、γ=0.9、ε=1.0、decay=0.995跑一遍,观察收敛曲线。如果收敛太慢,把α调到0.2或0.3;如果Q值震荡厉害,把α降到0.05。γ在迷宫问题里通常设0.9到0.95,因为路径长度有限,不需要太远的视野。

有个经验:α和ε要配合调整。如果ε衰减很快,α可以适当大一点,因为后期主要是利用,需要快速稳定。如果ε衰减慢,α要小一点,避免在探索阶段把Q值搞得太激进。

4. 常见问题排查与实战避坑指南

4.1 智能体原地打转不前进怎么办

这是最常见的问题。智能体在起点附近来回走,Q值不分化,看起来像卡住了。

原因通常有三个:一是奖励设计有问题,步进惩罚太小,智能体不在乎绕路;二是ε太大,一直在随机探索;三是学习率太小,Q值更新太慢。

排查顺序:先看奖励,把步进惩罚从-0.01调到-0.05试试;再看ε,打印每个回合的ε值,确认它在正常衰减;最后看Q值,打印几个状态的Q值,如果全是0或者全相等,说明学习率太小或者回合数不够。

我遇到过一次特殊情况:迷宫起点被墙包围,只有一个出口,但出口方向需要先往上走再绕。智能体在起点附近随机走了很久才找到出口。这种时候可以手动增加起点附近的初始探索,或者把ε的初始值设得更高。

4.2 Q值不收敛或震荡的排查思路

Q值震荡表现为相邻回合的路径长度差异很大,或者Q表的值忽大忽小。

最可能的原因是学习率太大。α=0.5以上在大多数迷宫问题上都会震荡。降到0.1或0.05通常能解决。

第二个原因是γ太接近1,比如0.99以上。这会让未来奖励的估计方差很大,导致Q值不稳定。迷宫问题γ=0.9到0.95就够了。

第三个原因是环境有随机性。标准迷宫是确定性的,同样的状态同样的动作结果一样。如果你加了随机风或者随机初始位置,Q值收敛会慢很多,需要更多回合。

还有一个隐蔽的原因:状态表示有歧义。比如用元组表示状态时,如果两个不同的格子映射到了同一个状态ID,Q表就会混乱。检查状态ID的计算公式,确保每个格子对应唯一的ID。

4.3 训练速度太慢的优化技巧

5x5迷宫跑1000回合大概几秒钟,但10x10跑5000回合可能要一两分钟。如果迷宫更大,纯Python循环会明显变慢。

优化方向有几个。第一,用numpy向量化操作替代循环。Q表的更新可以批量进行,但迷宫问题里状态转移是串行的,向量化空间有限。第二,减少不必要的状态ID转换,在循环外算好映射表。第三,用更小的状态空间,墙的格子不建Q表行。

我试过用字典代替numpy数组存Q表,在状态稀疏的时候字典更快,但状态密集时numpy的索引优势明显。迷宫问题状态比较密集,numpy更合适。

还有一个技巧是提前终止。如果连续N个回合都在最优步数内到达终点,就可以停止训练。我一般设N=50。这能省不少时间,尤其是参数设得比较保守的时候。

4.4 迷宫变体:从固定地图到随机障碍的扩展

标准迷宫是固定地图,智能体学到的策略只适用于这一张图。如果想让它适应多张图,需要把地图作为输入的一部分,或者用函数逼近代替Q表。

一个折中方案是随机生成障碍,但保持起点和终点固定。这样智能体需要学到更通用的策略。实现上,每次reset时重新生成迷宫,但Q表保留。这相当于在线学习,智能体要同时适应多张地图。

我试过这个方案,收敛需要的回合数大概是固定地图的3到5倍。但学到的策略更鲁棒,换一张新图也能走个大概。如果迷宫尺寸不大,这个扩展很有意思,能让你更深刻地理解Q-Learning的泛化能力边界。

另一个扩展是加入随机风,即动作有一定概率偏离预期方向。这会让环境变成非确定性,Q-Learning仍然适用,但需要更多训练。这个扩展能帮你理解为什么Q-Learning在非确定性环境下仍然有效——因为它学的是期望价值。

4.5 常见问题速查表

问题现象可能原因排查方法解决方案
原地打转步进惩罚太小打印每回合步数增大步进惩罚
Q值震荡学习率太大观察Q值变化降低α
收敛太慢ε衰减太慢打印ε值加快衰减
到达终点后不停止done因子未乘检查td_target计算加(1-done)
策略有循环训练不充分可视化路径增加回合数
某些状态Q值全为0从未访问过检查访问计数增加探索
路径不是最短γ太小检查γ值增大γ

这张表是我踩坑踩出来的,基本上覆盖了90%的常见问题。遇到问题先查表,能省很多时间。

5. 从迷宫到真实问题:Q-Learning的适用边界与我的实操体会

迷宫问题虽然简单,但它揭示的序贯决策框架可以迁移到很多场景。只要一个问题满足"状态有限、动作有限、奖励可定义、决策有先后"这四个条件,Q-Learning就能用。比如库存管理、简单游戏AI、资源调度,甚至一些推荐系统的冷启动问题。

但Q-Learning的局限也很明显。状态一多,Q表就爆炸。10x10迷宫100个状态还能接受,100x100就是10000个状态,1000x1000就是一百万,内存和训练时间都受不了。这时候就需要函数逼近,用神经网络代替Q表,也就是DQN的思路。但那是另一个话题了,迷宫问题先把表格方法吃透,再往深度强化学习走会顺很多。

我在实际项目里用Q-Learning的经验是:它最适合状态离散且数量可控、环境确定性较强、奖励可以清晰定义的场景。如果状态是连续的,先做离散化,但离散化的粒度需要仔细调——太粗会丢失信息,太细状态爆炸。我一般先用较粗的离散化跑通流程,再逐步细化。

还有一个体会是:奖励设计比算法选择更重要。同样的Q-Learning,奖励设计得好,收敛快且策略合理;奖励设计得差,怎么调参数都不对。迷宫问题里步进惩罚和终点奖励的比例需要反复试,我建议从1:100开始调,比如-0.01和+1.0。

最后分享一个小技巧:训练过程中定期保存Q表,尤其是当回合步数创出新低的时候。这样即使后续训练震荡了,你也能回滚到最好的版本。我一般每100回合存一次,文件名带上回合数和平均步数,方便对比。

这个迷宫实例后续还可以这样扩展:加入多终点让智能体学习优先级,加入动态障碍考察在线适应能力,或者把Q表换成神经网络看看深度强化学习的效果差异。每一个扩展都能让你对序贯决策的理解更深一层。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进