CVer从0入门NLP(一)———词向量与RNN模型

1,726 阅读19分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

🍊作者简介:秃头小苏,致力于用最通俗的语言描述问题

🍊专栏推荐:深度学习网络原理与实战

🍊近期目标:写好专栏的每一篇文章

🍊支持小苏:点赞👍🏼、收藏⭐、留言📩

 

CVer从0入门NLP(一)———词向量与RNN模型

写在前面

Hello,大家好,我是小苏👦🏽👦🏽👦🏽

之前的博客中,我都为大家介绍的是计算机视觉的知识,今天准备和大家唠唠NLP的内容。其实呢,对于NLP,我也是初学者,之前只是有一个大概的了解,所以本系列会以一个初学者的视角带大家走进NLP的世界,如果博客中有解释不到位的地方,希望各位大佬指正。🍭🍭🍭

当然了,NLP的内容很多,你如果在网上搜NLP学习路线的话你会看的眼花缭乱,本系列主要会介绍一些重要的知识点,一些历史久远的模型就不介绍了,我个人觉得用处不大,我们的目标是像经典模型看齐,如GPT系列,BERT家族等等。🍡🍡🍡

本系列目前准备先分三节介绍,后面会慢慢补充新内容。第一节先从词向量为切入点,然后介绍RNN模型并手撸一个RNN;第二节会介绍RNN的改进LSTM及ELMO模型;第三节会详细介绍GPT和BERT,以及它们的相同点和不同点。🍬🍬🍬

 

词向量

我们知道,NLP任务中我们处理的对象是一个个的词,但是计算机根本不认识我们的词啊,需要将其转换为适合计算机处理的数据类型。一种常见的做法是独热编码(one-hot编码),假设我们现在要对“秃”、“头”,“小”,“苏”四个字进行独热编码,其结果如下:

image-20230913152222723

​ 可以看出,上图可以用一串数字表示出“秃”、“头”,“小”,“苏”这四个汉字,如用1 0 0 0表示“秃”,用0 1 0 0表示“头”......

​ 但是这种表示方法是否存在缺陷呢?大家都可以思考思考,我给出两点如下:

  1. 这种编码方式对于我这个案例来说貌似是还蛮不错的,但是大家有没有想过,对于一个文本翻译任务来说,往往里面有大量大量的汉字,假设有10000个,那么一个单独的字,如“秃”就需要一个1×10000维的矩阵来表示,而且矩阵中有9999个0,这无疑是对空间的一种浪费。
  2. 这种编码方式无法表示两个相关单词的关系,如“秃”和“头”这两个单词明显是有某种内在的关系的,但是独热编码却无法表示这种关系【余弦相似度为0,后文对余弦相似度有介绍】。

基于以上的两点,我觉得我们的对词的编码应该符合以下几点要求:

  1. 我们可以将词表示为数字向量。
  2. 我们尽可能的节省空间的消耗。
  3. 我们可以轻松计算向量之间的相似程度。

我们先来看这样的一个例子,参考:The Illustrated Word2vec🎅🏽🎅🏽🎅🏽🍚🍚🍚

现在正值秋招大好时机,大家的工作都找的怎么样了腻,祝大家都能找到令自己满意的工作。在投简历的过程中,我们会发现很多公司都会有性格测试这一环节,这个测试会咨询你一系列的问题,然后从多个维度来对你的性格做全面分析。其中,测试测试者的内向或外向往往是测试中的一个维度,假设我(Jay)的内向/外向得分为38(满分100),则我们可以绘制下图:

image-20230913154654011

为了更好的表示数据,我们将数据限制到-1~1范围内,如下:

img

这样我们就可以对Jay这个人是否外向做一个大致的评价,但是人是复杂的,仅仅从一个维度来分析一个人的性格肯定是不准确的,因此,我们再来添加一个维度来综合评价Jay这个人的性格特点:

img

可以看到,现在我们就可以从两个维度来描述Jay这个人了,在上图的坐标系中就是一个坐标为(-0.4,0.8)的点,或者说是从原点到(-0.4,0.8)的向量。当然了,如何还有别人有这样的两个维度,我就能通过比较他们的向量来表示他们的相似性。

image-20230913162620927

从上图可以和明显的看出,Person1和Jay更像,但是这是我们直观的感受,我们可不可以通过数值来反应他们之间的相似度呢,当然可以,一种常见的计算相似度的方法是余弦相似度cosine_similarity,结果如下:

img

🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷

不知道大家知不知道计算余弦相似度,这里简单介绍一下:

