首页
/ ML-For-Beginners Q-Learning 实战:为「Peter and the Wolf」构建含能量与疲劳机制的更真实世界

ML-For-Beginners Q-Learning 实战:为「Peter and the Wolf」构建含能量与疲劳机制的更真实世界

2026-09-06 10:55:33作者:田桥桑Industrious

本篇技术指南基于 ML-For-Beginners 课程 8-Reinforcement 章节第 1 课的课后挑战作业 assignment.es.md(西语版,对应英文版 assignment.md)展开。该作业要求在 Q-Learning 原环境基础上引入能量(energy)与疲劳(fatigue)机制,重写奖励函数并用强化学习训练出能击败狼的最优策略,再与随机游走基线做胜率对比。读完后,你将掌握:如何扩展 RL 环境的状态定义、如何围绕「稀有成功事件」设计奖励与超参数,以及如何用仓库自带的源码级参考解答对照验证自己的实现。

1. 挑战背景:从「找到苹果」到「打败狼」

课程第 1 课 notebook.ipynb 中,Peter 在一个 width x height 的棋盘上移动,目标是找到苹果、避开狼与水域。原始版本里 Peter 几乎「不知疲倦、不会饥饿」——移动本身不消耗任何资源,奖励函数只有三档(来自 README.md):

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

而本作业要求把世界改造成「更真实」的版本,Peter 必须管理体能才能完成真正的目标——找到并杀死狼。作业给出的五条世界规则(完整继承自原文档)是:

  1. 移动即消耗:从一处移动到另一处,Peter 会损失 能量(energy) 并获得一些 疲劳(fatigue)
  2. 吃苹果回能量:Peter 可以通过吃苹果获得更多能量;
  3. 在树下或草地上休息:走进棋盘上的树(tree)或草地(grass,即绿色方块)格子,即可消除疲劳;
  4. 核心目标改变:Peter 需要找到并杀死狼;
  5. 战斗有门槛:要杀死狼,Peter 必须保持一定的能量与疲劳水平,否则战斗失败(被狼击败)。

注意规则 3 与原环境语义的变化:在原课程中 tree/草地只是可通行的休息地标记,在新规则中它承担了「重置疲劳」的机制功能;apple 从「游戏终点」变成了「补给品」;wolf 从「必须避开的死区」变成了「可以攻击的目标」。

2. 原环境源码解读:改造前必须先读懂 rlboard.py

作业明确指出:以原始 notebook.ipynb 为起点。而 Notebook 的环境逻辑全部封装在 rlboard.pyBoard 类中。改造前,先看清这个类的关键成员(以下均引自 rlboard.py):

  • 格子类型常量rlboard.py#L44-L49):

    class Cell:
        empty = 0
        water = 1
        wolf  = 2
        tree  = 3
        apple = 4
    

    这五个取值正是新规则的挂载点:tree(3)用于恢复疲劳,apple(4)用于恢复能量,wolf(2)从「失败态」变为「结算战斗的终局态」。

  • 随机生成棋盘rlboard.py#L67-L97):randomize(water_size=5, num_water=3, num_wolves=1, num_trees=5, num_apples=3, seed=None) 会先随机游走生成水域,再在空地上放置树、狼、苹果。seed 参数用于复现实验,作业中的参考解答使用 seed=13

  • 移动与合法性is_valid(pos)rlboard.py#L105-L106)判断坐标是否越界;move(dpos, check_correctness=True)rlboard.py#L111-L114)把 self.human 更新为新位置,并支持 check_correctness=False 允许走出棋盘以终止回合。

  • 内置 walk 循环rlboard.py#L174-L195):接受一个 policy 函数,随机起点出发,碰到 apple 返回步数(成功)、碰到 wolf/water 返回 -1(失败)。新规则下成功/失败条件全部改变,因此需要重写这个循环(见第 5 节),而不能直接复用 Board.walk

  • 可视化image(Q=None) / plot(Q=None)rlboard.py#L129-L169)能把 Q-Table 以「指向最优方向的箭头」形式画到棋盘上,训练前后各画一次即可直观对比策略变化。

