想想一下自己很久没有接触model, CNN, AI agent这些内容,但是最近自己突然由于各种原因需要重新接触这些内容,然后写出(或者是让AI写出)一个可以满足特定小需求的代码。

那当然是简洁,快速,不追求性能好不好,有个结果就可以,很符合我们今天的主题。

假如你现在处在这样的场景中,要做些什么才对呢?


environment

首先是确认自己的环境还能跑起来。

所幸第一步是非常简单的,我们有很多方法来配置当前所需要的环境,常见的环境管理方式有实际路径(当然是最不推荐的),python venv, conda, rye/uv.

后三个由于是虚拟环境,可以实现项目环境的隔离,不容易把环境整坏,而且也都可以通过requirement.txt/pyproject.toml实现环境的快速迁移和恢复,算是比较推荐的方式。我个人目前比较喜欢用rye来进行python环境的管理,这一部分内容在之前有写过,这里就不再说了,我这里都以rye作为默认python管理工具。

我们可以打开一个jupyter notebook,输入以下内容:

1
2
3
4
5
import torch
import torch.nn as nn

print(torch.__version__)
print(torch.cuda.is_available())

当然也默认是nvidia GPU

很简单,检查pytorch环境,以及GPU链路正常,保证torch可以正常(在GPU上)使用。


model select

然后要想好要怎么选择模型,选择模型本质上是面对任务的三种姿态。

第一种,是选择从头开始,自己搭建(nn.Module)。需要了解nn.linear, nn.Conv2d,
nn.TransformerEncoderLayer这些,每一层都是在做什么,这个我们之后再说。这个方法本质上是需要比较清楚的知道自己想要做什么结构,或者是现在需要复现论文之类的,日常自己搭建模型走这条路太慢,暂时不用考虑。

第二种,是选择拿来改改给自己用(torchvision.models之类)。这种模型的结构和权重是现成的,,我们只需要稍微改改最后几层的行为来适配当前任务就行。这个是日常视觉任务最常见的姿势,resnet, efficientnet, vit等直接拿来用,换一个输出头就完了。

第三种,微调训练大模型(huggingface和modelscope)。其实本质和第二种是一样的,但是规模很大,适用范围更广,NLP,多模态等都可以在huggingface上面做,本质是AutoModel.from_pretrained()加载权重,接上自己的任务头,或者直接Trainer微调。

我们自己做一个小东西的话,一般直接选择第二种拿来主义即可。


train loop

然后我们需要对搭建好的模型进行训练。所谓训练,就是让模型反复自己计算结果,反复对答案,看看哪里对哪里错,然后修正自己,从而让自己的行为更加贴近正确行为。

调整模型一般需要记住几句关键statement,比如:

1
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

这个很明显,就是指模型对完答案之后怎样优化自己。这里展示的是:使用”Adam”方法来调整模型的参数,每次调整的幅度是”1e-3”.

具体选择什么优化方式可以根据自己的应用场景问AI,不过一般Adam就行;lr一般不要太大,不然会模型参数会漫天乱漂,也不能太小,不然循环训练多少次改进很小学不动。这个可以问AI,不过自己运行一次也就知道该写多少了。

1
criterion = nn.CrossEntropyLoss()

这一步的含义是,用来计算模型本次的回答”有多错”,可以理解为打分。分类任务可以默认用这个,回归任务换成nn.MSELoss(),当然也可以让AI帮助选择。

1
2
3
4
5
6
for epoch in range(10):
optimizer.zero_grad()
output = model(x)
loss = criterion(output, y)
loss.backward()
optimizer.step()

optimizer.zero_grad()意味着每次循环,先把上次的梯度清空,保证本次训练没有被之前的数据污染。

output = model(x)就是把数据x喂给模型,让它给你预计结果。

loss = criterion(output, y)让模型把自己计算出来的结果和真实结果对比,看看相差多少,怎么改进。

loss.backward()把错误分数往回推算,看看每个参数应该往什么方向调整(对于模型参数改进很重要)。

optimizer.step()执行调整。(上一步是算出调整方向,这次整的调整模型参数)

流程就是:清空->前向计算->计算loss->反向计算->更新参数。

训练的时候有两个坑,第一个是要清空梯度,不然训练结果会非常诡异;第二个是只做inference的时候最好加上一层:

1
2
with torch.no_grad():
output = model(x)

