神经网络基础(五)——循环神经网络

1. 语言模型

和电脑玩一个游戏,我们写出一个句子前面的一些词,然后,让电脑帮我们写下接下来的一个词。比如下面这句:

我昨天上学迟到了,老师批评了____。

语言模型就是这样的东西:给定一个一句话前面的部分,预测接下来最有可能的一个词是什么。
使用RNN之前,语言模型主要是采用N-Gram。N可以是一个自然数,比如2或者3。它的含义是,假设一个词出现的概率只与前面N个词相关。我们以2-Gram为例。首先,对前面的一句话进行切词:

我 昨天 上学 迟到 了 ,老师 批评 了 ____。

如果用2-Gram进行建模,那么电脑在预测的时候,只会看到前面的『了』,然后,电脑会在语料库中,搜索『了』后面最可能的一个词。不管最后电脑选的是不是『我』,我们都知道这个模型是不靠谱的,因为『了』前面说了那么一大堆实际上是没有用到的。如果是3-Gram模型呢,会搜索『批评了』后面最可能的词,感觉上比2-Gram靠谱了不少,但还是远远不够的。因为这句话最关键的信息『我』,远在9个词之前!

现在读者可能会想,可以提升继续提升N的值呀,比如4-Gram、5-Gram…。实际上,这个想法是没有实用性的。因为我们想处理任意长度的句子,N设为多少都不合适;另外,模型的大小和N的关系是指数级的,4-Gram模型就会占用海量的存储空间。

所以,该轮到RNN出场了,RNN理论上可以往前看(往后看)任意多个词。


2. 循环神经网络

2.1 基本概念

在这里插入图片描述

  • x x x是一个向量,它表示输入层的值
  • s s s是一个向量,它表示隐藏层的值
  • U U U是输入层到隐藏层的权重矩阵
  • o o o也是一个向量,它表示输出层的值
  • V V V是隐藏层到输出层的权重矩阵

2.2 表示方式

网络在 t t t时刻接收到输入 x t − 1 x_{t-1} xt1之后,隐藏层的值是 s t s_t st,输出值是 o t o_t ot。关键一点是, s t s_t st的值不仅仅取决于 x t x_t xt,还取决于 s t − 1 s_{t-1} st1。我们可以用下面的公式来表示循环神经网络的计算方法:
o t = g ( V s t ) (2.1) \begin{aligned} \mathrm{o}_t&=g(V\mathrm{s}_t) \tag{2.1} \end{aligned} ot=g(Vst)(2.1)
s t = f ( U x t + W s t − 1 ) (2.2) \begin{aligned} \mathrm{s}_t&=f(U\mathrm{x}_t+W\mathrm{s}_{t-1}) \tag{2.2} \end{aligned} st=f(Uxt+Wst1)(2.2)

  • x x x是一个向量,它表示输入层的值
  • s s s是一个向量,它表示隐藏层的值
  • U U U是输入层到隐藏层的权重矩阵
  • o o o也是一个向量,它表示输出层的值
  • V V V是隐藏层到输出层的权重矩阵
  • g , f g, f g,f都是激活函数

如果反复把式2.2带入到式2.1,我们将得到:
o t = g ( V s t ) = V f ( U x t + W s t − 1 ) = V f ( U x t + W f ( U x t − 1 + W s t − 2 ) ) = V f ( U x t + W f ( U x t − 1 + W f ( U x t − 2 + W s t − 3 ) ) ) = V f ( U x t + W f ( U x t − 1 + W f ( U x t − 2 + W f ( U x t − 3 + . . . ) ) ) ) \begin{aligned} \mathrm{o}_t&=g(V\mathrm{s}_t)\\ &=Vf(U\mathrm{x}_t+W\mathrm{s}_{t-1})\\ &=Vf(U\mathrm{x}_t+Wf(U\mathrm{x}_{t-1}+W\mathrm{s}_{t-2}))\\ &=Vf(U\mathrm{x}_t+Wf(U\mathrm{x}_{t-1}+Wf(U\mathrm{x}_{t-2}+W\mathrm{s}_{t-3})))\\ &=Vf(U\mathrm{x}_t+Wf(U\mathrm{x}_{t-1}+Wf(U\mathrm{x}_{t-2}+Wf(U\mathrm{x}_{t-3}+...)))) \end{aligned} ot=g(Vst)=Vf(Uxt+Wst1)=Vf(Uxt+Wf(Uxt1+Wst2))=Vf(Uxt+Wf(Uxt1+Wf(Uxt2+Wst3)))=Vf(Uxt+Wf(Uxt1+Wf(Uxt2+Wf(Uxt3+...))))


3. 循环神经网络的训练

循环神经网络的训练算法:BPTT
BPTT算法是针对循环层的训练算法,它的基本原理和BP算法是一样的,也包含同样的三个步骤:

  • 前向计算每个神经元的输出值;
  • 反向计算每个神经元的误差项值,它是误差函数E对神经元j的加权输入的偏导数;
  • 计算每个权重的梯度。

最后再用随机梯度下降算法更新权重。

循环层如下图所示:
在这里插入图片描述

3.1 前向计算

循环层而言,根据循环层图结构可以写出前向计算的式子为:
s t = f ( U x t + W s t − 1 ) (3.1) \mathrm{s}_t=f(U\mathrm{x}_t+W\mathrm{s}_{t-1}) \tag{3.1} st=f(Uxt+Wst1)(3.1)
在公式3.1中,各字母的含义如下:

  • s t s_t st x t x_t xt s t − 1 s_{t-1} st1都是向量,用黑体字母表示;
  • U、V是矩阵,用大写字母表示。
  • 向量的下标 t t t表示时刻,例如, s t s_t st表示在 t t t时刻向量 s s s的值。

