e63ec36e16a034d6c6494929d0c6314e.png

TensorFlow 是一个非常强大的开源库,用于实现和部署大规模机器学习模型。这让它很适合于研究和生产。多年来,TensorFlow 已成为最受欢迎的深度学习库之一。

这篇文章的主要目标是建立对深度学习库,特别是 TensorFlow 的工作原理的理解。为了实现这一目标,我们将模仿它的 API 并从头开始实现其核心构建块。读完这篇文章,你将能够自信的使用 TensosrFlow,因为你将对它的内部工作有一个深刻的概念性理解。你还将进一步了解 Variables(变量)、Tensors(张量)、Sessions(会话)和 Operations(操作)等内容。

注意: 如果你熟悉 TensorFlow 的基础知识,包括计算图如何工作,你可以跳过理论部分直接到后文的实现部分。

原理

TensorFlow 是一个由两个核心构建块组成的框架 - 用于定义计算图的库和在各种不同硬件上执行此类 Graph(图)的运行。计算图有很多优点,稍后我们会详细讨论。

现在你可能会问自己的问题是,计算图究竟是什么?

Computational Graphs(计算图)

简而言之,计算图是将计算描述为有向图的抽象方式。有向图是由节点(顶点)和边组成的数据结构。 它是由有向边成对连接的一组节点。

这是一个非常简单的例子:

5eb51f64341ed4f6d1384893fb5a0dab.png

Graph(图)有许多形状和大小,用于解决许多现实问题,例如代表网络,包括电话网络、电路网络、道路网络、甚至社交网络。它们也常用在计算机科学中以描述相关性,用于调度或在编译器中用于表示直线代码(没有循环和条件分支的语句序列)。对后者使用 Graph(图)允许编译器有效的消除公共子表达式公共子表达式。

当然,在面试技术时,它们经常被用于拷问面试者。

现在我们对有向图有了基本的了解,让我们回到计算图。TensorFlow 在内部使用有向图来表示计算,称之为 data flow graphs(数据流图)(或计算图)。

虽然节点可以是任何东西,但是节点在计算图中主要表示operations(操作), Variable(变量)placeholders(占位符)

操作根据特定的规则来创建或操作数据。在 TensorFlow 中,这些规则被称为 Ops(操作,Operations的缩写)。另一方面,Variables(变量)也代表可以通过对这些 Variables(变量)运行 Ops 来操纵的共享持久状态。

边对应于流经不同操作的数据或多维数组(所谓的 Tensors(张量))。换句话说边将信息从一个节点传送到另一个节点。一个操作(一个节点)的输出成为另一个操作的输入,连接两个节点的边携带该值。

这是一个非常简单的程序示例:

ee7a5820b9ec72756828d483c40f7fa7.png

为了从该程序中创建计算图,我们为程序中的每个操作创建节点,以及输入 Variables(变量) a 和 b。实际上,如果 a 和 b 不改变,它们可以是常数。如果一个节点用作另一个操作的输入,我们将绘制从一个节点到另一个节点的有向箭头。

该程序的计算图如下所示:

e39884ff51a592a6c373d8af0a1a4ee8.png

这个图表是从左到右绘制的,但你也可以找到从上到下绘制的图表,反之亦然。 我选择前者只是因为我觉得它更具有可读性。

上面的计算图表示我们需要执行的不同的计算步骤,以获得最终结果。首先,创建两个常量 a 和 b 。然后将它们相乘,再将它们相加,然后用这两个操作的结果将它们相除。最后,我们打印出结果。

这个不难,但问题是我们为什么需要一个计算图?把计算组织成有向图有什么好处

首先,计算图是描述计算机程序及其计算的更抽象的方式。在最基本的层次上,大多数计算机程序主要由两个基本操作组成,这些操作通常是按顺序逐行执行的。这意味着我们要先把 a 和 b 相乘,只有当这个表达式被计算出来时,我们才取它们的和。因此,程序指定了执行顺序,但是计算图专门指定了操作之间的依赖关系。换句话说,这些操作的输出将如何从一个操作流到另一个操作。

这允许并行依赖性驱动调度。如果我们看一下计算图,就可以看到我们可以并行执行乘法和加法。因为这两个操作并不相互依赖。因此,我们可以使用 Graph(图)的拓扑来驱动操作调度并以最有效的方式执行它们,例如,在一台机器上使用多个 GPU,甚至将执行分配到多台机器上。TensorFlow正是这样做的,它可以通过构造一个有向图,将彼此不依赖的操作分配给不同的核心,只需要实际编写程序的人的最小输入。太棒了,你不觉得吗?