余弦相似度是一种用于衡量两个向量之间相似性的度量方法,通常在自然语言处理和信息检索等领域广泛使用。它计算两个向量之间的夹角余弦值,值越接近1表示两个向量越相似,值越接近-1表示两个向量越不相似,值接近0表示两个向量之间没有明显的相似性。

余弦相似度的计算公式如下:

余弦相似度=ABAB\frac{A \cdot B}{||A||||B||}

其中:

  • AB 是要比较的两个向量。
  • ABA \cdot B 表示向量**AA**与向量 **BB**的点积(内积)。
  • **A||A||**和 B||B|| 分别表示向量 **AA**与向量 **BB**的范数(模)。

可以来简单举个例子:

假设有两个向量 A=[2,3]A=[2,3]B=[1,4]B=[1,4]。我们来计算它们之间的余弦相似度:

AB=(2×1)+(3×4)=14A \cdot B=(2×1)+(3×4)=14

A=22+32=13||A||=\sqrt{2^2+3^2}=\sqrt{13}

B=12+42=17||B||=\sqrt{1^2+4^2}=\sqrt{17}

则:余弦相似度=ABAB=1413170.86\frac{A \cdot B}{||A||||B||}=\frac{14}{\sqrt{13}\sqrt{17}}\approx0.86

🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷🌷

上面展示的是从两个维度刻画一个人的性格,但是在实际中比两维更多,国外心理学家研究了五个主要人格,所以我们可以将上面的二维扩展到五维,如下图所示:

img

显然,现在我们有五个维度的数据,我们无法通过平面向量的形式来观察不同人物之前的相似性,但是我们仍然可以计算他们之前的相似度,如下:

img


通过上面的性格测评小例子,我想告诉大家的是我们可以把诸如"外向/内向"、“自卑/自负”等性格特征表述成向量的形式,并且每个人都可以用这些种向量形式表示,同时我们可以根据这种向量的表述来计算每个人之前的相似度。

同样的道理,人可以,那么词也可以,我们把一个个词表示成这样的向量形式,这种向量表示形式就是词向量。那么词向量到底长什么样呢?我们一起来看看“King”这个词的词向量(这是在维基百科上训练好的),如下:

[ 0.50451 , 0.68607 , -0.59517 , -0.022801, 0.60046 , -0.13498 , -0.08813 , 0.47377 , -0.61798 , -0.31012 , -0.076666, 1.493 , -0.034189, -0.98173 , 0.68229 , 0.81722 , -0.51874 , -0.31503 , -0.55809 , 0.66421 , 0.1961 , -0.13495 , -0.11476 , -0.30344 , 0.41177 , -2.223 , -1.0756 , -1.0783 , -0.34354 , 0.33505 , 1.9927 , -0.04234 , -0.64319 , 0.71125 , 0.49159 , 0.16754 , 0.34344 , -0.25663 , -0.8523 , 0.1661 , 0.40102 , 1.1685 , -1.0137 , -0.21585 , -0.15155 , 0.78321 , -0.91241 , -1.6106 , -0.64426 , -0.51042 ]

这一共有50个数字,即表示我们选择了50个维度的特征来表示“king”这个词,也即这个向量表示“king”这个词。同样的道理,别的单词也会有属于他们自己的向量表示,形式和上面的是一样的,都是50维,但是里面具体的值不同。为了方便展示不同词之间的联系,我们将表示“king”的词向量换一种方式展示,根据其值的不同标记成不同的颜色(若数值接近2,则为红色;接近0,则为白色;接近-2,则为蓝色),如下图:

image-20230913194932868

当然了,我们用同样的道理,会得到其它词的词向量表示,如下:

img

可以看到,“Man”和“Woman”之前的相似程度似乎比它们和“King”之前的相似程度高,这也是符合我们直觉的,即“Man”和“Woman”之前的联系似乎比较大。

这就说明,经过把词变成词向量之后,我们可以发现不同词之前的相关程度了。这里你可能会问了,怎么把词变成词向量呢?不急,我们马上解答。🧃🧃🧃

我们再拿我们一开始“秃”、“头”,“小”,“苏”四个字为例,我们使用独热编码编码这四个字后,它们之间的余弦相似度都为0,无法表示它们之间的相关程度,因此使用独热编码作为词向量效果不好。那么改使用什么呢,一种可能的方案是Word Embedding。我们先来说说通过Word Embedding可以达到什么样的效果,同样拿“秃”、“头”,“小”,“苏”四个字为例,使用Word Embedding后它们的分布是这样的:

image-20220805160030426

即“秃”和“头”在某个空间中离的比较近,说明这两个词的相关性较大。即Word Embedding可以从较高的维度去考虑一些词,那么会发现一些词之前存在某种关联。

