词向量表示
调包实现word2vec
实验介绍
[!question] 借助gensim工具包,利用给定的训练语料训练word2vec(CBOW)
训练后实现:
(1)自选5对词语相似度计算
(2)任选5个词,找出与他们最接近的5个语义相似的词
(3)计算爸爸-男人+女人=?
词的表示方法:
由于机器学习方法通常只能接受向量作为输入,因此在NLP中使用机器学习方法时,往往需要将词表示为向量。向量表示更为规范,通常由固定的维度,并且易于进行机器学习算法中的各类运算
- 独热编码:最简单的方式,但是会带来维度灾难,且无法表示词与词之间的相关性
- 分布式表示
- 稀疏向量表示:词-词共现矩阵
- 稠密向量表示:传统方法是基于SVD的潜在语义分析,近期方法有word2vec和Glove,现在常用上下文相关词嵌入
实验1只需要调用gensim
模块实现即可,该模块封装了Word2Vec模型,可以自由选择调用skip-gram和CBOW,同时还有其他参数可供调节
代码
1 | from gensim.models import Word2Vec |
注意事项
- 读取文件时,首先要按照换行符切割成每行单独的列表,然后再按照空格对每行分别处理,形成每行按照词进行分割的二维列表
- 这里也可以使用
gensim
提供的处理函数simple_preprocess
函数,效果和按照空格切割类似,只是还会舍弃一些停用词 - 训练模型时如果觉得效果不好,可以尝试修改超参数
实验结果
句子的向量表示
实验介绍
[!question] 利用
transformers
中的Bert和GPT2包,任选5对中文与英文句子计算相似度
句子同样也可以像词一样变成向量表示,通过bert、gpt等预训练模型,可将输入的句子转化为向量,从而具有可运算的性质
BERT和GPT是两种基于Transformer架构的预训练模型,BERT侧重于理解句⼦中的上下⽂和含义,适合词语级别的任务;⽽GPT则专注于⽣成连贯的⽂本,适⽤于⽣成式任务。两者在训练⽅式、任务⽬标和适⽤场景上有所不同,BERT使⽤掩码语⾔模型和下⼀句预测,GPT采⽤⾃回归语⾔模型。
注意:Bert和GPT2都是动态词向量(上下文嵌入),每个词的向量表示不是固定的,而是根据上下文变动
⽂本相似度计算思路
⾸先使⽤BERT的分词器对输⼊的单词进⾏编码,然后将编码后的数据输⼊到BERT模型中获取嵌⼊向量。随后,对这些向量进⾏平均池化处理以获得更加稳定的特征表⽰,最后通过余弦相似度函数计算两个嵌⼊向量之间的相似度。这种⽅法结合了BERT的深层语义理解能⼒和余弦相似度的直观度量⽅式,能有效地评估两个⽂本之间的语义接近程度。
⽂本编码
在BERT中,⽂本⾸先通过⼀个分词器(Tokenizer)处理,该分词器将原始输⼊⽂本转换为模型可以理解的格式,包括将单词转换为词汇表中的索引、添加特殊的分隔符(如[CLS]和[SEP]),以及⽣成对应的注意⼒掩码(Attention Mask)。这⼀步是处理⽂本数据的关键,它直接关系到后续模型能否正确理解和处理输⼊数据。
嵌⼊向量的获取
通过BERT模型对编码后的⽂本进⾏处理后,可以获取到每个输⼊token的嵌⼊表⽰。这些表⽰是在模型的多层⽹络结构中⽣成的,其中每⼀层都通过⾃注意⼒机制和前馈⽹络计算得到新的表⽰。在⾃然语⾔处理任务中,通常使⽤模型最后⼀层的输出作为最终的特征表⽰,因为它们包含了经过多层处理后的⾼级语义信息。
余弦相似度的计算
余弦相似度是⼀种常⽤的相似度度量⽅式,它通过计算两个向量的夹⻆余弦值来评估它们的相似度。在⽂本处理中,将两个⽂本的嵌⼊向量进⾏余弦相似度计算,可以得到⼀个介于-1和1之间的标量值,表⽰这两个⽂本在语义上的接近程度。值越接近1,表⽰语义相似度越⾼;值越接近-1,表⽰语义差异越⼤。
代码
Bert 中文相似度
model.to(device)
将模型移动到指定的设备(GPU 或 CPU)model.eval()
将模型设置为评估模式(关闭 dropout 等训练时的特殊操作)- **
tokenizers(sentence, return_tensors='pt', truncation=True, max_length=128)
**:- 将输入的句子分词,并转换为模型可以处理的张量格式。
return_tensors='pt'
:返回 PyTorch 张量。truncation=True
:如果句子长度超过max_length
,则截断。max_length=128
:设置句子的最大长度为 128。
- **
inputs = {k: v.to(device) for k,v in inputs.items()}
**:- 将输入数据移动到指定的设备(GPU 或 CPU)。
- **
with torch.no_grad():
**:- 禁用梯度计算,因为在评估模式下不需要计算梯度。
- **
outputs = model(**inputs)
**:- 将输入数据传递给 Bert/GPT-2 模型,得到输出。
- **
cls_embedding = outputs.last_hidden_state[:, 0, :]
**:- 提取模型输出的最后一层隐藏状态(
last_hidden_state
),并取第一个 token([:, 0, :]
)作为句子的嵌入表示。 - 这里假设第一个 token 是句子的整体表示(类似于 BERT 的
[CLS]
token)。
- 提取模型输出的最后一层隐藏状态(
- **
return cls_embedding.cpu().numpy()
**:- 将句子的嵌入表示从 GPU 移动到 CPU,并转换为 NumPy 数组。
cosine_similarity
返回(1,1)的矩阵,需要用[1][1]
进行提取
1 | import torch |
Bert 英文相似度
没有多少区别,只需要把model_name
更换,加载英文模型即可
1 | # 英文Bert |
GPT2 英文相似度
只需要把Bert的加载模型换成以下即可:
1 | import torch |
不过看英文的相似度的时候,感觉英文句子之间的相似度似乎都很高。。。
注意 **input
的作用
在 Python 中,**inputs
是一种特殊的语法,用于将字典解包为关键字参数(keyword arguments)。具体来说,它会将字典中的键值对解包为函数的命名参数。
**inputs
的作用
假设 inputs
是一个字典,例如:
1 | inputs = { |
那么 **inputs
会将这个字典解包为:
1 | input_ids=tensor1, attention_mask=tensor2 |
- 代码中的具体应用
在代码中:
1 | outputs = model(**inputs) |
model
是 GPT-2 模型,它的forward
方法需要一些命名参数,例如input_ids
和attention_mask
。inputs
是一个字典,包含了这些参数:1
2
3
4inputs = {
'input_ids': tensor1, # 输入的 token IDs
'attention_mask': tensor2 # 注意力掩码
}**inputs
会将字典解包为:1
model(input_ids=tensor1, attention_mask=tensor2)
- 为什么需要
**inputs
?
- GPT-2/Bert 模型的
forward
方法需要明确的命名参数(如input_ids
和attention_mask
),而不是一个字典。 - 使用
**inputs
可以方便地将字典解包为命名参数,避免手动写:
1 | outputs = model(input_ids=inputs['input_ids'], attention_mask=inputs['attention_mask']) |
- 示例
假设 inputs
是以下字典:
1 | inputs = { |
那么 model(**inputs)
等价于:
1 | model(input_ids=torch.tensor([[1, 2, 3]]), attention_mask=torch.tensor([[1, 1, 1]])) |
封装 word2vec
[!question] 这个task需要将给的 word2vec.py 拆开封装,至少包含 configs, models, dataloader, trainers, main等部分
为了养成良好的代码管理习惯,封装是非常重要的。
在github上,我们通常会看到代码往往不会都写在一个文件中,而是拆开了多个模块,通过调用接口来实现功能。这样的好处是便于进行修改和debug
深度学习的代码,通常包括几个文件/文件夹:
- Models:用于存放所有模型代码
- Algorithm:用于存放所有算法代码
- Trainers:用于存放训练/测试流程代码
- Dataloader:用于存放读写数据/数据预处理部分代码
- Configs:用于存放所有超参数设置部分
- Checkpoints:用于存放模型参数
- Main:用于存放主要流程
此外,通常会有utils文件夹和models文件夹用于存放一些工具和模块
代码处理
我将代码重构成了以下文件,使用tree
命令查看
1 | word2vec:. |
models
里面存放cbow和ckip-gram代码,此处与CBOW为例,skip-gram类似:
1 | import torch |
其中,里面的forward
方法需要注意一下,以CBOW为例:
输入参数
context_words
: 上下文词的索引,形状为(batch_size, context_size)
target_words
: 目标词的索引,形状为(batch_size)
negative_words
: 负采样的词索引,形状为(batch_size, num_negatives)
步骤1:嵌入层操作
上下文嵌入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26context_emb = self.input_embeddings(context_words) # (batch, context_size, embed_dim)
```
- 将上下文词通过输入嵌入层转换为向量,得到形状 `(batch_size, context_size, embedding_dim)`。
- **取平均**:
```python
context_emb = torch.mean(context_emb, dim=1) # (batch, embed_dim)
```
- 对上下文词的嵌入取平均,得到单个向量表示整个上下文,形状变为 `(batch_size, embedding_dim)`。
2. **目标词和负样本嵌入**
```python
target_emb = self.output_embeddings(target_words) # (batch, embed_dim)
negative_emb = self.output_embeddings(negative_words) # (batch, neg, embed_dim)
```
- 目标词和负样本通过输出嵌入层转换为向量,形状分别为 `(batch_size, embedding_dim)` 和 `(batch_size, num_negatives, embedding_dim)`。
#### **步骤2:计算正样本损失**
```python
positive_score = torch.sum(context_emb * target_emb, dim=1) # (batch)
positive_loss = torch.log(torch.sigmoid(positive_score) + 1e-10)
- 分数计算:上下文向量与目标词向量的逐元素乘积之和(点积),表示两者的相似性。
- 损失转换:通过
sigmoid
将分数映射到(0,1)区间,取对数并添加极小值(1e-10
)防止数值溢出。
步骤3:计算负样本损失
1 | negative_score = torch.bmm(negative_emb, context_emb.unsqueeze(2)).squeeze() # (batch, neg) |
- 分数计算:
context_emb.unsqueeze(2)
扩展维度为(batch, embed_dim, 1)
。torch.bmm
(批量矩阵乘法)计算每个负样本与上下文向量的点积,得到形状(batch_size, num_negatives)
。
- 损失转换:
- 对负样本分数取负号(因为希望负样本得分低),通过
sigmoid
和log
计算损失,并对所有负样本求和。
- 对负样本分数取负号(因为希望负样本得分低),通过
步骤4:总损失
1 | loss = -(positive_loss + negative_loss).mean() |
- 合并正负样本损失,取负数(因优化器默认最小化损失),最后取批次平均值。
CBOW与Skip-gram关键区别
步骤 | CBOW | Skip-gram |
---|---|---|
输入处理 | 上下文词取平均得到单个向量 | 直接使用中心词向量 |
负样本计算维度 | (batch, neg, embed_dim) @ (batch, embed_dim, 1) |
同左 |
目标对象 | 从上下文预测中心词 | 从中心词预测上下文 |
数学原理
- 正样本损失:最大化目标词与上下文(CBOW)或中心词(Skip-gram)的相似性。
$$
\text{positive_loss} = \log \sigma(\mathbf{v}{\text{context}} \cdot \mathbf{v}{\text{target}})
$$ - 负样本损失:最小化负样本与上下文的相似性。
$$
\text{negative_loss} = \sum_{i=1}^{k} \log \sigma(-\mathbf{v}{\text{context}} \cdot \mathbf{v}{\text{negative}_i})
$$ - 总损失:
$$
\mathcal{L} = -\left(\text{positive_loss} + \text{negative_loss}\right)
$$
代码设计要点
- 双嵌入层:
input_embeddings
和output_embeddings
分别用于输入词和输出词(即老师课上讲的中心词和上下文词向量,根据模型不同有所区别),增强模型表达能力。 - 负采样加速:通过批量矩阵乘法(
torch.bmm
)高效计算多个负样本的分数。 - 数值稳定性:添加
1e-10
避免对零取对数。 - 损失符号:负号将极大似然估计转换为最小化问题,与优化器兼容。
utils代码
里面没有做太多的修改
config.py
存放配置文件,使用Config
类表示,同时使用argprase
模块便于处理不同的参数情况dataloader.py
存放读取数据、构建词汇表、定义数据集的代码metrics.py
存放余弦相似度计算与查找最相近词代码negative_sampler.py
存放负采样分布生成的代码和负采样器代码
详细说明关于负采样分布部分:
1 | import numpy as np |
1. 负采样的目的
在Word2Vec中,原始的损失函数需要计算所有词汇的softmax概率,这会导致计算复杂度与词汇表大小成正比。例如,若词汇表有10万词,每次预测都需要计算10万次点积,效率极低。负采样(Negative Sampling) 通过以下方式优化:
- 简化计算:将多分类问题转换为二分类问题,只计算正样本和少量负样本的得分。
- 加速训练:避免遍历整个词表,大幅减少计算量。
2. 负采样分布的数学原理
负采样的核心是如何选择负样本。直接按原始词频采样会导致高频词被过度选择(如“的”、“是”),而低频词几乎被忽略。为解决这一问题,采用修正的词频分布:
- 幂次调整(Subsampling):对词频 $f(w)$ 取 $f(w)^{0.75}$,公式为:
$$P(w) = \frac{f(w)^{0.75}}{\sum_{w’} f(w’)^{0.75}}$$ - 作用:
- 降低高频词的采样概率。
- 提升低频词的采样机会,平衡数据分布。
3. 代码解析
(1) 生成负采样分布 get_negative_sampling_distribution
- 输入:
word_counts
是每个词在训练语料中的出现次数。 - 步骤:
- 幂次调整:将词频 $f(w)$ 转换为 $f(w)^{0.75}$,抑制高频词。
- 归一化:将调整后的值转换为概率分布,确保所有概率之和为1。
- 示例:
- 假设词A的原始频率为1000,词B为10。
- 调整后:$1000^{0.75} \approx 177.8$,$10^{0.75} \approx 5.6$。
- 归一化后,词A的概率从绝对主导(约99%)降低,词B的概率相对提升。
(2) 负采样器类 NegativeSampler
- 功能:根据预定义的分布批量采样负样本。
- 参数:
batch_size
:批次大小(正样本数量)。num_negatives
:每个正样本对应的负样本数。
- 输出:形状为
(batch_size, num_negatives)
的数组,每行包含一个正样本对应的多个负样本索引。 - 示例:
若
batch_size=3
,num_negatives=2
,则输出可能是:1
2
3[[5, 12], # 第1个正样本的2个负样本
[8, 3], # 第2个正样本的2个负样本
[1, 7]] # 第3个正样本的2个负样本
4. 负采样在Word2Vec训练中的流程
整体流程
- 准备数据:从训练语料中提取正样本(如中心词-上下文词对)。
- 生成负样本:对每个正样本,采样
num_negatives
个负样本。 - 计算损失:
- 正样本得分:中心词与目标词的点积。
- 负样本得分:中心词与所有负样本的点积。
- 损失函数:最大化正样本得分,最小化负样本得分。
- 反向传播:根据损失更新词向量参数。
5. 负采样的作用
作用 | 说明 |
---|---|
降低计算复杂度 | 仅计算正样本和少量负样本的得分,而非整个词表。 |
平衡高频/低频词 | 通过幂次调整,减少高频词的过度影响,增加低频词的参与。 |
提升语义区分能力 | 迫使模型区分正样本与随机负样本,增强词向量的判别性。 |
6. 参数选择的影响
- 幂次值(0.75):
- 值越小,高频词的抑制越强(极端情况为均匀分布)。
- 原论文通过实验确定0.75为平衡点。
- 负样本数量(num_negatives):
- 数量越多,训练越稳定,但计算量增加。
- 通常选择5~20个负样本。
7. 示例说明
假设语料中词频分布如下:
1 | 词A: 1000次 |
- 原始分布:词A的概率为 $\dfrac{1000}{1110} \approx 90.1%$。
- 调整后分布:词A的概率降至约 $\dfrac{1000^{0.75}}{1000^{0.75} + 100^{0.75} + 10^{0.75}} \approx 70.5%$。
- 效果:词B和词C的采样概率显著提升,模型能更均衡地学习不同频率的词。
trainer
将原本训练的过程进行了封装
1 | import torch |
main
保留设置随机数和相关接口调用部分,以及最后判断词的相似度部分
1 | import torch |
为什么在计算word2vec时需要反向传播
[!question] 感觉好像在word2vec计算的时候没有见到用什么卷积层、全连接层之类的结构,为什么还需要用反向传播来优化参数?
Word2Vec 训练需要优化器和反向传播的原因在于其本质是一个基于神经网络的无监督学习模型,其目标是通过调整词向量参数来最小化损失函数。以下是详细解释:
1. Word2Vec 的神经网络本质
Word2Vec 虽然结构简单,但它是一个典型的浅层神经网络模型:
- 输入层:词的索引(one-hot 向量)。
- 隐藏层:词向量(嵌入层),没有激活函数,也就是线性单元。
- 输出层:预测目标词的概率分布(通过 softmax
其目标是通过训练调整词向量参数,使得模型能够根据上下文预测目标词(CBOW)或根据中心词预测上下文(Skip-gram)。优化器和反向传播是神经网络训练的核心工具,用于实现这一目标。当这个模型训练好以后,我们并不会⽤这个训练好的模型处理新的任务,我们真正需要的是这个模型通过训练数据所学得的参数,例如隐层的权重矩阵。
2. 优化器作用
优化器的职责
优化器(如代码中的 Adam
)负责根据损失函数的梯度更新模型参数(即词向量权重)。具体来说:
- 梯度计算:通过反向传播计算损失函数对模型参数的梯度。
- 参数更新:根据梯度方向和优化算法(如 Adam 的动量、自适应学习率)调整参数。
Word2Vec 的参数更新
在代码中,模型参数是嵌入层的权重:
1 | self.model.parameters() # 包括 input_embeddings 和 output_embeddings 的权重 |
优化器通过调整这些权重,使得正样本的相似性得分更高,负样本的相似性得分更低。
3. 反向传播的作用
反向传播的流程
- 前向传播:计算模型的输出(损失函数)。
1 | loss = self.model(inputs, targets, negative_samples) |
- 梯度计算:反向传播(
loss.backward()
)自动计算损失对模型参数的梯度。
1 | loss.backward() # 计算梯度 |
- 参数更新:优化器根据梯度更新参数。
1 | self.optimizer.step() # 更新参数 |
Word2Vec 中的反向传播
- 词向量的梯度更新:
反向传播会调整input_embeddings
和output_embeddings
的权重。例如:- 在 CBOW 中,
input_embeddings
对应上下文词向量,output_embeddings
对应目标词向量。 - 梯度会更新这些向量,使得正样本的相似性(上下文与目标词的点积)最大化,负样本的相似性最小化。
- 在 CBOW 中,
4. 训练过程的具体实现(结合代码)
步骤1:前向传播计算损失
1 | loss = self.model(inputs, targets, negative_samples) |
- 输入
inputs
(上下文词或中心词)、targets
(目标词)、negative_samples
(负样本)。 - 模型通过嵌入层和损失计算(正样本得分 + 负样本得分)得到总损失。
步骤2:反向传播计算梯度
1 | loss.backward() |
- 自动计算损失对
input_embeddings
和output_embeddings
权重的梯度。
步骤3:优化器更新参数
1 | self.optimizer.step() |
- 根据梯度更新词向量参数,例如:
- 增大正样本的相似性(如
context_emb
和target_emb
的向量方向更接近)。 - 降低负样本的相似性(如
context_emb
和negative_emb
的向量方向更远离)。
- 增大正样本的相似性(如
5. 为什么需要显式调用优化器和反向传播?
- 参数学习需求:
Word2Vec 的目标是通过调整词向量参数来最小化损失函数,这必须依赖梯度下降类算法。 - 嵌入层的可训练性:
nn.Embedding
层的权重是模型参数,需要梯度更新(通过requires_grad=True
自动实现)。 - 负采样的高效训练:
负采样通过近似 softmax 加速训练,但仍需梯度更新(负样本的嵌入层权重需要调整)。
6. 代码中的关键操作
梯度清零
1 | self.optimizer.zero_grad() # 清空上一批次的梯度 |
- 防止梯度累积(每个 batch 独立计算梯度)。
损失计算与梯度传播
1 | loss = self.model(...) # 前向传播 |
- 这是 PyTorch 训练神经网络的通用流程。
7. 直观示例
假设模型在某个 batch 中处理以下样本:
- 正样本:
(context="cat", target="climb")
- 负样本:
(context="cat", negative="math")
训练过程会:
- 计算
context_emb("cat")
和target_emb("climb")
的相似性得分。 - 计算
context_emb("cat")
和negative_emb("math")
的相似性得分。 - 通过反向传播调整:
- 使
input_embeddings["cat"]
更接近output_embeddings["climb"]
。 - 使
input_embeddings["cat"]
远离output_embeddings["math"]
。
- 使
总结
- 优化器:负责根据梯度更新词向量参数。
- 反向传播:自动计算损失对参数的梯度。
- 训练目标:通过最小化损失函数,学习到语义相关的词向量表示。
如果没有优化器和反向传播,模型将无法自动调整词向量参数,Word2Vec 的训练过程将无法实现。
Pytorch里面的回调
[!question] 为什么我这里写的是
loss = self.model(inputs, targets, negative_samples)
,并没有调用forward
,但是会自动执行forward
函数?
你这里调用的是:
1 | loss = self.model(inputs, targets, negative_samples) |
但实际上调用的是模型类(继承自nn.Module
)的forward
方法。这是因为:
在PyTorch中,通过调用模型实例 (self.model(...)
) 时,PyTorch内部会自动调用你定义的forward
方法。
也就是说:
- 定义的模型继承自
nn.Module
。 nn.Module
类重写了Python的__call__
方法。- 当执行
model(input)
时,实际上调用的是model.__call__(input)
,这个方法会在执行一些PyTorch的内部逻辑(例如自动计算梯度、钩子函数等)之后,自动调用你自定义的forward
方法。
因此,虽然你没有明确写:
1 | loss = self.model.forward(inputs, targets, negative_samples) |
但由于PyTorch内部机制的关系,你写:
1 | loss = self.model(inputs, targets, negative_samples) |
就会自动调用模型中定义的:
1 | def forward(self, context_words, target_words, negative_words): |
所以,代码运行的路径是:
1 | self.model(inputs, targets, negative_samples) |
推荐做法:
通常推荐直接调用模型实例(model(x)
)而不是显式调用forward
,因为直接调用能确保所有PyTorch内置机制正常运行。
损失函数的设计原因
Word2Vec的目标和损失函数
Word2Vec的核心思想是 通过最大化上下文和目标词的相似性,最小化负样本和上下文的相似性。这里的相似性通常通过点积(即内积)来度量。
1. Skip-gram模型:
在 Skip-gram 模型中,给定一个中心词(target word),模型的目标是预测其上下文词(context words)。假设你有一个句子:
1 | I love natural language processing |
如果选择 “love” 为中心词,那么目标是通过 “love” 来预测上下文词(例如: “I”, “natural”, “language”, “processing”)。
损失函数的工作原理:
- 正样本: 模型通过目标词(例如 “love”)与其上下文词(例如 “I”, “natural”, “language”, “processing”)的相似性进行训练,目标是最大化目标词与上下文词的相似性。
- 负样本: 为了使模型学习区分非上下文词,模型从词汇表中随机选择一些词作为负样本。通过最小化这些负样本与目标词的相似性,模型能够有效地学习如何区分真实的上下文词和随机的非上下文词。
为什么最大化目标词与上下文词的相似性?
- 最大化目标词与上下文词的相似性是因为我们希望目标词能够很好地代表其上下文,捕捉到它们之间的语义关系。例如,如果两个词经常出现在相似的上下文中(如 “dog” 和 “cat”),它们的词向量应该非常相似。
为什么最小化负样本与上下文的相似性?
- 负样本是从词汇表中随机选出的词,这些词应该与目标词的上下文关系较弱。通过最小化负样本与上下文词的相似性,模型能够确保只有在实际语境下出现的词才会被聚集在一起,而不是将无关的词(负样本)与目标词错误地关联。
2. CBOW模型:
CBOW模型与Skip-gram相反,它的目标是给定上下文词,来预测中心词。假设句子仍然是:
1 | I love natural language processing |
如果选择 “love” 为中心词,CBOW模型的目标是通过上下文词(例如:”I”, “natural”, “language”, “processing”)来预测中心词 “love”。
损失函数的工作原理:
- 正样本: 在CBOW中,给定一组上下文词,模型通过它们来预测一个目标词。目标是最大化上下文词和目标词的相似性。
- 负样本: 同样地,通过选择负样本,模型将学习区分真实上下文和随机无关的词。
损失函数的数学形式
Word2Vec的损失函数通常使用负对数似然损失(Negative Log-Likelihood Loss),结合负采样(Negative Sampling)。假设模型预测某个上下文的目标词是 w_o
,负样本为一组随机选择的词 w_1, w_2, ..., w_k
,那么总的损失可以表示为:
$$L=−logP(wo∣context)−∑i=1klogP(wi∣context)L = - \log P(w_o | context) - \sum_{i=1}^k \log P(w_i | context)$$
- P(w_o | context): 表示给定上下文预测目标词的概率。通过最大化这个概率,模型希望使得目标词和上下文词之间的相似性最大化。
- P(w_i | context): 表示给定上下文预测负样本的概率。通过最小化这个概率,模型希望使得负样本和上下文词之间的相似性最小化。
在实际计算中,P(w | context)
通过 softmax 或者 负采样(Negative Sampling) 来实现,后者是一种近似计算的方式,用于提高训练效率。
总结
最大化目标词与上下文的相似性 和 最小化负样本与上下文的相似性 的动机是基于词语在语义上的相似性原则。通过这种方式,Word2Vec可以学习到具有良好语义表示的词向量,使得语义上相似的词具有相似的向量表示。具体来说:
- 最大化相似性:目标是使得目标词与上下文之间的语义关系被模型所捕捉,从而使得相似语义的词在向量空间中靠得更近。
- 最小化相似性:通过负采样,使得模型学会区分实际的上下文词和随机无关的词,从而避免错误的词语关联。
最终,这种训练方式使得模型能够有效地将每个词映射到一个低维度的向量空间,并且能够捕捉到词语之间的语义和语法相似性。
实验结果
CBOW 实现
由于 torch 版本的不同,原本能运行的代码在电脑上跑不通…让我们来解决一下
报错的原因是 batch size不统一…让我们回到数据加载的代码中看看:
1 | class Word2VecDataset(Dataset): |
其中,对于skip-gram和cbow,采用了不同的配对方式:
1 | # skip-gram: |
但是[context]
在边界时,可能会出现长度不一致现象,于是batch size不统一,在高版本的torch中,这个问题被隐式解决了,但是在低版本中没有得到正确处理。
通常,遇到这种情况,最常见的解决办法就是重写一个 collate_fn
函数,在dataloader构建的时候传入,这样就可以按照我们写的 collate_fn
进行处理。
什么是 collate_fn
collate_fn
是 PyTorch 的 DataLoader
中一个非常重要的参数,它用于定义如何将多个样本(从数据集中获取的)组合成一个批次(batch)。默认情况下,DataLoader
会自动将样本堆叠成一个批次,但在某些情况下,默认行为可能不适用,这时就需要重写 collate_fn
。
1. collate_fn
的作用
在 PyTorch 中,DataLoader
的作用是从数据集中加载数据,并将其整理成批次(batch),供模型训练或推理使用。collate_fn
是一个函数,它定义了如何将多个样本(从数据集中获取的)组合成一个批次。
默认的 collate_fn
行为是:
- 如果样本是张量(tensor),它会将样本堆叠(stack)成一个更大的张量。
- 如果样本是列表、元组或字典,它会递归地对每个元素进行堆叠。
例如:
1 | # 假设数据集返回的样本是 (tensor, label) |
2. 为什么需要重写 collate_fn
?
默认的 collate_fn
假设所有样本的形状和类型是一致的,但在某些情况下,这种假设不成立,这时就需要自定义 collate_fn
。以下是一些常见的场景:
(1) 样本长度不一致
例如,在处理变长序列(如文本、音频)时,每个样本的长度可能不同。默认的 collate_fn
无法直接处理这种情况。
- 解决方法:在
collate_fn
中使用pad_sequence
对序列进行填充(padding),使它们的长度一致。
1 | from torch.nn.utils.rnn import pad_sequence |
(2) 样本是复杂的数据结构
如果样本是字典、嵌套的元组或其他复杂结构,默认的 collate_fn
可能无法正确处理。
- 解决方法:在
collate_fn
中手动定义如何组合这些复杂结构。
1 | def collate_fn(batch): |
(3) 需要自定义数据处理逻辑
有时,我们可能需要在组合批次时对数据进行额外的处理,例如数据增强、归一化等。
- 解决方法:在
collate_fn
中添加自定义逻辑。
1 | def collate_fn(batch): |
3. 如何使用 collate_fn
?
在创建 DataLoader
时,将自定义的 collate_fn
传递给 collate_fn
参数即可:
1 | from torch.utils.data import DataLoader |
4. 总结
- 默认行为:
collate_fn
默认会将样本堆叠成一个批次,适用于样本形状和类型一致的情况。 - 重写场景:当样本长度不一致、数据结构复杂或需要自定义处理逻辑时,需要重写
collate_fn
。 - 灵活性:通过自定义
collate_fn
,可以灵活地处理各种数据形式,满足不同的需求。
解决办法1:直接填0
按照我们上面的介绍,为了处理这种情况,最简单的解决办法就是对不等长的样本进行填充,通常直接填0:
1 | def collate_fn_cbow(batch): |
这样子当然可以跑,不过有两个问题:
- 首先0本身也是一个单词的id,让我们回到构建dataset的部分:
1 | def build_vocab(corpus, min_count=1): |
enumerate
默认是从0开始的,因此这里word2idx和idx2word实际上0号索引本身是有一个单词对应的。
其次,如果特别设置了0是一个特殊的token,那你用0作为上下文就会影响中心词的表达,因为真实的场景不会有特殊id,也就是说,填充的0
会被视为有效上下文词,影响中心词的语义表达。
解决方法2:添加<PAD>标记,设置专门掩码
这个方法需要对build_vocab
进行修改,然后再修改cbow
的forward
函数:
1 | def build_vocab(corpus, min_count=1): |
这部分的代码思路有点长,用deepseek来解释一下:
一、改进后的 collate_fn_cbow
函数
功能目标
在 CBOW 模式 下,每个样本的上下文词列表(context
)长度可能不同(例如一个样本的上下文有2个词,另一个有4个词)。collate_fn_cbow
的作用是将这些不同长度的上下文填充到相同长度,以便批量输入模型。
示例说明
假设一个批次包含两个样本:
- 样本1:
context = [2, 3]
,target = 5
- 样本2:
context = [4, 1, 7]
,target = 6
经过 collate_fn_cbow
处理后:
max_len = 3
(最长上下文长度)填充后的结果:
1
2
3
4
5padded_contexts = [
[2, 3, 0], # 样本1填充一个0
[4, 1, 7] # 样本2无需填充
]
targets = [5, 6]
关键点
- 填充值:使用显式定义的
<PAD>
标记(索引为0),确保不与真实词汇冲突。 - 输入形状:填充后的上下文张量形状为
[batch_size, max_len]
,可直接输入模型。
二、改进后的模型 forward
函数(以 CBOW 为例)
功能目标
在计算上下文向量的均值时,忽略填充的 <PAD>
标记,确保填充位置不影响模型学习。
示例说明
假设输入为:
1 | context_words = [ |
掩码生成:
1
2
3
4mask = [
[True, True, False], # 样本1的第三个位置是PAD
[True, True, True] # 样本2无PAD
]词向量掩码处理:
- 样本1的第三个位置的词向量会被置零,不参与求和。
均值计算:
- 样本1的均值 = (向量2 + 向量3) / 2
- 样本2的均值 = (向量4 + 向量1 + 向量7) / 3
关键点
- 掩码机制:通过布尔掩码标识有效词位置,确保PAD位置的向量不参与计算。
- 鲁棒性:使用
clamp(min=1e-10)
避免除零错误(即使某个样本的上下文全是PAD,也能安全计算)。
总结
- **
collate_fn_cbow
**:- 作用:将变长的上下文填充到相同长度,生成批量数据。
- 关键:使用独立的
<PAD>
标记(索引0)填充,不与真实词汇冲突。
- 模型
forward
函数:- 作用:通过掩码机制,在计算上下文向量时忽略填充位置。
- 关键:利用掩码屏蔽无效位置,确保模型只关注真实的上下文词。
通过这两项改进,代码可以正确处理 CBOW 模式的变长输入,同时避免 PAD 标记对模型训练的干扰。
最后再运行一次: