\u200E

在前面章节,我们已经熟悉了动态图的编程方法,并通过一个实例对比,初步了解了两者的差异。接下来侧重于介绍静态图的编程方法。 首先回顾一下前面章节给出的对比实例,如下所示:

import paddle.fluid as fluid

# 在动态图模式下调用fill_constant操作
with fluid.dygraph.guard():
    data = fluid.layers.fill_constant(shape=[3, 4], value=16, dtype='int64')
    # 在上面代码运行后,‘data’中已经存储了一个shape为[3, 4]的Tensor,并且每个元素的值都是16
    # 对‘data’进行print操作,可以看到其中具体的数据值
    print('In DyGraph mode, after calling layers.fill_constant, data = ', data)

print('-------------------------------------------------')

# 在静态图模式下调用fill_constant操作
data = fluid.layers.fill_constant(shape=[3, 4], value=16, dtype='int64')
# 在上面代码运行后,‘data’中并没有实际数据,
# 仅仅是“记录”了在此处有一个Tensor,其shape应该为[3, 4],但并没有为其赋值,也没有为其分配空间
# 此时对‘data’进行print操作,仅能看到一个对Tensor的结构描述信息
print('In static mode, after calling layers.fill_constant, data = ', data)

# 下面代码用来实际“执行”操作

# 与动态图的细节区别:
# 在动态图模式下,默认会自动选择运行设备(CPU或者GPU),但在静态图模式下,需要用户指定运行设备
# 此处调用fluid.CPUPlace() API来指定在CPU设备上运行程序
place = fluid.CPUPlace()
# 创建“执行器”,并用place参数指明需要在何种设备上运行
exe = fluid.Executor(place=place)
# 初始化操作,包括为所有变量分配空间等,比如上面的‘data’,在下面这行代码执行后才会被分配实际的内存空间
exe.run(fluid.default_startup_program())
# 使用执行器“执行”已经记录的所有操作,在本例中即执行fill_constant操作为data变量赋值
# 在调用执行器的run接口时,可以通过fetch_list参数来指定要获取哪些变量的计算结果,这里我们要获取‘data’的结果
data_after_run = exe.run(fetch_list=[data])
# 此时我们打印执行器返回的结果,可以看到“执行”后,Tensor中的数据已经被赋值,每个元素的值都是16
print('data after run:', data_after_run)

从上面的例子中我们可以看出:

  • 动态图的优势在于所有操作在运行时就已经完成,更接近我们平时的编程方式,可随时获取每一个操作的执行结果,使得程序易于调试;静态图在过程中并没有实际执行操作,上述例子中可以看到过程中只能打印声明的类型,最后需要调用执行器来统一执行所有操作,计算结果需要通过执行器统一返回,这使得调试变得困难。
  • 但静态图的优势就在于,在“运行时”所有操作和执行顺序都已经定义完成,能够根据全局信息来做各种优化策略,比如合并相邻操作来进行加速或者减少中间变量,因此对于同样的网络结构,使用静态图模型运行往往能够获取更好的性能和更少的内存占用。比较典型的如循环神经网络语言模型(具体实现代码请参考:动态图实现静态图实现),由于使用了LSTM结构,动态图在执行过程中,不可避免的会在Python API和底层C++高性能计算库之间频繁切换执行,而静态图由于执行期几乎全部由C++高性能库完成(Python API的调用基本都集中在编译期完成),所以对于此类网络模型,以及类似的使用了LSTM,RNN、GRU等结构的网络来说,静态图一般都能在性能方面体现较大的优势。

  另一方面,对于一些复杂的使用场景(比如模型中需要用到与数据相关的控制流操作,可参见下文组建复杂网络的示例),目前动态图模型还无法很好的支持使用飞桨高性能预测库进行预测部署,这对性能有较高要求的任务来说是不可接受的。在这种场景下,我们使用动态图模型完成模型的调试和训练之后,就需要使用静态图方式来重新实现该模型。这样做能够最大化的利用动态图和静态图的优势:在模型开发调试阶段使用动态图,以获得更好的调试能力和更优的编程体验;在模型开发完成后,利用静态图获得高性能预测引擎的部署能力,以提高在线预测的性能,缩短在线响应时间或节约在线计算资源。
  比较典型的如NLP领域应用比较多的Transformer模型,在预测(解码)阶段的Beam Search算法中,需要使用循环操作来进行候选的选取与生成,在动态图实现中使用了Python的for语句来完成该操作,但目前飞桨的高性能预测库还不支持对这类控制流的转化,所以需要使用静态图实现Transformer,再进行预测部署的工作。大家在看完本篇内容后,也可以对比Transformer的动态图实现静态图实现中的Beam Search部分,作为进阶学习的例子。

