飞桨深度学习平台设计之“道”

当读者习惯使用飞桨框架后会发现程序呈现出“八股文”的形态,即不同的程序员、使用不同模型、解决不同任务的时候,他们编写的建模程序是极其相似的。虽然这些设计在某些“极客”的眼里缺乏精彩,但从实用性的角度,我们更期望建模者聚焦需要解决的任务,而不是将精力投入在框架的学习上。因此使用飞桨编写模型是有标准的套路设计的,只要通过一个示例程序掌握使用飞桨的方法,编写不同任务的多种建模程序将变得十分容易。

这点与Python的设计思想一致:对于某个特定功能,并不是实现方式越灵活、越多样越好,最好只有一种符合“道”的最佳实现。此处“道”指的是如何更加匹配人的思维习惯。当程序员第一次看到Python的多种应用方式时,感觉程序天然就应该如此实现。但相信我,不是所有的编程语言都具备这样合“道”的设计,很多编程语言的设计思路是人需要去理解机器的运作原理,而不能以人类习惯的方式设计程序。同时,灵活意味着复杂,会增加程序员之间的沟通难度,也不适合现代工业化生产软件的趋势。

飞桨设计的初衷不仅要易于学习,还期望使用者能够体会到它的美感和哲学,与人类最自然的认知和使用习惯契合。

使用飞桨构建波士顿房价预测模型

本书中的案例覆盖预测任务、推荐系统、计算机视觉和自然语言处理等主流应用场景,所有案例的代码结构完全一致,如 图1 所示。

使用飞桨重写房价预测模型 - 图1

图1:使用飞桨框架构建神经网络过程

在之前的章节中,我们学习了使用Python和Numpy构建波士顿房价预测模型的方法,本节课我们将尝试使用飞桨重写房价预测模型,大家可以体会一下二者的异同。在数据处理之前,需要先加载飞桨框架的相关类库。

  1. #加载飞桨、Numpy和相关类库
  2. import paddle
  3. import paddle.fluid as fluid
  4. import paddle.fluid.dygraph as dygraph
  5. from paddle.fluid.dygraph import Linear
  6. import numpy as np
  7. import os
  8. import random

代码中参数含义如下:

  • paddle/fluid:飞桨的主库,目前大部分的实用函数均在paddle.fluid包内。
  • dygraph:动态图的类库。
  • FC:神经网络的全连接层函数,即包含所有输入权重相加和激活函数的基本神经元结构。在房价预测任务中,使用只有一层的神经网络(全连接层)来实现线性回归模型。

说明:

飞桨支持两种深度学习建模编写方式,更方便调试的动态图模式和性能更好并便于部署的静态图模式。

  • 静态图模式(声明式编程范式,类比C++):先编译后执行的方式。用户需预先定义完整的网络结构,再对网络结构进行编译优化后,才能执行获得计算结果。
  • 动态图模式(命令式编程范式,类比Python):解析式的执行方式。用户无需预先定义完整的网络结构,每写一行网络代码,即可同时获得计算结果。

为了学习模型和调试的方便,本教程均使用动态图模式编写模型。在后续的资深教程中,会详细介绍静态图以及将动态图模型转成静态图的方法。仅在部分场景下需要模型转换,并且是相对容易的。


数据处理

