XGBoost:基数,总是被忽视的关键超参数
更新:发现我关于渐变提升的新书,实用渐变提升。这是用 python 中的许多例子对渐变增强的深入探究。
https://www.amazon.com/dp/B0BJ82S916
在处理超参数调整时,大多数注意力都集中在过度拟合和使用正确的正则化参数上,以确保模型不会过度学习。
然而,还有一个非常重要的问题要问:预测空间的基数是多少?换句话说,XGBoost 和更一般的决策树可以预测多少不同的值?
XGBoost 和 boosted 树是离散模型
当使用 XGBoost、CatBoost 或 LightGBM 时,记住所有这些库都依赖决策树是绝对重要的,决策树的叶子只包含常量值(除非您已经为 LightGBM 启用了线性树。参见我关于主题的另一篇文章。)
简单地说,这意味着给定的模型只能预测离散数量的值。其他模型,例如高斯过程或基本线性模型,可以预测无限数量的值。
下面的代码和图表说明了:
显示梯度增强树的离散性质。作者代码。
梯度推进树的离散性质是明确的,如上面代码生成的图所示:
梯度增强树的离散性质。剧情作者。
梯度推进树只能预测一组有限的离散值。
为什么要关心基数?
基数很重要,原因有二:
- 它与你的预测集的基数直接相关。比方说,你需要预测
n
种工作和m
个国家的收入。如果你的模型预测小于n*m
值,你的模型很可能无法正确预测收入。可能的预测集不够大,不足以捕捉现实的复杂性。 - 这是过度拟合的一个很好的指标。如果您的模型基数比您的预测集的基数高得多,那么您的模型可能会过度拟合。
计算梯度提升树的基数
计算梯度增强树模型可以生成的预测基数并不容易。这取决于每棵树的结构,以及每个决策节点做出的决策。
在只有一个估计器的简单树集合的情况下,计算是简单的。有多少树叶,就有多少种可能的预测。如果树是完整的,即如果每个分支都达到了最大深度,则叶子的数量等于2^max_depth
。
当有n
个估计器时,理论上讲,第一棵树的每个可能的预测可以被第二棵树带来的任何校正更新,第二棵树又可以被第三棵树带来的任何校正更新,等等。因此,从理论上讲,梯度助推器树可以产生高达
作者的公式
实际上,这个值是一个上限,因为对于一组特征来说,每棵树相对于其他树都不是独立的。
实际上,考虑第一棵树的一片叶子,特征区间的笛卡儿积允许到达这些叶子。因此,当特征在这些间隔中变化时,第一个树的响应总是相同的值。
当在这些范围内改变特征时,可以到达其他树的每片叶子的几率很低。这解释了为什么上面的公式显然是一个最大值,因为只有叶子的子集是可达的。
驱动梯度提高了树基数
为了控制模型基数而调整的超参数与导致过度拟合的参数相同。基于上一节的解释,我们看到基本的n_estimators
和max_depth
可以用于引导基数。
gamma
和lamda
,因为它们控制节点分裂也可以使用。如果您使用的是 LightGBM,那就更简单了,因为有一个参数num_leaves
,它定义了给定估计器的最大叶子数,而不管深度如何。
结论
基数是基于梯度提升树的模型的基本特征,必须根据要预测的值的基数进行分析。
这是梯度增强树的离散性质的直接结果。
重要的是要确保生成的模型有足够的自由度来为预测过程中考虑的每个不同情况生成不同的值。
XGBoost for time series: lightGBM 是更大的船!
威廉·戴尼奥在 Unsplash 上拍摄的照片
更新:发现我关于渐变提升的新书,实用渐变提升。这是用 python 中的许多例子对渐变增强的深入探究。
https://amzn.to/3gaBn4R [## 实用的渐变增强:深入探究 Python 中的渐变增强
这本书的梯度推进方法是为学生,学者,工程师和数据科学家谁希望…](https://amzn.to/3gaBn4R)
在处理时间序列时,能够处理外推是至关重要的。没有外推法,处理趋势是不可能的。
从数学上讲,这意味着基础模型必须在其公式中至少集成一个线性部分。
在上一篇文章中,我展示了 XGBoost 的一个最重要的限制是它不能根据构造进行推断。
这是相当严格的,因为在许多 ML 情况下,你需要能够推断。在处理时间序列时尤其如此,因为你通常想要预测未来,而没有外推,你就被困在了过去。XGBoost 所做的所有预测都是历史值的集合。
在某种程度上,XGBoost 无法预测历史上没有的东西。
问题
为了具体说明这个问题,让我们以我在上一篇文章中使用的代码为例:
试着用 XGBoost 建模一个简单的线性函数。作者代码。
我们面对的是一个简单的线性时间序列,它与时间严格成比例,可以用一个基本的线性方程来建模。
正如我们在前面的脚本生成的图中看到的,XGBoost 没有抓住趋势:
XGBoost 预测与实际值。作者的情节。
解决方案
然而,XGBoost 和更一般的使用梯度推进方法训练的决策树的集成仍然是非常强大的工具,尽管有这种限制,但在时间序列上表现相对较好。
如果我们能保持这种树状的方法,并在模型中集成一些关于特征的比例关系,那将是完美的。
我最近看到了这篇不错的论文,用分段线性回归树进行梯度提升,它准确地提出了一种使用更复杂的基础学习器的方法:分段线性函数而不是分段常数函数。
在本文中,他们表明,使用这种更复杂的学习器不仅提高了精度,而且加快了收敛速度。
从数学上讲,这意味着标准梯度的恒定权重提升了决策树,用以下公式计算:
最佳叶重。作者的公式
其中,G_j 是应用于叶中剩余数据的目标函数的梯度,H_j 是目标函数的 hessian,将被更复杂的估计器代替:
使用所选要素计算的线性权重。作者的公式。
对于树的每个叶子,标准梯度增强方法必须适合于识别 a 参数的最佳值,而且选择要使用的相关特征
在他们的论文中,作者解释了如何做这件事。
非常好的消息是,它已经在 XGBoost 最重要和最著名的竞争对手之一:LightGBM 中实现了!
LigthGBM
在深入讨论这个主题之前,先简单介绍一下 LightGBM。这是一个实现梯度推进方法来训练决策树集合的库。
根据 GitHub stars 的评价,它不如 XGBoost 有名,但它仍然是 XGBoost 的一个非常受欢迎和重要的替代产品。
它主要由微软开发。允许使用线性函数作为基础预测值的选项是linear_tree.
在数学上,我们相信
让我们检查一下之前的例子,当使用这种基础学习者的 LightGBM 时,我们得到了预期的结果。
首先,为了让我们相信选项linear_tree
确实能够解决我们的问题,我们将运行没有该选项的第一个脚本:
尝试在没有 linear_tree 选项的情况下捕捉数据的线性行为。作者代码。
正如这个脚本生成的图所显示的,在没有选项linear_tree
的情况下,LighGBM 的性能并不比 XGBoost 好:
没有线性树,LightGBM 无法推断。图片由作者提供。
现在让我们运行完全相同的代码,除了选项linear_tree:
之外,使用 LighGBM 默认参数
用 LighGBM 和线性树捕捉数据的线性本质。作者代码。
答对了。正如理论预测的那样(这是个好消息,否则我会白写这篇文章:),LightGBM 抓住了数据的线性本质:
LightGBM 外推完美!作者的情节。
更深刻的理解
看看 lightGBM 选择的参数会非常有趣。
我们知道,我们非常基本的时间序列只是与时间成比例,其系数值为 6.66。
理想情况下,lightGBM 应该将这个值确定为其线性模型的最佳值。
这很容易检查。我们将生成最简单的模型,以便于阅读模型定义。
我们将使用save_model
函数来导出模型。下面是代码:
代码基本上和以前一样,除了我们调整参数得到最简单的一个:深度减少到 1,估计器的数量是 2。
查看模型定义,我们得到:
LightGBM 导出的模型定义。内容由作者提供。
两条重要的线是:
leaf _ features = 0 0
leaf _ coeff = 0 . 7019912661967
这意味着所使用的特征是第一个特征,即时间。因为深度等于 1,所以有两片叶子。一个重量为 0.701,另一个重量为 6.659。这不完全是 6.66,但已经很接近了。
结论
在处理数据科学问题时,理解要素之间以及要素与时间之间的关联方式至关重要。基于这种理解,有必要将这种关系转化为数学公式。
了解这些公式,掌握可用机器学习算法的底层数学基础的数据科学家将能够选择正确的算法并建立相关的模型。
我们在本文中已经看到,为了进行外推,我们至少需要在我们的模型中集成一些线性。知道 LightGBM 支持这种基础学习者有助于我们高效优雅地解决我们的问题。
XGBoost:它的谱系、建筑特色和创新
顺序和并行架构合二为一
照片由玛丽奥拉·格罗贝尔斯卡在 Unsplash 上拍摄
介绍
XGBoost (极限梯度提升)是一种强大的学习算法,在过去的许多比赛中胜过了许多传统的机器学习算法。
简而言之,XGBoost 集顺序和并行架构于一身:虽然它是一种顺序学习算法(加法策略),但它将并行计算融入其架构中,以提高系统效率。
制作人:杉尾道夫
这篇文章是针对初学者的 XGBoost 的介绍性概述,是一篇一站式的文章,它将向您提供关于 XGBoost 的整体情况(如果不是细节的话)——它的谱系、架构特性和创新特性。在文章的最后,我还会给出一个简短的补充资源列表,这样读者就可以更详细地了解文章中涉及的主题。
现在,我们开始吧。
为了理解 XGBoost 的特性,我们可以从快速概述它的系谱开始。
讨论
A.XGBoost 系谱
从自上而下的角度来看,XGBoost 是 监督机器学习 的子类。而且顾名思义,XGBoost 是 Boosting 机 的高级变种,是 基于树的系综 算法的子类,像随机森林。
然而,Boosting Machine 在操作学习过程的方式上与 Random Forest 有着根本的不同。
助推机
随机森林并行运行多个独立的决策树,并通过平均所有结果来组合它们的结果。这种方法使用随机引导抽样,通常被称为 bagging。从这个意义上说,随机森林是一种并行学习算法。
相反,Boosting Machine 使用一个加法策略:即“一次增加一棵新树”(xgboost developers,2022 )。Boosting 机器依次运行名为 的单个弱/简单决策树,基学习器 。简单来说,概念上的 Boosting Machine 是建立在顺序学习架构上的。
从这个意义上说,Boosting Machine 是顺序学习的,而 Random Forest 是并行学习的。
作为增压机的参考,这里有一个麻省理工学院关于增压的讲座:https://www.youtube.com/watch?v=UHBmv7qCey4
也就是说,为了避免混淆,我应该从 系统优化 的角度在这里做一个脚注。XGBoost 还设计用于运行并行计算,以提高计算资源的使用效率( xgboost developers,n.d. )。总的来说,XGBoost 虽然继承了 Boosting Machine 的顺序学习架构,但却通过并行计算来优化系统。
渐变助推机
顾名思义,XGBoost(极限梯度推进)是梯度推进机(GBM)的高级变种,是推进机家族成员。
作为其 **加法策略的一部分,**梯度推进机(GBM)使用梯度下降进行优化。为了减少计算量,GBM 利用泰勒展开式的一阶项逼近目标函数,并忽略高阶项进行学习优化。换句话说,它使用目标函数(损失函数)的一阶导数( 梯度 )来确定下一个弱学习器预测器。通过这种方式,梯度提升在保留现有弱预测器的同时,在它们之上添加新的预测器以减少当前误差,从而逐步提高性能。(弗里德曼,2000 年)
b .****XGBoost的算法特点
牛顿助推机
XGBoost 扩展了梯度增强的思想,除了它的一阶导数(梯度)之外,它还使用目标函数的二阶导数(Hessian:Curvature)来进一步优化它的学习过程。这个方法叫做 牛顿拉夫逊法 。而采用牛顿拉夫逊法的增压机叫做 牛顿增压 。关于梯度下降和牛顿提升之间的差异的进一步讨论,您可以阅读由 Fabio Sigrist 撰写的论文Gradient and Newton Boosting for class ification and Regression。
由于加法策略的特定结构,二阶近似产生多个有益的数学特性,以简化算法,进一步提高计算效率。(Guestrin 和陈,2016 年)
规则化:解决方差-偏差权衡
Jerome Friedman ,梯度推进机的设计者( Friedman,2000 )阐述了正则化对于解决 偏差-方差权衡 、欠拟合-过拟合权衡问题的重要性,特别推荐用户调整梯度推进机的三个元参数:迭代次数、学习速率和终端节点/叶子数。(弗里德曼,2000 年,第 1203、1214–1215 页)
在这种背景下,XGBoost 继承了梯度推进机的正则化重点,并对其进行了进一步扩展。
- 首先,XGBoost 使用户能够调整各种超参数来约束树:例如,树的数量、单个树的深度、分区的实例权重的最小和、提升轮的最大数量以及节点/叶的数量。
- 第二,它允许用户在学习过程中应用学习速率,收缩率。( Guestrin &陈 2016 第 3 页)
- 第三,它使用户能够使用随机采样技术,如列子采样。( Guestrin &陈 2016 第 3 页
- 第四,它使用户能够调整 L1 和 L2 正则项。
C.创新:
稀疏感知算法和加权分位数草图
更重要的是,XGBoost 引入了两项创新:稀疏感知算法和加权分位数草图。(陈&guest rin 2016 p10
首先,XGBoost 有一个内置的特性叫做 默认方向。 该功能捕获稀疏数据结构的模式,并根据该模式确定每个节点的分割方向。Guestrin & Chen 提出了稀疏性的三个典型原因:
“1)数据中存在缺失值;2)统计中经常出现零条目;以及 3)特征工程的人工制品,例如一键编码( Guestrin &陈 2016
原则上,这个特性使得 XGBoost 稀疏感知算法 能够处理缺失数据:用户不需要估算缺失数据。
默认方向决定了分割的方向, 加权分位数草图 提出候选分割点。下面节选自陈和 Guestrin 的论文总结了它是什么。
“一种新的分布式加权分位数草图算法……能够以可证明的理论保证处理加权数据。总体思路是提出一种支持合并和剪枝操作的数据结构,每个操作都被证明能保持一定的精度水平。”( Guestrin &陈 2016
系统优化:效率和可扩展性
到目前为止,我们从学习算法架构的角度看到了 XGBoost 的框架。现在,我们可以从系统优化的角度来看待。
原生的 XGBoost API 在追求计算效率,或者说系统优化方面也是创新的。该 API 被称为 eXtreme (X ),因为 XGBoost 旨在通过在给定的计算资源(处理器(CPU、GPU)、内存和核外(磁盘空间):缓存访问、块数据压缩和分片)之间有效地分配计算任务,使用户能够利用给定系统计算能力的极限。( databricks,2017
关于原生 XGBoost API 的更多创新方面,这里有一个由 XGBoost 的发明者(Chen & Guestrin), XGBoost:一个可扩展的树提升系统 概述的伟大作品。
结论
这个对 XGBoost 的快速概述回顾了它的系谱、架构特性和创新,但没有深入细节。
简而言之,XGBoost 具有顺序-并行混合体系结构,从某种意义上说,它继承了 Boosting 机器谱系中的顺序学习体系结构,同时将并行计算融入其体系结构中,以提高系统效率。
由于 Boosting Machine 有过度拟合的倾向,所以原生的 XGBoost API 非常注重解决偏差-方差权衡问题,并通过超参数调整方便用户应用各种正则化技术。
如果您对原生 XGBoost API 的实现示例感兴趣,可以阅读我的另一篇文章, 用原生 XGBoost API 进行成对超参数调优。
感谢你阅读这篇文章。
建议的外部资源
对于那些想探索 XGBoost 更多细节的人来说,这里有一个我最喜欢的关于该算法的参考资料的简短列表:
- 为了快速概述 XGBoost,Jason Brownlee 对机器学习的梯度推进算法 g 的简要介绍非常简洁地捕捉到了 XGBoost 的谱系和主要特性:
- 对于 XGBoost 的数学解释, TreeBoosting-03:为什么每次机器学习比赛 XGBoost 都会赢? by 哈阮给了我一个很好的补充资源。
- XGBoost 的创新方面,XGBoost:XGBoost 的发明人陈& Guestrin 的一个可扩展的树提升系统 给大家做一个简单的总结。
- 官方原生 XGBoost API 的在线文档给你一个官方的 XGBoost 教程 。它是创新算法的重要基础资源。
- 如果你有时间和灵魂去读一篇负荷很重的论文,你应该读一读杰罗姆·h·弗里德曼的论文《关于梯度推进机》, 贪婪函数逼近:一台梯度推进机 :
确认
我要感谢 TDS 的编辑团队,特别是凯瑟琳·普雷里,感谢他们在编辑阶段提供的宝贵意见。
参考
- Brownlee,J. 对机器学习的梯度推进算法的温和介绍 。 (2016)。检索自机器学习掌握:https://Machine Learning Mastery . com/gentle-introduction-gradient-boosting-algorithm-Machine-Learning/
- 数据砖。xgboost-Linux 64。 (2017)。从 github 检索:https://github . com/databricks/xgboost-Linux 64/blob/master/doc/model . MD
- j . Friedman*贪婪函数逼近:一台梯度助推机 。(2000).统计年鉴,29 (5),1189–1232。doi:10.1214/aos/1013203451*
- Guestrin,c .,& Chen,T. XGBoost: 一个可扩展的树提升系统 。 (2016)。https://doi.org/10.48550/arXiv.1603.02754
- Nguyen,H. TreeBoosting-03:为什么 XGBoost 每次机器学习比赛都赢? (未注明)。检索自数据科学博客:https://datasciblog.github.io/2020/02/26/tree-boosting-03
- xgboost 开发人员。 [XGBoost 文档](https://xgboost.readthedocs.io/: https://xgboost.readthedocs.io/en/stable/) 。(未注明)。检索自https://xgboost.readthedocs.io/:https://xgboost.readthedocs.io/en/stable/
XGBoost:使用单调约束传递业务知识
Photo by 愚木混株 cdd20 on Unsplash
几天前,我和我的一个好朋友 Julia Simon 讨论在一个基于决策树的模型中考虑商业知识。
她想到了一个非常简单的问题,即预测的值随着给定的特征严格地增加。她想知道是否有可能强制模型确保这种约束。
答案是肯定的,而且在很久以前就已经添加到 XGBoost 中了(根据 XGBoost changelogs 的说法是 2017 年 12 月左右),但它并不是 XGBoost 的一个非常知名的特性:单调约束。
让我们看看这是如何实现的,底层的数学是什么,以及它是如何工作的。
在我的书实用梯度提升中有更多关于决策树的梯度提升:
https://amzn.to/3EctIej [## 实用的渐变增强:深入探究 Python 中的渐变增强
这本书的梯度推进方法是为学生,学者,工程师和数据科学家谁希望…](https://amzn.to/3EctIej)
单调约束
先来定义一下monotonic constraint
。首先,在数学中,monotonic
是一个适用于函数的术语,意思是当那个函数的输入增加时,函数的输出或者严格地增加或者减少。
例如,函数 x 是严格单调的:
x 是严格单调的。作者的锅。
相反,x 函数不是单调的,至少在其整个域 R:
x 在 R. Plot 上不是单调的。
限于 R+,x 是单调的,同样代表 R-。
从数学上讲,说 f 是monotonic
意味着
f(x1)> f(x2)如果 x1 > x2 在单调递增的情况下。
或者
f(x_1) < f(x_2)如果 x_1 < x_2 在单调递减的情况下。
为什么需要单调约束?
在许多情况下,数据科学家预先知道要预测的值和某些特征之间的关系。例如,瓶装水的销售水平与温度成正比,因此在预测瓶装水销售的模型中实施这种约束可能会很有趣。
使用monotonic
约束是向 XGBoost 模型添加这种约束的简单方法。
让我们看一个简单的例子。假设我们正在尝试对以下等式建模,其中预测y
的值取决于x
,如下所示:
y = 3*x。
这是一个非常简单的关系,其中y
与x
严格成正比。然而,在现实生活中收集数据时,会引入噪声,这会导致数据点在局部不符合该关系。在这些情况下,有必要确保模型是monotonic
,理论公式也是如此。
下面的代码显示了如何使用 XGBoost 和monotonic
约束:
XGBoost 中的monotonic
约束是如何实现的?
我在以前的文章中展示了如何从头开始实现决策树的梯度推进:
这段代码可以很容易地修改成集成monotonic
约束。处理代码中的约束通常需要开发一个求解器,而且通常是相当复杂的代码。各种方法都是可能的。在本文中,您可以看到如何使用基于几何的迭代方法来实现这样的求解器:
然而,在梯度提升应用于决策树的情况下,monotonic
约束可以很容易地实现。这种实现的简单性来自于使用二进制决策树作为底层模型。
实际上,每个节点处理的决策是一个值和一个阈值之间的比较。因此,加强单调性只需要在决策节点级别考虑这种单调性。
例如,如果右节点包含列A
小于阈值T
的行,则右节点的增益必须小于左节点的增益。
XGBoost 如何处理单调约束?
为了了解我们如何实现这种约束,让我们看看 XGBoost 是如何在其 C++代码中实现的:
从 XGBoost 代码中提取。
代码实际上非常简单。它只是确保单调性在增益级别得到尊重。如果不是这样,代码就人为地将增益设置为negative_infinity
,以确保这种分裂不会被保持。
因此,不能确保单调性的决策节点被丢弃。
单调约束的应用及效果
下面的代码片段显示了如何向 XGBoost 模型添加monotonic
约束:
用单调约束训练 XGBoost 模型。作者代码
在这个教育示例中,两个 XGBoost 模型被训练来学习一个简单的理论模型,其中y = 6.66 x
。添加了一些严格的负面噪声,以确保训练数据不是monotone
,即有时是y_j < y_i
,即使是x_i < x_j
。
第一个模型在没有任何约束的情况下被训练,而第二个模型添加了一个monotonic
约束。
注意,这是通过定义参数monotone_constraint
来实现的。此参数是一个元组,必须包含与模型中的特征一样多的项目。
当与特征f_i
相关联的项目c_i
为 0 时,不应用约束。当c_i = 1
时,执行增加的单调约束,而当c_i = -1
时,执行减少的单调约束。
结果预测显示在该图中:
原始数据、无约束和有约束的预测。作者的情节。
放大图可以更好地显示约束的效果:
绿色表示的受约束预测值正在严格增加。作者的情节。
它清楚地表明,没有约束的模型不能确保单调性,因为预测并不总是增加的。相反,约束模型只生成增加的预测。
结论
单调约束是将业务知识转移到模型的一种简单方法。这是一个非常简单而有效的方法来引导模型走向相关的模型化。
如果你想了解更多关于梯度增强及其应用的知识,我写了一本关于这个主题的书,实用梯度增强,它详细介绍了数学基础,并提供了实用信息来充分利用 XGBoost、LightGBM 和 CatBoost:
https://amzn.to/3EctIej [## 实用的渐变增强:深入探究 Python 中的渐变增强
这本书的梯度推进方法是为学生,学者,工程师和数据科学家谁希望…](https://amzn.to/3EctIej)
酵母绿咖啡加工:水和巧克力的附加实验
咖啡数据科学
用酵母胡闹
在之前的中,我用酵母做了实验,展示了它对生咖啡的影响。在咖啡果和绿豆上使用酵母加工已经完成,但是没有太多关于绿豆发酵的公开信息。一般来说,酵母加工减少了大大影响咖啡豆甜味的酸度和苦味。
在用酵母做实验时,我做了两个额外的实验:水和可可粉。
在的水实验中,我很好奇将咖啡豆水合,然后单独脱水是否是酵母咖啡更好的部分原因。最终,事实证明并非如此,但我很高兴我完成了这个实验,因为它有助于分离一个变量。
对于可可粉,我随机想到看看酵母和可可粉如何在发酵过程中影响咖啡风味可能会很有趣。我希望创造一种巧克力咖啡,但可可粉似乎对酵母没有影响。
设备/技术
咖啡研磨机:小生零
咖啡:家庭烘焙咖啡,中杯(第一口+ 1 分钟)
预灌注:长,约 25 秒
输液:压力脉动
过滤篮 : 20g VST
其他设备: Atago TDS 计、 Acaia 比重秤、 Kruve 筛
绩效指标
我使用两个指标来评估技术之间的差异:最终得分和咖啡萃取。
最终得分 是评分卡上 7 个指标(辛辣、浓郁、糖浆、甜味、酸味、苦味和余味)的平均值。当然,这些分数是主观的,但它们符合我的口味,帮助我提高了我的拍摄水平。分数有一些变化。我的目标是保持每个指标的一致性,但有时粒度很难确定。
使用折射仪测量总溶解固体量(TDS),该数字结合咖啡的输出重量和输入重量,用于确定提取到杯中的咖啡的百分比,称为提取率(EY)** 。**
强度半径(IR) 定义为 TDS vs EY 控制图上原点的半径,所以 IR = sqrt( TDS + EY)。这一指标有助于标准化产量或酿造比的击球性能。
水实验
我设置了同样的豆子,经历了同样的过程,但是我为其中一个豆子留下了酵母。
****
每张图片的左边和右边分别是水和酵母处理。所有图片由作者提供。
它们在罐子里呆了 24 小时,豆子吸收了所有的水分。然而,经过水处理的有液体流出。我用 TDS 仪测试过,甚至还尝过。它非常涩,不好吃。因为糖的含量,我不确定酵母是否在消耗这种提取物,但是当我冲洗和干燥咖啡豆时,我没有把它洗掉。我也担心它会使咖啡豆脱去咖啡因,但是我没有任何证据。
****
水加工过的豆子烘烤起来非常不同。我应该把它们放久一点,它们的密度要大得多,这表明它们在烘烤中没有被充分开发。这使得味道比较变得困难。
****
水加工(左)和酵母加工(右)
部分由于烘烤,酵母比水加工的豆子好得多。我很快结束了实验,因为很明显,味觉得分分布不会有重叠。大多数用水加工过的豆子很难直接饮用。
****
向酵母和绿豆中加入可可
我看到了可可包,说“也许”,所以我试了一下。这是有趣的照片。
****
酵母加工成白色,可可+酵母看起来像巧克力棕色。
冲洗后这两个看起来很相似。
****
他们烤得非常相似,这让我认为品尝结果不会显示任何重要的东西。
就口味(最终得分)而言,他们差不多。就 TDS/EY/IR 而言,可可豆稍微好一些,但总的来说,我很快就结束了这个实验,因为看不到好的味道。
****
即使这两个实验都没有发现新的东西,我仍然喜欢写它们,因为我的失败造就了今天的我。研究就是为了一个绝妙的想法把你的头往墙上撞 100 次。
如果你愿意,可以在推特、 YouTube 和 Instagram 上关注我,我会在那里发布不同机器上的浓缩咖啡照片和浓缩咖啡相关的视频。你也可以在 LinkedIn 上找到我。也可以关注我在中和订阅。
我的进一步阅读:
工作和学校故事集
Yelp 数据科学家面试问题演练
帮你解决 Yelp SQL 面试问题
作者在 Canva 上创建的图像
如果你曾经去过一个新城镇,想找一家好餐馆,你可能知道 Yelp。作为一家企业,Yelp 专注于创建和维护总结大量信息的产品,以帮助客户做出明智的决策。它追踪世界各地成千上万不同企业的公众形象。
组织所有这些数据并不是一件容易的事情,因此 Yelp 一直在寻找有前途的数据科学家加入其行列。
Yelp 的数据科学家必须编写不同难度的查询。为了在工作中取得成功,数据科学家候选人必须具备扎实的基本和中级 SQL 概念的基础知识。我们来看看回答技术面试问题你应该知道的具体概念。
面试中测试基本到中级概念
最佳候选人用最少的代码行编写回答问题的查询。如果没有对 SQL 概念、语句和子句的深入了解,这通常是不可能的。
扎实的基础知识可以帮助你获得一份工作。更重要的是,一旦你有了工作,掌握实用和理论上的 SQL 可以帮助你保住工作。雇主希望有人写防呆代码。Yelp 也不例外。要编写这样的查询,您需要对 SQL 有深刻的理解。
作者在 Canva 上创建的图片
以下是回答这类数据科学面试问题时你应该知道的概念:
选择/从
当您开始学习 SQL 时,SELECT 和 FROM 语句通常是您学习的最初几个概念。我们使用 FROM 语句来指定我们将使用的表。我们使用 SELECT 语句来查看表中的特定列。
这两种说法很容易掌握。不管有多难,SELECT 和 FROM 对于编写任何 SQL 查询都是必不可少的。因此,如果您没有 SQL 的工作知识,这两条语句是一个很好的起点。
哪里
Yelp 的用户通常希望看到最受欢迎的商家。过滤这些数据是 Yelp 数据科学家工作的一大部分。
WHERE 语句允许您设置条件。编写 SQL 查询时,其主要目的是确保只返回满足指定条件的记录。WHERE 语句中可以使用许多运算符。具体可以用:equals(=),not equals(!=)、大于(>)、小于(<)以及比较运算符的其他变体。对数值使用比较运算符很容易。
您还应该知道如何比较非数值(使用=或!=比较运算符)。要编写条件,必须使用正确的语法规则。例如,要将列值与文本值进行比较,需要单引号“”。要编写条件,必须使用正确的语法规则。例如,要将列值与文本值进行比较,需要单引号“”。此外,您应该知道如何使用字母和不同的运算符按照字母顺序进行排序。
例如,您可能需要删除以字母 A、B 或 c 开头的值,SQL 中的 WHERE 语句非常通用,可以应用于许多不同的任务。
最小值/最大值()
这些问题以及类似的问题通常会测试您对中级 SQL 概念的了解,例如 SQL 中的 MIN()和 MAX()聚合函数。在我们的例子中,候选人必须找到拥有最多“酷”选票的企业。在这种任务中,MAX()聚合函数会很有用。它返回指定列中具有最大值的行。MIN()返回最低值。
对于数值,使用这些聚合函数是很直观的。此外,了解 MIN()和 MAX()在应用于非数值时的工作方式也很有用。例如,MIN()函数可用于查找一列中最早的日期或以 A 开头的单词或字母表中其他较早的字母。MAX()可以做相反的事情。阅读本文“ 基础 SQL 面试问题 ”了解更多关于聚合函数的知识。
排序依据
通常会遇到 SQL 面试问题,要求您找出特定列中具有最高值的行。理解这一概念对于回答今天数据科学访谈中提出的大多数 SQL 问题至关重要。ORDER BY 允许您指定行排列的标准。该子句通常与另外两个关键字一起使用:DESC 和 ASC。这些关键字帮助您指定是希望结果按升序还是降序排列。
在处理数字时,ORDER BY 子句的默认行为很容易预测:它将从最小的数字到最大的数字对值进行排序。数据科学家职位的优秀候选人还应该知道如何对字母和日期值使用 ORDER BY。
内部连接
连接是 SQL 中不可或缺的概念。任何有抱负的数据科学家都应该在进入更高级的概念之前掌握连接。由于其实用性,内部连接可用于回答各种各样的 SQL 问题。
首先,考生应该理解编写 SQL 查询的语法。例如,您可以从组合表中选择列,而不仅仅是 from 语句后面的列。此外,如有必要,他们应该能够演示如何从一个表中选择列。
具有编写 SQL 查询实际知识的数据科学家也应该理解编写别名的重要性。他们还应该熟悉用 SQL 编写别名的语法,并理解它们在提高 SQL 查询可读性方面的作用。
编写内部连接的另一个重要方面是 ON 语句。熟练的数据科学家应该了解它的用途,并能够正确地将值从一个表映射到另一个表。选择正确的连接键来获得期望的结果也很重要。
考生应该能够解释多种联接类型之间的区别。当两个表中的记录没有任何公共值时会发生什么?INNER JOIN 是做什么的?根据资历级别,候选人应该能够区分内部和外部连接,并选择正确的类型来解决手头的问题。
在本文中,我们将回顾一个问题,并使用内部连接的关键特性来得出答案。
Yelp 数据科学家面试问题演练
在本文中,我们将重点关注 Yelp 数据科学家职位候选人的问题。
顶级酷票
这个问题目前在 StrataScratch 平台上被标记为‘中等’难度。条件相当简单明了。候选人必须编写一个查询来查找具有最高“酷”票数的记录,并以特定的格式返回。
截图来自 StrataScratch
在这个问题中,候选人必须使用一个有 9 列的表格。他们必须编写 SQL 查询来查找在 cool 列中具有最高整数值的记录。然后,受访者必须输出两列各自的值, business_name 和 review_text 。
这项任务很简单,有很多方法可以得到想要的结果。因此,您的重点应该是编写一个优化的 SQL 查询。只要您理解了本文中概述的原则和陈述,您应该能够找到一个运行良好且不过分冗长的解决方案。
可用数据集
截图来自 StrataScratch
数据假设
解决任何 SQL 问题的第一步,也可能是最重要的一步是研究可用数据。
首先要做的是扫描列名及其对应的数据类型。你可以利用这个机会形成第一印象,进入思维空间解决问题。如果有些事情不清楚,你可以提问来检查你的假设。
只要有可能,也试着从表中查看实际记录。看到实际的数据可以帮助你更好地理解你将使用什么样的价值观。除了实际数据之外,注意列名和数据类型将为您制定计划提供足够的信息。
如果你已经分析了数据,但事情仍然不清楚,不要害怕要求澄清一些观点。你可以通过问一些具体的、指示性的问题来获得更多的信息。
根据问题的表述,您应该准备好对值进行造型、格式化或以任何其他方式操作数据。一旦完成了对表的分析,您还应该清楚哪些列是重要的,哪些列可以安全地忽略。
让我们来看看 yelp_reviews 表的列:
- 这个问题的最终解决方案应该返回 business_name 列中的值。我们将使用 SELECT 语句来查看该列。
- 我们的问题没有提到识别每个评论,所以我们可以忽略 review_id 列。
- 这个问题不要求我们识别用户,所以我们可以忽略 user_id 列
- 我们不必用‘星星’的数量作为判断标准,所以星星列可以忽略。
- 由于时间顺序对解决方案并不重要,我们可以忽略 review_date 列
- 最终输出必须包括来自 review_text 列的值,因此我们将需要它。
- 该问题要求我们确定拥有最多“酷”投票的企业,因此我们需要使用酷列。另外两个栏目——搞笑和有用可以忽略。
这个具体问题可以用许多不同的方法来解决。如果你只能选择一个解决方案,但无法在几个好的选项中做出选择,你应该问问面试官。有时候面试官可能更喜欢标准 SQL 而不是它的许多方言。
解决方案逻辑
一旦您理解了问题和包含所有数据的表,您就可以轻松地回答像这样的复杂的 SQL 问题。首先,我们必须制定回答这个问题的方法:
- 根据问题描述,我们需要输出两列— business_name 和 review_text 。首先,我们必须使用 SELECT 语句来查看它们。
- 然后,我们必须编写 SELECT 语句,但这次是为了查看具有最高票数的记录。我们可以使用 MAX()聚合函数找到这个记录。
- 最后,我们编写 ON 语句来过滤掉所有没有最高“酷”票数的企业。
要回答 Yelp 数据科学家的采访问题,您必须具备良好的基本和中级 SQL 概念的工作知识。在第一步中,我们需要使用 SELECT 和 FROM 语句的组合。这是任何 SQL 查询的最基本的构建块,因此您应该轻松地完成这一步。
在下一步中,我们使用 MAX()聚合函数来查找 cool 列中的最大数值。函数的名称是自我描述的:它返回列中的最大值。
请注意,在执行任何类型的连接时,为表提供别名总是一个好主意。这样,您不必每次都键入表名。可以在每个表名后分配别名。您留下一个空格,并为该表编写别名。
看着这个问题,有人可能会想:如果两个或更多的企业分享最高数量的“酷”票会怎么样?在我们的解决方案中,我们使用内部连接来保留拥有最高票数的企业,并过滤掉其余的企业。如果有三家企业获得了最高票数,我们的最终结果将包括这三家企业。
常见错误解决方案
正如我们之前提到的,知识渊博的数据科学家可以找到多种方法来解决问题。最常见的错误之一是使用 ORDER BY 和 LIMIT 语句。使用这种方法,我们编写一个查询,以降序对“cool”列中的值进行排序,并使用 LIMIT 语句显示第一个值。
这种方法的问题是,最有可能的是,将有多个企业拥有最高数量的“酷”票。根据问题,我们需要退回所有这些业务,而不仅仅是一个。如果我们不知道有多少企业共享最高票数,极限陈述实际上是没有用的。
SELECT business_name,
review_text
FROM yelp_reviews
ORDER BY cool DESC
LIMIT 1
正确的解决方案
编写子查询来查找最高数量的“酷”票
首先,我们必须编写一个子查询,它将返回“cool”列中的最大值。我们使用 AS 语句,所以我们可以将这个值称为’ max_cool '。
SELECT max(cool) AS max_cool
FROM yelp_reviews
运行此代码将返回以下输出:
截图来自 StrataScratch
这一步帮助我们解决了一个难题——我们知道在 yelp_reviews 表格中,企业获得“酷”投票的最高可能数量。
抓取感兴趣的栏目
首先,我们必须选择最终输出中包含的两个字段。我们应该返回来自 yelp_reviews 表中两列的值,这两列是 business_name 和 review_text 列。完成后,SQL 代码将如下所示:
SELECT business_name,
review_text
FROM yelp_reviews
该查询将返回具有相应 review_text 值的所有企业。让我们来看看:
截图来自 StrataScratch
使用内部连接过滤掉业务
在最后一步,我们使用内部连接过滤掉没有达到最大“酷”票数的企业。
为了实现这一点,我们将使用之前创建的子查询内部连接 yelp_reviews 表。
我们将给出每个表的别名,以使查询更具可读性。“yr”用于 yelp_reviews 表,而“mc”用于子查询。
SELECT business_name,
review_text
FROM yelp_reviews yr
INNER JOIN (
SELECT
max(cool) as max_cool
FROM yelp_reviews) mc
ON yr.cool = mc.max_cool
最后,我们必须选择连接键并编写 on 语句的条件。我们必须参考 yelp_reviews 表中的 cool 列,并将其设置为最大值。
这是最终答案。如果我们运行代码,输出将如下所示:
截图来自 StrataScratch
这两家公司都拥有最高的“酷”票数。
另一个正确的解决方案
使用内部连接过滤掉投票数低于最大值的企业非常简单,但是还有一个更简单的解决方案。
逻辑大致相同:我们选择想要查看的列,并有条件地返回具有最高“酷”投票数的记录。就像使用 INNER JOIN 的解决方案一样,我们使用 max()聚合函数来查找最高票数。主要区别在于,使用这种方法,我们使用 WHERE 语句来过滤企业。
该解决方案回答了 Yelp 数据科学家的采访问题,并处理了边缘案例。运行该查询将返回任意数量的具有最高票数的企业。有人可能会说,由于这种解决方案只需要编写较少的代码,因此是一种更优化的解决方案。
SELECT business_name,
review_text
FROM yelp_reviews
WHERE cool =
(SELECT max(cool)
FROM yelp_reviews)
最后的话
像 Yelp 这样的公司正在寻找编写简单而有效的 SQL 查询的数据科学家。为面试做好充分准备可以显著增加你获得数据科学工作的机会。如果你想知道如何构建你的代码,特别是在性能和可读性方面,我们推荐我们的帖子“ 编写 SQL 查询的最佳实践 ”。
在这篇文章中,我们回答了一个有趣的问题,这个问题是问参加 Yelp 数据科学家职位面试的候选人的。这不是最容易解决的问题,但面试官用它来衡量候选人的 SQL 知识的深度。
最初发表于https://www.stratascratch.com。
是的,Python 有一个内置的数据库。以下是使用方法。
Python 中 SQLite 的简单指南。
图片来自 Shutterstock,授权给 Frank Andrade
信不信由你,在你的电脑上安装 Python 的那一刻,你也安装了其他奇妙的工具。其中之一就是 SQLite。
SQLite 是一个嵌入式的、基于文件的关系数据库管理系统(RDBMS ),可以在我们的 Python 应用程序中使用,而无需安装任何额外的软件。相反,我们只需要导入内置的 Python 库sqlite3
就可以使用这个数据库。
在本指南中,我们将了解如何连接到数据库,创建表,将数据插入表中,以及如何将其与 Pandas 集成。
如果你不想看,可以看我的 YouTube 视频!
请务必点击 订阅此处 获取我在所有教程中使用的 SQL 备忘单(免费 PDF)
创建到数据库的连接
我们要做的第一件事是创建一个到数据库的连接。为此,我们只需要导入 sqlite3 并使用.connect
方法。在括号内,我们写下我们想要创建的数据库的名称。在我的例子中,我将其命名为“students.db”
**import** sqlite3
# create a connection
conn = sqlite3.connect('students.db')
如果您运行上面的代码,将在您的工作目录中创建一个名为“students.db”的新文件。
作者图片
现在我们可以创建一个表并将数据放入其中。
创建表格
在创建表之前,我们需要创建一个游标。游标是一种用于建立执行 SQL 查询的连接的对象。我们将使用光标来创建表格、插入数据等等。
要创建一个光标,我们只需要使用我们已经创建的连接和.cursor
方法。
c = conn.cursor()
之后,我们使用.execute
方法在数据库中创建一个新表。在引号内,我们编写了用于在大多数 RDBMS 中创建表的普通 SQL 语法。在这种情况下,我们使用CREATE TABLE
语句。
c.**execute**("""**CREATE TABLE** students (
name **TEXT**,
age **INTEGER**,
height **REAL**
)""")
如您所见,在创建表的列时,我们需要定义数据类型。与大多数拥有数十种数据类型的 RDBMS 不同,SQLite 只有 5 种数据类型:
- Null:缺少值
- 整数:没有小数点的数字(例如 1、2、3、4)
- 实数:带小数点的数字(如 6.2、7.6、11.2)
- 文本:任何字符数据
- Blob:作为值存储在数据库中的二进制数据的集合。它允许我们在数据库中存储文档、图像和其他多媒体文件。
最后,我们必须提交并关闭连接。以下是目前为止的代码。
太好了!我们已经创建了第一个表,但是它是空的,所以让我们将一些数据放入其中。
将数据插入表格
让我们从向“学生”表添加一行开始。为此,我们再次使用.execute
,但是现在我们使用INSERT INTO
语句。
下面我补充一个学生“马克”的数据,他今年 20 岁,身高 1.9 米。
c.**execute**("**INSERT INTO** students **VALUES** ('mark', 20, 1.9)")
注意,在运行上面的代码之前,您需要注释掉CREATE TABLE
语句,因为该表已经存在。
我们也可以插入多行,但是在这种情况下,我们使用.executemany
方法。除此之外,我们使用?
作为占位符。这有助于我们从名为all_students
的列表中添加数据。
all_students = [
('john', 21, 1.8),
('david', 35, 1.7),
('michael', 19, 1.83),
]
c.**executemany**("**INSERT INTO** students **VALUES** (?, ?, ?)", all_students)
从表 a 中选择数据显示数据
到目前为止,我们已经创建了一个表并将数据放入其中,但是我们还没有看到我们的表。要查看我们的数据,我们首先需要用SELECT
语句从我们的表中选择数据,然后用.fetchall
显示它。
c.execute("**SELECT** * **FROM** students")
**print**(c**.fetchall()**)
打印的输出应该是:
[(‘mark’, 20, 1.9), (‘john’, 21, 1.8), (‘david’, 35, 1.7), (‘michael’, 19, 1.83)]
如果您不想在每次想要查看表格中的数据时都重复这些步骤,您可以使用 SQLiteViewer 。在那里你只需要拖动你的。db 文件来查看其内容。
作者图片
这是我们到目前为止所做的一切
这是 Python 中 SQLite 的基础。在 SQLite 中,更新行、删除行、排序数据和删除表也是可能的。您只需要使用您的 SQL 知识来执行它们。
使用 Pandas 和 SQLite
SQLite 可以与 Pandas 中的 dataframes 集成。例如,我们将使用一个名为population_total.csv
的 CSV 文件,您可以在这里下载。
**import** pandas **as** pd
df = pd.read_csv("population_total.csv")
以下是数据帧的外观:
>>> df country year population0 China 2020.0 1.439324e+09
1 China 2019.0 1.433784e+09
2 China 2018.0 1.427648e+09
3 China 2017.0 1.421022e+09
4 China 2016.0 1.414049e+09
... ... ... ...
4180 United States 1965.0 1.997337e+08
4181 United States 1960.0 1.867206e+08
4182 United States 1955.0 1.716853e+08
4183 India 1960.0 4.505477e+08
4184 India 1955.0 4.098806e+08
现在让我们创建一个内存中的 SQLite 数据库。为此,首先,我们需要安装 sqlalchemy: pip install sqlalchemy
然后我们需要创造一个引擎。
**from** sqlalchemy **import** create_engine
engine = create_engine('sqlite://', echo=**False**)
现在让我们将数据帧附加到数据库中的一个表中(这个表不需要预先创建)。在本例中,我将把df
附加到一个我命名为“population”的表中。
df.**to_sql**("population", con=engine)
要查看我们的表,我们运行下面的代码。
engine.**execute**("**SELECT** * **FROM** population").fetchall()
注意:如果您想要创建一个 sqlite 文件(而不是内存中的数据库),您应该创建一个带有文件数据库的引擎。
让我们创建一个mydb.db
文件,然后将df
数据帧附加到一个“人口”表。
**from** sqlalchemy **import** create_engine
engine = create_engine("sqlite:///mydb.db")df.to_sql("population", engine)
同样,您可以使用.fetchall
来查看表格或使用 SQLite Viewer。
恭喜你!现在您知道如何在 Python 中使用 SQLite,甚至将它与 Pandas 中的 dataframes 集成。
学习 SQL —数据专业人员最需要的技能。 加入我的 20k+人电子邮件列表,获取我的免费 SQL 备忘单。
如果你喜欢阅读这样的故事,并想支持我成为一名作家,可以考虑报名成为一名媒体成员。每月 5 美元,让您可以无限制地访问数以千计的 Python 指南和数据科学文章。如果你用我的链接注册,我会赚一小笔佣金,不需要你额外付费。
https://frank-andrade.medium.com/membership
YOLOv6:下一代物体探测—回顾与比较
图片来自 Unsplash
近年来,计算机视觉领域发展迅速,并取得了几年前看起来像科幻小说一样的成果。从分析 x 光图像和诊断病人到(半)自动驾驶汽车,我们正在见证一场革命。这些突破有很多原因——构建更好、更易访问的计算资源,但事实上它们是我们最接近开源数据科学 (OSDS)的东西。向社区公开源代码可以释放“群众的智慧”,实现大规模创新和解决问题。
计算机视觉领域最受欢迎的操作系统项目之一是 YOLO(你只需看一次)。YOLO 是一种高效的实时对象检测算法,由 Joseph Redmon 等人在 2015 年的开创性论文中首次描述。YOLO 将图像划分为一个网格系统,每个网格检测自身内部的对象。它可以用于实时推理,并且需要很少的计算资源。
今天,在第一版 YOLO 发布 7 年后,美团的研究小组发布了新的 YOLOv6 型号——它是来踢一脚的!
YOLO 的历史
YOLO 之前的目标检测
在 YOLO 之前,两阶段对象检测架构主导了该领域。它使用基于区域的分类器来定位区域,然后将它们传递给更健壮的分类器。虽然这种方法给出了具有高平均精度(mAP)的精确结果,但是它是非常资源密集的,在其操作中需要多次迭代。
两阶段对象检测,来自纸张的图像
YOLO 是如何工作的?
YOLO 提出了一种不同的方法,其中两个阶段都在同一个神经网络中进行。首先,图像被分成单元,每个单元具有 SxS 的等维区域。然后,每个单元用边界框坐标(相对于其坐标)和对象标签以及该事物出现在单元中的概率来检测和定位它所包含的对象。
YOLOv1,图像来自原纸
因为每个单元“独立工作”,所以它可以同时处理网格,减少了训练和推断所需的计算能力和时间。事实上,YOLO 实现了最先进的结果,击败了其他实时对象检测算法。
YOLO 有哪些版本?
- yolov 1(2015 年 6 月):你只看一次:统一的、实时的物体检测
- yolo v2(2016 年 12 月): YOLO9000:更好、更快、更强
- yolo v3(2018 年 4 月): YOLOv3:增量改进
- yolov 4(2020 年 4 月): YOLOv4:物体检测的最佳速度和精度
- yolov 5(2020 年 5 月): Github repo (尚未发布论文)
YOLOv6 是来踢**和取名字的
MT-YOLOv6 的灵感来自最初的一级 YOLO 建筑,因此被其作者(勇敢地)命名为 YOLOv6。虽然它提供了出色的结果,但值得注意的是 MT-YOLOv6 不是官方 YOLO 系列的一部分。
YOLOv6 是一个专用于工业应用的单级对象检测框架,具有硬件友好的高效设计和高性能。它在检测准确性和推理速度方面优于 YOLOv5,是生产应用中 YOLO 架构的最佳 OS 版本。
YOLOv6 成就
- yolov 6-nano-在 COCO val2017 数据集上实现 35.0 地图,在 T4 上使用 TensorRT FP16 进行 bs32 推理,每秒 1242 帧
- yolov 6-s-在 COCO val2017 数据集上实现 43.1 地图,在 T4 上使用 TensorRT FP16 进行 bs32 推理,每秒 520 帧。
单一图像推理
来自 YOLOv6 存储库的图像
YOLOv6s (red)提供了比所有以前版本的 YOLOv5 更好的平均精度(mAP ),推理时间大约快 2 倍。我们还可以看到基于 YOLO 的架构和基于两阶段对象检测的 EfficientDet 之间的巨大性能差距。
视频推理
来自 YOLOv6 存储库的图像
与单个图像推断相同,YOLOv6 在所有 FPS 频谱上为视频提供了更好的结果。有趣的是注意到了大约 550–620 FPS 的曲线变化。我想知道这是否与硬件性能有关,以及维护人员在进行实验时是否减少了硬件的偏差。
基准
作者图片
- 在 COCO val2017 数据集上测试了不同物体探测器的地图和速度的比较。
- 其他方法的速度结果在维护者的环境中使用官方代码库和模型进行了测试,如果在相应的官方版本中没有找到的话。
免责声明:上述评论是基于作者的说法,我们还有待核实。
YOLOv5 与 YOLOv6
YOLOv5 和 YOLOv6 的性能指标评测比较
在研究这两种型号的基准时,我发现很难对苹果进行比较。YOLOv6 的型号较少(缺少 m/l/x),并且没有任何大于 640 像素的图像信息。对于两个项目报告的基准,我们可以清楚地看到 YOLOv6 在 mAP 方面的改进。然而,v6 的参数和失败次数是 v5 的两倍,这让我想亲自深入训练过程,仔细检查下面的结果。
作者图片
YOLOv5 和 YOLOv6 之间的定性比较
我使用两种型号的 s 版本来检测以下图像中的对象:
YOLOv6 性能,图像来自 YOLOv5 存储库
YOLOv5 表演,图片来自 YOLOv5 知识库
YOLOv6 性能,图像来自 YOLOv5 存储库
YOLOv5 表演,图片来自 YOLOv5 知识库
我们可以清楚地看到 YOLOv6s 在图像中检测到更多的物体,并且对它们的标签有更高的信心。
灵活性
两个项目都有相似的方法来创建不同的模型大小。最大的区别是 YOLOv5 使用 YAML ,而 YOLOv6 直接在 Python 中定义模型参数。预示性的一瞥也表明 YOLOv5 可能在一定程度上更具可定制性。
然而,YOLOv6 如此灵活的事实意味着我们可以在未来看到更大版本的 YOLOv6,甚至更高精度的预测!
如果你创造了一个更大的 YOLOv6 模型,让我们知道不和谐!我们很想看看!
使用
你可以使用 DagsHub 的应用与 YOLOv6 的最新版本进行交互。如果您想在本地计算机上使用它,请按照下列步骤操作:
安装
git clone https://dagshub.com/nirbarazida/YOLOv6 cd
YOLOv6 pip install -r requirements.txt
dvc pull
推论
使用 YOLOv6s: python tools/infer.py --weights yolov6s.pt --source <path to image/directory>
使用 YOLOv6n: python tools/infer.py --weights yolov6n.pt --source <path to image/directory>
结论
YOLOv6 是最近发布的最令人兴奋的 OSDS 项目之一。与以前的 YOLO 版本相比,它提供了最先进的结果和各方面的显著改进。维护人员目前专注于丰富模型类型、部署选项和量化工具。尽管如此,与任何开源项目一样,社区可以极大地影响其路线图和进度曲线。
尽管该项目仍处于早期阶段,但它看起来非常有前途,我很想知道它将来会打破哪些基准。
YOLOv7:深入了解当前物体检测的最新技术
在定制培训脚本中使用 YOLOv7 需要知道的一切
在其发布后不久,YOLOv7 是用于计算机视觉任务的最快和最准确的实时对象检测模型。官方论文在 MS COCO 数据集上展示了这种改进的架构如何在速度和准确性方面超越所有以前的 YOLO 版本以及所有其他对象检测模型;在不使用任何预训练砝码的情况下实现这一性能。此外,在围绕以前的 YOLO 模型的命名惯例的所有争议之后,由于 YOLOv7 是由开发 Scaled-YOLOv4 的同一作者发布的,机器学习社区似乎很乐意接受这是“官方”YOLO 家族的下一个迭代!
在 YOLOv7 发布的时候,我们——作为微软数据和人工智能服务线的一部分——正在进行一个具有挑战性的基于对象检测的客户项目,这个领域与 COCO 完全不同。不用说,我们和客户都对将 YOLOv7 应用于我们的问题的前景感到非常兴奋。不幸的是,当使用开箱即用的设置时,结果是…这么说吧,不太好。
在阅读了官方论文后,我们发现,虽然它对架构的变化进行了全面的概述,但它忽略了许多关于模型如何被训练的细节;例如,应用了哪些数据扩充技术,以及损失函数如何衡量模型做得好不好!为了理解这些技术细节,我们决定直接调试代码。然而,由于 YOLOv7 库是 YOLOR codebase 的一个派生版本,而 YOLOR code base 本身是 YOLOv5 的一个派生版本,我们发现它包含了很多复杂的功能,其中很多在训练模型时并不需要;例如,能够以 Yaml 格式指定定制架构,并将其转换成 PyTorch 模型。此外,代码库包含许多已经从头实现的自定义组件,如多 GPU 训练循环、若干数据扩充、保存数据加载器工作器的采样器和多学习率调度器,其中许多现在可以在 PyTorch 或其他库中获得。结果,有很多代码需要分析;我们花了很长时间来理解一切是如何工作的,以及训练循环的复杂性,这有助于模型的出色性能!最终,有了这种理解,我们就能够建立我们的训练食谱,在我们的任务中获得持续的好结果。
在本文中,我们打算采用一种实用的方法来演示如何在定制的训练脚本中训练 YOLOv7 模型,以及探索诸如数据扩充技术、如何选择和修改锚盒以及揭示损失函数如何工作等领域;(希望如此!)使你能够建立一种直觉,知道什么可能对你自己的问题有效。由于 YOLOv7 架构在官方文件以及许多其他来源中都有详细的描述,所以我们在这里不打算讨论它。相反,我们打算关注所有其他细节,这些虽然对 YOLOv7 的性能有所贡献,但没有在论文中涉及。这往往是通过多个版本的 YOLO 模型积累起来的知识,但对于刚进入该领域的人来说,要找到这些知识可能非常困难。
为了说明这些概念,我们将使用我们自己的 YOLOv7 实现,它利用了官方的预训练权重,但在编写时考虑了模块化和可读性。这个项目最初是为了让我们更好地了解 YOLOv7 的工作原理,以便更好地理解如何应用它,但在成功地将它用于几个不同的任务后,我们决定将其公开。虽然我们建议使用官方实现,如果你想准确地复制 COCO 上的公布结果,我们发现这种实现更灵活地应用和扩展到自定义域。希望这个实现能够为任何希望在自己的定制培训脚本中使用 YOLOv7 的人提供一个清晰的起点,同时为最初实现中培训时使用的技术提供更多的透明度。
在本文中,我们将讨论:
一路探索所有细节,例如:
Tl;博士: 如果你只想看到一些可以直接使用的工作代码,复制这篇文章所需的所有代码都可以在笔记本 这里 中找到。虽然在整篇文章中使用了代码片段,但这主要是出于美观的目的,请遵从笔记本,而 和 为工作代码。
承认
我们要感谢英国航空公司,如果没有他们持续延误的航班,这篇文章可能就不会出现。
数据加载
首先,让我们看看如何以 YOLOv7 期望的格式加载我们的数据集。
选择数据集
贯穿本文,我们将使用 Kaggle 汽车对象检测数据集;然而,由于我们的目的是演示 YOLOv7 如何应用于任何问题,这实际上是这项工作中最不重要的部分。此外,由于图像与 COCO 非常相似,这将使我们能够在进行任何训练之前,用预训练的模型进行实验。
该数据集的注释采用. csv 文件的形式,该文件将图像名称与相应的注释相关联;其中每行代表一个边界框。虽然在训练集中有大约 1000 幅图像,但是只有那些带有注释的图像才包含在这个文件中。
我们可以通过将它加载到熊猫数据帧中来查看它的格式。
由于我们的数据集中并非所有图像都包含我们试图检测的对象的实例,因此我们还希望包含一些不包含汽车的图像。为此,我们可以定义一个函数来加载注释,其中也包括 100 张“负面”图像。此外,由于指定的测试集是未标记的,让我们随机选取这些图像的 20%作为我们的验证集。
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/examples/train_cars.py
import pandas as pd
import random
def load_cars_df(annotations_file_path, images_path):
all_images = sorted(set([p.parts[-1] for p in images_path.iterdir()]))
image_id_to_image = {i: im for i, im in enumerate(all_images)}
image_to_image_id = {v: k for k, v, in image_id_to_image.items()}
annotations_df = pd.read_csv(annotations_file_path)
annotations_df.loc[:, "class_name"] = "car"
annotations_df.loc[:, "has_annotation"] = True
# add 100 empty images to the dataset
empty_images = sorted(set(all_images) - set(annotations_df.image.unique()))
non_annotated_df = pd.DataFrame(list(empty_images)[:100], columns=["image"])
non_annotated_df.loc[:, "has_annotation"] = False
non_annotated_df.loc[:, "class_name"] = "background"
df = pd.concat((annotations_df, non_annotated_df))
class_id_to_label = dict(
enumerate(df.query("has_annotation == True").class_name.unique())
)
class_label_to_id = {v: k for k, v in class_id_to_label.items()}
df["image_id"] = df.image.map(image_to_image_id)
df["class_id"] = df.class_name.map(class_label_to_id)
file_names = tuple(df.image.unique())
random.seed(42)
validation_files = set(random.sample(file_names, int(len(df) * 0.2)))
train_df = df[~df.image.isin(validation_files)]
valid_df = df[df.image.isin(validation_files)]
lookups = {
"image_id_to_image": image_id_to_image,
"image_to_image_id": image_to_image_id,
"class_id_to_label": class_id_to_label,
"class_label_to_id": class_label_to_id,
}
return train_df, valid_df, lookups
我们现在可以使用这个函数来加载我们的数据:
为了更容易地将预测与图像相关联,我们为每个图像分配了一个唯一的 id;在这种情况下,它只是一个递增的整数计数。此外,我们添加了一个整数值来表示我们想要检测的类,在本例中是一个单独的类,即“car”。
一般物体检测模型都会预留0
作为背景类,所以类标签要从1
开始。YOLOv7 的情况是而不是,所以我们从0
开始我们的类编码。对于不包含汽车的图像,我们不需要类别 id。我们可以通过检查函数返回的查找来确认这一点。
最后,让我们看看我们的训练和验证集的每个类中的图像数量。由于一幅图像可能有多个注释,我们需要确保在计算计数时考虑到这一点:
创建数据集适配器
通常,在这一点上,我们会创建一个 PyTorch 数据集,该数据集特定于我们将要训练的模型。
然而,我们经常使用首先创建数据集‘adaptor’类的模式,单独负责包装底层数据源并适当地加载它。通过这种方式,我们可以在使用不同数据集时轻松切换适配器,而无需更改任何特定于我们正在训练的模型的预处理逻辑。
因此,现在让我们专注于创建一个CarsDatasetAdaptor
类,它将特定的原始数据集格式转换成图像和相应的注释。此外,让我们加载我们分配的图像 id,以及我们的图像的高度和宽度,因为它们可能对我们以后有用。
这一点的实现如下所示:
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/examples/train_cars.py
from torch.utils.data import Dataset
class CarsDatasetAdaptor(Dataset):
def __init__(
self,
images_dir_path,
annotations_dataframe,
transforms=None,
):
self.images_dir_path = Path(images_dir_path)
self.annotations_df = annotations_dataframe
self.transforms = transforms
self.image_idx_to_image_id = {
idx: image_id
for idx, image_id in enumerate(self.annotations_df.image_id.unique())
}
self.image_id_to_image_idx = {
v: k for k, v, in self.image_idx_to_image_id.items()
}
def __len__(self) -> int:
return len(self.image_idx_to_image_id)
def __getitem__(self, index):
image_id = self.image_idx_to_image_id[index]
image_info = self.annotations_df[self.annotations_df.image_id == image_id]
file_name = image_info.image.values[0]
assert image_id == image_info.image_id.values[0]
image = Image.open(self.images_dir_path / file_name).convert("RGB")
image = np.array(image)
image_hw = image.shape[:2]
if image_info.has_annotation.any():
xyxy_bboxes = image_info[["xmin", "ymin", "xmax", "ymax"]].values
class_ids = image_info["class_id"].values
else:
xyxy_bboxes = np.array([])
class_ids = np.array([])
if self.transforms is not None:
transformed = self.transforms(
image=image, bboxes=xyxy_bboxes, labels=class_ids
)
image = transformed["image"]
xyxy_bboxes = np.array(transformed["bboxes"])
class_ids = np.array(transformed["labels"])
return image, xyxy_bboxes, class_ids, image_id, image_hw
注意,对于我们的背景图像,我们只是为边界框和类 id 返回一个空数组。
利用这一点,我们可以确认数据集的长度与我们之前计算的训练图像的总数相同。
现在,我们可以用它来可视化我们的一些图像,如下所示:
创建 YOLOv7 数据集
现在我们已经创建了数据集适配器,让我们创建一个数据集,它将我们的输入预处理成 YOLOv7 不管我们使用的适配器是什么,这些步骤都应该保持不变。
这一点的实现如下所示:
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/dataset.py
class Yolov7Dataset(Dataset):
"""
A dataset which takes an object detection dataset returning (image, boxes, classes, image_id, image_hw)
and applies the necessary preprocessing steps as required by Yolov7 models.
By default, this class expects the image, boxes (N, 4) and classes (N,) to be numpy arrays,
with the boxes in (x1,y1,x2,y2) format, but this behaviour can be modified by
overriding the `load_from_dataset` method.
"""
def __init__(self, dataset, transforms=None):
self.ds = dataset
self.transforms = transforms
def __len__(self):
return len(self.ds)
def load_from_dataset(self, index):
image, boxes, classes, image_id, shape = self.ds[index]
return image, boxes, classes, image_id, shape
def __getitem__(self, index):
image, boxes, classes, image_id, original_image_size = self.load_from_dataset(
index
)
if self.transforms is not None:
transformed = self.transforms(image=image, bboxes=boxes, labels=classes)
image = transformed["image"]
boxes = np.array(transformed["bboxes"])
classes = np.array(transformed["labels"])
image = image / 255 # 0 - 1 range
if len(boxes) != 0:
# filter boxes with 0 area in any dimension
valid_boxes = (boxes[:, 2] > boxes[:, 0]) & (boxes[:, 3] > boxes[:, 1])
boxes = boxes[valid_boxes]
classes = classes[valid_boxes]
boxes = torchvision.ops.box_convert(
torch.as_tensor(boxes, dtype=torch.float32), "xyxy", "cxcywh"
)
boxes[:, [1, 3]] /= image.shape[0] # normalized height 0-1
boxes[:, [0, 2]] /= image.shape[1] # normalized width 0-1
classes = np.expand_dims(classes, 1)
labels_out = torch.hstack(
(
torch.zeros((len(boxes), 1)),
torch.as_tensor(classes, dtype=torch.float32),
boxes,
)
)
else:
labels_out = torch.zeros((0, 6))
try:
if len(image_id) > 0:
image_id_tensor = torch.as_tensor([])
except TypeError:
image_id_tensor = torch.as_tensor(image_id)
return (
torch.as_tensor(image.transpose(2, 0, 1), dtype=torch.float32),
labels_out,
image_id_tensor,
torch.as_tensor(original_image_size),
)
让我们使用这个数据集包装我们的数据适配器,并检查一些输出:
由于我们没有定义任何转换,输出基本上是相同的,主要的例外是盒子现在是规范化的 cxcywh 格式,并且我们所有的输出都被转换成张量。注意cx
,cy
代表中心 x 和 y,这意味着坐标对应于盒子的中心。
需要注意的一点是,我们的标签采用了[0, class_id, ncx, ncy, nw, nh]
的形式。张量开始处的零空间将被 collate 函数稍后使用。
转换
现在,让我们定义一些转换!为此,我们将使用优秀的albuminations 库,它提供了许多转换图像和边界框的选项。
虽然我们选择的转换很大程度上是领域特定的,但是在这里,我们将定义与原始实现中使用的转换相似的转换。
这些是:
- 在保持纵横比的同时,根据给定的输入(640 的倍数)调整图像大小
- 如果图像不是正方形,应用填充。为此,我们将遵循纸在使用灰色填充,这是一个任意的选择。
培训期间:
- 水平翻转。
我们可以使用下面的函数来创建这些转换,如下所示:
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/dataset.py
def create_yolov7_transforms(
image_size=(640, 640),
training=False,
training_transforms=(A.HorizontalFlip(p=0.5),),
):
transforms = [
A.LongestMaxSize(max(image_size)),
A.PadIfNeeded(
image_size[0],
image_size[1],
border_mode=0,
value=(114, 114, 114),
),
]
if training:
transforms.extend(training_transforms)
return A.Compose(
transforms,
bbox_params=A.BboxParams(format="pascal_voc", label_fields=["labels"]),
)
现在,让我们重新创建数据集,这一次传递将在评估期间使用的默认转换。对于我们的目标图像尺寸,我们将使用640
,这是较小的 YOLOv7 模型的训练值。一般来说,我们可以选择 8 的任意倍数。
使用这些变换,我们可以看到我们的图像已经被调整到我们的目标大小,并且应用了填充。使用填充的原因是,我们可以保持图像中对象的长宽比,但在我们的数据集中图像有一个共同的大小;使我们能够高效地批量处理它们!
使用预训练模型
既然我们已经探索了如何加载和准备我们的数据,让我们继续看看我们如何利用预训练模型来进行一些预测!
加载模型
为了理解如何与模型交互,让我们加载一个预训练的检查点,并使用它对数据集中的一些图像进行推断。由于这个检查点是在包含汽车图像的 COCO 上训练的,我们可以假设这个模型在开箱即用的情况下应该在这个任务上表现得相当好。为了查看可用的模型,我们可以导入AVAILABLE_MODELS
变量。
在这里,我们可以看到可用的模型是原始论文中定义的体系结构。让我们使用create_yolov7_model
函数创建标准的yolov7
模型。
现在,让我们来看看模型的预测。向前通过模型将返回 FPN 头给出的原始特征地图,为了将这些转换成有意义的预测,我们可以使用postprocess
方法。
考察形状,可以看到模型已经做了 25200 次预测!每个预测都有一个关联的长度为 6 的张量-条目对应于 xyxy 格式的边界框坐标、置信度得分和类别索引。
通常,对象检测模型倾向于做出许多相似的、重叠的预测。虽然有许多方法来处理这个问题,但在最初的论文中,作者使用了非最大抑制 (NMS)来解决这个问题。我们可以使用下面的函数来应用 NMS 以及第二轮置信度阈值。此外,在后处理过程中,我们通常希望过滤置信度低于预定义阈值的任何预测,让我们在此处增加置信度阈值。
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/trainer.py
def filter_eval_predictions(
predictions: List[Tensor],
confidence_threshold: float = 0.2,
nms_threshold: float = 0.65,
) -> List[Tensor]:
nms_preds = []
for pred in predictions:
pred = pred[pred[:, 4] > confidence_threshold]
nms_idx = torchvision.ops.batched_nms(
boxes=pred[:, :4],
scores=pred[:, 4],
idxs=pred[:, 5],
iou_threshold=nms_threshold,
)
nms_preds.append(pred[nms_idx])
return nms_preds
应用 NMS 后,我们可以看到,现在我们只有一个单一的预测这个图像。让我们想象一下这是什么样子:
我们可以看到这个看起来相当不错!来自模型的预测实际上比地面事实更紧密地围绕着汽车!
现在我们有了我们的预测,唯一要注意的是边界框是相对于调整后的图像大小的。为了将我们的预测缩放回原始图像大小,我们可以使用以下函数:
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/trainer.py
def scale_bboxes_to_original_image_size(
xyxy_boxes, resized_hw, original_hw, is_padded=True
):
scaled_boxes = xyxy_boxes.clone()
scale_ratio = resized_hw[0] / original_hw[0], resized_hw[1] / original_hw[1]
if is_padded:
# remove padding
pad_scale = min(scale_ratio)
padding = (resized_hw[1] - original_hw[1] * pad_scale) / 2, (
resized_hw[0] - original_hw[0] * pad_scale
) / 2
scaled_boxes[:, [0, 2]] -= padding[0] # x padding
scaled_boxes[:, [1, 3]] -= padding[1] # y padding
scale_ratio = (pad_scale, pad_scale)
scaled_boxes[:, [0, 2]] /= scale_ratio[1]
scaled_boxes[:, [1, 3]] /= scale_ratio[0]
# Clip bounding xyxy bounding boxes to image shape (height, width)
scaled_boxes[:, 0].clamp_(0, original_hw[1]) # x1
scaled_boxes[:, 1].clamp_(0, original_hw[0]) # y1
scaled_boxes[:, 2].clamp_(0, original_hw[1]) # x2
scaled_boxes[:, 3].clamp_(0, original_hw[0]) # y2
return scaled_boxes
理解损失
在我们开始训练之前,除了模型架构之外,我们还需要一个损失函数,它将使我们能够衡量我们的模型执行得有多好;为了更新我们的参数。由于对象检测是教导模型的一个难题,所以这种模型的损失函数通常相当复杂,YOLOv7 也不例外。在这里,我们将尽最大努力说明其背后的直觉,以促进其理解。
在我们深入研究实际损失函数之前,让我们先了解一些需要理解的背景概念。
锚箱
目标检测的主要困难之一是输出检测框。也就是说,我们如何训练一个模型来创建一个边界框,并在图像中正确定位它?
有几种不同的方法,但 YOLOv7 家族是我们所说的基于主播的模式。在这些模型中,一般的哲学是首先创建许多潜在的包围盒,然后选择最有希望的选项来匹配我们的目标对象;根据需要稍微移动和调整它们的大小,以获得最佳的匹配。
基本思想是,我们在每个图像的顶部绘制一个网格,并且在每个网格交叉点(锚点),基于多个锚点大小生成候选框(锚点框)。也就是说,同一组框在每个锚点重复出现。这样,model 需要学习的任务,稍微调整这些盒子的位置和大小,比从头开始生成盒子要简单。
在锚点样本处生成的锚点框的示例。
然而,这种方法的一个问题是,我们的目标,地面真相,盒子的大小可以变化——从小到大!因此,通常不可能定义一组可以匹配所有目标的锚尺寸。出于这个原因,基于锚的模型架构通常采用特征金字塔网络(FPN)来协助这一点;YOLOv7 就是这种情况。
要素金字塔网络(FPN)
FPNs(在用于对象检测的特征金字塔网络中介绍)背后的主要思想是利用卷积层的性质——减少特征空间的大小并增加初始图像中每个特征的覆盖范围——来输出不同比例的预测。fpn 通常被实现为卷积层的堆栈,正如我们通过检查 YOLOv7 模型的检测头所看到的那样。
虽然我们可以简单地将最终层的输出作为预测,但是由于较深的卷积层隐含地利用来自先前层的信息来学习更多的高级特征,因此它们无法访问如何检测包含在先前层中的较低级特征的信息;这可能导致检测较小对象时性能不佳。
由于这个原因,自上而下的路径和横向连接被添加到常规的自下而上的路径(回旋层的正常流动)。自上而下的路径通过从更高的金字塔等级向上采样空间上更粗糙但语义上更强的特征地图来产生更高分辨率的特征。然后,通过横向连接,用自下而上路径的特征增强这些特征。自底向上的特征映射具有较低层次的语义,但是它的激活被更精确地定位,因为它被二次抽样的次数更少。
总之,FPNs 在多个尺度上提供了语义强的特征,这使得它们非常适合于对象检测。下图显示了 YOLOv7 在其 FPN 中实现的连接:
yolov 7 系列特征提议网络架构的表示。来源: YOLOv7 纸 。
在这里,我们可以看到我们有一个“正常模型”和一个“带辅助头的模型”。这是因为 YOLOv7 家族中一些体型较大的车型在训练时使用了深度监督;也就是说,为了更好地学习任务,他们利用了损失中更深层的输出。稍后我们将进一步探讨这一点。
从图像中,我们可以看到 FPN 中的每个图层(也称为每个 FPN 头)的特征比例是前一个图层的一半(每个引线头及其对应的辅助头的比例相同)。这可以理解为每一个随后的 FPN 头部“看到”的物体都是前一个的两倍大。我们可以通过给每个 FPN 头分配不同步长(网格单元边长)和比例锚尺寸的网格来利用这一点。
例如,基本yolov7
模型的锚配置如下所示:
yolov 7 系列主模型中每个 fpn 头的锚网格和不同(默认)锚框尺寸的图示
正如我们所看到的,我们有锚框大小和网格,覆盖了完全不同的尺度:从微小的对象到可以占据整个图像的对象。
现在,我们从概念上理解了这些想法,让我们看看从我们的模型中得出的 FPN 输出,这将用于计算我们的损失。
这些章节直接取自 原 FPN 论文 ,因为我们觉得不需要进一步解释。
分解 FPN 产出
回想一下,当我们之前进行预测时,我们使用模型的postprocess
方法将原始 FPN 输出转换成可用的边界框。既然我们理解了 FPN 试图做什么背后的直觉,让我们检查这些原始输出。
我们模型的输出总是一个List[Tensor]
,其中每个组件对应一个 FPN 的头。对于使用深度监控的型号,辅助头输出在导联头输出之后(每一个的数量总是相同的,线对的两侧顺序相同)。其余的,包括我们在这里使用的,只有导联头输出。
检查每个 FPN 输出的形状,我们可以看到每个输出都有以下尺寸:
[n_images, n_anchor_sizes, n_grid_rows, n_grid_cols, n_features]
其中:
n_images
—批次中图像的数量(批次大小)。n_anchor_sizes
-与头部相关的锚尺寸(通常为 3)。n_grid_rows
——垂直方向上锚的数量,img_height / stride
。n_grid_cols
-水平方向的锚数量,img_width / stride
。n_features
-5 + num_classes
-
-cx
-锚箱中心水平校正。cy
-锚箱中心垂直校正。w
-锚箱宽度修正。h
-锚箱高度修正。obj_score
-与锚盒内包含对象的概率成比例的分数。cls_score
-每类一个,得分与该对象所属类别的概率成比例。
当这些输出在后处理期间被映射成有用的预测时,我们应用以下操作:
cx
、cy
:final = 2 * sigmoid(initial) - 0.5
[(∞、∞)、(∞、∞)]→[(0.5,1.5),(-0.5,1.5)]
-模型只能将锚点中心从 0.5 个单元后向前移动 1.5 个单元。请注意,对于损失(即,当我们训练时),我们使用网格坐标。w
、h
:final = (2 * sigmoid(initial)**2
[(∞、∞)、(∞、∞)] → [(0,4),(0,4)]
——模型可以任意变小,但最多变大 4 倍。更大的物体,在这个范围之外,必须由下一个 FPN 头预测。obj_score
:final = sigmoid(initial)
(∞,∞) → (0,1)
-确保分数映射到一个概率。cls_score
:final = sigmoid(initial)
(∞,∞) → (0,1)
-确保分数映射到一个概率。
中心先验
现在,很容易看出,如果我们在每个网格的每个定位点放置 3 个定位框,我们最终会得到很多框:3*80*80 + 3*40*40 + 3*20*20=25200
准确地说,是每个 640x640px 图像!问题是,这些预测中的大部分都不会包含一个我们归类为“背景”的物体。根据我们需要应用于每个预测的操作顺序,计算很容易堆积起来并减慢训练速度!
为了降低问题的计算成本,YOLOv7 loss 首先找到可能与每个目标框匹配的锚框,并对它们进行不同的处理——这些锚框被称为中心优先锚框。该过程应用于每个 FPN 头,对于每个目标框,一次批量跨越所有图像。
每个锚——我们网格中的坐标——定义一个网格单元;其中我们认为锚点位于其对应网格单元的左上方。随后,每个单元格(边界上的单元格除外)有 4 个相邻的单元格(上、下、左、右)。对于每个 FPN 头部,每个目标框位于网格单元内的某个位置。假设我们有下面的网格,目标框的中心用一个*
表示:
基于模型的设计和训练方式,它能够输出的x
和y
修正量在[-0.5, 1.5]
网格单元的范围内。因此,只有最近锚盒的子集能够匹配目标中心。我们选择这些锚框中的一些来代表目标框的之前的中心。
- 对于引线头,我们在之前使用精细中心,这是一个更有针对性的选择。这由每个头的 3 个锚组成:锚与包含目标框中心的单元相关联,旁边是离目标框中心最近的 2 个网格单元的锚。在图中,中心前锚标有
X
。
铅检测头的选定中心先验
- 对于辅助头(对于使用深度监控的型号),我们在之前使用粗中心,这是一个针对性较低的选择。这由每个头的 5 个锚组成:包含目标框中心的单元的锚,在所有 4 个相邻网格单元旁边。
辅助探测头的选定中心先验
这种细与粗的区分背后的推理是,辅助头的学习能力低于领头头,因为领头头在网络中的位置更深。因此,我们尽量避免从辅助头可以学习的地方限制太多,以确保我们不会丢失有价值的信息。
类似于坐标校正,模型只能在间隔[0, 4]
中对每个锚框的宽度和高度应用乘法修改器。这意味着,它最多可以使锚盒的侧面扩大 4 倍。因此,从被选为中心先验的锚框中,我们过滤那些比目标框大或小 4 倍的锚框。
总之,中心先验由锚框组成,锚框的锚足够靠近目标框中心,并且其边不太偏离目标框边尺寸。
最优运输分配
评估对象检测模型时的困难之一是能够将预测框与目标框进行匹配,以便量化模型是否做得好。
最简单的方法是定义 Union (IoU)阈值上的交集,并基于此做出决定。虽然这通常是可行的,但当存在遮挡、模糊或多个对象非常靠近时,就会出现问题。最优传输分配 (OTA)旨在通过将标签分配视为每个图像的全局优化问题来解决其中一些问题。
主要直觉在于将每个目标框视为k
正标签分配的提供者,而将每个预测框视为一个正标签分配或一个背景分配的需求者。k
是动态的,依赖于每个目标框。然后,将一个正标签分配从目标盒传送到预测盒具有基于分类和回归的成本。最后,目标是找到一个运输计划(标签分配),使图像的总成本最小化。
这可以使用现成的解算器来完成,但 YOLOv7 实现了 simOTA (在 YOLOX 论文中介绍),这是 OTA 问题的简化版本。以减少标签分配的计算成本为目标,它为每个目标分配具有最低运输成本的𝑘预测盒,而不是解决全局问题。中心先验框被用作该过程的候选者。
这有助于我们进一步筛选可能与真实目标相匹配的模型输出数量。
YOLOv7 损失算法
既然我们已经介绍了 YOLOv7 损耗计算中使用的最复杂的部分,我们可以将使用的算法分解为以下步骤:
- 对于每个 FPN 头(或每个 FPN 头和辅助 FPN 头对,如果使用辅助头):
- 找到中心优先锚盒。
- 通过 simOTA 算法优化候选选择。为此,请始终使用铅 FPN 头。
- 使用预测的对象概率和 Union 上的完全交集 (CIoU)之间的二元交叉熵损失获得对象损失分数,将匹配的目标作为基础事实。如果没有匹配,这是 0。
- 如果有选择的候选锚盒,也计算(否则都是 0):
-盒(或回归)损失,定义为所有候选锚盒与其匹配目标之间的mean(1 - CIoU)
。
-分类损失,使用每个锚盒的预测类别概率和匹配目标的真实类别的独热编码向量之间的二进制交叉熵损失。 - 如果模型使用辅助头,将从辅助头获得的每个分量加到相应的主损耗分量上(即
x = x + aux_wt*aux_x
)。贡献权重(aux_wt
)由预定义的超参数定义。 - 将目标损失乘以相应的 FPN 头权重(预定义的超参数)。
2.将每个损失成分(客体、分类、回归)乘以其贡献权重(预定义的超参数)。
3.合计已经加权的损失部分。
4.将最终损失值乘以批量。
作为一个技术细节,评估期间报告的损失通过跳过 simOTA 和从不使用辅助头在计算上更便宜,即使对于时尚深度监控的模型也是如此。
虽然这个过程包含很多复杂性,但在实践中,这些都被封装在一个类中,该类可以如下所示创建:
微调模型
现在,我们已经了解了如何使用预训练模型进行预测,以及我们的损失函数如何衡量这些预测的质量,让我们看看如何根据自定义任务对模型进行微调。为了获得论文中报告的性能水平,YOLOv7 使用各种技术进行了训练。然而,出于我们的目的,在逐步引入不同的技术之前,让我们从所需的尽可能少的训练循环开始。
为了处理训练循环的样板文件,让我们使用 PyTorch 加速的。这将使我们能够只定义与我们的用例相关的训练循环的部分,而不必管理所有的样板文件。为此,我们可以覆盖默认 PyTorch 加速 [Trainer](https://pytorch-accelerated.readthedocs.io/en/latest/trainer.html#pytorch_accelerated.trainer.Trainer)
的部分内容,并创建一个特定于 YOLOv7 型号的训练器,如下所示:
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/trainer.py
from pytorch_accelerated import Trainer
class Yolov7Trainer(Trainer):
YOLO7_PADDING_VALUE = -2.0
def __init__(
self,
model,
loss_func,
optimizer,
callbacks,
filter_eval_predictions_fn=None,
):
super().__init__(
model=model, loss_func=loss_func, optimizer=optimizer, callbacks=callbacks
)
self.filter_eval_predictions = filter_eval_predictions_fn
def training_run_start(self):
self.loss_func.to(self.device)
def evaluation_run_start(self):
self.loss_func.to(self.device)
def train_epoch_start(self):
super().train_epoch_start()
self.loss_func.train()
def eval_epoch_start(self):
super().eval_epoch_start()
self.loss_func.eval()
def calculate_train_batch_loss(self, batch) -> dict:
images, labels = batch[0], batch[1]
fpn_heads_outputs = self.model(images)
loss, _ = self.loss_func(
fpn_heads_outputs=fpn_heads_outputs, targets=labels, images=images
)
return {
"loss": loss,
"model_outputs": fpn_heads_outputs,
"batch_size": images.size(0),
}
def calculate_eval_batch_loss(self, batch) -> dict:
with torch.no_grad():
images, labels, image_ids, original_image_sizes = (
batch[0],
batch[1],
batch[2],
batch[3].cpu(),
)
fpn_heads_outputs = self.model(images)
val_loss, _ = self.loss_func(
fpn_heads_outputs=fpn_heads_outputs, targets=labels
)
preds = self.model.postprocess(fpn_heads_outputs, conf_thres=0.001)
if self.filter_eval_predictions is not None:
preds = self.filter_eval_predictions(preds)
resized_image_sizes = torch.as_tensor(
images.shape[2:], device=original_image_sizes.device
)[None].repeat(len(preds), 1)
formatted_predictions = self.get_formatted_preds(
image_ids, preds, original_image_sizes, resized_image_sizes
)
gathered_predictions = (
self.gather(formatted_predictions, padding_value=self.YOLO7_PADDING_VALUE)
.detach()
.cpu()
)
return {
"loss": val_loss,
"model_outputs": fpn_heads_outputs,
"predictions": gathered_predictions,
"batch_size": images.size(0),
}
def get_formatted_preds(
self, image_ids, preds, original_image_sizes, resized_image_sizes
):
"""
scale bboxes to original image dimensions, and associate image id with predictions
"""
formatted_preds = []
for i, (image_id, image_preds) in enumerate(zip(image_ids, preds)):
# image_id, x1, y1, x2, y2, score, class_id
formatted_preds.append(
torch.cat(
(
scale_bboxes_to_original_image_size(
image_preds[:, :4],
resized_hw=resized_image_sizes[i],
original_hw=original_image_sizes[i],
is_padded=True,
),
image_preds[:, 4:],
image_id.repeat(image_preds.shape[0])[None].T,
),
1,
)
)
if not formatted_preds:
# if no predictions, create placeholder so that it can be gathered across processes
stacked_preds = torch.tensor(
[self.YOLO7_PADDING_VALUE] * 7, device=self.device
)[None]
else:
stacked_preds = torch.vstack(formatted_preds)
return stacked_preds
我们的训练步骤非常简单,唯一的修改是我们需要从返回的字典中提取总损失。对于评估步骤,我们首先计算损失,然后检索检测。
评估逻辑
为了评估我们的模型在这个任务上的性能,我们可以使用平均精度(mAP);对象检测任务的标准度量。也许最广泛使用(和信任)的 mAP 实现是包含在 PyCOCOTools 包中的类,它用于评估官方 COCO 排行榜提交。
然而,由于它没有最具创意的界面,我们围绕它创建了一个简单的包装器,使它更加用户友好。此外,对于 COCO 竞赛排行榜之外的许多案例,使用固定的借据阈值评估预测可能是有利的,而不是默认使用的借据范围,我们在评估器中添加了一个选项来执行此操作。
为了封装我们的评估逻辑以便在训练中使用,让我们为这个创建一个回调;其将在每个评估步骤结束时被更新,然后在每个评估时期结束时被计算。
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/evaluation/calculate_map_callback.py
from pytorch_accelerated.callbacks import TrainerCallback
class CalculateMeanAveragePrecisionCallback(TrainerCallback):
"""
A callback which accumulates predictions made during an epoch and uses these to calculate the Mean Average Precision
from the given targets.
.. Note:: If using distributed training or evaluation, this callback assumes that predictions have been gathered
from all processes during the evaluation step of the main training loop.
"""
def __init__(
self,
targets_json,
iou_threshold=None,
save_predictions_output_dir_path=None,
verbose=False,
):
"""
:param targets_json: a COCO-formatted dictionary with the keys "images", "categories" and "annotations"
:param iou_threshold: If set, the IoU threshold at which mAP will be calculated. Otherwise, the COCO default range of IoU thresholds will be used.
:param save_predictions_output_dir_path: If provided, the path to which the accumulated predictions will be saved, in coco json format.
:param verbose: If True, display the output provided by pycocotools, containing the average precision and recall across a range of box sizes.
"""
self.evaluator = COCOMeanAveragePrecision(iou_threshold)
self.targets_json = targets_json
self.verbose = verbose
self.save_predictions_path = (
Path(save_predictions_output_dir_path)
if save_predictions_output_dir_path is not None
else None
)
self.eval_predictions = []
self.image_ids = set()
def on_eval_step_end(self, trainer, batch, batch_output, **kwargs):
predictions = batch_output["predictions"]
if len(predictions) > 0:
self._update(predictions)
def on_eval_epoch_end(self, trainer, **kwargs):
preds_df = pd.DataFrame(
self.eval_predictions,
columns=[
XMIN_COL,
YMIN_COL,
XMAX_COL,
YMAX_COL,
SCORE_COL,
CLASS_ID_COL,
IMAGE_ID_COL,
],
)
predictions_json = self.evaluator.create_predictions_coco_json_from_df(preds_df)
self._save_predictions(trainer, predictions_json)
if self.verbose and trainer.run_config.is_local_process_zero:
self.evaluator.verbose = True
map_ = self.evaluator.compute(self.targets_json, predictions_json)
trainer.run_history.update_metric(f"map", map_)
self._reset()
@classmethod
def create_from_targets_df(
cls,
targets_df,
image_ids,
iou_threshold=None,
save_predictions_output_dir_path=None,
verbose=False,
):
"""
Create an instance of :class:`CalculateMeanAveragePrecisionCallback` from a dataframe containing the ground
truth targets and a collections of all image ids in the dataset.
:param targets_df: DF w/ cols: ["image_id", "xmin", "ymin", "xmax", "ymax", "class_id"]
:param image_ids: A collection of all image ids in the dataset, including those without annotations.
:param iou_threshold: If set, the IoU threshold at which mAP will be calculated. Otherwise, the COCO default range of IoU thresholds will be used.
:param save_predictions_output_dir_path: If provided, the path to which the accumulated predictions will be saved, in coco json format.
:param verbose: If True, display the output provided by pycocotools, containing the average precision and recall across a range of box sizes.
:return: An instance of :class:`CalculateMeanAveragePrecisionCallback`
"""
targets_json = COCOMeanAveragePrecision.create_targets_coco_json_from_df(
targets_df, image_ids
)
return cls(
targets_json=targets_json,
iou_threshold=iou_threshold,
save_predictions_output_dir_path=save_predictions_output_dir_path,
verbose=verbose,
)
def _remove_seen(self, labels):
"""
Remove any image id that has already been seen during the evaluation epoch. This can arise when performing
distributed evaluation on a dataset where the batch size does not evenly divide the number of samples.
"""
image_ids = labels[:, -1].tolist()
# remove any image_idx that has already been seen
# this can arise from distributed training where batch size does not evenly divide dataset
seen_id_mask = torch.as_tensor(
[False if idx not in self.image_ids else True for idx in image_ids]
)
if seen_id_mask.all():
# no update required as all ids already seen this pass
return []
elif seen_id_mask.any(): # at least one True
# remove predictions for images already seen this pass
labels = labels[~seen_id_mask]
return labels
def _update(self, predictions):
filtered_predictions = self._remove_seen(predictions)
if len(filtered_predictions) > 0:
self.eval_predictions.extend(filtered_predictions.tolist())
updated_ids = filtered_predictions[:, -1].unique().tolist()
self.image_ids.update(updated_ids)
def _reset(self):
self.image_ids = set()
self.eval_predictions = []
def _save_predictions(self, trainer, predictions_json):
if (
self.save_predictions_path is not None
and trainer.run_config.is_world_process_zero
):
with open(self.save_predictions_path / "predictions.json", "w") as f:
json.dump(predictions_json, f)
现在,我们所要做的就是将我们的回调插入我们的训练器,我们的地图将在每个时期被记录下来!
跑步训练
现在,让我们将目前为止看到的所有内容放入一个简单的培训脚本中。在这里,我们使用了一个简单的训练方法,它适用于各种任务,并且进行了最小的超参数调整。
因为我们注意到这个数据集的基础事实框可以包含对象周围相当多的空间,所以我们决定将用于评估的 IoU 阈值设置得相当低;因为由该模型产生的盒子很可能会更紧地围绕该对象。
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/examples/minimal_finetune_cars.py
import os
import random
from functools import partial
from pathlib import Path
import numpy as np
import pandas as pd
import torch
from func_to_script import script
from PIL import Image
from pytorch_accelerated.callbacks import (
EarlyStoppingCallback,
SaveBestModelCallback,
get_default_callbacks,
)
from pytorch_accelerated.schedulers import CosineLrScheduler
from torch.utils.data import Dataset
from yolov7 import create_yolov7_model
from yolov7.dataset import Yolov7Dataset, create_yolov7_transforms, yolov7_collate_fn
from yolov7.evaluation import CalculateMeanAveragePrecisionCallback
from yolov7.loss_factory import create_yolov7_loss
from yolov7.trainer import Yolov7Trainer, filter_eval_predictions
def load_cars_df(annotations_file_path, images_path):
all_images = sorted(set([p.parts[-1] for p in images_path.iterdir()]))
image_id_to_image = {i: im for i, im in enumerate(all_images)}
image_to_image_id = {v: k for k, v, in image_id_to_image.items()}
annotations_df = pd.read_csv(annotations_file_path)
annotations_df.loc[:, "class_name"] = "car"
annotations_df.loc[:, "has_annotation"] = True
# add 100 empty images to the dataset
empty_images = sorted(set(all_images) - set(annotations_df.image.unique()))
non_annotated_df = pd.DataFrame(list(empty_images)[:100], columns=["image"])
non_annotated_df.loc[:, "has_annotation"] = False
non_annotated_df.loc[:, "class_name"] = "background"
df = pd.concat((annotations_df, non_annotated_df))
class_id_to_label = dict(
enumerate(df.query("has_annotation == True").class_name.unique())
)
class_label_to_id = {v: k for k, v in class_id_to_label.items()}
df["image_id"] = df.image.map(image_to_image_id)
df["class_id"] = df.class_name.map(class_label_to_id)
file_names = tuple(df.image.unique())
random.seed(42)
validation_files = set(random.sample(file_names, int(len(df) * 0.2)))
train_df = df[~df.image.isin(validation_files)]
valid_df = df[df.image.isin(validation_files)]
lookups = {
"image_id_to_image": image_id_to_image,
"image_to_image_id": image_to_image_id,
"class_id_to_label": class_id_to_label,
"class_label_to_id": class_label_to_id,
}
return train_df, valid_df, lookups
class CarsDatasetAdaptor(Dataset):
def __init__(
self,
images_dir_path,
annotations_dataframe,
transforms=None,
):
self.images_dir_path = Path(images_dir_path)
self.annotations_df = annotations_dataframe
self.transforms = transforms
self.image_idx_to_image_id = {
idx: image_id
for idx, image_id in enumerate(self.annotations_df.image_id.unique())
}
self.image_id_to_image_idx = {
v: k for k, v, in self.image_idx_to_image_id.items()
}
def __len__(self) -> int:
return len(self.image_idx_to_image_id)
def __getitem__(self, index):
image_id = self.image_idx_to_image_id[index]
image_info = self.annotations_df[self.annotations_df.image_id == image_id]
file_name = image_info.image.values[0]
assert image_id == image_info.image_id.values[0]
image = Image.open(self.images_dir_path / file_name).convert("RGB")
image = np.array(image)
image_hw = image.shape[:2]
if image_info.has_annotation.any():
xyxy_bboxes = image_info[["xmin", "ymin", "xmax", "ymax"]].values
class_ids = image_info["class_id"].values
else:
xyxy_bboxes = np.array([])
class_ids = np.array([])
if self.transforms is not None:
transformed = self.transforms(
image=image, bboxes=xyxy_bboxes, labels=class_ids
)
image = transformed["image"]
xyxy_bboxes = np.array(transformed["bboxes"])
class_ids = np.array(transformed["labels"])
return image, xyxy_bboxes, class_ids, image_id, image_hw
DATA_PATH = Path("/".join(Path(__file__).absolute().parts[:-2])) / "data/cars"
@script
def main(
data_path: str = DATA_PATH,
image_size: int = 640,
pretrained: bool = True,
num_epochs: int = 30,
batch_size: int = 8,
):
# Load data
data_path = Path(data_path)
images_path = data_path / "training_images"
annotations_file_path = data_path / "annotations.csv"
train_df, valid_df, lookups = load_cars_df(annotations_file_path, images_path)
num_classes = 1
# Create datasets
train_ds = CarsDatasetAdaptor(
images_path,
train_df,
)
eval_ds = CarsDatasetAdaptor(images_path, valid_df)
train_yds = Yolov7Dataset(
train_ds,
create_yolov7_transforms(training=True, image_size=(image_size, image_size)),
)
eval_yds = Yolov7Dataset(
eval_ds,
create_yolov7_transforms(training=False, image_size=(image_size, image_size)),
)
# Create model, loss function and optimizer
model = create_yolov7_model(
architecture="yolov7", num_classes=num_classes, pretrained=pretrained
)
loss_func = create_yolov7_loss(model, image_size=image_size)
optimizer = torch.optim.SGD(
model.parameters(), lr=0.01, momentum=0.9, nesterov=True
)
# Create trainer and train
trainer = Yolov7Trainer(
model=model,
optimizer=optimizer,
loss_func=loss_func,
filter_eval_predictions_fn=partial(
filter_eval_predictions, confidence_threshold=0.01, nms_threshold=0.3
),
callbacks=[
CalculateMeanAveragePrecisionCallback.create_from_targets_df(
targets_df=valid_df.query("has_annotation == True")[
["image_id", "xmin", "ymin", "xmax", "ymax", "class_id"]
],
image_ids=set(valid_df.image_id.unique()),
iou_threshold=0.2,
),
SaveBestModelCallback(watch_metric="map", greater_is_better=True),
EarlyStoppingCallback(
early_stopping_patience=3,
watch_metric="map",
greater_is_better=True,
early_stopping_threshold=0.001,
),
*get_default_callbacks(progress_bar=True),
],
)
trainer.train(
num_epochs=num_epochs,
train_dataset=train_yds,
eval_dataset=eval_yds,
per_device_batch_size=batch_size,
create_scheduler_fn=CosineLrScheduler.create_scheduler_fn(
num_warmup_epochs=5,
num_cooldown_epochs=5,
k_decay=2,
),
collate_fn=yolov7_collate_fn,
)
if __name__ == "__main__":
main()
启动训练如这里所述,使用单个 V100 GPU 并启用 fp16,经过 3 个时期后,我们获得了0.995
的地图,这表明模型已经几乎完美地学习了任务!
然而,虽然这是一个伟大的结果,但它在很大程度上是意料之中的,因为 COCO 包含了汽车的图像。
从头开始训练
现在,我们已经成功地微调了一个预训练的 YOLOv7 模型,让我们探索如何从头开始训练这个模型。虽然这可以使用许多不同的训练食谱来完成,但让我们来看看作者在 COCO 上训练时使用的一些关键技术。
镶嵌增强
数据扩充是深度学习中的一项重要技术,其中我们通过在训练期间对我们的数据应用一系列扩充来综合扩展我们的数据集。虽然对象检测中常见的变换往往是增强,如翻转和旋转,但 YOLO 的作者采用了一种略有不同的方法,即应用马赛克增强;之前由 YOLOv4、YOLOv5 和 YOLOX 型号使用。
镶嵌增强的目的是克服对象检测模型倾向于集中于检测朝向图像中心的项目的观察。关键的想法是,如果我们将多幅图像拼接在一起,对象很可能位于通常在数据集中看到的图像中观察不到的位置和上下文中;这将迫使模型学习的特征更加位置不变。
虽然 mosaic 有几个不同的实现,每个都有微小的差异,但这里我们将展示一个组合了四个不同图像的实现。这种实现在过去对我们很有效,有各种各样的对象检测模型。
虽然在创建镶嵌图之前不需要调整图像的大小,但这确实会导致创建的镶嵌图大小相似。因此,我们将在这里采用这种方法。我们可以通过创建一个简单的调整大小转换并将其添加到数据集适配器中来实现这一点。
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/dataset.py
import albumentations as A
def create_base_transforms(target_image_size):
return A.Compose(
[
A.LongestMaxSize(target_image_size),
],
bbox_params=A.BboxParams(format="pascal_voc", label_fields=["labels"]),
)
为了应用我们的增强,我们再次使用白蛋白,它支持许多对象检测转换。
虽然数据扩充通常以函数的形式实现,传递给 PyTorch 数据集并在加载图像后立即应用,但由于镶嵌需要从数据集中加载多幅图像,因此这种方法在这里不起作用。我们决定将 mosaic 实现为数据集包装类,以清晰地封装这一逻辑。我们可以导入并使用它,如下所示:
让我们看一些产生的图像类型的例子。由于我们还没有向镶嵌数据集传递任何调整大小的变换,这些图像相当大。
请注意,虽然镶嵌图像看起来非常不同,但它们都被称为具有相同索引的*,因此被应用于相同的图像!创建镶嵌图时,它会从数据集中随机选择另外 3 幅图像,并将它们放置在随机位置,这样每次都会生成不同外观的图像。因此,应用这种增强确实打破了我们对训练时期的概念——数据集中的每幅图像只被看到一次——因为图像可以被看到多次!*
因此,在使用 mosaic 进行训练时,我们的策略是不要过多考虑历元的数量,尽可能长时间地训练模型,直到它停止收敛。毕竟,纪元的概念只有在帮助我们跟踪训练时才真正有用——该模型只能看到连续的图像流!
混合增强
镶嵌增强通常与另一种变换一起应用— 混合。为了形象化这是做什么的,让我们暂时禁用马赛克,并启用它自己的混音,我们可以这样做,如下所示:
有意思!我们可以看到,它已经结合了两个图像在一起,这导致了一些’幽灵’寻找汽车和背景!现在,让我们启用两种转换并检查我们的输出。
哇!在我们生成的图像中有相当多的汽车要检测,在许多不同的位置——这对模型来说肯定是一个挑战!请注意,当我们一起应用马赛克和 mixup 时,单个图像与马赛克混合在一起。
镶嵌后仿射变换
正如我们之前提到的,我们正在创建的镶嵌图比我们将用来训练模型的图像尺寸要大得多,所以我们需要在这里做一些大小调整。最简单的方法是在创建马赛克后简单地应用调整大小变换。
虽然这可以工作,但这可能会导致一些非常小的对象,因为我们实际上是将四个图像的大小调整为一个图像的大小——这可能会成为一个问题,因为域已经包含非常小的边界框了!此外,我们的每个马赛克在结构上非常相似,每个象限都有一个图像。回想一下,我们的目标是使模型对位置变化更加健壮,这实际上可能没有多大帮助;因为模型可能只是从每个象限的中间开始寻找。
为了克服这一点,我们可以采取的一种方法是简单地从我们的马赛克中随机选取一部分。这将仍然提供定位的可变性,同时保持目标对象的大小和纵横比。在这一点上,这也可能是一个很好的机会,加入一些其他的变换,如缩放和旋转,以增加更多的可变性。
所使用的精确变换和大小将在很大程度上取决于您所使用的图像,因此我们建议在训练模型之前,先试验这些设置,以确保所有对象仍然可见和可识别!
我们可以定义应用于镶嵌图像的变换,如下所示。这里,我们选择了一系列仿射变换——在我们目标数据的合理范围内——然后是随机裁剪。在最初的实现之后,我们也比 mosaic 更少地应用 mixup。
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/mosaic.py
def create_post_mosaic_transform(
output_height,
output_width,
pad_colour=(0, 0, 0),
rotation_range=(-10, 10),
shear_range=(-10, 10),
translation_percent_range=(-0.2, 0.2),
scale_range=(0.08, 1.0),
apply_prob=0.8,
):
return A.Compose(
[
A.Affine(
cval=pad_colour,
rotate=rotation_range,
shear=shear_range,
translate_percent=translation_percent_range,
scale=None,
keep_ratio=True,
p=apply_prob,
),
A.HorizontalFlip(),
A.RandomResizedCrop(height=output_height, width=output_width, scale=scale_range),
],
bbox_params=A.BboxParams(format="pascal_voc", label_fields=["labels"], min_visibility=0.25),
)
查看这些图像,我们可以看到大量的变化,这些图像现在是用于训练的正确大小。由于我们选择了随机比例,我们还可以看到,并非每个图像看起来都像马赛克,因此这些输出不应与模型在推理过程中看到的图像太不相似。如果使用更极端的增强,例如在训练图像和推断图像之间存在显著差异,则在训练结束前不久禁用这些增强可能是有利的。
在官方实现中,作者在训练期间使用 4 和 9 图像的马赛克。然而,当结合缩放和裁剪来检查这些增强的输出时,在许多情况下,输出看起来非常相似,所以我们选择在这里省略这一点。
将权重衰减应用于参数组
在前面的简单示例中,我们创建了优化器,以便它可以优化我们模型的所有参数。然而,如果我们想跟随作者介绍权重衰减正则化,遵循的使用卷积神经网络进行图像分类的锦囊妙计中给出的指导,这可能不是最佳的;该论文建议权重衰减应该仅应用于卷积和全连接层。
为了在 PyTorch 中实现这一点,我们需要创建两个不同的参数组来进行优化;一个包含我们的卷积权重,另一个包含剩余的参数。我们可以这样做,如下所示:
检查方法定义,我们可以看到这是一个简单的过滤操作:
现在我们可以简单地将这些传递给优化器:
optimizer = torch.optim.SGD(
param_groups["other_params"], lr=0.01, momentum=0.937, nesterov=True
)
optimizer.add_param_group(
{"params": param_groups["conv_weights"], "weight_decay": weight_decay}
)
学习率调度
当训练神经网络时,我们经常希望在训练期间调整我们的学习率的值;这是使用学习率调度器来完成的。虽然有许多流行的时间表,但作者选择了余弦学习率时间表——在训练开始时进行线性热身。它具有以下形状:
余弦学习率计划(带热身)
在实践中,我们发现一段时间的预热和冷却——学习率保持在最小值——通常是这个调度器的一个好策略。此外,调度程序 PyTorch-accelerated 支持一个 k-decay 参数,该参数可用于调整退火的积极程度。
对于这个问题,我们发现使用 k-decay 将学习速率保持在一个更高的值更长的时间效果很好。该时间表以及预热和冷却时间如下所示:
余弦学习率计划(带预热),设置为 k_decay = 2
梯度累积、缩放权重衰减
在训练一个模型的时候,我们使用的批量大小往往是由我们的硬件决定的;因为我们想尽量增加我们可以放在 GPU 上的数据量。但是,必须考虑一些因素:
- 对于非常小的批量,我们无法估计整个数据集的梯度。这可能导致训练不稳定。
- 修改批量大小会导致超参数需要不同的设置,例如学习率和权重衰减。这使得很难找到一组一致的超参数。
为了克服这一点,作者使用了一种称为 梯度累积 的技术,其中来自多个步骤的梯度被累积以模拟更大的批量。例如,假设我们在 GPU 上可以容纳的最大批量是 8。我们可以保存梯度值,继续下一批并添加这些新梯度,而不是在每批结束时更新模型的参数。在指定数量的步骤之后,我们执行更新;如果我们将步骤数设置为 4,这大致相当于使用 32 的批量大小!
在 PyTorch 中,这可以手动执行,如下所示:
num_accumulation_steps = 4
# loop through ennumerated batches
for step, (inputs, labels) in enumerate(data_loader):
model_outputs = model(inputs)
loss = loss_fn(model_outputs, labels)
# normalize loss to account for batch accumulation
loss = loss / num_accumulation_steps
# calculate gradients, these are summed automatically
loss.backward()
if ((step + 1) % num_accumulation_steps == 0) or
(step + 1 == len(data_loader)):
# perform weight update
optimizer.step()
optimizer.zero_grad()
在最初的 YOLOv7 实施中,选择梯度累积步骤的数量,使得总批量(在所有过程中)至少为 64;这缓解了前面讨论的两个问题。此外,作者以下列方式根据批次大小调整重量衰减:
nominal_batch_size = 64
num_accumulate_steps = max(round(nominal_batch_size / total_batch_size), 1)
base_weight_decay = 0.0005
scaled_weight_decay = (
base_weight_decay * total_batch_size * num_accumulate_steps / nominal_batch_size
)
我们可以将这些关系形象化如下:
首先查看累积步骤的数量,我们可以看到累积步骤的数量会减少,直到达到我们的名义批量,然后不再需要梯度累积。
现在来看看所使用的重量衰减量,我们可以看到,在达到标称批量之前,重量衰减量保持在基础值,然后随着批量的增加而线性增加;随着批量变大,应用更多的重量衰减。
模型 EMA
在训练模型时,通过对在整个训练运行中观察到的参数进行移动平均来设置模型权重值可能是有益的,这与使用在最后一次增量更新之后获得的参数相反。这通常通过维护模型参数的指数加权平均值(EMA)来实现,在实践中,这通常意味着维护模型的另一个副本来存储这些平均权重。然而,不是在每个更新步骤之后更新该模型的所有参数,而是使用现有参数值和更新值的线性组合来设置这些参数。
这是使用以下公式完成的:
updated_EMA_model_weights = decay * EMA_model_weights + (1\. - decay) * updated_model_weights
其中衰减是我们设定的参数。例如,如果我们设置 decay=0.99,我们有:
updated_EMA_model_weights = 0.99 * EMA_model_weights + 0.01 * updated_model_wei.99 * EMA_model_weights + 0.01 * updated_model_weights
我们可以看到它保留了 99%的现有状态,只保留了 1%的新状态!
为了理解为什么这可能是有益的,让我们考虑这样的情况,我们的模型在训练的早期阶段,在一批数据上表现得非常差。这可能导致对我们的参数进行大量更新,过度补偿所获得的高损失,这对即将到来的批次是不利的。通过仅并入最新参数的一小部分,大的更新将被“平滑”,并且对模型的权重具有较小的整体影响。有时,这些平均参数有时可以在评估期间产生明显更好的结果,并且这种技术已经在用于流行模型的若干训练方案中使用,例如训练 MNASNet、MobileNet-V3 和 EfficientNet 使用 TensorFlow 中包含的实现。
YOLOv7 作者采用的 EMA 方法与其他实现略有不同,因为它不使用固定的衰减,而是根据更新次数来改变衰减量。我们可以扩展 PyTorch-accelerated 中包含的 ModelEMA 类来实现如下定义的行为:
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/utils.py
from pytorch-accelerated.utils import ModelEma
class Yolov7ModelEma(ModelEma):
def __init__(self, model, decay=0.9999):
super().__init__(model, decay)
self.num_updates = 0
self.decay_fn = lambda x: decay * (
1 - math.exp(-x / 2000)
) # decay exponential ramp (to help early epochs)
self.decay = self.decay_fn(self.num_updates)
def update(self, model):
super().update(model)
self.num_updates += 1
self.decay = self.decay_fn(self.num_updates)
在这里,我们可以看到衰减是通过在每次更新后调用一个函数来设置的。让我们想象一下这是什么样子:
由此可以看出,衰减量随着更新次数的增加而增加,即每个历元一次。
回想上面的公式,这意味着,最初,我们倾向于使用更新的模型权重,而不是历史平均值。然而,随着训练的进行,我们开始加入更多以前时期的平均体重。这与这种技术的通常用法有很大的不同,这种技术的设计是为了帮助 EMA 模型在更早的时期更快地收敛。
选择合适的锚箱尺寸
回想一下之前关于锚框的讨论,以及这些如何在 YOLOv7 如何检测对象方面发挥重要作用,让我们看看如何评估我们选择的锚框是否适合我们的问题,如果不适合,为我们的数据集找到一些明智的选择。
这里的方法很大程度上是从 YOLOv5 中使用的自身抗体方法改编而来的,YOLOv7 中也使用了这种方法。
评估当前锚盒
最简单的方法是简单地使用与 COCO 相同的锚盒,这些锚盒已经与定义的架构捆绑在一起。
这里我们可以看到,我们有 3 个组,每个组对应于特征金字塔网络的每一层。这些数字对应于我们的锚大小,锚框的宽度和高度将被生成。
回想一下,特征金字塔网络(FPN)有三个输出,每个输出的作用是根据对象的比例来检测对象。
例如:
- P3 8 号用于探测较小的物体。
- P4/16 用于探测中等物体。
- P5/32 用于探测更大的物体。
考虑到这一点,我们需要为每一层设置相应的锚点大小。
为了评估我们当前的锚盒,我们可以计算最好的可能召回,如果模型能够成功地将适当的锚盒与基础事实相匹配,这将会发生。
查找和调整地面真实边界框
为了评估锚盒,我们首先需要了解数据集中对象的形状和大小。然而,在我们可以评估之前,我们需要根据我们将在其上训练的图像的大小来调整我们的地面真相框的宽度和高度——对于该架构,这被推荐为 640。
让我们从找出训练集中所有地面真值框的宽度和高度开始。我们可以如下所示计算这些值:
接下来,我们需要图像的高度和宽度。有时候,我们提前掌握了这些信息,在这种情况下,我们可以直接使用这些知识。否则,我们可以这样做:
我们现在可以将其与现有的数据框架合并:
现在,我们可以使用这些信息来获得地面实况目标相对于目标图像大小的调整后的宽度和高度。为了保持图像中对象的纵横比,调整大小的推荐方法是缩放图像,使最长的尺寸等于我们的目标尺寸。我们可以使用下面的函数做到这一点:
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/anchors.py
def calculate_resized_gt_wh(gt_wh, image_sizes, target_image_size=640):
"""
Given an array of bounding box widths and heights, and their corresponding image sizes,
resize these relative to the specified target image size.
This function assumes that resizing will be performed by scaling the image such that the longest
side is equal to the given target image size.
:param gt_wh: an array of shape [N, 2] containing the raw width and height of each box.
:param image_sizes: an array of shape [N, 2] or [1, 2] containing the width and height of the image corresponding to each box.
:param target_image_size: the size of the images that will be used during training.
"""
normalized_gt_wh = gt_wh / image_sizes
target_image_sizes = (
target_image_size * image_sizes / image_sizes.max(1, keepdims=True)
)
resized_gt_wh = target_image_sizes * normalized_gt_wh
tiny_boxes_exist = (resized_gt_wh < 3).any(1).sum()
if tiny_boxes_exist:
print(
f"""WARNING: Extremely small objects found.
{tiny_boxes_exist} of {len(resized_gt_wh)} labels are < 3 pixels in size. These will be removed
"""
)
resized_gt_wh = resized_gt_wh[(resized_gt_wh >= 2.0).any(1)]
return resized_gt_wh
或者,在这种情况下,由于我们所有的图像都是相同的大小,我们可以简单地指定一个图像大小。
请注意,我们还过滤掉了相对于新图像尺寸而言非常小(高度或宽度都小于 3 个像素)的任何框,因为这些框通常太小而没有用!
计算最佳可能回忆
既然我们在训练集中有了所有地面真实框的宽度和高度,我们可以如下评估我们当前的锚框:
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/anchors.py
def calculate_best_possible_recall(anchors, gt_wh):
"""
Given a tensor of anchors and and an array of widths and heights for each bounding box in the dataset,
calculate the best possible recall that can be obtained if every box was matched to an appropriate anchor.
:param anchors: a tensor of shape [N, 2] representing the width and height of each anchor
:param gt_wh: a tensor of shape [N, 2] representing the width and height of each ground truth bounding box
"""
best_anchor_ratio = calculate_best_anchor_ratio(anchors=anchors, wh=gt_wh)
best_possible_recall = (
(best_anchor_ratio > 1.0 / LOSS_ANCHOR_MULTIPLE_THRESHOLD).float().mean()
)
return best_possible_recall
由此,我们可以看到,当前的锚盒很适合这个数据集;这很有道理,因为这些图像与《可可》中的图像非常相似。
这是怎么回事?
在这一点上,你可能想知道,我们到底是如何计算最佳可能的回忆。要回答这个问题,让我们手动完成这个过程。
直觉上,我们希望确保至少有一个锚可以匹配到每个地面真相框。虽然我们可以通过将它框架化为一个优化问题来做到这一点——我们如何将每个地面真相框与其最佳锚匹配——但这将为我们试图做的事情带来很多复杂性。
给定一个锚定盒,我们需要一种更简单的方法来衡量它能在多大程度上适合地面真相盒。让我们研究一种可以做到这一点的方法,从单个地面真相框的宽度和高度开始。
对于每个锚盒,我们可以检查其高度和宽度与地面真实目标的高度和宽度的比率,并使用它来了解最大的差异在哪里。
因为这些比率的比例将取决于锚盒的边是大于还是小于我们的地面真值盒的边,所以我们可以通过计算倒数并取每个锚的最小比率来确保我们的幅度在范围[0,1]内。
由此,我们现在有了一个指示,即每个锚盒的宽度和高度独立地“适合”我们的地面真实目标的程度。
现在,我们的挑战是如何评估宽度和高度的匹配度!
一种方法是,对每个锚取最小比率;代表最不符合我们现实的一方。
我们之所以在这里选择最不合适的一边,是因为我们知道另一边与我们的目标至少以及所选择的一边相匹配;我们可以认为这是最坏的情况!
现在,让我们从这些选项中选择最匹配的锚框,这只是最大的值。
在最差的拟合选项中,这是我们选择的匹配!
回想一下,损失函数只匹配比地面真实目标的大小大或小 4 倍的锚框,我们现在可以验证这个锚是否在这个范围内,并且将被认为是成功的匹配。
我们可以如下所示,取损失倍数的倒数,以确保它与我们的价值在同一范围内:
由此,我们可以看到,至少我们的一个锚可以成功地匹配到我们选择的地面真实目标!
既然我们理解了步骤的顺序,我们现在可以将相同的逻辑应用于我们所有的基础事实框,以查看我们可以用当前的锚集获得多少匹配:
现在我们已经计算了,对于每个地面真值盒,它是否有一个匹配。我们可以取平均匹配数来找出最佳可能回忆;在我们的例子中,这是 1,正如我们前面看到的!
选择新的锚箱
虽然使用预定义锚可能是类似数据集的好选择,但这可能并不适用于所有数据集,例如,包含大量小对象的数据集。在这些情况下,更好的方法可能是选择全新的锚。
让我们探索一下如何做到这一点!
首先,让我们定义我们的架构需要的锚点数量。
现在,基于我们的边界框,我们需要定义一个合理的锚模板的宽度和高度。我们可以估计这一点的一种方法是通过使用 K-means 来根据我们需要的锚大小的数量来聚集我们的地面真实纵横比。然后,我们可以使用这些质心作为我们的开始估计。我们可以使用以下函数来实现这一点:
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/anchors.py
def estimate_anchors(num_anchors, gt_wh):
"""
Given a target number of anchors and an array of widths and heights for each bounding box in the dataset,
estimate a set of anchors using the centroids from Kmeans clustering.
:param num_anchors: the number of anchors to return
:param gt_wh: an array of shape [N, 2] representing the width and height of each ground truth bounding box
"""
print(f"Running kmeans for {num_anchors} anchors on {len(gt_wh)} points...")
std_dev = gt_wh.std(0)
proposed_anchors, _ = kmeans(
gt_wh / std_dev, num_anchors, iter=30
) # divide by std so they are in approx same range
proposed_anchors *= std_dev
return proposed_anchors
在这里,我们可以看到,我们现在有了一组锚模板,可以用作起点。像以前一样,让我们使用这些锚盒来计算我们的最佳可能回忆:
我们再次看到,我们的最佳可能召回率是 1,这意味着这些锚大小也很适合我们的问题!
虽然在这种情况下可能是不必要的,但我们可以使用遗传算法进一步改进这些锚。遵循这种方法,我们可以定义一个适应度(或奖励)函数来衡量我们的锚盒与我们的数据匹配的程度,并对我们的锚大小进行小的随机改变,以尝试并最大化该函数。
在这种情况下,我们可以将我们的适应度函数定义如下:
def anchor_fitness(anchors, wh):
"""
A fitness function that can be used to evolve a set of anchors. This function calculates the mean best anchor ratio
for all matches that are within the multiple range considered during the loss calculation.
"""
best_anchor_ratio = calculate_best_anchor_ratio(anchors=anchors, gt_wh=wh)
return (
best_anchor_ratio
* (best_anchor_ratio > 1 / LOSS_ANCHOR_MULTIPLE_THRESHOLD).float()
).mean()
在这里,我们为每场比赛取最佳锚定比,在损失计算时会考虑。如果一个定位框比它匹配的边界框大或小四倍以上,它不会影响我们的分数。让我们用这个来计算我们建议的锚尺寸的适合度分数:
现在,让我们在优化我们的锚时使用这个作为适应度函数,如下所示:
检查这个函数的定义,我们可以看到,对于指定的迭代次数,我们只是从正态分布中采样随机噪声,并使用它来改变我们的锚大小。如果这一变化导致分数增加,我们将这些作为我们的锚尺寸!
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/yolov7/anchors.py
def evolve_anchors(
proposed_anchors,
gt_wh,
num_iterations=1000,
mutation_probability=0.9,
mutation_noise_mean=1,
mutation_noise_std=0.1,
anchor_fitness_fn=anchor_fitness,
verbose=False,
):
"""
Use a genetic algorithm to mutate the given anchors to try and optimise them based on the given widths and heights of the
ground truth boxes based on the provided fitness function. Anchor dimensions are mutated by adding random noise sampled
from a normal distribution with the mean and standard deviation provided.
:param proposed_anchors: a tensor containing the aspect ratios of the anchor boxes to evolve
:param gt_wh: a tensor of shape [N, 2] representing the width and height of each ground truth bounding box
:param num_generations: the number of iterations for which to run the algorithm
:param mutation_probability: the probability that each anchor dimension is mutated during each iteration
:param mutation_noise_mean: the mean of the normal distribution from which the mutation noise will be sampled
:param mutation_noise_std: the standard deviation of the normal distribution from which the mutation noise will be sampled
:param anchor_fitness_fn: the reward function that will be used during the optimization process. This should accept proposed_anchors and gt_wh as arguments
:param verbose: if True, the value of the fitness function will be printed at the end of each iteration
"""
best_fitness = anchor_fitness_fn(proposed_anchors, gt_wh)
anchor_shape = proposed_anchors.shape
pbar = tqdm(range(num_iterations), desc=f"Evolving anchors with Genetic Algorithm:")
for i, _ in enumerate(pbar):
# Define mutation by sampling noise from a normal distribution
anchor_mutation = np.ones(anchor_shape)
anchor_mutation = (
(np.random.random(anchor_shape) < mutation_probability)
* np.random.randn(*anchor_shape)
* mutation_noise_std
+ mutation_noise_mean
).clip(0.3, 3.0)
mutated_anchors = (proposed_anchors.copy() * anchor_mutation).clip(min=2.0)
mutated_anchor_fitness = anchor_fitness_fn(mutated_anchors, gt_wh)
if mutated_anchor_fitness > best_fitness:
best_fitness, proposed_anchors = (
mutated_anchor_fitness,
mutated_anchors.copy(),
)
pbar.desc = (
f"Evolving anchors with Genetic Algorithm: fitness = {best_fitness:.4f}"
)
if verbose:
print(f"Iteration: {i}, Fitness: {best_fitness}")
return proposed_anchors
让我们看看这是否提高了我们的分数:
我们可以看到,正如我们所预期的那样,我们进化的锚比我们最初提出的锚具有更好的适应度分数!
现在,剩下要做的就是考虑每个锚的最小维度,按照粗略的升序对锚进行排序。
将所有这些放在一起
现在我们已经了解了这个过程,我们可以使用下面的函数在一个步骤中为我们的数据集计算锚。
在这种情况下,由于我们的最佳召回率已经大于阈值,所以我们可以保持原始的锚大小!
但是,如果我们的锚点大小发生变化,我们可以按如下所示进行更新:
跑步训练
现在,我们已经研究了在原始培训配方中使用的一些技术,让我们更新我们的培训脚本,以包括其中的一些功能。更新后的脚本如下所示:
# https://github.com/Chris-hughes10/Yolov7-training/blob/main/examples/train_cars.py
import random
from functools import partial
from pathlib import Path
import numpy as np
import pandas as pd
import torch
from func_to_script import script
from PIL import Image
from pytorch_accelerated.callbacks import (
ModelEmaCallback,
ProgressBarCallback,
SaveBestModelCallback,
get_default_callbacks,
)
from pytorch_accelerated.schedulers import CosineLrScheduler
from torch.utils.data import Dataset
from yolov7 import create_yolov7_model
from yolov7.dataset import (
Yolov7Dataset,
create_base_transforms,
create_yolov7_transforms,
yolov7_collate_fn,
)
from yolov7.evaluation import CalculateMeanAveragePrecisionCallback
from yolov7.loss_factory import create_yolov7_loss
from yolov7.mosaic import MosaicMixupDataset, create_post_mosaic_transform
from yolov7.trainer import Yolov7Trainer, filter_eval_predictions
from yolov7.utils import SaveBatchesCallback, Yolov7ModelEma
def load_cars_df(annotations_file_path, images_path):
all_images = sorted(set([p.parts[-1] for p in images_path.iterdir()]))
image_id_to_image = {i: im for i, im in enumerate(all_images)}
image_to_image_id = {v: k for k, v, in image_id_to_image.items()}
annotations_df = pd.read_csv(annotations_file_path)
annotations_df.loc[:, "class_name"] = "car"
annotations_df.loc[:, "has_annotation"] = True
# add 100 empty images to the dataset
empty_images = sorted(set(all_images) - set(annotations_df.image.unique()))
non_annotated_df = pd.DataFrame(list(empty_images)[:100], columns=["image"])
non_annotated_df.loc[:, "has_annotation"] = False
non_annotated_df.loc[:, "class_name"] = "background"
df = pd.concat((annotations_df, non_annotated_df))
class_id_to_label = dict(
enumerate(df.query("has_annotation == True").class_name.unique())
)
class_label_to_id = {v: k for k, v in class_id_to_label.items()}
df["image_id"] = df.image.map(image_to_image_id)
df["class_id"] = df.class_name.map(class_label_to_id)
file_names = tuple(df.image.unique())
random.seed(42)
validation_files = set(random.sample(file_names, int(len(df) * 0.2)))
train_df = df[~df.image.isin(validation_files)]
valid_df = df[df.image.isin(validation_files)]
lookups = {
"image_id_to_image": image_id_to_image,
"image_to_image_id": image_to_image_id,
"class_id_to_label": class_id_to_label,
"class_label_to_id": class_label_to_id,
}
return train_df, valid_df, lookups
class CarsDatasetAdaptor(Dataset):
def __init__(
self,
images_dir_path,
annotations_dataframe,
transforms=None,
):
self.images_dir_path = Path(images_dir_path)
self.annotations_df = annotations_dataframe
self.transforms = transforms
self.image_idx_to_image_id = {
idx: image_id
for idx, image_id in enumerate(self.annotations_df.image_id.unique())
}
self.image_id_to_image_idx = {
v: k for k, v, in self.image_idx_to_image_id.items()
}
def __len__(self) -> int:
return len(self.image_idx_to_image_id)
def __getitem__(self, index):
image_id = self.image_idx_to_image_id[index]
image_info = self.annotations_df[self.annotations_df.image_id == image_id]
file_name = image_info.image.values[0]
assert image_id == image_info.image_id.values[0]
image = Image.open(self.images_dir_path / file_name).convert("RGB")
image = np.array(image)
image_hw = image.shape[:2]
if image_info.has_annotation.any():
xyxy_bboxes = image_info[["xmin", "ymin", "xmax", "ymax"]].values
class_ids = image_info["class_id"].values
else:
xyxy_bboxes = np.array([])
class_ids = np.array([])
if self.transforms is not None:
transformed = self.transforms(
image=image, bboxes=xyxy_bboxes, labels=class_ids
)
image = transformed["image"]
xyxy_bboxes = np.array(transformed["bboxes"])
class_ids = np.array(transformed["labels"])
return image, xyxy_bboxes, class_ids, image_id, image_hw
DATA_PATH = Path("/".join(Path(__file__).absolute().parts[:-2])) / "data/cars"
@script
def main(
data_path: str = DATA_PATH,
image_size: int = 640,
pretrained: bool = False,
num_epochs: int = 300,
batch_size: int = 8,
):
# load data
data_path = Path(data_path)
images_path = data_path / "training_images"
annotations_file_path = data_path / "annotations.csv"
train_df, valid_df, lookups = load_cars_df(annotations_file_path, images_path)
num_classes = 1
# create datasets
train_ds = DatasetAdaptor(
images_path, train_df, transforms=create_base_transforms(image_size)
)
eval_ds = DatasetAdaptor(images_path, valid_df)
mds = MosaicMixupDataset(
train_ds,
apply_mixup_probability=0.15,
post_mosaic_transforms=create_post_mosaic_transform(
output_height=image_size, output_width=image_size
),
)
if pretrained:
# disable mosaic if finetuning
mds.disable()
train_yds = Yolov7Dataset(
mds,
create_yolov7_transforms(training=True, image_size=(image_size, image_size)),
)
eval_yds = Yolov7Dataset(
eval_ds,
create_yolov7_transforms(training=False, image_size=(image_size, image_size)),
)
# create model, loss function and optimizer
model = create_yolov7_model(
architecture="yolov7", num_classes=num_classes, pretrained=pretrained
)
param_groups = model.get_parameter_groups()
loss_func = create_yolov7_loss(model, image_size=image_size)
optimizer = torch.optim.SGD(
param_groups["other_params"], lr=0.01, momentum=0.937, nesterov=True
)
# create evaluation callback and trainer
calculate_map_callback = (
CalculateMeanAveragePrecisionCallback.create_from_targets_df(
targets_df=valid_df.query("has_annotation == True")[
["image_id", "xmin", "ymin", "xmax", "ymax", "class_id"]
],
image_ids=set(valid_df.image_id.unique()),
iou_threshold=0.2,
)
)
trainer = Yolov7Trainer(
model=model,
optimizer=optimizer,
loss_func=loss_func,
filter_eval_predictions_fn=partial(
filter_eval_predictions, confidence_threshold=0.01, nms_threshold=0.3
),
callbacks=[
calculate_map_callback,
ModelEmaCallback(
decay=0.9999,
model_ema=Yolov7ModelEma,
callbacks=[ProgressBarCallback, calculate_map_callback],
),
SaveBestModelCallback(watch_metric="map", greater_is_better=True),
SaveBatchesCallback("./batches", num_images_per_batch=3),
*get_default_callbacks(progress_bar=True),
],
)
# calculate scaled weight decay and gradient accumulation steps
total_batch_size = (
batch_size * trainer._accelerator.num_processes
) # batch size across all processes
nominal_batch_size = 64
num_accumulate_steps = max(round(nominal_batch_size / total_batch_size), 1)
base_weight_decay = 0.0005
scaled_weight_decay = (
base_weight_decay * total_batch_size * num_accumulate_steps / nominal_batch_size
)
optimizer.add_param_group(
{"params": param_groups["conv_weights"], "weight_decay": scaled_weight_decay}
)
# run training
trainer.train(
num_epochs=num_epochs,
train_dataset=train_yds,
eval_dataset=eval_yds,
per_device_batch_size=batch_size,
create_scheduler_fn=CosineLrScheduler.create_scheduler_fn(
num_warmup_epochs=5,
num_cooldown_epochs=5,
k_decay=2,
),
collate_fn=yolov7_collate_fn,
gradient_accumulation_steps=num_accumulate_steps,
)
if __name__ == "__main__":
main()
再次启动训练,如本文所述,使用单个 V100 GPU 并启用 fp16,在 300 个时期后,我们获得了模型和 EMA 模型的0.997
图;比我们的迁移学习运行略有增加,并且可能是在这个数据集上可以实现的最高性能!
结论
希望这已经提供了 YOLOv7 培训过程中一些最有趣的想法的全面概述,以及如何在定制培训脚本中应用这些想法。
复制这篇文章所需的所有代码都可以作为笔记本 在这里 。虽然在整篇文章中使用了代码片段,但这主要是出于美观的目的,请遵从笔记本,而https://github.com/Chris-hughes10/Yolov7-training为工作代码。
克里斯·休斯 和T21【伯纳特】普伊格阵营 都在领英
使用的数据集
这里,我们使用了来自 Kaggle 的汽车物体检测数据集,该数据集作为竞赛六(tjmachinelearning.com)的一部分公开提供。该数据集经常用于学习目的。
虽然这个数据集没有明确的许可,但是我们从作者那里得到了明确的许可,可以将它作为本文的一部分使用。除非另有说明,本文中使用的所有图像都来自该数据集。
参考
- 【2207.02696】yolov 7:可训练的赠品袋为实时物体探测器树立了新的艺术水平(arxiv.org)
- 回应关于 yolov 5(roboflow.com)的争议
- 【2011.08036】Scaled-yolov 4:缩放跨级局部网络(arxiv.org)
- WongKinYiu/yolor:论文的实现——你只学习一种表示法:多任务统一网络(https://arxiv.org/abs/2105.04206)(github.com)
- ultralytics/yolov5: YOLOv5🚀在 py torch>ONNX>CoreML>TF lite(github.com)
- Chris-Hughes 10/yolov 7-training:yolov 7 模型系列的一个干净的模块化实现,它使用官方的预训练权重,并带有用于训练模型执行定制(非 COCO)任务的实用程序。(github.com)
- 【WongKinYiu/yolov7:纸的实现——yolov 7:可训练的免费包为实时物体探测器树立了新的艺术水平(github.com)
- 相册:快速灵活的图像增强
- 非最大抑制解释|论文代码
- 【1612.03144】用于目标检测的特征金字塔网络(arxiv.org)
- Jaccard 索引—维基百科
- 【2103.14259】OTA:目标检测的最佳运输分配(arxiv.org)
- 【2107.08430】YOLOX:2021 年超越 YOLO 系列(arxiv.org)
- Chris-Hughes 10/pytorch-accelerated:一个轻量级库,旨在通过提供一个最小但可扩展的训练循环来加速 py torch 模型的训练过程,该训练循环足够灵活,可以处理大多数用例,并且能够利用不同的硬件选项,而无需更改代码。文件:https://pytorch-accelerated.readthedocs.io/en/latest/(github.com)
- 培训师— pytorch 加速 0.1.3 文档
- 评估措施(信息检索)—维基百科
- pycocotools PyPI
- 回调— pytorch 加速的 0.1.3 文档
- 快速入门— pytorch 加速的 0.1.3 文档
- *【arxiv.org *
- 深度学习基础——体重衰减|作者:Sophia Yang | Analytics vid hya | Medium
- 【1812.01187】利用卷积神经网络进行图像分类的锦囊妙计(arxiv.org)
- 什么是深度学习中的梯度积累?|作者拉兹·罗滕博格|走向数据科学
- Utils — pytorch 加速的 0.1.3 文档
- 遗传算法——GeeksforGeeks