你醒啦,手术很成功喵
想想一下自己很久没有接触model, CNN, AI agent这些内容,但是最近自己突然由于各种原因需要重新接触这些内容,然后写出(或者是让AI写出)一个可以满足特定小需求的代码。
那当然是简洁,快速,不追求性能好不好,有个结果就可以,很符合我们今天的主题。
假如你现在处在这样的场景中,要做些什么才对呢?
environment
首先是确认自己的环境还能跑起来。
所幸第一步是非常简单的,我们有很多方法来配置当前所需要的环境,常见的环境管理方式有实际路径(当然是最不推荐的),python venv, conda, rye/uv.
后三个由于是虚拟环境,可以实现项目环境的隔离,不容易把环境整坏,而且也都可以通过requirement.txt/pyproject.toml实现环境的快速迁移和恢复,算是比较推荐的方式。我个人目前比较喜欢用rye来进行python环境的管理,这一部分内容在之前有写过,这里就不再说了,我这里都以rye作为默认python管理工具。
我们可以打开一个jupyter notebook,输入以下内容:
1 | import torch |
当然也默认是nvidia GPU
很简单,检查pytorch环境,以及GPU链路正常,保证torch可以正常(在GPU上)使用。
annoying syntax
这个确实是很让人生气的一环。我不可能每次都dir()+help()去查可恶的手册。不过这次我们相对聚焦于模型搭积木,那么相对来说要在短时间内记忆的东西不算多。
nn.module: 这个是搭积木的第一块。首先看以下格式:
1 | class MyModel(nn.Module): |
这是一个很简单的格式,我们可以通过在def __init__(self):下完成模型的构建,然后在def forward(self, x):处写出数据处理。
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 | for epoch in range(10): |
optimizer.zero_grad()意味着每次循环,先把上次的梯度清空,保证本次训练没有被之前的数据污染。
output = model(x)就是把数据x喂给模型,让它给你预计结果。
loss = criterion(output, y)让模型把自己计算出来的结果和真实结果对比,看看相差多少,怎么改进。
loss.backward()把错误分数往回推算,看看每个参数应该往什么方向调整(对于模型参数改进很重要)。
optimizer.step()执行调整。(上一步是算出调整方向,这次整的调整模型参数)
流程就是:清空->前向计算->计算loss->反向计算->更新参数。
训练的时候有两个坑,第一个是要清空梯度,不然训练结果会非常诡异;第二个是只做inference的时候最好加上一层:
1 | with torch.no_grad(): |
如果不加的话pytorch还会在后台自己算梯度,会浪费显存,数据大的时候还会OOM.
OOM:CUDA Out Of Memory.
simple routine
需要完整记住的东西就以上这么多,其他小细节可以在写模型的过程中得知。来看一个简单的例子:
packages
1 | import torch |
这里就是引入需要的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 | transform = transforms.Compose([ |
这里把数据整理成224x224,原因是之后用了resnet,需要数据是224x224才能喂进去,否则卷积池化之后特征图会变成负尺寸。具体什么模型需要什么输入,可以问AI.
train_dataset这一行,意味着获取数据集。root参数指的是数据存放路径,如果没有会创建文件夹;train=True是要下载训练集,如果是False就是下载测试集。
train_loader这里,DataLoader是把数据分批次,打乱顺序向外发送。
select model
1 | model = models.resnet18(weights=None) |
这里选择的模型是resnet18,weights=None的含义是不导入已有权重,随机初始化权重,也就是从头训练;如果是迁移训练,就给weights传入已有权重。
model.fc = nn.Linear(512, 10)含义为,本来的resnet18最后一层是nn.Linear(512, 1000),但是这个数据集只有10中数据类型,所以输出头换为1.
这个fc是哪来的呢?我怎么知道最后一层是什么?我们可以通过print(model)看到模型长这个样子:
1 | ResNet( |
看着挺吓人,但是其实结构还是挺清楚的。:前面的是该层的名字,后面是类似函数调用的表达式。
之后我们想要改哪一层就用哪个名字即可。比如我想要改第一层,第一层叫model.conv1,默认接收三通道RGB,但是我的图片是灰度图(假设),就把通道数改为1:
1 | mnodel.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding-3, bias=False) |
中间层一般不会改,如果要改的话不如从头自己搭(逃)
实际常见的改法除了直接修改输出结果类别数,还可以把最后改成多层,比如:
1 | model.fc = nn.Sequential( |
这就相当于是多加了一层。具体怎么加,可以往下多看一会,了解一下”某一层”的结构是什么,或者也可以print(model)之后把model结构给AI,让它教我们怎么改。
对于小数据集,还有一种操作是,冻结之前的所有层,只训练最后一层用于适配任务要求即可:
1 | for param in model.parameters(): |
也就是不计算所有层参数的梯度(当然也就不会更新模型参数),但是最后一层是之后修改的,默认是可训练的。
prepare train
1 | optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) |
经典Adam和CrossEntropyLoss,这两个在本期搭模型篇暂不解释。
train loops
1 | model.train() |
model.train()是切换到train mode. batchnorm, dropout这类层的行为会在不同mode下发生变化。
之后是一个双层循环,外层每跑完一次意味着训练所有数据被喂过一轮;内层每次循环都是取一个batch用作训练,直到训练集用完。
inference
1 | model.eval() |
推理部分,其实就是看看最后训练出来的model计算结果到底对不对。
model.eval()切换到eval mode,同时with torch.no_grad():暂时关闭梯度计算节省显存。
sample, label = next(iter(train_loader))的含义是取出一个batch的训练集来测试,这里是偷懒。当然正式应该是用测试集,也就是里面换成test_loader.
1 | test_dataset = CIFAR10(root="./data", train=False, download=True, |
省一点流量求求了
最后就是print的含义是取出测试batch的前5个结果。
argmax就是取分数最大的类别作为最后预测类别(dim=1, 就是沿着第一个维度取最大值;pred的shape是[32,10],32为样本数bath_size, 10是10个类别)。
extension1 : model archtecture
看模型结构,除了print(model),还有其他方式。
print只能看到层的名字和结构,看不到输出尺寸,局限性很大,这个适用于(大改的时候)你对模型比较熟,或者要求不很高只改最后一层就可以。
对于pytorch来说,有看参数的方式:
1 | total = sum(p.numel() for p in model.parameters()) |
但是这还是有点原始了。隔壁keras就有很现代model.summary看输出尺寸。
有的兄弟,有的!不过要额外下载:rye add torchinfo.
之后就可以通过:
1 | from torchinfo import summary |
这个输出和隔壁keras几乎一样。
1 | ========================================================================================== |
input_size(batch_size, channel, hight, width). 这个和隔壁的NHWC不一样,隔壁是通道数在最后。
可以稍微分析一下模型结构:
第一步是前四层:
1 | Conv2d → BatchNorm2d → ReLU → MaxPool2d |
这个其实是一个快速预处理,减小尺寸,减小计算量。
第二步是四组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 | from tqdm import tqdm |
其实只需要用tqdm(train_loader)把dataloader包一层就行。
desc是最左边的标题。leave的含义是每个epoch跑完之后进度条不消失;set_postfix是实时更新进度条右侧显示的内容,跑起来会让人感觉程序还在蠕动。
layers
之前我们讲述了什么是拿来主义,现在来看看搭建模型的第一种方式:如何自己从头自定义模型。
containerlayers
component layers
advanced layers
loss
optimizer
Large Models From Here homepage
click here to homepage.




