文章目录
1引言
所有代码位于:https://1drv.ms/f/s!AvF6gzVaw0cNjpx9BAtQYGAHFT3gZA?e=jycNfH;
这篇笔记的代码都位于:
RNN_model/RNN.py
;
-
后面会实现基于 Truncated BPTT 的学习,而 Truncated BPTT 会在水平方向上有一个固定T个时刻的RNN层序列;
-
再结合上一节文章中关于 Truncated BPTT 的mini-batch学习的数据输入顺序,接下来在实现RNN的时候会将水平方向上延伸的神经网络实现为一个层,并将这个层命名为Time RNN层,简写为TRNN;如下图所示;
-
同时,我们再强调一下接下来的约定:
- 将进行 Time RNN 层中的单步处理的层称为“RNN 层”
- 将一次处理 T 步的层称为“Time RNN 层”
2单步RNN层的实现
2.1计算过程的维度分析
- 这里我们考虑的是mini-batch的情况;
-
单个RNN层进行的计算如式(1)所示:
h t = tanh ( h t ) = tanh ( h t − 1 W h + x t W x + b ) (1) \boldsymbol{h}_t=\tanh(\boldsymbol{h}_t)=\tanh(\boldsymbol{h}_{t-1}\boldsymbol{W}_h+\boldsymbol{x}_t\boldsymbol{W}_x+\boldsymbol{b}) \tag{1} ht=tanh(ht)=tanh(ht−1Wh+xtWx+b)(1) -
我们在上一篇文章中也写了考虑一个输入数据的情况下,计算过程的维度变化,如下式所示:
( 1 , H ) = t a n h ( ( 1 , H ) ⋅ ( H , H ) + ( 1 , D ) ⋅ ( D , H ) + ( 1 , H ) ) (2) (1,H)=tanh((1,H)\cdot(H,H)+(1,D)\cdot(D,H)+(1,H)) \tag{2} (1,H)=tanh((1,H)⋅(H,H)+(1,D)⋅(D,H)+(1,H))(2) -
那么,扩展到mini-batch样本的话,单个RNN层的维度变化如下图所示:
- 变化的只是单个RNN层的输入,即
x
t
\boldsymbol{x}_t
xt和
h
t
−
1
\boldsymbol{h}_{t-1}
ht−1,第一个维度从
1
变成了N
; - 因此可以说,网络的权重矩阵维度是不变的,不管是一条数据还是一批数据;因为每次一条数据可以训练网络,每次一批数据也可以训练网络,mini-batch的目的是加速训练过程,同时批处理情况下更新权重更稳定;
- 图中没写 b \boldsymbol{b} b, b \boldsymbol{b} b可以保持 ( 1 , H ) (1,H) (1,H)不变,只是在计算加法的时候需要进行广播,也即本书中说到的repeat节点;
- 变化的只是单个RNN层的输入,即
x
t
\boldsymbol{x}_t
xt和
h
t
−
1
\boldsymbol{h}_{t-1}
ht−1,第一个维度从
2.2代码实现
2.2.1初始化和前向传播
-
初始化只需要初始化三个参数矩阵及其梯度变量;
-
前向传播直接根据公式进行就可以了;计算完后保存的输入和输出在反向传播时将会使用;
-
代码如下:
class RNN: def __init__(self, Wx, Wh, b): self.params = [Wx, Wh, b] self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)] self.cache = None def forward(self, x, h_prev): ''' @param x:(N,D);当前时刻的输入; @param h_prev:(N,H);上一个RNN单元的输出;''' Wx, Wh, b = self.params t = np.dot(h_prev, Wh) + np.dot(x, Wx) + b # t:(N,H) h_next = np.tanh(t) # h_next:(N,H) self.cache = (x, h_prev, h_next) return h_next
2.2.2反向传播
-
单个RNN层的反向传播计算图如下图所示:
-
从后往前看一下反向传播的过程:
-
y
=
t
a
n
h
(
x
)
y=tanh(x)
y=tanh(x)的导数为:
y
′
=
1
−
y
2
y^{'}=1-y^2
y′=1−y2;前向计算时,
t
a
n
h
(
x
)
tanh(x)
tanh(x)作用在了式(1)
h
t
\boldsymbol{h}_t
ht的每一个元素上,且各个元素互不干扰;因此反向传播到图中tanh节点输入侧时的梯度需要对输出侧的每个元素的梯度乘上对应的局部梯度;即:
dt = dh_next * (1 - h_next ** 2)
; - 来看反向传播时第一个加法结点:加法不管是两个标量还是两个向量还是两个矩阵,要想相加,维度肯定是相同的,并且肯定也是元素级别的加法,即对应位置元素相加,所以元素之间也是互不影响的;因此就可以按照本书中说的加法的反向传播规律传递上游梯度;
- 那么①和②处的梯度就都是
dt
; - 前面的章节中还说到,
b
\boldsymbol{b}
b再参与加法的时候会进行广播,广播即复制了
N
份,因此反向传播的梯度需要累计到最初的一份 b \boldsymbol{b} b上;所以需要按列求和累加梯度;即db = np.sum(dt, axis=0)
;
- 那么①和②处的梯度就都是
- 来看反向传播时第二个加法结点:第二处在前向计算时也是两个维度相同的矩阵相加;因此②处的梯度传递到③、④处都是
dt
; - 接下来就是两个标准的矩阵乘法了;关于矩阵乘法的反向传播的理解在这篇文章;直接套公式就行;
-
y
=
t
a
n
h
(
x
)
y=tanh(x)
y=tanh(x)的导数为:
y
′
=
1
−
y
2
y^{'}=1-y^2
y′=1−y2;前向计算时,
t
a
n
h
(
x
)
tanh(x)
tanh(x)作用在了式(1)
h
t
\boldsymbol{h}_t
ht的每一个元素上,且各个元素互不干扰;因此反向传播到图中tanh节点输入侧时的梯度需要对输出侧的每个元素的梯度乘上对应的局部梯度;即:
-
这样,就可以实现反向传播;代码如下:
def backward(self, dh_next): ''' 输入当前时刻的输出侧的梯度;返回当前时刻输入的梯度以及上一时刻输出的梯度; @param dh_next:(N,H);当前RNN单元输出侧的梯度; @return dx:(N,D);当前时刻的梯度;dh_prev:(N,H);上一个RNN单元输出侧的梯度;''' Wx, Wh, b = self.params x, h_prev, h_next = self.cache dt = dh_next * (1 - h_next ** 2) # dt:(N,H) db = np.sum(dt, axis=0) # db:(1,H) dWh = np.dot(h_prev.T, dt) # dWh:(H,H) dh_prev = np.dot(dt, Wh.T) # dh_prev:(N,H) dWx = np.dot(x.T, dt) # dWx:(D,H) dx = np.dot(dt, Wx.T) # dx:(N,D) self.grads[0][...] = dWx self.grads[1][...] = dWh self.grads[2][...] = db return dx, dh_prev
3 TRNN的实现
-
TRNN内部有多个RNN层,每个RNN层在前向计算时信息流是不会断的,只在反向传播的时候以块为单位进行;因此就涉及到每一个TRNN层之间的隐藏状态的传递问题,这里使用变量
h
来保存,如下图所示:
3.1 TRNN的代码实现
3.1.1初始化工作
-
这里的TRNN只有一组 W h \boldsymbol{W}_h Wh、 W x \boldsymbol{W}_x Wx、 b \boldsymbol{b} b权重参数及其梯度;在前向计算时会生成固定数量的RNN层用于计算,然后在反向传播时计算这些RNN层的梯度,并最终累加到这一组 W h \boldsymbol{W}_h Wh、 W x \boldsymbol{W}_x Wx、 b \boldsymbol{b} b权重参数的梯度中。
-
因此初始化主要是初始化参数及其梯度;
-
成员变量
layers
在列表中保存多个 RNN 层 -
另外:
- 设置
self.h
保存TRNN的隐藏状态(如上图所示); - 设置
stateful
参数:该参数为true
时,无论时序数据多长,Time RNN 层的正向传播都可以不中断地进行;若为false
,每次前向传播时,第一个 RNN 层的隐藏状态都会被初始化为零矩阵;这是没有状态的模式,称为“无状态”。- 该参数不影响反向传播,即反向传播就是每个块内单独进行,不继承上一个TRNN块传来的梯度;
- 设置
-
考虑到TimeRNN类的扩展性,将设定Time RNN层的隐藏状态的方法实现为
set_state(h)
。另外,将重设隐藏状态的方法实现为reset_state()
;目前暂未使用; -
初始化代码如下:
class TimeRNN: def __init__(self, Wx, Wh, b, stateful=False): ''' @param Wx:(D,H); @param Wh:(H,H); @param b:(1,H);''' 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.dh = None, None self.stateful = stateful def set_state(self, h): self.h = h def reset_state(self): self.h = None
3.1.2前向传播
-
每次调用前向传播,成员变量
layers
列表**都被清空**; -
如果TRNN层是无状态的,那么TRNN的隐藏状态
self.h
就是零矩阵,TRNN层之间的前向传播就是中断的; -
根据设定的TRNN块的大小(即T值)生成相应数量的RNN层(都来自TRNN初始化时的参数矩阵);然后依次用输入数据中每个时刻的数据计算每个RNN层的输出
-
代码如下:
- T个RNN层;第一次的TRNN层的第一个RNN层的
h_prev
是零矩阵;每个RNN层的输出self.h
进入下一个RNN层;如果TRNN是有状态的,那么下一次的TRNN层的第一个RNN层的h_prev
就不是零矩阵,否则还是零矩阵;
def forward(self, xs): ''' @param xs:(N,T,D);TRNN层的输入数据; @return hs:(N,T,H);TRNN层的输出数据;''' Wx, Wh, b = self.params N, T, D = xs.shape D, H = Wx.shape self.layers = [] # 每次调用forward都会清空 hs = np.empty((N, T, H), dtype='f') # 初始化TRNN层的输出 if not self.stateful or self.h is None: # 当第二次开始执行forward时,如果是无状态则会重新将h初始化为0 self.h = np.zeros((N, H), dtype='f') for t in range(T): layer = RNN(*self.params) self.h = layer.forward(xs[:, t, :], self.h) # RNN层的输出 hs[:, t, :] = self.h self.layers.append(layer) # 保存层的信息在反向传播的时候用 return hs
- T个RNN层;第一次的TRNN层的第一个RNN层的
3.1.3反向传播
- 先总结:
- TRNN层虽然有多个RNN层,但是相当于每个RNN层共用一套参数;反向传播的时候每个RNN层计算出来的参数梯度都会进行累加,最终作为TRNN层的参数梯度。
-
TRNN层的反向传播图如下图所示:
- 由于是截断的RNN,因此每个RNN层都不接收来自上游TRNN层传过来的梯度
d
h
d\boldsymbol{h}
dh;但这里在实现的时候依然是将
d
h
d\boldsymbol{h}
dh保存在了成员变量
self.dh
中;
- 由于是截断的RNN,因此每个RNN层都不接收来自上游TRNN层传过来的梯度
d
h
d\boldsymbol{h}
dh;但这里在实现的时候依然是将
d
h
d\boldsymbol{h}
dh保存在了成员变量
-
其中每一个RNN层的反向传播图如下图所示:
- 由于每一个RNN层的输出形成了分支,因此,除了最后一个RNN层以外,其余的RNN层的输出侧梯度是两个分支梯度的和;
- 得到输出侧梯度之后,就可以用前面实现的单个RNN层的反向传播方法计算当前RNN层的
dx
,dh_prev
;
-
反向传播的代码如下所示:
def backward(self, dhs): ''' @param dhs:(N,T,H);TRNN层的输出侧的梯度; @return dxs:(N,T,D);TRNN层的输入侧的梯度;''' Wx, Wh, b = self.params N, T, H = dhs.shape D, H = Wx.shape dxs = np.empty((N, T, D), dtype='f') # dxs:(N,T,D) dh = 0 # 每个TRNN层右侧传来的初始梯度都是0 grads = [0, 0, 0] for t in reversed(range(T)): # 从右往左遍历每一个RNN层 layer = self.layers[t] # 取出相应的前向传播里面保存的每个RNN层 dx, dh = layer.backward(dhs[:, t, :] + dh) # dx:(N,D);dh:(N,H) dxs[:, t, :] = dx # 保存对应的输入侧的梯度 for i, grad in enumerate(layer.grads): # 把当前这个RNN层的反向传播的梯度结果累加到grads里面; # grads中每个元素都是一个矩阵(向量),因此这里的累加是对应元素的加法 grads[i] += grad # 循环结束,此时grads列表中每个参数的梯度是T个RNN层对应参数梯度的和 # 最终要将累加的梯度保存到TRNN成员变量self.grads的对应位置 for i, grad in enumerate(grads): self.grads[i][...] = grad self.dh = dh return dxs
4基于RNN的语言模型的实现
基于 RNN 的语言模型称为 RNNLM(RNN Language Model,RNN 语言模型)
4.1 RNNLM的基本结构
-
用于表示语言模型的RNN结构图如下图所示(左图是展开前,右图是展开后)
- 由于处理的是语言模型,因此处理的是句子单词;所以最开始需要使用前面说过的Embedding层,将单词 ID 转化为单词的分布式表示(单词向量);
- 每个单词向量经过RNN层之后得到隐藏状态,传递到下一个层,即图中的Affine层(仿射变换,是线性变换和平移变换的叠加);
- 变换后,当前时刻的结果输入到softmax层之后,转换为概率,完成**对下一个位置单词的预测**;
-
以
you say goodbye and i say hello.
为例子,RNNLM的处理过程如下图所示:- 由于RNN层的数据输入是按顺序的,因此当输入单词goodbye时,RNN层已经处理过前面的两个单词了;即**RNN能够记忆上下文**;
- 换句话说就是,RNN 将“you say”这一过去的信息保存为了简短的隐藏状态向量(即上面TRNN部分前向传播中的
self.h
);这个隐藏状态向量将输入到下图中上方的Affine层以及下一个单词的RNN层作为h_prev
;
-
类似于前面将T个RNN层合并为一个TRNN层,这里需要将Embedding层、Affine层、Softmax层也合并为一个Time层;合并之后的网络结构就如下图所示:
4.2 Time Embedding层的实现
-
Time Embedding层即包含了T个Embedding层的计算;和TRNN类似,Time Embedding层也是只用一套权重参数,在一次forward和backward中完成对参数梯度的一次计算;
-
其他就没什么需要注意的地方了;代码如下:
class TimeEmbedding: def __init__(self, W): self.params = [W] self.grads = [np.zeros_like(W)] self.layers = None self.W = W def forward(self, xs): ''' @param xs:(N,T);T是时间步数;N是批量大小;元素值是单词ID; @return out:(N,T,D);每条数据每个单词对应的单词向量;''' N, T = xs.shape V, D = self.W.shape out = np.empty((N, T, D), dtype='f') # 初始化Embedding结果 self.layers = [] # 每次forward都是重新构建Embedding层 for t in range(T): # 为每一个时间步构建一个Embedding层;用相同的权重 layer = Embedding(self.W) out[:, t, :] = layer.forward(xs[:, t]) # 调用Embedding层的forward函数 self.layers.append(layer) # 保存每一个Embedding层,反向传播时需要用 return out def backward(self, dout): '''@param dout:(N,T,D);TRNN层传递来的梯度;''' N, T, D = dout.shape grad = 0 # 由于每个Embedding层都是使用同一个权重,即权重共享,因此梯度要进行累加; for t in range(T): layer = self.layers[t] # t时刻的Embedding层 layer.backward(dout[:, t, :]) # t时刻的dout进行反向传播 grad += layer.grads[0] # 梯度累加 self.grads[0][...] = grad # 将这一次的梯度保存到成员变量中 return None
4.3 Time Affine层的实现
-
这个层其实就是线性变换,然后加了偏置;
-
代码中为了加速,会有类似
rx = x.reshape(N*T, -1)
的语句:- 因为输入到Affine层的是TRNN层的隐藏状态向量,维度为
(N,T,H)
; - 而Affine层的作用是对隐藏状态向量最后的那个特征维度
H
进行变换,变换到V
,以便后续进行Softmax; - 因此,这个时候,前面的
N
和T
两个维度在Affine层看来其实都是”mini-batch“;所以可以将前面两个维度合并,通过矩阵乘法一次性处理掉,而不是使用循环; - forward的时候这样进行加速;backward的时候也可以类似地方法进行加速;详见下方代码;
- 因为输入到Affine层的是TRNN层的隐藏状态向量,维度为
-
代码如下:
class TimeAffine: def __init__(self, W, b): ''' @param W:(H,V); @param b:(V,);''' self.params = [W, b] self.grads = [np.zeros_like(W), np.zeros_like(b)] self.x = None def forward(self, x): ''' TRNN层的输出进入到TAffine层; @param x:(N,T,H);N是批量大小;T是时间步数;H是隐藏状态维度;''' N, T, H = x.shape W, b = self.params rx = x.reshape(N*T, -1) # (N*T,H) out = np.dot(rx, W) + b # out:(N*T,V);+b时会进行广播 self.x = x return out.reshape(N, T, -1) # (N,T,V) def backward(self, dout): ''' 输入当前时刻的输出侧的梯度;返回当前时刻输入侧的梯度; @param dout:(N,T,V); @return dx:(N,T,H);''' x = self.x N, T, D = x.shape W, b = self.params dout = dout.reshape(N*T, -1) # (N*T,V) rx = x.reshape(N*T, -1) # (N*T,H) db = np.sum(dout, axis=0) # 因为forward时+b进行了广播,即repeat节点;所以这里梯度要在列方向上累加;db:(V,) # 套用矩阵乘法求梯度的公式 dW = np.dot(rx.T, dout) # (H,V) dx = np.dot(dout, W.T) # (N*T,H) dx = dx.reshape(*x.shape) # 还原维度:(N,T,H) self.grads[0][...] = dW self.grads[1][...] = db return dx
4.4 Time Softmax with Loss层
之前我们就是将softmax的计算和损失计算合并为一层进行处理;这里在此基础上,实现Time Softmax with Loss层。
-
softmax和损失计算是没有权重参数的;
-
T
个 Softmax with Loss 层各自算出损失,然后将它们加在一起取平均,将得到的值作为最终的损失,如下式所示:- 从代码层面来看,因为Time xxx层都是共享的权重,最终优化的都是同一组参数,因此这里T也相当于某笔数据里面的小batch;所以对所有的单层的损失求和,数据量是N*T,而不仅仅是公式中所说的T;
L = 1 T ( L 0 + L 1 + ⋯ + L T − 1 ) (3) L=\frac{1}{T}(L_0+L_1+\cdots+L_{T-1}) \tag{3} L=T1(L0+L1+⋯+LT−1)(3)
-
其他就没啥需要指出的地方了;损失计算和之前一样;
-
Time Softmax with Loss层的初始化和前向传播代码如下:
class TimeSoftmaxWithLoss: def __init__(self): self.params, self.grads = [], [] # softmax和损失计算是没有权重参数的 self.cache = None self.ignore_label = -1 def forward(self, xs, ts): ''' 输入是TAffine层的输出以及真实标签;假设真实标签这里是独热编码; @param xs:(N,T,V); @param ts:(N,T,V);''' N, T, V = xs.shape if ts.ndim == 3: # 在监督标签为one-hot向量的情况下 # 获取每个真实标签值(即索引) ts = ts.argmax(axis=2) # 维度为(N,T) mask = (ts != self.ignore_label) # 这里应该是考虑:如果每个TRNNLM块里面数据不够填充了数据,那这些填充的数据的标签就是-1 # 按批次大小和时序大小进行整理(reshape) # 因为Time xxx层都是共享的权重,最终优化的都是同一组参数,因此这里T也相当于某笔数据里面的小batch # 这样进行reshape一来不影响模型参数训练和优化的目标,二来可以利用矩阵计算的优势进行加速 xs = xs.reshape(N * T, V) # (N*T,V) ts = ts.reshape(N * T) # (N*T,) mask = mask.reshape(N * T) # (N*T,) ys = softmax(xs) # (N*T,V) ls = np.log(ys[np.arange(N * T), ts]) # 计算损失的过程与最开始说的softmax一样; ls:(N*T,) ls *= mask # 与ignore_label相应的数据将损失设为0; ls:(N*T,) # 求和再除以有效的数据量 loss = -np.sum(ls) loss /= mask.sum() self.cache = (ts, ys, mask, (N, T, V)) return loss
-
Time Softmax with Loss层的前向传播计算图如下图所示:
- 都是常规的节点:加法、乘法;
- softmax-with-loss的局部梯度在之前的笔记中也写过了,即 y − t \boldsymbol{y}-\boldsymbol{t} y−t;
- 考虑到可能存在时序数据填充的情况,因此最后的梯度要将填充时刻的梯度置为0;
-
Time Softmax with Loss层的反向传播代码如下:
- 根据链式求导法则,最终是各个梯度的乘积;因此先后顺序其实无所谓;所以在下面的代码中乘积的顺序不是严格按照反向传播顺序的;
def backward(self, dout=1): ''' @param dout: 损失函数的梯度;该层是最后一个层,因此dout=1''' ts, ys, mask, (N, T, V) = self.cache dx = ys # (N*T,V) dx[np.arange(N * T), ts] -= 1 # 这里就是softmax-with-loss的局部梯度 dx *= dout dx /= mask.sum() # (N*T,V) # mask[:, np.newaxis]将mask的维度从(N*T,)变成(N*T,1) dx *= mask[:, np.newaxis] # 与ignore_label相应的数据将梯度设为0 dx = dx.reshape((N, T, V)) return dx