那么如何进行Word Embedding,如何得到我们的词向量呢?首先我需要让大家认识到一点,进行Word Embedding,其实重点就是寻找一个合适的矩阵Q。然后将我们之前的one hot编码乘上Q,,比如“秃”的one hot 编码是1 0 0 0,假设我们寻找到了一个矩阵Q,

image.png 那么我们将它们两个相乘,就得到了“秃”的词向量:

词向量“秃”:

image.png 同理,我们可以得到其它几个词的词向量:

image-20230913212503302

好了,到这里你或许明白了我们的目标就是寻找一个变化矩阵Q。那么这个Q又是怎么寻找的呢,其实呢,这个Q矩阵是训练出来的。一开始,有一种神经网络语言模型,叫做NNLM,它在完成它的任务的时候产生了一种副产物,这个副产物就是这个矩阵Q。【这里我们不细讲了,大家感兴趣的去了解一下,资料很多】后面人们发现这个副产物挺好用,因为可以进行Word Embedding,将词变成词向量嘛。于是科研人员就进一步研究,设计出了Word2Vec模型,这个模型是专门用来得到这个矩阵Q的。【后面我们也叫这个矩阵Q为Embedding矩阵】🥗🥗🥗

Word2Vec模型有两个结构,如下:

  • CBOW,这种模型类似于完型填空,核心思想是把一个句子中间的某个词挡住,然后用这个词的上下文单词去预测这个被挡住的词。🍚🍚🍚
  • Skip-gram,这个和CBOW结构刚好相反,它的核心思想是根据一个给定的词去预测这个词的上下文。🍚🍚🍚

它们的区别可以用下图表示:

Word2Vec

至于它们具体是怎么实现的我不打算讲,感兴趣的可以去搜搜。我简单说说它的思路:在它们训练时,首先会随机初始化一个Embedding表和Context表,然后我们会根据输入单词去查找两个表,并计算它们的点积,这个点击表示输入和上下文的相似程度,接着会根据这个相似程度来设计损失函数,最后根据损失不断的调整两个表。当训练完成后,我们就得到了我们的Embedding表,也就是Q矩阵。🍗🍗🍗

 

RNN模型

上一小节我们介绍了词向量,它解决的是我们NLP任务中输入问题。下面我们将一起来唠唠NLP任务中的常见模型。🍄🍄🍄

RNN模型结构

RNN(循环神经网络)我想大家多少都有所耳闻吧,它主要用于解决时序问题,例如时间序列、自然语言文本、音频信号等。

话不多说,我们直接来看RNN的模型图,如下:

img

啊,什么,这这点!!!?你或许感到震惊,RNN的模型结构就这么点儿???是的,没错,就这些。🥗🥗🥗首先,它有一个输入XtX_t,这是一个序列输入,比如某时刻的输入为xix_ixix_i会输入到模块A中【注意:这里不止一个输入,还会有一个输入hi1h_{i-1}一起送入模块A】,然后模块A输出一个值hih_i。接着会将输出hih_i和下一个输入xi+1x_{i+1}送入模块A,得到输出hi+1h_{i+1}。【注意:最基础的RNN的输出和hth_t是一样的】重复上面的过程,就是RNN啦。

上面的图用一个循环表示RNN,其实看起来还是比较不舒服,那么我们把这个循环展开,其结构就会比较清晰了,如下图所示:

img

知道了RNN的大体结构,我觉得你或与会对模块A的结构很敢兴趣,那我劝你不要太敢兴趣。🧃🧃🧃因为模块A真的很简单,就是一个tanh层,如下:

img

enmmmm,就是这么简单,如果你对此结构还存有疑惑的话,那么稍后看看后文的代码手撸RNN部分,或许能解决你的大部分疑惑。

到这里,其实RNN的模型结构就讲完了,是不是很简单呢。🍭🍭🍭那么下面讲什么呢?自然是RNN存在什么问题,这样才能过渡到后面更加牛*的网络嘛。🍄🍄🍄

那么RNN存在什么问题呢?那就是长距离依赖问题,何为长距离依赖呢?他和短距离依赖是相对的概念,我们来举个例子来介绍什么是长距离依赖,什么是短距离依赖:

  • 对于这样一句话:“我爱在足球场上踢__”,我们是不是很容易得到空格里的答案,因为在空格前几个字有足球场,所以我们知道这里要填“足球”。这种能根据上下文附近就判断预测答案的就是短距离依赖。【短距离依赖的图示如下】

    img
  • 对于这样一句话:“我爸爸从小就带我去足球场踢足球,我的爱好就是足球。我和爸爸关系非常好,经常带我一起玩耍,.......,真是一个伟大的父亲。长大后,我的爱好一直没变,现在我就要去踢__”,大家感受到了嘛,这里空格中要填的词我们要往上文找很就才可以发现,这种预测答案需要看上文很远距离找到答案的就是长距离依赖。

    img