如果不加的话pytorch还会在后台自己算梯度,会浪费显存,数据大的时候还会OOM.

OOM:CUDA Out Of Memory.


simple routine

需要完整记住的东西就以上这么多,其他小细节可以在写模型的过程中得知。来看一个简单的例子:

packages

1
2
3
4
5
6
import torch
import torch.nn as nn
import torchvision.models as models
import torchvision.transforms as transforms
from torchvision.datasets import CIFAR10
from torch.utils.data import DataLoader

这里就是引入需要的package.

transforms用于修整输入数据为模型可接受的格式,DataLoader用于加载数据,CIFAR10是一个torchvision内置的图像分类数据集,有10种图片,总共6w张图片,每张是32x32彩色图片。

当然可以输出看一看模型长什么样子。

select device

1
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

检测cuda是否可用,如果可用就在GPU上完成计算。

load data

1
2
3
4
5
6
7
8
transform = transforms.Compose([
transforms.Resize(224),
transforms.ToTensor(),
])

train_dataset = CIFAR10(root="./data", train=True, download=True,
transform=transform)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)

这里把数据整理成224x224,原因是之后用了resnet,需要数据是224x224才能喂进去,否则卷积池化之后特征图会变成负尺寸。具体什么模型需要什么输入,可以问AI.

train_dataset这一行,意味着获取数据集。root参数指的是数据存放路径,如果没有会创建文件夹;train=True是要下载训练集,如果是False就是下载测试集。

train_loader这里,DataLoader是把数据分批次,打乱顺序向外发送。

select model

1
2
3
model = models.resnet18(weights=None)
model.fc = nn.Linear(512, 10)
model = model.to(device)

这里选择的模型是resnet18,weights=None的含义是不导入已有权重,随机初始化权重,也就是从头训练;如果是迁移训练,就给weights传入已有权重。

model.fc = nn.Linear(512, 10)含义为,本来的resnet18最后一层是nn.Linear(512, 1000),但是这个数据集只有10中数据类型,所以输出头换为10.

