第四课.词向量

词向量发展

在自然语言处理问题中,会涉及到一个重要部分:词向量;
词向量:我希望将单词word转为一个vector,因为机器不能直接感知到人类所说的语言,只有将单词转换为数值对象才能进行后续处理;
在早期NLP领域,单词的编码使用one-hot,这样的编码只是为了区分各个词,完全没有语义信息;
后来出现了TF-IDF,为每个word增加权重信息,常见的word权重大,罕见的word权重小,这样处理后,稍微看到了一点语义信息;
单词的编码还需要包含相似性,比如美洲豹和剑齿虎的编码在欧氏空间应该有较近的距离:
fig1
伴随着这样的思考,诞生了分布式表示(Distributed Representation):用一个词附近的词表示该词,这个想法是NLP的重大转折点,比如之后出现了word2vec(CBOW和Skip-Gram),CBOW可以理解为用周围的词预测中间的词,Skip-Gram可以理解为用中间的词预测周围的词,在paper中,也指出了Skip-Gram的效果更好

Skip-Gram

首先需要明白,词向量本身只是一个矩阵,对于同样的词汇表和同样的目标任务,不管是CBOW还是Skip-Gram,它们训练出来的词向量都是一个同样形状的二维张量,唯一不同在于其训练的方式;


关于词向量如何将单词编码
需要让输入的单词都是one-hot编码的形式,假设词汇表的单词数量为3000,则表中某个词bird将编码为[0,0,0,1,0,...,0],该向量中只有一个1,其余都是0,共3000位元素,bird的one-hot编码形状为[1,3000];
如果要将编码压缩为100个元素的向量,词向量形状则为[3000,100],当bird的one-hot编码与词向量相乘,就得到bird的向量[1,100],这个向量携带了一定的语义信息,准确来说,[1,100]这个向量才是词向量;
之前的二维张量叫做词向量也是有原因的,编码bird的过程等价于从张量索取了bird对应的行,所以这个二维张量相当于是词汇表中所有单词的编码集合


前面说过,Skip-Gram可以理解为用中间的词预测周围的词,根据Distributed Representation可以理解,其本质应该是中心词与周围词的关系更密切,这种关系被形象地可视化为:
fig2
projection并不是一个线性变换,而是指投影,即已编码的中心词w(t)与已编码的周围词w(t+i)投影相乘,结果越大,代表关系越紧密;
另外一提,Skip-Gram本身(对中心词和周围词编码,再投影)并没有意义,但通过Skip-Gram这种方式来训练,可以得到一个好的词向量;
Skip-Gram具体实现过程
词向量的训练实际上属于无监督学习,Skip-Gram需要两个同样shape的二维张量,一个用于编码中心词,另一个用于编码所有在词汇表内的词;
不能共用二维张量的原因:所谓中心词与周围词关系密切不能在同一编码方式下投影,比如"I like NLP",同一种编码下,很难让机器看出like与I和NLP相似;
令中心词编码后的向量为 v c v_{c} vc,某个周围词编码后的向量为 u o u_{o} uo,词汇表内各个词编码后的向量 u w u_{w} uw,编码用到两个张量embed1和embed2,注意, v c v_{c} vc通过张量embed1编码, u o u_{o} uo u w u_{w} uw都是通过embed2编码,词汇表共 W W W个单词;
则基于softmax可以求出相似度的概率表达:
p ( u o ∣ v c ) = e x p ( u o T v c ) ∑ w = 1 W e x p ( u w T v c ) p(u_{o}|v_{c})=\frac{exp(u_{o}^{T}v_{c})}{\sum_{w=1}^{W}exp(u_{w}^{T}v_{c})} p(uovc)=w=1Wexp(uwTvc)exp(uoTvc)
可以看出,中心词与周围词越相似,则输出概率值越大,Skip-Gram要用中心词与在窗口内的所有周围词计算相似度,假设窗口内前后各有 c c c个词,随机从语料库选择 T T T个中心词,则训练目标为:
m i n − 1 T ∑ t = 1 T ∑ − c ⩽ j ⩽ c , j ≠ 0 l o g ( p ( w t + j ∣ w t ) ) min-\frac{1}{T}\sum_{t=1}^{T}\sum_{-c\leqslant j\leqslant c,j\neq 0}^{}log(p(w_{t+j}|w_{t})) minT1t=1Tcjc,j=0log(p(wt+jwt))
p ( w t + j ∣ w t ) p(w_{t+j}|w_{t}) p(wt+jwt)取对数可以将连乘转为累加,避免反向传播计算梯度时出现梯度消失现象;
在上述计算中,softmax分母计算量很大, v c v_{c} vc要与每个word的编码求点积,为了加速计算,改进出简化版本,核心做法在于随机从词汇表中选出负例样本,计算规模得到缩减,softmax也改成sigmoid函数:
p ( u o T v c ) = 1 1 + e x p ( − u o T v c ) p(u_{o}^{T}v_{c})=\frac{1}{1+exp(-u_{o}^{T}v_{c})} p(uoTvc)=1+exp(uoTvc)1
假设从词汇表随机采样 K K K个单词的编码 u k u_{k} uk作为负样本,则训练目标改写为:
m i n − l o g ( p ( u o T v c ) ) + ∑ k = 1 K l o g ( p ( u k T v c ) ) min-log(p(u_{o}^{T}v_{c}))+\sum_{k=1}^{K}log(p(u_{k}^{T}v_{c})) minlog(p(uoTvc))+k=1Klog(p(ukTvc))
上式希望中心词与周围词越紧密越好,与随机采样的负样本关系越疏远越好;
注意一个细节,sigmoid函数将值映射到0-1之间,但log(0->1)的值是小于零的,所以 ∑ k = 1 K l o g ( p ( u k T v c ) ) \sum_{k=1}^{K}log(p(u_{k}^{T}v_{c})) k=1Klog(p(ukTvc))会是负值且绝对值较大, − l o g ( p ( u o T v c ) ) -log(p(u_{o}^{T}v_{c})) log(p(uoTvc))虽是正值,但比较小,最终的目标值将会小于零,这对人类视角看待最小化问题有一些别扭,所以,根据sigmoid的单调性,目标改写为:
m i n − [ l o g ( p ( u o T v c ) ) + ∑ k = 1 K l o g ( p ( − u k T v c ) ) ] min-[log(p(u_{o}^{T}v_{c}))+\sum_{k=1}^{K}log(p(-u_{k}^{T}v_{c}))] min[log(p(uoTvc))+k=1Klog(p(ukTvc))]
这样一来,目标值将必然大于零,所以最小化的结果越接近零越好;接下来,我将根据这个负采样的做法实现Skip-Gram训练,通过反向传播的梯度更新张量embed1和embed2,最后取出embed1即词向量

