Pytorch教程系列(二)

翻译

Posted by 柳阳飞 on July 22, 2019
以下内容是翻译自papersapce上的Pytorch教程系列,目的是为了自己学习时候记录进度督促自己,另一方面也可供需要的人汲取。

PyTorch 101 第二部分:搭建自己的神经网络

在这篇文章里,我们将如何使用pytorch来搭建自定义的神经网络以及如何配置训练过程,我们将会实现一个ResNet在CIFAR-10上做图像分类。在开始之前需要说明的是,这篇文章不是为了实现最好的分类精度,而是教你如何使用pytorch。

友情提示:这是我们的PyTorch系列教程的第二部分,我们强烈推荐阅读第一部分,尽管并不是必须去阅读。

可以在这里获得所有的代码。

接下来我们将学习:

  1. 如何使用nn.Module类搭建神经网络。
  2. 如何使用DatasetDataloader来定制数据输入以及数据增强。
  3. 如何使用不同的学习率策略调整学习率。
  4. 在CIFAR-10上训练一个基础的ResNet图像分类器。
预备知识
  1. 链式法则
  2. 深度学习基础
  3. PyTorch 1.0
  4. 第一部分知识
一个简单的神经网路

接下来,我们会实现一个非常简单的神经网络。

搭建神经网络

torch.nn模块是pytorch中设计神经网络的基础,这个模块可以用来实现全连接层,卷积层,池化层激活函数以及通过实例化torch.nn.Module实现整个神经网络。

许多nn.Module类可以被串联起来形成一个更大的nn.Module类,这就是我们如何实现多层神经网络。实际上,在PyTorch中,nn.Module可以被用来表示任意的函数f

nn.Module中有两个方法必须重写:

  1. __init__函数。这个函数在创建一个nn.Module实例时被调用,在这里需要定义不同的变量例如卷积层中的filters, kernel_sizedropout层的dropout probability
  2. forward函数。这是定义计算输出的地方,这个函数不需要明确的调用,可以通过调用nn.Module实例来运行,就像一个带有输入做为参数的函数一样。

code1

另一个运用广泛并且重要的类就是nn.Sequential类,启动此类时,我们可以按照特定顺序传递nn.Module对象的列表,返回的对象是一个nn.Module对象,当使用输入运行此对象时,它将按顺序运行通过我们传递给它的所有nn.Module对象的输入,其顺序与传递它们的顺序相同。

code2


现在让我们开始实现分类网络,我们将利用卷积和池化层以及自定义实现的残差块。

block

