返回介绍

3.1 数据流图简介

发布于 2024-02-05 23:12:36 字数 6728 浏览 0 评论 0 收藏 0

本节将脱离TensorFlow的语境,介绍一些数据流图的基础知识,内容包括节点、边和节点依赖关系的定义。此外,为对一些关键原理进行解释,本章还提供了若干实例。如果你对数据流图已有一定使用经验或已运用自如,可直接跳过本节。

3.1.1 数据流图基础

借助TensorFlow API用代码描述的数据流图是每个TensorFlow程序的核心。毫不意外,数据流图这种特殊类型的有向图正是用于定义计算结构的。在TensorFlow中,数据流图本质上是一组链接在一起的函数,每个函数都会将其输出传递给0个、1个或更多位于这个级联链上的其他函数。按照这种方式,用户可利用一些很小的、为人们所充分理解的数学函数构造数据的复杂变换。下面来看一个比较简单的例子。

上图展示了可完成基本加法运算的数据流图。在该图中,加法运算是用圆圈表示的,它可接收两个输入(以指向该函数的箭头表示),并将1和2之和3输出(对应从该函数引出的箭头)。该函数的运算结果可传递给其他函数,也可直接返回给客户。

该数据流图可用如下简单公式表示:

上面的例子解释了在构建数据流图时,两个基础构件——节点和边是如何使用的。下面回顾节点和边的基本性质:

节点(node):在数据流图的语境中,节点通常以圆圈、椭圆和方框表示,代表了对数据所做的运算或某种操作。在上例中,“add”对应于一个孤立节点。

边(edge):对应于向Operation传入和从Operation传出的实际数值,通常以箭头表示。在“add”这个例子中,输入1和2均为指向运算节点的边,而输出3则为从运算节点引出的边。可从概念上将边视为不同Operation之间的连接,因为它们将信息从一个节点传输到另一个节点。

下面来看一个更有趣的例子。

相比之前的例子,上图所示的数据流图略复杂。由于数据是从左侧流向右侧的(如箭头方向所示),因此可从最左端开始对这个数据流图进行分析:

1)最开始时,可看到两个值5和3流入该数据流图。它们可能来自另一个数据流图,也可能读取自某个文件,或是由客户直接输入。

2)这些初始值被分别传入两个明确的“input”节点(图中分别以a、b标识)。这些“input”节点的作用仅仅是传递它们的输入值——节点a接收到输入值5后,将同样的数值输出给节点c和节点d,节点b对其输入值3也完成同样的动作。

3)节点c代表乘法运算。它分别从节点a和b接收输入值5和3,并将运算结果15输出到节点e。与此同时,节点d对相同的两个输入执行加法运算,并将计算结果8传递给节点e。

4)最后,该数据流图的终点——节点e是另一个“add”节点。它接收输入值15和8,将两者相加,然后输出该数据流图的最终结果23。

下面说明为何上述图形表示看起来像是一组公式:

当a=5、b=3时,若要求解e,只需依次代入上述公式。

经过上述步骤,便完成了计算,这里有一些概念值得重点说明:

上述使用“input”节点的模式十分有用,因为这使得我们能够将单个输入值传递给大量后继节点。如果不这样做,客户(或传入这些初值的其他数据源)便不得不将输入值显式传递给数据流图中的多个节点。按照这种模式,客户只需保证一次性传入恰当的输入值,而如何对这些输入重复使用的细节便被隐藏起来。稍后,我们将对数据流图的抽象做更深入的探讨。

