首页
/ ML-For-Beginners 强化学习实战:把 CartPole 的 Q-Learning 算法迁移到 Mountain Car 连续状态问题

ML-For-Beginners 强化学习实战:把 CartPole 的 Q-Learning 算法迁移到 Mountain Car 连续状态问题

2026-09-06 18:44:39作者:彭桢灵Jeremy

本指南围绕 ML-For-Beginners 仓库 8-Reinforcement/2-Gym 课程的课后作业展开:如何在只做少量代码改动的前提下,把上一课中在 CartPole 上训练成功的 Q-Learning 算法,迁移到 OpenAI Gym 的 Mountain Car(山地车) 连续状态环境中,并让算法成功学会冲出山谷、夺取旗帜。读完本文,你将掌握 Gym 环境统一 API 的使用方法、连续状态离散化函数的改写思路、Q-Table 字典结构与 ε-greedy 训练循环的复用处,以及如何通过超参数调节让算法收敛并达到评估标准。

Mountain Car 环境:小车被困谷底,目标是通过反复加速积攒动量冲上右侧山顶旗帜

OpenAI Gym 的统一环境接口:算法可迁移的前提

8-Reinforcement/2-Gym/assignment.md 开篇即点明了本作业成立的根本前提:OpenAI Gym 中的所有环境都对外提供同一套编程接口——即相同的方法 resetsteprender,以及相同的 **action space(动作空间)**与 observation space(观察空间)抽象。正是因为接口统一,同一套强化学习算法才能够在仅做极少量代码改动的情况下被复用到不同环境,这也正是强化学习算法"一次编写、多处迁移"的工程价值所在。

在上一课 8-Reinforcement/2-Gym/README.md 中,环境的初始化与运行代码为:

env = gym.make("CartPole-v1")
print(env.action_space)
print(env.observation_space)
print(env.action_space.sample())
env.reset()
for i in range(100):
    env.render()
    env.step(env.action_space.sample())
env.close()

作业要求迁移时替换的核心正是 gym.make(...) 中的环境名,以及随环境一起变化的动作空间与观察空间定义。而运行模拟的主循环结构——env.reset() 获得初始观察、循环中 env.step(action) 返回 (obs, reward, done, info)——可以原样保留。这一点可参照同一环境内 8-Reinforcement/2-Gym/solution/notebook.ipynb(其中保留了完整的 CartPole 基线训练流程与超参数设定),作为迁移时的对照实现。

Mountain Car 环境剖析:卡在山谷里的小车

Mountain Car 环境 描述了一辆被困在山谷中的小车。它的任务目标非常直观:驶出山谷、抵达山顶的旗帜。但由于地形与动力的限制,这个问题远比看上去复杂。

动作空间

每一步,智能体只能从以下三个离散动作中选择其一:

含义
0 向左加速
1 不加速
2 向右加速

观察空间

与 CartPole 的 4 维连续观察不同,Mountain Car 的观察空间只包含两个连续实数,分别刻画小车的位置与速度:

编号 观察量 最小值 最大值
0 小车位置 -1.2 0.6
1 小车速度 -0.07 0.07

观察空间维度从 CartPole 的 4 维降到 2 维,这一变化直接决定了状态离散化函数必须重写:CartPole 的 discretize 需要处理 4 个分量,而 Mountain Car 只需要把位置与速度两个分量映射到离散区间。

奖励系统

Mountain Car 的奖励设计是"棘手(tricky)"的,作业文档给出了明确规则:

  • 若智能体抵达山顶的旗帜(位置 = 0.5),获得 0 的奖励;
  • 若智能体位置小于 0.5,则每步获得 -1 的奖励。

也就是说,在整条轨迹中,除了最终到达旗帜的那一步会获得 0(相对"不惩罚")之外,其余每一步都持续施加 -1 的惩罚。这与 CartPole 中"每一步存活都 +1"的设计形成了镜像对比:CartPole 鼓励活得更久,而 Mountain Car 通过逐步惩罚迫使智能体尽快到达目标。

回合终止条件

