前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >用PyTorch从零开始实现Word2Ve详细教程(附Python代码演练)

用PyTorch从零开始实现Word2Ve详细教程(附Python代码演练)

作者头像
磐创AI
发布2023-08-29 08:23:48
8910
发布2023-08-29 08:23:48
举报

引言

自然语言处理(NLP)中的一个重要组成部分是将单词、短语或更大的文本体转化为连续的数值向量。有许多实现此任务的技术,但在本文中,我们将着重介绍一种在2013年发表的技术,称为Word2Vec。

Word2Vec是由Mikolov等人在一篇名为“Efficient Estimation of Word Representations in Vector Space”的论文中发表的算法。这篇论文值得一读,虽然在本文中,我们将从头开始在PyTorch中构建它。

简而言之,Word2Vec使用一个单隐藏层的人工神经网络来学习稠密的词向量嵌入。这些词嵌入使我们能够识别具有相似语义含义的单词。此外,词嵌入还使我们能够应用代数运算。

例如,“向量('King')-向量('Man')+向量('Woman')的结果是最接近词Queen的向量表示”(“Efficient Estimation of Word Representations in Vector Space”2)。

图1是一个三维词嵌入示例。词嵌入可以学习单词之间的语义关系。“男性-女性”示例说明了“man”和“woman”之间的关系与“king”和“queen”之间的关系非常相似。语法关系可以通过嵌入来编码,如“动词时态”示例所示。


词嵌入概述

在我们深入了解模型概述和PyTorch代码之前,让我们先解释一下词嵌入的概念。

为什么我们需要词嵌入?

计算机只是抽象的计算器。它们非常擅长进行数学计算。当我们想要向计算机表达我们的思想时,我们必须使用数字语言。如果我们想要确定一篇Yelp评论的情感或一本流行书的主题,我们首先需要将文本转化为向量。只有在这之后,我们才能使用后续的过程从文本中提取所关心的信息。

最简单的词嵌入

词嵌入是我们将思想转化为计算机可以理解的语言的方式。让我们通过一个例子来进行说明,该例子摘自维基百科关于Python的文章,其中提到“python consistently ranks as one of the most popular programming languages.” 这个句子包含11个单词,那么为什么我们不创建一个长度为11的向量,其中每个索引的值为1表示单词存在,值为0表示单词不存在呢?这通常被称为one-hot编码。

代码语言:javascript
复制
python       = [1,0,0,0,0,0,0,0,0,0,0]consistently = [0,1,0,0,0,0,0,0,0,0,0]ranks        = [0,0,1,0,0,0,0,0,0,0,0]as           = [0,0,0,1,0,0,0,0,0,0,0]one          = [0,0,0,0,1,0,0,0,0,0,0]of           = [0,0,0,0,0,1,0,0,0,0,0]the          = [0,0,0,0,0,0,1,0,0,0,0]most         = [0,0,0,0,0,0,0,1,0,0,0]popular      = [0,0,0,0,0,0,0,0,1,0,0]programming  = [0,0,0,0,0,0,0,0,0,1,0]languages    = [0,0,0,0,0,0,0,0,0,0,1]

将单词转化为向量的这种方法可以说是最简单的方法。然而,它存在一些缺点,这些缺点为word2vec嵌入提供了动力。

•首先,嵌入向量的长度与词汇量的大小呈线性增长关系。一旦我们需要嵌入数百万个单词,这种嵌入方法在空间复杂性方面会变得有问题。•其次,这些向量是稀疏的。每个向量只有一个值为1的条目,其余条目的值为0。•再次,这是一种显著的内存浪费。•最后,每个单词向量与其他单词向量正交。

因此,无法确定哪些单词最相似。我认为单词“python”和“programming”应该被认为比“python”和“ranks”更相似。不幸的是,这些单词的向量表示与其他每个向量都完全不同。

改进的词嵌入

我们的目标现在更加精确:我们能否创建固定长度的嵌入向量,使我们能够确定哪些单词彼此最相似?一个例子可能是:

代码语言:javascript
复制
python       = [0.5,0.8,-0.1]ranks        = [-0.5,0.1,0.8]programming  = [0.9,0.4,0.1]

如果我们计算“python”和“ranks”的点积,我们会得到:

如果我们计算“python”和“programming”的点积,我们会得到:

由于“python”和“ranks”之间的得分低于“python”和“programming”之间的得分,我们可以说“python”和“programming”更相似。通常情况下,我们不会使用两个嵌入向量之间的点积来计算相似性得分。相反,我们将使用余弦相似度,因为它消除了向量范数的影响并返回一个更标准化的得分。

不管怎样,我们通过一位有效的编码方法解决了面临的两个问题——我们的嵌入向量是固定长度的,并且它们允许我们计算单词之间的相似性。


Skip-gram Word2Vec架构

现在我们对词嵌入有了一定的了解,问题就变成了如何学习这些嵌入。这就是Mikolov的word2vec模型发挥作用的地方。如果你对人工神经网络不熟悉,下面的部分将不太清楚,因为word2vec基本上是基于这种类型的模型的。

我强烈推荐你查看Michael Nielsen的免费在线深度学习和神经网络课程以及3Blue1Brown的YouTube系列关于神经网络的视频。

http://neuralnetworksanddeeplearning.com/

Skip-gram

回想一下之前的句子,“python consistently ranks as one of the most popular programming languages.”想象一下有人不知道“programming”这个词,并且想弄清楚它的意思。

一个合理的方法是查看周围的词来了解这个未知词的含义。他们会注意到它被“popular”和“language”包围。这些词可以给他们关于“programming”的可能含义的暗示。这正是skip-gram模型的工作原理。

最终,我们将训练一个神经网络,给定一个输入词,预测其周围上下文词。在图2中,绿色单词是未知的目标词,蓝色单词是其周围上下文词,我们的神经网络将被训练来预测这些上下文词。

在这个例子中,窗口大小为2。这意味着每个目标词周围都会有2个上下文词需要模型进行预测。由于单词"rank"左边有2个词,右边也有2个词,所以针对这个目标词的训练数据将有4个示例。

模型架构

用于学习这些词嵌入的神经网络是一个单隐藏层前馈网络。网络的输入是目标词,标签是上下文词。隐藏层的维度将是我们选择来嵌入单词的维度。在这个例子中,我们将使用300的嵌入大小。

让我们通过一个例子来说明这个模型是如何工作的。如果我们想要嵌入一个词,第一步是找到它在词汇表中的索引。然后将该索引作为行索引传递给网络作为嵌入矩阵中的行索引。

在图3中,输入词是词汇向量中的第二个条目。这意味着我们现在将进入第二行的绿色嵌入矩阵。这行的长度是300,即嵌入维度N。然后我们将这个向量(即隐藏层)与一个形状为N x V的第二个嵌入矩阵进行矩阵乘法,得到一个长度为V的向量。

请注意,第二个嵌入矩阵(紫色矩阵)中有V列。每一列代表词汇表中的一个单词。另一种概念化这个矩阵乘法的方法是认识到它表示隐藏层向量(目标词的隐藏层)与词汇表中的每个单词(紫色矩阵的列)之间的点积。结果是一个长度为V的向量,表示上下文词的预测。由于我们的上下文窗口大小为2,我们将有4个长度为V的预测向量。然后我们将这些预测向量与相应的真实向量进行比较,计算损失,通过网络进行反向传播来更新模型参数。在这种情况下,模型参数是嵌入矩阵的元素。这个训练过程的机制将在稍后的PyTorch代码中详细介绍。

负采样

在Mikolov等人的论文《Distributed Representations of Words and Phrases and their Compositionality》中,作者对原始的word2vec模型提出了两个改进方法——负采样和子采样。

在图3中,请注意每个预测向量的长度为V。将与每个预测向量进行比较的真实向量也将具有长度V,但真实向量将非常稀疏,因为向量中只有一个元素被标记为1——即模型正在训练预测的真实上下文词。这个真实的上下文词将被称为“正上下文词”。词汇表中的其他单词(即V-1个单词)将被标记为0,因为它们不是训练示例中的上下文词。所有这些单词将被称为“负上下文词”。

