NLP扎实基础1:Word2vec模型Skip-Gram Pytorch复现

文章目录

  • Word2vec与Skip-Gram的简介
  • 实现Word2vec的朴素想法
  • Skip-Gram算法流程
  • Pytorch复现

Word2vec与Skip-Gram的简介

  1. word to vector是NLP领域殿堂级的思想,这种思想为后面的xxx To Vector提供了非常多的启发。通过已有的训练数据将文本(字或词)转为一个合适的向量,为后续各式各样的任务奠定了一个扎实的基础。

    假如我们可以将语义相同的词使用相似的向量表示,这样我们只需要求两个向量的距离,就可以知道两个词语义的差距了,这为我们的应用提供的便利。

  2. CBOWSkip-Gram是两种实现Word2vec思想的实现方法,这两种实现方式有一个共同点,就是认为:一个句子中挨得越近的词越相似,离的越远的词越不同

    个人理解:这种想法其实很奇怪,比如:我/爱/撸串,这个’我’和’爱’和’撸串’,明显没啥关系啊。但模型这波在大气层。的确’我爱撸串’的词与词之间没什么关联,但是’我爱撸串’,‘我爱烤串’,‘我爱跑步’,这三个句子里’撸串’、‘烤串’、'跑步’就有一定的关联了。当采用CBOWSkip-Gram训练时,这三个词会越来越近。当语料足够大的时候,‘撸串’和’烤串’同时出现在类似的句子中的概率是非常大的,但是’跑步’出现的句子场景就不同了,因此就把’撸串’、'烤串’越训练越接近,而’跑步’就稍稍疏远。

    这两个模型同时在这篇论文中提出:

    Mikolov, Tomas, et al. “Efficient estimation of word representations in vector space.” arXiv preprint arXiv:1301.3781 (2013).

  3. 针对Skip-Gram有很多优化,最常用的负采样就出自论文:

    Mikolov, Tomas, et al. “Distributed representations of words and phrases and their compositionality.” Advances in neural information processing systems 26 (2013).

实现Word2vec的朴素想法

两个目标:

  1. 挨得近的词更相似
  2. 挨得远的词更远

翻译成计算方法就是:

  1. 挨得近的词(词对应的向量)更相似(点积更小)
  2. 挨得远的词更远(点积更大)

这样整个流程就有了:

  1. 随机初始化每个词的向量
  2. 给一个挨得近的阈值,比如window=3,离这个词3个词以内的我们就认为它算近,超过3个词就是挨得远
  3. 按照下面的运算即可:
    NLP扎实基础1:Word2vec模型Skip-Gram Pytorch复现_第1张图片
    但存在问题:运算量太大了;因此我们进行优化

Skip-Gram算法流程

Skip-Gram的流程如下:

  1. 首先使用整数来表示一个词:

    一个例子:“我喜欢撸串和凉啤”,首先构造一个滑动窗口,假设每个窗口为5,那么中间的词作为中心词,窗口里的其他词作为背景词,对应到模型中,用一个整数来表示一个字/词
    NLP扎实基础1:Word2vec模型Skip-Gram Pytorch复现_第2张图片

然后训练模型的流程如下:

NLP扎实基础1:Word2vec模型Skip-Gram Pytorch复现_第3张图片

  1. 首先初始化一个矩阵,有多少个词就有多少行,每个词用多少维来表示,就有多少列

  2. 将词与token对应

  3. 采集正例与负例,窗口内的词为全部的正例,不在窗口内的词统统可以作为负例

    注意:采集负例时等概率随机采样是可以的,优化一步就是按照词频的大小来不同概率采样;再优化一步是计算出词频后,计算词频的0.75次方后归一,作为采样概率

  4. 每个滑动窗口都只计算这一批的正例与负例,依次滑动下去

  5. 最后的loss见代码

    注意:本次代码中最后的loss采用的是直接点积,还有一种复现方式是把它当做一个分类问题来做,即正例分类为1,负例分类为0,用交叉熵作为loss,没问题。这两个优化目标都是一样的,都是下面的公式(详情参考论文)

    J ( θ ) = − 1 T ∑ t = 1 T ∑ − m ≤ j ≤ m log ⁡ p ( w t + j ∣ w t ) J(\theta) = -\frac{1}{T}\sum_{t=1}^T\sum_{-m \leq j \leq m}\log p(w_{t+j}|w_t) J(θ)=T1t=1Tmjmlogp(wt+jwt)