一个 episode 在以下任一条件成立时结束:

  • 小车位置超过 0.5(成功冲上右侧山顶、抵达旗帜);
  • 回合步数超过 200(超时判负)。

由于每步 -1、最多 200 步,一个失败回合的累计回报下界是 -200;成功回合则会明显高于该水平。这意味着**"单回合累计回报"可以作为训练是否成功的直接观测指标**——这与评估标准中"少于 200 步抓到旗帜"的判据是自洽的。

核心难点:引擎马力不足,必须来回摆荡积攒动量

如果读者认为这个任务只是"向右加速然后直冲山顶",那就低估了它。作业文档专门强调了这个问题的关键技巧:

这个问题的核心难点在于,小车的引擎不足以一次性翻越整座山。因此,唯一的成功方式是来回驾驶以积攒动量(build up momentum)

这意味着一个"贪心"的策略(比如一路向右加速)注定失败。智能体必须学会先向左下坡方向行驶以获得加速度,再借助下坡带来的速度与惯性冲上右侧山坡,必要时还要在左侧山谷中来回摆荡多次,直到速度足以越过山脊。这也是 Mountain Car 常被用作"稀疏奖励 + 需要长期规划"的经典教学环境的原因——它的最优策略并非直觉上"直奔目标",而是"先退后进"

对 Q-Learning 而言,这一特性意味着 Q-Table 中真正有价值的 (state, action) 组合散布在大量看似"离目标越来越远"的状态里,学习器必须有足够的探索机会去发现它们,并对"向左下坡加速"这类反直觉动作赋予高价值。

从 CartPole 迁移 Q-Learning:五处最小改动

作业指令明确要求:以现有 notebook.ipynb 代码为起点,替换新环境、改写状态离散化函数,用尽量小的代码改动让现有算法跑起来。对照 CartPole 一课(见 8-Reinforcement/2-Gym/README.md),迁移通常只涉及以下五类改动。

1. 替换环境

env = gym.make("MountainCar-v0")          # 原来是 CartPole-v1
print(env.action_space)                    # Discrete(3)
print(env.observation_space.low)           # [-1.2  -0.07]
print(env.observation_space.high)          # [ 0.6   0.07]
print(env.action_space.sample())           # 0/1/2 之一

gym.make 替换后,env.reset()env.step()env.render() 的调用方式不变;变化的是观察向量的维度(2 维)、动作取值个数(3 个)与物理语义。

2. 改写状态离散化函数

CartPole 课程中给出的两种离散化思路依然适用,但映射的参数区间需要按 Mountain Car 的观察空间重设。课程原版的区间划分式离散化如下(CartPole 4 分量版本):

def create_bins(i, num):
    return np.arange(num + 1) * (i[1] - i[0]) / num + i[0]

ints = [(-5, 5), (-2, 2), (-0.5, 0.5), (-2, 2)]  # 各参数的取值范围区间
nbins = [20, 20, 10, 10]                          # 各参数的区间划分数
bins = [create_bins(ints[i], nbins[i]) for i in range(4)]

def discretize_bins(x):
    return tuple(np.digitize(x[i], bins[i]) for i in range(4))

对于 Mountain Car,只需把区间列表改为观察空间表格中的真实范围即可,例如划分为 20 个位置槽与 20 个速度槽,得到 20×20 个离散状态。课程还介绍了另一类基于线性缩放的离散化:

def discretize(x):
    return tuple((x / np.array([0.25, 0.25, 0.01, 0.1])).astype(np.int))

这类写法对 CartPole 成立的前提是"参数通常落在有限区间内"。对于 Mountain Car,位置与速度都由 Gym 明确给出了上下界(-1.2~0.6、-0.07~0.07),因此更稳妥的做法是先用边界值把连续量截断(clip)到区间内,再按固定步长映射为整数索引。从源码结构看,直接读取 env.observation_space.low / high 来动态生成区间,是比硬编码更可移植的写法——无论替换成哪个 Gym 环境,离散化逻辑都能自动适配。

3. 用字典维护 Q-Table(状态维度不定时的通用做法)