虽然PyTorch提供了许多开箱即用的torch.nn模块,但我们必须自己实现残差块。在实现神经网络之前,我们需要实现残差块。

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
class ResidualBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super(ResidualBlock, self).__init__()
        
        # Conv Layer 1
        self.conv1 = nn.Conv2d(
            in_channels=in_channels, out_channels=out_channels,
            kernel_size=(3, 3), stride=stride, padding=1, bias=False
        )
        self.bn1 = nn.BatchNorm2d(out_channels)
        
        # Conv Layer 2
        self.conv2 = nn.Conv2d(
            in_channels=out_channels, out_channels=out_channels,
            kernel_size=(3, 3), stride=1, padding=1, bias=False
        )
        self.bn2 = nn.BatchNorm2d(out_channels)
    
        # Shortcut connection to downsample residual
        # In case the output dimensions of the residual block is not the           same 
        # as it's input, have a convolutional layer downsample the layer 
        # being bought forward by approporate striding and filters
        self.shortcut = nn.Sequential()
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(
                    in_channels=in_channels, out_channels=out_channels,
                    kernel_size=(1, 1), stride=stride, bias=False
                ),
                nn.BatchNorm2d(out_channels)
            )

    def forward(self, x):
        out = nn.ReLU()(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += self.shortcut(x)
        out = nn.ReLU()(out)
        return out

如你所见,我们在__init__函数中定义了层或者网络的组件,在forward函数中,我们如何将这些组件串在一起以计算输入的输出。

现在,我们定义整个网络:

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
class ResNet(nn.Module):
    def __init__(self, num_classes=10):
        super(ResNet, self).__init__()
        
        # Initial input conv
        self.conv1 = nn.Conv2d(
            in_channels=3, out_channels=64, kernel_size=(3, 3),
            stride=1, padding=1, bias=False
        )

        self.bn1 = nn.BatchNorm2d(64)
        
        # Create blocks
        self.block1 = self._create_block(64, 64, stride=1)
        self.block2 = self._create_block(64, 128, stride=2)
        self.block3 = self._create_block(128, 256, stride=2)
        self.block4 = self._create_block(256, 512, stride=2)
        self.linear = nn.Linear(512, num_classes)
    
    # A block is just two residual blocks for ResNet18
    def _create_block(self, in_channels, out_channels, stride):
        return nn.Sequential(
            ResidualBlock(in_channels, out_channels, stride),
            ResidualBlock(out_channels, out_channels, 1)
        )

    def forward(self, x):
	# Output of one layer becomes input to the next
        out = nn.ReLU()(self.bn1(self.conv1(x)))
        out = self.stage1(out)
        out = self.stage2(out)
        out = self.stage3(out)
        out = self.stage4(out)
        out = nn.AvgPool2d(4)(out)
        out = out.view(out.size(0), -1)
        out = self.linear(out)
        return out
输入格式

现在我们有了网络,我们将注意力转向输入,在使用深度学习时,我们遇到了不同类型的输入:图像,音频或高维结构数据。

我们正在处理的数据类型将决定我们使用的输入,通常在pytorch中,batch总是第一个维度。由于我们在这里处理图像,我将描述图像所需的输入格式。

输入的图像格式是[B C H W],其中Bbatch_sizeCchannelsH,W分别是heightwidth

由于我们使用了随机权重,我们神经网络的输出现在是乱码。现在让我们训练我们的网络。

加载数据

现在开始加载数据,我们将利用torch.utils.data.Datasettorch.utils.data.Dataloader类。

我们首先将CIFAR-10数据集下载到当前目录下。启动终端,cd到您的代码目录并运行以下命令。

code3

如果您使用的是macOS,则可能需要使用curl,如果您使用的是Windows,则需要手动下载。

现在我们将读取CIFAR数据集中存在的标签。

code4

我们使用PIL库来读图片,在写加载数据的功能之前,我们先完成预处理函数完成如下:

  1. 以0.5的概率随机水平翻转图像。
  2. 用CIFAR数据集的均值和方差归一化数据。
  3. reshape [W H C]—>[C H W]。

code5

通常,pytorch提供两个类构建输入管道来加载数据。

  1. torch.data.utils.dataset,我们称其为dataset类。
  2. torch.data.utils.dataloader,我们称其为dataloader类。
torch.utils.data.dataset

dataset是一个加载数据的类,返回一个可以迭代的生成器,它还允许将数据增强技术合并到输入管道中。

如果你想为自己的数据创建一个dataset类,你需要重载三个函数:

  1. __init__函数。定义与你自己数据相关的元素,更重要的是,数据的位置,还可以定义想用的数据增强。
  2. __len__函数。返回数据集的长度。
  3. _getitem__函数。这个函数输入一个参数index i,返回一个数据样例,在我们的训练循环期间,每次迭代都会调用此函数,数据集对象使用不同的i。

以下是加载CIFAR数据的实现:

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
class Cifar10Dataset(torch.utils.data.Dataset):
    def __init__(self, data_dir, data_size = 0, transforms = None):
        files = os.listdir(data_dir)
        files = [os.path.join(data_dir,x) for x in files]
        
        
        if data_size < 0 or data_size > len(files):
            assert("Data size should be between 0 to number of files in the dataset")
        
        if data_size == 0:
            data_size = len(files)
        
        self.data_size = data_size
        self.files = random.sample(files, self.data_size)
        self.transforms = transforms
        
    def __len__(self):
        return self.data_size
    
    def __getitem__(self, idx):
        image_address = self.files[idx]
        image = Image.open(image_address)
        image = preprocess(image)
        label_name = image_address[:-4].split("_")[-1]
        label = label_mapping[label_name]
        
        image = image.astype(np.float32)
        
        if self.transforms:
            image = self.transforms(image)

        return image, label

我们使用__getitem__函数提取每张图片的标签。

Dataset类允许我们合并延迟数据加载原则。这意味着它不是一次性加载所有的数据到内存中,而是当需要时只加载一个数据(__getitem__调用时)。

一旦你创建了一个Dataset类,基本上可以用任何python迭代器的方法来迭代对象,每一次迭代,__getitem__函数以i作为输入参数。

数据增强

__init__函数中还传入了一个参数transforms,它可以是任何做数据增强的python函数。你也可以在预处理代码中做数据增强,在__getitem__中做只是习惯问题。

这里我们也加入了数据增强,这些数据增强既可以是函数也可以是类实现。你只需要保证可以在__getitem__函数中应用这些来获得期望的输出。

我们有大量的数据扩充库可用于增强数据。例如,torchvision库提供了许多预建的变换,并且能够成更大的变换,但是我们的讨论仅限于pytorch。

torch.utils.data.Dataloader

Dataloader类很方便:

  1. Batching of Data
  2. Shuffling of Data
  3. 使用多线程同时加载许多数据。
  4. 预取。当GPU处理当前批数据时,Dataloader可以同时将下一批处理加载到内存中。这意味着GPU不必等待下一批,它可以加快培训速度。

使用Dataset对象实例化Dataloader对象,然后可以像对数据集实例一样迭代Dataloader对象实例。然而,你可以指定各种选项,以便可以更好地控制循环选项。

1
2
3
4
5
trainset = Cifar10Dataset(data_dir = "cifar/train/", transforms=None)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=128, shuffle=True, num_workers=2)

