一、引言:从机器学习到深度学习

在之前的学习中,我们学习了决策树、随机森林、SVM等经典机器学习算法。这些算法在很多场景下表现出色,但它们有一个共同的特点:需要人工设计和提取特征。比如,要让计算机识别手写数字,我们需要告诉它“这个笔画是什么形状”“这个区域的像素密度是多少”——这被称为特征工程

深度学习彻底改变了这一局面。它让神经网络自动从原始数据中学习特征——我们只需要把图片的原始像素喂给网络,网络就能自己学会识别数字。这种“端到端”的学习方式,是深度学习最强大的能力。

本篇博客将基于PyTorch框架,带领大家从零开始构建一个识别手写数字的神经网络。我们将学习张量、数据集加载、神经网络构建、训练循环、损失函数、优化器等核心概念,并通过MNIST数据集完成一个完整的深度学习实战项目。

二、PyTorch与深度学习基础

2.1 什么是PyTorch

PyTorch是当前最流行的深度学习框架之一,由Facebook(现Meta)开源。它以动态计算图为核心,代码风格接近Python原生语法,非常适合研究和开发。

PyTorch生态包含三个核心库:

功能
torch核心库,提供张量计算、自动求导、神经网络模块
torchvision视觉工具库,提供数据集、模型、图像变换
torchaudio音频工具库,提供音频处理和数据集
import torch
import torchvision
import torchaudio
print(torch.__version__)        # 查看PyTorch版本
print(torchvision.__version__)  # 查看torchvision版本

2.2 张量(Tensor)

张量是PyTorch中的核心数据结构,可以理解为多维数组。它与NumPy数组类似,但有两大优势:

  1. 支持GPU加速:张量可以在GPU上运行,大幅提升计算速度

  2. 自动求导:张量可以记录计算图,自动计算梯度

import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets
from torchvision.transforms import ToTensor

张量 vs NumPy数组

对比项NumPy数组PyTorch张量
运行位置仅CPUCPU/GPU均可
自动求导不支持支持
深度学习集成需手动转换原生支持

三、MNIST数据集——深度学习的“Hello World”

3.1 MNIST简介

MNIST(Modified National Institute of Standards and Technology)是手写数字识别领域最经典的数据集:

  • 70000张手写数字图像(0-9)

  • 60000张用于训练,10000张用于测试

  • 每张图像为28×28像素的灰度图

  • 数字居中显示,减少了预处理工作

3.2 加载数据集

from torchvision import datasets
from torchvision.transforms import ToTensor

# 下载训练数据集
training_data = datasets.MNIST(
    root="data",              # 数据保存路径
    train=True,               # 加载训练集
    download=True,            # 自动下载
    transform=ToTensor(),     # 转为张量
)

# 下载测试数据集
test_data = datasets.MNIST(
    root="data", 
    train=False, 
    download=True,
    transform=ToTensor()
)

ToTensor()的作用:将PIL图像或NumPy数组转换为PyTorch张量,同时将像素值从0-255缩放到0-1之间。这是因为神经网络对0-1范围的数据更敏感,训练更稳定。

3.3 数据可视化

在训练之前,让我们先看看数据长什么样:

from matplotlib import pyplot as plt

figure = plt.figure()
for i in range(9):
    img, label = training_data[i]
    figure.add_subplot(3, 3, i+1)    # 3×3子图
    plt.title(label)                  # 显示标签
    plt.axis('off')                   # 关闭坐标轴
    plt.imshow(img.squeeze(), cmap='gray')  # 灰度显示
plt.show()

img.squeeze()的作用是去掉维度为1的维度(从[1, 28, 28]变成[28, 28]),这样plt.imshow()才能正确显示。

四、DataLoader——高效的数据管理

4.1 什么是DataLoader

DataLoader是PyTorch提供的数据加载工具,它帮助我们:

  • 批量加载:将数据集分成多个批次(batch),每次训练只加载一批

  • 打乱顺序:每个epoch重新打乱数据,避免模型学到顺序规律

  • 并行加载:使用多线程加速数据读取

from torch.utils.data import DataLoader

train_dataloader = DataLoader(training_data, batch_size=64)
test_dataloader = DataLoader(test_data, batch_size=64)

for x, y in test_dataloader:
    print(f"Shape of x [N, C, H, W]: {x.shape}")  # [64, 1, 28, 28]
    print(f"Shape of y: {y.shape} {y.dtype}")      # [64] int64
    break

批次数据的维度含义

维度含义示例值
N批次大小64
C通道数1(灰度图)
H高度28
W宽度28

为什么要分批?

  • 减少内存占用(一次处理64张而不是60000张)

  • 提高训练速度(GPU并行处理)

  • 引入随机性,有助于跳出局部最优

五、设备选择——CPU还是GPU

深度学习模型训练涉及大量矩阵运算,GPU的并行计算能力比CPU强大几十倍甚至上百倍。

device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
print(f"Using {device} device")

