用 Twitter 了解公众对核能的情绪
一个文本分类项目与裘德布埃纳塞达
来源:Pixabay
随着气候变化的影响变得越来越普遍,替代能源的必要性变得更加迫切。因此,围绕核能的讨论是不可能避免的,因为它是一个关键的竞争者,其效率是不可否认的。然而,核能已经成为一个分裂和两极分化的话题,因为如果没有适当的预防措施,其副产品可能对人类健康和环境产生毁灭性影响。
对于这个项目,我们想了解人们对核能的情绪是否容易被识别,以及它看起来像什么。在 Twitter 及其用户意见的帮助下,我们能够尝试这种分类。我们这个项目的目标是首先通过对推特上的情绪进行分类来了解公众对核能的看法,然后根据与每个情绪相关的关键词或主题提取价值。这对于公司和政策制定者来说都是非常有用的,可以围绕他们的受众所谈论的内容来优化和定制信息和策略。
数据
我们使用 Twint 从 Twitter 上随机抓取了 3000 条使用搜索词Nuclear Energy
的推文。所有推文都来自 2020 年。下面是我们数据集中的一个 tweet 文本示例:
| Date: 2020-06-12 | Time 15:06:43 | Tweet: "It's dangerous to lift the prohibition on nuclear. Radioactive waste containment isn't clean energy, it's an accident waiting to happen. Nuclear energy lobbyist should never be trusted, they'll lie to make a profit, even if it means cancer and birth defects for the people."
每条推文的情绪都在维德的协助下贴上了标签。这个项目中使用的三种标签分别是:阳性、阴性和中性。我们标记的推文的分布严重不平衡——大多数推文带有中性情绪,然后是积极情绪,然后是消极情绪。
标签分布,用 Seaborn 绘制
我们最初的 EDA 侧重于分析每个情感类的词频。在下面的单词云中,可以开始观察到不同类别之间在词汇上的明显差异。
负面推文中的前 20 个词
正面推文中的前 20 个词
中性推文中的前 20 个词
中性班的学生似乎肯定会选择更多的研究和信息主题。
数据处理和特征工程
由于我们只处理每条推文的文本数据,建模之前采取的清理和工程步骤是:
- 使用 NLTK 删除了英语停用词。
- 标点符号、链接和非字母字符也用正则表达式从推文中删除。
- 我们定制了我们的停用词列表,包括我们的搜索词和要从每条推文中删除的主题——“核能”、“核能”、“核能”和“能源”。
- 每条 tweet 都使用 NLTK 的 TF-IDF 实现进行了标记化、矢量化和小写转换。
- 在我们的 EDA 中使用了词频,但是我们的模型依赖于每个词的 TF-IDF 分数。
建模
为了对一条推文的情绪进行分类,我们训练并测试了以下模型——基线虚拟分类器(预测多数类)、朴素贝叶斯分类器、随机森林分类器和线性 SVM 分类器。由于我们的班级最初是不平衡的,我们必须在模型中考虑到这一点。下面是我们测试预测的准确性得分的快照:
使用 sklearn 建模结果
我们的线性 SVM 模型表现最好,其次是随机森林。线性 SVM 与自然语言处理和文本分类配合得非常好,因为我们最终得到了比观察值更多的特征。
使用我们的线性 SVM,我们能够获得有用的洞察力,了解模型正在使用什么特征或词来进行情感分类。在这三个图表中,我们可视化了每个情感的前 5 个特征/词系数。在很大程度上,这些话看起来很合理,并为每种情绪的分类提供了透明度。
总结想法&要点
Twitter 等社交媒体的使用已经成为获取任何和所有话题的公众意见的重要工具。这对公司和政策制定者来说都是至关重要的信息。我们能够使用 Twitter 来捕捉公众对核能的看法,并强调围绕每种情绪的关键问题和主题——积极的、消极的和中立的。这对于公司和政策制定者来说,在营销和信息传递方面意义重大。举个例子——废料经常与负面讨论联系在一起,所以我们可以得出结论,一个很大的担忧是围绕核废料及其后果,而不是能源本身。因此,可以在考虑到这一点的情况下精心制作信息,以消除顾虑和担忧。
后续步骤
为了扩展我们模型的预测能力,添加其他非文本特征以及探索神经网络模型可能是值得的。
来源:Pixabay
如果你对这个项目背后的代码感兴趣——看看我的 Github:【https://github.com/AlisonSalerno/twitter_sentiment_analysis
理解 Python 字节码
了解如何反汇编 Python 字节码
编程语言的源代码可以使用解释器或编译器来执行。在编译语言中,编译器会将源代码直接翻译成二进制机器代码。该机器代码特定于目标机器,因为每台机器可以具有不同操作系统和硬件。编译后,目标机器会直接运行机器码。
在解释语言中,源代码不是由目标机器直接运行的。还有一个程序叫做解释器,它直接读取并执行源代码。特定于目标机器的解释器将源代码的每个语句翻译成机器代码并运行它。
Python 通常被称为解释语言,然而,它结合了编译和解释。当我们执行一个源代码(扩展名为.py
的文件)时,Python 首先将其编译成字节码。字节码是独立于平台的低级源代码表示,但是,它不是二进制机器码,不能由目标机器直接运行。事实上,它是一个虚拟机的指令集,这个虚拟机被称为 Python 虚拟机(PVM)。
编译后,字节码被发送到 PVM 执行。PVM 是运行字节码的解释器,是 Python 系统的一部分。字节码是独立于平台的,但是 PVM 是特定于目标机器的。Python 编程语言的默认实现是用 C 编程语言编写的 CPython。CPython 将 Python 源代码编译成字节码,然后这个字节码由 CPython 虚拟机执行。
生成字节码文件
在 Python 中,字节码存储在一个.pyc
文件中。在 Python 3 中,字节码文件存储在一个名为__pycache__
的文件夹中。当您尝试导入您创建的另一个文件时,会自动创建此文件夹:
import file_name
但是,如果我们不在源代码中导入另一个文件,它就不会被创建。在这种情况下,我们仍然可以手动创建它。要从命令行编译单个文件file_1.py
到file_n.py
,我们可以写:
python -m compileall file_1.py ... file_n.py
所有生成的pyc
文件都将存储在__pycache__
文件夹中。如果在compileall,
之后没有提供文件名,它将编译当前文件夹中的所有 python 源代码文件。
我们还可以使用compile()
函数来编译包含 Python 源代码的字符串。该函数的语法是:
compile(*source*, *filename*, *mode*, *flag*, *dont_inherit*, *optimize*)
我们只关注前三个必需的参数(其他的是可选的)。source
是要编译的源代码,可以是字符串、字节对象或 AST 对象。filename
是源代码所来自的文件的名称。如果源代码不是来自一个文件,你可以写你喜欢的任何东西或者留下一个空字符串。mode
可以是:
'exec'
:接受任何形式的 Python 源代码(任意数量的语句或块)。它将它们编译成字节码,最终返回None
'eval'
:接受单个表达式,并将其编译成字节码,最终返回该表达式的值
'single'
:只接受一条语句(或用;
分隔的多条语句)。如果最后一条语句是一个表达式,那么产生的字节码会将该表达式的值的repr()
打印到标准输出中。
例如,为了编译一些 Python 语句,我们可以编写:
s='''
a=5
a+=1
print(a)
'''
compile(s, "", "exec")
或者换句话说:
compile("a=5 \na+=1 \nprint(a)", "", "exec")
要评估一个表达式,我们可以写:
compile("a+7", "", "eval")
如果您没有表达式,此模式会给出一个错误:
# This does not work:
compile("a=a+1", "", "eval")
这里a=a+1
不是一个表达式,不返回任何东西,所以我们不能使用eval
模式。但是,我们可以使用single
模式来编译它:
compile("a=a+1", "", "single")
但是compile
返回的是什么?当您运行compile
函数时,Python 返回:
<code object <module> at 0x000001A1DED95540, file "", line 1>
所以compile
函数返回的是一个代码对象(在at
之后的地址在你的机器上可以不同)。
代码对象
compile()
函数返回一个 Python 代码对象。Python 中的一切都是对象。例如,我们定义了一个整数变量,它的值存储在一个int
对象中,您可以使用type()
函数轻松检查它的类型:
a = 5
type(a) # Output is: int
以类似的方式,编译函数生成的字节码存储在code
对象中。
c = compile("a=a+1", "", "single")
type(c) # Output is: code
code 对象不仅包含字节码,还包含 CPython 运行字节码所需的一些其他信息(稍后将讨论)。代码对象可以通过传递给exec()
或eval()
函数来执行或评估。所以我们可以写:
exec(compile("print(5)", "", "single")) # Output is: 5
当你在 Python 中定义一个函数时,它会为它创建一个代码对象,你可以使用__code__
属性来访问它。例如,我们可以写:
def f(n):
return n
f.__code__
输出将是:
<code object f at 0x000001A1E093E660, file "<ipython-input-61-88c7683062d9>", line 1>
像任何其他对象一样,code 对象也有一些属性,要获得存储在 code 对象中的字节码,可以使用它的co_code
属性:
c = compile("print(5)", "", "single")
c.co_code
输出是:
b'e\x00d\x00\x83\x01F\x00d\x01S\x00'
结果是一个前缀为b'.
的字节文字,它是一个不可变的字节序列,类型为bytes
。每个字节可以有一个 0 到 255 的十进制值。所以一个字节文字是一个不可变的 0 到 255 之间的整数序列。每个字节可由字符代码与字节值相同的 ASCII 字符显示,也可由前导\x
后跟两个字符显示。前导\x
转义意味着接下来的两个字符被解释为字符代码的十六进制数字。例如:
print(c.co_code[0])
chr(c.co_code[0])
给出:
101
'e'
因为第一个元素具有十进制值 101,并且可以用字符e
来显示,该字符的 ASCII 字符代码是 101。或者:
print(c.co_code[4])
chr(c.co_code[4])
给出:
131
'\x83'
因为第 4 个元素的十进制值为 131。131 的十六进制值是 83。所以这个字节可以用一个字符码为\x83
的字符来表示。
这些字节序列可以被 CPython 解释,但是它们对人类不友好。所以我们需要理解这些字节是如何映射到 CPython 将要执行的实际指令的。在下一节中,我们将把字节码分解成一些对人友好的指令,看看 CPython 如何执行字节码。
字节码细节
在深入细节之前,重要的是要注意字节码的实现细节通常会随着 Python 版本的不同而变化。因此,您在本文中看到的内容可能并不适用于 Python 的所有版本。事实上,它包括了 3.6 版本中发生的变化,一些细节可能对旧版本无效。本文中的代码已经用 Python 3.7 进行了测试。
字节码可以被认为是 Python 解释器的一系列指令或低级程序。在 3.6 版本之后,Python 对每条指令使用 2 个字节。一个字节用于该指令的代码,称为操作码,一个字节保留用于其参数,称为 oparg。每个操作码都有一个友好的名字,叫做 opname 。字节码指令的一般格式如下:
opcode oparg
opcode oparg
.
.
.
我们的字节码中已经有了操作码,我们只需要将它们映射到它们对应的 opname。有一个名为dis
的模块可以帮助解决这个问题。在这个模块中,有一个名为opname
的列表,它存储了所有的 opnames。此列表的第 i 个元素给出了操作码等于 i 的指令的 opname。
有些指令不需要参数,所以它们会忽略操作码后面的字节。值低于某个数字的操作码会忽略它们的参数。该值存储在dis.HAVE_ARGUMENT
中,目前等于 90。于是操作码> = dis.HAVE_ARGUMENT
有了争论,操作码< dis.HAVE_ARGUMENT
忽略。
例如,假设我们有一个短字节码b'd\x00Z\x00d\x01S\x00'
,我们想反汇编它。这个字节码代表一个四字节的序列。我们可以很容易地显示它们的十进制值:
bytecode = b'd\x00Z\x00d\x01S\x00'
for byte in bytecode:
print(byte, end=' ')
输出将是:
100 0 90 0 100 1 83 0
字节码的前两个字节是100 0
。第一个字节是操作码。要得到它的 opname 我们可以写(dis
应该先导入):
dis.opname[100]
而结果是LOAD_CONST
。由于操作码大于dis.HAVE_ARGUMENT
,它有一个 oparg,即第二个字节0
。所以100 0
翻译过来就是:
LOAD_CONST 0
字节码中的最后两个字节是83 0
。我们再次写dis.opname[83]
,结果是RETURN_VALUE
。83 小于 90 ( dis.HAVE_ARGUMENT
),所以该操作码忽略 oparg,并且83 0
被分解为:
RETURN_VALUE
此外,某些指令的参数可能太大,无法放入默认的一个字节中。有一个特殊的操作码144
来处理这些指令。它的 opname 是EXTENDED_ARG
,也存放在dis.EXTENDED_ARG
。这个操作码是任何参数大于一个字节的操作码的前缀。例如,假设我们有操作码 131(它的 opname 是CALL_FUNCTION
),它的 oparg 需要是 260。所以应该是:
CALL_FUNCTION 260
但是,一个字节可以存储的最大数量是 255,260 不适合一个字节。所以这个操作码的前缀是EXTENDED_ARG
:
EXTENDED_ARG 1
CALL_FUNCTION 4
当解释器执行EXTENDED_ARG
时,它的 oparg(为 1)左移 8 位,并存储在一个临时变量中。姑且称之为extended_arg
(不要和 opname EXTENDED_ARG
混淆):
extened_arg = 1 << 8 # same as 1 * 256
于是二进制值0b1
(二进制值 1)被转换为0b100000000
。这就像在十进制系统中 1 乘以 256,extened_arg
将等于 256。现在我们在extened_arg
中有两个字节。当解释器执行到下一条指令时,使用按位or
将这个双字节值添加到它的 oparg(这里是 4)中。
extened_arg = extened_arg | 4
# Same as extened_arg += 4
这就像把 oparg 的值加到extened_arg
上。所以现在我们有:
extened_arg = 256 + 4 = 260
该值将作为CALL_FUNCTION
的实际 oparg。所以,事实上,
EXTENDED_ARG 1
CALL_FUNCTION 4
被解释为:
EXTENDED_ARG 1
CALL_FUNCTION 260
对于每个操作码,最多允许三个前缀EXTENDED_ARG
,形成一个从两字节到四字节的参数。
现在,我们可以专注于 oparg 本身。这是什么意思?实际上,每个 oparg 的含义取决于它的操作码。如前所述,代码对象存储除字节码之外的一些信息。可以使用代码对象的不同属性来访问这些信息,我们需要其中的一些属性来解释每个 oparg 的含义。这些属性是:co_consts
、co_names
、co_varnames
、co_cellvars
和co_freevars
。
代码对象属性
我将用一个例子来解释这些属性的含义。假设您有这个源代码的代码对象:
# Listing 1
s = '''
a = 5
b = 'text'
def f(x):
return x
f(5)
'''
c=compile(s, "", "exec")
现在,我们可以检查每个属性中存储了什么:
1- co_consts
:包含字节码使用的文字的元组。这里c.co_consts
返回:
(5, 'text', <code object f at 0x00000218C297EF60, file "", line 4>, 'f', None)
所以字面量5
和'text'
以及函数名'f'
都存储在这个元组中。此外,函数f
的主体存储在一个单独的代码对象中,并被视为一个同样存储在该元组中的文字。记住compile()
中的exec
模式生成一个最终返回None
的字节码。这个None
值也被存储为一个文字。事实上,如果你像这样在eval
模式下编译一个表达式:
s = "3 * a"
c1 = compile(s, "", "eval")
c1.co_consts # Output is (3,)
None
将不再包含在co_consts
元组中。原因是这个表达式返回它的最终值而不是None
。
如果你试图获取一个函数的目标代码的co_const
,比如:
def f(x):
a = x * 2
return a
f.__code__.co_consts
结果会是(None, 2)
。事实上,函数的默认返回值是None
,并且总是作为文字添加。正如我后面解释的,为了提高效率,Python 不会检查你是否总是要到达一个return
语句,所以None
总是被添加为默认返回值。
2- co_names
:包含字节码使用的名称的元组,可以是全局变量、函数和类,也可以是从对象加载的属性。例如,对于清单 1 中的目标代码,c.co_names
给出了:
('a', 'b', 'f')
3- co_varnames
:包含字节码使用的本地名称的元组(首先是参数,然后是本地变量)。如果我们对清单 1 的对象代码进行尝试,它会给出一个空元组。原因是局部名称是在函数内部定义的,清单 1 中的函数是作为一个单独的代码对象存储的,所以它的局部变量不会包含在这个元组中。为了访问一个函数的局部变量,我们应该为那个函数的代码对象使用这个属性。所以我们先写这段源代码:
def f(x):
z = 3
t = 5
def g(y):
return t*x + y
return g
a = 5
b = 1
h = f(a)
现在f.__code__
给出f
的代码对象,f.__code__.co_varnames
给出:
('x', 'z', 'g')
为什么不包括t
?原因是t
不是f
的局部变量。它是一个非局部变量,因为它是由f
中的闭包g
访问的。实际上,x
也是一个非局部变量,但既然是函数的自变量,那么它总是包含在这个元组中。要了解更多关于闭包和非局部变量的知识,你可以参考这篇文章。
4- co_cellvars
:包含非局部变量名称的元组。这些是由内部函数访问的函数的局部变量。所以f.__code__.co_cellvars
给出:
('t', 'x')
5- co_freevars
: 包含自由变量名称的元组。自由变量是外部函数的局部变量,由内部函数访问。所以这个属性应该和闭包h
的代码对象一起使用。现在h.__code__.co_freevars
给出了相同的结果:
('t', 'x')
现在我们已经熟悉了这些属性,我们可以回到 opargs。每个 oparg 的含义取决于它的操作码。我们有不同类别的操作码,每个类别的 oparg 都有不同的含义。在dis
模块中,有一些列表给出了每个类别的操作码:
1- dis.hasconst
:此列表等于【100】。所以只有操作码 100(它的 opname 是 LOAD_CONST)属于hasconst
的范畴。这个操作码的 oparg 给出了co_consts
元组中一个元素的索引。例如,在清单 1 的字节码中,如果我们有:
LOAD_CONST 1
那么 oparg 就是索引为 1 的co_consts
的元素。所以我们要把1
换成co_consts[1]
等于'text'
。因此该指令将被解释为:
LOAD_CONST 'text'
类似地,在dis
模块中有一些其他的列表定义操作码的其他类别:
2- dis.hasname
:这个列表中操作码的 oparg 是co_names
中一个元素的索引
3- dis.haslocal
:该列表中操作码的 oparg 是co_varnames
中一个元素的索引
4- dis.hasfree
:该列表中操作码的 oparg 是co_cellvars + co_freevars
中一个元素的索引
5- dis.hascompare
:这个列表中操作码的 oparg 是元组dis.cmp_op
的一个元素的索引。这个元组包含比较和成员操作符,如<
或==
6- dis.hasjrel
:这个列表中操作码的 oparg 应该替换为offset + 2 + oparg
,其中offset
是字节码序列中代表操作码的字节的索引。
code 对象还有一个更重要的属性应该在这里讨论。它被称为co_lnotab
,存储字节码的行号信息。这是一个存储在字节文字中的有符号字节数组,用于将字节码偏移量映射到源代码行号。我举个例子解释一下。假设您的源代码只有三行,并且已经被编译成 24 字节的字节码:
1 0 LOAD_CONST 0
2 STORE_NAME 0
2 4 LOAD_NAME 0
6 LOAD_CONST 1
8 INPLACE_ADD
10 STORE_NAME 0
3 12 LOAD_NAME 1
14 LOAD_NAME 0
16 CALL_FUNCTION 1
18 POP_TOP
20 LOAD_CONST 2
22 RETURN_VALUE
现在我们有了从字节码偏移量到行号的映射,如下表所示:
字节码偏移量总是从 0 开始。code 对象有一个名为co_firstlineno
的属性,它给出了偏移量 0 的行号。对于这个例子来说co_firstlineno
等于 1。Python 只存储从一行到下一行的增量(不包括第一行),而不是存储偏移量和行号。所以前面的表格变成了:
这两个增量列按如下顺序压缩在一起:
4 1 8 1
每个数字存储在一个字节中,整个序列作为一个字节文字存储在代码对象的co_lnotab
中。因此,如果您检查co_lnotab
的值,您会得到:
b'\x04\x01\x08\x01'
它是前一个序列的字节数。因此,通过拥有属性co_lnotab
和co_firstlineno
,您可以检索从字节码偏移到源代码行号的映射。co_lnotab
是一个有符号的字节序列。所以其中的每个有符号字节可以取-128 到 127 之间的值(这些值仍然存储在取 0 到 255 的字节中。但是介于 128 和 255 之间的值被认为是负数)。负增量意味着行号在减少(这个特性用在优化器中)。但是如果行增量大于 127 会怎么样呢?在这种情况下,行增量将被分成 127 和一些额外的字节,这些额外的字节将以零偏移增量存储(如果它小于-128,它将被分成-128 和一些额外的字节,偏移增量为零)。例如,假设字节码偏移量与行号的关系如下:
那么偏移增量与行号增量之比应为:
139 等于 127 + 12。所以前一行应该写成:
并且应该存储为8 127 0 12
。所以co_lnotab
的值会是:b'\x08\x7f\x00\x0c'
。
反汇编字节码
现在我们已经熟悉了字节码结构,我们可以编写一个简单的反汇编程序。我们首先编写一个生成器函数来解包每个指令,并生成偏移量、操作码和 oparg:
这个函数从字节码中读取下一对字节。第一个字节是操作码。通过将该操作码与dis.HAVE_ARGUMENT
进行比较,该函数决定是将第二个字节作为 oparg 还是忽略它。使用按位 or ( |
)将extended_arg
的值添加到 oparg。最初,它为零,对 oparg 没有影响。如果操作码等于dis.EXTENDED_ARG
,它的 oparg 将左移 8 位,并存储在一个名为extended_arg
的临时变量中。
在下一次迭代中,这个临时变量将被添加到下一个 oparg 中,并向其中添加一个字节。如果下一个操作码再次是dis.EXTENDED_ARG
,则该过程继续,并且每次将一个字节加到extended_arg
。最后,当它到达一个不同的操作码时,extended_arg
将被加到它的 oparg 并被设置回零。
find_linestarts
函数返回一个字典,其中包含每个字节码偏移量的源代码行号。
它首先将co_lnotab
字节文字分成两个序列。一个是偏移增量,另一个是行号增量。偏移0
的行号在co_firstlineno
中。将这两个数字相加得到字节码偏移量及其对应的行号。如果行号增量等于或大于 128 (0x80),它将被视为减量。
get_argvalue
函数返回每个 oparg 的友好含义。它首先检查操作码属于哪个类别,然后判断 oparg 指的是什么。
findlabels
函数找到字节码中所有作为跳转目标的偏移量,并返回这些偏移量的列表。跳转目标将在下一节讨论。
现在我们可以使用所有这些函数来反汇编字节码。dissassemble
函数获取一个代码对象并将其反汇编:
它将首先解包代码对象的字节码中每对字节的偏移量、操作码和 oparg。然后,它找到相应的源代码行号,并检查偏移量是否是跳转目标。最后,它查找 opname 和 oparg 的含义,并打印所有信息。如前所述,每个函数定义都存储在一个单独的代码对象中。所以在最后,函数递归地调用自己来反汇编字节码中的所有函数定义。下面是一个使用这个函数的例子。最初,我们有这样的源代码:
a=0
while a<10:
print(a)
a += 1
我们首先将它存储在一个字符串中,并编译它以获得目标代码。然后我们使用disassemble
函数反汇编它的字节码:
s='''a=0
while a<10:
print(a)
a += 1
'''
c=compile(s, "", "exec")
disassemble(c)
输出是:
1 0 LOAD_CONST 0 (0)
2 STORE_NAME 0 (a)
2 4 SETUP_LOOP 28 (to 34)
>> 6 LOAD_NAME 0 (a)
8 LOAD_CONST 1 (10)
10 COMPARE_OP 0 (<)
12 POP_JUMP_IF_FALSE 32
3 14 LOAD_NAME 1 (print)
16 LOAD_NAME 0 (a)
18 CALL_FUNCTION 1
20 POP_TOP
4 22 LOAD_NAME 0 (a)
24 LOAD_CONST 2 (1)
26 INPLACE_ADD
28 STORE_NAME 0 (a)
30 JUMP_ABSOLUTE 6
>> 32 POP_BLOCK
>> 34 LOAD_CONST 3 (None)
36 RETURN_VALUE
所以 4 行源代码被转换成 38 字节的字节码或 19 行字节码。在下一节中,我将解释这些指令的含义以及 CPython 将如何解释它们。
模块dis
有一个名为dis()
的函数,同样可以反汇编代码对象。实际上,本文中的disassmble
函数是dis.dis
函数的简化版。因此,我们可以写dis.dis(c)
来得到类似的输出,而不是写disassemble(c)
。
反汇编一个 pyc 文件
如前所述,编译源代码时,字节码存储在一个pyc
文件中。这个字节码可以用类似的方式反汇编。但是,需要提到的是,pyc
文件包含一些元数据和 编组 格式的代码对象。封送格式用于 Python 的内部对象序列化。元数据的大小取决于 Python 版本,对于版本 3.7,它是 16 个字节。所以当你读取pyc
文件时,首先你应该读取元数据,然后使用marshal
模块加载代码对象。例如,要反汇编__pycache__
文件夹中名为u1.cpython-37.pyc
的pyc
文件,我们可以写:
字节码操作
到目前为止,我们已经学习了如何反汇编字节码指令。我们现在可以关注这些指令的含义以及它们是如何被 CPython 执行的。Python 的默认实现 CPython 使用基于栈的虚拟机。所以首先我们应该熟悉堆栈。
堆栈和堆
栈是一种具有后进先出顺序的数据结构。它有两个主要操作:
- push:将元素添加到堆栈中
- pop:删除最近添加的元素
因此,添加或推入堆栈的最后一个元素是要移除或弹出的第一个元素。使用堆栈存储数据的好处是内存是为你管理的。读取和写入堆栈非常快,但是,堆栈的大小是有限的。
Python 中的数据表示为存储在私有堆上的对象。与堆栈相比,访问堆上的数据要慢一些,但是,堆的大小只受虚拟内存大小的限制。heap 的元素彼此之间没有依赖关系,可以随时随机访问。Python 中的一切都是对象,对象总是存储在堆中。它只是存储在堆栈中的对象的引用(或指针)。
CPython 使用调用栈来运行 Python 程序。在 Python 中调用一个函数时,一个新的框架 被推送到调用栈上,每次函数调用返回时,其框架被弹出。程序运行的模块有一个最底层的框架,称为全局框架或模块框架。
每一帧都有一个评估栈,在那里执行 Python 函数。函数参数及其局部变量被压入这个计算堆栈。CPython 使用评估堆栈来存储任何操作所需的参数以及这些操作的结果。在开始该操作之前,所有必需的参数都被推送到评估堆栈上。然后操作开始并弹出它的参数。当操作完成时,它将结果推回计算堆栈。
所有对象都存储在堆中,框架中的评估堆栈处理对它们的引用。因此,对这些对象的引用可以被临时推到计算堆栈上,以供后面的操作使用。Python 的大部分字节码指令都是在当前框架中操作求值栈。在本文中,每当我们谈到堆栈时,它指的是当前框架中的评估堆栈或全局框架中的评估堆栈,如果我们不在任何函数的范围内。
让我从一个简单的例子开始,反汇编以下源代码的字节码:
a=1
b=2
c=a+b
为此,我们可以写:
s='''a=1
b=2
c=a+b
'''
c=compile(s, "", "exec")
disassemble(c)
我们得到了:
1 0 LOAD_CONST 0 (1)
2 STORE_NAME 0 (a)
2 4 LOAD_CONST 1 (2)
6 STORE_NAME 1 (b)
3 8 LOAD_NAME 0 (a)
10 LOAD_NAME 1 (b)
12 BINARY_ADD
14 STORE_NAME 2 (c)
16 LOAD_CONST 2 (None)
18 RETURN_VALUE
此外,我们可以检查代码对象的其他一些属性:
c.co_consts
# output is: (1, 2, None)
c.co_names
# output is: ('a', 'b', 'c')
这里代码在模块中运行,所以我们在全局框架中。第一个指令是LOAD_CONST 0
。指令
**LOAD_CONST** *consti*
将co_consts[consti]
的值推送到堆栈上。所以我们将co_consts[0]
(等于1
)压入堆栈。
值得注意的是,stack 使用对对象的引用。因此,每当我们说一个指令将一个对象或对象的值压入堆栈时,就意味着压入了对该对象的引用(或指针)。当一个对象或它的值被弹出堆栈时,也会发生同样的事情。再次弹出它的引用。解释器知道如何使用这些引用来检索或存储对象的数据。
指令
**STORE_NAME** *namei*
弹出堆栈的顶部,并将其存储到一个对象中,该对象的引用存储在代码对象的co_names[namei]
中。所以STORE_NAME 0
在栈顶弹出元素(也就是1
)并存储在一个对象中。对这个对象的引用是co_names[0]
也就是a
。这两条指令是源代码中a=1
的字节码等价物。b=2
被类似地转换,现在解释器已经创建了对象a
和b
。源代码的最后一行是c=a+b
。指令
**BINARY_ADD**
弹出堆栈顶部的两个元素(1
和2
),将它们相加,并将结果(3
)推送到堆栈上。所以现在3
在栈顶。之后STORE_NAME 2
将栈顶弹出到本地对象(引用的)c
。现在记住exec
模式下的compile
将源代码编译成最终返回None
的字节码。指令LOAD_CONST 2
将co_consts[2]=None
推到堆栈上,指令
**RETURN_VALUE**
将堆栈的顶部返回给函数的调用方。当然,这里我们是在模块范围内,没有调用函数,所以None
是最终结果,它保留在全局堆栈的顶部。图 1 显示了偏移量为 0 到 14 的所有字节码操作(同样应该注意的是,对象的引用被推送到堆栈上,而不是对象或它们的值。该图没有明确示出)。
函数、全局和局部变量
现在让我们看看如果我们也有一个函数会发生什么。我们将分解源代码的字节码,它有一个功能:
#Listing 2
s='''a = 1
b = 2
def f(x):
global b
b = 3
y = x + 1
return y
f(4)
print(a)
'''
c=compile(s, "", "exec")
disassemble(c)
输出是:
1 0 LOAD_CONST 0 (1)
2 STORE_NAME 0 (a)
2 4 LOAD_CONST 1 (2)
6 STORE_GLOBAL 1 (b)
3 8 LOAD_CONST 2 (<code object f at 0x00000218C2E758A0, file "", line 3>)
10 LOAD_CONST 3 ('f')
12 MAKE_FUNCTION 0
14 STORE_NAME 2 (f)
8 16 LOAD_NAME 2 (f)
18 LOAD_CONST 4 (4)
20 CALL_FUNCTION 1
22 POP_TOP
9 24 LOAD_NAME 3 (print)
26 LOAD_NAME 0 (a)
28 CALL_FUNCTION 1
30 POP_TOP
32 LOAD_CONST 5 (None)
34 RETURN_VALUE
Disassembly of<code object f at 0x00000218C2E758A0, file "", line 3>:
5 0 LOAD_CONST 1 (3)
2 STORE_GLOBAL 0 (b)
6 4 LOAD_FAST 0 (x)
6 LOAD_CONST 2 (1)
8 BINARY_ADD
10 STORE_FAST 1 (y)
7 12 LOAD_FAST 1 (y)
14 RETURN_VALUE
此外,我们可以检查代码对象的其他一些属性:
c.co_consts
# output is: (1, 2, <code object f at 0x00000218C2E758A0, file "", line 3>, 'f', 4, None)c.co_names
# Output is: ('a', 'b', 'f', 'print')
在第一行(偏移量 0 和 2 ),首先使用LOAD_CONST 0
将常量1
推入全局帧的评估堆栈。然后STORE_NAME 0
弹出并存储在一个对象中。
在第二行中,使用LOAD_CONST 1
将常量2
推入堆栈。但是,使用不同的 opname 将其分配给引用。指令
**STORE_GLOBAL** *namei*
弹出栈顶并将其存储到一个对象中,该对象的引用存储在co_names[namei]
中。所以2
存储在b
引用的对象中。这被认为是一个全局变量。但是为什么这个指令没有用于a
?原因是a
是函数f
内部的全局变量。如果变量是在模块范围内定义的,并且没有函数访问它,那么它将通过STORE_NAME
和LOAD_NAME
被存储和加载。在模块范围内,全局变量和局部变量没有区别。
第三行定义了函数f
。函数体在一个名为<code object f at 0x00000218C2E758A0, file "", line 3>
的单独代码对象中编译,并被推送到堆栈上。然后,一个函数名为'f'
的字符串对象被推送到堆栈上(事实上,对它们的引用被推送到堆栈上)。指令
**MAKE_FUNCTION** *argc*
用于创建函数。它需要一些应该被推到堆栈上的参数。函数名应该在栈顶,函数的代码对象应该在栈底。在这个例子中,它的 oparg 是零,但是它可以有其他值。例如,如果函数定义有一个关键字参数,如:
def f(x=5):
global b
b = 3
y = x + 1
return y
那么第 2 行的反汇编字节码应该是:
2 4 LOAD_CONST 5 ((5,))
6 LOAD_CONST 1 (<code object f at 0x00000218C2E75AE0, file "", line 2>)
8 LOAD_CONST 2 ('f')
10 MAKE_FUNCTION 1
MAKE_FUNCTION
的 oparg1
表示该函数有一些关键字参数,一个包含默认值的 tuple 应该在该函数的 code 对象(这里是(5,)
)之前被推送到堆栈上。创建函数后,MAKE_FUNCTION
将新的函数对象推送到堆栈上。然后在偏移量 14 处,STORE_NAME 2
弹出函数对象,并存储为f
引用的函数对象。
现在让我们看看从第 5 行开始的f(x)
的代码对象内部。语句global a
不会转换成字节码中的独立指令。它只是指导编译器将a
视为一个全局变量。所以STORE_GLOBAL 0
将被用来改变它的值。指令
**LOAD_GLOBAL** *namei*
将对由co_names[namei]
引用的对象的引用推送到堆栈上。然后使用STORE_GLOBAL 0
将其存储在b
中。指令
**LOAD_FAST** *var_num*
将引用为co_varnames[var_num]
的对象的引用推送到堆栈上。在函数f
的代码对象中,属性co_varnames
包含:
('x', 'y')
因此LOAD_FAST 0
将x
推到堆栈上。然后1
被推到堆栈上。BINARY_ADD
弹出x
和1
,将它们相加,并将结果推送到堆栈上。指令
**STORE_FAST** *var_num*
弹出堆栈的顶部,并将其存储到一个对象中,该对象的引用存储在co_varnames[var_num]
中。所以STORE_FAST 1
弹出结果并存储在一个引用为y
的对象中。LOAD_FAST
和STORE_FAST
用于函数的局部变量。因此它们不在模块范围内使用。另一方面,LOAD_GLOBAL
和STORE_GLOBAL
用于函数内部访问的全局变量。最后,LOAD_FAST 1
将把y
的值推到栈顶,而RETURN_VALUE
将把它返回给模块函数的调用者。
但是这个函数怎么调用呢?如果你看第 8 行的字节码,首先,LOAD_NAME
2
将引用为f
的函数对象推送到堆栈上。LOAD_CONST 4
将其参数(4
)推送到堆栈上。指令
**CALL_FUNCTION** *argc*
用位置参数调用可调用对象。它的 oparg, argc 表示位置参数的数量。堆栈的顶部包含位置参数,最右边的参数位于顶部。参数下面是要调用的函数可调用对象。
CALL_FUNCTION
首先从堆栈中弹出所有的参数和可调用对象。然后,它将在调用堆栈上分配一个新的框架,为函数调用填充局部变量,并在该框架内执行函数的字节码。一旦完成,框架将弹出调用堆栈,在前面的框架中,函数的返回值将被推到计算堆栈的顶部。如果没有前一个框架,它将被推到全局框架的评估堆栈的顶部。
在我们的例子中,我们只有一个位置参数,所以指令将是CALL_FUNCTION 1
。在那之后,指令
**POP_TOP**
将项目弹出到堆栈顶部。这是因为我们不再需要函数的返回值。图 2 显示了偏移量为 16 到 22 的所有字节码操作。f(x)
中的字节码指令用红色显示。
图 2
内置函数
在清单 2 的反汇编字节码的第 9 行,我们想要print(a)
。print
也是函数,不过是内置的 Python 函数。函数名是对其可调用对象的引用。因此,首先将它推送到堆栈上,然后再推它的参数。最后,它将被称为使用CALL_FUNCTION
。print
会返回None
,之后返回值会弹出堆栈。
Python 使用其内置函数来创建数据结构。例如,下面一行:
a = [1,2,3]
将被转换为:
1 0 LOAD_CONST 0 (1)
2 LOAD_CONST 1 (2)
4 LOAD_CONST 2 (3)
6 BUILD_LIST 3
8 STORE_NAME 0 (a)
最初,列表中的每个元素都被推送到堆栈上。然后是指令
**BUILD_LIST** *count*
使用堆栈中的计数项创建列表,并将结果列表对象推送到堆栈上。最后,栈上的对象将被弹出并存储在堆上,而a
将成为它的引用。
EXTENDED_ARG
如前所述,一些指令的参数太大,无法放入默认的一个字节,它们将被加上前缀指令EXTENDED_ARG
。这里有一个例子。假设我们想要打印 260 个*
字符。我们可以简单地写print('*' * 260)
。然而,我将写一些不寻常的东西来代替:
s= 'print(' + '"*",' * 260 + ')'
c = compile(s, "", "exec")
disassemble(c)
这里的s
包含一个print
函数,它有 260 个参数,每个参数都是一个*
字符。现在看看反汇编后的字节码:
1 0 LOAD_NAME 0 (print)
2 LOAD_CONST 0 ('*')
4 LOAD_CONST 0 ('*')
. .
. .
. . 518 LOAD_CONST 0 ('*')
520 LOAD_CONST 0 ('*')
522 EXTENDED_ARG 1
524 CALL_FUNCTION 260
526 POP_TOP
528 LOAD_CONST 1 (None)
530 RETURN_VALUE
这里print
首先被推到堆栈上。然后推送它的 260 个参数。然后CALL_FUNCTION
应该调用函数。但是它需要(目标函数的)参数个数作为它的 oparg。这里这个数字是 260,比一个字节所能容纳的最大数字还要大。记住 oparg 只有一个字节。所以CALL_FUNCTION
是以EXTENDED_ARG
为前缀的。实际的字节码是:
522 EXTENDED_ARG 1
524 CALL_FUNCTION 4
如前所述,EXTENDED_ARG 的 oparg 将左移 8 位或简单地乘以 256,并添加到下一个操作码的 oparg 中。所以CALL_FUNCTION
的 oparg 将被解释为256+4 = 260
(请注意,disassemble
函数显示的是这个被解释的 oparg,而不是字节码中实际的 oparg)。
条件语句和跳转
考虑下面的源代码,它有一个if-else
语句:
s='''a = 1
if a>=0:
b=a
else:
b=-a
'''
c=compile(s, "", "exec")
disassemble(c)
反汇编的字节码是:
1 0 LOAD_CONST 0 (1)
2 STORE_NAME 0 (a)
2 4 LOAD_NAME 0 (a)
6 LOAD_CONST 1 (0)
8 COMPARE_OP 5 (>=)
10 POP_JUMP_IF_FALSE 18
3 12 LOAD_NAME 0 (a)
14 STORE_NAME 1 (b)
16 JUMP_FORWARD 6 (to 24)
5 >> 18 LOAD_NAME 0 (a)
20 UNARY_NEGATIVE
22 STORE_NAME 1 (b)
>> 24 LOAD_CONST 2 (None)
26 RETURN_VALUE
我们这里有一些新的指示。在第 2 行中,a
引用的对象被推送到堆栈上,然后文字0
被推。指令
**COMPARE_OP** *oparg*
执行布尔运算。操作名称见cmp_op[oparg]
。cmp_op
的值存储在一个名为dis.cmp_op
的列表中。该指令首先弹出堆栈的顶部两个元素。我们把第一个叫做TOS1
,第二个叫做TOS2
。然后对它们执行由 oparg 选择的布尔运算(TOS2 cmp_op[oparg] TOS1)
,结果被推到堆栈的顶部。在本例中为TOS1=0
和TOS2=value of a
。另外, oparg 为5
和cmp_op[5]='≥'
。因此cmp_op
将测试a≥0
并将结果(真或假)存储在堆栈顶部。
指令
**POP_JUMP_IF_FALSE** *target*
执行条件跳转。首先,它弹出栈顶。如果栈顶的元素为 false,它将字节码计数器设置为目标。字节码计数器显示正在执行的当前字节码偏移量。所以它跳转到等于 target 的字节码偏移量,字节码从那里继续执行。字节码中的 offset 18 是一个跳转目标,所以在反汇编的字节码中在它前面有一个>>
。指令
**JUMP_FORWARD** *delta*
将字节码计数器增加增量。在前面的字节码中,这条指令的偏移量是 16,我们知道每条指令占用 2 个字节。所以当这条指令结束时,字节码计数器是16+2=18
。这里是delta=6
和18+6=24
,所以跳到偏移24
。偏移 24 是一个跳转目标,它也有一个>>
符号。
现在我们可以看到if-else
语句是如何被转换成字节码的。cmp_op
检查a≥0
是否。如果结果为假,POP_JUMP_IF_FALSE
跳转到偏移 18,这是else
块的开始。如果为真,将执行if
块,然后JUMP_FORWARD
跳转到偏移量 24,不执行else
块。
现在让我们看一个更复杂的布尔表达式。考虑以下源代码:
s='''a = 1
c = 3
if a>=0 and c==3:
b=a
else:
b=-a
'''
c=compile(s, "", "exec")
disassemble(c)
这里我们有一个逻辑and
。反汇编的字节码是:
1 0 LOAD_CONST 0 (1)
2 STORE_NAME 0 (a)
2 4 LOAD_CONST 1 (3)
6 STORE_NAME 1 (c)
3 8 LOAD_NAME 0 (a)
10 LOAD_CONST 2 (0)
12 COMPARE_OP 5 (>=)
14 POP_JUMP_IF_FALSE 30
16 LOAD_NAME 1 (c)
18 LOAD_CONST 1 (3)
20 COMPARE_OP 2 (==)
22 POP_JUMP_IF_FALSE 30
4 24 LOAD_NAME 0 (a)
26 STORE_NAME 2 (b)
28 JUMP_FORWARD 6 (to 36)
6 >> 30 LOAD_NAME 0 (a)
32 UNARY_NEGATIVE
34 STORE_NAME 2 (b)
>> 36 LOAD_CONST 3 (None)
38 RETURN_VALUE
在 Python 中and
是短路运算符。所以在对X and Y
求值的时候,只有X
为真,它才会对Y
求值。这在字节码中很容易看到。在第 3 行,首先对and
的左操作数求值。如果(a≥0)
为假,则不计算第二个操作数,并跳转到偏移量 30 执行else
块。但是,如果为真,第二个操作数(b==3)
也将被求值。
循环和块堆栈
如前所述,每个框架内都有一个评估堆栈。另外,在每一帧中,都有一个块堆栈。CPython 使用它来跟踪某些类型的控制结构,如循环、with
块和try/except
块。当 CPython 想要进入这些结构中的一个时,一个新的项目被推到块堆栈上,当 CPython 退出该结构时,该结构的项目被弹出块堆栈。使用块堆栈 CPython 知道哪个结构当前是活动的。所以当它到达一个break
或continue
语句时,它知道哪些结构应该受到影响。
让我们看看循环是如何在字节码中实现的。考虑下面的代码及其反汇编的字节码:
s='''for i in range(3):
print(i)
'''
c=compile(s, "", "exec")
disassemble(c)--------------------------------------------------------------------1 0 SETUP_LOOP 24 (to 26)
2 LOAD_NAME 0 (range)
4 LOAD_CONST 0 (3)
6 CALL_FUNCTION 1
8 GET_ITER
>> 10 FOR_ITER 12 (to 24)
12 STORE_NAME 1 (i)
2 14 LOAD_NAME 2 (print)
16 LOAD_NAME 1 (i)
18 CALL_FUNCTION 1
20 POP_TOP
22 JUMP_ABSOLUTE 10
>> 24 POP_BLOCK
>> 26 LOAD_CONST 1 (None)
28 RETURN_VALUE
指令
**SETUP_LOOP** *delta*
在循环开始前执行。此指令将一个新项目(也称为块)推送到块堆栈上。增量加到字节码计数器,确定循环后下一条指令的偏移量。这里SET_LOOP
的偏移量是0
,所以字节码计数器是0+2=2
。另外, delta 为24
,所以循环后下一条指令的偏移量为2+24=26
。这个偏移量存储在推送到块堆栈上的块中。此外,评估堆栈中的当前项目数存储在该块中。
之后,应该执行功能range(3)
。它的 oparg ( 3
)被推到函数名之前。结果是一个 可迭代 。Iterables 可以使用以下指令生成一个 迭代器 :
**GET_ITER**
它将 iterable 放在堆栈的顶部,并推送它的迭代器。说明:
**FOR_ITER** *delta*
假设栈顶有一个迭代器。它调用它的__next__()
方法。如果它产生一个新值,这个值被推到栈顶(迭代器之上)。在循环内部,栈顶存储在其后的i
中,执行print
函数。然后弹出栈顶,即迭代器的当前值。在那之后,指令
**JUMP_ABSOLUTE** *target*
将字节码计数器设置为目标并跳转到目标偏移量。所以它跳到偏移量 10,再次运行FOR_ITER
来获取迭代器的下一个值。如果迭代器指示没有其他元素可用,则弹出栈顶,并且字节码计数器增加增量。这里是*delta*=12
,所以循环结束后,跳转到偏移量 24。在偏移量 24 处,指令
**POP_BLOCK**
从块堆栈顶部移除当前块。循环后下一条指令的偏移量存储在块中(这里是 26)。所以解释器会跳到那个偏移量,并从那里继续执行。图 3 显示了偏移量为 0、10、24 和 26 的字节码操作(事实上,在图 1 和图 2 中,我们只显示了每一帧中的评估堆栈)。
图 3
但是如果我们在这个循环中添加一个break
语句会发生什么呢?考虑下面的源代码及其反汇编的字节码:
s='''for i in range(3):
break
print(i)
'''
c=compile(s, "", "exec")
disassemble(c)--------------------------------------------------------------------1 0 SETUP_LOOP 26 (to 28)
2 LOAD_NAME 0 (range)
4 LOAD_CONST 0 (3)
6 CALL_FUNCTION 1
8 GET_ITER
>> 10 FOR_ITER 14 (to 26)
12 STORE_NAME 1 (i)
2 14 BREAK_LOOP
3 16 LOAD_NAME 2 (print)
18 LOAD_NAME 1 (i)
20 CALL_FUNCTION 1
22 POP_TOP
24 JUMP_ABSOLUTE 10
>> 26 POP_BLOCK
>> 28 LOAD_CONST 1 (None)
30 RETURN_VALUE
我们只在前一个循环中添加了一个break
语句。该语句被转换为
**BREAK_LOOP**
该操作码移除评估堆栈上的那些额外项目,并从块堆栈的顶部弹出该块。您应该注意到,循环的其他指令仍在使用评估堆栈。因此,当循环中断时,属于它的项应该从计算堆栈中弹出。在这个例子中,迭代器对象仍然在栈顶。请记住,在开始循环之前,块堆栈中的块存储评估堆栈中存在的项数。
因此,通过知道这个数字,BREAK_LOOP
从评估堆栈中弹出那些额外的项目。然后跳转到存储在块堆栈的当前块中的偏移量(这里是 28)。这是循环后下一条指令的偏移量。因此循环中断,从那里继续执行。
创建代码对象
code 对象是一个类型为code
的对象,可以动态创建。模块types
可以帮助动态创建新类型,该模块中的类CodeType()
返回一个新的代码对象:
types.CodeType(co_argcount, co_kwonlyargcount,
co_nlocals, co_stacksize, co_flags,
co_code, co_consts, co_names,
co_varnames, co_filename, co_name,
co_firstlineno, co_lnotab, freevars=None,
cellvars=None)
参数构成了代码对象的所有属性。你已经熟悉了其中的一些参数(比如co_varnames
和co_firstlineno
)。freevars
和cellvars
是可选的,因为它们在闭包中使用,并且不是所有的函数都使用它们(关于它们的更多信息,请参考本文)。其他属性以下面的函数为例进行说明:
def f(a, b, *args, c, **kwargs):
d=1
def g():
return 1
g()
return 1
co_argcount
:如果代码对象是一个函数的对象,则是它所采用的参数个数(不包括仅关键字的参数,*
或**
args)。对于功能f
,它是2
。
co_kwonlyargcount
:如果代码对象是函数的代码对象,则为仅关键字参数的数量(不包括**
arg)。对于功能f
,它是1
。
co_nlocals
:局部变量的个数加上代码对象中定义的函数名(自变量也被认为是局部变量)。事实上,co_varnames
中的元素数就是('a', 'b', 'c', 'args', 'kwargs', 'd', 'g')
。所以是f
的7
。
co_stacksize
:显示此代码对象将推入计算堆栈的元素的最大数量。请记住,有些操作码需要将一些元素推送到计算堆栈上。这个属性显示了栈在字节码操作中可以增长到的最大大小。在这个例子中是2
。让我解释一下原因。如果你反汇编这个函数的字节码,你会得到:
2 0 LOAD_CONST 1 (1)
2 STORE_FAST 5 (d)
3 4 LOAD_CONST 2 (<code object g at 0x0000028A62AB1D20, file "<ipython-input-614-cb7dfbcc0072>", line 3>)
6 LOAD_CONST 3 ('f.<locals>.g')
8 MAKE_FUNCTION 0
10 STORE_FAST 6 (g)
5 12 LOAD_FAST 6 (g)
14 CALL_FUNCTION 0
16 POP_TOP
6 18 LOAD_CONST 1 (1)
20 RETURN_VALUE
在第 2 行,使用LOAD_CONST
将一个元素推到堆栈上,并将使用STORE_FAST
弹出。第 5 行和第 6 行类似地将一个元素推到堆栈上,稍后再弹出它。但是在第 3 行中,两个元素被推到堆栈上来定义内部函数g
:它的代码对象和它的名字。这是这个代码对象将被推到计算堆栈上的元素的最大数量,它决定了堆栈的大小。
co_flags
:整数,用位表示函数是否接受可变数量的参数,函数是否是生成器等。在我们的例子中,它的值是79
。79
的二进制值为0b1001111
。它使用一个小端系统,其中字节从左到右按重要性递增的方式写入。所以第一位是右边的第一个。你可以参考这个链接来了解这些位的含义。例如,右数第三位代表CO_VARARGS
标志。当它是1
时,意味着代码对象有一个可变的位置参数(*args
-like)。
co_filename
:字符串,指定函数所在的文件。在这种情况下,这是'<ipython-input-59–960ced5b1120>'
,因为我正在运行 Jupyter 笔记本中的脚本。
co_name
:定义该代码对象的名称。这里是函数的名字'f'
。
字节码注入
现在我们已经完全熟悉了代码对象,我们可以开始改变它的字节码了。需要注意的是,代码对象是不可变的。所以一旦创造了,我们就不能改变它。假设我们想要更改以下函数的字节码:
def f(x, y):
return x + yc = f.__code__
这里我们不能直接改变函数的代码对象的字节码。相反,我们需要创建一个新的代码对象,然后将它分配给这个函数。为此,我们需要更多的函数。disassemble
函数可以将字节码分解成一些对人类友好的指令。我们可以随心所欲地修改它们,但是我们需要将它们组装回字节码,以将其分配给新的代码对象。disassemble
的输出是一个格式化的字符串,易于阅读,但难以更改。所以我将添加一个新函数,它可以将字节码分解成一系列指令。它与disassemble
非常相似,但是,它的输出是一个列表。
我们可以在之前的函数上尝试一下:
disassembled_bytecode = disassemble_to_list(c)
现在disassembled_bytecode
等于:
[['LOAD_FAST', 'x'],
['LOAD_FAST', 'y'],
['BINARY_ADD'],
['RETURN_VALUE']]
我们现在可以很容易地更改该列表的说明。但是我们还需要将它组装回字节码:
函数get_oparg
类似于get_argvalue
的逆函数。它接受一个 argvalue,这是 oparg 的友好含义,并返回相应的 oparg。它需要 code 对象作为它的参数,因为 code 对象的属性如co_consts
是将 argvalue 转换成 oparg 所必需的。
函数assemble
获取一个代码对象和一个反汇编的字节码列表,并将其组装回字节码。它使用dis.opname
将 opname 转换成操作码。然后它调用get_oparg
将 argvalue 转换成 oparg。最后,它返回字节码列表的字节文字。我们现在可以使用这些新函数来改变前面函数f
的字节码。首先,我们改变disassembled_bytecode
中的一条指令:
disassembled_bytecode[2] = ['BINARY_MULTIPLY']
指令
**BINARY_MULTIPLY**
弹出堆栈顶部的两个元素,将它们相乘,并将结果推送到堆栈上。现在我们组装修改后的反汇编字节码:
new_co_code= assemble(disassembled_bytecode, c.co_consts,
c.co_varnames, c.co_names,
c.co_cellvars+c.co_freevars)
之后,我们创建一个新的代码对象:
import types
nc = types.CodeType(c.co_argcount, c.co_kwonlyargcount,
c.co_nlocals, c.co_stacksize, c.co_flags,
new_co_code, c.co_consts, c.co_names,
c.co_varnames, c.co_filename, c.co_name,
c.co_firstlineno, c.co_lnotab,
c.co_freevars, c.co_cellvars)
f.__code__ = nc
我们使用f
的所有属性来创建它,只替换新的字节码(new_co_code
)。然后我们将新的代码对象分配给f
。现在,如果我们再次运行f
,它不会将其参数加在一起。相反,它会将它们相乘:
f(2,5) # Output is 10 not 7
注意:types.CodeType
函数有两个可选参数freevars
和 cellvars
,但是使用时要小心。如前所述,代码对象的co_cellvars
和co_freevars
属性仅在代码对象属于具有自由变量或非局部变量的函数时使用。所以函数应该是一个闭包,或者闭包应该已经在函数内部定义了。例如,考虑以下函数:
def func(x):
def g(y):
return x + y
return g
现在如果检查它的代码对象:
c = func.__code__
c.co_cellvars # Output is: ('x',)
事实上,这个函数有一个非局部变量x
,因为这个变量是由其内部函数访问的。现在我们可以尝试使用相同的属性重新创建它的代码对象:
nc = types.CodeType(c.co_argcount, c.co_kwonlyargcount,
c.co_nlocals, c.co_stacksize, c.co_flags,
new_co_code, c.co_consts, c.co_names,
c.co_varnames, c.co_filename, c.co_name,
c.co_firstlineno, c.co_lnotab,
cellvars = c.co_cellvars,
freevars = c.co_freevars)
但是如果我们检查新代码对象的相同属性
nc.co_cellvars Output is: ()
结果是空的。所以types.CodeType
不能创建相同的代码对象。如果你试图将这个代码对象赋给一个函数并执行那个函数,你会得到一个错误(这个已经在 Python 3.7.4 上测试过了)。
代码优化
理解字节码指令可以帮助我们优化源代码。考虑以下源代码:
setup1='''import math
mult = 2
def f():
total = 0
i = 1
for i in range(1, 200):
total += mult * math.log(i)
return total
'''setup2='''import math
def f():
log = math.log
mult = 2
total = 0
for i in range(1, 200):
total += mult * log(i)
return total
'''
这里我们定义一个函数f()
来计算一个简单的数学表达式。它有两种不同的定义。在setup1
中,我们在f()
中使用全局变量mult
,并直接使用math
模块中的log()
函数。在setup2
中,mult
是f()
的局部变量。另外,math.log
首先存储在本地变量log
中。现在我们可以比较这些函数的性能:
t1 = timeit.timeit(stmt="f()", setup=setup1, number=100000)
t2 = timeit.timeit(stmt="f()", setup=setup2, number=100000)
print("t1=", t1)
print("t2=", t2)
--------------------------------------------------------------------
t1= 3.8076129000110086
t2= 3.2230119000014383
你可能会得到不同的t1
和t2
的数字,但底线是setup2
比setup1
快。现在让我们比较一下它们的字节码,看看为什么它更快。我们只看setup1
和setup2
的反汇编代码中的第 7 行。这是这一行的字节码:total += mult * log(i)
。
在setup1
中,我们有:
7 24 LOAD_FAST 0 (total)
26 LOAD_GLOBAL 1 (mult)
28 LOAD_GLOBAL 2 (math)
30 LOAD_METHOD 3 (log)
32 LOAD_FAST 1 (i)
34 CALL_METHOD 1
36 BINARY_MULTIPLY
38 INPLACE_ADD
40 STORE_FAST 0 (total)
42 JUMP_ABSOLUTE 20
>> 44 POP_BLOCK
但是在setup2
中我们得到:
7 30 LOAD_FAST 2 (total)
32 LOAD_FAST 1 (mult)
34 LOAD_FAST 0 (log)
36 LOAD_FAST 3 (i)
38 CALL_FUNCTION 1
40 BINARY_MULTIPLY
42 INPLACE_ADD
44 STORE_FAST 2 (total)
46 JUMP_ABSOLUTE 26
>> 48 POP_BLOCK
如您在setup1
中所见,mult
和math
均使用LOAG_GLOBAL
加载,但在setup2
中,mult
和log
使用LOAD_FAST
加载。因此两个LOAD_GLOBAL
指令被替换为LOAD_FAST
。事实是LOAD_FAST
顾名思义比LOAD_GLOBAL
快得多。我们提到的全局和局部变量的名称都存储在co_names
和co_varnames
中。但是在执行编译后的代码时,CPython 解释器是如何找到值的呢?
局部变量存储在每一帧的数组中(为了简单起见,前面的图中没有显示)。我们知道局部变量的名字存储在co_varnames
中。它们的值将以相同的顺序存储在该数组中。因此,当解释器看到类似于LOAD_FAST 1 (mult)
的指令时,它读取索引1
处的数组元素。
模块的全局和内置存储在一个字典中。我们知道他们的名字存储在co_names
中。因此,当解释器看到类似于LOAD_GLOBAL 1 (mult)
的指令时,它首先从co_names[1]
获得全局变量的名称。然后,它将在字典中查找这个名称以获得它的值。与简单的局部变量数组查找相比,这是一个非常慢的过程。因此,LOAD_FAST
比LOAD_GLOBAL
快,用LOAD_FAST
代替LOAD_GLOBAL
可以提高性能。这可以通过简单地将内置变量和全局变量存储到局部变量中或者直接改变字节码指令来实现。
示例:在 Python 中定义常数
这个例子说明了如何使用字节码注入来改变函数的行为。我们将编写一个装饰器,为 Python 添加一个 const 语句。在一些编程语言如 C、C++和 JavaScript 中,有一个 const 关键字。如果使用这个关键字将一个变量声明为 const,那么改变它的值是非法的,我们不能再在源代码中改变这个变量的值了。
Python 没有 const 语句,我也没有说 Python 中真的有必要有这样的关键字。此外,定义常量也可以在不使用字节码注入的情况下完成。所以这只是一个展示如何将字节码注入付诸实施的例子。首先,让我展示一下如何使用它。const 关键字是使用名为const
的函数装饰器提供的。一旦用const
修饰了一个函数,就可以用关键字const.
将函数内部的变量声明为常量(最后的.
是关键字的一部分)。这里有一个例子:
@const
def f(x):
const. A=5
return A*x
f(2) # Output is: 10
f
中的变量A
现在是一个常量。现在,如果您尝试在f
中重新分配这个变量,将会引发一个异常:
@const
def f(x):
const. A=5
A = A + 1
return A*x
--------------------------------------------------------------------# This raises an exception :
**ConstError**: 'A' is a constant and cannot be reassigned!
当变量被声明为 const 时。,应该赋给它的初始值,它将是那个函数的局部变量。
现在让我向你展示它是如何实现的。假设我这样定义一个函数(没有修饰):
def f(x):
const. A=5
A = A + 1
return A*x
它将被适当地编译。但是如果您尝试执行这个函数,您会得到一个错误:
f(2)
--------------------------------------------------------------------**NameError**: name 'const' is not defined
现在让我们来看看这个函数反汇编后的字节码:
2 0 LOAD_CONST 1 (5)
2 LOAD_GLOBAL 0 (const)
4 STORE_ATTR 1 (A)
3 6 LOAD_FAST 1 (A)
8 LOAD_CONST 2 (1)
10 BINARY_ADD
12 STORE_FAST 1 (A)
4 14 LOAD_FAST 1 (A)
16 LOAD_FAST 0 (x)
18 BINARY_MULTIPLY
20 RETURN_VALUE
当 Python 试图编译函数时,它将const
作为一个全局变量,因为它没有在函数中定义。变量A
被认为是全局变量A
的一个属性。事实上,const. A=1
和const.A=1
是一样的,因为 Python 忽略了点运算符和属性名之间的空格。当然,我们在源代码中确实没有名为A
的全局变量。但是 Python 不会在编译时检查它。只有在执行过程中,才会发现名称const
没有被定义。所以我们的源代码在编译时会被接受。但是我们需要在执行这个函数的代码对象之前改变它的字节码。我们首先需要创建一个函数来更改字节码:
这个函数接收由assemble_to_list
生成的字节码指令列表作为它的参数。它有两个名为constants
和indices
的列表,分别存储声明为 const 的变量的名称和它们第一次被赋值的偏移量。第一个循环搜索字节码指令列表,找到所有的['LOAD_GLOBAL', 'const']
指令。变量名应该在下一条指令中。在本例中,下一条指令是['STORE_ATTR', 'A']
,名称是A
。该指令的名称和偏移量存储在constants
和indices
中。现在我们需要去掉全局变量const
及其属性,并创建一个名为A
的局部变量。指令
**NOP**
是“什么都不做”的代码。当解释器到达NOP
时,它将忽略它。我们不能简单地从指令列表中删除操作码,因为删除一条指令会减少后面所有指令的偏移量。现在,如果字节码中有一些跳转,它们的目标偏移量也应该改变。所以简单地用NOP
替换不需要的指令要容易得多。现在我们用NOP
代替['LOAD_GLOBAL', 'const']
,然后用['STORE_FAST', 'A']
代替['STORE_ATTR', 'A']
。最终的字节码如下所示:
2 0 LOAD_CONST 1 (5)
2 NOP
4 STORE_FAST 1 (A)
3 6 LOAD_FAST 1 (A)
8 LOAD_CONST 2 (1)
10 BINARY_ADD
12 STORE_FAST 1 (A)
4 14 LOAD_FAST 1 (A)
16 LOAD_FAST 0 (x)
18 BINARY_MULTIPLY
20 RETURN_VALUE
现在第 2 行相当于源代码中的a=2
,执行这个字节码不会导致任何运行时错误。该循环还检查同一个变量是否被声明为 const 两次。因此,如果声明为 const 的变量已经存在于constants
列表中,它将引发一个自定义异常。现在唯一剩下的事情是确保常量变量没有被重新分配。
第二个循环再次搜索字节码指令列表,寻找常量变量的重新赋值。任何像['STORE_GLOBAL', 'A']
或['STORE_FAST', 'A']
这样的指令都意味着重分配在源代码中,所以它会引发一个定制的异常来警告用户。需要常量初始赋值的偏移量,以确保初始赋值不被视为再赋值。
如前所述,字节码应该在执行代码之前更改。所以需要在调用函数f
之前调用函数add_const
。为此,我们将它放在一个装饰器中。装饰函数const
接收目标函数f
作为它的参数。它将首先使用add_const
更改f
的字节码,然后用修改后的字节码创建一个新的代码对象。该代码对象将被分配给f
。
当我们创建新的代码对象时,需要修改它的一些属性。在原始函数中,const
是一个全局变量,A
是一个属性,所以它们都被添加到了co_names
元组中,它们应该从新代码对象的co_names
中移除。另外,当一个像A
这样的属性变成局部变量时,它的名字要加到co_varnames
元组中。属性co_nlocals
给出了局部变量(加上定义的函数)的数量,也应该被更新。其他属性保持不变。装饰器最终返回带有新代码对象的目标函数,现在目标函数已经准备好执行了。
理解 Python 的字节码可以让你熟悉 Python 编译器和虚拟机的底层实现。如果您知道源代码是如何转换成字节码的,那么您就可以在编写和优化代码方面做出更好的决策。字节码注入也是代码优化和元编程的有用工具。在本文中,我只涉及了少量的字节码指令。可以参考[dis](https://docs.python.org/3/library/dis.html)
模块的网页查看 Python 的字节码指令完整列表。我希望你喜欢阅读这篇文章。本文的所有代码清单都可以作为 Jupyter 笔记本下载,网址:https://github . com/Reza-bag heri/Understanding-Python-Bytecode
理解 Python 字典
Python 词典简介
在 python 中,字典是一种包含无序的键/值对集合的数据结构。在这篇文章中,我们将讨论如何在 python 中定义和使用字典。
我们开始吧!
python 中的字典是用花括号“{}”定义的。我们将从定义一个包含两个键的字典开始。第一个键称为“News ”,它将映射到新闻标题列表。第二个键将被称为“点击”,它将映射到一个包含文章被点击次数的列表:
news_dict = {"News":["Selling Toilet Paper During the Pandemic", "How to Reopen Gyms?", "Covid-19 Risk Based on Blood Type?"] , "Clicks":[100, 500, 10000] }
如果我们印刷字典,我们有:
print(news_dict)
现在我们已经定义了字典,让我们看看字典对象可用的方法:
print(dir(news_dict))
我们将讨论方法“clear()”、“copy()”、“get()”、“items()”、“pop()”、“update()”和“values()”。
第一种方法“clear()”非常简单。它只是清除字典:
news_dict.clear()
print("Cleared dictionary: ", news_dict)
接下来我们有“copy()”方法。此方法返回字典的浅层副本:
copy_dict = news_dict.copy()
这允许我们修改副本,同时保持原始字典不变:
copy_dict['Clicks'] = [100, 100, 100]
print(news_dict)
print(copy_dict)
“get()”方法将一个键作为输入,并返回该键的值:
print(news_dict.get("News"))
我们可以对“点击”键进行同样的操作:
print(news_dict.get("Clicks"))
我们还可以使用以下方法获得相同的结果:
print(news_dict['News'])
print(news_dict['Clicks'])
“items()”方法返回一组键/值对。我们可以在 for 循环中迭代键值对:
for key, value in news_dict.items():
print(key, value)
我们还可以使用 key 方法来获取键的名称:
print(new_dict.keys())
接下来,“pop()”方法允许我们删除键及其值:
news_dict.pop('Clicks')
print(news_dict)
此外,“update()”方法允许我们用新值覆盖字典中的键和值:
news_dict.update(News = "New York City Begins to Reopen", Clicks = 30000)
print(news_dict)
最后,“values()”方法返回字典值的新视图:
print(news_dict.values())
我就说到这里,但是您可以自己随意使用这些字典方法。
结论
总之,在这篇文章中,我们讨论了如何在 python 中定义和使用字典。我们讨论了如何清除值、访问键和值、更新字典以及复制字典。我希望你觉得这篇文章有用/有趣。这篇文章的代码可以在 GitHub 上找到。感谢您的阅读!
理解 Python 生成器
Python 中的生成器简介
生成器函数是 python 中的函数,它提供了一种执行迭代的简单方法。这很有用,因为处理列表要求我们将每个值存储在内存中,这对于大量输入来说是不实际的。此外,与生成器的简单实现相比,从头构建迭代器需要大量代码。例如,在构建迭代器类时,需要定义 dunder 方法“iter()”和“next()”,跟踪内部状态,并在没有其他值要返回时引发“StopIteration”。为了演示生成器函数的强大功能,我们将比较一个用于生成从 0 到 n 的正整数的函数的不同实现。
我们开始吧!
首先,让我们定义一个函数,它接受一个整数作为输入,并返回一个小于或等于输入的正整数列表:
def generate_list(input_value):
number, numbers = 0, []
while number <= input_value:
numbers.append(number)
number += 2
return numbers
现在,让我们用我们的函数定义一个从 0 到 n = 10 的正整数列表:
values = generate_list(10)
我们可以使用内置的 sum 方法对结果列表进行求和:
print("Sum of list: ", sum(values))
这里的问题是完整的列表是在内存中构建的。当您处理大量数据时,这就成了问题。我们可以使用生成器模式来修正这个问题。
接下来,让我们将生成器实现为迭代器对象。我们的类将需要 dunder 方法“init()”、“iter()”和“next()”。当没有额外的值要返回时,我们还需要引发“StopIteration”。让我们首先定义我们的“init()”方法:
class iterator_object(object):
def __init__(self, input_value):
self.input_value = input_value
self.numbers = 0
在我们的“init()”方法中,我们初始化了类属性。接下来,让我们定义 dunder 方法’ iter()':
class iterator_object(object):
...
def __iter__(self):
return self
现在,让我们添加我们的“next()”方法:
class iterator_object(object):
...
def __next__(self):
return self.next()
最后,我们可以定义类方法“next()”:
class iterator_object(object):
...
def next(self):
if self.number <= self.input_value:
current, self.number = self.number, self.number + 2
return current
else:
raise StopIteration()
现在,我们可以定义一个输入值为 10 的类实例,并打印出结果迭代器对象的总和:
value = iterator_object(10)
print("Sum using an Iterator Object: ", sum(value))
这是我们所期望的结果。注意,为了将我们的生成器实现为迭代器对象,我们需要编写相当多的代码:
class iterator_object(object):
def __init__(self, input_value):
self.input_value = input_value
self.number = 0
def __iter__(self):
return self
def __next__(self):
return self.next()
def next(self):
if self.number <= self.input_value:
current, self.number = self.number, self.number + 2
return current
else:
raise StopIteration()
幸运的是,python 提供了‘yield’关键字,当使用时,它提供了构建迭代器的捷径。我们可以用 yield 来定义同样的函数:
def generator_function(input_value):
number = 0
while number <= input_value:
yield number
number += 2
让我们调用输入值为 10 的生成器,并打印结果的总和:
value = generator_function(10)
print("Sum using a Generator: ", sum(value))
正如我们所看到的,我们用比迭代器对象实现少得多的代码实现了相同的结果,同时保留了迭代器的优点。我就讲到这里,但是您可以自己随意摆弄代码。
结论
总之,在这篇文章中,我们讨论了 python 中的生成器函数。我们概述了同一个函数的三个实现,以展示生成器函数的强大功能。我们讨论了在列表上执行一些操作对于大量输入来说是如何成为问题的。我们通过构建一个生成器函数的迭代器对象实现解决了这个问题。这个解决方案需要大量代码。最后,我们讨论了使用 yield 语句来定义生成器函数,这为构建迭代器提供了一条捷径。我希望你对这篇文章感兴趣/有用。这篇文章中的代码可以在 GitHub 上找到。感谢您的阅读!
通过模拟了解 Python 多线程和多重处理
边做边学:简单的模拟有助于更好地理解复杂的想法,如多线程和多重处理。
Python 是一种优秀的通用语言,在各个领域都有应用。然而,有时候你只是希望它能进一步加速。提高速度的一个方法是用多线程或多重处理来并行工作。
有许多很好的资源可以解释这两者的概念。为了避免重复劳动,这里有一些我认为非常有用的方法。
- https://stack overflow . com/questions/18114285/线程与多处理模块的区别是什么
- https://medium . com/content square-engineering-blog/multi threading-vs-multi processing-in-python-ECE 023 ad 55 a
- https://www . geeks forgeeks . org/difference-between-multi threading-vs-multi processing-in-python/
- https://docs.python.org/3/library/concurrent.futures.html
在本文中,我想为那些想进一步探索概念并在自己的笔记本电脑上进行测试的人提供一个简单的模拟。所以我们开始吧!
模拟设置
概括地说,我通过以下步骤创建了一个模拟:
- 创建两个函数来模拟 IO 密集型和 CPU 密集型任务
- 使用“ concurrent.futures ”模块创建一个“中枢”函数,在不同的并行化设置下模拟执行一个任务 100 次
- 使用 Python 的“timeit”函数执行“hub”函数 5 次,以获得给定并行化设置下的平均花费时间
下面是包含设置细节的代码块。
性能比较
我在本地笔记本电脑上的测试结果在这里分享。请注意,在不同的硬件环境下,确切的模拟数字会有所不同,但原则应该始终适用。
我在下面的图表中显示了模拟结果:x 轴是工作人员的数量(例如线程号或进程号),y 轴是完成计算所花费的时间。四种不同的颜色标记了不同的设置:CPU 繁重任务的多处理(蓝色),CPU 繁重任务的多线程(红色),IO 繁重任务的多处理(黄色),IO 繁重任务的多线程(绿色)。
从图表中可以很容易地看出一些发现:
- “多线程”和“多处理”在 IO 繁重任务中同样有效。随着工作人员的增加,总任务花费的时间从大约 10 秒(1 个工作人员)减少到 1.3 秒(8 个工作人员),这意味着速度提高了大约 8 倍。
- **“多线程”在 CPU 繁重的任务上表现不佳。**红色条形图显示,无论使用多少“线程”,总花费时间始终在 10 秒左右。
- **“多处理”在 CPU 繁重的任务上是有效的,然而,它在硬件限制下达到了一个平台。**在我的例子中,当 worker #大于等于 5 时,它的最大加速仍然是 5X (~2 秒),小于他们的实际 worker #(例如 6、7、8)。这是因为我的笔记本电脑有 6 个内核,鉴于系统需要 1 个内核来维持其功能,其余 5 个内核可用于计算,因此最大速度提升是 5X。
看一下硬件概述,您会发现我的笔记本电脑的 6 核特性以及启用的“超线程技术”,因此线程数不限于处理器数 6。
我的笔记本电脑硬件概述
希望你喜欢这篇短文,并自己练习。更重要的是,您现在应该对 Python 中多线程和多处理的区别有了更好的理解。
边做边学既有趣又有效。尽情享受吧!
— — — — — — — — — — — — — —
如果你喜欢这篇文章,通过点赞、分享和评论来帮助传播。潘目前是 LinkedIn 的数据科学经理。可以看之前的文章,关注他上LinkedIn。
使用张量流量子了解量子 ML
通过探索 TensorFlow 量子示例开始您的量子机器学习之旅
上图 : Forest Stearns(谷歌人工智能量子艺术家常驻)展示了安装在低温恒温器上的 Sycamore 量子处理器。图片来源:使用可编程超导处理器的量子优势。底部 : TensorFlow 量子标志,来源
这篇文章是为量子机器学习的初学者准备的,这是当前经典机器学习思想的必然未来。本文的重点是介绍结合机器学习和量子计算有利于解决特定领域问题的领域。文章的结构是,首先描述与问题相关的量子计算概念,然后描述谷歌人工智能量子团队对问题的解决方案。此外,它遵循了谷歌人工智能量子团队最近与滑铁卢大学、Alphabet 的 X 和大众汽车合作发表的关于使用 Tensorflow Quantum 进行量子机器学习的工作。
在进入一些问题之前,有责任介绍一下 Tensorflow Quantum ,这是一个开源库,用于对量子数据进行计算,以及创建混合量子经典学习模型。与 Tensorflow 量子库相关的白皮书可访问了解更多技术细节。
量子计算所必需的量子电路被定义为一个模型,其中一个计算是一系列量子门。TensorFlow Quantum 使用 Cirq 设计这些量子电路的逻辑。在继续之前,我建议您浏览一下 TensorFlow Quantum 和 Cirq 的 API,或者将这些站点加入书签,以便在需要理解时参考。
量子数据的二元分类
为此,我们设计了一个混合量子经典神经网络,用于在量子数据源之间进行分类。量子数据从布洛赫球生成。在 Bloch 球体中,我们首先选择 XZ 平面中的两条射线,然后选择随机分布在这些射线周围的点。因此,任务是分离由于选择上述光线而产生的两个斑点。下面是对应两个不同角度的图像:θ和ϕ.这两个角度都位于 XZ 平面内,并相对于正 X 轴进行测量。在下图中,θ对应于蓝色光线与 XZ 平面中的正 x 轴所成的角度,而ϕ以类似的方式对应于橙色光线。
θ= 3&φ= 0
θ= 2&φ= 7
总之,我们可以在布洛赫球中利用 X 旋转和 Z 旋转来制造任何量子态。
针对上述分类任务,设计了一种混合量子经典神经网络模型。典型的混合量子-经典神经网络的示意图如下所示。
“对用于 TensorFlow Quantum 中量子数据的混合量子经典判别模型的推理和训练的端到端管道中涉及的计算步骤的高级抽象概述。”— 图片鸣谢: TensorFlow Quantum 中的图 5:量子机器学习的软件框架 arXiv:2003.02989,2020 。
首先,生成一个量子数据,在我们的例子中是布洛赫球表面上的点,然后使用 Cirq 的量子门创建一个量子电路,以φ作为它们各自的参数,之后从 Tensorflow 量子库中选择一个层来对量子模型本身中的量子位执行计算。一旦量子计算完成,通过采样或测量从量子位获得的值的平均值来获得层输出的期望。这种测量行为去除了量子位的量子本质,产生了一个经典变量。这个经典变量然后服从经典神经网络模型,最终输出预测概率。在下面的图中,我们给出了混合神经网络的训练损失图,该混合神经网络被训练来对布洛赫球表面上两条射线周围的点进行分类。在正在进行的训练期间分类误差的下降和在训练结束时 0.00085 的最终损失表明所提出的混合神经网络在分类量子数据方面是成功的。
混合量子经典神经网络相对于用于对θ= 2&φ= 7的布洛赫球表面上的点进行分类的训练时期的分类交叉熵损失
量子图递归神经网络
在本节中,我们旨在使用 QGRNN 学习给定伊辛模型的目标哈密顿量的动力学。这里使用的横场伊辛模型是热力学和磁学中使用的经典伊辛模型的量子再现。伊辛模型的目标哈密顿量如下:
**伊辛量子模型的目标哈密顿量。**图片鸣谢:TensorFlow Quantum:量子机器学习的软件框架 arXiv:2003.02989,2020 。
量子计算中的伊辛模型基本上是一个格子-格子结构,量子位的自旋用 Z 表示,如上图所示。 J 是耦合系数,表示量子位的自旋对之间的相互作用强度,而 B 是量子位拥有特定自旋的偏置系数。添加术语 X 是为了传达任何外部场对特定量子位相对于 Ising 模型主要的最近邻自旋对相互作用的影响。除了伊辛模型的可变耦合强度之外,自旋之间的相互作用有利于模拟几个机器学习模型,如 Hopfield networks 和Boltzmann machines(Schuld&Petruccione(2018))。此外,伊辛模型密切模拟计算问题子类的基本数学,称为二次无约束二元优化(曲波)问题。
在介绍了伊辛模型及其哈密顿量之后,我们在下面介绍实现上述同化伊辛模型动力学的目标的步骤。
用 Ising 模型上的变分量子本征解算器制备量子数据
具有棕色到蓝绿色色图的图中的节点描绘了具有特定自旋的量子位的偏置系数 B,而具有红色到蓝色色图的图中的边示出了两个最近的相邻量子位之间的耦合系数 J。彩色条显示了 B 和 J 系数对目标 Ising 模型动力学的影响。
现在,我们将构建一个变分量子本征解算器 (VQE),寻找一个靠近基态的低能态,使其可以作为伊辛模型& QGRNN 的初态。变分法提供了一种找到近似基态能量状态的方法。下面是通过训练 VQE 获得的低能量状态以及同样的训练损失图。
训练 VQE 建议的低能状态及其训练损失图
构造伊辛模型和 QGRNN
我们需要取哈密顿量的指数来构造伊辛模型。然而,一般来说,两个不可交换的可观测量的幂不等于它们的和的幂。为了解决这个问题,我们选择铃木-特罗特展开式,因为它可以得出易于计算的近似解。
现在我们需要初始化一个 QGRNN 模型。由于 QGRNN 不知道目标哈密顿量,我们在 QGRNN 内部初始化了一个随机图。
在 QGRNN 内部初始化随机图
使用互换测试构建保真度
在这一步,我们已经从伊辛模型的真实哈密顿量生成了量子数据,以及从 QGRNN 预测的量子数据。众所周知,对量子比特的测量导致对其量子信息的拆解;因此,我们用交换测试实现保真度,用于比较真实和预测的量子态。该交换测试用于计算用于训练 QGRNN 的平均不忠损失函数。
QGRNN 的培训和结果
从上面的练习可以得出结论,在这种情况下,QGRNN 从其量子数据中学习给定目标哈密顿量的时间演化动力学,类似于 Ising 模型。
变分量子热化器
在本节中,我们将利用 TensorFlow Quantum 探索量子计算和经典的基于能量的模型的结合。在这里,我们将研究 2D 海森堡模型,并应用变分量子热化器(VQT)来生成模型的近似热态。VQT 在随后的白皮书中被介绍。
目标 2D 海森堡模型的 密度矩阵 **。**类似 TensorFlow Quantum 中图 24 的图像:量子机器学习的软件框架 arXiv:2003.02989,2020 。
为了跟踪我们基于能量的学习模型的性能,我们使用了一个叫做保真度的易处理的量。
基于能量的学习模型
基于物理学和指数族的概念,我们将使用基于能量的玻尔兹曼机来学习 2D·海登伯格模型的动力学。玻尔兹曼机经典模型可以通过用泡利 z 算符替换每个比特,并将自旋映射到量子比特,即 1 to|0⟩和-1 |1⟩.,而快速转换成量子力学伊辛模型
当一个 ansatz 关于量子位之间的零相关性被建立时,玻尔兹曼机器被简化为具有简化特征的量子位集合上的独立伯努利分布的乘积。因此,我们将首先在 VQT 实施伯努利循证医学。我们使用 VQT 白皮书中提到的经典 VQT 损失函数。
VQT 通过将伯努利 EBM 转换成伊辛模型得出结果
历元 1 估计密度矩阵,损失:3.1398983,最佳状态保真度:0.0356728676646754
历元 20 估计密度矩阵,损失:-1.3389935,最佳状态保真度:0.16234265428802852
历元 40 估计密度矩阵,损失:-10.262356,最佳状态保真度:0.59447319578201
历元 60 估计密度矩阵,损失:-15.053259,最佳状态保真度:0.8891700468979
历元 80 估计密度矩阵,损失:-15.950613,最佳状态保真度:0.92273758099982
Epoch 100 估计密度矩阵,损耗:-16.35294,最佳状态保真度:0.946553737877106
VQT (Bernoulli EBM)培训损失
VQT (Bernoulli EBM)保真度与最优状态
VQT 通过将玻尔兹曼机 EBM 转换成伊辛模型得出结果
历元 1 估计密度矩阵,损失:-2.9771433,最佳状态保真度:0.053690879554551
历元 20 估计密度矩阵,损失:-13.478342,最佳状态保真度:0.664609698510499
历元 40 估计密度矩阵,损失:-16.392994,最佳状态保真度:0.9318277209146
历元 60 估计密度矩阵,损失:-17.340147,最佳状态保真度:0.9984435197535
历元 80 估计密度矩阵,损失:-17.391596,最佳状态保真度:0.9940862273719
历元 100 估计密度矩阵,损耗:-17.400259,最佳状态保真度:0.999360266561525
VQT (Boltzmann machine EBM)培训流失
VQT(玻尔兹曼机 EBM)保真度与最优状态
毫无疑问。从结果可以推断,基于能量的方法在学习 2D 海森堡模型的哈密顿量的动力学方面是成功的。参考受限玻尔兹曼机器 (RBM)对 MNIST 的分类,进一步阅读基于能源的模型。
在概述了三个例子以及对它们的量子计算和机器学习概念的理解之后,不要不知所措是非常必要的。我向初学者介绍量子机器学习的目标,除了将量子计算应用于各种特定领域的问题,只有作为读者的你坚持不懈并不断更新关于量子机器学习的知识,才会成功。下面我提出一个有趣的方法,用量子机器学习来解决组合优化问题。
进一步阅读
量子近似优化算法 (QAOA)
PennyLane : 一个跨平台的 Python 库,用于量子机器学习、自动微分和混合量子经典计算的优化。
Strawberry Fields : 一个用于设计、优化和利用光子量子计算机的全栈 Python 库。
理解破解验证码的人工智能模型
皇家护理学院
对递归皮层网络结构的深入探究
计算机视觉是人工智能[1]研究最多的主题之一,然而目前应对其挑战的解决方案 ConvNets 最近因容易被愚弄而受到批评。举几个例子,这些网络可能会以很高的可信度输出错误的类别预测:缺少 ConvNets 依赖的统计线索的自然发生的图像[2],它们正确分类但改变了单个像素的图像[3],或者在场景中添加了不应改变预测类别的物理对象的图像[4]。因此,如果我们想建造真正智能的机器,至少应该努力探索新的想法。
其中一个相当新的想法是 Vicarious 的递归皮层网络(RCNs ),它从神经科学中获得灵感。这种模式声称以极高的数据效率破解了基于文本的验证码[5],并吸引了一些围绕it的评论和争论。然而,我还没有看到一篇文章彻底解释这个有趣的新模型。因此,我决定写两篇文章,每一篇都解释这个模型的一个特定方面。在本文中,我们将讨论它的结构以及它如何生成图像,如 RCN 主要论文的补充材料所述[5]。
本文假设我们对 ConvNets 有基本的了解,因为我们对它进行了大量的类比。
为了让你的大脑为 RCN 的细节做好准备,你需要理解 RCN 是基于将形状(物体的草图)与外观(其纹理)分开的神经启发的想法,并且它是一个生成模型,而不是一个鉴别模型,所以我们可以像 GANs 一样从中生成/采样图像。此外,它是一个并行的层次结构,就像 ConvNets 一样,它首先在第一层生成目标对象的形状,然后在最后一层添加外观。然而,与 ConvNets 相反,它依赖于来自图形模型的大量文献,而不是依赖于加权和以及梯度下降。现在,让我们深入研究 RCN 结构的细节。
要素图层
RCN 中的第一类图层称为要素图层。我们将逐步理解该模型,因此,现在让我们假设该模型的整个层次结构由这种类型的层组成,这种类型的层只是堆叠在彼此的顶部,从顶层的抽象概念到更详细的概念,随着我们越来越靠近底层,如图 1 所示。这种类型的图层由覆盖 2D 空间的多个节点组成,就像 ConvNets 中的要素地图一样。
图 1:多个要素层堆叠在一起,节点跨越 2D 空间。从第 4 层移动到第 1 层是从抽象概念移动到更详细的底层概念。
每个节点由多个通道组成,每个通道代表一个独特的功能。通道是二进制变量,可为真或假,表示对应于该通道的特征是否存在于最终生成的影像中的节点(x,y)坐标处。在任何特定层,所有节点都有相同的信道列表。
让我们以一个中间层为例,让我们对其通道和上层提出一些假设,以便于解释。该层的通道列表将表示一条双曲线、一个圆和一条抛物线。在生成图像的某一次运行中,上面各层的计算需要在(x,y)坐标(1,1)处画一个圆。因此,( 1,1)处的节点将对应于特征“圆形”的通道设置为真。这将直接影响它下面的层中的一些节点;它将触发与(1,1)的邻域中的圆相关联的较低级特征被设置为真。仅作为示例,那些较低级别的特征可以是具有不同取向的四个弯曲段。当这些较低的功能被触发时,它们还会触发甚至更低的层中的通道,直到我们到达生成图像的最终层。我们可以想象这种触发操作,如图 2 所示。
你可能想知道圆怎么知道它需要 4 个曲线段来表示自己?RCN 如何知道它需要一个通道来表示一个圆?通道及其与其他层的连线将在 RCNs 的学习阶段建立。
图 2:要素层中的信息流。特征节点是包含表示通道的圆盘的胶囊。为了简明起见,一些上层和下层被表示为长方体,但是它们由作为中间层的特征节点组成。触发的连接和通道正在发光。请注意,顶部中间层由 3 个通道组成,第二层由 4 个通道组成。
您可能想要反对模型采用的高度僵化和确定性的生成方法;对于人类来说,如图 3 所示,圆的曲率的微小扰动仍然被认为是圆。
图 3:通过扰动图 2 中的四个曲线段,圆的多种变化。
在我们的层中,将这些变化中的每一个单独视为一个新的通道是很困难的。类似地,当我们后来将 rcn 用于分类而不是生成时,将这些变体归入同一概念将大大有助于推广到新的变体。但是,我们如何将 rcn 改变为具有这种能力呢?
池层
为了实现这一点,引入了一种新的层,即池层。它位于任意两个要素图层之间,充当它们之间的中介。它也将由通道组成,但它们将是整数值而不是二进制。
为了说明,让我们回到我们的圆的例子。圆形要素不是从其下方的要素图层请求 4 条固定坐标的曲线段,而是从池图层请求这些曲线段。然后,池层中的每个激活的通道将在其邻域中选择低于它的级别的节点,以允许其特征的小扰动。因此,如果我们将邻域设置为池节点正下方的 9 个节点,那么池通道无论何时被激活,都将对这 9 个节点中的一个进行统一采样并触发它,并且它选择的节点的索引将是该池通道的状态,一个整数。您可以在图 4 中看到多次运行,其中每次运行对一组不同的低级节点进行采样,从而允许圆的不同变化。
图 4:池化层的操作。这个 GIF 中的每一帧都是不同的运行。汇集节点是立方体。在此 GIF 中,池节点有 4 个通道,相当于其下要素图层的 4 个通道。为了清楚起见,上面的&下面的层已经被完全移除。
尽管我们的模型中需要可变性,但是如果这种可变性得到更多的约束和协调,那就更好了。在前面的两个图中,一些圆太怪异了,不能认为是圆,因为它们的曲线段是不连续的,如图 5 所示,我们想拒绝它们的生成。因此,如果我们可以添加一种机制来汇集通道,以协调它们对特征节点的采样,从而专注于更连续的可变性,我们的模型将更合适。
图 5:一个圆的多种变化,我们不希望这些变化被打上一个红叉。
RCN 的作者在池层中引入了横向连接来实现这一点。本质上,池通道将与它们附近的其他池通道连接,这些连接不允许一些状态对同时在两个通道中出现。简单的限制这两个通道的采样空间是不会允许的。例如,在圆的变体中,这些连接将不允许两个相邻的线段彼此远离。这种机制如图 6 所示。同样,这些联系将在学习阶段建立。值得注意的是,当代的普通人工神经网络在它们的层中没有任何类型的横向连接,尽管生物神经网络有横向连接,并且被认为在视觉皮层的轮廓整合中起作用6。
图 6: GIF 展示了 rcn 在横向连接下的操作。当两端的状态不能同时出现时,横向连接会发出红光。应该注意的是,一个工作的 RCN 首先实际上不会产生不允许的状态;我们只是生成它们来理解哪些变化被丢弃了。为了清楚起见,上面的&下面的几层已经完全去掉了。
到目前为止,我们一直在谈论 rcn 的中间层;剩下的是最顶层和最后一层,与生成的图像的像素接口。最顶层只是一个普通的要素图层,其中每个结点的通道都是我们的标注数据集的类。在生成时,我们只需选择位置和我们想要生成的类,转到具有所选位置的节点,并告诉它激活我们选择的类的通道。这将触发它下面的池层中的一些通道,然后是下面的要素层,以此类推,直到我们到达最后一个要素层。根据您对 ConvNets 的了解,您可能会认为最顶层只有一个节点,但这里的情况并非如此,实际上这是 rcn 的优点之一,但讨论这一点超出了本文的范围。
最后一个要素图层将是唯一的。还记得我说过 RCNs 把外形和外观分开吗?嗯,这一层将负责输出要生成的对象的形状。因此,这一层应该具有非常低级的特征,任何形状的最基本的构建块,这将允许我们生成任何我们想要的形状。以不同角度旋转的微小边缘是很好的候选,这实际上是作者所利用的。
作者选择用最后一级的特征来表示一个 3x3 的窗口,该窗口的边缘有一定的旋转,他们称之为面片描述符。他们选择的旋转数是 16。此外,为了能够在以后添加外观,每次旋转将有两个方向,以便能够区分背景是位于边缘的左侧还是右侧(如果是外部边缘),以及一个额外的方向用于内部边缘(即对象内部)。在图 7 中,您可以看到最后一个要素层的一个节点,在图 8 中,您可以看到这些面片描述符是如何形成某种形状的。
图 7:最后一个要素层的节点。有 48 个硬编码(未学习)通道,对应于 16 个边缘旋转* 3 个方向。所示的面片描述符是 45°边的所有可能方向。“IN”表示内部区域,而“OUT”表示外部区域。
图 8:使用不同的补丁描述符形成字母“I”形状的例子。
现在,当我们到达最后一个要素图层时,我们有了一个指定对象边缘以及边缘周围区域是内部还是外部的蓝图。剩下的工作是添加外观,将图像中的每个剩余区域指示为入区或出区,然后为所有区域添加颜色。这将通过使用条件随机场来完成。不涉及数学术语,接下来要发生的是,我们将为最终图像中的每个像素分配一个颜色和状态(入或出)的概率分布。该分布将反映从边缘图提供的信息。例如,如果有两个相邻像素,其中一个在内,另一个在外,则它们具有不同颜色的概率会大大增加。如果两个相邻像素位于内边缘的相对侧,则它们具有不同颜色的概率会增加。如果像素在内部,它们之间没有内边缘,那么它们具有相同颜色的概率增加,但是允许外部像素彼此偏离,等等。为了产生最终的图像,你只需要从我们刚刚建立的联合概率分布中取样。为了使生成的图像更丰富,我们实际上可以用纹理代替颜色。我们不打算进一步讨论这一层,因为 rcn 可以在没有外观的情况下执行分类。
本文到此为止。如果你想了解更多关于 RCN 的知识,你可以查看它的论文[5]和附带的补充材料文档,或者你可以阅读我的其余文章,其中谈到了推理、学习、在不同数据集上应用 RCN 的结果。
参考
[1] R.Perrault,Y. Shoham,E. Brynjolfsson 等,人工智能指数 2019 年度报告(2019),以人为中心的人工智能研究所-斯坦福大学。
[2] D. Hendrycks,K. Zhao,S. Basart 等著《自然对抗性实例》(2019),arXiv:1907.07174。
[3] J.Su,D. Vasconcellos Vargas,S. Kouichi,愚弄深度神经网络的一个像素攻击(2017),arXiv:1710.08864。
[4] M. Sharif,S. Bhagavatula,L. Bauer,带有目标的对抗性例子的一般框架(2017),arXiv:1801.00349。
[5] D. George,W. Lehrach,K. Kansky 等,一种以高数据效率训练并打破基于文本的验证码的生成视觉模型(2017),科学杂志(第 358 卷—第 6368 期)。
[6] H. Liang,X. Gong,M. Chen,等,初级视皮层中反馈与侧连接的相互作用(2017),美国国家科学院院刊。
理解递归
安德里亚·费拉里奥在 Unsplash 上拍摄的照片
了解如何利用这一基本但令人困惑的编程概念
每次我有一段时间不用它的时候,我发现自己都要重新学习这个概念。我大体上记得它是什么,以及我们为什么需要它,但是在长期搁置之后,递归编程总是一场斗争。
现在它又一次让我记忆犹新,让我来记录一下我们为什么以及如何使用递归。如果你想下载这篇文章中用到的代码,你可以在我的 GitHub 上找到它。
学过递归的人可能都记得计算斐波那契数的经典例子:
# Function that returns the nth number in the Fibonacci sequence
# For example, if n=3 the 3rd number in the sequence is returneddef fib(n):
if n==0:
return 1
elif n==1:
return 1
else:
return fib(n-1) + fib(n-2)
在我们进入一个更实际的例子之前,让我们用它来理解递归是如何工作的。
递归是函数调用自身。我把递归比作 do-while 循环,遗憾的是这在 Python 中并不存在。与 for 循环不同,do-while 循环会在满足终止条件之前一直运行,而 for 循环会预先指定要运行的次数。do-while 循环的美妙之处在于它会一直运行到任务完成**——您不需要提前知道需要运行多少次。**
递归很棒,因为它是 do-while 循环的有价值的替代品。它还会一直运行,直到任务完成。但是递归不是一个循环,而是像电影《盗梦空间》。在《盗梦空间》中,莱昂纳多·迪卡普里奥和他那群快乐的盗梦贼冒险进入了一个人的梦的更复杂的层面——每一层都是梦中的梦。只要他们能找到回去的路,他们能走多深是没有限制的。
每次我们的递归函数调用自己时,它实际上是更深入一层(当前梦中的另一个梦)。但就像《盗梦空间》中的角色一样,深入是达到目的的一种手段。让我们看看我们的斐波纳契代码,特别是最后一行:
return **fib(n-1) + fib(n-2)**
这里,该函数调用自身两次,并将结果相加,但是,尽管最初的函数是用 n 作为输入调用的,但这两次调用是用修改后的输入进行的: n-1 和 n-2 。所以基本上梦里的每一个梦都从最初的输入 n (通过从中减去)逐渐减少,直到它到达终点。让我们展开 fib(5) 来看看这是如何工作的:
fib(5)= fib(4) + fib(3)= fib(3) + fib(2) + fib(2) + fib(1)= fib(2) + fib(1) + fib(1) + fib(0) + fib(1) + fib(0) + 1= fib(1) + fib(0) + 1 + 1 + 1 + 1 + 1 + 1= 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1= **8**
请注意,该函数一直持续到到达 fib(1) 或 fib(0) 为止,这些都是终止情况。比如 fib(3) 变成 fib(2) + fib(1) ,就变成 fib(1) + fib(0) + fib(1) 。这种自我传播,直到达到一个终止的情况,这就是函数如何越来越深(进入梦中的梦),直到它到达一个出口点。
我们可以通过对照我们的函数来验证 8 是否是正确答案:
In: fib(5)Out: 8In:# For fun, print first 10 values of the Fibonacci Sequence
for i in range(10):
print(fib(i))Out:1
1
2
3
5
8
13
21
34
55
结束
让我们花点时间来谈谈终止案例。**这些都很关键,因为如果没有这些情况,函数就会无限期地调用自己,陷入死循环。**所以编写任何递归函数的第一步是指定它可以退出(终止)的条件。
在斐波那契数列的例子中,我们知道序列中的前两个值是 1 和 1。即 fib(0) = 1 和 fib(1) = 1 。所以任何时候我们得到一个 0 或 1 的输入,我们就可以返回一个 1,然后就结束了。如果我们的输入大于 1,那么我们需要像上面那样,通过一系列递归函数调用,朝着终止情况(0 或 1)前进。
更实际的用例
打印斐波那契数列是一个巧妙的聚会把戏,但是现在让我们来看一个更实际的用例——遍历树。树木的问题在于我们不知道它们有多深。你可以有一个这样的:
root
| \
| \
b1 b2
或者像这样稍微深一点的:
root ____________
| \ \
| \ \
b1 ____ b2 b3
| \ \ | \
l1 l2 l3 l1 l2
让我们编写一个足够通用的函数,它可以处理这两种树(甚至更复杂的树)。首先,让我们使用 Python 字典构建上面描述的两棵树:
my_dict0 = {'root': {'b1': [1],
'b2': [2]}
}my_dict1 = {'root': {'b1': {'leaf1': [1,2,3],
'leaf2': [4],
'leaf3': [5,6]
},
'b2': {'leaf1': [7,8],
'leaf2': [9,10,11]
},
'b3': 12
}
}
现在让我们考虑如何通过递归来遍历这些树。首先让我们定义终止的情况。由于我们构造字典的方式,终止的情况非常简单:如果我们看到一个列表(或单个值),我们可以只追加内容并继续前进。注意,我将结果存储在一个名为 result 的扩展列表中( result 是函数运行完成后返回的结果)。另外,请注意,虽然我称之为终止案例,但我们还没有返回任何内容。相反,我们只是将结束节点的值附加到结果中,并继续我们的 for 循环,因为我们知道我们已经完成了树的当前子部分。
def parse_dict(my_dict, result):
for key, val in my_dict.items():
**if type(val) == list:
for i in val:
result.append(i)**
如果我们看不到名单呢?然后,我们需要通过递归(Inception)更深入地研究。我们这样做的方法是通过再次调用我们的函数,但是在修改的输入上(如果我们每次调用都不改变我们的输入,我们将陷入无限循环)。因此,我们现在只给函数当前正在检查的树的一部分( my_dict ),而不是给函数原始输入。例如,一个对 parse_dict 的递归调用可能只处理 b1 分支(它有 3 个叶子: leaf1 、 leaf2 和 leaf3 ),而另一个调用可能只处理 b3 分支(它只有值 12 )。
def parse_dict(my_dict, result):
for key, val in my_dict.items():
if type(val) == list:
for i in val:
result.append(i)
**elif type(val) == dict:
parse_dict(val, result)**
最后要添加的是一个终止案例,它检查分支何时没有叶子或列表,并且只包含一个数值(比如 b3 )。在这种情况下,我们可以直接将 val 中的值追加到结果中:
def parse_dict(my_dict, result):
for key, val in my_dict.items():
if type(val) == list:
for i in val:
result.append(i)
elif type(val) == dict:
parse_dict(val, result)
**else:
result.append(val)**
return result
如果我们在简单的树上运行函数, my_dict0 ,我们得到:
In: parse_dict(my_dict0, [])Out: [1, 2]
如果我们在 my_dict1 上运行它,我们会得到:
In: parse_dict(my_dict1, [])Out: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
酷,我们已经遍历了我们的树,挖掘了存储在每个端节点中的值。不管树是简单的还是更复杂、更深入的,我们的函数都能工作。
需要记住的一点是,如果你有一棵非常复杂的树,有很多层和很多分支,那么递归遍历它可能需要很长时间。如果您正在处理非常复杂的树结构,那么投入一些时间和思想来构建一个更加优化的解决方案是有意义的。
额外收获:获得每个值的路径
我们可能还想知道到达每个最终值所经过的路径(或者到达特定最终值的路径)。下面的函数产生一个包含每个结束值的路径的列表(与 parse_dict 的输出共享相同的顺序)。它的工作方式与 parse_dict 类似,所以我就不赘述了(但是代码中有一些注释)。
def get_path(my_dict, result, path):
for key, val in my_dict.items():
# If it's a dict, go one level deeper and update path
# to include the current key (node name)
if type(val) == dict:
get_path(val, result, path+[key])
# If it's not a dict, then append the key and don't
# go down another level (since no need to)
elif type(val) == list:
for i in val:
result.append(path+[key])
else:
result.append(path+[key])
return result
下面是一些输出示例。注意,我们需要提供两个输入——返回的结果和路径,后者是附加到结果的中间输出(但在函数结束时不返回)。
In: get_path(my_dict1, [], [])Out: [['root', 'b1', 'leaf1'],
['root', 'b1', 'leaf1'],
['root', 'b1', 'leaf1'],
['root', 'b1', 'leaf2'],
['root', 'b1', 'leaf3'],
['root', 'b1', 'leaf3'],
['root', 'b2', 'leaf1'],
['root', 'b2', 'leaf1'],
['root', 'b2', 'leaf2'],
['root', 'b2', 'leaf2'],
['root', 'b2', 'leaf2'],
['root', 'b3']]
希望这对你有帮助。干杯!
更多数据科学相关帖子由我:
了解感兴趣区域—(投资回报池)
理解 ML
快速简单地解释什么是投资回报池及其工作原理?为什么我们在快速 R-CNN 中使用它?
我们将讨论Fast R-CNN论文中描述的原始 RoI 合并(上图中的浅蓝色矩形)。这个过程有第二个和第三个版本,叫做ROI align和ROI warp。
如果你对那两个感兴趣请查看 这篇文章
什么是 RoI?
RoI (感兴趣区域)是从原始图像中提出的区域。我们不打算描述如何提取这些区域,因为有多种方法可以做到这一点。我们现在唯一应该知道的是,有多个这样的区域,所有这些区域都应该在最后进行测试。
R-CNN 的工作速度有多快?
特征抽出
快速 R-CNN 不同于基本 R-CNN 网络。它只有一个卷积特征提取(在我们的例子中,我们将使用 VGG16)。
VGG16 特征提取输出尺寸
我们的模型接受大小为 512x512x3 (宽 x 高 x RGB)的图像输入,VGG16 将它映射到一个 16x16x512 特征图中。您可以使用不同的输入大小(通常较小,Keras 中 VGG16 的默认输入大小是 224x224)。
如果你查看输出矩阵,你应该注意到它的宽度和高度正好是输入图像的 32 倍(512/32 = 16)。这一点很重要,因为所有 ROI 都必须按比例缩小。****
样本 ROI
这里我们有 4 个不同的 ROI。在实际的快速 R-CNN 中,你可能有成千上万个这样的图片,但是把它们都打印出来会使图片不可读。
感兴趣区域,图片来源:斯蒂芬妮·布斯https://www.flickr.com/photos/bunny/
重要的是要记住 RoI 不是一个边界框。它可能看起来像一个,但它只是一个进一步处理的建议。许多人认为这是因为大部分的论文和博客文章都是在创造提案而不是实物。这样更方便,我在我的图片上也是这样做的。这是一个不同的提案领域的例子,也将由快速 R-CNN 检查(绿框)。
没有意义的感兴趣区域:)
有一些方法可以限制 ROI 的数量,也许我会在以后写出来。
如何从特征图中获取 ROI?
现在,当我们知道什么是 RoI 时,我们必须能够将它们映射到 VGG16 的输出特征图上。
将我们的 ROI 映射到 VGG16 的输出上
每个 RoI 都有其原始坐标和大小。从现在开始,我们将只关注其中之一:
我们的投资回报目标
其原始尺寸为 145x200 ,左上角设置在 (192,296) 。正如你可能知道的,我们不能用 32 (比例因子)来划分这些数字。
- 宽度:200/32 = 6.25
- 身高:145/32 = ~4.53
- x: 296/32 = 9.25
- y: 192/32 = 6
只有最后一个数字(左上角的 Y 坐标)才有意义。这是因为我们现在正在处理一个 16x16 的网格,我们只关心整数(更准确地说:自然数)。
特征图上坐标的量化
量化 是将输入从一大组数值(如 实数 )约束到一个离散集合(如 整数 ) 的过程
如果我们将原始 RoI 放在特征图上,它看起来会像这样:
特征图上的原始 RoI
我们不能真正地在它上面应用池层,因为一些“单元”是分开的。量化所做的是,在将结果放入矩阵之前,将每个结果向下舍入。 9.25 变为 9 , 4.53 变为 4 等。
量化 RoI
您可以注意到,我们刚刚丢失了一堆数据(深蓝色)并获得了新数据(绿色):
量化损失
我们不必处理它,因为它仍然会工作,但有一个不同版本的过程称为 RoIAlign 可以修复它。
投资回报池
现在,当我们将 RoI 映射到特征图上时,我们可以在其上应用池化。为了方便起见,我们将再次选择 RoI Pooling 层的大小,但请记住大小可能会有所不同。你可能会问“为什么我们还要应用投资回报池?”这是个好问题。如果你看看快速 R-CNN 的原始设计:
在 RoI 汇集层之后,有一个固定大小的全连接层*。因为我们的 ROI 具有不同的大小,所以我们必须将它们汇集成相同的大小(在我们的示例中为 3x3x512 )。此时,我们绘制的 RoI 大小为 4x6x512 ,正如您所想象的,我们无法将 4 除以 3 😦。这就是量子化再次出现的地方。***
映射的 RoI 和池层
这次我们不用处理坐标,只用大小。我们很幸运(或者只是方便的池层大小)的是 6 可以被 3 除,得到 2 ,但是当你用 3 除 4 时,我们剩下的是 1.33 。应用同样的方法(向下舍入)后,我们得到了一个 1x2 向量。我们的映射如下所示:
数据池映射
因为量子化,我们又一次失去了最下面一行:
数据池映射
现在我们可以将数据汇集成 3x3x512 矩阵****
数据汇集过程
在这种情况下,我们应用了最大池**,但在您的模型中可能会有所不同。Ofc。这个过程是在整个 RoI 矩阵上完成的,而不仅仅是在最顶层。所以最终结果看起来是这样的:**
全尺寸池输出
同样的过程被应用到我们原始图像的每一个 RoI,所以最后,我们可能有数百甚至数千个 3x3x512 矩阵。这些矩阵中的每一个都必须通过网络的其余部分发送(从 FC 层开始)。对于它们中的每一个,模型分别生成 bbox 和 class。
接下来呢?
在池化完成后,我们确定我们的输入大小为 3x3x512 ,这样我们就可以将它送入 FC 层进行进一步处理。还有一件事要讨论。由于量化过程,我们丢失了很多数据。准确地说,是这么多:
量化中的数据丢失(深蓝色和浅蓝色),数据增益(绿色)
这可能是一个问题,因为每个“单元”都包含大量的数据(特征图上的 1x1x512,粗略地转换为原始图像上的 32x32x3,但请不要使用该参考,因为卷积层不是这样工作的)。有一种方法可以解决这个问题(RoIAlign ),我将很快就此写第二篇文章。
编辑:这里是第二篇关于 RoIAlign 和 ROI warphttps://medium . com/@ Kemal piro/understanding-region-of-interest-part-2-ROI-align-and-ROI-warp-f 795196 fc 193
参考文献:
最初发布于https://erdem . pl。**
了解感兴趣区域— (RoI 对齐和 RoI 扭曲)
理解 ML
直观解释 RoI Align 的工作原理,以及为什么它优于标准 RoI 合并?
如果你不熟悉 RoI 的概念,先阅读一下了解感兴趣区域— (RoI 汇集)可能会有所帮助。本文不包括对什么是 RoI 的介绍,而只集中在RoI align和 RoIWarp 上。
为什么我们要修改投资回报池?
正如您在本系列的第一部分中所记得的,RoI Pooling 有一个主要问题。在此过程中,它会丢失大量数据。
RoI 集中量化损失(深蓝色和浅蓝色),数据增益(绿色)
每当它这样做时,关于该对象的部分信息就会丢失。这降低了整个模型的精度,很多真正聪明的人都考虑过这个问题。
设置
开始之前,我需要快速解释一下我们的模型。
原创 Mask R-CNN 架构。来源:https://arxiv.org/pdf/1703.06870.pdf
屏蔽 R-CNN 输出。来源:https://arxiv.org/pdf/1703.06870.pdf
我们将使用屏蔽 R-CNN* 网络进行测试。我们使用它的唯一原因是这种网络更多地受益于精确的池层,因此更容易显示 RoI Align 和 RoI Pooling 之间的差异。在实现投资回报池之前,我们使用哪个网络并不重要。因此,我们的设置保持不变,如下所示:***
模型特征映射过程。猫 i 法师来源:斯蒂芬妮·布斯https://www.flickr.com/photos/bunny/
我们的模型接受大小为 512x512x3 (宽 x 高 x RGB)的图像输入,VGG16 将它映射到一个 16x16x512 特征图中。比例因子是 32 。
接下来,我们使用一个建议的 ROI(145 x200框)并尝试将其映射到特征图上。因为不是所有的物体尺寸都可以除以 32,所以我们放置的 RoI 不与网格对齐。
RoI 放置
- (9.25,6) —左上角
- 6.25 —宽度
- 4.53 —高度
我们再次选择我们的池层大小为 3x3 ,因此最终结果形状为 3x3x512 (这只是一个任意的例子,以便更容易在图像上显示。您的池层可能会有不同的大小)。
汇集层
至此,一切看起来与 RoI Pooling 中的完全相同。
RoI Align 简介
RoI Pooling 和 RoI Align 的主要区别在于量化。 RoI Align 不使用量化进行数据汇集。你知道快速 R-CNN 是两次应用量化。第一次在映射过程中,第二次在汇集过程中。
映射和汇集时的量化
我们可以跳过这一步,将原始 RoI 分成 9 个大小相等的方框,并在每个方框内应用双线性插值。让我们来定义盒子:
RoI 框尺寸
每个框的大小由映射的 RoI 的大小和汇集层的大小决定。我们使用的是 3x3 池层,所以我们必须将映射的 RoI ( 6.25x4.53 )除以 3。这给了我们一个高度为 1.51 和宽度为 2.08 的盒子(这里我将数值四舍五入以使其更简单)。现在,我们可以将我们的盒子放入映射的 RoI 中:
RoI 被分成多个方框
如果您查看第一个框(左上角),您会注意到它覆盖了六个不同的网格单元。为了提取池层的价值,我们必须从中抽取一些数据。为了对数据进行采样,我们必须在那个盒子里创建四个采样点。
采样点分布
你可以通过将盒子的高度和宽度除以 3* 来计算这些点应该在哪里。***
在我们的例子中,我们像这样计算第一个点(左上角)的坐标:
- X = X_box +(宽度/3) * 1 = 9.94
- Y = Y_box +(高度/3) * 1 = 6.50
为了计算第二个点(左下角),我们只需改变 Y:
- X = X_box +(宽度/3) * 1 = 9.94
- Y = Y_box +(高度/3) * 2 = 7.01
现在,当我们有了所有的点,我们可以应用双线性插值来采样这个盒子的数据。双线性插值通常用于图像处理中对颜色进行采样,其公式如下所示:
双线性插值方程
请不要试图理解这个等式,而是看一看它是如何工作的图形解释:
第一点的双线性插值
当你从我们的盒子中取出第一个点时,你把它和最近的相邻单元连接起来(正好在中间),除非它已经被取了。在这种情况下,我们的点有坐标 (9.44,6.50) 。左上方向上最接近单元格中间的是 (9.50,6.50) (如果我们的点在网格上仅高出 0.01,则为(9.50,5.50))。然后我们必须选择一个左下方的点,最接近的点是 (9.50,7.50) 。遵循同样的规则,我们选择 (10.50,6.50) 和 (10.50,7.50) 作为右上角和右下角的点。在 RoI 上方,您可以看到获得第一个点的值(0.14)的整个计算过程。
第二点的双线性插值
这次我们从以下位置开始插值:
- 左上:(10.50,6.50)
- 左下方:(10.50,7.50)
- 右上:(11.50,6.50)
- 右下:(11.50,7.50)
你应该在这里开始看到一个模式:)。以下是其他几点:
第三点的双线性插值
- 左上:(9.50,6.50)
- 左下方:(9.50,7.50)
- 右上:(10.50,6.50)
- 右下:(10.50,7.50)
第四点的双线性插值
- 左上:(10.50,6.50)
- 左下方:(10.50,7.50)
- 右上:(11.50,6.50)
- 右下:(11.50,7.50)
现在我们已经计算了所有的点,可以对它们应用最大池*(如果你愿意,也可以是平均池)😗**
第一箱统筹
我不会给你看所有的插值,因为这会花很长时间,你可能已经知道如何做了。我将向您展示使用 RoIAlign 从该 RoI 汇集数据的整个过程:
RoIAlign 池化过程(在新标签中打开图像以检查所有计算)
还有 ofc。此过程适用于所有图层,因此最终结果包含 512 个图层(与要素地图输入相同)
RoIAlign 全尺寸
请注意,即使我们没有将采样点放置在要素地图的所有像元内,我们也是通过双线性插值从这些像元中提取数据。
在这种情况下,单元格 11x6、11x7、11x8、11x9、11x10、13x6、13x7、13x8、13x9、13x10、15x6、15x7、15x8、15x9、15x10 中不会有任何点。如果您查看第二个点计算(第一个框),它仍然使用 11x6 和 11x7 单元进行双线性插值,即使该点位于 10x6 单元中。
如果您比较 RoIAlign 和 RoIPooling 的数据损失/数据增加,您应该看到 RoIAlign 使用整个区域来汇集来自以下区域的数据:
比较 RoIAlign(左)和 RoIPooling(右)数据源。
- 绿色表示用于池化的附加数据。
- 蓝色(两种阴影)表示数据在汇集时丢失。
洛奇普——在中间见我
通过多任务网络级联 在 实例感知语义分段中引入了第三种汇集数据的方法,这种方法被称为 RoIWarp 。RoIWarp 的想法与 RoIAlign 或多或少是一样的,唯一的区别是 RoIWarp 是将 RoI 映射量化到特征映射上。
RoI 翘曲
如果从数据丢失/数据增加的角度来看:
RoI 翘曲数据丢失/数据增加
由于双线性插值,我们只损失了一小部分。
RoIAlign 和 RoIWarp 如何影响精度
如果我们看一下 Mask R-CNN 的论文,有一些重要的数字需要讨论。第一个是在步幅为 16 的 ResNet-50-C4 上应用不同 RoI 层时平均精度的变化:**
当应用 RoIWarp 时,只有很小的改进,但是应用 RoIAlign 使我们在精度上有了显著的提高。这种提升随着步幅的增加而增加:
其中 APbb 是检测边界框的平均精度。测试在 ResNet-50-C5 上进行,步幅为 32。
摘要
当我们想提高我们的 R-CNN 类模型的准确性时,理解 RoI 池是很重要的。在 2014 年关于快速 R-CNN 的论文中提出的标准方法和 2018 年关于屏蔽 R-CNN 的论文中提出的新方法之间存在显著差异。这并不意味着这些方法只适用于特定的网络,我们可以很容易地在快速 R-CNN 中使用 RoIAlign,在 Mask R-CNN 中使用 RoIPooling,但你必须记住,RoIAlign 平均来说可以提供更好的精度。
我真的希望我的解释很容易理解,因为我已经看到了很多关于投资回报池的帖子,而没有进入计算。在我看来,更直观的方法总是更好,尤其是如果你不想花一整天的时间一遍又一遍地阅读原始论文以最终理解它的作用。
参考资料:
- R.吉斯克。快速 R-CNN。2014 年https://arxiv.org/pdf/1504.08083.pdf ICCV
- J.戴,贺国强,孙军。基于多任务网络级联的实例感知语义分割。2016 年https://arxiv.org/pdf/1512.04412.pdf CVPR
- K.何、g .基约萨里、p .杜尔和 r .吉希克。面具 R-CNN 在 ICCV,2018https://arxiv.org/pdf/1703.06870.pdf
原载于https://erdem . pl。**