AI 学习课程 / LESSON 06

第六节:PyTorch 基础

你已经会理解形状、手推梯度和评估模型。现在把这些步骤写成 PyTorch:让框架追踪计算、计算梯度,帮你组织参数与批次,同时保留你对训练过程的控制。

Tensor 与 NumPy 对照逐步执行训练循环CPU 版实练

01 / 为什么现在学习框架?

数学没有换,组织计算的工具换了

torch.Tensor

数值数组,附带形状、类型、设备和梯度相关信息。

nn.Module

组织网络层与可训练参数;调用模型执行前向计算。

autograd 与 optim

前者根据计算图求梯度,后者按梯度更新参数。

第二节:你写前向公式 → 手推梯度 → 手动更新 w、b 本节:你写前向公式 → loss.backward() → optimizer.step() 数据划分、损失选择、学习率和验证流程仍由你决定。

项目环境: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 缓存,权限不足时申请权限。

浏览器图解不加载 PyTorch,也不需要先安装框架。它模拟数学和 API 的行为,帮助你理解;真实自动求导、DataLoader 和 .pt 保存加载在配套 Python 中验证。

环境配置参考 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。

X:(B,d入),weight:(d出,d入),bias:(d出,) X @ weight.T:(B,d出),再广播 bias → Y:(B,d出) 这与第一节使用的 X @ W 相容:这里的 weight 相当于那里的 W.T。

实验 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 对需要求导的计算追踪运算关系;反向沿关系使用链式法则。

w=1,b=0 → ŷ=2 → 误差=-3 → L=9 ∂L/∂ŷ=2(ŷ−5)=-6 ∂L/∂w=(∂L/∂ŷ)×2=-12;∂L/∂b=(∂L/∂ŷ)×1=-6 η=0.1 时:w新=1−0.1×(-12)=2.2;b新=0−0.1×(-6)=0.6

实验 B:亲手执行四个操作

① w × x对 w 的局部导数:2
② 加 b对 b 的局部导数:1
③ 减 y=5对预测的局部导数:1
④ 平方 → L局部导数:2×误差
  1. 前向 → backward:观察 w、b 没变,只出现 .grad。
  2. 再前向一次 → 再 backward:不清空梯度,会从 -12/-6 累加到 -24/-12。
  3. 清空梯度 → 新前向 → 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 小网络

z=w₁x+b₁;h=tanh(z);ŷ=w₂h+b₂;L=(ŷ−y)² x=2,y=1,初始 [w₁,b₁,w₂,b₂]=[0.5,0,1,0]
参数初始值手推梯度预期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 不变,但这一轮参与训练的数量可能变化。

一个 epoch 指完整遍历一次 DataLoader。batch_size=2、不丢弃尾批时,一轮有 3 次参数更新,6 个样本都用一次。一个 step 常指一次 optimizer.step(),不要把它与 epoch 混为一谈。

浏览器用固定伪随机洗牌,PyTorch 使用 torch.Generator,顺序可能不同;样本分组、尾批与形状规则一致。修改本区设置会重置下方训练实验。

对应 Dataset 与 DataLoader 教程。

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。

本批 B 个样本:L = (1/B)Σ(ŷᵢ−yᵢ)² ∂L/∂w = (2/B)Σ(ŷᵢ−yᵢ)xᵢ ∂L/∂b = (2/B)Σ(ŷᵢ−yᵢ) SGD 更新:参数 ← 参数 − η·梯度
真实完整训练代码长什么样?
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))
回归预测若为 (B,1),标签也保持 (B,1)。如果标签写成 (B,),广播可能悄悄得到 (B,B) 的差值,损失就不是逐条配对的误差。PyTorch 的 MSE 接口通常会对尺寸不一致发出警告,但仍可能继续广播计算;应检查形状。
两个样本的形状错误,具体会算成什么?
预测 pred=[[2],[4]],形状 (2,1) 正确标签 y=[[1],[3]],形状 (2,1) pred−y=[[1],[1]] → MSE=1 若标签改成 y=[1,3],形状 (2,): pred−y=[[1,-1],[3,1]],形状 (2,2) 错误地比较了所有组合 → MSE=(1+1+9+1)/4=3

08 / 两种状态,分别控制两件事

eval 与 no_grad 不能互相替代

model.train() / model.eval()

控制层的训练模式,例如 Dropout 和 BatchNorm 的行为。本节的单独 Linear 没有这些模式差异,但仍采用完整习惯。

torch.no_grad()

暂时不记录梯度计算图,常用于验证和推理;不自动切换层的训练模式。

训练时对需要更新的参数记录梯度。验证和推理时通常同时 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 是否可用。
模型参数 → CPU
与
输入、标签 → CPU
一致,才能计算

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 用于推理复现。要精确续训,还需考虑优化器状态、训练位置和随机状态等,而不只是恢复模型参数。

对应 保存加载教程与 Checkpoint 与最佳模型保存说明。

10 / 浏览器理解之后,运行真正的框架

配套代码:从梯度核对到保存加载

打开配套 PyTorch 代码;运行 uv run python lessons/lesson-06-pytorch.py。代码依次执行:

  1. 验证 from_numpy 与 tensor 的内存差异,手算 nn.Linear 输出。
  2. 区分 backward 与 step,验证梯度累加。
  3. 把第二节 tanh 网络改写为 Module,比较自动梯度、手推与数值差分。
  4. 检查 DataLoader 的 4+2 尾批和 drop_last。
  5. 训练一个 Linear 回归模型,分别记录在线批损失、轮末训练 MSE 和验证 MSE。
  6. 保存 checkpoint,加载到新模型,核对预测逐项一致。

结果在 lessons/lesson-06-output/,包含梯度 CSV、训练 CSV、运行摘要 JSON 与 linear-checkpoint.pt。固定随机种子帮助复现,不意味着所有硬件与版本都有逐位相同结果。

你的验收练习

  1. 说清输入 (B,2) 为什么能送进 Linear(2,3),并手算一个输出值。
  2. 不运行代码,写出实验 B 第一次 backward 的 w.grad 和 b.grad,以及 η=0.1 的更新值。
  3. 修改 batch_size 为 4,检查实际循环次数与尾批形状。
  4. 说明 Module 自动管理了什么,autograd 替代了哪段手写代码。
  5. 加载保存文件,预测 x=4、5,检查与保存前模型一致。
建议按顺序阅读的官方资料

PyTorch Learn the Basics:Tensors → Build Model → Autograd → DataLoader → Optimization → Save & Load。

11 / 检查理解

五道自测

1. Linear(2,3) 的 weight 形状是?

2. loss.backward() 会直接更新参数吗?

3. 6 条样本,batch_size=4,不丢尾批,一轮有几批?

4. 只调用 model.eval() 是否关闭梯度追踪?

5. 回归预测是 (B,1),标签也应保持什么形状以逐条计算本例 MSE?

下一节:训练更可靠的神经网络。学习激活函数、梯度消失、Adam、Dropout、归一化与训练排查。