数据处理的代码不依赖框架实现,与使用Python构建房价预测任务的代码相同(详细解读请参考1-2章),这里不再赘述。

  1. def load_data():
  2. # 从文件导入数据
  3. datafile = './work/housing.data'
  4. data = np.fromfile(datafile, sep=' ')
  5. # 每条数据包括14项,其中前面13项是影响因素,第14项是相应的房屋价格中位数
  6. feature_names = [ 'CRIM', 'ZN', 'INDUS', 'CHAS', 'NOX', 'RM', 'AGE', \
  7. 'DIS', 'RAD', 'TAX', 'PTRATIO', 'B', 'LSTAT', 'MEDV' ]
  8. feature_num = len(feature_names)
  9. # 将原始数据进行Reshape,变成[N, 14]这样的形状
  10. data = data.reshape([data.shape[0] // feature_num, feature_num])
  11. # 将原数据集拆分成训练集和测试集
  12. # 这里使用80%的数据做训练,20%的数据做测试
  13. # 测试集和训练集必须是没有交集的
  14. ratio = 0.8
  15. offset = int(data.shape[0] * ratio)
  16. training_data = data[:offset]
  17. # 计算train数据集的最大值,最小值,平均值
  18. maximums, minimums, avgs = training_data.max(axis=0), training_data.min(axis=0), \
  19. training_data.sum(axis=0) / training_data.shape[0]
  20. # 记录数据的归一化参数,在预测时对数据做归一化
  21. global max_values
  22. global min_values
  23. global avg_values
  24. max_values = maximums
  25. min_values = minimums
  26. avg_values = avgs
  27. # 对数据进行归一化处理
  28. for i in range(feature_num):
  29. #print(maximums[i], minimums[i], avgs[i])
  30. data[:, i] = (data[:, i] - avgs[i]) / (maximums[i] - minimums[i])
  31. # 训练集和测试集的划分比例
  32. #ratio = 0.8
  33. #offset = int(data.shape[0] * ratio)
  34. training_data = data[:offset]
  35. test_data = data[offset:]
  36. return training_data, test_data

模型设计

模型定义的实质是定义线性回归的网络结构,飞桨建议通过创建Python类的方式完成模型网络的定义,即定义init函数和forward函数。forward函数是框架指定实现前向计算逻辑的函数,程序在调用模型实例时会自动执行forward方法。在forward函数中使用的网络层需要在init函数中声明。

实现过程分如下两步:

  • 定义init函数:在类的初始化函数中声明每一层网络的实现函数。在房价预测模型中,只需要定义一层全连接层FC,模型结构和1-2 节模型保持一致。
  • 定义forward函数:构建神经网络结构,实现前向计算过程,并返回预测结果,在本任务中返回的是房价预测结果。

说明:

name_scope变量用于调试模型时追踪多个模型的变量,在此忽略即可,飞桨1.7及之后版本不强制用户设置name_scope


  1. class Regressor(fluid.dygraph.Layer):
  2. def __init__(self, name_scope):
  3. super(Regressor, self).__init__(name_scope)
  4. name_scope = self.full_name()
  5. # 定义一层全连接层,输出维度是1,激活函数为None,即不使用激活函数
  6. self.fc = Linear(input_dim=13, output_dim=1, act=None)
  7. # 网络的前向计算函数
  8. def forward(self, inputs):
  9. x = self.fc(inputs)
  10. return x

训练配置

训练配置过程包含四步,如 图2 所示:

使用飞桨重写房价预测模型 - 图2

图2:训练配置流程示意图

  • grard函数指定运行训练的机器资源,表明在with作用域下的程序均执行在本机的CPU资源上。dygraph.guard表示在with作用域下的程序会以飞桨动态图的模式执行(实时执行)。
  • 声明定义好的回归模型Regressor实例,并将模型的状态设置为训练。
  • 使用load_data函数加载训练数据和测试数据。
  • 设置优化算法和学习率,优化算法采用随机梯度下降SGD,学习率设置为0.01。

训练配置代码如下所示:

  1. # 定义飞桨动态图的工作环境
  2. with fluid.dygraph.guard():
  3. # 声明定义好的线性回归模型
  4. model = Regressor("Regressor")
  5. # 开启模型训练模式
  6. model.train()
  7. # 加载数据
  8. training_data, test_data = load_data()
  9. # 定义优化算法,这里使用随机梯度下降-SGD
  10. # 学习率设置为0.01
  11. opt = fluid.optimizer.SGD(learning_rate=0.01, parameter_list=model.parameters())

说明:

  • 默认本案例运行在读者的笔记本上,因此模型训练的机器资源为CPU。
  • 模型实例有两种状态:训练状态(.train())和预测状态(.eval())。训练时要执行正向计算和反向传播梯度两个过程,而预测时只需要执行正向计算。为模型指定运行状态,有两点原因:

(1)部分高级的算子(例如Drop out和Batch Normalization,在计算机视觉的章节会详细介绍)在两个状态执行的逻辑不同。

(2)从性能和存储空间的考虑,预测状态时更节省内存,性能更好。

  • 在上述代码中可以发现声明模型、定义优化器等操作都在with创建的 fluid.dygraph.guard()上下文环境中进行,可以理解为with fluid.dygraph.guard()创建了飞桨动态图的工作环境,在该环境下完成模型声明、数据转换及模型训练等操作。

在基于Python实现神经网络模型的案例中,我们为实现梯度下降编写了大量代码,而使用飞桨框架只需要定义SDG就可以实现优化器设置,大大简化了这个过程。

训练过程

训练过程采用二层循环嵌套方式:

  • 内层循环: 负责整个数据集的一次遍历,采用分批次方式(batch)。假设数据集样本数量为1000,一个批次有10个样本,则遍历一次数据集的批次数量是1000/10=100,即内层循环需要执行100次。
  1. for iter_id, mini_batch in enumerate(mini_batches):
  • 外层循环: 定义遍历数据集的次数,通过参数EPOCH_NUM设置。
  1. for epoch_id in range(EPOCH_NUM):

说明:

batch的取值会影响模型训练效果。batch过大,会增大内存消耗和计算时间,且效果并不会明显提升;batch过小,每个batch的样本数据将没有统计意义。由于房价预测模型的训练数据集较小,我们将batch为设置10。


每次内层循环都需要执行如下四个步骤,如 图3 所示,计算过程与使用Python编写模型完全一致。

使用飞桨重写房价预测模型 - 图3

图3:内循环计算过程

  • 数据准备:将一个批次的数据转变成np.array和内置格式。
  • 前向计算:将一个批次的样本数据灌入网络中,计算输出结果。
  • 计算损失函数:以前向计算结果和真实房价作为输入,通过损失函数square_error_cost计算出损失函数值(Loss),API可参考square_error_cost。飞桨所有的API接口都有完整的说明和使用案例,在后续的资深教程中我们会详细介绍API的查阅方法。
  • 反向传播:执行梯度反向传播backward函数,即从后到前逐层计算每一层的梯度,并根据设置的优化算法更新参数opt.minimize
  1. with dygraph.guard(fluid.CPUPlace()):
  2. EPOCH_NUM = 10 # 设置外层循环次数
  3. BATCH_SIZE = 10 # 设置batch大小
  4. # 定义外层循环
  5. for epoch_id in range(EPOCH_NUM):
  6. # 在每轮迭代开始之前,将训练数据的顺序随机的打乱
  7. np.random.shuffle(training_data)
  8. # 将训练数据进行拆分,每个batch包含10条数据
  9. mini_batches = [training_data[k:k+BATCH_SIZE] for k in range(0, len(training_data), BATCH_SIZE)]
  10. # 定义内层循环
  11. for iter_id, mini_batch in enumerate(mini_batches):
  12. x = np.array(mini_batch[:, :-1]).astype('float32') # 获得当前批次训练数据
  13. y = np.array(mini_batch[:, -1:]).astype('float32') # 获得当前批次训练标签(真实房价)
  14. # 将numpy数据转为飞桨动态图variable形式
  15. house_features = dygraph.to_variable(x)
  16. prices = dygraph.to_variable(y)
  17. # 前向计算
  18. predicts = model(house_features)
  19. # 计算损失
  20. loss = fluid.layers.square_error_cost(predicts, label=prices)
  21. avg_loss = fluid.layers.mean(loss)
  22. if iter_id%20==0:
  23. print("epoch: {}, iter: {}, loss is: {}".format(epoch_id, iter_id, avg_loss.numpy()))
  24. # 反向传播
  25. avg_loss.backward()
  26. # 最小化loss,更新参数
  27. opt.minimize(avg_loss)
  28. # 清除梯度
  29. model.clear_gradients()
  30. # 保存模型
  31. fluid.save_dygraph(model.state_dict(), 'LR_model')
  1. epoch: 0, iter: 0, loss is: [0.2819261]
  2. epoch: 0, iter: 20, loss is: [0.09325473]
  3. epoch: 0, iter: 40, loss is: [0.31836104]
  4. epoch: 1, iter: 0, loss is: [0.06697255]
  5. epoch: 1, iter: 20, loss is: [0.14381303]
  6. epoch: 1, iter: 40, loss is: [0.02925108]
  7. epoch: 2, iter: 0, loss is: [0.03091546]
  8. epoch: 2, iter: 20, loss is: [0.17063697]
  9. epoch: 2, iter: 40, loss is: [0.1874596]
  10. epoch: 3, iter: 0, loss is: [0.12090156]
  11. epoch: 3, iter: 20, loss is: [0.0605287]
  12. epoch: 3, iter: 40, loss is: [0.06634661]
  13. epoch: 4, iter: 0, loss is: [0.06475429]
  14. epoch: 4, iter: 20, loss is: [0.06778971]
  15. epoch: 4, iter: 40, loss is: [0.32411507]
  16. epoch: 5, iter: 0, loss is: [0.08117181]
  17. epoch: 5, iter: 20, loss is: [0.06476147]
  18. epoch: 5, iter: 40, loss is: [0.00810404]
  19. epoch: 6, iter: 0, loss is: [0.02915803]
  20. epoch: 6, iter: 20, loss is: [0.10254985]
  21. epoch: 6, iter: 40, loss is: [0.07706425]
  22. epoch: 7, iter: 0, loss is: [0.10288523]
  23. epoch: 7, iter: 20, loss is: [0.12237016]
  24. epoch: 7, iter: 40, loss is: [0.0179625]
  25. epoch: 8, iter: 0, loss is: [0.03814844]
  26. epoch: 8, iter: 20, loss is: [0.1533469]
  27. epoch: 8, iter: 40, loss is: [0.0867041]
  28. epoch: 9, iter: 0, loss is: [0.08161684]
  29. epoch: 9, iter: 20, loss is: [0.09394293]
  30. epoch: 9, iter: 40, loss is: [0.24671933]

这个实现过程令人惊喜,前向计算、计算损失和反向传播梯度,每个操作居然只有1-2行代码即可实现!我们再也不用一点点的实现模型训练的细节,这就是使用飞桨框架的威力!

保存并测试模型

保存模型

将模型当前的参数数据model.state_dict()保存到文件中(通过参数指定保存的文件名 LR_model),以备预测或校验的程序调用,代码如下所示。

  1. # 定义飞桨动态图工作环境
  2. with fluid.dygraph.guard():
  3. # 保存模型参数,文件名为LR_model
  4. fluid.save_dygraph(model.state_dict(), 'LR_model')
  5. print("模型保存成功,模型参数保存在LR_model中")
  1. 模型保存成功,模型参数保存在LR_model

理论而言,直接使用模型实例即可完成预测,而本教程中预测的方式为什么是先保存模型,再加载模型呢?这是因为在实际应用中,训练模型和使用模型往往是不同的场景。模型训练通常使用大量的线下服务器(不对外向企业的客户/用户提供在线服务),而模型预测则通常使用线上提供预测服务的服务器,或者将已经完成的预测模型嵌入手机或其他终端设备中使用。因此本教程的讲解方式更贴合真实场景的使用方法。

回顾下基于飞桨实现的房价预测模型,实现效果与之前基于Python实现的模型没有区别,但两者的实现成本有天壤之别。飞桨的愿景是用户只需要了解模型的逻辑概念,不需要关心实现细节,就能搭建强大的模型。

测试模型

下面我们选择一条数据样本,测试下模型的预测效果。测试过程和在应用场景中使用模型的过程一致,主要可分成如下三个步骤:

  • 配置模型预测的机器资源。本案例默认使用本机,因此无需写代码指定。
  • 将训练好的模型参数加载到模型实例中。由两个语句完成,第一句是从文件中读取模型参数;第二句是将参数内容加载到模型。加载完毕后,需要将模型的状态调整为evalueation(校验)。上文中提到,训练状态的模型需要同时支持前向计算和反向传导梯度,模型的实现较为臃肿,而校验和预测状态的模型只需要支持前向计算,模型的实现更加简单,性能更好。
  • 将待预测的样本特征输入到模型中,打印输出的预测结果。

通过load_one_example函数实现从数据集中抽一条样本作为测试样本,具体实现代码如下所示。

  1. def load_one_example(data_dir):
  2. f = open(data_dir, 'r')
  3. datas = f.readlines()
  4. # 选择倒数第10条数据用于测试
  5. tmp = datas[-10]
  6. tmp = tmp.strip().split()
  7. one_data = [float(v) for v in tmp]
  8. # 对数据进行归一化处理
  9. for i in range(len(one_data)-1):
  10. one_data[i] = (one_data[i] - avg_values[i]) / (max_values[i] - min_values[i])
  11. data = np.reshape(np.array(one_data[:-1]), [1, -1]).astype(np.float32)
  12. label = one_data[-1]
  13. return data, label
  1. with dygraph.guard():
  2. # 参数为保存模型参数的文件地址
  3. model_dict, _ = fluid.load_dygraph('LR_model')
  4. model.load_dict(model_dict)
  5. model.eval()
  6. # 参数为数据集的文件地址
  7. test_data, label = load_one_example('./work/housing.data')
  8. # 将数据转为动态图的variable格式
  9. test_data = dygraph.to_variable(test_data)
  10. results = model(test_data)
  11. # 对结果做反归一化处理
  12. results = results * (max_values[-1] - min_values[-1]) + avg_values[-1]
  13. print("Inference result is {}, the corresponding label is {}".format(results.numpy(), label))
  1. Inference result is [[17.563766]], the corresponding label is 19.7

通过比较“模型预测值”和“真实房价”可见,模型的预测效果与真实房价接近。房价预测仅是一个最简单的模型,使用飞桨编写均可事半功倍。那么对于工业实践中更复杂的模型,使用飞桨节约的成本是不可估量的。同时飞桨针对很多应用场景和机器资源做了性能优化,在功能和性能上远强于自行编写的模型。

从下一章开始,我们就将通过“手写数字识别”的案例,完整的掌握使用飞桨编写模型的方方面面。

作业1-6

  • AI studio上阅读房价预测案例(两个版本)的代码,并运行观察效果。
  • 在本机或服务器上安装Python、jupyter和Paddle,运行房价预测的案例(两个版本),并观察运行效果。
  • 想一想:基于Python编写的模型和基于飞桨编写的模型在存在哪些异同?如程序结构,编写难易度,模型的预测效果,训练的耗时等等。