目录
- 前言
- 一、概念:机器学习&深度学习
- 1. 机器学习
- 2. 深度学习
- 二、实操:波士顿房价预测任务
- 1. 线性回归模型
- 2. 线性回归模型的神经网络结构
- 3. 数据处理
- 4. 模型设计
- 5. 训练配置
- 6. 训练过程
- 6.1. 梯度下降法
- 6.2. 计算梯度
- 6.3. 使用Numpy进行梯度计算
- 6.4. 确定损失函数更小的点
- 6.5. 代码封装Train函数
- 6.6. 训练扩展到全部参数
- 6.7. 随机梯度下降法( Stochastic Gradient Descent)
- 三、使用飞桨重写波士顿房价预测任务
- 1. 模型设计
- 2. 训练配置
- 3. 训练过程
- 4. 模型保存和推理
- 4.1. 模型保存
- 4.2. 模型推理
- 5. 使用飞桨高层API实现波士顿房价预测任务
- 总结
前言
利用百度飞桨(登录后可看)入门深度学习,直接在线跑代码嘎嘎方便
一、概念:机器学习&深度学习
人工智能(Artificial Intelligence,AI)是最宽泛的概念,是研发用于模拟、延伸和扩展人的智能的理论、方法、技术及应用系统的一门新的技术科学。由于这个定义只阐述了目标,而没有限定方法,因此实现人工智能存在的诸多方法和分支,导致其变成一个“大杂烩”式的学科。机器学习(Machine Learning,ML)是当前比较有效的一种实现人工智能的方式。深度学习(Deep Learning,DL)是机器学习算法中最热门的一个分支,近些年取得了显著的进展,并替代了大多数传统机器学习算法。
1. 机器学习
- 机器学习的实现可以分成两步:训练和预测。
- 模型假设、评价函数(损失/优化目标)和优化算法是构成模型的三个关键要素。模型有效的基本条件是能够拟合已知的样本,衡量模型预测值和真实值差距的评价函数也被称为损失函数(Loss)
- 学习的本质是“参数估计”
2. 深度学习
两者在理论结构上是一致的,即:模型假设、评价函数和优化算法,其根本差别在于假设的复杂度
-
复杂的变换已经无法用数学公式表达,因此研究者们借鉴了人脑神经元的结构,设计出神经网络的模型
-
人工神经网络包括多个神经网络层,如:卷积层、全连接层、LSTM等,每一层又包括很多神经元,超过三层的非线性神经网络都可以被称为深度神经网络。通俗的讲,深度学习的模型可以视为是输入到输出的映射函数
-
神经元: 神经网络中每个节点称为神经元,由两部分组成:
加权和:将所有输入加权求和。
非线性变换(激活函数):加权和的结果经过一个非线性函数变换,让神经元计算具备非线性的能力。
-
多层连接: 大量这样的节点按照不同的层次排布,形成多层的结构连接起来,即称为神经网络。
-
前向计算:得到输出;后向传播:更新参数。
-
计算图: 以图形化的方式展现神经网络的计算逻辑又称为计算图,也可以将神经网络的计算图以公式的方式表达:
-
发展历史
-
深度学习改变了AI应用的研发模式:
实现了端到端的学习:在数据充足的情况下,深度学习模型可以实现端到端的学习,即不需要专门做特征工程,将原始的特征输入模型中,模型可同时完成特征提取和分类任务
实现了深度学习框架标准化:推动人工智能进入工业大生产阶段,算法的通用性导致标准化、自动化和模块化的框架产生
二、实操:波士顿房价预测任务
该数据集统计了13种可能影响房价的因素和该类型房屋的均价,期望构建一个基于13个因素进行房价预测的模型
1. 线性回归模型
- 对于预测问题,可以根据预测输出的类型是连续的实数值,还是离散的标签,区分为回归任务和分类任务。因为房价是一个连续值,所以房价预测显然是一个回归任务。下面我们尝试用最简单的线性回归模型解决这个问题,并用神经网络来实现这个模型。
为什么选择
MSE
?
因为呈现出“圆滑”的坡度,曲线的最低点是可导的。越接近最低点,曲线的坡度逐渐放缓,有助于通过当前的梯度来判断接近最低点的程度(是否逐渐减少步长,以免错过最低点)。
2. 线性回归模型的神经网络结构
- 神经网络的标准结构中每个神经元由加权和与非线性变换构成,然后将多个神经元分层的摆放并连接形成神经网络。线性回归模型可以认为是神经网络模型的一种极简特例,是一个只有加权和、没有非线性变换的神经元(无需形成网络)
3. 数据处理
数据处理包含五个部分:数据导入、数据形状变换、数据集划分、数据归一化处理和封装
load data
函数。数据预处理后,才能被模型调用。
- 数据读取
# 导入需要用到的package
import numpy as np
import json
# 读入训练数据
datafile = './work/housing.data'
data = np.fromfile(datafile, sep=' ')
data
array([6.320e-03, 1.800e+01, 2.310e+00, …, 3.969e+02,7.880e+00,1.190e+01])
- 数据形状变换:由于读入的原始数据是1维的,所有数据都连在一起。因此需要我们将数据的形状进行变换,形成一个2维的矩阵,每行为一个数据样本(14个值),每个数据样本包含13个 x x x(影响房价的特征)和一个 y y y(该类型房屋的均价)。
# 读入之后的数据被转化成1维array,其中array的第0-13项是第一条数据,第14-27项是第二条数据,以此类推....
# 这里对原始数据做reshape,变成N x 14的形式
feature_names = [ 'CRIM', 'ZN', 'INDUS', 'CHAS', 'NOX', 'RM', 'AGE','DIS', 'RAD', 'TAX', 'PTRATIO', 'B', 'LSTAT', 'MEDV' ]
feature_num = len(feature_names)
data = data.reshape([data.shape[0] // feature_num, feature_num])# 查看数据
x = data[0]
print(x.shape)
print(x)
- 数据集划分:将数据集划分成训练集和测试集,其中训练集用于确定模型的参数,测试集用于评判模型的效果
# 将80%的数据用作训练集,20%用作测试集
ratio = 0.8
offset = int(data.shape[0] * ratio)
training_data = data[:offset]
training_data.shape
- 数据归一化处理:对每个特征进行归一化处理,使得每个特征的取值缩放到0~1之间。这样做有两个好处:一是模型训练更高效;二是特征前的权重大小可以代表该变量对预测结果的贡献度(因为每个特征值本身的范围相同)
# 计算train数据集的最大值,最小值
maximums, minimums = \training_data.max(axis=0), \training_data.min(axis=0),
# 对数据进行归一化处理
for i in range(feature_num):data[:, i] = (data[:, i] - minimums[i]) / (maximums[i] - minimums[i])
- 封装成
load data
函数
def load_data():# 从文件导入数据datafile = './work/housing.data'data = np.fromfile(datafile, sep=' ')# 每条数据包括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.8offset = int(data.shape[0] * ratio)training_data = data[:offset]# 计算训练集的最大值,最小值maximums, minimums = training_data.max(axis=0), \training_data.min(axis=0)# 对数据进行归一化处理for i in range(feature_num):data[:, i] = (data[:, i] - minimums[i]) / (maximums[i] - minimums[i])# 训练集和测试集的划分比例training_data = data[:offset]test_data = data[offset:]return training_data, test_data# 获取数据
training_data, test_data = load_data()
x = training_data[:, :-1]
y = training_data[:, -1:]
# 查看数据
print(x[0])
print(y[0])
4. 模型设计
计算预测输出的过程以“类和对象”的方式来描述,类成员变量有参数 w w w和 b b b。通过写一个forward
函数(代表“前向计算”)完成上述从特征和参数到输出预测值的计算过程,代码实现如下。
class Network(object):def __init__(self, num_of_weights):# 随机产生w的初始值# 为了保持程序每次运行结果的一致性,# 此处设置固定的随机数种子np.random.seed(0)self.w = np.random.randn(num_of_weights, 1)self.b = 0.def forward(self, x):z = np.dot(x, self.w) + self.breturn z# 基于Network类的定义,模型的计算过程如下
net = Network(13)
x1 = x[0]
y1 = y[0]
z = net.forward(x1) # [2.39362982]
print(z)
5. 训练配置
模型设计完成后,需要通过训练配置寻找模型的最优值,即通过损失函数来衡量模型的好坏。训练配置也是深度学习模型关键要素之一。
这时我们需要有某种指标来衡量预测值 z z z跟真实值 y y y之间的差距,对于回归问题,最常采用的衡量方法是使用均方误差作为评价模型好坏的指标
class Network(object):def __init__(self, num_of_weights):# 随机产生w的初始值# 为了保持程序每次运行结果的一致性,此处设置固定的随机数种子np.random.seed(0)self.w = np.random.randn(num_of_weights, 1)self.b = 0.def forward(self, x):z = np.dot(x, self.w) + self.breturn zdef loss(self, z, y):error = z - ycost = error * errorcost = np.mean(cost)return cost
注意:类中的变量 x , ω , b , z , e r r o r x,\omega,b,z,error x,ω,b,z,error等均是向量。以变量 x x x为例,共有两个维度,一个代表特征数量(值为13),一个代表样本数量
net = Network(13)
# 此处可以一次性计算多个样本的预测值和损失函数
x1 = x[0:3]
y1 = y[0:3]
z = net.forward(x1)
print('predict: ', z)
loss = net.loss(z, y1)
print('loss:', loss)# 输出
predict: [[2.39362982][2.46752393][2.02483479]]
loss: 3.384496992612791
6. 训练过程
如何求解 ω , b \omega,b ω,b的数值称为模型训练过程。训练过程是深度学习模型的关键要素之一,其目标是让定义的损失函数尽可能的小,也就是说找到一个参数解 ω , b \omega,b ω,b使得损失函数取得极小值
考虑极值点处取得:
但是这种方法只对线性回归这样简单的任务有效。如果模型中含有非线性变换,或者损失函数不是均方差这种简单的形式,则很难通过上式求解。为了解决这个问题,下面我们将引入更加普适的数值求解方法:梯度下降法(从当前的参数取值,一步步的按照下坡的方向下降,直到走到最低点)
6.1. 梯度下降法
训练的关键是找到一组 ( w , b ) (w, b) (w,b),使得损失函数 L L L取极小值。我们先看一下损失函数 L L L只随两个参数 w 5 w_5 w5、 w 9 w_9 w9变化时的简单情形,启发下寻解的思路。
L = L ( w 5 , w 9 ) L=L(w_5, w_9) L=L(w5,w9)这里我们将 w 0 , w 1 , . . . , w 12 w_0, w_1, ..., w_{12} w0,w1,...,w12中除 w 5 , w 9 w_5, w_9 w5,w9之外的参数和 b b b都固定下来,可以用图画出 L ( w 5 , w 9 ) L(w_5, w_9) L(w5,w9)的形式。
为什么这里我们选择 w 5 w_5 w5和 w 9 w_9 w9来画图?因为这两个参数可比较直观的从损失函数的曲面图上发现极值点的存在。其他参数组合不够直观
net = Network(13)
losses = []
#只画出参数w5和w9在区间[-160, 160]的曲线部分,以及包含损失函数的极值
w5 = np.arange(-160.0, 160.0, 1.0)
w9 = np.arange(-160.0, 160.0, 1.0)
losses = np.zeros([len(w5), len(w9)])#计算设定区域内每个参数取值所对应的Loss
for i in range(len(w5)):for j in range(len(w9)):net.w[5] = w5[i]net.w[9] = w9[j]z = net.forward(x)loss = net.loss(z, y)losses[i, j] = loss#使用matplotlib将两个变量和对应的Loss作3D图
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
fig = plt.figure()
ax = Axes3D(fig)w5, w9 = np.meshgrid(w5, w9)ax.plot_surface(w5, w9, losses, rstride=1, cstride=1, cmap='rainbow')
plt.show()
现在我们要找出一组 [ w 5 , w 9 ] [w_5, w_9] [w5,w9]的值,使得损失函数最小,实现梯度下降法的方案如下:
- 步骤1:随机的选一组初始值,例如: [ w 5 , w 9 ] = [ − 100.0 , − 100.0 ] [w_5, w_9] = [-100.0, -100.0] [w5,w9]=[−100.0,−100.0]
- 步骤2:选取下一个点 [ w 5 ′ , w 9 ′ ] [w_5^{'} , w_9^{'}] [w5′,w9′],使得 L ( w 5 ′ , w 9 ′ ) < L ( w 5 , w 9 ) L(w_5^{'} , w_9^{'}) < L(w_5, w_9) L(w5′,w9′)<L(w5,w9)
- 步骤3:重复步骤2,直到损失函数几乎不再下降。
如何选择 [ w 5 ′ , w 9 ′ ] [w_5^{'} , w_9^{'}] [w5′,w9′]是至关重要的,第一要保证 L L L是下降的,第二要使得下降的趋势尽可能的快。微积分的基础知识告诉我们,沿着梯度的反方向,是函数值下降最快的方向。简单理解,函数在某一个点的梯度方向是曲线斜率最大的方向,但梯度方向是向上的,所以下降最快的是梯度的反方向
6.2. 计算梯度
上面我们讲过了损失函数的计算方法,这里稍微改写,为了使梯度计算更加简洁,引入因子 1 2 \frac{1}{2} 21,定义损失函数如下: L = 1 2 N ∑ i = 1 N ( y i − z i ) 2 L= \frac{1}{2N}\sum_{i=1}^N{(y_i - z_i)^2} L=2N1i=1∑N(yi−zi)2其中 z i z_i zi是网络对第 i i i个样本的预测值:
z i = ∑ j = 0 12 x i j ⋅ w j + b z_i = \sum_{j=0}^{12}{x_i^{j}\cdot w_j} + b zi=j=0∑12xij⋅wj+b梯度的定义:
可以计算出 L L L对 w w w和 b b b的偏导数:
∂ L ∂ w j = 1 N ∑ i = 1 N ( z i − y i ) ∂ z i ∂ w j = 1 N ∑ i = 1 N ( z i − y i ) x i j \frac{\partial{L}}{\partial{w_j}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)\frac{\partial{z_i}}{\partial{w_j}}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)x_i^{j}} ∂wj∂L=N1i=1∑N(zi−yi)∂wj∂zi=N1i=1∑N(zi−yi)xij ∂ L ∂ b = 1 N ∑ i = 1 N ( z i − y i ) ∂ z i ∂ b = 1 N ∑ i = 1 N ( z i − y i ) \frac{\partial{L}}{\partial{b}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)\frac{\partial{z_i}}{\partial{b}}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)} ∂b∂L=N1i=1∑N(zi−yi)∂b∂zi=N1i=1∑N(zi−yi)
从导数的计算过程可以看出,因子 1 2 \frac{1}{2} 21被消掉了,这是因为二次函数求导的时候会产生因子 2 2 2,这也是我们将损失函数改写的原因。
下面我们考虑只有一个样本的情况下,计算梯度:
L = 1 2 ( y i − z i ) 2 L= \frac{1}{2}{(y_i - z_i)^2} L=21(yi−zi)2 z 1 = x 1 0 ⋅ w 0 + x 1 1 ⋅ w 1 + . . . + x 1 12 ⋅ w 12 + b z_1 = {x_1^{0}\cdot w_0} + {x_1^{1}\cdot w_1} + ... + {x_1^{12}\cdot w_{12}} + b z1=x10⋅w0+x11⋅w1+...+x112⋅w12+b可以计算出: L = 1 2 ( x 1 0 ⋅ w 0 + x 1 1 ⋅ w 1 + . . . + x 1 12 ⋅ w 12 + b − y 1 ) 2 L= \frac{1}{2}{({x_1^{0}\cdot w_0} + {x_1^{1}\cdot w_1} + ... + {x_1^{12}\cdot w_{12}} + b - y_1)^2} L=21(x10⋅w0+x11⋅w1+...+x112⋅w12+b−y1)2
可以计算出 L L L对 w w w和 b b b的偏导数:
∂ L ∂ w 0 = ( x 1 0 ⋅ w 0 + x 1 1 ⋅ w 1 + . . . + x 1 12 ⋅ w 1 2 + b − y 1 ) ⋅ x 1 0 = ( z 1 − y 1 ) ⋅ x 1 0 \frac{\partial{L}}{\partial{w_0}} = ({x_1^{0}\cdot w_0} + {x_1^{1}\cdot w_1} + ... + {x_1^{12}\cdot w_12} + b - y_1)\cdot x_1^{0}=({z_1} - {y_1})\cdot x_1^{0} ∂w0∂L=(x10⋅w0+x11⋅w1+...+x112⋅w12+b−y1)⋅x10=(z1−y1)⋅x10 ∂ L ∂ b = ( x 1 0 ⋅ w 0 + x 1 1 ⋅ w 1 + . . . + x 1 12 ⋅ w 12 + b − y 1 ) ⋅ 1 = ( z 1 − y 1 ) \frac{\partial{L}}{\partial{b}} = ({x_1^{0}\cdot w_0} + {x_1^{1}\cdot w_1} + ... + {x_1^{12}\cdot w_{12}} + b - y_1)\cdot 1 = ({z_1} - {y_1}) ∂b∂L=(x10⋅w0+x11⋅w1+...+x112⋅w12+b−y1)⋅1=(z1−y1)
x1 = x[0]
y1 = y[0]
z1 = net.forward(x1)
print('x1 {}, shape {}'.format(x1, x1.shape))
print('y1 {}, shape {}'.format(y1, y1.shape))
print('z1 {}, shape {}'.format(z1, z1.shape))
按上面的公式,当只有一个样本时,可以计算某个 w j w_j wj,比如 w 0 w_0 w0的梯度。
gradient_w0 = (z1 - y1) * x1[0]
print('gradient_w0 {}'.format(gradient_w0))
gradient_w1 = (z1 - y1) * x1[1]
print('gradient_w1 {}'.format(gradient_w1))
gradient_w2= (z1 - y1) * x1[2]
print('gradient_w2 {}'.format(gradient_w2))
# 写个循环QAQ.....
6.3. 使用Numpy进行梯度计算
基于Numpy广播机制(对向量和矩阵计算如同对1个单一变量计算一样),可以更快速的实现梯度计算。计算梯度的代码中直接用 ( z 1 − y 1 ) ⋅ x 1 (z_1 - y_1) \cdot x_1 (z1−y1)⋅x1,得到的是一个 13 13 13维的向量,每个分量分别代表该维度的梯度。
gradient_w = (z1 - y1) * x1
print('gradient_w_by_sample1 {}, gradient.shape {}'.format(gradient_w, gradient_w.shape))
输入数据中有多个样本,每个样本都对梯度有贡献。如上代码计算了只有样本1时的梯度值,同样的计算方法也可以计算样本2和样本3对梯度的贡献。可以使用Numpy的矩阵操作来简化运算,如3个样本的情况。
# 注意这里是一次取出3个样本的数据,不是取出第3个样本
x3samples = x[0:3]
y3samples = y[0:3]
z3samples = net.forward(x3samples)print('x {}, shape {}'.format(x3samples, x3samples.shape))
print('y {}, shape {}'.format(y3samples, y3samples.shape))
print('z {}, shape {}'.format(z3samples, z3samples.shape))
此处可见,计算梯度gradient_w
的维度是 3 × 13 3 \times 13 3×13,并且其第1行与上面第1个样本计算的梯度gradient_w_by_sample1
一致,第2行与上面第2个样本计算的梯度gradient_w_by_sample2
一致,第3行与上面第3个样本计算的梯度gradient_w_by_sample3
一致。这里使用矩阵操作,可以更加方便的对3个样本分别计算各自对梯度的贡献。
那么对于有N个样本的情形,我们可以直接使用如下方式计算出所有样本对梯度的贡献,这就是使用Numpy库广播功能带来的便捷。小结一下这里使用Numpy库的广播功能:
- 扩展参数的维度,代替for循环来计算1个样本对从 w 0 w_{0} w0到 w 12 w_{12} w12的所有参数的梯度。
- 扩展样本的维度,代替for循环来计算样本0到样本403对参数的梯度。
根据梯度的计算公式,总梯度是对每个样本对梯度贡献的平均值: ∂ L ∂ w j = 1 N ∑ i = 1 N ( z i − y i ) ∂ z i ∂ w j = 1 N ∑ i = 1 N ( z i − y i ) x i j \frac{\partial{L}}{\partial{w_j}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)\frac{\partial{z_i}}{\partial{w_j}}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)x_i^{j}} ∂wj∂L=N1i=1∑N(zi−yi)∂wj∂zi=N1i=1∑N(zi−yi)xij
z = net.forward(x)
# w的梯度计算
gradient_w = (z - y) * x
gradient_w = np.mean(gradient_w, axis=0)
gradient_w = gradient_w[:, np.newaxis]
gradient_w
# b的梯度计算
gradient_b = (z - y)
gradient_b = np.mean(gradient_b)
# 此处b是一个数值,所以可以直接用np.mean得到一个标量
gradient_b # 142.50289323156107
- 写成
Network
类的gradient
函数
class Network(object):def __init__(self, num_of_weights):# 随机产生w的初始值# 为了保持程序每次运行结果的一致性,此处设置固定的随机数种子np.random.seed(0)self.w = np.random.randn(num_of_weights, 1)self.b = 0.def forward(self, x):z = np.dot(x, self.w) + self.breturn zdef loss(self, z, y):error = z - ynum_samples = error.shape[0]cost = error * errorcost = np.sum(cost) / num_samplesreturn costdef gradient(self, x, y):z = self.forward(x)gradient_w = (z-y)*xgradient_w = np.mean(gradient_w, axis=0)gradient_w = gradient_w[:, np.newaxis]gradient_b = (z - y)gradient_b = np.mean(gradient_b)return gradient_w, gradient_b
- 调用上面定义的gradient函数,计算梯度
# 初始化网络
net = Network(13)
# 设置[w5, w9] = [-100., -100.]
net.w[5] = -100.0
net.w[9] = -100.0z = net.forward(x)
loss = net.loss(z, y)
gradient_w, gradient_b = net.gradient(x, y)
gradient_w5 = gradient_w[5][0]
gradient_w9 = gradient_w[9][0]
print('point {}, loss {}'.format([net.w[5][0], net.w[9][0]], loss))
print('gradient {}'.format([gradient_w5, gradient_w9]))# 输出
point [-100.0, -100.0], loss 7873.345739941161
gradient [-45.87968288123223, -35.50236884482904]
6.4. 确定损失函数更小的点
# 在[w5, w9]平面上,沿着梯度的反方向移动到下一个点P1
# 定义移动步长 eta
eta = 0.1
# 更新参数w5和w9(相减:参数需要向梯度的反方向移动)
# eta:每次移动的步长,又称为学习率
net.w[5] = net.w[5] - eta * gradient_w5
net.w[9] = net.w[9] - eta * gradient_w9
# 重新计算z和loss
z = net.forward(x)
loss = net.loss(z, y)
gradient_w, gradient_b = net.gradient(x, y)
gradient_w5 = gradient_w[5][0]
gradient_w9 = gradient_w[9][0]
print('point {}, loss {}'.format([net.w[5][0], net.w[9][0]], loss))
print('gradient {}'.format([gradient_w5, gradient_w9]))# 输出
point [-95.41203171187678, -96.4497631155171], loss 7214.694816482369
gradient [-43.883932999069096, -34.019273908495926]
为什么之前我们要做输入特征的归一化,保持尺度一致?
这是为了让统一的步长更加合适,特征输入归一化后,不同参数输出的Loss是一个比较规整的曲线,学习率可以设置成统一的值 ;特征输入未归一化时,不同特征对应的参数所需的步长不一致,尺度较大的参数需要大步长,尺寸较小的参数需要小步长,导致无法设置统一的学习率
6.5. 代码封装Train函数
将上面的循环计算过程封装在train
和update
函数中
class Network(object):def __init__(self, num_of_weights):# 随机产生w的初始值# 为了保持程序每次运行结果的一致性,此处设置固定的随机数种子np.random.seed(0)self.w = np.random.randn(num_of_weights,1)self.w[5] = -100.self.w[9] = -100.self.b = 0.def forward(self, x):z = np.dot(x, self.w) + self.breturn zdef loss(self, z, y):error = z - ynum_samples = error.shape[0]cost = error * errorcost = np.sum(cost) / num_samplesreturn costdef gradient(self, x, y):z = self.forward(x)gradient_w = (z-y)*xgradient_w = np.mean(gradient_w, axis=0)gradient_w = gradient_w[:, np.newaxis]gradient_b = (z - y)gradient_b = np.mean(gradient_b) return gradient_w, gradient_bdef update(self, gradient_w5, gradient_w9, eta=0.01):net.w[5] = net.w[5] - eta * gradient_w5net.w[9] = net.w[9] - eta * gradient_w9def train(self, x, y, iterations=100, eta=0.01):points = []losses = []for i in range(iterations):points.append([net.w[5][0], net.w[9][0]])z = self.forward(x)L = self.loss(z, y)gradient_w, gradient_b = self.gradient(x, y)gradient_w5 = gradient_w[5][0]gradient_w9 = gradient_w[9][0]self.update(gradient_w5, gradient_w9, eta)losses.append(L)if i % 50 == 0:print('iter {}, point {}, loss {}'.format(i, [net.w[5][0], net.w[9][0]], L))return points, losses# 获取数据
train_data, test_data = load_data()
x = train_data[:, :-1]
y = train_data[:, -1:]
# 创建网络
net = Network(13)
num_iterations=2000
# 启动训练
points, losses = net.train(x, y, iterations=num_iterations, eta=0.01)# 画出损失函数的变化趋势
plot_x = np.arange(num_iterations)
plot_y = np.array(losses)
plt.plot(plot_x, plot_y)
plt.show()
6.6. 训练扩展到全部参数
上面演示的梯度下降的过程仅包含 w 5 w_5 w5和 w 9 w_9 w9两个参数,但房价预测的完整模型,必须要对所有参数 w w w和 b b b进行求解。这需要将Network中的update
和train
函数进行修改。由于不再限定参与计算的参数(所有参数均参与计算),修改之后的代码反而更加简洁。实现逻辑:“前向计算输出、根据输出和真实值计算Loss、基于Loss和输入计算梯度、根据梯度更新参数值”四个部分反复执行,直到到损失函数最小
class Network(object):def __init__(self, num_of_weights):# 随机产生w的初始值# 为了保持程序每次运行结果的一致性,此处设置固定的随机数种子np.random.seed(0)self.w = np.random.randn(num_of_weights, 1)self.b = 0.def forward(self, x):z = np.dot(x, self.w) + self.breturn zdef loss(self, z, y):error = z - ynum_samples = error.shape[0]cost = error * errorcost = np.sum(cost) / num_samplesreturn costdef gradient(self, x, y):z = self.forward(x)gradient_w = (z-y)*xgradient_w = np.mean(gradient_w, axis=0)gradient_w = gradient_w[:, np.newaxis]gradient_b = (z - y)gradient_b = np.mean(gradient_b) return gradient_w, gradient_bdef update(self, gradient_w, gradient_b, eta = 0.01):self.w = self.w - eta * gradient_wself.b = self.b - eta * gradient_bdef train(self, x, y, iterations=100, eta=0.01):losses = []for i in range(iterations):z = self.forward(x)L = self.loss(z, y)gradient_w, gradient_b = self.gradient(x, y)self.update(gradient_w, gradient_b, eta)losses.append(L)if (i+1) % 10 == 0:print('iter {}, loss {}'.format(i, L))return losses# 获取数据
train_data, test_data = load_data()
x = train_data[:, :-1]
y = train_data[:, -1:]
# 创建网络
net = Network(13)
num_iterations=1000
# 启动训练
losses = net.train(x,y, iterations=num_iterations, eta=0.01)# 画出损失函数的变化趋势
plot_x = np.arange(num_iterations)
plot_y = np.array(losses)
plt.plot(plot_x, plot_y)
plt.show()
6.7. 随机梯度下降法( Stochastic Gradient Descent)
在上述程序中,每次损失函数和梯度计算都是基于数据集中的全量数据。对于波士顿房价预测任务数据集而言,样本数比较少,只有404个。但在实际问题中,数据集往往非常大,如果每次都使用全量数据进行计算,效率非常低,通俗地说就是“杀鸡焉用牛刀”。由于参数每次只沿着梯度反方向更新一点点,因此方向并不需要那么精确。一个合理的解决方案是每次从总的数据集中随机抽取出小部分数据来代表整体,基于这部分数据计算梯度和损失来更新参数,这种方法被称作随机梯度下降法(Stochastic Gradient Descent,SGD),核心概念如下:
minibatch
:每次迭代时抽取出来的一批数据被称为一个minibatch。batch size
:每个minibatch所包含的样本数目称为batch size。Epoch
:当程序迭代的时候,按minibatch逐渐抽取出样本,当把整个数据集都遍历到了的时候,则完成了一轮训练,也叫一个Epoch(轮次)。启动训练时,可以将训练的轮数num_epochs
和batch_size
作为参数传入。
下面结合程序介绍具体的实现过程,涉及到数据处理和训练过程两部分代码的修改。
1)数据处理需要实现拆分数据批次和样本乱序(为了实现随机抽样的效果)两个功能。
2)训练过程代码修改。将每个随机抽取的minibatch数据输入到模型中用于参数训练。训练过程的核心是两层循环:
- 第一层循环,代表样本集合要被训练遍历几次,称为“epoch”,代码如下:
for epoch_id in range(num_epochs):
- 第二层循环,代表每次遍历时,样本集合被拆分成的多个批次,需要全部执行训练,称为“iter (iteration)”,代码如下:
for iter_id,mini_batch in emumerate(mini_batches):
在两层循环的内部是经典的四步训练流程:前向计算->计算损失->计算梯度->更新参数,这与大家之前所学是一致的,代码如下:
x = mini_batch[:, :-1]y = mini_batch[:, -1:]a = self.forward(x) #前向计算loss = self.loss(a, y) #计算损失gradient_w, gradient_b = self.gradient(x, y) #计算梯度self.update(gradient_w, gradient_b, eta) #更新参数
将两部分改写的代码集成到Network类中的train
函数中,最终的实现如下
import numpy as npclass Network(object):def __init__(self, num_of_weights):# 随机产生w的初始值# 为了保持程序每次运行结果的一致性,此处设置固定的随机数种子#np.random.seed(0)self.w = np.random.randn(num_of_weights, 1)self.b = 0.def forward(self, x):z = np.dot(x, self.w) + self.breturn zdef loss(self, z, y):error = z - ynum_samples = error.shape[0]cost = error * errorcost = np.sum(cost) / num_samplesreturn costdef gradient(self, x, y):z = self.forward(x)N = x.shape[0]gradient_w = 1. / N * np.sum((z-y) * x, axis=0)gradient_w = gradient_w[:, np.newaxis]gradient_b = 1. / N * np.sum(z-y)return gradient_w, gradient_bdef update(self, gradient_w, gradient_b, eta = 0.01):self.w = self.w - eta * gradient_wself.b = self.b - eta * gradient_bdef train(self, training_data, num_epochs, batch_size=10, eta=0.01):n = len(training_data)losses = []for epoch_id in range(num_epochs):# 在每轮迭代开始之前,将训练数据的顺序随机打乱# 然后再按每次取batch_size条数据的方式取出np.random.shuffle(training_data)# 将训练数据进行拆分,每个mini_batch包含batch_size条的数据mini_batches = [training_data[k:k+batch_size] for k in range(0, n, batch_size)]for iter_id, mini_batch in enumerate(mini_batches):#print(self.w.shape)#print(self.b)x = mini_batch[:, :-1]y = mini_batch[:, -1:]a = self.forward(x)loss = self.loss(a, y)gradient_w, gradient_b = self.gradient(x, y)self.update(gradient_w, gradient_b, eta)losses.append(loss)print('Epoch {:3d} / iter {:3d}, loss = {:.4f}'.format(epoch_id, iter_id, loss))return losses# 获取数据
train_data, test_data = load_data()# 创建网络
net = Network(13)
# 启动训练
losses = net.train(train_data, num_epochs=50, batch_size=100, eta=0.1)# 画出损失函数的变化趋势
plot_x = np.arange(len(losses))
plot_y = np.array(losses)
plt.plot(plot_x, plot_y)
plt.show()
观察上述损失函数的变化,随机梯度下降加快了训练过程,但由于每次仅基于少量样本更新参数和计算损失ii,所以损失下降曲线会出现震荡
三、使用飞桨重写波士顿房价预测任务
在之前的章节中,我们学习了使用Python和NumPy实现波士顿房价预测任务的方法,本章我们将尝试使用飞桨重写房价预测任务,体会二者的异同
#加载飞桨、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. 模型设计
模型设计的实质是定义线性回归的网络结构,建议通过创建Python类的方式构建模型,该类需要继承
paddle.nn.Layer
父类,并且在类中定义init
函数和forward
函数。forward
是飞桨前向计算逻辑的函数,在调用模型实例时会自动执行,其使用的网络层需要在init
中声明。
init
函数:在类的初始化函数中声明每一层网络的实现函数。在房价预测任务中,只需要定义一层全连接层,模型结构和第1.3节保持一致。forward
函数:在构建神经网络时实现前向计算过程,并返回预测结果,在本任务中返回的是房价预测结果。
class Regressor(paddle.nn.Layer):# self代表类的实例自身def __init__(self):# 初始化父类中的一些参数super(Regressor, self).__init__()# 定义一层全连接层,输入维度是13,输出维度是1self.fc = Linear(in_features=13, out_features=1)# 网络的前向计算def forward(self, inputs):x = self.fc(inputs)return x
2. 训练配置
- 声明定义好的回归模型实例为Regressor,并将模型的状态设置为
train
。 - 使用
load_data
函数加载训练数据和测试数据。 - 设置优化算法和学习率,优化算法采用随机梯度下降,学习率设置为0.01。
# 声明定义好的线性回归模型
model = Regressor()
# 开启模型训练模式,模型的状态设置为train
model.train()
# 使用load_data加载训练集数据和测试集数据
training_data, test_data = load_data()
# 定义优化算法,采用随机梯度下降SGD
# 学习率设置为0.01
opt = paddle.optimizer.SGD(learning_rate=0.005, parameters=model.parameters())
说明:
模型实例有两种状态:训练状态.train()
和预测状态.eval()
。训练时要执行前向计算和反向传播两个过程,而预测时只需要执行前向计算。为模型指定运行状态,有如下两点原因:
- 部分高级的算子在两个状态执行的逻辑不同,如
Dropout
和BatchNorm
(在“计算机视觉”章节中详细介绍)。 - 从性能和存储空间的考虑,预测状态时更节省内存(无需记录反向梯度),性能更好。
在第6.3节,我们已经为实现梯度下降编写了大量代码,而使用飞桨只需要设置SGD
函数,即可实现梯度下降,大大简化了这个过程。
3. 训练过程
- 数据准备:将数据先转换成
np.array
格式,再转换成张量(Tensor)。 - 前向计算:按batch_size大小,将数据灌入模型中,计算输出结果。
- 计算损失函数:以前向计算结果和真实房价作为输入,通过square_error_cost API计算损失函数。飞桨所有的API接口都有完整的说明和使用案例,可以登录飞桨官网API文档获取。
- 反向传播:执行梯度反向传播
backward
,即从后到前逐层计算每一层的梯度,并根据设置的优化算法更新参数opt.step
。
模型的训练过程采用二层循环嵌套方式:
- 内层循环:按批大小(batch_size,即一次模型训练使用的样本数量),对数据集进行一次遍历,完成一轮模型训练。假设数据集的样本数量为1000,批大小是10),那么遍历一次数据集训练需要 1000 10 = 100 \frac{1000}{10}=100 101000=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。
epoch_num = 20 # 设置模型训练轮次
batch_size = 10 # 设置批大小,即一次模型训练使用的样本数量# 定义模型训练轮次epoch(外层循环)
for epoch_id in range(epoch_num):# 在每轮迭代开始之前,对训练集数据进行样本乱序np.random.shuffle(training_data)# 对训练集数据进行拆分,batch_size设置为10mini_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_costloss = 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()
4. 模型保存和推理
4.1. 模型保存
使用paddle.save API将模型当前的参数 model.state_dict() 保存到文件中,用于模型模型评估或模型推理
# 保存模型参数,文件名为LR_model.pdparams
paddle.save(model.state_dict(), 'LR_model.pdparams')
print("模型保存成功, 模型参数保存在LR_model.pdparams中")
说明:
为什么要执行模型保存的操作,而不是直接使用训练好的模型进行推理呢?理论而言,直接使用模型实例即可完成模型推理,但是在实际应用中,模型训练和推理往往是不同的场景。模型训练通常使用大量的线下服务器(不向企业的客户/用户提供在线服务);模型推理则通常使用云端的推理服务器实现或者将已经训练好的模型嵌入手机或其他终端设备中使用。因此本教程中“先保存模型,再加载模型”的讲解方式更贴合真实场景的使用方法。
4.2. 模型推理
任意选择一条样本数据,测试模型的推理效果。推理过程和在应用场景中使用模型的过程一致,主要可分成如下三步:
- 配置模型推理的机器资源。本案例默认使用本机,因此无需代码指定。
- 将训练好的模型参数加载到模型实例中。先从文件中读取模型参数,再将参数加载到模型。加载后,将模型的状态调整为
eval()
。上文提到,训练状态的模型需要同时支持前向计算和反向梯度传播;而评估和推理状态的模型只需要支持前向计算,模型的实现更加简单,性能更好。 - 通过
load_one_example
函数实现从数据集中抽一条样本作为测试样本。
实现代码如下:
def load_one_example():# 从测试集中随机选择一条作为推理数据idx = np.random.randint(0, test_data.shape[0])idx = -10one_data, label = test_data[idx, :-1], test_data[idx, -1]# 将数据格式修改为[1,13]one_data = one_data.reshape([1,-1])return one_data, label
# 将模型参数保存到指定路径中
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))
5. 使用飞桨高层API实现波士顿房价预测任务
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))
本实践使用的高层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会更加方便。
总结
详细介绍了如何使用Numpy实现梯度下降算法,构建并训练了一个简单的线性模型实现波士顿房价预测,可以总结出,使用神经网络建模房价预测有三个要点:
- 构建网络,初始化参数 w w w和 b b b,定义预测和损失函数的计算方法。
- 随机选择初始点,建立梯度的计算方法和参数更新方式。
- 将数据集的数据按
batch size
的大小分成多个minibatch
,分别灌入模型计算梯度并更新参数,不断迭代直到损失函数几乎不再下降。
本节我们使用一层的线性回归模型完成了预测房价任务,输出是一个连续值,是一个典型的回归任务。在下一部分,我们通过“手写数字识别”的案例,使用神经网络实现分类任务,介绍完整掌握使用飞桨编写模型的方方面面。分类任务和回归任务机器学习领域常见的两大类任务。