引言:从理论到实践的跨越
在前一篇文章中,我们深入探讨了值函数近似和DQN的三大核心技术。现在,是时候将这些理论知识转化为实际可运行的代码了。本文将带领你从零开始,使用PyTorch框架在CartPole-v1环境中完整实现DQN算法。这不仅是一次编程实践,更是一次对深度强化学习核心概念的深刻理解过程。
CartPole(车杆平衡)问题是强化学习领域的"Hello World",看似简单,却包含了连续状态空间、稀疏奖励、延迟奖励等核心挑战。通过在这个环境中实现DQN,我们将亲手见证一个神经网络从随机决策到精通平衡的完整学习过程。
第一部分:环境理解与问题分析
1.1 CartPole-v1环境详解
CartPole问题描述:一个小车在一个一维的无摩擦轨道上移动,车上通过一个关节连接着一根直杆。智能体的任务是通过向左或向右移动小车来防止杆子倒下。
状态空间(State Space):4个连续值,维度为4:
- 小车位置(Cart Position):范围[-2.4, 2.4]
- 小车速度(Cart Velocity):无固定范围
- 杆子角度(Pole Angle):范围[-0.2095, 0.2095]弧度(约±12°)
- 杆子顶端速度(Pole Velocity At Tip):无固定范围
动作空间(Action Space):离散的2个动作:
- 0:向左推小车
- 1:向右推小车
奖励函数(Reward Function):
- 每一步(只要杆子没有倒下)获得+1的奖励
- 当出现以下情况时,回合终止:
- 杆子倾斜超过15°(约0.2618弧度)
- 小车位置超出轨道边界(±2.4)
- 回合长度达到200步(CartPole-v1的最大步数)
目标:最大化累积奖励,即尽可能长时间地保持杆子竖直。在CartPole-v1中,连续100个回合的平均奖励达到195分即认为问题已解决。
1.2 为什么选择DQN解决CartPole?
尽管CartPole状态是连续的,但动作是离散的(只有2个),这使其成为DQN的理想测试平台:
- 连续状态空间:需要函数近似(神经网络)来处理
- 离散动作空间:适合Q-learning类算法
- 奖励稀疏但密集:每步都有奖励,学习信号充足
- 环境相对简单:适合调试和验证算法实现
第二部分:DQN实现架构设计
2.1 系统组件设计
我们的DQN实现将包含以下核心组件:
- Q网络(Q-Network):一个前馈神经网络,将4维状态映射到2个动作的Q值
- 经验回放缓冲池(Replay Buffer):存储和采样智能体的经验
- 目标网络(Target Network):稳定训练过程
- ε-贪婪策略(Epsilon-Greedy Policy):平衡探索与利用
- 训练循环(Training Loop):协调所有组件完成学习过程
2.2 神经网络架构选择
对于CartPole问题,我们采用一个简单的多层感知机(MLP):
- 输入层:4个神经元(对应4个状态维度)
- 隐藏层:2个全连接层,每层128个神经元,使用ReLU激活函数
- 输出层:2个神经元(对应2个动作的Q值)
这种架构在保证足够表达能力的同时,避免了过拟合的风险。
第三部分:从零开始实现DQN
3.1 环境设置与导入库
python
import gymnasium as gym
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
from collections import deque, namedtuple
import random
import matplotlib.pyplot as plt
from matplotlib import animation
import warnings
warnings.filterwarnings('ignore')
# 设置随机种子以保证结果可复现
SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
# 检查GPU是否可用
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
3.2 定义Q网络
python
class QNetwork(nn.Module):
"""
深度Q网络:将状态映射到动作价值
"""
def __init__(self, state_size, action_size, hidden_size=128):
super(QNetwork, self).__init__()
self.fc1 = nn.Linear(state_size, hidden_size)
self.fc2 = nn.Linear(hidden_size, hidden_size)
self.fc3 = nn.Linear(hidden_size, action_size)
# 初始化权重
self._initialize_weights()
def _initialize_weights(self):
"""Xavier初始化,有助于训练稳定性"""
for layer in [self.fc1, self.fc2, self.fc3]:
nn.init.xavier_uniform_(layer.weight)
nn.init.constant_(layer.bias, 0)
def forward(self, state):
x = torch.relu(self.fc1(state))
x = torch.relu(self.fc2(x))
return self.fc3(x)
3.3 实现经验回放缓冲池
python
Transition = namedtuple('Transition',
('state', 'action', 'reward', 'next_state', 'done'))
class ReplayBuffer:
"""
经验回放缓冲池:存储和采样智能体的经验
"""
def __init__(self, capacity):
self.buffer = deque(maxlen=capacity)
self.capacity = capacity
def push(self, *args):
"""存储一个转移经验"""
self.buffer.append(Transition(*args))
def sample(self, batch_size):
"""随机采样一批经验"""
# 确保采样数量不超过缓冲池大小
batch_size = min(batch_size, len(self.buffer))
return random.sample(self.buffer, batch_size)
def __len__(self):
return len(self.buffer)
def is_ready(self, batch_size):
"""检查缓冲池是否有足够的数据进行采样"""
return len(self.buffer) >= batch_size
3.4 实现智能体类
python
class DQNAgent:
"""
DQN智能体:整合所有组件并实现学习算法
"""
def __init__(self, state_size, action_size):
self.state_size = state_size
self.action_size = action_size
# 超参数
self.buffer_size = 10000 # 经验回放缓冲池大小
self.batch_size = 64 # 训练批次大小
self.gamma = 0.99 # 折扣因子
self.learning_rate = 0.001 # 学习率
self.tau = 0.005 # 目标网络软更新系数
self.update_every = 4 # 每多少步更新一次网络
# ε-贪婪策略参数
self.epsilon = 1.0 # 初始探索率
self.epsilon_min = 0.01 # 最小探索率
self.epsilon_decay = 0.995 # 探索率衰减率
# 设备
self.device = device
# 初始化Q网络和目标网络
self.qnetwork_local = QNetwork(state_size, action_size).to(self.device)
self.qnetwork_target = QNetwork(state_size, action_size).to(self.device)
self.qnetwork_target.load_state_dict(self.qnetwork_local.state_dict())
self.qnetwork_target.eval() # 目标网络设为评估模式
# 优化器
self.optimizer = optim.Adam(self.qnetwork_local.parameters(),
lr=self.learning_rate)
# 经验回放缓冲池
self.memory = ReplayBuffer(self.buffer_size)
# 训练步数计数器
self.t_step = 0
def act(self, state, training=True):
"""
根据当前策略选择动作
"""
state = torch.from_numpy(state).float().unsqueeze(0).to(self.device)
# 训练模式下使用ε-贪婪策略
if training and random.random() < self.epsilon:
return random.choice(range(self.action_size))
# 评估模式或利用模式下使用贪婪策略
with torch.no_grad():
action_values = self.qnetwork_local(state)
return torch.argmax(action_values).item()
def step(self, state, action, reward, next_state, done):
"""
处理一步交互:存储经验并学习
"""
# 存储经验到回放缓冲池
self.memory.push(state, action, reward, next_state, done)
# 每隔update_every步学习一次
self.t_step = (self.t_step + 1) % self.update_every
if self.t_step == 0 and self.memory.is_ready(self.batch_size):
self._learn()
def _learn(self):
"""
从经验回放缓冲池中采样并更新Q网络
"""
# 1. 从缓冲池中采样
transitions = self.memory.sample(self.batch_size)
batch = Transition(*zip(*transitions))
# 2. 将数据转换为张量
state_batch = torch.FloatTensor(batch.state).to(self.device)
action_batch = torch.LongTensor(batch.action).unsqueeze(1).to(self.device)
reward_batch = torch.FloatTensor(batch.reward).unsqueeze(1).to(self.device)
next_state_batch = torch.FloatTensor(batch.next_state).to(self.device)
done_batch = torch.FloatTensor(batch.done).unsqueeze(1).to(self.device)
# 3. 计算当前Q值 Q(s, a)
q_local = self.qnetwork_local(state_batch)
q_current = q_local.gather(1, action_batch)
# 4. 计算目标Q值 y = r + γ * max_a' Q_target(s', a') * (1 - done)
with torch.no_grad():
q_target_next = self.qnetwork_target(next_state_batch).max(1)[0].unsqueeze(1)
q_target = reward_batch + (self.gamma * q_target_next * (1 - done_batch))
# 5. 计算损失(均方误差)
loss = nn.functional.mse_loss(q_current, q_target)
# 6. 梯度下降优化
self.optimizer.zero_grad()
loss.backward()
# 7. 梯度裁剪(防止梯度爆炸)
torch.nn.utils.clip_grad_norm_(self.qnetwork_local.parameters(), 1.0)
self.optimizer.step()
# 8. 软更新目标网络
self._soft_update()
# 9. 衰减探索率
self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay)
def _soft_update(self):
"""
软更新目标网络参数:θ_target = τ * θ_local + (1 - τ) * θ_target
"""
for target_param, local_param in zip(self.qnetwork_target.parameters(),
self.qnetwork_local.parameters()):
target_param.data.copy_(self.tau * local_param.data +
(1.0 - self.tau) * target_param.data)
3.5 训练循环实现
python
def train_dqn(env, agent, n_episodes=500, max_t=1000,
solve_score=195, window_size=100):
"""
训练DQN智能体
"""
scores = [] # 每个回合的得分
scores_window = deque(maxlen=window_size) # 最近100个回合的得分
epsilons = [] # 记录每个回合的探索率
for i_episode in range(1, n_episodes + 1):
state, _ = env.reset()
score = 0
for t in range(max_t):
# 选择动作
action = agent.act(state)
# 执行动作
next_state, reward, done, truncated, _ = env.step(action)
terminated = done or truncated
# 智能体学习
agent.step(state, action, reward, next_state, terminated)
# 更新状态和得分
state = next_state
score += reward
if terminated:
break
# 记录得分和探索率
scores_window.append(score)
scores.append(score)
epsilons.append(agent.epsilon)
# 打印训练进度
if i_episode % 50 == 0:
mean_score = np.mean(scores_window)
print(f'\rEpisode {i_episode}\tAverage Score: {mean_score:.2f}\tEpsilon: {agent.epsilon:.3f}')
# 如果达到解决标准,保存模型
if mean_score >= solve_score:
print(f'\nEnvironment solved in {i_episode} episodes!')
torch.save(agent.qnetwork_local.state_dict(), 'checkpoint.pth')
break
return scores, epsilons
第四部分:训练与结果分析
4.1 开始训练
python
# 创建环境和智能体
env = gym.make('CartPole-v1')
state_size = env.observation_space.shape[0]
action_size = env.action_space.n
agent = DQNAgent(state_size, action_size)
# 开始训练
print("开始训练DQN智能体...")
scores, epsilons = train_dqn(env, agent, n_episodes=500)
4.2 可视化训练结果
python
def plot_training_results(scores, epsilons, window_size=100):
"""
绘制训练结果图表
"""
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10))
# 绘制得分曲线
ax1.plot(scores, alpha=0.6, label='Episode Score')
# 计算移动平均
moving_avg = []
for i in range(len(scores)):
if i < window_size:
moving_avg.append(np.mean(scores[:i+1]))
else:
moving_avg.append(np.mean(scores[i-window_size+1:i+1]))
ax1.plot(moving_avg, 'r-', linewidth=2, label=f'{window_size}-Episode Moving Average')
ax1.axhline(y=195, color='g', linestyle='--', label='Solved Threshold (195)')
ax1.set_xlabel('Episode')
ax1.set_ylabel('Score')
ax1.set_title('DQN Training Performance on CartPole-v1')
ax1.legend()
ax1.grid(True)
# 绘制探索率衰减曲线
ax2.plot(epsilons, 'b-', linewidth=2)
ax2.set_xlabel('Episode')
ax2.set_ylabel('Epsilon')
ax2.set_title('Exploration Rate Decay')
ax2.grid(True)
plt.tight_layout()
plt.show()
# 绘制训练结果
plot_training_results(scores, epsilons)
4.3 测试训练好的智能体
python
def test_agent(env, agent, n_episodes=10, render=True):
"""
测试训练好的智能体
"""
agent.epsilon = 0.0 # 测试时使用完全贪婪策略
test_scores = []
for i_episode in range(1, n_episodes + 1):
state, _ = env.reset()
score = 0
frames = [] # 用于存储渲染帧
for t in range(200): # CartPole最多200步
if render:
frames.append(env.render())
action = agent.act(state, training=False)
next_state, reward, done, truncated, _ = env.step(action)
terminated = done or truncated
state = next_state
score += reward
if terminated:
break
test_scores.append(score)
print(f'Test Episode {i_episode}: Score = {score}')
# 可选:保存第一个回合的动画
if i_episode == 1 and render and len(frames) > 0:
save_animation(frames, f"cartpole_episode_{i_episode}.gif")
print(f'\nAverage test score over {n_episodes} episodes: {np.mean(test_scores):.2f}')
print(f'Maximum test score: {np.max(test_scores)}')
print(f'Minimum test score: {np.min(test_scores)}')
return test_scores
def save_animation(frames, filename, fps=30):
"""
保存回合动画为GIF
"""
# 由于CSDN环境限制,这里仅展示代码框架
print(f"动画已保存至 {filename} (在本地环境中运行此代码生成动画)")
# 加载训练好的模型
agent.qnetwork_local.load_state_dict(torch.load('checkpoint.pth', map_location=device))
# 测试智能体
test_scores = test_agent(env, agent, n_episodes=10, render=True)
第五部分:深入分析与优化建议
5.1 训练过程分析
从训练曲线中,我们可以观察到DQN学习的几个典型阶段:
- 随机探索阶段(前50回合):智能体随机探索,得分较低且波动大
- 初步学习阶段(50-200回合):智能体开始学习到基本策略,得分逐渐上升
- 稳定提升阶段(200-300回合):智能体策略趋于稳定,得分接近或达到最优
- 收敛阶段(300回合后):智能体找到近似最优策略,得分稳定在195以上
5.2 关键超参数的影响
-
学习率(Learning Rate):
- 过高:训练不稳定,可能无法收敛
- 过低:学习速度慢,需要更多训练回合
- 建议:1e-3到1e-4之间
-
折扣因子(Gamma):
- 过高:过于重视未来奖励,可能导致学习困难
- 过低:过于重视即时奖励,可能陷入局部最优
- 建议:0.95-0.99之间
-
探索率衰减(Epsilon Decay):
- 过快:探索不充分,可能错过最优策略
- 过慢:学习效率低,收敛慢
- 建议:0.99-0.999之间
5.3 常见问题与调试技巧
-
训练不收敛:
- 检查梯度裁剪是否有效
- 降低学习率
- 增加目标网络更新频率
-
过拟合:
- 增加经验回放缓冲池大小
- 在神经网络中添加Dropout层
- 减少网络容量
-
探索不足:
- 增加初始探索率
- 减慢探索率衰减速度
- 尝试更复杂的探索策略(如噪声探索)
第六部分:DQN的扩展与改进
6.1 Double DQN实现
Double DQN解决了Q-learning中普遍存在的过估计问题:
python
class DoubleDQNAgent(DQNAgent):
"""
Double DQN:使用两个网络减少过估计
"""
def _learn(self):
# ... 前面的代码与DQN相同 ...
# 3. 计算当前Q值 Q(s, a)
q_local = self.qnetwork_local(state_batch)
q_current = q_local.gather(1, action_batch)
# 4. Double DQN: 使用本地网络选择动作,目标网络评估价值
with torch.no_grad():
# 使用本地网络选择下一状态的最佳动作
next_actions = self.qnetwork_local(next_state_batch).max(1)[1].unsqueeze(1)
# 使用目标网络评估这些动作的价值
q_target_next = self.qnetwork_target(next_state_batch).gather(1, next_actions)
q_target = reward_batch + (self.gamma * q_target_next * (1 - done_batch))
# ... 后续代码与DQN相同 ...
6.2 Dueling DQN架构
Dueling DQN将Q值分解为状态价值和优势函数:
python
class DuelingQNetwork(nn.Module):
"""
Dueling DQN:将Q值分解为状态价值和优势函数
"""
def __init__(self, state_size, action_size, hidden_size=128):
super(DuelingQNetwork, self).__init__()
# 共享的特征提取层
self.feature_layer = nn.Sequential(
nn.Linear(state_size, hidden_size),
nn.ReLU(),
nn.Linear(hidden_size, hidden_size),
nn.ReLU()
)
# 状态价值流
self.value_stream = nn.Sequential(
nn.Linear(hidden_size, hidden_size),
nn.ReLU(),
nn.Linear(hidden_size, 1)
)
# 优势流
self.advantage_stream = nn.Sequential(
nn.Linear(hidden_size, hidden_size),
nn.ReLU(),
nn.Linear(hidden_size, action_size)
)
def forward(self, state):
features = self.feature_layer(state)
value = self.value_stream(features)
advantages = self.advantage_stream(features)
# 合并价值和优势:Q(s,a) = V(s) + A(s,a) - mean(A(s,a))
q_values = value + (advantages - advantages.mean(dim=1, keepdim=True))
return q_values