Mikolov等人提出了一种称为负采样的方法,它减小了真实向量和预测向量的大小。这减少了网络的计算要求,并加快了训练速度。Mikolov提出了一种方法,从现有的V-1个负上下文词中使用条件概率分布抽样少量负上下文词。

在提供的代码中,我实现了一种与Mikolov提出的方法略有不同的负采样过程。这种方法更简单,但仍会产生高质量的嵌入。在Mikolov的论文中,选择负样本的概率基于在目标词的上下文中看到候选词的条件概率。因此,对于词汇表中的每个词,我们将为词汇表中的每个其他词生成一个概率分布。这些分布表示在目标词的上下文中看到另一个词的条件概率。然后,负上下文词将以与上下文词的条件概率成反比的概率进行抽样。

我以稍微不同的方式实现了负采样,避免了条件分布。首先,我找到了词汇表中每个词的频率。我通过找到总体频率来忽略条件概率。然后,使用与单词频率成比例的词汇索引填充了一个任意大的负采样向量。例如,如果词语“is”在语料库中占0.01%,并且我们决定负采样向量的大小应为1,000,000,那么负采样向量的100个元素(0.01%x1,000,000)将被填充为词语“is”的词汇索引。然后,对于每个训练示例,我们从负采样向量中随机抽样少量元素。如果这个小的数目是20,而词汇表有10,001个单词,我们刚刚减少了预测和真实向量的长度,减少了9,980个元素。这样的减少大大加快了模型的训练时间。

子采样

子采样是Mikolov等人提出的另一种方法,用于减少训练时间并改善模型性能。子采样的基本观察结果是,高频词“提供的信息价值比罕见的词要少”(“Distributed Representations of Words and Phrases and their Compositionality” 4)。例如,像“is”、“the”或“in”这样的词经常出现。这些词很可能与许多其他词共同出现。这意味着围绕这些高频词的上下文词对高频词本身的上下文信息贡献很少。因此,我们不使用语料库中的每个词对,而是按照词对中词的频率成反比的概率进行采样。具体的实现细节将在下一节中解释。


PyTorch实现

有一些框架已经将word2vec的实现细节抽象化了。这些选项非常强大,并提供了用户的可扩展性。例如,gensim提供了一个word2vec API,包括使用预训练模型和多词n-grams等其他功能。但是,在本教程中,我们将创建一个不使用任何这些框架的word2vec模型。

本教程中的所有代码都可以在我的GitHub上找到。请注意,存储库中的代码可能会随着我的工作而更改。为了本教程的目的,在此处将呈现一个简化版本的代码,它将在Google Colab笔记本中展示。

获取数据

我们将使用PyTorch提供的名为WikiText103的维基百科数据集来训练我们的word2vec模型。在下面的代码中,你将看到我如何导入并打印数据集的前几行。第一个文本来自维基百科关于《战场女武神3》的文章。

代码语言:javascript
复制
%%capture!pip install torch torchtext torchdata
代码语言:javascript
复制
from torchtext.data import to_map_style_datasetfrom torchtext.datasets import WikiText103
代码语言:javascript
复制
def get_data():    # gets the data    train_iter = WikiText103(split='train')    train_iter = to_map_style_dataset(train_iter)    valid_iter = WikiText103(split='test')    valid_iter = to_map_style_dataset(valid_iter)
    return train_iter, valid_iter
train_iter, valid_iter = get_data()count = 0for text in iter(train_iter):    print(text)    count += 1    if count == 6:        break


 = Valkyria Chronicles III = 


 Senjō no Valkyria 3 : <unk> Chronicles ( Japanese : 戦場のヴァルキュリア3 , lit . Valkyria of the Battlefield 3 ) , commonly referred to as Valkyria Chronicles III outside Japan , is a tactical role @-@ playing video game developed by Sega and Media.Vision for the PlayStation Portable . Released in January 2011 in Japan , it is the third game in the Valkyria series . Employing the same fusion of tactical and real @-@ time gameplay as its predecessors , the story runs parallel to the first game and follows the " Nameless " , a penal military unit serving the nation of Gallia during the Second Europan War who perform secret black operations and are pitted against the Imperial unit " <unk> Raven " . 
 The game began development in 2010 , carrying over a large portion of the work done on Valkyria Chronicles II . While it retained the standard features of the series , it also underwent multiple adjustments , such as making the game more forgiving for series newcomers . Character designer <unk> Honjou and composer Hitoshi Sakimoto both returned from previous entries , along with Valkyria Chronicles II director Takeshi Ozawa . A large team of writers handled the script . The game 's opening theme was sung by May 'n . 
 It met with positive sales in Japan , and was praised by both Japanese and western critics . After release , it received downloadable content , along with an expanded edition in November of that year . It was also adapted into manga and an original video animation series . Due to low sales of Valkyria Chronicles II , Valkyria Chronicles III was not localized , but a fan translation compatible with the game 's expanded edition was released in 2014 . Media.Vision would return to the franchise with the development of Valkyria : Azure Revolution for the PlayStation 4 .
设置参数和配置

现在,我们将设置参数和配置值,这些值将在接下来的代码中使用。此代码片段中的某些参数与笔记本中稍后的代码相关,但请你耐心等待。

代码语言:javascript
复制
from dataclasses import dataclass, fieldimport torchimport torch.nn as nn
代码语言:javascript
复制
@dataclass(repr=True)class Word2VecParams:
    # skipgram parameters    MIN_FREQ = 50     SKIPGRAM_N_WORDS = 8    T = 85    NEG_SAMPLES = 50    NS_ARRAY_LEN = 5_000_000    SPECIALS = ""    TOKENIZER = 'basic_english'
    # network parameters    BATCH_SIZE = 100    EMBED_DIM = 300    EMBED_MAX_NORM = None    N_EPOCHS = 5    DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")     CRITERION = nn.BCEWithLogitsLoss()
代码语言:javascript
复制
params = Word2VecParams()

在这里,我们构建了一个包含定义word2vec模型的参数的数据类。第一部分控制文本预处理和skipgram构建。我们只考虑至少出现50次的单词。这由MIN_FREQ参数控制。SKIPGRAM_N_WORDS是我们用于构建skipgram的窗口大小。这意味着我们将查看目标单词之前和之后的8个单词。T控制着我们如何计算子采样概率。这意味着频率处于第85百分位数的单词将有很小的概率被子采样,就像我们在上面的子采样部分所描述的那样。NEG_SAMPLES是每个训练示例中要使用的负样本数,正如上面的负采样部分所述。NS_ARRAY_LEN是我们将从中对负观测进行采样的负采样向量的长度。SPECIALS是用于占位的字符串,如果单词不满足最小频率要求,则从词汇表中排除。TOKENIZER指的是我们希望将文本语料库转换为标记的方式。"basic_english" tokenizer将所有文本按空格拆分。

第二部分定义了模型配置和超参数。BATCH_SIZE是每个小批量中将用于训练网络的文档数量。EMBED_DIM是我们将为词汇表中的每个单词使用的嵌入的维度。EMBED_MAX_NORM是每个嵌入向量可以达到的最大范数。N_EPOCHS是我们将训练模型的轮数。DEVICE告诉PyTorch是使用CPU还是GPU来训练模型。CRITERION是使用的损失函数。在讨论模型训练过程时将继续讨论损失函数的选择。

构建词汇表

准备文本数据以供word2vec模型使用的下一步是构建词汇表。我们将构建一个名为Vocab的类,并且它将具有允许我们查找单词索引和频率的方法。我们还可以通过索引查找单词,以及获取整个文本语料库中单词的总数。

