ML-For-Beginners 强化学习实战:用 OpenAI Gym 与 Q-Learning 让智能体学会 CartPole 平衡
【免费下载链接】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-Reinforcement/2-Gym(本仓库对应译文见 translations/cs/8-Reinforcement/2-Gym/README.md)的核心内容。在上一个课时中,课程通过自建Board棋盘解决了"Petr 与狼"这一离散状态的 Q-Learning 问题;本课则把同样的算法思想迁移到连续状态环境——使用 OpenAI Gym 的 CartPole-v1 模拟器,让智能体学会在滑动小车上保持竖直杆平衡。读完本文,你将掌握 Gym 环境的基本 API(reset/step/render与观测/动作空间)、两种状态离散化方法、基于字典的 Q-Table 结构,以及从零训练并可视化一个 Q-Learning 平衡智能体的完整代码路径。
图:Peter 和他的朋友为了逃出灰狼的追击而学会了滑冰(出自本课 README)。
一、从离散棋盘到连续状态:为什么要引入 Gym
前一课解决的"找苹果躲灰狼"问题看似玩具,但棋盘类游戏(国际象棋、围棋)本质与其相同:都存在一块棋盘、明确的规则,以及离散的状态。然而现实世界中的大量问题并不满足这种条件。本课要处理的是连续状态问题——状态由一个或多个实数描述,例如杆的位置、速度、角度等。
课程以"Petr 学习滑冰保持平衡"作为叙事场景:如果 Petr 想逃过灰狼,他必须学会移动得更快,而保持平衡正是滑冰的第一步。为此我们使用其简化物理模型CartPole:
- 水平方向有一个可左右移动的滑块(cart);
- 目标是在滑块顶部保持一根竖直杆(pole)的平衡,时间越长越好。
对 CartPole 的问题设定,课程的 2-Gym README 给出了明确说明:在 CartPole 世界中,我们有一个可以向左或向右移动的水平滑块,目标是在滑块顶部平衡一根垂直杆。
在上一课里,游戏规则和状态由我们自己定义的Board类承载(见 8-Reinforcement/1-QLearning/rlboard.py 及 1-QLearning README)。本课不再自行编写环境,而是采用OpenAI Gym提供的专用模拟环境,由它负责模拟平衡杆背后的物理规律。Gym 是训练强化学习算法最流行的模拟环境库之一,既能模拟 CartPole,也能模拟 Atari 游戏等多种环境。
运行前提:本课代码建议在本地 Python 环境(如 Visual Studio Code)中运行,此时
render()会弹出新的窗口显示模拟画面;若在在线环境运行,通常需要针对渲染方式做一些调整(课程的 README 中对此有说明)。本仓库中的可运行载体为 notebook.ipynb,并提供了已填充代码的 solution/notebook.ipynb 供对照。
二、环境初始化与空间认知
2.1 安装与导入
首先安装 Gym 并导入所需库(对应 notebook 中的第 1 个代码块):
import sys !{sys.executable} -m pip install gym import gym import matplotlib.pyplot as plt import numpy as np import random代码用!{sys.executable} -m pip install gym把包安装到当前正在运行 notebook 的 Python 解释器,避免"装到了错误的 Python"这一经典问题。
2.2 初始化 CartPole-v1 环境
每个 Gym 环境都关联两类核心对象:
- 观测空间(observation space):定义了从环境获取信息的结构。CartPole 问题中,我们获得杆的位置、速度等数值;
- 动作空间(action space):定义了可执行的动作集合。CartPole 的动作空间是离散的,只有两个动作——向左与向右。
初始化并查看空间(对应第 2 个代码块,与 notebook 中第 3 个单元格一致):
env = gym.make("CartPole-v1") print(env.action_space) print(env.observation_space) print(env.action_space.sample())第 3 行env.action_space.sample()随机抽取一个动作,用于验证动作空间的定义。
2.3 随机策略下的 100 步仿真
为了直观理解环境如何运转,先运行一段 100 步的短仿真。每一步我们给定一个待执行的动作,这里是从action_space随机选取(第 3 个代码块):
env.reset() for i in range(100): env.render() env.step(env.action_space.sample()) env.close()推荐在本地 Python 上运行此代码以看到渲染窗口;屏幕上会观察到杆很快倾倒,类似下面这张动图所示的效果:
2.4 step 的返回值:观测、奖励与 done 标志
仿真过程中我们必须拿到观测才能决定如何行动。实际上step函数会返回四项内容:当前观测obs、奖励函数值rew、done标志(表示继续仿真是否还有意义)以及附加信息info(第 4 个代码块):
env.reset() done = False while not done: env.render() obs, rew, done, info = env.step(env.action_space.sample()) print(f"{obs} -> {rew}") env.close()在 notebook 输出中会看到类似这样的序列:
[ 0.03403272 -0.24301182 0.02669811 0.2895829 ] -> 1.0 [ 0.02917248 -0.04828055 0.03248977 0.00543839] -> 1.0 [ 0.02820687 0.14636075 0.03259854 -0.27681916] -> 1.0 [ 0.03113408 0.34100283 0.02706215 -0.55904489] -> 1.0 [ 0.03795414 0.53573468 0.01588125 -0.84308041] -> 1.0 ... [ 0.17299878 0.15868546 -0.20754175 -0.55975453] -> 1.0 [ 0.17617249 0.35602306 -0.21873684 -0.90998894] -> 1.0每个 step 返回的观测向量(长度为 4 的实数数组)依次包含:
- 小车的位置(cart position)
- 小车的速度(cart velocity)
- 杆的角度(pole angle)
- 杆的旋转速率(pole rotation rate)
关于奖励:可以发现每一步的奖励恒为 1。原因很简单——我们的目标就是"存活"得尽可能久,即让杆在尽量长的时间内保持合理竖直。若查看这些数值的范围(第 5 个代码块):
print(env.observation_space.low) print(env.observation_space.high)判定标准:CartPole 仿真被认为"解决",当且仅当能在连续 100 次试验中获得平均奖励 195(课程的 2-Gym README 给出了这条形式化标准)。
三、状态离散化:把连续观测变成有限状态
Q-Learning 需要一张 Q-Table,用以定义"在每个状态该做什么"。这要求状态必须是离散的——即由有限个离散值构成。因此,必须对连续观测做离散化,把它映射到有限状态集合。
3.1 两种离散化思路
课程给出了两种做法(见 2-Gym README 的 State discretization 小节):
划分区间(bins):如果我们知道某个值的取值范围,就把该区间切成若干个bins,再用"该值所属 bin 的编号"替换原值。可借助 numpy 的
digitize实现。这种做法的好处是状态规模精确已知——它完全取决于我们为每个维度选取的 bin 数量。线性缩放后取整:先把数值通过线性插值映射到某个有限区间(例如 -20~20),再四舍五入转为整数。这种做法的缺点是对状态规模的控制较弱,尤其当我们并不确知输入值的精确范围时。例如在本问题中,4 个观测值里有 2 个本身没有明确的上下界,若不加约束可能导致无限多状态。
本课最终采用第二种思路。其依据是:虽然这些值理论上无界,但实际中它们极少超出某些有限区间,因此极端状态出现的概率非常低,可以放心离散化。
3.2 量化式离散化函数 discretize
下面这个函数接收环境的观测x,产出由 4 个整数构成的元组(第 6 个代码块):
def discretize(x): return tuple((x/np.array([0.25, 0.25, 0.01, 0.1])).astype(np.int))其思想是用一组合适的尺度因子(0.25、0.25、0.01、0.1)对四个观测分量分别做粗量化:角度、速度等物理量的微小波动被折叠进同一个整数桶,从而压缩状态空间。分母越小,对该维度的分辨越细。
3.3 基于 bins 的离散化函数 discretize_bins
同时课程也演示了基于区间划分的另一种方案(第 7 个代码块):
def create_bins(i,num): return np.arange(num+1)*(i[1]-i[0])/num+i[0] print("Sample bins for interval (-5,5) with 10 bins\n",create_bins((-5,5),10)) ints = [(-5,5),(-2,2),(-0.5,0.5),(-2,2)] # intervals of values for each parameter nbins = [20,20,10,10] # number of bins for each parameter 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))这里create_bins在指定区间内生成等宽网格;ints依次给出四个观测分量各自的经验区间(小车位置与速度、杆角度与角速度),nbins给出每个分量划分的份数(20、20、10、10)。discretize_bins则调用np.digitize把每个分量化归到对应 bin。
两种方法的输出语义有区别:discretize_bins返回的是0-based 的 bin 编号,因此当某个输入分量取值在 0 附近时,它返回的是区间中部的编号(如 10);而discretize没有刻意平移输出范围,允许出现负整数,0 依然对应 0,状态值不被偏移。
3.4 在短仿真中观察离散状态
运行一小段随机仿真来打印离散化后的环境状态(第 8 个代码块):
env.reset() done = False while not done: #env.render() obs, rew, done, info = env.step(env.action_space.sample()) #print(discretize_bins(obs)) print(discretize(obs)) env.close()如果希望看到环境实时画面,取消注释以
env.render()开头的那一行;否则程序可在后台"隐形"执行,速度更快。随后的 Q-Learning 训练将统一采用这种隐形执行模式,对应源码实现可在 solution/notebook.ipynb 中逐格对照。
四、Q-Table 的数据结构选择
在上一课中,状态只是 0~8 的一对整数,因此 Q-Table 用形状为8x8x2的 numpy 张量表示非常方便。若采用 bins 离散化,状态向量规模同样已知,可以沿用张量方案:把状态表示为形状20x20x10x10x2的数组——其中最后的 2 对应动作空间维度,其余四个维度分别对应我们为观测空间每个分量选定的 bin 数量。
但有些时候观测空间的精确维度并不确定:例如discretize函数产生的状态,因为原始值无界,我们永远无法保证其落在特定范围内。因此课程改用更灵活的字典结构(第 9 个代码块):
Q = {} actions = (0,1) def qvalues(state): return [Q.get((state,a),0) for a in actions]核心设计:
- 以(state, action)二元组作为字典
Q的键; - 键对应的值即该 (状态, 动作) 组合的 Q-Table 条目;
- 配套的
qvalues(state)返回给定状态下、遍历所有可能动作(actions = (0, 1))得到的 Q 值列表; - 若条目尚不存在,
dict.get的第二个参数0保证返回默认值 0——即"未知状态下默认每个动作价值相同"。
字典方案天然适配未知边界的状态空间:只有被真实访问过的 (状态, 动作) 对才会占据内存。这一取舍正是从上一课"自建棋盘、张量 Q-Table"到本课"真实连续环境、字典 Q-Table"的递进体现。
五、Q-Learning 训练循环:让 Petr 学会平衡
5.1 超参数设定
在开始学习前先设定超参数(第 10 个代码块):
# hyperparameters alpha = 0.3 gamma = 0.9 epsilon = 0.90三个超参数的含义(课程 README 中有逐条解释):
alpha为学习率,决定每一步应以多大程度修正 Q-Table 的当前值。上一课从 1 起步、训练中逐渐调低;本课为简化起见保持恒定,读者之后可以自行实验调整。gamma为折扣因子,反映"未来奖励"相对"当前奖励"的优先程度。取值接近 1 意味着智能体更看重长期收益。epsilon为探索/利用因子,决定算法在探索与利用之间的倾向。在本算法的实现中,epsilon比例的情况下按 Q-Table 值选动作,其余情况执行随机动作,从而有机会触及此前从未见过的搜索空间区域。
就平衡问题而言:随机动作(探索)相当于朝错误方向"胡乱打了一拳",杆必须学会从这些"失误"中恢复平衡——这正是探索对学习有价值的直观原因。
5.2 两个算法改进
相比上一课的算法,课程引入两个增强(见 2-Gym README 的 Improve the algorithm 小节):
- 统计平均累积奖励:每 5000 次迭代打印一次进度,并把该窗口内的累积奖励做平均。若平均分超过 195,就可以认为问题已解决,且质量高于形式化要求。
- 追踪最优快照
Qmax与Qbest:记录训练中出现的"最大平均累积奖励",并保存与之对应的那版 Q-Table。训练中偶尔会发现平均累积结果下滑,此时我们希望保留的是历史上表现最好的模型参数,而不是最新的(可能已被破坏的)参数。
5.3 完整训练循环
把每一步的累积奖励收集进rewards向量,供后续绘图(第 11 个代码块):
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 # == do the simulation == while not done: s = discretize(obs) if random.random()<epsilon: # exploitation - chose the action according to Q-Table probabilities v = probs(np.array(qvalues(s))) a = random.choices(actions,weights=v)[0] else: # exploration - randomly chose the action 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) # == Periodically print results and calculate average 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=[]逐段拆解训练循环中的关键逻辑:
probs(v)把 Q 值向量转成合法的概率分布:先减去最小值并加上一个极小量eps=1e-4(保证数值稳定、避免除零),再做归一化。这样"Q 值更高的动作被选中的概率更大",但并非每次必选最优——保留了随机性。- 主循环设定
epoch上限为 100000 次完整仿真(episode)。每个 episode 内,当done为真(杆已倒下)即结束。 - 动作选择:
random.random() < epsilon时走利用路线——用probs(qvalues(s))构造概率,random.choices依权重采样动作;否则走探索路线——np.random.randint(env.action_space.n)均匀随机选一个动作。 - Q 值更新:执行动作后拿到新观测
ns,按 Q-Learning 更新公式Q[(s,a)] = (1 - alpha) * Q.get((s,a),0) + alpha * (rew + gamma * max(qvalues(ns)))即"新值 = 旧值 + 学习率 × (即时奖励 + 折扣 × 后继状态最优值 − 旧值)"的展开写法。 - 进度报告:每 5000 个 epoch 打印当前窗口平均累积奖励,并在超过历史最优
Qmax时用Qbest = Q保存最优 Q-Table,随后清空cum_rewards开始新一轮统计。
从运行结果能观察到的两个典型现象(课程的 README 中有原述):
- 接近目标:我们很可能已经达到或逼近"连续 100+ 次运行累积奖励 195"的目标;即便平均数值较小也不能断言失败,因为这里对 5000 次运行取平均,而形式化标准只要求连续 100 次。
- 奖励回退:有时平均奖励开始下降,说明新写入 Q-Table 的某些条目反而"污染"了已学好的值,使局面变差——这正是需要
Qbest快照机制的原因。
六、训练进度可视化
训练过程中,每个迭代的累积奖励都被写入rewards向量。直接把它按迭代序号绘图:
plt.plot(rewards)由于随机训练过程的固有特性,各轮训练(session)的长度差异极大,原始曲线几乎无法读出任何趋势。为了提取有效信息,可以计算运行平均值(running average)——例如以 100 为窗口,对实验序列做滑动平均。借助np.convolve可非常方便地实现(第 12 个代码块):
def running_average(x,window): return np.convolve(x,np.ones(window)/window,mode='valid') plt.plot(running_average(rewards,100))np.convolve用长度为window、取值全为1/window的核与信号做卷积,等价于逐点滑动求平均;mode='valid'让输出只在核完全覆盖信号的位置产生,长度相应缩短。滑动平均后的曲线能清晰显示出整体上升与局部的回退波动。
七、训练中动态调节超参数
为了让学习更稳定,一个自然改进是在训练过程中动态调整超参数(课程 README 的原述建议):
- 学习率
alpha:可从接近 1 的初值开始,随后逐步调低。随着时间推移,Q-Table 中会积累出较好的概率值,此时应做微调而非用新值整体覆盖旧值。 - 探索率
epsilon:倾向于随时间缓慢增大,从而"少探索、多利用"。合理的做法是从较低的epsilon起步,逐步提升到接近 1。
任务 1:实验不同的超参数取值,看看能否拿到更高的累积奖励——是否突破了 195?
任务 2:要形式化地解决该问题,必须在连续 100 次运行中平均奖励达到 195。请在训练中实时统计这一指标,确认已经"正式解决"问题。
八、把训练好的模型跑起来
训练完毕后,最直观的验证是让学到的模型真实运行。以下代码与训练时采用相同的动作选择策略——按 Q-Table 中的概率分布采样(第 13 个代码块):
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()打开渲染窗口后,会看到杆被持续扶正、较长时间保持平衡的画面,效果类似这张动图:
在仓库的 solution/notebook.ipynb 末尾还给出了一个进阶技巧:把渲染帧用 PIL 逐帧抓取、合成动画 GIF 保存到images/cartpole-balance.gif。其关键代码片段如下(注意它使用的是最优快照Qbest而非最终的Q):
from PIL import Image obs = env.reset() done = False i=0 ims = [] while not done: s = discretize(obs) img=env.render(mode='rgb_array') ims.append(Image.fromarray(img)) v = probs(np.array([Qbest.get((s,a),0) for a in actions])) a = random.choices(actions,weights=v)[0] obs,_,done,_ = env.step(a) i+=1 env.close() ims[0].save('images/cartpole-balance.gif',save_all=True,append_images=ims[1::2],loop=0,duration=5) print(i)要点:env.render(mode='rgb_array')返回图像帧而非弹窗,Image.fromarray把帧转为 PIL 图像,append_images=ims[1::2]隔帧采样以压缩体积,loop=0表示无限循环,duration=5为每帧毫秒数。
九、挑战与延伸思考
课程为读者预留了两组开放性挑战(见 2-Gym README 的 🚀Challenge):
任务 3:上述演示使用的是训练结束时的最终 Q-Table,它未必是最优的——还记得训练时我们把表现最好的 Q-Table 存进了
Qbest吗?请把Qbest复制回Q再跑一遍,观察平衡表现是否有差异。
任务 4:上文每一步并非选取最优动作,而是按概率分布采样。始终选择 Q 值最高的动作(用
np.argmax找到最大 Q 值对应的动作编号)是否更合理?请实现这一贪心策略并观察它是否改善了平衡。
这两项任务恰好构成一对张力:任务 4 的贪心策略对应纯利用,能充分榨取已学知识;任务 3 则检验训练中"最优快照"的价值。两者结合可以更深入理解探索与利用的权衡。
9.1 配套作业:把同一套算法迁移到 Mountain Car
环境 API 的统一设计使得迁移算法成本极低:Gym 中所有环境都提供相同的reset、step、render方法以及相同的动作空间/观测空间抽象。因此可以把 CartPole 的 Q-Learning 几乎原样搬到另一个环境。
课程的 assignment.md 要求训练Mountain Car(山地小车):一辆陷入山谷的小车,需要通过反复来回行驶积蓄动量,最终冲出山谷抓住旗子。其动作空间是 3 个离散值:
| 值 | 含义 |
|---|---|
| 0 | 向左加速 |
| 1 | 不加速 |
| 2 | 向右加速 |
观测空间只有两个分量:
| 序号 | 观测 | 最小值 | 最大值 |
|---|---|---|---|
| 0 | 小车位置 | -1.2 | 0.6 |
| 1 | 小车速度 | -0.07 | 0.07 |
奖励系统也很有特色:到达山顶旗子处(位置 = 0.5)得 0 分,否则每一步得 -1 分;当小车位置超过 0.5 或 episode 超过 200 步时终止。关键难点在于小车引擎动力不足,无法一次冲上陡坡,唯一的成功路径是前后往返积累动量。作业要求读者从 notebook.ipynb 的现有代码出发,替换新环境、改写状态离散化函数,以尽可能小的改动让算法在新问题上收敛,并通过调参优化结果。Mountain Car 环境的示意图见仓库图片 mountaincar.png。
十、小结与下一步
本课的核心收获可以概括为一条从"玩具问题"通往"真实问题"的路径:
- 学会使用OpenAI Gym抽象:
env.make建环境,reset重开一局,step返回 (观测, 奖励, done, info),render可视化; - 理解连续状态必须离散化:既可用
np.digitize分 bin 精确控制状态规模,也可用带尺度因子的量化方法(discretize)配合字典结构处理"无界观测"; - 掌握字典版 Q-Table与
qvalues()辅助函数的写法,适配未知边界的连续环境; - 跑通含探索/利用策略、Q 值迭代更新、最优快照保存(
Qmax/Qbest)的完整训练循环,并用running_average平滑曲线评估收敛; - 借助统一环境 API,把算法低成本迁移到 Mountain Car 等新任务(assignment.md)。
课程在 2-Gym README 的结论 中同时指出了边界与方向:本课成功把 Q-Learning 应用于离散与连续两类环境,但动作仍然是离散的。当动作空间也连续、或观测空间复杂到如图像级别(例如 Atari 游戏画面)时,往往需要更强大的机器学习工具——例如神经网络——来逼近值函数或策略。这是下一阶段(更高级的 AI 课程)将要探讨的主题。想继续深入学习本系列,可回到上一课 8-Reinforcement/1-QLearning 复习 Q-Learning 与 Bellman 方程的原理,或进入仓库中 8-Reinforcement 的目录结构浏览完整课程组织。
参考实现:完整的 13 个代码块均已按序嵌入 notebook.ipynb(学习用骨架,代码块以注释占位),带全部代码的成稿位于 solution/notebook.ipynb;本文对应的译文原文在 translations/cs/8-Reinforcement/2-Gym/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),仅供参考