自然语言处理(三):基于跳元模型的word2vec实现

跳元模型

回顾一下第一节讲过的跳元模型

跳元模型(Skip-gram Model)是一种用于学习词向量的模型,属于Word2Vec算法中的一种。它的目标是通过给定一个中心词语来预测其周围的上下文词语。

这节我们以跳元模型为例,讲解word2vec的实现

文章内容来自李沐大神的《动手学深度学习》并加以我的理解,感兴趣可以去https://zh-v2.d2l.ai/查看完整书籍


文章目录

  • 跳元模型
  • 数据集
    • 数据集的获取
    • 词表的构建
    • 下采样
    • 中心词和上下文词的提取
    • 负采样
    • 小批量加载训练实例
    • 整合所有代码
  • 训练word2vec
    • 跳元模型
      • 嵌入层
      • 定义前向传播
    • 训练
      • 二元交叉熵损失
      • 初始化模型参数
      • 定义训练阶段代码
    • 应用词嵌入


数据集

我们在这里使用的数据集是Penn Tree Bank(PTB)。该语料库取自“华尔街日报”的文章,分为训练集、验证集和测试集。在原始格式中,文本文件的每一行表示由空格分隔的一句话。在这里,我们将每个单词视为一个词元。

数据集的获取

import math
import os
import random
import torch
from d2l import torch as d2l
#@save
d2l.DATA_HUB['ptb'] = (d2l.DATA_URL + 'ptb.zip',
                       '319d85e578af0cdc590547f26231e4e31cdf1e42')

#@save
def read_ptb():
    """将PTB数据集加载到文本行的列表中"""
    data_dir = d2l.download_extract('ptb')
    # Readthetrainingset.
    with open(os.path.join(data_dir, 'ptb.train.txt')) as f:
        raw_text = f.read()
    return [line.split() for line in raw_text.split('\n')]

sentences = read_ptb()
f'# sentences数: {len(sentences)}'

在这里插入图片描述
在读取训练集之后,我们为语料库构建了一个词表,其中出现次数少于10次的任何单词都将由“”词元替换。请注意,原始数据集还包含表示稀有(未知)单词的“”词元。

词表的构建

vocab = d2l.Vocab(sentences, min_freq=10)
f'vocab size: {len(vocab)}'

在这里插入图片描述

词表(Vocabulary),也被称为词典(Dictionary)或词汇表,是在自然语言处理(NLP)任务中用于表示文本数据中所有不同单词的集合。
在文本处理中,将文本数据转换为机器可处理的形式通常需要对单词进行编码。词表是一个重要的组成部分,它将每个单词映射到一个唯一的标识符,例如整数索引。通过构建词表,我们可以将文本数据中的单词转换为数字表示,以便机器学习模型可以对其进行处理。

词表的构建过程包括以下步骤:

  1. 收集所有的单词:遍历文本数据,将其中出现的所有单词进行收集。
  2. 去重:去除重复的单词,确保每个单词只在词表中出现一次。
  3. 分配唯一标识符:为每个单词分配一个唯一的标识符,例如整数索引。常见的做法是按照单词出现的频率进行排序,频率高的单词通常被分配较小的索引。
  4. 特殊标记:词表通常还包括一些特殊标记,如未知词元(UNK)、填充词元(PAD)、开始词元(START)、结束词元(END)等。这些标记用于表示特定的语义或在模型中的特殊处理。

词表在NLP任务中起到了关键的作用,它不仅提供了单词到数字的映射,还可以用于统计词频、计算词向量等。构建一个良好的词表对于成功实施文本处理任务至关重要。

d2l中的词表如下实现:

class Vocab:
    """Vocabulary for text."""
    def __init__(self, tokens=None, min_freq=0, reserved_tokens=None):
        """Defined in :numref:`sec_text_preprocessing`"""
        if tokens is None:
            tokens = []
        if reserved_tokens is None:
            reserved_tokens = []
        # Sort according to frequencies
        counter = count_corpus(tokens)
        self._token_freqs = sorted(counter.items(), key=lambda x: x[1],
                                   reverse=True)
        # The index for the unknown token is 0
        self.idx_to_token = ['<unk>'] + reserved_tokens
        self.token_to_idx = {token: idx
                             for idx, token in enumerate(self.idx_to_token)}
        for token, freq in self._token_freqs:
            if freq < min_freq:
                break
            if token not in self.token_to_idx:
                self.idx_to_token.append(token)
                self.token_to_idx[token] = len(self.idx_to_token) - 1

    def __len__(self):
        return len(self.idx_to_token)

    def __getitem__(self, tokens):
        if not isinstance(tokens, (list, tuple)):
            return self.token_to_idx.get(tokens, self.unk)
        return [self.__getitem__(token) for token in tokens]

    def to_tokens(self, indices):
        if not isinstance(indices, (list, tuple)):
            return self.idx_to_token[indices]
        return [self.idx_to_token[index] for index in indices]
	
	def count_corpus(tokens):
    """Count token frequencies.

    Defined in :numref:`sec_text_preprocessing`"""
    # Here `tokens` is a 1D list or 2D list
    if len(tokens) == 0 or isinstance(tokens[0], list):
        # Flatten a list of token lists into a list of tokens
        tokens = [token for line in tokens for token in line]
    return collections.Counter(tokens)
    
    @property
    def unk(self):  # Index for the unknown token
        return 0

    @property
    def token_freqs(self):  # Index for the unknown token
        return self._token_freqs