接下来开始介绍静态图的编程方法,包括如下几部分:

  1. 在飞桨静态图编程方式中,如何表示和定义数据变量。
  2. 如何使用静态图组建一个深度学习网络并进行训练。
  3. 以一个简单的例子来说明,如何根据一个动态图网络来实现其静态图版本。

静态图的数据表示和定义

与动态图一样,静态图也使用变量和常量来表示数据,但是由于在调用执行器之前,静态图并不执行实际操作(这个阶段一般称为“组网阶段”或者“编译阶段”),因此也不会在此时读入数据,所以在静态图中还需要一种特殊的变量来表示输入数据,一般称为“占位符”。在飞桨中我们使用 fluid.data 来创建占位符,fluid.data需要指定Tensor的形状信息和数据类型,当遇到无法确定的维度时,可以将相应维度指定为None,如下面的代码片段所示:

import paddle.fluid as fluid

# 定义一个数据类型为int64的二维数据变量x,x第一维的维度为3,第二个维度未知,要在程序执行过程中才能确定,
# 因此x的形状可以指定为[3, None]
x = fluid.data(name="x", shape=[3, None], dtype="int64")
print('x =', x)

# 大多数网络都会采用batch方式进行数据组织,batch大小在定义时不确定,
# 因此batch所在维度(通常是第一维)可以指定为None
batched_x = fluid.data(name="batched_x", shape=[None, 3, None], dtype='int64')
print('batched_x =', batched_x)
x = name: "x"
type {
  type: LOD_TENSOR
  lod_tensor {
    tensor {
      data_type: INT64
      dims: 3
      dims: -1
    }
    lod_level: 0
  }
}
persistable: false
need_check_feed: true

batched_x = name: "batched_x"
type {
  type: LOD_TENSOR
  lod_tensor {
    tensor {
      data_type: INT64
      dims: -1
      dims: 3
      dims: -1
    }
    lod_level: 0
  }
}
persistable: false
need_check_feed: true

使用静态图组建网络

在飞桨中,数据计算类API统一称为Operator(算子),简称OP,大多数OP在 paddle.fluid.layers 模块中提供。下面是一个完整的静态图计算网络的例子,这个例子中我们定义了两个int64类型的输入数据a和b,并使用elementwise_add OP来对a、b进行“逐元素加和”的操作,完成一个简单的“result = a + b”运算。

import paddle.fluid as fluid

# 定义输入变量,即“占位符”,输入变量的第一维是batch大小,在运行期读入数据时才会确定,因此在这里以None表示
a = fluid.data(name="a", shape=[None, 1], dtype='int64')
b = fluid.data(name="b", shape=[None, 1], dtype='int64')

# 组建网络(此处网络仅由一个操作构成,即elementwise_add)
result = fluid.layers.elementwise_add(a,b)

# 准备运行网络
cpu = fluid.CPUPlace() # 定义运算设备,这里选择在CPU下训练
exe = fluid.Executor(cpu) # 创建执行器
exe.run(fluid.default_startup_program()) # 网络参数初始化

# 读取输入数据
import numpy
data_1 = int(input("Please enter an integer: a="))
data_2 = int(input("Please enter an integer: b="))
print('---------------------------------')
x = numpy.array([[data_1]])
y = numpy.array([[data_2]])

# 运行网络
outs = exe.run(
    feed={'a':x, 'b':y}, # 将输入数据x, y分别赋值给变量a,b
    fetch_list=[result] # 通过fetch_list参数指定需要获取的变量结果
    )

# 输出计算结果
print("%d+%d=%d" % (data_1, data_2, outs[0][0]))

组建更加复杂的网络