CartPole 课程在讲解 Q-Table 结构时指出:当状态空间精确已知时,可以用定长 numpy 张量存储(CartPole 是 20×20×10×10×2);但当离散化无法严格限制状态范围时,用字典 + (state, action) 作为键更稳健。课程代码为:

Q = {}
actions = (0, 1)          # Mountain Car 下改为 actions = (0, 1, 2)

def qvalues(state):
    return [Q.get((state, a), 0) for a in actions]

qvalues() 返回某状态下所有动作的 Q 值列表,条目不存在时默认返回 0。迁移到 Mountain Car 时,字典数据结构可以原封不动复用,只需把 actions 元组改为 3 个动作即可。

4. 保留 ε-greedy 训练主循环

CartPole 课程给出的完整训练主循环中,probs 把一组 Q 值转换为采样用的概率分布,训练时按 epsilon 决定"利用"还是"探索":

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

Qmax = 0
cum_rewards = []
rewards = []
for epoch in range(100000):
    obs = env.reset()
    done = False
    cum_reward = 0
    while not done:
        s = discretize(obs)
        if random.random() < epsilon:
            # 利用:按 Q-Table 概率分布采样动作
            v = probs(np.array(qvalues(s)))
            a = random.choices(actions, weights=v)[0]
        else:
            # 探索:随机动作
            a = np.random.randint(env.action_space.n)
        obs, rew, done, info = env.step(a)
        cum_reward += rew
        ns = discretize(obs)
        Q[(s, a)] = (1 - alpha) * Q.get((s, a), 0) + alpha * (rew + gamma * max(qvalues(ns)))
    cum_rewards.append(cum_reward)
    rewards.append(cum_reward)
    if epoch % 5000 == 0:
        print(f"{epoch}: {np.average(cum_rewards)}, alpha={alpha}, epsilon={epsilon}")
        if np.average(cum_rewards) > Qmax:
            Qmax = np.average(cum_rewards)
            Qbest = Q
        cum_rewards = []

这段代码即作业所说的"现有算法"。注意它的 Q 值更新公式与课程上一课(8-Reinforcement/1-QLearning)完全一致,是标准的时序差分更新:

Q[s, a] ← (1 - alpha) × Q[s, a] + alpha × (reward + gamma × max(Q[s', a']))

其中学习率 alpha 控制新信息对旧值的影响权重,折扣因子 gamma 权衡"当下回报"与"未来回报",epsilon 控制探索与利用的切换。CartPole 课程采用的初始超参数为 alpha = 0.3gamma = 0.9epsilon = 0.90——在 Mountain Car 上,这些值通常需要进一步调节才能让算法收敛(详见下文超参数一节)。

另外,代码中"每隔 5000 回合打印一次平均累计回报、并把历史最优 Q-Table 存入 Qbest"的设计,正是为了解决随机训练中"平均回报后期回落"的问题——即后续更新可能破坏已学到的良好策略,因此要显式保存训练中出现过的最优副本。在 Mountain Car 这种每回合回报方差大(-200 到接近 0 之间波动)的环境里,"保存最优副本"的价值会更加突出。

5. 用 env.action_space.n 保持动作随机采样通用

训练循环中探索分支使用了 env.action_space.n 来产生随机动作索引,这同样是一个"环境无关"的写法:换到 Mountain Car 后它会自动返回 3,无需任何改动即可支持三动作空间。

让算法跑通的关键:状态粒度与收敛策略

作业明确给出提示:"超参数调节很可能是让算法收敛所必需的"。这意味着把上面的代码原样改到 Mountain Car 上往往不会一次成功。基于 CartPole 课程中的方法论(见 8-Reinforcement/2-Gym/README.md 的 "Varying hyperparameters" 一节),以下三类策略值得优先尝试。

学习率 alpha:从大到小衰减

CartPole 课程指出,训练初期应让 Q-Table 快速吸收新信息,因此 alpha 可从接近 1 的值起步;随着训练推进、Q 值逐渐可靠,应逐步调小 alpha,避免新样本把已学好的条目"完全覆盖"。在 Mountain Car 上,一种常见的做法是让 alpha 随回合数线性或指数衰减(例如从 0.8~0.9 逐步降到 0.05 附近),这能让前期的探索经验快速沉淀为策略,后期保持稳定。