代码语言:javascript
复制
class Vocab:    def __init__(self, list, specials):        self.stoi = {v[0]:(k, v[1]) for k, v in enumerate(list)}        self.itos = {k:(v[0], v[1]) for k, v in enumerate(list)}        self._specials = specials[0]        self.total_tokens = np.nansum(            [f for _, (_, f) in self.stoi.items()]            , dtype=int)
    def __len__(self):        return len(self.stoi) - 1
    def get_index(self, word: Union[str, List]):        if isinstance(word, str):            if word in self.stoi:                 return self.stoi.get(word)[0]            else:                return self.stoi.get(self._specials)[0]        elif isinstance(word, list):            res = []            for w in word:                if w in self.stoi:                     res.append(self.stoi.get(w)[0])                else:                    res.append(self.stoi.get(self._specials)[0])            return res        else:            raise ValueError(                f"Word {word} is not a string or a list of strings."                )

    def get_freq(self, word: Union[str, List]):        if isinstance(word, str):            if word in self.stoi:                 return self.stoi.get(word)[1]            else:                return self.stoi.get(self._specials)[1]        elif isinstance(word, list):            res = []            for w in word:                if w in self.stoi:                    res.append(self.stoi.get(w)[1])                else:                    res.append(self.stoi.get(self._specials)[1])            return res        else:            raise ValueError(                f"Word {word} is not a string or a list of strings."                )

    def lookup_token(self, token: Union[int, List]):        if isinstance(token, (int, np.int64)):            if token in self.itos:                return self.itos.get(token)[0]            else:                raise ValueError(f"Token {token} not in vocabulary")        elif isinstance(token, list):            res = []            for t in token:                if t in self.itos:                    res.append(self.itos.get(token)[0])                else:                    raise ValueError(f"Token {t} is not a valid index.")            return res
代码语言:javascript
复制
def yield_tokens(iterator, tokenizer):    r = re.compile('[a-z1-9]')    for text in iterator:        res = tokenizer(text)        res = list(filter(r.match, res))        yield res
def build_vocab(        iterator,        tokenizer,         params: Word2VecParams,        max_tokens: Optional[int] = None,    ):    counter = Counter()    for tokens in yield_tokens(iterator, tokenizer):        counter.update(tokens)
    # First sort by descending frequency, then lexicographically    sorted_by_freq_tuples = sorted(        counter.items(), key=lambda x: (-x[1], x[0])        )
    ordered_dict = OrderedDict(sorted_by_freq_tuples)
    tokens = []    for token, freq in ordered_dict.items():        if freq >= params.MIN_FREQ:            tokens.append((token, freq))
    specials = (params.SPECIALS, np.nan)    tokens[0] = specials
    return Vocab(tokens, specials)

无需查看此代码中的每一行,请注意该类Vocab具有stoi, itos,total_tokens属性以及get_index(), get_freq(),lookup_token()方法。以下将展示这些属性和方法的作用。

按名称在词汇表中查找单词:

代码语言:javascript
复制
print(vocab.stoi.get('python'))
代码语言:javascript
复制
(13898, 403)

按索引在词汇表中查找单词:

代码语言:javascript
复制
print(vocab.itos.get(13898))
代码语言:javascript
复制
('python', 403)

按名称在词汇表中查找单词索引值:

代码语言:javascript
复制
print(vocab.get_index('python'))
代码语言:javascript
复制
13898

按名字查找词汇中的词频:

代码语言:javascript
复制
print(vocab.get_freq('python'))
代码语言:javascript
复制
403

查找词汇表索引值中的单词:

代码语言:javascript
复制
print(vocab.lookup_token(13898))
代码语言:javascript
复制
python

查找语料库中令牌总数

代码语言:javascript
复制
print(vocab.total_tokens)
代码语言:javascript
复制
77514579

stoi 是一个字典,其中键是单词,值是键单词的索引和频率的元组。例如,单词“python”是第13898个最常见的单词,出现了403次。这个单词在stoi字典中的条目将是{"python": (13898, 403)}。itos类似于stoi,但其键是索引值,因此“python”的条目将是{13898: ("python", 403)}。total_tokens 属性是整个语料库中标记的总数。在我们的示例中,有77,514,579个单词。

get_index() 方法接受一个单词或一个单词列表作为输入,并返回这些单词的索引或索引列表。如果我们调用 Vocab.get_index("python"),返回的值是 13898。get_freq() 方法接受一个单词或一个单词列表作为输入,并返回单词的频率作为整数或整数列表。如果我们调用 Vocab.get_freq("python"),返回的值是 403。最后,lookup_token() 方法接受一个整数并返回占据该索引的单词。例如,如果我们调用 Vocab.lookup_token(13898),该方法将返回 "python"。

上面的代码片段中的最后两个函数是 yield_tokens() 和 build_vocab() 函数。yield_tokens() 函数对文本进行预处理和分词。预处理简单地删除所有非字母或数字的字符。build_vocab() 函数接受原始的维基百科文本,对其进行分词,然后构建一个 Vocab 对象。再次强调,我不会逐行解释此函数中的每一行代码。关键是该函数构建了一个 Vocab 对象。

构建 PyTorch Dataloader

下一步是构建带有子采样的skip-gram模型,并为我们的 PyTorch 模型创建数据加载器。关于为什么数据加载器对于在大量数据上训练的 PyTorch 模型如此关键,请查阅文档:https://pytorch.org/tutorials/beginner/basics/data_tutorial.html

代码语言:javascript
复制
class SkipGrams:    def __init__(self, vocab: Vocab, params: Word2VecParams, tokenizer):        self.vocab = vocab        self.params = params        self.t = self._t()        self.tokenizer = tokenizer        self.discard_probs = self._create_discard_dict()
    def _t(self):        freq_list = []        for _, (_, freq) in list(self.vocab.stoi.items())[1:]:            freq_list.append(freq/self.vocab.total_tokens)        return np.percentile(freq_list, self.params.T)

    def _create_discard_dict(self):        discard_dict = {}        for _, (word, freq) in self.vocab.stoi.items():            dicard_prob = 1-np.sqrt(                self.t / (freq/self.vocab.total_tokens + self.t))            discard_dict[word] = dicard_prob        return discard_dict

    def collate_skipgram(self, batch):        batch_input, batch_output  = [], []        for text in batch:            text_tokens = self.vocab.get_index(self.tokenizer(text))
            if len(text_tokens) < self.params.SKIPGRAM_N_WORDS * 2 + 1:                continue
            for idx in range(len(text_tokens) - self.params.SKIPGRAM_N_WORDS*2                ):                token_id_sequence = text_tokens[                    idx : (idx + self.params.SKIPGRAM_N_WORDS * 2 + 1)                    ]                input_ = token_id_sequence.pop(self.params.SKIPGRAM_N_WORDS)                outputs = token_id_sequence
                prb = random.random()                del_pair = self.discard_probs.get(input_)                if input_==0 or del_pair >= prb:                    continue                else:                    for output in outputs:                        prb = random.random()                        del_pair = self.discard_probs.get(output)                        if output==0 or del_pair >= prb:                            continue                        else:                            batch_input.append(input_)                            batch_output.append(output)
        batch_input = torch.tensor(batch_input, dtype=torch.long)        batch_output = torch.tensor(batch_output, dtype=torch.long)
        return batch_input, batch_output

这个类可能是我们迄今为止处理的最复杂的类,所以让我们仔细逐个方法地进行分析,首先从最后一个方法collate_skipgram()开始。我们首先初始化两个列表batch_input和batch_output。这些列表将被填充为词汇索引。最终,batch_input列表将包含每个目标词的索引,而batch_output列表将包含每个目标词的正向上下文词索引。第一步是遍历批处理中的每个文本,并将所有标记转换为相应的词汇索引:

代码语言:javascript
复制
for text in batch:    text_tokens = self.vocab.get_index(self.tokenizer(text))

接下来的步骤是检查文本是否足够长以生成训练示例。回想一下之前的句子:“python consistently ranks as one of the most popular programming languages.”,它有11个单词。如果我们将SKIPGRAM_N_WORDS设置为8,则长度为11个单词的文档是不足够的,因为我们无法在文档中找到一个单词,在它之前有8个上下文单词以及在它之后有8个上下文单词。

代码语言:javascript
复制
if len(text_tokens) < self.params.SKIPGRAM_N_WORDS * 2 + 1:    continue

然后,我们创建一个目标词列表和一个围绕目标词的所有上下文词列表,确保我们始终有一个完整的上下文词集:

代码语言:javascript
复制
for idx in range(len(text_tokens) - self.params.SKIPGRAM_N_WORDS*2):    token_id_sequence = text_tokens[        idx : (idx + self.params.SKIPGRAM_N_WORDS * 2 + 1)        ]    input_ = token_id_sequence.pop(self.params.SKIPGRAM_N_WORDS)    outputs = token_id_sequence

现在,我们实现子采样。我们查找丢弃目标词的概率,给定它的频率,然后以该概率删除它。我们稍后将看到如何计算这些丢弃概率。