在动态图中,可以方便的使用Python的控制流语句(如for,if-else等)来进行条件判断,但是在静态图中,由于组网阶段并没有实际执行操作,也没有产生中间计算结果,因此无法使用Python的控制流语句来进行条件判断,为此静态图提供了多个控制流OP来实现条件判断。这里以fluid.layers.while_loop为例来说明如何在静态图中实现条件循环的操作。

while_loop API用于实现类似while/for的循环控制功能,使用一个callable的方法cond作为参数来表示循环的条件,只要cond的返回值为True,while_loop就会循环执行循环体body(也是一个callable的方法),直到 cond 的返回值为False。对于while_loop API的详细定义和具体说明请参考文档fluid.layers.while_loop

下面的例子中,使用while_loop API进行条件循环操作,其实现的功能相当于在python中实现如下代码:

i = 0
ten = 10
while i < ten:
    i = i + 1
print('i =', i)

在静态图中使用while_loop API实现以上代码的逻辑:

# 该示例代码展示整数循环+1,循环10次,输出计数结果
import paddle.fluid as fluid
import paddle.fluid.layers as layers

# 定义cond方法,作为while_loop的判断条件
def cond(i, ten):
    return i < ten 

# 定义body方法,作为while_loop的执行体,只要cond返回值为True,while_loop就会一直调用该方法进行计算
# 由于在使用while_loop OP时,cond和body的参数都是由while_loop的loop_vars参数指定的,所以cond和body必须有相同数量的参数列表,因此body中虽然只需要i这个参数,但是仍然要保持参数列表个数为2,此处添加了一个dummy参数来进行"占位"
def body(i, dummy):
    # 计算过程是对输入参数i进行自增操作,即 i = i + 1
    i = i + 1
    return i, dummy

i = layers.fill_constant(shape=[1], dtype='int64', value=0) # 循环计数器
ten = layers.fill_constant(shape=[1], dtype='int64', value=10) # 循环次数
out, ten = layers.while_loop(cond, body, [i, ten]) # while_loop的返回值是一个tensor列表,其长度,结构,类型与loop_vars相同

exe = fluid.Executor(fluid.CPUPlace())
res = exe.run(fluid.default_main_program(), feed={}, fetch_list=out)
print(res) #[array([10])]

限于篇幅,上面仅仅用一个最简单的例子来说明如何在静态图中实现循环操作,循环操作在很多应用中都有着重要作用,比如NLP中常用的Transformer模型,在解码(生成)阶段的Beam Search算法中,需要使用循环操作来进行候选的选取与生成,可以参考Transformer模型的实现来进一步学习while_loop在复杂场景下的用法。

除while_loop之外,飞桨还提供fluid.layers.cond API来实现条件分支的操作,以及fluid.layers.switch_case和fluid.layers.case API来实现分支控制功能,具体用法请参考文档:condswitch_casecase

根据动态图转写静态图代码

下面以一个经典的任务“波士顿房价预测”为例,来说明如何根据动态图代码来转写静态图代码。我们回顾一下这个任务:基于13种可能影响房价的因素X来预计房价Y,共有500多条数据样本,每个数据样本包含13个X(影响房价的特征)和一个Y(该类型房屋的均价)。

关于“波士顿房价预测”任务以及相关数据集的详细描述,可以参考文档使用飞桨重写【房价预测】模型

下面是一个使用动态图进行“波士顿房价预测”的例子:

import numpy
import paddle
import paddle.fluid as fluid

# 定义网络结构,该任务中使用线性回归模型,网络由一个FC层构成
class LinearRegression(fluid.dygraph.Layer):
    def __init__(self, input_dim, hidden):
        super(LinearRegression, self).__init__()
        self.linear = fluid.dygraph.Linear(input_dim, hidden)

    def forward(self, x):
        x = self.linear(x)
        return x


# 训练和预测的数据读取处理,这部分的用法动态图和静态图是一致的
batch_size = 20
train_reader = fluid.io.batch(fluid.io.shuffle(paddle.dataset.uci_housing.train(), 500), batch_size)
test_reader = fluid.io.batch(paddle.dataset.uci_housing.test(), batch_size)

# 波士顿房价预测任务中,共有13个特征
input_feature = 13

