首页
/ ML-For-Beginners 强化学习实战:在“彼得与狼”棋盘环境中从零实现 Q-Learning

ML-For-Beginners 强化学习实战:在“彼得与狼”棋盘环境中从零实现 Q-Learning

2026-09-06 12:11:43作者:蔡丛锟

本文以 ML-For-Beginners 仓库 8-Reinforcement 模块的第一课“Introduction to Reinforcement Learning and Q-Learning”为主线(骨架取自该课的意大利语译本 README.it.md,英文原版见 README.md),完整还原从随机漫游基线、奖励函数设计、Q 表与贝尔曼方程,到 5000 轮训练、策略检验与学习过程分析的全部代码与参数。读完后你能够独立运行 配套 notebook 复现 Q-Learning,并结合 rlboard.py 的源码理解棋盘环境的实现细节。

彼得与狼强化学习环境的 8x8 棋盘:蓝格为水、绿格为树、狼与苹果贴图、人物站在空白格上

训练收敛后的 Q 表可视化:每个空白格中心绘出指向最优移动方向的向量,整体指向苹果并绕开狼与水

训练过程中每个回合步数的曲线:先上升、后下降,并伴随因学习率过大导致的突然跳变

教程定位:本课在 ML-For-Beginners 中的位置

本课是 ML-For-Beginners 九周课程中“Reinforcement”阶段的第一课,核心目标是让读者在一个极简的棋盘环境里亲手实现一个完整的 Q-Learning 训练循环。强化学习涉及三个核心概念:代理(agent)、若干状态(states),以及每个状态下的一组动作(actions)。在某个状态执行一个动作后,代理会收到一个奖励(reward)

原课程文档用一个 Super Mario 的比喻来解释这三个概念:你是马里奥,站在关卡中一处悬崖边,头顶有一枚金币。“是马里奥、在某个关卡、处于某个具体位置”——这就是状态;向右走一步(一个动作)会掉下悬崖,得到一个很低的分数;而按跳跃键则可以得分并存活,这是一个正向结果,应得到正的分数。借助强化学习加一个模拟器(游戏),就可以学会“活着并尽可能多得分”的最优玩法。

课程场景取材于俄罗斯作曲家 Sergei Prokofiev 的音乐童话《彼得与狼》(意大利语译本称 Pierino)。本课用强化学习让彼得在环境中探索、收集苹果、并避开狼。按文档定义,强化学习(Reinforcement Learning, RL)是一种通过大量实验来学习代理在环境中最优行为的学习技术;代理在环境中必须有一个由奖励函数定义的目标

一、环境:彼得与狼的棋盘世界

为简化问题,课程将彼得的世界建模为一个 width x height 的方形棋盘。棋盘上每个格子只能是以下之一:

  • 地面(ground):彼得和其他生物可以行走;
  • 水(water):显然不能走;
  • 树或草(tree / grass):可以休息的地方;
  • 苹果(apple):彼得找到就可以充饥的目标;
  • 狼(wolf):危险,应当避开。

环境的全部逻辑封装在独立的 Python 模块 rlboard.py 中。创建并随机化一个 8x8 棋盘的代码如下(原文档代码块 1):

from rlboard import *

width, height = 8,8
m = Board(width,height)
m.randomize(seed=13)
m.plot()

运行后应打印出与上节首图类似的棋盘图像。从源码看,Board 类把五种格子编码为内部枚举(见 rlboard.py):empty=0、water=1、wolf=2、tree=3、apple=4randomize() 的默认参数为 water_size=5, num_water=3, num_wolves=1, num_trees=5, num_apples=3rlboard.py),即默认生成 3 处水域(每处由最多 5 格水组成的随机游走片段)、1 只狼、5 棵树和 3 个苹果;传入 seed=13 可以复现完全相同的棋盘,这也是课程能给出确定示例图的原因。

运行前提:本课代码在 Jupyter Notebook 中运行(本地或云端均可)。如果从云端打开 notebook,还必须把 rlboard.py 一并下载到与 notebook 相同的目录,因为 notebook 中的代码直接 from rlboard import *。从 rlboard.py 的 import 语句(rlboard.py)可以确认环境依赖:numpymatplotlibopencv-python(cv2)、randommath。渲染棋盘时,狼、苹果、人物贴图分别来自 images/wolf.pngimages/apple.pngimages/human.png(见 rlboard.pyimload 加载逻辑)。

二、动作空间与策略

在本例中,彼得的目标是找到苹果,同时避开狼和其他障碍;为此他只需不停走动,直到碰到苹果。因此在任意位置,他都能从四个动作中选择一个:上、下、左、右。课程用字典把动作映射为对应的坐标增量(原文档代码块 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()) }

例如向右移动(R)对应坐标增量 (1,0)action_idx 用于在 Q 表第三维中定位每个动作的索引。

汇总一下,本场景的策略与目标是:

  • 策略(policy):代理(彼得)的策略由所谓的 policy 定义——一个“在任意给定状态下返回应执行动作”的函数。这里问题的状态由整个棋盘(含玩家当前位置)表示。
  • 目标:强化学习的目标是最终学会一个好的 policy,从而高效地解决问题。作为基线(baseline),课程先考察最简单的策略——随机漫游(random walk)

三、随机漫游基线

首先实现随机漫游策略:在允许的動作中随机选择下一个动作,直到碰到苹果为止(原文档代码块 3):

def random_policy(m):
    return random.choice(list(actions))

def walk(m,policy,start_position=None):
    n = 0 # 步数
    # 设置初始位置
    if start_position:
        m.human = start_position
    else:
        m.random_start()
    while True:
        if m.at() == Board.Cell.apple:
            return n # 成功!
        if m.at() in [Board.Cell.wolf, Board.Cell.water]:
            return -1 # 被狼吃掉或淹死
        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) # 执行实际移动
                break
        n+=1

walk(m,random_policy)

walk 的调用应返回对应路径的长度(即步数),该值在每次运行之间会有所不同。从源码结构看,walk 中用到的 m.at()m.is_valid()m.move_pos()m.move()m.random_start() 全部由 Board 类提供(rlboard.py):move(dpos, check_correctness=True) 默认会把越界移动拦截掉,而训练循环里会用 check_correctness=False 放行越界以终止回合。另外,Board 类自身也带有一个功能相近的 walk() 方法(rlboard.py),支持把轨迹帧存盘为图片序列(save_to 参数),课程里的 walk 是其加了 start_position 参数的教学重写版。

接着把漫游实验跑 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 步,这个代价是相当高的——这正是后续要用 Q-Learning 去压缩的量。

四、奖励函数