另一个关键优势是可移植性。Graph(图)是与代码无关的语言表示。因此,如果您需要高效率,我们可以在 Python 中构建 Graph(图),保存模型(TensorFlow 使用协议缓冲区),并使用不同的语言(如 C++)恢复模型。

现在我们已经有了坚实的基础,让我们来看看构成 TensorFlow 的计算图的核心部分,这些是我们稍后将从头开始实现的部分。

TensorFlow 基础知识

TensorFlow 中的计算图由几部分组成:

  • Variables(变量): 将 TensorFlow Variables(变量)视为计算机程序中的常规 Variables(变量)。Variables(变量)可以在任何时间点修改,但不同之处在于它们必须在 Sessions(会话)中运行 Graph(图)之前进行初始化。它们代表 Graph(图)中的可变参数。Variables(变量)的一个很好的样本是神经网络中的权重或偏差。
  • Placeholders(占位符): 占位符允许从外部将数据提供到 Graph(图)中,而不像 Variables(变量),它们不需要初始化。占位符只是定义形状和数据类型。我们可以将占位符视为图中的空节点,之后会提供它的值。它们通常用于输入和标签。
  • Constants(常量): 不能更改的参数。
  • Operations(操作): 表示图中用于在 Tensors(张量) 上执行计算的节点。
  • Graph(图): Graph(图)就像是一个中心集线器,它将所有 Variables(变量)、Placeholders(占位符)、Constants(常量) 连接到操作。
  • Session(会话): Session(会话)创建一个运行时,在该运行时执行操作并评估 Tensors(张量)。它还分配内存并保存中间结果和 Variables(变量) 的值。

还记得从一开始我们说过 TensorFlow 由两部分组成,一个用于定义计算图的库和一个用于执行这些 Graph(图) 的运行吗?这就是 Graph(图)Session(会话)。Graph Class(图形类)用于构造计算图,Session(会话)是用于执行和评估所有或部分的节点。延迟执行的主要优点是在计算图的定义期间,我们可以构造非常复杂的表达式,不需要直接计算并在所需的内存中分配空间。

例如,如果我们使用 NumPy 定义一个大矩阵,比如说一万亿乘以一万亿,我们会立即得到一个内存不足的报错。 在 TensorFlow 中,我们将定义一个 Tensors(张量),这是对多维数组的描述。它可以具有形状和数据类型,但它没有实际值。

55be089e44d0356370fee9981aebfc64.png

在上面的代码片段中,我们使用 tf.zeros 和 np.zeros 来创建一个矩阵,其中所有元素都设置为零。虽然 NumPy 会立即实例化一万亿个矩阵填充零所需的内存量,但 TensorFlow 只会声明形状和数据类型,在 Graph(图)的这一部分执行之前不会分配内存。这是不是很酷?

要记住声明和执行之间的核心区别非常重要,因为这使得 TensorFlow 能够将计算负载分布到连接到不同机器的不同设备(CPU、GPU、TPUs)上。

有了这些核心构建块,我们将简单程序转换为 TensorFlow 程序。一般来说,这可以分为两个阶段:

  1. 构建计算图。
  2. 运行 Session(会话)。

下面是我们的简单程序在 TensorFlow 中的样子:

70aee0c169c05379cad65f03c32d051e.png

我们从导入 tensorflow 开始。接下来,我们在 with 语句中创建一个 Session 对象。这样做的好处是在代码被块执行后 Session(会话)会自动关闭,不必调用 sess.close()。此外,这些 with 代码块很常用。

现在,在 with-block 中,我们可以开始构造新的 TensorFlow 操作(节点),从而定义边( Tensors(张量))。 例如:

a = tf.constant(15, name="a")

这将创建一个名为 a 的新 Constant Tensor(张量),其值为 15。这个名称是可选的,但是当您想要查看生成的 Graph(图)时非常有用,我们后文会看到。

但现在的问题是,我们的 Graph(图)在哪里?我的意思是,我们还没有创建Graph(图),但我们已经添加了这些操作。这是因为 TensorFlow 为当前线程提供了一个默认图,它是同一上下文中所有 API 函数的隐式参数。一般来说,仅仅依靠默认图就足够了。但是,对于高级用例,我们还可以创建多个 Graph(图)。

好的,现在我们可以为 b 创建另一个常量,并定义的基本的算术运算,例如 multiply、 add 和 divide。 所有这些操作都会自动添加到默认图中。

就是这样!我们完成了第一步并构建了计算图。现在是时候计算结果了。记住,到目前为止,还没有对任何张量进行计算,也没有为这些张量指定实际的数值。我们要做的是运行 Session(会话) 以明确告诉 TensorFlow 执行 Graph(图)。

好的,这个很容易。我们已经创建了一个 Session 对象,我们要做的就是调用 sess.run(res) 并传递一个想要评估的操作(这里是 res )。这将只计算 res 值所需的计算图。这意味着为了计算 res ,我们必须计算 prod 和 sum 以及 a 和 b 。最后,我们可以 print 结果,即 run() 返回的 Tensor(张量)。

酷!让我们导出 Graph(图)并使用 TensorBoard 将其可视化:

6c115e251ee3cce983d8f92cb397e68e.png

这看起来很是不是熟悉?

顺便说一下,TensorBoard 不仅非常适合可视化学习,而且还可以查看和调试计算图,所以一定要查看。

好的,我们学了足够的理论知识了!让我们直接进入编码。

从头实现 TensorFlow 的 API

我们的目标是模仿 TensorFlow 的基本操作,以便用自己的 API 模拟简单程序,就像我们刚才用 TensorFlow 做的那样。

之前,我们了解了一些核心构建块,例如 Variable , Operation 或 Graph。 这些是我们想要从头开始实现的构建块,所以让我们开始吧。

Graph(图)

第一个缺失的部分是 Graph(图)。 Graph 包含一组 Operations(操作) 对象,这代表计算单位。此外,Graph(图) 还包含一组 Placeholders(占位符) 和 Variable,它们表示在操作之间流动的数据单位。

对于我们的实现,我们基本上需要三个列表来存储所有这些对象。此外,我们的 Graph(图) 需要一个名为 as_default 的方法,可以调用它来创建一个用于存储当前 Graph(图) 实例的全局变量。 这样,在创建 Operations(操作)、Placeholders(占位符) 或 Variables(变量) 时,我们不用传递对 Graph(图) 的引用。

所以,我们开始吧:

class Graph(): def __init__(self): self.operations = [] self.placeholders = [] self.variables = [] self.constants = [] def as_default(self): global _default_graph _default_graph = self

Operations(操作)

下一个缺失的部分是操作。 回想一下,操作是计算图中的节点,并在 Tensors(张量)上执行计算。 大多数操作将零或多个 Tensors(张量)作为输入,并产生零个或多个 Tensors(张量)作为输出。

简而言之,操作的特征如下:

  1. 它有一个 input_nodes 列表
  2. 实现 forward(正向)函数
  3. 实现 backward(反向)函数
  4. 记得它的输出
  5. 将自身添加到默认图中

因此,每个节点只知道它的直接周围的环境,这意味着它的本地输入和输出直接传递给正在使用它的下一个节点。

输入节点是进入该操作的 Tensors(张量)(≥ 0)列表。

forward 和 backward 都只是占位符方法,它们必须由每个特定操作实现。 在我们的实现中,在前向传递(或前向传播)期间调用 forward 来计算操作的输出,而在反向传递(或反向传播)期间调用 backward 来计算操作对每个输入的 Variables(变量)的梯度。这并不是 TensorFlow 的具体做法,但我发现,如果一个操作是完全自主的,就更容易推断出来,它知道如何计算每个输入 Variables(变量)的输出和局部梯度。

请注意,在这篇文章中我们将只实现前向传递,我们将在另一篇文章中讨论反向传递。 这意味着我们可以将 backward 函数留空,现在不考虑。

每个操作都在默认图中注册也很重要。 当您想要使用多个 Graph(图) 时,这会派上用场。

让我们一步一步来,首先实现基类:

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 已经实现了这些操作,尤其是对一些像两个矩阵之间的点积这样更复杂的操作。后者允许我们轻松评估一批例子的 Graph(图),并计算批次中每个观察的输出。

