NLP从0开始

写在前面

整篇文章的写作思路围绕NLP主流任务展开。考虑到阅读人群基础不同,作为核心的这一部分会放在后面的章节,会先用较大的篇幅讲解NLP基础,包括如何把人类语言转化为计算机识别的编码,如何进行分词,循环神经网络的原理等等。
本人实力有限,如果发现梳理有错误,欢迎及时指出,若有不明白可以评论区留言,对问题会进行反复修订(电子文档的好处~)

来自总是重复名字我很烦啊的专属防爬贴!!!

NLP任务

NLP( Nature Language Processing),主要关注人类语言和机器语言的交互。例如自动生成李白风格的诗歌、机器翻译、完形填空、阅读理解等等。在1950年计算机科学之父图灵论文提出“Computing machinery and intelligence”开始,人类语言就开始尝试和计算机科学进行交融。回想一下我们在学习英语的时候,是不是买了语法书,上语法课,知道什么是主谓宾定状补,什么动词放在名词后,一个句子只能有一个动词等等。没错,在NLP初期,很多人也是通过类似的方式,基于规则的方式,教给计算机语法,从语法的角度去理解人类语言。但是这样有一个最大的弊端,语法是一个非常抽象复杂的东西,而语法和理解语言真的有必然的联系吗?或者说精通英文语法和精通英文理解的联系大吗?例如听到“Welcome to China”你大脑会去分析这一句是一个祈使句没有主语这种语法结构,才能理解他的意思吗?显然不是,语法只是语言学家从自然语言中总结出来的片面的规则,他总有缺陷。但最开始基于统计效果也很一般,因为没有很好的概率统计模型去完成这个工作,到21世纪,深度学习技术兴起,迅速占领NLP主流市场,各种语音助手、机器翻译开始出现,基于规则的流派逐渐成为江湖传说。

自然语言转化为机器语言

分词

人类语言不管是中文、英文还是其他文字,他都是符号,而计算机只能识别数字信息,所以,要把自然语言输入到计算机,第一步就是把字符转化为数字。但这一步之前,还需要把字符分离出来,比如[‘Welcome to China!’]就需要分成[‘Welcome’, ‘to’, ‘China’, ‘!’]。中文就更加复杂,例如[‘我爱北京天安门’]则需要分成[‘我’,‘爱’,‘北京’,‘天安门’]。
下面直接介绍工具:
中文的分词一般使用jieba

pip install jieba
import jieba
text = '我爱北京天安门'
# 精准模式适用于文本分析
jieba.lcut(text, cut_all=False)
>>> ['我','爱','北京','天安门']
# 搜索引擎模式适用于搜索引擎分词
jieba.lcut_for_search(content)
# 自定义词典分词
jieba.load_userdict('词典路径')

词性标注一般使用jieba.posseg

import jieba.posseg as pseg
text = '我爱北京天安门'
pseg.lcut(text)
>>> [pair('我', 'r'), pair('爱', 'v'), pair('北京', ''ns), pair('天安门', 'ns')]

英文分词简单的可以直接.split()进行切分,若要标注词性,精准分词等,可使用hanlp工具包:hanlp文档

分词进阶内容(针对中文)

在NLP领域,分词可以说是非常基础并且重要的工具,但是中文分词不像英文那么简单,英文可以按照空格直接分词,当然bert中把playing也分成了play ##ing两个,这是google团队采用word piece分词(这都是题外话啦)。中文分词难在词和字都有含义,例如,“从小吃到大”,你可以理解“从小 吃 到 大”,也可以理解“从 小吃 到 大”。

机械分词和隐马尔可夫链(HMM)

基于词典的机械切分可以理解为在一个已经载入好的海量数据库中去寻找和当前词语匹配的词,并进行切分。例如数据库中有“小吃”这个词,那么“从小吃到大”就可以被切分称“从 小吃 到 大”。如果数据库没有这个“新冠肺炎”,则很可能被切分成“新 冠 肺炎”。所以这个切分的弊端是非常明显的:1、存在歧义;2、依赖词典决定效果。
那有什么办法可以解决这个问题吗?传统的语言模型给了我们答案。
给出一个联合概率公式:若句子s由W1,W2…Wn个词组成,那么句子的概率
P ( s ) = P ( W 1 , W 2 , W 3 , W 4 . . . ) = P ( W 1 ) P ( W 2 ∣ W 1 ) . . . P ( W n ∣ W 1 W 2 . . . W n − 1 ) P(s) = P(W_1, W_2, W_3, W_4...)=P(W_1)P(W_2|W_1)...P(W_n|W_1W_2...W_{n-1}) P(s)=P(W1,W2,W3,W4...)=P(W1)P(W2W1)...P(WnW1W2...Wn1)
P ( W i ∣ W i − 1 ) = P ( W i − 1 , W i ) P ( W i − 1 ) P(W_i|W_{i-1})=\frac{P(W_{i-1},W_i)}{P(W_{i-1})} P(WiWi1)=P(Wi1)P(Wi1,Wi)
举个例子就好理解了:“他说我爱她不是因为他爱它”
P ( 它|爱 ) = P ( 爱 , 它 ) P ( 爱 ) = c o u n t ( 爱 , 它 ) c o u n t ( 爱 ) = 1 2 P(它|爱)=\frac {P(爱,它)}{P(爱)}=\frac {count(爱,它)}{count(爱)}=\frac {1}{2} P(它|爱)=P()P(,)=count()count(,)=21
这意味着,如果是爱,那有50%的概率是爱它。
但其实这样的模型有一个巨大的问题,计算两三个词还好,万一这个语料库有1000个词,那这个概率计算量是非常惊人的,而且就算你取到了一个比较大的值,他真的可以表示所有的词吗?并不一定。再或者,如果 P ( W i − 1 , W i ) P(W_{i-1},W_i) P(Wi1,Wi)中万一有一个数为0,或者为1,我们是不是就可以认为这个词语出现的概率就是0或者1呢?
所以,马尔科夫就假设,不需要看那么多词,我们就看下一个词。即:
P ( s ) = P ( W 1 , W 2 , W 3 , W 4 . . . ) = P ( W 1 ) P ( W 2 ∣ W 1 ) . . . P ( W n ∣ W n − 1 ) = ∏ i = 1 n P ( W i ∣ W i − 1 ) P(s) = P(W_1, W_2, W_3, W_4...)=P(W_1)P(W_2|W_1)...P(W_n|W_{n-1})=\prod_{i=1}^{n}P(W_i|W_{i-1}) P(s)=P(W1,W2,W3,W4...)=P(W1)P(W2W1)...P(WnWn1)=i=1nP(WiWi1)
最后,我们用一个图示案例“过过儿过过的生活”来表示一下这个马尔科夫机械切分是怎么做的?
在这里插入图片描述
接下来我们看看HMM做了什么优化。

