十四、神經網路工具箱nn

鹤比纷恆红發表於2024-10-29

  使用autograd可實現深度學習模型,但其抽象程度較低,如果用其來實現深度學習模型,則需要編寫的程式碼量極大。在這種情況下,torch.nn應運而生,其是專門為深度學習而設計的模組。torch.nn的核心資料結構是Module,它是一個抽象概念,既可以表示神經網路中的某個層(layer),也可以表示一個包含很多層的神經網路。在實際使用中,最常見的做法是繼承nn.Module,撰寫自己的網路/層。

  下面先來看看如何用nn.Module實現自己的全連線層。全連線層,又名仿射層,輸出y和輸入x滿足y=Wx+b,W和b是可學習的引數。

import torch as t
from torch import nn

# 定義一個繼承自nn.Module的線性層類Linear
class Linear(nn.Module):
    # 初始化函式,定義輸入和輸出特徵維度
    def __init__(self, in_features, out_features):
        # 呼叫父類nn.Module的初始化方法
        super(Linear, self).__init__()
        # 建立可學習的權重引數w,形狀為[in_features, out_features]
        self.w = nn.Parameter(t.randn(in_features, out_features))
        # 建立可學習的偏置引數b,形狀為[out_features]
        self.b = nn.Parameter(t.randn(out_features))

    # 定義前向傳播函式
    def forward(self, x):
        # 使用矩陣乘法計算輸入x和權重w的乘積,輸出形狀為[batch_size, out_features]
        x = x.mm(self.w)
        # 將偏置b擴充套件到與x相同的形狀,並將其加到結果上
        return x + self.b.expand_as(x)

# 例項化Linear類,指定輸入特徵維度為4,輸出特徵維度為3
layer = Linear(4, 3)
# 建立一個形狀為[2, 4]的隨機輸入張量,代表2個樣本,每個樣本有4個特徵
input = t.randn(2, 4)
# 將輸入張量傳入線性層,得到輸出結果
output = layer(input)
print(output)

# 遍歷並列印線性層中的引數名稱和對應的張量
for name, parameter in layer.named_parameters():
    print(name, parameter)
    
tensor([[ 1.3758, -0.3045, -2.7855],
        [ 2.0449,  2.1037, -3.9691]], grad_fn=<AddBackward0>)
w Parameter containing:
tensor([[-0.7288,  0.9462,  0.1709],
        [-0.5582, -0.1212,  1.1237],
        [-0.4097, -0.2673, -0.9135],
        [-0.0122,  2.1431, -0.0641]], requires_grad=True)
b Parameter containing:
tensor([ 0.9055, -0.0583, -1.6974], requires_grad=True)

全連線層的實現比較簡單,但需注意以下幾點:

  • 自定義層Linear必須繼承nn.Module,並且在其建構函式中需呼叫nn.Module的建構函式,即super(Linear, self).__init__()nn.Module.__init__(self),推薦使用第一種用法,儘管第二種寫法更直觀。
  • 在建構函式__init__中必須自己定義可學習的引數,並封裝成Parameter,如在本例中我們把wb封裝成parameterparameter是一種特殊的Tensor但其預設需要求導(requires_grad = True)
  • forward函式實現前向傳播過程,其輸入可以是一個或多個tensor。
  • 無需寫反向傳播函式,nn.Module能夠利用autograd自動實現反向傳播,這點比Function簡單許多。
  • 使用時,直觀上可將layer看成數學概念中的函式,呼叫layer(input)即可得到input對應的結果。它等價於layers.__call__(input),在__call__函式中,主要呼叫的是 layer.forward(x),另外還對鉤子做了一些處理。所以在實際使用中應儘量使用layer(x)而不是使用layer.forward(x)
  • Module中的可學習引數可以透過named_parameters()或者parameters()返回迭代器,前者會給每個parameter都附上名字,使其更具有辨識度。

  Module能夠自動檢測到自己的Parameter,並將其作為學習引數。除了parameter之外,Module還包含子Module主Module能夠遞迴查詢子Module中的parameter。下面再來看看稍微複雜一點的網路,多層感知機。

  層感知機的網路結構如下圖所示,它由兩個全連線層組成,採用sigmoid函式作為啟用函式,圖中沒有畫出。

    十四、神經網路工具箱nn

class Perceptron(nn.Module):
    def __init__(self, in_features, hidden_features, out_features):
        nn.Module.__init__(self)
        # 定義第一層,輸入特徵數為 in_features,隱藏層特徵數為 hidden_features
        self.layer1 = Linear(in_features, hidden_features)
        # 定義第二層,輸入特徵數為 hidden_features,輸出特徵數為 out_features
        self.layer2 = Linear(hidden_features, out_features)

    # 定義前向傳播函式 forward
    def forward(self, x):
        # 首先將輸入 x 傳入第一層,得到隱層輸出
        x = self.layer1(x)
        # 對隱層輸出應用 sigmoid 啟用函式
        x = t.sigmoid(x)
        # 將啟用後的輸出傳入第二層,得到最終輸出
        return self.layer2(x)

# 建立感知器例項,輸入特徵數為3,隱藏層特徵數為4,輸出特徵數為1
Perceptron = Perceptron(3, 4, 1)

# 列印感知器中的引數名稱和對應的值
for name, param in Perceptron.named_parameters():
    print(name, param)
    
layer1.w Parameter containing:
tensor([[-0.0791, -1.0982,  1.1377,  1.1678],
        [ 0.3987, -1.3077, -1.0768,  0.1234],
        [ 0.3717, -1.4077, -0.2922,  0.5084]], requires_grad=True)
