1. 从直觉到代码:为什么倒立摆是强化学习的“Hello World”
如果你对强化学习感兴趣,想找一个项目来练手,那么“倒立摆”几乎是一个绕不开的经典案例。它不像下围棋的AlphaGo那样遥不可及,也不像简单的“走迷宫”那样过于基础。倒立摆问题,恰恰卡在一个非常精妙的位置:它的状态空间连续且维度适中,动作空间离散,物理模型清晰,但控制逻辑却并不直观。这就像一个完美的沙盒,让你能亲手验证强化学习那些听起来有点“玄乎”的理论——比如智能体如何通过“试错”学会一个人类工程师需要复杂数学推导才能设计的控制器。
我第一次接触这个项目时,心里也犯嘀咕:一个连PID控制器都需要仔细调参才能稳定的系统,真的能靠一个“傻乎乎”的、只知道根据表格(Q表)选动作的Q-learning算法学会吗?答案是肯定的,而且过程极具启发性。它完美地展示了强化学习的核心范式:智能体(Agent)通过与环境(Environment)交互,根据环境反馈的奖励(Reward)来调整自己的策略(Policy),最终学会完成特定任务。在倒立摆任务中,任务就是让摆杆保持竖直向上。我们将使用最经典的Q-learning算法,这是一种无模型的、基于值函数的时序差分学习方法。它不依赖于环境的动力学模型,而是通过不断地交互来估计在某个状态(State)下采取某个动作(Action)的长期价值(Q值),并据此做出决策。
这个实战项目的目标非常明确:我们不依赖任何现成的控制理论,仅用Q-learning算法,从零开始训练一个智能体,让它学会控制小车(或滑轨)左右移动,以保持顶部的摆杆不倒。完成这个项目,你将彻底理解状态离散化、奖励函数设计、探索与利用的权衡这些核心概念,并拥有一个可以随时运行和修改的代码框架。下面,我们就从最核心的环境搭建开始。
2. 环境构建:将物理世界转化为算法可理解的“游戏”
强化学习智能体生活在它自己的“世界”里,这个世界就是环境。我们的第一步,是创建一个标准化的Gymnasium环境。Gymnasium是OpenAI Gym的维护分支,提供了大量标准化的环境接口,极大方便了算法的测试与比较。虽然Gymnasium内置了经典的CartPole-v1环境,但为了彻底理解其内部机制,我强烈建议我们从零开始构建一个简化版本。这不仅有助于调试,更能让你深刻理解状态、动作和奖励是如何定义的。
2.1 定义环境动力学:倒立摆的物理心脏
一个倒立摆系统通常由几个关键物理量描述:小车位置x、小车速度v、摆杆角度theta(从竖直向上位置开始计算)、摆杆角速度theta_dot。这些连续变量共同构成了我们的状态空间。环境的动力学由微分方程控制,这里我们采用经典的近似动力学模型。
在每一时间步,智能体对环境施加一个力(动作),这个力会改变小车的加速度,进而通过摆杆的铰链影响摆杆的角加速度。其核心微分方程可以简化表述为:
摆杆角加速度 = (重力 * sin(角度) + cos(角度) * (-外力 - 摆杆质量*长度*角速度^2 * sin(角度)) / 总质量) / (长度 * (4/3 - (摆杆质量 * cos(角度)^2) / 总质量)) 小车加速度 = (外力 + 摆杆质量 * 长度 * (角速度^2 * sin(角度) - 角加速度 * cos(角度))) / 总质量其中,总质量 = 小车质量 + 摆杆质量。外力即智能体选择的动作(如向左-10N,向右+10N)。在实际编程中,我们会使用欧拉积分法来更新状态:
新角速度 = 当前角速度 + 角加速度 * 时间步长(dt) 新角度 = 当前角度 + 新角速度 * dt 新小车速度 = 当前小车速度 + 小车加速度 * dt 新小车位置 = 当前小车位置 + 新小车速度 * dt这个dt就是仿真步长,通常设为0.02秒(即50Hz),与CartPole-v1环境保持一致。这里有一个关键细节:在计算角度时,我们通常将其归一化到[-π, π]区间,这能避免角度值无限增长带来的数值问题,使用(angle + np.pi) % (2 * np.pi) - np.pi即可实现。
注意:对于首次实现,我建议直接使用
gymnasium.make(‘CartPole-v1’)来获取环境。它的动力学经过充分验证,且被广泛用作基准。我们自制环境的主要目的是教学和深度定制。在本文的后续部分,我们将以标准CartPole-v1环境为例进行讲解,其状态是一个4维向量:[车位置, 车速, 杆角度, 杆角速度]。
2.2 设计奖励函数与终止条件:告诉智能体什么是“好”
奖励函数是强化学习任务的“指挥棒”。一个设计不当的奖励函数会导致智能体学到奇怪甚至完全错误的行为。对于倒立摆,我们的目标是让杆子直立,同时小车尽量不要偏离中心太远。
一个直观且有效的奖励设计是:只要杆子没有倒下,每一步都给予+1的奖励。这就是CartPole-v1的标准设置。杆子“倒下”的定义就是终止条件,通常包括:
- 角度阈值:摆杆角度绝对值超过某个值(如12度或15度,即约0.2弧度)。
- 位置阈值:小车位置绝对值超过轨道长度的一半(如2.4个单位)。
- 步数限制:为了防止智能体在局部最优中无限循环,通常设置一个最大步数(如500步)。达到最大步数视为成功完成一个回合(episode),并触发终止,但这不是因为“失败”。
奖励函数的设计是门艺术。除了简单的每一步+1,你也可以尝试加入一些形奖励来引导学习:
- 对角度绝对值进行负奖励:
reward = 1.0 - abs(theta) / angle_threshold。这样杆子越垂直,奖励越高。 - 对小车的偏离进行轻微惩罚:
reward = 1.0 - 0.1 * abs(x),鼓励小车待在中心附近。 但请注意,引入形奖励可能会让问题变得更复杂,初期建议使用最简单的每步+1奖励,它已被证明是有效的。
2.3 实现标准Gymnasium接口:让智能体能“玩”起来
为了让我们的环境能与各种强化学习算法库兼容,必须实现Gymnasium的核心接口。主要需要实现三个方法:
reset(seed=None): 初始化环境,将状态重置为随机初始值(通常在小角度范围内),并返回初始状态。step(action): 接收智能体的动作(0或1,代表向左或向右施力),根据动力学方程计算下一状态,判断是否终止,计算奖励,并返回(next_state, reward, terminated, truncated, info)这五个值。terminated表示因失败(杆倒/车出界)而终止,truncated表示因步数限制而终止。render(): 可选方法,用于可视化当前状态,可以用matplotlib简单绘制小车和摆杆。
完成这些,一个功能完整的环境就搭建好了。智能体将通过调用env.step(action)来与之交互,获得反馈,从而学习。
3. Q-learning算法核心:构建智能体的“经验表格”
有了环境,我们来看看智能体的大脑——Q-learning算法。Q-learning是一种表格型方法,其核心是维护一张Q表。这张表的行代表所有可能的状态(离散化后),列代表所有可能的动作。Q值Q(s, a)代表了在状态s下采取动作a,并且此后一直遵循最优策略所能获得的期望累积奖励。
3.1 状态离散化:将连续世界装入离散表格
CartPole-v1的状态是4维连续空间,而Q表需要离散的索引。因此,我们必须进行状态离散化(State Discretization)。这是本项目第一个关键技巧,直接影响到学习效率和最终性能。
一个简单粗暴的方法是将每个状态维度均匀地划分为若干个区间(bin)。例如:
- 车位置
x: 范围[-2.4, 2.4],划分为 10 个区间。 - 车速
v: 范围[-3.0, 3.0](根据经验估计),划分为 10 个区间。 - 杆角度
theta: 范围[-0.2, 0.2]弧度(约±12度),划分为 20 个区间(因为角度对稳定性更敏感)。 - 杆角速度
theta_dot: 范围[-2.0, 2.0],划分为 20 个区间。
这样,总的状态空间大小就是10 * 10 * 20 * 20 = 40,000。对于每个维度的具体范围,你需要通过观察智能体交互时状态值的大致分布来调整。离散化粒度是一个重要的超参数:区间太少,状态区分度低,智能体学不到精细控制;区间太多,Q表过于稀疏,需要更多样本来填充,学习变慢且容易过拟合。
离散化的代码实现,就是为每个连续值找到它所属的区间索引。我们可以用np.digitize函数,或者自己写一个简单的映射函数:
def discretize_state(state, bins): """将连续状态state映射为离散索引元组""" discretized = [] for i in range(len(state)): # 将state[i]映射到bins[i]的某个索引 # 例如,如果bins[i] = np.linspace(-2.4, 2.4, 10),则找到state[i]落在哪个区间 discretized.append(np.digitize(state[i], bins[i]) - 1) # digitize返回1-based索引 # 确保索引在有效范围内 discretized[-1] = max(0, min(discretized[-1], len(bins[i])-2)) return tuple(discretized)3.2 Q表更新与动作选择:学习与决策的平衡
有了离散状态,智能体在每一步的决策和学习流程如下:
- 观察状态:从环境获得当前连续状态
s,将其离散化为s_discrete。 - 选择动作:根据当前Q表和探索策略,选择一个动作
a。最常用的策略是ε-贪婪策略:以概率ε随机选择一个动作(探索),以概率1-ε选择当前状态下Q值最大的动作(利用)。 - 执行动作:将动作
a传递给环境,得到下一个状态s‘、奖励r和终止标志done。 - 更新Q表:这是Q-learning的核心学习步骤。更新公式为:
Q(s, a) ← Q(s, a) + α * [ r + γ * max_a’ Q(s’, a’) - Q(s, a) ]α是学习率,控制新信息覆盖旧信息的程度。γ是折扣因子,表示对未来奖励的重视程度(0更重视即时奖励,1更重视长期回报)。r + γ * max_a’ Q(s’, a’)被称为目标值,它是当前步骤实际获得的即时奖励加上对下一状态最佳估计的折现。r + γ * max_a’ Q(s’, a’) - Q(s, a)是时序差分误差,代表了当前估计与目标之间的差距。
- 状态转移:将
s’设为新的当前状态,重复步骤1。
如果当前步骤是终止状态(done=True),那么目标值中就没有未来部分,即max_a’ Q(s’, a’)为0,更新公式简化为Q(s, a) ← Q(s, a) + α * [ r - Q(s, a) ]。
关于探索率ε:通常我们使用衰减的ε,例如ε = max(ε_min, ε * ε_decay)。训练初期需要大量探索,因此ε较高;随着学习进行,逐渐降低ε,让智能体更多地利用已学到的知识。这是平衡探索与利用的常用手法。
4. 训练流程与超参数调优:让智能体从零学起
现在,我们将环境、离散化、Q-learning算法组合起来,形成完整的训练循环。这个循环将运行多个回合,直到智能体的性能达到满意水平。
4.1 完整的训练循环架构
一个典型的训练循环代码如下所示。请注意,其中包含了关键的超参数和性能监控逻辑。
import gymnasium as gym import numpy as np # 超参数 EPISODES = 2000 # 训练总回合数 MAX_STEPS = 500 # 每回合最大步数 ALPHA = 0.1 # 学习率 GAMMA = 0.99 # 折扣因子 EPSILON = 1.0 # 初始探索率 EPSILON_MIN = 0.01 # 最小探索率 EPSILON_DECAY = 0.995 # 探索率衰减因子 # 创建环境 env = gym.make(‘CartPole-v1’) # 定义离散化区间(需要根据环境观察空间的范围调整) state_bins = [ np.linspace(-2.4, 2.4, 10), # 车位置 np.linspace(-3.0, 3.0, 10), # 车速 np.linspace(-0.2, 0.2, 20), # 杆角度 np.linspace(-2.0, 2.0, 20) # 杆角速度 ] state_dims = [len(bins)-1 for bins in state_bins] # 每个维度的离散状态数 action_dims = env.action_space.n # 动作空间大小(2) # 初始化Q表,形状为 (state_dim1, state_dim2, state_dim3, state_dim4, action_dim) Q_table = np.zeros(state_dims + [action_dims]) # 训练记录 rewards_history = [] epsilon_history = [] for episode in range(EPISODES): state, _ = env.reset() state_disc = discretize_state(state, state_bins) total_reward = 0 done = False for step in range(MAX_STEPS): # 1. ε-贪婪策略选择动作 if np.random.random() < EPSILON: action = env.action_space.sample() # 随机探索 else: action = np.argmax(Q_table[state_disc]) # 贪婪利用 # 2. 执行动作 next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated total_reward += reward # 3. 离散化下一状态 next_state_disc = discretize_state(next_state, state_bins) # 4. 更新Q表 current_q = Q_table[state_disc + (action,)] if done and terminated: # 因失败终止,无未来奖励 target_q = reward else: max_next_q = np.max(Q_table[next_state_disc]) target_q = reward + GAMMA * max_next_q Q_table[state_disc + (action,)] = current_q + ALPHA * (target_q - current_q) # 5. 状态转移 state_disc = next_state_disc if done: break # 回合结束,衰减探索率 EPSILON = max(EPSILON_MIN, EPSILON * EPSILON_DECAY) rewards_history.append(total_reward) epsilon_history.append(EPSILON) # 每100回合输出一次平均奖励 if episode % 100 == 0: avg_reward = np.mean(rewards_history[-100:]) print(f”Episode {episode}, Avg Reward (last 100): {avg_reward:.2f}, Epsilon: {EPSILON:.3f}”) env.close()4.2 超参数的影响与调优经验
训练强化学习智能体,很大程度上是在调优超参数。以下是几个关键参数的经验之谈:
- 学习率
α:控制学习速度。太大可能导致Q值震荡甚至发散;太小则学习缓慢。典型范围在[0.01, 0.5]之间。对于稳定的CartPole,0.1通常是个不错的起点。 - 折扣因子
γ:决定了智能体对未来奖励的重视程度。越接近1,智能体越有远见。对于倒立摆这种需要持续平衡的任务,γ应该设得较高,比如0.99。如果任务更看重即时奖励,可以设低一些。 - 探索率
ε及其衰减:初始ε通常设为1.0(完全随机探索)。衰减因子ε_decay控制探索减少的速度。0.995意味着每回合探索率乘以0.995。你需要观察训练曲线:如果奖励很早就停滞不前,可能是探索衰减太快,智能体陷入了局部最优;如果奖励一直波动很大,可能是探索率始终太高,智能体无法稳定利用学到的策略。 - 离散化区间数:这是最影响性能的参数之一。我建议从一个中等粒度开始(如每个维度10个区间),观察学习效果。如果学习很快但性能上限低(比如始终达不到500步),可能是状态区分度不够,可以尝试增加角度和角速度的区间数。如果学习极其缓慢,可能是状态空间太大,可以适当减少区间或增大探索率。
一个重要的训练技巧是观察“移动平均奖励”。单回合奖励波动很大,看最近100回合的平均奖励更能反映智能体的真实学习进度。当这个平均奖励接近最大步数(如500)并保持稳定时,说明智能体已经学会了。
5. 结果可视化与策略分析:解读智能体学到了什么
训练完成后,我们不仅要看它能不能平衡,还要理解它到底学到了什么策略。可视化是最直观的工具。
5.1 训练过程可视化:学习曲线的秘密
绘制奖励随训练回合的变化曲线,以及探索率ε的衰减曲线,放在同一张图上,可以清晰地看到学习过程。
import matplotlib.pyplot as plt plt.figure(figsize=(12, 5)) # 子图1:奖励历史(平滑处理) plt.subplot(1, 2, 1) # 计算每100回合的移动平均奖励,使曲线更平滑 window_size = 100 moving_avg = np.convolve(rewards_history, np.ones(window_size)/window_size, mode=‘valid’) plt.plot(range(window_size-1, len(rewards_history)), moving_avg, label=‘Moving Avg Reward (100 episodes)’, color=‘blue’) plt.xlabel(‘Episode’) plt.ylabel(‘Total Reward’) plt.title(‘Training Progress’) plt.legend() plt.grid(True) # 子图2:探索率衰减 plt.subplot(1, 2, 2) plt.plot(epsilon_history, label=‘Epsilon’, color=‘red’) plt.xlabel(‘Episode’) plt.ylabel(‘Epsilon’) plt.title(‘Exploration Rate Decay’) plt.legend() plt.grid(True) plt.tight_layout() plt.show()从学习曲线中,你通常能看到几个阶段:初期奖励很低(随机探索),随后奖励快速上升(智能体发现了有价值的动作),最后奖励在高位波动并逐渐稳定(策略趋于成熟)。探索率曲线应平滑下降,与奖励上升期相对应。
5.2 策略可视化与Q表洞察:看看智能体的“脑回路”
我们可以运行一个训练好的智能体,并录制其控制过程。更深入的是,尝试可视化Q表的一部分,来理解智能体的决策逻辑。由于Q表是高维的,我们可以固定其中两个维度,观察在另外两个维度构成的平面上,不同动作的Q值分布。
例如,我们固定小车位置和速度在中间值,观察摆杆角度和角速度构成的相位平面上的策略:
# 假设我们固定车位置索引为5(接近中心),车速索引为5(接近零) fixed_x_idx, fixed_v_idx = 5, 5 # 创建一个网格来绘制策略 theta_idx_range = range(state_dims[2]) # 角度维度索引 theta_dot_idx_range = range(state_dims[3]) # 角速度维度索引 policy_map = np.zeros((len(theta_idx_range), len(theta_dot_idx_range))) for i, theta_idx in enumerate(theta_idx_range): for j, theta_dot_idx in enumerate(theta_dot_idx_range): state_idx = (fixed_x_idx, fixed_v_idx, theta_idx, theta_dot_idx) # 选择Q值最大的动作作为策略 policy_map[i, j] = np.argmax(Q_table[state_idx]) # 将离散索引映射回近似的物理值用于坐标轴 theta_vals = state_bins[2][:-1] # 忽略最后一个边界值 theta_dot_vals = state_bins[3][:-1] plt.figure(figsize=(8, 6)) # 使用imshow或pcolormesh绘制策略图 plt.imshow(policy_map.T, origin=‘lower’, aspect=‘auto’, extent=[theta_vals[0], theta_vals[-1], theta_dot_vals[0], theta_dot_vals[-1]], cmap=‘coolwarm’) plt.colorbar(label=‘Action (0=Left, 1=Right)’) plt.xlabel(‘Pole Angle (rad)’) plt.ylabel(‘Pole Angular Velocity (rad/s)’) plt.title(‘Learned Policy Slice (at center position & zero cart velocity)’) plt.show()这张图会显示,在角度-角速度平面上,智能体在什么区域会选择向左推,什么区域会选择向右推。你可能会看到一个清晰的分界线,这类似于一个简化的控制律。例如,当杆向右倒(角度为正)时,智能体很可能选择向右移动小车以“接住”杆子,这与直观物理是一致的。
5.3 性能测试与鲁棒性验证
最后,关闭探索(设置ε=0),让智能体纯粹利用学到的策略运行多个回合,统计平均步数。一个训练良好的智能体应该能稳定达到最大步数(500步)。你还可以稍微修改环境参数(如重力、杆长),测试策略的鲁棒性。表格型Q-learning学到的策略通常对微小扰动有一定鲁棒性,但较大变化可能需要重新训练或调整离散化区间。
6. 从Q-learning出发:局限性与进阶方向
通过这个项目,你已经成功用最经典的Q-learning算法解决了倒立摆问题。但我们必须清醒地认识到表格型Q-learning的局限性,这也正是深度强化学习等更高级方法发展的动力。
表格型Q-learning的核心局限:
- 维度灾难:我们的状态空间被离散为4万个单元。如果状态维度再增加(比如一个7自由度的机械臂),或者离散化更精细,Q表的大小会呈指数级爆炸,根本无法存储和训练。
- 无法处理连续动作:Q-learning天然适用于离散动作空间。对于需要连续力输出的控制问题(如油门、方向盘角度),表格法无能为力。
- 泛化能力差:Q表只存储了见过的离散状态的值。对于未在训练中精确出现过的状态,智能体无法做出很好的推断。
由此引出的进阶方向:
- 函数逼近:用参数化函数(如线性函数、神经网络)来近似Q值函数
Q(s, a; θ),而不是用表格存储。这就是深度Q网络(DQN)的核心思想。DQN使用神经网络作为Q函数逼近器,可以处理高维原始输入(如图像),并具备一定的泛化能力。 - 策略梯度方法:直接参数化策略
π(a|s; θ),并通过梯度上升来优化策略参数以最大化期望回报。这类方法(如REINFORCE, Actor-Critic, PPO)天然适用于连续动作空间。 - 结合模型:上述都是无模型方法。基于模型的强化学习则尝试学习环境动力学模型,然后利用这个模型进行规划或辅助学习,可以大幅提升样本效率。
倒立摆问题本身也有更复杂的变体,如二阶倒立摆(双摆),其状态空间更复杂、更不稳定,对算法是更大的挑战。从Q-learning到DQN,再到策略梯度方法去解决连续控制版本的倒立摆(如Pendulum-v1环境),是一条清晰的学习路径。
我个人在实践中的体会是,这个Q-learning倒立摆项目最大的价值,不在于实现了一个多强的控制器,而在于它像一把钥匙,帮你打通了强化学习从理论到实践的任督二脉。当你亲手调整超参数、观察学习曲线、可视化Q表并看到智能体从零开始学会平衡时,那些抽象的概念——状态、动作、奖励、值函数、探索与利用——就变得无比具体和深刻。它留下的印象,远比读十篇论文更牢固。在后续学习更复杂的深度强化学习算法时,你会不断地回溯到这个最基础的模型上来理解新概念。所以,尽管它简单,但请务必重视这个起点,把每个环节都吃透。