模組 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 點。內容經審核後公開。

正在載入討論…