第 4 章:第一个神经网络实战——使用 PyTorch

发布于:2025-06-28 ⋅ 阅读:(20) ⋅ 点赞:(0)

第 4 章:第一个神经网络实战——使用 PyTorch

在这里插入图片描述

经过前三章的学习,我们已经对神经网络的理论基础有了扎实的理解。我们知道数据如何前向传播,如何用损失函数评估预测,以及如何通过梯度下降和反向传播来更新网络参数。

理论是根基,但真正的乐趣在于实践。从本章开始,我们将走出理论的殿堂,亲手用代码构建、训练并评估一个真正的神经网络。

我们将使用的工具是 PyTorch,一个由 Facebook 人工智能研究院(FAIR)开发和维护的、当今最流行、最强大的深度学习框架之一。

在本章中,我们将一起完成以下任务:

  1. 环境搭建:安装 PyTorch 并配置好我们的开发环境。
  2. 数据加载:加载并处理经典的 MNIST 手写数字数据集。
  3. 模型构建:使用 PyTorch 的 nn 模块定义我们的神经网络结构。
  4. 模型训练:编写训练循环,实现我们学过的"前向传播 -> 计算损失 -> 反向传播 -> 更新参数"的完整流程。
  5. 模型评估与预测:在测试数据上检验我们模型的性能,并用它来进行预测。

准备好将理论转化为现实了吗?让我们开始吧!

4.1 PyTorch:优雅的深度学习利器

在我们开始编码之前,先简单了解一下为什么选择 PyTorch。

PyTorch 之所以备受学术界和工业界的青睐,主要有以下几个原因:

  • Pythonic: 它的设计哲学与 Python 高度契合,代码直观、易于上手,调试也相对简单。
  • 动态计算图: 与一些早期框架的静态图不同,PyTorch 的计算图是动态的。这意味着你可以在运行时改变网络结构,这为复杂的模型设计提供了极大的灵活性。
  • 强大的生态系统: 拥有丰富的库(如 torchvision 用于图像处理,torchaudio 用于音频处理)和活跃的社区支持。
  • 无缝的 CPU/GPU 切换: 可以非常方便地将计算任务在 CPU 和 GPU 之间切换。

PyTorch 的核心是 张量(Tensor),它是一种多维数组,与我们熟知的 NumPy ndarray 非常相似,但它有一个关键的超能力:可以在 GPU 上进行计算以加速运算。此外,PyTorch 的 自动求导机制(Autograd) 会自动为我们处理所有与梯度相关的计算,让我们从反向传播的复杂数学中解放出来。

环境搭建

现在,让我们来安装 PyTorch。官方推荐使用 condapip进行安装。最稳妥的方式是访问 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 的类来实现的。这个基类为我们提供了模型追踪、参数管理等一系列底层功能。

我们的模型需要完成以下任务:

  1. 接收一个被"压平"的 28x28 像素图像(即一个长度为 784 的一维向量)作为输入。
  2. 通过几个全连接的线性层(nn.Linear)进行变换。
  3. 在层与层之间使用非线性激活函数(例如 ReLU)来增加模型的表达能力。
  4. 最终输出一个包含 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个节点)得出分类结果。

现在,我们的模型、数据都已经准备就绪。在把它们投入训练的熔炉之前,我们还需最后两个关键组件:

  1. 损失函数(Loss Function / Criterion):定义了我们优化的"目标"。它会衡量模型输出与真实标签之间的差距。
  2. 优化器(Optimizer):定义了我们实现优化的"方法"。它会根据损失函数计算出的梯度,来更新模型的权重。

1. 损失函数

对于像 MNIST 这样的多分类问题,torch.nn.CrossEntropyLoss 是最理想的选择。它是一个非常强大的损失函数,其内部帮我们集成了两个步骤:

  1. nn.LogSoftmax():将模型的原始输出(logits)转换成对数概率。
  2. nn.NLLLoss()(Negative Log Likelihood Loss):计算这些对数概率与真实标签之间的负对数似然损失。

组合在一起,它就能非常有效地衡量我们的分类模型表现有多糟糕。我们的目标就是让这个损失值尽可能地小。

# 定义损失函数
criterion = nn.CrossEntropyLoss()

2. 优化器

优化器负责执行梯度下降算法。PyTorch 在 torch.optim 模块中提供了多种优化算法的实现,如 SGD, Adam, RMSprop 等。

我们将使用 Adam(Adaptive Moment Estimation),它是一种非常流行且通常表现优异的优化算法,它会为每个参数独立地计算自适应学习率。

在创建优化器时,我们需要告诉它两件事:

  1. 哪些参数需要被优化:我们可以通过调用 model.parameters() 来轻松获取模型中所有需要学习的参数。
  2. 学习率(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. 模型评估

评估过程与训练过程非常相似,但有几个关键区别:

  1. 开启评估模式:我们需要调用 model.eval()。这会告诉模型中的特定层(如 Dropout, BatchNorm)它们现在处于评估模式,其行为应与训练时不同。对于我们这个简单模型,虽然没有这些层,但这始终是一个好习惯。
  2. 关闭梯度计算:在评估时,我们不需要计算梯度,这可以大大加快计算速度并节省内存。我们可以使用 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`)。绝大多数情况下,它们都是绿色的(预测正确),偶尔出现一两个红色的(预测错误),让你能直观地感受到模型的强大,也能看到它犯错的样子。



网站公告

今日签到

点亮在社区的每一天
去签到