前言

本文将通过CNN 让计算机识别MNIST数据集中的手写数字 以此来介绍Pytorch的基本使用方法:

  • Pytorch中的数据类型——tensor
  • Pytorch中的数据集、数据加载器——Dataset、DataLoader
  • Pytorch中的基础类模型——torch.nn.Module

以及程序设计上的一些小技巧。

1. tensor

1.1 概念

tensor一词译为张量,一般我们所接触的矩阵是二维的,称为二阶张量、向量称为一阶张量、标量称为零阶张量。接下来我们通过Numpy库了解一下张量。(这里并非数学上严格的定义,感性理解一下即可)

1.1.1 二阶张量 矩阵

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 首先我们举一个三行八列的矩阵
import numpy as np

a = np.arange(1,25).reshape(3,8)
'''
array([[ 1, 2, 3, 4, 5, 6, 7, 8],
[ 9, 10, 11, 12, 13, 14, 15, 16],
[17, 18, 19, 20, 21, 22, 23, 24]])'''

b = np.ones_like(a).T
''' array([[1, 1, 1],
[1, 1, 1],
[1, 1, 1],
[1, 1, 1],
[1, 1, 1],
[1, 1, 1],
[1, 1, 1],
[1, 1, 1]])'''

# 我们创建以上两个矩阵,接下来我们把他们做点乘
a@b
''' array([[ 36, 36, 36],
[100, 100, 100],
[164, 164, 164]]) '''

上面我们创建了两个矩阵a为三行八列,b为八行三列,两者做点积得到一个三行三列的矩阵。

我们拉到列表的角度解释这个矩阵,我们将所有数据都包含在一个大列表之内,大列表里有三个小列表,每个列表内有八个元素,

三个小列表代表三行,每个列表的八个元素代表八个维度

这里举个小栗子帮助理解一下维度:

我们在三年级二班给各位同学做信息登记,每个人所需要填写【姓名、年龄、身高、体重】四项内容,我们把每个人的信息记为一条数据,那么我们就可以说这条数据有四个特征,它的维度为四。

通常来讲我们把特征记为feature,称这条数据有四个特征。

现在整个班级的信息都填写好了应该是如何的形状,我们假设有32个人:

【【张三、7、130、 70】

​ 【李四、7、131、 71】

​ 。。。

​ 【李小明、7、129、70】】 如何我们得到一个32行4列的矩阵,记为(32,4)

接下来我们把视角拉倒整个三年级,我们假设有7个班级:

那么我们得到的数据维度应该是(7,32,4),表示我们有7个班级的数据,每个班级的数据维度(32,4)。

此后我们都称这个“班级为batch“ ,至此我们从二维的矩阵上升到三维的张量。

1.1.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 首先介绍下一张图片通常的数据格式,我们使用Numpy来伪造一下数据

np.random.randn(1,3,28,28)
'''array([[[[-1.03301822, -0.26956785, -1.81246034, ..., -0.2025034 ,
-0.24770628, 0.45183312],
[ 1.09102807, 0.92955389, 0.07537901, ..., 0.69203358,
-0.17726632, -0.74610015],
[ 0.40508712, -1.2507095 , 0.68702445, ..., -0.12526432,
0.0390443 , 1.00993313],
...,
[ 1.96843042, -2.43286678, 0.08543089, ..., -1.57232148,
0.92844287, -0.25532137],
[ 0.46919141, -0.13700029, 1.78645959, ..., 0.01334257,
1.31030895, -0.22523819],
[ 0.63897933, 0.54846445, -0.64030391, ..., 0.92298892,
-0.50840421, 1.34232325]],

[[-0.01892086, 0.1456131 , -0.08903806, ..., 1.68250139,
1.2097305 , -0.2680935 ],
[ 0.92759263, 0.22665021, 1.28734004, ..., 0.09925943,
1.30039407, 3.34710594],
[ 0.53486942, -0.56230181, -1.92117215, ..., 1.33047469,
-1.19211895, -0.03081918],
...,
[ 0.2539067 , -2.13160564, 0.27519544, ..., -0.62223126,
0.5818296 , 0.07102949],
[-0.7524386 , -0.71244818, 0.88997093, ..., 0.16566338,
0.80577231, -3.35350436],
[ 0.99558393, -2.32335969, -2.87512549, ..., 1.16290939,
2.24089232, 0.22083378]],

[[ 1.35970859, 0.7961136 , 0.09896652, ..., 1.82609401,
-0.49607535, 0.23424012],
[-0.22283053, -1.35535905, -0.55896315, ..., 1.68093489,
0.80969216, 0.63538616],
[-0.88285682, 0.59389887, -1.05559301, ..., -0.00719476,
-0.25654492, -1.40716977],
...,
[ 0.44508688, -0.05650302, -2.97674436, ..., 1.25730001,
-1.66409024, 0.96057644],
[-1.3237267 , -0.27798159, -1.8947621 , ..., 1.96216661,
-0.10569547, -0.8446272 ],
[ 0.22525617, 0.75040916, 0.72823974, ..., -1.93525763,
-0.74464397, 0.55771249]]]]) '''

