把 NumPy DQN 迁移到 PyTorch

一直想要系统学习RL的相关知识,脑子里想着能不能完全依靠 Codex 进行学习,于是就有了下面的教程。

完整内容:https://github.com/lanheixingkong/rl-course

第六课用 NumPy 看懂 DQN已经用 NumPy 手写了一个最小 DQN。

那一课的目的不是追求工程效率,而是看清楚 DQN 的内部机制:


   
   
    
   
   online network
target network
replay buffer
epsilon-greedy
Q-learning target

第七课不换算法。

它要解决的是一个更接近真实项目的问题:


   
   
    
   
   为什么别人写的 DQN 代码通常是 PyTorch 版本?

真实深度学习项目很少手写反向传播。第六课手写的:


   
   
    
   
   forward
loss
gradient
weight update

在 PyTorch 里会变成:


   
   
    
   
   loss = F.mse_loss(predictions, targets)
optimizer.zero_grad()
loss.backward()
optimizer.step()

所以第七课要建立的核心理解是:


   
   
    
   
   算法没有变,工程实现变了。

1. 为什么要学 PyTorch DQN

第六课的 NumPy DQN 很适合学习机制,因为所有东西都摊开写:


   
   
    
   
   w1
b1
w2
b2
手动计算梯度
手动更新权重

但别人真实项目里的 DQN 往往不会这样写。

真实代码通常使用:


   
   
    
   
   torch.nn.Module
torch.optim.Adam
loss.backward()
torch.no_grad()
state_dict()
load_state_dict()

如果你只看过 NumPy 版,突然看到 PyTorch DQN 会有断层。

所以本课做一个桥接:


   
   
    
   
   第六课:看清 DQN 机制
第七课:看懂真实 PyTorch 写法

2. 本课案例

本课仍然使用第六课的 GridWorld。

这很重要,因为我们要控制变量:


   
   
    
   
   环境不变;
状态特征不变;
replay buffer 不变;
target 公式不变;
只把神经网络训练部分换成 PyTorch。

默认地图:


   
   
    
   
   5 x 5 GridWorld
start = (4,0)
goal = (0,4)
actions = U, R, D, L

环境和 PyTorch 的分工

在 RL 项目里,环境和神经网络训练是两件不同的事。

环境负责定义“世界如何反应”:


   
   
    
   
   reset()       -> 给 agent 一个初始 state
step(action) -> 接收 agent 的 action,返回 next_state、reward、done

所以,状态空间、动作规则、墙在哪里、终点在哪里、奖励怎么给、什么时候结束,都是环境负责的。

PyTorch 负责训练神经网络:


   
   
    
   
   QNetwork                   -> 用神经网络表示 Q(s,a)
torch.tensor               -> 把 Python / NumPy 数据变成 PyTorch 可计算的数据
F.mse_loss                 -> 计算预测值和 target 的差距
loss.backward()            -> 自动求梯度
optimizer.step()           -> 更新 online_network 的参数
state_dict/load_state_dict -> 保存或复制网络权重

本课的GridWorld 是我们自己写的环境。PyTorch 不知道墙、坑、终点这些规则,它只看到 replay buffer 里的一批批经验:


   
   
    
   
   (state, action, reward, next_state, done)

真实项目中,环境可能有三种来源:


   
   
    
   
   自己写的业务或仿真环境;
Gymnasium、MuJoCo、Atari、Isaac Gym 等库提供的环境;
真实系统,例如推荐系统、机器人、交易系统或调度系统。

只要环境能提供类似reset() /step(action) 的交互接口,DQN 就可以把交互得到的经验拿来训练网络。

3. 运行代码

如果还没有安装依赖,先运行:


   
   
    
   
   conda activate rl-course
uv pip install -r requirements.txt

然后运行:


   
   
    
   
   python lessons/07_pytorch_dqn/pytorch_dqn_gridworld.py

你会看到类似输出:


   
   
    
   
   PyTorch DQN GridWorld config:
  torch_version       : 2.12.1
  device              : cpu
  rows x cols         : 5 x 5
  episodes            : 400
  max_steps           : 80
  start_mode          : random
  hidden_dim          : 32
  learning_rate       : 0.01