class add(BinaryOperation):  """  计算元素 a + b""" def forward(self, a, b):  return a + b def backward(self, upstream_grad):  raise NotImplementedErrorclass multiply(BinaryOperation):  """  计算元素 a * b """  def forward(self, a, b):  return a * b def backward(self, upstream_grad):  raise NotImplementedErrorclass divide(BinaryOperation):  """  返回输入元素的真实除法 """  def forward(self, a, b): return np.true_divide(a, b) def backward(self, upstream_grad): raise NotImplementedErrorclass matmul(BinaryOperation): """ 将矩阵 A 乘以矩阵 B,得到 A * B """ def forward(self, a, b): return a.dot(b) def backward(self, upstream_grad): raise NotImplementedError

占位符

当我们查看简单程序及其计算图时,我们可以注意到并非所有节点都是操作,尤其是 a 和 b 。 相反,当我们想要计算 Session(会话)中 Graph(图)的输出时,必须提供的 Graph(图)输入。

在 TensorFlow 中,有多种方法可以为 Graph(图)提供输入值,比如 占位符、 Variable(变量)或 常量。我们已经简要地讨论了每一个,现在是是时候来实现其中的第一个—— 占位符。

class Placeholder(): def __init__(self): self.value = None _default_graph.placeholders.append(self)

正如我们所看到的,占位符的实现非常简单。它没有初始化,因此只是一个名字,只将自己附加到默认图中。占位符的值是使用 Session.run() 的 feed_dict 可选参数提供的,但是当我们实现 Session(会话)时,会详细介绍这个参数。

常数

我们要实现的下一个构建块是常量。常量与 Variable(变量)完全相反,它们一旦初始化就不能更改。另一方面,Variable(变量)在我们的计算图中表示可变的参数。例如,神经网络中的权重和偏差。

输入和标签使用占位符而不是 Variable(变量)是因为它们总是在每次迭代中更改。此外,这种区别非常重要,因为 Variable(变量)在反向传递期间进行了优化,而常量和占位符则没有。所以我们不能简单地用一个 Variable(变量)来表示常数。占位符是有用的,但也感觉有点被误用了。为了提供这样的 Feature(特征),我们引入了常量。

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 的 Variable(变量),那么这通常被看作是一个提示,说明这个名称被开发人员视为私有的。但这并不是解释器强制执行的,也就是说,在 Python 中私有 Variable(变量)和公共 Variable(变量)之间并没有明显的区别。

然后是双下划线 __ ,也叫“ Dunder ”。Dunder ,它被解释器用不同的方式处理。它实际上应用了命名识别编码。查看我们的实现,可以看到我们在类构造函数中定义了一个属性 __value 。因为属性名中有双下划线,Python 会在内部将该属性重命名为 _constant_value 之类的名称,所以它用类名作为属性的前缀。这个特征实际上是为了在处理继承时防止命名冲突。不过,我们可以结合这个与 Getter 来创建一些私有属性。

我们创建了一个 Dunder 属性 __value,通过另一个“公开”可用属性 value 公开该值,并在有人试图设置该值时弹出 ValueError。这样,我们 API 的用户就不能轻易地重新分配值,除非他们愿意投入更多的工作,并发现我们在内部使用 Dunder。所以它不是一个真正的常量,更像是 JavaScript 中的 const,但就我们的目的而言,这是完全可以的。这至少可以保护值不被轻易重新分配。

Variable(变量)

计算图的输入与正在调优和优化的“内部”参数之间存在定性差异。以一个简单的感知器为例,它计算 y=w*x+b。 x 表示输入数据, w 和 b 是可训练参数,即计算图中 的Variable(变量)。神经网络是不可能没有 Variable(变量)训练的。在 TensorFlow 中,Variable(变量)在调用 Session.run() 时保持在 Graph(图)中的状态不变,这和每次调用 run() 时必须提供占位符不同。

实现 Variable(变量)很容易。它们需要一个初始值并将自己附加到默认图中。就像下面这样。

class Variable(): def __init__(self, initial_value=None): self.value = initial_value _default_graph.variables.append(self)

Session(会话)

在这一点上,我们对构建计算图非常有信心,我们已经实现了最重要的构建块来镜像 TensorFlow 的 API ,并用我们的 API 来重写我们的简单程序。我们还需要构建最后一个缺少的部分 Session(会话).

所以,我们要开始思考如何计算一个操作的输出。如果我们从开始回忆,这正是 Session(会话)所做的。它是一个执行操作并计算图中的节点的运行。