基于序列标注的分词

假设我们对每一个词都进行一个标注,表示他的位置。
S:单个字;B:词语的开头;M:词语的中间;E:词语的结束。
那么在进行分词的时候,我们只需要让计算机正确把S、BM***ME(B开头E结束)进行切分就行。例如我在湖边钓鱼,不小心睡着了,他的序列编码就是SSBEBESBMEBME(如下图)。切分出来就是“我 在 湖边 钓鱼 , 不小心 睡着了”。这样看似乎科学了很多。那么如何让机器自动学会这个东西呢?并且这种序列标注还有一定的技巧,例如B和M之后一定不是B,E之后肯定不是E。我们可以给他一定的规则让分词效果更好。
在这里插入图片描述

最好用的传统分词:CRF(训练阶段)

我们把“我在湖边钓鱼,不小心睡着了”作为观测序列x(分别是{x1,x2,…,xn}),把SSBEBESBMEBME作为状态序列(分别是{y1,y2,…,yn})。
每一个状态,都可以由整个观测序列x决定,即y3与{x1,x2,…,xn}都相关
每一个状态,仅于相邻状态相关,即y2仅与y1和y3相关。
P ( y ∣ x ) = 1 Z e x p ( ∑ λ j t j ( y i − 1 , y i , x , i ) + ∑ μ k s k ( y i , x , i ) ) P(y|x)=\frac {1}{Z}exp(\sum\lambda_jt_j(y_{i-1},y_i,x,i)+\sum\mu_ks_k(y_i,x,i)) P(yx)=Z1exp(λjtj(yi1,yi,x,i)+μksk(yi,x,i))
t和s分别是状态转移函数和状态特征函数。
例如:
t j ( y i − 1 , y i , x , i ) = I ( y i − 1 = " B " 且 y i = " E " ) t_j(y_{i-1},y_i,x,i)=I(y_{i-1}="B"且y_i="E") tj(yi1,yi,x,i)=I(yi1="B"yi="E")
s k ( y i , x , i ) = I ( x i − 1 = " 钓 " 且 x i = " 鱼 " 且 x i + 1 = " , " 且 y i = " E " ) s_k(y_i,x,i)=I(x_{i-1}="钓"且x_i="鱼"且x_{i+1}=","且y_i="E") sk(yi,x,i)=I(xi1=""xi=""xi+1=","yi="E")
I(·)是一个权重函数,表示I(条件)时权重是多少,也就是对应的lambda和u的值。这样,我们就知道了每一个词和他附近的一些词(可能是相邻的1个词,也可能是3 5个词等等)的条件概率,接下来我们要寻求一条最优的路径去拼接出最可能的编码情况。

维特比Viterbi解码(预测阶段)

老规矩,先上公式再解释。虽然已经极力写的通俗易懂,但是一些公式问题上还是不能省。维特比的dp矩阵:
d p i j = m i n ( d p i − 1 , 1 + d i s t ( N i − 1 , 1 , N i , j ) + d p i − 1 , 2 + d i s t ( N i − 1 , 2 , N i , j ) ) dp_{ij}=min(dp_{i-1,1}+dist(N_{i-1,1},N_{i,j})+dp_{i-1,2}+dist(N_{i-1,2},N_{i,j})) dpij=min(dpi1,1+dist(Ni1,1,Ni,j)+dpi1,2+dist(Ni1,2,Ni,j))
确实有点复杂,所以我们画个图来形象理解一下:
在这里插入图片描述
图中蓝色数字表示转移的开销,紫色数字表示状态的开销,例如Start到N11,需要花费1+2=3点开销。我们要计算从start到end到最佳路径,我们需要计算N31和N32到end的最小开销路径,这个时候显然是N31(2+1 < 1 + 3),然后我们需要计算N21和N22到N31的最小开销路径,2+3>3+1,所以是N22到N31,接着计算N11和N12到N22点最优化路径,显然是N11。现在,最优路径已经计算出来:Start -> N11 -> N22 -> N31 -> End。这种倒过来递归求解最优路径的过程,被称作维特比解码过程。
在CRF解码过程中,我们需要t函数就是蓝色数字,s函数就是紫色数字,我们通过求解CRF后得到这样的篱笆网络,就能计算得到最优路径。