Pytorch实现Skip-Gram

随机种子和超参数设置

为了使实验可以复现,设置随机种子,并固定超参数:

import torch
import torch.nn as nn
import torch.nn.functional as F

from collections import Counter#统计单词出现次数
import numpy as np

#为了确保实验可以复现,设置随机种子
import random

#都设置一遍随机种子
random.seed(53113)
np.random.seed(53113)
torch.manual_seed(53113)

USE_CUDA=torch.cuda.is_available()

if USE_CUDA:
    #GPU也设置随机种子
    torch.cuda.manual_seed(53113)
    
#设置超参数hyper parameters
C=3 #context window
K=100 #负例采样数量
NUM_EPOCHS=1
MAX_VOCAB_SIZE=30000 #英语中,有3万个常见单词
BATCH_SIZE=128
LEARNING_RATE=0.2
EMBEDDING_SIZE=100

语料处理

首先获取文本组成的语料数据,确保已删除所有标点符号,单词用空格分开,然后可以用split()分词得到字符串列表:

#用于对语料数据进行分词
def word_tokenize(text):
    #S.split(sep=None, maxsplit=-1) -> list of strings
    #split的seq默认为所有空字符
    return text.split()

下一步创建单词表vocab{word:counts},vocab设置容量3万个单词MAX_VOCAB_SIZE=30000(英语常用单词有3万个),在这3万个单词中,有一个比较特殊:用于代表语料库中所有不常见单词,记为"<unk>";
一般训练一个良好的词向量需要超大的数据,我的小数据集效果应该不会太好,注意:这个数据集已经去除过所有标点符号;
分词获取列表:

with open("./DataSet/textset/texttrain.txt","r") as f:
    text=f.read()

#对语料进行分词
text=word_tokenize(text.lower())

借助from collections import Counter统计单词出现次数:

#统计词数,len(Counter(text).keys()) 远大于 MAX_VOCAB_SIZE
vocab=dict(Counter(text).most_common(MAX_VOCAB_SIZE-1))

#最后一个统一为UNK
vocab["<unk>"]=len(text)-np.sum(list(vocab.values()))

构建mapping,为词建立序号,以便于one-hot编码:

#构建mapping,为词建立序号,以便于one-hot编码
idx_to_word=[word for word in vocab.keys()]
word_to_idx={word:i for i,word in enumerate(idx_to_word)}

