模块 08 · 第 4 课

PyTorch 入门

把前三课手写的东西用 PyTorch 再做一遍:张量、自动求导、nn.Module、优化器。每一步都和手写的版本对照,算出的梯度、训练出的参数完全一样。

  • 约 45 分钟
  • 难度:入门
  • 实测:2026-09-14 torch 2.14,CPU,固定随机种子

前三课我们手写了线性回归、梯度下降和反向传播。它们能用,但是慢,而且每换一个模型都要重写很多代码。

PyTorch 是目前最常用的深度学习框架。这一课不讲它的所有功能,只做一件事:把前三课做过的事用 PyTorch 再做一遍,并且每一步都和手写的版本对照。你会发现,它做的事和你手写的完全一样,只是换了一套更快、更方便的写法。

安装(没有显卡也没关系,这一课和下一个模块全部在 CPU 上运行):

uv add torch

张量和自动求导

PyTorch 最基本的东西是张量(tensor):一个多维的数组,和 numpy 的数组很像。一个数是 0 维张量,一串数是 1 维,一张表格是 2 维。

张量有一个 numpy 数组没有的能力:自动求导。创建张量时加上 requires_grad=True,PyTorch 就会记下它参与的每一步运算,就像上一课的 Num 那样:

a = torch.tensor(2.0, requires_grad=True)  # requires_grad:记下它参与的运算,以便求导
b = torch.tensor(-3.0, requires_grad=True)
c = torch.tensor(10.0, requires_grad=True)
f = (a * b + c) ** 2
f.backward()
print(f"  f = {f.item()},df/da={a.grad.item()}, df/db={b.grad.item()}, df/dc={c.grad.item()}")
== 1. 同一个算式 f = (a × b + c)²,a=2, b=-3, c=10
  f = 16.0,df/da=-24.0, df/db=16.0, df/dc=8.0

和上一课手写的 Num 算出的结果一模一样:-24、16、8。f.backward() 做的,就是上一课 Num.backward 做的事:从 f 出发,沿着记下的计算过程,用链式法则把梯度传回去,存进每个张量的 .grad 里。.item() 把只有一个数的张量转换成普通的 Python 数字。

线性回归:四样东西

用 PyTorch 重写第 2 课的线性回归,会用到 PyTorch 训练模型的四样基本东西:

x = torch.tensor((area - area.mean()) / area.std(), dtype=torch.float32).unsqueeze(1)  # 标准化,形状 (50, 1)
y = torch.tensor(price, dtype=torch.float32).unsqueeze(1)

model = nn.Linear(1, 1)  # 就是 w × x + b
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
loss_fn = nn.MSELoss()
for step in range(100):
    loss = loss_fn(model(x), y)
    optimizer.zero_grad()  # 清空上一步的梯度
    loss.backward()  # 反向传播,算出每个参数的梯度
    optimizer.step()  # 按梯度更新参数:p -= lr × p.grad
  • 模型nn.Linear(1, 1) 就是 w × x + b,1 个输入、1 个输出。它自己创建并管理 wb 这两个参数,并且已经设置好了 requires_grad
  • 损失函数nn.MSELoss() 就是第 1 课的均方误差。
  • 优化器torch.optim.SGD 负责更新参数。optimizer.step() 就是第 2 课的 w -= lr × dw,只是它会对 model.parameters() 里的每一个参数都做一遍。
  • 训练循环:清零梯度、反向传播、更新参数,三行,顺序和上一课手写的完全一样。

unsqueeze(1) 把形状从 (50,) 变成 (50, 1),意思是"50 个样本,每个样本 1 个特征"。PyTorch 的层都约定第一个维度是样本的数量,下面会细说。

== 2. 线性回归 100 步后:损失 143.05,换算回原单位 w=1.2571 b=14.806

和第 2 课手写的梯度下降结果完全一样:损失 143.05,w = 1.2571, b = 14.806

自己的网络:继承 nn.Module

复杂一点的模型,要自己写一个类,继承 nn.Module。用它重写上一课的异或网络:

class TinyNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.hidden = nn.Linear(2, 4)
        self.output = nn.Linear(4, 1)

    def forward(self, x):
        return torch.tanh(self.output(torch.tanh(self.hidden(x))))

__init__ 里定义有哪些层,forward 里写数据怎么流过这些层。上一课我们写了 NeuronLayer 两个类,现在一个 nn.Linear(2, 4) 就是"一层 4 个神经元,每个 2 个输入",它把 4 个神经元的计算合在一起,用一次矩阵乘法完成。

nn.Module 会自动找到你在 __init__ 里定义的所有层的参数,net.parameters() 就能拿到它们。数一数:

== 3. 异或网络,共 17 个参数