代码语言:javascript
复制
prb = random.random() del_pair = self.discard_probs.get(input_) if input_==0 or del_pair >= prb:     continue

然后,如果上一步没有删除目标词本身,我们对围绕目标词的上下文词执行相同的子采样过程。最后,我们分别将得到的数据附加到batch_input和batch_output列表中。

代码语言:javascript
复制
else:    for output in outputs:        prb = random.random()        del_pair = self.discard_probs.get(output)        if output==0 or del_pair >= prb:            continue        else:            batch_input.append(input_)            batch_output.append(output)

我们如何计算每个词的丢弃概率?回想一下,我们对一个词进行子采样的概率与它在语料库中的频率成反比。换句话说,词的频率越高,我们越有可能从训练数据中丢弃它。我使用的计算丢弃概率的公式如下:

这与Mikolov等人提出的公式略有不同,但达到了类似的目标。小差异在于分母中的+t部分。如果从分母中排除+t,那么频率大于t的词将被有效地从数据中删除,因为平方根中的值将大于1。这个公式在_create_discard_dict()方法中实现,该方法创建一个Python字典,其中键是词索引,值是丢弃该词的概率。

接下来的问题是t从哪里来?回想一下我们的Word2VecParams有参数T。在我们的代码中,该参数设置为85。这意味着我们找到第85个百分位词频,然后将t设置为该值。这有效地使得随机抽样具有85百分位数或以上频率的词的概率接近但略高于0%。这个计算是SkipGram类中的_t()方法所实现的。

创建负采样数组

在定义PyTorch模型之前的最后一步是创建负采样数组。高层目标是创建一个长度为5,000,000的数组,并使用词汇表中的词频比例填充它。

代码语言:javascript
复制
class NegativeSampler:    def __init__(self, vocab: Vocab, ns_exponent: float, ns_array_len: int):        self.vocab = vocab        self.ns_exponent = ns_exponent        self.ns_array_len = ns_array_len        self.ns_array = self._create_negative_sampling()
    def __len__(self):        return len(self.ns_array)
    def _create_negative_sampling(self):
        frequency_dict = {word:freq**(self.ns_exponent) \                          for _,(word, freq) in                           list(self.vocab.stoi.items())[1:]}        frequency_dict_scaled = {            word:             max(1,int((freq/self.vocab.total_tokens)*self.ns_array_len))             for word, freq in frequency_dict.items()            }        ns_array = []        for word, freq in tqdm(frequency_dict_scaled.items()):            ns_array = ns_array + [word]*freq        return ns_array
    def sample(self,n_batches: int=1, n_samples: int=1):        samples = []        for _ in range(n_batches):            samples.append(random.sample(self.ns_array, n_samples))        samples = torch.as_tensor(np.array(samples))        return samples

_create_negative_sampling()方法按照上面指定的方式创建数组。唯一的小差异是,如果一个词的频率意味着它在负采样向量中应该有少于1个条目,我们确保该词索引仍然出现在负采样数组的一个元素中,以免在抽样负上下文词时完全丢失该词。

sample()方法返回一个列表的列表,外部列表中的列表数量等于批处理中的示例数量,内部列表中的样本数量是每个示例的负采样数量,我们在Word2VecParams数据类中将其设置为50。

定义PyTorch模型

最后,我们开始在PyTorch中构建word2vec模型。构建PyTorch神经网络的惯用方法是在构造函数中定义各种网络架构层,并在名为forward()的方法中定义数据通过网络的前向传递。

幸运的是,PyTorch已经将更新模型参数的反向传递抽象化,因此我们不需要手动计算梯度。

代码语言:javascript
复制
class Model(nn.Module):    def __init__(self, vocab: Vocab, params: Word2VecParams):        super().__init__()        self.vocab = vocab        self.t_embeddings = nn.Embedding(            self.vocab.__len__()+1,             params.EMBED_DIM,             max_norm=params.EMBED_MAX_NORM            )        self.c_embeddings = nn.Embedding(            self.vocab.__len__()+1,             params.EMBED_DIM,             max_norm=params.EMBED_MAX_NORM            )
    def forward(self, inputs, context):        target_embeddings = self.t_embeddings(inputs)        n_examples = target_embeddings.shape[0]        n_dimensions = target_embeddings.shape[1]        target_embeddings = target_embeddings.view(n_examples, 1, n_dimensions)
        context_embeddings = self.c_embeddings(context)        context_embeddings = context_embeddings.permute(0,2,1)
        dots = target_embeddings.bmm(context_embeddings)        dots = dots.view(dots.shape[0], dots.shape[2])        return dots 
    def normalize_embeddings(self):        embeddings = list(self.t_embeddings.parameters())[0]        embeddings = embeddings.cpu().detach().numpy()         norms = (embeddings ** 2).sum(axis=1) ** (1 / 2)        norms = norms.reshape(norms.shape[0], 1)        return embeddings / norms
    def get_similar_words(self, word, n):        word_id = self.vocab.get_index(word)        if word_id == 0:            print("Out of vocabulary word")            return
        embedding_norms = self.normalize_embeddings()        word_vec = embedding_norms[word_id]        word_vec = np.reshape(word_vec, (word_vec.shape[0], 1))        dists = np.matmul(embedding_norms, word_vec).flatten()        topN_ids = np.argsort(-dists)[1 : n + 1]
        topN_dict = {}        for sim_word_id in topN_ids:            sim_word = self.vocab.lookup_token(sim_word_id)            topN_dict[sim_word] = dists[sim_word_id]        return topN_dict
    def get_similarity(self, word1, word2):        idx1 = self.vocab.get_index(word1)        idx2 = self.vocab.get_index(word2)        if idx1 == 0 or idx2 == 0:            print("One or both words are out of vocabulary")            return
        embedding_norms = self.normalize_embeddings()        word1_vec, word2_vec = embedding_norms[idx1], embedding_norms[idx2]
        return cosine(word1_vec, word2_vec)

PyTorch模型总是继承自torch.nn.Module类。我们将利用PyTorch的嵌入层(embedding layer),它创建了一个词向量的查找表。在我们的word2vec模型中,我们定义了两个层:self.t_embeddings是我们感兴趣学习的目标词嵌入,self.c_embeddings是图2中的次要(紫色)嵌入矩阵。这两个嵌入矩阵都是随机初始化的。

如果我们想要训练网络中的每个参数,我们可以在这一点上放弃负采样,这样前向方法会更简单一些。但是,负采样已被证明可以提高模型准确性并减少训练时间,因此值得实现。让我们深入研究前向传递。

代码语言:javascript
复制
def forward(self, inputs, context):    target_embeddings = self.t_embeddings(inputs)    n_examples = target_embeddings.shape[0]    n_dimensions = target_embeddings.shape[1]    target_embeddings = target_embeddings.view(n_examples, 1, n_dimensions)
    context_embeddings = self.c_embeddings(context)    context_embeddings = context_embeddings.permute(0,2,1)
    dots = target_embeddings.bmm(context_embeddings)    dots = dots.view(dots.shape[0], dots.shape[2])    return dots

假设我们有一个训练示例作为一个批次传递。在这个例子中,inputs只包含与索引1相关联的单词。前向传递的第一步是在self.t_embeddings表中查找该单词的嵌入。然后我们使用.view()方法对其进行重塑,以便我们在网络中通过的输入具有单独的向量。在实际实现中,批次大小为100。.view()方法为批次中的每个训练示例中的每个单词创建一个(1 x N)矩阵。

图4将帮助读者理解forward()方法的这前四行代码的作用。

然后,对于每个输入,我们需要获取上下文词的嵌入。对于这个例子,假设实际的上下文词与self.c_embeddings表的第8个索引相关联,而负上下文词与self.c_embeddings表的第6个索引相关联。在这个玩具示例中,我们只使用了1个负样本。

图5是PyTorch代码中下面两行的可视化结果。

目标嵌入向量的维度为(1 x N),而我们的上下文嵌入矩阵的维度为(N x 2)。因此,我们的矩阵乘法得到一个维度为(1 x 2)的矩阵。

图6展示了forward()方法的最后两行的功能。

