PyTorch 101, Part 2: 构建你的第一个卷积神经网络

翻译原文https://blog.paperspace.com/pytorch-101-building-neural-networks/

说在前面:这篇文章是Ayoosh Kathuria关于PyTorch教程的系列文章,非常喜欢他的系列教程,讲的很详细很有启发。因此把原文的系列教程翻译了下来,并结合了自己的部分理解。因为本人能力有限,难免和原文表达的含义有所出入,仅仅作为交流使用。本篇博客是该系列论文的第二篇(一共有五篇)


PyTorch 101, Part 2: 构建你的第一个卷积神经网络

        在这个部分,我们将会实现一个卷积神经网络,对CIFAR-10的图片进行分类。我们会涉及到实现卷积神经网络,数据加载和设置衰退学习率的策略。

        在这篇文章中,我们将会讨论如何使用PyTorch去构建定制的神经网络架构,怎样去配置你的训练循环。我们将会实现一个RestNet网络架构,对CIFAR-10数据集的图片进行分类。

        在文章开始之前,我强调一下这篇教程的目的不是在任务中获得最好的精度,而是向你展示如何去使用PyTorch。

        我再强调一下,这是我们的基于PyTorch系列教程的第二部分。尽管阅读第一部分对这片文章来说不是必要的,但是还是强烈建议去读一下。

在这篇文章中,我们将会涉及:

  1. 如何使用nn.Module类去构建神经网络
  2. 如何使用Dataset和Dataloader类去构建带有数据增广的定制数据输入。
  3. 如何使用不同学习率策略去配置你的学习率。
  4. 在CIFAR-10数据集中,训练一个基于分类器Resnet网络进行图片分类.

目录

PyTorch 101, Part 2: 构建你的第一个卷积神经网络

1 前沿知识

2 一个简单的神经网络

3 构造网络

4 输入格式

5 加载数据

     5.1 torch.data.utils.dataset

     5.2 数据增广

     5.3 torch.utils.data.Dataloader

6 训练和评估

     6.1 torch.optim

7 训练循环

8 结论


1 前沿知识

  1. 链式求导法则
  2. 了解深度学习的基础知识
  3. PyTorch 1.0
  4. 阅读过了教程第一部分

2 一个简单的神经网络

        在这个教程中,我们将会实现一个非常简单的神经网络:

3 构造网络

        在PyTorch中,torch.nn模块是设计神经网络的基石。通过实例化torch.nn.Module对象,可以实现一个网络层,比如一个全连接层,一个卷积层,一个池化层,一个激活函数,以及一个完整的网络架构(从这里开始,我们用nn.module来表示torch.nn.Module)。

        多个nn.Module对象连接在一起可以形成一个更大的nn.Module对象,这就是我们可以使用很多网络层去实现一个神经网络的原因。事实上,在PyTorch中,nn.Module可以用来表示随意的函数f。

        在使用nn.Module类的时候,你必须要去重写它的两个函数。

  1. __init__()函数。在你创建一个nn.Module实例对象的时候,这个函数就会被调用。在这里,你可以定义网络层的很多参数,比如过滤器,卷积层中卷积核的尺寸,dropout层中dropout的可能性。
  2. forward()函数。这里是你定义你的输出是如何计算的地方。这个函数不需要你显示的调用,通过调用nn.Module的实例就可以运行。就像一个带参的函数一样。
# Very simple layer that just multiplies the input by a number
class MyLayer(nn.Module):
  def __init__(self, param):
    super().__init__()
    self.param = param 
  
  def forward(self, x):
    return x * self.param
  
myLayerObject = MyLayer(5)
output = myLayerObject(torch.Tensor([5, 4, 3]) )    #calling forward inexplicitly 
print(output)

        从上面我们可以看到,在PyTorch中,网络的定义和数据处理是分开进行的。在__init__()函数中,我们定义网络层的先后顺序,并设置卷积核尺寸,步长大小等等;而在forward()函数中,我们定义数据在网络层中是如何进行处理的。