Pytorch复现

以下代码都有注释,其中:

  1. 初始化矩阵,使用nn.Embedding()来做
  2. 词与token的对应,使用torchtext(0.12.0+版本),参考SkipGramDataset类中的reform_vocab()函数
  3. 正例与负例,参考SkipGramDataset类中的generate_skip()函数
  4. 训练过程,参考Word2VecModel的前向传播部分
  5. 最后的loss,在前向传播中使用torch.bmm()计算点积
import numpy as np
from torchtext.vocab import vocab
from collections import Counter, OrderedDict
from torch.utils.data import Dataset, DataLoader
from torchtext.transforms import VocabTransform  # 注意:torchtext版本0.12+
from copy import deepcopy
import torch
from torch import nn
from torch.nn import functional as F


def get_text():
    sentence_list = [  # 假设这是全部的训练语料
        "nlp drives computer programs that translate text from one language to another",
        "nlp combines computational linguistics rule based modeling of human language with statistical",
        "nlp model respond to text or voice data and respond with text",
    ]
    return sentence_list


class SkipGramDataset(Dataset):
    def __init__(self, text_list, side_window=3, side_negative_sample=6):
        """
        构造Word2vec的skip-gram采样Dataset
        :param text_list: 语料
        :param side_window: 单侧正例(构造背景词)采样数,总正例是:2 * side_window
        :param side_negative_sample: 单侧负例(构造噪声词)采样数,总负例是:2 * side_window * side_negative_sample
        """
        super(SkipGramDataset, self).__init__()
        self.side_window = side_window
        self.side_negative_sample = side_negative_sample
        text_vocab, vocab_transform, word_freq = self.reform_vocab(text_list)
        self.text_list = text_list  # 原始文本
        self.text_vocab = text_vocab  # torchtext的vocab
        self.vocab_transform = vocab_transform  # torchtext的vocab_transform
        self.word_freq = np.array(word_freq)
        pos_skip = self.generate_skip()
        self.pos_skip: np.ndarray = np.array(pos_skip)

    def __len__(self):
        return int(len(self.pos_skip) / (2 * self.side_window))

    def __getitem__(self, idx):
        center_id = self.pos_skip[idx * 2 * self.side_window, 0]
        pos_token = self.pos_skip[idx * 2 * self.side_window:(idx + 1) * 2 * self.side_window][:, 1]  # 正例
        # 开始采集负例
        # 以非相邻的词的频数作为权重
        # 注意:原始论文中将每个词的频率变为原来的0.75次方,然后归一化,作为采样概率,这里简化一些,博主就没有这步操作了
        neg_weight = deepcopy(self.word_freq)

        neg_weight[pos_token] = 0
        neg_token = np.random.choice(np.arange(0, len(self.text_vocab)),
                                     self.side_negative_sample * self.side_window * 2,
                                     p=neg_weight / neg_weight.sum())
        # neg_token 即为噪声词,负例
        return center_id, pos_token, neg_token

    def reform_vocab(self, text_list):
        """根据语料构造torchtext的vocab"""
        total_word_list = []
        for _ in text_list:  # 将嵌套的列表([[xx,xx],[xx,xx]...])拉平 ([xx,xx,xx...])
            total_word_list += _.split(" ")
        counter = Counter(total_word_list)  # 统计计数
        sorted_by_freq_tuples = sorted(counter.items(), key=lambda x: x[1], reverse=True)  # 构造成可接受的格式:[(单词,num), ...]
        ordered_dict = OrderedDict(sorted_by_freq_tuples)
        # 开始构造 vocab
        special_token = ["", ""]  # 特殊字符
        text_vocab = vocab(ordered_dict, specials=special_token)  # 单词转token,specials里是特殊字符,可以为空
        text_vocab.set_default_index(0)
        vocab_transform = VocabTransform(text_vocab)
        word_freq = [0] * len(special_token) + [i[1] for i in sorted_by_freq_tuples]  # 频数
        return text_vocab, vocab_transform, word_freq

    def generate_skip(self):
        """采集所有的正例"""
        pos_skip = []
        for sentence in self.text_list:
            sentence_id_list = self.vocab_transform(sentence.split(' '))
            for center_index in range(
                    self.side_window, len(sentence_id_list) - self.side_window):  # 防止前面或后面取不到足够的值,这是取index的上下界
                # 采集正例
                pos_index = list(range(center_index - self.side_window, center_index + self.side_window + 1))  # 正例的计数
                del pos_index[self.side_window]
                for __pos_i in pos_index:
                    pos_skip.append([sentence_id_list[center_index], sentence_id_list[__pos_i]])
        return pos_skip

    def get_vocab_transform(self):
        return self.vocab_transform


