目录
一:前言
二:先介绍v5源码中必须知道的一些文件(了解的可直接加入第三代码部分)
编辑
三:训练
参数配置
模式选择
搭建网络
加载预训练和自定义模型的参数
是否需要冻结层数
定义累计梯度的次数
设置优化组:权重,偏置,其他参数
为什么卷积层(不包含Batch Normalization层)的权重参数不需要进行权重衰减的参数
对于偏置项(bias)参数,通常不需要进行权重衰减的原因有以下几点
设置lr,epoch,32倍数(max(model.stride)),指数移动平均
准备开始训练
加载训练集的数据 dataloader
数据增强 __getitem__函数,
进行mosaic和对整合的大图再进行随机旋转、平移、缩放、裁剪
加载验证集的数据 testloader
调整分类损失权重
开始真正的训练
训练:Warmup 热身
Forward
每轮更新梯度和学习率
保存一轮中的前三批图片
更新EMA
保存训练last.pt模型
整个训练完成
一:前言
上一篇已经介绍了yolov5的脚本理论和改进的地方,这一章讲通俗易懂详细的解析每一行代码,过程有点漫长,如果你在阅读v5代码上有不懂的话,那么这篇肯定会帮助到你,希望大家能坚持看完。分为训练和预测部分
二:先介绍v5源码中必须知道的一些文件(了解的可直接加入第三代码部分)
V5简单的分为两部分
打开yolov5s.yaml文件即可看到backbone和head的参数
一直走到最后24层,所以作者把yolov5定义成25层
Yolov5的层结构,第0层是0-P1/2以此类推
1、 From:-1:表示该层的输入是从上一层过来的。而再看看第12层,他是【-1,6】说明是从第11层和第6层作为第12层的输入,
2、 Number:也就是neck的个数:例如第0层表示有1个focus层而第三层有3个BottleneckCSP层,但是这个number表示几并不 是说就有几个层,它是由depth_multiple: 0.33 (决定深度)。width_multiple: 0.50(决定通道数)
3、Args是传入的参数的信息:比如conv层,在common中,传入的参数是这四个(输出通道,卷积大小,步长,池化)
因为真正调用conv的时候,c1参数传进来的,不用在这个yaml里面定义
这里的注释很重要,但是写了注释会报错~可能是不能解析你在yaml写的东西了
经过五次卷积后,640*640变成了20*20,也就是下采样了32倍
C3是DarkNet53里面的CSP结构
C3是 DarkNet53 模型中的一个模块,它代表了 CSP (Cross Stage Partial) 结构。CSP 结构是一种特殊的残差连接结构,被用于构建深层的卷积神经网络。
那BottleneckCSP呢?
BottleneckCSP 也是一种特殊的模块,它结合了 Bottleneck 和 CSP 结构。在模型中,BottleneckCSP 用于替代常规的 Bottleneck 模块,以提供更强的特征表达和表示学习能力。
在锚框定义中,8*8对应着底层特征检测大物体,16*16检测中物体,32*32检测小物体
对比发现,其实5n,5l,5m,5s,5x的不同版本的yaml,其实就只有这两个参数设置不一样....只不过是变得更深,通道数更多了,缩放的倍数不一样。
这里拿v5的6.0来举个例子:比如这个C3,n=9说明有9个c3模块
本来是9个c3模块,但是因为深度才0.33倍,9*0.33=2.97,如果是熵取整了变成3个c3,如图可以看到,变成了三个通道
三:训练
参数配置
定义配置参数和Edit Configurations配置的文件和参数,我只有一块GPU很挺多参数都没有用到
parser = argparse.ArgumentParser()
parser.add_argument('--weights', type=str, default='yolov5s.pt', help='initial weights path')
parser.add_argument('--cfg', type=str, default='', help='model.yaml path') # 网络配置
parser.add_argument('--data', type=str, default='data/coco128.yaml', help='data.yaml path') # 数据
parser.add_argument('--hyp', type=str, default='data/hyp.scratch.yaml',
help='hyperparameters path') # 超参数文件用于定义模型的各种超参数,如学习率、权重衰减、数据增强等。
parser.add_argument('--epochs', type=int, default=300)
parser.add_argument('--batch-size', type=int, default=16, help='total batch size for all GPUs')
parser.add_argument('--img-size', nargs='+', type=int, default=[640, 640],
help='[train, test] image sizes') # 当使用argparse库解析命令行参数时,它会自动将命令行参数中的短横线转换为下划线,以便与Python中的变量命名规范保持一致。
parser.add_argument('--rect', action='store_true', help='rectangular training') # 矩形训练
parser.add_argument('--resume', nargs='?', const=True, default=False, help='resume most recent training') # 接着之前的训练
parser.add_argument('--nosave', action='store_true', help='only save final checkpoint') # 不保存
parser.add_argument('--notest', action='store_true', help='only test final epoch') # 不测试
parser.add_argument('--noautoanchor', action='store_true', help='disable autoanchor check') # 是否调整候选框
parser.add_argument('--evolve', action='store_true',
help='evolve hyperparameters') # 是否需要更新超参数,否则使用自己定义的超参数。即--进化算法优化
parser.add_argument('--bucket', type=str, default='',
help='gsutil bucket') # 一般用不上。用于指定gsutil的存储桶(bucket)。gsutil是一个用于与Google Cloud Storage进行交互的命令行工具,用于上传和下载文件。
parser.add_argument('--cache-images', action='store_true', help='cache images for faster training') # 缓存图片
parser.add_argument('--image-weights', action='store_true',
help='use weighted image selection for training') # 图片权重
parser.add_argument('--name', default='',
help='renames experiment folder exp{N} to exp{N}_{name} if supplied') # 好像是文件名?
parser.add_argument('--device', default='', help='cuda device, i.e. 0 or 0,1,2,3 or cpu')
parser.add_argument('--multi-scale', action='store_true', help='vary img-size +/- 50%%') # 是否多尺度训练
parser.add_argument('--single-cls', action='store_true', help='train as single-class dataset') # 是否一个类别
parser.add_argument('--adam', action='store_true', help='use torch.optim.Adam() optimizer') # 优化器选择
parser.add_argument('--sync-bn', action='store_true',
help='use SyncBatchNorm, only available in DDP mode') # 跨GPU的BN
parser.add_argument('--local_rank', type=int, default=-1, help='DDP parameter, do not modify') # GPU ID
parser.add_argument('--logdir', type=str, default='runs/', help='logging directory')
parser.add_argument('--workers', type=int, default=0, help='maximum number of dataloader workers') # windows的同学别改
opt = parser.parse_args()
设置单进程【0,-1】还是并行计算
# Set DDP variables WORLD_SIZE:进程数 RANK:进程编号
opt.total_batch_size = opt.batch_size
# WORLD_SIZE表示用于训练的进程数量,即分布式训练的总体规模。
# 如果环境变量中没有定义WORLD_SIZE,则默认为单机训练,即opt.world_size设置为1。
opt.world_size = int(os.environ['WORLD_SIZE']) if 'WORLD_SIZE' in os.environ else 1
# 如果环境变量中没有定义RANK,则默认将opt.global_rank设置为-1,表示当前进程不参与分布式训练或不需要全局标识符。
opt.global_rank = int(os.environ['RANK']) if 'RANK' in os.environ else -1
set_logging(opt.global_rank)
# 确保只有主进程(rank为0)或者单进程的情况下执行相关操作
if opt.global_rank in [-1, 0]:
# 检查yolov5仓库最近是否更新,更新了的话函数会打印出相应的提示信息
check_git_status()
是否延续上一次的训练 if opt.resume: (还是直接) 加载:定义配置好的参数和Edit Configurations配置的文件和参数
# Resume
if opt.resume: # resume an interrupted run 是否延续上一次的训练
# 传入模型的路径或者最后一次跑的模型(在runs中有last.pt)
ckpt = opt.resume if isinstance(opt.resume, str) else get_latest_run() # specified or most recent path
log_dir = Path(ckpt).parent.parent # runs/exp0
assert os.path.isfile(ckpt), 'ERROR: --resume checkpoint does not exist'
with open(log_dir / 'opt.yaml') as f:
opt = argparse.Namespace(**yaml.load(f, Loader=yaml.FullLoader)) # replace
opt.cfg, opt.weights, opt.resume = '', ckpt, True
logger.info('Resuming training from %s' % ckpt)
扩展图片确保有宽高。如果opt.img_size中只有一个元素(例如[416]),将[opt.img_size[-1]]乘以2-len(opt.img_size))。使用increment_dir是自增长模式,定义好存储训练所有中间结果和信息的文件目录
else: # 加载之前配置好的参数
# opt.hyp = opt.hyp or ('hyp.finetune.yaml' if opt.weights else 'hyp.scratch.yaml')
opt.data, opt.cfg, opt.hyp = check_file(opt.data), check_file(opt.cfg), check_file(opt.hyp) # check files
# 如果发现传入的模型文件为空的话产生报错信息
assert len(opt.cfg) or len(opt.weights), 'either --cfg or --weights must be specified'
# extend to 2 sizes (train, test)通过扩展 ,这次的图片没有变化。
# 但如果opt.img_size中只有一个元素(例如[416]),将[opt.img_size[-1]]乘以2-len(opt.img_size)),
# 可以将另一个相同的元素添加到opt.img_size列表中,以确保它的长度为2。
opt.img_size.extend([opt.img_size[-1]] * (2 - len(opt.img_size)))
# runs/exp1它会使用 increment_dir()(可以以增量的形式保存) 函数创建一个新的日志目录,作为实验结果的保存路径。
log_dir = increment_dir(Path(opt.logdir) / 'exp', opt.name)#increment_dir是自增长模式
定义device ,gpu
device = select_device(opt.device, batch_size=opt.batch_size)
加载超参数:
with open(opt.hyp) as f:
hyp = yaml.load(f, Loader=yaml.FullLoader)
判断是否使用“进化算法优化”,它可以帮你挑选出尽量好的超参数而不需要你手动去调,但是很依赖计算机资源,需要大量的时间来运作
if not opt.evolve:表示不使用,执行if语句使用定义好的超参数进行训练
# if not opt.evolve: 执行训练操作。不采用进化算法优化来更新超参数
# else: 程序将执行超参数搜索和突变的操作,即进化算法优化。
if not opt.evolve:
# 设置 Tensorboard 的日志记录器 (SummaryWriter)
tb_writer = None
if opt.global_rank in [-1, 0]:
logger.info(f'Start Tensorboard with "tensorboard --logdir {opt.logdir}", view at http://localhost:6006/')
tb_writer = SummaryWriter(log_dir=log_dir) # runs/exp0
# 通过runs生成events.out.tfevents.1685252255.LAPTOP - O2C6V881.21268.0文件,不知道是干嘛用的
train(hyp, opt, device, tb_writer)
加载 opt.epochs, opt.batch_size, opt.total_batch_size, opt.weights, opt.global_rank和 yaml.dump保存文件hyp.yaml和opt.yaml
定义随机种子init_seeds(2 + rank),一个进程的种子不会改变,只要是为了用在并行计算中!
logger.info(f'Hyperparameters {hyp}') # 训练时候参数,各epoch情况,损失,测试集的结果全部保存
log_dir = Path(tb_writer.log_dir) if tb_writer else Path(opt.logdir) / 'evolve' # logging directory
wdir = log_dir / 'weights' # weights directory
os.makedirs(wdir, exist_ok=True) # 保存路径
last = wdir / 'last.pt'
best = wdir / 'best.pt'
results_file = str(log_dir / 'results.txt') # 训练过程中各种指标
epochs, batch_size, total_batch_size, weights, rank = \
opt.epochs, opt.batch_size, opt.total_batch_size, opt.weights, opt.global_rank
# yaml.dump函数用来--保存当前配置文件的参数,在runs/exp下可查看hyp.yaml和opt.yaml,再pycharm打开可以查看文件的保存的信息
with open(log_dir / 'hyp.yaml', 'w') as f:
yaml.dump(hyp, f, sort_keys=False)
with open(log_dir / 'opt.yaml', 'w') as f: #
# 当你传递一个对象给 vars() 函数时,它会返回该对象所有可访问的属性和对应的值的字典。
yaml.dump(vars(opt), f, sort_keys=False)
获取文件路径
train_path = data_dict['train']
test_path = data_dict['val']
获取nc, 数据集的类别个数, names,锚框时候标注的标签名
# single_cls是一个布尔型参数,用于指示是否进行单类别(就只有1个,这里有“口罩”和“没有口罩”两种类别检测。
nc, names = (1, ['item']) if opt.single_cls else (int(data_dict['nc']), data_dict['names']) # number classes, names
# 判断标签类别个数是否与设置的nc相等
assert len(names) == nc, '%g names found for nc=%g dataset in %s' % (len(names), nc, opt.data) # check
模式选择
判断是否使用迁移学习,if pretrained: 没有预训练就使用自己定义的模型 。
但是即使使用了迁移学习,也还是需要调用自己定义的模型 model = Model(opt.cfg or ckpt['model'].yaml, ch=3, nc=nc).to(device),用来获取相同的模型的参数加载预训练权重:pretrained = weights.endswith('.pt')
ckpt = torch.load(weights, map_location=device) : 使用ckpt字典对象保存预训练模型的网络结构,以便在训练或推断过程中使用它们。
# Model
pretrained = weights.endswith('.pt')
if pretrained: # 有预训练模型的话,会自动下载,最好在github下载好 然后放到对应位置
# 与分布式训练相关
with torch_distributed_zero_first(rank):
# 如果本地不存在指定的权重文件,则会尝试从指定的链接(github上)
attempt_download(weights)
# load checkpoint #加载预训练模型后,使用ckpt字典对象来访问和预加载模型的权重和参数,以便在训练或推断过程中使用它们。
# map_location用于指定将模型加载到哪个设备上
ckpt = torch.load(weights, map_location=device)
# 如果超参数文件hyp中包含了锚框参数(anchors),则将其进行舍入操作(round函数)
if hyp.get('anchors'):
ckpt['model'].yaml['anchors'] = round(hyp['anchors']) # force autoanchor
# 初始化模型
# # opt.cfg加载的yolov5s.yaml和yolov5s.pt是一样的结构。类似迁移学习
#下次调用model就加入forward函数 两者都成立(非空),则使用opt.cfg作为模型的配置文件路径。否则,使用ckpt['model'].yaml作为模型的配置文件路径。
# opt.cfg or ckpt['model'].yaml 这两个yaml应该是一样的
model = Model(opt.cfg or ckpt['model'].yaml, ch=3, nc=nc).to(device)
exclude = ['anchor'] if opt.cfg or hyp.get('anchors') else [] # exclude keys
# 取出预训练模型的参数
state_dict = ckpt['model'].float().state_dict() # to FP32
# 判断预训练的参数跟自己Model定义的模型有多少个参数是相同的,"参数是相同的" 指的是预训练的模型和自己定义的模型中具有相同名称的参数。
state_dict = intersect_dicts(state_dict, model.state_dict(), exclude=exclude) # intersect
# 然后所有相同的参数加载进来
model.load_state_dict(state_dict, strict=False) # load
logger.info('Transferred %g/%g items from %s' % (len(state_dict), len(model.state_dict()), weights)) # report
# 如果没有提供权重文件,则创建一个新的模型对象。这样,可以在训练或推理阶段使用自己已有的权重进行初始化,或者从头开始训练一个新的模型。
else:
model = Model(opt.cfg, ch=3, nc=nc).to(device) # create 就是咱们之前讲的创建模型那块
搭建网络
进入model,使用parse_model:用来搭建yolov5的每一层。gw是处理通道数的,gd是处理网络层的深度(个数)的
# parse_model:用来搭建yolov5的每一层。
def parse_model(d, ch): # d:yolov5s.yaml,input_channels(3):[3]
# logger.info打印yaml的每一列的信息:分别是from n params module arguments
logger.info('\n%3s%18s%3s%10s %-40s%-30s' % ('', 'from', 'n', 'params', 'module', 'arguments'))
#
anchors, nc, gd, gw = d['anchors'], d['nc'], d['depth_multiple'], d['width_multiple']
# na表示anchors的数量。除2是因为每个锚框通常由两个值来定义,即它的宽度和高度。
na = (len(anchors[0]) // 2) if isinstance(anchors, list) else anchors
# 3个锚框*(类别个数(80个类别的概率)+(4个坐标值)+置信度(存在目标的概率)),每个anchors都会产生85列的输出信息
no = na * (nc + 5)
# layers存储下面搭建网络的每一层,save标志位:是否需要保存类似“残差”的那一层。因为不仅会传到下一层,而且会保存起来提供给后面需要concat的层
layers, save, c2 = [], [], ch[-1] # layers, savelist, ch out
n = max(round(n * gd), 1) if n > 1 else n 执行depth_multiple,不同版本yaml不一样的depth_multiple,最少是1层,如果你number是=三层而且gd=0.4 ,那么你的三层就会被缩小成1层,如果gd=1.4,那么你的number=3层就会变成4.2取整变成4层
# 以v6.0第一层为例:from:-1 number;1 module:conv args:[64,6,2,2]
for i, (f, n, m, args) in enumerate(d['backbone'] + d['head']): # from, number, module, args
# 如果 m 是字符串类型,则使用 eval 函数将其转换为相应的模块类型。因为传进来的仅仅是str类型的conv
m = eval(m) if isinstance(m, str) else m
#输入args是[64,3]但实际上是[64,3,1,1]后面两个是stride、padding默认是1
for j, a in enumerate(args):
try:
# 如果参数 a 是字符串类型,则使用 eval 函数将其转换为相应的数据类型。表示则直接返回a, a在这里是int类型:直接返回
args[j] = eval(a) if isinstance(a, str) else a
except:
pass
# 开始执行depth_multiple,不同版本yaml不一样的depth_multiple,最少是1层,如果你是三层而且gd=0.4,那么你的三层就会被缩小成1层,然后gd=1.4,那么你的3层就会变成4.2取整变成4层
n = max(round(n * gd), 1) if n > 1 else n # depth gain
定义第一层 c1, c2 = ch[f], args[0] c1是输入通道3,自己定义的 c2是yolov5s.yaml里面网络第一层的输出通道
c2 = make_divisible(c2 * gw, 8) if c2 != no else c2 首先,将 c2 乘以 gw(gw 是 width_multiple,用于调整输入输出通道数的宽度),这是为了根据比例调整通道数然后,使用 make_divisible 函数将调整后的通道数取最接近的可被 8 整除的数。这是为了确保计算效率,因为许多硬件加速库对于被 8 整除的通道数有更好的优化。
args = [c1, c2, *args[1:]] 拼接起来形成新的args,比如6.0版本的这里的args:【3,64,6,2,2】
self.info()打印:Model Summary: 191 layers, 7.25779e+06 parameters, 7.25779e+06 gradients
其含义是: 该模型有 191 层,7257790 个参数和 7257790 个梯度。
# 以v6.0第一层为例:from:-1 number;1 module:conv args:[64,6,2,2]
for i, (f, n, m, args) in enumerate(d['backbone'] + d['head']): # from, number, module, args
# 如果 m 是字符串类型,则使用 eval 函数将其转换为相应的模块类型。因为传进来的仅仅是str类型的conv
m = eval(m) if isinstance(m, str) else m
#输入args是[64,3]但实际上是[64,3,1,1]后面两个是stride、padding默认是1
for j, a in enumerate(args):
try:
# 如果参数 a 是字符串类型,则使用 eval 函数将其转换为相应的数据类型。表示则直接返回a, a在这里是int类型:直接返回
args[j] = eval(a) if isinstance(a, str) else a
except:
pass
# 开始执行depth_multiple,不同版本yaml不一样的depth_multiple,最少是1层,如果你是三层而且gd=0.4,那么你的三层就会被缩小成1层,然后gd=1.4,那么你的3层就会变成4.2取整变成4层
n = max(round(n * gd), 1) if n > 1 else n # depth gain
# 根据当前模块 m 是否属于 [Conv, Bottleneck, SPP, DWConv, MixConv2d, Focus, CrossConv, BottleneckCSP, C3] 这些类型之一,
if m in [Conv, Bottleneck, SPP, DWConv, MixConv2d, Focus, CrossConv, BottleneckCSP, C3]:
# 下面再elif之前的这里,都是对第一层的处理,无论是哪个版本的
# c1:输入通道 c2:输出通道
c1, c2 = ch[f], args[0] #f是yaml里面定义的,-1的话表示上一层做当输入
# Normal
# if i > 0 and args[0] != no: # channel expansion factor
# ex = 1.75 # exponential (default 2.0)
# e = math.log(c2 / ch[1]) / math.log(2)
# c2 = int(ch[1] * ex ** e)
# if m != Focus:
# 判断如果C2的通道数不等于no的个数,先c2*gw(width_multiple),然后再与8看看是不是8的倍数,考虑硬件计算效率 ,其中no = na * (nc + 5)
# 进而输出新的输出通道
c2 = make_divisible(c2 * gw, 8) if c2 != no else c2
# Experimental
# if i > 0 and args[0] != no: # channel expansion factor
# ex = 1 + gw # exponential (default 2.0)
# ch1 = 32 # ch[1]
# e = math.log(c2 / ch1) / math.log(2) # level 1-n
# c2 = int(ch1 * ex ** e)
# if m != Focus:
# c2 = make_divisible(c2, 8) if c2 != no else c2
# 拼接起来形成新的args,比如6.0版本的这里的args:【3,64,6,2,2】
args = [c1, c2, *args[1:]]
# 如果是BottleneckCSP或者C3层:
# 对于BottleneckCSP层来说:[-1, 3, BottleneckCSP, [128]]这里只传入了一个参数进来
if m in [BottleneckCSP, C3]:
# 类似于ResNet的设计
args.insert(2, n)
n = 1
elif m is nn.BatchNorm2d: # 如果模块类型是 BatchNorm2d,则使用输入通道数作为参数。
args = [ch[f]]
elif m is Concat: # 如果模块类型是 Concat,则计算要合并的输入通道数。
c2 = sum([ch[-1 if x == -1 else x + 1] for x in f])
elif m is Detect: # 如果模块类型是 Detect,则根据锚点和输入通道数设置参数。
args.append([ch[x + 1] for x in f])
if isinstance(args[1], int): # number of anchors
args[1] = [list(range(args[1] * 2))] * len(f)
else:
c2 = ch[f]
# 如果n大于1,也跟上面的BottleneckCSP层操作含义一样 ,n小于1直接使用单个模块 m。否则使用n个,n在上面已经乘以gd处理
#将该层的模型搭建好nn.Sequential, 第一focus是focus,输入是3,首先切片变成12通道,【64,3】64输出通道,3是kernel
m_ = nn.Sequential(*[m(*args) for _ in range(n)]) if n > 1 else m(*args) # module
# 获取模块的类型 如果有__main__,就用空字符串替换掉
# str(m)的结果可能类似于"<class '__main__.Conv'>",经过切片和替换操作后,得到的结果就是'Conv',即模块的类型名称。
t = str(m)[8:-2].replace('__main__.', '') # module type
# 使用 x.numel() 计算每一层的参数量,这里以0层为例,那就是第0层的参数量。 并将其累加得到总的参数数量。
np = sum([x.numel() for x in m_.parameters()]) # number params
# i:第几层的层索引 f:表示该层的输入是从上一层过来的 t:模块名字 np该层的参数量
m_.i, m_.f, m_.type, m_.np = i, f, t, np # attach index, 'from' index, type, number params
# 每一层打印一行信息,就是这些信息:m_.i, m_.f, m_.type, m_.np
logger.info('%3s%18s%3s%10.0f %-40s%-30s' % (i, f, n, np, t, args)) # print
# 指定哪些层需要保留,yaml里除了-1以外的都要保存
save.extend(x % i for x in ([f] if isinstance(f, int) else f) if x != -1) # append to savelist
## 最后,将构建的模块对象添加到 layers 列表中,并记录相应的信息(如索引、来源索引、类型和参数数量)。
layers.append(m_)
# 将输出通道数 c2 添加到通道列表 ch 中,用于后续层的构建。
# 第0层输出通道:[3,32], 第1层输出通道:[32, 6], 第2层输出通道:[32, 64, 64],每次遍历下一次的时候都调用c2 = ch[f],来获取上一层的通道
ch.append(c2)
# 因为yaml文件里面没有配置ch,所以使用的参数自定义的ch=3,相当于在yaml中转化后的字典中又添加了一个键值对,为ch(输入通道):3,[ch]表示以列表形式传入
self.model, self.save = parse_model(deepcopy(self.yaml), #先深度拷贝一份,可能是为了可读性吧,不拷贝感觉结果是一样的
ch=[ch]) # 返回的结果是:# model(你的模型), savelist:[4,6,10,14,17,20,23]需要做cat的层
# print([x.shape for x in self.forward(torch.zeros(1, ch, 64, 64))])
# Build strides, anchors
# 求网络的步长,以及对anchors的处理。# 获取最后一层 Detect()
m = self.model[-1]
# 最后一层是Detect模块,看yaml文件就知道了
if isinstance(m, Detect):
s = 128 # 2x min stride
# torch.zeros(1, ch, s, s):新建了一张空白的图片,ch三通道的,这里是128*128的。 而v6.0是256*256的。传进去整个模型中,进行了forward
# v6.0的网络图看的出来,Detect大物体的的网络是结果了三次conv的,也就是下采样了8倍,256/8=32。我们看得出来,但是这个模型它不知道看不出来。
# 所以需要构建一张空白的图片进行前向传播,它就知道到达大物体预测的时候图片的大小是32,用256/32,得出m.stride是8 含有16,32
m.stride = torch.tensor([s / x.shape[-2] for x in self.forward(torch.zeros(1, ch, s, s))]) # forward ([ 8., 16., 32.])
# 因为定义的锚框大小是相对于原始图片的大小,但是训练的时候用到的anchors是在最终的低中高特征图上使用这个anchors的,
# 比如这个大物体,缩小了八倍,则定义的anchors也必须缩小八倍,才能有效的进行训练
m.anchors /= m.stride.view(-1, 1, 1)
# 判断锚框的判断低中高的有没有写反,写反的话check_anchor_order会帮你修复
check_anchor_order(m)
self.stride = m.stride #用于初始化模型中的偏置项(biases)。
self._initialize_biases() # only run once某些初始化操作只需要在模型创建或实例化的时候运行一次,以确保模型的参数和状态被正确初始化。
# print('Strides: %s' % m.stride.tolist())
# Init weights, biases
# 对网络的初始化,以及日志的打印
initialize_weights(self)
self.info()
print('')
加载预训练和自定义模型的参数
退出model
state_dict = ckpt['model'].float().state_dict() # 取出预训练模型的参数
state_dict = intersect_dicts(state_dict, model.state_dict(), exclude=exclude) 判断预训练的参数跟自己Model定义的模型有多少个参数是相同的(一些层相同)
model.load_state_dict(state_dict, strict=False) # 然后所有相同的参数加载进来
然后就会打印 : Transferred 362/370 items from yolov5s.pt
输出:Transferred 362/370 items from yolov5s.pt 表示从 yolov5s.pt 文件中转移了 362 个项目
# 初始化模型
# # opt.cfg加载的yolov5s.yaml和yolov5s.pt是一样的结构。类似迁移学习
#下次调用model就加入forward函数 两者都成立(非空),则使用opt.cfg作为模型的配置文件路径。否则,使用ckpt['model'].yaml作为模型的配置文件路径。
# opt.cfg or ckpt['model'].yaml 这两个yaml应该是一样的
model = Model(opt.cfg or ckpt['model'].yaml, ch=3, nc=nc).to(device)
exclude = ['anchor'] if opt.cfg or hyp.get('anchors') else [] # exclude keys
# 取出预训练模型的参数
state_dict = ckpt['model'].float().state_dict() # to FP32
# 判断预训练的参数跟自己Model定义的模型有多少个参数是相同的,"参数是相同的" 指的是预训练的模型和自己定义的模型中具有相同名称的参数。
state_dict = intersect_dicts(state_dict, model.state_dict(), exclude=exclude) # intersect
# 然后所有相同的参数加载进来
model.load_state_dict(state_dict, strict=False) # load
logger.info('Transferred %g/%g items from %s' % (len(state_dict), len(model.state_dict()), weights)) # report
# 如果没有提供权重文件,则创建一个新的模型对象。这样,可以在训练或推理阶段使用自己已有的权重进行初始化,或者从头开始训练一个新的模型。
else:
model = Model(opt.cfg, ch=3, nc=nc).to(device) # create 就是咱们之前讲的创建模型那块
是否需要冻结层数
# 比如Freeze是10,相当于把前10冻结,也就是这里的backbone,相当于只训练了head部分,冻结backbone部分,
# 所以当你必须改变某几层的参数的时候,就可以使用这种冻结的方法
freeze = ['', ] # parameter names to freeze (full or partial)
if any(freeze):
for k, v in model.named_parameters():
if any(x in k for x in freeze):
print('freezing %s' % k)
v.requires_grad = False
定义累计梯度的次数
nbs = 64 累计多少次更新一次模型,就是64/16=4次,从16张变成一次处理64张图片(会累积读取四次才有64张,然后再一起计算梯度)
权重衰减通常与需要更新的数据量多少有关,假设total_batch_size是16,此时nbs = 64 ,total_batch_size * accumulate/ nbs=1对于权重衰减来说是没有变化的
但是当total_batch_size是128,此时nbs = 64 。 total_batch_size * accumulate/ nbs=2,此时权重衰退也是之前的两倍
# Optimizer
# 累计多少次更新一次模型,就是64/16=4次,从16张变成一次处理64张图片(会累积读取四次才有64张,然后再一起计算梯度)
nbs = 64
# accumulate loss before optimizing round() 方法返回浮点数x的四舍五入值。累计梯度的次数
accumulate = max(round(nbs / total_batch_size), 1)
#权重衰减太通常与需要更新的数据量多少有关,假设total_batch_size是16,total_batch_size * accumulate/ nbs=1对于权重衰减来说是没有变化的
#但是当total_batch_size是128,total_batch_size * accumulate/ nbs=2,此时权重衰退也是之前的两倍
#total_batch_size * accumulate / nbs是权重衰退的缩放因子
hyp['weight_decay'] *= total_batch_size * accumulate / nbs
设置优化组:权重,偏置,其他参数
对于pg0参数组,使用了optim.SGD优化器进行参数更新。optim.SGD使用随机梯度下降(Stochastic Gradient Descent)算法来更新参数。具体更新的方式如下: 1 、计算参数的梯度:通过计算损失函数对于参数的梯度,获取参数的梯度信息。 2 、应用动量:使用动量(momentum)来加速参数更新过程。动量可以看作是参数更新的惯性,有助于在参数空间中跨越平坦区域和局部最小值。 3 、更新参数:根据学习率(lr)和动量,按照梯度的方向和大小来更新参数的数值。新的参数值等于当前参数值减去学习率乘以梯度。 4 、应用Nesterov动量:如果设置了Nesterov动量(nesterov=True),则在更新参数之前,先使用当前参数值加上动量项乘以学习率的修正项。这个修正项是基于参数在动量方向上的一个预测值。 对于pg1参数组,由于通过optimizer.add_param_group添加到了优化器中,并设置了权重衰减(weight decay)参数hyp['weight_decay'],因此参数的更新方式中还包括权重衰减的影响。具体的更新方式和pg0类似,但额外考虑了权重衰减项,即在更新参数时会减去一个权重衰减的部分。 pg2参数组,因为只是偏置常数,所以并没有设置额外的超参数,因此它的参数更新方式与pg0类似,但没有应用权重衰减。
为什么卷积层(不包含Batch Normalization层)的权重参数不需要进行权重衰减的参数
1 Batch Normalization(BN)层通常用于规范化输入数据并加速训练过程,并不是更新权重的层。BN层包含了可训练的缩放因子和偏置项参数,它们也参与了模型的优化过程。
由于BN层的参数通常具有较小的尺度,并且在训练过程中被规范化,所以通常不需要额外的权重衰减。
对于偏置项(bias)参数,通常不需要进行权重衰减的原因有以下几点
1 偏置项的作用:偏置项是神经网络中的常数参数,用于调整神经元的激活阈值,使其更适应输入数据。偏置项的作用是引入模型的偏移能力,即在输入数据没有明显偏移时,通过调整偏置项可以使模型能够更好地适应数据。
2 参数数量:偏置项通常只有一个参数,而权重参数的数量通常是相对较大的。由于偏置项的数量较少,即使不进行权重衰减,对模型整体的复杂度和过拟合风险的影响较小。
3 参数更新方式:在优化算法中,偏置项的更新方式与权重参数略有不同。通常,权重参数的更新会考虑梯度和学习率等因素,而偏置项的更新只与学习率相关。因此,对偏置项进行权重衰减可能对优化算法的性能和收敛速度产生不利影响。
pg0, pg1, pg2 = [], [], []
for k, v in model.named_parameters():
v.requires_grad = True
#因为,权重衰退一般只 作用于卷积层的w参数,不需要作用于其他参数,所以必须对卷积层的所有w(不包含BN层的)进行权重衰减,
if '.bias' in k:
pg2.append(v) # bias
# 如果不是BN层但是又有w的话,也就是所有卷积的w,放到pg1中
elif '.weight' in k and '.bn' not in k:
pg1.append(v) # apply weight decay
else:
# BN层的w
pg0.append(v) # all else
# 因为,权重衰退一般只 作用于卷积层的w参数,不需要作用于其他参数,所以必须对卷积层的所有w(不包含BN层的)进行权重衰减,
if opt.adam: # 优化器与学习率衰减
# pg0被传递给优化器作为需要进行权重更新的参数列表。传递pg0的目的是为了将不受权重衰减影响的参数从权重衰减的处理中排除,以避免对这些参数应用额外的惩罚。
optimizer = optim.Adam(pg0, lr=hyp['lr0'], betas=(hyp['momentum'], 0.999)) # adjust beta1 to momentum
else:
#没有指定,则默认使用SGD,这里表示要优化的变量是pg0
# Batch Normalization(BN)层通常用于规范化输入数据并加速训练过程。BN层包含了可训练的缩放因子和偏置项参数,它们也参与了模型的优化过程。
# 由于BN层的参数通常具有较小的尺度,并且在训练过程中被规范化,所以通常不需要额外的权重衰减。
optimizer = optim.SGD(pg0, lr=hyp['lr0'], momentum=hyp['momentum'], nesterov=True)
# 在上述代码中,pg0、pg1和pg2是不同的参数组。它们分别包含了不同的模型参数。这些参数组使用了不同的超参数设置,以控制它们在优化过程中的更新方式。
# 权重衰减(weightdecay)是一种常用的正则化技术。它通过在损失函数中添加一个正则化项,鼓励模型的权重参数保持较小的值,从而降低过拟合的风险。
optimizer.add_param_group({'params': pg1, 'weight_decay': hyp['weight_decay']}) # add pg1 with weight_decay
optimizer.add_param_group({'params': pg2}) # add pg2 (biases)
#len(pg1)是需要进行权重衰减的w参数的个数
# len(pg0)是不需要权重衰减的w参数的个数
# (len(pg2)是b的个数
logger.info('Optimizer groups: %g .bias, %g conv.weight, %g other' % (len(pg2), len(pg1), len(pg0)))
# 删除这些对象的目的是在优化器对象被创建后,不再需要对它们的引用,因此可以通过删除它们来释放内存并提高代码的效率。减少不必要的开销
del pg0, pg1, pg2
设置lr,epoch,32倍数(max(model.stride)),指数移动平均
# 获取模型的最高层的特征相较于原始输入图片多少倍, 是32倍
gs = int(max(model.stride))
# 检查输入图片的尺寸满不满足32的倍数,如果不满足,则会帮你补成32的倍数作为输入图片的大小
imgsz, imgsz_test = [check_img_size(x, gs) for x in opt.img_size] # verify imgsz are gs-multiples
# DP mode 如果你的机器里面有过个GPU,需要改一些参数。官网教程:https://github.com/ultralytics/yolov5/issues/475
if cuda and rank == -1 and torch.cuda.device_count() > 1:
# 如果多卡,使用数据并行化的操作
model = torch.nn.DataParallel(model)
# SyncBatchNorm 多卡同步做BN
if opt.sync_bn and cuda and rank != -1:
model = torch.nn.SyncBatchNorm.convert_sync_batchnorm(model).to(device)
logger.info('Using SyncBatchNorm()')
# EMA 模型 Exponential moving average 滑动平均能让参数更新的更平滑一点不至于波动太大
# 参考博客:https://www.jianshu.com/p/f99f982ad370
# EMA可以在训练过程中使用移动平均参数来评估模型性能,以获得更稳定的预测结果。
ema = ModelEMA(model) if rank in [-1, 0] else None
# DDP mode 多机多卡,有时候DP可能会出现负载不均衡,这个能直接解决该问题。DP用的时候 经常ID为0的GPU干满,其他的没咋用
if cuda and rank != -1:
model = DDP(model, device_ids=[opt.local_rank], output_device=opt.local_rank)
准备开始训练
加载训练集的数据 dataloader
# augment: 是否进行数据增强。如果设置为True,将应用数据增强技术来扩充训练数据集。 # cache: 是否缓存图像。如果设置为True,在加载图像后会将其缓存,以提高训练效率。 # rect: 是否使用矩形训练。如果设置为True,将使用矩形框选区域的方式对图像进行训练。 # rank: 当前进程的排名(通常用于分布式训练)。它指示当前进程在所有进程中的位置。 # world_size: 训练的总进程数(通常用于分布式训练)。它指示训练中总共有多少个进程。
# 加载训练集的数据
dataloader, dataset = create_dataloader(train_path,
imgsz,
batch_size,
gs,
opt,
hyp=hyp,
augment=True,
cache=opt.cache_images,
rect=opt.rect, # 矩形训练
rank=rank,
world_size=opt.world_size,
workers=opt.workers)
def create_dataloader(path, imgsz, batch_size, stride, opt, hyp=None, augment=False, cache=False, pad=0.0, rect=False,
rank=-1, world_size=1, workers=8):
# Make sure only the first process in DDP process the dataset first, and the following others can use the cache.
with torch_distributed_zero_first(rank):
dataset = LoadImagesAndLabels(path, imgsz, batch_size,#LoadImagesAndLabels函数加载数据集
augment=augment, # augment images
hyp=hyp, # augmentation hyperparameters
rect=rect, # rectangular training
cache_images=cache,
single_cls=opt.single_cls,
stride=int(stride),
pad=pad,
rank=rank)
进入class LoadImagesAndLabels(Dataset)
该类继承pytorch的Dataset类,需要实现父类的__init__方法, __getitem__方法和__len__方法, 在每个step训练的时候, DataLodar迭代器通过__getitem__方法获取一批训练数据。
数据增强 __getitem__函数,
def __getitem__(self, index):
if self.image_weights:
index = self.indices[index]
hyp = self.hyp
#训练时采用mosaic数据增强方式,load_mosaic将随机选取4张图片组合成一张图片,输出的img size为self.img_size*self.img_size,
# 如 640*640,参见load_mosaic函数解读
mosaic = self.mosaic and random.random() < hyp['mosaic']
if mosaic:
# Load mosaic
img, labels = load_mosaic(self, index)
shapes = None
# 重新选取4张图像mosaic增强,将mosaic增强后的图像与之前mosaic增强后的数据进行mixup数据增强。
# MixUp https://arxiv.org/pdf/1710.09412.pdf
if random.random() < hyp['mixup']:
img2, labels2 = load_mosaic(self, random.randint(0, len(self.labels) - 1))
r = np.random.beta(8.0, 8.0) # mixup ratio, alpha=beta=8.0
img = (img * r + img2 * (1 - r)).astype(np.uint8)
labels = np.concatenate((labels, labels2), 0)
else:
# Load image
img, (h0, w0), (h, w) = load_image(self, index)
# 在推理时,单张图片推理,yolov5使用自适应图片缩放的方式,减少填充的黑边以减少计算量。
# Letterbox shape = self.batch_shapes[self.batch[index]] if self.rect else self.img_size # final letterboxed shape
# 在__init__函数讲解中有介绍过self.batch_shapes里面存放的是每个batch最终输入网络的图像的shape,这个shape是已经经过计算补充了无效像素的shape。
# Letterbox 具体实现自适应缩放过程,scaleup控制是否向上缩放(即放大图片)参见letterbox函数解读。
shape = self.batch_shapes[self.batch[index]] if self.rect else self.img_size # final letterboxed shape
img, ratio, pad = letterbox(img, shape, auto=False, scaleup=self.augment)
shapes = (h0, w0), ((h / h0, w / w0), pad) # for COCO mAP rescaling
# Load labels
labels = []
x = self.labels[index]
if x.size > 0:
# Normalized xywh to pixel xyxy format
labels = x.copy()
# img缩放与补黑边后,label的框需要适配,xywhn2xyxy是将标注的label中归一化的xywh中心点 + 宽高 -> xyxy左上角 + 右下角坐标,再加上补边的偏移。
labels[:, 1] = ratio[0] * w * (x[:, 1] - x[:, 3] / 2) + pad[0] # pad width
labels[:, 2] = ratio[1] * h * (x[:, 2] - x[:, 4] / 2) + pad[1] # pad height
labels[:, 3] = ratio[0] * w * (x[:, 1] + x[:, 3] / 2) + pad[0]
labels[:, 4] = ratio[1] * h * (x[:, 2] + x[:, 4] / 2) + pad[1]
# 随机裁剪缩放等数据增强。 马赛克和随即增强选一种方法
if self.augment:
# Augment imagespace
if not mosaic: #这个之前在mosaic方法最后做过了
img, labels = random_perspective(img, labels,
degrees=hyp['degrees'],
translate=hyp['translate'],
scale=hyp['scale'],
shear=hyp['shear'],
perspective=hyp['perspective'])
# Augment colorspace h:色调 s:饱和度 V:亮度
# augment_hsv函数将图像转到HSV空间进行数据增强,再转回BGR
augment_hsv(img, hgain=hyp['hsv_h'], sgain=hyp['hsv_s'], vgain=hyp['hsv_v'])
# Apply cutouts
# if random.random() < 0.9:
# labels = cutout(img, labels)
nL = len(labels) # number of labels
if nL: #1.调整标签格式 2.归一化标签取值范围
labels[:, 1:5] = xyxy2xywh(labels[:, 1:5]) # convert xyxy to xywh
labels[:, [2, 4]] /= img.shape[0] # normalized height 0-1
labels[:, [1, 3]] /= img.shape[1] # normalized width 0-1
if self.augment:#要不要做翻转操作
# flip up-down
if random.random() < hyp['flipud']:
img = np.flipud(img)
if nL:
labels[:, 2] = 1 - labels[:, 2]
# flip left-right
if random.random() < hyp['fliplr']:
img = np.fliplr(img)
if nL:
labels[:, 1] = 1 - labels[:, 1]
labels_out = torch.zeros((nL, 6))
if nL:
labels_out[:, 1:] = torch.from_numpy(labels)
# Convert
img = img[:, :, ::-1].transpose(2, 0, 1) # BGR to RGB, to 3x416x416 要满足pytorch的格式
img = np.ascontiguousarray(img)
return torch.from_numpy(img), labels_out, self.img_files[index], shapes
进行mosaic和对整合的大图再进行随机旋转、平移、缩放、裁剪
先初始化标注列表为空,然后获取图像尺寸s 假设模型输入尺寸为s,生成一幅尺寸为2s * 2s的灰色图, 从点A(s/2, s/2)和点B(3s/2, 3s/2)限定的矩形内随机选择一点作为拼接点, self.mosaic_border = [-img_size // 2, -img_size // 2] #限定范围
1.创建一个大小为 (s * 2, s * 2, img.shape[2]) 的多维数组 img4,用来存储拼接后的大图像,用114灰色当背景, 当小图小于当前图片放在大图中位置上, 2.计算当前图片放在大图中什么位置,对于左上角,右小角的点最大不能超过大图的中心点(xc,yc) 3.计算在小图中取哪一部分放到大图中,因为可能小图大小超出了(x1a, y1a, x2a, y2a)应该放在的位置,那就选取一部分放进去,
def load_mosaic(self, index):
# loads images in a mosaic
labels4 = []
# 先初始化标注列表为空,然后获取图像尺寸s
s = self.img_size
# 假设模型输入尺寸为s,生成一幅尺寸为2s * 2s的灰色图, 从点A(s/2, s/2)和点B(3s/2, 3s/2)限定的矩形内随机选择一点作为拼接点,代码如下
yc, xc = [int(random.uniform(-x, 2 * s + x)) for x in self.mosaic_border] # mosaic center x, y
indices = [index] + [random.randint(0, len(self.labels) - 1) for _ in range(3)] # 3 additional image indices
for i, index in enumerate(indices):
# Load image
img, _, (h, w) = load_image(self, index)
# place img in img4
if i == 0: # top left 1.创建一个大小为 (s * 2, s * 2, img.shape[2]) 的多维数组 img4,用来存储拼接后的大图像。
# 用114灰色当背景, 当小图小于当前图片放在大图中位置上,
img4 = np.full((s * 2, s * 2, img.shape[2]), 114, dtype=np.uint8) # base image with 4 tiles
# .计算当前图片放在大图中什么位置,对于左上角,右小角的点最大不能超过大图的中心点(xc,yc)
x1a, y1a, x2a, y2a = max(xc - w, 0), max(yc - h, 0), xc, yc
# 计算在小图中取哪一部分放到大图中,因为可能小图大小超出了(x1a, y1a, x2a, y2a)应该放在的位置,那就选取一部分放进去,
x1b, y1b, x2b, y2b = w - (x2a - x1a), h - (y2a - y1a), w, h
elif i == 1: # top right
x1a, y1a, x2a, y2a = xc, max(yc - h, 0), min(xc + w, s * 2), yc
x1b, y1b, x2b, y2b = 0, h - (y2a - y1a), min(w, x2a - x1a), h
elif i == 2: # bottom left
x1a, y1a, x2a, y2a = max(xc - w, 0), yc, xc, min(s * 2, yc + h)
x1b, y1b, x2b, y2b = w - (x2a - x1a), 0, w, min(y2a - y1a, h)
elif i == 3: # bottom right
x1a, y1a, x2a, y2a = xc, yc, min(xc + w, s * 2), min(s * 2, yc + h)
x1b, y1b, x2b, y2b = 0, 0, min(w, x2a - x1a), min(y2a - y1a, h)
截图小图中的部分放到大图中 2.由于小图可能填充不满,所以还需要计算差异值,因为一会要更新坐标框标签
img4[y1a:y2a, x1a:x2a] = img[y1b:y2b, x1b:x2b] # img4[ymin:ymax, xmin:xmax]
padw = x1a - x1b
padh = y1a - y1b
Labels 标签值要重新计算Z(更新为大图中坐标,主要标签的坐标就变成相相对于大图的坐标了 由于大图和小图的尺寸不一致,小图块可能无法完全填充大图对应位置的区域。这会导致小图块在大图中的位置发生偏移。
x = self.labels[index]
labels = x.copy()
if x.size > 0: # Normalized xywh to pixel xyxy format
labels[:, 1] = w * (x[:, 1] - x[:, 3] / 2) + padw
labels[:, 2] = h * (x[:, 2] - x[:, 4] / 2) + padh
labels[:, 3] = w * (x[:, 1] + x[:, 3] / 2) + padw
labels[:, 4] = h * (x[:, 2] + x[:, 4] / 2) + padh
labels4.append(labels)
# Concat/clip labels 坐标计算完之后可能越界,调整坐标值,让他们都在大图中
if len(labels4):
labels4 = np.concatenate(labels4, 0)
np.clip(labels4[:, 1:], 0, 2 * s, out=labels4[:, 1:]) # use with random_perspective
# img4, labels4 = replicate(img4, labels4) # replicate
# Augment 对整合的大图再进行随机旋转、平移、缩放、裁剪
# 随机选择四张图,取其部分拼入该图,如下图所示,四种颜色代表四张样本图,超出的部分将被舍弃
img4, labels4 = random_perspective(img4, labels4,
degrees=self.hyp['degrees'],
translate=self.hyp['translate'],
scale=self.hyp['scale'],
shear=self.hyp['shear'],
perspective=self.hyp['perspective'],
border=self.mosaic_border) # border to remove
return img4, labels4
每个轮次的批次数(nb)。 nb = len(dataloader)
设置断言:
而如果数据集中的标签类别为0、1、2,而模型的配置中设置的类别数目 nc 仍然是2。,最大的标签类别是2,不小于预设的类别数目。
assert mlc < nc, 'Label class %g exceeds nc=%g in %s. Possible class labels are 0-%g' % (mlc, nc, opt.data, nc - 1)
# 垂直方向进行拼接,通过索引操作 [:, 0] 获取拼接后标签数组的第一列的最大值
mlc = np.concatenate(dataset.labels, 0)[:, 0].max()
# 每个轮次的批次数(nb)。
nb = len(dataloader)
# 如果条件为false,就会触发断言错误
# 假设有一个数据集包含了2个类别的标签,分别是0、1。而在模型的配置中,设置的类别数目 nc 是2。这种情况下,最大的标签类别是1,小于预设的类别数目,成立
# 而如果数据集中的标签类别为0、1、2,而模型的配置中设置的类别数目 nc 仍然是2。,最大的标签类别是2,不小于预设的类别数目。
assert mlc < nc, 'Label class %g exceeds nc=%g in %s. Possible class labels are 0-%g' % (mlc, nc, opt.data, nc - 1)
加载验证集的数据 testloader
# #加载验证集的数据
if rank in [-1, 0]:
ema.updates = start_epoch * nb // accumulate # set EMA updates test_path是验证机
testloader = create_dataloader(test_path,
imgsz_test,
total_batch_size,
gs,
opt,
hyp=hyp,
augment=False,
cache=opt.cache_images and not opt.notest,
rect=True,
rank=-1,
world_size=opt.world_size,
workers=opt.workers
)[0] # testloader
使用plot绘制数据集图片,保存到runs/exp下 仅仅是可视化数据而已
#使用plot绘制数据集图片
if not opt.resume:
# 它包含了数据集中所有样本的标签信息 5=类别标签+4个归一化坐标
labels = np.concatenate(dataset.labels, 0)
c = torch.tensor(labels[:, 0]) # classes
# cf = torch.bincount(c.long(), minlength=nc) + 1. # frequency
# model._initialize_biases(cf.to(device))
#分别画出了runs/exp下的labels.png和labels_correlogram.png两张图片
plot_labels(labels, save_dir=log_dir)
if tb_writer: # TensorBoard 的 add_histogram 函数
# tb_writer.add_hparams(hyp, {}) # causes duplicate https://github.com/ultralytics/yolov5/pull/384
tb_writer.add_histogram('classes', c, 0) # c 是标签数组 labels 中每个类别出现的次数统计结果。
# check_anchors 函数会计算预先定义的锚框与标注的目标框之间的 IoU(交并比)值,并根据给定的阈值 hyp['anchor_t'] 判断匹配程度。
# 如果某个预先定义的锚框与目标框的 IoU 值小于阈值,则认为匹配差异较大,需要进行微调。
if not opt.noautoanchor:
check_anchors(dataset, model=model, thr=hyp['anchor_t'], imgsz=imgsz)
调整分类损失权重
hyp['cls'] = hyp['cls'] * (nc / 80.)
假设我们有两个数据集 A 和 B,数据集 A 中有 100 个类别,数据集 B 中有 50 个类别。在训练过程中,如果不对分类损失权重进行调整,模型可能更关注数据集 A 中的类别,因为其类别数量更多。这可能导致在数据集 B 上的分类结果不准确。
通过将 hyp['cls'] 乘以 (nc / 80.),其中 nc 分别为数据集 A 和 B 中的类别数量,可以将分类损失权重调整为相对合适的值。例如,如果数据集 A 中的 nc 为 100,数据集 B 中的 nc 为 50,那么在计算分类损失时,数据集 A 的权重将是数据集 B 的两倍,从而平衡了类别不平衡的影响,使模型在不同数据集上都能取得准确的分类结果。
这样做的目的是为了确保模型对于每个类别都能够获得充分的训练,避免因为类别不平衡而导致某些类别被忽视或权重过大。通过调整分类损失权重,可以使模型更全面地学习各个类别的特征,从而提高分类准确性。
# Model parameters 类别个数,
hyp['cls'] = hyp['cls'] * (nc / 80.) # scale coco-tuned hyp['cls'] to current dataset
# attach number of classes to model
model.nc = nc
# attach hyperparameters to model
model.hyp = hyp
# IOU 损失用于度量预测框与真实框之间的重叠程度,而目标损失用于度量预测框中是否存在目标。通过调整权重比例,可以控制这两个损失对最终模型训练的影响程度。
model.gr = 1.0
# 它通过统计每个类别在训练数据中的频率(出现次数)来确定权重,某一类的个数多,那么权重就大。 然后对权重进行归一化处理,
# 确保模型在训练过程中对于不同类别的样本都能够得到适当的关注和权重调整,提高模型的性能和泛化能力。
model.class_weights = labels_to_class_weights(dataset.labels, nc).to(device)
model.names = names
热身持续多少个epoch,存储训练过程中的各项指标结果,记录上一次更新学习率的训练轮数。使用混合精度
# Start training
#to统计一轮需要的时间
t0 = time.time()
# 热身持续多少个epoch ,通过向上取整并将结果限制为最小值 1000,确保有足够的并行工作进程来加速数据加载过程。
nw = max(round(hyp['warmup_epochs'] * nb), 1e3)
# nw = min(nw, (epochs - start_epoch) / 2 * nb) # limit warmup to < 1/2 of training
# 日志要保存的结果,先初始化 # mAP per class 一个形状为 (nc,) 的全零数组,用于存储每个类别的平均精度 (mAP)。
# 由于口罩检测只有两个类别,所以有两个map
maps = np.zeros(nc)
# 用于存储训练过程中的各项指标结果,包括 精确率P, 召回率R, mAP @ .5, mAP @ .5 - .95, 验证集上的损失值val_loss(box, obj, cls)
results = (0, 0, 0, 0, 0, 0, 0) #
#用于记录上一次更新学习率的训练轮数。
# 这种设置通常在训练过程中的断点恢复或继续训练时使用,以确保学习率调度器从正确的轮数开始进行调整,而不是重新计算或重新初始化学习率的调度。
scheduler.last_epoch = start_epoch - 1 # do not move
# 自动混合精度训练,1.6新功能 fp32与fp16混合 提速比较多,作用是当对象在进行反向传播时,自动调整梯度的缩放,以防止数值溢出或精度损失。
scaler = amp.GradScaler(enabled=cuda)
开始真正的训练
设置训练模式,定义损失,创建进度条,i记录目前处理了几个批次
归一化,并将其从uint8类型转换为float32类型,并将像素值从范围[0, 255]归一化到[0.0, 1.0]范围。
imgs = imgs.to(device, non_blocking=True).float() / 255.0
for epoch in range(start_epoch, epochs): # epoch ------------------------------------------------------------------
model.train()
# opt.image_weights:这是一个用于指示是否要更新图像权重的选项。如果设置为 True,则执行下面的操作;否则跳过。
if opt.image_weights:
# Generate indices
if rank in [-1, 0]:
# class weights 计算的图像类别权重,(1 - maps)表示不精确度。如果一个类别的不精确度高,那么这个类别就会被计算出比较大的权重cw,
# 计算出比较大的权重cw,来增加被采样到的概率
cw = model.class_weights.cpu().numpy() * (1 - maps) ** 2
#,由于传给模型的是图片,而不是检测框,所以要把类别权重换算到图像的维度求出图片的权重
# image weights 根据图像标签和类别权重计算的图像权重
iw = labels_to_image_weights(dataset.labels, nc=nc,class_weights=cw)
# 根据图像权重 iw 权重随机重采样,得到的不再是原始的数据集,而是会多包含一些难识别的样本,下面喂数据的时候也会更多的给出难识别的样本
dataset.indices = random.choices(range(dataset.n), weights=iw,k=dataset.n)
# Broadcast if DDP 如果使用分布式数据并行(DDP)则将图像权重索引广播给其他进程
if rank != -1: # indices 是一个 torch.tensor 对象,其中包含了图像权重索引。
indices = (torch.tensor(dataset.indices) if rank == 0 else torch.zeros(dataset.n)).int()
dist.broadcast(indices, 0) # 使用 dist.broadcast 函数将 indices 广播给其他进程。
if rank != 0: # 如果当前进程不是主进程(rank 不等于 0),则将广播的索引赋值给 dataset.indices。
dataset.indices = indices.cpu().numpy()
# Update mosaic border
# b = int(random.uniform(0.25 * imgsz, 0.75 * imgsz + gs) // gs * gs)
# dataset.mosaic_border = [b - imgsz, -b] # height, width borders
# mean losses 4=(边界框损失、目标置信度损失、分类损失和总损失。)
mloss = torch.zeros(4, device=device)
# DDP模式每次取数据的随机种子都不同
if rank != -1:
dataloader.sampler.set_epoch(epoch)
# 创建进度条 迭代器 pbar,用于遍历数据加载器中的批次数据
pbar = enumerate(dataloader)
# pbar_len = len(pbar) 14
logger.info(('\n' + '%10s' * 8) % (
'Epoch', 'gpu_mem', 'box', 'obj', 'cls', 'total', 'targets', 'img_size')) # gpu_mem:GPU内存使用情况
# 使用 tqdm 函数创建一个进度条,总共迭代的次数为 nb,即数据加载器中的批次数。
if rank in [-1, 0]:
pbar = tqdm(pbar, total=nb) # progress bar
optimizer.zero_grad()
# imgs 是输入图像的张量,targets 是目标标注的张量,paths路径方便做可视化的一些操作
for i, (imgs, targets, paths, _) in pbar: # batch -------------------------------------------------------------
#ni记录目前处理了几个批次
ni = i + nb * epoch # number integrated batches (since train start)
# 归一化,并将其从uint8类型转换为float32类型,并将像素值从范围[0, 255]归一化到[0.0, 1.0]范围。
训练:Warmup 热身
热身阶段内(ni <= nw)调整学习率和动量的值。它可以先设置较小的lr,然后慢慢warmup到你的初始学习率,
accumulate是线性插值慢慢增加的,从1到4,如果accumulate是4批次更新一次,那就可以达到64batch的效果,2的话可以达到32一批次的效果
# imgs 是输入图像的张量,targets 是目标标注的张量,paths路径方便做可视化的一些操作
for i, (imgs, targets, paths, _) in pbar: # batch -------------------------------------------------------------
#ni记录目前处理了几个批次
ni = i + nb * epoch # number integrated batches (since train start)
# 归一化,并将其从uint8类型转换为float32类型,并将像素值从范围[0, 255]归一化到[0.0, 1.0]范围。
imgs = imgs.to(device, non_blocking=True).float() / 255.0 # uint8 to float32, 0-255 to 0.0-1.0
# Warmup 热身 热身阶段内(ni <= nw)调整学习率和动量的值。它可以先设置较小的lr,然后慢慢warmup到你的初始学习率,
if ni <= nw: #一次处理14个
# nw:热身持续多少个epoch
xi = [0, nw] # x interp,
# model.gr = np.interp(ni, xi, [0.0, 1.0]) # iou loss ratio (obj_loss = 1.0 or iou)
# 一轮产生一个accumulate
accumulate = np.interp(ni, xi, [1, nbs / total_batch_size]).round()
# # accumulate 决定了在反向传播之前要积累多少个批次的梯度更新。
accumulate = max(1, accumulate)
# 长度为3的列表,说明该优化器有3个参数组。每个参数组都可以单独设置学习率和动量等超参数的值。每个元素都是一个字典,
# 包含了参数组的相关信息{'lr': 0.01, 'momentum': 0.937, 'dampening': 0, 'weight_decay': 0, 'nesterov': True,
# 'maximize': False, 'foreach': None, 'differentiable': False}
# 这里的optimizer是SGD
# 根据迭代步数的变化,动态调整不同参数组的学习率和动量值,以实现更灵活的优化策略。
for j, x in enumerate(optimizer.param_groups):
# bias lr falls from 0.1 to lr0, all other lrs rise from 0.0 to lr0 lf就是余弦衰退函数
# x['lr']进行赋值,实际上修改了optimizer.param_groups中对应参数组的学习率。跟着模型的参数走的
x['lr'] = np.interp(ni, xi, [hyp['warmup_bias_lr'] if j == 2 else 0.0,
x['initial_lr'] * lf(epoch)]) #lf是学习率因子,上面已经定义过lf = lambda x: ((1 + math.cos(x * math.pi / epochs)) / 2) * (1 - hyp['lrf']) + hyp['lrf']
# # 如果当前参数组具有动量参数(momentum),则进行动量值的插值调整。
if 'momentum' in x: #xi = [0, nw] # 0-1000【训练开始时的轮数,训练结束时的轮数】
x['momentum'] = np.interp(ni, xi, [hyp['warmup_momentum'], hyp['momentum']])
# Multi-scale 各种输入的大小,也是随机的范围[imgsz * 0.5, imgsz * 1.5 + gs] 其中gs=32
#使用多尺度因子sf,来改变图片尺寸从而起到多尺度训练的效果,一般不用
if opt.multi_scale: # fasle
sz = random.randrange(imgsz * 0.5, imgsz * 1.5 + gs) // gs * gs # size
sf = sz / max(imgs.shape[2:]) # scale factor
if sf != 1: # 得到新的输入大小
ns = [math.ceil(x * sf / gs) * gs for x in imgs.shape[2:]] # new shape (stretched to gs-multiple)
imgs = F.interpolate(imgs, size=ns, mode='bilinear', align_corners=False)
Forward
forward传给模型得到预测框,然后做loss : pred = model(imgs)
loss:总损失 loss_items:(分类损失,回归损失,置信度损失): loss, loss_items = compute_loss(pred, targets.to(device), model)
计算梯度: 在混合精度训练中,首先需要创建一个梯度缩放器(scaler)对象来管理梯度的缩放操作。这可以通过使用 amp.GradScaler() 函数来实现 scaler.scale(loss).backward()
# Forward
with amp.autocast(enabled=cuda): # amp.autocast(enabled=cuda)用到了1.6新特性 混合精度
# forward传给模型得到预测框,然后做loss
pred = model(imgs)
# loss:总损失 loss_items:(分类损失,回归损失,置信度损失)
loss, loss_items = compute_loss(pred, targets.to(device), model) # loss scaled by batch_size
# 分布式数据并行(DDP)模式下对梯度进行平均处理,确保各个设备上的梯度一致性。
if rank != -1:
loss *= opt.world_size # gradient averaged between devices in DDP mode
# Backward
# 在混合精度训练中,首先需要创建一个梯度缩放器(scaler)对象来管理梯度的缩放操作。这可以通过使用 amp.GradScaler() 函数来实现。
scaler.scale(loss).backward()
#accumulate是线性插值慢慢增加的,从1到4,如果accumulate是4批次更新一次,那就可以达到64batch的效果,2的话可以达到32一批次的效果
if ni % accumulate == 0:#累积步数,用于在一次反向传播中累积多个小批量的梯度,从而相当于使用了更大的批量大小进行训练
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad()
if ema:#指数移动平均,通过将上一次的参数平均值与当前参数值按照一定的权重进行加权平均得到的。
ema.update(model)#这样,参数的更新将不仅依赖于当前的梯度,还考虑了之前的参数更新情况,从而减小了参数更新的波动。
每轮更新梯度和学习率
根据前面的学习率策略来更新学习率,最重要还是更新梯度,学习率只能说是微调,慢慢变小,因为这个是超参数,不能通过学习的得来,只能调
#第一轮训练结束
# end batch ------------------------------------------------------------------------------------------------
# Scheduler 学习率衰减 用于获取优化器中各个参数组的学习率的列表。在优化器中,参数以参数组的形式进行管理,每个参数组可以有不同的学习率。
# 参数组的一般有{'lr': 0.01, 'momentum': 0.937, 'dampening': 0, 'weight_decay': 0, 'nesterov': True,
# 'maximize': False, 'foreach': None, 'differentiable': False}
lr = [x['lr'] for x in optimizer.param_groups] # for tensorboard
# Scheduler 学习率衰减
#根据前面的学习率策略来更新学习率,最重要还是更新梯度,学习率只能说是微调,慢慢变小,因为这个是超参数,不能通过学习的得来,只能调
scheduler.step()
保存一轮中的前三批图片
在训练样本结束之后,前三批的效果在runs/exp文件夹下保存train_batch0(batch1,batch2).jpg前三批的效果图, 每一批有16张图片,每一张又做了数据增强,多张拼接起
更新一次梯度后,开始 Print 展示信息,Plot tensorboard:
# Print 展示信息
if rank in [-1, 0]:
mloss = (mloss * i + loss_items) / (i + 1) # update mean losses
mem = '%.3gG' % (torch.cuda.memory_reserved() / 1E9 if torch.cuda.is_available() else 0) # (GB)
# '%g/%g' % (epoch, epochs - 1):表示当前训练的轮数和总轮数。mem: 表示当前GPU内存的使用情况。
# *mloss: 表示一组损失函数的值,包括总损失和各个损失分量。,targets.shape[0]: 表示目标数据的数量,imgs.shape[-1]: 表示输入图像的通道数。
s = ('%10s' * 2 + '%10.4g' * 6) % (
'%g/%g' % (epoch, epochs - 1), mem, *mloss, targets.shape[0], imgs.shape[-1])
# 每次更新ni的进度条
pbar.set_description(s)
# Plot tensorboard
# 在训练样本结束之后,前三批的效果在runs/exp文件夹下保存train_batch0(batch1,batch2).jpg前三批的效果图,
# 每一批有16张图片,每一张又做了数据增强,多张拼接起来
if ni < 3:
f = str(log_dir / ('train_batch%g.jpg' % ni)) # filename
result = plot_images(images=imgs, targets=targets, paths=paths, fname=f)
if tb_writer and result is not None:
tb_writer.add_image(f, result, dataformats='HWC', global_step=epoch)
# tb_writer.add_graph(model, imgs) # add model to tensorboard
#第一轮训练结束
# one epoch end ------------------------------------------------------------------------------------------------
更新EMA
在YOLO训练中,EMA(Exponential Moving Average)是一种常用的优化技巧,用于稳定和改善模型的训练过程。
EMA的更新是通过计算移动平均值来实现的。在YOLO中,通常将EMA应用于模型的权重参数。每次进行参数更新时,会同时更新原始参数和EMA参数。具体来说,EMA参数会根据当前的权重参数和先前的EMA参数进行加权平均计算。
# DDP process 0 or single-GPU
if rank in [-1, 0]:
# mAP 更新EMA
if ema:
#给ema添加属性
ema.update_attr(model, include=['yaml', 'nc', 'hyp', 'gr', 'names', 'stride'])
训练一轮立即验证一轮
判断当前这一轮是不是最后一轮,如果不是的话,会把目前训练好的模型在验证集跑一遍,并得到results, maps, times
#判断当前这一轮是不是最后一轮,如果不是的话,会把目前训练好的模型在验证集跑一遍,并得到results, maps, times
if not opt.notest or final_epoch: # Calculate mAP 上面定义了results = (0, 0, 0, 0, 0, 0, 0)
results, maps, times = test.test(opt.data,
batch_size=total_batch_size,
imgsz=imgsz_test,
model=ema.ema,
single_cls=opt.single_cls, # single_cls是一个布尔型参数,用于指示是否进行单类别的目标检测。
dataloader=testloader,
save_dir=log_dir,
plots=epoch == 0 or final_epoch) # plot first and last
# Write
with open(results_file, 'a') as f:
f.write(s + '%10.4g' * 7 % results + '\n') # P, R, mAP@.5, mAP@.5-.95, val_loss(box, obj, cls)
if len(opt.name) and opt.bucket: # 这个整不了,涉及上传
os.system('gsutil cp %s gs://%s/results/results%s.txt' % (results_file, opt.bucket, opt.name))
# Tensorboard
if tb_writer:
tags = ['train/box_loss', 'train/obj_loss', 'train/cls_loss', # train loss
'metrics/precision', 'metrics/recall', 'metrics/mAP_0.5', 'metrics/mAP_0.5:0.95',
'val/box_loss', 'val/obj_loss', 'val/cls_loss', # val loss
'x/lr0', 'x/lr1', 'x/lr2'] # params
for x, tag in zip(list(mloss[:-1]) + list(results) + lr, tags):
tb_writer.add_scalar(tag, x, epoch)
# Update best mAP 接收一个结果数组 results,该数组包含一组指标值(如准确率、召回率、mAP 等)。返回一个衡量模型性能的综合加权组合的指标值
保存训练last.pt模型
fi = fitness(np.array(results).reshape(1, -1)) # weighted combination of [P, R, mAP@.5, mAP@.5-.95]
if fi > best_fitness: #较大的适应度值被认为是更好的表现。
best_fitness = fi
# Save model
save = (not opt.nosave) or (final_epoch and not opt.evolve)
if save:
with open(results_file, 'r') as f: # create checkpoint
ckpt = {'epoch': epoch, #ckpt 是一个字典对象,用于保存训练过程中的各种状态和信息。
'best_fitness': best_fitness,
'training_results': f.read(),
'model': ema.ema,
'optimizer': None if final_epoch else optimizer.state_dict()}
# Save last, best and delete
# 你还在纠结为什么保存ckpt?因为上面已经把model的更新参数写进去了啊
torch.save(ckpt, last)
if best_fitness == fi:
#如果本轮是最好的成绩,就保存作为做好的 模型
torch.save(ckpt, best)
del ckpt
# end epoch ----------------------------------------------------------------------------------------------------
# end training
整个训练完成
# 用来在训练过程中对模型文件和结果文件进行管理和备份的操作。
if rank in [-1, 0]:
# Strip optimizers
# 如果opt.name是一个数字,将其作为结果文件名的后缀,否则为空字符串。
n = opt.name if opt.name.isnumeric() else ''
# fresults表示结果文件的路径,flast表示最后一次训练权重文件的路径,fbest表示最佳权重文件的路径。
fresults, flast, fbest = log_dir / f'results{n}.txt', wdir / f'last{n}.pt', wdir / f'best{n}.pt'
for f1, f2 in zip([wdir / 'last.pt', wdir / 'best.pt', results_file], [flast, fbest, fresults]):
if os.path.exists(f1):
os.rename(f1, f2) # rename
if str(f2).endswith('.pt'): # is *.pt
# 调用strip_optimizer函数,将优化器相关的信息从模型文件中删除,以减小文件大小并提高后续使用的效率。
strip_optimizer(f2) # strip optimizer
# 如果目标文件路径的文件名以.pt结尾,且设置了opt.bucket,则通过执行os.system命令将该文件上传到指定的云存储桶中。这里用不上
os.system('gsutil cp %s gs://%s/weights' % (f2, opt.bucket)) if opt.bucket else None # upload
# Finish
# 确定是否进行进化算法训练。如果不是进化算法训练,则调用plot_results函数,将训练结果绘制成图表并保存为results.png文件。
if not opt.evolve:
#最后训练完成后,会保存一些指标图片,例如会给出验证集的前三批标注框和验证框画出来。github源码才有,这份代码没有。
plot_results(save_dir=log_dir) # save as results.png
# 完成的轮次数和总共花费的小时数。
logger.info('%g epochs completed in %.3f hours.\n' % (epoch - start_epoch + 1, (time.time() - t0) / 3600))
# 根据进程的rank值决定是否销毁进程组。如果进程的rank不是-1或0,则调用dist.destroy_process_group()销毁进程组,以释放分布式训练所占用的资源。
dist.destroy_process_group() if rank not in [-1, 0] else None
# 通过调用torch.cuda.empty_cache()清空GPU缓存,以释放已经使用的GPU内存。
torch.cuda.empty_cache()
# 整个训练过程结束后,函数返回训练结果。
return results