使用负采样来概念化这个前向传递的另一种方式是将其视为目标词与上下文中的每个词(正上下文词和所有负上下文词)之间的点积。在本例中,我们的正上下文词是词汇表中的第8个词,负上下文词是词汇表中的第6个词。结果得到的(1 x 2)向量包含了这两个上下文词的逻辑回归值。由于我们知道第一个上下文词是正上下文词,第二个上下文词是负上下文词,所以第一个元素的值应该较大,第二个元素的值应该较小。为了实现这一点,我们将使用torch.nn.BCEWithLogitsLoss作为损失函数。我们将在后面的部分重新讨论损失函数的选择。

Model类中的最后三个方法是normalize_embeddings()、get_similar_words()和get_similarity()。不详细介绍每个方法的细节,normalize_embeddings()方法将每个词嵌入进行缩放,使其成为单位向量(即范数为1)。get_similar_words()方法将接受一个词,并返回最相似的前n个词的列表。所使用的相似度度量是余弦相似度。换句话说,该方法将返回向量表示与所关注词之间的夹角最小的词。最后,get_similarity()方法将接受两个词,并返回两个词向量之间的余弦相似度。

创建Trainer

该过程的最后一步是创建一个名为Trainer的类。Trainer类的代码如下:

代码语言:javascript
复制
class Trainer:    def __init__(self, model: Model, params: Word2VecParams, optimizer,                vocab: Vocab, train_iter, valid_iter, skipgrams: SkipGrams):        self.model = model        self.optimizer = optimizer        self.vocab = vocab        self.train_iter = train_iter        self.valid_iter = valid_iter        self.skipgrams = skipgrams        self.params = params
        self.epoch_train_mins = {}        self.loss = {"train": [], "valid": []}
        # sending all to device        self.model.to(self.params.DEVICE)        self.params.CRITERION.to(self.params.DEVICE)
        self.negative_sampler = NegativeSampler(            vocab=self.vocab, ns_exponent=.75,             ns_array_len=self.params.NS_ARRAY_LEN            )        self.testwords = ['love', 'hurricane', 'military', 'army']

    def train(self):        for epoch in range(self.params.N_EPOCHS):            # Generate Dataloaders            self.train_dataloader = DataLoader(                self.train_iter,                batch_size=self.params.BATCH_SIZE,                shuffle=False,                collate_fn=self.skipgrams.collate_skipgram            )            self.valid_dataloader = DataLoader(                self.valid_iter,                batch_size=self.params.BATCH_SIZE,                shuffle=False,                collate_fn=self.skipgrams.collate_skipgram            )            # training the model            st_time = monotonic()            self._train_epoch()            self.epoch_train_mins[epoch] = round((monotonic()-st_time)/60, 1)
            # validating the model            self._validate_epoch()            print(f"""Epoch: {epoch+1}/{self.params.N_EPOCHS}\n""",             f"""    Train Loss: {self.loss['train'][-1]:.2}\n""",            f"""    Valid Loss: {self.loss['valid'][-1]:.2}\n""",            f"""    Training Time (mins): {self.epoch_train_mins.get(epoch)}"""            """\n"""            )            self.test_testwords()

    def _train_epoch(self):        self.model.train()        running_loss = []
        for i, batch_data in enumerate(self.train_dataloader, 1):            if len(batch_data[0]) == 0:                continue            inputs = batch_data[0].to(self.params.DEVICE)            pos_labels = batch_data[1].to(self.params.DEVICE)            neg_labels = self.negative_sampler.sample(                pos_labels.shape[0], self.params.NEG_SAMPLES                )            neg_labels = neg_labels.to(self.params.DEVICE)            context = torch.cat(                [pos_labels.view(pos_labels.shape[0], 1),                 neg_labels], dim=1              )            
            # building the targets tensor              y_pos = torch.ones((pos_labels.shape[0], 1))            y_neg = torch.zeros((neg_labels.shape[0], neg_labels.shape[1]))            y = torch.cat([y_pos, y_neg], dim=1).to(self.params.DEVICE)
            self.optimizer.zero_grad()
            outputs = self.model(inputs, context)            loss = self.params.CRITERION(outputs, y)            loss.backward()            self.optimizer.step()
            running_loss.append(loss.item())
        epoch_loss = np.mean(running_loss)
        self.loss['train'].append(epoch_loss)
    def _validate_epoch(self):        self.model.eval()        running_loss = []
        with torch.no_grad():            for i, batch_data in enumerate(self.valid_dataloader, 1):                if len(batch_data[0]) == 0:                    continue                inputs = batch_data[0].to(self.params.DEVICE)                pos_labels = batch_data[1].to(self.params.DEVICE)                neg_labels = self.negative_sampler.sample(                    pos_labels.shape[0], self.params.NEG_SAMPLES                    ).to(self.params.DEVICE)                context = torch.cat(                    [pos_labels.view(pos_labels.shape[0], 1),                     neg_labels], dim=1                  )

                # building the targets tensor                  y_pos = torch.ones((pos_labels.shape[0], 1))                y_neg = torch.zeros((neg_labels.shape[0], neg_labels.shape[1]))                y = torch.cat([y_pos, y_neg], dim=1).to(self.params.DEVICE)
                preds = self.model(inputs, context).to(self.params.DEVICE)                loss = self.params.CRITERION(preds, y)
                running_loss.append(loss.item())
            epoch_loss = np.mean(running_loss)            self.loss['valid'].append(epoch_loss)
    def test_testwords(self, n: int = 5):        for word in self.testwords:            print(word)            nn_words = self.model.get_similar_words(word, n)            for w, sim in nn_words.items():                print(f"{w} ({sim:.3})", end=' ')            print('\n')

这个类将协调之前开发的所有代码来训练模型。这个类中的方法包括train()、_train_epoch()、_validate_epoch()和test_testwords()。train()方法是我们调用来开始模型训练的方法。它循环遍历所有的epochs,并调用_train_epoch()和_validate_epoch()方法。

在训练和验证完一个epoch之后,它将调用test_testwords()方法打印出测试词汇,以便我们可以直观地检查嵌入是否改善。这个类中最关键的方法是_train_epoch()和_validate_epoch()方法。这两个方法在功能上非常相似,但有一个小差别。让我们深入了解_train_epoch()方法。

代码语言:javascript
复制
def _train_epoch(self):    self.model.train()    running_loss = []
    for i, batch_data in enumerate(self.train_dataloader, 1):        if len(batch_data[0]) == 0:            continue        inputs = batch_data[0].to(self.params.DEVICE)        pos_labels = batch_data[1].to(self.params.DEVICE)        neg_labels = self.negative_sampler.sample(            pos_labels.shape[0], self.params.NEG_SAMPLES            )        neg_labels = neg_labels.to(self.params.DEVICE)        context = torch.cat(            [pos_labels.view(pos_labels.shape[0], 1),             neg_labels], dim=1            )            
        # building the targets tensor          y_pos = torch.ones((pos_labels.shape[0], 1))        y_neg = torch.zeros((neg_labels.shape[0], neg_labels.shape[1]))        y = torch.cat([y_pos, y_neg], dim=1).to(self.params.DEVICE)
        self.optimizer.zero_grad()
        outputs = self.model(inputs, context)        loss = self.params.CRITERION(outputs, y)        loss.backward()        self.optimizer.step()
        running_loss.append(loss.item())
    epoch_loss = np.mean(running_loss)
    self.loss['train'].append(epoch_loss)

首先,我们通过self.model.train()告诉模型它处于训练模式。这样可以让PyTorch对某些类型的网络层在训练过程中按预期进行操作。虽然这个模型中没有实现这些层类型,但通常最佳做法是告知PyTorch模型正在进行训练。下一步是循环遍历每个批次,获取正上下文词和负上下文词,并将它们发送到适当的设备(CPU或GPU)。换句话说,我们创建上下文张量,它访问使用SkipGrams类构建的数据加载器中的批量数据,并将其与使用NegativeSampler类生成的负样本进行连接。接下来,我们构造了ground truth张量y。由于我们知道上下文张量中的第一个元素是正上下文词,后面的所有元素都是负上下文词,所以我们创建了一个张量y,其中张量的第一个元素为1,后面的所有元素都为0。

现在我们有了输入数据、上下文数据和真实标签,我们可以进行前向传递。第一步是告诉PyTorch将所有梯度设置为0。否则,每次通过模型传递一批数据时,梯度会被累加,这不是期望的行为。然后,我们使用以下代码执行前向传递:

代码语言:javascript
复制
outputs = self.model(inputs, context)

接下来,计算损失。由于我们有一个二元分类问题,张量y的第一个元素为1,后面的元素为0,所以我们使用torch.nn.BCEWithLogitsLoss目标函数。有关此损失函数的更多信息,请参考文档。Sebastian Raschka的博客提供了关于PyTorch中二元交叉熵损失的很好的概述,可以提供进一步的了解。

代码语言:javascript
复制
loss = self.params.CRITERION(outputs, y)

PyTorch在进行损失计算时会自动计算梯度。梯度包含了使模型参数进行微小调整并降低损失所需的所有信息。这个自动计算过程在以下代码行中完成:

代码语言:javascript
复制
loss.backward() 

模型参数的微小更新在接下来的代码行中进行。请注意,我们使用了torch.optim.Adam优化器。Adam是最先进的凸优化算法之一,是随机梯度下降的一个后代。在本文中,我不会详细介绍Adam,但请注意,由于它利用了自适应学习和梯度下降,它往往是最快的优化算法之一。

代码语言:javascript
复制
self.optimizer.step() 

_validate_epoch()方法与_train_epoch()方法完全相同,只是它不跟踪梯度,也不使用优化器进行模型参数的更新。这一切都是通过torch.no_grad()语句实现的。此外,_validate_epoch()方法只使用验证数据,而不使用训练数据。

将所有内容整合在一起

下面是完整的word2vec笔记本代码:

代码语言:javascript
复制
%%capture!pip install torch torchtext torchdata
代码语言:javascript
复制
import osimport randomimport refrom collections import Counter, OrderedDictfrom dataclasses import dataclassfrom time import monotonicfrom typing import Dict, List, Optional, Union
import numpy as npimport torchimport torch.nn as nnfrom scipy.spatial.distance import cosinefrom torch.utils.data import DataLoaderfrom torchtext.data import to_map_style_datasetfrom torchtext.data.utils import get_tokenizerfrom torchtext.datasets import WikiText103from tqdm import tqdm
代码语言:javascript
复制
def get_data():    # gets the data    train_iter = WikiText103(split='train')    train_iter = to_map_style_dataset(train_iter)    valid_iter = WikiText103(split='test')    valid_iter = to_map_style_dataset(valid_iter)
    return train_iter, valid_iter
代码语言:javascript
复制
@dataclassclass Word2VecParams:
    # skipgram parameters    MIN_FREQ = 50     SKIPGRAM_N_WORDS = 8    T = 85    NEG_SAMPLES = 50    NS_ARRAY_LEN = 5_000_000    SPECIALS = ""    TOKENIZER = 'basic_english'
    # network parameters    BATCH_SIZE = 100    EMBED_DIM = 300    EMBED_MAX_NORM = None    N_EPOCHS = 5    DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")     CRITERION = nn.BCEWithLogitsLoss()
代码语言:javascript
复制
class Vocab:    def __init__(self, list, specials):        self.stoi = {v[0]:(k, v[1]) for k, v in enumerate(list)}        self.itos = {k:(v[0], v[1]) for k, v in enumerate(list)}        self._specials = specials[0]        self.total_tokens = np.nansum(            [f for _, (_, f) in self.stoi.items()]            , dtype=int)
    def __len__(self):        return len(self.stoi) - 1
    def get_index(self, word: Union[str, List]):        if isinstance(word, str):            if word in self.stoi:                 return self.stoi.get(word)[0]            else:                return self.stoi.get(self._specials)[0]        elif isinstance(word, list):            res = []            for w in word:                if w in self.stoi:                     res.append(self.stoi.get(w)[0])                else:                    res.append(self.stoi.get(self._specials)[0])            return res        else:            raise ValueError(                f"Word {word} is not a string or a list of strings."                )

    def get_freq(self, word: Union[str, List]):        if isinstance(word, str):            if word in self.stoi:                 return self.stoi.get(word)[1]            else:                return self.stoi.get(self._specials)[1]        elif isinstance(word, list):            res = []            for w in word:                if w in self.stoi:                    res.append(self.stoi.get(w)[1])                else:                    res.append(self.stoi.get(self._specials)[1])            return res        else:            raise ValueError(                f"Word {word} is not a string or a list of strings."                )

    def lookup_token(self, token: Union[int, List]):        if isinstance(token, (int, np.int64)):            if token in self.itos:                return self.itos.get(token)[0]            else:                raise ValueError(f"Token {token} not in vocabulary")        elif isinstance(token, list):            res = []            for t in token:                if t in self.itos:                    res.append(self.itos.get(token)[0])                else:                    raise ValueError(f"Token {t} is not a valid index.")            return res
代码语言:javascript
复制
def yield_tokens(iterator, tokenizer):    r = re.compile('[a-z1-9]')    for text in iterator:        res = tokenizer(text)        res = list(filter(r.match, res))        yield res
def vocab(ordered_dict: Dict, min_freq: int = 1, specials: str = ''):    tokens = []    # Save room for special tokens    for token, freq in ordered_dict.items():        if freq >= min_freq:            tokens.append((token, freq))
    specials = (specials, np.nan)    tokens[0] = specials
    return Vocab(tokens, specials)
def pipeline(word, vocab, tokenizer):    return vocab(tokenizer(word))
def build_vocab(        iterator,        tokenizer,         params: Word2VecParams,        max_tokens: Optional[int] = None,    ):    counter = Counter()    for tokens in yield_tokens(iterator, tokenizer):        counter.update(tokens)
    # First sort by descending frequency, then lexicographically    sorted_by_freq_tuples = sorted(        counter.items(), key=lambda x: (-x[1], x[0])        )
    ordered_dict = OrderedDict(sorted_by_freq_tuples)
    word_vocab = vocab(        ordered_dict, min_freq=params.MIN_FREQ, specials=params.SPECIALS        )    return word_vocab
代码语言:javascript
复制
class SkipGrams:    def __init__(self, vocab: Vocab, params: Word2VecParams, tokenizer):        self.vocab = vocab        self.params = params        self.t = self._t()        self.tokenizer = tokenizer        self.discard_probs = self._create_discard_dict()
    def _t(self):        freq_list = []        for _, (_, freq) in list(self.vocab.stoi.items())[1:]:            freq_list.append(freq/self.vocab.total_tokens)        return np.percentile(freq_list, self.params.T)

    def _create_discard_dict(self):        discard_dict = {}        for _, (word, freq) in self.vocab.stoi.items():            dicard_prob = 1-np.sqrt(                self.t / (freq/self.vocab.total_tokens + self.t))            discard_dict[word] = dicard_prob        return discard_dict

    def collate_skipgram(self, batch):        batch_input, batch_output  = [], []        for text in batch:            text_tokens = self.vocab.get_index(self.tokenizer(text))
            if len(text_tokens) < self.params.SKIPGRAM_N_WORDS * 2 + 1:                continue
            for idx in range(len(text_tokens) - self.params.SKIPGRAM_N_WORDS*2                ):                token_id_sequence = text_tokens[                    idx : (idx + self.params.SKIPGRAM_N_WORDS * 2 + 1)                    ]                input_ = token_id_sequence.pop(self.params.SKIPGRAM_N_WORDS)                outputs = token_id_sequence
                prb = random.random()                del_pair = self.discard_probs.get(input_)                if input_==0 or del_pair >= prb:                    continue                else:                    for output in outputs:                        prb = random.random()                        del_pair = self.discard_probs.get(output)                        if output==0 or del_pair >= prb:                            continue                        else:                            batch_input.append(input_)                            batch_output.append(output)
        batch_input = torch.tensor(batch_input, dtype=torch.long)        batch_output = torch.tensor(batch_output, dtype=torch.long)
        return batch_input, batch_output
