前两周我帮一个刚接触智能控制的小团队带了个入门 Demo,目标很朴素:让机器人自己走出迷宫,而且不告诉它“哪条路最省事”。迷宫是网格化的,机器人只知道四个方向的动作,撞墙会被扣分,走到出口会拿到奖励,除此之外,它不掌握任何地图信息。跑了不到几百回合,它从到处瞎撞,到能稳定绕开障碍,最后用一条很短的路径抵达出口。这个过程走完,基本就把强化学习里最核心的状态、动作、奖励、训练循环都串起来了。
这篇就从“机器人走出迷宫”这个任务讲起。适合第一次接触强化学习的开发者,也适合那些已经看过公式、但不知道怎么落地到一个小 Demo 上的朋友。我会用一套简单的网格迷宫作为例子,把 Q-learning 背后的原理、环境建模、参数调法和踩坑记录都写清楚。你不需要先懂神经网络,也不需要翻完一本厚书,只要照着这篇的思路跑一次,就能理解强化学习到底是怎么回事。
1. 迷宫任务到底在跟强化学习“交换”什么信息
1.1 把迷宫抽象成状态、动作、奖励
强化学习有四个最基本的零件:环境、状态、动作、奖励。迷宫任务里,机器人观察到的“状态”就是当前所在格子的坐标,比如(0,0)表示左下角起点;它能执行的“动作”就是向上、向下、向左、向右;每一次动作之后,环境会回一个“奖励”信号,告诉它这个动作好不好。
这四个零件缺一不可。状态决定了机器人目前处在什么局面,动作决定了它可以怎么改变局面,奖励决定了它应该往哪个方向优化,而环境负责把所有逻辑串起来:你给出一个动作,环境返回下一个状态和奖励。
初学者最容易忽略的一点是:状态不一定等于完整地图。机器人不需要知道墙在哪里、出口在哪里,它只需要知道自己“当前在哪个格子”。地图信息藏在环境里,机器人必须通过一次次试错才能慢慢摸清。这跟普通游戏里打开小地图看全局路径是完全不同的体验。
在一套简单的迷宫环境里,我通常会这样定义:
| 组件 | 迷宫中的对应 | 说明 |
|---|---|---|
| 环境 | 整个网格地图 | 包含边界、墙体、起点、出口 |
| 状态 | 机器人当前所在格子坐标 | 例如(2,3)表示第二行第三列 |
| 动作 | 上下左右四个方向的移动 | 撞墙时留在原地,不越界 |
| 奖励 | 走到出口给正奖励,撞墙给负奖励 | 后续细讲推荐方案 |
这个抽象方式非常关键。你之后把地图加大、改成连续空间、甚至换成机器人身上的摄像头画面,本质上还是在围绕“状态、动作、奖励”这三件事做文章。
1.2 “没有标准答案”才是关键
很多第一次接触强化学习的人会拿它跟监督学习对比。监督学习里,你已经知道每个输入对应的正确答案,模型只需要把这些映射关系“背”下来,比如判断一张图片是不是猫,训练数据里有明确的标签。但强化学习没有这种标准答案。
迷宫任务里,你没有办法提前告诉机器人“当你站在(2,2)时就要向上走”。因为你不可能提前枚举所有情况,而且一步动作的好坏要看未来几步的影响。站在某个格子,向上走可能暂时看着绕路,但能避开一面墙;向下走可能立刻靠近出口,却会走进死胡同。如果只用当前这一步的“对错”来判断,机器人永远学不会长远规划。
这就像小时候玩的“热/冷”游戏。你闭着眼睛找藏起来的奖品,别人只告诉你“更热了”或者“更冷了”。你没有每一步的标准路径,只能根据温度的反馈调整方向。温度不是标准答案,它只是一个评估信号。
强化学习的目标也不是“每一步都做对”,而是“最大化整个过程的回报”。机器人可以偶尔走弯路,但整体上要能攒到更多奖励。于是就有了策略这个概念:策略π表示“状态到动作”的映射关系,机器人要学的就是找到一个策略,使执行它之后累积的奖励最多。
2. 环境建模是第一道分水岭,建模错了后面全白搭
2.1 迷宫环境到底怎么定义才不容易出 bug
很多强化学习项目跑不出效果,问题不是出在算法,而是出在环境代码。环境是算法和任务之间的桥梁,如果桥搭歪了,再高级的算法也过不去。
我习惯用“坐标 + 墙体集合”的方式建模。这样写最直接,也不容易跟二维数组的行列概念混淆。以下是一个 6 行 6 列的迷宫:
ROWS = 6 COLS = 6 # 墙体的坐标集合 WALLS = { (1, 0), (1, 1), (1, 3), (1, 4), (3, 1), (3, 2), (3, 4), (4, 4), } START = (0, 0) EXIT = (5, 5) ACTIONS = ("up", "down", "left", "right") MOVES = { "up": (-1, 0), "down": (1, 0), "left": (0, -1), "right": (0, 1), }然后写一个不可见的地图逻辑:判断一个位置是不是合法位置,以及执行一次动作后到达哪里。
def is_valid(pos): r, c = pos return 0 <= r < ROWS and 0 <= c < COLS and pos not in WALLS def step(state, action): dr, dc = MOVES[action] next_state = (state[0] + dr, state[1] + dc) if not is_valid(next_state): # 撞墙或越界:留在原地 return state return next_statestep函数是环境的核心。我特别强调一点:撞墙或越界时,不要直接让程序报错,而是让机器人留在原地。这样方便在训练循环里统一处理 “撞墙” 这个信号,也符合真实机器人的行为:撞墙不是死亡,只是行动失败,得换一条路。
墙体集合用set而不是列表,是为了用x in WALLS做常数时间判断。这在小 Demo 里看不出性能差异,但它能养成一个好习惯:环境逻辑本身要快,后面算法要跑几千几万次循环,环境慢了会特别难受。
2.2 奖励设置会带来完全不同的学习行为
同一张地图,奖励函数不同,机器人学出来的行为很可能完全不同。这地方我踩过不少次,下面说清楚几种典型写法。
最常见的是“稀疏奖励”:到达出口给一个大的正奖励,其余情况一律 0 或很小的负奖励。这种方法概念上最直观,机器人必须靠大量随机探索碰巧发现出口,才能把奖励信号一路传回来。问题也很明显:如果迷宫稍大,随机撞到出口的概率太低,训练会非常慢。
另一种是“每一步都有一点小惩罚”,比如走出终点之外的每一步给-0.04。这样机器人会天然倾向于用更少的步数到达出口,学出来的路线更短。如果只给出入口奖励,其余全是 0,机器人虽然能到达,但路径经常乱绕,因为它并不在乎步数。
还有一个做法是设计“势能奖励”:根据目标点的距离给一个接近目标的加分。这种方式在某些场景收敛快,但在迷宫里特别容易把机器人带偏。因为它会被“距离”欺骗,比如墙体前面明明绕过去更近,但它发现直接往目标方向走暂时能提高分数,于是反而卡在墙边反复尝试。入门阶段我不推荐用这种奖励,等基础扎实了再玩。
我现在的默认结果是:
| 情况 | 奖励 |
|---|---|
| 到达出口 | +10 |
| 撞墙或越界 | -1 |
| 普通移动 | -0.04 |
为什么出口奖励和撞墙惩罚差这么多?因为-0.04是微小的行为约束,只让机器人尽量少走冤枉路;-1是明显的行为否定,让它迅速避开墙;+10是明确的正反馈,让它知道目标在哪里。这三者之间要有比较清晰的层次,混在一起会让 Q 值更新变得很“模糊”。
奖励不是越大越好。如果出口奖励设为+100,但每步没有代价,机器人可能走到出口附近还是会绕,因为只要有正收益,它就觉得“怎么走都行”。反过来,如果每步惩罚设为-1,机器人可能干脆原地不动,因为至少不会继续扣分。奖励的数量级要经过实际跑分来判断,不能拍脑袋。
3. Q-learning:一张表就能让机器人记住“哪个方向值得试”
3.1 Q 表、TD target、更新公式拆开讲
Q-learning 是入门强化学习绕不开的经典算法,因为它好理解、好落地,而且在小规模状态空间里效果很可靠。它要维护一张 Q 表,记录“在状态s下执行动作a”的价值,记作Q(s, a)。
你可以把 Q 表想象成一本“经验账本”。每个格子的四个方向都有一笔账,刚开始都是 0,表示“没试过、不知道好坏”。机器人每走一步,就根据实际收到的奖励去更新这笔账。
Q-learning 的核心更新公式长这样:
Q(s, a) ← Q(s, a) + α * [ r + γ * max(Q(s', a')) - Q(s, a) ]拆开看每个符号:
α是学习率,决定新经验有多大影响;r是这一步拿到的奖励;γ是折扣因子,决定未来奖励在现在看来的权重;s'是执行动作后进入的下一个状态;max(Q(s', a'))表示下个状态里所有动作中最好的那个价值,代表“未来能得到的最大收益”。
公式里的r + γ * max(Q(s', a'))叫 TD target,它是“当前即时奖励”和“未来最优收益”的组合;Q(s, a)是旧估值。两者相减得到误差,再用学习率去修正。这就是时序差分学习的核心思路:不一定要走到终点才知道结果,走一步之后,就可以把“下一步的估计价值”当成部分答案拿回来更新。
这个思想比动态规划要轻得多。机器人不需要知道完整的地图模型,只需要通过实际采样一步步试错,就能逐步逼近真实价值。这也是强化学习适合真实机器人控制的原因之一,因为真实世界里你不可能提前拿到可靠的全局模型。
为了直观理解,我举个例子。假设机器人第一次随机走到出口旁边的格子(4,5),向左一步进入出口,得到+10。此时如果γ = 0.9,那么当前格子“向左”这个动作的价值会瞬间上涨很多。以后机器人再次到(4,5)附近时,它就更倾向于向左,而不是到处乱撞。
3.2 一个完整可跑的 Q-learning 训练循环
下面的代码不依赖任何第三方库,用纯 Python 就能跑起来。它先初始化 Q 表,然后开始多轮训练:
import random # 初始化 Q 表 Q = {} for r in range(ROWS): for c in range(COLS): if (r, c) not in WALLS: for action in ACTIONS: Q[((r, c), action)] = 0.0 alpha = 0.1 gamma = 0.95 epsilon = 1.0 min_epsilon = 0.05 epsilon_decay = 0.995 total_episodes = 2000 max_steps = 100 for episode in range(total_episodes): state = START done = False for t in range(max_steps): # epsilon-greedy 策略 if random.random() < epsilon: action = random.choice(ACTIONS) else: best_value = max(Q[(state, action)] for action in ACTIONS) candidates = [ action for action in ACTIONS if Q[(state, action)] == best_value ] action = random.choice(candidates) next_state = step(state, action) # 奖励统一在环境外面处理,便于调试 if next_state == EXIT: reward = 10.0 done = True elif next_state == state: reward = -1.0 else: reward = -0.04 # 计算下一个状态的最大 Q 值 best_next = max(Q[(next_state, action)] for action in ACTIONS) # Q-learning 更新 Q[(state, action)] += alpha * ( reward + gamma * best_next - Q[(state, action)] ) state = next_state if done: break # 每回合结束后衰减探索率 epsilon = max(min_epsilon, epsilon * epsilon_decay)这段代码里有几个容易看走眼的点。
第一个是“在 Q 值相同的情况下,随机挑选一个动作”。如果直接固定写max(ACTIONS, key=...),机器人遇到所有动作 Q 值都等于 0 的情况时,会一直选第一个动作,导致某些方向永远没被探索过。随机打破平局是我一直坚持的写法。
第二个是best_next的计算。当next_state是出口时,我把这回合直接 break,出口状态对应的 Q 值仍然是 0,所以best_next算出来是 0,正好符合“到达终点后没有未来奖励”的设定。如果你不希望依赖这一点,也可以在done时强制把best_next设为 0,逻辑上更严谨。
第三个是探索率epsilon的衰减。一开始机器人完全随机,这样才能尽可能覆盖地图。随着训练推进,它的 Q 表越来越可信,就应该逐渐减少随机行为,更多利用已经学到的经验。衰减系数0.995表示每回合乘以0.995,训练 2000 回合后,探索率会从1.0慢慢掉到0.05附近,不会过早进入纯利用阶段。
3.3 训练完之后怎么把策略“导出来”
训练完成后,策略就藏在 Q 表里。下一步是让机器人从起点开始,始终选择 Q 值最大的动作,走出一条完整路径:
def choose_best_action(state): best_value = max(Q[(state, action)] for action in ACTIONS) candidates = [ action for action in ACTIONS if Q[(state, action)] == best_value ] return random.choice(candidates) state = START path = [state] for _ in range(max_steps): action = choose_best_action(state) state = step(state, action) path.append(state) if state == EXIT: break我比较喜欢把path打印出来,一行坐标看过去,基本能判断机器人是否学会了。如果打印出来的路径是(0,0) -> (0,1) -> ... -> (5,5),而且没有反复经过同一个格子,说明训练成功了。
如果路径还是乱跳,先别急着改算法。回到 Q 表里随便挑几个状态,打印看看这几个方向的 Q 值分布是否合理。比如离出口近的那个方向 Q 值应该明显高于其他方向,如果差距很小,说明训练还不够,或者参数需要调整。
4. 参数不是玄学,是有一套调法在的
4.1 三个核心参数各自管什么
很多人在 Q-learning 里调参,就像在黑暗中拧旋钮,拧完也不知道发生了什么。其实核心参数就那么几个,理解清楚之后,调参逻辑会清晰很多。
| 参数 | 含义 | 初学者建议范围 | 调过头会怎样 |
|---|---|---|---|
alpha学习率 | 新经验对旧 Q 值的影响程度 | 0.05 ~ 0.2 | 太大,Q 值震荡不收敛;太小,训练慢 |
gamma折扣因子 | 对未来奖励的重视程度 | 0.9 ~ 0.99 | 太小,变得短视,学不会长路径 |
epsilon探索率 | 采取随机动作的概率 | 从1.0衰减到0.05左右 | 下降太快,探索不足,卡在局部最优 |
先看学习率alpha。它本质上是“步伐大小”。如果alpha = 0.1,每次更新只向误差方向移动 10%。这个值听起来不大,但在大量回合里足够稳定。新手最容易犯的错误是把alpha设成0.8甚至更高,结果 Q 值像过山车一样剧烈震荡,训练曲线看不出来它在收敛。我遇到震荡时,第一反应就是把alpha降到0.1以下。
再看折扣因子gamma。它的意思是“未来的一块钱,现在值多少钱”。迷宫任务里,如果gamma = 0.9,机器人更看重接下来几步内的收益;如果gamma = 0.99,它会更重视长远的路线选择。太小的gamma会让机器人变得极度短视,看到眼前有个奖励就走过去,哪怕前方是死胡同。入门环境里,0.9~0.95已经够用,只有迷宫很大、路径很长时才需要逼近0.99。
最后是探索率epsilon。我习惯从1.0开始,让机器人先做大量随机动作,把地图“摸”一遍;随训练推进逐步下降到0.05。这个过程叫探索率的退火。如果一开始就把epsilon设成0.1,机器人大部分时间直接走 Q 表里 Q 值最高的方向,可 Q 表刚开始全是 0,它很容易一条路走到黑,很多区域永远探索不到。
4.2 一套能直接抄的默认配置
我不会一上来就调参,而是先找一套能让任务跑起来的保守配置。
对于一个 6x6 小迷宫,我常用的配置是:
- 训练回合数:
2000 - 单回合最大步数:
100 - 学习率:
0.1 - 折扣因子:
0.95 - 初始探索率:
1.0 - 最终探索率:
0.05 - 探索率衰减:
0.995
这套配置的好处是:步数上限远超理论最短路径,回合数够多,探索衰减温和。即使地图稍微复杂一点,也能大概率找到出口。如果你把迷宫改成 10x10,我会把回合数提高到5000,最大步数提高到200,其余保持不变。
为什么最大步数不能设置得太小?因为探索初期机器人完全随机,极大可能在迷宫里转很久到不了出口。如果提前截断回合,它会失去很多“探索地图”的机会。但也不能设得太大,否则每一个训练回合都很慢,浪费计算资源。一个经验值是:最大步数至少是“从起点到出口的最短路径长度”的三到五倍。
4.3 怎么判断机器人“学会了”
判断是否学会,不能只看最后一次训练结果,更不能看单个回合。我通常记录两个指标:每回合的总回报、每回合到达出口时的步数。
把总回报画成一条曲线,你会看到它在训练早期可能是负的,因为机器人经常撞墙、乱走;随着探索到出口,曲线开始爬升,最后稳定在一个高位。单次结果波动很正常,我习惯看最近几十个回合的滚动平均。
如果曲线一直在原地不动,只有两种可能:一是奖励信号根本没有传回去,二是探索不足。前者要检查环境里的step函数和奖励计算,后者可以暂时把epsilon衰减调慢一点,或者把min_epsilon调高一点看看有没有变化。
如果曲线能爬升但步数降不下来,说明机器人虽然找到了出口,但没有学会“走近路”。此时把每步的负奖励从-0.04调成-0.1,通常就能促使它选择更短的路径。奖励函数里那个“微小代价”的效果,在这一步体现得非常明显。
5. 迷宫变大之后:从 Q 表到深度强化学习
5.1 从二维数组到高维状态,为什么 Q 表记不下
Q-learning 在小迷宫上非常好用,但它有一个硬伤:它用一张二维表把每个离散状态都单独存一份。地图一旦变大,状态数会迅速膨胀。
举个例子,一个 20x20 的迷宫,去掉墙体后大概有 400 个状态,每个状态有 4 个动作,Q 表里有大约 1600 条记录。这个量级还好。但如果状态变成机器人的摄像头图像,每一帧可能有几十万个像素,每个像素还有连续数值,你根本没法把每一种像素组合都单独列成一行。
更麻烦的是 Q 表没法“泛化”。两个非常相似的格子,一个是真实可走的路,另一个是墙边,Q 表会当成两个完全独立的状态来处理。机器人学会了 A 格子的经验,却不知道把它迁移到 B 格子上。这种低效在小地图里可以忍,在高维空间里会彻底撑不住。
我把 Q 表和神经网络的差异比作“经验手册”和“判断能力”。手册把每条经验都写下来,翻起来慢、占地方;神经网络负责从经验中提炼规律,遇到没见过的状态也能根据已有经验给出一个差不多的判断。
5.2 把 Q 表换成神经网络,思路其实没有变
深度强化学习,尤其是 DQN 风格的做法,并不是把 Q-learning 推倒重来,而是只做一个替换:把“查 Q 表”这一步换成“神经网络前向计算”。
| Q-learning | DQN 思路 |
|---|---|
在 Q 表里查Q(s, a) | 输入状态s,网络输出每个动作对应的 Q 值 |
| 更新公式直接改表 | 用均方误差损失反向传播更新网络权重 |
r + γ * max(Q(s', a'))作为目标值 | 目标网络计算目标值 |
| 每条经验只更新一个格子 | 从经验池里随机采样更新网络 |
DQN 有一个关键训练技巧叫“经验回放”。它把机器人每一步产生的经验(s, a, r, s')存进一个缓冲区,训练时随机抽一批出来更新网络。这样做有两个好处:一是打断相邻采样之间的相关性,防止网络只盯着最近几步;二是让旧经验反复被使用,数据利用率更高。
还有一个技巧是“目标网络”。如果直接用同一个网络既算当前 Q 值又算目标值,参数一变,目标也跟着变,训练会非常不稳定。于是训练时维护一个“滞后更新”的目标网络,一段时间才同步一次参数,让机器人有了一个相对固定的改进方向。
这里不展开写具体网络结构,因为从 Q-learning 到 DQN 的跳板,核心是理解“用函数近似代替查表”。神经网络 Q 值输出的维度等于动作个数,比如迷宫上下左右四个动作,网络输出层就输出四个值。这是我自己从 Q 表过渡到深度强化学习时,觉得最通顺的一条思路。
5.3 什么时候不急着上深度强化学习
新手很容易被“深度”两个字吸引,一上来就搭神经网络。但深度强化学习远没有 Q-learning 稳,它对超参数、网络结构、随机种子都更敏感。我见过不少项目,明明用 Q-learning 几十秒就能跑通的训练,换成 DQN 后反而一直不收敛。
如果你只是做一个小迷宫,务必先用 Q-learning 验证环境逻辑和奖励函数。只有确认“表格版本”能稳定收敛,再考虑升级到深度版本。如果状态本来就是离散的、状态数也没超过几万个,Q-learning 效率更高、逻辑也可解释。深度强化学习适合解决的是高维状态、连续动作、或者状态之间天然有共享特征的任务,不是解决所有问题的银弹。
当迷宫变成类似机器人视觉导航的任务时,你自然会发现 Q 表没办法处理图像状态,这时候再引入神经网络才有必要。从小处入手,逻辑上会顺很多。
6. 实操中翻过的车和排查清单
6.1 机器人总在墙边横跳,多半不是“傻”,是信号问题
有一种现象很典型:机器人已经训练了很久,但每次跑到一面墙前面,就开始左一步右一步反复横跳,就是不绕开。第一次遇到这个现象时我也以为是探索率太高,后来打印 Q 值才发现,问题出在奖励的设置上。
如果普通移动奖励是 0,撞墙奖励也是 0,那么所有能移动动作的 Q 值在计算上可能都差不多。机器人发现撞墙和正常走没有区别,横跳和绕路也没有区别,自然会倾向于不动脑子地反复执行那几个动作。解决方法是给每步一个轻微的负奖励,给撞墙一个更明显的负奖励,让它在数值上分清楚“白白撞墙”和“有效移动”的差别。
还有一个小概率原因是探索率长期过高。如果min_epsilon设成0.3,即使训练结束,它也有三成概率随机乱走,看起来就像没有学会。评估模型时,我会临时把epsilon强制设为0,只看纯利用策略的表现。
6.2 到达了出口,路线却很绕,问题出在哪
如果机器人到达出口没问题,但路径明显绕远路,最可能的原因是它没有“长远计划”的压力。出口奖励对机器人来说是拿到了就行,至于走了多少步,如果每步没有惩罚,它完全不在乎。
我会先看两个参数:gamma是不是太低,每步惩罚是不是太小。gamma低了,机器人只关心接下来几步内能不能拿到奖励;每步惩罚太小,它也不会为了节省几步去改变行为。通常把每步奖励从-0.04调到-0.1,配合gamma = 0.95,路径就会明显变直。
不要为了追求“绝对最短路径”而把惩罚设得太极端。惩罚如果大到超过探索成本,机器人会变得过于保守,宁可待在原地也不愿意尝试新的路径。安全和效率之间要留一点缓冲。
6.3 一个 5 步排查流程
当我面对一个训练失败的迷宫项目时,不会上来就改算法,而是按下面这个流程来走:
- 固定随机种子。这样每次结果一致,否则你很难判断改动到底有没有效果。
- 手动走一条可行路径。直接用
step函数从起点一步一步到出口,确认坐标方向、墙体判断、奖励计算都符合预期。 - 关闭探索做一次评估。把
epsilon设为 0,从起点跑一次,看看输出路径是否合理。 - 打印若干关键状态的 Q 值。比如出口旁边的四个方向、起点附近的四个方向,看数值是否跟直觉一致。
- 逐步扩大地图。先在小地图上调通环境和奖励,再往更复杂的迷宫上迁移。
这个方法帮我省下了大量在算法层面乱试的时间。绝大多数训练失败,最后都能在“环境错误”这一步里找到原因。
6.4 一个小教训:坐标方向弄反,训练一万回合也没用
有一次我帮一个朋友调试迷宫 Demo,他告诉我机器人训练了好几千回合,Q 值却始终没有上升趋势。我让他打印几次“状态转移日志”,结果发现:他定义动作"down"是行号加 1,但地图里的出口坐标写反了,机器人以为自己在往出口走,实际上每次都在向着墙的方向移动。
问题不在算法,而在环境编码层的坐标方向不一致。这种错误在刚上手时很容易出现,尤其是用二维数组表示地图时,行和列非常容易混。
我的习惯是,在写训练循环之前,先用几行代码手动跑一遍step函数,把关键位置的转移结果打印出来看一眼。比如从(4,5)向左走,应该到达(4,4),如果输出的是别的坐标,说明MOVES表或者地图坐标定义有问题。这一步检查只要一分钟,却可以省下后面几个小时盲目调参的时间。
最后再分享一个我常用的调试习惯:每个训练回合结束后,额外记录一下“本回合是否到达出口”这个布尔值。看这个布尔值的频率变化,比盯着 Q 值数字更容易判断训练是否进入正轨。如果到达出口的次数逐渐增加,说明强化学习起作用了;如果始终是 0,那一定不只是参数问题,回头把环境翻一遍。把这个习惯保持下来,你会发现“让机器人走出迷宫”并不难,难的是把每一步的反馈都设计得清清楚楚。