模組 08 · 第 5 課

識別手寫數字

第一個真正的分類任務:讓網路認出 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 模組看評估失敗的案例是一樣的。

練習

  1. 把隱藏層從 64 個神經元改成 16 個、256 個,訓練集和測試集的準確率分別怎麼變?
  2. Adam 換成 SGD,學習率同樣用 0.01,30 輪後測試集準確率是多少?把學習率調到多少,SGD 才能追上 Adam?
  3. 在訓練開始之前,先算一次整個訓練集上的損失。它離 2.30 近嗎?

自測

1. 分類任務的輸出和迴歸任務有什麼不同?softmax 在做什麼?

迴歸輸出一個連續的數。分類輸出每個類別一個分數,softmax 對這些分數取指數再除以總和,把它們變成加起來為 1 的機率,分數越大的類別機率越大。

2. 交叉熵損失是怎麼算的?10 分類的網路剛開始訓練時,損失大約是多少?

取網路給正確答案的機率,算 -log(機率)。正確答案的機率越接近 1,損失越接近 0。剛開始訓練時,10 個類別的機率都在 0.1 附近,損失大約是 -log(0.1) ≈ 2.30。

3. 為什麼"最後一批的損失"忽高忽低?怎麼判斷訓練的效果?

每一批只是一小部分資料,碰巧抽到難的樣本損失就高,而且最後一批只有幾張圖。判斷效果要看整個訓練集和測試集上的指標,比如準確率或全部資料的平均損失,而不是某一批的損失。

提問與討論

這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。

提問 +3 點,回答別人 +6 點。內容經審核後公開。

正在載入討論…