1.3.3.4 训练过程
上述计算过程描述了如何构建神经网络,通过神经网络完成预测值和损失函数的计算。接下来介绍如何求解参数w和b的数值,这个过程也称为模型训练过程。训练过程是深度学习模型的关键要素之一,其目标是让定义的损失函数尽可能的小,也就是说找到一个参数解w和b,使得损失函数取得极小值。
我们先做一个小测试:如图5所示,基于微积分知识,求一条曲线在某个点的斜率等于函数在该点的导数值。那么大家思考下,当处于曲线的极值点时,该点的斜率是多少?
图5:曲线斜率等于导数值
方案一: 这个问题并不难回答,处于曲线极值点时的斜率为0,即函数在极值点的导数为0。那么,让损失函数取极小值的w和b应该是下述方程组的解:
将样本数据(x,y)带入上面的方程组中即可求解出w和b的值,但是这种方法只对线性回归这样简单的任务有效。
方案二: 如果模型中含有非线性变换,或者损失函数不是均方差这种简单的形式,则很难通过上式求解。为了解决这个问题,下面我们将引入更加普适的数值求解方法:梯度下降法。
(1)梯度下降法
在现实中存在大量的函数正向求解容易,但反向求解较难,被称为单向函数,这种函数在密码学中有大量的应用。密码锁的特点是可以迅速判断一个密钥是否是正确的(已知xx,求yy很容易),但是即使获取到密码锁系统,无法破解出正确的密钥是什么(已知yy,求xx很难)。神经网络模型的损失函数就是这样的单向函数,反向求解并不容易。
这种情况特别类似于一位想从山峰走到坡谷的盲人,他看不见坡谷在哪(无法逆向求解出Loss导数为0时的参数值),但可以伸脚探索身边的坡度(当前点的导数值,也称为梯度)。那么,求解Loss函数最小值可以这样实现:从当前的参数取值,一步步的按照下坡的方向下降,直到走到最低点。这种方法笔者称它为“盲人下坡法”。有个更正式的说法“梯度下降法(Gradient Descent,GD)”。
训练的关键是找到一组(w,b),使得损失函数L取极小值。我们先看一下损失函数L只随两个参数w_5、w_9(随便选的)变化时的简单情形,启发下寻解的思路。
L=L(w5,w9)
这里我们将w_0, w_1, ..., w_{12}中除w_5, w_9之外的参数和b都固定下来,可以用图画出L(w_5, w_9)的形式。
In [17]
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来画图?这是因为选择这两个参数的时候,可比较直观的从损失函数的曲面图上发现极值点的存在。其他参数组合,从图形上观测损失函数的极值点不够直观。
观察上述曲线呈现出“圆滑”的坡度,这正是我们选择以均方误差作为损失函数的原因之一。图6呈现了只有一个参数维度时,均方误差和绝对值误差(只将每个样本的误差累加,不做平方处理)的损失函数曲线图。
图6:均方误差和绝对值误差损失函数曲线图
由此可见,均方误差表现的“圆滑”的坡度有两个好处:
- 曲线的最低点是可导的。
- 越接近最低点,曲线的坡度逐渐放缓,有助于通过当前的梯度来判断接近最低点的程度(是否逐渐减少步长,以免错过最低点)。
而绝对值误差是不具备这两个特性的,这也是损失函数的设计不仅仅要考虑“合理性”,还要追求“易解性”的原因。
图7:梯度下降方向示意图
(2)计算梯度
从导数的计算过程可以看出,因子1/2被消掉了,这是因为二次函数求导的时候会产生因子2,这也是我们将损失函数改写的原因。
下面我们考虑只有一个样本的情况下,计算梯度:
In [18] 首先,取出一个样本,并查看其数据内容和维度
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))
x1 [0. 0.18 0.07344184 0. 0.31481481 0.57750527
0.64160659 0.26920314 0. 0.22755741 0.28723404 1.
0.08967991], shape (13,)
y1 [0.42222222], shape (1,)
z1 [130.86954441], shape (1,)
按上面的公式,当只有一个样本时,可以计算某个w_ j,比如w_0的梯度。
In [19]
gradient_w0 = (z1 - y1) * x1[0] print('gradient_w0 {}'.format(gradient_w0))
gradient_w0 [0.]
同样我们可以计算w_1的梯度。
聪明的读者可能已经想到,写一个for循环即可计算从w_0到w_{12}的所有权重的梯度,该方法读者可以自行实现。
(3)使用Numpy进行梯度计算
基于Numpy广播机制(对向量和矩阵计算如同对1个单一变量计算一样),可以更快速的实现梯度计算。计算梯度的代码中直接用(z_1 - y_1) * x_1,得到的是一个13维的向量,每个分量分别代表该维度的梯度。
In [22]
gradient_w = (z1 - y1) * x1 print('gradient_w_by_sample1 {}, gradient.shape {}'.format(gradient_w, gradient_w.shape))
gradient_w_by_sample1 [ 0. 23.48051799 9.58029163 0. 41.06674958
75.33401592 83.69586171 35.11682862 0. 29.68425495
37.46891169 130.44732219 11.69850434], gradient.shape (13,)
输入数据中有多个样本,每个样本都对梯度有贡献。如上代码计算了只有样本1时的梯度值,同样的计算方法也可以计算样本2和样本3对梯度的贡献。
In [23]
x2 = x[1] y2 = y[1] z2 = net.forward(x2) gradient_w = (z2 - y2) * x2 print('gradient_w_by_sample2 {}, gradient.shape {}'.format(gradient_w, gradient_w.shape))
gradient_w_by_sample2 [2.54738434e-02 0.00000000e+00 2.83333765e+01 0.00000000e+00
1.86624242e+01 5.91703008e+01 8.45121992e+01 3.76793284e+01
4.69458498e+00 1.23980167e+01 5.97311025e+01 1.07975454e+02
2.20777626e+01], gradient.shape (13,)
In [24]
x3 = x[2] y3 = y[2] z3 = net.forward(x3) gradient_w = (z3 - y3) * x3 print('gradient_w_by_sample3 {}, gradient.shape {}'.format(gradient_w, gradient_w.shape))
gradient_w_by_sample3 [3.07963708e-02 0.00000000e+00 3.42860463e+01 0.00000000e+00
2.25832858e+01 9.07287666e+01 7.83155260e+01 4.55955257e+01
5.68088867e+00 1.50027645e+01 7.22802431e+01 1.29029688e+02
8.29246719e+00], gradient.shape (13,)
可能有的读者再次想到可以使用for循环把每个样本对梯度的贡献都计算出来,然后再作平均。但是我们不需要这么做,仍然可以使用Numpy的矩阵操作来简化运算,如3个样本的情况。
In [25]
# 注意这里是一次取出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))
x [[0.00000000e+00 1.80000000e-01 7.34418420e-02 0.00000000e+00
3.14814815e-01 5.77505269e-01 6.41606591e-01 2.69203139e-01
0.00000000e+00 2.27557411e-01 2.87234043e-01 1.00000000e+00
8.96799117e-02]
[2.35922539e-04 0.00000000e+00 2.62405717e-01 0.00000000e+00
1.72839506e-01 5.47997701e-01 7.82698249e-01 3.48961980e-01
4.34782609e-02 1.14822547e-01 5.53191489e-01 1.00000000e+00
2.04470199e-01]
[2.35697744e-04 0.00000000e+00 2.62405717e-01 0.00000000e+00
1.72839506e-01 6.94385898e-01 5.99382080e-01 3.48961980e-01
4.34782609e-02 1.14822547e-01 5.53191489e-01 9.87519166e-01
6.34657837e-02]], shape (3, 13)
y [[0.42222222]
[0.36888889]
[0.66 ]], shape (3, 1)
z [[130.86954441]
[108.34434338]
[131.3204395 ]], shape (3, 1)
上面的x3samples, y3samples, z3samples的第一维大小均为3,表示有3个样本。下面计算这3个样本对梯度的贡献。
In [26]
gradient_w = (z3samples - y3samples) * x3samples print('gradient_w {}, gradient.shape {}'.format(gradient_w, gradient_w.shape))
gradient_w [[0.00000000e+00 2.34805180e+01 9.58029163e+00 0.00000000e+00
4.10667496e+01 7.53340159e+01 8.36958617e+01 3.51168286e+01
0.00000000e+00 2.96842549e+01 3.74689117e+01 1.30447322e+02
1.16985043e+01]
[2.54738434e-02 0.00000000e+00 2.83333765e+01 0.00000000e+00
1.86624242e+01 5.91703008e+01 8.45121992e+01 3.76793284e+01
4.69458498e+00 1.23980167e+01 5.97311025e+01 1.07975454e+02
2.20777626e+01]
[3.07963708e-02 0.00000000e+00 3.42860463e+01 0.00000000e+00
2.25832858e+01 9.07287666e+01 7.83155260e+01 4.55955257e+01
5.68088867e+00 1.50027645e+01 7.22802431e+01 1.29029688e+02
8.29246719e+00]], gradient.shape (3, 13)
此处可见,计算梯度gradient_w
的维度是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_{12} 的所有参数的梯度。
- 另一方面可以扩展样本的维度,代替for循环来计算样本0到样本403对参数的梯度。
In [27]
z = net.forward(x) gradient_w = (z - y) * x print('gradient_w shape {}'.format(gradient_w.shape)) print(gradient_w)
gradient_w shape (404, 13)
[[0.00000000e+00 2.34805180e+01 9.58029163e+00 ... 3.74689117e+01
1.30447322e+02 1.16985043e+01]
[2.54738434e-02 0.00000000e+00 2.83333765e+01 ... 5.97311025e+01
1.07975454e+02 2.20777626e+01]
[3.07963708e-02 0.00000000e+00 3.42860463e+01 ... 7.22802431e+01
1.29029688e+02 8.29246719e+00]
...
[3.97706874e+01 0.00000000e+00 1.74130673e+02 ... 2.01043762e+02
2.48659390e+02 1.27554582e+02]
[2.69696515e+01 0.00000000e+00 1.75225687e+02 ... 2.02308019e+02
2.34270491e+02 1.28287658e+02]
[6.08972123e+01 0.00000000e+00 1.53017134e+02 ... 1.76666981e+02
2.18509161e+02 1.08772220e+02]]
上面gradient_w的每一行代表了一个样本对梯度的贡献。根据梯度的计算公式,总梯度是对每个样本对梯度贡献的平均值。
我们也可以使用Numpy的均值函数来完成此过程:
In [28]
# axis = 0 表示把每一行做相加然后再除以总的行数 gradient_w = np.mean(gradient_w, axis=0) print('gradient_w ', gradient_w.shape) print('w ', net.w.shape) print(gradient_w) print(net.w)
我们使用Numpy的矩阵操作方便地完成了gradient的计算,但引入了一个问题,******gradient_w******
的形状是(13,),而w的维度是(13, 1)。导致该问题的原因是使用******np.mean******
函数时消除了第0维。为了加减乘除等计算方便,gradient_w
和w必须保持一致的形状。因此我们将gradient_w
的维度也设置为(13,1),代码如下:
In [29]
gradient_w = gradient_w[:, np.newaxis] print('gradient_w shape', gradient_w.shape)
gradient_w shape (13, 1)
综合上面的剖析,计算梯度的代码如下所示。
In [30]
z = net.forward(x) gradient_w = (z - y) * x gradient_w = np.mean(gradient_w, axis=0) gradient_w = gradient_w[:, np.newaxis] gradient_w
array([[ 4.6555403 ],
[ 19.35268996],
[ 55.88081118],
[ 14.00266972],
[ 47.98588869],
[ 76.87210821],
[ 94.8555119 ],
[ 36.07579608],
[ 45.44575958],
[ 59.65733292],
[ 83.65114918],
[134.80387478],
[ 38.93998153]])
上述代码非常简洁地完成了w的梯度计算。同样,计算b的梯度的代码也是类似的原理。
In [31]
gradient_b = (z - y) gradient_b = np.mean(gradient_b) # 此处b是一个数值,所以可以直接用np.mean得到一个标量 gradient_b
142.50289323156107
将上面计算w和b的梯度的过程,写成Network类的gradient
函数,实现方法如下所示。
In [32]
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.b return z def loss(self, z, y): error = z - y num_samples = error.shape[0] cost = error * error cost = np.sum(cost) / num_samples return cost def gradient(self, x, y): z = self.forward(x) gradient_w = (z-y)*x gradient_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
In [33]
# 调用上面定义的gradient函数,计算梯度 # 初始化网络 net = Network(13) # 设置[w5, w9] = [-100., -100.] net.w[5] = -100.0 net.w[9] = -100.0 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 [-100.0, -100.0], loss 7873.345739941161
gradient [-45.87968288123223, -35.50236884482904]
(4)确定损失函数更小的点
下面我们开始研究更新梯度的方法。首先沿着梯度的反方向移动一小步,找到下一个点P1,观察损失函数的变化。
In [34]
# 在[w5, w9]平面上,沿着梯度的反方向移动到下一个点P1 # 定义移动步长 eta eta = 0.1 # 更新参数w5和w9 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]
运行上面的代码,可以发现沿着梯度反方向走一小步,下一个点的损失函数的确减少了。感兴趣的话,大家可以尝试不停的点击上面的代码块,观察损失函数是否一直在变小。
在上述代码中,每次更新参数使用的语句:net.w[5] = net.w[5] - eta * gradient_w5
- 相减:参数需要向梯度的反方向移动。
- eta:控制每次参数值沿着梯度反方向变动的大小,即每次移动的步长,又称为学习率。
大家可以思考下,为什么之前我们要做输入特征的归一化,保持尺度一致?这是为了让统一的步长更加合适。
如图8所示,特征输入归一化后,不同参数输出的Loss是一个比较规整的曲线,学习率可以设置成统一的值;特征输入未归一化时,不同特征对应的参数所需的步长不一致,步子迈的太大就有可能走过了,尺度较大的参数需要大步长,尺寸较小的参数需要小步长,导致无法设置统一的学习率。
图8:未归一化的特征,会导致不同特征维度的理想步长不同
(5)代码封装Train函数
将上面的循环计算过程封装在train
和update
函数中,实现方法如下所示。
In [35]
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.b return z def loss(self, z, y): error = z - y num_samples = error.shape[0] cost = error * error cost = np.sum(cost) / num_samples return cost def gradient(self, x, y): z = self.forward(x) gradient_w = (z-y)*x gradient_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 def update(self, gradient_w5, gradient_w9, eta=0.01): net.w[5] = net.w[5] - eta * gradient_w5 net.w[9] = net.w[9] - eta * gradient_w9 def 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()
iter 0, point [-99.54120317118768, -99.64497631155172], loss 7873.345739941161
iter 50, point [-78.9761810944732, -83.65939206734069], loss 5131.480704109405
...
iter 1950, point [0.24524412503452966, -8.065729922139326], loss 3.3047037451160453
(6)训练扩展到全部参数
为了能给读者直观的感受,上面演示的梯度下降的过程仅包含w_5和w_9两个参数,但房价预测的完整模型,必须要对所有参数w和b进行求解。这需要将Network中的update
和train
函数进行修改。由于不再限定参与计算的参数(所有参数均参与计算),修改之后的代码反而更加简洁。
实现逻辑:“前向计算输出、根据输出和真实值计算Loss、基于Loss和输入计算梯度、根据梯度更新参数值”四个部分反复执行,直到到损失函数最小。具体代码如下所示。
In [36]
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.b return z def loss(self, z, y): error = z - y num_samples = error.shape[0] cost = error * error cost = np.sum(cost) / num_samples return cost def gradient(self, x, y): z = self.forward(x) gradient_w = (z-y)*x gradient_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 def update(self, gradient_w, gradient_b, eta = 0.01): self.w = self.w - eta * gradient_w self.b = self.b - eta * gradient_b def 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()
iter 9, loss 5.143394325795511
iter 999, loss 0.17513006784373505
(7)随机梯度下降法( Stochastic Gradient Descent)
在上述程序中,每次损失函数和梯度计算都是基于数据集中的全量数据。对于波士顿房价预测任务数据集而言,样本数比较少,只有404个。但在实际问题中,数据集往往非常大,如果每次都使用全量数据进行计算,效率非常低,通俗地说就是“杀鸡焉用牛刀”。由于参数每次只沿着梯度反方向更新一点点,因此方向并不需要那么精确。一个合理的解决方案是每次从总的数据集中随机抽取出小部分数据来代表整体,基于这部分数据计算梯度和损失来更新参数,这种方法被称作随机梯度下降法(Stochastic Gradient Descent,SGD),核心概念如下:
- mini-batch:每次迭代时抽取出来的一批数据被称为一个mini-batch。
- batch_size:每个mini-batch所包含的样本数目称为batch_size。
- Epoch:当程序迭代的时候,按mini-batch逐渐抽取出样本,当把整个数据集都遍历到了的时候,则完成了一轮训练,也叫一个Epoch(轮次)。启动训练时,可以将训练的轮数
num_epochs
和batch_size
作为参数传入。
下面结合程序介绍具体的实现过程,涉及到数据处理和训练过程两部分代码的修改。
1)数据处理需要实现拆分数据批次和样本乱序(为了实现随机抽样的效果)两个功能。
In [37]
# 获取数据 train_data, test_data = load_data() train_data.shape
(404, 14)
train_data中一共包含404条数据,如果batch_size=10,即取前0-9号样本作为第一个mini-batch,命名train_data1。
In [38]
train_data1 = train_data[0:10] train_data1.shape
(10, 14)
使用train_data1的数据(0-9号样本)计算梯度并更新网络参数。
In [39]
net = Network(13) x = train_data1[:, :-1] y = train_data1[:, -1:] loss = net.train(x, y, iterations=1, eta=0.01) loss
[4.497480200683046]
再取出10-19号样本作为第二个mini-batch,计算梯度并更新网络参数。
In [40]
train_data2 = train_data[10:20] x = train_data2[:, :-1] y = train_data2[:, -1:] loss = net.train(x, y, iterations=1, eta=0.01) loss
[5.849682302465982]
按此方法不断的取出新的mini-batch,并逐渐更新网络参数。
接下来,将train_data分成大小为batch_size的多个mini_batch,如下代码所示:将train_data分成404/10+1=41个 mini_batch,其中前40个mini_batch,每个均含有10个样本,最后一个mini_batch只含有4个样本。
In [41]
batch_size = 10 n = len(train_data) mini_batches = [train_data[k:k+batch_size] for k in range(0, n, batch_size)] print('total number of mini_batches is ', len(mini_batches)) print('first mini_batch shape ', mini_batches[0].shape) print('last mini_batch shape ', mini_batches[-1].shape)
total number of mini_batches is 41
first mini_batch shape (10, 14)
last mini_batch shape (4, 14)
另外,这里是按顺序读取mini-batch,而SGD里面是随机抽取一部分样本代表总体。为了实现随机抽样的效果,我们先将train_data里面的样本顺序随机打乱,然后再抽取mini-batch。随机打乱样本顺序,需要用到np.random.shuffle
函数,下面先介绍它的用法。
说明:
通过大量实验发现,模型对最后出现的数据印象更加深刻。训练数据导入后,越接近模型训练结束,最后几个批次数据对模型参数的影响越大。为了避免模型记忆影响训练效果,需要进行样本乱序操作。
In [42]
# 新建一个array a = np.array([1,2,3,4,5,6,7,8,9,10,11,12]) print('before shuffle', a) np.random.shuffle(a) print('after shuffle', a)
before shuffle [ 1 2 3 4 5 6 7 8 9 10 11 12]
after shuffle [ 7 2 11 3 8 6 12 1 4 5 10 9]
多次运行上面代码,可以发现每次执行shuffle
函数后数字顺序均不同。上面举的是一个1维数组乱序的案例,我们再观察下2维数组乱序后的效果
In [43]
# 新建一个array a = np.array([1,2,3,4,5,6,7,8,9,10,11,12]) a = a.reshape([6, 2]) print('before shuffle\n', a) np.random.shuffle(a) print('after shuffle\n', a)
before shuffle
[[ 1 2]
[ 3 4]
[ 5 6]
[ 7 8]
[ 9 10]
[11 12]]
after shuffle
[[ 1 2]
[ 3 4]
[ 5 6]
[ 9 10]
[11 12]
[ 7 8]]
观察运行结果可发现,数组的元素在第0维被随机打乱,但第1维的顺序保持不变。******(默认打乱0维,可指定参数)******例如2仍然紧挨在1的后面,8仍然紧挨在7的后面,而第二维的[3, 4]并不排在[1, 2]的后面。将这部分实现SGD算法的代码集成到Network类中的train
函数中,最终的完整代码如下。
In [44]
# 获取数据 train_data, test_data = load_data() # 打乱样本顺序 np.random.shuffle(train_data) # 将train_data分成多个minibatch batch_size = 10 n = len(train_data) mini_batches = [train_data[k:k+batch_size] for k in range(0, n, batch_size)] # 创建网络 net = Network(13) # 依次使用每个mini_batch的数据 for mini_batch in mini_batches: x = mini_batch[:, :-1] y = mini_batch[:, -1:] loss = net.train(x, y, iterations=1)
2)训练过程代码修改。将每个随机抽取的minibatch数据输入到模型中用于参数训练。训练过程的核心是两层循环:
在两层循环的内部是经典的四步训练流程:前向计算->计算损失->计算梯度->更新参数,这与大家之前所学是一致的,代码如下:
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
函数中,最终的实现如下
In [45]
import numpy as np 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.b return z def loss(self, z, y): error = z - y num_samples = error.shape[0] cost = error * error cost = np.sum(cost) / num_samples return cost def 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_b def update(self, gradient_w, gradient_b, eta = 0.01): self.w = self.w - eta * gradient_w self.b = self.b - eta * gradient_b def 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()
Epoch 0 / iter 0, loss = 1.0281
Epoch 0 / iter 1, loss = 0.5048
Epoch 0 / iter 2, loss = 0.6382
Epoch 0 / iter 3, loss = 0.5168
Epoch 0 / iter 4, loss = 0.1951
Epoch 1 / iter 0, loss = 0.6281 Epoch 1 / iter 1, loss = 0.4611 Epoch 1 / iter 2, loss = 0.4520 Epoch 1 / iter 3, loss = 0.3961 Epoch 1 / iter 4, loss = 0.1381
...
Epoch 49 / iter 0, loss = 0.0732 Epoch 49 / iter 1, loss = 0.0808
Epoch 49 / iter 2, loss = 0.0896Epoch 49 / iter 3, loss = 0.1306
Epoch 49 / iter 4, loss = 0.1896
观察上述损失函数的变化,随机梯度下降加快了训练过程,但由于每次仅基于少量样本更新参数和计算损失i,所以损失下降曲线会出现震荡。
说明:
由于房价预测的数据量过少,所以难以感受到随机梯度下降带来的性能提升。