要让策略更“聪明”,需要判断哪些移动比别的“更好”,为此必须先定义目标。目标可以表达为一个奖励函数:它为每个状态返回一个分数,数值越大表示状态越好(原文档代码块 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

三个常数的含义:

常量 取值 触发条件
move_reward -0.1 普通移动一格,轻微惩罚以抑制无限徘徊
goal_reward 10 到达苹果,回合目标达成
end_reward -10 掉入水中、撞上狼或走出棋盘(m.is_valid(pos) 为假)

奖励函数有一个值得注意的特性:在大多数情况下,实质性的奖励只在游戏结束时才发放。这意味着算法必须想办法“记住”那些最终导向正奖励的“好”步骤并提升其权重;同理,所有导向坏结果的移动都应被抑制。这种“延迟奖励”正是需要贝尔曼方程的原因。

五、Q 表:策略的数据结构

本课使用的算法叫 Q-Learning。在该算法中,policy 由一个称为 Q 表(Q-Table) 的函数(或数据结构)定义,它记录每个状态下每个动作的“好程度”(goodness)。

之所以叫 Q 表,是因为把它表示成一张表或多维数组往往更方便。由于棋盘尺寸为 width x height,Q 表可以用形状为 width x height x len(actions) 的 numpy 数组表示(原文档代码块 6):

Q = np.ones((width,height,len(actions)),dtype=np.float)*1.0/len(actions)

所有 Q 表值初始化为同一个数值,本例中是 1/len(actions) = 0.25。这正对应“随机漫游”策略——每个状态下所有移动同样好。可以把 Q 表传给 plot 函数,在棋盘上可视化这张表:m.plot(Q)。此时每个格子中心会画一根指示“偏好移动方向”的箭头;由于四个方向权重相等,画面上显示的是一个点。从源码看,这个绘制逻辑在 Board.image() 中:对每个空白格调用 probs(Q[x,y]) 归一化后,按四个方向向量加权求和出净方向再画线(rlboard.py)。

接下来就是运行模拟、探索环境,学习出一组更好的 Q 表取值分布,从而更快地找到通往苹果的路径。

六、Q-Learning 的本质:贝尔曼方程

一旦开始移动,每个动作都有对应奖励,理论上可以只按“即时奖励最高”来选下一步。然而在大多数状态下,单步移动并不能达成“到达苹果”的目标,因此无法立刻判断哪个方向更好。

要记住的是:重要的不是即时结果,而是模拟结束时获得的最终结果。

为了考虑这种延迟奖励,需要借助**动态规划(dynamic programming)**的思想,以递归方式思考问题:

  • 假设当前处于状态 s,想转移到下一个状态 s'
  • 这样做会得到即时奖励 r(s,a)(由奖励函数定义)加上若干未来奖励;
  • 若 Q 表正确反映了每个动作的“吸引力”,那么在 s' 处会选取得分最高的动作,即取 maxa'Q(s',a')(对所有可能动作 a' 求最大值)。

由此得到计算状态 s、动作 a 处 Q 表值的贝尔曼公式

Q(s,a) ← (1-α)·Q(s,a) + α·( r(s,a) + γ · max_{a'} Q(s',a') )

其中 γ 是折扣因子(discount factor),它决定你有多大程度上偏好当前奖励而非未来奖励(以及反之)。

七、学习算法伪代码

基于上述方程,学习算法的伪代码为:

  1. 用所有状态、动作相等的数值初始化 Q 表 Q;
  2. 设置学习率 α ← 1;
  3. 重复很多次模拟:
    1. 从随机位置开始;
    2. 重复:
      1. 在状态 s 选择一个动作 a
      2. 执行动作,转移到新状态 s'
      3. 若遇到终局条件或累计奖励过小,则退出本次模拟;
      4. 计算新状态的奖励 r
      5. 按贝尔曼方程更新 Q 函数:Q(s,a)(1-α)Q(s,a)+α(r+γ maxa'Q(s',a'))
      6. ss'
      7. 更新累计奖励并降低 α。

探索与利用的平衡

上述算法没有明确说明第 2.1 步具体如何选动作:

  • 随机选动作,就是在随机**探索(explore)**环境——大概率会经常“死掉”,也会跑到平时不会去的区域;
  • 若只**利用(exploit)**已知的 Q 表值、永远选当前状态下的最高 Q 动作,则会阻止对其他状态的探索,很可能找不到最优解。

因此最佳做法是在探索与利用之间取得平衡:以与 Q 表取值成比例的概率来选取动作。训练初期 Q 表各值相等时,这等价于随机选择;随着对环境了解加深,代理会越来越倾向于走最优路线,同时仍偶尔选择未探索过的路径。

八、Python 实现:概率化与训练循环

8.1 probs():把 Q 值转成动作概率

实现算法前,需要一个函数把 Q 表中任意数值转换为一组动作概率向量(原文档代码块 7):

def probs(v,eps=1e-4):
    v = v-v.min()+eps
    v = v/v.sum()
    return v

在原向量上加上少量 eps 是为了避免初始情形下(向量各分量完全相同,减最小值后全为 0)出现除以 0 的问题。注意 rlboard.py 里也内置了一个 probs()rlboard.py),它用 v.sum()>0 判断代替 eps,两者用途一致。

8.2 跑 5000 轮(epoch)训练

把学习算法跑 5000 次实验,每次实验称为一个 epoch(原文档代码块 8):

for epoch in range(5000):

    # 选择起始点
    m.random_start()

    # 开始移动
    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

逐行对应关系值得强调:

  • m.random_start() 对应伪代码“从随机位置开始”——源码里它会随机挑选一个 empty 格子作为起点(rlboard.py);
  • random.choices(list(actions),weights=v) 即第 2.1 步“按 Q 值成比例概率选动作”,实现了探索/利用的平衡;
  • m.move(dpos,check_correctness=False) 对应“执行动作转移到 s'”,并故意放行越界移动,使其触发 reward 中的 end_reward 而结束回合;
  • if r==end_reward or cum_reward < -1000 对应“遇到终局条件或总奖励过小则退出模拟”;
  • alpha = np.exp(-n / 10e5) 是随步数 n 指数衰减的学习率,对应“降低 α”;gamma = 0.5 即折扣因子 γ;
  • 最后一行 Q[x,y,ai] = (1-alpha)*Q[x,y,ai] + alpha*(r + gamma*Q[x+dpos[0], y+dpos[1]].max()) 正是贝尔曼更新式,其中 Q[...].max()maxa' Q(s',a'),在 numpy 上对第三维取最大。

仓库内参考实现solution/notebook.ipynb 中的完整可运行版本把训练轮数提升到 10000,并把学习率衰减改为 alpha = np.exp(-n / 3000),同时每轮打印进度(clear_output)。从源码结构看,两种参数取值的差异在于学习率衰减速率:原文档的 10e5 衰减非常缓慢(回合步数 n 通常只有几十到几百),而解答 notebook 的 3000 让 α 在回合内更明显地收敛到较小值,使训练后期对 Q 表只作小幅调整。复现时两组参数都可以尝试。

训练完成后,Q 表会被更新为定义每个状态下各动作“吸引力”的数值。用 m.plot(Q) 可视化时,每个格子会画一个指向期望移动方向的小向量(为简化用圆点代替箭头)——这就是正文开头的第三张图:可以看到箭头整体“汇聚”向苹果、并绕开水域与狼。

九、策略检验与导航

9.1 严格策略 qpolicy_strict

既然 Q 表列出了每个状态下每个动作的“吸引力”,用它定义高效导航就很简单。最简单的做法是总是选 Q 值最高的动作(原文档代码块 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)

已知现象:多次运行上面代码时,你可能会注意到它有时“卡住”,需要在 notebook 里按 STOP 中断。原因是可能存在两个状态在最优 Q 值意义上“互相指向”的情况,代理就会在这两个状态之间无限来回移动。

9.2 两个挑战任务

原文档给出两个动手任务:

  • 任务 1:修改 walk 函数,把最大路径长度限制在一定步数内(比如 100),观察上面的代码时不时返回这个上限值;
  • 任务 2:修改 walk 函数,使其不再回到之前已经到过的地方。这能防止 walk 死循环,但代理仍可能被困在无法逃脱的位置。

9.3 概率化导航策略 qpolicy

更好的导航策略是训练时所用的那种:结合利用与探索,以与 Q 表取值成比例的概率选择每个动作。该策略仍可能让代理回到已探索过的位置,但如原文档所示,它得到的平均路径非常短(注意 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 步之间——与随机漫游基线的 30–40 步形成鲜明对比。

十、观察学习过程:路径长度曲线

如前所述,学习过程是在探索与利用已有知识之间的平衡。学习结果(帮助代理找到通往目标短路径的能力)显然改善了,但平均路径长度在训练过程中的行为也值得观察(见正文开头第三张图,原文档展示的是 lpath 随回合的曲线)。原文档归纳出三点观察:

  1. 平均路径长度先上升。一开始代理对环境一无所知,容易被困在坏状态(水或狼)中;随着学到更多知识并开始利用,它能在环境中探索得更久,但此时还不太清楚苹果在哪里。
  2. 学得多之后路径长度下降。一旦学得足够,代理更容易达成目标,路径长度开始下降。但由于仍保持探索倾向,它会时常偏离最佳路径去尝试新选项,使路径长于最优。
  3. 长度会突然跳升。曲线在某个点上出现陡增,这体现了过程的随机性:有时会用新值“污染”(覆盖)Q 表系数。理想情况下应通过调低学习率来缓解——例如训练后期只让 Q 表值做小幅调整(对应 alpha = np.exp(-n / 10e5) 这类衰减设计)。

从源码结构看,lpath 列表只在 r == end_rewardcum_reward < -1000append(n)break,即记录的是以死亡/越界/累计奖励过低结束的那些回合的步数;到达苹果的回合不会进入该列表。因此这条曲线刻画的是“失败/长回合”的步数演化,解读曲线时应结合这一点。

最后,原文档强调:学习过程的成败与质量显著依赖于学习率、学习率衰减、折扣因子等参数。它们通常被称为超参数(hyperparameters),以区别于训练过程中被优化的参数(parameters)(如 Q 表系数)。寻找超参数最优值的过程叫超参数优化(hyperparameter optimization),值得单独成章讨论。

十一、扩展练习:一个更真实的世界

本课的课后作业是 assignment.it.md(英文原版 assignment.md),要求把世界改得更真实:

  1. 彼得每移动一格都会损失能量并累积疲劳
  2. 吃苹果可以获得能量;
  3. 在树下或草地上(绿色格子)休息可以消除疲劳;
  4. 彼得需要找到并击败狼;
  5. 击败狼需要满足一定的能量/疲劳水平,否则战斗失败。

作业要求以 notebook.ipynb 为起点,按新规则改写奖励函数,运行强化学习算法学习获胜的最优策略,并用“胜/负局数”把 Q-Learning 与随机漫游做对比。两个值得注意的实现提示:新世界的状态比单纯位置更复杂,可以用元组 (Board, energy, fatigue) 表示状态,也可以定义专门的状态类或修改 rlboard.py 中的 Board 类;由于“战胜狼”是低概率事件,训练时间会明显变长,可能需要调整超参数(尤其是 epoch 数量)。该作业配有评分量规(rubric):优秀标准是提交含新规则定义、Q-Learning 实现与文字说明的 notebook,且 Q-Learning 能显著优于随机漫游。

十二、本课涉及的仓库资源

资源 相对路径 说明
意大利语课程文档(本文骨架) README.it.md 与英文版内容一致,附课前/课后测验入口
英文原版课程文档 README.md 含 10 个代码块的完整讲解
教学 notebook(代码块占位) notebook.ipynb 学习者按文档逐块填写
参考解答 notebook solution/notebook.ipynb 可直接运行的完整实现(10000 epochs 版本)
棋盘环境模块 rlboard.py Board 类、格子枚举、随机化与渲染
课后作业 assignment.md / assignment.it.md “更真实的世界”扩展任务与评分量规
环境示意图 environment.pnglearned.pnglpathlen1.png 环境、收敛后 Q 表、学习曲线

整套课程以“随机漫游 → 奖励函数 → Q 表 → 贝尔曼更新 → 探索/利用平衡 → 收敛检验”为主线,恰好覆盖了一个表格型 Q-Learning 从建模到评估的最小完整闭环;后续的第二课(Gym 环境)可以在此基础上引入通用 RL 工具链继续深入。

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