import collections
import math
import os
import shutil
import pandas as pd
from mxnet import gluon, init, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l
npx.set_np()
获取并组织数据集
比赛数据集分为训练集和测试集,其中训练集包含50000张、测试集包含300000张图像。 在测试集中,10000张图像将被用于评估,而剩下的290000张图像将不会被进行评估,包含它们只是为了防止手动标记测试集并提交标记结果。 两个数据集中的图像都是png格式,高度和宽度均为32像素并有三个颜色通道(RGB)。 这些图片共涵盖10个类别:飞机、汽车、鸟类、猫、鹿、狗、青蛙、马、船和卡车。
下载数据集
为了便于入门,[我们提供包含前1000个训练图像和5个随机测试图像的数据集的小规模样本]
#@save
d2l.DATA_HUB['cifar10_tiny'] = (d2l.DATA_URL + 'kaggle_cifar10_tiny.zip',
'2068874e4b9a9f0fb07ebe0ad2b29754449ccacd')
# 如果使用完整的Kaggle竞赛的数据集,设置demo为False
demo = True
if demo:
data_dir = d2l.download_extract('cifar10_tiny')
else:
data_dir = '../data/cifar-10/'
[整理数据集]
我们需要整理数据集来训练和测试模型。 首先,我们用以下函数读取CSV文件中的标签,它返回一个字典,该字典将文件名中不带扩展名的部分映射到其标签。
#@save
def read_csv_labels(fname):
"""读取fname来给标签字典返回一个文件名"""
with open(fname, 'r') as f:
# 跳过文件头行(列名)
lines = f.readlines()[1:]
tokens = [l.rstrip().split(',') for l in lines]
return dict(((name, label) for name, label in tokens))
labels = read_csv_labels(os.path.join(data_dir, 'trainLabels.csv'))
print('# 训练样本 :', len(labels))
print('# 类别 :', len(set(labels.values())))
[将验证集从原始的训练集中拆分出来]
接下来,我们定义reorg_train_valid
函数来[将验证集从原始的训练集中拆分出来]。 此函数中的参数valid_ratio
是验证集中的样本数与原始训练集中的样本数之比。 更具体地说,令𝑛n等于样本最少的类别中的图像数量,而𝑟r是比率。 验证集将为每个类别拆分出max(⌊𝑛𝑟⌋,1)max(⌊nr⌋,1)张图像。 让我们以valid_ratio=0.1
为例,由于原始的训练集有50000张图像,因此train_valid_test/train
路径中将有45000张图像用于训练,而剩下5000张图像将作为路径train_valid_test/valid
中的验证集。 组织数据集后,同类别的图像将被放置在同一文件夹下。
#@save
def copyfile(filename, target_dir):
"""将文件复制到目标目录"""
os.makedirs(target_dir, exist_ok=True)
shutil.copy(filename, target_dir)
#@save
def reorg_train_valid(data_dir, labels, valid_ratio):
"""将验证集从原始的训练集中拆分出来"""
# 训练数据集中样本最少的类别中的样本数
n = collections.Counter(labels.values()).most_common()[-1][1]
# 验证集中每个类别的样本数
n_valid_per_label = max(1, math.floor(n * valid_ratio))
label_count = {}
for train_file in os.listdir(os.path.join(data_dir, 'train')):
label = labels[train_file.split('.')[0]]
fname = os.path.join(data_dir, 'train', train_file)
copyfile(fname, os.path.join(data_dir, 'train_valid_test',
'train_valid', label))
if label not in label_count or label_count[label] < n_valid_per_label:
copyfile(fname, os.path.join(data_dir, 'train_valid_test',
'valid', label))
label_count[label] = label_count.get(label, 0) + 1
else:
copyfile(fname, os.path.join(data_dir, 'train_valid_test',
'train', label))
return n_valid_per_label
copyfile:
- 功能:将指定的文件复制到目标目录。
- 参数:
filename
: 要复制的文件的路径。target_dir
: 目标目录的路径。- 执行流程:
- 使用
os.makedirs
创建目标目录,如果目录已存在,则exist_ok=True
会避免抛出错误。- 使用
shutil.copy
将文件从filename
复制到target_dir
。reorg_train_valid:
- 功能:从原始的训练集中拆分出验证集,确保每个类别在验证集中有相同数量的样本。
- 参数:
data_dir
: 数据集的根目录。labels
: 一个字典,键是文件名(不包含扩展名),值是对应的标签。valid_ratio
: 用于确定验证集大小的比例。- 执行流程:
- 首先,计算训练集中每个类别的样本数,并找出最少样本数
n
。- 计算每个类别在验证集中应有的样本数
n_valid_per_label
,至少为1。- 初始化一个字典
label_count
来跟踪每个类别已经复制到验证集中的样本数。- 遍历
data_dir/train
目录中的所有文件:
- 对于每个文件,获取其标签,并确定文件的完整路径。
- 将文件复制到
train_valid
目录下相应的标签子目录。- 如果该标签的已复制样本数小于
n_valid_per_label
,则将文件复制到valid
目录;否则,复制到train
目录。- 更新
label_count
字典。- 返回值:返回每个类别在验证集中的样本数
n_valid_per_label
。这些函数使用了
os
和shutil
模块来处理文件和目录操作,以及collections
和math
模块来进行计数和数学计算。copyfile
函数是reorg_train_valid
函数的一个辅助函数,用于复制文件。
下面的reorg_test
函数用来[在预测期间整理测试集,以方便读取]。
#@save
def reorg_test(data_dir):
"""在预测期间整理测试集,以方便读取"""
for test_file in os.listdir(os.path.join(data_dir, 'test')):
copyfile(os.path.join(data_dir, 'test', test_file),
os.path.join(data_dir, 'train_valid_test', 'test',
'unknown'))
最后,我们使用一个函数来[调用前面定义的函数]read_csv_labels
、reorg_train_valid
和reorg_test
。
def reorg_cifar10_data(data_dir, valid_ratio):
labels = read_csv_labels(os.path.join(data_dir, 'trainLabels.csv'))
reorg_train_valid(data_dir, labels, valid_ratio)
reorg_test(data_dir)
在这里,我们只将样本数据集的批量大小设置为32。 在实际训练和测试中,应该使用Kaggle竞赛的完整数据集,并将batch_size
设置为更大的整数,例如128。 我们将10%的训练样本作为调整超参数的验证集。
batch_size = 32 if demo else 128
valid_ratio = 0.1
reorg_cifar10_data(data_dir, valid_ratio)
[图像增广]
我们使用图像增广来解决过拟合的问题。例如在训练中,我们可以随机水平翻转图像。 我们还可以对彩色图像的三个RGB通道执行标准化。 下面,我们列出了其中一些可以调整的操作。
transform_train = gluon.data.vision.transforms.Compose([
# 在高度和宽度上将图像放大到40像素的正方形
gluon.data.vision.transforms.Resize(40),
# 随机裁剪出一个高度和宽度均为40像素的正方形图像,
# 生成一个面积为原始图像面积0.64~1倍的小正方形,
# 然后将其缩放为高度和宽度均为32像素的正方形
gluon.data.vision.transforms.RandomResizedCrop(32, scale=(0.64, 1.0),
ratio=(1.0, 1.0)),
gluon.data.vision.transforms.RandomFlipLeftRight(),
gluon.data.vision.transforms.ToTensor(),
# 标准化图像的每个通道
gluon.data.vision.transforms.Normalize([0.4914, 0.4822, 0.4465],
[0.2023, 0.1994, 0.2010])])
Resize:
gluon.data.vision.transforms.Resize(40)
: 将图像的高度和宽度放大或缩小到40像素,使其成为一个正方形。RandomResizedCrop:
gluon.data.vision.transforms.RandomResizedCrop(32, scale=(0.64, 1.0), ratio=(1.0, 1.0))
: 随机裁剪图像,生成一个面积为原始图像面积的64%到100%的小正方形。然后,将这个小正方形缩放到32x32像素的大小。ratio=(1.0, 1.0)
表示裁剪区域的纵横比将保持为1,即正方形。RandomFlipLeftRight:
gluon.data.vision.transforms.RandomFlipLeftRight()
: 随机水平翻转图像,这增加了数据的多样性并有助于模型的泛化。ToTensor:
gluon.data.vision.transforms.ToTensor()
: 将PIL图像或Numpy数组转换为torch.FloatTensor
类型,并将图像的数值范围从[0, 255]归一化到[0.0, 1.0]。Normalize:
gluon.data.vision.transforms.Normalize([0.4914, 0.4822, 0.4465], [0.2023, 0.1994, 0.2010])
: 标准化图像的每个通道,使用给定的均值[0.4914, 0.4822, 0.4465]
和标准差[0.2023, 0.1994, 0.2010]
。这些值通常是ImageNet数据集的均值和标准差,但可以根据具体数据集进行调整。
transform_train
是通过Compose
函数组合这些单独的变换步骤而成的,确保它们将按顺序应用于图像。这种类型的转换流程在数据加载和预处理阶段非常有用,特别是在使用gluon.data.DataLoader
加载图像数据时。
在测试期间,我们只对图像执行标准化,以消除评估结果中的随机性。
transform_test = gluon.data.vision.transforms.Compose([
gluon.data.vision.transforms.ToTensor(),
gluon.data.vision.transforms.Normalize([0.4914, 0.4822, 0.4465],
[0.2023, 0.1994, 0.2010])])
读取数据集
接下来,我们[读取由原始图像组成的数据集],每个样本都包括一张图片和一个标签。
train_ds, valid_ds, train_valid_ds, test_ds = [
gluon.data.vision.ImageFolderDataset(
os.path.join(data_dir, 'train_valid_test', folder))
for folder in ['train', 'valid', 'train_valid', 'test']]
在训练期间,我们需要[指定上面定义的所有图像增广操作]。 当验证集在超参数调整过程中用于模型评估时,不应引入图像增广的随机性。 在最终预测之前,我们根据训练集和验证集组合而成的训练模型进行训练,以充分利用所有标记的数据。
train_iter, train_valid_iter = [gluon.data.DataLoader(
dataset.transform_first(transform_train), batch_size, shuffle=True,
last_batch='discard') for dataset in (train_ds, train_valid_ds)]
valid_iter = gluon.data.DataLoader(
valid_ds.transform_first(transform_test), batch_size, shuffle=False,
last_batch='discard')
test_iter = gluon.data.DataLoader(
test_ds.transform_first(transform_test), batch_size, shuffle=False,
last_batch='keep')
变量定义:
train_iter
: 训练数据的迭代器。train_valid_iter
: 训练和验证数据的迭代器(如果有的话)。valid_iter
: 验证数据的迭代器。test_iter
: 测试数据的迭代器。batch_size
: 每个批次加载的样本数量,这个变量在代码中没有直接定义,应该在外部定义好。训练数据迭代器 (
train_iter
和train_valid_iter
):
- 使用列表推导式创建两个迭代器。对于
train_ds
和train_valid_ds
数据集,应用了transform_train
变换流程。dataset.transform_first(transform_train)
: 将transform_train
变换应用到数据集的第一个元素,即图像数据。shuffle=True
: 在每个epoch开始时对数据进行洗牌。last_batch='discard'
: 如果最后一个批次的样本数量少于batch_size
,则丢弃这个批次。验证数据迭代器 (
valid_iter
):
- 使用
valid_ds
数据集,应用transform_test
变换流程。shuffle=False
: 不对验证数据进行洗牌,因为验证集通常不需要洗牌。last_batch='discard'
: 同上,丢弃样本数量不足的最后一个批次。测试数据迭代器 (
test_iter
):
- 使用
test_ds
数据集,应用transform_test
变换流程。shuffle=False
: 测试集同样不需要洗牌。last_batch='keep'
: 与训练和验证不同,测试集通常会保留最后一个不足batch_size
的批次,以确保所有样本都被评估。
定义[模型]
在这里,我们基于HybridBlock
类构建剩余块
class Residual(nn.HybridBlock):
def __init__(self, num_channels, use_1x1conv=False, strides=1, **kwargs):
super(Residual, self).__init__(**kwargs)
self.conv1 = nn.Conv2D(num_channels, kernel_size=3, padding=1,
strides=strides)
self.conv2 = nn.Conv2D(num_channels, kernel_size=3, padding=1)
if use_1x1conv:
self.conv3 = nn.Conv2D(num_channels, kernel_size=1,
strides=strides)
else:
self.conv3 = None
self.bn1 = nn.BatchNorm()
self.bn2 = nn.BatchNorm()
def hybrid_forward(self, F, X):
Y = F.npx.relu(self.bn1(self.conv1(X)))
Y = self.bn2(self.conv2(Y))
if self.conv3:
X = self.conv3(X)
return F.npx.relu(Y + X)
类定义和构造函数:
class Residual(nn.HybridBlock)
: 定义了一个继承自nn.HybridBlock
的类,这意味着这个块可以在图的编译模式下运行,也可以在命令式模式下运行。def __init__(self, num_channels, use_1x1conv=False, strides=1, **kwargs)
: 构造函数接受以下参数:
num_channels
: 卷积层的通道数。use_1x1conv
: 一个布尔值,指示是否使用1x1卷积来匹配通道数和降低维度。strides
: 卷积层的步长。**kwargs
: 其他关键字参数,用于传递给父类nn.HybridBlock
。成员变量:
self.conv1
: 第一个卷积层,使用3x3的卷积核,填充为1,步长由构造函数参数strides
决定。self.conv2
: 第二个卷积层,也是3x3的卷积核,填充为1。self.conv3
: 可选的1x1卷积层,仅当use_1x1conv
为True
时创建,用于匹配通道数或降低维度。self.bn1
和self.bn2
: 两个批量归一化层,用于归一化卷积层的输出。前向传播方法:
def hybrid_forward(self, F, X)
: 定义了块的前向传播逻辑。F
是一个函数接口,用于执行所需的操作,X
是输入数据。
Y = F.npx.relu(self.bn1(self.conv1(X)))
: 应用第一个卷积层,批量归一化,然后进行ReLU激活函数。Y = self.bn2(self.conv2(Y))
: 应用第二个卷积层和批量归一化。if self.conv3:
: 如果存在1x1卷积层,则对输入X
应用这个卷积层。return F.npx.relu(Y + X)
: 将经过两个卷积层的输出Y
和可能经过1x1卷积的输入X
相加,然后应用ReLU激活函数作为最终输出。使用示例:
这个残差单元可以作为构建更深层次网络的组件。例如,在构建一个残差网络时,可以在网络中堆叠多个这样的残差单元,并通过调整
num_channels
、use_1x1conv
和strides
参数来控制网络的深度和宽度。
接下来,我们定义Resnet-18模型。
def resnet18(num_classes):
net = nn.HybridSequential()
net.add(nn.Conv2D(64, kernel_size=3, strides=1, padding=1),
nn.BatchNorm(), nn.Activation('relu'))
def resnet_block(num_channels, num_residuals, first_block=False):
blk = nn.HybridSequential()
for i in range(num_residuals):
if i == 0 and not first_block:
blk.add(Residual(num_channels, use_1x1conv=True, strides=2))
else:
blk.add(Residual(num_channels))
return blk
net.add(resnet_block(64, 2, first_block=True),
resnet_block(128, 2),
resnet_block(256, 2),
resnet_block(512, 2))
net.add(nn.GlobalAvgPool2D(), nn.Dense(num_classes))
return net
函数定义:
def resnet18(num_classes)
: 定义了一个函数,接受num_classes
参数,表示输出类别的数量。网络构建:
net = nn.HybridSequential()
: 创建一个HybridSequential
模型,这是一个容器,可以顺序添加多个神经网络层。初始层:
net.add(...)
: 向模型中添加初始层,包括一个卷积层、批量归一化层和激活层。残差块定义:
def resnet_block(...)
: 定义了一个内部函数,用于创建残差块的序列。每个残差块由多个Residual
单元组成。
num_channels
: 每个残差单元的通道数。num_residuals
: 每个残差块中的残差单元数量。first_block
: 指示是否是第一个残差块,如果是,则使用步长为2的1x1卷积来降低特征图的维度。残差块添加:
- 通过调用
resnet_block
函数,向模型中添加了四个残差块序列,每个序列的通道数依次翻倍,分别为 64、128、256 和 512。全局平均池化和分类层:
net.add(nn.GlobalAvgPool2D(), nn.Dense(num_classes))
: 在残差块之后,添加全局平均池化层和全连接层(分类层),其中全连接层的输出维度为num_classes
。返回值:
return net
: 返回构建好的 ResNet-18 模型。
在训练开始之前我们使用Xavier初始化。
def get_net(devices):
num_classes = 10
net = resnet18(num_classes)
net.initialize(ctx=devices, init=init.Xavier())
return net
loss = gluon.loss.SoftmaxCrossEntropyLoss()
get_net
函数:
def get_net(devices)
: 定义了一个函数,接受devices
参数,这个参数应该是一个设备列表,例如[gpu(0)]
或[cpu()]
,用于指定模型的运行设备。
num_classes = 10
: 定义了输出类别的数量,这里设置为10,意味着模型的输出层将有10个神经元。
net = resnet18(num_classes)
: 调用之前定义的resnet18
函数来创建一个 ResNet-18 模型。
net.initialize(ctx=devices, init=init.Xavier())
: 使用 Xavier 初始化方法初始化模型的参数。ctx
参数指定了初始化参数的设备上下文,以确保参数在正确的设备上创建和初始化。
return net
: 返回初始化好的模型。损失函数:
loss = gluon.loss.SoftmaxCrossEntropyLoss()
: 定义了一个 Softmax Cross-Entropy 损失函数,这是多分类问题中常用的损失函数。
定义[训练函数]
我们将根据模型在验证集上的表现来选择模型并调整超参数。 下面我们定义了模型训练函数train
。
def train(net, train_iter, valid_iter, num_epochs, lr, wd, devices, lr_period,
lr_decay):
trainer = gluon.Trainer(net.collect_params(), 'sgd',
{'learning_rate': lr, 'momentum': 0.9, 'wd': wd})
num_batches, timer = len(train_iter), d2l.Timer()
legend = ['train loss', 'train acc']
if valid_iter is not None:
legend.append('valid acc')
animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs],
legend=legend)
for epoch in range(num_epochs):
metric = d2l.Accumulator(3)
if epoch > 0 and epoch % lr_period == 0:
trainer.set_learning_rate(trainer.learning_rate * lr_decay)
for i, (features, labels) in enumerate(train_iter):
timer.start()
l, acc = d2l.train_batch_ch13(
net, features, labels.astype('float32'), loss, trainer,
devices, d2l.split_batch)
metric.add(l, acc, labels.shape[0])
timer.stop()
if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
animator.add(epoch + (i + 1) / num_batches,
(metric[0] / metric[2], metric[1] / metric[2],
None))
if valid_iter is not None:
valid_acc = d2l.evaluate_accuracy_gpus(net, valid_iter,
d2l.split_batch)
animator.add(epoch + 1, (None, None, valid_acc))
measures = (f'train loss {metric[0] / metric[2]:.3f}, '
f'train acc {metric[1] / metric[2]:.3f}')
if valid_iter is not None:
measures += f', valid acc {valid_acc:.3f}'
print(measures + f'\n{metric[2] * num_epochs / timer.sum():.1f}'
f' examples/sec on {str(devices)}')
函数定义:
def train(net, train_iter, valid_iter, num_epochs, lr, wd, devices, lr_period, lr_decay)
: 定义了一个训练函数,接受模型、训练数据迭代器、验证数据迭代器、训练轮数、学习率、权重衰减、设备列表、学习率衰减周期和衰减率作为参数。初始化和配置:
trainer
: 使用SGD优化器创建一个gluon.Trainer
实例,设置了学习率、动量和权重衰减。num_batches
: 计算训练迭代器中的批次总数。timer
: 创建一个d2l.Timer
实例,用于测量训练和评估的时间。legend
: 定义了动画器图例,根据是否有验证迭代器,可能包含训练损失、训练准确率和验证准确率。animator
: 使用d2l.Animator
创建一个动画器,用于可视化训练过程。训练循环:
- 外层循环遍历
num_epochs
指定的训练轮数。- 如果当前epoch是学习率衰减周期的整数倍,则更新学习率。
- 内层循环遍历训练迭代器中的所有批次。
- 使用
d2l.train_batch_ch13
函数来训练一个批次的数据,并计算损失和准确率。- 更新动画器,显示训练进度和性能指标。
- 如果有验证迭代器,使用
d2l.evaluate_accuracy_gpus
函数评估验证集上的准确率,并更新动画器。性能输出:
- 在每个epoch结束时,打印出训练损失、训练准确率和(如果有的话)验证准确率。
- 计算并打印出模型在训练过程中处理的样本数的速度。
注意事项:
- 这段代码中使用了一些自定义函数和类,如
d2l.Accumulator
、d2l.train_batch_ch13
和d2l.evaluate_accuracy_gpus
,它们可能来自 "Dive into Deep Learning" 的代码库。d2l.split_batch
函数可能用于将数据分发到多个GPU上。devices
参数应该是一个设备列表,用于指定模型应该在哪些设备上运行。lr_period
和lr_decay
参数用于实现学习率衰减策略。
[训练和验证模型]
现在,我们可以训练和验证模型了,而以下所有超参数都可以调整。 例如,我们可以增加周期的数量。当lr_period
和lr_decay
分别设置为4和0.9时,优化算法的学习速率将在每4个周期乘以0.9。 为便于演示,我们在这里只训练20个周期。
devices, num_epochs, lr, wd = d2l.try_all_gpus(), 20, 0.02, 5e-4
lr_period, lr_decay, net = 4, 0.9, get_net(devices)
net.hybridize()
train(net, train_iter, valid_iter, num_epochs, lr, wd, devices, lr_period,
lr_decay)
在 MXNet 中,
net.hybridize()
是一个方法调用,用于启用混合编程模式,这是 MXNet 的一个特性,允许模型在图的编译模式下运行以提高性能。当你调用hybridize
方法后,网络的每个块(block)都会转换为图的节点,并且网络的前向传播和后向传播都会被编译成高效的执行图。以下是
net.hybridize()
方法的一些关键点:
启用混合模式:调用
hybridize()
后,网络将尝试使用图的编译模式来执行前向和后向传播。这通常可以提高计算性能,因为编译后的图可以优化内存访问模式和执行速度。对现有模型的影响:一旦网络被混合化,所有已经定义的层和操作都将按照图的编译模式执行。这意味着在调用
hybridize()
之后添加到网络中的层将不会被编译。使用条件:混合模式在某些情况下可以提供显著的性能提升,特别是在网络结构固定且使用相同输入尺寸进行多次前向传播时。然而,如果网络结构在运行时动态变化,或者输入尺寸经常变化,混合模式可能不会带来性能提升。
调用时机:通常在模型定义完成并且初始化之后,但在开始训练循环之前调用
net.hybridize()
。