也就是说,RNN网络对于长距离依赖的问题效果很不好,因此我们后面会对RNN网络进行改进,进而提高其对长距离依赖的能力。🥝🥝🥝

手撸RNN

想必大家通过上文的讲述,已经对RNN的代码结构有了一定的认识,下面我们就来使用Pytorch来实现一个RNN网络,让大家对其有一个更加清晰的认识。🥂🥂🥂

这部分的思路是这样的,我先给大家调用一下官方封装好的RNN模型,展示模型输入输出的结果;然后再手撸一个RNN函数,来验证其结果是否和官方一致。

好了,我们就先来使用官方定义好的RNN模型来实现,具体可以看这个连接:RNN🍵🍵🍵

import torch 
import torch.nn as nn
bs, T = 2, 3   #批大小,输入序列长度
input_size, hidden_size = 2, 3  # 输入特征大小,隐含层特征大小
input = torch.randn(bs, T, input_size) # 随机初始化一个输入特征序列
h_prev = torch.zeros(bs, hidden_size)  # 初始隐含状态

我们先来打印看一下inputh_prev以及它们的shape,如下:

我们来解释一下这些变量,input就是我们输入的数据,他的维度为(2, 3, 2),三个维度分别表示(bs, T, input_size),即(批大小,输入序列长度,输入特征大小)。我这样介绍大家可能还一头雾水,我结合input的打印结果给大家介绍,首先很明显这是一个维度为(2, 3, 2)的向量,这个大家都知道哈,不知道我就真没办法啦,去补补课吧。🍸🍸🍸那么这个向量的第一个维度是2,就代表我们1个batch有两条数据,每个都是(3, 2)维度的向量,如下:

这个和计算机视觉中的bs(batch_size)是一个意思啦,接下来我们来看每条数据,即这个(3,2)维的向量,以第一条为例:这个3表示输入序列长度,表示每条数据又有三个小部分构成,分别为[-0.0657, -0.9015]、[-0.0324, -0.5666]、[-0.2630, 2.4861]。这是什么意思呢,这表示我们的输入会分三次送入RNN网络中,分别是x0x1x2x_0、x_1、x_2,不知道这样大家能否理解,我画个图大家就知道了,如下:

image-20230915195447555

大家可能发现了,这个维度的3个数据就相当于3个词,分别一步步的送入RNN网络中,那么其实最后一个维度2,也就是输入特征大小也很好理解了,它就表示每个词的维度,就是我们前文所说的词向量,那么我们这里就是每个词向量有两个维度的特征。🍚🍚🍚

通过上文的介绍,我想大家了解input这个输入了,那么h_prev是什么呢,其是隐层的输出,也就是上图中的h0h1h2h_0、h_1、h_2

接着我们就来调用pytorch中RNN的API:

# 调用pytorch RNN API
rnn = nn.RNN(input_size, hidden_size, batch_first=True)
rnn_output, state_final = rnn(input, h_prev.unsqueeze(0))

batch_first=True这个参数是定义我们输入的格式为(bs, T, input_size)的,pytorch文档中都解释的很详细,大家自己去看一下就好。至于这个h_prev.unsqueeze(0)这里加了第一个维度,这是由于RNN API的输入要求是三维的向量,如下:

我们来看看输出的rnn_outputstate_final的值和shape吧,如下:

image-20230915200857983

rnn_output其实就是每个隐藏层的输出,而state_final则是最终的输出,在基础的RNN中,state_final的值就等于最后一个隐藏层的输出,我们从数值上也可以发现,如下:

image-20230915201239237

为了方便大家理解,再画一个图,如下:【注意:图都是以batch中一条数据为例表示的】

image-20230915202347081


那么上文就为大家介绍了如何使用pytorch官方API实现RNN,但是这样我们无法看到RNN内部是如何实现的,那么这样我们就来手动实现一个RNN。其实很简单,主要就是用到了一个公式,如下:

image-20230915203051193

这个公式可以在pytorch官方文档中看到,其实不知道大家发现没有,其实这个公式和卷积神经网络的公式是很像的,只不过RNN这里有两个输入而已。还有一点和大家说一下,上图公式中含有转置,实现起来转置来转置去的会很绕,上面的公式其实和下面是一样的【上下两个xtx_t维度其实变了】:

image-20230915203513846