获取词频,用于后续训练的采样:

#计算词频率
word_counts=np.array([count for count in vocab.values()],dtype=np.float32)
word_freqs=word_counts/np.sum(word_counts)

#word2vec论文中增加了一个细节
word_freqs=word_freqs**(3./4.)
word_freqs=word_freqs/np.sum(word_freqs)

注意:word_to_idx,idx_to_word,word_freqs,word_counts的顺序都是一致对应的

使用dataloader

dataloader是pytorch中的数据加载器,用于高效生成训练需要的batch data,在使用dataloader前,先定义dataset,一般流程为:实现dataset对象,用dataloader封装dataset产生batch

实现Dataset对象

dataset继承自torch.utils.data.Dataset,对于NLP的简单任务,至少需要在dataset类中完成三个魔法方法__init__(),__len__(),__getitem__();
在本次实现中,dataset应该做到将text列表内的单词进行编码(基于word_to_idx),__getitem__()能够根据输入的index从经过编码的text返回一个中心词和 2 c 2c 2c个周围词, 2 c K 2cK 2cK个负例词:

import torch.utils.data as tud

class WordEmbeddingDataset(tud.Dataset):
    # word_to_idx,idx_to_word,word_freqs,word_counts的顺序都是一致对应的
    def __init__(self,text,word_to_idx,idx_to_word,word_freqs,word_counts):
        super().__init__()
        #获得text的每个单词在word_to_idx中的序号,D.get(k[,d]) -> D[k] if k in D, else d.
        self.text_encoded=[word_to_idx.get(word,word_to_idx["<unk>"]) for word in text]      
        self.text_encoded=torch.tensor(self.text_encoded,dtype=torch.long)
        
        self.word_to_idx=word_to_idx
        self.idx_to_word=idx_to_word
        self.word_freqs=torch.tensor(word_freqs,dtype=torch.float)
    
    def __len__(self):
        #数据集的item数量
        #数据集就是text的每个词通过word_to_idx进行了编码
        return len(self.text_encoded)
    
    
    def __getitem__(self,location):
        #给一个词在text_encoded中的位置,返回一串训练数据(前后的词)
        center_word=self.text_encoded[location]
        #周围词在text_encoded中的位置,range左闭右开
        pos_indices=list(range(location-C,location))+list(range(location+1,location+C+1))
        #目前的pos_indices可能会超出text_encoded的边界,所以通过取余避免
        pos_indices=[i%len(self.text_encoded) for i in pos_indices]
        #类似于numpy的快速索引
        pos_words=self.text_encoded[pos_indices]
        
        #根据词出现的频率随机采样,借助torch.multinomial
        """
        torch.multinomial(input, num_samples, replacement=False,)->LongTensor
        replacement指的是取样时是否是有放回的取样
        返回的是input的索引组成的tensor
        """
        #由于word_freqs顺序与word_to_idx顺序一致,相当于采样返回的也是idx
        neg_words=torch.multinomial(self.word_freqs,K*pos_words.shape[0],replacement=True)
        
        return center_word,pos_words,neg_words


dataset=WordEmbeddingDataset(text,word_to_idx,idx_to_word,word_freqs,word_counts)

dataloader封装

使用dataloader包装dataset,读取占用更少内存,高效产生batch:

dataloader=tud.DataLoader(dataset,batch_size=BATCH_SIZE,shuffle=True,num_workers=4)

定义模型

首先明确:Embedding本质就是一个(vocab_size,embed_size)的tensor;
按照前面的说明,此次Embedding层需要两个二维tensor,在前面所说的embed1命名为embed_in,embed2命名为embed_out,像第二课所说的,__init__()中定义要计算梯度的层,前向传播过程定义在forward内;
本次实现新增一个实例方法get_weight,用于获取词向量embed_in;


torch.nn.Embedding
在nn中已经有Embedding层,输入并不需要真的转换到one-hot向量,而是像CrossEntropyLoss那样,使用了索引的方式计算,这也是为什么dataset类里的self.text_encoded元素类型为torch.long的原因;
对于nn.Embedding(vocab_size,embed_size)
Embedding的输入可以是一个在0到vocab_size-1之间的整数,输出是一个包含embed_size个元素的向量


模型定义如下:

