有效地使用 %timeit、%lprun 和 %mprun 来编写高效的 Python 代码
一个关于如何使用魔法命令测试最高效 Python 代码的实用指南
·发表在Towards Data Science ·阅读时间 11 分钟·2023 年 3 月 8 日
–
我最近进行了一次编码面试,其中一个考核标准是代码的优化程度。不幸的是,我未能通过测试,并不是因为代码不能运行(实际上是能运行的)或逻辑错误(实际上没有错误),而是因为我的代码没有优化。
作为数据科学家,高效的代码:
-
在处理和分析大型或复杂数据时节省时间。
-
促进可扩展性,确保你的数据集能够处理更大的数据集和复杂的模型。
-
创建可重用和模块化的代码,这样可以节省时间并减少错误。
-
易于维护和更新,因为它简单易懂。
-
可与更广泛的受众分享,因为它可以在性能较低的硬件上运行。
优化代码 == 高效代码
在 Python 中,高效的代码是:
-
符合 Python 风格的 — 它使用 Python 的独特风格和习惯用法,按照创始人和社区所期望的方式进行编写。
-
快速 — 应该在最短的时间内运行,消耗最少的内存和资源。
公司和雇主更喜欢可以轻松扩展并允许新开发者快速上手的优化代码。
在本文中,我们将讨论四个 python 魔法命令,它们测试我们代码的效率。我们还将使用不同的代码方法执行任务,并测量最有效的方法。
魔法命令 — 这些是以%
或%%
开头的特殊 python 命令,支持在Jupyter笔记本和Ipython内核中使用。它们提供了一种快速而强大的方式来执行任务,如计时代码(本文讨论了这个问题)、显示可视化内容和导航目录。
行魔法命令: 这些命令有一个%并作用于一行输入。
单元魔法命令: 这些命令有两个%%并作用于多行代码或一个单元块。
注意。 你可能对‘!’符号很熟悉,它是魔法命令%system
的简写。该命令直接在笔记本中执行 shell 命令,例如使用!pip install package
来安装软件包。
要显示所有内置的 python 魔法命令,请使用%lsmagic
。
按作者显示魔法命令
要找出魔法命令的功能,可以使用代码*%magic_command****?***来在当前位置显示其文档。
作者截图
1. %timeit
这个魔法命令测量单行代码的执行时间。它会多次运行代码并返回平均执行时间。
%timeit 语法: 该命令后面跟着要测试的代码,全部在一行中。
%timeit code_to_execute
示例输出
34.6 ns ± 1.17 ns per loop (mean ±
std. dev. of 7 runs, 10000000 loops each)
输出解释:
-
32.4 ns = 平均执行时间。使用下表进行时间转换。
-
1.17 ns = 测量的标准差。
作者的时间单位表
-
7 runs = 重复过程的运行次数或迭代次数。我们有不同的运行次数,以考虑内存使用和 CPU 负载等因素的变化,这些因素在一次运行中可能保持不变,但在其他运行中可能会有所不同。
-
10,000,000 loops = 每次迭代执行代码的次数。因此,代码总共运行了
runs*loops
次。
运行次数和循环次数根据代码复杂性自动确定,但你也可以将它们作为参数传递,具体如下所述。
任务 1:计时一行代码 — 比较[]与 list()在实例化列表时的表现。
使用文字符号[]创建列表
%timeit l1=['sue','joe','liz']
###Result
34.6 ns ± 1.17 ns per loop (mean ±
std. dev. of 7 runs, 10000000 loops each)
使用 list()创建列表
%timeit l2=list(['sue','joe','liz'])
###Result
92.8 ns ± 1.35 ns per loop (mean ±
std. dev. of 7 runs, 10000000 loops each)
输出解释: 使用文字符号需要 34.6 ns,不到使用函数名称(92.8 ns)的一半时间。
因此,在实例化 python 列表、元组或字典时,使用其文字符号比使用其函数名称更高效。
#Efficient
lst = []
tup = ()
dct = {}
#Not Efficient
lst = list()
tup = tuple()
dct = dict()
使用 range 函数创建数字列表时也是如此。使用*
进行列表拆包比使用list()
名称更高效。
#Efficient
lst = [*range(10)]
#Less efficient
lst = list(range(10))
指定运行次数和循环次数 — 在%timeit
命令后,可以通过 -r 和 -n 作为参数传入所需的运行次数和循环次数。
%timeit -r 5 -n 1000 list=['sue','liz','joe']
###Result
42 ns ± 0.458 ns per loop (mean ± std. dev. of 5 runs, 1000 loops each)
2. %%timeit
这个命令前面带有两个百分号。它测量执行包含多行代码的单元块的平均时间。
%%timeit 语法: 命令写在单元块的开头,后面紧跟要计时的代码行。
%%timeit
code_line_1
code_line_2
...
任务 2:计时多个代码行(一个单元块) — 比较一个 for 循环和一个列表推导式,它们都对 0 到 1000 的所有数字进行平方运算。
For-loop — 下面,我们使用%%timeit
并传入所需的运行次数(5)和每次运行的循环次数(1000)。
%%timeit -r 5 -n 1000
squared_list=[]
for num in range(1000):
num_squared=num**2
squared_list.append(num_squared)
###Result
198 µs ± 9.31 µs per loop (mean ±
std. dev. of 5 runs, 1000 loops each)
代码执行时间为 198 微秒。
列表推导式 — 这里我们使用一个百分号的%timeit
,因为我们只测量一行代码。
%timeit -r 5 -n 1000 squared_list=[num**2 for num in range(1000)]
###Result
173 µs ± 7.22 µs per loop (mean ±
std. dev. of 5 runs, 1000 loops each)
列表推导式代码的速度更快,为 173 微秒。
因此,只要可能,并且不影响可读性,就使用列表推导式而不是 for 循环。
%lprun — 行分析
这个命令来自行分析器,该库描述了 python 函数、程序或脚本的时间性能。
它检查函数中每行代码所花的时间,并返回逐行分析的结果。
%lprun 语法: 命令后跟 -f,表示我们正在分析一个函数。然后传入函数名,再传入带有参数的函数调用。
%lprun -f function_name function_name(args)
行分析器不是内置在 python 中的,需要在系统中首次使用时安装。每次运行新内核时,还需要将其加载到 ipython 会话中。
!pip install line_profiler
%load_ext line_profiler
返回的表格是对函数中每一行的分析,包括以下列:
-
行号: 函数中该行的位置。
-
次数: 该行执行的次数。
-
时间: 行执行所用的总时间。计时器单位在表格顶部指定。
-
每次执行时间: 执行一行代码所需的平均时间(时间/次数)。
-
%时间: 每行代码所占的时间百分比,相对于其他行。
-
行内容: 行的实际源代码。
任务 3:计时函数 — 比较一个 for 循环和一个内置 python 函数,去除列表中的重复项。
在这个例子中,两个函数都接收一个列表,去除重复项,并返回一个唯一项的列表。
使用 for 循环
def remove_dups1(lst):
uniques = []
for name in lst:
if name not in uniques:
uniques.append(name)
return uniques
%lprun -f remove_dups1 remove_dups1(lst)
计时器单位为秒(1e-07 s),相当于下表中的 0.1 微秒。整个函数运行了 14.6 微秒,而 for 循环代码被多次运行(许多次数)。
作者提供的时间单位表
使用 set() 函数
def remove_dups2(lst):
return list(set(lst))
%lprun -f remove_dups2 remove_dups2(lst)
这个函数只有一行代码,运行了一次(1 hit)。整个函数运行了 3.3 微秒。
因此,尽可能使用内置函数来执行你需要的任务。这是因为这些函数经过优化以提高操作效率。以下是你可以在代码中利用的内置 Python 函数的列表。
%mprun — 内存分析
该命令来源于memory profiler库,该库概述了函数的内存使用情况。
因此,%lprun 测量时间,而 %mprun 测量内存消耗,并返回逐行的内存资源分析。
然而,使用 %mprun 时,函数需要保存到一个单独的 Python 文件中。该文件可以保存在你的当前工作目录中,然后你将其导入到会话中,并对其运行命令。我们很快就会做所有这些。
再次,你需要将内存分析器库安装到你的系统中,然后加载到当前内核会话中。
!pip install memory_profiler
%load_ext memory_profiler
%mprun 语法: 命令后跟 -f,接着是函数名,最后是函数调用。
from my_file import func_name
%mprun -f func_name func_name(params)
返回的表格包含每行代码的以下信息:
-
Line #: 正在执行的行号。
-
Mem usage: 执行该行代码后,Python 解释器使用的内存量,以字节为单位。
-
Increment: 与上一行相比内存使用的差异。可以将其视为该行对内存的影响。
-
Occurrences: 这一行中创建的相同类型项的实例数量。
-
Line Contents: 该行上的源代码。
任务 4: 在 Pandas DataFrame 中计时一个函数 — 在 Pandas 列上执行计算的最有效方法是什么?
在下面的示例中,我们将使用一个Pandas Dataframe,并对一列进行一些计算。我使用了 Kaggle 数据集‘燃料消耗’,可以在这里找到,数据集采用开放数据库许可。
首先,导入 Pandas 库,然后将数据集加载到当前会话中。如果代码返回module not found错误,请确保首先安装 Pandas 库。
import pandas as pd
data = pd.read_csv('Fuel_Consumption_2000-2022.csv')
作者提供的数据集的前 5 行
该函数接收一个 Pandas dataframe,将列的值乘以一个标量,并返回一个修改后的 dataframe。我们将测试四个函数,以检查最节省内存的方法。
记住%mprun
必须从文件中访问函数。要将函数保存到一个文件中,请运行下面的单元格块,其中顶行是%%file your_file.py.
这将创建并写入(或覆盖)内容到your_file.py
。
%%file my_file.py
def calc_apply(df):
column = df['COMB (mpg)']
new_vals = column.apply(lambda x: x* 0.425)
df['kml'] = new_vals
return df
def calc_listcomp(df):
column = df['COMB (mpg)']
new_vals = [x*0.425 for x in column]
df['kml'] = new_vals
return df
def calc_direct(df):
column = df['COMB (mpg)']
new_vals = column*0.425
df['kml'] = new_vals
return df
def calc_numpy(df):
column = df['COMB (mpg)'].values
new_vals = column*0.425
df['kml'] = pd.Series(new_vals)
return df
接下来,加载内存分析器扩展并从文件中导入你的函数。
%load_ext memory_profiler
from my_file import calc_apply, calc_listcomp,
calc_direct, calc_numpy
方法 1: 使用 apply with a lambda 函数
%mprun -f calc_apply calc_apply(data.copy())
作者提供的图片
乘法发生的apply
函数行会导致 45,000 次出现和 1.6 MB 的内存增量。
方法 2: 使用列表推导
%mprun -f calc_listcomp calc_listcomp(data.copy())
作者提供的图片
使用列表推导将出现次数减少到大约 22,500。然而,两行的内存增加也为 1.7 MB。
方法 3: 直接乘法。
%mprun -f calc_direct calc_direct(data.copy())
使用直接乘法方法会导致内存中只有一个项,并且内存增加非常小,为 0.4 MB。
方法 4: 使用 NumPy ,首先调用 *Series.values*
将列转换为 NumPy 数组。
第四种方法首先将列转换为一个 NumPy 数组,然后与标量值相乘。与之前的方法 3 相似,这里内存中只有一个项的存在,并且内存增加也为 0.4 MB。
直接乘法与 NumPy 乘法的速度对比。
虽然 NumPy 计算消耗的内存与直接方法相同,但它更快。请查看使用%lprun
测量每行所需时间的两个函数的结果。
直接乘法 — 更慢
%lprun -f calc_direct calc_direct(data.copy())
直接乘法由作者提供
NumPy 的计算 — 更快
%lprun -f calc_numpy calc_numpy(data.copy())
NumPy 的乘法由作者提供
NumPy 计算(列首先通过Series.values
转换为 NumPy 数组)更快,仅需 137 毫秒,而直接乘法需要 1150 毫秒。百分比时间也显著较少,为 9.7%,而直接乘法为 45%。
因此,数据框中的数值计算使用 NumPy 最为高效,因为它对逐元素操作进行了优化。
结论
在这篇文章中,我们讨论了编写高效且优化的 python 代码的重要性。我们查看了不同的代码示例,并确定了最有效的编码方法。
我们探索了四个魔法命令;%timeit
、%%timeit
、%lprun
和 %mprun
。前三个命令用于测试代码执行的时间,而最后一个命令用于测量消耗的内存。我们还了解到,行魔法命令作用于一行代码,并以一个 % 开头。而单元魔法命令以两个 %% 开头,作用于直接在其下方的多行代码。
我希望你喜欢这篇文章。要在我发布新文章时收到更多类似内容,请订阅这里。如果你还不是 Medium 会员,并且希望支持我作为作者,请通过此链接订阅$5,我将获得一小笔佣金。感谢你的阅读!
参考资料
3. Jupyter Notebook 中的魔法命令用于分析
数据科学中的高效编码:轻松调试 Pandas 链式操作
PYTHON 编程
如何在不将链式操作拆分为单独语句的情况下检查 Pandas 数据框
·发表于 Towards Data Science ·9 分钟阅读·2023 年 11 月 15 日
–
在不打破链式操作的情况下调试链式 Pandas 操作是可能的。照片由Miltiadis Fragkidis提供,来源于Unsplash
调试是编程的核心。我在以下文章中写到过这个话题:
Pdb 调试器值得学习和使用吗?
towardsdatascience.com
这个说法相当通用,不依赖于语言或框架。当你使用 Python 进行数据分析时,无论是进行复杂的数据分析、编写机器学习软件产品,还是创建 Streamlit 或 Django 应用,你都需要调试代码。
这篇文章讨论了调试 Pandas 代码,或者更具体地说,是在链式操作的情况下调试 Pandas 代码。这种调试提出了一个挑战性的问题。当你不知道如何做到这一点时,链式 Pandas 操作似乎比常规 Pandas 代码,即使用方括号的单独 Pandas 操作,更难调试。
要调试使用方括号的常规 Pandas 代码,只需添加一个 Python 断点 — 并使用 pdb
交互式调试器。可能是这样:
>>> d = pd.DataFrame(dict(
... x=[1, 2, 2, 3, 4],
... y=[.2, .34, 2.3, .11, .101],
... group=["a", "a", "b", "b", "b"]
.. ))
>>> d["xy"] = d.x + d.y
>>> breakpoint()
>>> d = d[d.group == "a"]
不幸的是,当代码由链式操作组成时,你不能这样做,例如在这里:
>>> d = d.assign(xy=lambda df: df.x + df.y).query("group == 'a'")
或者,根据你的喜好,可能是这样:
>>> d = d.assign(xy=d.x + d.y).query("group == 'a'")
在这种情况下,没有地方可以停下来查看代码——你只能在链的前面或后面这样做。因此,一个解决方案是在你想调试代码的地方将主链分成两个子链(两个管道),然后从那里进行调试。大多数情况下,调试后你会希望回到一个链而不是两个,因此我个人不喜欢使用这种调试方式。
这就是我想在本文中介绍的方法。我将展示一种调试链式 Pandas 操作的方法,这种方法不需要打断链。相反,你可以添加类似于典型 Python breakpoint
的东西。添加和移除这个断点很简单,使调试链式 Pandas 操作变得轻松。
我将提出三种不同的函数,这些函数将帮助你调试 Pandas 操作链中的代码。一旦你了解了它们背后的思想,你将能够实现自己的调试函数。
用于调试 Pandas 操作链的函数
我接下来要展示的所有函数都利用了 pd.pipe()
函数:
## pandas.DataFrame.pipe - pandas 2.1.2 文档
应用到 Series/DataFrame 的函数,和 会被传递到 。或者,数据关键字是一个字符串的元组…
pandas.pydata.org
你可以使用 pd.pip()
来调用——并应用于数据框——任何期望 Pandas 数据框或系列的函数。这为我们打开了许多可能性:任何这样的函数都可以被添加到 Pandas 操作链中。这就是我们如何构建下面的函数的方式。
通过断点调试
让我从最重要且同时最简单的函数开始。它会在 Pandas 操作链中添加一个典型的 breakpoint
:
def pdbreakpoint(d: pd.DataFrame) -> pd.DataFrame:
df = d.copy(deep=True)
breakpoint()
return d
简单,不是吗?
你应该知道为什么我们要创建数据框的深拷贝。如果我们不这样做,就有可能返回原始数据框。在我们的函数中,你对 df
数据框所做的一切不会影响原始数据框 d
。因此,你可以对 df
进行更改,一切都将正常。但是你不应对 d
进行任何更改,因为这些更改会反映在返回的数据框中——这样,在调试过程中传递到后续操作的管道中的数据框将使用这个更改后的数据框。
我们将使用 pdbreakpoint()
函数来处理以下 Pandas 管道:
>>> d = pd.DataFrame(dict(
... x=[1, 2, 2, 3, 4],
... y=[.2, .34, 2.3, .11, .101],
... group=["a", "a", "b", "b", "b"]
.. ))
>>> d = d.assign(xy=d.x + d.y).query("group == 'a'")
当然,这是一个过于简单的例子,但我们不需要复杂的管道,因为这可能会使我们分心,从而失去对今天的重点:调试。我使用 d
作为数据框的名称是有原因的;我想使用一个不同于 df
的名称,因为我在 pdbreakpoint()
函数内部使用了 df
。
记住,如果你通常将df
用作临时数据框的名称,你可以考虑在pdbreakpoint()
内部使用类似d
的名称。选择权在你,但唯一的要求是使用一个在pdbreakpoint()
内部未在外部作用域中使用的数据框名称。
在
pdbreakpoint()
内部使用一个在外部作用域中未使用的数据框名称。
现在,假设你想在使用assign()
函数后但在使用query()
函数之前检查d
数据框。如前所述,你可以通过将链分成两个操作来实现这一点。上面,我展示了如何针对基于方括号的 Pandas 代码做到这一点,下面,我将展示如何针对基于链式操作的 Pandas 代码做到这一点:
>>> d = d.assign(xy=d.x + d.y)
>>> breakpoint()
>>> d = d.query("group == 'a'")
但这是我从未喜欢做的事情。这就是我提出pdbreakpoint()
函数的原因。你可以按以下方式使用它:
>>> d = (
... d.assign(xy=d.x + d.y)
... .pipe(pdbreakpoint)
... .query("group == 'a'")
... )
下面的截图展示了会发生什么:
Python 3.11 的截图:通过 pdbreaking 函数进入 pdb 调试器。图片由作者提供。
你现在在pdbreakpoint()
函数内部,再次查看时,你会看到你可以访问一个df
数据框——这是在运行assign()
之后和运行query()
之前的数据框。请看:
Python 3.11 的截图:在使用 pdbreaking 函数后使用 pdb 调试器。图片由作者提供。
所以,我们在断点内部检查了df
;在这样做时,我们运行了df.query("group == 'b'")
。然而,在按下c
(用于continue
)后,我们返回到常规会话,并获得最终结果,即运行query("group == 'a'")
后的数据框,即对d
数据框执行整个操作链后的数据框。
就是这样!这是一种非常简单的使用pdb
内置 Python 调试器检查 Pandas 数据框的方法。
现在我们知道如何实现这样的函数,我们可以利用这些知识实现其他函数,帮助我们调试 Pandas 链式操作。
打印数据框的前几行
我们的下一个函数将不使用pdb
交互式调试器。相反,它将简单地打印数据框的前几行,并可以选择一个列的子集:
def pdhead(
df: pd.DataFrame,
n: int = 2,
cols: Optional[Sequence[str]] = None,
**kwargs
) -> pd.DataFrame:
if cols:
print(df.filter(cols).head(n), **kwargs)
else:
print(df.head(n), **kwargs)
return df
让我们看看代码的实际效果:
Python 3.11 的截图:使用 pdhead 函数。图片由作者提供。
为了展示pdhead()
的工作原理,我们使用了两次——虽然在实际使用中,你不会连续两次使用这个函数。第一次,我们在没有n
的情况下使用它(即,使用默认的n
值2
),第二次使用n
为3
。
你可以看到函数按预期工作。下面的截图展示了pdhead()
如何与cols
参数一起工作:
Python 3.11 的截图:使用 pdhead 函数。图片由作者提供。
因此,当你想只查看选定的列的数据框时,cols
会非常方便。在这里,我们使用了n
和cols
两个参数,并查看了d
数据框的前面三行,包含两列:xy
和group
。
正如你可能已经注意到的,xy
是在这条操作链中创建的,这没有任何问题,因为我们使用pdhead()
的临时版本的数据框已经包含了这个列。
你可以轻松地重新实现该函数,以显示数据框的尾部,或显示数据框的选定部分:列的子集和/或行的子集。我将这留给你作为练习。
在链中做一些事情
这次,我将展示一个通用函数,使你能够在 Pandas 操作链中实现任何你想做的事情:
def pddo(
df: pd.DataFrame,
func: callable,
usedf: bool = True,
*args, **kwargs
) -> pd.DataFrame:
if usedf:
func(df, *args, **kwargs)
else:
func(*args, **kwargs)
return df
这个函数比之前的函数稍微复杂一点。它有两种不同的使用情况。无论你对检查的数据框的操作意图是什么,都必须在可调用的func()
中反映出来。你可以为此使用位置参数和关键字参数。
用例 1:不要使用数据框。在这种情况下,将usedf
设置为False
。例如,你可以打印一个标志:
截图来自 Python 3.11:使用 pddo 函数打印标志。图片作者提供。
或者当前日期和时间:
截图来自 Python 3.11:使用 pddo 函数打印日期和时间。图片作者提供。
你还可以记录信息——但要记住,当usedf
设置为False
时,你无法访问数据框。如果你需要访问,你需要将此参数设置为True
,接下来的用例就是关于它的。
用例 2:使用数据框。更有趣的用例涉及访问数据框。让我们从一些简单的例子开始:
截图来自 Python 3.11:使用 pddo 函数打印数据框的形状。图片作者提供。
如你所用,当usedf
为True
(这是pddo()
的默认值)时,你可以使用数据框。它作为func()
的第一个参数使用——你不能更改这一点,否则pipe()
将会崩溃。
实际上,你可以使用pddo()
执行相当高级的操作:
截图来自 Python 3.11:使用 pddo 函数打印 Pandas 操作的复杂管道结果。图片作者提供。
foo()
函数执行了相当复杂的一系列操作并打印结果。然而,一旦pipe()
调用pddo()
(它调用foo()
)返回,原始链条将恢复到pipe()
函数被调用之前的状态。
你需要记住,如果你想打印某些内容,你需要直接调用print()
方法。实际上,你可以实现自己的函数,在其中不需要调用print()
——我将把这个作为练习留给你。然而,这样的版本只能用于打印,而当前版本的pddo()
更为通用,因为你可以,例如,将数据框记录到日志记录器中。
结论
我们已经探讨了使用自定义函数调试链式操作。最关键的函数是pdbreakpoint()
,因为它允许你使用pdb
,内置的交互式 Python 调试器。其他函数采用了静态调试,但其中一些也可以用于其他目的,例如日志记录。
你可以扩展这组用于调试 Pandas 操作的函数。当你需要在特定点检查数据框的状态,而不将链拆分为单独的语句时,它们可以帮助调试这些操作的管道。
既然你了解了这个概念,你应该不会在实现自己的函数时遇到问题。然而,我的建议是不要过度使用它们。我们讨论的是调试,我认为在调试 Pandas 代码时需要从十几个函数中选择,这更多是干扰而非帮助。
说实话,我自己只使用pdbreakpoint()
函数,但我想与你分享这个概念,而不仅仅是一个函数——以便你可以选择自己调试的方法。正如我在我之前的Towards Data Science关于 Python 调试的文章中讨论的,我是pdb
交互式调试器的忠实粉丝,我很少需要使用其他工具。但这并不意味着其他方法在某些情况下不是同样有用的。
感谢阅读。如果你喜欢这篇文章,你可能也会喜欢我写的其他文章;你可以在这里看到它们。如果你想加入 Medium,请使用下面的推荐链接:
[## 通过我的推荐链接加入 Medium - Marcin Kozak
作为 Medium 会员,你的会员费的一部分将会分配给你阅读的作者,并且你可以完全访问每个故事……
medium.com](https://medium.com/@nyggus/membership?source=post_page-----0089f6de920f--------------------------------)
高效深度学习:释放模型压缩的力量
图片来源:作者
加速生产中的模型推理速度
·发表于Towards Data Science ·9 分钟阅读·2023 年 9 月 3 日
–
介绍
当机器学习模型投入生产时,通常会有一些在模型原型阶段未考虑的要求。例如,生产中的模型必须处理来自不同用户的大量请求。因此,你需要优化例如延迟和/或吞吐量。
-
延迟:是完成任务所需的时间,例如点击链接后加载网页所需的时间。这是从开始某项工作到看到结果的等待时间。
-
吞吐量:是系统在一定时间内能够处理的请求数量。
这意味着机器学习模型必须非常快速地进行预测,为此有各种技术可以提高模型推理的速度,让我们在本文中探讨其中最重要的几种。
模型压缩
有一些技术旨在使模型更小,这就是为什么它们被称为模型压缩技术,而其他技术则专注于使模型推理更快,因此属于模型优化领域。
但通常使模型更小也有助于推理速度,因此这两个研究领域之间的界限非常模糊。
低秩分解
这是我们看到的第一种方法,实际上这方面的研究很多,最近有许多相关论文发表。
基本思想是将神经网络中的矩阵(代表网络层的矩阵)替换为具有较低维度的矩阵,尽管更准确的说法是张量,因为我们常常会有超过 2 维的矩阵。通过这种方式,我们将拥有更少的网络参数和更快的推理速度。
一个简单的例子是在 CNN 网络中用 1x1 卷积替代 3x3 卷积。这样的技术被 SqueezeNet等网络使用。
最近,类似的思想被应用于其他目的,例如在资源有限的情况下允许对大型语言模型进行微调。
在对预训练模型进行微调以用于下游任务时,仍需对预训练模型的所有参数进行训练,这可能会非常昂贵。
所以,名为“大规模语言模型的低秩适应”或 LoRA 的方法的想法是将原始模型中的矩阵替换为一对对尺寸较小的矩阵(使用矩阵分解)。这样,只有这些新的矩阵需要重新训练,以便将预训练模型调整到更多的下游任务中。
LoRA 中的矩阵分解(来源: https://arxiv.org/pdf/2106.09685.pdf)
现在让我们看看如何使用 Hugging Face 🤗 的PEFT库来实现微调。
假设我们想使用 LoRA 对[bigscience/mt0-large](https://huggingface.co/bigscience/mt0-large)
进行微调。我们必须首先处理导入我们需要的内容。
!pip install peft
!pip install transformers
from transformers import AutoModelForSeq2SeqLM
from peft import get_peft_model, LoraConfig, TaskType
model_name_or_path = "bigscience/mt0-large"
tokenizer_name_or_path = "bigscience/mt0-large"
下一步将是创建一个 LoRA 配置,以便在微调过程中应用。
peft_config = LoraConfig(
task_type=TaskType.SEQ_2_SEQ_LM, inference_mode=False, r=8, lora_alpha=32, lora_dropout=0.1
)
我们现在使用 Transformers 库的基础模型和我们为 LoRA 创建的配置对象来实例化模型。
model = AutoModelForSeq2SeqLM.from_pretrained(model_name_or_path)
model = get_peft_model(model, peft_config)
model.print_trainable_parameters()
知识蒸馏
这是另一种方法,它允许我们将一个“小型”且因此速度更快的模型投入生产。
这个方法的核心思想是有一个称为教师的大型模型和一个称为学生的小型模型,我们将利用教师的知识来教学生如何进行预测。这样我们就可以只将学生模型投入生产。
知识蒸馏(来源:www.analyticsvidhya.com/blog/2022/01/knowledge-distillation-theory-and-end-to-end-case-study/
)
以这种方式开发的经典模型示例是DistillBERT,它是 BERT的学生模型。DistilBERT 比 BERT 小 40%,但保留了 97%的语言理解能力,并且在推理时快 60%。
这种方法的一个缺点是,你仍然需要拥有大型教师模型以训练学生,而你可能没有资源训练像教师那样的模型。
让我们看一个 Python 中的知识蒸馏的简单示例。一个关键概念是KL 散度,这是一个理解两个分布之间差异的数学概念,实际上在我们的案例中,我们想要了解两个模型的预测之间的差异,因此训练的损失函数将基于这个数学概念。
import tensorflow as tf
from tensorflow.keras import layers, models
from tensorflow.keras.datasets import mnist
from tensorflow.keras.utils import to_categorical
import numpy as np
# Load the MNIST dataset
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
# Preprocess the data
train_images = train_images.reshape((60000, 28, 28, 1)).astype('float32') / 255
test_images = test_images.reshape((10000, 28, 28, 1)).astype('float32') / 255
train_labels = to_categorical(train_labels)
test_labels = to_categorical(test_labels)
# Define the teacher model (a larger model)
teacher_model = models.Sequential([
layers.Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1)),
layers.MaxPooling2D((2, 2)),
layers.Conv2D(64, (3, 3), activation='relu'),
layers.MaxPooling2D((2, 2)),
layers.Conv2D(64, (3, 3), activation='relu'),
layers.Flatten(),
layers.Dense(64, activation='relu'),
layers.Dense(10, activation='softmax')
])
teacher_model.compile(optimizer='adam',
loss='categorical_crossentropy',
metrics=['accuracy'])
# Train the teacher model
teacher_model.fit(train_images, train_labels, epochs=5, batch_size=64, validation_split=0.2)
# Define the student model (a smaller model)
student_model = models.Sequential([
layers.Flatten(input_shape=(28, 28, 1)),
layers.Dense(64, activation='relu'),
layers.Dense(10, activation='softmax')
])
student_model.compile(optimizer='adam',
loss='categorical_crossentropy',
metrics=['accuracy'])
# Knowledge distillation step: Transfer knowledge from the teacher to the student
def distillation_loss(y_true, y_pred):
alpha = 0.1 # Temperature parameter (adjust as needed)
return tf.keras.losses.KLDivergence()(tf.nn.softmax(y_true / alpha, axis=1),
tf.nn.softmax(y_pred / alpha, axis=1))
# Train the student model using knowledge distillation
student_model.fit(train_images, train_labels, epochs=10, batch_size=64,
validation_split=0.2, loss=distillation_loss)
# Evaluate the student model
test_loss, test_acc = student_model.evaluate(test_images, test_labels)
print(f'Test accuracy: {test_acc * 100:.2f}%')
剪枝
剪枝是一种模型压缩方法,我在研究生论文中研究过这个方法,实际上我之前已经发布了一篇关于如何在 Julia 中实现剪枝的文章:Julia 中的迭代剪枝方法。
剪枝诞生是为了应对决策树中的过拟合,实际上,剪去了分支以减少树的深度。这个概念后来被应用于神经网络中,在其中网络中的边和/或节点被移除(取决于是进行非结构化剪枝还是结构化剪枝)。
神经网络剪枝(来源:towardsdatascience.com/pruning-neural-networks-1bb3ab5791f9
)
如果我们从网络中移除整个节点,表示层的矩阵会变得更小,模型也会变得更快。
相反,如果我们移除单个边,矩阵的大小将保持不变,但我们会在与被移除的边对应的位置放置零,因此我们将拥有非常稀疏的矩阵。因此,在非结构化剪枝中,优势不在于提高速度,而在于节省内存,因为在内存中保存稀疏矩阵占用的空间比保存密集矩阵少得多。
但我们要剪枝的节点或边是什么呢?最不必要的那些……关于这一点有很多研究,我特别想给你推荐两篇论文:
让我们看一个简单的 Python 脚本,了解如何为一个简单的 MNIST 模型实现剪枝。
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense
from tensorflow.keras.datasets import mnist
from tensorflow.keras.utils import to_categorical
from tensorflow_model_optimization.sparsity import keras as sparsity
import numpy as np
# Load the MNIST dataset
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
# Preprocess the data
train_images = train_images.reshape((60000, 28, 28, 1)).astype('float32') / 255
test_images = test_images.reshape((10000, 28, 28, 1)).astype('float32') / 255
train_labels = to_categorical(train_labels)
test_labels = to_categorical(test_labels)
# Create a simple neural network model
def create_model():
model = Sequential([
tf.keras.layers.Flatten(input_shape=(28, 28, 1)),
tf.keras.layers.Dense(128, activation='relu'),
tf.keras.layers.Dropout(0.2),
tf.keras.layers.Dense(64, activation='relu'),
tf.keras.layers.Dropout(0.2),
tf.keras.layers.Dense(10, activation='softmax')
])
return model
# Create and compile the original model
model = create_model()
model.compile(optimizer='adam',
loss='categorical_crossentropy',
metrics=['accuracy'])
# Train the original model
model.fit(train_images, train_labels, epochs=5, batch_size=64, validation_split=0.2)
# Prune the model
# Specify the pruning parameters
pruning_params = {
'pruning_schedule': sparsity.PolynomialDecay(initial_sparsity=0.50,
final_sparsity=0.90,
begin_step=0,
end_step=2000,
frequency=100)
}
# Create a pruned model
pruned_model = sparsity.prune_low_magnitude(create_model(), **pruning_params)
# Compile the pruned model
pruned_model.compile(optimizer='adam',
loss='categorical_crossentropy',
metrics=['accuracy'])
# Train the pruned model (fine-tuning)
pruned_model.fit(train_images, train_labels, epochs=2, batch_size=64, validation_split=0.2)
# Strip pruning wrappers to create a smaller and faster model
final_model = sparsity.strip_pruning(pruned_model)
# Evaluate the final pruned model
test_loss, test_acc = final_model.evaluate(test_images, test_labels)
print(f'Test accuracy after pruning: {test_acc * 100:.2f}%')
量化
我不认为我说错了,量化可能是目前使用最广泛的压缩技术。再一次,基本概念很简单。我们通常用 32 位浮点数表示神经网络的参数。但如果我们使用更少的位数呢?我们可以使用 16 位、8 位、4 位,甚至 1 位,甚至可以拥有二进制网络!
这意味着什么?通过使用较低精度的数字,模型的体积会更小,但也会失去精度,结果会比原始模型更为近似。这是一种在需要将模型部署到边缘设备上时常用的技术,特别是智能手机等特定硬件上,因为它能大幅缩小网络的尺寸。许多框架允许轻松应用量化,例如 TensorFlow Lite、PyTorch 或 TensorRT。
量化可以在训练前应用,即我们直接截断参数只能取特定范围内的值,或者在训练后应用,即在结束时对参数的值进行四舍五入。
在这里,我们快速展示了如何在 Python 中应用量化。
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Flatten, Dropout
from tensorflow.keras.datasets import mnist
from tensorflow.keras.utils import to_categorical
import numpy as np
# Load the MNIST dataset
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
# Preprocess the data
train_images = train_images.reshape((60000, 28, 28, 1)).astype('float32') / 255
test_images = test_images.reshape((10000, 28, 28, 1)).astype('float32') / 255
train_labels = to_categorical(train_labels)
test_labels = to_categorical(test_labels)
# Create a simple neural network model
def create_model():
model = Sequential([
Flatten(input_shape=(28, 28, 1)),
Dense(128, activation='relu'),
Dropout(0.2),
Dense(64, activation='relu'),
Dropout(0.2),
Dense(10, activation='softmax')
])
return model
# Create and compile the original model
model = create_model()
model.compile(optimizer='adam',
loss='categorical_crossentropy',
metrics=['accuracy'])
# Train the original model
model.fit(train_images, train_labels, epochs=5, batch_size=64, validation_split=0.2)
# Quantize the model to 8-bit integers
converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
quantized_model = converter.convert()
# Save the quantized model to a file
with open('quantized_model.tflite', 'wb') as f:
f.write(quantized_model)
# Load the quantized model for inference
interpreter = tf.lite.Interpreter(model_path='quantized_model.tflite')
interpreter.allocate_tensors()
# Evaluate the quantized model
test_loss, test_acc = 0.0, 0.0
for i in range(len(test_images)):
input_data = np.array([test_images[i]], dtype=np.float32)
interpreter.set_tensor(interpreter.get_input_details()[0]['index'], input_data)
interpreter.invoke()
output_data = interpreter.get_tensor(interpreter.get_output_details()[0]['index'])
test_loss += tf.keras.losses.categorical_crossentropy(test_labels[i], output_data).numpy()
test_acc += np.argmax(test_labels[i]) == np.argmax(output_data)
test_loss /= len(test_images)
test_acc /= len(test_images)
print(f'Test accuracy after quantization: {test_acc * 100:.2f}%')
结论
在本文中,我们探讨了几种模型压缩方法,以加快模型推理阶段,这对于生产环境中的模型可能是一个关键要求。特别地,我们关注了低秩分解、知识蒸馏、剪枝和量化,解释了基本概念,并展示了 Python 中的简单实现。
模型压缩对于在资源有限(如 RAM、GPU 等)的特定硬件上部署模型尤其有用,例如智能手机。
我非常热衷的一个应用案例是使用模型压缩来在卫星和航天器上部署模型,这在地球观测领域尤其有用,例如使卫星能够自主识别需要丢弃的数据或图像,以避免在数据传输到地面段进行分析时产生过多流量。希望这篇文章对你更好地理解这个话题有所帮助。
如果你喜欢这篇文章,请在 Medium 上关注我!😄
💼 Linkedin ️| 🐦 Twitter | 💻 网站
使用 PyTorch 的高效图像分割:第一部分
概念与想法
·
关注 发布于 Towards Data Science ·18 分钟阅读·2023 年 6 月 27 日
–
在这个四部分系列中,我们将一步步地使用 PyTorch 的深度学习技术从零开始实现图像分割。我们将从本文开始介绍图像分割所需的基本概念与想法。
图 1:宠物图像及其分割掩膜(来源:The Oxford-IIIT Pet Dataset)
与 Naresh Singh 合作撰写
图像分割是一种将图像中属于特定对象的像素隔离的技术。隔离对象像素开辟了有趣的应用。例如,在图 1 中,右侧的图像是对应左侧宠物图像的掩码,其中黄色像素属于宠物。一旦像素被识别,我们可以轻松地放大宠物或更改图像背景。这种技术在几个社交媒体应用中的面部滤镜功能中被广泛使用。
我们在本系列文章结束时的目标是让读者了解构建视觉 AI 模型并使用 PyTorch 进行不同设置实验的所有步骤。
本系列文章
本系列适用于所有深度学习经验水平的读者。如果你想了解深度学习和视觉 AI 的实践,以及一些扎实的理论和实践经验,你来对地方了!预计这是一个四部分的系列,包括以下文章:
-
概念和思路(本文)
图像分割简介
图像分割将图像划分或分割成对应于对象、背景和边界的区域。请查看图 2,它展示了一个城市场景。它用不同的颜色掩码标记了对应于汽车、摩托车、树木、建筑物、人行道和其他有趣对象的区域。这些区域是通过图像分割技术识别的。
历史上,我们使用了专用图像处理工具和流程来将图像分解为不同区域。然而,由于过去二十年来视觉数据的惊人增长,深度学习已成为图像分割任务的首选解决方案。它显著减少了对专家的依赖,以构建特定领域的图像分割策略,这在过去是必需的。只要有足够的训练数据,深度学习从业者可以训练图像分割模型。
图 2:来自a2d2 数据集 (CC BY-ND 4.0)的分割场景
图像分割的应用有哪些?
图像分割在通信、农业、交通、医疗保健等多个领域都有应用。此外,随着视觉数据的增长,它的应用也在不断增长。以下是一些例子:
-
在自动驾驶汽车中,深度学习模型不断处理来自汽车摄像头的视频流,将场景分割成汽车、行人和交通信号灯等对象,这对于汽车安全操作至关重要。
-
在医学成像中,图像分割帮助医生识别医学扫描中的肿瘤、病变和其他异常区域。
-
在Zoom 视频通话中,利用虚拟场景替换背景以保护个人隐私。
-
在农业中,通过图像分割识别的杂草和作物区域的信息被用来保持健康的作物产量。
你可以在v7labs 的这一页面上阅读有关图像分割实际应用的更多细节。
图像分割任务的不同类型有哪些?
图像分割任务有很多不同类型,每种类型都有其优缺点。最常见的两种图像分割任务是:
-
类别或语义分割:类别分割为每个图像像素分配一个语义类别,如背景、道路、汽车或行人。如果图像中有 2 辆车,那么与两辆车对应的像素将标记为汽车像素。这通常用于自主驾驶和场景理解等任务。
-
对象或实例分割:对象分割识别图像中的对象,并为每个独特对象分配一个掩膜。如果图像中有 2 辆车,那么与每辆车对应的像素将被识别为属于不同的对象。对象分割通常用于跟踪单个对象,例如编程为跟随前方特定汽车的自动驾驶汽车。
图 3:对象和类别分割(来源:MS Coco — 创作共享署名许可)
在这一系列中,我们将重点关注类别分割。
实施高效图像分割所需的决策
高效训练模型以提高速度和准确性涉及在项目生命周期内做出许多重要决策。这包括(但不限于):
-
选择你的深度学习框架
-
选择一个好的模型架构
-
选择一个有效的损失函数来优化你关心的方面
-
避免过拟合和欠拟合
-
评估模型的准确性
在本文的其余部分,我们将深入探讨上述每一个方面,并提供大量链接,以便进一步了解每个主题。
高效图像分割的 PyTorch
什么是 PyTorch?
“PyTorch 是一个开源深度学习框架,旨在灵活和模块化以便于研究,同时具备生产部署所需的稳定性和支持。PyTorch 提供了一个 Python 包,用于高层次的特性,如张量计算(类似于 NumPy),并具有强大的 GPU 加速和 TorchScript,实现了在急切模式和图模式之间的轻松过渡。最新版本的 PyTorch 框架提供了基于图的执行、分布式训练、移动部署和量化。”(来源:Meta AI 页面的 PyTorch)
PyTorch 是用 Python 和 C++ 编写的,这使得它既易于使用和学习,又高效运行。它支持多种硬件平台,包括(服务器和移动设备)CPU、GPU 和 TPU。
为什么 PyTorch 是图像分割的好选择?
PyTorch 是深度学习研究和开发的热门选择,因为它提供了一个灵活且强大的环境来创建和训练神经网络。它是实现基于深度学习的图像分割的绝佳框架,具有以下特点:
-
灵活性:PyTorch 是一个灵活的框架,允许你以多种方式创建和训练神经网络。你可以使用预训练模型,也可以非常轻松地从头开始创建自己的模型。
-
后端支持:PyTorch 支持多种后端,如 GPU/TPU 硬件。
-
领域库:PyTorch 具有丰富的领域库,使得处理特定数据垂直领域变得非常容易。例如,对于与视觉(图像/视频)相关的 AI,PyTorch 提供了一个库 torchvision,我们将在本系列中广泛使用。
-
易用性和社区接受度:PyTorch 是一个易于使用的框架,文档齐全,并拥有一个大型的 社区 用户和开发者。许多研究人员在他们的实验中使用 PyTorch,他们发表的论文中有一个 PyTorch 实现的模型可以自由获取。
数据集的选择
我们将使用 Oxford IIIT Pet 数据集(许可协议:CC BY-SA 4.0)进行类别分割。这个数据集的训练集中有 3680 张图像,每张图像都有一个分割三值图。三值图分为 3 类像素:
-
宠物
-
背景
-
边界
我们选择这个数据集是因为它足够多样化,能够提供一个非平凡的类别分割任务。此外,它又不至于复杂到我们需要花时间处理类别不平衡等问题,从而失去对我们想要学习和解决的主要问题的关注;即类别分割。
其他常用的图像分割任务数据集包括:
使用 PyTorch 进行高效图像分割
在本系列中,我们将从头开始训练多个分类分割模型。构建和训练从头开始的模型时需要考虑许多因素。以下,我们将探讨在进行此操作时需要做出的一些关键决策。
选择适合你任务的模型
选择适合图像分割的深度学习模型时,有许多因素需要考虑。最重要的一些因素包括:
-
图像分割任务的类型:图像分割任务主要有两种类型:分类(语义)分割和对象(实例)分割。由于我们专注于较简单的分类分割问题,我们将根据这个问题来建模。
-
数据集的大小和复杂性:数据集的大小和复杂性将影响我们需要使用的模型的复杂性。例如,如果我们处理的是空间维度较小的图像,我们可以使用较简单(或较浅)的模型,如全卷积网络(FCN)。如果我们处理的是大而复杂的数据集,我们可以使用更复杂(或更深)的模型,如 U-Net。
-
预训练模型的可用性:有许多预训练模型可用于图像分割。这些模型可以作为我们自己模型的起点,也可以直接使用。然而,如果我们使用预训练模型,可能会受到模型输入图像空间维度的限制。在本系列中,我们将重点关注从头开始训练模型。
-
可用的计算资源:深度学习模型的训练可能计算开销较大。如果我们的计算资源有限,可能需要选择较简单的模型或更高效的模型架构。
在这一系列中,我们将使用 Oxford IIIT Pet 数据集,因为它足够大,可以训练中等规模的模型,并且需要使用 GPU。我们强烈建议在 kaggle.com 上创建一个帐户,或使用 Google Colab 的免费 GPU 来运行本系列中提到的笔记本和代码。
模型架构
以下是一些最受欢迎的深度学习模型架构,用于图像分割:
-
U-Net: U-Net 是一种常用于图像分割任务的卷积神经网络。它使用跳跃连接,这有助于加快网络训练速度并提高整体准确率。如果必须选择,U-Net 始终是一个极佳的默认选择!
-
FCN:全卷积网络(FCN)是一个完全卷积的网络,但它不如 U-Net 深。缺乏深度主要是因为在较高的网络深度下,准确率会下降。这使得它训练更快,但可能不如 U-Net 准确。
-
SegNet:SegNet 是一种类似于 U-Net 的流行模型架构,并且比 U-Net 使用更少的激活内存。我们在这一系列中也将使用 SegNet。
-
视觉 Transformer (ViT):视觉 Transformer 最近因其简单结构和将注意力机制应用于文本、视觉等领域的能力而受到欢迎。与 CNN 相比,视觉 Transformer 在训练和推理时可能更高效,但历史上需要更多数据来训练。我们在这一系列中也将使用 ViT。
图 4:U-Net 模型架构。来源:弗莱堡大学,U-Net 的原作者。
这些只是众多可以用于图像分割的深度学习模型中的一部分。适合你特定任务的最佳模型将取决于之前提到的因素、具体任务和你自己的实验。
选择正确的损失函数
图像分割任务的损失函数选择非常重要,因为它对模型性能有显著影响。可用的损失函数有很多,每种都有其优缺点。图像分割中最流行的损失函数包括:
-
交叉熵损失:交叉熵损失是预测概率分布与真实概率分布之间差异的度量。
-
IoU 损失:IoU 损失衡量每个类别中预测掩膜与真实掩膜之间的重叠量。IoU 损失惩罚预测或召回性能差的情况。由于定义的 IoU 不是可微分的,因此我们需要稍微调整它以用作损失函数。
-
Dice 损失:Dice 损失也是衡量预测掩膜与真实掩膜之间重叠量的一种方法。
-
Tversky 损失:Tversky 损失被提出作为一种稳健的损失函数,可以用于处理不平衡的数据集。
-
Focal 损失:Focal 损失旨在关注难以分类的样本。这对于提高模型在具有挑战性数据集上的表现可能很有帮助。
对于特定任务,最佳损失函数将取决于任务的具体要求。例如,如果准确性更重要,则 IoU 损失或 Dice 损失可能是更好的选择。如果任务存在不平衡,则 Tversky 损失或 Focal 损失可能是较好的选择。使用的具体损失函数可能会影响模型训练时的收敛速度。
损失函数是模型的一个超参数,根据我们观察到的结果使用不同的损失函数可以让我们更快地减少损失,并提高模型的准确性。
默认:在这一系列中,我们将使用交叉熵损失,因为在结果未知时,选择它通常是一个很好的默认选择。
你可以使用以下资源来了解更多关于损失函数的内容。
让我们详细查看下面定义的 IoU 损失,作为分割任务中交叉熵损失的一个强健替代方案。
自定义 IoU 损失
IoU 被定义为交集与并集之比。对于图像分割任务,我们可以通过计算每个类别的像素交集来计算 IoU,这些像素是由模型预测的,并且在实际分割掩码中。
例如,如果我们有 2 个类别:
-
背景
-
人物
然后我们可以确定哪些像素被分类为人物,并将其与实际人物像素进行比较,从而计算人物类别的 IoU。同样,我们可以计算背景类别的 IoU。
一旦我们有了这些特定类别的 IoU 度量,我们可以选择对它们进行无权平均,或在平均之前加权,以考虑之前示例中的任何类别不平衡。
按定义的 IoU 度量要求我们为每个度量计算硬标签。这需要使用 argmax() 函数,而该函数不可微分,因此我们不能将此度量用作损失函数。因此,我们用 softmax() 替代硬标签,并使用预测的概率作为软标签来计算 IoU 度量。这将得到一个可微分的度量,我们可以基于此计算损失。因此,有时,在作为损失函数时,IoU 度量也被称为软 IoU 度量。
如果我们有一个在 0.0 和 1.0 之间取值的度量(M),我们可以计算损失(L)如下:
L = 1 — M
不过,如果你的指标值在 0.0 和 1.0 之间,可以使用另一种技巧将指标转换为损失。计算:
L = -log(M)
即计算指标的负对数。这与之前的公式有意义的不同,你可以在这里和这里了解更多。基本上,这将带来更好的模型学习效果。
图 6:比较 1-P(x)与-log(P(x))产生的损失。来源:作者。
使用 IoU 作为损失函数也使得损失函数更接近于捕捉我们真正关心的内容。使用评估指标作为损失函数有利有弊。如果你对深入探讨这个领域感兴趣,可以从这个讨论开始。
数据增强
为了高效且有效地训练你的模型以获得良好的准确率,需要注意训练数据的数量和种类。所使用的训练数据的选择会显著影响最终模型的准确率,所以如果你希望从这篇文章系列中学到一件事,那就是这一点!
通常情况下,我们会将数据分成 3 部分,部分之间的大致比例如下所示。
-
训练 (80%)
-
验证 (10%)
-
测试 (10%)
你会在训练集上训练你的模型,在验证集上评估准确率,然后重复这个过程,直到你对报告的指标感到满意。只有在那时你才会在测试集上评估模型,并报告数字。这样做是为了防止任何偏差渗入到模型的架构和训练及评估过程中使用的超参数中。一般来说,你越是根据测试数据的结果来调整设置,你的结果就会越不可靠。因此,我们必须将决策限制在仅基于训练和验证数据集上看到的结果。
在这一系列中,我们不会使用测试数据集。相反,我们将使用我们的测试数据集作为验证数据集,并对测试数据集应用数据增强,以便我们总是在稍有不同的数据上验证我们的模型。这种做法有助于防止我们在验证数据集上过拟合决策。这有点像是一个变通方法,我们这样做只是为了方便和作为一种捷径。对于生产模型开发,你应该尽量坚持上述标准方法。
在这一系列中我们将使用的数据集包含 3680 张图像作为训练集。虽然这可能看起来图像数量很多,但我们希望确保我们的模型不会在这些图像上过拟合,因为我们将对模型进行多个轮次的训练。
在一个训练周期中,我们会在整个训练数据集上训练模型,通常我们会在生产环境中训练模型 60 个周期或更多。在本系列中,我们将只训练模型 20 个周期以加快迭代速度。为了防止过拟合,我们将采用一种叫做 数据增强 的技术,用于从现有输入数据生成新的输入数据。数据增强的基本理念是,如果你稍微更改图像,它对模型来说就像是一张新图像,但可以推测期望的输出是否相同。以下是我们将在本系列中应用的一些数据增强示例。
虽然我们将使用 Torchvision 库来应用上述数据增强,但我们也鼓励你评估 Albumentations 数据增强库在视觉任务中的应用。两个库都提供了丰富的图像数据变换选项。我们个人继续使用 Torchvision,因为这是我们最初选择的库。Albumentations 支持更丰富的数据增强原语,可以同时对输入图像及其真实标签或掩码进行更改。例如,如果你需要调整图像大小或翻转图像,你也需要对真实分割掩码进行相应的更改。Albumentations 可以直接为你完成这些操作。
广义而言,这两个库都支持对图像应用的变换,这些变换可以是像素级别的,也可以改变图像的空间维度。像素级变换被 torchvision 称为颜色变换,而空间变换被称为几何变换。
接下来,我们将看到 Torchvision 和 Albumentations 库对像素级和几何变换的应用示例。
图 7:使用 Albumentations 对图像应用的像素级数据增强示例。来源:Albumentations
图 8:使用 Torchvision 变换对图像应用的数据增强示例。来源:作者 (notebook)
图 9:使用 Albumentations 应用的空间级变换示例。来源:作者 (notebook)
评估模型性能
在评估模型的性能时,你会想要了解它在代表模型在真实数据上的表现质量的指标上的表现。例如,对于图像分割任务,我们想知道模型在预测像素的正确类别方面的准确度。因此,我们称像素准确率为该模型的验证指标。
你可以将评估指标用作损失函数(为什么不优化你真正关心的东西!),只不过这可能并不总是可行的。
除了准确率,我们还将跟踪 IoU 指标(也称为Jaccard 指数),以及我们上面定义的自定义 IoU 指标。
要了解更多适用于图像分割任务的各种准确率指标,请参见:
-
如何评估图像分割模型
-
评估图像分割模型
使用像素准确率作为性能指标的缺点
尽管准确率指标可能是衡量图像分割任务表现的一个良好默认选择,但它确实存在自身的缺陷,这些缺陷可能在特定情况下非常重要。
例如,考虑一个图像分割任务,以识别图片中的人的眼睛,并相应地标记这些像素。因此,模型将把每个像素分类为以下之一:
-
背景
-
眼睛
假设每张图像中只有 1 个人,并且 98%的像素不对应于眼睛。在这种情况下,模型可以简单地学会将每个像素预测为背景像素,从而在分割任务中实现 98%的像素准确率。哇!
图 10:一个人的面部图像及其眼睛的分割掩码。你可以看到眼睛只占整个图像的很小一部分。来源:改编自Unsplash
在这种情况下,使用 IoU 或 Dice 指标可能是一个更好的选择,因为 IoU 会捕捉到预测的正确部分,并且不会受到每个类别或类别在原始图像中所占区域的偏见。你甚至可以考虑将 IoU 或 Dice 系数按类别作为指标。这可能更好地反映模型在当前任务中的表现。
当仅考虑像素准确性时,精确度和召回率对于我们计算分割掩膜的对象(如上述示例中的眼睛)可以捕捉我们所寻找的细节。
现在我们已经涵盖了图像分割理论基础的大部分内容,让我们绕道讨论一下与实际工作负载的图像分割推理和部署相关的考虑。
模型大小和推理延迟
最后但同样重要的是,我们希望确保我们的模型参数数量合理而不多,因为我们需要一个小而高效的模型。我们将在未来的帖子中详细探讨这个方面,讨论如何使用高效模型架构来减少模型大小。
就推理延迟而言,重要的是模型执行的数学运算(加减运算)的数量。模型大小和加减运算可以通过torchinfo包显示。虽然加减运算是确定模型延迟的一个很好的代理,但在不同的后端之间,延迟可能会有很大差异。唯一真正确定模型在特定后端或设备上性能的方法是对该设备进行性能分析和基准测试,并使用你预计在生产环境中看到的输入。
from torchinfo import summary
model = nn.Linear(1000, 500)
summary(
model,
input_size=(1, 1000),
col_names=["kernel_size", "output_size", "num_params", "mult_adds"],
col_width=15,
)
输出:
====================================================================================================
Layer (type:depth-idx) Kernel Shape Output Shape Param # Mult-Adds
====================================================================================================
Linear -- [1, 500] 500,500 500,500
====================================================================================================
Total params: 500,500
Trainable params: 500,500
Non-trainable params: 0
Total mult-adds (M): 0.50
====================================================================================================
Input size (MB): 0.00
Forward/backward pass size (MB): 0.00
Params size (MB): 2.00
Estimated Total Size (MB): 2.01
====================================================================================================
进一步阅读
以下文章提供了有关图像分割基础知识的额外信息。如果你喜欢阅读不同观点的内容,请考虑阅读这些文章。
如果你希望亲自动手处理 Oxford IIIT Pet 数据集,并使用 torchvision 和 Albumentations 进行图像增强,我们提供了一个在 Kaggle 上的起始笔记本供你克隆和尝试。本文中的许多图像都是由该笔记本生成的!
文章总结
这是我们迄今为止讨论内容的快速回顾。
-
图像分割是一种将图像划分为多个部分的技术(来源:维基百科)
-
图像分割任务主要有两种类型:类别(语义)分割和对象(实例)分割。类别分割将图像中的每个像素分配给一个语义类别。对象分割则识别图像中的每个独立对象,并为每个唯一对象分配一个掩膜。
-
在本系列高效图像分割中,我们将使用 PyTorch 作为深度学习框架,并使用 Oxford IIIT Pet 数据集。
-
选择合适的深度学习模型进行图像分割时需要考虑许多因素,包括(但不限于)图像分割任务的类型、数据集的大小和复杂性、预训练模型的可用性以及计算资源的情况。一些最受欢迎的图像分割深度学习模型架构包括 U-Net、FCN、SegNet 和 Vision Transformer(ViT)。
-
图像分割任务中损失函数的选择非常重要,因为它可能对模型的性能和训练效率产生重大影响。对于图像分割任务,我们可以使用交叉熵损失、IoU 损失、Dice 损失或 Focal 损失(以及其他一些)。
-
数据增强是一种宝贵的技术,用于防止过拟合以及处理训练数据不足的问题。
-
评估模型性能对当前任务至关重要,必须仔细选择这一指标。
-
模型的大小和推理延迟是开发模型时需要考虑的重要指标,尤其是当你打算将其用于实时应用程序,如面部分割或背景噪声去除时。
在下一篇文章中,我们将讨论如何使用 PyTorch 从零开始构建一个卷积神经网络(CNN)来对 Oxford IIIT Pet 数据集进行图像分割。
使用 PyTorch 进行高效图像分割:第二部分
基于 CNN 的模型
·
关注 发表在 Towards Data Science ·11 分钟阅读·2023 年 6 月 27 日
–
这是一个 4 部分系列中的第二部分,旨在使用 PyTorch 中的深度学习技术一步步实现图像分割。本部分将重点介绍实现一个基线图像分割卷积神经网络(CNN)模型。
与Naresh Singh共同撰写
图 1:使用 CNN 进行图像分割的结果。从上到下依次为输入图像、真实分割掩码、预测的分割掩码。来源:作者
文章大纲
在本文中,我们将实现一种基于卷积神经网络(CNN)的架构,称为SegNet,它将为输入图像中的每个像素分配相应的宠物标签,例如猫或狗。那些不属于任何宠物的像素将被归类为背景像素。我们将使用Oxford Pets 数据集和PyTorch来构建和训练此模型,以便深入了解成功完成图像分割任务所需的内容。模型构建过程将是动手操作的,我们将详细讨论模型中每一层的作用。文章中将包含大量研究论文和文章的参考资料以供进一步学习。
在本文中,我们将参考来自这个笔记本的代码和结果。如果你希望复现结果,你需要一个 GPU,以确保笔记本在合理的时间内完成运行。
本系列文章
本系列适用于所有深度学习经验水平的读者。如果你想了解深度学习和视觉 AI 的实践,同时获得一些扎实的理论和实践经验,你来对地方了!预计这是一个四部分的系列,包含以下文章:
-
基于 CNN 的模型(本文)
让我们从卷积层的简短介绍开始讨论,以及一些通常一起使用的其他层,作为卷积块。
卷积-批归一化-ReLU 和 最大池化/反池化
卷积、批归一化、ReLU 块是视觉 AI 的圣三位一体。你会在基于 CNN 的视觉 AI 模型中频繁看到它的使用。这些术语分别代表在 PyTorch 中实现的不同层。卷积层负责对输入张量进行学习到的滤波器的交叉相关操作。批归一化将批中的元素中心化到零均值和单位方差,而 ReLU 是一个非线性激活函数,只保留输入中的正值。
一个典型的卷积神经网络(CNN)在层叠的过程中逐步减少输入的空间维度。空间维度减少的动机将在下一节讨论。这个减少是通过使用最大值或平均值等简单函数对邻近值进行池化来实现的。我们将在最大池化部分进一步讨论这个问题。在分类问题中,一系列 Conv-BN-ReLU-Pool 块后面跟着一个分类头,它预测输入属于目标类之一的概率。某些问题集,如语义分割,要求逐像素预测。对于这种情况,会在下采样块后添加一系列上采样块,以将其输出投影到所需的空间维度。上采样块实际上是 Conv-BN-ReLU-Unpool 块,它用反池化层替换了池化层。我们将在最大池化部分进一步讨论反池化。
现在,让我们进一步阐述卷积层背后的动机。
卷积
卷积是视觉 AI 模型的基本构建块。它们在计算机视觉中被广泛使用,并且历史上被用于实现视觉变换,例如:
-
边缘检测
-
图像模糊和锐化
-
压花
-
强化
卷积操作是两个矩阵的逐元素乘法和聚合。图 2 展示了一个卷积操作的例子。
图 2:卷积操作的示意图。来源:作者
在深度学习环境中,卷积是在较大尺寸的输入上进行的,卷积滤波器或内核是一个n 维参数矩阵。通过在输入上滑动滤波器并对相应部分应用卷积来实现这一点。滑动的范围由步幅参数配置。步幅为一表示内核滑动一步以处理下一个部分。与使用固定滤波器的传统方法不同,深度学习通过反向传播从数据中学习滤波器。
那么,卷积如何在深度学习中提供帮助?
在深度学习中,卷积层用于检测视觉特征。一个典型的 CNN 模型包含这样一系列层。栈底层检测简单特征,如线条和边缘。随着我们向上移动,这些层检测越来越复杂的特征。栈中的中间层检测线条和边缘的组合,而顶层检测复杂的形状,如汽车、面孔或飞机。图 3 直观地展示了训练模型的顶层和底层的输出。
图 3:卷积滤波器学习识别的内容。来源:用于可扩展无监督层次表示学习的卷积深度置信网络
卷积层具有一组可学习的滤波器,这些滤波器作用于输入中的小区域,为每个区域产生一个代表性输出值。例如,一个 3x3 的滤波器在一个 3x3 大小的区域上操作,并产生一个代表该区域的值。对输入区域重复应用滤波器会产生一个输出,该输出成为堆栈中下一层的输入。直观地说,越高层的层可以“看到”输入的更大区域。例如,第二个卷积层中的 3x3 滤波器在第一个卷积层的输出上操作,其中每个单元格包含输入中 3x3 大小区域的信息。如果我们假设卷积操作的步长为 1,那么第二层中的滤波器将“看到”原始输入的 5x5 大小区域。这被称为卷积的感受野。卷积层的重复应用逐渐减少输入图像的空间尺寸,并增加滤波器的视野,使它们能够“看到”复杂的形状。图 4 展示了卷积网络对 1-D 输入的处理过程。输出层中的一个元素是相对较大输入块的代表。
图 4:卷积的 1 维感受野,核大小为 3,应用 3 次。假设步长=1 且无填充。经过第三次连续应用卷积核后,单个像素能够看到原始输入图像中的 7 个像素。来源:作者
一旦卷积层能够检测到这些对象并生成它们的表示,我们就可以将这些表示用于图像分类、图像分割以及对象检测和定位。广义上说,CNN 遵循以下一般原则:
-
卷积层要么保持输出通道数(©)不变,要么将其加倍。
-
使用步长=1 保持空间尺寸不变,或使用步长=2 将其减少一半。
-
对卷积块的输出进行池化以改变图像的空间尺寸是很常见的。
卷积层将核独立地应用于每个输入。这可能导致其输出对不同输入变化。批量归一化层通常跟在卷积层后面,以解决这个问题。让我们在下一节详细了解其作用。
批量归一化
批量归一化层将批输入中的通道值标准化为零均值和单位方差。这种标准化是针对批中每个通道独立进行的,以确保输入的通道值具有相同的分布。批量归一化具有以下好处:
-
它通过防止梯度变得过小来稳定训练过程。
-
它在我们的任务上实现了更快的收敛速度。
如果我们只有一堆卷积层,这实际上等同于一个单卷积层网络,因为线性变换的级联效应。换句话说,一系列线性变换可以用一个具有相同效果的单一线性变换来替代。直观上,如果我们用一个常数k₁乘以一个向量,再乘以另一个常数k₂,这等同于用常数k₁k₂进行一次乘法。因此,为了使网络实际具有深度,它们必须具有非线性以防止其崩溃。我们将在下一节中讨论 ReLU,它通常用作非线性函数。
ReLU
ReLU 是一个简单的非线性激活函数,它将最低输入值剪裁为大于或等于 0。它还帮助解决梯度消失问题,将输出限制为大于或等于 0。ReLU 层通常后接一个池化层,以在下采样子网络中缩小空间维度,或者一个反池化层,以在上采样子网络中扩大空间维度。详细信息将在下一节中提供。
池化
池化层用于缩小输入的空间维度。使用stride=2的池化将输入的空间维度从(H, W)转换为(H/2, W/2)。最大池化是深度 CNN 中最常用的池化技术。它将 2x2 网格中的最大值投影到输出上。然后,我们根据与卷积类似的步幅滑动 2x2 池化窗口到下一个区域。重复此过程,使用stride=2的结果是输出的高度和宽度都是输入的一半。另一种常用的池化层是平均池化层,它计算平均值而不是最大值。
池化层的反向操作称为反池化层。它将一个(H, W)维度的输入转换为一个(2H, 2W)维度的输出,适用于stride=2。这一转换的必要成分是选择在输出的 2x2 区域中投影输入值的位置。为此,我们需要一个max-unpooling索引图,它告诉我们输出区域中的目标位置。这个反池化图是由之前的最大池化操作生成的。图 5 展示了池化和反池化操作的示例。
图 5:最大池化和反池化。来源:DeepPainter: Painter Classification Using Deep Convolutional Autoencoders
我们可以将最大池化视为一种非线性激活函数。然而,据报道,使用它来替代如 ReLU 这样的非线性函数会影响网络性能。相比之下,平均池化不能被视为非线性函数,因为它使用所有输入生成一个线性组合的输出。
这涵盖了深度 CNN 的所有基本构建块。现在,让我们将它们组合起来创建一个模型。我们为这次练习选择的模型称为 SegNet。接下来我们将讨论它。
SegNet: 一种基于 CNN 的模型
SegNet 是一个基于本文讨论的基本块的深度 CNN 模型。它有两个不同的部分。底部部分,也称为编码器,进行下采样以生成代表输入的特征。顶部解码器部分上采样特征以进行逐像素分类。每个部分由一系列 Conv-BN-ReLU 块组成。这些块还在下采样和上采样路径中分别包含池化或反池化层。图 6 显示了层的更详细排列。SegNet 使用编码器中的最大池化操作的池化索引来确定在解码器中的最大反池化操作期间复制哪些值。虽然激活张量的每个元素是 4 字节(32 位),但在 2x2 的方块内可以仅用 2 位来存储偏移量。这在内存使用方面更有效,因为这些激活(或 SegNet 中的索引)在模型运行时需要被存储。
图 6: SegNet 模型架构用于图像分割。来源: SegNet: 用于图像分割的深度卷积编码器-解码器架构
此笔记本包含本节的所有代码。
该模型具有 15.27M 可训练参数。
在模型训练和验证期间使用了以下配置。
-
随机水平翻转 和 颜色抖动 数据增强被应用于训练集以防止过拟合
-
图像在不保持纵横比的调整操作中被调整为 128x128 像素
-
对图像没有应用输入标准化;而是使用了 作为模型第一层的批量归一化层
-
模型使用 Adam 优化器训练 20 个周期,学习率为 0.001,并且使用 StepLR 调度器,每 7 个周期将学习率衰减 0.7。
-
交叉熵损失函数用于将像素分类为属于宠物、背景或宠物边界。
经过 20 个训练轮次,模型达到了 88.28%的验证准确率。
我们绘制了一个 gif,展示了模型如何学习预测验证集 21 张图像的分割掩码。
图 6:一个 gif,展示了 SegNet 模型如何学习预测验证集 21 张图像的分割掩码。来源:作者
所有验证指标的定义在本系列的第一部分中描述。
如果你想查看一个使用 Tensorflow 实现的用于分割宠物图像的全卷积模型,请参阅《高效深度学习书》的第四章:高效架构。
模型学习的观察
根据经过训练的模型在每个轮次后的预测发展情况,我们可以观察到以下几点。
-
模型能够在仅经过 1 个训练轮次时,就学会使输出在图像中的宠物位置上看起来正确。
-
边界像素更难以分割,因为我们使用的是一个未加权的损失函数,该函数对每次成功(或失败)的处理是一样的,因此,边界像素的错误对模型的损失影响不大。我们鼓励你研究这个问题,并查看你可以尝试哪些策略来解决它。试试使用 Focal Loss 并看看效果如何。
-
即使在 20 个训练轮次后,模型似乎仍在学习。这表明,如果我们训练模型更长时间,可能会提高验证准确率。
-
有些真实标签本身也很难确定——例如,中间一行最后一列的狗的掩码在狗的身体被植物遮挡的区域有很多未知像素。这对模型来说很难确定,因此对于这样的示例,通常会有准确率损失。然而,这并不意味着模型表现不好。除了查看整体验证指标之外,还应随时检查预测,以了解模型的行为。
图 7:一个包含大量未知像素的真实分割掩码示例。这对任何机器学习模型来说都是一个非常困难的输入。来源:作者
结论
在本系列的第二部分中,我们学习了深度卷积神经网络(CNN)在视觉 AI 中的基本构建块。我们展示了如何从零开始在 PyTorch 中实现 SegNet 模型,并可视化了模型在连续训练周期上的表现,这有助于你理解模型如何迅速学习到足够的知识以使输出大致接近正确范围。在这种情况下,我们可以看到分割掩码在第一次训练周期时就大致类似于实际的分割掩码!
在本系列的下一部分,我们将探讨如何优化我们的模型以实现设备上的推理,并减少可训练参数的数量(从而减少模型大小),同时保持验证精度大致不变。
进一步阅读
在这里阅读更多关于卷积的内容:
-
由约瑟夫·雷德蒙教授在华盛顿大学讲授的课程 “计算机视觉的古老秘密” 提供了一套关于卷积(特别是第 4、5 和 13 章)的优秀视频,我们强烈推荐观看。
-
深度学习中的卷积算术指南(强烈推荐)
-
towardsdatascience.com/computer-vision-convolution-basics-2d0ae3b79346
在这里阅读更多关于批量归一化的内容:
在这里阅读更多关于激活函数和 ReLU 的内容:
使用 PyTorch 进行高效的图像分割:第三部分
深度可分离卷积
·
关注 发布于Towards Data Science · 12 分钟阅读 · 2023 年 6 月 27 日
–
在这个四部分系列中,我们将逐步实现图像分割,使用 PyTorch 中的深度学习技术从零开始。本部分将重点优化我们的 CNN 基线模型,通过使用深度可分离卷积来减少可训练参数的数量,使模型可以在移动设备和其他边缘设备上部署。
与Naresh Singh共同撰写
图 1:使用深度可分离卷积而非普通卷积进行图像分割的结果。从上到下依次为输入图像、真实分割掩码和预测分割掩码。来源:作者
文章大纲
在本文中,我们将增强我们早期构建的卷积神经网络(CNN),以减少网络中可学习参数的数量。识别输入图像中的宠物像素(属于猫、狗、仓鼠等的像素)这一任务保持不变。我们选择的网络将继续是SegNet,我们唯一的改变是用深度可分卷积(DSC)替换卷积层。在此之前,我们将深入探讨深度可分卷积的理论与实践,并欣赏这一技术背后的理念。
在本文中,我们将引用来自这个笔记本的代码和结果用于模型训练,以及这个笔记本作为 DSC 的入门。如果你希望复现结果,你需要一个 GPU 以确保第一个笔记本在合理的时间内完成运行。第二个笔记本可以在普通的 CPU 上运行。
本系列文章
本系列适合所有深度学习经验水平的读者。如果你想学习深度学习和视觉 AI 的实践,了解一些扎实的理论和实践经验,你来对地方了!预计将会有 4 篇文章的系列,内容如下:
-
深度可分卷积(本文)
介绍
让我们从模型大小和计算成本的角度深入探讨卷积。可训练参数的数量是模型大小的一个很好的指标,而张量操作的数量反映了模型的复杂性或计算成本。考虑一个具有 n 个 dₖ x dₖ大小滤波器的卷积层。进一步假设此层处理形状为m x h x w的输入,其中m是输入通道的数量,而h和w分别是高度和宽度维度。在这种情况下,卷积层将产生形状为n x h x w的输出,如图 2 所示。我们假设卷积使用stride=1。让我们继续评估这一设置的可训练参数和计算成本。
图 2:常规卷积滤波器应用于输入以产生输出。假设 stride=1 和 padding=dₖ-2。来源:高效深度学习书
可训练参数的评估: 我们有 n 个滤波器,每个滤波器都有 m x dₖ x dₖ 个可训练参数。这总共会有 n x m x dₖ x dₖ 个可训练参数。为了简化讨论,忽略了偏置项。我们来看下面的 PyTorch 代码以验证我们的理解。
import torch
from torch import nn
def num_parameters(m):
return sum([p.numel() for p in m.parameters()])
dk, m, n = 3, 16, 32
print(f"Expected number of parameters: {m * dk * dk * n}")
conv1 = nn.Conv2d(in_channels=m, out_channels=n, kernel_size=dk, bias=False)
print(f"Actual number of parameters: {num_parameters(conv1)}")
打印如下内容。
Expected number of parameters: 4608
Actual number of parameters: 4608
现在,让我们评估卷积的计算成本。
计算成本的评估: 一个形状为 m x dₖ x dₖ 的单个卷积滤波器在对尺寸为 h x w 的输入进行 stride=1 和 padding=dₖ-2 操作时,会对每个尺寸为 dₖ x dₖ 的图像区域应用卷积滤波器 h x w 次,总共 h x w 个区域。这导致每个滤波器或输出通道的成本为 m x dₖ x dₖ x h x w。由于我们希望计算 n 个输出通道,因此总成本为 m x dₖ x dₖ x h x n。让我们使用 torchinfo PyTorch 包来验证这一点。
from torchinfo import summary
h, w = 128, 128
print(f"Expected total multiplies: {m * dk * dk * h * w * n}")
summary(conv1, input_size=(1, m, h, w))
将打印如下内容。
Expected total multiplies: 75497472
==========================================================================================
Layer (type:depth-idx) Output Shape Param #
==========================================================================================
Conv2d [1, 32, 128, 128] 4,608
==========================================================================================
Total params: 4,608
Trainable params: 4,608
Non-trainable params: 0
Total mult-adds (M): 75.50
==========================================================================================
Input size (MB): 1.05
Forward/backward pass size (MB): 4.19
Params size (MB): 0.02
Estimated Total Size (MB): 5.26
==========================================================================================
如果我们暂时忽略卷积层的实现细节,我们会发现,从高层次来看,卷积层只是将 m x h x w 的输入转换为 n x h x w 的输出。这一转换是通过可训练的滤波器实现的,滤波器在“看到”输入时逐步学习特征。接下来的问题是:是否可以使用更少的可训练参数来实现这一转换,同时确保层的学习能力不会受到最小妥协?深度可分离卷积旨在回答这个确切的问题。让我们详细了解它们,并学习它们在我们评估指标上的表现。
深度可分离卷积
深度可分离卷积(DSC)的概念最早由 Laurent Sifre 在其博士论文《刚性运动散射用于图像分类》中提出。从那时起,它们在各种流行的深度卷积网络中成功应用,例如 XceptionNet 和 MobileNet。
普通卷积与 DSC 之间的主要区别在于 DSC 由 2 个卷积组成,如下所述:
-
深度分组卷积,其中输入通道 m 的数量等于输出通道的数量,使得每个输出通道仅受单个输入通道的影响。在 PyTorch 中,这被称为“分组”卷积。你可以在 PyTorch 的 这里 阅读更多关于分组卷积的信息。
-
逐点卷积(滤波器大小=1),其工作方式类似于普通卷积,每个 n 个滤波器作用于所有 m 个输入通道,以产生单个输出值。
图 3:深度可分卷积滤波器应用于输入以生成输出。假设 stride=1 和 padding=dₖ-2。来源:高效深度学习书
让我们对 DSC 进行与常规卷积相同的操作,计算可训练参数和计算量。
可训练参数的评估: “分组”卷积有 m 个滤波器,每个滤波器有 dₖ x dₖ 个可学习的参数,生成 m 个输出通道。这导致总共 m x dₖ x dₖ 个可学习的参数。点卷积有 n 个大小为 m x 1 x 1 的滤波器,合计为 n x m x 1 x 1 个可学习的参数。让我们查看下面的 PyTorch 代码以验证我们的理解。
class DepthwiseSeparableConv(nn.Sequential):
def __init__(self, chin, chout, dk):
super().__init__(
# Depthwise convolution
nn.Conv2d(chin, chin, kernel_size=dk, stride=1, padding=dk-2, bias=False, groups=chin),
# Pointwise convolution
nn.Conv2d(chin, chout, kernel_size=1, bias=False),
)
conv2 = DepthwiseSeparableConv(chin=m, chout=n, dk=dk)
print(f"Expected number of parameters: {m * dk * dk + m * 1 * 1 * n}")
print(f"Actual number of parameters: {num_parameters(conv2)}")
将打印。
Expected number of parameters: 656
Actual number of parameters: 656
我们可以看到,DSC 版本的参数大约少 7x。接下来,让我们关注 DSC 层的计算成本。
计算成本的评估: 假设我们的输入空间维度为 m x h x w。在 DSC 的分组卷积部分,我们有 m 个大小为 dₖ x dₖ 的滤波器。一个滤波器应用于其对应的输入通道,结果是 m x dₖ x dₖ x h x w 的段成本。对于点卷积,我们应用 n 个大小为 m x 1 x 1 的滤波器以生成 n 个输出通道。这导致 n x m x 1 x 1 x h x w 的段成本。我们需要将分组和点卷积操作的成本加起来计算总成本。让我们使用 torchinfo PyTorch 包验证这一点。
print(f"Expected total multiplies: {m * dk * dk * h * w + m * 1 * 1 * h * w * n}")
s2 = summary(conv2, input_size=(1, m, h, w))
print(f"Actual multiplies: {s2.total_mult_adds}")
print(s2)
将打印。
Expected total multiplies: 10747904
Actual multiplies: 10747904
==========================================================================================
Layer (type:depth-idx) Output Shape Param #
==========================================================================================
DepthwiseSeparableConv [1, 32, 128, 128] --
├─Conv2d: 1-1 [1, 16, 128, 128] 144
├─Conv2d: 1-2 [1, 32, 128, 128] 512
==========================================================================================
Total params: 656
Trainable params: 656
Non-trainable params: 0
Total mult-adds (M): 10.75
==========================================================================================
Input size (MB): 1.05
Forward/backward pass size (MB): 6.29
Params size (MB): 0.00
Estimated Total Size (MB): 7.34
==========================================================================================
让我们通过一些示例比较两种卷积的大小和成本,以获得一些直观的理解。
常规卷积与深度可分卷积的大小和成本比较
为了比较常规卷积和深度可分卷积的大小和成本,我们将假设输入大小为 128 x 128,卷积核大小为 3 x 3,网络逐步将空间维度减半并将通道维度加倍。我们假设每一步有一个 2d-conv 层,但实际上可能会有更多。
图 4:比较常规卷积和深度可分卷积的可训练参数(大小)和多次加法(成本)。我们还展示了两种卷积的大小和成本的比例。来源:作者。
你可以看到,平均而言,DSC 的大小和计算成本大约是上述配置常规卷积成本的 11% 到 12%。
图 5:常规卷积与深度可分卷积的相对大小和成本。来源:作者。
现在我们已经对各种卷积类型及其相对成本有了很好的理解,你一定在想使用 DSC 是否有任何缺点。到目前为止,我们看到的一切似乎都表明它们在各方面都更好!不过,我们还没有考虑一个重要方面,即它们对模型准确性的影响。让我们通过下面的实验来探讨这个问题。
使用深度可分离卷积的 SegNet
这个笔记本包含了本节的所有代码。
我们将从之前的帖子中调整我们的 SegNet 模型,并将所有常规卷积层替换为 DSC 层。这样做后,我们发现笔记本中的参数数量从 15.27M 减少到 1.75M,减少了 88.5%!这与我们之前估计的网络可训练参数减少 11%到 12%的一致。
在模型训练和验证过程中使用了与之前相似的配置。配置如下所示。
-
随机水平翻转和颜色抖动数据增强方法被应用于训练集,以防止过拟合。
-
图像在进行不保持长宽比的调整操作时被调整为 128x128 像素。
-
图像没有进行输入归一化处理,而是使用批量归一化层作为模型的第一层。
-
模型使用 Adam 优化器进行 20 个周期的训练,学习率为 0.001,并且没有学习率调度器。
-
交叉熵损失函数用于将像素分类为宠物、背景或宠物边界。
该模型在 20 个训练周期后达到了 86.96%的验证准确率。这低于使用常规卷积在相同训练周期内达到的 88.28%准确率。我们已经通过实验确定,训练更多周期会提高两个模型的准确性,因此 20 个周期绝对不是训练周期的结束。为了本文章的演示目的,我们在 20 个周期后停止训练。
我们绘制了一个 gif,展示了模型如何学习预测验证集中 21 张图像的分割掩码。
图 6:一个 gif 展示了使用 DSC 的 SegNet 模型如何学习预测验证集中 21 张图像的分割掩码。来源:作者
现在我们已经看到模型如何通过训练周期的进展,让我们比较一下使用常规卷积和 DSC 的模型的训练周期。
准确性比较
我们发现查看使用常规卷积和 DSC 的模型训练周期很有用。我们注意到的主要区别是在训练的早期阶段(周期),之后两种模型大致趋于相同的预测流程。实际上,在训练了 100 个周期后,我们发现使用 DSC 的模型准确性比使用常规卷积的模型低约 1%。这与我们从仅 20 个周期的训练中观察到的结果一致。
图 7:一个动图展示了使用常规卷积与 DSC 的 SegNet 模型预测的分割掩码进展。来源:作者。
你可能会注意到,两种模型在仅经过 6 个训练周期后预测大致正确——即可以直观地看到模型预测了一些有用的东西。训练模型的大部分艰巨工作在于确保预测掩码的边界尽可能紧密,并尽可能接近图像中的实际物体。这意味着尽管在后续训练周期中,准确性的绝对提升可能较少,但这对预测质量的影响要大得多。我们注意到,在较高的绝对准确性值(例如从 89%提升到 90%)下,准确性的单个位数提升会显著改善预测质量。
与 UNet 模型的比较
我们进行了一个实验,调整了很多超参数,重点是提高整体准确性,以了解这种设置与最优设置的接近程度。以下是该实验的配置。
-
图像大小:128 x 128——与之前的实验相同
-
训练周期:100——当前实验训练了 20 个周期
-
数据增强:增加了很多数据增强技术,例如图像旋转、通道丢弃、随机块移除。我们使用了 Albumentations 而不是 torchvision transforms。Albumentations 会自动为我们转换分割掩码。
-
学习率调度器:使用了 StepLR 调度器,每 25 个训练周期衰减 0.8 倍
-
损失函数:我们尝试了 4 种不同的损失函数:交叉熵、焦点损失、Dice 损失、加权交叉熵。Dice 损失表现最差,而其他损失函数则相差无几。实际上,经过 100 个周期后,其他损失函数的最佳准确性差异在小数点后第四位(假设准确性在 0.0 到 1.0 之间)。
-
卷积类型:常规
-
模型类型:UNet——当前实验使用了 SegNet 模型
我们在上述设置下取得了 91.3%的最佳验证准确性。我们注意到图像大小对最佳验证准确性有显著影响。例如,当我们将图像大小更改为 256 x 256 时,最佳验证准确性上升到 93.0%。然而,训练时间大大增加,并且使用了更多内存,这意味着我们不得不减少批量大小。
图 8:使用上述超参数训练 100 个训练周期的 UNet 模型的结果。来源:作者。
你会发现,预测结果相比之前的要更平滑、更清晰。
结论
在本系列第三部分,我们了解了深度可分离卷积(DSC)作为一种在不显著降低验证准确性的情况下减少模型大小和训练/推理成本的技术。我们了解了在特定设置下,常规卷积与 DSC 之间的大小/成本权衡。
我们展示了如何在 PyTorch 中调整 SegNet 模型以使用 DSC。这项技术可以应用于任何深度 CNN。事实上,我们可以选择性地用 DSC 替换一些卷积层 —— 即我们不需要全部替换。选择替换哪些层将取决于你希望在模型大小/运行成本和预测准确性之间达成的平衡。这个决定将取决于你的具体使用案例和部署设置。
虽然本文训练了模型 20 个周期,但我们解释了这对于生产工作负载来说是不足够的,并且提供了如果训练更多周期会得到什么的初步了解。此外,我们介绍了一些在模型训练过程中可以调整的超参数。虽然这个列表并不全面,但它应能让你理解训练一个用于生产工作的图像分割模型所需的复杂性和决策过程。
在本系列的下一部分,我们将探讨 Vision Transformers,以及如何利用这种模型架构来执行宠物分割任务的图像分割。
参考文献和进一步阅读
-
可分离卷积基础介绍
使用 PyTorch 高效图像分割:第四部分
基于 Vision Transformer 的模型
·
关注 发布于 Towards Data Science ·14 分钟阅读·2023 年 6 月 27 日
–
在这个四部分的系列中,我们将一步步从头开始使用 PyTorch 中的深度学习技术实现图像分割。这一部分将重点实现基于 Vision Transformer 的图像分割模型。
与 Naresh Singh 共同创作
图 1:使用 vision transformer 模型架构进行图像分割的结果。从上到下,输入图像、真实分割掩膜和预测的分割掩膜。来源:作者
文章大纲
在本文中,我们将探讨 变换器架构,它在深度学习领域引起了轰动。变换器是一种多模态架构,可以建模语言、视觉和音频等不同模态。
在本文中,我们将
-
了解变换器架构和涉及的关键概念
-
了解视觉变换器架构
-
介绍一个从头开始编写的视觉变换器模型,以便你可以欣赏所有的构建块和活动部分
-
跟踪输入张量进入此模型并检查其形状如何变化
-
使用此模型对牛津 IIIT 宠物数据集进行图像分割
-
观察此分割任务的结果
-
简要介绍 SegFormer,一个用于语义分割的最新视觉变换器
在本文中,我们将参考此 笔记本中的代码和结果进行模型训练。如果你希望重现结果,你需要一个 GPU 以确保第一个笔记本能在合理的时间内完成运行。
本系列文章
本系列适合所有经验水平的深度学习读者。如果你想了解深度学习和视觉 AI 的实践,同时获得一些扎实的理论和实际经验,你来对地方了!预计这是一个包含四部分的系列,以下是各篇文章:
-
基于视觉变换器的模型(本文)
让我们从对变换器架构的介绍和直观理解开始我们的视觉变换器之旅。
变换器架构
我们可以将变换器架构看作是交替的通信和计算层的组合。这个概念在图 2 中以视觉形式展示。变换器有 N 个处理单元(图 2 中的 N 为 3),每个处理单元负责处理输入的 1/N 部分。为了使这些处理单元产生有意义的结果,每个处理单元需要对输入有一个全局视图。因此,系统会在每个处理单元与其他所有处理单元之间反复传递数据的信息;这通过从每个处理单元到其他所有处理单元的红色、绿色和蓝色箭头表示。随后基于这些信息进行一些计算。经过足够多次的重复这个过程,模型能够产生期望的结果。
图 2:变换器中的交错通信和计算。图像仅显示了 2 层通信和计算。实际上,还有许多更多这样的层。来源:作者。
值得注意的是,大多数在线资源通常讨论变换器的编码器和解码器,正如标题为 “Attention is all you need” 的论文中所展示的。然而,在本文中,我们将只描述变换器的编码器部分。
让我们更详细地看看变换器中的通信和计算是什么。
变换器中的通信:注意力
在变换器中,通信是通过一个称为注意力层的层来实现的。在 PyTorch 中,这称为 MultiHeadAttention。我们稍后会解释这个名称的原因。
文档中写道:
“允许模型同时关注来自不同表示子空间的信息,如论文中所述: Attention is all you need。”
注意力机制消耗形状为 (Batch, Length, Features) 的输入张量 x,并生成一个形状类似的张量 y,使得每个输入的特征根据该张量关注的同一实例中的其他输入进行更新。因此,在“Length”大小的实例中,每个“Features”长度的张量的特征都是基于每个其他张量更新的。这就是注意力机制的二次成本所在。
图 3:单词“it”相对于句子中的其他单词的注意力。我们可以看到,“it”正关注句子中的单词“animal”,“too”和“tire(d)”。来源:使用 这个 colab 生成。
在视觉变换器的上下文中,变换器的输入是图像。假设这是一个 128 x 128(宽度,高度)的图像。我们将其切成多个 16 x 16 大小的小块。对于一个 128 x 128 的图像,我们得到 64 个块(长度),每行 8 个块,共 8 行块。
每一个这些 16 x 16 像素大小的 64 个块都被视为变换器模型的一个独立输入。无需深入细节,只需将此过程视为由 64 个不同的处理单元驱动,每个单元处理一个 16x16 的图像块。
在每一轮中,每个处理单元的注意力机制负责查看其负责的图像块,并查询其他剩余的 63 个处理单元,询问它们是否有任何可能相关且有用的信息,以帮助其有效地处理自身的图像块。
注意力机制之后是计算步骤,我们将接下来讨论这个步骤。
变换器中的计算:多层感知器
变换器中的计算实际上就是一个多层感知器(MLP)单元。该单元由 2 个线性层组成,中间有一个 GeLU 非线性激活函数。也可以考虑使用其他非线性激活函数。该单元首先将输入投影到 4 倍大小,然后再将其投影回 1 倍,即与输入大小相同。
在我们笔记本中的代码中,这个类叫做 MultiLayerPerceptron。代码如下。
class MultiLayerPerceptron(nn.Sequential):
def __init__(self, embed_size, dropout):
super().__init__(
nn.Linear(embed_size, embed_size * 4),
nn.GELU(),
nn.Linear(embed_size * 4, embed_size),
nn.Dropout(p=dropout),
)
# end def
# end class
现在我们已经理解了变换器架构的高层次工作原理,让我们将注意力集中在视觉变换器上,因为我们将进行图像分割。
视觉变换器
视觉变换器首次在题为“An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale”的论文中介绍。该论文讨论了作者如何将原始变换器架构应用于图像分类问题。这是通过将图像拆分为 16x16 的补丁,并将每个补丁视为模型的输入令牌来完成的。变换器编码器模型接收这些输入令牌,并被要求预测输入图像的类别。
图 4:来源:Transformers for image recognition at scale。
在我们的案例中,我们对图像分割感兴趣。我们可以将其视为像素级分类任务,因为我们打算预测每个像素的目标类别。
我们对原始视觉变换器进行了一个小但重要的更改,将用于分类的 MLP 头替换为用于像素级分类的 MLP 头。我们在输出中有一个线性层,所有的补丁共享这个线性层,其分割掩模由视觉变换器预测。这个共享的线性层为每个输入到模型中的补丁预测一个分割掩模。
在视觉变换器的情况下,16x16 的补丁被视为在特定时间步长的单个输入令牌。
图 5:视觉变换器在图像分割中的端到端工作。图像使用这个notebook生成。来源:作者。
为视觉变换器中的张量维度建立直觉
当处理深度 CNN 时,我们大多数使用的张量维度是(N, C H, W),其中字母代表以下含义:
-
N: 批次大小
-
C: 通道数
-
H: 高度
-
W: 宽度
你可以看到这个格式是针对 2D 图像处理的,因为它包含了非常特定于图像的特征。
另一方面,使用变换器时,事情变得更加通用和与领域无关。以下所述适用于视觉、文本、NLP、音频或其他可以表示为序列的输入数据的问题。值得注意的是,在张量通过视觉变换器流动时,表示方式几乎没有特定于视觉的偏差。
在处理变换器和注意力机制时,我们期望张量具有以下形状:(B, T, C),其中字母代表以下含义:
-
B:批量大小(与 CNN 相同)
-
T:时间维度或序列长度。这个维度有时也被称为 L。在视觉变换器的情况下,每个图像块对应于此维度。如果我们有 16 个图像块,那么 T 维度的值将是 16。
-
C:通道或嵌入大小维度。这个维度有时也被称为 E。在处理图像时,每个 3x16x16(通道,宽度,高度)大小的图像块通过嵌入层映射到大小为 C 的嵌入。我们稍后将看到这是如何完成的。
让我们深入探讨输入图像张量如何在预测分割掩膜的过程中发生变化和处理。
视觉变换器中张量的传递过程
在深度卷积神经网络中,张量的传递过程大致如下(在 UNet、SegNet 或其他基于 CNN 的架构中)。
输入张量的形状通常为 (1, 3, 128, 128)。该张量经过一系列卷积和最大池化操作,其中空间维度被缩小,通道维度则通常增加 2 倍。这称为特征编码器。之后,我们进行反向操作,增加空间维度并减少通道维度。这称为特征解码器。解码过程后,我们得到形状为 (1, 64, 128, 128) 的张量。然后,使用无偏置的 1x1 点卷积将其投影到我们期望的输出通道数量 C,即 (1, C, 128, 128)。
图 6:用于图像分割的深度 CNN 中张量形状的典型演变过程。来源:作者。
在视觉变换器中,流程要复杂得多。让我们先看下面的图像,然后尝试理解张量在每一步的形状如何变化。
图 7:用于图像分割的视觉变换器中张量形状的典型演变过程。来源:作者。
让我们详细查看每一步,并观察它如何更新通过视觉变换器的张量形状。为了更好地理解这一点,我们将张量的维度设置为具体的值。
-
批量归一化: 输入和输出张量的形状为 (1, 3, 128, 128)。形状保持不变,但值被标准化为零均值和单位方差。
-
图像到补丁: 形状为(1, 3, 128, 128)的输入张量被转换成堆叠的 16x16 图像补丁。输出张量的形状为(1, 64, 768)。
-
补丁嵌入: 补丁嵌入层将 768 个输入通道映射到 512 个嵌入通道(以此示例为例)。输出张量的形状为(1, 64, 512)。补丁嵌入层基本上只是 PyTorch 中的一个 nn.Linear 层。
-
位置嵌入: 位置嵌入层没有输入张量,但有效地贡献了一个可学习的参数(PyTorch 中的可训练张量),其形状与补丁嵌入相同。形状为(1, 64, 512)。
-
添加: 补丁和位置嵌入逐个相加以产生视觉变换器编码器的输入。这个张量的形状为(1, 64, 512)。你会注意到,视觉变换器的主要工作单元,即编码器,基本上保持了这个张量形状不变。
-
变换器编码器: 形状为(1, 64, 512)的输入张量流经多个变换器编码器块,每个块具有多个注意力头(通信)后跟一个 MLP 层(计算)。张量的形状保持不变为(1, 64, 512)。
-
线性输出投影: 如果我们假设我们想将每个图像分割成 10 个类别,那么我们需要每个 16x16 大小的补丁具有 10 个通道。输出投影的 nn.Linear 层现在将 512 个嵌入通道转换为 16x16x10 = 2560 个输出通道,这个张量的形状将是(1, 64, 2560)。在上面的图中,C’ = 10。理想情况下,这将是一个多层感知机,因为*“MLPs are universal function approximators”*,但由于这是一个教育练习,我们使用了一个单一的线性层。
-
补丁到图像: 这一层将编码为(1, 64, 2560)张量的 64 个补丁转换回看起来像分割掩码的东西。这可以是 10 个单通道图像,或者在这种情况下是一个单一的 10 通道图像,每个通道都是 10 个类别中的一个类别的分割掩码。输出张量的形状为(1, 10, 128, 128)。
就这样——我们已经成功地使用视觉变换器对输入图像进行了分割!接下来,让我们看一下实验及其结果。
视觉变换器的实际应用
这个笔记本包含了本节的所有代码。
就代码和类结构而言,它紧密地模拟了上面的框图。上述提到的大部分概念与这个笔记本中的类名有 1:1 的对应关系。
有一些与注意力层相关的概念,这些概念是我们模型的关键超参数。我们没有提及多头注意力的详细信息,因为我们提到它超出了本文的范围。如果你对 Transformers 中的注意力机制没有基本了解,我们强烈建议在继续之前阅读上述参考材料。
我们为视觉 Transformer 分割使用了以下模型参数。
-
PatchEmbedding 层的嵌入维度为 768
-
12 个 Transformer 编码器块
-
每个 Transformer 编码器块中有 8 个注意力头
-
在多头注意力和 MLP 中使用 20% 的 dropout
这个配置可以在 VisionTransformerArgs Python 数据类中看到。
@dataclass
class VisionTransformerArgs:
"""Arguments to the VisionTransformerForSegmentation."""
image_size: int = 128
patch_size: int = 16
in_channels: int = 3
out_channels: int = 3
embed_size: int = 768
num_blocks: int = 12
num_heads: int = 8
dropout: float = 0.2
# end class
在模型训练和验证过程中使用了与 之前 类似的配置。配置详见下文。
-
对训练集应用 随机水平翻转 和 颜色抖动 数据增强以防止过拟合
-
图像在非保持宽高比的调整操作中被调整为 128x128 像素
-
图像未应用输入归一化 —— 而是使用了 作为模型第一层的批归一化层
-
模型使用 Adam 优化器进行 50 次训练周期,学习率为 0.0004,并使用 StepLR 调度器,每 12 个周期将学习率衰减 0.8 倍
-
交叉熵损失函数用于将像素分类为宠物、背景或宠物边界
模型有 8628 万个参数,并在 50 次训练周期后达到了 85.89% 的验证准确率。这低于深度 CNN 模型在 20 次训练周期后达到的 88.28% 的准确率。这可能由于一些需要实验验证的因素。
-
最后的输出投影层是单个 nn.Linear,而不是多层感知机。
-
16x16 的补丁大小太大,无法捕捉更多细粒度的细节
-
训练周期不足
-
训练数据不足 —— 众所周知,Transformer 模型相比于深度 CNN 模型需要更多的数据才能有效训练
-
学习率过低
我们绘制了一个 gif,展示了模型如何学习预测验证集中的 21 张图像的分割掩码。
图 8:一个 gif 展示了视觉 Transformer 对图像分割模型预测的分割掩码的进展。来源:作者。
我们在早期训练轮次中注意到了一些有趣的现象。预测的分割掩码出现了一些奇怪的块状伪影。我们能想到的唯一原因是因为我们将图像分解为大小为 16x16 的图块,而在训练了很少的轮次后,模型还没有学到除了关于这个 16x16 图块是否通常被宠物或背景像素覆盖的非常粗糙的信息之外的任何有用知识。
图 9:使用视觉变换器进行图像分割时,预测的分割掩码中看到的块状伪影。来源:作者。
现在我们已经看到一个基本的视觉变换器在实际应用中,让我们将注意力转向用于分割任务的最先进视觉变换器。
SegFormer:基于变换器的语义分割
SegFormer 架构在 这篇论文 中于 2021 年提出。我们看到的变换器是 SegFormer 架构的简化版本。
图 10:SegFormer 架构。来源:SegFormer 论文 (2021)。
最值得注意的是,SegFormer:
-
生成 4 组图像,图块大小分别为 4x4、8x8、16x16 和 32x32,而不是生成单一图块大小为 16x16 的图像。
-
使用了 4 个变换器编码块,而不仅仅是 1 个。这感觉像是模型集成。
-
在自注意力的前期和后期阶段使用卷积。
-
不使用位置嵌入
-
每个变换器块处理图像的空间分辨率为 H/4 x W/4、H/8 x W/8、H/16 x W/16 和 H/32 x W/32。
-
类似地,当空间维度减少时,通道数会增加。这与深度 CNN 类似。
-
在解码器中,将多个空间维度的预测结果上采样并合并在一起。
-
MLP 将所有这些预测结合起来,提供最终预测。
-
最终预测位于空间维度 H/4,W/4,而不是 H,W。
结论
在本系列的第四部分中,我们介绍了变换器架构,特别是视觉变换器。我们对视觉变换器如何工作有了直观的理解,并了解了视觉变换器通信和计算阶段的基本构建块。我们看到了视觉变换器采用的独特图块方法,用于预测分割掩码,然后将预测结果结合在一起。
我们回顾了一项实验,展示了视觉变换器的实际应用,并能够将结果与深度 CNN 方法进行比较。虽然我们的视觉变换器不是最先进的,但它能够取得相当不错的结果。我们简要介绍了如 SegFormer 等最先进的方法。
现在应该很清楚,相比于基于深度 CNN 的方法,transformers 具有更多的活动部分且更复杂。从原始的 FLOPs 角度来看,transformers 有可能更高效。在 transformers 中,唯一真正计算密集的层是 nn.Linear。大多数架构使用优化的矩阵乘法来实现这一点。由于这种架构上的简单性,transformers 有可能比基于深度 CNN 的方法更容易优化和加速。
恭喜你读到这里!我们很高兴你喜欢阅读这一系列关于 PyTorch 高效图像分割的文章。如果你有问题或评论,请随时在评论区留言。
进一步阅读
注意力机制的详细信息超出了本文的范围。此外,您可以参考许多高质量的资源以深入了解注意力机制。以下是我们强烈推荐的一些资源。
我们将在下面提供更多关于 vision transformers 的文章链接。
-
在 PyTorch 中实现 Vision Transformer (ViT):这篇文章详细介绍了在 PyTorch 中实现用于图像分类的 vision transformer。值得注意的是,他们的实现使用了 einops,但我们避免使用,因为这是一个以教育为重点的练习(不过我们建议您学习和使用 einops 以提高代码的可读性)。我们则使用原生 PyTorch 操作符来排列和重排张量维度。此外,作者在一些地方使用了 Conv2d 替代 Linear 层。我们希望在完全不使用卷积层的情况下构建 vision transformers 的实现。
-
在 PyTorch 中实现 SegFormer
使用 NumPy 实现高效的 k-近邻(k-NN)解决方案
原文:
towardsdatascience.com/efficient-k-nearest-neighbors-k-nn-solutions-with-numpy-58cbac2a0971
快速计算
利用 NumPy 的广播、花式索引和排序进行性能计算
·发布在数据科学前沿 ·阅读时间 9 分钟·2023 年 7 月 20 日
–
图片来源:作者创建,Canva
介绍
我有一个朋友是一名城市规划师。一天,他被要求重新评估城市中成千上万的加油站的位置适宜性,需要找出每个加油站的 k 个最近加油站的位置。
我们如何在很短的时间内找到最近的 k 个加油站?这是 k-近邻问题的一个实际应用场景。
因此,他来找我寻求帮助,希望我能提供一个高性能的解决方案。
所以我写下了这篇文章,它将指导你如何使用 NumPy 高效解决 k-近邻问题。通过与 Python 迭代解决方案进行比较,我们将展示 NumPy 的强大性能。
在本文中,我们将深入探讨利用高级 NumPy 特性,如广播、花式索引和排序,来实现高性能的 k-近邻算法。
阅读本文后,你将能够:
-
了解 k-近邻问题及其实际应用场景
-
学习如何使用 NumPy 库解决 k-近邻问题
-
深入理解 NumPy 广播、花式索引和排序等特性在算法中的作用
-
比较 NumPy 与 Python 迭代解决方案的性能,探索为什么 NumPy 更优
让我们一起深入探讨NumPy 的高性能世界,探索如何仅使用 NumPy 更快、更有效地解决 k-近邻问题。
k-NN 问题的几何原理
从几何角度回顾我朋友面临的加油站问题。
假设我们将所有的加油站放置在二维平面上,加油站之间的距离实际上是平面上两个点之间的欧几里得距离。解决公式如下:
由作者创建,Embed Fun
但是,任意两个点之间的距离应该如何计算呢?
我们可以将二维平面想象成一个棋盘,将加油站简化为六个,并将这六个点依次排列在棋盘的水平和垂直边缘,如图所示:
在棋盘上排列这六个点。图像由作者提供
然后,任意两个点的延伸线相交的网格可以表示这两个点之间的距离。当i=j
时,这两个点是相同的,距离应该是 0。
假设这里的k=2
,我们只需将每个点到其他点的距离按升序排序,然后取出前三个距离(包括自身),这些点就是离这个点最近的两个点。
排序后,我们可以得到彼此最接近的 3 个点。图像由作者提供
传统的 Python 迭代解决方案
作为性能基准,我们先来看传统的 Python 迭代解决方案。
这个解决方案的思路相对简单:
-
计算坐标点自身与列表中其他坐标点之间的欧几里得距离。
-
然后比较当前点与其他点之间的距离。
-
选择满足要求的前
k
个点。
接下来是代码部分。
首先,我们随机生成六个坐标点。由于稍后将使用相同的坐标进行比较,因此我们需要为random
包添加一个seed
。
import random
import matplotlib.pyplot as plt
%matplotlib inline
plt.style.use('seaborn-v0_8-whitegrid')
random.seed(5)
def generate_points(n: int=6) -> list[tuple]:
points = []
for i in range(n):
points.append((random.randint(0, 100), random.randint(0, 100)))
return points
接下来,开始计算每个点到列表中所有点(包括自身)的距离,这需要两个迭代。
def calc_dist(points: list[tuple]) -> list[list]:
result = []
for i, left in enumerate(points):
row = [left]
for j, right in enumerate(points):
dist = (left[0] - right[0])**2 + (left[1] - right[1])**2
row.append(dist)
result.append(row)
return result
然后,对每个点与其他点之间的距离进行排序,并在原始列表中找到与距离对应的点的索引。
def find_sorted_index(with_dist: list[list]) -> list[list]:
results = []
for row in with_dist:
dists = row[1:]
sorted_dists = sorted(dists)
indices = [dists.index(i) for i in sorted_dists]
row[1:] = indices
results.append(row)
return results
最终返回应该是一个二维数组,其中数组的每一行的第一个项目是当前点,其他项目是排序后每个点在列表中的索引。
最后,我们根据索引在原始坐标列表中找到每个符合条件的点。
def find_k_nearest(points: list[tuple], with_indices: list[list], k: int) -> list[tuple]:
results = []
for row in with_indices:
# Since the closest point to the current point is itself, we can get the point itself directly, so here is +2
k_indices = row[1:k+2]
the_points = [points[i] for i in k_indices]
results.append(the_points)
return results
结果是一个二维数组,数组的每一行是当前点及其他两个最近的点。
为了方便评估结果,我们使用Matplotlib
绘制所有坐标点以及从每个坐标到两个最近坐标的线条。
def draw_points(points: list[tuple]):
x, y = [], []
for point in points:
x.append(point[0])
y.append(point[1])
plt.scatter(x, y, s=100)
def draw_lines(nearest: list[list]):
for row in nearest:
start = row[0]
for end in row[1:]:
plt.plot([start[0], end[0]], [start[1], end[1]], color='black')
def orig_main(count: int = 6):
k = 2
points = generate_points(count)
with_dist = calc_dist(points)
sorted_index = find_sorted_index(with_dist)
nearest = find_k_nearest(points, sorted_index, k)
return points, nearest
points, nearest = orig_main(6)
draw_points(points)
draw_lines(nearest)
结果如下:
传统的 Python 迭代解决方案。图像由作者提供
如你所见,图表上出现了六个坐标和相应的线条。
这个图表将作为基准,并将与稍后使用 NumPy 的结果进行比较,以确认算法的正确性。
使用 NumPy 解法的基础知识
接下来,让我们看看如何使用 NumPy 来解决这个问题。
在编写代码之前,我们需要对 NumPy 的一些基本概念进行预热。
广播
由于涉及将一组坐标点水平放置(shape=(1, 6)
)和垂直放置(shape=(6, 1)
),并形成一个 (6, 6) 的矩阵。
在计算距离之后,涉及两个不同大小数组之间的操作,因此我们需要使用 NumPy 的广播机制。
这里是一个例子:
In: a = np.arange(6).reshape(1, 6)
b = np.arange(6).reshape(6, 1)
a + b
Out: [[ 0 1 2 3 4 5]
[ 1 2 3 4 5 6]
[ 2 3 4 5 6 7]
[ 3 4 5 6 7 8]
[ 4 5 6 7 8 9]
[ 5 6 7 8 9 10]]
如你所见,当 (1, 6) 的数组和 (6, 1) 的数组相加时,结果的形状是 (6, 6)。
关于具体的原理,请参阅 官方文档。示意图如下:
广播如何工作。图像由作者提供
排序
在解决了任意两个点之间的距离之后,我们还需要对这些距离进行排序。
就像 Python 标准库中的 sort()
函数一样,NumPy 也有一个排序函数:np.sort()
。另外,ndarray.sort()
函数也可以用于排序。
由于我们在对距离进行排序,因此还需要在排序后找到每个项目在原始数组中的索引。在 NumPy 中,我们可以使用 np.argsort()
来获取:
In: x = np.array([2, 1, 4, 3, 5])
i = np.argsort(x)
print(i)
Out: [1 0 3 2 4]
当然,我们只需要关注 k 最近的点,不需要知道距离的顺序。
因此,我们可以使用 NumPy 的 [argpartition()](https://numpy.org/doc/stable/reference/generated/numpy.argpartition.html)
API,它可以在不排序的情况下返回最小几个点的索引,这样性能会更好。
花式索引
在传统的 Python 列表中,如果我们想通过索引查找一组数据,我们需要分别遍历数据列表和索引列表,这样性能非常差。
但 NumPy 提供了花式索引,以快速查找与索引对应的数据。这里是一个例子:
In: x = np.array([8, 2, 4, 5, 3, 7, 1, 6])
ind = [0, 3, 7]
print(x[ind])
Out: [8 5 6]
花式索引可以快速查找与索引数组对应的数据。图像由作者提供
由于花式索引是一组整数数组,因此有一个规则需要遵循:
索引的数据反映了广播索引数组的形状,与数据数组的形状无关。
NumPy 解法
在了解了 NumPy 的一些基础知识之后,让我们看看如何使用 NumPy 解决 k-NN 问题。
由于我们在这里使用一组坐标点来形成一个数组,我们需要使用 NumPy 的 structured_array:
import numpy as np
from numpy import ndarray
random.seed(5)
def structured_array(points: list[tuple]) -> ndarray:
dt = np.dtype([('x', 'int'), ('y', 'int')])
return np.array(points, dtype=dt)
接下来,在水平和垂直方向上向原始的一维数组添加一个额外的维度,将其转变为二维棋盘的两个边:
然后使用广播机制来计算每个点之间的距离。
最终,得到一个 (6, 6) 的二维数组:
def np_find_dist(s_array: ndarray) -> ndarray:
a = s_array.reshape(6, 1)
b = s_array.reshape(1, 6)
dist = (a['x'] - b['x'])**2 + (a['y'] - b['y'])**2
return dist
然后,使用argpartition
方法找出每一行中距离最小的两个点的索引:
def np_k_nearest(dist: ndarray, k: int) -> ndarray:
k_indices = np.argpartition(dist, k+1, axis=1)[:, :k+1]
return k_indices
我们仍需要两个Matplotlib
绘图方法来评估结果的正确性:
def np_draw_points(s_array: ndarray):
plt.scatter(s_array['x'], s_array['y'], s=100)
def np_draw_lines(s_array: ndarray, k_indices: ndarray, k: int):
for i in range(s_array.shape[0]):
for j in k_indices[i, :k+1]:
plt.plot([s_array[i]['x'], s_array[j]['x']],
[s_array[i]['y'], s_array[j]['y']],
color='black')
最后,编写一个主方法将所有代码整合在一起:
def np_main(count: int = 6):
k = 2
points = generate_points(count)
s_array = structured_array(points)
np_dist = np_find_dist(s_array)
k_indices = np_k_nearest(np_dist, k)
results = [s_array[k_indices[i, :k+1]]
for i in range(s_array.shape[0])]
return results, s_array, k_indices, k
results, s_array, k_indices, k = np_main(6)
np_draw_points(s_array)
np_draw_lines(s_array, k_indices, k)
仅从代码来看,它已经比 Python 迭代版本简洁得多。接下来,我们用图表比较结果:
NumPy 解决方案的 k-NN 结果。图片由作者提供
看,结果完全相同!
两种解决方案的性能比较
最后,我们来比较这两种解决方案的执行性能。这里我们仍然使用%timeit
进行评估。
首先是 Python 迭代方法。让我们看看扩展到 1,000 个坐标需要多长时间:
Python 迭代解决方案的执行时间。图片由作者提供
然后是 NumPy 实现。看看 1,000 个坐标需要多长时间:
NumPy 解决方案的执行时间。图片由作者提供
惊讶吧?性能提高了数百倍,所以我的朋友不必担心计算不了。
结论
本文教会了我们如何使用 NumPy 的广播、花式索引和排序来高效解决 k 最近邻问题。
我们还比较了 NumPy 与 Python 迭代解决方案的性能,并深入理解了为什么 NumPy 在解决这类问题时表现更好。
总结一下,我们学到了以下内容:
-
k 最近邻问题的定义及实际应用场景
-
如何使用 NumPy 库解决 k 最近邻问题
-
NumPy 的广播、花式索引、排序及其他特性在算法实现中的应用
-
NumPy 与 Python 暴力解决方案的性能比较分析
虽然本文提供了一种高效的 k 最近邻解决方案,但这只是一个起点。
在未来的文章中,我将使用高级算法和数据结构重新解释这个问题的解决方案,展示更多高效且可用的算法技巧。
敬请关注未来的文章。如果你对本文感兴趣,欢迎评论,我会逐一回复。
让我从基础开始,带你了解工作中的最佳科学计算实践。
快速计算
查看列表4 篇故事!如何用 Numexpr 优化多维 Numpy 数组操作。 [## 通过我的推荐链接加入 Medium - 彭倩
作为 Medium 会员,你的会员费的一部分将直接支持你阅读的作者,并且你可以完全访问每一篇故事……
medium.com](https://medium.com/@qtalen/membership?source=post_page-----58cbac2a0971--------------------------------)
这篇文章最初发布在:www.dataleadsfuture.com/efficient-k-nearest-neighbors-k-nn-solutions-with-numpy/