上面就是一张图片**W(width)为28,H(heigh)**为28,有RGB三个通道,batch为1的图片(这个batch里面只有一张图片)的数据表示形式。

当然上面的数据太过复杂,我们以下面W和H都为4的数据继续讲解一下各个数据的意义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
np.random.randn(1,3,4,4)
'''array([[[[ 0.43748082, -0.65000689, 0.13972451, -0.40213376],
[ 0.09342289, -0.83655856, 0.51844492, 0.96505144],
[ 0.68421876, 1.05527391, -0.30821748, -1.89826909],
[-0.36654524, 0.22642376, 0.16545107, 0.00401234]],

[[-0.13032482, 0.68182741, -0.52511016, 0.75875314],
[-1.39072336, -0.22848391, -1.64733525, 0.3339502 ],
[-1.06568103, -0.58455172, -0.02874822, -0.64499225],
[-0.23380602, -0.74809941, -0.71214339, -0.44950305]],

[[-1.51112191, 0.49145194, -0.01839728, -1.52788219],
[ 0.93370593, 0.96444176, -0.67434299, -1.8492484 ],
[ 0.51140855, -0.58682968, -1.16261225, -0.65782238],
[ 0.8643421 , 0.79983446, -0.92330871, -2.45649675]]]]) '''
  • 一号位batch=1表示只有一张图片

    • 若batch=3,我们下面降到的模型依次取本批次内【0】号(3,4,4)、【1】(3,4,4)、【2】(3,4,4)进行处理
  • 二号位Channel=3 表示有三个通道分别是RGB

    • 如上面 0.43748082….0.00401234,就表示在R通道内,这张图片的颜色数据
    • G和B通道同理,让三者叠加就可以表示颜色的明暗,从而勾勒画面,渲染颜色
  • 最后的两位就表示长宽,每个元素表示像素点的明暗程度。如R通道的第一个元素0.43748082就表示这个像素点有多红

    (红也有程度对吧)

1.2 Pytorch中tensor的API

Pytorch中tensor号称是跟Numpy及其相似的操作方式,有Numpy的学习基础的话几乎不用付出学习成本来适应tensor。但是实际情况就经常出现各种警告。无论如何,tensor可以享受到GPU的加速运算,总的来说也够友好,下面我们将介绍其常用的API

首先是随机函数,基本跟Numpy是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
rand_tensor = torch.rand(shape)
ones_tensor = torch.ones(shape)
zeros_tensor = torch.zeros(shape)

'''
Random Tensor:
tensor([[0.7453, 0.7993, 0.8484],
[0.3592, 0.3243, 0.7226]])

Ones Tensor:
tensor([[1., 1., 1.],
[1., 1., 1.]])

Zeros Tensor:
tensor([[0., 0., 0.],
[0., 0., 0.]]) '''

接下来介绍tensor对象的一些属性,其中device默认就是使用cpu,表示我们数据在cpu上。

1
2
3
4
5
6
7
8
9
10
11
tensor = torch.rand(3,4)
'''
tensor([[0.8165, 0.1909, 0.6631, 0.3062],
[0.0178, 0.5158, 0.0267, 0.9819],
[0.6103, 0.7354, 0.7933, 0.2770]]) '''

tensor.shape # 将返回 torch.Size([3, 4])
tensor.dtype # 将返回 torch.float32
tensor.device # 将返回 cpu

device = 'cuda' if torch.cuda.is_available() else 'cpu' # 这句话经常来指定数据处理的设备

torch.cat也是一个常用的函数,用来链接数据。

下面以第一行为例,cat函数将[0.8165, 0.1909, 0.6631, 0.3062][0.8165, 0.1909, 0.6631, 0.3062][0.8165, 0.1909, 0.6631, 0.3062,]连接,这是因为dim=1表示在第一维度,其视角内的可操作单位为0.8165, 0.1909, 0.6631, 0.3062这些元素,dim=0则可操作的基本单位为tensor(这里的tensor表示上面的三行四列的实例张量)

