文章目录
1 LSTM结构整理
-
一个LSTM层结构如下图:包括输出门、遗忘门、新增信息的tanh节点,以及输入门:
-
涉及如下公式:
f = σ ( x t W x ( f ) + h t − 1 W h ( f ) + b ( f ) ) (1) \boldsymbol{f}=\sigma\left(\boldsymbol{x}_t \boldsymbol{W}_x^{(\mathrm{f})}+\boldsymbol{h}_{t-1} \boldsymbol{W}_h^{(\mathrm{f})}+\boldsymbol{b}^{(\mathrm{f})}\right) \tag{1} f=σ(xtWx(f)+ht−1Wh(f)+b(f))(1)g = tanh ( x t W x ( g ) + h t − 1 W h ( g ) + b ( g ) ) (2) \boldsymbol{g}=\tanh \left(\boldsymbol{x}_t \boldsymbol{W}_x^{(\mathrm{g})}+\boldsymbol{h}_{t-1} \boldsymbol{W}_h^{(\mathrm{g})}+\boldsymbol{b}^{(\mathrm{g})}\right) \tag{2} g=tanh(xtWx(g)+ht−1Wh(g)+b(g))(2)
i = σ ( x t W x ( i ) + h t − 1 W h ( i ) + b ( i ) ) (3) \boldsymbol{i}=\sigma\left(\boldsymbol{x}_t \boldsymbol{W}_x^{(\mathrm{i})}+\boldsymbol{h}_{t-1} \boldsymbol{W}_h^{(\mathrm{i})}+\boldsymbol{b}^{(\mathrm{i})}\right) \tag{3} i=σ(xtWx(i)+ht−1Wh(i)+b(i))(3)
o = σ ( x t W x ( o ) + h t − 1 W h ( o ) + b ( o ) ) (4) \boldsymbol{o}=\sigma\left(\boldsymbol{x}_t \boldsymbol{W}_x^{(\mathrm{o})}+\boldsymbol{h}_{t-1} \boldsymbol{W}_h^{(\mathrm{o})}+\boldsymbol{b}^{(\mathrm{o})}\right) \tag{4} o=σ(xtWx(o)+ht−1Wh(o)+b(o))(4)
c t = f ⊙ c t − 1 + g ⊙ i (5) \boldsymbol{c}_t=\boldsymbol{f}\odot\boldsymbol{c}_{\boldsymbol{t}-1}+\boldsymbol{g}\odot i \tag{5} ct=f⊙ct−1+g⊙i(5)
h t = o ⊙ tanh ( c t ) (6) \boldsymbol{h}_t=\boldsymbol{o}\odot\tanh(\boldsymbol{c}_t) \tag{6} ht=o⊙tanh(ct)(6)
-
前面四个公式都是两个权重矩阵和一个偏置参数;为了充分利用矩阵计算进行加速,可以将这四组矩阵(向量)合在一起,如下图所示:
- 对于一个LSTM层来说,由于 x t \boldsymbol{x}_t xt和 h t − 1 \boldsymbol{h}_{t-1} ht−1都是行向量,因此将几个仿射变换在列方向上进行堆叠和分开计算的效果是一样的;
- 每一个 b \boldsymbol{b} b在加法的时候都是广播到前面计算结果的每一行上,因此将四个 b \boldsymbol{b} b在列方向堆叠也是没有问题的。
-
整合之后的仿射变换维度变化如下图所示
-
将四个仿射变换整合到一起,就可以将LSTM的计算图变成下面这样:
- 左侧部分其实又好像回到了RNN的结构图,即两个矩阵乘法+一个偏置加法;
- 图中的slice节点用于取出对应的结果;虽然每个权重矩阵不同,但是形状是一样的,因此取出的过程也是均等分成四份(列方向上);
2 LSTM的代码实现
代码项目目录:https://1drv.ms/f/s!AvF6gzVaw0cNjpx9BAtQYGAHFT3gZA?e=fLpzP6;
代码位于:
LSTM_model/LSTM.py
;
2.1初始化和前向传播
-
初始化代码如下:
class LSTM: def __init__(self, Wx, Wh, b): '''输入参数都是整合了四个仿射变换的参数 @param Wx: (D, 4H) @param Wh: (H, 4H) @param b: (1, 4H)''' self.params = [Wx, Wh, b] self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)] self.cache = None
-
前向计算代码如下:
- 和前面一样: N N N表示批处理大小, D D D表示输入数据的维度,记忆单元和隐藏状态的维数都是 H H H;
def forward(self, x, h_prev, c_prev): ''' @param x: (N, D) @param h_prev: (N, H) @param c_prev: (N, H)''' Wx, Wh, b = self.params N, H = h_prev.shape A = np.dot(x, Wx) + np.dot(h_prev, Wh) + b # A:(N,4H) # 从整合的结果中取出对应的结果;从列方向上进行切片 f = A[:, :H] # (N,H) g = A[:, H:2*H] # (N,H) i = A[:, 2*H:3*H] # (N,H) o = A[:, 3*H:] # (N,H) # 将三个门的输出转换为权重 f = sigmoid(f) # 遗忘门 (N,H) i = sigmoid(i) # 输入门 (N,H) o = sigmoid(o) # 输出门 (N,H) # 计算新增记忆 g = np.tanh(g) # (N,H) # 计算遗忘了一些信息&加入新的信息之后的当前时刻的记忆单元 c_next = f * c_prev + g * i # (N,H) # 基于新的记忆单元计算当前时刻的输出,也即隐藏状态 h_next = o * np.tanh(c_next) # (N,H) self.cache = (x, h_prev, c_prev, i, f, g, o, c_next) return h_next, c_next
2.2反向传播
-
反向传播的计算图参考下图:
-
在三个门以及一个tanh节点之前的梯度传播情况如下图所示;代码如下:
def backward(self, dh_next, dc_next): ''' @param dh_next: (N,H);水平方向上后一个时刻传递来的&垂直方向上后面的层传递来的梯度两者的累加 @param dc_next: (N,H);水平方向上后一个时刻传递来的记忆单元的梯度''' Wx, Wh, b = self.params x, h_prev, c_prev, i, f, g, o, c_next = self.cache tanh_c_next = np.tanh(c_next) ds = dc_next + (dh_next * o) * (1 - tanh_c_next ** 2) dc_prev = ds * f di = ds * g df = ds * c_prev do = dh_next * tanh_c_next dg = ds * i
-
然后计算一下三个门以及tanh节点处的输入侧梯度;sigmoid导数以及tanh的导数都知道了;所以直接局部梯度*输出侧梯度即可;
# sigmoid函数和tanh节点处输入侧的梯度;=局部梯度*各自输出侧梯度 di *= i * (1 - i) df *= f * (1 - f) do *= o * (1 - o) dg *= (1 - g ** 2)
-
slice节点就是从整合的矩阵中取出对应的列;不涉及计算;因此反向传播时梯度直接整合回原样即可;
# 按列将梯度拼接;拼接顺序要和forward的时候取出来的顺序保持一致 dA = np.hstack((df, dg, di, do))
-
然后就和RNN部分的反向传播一样了:
- db的计算由于b会进行广播,因此db也是需要按列累加的;
-
全部的反向传播代码+注释如下:
def backward(self, dh_next, dc_next): ''' @param dh_next: (N,H);水平方向上后一个时刻传递来的&垂直方向上后面的层传递来的梯度两者的累加 @param dc_next: (N,H);水平方向上后一个时刻传递来的记忆单元的梯度 @return dx: (N,D);输入数据的梯度 @return dh_prev: (N,H);水平方向上上一个时刻隐藏状态的梯度; @return dc_prev: (N,H);水平方向上上一个时刻记忆单元的梯度;''' Wx, Wh, b = self.params x, h_prev, c_prev, i, f, g, o, c_next = self.cache tanh_c_next = np.tanh(c_next) # (N,H) ds = dc_next + (dh_next * o) * (1 - tanh_c_next ** 2) # (N,H) dc_prev = ds * f # (N,H) di = ds * g # (N,H) df = ds * c_prev # (N,H) do = dh_next * tanh_c_next # (N,H) dg = ds * i # (N,H) # sigmoid函数和tanh节点处输入侧的梯度;=局部梯度*各自输出侧梯度 di *= i * (1 - i) # (N,H) df *= f * (1 - f) # (N,H) do *= o * (1 - o) # (N,H) dg *= (1 - g ** 2) # (N,H) # 按列将梯度拼接;拼接顺序要和forward的时候取出来的顺序保持一致 dA = np.hstack((df, dg, di, do)) # (N,4H) dWh = np.dot(h_prev.T, dA) # (H,4H)=(H,N)x(N,4H) dWx = np.dot(x.T, dA) # (D,4H)=(D,N)x(N,4H) db = dA.sum(axis=0) # (1,4H) self.grads[0][...] = dWx self.grads[1][...] = dWh self.grads[2][...] = db dx = np.dot(dA, Wx.T) # (N,D)=(N,4H)x(4H,D) dh_prev = np.dot(dA, Wh.T) # (N,H)=(N,4H)x(4H,H) return dx, dh_prev, dc_prev
3 Time LSTM层的实现
和TRNN一样,TLSTM层是整体处理 T T T个时序数据的层;
代码位于:
LSTM_model/LSTM.py
;
3.1 Time LSTM层的结构
-
TLSTM层如下图所示:
-
为了forward时,在每个TLSTM层之间传递隐藏状态和记忆单元,需要保存中间结果,如下图所示:
3.2 Time LSTM层的代码实现
3.2.1初始化
-
代码如下:
class TimeLSTM: def __init__(self, Wx, Wh, b, stateful=False): # 和TRNN一样,每个LSTM层都用同一组[Wx, Wh, b];这一组[Wx, Wh, b]是整合了四个仿射变换的大矩阵 self.params = [Wx, Wh, b] self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)] self.layers = None self.h, self.c = None, None # 保存中间状态,在TimeLSTM层间传播 self.dh = None self.stateful = stateful
3.2.2前向传播
-
和TRNN一样,TLSTM的每个LSTM层实际上是本身,只是在水平方向上展现了一种数据输入的顺序;因此TLSTM每次forward时会生成 T T T个LSTM层,计算之后,在backward时会将每个LSTM层的的梯度累加;
-
所以代码也无需多解释;如下所示:
def forward(self, xs): Wx, Wh, b = self.params N, T, D = xs.shape H = Wh.shape[0] self.layers = [] # 每次调用forward都是重新生成T个层; hs = np.empty((N, T, H), dtype='f') if not self.stateful or self.h is None: self.h = np.zeros((N, H), dtype='f') if not self.stateful or self.c is None: self.c = np.zeros((N, H), dtype='f') for t in range(T): layer = LSTM(*self.params) self.h, self.c = layer.forward(xs[:, t, :], self.h, self.c) hs[:, t, :] = self.h self.layers.append(layer) return hs
3.2.3反向传播
-
代码如下:
def backward(self, dhs): ''' @param dhs: (N, T, H) @return dxs: (N, T, D)''' Wx, Wh, b = self.params N, T, H = dhs.shape D = Wx.shape[0] dxs = np.empty((N, T, D), dtype='f') dh, dc = 0, 0 # 每次反向传播时都是不接收上一个TLSTM层的梯度的 grads = [0, 0, 0] for t in reversed(range(T)): layer = self.layers[t] dx, dh, dc = layer.backward(dhs[:, t, :] + dh, dc) dxs[:, t, :] = dx for i, grad in enumerate(layer.grads): # 将每一个时间步的权重参数梯度累加 grads[i] += grad for i, grad in enumerate(grads): self.grads[i][...] = grad self.dh = dh # 其实也没用,因为下一次backward的时候也不考虑上一个TLSTM层的梯度 return dxs
4使用LSTM对语言模型建模
- 这里我们仍然称之为RNN;前面用RNN进行语言建模的我们叫simple RNN;
- 代码位于:
LSTM_model/LSTMLM.py
;
-
与RNN相比,这里的区别就在于将TRNN层替换为TLSTM层;如下图所示:
4.1初始化
-
包括初始化权重和初始化层,过程基本和RNNLM一样,详见
2.3-基于RNN的语言模型的学习与评价
;需要指出的是:- 由于LSTM层在实现时将四个仿射变换整合在了一起,因此这里在初始化权重时就需要初始化整合之后的矩阵;
- 初始化代码如下
class Rnnlm: def __init__(self, vocab_size=10000, wordvec_size=100, hidden_size=100): V, D, H = vocab_size, wordvec_size, hidden_size rn = np.random.randn # 初始化权重 embed_W = (rn(V, D) / 100).astype('f') # LSTM层的整合之后的四个仿射变换 lstm_Wx = (rn(D, 4 * H) / np.sqrt(D)).astype('f') lstm_Wh = (rn(H, 4 * H) / np.sqrt(H)).astype('f') lstm_b = np.zeros(4 * H).astype('f') # (4H,) affine_W = (rn(H, V) / np.sqrt(H)).astype('f') affine_b = np.zeros(V).astype('f') # 生成层 self.layers = [ TimeEmbedding(embed_W), TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=True), # forward时信息流不中断 TimeAffine(affine_W, affine_b) ] self.loss_layer = TimeSoftmaxWithLoss() self.lstm_layer = self.layers[1] # 将所有的权重和梯度整理到列表中 self.params, self.grads = [], [] for layer in self.layers: self.params += layer.params self.grads += layer.grads
4.2前向计算
-
单从语言模型建模这个层面看,LSTMLM和RNNLM的前向计算完全一样;这里在实现的时候拆成了两个部分:一是计算得分的过程;二是计算损失的部分;代码如下:
def predict(self, xs): ''' @param xs: (N, T);输入的数据; @return xs: (N, V);Affine层之后的输出,即得分;''' # 具体每个Time xxx层进行前向传播(不包括损失计算) for layer in self.layers: xs = layer.forward(xs) return xs def forward(self, xs, ts): ''' @param xs: (N, T);输入的数据; @param ts: (N, T);监督数据;(N,T)时不是独热编码形式; @return loss: 损失值;''' score = self.predict(xs) loss = self.loss_layer.forward(score, ts) return loss
4.3反向传播
-
和RNNLM也是一样的,如下图所示:
4.4其他
- 另外,增加了读取和保存训练参数的函数load_params、save_params;详见代码;
5在PTB数据集上进行训练和测试
- 主要过程和用RNNLM是一样的;这里主要说一下区别;
- 代码位于:
LSTM_model/train_LSTMLM.py
;
-
读取数据之后,使用前面构建的TLSTM版本的语言模型来构建模型,生成的model如下图所示:
-
由于LSTMLM与RNNLM的区别只是将Time RNN换成了Time LSTM,其他都不变(包括mini-batch数据的组织),因此训练类就不需要变了,延用RNNLM使用的训练类;
- 这里将PTB训练集的数据都进行了训练;还是和RNNLM一样,将训练集视为一个长句子,然后根据
batch_size
的大小分成相应数量的句子;
- 这里将PTB训练集的数据都进行了训练;还是和RNNLM一样,将训练集视为一个长句子,然后根据
-
为了能够在GPU上运行,在各个需要转移数据和权重到GPU上的地方,加了是否使用GPU的判断;
-
另外,在运行的时候,可能存在overflow的警告:
- RuntimeWarning: overflow encountered in exp
- 看了这个博客,将sigmoid函数换成了tanh的方法;如果采用循环的方法,会非常慢,所以不建议;
-
而且,这里梯度裁剪非常重要;LSTM里面也是有矩阵乘法的,所以需要用上梯度裁剪;否则梯度变化幅度大,模型训练不稳定,困惑度跑不出书上的那个结果;
-
如下图所示是一开始的实验结果:
- 可以看到左图,存在溢出的情况,导致困惑度巨大;
- 将sigmoid函数换成了tanh的方法之后正常了,右图;但是困惑度只能降到一千多;
-
然后在训练时,加入了梯度裁剪,能够跑出与书本上差不多的实验结果;