DQN实战:用Python从零实现Q值计算(附完整代码)
DQN实战用Python从零实现Q值计算附完整代码最近几年深度强化学习从实验室的论文里走出来实实在在地解决了不少现实问题。从游戏AI到机器人控制再到推荐系统背后常常能看到一个经典算法的身影——DQN。很多朋友一上来就被“深度Q网络”这个名字唬住了觉得它高深莫测。其实剥开神经网络的外壳它的核心思想非常直观学会给每个“状态-动作”对打分。这个分数就是Q值。今天我们不谈复杂的数学推导也不空谈理论就从一个最朴素的起点开始如何用Python从零开始亲手计算出这个至关重要的Q值。这篇文章是为那些已经熟悉Python基础语法对强化学习充满好奇但又被各种公式和框架搞得晕头转向的实践者准备的。我们将一步步搭建一个最简化的环境用最清晰的代码把Q值计算的全过程摊开在你面前。你会发现理解它远比想象中简单。1. 环境搭建与核心概念具象化在动手写代码之前我们需要先创造一个极简的“世界”让我们的智能体在里面学习和行动。同时我们必须把几个核心术语从抽象的符号变成你代码里实实在在的变量。1.1 设计一个微型网格世界我们不用复杂的游戏环境自己定义一个最简单的网格世界。假设有一个3x3的网格智能体从左上角(0,0)出发目标是到达右下角的宝藏(2,2)。每次移动会消耗一点体力负奖励到达宝藏获得大奖正奖励撞墙则留在原地并受到惩罚。import numpy as np class MiniGridWorld: def __init__(self): self.grid_size 3 self.state (0, 0) # 起始状态 self.goal (2, 2) # 目标状态 self.actions [up, down, left, right] # 动作空间 self.action_map {up: (-1, 0), down: (1, 0), left: (0, -1), right: (0, 1)} def reset(self): 重置环境到起始状态 self.state (0, 0) return self.state def step(self, action): 执行一个动作返回新状态、奖励和是否结束 move self.action_map[action] new_state (self.state[0] move[0], self.state[1] move[1]) # 边界检查撞墙则留在原地 if not (0 new_state[0] self.grid_size and 0 new_state[1] self.grid_size): new_state self.state reward -1.0 # 撞墙惩罚 else: self.state new_state # 奖励函数设计 if new_state self.goal: reward 10.0 # 到达目标大奖励 else: reward -0.1 # 每走一步小惩罚鼓励快速到达 done (new_state self.goal) # 是否到达终点 return new_state, reward, done def get_state_index(self, state): 将坐标状态转换为一个唯一的索引便于Q表存储 return state[0] * self.grid_size state[1]这个环境类虽然简单但包含了强化学习环境的几个核心要素状态、动作、状态转移和奖励。reward的设计是门艺术这里我们采用了一种常见策略用小的负奖励-0.1鼓励智能体尽快找到终点避免无效徘徊用大的正奖励10明确目标用较大的负奖励-1阻止撞墙。1.2 Q值到底是什么从表格到大脑在传统Q学习中Q值被存储在一个巨大的表格里行是状态列是动作。对于我们的3x3网格9个状态和4个动作Q表就是一个9x4的矩阵。Q(s, a) 这个数字代表的是在状态s下选择动作a并且从此以后都采取最优策略所能获得的“未来总收益的期望值”。它不是一个立即的奖励而是包含了长远眼光的估值。理解Q值的一个关键比喻它不像你打工一天赚的日薪即时奖励而更像你选择一份职业后未来几十年职业生涯总收入的预估长期回报。折扣因子γ就是你对未来收入的“折现率”越远的钱现在看来越不值钱。当状态空间变得巨大比如游戏的一帧图像有上百万种像素组合时Q表就存不下了。这就是DQN的用武之地用一个深度神经网络来拟合这个Q表。你可以把这个神经网络想象成一个极其复杂的函数逼近器输入是状态s比如图像像素它直接输出所有动作a对应的Q值。这个网络的训练目标就是让它的输出越来越接近我们通过经验估算出来的“真实”Q值。2. 传统Q学习的表格法实现在引入神经网络之前我们先用手算和表格的方式把Q学习的更新过程彻底搞明白。这是理解一切的基础。2.1 初始化与贝尔曼方程的手动迭代我们先初始化Q表所有值设为0代表智能体一开始对这个世界一无所知认为所有动作价值都一样。def initialize_q_table(env): 初始化Q表状态数 x 动作数 n_states env.grid_size * env.grid_size n_actions len(env.actions) return np.zeros((n_states, n_actions)) # 创建环境和Q表 env MiniGridWorld() q_table initialize_q_table(env) print(初始Q表全零) print(q_table)接下来是核心的Q值更新也就是贝尔曼方程的代码实现。这个公式是强化学习的灵魂Q(s, a) Q(s, a) α * [ r γ * max_a Q(s, a) - Q(s, a) ]我们来拆解一下r γ * max_a Q(s, a)这是“目标值”。它由两部分组成立即获得的奖励r加上折扣后的、在下一个状态s下能获得的最佳未来收益max_a Q(s, a)。Q(s, a)这是“当前估计值”。两者的差(目标值 - 当前估计值)就是时序差分误差。我们沿着减少这个误差的方向以学习率α的步长更新当前的Q值。def update_q_table(env, q_table, state, action, reward, next_state, alpha0.1, gamma0.9): 使用Q学习更新规则更新Q表 Args: alpha: 学习率控制更新步长 gamma: 折扣因子衡量未来奖励的重要性 state_idx env.get_state_index(state) next_state_idx env.get_state_index(next_state) action_idx env.actions.index(action) # 当前Q值 current_q q_table[state_idx, action_idx] # 下一个状态的最大Q值最优未来收益 max_future_q np.max(q_table[next_state_idx, :]) # 目标Q值 target_q reward gamma * max_future_q # Q值更新 new_q current_q alpha * (target_q - current_q) q_table[state_idx, action_idx] new_q return q_table, target_q - current_q # 返回更新后的表和TD误差2.2 探索与利用的平衡策略智能体不能一直用当前认为最好的动作利用否则可能永远发现不了更好的策略也不能一直随机乱动探索那样学不到东西。我们需要一个策略来平衡二者最经典的是ε-贪婪策略。def epsilon_greedy_policy(state, q_table, env, epsilon0.1): ε-贪婪策略选择动作 以 (1-ε) 的概率选择当前最优动作利用以 ε 的概率随机选择动作探索 state_idx env.get_state_index(state) if np.random.random() epsilon: # 探索随机选择一个动作 action np.random.choice(env.actions) else: # 利用选择当前状态下Q值最大的动作 # 如果多个动作Q值相同随机选一个 max_q np.max(q_table[state_idx, :]) # 获取所有具有最大Q值的动作索引 max_action_indices np.where(q_table[state_idx, :] max_q)[0] chosen_idx np.random.choice(max_action_indices) action env.actions[chosen_idx] return actionepsilon参数控制着探索的强度。通常在训练初期设置得较高如0.5让智能体多探索随着训练进行逐渐降低如衰减到0.01让智能体更多地利用学到的知识。3. 从Q表到深度Q网络DQN的飞跃表格法在状态空间小时工作良好但它的局限性显而易见。我们的网格世界只有9个状态如果是一个经典的Atari游戏《Breakout》一帧图像有210x160x3100,800个像素点每个像素256种取值状态数量是256的100800次方这是一个天文数字任何计算机都无法存储对应的Q表。这就是维度灾难。深度Q网络DQN用神经网络参数θ来近似Q函数Q(s, a; θ) ≈ Q*(s, a)。神经网络的强大之处在于它能从高维原始输入如图像中自动提取特征并泛化到未见过的相似状态。3.1 搭建Q网络模型我们使用PyTorch来构建一个简单的全连接网络。对于我们的网格世界状态用坐标表示所以输入是2维输出是4维对应四个动作的Q值。import torch import torch.nn as nn import torch.optim as optim class QNetwork(nn.Module): 一个简单的全连接Q网络 def __init__(self, input_dim, output_dim, hidden_dim64): super(QNetwork, self).__init__() self.fc1 nn.Linear(input_dim, hidden_dim) self.fc2 nn.Linear(hidden_dim, hidden_dim) self.fc3 nn.Linear(hidden_dim, output_dim) self.relu nn.ReLU() def forward(self, x): x self.relu(self.fc1(x)) x self.relu(self.fc2(x)) x self.fc3(x) # 输出层不需要激活函数 return x # 实例化网络 # 输入状态坐标 (x, y)我们将其归一化到[0,1]范围 input_dim 2 output_dim len(env.actions) # 4个动作 q_net QNetwork(input_dim, output_dim) print(q_net)这个网络结构非常基础但对于我们理解原理足够了。在实际的Atari游戏DQN中输入是经过处理的图像网络前端会是卷积层CNN来提取视觉特征。3.2 经验回放与目标网络DQN稳定的两大支柱直接使用神经网络进行在线Q学习会非常不稳定主要有两个原因1连续样本之间存在强相关性2更新目标Q值随着网络本身不断变化。DQN论文提出了两个革命性的技巧来解决它们。经验回放智能体将每一步的经历(s, a, r, s, done)存储到一个固定大小的“记忆库”中。训练时随机从库中抽取一小批mini-batch经验来进行学习。这样做打破了数据间的连续性使得样本更像独立同分布大大提高了训练的稳定性和数据效率。from collections import deque import random class ReplayBuffer: 经验回放缓冲区 def __init__(self, capacity): self.buffer deque(maxlencapacity) def push(self, state, action, reward, next_state, done): 保存一条经验 # 将状态和动作转换为可存储形式例如索引 action_idx env.actions.index(action) experience (state, action_idx, reward, next_state, done) self.buffer.append(experience) def sample(self, batch_size): 随机采样一批经验 if len(self.buffer) batch_size: return None batch random.sample(self.buffer, batch_size) # 解包并转换为适合PyTorch处理的格式 states, actions, rewards, next_states, dones zip(*batch) return states, actions, rewards, next_states, dones def __len__(self): return len(self.buffer)固定Q目标使用两个结构相同但参数不同的网络。一个叫在线网络用于选择动作和评估当前Q值另一个叫目标网络用于计算更新目标r γ * max_a Q_target(s, a)。目标网络的参数每隔一定步数如C步才从在线网络复制过来其余时间保持固定。这相当于给移动的目标按下了“暂停键”让学习过程更平稳。# 初始化在线网络和目标网络 online_net QNetwork(input_dim, output_dim) target_net QNetwork(input_dim, output_dim) # 初始时目标网络参数与在线网络同步 target_net.load_state_dict(online_net.state_dict()) target_net.eval() # 目标网络设置为评估模式不计算梯度 # 定义优化器 optimizer optim.Adam(online_net.parameters(), lr0.001) loss_fn nn.MSELoss() # 使用均方误差损失4. 完整的DQN训练循环与代码实现现在我们把所有组件组装起来形成一个完整的训练流程。这个过程清晰地展示了Q值是如何通过神经网络被学习和更新的。4.1 单步训练函数详解这是整个DQN算法最核心的一步包含了从经验采样、计算目标Q值、计算损失到反向传播的全过程。def train_step(buffer, online_net, target_net, optimizer, loss_fn, batch_size32, gamma0.99): 从经验回放中采样一批数据训练在线网络一次 if len(buffer) batch_size: return None # 1. 采样 batch buffer.sample(batch_size) if batch is None: return None states, action_idxs, rewards, next_states, dones batch # 2. 转换为Tensor # 状态归一化将坐标(x,y)从[0,2]映射到[0,1] state_tensor torch.FloatTensor([[(s[0]/2.0), (s[1]/2.0)] for s in states]) action_tensor torch.LongTensor(action_idxs).unsqueeze(1) # 形状从[batch]变为[batch, 1] reward_tensor torch.FloatTensor(rewards) next_state_tensor torch.FloatTensor([[(ns[0]/2.0), (ns[1]/2.0)] for ns in next_states]) done_tensor torch.FloatTensor(dones) # 3. 计算当前Q值 (Q_online) # online_net(state_tensor) 输出形状: [batch_size, n_actions] # gather(1, action_tensor) 根据action_tensor的索引取出对应动作的Q值形状: [batch_size, 1] current_q_values online_net(state_tensor).gather(1, action_tensor).squeeze() # 4. 计算目标Q值 with torch.no_grad(): # 目标网络不计算梯度 # 下一个状态的最大Q值 (由目标网络计算) next_q_values target_net(next_state_tensor).max(1)[0] # max(1)[0]取每行的最大值 # 如果下一个状态是终止状态(doneTrue)则没有未来的奖励 target_q_values reward_tensor gamma * next_q_values * (1 - done_tensor) # 5. 计算损失 (MSE Loss) loss loss_fn(current_q_values, target_q_values) # 6. 反向传播与优化 optimizer.zero_grad() loss.backward() # 可选梯度裁剪防止梯度爆炸 torch.nn.utils.clip_grad_norm_(online_net.parameters(), max_norm1.0) optimizer.step() return loss.item()这个函数里有几个关键点值得深入理解gather操作这是PyTorch中根据索引从张量收集值的操作。因为我们只需要智能体实际执行的那个动作a对应的Q值而不是所有动作的Q值。with torch.no_grad()在计算目标Q值时我们不需要对目标网络的参数求梯度因为它只是提供一个相对固定的“目标标签”。(1 - done_tensor)这是一个巧妙的处理。当doneTrue时next_state是终止状态没有未来奖励所以target_q reward。4.2 主训练循环与超参数调校下面我们把环境交互、经验存储、网络训练和策略更新整合到一个完整的训练循环中。超参数的选择对训练成功至关重要。def train_dqn(env, episodes500, max_steps100, epsilon_start1.0, epsilon_end0.01, epsilon_decay0.995, buffer_capacity10000, batch_size32, target_update_freq10, gamma0.99, lr0.001): 完整的DQN训练主函数 Args: episodes: 训练轮数 max_steps: 每轮最大步数 epsilon_start/end/decay: ε-贪婪策略的衰减参数 target_update_freq: 目标网络更新频率每隔多少步同步一次 # 初始化 online_net QNetwork(2, 4) target_net QNetwork(2, 4) target_net.load_state_dict(online_net.state_dict()) target_net.eval() optimizer optim.Adam(online_net.parameters(), lrlr) loss_fn nn.MSELoss() buffer ReplayBuffer(buffer_capacity) epsilon epsilon_start total_steps 0 episode_rewards [] for episode in range(episodes): state env.reset() episode_reward 0 done False step 0 while not done and step max_steps: # 1. 选择动作 (ε-贪婪策略) # 注意这里需要将状态转换为网络输入格式 state_tensor torch.FloatTensor([state[0]/2.0, state[1]/2.0]).unsqueeze(0) if np.random.random() epsilon: action np.random.choice(env.actions) # 探索 else: with torch.no_grad(): q_values online_net(state_tensor) action_idx q_values.argmax().item() action env.actions[action_idx] # 利用 # 2. 执行动作与环境交互 next_state, reward, done env.step(action) episode_reward reward # 3. 存储经验 buffer.push(state, action, reward, next_state, done) # 4. 状态转移 state next_state step 1 total_steps 1 # 5. 训练网络 (如果经验池足够) if len(buffer) batch_size: loss train_step(buffer, online_net, target_net, optimizer, loss_fn, batch_size, gamma) # 可以在这里记录损失用于监控 # 6. 定期更新目标网络参数 if total_steps % target_update_freq 0: target_net.load_state_dict(online_net.state_dict()) # 每轮结束后衰减ε epsilon max(epsilon_end, epsilon * epsilon_decay) episode_rewards.append(episode_reward) # 每50轮输出一次训练进度 if (episode 1) % 50 0: avg_reward np.mean(episode_rewards[-50:]) print(fEpisode {episode1}, Avg Reward (last 50): {avg_reward:.2f}, Epsilon: {epsilon:.3f}) return online_net, episode_rewards运行这个训练函数你会看到智能体在早期由于高epsilon值奖励可能很低经常撞墙或绕远路。随着训练进行epsilon降低智能体开始利用学到的策略平均奖励会逐渐上升并稳定在一个较高的正值这意味着它找到了通往宝藏的较优路径。4.3 可视化与调试看看你的智能体学到了什么训练完成后我们如何知道网络学到了正确的Q值呢一个直观的方法是可视化Q值。对于我们的2D网格世界我们可以让训练好的网络为每个状态下的每个动作预测Q值并绘制出来。import matplotlib.pyplot as plt def visualize_q_values(net, env): 可视化网格世界中每个状态、每个动作的Q值 fig, axes plt.subplots(2, 2, figsize(10, 10)) action_names [Up, Down, Left, Right] for i, action in enumerate(env.actions): ax axes[i//2, i%2] q_map np.zeros((env.grid_size, env.grid_size)) # 遍历所有状态 for x in range(env.grid_size): for y in range(env.grid_size): state_tensor torch.FloatTensor([[x/2.0, y/2.0]]) with torch.no_grad(): q_values net(state_tensor) q_map[x, y] q_values[0, i].item() # 第i个动作的Q值 # 绘制热力图 im ax.imshow(q_map, cmaphot, interpolationnearest) ax.set_title(fQ-values for Action: {action_names[i]}) ax.set_xticks(range(env.grid_size)) ax.set_yticks(range(env.grid_size)) # 在每个格子中显示Q值 for x in range(env.grid_size): for y in range(env.grid_size): text ax.text(y, x, f{q_map[x, y]:.1f}, hacenter, vacenter, colorblue) fig.colorbar(im, axax) plt.tight_layout() plt.show() # 假设我们已经训练好了网络 trained_net # visualize_q_values(trained_net, env)一个训练良好的网络其Q值图会呈现出清晰的模式。例如在靠近目标(2,2)的状态下“向右”和“向下”动作的Q值会明显高于“向左”和“向上”。在边缘状态指向墙外的动作Q值会很低。通过观察这些图你可以直观地判断训练是否成功以及策略是否符合预期。另一个重要的调试工具是绘制训练曲线观察奖励和损失的变化趋势。def plot_training_progress(rewards_history, window50): 绘制训练过程中的奖励变化 plt.figure(figsize(12, 5)) plt.subplot(1, 2, 1) plt.plot(rewards_history, alpha0.6, labelEpisode Reward) # 计算移动平均使曲线更平滑 moving_avg np.convolve(rewards_history, np.ones(window)/window, modevalid) plt.plot(range(window-1, len(rewards_history)), moving_avg, r-, linewidth2, labelfMoving Avg (window{window})) plt.xlabel(Episode) plt.ylabel(Total Reward) plt.title(Training Progress: Reward per Episode) plt.legend() plt.grid(True, alpha0.3) plt.subplot(1, 2, 2) # 绘制最后100轮的奖励分布直方图 last_n 100 if len(rewards_history) last_n: plt.hist(rewards_history[-last_n:], bins20, edgecolorblack, alpha0.7) plt.xlabel(Total Reward) plt.ylabel(Frequency) plt.title(fDistribution of Rewards (Last {last_n} Episodes)) plt.grid(True, alpha0.3) plt.tight_layout() plt.show()如果训练曲线呈现上升并最终稳定在高位说明学习是有效的。如果奖励波动剧烈或没有上升趋势可能需要调整学习率、探索率epsilon或网络结构。5. 常见陷阱、调试技巧与进阶思考即使按照上面的代码一步步实现你也很可能在第一次运行时得不到理想的结果。深度强化学习以其训练不稳定而“臭名昭著”。下面是一些我实践中总结的常见问题和解决思路。5.1 训练不收敛或策略糟糕问题表现奖励曲线不上涨智能体学不到有效策略或者策略时好时坏。检查1奖励函数设计。这是最容易出问题的地方。奖励是智能体学习的唯一“指挥棒”。确保你的奖励信号是清晰、合理且可学习的。在我们的网格例子中如果只设置到达终点有10奖励其他动作奖励为0智能体可能因为探索不到终点而永远学不到东西。加入每步小惩罚-0.1提供了持续的梯度信号。检查2学习率与折扣因子。学习率α太大可能导致震荡太小则学习缓慢。通常从0.001或0.0001开始尝试。折扣因子γ决定了智能体的“远见”程度。对于回合制任务如到达终点γ可以接近1如0.99对于需要快速决策的连续任务γ可以小一些如0.9。检查3探索率衰减。初始epsilon太高如1.0会导致纯随机探索学习缓慢衰减太快可能导致智能体过早陷入局部最优。一个常见的策略是线性衰减或指数衰减确保在训练中期有足够的探索。调试小技巧在训练初期打印出智能体选择的动作和接收的奖励。如果它一直在重复几个无效动作或者奖励始终为负且没有改善那么问题很可能出在探索策略或奖励函数上。5.2 超参数敏感性对比DQN对超参数相当敏感。下面这个表格对比了几组常见配置可能带来的影响你可以根据训练现象进行针对性调整。超参数设置过高可能导致设置过低可能导致推荐起始值/策略学习率 (lr)训练不稳定、震荡、发散学习速度极慢、收敛困难1e-3 到 1e-4使用Adam优化器时通常更稳健折扣因子 (γ)智能体过于“远视”忽视近期奖励智能体变得“短视”只追求即时奖励回合制任务0.99连续控制0.9 - 0.95探索率初始值 (ε_start)初期完全随机学习效率低下初期缺乏探索易陷入局部最优1.0完全随机开始探索率衰减 (ε_decay)探索过快结束策略可能不是最优探索持续太久收敛速度慢指数衰减每轮乘以0.995直到ε_end回放缓冲区大小占用内存多旧经验可能过时样本相关性高训练不稳定1e4 到 1e6取决于任务复杂度批次大小 (batch_size)更新方差小但计算慢可能过拟合更新噪声大不稳定32, 64, 128常用32或64目标网络更新频率目标变化快训练不稳定目标过于陈旧学习到错误信息每隔1000到10000步同步一次或使用软更新5.3 从网格世界走向更复杂的环境当你成功让智能体在微型网格世界中找到宝藏后就可以挑战更复杂的环境了。例如OpenAI Gym库提供了大量标准测试环境。# 示例在CartPole倒立摆环境中应用DQN的思路 import gym env_gym gym.make(CartPole-v1) state_dim env_gym.observation_space.shape[0] # 状态维度变为4 action_dim env_gym.action_space.n # 动作维度变为2 # 网络需要相应调整输入输出维度 q_net_cartpole QNetwork(input_dimstate_dim, output_dimaction_dim, hidden_dim128)对于像Atari游戏这样的图像输入你需要将全连接网络替换为卷积神经网络CNN并在输入前对图像进行预处理灰度化、缩放、帧堆叠等。这时经验回放和目标网络的重要性会更加凸显。最后别忘了保存和加载训练好的模型。你可以用torch.save和torch.load来保存网络参数这样就不需要每次从头训练。# 保存模型 def save_model(model, pathdqn_model.pth): torch.save(model.state_dict(), path) print(fModel saved to {path}) # 加载模型 def load_model(model, pathdqn_model.pth): model.load_state_dict(torch.load(path)) model.eval() print(fModel loaded from {path}) return model亲手实现一遍DQN的Q值计算最大的收获不是代码本身而是对那个看似神秘的贝尔曼方程有了血肉般的理解。你看到的不再是公式而是current_q、target_q和loss之间具体的数值流动。下次当你读到Double DQN、Dueling DQN、Prioritized Experience Replay这些改进算法时你会立刻明白它们是在解决我们今天遇到的哪个具体问题。从这个最简单的网格世界出发你已经掌握了打开深度强化学习大门的钥匙。