下采样

文本数据通常有“the”“a”和“in”等高频词:它们在非常大的语料库中甚至可能出现数十亿次。然而,这些词经常在上下文窗口中与许多不同的词共同出现,提供的有用信息很少。例如,考虑上下文窗口中的词“chip”:直观地说,它与低频单词“intel”的共现比与高频单词“a”的共现在训练中更有用。此外,大量(高频)单词的训练速度很慢。因此,当训练词嵌入模型时,可以对高频单词进行下采样 (Mikolov et al., 2013)。具体地说,数据集中的每个词 w i w_i wi将有概率 P ( w i ) P(w_i) P(wi)地被丢弃:
P ( w i ) = m a x ( 1 − t f ( w i ) , 0 ) P(w_i)=max(1-\sqrt{\frac{t}{f(w_i)}},0) P(wi)=max(1f(wi)t ,0)
其中 f ( w i ) f(w_i) f(wi) w i w_i wi的词数与数据集中的总词数的比率,常量 t t t是超参数(在实验中为 1 0 − 4 10^{-4} 104)。我们可以看到,只有当相对比率 f ( w i ) > t f(w_i)>t f(wi)>t时,(高频)词 w i w_i wi才能被丢弃,且该词的相对比率越高,被丢弃的概率就越大。

#@save
def subsample(sentences, vocab):
    """下采样高频词"""
    # 排除未知词元'<unk>'
    sentences = [[token for token in line if vocab[token] != vocab.unk]
                 for line in sentences]
    counter = d2l.count_corpus(sentences)
    num_tokens = sum(counter.values())

    # 如果在下采样期间保留词元,则返回True
    def keep(token):
        return(random.uniform(0, 1) <
               math.sqrt(1e-4 / counter[token] * num_tokens))

    return ([[token for token in line if keep(token)] for line in sentences],
            counter)

subsampled, counter = subsample(sentences, vocab)

这段代码是一个用于下采样高频词的函数。

函数的输入参数包括 sentences(句子列表)和 vocab(词表)。该函数的输出是一个经过下采样处理后的句子列表和词频统计计数器。

下采样的过程如下:

  1. 首先,函数排除了句子中的未知词元 '<unk>',即词表中未知词的标记。
  2. 然后,使用 count_corpus 函数统计句子中每个词元的频次,并计算语料中总的词元数。
  3. 定义了一个内部函数 keep(token),用于判断是否在下采样过程中保留词元。这里使用了一个阈值 1e-4 和词元的频次来决定是否保留词元。频次较高的词元将有较小的概率保留下来,而频次较低的词元将有较大的概率保留下来。
  4. 最后,函数遍历每个句子中的词元,根据 keep 函数的结果来决定是否保留该词元。保留的词元构成了经过下采样处理后的句子列表。

下面的代码片段绘制了下采样前后每句话的词元数量的直方图。正如预期的那样,下采样通过删除高频词来显著缩短句子,这将使训练加速。

d2l.show_list_len_pair_hist(
    ['origin', 'subsampled'], '# tokens per sentence',
    'count', sentences, subsampled);

在这里插入图片描述
我们看出句子的长度显著降低。对于单个词元,高频词“the”的采样率不到1/20。

def compare_counts(token):
    return (f'"{token}"的数量:'
            f'之前={sum([l.count(token) for l in sentences])}, '
            f'之后={sum([l.count(token) for l in subsampled])}')

compare_counts('the')

在这里插入图片描述
相比之下,低频词“join”则被完全保留。

compare_counts('join')

在这里插入图片描述
在下采样之后,我们将词元映射到它们在语料库中的索引。
在这里插入图片描述

中心词和上下文词的提取

下面的get_centers_and_contexts函数从corpus中提取所有中心词及其上下文词。它随机采样1到max_window_size之间的整数作为上下文窗口。对于任一中心词,与其距离不超过采样上下文窗口大小的词为其上下文词。

#@save
def get_centers_and_contexts(corpus, max_window_size):
    """返回跳元模型中的中心词和上下文词"""
    centers, contexts = [], []
    for line in corpus:
        # 要形成“中心词-上下文词”对,每个句子至少需要有2个词
        if len(line) < 2:
            continue
        centers += line
        for i in range(len(line)):  # 上下文窗口中间i
            window_size = random.randint(1, max_window_size)
            indices = list(range(max(0, i - window_size),
                                 min(len(line), i + 1 + window_size)))
            # 从上下文词中排除中心词
            indices.remove(i)
            contexts.append([line[idx] for idx in indices])
    return centers, contexts

函数的输入参数包括 corpusmax_window_sizecorpus 是一个包含多个句子的语料库,每个句子由单词列表表示。max_window_size 是一个整数,表示上下文窗口的最大大小。

函数的输出是两个列表,分别是中心词列表 centers 和上下文词列表 contexts

代码的逻辑如下:

  1. 创建空的中心词列表 centers 和上下文词列表 contexts
  2. 对于语料库中的每个句子 line,执行以下步骤:
    • 如果句子的长度小于 2,即句子中不足两个词,则跳过该句子。
    • 将句子中的所有单词添加到中心词列表 centers 中。
    • 对于句子中的每个词的索引 i,执行以下步骤:
      • 随机生成一个上下文窗口的大小 window_size,范围为 1 到 max_window_size
      • 计算上下文窗口的左边界索引为 max(0, i - window_size),右边界索引为 min(len(line), i + 1 + window_size)
      • 创建一个索引列表 indices,包含上下文窗口范围内的索引。
      • 从索引列表中移除中心词的索引 i,得到最终的上下文词索引列表。
      • 根据最终的上下文词索引列表,获取对应的上下文词,并将其添加到上下文词列表 contexts 中。
  3. 完成语料库中所有句子的处理后,返回中心词列表 centers 和上下文词列表 contexts

