ML-For-Beginners 强化学习实战:用「彼得与狼」网格世界从零实现 Q-Learning
【免费下载链接】ML-For-Beginners12 weeks, 26 lessons, 52 quizzes, classic Machine Learning for all项目地址: https://gitcode.com/GitHub_Trending/ml/ML-For-Beginners
本篇指南基于微软开源课程 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)在环境中探索、收集苹果、躲避狼。为简单起见,把世界抽象为一个widthxheight大小的方板,每个单元格可以是以下之一:
- 地面(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,是因为用表格(多维数组)表示它往往很方便。棋盘尺寸是widthxheight,所以可以用形状为widthxheightxlen(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 公式:
$$Q(s,a) \leftarrow (1-\alpha),Q(s,a) + \alpha,\big(r(s,a) + \gamma,\max_{a'} Q(s',a')\big)$$
其中 γ 是所谓的折扣因子(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。
【免费下载链接】ML-For-Beginners12 weeks, 26 lessons, 52 quizzes, classic Machine Learning for all项目地址: https://gitcode.com/GitHub_Trending/ml/ML-For-Beginners
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考