和上一课手写的网络一样,17 个参数。训练:

X = torch.tensor([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=torch.float32)
Y = torch.tensor([[-1], [1], [1], [-1]], dtype=torch.float32)

optimizer = torch.optim.SGD(net.parameters(), lr=0.1)
for epoch in range(1, 301):
    loss = ((net(X) - Y) ** 2).sum()  # 和第 3 课一样:4 个样本的平方误差之和
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

注意 net(X) 一次就算完了 4 个样本。上一课我们用一个 for 循环一个一个地算,现在把 4 个样本放进一个形状为 (4, 2) 的张量,一次矩阵运算全部算完。

  第   1 轮  损失 4.5469
  第  10 轮  损失 3.7176
  第  50 轮  损失 0.5588
  第 100 轮  损失 0.0514
  第 200 轮  损失 0.0174
  第 300 轮  损失 0.0100
  训练后的预测: ['-0.953', '+0.964', '+0.944', '-0.943']

同样学会了异或。损失的变化过程和上一课的数字不完全一样,因为参数的初始值是随机的,PyTorch 和我们手写的版本用了不同的随机数。但趋势一样:前面慢,然后突然下降,最后接近 0。

训练完做预测时,套上了 torch.no_grad()

with torch.no_grad():  # 只是预测,不需要记录求导信息
    print("  训练后的预测:", [f"{v:+.3f}" for v in net(X).squeeze(1).tolist()])

默认情况下,PyTorch 会为每一步运算记录求导所需的信息。只是预测、不需要训练时,关掉它能省内存、更快。

形状:最容易出错的地方

用 PyTorch 写代码,大部分的报错都和张量的形状有关。一个约定要牢记:第一个维度是一批样本的数量

batch = torch.randn(32, 2)  # 32 个样本,每个 2 个特征
== 4. 一次喂 32 个样本:输入形状 (32, 2) → 隐藏层 (32, 4) → 输出 (32, 1)

输入是 32 个样本、每个 2 个特征;经过隐藏层变成 32 个样本、每个 4 个数;最后是 32 个样本、每个 1 个输出。样本的数量在整个过程中保持不变,每一层只改变最后一个维度。

一次处理一批样本,而不是一个一个处理,是深度学习快的关键原因之一。矩阵运算在 CPU 上、特别是在 GPU 上,都是高度优化过的,一次算 32 个样本和算 1 个样本花的时间差不了多少。

写代码时,在关键的地方打印 x.shape,是最有效的调试方法。

手写和 PyTorch 对照

手写的版本 PyTorch
Num,记下局部导数 张量,requires_grad=True
Num.backward() loss.backward()
p.grad = 0.0 optimizer.zero_grad()
p.value -= lr * p.grad optimizer.step()
NeuronLayer 两个类 nn.Linear
均方误差的函数 nn.MSELoss()
一个一个样本地循环 一批样本放进一个张量,一次算完

PyTorch 没有做任何魔法。你已经亲手写过它最核心的部分,现在只是换一套更快、更省事的写法。以后遇到奇怪的训练问题,比如损失不下降、梯度变成 NaN,你知道底下在发生什么,就知道该从哪里查起。

练习

  1. 把线性回归的数据换成没有标准化的面积(直接用 area),学习率要设成多少才能不发散?和第 2 课的结论一致吗?
  2. torch.optim.SGD 换成 torch.optim.Adam,学习率设成 0.01,重新训练异或网络。损失下降得更快还是更慢?
  3. 故意制造一个形状错误:把异或的输入 X 改成形状 (4, 3)(每个样本 3 个特征),运行,读一读报错信息,看它是怎么告诉你形状不匹配的。

自测

1. optimizer.zero_grad()、loss.backward()、optimizer.step() 分别在做什么?

zero_grad 把所有参数的梯度清零,避免和上一步的梯度累加;backward 从损失出发做反向传播,算出每个参数的梯度,存在 .grad 里;step 按照梯度更新每个参数,最简单的 SGD 就是 p = p - lr × p.grad。

2. 为什么 PyTorch 约定张量的第一个维度是样本数量?

深度学习总是一次处理一批样本,把它们放在一个张量里用矩阵运算一起算,比一个一个算快得多。约定第一个维度是样本数量,每一层就只需要处理最后的特征维度,样本数量在整个网络里保持不变。

3. 做预测时为什么要用 torch.no_grad()?

默认情况下,PyTorch 会为每一步运算记录反向传播所需的信息,这要占用额外的内存和时间。只做预测、不需要训练时,用 no_grad 关掉它,更省资源、更快。

提问与讨论

这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。

提问 +3 积分,回答别人 +6 积分。内容经审核后公开。

正在加载讨论…