接下来,我们创建一个人工数据集,分别包含7个和3个单词的两个句子。设置最大上下文窗口大小为2,并打印所有中心词及其上下文词。

tiny_dataset = [list(range(7)), list(range(7, 10))]
print('数据集', tiny_dataset)
for center, context in zip(*get_centers_and_contexts(tiny_dataset, 2)):
    print('中心词', center, '的上下文词是', context)

在这里插入图片描述
在PTB数据集上进行训练时,我们将最大上下文窗口大小设置为5。下面提取数据集中的所有中心词及其上下文词。

all_centers, all_contexts = get_centers_and_contexts(corpus, 5)
f'# “中心词-上下文词对”的数量: {sum([len(contexts) for contexts in all_contexts])}'

在这里插入图片描述

负采样

我们使用负采样进行近似训练。为了根据预定义的分布对噪声词进行采样,我们定义以下RandomGenerator类,其中(可能未规范化的)采样分布通过变量sampling_weights传递。

sampling_weights具体来说是一个和population相同形状的列表,代表了population中对应元素被采样的概率

#@save
class RandomGenerator:
    """根据n个采样权重在{1,...,n}中随机抽取"""
    def __init__(self, sampling_weights):
        # Exclude
        self.population = list(range(1, len(sampling_weights) + 1))
        self.sampling_weights = sampling_weights
        self.candidates = []
        self.i = 0

    def draw(self):#每次调用函数缓存10000个采样,输出一个采样,i自增1,直到用完整个缓存继续采样
        if self.i == len(self.candidates):
            # 缓存k个随机采样结果
            self.candidates = random.choices(
                self.population, self.sampling_weights, k=10000)
            self.i = 0
        self.i += 1
        return self.candidates[self.i - 1]

这段代码定义了一个名为 RandomGenerator 的类,用于根据给定的采样权重在整数范围 {1, ..., n} 中进行随机抽取。

构造函数 __init__ 在创建类的实例时被调用,并接受一个采样权重列表 sampling_weights 作为参数。在构造函数内部,以下操作被执行:

  • 创建一个整数列表 population,其中包含了从 1 到 len(sampling_weights) 的整数范围,用于表示可能的抽样候选项。
  • 将采样权重列表存储在实例变量 sampling_weights 中。
  • 创建一个空的候选项列表 candidates,用于缓存采样结果。
  • 初始化一个索引变量 i,用于追踪当前采样的位置。

draw 方法用于执行随机抽取操作。方法的逻辑如下:

  • 首先,检查当前采样位置 i 是否等于候选项列表 candidates 的长度。如果相等,说明之前缓存的采样结果已经用完,需要重新生成一批新的采样结果。
  • 调用 random.choices 函数来生成 k=10000 个随机采样结果,其中 population 参数为整数范围 {1, ..., n}sampling_weights 参数为采样权重列表,k 参数表示生成的采样结果数量。生成的结果存储在候选项列表 candidates 中。
  • 将索引变量 i 增加 1,表示进行了一次新的采样。
  • 返回候选项列表中当前采样位置对应的元素。

这种设计的目的是为了提高采样效率。通过一次性生成一批采样结果并进行缓存,可以减少对 random.choices 函数的调用次数,提高整体的性能。每次调用 draw 方法时,通过逐个返回缓存的采样结果,避免了每次调用都进行一次完整的随机抽取操作。

例如,我们可以在索引1、2和3中绘制10个随机变量 X X X,采样概率为 P ( X = 1 ) = 2 / 9 , P ( X = 2 ) = 3 / 9 和 P ( X = 3 ) = 4 / 9 P(X=1)=2/9,P(X=2)=3/9和P(X=3)=4/9 P(X=1)=2/9,P(X=2)=3/9P(X=3)=4/9,如下所示。

#@save
generator = RandomGenerator([2, 3, 4])
[generator.draw() for _ in range(10)]

在这里插入图片描述
对于一对中心词和上下文词,我们随机抽取了K个(实验中为5个)噪声词。根据word2vec论文中的建议,将噪声词 w w w的采样概率 P ( w ) P(w) P(w)设置为其在字典中的相对频率,其幂为0.75 (Mikolov et al., 2013)。

#@save
def get_negatives(all_contexts, vocab, counter, K):
    """返回负采样中的噪声词"""
    # 索引为1、2、...(索引0是词表中排除的未知标记)
    sampling_weights = [counter[vocab.to_tokens(i)]**0.75
                        for i in range(1, len(vocab))]
    all_negatives, generator = [], RandomGenerator(sampling_weights)
    for contexts in all_contexts:
        negatives = []
        while len(negatives) < len(contexts) * K:
            neg = generator.draw()
            # 噪声词不能是上下文词
            if neg not in contexts:
                negatives.append(neg)
        all_negatives.append(negatives)
    return all_negatives

all_negatives = get_negatives(all_contexts, vocab, counter, 5)