CRF++工具的使用(努力更新中… …)

编码

编码的方式有很多,因为现在主流的编码都是基于预训练模型来做,所以以前的编码我们就做一个简单介绍:
1、one-hot:有n个字符,则创建一个长度为n的稀疏矩阵,把对应字符编码的位置设置为1。举个例子

from keras.preprocessing.text import Tokenizer
text = {'我', '爱', '你'}
t = Tokenizer(num_words=None, char_level=False)
t.fit_on_texts(text)
for token in vocab:
    zero_list = [0]*len(text)
    token_index = t.texts_to_sequences([token])[0][0] - 1 # texts_to_sequences以1开始
    zero_list[token_index] = 1
    print(token, 'one-hot编码:',zero_list)
>>>
我 one-hot编码:[1,0,0]
爱 one-hot编码:[0,1,0]
你 one-hot编码:[0,0,1]

但这种编码方式短一点的文本还行,设想一下,如果要用这个方法去编码射雕英雄传这本小说,内存将无法承受如此大的稀疏矩阵存储。
2、word2vec:包含CBOW和skipgram两种模式。
CBOW是用左右两侧的上下文对中间词进行预测,假定有一句话Hope can set you free,windows=3,因此模型第一个训练出来的样本来自Hope can set,CBOW模式下将Hope set作为输入,can作为输出,在模型训练时,Hope can set都使用的one-hot编码,Hope:[1,0,0,0,0],can:[0,1,0,0,0], set:0,0,1,0,0。用变换矩阵35相乘得到31的【表示矩阵】。这里的3是最后得到的词向量维度。【表示矩阵】再和【变换矩阵】shape(5 * 3)相乘得到5 * 1的目标矩阵,在这里我们希望他是[0,1,0,0,0]。然后窗口按序向后移动重新更新参数,直到所有的语料被遍历完成,得到最终的【变换矩阵】。
skipgram是用中间的词预测两侧上下文。假定有一句话Hope can set you free,windows=3,因此模型第一个训练出来的样本来自Hope can set。skipgram模式下将can作为输入,Hope set作为输出。输入can[0,1,0,0,0]reshape(5,1)和变换矩阵3 * 5相乘得到3 * 1的表示矩阵,这个表示矩阵和许多不同的5 * 3的变换矩阵相乘得到5 * 1目标矩阵。
Github:fasttxt

# fasttext需要去github下载完整版,pip安装的是阉割版有些功能不能使用,链接在上方。
import fasttext
# 这里选择了一个维基百科数据集
model = fasttext.train_unsupervised('./data/enwik9/enwik9.txt')
model.get_word_vector('the')
>>>
array([ 0.31554842, -0.00567535,  0.02542016,  0.01240454, -0.27713037,
        0.09596794, -0.42942137, -0.13186786, -0.2389577 , -0.02847404,
        0.12398094,  0.2940258 , -0.16931549,  0.02517833,  0.10820759,
       -0.11116117,  0.07980197,  0.17255728,  0.05923959,  0.05312477,
        0.25332063,  0.12383557,  0.26340196, -0.06877434,  0.01602176,
        0.0572529 ,  0.175312  ,  0.04499907, -0.03429295,  0.26550826,
        0.05361822, -0.08058389,  0.35940173,  0.18476954,  0.11618206,
        0.01335344,  0.02825387, -0.02110594, -0.03370227, -0.03843364,
        0.03603617,  0.04085227,  0.37722406, -0.08784803, -0.10871147,
       -0.3422877 , -0.17854837, -0.12285236, -0.01105188, -0.22011152,
        0.16862307, -0.0683898 ,  0.24339588, -0.32868978, -0.1517483 ,
       -0.15977417,  0.10827688, -0.32918802,  0.01938748,  0.20195097,
       -0.1241372 ,  0.2528724 , -0.01422824,  0.07056748,  0.09309146,
        0.20510688,  0.00314162,  0.01717972, -0.1129839 ,  0.12191518,
       -0.16137297,  0.00360382,  0.1382029 , -0.10296268, -0.03633826,
        0.051523  , -0.26057386, -0.19538148,  0.18406765, -0.07116103,
        0.04601068, -0.04241964, -0.05157055,  0.03981249,  0.25914833,
       -0.12596582, -0.00762906, -0.2766355 , -0.40362012,  0.09305142,
       -0.01467515,  0.4663454 ,  0.01874566,  0.03209095, -0.02476245,
       -0.12284862, -0.21247824, -0.2051559 ,  0.24576288, -0.23159373],
      dtype=float32)