1
2
3
4
5
6
7
8
9
torch.cat([tensor, tensor, tensor], dim=1)

'''
tensor([[0.8165, 0.1909, 0.6631, 0.3062, 0.8165, 0.1909, 0.6631, 0.3062, 0.8165,
0.1909, 0.6631, 0.3062],
[0.0178, 0.5158, 0.0267, 0.9819, 0.0178, 0.5158, 0.0267, 0.9819, 0.0178,
0.5158, 0.0267, 0.9819],
[0.6103, 0.7354, 0.7933, 0.2770, 0.6103, 0.7354, 0.7933, 0.2770, 0.6103,
0.7354, 0.7933, 0.2770]]) '''

另外介绍一下常用的类型转换

1
2
3
4
5
6
t = torch.rand(3,4)
n = np.random.randn(3,4)

t.to_list() # 将tensor类型转换为list
t.numpy() # 转换为Numpy类型
torch.from_numpy(n) # 从Numpy转换为tensor

最后最常用的就是下面两句

1
2
3
4
data = list(range(1,10))

torch.tensor(data) # 返回tensor([1, 2, 3, 4, 5, 6, 7, 8, 9])可使用dtype=torch.float32换成浮点数
torch.Tensor(data) # 返回 tensor([1., 2., 3., 4., 5., 6., 7., 8., 9.])

2. Dataset DataLoader

都说数据科学家一般的时间都花在数据处理上,一点不假。前面花了这么大篇幅讲价tensor,接下来我们将介绍Pytorch中存储数据的

Dataset和数据加载器DataLoader

2.1 你的数据类

虽然我们使用的MNIST数据集已经可以直接通过Pytorch的API调用,如下

from torchvision import datasets

datasets.MNIST(root='../dataset/mnist/', train=True, download=True, transform=transform)

root表示存储或者加载数据的路径

train表示是否只加载训练部分的数据集,不设定默认加载全部数据集

download字面意思

transform指代这批数据使用什么转换形式,一般来说是一种数据增强方式,以后会专门介绍

我们还是来具体解释下通常要自定义使用的dataset。

定义符合你要求的数据集有三步必须操作:

  • 定义你自己的数据集类并继承自torch.utils.data.Dataset
  • 需要包含__len__方法返回长度
  • 需要包含__getitem__方法,按照下标取得数据

以上配置也都是为了配合DataLoader的使用,下面我们定义一个dataset类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from torch.utils.data import Dataset

class TitanicDataSets(Dataset):
def __init__(self,flag):
xy = preprocess(pd.read_csv("Titanic.csv"), flag="train")

if flag == "train":
self.x_data = xy.iloc[:, :-1][:800]
self.y_data = xy.iloc[:, -1][:800]
self.len = self.x_data.shape[0]
if flag == "valid":
self.x_data = xy[0][800:892]
self.y_data = xy[1][800:892]
self.len = self.x_data.shape[0]

def __getitem__(self, index):
return self.x_data[index], self.y_data[index]

def __len__(self):
return self.len

上面我们引入kaggle著名的泰坦尼克号幸存者预测比赛使用的数据集,其中x_data获得的是前八百行乘客的信息,y_data记录的就是是否存活。

一般来说我们也将数据集分为train和valid两部分,因为最后我们需要预测的数据集并不会有是否存活的标签,所以通过训练模型参数以拟合train部分的数据,以valid为本次训练的结果导向以修正模型参数,最终预测,就是我们的目的。

如上面所示,Python中的语句就是这么简洁明了,我们在初始部分读取数据集,然后根据传入的flag决定是处理train还是valid的部分数据,最后我们赋予这个类像列表那样的获取下标和切片能力(__getitem__方法)、以及返回长度的能力(__len__方法)

2.2 数据加载器

Pytorch的数据加载器DataLoader简单易用,下面介绍它部分常用参数。

1
2
train_dataset = TitanicDataSets(flag="train")
train_loader = DataLoader(dataset=train_dataset, batch_size=32, shuffle=True, num_workers=2)
  • dataset 表示它所处理的数据,一般是你定义的dataset类,或者具有下标取值,和返回长度的数据类型也可以
  • batch_size 表示一词传给模型多少条数据
  • shuffle 表示是否打乱
  • num_workers 表示使用你cpu的几个核进行读取

可以使用下面的语句查看dataloader返回给你的数据形状

1
2
samples = next(iter(train_loader))
samples[:2] # 查看本批次(batch)的前两个样本([0]号,[1]号)