layer1.b Parameter containing:
tensor([0.3347, 0.5307, 1.2622, 1.9496], requires_grad=True)
layer2.w Parameter containing:
tensor([[ 0.5427],
        [-0.9131],
        [ 0.0052],
        [-1.5111]], requires_grad=True)
layer2.b Parameter containing:
tensor([-0.9579], requires_grad=True)

  可見,即使是稍複雜的多層感知機,其實現依舊很簡單。 建構函式__init__中,可利用前面自定義的Linear層(module),作為當前module物件的一個子module,它的可學習引數,也會成為當前module的可學習引數。

module中parameter的命名規範:

  • 對於類似self.param_name = nn.Parameter(t.randn(3, 4)),命名為param_name
  • 對於子Module中的parameter,會其名字之前加上當前Module的名字。如對於self.sub_module = SubModel(),SubModel中有個parameter的名字叫做param_name,那麼二者拼接而成的parameter name 就是sub_module.param_name

在查閱函式使用文件時,要關注以下幾點:

  • 建構函式的引數,如nn.Linear(in_features, out_features, bias),需關注這三個引數的作用。
  • 屬性、可學習引數和子module。如nn.Linear中有weightbias兩個可學習引數,不包含子module。
  • 輸入輸出的形狀,如nn.linear的輸入形狀是(N, input_features),輸出為(N,output_features),N是batch_size。

  這些自定義layer對輸入形狀都有假設:輸入的不是單個資料,而是一個batch。輸入只有一個資料,則必須呼叫tensor.unsqueeze(0)tensor[None]將資料偽裝成batch_size=1的batch。

1.1 常用神經網路層

1.1.1 影像相關層

  影像相關層主要包括卷積層(Conv)、池化層(Pool)等,這些層在實際使用中可分為一維(1D)、二維(2D)、三維(3D),池化方式又分為平均池化(AvgPool)、最大值池化(MaxPool)、自適應池化(AdaptiveAvgPool)等。而卷積層除了常用的前向卷積之外,還有逆卷積(TransposeConv)。下面舉例說明一些基礎的使用。

from PIL import Image  # Python Imaging Library,用於影像處理
from torchvision.transforms import ToTensor, ToPILImage  # 轉換工具,從影像到張量,和從張量到影像的轉換
import torch as t
from torch import nn  # 引入PyTorch的神經網路模組

# 建立影像到張量的轉換器
to_tensor = ToTensor()  # 將影像轉換為張量,範圍為[0, 1]
# 建立張量到影像的轉換器
to_pil = ToPILImage()  # 將張量轉換回影像
# 開啟一幅名為 'lena.png' 的影像
lena = Image.open('imgs/lena.png')

十四、神經網路工具箱nn

# 將影像轉換為張量,並增加一個維度以形成一個批次,batch_size = 1
input = to_tensor(lena).unsqueeze(0)  # input 形狀為 [1, C, H, W],C 為通道數,H 為高度,W 為寬度
# 定義銳化卷積核,卷積核用於增強影像的邊緣
# 建立一個 3x3 的張量,初始值為 -1/9,其他值為 1
kernel = t.ones(3, 3) / -9.0  # 所有元素為 -1/9
kernel[1][1] = 1  # 中間的元素設為 1,形成銳化效果
# 建立一個卷積層,輸入通道數和輸出通道數都為 1,卷積核的大小為 (3, 3),步幅為 1
conv = nn.Conv2d(1, 1, (3, 3), stride=1, bias=False)
# 將定義好的卷積核賦值給卷積層的權重
conv.weight.data = kernel.view(1, 1, 3, 3)  # kernel 需要調整形狀為 [out_channels, in_channels, height, width]
# 對輸入張量進行卷積運算,得到輸出
out = conv(input)
# 將卷積輸出張量轉換回影像,並去掉批次維度
# out.data.squeeze(0) 將輸出的形狀從 [1, 1, H, W] 轉換為 [1, H, W]
output_image = to_pil(out.data.squeeze(0))  # 轉換為 PIL 影像

十四、神經網路工具箱nn

  池化層可以看作是一種特殊的卷積層,用來下采樣。但池化層沒有可學習引數,其weight是固定的。

    十四、神經網路工具箱nn

除了卷積層和池化層,深度學習中還將常用到以下幾個層:

  • Linear:全連線層。
  • BatchNorm:批規範化層,分為1D、2D和3D。除了標準的BatchNorm之外,還有在風格遷移中常用到的InstanceNorm層。
  • Dropout:dropout層,用來防止過擬合,同樣分為1D、2D和3D。

  下面透過例子來說明它們的使用。

# 輸入 batch_size=2,維度3
input = t.randn(2, 3)
linear = nn.Linear(3, 4)
h = linear(input)
h

## tensor([[ 0.6993, -1.1460,  0.5710, -0.2496],
##         [-0.1921,  0.8154, -0.3038,  0.1873]])

# 4 channel,初始化標準差為4,均值為0
bn = nn.BatchNorm1d(4)
bn.weight.data = t.ones(4) * 4
bn.bias.data = t.zeros(4)

bn_out = bn(h)
# 注意輸出的均值和方差
# 方差是標準差的平方,計算無偏方差分母會減1
# 使用unbiased=False 分母不減1
bn_out.mean(0), bn_out.var(0, unbiased=False)

## (tensor(1.00000e-07 *
##         [ 1.1921,  0.0000,  0.0000,  0.0000]),
##  tensor([ 15.9992,  15.9998,  15.9992,  15.9966]))

# 每個元素以0.5的機率捨棄
dropout = nn.Dropout(0.5)
o = dropout(bn_out)
o # 有一半左右的數變為0