epsilon:从高探索走向高利用

课程建议 epsilon 整体呈上升趋势——前期保持较高随机性(充分探索来回摆荡这类反直觉动作),后期逐步逼近 1(更多依据 Q-Table 决策),才能把训练学到的策略稳定地"兑现"为高回报。CartPole 示例固定使用 epsilon = 0.90 只是出于简单;Mountain Car 环境奖励稀疏、最优路径冗长,对探索时机更加敏感,动态调节 epsilon 往往能显著加快收敛。

离散化粒度:状态数不宜过大

Q-Learning 是表格型方法,状态数直接决定需要学习的参数总量。CartPole 课程以 20×20×10×10(加上 2 个动作)获得 195 分达标成绩;Mountain Car 只有位置与速度两个维度,若同样采用 20×20 甚至更细的划分,配合三动作空间,状态-动作对数量是 20×20×3 = 1200 的量级,对表格型 Q-Learning 来说是可控的。若算法迟迟不收敛,可以先从更粗的网格(例如 10×10)起步确认训练循环本身正确,再逐步细化以逼近 200 步以内的更优解。

评估标准:明确"成功"的定义

作业文档给出了一套三档评估标准,它既是自我检验的清单,也定义了什么叫"真正解决了 Mountain Car":

标准 优秀(Exemplary) 中规中矩(Adequate) 仍需努力(Needs Improvement)
判定 Q-Learning 算法成功地从 CartPole 示例迁移,仅需极少代码改动,且能够在少于 200 步内解决"抵达旗帜"问题 从网上采纳了一个新的 Q-Learning 算法,但文档完善;或采纳了现有算法,但未达到预期结果 未能成功采纳任何算法,但已在解题方向上取得实质性进展(实现了状态离散化、Q-Table 数据结构等)

值得注意的细节是:评估标准明确把"改动尽量少"当作优秀档的必要条件,这与作业开篇"Gym 环境 API 统一、算法可低成本迁移"的论点互相呼应——本作业考察的不仅是"让 Mountain Car 学会开车",更是"能否识别强化学习流水线中哪些部分与环境无关、哪些部分必须随环境改变"的抽象能力。

训练成果的可视化与检验

当算法在 Mountain Car 上逐步收敛后,CartPole 课程中训练可视化的工具可以原样复用,帮助确认策略质量。

用滚动平均观察收敛趋势

CartPole 课程指出,由于随机训练过程本身方差极大,直接绘制 rewards 几乎看不出趋势;更有效的方式是计算运行平均(running average),可用 np.convolve 一行实现:

def running_average(x, window):
    return np.convolve(x, np.ones(window) / window, mode='valid')

plt.plot(running_average(rewards, 100))

在 Mountain Car 场景下,可将"单回合累计回报"作为纵轴:若运行平均稳定上升并趋近于一个显著高于 -200 的水平,说明智能体正在越来越多地成功抵达旗帜;若曲线长期停滞在 -200 附近,则说明离散化、奖励信号或超参数仍需调整。

把学到的策略真正"跑起来"

训练结束后,使用与训练一致的按概率采样策略驱动一次完整模拟:

obs = env.reset()
done = False
while not done:
    s = discretize(obs)
    env.render()
    v = probs(np.array(qvalues(s)))
    a = random.choices(actions, weights=v)[0]
    obs, _, done, _ = env.step(a)
env.close()

如果小车在视野中来回摆荡若干次后成功越过右侧山脊抵达旗帜,且步数小于 200,即可对照评估标准确认达到"优秀"档。

仓库导航与后续阅读

围绕本作业,你可以在当前仓库中继续对照以下资源:

作业的最终动作是在上述代码基础上完成迁移、调参并给出可复现的结果:以 notebook.ipynb 为起点,替换环境、重写针对位置与速度的离散化函数、保持 Q-Table 字典结构与时序差分更新不变,让小车学会"先向后退、再借势冲顶"的非直觉策略,最终在 200 步内抵达旗帜。

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