class Word2VecModel(nn.Module):
    def __init__(self, vocab_size=100, hidden=128):
        """
        Word2vec模型
        :param vocab_size: 每个词的词向量维度
        :param hidden: 隐层维度
        """
        super(Word2VecModel, self).__init__()
        self.vocab_size = vocab_size
        self.hidden = hidden
        self.center_embedding = nn.Embedding(self.vocab_size, self.hidden)  # 作为中心词时对应的embedding
        self.back_embedding = nn.Embedding(self.vocab_size, self.hidden)  # 作为背景词时对应的embedding

    def forward(self, input_labels, pos_labels, neg_labels):
        center_embed = self.center_embedding(input_labels)  # 作为中心词时的embedding [batch, hidden]
        # 正例背景词的embedding [batch, 2 * side_window, hidden]
        pos_embedding = self.back_embedding(pos_labels)
        # 噪声词的embedding [batch, 2 * side_window * side_negative_sample, hidden]
        neg_embedding = self.back_embedding(neg_labels)

        center_embed = center_embed.unsqueeze(-1)  # 在最后面添加一个维度1
        # 由于是正例,因此期望:两个词越接近越好,即点积越小越好
        pos_dot = torch.bmm(pos_embedding, center_embed)  # 结果维度:[batch, 2 * side_window, 1]
        # 期望噪声词与中心词越不相似(点积越大)越好;这里center_embed加个负号,将结果也变为越小越好
        neg_dot = torch.bmm(neg_embedding, -center_embed)  # 结果维度:[batch, 2 * side_window * side_negative_sample, 1]

        # 由于之前的维度是 [xx, xx , 1],因此把最后一个维度的1去掉:
        pos_dot = pos_dot.squeeze(-1)  # 修改后维度 [batch, 2 * side_window]
        neg_dot = neg_dot.squeeze(-1)

        # 这里对求出的点积使用LogSigmoid变换,解决梯度消失的问题,同时对每个batch的结果求和
        pos_loss = F.logsigmoid(pos_dot).sum(1)
        neg_loss = F.logsigmoid(neg_dot).sum(1)

        loss = neg_loss + pos_loss
        return -loss

    def get_embedding(self, token_list: list):
        # 论文中建议使用 center_embedding 作为最终的词向量,当然 back_embedding 其实也是一样的含义
        return self.center_embedding(torch.Tensor(token_list).long())


def main():
    sentence_list = get_text()
    skip_gram_data_set = SkipGramDataset(sentence_list)  # 构造 DataSet
    data_loader = DataLoader(skip_gram_data_set, batch_size=1, drop_last=True)  # 将DataSet封装成DataLoader
    # 开始训练
    model = Word2VecModel()
    optimizer = torch.optim.Adam(model.parameters())

    for _epoch_i in range(10):
        loss_list = []
        for center_token, pos_token, neg_token in data_loader:
            # 开始训练
            optimizer.zero_grad()
            loss = model(center_token, pos_token, neg_token).mean()
            loss.backward()
            optimizer.step()
            loss_list.append(loss.item())
        print("训练中:", _epoch_i, "Loss:", np.sum(loss_list))

    # 最后测试一下
    # 得到: nlp can translate text from one language to another 的词向量
    sentence = "nlp can translate text from one language to another"
    vocab_transform = skip_gram_data_set.get_vocab_transform()
    sentence_ids = vocab_transform(sentence.split(' '))
    sentence_embedding = model.get_embedding(sentence_ids)
    print("这个是句向量的维度:", sentence_embedding.shape)


if __name__ == '__main__':
    main()

你可能感兴趣的:(自然语言处理,自然语言处理,pytorch,word2vec)