本文约1.1w字,全文阅读约花费20-30分钟。
本文适合:
- 深度学习和计算机视觉初学者: 对深度学习和计算机视觉领域有一定了解,希望通过实验加深理解的学生或自学者,本文提供一些实验过程中的技巧。
- 实验室研究人员和工程师: 在学术或者工业研究领域中,需要进行深度学习和计算机视觉实验的专业人员。
免责声明
- 本博客中的信息基于作者的个人经验和已公开的研究资料。虽然我们力求提供准确无误的资料,但我们不能保证全部内容完全无误,如有错误或遗漏,欢迎读者指正。
- 本博客提供的技巧和建议不保证在所有环境和数据集上均能获得相同的效果,实际效果可能根据具体情况而有所不同。
- 技术领域的快速发展意味着相关的实验技巧和工具会不断更新和迭代。本博客中的内容将根据可能的更新做适时调整,但我们不保证内容始终反映最新的技术状态。
本文从图像操作、文件操作、张量操作、模型训练和wandb的使用五个方面进行深度学习实验技巧的分享,最后通过一份自己写的CLIP训练代码来融汇所有实验技巧。
一、 图像操作
1. 图像加载与保存(推荐使用PIL函数库)
(1)加载:使用PIL.Image类中的类方法open,传入img_path作为参数。返回一个PIL Image类的对象。
(2)展示:对于PIL Image类对象img,通过img.imshow(img_name)的方法展示图片,img_name可以为任意字符串。
(3)保存:对于PIL Image类对象img,通过img.save(save_path)的方式保存图片。路径建议为绝对路径,否则会保存在与运行的py文件相同的路径下。
2. 图像格式转换(PIL/tensor/numpy之间的转换)
(1)PIL与tensor转换:使用torchvision.transforms包下面的ToTensor和ToPILImage类的默认__call__方法:
- PIL -> Tensor:img_tensor = ToTensor()(img) # 因为ToTensor和ToPILImage都是类,所以第一对小括号用来创建对象,第二对小括号用来传递图像参数。
- Tensor -> PIL:img = ToPILImage()(img_tensor)
(2)PIL与numpy转换:使用numpy库和PIL.Image类的方法。
- PIL -> numpy:img_numpy = np.asarray(img)
- Numpy -> PIL: img = Image.fromarray(np.uint8(img_numpy)).convert(‘RGB’)
(3)Numpy与tensor转换:使用torch库和tensor自带的功能即可
- numpy -> torch:img_torch = torch.from_numpy(img_numpy)
- Torch -> numpy:img_numpy = img_torch.detach().numpy()
(4)示例:PIL -> numpy -> tensor -> numpy -> PIL,需要注意numpy与tensor表示的数据在形状和维度上的区别(模型训练/推理过程中的维度与tensor为准)。
3. 图像维度变换:需要注意,一般神经网络模型要求的输入都是(B C H W)的,我们读入的PIL.Image对象(转numpy可看到维度为H W C)在经过ToTensor处理后会转变为(C H W)的,并且归一化到[0, 1.0],因此在经过DataLoader分批后,得到的数据为(B C H W),符合模型输入。
4. 图像预处理:基本使用torchvision.transforms库中的函数就可以完成操作,以下操作均针对tensor,如果数据为ndarray或者PIL.Image格式,需要首先经过transforms.ToTensor,之后在进行下述数据增强。
(1)随机放缩裁减:RandomResizedCrop,先根据scale参数进行裁剪,之后根据size进行缩放(size如果为二元组,则指定宽高,如果为int,则宽高都用这个值)
(2)中心裁减:CenterCrop(size)
(3)随机翻转:RandomHorizontalFlip(p=0.5)、RandomVerticalFlip(p=0.5)
(4)改变形状:Resize(size=(512, 512)),默认插值方式为InterpolationMode.BILINEAR(interpolation).
(5)归一化:
① Normalize(mean, std),使用均值和标准差归一化张量图像。此转换不支持PIL图像。
② 给定平均值(mean[1],…,mean[n]) 和标准差(std[1],…,std[n]) 对于 n 通道,该变换将归一化输入 torch.*Tensor 的每个通道,即output[channel] = (input[channel] - mean[channel]) / std[channel]。
③ 使用时一般设置mean和std为0.5,即transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]),每个0.5对应一个通道,可以将[0, 1.0]的数据归一化到[-1.0, 1.0]范围。(【0, 1】- 0.5 ) / 0.5 = 【-1, 1】 ,即取值范围规范到【-1, 1】。
# 定义一个transform序列
transform = transforms.Compose([
transforms.ToTensor(), # 将PIL.Image/ndarray数据转换为torch.Tensor
# ... 这里可以加入更多的transforms函数,比如
transforms.Resize((224, 224)), # 调整图像大小
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), # 标准化
# ...
])
# 应用transform
tensor_image = transform(pil_image)
二、 文件操作
1.文件夹删除:import shutil -> shutil.rmtree(dir_path),dir_path为文件夹的路径。要求安装shutil函数库,执行pip install shutilwhich指令安装。
2.文件夹创建:os.makedirs(dir_path),相比于makedir,makedirs会将路径上不存在的文件夹都创建出来。
3.判断路径是否存在:os.path.exists(path)
4.路径拼接:os.path.join(dir_path, file_name)
5.获取文件夹下所有文件名:os.listdir(dir_path)
6. 获取文件路径的文件名:filename = os.path.split(file_path)[-1]。split之后得到一个(dir_path, filename)元组。
7.判断文件名的后缀:extention = os.path.splitext(filename)[-1]。splitext之后得到一个(name, extention)元组,extention的形式为’.jpg’。
8.读取文件内容:以r模式open文件,之后readlines().
# 读取mapping文件的每一行,保存(prompt, img_name)二元组
with open(file_path, 'r') as f:
lines = f.readlines()
for line in lines:
ls = line.split(' ') # 得到分割后的list
img_name = ls[0]
ls.pop(0)
prompt_img_pairs.append((' '.join(ls)[:-1], img_name))
9.写入文件:以a/w模式open文件,之后write,换行需要加\n。
# 向文件中写入内容,每一行格式为:img_name prompt\n
f = open(os.path.join(matrix_dir, 'mapping.txt'), 'a')
with tqdm(prompt_img_pair) as bar:
bar.set_description("saving original image...")
for (prompt, img_path, img_name) in bar:
img = Image.open(img_path)
ori_img = img.resize((512, 512), Image.LANCZOS)
ori_img.save(os.path.join(img_dir, img_name)) # save
f.write(img_name + ' ' + prompt + '\n')
f.close()
三、张量操作
1.张量维度变换
(1)维度增加:unsqueeze(0)
(2)维度减小:squeeze(),不加参数会默认压缩掉所有为1的维度。
(3)维度转换:假设图片img为(1, 512, 512, 3),转化为(1, 3, 512, 512)
①tensor自带的permute(0, 3, 1, 2),numpy使用自带的transpose((0, 3, 1, 2))
②(推荐) einops库(pip install einops)中的rearrange():img = rearrange(img, ‘b h w c -> b c h w’).
(4)维度重复:einops库中的repeat():在灰度图转RGB图的场景下,img = repeat(img, ‘c h w -> (c m) h w’, m=3)。
2.张量设备和类型转换:
(1)(推荐) 使用张量的to方法,可以设置device和dtype。print(img_tensor.to(device="cuda", dtype=torch.float16))
(2)使用张量的.cuda()和.cpu()方法,实现设备便捷切换。
3.数据裁减:torch.clamp(tensor, 0, 1),将超出范围的值修剪到0-1.
4.数据填充:torch.. functional.pad(input, (0, 0, 0, 0, 2 * self.custom_padding, 0), mode=self.time_padding_mode,)
5.构建连续值张量:torch.arange(10) -> 生成[0, 1,...9]的连续张量。
6.张量拼接:torch.cat([tensor1, tensor2], dim = 1)。常用,保存图片便于观察差异。
四、模型训练相关
1. 后台训练:使用nohup与&。以下为test.sh后台训练脚本(在控制台输入bash test.sh命令运行),该脚本指定使用conda的sharedEnv环境下的python运行./CLIP_train.py的python代码(代码会在最后给出),将训练过程中的输出重定向(>)到./output.txt文件中,nohup与&启动后台训练。
#!/usr/bin/env bash
nohup /usr/local/anaconda3/envs/sharedEnv/bin/python3 -u ./CLIP_train.py > ./output.txt &
2. 关闭后台线程:使用top和nvidia-smi命令找到自己的线程,’kill -9 线程号’杀死即可。
3. Hugging face相关:
(1) 模型加载:from_pretrained(仓库地址 / 本地模型文件夹)
(2) 模型保存:save_pretrained(目标地址, safe_serialization=False),保存多个模型需要将不同模型放入不同文件夹,避免权重覆盖的问题。
4. Dataloader: 使用tqdm自己从dataloader中提取数据。
with tqdm(range(total_steps)) as bar:
bar.set_description('start training...')
for i in bar:
# 加载数据
video = next(iter(dataloader)).to(device=device, dtype=torch.float32) # torch.Size([1, 9, 3, 256, 256])
5. 多模型优化器:训练如CLIP模型,需要同时训练文本编码器和图像编码器从而实现特征空间的对齐,我们需要使用一个优化器优化两个网络的参数。
(1) 优化器使用torch.optim.AdamW或transformers.AdamW
(2) 设置模型参数:[{'params': textClip.parameters()}, {'params': visionClip.parameters()}]
optimizer = AdamW([{'params': textClip.parameters()}, {'params': visionClip.parameters()}], lr=lr)
6. lr_scheduler:通过lr_scheduler.step()对优化器的lr进行更新。
(1) 在训练过程中,需要在optimizer.step()后面调用lr_scheduler.step()。
(2) 可以使用transformers.get_linear_schedule_with_warmup
(3) 代码示例:创建一个lr_scheduler,设置optimizer作为其处理的对象。warm_up操作用来逐步提高lr,知道我们预设的lr,有助于提高训练过程的稳定性、防止过早过拟合。
# lr更新策略
total_steps = (len(dataset) // batch_size) * epoch # 总的step
warm_up_ratio = 0.1 # 定义要预热的step
lr_scheduler = get_linear_schedule_with_warmup(
optimizer,
num_warmup_steps=warm_up_ratio * total_steps,
num_training_steps=total_steps
)
7. model.train()和model.eval():
(1) 在训练模式(model.train)下,所有的正则化层(如Dropout
、BatchNorm
等)都会按照正常的操作进行,即Dropout
层会随机丢弃节点,BatchNorm
层会根据批量数据的统计信息更新其参数;而在评估模式下,所有的正则化层都会停止正常操作。例如,Dropout
层不再丢弃节点,BatchNorm
层会停止更新其参数,而是使用在训练过程中学到的参数(如均值和方差)。
(2)train是模型默认的模式,当你初始化一个模型时,或者在调用model.train()
之后,模型处于训练模式。调用model.eval()
会将模型设置为评估模式。
五、 Wandb训练过程记录与结果可视化
1. 简介:Weights & Biases (wandb) 是一种机器学习实验跟踪工具,它允许研究者和开发者记录和可视化他们的实验。wandb 旨在帮助团队和个人在复杂的机器学习工作流程中轻松跟踪实验、比较模型版本和分析结果。
2. 安装与登录
(1) 安装:pip install wandb
(2) 登录:wandb login -> 需要输入API key,在官网注册后获取(Weights & Biases (wandb.ai))
3. 训练记录(日志)保存:
(1) 科学上网:直接运行训练代码即可,训练结束/手动终止后会同步log到wandb网站。
(2) 无科学上网:需要先保存offline模型 -> 训练结束后需要使用wandb sync命令进行同步,在python训练代码中添加下面两个环境变量配置:
os.environ["WANDB_API_KEY"] = 'your_api_key' # API key,官网获取
os.environ["WANDB_MODE"] = "offline" # 离线日志
(3) 无科学上网时,训练结束/手动终止后,需要将保存的log文件夹下载到本机,之后使用‘wandb sync 文件夹’命令完成同步。
4. 常用函数
(1) wandb.config():用于保存训练配置,这些配置包含超参数、数据集名称或模型类型等输入设置,以及其他自变量。在使用的时候可以在wandb.init()中就进行对config的定义,这时候就能够直接对config内容进行输入。如果在init中没有设置conifg,那么就使用wandb.config.update()进行具体内容的输入。
# 定义config
config=dic(learing rate=0.1,batch size=2,epoch=50)
(2) wandb.init():初始化。参数:
① project:字符串,用于定义你的项目名称,wandb在运行的过程中会自动帮你创建一个项目文件,将所有项目名称相同的文件都放在一起。
② name:字符串,表示具体的名称,不写的话也可以,官方文档中说不定义具体名称的工程系统会自动使用两个随机单词进行命名,建议每次运行训练代码时都起一个新的name,比如exp1,exp2...。
③ conifg:配置信息,见wandb.config()。
④ resume:用于设置可恢复行为,通俗的理解就是当遇见意外中断时是否可继续。通常设置为默认None。
wb_test= wandb.init(project='project',
name='name',
config=config,
resume='None')
(3) wandb.log()和wandb.Image()
① wandb.log()会将数据记录到当前的历史记录。每次运行到这里,系统就会将log内的参数值自动上传更新,一般数据会直接绘制成表格。
② wandb.Image()用于图像的显示,会将ndarray数组或者PIL.Image实例转化为PNG,在网页上直接显示出来。
wandb.log({'loss': loss, 'epoch': epoch, 'learning rate': cur_lr,
'images': wandb.Image(images.float()),
'masks': {'true': wandb.Image(targets.float()),
'pred': wandb.Image(pred.float())}
})
5. 最佳实践:wandb一般嵌入到模型的训练阶段。
(1) 找到合适的地方进行wandb的初始化和config内容的设置。通常写在train/main函数里面.
(2) 数据显示部分要找到合适的位置,基本上放在train函数里面的for循环中就基本不会有太大问题了,最多就是在显示图片的时候仔细查看一下,输入的格式是否符合wandb.Image的需求,防止在使用的过程中报错。
(3) 只需要config/init/log即可完成所有参数保存操作。
六、 总结
最后通过一份CLIP_train.py的CLIP模型训练代码来融汇上述实验技巧。该训练代码基于Hugging-face的预训练CLIP文本编码器和视觉编码器,在coco2017数据集(CLIPDataset为我自己定义的数据集构造方法,因为不是重点,就没有列出来,需要的话可以私信)上进行全参训练。注意,该代码无法直接运行,仅作学习使用,希望运行代码的读者可以联系我获取完整项目。
在这篇文章中,我分享了我在深度学习和计算机视觉实验中积累的一些技巧和最佳实践。我希望这些内容能对你们的研究和开发工作提供帮助,无论你是刚刚入门的新手,还是寻求提升的资深研究员。
深度学习是一个快速发展的领域,新技术和方法层出不穷,而我自己的知识和经验也是有限的。可能在某些地方存在疏漏或者需要更新的信息。因此,我非常欢迎大家的建议和指正。如果你有任何想法或者经验想要分享,或者发现了文中的错误,请随时私信与我联系,让我们共同进步。
import os
import torch
import wandb
from transformers import CLIPTokenizer, CLIPImageProcessor, CLIPVisionModelWithProjection, CLIPTextModelWithProjection
from transformers import AdamW, get_linear_schedule_with_warmup
from torch.utils.data import DataLoader
from Dataset.CLIPDataset import CLIPDataset # 自己定义的数据集构造方法
from torch import nn
from tqdm import tqdm
# 配置offline模型 -> 训练结束后需要使用wandb sync命令进行同步
# os.environ["WANDB_API_KEY"] = 'your_api_key'
# os.environ["WANDB_MODE"] = "offline"
model_dir = './hugging-face-models/CLIP'
# model_dir = './Clip'
device = 'cuda'
# model
tokenizer = CLIPTokenizer.from_pretrained(model_dir) # 分词器
processor = CLIPImageProcessor.from_pretrained(model_dir) # 图片预处理器,调用其中的preprocess方法,在返回值中获取pixel_values
visionClip = CLIPVisionModelWithProjection.from_pretrained(model_dir).to(device) # 视觉编码器
textClip = CLIPTextModelWithProjection.from_pretrained(model_dir).to(device) # 文本编码器
# visionClip = CLIPVisionModelWithProjection.from_pretrained(os.path.join(model_dir, 'visionClip')).to(device)
# textClip = CLIPTextModelWithProjection.from_pretrained(os.path.join(model_dir, 'textClip')).to(device)
# 数据并行
# visionClip = nn.DataParallel(visionClip)
# textClip = nn.DataParallel(textClip)
# params
epoch = 10
lr = 5e-6
batch_size = 128
# wandb的config信息
config = dict(
dataset_id="coco2017-val",
learning_rate=lr,
epoch=epoch,
batch_size=batch_size
)
# data
dataset = CLIPDataset(preprocessor=processor) # 自己定义的数据集构造方法,可私信获得
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True, num_workers=8, drop_last=True)
# 优化器 -> 优化两个网络,其中的lr可以通过lr_scheduler分别调整
optimizer = AdamW([{'params': textClip.parameters()}, {'params': visionClip.parameters()}], lr=lr)
# 损失函数
loss = nn.CrossEntropyLoss().to(device)
# lr更新策略
total_steps = (len(dataset) // batch_size) * epoch # 总的step
warm_up_ratio = 0.1 # 定义要预热的step
lr_scheduler = get_linear_schedule_with_warmup(
optimizer,
num_warmup_steps=warm_up_ratio * total_steps,
num_training_steps=total_steps
)
if __name__ == '__main__':
# 初始化wandb
_wandb = wandb.init(
project='CLIP-training',
name='exp9', # 本次实验的名称
config=config,
resume='allow'
)
# 训练过程
for i in range(1, epoch + 1):
with tqdm(enumerate(dataloader), total=len(dataset) // batch_size) as bar:
bar.set_description("training epoch: {}".format(i))
for idx, (images, prompts) in bar:
# tokenizer
texts = tokenizer(prompts, padding="max_length",
max_length=tokenizer.model_max_length,
truncation=True,
return_tensors="pt").to(device)
images = images.to(device)
# embedding
t_embed = textClip(texts.input_ids, texts.attention_mask)[0] # (B, N) -> (16, 512)
i_embed = visionClip(images).image_embeds # (B, N) -> (16, 512)
# cosine similarity
cosine_sim = t_embed @ i_embed.T
# count loss
ground_truth = torch.arange(batch_size, dtype=torch.long, device=device)
total_loss = loss(cosine_sim, ground_truth)
# backward
optimizer.zero_grad()
total_loss.backward() # 计算损失
optimizer.step()
lr_scheduler.step() # 学习率更新
# 保存loss
wandb.log({'loss': total_loss.item(), 'epoch': i})
bar.set_postfix(total_loss=total_loss.item())
# 保存全部模型
textClip.save_pretrained('./Clip/textClip', safe_serialization=False)
visionClip.save_pretrained('./Clip/visionClip', safe_serialization=False)