这个fc是哪来的呢?我怎么知道最后一层是什么?我们可以通过print(model)看到模型长这个样子:

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
ResNet(
(conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)
(bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False)
(layer1): Sequential(
(0): BasicBlock(
(conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(1): BasicBlock(
(conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(layer2): Sequential(
(0): BasicBlock(
(conv1): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(downsample): Sequential(
(0): Conv2d(64, 128, kernel_size=(1, 1), stride=(2, 2), bias=False)
(1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(1): BasicBlock(
(conv1): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(layer3): Sequential(
(0): BasicBlock(
(conv1): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(downsample): Sequential(
(0): Conv2d(128, 256, kernel_size=(1, 1), stride=(2, 2), bias=False)
(1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(1): BasicBlock(
(conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(layer4): Sequential(
(0): BasicBlock(
(conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(downsample): Sequential(
(0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)
(1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(1): BasicBlock(
(conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
(fc): Linear(in_features=512, out_features=10, bias=True)
)

看着挺吓人,但是其实结构还是挺清楚的。:前面的是该层的名字,后面是类似函数调用的表达式。

之后我们想要改哪一层就用哪个名字即可。比如我想要改第一层,第一层叫model.conv1,默认接收三通道RGB,但是我的图片是灰度图(假设),就把通道数改为1:

1
model.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)

中间层一般不会改,如果要改的话不如从头自己搭(逃)

实际常见的改法除了直接修改输出结果类别数,还可以把最后改成多层,比如:

1
2
3
4
5
model.fc = nn.Sequential(
nn.Linear(512, 256),
nn.ReLU(),
nn.Linear(256,10)
)

这就相当于是多加了一层。具体怎么加,可以往下多看一会,了解一下”某一层”的结构是什么,或者也可以print(model)之后把model结构给AI,让它教我们怎么改。

对于小数据集,还有一种操作是,冻结之前的所有层,只训练最后一层用于适配任务要求即可:

1
2
3
for param in model.parameters():
param.requires_grad = False
model.fc = nn.Linear(512, 10)

也就是不计算所有层参数的梯度(当然也就不会更新模型参数),但是最后一层是之后修改的,默认是可训练的。

prepare train

1
2
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()

经典Adam和CrossEntropyLoss,这两个在本期搭模型篇暂不解释。

train loops

1
2
3
4
5
6
7
8
9
10
11
model.train()
for epoch in range(5):
for x, y in train_loader:
x, y = x.to(device), y.to(device)

optimizer.zero_grad()
output = model(x)
loss = criterion(output, y)
loss.backward()
optimizer.step()
print(f"epoch {epoch + 1} done, loss: {loss.item():.4f}")

model.train()是切换到train mode. batchnorm, dropout这类层的行为会在不同mode下发生变化。

之后是一个双层循环,外层每跑完一次意味着训练所有数据被喂过一轮;内层每次循环都是取一个batch用作训练,直到训练集用完。

inference

1
2
3
4
5
6
7
model.eval()
with torch.no_grad():
sample, label = next(iter(train_loader))
sample = sample.to(device)
pred = model(sample)
print(f"pred class: {pred.argmax(dim=1)[:5]}")
print(f"real class: {label[:5]}")

推理部分,其实就是看看最后训练出来的model计算结果到底对不对。

model.eval()切换到eval mode,同时with torch.no_grad():暂时关闭梯度计算节省显存。

sample, label = next(iter(train_loader))的含义是取出一个batch的训练集来测试,这里是偷懒。当然正式应该是用测试集,也就是里面换成test_loader.

1
2
3
test_dataset = CIFAR10(root="./data", train=False, download=True,
transform=transform)
test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False)

省一点流量求求了

最后就是print的含义是取出测试batch的前5个结果。

argmax就是取分数最大的类别作为最后预测类别(dim=1, 就是沿着第一个维度取最大值;pred的shape是[32,10],32为样本数bath_size, 10是10个类别)。


extension1 : model archtecture

看模型结构,除了print(model),还有其他方式。

print只能看到层的名字和结构,看不到输出尺寸,局限性很大,这个适用于(大改的时候)你对模型比较熟,或者要求不很高只改最后一层就可以。

对于pytorch来说,有看参数的方式:

1
2
3
4
5
6
total = sum(p.numel() for p in model.parameters())
print(f"parameter size: {total:,}")

#只看可训练参数
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"trainable parameter size: {trainable:,}")

但是这还是有点原始了。隔壁keras就有很现代model.summary看输出尺寸。

有的兄弟,有的!不过要额外下载:rye add torchinfo.

之后就可以通过:

1
2
from torchinfo import summary
summary(model, input_size=(1,3,224,224))

这个输出和隔壁keras几乎一样。

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
==========================================================================================
Layer (type:depth-idx) Output Shape Param #
==========================================================================================
ResNet [1, 10] --
├─Conv2d: 1-1 [1, 64, 112, 112] 9,408
├─BatchNorm2d: 1-2 [1, 64, 112, 112] 128
├─ReLU: 1-3 [1, 64, 112, 112] --
├─MaxPool2d: 1-4 [1, 64, 56, 56] --
├─Sequential: 1-5 [1, 64, 56, 56] --
│ └─BasicBlock: 2-1 [1, 64, 56, 56] --
│ │ └─Conv2d: 3-1 [1, 64, 56, 56] 36,864
│ │ └─BatchNorm2d: 3-2 [1, 64, 56, 56] 128
│ │ └─ReLU: 3-3 [1, 64, 56, 56] --
│ │ └─Conv2d: 3-4 [1, 64, 56, 56] 36,864
│ │ └─BatchNorm2d: 3-5 [1, 64, 56, 56] 128
│ │ └─ReLU: 3-6 [1, 64, 56, 56] --
│ └─BasicBlock: 2-2 [1, 64, 56, 56] --
│ │ └─Conv2d: 3-7 [1, 64, 56, 56] 36,864
│ │ └─BatchNorm2d: 3-8 [1, 64, 56, 56] 128
│ │ └─ReLU: 3-9 [1, 64, 56, 56] --
│ │ └─Conv2d: 3-10 [1, 64, 56, 56] 36,864
│ │ └─BatchNorm2d: 3-11 [1, 64, 56, 56] 128
│ │ └─ReLU: 3-12 [1, 64, 56, 56] --
├─Sequential: 1-6 [1, 128, 28, 28] --
│ └─BasicBlock: 2-3 [1, 128, 28, 28] --
│ │ └─Conv2d: 3-13 [1, 128, 28, 28] 73,728
│ │ └─BatchNorm2d: 3-14 [1, 128, 28, 28] 256
│ │ └─ReLU: 3-15 [1, 128, 28, 28] --
│ │ └─Conv2d: 3-16 [1, 128, 28, 28] 147,456
│ │ └─BatchNorm2d: 3-17 [1, 128, 28, 28] 256
│ │ └─Sequential: 3-18 [1, 128, 28, 28] 8,448
│ │ └─ReLU: 3-19 [1, 128, 28, 28] --
│ └─BasicBlock: 2-4 [1, 128, 28, 28] --
│ │ └─Conv2d: 3-20 [1, 128, 28, 28] 147,456
│ │ └─BatchNorm2d: 3-21 [1, 128, 28, 28] 256
│ │ └─ReLU: 3-22 [1, 128, 28, 28] --
│ │ └─Conv2d: 3-23 [1, 128, 28, 28] 147,456
│ │ └─BatchNorm2d: 3-24 [1, 128, 28, 28] 256
│ │ └─ReLU: 3-25 [1, 128, 28, 28] --
├─Sequential: 1-7 [1, 256, 14, 14] --
│ └─BasicBlock: 2-5 [1, 256, 14, 14] --
│ │ └─Conv2d: 3-26 [1, 256, 14, 14] 294,912
│ │ └─BatchNorm2d: 3-27 [1, 256, 14, 14] 512
│ │ └─ReLU: 3-28 [1, 256, 14, 14] --
│ │ └─Conv2d: 3-29 [1, 256, 14, 14] 589,824
│ │ └─BatchNorm2d: 3-30 [1, 256, 14, 14] 512
│ │ └─Sequential: 3-31 [1, 256, 14, 14] 33,280
│ │ └─ReLU: 3-32 [1, 256, 14, 14] --
│ └─BasicBlock: 2-6 [1, 256, 14, 14] --
│ │ └─Conv2d: 3-33 [1, 256, 14, 14] 589,824
│ │ └─BatchNorm2d: 3-34 [1, 256, 14, 14] 512
│ │ └─ReLU: 3-35 [1, 256, 14, 14] --
│ │ └─Conv2d: 3-36 [1, 256, 14, 14] 589,824
│ │ └─BatchNorm2d: 3-37 [1, 256, 14, 14] 512
│ │ └─ReLU: 3-38 [1, 256, 14, 14] --
├─Sequential: 1-8 [1, 512, 7, 7] --
│ └─BasicBlock: 2-7 [1, 512, 7, 7] --
│ │ └─Conv2d: 3-39 [1, 512, 7, 7] 1,179,648
│ │ └─BatchNorm2d: 3-40 [1, 512, 7, 7] 1,024
│ │ └─ReLU: 3-41 [1, 512, 7, 7] --
│ │ └─Conv2d: 3-42 [1, 512, 7, 7] 2,359,296
│ │ └─BatchNorm2d: 3-43 [1, 512, 7, 7] 1,024
│ │ └─Sequential: 3-44 [1, 512, 7, 7] 132,096
│ │ └─ReLU: 3-45 [1, 512, 7, 7] --
│ └─BasicBlock: 2-8 [1, 512, 7, 7] --
│ │ └─Conv2d: 3-46 [1, 512, 7, 7] 2,359,296
│ │ └─BatchNorm2d: 3-47 [1, 512, 7, 7] 1,024
│ │ └─ReLU: 3-48 [1, 512, 7, 7] --
│ │ └─Conv2d: 3-49 [1, 512, 7, 7] 2,359,296
│ │ └─BatchNorm2d: 3-50 [1, 512, 7, 7] 1,024
│ │ └─ReLU: 3-51 [1, 512, 7, 7] --
├─AdaptiveAvgPool2d: 1-9 [1, 512, 1, 1] --
├─Linear: 1-10 [1, 10] 5,130
==========================================================================================
Total params: 11,181,642
Trainable params: 11,181,642
Non-trainable params: 0
Total mult-adds (Units.GIGABYTES): 1.81
==========================================================================================
Input size (MB): 0.60
Forward/backward pass size (MB): 39.74
Params size (MB): 44.73
Estimated Total Size (MB): 85.07
==========================================================================================

input_size(batch_size, channel, hight, width). 这个和隔壁的NHWC不一样,隔壁是通道数在最后。

可以稍微分析一下模型结构:

第一步是前四层:

1
2
Conv2d → BatchNorm2d → ReLU → MaxPool2d
224x224 → 112x112 → 56x56

这个其实是一个快速预处理,减小尺寸,减小计算量。

第二步是四组sequential,每组两个basicblock(这个后面会说模型层级结构,可以之后再理解),每个basicblock都是:Conv2d → BN → ReLU → Conv2d → BN → ReLU.

四组操作之后尺寸大大缩小,特征增加。(经典的空间换信息,这个之后也会说)

第三步就是输出头,将[1,512,1,1],这里最后把7x7的图压缩成1x1,相当于只保留最后的信息,通过这个信息(512种信息中的一种),判断最后是哪个类别。

最后就是分类头,最终输出的结果应该是分成几个类。

除了模型结构,下面几行也是很重要的:

  • total params: 11,181,642,大约有11M个参数。
  • estimated total size: 85MB,大约需要85MB显存,如果换成batch_size=32的话还要乘32,看看会不会OOM.
  • Non-trainable params:0这里会显示冻结了多少层。

extension2 : tqdm

我最讨厌没有任何反馈的干等了,所以要给train加上进度条。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from tqdm import tqdm

model.train()
for epoch in range(5):
loop = tqdm(train_loader, desc=f"epoch {epoch+1}/5", leave=True)

running_loss = 0.0
for i, (x, y) in enumerate(loop):
x, y = x.to(device), y.to(device)
optimizer.zero_grad()
output = model(x)
loss = criterion(output, y)
loss.backward()
optimizer.step()

running_loss +=loss.item()
avg_loss = running_loss/(i+1)

loop.set_postfix(loss=f"{avg_loss:.4f}")

其实只需要用tqdm(train_loader)把dataloader包一层就行。

desc是最左边的标题。leave的含义是每个epoch跑完之后进度条不消失;set_postfix是实时更新进度条右侧显示的内容,跑起来会让人感觉程序还在蠕动。


extension3 : checkpoint

如果训练时间很长一次练不完,中途崩掉或者手滑关掉,之前的训练就白费了,所以要定期保存检查点。

1
2
3
4
5
6
torch.save({
'epoch':epoch,
'model_state_dict':model.state_dict(),
'optimizer_state_dict':optimizer_state_dict(),
'loss':loss,
}, f"checkpoint_epoch{epoch}.pth")

state_dict就是当前所有参数的snapshot,把这些状态都保存好,就可以从指定检查点复活。

读档方式:

1
2
3
4
5
6
7
8
checkpoint = torch.load("checkpoint_epoch10.pth")

model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
start_epoch = checkpoint['epoch'] + 1

for epoch in range(start_epoch, total_epochs):
...

保存完整检查点一般用于继续训练,用于推理的话只存权重即可:

1
2
torch.save(model.state_dict(), "weights.pth")
model.load_state_dict(torch.load("weights.pth"))

但是实际上不是每个几轮存一个,而是保存loss最低那个:

1
2
3
4
5
6
7
8
9
best_loss = float('inf')

for epoch in range(30):
train(...)
val_loss = validate(...)

if val_loss < best_loss:
best_loss = val_loss
torch.save(model.state_dict(), "best_model.pth")

layers

之前我们讲述了什么是拿来主义,现在来看看搭建模型的第一种方式:如何自己从头自定义模型。

有些时候还是需要自己搭积木。


annoying syntax

这个确实是很让人生气的一环。我不可能每次都dir()+help()去查可恶的手册。不过这次我们相对聚焦于模型搭积木,那么相对来说要在短时间内记忆的东西不算多。

nn.module: 这个是搭积木的第一块。首先看以下格式:

1
2
3
4
5
6
7
8
class MyModel(nn.Module):
def __init__(self):
super().__init__()
# 模型搭积木

def forward(self, x):
# datapath
return x

这是一个很简单的格式,我们可以通过在def __init__(self):下完成模型的构建,然后在def forward(self, x):处写出数据处理。

然后看看要在这里面怎么添加模型的结构配置。


container layers

container就是把一堆层整合起来的一个外壳。

nn.Sequential

这个是最常用的一个,sequential的含义就是不同层就是按顺序塞入模型的,数据简单的从第一层流向最后一层。

1
2
3
4
5
model = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10)
)

这就一个很简单的Sequential,数据就是784维输入经过中间256隐藏层,最后成为10维的输出。

这里forward不用自己写,也不需要写class,直接一个接一个塞入层构成一个完整模型。

激活是一定要的,如果没有激活引入非线性计算,理论上无论多少层计算数学上等价于一层。

nn.ModuleList

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyModel(nn.Module):
def __init__(self):
super().__init__()
self.layers = nn.ModuleList([
nn.Linear(128, 64),
nn.Linear(64, 32),
nn.Linear(32, 10)
])

def forward(self, x):
for layer in self.layers:
x = layer(x)
return x

这个就是创建三个层,但是他是类似python的list,pytorch可以追踪里面的所有参数,如果是普通的list,pytorch找不到参数,optimizer就不会更新参数。

这个适合动态层数,或者是在forward里面做非线性操作。

nn.ModuleDict

这个其实就是一个字典版,可以通过名字作为索引而不是下标。

1
2
3
4
5
6
7
8
9
10
class MyModel(nn.Module):
def __init__(self):
super().__init__()
self.heads = nn.ModuleDict({
'classifier':nn.Linear(512, 10),
'regressor':nn.Linear(512, 1)
})

def forward(self, x, task):
return self.heads[task](x)

可以做多任务模型,可以根据不同任务走不同的输出头。


component layers

上面的container更像是组织方式,这里来看具体的layer在做什么。

nn.Linear

这是一个全连接层,意味着每个输入节点和输出节点都连接。

1
nn.Linear(in_features=512, out_features=10)

两个参数是输入维度和输出维度,可以把输入特征压缩,也可以是MLP中的特征变换(MLP依旧是之后再说)。

nn.Conv2d

这是一个卷积层,是视觉任务的核心层。在这里会用一个自定义大小的窗口按照设置好的步长在原来的图片上滑动,通过进行矩阵计算,提取局部特征,获得的是一个特征信息的新矩阵。

1
nn.Conv2d(in_channels=3, out_channels=64, hernel_size=3, stride=1, padding=1)

in_channel就是输入通道数,对于RGB图像来说,每个像素点需要一个三元数对表示,所以相当于同一个位置有三个信息,输入通道为3;

out_channels就是输出通道,意味着我想要提取出几组信息。比如说我想把RGB图像提取两组特征信息,那就是每个像素点有两个特征数值。需要几个输出特征,就要几个卷积核进行扫描。

hernel_size就是卷积窗口大小,3就是3x3.

stride是卷积窗口滑动的步长。

padding是在图像的边缘补padding圈0.
原因是卷积窗口的一次计算只有一个特征值,那么如果原图像是x边长,卷积窗口是3x3,滑动步长是1,窗口只能滑动x-2次,也就是输出特征图的尺寸会比原图减小2,如果不希望特征图尺寸发生变化,就要在原图边缘补上一圈0,就可以保证计算的特征图尺寸和原图一样。

nn.MAXPool2d & nn.AvgPool2d

这是用来缩小尺寸的池化层。

1
nn.MaxPool2d(kernel_size=2, stride=2)

在每个区域里面取最大值或者平均值。上面是kernel_size=2, stride=2,结果就是尺寸减半。

MaxPool用于保留最显著特征,AvgPool是取平均值,让结果更加平滑,视觉任务一般选择MaxPool.

还有一个比较特殊的:

1
nn.AdaptiveAvgPool2d((1,1))

不管输入是什么尺寸,强行指定输出尺寸是(1, 1).

这个就是空间换信息。简单来说,卷积和池化都会导致尺寸改变。当卷积核尺寸大于1,步幅大于1,padding填充不能让尺寸保持不变,计算出的图像尺寸就会变小;池化对每个局部区域取最大值或者平均值,其结果就是尺寸缩小,同时保留重要信息。为什么一般是空间减半通道数翻倍呢,因为这样的话前后计算量接近,让特征图在不同层信息量大致均衡。

关于卷积和池化,这两个一般是搭配使用;kernel_size也可以不是正方形,比如指定kernel_size=(3, 5)就是一个长方形卷积核,不过长方形卷积核一般用于输入不对称的场景,比如音频处理。

nn.BatchNorm2d

归一化层。

1
nn.BatchNorm2d(num_features=64)

只有一个通道数,和上一层对齐即可。他做的事情是把一个batch内的数据拉回均值为0,方差为1的范围,把数据拉回这样的小区间方便训练。

在train/eval模式下行为不同。

activation

激活函数可以引入非线性操作,没有非线性不管堆多少层Linear,整个模型等价于一层Linear,完全没有意义。

1
2
3
4
5
6
7
8
9
nn.ReLU() # max(0,x) 负数归零

nn.LeakyReLU() # 负数给一个很小的斜率

nn.GELU() # 比ReLU更平滑,transformer常用

nn.Sigmoid() # 输出0/1,二分类

nn.Tanh() # -1 ~ 1

dimension

拿Convxd做例子,这个是和处理的数据有关系的。比如图像,他显然是一个2d输入,但是音频就是一个1d输入了。对于不同类型的输入,需要选择对应的维度。

layer input dimension kernel dimension situation
Conv1d (N, C, L) (C_out, C_in, kL) audio/time
Conv2d (N, C, H, W) (C_out, C_in, kH, kW) image
Conv3d (N, C, D, H, W) (C_out, C_in, kD, kH, kW) video, 3dmodel
ConvND (N, C, *spatial_dims) (C_out, C_in, *kernel_dims) ???

advanced layers

nn.TransformerEncoderLayer

可以实现attention机制,是transformer的核心组件。

1
nn.TransformerEncoderLayer(d_model=512, nhead=8, dim_feedforward=2048)

d_model是每个token的特征维度;

nhead是多头注意力的头数,d_model要能被nhead整除

dim_feedforward是内部FFN的隐藏层维度,一般是d_model的4倍。

attention和卷积的不同是,当有关联的上下文太长,卷积很难做到远距离依赖,但是attention可以做到让序列里几乎任意两个位置相互联系。当然一般也不会只用一层:

1
2
3
encoder_layer = nn.TransformerEncoderLayer(d_model=512, nhead=8)
transformer = nn.TransformerEncoder(encoder_layer, num_layers=6)
#实现了6层的编码器堆叠

nn.Dropout

随机关闭一些神经元,防止过拟合。

1
nn.Dropout(p=0.5)

p是归零的概率,只在训练中生效,意思为训练中随机50%神经元输出归零。
同样是traineval行为不同。


concatenate

然后我们可以自己整一个小的:

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
class SimpleNet(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(),
nn.MaxPool2d(2,2), # 尺寸减半

nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(2,2) # 尺寸再次减半
)
self.pool = nn.AdaptiveAvgPool2d((1,1))
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(64, num_classes)
)

def forward(self, x):
x = self.features(x)
x = self.pool(x)
x = self.classifier(x)
return x

Linear只能接受2维,Flatten是把[N,C,W,H]整理成[N,CxWxH],由于此处已经整理成(1, 1),所以结果就是[N,C].

然后用这个SimpleNet替换之前的Resnet试试:

print(model):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SimpleNet(
(features): Sequential(
(0): Conv2d(3, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(1): BatchNorm2d(32, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(2): ReLU()
(3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(4): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(5): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(6): ReLU()
(7): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
)
(pool): AdaptiveAvgPool2d(output_size=(1, 1))
(classifier): Sequential(
(0): Flatten(start_dim=1, end_dim=-1)
(1): Linear(in_features=64, out_features=10, bias=True)
)
)

summary(model, input_size(1, 3, 224, 224)):

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
==========================================================================================
Layer (type:depth-idx) Output Shape Param #
==========================================================================================
SimpleNet [1, 10] --
├─Sequential: 1-1 [1, 64, 56, 56] --
│ └─Conv2d: 2-1 [1, 32, 224, 224] 896
│ └─BatchNorm2d: 2-2 [1, 32, 224, 224] 64
│ └─ReLU: 2-3 [1, 32, 224, 224] --
│ └─MaxPool2d: 2-4 [1, 32, 112, 112] --
│ └─Conv2d: 2-5 [1, 64, 112, 112] 18,496
│ └─BatchNorm2d: 2-6 [1, 64, 112, 112] 128
│ └─ReLU: 2-7 [1, 64, 112, 112] --
│ └─MaxPool2d: 2-8 [1, 64, 56, 56] --
├─AdaptiveAvgPool2d: 1-2 [1, 64, 1, 1] --
├─Sequential: 1-3 [1, 10] --
│ └─Flatten: 2-9 [1, 64] --
│ └─Linear: 2-10 [1, 10] 650
==========================================================================================
Total params: 20,234
Trainable params: 20,234
Non-trainable params: 0
Total mult-adds (Units.MEGABYTES): 276.97
==========================================================================================
Input size (MB): 0.60
Forward/backward pass size (MB): 38.54
Params size (MB): 0.08
Estimated Total Size (MB): 39.22
==========================================================================================

当然最后结果跟resnet差远了


loss

之前说过训练就是让模型自己去对答案,根据自己的回答和答案差多少,来调整自己。

但是差多少要怎么度量,这就需要loss来判断。模型训练时,loss越小,则说明模型在当前训练集上的适应性越高,(理论上来说)这个模型就越正确(如果没有过拟合的话)。

一个模型在训练过程中,如果loss在降低,就说明他在学东西;如果loss很低又回升,就需要注意是不是模型在背答案了。

nn.CrossEntropyLoss

分类任务默认可以选择交叉熵。

1
2
criterion = nn.CrossEntropyLoss()
loss = criterion(output, y)

计算模型输出和实际真实值的差距。

output shape : [batch_size, num_classes]
y shape : batch_size

CrossEntropyLoss已经算过一次Softmax了

nn.MSELoss

回归和预测连续值的时候可以用这个。

1
2
criterion = nn.MSELoss()
loss = criterion(output, y)

计算均方差,预测值和真实值相差越大惩罚越重。

nn.BCEWithLogitsLoss

二分类或者多标签分类(一个样本可以属于多个类别)可用。

1
2
criterion = nn.BCEWithLogitsLoss()
loss = criterion(output, y.float())

多标签情况下,y shape : [batch_size, num_classes]


optimizer

loss计算出来”这次的回答有多离谱”,optimizer提供了修正模型的方式。

loss.backward()计算出每个参数对错误回答得贡献程度,也就是梯度。梯度是loss上升最快的方向,所以向反方向走就可以减小loss;参数具体朝反方向走多远(修改幅度多大)要看lr(learn rate).

SGD

SGD就是每次向梯度反方向走一步。

1
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)

momentum可以让更新方向受到上一次更新的影响(带上一点惯性),避免来回震荡。

SGD收敛很慢(显然),但是有些时候结果会意外的好(当然是“有些时候”)。

Adam

自适应学习率,对于每个参数单独维护一个lr,lr会根据历史梯度做调整。

1
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

对lr不很敏感(因为会调整),收敛快(因为会调整)。

AdamW

Adam的改进版,加入了权重衰减(weight decay)。

1
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)

AdamW修复了Adam中的小问题,fine-tune的时候更推荐使用这个。


extension4 : lr_scheduler

lr可以根据训练进度调整:

1
2
3
4
5
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)

for epoch in range(30):
train(...)
scheduler.step() #更新lr

StepLR是每个step_size个epoch,lr乘上gamma,gamma<1意味着后期精细收敛。

还有常用的scheduler:

1
2
3
4
5
# 余弦退火,lr按照cos从初始值降到0
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer,T_max=30)

# 监控验证集loss
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=3)

addition

这里是一个补充内容,在今年前几月看来,AI发展还是太快了。很多感慨可以在同期的很多篇文章里讲过,这里只说对本篇的影响。

首先,关于以上内容是否还具有很强的意义,我个人感觉见仁见智。毕竟在发展这么快的当下很多事情很难说出有把握的预测。这里补充一个可以同时证明AI发展很快和我对于pytorch需要学什么内容的质疑的合理性。

我在写完这篇不久之后就尝试用AI写出一个辅助学习的内容,也就是这个pytorch visual builder.

他具体实现了在网页端像搭积木一样对layer的拼接:

pvb 1

可以查看在假设输入格式的情况下每一层的输入和输出格式,可以在model store里面下载网上的一部分模型,这些模型拖入中间画布还会被分解成很多基础层方便查看和理解;可以在中间以图形化的方式修改输出结果用来适配指定任务:

pvb 2

还有训练设置,简单的界面设置等,可以更换主题颜色和背景,同时设定好的模型和训练可以在右上角点击导出:

pvb 3

当然还有其他就不讲了。

这东西完全是AI写的,而且总共也只用了十分钟不到。

所以要敬告各位,多关注AI的发展,不要让当下自己做的事情在未来变得毫无意义。

相关阅读:

Large Models From Here homepage

click here to homepage.