[入门]4. 训练一个分类器

训练一个分类器

我们这一次来从头开始,训练一个图片分类器。

1. 准备数据

1
2
3
4
5
6
import numpy as np
import torch
import torchvision
import torchvision.transforms as transforms
import matplotlib.pyplot as plt
%matplotlib inline

torchvision是独立于pytorch的关于图像操作的一些方便工具库,torchvision主要包括一下几个包:

  • vision.datasets : 几个常用视觉数据集,可以下载和加载
  • vision.models : 流行的模型,例如 AlexNet, VGG, and ResNet 以及训练好的参数
  • vision.transforms : 常用的图像操作,例如:随机切割,旋转等
  • vision.utils : 用于把形似 (3 x H x W) 的张量保存到硬盘中,给一个mini-batch的图像可以产生一个图像格网

首先我们需要导入原始的数据,这里我们使用CIFAR-10数据集,它可以通过下面一个函数导入:

1
torchvision.datasets.CIFAR10(root, train=True, transform=None, target_transform=None, download=False)
  • root: 数据集存放或下载的路径
  • train: bool, If True, creates dataset from training set, otherwise creates from test set
  • transform (callable, optional) – A function/transform that takes in an PIL image and returns a transformed version.
  • download (bool, optional): 如果为True, 那么如果root目录下没有想要的数据,就会自动把数据下载到root目录下

1.1 原始数据

下面举个例子,看这样可以导入数据。但是由于某些大家都知道的原因,下载速度会很慢,所以最好自己手动下载数据。

1
2
# 我实现自己下载好了,download是个摆设参数
example = torchvision.datasets.CIFAR10(root='./data', train=True, download=True)
Using downloaded and verified file: ./data\cifar-10-python.tar.gz

可以看到导入的数据集是一个PIL对象的集合,该集合可以索引:

1
2
3
 __getitem__(index):
Parameters: index (int) – Index
Returns: (image, target) where target is index of the target class.

1
2
print(example)
print(example[0])
Dataset CIFAR10
    Number of datapoints: 50000
    Split: train
    Root Location: ./data
    Transforms (if any): None
    Target Transforms (if any): None
(<PIL.Image.Image image mode=RGB size=32x32 at 0x1F8A8FFDB38>, 6)
1
2
plt.imshow(example[0][0])
plt.show()
png
png

1.2 数据Transform

我们可以看到导入的原始数据是PIL的Image对象,这是无法用来训练的,所以我们必须要把原始的数据进行一些转换。

第一个转换是ToTensor:

1
2
class torchvision.transforms.ToTensor:
def __call__(pic) -> Tensor

  • Converts a PIL Image or numpy.ndarray (H x W x C) to (C x H x W) Tensor
  • Scale的问题
    • 如果Image的模式为(L, LA, P, I, F, RGB, YCbCr, RGBA, CMYK, 1)或者ndarray的类型为np.uint8,则会将输入从[0, 255)的范围自动Scale到[0, 1)
    • 否则,不做Scale 这个函数可以说是专门为图像数据的读入设计的,还很贴心的自带归一化操作。

第二个转换是Normalize,我们希望把数据范围变成[-1, 1],这就需要Normalize了:

1
2
class torchvision.transforms.Normalize(mean, std, inplace=False):
def __call__(tensor) -> Normalized Tensor image

解释如下:

Normalize a tensor image with mean and standard deviation.
Given mean: (M1,...,Mn) and std: (S1,..,Sn) for n channels, this transform will normalize each channel of the input torch.*Tensor
i.e. \(input[channel] = \frac{input[channel] - mean[channel]}{std[channel]}\)

我们现在的范围是[0, 1),所以只要把mean和std都设置为0.5,就可以把范围变换到[-1, 1)

为了方便使用,我们可以使用一个工具性质的类来辅助完成多次变换:

1
class torchvision.transforms.Compose(transforms: list)

现在正式导入数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 导入训练集
transform = transforms.Compose(
[transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]
)
trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
download=True, transform=transform)
# 导入测试集
testset = torchvision.datasets.CIFAR10(root='./data', train=False,
download=True, transform=transform)

# 标签
classes = ('plane', 'car', 'bird', 'cat',
'deer', 'dog', 'frog', 'horse', 'ship', 'truck')
Files already downloaded and verified
Files already downloaded and verified

可以查看一下导入的数据:

1
2
print(testset[0][0].shape, classes[testset[0][1]])
print(len(trainset), len(testset))
torch.Size([3, 32, 32]) cat
50000 10000

我们可以看到,导入后的数据是个\(C \times H \times W\)的Tensor,其中第一个维度代表了RGB。

1.3 DataLoader

到目前为止,我们似乎已经把该做的都做好了,但是我们注意到这个例子中我们的数据量还是不小的,一般而言在数据量比较大是,我们会使用mini-batch的方法来训练,所以有必要使用一个用于生成batch的工具,这就是DataLoader

1
2
3
4
5
6
class torch.utils.data.DataLoader(
dataset, batch_size=1, shuffle=False,
num_workers=0, sampler=None,
batch_sampler=None, drop_last=False,
...
)

这玩意咋用呢,看一下它的主要参数:

  • dataset: 它必须是torch.utils.data.Dataset的子类,实现了__len____getitem__方法的重写,幸运的是我们导入的两个set已经是了
  • batch_size: 一个batch里有几个样本,默认为1
  • shuffle: 如果设置为True,那么每个epoch都会洗一次牌
  • num_workers: 数据加载时使用多少个子进程,如果是0,代表只在main进程中加载

构造好了对象之后,可以直接进行迭代使用。

1
2
3
4
trainloader = torch.utils.data.DataLoader(trainset, batch_size=10,
shuffle=False, num_workers=2)
testloader = torch.utils.data.DataLoader(testset, batch_size=10,
shuffle=False, num_workers=2)

2. 构造神经网络

现在让我们来构造一个卷积神经网络,首先导入nn

1
import torch.nn as nn

下面就是构建卷积神经网络了,注意各层的维度控制,用到的API如下:

1
2
3
4
5
Conv2d(in_channels, out_channels, kernel_size,
stride=1, padding=0, dilation=1, groups=1, bias=True)
MaxPool2d(kernel_size, stride=None, padding=0,
dilation=1, return_indices=False, ceil_mode=False)
Softmax(dim=None)

在构造卷积时最麻烦的就是要注意维度的问题了,虽然很繁琐,但我觉得既然是初学有必要再自己分析一波:

  1. 输入图片n x (32 x 32 x 3),使用6个(5 x 5 x 3)的filter,输出结果为n x (28 x 28 x 6)
  2. 输入数据n x (28 x 28 x 6),使用(2 x 2)的池化单元,stride为2,输出结果为n x (16 x 16 x 6)
  3. 输入数据n x (16 x 16 x 6),使用16个(5 x 5 x 6)的filter,输出结果为n x (12 x 12 x 16)
  4. 输入数据n x (12 x 12 x 16),使用(2 x 2)的池化单元,stride为2,输出结果为n x (5 x 5 x 16)
  5. 进入全连接层,后面的略

另外一点,在Pytorch官方的Tutorial中,是没有最后一个SoftMax层的,而是直接输出选择最大的那个单元(也就是所谓的HardMax),虽然结果都一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.conv1 = nn.Conv2d(3, 6, 5)
self.pool = nn.MaxPool2d(2, 2)
self.conv2 = nn.Conv2d(6, 16, 5)
self.relu = nn.ReLU()
self.fc1 = nn.Linear(16 * 5 * 5, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, 10)
self.sm = nn.Softmax(1)

def forward(self, x):
x = self.pool(self.relu(self.conv1(x)))
x = self.pool(self.relu(self.conv2(x)))
x = x.view(-1, 16 * 5 * 5)
x = self.relu(self.fc1(x))
x = self.relu(self.fc2(x))
x = self.fc3(x)
res = self.sm(x)
return res

3. 训练

1
net = Net()
1
2
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(net.parameters(), lr=1e-3)
1
2
# 注意epoch和iteration不同,epoch是指完全训练完整个数据集
epoches = 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
%%time
for epoch in range(epoches):
running_loss = 0.0
for i, data in enumerate(trainloader):
# 获取batch数据
images, labels = data
# 梯度清零
net.zero_grad()
# forward
outputs = net(images)
# loss
loss = criterion(outputs, labels)
# backward
loss.backward()
# update
optimizer.step()
# print statistics
running_loss += loss.item()
if i % 2000 == 1999: # print every 2000 mini-batches
print('[%d, %5d] loss: %.3f' %
(epoch + 1, i + 1, running_loss / 2000))
running_loss = 0.0


