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中高級的功能,這將增強你的深度學習的設計。這將包括創建更爲複雜的架構,如何去定製訓練,比如不同的參數有着不同的學習率。

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