假设输入向量 x x x的维度是 m m m,输出向量 s s s的维度是 n n n,则矩阵U的维度是 n × m n \times m n×m,矩阵W的维度是 n × n n \times n n×n。下面是上式展开成矩阵的样子,看起来更直观一些:
[ s 1 t s 2 t . . s n t ] = f ( [ u 11 u 12 . . . u 1 m u 21 u 22 . . . u 2 m . . u n 1 u n 2 . . . u n m ] [ x 1 t x 2 t . . x m t ] + [ w 11 w 12 . . . w 1 n w 21 w 22 . . . w 2 n . . w n 1 w n 2 . . . w n n ] [ s 1 t − 1 s 2 t − 1 . . s n t − 1 ] ) (3.2) \begin{aligned} \begin{bmatrix} s_1^t\\ s_2^t\\ .\\.\\ s_n^t\\ \end{bmatrix}=f( \begin{bmatrix} u_{11} u_{12} ... u_{1m}\\ u_{21} u_{22} ... u_{2m}\\ .\\.\\ u_{n1} u_{n2} ... u_{nm}\\ \end{bmatrix} \begin{bmatrix} x_1^t\\ x_2^t\\ .\\.\\ x_m^t\\ \end{bmatrix}+ \begin{bmatrix} w_{11} w_{12} ... w_{1n}\\ w_{21} w_{22} ... w_{2n}\\ .\\.\\ w_{n1} w_{n2} ... w_{nn}\\ \end{bmatrix} \begin{bmatrix} s_1^{t-1}\\ s_2^{t-1}\\ .\\.\\ s_n^{t-1}\\ \end{bmatrix}) \end{aligned} \tag{3.2} s1ts2t..snt=f(u11u12...u1mu21u22...u2m..un1un2...unmx1tx2t..xmt+w11w12...w1nw21w22...w2n..wn1wn2...wnns1t1s2t1..snt1)(3.2)
在上述公式中,各字母的含义如下所示:

  • s j t s_j^t sjt:表示向量 s s s的第 j j j个元素在 t t t时刻的值。
  • u j i u_{ji} uji:表示输入层 i i i个神经元到循环层第 j j j个神经元的权重。
  • w j i w_{ji} wji表示循环层第t − 1 -1 1时刻的第 i i i个神经元到循环层第 t t t个时刻的第 j j j个神经元的权重。
  • 公式(3.1)中的 x t x_t xt在公式(3.2)中用一个 m × 1 m \times 1 m×1的向量表示了。

3.2 反向计算

3.2.1 目的

PP算法将 第 l l l t t t时刻 的误差项 δ t l \delta_t^l δtl值沿两个方向传播:

  • 一个是方向是将其沿时间线传递到初始时刻 t 1 t_1 t1,得到 δ t 1 \delta_t^{1} δt1,这部分只和权重矩阵W有关。
  • 另一个方向是其传递到上一层网络,得到 δ t l − 1 \delta_t^{l-1} δtl1,这部分只和权重矩阵U有关;

在这里插入图片描述

假定当前时刻为 t t t,则有:
s t = f ( U x t + W s t − 1 ) \mathrm{s}_t=f(U\mathrm{x}_t+W\mathrm{s}_{t-1})\\ st=f(Uxt+Wst1)
用向量 n e t t \mathrm{net}_t nett表示神经元在 t t t时刻的加权输入,将公式(3.1)写成:
s t = f ( n e t t ) n e t t = U x t + W f ( n e t t − 1 ) s t − 1 = f ( n e t t − 1 ) (3.1-1) \begin{aligned} \mathrm{s}_t=&f(\mathrm{net}_t)\\ \mathrm{net}_t&=U\mathrm{x}_t+Wf(\mathrm{net}_{t-1})\\ \mathrm{s}_{t-1}&=f(\mathrm{net}_{t-1})\\ \end{aligned} \tag{3.1-1} st=nettst1f(nett)=Uxt+Wf(nett1)=f(nett1)(3.1-1)
n e t t = U x t + W s t − 1 [ n e t 1 t n e t 2 t . . n e t n t ] = U x t + [ w 11 w 12 . . . w 1 n w 21 w 22 . . . w 2 n . . w n 1 w n 2 . . . w n n ] [ s 1 t − 1 s 2 t − 1 . . s n t − 1 ] = U x t + [ w 11 s 1 t − 1 + w 12 s 2 t − 1 . . . w 1 n s n t − 1 w 21 s 1 t − 1 + w 22 s 2 t − 1 . . . w 2 n s n t − 1 . . w n 1 s 1 t − 1 + w n 2 s 2 t − 1 . . . w n n s n t − 1 ] \begin{aligned} \mathrm{net}_t=&U\mathrm{x}_t+W\mathrm{s}_{t-1}\\ \begin{bmatrix} net_1^t\\ net_2^t\\ .\\.\\ net_n^t\\ \end{bmatrix}=&U\mathrm{x}_t+ \begin{bmatrix} w_{11} & w_{12} & ... & w_{1n}\\ w_{21} & w_{22} & ... & w_{2n}\\ .\\.\\ w_{n1} & w_{n2} & ... & w_{nn}\\ \end{bmatrix} \begin{bmatrix} s_1^{t-1}\\ s_2^{t-1}\\ .\\.\\ s_n^{t-1}\\ \end{bmatrix}\\ =&U\mathrm{x}_t+ \begin{bmatrix} w_{11}s_1^{t-1}+w_{12}s_2^{t-1}...w_{1n}s_n^{t-1}\\ w_{21}s_1^{t-1}+w_{22}s_2^{t-1}...w_{2n}s_n^{t-1}\\ .\\.\\ w_{n1}s_1^{t-1}+w_{n2}s_2^{t-1}...w_{nn}s_n^{t-1}\\ \end{bmatrix}\\ \end{aligned} nett=net1tnet2t..netnt==Uxt+Wst1Uxt+w11w21..wn1w12w22wn2.........w1nw2nwnns1t1s2t1..snt1Uxt+w11s1t1+w12s2t1...w1nsnt1w21s1t1+w22s2t1...w2nsnt1..wn1s1t1+wn2s2t1...wnnsnt1
因为对W求导与 U x t U\mathrm{x}_t Uxt无关,我们不再考虑。现在,我们考虑对权重项 w j i t w^t_{ji} wjit求导。通过观察上式我们可以看到 w j i w_{ji} wji只与 n e t j t net_j^t netjt有关,所以:
∂ E ∂ w j i t = ∂ E ∂ n e t j t ∂ n e t j t ∂ w j i t = δ j t s i t − 1 \begin{aligned} \frac{\partial{E}}{\partial{w^t_{ji}}}=&\frac{\partial{E}}{\partial{net_j^t}}\frac{\partial{net_j^t}}{\partial{w^t_{ji}}}\\ =&\delta_j^ts_i^{t-1} \end{aligned} wjitE==netjtEwjitnetjtδjtsit1
按照上面的规律就可以可得:

