WordEmbedding
词向量发展
在自然语言处理问题中,会涉及到一个重要部分:词向量;
词向量:我希望将单词word转为一个vector,因为机器不能直接感知到人类所说的语言,只有将单词转换为数值对象才能进行后续处理;
在早期NLP领域,单词的编码使用one-hot,这样的编码只是为了区分各个词,完全没有语义信息;
后来出现了TF-IDF,为每个word增加权重信息,常见的word权重大,罕见的word权重小,这样处理后,稍微看到了一点语义信息;
单词的编码还需要包含相似性,比如美洲豹和剑齿虎的编码在欧氏空间应该有较近的距离:
伴随着这样的思考,诞生了分布式表示(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可以理解,其本质应该是中心词与周围词的关系更密切,这种关系被形象地可视化为:
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(uo∣vc)=∑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}))
min−T1t=1∑T−c⩽j⩽c,j=0∑log(p(wt+j∣wt))
对
p
(
w
t
+
j
∣
w
t
)
p(w_{t+j}|w_{t})
p(wt+j∣wt)取对数可以将连乘转为累加,避免反向传播计算梯度时出现梯度消失现象;
在上述计算中,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}))
min−log(p(uoTvc))+k=1∑Klog(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=1∑Klog(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()
在实际训练中,通常不会过度关注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,通过重新生成一个对象,并导入参数可验证: