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 个输出。它自己创建并管理w和b这两个参数,并且已经设置好了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 里写数据怎么流过这些层。上一课我们写了 Neuron 和 Layer 两个类,现在一个 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() |
Neuron、Layer 两个类 |
nn.Linear |
| 均方误差的函数 | nn.MSELoss() |
| 一个一个样本地循环 | 一批样本放进一个张量,一次算完 |
PyTorch 没有做任何魔法。你已经亲手写过它最核心的部分,现在只是换一套更快、更省事的写法。以后遇到奇怪的训练问题,比如损失不下降、梯度变成 NaN,你知道底下在发生什么,就知道该从哪里查起。
练习
- 把线性回归的数据换成没有标准化的面积(直接用
area),学习率要设成多少才能不发散?和第 2 课的结论一致吗? - 把
torch.optim.SGD换成torch.optim.Adam,学习率设成 0.01,重新训练异或网络。损失下降得更快还是更慢? - 故意制造一个形状错误:把异或的输入
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 积分。内容经审核后公开。
正在加载讨论…