这段代码依次检查:

  1. CUDA:NVIDIA显卡的GPU加速(Windows/Linux)

  2. MPS:苹果M系列芯片的GPU加速(Mac)

  3. CPU:都不可用时回退到CPU

重要提醒:模型和数据都必须放到同一个设备上!如果模型在GPU上,数据在CPU上,会报错。

六、构建神经网络

6.1 神经网络类的结构

在PyTorch中,构建神经网络需要继承nn.Module类,并实现两个方法:

class NeuralNetwork(nn.Module):
    def __init__(self):
        super().__init__()              # 继承父类初始化
        self.flatten = nn.Flatten()     # 展平层
        self.hidden1 = nn.Linear(28*28, 128)  # 输入层→隐藏层1
        self.hidden2 = nn.Linear(128, 256)    # 隐藏层1→隐藏层2
        self.hidden3 = nn.Linear(256, 128)    # 隐藏层2→隐藏层3
        self.out = nn.Linear(128, 10)         # 隐藏层3→输出层
    
    def forward(self, x):
        x = self.flatten(x)       # 展平:[64,1,28,28] → [64, 784]
        x = self.hidden1(x)
        x = torch.sigmoid(x)      # 激活函数
        x = self.hidden2(x)
        x = torch.sigmoid(x)
        x = self.hidden3(x)
        x = torch.sigmoid(x)
        x = self.out(x)           # 输出10个类别的得分
        return x

model = NeuralNetwork().to(device)
print(model)

6.2 网络层详解

nn.Flatten():将多维输入展平为一维。28×28的图像被展平成784维向量,因为全连接层只接受一维输入。

nn.Linear(in, out):全连接层,执行 y = xW^{T} + b 的线性变换。

输入维度输出维度参数数量
hidden1784128784×128 + 128 = 100480
hidden2128256128×256 + 256 = 33024
hidden3256128256×128 + 128 = 32896
out12810128×10 + 10 = 1290

输出层为什么是10? 因为MNIST有10个类别(数字0-9),网络需要输出每个类别的得分。

6.3 激活函数——引入非线性

激活函数是神经网络的灵魂。如果没有激活函数,多层网络本质上还是一个线性变换,无法拟合复杂函数。

x = torch.sigmoid(x)    # Sigmoid激活函数

Sigmoid函数:将输入压缩到(0,1)区间,公式为:

\sigma \left ( x \right ) = \frac{1}{1+e^{-x}}

Sigmoid的缺点

  • 容易导致梯度消失(当输入很大或很小时,梯度接近0)

  • 输出不是以0为中心

现代网络更常用的激活函数

  • ReLUtorch.relu(x),计算简单,缓解梯度消失

  • Tanhtorch.tanh(x),输出以0为中心

  • LeakyReLU:ReLU的改进版,避免神经元死亡

七、训练函数——让网络学习

7.1 训练流程

训练神经网络的核心流程可以概括为五步:

1. 前向传播:计算预测值
2. 计算损失:衡量预测与真实的差距
3. 梯度清零:清除上一轮的梯度
4. 反向传播:计算每个参数的梯度
5. 更新参数:根据梯度调整权重

7.2 训练代码

def train(dataloader, model, loss_fn, optimizer):
    model.train()    # 切换到训练模式
    
    batch_size_num = 1
    for x, y in dataloader:
        x, y = x.to(device), y.to(device)    # 数据传入设备
        
        # 1. 前向传播
        pred = model.forward(x)
        
        # 2. 计算损失
        loss = loss_fn(pred, y)
        
        # 3. 梯度清零
        optimizer.zero_grad()
        
        # 4. 反向传播
        loss.backward()
        
        # 5. 更新参数
        optimizer.step()
        
        loss_value = loss.item()
        if batch_size_num % 100 == 0:
            print(f"loss: {loss_value:>7f} [number: {batch_size_num}]")
        batch_size_num += 1

7.3 关键概念详解

model.train() vs model.eval()

  • train():启用Dropout、BatchNorm等训练专用层

  • eval():关闭这些层,使用固定的推理模式

optimizer.zero_grad():PyTorch默认会累积梯度,所以每轮迭代前必须清零,否则梯度会一直累加。

loss.backward():自动计算所有参数的梯度(自动求导),这是PyTorch最强大的功能之一。

optimizer.step():根据计算出的梯度更新参数,如 w = w -lr\times \triangledown w

八、测试函数——评估模型性能

def test(dataloader, model, loss_fn):
    size = len(dataloader.dataset)    # 数据集大小(10000)
    num_batches = len(dataloader)     # 批次数量
    model.eval()                       # 切换到测试模式
    
    test_loss, correct = 0, 0
    with torch.no_grad():              # 不计算梯度,节省内存
        for x, y in dataloader:
            x, y = x.to(device), y.to(device)
            pred = model.forward(x)
            test_loss += loss_fn(pred, y).item()
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()
    
    test_loss /= num_batches    # 平均损失
    correct /= size             # 准确率
    print(f"Accuracy: {(100*correct)}%, Avg loss: {test_loss}")

