第 4 章:第一個神經網絡實戰——使用 PyTorch
經過前三章的學習,我們已經對神經網絡的理論基礎有了扎實的理解。我們知道數據如何前向傳播,如何用損失函數評估預測,以及如何通過梯度下降和反向傳播來更新網絡參數。
理論是根基,但真正的樂趣在于實踐。從本章開始,我們將走出理論的殿堂,親手用代碼構建、訓練并評估一個真正的神經網絡。
我們將使用的工具是 PyTorch,一個由 Facebook 人工智能研究院(FAIR)開發和維護的、當今最流行、最強大的深度學習框架之一。
在本章中,我們將一起完成以下任務:
- 環境搭建:安裝 PyTorch 并配置好我們的開發環境。
- 數據加載:加載并處理經典的 MNIST 手寫數字數據集。
- 模型構建:使用 PyTorch 的
nn
模塊定義我們的神經網絡結構。 - 模型訓練:編寫訓練循環,實現我們學過的"前向傳播 -> 計算損失 -> 反向傳播 -> 更新參數"的完整流程。
- 模型評估與預測:在測試數據上檢驗我們模型的性能,并用它來進行預測。
準備好將理論轉化為現實了嗎?讓我們開始吧!
4.1 PyTorch:優雅的深度學習利器
在我們開始編碼之前,先簡單了解一下為什么選擇 PyTorch。
PyTorch 之所以備受學術界和工業界的青睞,主要有以下幾個原因:
- Pythonic: 它的設計哲學與 Python 高度契合,代碼直觀、易于上手,調試也相對簡單。
- 動態計算圖: 與一些早期框架的靜態圖不同,PyTorch 的計算圖是動態的。這意味著你可以在運行時改變網絡結構,這為復雜的模型設計提供了極大的靈活性。
- 強大的生態系統: 擁有豐富的庫(如
torchvision
用于圖像處理,torchaudio
用于音頻處理)和活躍的社區支持。 - 無縫的 CPU/GPU 切換: 可以非常方便地將計算任務在 CPU 和 GPU 之間切換。
PyTorch 的核心是 張量(Tensor),它是一種多維數組,與我們熟知的 NumPy ndarray
非常相似,但它有一個關鍵的超能力:可以在 GPU 上進行計算以加速運算。此外,PyTorch 的 自動求導機制(Autograd) 會自動為我們處理所有與梯度相關的計算,讓我們從反向傳播的復雜數學中解放出來。
環境搭建
現在,讓我們來安裝 PyTorch。官方推薦使用 conda
或 pip
進行安裝。最穩妥的方式是訪問 PyTorch 官網的 “Get Started” 頁面,根據你的操作系統(Windows/Mac/Linux)、包管理器(Conda/Pip)、計算平臺(CPU/CUDA版本)來生成最適合你系統的安裝命令。
對于大多數沒有 NVIDIA GPU 的用戶,一個典型的 CPU 版本 安裝命令如下(使用 pip):
pip install torch torchvision torchaudio
強烈建議 您訪問官網獲取最準確的命令。
安裝完成后,你可以在 Python 解釋器或腳本中通過以下代碼來驗證安裝是否成功:
import torch# 打印 PyTorch 版本
print(f"PyTorch Version: {torch.__version__}")# 創建一個張量
x = torch.rand(5, 3)
print("A random tensor:")
print(x)# 檢查是否有可用的 GPU
is_cuda_available = torch.cuda.is_available()
print(f"CUDA (GPU) Available: {is_cuda_available}")if is_cuda_available:print(f"CUDA Device Name: {torch.cuda.get_device_name(0)}")
如果代碼能夠順利運行并打印出版本號和張量,那么恭喜你,PyTorch 環境已經準備就緒!
在接下來的章節中,我們將使用 Jupyter Notebook 或類似的交互式環境進行編碼,這非常適合數據科學和機器學習的探索性工作。
4.2 數據準備:加載與變換 MNIST
在機器學習中,數據是驅動一切的燃料。對于我們的第一個項目,我們將使用 MNIST 數據集,這是一個包含了 70,000 張 28x28
像素的手寫數字灰度圖像的集合(60,000 張用于訓練,10,000 張用于測試),由美國國家標準與技術研究院整理。它是圖像分類領域的"Hello, World!"。
幸運的是,torchvision
庫讓我們可以極其方便地獲取和使用它。
1. 定義數據變換
在將圖像送入模型之前,我們通常需要進行一些預處理。最常見的兩個步驟是:
- 轉換為張量:將 PIL 圖像或 NumPy
ndarray
轉換為 PyTorch 的Tensor
格式。 - 歸一化 (Normalization):將張量的像素值從
[0, 255]
的范圍縮放到一個更小的、以 0 為中心的范圍,例如[-1, 1]
。這有助于加速模型收斂并提高性能。
我們可以使用 torchvision.transforms.Compose
將這些操作串聯起來。
from torchvision import datasets, transforms# 定義一個轉換流程
transform = transforms.Compose([transforms.ToTensor(), # 將圖片轉換為張量,并將像素值從 [0, 255] 歸一化到 [0.0, 1.0]transforms.Normalize((0.5,), (0.5,)) # 將 [0.0, 1.0] 的范圍歸一化到 [-1.0, 1.0]
])
注:對于 MNIST 這樣的灰度圖,其均值(Mean)和標準差(Standard Deviation)都接近 0.5,所以我們使用 (0.5,)
作為歸一化參數。
2. 下載并加載數據集
現在我們可以使用 datasets.MNIST
來下載并創建我們的訓練集和測試集了。
# 下載訓練數據集
train_dataset = datasets.MNIST(root='./data', # 數據存放的根目錄train=True, # 指定這是訓練集download=True, # 如果 `./data` 目錄下沒有數據,就自動下載transform=transform # 應用我們剛剛定義的轉換
)# 下載測試數據集
test_dataset = datasets.MNIST(root='./data',train=False, # 指定這是測試集download=True,transform=transform
)
3. 創建數據加載器(DataLoader)
直接在完整的數據集上進行迭代效率很低。我們通常希望分批次(mini-batch)地、并且隨機地給模型喂數據。torch.utils.data.DataLoader
正是為此而生。
DataLoader
是一個迭代器,它將數據集封裝起來,為我們提供了批處理、數據打亂、并行加載等一系列功能。
from torch.utils.data import DataLoader# 定義批次大小
batch_size = 64# 創建訓練數據加載器
train_loader = DataLoader(dataset=train_dataset,batch_size=batch_size,shuffle=True # 打亂數據,這在訓練時非常重要
)# 創建測試數據加載器
test_loader = DataLoader(dataset=test_dataset,batch_size=batch_size,shuffle=False # 測試時通常不需要打亂數據
)
4. 可視化我們的數據
為了更直觀地感受我們正在處理的數據,讓我們來看一下訓練集中的一些圖片。
import matplotlib.pyplot as plt
import numpy as np# 從訓練數據加載器中獲取一個批次的數據
dataiter = iter(train_loader)
images, labels = next(dataiter)# images.shape 會是 [64, 1, 28, 28],代表 (批次大小, 通道數, 高, 寬)# 創建一個 8x8 的網格來顯示圖片
fig, axes = plt.subplots(8, 8, figsize=(10, 10))
for i, ax in enumerate(axes.flat):# 顯示圖片# 我們需要將 Normalize 的效果反轉回來以便正確顯示img = images[i].numpy().squeeze() # 去掉通道維度ax.imshow(img, cmap='gray')# 顯示標簽ax.set_title(labels[i].item())ax.axis('off')plt.tight_layout()
plt.show()
運行這段代碼,你應該能看到一個 8x8 的網格,里面是各種手寫數字的圖片及其對應的標簽。
到此為止,我們已經成功地將數據準備就緒。下一步,我們將利用這些數據來構建和定義我們的第一個神經網絡模型。
4.3 模型構建:定義你的神經網絡
數據已經就位,現在是時候構建我們的大腦——神經網絡模型了。在 PyTorch 中,任何自定義的模型都是通過創建一個繼承自 torch.nn.Module
的類來實現的。這個基類為我們提供了模型追蹤、參數管理等一系列底層功能。
我們的模型需要完成以下任務:
- 接收一個被"壓平"的
28x28
像素圖像(即一個長度為 784 的一維向量)作為輸入。 - 通過幾個全連接的線性層(
nn.Linear
)進行變換。 - 在層與層之間使用非線性激活函數(例如
ReLU
)來增加模型的表達能力。 - 最終輸出一個包含 10 個值的向量,每個值代表輸入圖像是 0 到 9 這 10 個數字中某一個的"得分"或"對數概率"(logits)。
1. 定義模型類
讓我們來創建一個名為 SimpleMLP
(簡單多層感知機)的類。
import torch.nn as nn
import torch.nn.functional as Fclass SimpleMLP(nn.Module):def __init__(self):# 首先,調用父類的 __init__ 方法super(SimpleMLP, self).__init__()# 定義網絡的層次結構# 輸入層:784 個特征 (28*28)# 第一個隱藏層:128 個神經元self.fc1 = nn.Linear(28 * 28, 128)# 第二個隱藏層:64 個神經元self.fc2 = nn.Linear(128, 64)# 輸出層:10 個神經元,對應 10 個類別self.fc3 = nn.Linear(64, 10)def forward(self, x):# 定義數據在前向傳播中的流動方式# 1. 壓平輸入圖像# x 的原始 shape: [batch_size, 1, 28, 28]# x.view(-1, 28 * 28) 會將其轉換為 [batch_size, 784]x = x.view(-1, 28 * 28)# 2. 通過第一個隱藏層,并應用 ReLU 激活函數x = F.relu(self.fc1(x))# 3. 通過第二個隱藏層,并應用 ReLU 激活函數x = F.relu(self.fc2(x))# 4. 通過輸出層# 這里我們不需要應用 softmax,因為 nn.CrossEntropyLoss 會為我們處理x = self.fc3(x)return x
在這個類中:
__init__
方法負責"聲明"模型中所有需要學習參數的層。我們定義了三個線性層fc1
,fc2
,fc3
(fc = fully connected)。forward
方法則像一張流程圖,它接收輸入張量x
,并精確地定義了x
是如何一步步流過我們在__init__
中聲明的各個部分的。
2. 實例化并查看模型
現在我們可以輕松地創建這個模型的一個實例,并打印它來查看其結構。
# 創建模型實例
model = SimpleMLP()# 打印模型結構
print(model)
運行后,你將看到一個清晰的、描述我們模型結構的輸出:
SimpleMLP((fc1): Linear(in_features=784, out_features=128, bias=True)(fc2): Linear(in_features=128, out_features=64, bias=True)(fc3): Linear(in_features=64, out_features=10, bias=True)
)
這告訴我們模型由三個線性層組成,并清晰地標明了每一層的輸入和輸出特征數。PyTorch 已經自動為我們處理了每一層權重和偏置的初始化。
下面是我們剛剛定義的 SimpleMLP
模型的結構示意圖:
圖 4.1: 一個全連接神經網絡的結構示意圖。我們的模型與之類似,輸入層接收壓平的圖像數據(784個節點),經過兩個隱藏層(128和64個節點),最終由輸出層(10個節點)得出分類結果。
現在,我們的模型、數據都已經準備就緒。在把它們投入訓練的熔爐之前,我們還需最后兩個關鍵組件:
- 損失函數(Loss Function / Criterion):定義了我們優化的"目標"。它會衡量模型輸出與真實標簽之間的差距。
- 優化器(Optimizer):定義了我們實現優化的"方法"。它會根據損失函數計算出的梯度,來更新模型的權重。
1. 損失函數
對于像 MNIST 這樣的多分類問題,torch.nn.CrossEntropyLoss
是最理想的選擇。它是一個非常強大的損失函數,其內部幫我們集成了兩個步驟:
nn.LogSoftmax()
:將模型的原始輸出(logits)轉換成對數概率。nn.NLLLoss()
(Negative Log Likelihood Loss):計算這些對數概率與真實標簽之間的負對數似然損失。
組合在一起,它就能非常有效地衡量我們的分類模型表現有多糟糕。我們的目標就是讓這個損失值盡可能地小。
# 定義損失函數
criterion = nn.CrossEntropyLoss()
2. 優化器
優化器負責執行梯度下降算法。PyTorch 在 torch.optim
模塊中提供了多種優化算法的實現,如 SGD
, Adam
, RMSprop
等。
我們將使用 Adam(Adaptive Moment Estimation),它是一種非常流行且通常表現優異的優化算法,它會為每個參數獨立地計算自適應學習率。
在創建優化器時,我們需要告訴它兩件事:
- 哪些參數需要被優化:我們可以通過調用
model.parameters()
來輕松獲取模型中所有需要學習的參數。 - 學習率(Learning Rate):這是梯度下降中最重要的超參數之一,它控制了每次參數更新的步長。我們先從一個常用的值
0.001
開始。
from torch import optim# 定義優化器,并將模型參數傳遞給它
# lr = learning rate (學習率)
optimizer = optim.Adam(model.parameters(), lr=0.001)
至此,所有零件都已準備齊全:我們有了數據(DataLoader
)、有了模型(SimpleMLP
),有了衡量標準(CrossEntropyLoss
),也有了更新方法(Adam
)。
下一節,我們將把所有這些組件組裝起來,構建最終的訓練循環,真正開始訓練我們的模型!
4.5 訓練循環:讓模型學習起來
終于,我們來到了最激動人心的部分。我們將把之前準備的所有組件——數據、模型、損失函數、優化器——全部投入到這個訓練循環中,讓模型真正地開始學習。
訓練過程通常包含多個 輪次(Epochs)。一個 Epoch 指的是我們的模型完整地看過一遍訓練集中的所有數據。我們會訓練多個 Epochs,因為模型需要反復地從數據中學習,才能逐漸優化其內部的參數。
在每一個 Epoch 內部,我們會分批次(mini-batch)地將數據喂給模型,并執行我們爛熟于心的學習五部曲。
訓練代碼
下面是完整的訓練循環代碼。它看起來可能有點長,但其核心正是我們反復強調的五個步驟。
# 定義訓練的輪次
epochs = 15# 記錄訓練過程中的損失
train_losses = []print("開始訓練...")
for e in range(epochs):running_loss = 0# 內層循環:遍歷訓練數據加載器,獲取每個批次的數據for images, labels in train_loader:# 步驟 1: 梯度清零# 這是非常重要的一步,因為PyTorch默認會累積梯度optimizer.zero_grad()# 步驟 2: 前向傳播# 將一個批次的圖像數據輸入模型,得到預測輸出(logits)output = model(images)# 步驟 3: 計算損失# 比較模型的預測輸出和真實的標簽loss = criterion(output, labels)# 步驟 4: 反向傳播# 計算損失相對于模型所有參數的梯度loss.backward()# 步驟 5: 更新參數# 優化器根據梯度更新模型的權重optimizer.step()# 累加批次損失running_loss += loss.item()# 每個 Epoch結束后,打印一次平均損失epoch_loss = running_loss / len(train_loader)train_losses.append(epoch_loss)print(f"訓練輪次 {e+1}/{epochs}.. "f"訓練損失: {epoch_loss:.3f}")print("訓練完成!")
當你運行這段代碼時,你會看到損失值隨著訓練輪次的增加而穩步下降。這表明我們的模型正在從數據中學習,它對數字的預測正變得越來越準確!
例如,你可能會看到類似這樣的輸出:
開始訓練...
訓練輪次 1/15.. 訓練損失: 0.383
訓練輪次 2/15.. 訓練損失: 0.160
訓練輪次 3/15.. 訓練損失: 0.116
...
訓練輪次 15/15.. 訓練損失: 0.026
訓練完成!
這個不斷下降的損失值,就是我們所有理論知識和代碼工作的最好回報。
我們可以將記錄下來的 train_losses
繪制成圖表,來更直觀地觀察學習過程。
import matplotlib.pyplot as pltplt.plot(train_losses, label='Training loss')
plt.title('Loss over time')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.grid(True)
plt.show()
圖 4.2: 訓練損失隨訓練輪次(Epochs)變化的曲線。可以看到損失值迅速下降并逐漸趨于平穩,這表明模型正在有效地學習。
我們的模型已經學有所成。但它究竟學得怎么樣?口說無憑,我們需要在它從未見過的數據(測試集)上檢驗它的真實能力。這就是我們最后一節要做的事情:模型評估與預測。
4.6 模型評估與預測:見證成果的時刻
模型訓練完成,但它的表現如何?訓練損失低并不完全代表模型泛化能力強。我們需要在獨立的測試集上評估其性能,這才是衡量模型真實水平的黃金標準。
1. 模型評估
評估過程與訓練過程非常相似,但有幾個關鍵區別:
- 開啟評估模式:我們需要調用
model.eval()
。這會告訴模型中的特定層(如 Dropout, BatchNorm)它們現在處于評估模式,其行為應與訓練時不同。對于我們這個簡單模型,雖然沒有這些層,但這始終是一個好習慣。 - 關閉梯度計算:在評估時,我們不需要計算梯度,這可以大大加快計算速度并節省內存。我們可以使用
with torch.no_grad():
上下文管理器來包裹我們的評估代碼。
我們將計算模型在整個測試集上的準確率(Accuracy)。
# 準備評估
correct_count, all_count = 0, 0
print("開始評估...")
model.eval() # 切換到評估模式with torch.no_grad(): # 關閉梯度計算for images, labels in test_loader:# 對每個批次進行預測for i in range(len(labels)):img = images[i].view(1, 784)log_ps = model(img) # 獲取 log-probabilities# 將 log-probabilities 轉換為真實概率ps = torch.exp(log_ps)# 獲取概率最高的類別作為預測結果probab = list(ps.numpy()[0])pred_label = probab.index(max(probab))# 與真實標簽比較true_label = labels.numpy()[i]if(true_label == pred_label):correct_count += 1all_count += 1print(f"測試集圖片總數: {all_count}")
print(f"模型準確率 = {(correct_count/all_count):.3f}")
運行后,你可能會看到一個非常喜人的結果,比如 模型準確率 = 0.975
。這意味著我們的模型在它從未見過的 10,000 張圖片中,有 97.5% 的概率能夠正確識別出數字!對于一個如此簡單的模型來說,這是一個非常出色的成績。
除了計算總體準確率,我們還可以使用 混淆矩陣(Confusion Matrix) 來更深入地分析模型的性能。混淆矩陣可以清晰地展示出模型對于每個類別的分類情況,尤其是哪些類別之間容易被混淆。
from sklearn.metrics import confusion_matrix
import seaborn as sns# 重新獲取所有預測和標簽用于生成混淆矩陣
y_pred = []
y_true = []model.eval()
with torch.no_grad():for images, labels in test_loader:outputs = model(images)_, predicted = torch.max(outputs, 1)y_pred.extend(predicted.numpy())y_true.extend(labels.numpy())# 計算混淆矩陣
cm = confusion_matrix(y_true, y_pred)# 繪制混淆矩陣
plt.figure(figsize=(10, 8))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=range(10), yticklabels=range(10))
plt.xlabel('Predicted Label')
plt.ylabel('True Label')
plt.title('Confusion Matrix')
plt.show()
圖 4.3: MNIST 測試集的混淆矩陣。對角線上的數字代表該類別被正確預測的數量,顏色越深表示數量越多。非對角線上的數字則代表模型犯錯的情況(例如,將真實標簽為’9’的圖片錯誤地預測為了’4’)。
2. 單個圖像預測與可視化
數字化的準確率固然重要,但親眼看到模型的預測結果會更加震撼。讓我們編寫一小段代碼,從測試集中隨機抽取一些圖片,讓模型進行預測,并將結果可視化出來。
# 再次獲取一批測試數據
dataiter = iter(test_loader)
images, labels = next(dataiter)# 進行預測
output = model(images)
# 將 log-probabilities 轉換為概率
ps = torch.exp(output)# 獲取預測的類別 (概率最高的那個)
_, top_class = ps.topk(1, dim=1)# 創建一個 8x8 的網格來顯示圖片和預測結果
fig, axes = plt.subplots(8, 8, figsize=(12, 12))
for i, ax in enumerate(axes.flat):ax.imshow(images[i].numpy().squeeze(), cmap='gray')# 設置標題,綠色為正確,紅色為錯誤ax.set_title(f'Pred: {top_class[i].item()}\nTrue: {labels[i].item()}',color=("green" if top_class[i] == labels[i] else "red"))ax.axis('off')plt.tight_layout()
plt.show()
運行這段代碼,你會看到一個圖片網格。每張圖片的標題都顯示了模型的預測值(Pred
)和真實值(True
)。絕大多數情況下,它們都是綠色的(預測正確),偶爾出現一兩個紅色的(預測錯誤),讓你能直觀地感受到模型的強大,也能看到它犯錯的樣子。
祝賀你!
你已經成功地走完了從零開始構建一個神經網絡的全過程。從抽象的理論到具體的代碼實現,你親手打造并訓練了一個能夠高精度識別手寫數字的智能模型。這不僅僅是一個練習,你掌握的這套流程——數據準備、模型構建、定義損失與優化、訓練、評估——是所有更復雜、更強大的深度學習項目的基礎。
紅色為錯誤
ax.set_title(
f’Pred: {top_class[i].item()}\nTrue: {labels[i].item()}',
color=(“green” if top_class[i] == labels[i] else “red”)
)
ax.axis(‘off’)
plt.tight_layout()
plt.show()
運行這段代碼,你會看到一個圖片網格。每張圖片的標題都顯示了模型的預測值(`Pred`)和真實值(`True`)。絕大多數情況下,它們都是綠色的(預測正確),偶爾出現一兩個紅色的(預測錯誤),讓你能直觀地感受到模型的強大,也能看到它犯錯的樣子。