01 / 为什么现在学习框架?
数学没有换,组织计算的工具换了
torch.Tensor
数值数组,附带形状、类型、设备和梯度相关信息。
nn.Module
组织网络层与可训练参数;调用模型执行前向计算。
autograd 与 optim
前者根据计算图求梯度,后者按梯度更新参数。
项目环境:uv 管理 Python,默认缓存复用
# 当前项目已经使用 uv 管理的 Python,并配置 PyTorch CPU 源。
uv sync
uv run python lessons/lesson-06-pytorch.py项目的 .python-version记录 Python 3.12,pyproject.toml记录依赖与 CPU 源,uv.lock记录解析版本。执行 uv run 使用项目环境,无需手动激活。使用已有默认 uv 缓存,权限不足时申请权限。
环境配置参考 uv 的 PyTorch 集成指南。
02 / 先连接熟悉的数组
Tensor 仍有 shape,但还要看 dtype 与 device
import torch
X = torch.tensor([[1., 2.], [3., 4.]], dtype=torch.float32)
print(X.shape) # torch.Size([2, 2])
print(X.dtype) # torch.float32
print(X.device) # cpu
print(X.requires_grad) # False浮点数用于本节网络计算;整数张量也存在,例如分类类别编号常用整数。需要梯度的参数在本节使用浮点类型;不能把整数参数设成 requires_grad=True。输入 X 和真实 y 通常不要求求导,但参数要求。
from_numpy 共享内存,tensor 创建副本
NumPy 数组 a
PyTorch 张量 t(模拟)
import numpy as np
a = np.array([1., 2., 3.], dtype=np.float32)
t = torch.from_numpy(a) # CPU 张量,保留 NumPy dtype,共享内存
copy = torch.tensor(a) # 独立副本
# 要把求导中的张量转成 NumPy,先分离计算图,再移到 CPU。
array = t.detach().cpu().numpy()
# 若还需要独立内存:array = t.detach().cpu().numpy().copy()detach()分离梯度追踪,但不等于复制数据。兼容条件下 .numpy()也会共享 CPU 内存;不要把它们当成独立快照。NumPy 的 float64 和默认 float32 层混用,可能造成类型不匹配;本节显式使用 float32,数值梯度检查单独用 float64。
接口与共享内存说明见 PyTorch Tensor 教程。
03 / 一层网络就是你熟悉的矩阵计算
nn.Linear:注意 weight 的两个轴
nn.Linear(in_features, out_features)表示每条输入有多少特征、每条输出有多少数。PyTorch 把 weight存成 (输出数, 输入数),因此前向计算是 X @ weight.T + bias。
实验 A:逐个输出检查点积
输入 X
weight(每行一个输出)
输出 Y:点击一个值
layer = torch.nn.Linear(2, 3)
Y = layer(X) # X.shape=(B,2),Y.shape=(B,3)
print(layer.weight.shape) # (3,2)
print(layer.bias.shape) # (3,)调用 layer(X)就是前向计算,不会自动训练。层的参数默认需要梯度,并出现在 layer.parameters()中。本图使用固定权重方便手算,真实创建层时通常随机初始化。
公式与权重形状见 nn.Linear 官方接口。
04 / 自动求导,也要理解它求了什么
backward 求梯度,step 才改变参数
回到一条样本:x=2、y=5,初始 w=1、b=0。计算 ŷ=2w+b,损失 L=(ŷ−5)²。前向运行时,PyTorch 对需要求导的计算追踪运算关系;反向沿关系使用链式法则。
实验 B:亲手执行四个操作
- 前向 → backward:观察 w、b 没变,只出现 .grad。
- 再前向一次 → 再 backward:不清空梯度,会从 -12/-6 累加到 -24/-12。
- 清空梯度 → 新前向 → backward → step:w、b 才按梯度更新。
X = torch.tensor([[2.]], dtype=torch.float32)
y = torch.tensor([[5.]], dtype=torch.float32)
model = torch.nn.Linear(1, 1)
with torch.no_grad():
model.weight.fill_(1)
model.bias.zero_()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
optimizer.zero_grad(set_to_none=True)
pred = model(X)
loss = torch.nn.functional.mse_loss(pred, y)
loss.backward()
print(model.weight.grad, model.bias.grad)
optimizer.step()set_to_none=True让梯度变成 None,下一次反向再写入。默认累加可以用于有意的梯度积累;本节每个独立批次更新前先清空,避免无意混入上批梯度。
计算图、leaf 和 requires_grad 到底对应什么?
直接创建且需要梯度的 w、b 是这里的叶子张量;中间预测与 loss 是运算结果。默认把梯度放在需要梯度的叶子参数的 .grad里。中间张量想保留梯度需另行 retain_grad()。
一次前向生成一条计算图。默认 backward 后通常释放反向所需的中间数据,不能拿同一个 loss 随意反向第二次。本实验的第二次 backward 前先重新前向,建立新图;梯度缓冲却会继续累加。常规训练每批新建前向图即可,不需要 retain_graph=True。
loss 是标量,本节可直接 loss.backward()。不能随意把训练中的预测 detach 后再算 loss,否则会切断所需的梯度路径。
行为核对参考 PyTorch 自动求导教程与 Autograd 机制。
05 / 把第二节的网络写成 Module
还是同一个 tanh 小网络
| 参数 | 初始值 | 手推梯度预期 | PyTorch 对应属性 | 更新后值 |
|---|
浏览器计算手推预期值;配套 Python 实际调用 autograd,并与手推和数值差分核对。
class TinyNetwork(torch.nn.Module):
def __init__(self):
super().__init__()
self.hidden = torch.nn.Linear(1, 1)
self.output = torch.nn.Linear(1, 1)
def forward(self, x):
h = torch.tanh(self.hidden(x))
return self.output(h)
model = TinyNetwork()
X = torch.tensor([[2.]], dtype=torch.float32)
y = torch.tensor([[1.]], dtype=torch.float32)
pred = model(X) # 调用 forward
loss = torch.nn.functional.mse_loss(pred, y)
loss.backward() # 四组参数的梯度由框架计算super().__init__()初始化 Module 的组织机制;把层赋给 self.hidden 和 self.output 后,它们的参数就能被 model.parameters()与 state_dict()找到。PyTorch 不需要你在 forward 中再手写反向公式。
上面是结构写法,参数随机初始化。配套文件的构造器另用 no_grad 设定 .5、0、1、0,以复现第二节的数值。
06 / 从单条记录到一批记录
Dataset 存样本,DataLoader 组织取样
TensorDataset(X,y)把对应的输入与标签配对。DataLoader按批大小取出成批的张量,可在每轮训练前洗牌。配对取样保证输入和标签仍然对应。
from torch.utils.data import TensorDataset, DataLoader
X = torch.tensor([[-2.], [-1.], [0.], [1.], [2.], [3.]])
y = 2*X + 1
dataset = TensorDataset(X, y)
loader = DataLoader(dataset, batch_size=4, shuffle=True,
drop_last=False, num_workers=0)
for batch_x, batch_y in loader:
print(batch_x.shape, batch_y.shape)实验 C:6 条样本能组成几批?
选择 batch_size=4:不丢尾批时得到 4+2,输入形状分别是 (4,1)、(2,1);丢尾批时只得到第一批 4 个样本。本例总样本数 6 不变,但这一轮参与训练的数量可能变化。
浏览器用固定伪随机洗牌,PyTorch 使用 torch.Generator,顺序可能不同;样本分组、尾批与形状规则一致。修改本区设置会重置下方训练实验。
07 / 把接口连成训练循环
逐行执行:清空 → 前向 → 反向 → 更新
使用上方 6 个点:y=2x+1,初始 w=b=0。浏览器用均方误差的解析梯度模拟 SGD。当前批次在上方高亮;一次更新要走完四个步骤。
optimizer.zero_grad(set_to_none=True)pred = model(batch_x); loss = loss_fn(pred, batch_y)loss.backward()optimizer.step()曲线是每次更新后重新计算的完整训练 MSE,不是跨批次在线损失的平均;这里只讲流程,不把它称作泛化成绩。配套 Python 另外报告验证 MSE。
真实完整训练代码长什么样?
device = torch.device("cpu")
epochs = 80
X_val = torch.tensor([[-1.5], [-0.5], [0.5], [1.5], [2.5]])
y_val = 2*X_val + 1
# loader 使用上一部分准备的 6 个训练样本。
model = torch.nn.Linear(1, 1).to(device)
loss_fn = torch.nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)
for epoch in range(epochs):
model.train()
for batch_x, batch_y in loader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
optimizer.zero_grad(set_to_none=True)
pred = model(batch_x)
assert pred.shape == batch_y.shape
loss = loss_fn(pred, batch_y)
loss.backward()
optimizer.step()
model.eval()
with torch.no_grad():
val_loss = loss_fn(model(X_val.to(device)), y_val.to(device))两个样本的形状错误,具体会算成什么?
08 / 两种状态,分别控制两件事
eval 与 no_grad 不能互相替代
控制层的训练模式,例如 Dropout 和 BatchNorm 的行为。本节的单独 Linear 没有这些模式差异,但仍采用完整习惯。
暂时不记录梯度计算图,常用于验证和推理;不自动切换层的训练模式。
训练时对需要更新的参数记录梯度。验证和推理时通常同时 model.eval()与 with torch.no_grad():。不要把训练前向放在 no_grad 里,再期待 loss.backward() 为参数求导。
CPU 与 GPU:模型、输入、标签要配套
device = torch.device("cpu") # 当前项目使用 CPU 版
model = model.to(device)
batch_x = batch_x.to(device)
batch_y = batch_y.to(device)
# GPU 构建与硬件可用时才可选择 cuda。
# torch.cuda.is_available() 用来检查 CUDA 是否可用。Tensor 的 .to(device)返回对应设备的张量,因此记得接住返回值;同设备时可能就是原张量。Module 的 .to(device)会移动其参数与缓冲。本 CPU 版不提供 CUDA 运算,图中实验也不代表你的机器有 GPU。
训练模式与梯度追踪的区别见 Autograd 中的评估模式说明。
09 / 参数要固定,结构与处理约定也要保留
保存 state_dict,重建结构后加载
torch.save(model.state_dict(), "linear-weights.pt")
restored = torch.nn.Linear(1, 1) # 同样的模型结构
state = torch.load("linear-weights.pt", map_location="cpu", weights_only=True)
restored.load_state_dict(state)
restored.eval()
with torch.no_grad():
pred = restored(torch.tensor([[4.]], dtype=torch.float32))state_dict()保存参数与注册缓冲的名字和值,不自动包含网络结构、输入单位和你没注册的预处理规则。第五节的中位数、标准化和类别列顺序,仍须与模型一起保存。本例没有输入缩放,配套 checkpoint 保存结构名称与输入单位。
实验 D:快照与当前模型不是一回事
使用上方训练循环的当前 w、b。这里保存的是浏览器内存中的数值副本,演示快照;真正 .pt 文件由 Python 代码写入。
先完成几轮训练,保存 → 把当前模型设零 → 加载,观察预测能否恢复。加载参数之后,这个浏览器演示会重置梯度与批次位置。
为什么保存“最佳模型”不能只写 best = model.state_dict()?
这个字典中的张量会引用当前模型状态,后续训练可能继续改变它。若只在内存中保留快照,可用 copy.deepcopy(model.state_dict());或者当下直接 torch.save到文件。配套代码保存时对 CPU 张量 clone,避免快照随后改变。
本节保存的 checkpoint 用于推理复现。要精确续训,还需考虑优化器状态、训练位置和随机状态等,而不只是恢复模型参数。
10 / 浏览器理解之后,运行真正的框架
配套代码:从梯度核对到保存加载
打开配套 PyTorch 代码;运行 uv run python lessons/lesson-06-pytorch.py。代码依次执行:
- 验证 from_numpy 与 tensor 的内存差异,手算 nn.Linear 输出。
- 区分 backward 与 step,验证梯度累加。
- 把第二节 tanh 网络改写为 Module,比较自动梯度、手推与数值差分。
- 检查 DataLoader 的 4+2 尾批和 drop_last。
- 训练一个 Linear 回归模型,分别记录在线批损失、轮末训练 MSE 和验证 MSE。
- 保存 checkpoint,加载到新模型,核对预测逐项一致。
结果在 lessons/lesson-06-output/,包含梯度 CSV、训练 CSV、运行摘要 JSON 与 linear-checkpoint.pt。固定随机种子帮助复现,不意味着所有硬件与版本都有逐位相同结果。
你的验收练习
- 说清输入 (B,2) 为什么能送进 Linear(2,3),并手算一个输出值。
- 不运行代码,写出实验 B 第一次 backward 的 w.grad 和 b.grad,以及 η=0.1 的更新值。
- 修改 batch_size 为 4,检查实际循环次数与尾批形状。
- 说明 Module 自动管理了什么,autograd 替代了哪段手写代码。
- 加载保存文件,预测 x=4、5,检查与保存前模型一致。
建议按顺序阅读的官方资料
PyTorch Learn the Basics:Tensors → Build Model → Autograd → DataLoader → Optimization → Save & Load。
11 / 检查理解