episode   200 | avg return last 100:  0.748 | avg steps:   5.8 | success rate:  0.97 | avg loss:  0.00059
episode   400 | avg return last 100:  0.850 | avg steps:   4.8 | success rate:  1.00 | avg loss:  0.00035

Training summary:
  network parameters            : 1124
  success rate last 100   : 1.00

Greedy reachability:
  from start (4, 0): goal in 8 steps
  from all non-terminal states: 20/20 reach goal

4. 先读输出

大部分输出和第六课相同。

新增或更重要的字段:

字段
含义
torch_version
当前使用的 PyTorch 版本
device
网络和 tensor 放在哪里运行,默认是cpu
learning_rate
Adam 优化器的学习率
network parameters
PyTorch 网络中的可训练参数数量

注意:第七课的训练结果不必和第六课完全一致。

原因包括:


   
   
    
   
   PyTorch 默认初始化方式不同;
Adam 和第六课手写 SGD 不同;
浮点计算细节不同;
随机探索轨迹仍然会影响训练。

本课重点不是和 NumPy 版本逐行输出一致,而是看懂真实 PyTorch DQN 的实现结构。

5. 查看单步过程

运行:


   
   
    
   
   python lessons/07_pytorch_dqn/pytorch_dqn_gridworld.py --episodes 3 --debug-episodes 1 --log-every 0 --max-steps 15 --warmup-steps 5 --batch-size 4 --epsilon-decay-steps 100

你会看到:


   
   
    
   
   step  epsilon  state   action  reward  next    replay  loss

前几步loss 是-,因为 replay buffer 还没有足够经验。

经验足够后,代码从 replay buffer 随机抽 batch,然后用 PyTorch 更新online_network

6. 代码地图

打开:


   
   
    
   
   lessons/07_pytorch_dqn/pytorch_dqn_gridworld.py

按这个顺序读:


   
   
    
   
   QNetwork
choose_action
train_dqn
  -> collect transition
  -> sample replay batch
  -> online_network(batch_states)
  -> target_network(batch_next_states)
  -> F.mse_loss
  -> loss.backward()
  -> optimizer.step()
  -> target_network.load_state_dict(...)

你会发现,DQN 的结构仍然是第六课那套。

变化的是:


   
   
    
   
   第六课手写网络参数和梯度;
第七课交给 PyTorch 管理网络参数和梯度。

先建立一个 PyTorch 最小心智模型:


   
   
    
   
   tensor      = PyTorch 的数组,类似 NumPy array,但可以放到 GPU/MPS,并且可以记录梯度
nn.Module   = 神经网络模块,负责保存参数,并定义 forward 计算
forward     = 输入 tensor 如何变成输出 tensor
loss        = 一个标量,表示预测结果和训练目标差多少
backward    = 从 loss 反向计算每个参数的梯度
optimizer   = 根据梯度更新参数
no_grad     = 这段计算只做预测,不记录梯度,不训练网络

PyTorch 训练通常是这个固定流程:


   
   
    
   
   准备输入 tensor
network(input) 得到预测
用预测和 target 算 loss
zero_grad 清空旧梯度
backward 计算新梯度
step 更新参数

7. QNetwork:PyTorch 版神经网络

第六课手写权重:


   
   
    
   
   w1, b1, w2, b2

第七课用 PyTorch:


   
   
    
   
   class QNetwork(nn.Module):
    def
 __init__(self, input_dim, hidden_dim, output_dim):
        super
().__init__()
        self
.layers = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, output_dim),
        )

这段代码里有三个 PyTorch 基础概念:

代码
PyTorch 含义
在本课中的含义
nn.Module
所有 PyTorch 神经网络通常都继承它
QNetwork 成为一个可训练网络
super().__init__()
初始化nn.Module 内部机制
让 PyTorch 能追踪网络层和参数
nn.Sequential(...)
按顺序串起多层网络
输入 state 特征,依次经过 Linear、ReLU、Linear

nn.Linear(input_dim, hidden_dim) 会自动创建一组权重和偏置,相当于第六课手写的w1, b1