testset = Cifar10Dataset(data_dir = "cifar/test/", transforms=None)
testloader = torch.utils.data.DataLoader(testset, batch_size=128, shuffle=True, num_workers=2)

trainsettestset都是python生成器对象,可以通过下面的语句迭代:

code6

Dataloader类加载数据比Dataset更加方便,在每次迭代中,Dataset类仅返回__getitem__函数的输出,Dataloader做的更多:

  1. 注意trainset__getitem__方法返回一个形状是$3\times 32\times 32$的矩阵,Dataloader将图像打包成形状是$128\times 3\times 32\times 32$的Tensor。
  2. __getitem__方法返回一个矩阵时,Dataloader类自动转换成一个Tensor。
  3. 即使__getitem__方法返回一个非数字类型的对象,Dataloader类将它转换成一个sizeB的list/tuple。假设__getitem__也返回一个字符串,即标签字符串,如果在实例化Dataloader时设置$batch=128$,每次迭代时,Dataloader将会返回一个128个字符串的元组。
训练和评估

在开始写训练循环之前,需要先决定超参数和优化算法,PyTorch通过torch.optim提供了许多内建的优化算法。

torch.optim

torch.optim模块提供许多优化算法。

  1. 不同优化算法(optim.SGD,optim.Adam)
  2. 可以调节学习率(用optim.lr_scheduler)
  3. 不同的参数有不同的学习率(本节不讨论)

我们将使用cross entropy loss和基于动量的SGD优化算法,学习率在第150轮和200轮以0.1的因子衰减。

1
2
3
4
5
6
7
8
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")     #Check whether a GPU is present.

clf = ResNet()
clf.to(device)   #Put the network on GPU if present

criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(clf.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4)
scheduler = torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones=[150, 200], gamma=0.1)

第一行中,如果有GPU,device就是“cuda:0”,否则就是“cpu”。默认情况下,当我们初始化一个网络,它保留在CPU上,clf.to(device)将网络移到GPU上,在后面的系列中将讲述如何使用多块GPUs。我们也可以使用clf.cuda(0)将网络clf移到GPU 0。(0是GPU编号,可替换)

criterion是一个nn.CrossEmtropy类,如名字所示,实现cross entropy loss,也是nn.Module的子类。