突击小测验。哪一个节点将首先执行运算?是乘法节点c还是加法节点d?答案是:无从知晓。仅凭上述数据流图,无法推知c和d中的哪一个节点将率先执行。有的读者可能会按照从左到右、自上而下的顺序阅读该数据流图,从而做出节点c先运行的假设。但我们需要指出,在该数据流图中,将节点d绘制在c的上方也未尝不可。也可能有些读者认为这些节点会并发执行,但考虑到各种实现细节或硬件的限制,实际情况往往并非总是如此。实际上,最好的方式是将它们的执行视为相互独立。由于节点c并不依赖于来自节点d的任何信息,所以节点c在完成自身的运算时无需关心节点d的状态如何。反之亦然,节点d也不需要任何来自节点c的信息。在本章稍后,还将对节点依赖关系进行更深入的介绍。

接下来,对上述数据流图稍做修改。

主要的变化有两点:

1)来自节点b的“input”值3现在也传递给了节点e。

2)节点e中的函数“add”被替换为“sum”,表明它可完成两个以上的数的加法运算。

你已经注意到,上图在看起来被其他节点“隔离”的两个节点之间添加了一条边。一般而言,任何节点都可将其输出传递给数据流图中的任意后继节点,而无论这两者之间发生了多少计算。数据流图甚至可以拥有下图所示的结构,它仍然是完全合法的。

通过这两个数据流图,想必你已能够初步感受到对数据流图的输入进行抽象所带来的好处。我们能够对数据流图中内部运算的精确细节进行操控,但客户只需了解将何种信息传递给那两个输入节点则可。我们甚至可以进一步抽象,将上述数据流图表示为如下的黑箱。

这样,我们便可将整个节点序列视为拥有一组输入和输出的离散构件。这种抽象方式使得对级联在一起的若干个运算组进行可视化更加容易,而无需关心每个部件的具体细节。

3.1.2 节点的依赖关系

在数据流图中,节点之间的某些类型的连接是不被允许的,最常见的一种是将造成循环依赖(circular dependency)的连接。为理解“循环依赖”这个概念,需要先理解何为“依赖关系”。再次观察下面的数据流图。

循环依赖这个概念其实非常简单:对于任意节点A,如果其输出对于某个后继节点B的计算是必需的,则称节点A为节点B的依赖节点。如果某个节点A和节点B彼此不需要来自对方的任何信息,则称两者是独立的。为对此进行可视化,首先观察当乘法节点c出于某种原因无法完成计算时会出现何种情况。

可以预见,由于节点e需要来自节点c的输出,因此其运算无法执行,只能无限等待节点c的数据的到来。容易看出,节点c和节点d均为节点e的依赖节点,因为它们均将信息直接传递到最后的加法函数。然而,稍加思索便可看出节点a和节点b也是节点e的依赖节点。如果输入节点中有一个未能将其输入传递给数据流图中的下一个函数,情形会怎样?

可以看出,若将输入中的某一个移除,会导致数据流图中的大部分运算中断,从而表明依赖关系具有传递性。即,若A依赖于B,而B依赖于C,则A依赖于C。在本例中,最终节点e依赖于节点c和节点d,而节点c和节点d均依赖于输入节点b。因此,最终节点e也依赖于输入节点b。同理可知节点e也依赖于输入节点a。此外,还可对节点e的不同依赖节点进行区分:

1)称节点e直接依赖于节点c和节点d。即为使节点e的运算得到执行,必须有直接来自节点c和节点d的数据。

2)称节点e间接依赖于节点a和节点b。这表示节点a和节点b的输出并未直接传递到节点e,而是传递到某个(或某些)中间节点,而这些中间节点可能是节点e的直接依赖节点,也可能是间接依赖节点。这意味着一个节点可以是被许多层的中间节点相隔的另一个节点的间接依赖节点(且这些中间节点中的每一个也是后者的依赖节点)。

最后来观察将数据流图的输出传递给其自身的某个位于前端的节点时会出现何种情况。

不幸的是,上面的数据流图看起来无法工作。我们试图将节点e的输出送回节点b,并希望该数据流图的计算能够循环进行。这里的问题在于节点e现在变为节点b的直接依赖节点;而与此同时,节点e仍然依赖于节点b(前文已说明过)。其结果是节点b和节点e都无法得到执行,因为它们都在等待对方计算的完成。