max_epoch_num = 100
with fluid.dygraph.guard():
    # 定义网络
    model = LinearRegression(input_dim=input_feature, hidden=1)
    #定义优化器
    sgd = fluid.optimizer.SGD(learning_rate=0.001, parameter_list=model.parameters() )
    
    # 执行max_epoch_num次训练
    for epoch in range(max_epoch_num):
        # 读取训练数据进行训练
        for batch_id, data in enumerate(train_reader()):
            x_data = numpy.array([x[0] for x in data], dtype=numpy.float32)
            y_data = numpy.array([x[1] for x in data], dtype=numpy.float32)

            # 将numpy数据转换为网络训练所需的Tensor数据
            x_var = fluid.dygraph.to_variable(x_data)
            y_var = fluid.dygraph.to_variable(y_data)

            # 调用网络,执行前向计算
            prediction = model(x_var)
            
            # 计算损失值
            loss = fluid.layers.square_error_cost(prediction, y_var)
            avg_loss = fluid.layers.mean(loss)

            if batch_id % 10 == 0 and batch_id is not 0:
                print("epoch: {}, batch_id: {}, loss is: {}".format(epoch, batch_id, avg_loss.numpy()))
            
            # 执行反向计算,并调用minimize接口计算和更新梯度
            avg_loss.backward()
            sgd.minimize(avg_loss)
            
            # 将本次计算的梯度值清零,以便进行下一次迭代和梯度更新
            model.clear_gradients()

    # 训练结束,保存训练好的模型
    fluid.dygraph.save_dygraph(model.state_dict(), 'dygraph_linear')

with fluid.dygraph.guard():
    linear_infer = LinearRegression(input_dim=input_feature, hidden=1)
    # 加载之前已经训练好的模型准备进行预测
    model_dict, _ = fluid.dygraph.load_dygraph("dygraph_linear")
    linear_infer.set_dict(model_dict)
    print("checkpoint loaded.")

    # 开启评估测试模式(区别于训练模式)
    linear_infer.eval()
    infer_data = next(test_reader())

    infer_x_data = numpy.array([x[0] for x in infer_data], dtype=numpy.float32)
    infer_y_data = numpy.array([x[1] for x in infer_data], dtype=numpy.float32)

    infer_x_var = fluid.dygraph.to_variable(infer_x_data)
    infer_y_var = fluid.dygraph.to_variable(infer_y_data)

    infer_result = linear_infer(infer_x_var)

    print("id: prediction ground_truth")
    for idx, val in enumerate(infer_result.numpy()):
        print("%d: %.2f %.2f" % (idx, val, infer_y_data[idx]))

对应的静态图实现方式如下:

import paddle
import paddle.fluid as fluid
import numpy


# 定义网络结构,该任务中使用线性回归模型,网络由一个fc层构成
def linear_regression_net(input, hidden):
    #区别1:在静态图中要使用静态图对应的OP,与fluid.dygraph.Linear对应的是fluid.layers.fc,其用法如下,更详细的OP使用方法说明可以参考飞桨官网的API文档
    out = fluid.layers.fc(input, hidden)
    return out


# 训练和预测的数据读取处理与动态图一致
batch_size = 20
train_reader = fluid.io.batch(fluid.io.shuffle(paddle.dataset.uci_housing.train(), 500), batch_size)
test_reader = fluid.io.batch(paddle.dataset.uci_housing.test(), batch_size)

max_epoch_num = 100

# 区别2:在静态图中需要明确定义输入变量,即“占位符”,在静态图组网阶段并没有读入数据,所以需要使用占位符指明输入数据的类型,shape等信息
# 波士顿房价预测任务中,共有13个特征,数据以batch形式组织,batch大小在定义时可以不确定,用None表示,因此shape=[None, 13]
x = fluid.data(name='x', shape=[None, 13], dtype='float32')
y = fluid.data(name='y', shape=[None, 1], dtype='float32')

# 调用网络,执行前向计算
prediction = linear_regression_net(x, 1)

# 计算损失值
loss = fluid.layers.square_error_cost(input=prediction, label=y)
avg_loss = fluid.layers.mean(loss)

# 定义优化器,并调用minimize接口计算和更新梯度
sgd_optimizer = fluid.optimizer.SGD(learning_rate=0.001)
sgd_optimizer.minimize(avg_loss)