函数接受以下参数:

  • all_contexts:表示所有的上下文词的列表或可迭代对象。
  • vocab:表示词汇表的对象。
  • counter:表示词频计数器的对象。
  • K:表示每个上下文词应采样的噪声词数量。

函数的逻辑如下:

  • 首先,根据词频计数器 counter 和词汇表 vocab,计算每个词的采样权重列表 sampling_weights。对于词汇表中的每个词(除了未知标记),使用词频的 0.75 次方作为权重值。这样可以使较低频次的词有更高的采样概率。
  • 创建一个空列表 all_negatives,用于存储每个上下文对应的噪声词列表。
  • 创建一个随机生成器对象 generator,并将前面计算得到的采样权重列表 sampling_weights 作为参数传递给生成器的构造函数。
  • 对于每个上下文词列表 contexts,执行以下操作:
    • 创建一个空列表 negatives,用于存储当前上下文词的噪声词。
    • 在噪声词列表中采样,直到噪声词的数量达到当前上下文词数量乘以 K。每次从随机生成器 generator 中使用 draw 方法进行采样,得到一个噪声词。
    • 检查采样得到的噪声词是否不在当前上下文词列表 contexts 中,如果满足条件,则将噪声词添加到 negatives 列表中。
    • negatives 列表添加到 all_negatives 列表中,表示当前上下文对应的噪声词列表。
  • 最后,函数返回 all_negatives,其中包含了每个上下文对应的噪声词列表。

小批量加载训练实例

在提取所有中心词及其上下文词和采样噪声词后,将它们转换成小批量的样本,在训练过程中可以迭代加载。
在小批量中, i t h i^{th} ith个样本包括中心词及其 n i n_i ni个上下文词和 m i m_i mi个噪声词。由于上下文窗口大小不同, n i + m i n_i+m_i ni+mi对于不同的 i i i是不同的。因此,对于每个样本,我们在contexts_negatives个变量中将其上下文词和噪声词连结起来,并填充零,直到连结长度达到 m a x ( n i + m i ) max(n_i+m_i) max(ni+mi)。为了在计算损失时排除填充,我们定义了掩码变量masks。在masks中的元素和contexts_negatives中的元素之间存在一一对应关系,其中masks中的0(否则为1)对应于contexts_negatives中的填充。

为了区分正反例,我们在contexts_negatives中通过一个labels变量将上下文词与噪声词分开。类似于masks,在labels中的元素和contexts_negatives中的元素之间也存在一一对应关系,其中labels中的1(否则为0)对应于contexts_negatives中的上下文词的正例。

上述思想在下面的batchify函数中实现。其输入data是长度等于批量大小的列表,其中每个元素是由中心词center、其上下文词context和其噪声词negative组成的样本。此函数返回一个可以在训练期间加载用于计算的小批量,例如包括掩码变量。

#@save
def batchify(data):
    """返回带有负采样的跳元模型的小批量样本"""
    #计算最大长度
    max_len = max(len(c) + len(n) for _, c, n in data)
    centers, contexts_negatives, masks, labels = [], [], [], []
    for center, context, negative in data:
        cur_len = len(context) + len(negative)
        centers += [center]
        contexts_negatives += \
            [context + negative + [0] * (max_len - cur_len)]
        masks += [[1] * cur_len + [0] * (max_len - cur_len)]
        labels += [[1] * len(context) + [0] * (max_len - len(context))]#负采样的部分也标注为0
    return (torch.tensor(centers).reshape((-1, 1)), torch.tensor(
        contexts_negatives), torch.tensor(masks), torch.tensor(labels))

函数接受一个数据列表 data,其中每个元素包含了中心词、上下文词列表和负采样的噪声词列表。

函数的逻辑如下:

  • 首先,计算所有样本中上下文词列表和负采样噪声词列表的最大长度 max_len。这将用于确定小批量样本的张量形状。

  • 创建四个空列表:centers 用于存储中心词,contexts_negatives 用于存储拼接后的上下文词列表和负采样噪声词列表,masks 用于存储掩码张量,labels 用于存储标签张量。

  • 对于数据列表 data 中的每个元组 (center, context, negative),执行以下操作:

    • 计算当前样本中上下文词列表和负采样噪声词列表的长度 cur_len
    • 将中心词 center 添加到 centers 列表中。
    • 将上下文词列表、负采样噪声词列表和填充的零元素拼接成一个长度为 max_len 的列表,并将结果添加到 contexts_negatives 列表中。
    • 创建一个掩码张量,**其中上下文词部分和负采样的部分为 1,填充部分为 0,**并将结果添加到 masks 列表中。
    • 创建一个标签张量,**其中上下文词部分为 1,负采样噪声词和填充部分为 0,**并将结果添加到 labels 列表中。
  • 最后,函数将四个列表转换为张量,并返回一个包含中心词张量、上下文词列表和负采样噪声词列表的元组。

让我们使用一个小批量的两个样本来测试此函数。

x_1 = (1, [2, 2], [3, 3, 3, 3])
x_2 = (1, [2, 2, 2], [3, 3])
batch = batchify((x_1, x_2))

names = ['centers', 'contexts_negatives', 'masks', 'labels']
for name, data in zip(names, batch):
    print(name, '=', data)

在这里插入图片描述

整合所有代码

