首页
/ ML-For-Beginners 强化学习实战:用「彼得与狼」网格世界从零实现 Q-Learning

ML-For-Beginners 强化学习实战:用「彼得与狼」网格世界从零实现 Q-Learning

2026-09-06 14:44:46作者:范垣楠Rhoda

本篇指南基于微软开源课程 ML-For-Beginners 第 8 周强化学习模块的第一课(8-Reinforcement/1-QLearning 的中文版 translations/README.zh-cn.md)。将以俄罗斯音乐童话《彼得与狼》为原型搭建一个网格世界环境,完整走通「环境建模 → 随机行走基线 → 奖励函数 → Q-Table 与贝尔曼方程 → 探索/利用平衡 → 策略评估」的 Q-Learning 全流程。读完后,你将能独立实现一个表格型强化学习智能体,理解 Q-Table 的含义、学习率与折扣因子等超参数的作用,并知道如何通过统计路径长度来验证学习效果。

彼得与狼的网格世界环境:8x8 棋盘上分布着水、狼、苹果与树

强化学习的三个核心概念:代理、状态与动作

强化学习(Reinforcement Learning, RL)围绕三个基本概念展开:代理(agent)状态(state)每个状态下的一组动作(actions)。代理在某个指定状态下执行一个动作,就会得到一个奖励(reward)。

原文档用超级马里奥的游戏来建立直觉:你是马里奥,站在悬崖边上,头顶有一枚硬币。「你是马里奥、在游戏关卡中、处于某个特定位置」——这就是一个状态。向右移动一步(一个动作)会坠下悬崖,得到一个低分;按下跳跃按钮则能活下来并得分,这是一个积极结果,应给予正向分数。借助强化学习加一个模拟器(游戏),就能学会「如何玩游戏以最大化奖励」——既活下来,又尽可能多地得分。

RL 与监督学习、无监督学习并列为机器学习的基本范式。与分类/回归不同,RL 通常没有标注数据集,而是要让计算机反复「玩」很多次、观察结果来学习行为。这也是为什么本课只需要两样东西:一个可重复模拟的环境(定义规则、状态和动作),以及一个告诉我们表现好坏的奖励函数

先决条件和运行环境

本课用 Python 做实验,你需要能在本地或云上运行 Jupyter Notebook:

  • 打开本课的教学笔记本,跟随本文逐节编译、运行即可;
  • 注意:如果你是从云端打开代码,还需要额外获取笔记本代码中用到的 rlboard.py 文件,并将其放到与笔记本相同的目录中(笔记本第 1 个代码块通过 from rlboard import * 引用它)。

从源码结构看,rlboard.py 是一个完整的环境模拟器,依赖 matplotlibnumpycv2 三个库(见 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=4Board.Cell),后文 m.at() 的返回值就是这些整型常量;
  • randomize(seed=13) 负责随机撒布地图元素,其默认参数为 water_size=5, num_water=3, num_wolves=1, num_trees=5, num_apples=3randomize 方法)。也就是说,一个 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-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)(1α)Q(s,a)+α(r(s,a)+γaQ(s,a))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;
  • 多次重复模拟:
    1. 从随机位置开始;
    2. 重复:
      1. 在状态 s 选择一个动作 a
      2. 通过移动到新状态 s' 来执行动作;
      3. 如果遇到游戏结束情况,或总奖励太小——退出模拟;
      4. 计算新状态下的奖励 r
      5. 根据 Bellman 方程更新 Q 函数:Q(s,a)(1-α)Q(s,a)+α(r+γ maxa'Q(s',a'))
      6. ss'
      7. 更新总奖励并减小 α。

在上面的算法中,步骤 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.movecheck_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) 可复现下图):

学习过程中每个 episode 路径长度的变化曲线:先上升、再下降、偶发突变

学习曲线可概括为三个阶段:

  • 平均路径长度先增加。起初对环境一无所知时,很可能陷入水或狼等坏状态而早早结束;随着学到一些知识、能更长时间地探索环境,虽然还不知道苹果在哪,但存活步数变多,平均路径长度反而上升。
  • 随知识积累,路径长度下降。学到足够多后,代理更容易达成目标,路径开始变短;但由于仍对探索保持开放,常常偏离最佳路径去探索新选项,使路径略长于最优。
  • 长度突然增加。曲线上某个时刻长度会突然跳升,这体现了过程的随机性:我们可能在某个时点用新值「覆盖」并破坏了 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 强化学习第一课的内容脉络,关键要点回顾:

  1. 环境建模Board 类(rlboard.py)把《彼得与狼》抽象为 8x8 网格,Cell 常量定义地面/水/狼/树/苹果五类格子;
  2. 基线对照:随机走动平均 30-40 步找到苹果,且存在被狼吃死的概率;
  3. 延迟奖励:奖励函数只在终止状态给出实质分数(±10),中间步骤仅 -0.1;
  4. Q-Table + 贝尔曼方程Q(s,a) ← (1-α)Q(s,a) + α(r + γ max Q(s',a')),初始化为均匀的 0.25 即等价随机策略;
  5. 探索/利用平衡:按 Q 值归一化后的概率采样动作(probs + random.choices(weights=...));
  6. 效果验证:概率导航策略平均路径 3-6 步,逼近地图理论下限;
  7. 超参数意识:学习率衰减、折扣因子等显著影响收敛质量与曲线形态。

学完本节内容后,可以继续学习第 8 周的第二课 使用 Gym 模拟环境,把 Q-Learning 从自建的格子世界迁移到标准强化学习库 OpenAI Gym 中;第 8 周总览见 8-Reinforcement/README.md

登录后查看全文
热门项目推荐
相关项目推荐