另外一个经常使用并且非常重要的类是nn.Sequential类。当我们初始化这个类的时候,我们可以传入一个特定顺序的nn.Module对象。这个对象由nn.Sequential类返回,并且它本身即是一个nn.Module对象。当我们传入输入数据运行这个对象的时候,它按照我们输入给他们的顺序来,有序地运行我们传入的所有的nn.Module对象。

combinedNetwork = nn.Sequential(MyLayer(5), MyLayer(10))

output = combinedNetwork([3,4])

#equivalent to..
# out = MyLayer(5)([3,4])
# out = MyLayer(10)(out)

        我们开始去实现我们的分类网络。我们将会使用卷积层和池化层,以及一个定制的实现好了的残差层:

        尽管PyTorch的torch.nn模块提供了很多的模块,但是我们还是需要自己去实现残差块。在我们实现一个神经网络之前,我们需要实现RestNet模块(ResNet模块实际实际上就是不同网络层的不同排列组合):

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):
        # 其实还可以把ReLU层放在__init__()中,将Con,BN,ReLU层一起构成一个Sequential
        out = nn.ReLU()(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += self.shortcut(x)
        out = nn.ReLU()(out)
        return out

在ResNet网络模块中,我们可以看到有两中类型的ResNet模块

  • 当输入通道数=输出通道数或者stride=1的时候,恒等映射就是一条直线。
  • 当输入通道数!=输出通道数或者stride!=1的时候,恒等映射就是一个卷积操作

因为存在这两种情况,因此我们需要进行if判断,到底恒等映射应该是哪一种情况。

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

        现在,我们可以定义我们整个网络。

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)
        # 这一层也可以放在__init__函数中,4指的是进行平均池化层的kernel_size
        out = nn.AvgPool2d(4)(out)
        # 在进行全连接之前,我们需要将数据调整成行向量
        out = out.view(out.size(0), -1)
        out = self.linear(out)
        return out

4 输入格式

        现在,我们已经拥有了我们的网络对象,我们将我们的目光转移到输入上。当我们在使用深度学习的时候,我们遇到很多不同种类的输入。图像,音频或者是高维的结构数据。

        我们正在处理的这些类型的数据,会规定我们的输入格式。在PyTorch中,通常总是让batch在第一维度。因为这里处理的是图像,我会描述一下接收图片的输入格式。

        图片的输入格式是[B C H W]。其中B是批量的尺寸,C是图片的通道数,H和W分别是图片的高和宽。

        因为我们使用随机的权重,因此我们神经网络的输出现在来说还是不确定的。接下来让我们训练我们的网络。

5 加载数据

        现在,让我们加载数据,我们将会使用torch.utils.data.Dataset和torch.utils.data.Dotaloader这两个类。

        我们首先需要再我们的代码文件目录中下载CIFAR-10数据集。打开终端,转到你的代码目录下,然后运行下面的命令。

wget http://pjreddie.com/media/files/cifar.tgz
tar xzf cifar.tgz

        如果你使用macOS,你可能需要使用curl,或者如果你使用windows,你需要手动下载。

        我们需要读取CIFAR数据集中的类别标签。

data_dir = "cifar/train/"

with open("cifar/labels.txt") as label_file:
    labels = label_file.read().split()
    label_mapping = dict(zip(labels, list(range(len(labels)))))

        我们将会使用PIL库去读取图片。在我们写函数取加载我们数据之前,我们需要写一个预处理函数来进行下面的工作。

  1. 使用0.5的可能性去随机水平我们的图片
  2. 使用CIFAR数据集的均值标准差去归一化我们的图片
  3. 将图片的通道由W H C变成C H W
def preprocess(image):
    image = np.array(image)
    
    if random.random() > 0.5:
        image = image[:-1,:,:]
    
    cifar_mean = np.array([0.4914, 0.4822, 0.4465]).reshape(1,1,-1)
    cifar_std  = np.array([0.2023, 0.1994, 0.2010]).reshape(1,1,-1)
    image = (image - cifar_mean) / cifar_std
    
    # 下面个等价于:image = image.permute(2,1,0)
    # permute输入的是转置之后的维度顺序;transpose输入的是哪两个维度需要进行转置
    image = image.transpose(2,0)
    return image