## tensor([[ 7.9998, -8.0000,  0.0000, -7.9992],
##         [-0.0000,  8.0000, -7.9998,  7.9992]])

1.1.2 啟用函式

  PyTorch實現了常見的啟用函式,其具體的介面資訊可參見官方文件,這些啟用函式可作為獨立的layer使用。這裡將介紹最常用的啟用函式ReLU,其數學表示式為:十四、神經網路工具箱nn

relu = nn.ReLU(inplace=True)
input = t.randn(2, 3)
print(input)
output = relu(input)
print(output) # 小於0的都被截斷為0
# 等價於input.clamp(min=0)

  ReLU函式有個inplace引數,如果設為True,它會把輸出直接覆蓋到輸入中,這樣可以節省記憶體/視訊記憶體。之所以可以覆蓋是因為在計算ReLU的反向傳播時,只需根據輸出就能夠推算出反向傳播的梯度。但是隻有少數的autograd操作支援inplace操作(如tensor.sigmoid_()),除非你明確地知道自己在做什麼,否則一般不要使用inplace操作

  在以上的例子中,基本上都是將每一層的輸出直接作為下一層的輸入,這種網路稱為前饋傳播網路(feedforward neural network)。對於此類網路如果每次都寫複雜的forward函式會有些麻煩,在此就有兩種簡化方式,ModuleList和Sequential。其中Sequential是一個特殊的module,它包含幾個子Module,前向傳播時會將輸入一層接一層的傳遞下去。ModuleList也是一個特殊的module,可以包含幾個子module,可以像用list一樣使用它,但不能直接把輸入傳給ModuleList。下面舉例說明。

import torch.nn as nn
from collections import OrderedDict

# net1 使用 nn.Sequential() 和 add_module 方法逐步新增模組
net1 = nn.Sequential()

# 新增摺積層 'conv'
# Conv2d 引數含義:輸入通道數(3),輸出通道數(3),卷積核大小(3x3)
net1.add_module('conv', nn.Conv2d(3, 3, 3))

# 新增批歸一化層 'batchnorm'
# BatchNorm2d 引數含義:需要歸一化的通道數為 3
net1.add_module('batchnorm', nn.BatchNorm2d(3))

# 新增啟用函式 ReLU 'activation_layer'
# ReLU 是一種常用的啟用函式,將負值置為 0,保持正值不變
net1.add_module('activation_layer', nn.ReLU())

# net2 使用 nn.Sequential 直接傳遞模組的順序列表
# 其中包含卷積層、批歸一化層和 ReLU 啟用函式
net2 = nn.Sequential(
    nn.Conv2d(3, 3, 3),       # 卷積層:輸入通道 3,輸出通道 3,卷積核 3x3
    nn.BatchNorm2d(3),        # 批歸一化:通道數為 3
    nn.ReLU()                 # ReLU 啟用函式
)

# net3 使用 OrderedDict 明確命名每個層,利用 nn.Sequential 構建模型
# OrderedDict 保證了字典中鍵值對的順序
net3 = nn.Sequential(OrderedDict([
    ('conv1', nn.Conv2d(3, 3, 3)),   # 卷積層:命名為 conv1,輸入通道 3,輸出通道 3,卷積核 3x3
    ('bn1', nn.BatchNorm2d(3)),      # 批歸一化層:命名為 bn1,通道數為 3
    ('relu1', nn.ReLU())             # ReLU 啟用函式:命名為 relu1
]))

# 列印出 net1, net2 和 net3 的結構
print('net1:', net1)
print('net2:', net2)
print('net3:', net3)

## 輸出結果
net1: Sequential(
  (conv): Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1))
  (batchnorm): BatchNorm2d(3, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (activation_layer): ReLU()
)
net2: Sequential(
  (0): Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1))
  (1): BatchNorm2d(3, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (2): ReLU()
)
net3: Sequential(
  (conv1): Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1))
  (bn1): BatchNorm2d(3, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (relu1): ReLU()
)

# 可根據名字或序號取出子module
net1.conv, net2[0], net3.conv1
(Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1)),
 Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1)),
 Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1)))
import torch as t
import torch.nn as nn

# 其中 1 是批次大小 (batch size),3 是通道數 (channels),4x4 是特徵圖大小 (height x width)
input = t.rand(1, 3, 4, 4)
output = net1(input)
output = net2(input)
output = net3(input)
output = net3.relu1(net1.batchnorm(net1.conv(input)))


# 建立一個 nn.ModuleList,其中包含兩個線性層和一個 ReLU 啟用函式
modellist = nn.ModuleList([nn.Linear(3, 4), nn.ReLU(), nn.Linear(4, 2)])
# 建立一個形狀為 (1, 3) 的隨機輸入張量
input = t.randn(1, 3)
# 透過遍歷 modellist,將 input 依次透過每個層,手動向前傳播
for model in modellist:
    input = model(input)   
# 下面會報錯,因為 modellist 沒有實現 forward 方法
output = modellist(input)

  需要注意的是,nn.ModuleList 是 PyTorch 中的一個容器,用來儲存子模組的列表。你可以將多個模型層按順序儲存在 ModuleList 中,但它 自動不會實現 forward() 方法,可以向上述程式碼一樣,手動遍歷 modellist 來進行前向傳播。

1.1.3 損失函式

  在深度學習中要用到各種各樣的損失函式(loss function),這些損失函式可看作是一種特殊的layer,PyTorch也將這些損失函式實現為nn.Module的子類。然而在實際使用中通常將這些loss function專門提取出來,和主模型互相獨立。下面以分類中最常用的交叉熵損失CrossEntropyloss為例說明。

import torch as t
import torch.nn as nn