3. 模型

对于模型,以我的理解,数据虽然是死的,但是理解它的方式是活的;模型是活的,但是组合它的方式并没有那么灵活。这里之所以说是组合,说点题外话,是因为如今预训练模型大行其道,大模型在各个任务上不断刷新纪录(SOTA),小型机构很难有力量去训练这种大模型,于是在大模型上修修改改以适应下游任务的方式,只能使用这种像是Transformer的方式不断变形组合,总感觉缺了点活力。(奠定Pre-train的Bert就是在Transformer基础上提出来的)。

3.1 模型定义

下面开始定义我们的CNN模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Net(torch.nn.Module):
def __init__(self):
super().__init__()
self.conv1 = torch.nn.Conv2d(1, 10, kernel_size=3, padding=1)
self.conv2 = torch.nn.Conv2d(10, 20, kernel_size=3, padding=1)
self.pooling = torch.nn.MaxPool2d(2)
self.fc = torch.nn.Linear(320, 10)


def forward(self, x):
# flatten data from (n,1,28,28) to (n, 784)
batch_size = x.size(0)
x = F.relu(self.pooling(self.conv1(x)))
x = F.relu(self.pooling(self.conv2(x)))
x = x.view(batch_size, -1) # -1 此处自动算出的是320
x = self.fc(x)

return x
  • 初始化部分就是定义这个模型的各种方法
  • forward向前传播,应用初始化部分定义的函数

因为MNIST数据集是黑白图片所以只有一个通道(以灰度grey刻画图像即可)

3.2 模型功能细节

torch.nn.Conv2d即convolution(卷积层)

  • 第一个参数表示进入卷积层数据的channel数

  • 第二个参数表示完成卷积后数据的channel数

  • padding=1 即在图形周围填充一圈为0的数据(一般来说是有些图形在某些情况下不padding将会取不到原本边界上的值)

  • kernel_size表示卷积核大小(3,3),如图所示(5,5)的图形padding之后变为(7,7),其经过卷积核映射成(3,3)的形状

卷积核进行的操作是elementwise multiplication,就是元素与核上对应元素相乘之后加起来就可以了

这里也请各位查看Pytoch文档查看更多参数的详细解释

torch.nn.MaxPool2d

也就是在一个设定的核的窗口内取最大值

注意maxpool就是为了取得此区域最大值作为特征输出给下一层的所以不会有overlap的地方

激活函数relu

直接上图,置于sigma函数、softmax函数、tanh函数之后会开文讲

torch.nn.Linear

就是全连接层。

对于经过卷积、池化、激活的数据,维度为(batch_size, b, c, d),

我们将其压缩为(batch_size, n) 最后送给全连接层做n到10的映射,最后变为(batch_size, 10),以最大数的下标作为我们模型的预测结果

1
2
3
4
5
6
7
8
9
# 如batch_size = 2

tensor = torch.rand(2,10)
'''
[[0.1022, 0.3252, 0.8618, 0.1433, 0.8307, 0.8538, 0.1535, 0.1760, 0.0021,0.9704],
[0.6809, 0.6555, 0.1134, 0.3555, 0.4866, 0.5923, 0.5204, 0.9048, 0.5630,0.4472]]
'''

tensor.argmax(dim=-1) # 获得 [9, 7] 第一个列表最大值的下标是9,第二个是7

4. 训练

4.1 一般流程

首先将模型实例化,并引入损失函数、优化器和设备。(关于损失函数和优化器之后也会开文讲)

1
2
3
4
5
6
model = Net()
criterion = torch.nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)

device = 'cuda' if torch.cuda.is_available() else 'cpu'
model.to(device)

加载数据,这里函数名都是见名知意,很好理解

1
2
3
4
5
6
7
batch_size = 64

train_dataset = datasets.MNIST(root='../dataset/mnist/', train=True, download=True)
train_loader = DataLoader(train_dataset, shuffle=True, batch_size=batch_size)

test_dataset = datasets.MNIST(root='../dataset/mnist/', train=False, download=True)
test_loader = DataLoader(test_dataset, shuffle=False, batch_size=batch_size)

下面定义训练循环

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
27
28
29
30
31
32
33
def train(epoch):
running_loss = 0.0
for batch_idx, data in enumerate(train_loader, 0):
inputs, target = data
# 注意这里因为是MNIST数据集,所以自动返回tensor类型,这才有to()方法
inputs = inputs.to(device)
target = target.to(device)
optimizer.zero_grad()