nn.Linear(hidden_dim, output_dim) 相当于第六课手写的w2, b2

nn.ReLU() 是激活函数,把负数变成 0,正数保持不变。它让网络不只是做线性加权。

forward() 定义输入如何变成输出:


   
   
    
   
   def forward(self, states):
    return
 self.layers(states)

在 PyTorch 中,通常不直接调用network.forward(states),而是写:


   
   
    
   
   q_values = network(states)

PyTorch 会自动调用forward()

输出仍然是 4 个 Q 值:


   
   
    
   
   Q(s,U)
Q(s,R)
Q(s,D)
Q(s,L)

所以本课不是改变 DQN 的学习对象。它仍然在估计:


   
   
    
   
   Q(s,a)

8. online_network、target_network 和 optimizer

核心代码:


   
   
    
   
   online_network = QNetwork(...).to(device)
target_network = QNetwork(...).to(device)
target_network.load_state_dict(online_network.state_dict())
target_network.eval()
optimizer = torch.optim.Adam(online_network.parameters(), lr=learning_rate)

逐个解释:

代码
含义
.to(device)
把网络参数放到 CPU/GPU/MPS
state_dict()
网络权重字典
load_state_dict(...)
复制网络权重
.eval()
把 target network 放到评估模式
online_network.parameters()
Adam 要更新的参数

device 是 PyTorch 的计算位置:


   
   
    
   
   cpu  -> 普通 CPU
cuda -> NVIDIA GPU
mps  -> Apple Silicon GPU

网络和输入 tensor 必须在同一个 device 上,否则 PyTorch 会报错。所以代码里既有:


   
   
    
   
   online_network = QNetwork(...).to(device)

也会在创建 batch tensor 时使用:


   
   
    
   
   torch.tensor(..., device=device)

target network 仍然不单独训练。它只是周期性复制 online network:


   
   
    
   
   target_network.load_state_dict(online_network.state_dict())

Adam 只接收:


   
   
    
   
   online_network.parameters()

所以优化器只会更新 online network。

9. 为什么要用 torch.no_grad(),以及如何组装 replay batch

选择动作时只需要用网络预测 Q 值,不需要训练。

代码写:


   
   
    
   
   with torch.no_grad():
    q_values = network(state_tensor)[0]

这里的state_tensor 是把一个状态特征转换成 PyTorch tensor:


   
   
    
   
   state_tensor = torch.tensor(..., dtype=torch.float32, device=device)

dtype=torch.float32 表示用 32 位浮点数。神经网络训练通常使用浮点数,而不是 Python 的普通整数或列表。

torch.no_grad() 的意思是:


   
   
    
   
   这段计算不要记录梯度。

这样更省内存,也避免 PyTorch 把这一步放进反向传播图里。

计算 target 前,先要从 replay batch 里组装训练数据。

每条 replay 经验都是:


   
   
    
   
   (state, action, reward, next_state, done)

但 PyTorch 不能直接训练 Python 对象列表,所以代码要先把这一批经验拆成多个 tensor。

动作 batch:


   
   
    
   
   batch_actions = torch.as_tensor(
    [transition.action for transition in batch],
    dtype=torch.int64,
    device=device,
).unsqueeze(1)

含义是:


   
   
    
   
   从 batch 中取出每条经验实际执行过的 action;
转换成 PyTorch tensor;
放到当前 device;
用 int64,因为 gather 取索引时需要整数索引;
unsqueeze(1) 把形状从 [batch_size] 变成 [batch_size, 1]。

奖励 batch:


   
   
    
   
   batch_rewards = torch.as_tensor(
    [transition.reward for transition in batch],
    dtype=torch.float32,
    device=device,
)

含义是:


   
   
    
   
   从 batch 中取出每条经验得到的 reward;
转换成 float32 tensor;
形状是 [batch_size]。

终止标记 batch:


   
   
    
   
   batch_dones = torch.as_tensor(
    [transition.done for transition in batch],
    dtype=torch.float32,
    device=device,
)

done 原本是布尔值。转换成float32 后:


   
   
    
   
   False -> 0.0
True  -> 1.0

这样后面可以用:


   
   
    
   
   1.0 - batch_dones

