word2vec在PyTorch中的实现

1. 文本表示:从词袋模型到word2vec

  文本表示的意思是把字词数字化表示成向量或矩阵,以便计算机能进行处理。文本表示是自然语言处理的起始环节。

  文本表示按照细粒度划分,一般可分为字级别、词语级别和句子级别的文本表示。以汉字为例,字级别的话就是以每个汉字为一个基本单元。更为常见的是词语级别,所以要先对句子进行分词。

  文本表示分为离散表示和分布式表示。离散表示的代表就是词袋模型,one-hot(也叫独热编码)、n-gram、TF-IDF都可以看作是词袋模型。分布式表示也叫做词嵌入(word embedding),经典模型是word2vec,还包括后来的Glove、ELMO、GPT和BERT。

1.1 词袋模型

  假如现在有若干篇文档,把这些文档中的每个句子进行分词,然后进行去重,把每个词看作是词典的词语,对文本表示的模型,叫做词袋模型。这种模型的特点是字典中的词没有特定的顺序,句子的总体结构也被舍弃了。下面分别介绍词袋模型中的one-hot、TF-IDF和n-gram文本表示方法。

1.1.1 one-hot

  one-hot,就是用一个很长的向量来表示一个词,向量的长度为词典的大小N,向量的分量只有一个1,其他全为0,1的位置对应该词在词典中的索引。 假设一段文本有1000个词,如果用一个矩阵来表示这个文本,那么这个矩阵的维度为1000*1000。  但是这种表示方法存在一些缺点:

  1. 当语料长度过长的时候(容易引发维数灾难)。举个例子,如果一个文本有10万个词,那么用稀疏向量来表示这个文本矩阵的话,需要用一个10万×10万的矩阵来表示它。
  2. 无法体现出近义词之间的关系。例如‘’方便面‘’,‘’面条‘’,在中文中两个词是有一定关系的,但是如果用稀疏向量就无法表示出他们之间的关系,如果采用余弦相似度计算的话,它们之间的相似度为0。
  3. 没有考虑句中词的顺序性,假定词之间相互独立。这意味着意思不同的句子可能得到一样的向量。

1.1.2 n-gram

  词袋模型的两种表示方法假设词与词之间是相互独立的,没有考虑它们之间的顺序。于是引入n-gram(n元语法)的概念。n-gram是从一个句子中提取n个连续的词的集合,可以获取到词语的前后信息。一般2-gram或者3-gram比较常见。

  比如“我/热爱/有趣/的/算法”,“算法/是/很/有趣/的”这两个句子,分解为2-gram词汇表:

  {我热爱,热爱有趣,有趣的,的算法,算法是,是很,很有趣,有趣的}

  这种表示方法的好处是可以获取更丰富的特征,提取字的前后信息,考虑了字之间的顺序性。但是问题也是显而易见的,这种方法没有解决数据稀疏和词表维度过高的问题,而且随着n的增大,词表维度会变得更高。

1.2 word2vec

 1、如果是用一个词语(中心词)作为输入,来预测它周围的上下文(背景词),那这个模型叫做Skip-gram 模型。
 2、而如果是拿一个词语的上下文(背景词)作为输入,来预测这个词语(中心词)本身,则是 CBOW 模型。

1.2.1 Skip-gram

word2vec在PyTorch中的实现_第1张图片
  首先说明一点:隐层的激活函数其实是线性的,相当于没做任何处理(这也是 Word2vec 简化之前语言模型的独到之处),我们要训练这个神经网络,用反向传播算法即可求出。
  当模型训练完后,最后得到的其实是神经网络的权重,比如现在输入一个 x 的 one-hot encoder: [1,0,0,…,0],对应刚说的那个词语『吴彦祖』,则在输入层到隐含层的权重里,只有对应 1 这个位置的权重被激活,这些权重的个数,跟隐含层节点数是一致的,从而这些权重组成一个向量 vx 来表示x,而因为每个词语的 one-hot encoder 里面 1 的位置是不同的,所以,这个向量 vx 就可以用来唯一表示 x。
  此外,我们刚说了,输出 y 也是用 V 个节点表示的,对应V个词语,所以其实,我们把输出节点置成 [1,0,0,…,0],它也能表示『吴彦祖』这个单词,但是激活的是隐含层到输出层的权重,这些权重的个数,跟隐含层一样,也可以组成一个向量 vy,跟上面提到的 vx 维度一样,并且可以看做是词语『吴彦祖』的另一种词向量。而这两种词向量 vx 和 vy,正是 Mikolov 在论文里所提到的输入向量和输出向量,一般我们用输入向量。
  需要提到一点的是,这个词向量的维度(与隐含层节点数一致)一般情况下要远远小于词语总数 V 的大小,所以 Word2vec 本质上是一种降维操作——把词语从 one-hot encoder 形式的表示降维到 Word2vec 形式的表示。

