提出问题
在上两节中都是预知时间步长度,然后以纯手工方式搭建循环神经网络。表19-5展示了前两节和后面的章节要实现的循环神经网络参数。
表19-5 不同场景下的循环神经网络参数
回波检测问题 | 二进制减法问题 | PM2.5预测问题 | |
---|---|---|---|
时间步 | 2 | 4 | 用户指定参数 |
网络输出类别 | 回归 | 二分类 | 多分类 |
分类函数 | 无 | Logistic函数 | Softmax函数 |
损失函数 | 均方差 | 二分类交叉熵 | 多分类交叉熵 |
时间步输出 | 最后一个 | 每一个 | 最后一个 |
批大小 | 1 | 1 | 用户指定参数 |
有无偏移值 | 有 | 无 | 有 |
如果后面再遇到多分类情况,或者其它参数有变化的话,我们不能那样纯手写代码,而是要抽象出来,写一个比较通用的框架。
“比较通用”是什么意思呢?那就是应该满足以下条件:
- 既可以支持分类网络(二分类和多分类),也可以支持回归网络;
- 每一个时间步可以有输出并且有监督学习信号,也可以只在最后一个时间步有输出;
- 第一个时间步的前向计算中不包含前一个时间步的隐层状态值(因为前面没有时间步);
- 最后一个时间步的反向传播中不包含下一个时间步的回传误差(因为后面没有时间步);
- 可以指定超参数进行网络训练,如:学习率、批大小、最大训练次数、输入层尺寸、隐层神经元数量、输出层尺寸等等;
- 可以保存训练结果并可以在以后加载参数,避免重新训练。
全输出网络通过时间反向传播
前两节的内容详细地描述了循环神经网络中的反向传播的方法,尽管如表19-5所示,二者有些许不同,但是还是可以从中总结出关于梯度计算和存储的一般性的规律,即通过时间的反向传播(BPTT,Back Propagation Through Time)。
如图19-14所示,我们仍以具有4个时间步的循环神经网络为例,推导通用的反向传播算法,然后再扩展到一般性。每一个时间步都有输出,所以称为全输出网路,主要是为了区别与后面的单输出形式。
图中蓝色箭头线表示前向计算的过程,线上的字符表示连接参数,用于矩阵相乘。红色的箭头线表示反向传播的过程,线上的数字表示计算梯度的顺序。
正向计算过程为:
ht = x_t \cdot U + s{t-1} \cdot W + b_u \tag{1}
只有在时间步t1时,公式1中的s_{t-1}为0。
后续过程为:
s_t = \sigma (h_t) \tag{2}
z_t = V \cdot s_t + b_v \tag{3}
a_t = C(z_t) \tag{4}
loss_t = L(a_t, y_t) \tag{5}
公式2中的\sigma是激活函数,一般为Tanh函数,但也不排除使用其它函数。公式4中的分类函数 C 和公式5中的损失函数 L 因不同的网络类型而不同,如表19-5所示。
Loss = \frac{1}{\tau} \sum_t^{\tau}loss_t \tag{6}
公式6中的\tau表示最大时间步数,或最后一个时间步数。
\frac{\partial loss_t}{\partial z_t} = a_t - y_t \rightarrow dz_t \tag{7}
对于图19-14的最后一个时间步来说,节点s和h的误差只与loss_{\tau}有关:
\frac{\partial Loss}{\partial s{\tau}}=\frac{\partial loss{\tau}}{\partial s{\tau}} = \frac{\partial loss{\tau}}{\partial z{\tau}}\frac{\partial z{\tau}}{\partial s{\tau}} = dz{\tau} \cdot V^T \tag{8}
\begin{aligned} \frac{\partial Loss}{\partial h{\tau}}=\frac{\partial loss{\tau}}{\partial h{\tau}} = \frac{\partial loss{\tau}}{\partial z{\tau}}\frac{\partial z{\tau}}{\partial s{\tau}}\frac{\partial s{\tau}}{\partial h{\tau}} \ &= dz{\tau} \cdot V^T \odot \sigma’(s{\tau}) \rightarrow dh{\tau} \end{aligned} \tag{9}
对于其它时间步来说,节点s的反向误差从红色箭头的2和横向的dh两个方向传回来,比如时间步t3:
\begin{aligned} \frac{\partial Loss}{\partial s3}=\frac{\partial loss_3}{\partial s_3} + \frac{\partial loss_4}{\partial h_4}\frac{\partial h_4}{\partial s_3} \ &=dz_3 \cdot V^T + dh{4} \cdot W^T \end{aligned} \tag{10}
\begin{aligned} \frac{\partial Loss}{\partial h_3}=\frac{\partial Loss}{\partial s_3}\frac{\partial s_3}{\partial h_3} \ &=(dz_3 \cdot V^T + dh_4 \cdot W^T) \odot \sigma’(s_3) \rightarrow dh_3 \end{aligned} \tag{11}
扩展到一般性:
\frac{\partial Loss}{\partial ht}=(dz_t \cdot V^T + dh{t+1} \cdot W^T) \odot \sigma’(s_t) \rightarrow dh_t \tag{12}
从公式12可以看到,求任意时间步t的dht是关键的环节,有了它之后,后面的问题都和全连接网络一样了,在19.1和19.2节中也有讲述具体的方法,在此不再赘述。求dh_t时,是要依赖dh{t+1}的结果的,所以,在通过时间的反向传播时,要先计算最后一个时间步的dh_{\tau},然后按照时间倒流的顺序一步步向前推导。
单输出网络通过时间的反向传播
图19-14描述了一种通用的网络形式,即在每一个时间步都有监督学习信号,即计算损失函数值。另外一种常见的特例是,只有最后一个时间步有输出,需要计算损失函数值,并且有反向传播的梯度产生,而前面所有的其它时间步都没有输出,这种情况如图19-15所示。
这种情况的反向传播比较简单,首先,最后一个时间步的梯度公式9依然不变。但是对于公式10,由于loss_3为0,所以公式简化为:
\begin{aligned} \frac{\partial Loss}{\partial h3}=\frac{\partial loss_4}{\partial h_3} =\frac{\partial loss_4}{\partial h_4}\frac{\partial h_4}{\partial s_3}\frac{\partial s_3}{\partial h_3} \ &=dh{4} \cdot W^T \odot \sigma’(s_3) \end{aligned} \tag{13}
扩展到一般性:
\frac{\partial Loss}{\partial ht}=dh{t+1} \cdot W^T \odot \sigma’(s_t) \rightarrow dh_t \tag{14}
如果有4个时间步,则第一个时间步的h节点的梯度为:
\begin{aligned} \frac{\partial Loss}{\partial h1}=dh{2} \cdot W^T \odot \sigma’(s1) \ =dh_3 \cdot (W^T \odot \sigma’(s_2)) \cdot (W^T \odot \sigma’(s_1)) \ =dh_4 \cdot (W^T \odot \sigma’(s_3)) \cdot (W^T \odot \sigma’(s_2)) \cdot (W^T \odot \sigma’(s_1)) \ =dh_4 \prod{t=1}^3 W^T \odot \sigma’(s_t) \end{aligned}
扩展到一般性:
\frac{\partial Loss}{\partial hk}=dh{\tau} \prod_{t=k}^{\tau-1} W^T \odot \sigma’(s_t) \rightarrow dh_k \tag{16}
dh{\tau}=dz{\tau} \cdot V^T \odot \sigma’(s_{\tau}) \tag{17}
公式17为最后一个时间步的梯度(公式9)的一般形式。
时间步类的设计
时间步类(timestep)的设计是核心,它体现了循环神经网络的核心概念。下面的代码是该类的初始化函数:
初始化
class timestep(object):
def __init__(self, net_type, output_type, isFirst=False, isLast=False):
self.isFirst = isFirst
self.isLast = isLast
self.netType = net_type
if (output_type == OutputType.EachStep):
self.needOutput = True
elif (output_type == OutputType.LastStep and isLast):
self.needOutput = True
else:
self.needOutput = False
- isFirst和isLast参数指定了该实例是否为第一个时间步或最后一个时间步
- netType参数指定了网络类型,回归、二分类、多分类,三种选择
- output_type结合isLast,可以指定该时间步是否有输出,如果是最后一个时间步,肯定有输出;如果不是最后一个时间步,并且如果output_type是OutputType.EachStep,则每个时间步都有输出,否则就没有输出。最后的判断结果记录在self.needOutput上
前向计算
def forward(self, x, U, bu, V, bv, W, prev_s):
...
if (self.isFirst):
self.h = np.dot(x, U) + bu
else:
self.h = np.dot(x, U) + np.dot(prev_s, W) + bu
#endif
self.s = Tanh().forward(self.h)
if (self.needOutput):
self.z = np.dot(self.s, V) + bv
if (self.netType == NetType.BinaryClassifier):
self.a = Logistic().forward(self.z)
elif (self.netType == NetType.MultipleClassifier):
self.a = Softmax().forward(self.z)
else:
self.a = self.z
#endif
#endif
- 如果是第一个时间步,在计算隐层节点值时,则只需要计算np.dot(x, U),prev_s参数为None,不需要计算在内;
- 如果不是第一个时间步,则prev_s参数是存在的,需要增加np.dot(prev_s, W)项;
- 如果该时间步有输出要求,即self.needOutput为True,则计算输出项;
- 如果是二分类,最后的输出用Logistic函数;如果是多分类,用Softmax函数;如果是回归,直接令self.a = self.z,这里的赋值是为了编程模型一致,对外只暴露self.a为结果值。
反向传播
def backward(self, y, prev_s, next_dh):
if (self.isLast):
assert(self.needOutput == True)
self.dz = self.a - y
self.dh = np.dot(self.dz, self.V.T) * Tanh().backward(self.s)
self.dV = np.dot(self.s.T, self.dz)
else:
assert(next_dh is not None)
if (self.needOutput):
self.dz = self.a - y
self.dh = (np.dot(self.dz, self.V.T) + np.dot(next_dh, self.W.T)) * Tanh().backward(self.s)
self.dV = np.dot(self.s.T, self.dz)
else:
self.dz = np.zeros_like(y)
self.dh = np.dot(next_dh, self.W.T) * Tanh().backward(self.s)
self.dV = np.zeros_like(self.V)
#endif
#endif
self.dbv = np.sum(self.dz, axis=0, keepdims=True)
self.dbu = np.sum(self.dh, axis=0, keepdims=True)
self.dU = np.dot(self.x.T, self.dh)
if (self.isFirst):
self.dW = np.zeros_like(self.W)
else:
self.dW = np.dot(prev_s.T, self.dh)
# end if
- 如果是最后一个时间步,则肯定要有监督学习信号,因此会计算dz、dh、dV等参数,但要注意计算dh时,只有np.dot(self.dz, self.V.T)项,因为next_dh不存在;
- 如果不是最后一个时间步,但是有输出(有监督学习信号),仍需要计算dz、dh、dV等参数,并且在计算dh时,需要考虑后一个时间步的next_dh传入,所以dh有np.dot(self.dz, self.V.T)和np.dot(next_dh, self.W.T)两部分组成;
- 如果不是最后一个时间步,并且没有输出,则只计算dh,误差来源是后面的时间步传入的next_dh;
- 如果是第一个时间步,则dW为0,因为prev_s为None(没有前一个时间步传入的状态值),否则需要计算dW = np.dot(prev_s.T, self.dh)。
网络模型类的设计
这部分代码量较大,由于篇幅原因,我们就不把代码全部列在这里了,而只是列出一些类方法。
初始化
- 接收传入的超参数,超参数由使用者指定;
- 创建一个子目录来保存参数初始化结果和训练结果;
- 初始化损失函数类和训练记录类;
- 初始化时间步实例。
前向计算
循环调用所有时间步实例的前向计算方法,遵循从前向后的顺序。要注意的是prev_s变量在第一个时间步是None。
反向传播
循环调用所有时间步实例的反向传播方法,遵循从后向前的顺序。要注意的是prev_s变量在第一个时间步是None,next_dh在最后一个时间步是None。
参数更新
在进行完反向传播后,每个时间步都会针对各个参数有自己的误差矩阵,由于循环神经网络的参数共享特性,需要统一进行更新,即把每个时间步的误差相加,然后乘以学习率,再除以批大小。
以W为例,其更新过程如下:
dw = np.zeros_like(self.W)
for i in range(self.ts):
dw += self.ts_list[i].dW
#end for
self.W = self.W - dw * self.hp.eta / batch_size
一定不要忘记除以批大小,笔者在开始阶段忘记了这一项,结果不得不把全局学习率设置得非常小,才可以正常训练。在加上这一项后,全局学习率设置为0.1就可以正常训练了。
保存和加载网络参数
- 保存/加载初始化网络参数,为了比较不同超参对网络的影响
- 保存/加载训练过程中最低损失函数时的网络参数
- 保存/加载训练结束时的网络参数
网络训练
代码片段如下:
for epoch in range(self.hp.max_epoch):
dataReader.Shuffle()
for iteration in range(max_iteration):
# get data
batch_x, batch_y = GetBatchTrainSamples()
# forward
self.forward(batch_x)
# backward
self.backward(batch_y)
# update
self.update(batch_x.shape[0])
# check loss and accuracy
if (checkpoint):
loss,acc = self.check_loss(X,Y)
- 外循环控制训练的epoch数,并且在每个epoch之间打乱样本顺序
- 内循环控制一个epoch内的迭代次数
- 每次迭代都遵守前向计算、反向传播、更新参数的顺序
- 定期检查验证集的损失函数值和准确度值
代码位置
原代码位置:ch19, Level3_Base
个人代码:Base**