#@save
def load_data_ptb(batch_size, max_window_size, num_noise_words):
    """下载PTB数据集,然后将其加载到内存中"""
    num_workers = 0;
    #获取数据集
    sentences = read_ptb()
    #生成词表
    vocab = d2l.Vocab(sentences, min_freq=10)
    #下采样
    subsampled, counter = subsample(sentences, vocab)
    #采样结果转索引
    corpus = [vocab[line] for line in subsampled]
    #获取所有中心词和上下文词
    all_centers, all_contexts = get_centers_and_contexts(
        corpus, max_window_size)
    #获取所有噪声词
    all_negatives = get_negatives(
        all_contexts, vocab, counter, num_noise_words)
	# 定义数据集
    class PTBDataset(torch.utils.data.Dataset):
        def __init__(self, centers, contexts, negatives):
            assert len(centers) == len(contexts) == len(negatives)
            self.centers = centers
            self.contexts = contexts
            self.negatives = negatives

        def __getitem__(self, index):
            return (self.centers[index], self.contexts[index],
                    self.negatives[index])

        def __len__(self):
            return len(self.centers)

    dataset = PTBDataset(all_centers, all_contexts, all_negatives)
	
    data_iter = torch.utils.data.DataLoader(
        dataset, batch_size, shuffle=True,
        collate_fn=batchify, num_workers=num_workers)
    return data_iter, vocab

注意:collate_fn=batchify
collate_fn是torch.utils.data.DataLoader类的一个可选参数,用于指定在加载每个小批量样本时要使用的函数。
当collate_fn参数被指定时,DataLoader会在每个小批量样本加载时调用这个函数,并将单个样本作为输入。collate_fn函数负责对单个样本进行处理,并将它们组合成一个小批量样本。
通常情况下,collate_fn函数用于将单个样本转换为张量形式,并根据需要进行填充或其他数据转换操作。这样可以确保每个小批量样本具有相同的形状,以便于输入到模型进行训练或推理。

让我们打印数据迭代器的第一个小批量。

data_iter, vocab = load_data_ptb(512, 5, 5)
for batch in data_iter:
    for name, data in zip(names, batch):
        print(name, 'shape:', data.shape)
    break

训练word2vec

使用上述定义的函数获取数据集

batch_size, max_window_size, num_noise_words = 512, 5, 5
data_iter, vocab = d2l.load_data_ptb(batch_size, max_window_size,
                                     num_noise_words)

跳元模型

嵌入层

嵌入层(Embedding Layer)是深度学习中常用的一种层类型,用于将离散的符号(如词、字符等)表示为连续的向量形式,也被称为词嵌入或向量表示。

在自然语言处理(NLP)任务中,文本数据通常以离散的符号形式表示,如词汇表中的单词。嵌入层可以将这些离散符号映射到连续的低维向量空间中,其中每个维度代表了一个语义特征。嵌入层的目的是通过学习这种映射关系,将相似的符号映射到相近的向量表示,从而捕捉到词之间的语义关系。

嵌入层通常通过一个可训练的参数矩阵来实现。该参数矩阵的维度是词汇表大小(词的数量)乘以嵌入向量的维度。在训练过程中,这些嵌入向量会根据模型的优化目标逐渐调整,以最大程度地捕捉到词汇之间的语义关系。

嵌入层在深度学习中的应用非常广泛,特别是在NLP任务中。它可以作为模型的第一层,将输入的离散符号(例如单词或字符)转换为密集向量表示,进而输入到后续层进行进一步处理,如循环神经网络(RNN)或卷积神经网络(CNN)等。

嵌入层将词元的索引映射到其特征向量。该层的权重是一个矩阵,其行数等于字典大小(input_dim),列数等于每个标记的向量维数(output_dim)。在词嵌入模型训练之后,这个权重就是我们所需要的。

# 创建嵌入层的参数矩阵
embedding_weights = torch.randn(vocab_size, embedding_dim)

我们可以使用torch中的Embedding创建嵌入层

embed = nn.Embedding(num_embeddings=20, embedding_dim=4)
print(f'Parameter embedding_weight ({embed.weight.shape}, '
      f'dtype={embed.weight.dtype})')

嵌入层的输入是词元(词)的索引。对于任何词元索引 i i i,其向量表示可以从嵌入层中的权重矩阵的第 i i i行获得。由于向量维度(output_dim)被设置为4,因此当小批量词元索引的形状为(2,3)时,嵌入层返回具有形状(2,3,4)的向量。

x = torch.tensor([[1, 2, 3], [4, 5, 6]])
embed(x)

在这里插入图片描述

定义前向传播

在前向传播中,跳元语法模型的输入包括形状为(批量大小,1)的中心词索引center和形状为(批量大小,max_len)的上下文与噪声词索引contexts_and_negatives,这两个变量首先通过嵌入层从词元索引转换成向量,然后它们的批量矩阵相乘,返回形状为(批量大小,1,max_len)的输出。输出中的每个元素是中心词向量和上下文或噪声词向量的点积。

跳元的定义:

def skip_gram(center, contexts_and_negatives, embed_v, embed_u):
    v = embed_v(center)
    u = embed_u(contexts_and_negatives)
    pred = torch.bmm(v, u.permute(0, 2, 1))#批量矩阵乘法
    return pred

在给定中心词center和上下文词汇contexts_and_negatives的情况下,代码使用embed_v和embed_u分别对中心词和上下文词汇进行嵌入操作。然后,通过调用torch.bmm函数执行批量矩阵乘法,计算中心词向量v与上下文词汇向量u的转置之间的乘积。最终,返回预测结果pred。