2. word2vec在Pytorch中的简单实现

  文本文件下载地址为 https://download.csdn.net/download/herosunly/11087806 。

2.1 导包

torch相关包加载

import torch 
import torch.nn as nn  #神经网络工具箱torch.nn 
import torch.nn.functional as F  #神经网络函数torch.nn.functional
import torch.utils.data as tud  #Pytorch读取训练集需要用到torch.utils.data类
import torch.nn.parameter as Parameter #参数更新和优化函数

词频统计和相似度、数据分析、数学包加载

from collections import Counter #统计词频
import sklearn
from sklearn.metrics.pairwise import cosine_similarity #余弦相似度函数

import pandas as pd
import numpy as np
import scipy              #数据分析三件套

import random
import math               #数学和随机离不开

2.2 设置随机化种子和超参数

随机化种子

USE_CUDA = torch.cuda.is_available() #GPU可用的标志位
random.seed(1)
np.random.seed(1)
torch.manual_seed(1)  

if USE_CUDA:
    torch.cuda.manual_seed(1)

超参数

# 词向量相关的超参数
K = 100 # number of negative samples 负样本随机采样数量,文本量越大该参数设置越小
C = 3 # nearby words threshold 指定周围三个单词进行预测,类似于3gram,根据和博士同事交流,一遍使用奇数,如阿里云安全比赛中,CNN的Filter size使用的是3、5 、7 、9。
EMBEDDING_SIZE = 100 #词向量维度
MAX_VOCAB_SIZE = 30000 # the vocabulary size 词汇表多大

# 参数优化的超参数
NUM_EPOCHS = 2 # The number of epochs of training 所有数据的训练大轮次数,每一大轮会对所有的数据进行训练
BATCH_SIZE = 128 # the batch size 每一轮中的每一小轮训练128个样本
LEARNING_RATE = 0.2 # the initial learning rate #学习率

LOG_FILE = "word-embedding.log"

2.3 读取文本数据并处理

得到单词词典(key为单词,value为频次),从而得到四个变量(idx_to_word、word_to_idx、word_counts、word_freq)。

with open("/content/gdrive/My Drive/Colab Notebooks/text8.train.txt", "r") as f: #读入文件
    text = f.read() #得到文本内容
    text = text.lower().split() #分割成词列表
    vocab_dict = dict(Counter(text).most_common(MAX_VOCAB_SIZE - 1)) #两步得到单词字典表,key是单词,value是次数
    vocab_dict[''] = len(text) - sum(list(vocab_dict.values())) #把不常用的单词都编码为""
    idx_to_word = list(vocab_dict.keys())
    word_to_idx = {word:ind for ind, word in enumerate(idx_to_word)}
    word_counts = np.array(list(vocab_dict.values()),dtype = np.float32)
    word_freqs = word_counts / sum(word_counts)
    
    VOCAB_SIZE = len(idx_to_word)  #获得词典的实际长度,有可能不足3W