适用前提Board.__init__ 会通过 imload 读取 images/wolf.pngimages/apple.pngimages/human.pngrlboard.py#L59-L63),并依赖 numpymatplotlibopencv-python。若从云环境打开 Notebook,务必把 rlboard.py 放到与 Notebook 同目录(README.md 中也有此提示)。

3. 状态建模:作业给出的三种实现路线

原文档在第一条 Note 中给出了三种合法的状态表示方案(这是本作业设计上的核心自由度):

方案 做法 特点
元组 把状态表示为 (Board, energy, fatigue) 三元组 改动最小,但函数签名和索引逻辑会比较啰嗦
独立状态类 定义一个 state 类携带棋盘 + 能量 + 疲劳(作业提示也可以让它继承自 Board 仓库参考解答采用的方案,语义最清晰
改造 Board 直接修改 rlboard.py 中的 Board 类,把 energy/fatigue 塞进棋盘对象 改动集中,但污染了原环境模块

仓库官方参考解答 solution/assignment-solution.ipynb 选择了第二种路线,定义如下(原样摘自该 Notebook):

class state:
    def __init__(self, board, energy=10, fatigue=0, init=True):
        self.board = board
        self.energy = energy
        self.fatigue = fatigue
        self.dead = False
        if init:
            self.board.random_start()   # 随机起点
        self.update()

    def at(self):
        return self.board.at()

    def update(self):
        if self.at() == Board.Cell.water:
            self.dead = True
            return
        if self.at() == Board.Cell.tree:
            self.fatigue = 0            # 规则 3:树下休息消除疲劳
        if self.at() == Board.Cell.apple:
            self.energy = 10            # 规则 2:吃苹果恢复能量

    def move(self, a):
        self.board.move(a)
        self.energy -= 1                # 规则 1:移动消耗能量
        self.fatigue += 1               # 规则 1:移动增加疲劳
        self.update()

    def is_winning(self):
        return self.energy > self.fatigue   # 规则 5:能量须高于疲劳才能取胜

这段代码把五条世界规则逐条映射到了具体数值:每走一步 energy -= 1, fatigue += 1;踩到树 fatigue 归零;踩到苹果 energy 重置为初始值 10;落水则置 dead;而「能否杀死狼」的判定从 is_winning() 可见,参考实现采用 energy > fatigue 作为战斗胜利条件——这就是对作业规则 5「certain levels of energy and fatigue」的一种具体化。你可以自行更换该阈值(例如要求 energy > 8 且 fatigue < 2),这会直接影响训练难度。

从源码结构看,参考解答的 Q-Table 仍然只以棋盘坐标 (x, y) 为键(第 6 节会看到),即把能量与疲劳放进了奖励信号而非状态索引中。如果你严格按 Note 中「状态包含能量与疲劳」的思路,也可以把 Q-Table 扩展为 (width, height, energy_levels, fatigue_levels, len(actions)) 的多维数组——状态空间会更准确,但维度增长会带来训练时间代价,这正是作业暗示「训练时间会更长」的另一个原因。

4. 重写奖励函数:把「打到狼」变成真正的目标

作业的核心指令是「Modify the reward function according to the rules of the game」。原奖励函数以「踩到苹果 = +10」为终点,新世界里苹果只是补给、狼才是结算点。参考解答的奖励函数(摘自 solution/assignment-solution.ipynb):

def reward(s):
    r = s.energy - s.fatigue
    if s.at() == Board.Cell.wolf:
        return 100 if s.is_winning() else -100   # 规则 4/5:战斗结算 ±100
    if s.at() == Board.Cell.water:
        return -100
    return r

它包含两层设计,值得逐点拆解:

  • 终局大额奖励:与狼相遇时,按 is_winning() 返回 +100(杀狼成功)或 -100(战斗失败),落水同样是 -100。这沿用了原课程「实质奖励只在游戏末端给出」的 RL 设定(README.md 的 Reward function 一节),要求算法自己去「记住」哪些中间步骤最终导向了胜局。
  • 过程性塑形(reward shaping):非终局状态下返回 energy - fatigue,即「体能净值」。这使 Q-Table 在训练早期就能区分「健康前进」与「透支前进」,相当于给稀疏的终局奖励补了一条连续梯度。

设计权衡:塑形奖励如果与终局奖励量级失衡,会诱导 Agent「永远躲在树下」这类捷径行为。实现时可以用「杀狼次数 / 死亡次数」的统计(第 7 节)来检验奖励设计是否偏航。

5. 重写 walk 循环:新规则的胜负结算

walk 以「碰到苹果=成功,碰到狼/水=失败」结算,新世界里结算条件完全不同。参考解答的改写(摘自 solution/assignment-solution.ipynb,保留随机游走策略 random_policy 以便对比):

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

def walk(board, policy):
    n = 0            # number of steps
    s = state(board)
    while True:
        if s.at() == Board.Cell.wolf:
            if s.is_winning():
                return n    # 成功:杀死狼,返回正步数
            else:
                return -n   # 失败:打不过狼,返回负步数
        if s.at() == Board.Cell.water:
            return 0        # 溺水:单独计数
        a = actions[policy(m)]   # 注意:参考解答此处引用了 notebook 全局棋盘 m
        s.move(a)
        n += 1

walk(m, random_policy)

返回值约定被重定义为三档:正数 = 杀狼成功(步数),负数 = 与狼交手但战败,0 = 溺水死亡。这个约定直接服务于作业要求的「compare the results of random walk with your algorithm in terms of number of games won and lost」(按胜负场数对比两种策略)。

6. 运行 Q-Learning:学习循环如何适配新世界

学习算法主体(Bellman 更新、exploit/explore 平衡)与原课程一致,差异集中在三处。参考解答的完整训练循环(摘自 solution/assignment-solution.ipynb):

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

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

lpath = []
for epoch in range(10000):                    # ① 原课程为 5000,这里翻倍
    s = state(m)                               # 每回合从随机起点、初始能量重新开始
    n = 0
    while True:
        x, y = s.board.human
        v = probs(Q[x, y])
        while True:                            # 拒绝越界动作,原地重采样
            a = random.choices(list(actions), weights=v)[0]
            dpos = actions[a]
            if s.board.is_valid(s.board.move_pos(s.board.human, dpos)):
                break
        s.move(dpos)
        r = reward(s)
        if abs(r) == 100:                      # ② 终局条件改为「打到狼或落水」
            lpath.append(n)
            break
        alpha = np.exp(-n / 3000)              # ③ 学习率按步数指数衰减
        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

与原课程循环(README.md 中的 5000 轮实现)逐条对照,适配点为:

  1. 状态来源x, y 取自 s.board.human,奖励取自已改造的 reward(s),回合生命周期由 state 对象驱动;
  2. 终止条件:原来是 r == end_reward or cum_reward < -1000,现在简化为 abs(r) == 100——因为新奖励函数里只有「战斗结算/落水」才会给出 ±100;
  3. 动作合法性:原实现允许走出棋盘来终止回合(m.move(dpos, check_correctness=False)),参考解答改为在棋盘内重采样动作直到合法。两者都是可行策略,但语义不同:前者把「走出地图」算作一次失败体验,后者保证每个被更新的 Q 值都对应真实可达转移。
  4. 超参数:epochs 从 5000 提到 10000,alpha = exp(-n/3000) 按步数衰减,gamma = 0.5 保持不变。

训练完成后执行 m.plot(Q) 即可把学到的策略画到棋盘上——参考解答的结论文字写道:溺水案例明显减少,但 Peter 仍不能总是杀死狼,邀请读者继续调节超参数(这正对应作业 Note 中「you may need to adjust the hyperparameters」的提示)。

7. 与随机游走基线对比:按胜负场数统计

作业明确要求「保留随机游走代码并在最后对比」。参考解答的统计函数把第 5 节的三档返回值翻译成胜负计数(摘自 solution/assignment-solution.ipynb):

def print_statistics(policy):
    s, w, n = 0, 0, 0
    for _ in range(100):
        z = walk(m, policy)
        if z < 0:
            w += 1        # 战败(与狼交手失败)
        elif z == 0:
            n += 1        # 溺水
        else:
            s += 1        # 杀狼成功
    print(f"Killed by wolf = {w}, won: {s} times, drown: {n} times")

print_statistics(random_policy)   # 基线
print_statistics(qpolicy)         # Q-Learning 策略

其中 qpolicy 与原课程完全相同——按 Q-Table 概率采样动作(而非 argmax 的 qpolicy_strict,后者在两个状态互相指向最优时会死循环,原课程 Notebook 中有专门说明)。评估时注意两点:

  • 每次 walk 都会从随机起点、energy=10, fatigue=0 的初始状态出发,因此 100 局统计天然覆盖不同地形难度;
  • 对比口径必须统一为「杀狼成功局数 vs 战败局数 vs 溺水库数」,这正是作业原文「in terms of the number of games won and lost」所指。

8. 超参数调优指南:稀有事件下的训练策略

作业第二条 Note 是关键经验提示:「在新世界里,游戏成功(与狼交战)是一个稀有事件,因此你可能需要调整超参数,尤其是 epoch 数,训练时间会显著变长。」 结合仓库证据,可以给出可操作的调优清单:

超参数 原课程取值 参考解答取值 调优方向与依据
epochs 5000 10000 杀狼是稀有事件,需要更多回合才能采样到足够多的 ±100 结算来修正 Q 值
学习率 α exp(-n/10e5) exp(-n/3000) 衰减更快。原课程指出路径长度曲线后期的「突增」源于低学习率下 Q-Table 被新值冲坏,加速衰减可缓解(README.md「Investigating the learning process」一节)
折扣因子 γ 0.5 0.5 控制「当前体能净值」与「未来杀狼收益」的权衡,调大 γ 会让 Agent 更看重终局奖励
战斗门槛 energy > fatigue 收紧(如 energy > 8)会使成功事件更稀有,训练更难;放宽则 Q-Table 收敛更快
能量/疲劳步长 每步 ±1 调大步长会加速能量耗尽,等价于「缩短回合」,也会影响稀有事件频率

此外可以记录每回合步数序列 lpath 并绘图(参考解答末尾 plt.plot(lpath))来观察学习曲线:若杀狼局数长期为零,优先加大 epochs 或放宽战斗门槛;若 Q 值震荡,优先调低 α 衰减速度。原课程还强调了 hyperparameter optimization 的通用概念(超参数 vs 参数的区别),可作为深入学习的入口。

9. 验收标准:三档评分细则

作业附带的 Rubric(完整继承自原文档)定义了交付物应达到的质量档位:

档位 达标要求
Exemplar(优秀) 提交了定义新世界规则的 Notebook,包含 Q-Learning 算法与必要的文字解释;Q-Learning 相比随机游走有显著提升
Adequate(合格) 提交了 Notebook,Q-Learning 已实现并优于随机游走但提升不显著;或 Notebook 文档化不足、代码组织混乱
Needs improvement(待改进) 对重新定义世界规则做了一些尝试,但 Q-Learning 算法跑不通,或奖励函数定义不完整

对照这份 Rubric,一份「优秀」级解答的自检清单是:① 五条世界规则是否全部落地(对照第 3 节 state 类的逐行映射);② 奖励函数是否同时定义了终局结算与过程奖励;③ 是否保留了 random_policy 基线并输出同口径的胜负统计;④ 是否有文字解释训练结果与超参数选择。

10. 仓库中的配套资源与延伸

总结:这份作业的价值在于完整走了一遍「环境改造 → 状态扩展 → 奖励重设计 → 学习循环适配 → 基线对比 → 超参数调优」的 RL 实战闭环。五条世界规则不是孤立设定,而共同制造了一个成功事件稀有、过程信号必须自行塑形的难训练场景——这正是真实业务中稀疏奖励问题的缩影。以 solution/assignment-solution.ipynb 为对照,你可以逐段验证自己的 state 类、reward 函数与训练循环是否符合 Rubric 的「优秀」档位要求。

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