# 建立一個形狀為 (3, 2) 的張量,表示 3 個樣本,每個樣本有 2 個類別的分數
score = t.randn(3, 2)  # 隨機生成的分數 (logits),每個樣本兩個類別
# 定義每個樣本的真實標籤
# 第一個樣本屬於類別 1,第二個樣本屬於類別 0,第三個樣本屬於類別 1
# 標籤必須是 LongTensor 型別,因為 PyTorch 的 CrossEntropyLoss 要求標籤型別為 long
label = t.Tensor([1, 0, 1]).long()
# 然後用真實的標籤與預測的機率進行對比,計算損失
criterion = nn.CrossEntropyLoss()
# 計算損失,傳入的引數是預測的分數 (logits) 和真實標籤
# 這裡 score 是模型的輸出 (logits),label 是真實標籤
loss = criterion(score, label)

1.2 最佳化器

  PyTorch將深度學習中常用的最佳化方法全部封裝在torch.optim中,其設計十分靈活,能夠很方便的擴充套件成自定義的最佳化方法。

  所有的最佳化方法都是繼承基類optim.Optimizer,並實現了自己的最佳化步驟。下面就以最基本的最佳化方法——隨機梯度下降法(SGD)舉例說明。這裡需重點掌握:

  • 最佳化方法的基本使用方法
  • 如何對模型的不同部分設定不同的學習率
  • 如何調整學習率
import torch.nn as nn

# 定義一個名為 Net 的神經網路類,繼承自 nn.Module
class Net(nn.Module):
    # 初始化方法,定義網路結構
    def __init__(self):
        super(Net, self).__init__()  # 呼叫父類 nn.Module 的建構函式
        
        # 定義特徵提取部分 (卷積層和池化層)
        self.features = nn.Sequential(
            nn.Conv2d(3, 6, 5),    # 第一個卷積層,輸入通道為3 (RGB影像),輸出通道為6,卷積核大小為5x5
            nn.ReLU(),             # ReLU 啟用函式
            nn.MaxPool2d(2, 2),    # 第一個最大池化層,池化視窗大小為2x2,步長為2
            nn.Conv2d(6, 16, 5),   # 第二個卷積層,輸入通道為6,輸出通道為16,卷積核大小為5x5
            nn.ReLU(),             # ReLU 啟用函式
            nn.MaxPool2d(2, 2)     # 第二個最大池化層,池化視窗大小為2x2,步長為2
        )
        
        # 定義分類器部分 (全連線層)
        self.classifier = nn.Sequential(
            nn.Linear(16 * 5 * 5, 120),  # 第一個全連線層,將16*5*5的張量轉為120維的向量
            nn.ReLU(),                   # ReLU 啟用函式
            nn.Linear(120, 84),          # 第二個全連線層,將120維向量轉為84維
            nn.ReLU(),                   # ReLU 啟用函式
            nn.Linear(84, 10)            # 第三個全連線層,將84維向量轉為10維(輸出為10個類別)
        )

    # 定義前向傳播函式,描述資料如何透過網路
    def forward(self, x):
        x = self.features(x)           # 輸入先經過特徵提取部分 (卷積 + 池化)
        
        # 將四維的特徵圖展平為二維張量,用於輸入全連線層
        # .view() 方法用於改變張量的形狀,-1 表示自動推斷這個維度的大小
        x = x.view(-1, 16 * 5 * 5)     # 將特徵圖展平為 (batch_size, 16*5*5) 的大小
        
        x = self.classifier(x)          # 然後輸入到分類器部分 (全連線層)
        return x                        # 返回分類結果

# 例項化一個 Net 類,建立網路
net = Net()
from torch import optim
import torch as t

# params=net.parameters() 將網路的所有引數傳遞給最佳化器
# lr=1 是學習率,控制每次引數更新的步長
optimizer = optim.SGD(params=net.parameters(), lr=1)

# 在每次進行反向傳播之前,需要將梯度清零
# PyTorch 中的梯度是累加的,因此在執行反向傳播之前呼叫 zero_grad() 清除上一次的梯度
optimizer.zero_grad()  # 等價於 net.zero_grad(),將網路中所有引數的梯度置為0

# 建立一個形狀為 (1, 3, 32, 32) 的隨機輸入張量,表示1個樣本,3個通道,大小為32x32的影像
input = t.randn(1, 3, 32, 32)

# 透過網路前向傳播,得到輸出
output = net(input)

# 進行反向傳播計算梯度
# output.backward(output) 是一種偽造的反向傳播,通常 output 是損失函式的值
output.backward(output)  
# loss = criterion(output, target)  # 計算損失
# loss.backward()  # 反向傳播計算梯度

# 執行最佳化步驟,更新網路引數
# optimizer.step() 會根據計算出的梯度和設定的學習率更新網路的引數
optimizer.step()
# 為不同子網路設定不同的學習率,在finetune中經常用到
# 如果對某個引數不指定學習率,就使用最外層的預設學習率
optimizer =optim.SGD([
                {'params': net.features.parameters()}, # 學習率為1e-5
                {'params': net.classifier.parameters(), 'lr': 1e-2}
            ], lr=1e-5)
optimizer

## 輸出結果
SGD (
Parameter Group 0
    dampening: 0
    lr: 1e-05
    momentum: 0
    nesterov: False
    weight_decay: 0

Parameter Group 1
    dampening: 0
    lr: 0.01
    momentum: 0
    nesterov: False
    weight_decay: 0
)
# 只為兩個全連線層設定較大的學習率,其餘層的學習率較小
special_layers = nn.ModuleList([net.classifier[0], net.classifier[3]])
# 用map獲取這兩個特定層的引數 ID,以便後續操作中進行區分
special_layers_params = list(map(id, special_layers.parameters()))
# 用filter過濾獲取模型中除特殊層外的其他層的引數
base_params = filter(lambda p: id(p) not in special_layers_params,
                     net.parameters())