值得注意的是,torch.bmm函数接受的输入张量的形状需满足要求。在这个例子中,v的形状应为(batch_size, 1, embedding_dim),u的形状应为(batch_size, num_negatives + num_contexts, embedding_dim),其中batch_size表示批量大小,embedding_dim表示词向量维度,num_negatives表示负样本数量,num_contexts表示上下文词汇数量。

skip_gram(torch.ones((2, 1), dtype=torch.long),
          torch.ones((2, 4), dtype=torch.long), embed, embed).shape

在这里插入图片描述

训练

在训练带负采样的跳元模型之前,我们先定义它的损失函数。

二元交叉熵损失

交叉熵(Cross-Entropy)是一种常用的损失函数,常用于分类任务中。它用于衡量模型的输出与目标标签之间的差异。
CrossEntropy = − ∑ i = 1 N ∑ j = 1 K y i j log ⁡ ( p i j ) \text{CrossEntropy} = -\sum_{i=1}^{N}\sum_{j=1}^{K} y_{ij} \log(p_{ij}) CrossEntropy=i=1Nj=1Kyijlog(pij)
其中, y i j y_{ij} yij表示第i个样本的真实标签的第j个元素(0或1), p i j p_{ij} pij表示模型预测的第i个样本属于第j个类别的概率。

二元交叉熵损失(Binary Cross-Entropy Loss)是交叉熵损失函数在二分类问题中的特殊形式。它用于衡量二分类模型的预测结果与真实结果之间的差异。

在二分类问题中,我们有两个类别,通常将它们表示为"正例"(positive)和"反例"(negative)。对于每个样本,我们用一个标签值来表示其真实类别,通常为0或1,其中0表示反例,1表示正例。模型给出的预测结果是一个介于0和1之间的概率值,表示样本属于正例的概率。

二元交叉熵损失的计算公式如下:
H ( p , q ) = − 1 N ∑ i = 1 N [ y i log ⁡ ( p i ) + ( 1 − y i ) log ⁡ ( 1 − p i ) ] H(p, q) = -\frac{1}{N}\sum_{i=1}^{N}[y_{i}\log(p_{i}) + (1-y_{i})\log(1-p_{i})] H(p,q)=N1i=1N[yilog(pi)+(1yi)log(1pi)]
其中, N N N表示样本数量, y i y_i yi表示第 i i i个样本的真实标签(取值为0或1), p i p_i pi表示预测为类别1的概率。

为什么使用二元交叉熵损失?
我们回顾上一节我们计算出的损失的函数:

在这里插入图片描述
观察到和二元交叉熵损失非常相似,其中中心词的上下文词被视为正例,而其他随机选择的词(负采样部分)则被视为负例。

对于每个训练样本,我们将中心词作为输入,然后使用模型进行预测。预测结果是一个介于0和1之间的概率值,表示上下文词是正例的概率。然后,我们使用二元交叉熵损失函数来计算预测结果与真实结果之间的差异,并通过优化算法(如梯度下降)来最小化这个损失函数。

class SigmoidBCELoss(nn.Module):
    # 带掩码的二元交叉熵损失
    def __init__(self):
        super().__init__()

    def forward(self, inputs, target, mask=None):
        out = nn.functional.binary_cross_entropy_with_logits(
            inputs, target, weight=mask, reduction="none")
        return out.mean(dim=1)

loss = SigmoidBCELoss()

在forward方法中,调用了nn.functional.binary_cross_entropy_with_logits函数,该函数是PyTorch中用于计算二元交叉熵损失的函数。它接收模型的输出(未经过Sigmoid函数)、真实标签和掩码作为输入,并返回一个张量,其中包含每个样本的损失值。

nn.functional.binary_cross_entropy_with_logits 是 PyTorch 中用于计算二分类问题的交叉熵损失函数。它可以在处理具有二元标签的分类任务时非常有用。该函数的输入是模型的输出 logits 和对应的目标标签,它会自动将 logits 通过 sigmoid 函数转换为概率,并计算预测概率与目标标签之间的交叉熵损失。

以下是 nn.functional.binary_cross_entropy_with_logits 函数的实现原理:

  1. 首先,输入的 logits 应该是一个具有任意形状的张量,通常来自于模型的输出层,没有经过 sigmoid 函数处理。

  2. 函数内部会将 logits 通过 sigmoid 函数转换为概率,即将 logits 的每个元素 x 转换为 p = 1 / (1 + exp(-x))。

  3. 然后,函数会将目标标签 target 转换为与 logits 相同的形状,以便进行逐元素的比较。

  4. 接下来,函数会计算每个预测概率 p 与对应的目标标签 t 之间的交叉熵损失,使用上述公式计算

  5. 最后,函数会返回所有样本的平均损失,即将每个样本的损失相加并除以样本总数(如果 reduction 参数设置为 'mean')。

pred = torch.tensor([[1.1, -2.2, 3.3, -4.4]] * 2)
label = torch.tensor([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]])
mask = torch.tensor([[1, 1, 1, 1], [1, 1, 0, 0]])
loss(pred, label, mask) * mask.shape[1] / mask.sum(axis=1)

在这里插入图片描述
计算过程:

def sigmd(x):
    return -math.log(1 / (1 + math.exp(-x)))

print(f'{(sigmd(1.1) + sigmd(2.2) + sigmd(-3.3) + sigmd(4.4)) / 4:.4f}')
print(f'{(sigmd(-1.1) + sigmd(-2.2)) / 2:.4f}')