# 区别3:在静态图中需要用户显示指定运行设备,此处指定运行设备为CPU
place = fluid.CPUPlace()

# 区别4:静态图中需要使用执行器执行之前已经定义好的网络
exe = fluid.Executor(place)

num_epochs = 100

# 区别5:静态图中数据要经过DataFeeder转换才能在执行器中使用
# 也可以使用其他数据读取API如DataLoader,具体用法请参考DataLoader API的文档
feeder = fluid.DataFeeder(place=place, feed_list=[x, y])

# 区别6:静态图中需要显示对网络进行初始化操作
exe.run(fluid.default_startup_program())

for epoch in range(num_epochs):
    for batch_id, data_train in enumerate(train_reader()):
        # 区别7:静态图中需要调用执行器的run方法执行计算过程,需要获取的计算结果(如avg_loss)需要通过fetch_list指定
        avg_loss_value, = exe.run(feed=feeder.feed(data_train), fetch_list=[avg_loss])

        if batch_id % 10 == 0 and batch_id is not 0:
            print("epoch: {}, batch_id: {}, loss is: {}".format(epoch, batch_id, avg_loss_value))

# 区别8:静态图中需要使用save_inference_model来保存模型,以供预测使用
fluid.io.save_inference_model('static_linear', ['x'], [prediction], exe)


infer_exe = fluid.Executor(place)
inference_scope = fluid.core.Scope()

# 使用训练好的模型做预测
with fluid.scope_guard(inference_scope):
    # 区别9:静态图中需要使用load_inference_model来加载之前保存的模型
    [inference_program, feed_target_names, fetch_targets
     ] = fluid.io.load_inference_model('static_linear', infer_exe)

    infer_data = next(test_reader())
    infer_x = numpy.array([data[0] for data in infer_data]).astype("float32")
    infer_y = numpy.array([data[1] for data in infer_data]).astype("float32")

    # 区别10:静态图中预测时也需要调用执行器的run方法执行计算过程,并指定之前加载的inference_program
    results = infer_exe.run(
        inference_program,
        feed={feed_target_names[0]: numpy.array(infer_x)},
        fetch_list=fetch_targets)

    print("id: prediction ground_truth")
    for idx, val in enumerate(results[0]):
        print("%d: %.2f %.2f" % (idx, val, infer_y[idx]))

总结一下,动态图改写成静态图的时候,主要有以下几点区别:

  1. 使用的API不同:飞桨同时提供了动态图用法和静态图用法,两者可以共用大部分API,但需要注意还有一小部分API需要区分动态图和静态图。
    • 动态图中如果使用了fluid.dygraph下的OP,需要替换为对应的fluid.layers下的OP,比如在动态图中使用了卷积操作fluid.dygraph.Conv2D,那么在静态图中对应的OP为fluid.layers.conv2d。动态图和静态图对应OP中,大部分都是名称相同,用法基本相同,但也有个别OP存在名称不同,参数列表相差较大导致用法不同的情况。比如本例中用到的fluid.dygraph.Linear,其对应的静态图OP为fluid.layers.fc,名称、参数列表均不同,除计算类API之外,保存与加载模型的API也不同,具体用法差异通过对比以上两个示例可以看出。
    • 动态图中可以随意使用Python的控制流语句,但是在静态图中,当控制流中的判断条件与数据相关时(如前文提到while_loop的例子),需要转换为使用while_loop,cond,case,switch_case等几个专用的控制流API。
  2. 数据读取流程不同:动态图在程序运行时读入数据,与我们平时编写python等程序的习惯相同,但在静态图组网阶段并没有实际运行网络,因此并不读入数据,所以需要使用“占位符”(即fluid.data)指明输入数据的类型,shape等信息,以完成组网;另外,在静态图执行时,数据要经过DataFeeder转换才能在执行器中使用,也可以使用其他数据读取API如DataLoader,具体用法请参考DataLoader API的文档。
  3. 执行时期不同:动态图是“所见即所得”的执行方式,而静态图分为编译期和执行器,无论是训练还是预测,都需要使用执行器来执行网络,调用执行器时,需要指定运行设备、初始化、指定要获取的返回值等。