print('Finished Training')
[1,  2000] loss: 2.151
[1,  4000] loss: 2.077
[2,  2000] loss: 2.037
[2,  4000] loss: 2.020
Finished Training
Wall time: 2min 26s

4. 预测

4.1 展示效果

首先来编写一下imshow函数,它会接受一个经过我们之前处理的image的Tensor,然后绘制出来: - 首先进行unnormalize操作,把取值变回到[0, 1) - 转换成numpy,进行转置,因为之前经过ToTensor操作后,image变成了(C, H, W),现在我们变回(H, W, C) - 使用plt.imshow,它接受代表图片的矩阵,可以是0255的整型也可以是01的浮点型

1
2
3
4
5
def imshow(img):
img = img / 2 + 0.5 # unnormalize
npimg = img.numpy()
plt.imshow(np.transpose(npimg, (1, 2, 0)))
plt.show()

然后我们来看一看第一批batch中前五个图片和预测的情况,这里用到了这个函数:

1
2
3
4
torchvision.utils.make_grid(
tensor, nrow=8, padding=2, normalize=False,
range=None, scale_each=False, pad_value=0
)

它的功能是把一系列的图片拼在一起作为一张图片展示。

1
2
3
4
5
dataiter = iter(testloader)
images, labels = dataiter.next()

imshow(torchvision.utils.make_grid(images[0:5]))
print('GroundTruth: ', ' '.join('%5s' % classes[labels[j]] for j in range(5)))
png
png
GroundTruth:    cat  ship  ship plane  frog

现在看看神经网络的输出。 其中torch.max()

1
2
3
4
5
outputs = net(images)
_, predicted = torch.max(outputs, 1)

print('Predicted: ', ' '.join('%5s' % classes[predicted[j]]
for j in range(5)))
Predicted:    cat  ship  ship plane  deer

还不错!

4.2 整体预测

1
2
3
4
5
6
7
8
9
10
11
12
13
correct = 0
total = 0
with torch.no_grad():
net.eval()
for data in testloader:
images, labels = data
outputs = net(images)
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()

print('Accuracy of the network on the 10000 test images: %d %%' % (
100 * correct / total))
Accuracy of the network on the 10000 test images: 44 %
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class_correct = list(0. for i in range(10))
class_total = list(0. for i in range(10))
with torch.no_grad():
for data in testloader:
images, labels = data
outputs = net(images)
_, predicted = torch.max(outputs, 1)
c = (predicted == labels).squeeze()
for i in range(4):
label = labels[i]
class_correct[label] += c[i].item()
class_total[label] += 1


for i in range(10):
print('Accuracy of %5s : %2d %%' % (
classes[i], 100 * class_correct[i] / class_total[i]))
Accuracy of plane : 48 %
Accuracy of   car : 47 %
Accuracy of  bird : 18 %
Accuracy of   cat : 43 %
Accuracy of  deer : 38 %
Accuracy of   dog : 34 %
Accuracy of  frog : 63 %
Accuracy of horse : 56 %
Accuracy of  ship : 51 %
Accuracy of truck : 37 %

5. 使用GPU

1
torch.cuda.is_available()
True
1
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

迁移到GPU上,然后训练一下试试:

1
2
3
net = Net().cuda()
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(net.parameters(), lr=1e-3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
%%time
for epoch in range(epoches):
running_loss = 0.0
for i, data in enumerate(trainloader):
# 获取batch数据
images, labels = data
images, labels = images.cuda(), labels.cuda()
# 梯度清零
net.zero_grad()
# forward
outputs = net(images)
# loss
loss = criterion(outputs, labels)
# backward
loss.backward()
# update
optimizer.step()
# print statistics
running_loss += loss.item()
if i % 2000 == 1999: # print every 2000 mini-batches
print('[%d, %5d] loss: %.3f' %
(epoch + 1, i + 1, running_loss / 2000))
running_loss = 0.0


print('Finished Training')
[1,  2000] loss: 2.159
[1,  4000] loss: 2.090
[2,  2000] loss: 2.038
[2,  4000] loss: 2.019
Finished Training
Wall time: 3min 46s

What?怎么还变慢了?实际上是因为数据量太小了,而转化成GPU的花费比节省的时间还大,如果数据量再大一些,区别就出来了!