第 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 F
class 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 plt
plt.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 += 1
all_count += 1
print(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`)。绝大多数情况下,它们都是绿色的(预测正确),偶尔出现一两个红色的(预测错误),让你能直观地感受到模型的强大,也能看到它犯错的样子。