最近因為肺炎無法無天只得駐留在家,整理一下早兩年學習的一些資料。

構建模型的方法

最基礎的方法是定義一個類,繼承 nn.Module,然後在其 def __init__() 中定義(實例化) forward() 中需要使用的網絡层。例如 self.name = nn.Conv2d(),這個參数名 name 将作为 model 的属性名,可以透過 model.name 訪問,如果這個层是帶有可學習的參数的,那麼它還會有 model.name.weightmodel.name.bias 等權重属性。

定義完所有不同的层之後,就可以在類的 forward() 函数中使用這些层,並決定輸入数據在網絡中的流動。對於一些不含可訓練參数的层,可以在這裡直接使用 nn.functional 中的函数而無需事先定義。

例如:

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
import torch
import torch.nn as nn
import torch.nn.functional as F

class net(nn.Module):
def __init__(self, output_channel):
# 首先必須要初始化父類的所有參数
super(net, self).__init__()
self.conv_1 = nn.Conv2d(3, output_channel, 3)
self.relu_1 = nn.ReLU()
def forward(self, x):
# 可以使用 torch 中對 tensor 的各種操作
size = x.size()
x = self.conv_1(x)
x = self.relu_1(x)
# 重複使用 conv1_1,即兩個卷積共享參数
x = self.conv_1(x)
# 不含 learnable 的层可不定義直接使用
x = F.max_pool2d(x, 2, 2)
x = F.interpolate(x, size, mode="bilinear")
return x

model = net(output_channel=8) # 實例化整個網絡,init 中可設置各種參数
output = model(input_tensor) # input_tensor 傳給 forward 中的參数 x

>>> print(model.conv_1)
>>> Conv2d(3, 8, kernel_size=(3, 3), stride=(1, 1))
>>> print(isinstance(model.conv_1.weight, torch.Tensor))
>>> True

使用 add_module 方法

在類的初始化函式中,self.name = nn.Module() 等同於調用 self.add_module("name", nn.Module()) ,后者這種方法主要是當层比較多而都是重複的結構時(例如 ResNet 中很多殘差單元都是完全相同的),方便自動設置层的名称。對應的在 forward 中常使用 getattr(self, "name")(x)

例如(代碼不完全):

1
2
3
4
5
6
7
8
9
10
11
12
def __init__(self):
super(net, self).__init__()
for i in range(1, 4):
self.add_module("conv1_%s" % str(i), nn.Conv2d(3, 3, 3, 2, 1))
def forward(self, x):
for i in range(1, 4):
x = getattr(self, "conv1_%s" str(i))(x)
return x

>>> input = torch.randn(1, 3, 16, 16)
>>> print(model(input).shape)
>>> torch.Size([1, 3, 2, 2])

使用 nn.Sequential,以及更一般的情況

需要進行一些判斷(以重複使⽤)的层或其它情況,可以将各種层 append 到一個 list 中,然后使⽤解構賦值 self.name = nn.Sequential(*[Conv2d(), Conv2d(), ...])。這樣的层的名稱为数字编号,访问其中的层: model.name[index]。給 nn.Sequential() 內的层賦上名稱,可以使用 from collections import OrderedDict -> nn.Sequential(OrderedDict([("name1", layer1()), ("name2", layer2())])),加上了名稱之後就可以使用 . 的方法訪問了。

nn.Sequential 實際上就是一個繼承了 nn.Module 的類,如同最開頭那樣定義的。因此我們可以直接單獨使用它:

1
2
3
4
5
6
7
8
model = nn.Sequential(
nn.Conv2d(3, 3, 3, 2, 1),
nn.Conv2d(3, 3, 3, 2, 1)
)
>>> input = torch.randn(1, 3, 16, 16)
# 網絡层的順序是固定了的
>>> print(model(input).shape)
>>> torch.Size([1, 3, 4, 4])

既然可以在自定義的類(A)中的 init 初始化 nn.Sequential,那麼也可以初始化另一個自定義類(B),當然 nn.Sequential 中也可以添加一個自定義類,最終裡面的层的名稱逐級嵌套,類似於 model.name_in_netA.name_in_netB[index in Sequential]

更多的需求可以參考 nn.ModuleListnn.ModuleDict,它們跟 Python 的 list 和 dict 基本相似,但是註冊到了 model 里,可以被整个 model 訪問。創建⽅法是传⼊一個 list 或 dict。此外,ModuleDict 內的层不僅可以用 ['name'] 訪問(同 Python),還可以用 ModuleDict.name 訪問(同 Module)。
這兩個方法與 nn.Sequential 的區別在於它們不可以單獨作為一個網絡使用,而必須在一個網絡的 init 中初始化,在 forward 中使用。此外,它們擁有 append 和 update 等方法。

初始化參数

如這章開頭例子所示,卷積等的權重和偏置就是一個 torch.Tensor,將 tensor 傳入初始化函式即可赋上指定的初始化值,一些自帶的初始化函式可在 torch.nn.init 找到,可以看到這些函式都以下劃線結尾,說明它們都是原位改变值的。

一般而言,會寫一個函式來進行一些判斷和循環完成對所有 tensor 的初始化,例如判斷是卷積的 weight 就使用 kaiming_uniform_(),判斷是卷積的 bias 就使用 zeros_()。關於如何返回所有的 tensor 可參考下一章。