关键点解析

  • torch.no_grad():测试时不需要计算梯度,关闭它可以节省大量内存和计算资源

  • pred.argmax(1):取输出向量中最大值的索引,即预测的类别。dim=1表示按行取最大值

  • (pred.argmax(1) == y):比较预测类别与真实标签,得到布尔张量

  • .type(torch.float).sum().item():将布尔值转为浮点数后求和,得到正确预测的数量

九、损失函数与优化器

9.1 交叉熵损失函数

loss_fn = nn.CrossEntropyLoss()

交叉熵损失是分类任务中最常用的损失函数,公式为:

L = -\sum_{i=1}^{C} y_{i}log(\hat{ y_{i}})

其中 y_{i} 是真实标签的one-hot编码,\hat{y_{i}}​ 是模型预测的概率。

为什么用交叉熵?

  • 它衡量的是两个概率分布之间的差异

  • 配合Softmax使用,梯度形式简洁,训练稳定

  • 对错误预测的惩罚更大,促使模型快速学习

9.2 优化器——Adam

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

Adam(Adaptive Moment Estimation)是目前最流行的优化器之一,它结合了:

  • 动量法:利用历史梯度的指数加权平均,加速收敛

  • RMSProp:自适应调整每个参数的学习率

Adam的优势

  • 自适应学习率,不需要手动调整太多

  • 收敛速度快,适合大多数任务

  • 对超参数不敏感

学习率(lr):控制参数更新的步长。

  • 太大:容易震荡,无法收敛

  • 太小:收敛太慢,训练时间长

  • Adam通常推荐0.001作为初始值

其他常用优化器

  • SGD:随机梯度下降,基础但需要精心调参

  • RMSProp:适合处理非平稳目标

  • AdamW:Adam的改进版,权重衰减更合理

十、训练与测试——完整流程

epochs = 10
for t in range(epochs):
    print(f"Epoch {t+1}\n-----------------------------------")
    train(train_dataloader, model, loss_fn, optimizer)
print("Done!")

test(test_dataloader, model, loss_fn)

Epoch:完整遍历一次训练数据集。通常需要多个epoch才能让模型充分学习。

训练过程观察

  • 随着epoch增加,loss逐渐下降,说明模型在学习

  • 如果训练loss下降但测试loss上升,说明出现了过拟合

  • 如果loss一直不下降,可能是学习率太小或网络结构有问题

十一、完整代码总结

import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets
from torchvision.transforms import ToTensor
from matplotlib import pyplot as plt

# 1. 加载数据
training_data = datasets.MNIST(root="data", train=True, download=True, transform=ToTensor())
test_data = datasets.MNIST(root="data", train=False, download=True, transform=ToTensor())

# 2. 创建DataLoader
train_dataloader = DataLoader(training_data, batch_size=64)
test_dataloader = DataLoader(test_data, batch_size=64)

# 3. 选择设备
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"

# 4. 构建模型
class NeuralNetwork(nn.Module):
    def __init__(self):
        super().__init__()
        self.flatten = nn.Flatten()
        self.hidden1 = nn.Linear(28*28, 128)
        self.hidden2 = nn.Linear(128, 256)
        self.hidden3 = nn.Linear(256, 128)
        self.out = nn.Linear(128, 10)
    def forward(self, x):
        x = self.flatten(x)
        x = torch.sigmoid(self.hidden1(x))
        x = torch.sigmoid(self.hidden2(x))
        x = torch.sigmoid(self.hidden3(x))
        x = self.out(x)
        return x

model = NeuralNetwork().to(device)

# 5. 定义损失函数和优化器
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# 6. 训练
def train(dataloader, model, loss_fn, optimizer):
    model.train()
    for batch, (x, y) in enumerate(dataloader):
        x, y = x.to(device), y.to(device)
        pred = model(x)
        loss = loss_fn(pred, y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

# 7. 测试
def test(dataloader, model, loss_fn):
    model.eval()
    size = len(dataloader.dataset)
    correct = 0
    with torch.no_grad():
        for x, y in dataloader:
            x, y = x.to(device), y.to(device)
            pred = model(x)
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()
    print(f"Accuracy: {100*correct/size}%")

# 8. 执行训练和测试
epochs = 10
for t in range(epochs):
    print(f"Epoch {t+1}")
    train(train_dataloader, model, loss_fn, optimizer)
test(test_dataloader, model, loss_fn)

十二、总结

本篇博客通过一个完整的手写数字识别项目,系统学习了PyTorch深度学习的核心知识:

知识点核心内容
张量PyTorch的核心数据结构,支持GPU加速和自动求导
MNIST数据集手写数字识别的经典数据集,60000训练+10000测试
DataLoader批量加载数据,提高训练效率
神经网络继承nn.Module,定义__init__forward
激活函数Sigmoid/ReLU,引入非线性
损失函数交叉熵,衡量预测与真实的差距
优化器Adam,自适应学习率,快速收敛
训练循环前向传播→计算损失→梯度清零→反向传播→更新参数
测试评估model.eval() + torch.no_grad(),计算准确率

更多推荐