1.5 使用飞桨重写波士顿房价预测任务
1.5.1 飞桨设计之“道”
当读者使用飞桨框架编写多个深度学习模型后,会发现程序呈现出“八股文”的形态。即不同的程序员、使用不同模型、解决不同任务的时候,他们编写的建模程序是极其相似的。虽然这些设计在某些“极客”的眼里缺乏精彩,但从实用性的角度,我们更期望建模者聚焦需要解决的任务,而不是将精力投入在框架的学习上。因此使用飞桨编写模型是有标准的套路设计的,只要通过一个示例程序掌握使用飞桨的方法,编写不同任务的多种建模程序将变得十分容易。
这点与Python的设计思想一致:对于某个特定功能,并不是实现方式越灵活、越多样越好,最好只有一种符合“道”的最佳实现。此处“道”指的是如何更加匹配人的思维习惯。当程序员第一次看到Python的多种应用方式时,感觉程序天然就应该如此实现。但不是所有的编程语言都具备这样合“道”的设计,很多编程语言的设计思路是人需要去理解机器的运作原理,而不能以人类习惯的方式设计程序。同时,灵活意味着复杂,会增加程序员之间的沟通难度,也不适合现代工业化生产软件的趋势。
飞桨设计的初衷不仅要易于学习,还期望使用者能够体会到它的美感和哲学,与人类最自然的认知和使用习惯契合。
1.5.2 使用飞桨实现波士顿房价预测任务
本教程中的案例覆盖计算机视觉、自然语言处理和推荐系统等主流应用场景,使用飞桨实现这些案例的流程基本一致,如图1所示。
图1:使用飞桨框架构建神经网络过程
在之前的章节中,我们学习了使用Python和NumPy实现波士顿房价预测任务的方法,本章我们将尝试使用飞桨重写房价预测任务,体会二者的异同。在数据处理之前,需要先加载飞桨框架的相关类库。
In [1]
#加载飞桨、NumPy和相关类库 import paddle from paddle.nn import Linear import paddle.nn.functional as F import numpy as np import os import random
代码中参数含义如下:
- paddle:飞桨的主库,paddle 根目录下保留了常用API的别名,当前包括:paddle.tensor、paddle.device目录下的所有API。
- paddle.nn:组网相关的API,包括 Linear、卷积 Conv2D、循环神经网络LSTM、损失函数CrossEntropyLoss、激活函数ReLU等。
- Linear:神经网络的全连接层函数,包含所有输入权重相加的基本神经元结构。在房价预测任务中,使用只有一层的神经网络(全连接层)实现线性回归模型。
- paddle.nn.functional:与paddle.nn一样,包含组网相关的API,如:Linear、激活函数ReLU等,二者包含的同名模块功能相同,运行性能也基本一致。差别在于paddle.nn目录下的模块均是类,每个类自带模块参数;paddle.nn.functional目录下的模块均是函数,需要手动传入函数计算所需要的参数。在实际使用时,卷积、全连接层等本身具有可学习的参数,建议使用paddle.nn实现;而激活函数、池化等操作没有可学习参数,可以考虑使用paddle.nn.functional。
说明:
飞桨支持两种深度学习任务的代码编写方式,更方便调试的动态图模式和性能;更好并便于部署的静态图模式。
- 动态图模式(命令式编程范式,类比Python):解析式的执行方式。用户无需预先定义完整的网络结构,每写一行网络代码,即可同时获得计算结果。
- 静态图模式(声明式编程范式,类比C++):先编译后执行的方式。用户需预先定义完整的网络结构,再对网络结构进行编译优化后,才能执行获得计算结果。
飞桨框架2.0及之后的版本,默认使用动态图模式进行编码,同时提供了完备的动转静支持,开发者仅需添加一个装饰器( to_static ),飞桨会自动将动态图的程序转换为静态图的program,并使用该program训练并可保存静态模型以实现推理部署。
1.5.2.1 数据处理
数据处理的代码不依赖飞桨框架实现,与使用Python构建房价预测任务的代码相同,详细解读请参考本课程第1.3节:使用Python和NumPy构建神经网络模型,这里不再赘述。
In [2]
def load_data(): # 从文件导入数据 datafile = './work/housing.data' data = np.fromfile(datafile, sep=' ', dtype=np.float32) # 每条数据包括14项,其中前面13项是影响因素,第14项是相应的房屋价格中位数 feature_names = [ 'CRIM', 'ZN', 'INDUS', 'CHAS', 'NOX', 'RM', 'AGE', \ 'DIS', 'RAD', 'TAX', 'PTRATIO', 'B', 'LSTAT', 'MEDV' ] feature_num = len(feature_names) # 将原始数据进行Reshape,变成[N, 14]这样的形状 data = data.reshape([data.shape[0] // feature_num, feature_num]) # 将原始数据集拆分成训练集和测试集 # 使用80%的数据做训练,20%的数据做测试,测试集和训练集不能存在交集 ratio = 0.8 offset = int(data.shape[0] * ratio) training_data = data[:offset] # 计算训练集的最大值和最小值 maximums, minimums = training_data.max(axis=0), training_data.min(axis=0) # 记录数据的归一化参数,在预测时对数据进行归一化 global max_values global min_values max_values = maximums min_values = minimums # 对数据进行归一化处理 for i in range(feature_num): data[:, i] = (data[:, i] - min_values[i]) / (maximums[i] - minimums[i]) # 划分训练集和测试集 training_data = data[:offset] test_data = data[offset:] return training_data, test_data
In [3]
# 验证数据读取的正确性 training_data, test_data = load_data() print(training_data.shape) print(training_data[1,:])
(404, 14)
[2.35922547e-04 0.00000000e+00 2.62405723e-01 0.00000000e+00
1.72839552e-01 5.47997713e-01 7.82698274e-01 3.48961979e-01
4.34782617e-02 1.14822544e-01 5.53191364e-01 1.00000000e+00
2.04470202e-01 3.68888885e-01]
1.5.2.2 模型设计
模型设计的实质是定义线性回归的网络结构,建议通过创建Python类的方式构建模型,该类需要继承paddle.nn.Layer
父类,并且在类中定义init
函数和forward
函数。forward
是飞桨前向计算逻辑的函数,在调用模型实例时会自动执行,其使用的网络层需要在init
中声明。
init
函数:在类的初始化函数中声明每一层网络的实现函数。在房价预测任务中,只需要定义一层全连接层,模型结构和 第1.3节 保持一致。forward
函数:在构建神经网络时实现前向计算过程,并返回预测结果,在本任务中返回的是房价预测结果。
In [4]
class Regressor(paddle.nn.Layer): # self代表类的实例自身 def __init__(self): # 初始化父类中的一些参数 super(Regressor, self).__init__() # 定义一层全连接层,输入维度是13,输出维度是1 self.fc = Linear(in_features=13, out_features=1) # 网络的前向计算 def forward(self, inputs): x = self.fc(inputs) return x
1.5.2.3 训练配置
训练配置过程如图2所示:
图2:训练配置流程示意图
- 声明定义好的回归模型实例为Regressor,并将模型的状态设置为
train
。 - 使用
load_data
函数加载训练数据和测试数据。 - 设置优化算法和学习率,优化算法采用随机梯度下降,学习率设置为0.01。
训练配置的代码实现如下:
In [14]
# 声明定义好的线性回归模型 model = Regressor() # 开启模型训练模式,模型的状态设置为train model.train() # 使用load_data加载训练集数据和测试集数据 training_data, test_data = load_data() # 定义优化算法,采用随机梯度下降SGD # 学习率设置为0.01 opt = paddle.optimizer.SGD(learning_rate=0.01, parameters=model.parameters())
说明:
模型实例有两种状态:训练状态.train``()
和预测状态.eval``()
。训练时要执行前向计算和反向传播两个过程,而预测时只需要执行前向计算。为模型指定运行状态,有如下两点原因:
1)部分高级的算子在两个状态执行的逻辑不同,如Dropout
和BatchNorm
(在“计算机视觉”章节中详细介绍)。
2)从性能和存储空间的考虑,预测状态时更节省内存(无需记录反向梯度),性能更好。
在第1.3节,我们已经为实现梯度下降编写了大量代码,而使用飞桨只需要设置SGD`函数,即可实现梯度下降,大大简化了这个过程。
1.5.2.4 训练过程
模型的训练过程采用二层循环嵌套方式:
- 内层循环:按批大小(batch_size,即一次模型训练使用的样本数量),对数据集进行一次遍历,完成一轮模型训练。假设数据集的样本数量为1000,批大小是10),那么遍历一次数据集训练需要 1000 / 10 =100 次,代码实现为:
for iter_id, mini_batch in enumerate(mini_batches):
- 外层循环:定义遍历数据集的次数,即模型训练的轮次(epoch),代码实现为:
for epoch_id in range(EPOCH_NUM):
说明:
batch_size的取值大小会影响模型训练的效果。batch_size过大,模型训练速度越快,但会增大内存消耗,且训练效果并不会明显提升(每次参数只向梯度反方向移动一小步,因此方向没必要特别精确);batch_size过小,模型可以更快地收敛,但训练过程中的梯度方向可能存在较大偏差。因此,batch_size的大小需要结合具体的任务配置,由于房价预测模型的训练数据集较小,batch_size设置为10。
每次模型训练都需要执行如图3所示的步骤,计算过程与使用Python编写模型完全一致。
图3:模型训练的计算过程
- 数据准备:将数据先转换成np.array格式,再转换成张量(Tensor)。
- 前向计算:按batch_size大小,将数据灌入模型中,计算输出结果。
- 计算损失函数:以前向计算结果和真实房价作为输入,通过square_error_cost API计算损失函数。飞桨所有的API接口都有完整的说明和使用案例,可以登录 飞桨官网API文档 获取。
- 反向传播:执行梯度反向传播
backward
,即从后到前逐层计算每一层的梯度,并根据设置的优化算法更新参数opt.step
。
In [19]
epoch_num = 20 # 设置模型训练轮次 batch_size = 10 # 设置批大小,即一次模型训练使用的样本数量 # 定义模型训练轮次epoch(外层循环) for epoch_id in range(epoch_num): # 在每轮迭代开始之前,对训练集数据进行样本乱序 np.random.shuffle(training_data) # 对训练集数据进行拆分,batch_size设置为10 mini_batches = [training_data[k:k+batch_size] for k in range(0, len(training_data), batch_size)] # 定义模型训练(内层循环) for iter_id, mini_batch in enumerate(mini_batches): x = np.array(mini_batch[:, :-1]) # 将当前批的房价影响因素的数据转换为np.array格式 y = np.array(mini_batch[:, -1:]) # 将当前批的标签数据(真实房价)转换为np.array格式 # 将np.array格式的数据转为张量tensor格式 house_features = paddle.to_tensor(x, dtype='float32') prices = paddle.to_tensor(y, dtype='float32') # 前向计算 predicts = model(house_features) # 计算损失,损失函数采用平方误差square_error_cost loss = F.square_error_cost(predicts, label=prices) avg_loss = paddle.mean(loss) if iter_id%20==0: print("epoch: {}, iter: {}, loss is: {}".format(epoch_id, iter_id, avg_loss.numpy())) # 反向传播,计算每层参数的梯度值 avg_loss.backward() # 更新参数,根据设置好的学习率迭代一步 opt.step() # 清空梯度变量,进行下一轮计算 opt.clear_grad()
epoch: 0, iter: 0, loss is: [0.03909567]
epoch: 0, iter: 20, loss is: [0.03733185]
epoch: 0, iter: 40, loss is: [0.07072089]
...
epoch: 19, iter: 0, loss is: [0.03512848]
epoch: 19, iter: 20, loss is: [0.0183361]
epoch: 19, iter: 40, loss is: [0.04575376]
这个实现过程令人惊喜,前向计算、计算损失和反向传播梯度,每个操作只需要1~2行代码即可实现!飞桨已经帮助我们自动实现了反向梯度计算和参数更新的过程,不再需要逐一编写代码,这就是使用飞桨框架的威力!
1.5.2.5 模型保存和推理
(1)模型保存
使用paddle.save API将模型当前的参数 model.state_dict() 保存到文件中,用于模型模型评估或模型推理。
In [20]
# 保存模型参数,文件名为LR_model.pdparams paddle.save(model.state_dict(), 'LR_model.pdparams') print("模型保存成功, 模型参数保存在LR_model.pdparams中")
模型保存成功, 模型参数保存在LR_model.pdparams中
说明:
为什么要执行模型保存的操作,而不是直接使用训练好的模型进行推理呢?理论而言,直接使用模型实例即可完成模型推理,但是在实际应用中,模型训练和推理往往是不同的场景。模型训练通常使用大量的线下服务器(不向企业的客户/用户提供在线服务);模型推理则通常使用云端的推理服务器实现或者将已经训练好的模型嵌入手机或其他终端设备中使用。因此本教程中“先保存模型,再加载模型”的讲解方式更贴合真实场景的使用方法。
(2)模型推理
任意选择一条样本数据,测试模型的推理效果。推理过程和在应用场景中使用模型的过程一致,主要可分成如下三步:
1)配置模型推理的机器资源。本案例默认使用本机,因此无需代码指定。
2)将训练好的模型参数加载到模型实例中。先从文件中读取模型参数,再将参数加载到模型。加载后,将模型的状态调整为eval``()
。上文提到,训练状态的模型需要同时支持前向计算和反向梯度传播;而评估和推理状态的模型只需要支持前向计算,模型的实现更加简单,性能更好。
3)通过load_one_example
函数实现从数据集中抽一条样本作为测试样本。
实现代码如下:
In [21]
def load_one_example(): # 从测试集中随机选择一条作为推理数据 idx = np.random.randint(0, test_data.shape[0]) idx = -10 one_data, label = test_data[idx, :-1], test_data[idx, -1] # 将数据格式修改为[1,13] one_data = one_data.reshape([1,-1]) return one_data, label
In [22]
# 将模型参数保存到指定路径中 model_dict = paddle.load('LR_model.pdparams') model.load_dict(model_dict) # 将模型状态修改为.eval model.eval() one_data, label = load_one_example() # 将数据格式转换为张量 one_data = paddle.to_tensor(one_data,dtype="float32") predict = model(one_data) # 对推理结果进行后处理 predict = predict * (max_values[-1] - min_values[-1]) + min_values[-1] # 对label数据进行后处理 label = label * (max_values[-1] - min_values[-1]) + min_values[-1] print("Inference result is {}, the corresponding label is {}".format(predict.numpy(), label))
Inference result is [[19.59287]], the corresponding label is 19.700000762939453
通过比较“模型预测值”和“真实房价”,模型的推理结果与真实房价较为接近。房价预测仅是一个最简单的模型,使用飞桨编写均可事半功倍。那么对于工业实践中更复杂的模型,使用飞桨节约的成本是不可估量的。同时飞桨针对很多应用场景和机器资源做了性能优化,在精度和性能上可以达到更好的效果。
1.5.3 使用飞桨高层API实现波士顿房价预测任务
如上代码使用飞桨的基础API完成了波士顿房价预测任务,是否有更加快捷地实现方法呢?答案是肯定的。下面使用飞桨高层API实现波士顿房价预测任务,代码实现如下:
In [13]
import paddle paddle.set_default_dtype("float32") # 使用飞桨高层API加载波士顿房价预测数据集,包括训练集和测试集 train_dataset = paddle.text.datasets.UCIHousing(mode='train') eval_dataset = paddle.text.datasets.UCIHousing(mode='test') # 模型训练 model = paddle.Model(Regressor()) model.prepare(paddle.optimizer.SGD(learning_rate=0.005, parameters=model.parameters()), paddle.nn.MSELoss()) model.fit(train_dataset, eval_dataset, epochs=10, batch_size=10, verbose=1) result = model.evaluate(eval_dataset, batch_size=10) print("result:",result) result_pred = model.predict(one_data, batch_size=1) # result_pred是一个list,元素数目对应模型的输出数目 result_pred = result_pred[0] # tuple,其中第一个值是array print("Inference result is {}, the corresponding label is {}".format(result_pred[0][0], label))
The loss value printed in the log is the current step, and the metric is the average value of previous steps.
Epoch 1/10
step 41/41 [==============================] - loss: 285.0259 - 1ms/step Eval begin...
result: {'loss': [67.6076]}
Predict begin...
step 1/1 [==============================] - 1ms/step
Predict samples: 13
Inference result is 16.163209915161133, the corresponding label is 19.700000762939453
本实践使用的高层API和介绍如下:
- paddle.text:用于加载文本领域数据集。
- model.prepare:用于定义模型训练参数,如优化器
paddle.optimizer.SGD
、损失函数paddle.nn.MSELoss
等。 - model.fit:用于模型训练,并指定相关参数,如训练轮次
epochs
,批大小batch_size
,可视化的模型方式verbose
。 - model.evaluate:用于在测试集上评估模型的损失函数值和评价指标。由于本实践没有定义模型评价指标,因此只输出损失函数值。本实践使用均方误差损失(Mean Squared Error,MSE)。
- model.predict:用于模型推理。
可以使用高层API,可以快速实现成模型的训练配置、训练过程和效果评估。如果不需要对模型训练过程做更精细化的调试,使用高层API会更加方便。
【小结】
本节我们使用一层的线性回归模型完成了预测房价任务,输出是一个连续值,是一个典型的回归任务。在第2章,我们通过“手写数字识别”的案例,使用神经网络实现分类任务,介绍完整掌握使用飞桨编写模型的方方面面。分类任务和回归任务机器学习领域常见的两大类任务。
作业1-6
- 在 AI Studio 上阅读房价预测案例(两个版本)的代码,并运行观察效果。
- 在本机或服务器上安装Python、jupyter和飞桨,运行房价预测的案例(两个版本),并观察运行效果。
- 想一想:基于Python编写的模型和基于飞桨编写的模型在存在哪些异同?如程序结构,编写难易度,模型的预测效果,训练的耗时等等。