# 超参数设定
# 训练词向量过程中,我们可以设定很多常用超参数来调节我们的模型效果无监督训练模式:'skipgram'或者'cbow',默认为'skipgram',在实践中,skipgram模式在利用子词方面比cbow好
# 词嵌入维度dim:默认100,但随着语料库的增大,词嵌入的维度往往也要更大
# 数据循环次数epoch:默认为5,但当你的数据集足够大,可能不需要那么多次
# 学习率lr:默认0.05,根据京表演选择[0.01,1]
# 使用的线程数thread:默认12个线程,一般和cpu核数相同
model = fasttext.train_unsupervised('.//data//enwik9//enwik9.txt','cbow',dim=50, epoch=1, lr=0.01, thread=8)

如果判断这个模型训练的好坏呢?最简单的办法就是我们看一看这个词向量的相邻词向量。

# 检查单词向量质量的一种简单方法就是查看其临近单词,通过我们主观来判断这些邻近单词与目标单词相关性来粗略评价模型好坏
# 查找运动相关的单词
print(model.get_nearest_neighbors('sports'))
>>>
[(0.8615252375602722, 'sport'), (0.8453112840652466, 'sporting'), (0.8213860392570496, 'sportsnet'), (0.8085111975669861, 'sportsground'), (0.7918924689292908, 'sportscars'), (0.7911261916160583, 'motorsports'), (0.7884459495544434, 'sportsplex'), (0.7826608419418335, 'sportscar'), (0.7793251872062683, 'athletics'), (0.7720682621002197, 'sportswomen')]

数据增强

如果数据集不够多,或者噪声多,我们可以尝试用数据增强的方式来增加数据。CV中数据增强可以通过图片的翻转,mask等等方式,但NLP我们不能把句子颠倒影响语义,所以可以尝试回译的方法。例如把中文翻译成韩文再把翻译的韩文翻译回中文。