实现Dataloader

  有了Dataloader之后,我们可以轻松随机打乱整个数据集,拿到batch的数据等等。一个Dataloader需要以下内容:

  1. 把所有text编码成数字。
  2. 保存vocabulary,单词count,normalized word frequency。
  3. 每个iteration sample一个中心词。
  4. 根据当前的中心词返回context单词。
  5. 根据中心词sample一些negative单词。
  6. 返回单词的counts。

  这里有一个好的tutorial(https://pytorch.org/tutorials/beginner/data_loading_tutorial.html)介绍如何使用PyTorch dataloader。为了使用dataloader,我们需要定义以下两个function:

__len__ 需要返回整个数据集中有多少个item。
__get__ 根据给定的index返回一个item。
class WordEmbeddingDataset(tud.Dataset):
    def __init__(self, text, idx_to_word, word_to_idx, word_counts, word_freqs):
        ''' text: a list of words, all text from the training dataset
            word_to_idx: the dictionary from word to idx
            idx_to_word: idx to word mapping
            word_freq: the frequency of each word
            word_counts: the word counts
            感觉并不需要存idx_to_word,相关信息已经包含在word_to_idx中了
        '''
        super().__init__() #通过父类初始化模型,然后重写两个方法
        self.text_encoded = [word_to_idx.get(word, word_to_idx[""]) for word in text] #通过两步操作,把词数字化表示。如果不在词典中,也表示为unk
        self.text_encoder = torch.Tensor(self.text_encoded).long() #转变为LongTensor,为什么要这样转换,是为了增大存储单词量
        
        self.word_to_idx = word_to_idx #保存数据
        self.idx_to_word = idx_to_word  #保存数据
        self.word_freqs = torch.Tensor(word_freqs) #保存数据
        self.word_counts = torch.Tensor(word_counts) #保存数据
        
    def __len__(self):
        #魔法函数__len__
        
        return len(self.text_encoded) #所有单词的总数
        
    def __getitem__(self, idx):
        #魔法函数__getitem__,这个函数跟普通函数不一样
        ''' 这个function返回以下数据用于训练
            - 中心词
            - 这个单词附近的(positive)单词
            - 随机采样的K个单词作为negative sample
        '''
        
        center_word = self.text_encoded[idx] #取得中心词
        pos_indices = list(range(idx - C, idx)) + list(range(idx + 1, idx + C + 1)) #注意左闭右开
        pos_indices = [i%len(self.text_encoded) for i in pos_indices] #不知是否有更好的方式
        
        pos_words = self.text_encoded[pos_indices]
        #周围词索引,就是希望出现的正例单词
        #print(pos_words)
        
        neg_words = torch.multinomial(self.word_freqs, K * pos_words.shape[0], True)#该步是基于paper中内容
        #负例采样单词索引,torch.multinomial作用是对self.word_freqs做K * pos_words.shape[0]次取值,输出的是self.word_freqs对应的下标。
        #取样方式采用有放回的采样,并且self.word_freqs数值越大,取样概率越大。
        #每个正确的单词采样K个,pos_words.shape[0]是正确单词数量
        
        return center_word, pos_words, neg_words 

  注:multinomial返回的是索引,但它为什么是words呢? 这里的原因是word_freqs本质上就是词表(dict.values())。而center_word和pos_words本质上是通过索引取出单词。

2.5 得到Dataloder

dataset = WordEmbeddingDataset(text, word_to_idx, idx_to_word, word_freqs, word_counts)
dataloader = tud.DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=4)  

2.6 定义Pytorch模型

在Pytorch神经网络模型中,需要定义__init__()和forward两个函数。