optimizer = t.optim.SGD([
            {'params': base_params},
            {'params': special_layers.parameters(), 'lr': 0.01}
        ], lr=0.001 )
optimizer

## 輸出結果
SGD (
Parameter Group 0
    dampening: 0
    lr: 0.001
    momentum: 0
    nesterov: False
    weight_decay: 0

Parameter Group 1
    dampening: 0
    lr: 0.01
    momentum: 0
    nesterov: False
    weight_decay: 0
)

  對於如何調整學習率,主要有兩種做法。一種是修改optimizer.param_groups中對應的學習率,另一種是更簡單也是較為推薦的做法——新建最佳化器,由於optimizer十分輕量級,構建開銷很小,故而可以構建新的optimizer。但是後者對於使用動量的最佳化器(如Adam),會丟失動量等狀態資訊,可能會造成損失函式的收斂出現震盪等情況。

# 方法1: 調整學習率,新建一個optimizer
old_lr = 0.1
optimizer1 =optim.SGD([
                {'params': net.features.parameters()},
                {'params': net.classifier.parameters(), 'lr': old_lr*0.1}
            ], lr=1e-5)

# 方法2: 調整學習率, 手動decay, 儲存動量
for param_group in optimizer.param_groups:
    param_group['lr'] *= 0.1 # 學習率為之前的0.1倍
    
# 假設你想將第二組引數(即 special_layers 的引數)的學習率調整為之前的 0.1 倍
for i, param_group in enumerate(optimizer.param_groups):
    if i == 1:  # 假設我們要調整第二組引數的學習率
        param_group['lr'] *= 0.1  # 將第二組引數的學習率調整為之前的 0.1 倍

1.3 nn.functional

  nn中還有一個很常用的模組:nn.functional,nn中的大多數layer,在functional中都有一個與之相對應的函式。nn.functional中的函式和nn.Module的主要區別在於,用nn.Module實現的layers是一個特殊的類,都是由class layer(nn.Module)定義,會自動提取可學習的引數。而nn.functional中的函式更像是純函式,由def function(input)定義。下面舉例說明functional的使用,並指出二者的不同之處。

input = t.randn(2, 3)
model = nn.Linear(3, 4)
output1 = model(input)
output2 = nn.functional.linear(input, model.weight, model.bias)
output1 == output2
b = nn.functional.relu(input)
# 使用ReLU的另一種呼叫方式
b2 = nn.ReLU()(input)
b == b2

  當模型有可學習的引數,最好用nn.Module,否則既可以使用nn.functional也可以使用nn.Module,二者在效能上沒有太大差異,具體的使用取決於個人的喜好。如啟用函式(ReLU、sigmoid、tanh),池化(MaxPool)等層由於沒有可學習引數,則可以使用對應的functional函式代替,而對於卷積、全連線等具有可學習引數的網路建議使用nn.Module。

  下面舉例說明,如何在模型中搭配使用nn.Module和nn.functional。另外雖然dropout操作也沒有可學習操作,但建議還是使用nn.Dropout而不是nn.functional.dropout,因為dropout在訓練和測試兩個階段的行為有所差別,使用nn.Module物件能夠透過model.eval操作加以區分。