如果这一条经验已经到达终止状态,done = 1.0,未来价值就会被乘成 0。

接着用 online network 预测当前状态的 Q 值:


   
   
    
   
   q_values = online_network(batch_states)
predictions = q_values.gather(1, batch_actions).squeeze(1)

online_network(batch_states) 会一次性输出这一批状态下所有动作的 Q 值:


   
   
    
   
   q_values shape = [batch_size, 4]

4 对应四个动作:


   
   
    
   
   U, R, D, L

但每条 replay 经验当时只真的执行了一个动作,所以predictions 只取那一个动作对应的 Q 值。

假设一个 batch 有 3 条经验,网络输出可能长这样:


   
   
    
   
   q_values shape = [3, 4]

第 1 行:状态 1 的 U/R/D/L 四个 Q 值
第 2 行:状态 2 的 U/R/D/L 四个 Q 值
第 3 行:状态 3 的 U/R/D/L 四个 Q 值

batch_actions 记录每条经验当时实际执行的动作编号,例如:


   
   
    
   
   batch_actions = [[1], [0], [2]]

gather(1, batch_actions) 的意思是:沿着第 1 个维度,也就是动作维度,从每一行取出对应动作的 Q 值。

可以把它看成按行取列:


   
   
    
   
   q_values = [
  [Q(s1,U), Q(s1,R), Q(s1,D), Q(s1,L)],
  [Q(s2,U), Q(s2,R), Q(s2,D), Q(s2,L)],
  [Q(s3,U), Q(s3,R), Q(s3,D), Q(s3,L)],
]

batch_actions = [
  [1],  # 第 1 条经验执行了 R
  [0],  # 第 2 条经验执行了 U
  [2],  # 第 3 条经验执行了 D
]

q_values.gather(1, batch_actions) = [
  [Q(s1,R)],
  [Q(s2,U)],
  [Q(s3,D)],
]

这里的1 表示沿着第 1 维取值。因为q_values 的形状是[batch_size, action_count]


   
   
    
   
   第 0 维:batch 中第几条经验
第 1 维:动作编号

squeeze(1) 是把形状从[batch_size, 1] 压成[batch_size],方便和targets 计算 loss。

这一步得到的是:


   
   
    
   
   模型现在对 Q(state, action) 的预测值

后面要把它和 Q-learning target 做比较,计算 loss。

计算 target 时要用torch.no_grad()


   
   
    
   
   with torch.no_grad():
    next_q = target_network(batch_next_states)
    targets = batch_rewards + gamma * (1.0 - batch_dones) * next_q.max(dim=1).values

逐行解释:


   
   
    
   
   next_q = target_network(batch_next_states)

这会得到每个next_state 下四个动作的 Q 值:


   
   
    
   
   next_q shape = [batch_size, 4]

   
   
    
   
   next_q.max(dim=1).values

表示对每一行取最大值,也就是:


   
   
    
   
   max_a' Q(next_state, a')

这正是 Q-learning target 里的“下一状态最好动作价值”。

完整 target:


   
   
    
   
   targets = batch_rewards + gamma * (1.0 - batch_dones) * next_q.max(dim=1).values

对应:


   
   
    
   
   target = reward + gamma * max_a' Q(next_state, a')

如果done = 1.0,说明已经到达终点或失败终止状态,就没有未来价值:


   
   
    
   
   target = reward

原因是:target 是学习目标,不是要被训练的网络输出。

如果不加no_grad(),PyTorch 会把 target network 的计算也放进计算图,既浪费内存,也会让初学者误以为 target network 也要参与梯度更新。

10. PyTorch 如何训练 online network

核心训练代码:


   
   
    
   
   loss = F.mse_loss(predictions, targets)
optimizer.zero_grad()
loss.backward()
optimizer.step()

逐行看:

代码
含义
F.mse_loss(...)
计算预测 Q 值和 target 的平方误差
optimizer.zero_grad()
清空上一轮梯度
loss.backward()
自动反向传播,计算梯度
optimizer.step()
用 Adam 更新 online network 参数

为什么要zero_grad()