class EmbeddingModel(nn.Module):
    def __init__(self, vocab_size, embed_size):
        ''' 初始化输入和输出embedding
        '''
        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)
        
        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: 中心词周围 context window 出现过的单词 [batch_size * (window_size * 2)]
        neg_labelss: 中心词周围没有出现过的单词,从 negative sampling 得到 [batch_size, (window_size * 2 * K)]
        
        return: loss, [batch_size]
        '''
        
        batch_size = input_labels.size(0)
        
        input_embedding = self.in_embed(input_labels) # B * embed_size
        pos_embedding = self.out_embed(pos_labels) # B * (2*C) * embed_size
        neg_embedding = self.out_embed(neg_labels) # B * (2*C * K) * embed_size
        
        log_pos = torch.bmm(pos_embedding, input_embedding.unsqueeze(2)).squeeze(2) # B * (2*C)
        # unsqueeze(2): input_embedding->B * embed_size * 1
        # bmm是batch matrix multiply
        log_neg = torch.bmm(neg_embedding, -input_embedding.unsqueeze(2)).squeeze(2) # B * (2*C*K)

        log_pos = F.logsigmoid(log_pos).sum(1) #.sum()结果只为一个数,.sum(1)结果是一维的张量
        log_neg = F.logsigmoid(log_neg).sum(1) # batch_size
       
        loss = log_pos + log_neg
        
        return -loss
    
    def input_embeddings(self):
        return self.in_embed.weight.data.cpu().numpy()

2.7 加载模型

model = EmbeddingModel(VOCAB_SIZE, EMBEDDING_SIZE)
if USE_CUDA:
    model = model.cuda()

2.8 模型评估

def evaluate(filename, embedding_weights): 
    if filename.endswith(".csv"):
        data = pd.read_csv(filename, sep=",")
    else:
        data = pd.read_csv(filename, sep="\t")
    human_similarity = []
    model_similarity = []
    for i in data.iloc[:, 0:2].index:
        word1, word2 = data.iloc[i, 0], data.iloc[i, 1]
        if word1 not in word_to_idx or word2 not in word_to_idx:
            continue
        else:
            word1_idx, word2_idx = word_to_idx[word1], word_to_idx[word2]
            word1_embed, word2_embed = embedding_weights[[word1_idx]], embedding_weights[[word2_idx]]
            model_similarity.append(float(sklearn.metrics.pairwise.cosine_similarity(word1_embed, word2_embed)))
            human_similarity.append(float(data.iloc[i, 2]))

    return scipy.stats.spearmanr(human_similarity, model_similarity)# , model_similarity

def find_nearest(word):
    index = word_to_idx[word]
    embedding = embedding_weights[index]
    cos_dis = np.array([scipy.spatial.distance.cosine(e, embedding) for e in embedding_weights])
    return [idx_to_word[i] for i in cos_dis.argsort()[:10]]

2.9 模型训练

  • 模型一般需要训练若干个epoch
  • 每个epoch我们都把所有的数据分成若干个batch
  • 把每个batch的输入和输出都包装成cuda tensor
  • forward pass,通过输入的句子预测每个单词的下一个单词
  • 用模型的预测和正确的下一个单词计算cross entropy loss
  • 清空模型当前gradient
  • backward pass
  • 更新模型参数
  • 每隔一定的iteration输出模型在当前iteration的loss,以及在验证数据集上做模型的评估
optimizer = torch.optim.SGD(model.parameters(), lr=LEARNING_RATE)
for e in range(NUM_EPOCHS):
    for i, (input_labels, pos_labels, neg_labels) in enumerate(dataloader):
        
        
        # TODO
        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()
            
        optimizer.zero_grad()
        loss = model(input_labels, pos_labels, neg_labels).mean()
        loss.backward()
        optimizer.step()

        if i % 100 == 0:
            with open(LOG_FILE, "a") as fout:
                fout.write("epoch: {}, iter: {}, loss: {}\n".format(e, i, loss.item()))
                print("epoch: {}, iter: {}, loss: {}".format(e, i, loss.item()))
            
        
        if i % 2000 == 0:
            embedding_weights = model.input_embeddings()
            sim_simlex = evaluate("/content/gdrive/My Drive/Colab Notebooks/simlex-999.txt", embedding_weights)
            sim_men = evaluate("/content/gdrive/My Drive/Colab Notebooks/men.txt", embedding_weights)
            sim_353 = evaluate("/content/gdrive/My Drive/Colab Notebooks/wordsim353.csv", embedding_weights)
            with open(LOG_FILE, "a") as fout:
                print("epoch: {}, iteration: {}, simlex-999: {}, men: {}, sim353: {}, nearest to monster: {}\n".format(
                    e, i, sim_simlex, sim_men, sim_353, find_nearest("monster")))
                fout.write("epoch: {}, iteration: {}, simlex-999: {}, men: {}, sim353: {}, nearest to monster: {}\n".format(
                    e, i, sim_simlex, sim_men, sim_353, find_nearest("monster")))
                
    embedding_weights = model.input_embeddings()
    np.save("embedding-{}".format(EMBEDDING_SIZE), embedding_weights)
    torch.save(model.state_dict(), "embedding-{}.th".format(EMBEDDING_SIZE))

2.10 最终模型效果检验

在 MEN 和 Simplex-999 数据集上做评估

embedding_weights = model.input_embeddings()
print("simlex-999", evaluate("simlex-999.txt", embedding_weights))
print("men", evaluate("men.txt", embedding_weights))
print("wordsim353", evaluate("wordsim353.csv", embedding_weights))

寻找nearest neighbors

for word in ["good", "fresh", "monster", "green", "like", "america", "chicago", "work", "computer", "language"]:
    print(word, find_nearest(word))

单词之间的关系

man_idx = word_to_idx["man"] 
king_idx = word_to_idx["king"] 
woman_idx = word_to_idx["woman"]
embedding = embedding_weights[woman_idx] - embedding_weights[man_idx] + embedding_weights[king_idx]
cos_dis = np.array([scipy.spatial.distance.cosine(e, embedding) for e in embedding_weights])
for i in cos_dis.argsort()[:20]:
    print(idx_to_word[i])

  word2vec是个非常重要的内容,但由于最近事情有点多,所以没有认真研究理论。后续一定好好研究,并输出博客。

你可能感兴趣的:(word2vec在PyTorch中的实现)