在全连接网络的权重梯度计算算法:只要知道了任意一个时刻的误差项 δ t \delta_t δt,以及上一个时刻循环层的输出值 s t − 1 \mathrm{s}_{t-1} st1,就可以按照下面的公式求出权重矩阵在t时刻的梯度 ∇ W t E \nabla_{Wt}E WtE
∇ W t E = [ δ 1 t s 1 t − 1 δ 1 t s 2 t − 1 . . . δ 1 t s n t − 1 δ 2 t s 1 t − 1 δ 2 t s 2 t − 1 . . . δ 2 t s n t − 1 . . δ n t s 1 t − 1 δ n t s 2 t − 1 . . . δ n t s n t − 1 ] (3.1) \nabla_{W_t}E=\begin{bmatrix} \delta_1^ts_1^{t-1} & \delta_1^ts_2^{t-1} & ... & \delta_1^ts_n^{t-1}\\ \delta_2^ts_1^{t-1} & \delta_2^ts_2^{t-1} & ... & \delta_2^ts_n^{t-1}\\ .\\.\\ \delta_n^ts_1^{t-1} & \delta_n^ts_2^{t-1} & ... & \delta_n^ts_n^{t-1}\\ \end{bmatrix} \tag{3.1} WtE=δ1ts1t1δ2ts1t1..δnts1t1δ1ts2t1δ2ts2t1δnts2t1.........δ1tsnt1δ2tsnt1δntsnt1(3.1)
δ i t \delta_i^t δit表示 t t t时刻误差项向量的第 i i i个分量; s i t − 1 s_i^{t-1} sit1表示 t − 1 t-1 t1时刻循环层第 i i i个神经元的输出值。

接下来的任务,就是求指定时间 t t t的误差项了。

3.2.2 对时间方向误差传递的计算

part 1:时间线上的骚操作——目标
  1. t − 1 t-1 t1时刻的误差项 δ t \delta^t δt求导有:
    δ j t = ∂ E ∂ n e t j t \begin{aligned} \delta^t_j=\frac{\partial{E}}{\partial{net_j^t}}\\ \end{aligned} δjt=netjtE
    写成矩阵的形式则有:
    δ t = ∂ E ∂ n e t t \begin{aligned} \delta^t=\frac{\partial{E}}{\partial{net^t}}\\ \end{aligned} δt=nettE

  2. 对任意 k k k时刻的时间权重 w w w求导有:
    δ j k = ∂ E ∂ n e t j t ∂ n e t j t ∂ n e t j t − 1 ∂ n e t j t − 1 ∂ n e t j t − 2 . . . ∂ n e t j k + 1 ∂ n e t j i k \begin{aligned} \delta^k_j=&\frac{\partial{E}}{\partial{net_j^t}}\frac{\partial{net_j^t}}{\partial{net_j^{t-1}}}\frac{\partial{net_j^{t-1}}}{\partial{net_j^{t-2}}}...\frac{\partial{net_j^{k+1}}}{\partial{net_{ji}^{k}}}\\ \end{aligned} δjk=netjtEnetjt1netjtnetjt2netjt1...netjiknetjk+1
    写成矩阵的形式则有:
    δ k = ∂ E ∂ n e t t ∂ n e t t ∂ n e t t − 1 ∂ n e t t − 1 ∂ n e t t − 2 . . . ∂ n e t k + 1 ∂ n e t k (3.3) \begin{aligned} \delta^k=&\frac{\partial{E}}{\partial{\mathrm{net}_t}}\frac{\partial{\mathrm{net}_{t}}}{\partial{\mathrm{net}_{t-1}}}\frac{\partial{\mathrm{net}_{t-1}}}{\partial{\mathrm{net}_{t-2}}}...\frac{\partial{\mathrm{net}_{k+1}}}{\partial{net_{k}}}\\ \end{aligned} \tag{3.3} δk=nettEnett1nettnett2nett1...netknetk+1(3.3)

part 2:时间线上的骚操作——求解

要求公式(3.3)的值,则需要考虑如何计算
∂ n e t t ∂ n e t t − 1 \frac{\partial{\mathrm{net}_t}}{\partial{\mathrm{net}_{t-1}}} nett1nett