通常,PyTorch给你提供了两个和构造加载数据的输入管线的类。

  1. torch.data.utils.dataset, 现在我们将其称之为dataset类。
  2. torch.data.utils.dataLoader,现在我们将会将其称之为dataloader类。

      5.1 torch.data.utils.dataset

        dataset是一个加载数据并且返回生成器的类,因此你可以迭代它。它还可以在你的输入中加入数据增广的技巧。

        如果你想为你的数据创建一个dataset实例对象,你需要去重写三个函数。

  1. __init__函数。在这里,你可以定义和你的数据集相关的东西。最重要的是你的数据集的位置。你还可以定义很多你想要应用的数据增广的技巧。
  2. __len__函数。在这里,你只需要返回数据集的长度。
  3. __getitem__函数。这个函数接收一个索引i的参数,然后返回一个数据样例。在我们训练循环的过程中,dataset实例对象会使用不同的i,在每次迭代中调用这个函数。

        这里有个CIFAR数据集的dataset对象的实现。

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类允许我们去合并延迟数据加载原则。这意味着,可以不一次性将所有数据加载到内存中(这个可以通过在__init__函数中将所有图片加载到内存中来实现,而不是仅仅加载地址),而是当需要的时候,一次性只加载一个数据样例(当__getitem__调用的时候)。

        当你创建一个Dataset类的对象的时候,你基本上可以迭代所有对象,就像遍历任何python迭代器一样。每此迭代,__getitem__函数就会用索引i当做是它的输入参数。

     5.2 数据增广

        在__init__函数中,我也传入了一个transforms的参数。它可以是实现数据增广的任意的python函数。尽管你可以在预处理代码中进行数据增广,但是在__getitem__进行只不过是个人习惯而已。

        这里,你也可是添加数据增广。这些数据增强既可以是函数也可以是类。你只需要确保在__getitem__函数中,你使用它们能够得到你想要的输出就行。

        我们有很多数据增广的库,你可以使用它们去进行数据增广。

        在我们的例子中,torchvision库提供了很多预制的变换,以及将他们组成一个更大变换的能力。但是在这里我们只限于讨论PyTorch。

     5.3 torch.utils.data.Dataloader

        这个Dataloader类能够简便:

  1. 批量化数据
  2. 随机化数据
  3. 使用线程一次加载多个数据
  4. 预取数据,也就是,当GPU处理当前批次数据的时候,Dataloader能够同时将下一批次数据加载到内存中。这意味着GPU不需要等待下一个批次的数据,而且能够加快训练。

        你可以使用一个Dataset对象去实例化一个Dataloader实例对象。然后你就可以像迭代一个dataset实例对象一样,去迭代一个Dataloader实例对象。

        然而,你可以具体制定不同的选项,这些选项能够让你对循环选项有着更多的控制。

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)

        trainset和trainloader都是python生成器对象,你可以用下面的方式去迭代它们。

for data in trainloader:   # or trainset
	img, label = data

        然而,Dataloader类比Dataset类更便利一些。在每一次迭代中,Dataset类会给我们返回__getitem__()函数的输出,Dataloader的作用比这个大。

  1. 注意,trainset的__getitem__()方法返回一个3*32*32形状的numpy 数组。Dataloader将图片批量化成一个128*3*32*32形状的Tensor(因为在你的代码中batch_size=128)
  2. 还需要注意,当我们的__getitem__()方法输出一个numpy数组的时候,Dataloader类就会自动将它转换成一个Tensor
  3. 即使__getitem__()方法返回一个非数值类型的对象,这个Dataloader类也会把它转换成一个尺寸为B的列表或者元组(在我们的例子中B是128)。假设__getitem__()也返回一个string,也就是标签值。如果我们在实例化dataloader的时候,设置batch=128,那么每个迭代,Dataloader将会给我们返回一个包含128字符串的元组。

        除了上述优点外,还可以添加预取、多线程加载,几乎每次都建议使用Dataloader类。