代码语言:javascript
复制
class NegativeSampler:    def __init__(self, vocab: Vocab, ns_exponent: float, ns_array_len: int):        self.vocab = vocab        self.ns_exponent = ns_exponent        self.ns_array_len = ns_array_len        self.ns_array = self._create_negative_sampling()
    def __len__(self):        return len(self.ns_array)
    def _create_negative_sampling(self):
        frequency_dict = {word:freq**(self.ns_exponent) \                          for _,(word, freq) in                           list(self.vocab.stoi.items())[1:]}        frequency_dict_scaled = {            word:             max(1,int((freq/self.vocab.total_tokens)*self.ns_array_len))             for word, freq in frequency_dict.items()            }        ns_array = []        for word, freq in tqdm(frequency_dict_scaled.items()):            ns_array = ns_array + [word]*freq        return ns_array
    def sample(self,n_batches: int=1, n_samples: int=1):        samples = []        for _ in range(n_batches):            samples.append(random.sample(self.ns_array, n_samples))        samples = torch.as_tensor(np.array(samples))        return samples
代码语言:javascript
复制
class Model(nn.Module):    def __init__(self, vocab: Vocab, params: Word2VecParams):        super().__init__()        self.vocab = vocab        self.t_embeddings = nn.Embedding(            self.vocab.__len__()+1,             params.EMBED_DIM,             max_norm=params.EMBED_MAX_NORM            )        self.c_embeddings = nn.Embedding(            self.vocab.__len__()+1,             params.EMBED_DIM,             max_norm=params.EMBED_MAX_NORM            )
    def forward(self, inputs, context):        # getting embeddings for target & reshaping         target_embeddings = self.t_embeddings(inputs)        n_examples = target_embeddings.shape[0]        n_dimensions = target_embeddings.shape[1]        target_embeddings = target_embeddings.view(n_examples, 1, n_dimensions)
        # get embeddings for context labels & reshaping         # Allows us to do a bunch of matrix multiplications        context_embeddings = self.c_embeddings(context)        # * This transposes each batch        context_embeddings = context_embeddings.permute(0,2,1)
        # * custom linear layer        dots = target_embeddings.bmm(context_embeddings)        dots = dots.view(dots.shape[0], dots.shape[2])        return dots 
    def normalize_embeddings(self):        embeddings = list(self.t_embeddings.parameters())[0]        embeddings = embeddings.cpu().detach().numpy()         norms = (embeddings ** 2).sum(axis=1) ** (1 / 2)        norms = norms.reshape(norms.shape[0], 1)        return embeddings / norms
    def get_similar_words(self, word, n):        word_id = self.vocab.get_index(word)        if word_id == 0:            print("Out of vocabulary word")            return
        embedding_norms = self.normalize_embeddings()        word_vec = embedding_norms[word_id]        word_vec = np.reshape(word_vec, (word_vec.shape[0], 1))        dists = np.matmul(embedding_norms, word_vec).flatten()        topN_ids = np.argsort(-dists)[1 : n + 1]
        topN_dict = {}        for sim_word_id in topN_ids:            sim_word = self.vocab.lookup_token(sim_word_id)            topN_dict[sim_word] = dists[sim_word_id]        return topN_dict
    def get_similarity(self, word1, word2):        idx1 = self.vocab.get_index(word1)        idx2 = self.vocab.get_index(word2)        if idx1 == 0 or idx2 == 0:            print("One or both words are out of vocabulary")            return
        embedding_norms = self.normalize_embeddings()        word1_vec, word2_vec = embedding_norms[idx1], embedding_norms[idx2]
        return cosine(word1_vec, word2_vec)
代码语言:javascript
复制
class Trainer:    def __init__(self, model: Model, params: Word2VecParams, optimizer,                vocab: Vocab, train_iter, valid_iter, skipgrams: SkipGrams):        self.model = model        self.optimizer = optimizer        self.vocab = vocab        self.train_iter = train_iter        self.valid_iter = valid_iter        self.skipgrams = skipgrams        self.params = params
        self.epoch_train_mins = {}        self.loss = {"train": [], "valid": []}
        # sending all to device        self.model.to(self.params.DEVICE)        self.params.CRITERION.to(self.params.DEVICE)
        self.negative_sampler = NegativeSampler(            vocab=self.vocab, ns_exponent=.75,             ns_array_len=self.params.NS_ARRAY_LEN            )        self.testwords = ['love', 'hurricane', 'military', 'army']

    def train(self):        self.test_testwords()        for epoch in range(self.params.N_EPOCHS):            # Generate Dataloaders            self.train_dataloader = DataLoader(                self.train_iter,                batch_size=self.params.BATCH_SIZE,                shuffle=False,                collate_fn=self.skipgrams.collate_skipgram            )            self.valid_dataloader = DataLoader(                self.valid_iter,                batch_size=self.params.BATCH_SIZE,                shuffle=False,                collate_fn=self.skipgrams.collate_skipgram            )            # training the model            st_time = monotonic()            self._train_epoch()            self.epoch_train_mins[epoch] = round((monotonic()-st_time)/60, 1)
            # validating the model            self._validate_epoch()            print(f"""Epoch: {epoch+1}/{self.params.N_EPOCHS}\n""",             f"""    Train Loss: {self.loss['train'][-1]:.2}\n""",            f"""    Valid Loss: {self.loss['valid'][-1]:.2}\n""",            f"""    Training Time (mins): {self.epoch_train_mins.get(epoch)}"""            """\n"""            )            self.test_testwords()

    def _train_epoch(self):        self.model.train()        running_loss = []
        for i, batch_data in enumerate(self.train_dataloader, 1):            if len(batch_data[0]) == 0:                continue            inputs = batch_data[0].to(self.params.DEVICE)            pos_labels = batch_data[1].to(self.params.DEVICE)            neg_labels = self.negative_sampler.sample(                pos_labels.shape[0], self.params.NEG_SAMPLES                )            neg_labels = neg_labels.to(self.params.DEVICE)            context = torch.cat(                [pos_labels.view(pos_labels.shape[0], 1),                 neg_labels], dim=1              )            
            # building the targets tensor              y_pos = torch.ones((pos_labels.shape[0], 1))            y_neg = torch.zeros((neg_labels.shape[0], neg_labels.shape[1]))            y = torch.cat([y_pos, y_neg], dim=1).to(self.params.DEVICE)
            self.optimizer.zero_grad()
            outputs = self.model(inputs, context)            loss = self.params.CRITERION(outputs, y)            loss.backward()            self.optimizer.step()
            running_loss.append(loss.item())
        epoch_loss = np.mean(running_loss)
        self.loss['train'].append(epoch_loss)
    def _validate_epoch(self):        self.model.eval()        running_loss = []
        with torch.no_grad():            for i, batch_data in enumerate(self.valid_dataloader, 1):                if len(batch_data[0]) == 0:                    continue                inputs = batch_data[0].to(self.params.DEVICE)                pos_labels = batch_data[1].to(self.params.DEVICE)                neg_labels = self.negative_sampler.sample(                    pos_labels.shape[0], self.params.NEG_SAMPLES                    ).to(self.params.DEVICE)                context = torch.cat(                    [pos_labels.view(pos_labels.shape[0], 1),                     neg_labels], dim=1                  )

                # building the targets tensor                  y_pos = torch.ones((pos_labels.shape[0], 1))                y_neg = torch.zeros((neg_labels.shape[0], neg_labels.shape[1]))                y = torch.cat([y_pos, y_neg], dim=1).to(self.params.DEVICE)
                preds = self.model(inputs, context).to(self.params.DEVICE)                loss = self.params.CRITERION(preds, y)
                running_loss.append(loss.item())
            epoch_loss = np.mean(running_loss)            self.loss['valid'].append(epoch_loss)
    def test_testwords(self, n: int = 5):        for word in self.testwords:            print(word)            nn_words = self.model.get_similar_words(word, n)            for w, sim in nn_words.items():                print(f"{w} ({sim:.3})", end=' ')            print('\n')