PyTorch 默认会把梯度累加到参数的.grad 上。如果不清空,上一次 batch 的梯度会混到这一次 batch 里。本课每次 replay batch 都希望独立计算一次梯度,所以训练前先清空。

loss.backward() 不会直接更新参数,它只计算梯度。

真正修改参数的是:


   
   
    
   
   optimizer.step()

这就是第七课和第六课最大的实现差异:


   
   
    
   
   第六课:手写梯度。
第七课:PyTorch 自动求梯度。

11. 参数实验

实验 1:保存模型


   
   
    
   
   python lessons/07_pytorch_dqn/pytorch_dqn_gridworld.py --save-model artifacts/lesson_07_pytorch_dqn.pt

保存的是:


   
   
    
   
   online_network.state_dict()

这是真实 PyTorch 项目中常见的模型保存方式。

如果命令是从项目根目录运行的,模型会保存在:


   
   
    
   
   artifacts/lesson_07_pytorch_dqn.pt

也就是完整路径:


   
   
    
   
   /Users/lei/development/deeplearning/rl-course/artifacts/lesson_07_pytorch_dqn.pt

注意:artifacts/lesson_07_pytorch_dqn.pt 是相对路径。如果你不是在项目根目录执行命令,文件会保存到你当时所在目录下面的artifacts/ 中。

可以用下面命令确认文件是否存在:


   
   
    
   
   ls -la artifacts

脚本成功保存时,最后还会输出类似:


   
   
    
   
   Saved model state_dict to: artifacts/lesson_07_pytorch_dqn.pt

实验 2:改学习率


   
   
    
   
   python lessons/07_pytorch_dqn/pytorch_dqn_gridworld.py --learning-rate 0.001
python lessons/07_pytorch_dqn/pytorch_dqn_gridworld.py --learning-rate 0.05

观察:


   
   
    
   
   loss 是否更平稳?
success rate 是否变慢或更波动?

实验 3:切换 device


   
   
    
   
   python lessons/07_pytorch_dqn/pytorch_dqn_gridworld.py --device auto

如果机器支持 MPS 或 CUDA,PyTorch 会把网络和 tensor 放到对应设备上。

小 GridWorld 不一定更快,但真实深度 RL 会依赖 GPU/MPS 加速。

12. 第七课和第六课的关系

位置
第六课 NumPy DQN
第七课 PyTorch DQN
网络定义
手写w1,b1,w2,b2
nn.Module
前向计算
手写矩阵乘法
forward()
loss
手写均方误差
F.mse_loss
反向传播
手写梯度
loss.backward()
参数更新
手写减法
optimizer.step()
target 同步
copy_from() load_state_dict()

算法结构不变:


   
   
    
   
   DQN = Q-learning target + replay buffer + target network + 神经网络拟合

13. 过关检查

学完本课后,你应该能回答:

  1. 1. 第七课和第六课的算法目标是否改变了?
  2. 2.nn.Module 在这里负责什么?
  3. 3.optimizer.zero_grad() 为什么每次训练前都要调用?
  4. 4.loss.backward() 做了什么?
  5. 5.optimizer.step() 更新的是哪个网络?
  6. 6. 为什么 target 计算要放在torch.no_grad() 里?
  7. 7.gather(1, batch_actions) 是在取什么?
  8. 8.state_dict() 和load_state_dict() 在 target network 同步中起什么作用?

14. 总结

第七课的重点不是发明新算法。

它建立的是这条映射:


   
   
    
   
   第六课手写 NumPy DQN
    -> 第七课 PyTorch DQN

算法仍然是:


   
   
    
   
   DQN = Q-learning target + replay buffer + target network + 神经网络拟合

变化的是工程实现:


   
   
    
   
   手写反向传播 -> PyTorch autograd
手写权重更新 -> Adam optimizer
手写参数复制 -> state_dict / load_state_dict

如果觉得内容不错,欢迎你点一下「在看」,或是将文章分享给其他有需要的人^^

相关好文推荐:

每次看见有人说能够识别出一段文字是不是AI生成的,我都忍不住想笑

飞书会取代微信吗?

AI 时代的软件与软件公司应该长什么样?

0条留言

留言