为了简便起见,我用不带转置的进行代码编写,大家先理解好这个,最后我也会把带转置的代码放出来,这时候理解带转置的可能更容易点。

# 手写一个rnn_forward函数,实现RNN的计算原理
def rnn_forward(input, weight_ih, weight_hh, bias_ih, bias_hh, h_prev):
    bs, T ,input_size = input.shape
    h_dim = weight_ih.shape[0]
    h_out = torch.zeros(bs, T, h_dim)
    
    for t in range(T):
        x = input[:,t,:].unsqueeze(2)    
        w_ih_batch = weight_ih.unsqueeze(0).tile(bs, 1, 1)
        w_hh_batch = weight_hh.unsqueeze(0).tile(bs, 1, 1)
        
        w_times_x = torch.bmm(x.transpose(1, 2), w_ih_batch.transpose(1, 2)).transpose(1, 2).squeeze(-1)
        w_times_h = torch.bmm(h_prev.unsqueeze(2).transpose(1, 2), w_hh_batch.transpose(1, 2)).transpose(1, 2).squeeze(-1)
        h_prev = torch.tanh(w_times_x + bias_ih + w_times_h + bias_hh)
        h_out[:,t,:] = h_prev
        
    return h_out, h_prev.unsqueeze(0)

我们看到代码并不长,所以其实还是很简单的,最主要的是大家注意for t in range(T)这个循环,就是不断的取输入序列中的向量送入RNN网络,比如开始是x0x_0送入、接着是x1x_1送入......依次类推,后面的几行代码都是围绕ht=tanh(Wihxt+bih+Whhh(t1)+bhh)h_{t}=\tanh \left(W_{i h} x_{t}+b_{i h}+W_{h h} h_{(t-1)}+b_{h h}\right)进行编写的,具体的细节大家慢慢调试吧,相信难不住你。因为设计到很多向量运算,所以特别要注意维度的变化。🍗🍗🍗

接下来我们要验证一下我们实现的RNN是否正确,但是我们需要传入WihbihWhhbhhW_{ih}、b_{ih}、W_{hh}、b_{hh}参数,这几个参数怎么得到呢,我们可以在rnn中看到这几个参数的值,我们也只有用这个才能保证我们最后的结果和官方的一致,我们可以来简单看看这几个值,如下:

image-20230915212318490

接着我们就可以将这里面的参数传入到rnn_forward函数中,如下:

custom_rnn_output, custom_state_final = rnn_forward(input, rnn.weight_ih_l0, rnn.weight_hh_l0, rnn.bias_ih_l0,  rnn.bias_hh_l0, h_prev)

同样,我们来打印一下custom_rnn_output和custom_state_final,如下:

image-20230915212531221

经过对比,你可以发现,使用官方API和使用我们自定义的函数实现的RNN的输出是一样,这就验证了我们方法的正确性。

下面给出带转置的,即ht=tanh(xtWihT+bih+ht1WhhT+bhh)h_{t}=\tanh \left(x_{t} W_{i h}^{T}+b_{i h}+h_{t-1} W_{h h}^{T}+b_{h h}\right)这个表达式的代码供大家参考,如下:

# custom 手写一个rnn_forward函数,实现RNN的计算原理
def rnn_forward(input, weight_ih, weight_hh, bias_ih, bias_hh, h_prev):
    bs, T, input_size = input.shape
    h_dim = weight_ih.shape[0]
    h_out = torch.zeros(bs, T, h_dim)

    for t in range(T):
        x = input[:, t, :].unsqueeze(2)
        w_ih_batch = weight_ih.unsqueeze(0).tile(bs, 1, 1)
        w_hh_batch = weight_hh.unsqueeze(0).tile(bs, 1, 1)

        w_times_x = torch.bmm(x.transpose(1, 2), w_ih_batch.transpose(1, 2)).transpose(1, 2).squeeze(-1)
        w_times_h = torch.bmm(h_prev.unsqueeze(2).transpose(1, 2), w_hh_batch.transpose(1, 2)).transpose(1, 2).squeeze(-1)
        h_prev = torch.tanh(w_times_x + bias_ih + w_times_h + bias_hh)
        h_out[:, t, :] = h_prev

    return h_out, h_prev.unsqueeze(0)

 

参考连接

1、The Illustrated Word2vec

2、理解 LSTM 网络

3、Transformer通俗笔记:从Word2Vec、Seq2Seq逐步理解到GPT、BERT

4、Understanding LSTM Networks

5、预训练语言模型的前世今生

6、PyTorch源码教程与前沿人工智能算法复现讲解

   

如若文章对你有所帮助,那就🛴🛴🛴

         一键三连 (1).gif