識別手寫數字
第一個真正的分類任務:讓網路認出 8×8 畫素的手寫數字。講清楚分類和迴歸的區別,softmax 和交叉熵在做什麼,為什麼要分小批訓練,最後用混淆矩陣看它錯在哪裡。
- 約 45 分鐘
- 難度:進階
- 實測:2026-09-14 torch 2.14,scikit-learn 1.9,CPU,固定隨機種子
程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。
前面幾課的任務都很小:50 個點的房價,4 個點的異或。這一課處理一份真實的資料:1797 張手寫數字的圖片,讓網路認出每張圖寫的是 0 到 9 裡的哪一個。
這是一個分類任務。房價預測輸出的是一個連續的數,叫回歸;認數字要從 10 個類別裡選一個,輸出的形式、損失函式都要換。這一課就講換成了什麼、為什麼。
資料來自 scikit-learn 自帶的手寫數字資料集,裝好就有,不用下載:
uv add torch scikit-learn
python digit_classifier.py
資料長什麼樣
digits = load_digits()
数据:1797 张图,每张 8×8 像素,像素值 0~16
第一张图,标签是 0:
::**==
**##++##::
..##.. ++==
::** ====
::== ====
::++ **--
..##::++**
--**++
每張圖 8×8 共 64 個畫素,每個畫素是 0 到 16 的整數,數越大越黑。腳本里用幾個字元把深淺畫了出來,能看出是一個 0。
網路的輸入就是這 64 個數,拉成一行。除以 16,縮放到 0 到 1 之間,道理和第 2 課的標準化一樣:
X = torch.tensor(digits.data / 16.0, dtype=torch.float32)
y = torch.tensor(digits.target, dtype=torch.long)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=0)
训练集 1347 张,测试集 450 张
四分之一的圖留出來做測試集,訓練時完全不給網路看。最後用它來檢查網路是真的學會了認數字,還是隻是記住了訓練集裡的那些圖。這和第 06 模組做評估集是一個道理:檢查的題目不能出現在練習題裡。
網路:輸出 10 個分數
model = nn.Sequential(
nn.Linear(64, 64), # 64 个像素 → 64 个隐藏神经元
nn.ReLU(),
nn.Linear(64, 10), # → 10 个输出,分别对应数字 0~9
)
模型共 4810 个参数
nn.Sequential 把幾層按順序串起來,省得自己寫一個類。算一下參數:第一層 64×64 個權重加 64 個偏置,4160 個;第二層 64×10 加 10,650 個;一共 4810 個。
這裡有兩處和前面不同。
啟用函式換成了 ReLU。它比 tanh 還簡單:輸入大於 0 就原樣輸出,否則輸出 0。它算得快,而且在層數多的時候比 tanh 更好訓練,現在的網路裡最常見的就是它和它的變種。
輸出是 10 個數,每個數字一個。它們叫分數(logits),可以是任意的正數負數,哪個最大,網路就認為是哪個數字。
softmax:把分數變成機率
10 個分數不好直接解釋。softmax 把它們變成 10 個機率:先對每個分數取指數(保證是正數),再除以所有的和(保證加起來是 1)。原來分數越大的,機率就越大。
probs = torch.softmax(model(X_test[:1]), dim=1)[0]
訓練好之後,測試集第一張圖的機率:
测试集第一张图(标签 2)的预测概率:
0:0.00 1:0.00 2:1.00 3:0.00 4:0.00 5:0.00 6:0.00 7:0.00 8:0.00 9:0.00
網路幾乎百分之百確定這是 2,它確實是 2。
你在第 01 模組第 2 課見過完全一樣的東西:大模型每一步輸出的,就是詞表裡每個詞元的分數,經過 softmax 變成機率,再按機率選下一個詞元。區別只在於大模型的"類別"有十幾萬個。
交叉熵:分類的損失
迴歸用均方誤差,分類用交叉熵(cross entropy)。它的演算法很簡單:看網路給正確答案的機率是多少,取對數,再取負號。
正确答案的概率 损失 = -log(概率)
1.00 0.00
0.90 0.11
0.50 0.69
0.10 2.30
0.01 4.61
正確答案的機率越接近 1,損失越接近 0;機率越小,損失越大,而且漲得很快。一個"很自信地答錯"的網路會被重重地罰。
還有一個數值值得記住:剛開始訓練時,網路什麼都不懂,10 個類別的機率差不多都是 0.1,損失大約是 -log(0.1) = 2.30。如果你訓練一個 10 分類的網路,第一步的損失離 2.3 很遠,多半是哪裡寫錯了。
PyTorch 的 nn.CrossEntropyLoss 把 softmax 和交叉熵合在一起算,所以模型的最後一層直接輸出分數,不要自己再加 softmax。
loss_fn = nn.CrossEntropyLoss() # 分类问题用交叉熵
第 09 模組訓練 GPT 用的也是它,那時的類別就是詞表裡的每一個詞元。
小批訓練
前幾課每一步都用全部的資料算一次梯度。這一課改成每次取 64 張圖:
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
for epoch in range(1, 31):
# 每一轮把训练集打乱,每次取 64 张图更新一次参数
order = torch.randperm(len(X_train))
for i in range(0, len(X_train), 64):
idx = order[i:i + 64]
loss = loss_fn(model(X_train[idx]), y_train[idx])
optimizer.zero_grad()
loss.backward()
optimizer.step()
這叫小批(mini-batch)訓練。1347 張圖,每批 64 張,一輪更新 22 次參數。把全部訓練資料過一遍叫一輪(epoch)。
為什麼不一次用全部資料?資料集大了,一次根本放不進記憶體,大模型的訓練資料有上萬億個詞元。而且小批的梯度雖然有些噪聲,但更新得勤快,往往比每輪只更新一次學得更快。每輪開始前把順序打亂,是為了讓每一批都是隨機的一小撮,不讓網路依賴資料的排列順序。
最佳化器也換成了 Adam。它在 SGD 的基礎上,給每個參數單獨調整步長:一直往同一個方向走的參數步子放大,來回擺動的參數步子縮小。第 2 課那種"一個學習率照顧不了兩個方向"的問題,它能自動緩解很多。實際訓練裡,Adam 和它的變種 AdamW 是最常用的預設選擇。
訓練結果
训练:
第 1 轮 最后一批的损失 0.1804 训练集准确率 87.8% 测试集准确率 87.6%
第 2 轮 最后一批的损失 0.0335 训练集准确率 92.7% 测试集准确率 92.4%
第 5 轮 最后一批的损失 0.3066 训练集准确率 97.6% 测试集准确率 96.7%
第 10 轮 最后一批的损失 0.0285 训练集准确率 98.7% 测试集准确率 96.0%
第 20 轮 最后一批的损失 0.0035 训练集准确率 99.7% 测试集准确率 97.3%
第 30 轮 最后一批的损失 0.0001 训练集准确率 100.0% 测试集准确率 97.8%
在我的電腦上,整個指令碼不到 5 秒就跑完了。
有兩點值得看。
第一,"最後一批的損失"跳來跳去:第 2 輪 0.03,第 5 輪反而是 0.31。這就是小批的噪聲:每一批只有 64 張圖(最後一批只有 1347 - 21×64 = 3 張),碰巧抽到幾張難認的,損失就高。判斷訓練得好不好,要看整個資料集上的指標,而不是某一批的損失。
第二,訓練集準確率到了 100%,測試集是 97.8%。網路在見過的圖上全對,在沒見過的圖上錯了一些。這個差距下一課專門講。
它錯在哪裡
準確率只是一個數。想知道網路錯在哪裡,看混淆矩陣:行是真實的數字,列是網路預測的數字,對角線上是答對的,其他位置是答錯的。
测试集 450 张里错了 10 张。混淆矩阵(行是真实数字,列是预测数字):
0 1 2 3 4 5 6 7 8 9
0 37 . . . . . . . . .
1 . 42 . . . . . . 1 .
2 . . 44 . . . . . . .
3 . . 1 44 . . . . . .
4 . . . . 38 . . . . .
5 . . . . . 47 . . . 1
6 . 1 . . . . 51 . . .
7 . 1 . . . . . 47 . .
8 . 2 1 . . . . . 45 .
9 . . . . . 1 . 1 . 45
10 個錯誤裡,有 4 個是被錯認成 1 的(6 和 7 各一個,8 有兩個)。把其中一張畫出來:
一张判错的图:真实是 7,模型认为是 1
..####::
::++==
**--..
==######++
++####--
##==
##..
..##
這個 7 中間多了一橫,下半部分就是一條豎線。8×8 的解析度下,說它是 1 也不算離譜。看錯誤的樣本,是瞭解模型的最好辦法:有時候是模型的問題,有時候是資料本身就模糊,有時候甚至是標籤標錯了。這一點和第 06 模組看評估失敗的案例是一樣的。
練習
- 把隱藏層從 64 個神經元改成 16 個、256 個,訓練集和測試集的準確率分別怎麼變?
- 把
Adam換成SGD,學習率同樣用 0.01,30 輪後測試集準確率是多少?把學習率調到多少,SGD 才能追上 Adam? - 在訓練開始之前,先算一次整個訓練集上的損失。它離 2.30 近嗎?
自測
1. 分類任務的輸出和迴歸任務有什麼不同?softmax 在做什麼?
迴歸輸出一個連續的數。分類輸出每個類別一個分數,softmax 對這些分數取指數再除以總和,把它們變成加起來為 1 的機率,分數越大的類別機率越大。
2. 交叉熵損失是怎麼算的?10 分類的網路剛開始訓練時,損失大約是多少?
取網路給正確答案的機率,算 -log(機率)。正確答案的機率越接近 1,損失越接近 0。剛開始訓練時,10 個類別的機率都在 0.1 附近,損失大約是 -log(0.1) ≈ 2.30。
3. 為什麼"最後一批的損失"忽高忽低?怎麼判斷訓練的效果?
每一批只是一小部分資料,碰巧抽到難的樣本損失就高,而且最後一批只有幾張圖。判斷效果要看整個訓練集和測試集上的指標,比如準確率或全部資料的平均損失,而不是某一批的損失。
提問與討論
這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。
提問 +3 點,回答別人 +6 點。內容經審核後公開。
正在載入討論…