nn.Module 類的一些属性

自定義的類繼承 nn.Module 之後,就可以調用其自帶方法,下面是一些常用的。為方便說明,這裡以 torchvision 內的 VGG-16 為例。導入後使用 vgg = torchvision.models.vgg16() 即可,具體網絡結構可以打開前面的超鏈接或是 IDE 裡打開。

model.modules()

這個方法會返回一個 generator,裡面是順次逐級的 model 的所有子模塊,如果某一個子模塊是一個 nn.Module,那麼還返回這個子模塊的所有子模塊。所以返回中第一個值會是整個網絡,第二個值是整個 self.features,接下來第三個開始會展開,依次返回 self.features 裡的所有层,再接下來是 self.avgpool,等等。也就是有

1
2
3
4
>>> all_modules = list(vgg.modules())
>>> all_modules[0].features == all_modules[1]
# index=0 是整個網絡,可以像上一章一樣的方法找到子模塊
>>> True

這個方法主要用於需要判斷網絡层的類型(卷積、全連接)然後進行下一步操作的,判斷一般使用 for m in model.modules(): -> if isinstance(m, nn.Conv2d): -> ...。例如在初始化 tensor 的值的時候就可以使用,例如 VGG 的 _initialize_weights 對不同類型的层使用不同的初始化方法。

有一個類似的方法 named_modules(),會返回帶上子模塊名字的字典。還有 children()named_children(),這兩個只返回直接的一級子模塊,而不會展開它們(如果是 nn.Module),可以循環調用直到末尾。

model.parameters()

上一個方法的最小單位是 Conv、MaxPool 之類的網絡层,需要通過 m.weightm.bias 訪問其中的權重 tensor,而這個方法會直接返回 model 中所有 tensor,不帶层級結構,每一個元素都是 tensor 且 .requires_grad == True,因此通常被傳給 optimizer。注意返回的都是和網絡中有相同內存地址的 tensor,如果在外部改变了這些 tensor 的值(正常情況下是被 optimizer),那麼網絡中的參数也將改变。

1
2
3
4
5
6
7
8
all_parameters = list(vgg.parameters())
# 覆蓋 tensor data,如果不帶 .data,則會覆蓋 requires_grad 等所有參数
all_parameters[0].data = torch.ones(64, 3, 3, 3, dtype=torch.float)

>>> print(vgg.features[0].weight[1,1])
>>> tensor([[1., 1., 1.],
[1., 1., 1.],
[1., 1., 1.]], grad_fn=<SelectBackward>)

同樣的,這個方法也有一個對應的 model.named_paramters()

model.state_dict()

.named_parameters() 類似,也是直接返回所有權重,但是除了可學習的 tensor 之外,還會返回一些层的狀態(persistent buffers),比如 BatchNorm 层會不斷紀錄 running averages,雖然不是作用在輸入上的權重,但是也是關係到訓練過程。因此這個方法主要用於保存 checkpoint 的時候返回所有需要保存的值。

数據集相關的 Dataset 和 Dataloader

torch.utils.data 下有兩個類,一個是 Dataset,用來給出一個索引返回一對圖片和標籤;另一個是 Dataloader,用於將前者以某種方式採樣,並返回一個 batch。

對於 Dataset,繼承之後重寫 __len__()__getitem()__

對於 Dataloader,有 samplercollate_fn

Registry

open-mmlab 和 Facebook 在他們的 MMCVfvcore 中都實現了一個 class Registry。他們在內部保存一個 dict,創建實例時提供一個 name MODELS = Registry('models'),然後使用裝飾器 @MODELS.register_module() class ResNet: 或直接調用函數 MODELS.register_module(ResNet) 註冊一個函數到 dict 中。

關於 Python 的裝飾器:裝飾器是一個函數,參數中接收一個函數 A(的地址)並最終返回一個函數 B,在這個返回的函數 B 的 return 中 A 被執行,B 的 return 之前的部分為裝飾器新添加的功能。

在執行到 @decorator 的時候,裝飾器函數的最外層(scope x)就已經執行

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
def log(func):
# scope x1
def wrapper(*args, **kw):
# scope y1
print('call %s():' % func.__name__)
return func(*args, **kw)
return wrapper

@log
def now():
pass
# 相當於執行了,現在 now 指向新的函數 log -> wrapper
now = log(now)

# 裝飾器也可以返回一個裝飾器
def log(text=""):
# scope x2
def decorator(func)
# scope y2
def wrapper(*args, **kw):
# scope z2
print('call %s():' % func.__name__)
return func(*args, **kw)
return wrapper
return decorator

@log() # 執行函數得到返回的裝飾器
def now():
pass
now = log()(now)

# 不在執行 func 的時候改變任何,而是執行到 @ 的時候註冊就完事了
def register(name=""):
# scope x2
def decorator(module_class)
# scope y2
name = module_class.__name__
add_to_self_dict(module_class, name)
# def wrapper(*args, **kw):
# # scope z2
# print('call %s():' % func.__name__)
# return func(*args, **kw)
# return wrapper
return func
return decorator

import 時就從頭至尾掃描(執行)了整個文件