代码语言:javascript
复制
params = Word2VecParams()train_iter, valid_iter = get_data()tokenizer = get_tokenizer(params.TOKENIZER)vocab = build_vocab(train_iter, tokenizer, params)skip_gram = SkipGrams(vocab=vocab, params=params, tokenizer=tokenizer)model = Model(vocab=vocab, params=params).to(params.DEVICE)optimizer = torch.optim.Adam(params = model.parameters())
代码语言:javascript
复制
trainer = Trainer(        model=model,         params=params,        optimizer=optimizer,         train_iter=train_iter,         valid_iter=valid_iter,         vocab=vocab,        skipgrams=skip_gram    )trainer.train()
代码语言:javascript
复制
100%|██████████| 49331/49331 [01:14<00:00, 663.13it/s]Epoch: 1/5     Train Loss: 0.96     Valid Loss: 0.18     Training Time (mins): 43.0
lovemusic (0.44) her (0.44) has (0.43) s (0.424) wrote (0.424) 
hurricanelandfall (0.287) changing (0.28) rapidly (0.279) steadily (0.276) winds (0.275) 
militaryby (0.457) for (0.45) although (0.449) was (0.449) any (0.444) 
armybritish (0.641) were (0.605) only (0.605) during (0.604) in (0.604) 
Epoch: 2/5     Train Loss: 0.14     Valid Loss: 0.11     Training Time (mins): 42.4
loveme (0.851) my (0.849) said (0.845) good (0.838) saying (0.832) 
hurricanestorm (0.676) tropical (0.64) landfall (0.629) winds (0.625) rapidly (0.589) 
militarywar (0.852) army (0.839) support (0.829) forces (0.82) supported (0.819) 
armyforces (0.896) troops (0.891) war (0.854) captured (0.839) military (0.839) 
Epoch: 3/5     Train Loss: 0.1     Valid Loss: 0.096     Training Time (mins): 42.5
lovegirl (0.825) woman (0.787) me (0.782) herself (0.781) song (0.773) 
hurricanestorm (0.787) tropical (0.745) landfall (0.736) cyclone (0.71) winds (0.685) 
militaryarmy (0.783) war (0.773) forces (0.756) government (0.74) troops (0.731) 
armytroops (0.859) forces (0.848) command (0.796) military (0.783) commanded (0.779) 
Epoch: 4/5     Train Loss: 0.092     Valid Loss: 0.091     Training Time (mins): 42.2
lovegirl (0.789) me (0.768) my (0.762) you (0.746) lover (0.743) 
hurricanestorm (0.825) tropical (0.787) landfall (0.781) cyclone (0.761) winds (0.709) 
militaryarmy (0.764) forces (0.744) troops (0.701) war (0.701) leadership (0.697) 
armytroops (0.836) forces (0.836) commanded (0.773) military (0.764) corps (0.743) 
Epoch: 5/5     Train Loss: 0.089     Valid Loss: 0.09     Training Time (mins): 42.2
lovegirl (0.763) my (0.728) me (0.723) lover (0.722) you (0.709) 
hurricanestorm (0.85) tropical (0.805) landfall (0.791) cyclone (0.781) intensity (0.717) 
militaryarmy (0.717) forces (0.674) officers (0.673) leadership (0.662) soldiers (0.662) 
armytroops (0.823) forces (0.804) command (0.761) corps (0.758) commanded (0.753) 

我在一个带有GPU的Google Colab实例中运行了这个笔记本。正如你所看到的,我训练了5个周期,每个周期需要42到43分钟。因此,整个笔记本在不到4小时的时间内运行完成。

结果
代码语言:javascript
复制
trainer = Trainer(        model=model,         params=params,        optimizer=optimizer,         train_iter=train_iter,         valid_iter=valid_iter,         vocab=vocab,        skipgrams=skip_gram    )trainer.train()
代码语言:javascript
复制
100%|██████████| 49331/49331 [01:14<00:00, 663.13it/s]Epoch: 1/5     Train Loss: 0.96     Valid Loss: 0.18     Training Time (mins): 43.0
lovemusic (0.44) her (0.44) has (0.43) s (0.424) wrote (0.424) 
hurricanelandfall (0.287) changing (0.28) rapidly (0.279) steadily (0.276) winds (0.275) 
militaryby (0.457) for (0.45) although (0.449) was (0.449) any (0.444) 
armybritish (0.641) were (0.605) only (0.605) during (0.604) in (0.604) 
Epoch: 2/5     Train Loss: 0.14     Valid Loss: 0.11     Training Time (mins): 42.4
loveme (0.851) my (0.849) said (0.845) good (0.838) saying (0.832) 
hurricanestorm (0.676) tropical (0.64) landfall (0.629) winds (0.625) rapidly (0.589) 
militarywar (0.852) army (0.839) support (0.829) forces (0.82) supported (0.819) 
armyforces (0.896) troops (0.891) war (0.854) captured (0.839) military (0.839) 
Epoch: 3/5     Train Loss: 0.1     Valid Loss: 0.096     Training Time (mins): 42.5
lovegirl (0.825) woman (0.787) me (0.782) herself (0.781) song (0.773) 
hurricanestorm (0.787) tropical (0.745) landfall (0.736) cyclone (0.71) winds (0.685) 
militaryarmy (0.783) war (0.773) forces (0.756) government (0.74) troops (0.731) 
armytroops (0.859) forces (0.848) command (0.796) military (0.783) commanded (0.779) 
Epoch: 4/5     Train Loss: 0.092     Valid Loss: 0.091     Training Time (mins): 42.2
lovegirl (0.789) me (0.768) my (0.762) you (0.746) lover (0.743) 
hurricanestorm (0.825) tropical (0.787) landfall (0.781) cyclone (0.761) winds (0.709) 
militaryarmy (0.764) forces (0.744) troops (0.701) war (0.701) leadership (0.697) 
armytroops (0.836) forces (0.836) commanded (0.773) military (0.764) corps (0.743) 
Epoch: 5/5     Train Loss: 0.089     Valid Loss: 0.09     Training Time (mins): 42.2
lovegirl (0.763) my (0.728) me (0.723) lover (0.722) you (0.709) 
hurricanestorm (0.85) tropical (0.805) landfall (0.791) cyclone (0.781) intensity (0.717) 
militaryarmy (0.717) forces (0.674) officers (0.673) leadership (0.662) soldiers (0.662) 
armytroops (0.823) forces (0.804) command (0.761) corps (0.758) commanded (0.753) 

在训练了将近4个小时后,请观察上面的代码片段中的结果。除了损失的减少,还可以观察到随着训练周期的增加,最相似的单词的质量也有所提高。在训练的第一个周期后,与military最相似的五个单词是:by、for、although、was和any。在训练了5个周期后,与military最相似的五个单词是:army、forces、officers、leadership和soldiers。这表明模型学习到的嵌入向量准确地表示了词汇中单词的语义含义,这与损失的减少一起展示了这一点。

感谢阅读!

结论

总而言之,我们回顾了一个使用PyTorch实现的带有负采样和子采样的word2vec模型。该模型使我们能够将单词转化为n维向量空间中的连续向量。这些嵌入向量的学习使得具有相似语义含义的单词被聚集在一起。通过足够的训练数据和充足的训练时间,word2vec模型还可以学习文本数据中的句法模式。

词嵌入是自然语言处理中的基础组成部分,并在更高级的模型(如基于Transformer的大型语言模型)中至关重要。深入理解word2vec是进一步学习自然语言处理的非常有帮助的基础!

参考引用

[1] T. Mikolov, K. Chen, G. Corrado, and J. Dean, Efficient Estimation of Word Representations in Vector Space (2013), Google Inc.

[2] T. Mikolov, I. Sutskever, K. Chen, G. Corrado, and J. Dean, Distributed Representations of Words and Phrases and their Compositionality (2013), Google Inc.

[3] S. Raschka, Losses Learned (2022), https://sebastianraschka.com

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-06-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 磐创AI 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
  • 词嵌入概述
    • 为什么我们需要词嵌入?
      • 最简单的词嵌入
        • 改进的词嵌入
        • Skip-gram Word2Vec架构
          • Skip-gram
            • 模型架构
              • 负采样
                • 子采样
                • PyTorch实现
                  • 获取数据
                    • 设置参数和配置
                      • 构建词汇表
                        • 构建 PyTorch Dataloader
                          • 创建负采样数组
                            • 定义PyTorch模型
                              • 创建Trainer
                                • 将所有内容整合在一起
                                  • 结果
                                  • 结论
                                  相关产品与服务
                                  NLP 服务
                                  NLP 服务(Natural Language Process,NLP)深度整合了腾讯内部的 NLP 技术,提供多项智能文本处理和文本生成能力,包括词法分析、相似词召回、词相似度、句子相似度、文本润色、句子纠错、文本补全、句子生成等。满足各行业的文本智能需求。
                                  领券
                                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档