从 TensorFlow 我们知道一个 Session(会话)有一个 run 方法,当然还有其他几个方法,但是我们只对这个特殊的方法感兴趣。

最后,我们希望能够像下面那样的使用 Session(会话):

session = Session()output = session.run(some_operation, {  X: train_X # [1,2,...,n_features] })

因此 run 接受两个参数,要执行的 operation 和将 Graph(图)元素映射到值的字典 feed_dict。这个字典用于为 Graph(图)中的占位符提供值。提供的操作是我们要为其计算输出的 Graph(图)元素。

为了计算给定操作的输出,我们必须对图中的所有节点进行拓扑排序,以确保按正确的顺序执行它们。这意味着我们不能在计算常数 a 和 b 之前先计算 Add。

拓扑排序 可以定义为有向非循环(DAG)中节点的排序,其中从节点 A 到节点 B 的每条有向边的排序中,节点 B 出现在节点 A 之前。

算法非常简单:

  1. 选择任一未访问的节点。在我们的样本中,这是传递给 Session.run() 的 Graph(图)中的最后一个计算节点。
  2. 通过递归迭代每个节点的 input_nodes 来执行深度优先搜索(DFS)。
  3. 如果我们到达一个没有更多输入的节点,就把这个节点标记为已访问,并将其添加到拓扑排序中。

下面是一个动画演示的算法在我们的具体计算图表的行动:

66edffd3b16aad2b8f4091c019048f70.gif

当我们从 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) # 开始递归深度优先搜索 recursive_helper(operation) return ordering

既然我们可以对计算图进行排序并确保节点的顺序正确,那我们就可以开始研究实际的 Session 类了。这意味着创建类并实现 run 方法。

我们要做的是以下内容:

  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) == Variableor type(node) == Constant: node.output = node.valueelse: inputs = [node.output for node in node.input_nodes] node.output = node.forward(*inputs) return operation.output

重点是我们要区分不同类型的节点,因为每个节点的输出可以用不同的方式计算。记住,在执行 Session(会话)时,只有 Variable(变量)和常量的实际值,但占位符仍在等待它们的值。因此,当我们计算 Placeholder(占位符) 的输出时,我们必须查找作为参数提供的 feed_dict 中的值。 对于 Variable(变量)和常量,我们可以简单地使用它们的 value 作为输出,对于操作,我们必须收集每个 input_node 的输出并在操作上调用 forward。

Wohoo!我们做到了。至少我们已经实现了镜像简单的 TensorFlow 程序所需的所有部分。让我们看看它是否真的有效,好吗?

为此,我们将 API 的所有代码放在一个名为 tf_api.py 的单独模块中。现在,我们可以导入这个模块并开始使用我们已经实现的内容。

import tf_api as tf# 创建默认图形tf.Graph().as_default()# 通过创建一些节点构造计算图a = tf.Constant(15)b = tf.Constant(5)prod = tf.multiply(a, b)sum = tf.add(a, b)res = tf.divide(prod, sum)# 创建会话对象session = tf.Session()# 运行计算图以计算“res”的输出out = session.run(res)print(out)

假设到目前为止我们所做的一切都是正确的,当我们运行这段代码时,它将正确地向控制台输出 3.75。这正是我们想要看到的输出。

这和我们对 TensorFlow 做的很有趣的相似,对吧?这里唯一的区别是这里是大写的,但这是故意的。在 TensorFlow 中,所有东西都是一个操作 —— 甚至是占位符和变量 —— 我们没有将它们作为操作来实现。为了将它们区分开来,我决定使用小写的运算符,其余部分大写。

结论

恭喜,您已经成功地实现了 TensorFlow 中的一些核心 API。在此过程中,我们讨论了了计算图、拓扑排序,并使用我们自己的 API 成功地镜像了一个简单的 TensorFlow 程序。现在,希望您有足够的信心开始使用 TensorFlow。

在写这篇文章时,我们深入研究了 TensorFlow 的源代码,对这个库的良好编写印象深刻。话虽如此,还是想再次强调,我们所实现的并不是 TensorFlow 中的全部工作原理,因为有很多很抽象,而 TensorFlow 要完整得多,但想让事情变得更简单,并把它简化为核心概念。

希望这篇文章对您有所帮助,让 TensorFlow 变得不那么令人害怕了。

原文链接:https://medium.com/@d3lm/understand-tensorflow-by-mimicking-its-api-from-scratch-faa55787170d

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