class EmbeddingLayer(nn.Module):
    def __init__(self,vocab_size,embed_size):
        super().__init__()
        self.vocab_size=vocab_size
        self.embed_size=embed_size
        
        self.in_embed=nn.Embedding(self.vocab_size,self.embed_size,sparse=False)
        self.out_embed=nn.Embedding(self.vocab_size,self.embed_size,sparse=False)
        
        #对embedding层的权重进行初始化,使训练一开始的loss较小
        initrange=0.5/self.embed_size
        self.in_embed.weight.data.uniform_(-initrange,initrange)
        self.out_embed.weight.data.uniform_(-initrange,initrange)
        
        
    def forward(self,input_labels,pos_labels,neg_labels):
        # input_labels: [batch_size]
        # pos_labels: [batch_size,C*2]
        # neg_labels: [batch_size,C*2*K]
        
        #nn.Embedding的输入是一组索引列表
        input_embedding=self.in_embed(input_labels) #[batch_size, embed_size]
        pos_embedding=self.out_embed(pos_labels)   #[batch_size, C*2, embed_size]
        neg_embedding=self.out_embed(neg_labels)   #[batch_size, C*2*K, embed_size]
        
        #为了bmm,增加一个维度
        input_embedding=input_embedding.unsqueeze(2) #[batch_size, embed_size, 1]
        #批处理张量乘法bmm:batch matrix multiplication(b,m,n)*(b,n,p)->(b,m,p)
        pos_dot=torch.bmm(pos_embedding,input_embedding) #[batch_size, C*2, 1]
        #去除最后一个维度
        pos_dot=pos_dot.squeeze(2) #[batch_size,C*2]
        
        neg_dot=torch.bmm(neg_embedding,input_embedding).squeeze(2) #[batch_size,C*2*K]
        
        #顺便计算目标函数,借助logsigmoid,即log(sigmoid(value))
        log_pos=F.logsigmoid(pos_dot).sum(dim=1)
        log_neg=F.logsigmoid(-neg_dot).sum(dim=1)
        
        loss=-log_pos-log_neg
        
        return loss #[batch_size]
    
    def get_weight(self):
        #获取词向量层的权重,用于np保存数据
        return self.in_embed.weight.data.cpu().numpy()

生成对象:

# 模型实例化
model=EmbeddingLayer(MAX_VOCAB_SIZE,EMBEDDING_SIZE)
if USE_CUDA:
    model=model.cuda()

训练

基于dataloader,类似于keras中的生成器产生batch,可以方便的产生batch data,比如:

for i,(input_labels,pos_labels,neg_labels) in enumerate(dataloader):

选择优化方法并训练:

optimizer=torch.optim.SGD(model.parameters(),lr=LEARNING_RATE)

loss_embedding=[]
for epoch in range(NUM_EPOCHS):
    for i,(input_labels,pos_labels,neg_labels) in enumerate(dataloader):
        #print(input_labels,pos_labels,neg_labels)
        input_labels=input_labels.long()
        pos_labels=pos_labels.long()
        neg_labels=neg_labels.long()
        
        if USE_CUDA:
            input_labels=input_labels.cuda()
            pos_labels=pos_labels.cuda()
            neg_labels=neg_labels.cuda()
        
        loss=model.forward(input_labels,pos_labels,neg_labels).mean()
        if i%100==0:
            print(epoch,i,loss.item())
            loss_embedding.append(loss.item())
        
        loss.backward()
        
        optimizer.step()
        
        model.zero_grad()

定期将loss加入列表后,可视化训练过程为:

#绘制学习曲线
import matplotlib.pyplot as plt
%matplotlib inline

plt.figure(figsize=(10,6))
plt.subplot(1,1,1)
#后面的loss基本变化微小,取0:300绘图,效果更好
nploss=np.array(loss_embedding[:300])
series=np.arange(len(nploss))
plt.plot(series,nploss)
plt.xlabel("series")
plt.ylabel("loss")
plt.title("learning cruve")
plt.show()

fig3
在实际训练中,通常不会过度关注loss,而是看spearmanr是否在上升


spearmanr:用模型计算人工词语的相似度,再与人工给出的相似度对比


保存模型参数

在定义模型时,可以通过方法get_weight()取出张量embed_in,我将用numpy的方式保存参数:

#np保存模型的参数
embedding_weights = model.get_weight()
np.save("embedding-{}".format(EMBEDDING_SIZE),embedding_weights)

也可以用torch保存参数,在保存时,需要明确保存的对象为model.state_dict()model.state_dict()是模型所有可学习的参数名与张量组成的字典:

torch.save(model.state_dict(), "embedding-{}.th".format(EMBEDDING_SIZE))

这样将会保存embed_in和embed_out,通过重新生成一个对象,并导入参数可验证:
fig4

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值