因此有:
∂ n e t t ∂ n e t t − 1 = ∂ n e t t ∂ s t − 1 ∂ s t − 1 ∂ n e t t − 1 \begin{aligned} \frac{\partial{\mathrm{net}_t}}{\partial{\mathrm{net}_{t-1}}}&=\frac{\partial{\mathrm{net}_t}}{\partial{\mathrm{s}_{t-1}}}\frac{\partial{\mathrm{s}_{t-1}}}{\partial{\mathrm{net}_{t-1}}}\\ \end{aligned} nett1nett=st1nettnett1st1

  • 对于 ∂ n e t t ∂ s t − 1 \frac{\partial{\mathrm{net}_t}}{\partial{\mathrm{s}_{t-1}}} st1nett来说:
    因为 ∂ n e t t \partial{\mathrm{net}_t} nett是向量函数, ∂ s t − 1 \partial{\mathrm{s}_{t-1}} st1是一个向量,根据计算法则( y \bold{y} y是关于 x \bold{x} x的函数):
    在这里插入图片描述
    则向量函数 ∂ n e t t \partial{\mathrm{net}_t} nett对向量 ∂ s t − 1 \partial{\mathrm{s}_{t-1}} st1求导,其结果为Jacobian矩阵,记为 W W W
    ∂ n e t t ∂ s t − 1 = [ ∂ n e t 1 t ∂ s 1 t − 1 ∂ n e t 1 t ∂ s 2 t − 1 . . . ∂ n e t 1 t ∂ s n t − 1 ∂ n e t 2 t ∂ s 1 t − 1 ∂ n e t 2 t ∂ s 2 t − 1 . . . ∂ n e t 2 t ∂ s n t − 1 . . ∂ n e t n t ∂ s 1 t − 1 ∂ n e t n t ∂ s 2 t − 1 . . . ∂ n e t n t ∂ s n t − 1 ] = [ w 11 w 12 . . . w 1 n w 21 w 22 . . . w 2 n . . w n 1 w n 2 . . . w n n ] = W \begin{aligned} \frac{\partial{\mathrm{net}_t}}{\partial{\mathrm{s}_{t-1}}}&= \begin{bmatrix} \frac{\partial{net_1^t}}{\partial{s_1^{t-1}}}& \frac{\partial{net_1^t}}{\partial{s_2^{t-1}}}& ...& \frac{\partial{net_1^t}}{\partial{s_n^{t-1}}}\\\\ \frac{\partial{net_2^t}}{\partial{s_1^{t-1}}}& \frac{\partial{net_2^t}}{\partial{s_2^{t-1}}}& ...& \frac{\partial{net_2^t}}{\partial{s_n^{t-1}}}\\ &.\\&.\\ \frac{\partial{net_n^t}}{\partial{s_1^{t-1}}}& \frac{\partial{net_n^t}}{\partial{s_2^{t-1}}}& ...& \frac{\partial{net_n^t}}{\partial{s_n^{t-1}}}\\ \end{bmatrix}\\\\ &=\begin{bmatrix} w_{11} & w_{12} & ... & w_{1n}\\ w_{21} & w_{22} & ... & w_{2n}\\ &.\\&.\\ w_{n1} & w_{n2} & ... & w_{nn}\\ \end{bmatrix}\\\\ &=W \end{aligned} st1nett=s1t1net1ts1t1net2ts1t1netnts2t1net1ts2t1net2t..s2t1netnt.........snt1net1tsnt1net2tsnt1netnt=w11w21wn1w12w22..wn2.........w1nw2nwnn=W

  • 对于 ∂ s t − 1 ∂ n e t t − 1 \frac{\partial{\mathrm{s}_{t-1}}}{\partial{\mathrm{net}_{t-1}}} nett1st1来说:
    因为 ∂ s t − 1 \partial{\mathrm{s}_{t-1}} st1是向量函数, ∂ n e t t − 1 \partial{\mathrm{net}_{t-1}} nett1是一个向量,所以:
    ∂ s t − 1 ∂ n e t t − 1 = [ ∂ s 1 t − 1 ∂ n e t 1 t − 1 ∂ s 1 t − 1 ∂ n e t 2 t − 1 . . . ∂ s 1 t − 1 ∂ n e t n t − 1 ∂ s 2 t − 1 ∂ n e t 1 t − 1 ∂ s 2 t − 1 ∂ n e t 2 t − 1 . . . ∂ s 2 t − 1 ∂ n e t n t − 1 . . ∂ s n t − 1 ∂ n e t 1 t − 1 ∂ s n t − 1 ∂ n e t 2 t − 1 . . . ∂ s n t − 1 ∂ n e t n t − 1 ] = [ f ′ ( n e t 1 t − 1 ) 0 . . . 0 0 f ′ ( n e t 2 t − 1 ) . . . 0 . . 0 0 . . . f ′ ( n e t n t − 1 ) ] = d i a g [ f ′ ( n e t t − 1 ) ] \begin{aligned} \frac{\partial{\mathrm{s}_{t-1}}}{\partial{\mathrm{net}_{t-1}}}&= \begin{bmatrix} \frac{\partial{s_1^{t-1}}}{\partial{net_1^{t-1}}}& \frac{\partial{s_1^{t-1}}}{\partial{net_2^{t-1}}}& ...& \frac{\partial{s_1^{t-1}}}{\partial{net_n^{t-1}}}\\\\ \frac{\partial{s_2^{t-1}}}{\partial{net_1^{t-1}}}& \frac{\partial{s_2^{t-1}}}{\partial{net_2^{t-1}}}& ...& \frac{\partial{s_2^{t-1}}}{\partial{net_n^{t-1}}}\\ &.\\&.\\ \frac{\partial{s_n^{t-1}}}{\partial{net_1^{t-1}}}& \frac{\partial{s_n^{t-1}}}{\partial{net_2^{t-1}}}& ...& \frac{\partial{s_n^{t-1}}}{\partial{net_n^{t-1}}}\\ \end{bmatrix}\\\\ &=\begin{bmatrix} f'(net_1^{t-1}) & 0 & ... & 0\\ 0 & f'(net_2^{t-1}) & ... & 0\\ &.\\&.\\ 0 & 0 & ... & f'(net_n^{t-1})\\ \end{bmatrix}\\\\ &=diag[f'(\mathrm{net}_{t-1})] \end{aligned} nett1st1=net1t1s1t1net1t1s2t1net1t1snt1net2t1s1t1net2t1s2t1..net2t1snt1.........netnt1s1t1netnt1s2t1netnt1snt1=f(net1t1)000f(net2t1)..0.........00f(netnt1)=diag[f(nett1)]

