简介:基于深度Q网络(DQN)的倒立摆控制项目,完整演示了如何用强化学习解决CartPole这一经典平衡问题。面向初学强化学习的开发者,以及需要快速搭建DQN基线实验的研究者。项目将Q学习与神经网络结合,通过gym环境完成智能体与环境交互,并重点实现了经验回放和目标网络两大关键机制,有助于理解从表格型Q值存储到深度网络逼近的演进过程。压缩包共7个文件,其中3个Python脚本负责智能体定义与训练主流程,另有Markdown文档提供使用说明与许可证信息,以及依赖清单和配置文件,整体大小仅4KB,结构精简,没有多余封装,便于逐行分析算法细节,适合复现和二次修改。目前已有988人学习下载,能够帮助读者快速掌握DQN的核心实现细节,并迁移到其他连续控制任务中。
1. 倒立摆上的Q学习与DQN:为什么越简单的环境越能看清算法的底牌
很多人在初学强化学习时,习惯性地往复杂环境上冲,结果在训练时连“到底学没学会”都判断不了。其实最能说明问题的一步,恰恰是把环境换成一个只有四个连续状态量、两个离散动作的CartPole-v1倒立摆,同时跑一遍Q-learning和DQN,观察它们在同一个任务上的行为差异。这个从Q表到深度网络的跨越,就是理解强化学习入门最好的教材。本文就顺着一个常见的cartpole-qlearning-master工程,把倒立摆环境、Q-learning的离散化训练、再升级到DQN的完整落地路径讲透,包括参数怎么设、为什么这样设、哪些地方容易翻车。适合刚跑通OpenAI Gym、想弄懂离散动作空间如何作用、以及想知道“为什么DQN有时表现还不如Q表”的从业者。
2. 把倒立摆与Q-learning的底子搭起来:离散化状态是关键
2.1 为什么是CartPole-v1:观测空间、动作空间与奖励的最小可玩模型
CartPole的任务目标是让小车上的杆保持直立,每过一个时间步就获得+1的奖励。若杆的倾角超过±12度(约0.2095弧度),或小车位置超出±2.4,Episode就会终止;在v1版本中,连续不倒500步即视为通关。环境返回的observation是一个四维浮点向量,具体如下表:
| 维度 | 含义 | 数值范围 |
|---|---|---|
| 0 | 小车位置 | [-4.8, 4.8] |
| 1 | 小车速度 | 无边界(实际训练中一般在[-2, 2]内波动) |
| 2 | 杆的倾角 | [-0.418, 0.418](弧度) |
| 3 | 杆顶端速度 | 无边界(实际一般在[-2, 2]内波动) |
动作空间只有两个离散值:0表示向左推,1表示向右推。环境本身只负责给状态、反馈奖励和终止信号,学习逻辑全在自己写的代码里。这也是它适合入门的原因:不需要处理复杂的图像输入、连续动作控制或部分可观测问题,所有算法的差异性都能归因到学习策略本身。
2.2 Q学习的表格法原理:更新公式是怎么来的
Q-learning属于典型的离策略(off-policy)时序差分方法,核心维护一张二维Q表,行是状态,列是动作,每个格子存一个价值估计。更新公式可以写成:
Q(s, a) ← Q(s, a) + α × [r + γ × max_a' Q(s', a') - Q(s, a)]
- α是学习率,决定新信息覆盖旧估计的速度。
- γ是折扣因子,控制未来奖励的相对权重。
- max_a' Q(s', a') 是下一状态中所有动作的最大Q值,它来自目标策略(贪心),而不来自当前的行为策略。
- 方括号整体是时间差分误差(TD error),当它等于0时,Q值表就收敛了。
这个改动虽然只发生在公式层面,但代码落地时有一个必须处理的矛盾:CartPole的状态空间是连续的,不可能为每个浮点状态都建一行Q表。解决办法没有魔法可言,只有一条路:把连续空间切成“格子”,让每个状态落入一个离散桶中。
2.3 把连续观测离散成桶:分桶函数与边界选取
我一般解决了数组的观测提取,立刻做的一件事是在训练循环外面打印一次obs_space的low和high,并据此设定分桶边界。原因很简单:边界设定不合理,整个Q表的索引就是无源之水,后面全白搭。下面是一段可以直接用于CartPole的分桶代码:
import numpy as np # 每个状态维度各分多少桶,顺序对应 observation 的四个分量 NUM_BUCKETS = (6, 8, 12, 8) # (位置, 速度, 角度, 角速度) # CartPole-v1 的实际边界 POSITION_MAX = 2.4 ANGLE_MAX = 0.418 # 速度类分量无界,经验裁剪到 [-2, 2],避免极端值直接撑爆索引 VELOCITY_MAX = 2.0 def discretize(obs): # 对速度做裁剪,同时避免除以0 pos, vel, ang, ang_vel = obs pos_bin = np.digitize(np.clip(pos, -POSITION_MAX, POSITION_MAX), np.linspace(-POSITION_MAX, POSITION_MAX, NUM_BUCKETS[0] - 1)) vel_bin = np.digitize(np.clip(vel, -VELOCITY_MAX, VELOCITY_MAX), np.linspace(-VELOCITY_MAX, VELOCITY_MAX, NUM_BUCKETS[1] - 1)) ang_bin = np.digitize(np.clip(ang, -ANGLE_MAX, ANGLE_MAX), np.linspace(-ANGLE_MAX, ANGLE_MAX, NUM_BUCKETS[2] - 1)) ang_vel_bin = np.digitize(np.clip(ang_vel, -VELOCITY_MAX, VELOCITY_MAX), np.linspace(-VELOCITY_MAX, VELOCITY_MAX, NUM_BUCKETS[3] - 1)) return (pos_bin, vel_bin, ang_bin, ang_vel_bin)代码逻辑说明:np.digitize的作用是把数值映射到边界数组的索引区间,返回0到N-1之间的整数。注意边界数组用的是np.linspace,N个桶只需要N-1个内部切分点。速度边界之所以设置为2.0,是因为我观察到训练中速度绝对值很少超过这个值,裁剪后损失的信息有限,但能保证索引不穿透。
参数说明:位置分6桶、速度分8桶、角度分12桶、角速度分8桶,总计6×8×12×8=4608个状态,每个状态存2个动作的Q值。角度分桶数量最大,因为倒立摆对角度变化最敏感,这是CartPole控制精度的命门;位置和角速度8桶足够,分太多反而会让每个桶的样本量骤降,Q值更新稀疏,难以收敛。
2.4 用Q表训练CartPole:单文件就能跑的完整训练循环
Q表本身可以设计成numpy数组,也可以直接用dict做惰性初始化。前者在训练前必须预先分配,后者在训练过程中按需创建,更适合探索初期状态覆盖不全的场景。下面是一段典型的完整训练代码,配合epsilon贪心策略学习:
import gym import numpy as np from collections import defaultdict env = gym.make("CartPole-v1") q_table = defaultdict(lambda: np.zeros(env.action_space.n)) # 超参数 alpha = 0.5 # 学习率,新样本的权重 gamma = 0.99 # 折扣因子,CartPole可适当取高 epsilon = 1.0 epsilon_min = 0.01 epsilon_decay = 0.995 episodes = 2000 def choose_action(state): if np.random.random() < epsilon: return env.action_space.sample() # 探索 return int(np.argmax(q_table[state])) # 利用 for ep in range(episodes): obs, _ = env.reset() state = discretize(obs) total_reward = 0 done = False while not done: action = choose_action(state) next_obs, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated next_state = discretize(next_obs) # Q-learning 更新 best_next_q = np.max(q_table[next_state]) q_table[state][action] += alpha * ( reward + gamma * best_next_q - q_table[state][action] ) state = next_state total_reward += reward epsilon = max(epsilon_min, epsilon * epsilon_decay) if (ep + 1) % 100 == 0: print(f"Episode {ep + 1}, reward: {total_reward}, epsilon: {epsilon:.3f}")逻辑说明:程序的核心循环是先根据当前状态用epsilon-greedy选择一个动作执行,拿到next_state后立刻做Q表更新,然后把下一状态作为当前状态继续。注意done判断必须是terminated或truncated的或运算,truncated对应步数达到500上限,此时杆并没有倒,但回合已被环境截断,同样应视为回合结束。这里如果漏了truncated,训练时会反复在一个已经失败的episode里续跑,行为会出现偏差。
参数说明:epsilon初值1.0保证前期充分探索,0.995的衰减系数使得约90轮后epsilon降到0.01附近。alpha取0.5是经验值,取太大导致Q值震荡,取太小收敛极慢。gamma取0.99是因为这个环境单回合最长500步,未来奖励信用需要较长回溯链。如果gamma取1.0,理论上无折扣,但实际会出现Q值不收敛的风险,这一点在后面的避坑章节中会展开。
2.5 Q表在CartPole上的实际表现与局限
按上面这套配置训练,通常300~800个episode内就能达到平均400分以上的水平,甚至轻松上到500分。CartPole的设计让Q表法显得绰绰有余,如果到这里就停笔,那对DQN的理解依然停留在表面。Q表真正的局限在于“维度诅咒”:状态一旦换成一维或多维连续输入(比如图像的像素、机械臂的关节角度组合),表格条目直接爆炸,训练样本也被稀释到无法更新。这就是引入神经网络做函数逼近的客观理由,也是DQN出现在这个标题里的原因。
3. 从Q表升级到DQN:用神经网络逼近Q函数的落地改法
3.1 为什么Q表够用但必须换成深度网络:维度诅咒与泛化能力
在CartPole上,4608个状态的Q表已经让训练很快了,但换到稍微复杂一点的环境,比如双关节倒立摆或带摩擦力的连续控制任务,状态空间维度扩大到十几个、几十个,表格法就彻底失去优势。神经网络能把高维输入直接映射到每个动作的Q值,通过权重共享实现泛化——相近的状态能输出相近的价值估计,这是Q表完全做不到的。所以在同一套cartpole环境下,从Q表格切到DQN的意义不在训练速度,而在于理解这套机制在真实工程项目中的可扩展性。
3.2 一个能跑通CartPole的DQN最小网络结构(PyTorch实现)
DQN的网络结构不需要堆注意力、CNN这类花哨组件,CartPole的观测只有4个浮点数,一个三层全连接网络就足够。输入层接4个状态量,输出层只有2个节点对应向左推和向右推的Q值。下面是最小可用结构,我用的是PyTorch的写法:
import torch.nn as nn class DQN(nn.Module): def __init__(self, state_dim=4, action_dim=2): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, 64), nn.ReLU(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, action_dim) ) def forward(self, x): return self.net(x)结构说明:两层隐藏层各64个神经元,加上ReLU激活。之所以不选更小的32维,是因为CartPole中状态之间的边界关系比较微妙,64维能提供更好的表达力;再往上加128、256也不是不行,但训练速度会变慢且收益有限,属于典型过度拟合。对于动作输出层,这里不需要激活函数,因为Q值本身可以是任意实数,网络直接输出原始线性值,训练时与目标Q值计算MSE即可。
一个值得注意的细节:输入状态的值域差异较大(位置在±2.4,角度在±0.418,速度可能到±2),如果不做归一化,网络收敛速度会明显变慢。常见做法是在将obs送入网络前,把每个分量除以自己的边界值,让它们大致落在[-1, 1]区间内。这样网络权重更新的梯度方向更平稳,训练曲线也不容易出现锯齿状震荡。
3.3 经验回放:为什么DQN需要Replay Buffer
Q-learning的更新是单条样本、用完即弃,但深度网络做的是梯度下降,对样本分布有独立同分布的假设。如果直接按转移顺序喂数据,相邻样本之间高度相关,梯度更新方向会被带偏,网络就会在几个极端样本上来回横跳。经验回放的做法是先存一批历史转移样本(state, action, reward, next_state, done),训练时每次随机抽取一个batch来更新。这样既能打破样本间的相关性,又能让某些稀疏关键样本被多次利用。CartPole哪怕只有几万条经验,也建议加上这一层。
from collections import deque import random class ReplayBuffer: def __init__(self, capacity=20000): self.buffer = deque(maxlen=capacity) def push(self, state, action, reward, next_state, done): self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) return (np.array([x[0] for x in batch], dtype=np.float32), np.array([x[1] for x in batch], dtype=np.int64), np.array([x[2] for x in batch], dtype=np.float32), np.array([x[3] for x in batch], dtype=np.float32), np.array([x[4] for x in batch], dtype=np.uint8)) def __len__(self): return len(self.buffer)参数说明:capacity设20000在CartPole上足够,因为在500步限制下,一个episode最多500条经验,20000大约等于40个回合的总量,远高于训练初期需要覆盖的状态多样性。采样采用随机抽样,batch内样本相互独立,能有效减小梯度方差。注意这里存储的state和next_state都是原生4维数组而不是离散桶索引,和Q表的做法有本质区别。
3.4 训练循环代码:采样、目标Q值与参数更新
DQN的更新公式依然沿用了Q-learning的核心思想,区别在于Q(s,a)和maxQ(s',a')都由网络计算得出。为了让网络训练稳定,常规做法是用另一个target network计算目标值,并定期同步参数,避免“用自己更新的网络来监督自己”导致目标移动。下面是一个简洁的训练循环实现,适合CartPole-v1快速验证:
optimizer = torch.optim.Adam(dqn.parameters(), lr=1e-3) loss_fn = nn.MSELoss() replay = ReplayBuffer(capacity=20000) batch_size = 64 gamma = 0.99 sync_target_steps = 500 train_start = 1000 # 经验池攒够一定量再开始训练 for ep in range(1000): obs, _ = env.reset() total_reward = 0 while True: # epsilon 贪心选择动作 if np.random.random() < epsilon: action = env.action_space.sample() else: with torch.no_grad(): q_values = dqn(torch.FloatTensor(obs).unsqueeze(0)) action = int(torch.argmax(q_values).item()) next_obs, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated replay.push(obs, action, reward, next_obs, done) obs = next_obs total_reward += reward if len(replay) > train_start: # 目标Q值由 target_net 计算,避免自举 states, actions, rewards, next_states, dones = replay.sample(batch_size) with torch.no_grad(): next_q = target_net(torch.FloatTensor(next_states)).max(1)[0] targets = torch.FloatTensor(rewards) + gamma * next_q * (1 - torch.FloatTensor(dones)) q_pred = dqn(torch.FloatTensor(states)).gather(1, torch.LongTensor(actions).unsqueeze(1)).squeeze(1) loss = loss_fn(q_pred, targets) optimizer.zero_grad() loss.backward() optimizer.step() if done: break epsilon = max(0.01, epsilon * 0.985) if ep % 50 == 0: print(f"Episode {ep}, reward: {total_reward}")逻辑说明:每个时间步都会把经验推入ReplayBuffer,但只有当经验池容量超过train_start之后才开始反向传播,否则初期一批高度相似的样本会让网络陷入局部最优。target_net并不参与梯度计算,只在更新样本生成时用到,它的参数通过每sync_target_steps步复制一次dqn来同步。
参数说明:学习率1e-3对CartPole这种低维任务足够,过大会导致Loss爆炸,过小则训练周期明显拉长。sync_target_steps取500,意味着每500步更新一次target网络;这个值对CartPole是经验最稳的区间,太频繁会让target失去稳定作用,太稀疏会让目标Q值偏离当前策略过久。train_start必须大于batch_size,否则采样时会因样本不足报错,一般取1000即可。
4. 倒立摆上Q表与DQN的选型对比:什么场景该用谁
4.1 收敛速度、样本效率和稳定性差异对比
很多初学者会误以为DQN一定全面优于Q-learning,实际跑一遍CartPole就会发现结论恰好相反。在简单离散化任务里,Q表几乎总是更快达到500分上限,因为状态空间已被手动压缩到几千个桶,每个桶都能被大量反复访问;而DQN要同时训练几万个网络参数,反而需要更多样本才能拟合出和Q表等价的映射关系。下面用一个汇总表说明两者差异:
| 对比项 | Q-learning + 离散化 | DQN |
|---|---|---|
| 达到400分所需回合数 | 约300~600 | 约500~1500(受随机种子影响大) |
| 状态存储方式 | 表格(离散索引) | 神经元权重(原始特征输入) |
| 更新单位 | 单条样本 | batch(通常64条) |
| 处理高维输入能力 | 差 | 强 |
| 对超参数敏感度 | 较低 | 高 |
| 训练稳定性 | 较稳定 | 依赖经验回放和目标网络 |
这里的得分水平还与epsilon衰减参数、分桶粒度有直接关系,不同配置下可能差出一倍训练量。但总体规律不变:环境状态空间能靠人工分桶解决的时候,Q表是更省心的方案;一旦状态变成了图像、点云或高维传感器数据,离散化的工作量远超可接受范围,DQN这类近似方法才展现价值。
4.2 DQN与Q表的四个实质差别,不只是把Q表换成网络
第一点差别在状态输入形式。Q表需要把连续观测离散成标量索引,而DQN直接吃原始浮点向量,甚至能吃像素矩阵。第二点在更新方式。Q表更新是查表后做一次算术运算,DQN则是构造损失函数做一轮梯度下降。第三点在样本相关性处理。Q表逐条更新天然不惧怕时序相关性,但DQN必须有经验回放机制,否则网络会被连续到访的相似状态带偏。第四点在目标值的稳定性。Q表更新时max项从一个固定表格里查,DQN的目标值本身来自一个还在不断变化的网络,必须引入target network来切断“标签跟着预测跑”的循环。理解这四点差别,才算真正看懂了标题里“Q-learning”到“DQN”的跨越点。
4.3 从Q-learning到DQN,再向深度强化学习算法谱系延伸
如果顺着CartPole这条线往下走,可以继续了解策略梯度方法(如REINFORCE)、Actor-Critic架构(如A2C、PPO)等深度强化学习算法。它们解决的是DQN在连续动作空间上的天然缺陷:DQN适合输出离散动作值,但机器人关节力矩这类连续控制就需要策略网络直接输出动作分布。再往后还有基于模型的强化学习,以及离线强化学习(如IQL)等研究路线,它们处理的是“不能在线交互,只能从固定数据集中学”的工程场景。但无论分支如何发展,Q值函数估计与TD更新这个内核始终贯穿其中,把CartPole上的DQN跑通,后续理解这些变体就不需要从头来过。
4.4 我的选型建议:先评估状态维度再决定方案
实际项目里我不会在“哪个更高级”上纠结,而是先看状态空间的维度。如果状态量少于十几个且边界明确,直接用Q表加分桶,开发和调参速度都快得多;如果状态维度达到几十以上,或者包含图像,那就不必犹豫,直接上DQN范式。CartPole作为一个“学步车”项目,两种方案都能跑,正好给了练习者一个提前感知判断尺度的机会。
5. 避坑:CartPole上最让我记忆犹新的5个翻车现场
5.1 分桶边界没对准环境上下限,分数始终上不去
现象:Q表训练几百轮之后,reward还是长期在20以下,曲线像心电图一样乱跳。原因:分桶的边界值和env实际返回的observation范围不匹配。最常见的是把POSITION_MAX设成4.8,但环境在位置超过2.4时就已判定终止,导致大量无用区间占据桶的编号,关键区域反而分辨率不足。解决:分桶前先打印env.observation_space.high和low,然后只对有效区间进行切分;速度这类无界分量通过np.clip强制截断。
5.2 epsilon衰减太快导致探索不足,DQN提前锁死在次优动作
现象:DQN刚开始训练时奖励快速上升,十几个episode后突然掉到0,之后再也回不来。原因:epsilon按0.985衰减,初值为0.9,十几轮后就已经低于0.5,探索骤减,网络被早期几个随机好转的样本锁死,之后再无机会尝试其它动作带。解决:把epsilon_decay调到0.995以上,或改用线性衰减,保证至少前200个episode里探索概率不低于0.3。一个简单的检验方式是将epsilon的衰减曲线打印出来,观察它与reward曲线的交叉点。
5.3 用同一个网络计算目标Q值,训练发散到value爆炸
现象:Loss从两位数快速增长到几万甚至NaN,Q值预测输出几百上千的离谱数值。原因:目标q值直接来自dqn自身,每个batch的更新量在改变Q值的同时又把目标值推远,形成正反馈循环,价值函数越来越飘。解决:引入target_net冻结参数,每固定步数同步一次,是DQN稳定训练的前提条件。CartPole上500步同步已经足够,不需要额外调校。
5.4 经验池太小,训练中段突然出现大幅回退
现象:训练到一半reward已经达到200以上,结果20个episode后突然跌到50以下,后续恢复缓慢。原因:ReplayBuffer容量过小(比如2000),老样本不断被冲走,网络频繁只能在新近样本上过拟合,当这批样本恰好来自几个异常episode时,策略就被带崩。解决:把capacity至少设到20000,确保能覆盖40个episode以上的历史经验;如果环境本身复杂,容量还应继续增大。
5.5 gamma取1.0让Q值无法收敛
现象:终止状态下奖励被原样向后传递,每个状态的Q值都偏高,训练后期所有动作的Q值几乎一样,选择失去意义。原因:CartPole的回合最长500步,gamma=1.0意味着远期奖励与即时奖励权重完全相同,且没有衰减来限制累计价值上限,Q值不断膨胀。解决:把gamma明确设为0.99或0.95,让远期奖励按步数折扣,保持Q值有界。如果希望模型更接近“每步都重要”的语义,取0.99即可,不建议使用1.0。
6. 固定策略评估:怎么确认倒立摆真的学会了,而不是碰运气跑赢
训练过程中的reward曲线带有很大的随机性,尤其是epsilon较高时,偶尔跑出高分并不意味着策略已经稳定。我习惯在训练结束后单独跑一个evaluate函数,将epsilon固定为0,连续evaluate 20个episode,统计平均步数。这才是判断模型是否真正收敛、Q表或DQN是否值得保存的依据。
def evaluate(env, policy, n_episodes=20, render=False): rewards = [] for _ in range(n_episodes): obs, _ = env.reset() total = 0 done = False while not done: state = discretize(obs) if use_q_table else obs action = policy(state) obs, r, terminated, truncated, _ = env.step(action) done = terminated or truncated total += r if render: env.render() rewards.append(total) return np.mean(rewards), np.std(rewards)上面这个函数无论Q表还是DQN都能调用,只需切换policy的具体实现:Q表就查表取argmax,DQN就前向推理得到Q值后取最大动作。评估时关闭探索噪声,得到的均值才代表策略的真实水平。CartPole-v1的评价标准很明确:平均分达到490以上、标准差小于10,才算稳定学会;若平均分只有300+且方差很大,说明策略在边界状态上依然脆弱,还需要更多训练或调低epsilon。
另一个很有用的验证方式是观察失败回合的终止原因。打印每个失败回合里done是terminated还是truncated,如果大部分终止是因为杆倾角超限而位置还没越界,说明控制策略已经能稳住姿态,但角度反馈不够灵敏,可以尝试增大角度分桶数量或提升角度在normalization中的权重;反之若多为位置越界,则说明速度控制存在欠阻尼震荡,需要调整reward设计或网络容量。
我个人的习惯是把观测归一化的参数也写进evaluate流程,保证评估时的输入分布和训练时完全一致,否则测试精度会平白损失几个百分点。训练时用obs/边界值,评估时也一定要做同样的除法,这个细节在DQN中最容易被忽略。希望这条经验帮你在跑倒立摆时少走几步弯路,尽早把精力放到真正影响算法效果的地方去。
本文还有配套的精品资源,点击获取