接着定义变量optimizer做为一个optim.SGD对象,第一个参数是clf.parameters()parameters()函数返回nn.Module对象的参数(由nn.Parameters对象实现,将在下一节探索高级PyTorch功能中学习,目前,可以认为是一个与Tensors有关的可学习的列表)。clf.parameters()基本上是神经网络的权重。

正如代码中所示,我们将调用优化器上的step()函数。调用step()时,优化器使用梯度更新规则方程更新clf.parameters()中的每个Tensor,通过调用每个Tensor的grad属性获取梯度。

通常,任何优化器的第一个参数,无论是SGD,Adam或是RMSprop,都是Tensors列表,它支持更新,其余的参数是不同的超参数。

scheduler,顾名思义,可以调节optimizer的超参数。optimizer用来实例化schduler,每次在调用scheduler.step()时更新参数。

写一个训练循环

我们最终训练了200轮,你可以增加轮数,在GPU上将会花费一点时间,本教程的工作重点是展示PyTorch如何工作而不是达到最佳准确度。

我们在每轮评估了分类精度。

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
for epoch in range(10):
    losses = []
    scheduler.step()
    # Train
    start = time.time()
    for batch_idx, (inputs, targets) in enumerate(trainloader):
        inputs, targets = inputs.to(device), targets.to(device)

        optimizer.zero_grad()                 # Zero the gradients

        outputs = clf(inputs)                 # Forward pass
        loss = criterion(outputs, targets)    # Compute the Loss
        loss.backward()                       # Compute the Gradients

        optimizer.step()                      # Updated the weights
        losses.append(loss.item())
        end = time.time()
        
        if batch_idx % 100 == 0:
          print('Batch Index : %d Loss : %.3f Time : %.3f seconds ' % (batch_idx,          	         np.mean(losses), end - start))
      
          start = time.time()
    # Evaluate
    clf.eval()
    total = 0
    correct = 0
    
    with torch.no_grad():
      for batch_idx, (inputs, targets) in enumerate(testloader):
          inputs, targets = inputs.to(device), targets.to(device)

          outputs = clf(inputs)
          _, predicted = torch.max(outputs.data, 1)
          total += targets.size(0)
          correct += predicted.eq(targets.data).cpu().sum()

      print('Epoch : %d Test Acc : %.3f' % (epoch, 100.*correct/total))
      print('--------------------------------------------------------------')
    clf.train()

现在,上面是一大块代码,虽然我在代码中添加了注释以告知读者发生了什么,但我现在将解释代码中不那么重要的部分。

我们首先在每轮开始调用了scheduler.step()来确保optimizer使用正确的学习率。

循环里的第一件事情就是把inputtarget移到GPU上,和模型在相同的设备上,否则pytorch就会抛出错误并停止。

注意我们在前向传递之前调用optimizer.zero_grad(),这是因为叶子Tensors会保留之前前向传递的梯度,如果在损失上再次调用backward,那么新的梯度就会加在之前的梯度上。这个功能方便RNNs工作,但是现在我们需要将梯度设为0以便梯度不会在后续传递之间积累。

我们将评估代码放在了torch.no_grad管理器中,这样在评估时就不会创建图,如果对此感到困惑,可以参考第一部分自动求导的概念。

同样要注意我们在评估之前调用了clf.eval(),然后是clf.train(),pytorch中的模型有两个状态eval()train()。它们之间的主要区别在于像BatchNorm这样的状态层和Dropout,它们在训练和推断时表现不一样:eval时进入推断模式,train时进入训练模式。

总结

这是一个详尽的教程,我们向您展示了如何构建基本的分类器,这仅仅是开始,我们已经涵盖了所有的模块可以使你开始用pytorch开发深度网络。

下一节,我们将研究PyTorch中的一些高级功能,这些功能将增强深度学习设计,这些包括创建更复杂架构的方法,如何定制培训,例如为不同参数设置不同的学习率。

传送门
  1. PyTorch 文档

  2. 更多PyTorch 例子

  3. 如何在PyTorch中使用Tensorboard