所以,
∂ n e t t ∂ n e t t − 1 = ∂ n e t t ∂ s t − 1 ∂ s t − 1 ∂ n e t t − 1 = W d i a g [ f ′ ( n e t t − 1 ) ] = [ w 11 f ′ ( n e t 1 t − 1 ) w 12 f ′ ( n e t 2 t − 1 ) . . . w 1 n f ( n e t n t − 1 ) w 21 f ′ ( n e t 1 t − 1 ) w 22 f ′ ( n e t 2 t − 1 ) . . . w 2 n f ( n e t n t − 1 ) . . w n 1 f ′ ( n e t 1 t − 1 ) w n 2 f ′ ( n e t 2 t − 1 ) . . . w n n f ′ ( n e t n t − 1 ) ] \begin{aligned} \frac{\partial{\mathrm{net}_t}}{\partial{\mathrm{net}_{t-1}}}&=\frac{\partial{\mathrm{net}_t}}{\partial{\mathrm{s}_{t-1}}}\frac{\partial{\mathrm{s}_{t-1}}}{\partial{\mathrm{net}_{t-1}}}\\\\ &=Wdiag[f'(\mathrm{net}_{t-1})]\\\\ &=\begin{bmatrix} w_{11}f'(net_1^{t-1}) & w_{12}f'(net_2^{t-1}) & ... & w_{1n}f(net_n^{t-1})\\ w_{21}f'(net_1^{t-1}) & w_{22} f'(net_2^{t-1}) & ... & w_{2n}f(net_n^{t-1})\\ &.\\&.\\ w_{n1}f'(net_1^{t-1}) & w_{n2} f'(net_2^{t-1}) & ... & w_{nn} f'(net_n^{t-1})\\ \end{bmatrix}\\ \end{aligned} nett1nett=st1nettnett1st1=Wdiag[f(nett1)]=w11f(net1t1)w21f(net1t1)wn1f(net1t1)w12f(net2t1)w22f(net2t1)..wn2f(net2t1).........w1nf(netnt1)w2nf(netnt1)wnnf(netnt1)

所以公式(3.3)可以写成:
∂ E ∂ w k = ∂ E ∂ n e t t ∂ n e t t ∂ n e t t − 1 ∂ n e t t − 1 ∂ n e t t − 2 . . . ∂ n e t k ∂ w k (3.3) \begin{aligned} \frac{\partial{E}}{\partial{\bold{w}_{k}}}=&\frac{\partial{E}}{\partial{\mathrm{net}_t}}\frac{\partial{\mathrm{net}_{t}}}{\partial{\mathrm{net}_{t-1}}}\frac{\partial{\mathrm{net}_{t-1}}}{\partial{\mathrm{net}_{t-2}}}...\frac{\partial{\mathrm{net}_{k}}}{\partial{\bold{w}_{k}}}\\ \end{aligned} \tag{3.3} wkE=nettEnett1nettnett2nett1...wknetk(3.3)
δ k = ∂ E ∂ n e t t ∂ n e t t ∂ n e t k ∂ n e t k + 1 ∂ n e t k = ∂ E ∂ n e t t ∂ n e t t ∂ n e t t − 1 ∂ n e t t − 1 ∂ n e t t − 2 . . . ∂ n e t k + 1 ∂ n e t k = ∂ E ∂ n e t t W d i a g [ f ′ ( n e t t − 1 ) ] W d i a g [ f ′ ( n e t t − 2 ) ] . . . W d i a g [ f ′ ( n e t k ) ] = δ t ∏ i = k t − 1 ( W d i a g [ f ′ ( n e t i ) ] ) (3.3-1) \begin{aligned} \delta_k =&\frac{\partial{E}}{\partial{\mathrm{net}_t}}\frac{\partial{\mathrm{net}_t}}{\partial{\mathrm{net}_k}}\frac{\partial{\mathrm{net}_{k+1}}}{\partial{\mathrm{net}_{k}}}\\ =&\frac{\partial{E}}{\partial{\mathrm{net}_t}}\frac{\partial{\mathrm{net}_t}}{\partial{\mathrm{net}_{t-1}}}\frac{\partial{\mathrm{net}_{t-1}}}{\partial{\mathrm{net}_{t-2}}}...\frac{\partial{\mathrm{net}_{k+1}}}{\partial{\mathrm{net}_{k}}}\\ =&\frac{\partial{E}}{\partial{\mathrm{net}_t}}Wdiag[f'(\mathrm{net}_{t-1})] Wdiag[f'(\mathrm{net}_{t-2})] ... Wdiag[f'(\mathrm{net}_{k})] \\ =&\delta_t\prod_{i=k}^{t-1}(Wdiag[f'(\mathrm{net}_{i})]) \tag{3.3-1} \end{aligned} δk====nettEnetknettnetknetk+1nettEnett1nettnett2nett1...netknetk+1nettEWdiag[f(nett1)]Wdiag[f(nett2)]...Wdiag[f(netk)]δti=kt1(Wdiag[f(neti)])(3.3-1)

3.2.3 对输入方向误差传递的计算

循环层加权输入 n e t l \mathrm{net}^l netl与上一层的加权输入 n e t l − 1 \mathrm{net}^{l-1} netl1关系如下:

n e t t l = U a t l − 1 + W s t − 1 a t l − 1 = f l − 1 ( n e t t l − 1 ) \begin{aligned} \mathrm{net}_t^l=&U\mathrm{a}_t^{l-1}+W\mathrm{s}_{t-1}\\ \mathrm{a}_t^{l-1}=&f^{l-1}(\mathrm{net}_t^{l-1}) \end{aligned} nettl=atl1=Uatl1+Wst1fl1(nettl1)
上式中 n e t t l \mathrm{net}_t^l nettl是第 l l l层神经元的加权输入(假设第l层是循环层); n e t t l − 1 \mathrm{net}_t^{l-1} nettl1是第 l − 1 l-1 l1层神经元的加权输入; a t l − 1 \mathrm{a}_t^{l-1} atl1是第l-1层神经元的输出; f l − 1 f^{l-1} fl1是第l-1层的激活函数。
∂ n e t t l ∂ n e t t l − 1 = ∂ n e t l ∂ a t l − 1 ∂ a t l − 1 ∂ n e t t l − 1 = U d i a g [ f ′ l − 1 ( n e t t l − 1 ) ] \begin{aligned} \frac{\partial{\mathrm{net}_t^l}}{\partial{\mathrm{net}_t^{l-1}}}=&\frac{\partial{\mathrm{net}^l}}{\partial{\mathrm{a}_t^{l-1}}}\frac{\partial{\mathrm{a}_t^{l-1}}}{\partial{\mathrm{net}_t^{l-1}}}\\ =&Udiag[f'^{l-1}(\mathrm{net}_t^{l-1})] \end{aligned} nettl1nettl==atl1netlnettl1atl1Udiag[fl1(nettl1)]
δ t l − 1 = ∂ E ∂ n e t t l − 1 = ∂ E ∂ n e t t l ∂ n e t t l ∂ n e t t l − 1 = δ t l U d i a g [ f ′ l − 1 ( n e t t l − 1 ) ] \begin{aligned} \delta_t^{l-1}=&\frac{\partial{E}}{\partial{\mathrm{net}_t^{l-1}}}\\ =&\frac{\partial{E}}{\partial{\mathrm{net}_t^l}}\frac{\partial{\mathrm{net}_t^l}}{\partial{\mathrm{net}_t^{l-1}}}\\ =&\delta_t^lUdiag[f'^{l-1}(\mathrm{net}_t^{l-1})] \end{aligned} δtl1===nettl1EnettlEnettl1nettlδtlUdiag[fl1(nettl1)]

3.3 权重梯度的计算

整理一下:

  • t t t 时刻的梯度公式:
    ∇ W t E = [ δ 1 t s 1 t − 1 δ 1 t s 2 t − 1 . . . δ 1 t s n t − 1 δ 2 t s 1 t − 1 δ 2 t s 2 t − 1 . . . δ 2 t s n t − 1 . . δ n t s 1 t − 1 δ n t s 2 t − 1 . . . δ n t s n t − 1 ] (3.1) \nabla_{W_t}E=\begin{bmatrix} \delta_1^ts_1^{t-1} & \delta_1^ts_2^{t-1} & ... & \delta_1^ts_n^{t-1}\\ \delta_2^ts_1^{t-1} & \delta_2^ts_2^{t-1} & ... & \delta_2^ts_n^{t-1}\\ .\\.\\ \delta_n^ts_1^{t-1} & \delta_n^ts_2^{t-1} & ... & \delta_n^ts_n^{t-1}\\ \end{bmatrix} \tag{3.1} WtE=δ1ts1t1δ2ts1t1..δnts1t1δ1ts2t1δ2ts2t1δnts2t1.........δ1tsnt1δ2tsnt1δntsnt1(3.1)
  • t t t 时刻的输入的误差项:
    δ t l − 1 = δ t l U d i a g [ f ′ l − 1 ( n e t t l − 1 ) ] \begin{aligned} \delta_t^{l-1}=&\delta_t^lUdiag[f'^{l-1}(\mathrm{net}_t^{l-1})] \end{aligned} δtl1=δtlUdiag[fl1(nettl1)]
  • 任意时刻的梯度公式:
    δ k = δ t ∏ i = k t − 1 ( W d i a g [ f ′ ( n e t i ) ] ) (3.3-1) \begin{aligned} \delta_k =&\delta_t\prod_{i=k}^{t-1}(Wdiag[f'(\mathrm{net}_{i})]) \tag{3.3-1} \end{aligned} δk=δti=kt1(Wdiag[f(neti)])(3.3-1)

矩阵求导部分知识储备不足,暂时没看懂:

  • 总的时间权重公式为:
    在这里插入图片描述
  • 总的输入权重公式为
    在这里插入图片描述

4. 梯度消失和梯度爆炸的问题

4.1 RNN面临的问题

RNNs并不能很好的处理较长的序列。一个主要的原因是,RNN在训练中很容易发生梯度爆炸梯度消失,这导致训练时梯度不能在较长序列中一直传递下去,从而使RNN无法捕捉到长距离的影响。

考虑到任意时刻的梯度公式:
δ k = δ t ∏ i = k t − 1 ( W d i a g [ f ′ ( n e t i ) ] ) (3.3-1) \begin{aligned} \delta_k =&\delta_t\prod_{i=k}^{t-1}(Wdiag[f'(\mathrm{net}_{i})]) \tag{3.3-1} \end{aligned} δk=δti=kt1(Wdiag[f(neti)])(3.3-1)
则有:
δ k T = δ t T ∏ i = k t − 1 W d i a g [ f ′ ( n e t i ) ] ∥ δ k T ∥ ⩽ ∥ δ t T ∥ ∏ i = k t − 1 ∥ W ∥ ∥ d i a g [ f ′ ( n e t i ) ] ∥ ⩽ ∥ δ t T ∥ ( β W β f ) t − k \begin{aligned} \delta_k^T=&\delta_t^T\prod_{i=k}^{t-1}Wdiag[f'(\mathrm{net}_{i})]\\ \|\delta_k^T\|\leqslant&\|\delta_t^T\|\prod_{i=k}^{t-1}\|W\|\|diag[f'(\mathrm{net}_{i})]\|\\ \leqslant&\|\delta_t^T\|(\beta_W\beta_f)^{t-k} \end{aligned} δkT=δkTδtTi=kt1Wdiag[f(neti)]δtTi=kt1Wdiag[f(neti)]δtT(βWβf)tk
上式的 β \beta β定义为矩阵的模的上界。因为上式是一个指数函数,如果 t − k t-k tk很大的话(也就是向前看很远的时候),会导致对应的误差项的值增长或缩小的非常快,这样就会导致相应的梯度爆炸梯度消失问题(取决于大于1还是小于1)。

梯度消失的直观解释:
在这里插入图片描述
从上图的t-3时刻开始,梯度已经几乎减少到0了。那么,从这个时刻开始再往之前走,得到的梯度(几乎为零)就不会对最终的梯度值有任何贡献,这就相当于无论t-3时刻之前的网络状态h是什么,在训练中都不会对权重数组W的更新产生影响,也就是网络事实上已经忽略了t-3时刻之前的状态。这就是原始RNN无法处理长距离依赖的原因。

4.2 解决方法

  • 梯度爆炸的处理方法:设置一个梯度阈值,当梯度超过这个阈值的时候可以直接截取。
  • 梯度消失的处理方法:
    • 合理的初始化权重值。初始化权重,使每个神经元尽可能不要取极大或极小值,以躲开梯度消失的区域。
    • 使用relu代替sigmoid和tanh作为激活函数。
    • 使用其他结构的RNNs,比如长短时记忆网络(LTSM)和Gated Recurrent Unit(GRU),这是最流行的做法。

5. RNN的应用举例——语言模型

5.1 语言模型基本概念

现在,我们介绍一下基于RNN语言模型。我们首先把词依次输入到循环神经网络中,每输入一个词,循环神经网络就输出截止到目前为止,下一个最可能的词。例如,当我们依次输入:

我 昨天 上学 迟到 了

神经网络的输出如下图所示:
在这里插入图片描述
现在,我们要利用这一特性做一个写歌词的模型。

5.2 数据概况

在这里使用周杰伦的歌词作为数据集,将换行和缩进进行处理后,得到的数据为:
在这里插入图片描述
那么,这些数据构成的集合为:
在这里插入图片描述
每个字符对应的编号为:
在这里插入图片描述
歌词则可以转化为:
在这里插入图片描述

5.3 向量化

在本节使用最简单的向量化方法:one-hot
具体方法如下:

  1. 建立一个包含所有词的词典,每个词对应词典中唯一的一个编号。
  2. 任意一个词都能够用一个 M M M维的向量表示出来,其中, M M M是词典的长度,
    在这里插入图片描述
    语言模型要求的输出是下一个最可能的词,我们可以让循环神经网络计算计算词典中每个词是下一个词的概率,这样,概率最大的词就是下一个最可能的词。因此,神经网络的输出向量也是一个M维向量,(其实不一定要是M维的向量,取决于输出权重的维度,即2.1节——基本概念中的权重V)向量中的每个元素对应着词典中相应的词是下一个词的概率。如下图所示:
    在这里插入图片描述

5.4 采样方式

5.4.1 随机批量采样

  • 选取子序列的大小(也就是timestamps的长度):若timestamps=3,则说明把3个连续的字符记为一个样本。
  • 选取批量的大小(一次选几个样本):若为2,则说明一次随机选两个子序列。
    在这里插入图片描述
    使用该方法产生数据集,则需要将第一个隐藏状态:初始化为任意一个值(因为没有用到),对于最后一个隐藏状态丢弃即可。

缺点:两个连续的子序列之间的相关性被忽略了。

5.4.2 相邻批量采样

  • 选取子序列的大小(也就是timestamps的长度):若timestamps=3,则说明把3个连续的字符记为一个样本。
  • 选取批量(batch_size)的大小(一次选几个样本):若为2,则说明一次随机选两个子序列。

此时,将子序列的个数按照batch_size分组,然后按照每组内的对应顺序组合成每一次训练的数据。例如:6个子序列:
在这里插入图片描述
分别将(1,4)(2,5)(3,6)进行组合,这样,在训练的时候就可以将第二个批量的初始隐藏状态第一个批量的最后一个隐藏状态的值。

def data_iter_consecutive(corpus_indices, batch_size, num_steps, ctx=None):
    '''
     Sample mini-batches in a consecutive order from sequential data.
    :param corpus_indices: 各文字对应编号的集合(其实就是本文的集合,用编号表示了)
    :param batch_size: 批量大小,每次同时选取 batch_size 个子序列进行训练
    :param num_steps: 相当于timestamps,
    :param ctx:
    :return:
    '''
    
    corpus_indices = nd.array(corpus_indices, ctx=ctx)
    data_len = len(corpus_indices)
    batch_len = data_len // batch_size
    # 保证各组具有同样的长度
    indices = corpus_indices[0 : batch_size * batch_len].reshape((
        batch_size, batch_len))
    # 这里的num_steps 就相当于timestamps
    epoch_size = (batch_len - 1) // num_steps
    for i in range(epoch_size):
        i = i * num_steps
        X = indices[:, i : i + num_steps]
        Y = indices[:, i + 1 : i + num_steps + 1]
        yield X, Y

5.5 最后要达到的目的

t t t时刻预测出的输出值,作为 t + 1 t+1 t+1时刻的输入值,并把当前 t t t时刻最后的隐含层的输出,作为下时刻 t + 1 t+1 t+1时刻的输入,如果采用的是相邻批量采样方法。

5.6 重要参数的维度说明

在这里,我们假设深度为1的RNN神经网络模型,batch_size=2, timestamps=3, vector_len=1024, hidden_units=256,那么需要做如下初始化:

  • 输入X的矩阵的维度为:timestamps , batch_size , vector_len
  • 输出Y的矩阵的维度为:batch_size , timestamps
  • 输入权重矩阵的维度为W_xh=(vector_len , num_hidden)
  • 输入偏置矩阵的维度为b_h=(num_hidden , batch_size)
  • 输出权重矩阵的维度为W_hy=(num_hidden , vector_len)
  • 输出偏置矩阵的维度为b_h=(vector_len , batch_size)
  • 隐藏层权重矩阵的维度为W_hh=(num_hidden , num_hidden)
  • 隐藏层初始状态的维度为W_hh=(batch_size , num_hidden)
  • 根据公式可知,隐藏层返回的数据维度为(batch_size , num_hidden),但是一般的框架返回的是(num_layers, batch_size, num_hidden)
  • 输出层的维度:每一个timestamps都会输出batch_size* vector_len的矩阵,最后总的输出取决于有多少个timestamps。在该层,一般会把数据重新reshape成timestamps * batch_size, vector_len,然后对输出Y矩阵进行转置操作,然后全部丢给softmax函数。

5.7 困惑度的问题

我们通常使用困惑度(perplexity)来评价语言模型的好坏。困惑度是对交叉熵损失函数做指数运算后得到的值。特别地,

最佳情况下,模型总是把标签类别的概率预测为1,此时困惑度为1;
最坏情况下,模型总是把标签类别的概率预测为0,此时困惑度为正无穷;
基线情况下,模型总是预测所有类别的概率都相同,此时困惑度为类别个数。
显然,任何一个有效模型的困惑度必须小于类别个数。在本例中,困惑度必须小于词典大小vocab_size。


6 代码实现

这里用到了MXnet框架:
读取文本数据:

def load_data_jay_lyrics():
    '''
    Load the Jay Chou lyric data set (available in the Chinese book).
    :return:
    '''
    with zipfile.ZipFile('data/jaychou_lyrics.txt.zip') as zin:
        with zin.open('jaychou_lyrics.txt') as f:
            corpus_chars = f.read().decode('utf-8')

    # 源文件数据
    corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
    corpus_chars = corpus_chars[0:10000]
    # 文本字组成的集合
    idx_to_char = list(set(corpus_chars))
    # 把每个字编号
    char_to_idx = dict([(char, i) for i, char in enumerate(idx_to_char)])
    # 计算总的长度
    vocab_size = len(char_to_idx)
    # 将本文编码化
    corpus_indices = [char_to_idx[char] for char in corpus_chars]

    return corpus_indices, char_to_idx, idx_to_char, vocab_size

格式化训练数据

def data_iter_consecutive(corpus_indices, batch_size, num_steps):
    '''
    Sample mini-batches in a consecutive order from sequential data.
    :param corpus_indices: 各文字对应编号的集合(其实就是本文的集合,用编号表示了)
    :param batch_size: 批量大小,每次同时选取 batch_size 个子序列进行训练
    :param num_steps: 相当于timestamps,
    :param ctx:
    :return:
    '''
    corpus_indices = nd.array(corpus_indices, ctx=ctx)
    data_len = len(corpus_indices)
    batch_len = data_len // batch_size
    # 保证各组具有同样的长度
    indices = corpus_indices[0 : batch_size * batch_len].reshape((
        batch_size, batch_len))
    # 这里的num_steps 就相当于timestamps
    epoch_size = (batch_len - 1) // num_steps
    for i in range(epoch_size):
        i = i * num_steps
        X = indices[:, i : i + num_steps]
        Y = indices[:, i + 1 : i + num_steps + 1]
        yield X, Y

One-Hot 编码

def to_onehot(X, size):
    # 把X中的每一个值均进行one-hot化,
    # 在这里的功能是将每个字转化成独热编码
    """Represent inputs with one-hot encoding."""
    return [nd.one_hot(x, size) for x in X.T]

使用GPU计算

def try_gpu():
    """If GPU is available, return mx.gpu(0); else return mx.cpu()."""
    try:
        ctx = mx.gpu()
        _ = nd.array([0], ctx=ctx)
    except mx.base.MXNetError:
        ctx = mx.cpu()
    return ctx

循环神经网络权重初始化

def get_params(num_inputs, num_hiddens, num_outputs):
    '''
    初始化模型参数
    :return:
    '''
    W_xh = nd.random.normal(scale=0.01, shape=(num_inputs, num_hiddens), ctx=ctx)
    W_hh = nd.random.normal(scale=0.01, shape=(num_hiddens, num_hiddens), ctx=ctx)
    W_hy = nd.random.normal(scale=0.01, shape=(num_hiddens, num_outputs), ctx=ctx)
    b_h = nd.zeros(num_hiddens, ctx=ctx)
    b_y = nd.zeros(num_outputs, ctx=ctx)

    params = [W_xh, W_hh, b_h, W_hy, b_y]
    for param in params:
        param.attach_grad()
    return params

初始化隐藏单元

def init_rnn_state(batch_size, num_hiddens):
    '''
    初始化隐藏单元的值
    :return:
    '''
    return (nd.zeros(shape=(batch_size, num_hiddens), ctx=ctx), )

运行一轮需要

def rnn(inputs, state, params):
    '''
    运行一轮模型
    :return:
    '''
    # inputs和outputs皆为num_steps个形状为(batch_size, vocab_size)的矩阵
    W_xh, W_hh, b_h, W_hq, b_q = params
    H, = state
    outputs = []
    for X in inputs:
        H = nd.tanh(nd.dot(X, W_xh) + nd.dot(H, W_hh) + b_h)
        Y = nd.dot(H, W_hq) + b_q
        outputs.append(Y)
    return outputs, (H,)

预测函数

def predict_rnn(
        prefix, num_chars, params, char_to_idx,
        num_hiddens, vocab_size, idx_to_char
):
    '''
    以下函数基于前缀prefix(含有数个字符的字符串)来预测接下来的num_chars个字符。
    这个函数稍显复杂,其中我们将循环神经单元rnn设置成了函数参数。
    :param prefix: 预先给定的字符
    :param num_chars: 需要预测的字符
    :param params: 神经网络的参数
    :param char_to_idx: 每个字符所对应的编号
    :param num_hiddens: 神经网络隐藏层神经元的数量
    :param vocab_size: 字典的长度
    :param idx_to_char:与编号对应的字符
    :return: 预测的结果
    '''
    # 初始化状态,
    state,  = init_rnn_state(1, num_hiddens)
    outputs = [char_to_idx[prefix[0]]]

    for i in range(num_chars + len(prefix) - 1):
        X = to_onehot(nd.array([outputs[-1]], ctx=ctx), vocab_size)
        Y, state = rnn(X, state, params)
        if i < len(prefix) -1:
            outputs.append(char_to_idx[prefix[i]])
        else:
            outputs.append(int(Y[0].argmax(axis=1).asscalar()))

    return ''.join([idx_to_char[i] for i in outputs])

随机梯度下降

def sgd(params, lr, batch_size):
    """Mini-batch stochastic gradient descent."""
    for param in params:
        param[:] = param - lr * param.grad / batch_size

梯度裁剪

def grad_clipping(params, theta, ctx):
    norm = nd.array([0], ctx)
    for param in params:
        norm += (param.grad ** 2).sum()
    norm = norm.sqrt().asscalar()
    if norm > theta:
        for param in params:
            param.grad[:] *= theta / norm

训练函数

def train_predict(
        num_inputs, num_hiddens, num_outputs, num_epochs,
        corpus_indices, batch_size, num_steps, vocab_size,
        num_chars, char_to_idx, idx_to_char, pred_period,
        prefixes, lr, clipping_theta
):
    params = get_params(num_inputs, num_hiddens, num_outputs)
    loss = gloss.SoftmaxCrossEntropyLoss()

    for epoch in range(num_epochs):
        state = init_rnn_state(batch_size, num_hiddens)
        l_sum, n, start = 0.0, 0, time.time()
        data_iter = data_iter_consecutive(corpus_indices, batch_size, num_steps)
        for X, Y in data_iter:
            # 需要使用detach函数从计算图分离隐藏状态
            for s in state:
                s.detach()
            with autograd.record():
                inputs = to_onehot(X, vocab_size)
                # outputs 有 timestamps 个 (batch_size * vocab_size)的矩阵
                (outputs, state) = rnn(inputs, state, params)

                # 拼接之后形状为(timestamps * batch_size, vocab_size)
                outputs = nd.concat(*outputs, dim=0)
                # Y的形状是(batch_size, num_steps),转置后再变成长度为
                # batch * num_steps 的向量,这样跟输出的行一一对应
                y = Y.T.reshape((-1,))
                # 使用交叉熵损失计算平均分类误差
                l = loss(outputs, y).mean()
            l.backward()
            grad_clipping(params, clipping_theta, ctx)
            sgd(params, lr, 1)  # 因为误差已经取过均值,梯度不用再做平均
            l_sum += l.asscalar() * y.size
            n += y.size

        if (epoch + 1) % pred_period == 0:
            print('epoch %d, perplexity %f, time %.2f sec' % (
                epoch + 1, math.exp(l_sum / n), time.time() - start))
            for prefix in prefixes:
                res = predict_rnn(
                    prefix, num_chars, params, char_to_idx,
                    num_hiddens, vocab_size, idx_to_char
                )
                print(' -', res)

主函数调用

def main():
    (corpus_indices, char_to_idx, idx_to_char, vocab_size) = load_data_jay_lyrics()

    num_epochs, num_steps, batch_size, lr, clipping_theta = 250, 35, 32, 1e2, 1e-2
    pred_period, pred_len, prefixes = 50, 50, ['分开', '不分开']

    num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
    # params = get_params(num_inputs, num_hiddens, num_outputs)
    # res = predict_rnn('分开', 10, params, char_to_idx,
    #     num_hiddens, vocab_size, idx_to_char)
    # print(res)

    train_predict(
        num_inputs, num_hiddens, num_outputs, num_epochs,
        corpus_indices, batch_size, num_steps, vocab_size,
        pred_len, char_to_idx, idx_to_char, pred_period,
        prefixes, lr, clipping_theta
    )

7. 运行效果

在这里插入图片描述


8. 参考文献

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值