从零开始的PyTorch【03】:优化你的神经网络模型
前言
欢迎回到PyTorch学习系列的第三篇!在前两篇文章中,我们学习了如何构建一个简单的神经网络并训练它,同时探索了数据集调整对模型性能的影响。今天,我们将深入探讨如何优化你的神经网络模型,使其在更复杂的任务中表现更好。我们将使用一个具有实际意义的复杂数据集,通过调整学习率、使用不同的优化算法、应用L2正则化以及数据增强等方法来提升模型性能。
使用复杂的数据集:CIFAR-10
在这篇文章中,我们将使用CIFAR-10数据集。这是一个经典的图像分类数据集,包含10个类别的60000张32x32彩色图像(每个类别6000张),其中50000张用于训练,10000张用于测试。类别包括飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船和卡车。CIFAR-10数据集因其复杂性和实际应用场景,被广泛用于评估深度学习模型的性能。
数据集的加载和预处理
首先,我们需要加载并预处理CIFAR-10数据集。以下是数据预处理的操作:
import torch
import torchvision
import torchvision.transforms as transforms
# 定义数据预处理操作
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])
# 加载训练数据集
train_dataset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2)
# 加载测试数据集
test_dataset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=100, shuffle=False, num_workers=2)
预处理操作解释:
-
transforms.ToTensor()
:- 这个操作将图像数据从PIL格式或NumPy数组转换为PyTorch的张量格式。张量是PyTorch中处理数据的主要格式。
- 图像通常是以
H x W x C
(高度 x 宽度 x 通道数)排列的,而张量格式要求数据排列为C x H x W
,即通道数放在最前面。 - 同时,这个操作还会将像素值从范围[0, 255]转换为范围[0.0, 1.0]的浮点数值。
-
transforms.Normalize(mean, std)
:- 归一化(Normalization) 是深度学习中的常用技术,通过对数据进行归一化处理,可以加速模型训练并提高收敛性。
- 归一化操作对每个通道(RGB)的像素值进行减均值、除以标准差的处理。即对于每个像素点,使用公式
(pixel_value - mean) / std
进行归一化。 - 这里的
mean
和std
是针对CIFAR-10数据集预先计算好的值:mean = (0.4914, 0.4822, 0.4465)
:表示数据集的每个通道(R、G、B)的平均值。std = (0.2023, 0.1994, 0.2010)
:表示数据集的每个通道(R、G、B)的标准差。
- 归一化的目的是让每个通道的像素值分布具有零均值和单位方差,这样可以减少输入数据的偏差,使得模型在训练时对不同通道的数据有更一致的处理效果。
这些预处理操作是深度学习中常见的数据标准化步骤,尤其在图像分类任务中,这样的处理可以提高模型的训练效果,并加速收敛。
构建改进的神经网络模型
为了处理CIFAR-10的复杂性,我们将构建一个简单的卷积神经网络(CNN)模型。在深度学习领域,卷积神经网络(CNN)是处理图像数据的强大工具。其设计灵感源自生物视觉系统,尤其适用于捕捉图像中的空间层次结构。
模型架构设计
import torch.nn as nn
import torch.nn.functional as F
class CNN(nn.Module):
def __init__(self):
super(CNN, self).__init__()
self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.conv3 = nn.Conv2d(64, 128, kernel_size=3, padding=1)
self.fc1 = nn.Linear(128*4*4, 256)
self.fc2 = nn.Linear(256, 128)
self.fc3 = nn.Linear(128, 10)
self.pool = nn.MaxPool2d(2, 2)
def forward(self, x):
x = self.pool(F.relu(self.conv1(x)))
x = self.pool(F.relu(self.conv2(x)))
x = self.pool(F.relu(self.conv3(x)))
x = x.view(-1, 128*4*4)
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = self.fc3(x)
return x
# 创建模型实例
model = CNN()
这段代码定义了一个经典的卷积神经网络模型,主要由三部分组成:卷积层、池化层和全连接层。每一部分在整个网络中都扮演了不同的角色,帮助网络学习到从低级特征到高级特征的抽象表示。
卷积层(Convolutional Layers)
self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.conv3 = nn.Conv2d(64, 128, kernel_size=3, padding=1)
卷积层的主要作用是通过应用多个卷积核(filters)来提取输入图像的局部特征。这些特征包括边缘、纹理和复杂的图案。每一层的卷积核通过训练学习到如何从输入数据中提取这些特征。
使用多层卷积允许网络从简单的特征(如边缘)逐渐提取出更复杂的特征(如形状和对象)。第一层卷积通常可以捕获低级特征,而后续的卷积层则逐渐学习到更高级的抽象特征。
在每一层中,通道数逐步增加(从32到128),这意味着网络在逐层加深的过程中,能够捕获更多的特征。这种设计可以让模型在学习过程中拥有更多的特征表示能力。
这样做的好处是,卷积操作可以利用参数共享的机制(即所有位置上的卷积核参数相同),大大减少了需要学习的参数数量,使得模型更具泛化能力;同时,卷积核只关注局部区域的信息,从而保留了输入数据的空间结构,这对于处理图像数据至关重要。
tips:对于卷积核大小(kernel_size):常见的选择是3x3,它能够在保留足够细节的同时减少计算复杂度;而对于步幅(stride)与填充(padding):步幅通常为1,以保证特征图大小不变。填充设置为1,也是为了保持输入和输出的尺寸一致,避免特征图过快缩小。
全连接层(Fully Connected Layers)
全连接层的作用:全连接层通过连接所有神经元,将卷积层提取的特征组合起来,进行最终的分类或回归任务。它们将二维的特征图展平成一维的向量,最终输出类概率。在全连接层中,每一个输入神经元与输出神经元之间都有一个独立的权重参数,网络可以通过调整这些权重来学会特征之间的复杂关系。
这样做的好处是,全连接层能够捕捉到高维特征之间的复杂非线性关系,从而增强模型的表达能力。由于全连接层与具体任务直接相关(如分类),它们能够灵活适应各种任务需求。
tips:第一层的神经元数量通常较多(如256),以确保足够的学习能力。后续层逐渐减少神经元数量,最终输出层的神经元数应与分类任务的类别数一致(如CIFAR-10数据集的10类);在每层全连接层后使用ReLU激活函数,可以引入非线性,使模型能够学习更复杂的函数映射。
池化层(Pooling Layers)
self.pool = nn.MaxPool2d(2, 2)
池化层通过下采样操作(通常为最大池化)来减少特征图的尺寸,同时保留重要信息。池化操作有助于减小计算量,增强模型的平移不变性,并避免过拟合。在本例中,MaxPool2d(2, 2)将每个2x2的区域缩减为单个最大值,这会将特征图的尺寸减少为原来的四分之一。
这样做的好处是,通过降低特征图的分辨率,池化层能够有效减少后续层的计算复杂度;同时,池化通过降低网络的表示能力,减少了模型的过拟合风险。
tips:常用的池化窗口大小为2x2,步幅通常设置为2,以达到减半特征图尺寸的效果;常用的池化方式是最大池化(Max Pooling),它能够在每个区域中保留最显著的特征。
模型的评价
在训练完成后,我们需要对模型的性能进行全面评估,不仅要考察准确率,还要使用精确率、召回率、F1-score等其他指标,确保模型在各个类别上的表现均衡。
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix
# 初始化评估指标
all_labels = []
all_predictions = []
# 测试数据增强前的模型性能
model.eval()
with torch.no_grad():
for images, labels in test_loader:
outputs = model(images)
_, predicted = torch.max(outputs.data, 1)
all_labels.extend(labels.cpu().numpy())
all_predictions.extend(predicted.cpu().numpy())
# 计算评估指标
accuracy = accuracy_score(all_labels, all_predictions)
precision = precision_score(all_labels, all_predictions, average='weighted')
recall = recall_score(all_labels, all_predictions, average='weighted')
f1 = f1_score(all_labels, all_predictions, average='weighted')
conf_matrix = confusion_matrix(all_labels, all_predictions)
# 打印评估结果
print(f'Accuracy: {100 * accuracy:.2f} %')
print(f'Precision: {precision:.2f}')
print(f'Recall: {recall:.2f}')
print(f'F1-score: {f1:.2f}')
print('Confusion Matrix:')
print(conf_matrix)
解释:
- 准确率(Accuracy): 计算模型的整体准确率,即预测正确的样本占总样本的比例。
- 精确率(Precision): 计算模型在每个类别上预测为正类的样本中,实际为正类的比例。
- 召回率(Recall): 计算模型在每个类别上实际为正类的样本中,预测正确的比例。
- F1-score: 精确率和召回率的调和平均数,综合评价模型的分类性能。
1. 调整学习率
接下来,我们尝试使用不同的学习率,观察它们对模型训练效果的影响。
import torch.optim as optim
# 使用不同的学习率
optimizer_low_lr = optim.SGD(model.parameters(), lr=0.001)
optimizer_medium_lr = optim.SGD(model.parameters(), lr=0.01)
optimizer_high_lr = optim.SGD(model.parameters(), lr=0.1)
# 选择其中一种学习率进行训练(此处以中等学习率为例)
optimizer = optimizer_medium_lr
criterion = nn.CrossEntropyLoss()
# 训练模型
num_epochs = 20
for epoch in range(num_epochs):
model.train()
running_loss = 0.0
for i, (inputs, labels) in enumerate(train_loader):
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
if (i + 1) % 100 == 0: # 每100个batch打印一次损失
print(f'Epoch [{epoch + 1}/{num_epochs}], Step [{i + 1}/{len(train_loader)}], Loss: {running_loss / 100:.4f}')
running_loss = 0.0
结果与分析:
- 学习率为0.001:使用26m4.7s完成20个Epoch的训练,Loss从2.3040降低到了2.2412,最终的f1-分数为0.26
---------- Test -----------
Accuracy: 30.47 %
Precision: 0.31
Recall: 0.30
F1-score: 0.27
---------- Train -----------
Accuracy: 29.67 %
Precision: 0.29
Recall: 0.30
F1-score: 0.26
学习率为0.001时,模型的收敛速度较慢,因此在20个epoch内,模型的损失函数并未显著降低。这导致模型未能充分学习到数据中的特征,表现为欠拟合(underfitting)。由于欠拟合,模型在训练集和测试集上的性能都不理想,表现在较低的F1-score和准确率上。
- 学习率为0.1:使用28m52s完成20个Epoch的训练,Loss从2.2189降低到了0.0712,最终结果的f1-分数为0.78
---------- Test -----------
Accuracy: 74.58 %
Precision: 0.75
Recall: 0.75
F1-score: 0.75
---------- Train -----------
Accuracy: 98.37 %
Precision: 0.98
Recall: 0.98
F1-score: 0.98
学习率为0.1时,模型的收敛速度非常快,但由于学习率过高,模型在训练过程中可能跳过了损失函数的局部最小值,导致震荡或不稳定。在这种情况下,模型在训练集上的表现非常好,但在测试集上表现相对较差,表明模型在过拟合(overfitting)训练数据。此外,过快的收敛速度可能导致模型陷入局部最优解,限制了其泛化能力。我们不妨减少训练的Epoch,将Epoch降为10重新训练,得到的结果如下所示:
---------- Test -----------
Accuracy: 71.43 %
Precision: 0.75
Recall: 0.71
F1-score: 0.72
---------- Train -----------
Accuracy: 86.60 %
Precision: 0.90
Recall: 0.87
F1-score: 0.87
结果是无论从训练集的表现来看,还是从测试集的表现来看,都不如Epoch为20的训练成果,说明模型还未收敛,可能在10~20轮中间停止会更好,但每次训练需要较多时间,由读者可以自行探索尝试合适的Epoch。
- 学习率为0.01:使用31m53.8s完成20个Epoch的训练,Loss从2.3010降低到了0.8840,最终结果的f1-分数为0.66
---------- Test -----------
Accuracy: 65.73 %
Precision: 0.67
Recall: 0.66
F1-score: 0.65
---------- Train -----------
Accuracy: 68.81 %
Precision: 0.70
Recall: 0.69
F1-score: 0.68
学习率为0.01时,模型的收敛速度适中。模型能够在有限的epoch内逐渐降低损失函数,并在训练集和测试集上都表现出相对较好的性能。这说明学习率为0.01是一个较为合理的选择,既避免了过慢的收敛速度,也防止了过快的学习导致的过拟合。然而,这仍然表明模型有一定的欠拟合现象,因为训练集和测试集的F1-score差距不大。
2. 使用不同的优化算法
前面我们应用的是SGD优化器,SGD优化器是深度学习中最基本的优化方法。它能够有效地优化模型参数,但由于其本质上是梯度下降方法,收敛速度较慢,尤其是在面对稀疏梯度或噪声较大的数据时。实验结果表明,在较高学习率下(0.1),SGD优化器能够较好地稳定收敛,并取得较好的测试集性能;而在较低学习率下(0.1),SGD优化器的表现则不够理想,可能因为步长过小,训练轮次过短,模型欠拟合。
除了SGD,我们还可以使用其他优化算法,如Adam和RMSprop。
# 使用Adam优化器
optimizer_adam = optim.Adam(model.parameters(), lr=0.1)
# 使用RMSprop优化器
optimizer_rmsprop = optim.RMSprop(model.parameters(), lr=0.1)
# 选择其中一种优化器进行训练(此处以Adam为例)
optimizer = optimizer_adam
num_epochs = 20
# 训练模型(代码与之前相同)
for epoch in range(num_epochs):
model.train()
running_loss = 0.0
for i, (inputs, labels) in enumerate(train_loader):
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
if (i + 1) % 100 == 0: # 每100个batch打印一次损失
print(f'Epoch [{epoch + 1}/{num_epochs}], Step [{i + 1}/{len(train_loader)}], Loss: {running_loss / 100:.4f}')
running_loss = 0.0
结果与分析:
- Adam优化器:通常比SGD更快收敛,尤其在处理稀疏梯度和具有噪声的数据时表现更好。
学习率为0.01的情况
---------- Test -----------
Accuracy: 53.99 %
Precision: 0.56
Recall: 0.54
F1-score: 0.54
---------- Train -----------
Accuracy: 60.25 %
Precision: 0.62
Recall: 0.60
F1-score: 0.60
学习率为0.001的情况
---------- Test -----------
Accuracy: 74.89 %
Precision: 0.75
Recall: 0.75
F1-score: 0.75
---------- Train -----------
Accuracy: 97.18 %
Precision: 0.97
Recall: 0.97
F1-score: 0.97
Adam优化器结合了动量(Momentum)和自适应学习率(Adaptive Learning Rate)的优点,使得它在处理稀疏梯度和噪声较大的数据时表现更好。实验结果显示,Adam优化器能够在较低的学习率下快速收敛并达到较好的性能。因此,Adam优化器比SGD更适合处理复杂的深度学习任务,尤其是在训练时间有限或数据复杂度较高的情况下。而且,相对于SGD需要选择较大的学习率,Adam优化器仅需要选择较小的学习率即可。
- RMSprop优化器:适用于非平稳目标函数,常用于RNN和其他序列模型。
学习率为0.01的情况:
---------- Test -----------
Accuracy: 10.00 %
Precision: 0.01
Recall: 0.10
F1-score: 0.02
---------- Train -----------
Accuracy: 10.00 %
Precision: 0.01
Recall: 0.10
F1-score: 0.02
学习率0.001的情况:
---------- Test -----------
Accuracy: 73.83 %
Precision: 0.74
Recall: 0.74
F1-score: 0.74
---------- Train -----------
Accuracy: 98.57 %
Precision: 0.99
Recall: 0.99
F1-score: 0.99
RMSprop优化器特别适合处理非平稳目标函数,常用于RNN和其他序列模型。实验结果表明,在较低学习率下(0.001),RMSprop能够较好地优化模型并取得与Adam类似的性能;但在较高学习率下(0.01),RMSprop表现不佳,可能是因为RMSprop对学习率较为敏感,高学习率导致了不稳定的更新和较差的模型性能。
3. 使用L2正则化
在之前最优的实验条件,即Adam优化器,学习率为0.001的条件下进行优化
L2正则化通过限制权重的大小来防止模型过拟合。我们可以通过在优化器中添加weight_decay
参数来实现。
# 使用L2正则化
optimizer_l2 = optim.Adam(model.parameters(), lr=0.001, weight_decay=0.0005)
# 训练模型(代码与之前相同)
for epoch in range(num_epochs):
model.train()
running_loss = 0.0
for i, (inputs, labels) in enumerate(train_loader):
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
if (i + 1) % 100 == 0: # 每100个batch打印一次损失
print(f'Epoch [{epoch + 1}/{num_epochs}], Step [{i + 1}/{len(train_loader)}], Loss: {running_loss / 100:.4f}')
running_loss = 0.0
结果与分析:
---------- Test -----------
Accuracy: 75.71 %
Precision: 0.77
Recall: 0.76
F1-score: 0.76
---------- Train -----------
Accuracy: 96.80 %
Precision: 0.97
Recall: 0.97
F1-score: 0.97
引入L2正则化后,测试集上的F1-score为0.76,训练集上的F1-score为0.97,与未使用正则化的模型相比,测试集的性能有所提升,而训练集的性能略微下降。
L2正则化通过对模型参数施加惩罚,限制了权重的过度增长,从而防止了模型的过拟合。实验结果显示,引入L2正则化后,模型在测试集上的表现有所提升,这表明正则化有效地增强了模型的泛化能力。然而,训练集的性能略微下降,这也是正则化的预期效果,因为它在一定程度上限制了模型对训练数据的拟合程度。总体而言,L2正则化在平衡模型复杂度和泛化能力方面起到了积极作用。
4. 数据增强的重要性
现在,我们在模型中加入数据增强操作,观察其对模型泛化能力的提升效果。
# 定义数据增强和标准化操作
transform_with_augmentation = transforms.Compose([
transforms.RandomHorizontalFlip(),
transforms.RandomCrop(32, padding=4),
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])
# 使用数据增强后的数据集
train_dataset_augmented = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_with_augmentation)
train_loader_augmented = torch.utils.data.DataLoader(train_dataset_augmented, batch_size=128, shuffle=True, num_workers=2)
# 使用Adam优化器进行训练
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 训练模型
for epoch in range(num_epochs):
model.train()
running_loss = 0.0
for i, (inputs, labels) in enumerate(train_loader_augmented):
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
if (i + 1) % 100 == 0: # 每100个batch打印一次损失
print(f'Epoch [{epoch + 1}/{num_epochs}], Step [{i + 1}/{len(train_loader_augmented)}], Loss: {running_loss / 100:.4f}')
running_loss = 0.0
结果与分析:
- 数据增强前
---------- Test -----------
Accuracy: 75.71 %
Precision: 0.77
Recall: 0.76
F1-score: 0.76
---------- Train -----------
Accuracy: 96.80 %
Precision: 0.97
Recall: 0.97
F1-score: 0.97
- 数据增强后
---------- Test -----------
Accuracy: 77.73 %
Precision: 0.79
Recall: 0.78
F1-score: 0.78
---------- Train -----------
Accuracy: 81.29 %
Precision: 0.82
Recall: 0.81
F1-score: 0.81
对比数据增强前后的模型性能
结果与分析:
在未使用数据增强的情况下,模型在训练数据上表现非常好,但在测试数据上却表现相对较差。这表明模型在训练过程中过度拟合了训练数据中的特征,导致泛化能力不足。因此,在实际应用中,这样的模型可能无法处理未见过的新数据。
引入数据增强后,模型在测试集上的表现显著提升,这说明通过增加训练数据的多样性,模型能够更好地适应新数据,泛化能力得到了增强。与此同时,训练集的性能略有下降,这表明数据增强有助于减轻过拟合现象。在实际应用中,数据增强是提升模型泛化能力的有效手段,尤其在训练数据量有限或数据具有较大变异性的情况下。
小结
在这篇文章中,我们使用CIFAR-10数据集,探讨了如何在更复杂的任务中优化神经网络模型。我们通过调整学习率、选择不同的优化算法、应用L2正则化以及数据增强,逐步提升了模型的性能。数据增强尤其显著提升了模型的泛化能力,体现了其在深度学习任务中的重要性。
在下一篇文章中,我们将进一步探讨迁移学习和预训练模型的应用,帮助你在大规模数据集和复杂任务中取得更好的结果。敬请期待!
课后练习
为了巩固今天的学习内容,建议你尝试以下练习:
- 调整卷积层和全连接层的结构,观察其对模型性能的影响。
- 尝试不同的数据增强策略,如颜色抖动、随机旋转等,观察其对模型的影响。
- 结合学习率调度器,进一步优化模型的训练过程。
如果在练习过程中遇到问题,欢迎随时留言讨论。希望你能继续享受PyTorch学习的过程!