把 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 |
|
device |
cpu
|
learning_rate |
|
network parameters |
|
注意:第七课的训练结果不必和第六课完全一致。
原因包括:
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 基础概念:
|
|
|
|
|---|---|---|
nn.Module |
|
QNetwork 成为一个可训练网络
|
super().__init__() |
nn.Module 内部机制
|
|
nn.Sequential(...) |
|
|
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) |
|
state_dict() |
|
load_state_dict(...) |
|
.eval() |
|
online_network.parameters() |
|
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(...) |
|
optimizer.zero_grad() |
|
loss.backward() |
|
optimizer.step() |
|
为什么要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. 第七课和第六课的关系
|
|
|
|
|---|---|---|
|
|
w1,b1,w2,b2
|
nn.Module |
|
|
|
forward() |
|
|
|
F.mse_loss |
|
|
|
loss.backward() |
|
|
|
optimizer.step() |
|
|
copy_from() |
load_state_dict() |
算法结构不变:
DQN = Q-learning target + replay buffer + target network + 神经网络拟合
13. 过关检查
学完本课后,你应该能回答:
-
1. 第七课和第六课的算法目标是否改变了? -
2. nn.Module在这里负责什么? -
3. optimizer.zero_grad()为什么每次训练前都要调用? -
4. loss.backward()做了什么? -
5. optimizer.step()更新的是哪个网络? -
6. 为什么 target 计算要放在 torch.no_grad()里? -
7. gather(1, batch_actions)是在取什么? -
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
如果觉得内容不错,欢迎你点一下「在看」,或是将文章分享给其他有需要的人^^
相关好文推荐:

0条留言