from torch.nn import functional as F
class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.fc1 = nn.Linear(16 * 5 * 5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

    def forward(self, x):
        x = F.pool(F.relu(self.conv1(x)), 2)
        x = F.pool(F.relu(self.conv2(x)), 2)
        x = x.view(-1, 16 * 5 * 5)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

  對於不具備可學習引數的層(啟用層、池化層等),將它們用函式代替,這樣則可以不用放置在建構函式__init__中。對於有可學習引數的模組,也可以用functional來代替,只不過實現起來較為繁瑣,需要手動定義引數parameter。

1.4 初始化策略

  在深度學習中引數的初始化十分重要,PyTorch中nn.Module的模組引數都採取了較為合理的初始化策略,因此一般不用我們考慮,當然我們也可以用自定義初始化去代替系統的預設初始化。而當我們在使用Parameter時,自定義初始化則尤為重要,因t.Tensor()返回的是記憶體中的隨機數,很可能會有極大值,這在實際訓練網路中會造成溢位或者梯度消失。PyTorch中nn.init模組就是專門為初始化而設計,如果某種初始化策略nn.init不提供,使用者也可以自己直接初始化。

# 利用nn.init初始化
from torch.nn import init
# 定義一個線性層,輸入特徵數為 3,輸出特徵數為 4
linear = nn.Linear(3, 4)
# 設定隨機種子,以確保每次執行程式碼時產生相同的隨機數
t.manual_seed(1)
# 等價於 linear.weight.data.normal_(0, std)
# 使用 Xavier 正態分佈初始化線性層的權重,根據輸入和輸出的特徵數量自動設定權重的標準差
init.xavier_normal_(linear.weight) #  # 初始化權重

# 直接初始化
import math
t.manual_seed(1)

# xavier初始化的計算公式
std = math.sqrt(2)/math.sqrt(7.)
linear.weight.data.normal_(0,std)
# 遍歷模型 net 的所有引數,包括引數名稱和引數本身
for name, params in net.named_parameters():
    # 檢查引數名稱中是否包含 'linear',如果是,則進行線性層的初始化
    if name.find('linear') != -1:
        # init linear
        # params[0] 表示線性層的權重(weight)
        # 可以在這裡新增權重的初始化方法,例如 Xavier 或 He 初始化
        # 例如: init.xavier_normal_(params[0]) 
        weight = params[0]  # 獲取權重引數
        
        # params[1] 表示線性層的偏置(bias)
        # 可以在這裡對偏置進行初始化,例如將偏置設定為零
        # 例如: nn.init.zeros_(params[1])
        bias = params[1]  # 獲取偏置引數
        
    # 檢查引數名稱中是否包含 'conv',如果是,則可以進行卷積層的初始化
    elif name.find('conv') != -1:
        # 在這裡新增摺積層的初始化方法
        # 例如: init.kaiming_normal_(params[0])  # 對卷積層的權重進行 Kaiming 初始化
        pass
        
    # 檢查引數名稱中是否包含 'norm',如果是,則可以進行歸一化層的初始化
    elif name.find('norm') != -1:
        # 在這裡新增歸一化層的初始化方法
        # 例如: params[0].data.fill_(1)  # 設定歸一化層的權重為1
        #       params[1].data.fill_(0)  # 設定歸一化層的偏置為0
        pass

1.5 nn.Module深入分析

  如果想要更深入地理解nn.Module,究其原理是很有必要的。首先來看看nn.Module基類的建構函式:

def __init__(self):
    self._parameters = OrderedDict()
    self._modules = OrderedDict()
    self._buffers = OrderedDict()
    self._backward_hooks = OrderedDict()
    self._forward_hooks = OrderedDict()
    self.training = True

其中每個屬性的解釋如下:

  • _parameters:字典,儲存使用者直接設定的parameter,self.param1 = nn.Parameter(t.randn(3, 3))會被檢測到,在字典中加入一個key為'param',value為對應parameter的item。而self.submodule = nn.Linear(3, 4)中的parameter則不會存於此。
  • _modules:子module,透過self.submodel = nn.Linear(3, 4)指定的子module會儲存於此。
  • _buffers:快取。如batchnorm使用momentum機制,每次前向傳播需用到上一次前向傳播的結果。
  • _backward_hooks_forward_hooks:鉤子技術,用來提取中間變數,類似variable的hook。
  • training:BatchNorm與Dropout層在訓練階段和測試階段中採取的策略不同,透過判斷training值來決定前向傳播策略。

  上述幾個屬性中,_parameters_modules_buffers這三個字典中的鍵值,都可以透過self.key方式獲得,效果等價於self._parameters['key'],下面舉例說明。

import torch as t
import torch.nn as nn

# 定義自定義神經網路類Net,繼承自nn.Module
class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        # 使用nn.Parameter定義一個3x3的引數矩陣param1
        # 這個引數矩陣將在反向傳播中進行訓練
        # 等價於self.register_parameter('param1', nn.Parameter(t.rand(3, 3)))
        self.param1 = nn.Parameter(t.rand(3, 3))
        
        # 定義一個線性層submodel1,輸入維度為3,輸出維度為4
        # 該層也包含可訓練的權重和偏置
        self.submodel1 = nn.Linear(3, 4) 

    # 定義前向傳播過程
    def forward(self, input):
        # 使用矩陣乘法將param1與輸入input相乘,生成中間結果x
        # param1為3x3矩陣,因此input的維度需要滿足乘法規則
        x = self.param1.mm(input)
        
        # 將中間結果x輸入到submodel1線性層中
        # submodel1會對x進行線性變換,輸出4維結果
        x = self.submodel1(x)
        
        # 返回最終的前向傳播結果
        return x

# 例項化Net模型
net = Net()
net  # 檢視模型結構

Net(
  (submodel1): Linear(in_features=3, out_features=4, bias=True)
)

  nn.Module在實際使用中可能層層巢狀,一個module包含若干個子module,每一個子module又包含了更多的子module。為方便使用者訪問各個子module,如函式children可以檢視直接子module,函式module可以檢視所有的子module(包括當前module)。與之相對應的還有函式named_childennamed_modules,其能夠在返回module列表的同時返回它們的名字。

# t.arange(0, 12):生成一個從 0 到 11(不包括 12)的張量,.view(3, 4):將張量的形狀變為 (3, 4),
input = t.arange(0, 12).view(3, 4)
model = nn.Dropout()
# 在訓練階段,會有一半左右的數被隨機置為0
model(input)

# 結果如下
tensor([[  0.,   2.,   0.,   0.],
        [  8.,   0.,  12.,  14.],
        [ 16.,   0.,   0.,  22.]])

model.training  = False
# 在測試階段,dropout什麼都不做
model(input)

  對於batchnorm、dropout、instancenorm等在訓練和測試階段行為差距巨大的層,如果在測試時不將其training值設為True,則可能會有很大影響,雖然可透過直接設定training屬性,來將子module設為train和eval模式,但這種方式較為繁瑣,更為推薦的做法是呼叫model.train()函式,它會將當前module及其子module中的所有training屬性都設為True,相應的,model.eval()函式會把training屬性都設為False。

  register_forward_hookregister_backward_hook,可在module前向傳播或反向傳播時註冊鉤子,每次前向傳播執行結束後會執行鉤子函式(hook)。前向傳播的鉤子函式具有如下形式:hook(module, input, output) -> None,而反向傳播則具有如下形式:hook(module, grad_input, grad_output) -> Tensor or None。需要注意鉤子函式使用後應及時刪除,以避免每次都執行鉤子增加執行負載。 

model = VGG()
# 建立一個空的 Tensor,用於儲存鉤子函式捕獲的輸出資料
features = t.Tensor()
def hook(module, input, output):
    '''把這層的輸出複製到features中'''
    features.copy_(output.data)
    
# 將鉤子函式 `hook` 註冊到 `model.layer8`(VGG 網路的第 8 層),每當該層執行前向傳播時,鉤子函式 `hook` 會自動執行。
handle = model.layer8.register_forward_hook(hook)
_ = model(input)
# 用完hook後刪除
handle.remove()

  在 PyTorch 的 nn.Module 中,__getattr____setattr__ 經常用於自定義屬性訪問和賦值的行為。getattr(obj, 'attr1')等價於obj.attrsetattr(obj, 'name', value)等價於obj.name=value,總結一下就是:

  • result = obj.name會呼叫buildin函式getattr(obj, 'name'),如果該屬性找不到,會呼叫obj.__getattr__('name')
  • obj.name = value會呼叫buildin函式setattr(obj, 'name', value),如果obj物件實現了__setattr__方法,setattr會直接呼叫obj.__setattr__('name', value')

  nn.Module實現了自定義的__setattr__函式,當執行module.name=value時,會在__setattr__中判斷value是否為Parameternn.Module物件,如果是則將這些物件加到_parameters_modules兩個字典中,而如果是其它型別的物件,如Variablelistdict等,則呼叫預設的操作,將這個值儲存在__dict__中。

  在PyTorch中儲存模型十分簡單,所有的Module物件都具有state_dict()函式,返回當前Module所有的狀態資料。將這些狀態資料儲存後,下次使用模型時即可利用model.load_state_dict()函式將狀態載入進來。最佳化器(optimizer)也有類似的機制,不過一般並不需要儲存最佳化器的執行狀態。

# 儲存模型
t.save(net.state_dict(), 'net.pth')

# 載入已儲存的模型
net2 = Net()
net2.load_state_dict(t.load('net.pth'))

  將Module放在GPU上執行也十分簡單,只需兩步:

  • model = model.cuda():將模型的所有引數轉存到GPU
  • input.cuda():將輸入資料也放置到GPU上

  至於如何在多個GPU上平行計算,PyTorch也提供了兩個函式,可實現簡單高效的並行GPU計算

  • nn.parallel.data_parallel(module, inputs, device_ids=None, output_device=None, dim=0, module_kwargs=None)
  • class torch.nn.DataParallel(module, device_ids=None, output_device=None, dim=0)

  可見二者的引數十分相似,透過device_ids引數可以指定在哪些GPU上進行最佳化,output_device指定輸出到哪個GPU上。唯一的不同就在於前者直接利用多GPU平行計算得出結果,而後者則返回一個新的module,能夠自動在多GPU上進行並行加速。

# method 1
new_net = nn.DataParallel(net, device_ids=[0, 1])
output = new_net(input)

# method 2
output = nn.parallel.data_parallel(new_net, input, device_ids=[0, 1])

1.6 nn和autograd的關係

  nn.Module利用的也是autograd技術,其主要工作是實現前向傳播。在forward函式中,nn.Module對輸入的tensor進行的各種操作,本質上都是用到了autograd技術。這裡需要對比autograd.Function和nn.Module之間的區別:

  • autograd.Function利用了Tensor對autograd技術的擴充套件,為autograd實現了新的運算op,不僅要實現前向傳播還要手動實現反向傳播
  • nn.Module利用了autograd技術,對nn的功能進行擴充套件,實現了深度學習中更多的層。只需實現前向傳播功能,autograd即會自動實現反向傳播
  • nn.functional是一些autograd操作的集合,是經過封裝的函式

  作為兩大類擴充PyTorch介面的方法,我們在實際使用中應該如何選擇呢?如果某一個操作,在autograd中尚未支援,那麼只能實現Function介面對應的前向傳播和反向傳播。如果某些時候利用autograd介面比較複雜,則可以利用Function將多個操作聚合,實現最佳化,正如第三章所實現的Sigmoid一樣,比直接利用autograd低階別的操作要快。而如果只是想在深度學習中增加某一層,使用nn.Module進行封裝則更為簡單高效。

1.7 搭建ResNet

  深度殘差網路(ResNet)在深度學習的發展中起到了很重要的作用,這一結構解決了訓練極深網路時的梯度消失問題。

  首先來看看ResNet的網路結構,這裡選取的是ResNet的一個變種:ResNet34。ResNet的網路結構如下圖所示,可見除了最開始的卷積池化和最後的池化全連線之外,網路中有很多結構相似的單元,這些重複單元的共同點就是有個跨層直連的shortcut。

十四、神經網路工具箱nn

  ResNet中將一個跨層直連的單元稱為Residual block(殘差塊),其結構如下圖所示,左邊部分是普通的卷積網路結構,右邊是直連,但如果輸入和輸出的通道數不一致,或其步長不為1,那麼就需要有一個專門的單元將二者轉成一致,使其可以相加。

十四、神經網路工具箱nn

  另外我們可以發現Residual block的大小也是有規律的,在最開始的pool之後有連續的幾個一模一樣的Residual block單元,這些單元的通道數一樣,在這裡我們將這幾個擁有多個Residual block單元的結構稱之為layer,注意和之前講的layer區分開來,這裡的layer是幾個層的集合。

  考慮到Residual block和layer出現了多次,我們可以把它們實現為一個子Module或函式。這裡我們將Residual block實現為一個子moduke,而將layer實現為一個函式。下面是實現程式碼,規律總結如下:

  • 對於模型中的重複部分,實現為子module或用函式生成相應的modulemake_layer
  • nn.Module和nn.Functional結合使用
  • 儘量使用nn.Seqential
from torch import nn
import torch as t
from torch.nn import functional as F

class ResidualBlock(nn.Module):
    """
    實現子模組:殘差塊(Residual Block)
    每個殘差塊包含兩個卷積層和一個可選的捷徑連線(shortcut)。
    """

    def __init__(self, in_channels, out_channels, stride=1, shortcut=None):
        # 呼叫父類建構函式,初始化基礎屬性
        super(ResidualBlock, self).__init__()
        
        # 定義殘差塊的左分支(即普通卷積分支)
        # 左分支的第一個卷積層:卷積核大小為3x3,步幅由引數`stride`指定,`padding`=1使輸出大小不變,禁用偏置(bias)
        self.left = nn.Sequential(
            nn.Conv2d(in_channels, out_channels, 3, stride=stride, padding=1, bias=False),
            nn.BatchNorm2d(out_channels),       # BN層標準化輸出
            nn.ReLU(inplace=True),              # 使用ReLU啟用函式進行非線性變換
            nn.Conv2d(out_channels, out_channels, 3, stride=1, padding=0, bias=False),  # 第二層卷積,步幅固定為1,輸出維度保持不變
            nn.BatchNorm2d(out_channels)        # BN層標準化輸出
        )
        
        # 右分支:捷徑連線,用於在輸入維度和輸出維度不一致時調整維度(即殘差對映)
        # 如果維度匹配,不需要捷徑連線(shortcut=None),否則提供一個卷積層調整維度
        self.right = shortcut

    def forward(self, x):
        # 計算左分支的輸出
        out = self.left(x)
        
        # 計算右分支的輸出:如果沒有捷徑連線,則使用原始輸入 x,否則透過捷徑連線對映得到 residual
        residual = x if self.right is None else self.right(x)
        
        # 左分支和右分支輸出相加(殘差連線)
        out += residual
        
        # 將結果透過 ReLU 啟用函式返回
        return F.relu(out)


class ResNet(nn.Module):
    """
    實現主模組:ResNet34
    ResNet34 包含多個 layer,每個 layer 又包含多個 residual block。
    """

    def __init__(self, num_classes=1000):
        # 呼叫父類建構函式,初始化基礎屬性
        super(ResNet, self).__init__()
        
        # 前幾層影像轉換層(影像預處理)
        # 輸入 3 通道(RGB影像),輸出 64 通道,卷積核大小為 7x7,步幅為 2,`padding` 為 3 使得輸出大小與輸入相近
        self.pre = nn.Sequential(
            nn.Conv2d(3, 64, 7, 2, 3, bias=False),
            nn.BatchNorm2d(64),         # BN層標準化輸出
            nn.ReLU(inplace=True),      # ReLU 啟用
            nn.MaxPool2d(3, 2, 1)       # 3x3 最大池化層,步幅為 2,`padding` 為 1 使輸出大小減半
        )

        # 構建 ResNet34 的 4 個 layer,每個 layer 包含不同數量的 Residual Block
        # 第一個 layer 包含 3 個殘差塊,輸入通道為 64,輸出通道為 64,步幅為 1(大小不變)
        self.layer1 = self._make_layer(64, 64, 3)
        
        # 第二個 layer 包含 4 個殘差塊,輸入通道為 64,輸出通道為 128,步幅為 2(大小減半)
        self.layer2 = self._make_layer(64, 128, 4, stride=2)
        
        # 第三個 layer 包含 6 個殘差塊,輸入通道為 128,輸出通道為 256,步幅為 2(大小減半)
        self.layer3 = self._make_layer(128, 256, 6, stride=2)
        
        # 第四個 layer 包含 3 個殘差塊,輸入通道為 256,輸出通道為 512,步幅為 2(大小減半)
        self.layer4 = self._make_layer(256, 512, 3, stride=2)

        # 分類用的全連線層,將最後輸出對映到類別數量(num_classes)
        self.fc = nn.Linear(512, num_classes)

    def _make_layer(self, inchannel, outchannel, block_num, stride=1):
        """
        構建 layer,包含多個 residual block。
        :param inchannel: 輸入通道數
        :param outchannel: 輸出通道數
        :param block_num: 殘差塊數量
        :param stride: 步幅
        """
        
        # 捷徑連線,用於調整第一個殘差塊的輸入尺寸和輸出尺寸
        # 使用 1x1 卷積,將輸入通道數從 inchannel 轉換為 outchannel,步幅為 stride
        shortcut = nn.Sequential(
            nn.Conv2d(inchannel, outchannel, 1, stride, bias=False),
            nn.BatchNorm2d(outchannel)
        )

        layers = []
        # 構建第一個 Residual Block,使用捷徑連線
        layers.append(ResidualBlock(inchannel, outchannel, stride, shortcut))

        # 構建後續的 Residual Block,不需要捷徑連線,輸入和輸出通道一致
        for i in range(1, block_num):
            layers.append(ResidualBlock(outchannel, outchannel))
        
        # 將所有 Residual Block 封裝成 nn.Sequential 以方便呼叫
        return nn.Sequential(*layers)

    def forward(self, x):
        # 前幾層的影像轉換操作
        x = self.pre(x)

        # 依次透過 4 個 layer,每個 layer 包含多個殘差塊
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)

        # 透過全域性平均池化,將輸出大小縮小到 1x1(相當於對每個通道的空間位置求平均)
        x = F.avg_pool2d(x, 7)
        
        # 將池化後的張量展平成一維向量,準備輸入全連線層
        x = x.view(x.size(0), -1)
        
        # 透過全連線層,將特徵對映為類別數量的輸出
        return self.fc(x)

# 例項化 ResNet 模型
model = ResNet()

# 生成一個隨機輸入張量(batch size=1,3 通道,224x224),模擬輸入圖片
input = t.randn(1, 3, 224, 224)

# 透過模型前向傳播獲得輸出
o = model(input)

相關文章