在这里插入图片描述

初始化模型参数

我们定义了两个嵌入层,将词表中的所有单词分别作为中心词和上下文词使用。字向量维度embed_size被设置为100。

embed_size = 100
net = nn.Sequential(nn.Embedding(num_embeddings=len(vocab),
                                 embedding_dim=embed_size),
                    nn.Embedding(num_embeddings=len(vocab),
                                 embedding_dim=embed_size))

定义训练阶段代码

训练阶段代码实现定义如下。由于填充的存在,损失函数的计算与以前的训练函数略有不同。

def train(net, data_iter, lr, num_epochs, device=d2l.try_gpu()):
    def init_weights(m):
        if type(m) == nn.Embedding:
            nn.init.xavier_uniform_(m.weight)
    net.apply(init_weights)
    net = net.to(device)
    optimizer = torch.optim.Adam(net.parameters(), lr=lr)
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[1, num_epochs])
    # 规范化的损失之和,规范化的损失数
    metric = d2l.Accumulator(2)
    for epoch in range(num_epochs):
        timer, num_batches = d2l.Timer(), len(data_iter)
        for i, batch in enumerate(data_iter):
            optimizer.zero_grad()
            center, context_negative, mask, label = [
                data.to(device) for data in batch]

            pred = skip_gram(center, context_negative, net[0], net[1])
            l = (loss(pred.reshape(label.shape).float(), label.float(), mask)
                     / mask.sum(axis=1) * mask.shape[1])
            l.sum().backward()
            optimizer.step()
            metric.add(l.sum(), l.numel())
            if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
                animator.add(epoch + (i + 1) / num_batches,
                             (metric[0] / metric[1],))
    print(f'loss {metric[0] / metric[1]:.3f}, '
          f'{metric[1] / timer.stop():.1f} tokens/sec on {str(device)}')

现在,我们可以使用负采样来训练跳元模型。

lr, num_epochs = 0.002, 5
train(net, data_iter, lr, num_epochs)

在这里插入图片描述

应用词嵌入

在训练word2vec模型之后,我们可以使用训练好模型中词向量的余弦相似度来从词表中找到与输入单词语义最相似的单词。

def get_similar_tokens(query_token, k, embed):
    W = embed.weight.data
    x = W[vocab[query_token]]
    # 计算余弦相似性。增加1e-9以获得数值稳定性
    cos = torch.mv(W, x) / torch.sqrt(torch.sum(W * W, dim=1) *
                                      torch.sum(x * x) + 1e-9)
    topk = torch.topk(cos, k=k+1)[1].cpu().numpy().astype('int32')
    for i in topk[1:]:  # 删除输入词
        print(f'cosine sim={float(cos[i]):.3f}: {vocab.to_tokens(i)}')

get_similar_tokens('chip', 3, net[0])

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/94729.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

如何通过内网穿透实现外部网络对Spring Boot服务端接口的HTTP监听和调试?

文章目录 前言1. 本地环境搭建1.1 环境参数1.2 搭建springboot服务项目 2. 内网穿透2.1 安装配置cpolar内网穿透2.1.1 windows系统2.1.2 linux系统 2.2 创建隧道映射本地端口2.3 测试公网地址 3. 固定公网地址3.1 保留一个二级子域名3.2 配置二级子域名3.2 测试使用固定公网地址…

【VLDB 2023】基于预测的云资源弹性伸缩框架MagicScaler,实现“高QoS,低成本”双丰收

开篇 近日&#xff0c;由阿里云计算平台大数据基础工程技术团队主导&#xff0c;与计算平台MaxCompute团队、华东师范大学数据科学与工程学院、达摩院合作&#xff0c;基于预测的云计算平台资源弹性伸缩框架论文《MagicScaler: Uncertainty-aware, Predictive Autoscaling 》被…

linux操作系统的权限的深入学习

1.Linux权限的概念 Linux下有两种用户&#xff1a;超级用户&#xff08;root&#xff09;、普通用户。 超级用户&#xff1a;可以再linux系统下做任何事情&#xff0c;不受限制 普通用户&#xff1a;在linux下做有限的事情。 超级用户的命令提示符是“#”&#xff0c;普通用户…

架构师日记-软件工程里的组织文化 | 京东云技术团队

一 引言 本文是京东到家自动化测试体系建设过程中的一些回顾和总结&#xff0c;删减了部分系统设计与实践的章节&#xff0c;保留了组织与文化相关的内容&#xff0c;整理成文&#xff0c;以飨读者。 下面就以QA&#xff08;Quality Assurance&#xff09;的视角来探讨工作中经…

Git分支机制

一、分支机制简述 要想真正理解Git的分支机制&#xff0c;我们要首先回过头来看一下Git是如何存储数据的。 Git并没有采用多个变更集( changeset )或是差异的方式存储数据&#xff0c;而是采用一系列快照的方式。当你发起提交时&#xff0c;Git存储的是提交对象( commi…

fastadmin iis伪静态应用入口文件index.php

<?xml version"1.0" encoding"UTF-8"?> <configuration><system.webServer><rewrite><rules><rule name"OrgPage" stopProcessing"true"><match url"^(.*)$" /><conditions…

开始MySQL之路——外键关联和多表联合查询详细概述

多表查询和外键关联 实际开发中&#xff0c;一个项目通常需要很多张表才能完成。例如&#xff0c;一个商城项目就需要分类表&#xff0c;商品表&#xff0c;订单表等多张表。且这些表的数据之间存在一定的关系&#xff0c;接下来我们将在单表的基础上&#xff0c;一起学习多表…