outputs = model(inputs)
loss = criterion(outputs, target)
loss.backward()
optimizer.step()

running_loss += loss.item()
if batch_idx % 300 == 299:
print('[%d, %5d] loss: %.3f' % (epoch+1, batch_idx+1, running_loss/300))
running_loss = 0.0


def test():
correct = 0
total = 0
with torch.no_grad():
for data in test_loader:
images, labels = data
images = images.to(device)
labels = labels.to(device)
outputs = model(images)
_, predicted = torch.max(outputs.data, dim=1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
print('accuracy on test set: %d %% ' % (100*correct/total))

以上模型准确率在98%

4.2 看看准不准

以下内容使用jupyter notebook查看

单个查看(train_loader就是上面流程中定义的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import matplotlib.pyplot as plt

def show_pred():
samples = next(iter(train_loader))
x = samples[0][0]

pred = model(x).argmax(-1)
print(pred.item())

plt.imshow(x.squeeze().numpy())
plt.axis('off')
plt.show()

show_pred()

批量查看(train_loader就是上面流程中定义的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import matplotlib.pyplot as plt

def show_batch_pred():
samples = next(iter(train_loader))
x = samples[0]

plt.figure(figsize=(10,5))
for i, imgs in enumerate(x[:10], 0):
npimg = imgs.numpy().transpose((1, 2, 0))
plt.subplot(2, 10, i+1)
plt.imshow(npimg, cmap=plt.cm.binary)
plt.axis('off')

pred = model(x[:10]).argmax(-1)
print(pred.numpy().tolist())

show_batch_pred()

可以看到还是错了一个的,倒数第三应该是4 (要不就是我看错了)

总结

  • 下面我们来总结一下训练一个模型的pipeline,我认为总结让我们的pipeline获得一定的泛化能力
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import torch
from torchvision import transforms
from torchvision import datasets
from torch.utils.data import DataLoader
import torch.nn.functional as F
import torch.optim as optim
from tqdm.auto import tqdm

# 定义数据

batch_size = 256
transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))])

train_dataset = datasets.MNIST(root='../dataset/mnist/', train=True, download=True, transform=transform)
train_loader = DataLoader(train_dataset, shuffle=True, batch_size=batch_size)
test_dataset = datasets.MNIST(root='../dataset/mnist/', train=False, download=True, transform=transform)
test_loader = DataLoader(test_dataset, shuffle=False, batch_size=batch_size)

# 定义模型

class Net(torch.nn.Module):
def __init__(self):
super(Net, self).__init__()
self.conv1 = torch.nn.Conv2d(1, 10, kernel_size=5)
self.conv2 = torch.nn.Conv2d(10, 20, kernel_size=5)
self.pooling = torch.nn.MaxPool2d(2)
self.fc = torch.nn.Linear(320, 10)


def forward(self, x):
# flatten data from (n,1,28,28) to (n, 784)
batch_size = x.size(0)
x = F.relu(self.pooling(self.conv1(x)))
x = F.relu(self.pooling(self.conv2(x)))
x = x.view(batch_size, -1) # -1 此处自动算出的是320
x = self.fc(x)

return x

# 训练和验证

def train(epoch):
running_loss = 0.0
for batch_idx, data in enumerate(train_loader, 0):
inputs, target = data
inputs = inputs.to(device)
target = target.to(device)
optimizer.zero_grad()

outputs = model(inputs)
loss = criterion(outputs, target)
loss.backward()
optimizer.step()

running_loss += loss.item()
if batch_idx % 300 == 299:
print('[%d, %5d] loss: %.3f' % (epoch+1, batch_idx+1, running_loss/300))
running_loss = 0.0

def test():
correct = 0
total = 0
with torch.no_grad():
for data in test_loader:
images, labels = data
images = images.to(device)
labels = labels.to(device)
outputs = model(images)
_, predicted = torch.max(outputs.data, dim=1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
print('accuracy on test set: %d %% ' % (100*correct/total))

def main(epochs):
for epoch in tqdm(range(epochs)):
train(epoch)
test()

# 模块定义,一般这里会加入超参数的定义

model = Net()
device = 'cuda' if torch.cuda.is_available() else 'cpu'
model.to(device)

criterion = torch.nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)

# 训练
main(10)
  • 回顾整个流程: 准备数据—> 定义模型—> 训练循环设计—> 超参数—> 训练并分析结果。各个环节细节的设计请各位参照Pytoch官方文档研究

  • 以上就是整个数据到模型到结果的流程,下节我们将介绍VGG、ResNet50等预训练模型