TensorFlow是一个非常强大的开源机器学习库,用于实现和部署机器学习模型。这使其非常适合研究和生产。多年来,它已成为最受欢迎的深度学习库之一。
这篇文章的目标是建立一种直觉和理解深度学习库的工作原理,特别是TensorFlow。为了实现这一目标,我们将模仿其API并从头开始实施其核心构建块。最终,你将能够自信地使用TensorFlow,因为你将对其内部工作原理有深刻的概念性理解。
理论
TensorFlow是一个由两个核心构建块组成的框架 - 用于定义计算图的库和用于在各种不同硬件上执行此类图的运行时。
计算图
简而言之,计算图是一种将计算描述为有向图的抽象方法。有向图是由节点(顶点)和边组成的数据结构。它是一组顶点通过有向边成对连接。
这是一个非常简单的例子:
图有许多形状和大小,用于解决许多现实问题,例如代表网络,包括电话网络,电路网络,道路网络甚至社交网络。它们也常用于计算机科学中以描述依赖性,用于调度或在编译器中用于表示直线代码(没有循环和条件分支的语句序列)。使用后者的图表允许编译器有效地消除公共子表达式。
TensorFlow在内部使用有向图来表示计算,他们称之为data flow graphs(或计算图)。
虽然有向图中的节点可以是任何节点,但计算图中的节点主要表示操作(operations),变量(variables)或占位符(placeholders)。
操作根据特定规则创建或操作数据。在TensorFlow中,这些规则称为Ops,是操作的缩写。另一方面,变量表示可以通过对这些变量运行Ops来操纵的共享持久状态。
这些边对应于流经不同操作的数据或多维数组(即所谓的张量)。换句话说,边将信息从一个节点传递到另一个节点。一个操作(一个节点)的输出成为另一个操作的输入,连接两个节点的边携带该值。
下面是一个非常简单的程序的例子:
为了从这个程序中创建一个计算图,我们为程序中的每个操作创建节点,以及输入变量a和b。事实上,a和b可以是常数如果它们不变的话。如果一个节点被用作另一个操作的输入,我们画一个从一个节点到另一个节点的有向箭头。
该程序的计算图可能如下所示:
上面的计算图表示了不同的计算步骤,我们需要执行这些步骤才能得到最终的结果。首先,我们创建两个常数a和b。然后我们把它们相乘,取它们的和,然后用这两个运算的结果一个除以另一个。最后,我们打印出结果。
这并不难,但问题是为什么我们需要一个计算图呢?将计算组织成有向图的优点是什么呢?
首先,计算图是描述计算机程序及其计算的一种更为抽象的方法。在最基本的层次上,大多数计算机程序主要由两部分组成——基本操作和按顺序逐行执行这些操作的顺序。这意味着我们首先要把a和b相乘,只有当这个表达式被求值时我们才会取它们的和。因此,程序指定了执行的顺序,但是计算图只指定了跨操作的依赖关系。换句话说,这些操作的输出如何从一个操作流向另一个操作。
这允许并行性或依赖性驱动调度。如果我们查看我们的计算图,我们会看到我们可以并行执行乘法和加法。那是因为这两个操作并不相互依赖。因此,我们可以使用图形的拓扑来驱动操作的调度并以最有效的方式执行它们,例如在一台机器上使用多个GPU,甚至在多台机器上分布执行。TensorFlow正是这样做的,它可以将不依赖于彼此的操作分配给不同的内核,只需要构造一个有向图,就可以从实际编写程序的人那里获得最少的输入。
另一个关键优势是可移植性。图是代码的独立于语言的表示。因此,我们可以用Python构建图形,保存模型,并使用另一种语言(比如c++)恢复模型。
TensorFlow基础知识
TensorFlow中的计算图包含几个部分:
- 变量:将TensorFlow变量视为计算机程序中的常规变量。变量可以在任何时间点修改,但不同之处在于它们必须在会话中运行图形之前进行初始化。它们代表图中的可变参数。变量的一个很好的例子是神经网络中的权重或偏差。
- 占位符:占位符允许我们从外部将数据提供到图中,而不像变量那样它们不需要初始化。占位符只是定义形状和数据类型。我们可以将占位符视为图中的空节点,稍后会提供该值。它们通常用于输入和标签。
- 常量:无法更改的参数。
- 操作:操作表示图形中执行Tensors计算的节点。
- 图:图就像一个中心枢纽,它将所有变量,占位符,常量连接到操作。
- 会话:会话创建一个运行时,在该运行时执行操作并评估Tensors。它还分配内存并保存中间结果和变量的值。
记得从一开始我们说TensorFlow由两部分组成,一个用于定义计算图的库和一个用于执行这些图形的运行时吗?这就是图和会话。Graph类用于构造计算图,Session用于执行和计算所有或部分节点。延迟执行的主要优点是在计算图的定义期间,我们可以构造非常复杂的表达式,而无需直接评估它们并在所需的内存中分配空间。
例如,如果我们使用NumPy来定义一个大的矩阵,我们会立即得到一个内存不足的错误。在TensorFlow中,我们将定义一个Tensor,它是多维数组的描述。它可能具有形状和数据类型,但它没有实际值。
在上面的Python代码片段中,我们使用tf.zeros和np.zeros创建一个矩阵,所有元素都设置为零。NumPyNumPy将立即实例化1万亿x1万亿的矩阵所需要的内存量(值为0),但TensorFlow只会声明形状和数据类型,但在图的这一部分执行之前不会分配内存。
声明和执行之间的核心区别非常重要,因为这是允许TensorFlow在连接到不同机器的不同设备(CPU,GPU,TPU)上分配计算负载的原因。
有了这些核心构建块,让我们将简单程序转换为TensorFlow程序。一般来说,这可以分为两个阶段:
- 构建计算图。
- 运行会话
以下是我们的简单程序在TensorFlow中的样子:
我们从导入tensorflow开始。接下来,我们在with语句中创建一个对象Session。这样做的好处是,在块执行后会话自动关闭,我们不必自己调用sess.close()。
现在,在with-block内部,我们可以开始构建新的TensorFlow操作(节点),从而定义边(Tensors)。例如:
a = tf.constant(15, name="a")
这就创建了一个名为a的Constant张量,它会生成值15。该名称是可选的,但是当您想要查看生成的图时,这个名称很有用,我们稍后会看到。
但现在的问题是,我们的图在哪里呢?我的意思是,我们还没有创建图,但我们已经添加了这些操作。这是因为TensorFlow为当前线程提供了一个默认图,它是同一上下文中所有API函数的隐式参数。一般来说,仅仅依靠默认图就足够了。但是,对于高级用例,我们还可以创建多个图。
好了,现在我们可以创建另一个Constant b,也定义了我们基本的算术运算,如multiply,add和divide。所有这些操作都会自动添加到默认图中。
现在是时候计算结果了。到目前为止,还没有求值,也没有给这些张量分配任何实际的数值。我们要做的是运行会话,显式地告诉TensorFlow执行图。
我们已经创建了一个会话对象,我们所要做的就是调用ses .run(res)并传递一个想求值的操作(这里是res)。这将只运行计算res值所需的计算图。这意味着为了计算res,我们必须计算prod和sum以及a和b。最后,我们可以打印结果,这就是run()返回的张量。
让我们导出图并使用TensorBoard将其可视化:
顺便说一句,TensorBoard不仅非常适合可视化学习,而且还可以查看和调试您的计算图,所以一定要查看它。
从头开始实现TensorFlow的API
我们的目标是模仿TensorFlow的基本操作,以便用我们自己的API镜像我们的简单程序,就像我们刚才用TensorFlow做的那样。
我们已经了解了一些核心构建模块,如Variable,Operation或Graph。这些是我们想要从头开始实现的构建块,所以让我们开始吧。
图
第一个缺失的部分是图。A Graph包含一组Operation对象,表示计算单位。此外,图包含一组Placeholder和Variable对象,它们表示在操作之间流动的数据单位。
对于我们的实现,我们基本上需要三个列表来存储所有这些对象 此外,我们的图需要一个调用的方法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对象作为输出。
简而言之,操作的特征如下:
- 它有一个列表 input_nodes
- 实现一个forward函数
- 实现一个backward函数
- 记住它的输出
- 将自己添加到默认图中
因此,每个节点只知道它周围的环境,这意味着它知道输入的本地输入和直接传递给下一个使用它的节点的输出。
输入节点是进入此操作的Tensors(≥0)列表。
这两个forward和backward只有占位符方法,它们必须通过每一个具体的操作来实现。在我们的实现中,在forward pass(或forward-propagation)期间调用forward,其计算操作的输出,而backward是在backward pass(或backpropagation)期间调用的,在此过程中,我们计算操作相对于每个输入变量的梯度。这并不是TensorFlow的工作方式但是我发现如果一个操作是完全自治的,这就更容易推理了,这意味着它知道如何计算输出和每个输入变量的局部梯度。
请注意,在这篇文章中我们将只实现forward pass,这意味着我们可以将 backward函数留空并且现在不用担心它。
每个操作都在默认图中注册也很重要。当您想要使用多个图时,这会派上用场。
让我们一步一步,首先实现基类:
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 NotImplementedErrorclass multiply(BinaryOperation): """ Computes a * b, element-wise """ def forward(self, a, b): return a * b def backward(self, upstream_grad): raise NotImplementedErrorclass 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 NotImplementedErrorclass 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中,有不同的方法为图提供输入值,例如Placeholder,Variable或Constant。
class Placeholder(): def __init__(self): self.value = None _default_graph.placeholders.append(self)
我们可以看到,实现Placeholder非常简单。它没有使用值(即名称)初始化,只将自己附加到默认图中。占位符的值是使用Session.run()的feed_dict可选参数提供的,但在实现会话时将对此进行更多介绍。
常量
我们要实现的下一个构建块是常量。常量与变量完全相反,因为初始化后它们无法更改。另一方面,变量表示我们的计算图中的可变参数。例如,神经网络中的权重和偏差。
使用占位符作为输入和标签而不是变量是绝对有意义的,因为它们总是在每次迭代时更改。此外,区别非常重要,因为变量在backward pass 期间被优化,而常量和占位符则不是。所以我们不能简单地用一个变量来输入常数。占位符可以工作,但感觉有点被滥用了。为了提供这种特性,我们引入常量。
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解释器强制执行。用单下划线_它的大部分是按惯例。因此,如果我们有一个名为_foo的变量,那么这通常被看作是一个暗示,即一个名称将被开发人员视为私有的。但这并不是解释器强制执行的,也就是说,Python在私有变量和公共变量之间没有这些明显的区别。
但是还有双下划线__,也叫“dunder”。解释器对dunder的处理是不同的,它不仅仅是一种约定。它实际上应用了命名混淆。看看我们的实现,我们可以看到我们在类构造函数中定义了一个属性__value。由于属性名中有双下划线,Python将在内部将属性重命名为类似于_Constant__value的名称,因此它使用类名作为属性的前缀。这个特性实际上是为了在处理继承时防止命名冲突。但是,我们可以将此行为与getter结合使用来创建一些私有属性。
我们所做的是创建一个dunder属性__value,通过另一个“publicly”可用属性值公开该值,并在有人试图设置该值时引发ValueError。这样,API的用户就不能简单地重新分配值,除非他们愿意投入更多的工作,并且发现我们在内部使用dunder。它不是一个真正的常量,更像是JavaScript中的const,但对于我们的目的,它是完全可以的。这至少可以防止值被轻易地重新分配。
变量
计算图的输入与正在调整和优化的“内部”参数之间存在质的差异。举个例子,拿一个计算的简单感知器y = w * x + b,x表示输入数据,w和b是可训练的参数,即计算图中的变量。在TensorFlow中,变量在调用Session.run()时保持图中的状态,而不是每次调用run()时都必须提供占位符。
实现变量很容易。它们需要初始值并将其自身附加到默认图。
class Variable(): def __init__(self, initial_value=None): self.value = initial_value _default_graph.variables.append(self)
会话
在这一点上,我说我们对构建计算图非常有信心,我们已经实现了最重要的构建块来镜像TensorFlow的API并使用我们自己的API重写我们的简单程序。我们必须建立一个最后一个缺失的部分 - 那就是Session。
因此,我们必须开始考虑如何计算操作的输出。
从TensorFlow我们知道一个会话有一个run方法,当然还有其他几个方法,但是我们只对这个特别的方法感兴趣。
最后,我们希望能够使用我们的会话如下:
session = Session()output = session.run(some_operation, { X: train_X # [1,2,...,n_features]})
因此run需要两个参数,一个operation要执行的参数和一个feed_dict将图元素映射到值的字典。此字典用于为图中的占位符提供值。提供的操作是我们要为其计算输出的图元素。
为了计算给定操作的输出,我们必须在拓扑上对图中的所有节点进行排序,以确保我们以正确的顺序执行它们。这意味着我们不能在计算常数a和b之前计算加法。
拓扑排序可以定义为有向无环图(DAG)中节点的排序,其中对于从节点a到节点B的每条有向边,节点B在排序中出现在a之前。
该算法非常简单:
- 选择任何未访问的节点。在我们的示例中,这是图中传递给Session.run()的最后一个计算节点。
- 通过递归迭代每个节点的input_nodes来执行深度优先搜索(deep -first search, DFS)。
- 如果我们到达一个没有更多输入的节点,请将该节点标记为已访问并将其添加到拓扑排序中。
以下是针对我们特定计算图的算法的动画插图:
当我们从拓扑上对以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
既然我们可以对计算图进行排序并确保节点的顺序正确,那么我们就可以开始研究实际的Session类。这意味着创建类并实现该run方法。
我们要做的是以下几点:
- 拓扑从提供的操作开始对图进行排序
- 迭代所有节点
- 区分不同类型的节点并计算它们的output。
按照这些步骤,我们最终得到一个可能是这样的实现:
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 graphtf.Graph().as_default()# construct computational graph by creating some nodesa = 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 objectsession = tf.Session()# run computational graph to compute the output for 'res'out = session.run(res)print(out)
当我们运行此代码时,假设到目前为止我们已经完成了所有操作,它将正确地打印3.75到控制台。这正是我们希望看到的输出。
这看起来和我们用TensorFlow做的很相似,对吧?唯一的区别是大写,但这是故意的。而在TensorFlow中,实际上所有东西都是一个操作——即使是占位符和变量——我们没有将它们实现为操作。为了区分它们,我决定使用小写操作并大写其余部分。
结论
我希望这篇文章对你有所帮助,TensorFlow现在有点不那么令人生畏了。