也许你非常聪明,决定将传递给节点b或节点e的值设置为某个初始状态值。毕竟,这个数据流图是受我们控制的。不妨假设节点e的输出的初始状态值为1,使其先工作起来。

上图给出了经过几轮循环各数据流图中各节点的状态。新引入的依赖关系制造了一个无穷反馈环,且该数据流图中的大部分边都趋向于无穷大。然而,出于多种原因,对于像TensorFlow这样的软件,这种类型的无限循环是非常不利的。

1)由于数据流图中存在无限循环,因此程序无法以优雅的方式终止。

2)依赖节点的数量变为无穷大,因为每轮迭代都依赖于之前的所有轮次的迭代。不幸的是,在统计依赖关系时,每个节点都不会只被统计一次,每当其输出发生变化时,它便会被再次记为依赖节点。这就使得追踪依赖信息变得不可能,而出于多种原因(详见本节的最后一部分),这种需求是至关重要的。

3)你经常会遇到这样的情况:被传递的值要么在正方向变得非常大(从而导致上溢),要么在负方向变得非常大(导致下溢),或者非常接近于0(使得每轮迭代在加法上失去意义)。

基于上述考虑,在TensorFlow中,真正的循环依赖关系是无法表示的,这并非坏事。在实际使用中,完全可通过对数据流图进行有限次的复制,然后将它们并排放置,并将代表相邻迭代轮次的副本的输出与输入串接。该过程通常被称为数据流图的“展开”(unrolling)。第6章还将对此进行更为详细的介绍。为了以图形化的方式展示数据流图的展开效果,下面给出一个将循环依赖展开5次后的数据流图。

对这个数据流图进行分析,便会发现这个由各节点和边构成的序列等价于将之前的数据流图遍历5次。请注意原始输入值(以数据流图顶部和底部的跳跃箭头表示)是传递给数据流图的每个副本的,因为代表每轮迭代的数据流图的每个副本都需要它们。按照这种方式将数据流图展开,可在保持确定性计算的同时模拟有用的循环依赖。

既然我们已理解了节点的依赖关系,接下来便可分析为什么追踪这种依赖关系十分有用。不妨假设在之前的例子中,我们只希望得到节点c(乘法节点)的输出。我们已经定义了完整的数据流图,其中包含独立于节点c和节点e(出现在节点c的后方)的节点d,那么是否必须执行整个数据流图的所有运算,即便并不需要节点d和节点e的输出?答案当然是否定的。观察该数据流图,不难发现,如果只需要节点c的输出,那么执行所有节点的运算便是浪费时间。但这里的问题在于:如何确保计算机只对必要的节点执行运算,而无需手工指定?答案是:利用节点之间的依赖关系!

这背后的概念相当简单,我们唯一需要确保的是为每个节点的直接(而非间接)依赖节点维护一个列表。可从一个空栈开始,它最终将保存所有我们希望运行的节点。从你希望获得其输出的节点开始。显然它必须得到执行,因此令其入栈。接下来查看该输出节点的依赖节点列表,这意味着为计算输出,那些节点必须运行,因此将它们全部入栈。然后,对所有那些节点进行检查,看它们的直接依赖节点有哪些,然后将它们全部入栈。继续这种追溯模式,直到数据流图中的所有依赖节点均已入栈。按照这种方式,便可保证我们获得运行该数据流图所需的全部节点,且只包含所有必需的节点。此外,利用上述栈结构,可对其中的节点进行排序,从而保证当遍历该栈时,其中的所有节点都会按照一定的次序得到运行。唯一需要注意的是需要追踪哪些节点已经完成了计算,并将它们的输出保存在内存中,以避免对同一节点反复计算。按照这种方式,便可确保计算量尽可能地精简,从而在规模较大的数据流图上节省以小时计的宝贵处理时间。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文