from google_trans_new import google_translator
translator = google_translator() # 实例化
text = ['今天天气不错', '一会儿去哪玩']
ko_res = translator.translate(text, lang_src='zh-cn', lang_tgt='ko')
print(ko_res)
cn_res = translator.translate(ko_res, lang_src='ko', lang_tgt='zh-cn')
print(cn_res)
>>>
[ '오늘의 날씨가 좋다 ",'어디에서 놀 수 있니? ' ]
['今天的天气很好,“我在哪里可以玩?” ]

RNN系列

为什么要语义捕捉

捕捉语义关联是NLP非常重要的一步。为什么要捕捉语义呢?举一个简单的例子。
一个补全内容的写作题:今天天气很好,我去打球,不小心把手机摔坏了,所以我下午要去____。
如果是让人来填,大概率都会填修手机把,因为你看到了前面的内容,把手机摔坏了,那为什么不是看到打球,填喝水、吃饭呢?因为这个空明显是关联手机摔坏的了的内容。人类理解起来很简单,但是你让机器去理解这句话,就很费劲了。为了让机器理解意思,在RNN之前,大家会选择N-Gram的方式,大概就是把前N个字一起连起来理解,知乎:N-Gram详解,但这有一个最大的问题,就是不同的句子他的N不一样,比如这句话,你至少需要N=12,13,而N每增加1计算量都是指数级增长,别说12了,5都算起来很费劲,所以NGram无法在长距离语义理解上取得突破,这个时候,RNN的横空出世打破了这一僵局。

从感知机到神经网络的矩阵表示

因为RNN不像卷积神经网络那样可以用一个gif图表示,RNN从头到尾其实都是矩阵运算,要了解RNN就必须先了解神经网络矩阵表示。
首先介绍最经典的单层感知器。
一层有n个感知器,每个感知器都会给出 x i w i + b x_iw_i+b xiwi+b的一个加权结果
∑ i = 1 n ( w i x i + b ) \sum\limits_{i=1}^{n} (w_ix_i + b) i=1n(wixi+b)
( w 1 , w 2 , ⋯   , w n , b ) ⋅ ( x 1 , x 2 , ⋯   , x n , 1 ) T = ( y 1 ) (w_1,w_2,\cdots,w_n,b)\cdot(x_1,x_2,\cdots,x_n,1)^T=(y_1) (w1,w2,,wn,b)(x1,x2,,xn,1)T=(y1)
那么对于一般的神经网络来说,我们就多增加几个感知器,也就形成了一个隐层。我们用三个神经元来举例:
三个神经元的神经网络图解 ( w 1 ( 1 ) w 2 ( 1 ) ⋯ w n ( 1 ) b ( 1 ) w 1 ( 2 ) w 2 ( 2 ) ⋯ w n ( 2 ) b ( 2 ) w 1 ( 3 ) w 2 ( 3 ) ⋯ w n ( 3 ) b ( 3 ) ) ⋅ ( x 1 , x 2 , ⋯   , x n , 1 ) T = ( y ( 1 ) , y ( 2 ) , y ( 3 ) ) T \begin{pmatrix} w_1^{(1)} & w_2^{(1)} & \cdots &w_n ^{(1)} & b^{(1)} \\\\ w_1^{(2)} & w_2^{(2)} & \cdots &w_n ^{(2)} & b^{(2)} \\\\ w_1^{(3)} & w_2^{(3)} & \cdots &w_n ^{(3)} & b^{(3)} \\\\ \end{pmatrix} \cdot(x_1,x_2,\cdots,x_n,1)^T = (y^{(1)},y^{(2)} ,y^{(3)} )^{T} w1(1)w1(2)w1(3)w2(1)w2(2)w2(3)wn(1)wn(2)wn(3)b(1)b(2)b(3) (x1,x2,,xn,1)T=(y(1),y(2),y(3))T
即: W X T = Y T \bf WX^T=Y^T WXT=YT
接下来继续拓展到多层神经网络:

神经网络图例
hidden layer1的输入为 f 1 ( W 1 X T ) f_1(\bf W_1X^T) f1(W1XT),hidden layer2的输入为 f 2 ( W 2 f 1 ( W 1 X T ) ) f_2({\bf W_2}{f_1}(\bf W_1X^T)) f2(W2f1(W1XT)),最后output layer的结果是 o u t p u t = f 0 ( W 0 f 2 ( W 2 f 1 ( W 1 X T ) ) ) output = f_0({\bf W_0}f_2({\bf W_2}{f_1}(\bf W_1X^T))) output=f0(W0f2(W2f1(W1XT)))

RNN

RNN是为了处理序列数据而建立的一套网络。简单理解什么叫序列数据:
[ x 0 , x 1 , x 2 , ⋯   , x n ] [x_0, x_1, x_2, \cdots, x_n] [x0,x1,x2,,xn]
这个序列可以表示n天内股票价格数据,n天内天气数据等等,即这个序列是一个随着时间连续变换的数据组成的,或者说,当前数据是在前一个数据基础上变化而来。那么既然所有数据都和之前样本存在关联,所以通过神经网络在时序上的展开,我们能够找到样本之间的关联。
我们单独引入了一个隐层变量:
[ h 0 , h 1 , h 2 , ⋯   , h n ] [h_0, h_1, h_2, \cdots, h_n] [h0,h1,h2,,hn]
他就表示 x i x_i xi t i t_i ti时刻对应的一个状态量。
那么如何计算这个t时刻的状态量呢?
h t = σ ( W i h x t + W h h h t − 1 + b h ) h_t = \sigma (\bf W_{ih}x_t + W_{hh}h_{t-1}+b_h) ht=σ(Wihxt+Whhht1+bh)
这个公式表示当前时刻的隐藏变量由当前时刻数据 x t x_t xt和前一时刻隐藏状态量 h t − 1 h_{t-1} ht1共同计算。
这样做有一个最大的好处就是,实际上 h t − 1 h_{t-1} ht1时刻的状态量是由 x t − 1 x_{t-1} xt1数据和 h t − 2 h_{t-2} ht2共同决定,最后无线套娃循环,就能保证当前时刻的状态是由前面所有时刻共同参与决定的。
最后整个循环完成之后,用一个sigmoid函数进行激活(sigmoid其实最主要的作用是分类,后面会讲),即:
Z t = s o f t m a x ( W h z h t + b z ) \bf Z_t = softmax(W_{hz}h_t + b_z) Zt=softmax(Whzht+bz)
最后放上这个网络图:
RNN
下面附上调包代码:

import torch
import torch.nn as nn
rnn = nn.RNN(5, 6, 3) # nn.RNN(input_size, hidden_size, num_layers * num_direction)
input1 = torch.randn(2, 3, 5) # (sequence_length输入序列长度(句子长度),batch_size批次样本数,input_size)
h0 = torch.randn(3, 3, 6) # (num_layers * num_direction层数*网络方向数, batch_zize批次样本数, hidden_size)
output, hn = rnn(input1, h0) # 输入张量放入RNN得到输出结果包含output,h0
print(output, hn)
>>>

关于RNN你必须要知道的两件事其一.RNN梯度消失并不影响训练

看了网上很多对RNN梯度消失的解读,个人感觉并不完整,数学公式的推导会造成一定的阅读障碍和阅读门槛,所以我们不如拆分这个问题,尝试着讲清楚。首先RNN的序列记忆是因为,更新当前输入状态矩阵是之前的隐层状态矩阵*当前输入状态矩阵,这个每一层都在传递的W矩阵是不变的,所以在传递过程中,梯度是不会消失的!换言之,假如 W ∗ S t − 1 W*S_{t-1} WSt1的梯度消失了,很多参数变成0了,但是当更新到 W ∗ S t W*S_{t} WSt时,他不一定会很多参数都是0,所以中间的短暂消失并不会影响到模型的训练。
在这里插入图片描述
我认为大家提到的RNN梯度消失,更可能是在说他的长期记忆消失。如图所示,假如t-1时刻的梯度是100,s时刻是0.01,t+1时刻是0.1,那么此时t-1时刻显然是非常重要的,但如果反向传播回来,t-1时刻的权重只有0.1,甚至和t+1时刻是一样的,这样就会导致模型投入更多的关注在t+1时刻,而对t-1时刻的关注少了,出现“记忆消失”。

关于RNN你必须要知道的两件事其二.RNN重置隐层状态而不是W矩阵

首先,RNN之所以对序列数据处理得心应手,最重要的原因是在于它可以实现句子一定间隔的语义捕捉。但当输入下一个句子的时候,我们默认这一句话和前一句话其实是两个事。因此需要重置隐层状态h,然后重新进行RNN迭代计算。也就是说,在处理一个句子的时候,我们需要更新隐藏态来保存之前的语义信息,而在处理多个句子的时候,后面的句子不大可能会和前面的句子保持密切联系,所以我们需要reset隐层矩阵,重新捕捉新句子/新文本的语意联系。

LSTM

RNN的确捕捉到了之前的语义信息,而且极大拓展了ngram的计算局限性(从前3个到了前面所有)。但是依然会存在一个问题,我们还是用之前的例子:**今天天气很好,我去打球,不小心把手机摔坏了,所以我下午要去____。**其实我们只需要看到前面10多个字就知道填什么了,天气很好,我去打球这类都是干扰信息,我们并不需要关注这么靠前的文字。而且RNN还存在一个非常重要的问题,就是如果序列过长,他一系列权重系数相乘,可能会出现梯度爆炸或者消失。梯度消失,梯度太小,权重无法被更新导致训练失败;梯度爆炸,梯度太大导致结果溢出NaN,或者训练失败。所以,LSTM为了解决长文本处理问题,就加入了一系列控制措施,控制门遗忘门细胞变量等,来抑制RNN的问题。具体的数学原理就不过多讨论,因为这个模型实在是太经典了,直接附上代码:

# 和RNN相比,LSTM就是在输入和输出的时候多了细胞状态c0,cn
import torch
import torch.nn as nn
lstm = nn.LSTM(5, 6, 2) # nn.LSTM(input_size, hidden_size, num_layers * num_direction)
input1 = torch.randn(1, 3, 5) # (sequence_length输入序列长度(句子长度),batch_size批次样本数,input_size)
h0 = torch.randn(2, 3, 6) # (num_layers * num_direction层数*网络方向数, batch_zize批次样本数, hidden_size)
c0 = torch.randn(2, 3, 6) # (num_layers * num_direction层数*网络方向数, batch_zize批次样本数, hidden_size)
output, (hn, cn) = lstm(input1, (h0, c0)) # 输入张量放入RNN得到输出结果包含output,h0

pytorch框架:

class net(nn.Module):
	def __init__(self, input_dim, hidden_dim, layer_dim, output_dim):
		super().__init__()
		self.hidden_dim = hidden_dim
		self.layer_dim = layer_dim
		self.output_dim = output_dim
		self.lstm = nn.LSTM(input_dim, hidden_dim, layer_dim, batch_first=True)
		self.fc = nn.Linear(hidden_dim, output_dim)
		self.dropout = nn.Dropout(p=0.2)
	def forward(self, x):
		h0 = torch.zeros(self.layer_dim, x.size(0), self.hidden_dim).requires_grad_().to(device)
		c0 = torch.zeros(self.layer_dim, x.size(0), self.hidden_dim).requires_grad_().to(device)
		output, (hn, cn) = self.lstm(x, (h0.detach(), c0.detach()))
		output = self.dropout(self.fc(output[:,-1,:]))
		return output

GRU

简易版LSTM。和RNN的代码写法几乎一模一样。

import torch
import torch.nn as nn
gru = nn.GRU(5, 6, 2)
input1 = torch.randn(1, 3, 5)
h0 = torch.randn(2, 3, 6)
out, hn = gru(input1, h0)

pytorch框架:

import torch
import torch.nn as nn
device = 'cuda' if torch.cuda.is_available() else 'cpu'
class net(nn.Module):
    def __init__(self, input_dim, hidden_dim, layer_dim, output_dim):
        super().__init__()
        self.hidden_dim = hidden_dim
        self.layer_dim = layer_dim
        self.output_dim = output_dim
        self.gru = nn.GRU(input_dim, hidden_dim, layer_dim, batch_first=True)
        self.fc = nn.Linear(hidden_dim, output_dim)
        self.dropout = nn.Dropout(p=0.2)
        
    def forward(self, x):
        h0 = torch.zeros(self.layer_dim, x.size(0), self.hidden_dim).requires_grad_().to(device)
        output, (hn, cn) = self.gru(x, h0.detach())
        output = self.dropout(self.fc(output[:,-1,:]))
        return output
gru = net(5, 6, 2, 2).to(device)
input1 = torch.randn(2,3, 5).to(device)
print(gru(input1))

Stacking RNN

三大传统RNN已经讲完,但是实际上后面很多模型都是通过RNN的堆叠完成的,例如大名鼎鼎的ElMo模型。所以在这里需要提前说一下这个思路。代码实现也非常简单,就是在代码的num_layers参数设置为2,3……

(RNN系列项目实战)分类任务-人名分类器

基于RNN、LSTM、GRU的分类任务实战【数据加载处理-循环神经网络框架撰写-模型训练-模型评估-模型预测-调优总结】

Wavenet(因果卷积)和Transformer架构分析

WaveNet-卷积网络在NLP中的跨界应用和Transformer架构分析

Transformer

网上对于Transformer的解读已经非常多了,我在这里就不重复别人的话了,可以参考一些解读的文章:
十分钟理解transformer
transformer模型详解
现在,假设你已经大概知道了transformer是什么东西,在这个基础上,我对transformer一些比较重要的点再通俗的讲一下。为了更加硬核一点,我们手撕原论文,从论文去发现问题,解决问题(放心,没有阅读理解,只会饱含例子)。

Transformer你一定要知道的背景

在RNN系列中,我们知道当前信息可以由之前信息传递而来,也就是隐藏状态和当前状态共同决定。这会导致两个问题:
1、过长的信息会出现梯度消失和爆炸。
a.梯度爆炸。出现这个问题主要是因为RNN反向传播的过程中,会对tanh求导乘以矩阵W,若是这个初始矩阵非常大,那么乘出来的结果也会非常大,对于长序列又是累乘debuff,那么梯度就会非常夸张。
b.梯度消失。和爆炸就是完全相反的情况,tanh求导后在[0,1]之间,更新参数乘以状态矩阵,万一W非常小,那么一个小于1的数乘一个小数,则会更小,加上累乘,梯度就会消失。
c.无论是爆炸还是消失,要么对一个参数的权重依赖特别大,要么完全无视一个参数,都不利于对特征的抽取。
2、当前状态的计算依赖上一个状态信息的传递,无法并行运算。
在GBDT这种残差树的学习中,我们知道GBDT除了降低偏差忽视方差外,最主要的问题就是他不能并行运算。在RNN中亦是如此,当前的计算需要等上一步计算结束。并且因为它是一步一步传递信息,如果想保存前面很多的信息需要大量的内存开销(这种情况是存在的,例如“今天我把手机摔坏了,我跟我的妈妈说xxxxx,我妈妈批评了我,说我xxxxx,这个时候我爸突然回来听到这个消息,打断了妈妈,说xxxxx,但已经于事无补,我还是得出去修手机。”这个修手机其实就和第一句话有关系,中间都是非绝对相关信息)。
这个时候,1dcnn网络的提出,可以解决并行问题,也可以扩大视野,但是只是缓解了问题,没有从根本上解决问题。扩大了视野并不等于上帝视野。1DCNN的因果卷积会单独用一章来讲,这里可以先通俗理解一下,就是卷积网络在NLP领域的不错尝试
此时,Transformer横空出世,表示:我要用上帝视野来并行抽取特征。人皆哗然,称出现了CNN和RNN的终结者。

Transformer编码和解码的理解

在这里插入图片描述
论文中说,编码器会输入一个(x1…xn)的序列,每一个xi会被映射成(z1…zn)的词向量。参考上文word embedding。然后解码器会拿到编码器的输出,生成一个长为m的序列(y1…ym)。
在这里插入图片描述

和编码器不一样的是,解码器的词是一个一个生成的。这就是自回归模型。当然,这里的预测阶段和训练阶段是有点区别的,我们举个例子:
在训练阶段,每个时间步输入是上一个时间步的输入加上真实标签序列向后移一位,假设序列是How are you
time step=1 input: SOS * * * *
time step=2 input: SOS How * * *
time step=3 input: SOS How are * *
time step=4 input: SOS How are you *
time step=5 input: SOS How are you EOS

在预测阶段:
time step=1 input: ‘SOS’,预测值"How"
time step=2 input: ‘SOS How’,预测值"are"
time step=3 input: ‘SOS How are’,预测值"you"
time step=4 input: ‘SOS How are you’,预测值"EOS"

LayerNorm的理解

之所以需要把这个LayerNorm单拎出来讲是因为我看了别人的解读,大家似乎都更乐意的把他说成是一个归一化的手段使得数值回归到平缓的区间,方便反向传播梯度更新。往复杂了说是改变了损失函数的利普希茨常数:各种Norm的解读。往简单了说就是训练更快更好了。但是个人拙见,应该把LayerNorm在长文本序列中的应用和BatchNorm做一个对比,就可以非常容易明白为什么是用LayerNorm。
在这里插入图片描述
在上一部分知道了输入的维度,是[Sequence length(一句话多少个单词n), batch(一次喂给模型多少句话), embedding dimension(每个词映射的维度m)],所以我们可以把矩阵空间画出来,就像这样一个正方形。我们知道,Norm的方法就是对矩阵内的元素进行均值和方差的归一化。此时,如果我们使用batch norm会出现如图的情况,如果这一batch全是长文本还好,一旦出现一些短文本,那他们的矩阵是非常稀疏的,二维矩阵空间内的有效值并不多,如果你每一批的文本长度变化都很大,那么你每一次做BN时它的方差和均值都会明显抖动;除此之外,在做预测时需要记录全局的均值和方差,万一你的预测样本是一个长度突破天际,比如训练集都是100-200个单词的,预测样本是一个400单词的,那么这个全局的均值和方差对它来说可能不是那么的适用。反过来看LN,他是对每个样本(句子)做方差和均值的计算,句子如果很长或者很短,均值和方差并不敏感,因为句子短他的矩阵就小,句子长矩阵就大嘛,就可以很好的规避这个问题。
在调用pytorch封装好的transformer的时候,和论文其实是有一些改良的。我们来看:
在这里插入图片描述

论文的计算逻辑应该是对sublayer,也就是样本通过mlp后加残差做dropout后再归一化:
return self.norm(self.dropout(x + sublayer(x)))
实际上pytorch封装的逻辑是先对样本空间做归一化后再通过mlp和dropout层,再加残差:
return x + self.dropout(sublayer(self.norm(x)))

自注意力公式的理解

A t t e n t i o n ( Q , K , V ) = s o f t m a x ( Q K T d k ) V Attention(Q,K,V)=softmax(\frac {QK^T}{\sqrt{d_k}})V Attention(Q,K,V)=softmax(dk QKT)V
对于这个公式,有两点需要做额外注解。
1.self-attention中的归一化
在训练时,随着词嵌入维度dk增大,Q*K点积后的结果也会增大,在训练时会让softmax函数进入梯度非常小的区域,可能出现梯度消失造成模型收敛困难。
从数学的意义上说,假设QK的统计变量满足标准正态分的独立随机变量。意味着QK满足均值为0,方差为1.那么QK点乘结果就是均值为0,方差为dk,为了抵消这种方差被放大dk倍的影响,在计算中主动把点积缩放 1 d k \frac {1}{\sqrt{}{d_k}} dk1,就可以使得点积后的结果均值为0,防方差为1。
softmax如何影响输出结果
softmax函数将输入向量x做了一个归一化映射,首先通过自然底数e将元素之间的差距拉大,然后再归一化为一个新的分布。在这个过程中假设某个输入x中最大元素的下标是k,如果输入的数量级变大,就是x中每个分量绝对值都很大,那么数学上会造成 y k y_k yk的值非常接近1。举个例子:
x = [ a , a , 2 a ] x=[a,a,2a] x=[a,a,2a]
a=1时, y 3 = [ 0.576 ] y_3=[0.576] y3=[0.576]
a=10时, y 3 = [ 0.999 ] y_3=[0.999] y3=[0.999]
a=100时, y 3 = [ 1 ] y_3=[1] y3=[1]
表面上 y 3 y_3 y3只和 y 1 y_1 y1 y 2 y_2 y2差了2倍,但是softmax后若是a足够大,即使是10, y 3 y_3 y3都已经远远超过 y 1 y_1 y1 y 2 y_2 y2,所以在输入元素数量级较大的时候,softmax几乎把全部的概率分布都分配给了最大标签。

详解Encoder部分的并行化

请添加图片描述
首先需要理解Encoder流程。假设输入的序列是How are you。那么这三个单词通过embedding模块变成x1,x2,x3是可以并行进行的。并且这一层的处理是不需要构建依赖关系。
进入self-attention层后,对于任意一个单词例如x1要计算x1对于其他所有token的注意力分布,得到z1,这个过程是有依赖性的,必须等序列中所有单词都完成embedding彩壳进行。因此这一步是不能并行运算的。但我们真实计算注意力分布的时候采用的都是矩阵计算,也就是可以一次性的计算出所有token的注意力张量,我们通常把矩阵计算看作并行计算。但这里的并行和词嵌入部分的并行并不是一个概念。
进入前馈全连接层对不同的张量z1,z2,z3是不构成依赖关系的,所以这一层也可以实现并行运算。请添加图片描述

详解Decoder部分的并行化

注意,Decoder模块也有并行化处理的思路。在谈论Decoder部分时,一定要分开谈论训练阶段和预测阶段。
在训练阶段Decoder是采用了并行处理的的。self-attention和encoder-decoder attention两个并行化处理也是体现在矩阵乘法,和encoder在注意力层的并行化理解是一致的,在进行embedding和feed forward时,因为各个向量不存在依赖关系,因此也是完全并行化运行,这也encoder在相同部分的并行化理解也是一样的。
但是decoder在预测阶段并不认为采用了并行化处理。因为第一个time step的输入只是一个SOS,后续每个time step的输入是依次添加之前所有预测得到的token。举个例子,若这句话有100个token输入,在训练阶段,则是把这100个token的encoder端输出一次性给到decoder端,具体通过mask的方法进行训练,但也体现了一些子层的并行处理。但是在预测阶段,则需要重复处理100次循环操作,每次输入添加一个token,输出序列比上一次多一个,这不是并行化。

Transformer解决seq2seq两大缺陷

seq2seq序列生成模型也是一个encoder到decoder的编码解码模型。但是存在两大缺陷:
1、seq2seq架构的第一大缺陷是将encoder端的所有信息通过RNN压缩成一个固定长度的语义向量中,用这个固定的向量来代表编码端的全部信息,这样既会造成信息的损耗,也无法让解码端在解码时把注意力聚焦在重要信息上。
2、seq2seq架构的第二大缺陷是无法并行运算,本质上他就是RNN到RNN的模型。
而Transformer同时解决了这两大缺陷,用Multi-head attention机制来解决encoder固定编码的问题,又让decoder在解码的时候每一步可以通过注意力去关注重要部分。

Transformer的一些思考

在transformer架构中,我们看到了其他模型的影子。例如feed forward中看到了resnet的残差设计防止梯度消失;在多头注意力模块中看到了经典cnn的卷积层多kernel设计;在位置编码模块中看到了RNN序列信息传递的设计。因此,通过对比,我们可更加容易理解各个模块的意义。那么大家还在transformer中看到了什么经典网络的影子呢?

(注意力机制项目实战)seq2seq任务-英译法

以GRUEncoder,GRUDecoder为baseline的英译法项目【从baseline到attention和bert调优】

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

总是重复名字我很烦啊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值