抖音seo矩阵系统源代码开发部署分享

一、 开发步骤分享 抖音SEO矩阵系统源代码开发部署分享&#xff0c;需要经验丰富的开发人员和服务器管理人员&#xff0c;以下是大致的步骤&#xff1a; 确定你需要的功能和设计&#xff0c;确定开发人员和设计师的角色和任务分配&#xff0c;以及开发进度和计划。 确定服务器…

java+ssm+mysql农场信息管理系统

项目介绍&#xff1a; 本系统为基于jspssmmysql的农场信息管理系统&#xff0c;功能如下&#xff1a; 用户&#xff1a;注册登录系统&#xff0c;菜地信息管理&#xff0c;农作物信息管理&#xff0c;种植信息管理&#xff0c;客户信息管理&#xff0c;商家信息管理&#xff…

《Flink学习笔记》——第四章 Flink运行时架构

4.1 系统架构 Flink运行时架构 Flink 运行时由两种类型的进程组成&#xff1a;一个 JobManager 和一个或者多个 TaskManager。 1、作业管理器&#xff08;JobManager&#xff09; JobManager是一个Flink集群中任务管理和调度的核心&#xff0c;是控制应用执行的主进程。也就…

花5分钟判断,你的Jmeter技能是大佬还是小白!

jmeter 这个工具既可以做接口的功能测试&#xff0c;也可以做自动化测试&#xff0c;还可以做性能测试&#xff0c;其主要用途就是用于性能测试。但是&#xff0c;有些公司和个人&#xff0c;就想用 jmeter 来做接口自动化测试。 你有没有想过呢&#xff1f; 下面我就给大家讲…

Redis之集群模式

一、Redis集群 一个节点就是一个运行在集群模式下的Redis服务器&#xff0c;Redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式。 Redis节点不会互相发现&#xff0c;连接各个节点的工作需要使用cluster meet命令来完成 CLUSTER MEE…

代码随想录算法训练营day42 | 01背包问题,416. 分割等和子集

目录 01背包问题 416. 分割等和子集 01背包问题 416. 分割等和子集 类型&#xff1a;动态规划&#xff0c;01背包 难度&#xff1a;medium 思路: 经典的01背包问题&#xff0c;背包容量为sum/2, 每个物品的重量为nums[i],其价值也为nums[i]。 需要注意的是&#xff0c;如果…

【PLSQL】PLSQL基础

文章目录 一&#xff1a;记录类型1.语法2.代码实例 二&#xff1a;字符转换三&#xff1a;%TYPE和%ROWTYPE1.%TYPE2.%ROWTYPE 四&#xff1a;循环1.LOOP2.WHILE&#xff08;推荐&#xff09;3.数字式循环 五&#xff1a;游标1.游标定义及读取2.游标属性3.NO_DATA_FOUND和%NOTFO…

基于SSM的旅游管理系统jsp房源信息java源代码Mysql

本项目为前几天收费帮学妹做的一个项目&#xff0c;Java EE JSP项目&#xff0c;在工作环境中基本使用不到&#xff0c;但是很多学校把这个当作编程入门的项目来做&#xff0c;故分享出本项目供初学者参考。 一、项目描述 基于SSM的旅游管理系统 系统有2权限&#xff1a;管理…

【Axure教程】调用日期选择器并筛选中继器表格

今天教大家在Axure里怎么调用代码调用浏览器的日期选择器并对对中继器表格进行日期区间的筛选。调用浏览器日期选择器的好处是&#xff0c;可以选择真实的日期&#xff0c;包括某年某月某日是星期几&#xff0c;哪个二月是29天……都是真实的&#xff0c;那不同的浏览器日期选择…

Linux内核学习(十二)—— 页高速缓存和页回写(基于Linux 2.6内核)

目录 一、缓存手段 二、Linux 页高速缓存 三、flusher 线程 Linux 内核实现了一个被叫做页高速缓存&#xff08;page cache&#xff09;的磁盘缓存&#xff0c;它主要用来减少对磁盘的 I/O 操作。它是通过把磁盘中的数据缓存到内存中&#xff0c;把对磁盘的访问变为对物理内…

Markdown 扩展语法练习

风无痕 August 26, 2023 Markdown 指南中文版 Markdown 入门指南Markdown 基本语法Markdown 扩展语法Markdown 基本语法练习Markdown 扩展语法练习 代码 <h3 id"table">表格</h3>| Syntax | Description | | --- | --- | | Header | Title | | Paragrap…

抖店商品怎么让达人带货?说下找达人技巧和寄样后的操作,可收藏

我是王路飞。 找达人带货的玩法是公认出单快、易爆单、长久稳定的出单方式。 虽然新手可能感觉要给达人佣金&#xff0c;自己利润会降低&#xff0c;但是这种玩法可以让你快速入门&#xff0c;且能长久玩下去。 尤其是现在抖音直播间的产品全都是来自抖音小店的&#xff0c;…

多线程基础篇

我们平常说的一个程序&#xff0c;一个程序中有声音&#xff0c;图片&#xff0c;字幕 实际上是一个进程中有多个线程 main线程是主线程。 多核&#xff0c;多个cpu&#xff0c;多个线程&#xff0c;切换的很快 单核的话是一个cpu,某一时间只能是一个线程&#xff0c;但是因为…