ML-For-Beginners 强化学习实战:用「彼得与狼」网格世界从零实现 Q-Learning
本篇指南基于微软开源课程 ML-For-Beginners 第 8 周强化学习模块的第一课(8-Reinforcement/1-QLearning 的中文版 translations/README.zh-cn.md)。将以俄罗斯音乐童话《彼得与狼》为原型搭建一个网格世界环境,完整走通「环境建模 → 随机行走基线 → 奖励函数 → Q-Table 与贝尔曼方程 → 探索/利用平衡 → 策略评估」的 Q-Learning 全流程。读完后,你将能独立实现一个表格型强化学习智能体,理解 Q-Table 的含义、学习率与折扣因子等超参数的作用,并知道如何通过统计路径长度来验证学习效果。
强化学习的三个核心概念:代理、状态与动作
强化学习(Reinforcement Learning, RL)围绕三个基本概念展开:代理(agent)、状态(state)和每个状态下的一组动作(actions)。代理在某个指定状态下执行一个动作,就会得到一个奖励(reward)。
原文档用超级马里奥的游戏来建立直觉:你是马里奥,站在悬崖边上,头顶有一枚硬币。「你是马里奥、在游戏关卡中、处于某个特定位置」——这就是一个状态。向右移动一步(一个动作)会坠下悬崖,得到一个低分;按下跳跃按钮则能活下来并得分,这是一个积极结果,应给予正向分数。借助强化学习加一个模拟器(游戏),就能学会「如何玩游戏以最大化奖励」——既活下来,又尽可能多地得分。
RL 与监督学习、无监督学习并列为机器学习的基本范式。与分类/回归不同,RL 通常没有标注数据集,而是要让计算机反复「玩」很多次、观察结果来学习行为。这也是为什么本课只需要两样东西:一个可重复模拟的环境(定义规则、状态和动作),以及一个告诉我们表现好坏的奖励函数。
先决条件和运行环境
本课用 Python 做实验,你需要能在本地或云上运行 Jupyter Notebook:
- 打开本课的教学笔记本,跟随本文逐节编译、运行即可;
- 注意:如果你是从云端打开代码,还需要额外获取笔记本代码中用到的
rlboard.py文件,并将其放到与笔记本相同的目录中(笔记本第 1 个代码块通过from rlboard import *引用它)。
从源码结构看,rlboard.py 是一个完整的环境模拟器,依赖 matplotlib、numpy、cv2 三个库(见 rlboard.py 头部导入),因此运行环境需要安装这三者。
环境:把「彼得与狼」建成 8x8 棋盘
本课让彼得(Peter)在环境中探索、收集苹果、躲避狼。为简单起见,把世界抽象为一个 width x height 大小的方板,每个单元格可以是以下之一:
- 地面(ground):彼得和其他生物可以在上面行走;
- 水(water):不能在上面行走;
- 树或草(tree/grass):可以休息的地方;
- 苹果(apple):彼得想找到用来果腹的食物;
- 狼(wolf):危险,应该避免遇到。
创建示例棋盘的核心代码如下(原文档「代码块 1」):
from rlboard import *
width, height = 8,8
m = Board(width,height)
m.randomize(seed=13)
m.plot()
这段代码会打印一张与环境示意图类似的图片。结合 rlboard.py 源码可以确认棋盘的实现细节:
- 单元格类型定义在
Board.Cell内部类中:empty=0, water=1, wolf=2, tree=3, apple=4(Board.Cell),后文m.at()的返回值就是这些整型常量; randomize(seed=13)负责随机撒布地图元素,其默认参数为water_size=5, num_water=3, num_wolves=1, num_trees=5, num_apples=3(randomize 方法)。也就是说,一个 8x8 棋盘上默认有 3 条水洼、1 只狼、5 棵树、3 个苹果——这正是后文「到最近苹果的平均距离约 5-6 步」的地图来源;plot()最终调用image()渲染出像素图(Board.plot),并且支持传入 Q-Table 参数在空格子上画出策略箭头,后文会用到。
动作与策略(Policy)
彼得的目标是找到苹果,同时避开狼和其他障碍物。在任何位置,他只能选择四个动作之一:上、下、左、右。我们用字典把动作映射到对应的坐标变化对,例如向右移动(R)对应 (1,0)(原文档「代码块 2」):
actions = { "U" : (0,-1), "D" : (0,1), "L" : (-1,0), "R" : (1,0) }
action_idx = { a : i for i,a in enumerate(actions.keys()) }
这里有两个约定值得注意:
- 坐标系中
x是行(上下对应第二个分量)、y是列(左右对应第一个分量),这与 numpy 数组索引Q[x, y]保持一致; action_idx建立了「动作字符 → 索引」的映射,后面更新 Q-Table 时需要用它索引第三维(len(actions)=4)。
概括一下本场景的策略与目标:
- 策略(policy):代理(彼得)的策略由一个函数定义,该函数返回任意给定状态下的动作。状态由棋盘表示,包括玩家的当前位置(
m.human属性); - 目标:强化学习的目的是最终学习到一个好策略,让我们能高效地解决问题。作为基线,先考虑最简单的策略——随机走动(random walk)。
基线:随机走动策略及其统计
随机走动策略就是每次从允许的动作中随机挑一个,直到找到苹果(原文档「代码块 3」):
def random_policy(m):
return random.choice(list(actions))
def walk(m,policy,start_position=None):
n = 0 # number of steps
# set initial position
if start_position:
m.human = start_position
else:
m.random_start()
while True:
if m.at() == Board.Cell.apple:
return n # success!
if m.at() in [Board.Cell.wolf, Board.Cell.water]:
return -1 # eaten by wolf or drowned
while True:
a = actions[policy(m)]
new_pos = m.move_pos(m.human,a)
if m.is_valid(new_pos) and m.at(new_pos)!=Board.Cell.water:
m.move(a) # do the actual move
break
n+=1
walk(m,random_policy)
walk 的调用会返回本次路径长度,每次运行结果可能不同。它的终止逻辑有三条:踩到苹果返回步数 n(成功);掉进水里或被狼吃掉返回 -1;对无效/水域格子则重试选动作。注意 Board 类本身也内置了一个功能等价的 walk 方法,且支持 save_to 参数把每一步渲染成图片序列——课程动画(如随机走动 GIF)就是基于它生成的。
多次运行该实验(例如 100 次)并打印统计信息(原文档「代码块 4」):
def print_statistics(policy):
s,w,n = 0,0,0
for _ in range(100):
z = walk(m,policy)
if z<0:
w+=1
else:
s += z
n += 1
print(f"Average path length = {s/n}, eaten by wolf: {w} times")
print_statistics(random_policy)
随机走动的统计结果是:平均路径长度约 30-40 步,而被狼吃掉会随机出现若干次。考虑到到最近苹果的平均距离只有约 5-6 步,这个数字相当大——随机策略的探索是极其低效的,这就是我们需要学习算法的动机。
奖励函数:延迟奖励问题
要让策略更智能,我们需要知道哪些动作比其他动作「更好」,这通过奖励函数定义:它为每个状态返回一个分数值,数字越大奖励越好(原文档「代码块 5」):
move_reward = -0.1
goal_reward = 10
end_reward = -10
def reward(m,pos=None):
pos = pos or m.human
if not m.is_valid(pos):
return end_reward
x = m.at(pos)
if x==Board.Cell.water or x == Board.Cell.wolf:
return end_reward
if x==Board.Cell.apple:
return goal_reward
return move_reward
奖励设计包含三个数值常量:每走一步 -0.1(轻微惩罚,鼓励走短路径)、到达苹果 +10(目标奖励)、掉水/遇狼/越界 -10(终止惩罚)。
奖励函数有一个关键特性:在大多数情况下,我们只在游戏结束时才得到实质性奖励。这意味着算法必须能「记住」那些最终导向正奖励的「好」步骤并提高它们的重要性,同时抑制所有导向坏结果的举动——延迟奖励(delayed reward) 的归属问题,正是强化学习区别于监督学习的核心难点,也是下一节贝尔曼方程要解决的问题。
Q-Table:用表格记录每个动作的「优点」
本课使用的算法叫 Q-Learning。在该算法中,策略由一个称为 Q-Table 的函数(或数据结构)定义,它记录给定状态下每个动作的「优点(goodness)」。
之所以叫 Q-Table,是因为用表格(多维数组)表示它往往很方便。棋盘尺寸是 width x height,所以可以用形状为 width x height x len(actions) 的 numpy 数组表示 Q-Table(原文档「代码块 6」):
Q = np.ones((width,height,len(actions)),dtype=np.float)*1.0/len(actions)
注意我们把 Q-Table 的所有值初始化为相等的数值 1/len(actions) = 0.25(即 4 个动作各 0.25)。这对应「随机走动」策略——每个状态中所有移动同样好。把 Q-Table 传给 plot 函数即可在棋盘上可视化表格:m.plot(Q)。在 rlboard.py 的 image 方法 中可以看到:对每个空格子,先调用 probs(Q[x,y]) 把 Q 值归一化成概率,再按四个方向加权求和画出一条「箭头」(draw_line),表示该格子偏好的移动方向;由于初始时所有方向等权,画出来的是一个点。
现在需要运行模拟、探索环境,学习一份更好的 Q-Table 数值分布,让我们能更快地找到通往苹果的路。
Q-Learning 的本质:贝尔曼方程
一旦开始移动,每个动作都有对应的奖励,理论上可以按最高即时奖励选择下一个动作。但在大多数状态下,这一步并不会直接实现「到达苹果」的目标,因此无法立即判断哪个方向更好。
请记住:重要的不是直接结果,而是我们在模拟结束时获得的最终结果。
为处理这种延迟奖励,需要借助**动态规划(dynamic programming)**的原则,递归地思考问题:
假设现在处于状态 s,想移动到下一个状态 s'。这样做会收到奖励函数定义的即时奖励 r(s,a),以及一些未来奖励。如果我们假设 Q-Table 正确反映了每个动作的「吸引力」,那么在状态 s' 我们会选择使 Q(s',a') 最大的动作 a'。因此,在状态 s 能获得的最佳未来奖励可定义为 maxa'Q(s',a')(最大值在状态 s' 的所有可能动作 a' 上计算)。
由此得到计算状态 s 下动作 a 的 Q-Table 值的 Bellman 公式:
其中 γ 是所谓的折扣因子(discount factor),决定你应在多大程度上偏好当前奖励而非未来奖励(反之亦然)。
学习算法伪代码与「探索 vs 利用」
基于上面的等式,学习算法的伪代码如下:
- 用相同的数字为所有状态和动作初始化 Q-Table Q;
- 设置学习率 α ← 1;
- 多次重复模拟:
- 从随机位置开始;
- 重复:
- 在状态 s 选择一个动作 a;
- 通过移动到新状态 s' 来执行动作;
- 如果遇到游戏结束情况,或总奖励太小——退出模拟;
- 计算新状态下的奖励 r;
- 根据 Bellman 方程更新 Q 函数:Q(s,a) ← (1-α)Q(s,a)+α(r+γ maxa'Q(s',a'));
- s ← s';
- 更新总奖励并减小 α。
在上面的算法中,步骤 2.1「如何选动作」并没有指定。两种极端:
- 探索(explore):随机选择动作,会随机探索环境,很可能经常死亡,也会探索到通常不会去的区域;
- 利用(exploit):选择 Q-Table 中值最高的最优动作。但这会阻止我们探索其他状态,可能永远找不到最优解。
最佳做法是在两者之间取得平衡:以与 Q-Table 值成比例的概率选择动作。一开始 Q-Table 值全部相同,等价于随机选择;随着对环境的了解越来越多,会更可能走最优路线,同时仍允许代理偶尔选择未探索的路径。
Python 实现:5000 个 epoch 的训练循环
实现学习算法之前,先需要一个函数把 Q-Table 中的任意数字转换为对应动作的概率向量(原文档「代码块 7」):
def probs(v,eps=1e-4):
v = v-v.min()+eps
v = v/v.sum()
return v
这里向原始向量加上小的 eps,是为了在初始阶段(向量所有分量相同时)避免除以 0。(注:rlboard.py 中内置了一个不带 eps 的同名 probs,用于绘图;本课代码块版本用 eps 保证训练早期数值稳定,两者用途不同。)
接下来运行学习算法,共 5000 次实验,即 epochs(原文档「代码块 8」):
lpath = []
for epoch in range(5000):
# Pick initial point
m.random_start()
# Start travelling
n=0
cum_reward = 0
while True:
x,y = m.human
v = probs(Q[x,y])
a = random.choices(list(actions),weights=v)[0]
dpos = actions[a]
m.move(dpos,check_correctness=False) # 允许走出棋盘,走出即终止本局
r = reward(m)
cum_reward += r
if r==end_reward or cum_reward < -1000:
lpath.append(n)
break
alpha = np.exp(-n / 10e5)
gamma = 0.5
ai = action_idx[a]
Q[x,y,ai] = (1 - alpha) * Q[x,y,ai] + alpha * (r + gamma * Q[x+dpos[0], y+dpos[1]].max())
n+=1
这段训练循环中的关键设计可以逐条对应到前面讲的原理:
| 代码 | 含义 |
|---|---|
v = probs(Q[x,y]) + random.choices(..., weights=v) |
探索/利用平衡:按 Q 值比例采样动作,而非贪心取最大 |
m.move(dpos,check_correctness=False) |
允许走出棋盘边界,越界时 reward() 返回 end_reward=-10,episode 随之终止。这与 Board.move 的 check_correctness 参数语义一致 |
cum_reward < -1000 |
兜底保护:防止无限打转时累计惩罚过深,强制结束本局 |
alpha = np.exp(-n / 10e5) |
学习率随步数 n 指数衰减,训练后期只小幅调整 Q-Table,避免「破坏」已学到的值 |
gamma = 0.5 |
折扣因子,权衡即时奖励与未来奖励 |
Q[x,y,ai] = (1-alpha)*Q[x,y,ai] + alpha*(r + gamma*Q[s'].max()) |
贝尔曼更新,action_idx 把动作字符映射回第三维索引;Q[x+dpos[0], y+dpos[1]].max() 即 max_a' Q(s',a') |
lpath.append(n) |
记录每个 episode 的步数,用于绘制学习曲线 |
学习率 α 的写法
np.exp(-n / 10e5)中,10e5是步数尺度;由于单次 episode 通常只有几十到几百步,α 在单局内几乎不变,衰减主要发生在跨 epoch 上。
执行算法后,Q-Table 已被更新为定义每个状态下各动作「吸引力」的数值,可以用 m.plot(Q) 重新可视化——训练前后对比见前文两张 learned.png 环境图:初始时所有格子是点,训练后箭头清晰地指向苹果方向。
策略评估:贪心策略会「挂起」吗
由于 Q-Table 列出了每个状态下每个动作的「吸引力」,用它定义高效导航非常容易。最简单的情况是选择 Q-Table 值最高的动作(原文档「代码块 9」):
def qpolicy_strict(m):
x,y = m.human
v = probs(Q[x,y])
a = list(actions)[np.argmax(v)]
return a
walk(m,qpolicy_strict)
如果你多次运行上面的代码,可能会注意到它有时会「挂起」,需要按笔记本中的 STOP 按钮中断。原因是可能存在两个状态在最优 Q 值方面相互「指向」的情况,此时代理会在这两个状态之间无限期地来回移动。
这正是贪心策略的缺陷:纯利用会导致局部环路。原文档给出两个挑战任务供实践(对应笔记本中的 Exercise 部分):
任务 1: 修改
walk函数,把路径最大长度限制为一定步数(比如 100),并时不时观察上面的代码返回这个上限值——验证「挂起」确实发生了。任务 2: 修改
walk函数,使其不再回到之前去过的地方。这可以防止walk循环,但代理仍可能被「困」在无法逃脱的位置。
概率导航策略:平均路径 3-6 步
更好的导航策略是训练时用过的那种——结合利用与探索:以与 Q-Table 值成比例的概率选择每个动作。该策略仍可能让代理返回已探索过的位置,但会导致到达目标位置的平均路径非常短(print_statistics 会运行 100 次模拟,原文档「代码块 10」):
def qpolicy(m):
x,y = m.human
v = probs(Q[x,y])
a = random.choices(list(actions),weights=v)[0]
return a
print_statistics(qpolicy)
运行后应得到比随机走动小得多的平均路径长度,范围为 3-6 步——与地图中「到最近苹果平均 5-6 步」的下限几乎重合,说明学到的策略已接近最优。对比随机走动的 30-40 步,量化地证明了 Q-Learning 的有效性。
观察学习过程:路径长度曲线与超参数
观察平均路径长度在学习过程中的变化非常有价值(plt.plot(lpath) 可复现下图):
学习曲线可概括为三个阶段:
- 平均路径长度先增加。起初对环境一无所知时,很可能陷入水或狼等坏状态而早早结束;随着学到一些知识、能更长时间地探索环境,虽然还不知道苹果在哪,但存活步数变多,平均路径长度反而上升。
- 随知识积累,路径长度下降。学到足够多后,代理更容易达成目标,路径开始变短;但由于仍对探索保持开放,常常偏离最佳路径去探索新选项,使路径略长于最优。
- 长度突然增加。曲线上某个时刻长度会突然跳升,这体现了过程的随机性:我们可能在某个时点用新值「覆盖」并破坏了 Q-Table 系数。理想情况下应通过降低学习率来最小化这种情况(例如训练结尾只对小数值做调整)——这正是训练代码中
alpha = np.exp(-n / 10e5)的用途。
总体上要记住:学习过程的成功与质量显著依赖于学习率、学习率衰减、折扣因子等参数。这些参数通常称为超参数(hyperparameters),以区别于训练期间被优化的参数(parameters)(例如 Q-Table 系数本身)。寻找最佳超参数值的过程称为超参数优化(hyperparameter optimization),值得单独成专题。
进阶作业:更真实的世界
学完本课后可继续完成配套作业 A More Realistic World(英文版见 assignment.md):给世界加入能量与疲劳规则——移动消耗能量、吃苹果补充能量、在树下/草地上休息消除疲劳,且必须以足够的能量和较低的疲劳才能击败狼。作业要求修改奖励函数、扩展状态表示(如 (Board, energy, fatigue) 元组或派生 Board 类),重新训练并对比随机走动与 Q-Learning 的胜负率。由于「打赢狼」是稀有事件,可能需要大幅调整 epoch 数等超参数。该作业的参考解答见 solution/assignment-solution.ipynb。
小结与后续学习路径
本文完整复刻了 ML-For-Beginners 强化学习第一课的内容脉络,关键要点回顾:
- 环境建模:
Board类(rlboard.py)把《彼得与狼》抽象为 8x8 网格,Cell常量定义地面/水/狼/树/苹果五类格子; - 基线对照:随机走动平均 30-40 步找到苹果,且存在被狼吃死的概率;
- 延迟奖励:奖励函数只在终止状态给出实质分数(±10),中间步骤仅 -0.1;
- Q-Table + 贝尔曼方程:
Q(s,a) ← (1-α)Q(s,a) + α(r + γ max Q(s',a')),初始化为均匀的 0.25 即等价随机策略; - 探索/利用平衡:按 Q 值归一化后的概率采样动作(
probs+random.choices(weights=...)); - 效果验证:概率导航策略平均路径 3-6 步,逼近地图理论下限;
- 超参数意识:学习率衰减、折扣因子等显著影响收敛质量与曲线形态。
学完本节内容后,可以继续学习第 8 周的第二课 使用 Gym 模拟环境,把 Q-Learning 从自建的格子世界迁移到标准强化学习库 OpenAI Gym 中;第 8 周总览见 8-Reinforcement/README.md。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00