6 训练和评估

        在我们开始写我们的循环之前,我们需要决定我们的超参数和我们优化算法。PyTorch在torch.optim中给我们提供了很多预制的优化算。

     6.1 torch.optim

        torch.optim模块给你提供了训练和优化之类的函数。

  1. 不同优化算法(比如optim.SGD, optim.Adam)
  2. 能够设置学习率
  3. 不同的参数能够有不同的学习率的能力(尽管在这篇文章中我们不讨论这个问题)

        我们使用一个交叉熵损失函数,和基于SGD优化算法的动量梯度下降法。我们的学习率在第150代和200代的时候会有0.1的下降。

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 0或者没有cpu的话,device设置为cuda:0。

        在我们初始化一个网络的时候,它默认是在CPU上面的。如果GPU存在的话,clf.to(device)会将这个网络移动到GPU上。在另一篇文章中,我们将回介绍如何使用多个GPU的细节。我们还可以使用clf.cuda(0)去将我们的网络clf移动到GPU 0上(在一般情况下,使用CPU的索引代替0)。

        criterions基本上是一个nn.CrossEntropy类的实例对象,顾名思义,它实现了交叉熵损失函数。它是nn.Module的子类。

        我们之后定义了变量optimizer作为optim.SGD的实例对象。optim.SGD的第一个参数是clf.parameters()。nn.Module对象的parameters()函数返回一个parameters(作为nn.Parameter对象的实现,我们将会在下一节中学习这个类,在那里我们探索PyTorch高级的功能。现在,把他们想象成一个和Tensor相关的列表,他们都是可学习的)。clf.parameters()是我们神经网络的基本权重。

        正如你在代码中看到的,在我们的代码中,我们将会调用optimizer的step()函数。当step()函数调用的时候,这个优化器就会使用梯度更新规则方程去更新clf.parameters()中的每个Tensor。可以使用每个Tensor的grad属性获取他们的梯度。

        一般情况下,任何优化器SGD, Adam或者是RMSprop,它的第一个参数,是要去更新的Tensors列表。剩下的参数定义了不同的超参数。

        顾名思义,sheduler可以规划optimizer的不同的超参数。optimizer是用来初始化scheduler的。我们调用scheduler.step()的使用,它每次都会更新超参数。

7 训练循环

        我们最终训练200代,你可以增加epochs的数量,在GPU上,这可能需要等一下。再次重申一下,这篇教程的目的是去展示PyTorch是如何工作的,而不是去获得最高的准确率。

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()  

        现在,上面是一大块代码。我没有把它拆成更小的代码,因此不会有连续性的风险。尽管在代码中我已经添加了注释去提醒读者它们的作用,但是我现在会解释代码中不那么琐碎的部分。

        我们首先在每个epoch的开始,调用了scheduler.step()函数,来确保optimizer会使用正确的学习率。

        在循环中,我们做的第一件事是我们把我们的input和target移动到GPU 0上了。这个应该和我们模型所在的设备相同,否则的话PyTorch就会报错并且停止。

        注意,在我们前向传播之前,我们调用了optimizer.zero_grad()函数。这是因为一个叶子Tensor(存放我们权重的地方)会从前面的传播中保存梯度。如果损失函数重新调用了backward函数,新的梯度就会简单地添加到有grad属性保存的之前的梯度。当我们使用RNNs的时候,这个函数就很方便了,但是现在,我们需要设置梯度为零,因此不需要去计算后面的梯度。

        我们也将我们的评估代码放到了torch.no_grad上下文中,因此评估的时候不需要创建计算图。如果你觉得难以理解,你可以回到part1去更新你的autograd的概念。

        还需要注意,在评估之前,我们调用了clf.eval()函数,然后clf.train()在它后面。在PyTorch中的模型有两个状态eval()和train()。这个状态的不同归根于状态层,比如Batch Norm层(训练中批处理统计vs推理总体统计)和Dropout层,在推理和训练中,他们有着不同的表现。eval告诉nn.Module去将这些层放在推理模块中,但是train()告诉nn.Module把它放在训练模块中。

8 结论

        这是一个非常详细的教程,我们向你展示了如何去构造一个基本的训练分类器。但是这仅仅是一个开始,我们已经涉及了所有的构造块,它可以让你开始使用PyTorch去构建深层网络架构。

        在这个系列论文的下一节,我们将研究一下PyTorch中高级的功能,这将增强你的深度学习的设计。这将包括创建更为复杂的架构,如何去定制训练,比如不同的参数有着不同的学习率。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章