# PyTorch Note ###### tags: `深度學習實作` `Python` <!-- [之前實做MNIST分類的程式](https://colab.research.google.com/drive/1H19zJvX9ZIUWKOFKv8ShLFilmppH9RLA) --> ```python= from __future__ import print_function import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim from torchvision import datasets, transforms # 繼承nn.Module來創建自己的模型。 class Net(nn.Module): # 覆寫__init__(self) def __init__(self): super(Net, self).__init__() # 創建forward中會使用到的神經網路層。 self.conv1 = nn.Conv2d(1, 32, 3, 1) self.conv2 = nn.Conv2d(32, 64, 3, 1) self.fc1 = nn.Linear(9216, 128) self.fc2 = nn.Linear(128, 10) # 覆寫forward(self, x) def forward(self, x): # 將前面建立的網路層與torch.nn.functional中的網路層串接起來。 x = self.conv1(x) x = F.relu(x) x = self.conv2(x) x = F.relu(x) x = F.max_pool2d(x, 2) x = torch.flatten(x, 1) x = self.fc1(x) x = F.relu(x) x = self.fc2(x) output = F.softmax(x, dim=1) return output def train(model, device, train_loader, optimizer, epoch): # 設定模型為訓練模式。 model.train() # 透過DataLoader依次取得Dataset中的資料。 for batch_idx, (data, target) in enumerate(train_loader): # 將取得的資料搬移至目標裝置中(如果有GPU就是GPU)。 data, target = data.to(device), target.to(device) # 先將所有權重的梯度值歸零。 optimizer.zero_grad() # 執行正向傳播得到模型的預測結果。 output = model(data) # 透過比較真正的結果、模型的預測結果計算損失。 loss = F.cross_entropy(output, target) # 執行反向傳播得到所有權重的梯度值。 loss.backward() # 透過事先創建的優化器來更新各個權重。 optimizer.step() # 每隔100個批次印一次訓練時的損失。 if batch_idx % 100 == 0: print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format( epoch, batch_idx * len(data), len(train_loader.dataset), 100. * batch_idx / len(train_loader), loss.item())) def test(model, device, test_loader): # 將模型切換至測試模式。 model.eval() test_loss = 0 correct = 0 # 測試時不需要計算梯度,使用行程式可以省去不必要的運算。 with torch.no_grad(): # 此部分的程式與訓練時差不多,不多做解釋。 for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) test_loss += F.cross_entropy(output, target).item() pred = output.argmax(dim=1, keepdim=True) correct += pred.eq(target.view_as(pred)).sum().item() test_loss /= len(test_loader.dataset) print('\nTest set: Average loss: {:.4f}, Accuracy: {}/{} ({:.0f}%)\n'.format( test_loss, correct, len(test_loader.dataset), 100. * correct / len(test_loader.dataset))) def main(): # 檢查電腦有沒有GPU可以用,如果有的話就將device設為"cuda",反之設成"cpu"。 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 使用PyTorch包好的MNIST資料集需要指定資料前處理的方式,透過下列程式指定轉換方式。 transform=transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) # 使用官方提供的MNIST資料集類別建立資料集 dataset1 = datasets.MNIST('../data', train=True, download=True, transform=transform) dataset2 = datasets.MNIST('../data', train=False, transform=transform) # 建立DataLoader,指定他們從dataset1、dataset2抓資料,一次抓128筆資料。 train_loader = torch.utils.data.DataLoader(dataset1,batch_size=128, shuffle=False) test_loader = torch.utils.data.DataLoader(dataset2, batch_size=128, shuffle=False) # 建構模型,並且將模型中的權重搬移到目標裝置。 model = Net().to(device) # 建構最佳化器。 optimizer = optim.Adam(model.parameters(), lr=0.001) # 訓練5個epochs。 epochs = 5 for epoch in range(epochs): train(model, device, train_loader, optimizer, epoch) test(model, device, test_loader) # 儲存模型。 torch.save(model.state_dict(), "myFirstModel.pth") if __name__ == '__main__': main() ``` # PyTorch的運作概念 ## 運作的大方向 神經網路的訓練分成正向傳播、反向傳播兩部分,反向傳播的過程中涉及非常多的梯度運算,簡單來講就是一堆偏微分,而計算偏微分的過程中需使用非常複雜的鏈鎖率運算。為了可以實現這樣的運算,PyTorch使用了<font color="orangered">計算圖(Computational Graph)</font>的概念,將神經網路中所有運算關係以計算圖記錄,之後就可以透過計算圖來協助取得各個權重的偏微分值,也就是梯度。有了梯度的結果以後,就可以使用各式最佳化演算法來更新各個權重值,使得模型可以做出更好的預測。 ## PyTorch具體如何產生計算圖:Autograd 在PyTorch框架中,Autograd是計算梯度最重要的套件。他在運作過程中,會將正向傳播中所有的運算記住,之後於反向傳播時針對這些運算來計算梯度。由於神經網路中<font color="blue">並不是所有張量都需要被更新,因此Autograd套件會判斷各個張量是否需要被更新來決定是否將其加入計算圖中</font>,判斷的方式為:假如輸入的張量`requires_grad=True`,那麼他到輸出間的計算過程都會被記錄。在執行完反向傳播後,該輸入張量的梯度值就會被記錄到`.grad`這個屬性中。 ### 補充:Autograd - Function Autograd套件中還有一個也很重要的類別叫做`Function`,計算圖中除了輸入的張量以外,其他每個Tensor都會有一個成員變數叫做`.grad_fn`,他會用來記錄各個Tensor在計算圖中負責的函數。 ## PyTorch的基本運算元素:張量 PyTorch中的所有函數其實都是對張量進行運算,因此如果希望使用PyTorch來實現深度學習模型的訓練,我們就需要將資料轉成張量的形式,詳細的內容將介紹於後面,這邊先有個概念就好。 # Overview使用PyTorch訓練模型的流程 訓練深度學習模型的步驟如下圖所示: 1. 載入資料集:訓練的過程中,我們會先將資料集讀進來,之後再將它分成訓練資料集、驗證資料集,這是為了測試模型的泛用性。 2. 資料前處理:在將資料讀進來之後,我們通常會使用資料擴增(Data Augmentation)的方式來增加資料集的大小,透過這樣的方式可以讓模型訓練得更好。除了資料擴增以外,還需要將資料轉成張量(Tensor),如此才能使用PyTorch來對這些資料進行運算。此部分將涉及下列知識,詳細內容會在後面的篇幅中介紹: - 建立張量。 - 將張量搬到GPU上運算。 - 定義張量是否需要被更新。 - 使用PyTorch提供的函式庫定義每次要取得的資料。 - PyTorhc中的資料擴增方法。 4. 設計模型:神經網路的訓練中最重要的就是模型的設計,首先需要決定我們要使用的模型,接著再使用PyTorch提供的函式庫來建構出預想中的模型,後面將會介紹如何透過這些函式庫來建構出我們設計出的模型。 5. 優化模型:訓練深度學習模型的過程中涉及非常多超參數的選擇,其中包含損失函數的類別、最佳化演算法的選擇,以及學習率的選擇等。之後筆記中將會介紹: - 透過PyTorch函式庫指定損失函數。 - 透過PyTorch函式庫指定最佳化演算法。 6. 訓練模型:前面幾個步驟為事前的準備,所有步驟都完成以後就可以開始訓練模型,過程中我們將會儲存Loss最低的權重。後面將會介紹: - 如何透過使用前述建立好的各個物件來實現完整的訓練流程。 - 透過PyTorch函式庫儲存訓練好的權重。 - 透過PyTorch函式庫於訓練過程中動態調整學習率。 ![](https://i.imgur.com/WZkv02h.png) # 一、 資料前處理相關 深度學習模型中的所有運算皆是基於張量(Tensor),因此筆記中將會先介紹如何建立張量、如何將影像資料轉為張量,接著再來介紹PyTorch中的資料前處理方法。 ## (一)建立張量 ### 1. 直接使用PyTorch函式庫產生張量: PyTorch中有許多可以產生張量的函式庫,筆記中只舉常見的幾種方式: - `torch.tensor()`:產生數值對應到列表的張量。 - `torch.zeros()`:產生數值全為零的張量。 - `torch.ones()`:產生數值全為一的張量。 - `torch.rand()`:產生數值為亂數的張量。 ```python= x = torch.tensor([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=torch.float) y = torch.zeros(2, 3) z = torch.ones(2, 3) r = torch.rand(2, 3) print("x = {}\n".format(x)) print("y = {}\n".format(y)) print("z = {}\n".format(z)) print("r = {}\n".format(r)) ``` 輸出結果: ```python= x = tensor([[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]]) y = tensor([[0., 0., 0.], [0., 0., 0.]]) z = tensor([[1., 1., 1.], [1., 1., 1.]]) r = tensor([[0.3711, 0.3957, 0.2134], [0.8127, 0.7507, 0.5913]]) ``` ### 2. 將影像資料(Numpy Array)轉為張量: 透過OpenCV函式庫可以讓我們將影像讀入,讀入的影像資料將會以Numpy陣列的方式被存於記憶體中,因此如果要將影像轉為張量形式,就必須了解如何將Numpy陣列轉換為張量。以下程式示範從載入影像到將其轉為張量的步驟,在創建完Numpy陣列後,就可以接著使用剛剛創建出來的Numpy陣列來產生Tensor,這部分可以使用`torch.from_numpy()`。 ```python= import numpy as np import cv2 import torch # 使用OpenCV函式庫將test.png讀進來,讀進來的資料型態為Numpy陣列。 img = cv2.imread("./test.png") # 將影像資料轉換為torch.Tensor型態。 img_tensor = torch.from_numpy(img) ``` ## (二)將Tensor搬到GPU上 如果使用CPU訓練神經網路將會耗費非常多時間,因此我們通常會使用GPU來增加其運算速度。<font color="red">使用前面介紹到的方法創建出來的張量其實還放在CPU上</font>,因此如果我們要使用GPU來運算的話,還需要透過`tensor.to("cuda")`的方式將Tensor丟到GPU上。 ```python= x_tensor = torch.from_numpy(x) print("The type of x_tensor and y_tensor is: {0}".format(x_tensor.type())) x_tensor = x_tensor.to("cuda") print("The type of x_tensor is: {0}".format(x_tensor.type())) ``` Output: ``` The type of x_tensor and y_tensor is: torch.LongTensor The type of x_tensor and y_tensor is: torch.cuda.LongTensor ``` ### 補充:有多顆GPU時,透過編號指定欲使用的GPU 前面的程式雖然只用`"cuda"`就可以指定要將資料搬到GPU,但PyTorch官方其實比較推薦另一種方式,因為這種方式還可以指定要將使用哪個GPU: ```python= # 這樣寫代表我們想要使用0號GPU gpu = torch.device("cuda:0") # 使用預設編號的GPU gpu = torch.device("cuda") # 指定完我們要使用的GPU之後,就可以透過上面建立的"gpu"物件指定將張量搬移到該裝置上。 x_tensor = x_tensor.to(gpu) ``` ## (三)建立可計算梯度的張量 ### 定義張量為可更新張量的方法 在PyTorch中,如果張量的`requires_grad`屬性被設定為`True`,就代表他可以被更新,也就是變數的意思。不過<font color="red">張量在剛被建立出來時,`requires_grad`會預設為`False`</font>。因此如果要讓PyTorch幫我們計算該張量的梯度,就要在建立Tensor後使用以下任一方法,讓PyTorch知道有哪些張量需要計算梯度: - `tensor.requires_grad = True`:直接指定成員物件`requires_grad`為True或False。 - `tensor.requires_grad_()`:不需要特別指定True或False,直接將`requires_grad`屬性設定為`True`。 ※ <font color="blue">Pytorch中`_`結尾的函數通常代表會改變該變數本身</font>。 :::success 張量預設`requires_grad == False`其實很合理,因為我們會預期創建出來的資料是常數,否則他將有可能被變動。例如:我們有一個影像張量,在透過`torch.from_numpy()`將他轉為張量後,如果預設`requires_grad`為`True`,那麼可能在多次運算後,他就變成一坨雜訊。 ::: ### 範例程式:建立可計算梯度的張量 ```python= gpu = torch.device("cuda") z = np.arange(1, 100).astype(float) z_tensor = torch.from_numpy(z).to(gpu) # 指定z_tensor為可被更新的張量。 z_tensor.requires_grad_() ``` 輸出結果: ```python= tensor([ 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., 90., 91., 92., 93., 94., 95., 96., 97., 98., 99.], device='cuda:0', dtype=torch.float64, requires_grad=True) ``` ### PyTorch中指定`requires_grad`的特性: 由於梯度的計算與鏈鎖率有關,因此張量的`requires_grad==True`具有傳遞性。假如輸入的張量可以計算梯度,那麼他後面的所有張量也都應該要可以計算梯度。所以<font color="blue">只要我們已經指定輸入張量可以計算梯度,就不需要再另外指定中間張量的`requires_grad`</font>。 ## (四)透過Dataset, DataLoader於訓練時取得資料 透過前面提到的方式,我們已經有辦法建立可以被更新的張量,並且也有辦法指定張量運作於GPU中。但在訓練深度學習模型的時候,往往一次都需要載入多筆資料 (mini-batch),而且也有可能會需要隨機從資料集中抓資料。如果單純使用前面提到的方法程式當然可以運作,但是會變的非常的凌亂,為了讓程式變的更整潔、更方便修改,我們通常會使用PyTorch提供的Dataset、DataLoader來協助載入資料。 - Dataset:定義一筆資料的形式。 - DataLoader:可以協助我們取得資料,我們可以決定要從哪個Dataset中抓資料、一次抓幾筆資料、是否要隨機存取等。 ### 1. Dataset 如同前面所說,`torch.utils.data.Dataset`是一個PyTorch提供的類別,透過這個類別我們可以定義每一筆資料的形式,接著就可以再使用DataLoader存取其中的資料。使用時我們需要自己定義一個繼承於`Dataset`的類別,並且需複寫以下**3個類別方法**: - `__init__(self)`: 這個部分是用來初始化資料路徑、[Transforms](https://hackmd.io/KaOUXp-0Tqmog7NrWSWVkA?view#Transform)等,將他們存到`self`中。 - `__getitem__(self, index)`: 透過建構式初始化的那些變數,我們可以到指定路徑得到指定index的訓練資料。以電腦視覺來說,我們的訓練資料會是影像,在我們取得這些影像之後,可以使用初始化時指定的一些函數來將輸入資料做資料擴增。 - `__len__(self)`: 用來告訴DataLoader,我們有多少筆訓練資料。 ### 2. DataLoader 如同前面所說,`torch.utils.data.dataloader`為PyTorch提供的類別,透過這個類別可以協助我們取得資料,我們可以決定要從哪個Dataset中抓資料、一次抓幾筆資料、是否要隨機存取等。使用DataLoader除了可以讓我們方便實踐以外,也可以<font color="blue">避免一次將所有資料讀進來,浪費大量記憶體的問題</font>。因為它只有在要用到資料時,才會將資料抓進來。 ([官方文件](https://pytorch.org/docs/stable/data.html#torch.utils.data.DataLoader)) ```python= DataLoader(dataset, batch_size=1, shuffle=False, sampler=None, batch_sampler=None, num_workers=0, collate_fn=None, pin_memory=False, drop_last=False, timeout=0, worker_init_fn=None, *, prefetch_factor=2, persistent_workers=False) ``` ```python= dataloader = DataLoader(MyDataset, batch_size=2, shuffle=True) ``` ## (五)補充:Data Augmentation 由於我們取得的資料往往都需要先做一些前處理,例如調整影像大小、影像切割、將影像轉為張量、資料正規化等,因此PyTorch的`torchvision.transform`提供了各式各樣的資料擴增方法。而因為我們往往都會使用到不只一個轉換,因此PyTorch還提供了`torchvision.transforms.Compose`,讓我們可以將多個連續的轉換組成一個完整的轉換。 ```python= # 包含切割影像、將影像轉換成張量的轉換函數 transform = transforms.Compose([ transforms.CenterCrop(10), transforms.ToTensor(), ]) ``` <!-- <font color="red">把範例程式改成MNIST中使用到的</font> ## 範例程式 此程式複製自[此網址](https://rowantseng.medium.com/pytorch-%E8%87%AA%E5%AE%9A%E7%BE%A9%E8%B3%87%E6%96%99%E9%9B%86-custom-dataset-7f9958a8ff15): ```python= import torchvision.transforms as trns from PIL import Image from scipy.io import loadmat from torch.utils.data import DataLoader from torch.utils.data.dataset import Dataset class dogDataset(Dataset): def __init__(self, root, split, transform): # -------------------------------------------- # Initialize paths, transforms, and so on # -------------------------------------------- self.transform = transform # Load image path and annotations mat = loadmat(f'{root}/{split}_list.mat', squeeze_me=True) self.imgs = mat['file_list'] self.imgs = [f'{root}/Images/{i}' for i in self.imgs] self.lbls = mat['labels'] assert len(self.imgs) == len(self.lbls), 'mismatched length!' print('Total data in {} split: {}'.format(split, len(self.imgs))) # Label from 0 to (len-1) self.lbls = self.lbls - 1 def __getitem__(self, index): # -------------------------------------------- # 1. Read from file (using numpy.fromfile, PIL.Image.open) # 2. Preprocess the data (torchvision.Transform) # 3. Return the data (e.g. image and label) # -------------------------------------------- imgpath = self.imgs[index] img = Image.open(imgpath).convert('RGB') lbl = int(self.lbls[index]) if self.transform is not None: img = self.transform(img) return img, lbl def __len__(self): # -------------------------------------------- # Indicate the total size of the dataset # -------------------------------------------- return len(self.imgs) # Create train/valid transforms train_transform = trns.Compose([ trns.Resize((256, 256)), trns.RandomCrop((224, 224)), trns.RandomHorizontalFlip(), trns.ToTensor(), trns.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) valid_transform = trns.Compose([ trns.Resize((224, 224)), trns.ToTensor(), trns.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) # Create train/valid datasets train_set = dogDataset(root='./dataset/dogsDataset', split='train', transform=train_transform) valid_set = dogDataset(root='./dataset/dogsDataset', split='test', transform=valid_transform) # Create train/valid loaders train_loader = DataLoader( dataset=train_set, batch_size=16, shuffle=True, num_workers=4) valid_loader = DataLoader( dataset=valid_set, batch_size=16, shuffle=False, num_workers=4) # Get images and labels in a mini-batch of train_loader for imgs, lbls in train_loader: print('Size of image:', imgs.size()) # batch_size * 3 * 224 * 224 print('Type of image:', imgs.dtype) # float32 print('Size of label:', lbls.size()) # batch_size print('Type of label:', lbls.dtype) # int64(long) break ``` --> ## 補充:也可以將Tensor轉回Numpy Array 有時候我們可能會需要在訓練的過程中將部分Tensor轉為Numpy Array,例如將訓練過程中模型的預測結果可能是一張圖片,為了透過opencv函式庫將其存成圖片,就需要先將其轉為Numpy Array。由於Numpy Array是在CPU上運算的,假如我們使用GPU來訓練神經網路,張量就會存在於GPU上面,這時就需要先將Tensor搬移至CPU,再將其轉為Numpy Array。 - `tensor.cpu()`: 將Tensor從GPU搬到CPU。 - `tensor.numpy()`: 將已經存在於CPU的Tensor轉為Numpy Array。 ```python= x = x_tensor.cpu().numpy() print("The type of x is: {}".format(type(x))) ``` Output: ``` The type of x is: <class 'numpy.ndarray'> ``` # 二、 設計模型 ## (一)PyTorch提供的兩種建構Layer的方式 PyTorch主要提供以下兩種建構模型的方式,兩者可以達到的功能差不多,不過使用上還是有些不一樣: 1. `torch.nn.functional.<LayerName>`:<font color="blue">透過Call Function的方式使用</font>,由於是Call Function的方式,因此以全連接層來說需要在呼叫的時候自行代入該層對應的兩個張量Weight, Bias。另外,有些功能在訓練跟測試時會有不同的運作方式,這時候如果使用這種方式來建構網路層就需要額外傳入參數告訴他當前為訓練或測試模式。 2. `torch.nn.<LayerName>`:<font color="blue">對應到Python中的Class</font>,使用時需要先針對各個不同功能產生物件,之後透過呼叫物件的方式來使用。實際程式中,一個神經網路層會對應到一個物件,由於各網路層皆為已產生的物件,因此各個物件會Maintain各自會用到的張量,我們不需要再自行代入。 ## (二)如何在兩種方式中做選擇? 如果會使用到以下三種情況,建議使用`torch.nn`實現,其他時候用哪一種都沒關係: - 如同前面提到的,如果使用`torch.nn.functional`需要自行傳入各神經網路層對應的權重張量,這樣做非常麻煩,因此<font color="blue">有權重的神經網路層</font>建議使用`torch.nn`實現。 - 另外前面也有提到,對於訓練、測試有不同行為的網路層來說,使用`torch.nn.functional`需要額外傳入當前的工作狀態,而無法單純使用`model.eval()`、`model.train()`的方式來改變整個模型的工作模式。因此在<font color="blue">使用如Dropout或Batch Normalization等功能時</font>,建議使用`torch.nn`實現。 - 由於`torch.nn.functional`不能搭配`Sequential()`使用,因此<font color="blue">如果有要使用`Sequential()`的時候</font>,只能使用`torch.nn`實現。 ### 補充:網路上討論區的特殊情況 網路上提到,如果希望讓Dilation不一樣大的卷積層共用權重,那麼可以使用`torch.nn.functional`實現。因為`torch.nn`在一開始就已經定義好各個網路層的行為了,同一組權重無法有不同的工作方式。 ```python= import torch import torch.nn as nn import torch.nn.functional as F class Model(nn.Module): def __init__(self): super(Model, self).__init__() self.weight = nn.Parameter(torch.Tensor(10,10,3,3)) def forward(self, x): x_1 = F.conv2d(x, self.weight,dilation=1, padding=1) x_2 = F.conv2d(x, self.weight,dilation=2, padding=2) return x_1 + x_2 ``` ## (三)使用PyTorch將各神經網路層打包成單一完整模型 目前已經介紹PyTorch提供的兩種神經網路層建構方式,但其實一個完整的神經網路由無數網路層組成,若每次都要直接在主程式中呼叫一堆神經網路層將會變得非常混亂,而且也難以維護。為了解決這個問題,PyTorch提供了一種可以將各個神經網路層打包為單一完整模型的方法,將模型打包完成後即可使用單一行程式呼叫完整神經網路。其實作方式跟Dataset、DataLoader一樣,都需要<font color="blue">自己定義一個繼承於PyTorch原始類別`nn.Module`的類別</font>,使用時需複寫以下**2個類別方法**: - `__init__`:自定義神經網路類別的建構式,我們會在這邊創建之後會用到的所有物件。前面說過如果要使用`torch.nn`中的神經網路層,需要先建構出網路層物件,這個建構的部分就會在`__init__`中實現。 - `forward`:定義神經網路被呼叫時的工作行為,由於一個神經網路的工作模式為無數神經網路層的串連,因此在這個類別方法中其實就是要依序呼叫所有會用到的神經網路層。 ## (四)PyTorch官方手寫數字辨識模型訓練程式 手寫數字辨識為深度學習的Hello Word,下列程式為PyTorch官方的手寫數字辨識模型的建構程式,可以看到程式中定義了一個繼承於`nn.Module`的類別,其中也確實覆寫了`__init__`、`forward`方法。首先查看`forward`中使用了哪些網路層,我們可以發現總共使用了卷積層、全連接層、Dropout層、ReLU、Max Pooling,以及Log Softmax。如同前面介紹的,我們偏好使用`torch.nn`實現具有權重以及訓練測試有不同行為的神經網路層,因此卷積層、全連接層,及Dropout層皆使用`torch.nn`實現,而剩餘的部分則可以使用`torch.nn.functional`實現。程式中確實於`__init__`建構了使用`torch.nn`類別的物件,而`forward`中則是依序將各個網路層串連得到輸出。 (下列程式使用到的函式庫將分為`torch.nn.functional`以及`torch.nn`於後面章節介紹) ```python= import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super(Net, self).__init__() # 建構式中創建所有用到torch.nn類別的物件 self.conv1 = nn.Conv2d(1, 32, 3, 1) self.conv2 = nn.Conv2d(32, 64, 3, 1) self.dropout1 = nn.Dropout(0.25) self.dropout2 = nn.Dropout(0.5) self.fc1 = nn.Linear(9216, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): # forward()中可以直接使用建構式中創建出來的神經網路層物件 # 其他torch.nn.functional中的函數可以直接使用 x = self.conv1(x) x = F.relu(x) x = self.conv2(x) x = F.relu(x) x = F.max_pool2d(x, 2) x = self.dropout1(x) x = torch.flatten(x, 1) x = self.fc1(x) x = F.relu(x) x = self.dropout2(x) x = self.fc2(x) output = F.log_softmax(x, dim=1) return output ``` ## (五)torch.nn.functional 筆記中只講解PyTorch官方手寫數字辨識程式中使用到的函數,該程式碼中只使用到三種`torch.nn.functional`類型函數,因此此部分只介紹這三種,若有其他特殊需求再到[官方文件](https://pytorch.org/docs/stable/nn.functional.html)查看詳細內容。 ### 1. torch.nn.functional.relu 這個函數會針對張量x的所有資料做ReLU運算。 ```python= x = F.relu(x) ``` ### 2. torch.nn.functional.max_pool2d 這個函數中的第一個參數為欲執行Max Pooling的張量,而第二個參數則代表每次執行Max Pooling所查看的範圍。以下列程式來說,由於第二個參數是2,因此最後他會針對每$2\times 2$的資料抓最大值丟到輸出。 ```python= x = F.max_pool2d(x, 2) ``` ### 3. torch.nn.functional.log_softmax 這個函數會先執行Softmax,之後再取log,使用時需要透過第二個參數來指定他要對哪個維度的資料做Softmax運算。對應到上面的程式我們可以知道x會是一個形狀為$(B, 10)$的張量,為了針對第二維度做運算,因此指定`dim`為$1$(程式中使用$0$代表第一個編號)。 ```python= # 因為x = self.fc2(x),而self.fc2輸出維度為10,因此上述說明才說張量x的形狀為(B, 10)。 output = F.log_softmax(x, dim=1) ``` ## (六)torch.nn 筆記中只講解PyTorch官方手寫數字辨識程式中使用到的函數,該程式碼中只使用到三種`torch.nn`類型函數,因此此部分只介紹這三種,若有其他特殊需求再到[官方文件](https://pytorch.org/docs/stable/nn.html)查看詳細內容。 ### 1. torch.nn.Linear 以下程式中`Linear`的輸入、輸出維度分別為3、4,藉由Broadcast的特性我們可以使用一個$2\times3$的張量作為輸入。這時他會針對每個$1\times3$的張量做運算,得到$2\times4$的輸出。 :::info `nn.Linear()`輸入張量的形狀為$(B, in\_features)$,因此下列輸入維度為$3$的例子中,輸入張量的形狀就是$(B, 3)$。 ::: ```python= d_in = 3 d_out = 4 linear_module = nn.Linear(d_in, d_out) example_tensor = torch.tensor([[1.,2,3], [4,5,6]]) # applys a linear transformation to the data transformed = linear_module(example_tensor) print('example_tensor', example_tensor.shape) print('transormed', transformed.shape) print() print('We can see that the weights exist in the background\n') print('W:', linear_module.weight) print('b:', linear_module.bias) ``` 輸出結果: ```python= example_tensor torch.Size([2, 3]) transormed torch.Size([2, 4]) We can see that the weights exist in the background W: Parameter containing: tensor([[ 0.5260, 0.4925, -0.0887], [ 0.3944, 0.4080, 0.2182], [-0.1409, 0.0518, 0.3034], [ 0.0913, 0.2452, -0.2616]], requires_grad=True) b: Parameter containing: tensor([0.5021, 0.0118, 0.1383, 0.4757], requires_grad=True) ``` ### 2. torch.nn.Conv2d PyTorch中的卷積運算規定輸入張量的形狀為:$(B, Channels, H, W)$,因此如果下列程式中的捲積層有16個輸入通道,那麼輸入張量在第二個方向就要有$16$維的資料。由於輸入張量的批量大小為$20$,因此輸出張量的批次大小也會是$20$。 ```python= # 建立一個輸入通道數量為3,輸出通道數量為10,Kernel大小為3乘3的卷積層。 conv = nn.Conv2d(3, 10, 3, stride=1) # 這行程式是要建立一個符合Conv2d運算的張量, # 根據規定我們可以知道這是一個批量大小為20,通道數量為3,大小為100*50的張量。 x = torch.randn(20, 3, 50, 100) output = conv(x) ``` ### 3. torch.nn.Dropout Dropout層為深度學習中避免過度配適的一種方法,他會依固定機率將神經元暫時移除。下列程式中即為一個Dropout rate為25%的Dropout層,他會以25%的機率將張量x的資料清為0。 ```python= self.dropout1 = nn.Dropout(0.25) x = self.dropout1(x) ``` ## 補充:Sequential Model 如我們要將多個Layers組合成一個大Model,那就可以使用PyTorch提供的Sequential Model。**使用這個方法時,他會自動幫我們把給定的Layers疊起來**。我們就不需要再自己一一宣告所有要用的Layer物件,再手動把他們堆疊起來。 ```python= d_in = 3 d_hidden = 4 d_out = 1 model = torch.nn.Sequential( nn.Linear(d_in, d_hidden), nn.Tanh(), nn.Linear(d_hidden, d_out), nn.Sigmoid() ) example_tensor = torch.tensor([[1.,2,3],[4,5,6]]) transformed = model(example_tensor) print('transformed', transformed.shape) ``` 輸出結果: 由於我們輸入的是$2\times3$的張量,因此經過$3\times4\times1$的網路就會得到$2\times1$的輸出結果。 ```python= transformed torch.Size([2, 1]) ``` ## 補充:3維張量相乘: `torch.matmul()` 在實作時除了使用常見的全連接層、卷積層以外,有時候我們還會需要使用一般的矩陣相乘。由於我們訓練時都是一次訓練整個批次的資料,因此原本的矩陣相乘會變成高維度張量的相乘,這時候就會需要用到`torch.matmul()`。 ```python= # 若兩個張量都是一般的矩陣,那matmul()就會變一般矩陣相乘。 a = torch.arange(4*5).reshape(4, 5) b = torch.arange(5*8).reshape(5, 8) c = torch.matmul(a, b) print(c.shape) # 若其中一個張量為長方體,另一個為矩形,則matmul()會將長方體的第一個方向做為batch_size運算。 batch_size = 32 a = torch.arange(batch_size, 4*5).reshape(batch_size, 4, 5) b = torch.arange(5*8).reshape(5, 8) c = torch.matmul(a, b) print(c.shape) batch_size = 32 a = torch.arange(batch_size, 4*5).reshape(batch_size, 5, 4) b = torch.arange(5*8).reshape(8, 5) c = torch.matmul(b, a) print(c.shape) ``` 輸出結果: ```python= torch.Size([4, 8]) torch.Size([32, 4, 8]) torch.Size([32, 8, 4]) ``` ## 補充:torch.nn.init 使用`torch.nn`提供的函數時,他會自動幫我們將權重初始化為亂數值,假如我們想要將他初始化為其他數值,那可以透過[torch.nn.init](https://pytorch.org/docs/stable/nn.html#torch-nn-init)來設定常見的初始值。 ```python= import torch.nn as nn fc = nn.Linear(100, 200) # 使用nn.init中提供的函數可以用來將張量重新初始化。 # 此例子示範將已創建的全連接層fc的Weight以Xavier uniform的方式初始化。 nn.init.xavier_uniform_(fc.weight) ``` ## 補充:只更新部分Layers的權重 很多時候我們都會需要針對部分權重做更新,例如: - 使用預訓練模型時,我們會希望不要變動該部分權重,只修改額外加入Layers的權重。 - 訓練GAN或Reinforcement Learning時,我們會將模型分成兩部分,一次只變動一部份的模型。 PyTorch提供三種常見的Freezing方式: 1. `tensor.requires_grad = False`: 一個一個修改不想變動的張量,透過這種方式可以讓梯度運算從他身上流過,只是`tensor.grad`會變`None`而已。 2. `tensor.detach()`: 使用這個方法會回傳一個`requires_grad`等於`False`,且資料與原本相同的張量。由於他已經跟原本的計算圖脫節了,所以之後在做反向傳播時就不會影響到在這之前的張量的`grad`<!-- 只是這個張量會阻絕梯度的流動,導致他前半部的張量的`grad`也變`None` -->。 :::warning 使用`tensot.detach()`得到的張量其實會跟本來的張量共用記憶體位址,因此要小心不要改到資料。 ::: 3. `with torch.no_grad()`: 在這個區段中的所有運算都不會產生計算圖,因此就算對這個區塊的最後一個張量使用`backward()`方法,自然也沒辦法更新這個區塊的張量。(這種情況只是不會產生計算圖,但其實`requires_grad`屬性還是`True`) <!-- 因此在這之前的張量也都會無法計算梯度(`tensor.grad = None`) --> :::info 在官方的DCGAN教學中,Generator產生的影像叫做`fake`,他直接使用`fake.detach()`做為Discriminator的輸入。這樣他可以不用重新使用兩次Generator,同樣的結果可以拿來計算Generator的Loss,也可以透過`.detach()`的方式複製出一份相同的資料。並且因為複製出來的張量把梯度流阻隔掉了,因此使用`discriminator(fake.detach()).backward()`時,不會變動到Generator中張量的`.grad`。 ::: :::danger 前面說過張量預設`requires_grad = False`,不過這個僅限我們直接創建出來的張量;如果使用這邊提到的`torch.nn`創建神經網路層,這些神經網路層其實也會對應到幾個張量,但是這些張量預設`requires_grad`為`True`。假如我們希望將這些Layer改成不計算梯度,那麼可以使用下列程式的方法: 1. 可以直接存取各層模型時:下列例子中的Conv2d物件本身沒有`requires_grad`成員,我們要去修改他對應到的張量。 ```python= self.conv = torch.nn.Conv2d(4096, 2, 1) self.conv.weight.requires_grad = False ``` 2. 如果模型已經被打包成class,需要指定內部Layers時:這個例子中,已經將一堆模型打包到一個類別中,但如果我們希望只將`resnet50`設定為不計算梯度,這時可以用下列程式中的方法: ```python= class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.resnet50 = models.resnet50(pretrained=True) self.resnet50 = torch.nn.Sequential(*list(self.resnet50.children())[:-2]) self.selfAttention = selfAttention(2048, 104) self.conv = torch.nn.Conv2d(4096, 2, 1) self.softmax = torch.nn.Softmax(dim=1) if __name__ == "__main__": discriminator = Discriminator() ''' named_parameters()會回傳各個神經網路層的名稱、其對應的張量。 各個權重的命名方式與我們定義的神經網路層名字有關, 因此可以透過判斷"resnet50"是否為其名字的子字串來判斷張量是否屬於resnet50。 ''' for name, p in discriminator.named_parameters(): if "resnet50" in name: p.requires_grad = False ``` ::: [錯誤處理1](http://www.yongfengli.tk/2018/04/13/inplace-operation-in-pytorch.html) [錯誤處理2](https://discuss.pytorch.org/t/encounter-the-runtimeerror-one-of-the-variables-needed-for-gradient-computation-has-been-modified-by-an-inplace-operation/836) # 三、 設定超參數相關 如同筆記一開始介紹的,訓練深度學習模型的過程中涉及非常多超參數的選擇,其中包含損失函數的類別、最佳化演算法的選擇,以及學習率的選擇等,以下分別介紹PyTorch中如何使用Loss Function、如何選擇我們要使用的最佳化演算法。 ## (一)評估模型好壞: Loss Function 我們知道在深度學習、機器學習中,如果想要知道模型的好壞,可以透過Loss Function來得到目前模型的Loss,進而評估好壞。而PyTorch中也提供了許多種常見的Loss Function,這些Loss被定義在`torch.nn`中。PyTorch中我們可以透過以下兩種方法計算Loss: 1. 直接呼叫`torch.nn.functional`底下的函數來計算Loss。 2. 透過`torch.nn`底下的類別來定義各類別的物件,之後再呼叫定義出來的物件計算Loss。 ```python= # 先創建一個代表MSE Loss的物件 mse_loss_fn = nn.MSELoss() pred = torch.tensor([0., 0, 0]) target = torch.tensor([1., 0, -1]) # 透過創建出來的物件,得到兩個張量之間的Loss loss = mse_loss_fn(pred, target) print(loss) ``` 輸出結果: ```python= tensor(0.6667) ``` ## (二)透過最佳化演算法更新權重: Optimizer 在機器學習中,我們得到各個權重的梯度值之後,就可以使用各式更新方法來更新每個權重,例如我們可以選擇最基本的SGD。PyTorch的Autograd套件會協助我們計算梯度,因此其實我們是有能力自行實現最佳化演算法的。不過如果我們的網路很大的話,這樣做會需要耗費很多時間,且非常可能會出錯,因此PyTorch提供了`torch.optim`套件。這個套件包含了各種更新權重的演算法,可以讓我們在得到梯度之後,僅用一個Function一次更新所有權重。其中有一點要非常注意,在<font color="red">PyTorch中各個權重的梯度值不會在更新完權重後自動歸零,因此每次執行反向傳播時要先使用`optimizer.zero_grad()`手動將梯度歸零</font>,否則梯度值將會持續累加。 :::success 前面說過PyTorch中,我們會先告訴他要負責那些張量的更新。在張量使用`.backward()`方法之後,計算圖連接的那些張量的梯度都會被計算出來存到那些張量的`.grad`中,這時只要使用`optimizer.step()`,他就會使用一開始指定的演算法來更新他負責的權重。 ::: ```python= # 先建立模型物件。 model = Net().to("cuda") # 建立Optimizer物件,其中第一個參數是用來跟他講他負責哪些權重,第二個則用來指定學習率。 optimizer = optim.Adam(model.parameters(), lr=0.001) # 將各個權重的梯度歸零之後才可以再繼續計算新的梯度,因為PyTorch預設梯度累加,不會自動歸零。 optimizer.zero_grad() ''' 正向傳播、反向傳播計算各個權重梯度的過程。 ''' # 取得各個權重的梯度後使用此方法即可更新所有權重。 optimizer.step() ``` ### 補充:對不同權重使用不同Optimizer參數 下列範例程式中我們使用SGD作為`model.base`、`model.classifier`的Optimizer,為了給他們不同的Optimizer超參數,我們需要使用這種方法。這種方法將Optimizer的第一個參數改成存放字典的串列物件,裡面每個字典一定會有一個叫做`params`的key,他會對應到一組權重,然後我們可以針對各組不同權重指定不同參數。以下面的例子來說,由於第一組權重沒有指定Learning Rate,因此使用預設值`lr=1e-2`;而第二組則使用他指定的Learning Rate,然後因為兩組權重都沒有指定`momentum`,因此採用預設值0.9。 ```python= optim.SGD([{'params': model.base.parameters()}, {'params': model.classifier.parameters(), 'lr': 1e-3}], lr=1e-2, momentum=0.9) ``` # 四、 訓練模型 ## (一)指定模型工作狀態 深度學習模型中,有些Layer在training, evaluation時會有不同工作方式,例如Dropout, Batch-Normalization。因此我們在實作時,應該要<font color="red">確保程式工作在正確的狀態</font>。PyTorch中提供了兩個函數來讓我們指定模型的工作狀態: - `model.train()`: 指定模型工作在訓練模式,我們應該在`train()`之類的函數的一開始使用這個函數。 - `model.eval()`: 指定模型工作在測試、驗證模式,我們應該在`test()`等函數的一開始呼叫這個函數。 除此之外,PyTorch也有提供一個檢查模型工作模式的函數: - `model.training()`: 可以讓我確認模型目前的工作狀態,如果是`True`就代表目前工作於訓練模式。 :::info 訓練、測試有不同工作模式的函數: Dropout在Testing時會使用所有權重,因此會將全種值縮小。Batch-Normalization則是只有在訓練時會參考該批次的所有資料來計算平均值、變異數,因此他在訓練、測試時會有不同運作方式。 ::: ## (二)完整PyTorch訓練流程 ```python= # 步驟1:建構Dataset物件 dataset = datasets.MNIST('../data', train=True, download=True, transform=transform) # 步驟2:建構DataLoader物件 train_loader = torch.utils.data.DataLoader(dataset, batch_size=128, shuffle=False) # 步驟3:建構模型物件 model = Net().to(device) # 步驟4:建構Optimizer物件 optimizer = optim.Adam(model.parameters(), lr=0.001) # 步驟5:將模型設定為訓練模式(因為有些網路層在訓練、測試有不同工作行為) model.train() # 步驟6:透過DataLoader批次取得訓練資料,逐步使用小批次資料更新模型。 for batch_idx, (data, target) in enumerate(train_loader): # 步驟6.1:使用的裝置要統一,將資料搬移到我們使用的裝置中。 data, target = data.to(device), target.to(device) # 步驟6.2:將各個權重的梯度值歸零。 optimizer.zero_grad() # 步驟6.3:執行正向傳播,過程中會建構出計算圖。 output = model(data) # 步驟6.4:計算Loss,算出來的結果將會是計算圖的最後節點。 loss = F.cross_entropy(output, target) # 步驟6.5:執行反向傳播得到所有權重的梯度值。 loss.backward() # 步驟6.6:更新權重。 optimizer.step() ``` <!-- 使用方式如下列程式: - **Line 9**: `torch.optim`中的各種更新演算法都是一個類別,我們要使用他們時,需先創建屬於該類別的物件。宣告物件時須設定相關的參數,並且<font color="blue">跟他講他需要負責那些張量的更新</font>。要注意的是:==如果我們有要將模型丟到GPU去跑,那麼要先把模型移到GPU之後再使用`model.parameter()`,因為在CPU跟GPU得到的結果不同==。 - **Line 15**: 接著要注意的是,<font color="red">PyTorch中不會自動將梯度值歸零</font>,而會在每次反向傳播後將過去的梯度累加。因此我們在反向傳播前,應該使用`optim.zero_grad()`來將梯度值歸零。 - **Line 17**: 執行完反向傳播之後,可以直接使用`optim.step`來更新所有權重,不再需要使用各個張量的`.grad`。 ```python= # create a simple model model = nn.Linear(1, 1) # create a simple dataset X_simple = torch.tensor([[1.]]) y_simple = torch.tensor([[2.]]) # create our optimizer optim = torch.optim.SGD(model.parameters(), lr=1e-2) mse_loss_fn = nn.MSELoss() y_hat = model(X_simple) print('model params before:', model.weight) loss = mse_loss_fn(y_hat, y_simple) optim.zero_grad() loss.backward() optim.step() print('model params after:', model.weight) ``` Output: ```python= model params before: Parameter containing: tensor([[-0.9604]], requires_grad=True) model params after: Parameter containing: tensor([[-0.9060]], requires_grad=True) ``` --> ## (三)將創建好的模型搬到GPU上 假如我們的程式有使用GPU來加速的話,除了要將訓練資料的張量搬至GPU外,也<font color="red">需要將模型搬到GPU</font>。因為模型裡面包含了很多的權重,而這些權重其實也都是張量,假如我們希望使用這些張量跟訓練資料做運算的話,就必須將模型也搬到GPU上。 ```python= model = MNIST().to(device) ``` ## (四)儲存、載入模型 模型的保存分為兩種方式:一種是<font color="blue">保存完整的模型</font>,連架構都會存起來,因此之後載入時不需先創建相同模型;另外一種則是<font color="blue">只保存模型中的參數</font>,因此之後在載入前,要先創建一個相同的架構才能載入。 ### 1. 保存和載入完整模型 #### 保存模型 這邊的`model`代表的是模型物件,而`PATH`則代表我們要儲存的路徑+檔名。 ```python= torch.save(model, PATH) ``` #### 載入模型 ```python= # Model class must be defined somewhere model = torch.load(PATH) ``` :::info 儲存的模型副檔名通常為`.pt`或`.pth`。 ::: :::warning 這種類型的方法雖然可以讓我們不用事先創建相同架構的模型物件,但是我們還是得在程式中的某個地方定義這個模型類別。 ::: ### 2. 只保存和載入權重、參數 這種是採用`state_dict`的方式來實現的,官方比較<font color="red">推薦</font>使用這種方式。 #### 儲存模型 由於我們只要儲存模型中的權重,因此我們使用的參數不是`model`,而是`model.state_dict()`,代表我們要將模型的`state_dict`存起來。另外,其實Optimizer裡面也有`state_dict`,他也可以用相同方式來儲存。如果我們希望同時儲存Model跟Optimizer的參數,可以使用下面的方式: ```python= checkpoint = { "Model": model.state_dict(), "Optimizer": optimizer.state_dict() } torch.save(checkpoint, PATH) ``` #### 載入模型 如果只要載入模型的權重的話,就要在一開始先創建相同的模型,之後就可以使用模型類別的`load_state_dict()`方法來把目前的`state_dict`取代為我們指定的`state_dict`。由於我們指定的是`state_dict`物件,因此仍然需要透過`torch.load()`來將該物件讀取進來。假如我們儲存的資料包含模型、Optimizer的參數,則可以用以下的方式載入: ```python= # 一開始要先建構模型 model = Net() # 這邊的載入是說:把我們建構出來的模型的權重換掉 model.load_state_dict(torch.load(PATH)["Model"]) optim = torch.optim.Adam(model.parameters(), lr=1e-3) optim.load_state_dict(torch.load(PATH)["Optimizer"]) ``` :::info 所謂的`state_dict`其實就是Python中的一種字典物件,PyTorch中使用這種字典物件來儲存各層的權重,他的key就是網路層的名稱,而value是該層對應到的權重張量。我們可以透過`model.state_dict()`的方式來取得模型的`state_dict`。 ::: ## 補充:調整學習率: Learning Rate Schedulers 我們在訓練的過程中,很有可能會需要根據執行的時間來調整Learning Rate,而PyTorch也有提供這類型的套件給我們使用。 ## 補充:反向傳播後保留動態圖: `retain_graph` <font color="blue">PyTorch預設會在每次反向傳播結束後,將整個計算圖釋放掉,等到下次正向傳播時再產生一次計算圖</font>。這個做法在一般的情況通常都可以正常運作,但如果我們因為一些需求,導致一次正向傳播要對應多次反向傳播,這時就必須在使用`tensor.backward()`時加入`retain_graph`作為參數。 <!-- ### 範例(待補充) --> ## 補充:使用多個GPU 如果我們要使用多個GPU來訓練神經網路,可以使用如下的方式,透過`nn.DataParallel`一次使用多個指定的GPU。假如我們不希望使用所有可用的GPU,那麼可以透過`os.environ["CUDA_VISIBLE_DEVICES"]`指定程式一共可以看到哪些GPU,當我們使用多個GPU來跑程式時,這邊的第一個GPU會預設為主要的設備,由他將工作分配給其他人,因此他的資源使用量會比較大。但如果我們不使用這邊的第一個設備,就要透過`torch.cuda.set_device(1)`來把主要設備改成第二個設備,要注意的是:這個主要設備一定要是`device_ids`的第一個設備,否則將會報錯。 ```python= # 只使用cuda:1, cuda:2, cuda:3, cuda:4。 os.environ["CUDA_VISIBLE_DEVICES"] = "1, 2, 3, 4" # 不使用cuda:1,因此改將主要設備改成cuda:2。 torch.cuda.set_device(1) # 從這邊可以看出我們確實沒使用cuda:1(cuda:1對應到這邊的0號設備),而用了cuda:2, cuda:3, cuda:4。 self.model = nn.DataParallel(self.model, device_ids=[1, 2]) # 最後將模型丟到這些設備上。 self.model.to(self.device) ``` ## 補充:取得Model中的權重 只要我們的網路是使用`nn.Module`組合出來的,那就可以使用`parameter()`方法來查看網路中的權重。我們取得的權重會是一層一層表示的,因此可以透過for迴圈來看第一層到最後一層的所有權重。 ```python= params = model.parameters() for param in params: print(param) ``` 輸出結果: ```python= Parameter containing: tensor([[-0.5607, 0.4221, -0.0254], [-0.3630, 0.4541, 0.0275], [-0.0703, -0.1463, 0.3065], [ 0.0065, -0.2664, 0.0267]], requires_grad=True) Parameter containing: tensor([-0.3196, 0.2911, 0.1999, -0.3758], requires_grad=True) Parameter containing: tensor([[-0.0289, 0.1544, 0.3992, -0.3301]], requires_grad=True) Parameter containing: tensor([-0.1438], requires_grad=True) ``` # 模型的量化 神經網路中有非常多的權重,這些權重的資料型態通常都是32位元的浮點數,非常佔記憶體空間。如果希望將模型部屬到手機等低算力的裝置,通常需要將模型中的權重以更少位元表示,這個過程就叫做量化。PyTorch中提供了幾種不同類型的量化方式,最常見的方式為使用`float32`訓練,並在訓練完之後將所有權重量化為`int8`。 # 在Pytorch中使用hook功能 > 這個章節大部分的內容參考於[此連結](https://zhuanlan.zhihu.com/p/279903361),程式碼都是從那邊複製的。 有時候我們需要讓模型輸出某一層的結果,如果使用以前的作法需要去修改模型的Class,在`__forward__()`中把該層的輸出拉出來回傳。這樣的作法可以在我們需要永久修改模型的時候使用,不過如果我們只是想要暫時做這件事情,使用以前的方法的話我們可能會忘記把Class改回來,這就會造成模型的使用出現問題。事實上,PyTorch中提供了hook的方法,讓我們可以在模型進行正向或反向傳播時執行我們指定的功能,使用這個方法就可以把某一層的輸出結果存起來。 :::info Hook其實還有許多不同的功能,例如: 1. 可以指定模型在做完反向傳播時,調整算出來的梯度值,使用這個方法就可以達到梯度修剪的作用。 2. 可以指定模型在做正向傳播的同時,每執完各層的運算就把各層的輸出張量形狀print,這樣就可以讓我們方便查看每一層輸出結果的形狀。 ::: ## torch.Tensor.register_hook <font color="blue">用於反向傳播的hook</font>。在一開始將張量註冊使用這個hook的時候,需要指定一個以下形式,也就是以`grad`做為參數的function object。當註冊過這個hook的張量的`grad`被更新,就會使用更新完的`grad`呼叫這個function object。 ``` hook(grad) -> Tensor or None ``` 以下是PyTorch官方提供的範例,可以看到<font color="orangered">最後還需要將hook移除</font>。 ``` >>> v = torch.tensor([0., 0., 0.], requires_grad=True) >>> h = v.register_hook(lambda grad: grad * 2) # double the gradient >>> v.backward(torch.tensor([1., 2., 3.])) >>> v.grad 2 4 6 [torch.FloatTensor of size (3,)] >>> h.remove() # removes the hook ``` ### 使用hook實現梯度剪裁 首先定義一個以`model`跟梯度邊界值做為參數的函數,之後在該函數中將模型中的各個神經網路層使用`register_hook()`註冊。註冊的同時使用lambda function作為傳入的參數,這個函數使用`grad`做為參數,他會限制參數`grad`的數值大小。 ```python= def gradient_clipper(model: nn.Module, val: float) -> nn.Module: for parameter in model.parameters(): parameter.register_hook(lambda grad: grad.clamp_(-val, val)) return model ``` 使用的一開始先呼叫前面定義的函數`gradient_clipper()`,他會回傳一個註冊過hook的模型`clipped_resnet`,之後使用這個模型做反向傳播時,就會將模型的梯度限制在$0.01$、$-0.01$之間。 ```python= clipped_resnet = gradient_clipper(resnet50(), 0.01) pred = clipped_resnet(dummy_input) loss = pred.log().mean() loss.backward() print(clipped_resnet.fc.bias.grad[:25]) ``` 以下為輸出結果,可以發現所有梯度值都不超過我們限制的範圍。 ```python= # tensor([-0.0010, -0.0047, -0.0010, -0.0009, -0.0015, 0.0027, 0.0017, -0.0023, # 0.0051, -0.0007, -0.0057, -0.0010, -0.0039, -0.0100, -0.0018, 0.0062, # 0.0034, -0.0010, 0.0052, 0.0021, 0.0010, 0.0017, -0.0100, 0.0021, # 0.0020]) ``` ## torch.nn.modules.module.register_module_forward_hook <font color="blue">用於正向傳播的hook</font>。他的使用方式跟`torch.Tensor.register_hook`差不多,只是它變成是在正向傳播完會觸發,而且他所需要的function object也不一樣了,變成下面這樣,可以發現他一共需要三個傳入的參數。 ``` hook(module, input, output) -> None or modified output ``` ### 使用hook實現取得指定神經網路層的輸出 首先定義`FeatureExtractor`類別,建構式使用`model`跟想要取得的神經網路層的名稱為為參數,並且在裡面使用`layers`將指定的神經網路層註冊hook。註冊時使用`self.save_outputs_hook(layer_id)`作為傳入的參數,在這個function object中我們會在hook被觸發時,將該神經網路層的輸出結果以字典的方式記錄在`self._features`變數中。由於這邊定義`forward()`回傳`self._features`,因此之後我們就可以使用這個class創建的物件作為新的模型。 ```python= from typing import Dict, Iterable, Callable class FeatureExtractor(nn.Module): def __init__(self, model: nn.Module, layers: Iterable[str]): super().__init__() self.model = model self.layers = layers self._features = {layer: torch.empty(0) for layer in layers} for layer_id in layers: layer = dict([*self.model.named_modules()])[layer_id] layer.register_forward_hook(self.save_outputs_hook(layer_id)) def save_outputs_hook(self, layer_id: str) -> Callable: def fn(_, __, output): self._features[layer_id] = output return fn def forward(self, x: Tensor) -> Dict[str, Tensor]: _ = self.model(x) return self._features ``` 使用時,先使用剛剛定義的類別創建模型物件,之後使用這個物件來取得我們指定的神經網路層的輸出結果。 ```python= resnet_features = FeatureExtractor(resnet50(), layers=["layer4", "avgpool"]) features = resnet_features(dummy_input) print({name: output.shape for name, output in features.items()}) # {'layer4': torch.Size([10, 2048, 7, 7]), 'avgpool': torch.Size([10, 2048, 1, 1])} ```