代码详解:通过模拟API来理解TensorFlow
TensorFlow是一个非常强大的开源库,用于实现和部署大规模机器学习模型。多年来,TensorFlow已成为最受欢迎的深度学习库之一。
这篇文章的目的是建立对深度学习库,特别是TensorFlow的工作原理的理解。为了实现这一目标,我们将模仿Tensor的API并从头开始实施其核心构建块。你将对内部工作有一个深刻的概念性理解,进一步了解变量、张量、会话或操作等内容。
理论
TensorFlow是一个由两个核心构建块组成的框架——用于定义计算图的库和用于在各种不同硬件上执行此类图形的运行。计算图有很多优点,但之后会有更多优点。
现在你可能会问的问题是,计算图究竟是什么?
计算图
简而言之,计算图是将计算描述为有向图的抽象方式。有向图是由节点(顶点)和边组成的数据结构。它是由有向边成对连接的一组顶点。
这是一个非常简单的例子:
有向无环图的简单示例
图形具有多种形状和大小,用于解决许多现实问题,例如代表网络,包括电话网络、电路网络、道路网络甚至社交网络。它们也常用于计算机科学中以描述相关性,用于调度或在编译器中用于表示直线代码(没有循环和条件分支的语句序列)。使用后者的图表允许编译器有效地消除公共子表达式。
当然,在技术面试中,它们经常被用来拷问候选人。
现在我们对有向图有了基本的了解,让我们回到计算图。
TensorFlow在内部使用有向图来表示计算,他们称之为数据流图(或计算图)。
虽然有向图中的节点可以是任何东西,但计算图中的节点主要表示操作、变量或占位符。
操作根据特定规则创建或操作数据。在TensorFlow中,这些规则称为Ops,是操作(operations)的缩写。另一方面,变量也代表可以通过对这些变量运行Ops来操纵的共享持久状态。
边缘对应于流经不同操作的数据或多维数组(所谓的张量)。换句话说,边缘将信息从一个节点传送到另一个节点。一个操作(一个节点)的输出成为另一个操作的输入,连接两个节点的边缘携带该值。
这是一个非常简单的程序示例:
为了从该程序中创建计算图,我们为程序中的每个操作创建节点,以及输入变量a和b。实际上,如果a和b不改变,它们可能是常数。如果一个节点用作另一个操作的输入,我们绘制从一个节点到另一个节点的有向箭头。
该程序的计算图可能如下所示:
代表简单程序及其数据流的计算图
此图表从左到右绘制,但也可以找到从上到下绘制的图形,反之亦然。我选择前者的原因仅仅是因为我觉得它更具可读性。
上面的计算图表示我们需要执行的不同计算步骤,以达到最终结果。首先,创建两个常量a和b。然后,将它们相乘,得到它们的总和并使用这两个操作的结果将一个除以另一个。最后,打印出结果。
这不是很难,但问题是为什么我们需要一个计算图?将计算组织为有向图有什么好处?
首先,计算图是描述计算机程序及其计算的更抽象的方式。在最基本的层面上,大多数计算机程序主要由两部分组成——原始操作和执行这些操作的顺序,通常是逐行的。这意味着我们首先将a和b相乘,并且只有当这个表达式被评估时,才会得到它们的总和。因此,程序指定执行的顺序,但计算图形专门指定操作的依赖性。换句话说,这些操作的输出将如何从一个操作流向另一个操作。
这允许并行或依赖性驱动调度。如果我们看一下计算图,我们就会看到可以并行执行乘法和加法。因为这两个操作并不相互依赖。因此,我们可以使用图的拓扑来驱动操作的调度并以最有效的方式执行它们。例如, 在一台机器上使用多个GPU,甚至在多台机器上分配执行。TensorFlow正是这样做的,TensorFlow正是这样做的,它可以将不依赖于彼此的操作分配给不同的内核,只需要构造一个有向图,就可以从实际编写程序的人那里获得最少的输入。
另一个关键优势是便携性。该图是与代码无关的语言表示。因此,如果你想要非常快的效果,我们可以在Python中构建图形,保存模型(TensorFlow使用协议缓冲区),使用不同的语言(如C ++)恢复模型。
现在我们已经有了坚实的基础,来看看构成TensorFlow计算图的核心部分。 这些是我们稍后将从头开始重新执行的部分。
TensorFlow基础
TensorFlow中的计算图由以下几部分组成:
· 变量:将TensorFlow变量视为计算机程序中的常规变量。变量可以在任何时间点修改,但不同之处在于它们必须在会话中运行图形之前进行初始化。 它们代表图表中的可变参数。变量的一个很好的例子是神经网络中的权重或偏差。
· 占位符:占位符允许我们从外部将数据提供到图表中,而不像变量那样,它们不需要初始化。占位符只是定义形状和数据类型。我们可以将占位符视为图中的空节点(empty nodes),稍后会提供该值。它们通常用于输入和标签。
· 常量:无法更改的参数。
· 操作:操作表示图形中执行Tensors计算的节点。
· 图形:图形就像一个中心枢纽,它将所有变量、占位符、常量连接到操作。
· 会话:会话创建运行时,在该运行时执行操作并评估张量。它还分配内存并保存中间结果和变量的值。
还记得一开始我们说过TensorFlow由两部分组成,一个用于定义计算图形的库和一个用于执行这些图形的运行吗?这便是图形和会话。图形类用于构造计算图,会话用于执行和评估所有节点或子集节点。延迟执行的主要优点是在计算图的定义期间,可以构造非常复杂的表达式,而无需直接评估它们并在所需的内存中分配空间。
例如,如果我们使用NumPy定义一个大的矩阵,比如万亿分之一,会立即得到一个内存不足的错误。在TensorFlow中,我们将定义一个Tensor,它是多维数组的描述。它可能具有形状和数据类型,但它没有实际值。
在上面的代码片段中,我们使用tf.zeros和np.zeros来创建矩阵,其中所有元素都设置为零。虽然NumPy会立即实例化一万亿个矩阵填充零所需的内存量,但TensorFlow只会声明形状和数据类型,但在图形的这一部分执行之前不会分配内存。
声明和执行之间的核心区别非常重要,因为这是允许TensorFlow在连接到不同机器的不同设备(CPU,GPU,TPU)上分配计算负载的原因。
有了这些核心构建块,让我们将简单程序转换为TensorFlow程序。一般来说,这可以分为两个阶段:
1. 计算图的构建
2. 运行会话
我们的简单程序在TensorFlow中的运行如下:
我们从导入tensorflow开始。接下来,在with语句中创建一个会话对象。这样做的好处是在块执行后会话自动关闭,我们不必自己调用sess.close()。 而且,这些带有块的非常常用。
现在,在with-block中,可以开始构造新的TensorFlow操作(节点),从而定义边缘(Tensors)。例如:
a = tf.constant(15, name="a")
这将创建一个名为a的新Constant Tensor,它将生成值15。该名称是可选的,但是当你想要查看生成的图表时非常有用,我们稍后会看到。
但现在的问题是,图表在哪里?我们还没有创建图表,但已经添加了这些操作。这是因为TensorFlow为当前线程提供了一个默认图形,它是同一上下文中所有API函数的隐式参数。一般来说,仅仅依靠默认图就足够了。但是,对于高级用例,还可以创建多个图形。
好的,现在可以为b创建另一个常量,并对基本算术运算进行定义,例如乘法、加法和除法。所有这些操作都会自动添加到默认图表中。
就是这样!我们完成了第一步并构建了计算图。现在是时候计算结果了。请记住,到目前为止还没有评估任何内容,也没有为这些张量中的任何一个赋予实际数值。我们要做的是运行会话以明确告诉TensorFlow执行图形。
好的,这个很容易。我们已经创建了一个会话对象,所要做的就是调用sess.run(res)并传递一个想要评估的操作(这里是res)。这将只计算res值所需的计算图形。这意味着为了计算res,我们必须计算prod和sum以及a和b。最后,可以打印结果,即run()返回的Tensor。
让我们导出图形并使用TensorBoard将其可视化:
生成的图由TensorBoard可视化
这看起来很熟悉,是吗?
顺便说一下,TensorBoard不仅非常适合可视化学习,而且还可以查看和调试计算图,所以一定要查看它。
好的,上面的内容都只存在于理论层面,让我们直接进入编码。
从头实现TensorFlow的API
我们的目标是模仿TensorFlow的基本操作,以便用自己的API模拟简单的程序,就像我们刚才用TensorFlow做的那样。
之前,我们了解了一些核心构建块,例如Variable,Operation或Graph。 这些是我们想要从头开始实现的构建块,所以让我们开始吧。
图表
第一个缺失的部分是图表。图表包含一组Operationobjects,它们代表计算单位。此外,图形包含一组占位符和变量对象,它们表示在操作之间流动的数据单位。
对于我们的实现,我们基本上需要三个列表来存储所有这些对象。此外,我们的图需要一个名为as_default的方法,可以调用它来创建一个用于存储当前图形实例的全局变量。这样,在创建操作、占位符或变量时,不必传递对图形的引用。
让我们开始吧:
class Graph():
def __init__(self):
self.operations = []
self.placeholders = []
self.variables = []
self.constants = []
def as_default(self):
global _default_graph
_default_graph = self
操作
下一个缺失的部分是操作。回想一下,操作是计算图中的节点,并在Tensors上执行计算。大多数操作将零或多个张量作为输入,并产生零个或多个Tensors对象作为输出。
简而言之,操作的特征如下:
1. 它有一个input_nodes列表
2. 实现前向功能
3. 实现后向功能
4. 记住输出
5. 将其添加到默认图表中
因此,每个节点只知道它的直接周围,这意味着它知道它本地输入和输出直接传递给正在消耗它的下一个节点。
输入节点是进入此操作的Tensors(≥0)列表。
前向和后向都只是占位符方法,它们必须由每个特定操作实现。在实行中,在前向传递(或前向传播)期间叫做向前,其计算操作的输出,而在向后传递(或反向传播)期间叫做向后,其中我们计算关于每个输入的操作的梯度变量。这并不是TensorFlow的工作方式,但我发现一个操完全自治的操作更容易推理,这意味着它知道如何计算输出和每个输入变量的局部梯度。
每个操作都在默认图表中注册也很重要。当你想要使用多个图形时,这会派上用场。
让我们一步步进行,首先实现基类:
class Operation():
def __init__(self, input_nodes=None):
self.input_nodes = input_nodes
self.output = None
# Append operation to the list of operations of the default graph
_default_graph.operations.append(self)
def forward(self):
pass
def backward(self):
pass
我们可以使用这个基类来实现各种操作。但事实证明,我们将在短时间内实施的操作都是具有两个参数a和b的操作。为了简化我们的工作并避免不必要的代码重复,创建一个BinaryOperation,它只需要将a和b初始化为输入节点。
class BinaryOperation(Operation):
def __init__(self, a, b):
super().__init__([a, b]
现在,我们可以使用BinaryOperation并实现一些更具体的操作,例如add,multiply,divide或matmul(用于乘以两个矩阵)。对于所有操作,假设输入是简单的标量或NumPy数组。这使得操作实现变得简单,因为NumPy已经实现了,尤其是更复杂的操作,例如两个矩阵之间的点积。后者允许我们轻松评估一批样品的图形,并计算批次中每个观察的输出。
class add(BinaryOperation):
"""
Computes a + b, element-wise
"""
def forward(self, a, b):
return a + b
def backward(self, upstream_grad):
raise NotImplementedError
class multiply(BinaryOperation):
"""
Computes a * b, element-wise
"""
def forward(self, a, b):
return a * b
def backward(self, upstream_grad):
raise NotImplementedError
class divide(BinaryOperation):
"""
Returns the true division of the inputs, element-wise
"""
def forward(self, a, b):
return np.true_divide(a, b)
def backward(self, upstream_grad):
raise NotImplementedError
class matmul(BinaryOperation):
"""
Multiplies matrix a by matrix b, producing a * b
"""
def forward(self, a, b):
return a.dot(b)
def backward(self, upstream_grad):
raise NotImplementedError
占位符
当我们查看简单程序及其计算图时,可以注意到并非所有节点都是操作,尤其是a和b。相反,当我们想要计算会话中图形的输出时,它们是必须提供的图形输入。
在TensorFlow中,有不同的方法为图形提供输入值,例如占位符、变量或常量。我们已经对其进行了简要的讨论,现在是时候实际执行第一个——占位符。
class Placeholder():
def __init__(self):
self.value = None
_default_graph.placeholders.append(self)
我们可以看到,占位符的实现非常简单。它没有使用值(即,名称)进行初始化,并且仅将其自身附加到默认图形。使用Session.run()的feed_dict可选参数提供占位符的值,但在实现会话时更多。
常量
我们要实现的下一个构建块是常量。常量与变量完全相反,因为初始化后它们无法更改。另一方面,变量表示计算图中的可变参数。例如,神经网络中的权重和偏差。
使用占位符作为输入和标签而不是变量是绝对有意义的,因为它们总是在每次迭代时更改。此外,区别非常重要,因为变量在向后传递期间被优化,而常量和占位符则不是。所以我们不能简单地使用一个变量来输入常数。占位符可以起作用,但这也有点误用。为了提供这样的功能,我们引入了常量。
class Constant():
def __init__(self, value=None):
self.__value = value
_default_graph.constants.append(self)
@property
def value(self):
return self.__value
@value.setter
def value(self, value):
raise ValueError("Cannot reassign value.")
在上面的例子中,我们利用了Python的一个特性,以使类更加像常量。
Python中的下划线具有特定含义。有些实际上只是惯例,有些则由Python解释器强制执行。使用单个下划线_大多数是按惯例。因此,如果我们有一个名为_foo的变量,那么这通常被视为提示开发人员将名称视为私有。但这并不是解释器强制执行的任何操作,也就是说,Python在私有变量和公共变量之间没有这些明显的区别。
但是后来有双下划线__或者也叫做“dunder”。解释者对待dunder的方式不同,它不仅仅是一个惯例。它实际上适用于命名修改。查看我们的实行的情况,可以看到在类构造函数中定义了一个属性__value。由于属性名称中有双下划线,Python会在内部将属性重命名为_Constant__value,因此它会在属性前加上类名称。此功能实际上是为了防止在使用继承时命名冲突。但是,我们可以结合getter使用此行为来创建一些私有属性。
我们所做的是创建了一个dunder属性__value,通过另一个“公开”可用属性值公开该值,并在有人试图设置该值时引发ValueError。这样,API的用户不能简单地重新分配值,除非他们会投入更多的工作并发现我们在内部使用dunder。所以它不是真正的常量,更像是JavaScript中的const,但出于我们的目的,它完全没问题。 至少可以保护价值不被轻易重新分配。
变量
计算图的输入与正在调整和优化的“内部”参数之间存在质的差异。例如,采用一个简单的感知器来计算y = w * x + b。虽然x表示输入数据,但w和b是可训练的参数,即计算图中的变量。没有变量训练,神经网络是不可能的。在TensorFlow中,变量在调用Session.run()时保持图中的状态,这与每次调用运行()时必须提供的占位符不同。
实现变量很容易。它们需要初始值并将其自身附加到默认图形。仅此而已。
class Variable():
def __init__(self, initial_value=None):
self.value = initial_value
_default_graph.variables.append(self)
会话
在这一点上,我会说我们对构建计算图非常有信心,我们已经实现了最重要的构建块来模拟TensorFlow的API,并使用自己的API重写简单的程序。我们必须构建最后一个缺失的部分——那就是会话。
因此,我们必须开始考虑如何计算操作的输出。如果从头开始回想起来,这正是会话的作用。它是一个运行时,在其中执行操作并评估图中的节点。
从TensorFlow我们知道会话有一个运行方法,当然还有其他几种方法,但我们只对这个特定方法感兴趣。
最后,我们希望能够使用的会话如下:
session = Session()output = session.run(some_operation, { X: train_X # [1,2,...,n_features]})
因此,运行需要两个参数,一个要执行的操作和一个将图元素映射到值的dictionary feed_dict。此dictionary用于为图表中的占位符提供值。提供的操作是我们要为其计算输出的图元素。
为了计算给定操作的输出,必须在拓扑上对图中的所有节点进行排序,以确保以正确的顺序对其进行执行。这意味着在评估常量a和b之前,我们无法评估Add。
拓扑排序可以被定义为有向非循环图(DAG)中的节点的排序,其中对于从节点A到节点B的每个有向边,节点B在排序中出现在A之前。
该算法非常简单:
1. 选择任何未访问的节点。在示例中,这是图中传递给Session.run()的最后一个计算节点。
2. 通过递归迭代每个节点的input_nodes来执行深度优先搜索(DFS)。
3. 如果到达一个没有更多输入的节点,请将该节点标记为已访问并将其添加到拓扑排序中。
这是特定计算图的算法动画插图:
计算图的拓扑排序
当我们从Div开始对拓扑计算图进行拓扑排序时,最终会得到一个排序,其中首先评估常数,然后是操作Mul和Add,最后是Div。请注意,拓扑排序不是唯一的。排序也可以是5,15,Add,Mul,Div,它实际上取决于处理input_nodes的顺序。这很有道理,不是吗?
创建一个微小的实用工具方法,在拓扑上从给定节点开始对计算图进行排序。
def topology_sort(operation):
ordering = []
visited_nodes = set()
def recursive_helper(node):
if isinstance(node, Operation):
for input_node in node.input_nodes:
if input_node not in visited_nodes:
recursive_helper(input_node)
visited_nodes.add(node)
ordering.append(node)
# start recursive depth-first search
recursive_helper(operation)
return ordering
既然可以对计算图进行排序并确保节点的顺序正确,那么就可以开始研究实际的会话类了。这意味着创建类并实现运行方法。
我们要做的是以下内容:
1. 拓扑从提供的操作开始对图表进行排序
2. 遍历所有节点
3. 区分不同类型的节点并计算其输出。
按照这些步骤,最终得到一个可能是这样的结果:
class Session():
def run(self, operation, feed_dict={}):
nodes_sorted = topology_sort(operation)
for node in nodes_sorted:
if type(node) == Placeholder:
node.output = feed_dict[node]
elif type(node) == Variable or type(node) == Constant:
node.output = node.value
else:
inputs = [node.output for node in node.input_nodes]
node.output = node.forward(*inputs)
return operation.output
重要的是区分不同类型的节点,因为每个节点的输出可以以不同的方式计算。 请记住,在执行会话时,我们只有变量和常量的实际值,但占位符仍在等待它们的值。因此,当我们计算占位符的输出时,必须查找作为参数提供的feed_dict中的值。对于变量和常量,可以简单地使用它们的值作为输出,对于操作,必须收集每个input_node的输出并在操作上调用forward。
哇!我们做到了。至少已经实现了所有需要模拟简单的TensorFlow程序所需的部分。让我们看看它是否真的有用。
为此,我们将API的所有代码放在一个名为tf_api.py的单独模块中。现在可以导入此模块并开始使用已实现的内容。
import tf_api as tf
# create default graph
tf.Graph().as_default()
# construct computational graph by creating some nodes
a = tf.Constant(15)
b = tf.Constant(5)
prod = tf.multiply(a, b)
sum = tf.add(a, b)
res = tf.divide(prod, sum)
# create a session object
session = tf.Session()
# run computational graph to compute the output for 'res'
out = session.run(res)
print(out)
当我们运行此代码时,假设到目前为止已经完成了所有操作,它将正确地打印出3.75到控制台。这正是我们希望看到的输出。
这看起来有点类似于我们对TensorFlow所做的,对吧?唯一的区别是资本化,这是有意为之。虽然在TensorFlow中,一切都只是一种操作——甚至占位符和变量——我们并没有将它们实现为操作。为了做区分,可以将操作用小写来表示并将其余部分资本化。