Jack He's Blog

some idea or work

调包实现word2vec

实验介绍

[!question] 借助gensim工具包,利用给定的训练语料训练word2vec(CBOW)
训练后实现:
(1)自选5对词语相似度计算
(2)任选5个词,找出与他们最接近的5个语义相似的词
(3)计算爸爸-男人+女人=?

词的表示方法:
由于机器学习方法通常只能接受向量作为输入,因此在NLP中使用机器学习方法时,往往需要将词表示为向量。向量表示更为规范,通常由固定的维度,并且易于进行机器学习算法中的各类运算

  • 独热编码:最简单的方式,但是会带来维度灾难,且无法表示词与词之间的相关性
  • 分布式表示
    • 稀疏向量表示:词-词共现矩阵
    • 稠密向量表示:传统方法是基于SVD的潜在语义分析,近期方法有word2vec和Glove,现在常用上下文相关词嵌入

实验1只需要调用gensim模块实现即可,该模块封装了Word2Vec模型,可以自由选择调用skip-gram和CBOW,同时还有其他参数可供调节

image.png

代码

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
from gensim.models import Word2Vec
from gensim.utils import simple_preprocess

# 读入文件与预处理
with open('homework3_input.txt', encoding = 'utf-8') as f:
text = f.read()

sentences = text.split('\n')

corpus = [sentence.split(' ') for sentence in sentences]
# print(corpus)
# print(type(corpus))

# 传入的是二维列表!
# corpus = [simple_preprocess(sentence) for sentence in sentences]
# print(corpus)

# 模型训练
model = Word2Vec(sentences=corpus, vector_size=100, window=5, min_count=1, workers=4, epochs=5, min_alpha=0.005, sg=0)

# 模型保存
model.save("word2vec.model")

# 模型加载
model = Word2Vec.load("word2vec.model")

# 相似度计算函数
def similarity_compute(word1, word2):
similarity = model.wv.similarity(word1, word2)
print(f"{word1}{word2} 相似度为:{similarity}")

# 找到前5个相近词
def most_similar(word):
simi_words = model.wv.most_similar(word, topn=5)
print(f"{word}的前五个相近词:")
for ans, score in simi_words:
print(f"{ans} : {score}")

# 计算5组词之间的相似度
similarity_compute('俄罗斯', '乌克兰')
similarity_compute('舅舅', '叔叔')
similarity_compute('证券', '金融')
similarity_compute('足球', '篮球')
similarity_compute('火锅', '麻辣烫')

# 寻找相近词
most_similar('足球')
most_similar('新华社')
most_similar('舅舅')
most_similar('欧洲')
most_similar('心脏')

# 计算'爸爸-男人+女人'的结果
result = model.wv.most_similar(positive=['爸爸', '女人'], negative=['男人'], topn=5)
for word, score in result:
print(f"可能的答案: {word}, 相似度:{score}")

注意事项

  • 读取文件时,首先要按照换行符切割成每行单独的列表,然后再按照空格对每行分别处理,形成每行按照词进行分割的二维列表
  • 这里也可以使用 gensim 提供的处理函数 simple_preprocess 函数,效果和按照空格切割类似,只是还会舍弃一些停用词
  • 训练模型时如果觉得效果不好,可以尝试修改超参数

实验结果

image.png

句子的向量表示

实验介绍

[!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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import torch
from transformers import BertTokenizer
from transformers import BertModel
from sklearn.metrics.pairwise import cosine_similarity

# 中文Bert
model_name = '../第二周/bert-base-chinese/bert-base-chinese'
tokenizers = BertTokenizer.from_pretrained(model_name)
model = BertModel.from_pretrained(model_name)

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 这句话不要忘了是字符串...

model.to(device) # 将模型移动到指定的设备(GPU 或 CPU)
model.eval() # 将模型设置为评估模式(关闭 dropout 等训练时的特殊操作)

def get_sentence_embedding(sentence):
inputs = tokenizers(sentence, return_tensors='pt', truncation=True, max_length=128) #
inputs = {k: v.to(device) for k,v in inputs.items()} # 将输入数据移动到指定的设备(GPU 或 CPU)

with torch.no_grad():
outputs = model(**inputs) # 函数解包,将inputs传给模型,得到输出

cls_embedding = outputs.last_hidden_state[:, 0, :] # 取出[CLS]对应的向量作为句子的embedding
return cls_embedding.cpu().numpy() # 将embedding从GPU转回CPU,并转为numpy数组返回

def compute_sentence_similarity(sentence1, sentence2):
embedding1 = get_sentence_embedding(sentence1) # 这里的embedding1就是句子1的embedding
embedding2 = get_sentence_embedding(sentence2) # 这里的embedding2就是句子2的embedding

sim = cosine_similarity(embedding1, embedding2)[0][0] # 会返回如$$[0.9926]] 这样的(1,1)矩阵结果,需要用[0][0]进行提取
print(f"句子相似度: {sim:.4f}")

sentence1 = "中国队大胜尼日利亚队"
sentence2 = "尼日利亚队大败中国队"
sentence3 = '吃葡萄不吐葡萄皮'
sentence4 = '不吃葡萄倒吐葡萄皮'
sentence5 = '夏洛特烦恼'
sentence6 = '西红市首富赚了100万,于是开了开心麻花'
sentence7 = '他正在学习自然语言处理'
sentence8 = '他正在学习人工智能技术'
sentence9 = '这部电影非常精彩,值得一看'
sentence10 = '这部电影太无聊了,浪费时间'

compute_sentence_similarity(sentence1, sentence2)
compute_sentence_similarity(sentence3, sentence4)
compute_sentence_similarity(sentence5, sentence6)
compute_sentence_similarity(sentence7, sentence8)
compute_sentence_similarity(sentence9, sentence10)

image.png

Bert 英文相似度

没有多少区别,只需要把model_name更换,加载英文模型即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 英文Bert
model_name = '../第二周/bert-base-cased/bert-base-cased'
tokenizers = BertTokenizer.from_pretrained(model_name)
model = BertModel.from_pretrained(model_name)

'''中间省略'''

sentence1 = "The weather is nice today, and the sun is shining."
sentence2 = "I love you."
sentence3 = "I enjoy eating apples and bananas."
sentence4 = "Apples and bananas are my favorite fruits."
sentence5 = "He is studying natural language processing."
sentence6 = "The cat is sleeping."
sentence7 = "This movie is fantastic and worth watching."
sentence8 = "That bookis so boring and a waste of time."
sentence9 = "She went to Beijing yesterday."
sentence10 = "He has married a beautiful girl."

image.png

GPT2 英文相似度

只需要把Bert的加载模型换成以下即可:

1
2
3
4
5
6
7
8
import torch
from transformers import GPT2Tokenizer
from transformers import GPT2Model
from sklearn.metrics.pairwise import cosine_similarity

model_name = './gpt2'
tokenizers = GPT2Tokenizer.from_pretrained(model_name)
model = GPT2Model.from_pretrained(model_name)

image.png

不过看英文的相似度的时候,感觉英文句子之间的相似度似乎都很高。。。

注意 **input 的作用

在 Python 中,**inputs 是一种特殊的语法,用于将字典解包为关键字参数(keyword arguments)。具体来说,它会将字典中的键值对解包为函数的命名参数。

  1. **inputs 的作用

假设 inputs 是一个字典,例如:

1
2
3
4
inputs = {
'input_ids': tensor1,
'attention_mask': tensor2
}

那么 **inputs 会将这个字典解包为:

1
input_ids=tensor1, attention_mask=tensor2
  1. 代码中的具体应用

在代码中:

1
outputs = model(**inputs)
  • model 是 GPT-2 模型,它的 forward 方法需要一些命名参数,例如 input_idsattention_mask
  • inputs 是一个字典,包含了这些参数:
    1
    2
    3
    4
    inputs = {
    'input_ids': tensor1, # 输入的 token IDs
    'attention_mask': tensor2 # 注意力掩码
    }
  • **inputs 会将字典解包为:
    1
    model(input_ids=tensor1, attention_mask=tensor2)
  1. 为什么需要 **inputs
  • GPT-2/Bert 模型的 forward 方法需要明确的命名参数(如 input_idsattention_mask),而不是一个字典。
  • 使用 **inputs 可以方便地将字典解包为命名参数,避免手动写:
1
outputs = model(input_ids=inputs['input_ids'], attention_mask=inputs['attention_mask'])
  1. 示例

假设 inputs 是以下字典:

1
2
3
4
inputs = {
'input_ids': torch.tensor([[1, 2, 3]]),
'attention_mask': torch.tensor([[1, 1, 1]])
}

那么 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
word2vec:.
│ main.py
│ trainers.py
├─data
│ homework3_input.txt
├─models
│ │ cbow.py
│ │ skip_gram.py
│ │ __init__.py
│ └─__pycache__
├─utils
│ │ configs.py
│ │ dataloader.py
│ │ metrics.py
│ │ negative_sampler.py
│ │ __init__.py
│ └─__pycache__
└─__pycache__

models

里面存放cbow和ckip-gram代码,此处与CBOW为例,skip-gram类似:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import torch
import torch.nn as nn

# 定义CBOW模型
class CBOWModel(nn.Module):
def __init__(self, vocab_size, embedding_dim):
'''参数初始化'''
super(CBOWModel, self).__init__() # 继承父类(nn.Module)的初始化方法
self.vocab_size = vocab_size # 词汇表大小
self.embedding_dim = embedding_dim # 词向量维度
self.input_embeddings = nn.Embedding(vocab_size, embedding_dim) # 输入词向量嵌入层
self.output_embeddings = nn.Embedding(vocab_size, embedding_dim) # 输出词向量嵌入层
self.init_emb() # 初始化词向量

def init_emb(self):
initrange = 0.5 / self.embedding_dim # 随机初始化范围
self.input_embeddings.weight.data.uniform_(-initrange, initrange) # 输入词向量初始化, uniform_()方法用于将权重张量初始化为均匀分布
self.output_embeddings.weight.data.uniform_(-0, 0) # 输出词向量初始化

def forward(self, context_words, target_words, negative_words):
'''
context_words: 上下文词的索引,形状为 (batch_size, context_size)
target_words: 目标词的索引,形状为 (batch_size)
negative_words: 负采样的词索引,形状为 (batch_size, num_negatives)
'''
# 将上下文词通过输入嵌入层转换为向量,得到形状 (batch_size, context_size, embedding_dim)
context_emb = self.input_embeddings(context_words) # (batch, context_size, embed_dim)
# 对上下文词的嵌入取平均,得到单个向量表示整个上下文,形状变为 (batch_size, embedding_dim)
context_emb = torch.mean(context_emb, dim=1) # (batch, embed_dim)
# 目标词和负样本通过输出嵌入层转换为向量,形状分别为 (batch_size, embedding_dim) 和 (batch_size, num_negatives, embedding_dim)。
target_emb = self.output_embeddings(target_words) # (batch, embed_dim)
negative_emb = self.output_embeddings(negative_words) # (batch, neg, embed_dim)

# 计算正样本的损失
# 分数计算:上下文向量与目标词向量的逐元素乘积之和(点积),表示两者的相似性。
positive_score = torch.sum(context_emb * target_emb, dim=1) # (batch)
# 损失转换:通过sigmoid将分数映射到(0,1)区间,取对数并添加极小值(1e-10)防止数值溢出。
positive_loss = torch.log(torch.sigmoid(positive_score) + 1e-10)

# 计算负样本的损失
# 分数计算:context_emb.unsqueeze(2) 扩展维度为 (batch, embed_dim, 1),torch.bmm(批量矩阵乘法)计算每个负样本与上下文向量的点积,得到形状 (batch_size, num_negatives)
negative_score = torch.bmm(negative_emb, context_emb.unsqueeze(2)).squeeze() # (batch, neg)
# 损失转换:对负样本分数取负号(因为希望负样本得分低),通过sigmoid和log计算损失,并对所有负样本求和。
negative_loss = torch.sum(torch.log(torch.sigmoid(-negative_score) + 1e-10), dim=1) # (batch)

loss = -(positive_loss + negative_loss) # (batch),负号是为了最小化loss
return loss.mean() # 平均loss

其中,里面的forward方法需要注意一下,以CBOW为例:
输入参数

  • context_words: 上下文词的索引,形状为 (batch_size, context_size)
  • target_words: 目标词的索引,形状为 (batch_size)
  • negative_words: 负采样的词索引,形状为 (batch_size, num_negatives)

步骤1:嵌入层操作

  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
    26
       context_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
2
negative_score = torch.bmm(negative_emb, context_emb.unsqueeze(2)).squeeze()  # (batch, neg)
negative_loss = torch.sum(torch.log(torch.sigmoid(-negative_score) + 1e-10), dim=1)
  • 分数计算
    • context_emb.unsqueeze(2) 扩展维度为 (batch, embed_dim, 1)
    • torch.bmm(批量矩阵乘法)计算每个负样本与上下文向量的点积,得到形状 (batch_size, num_negatives)
  • 损失转换
    • 对负样本分数取负号(因为希望负样本得分低),通过sigmoidlog计算损失,并对所有负样本求和。

步骤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)
    $$

代码设计要点

  1. 双嵌入层input_embeddingsoutput_embeddings分别用于输入词和输出词(即老师课上讲的中心词和上下文词向量,根据模型不同有所区别),增强模型表达能力。
  2. 负采样加速:通过批量矩阵乘法(torch.bmm)高效计算多个负样本的分数。
  3. 数值稳定性:添加1e-10避免对零取对数。
  4. 损失符号:负号将极大似然估计转换为最小化问题,与优化器兼容。

utils代码

里面没有做太多的修改

  • config.py存放配置文件,使用Config类表示,同时使用argprase模块便于处理不同的参数情况
  • dataloader.py 存放读取数据、构建词汇表、定义数据集的代码
  • metrics.py 存放余弦相似度计算与查找最相近词代码
  • negative_sampler.py 存放负采样分布生成的代码和负采样器代码

详细说明关于负采样分布部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import numpy as np

# 负采样分布
def get_negative_sampling_distribution(word_counts):
# 使用词频的0.75次方
power = 0.75
print(word_counts)
distribution = word_counts ** power # 计算负采样分布,给词频乘以0.75次方,进行平滑处理
print(distribution)
distribution /= distribution.sum() # 归一化
print(distribution)
return distribution # 返回负采样分布

class NegativeSampler:
def __init__(self, distribution):
self.distribution = distribution # 初始化分布
self.vocab_size = len(distribution) # 设置词汇表大小就是分布大小

def sample(self, batch_size, num_negatives):
# 使用numpy的choice进行批量采样
return np.random.choice(self.vocab_size, size=(batch_size, num_negatives), p=self.distribution) # 按照负采样分布采样,通过设置每个词的采样概率p实现,返回的是一个二维数组,shape=(batch_size, num_negatives),采样的范围是 [0, vocab_size),其中,二维数组的每一行表示每个正样本对应的负样本索引

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 是每个词在训练语料中的出现次数。
  • 步骤
    1. 幂次调整:将词频 $f(w)$ 转换为 $f(w)^{0.75}$,抑制高频词。
    2. 归一化:将调整后的值转换为概率分布,确保所有概率之和为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=3num_negatives=2,则输出可能是:

      1
      2
      3
      [[5, 12],  # 第1个正样本的2个负样本
      [8, 3], # 第2个正样本的2个负样本
      [1, 7]] # 第3个正样本的2个负样本

4. 负采样在Word2Vec训练中的流程

整体流程

  1. 准备数据:从训练语料中提取正样本(如中心词-上下文词对)。
  2. 生成负样本:对每个正样本,采样 num_negatives 个负样本。
  3. 计算损失
    • 正样本得分:中心词与目标词的点积。
    • 负样本得分:中心词与所有负样本的点积。
    • 损失函数:最大化正样本得分,最小化负样本得分。
  4. 反向传播:根据损失更新词向量参数。

5. 负采样的作用

作用 说明
降低计算复杂度 仅计算正样本和少量负样本的得分,而非整个词表。
平衡高频/低频词 通过幂次调整,减少高频词的过度影响,增加低频词的参与。
提升语义区分能力 迫使模型区分正样本与随机负样本,增强词向量的判别性。

6. 参数选择的影响

  • 幂次值(0.75)
    • 值越小,高频词的抑制越强(极端情况为均匀分布)。
    • 原论文通过实验确定0.75为平衡点。
  • 负样本数量(num_negatives)
    • 数量越多,训练越稳定,但计算量增加。
    • 通常选择5~20个负样本。

7. 示例说明

假设语料中词频分布如下:

1
2
3
词A: 1000次
词B: 100次
词C: 10次
  • 原始分布:词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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import torch
import numpy as np
from torch import optim
from tqdm import tqdm

class Trainer:
def __init__(self, model, config, dataloader, word2idx, idx2word, sampler):
# 参数初始化
self.model = model
self.config = config
self.dataloader = dataloader
self.word2idx = word2idx
self.idx2word = idx2word
self.sampler = sampler

# 设备设置
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
self.model.to(self.device)

# 优化器设置
self.optimizer = optim.Adam(self.model.parameters(), lr=config.learning_rate)

self.loss_history = [] # 记录损失

def _train_epoch(self, epoch):
'''训练单轮epoch'''
train_loss = 0 # 初始化训练损失
progress_bar = tqdm(self.dataloader, desc=f"Epoch {epoch+1}/{self.config.epochs}") # 进度条
for batch in progress_bar:
if self.config.mode == 'skip-gram': # skip-gram模式
center, target = batch # skip-gram模式,sentences为中心词和目标词对
inputs = torch.tensor(center, dtype=torch.long).to(self.device) # 转换为tensor
else:
context, target = batch # cbow模式,sentences为上下文词构成的元组和目标词对
inputs = torch.tensor(context, dtype=torch.long).to(self.device) # 转换为tensor

targets = torch.tensor(target, dtype=torch.long).to(self.device) # 将目标词转换为tensor
batch_size = targets.size(0) # 批量大小,即中心词个数

# 负采样
negative_samples = self.sampler.sample(batch_size, self.config.num_negatives) # 进行负采样,其中负采样的个数为num_negatives,结果是一个batch_size*num_negatives的矩阵
negative_samples = torch.tensor(negative_samples, dtype=torch.long).to(self.device) # 将负采样结果转换为tensor

# 清空原本的梯度,进行前向传播
self.optimizer.zero_grad()
loss = self.model(inputs, targets, negative_samples) # 计算损失

# loss进行反向传播,更新优化器参数
loss.backward()
self.optimizer.step()

# 记录损失
train_loss += loss.item()
# progress_bar.set_postfix(loss=loss.item())

# 计算平均损失
avg_loss = train_loss / len(self.dataloader)
self.loss_history.append(avg_loss) # 记录损失

return avg_loss

def train(self):
for epoch in range(self.config.epochs):
avg_loss = self._train_epoch(epoch) # 训练单轮epoch
print(f"Epoch {epoch+1}/{self.config.epochs}, Loss: {avg_loss:.4f}")

def get_and_save(self):
# 获取词向量
embeddings = self.model.input_embeddings.weight.data.cpu().numpy()

# 保存词向量
with open(self.config.save_path, 'w', encoding='utf-8') as f:
for idx, word in self.idx2word.items():
# map 是 Python 的内置函数,它会对一个可迭代对象(如列表)中的每个元素应用指定的函数(这里是 str 函数),并返回一个新的迭代器
# join 是 Python 的内置函数,它会将序列中的元素以指定的字符连接起来,并返回一个新的字符串
vector = ' '.join(map(str, embeddings[idx]))
f.write(f"{word} {vector}\n") # 保存词以及对应的词向量
print(f"Word vectors saved to {self.config.save_path}")

# 构建词向量矩阵并进行归一化
norm_embeddings = embeddings / (np.linalg.norm(embeddings, axis=1, keepdims=True) + 1e-10)

return norm_embeddings

main

保留设置随机数和相关接口调用部分,以及最后判断词的相似度部分

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import torch
import random
import numpy as np
from torch.utils.data import DataLoader
from torch import optim
from utils.configs import Config
from utils.dataloader import Word2VecDataset, read_data, build_vocab
from utils.negative_sampler import NegativeSampler, get_negative_sampling_distribution
from utils.metrics import cosine_similarity, find_most_similar
from models.skip_gram import SkipGramModel
from models.cbow import CBOWModel
from trainers import Trainer

# 设置随机种子以确保结果可复现
torch.manual_seed(0)
random.seed(0)
np.random.seed(0)

def main():
# 解析命令行参数
config = Config()
print("配置参数:")
print(f"embedding_dim: {config.embedding_dim}")
print(f"window_size: {config.window_size}")
print(f"num_negatives: {config.num_negatives}")
print(f"batch_size: {config.batch_size}")
print(f"epochs: {config.epochs}")
print(f"learning_rate: {config.learning_rate}")
print(f"mode: {config.mode}")
print(f"data_path: {config.data_path}")
print(f"save_path: {config.save_path}")

# 读取数据
corpus = read_data(config.data_path)
word2idx, idx2word, word_counts = build_vocab(corpus, min_count=1) # 构建词汇表
vocab_size = len(word2idx) # 词汇表大小
print(f"Vocab size: {vocab_size}") # 打印词汇表大小

# 负采样分布
distribution = get_negative_sampling_distribution(word_counts) # 根据词频构建分布
sampler = NegativeSampler(distribution) # 根据分布构建负采样器

# 创建Dataset和DataLoader
dataset = Word2VecDataset(corpus, word2idx, config.window_size, mode=config.mode) # 创建Dataset
dataloader = DataLoader(dataset, batch_size=config.batch_size, shuffle=True) # 创建DataLoader

# 初始化模型
if config.mode == 'skip-gram':
model = SkipGramModel(vocab_size, config.embedding_dim) # 初始化Skip-gram模型
else:
model = CBOWModel(vocab_size, config.embedding_dim) # 初始化CBOW模型

# 初始化训练器
trainer = Trainer(
model=model,
config=config,
dataloader=dataloader,
word2idx=word2idx,
idx2word=idx2word,
sampler=sampler
)

# 训练模型
trainer.train()

# 保存词向量,并获得归一化的词向量
norm_embeddings = trainer.get_and_save()

word_a = '新华社'
word_b = '法新社'
if word_a in word2idx and word_b in word2idx: # 判断词是否在词汇表中
vec_a = norm_embeddings[word2idx[word_a]] # 先将词转成索引,再取出对应的词向量
vec_b = norm_embeddings[word2idx[word_b]]
similarity = cosine_similarity(vec_a, vec_b) # 计算余弦相似度
print(f"'{word_a}' 与 '{word_b}' 的相似度为: {similarity:.4f}")
else:
print(f"'{word_a}' 或 '{word_b}' 不在词汇表中。")

# 示例:查找与指定词最相似的前N个词
target_word = '搜狐'
top_n = 5
similar_words = find_most_similar(target_word, word2idx, idx2word, norm_embeddings, top_n=top_n) # 找到与target_word最相似的前N个词
if similar_words:
print(f"与 '{target_word}' 最相似的前 {top_n} 个词:")
for word, score in similar_words:
print(f"{word}: {score:.4f}")

if __name__ == "__main__":
main() # 调用main函数

为什么在计算word2vec时需要反向传播

[!question] 感觉好像在word2vec计算的时候没有见到用什么卷积层、全连接层之类的结构,为什么还需要用反向传播来优化参数?

Word2Vec 训练需要优化器和反向传播的原因在于其本质是一个基于神经网络的无监督学习模型,其目标是通过调整词向量参数来最小化损失函数。以下是详细解释:
image.png

1. Word2Vec 的神经网络本质

Word2Vec 虽然结构简单,但它是一个典型的浅层神经网络模型

  • 输入层:词的索引(one-hot 向量)。
  • 隐藏层:词向量(嵌入层),没有激活函数,也就是线性单元。
  • 输出层:预测目标词的概率分布(通过 softmax

其目标是通过训练调整词向量参数,使得模型能够根据上下文预测目标词(CBOW)或根据中心词预测上下文(Skip-gram)。优化器和反向传播是神经网络训练的核心工具,用于实现这一目标。当这个模型训练好以后,我们并不会⽤这个训练好的模型处理新的任务,我们真正需要的是这个模型通过训练数据所学得的参数,例如隐层的权重矩阵。

2. 优化器作用

优化器的职责

优化器(如代码中的 Adam)负责根据损失函数的梯度更新模型参数(即词向量权重)。具体来说:

  1. 梯度计算:通过反向传播计算损失函数对模型参数的梯度。
  2. 参数更新:根据梯度方向和优化算法(如 Adam 的动量、自适应学习率)调整参数。

Word2Vec 的参数更新

在代码中,模型参数是嵌入层的权重:

1
self.model.parameters()  # 包括 input_embeddings 和 output_embeddings 的权重

优化器通过调整这些权重,使得正样本的相似性得分更高,负样本的相似性得分更低。

3. 反向传播的作用

反向传播的流程

  1. 前向传播:计算模型的输出(损失函数)。
1
loss = self.model(inputs, targets, negative_samples)
  1. 梯度计算:反向传播(loss.backward())自动计算损失对模型参数的梯度。
1
loss.backward()  # 计算梯度
  1. 参数更新:优化器根据梯度更新参数。
1
self.optimizer.step()  # 更新参数

Word2Vec 中的反向传播

  • 词向量的梯度更新
    反向传播会调整 input_embeddingsoutput_embeddings 的权重。例如:
    • 在 CBOW 中,input_embeddings 对应上下文词向量,output_embeddings 对应目标词向量。
    • 梯度会更新这些向量,使得正样本的相似性(上下文与目标词的点积)最大化,负样本的相似性最小化。

4. 训练过程的具体实现(结合代码)

步骤1:前向传播计算损失

1
loss = self.model(inputs, targets, negative_samples)
  • 输入 inputs(上下文词或中心词)、targets(目标词)、negative_samples(负样本)。
  • 模型通过嵌入层和损失计算(正样本得分 + 负样本得分)得到总损失。

步骤2:反向传播计算梯度

1
loss.backward()
  • 自动计算损失对 input_embeddingsoutput_embeddings 权重的梯度。

步骤3:优化器更新参数

1
self.optimizer.step()
  • 根据梯度更新词向量参数,例如:
    • 增大正样本的相似性(如 context_embtarget_emb 的向量方向更接近)。
    • 降低负样本的相似性(如 context_embnegative_emb 的向量方向更远离)。

5. 为什么需要显式调用优化器和反向传播?

  • 参数学习需求
    Word2Vec 的目标是通过调整词向量参数来最小化损失函数,这必须依赖梯度下降类算法。
  • 嵌入层的可训练性
    nn.Embedding 层的权重是模型参数,需要梯度更新(通过 requires_grad=True 自动实现)。
  • 负采样的高效训练
    负采样通过近似 softmax 加速训练,但仍需梯度更新(负样本的嵌入层权重需要调整)。

6. 代码中的关键操作

梯度清零

1
self.optimizer.zero_grad()  # 清空上一批次的梯度
  • 防止梯度累积(每个 batch 独立计算梯度)。

损失计算与梯度传播

1
2
3
loss = self.model(...)  # 前向传播
loss.backward() # 反向传播
self.optimizer.step() # 参数更新
  • 这是 PyTorch 训练神经网络的通用流程。

7. 直观示例

假设模型在某个 batch 中处理以下样本:

  • 正样本(context="cat", target="climb")
  • 负样本(context="cat", negative="math")

训练过程会:

  1. 计算 context_emb("cat")target_emb("climb") 的相似性得分。
  2. 计算 context_emb("cat")negative_emb("math") 的相似性得分。
  3. 通过反向传播调整:
    • 使 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
2
3
self.model(inputs, targets, negative_samples)
└─> nn.Module.__call__()
└─> your_model.forward(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=−log⁡P(wo∣context)−∑i=1klog⁡P(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可以学习到具有良好语义表示的词向量,使得语义上相似的词具有相似的向量表示。具体来说:

  • 最大化相似性:目标是使得目标词与上下文之间的语义关系被模型所捕捉,从而使得相似语义的词在向量空间中靠得更近。
  • 最小化相似性:通过负采样,使得模型学会区分实际的上下文词和随机无关的词,从而避免错误的词语关联。
    最终,这种训练方式使得模型能够有效地将每个词映射到一个低维度的向量空间,并且能够捕捉到词语之间的语义和语法相似性。

实验结果

1742353390143.png

CBOW 实现

由于 torch 版本的不同,原本能运行的代码在电脑上跑不通…让我们来解决一下
报错的原因是 batch size不统一…让我们回到数据加载的代码中看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Word2VecDataset(Dataset):
def __init__(self, corpus, word2idx, window_size, mode='skip-gram'):
self.pairs = [] # 保存中心词和上下文词对
self.mode = mode # 设置 skip-gram 或 cbow 模式
for sentence in corpus:
# 过滤掉不在词汇表中的词,并将句子中的词索引化,得到一个一维列表
indices = [word2idx[word] for word in sentence if word in word2idx]
for center_pos, center_word in enumerate(indices):
# 定义上下文窗口
start = max(0, center_pos - window_size) # 窗口起始位置,注意这里有边界问题,如果窗口越界,则取0
end = min(len(indices), center_pos + window_size + 1) # 窗口结束位置,注意这里也有边界问题,如果窗口越界,则取序列长度
context = indices[start:center_pos] + indices[center_pos+1:end] # 上下文词列表
for context_word in context:
if mode == 'skip-gram':
self.pairs.append((center_word, context_word)) # 保存中心词和上下文词对,将中心词与每一个上下文词配对,共配对len(context)次,表示中心词预测上下文词
elif mode == 'cbow':
self.pairs.append((list(context), center_word)) # 保存上下文词列表和中心词对,注意这里用了元组,表示多个上下文词共同预测中心词
print(f"Total pairs for {mode}: {len(self.pairs)}")

def __len__(self):
return len(self.pairs) # 返回样本数

def __getitem__(self, idx):
return self.pairs[idx] # 返回第idx个配对

其中,对于skip-gram和cbow,采用了不同的配对方式:

1
2
3
4
5
6
7
8
9
# skip-gram:
[center_word, context_word1]
...
[center_word, context_wordn]

# cbow
[[context], center_word]
...
[[context], center_word]

但是[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
2
3
4
5
6
# 假设数据集返回的样本是 (tensor, label)
sample1 = (torch.tensor([1, 2, 3]), 0)
sample2 = (torch.tensor([4, 5, 6]), 1)

# 默认的 collate_fn 会将它们组合成:
batch = (torch.tensor([[1, 2, 3], [4, 5, 6]]), torch.tensor([0, 1]))

2. 为什么需要重写 collate_fn

默认的 collate_fn 假设所有样本的形状和类型是一致的,但在某些情况下,这种假设不成立,这时就需要自定义 collate_fn。以下是一些常见的场景:

(1) 样本长度不一致

例如,在处理变长序列(如文本、音频)时,每个样本的长度可能不同。默认的 collate_fn 无法直接处理这种情况。

  • 解决方法:在 collate_fn 中使用 pad_sequence 对序列进行填充(padding),使它们的长度一致。
1
2
3
4
5
6
7
8
9
from torch.nn.utils.rnn import pad_sequence

def collate_fn(batch):
# batch 是一个列表,每个元素是 (sequence, label)
sequences, labels = zip(*batch)
# 对序列进行填充
sequences_padded = pad_sequence(sequences, batch_first=True, padding_value=0)
labels = torch.tensor(labels)
return sequences_padded, labels

(2) 样本是复杂的数据结构

如果样本是字典、嵌套的元组或其他复杂结构,默认的 collate_fn 可能无法正确处理。

  • 解决方法:在 collate_fn 中手动定义如何组合这些复杂结构。
1
2
3
4
5
def collate_fn(batch):
# batch 是一个列表,每个元素是 {"data": tensor, "label": int}
data = torch.stack([item["data"] for item in batch])
labels = torch.tensor([item["label"] for item in batch])
return {"data": data, "label": labels}

(3) 需要自定义数据处理逻辑

有时,我们可能需要在组合批次时对数据进行额外的处理,例如数据增强、归一化等。

  • 解决方法:在 collate_fn 中添加自定义逻辑。
1
2
3
4
5
6
def collate_fn(batch):
images, labels = zip(*batch)
# 对图像进行归一化
images = torch.stack([normalize(img) for img in images])
labels = torch.tensor(labels)
return images, labels

3. 如何使用 collate_fn

在创建 DataLoader 时,将自定义的 collate_fn 传递给 collate_fn 参数即可:

1
2
3
4
from torch.utils.data import DataLoader

# 假设 dataset 是你的数据集
dataloader = DataLoader(dataset, batch_size=32, shuffle=True, collate_fn=collate_fn)

4. 总结

  • 默认行为collate_fn 默认会将样本堆叠成一个批次,适用于样本形状和类型一致的情况。
  • 重写场景:当样本长度不一致、数据结构复杂或需要自定义处理逻辑时,需要重写 collate_fn
  • 灵活性:通过自定义 collate_fn,可以灵活地处理各种数据形式,满足不同的需求。

解决办法1:直接填0

按照我们上面的介绍,为了处理这种情况,最简单的解决办法就是对不等长的样本进行填充,通常直接填0:

1
2
3
4
5
def collate_fn_cbow(batch):
contexts, targets = zip(*batch) # 把batch解包
max_len = max(len(ctx) for ctx in contexts)
padded_contexts = [ctx + [0]*(max_len - len(ctx)) for ctx in contexts] # 假设0是PAD的索引
return torch.LongTensor(padded_contexts), torch.LongTensor(targets)

这样子当然可以跑,不过有两个问题:

  • 首先0本身也是一个单词的id,让我们回到构建dataset的部分:
1
2
3
4
5
6
7
8
9
10
def build_vocab(corpus, min_count=1):
counter = Counter() # 词频统计
for sentence in corpus:
counter.update(sentence) # 统计每个词的词频,update方法可以累加计数
# 过滤低频词
vocab = {word for word, count in counter.items() if count >= min_count} # 保留词频大于等于min_count的词
word2idx = {word: idx for idx, word in enumerate(vocab)} # 词到索引的映射
idx2word = {idx: word for word, idx in word2idx.items()} # 索引到词的映射
word_counts = np.array([counter[idx2word[i]] for i in range(len(idx2word))], dtype=np.float32) # 词频数组
return word2idx, idx2word, word_counts

enumerate 默认是从0开始的,因此这里word2idx和idx2word实际上0号索引本身是有一个单词对应的。

其次,如果特别设置了0是一个特殊的token,那你用0作为上下文就会影响中心词的表达,因为真实的场景不会有特殊id,也就是说,填充的0会被视为有效上下文词,影响中心词的语义表达。

解决方法2:添加<PAD>标记,设置专门掩码

这个方法需要对build_vocab进行修改,然后再修改cbowforward函数:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def build_vocab(corpus, min_count=1):
counter = Counter()
for sentence in corpus:
counter.update(sentence)

# 显式添加PAD标记
special_tokens = ['<PAD>'] # 索引0
sorted_words = special_tokens + sorted(
[word for word, count in counter.items() if count >= min_count],
key=lambda x: (-counter[x], x)) # 其他词按词频排序

word2idx = {word: idx for idx, word in enumerate(sorted_words)}
idx2word = {idx: word for idx, word in enumerate(sorted_words)}
word_counts = np.array([counter.get(word, 0) for word in sorted_words], dtype=np.float32) # 从 counter 中获取 word 的词频。如果 word 不在 counter 中(例如 <PAD>),则返回默认值 0
return word2idx, idx2word, word_counts

def collate_fn_cbow(batch):
# 解包批次数据:contexts是多个上下文词列表,targets是对应的中心词
contexts, targets = zip(*batch)
# 计算当前批次中最长的上下文长度
max_len = max(len(ctx) for ctx in contexts)
# 获取PAD标记的索引(假设是0)
pad_idx = 0
# 初始化填充后的上下文列表
padded_contexts = []

# 对每个上下文进行填充
for ctx in contexts:
# 计算需要填充的长度
padding_needed = max_len - len(ctx)
# 填充PAD标记(0)
padded_ctx = ctx + [pad_idx] * padding_needed
padded_contexts.append(padded_ctx)

# 转换为Tensor(形状:[batch_size, max_len])
padded_contexts = torch.LongTensor(padded_contexts)
targets = torch.LongTensor(targets)

return padded_contexts, targets

class CBOWModel(nn.Module):
'''
前面的内容不变
'''
def forward(self, context_words, target_words, negative_words):
# context_words形状: [batch_size, context_len]
batch_size, context_len = context_words.shape

# 步骤1:创建掩码(Mask),标识哪些位置是真实的上下文词(非PAD)
# 假设PAD的索引是0,非PAD位置的掩码为True,PAD位置为False
mask = (context_words != 0) # [batch_size, context_len]

# 步骤2:获取上下文词向量
context_emb = self.input_embeddings(context_words) # [batch, context_len, embed_dim]

# 步骤3:应用掩码,将PAD位置的向量置零
# 扩展掩码的维度以匹配词向量的形状:[batch, context_len, 1]
mask_expanded = mask.unsqueeze(-1).float()
context_emb_masked = context_emb * mask_expanded # [batch, context_len, embed_dim]

# 步骤4:计算有效上下文的均值(忽略PAD)
# 求和:沿着context_len维度求和
context_emb_sum = torch.sum(context_emb_masked, dim=1) # [batch, embed_dim]

# 计算有效上下文词的数量(每个样本的context_len减去PAD的数量)
valid_word_counts = torch.sum(mask.float(), dim=1) # [batch]

# 避免除以零(如果某个样本的上下文全是PAD)
valid_word_counts = valid_word_counts.clamp(min=1e-10)

# 计算均值
context_emb_mean = context_emb_sum / valid_word_counts.unsqueeze(-1) # [batch, embed_dim]

# 步骤5:计算目标词和负样本的词向量
# 目标词和负样本通过输出嵌入层转换为向量,形状分别为 (batch_size, embedding_dim) 和 (batch_size, num_negatives, embedding_dim)。
target_emb = self.output_embeddings(target_words) # (batch, embed_dim)
negative_emb = self.output_embeddings(negative_words) # (batch, neg, embed_dim)

# 计算正样本的损失
# 分数计算:上下文向量与目标词向量的逐元素乘积之和(点积),表示两者的相似性。
positive_score = torch.sum(context_emb_mean * target_emb, dim=1) # (batch)
# 损失转换:通过sigmoid将分数映射到(0,1)区间,取对数并添加极小值(1e-10)防止数值溢出。
positive_loss = torch.log(torch.sigmoid(positive_score) + 1e-10)

# 计算负样本的损失
# 分数计算:context_emb.unsqueeze(2) 扩展维度为 (batch, embed_dim, 1),torch.bmm(批量矩阵乘法)计算每个负样本与上下文向量的点积,得到形状 (batch_size, num_negatives)
negative_score = torch.bmm(negative_emb, context_emb_mean.unsqueeze(2)).squeeze() # (batch, neg)
# 损失转换:对负样本分数取负号(因为希望负样本得分低),通过sigmoid和log计算损失,并对所有负样本求和。
negative_loss = torch.sum(torch.log(torch.sigmoid(-negative_score) + 1e-10), dim=1) # (batch)

loss = -(positive_loss + negative_loss) # (batch),负号是为了最小化loss
return loss.mean() # 平均loss

这部分的代码思路有点长,用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
    5
    padded_contexts = [
    [2, 3, 0], # 样本1填充一个0
    [4, 1, 7] # 样本2无需填充
    ]
    targets = [5, 6]

关键点

  • 填充值:使用显式定义的 <PAD> 标记(索引为0),确保不与真实词汇冲突。
  • 输入形状:填充后的上下文张量形状为 [batch_size, max_len],可直接输入模型。

二、改进后的模型 forward 函数(以 CBOW 为例)

功能目标

在计算上下文向量的均值时,忽略填充的 <PAD> 标记,确保填充位置不影响模型学习。

示例说明

假设输入为:

1
2
3
4
context_words = [
[2, 3, 0], # 样本1(第三个位置是PAD)
[4, 1, 7] # 样本2(无PAD)
]
  • 掩码生成

    1
    2
    3
    4
    mask = [
    [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,也能安全计算)。

总结

  1. **collate_fn_cbow**:
    • 作用:将变长的上下文填充到相同长度,生成批量数据。
    • 关键:使用独立的 <PAD> 标记(索引0)填充,不与真实词汇冲突。
  2. 模型 forward 函数
    • 作用:通过掩码机制,在计算上下文向量时忽略填充位置。
    • 关键:利用掩码屏蔽无效位置,确保模型只关注真实的上下文词。
      通过这两项改进,代码可以正确处理 CBOW 模式的变长输入,同时避免 PAD 标记对模型训练的干扰。

最后再运行一次:
image.png

TF-IDF提取关键词

实验介绍

[!question]
Task 1 给出了停用词文档stop.txt、需要统计的目标文档input.txt、以及其余文档组成的文档集合Corpus,需要计算目标文档的TF-IDF结果,返回前十个关键词

TF-IDF(Term Frequency-Inverse Document Frequency)是一种广泛应用于信息检索和文本挖掘的统计方法,用于评估一个词语在一个文档集合或语料库中的重要性。TF-IDF结合了词频(TF)和逆文档频率(IDF)两个指标,通过平衡词语在特定文档中的频率与其在整个语料库中的普遍性,来识别出对文档具有代表性和区分性的关键词

  • 原理:通过计算词语在文档中的出现频率(TF)和该词语在整个语料库中出现的逆频率(IDF),衡量词语的重要性。
  • 优点:简单易实现,效果在结构化文本中表现良好。
  • 缺点:无法捕捉词语之间的关系,对语义信息考虑不足。

TF-IDF的核心思想:

  • 词频(TF, Term Frequency):衡量一个单词在某个文档中出现的频率。
  • 逆文档频率(IDF, Inverse Document Frequency):衡量该单词在整个文档集合中的重要性(如果一个词在许多文档中都出现,则它的区分度较低)。
  • TF-IDF权重:结合TF和IDF,使得在某个文档中频繁出现但在整体文档集合中较少出现的词具有较高的权重。

词频(TF,Term Frequency)

词频(TF)是衡量某个词在文档中出现的频率,它通常有不同的计算方式,最常见的是标准词频
$$TF(t, d) = \frac{\text{词 } t \text{ 在文档 } d \text{ 中出现的次数}}{\text{文档 } d \text{ 中的总词数}}$$
假设我们有一个文档:

1
"The quick brown fox jumps over the lazy dog. The fox is quick."

我们想计算词 "fox" 在该文档中的 TF 值:

  • 该文档共有 11 个词(去掉标点)。
  • "fox" 出现了 2 次。
  • 因此:
    $$TF(\text{“fox”}) = \frac{2}{11} = 0.1818$$
    其他词的TF也可以按此计算。

变种TF计算方式:

  • 对数归一化:$\log(1 + \text{count}(t, d))$,有利于减小高频词对文档的影响,避免某些词对文档特征表示的过度影响
  • 布尔TF(只考虑词是否出现,不计次数):如果单词出现,TF值设为1,否则为0。

逆文档频率(IDF,Inverse Document Frequency)

逆文档频率(IDF)用于衡量一个单词在整个文档集合(corpus)中的重要性。某些常见词(如”the”、”is”)可能在几乎所有文档中都出现,这些词的IDF值应该较低,而稀有词的IDF值应该较高。

计算公式:
$$IDF(t, D) = \log\left(\frac{N}{1 + df(t)}\right)$$
其中:

  • $N$ 是文档总数。
  • $df(t)$ 是包含单词 $t$ 的文档数量(document frequency)。
  • 之所以加 1,是为了避免分母为 0。

示例:

假设我们有 5 篇文档:

  1. “The cat is on the mat.”
  2. “The dog barked at the cat.”
  3. “The fox jumped over the dog.”
  4. “The lazy dog slept all day.”
  5. “A quick brown fox jumps high.”
    假设我们要计算 "fox" 的 IDF:
  • "fox" 出现在 2 篇文档(第3篇和第5篇)。
  • 总共有 5 篇文档。
    $$IDF(\text{“fox”}) = \log\left(\frac{5}{1 + 2}\right) = \log\left(\frac{5}{3}\right) = 0.2218$$

类似地,像 "the" 这样高频词可能出现在所有5篇文档,因此它的 IDF 会更低。

TF-IDF计算

最终,我们将TF和IDF相乘,以得到TF-IDF权重:
$$TFIDF(t, d, D) = TF(t, d) \times IDF(t, D)$$

示例:

假设 "fox" 在文档中的 TF 值是 0.1818,而 IDF 值是 0.2218,则:
$$TFIDF(\text{“fox”}) = 0.1818 \times 0.2218 = 0.0403$$
对于其他词,我们同样可以计算它们的TF-IDF值。

代码实现

我们可以使用 Python 和 sklearn.feature_extraction.text.TfidfVectorizer 计算TF-IDF。

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
from sklearn.feature_extraction.text import TfidfVectorizer

# 文档集合
documents = [
"The cat is on the mat.",
"The dog barked at the cat.",
"The fox jumped over the dog.",
"The lazy dog slept all day.",
"A quick brown fox jumps high."
]

# 初始化TF-IDF向量器
vectorizer = TfidfVectorizer()

# 计算TF-IDF矩阵
tfidf_matrix = vectorizer.fit_transform(documents)

# 获取词汇表
feature_names = vectorizer.get_feature_names_out()

# 输出TF-IDF矩阵
import pandas as pd
df = pd.DataFrame(tfidf_matrix.toarray(), columns=feature_names)
import ace_tools as tools
tools.display_dataframe_to_user(name="TF-IDF 矩阵", dataframe=df)

实验代码

  • read_documents 读入文档集合内容,注意,这里是一个二维列表,[[‘document1’], [‘document2’]…],后面还会通过分词处理成列表[[‘word11’, ‘word12’…],[‘word21’, ‘word22’,…],…]
  • compute_tf 计算词频,传入的token实际上就是上面的[‘word11’, ‘word12’…],然后去遍历里面的内容,通过defaultdict这个数据结构为里面的每个word统计词频,最后再做一个归一化
  • compute_df 实际上在遍历二维列表,第一个for循环遍历所有的document,第二个for循环遍历每个document里面的unique_word(这里需要用set将原本的[‘word11’, ‘word12’…]转化成只出现一次,因为df统计的是单词在多少个文档有出现)
  • compute_idf 传入之前算好的df和总的文档个数,然后遍历键值对进行计算
  • compute_tf_idf 使用enumerate进行枚举,先计算tf,再通过tf中存储的token找到对应的idf进行相乘
  • 整个步骤:读取文件-分词-删去停用词-计算tf-idf
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import os
import thulac
import math
from collections import defaultdict

# 读取文件夹内容
def read_documents(folder_path):
documents = []
for filename in os.listdir(folder_path):
if filename.endswith('.txt'):
filepath = os.path.join(folder_path, filename)
with open(filepath, 'r', encoding='utf-8') as f:
text = f.read()
documents.append(text)
return documents

# 计算词频(TF)
def compute_tf(tokens):
tf = defaultdict(int)
for token in tokens:
tf[token] += 1
total_terms = len(tokens)
for token in tf:
tf[token] /= total_terms
return tf

# 计算文档频率(DF),这里算的是整个文档集合的频率
def compute_df(documents_tokens):
df = defaultdict(int)
for tokens in documents_tokens:
unique_tokens = set(tokens)
for token in unique_tokens:
df[token] += 1
return df

# 计算逆文档频率(IDF)
def compute_idf(df, total_docs):
idf = {}
for token, freq in df.items():
idf[token] = math.log((total_docs + 1) / (1 + freq)) + 1
return idf

# 计算TF-IDF
def compute_tfidf(documents_tokens, idf):
tfidf = {}
for i, tokens in enumerate(documents_tokens):
tf = compute_tf(tokens)
for token, tf_value in tf.items():
tfidf_value = tf_value * idf[token]
if i in tfidf:
tfidf[i][token] = tfidf_value
else:
tfidf[i] = {token: tfidf_value}
return tfidf

# 停用词处理
def remove_stopwords(filepath):
with open(filepath, 'r', encoding='utf-8' ) as f:
stopwords = f.read().split('\n')
return stopwords

# 加载分词工具
thu1 = thulac.thulac(seg_only=True)
# print(type(thu1))

with open("作业用到的数据\homework2_input.txt", encoding='utf-8') as f:
input_text = f.read()

folder_path = '作业用到的数据\homework2_Corpus'
documents = read_documents(folder_path)

documents = [input_text] + documents # 整个文档集合

stopwords_path = '作业用到的数据\homework2_stop.txt'
stopwords = remove_stopwords(stopwords_path)
stopwords += [',', '。', '、', '“', '”'] # 手动添加一些停用词
# print(stopwords)

# 分词 + 去停用词,这里得到的是二维列表
documents_tokens = []
for document in documents:
word_str = thu1.cut(document, text=True) # 分词
word_list = word_str.split() # 词列表
filtered_words = [word for word in word_list if word not in stopwords] # 列表推导式
documents_tokens.append(filtered_words)

df_results = compute_df(documents_tokens)
idf_results = compute_idf(df_results, len(documents))
tfidf_results = compute_tfidf(documents_tokens, idf_results)

# 输出第一个文档结果,按照TF-IDF值从大到小排序
sorted_items = sorted(tfidf_results[0].items(), key=lambda x: x[1], reverse=True)[:10]
print("前十个TF-IDF值最高的词:")
for word, score in sorted_items:
print(f"{word}: {score:.4f}")

需要注意的事项

defaultdict和dict的区别

看到计算df和idf的两个代码段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def compute_df(documents_tokens):
df = defaultdict(int)
for tokens in documents_tokens:
unique_tokens = set(tokens)
for token in unique_tokens:
df[token] += 1
return df

# 计算逆文档频率(IDF)
def compute_idf(df, total_docs):
idf = {}
for token, freq in df.items():
idf[token] = math.log((total_docs + 1) / (1 + freq)) + 1
return idf

[!question] 如果把上面的df = defaultdict(int)改成df = {},会报错KeyError。但是下面的idf就是直接建立的字典,为什么有区别?

在 Python 中,字典(dict)和 defaultdict 都可以用来存储键值对,但它们在处理不存在的键时的行为存在本质区别。这种区别直接导致了在某些场景下使用 defaultdict 能够避免错误,而使用普通的 dict 则可能会抛出异常。下面我将详细解释其中的原因,并通过代码示例、内部机制分析以及实际应用场景来说明这一点。

一、普通字典 (dict) 的工作原理

在 Python 中,普通字典是一种哈希表实现的数据结构,它通过键(key)来存储对应的值(value)。当你试图访问字典中不存在的键时,Python 默认会抛出一个 KeyError 异常。这种设计的主要目的是提醒程序员:你正在尝试访问一个未被初始化的值,这可能是程序逻辑上的错误。例如,考虑下面这个简单的代码片段:

1
2
d = {}
print(d['missing']) # 试图访问不存在的键 'missing'

执行上述代码时,Python 会抛出 KeyError: 'missing',因为键 'missing' 并不存在于字典 d 中。
在你的代码中,使用的是如下操作:

1
2
3
tf = {}
for token in tokens:
tf[token] += 1

这里的 tf[token] += 1 实际上包含了两步操作:

  1. 读取 tf[token] 的当前值;
  2. 将该值加 1 后再赋值回 tf[token]
    对于一个普通字典,如果 token 是第一次出现,则字典中没有对应的键,也就无法读取到一个初始值,从而导致 tf[token] 的读取失败,抛出 KeyError 异常。
二、defaultdict 的设计与机制

defaultdict 是 Python 标准库 collections 模块中的一个类,它是 dict 的子类。与普通字典不同的是,defaultdict 需要在创建时传入一个工厂函数(通常是一个无参数的可调用对象),该工厂函数用于在键不存在时生成一个默认值。这样一来,当你访问一个不存在的键时,defaultdict 会自动调用这个工厂函数创建一个默认值,并将该键-值对添加到字典中。

例如,当我们使用 defaultdict(int) 时,传入的工厂函数是 int。调用 int() 会返回 0,因此每次访问一个不存在的键时,默认值就会被设置为 0。这使得我们能够直接进行加法运算而无需担心键是否已经存在。下面是一个简单的例子:

1
2
3
4
from collections import defaultdict

dd = defaultdict(int)
print(dd['apple']) # 由于 'apple' 不存在,会自动调用 int() 返回 0,然后输出 0

在你的代码中,如果将 tf 定义为 defaultdict(int),即:

1
2
3
4
5
from collections import defaultdict

tf = defaultdict(int)
for token in tokens:
tf[token] += 1

当第一次遇到某个 token 时,tf[token] 并不存在,此时 defaultdict 会调用 int(),返回 0,然后进行加法操作,从而不会报错。

三、为什么会报错:dict vs defaultdict
  1. 使用普通字典 (dict)

当你使用普通字典时,如果对一个不存在的键执行 tf[token] += 1,实际上发生的是:

  • 尝试读取 tf[token] 的当前值,但由于该键不存在,Python 抛出 KeyError
  • 程序无法进行后续的加法操作,因为异常中断了执行流程。
    这种行为虽然在某些场景下有助于发现逻辑错误,但在需要统计或计数时,这种严格的键存在性检查反而显得繁琐和不便。
  1. 使用 defaultdict

defaultdict 的内部实现利用了特殊方法 __missing__。当你访问 defaultdict 中一个不存在的键时,__missing__ 方法会被自动调用,进而调用你在创建 defaultdict 时传入的工厂函数,从而生成一个默认值,并将这个键-值对插入到字典中。这样,tf[token] += 1 就能顺利执行,因为即使 token 第一次出现时,其默认值也已经被初始化为 0

具体步骤如下:

  • 遍历 tokens 时,第一次遇到一个新的 token,在执行 tf[token] += 1 前,字典中没有该键;
  • defaultdict 检查到该键不存在后,调用工厂函数 int(),生成默认值 0
  • 然后执行 0 + 1,结果为 1,并将键 token 和值 1 存入字典中;
  • 以后再遇到相同的 token 时,直接在已有的值上累加,不再需要调用工厂函数。

因此,使用 defaultdict(int) 能够避免因键不存在而产生的 KeyError 异常,从而使得累加操作更加简洁和高效。

实验结果

image.png|416

TextRank

[!question] 同样是刚刚的数据集,但是只需要对目标文档去除停用词,并完成textrank关键字提取

TextRank 是一种基于图的无监督关键词提取和文本摘要算法,灵感来源于 PageRank 算法。由 Rada Mihalcea 和 Paul Tarau 在 2004 年提出,TextRank 通过构建词语之间的关系图,利用图中节点的重要性来识别关键字或生成摘要。

  1. 构建图模型:将文本中的词语或句子作为图的节点,根据一定的规则连接节点形成边。
  2. 计算节点重要性:通过迭代算法(类似 PageRank)计算每个节点的权重,反映其在图中的重要性。
  3. 提取关键词或生成摘要:根据节点权重排序,选取权重较高的词语作为关键词,或选择相关句子作为摘要。

以中心窗口的点建边
eg. 中心为3,窗口为5,那么建立(1,3),(2,3),(3,4),(3,5)边

相对于PageRank⾥的⽆权有向图,这⾥建⽴的是⽆权⽆向图(也有的建立的是有权无向图)

TextRank 的基本原理

TextRank 是一种 无监督图排序算法,适用于 自然语言处理(NLP)。它的核心思想是:

  • 文本中的句子或单词 视为 图中的节点
  • 通过 边(edges)连接相关的节点,形成一个无向图。
  • 采用 迭代计算 来确定每个节点(单词或句子)的重要性。
  • 选出最重要的单词作为 关键词,或选出最重要的句子作为 摘要

TextRank 关键词提取

关键词提取的目标是从一段文本中找出最重要的 N 个词。

算法步骤

  1. 文本预处理
    • 分词(Tokenization)
    • 去掉停用词(Stopwords)
    • 仅保留名词、动词、形容词等有意义的词
  2. 构建词图
    • 每个词 作为 一个节点
    • 如果两个词在 一个窗口范围内共现,则在它们之间添加一条边
    • 边的 权重 由词语共现的频率决定
  3. 计算 TextRank 评分
    • 使用 PageRank 公式 迭代更新每个词的权重:$$S(V_i) = (1 - d) + d \sum_{V_j \in In(V_i)} \frac{S(V_j)}{|Out(V_j)|}$$
      • $S(V_i)$ 是节点 $V_i$ 的重要性
      • $d$ 是阻尼系数(一般取 0.85)
      • $In(V_i)$ 是指向 $V_i$ 的所有节点
      • $Out(V_j)$ 是 $V_j$ 指向的节点总数
  4. 停止:
    • 达到迭代次数
    • 收敛
  5. 排序 & 选取关键词
    • 按照得分从高到低排列
    • 选取 前 N 个词 作为关键词

示例

假设有如下文本:

“深度学习是机器学习的一个分支,它在图像识别、自然语言处理等领域表现优秀。”

TextRank 可能会提取出 关键词: ✅ [“深度学习”, “机器学习”, “图像识别”, “自然语言处理”]

TextRank 实现代码 by networkx

下面是一个 Python 代码示例,使用 networkx 计算 TextRank 关键词:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import jieba
import networkx as nx
from collections import Counter
from itertools import combinations

def extract_keywords(text, top_k=5, window_size=2):
# 1. 分词
words = [w for w in jieba.cut(text) if len(w) > 1]

# 2. 构建词图
graph = nx.Graph()
for w1, w2 in combinations(words, 2):
if abs(words.index(w1) - words.index(w2)) < window_size:
graph.add_edge(w1, w2)

# 3. 计算 TextRank 评分
scores = nx.pagerank(graph)

# 4. 排序 & 选取关键词
keywords = sorted(scores, key=scores.get, reverse=True)[:top_k]
return keywords

text = "深度学习是机器学习的一个分支,它在图像识别、自然语言处理等领域表现优秀。"
print(extract_keywords(text))

示例输出

1
['深度学习', '机器学习', '图像识别', '自然语言处理']

使用jieba实现

jieba库中有已经实现的函数

1
2
3
4
5
6
7
8
9
import jieba
import jieba.analyse

with open('作业用到的数据\homework2_input.txt', 'r', encoding='utf-8') as f:
text = f.read()

res = jieba.analyse.textrank(text, topK=10, withWeight=True, allowPOS=('ns', 'n'))

print(res)

得到的结果如下:
image.png

可以看到,与TF-IDF比,人工智能的关键词rank同样是最高的,其他的略有不同。不同是正常的,因为TF-IDF除了目标文档外,还考虑了整个文档集合的逆文档频率IDF,IDF会对关键词的选择产生一定的影响。

手写实现

  • 老师给了一些参考代码,让我们来看看:
  • build_coorrence_graph 创建邻接矩阵(graph,这里是以defaultdict套defaultdict形式出现的,区别于c和c++的数组和vector形式),然后根据窗口大小构建连边(通过+1实现(这个地方会有问题,后面进行介绍))
  • 预处理部分同样需要读取文件,去除停用词。不过这里我们只需要保留名词,因此在模型初始化时,需要thulac(seg_only=False)来进行处理,让模型同时能够给出词性,这样才能在后面只保留名词
  • TextRank需要对一开始的rank字典进行初始化,通常初始化值为 1/words
  • max_itermin_diff是超参数,通常设置在合理值即可
  • 通常设置窗口大小为2,这样方便跟左右两边建立连边
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import thulac
from matplotlib import pyplot as plt
import numpy as np
from collections import defaultdict

with open('作业用到的数据\homework2_input.txt', 'r', encoding='utf-8') as f:
text = f.read()

with open('作业用到的数据\homework2_stop.txt', 'r', encoding='utf-8') as f:
stopwords = f.read().split()

stopwords = [',', '。', '“', '”', '、', '(', ')'] + stopwords

# 模型初始化
thu1 = thulac.thulac(seg_only=False)
words = thu1.cut(text)
# print(words)

# 只保留名词
words = [word[0] for word in words if word[1] == 'n']

# 去除停用词
words = [word for word in words if word not in stopwords]
# print(len(words))

def build_cooccurrence_graph(words, window_size=2):
graph = defaultdict(lambda: defaultdict(int)) # 构建无向图
for i in range(len(words)):
for j in range(i + 1, i + window_size + 1):
if j >= len(words): # 超出窗口大小
break
w1 = words[i] # 窗口左侧词
w2 = words[j] # 窗口右侧词
if w1 == w2: # 同一个词不参与构建图
continue
# 注意无向图
graph[w1][w2] += 1 # 左侧词指向右侧词
graph[w2][w1] += 1 # 右侧词指向左侧词

return graph

graph = build_cooccurrence_graph(words) # 构建图
# print(graph)

max_iter = 100 # 最大迭代次数
damping = 0.85 # 阻尼系数
nodes = len(graph) # 节点数
min_diff = 1e-5 # 收敛值

rank = defaultdict(float)
# 初始化TextRank
rank = {node: 1/len(graph) for node in graph} # 初始化为 1/N
# 预计算每个节点的出边权重总和
outSum = {node: sum(graph[node].values()) for node in graph}

change = []

for iteration in range(max_iter):
rank_new = {} # 新一轮的TextRank
max_change = 0 # 最大变化量
for node in graph: # 遍历图中的所有节点
# print(f"正在处理节点 {node}")
rank_sum = 0.0
for neighbor in graph[node]: # 遍历邻居
# rank_sum += graph[node][neighbor] * (rank[neighbor] / len(graph[neighbor])) # 计算TextRank
rank_sum += (graph[node][neighbor] / outSum[neighbor]) * rank[neighbor]
# rank_sum += (1 / len(graph[neighbor])) * rank[neighbor]
rank_new[node] = (1 - damping) + damping * rank_sum # 更新TextRank
max_change = max(max_change, abs(rank_new[node] - rank[node])) # 更新最大变化量
change.append(max_change)
# print(f"迭代 {iteration+1} 次,最大变化量:{max_change:.8f}")
rank = rank_new
if max_change < min_diff: # 收敛
print(f"迭代在第 {iteration+1} 次时收敛")
break

rank_res = sorted(rank.items(), key=lambda x: x[1], reverse=True)[:10]
print(rank_res)

# 绘制change值变化图
plt.plot(change)
plt.xlabel("Iteration")
plt.ylabel("Change")
# plt.yscale("log")
plt.title("Change of max_change in each iteration")
plt.show()

实验结果

image.png

收敛结果:
可以看到真的发生了收敛
image.png

讨论

[!question] rank_sum里面的三种方法有什么区别?

  1. 这个地方的问题是给每个邻居节点分配的权重问题,讲义上的说法是1/(j的出边个数),这个值一定是小于1的,因此这个样子转移一定会收敛。
  2. 但是原本的代码是graph[node][neighbor]/len(graph[neighbor],这样直接算len,忽略了两个节点之间可能有多条连边(因为在建图的时候是共现过1次就+1),因此会发生一开始的无法收敛问题
  3. 我用的是graph[node][neighbor] / outSum[neighbor],即(节点i与j的共现次数)/(节点j的出边个数),这样子的值同样<1,因此也会收敛,不过跟讲义上的不太一致,因为有加权
  4. 同样的,如果要按照讲义上的说法算的话,那么应该用1 / len(graph[neighbor],这样也能收敛,且rank与加权一致
  5. 好吧,现在有一个新的问题,为什么原本的pagerank算法不需要加权计算呢?.
  6. 原本的pagerank计算的是有向边,拿的是链表或链式前向星实现,一般要是指向了别的页面就建个指针,不会像textrank这样反复指

链式前向星实现

  • 原本经常写用数字表示的节点,这里用的是word指向
  • python里面有很好的库networkx可以实现,如果手动的话代码如下:
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import thulac
from matplotlib import pyplot as plt
from collections import defaultdict

# 读入文本与停用词
with open('作业用到的数据/homework2_input.txt', 'r', encoding='utf-8') as f:
text = f.read()
with open('作业用到的数据/homework2_stop.txt', 'r', encoding='utf-8') as f:
stopwords = f.read().split()

# 额外的标点符号停用词
stopwords = [',', '。', '“', '”', '、', '(', ')'] + stopwords

# 模型初始化,分词并只保留名词
thu1 = thulac.thulac(seg_only=False)
words_tag = thu1.cut(text)
words = [word for word, tag in words_tag if tag == 'n']
# 去除停用词
words = [word for word in words if word not in stopwords]

# 建立单词到编号的映射
unique_words = list(set(words))
word_to_id = {w: i for i, w in enumerate(unique_words)}
id_to_word = {i: w for w, i in word_to_id.items()}
N = len(unique_words)

# 用滑动窗口统计共现(仅记一次,无向边只记录一份)
window_size = 2
edge_dict = {} # key: (min(u, v), max(u, v)) value: 权重
for i in range(len(words)):
u = word_to_id[words[i]]
for j in range(i+1, min(i+window_size+1, len(words))):
v = word_to_id[words[j]]
if u == v:
continue
a, b = (u, v) if u < v else (v, u)
edge_dict[(a, b)] = edge_dict.get((a, b), 0) + 1

# 使用链式前向星存储图(无向图,我们将每条边添加为双向,但权重只累加一次)
head = [-1] * N # head[u]指向u的第一条边的下标
nxt = [] # nxt[i]指向同一节点的下一条边下标
to = [] # to[i]表示第i条边的终点
w_list = [] # w_list[i]表示第i条边的权重
edge_count = 0

def add_edge(u, v, weight):
global edge_count
nxt.append(head[u])
to.append(v)
w_list.append(weight)
head[u] = edge_count
edge_count += 1

# 根据边字典构造链式前向星——对于每条无向边 (u,v) 加入两条有向边
for (u, v), weight in edge_dict.items():
add_edge(u, v, weight)
add_edge(v, u, weight)

# 计算每个节点的邻边数(度数)
deg = [0] * N
for u in range(N):
i = head[u]
while i != -1:
deg[u] += 1
i = nxt[i]

# TextRank 算法参数设置
max_iter = 100
damping = 0.85
min_diff = 1e-5
rank = [1.0 / N] * N # 初始得分均等
change_list = []

# 迭代更新得分
for iteration in range(max_iter):
rank_new = [(1 - damping)] * N
# 遍历每个节点,将其得分分摊给所有邻居
for v in range(N):
if deg[v] == 0:
continue
contribution = damping * rank[v] / deg[v]
i = head[v]
while i != -1:
u = to[i]
rank_new[u] += contribution
i = nxt[i]
max_change = max(abs(rank_new[i] - rank[i]) for i in range(N))
change_list.append(max_change)
rank = rank_new
if max_change < min_diff:
print(f"迭代在第 {iteration+1} 次时收敛")
break

# 按得分排序,输出前10个关键词
rank_items = [(id_to_word[i], rank[i]) for i in range(N)]
rank_items.sort(key=lambda x: x[1], reverse=True)
top10 = rank_items[:10]
print("Top 10 关键词及其得分:")
for word, score in top10:
print(word, score)

# 绘制每次迭代最大变化量图
plt.plot(change_list)
plt.xlabel("Iteration")
plt.ylabel("Max Change")
plt.title("Change of max_change in each iteration")
plt.show()

运行结果:
image.png
结果与使用邻接矩阵结果一致

homework1:使用工具分词

问题描述

[!info] 分别使用jieba,THULAC,Hanlp对指定文本进行分词处理,并计算精确率

介绍

  • jieba是 Python 中⼀个流⾏的中⽂分词库,它提供了三种分词模式(精确模式、全模式和搜索引擎模式)
  • THULAC是THU推出的一个中文分词与语义标注工具
  • HanLP是基于机器学习工具构建的自然语言处理库
  • NLTK提供了一些评价自然语言处理任务效果的指标计算

具体的使用方法可以查找相关资料

在homework1中,给出了一份简短的中文语义资料 input,需要进行分词。对应的正确结果是 answer,将每个分词用空格隔开

[!note] 需要注意的事项

  • 如何读入数据?
    • with open('path', 'r', encoding='utf-8') as f 常用的打开文件方式,这样可以不写 close
    • 使用read()函数,该函数将文件内容读入,存储为字符串形式
  • 读入以后,由于后续分词的时候自然会将 input 数据按照每个分词转换为列表,故对 test 不做处理。但是 answer 在读入时仍然为一整个字符串,需要先按照空格做切分。split 函数会自动将结果存成一个列表
  • 如何判断结果正确性?
    • 注意,分词任务存在语句前后关系,如果前面分词错误了,那么后面分词很可能也是有问题的
    • 最正确的方法是使用动态规划,转移方程为 \(f[i,j] = \max(f[i,j-1], f[i-1, j], f[i-1,j-1]+(i==j))\)
    • 但如果码力不够的话,可以简化成比较 set 转化后的inputanswer结果的一致程度,这样子可以减小语句前后关系的影响
    • 这里因为只需要判断划分正确与否,所以可以理解成一个简单的二分类问题,因此这里的f1分数是micro-f1,统计所有 token 级别的 TP/FP/FN(例如:正确预测词边界的数量、误判的数量等)
  • 注意下变量名重名的问题…下意识把外面的precision和里面的写成一样的了,但python会报错…

Homework1代码:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# 导入相关metrics
import jieba
import hanlp
import thulac
from nltk.metrics import precision, recall, f_measure

# 读入文件
# 待分词文件
with open('作业\homework1_input.txt', 'r', encoding='utf-8') as f:
test = f.read() # 读取时是一整个字符串

# 标准分词文件
with open('作业\homework1_lac_labels.txt', 'r', encoding='utf-8') as f:
answer = f.read()

answer = answer.split(" ") # 按空格分割,得到一个列表
# print(answer)

def evaluate(pred_sentence, gold_sentence):
"""使用nltk.metric评测f1值, precision, recall"""
precision_score = precision(set(pred_sentence), set(gold_sentence))
recall_score = recall(set(pred_sentence), set(gold_sentence))
f1_score = f_measure(set(pred_sentence), set(gold_sentence))
return precision_score, recall_score, f1_score

# 分词 jieba
jieba_res = jieba.cut(test, cut_all=False)
jieba_res = list(jieba_res)
# print(jieba_res)

# 评测
precision_jieba, recall_jieba, f1_jieba = evaluate(jieba_res, answer)
print("jieba分词结果: ", precision_jieba, recall_jieba, f1_jieba)

# hanlp
HanLP = hanlp.load(hanlp.pretrained.tok.COARSE_ELECTRA_SMALL_ZH)  # 加载模型
hanlp_res = HanLP(test)
# print(hanlp_res)

# 评测
precision_han, recall_han, f1_han = evaluate(hanlp_res, answer)
print("hanlp分词结果: ",precision_han, recall_han, f1_han)

# thulac
thulac_res = []
thu1 = thulac.thulac(seg_only=True) # 注意这里要设置为True,否则会顺便进行词性标注,影响结果
thulac_res = thu1.cut(test, text=True) # 注意这个的返回结果跟jieba和hanlp不同,thulac返回的还是字符串,要做split

thulac_res = thulac_res.split()
# print(thulac_res)

# 评测
precision_thu, recall_thu, f1_thu = evaluate(thulac_res, answer)
print("thulac分词结果: ", precision_thu, recall_thu, f1_thu)

实验结果

最终得分:

method precision recall f1_score
jieba 0.781 0.786 0.784
hanlp 0.895 0.874 0.884
thulac 0.871 0.844 0.857

image.png

结果分析

显而易见,hanlp在各项任务表现较好,其次是thulac,最后是jieba

询问GPT,得到的答复如下:

1. 模型的选择和训练数据

  • HanLP: HanLP 使用了基于深度学习的预训练模型(如 Electra 和 BERT)。这些模型通过大规模的语料库进行训练,能够更好地理解上下文语境,从而提供高质量的分词结果。它们是基于上下文信息的词向量模型,能够有效区分歧义和处理一些复杂的语言现象。
  • thulac: thulac 是一个较为传统的中文分词工具,采用的是基于条件随机场(CRF)模型的分词方法。虽然这种方法在许多任务中表现不错,但由于它对上下文的建模能力较弱,可能会在一些复杂的语句或歧义词的处理上略显不足。
  • jieba: jieba 是一个较为基础的中文分词工具,使用了基于前缀词典和隐马尔可夫模型(HMM)等技术,简单高效,但在一些复杂语境下的分词精度较低。jieba 的分词精度常常依赖于词典的大小和更新,而对长文本和一些特殊词汇的处理能力较弱。

2. 上下文建模能力

  • HanLP 基于深度学习(如 BERT、Electra)模型,能够很好地利用上下文信息来处理词语歧义。
  • thulac 基于 CRF 模型,尽管 CRF 可以考虑到上下文,但其建模能力不如深度学习模型,特别是在处理复杂句子时。
  • jieba 则没有复杂的上下文建模,它主要依赖于词典和统计方法,不能有效地处理多义词和上下文变化。

3. 分词精度

  • HanLP 的分词精度通常比 thulac 和 jieba 要高,尤其是在处理长文本和复杂句子的分词时。它的预训练模型经过了大量的优化,能够更准确地进行分词,尤其是在命名实体、专业术语等领域的表现上较为突出。
  • thulac 作为较为精细的分词工具,其分词精度相对较高,但由于其训练方式和算法与 HanLP 相比稍显逊色,表现稍微差一些。
  • jieba 作为轻量级工具,在速度上有优势,但其在一些难度较高的分词任务中,可能会受到词典和算法的限制,导致精度较低。

4. 优化和应用场景

  • HanLP 更注重对自然语言处理任务(如分词、依存句法分析、命名实体识别等)的综合优化,因此它的分词效果通常比 thulac 和 jieba 要好,特别是在需要更高语境理解的应用场景下。
  • thulac 主要用于快速、高效的分词,但它在一些复杂的上下文场景中可能会受到限制。
  • jieba 尽管非常轻量和高效,适用于许多简单的应用场景,但其分词的准确性和对复杂句子的处理能力相比之下较弱。

总结:

  • HanLP 使用了最先进的深度学习模型,因此在处理复杂文本时,其表现最好。
  • thulac 的基于 CRF 的分词算法也有不错的表现,但它的上下文建模能力略逊色于 HanLP。
  • jieba 虽然速度快且易于使用,但其分词效果相对较弱,尤其是在复杂场景下表现不佳。

所以,HanLP 的优势在于其基于深度学习的预训练模型,而 thulac 和 jieba 则更多依赖于传统的统计和规则方法,因此 HanLP 在许多分词任务中表现最好。

homework2:使用transformers进行词法分词

引言

说起来大家可能会和我一样对这个方法和上面的方法看着好像一点都不一样,但是为什么都叫分词感到疑问…让我们来问问deepseek:

[!question] 中文分词和英文分词是不是不一样的意思?为什么感觉中文的分词倾向于把句子分成一个个的词语,但是英文分词含义更倾向于使用BPE等算法把原本的词分成子词?

是的,中文分词和英文分词的含义和应用场景存在显著差异,这种差异源于两种语言的根本特性不同。以下是详细分析:

1. 核心差异

  • 中文分词
    主要任务是将连续汉字序列切分成有意义的词语(例如“自然语言处理” → “自然/语言/处理”),因为中文没有天然空格分隔词语。
    这是中文NLP的基础预处理步骤,直接影响后续任务(如词性标注、句法分析等)。
  • 英文分词
    传统分词(Tokenization)指按空格和标点分割单词(例如“I love NLP!” → [“I”, “love”, “NLP”, “!”])。
    而用户提到的BPE(Byte-Pair Encoding)等子词算法,属于Subword Tokenization,目的是将单词拆分为更小的语义单元(例如“unhappy” → [“un”, “happy”]),属于词汇扩展技术,用于解决未登录词(OOV)问题。

2. 差异背后的原因

语言特性不同

  • 中文特性
    • 无空格分隔词语,词边界模糊(如“发展中国家”可切分为“发展/中/国家”或“发展中/国家”)。
    • 词语形态固定,缺乏英文的屈折变化(如“run” → “running”)。
    • 必须通过分词明确词单位,否则模型无法直接处理。
  • 英文特性
    • 天然以空格分隔单词,传统分词简单直接。
    • 存在大量形态变化(时态、复数等)和复合词(如“mother-in-law”),子词拆分能捕捉词根和词缀的语义(如“running” → “run”+“ing”)。

技术需求不同

  • 中文分词是刚需:若不先分词,模型会将每个汉字视为独立单元(可能丢失词语信息)。
  • 英文子词算法是优化手段:传统分词已足够,但Subword Tokenization能提升模型对罕见词和词形的处理能力。

3. 现代技术的融合

随着深度学习发展,中英文处理逐渐趋近于子词层面,但路径不同:

  • 中文:通常先进行传统分词,再对词语进一步拆分子词(如百度ERNIE使用字词混合表示)。
  • 英文:直接对原始文本应用BPE/WordPiece(如BERT的Tokenizer),跳过了传统分词步骤。

总结对比表

维度 中文分词 英文分词(Subword Tokenization)
目标 切分词语边界 拆分单词为子词
必要性 必需预处理步骤 优化手段(非必需)
输入 无空格连续文本 已按空格分隔的单词
典型算法 HMM、CRF、Jieba分词工具 BPE、WordPiece、SentencePiece
应用场景 所有中文NLP任务的基础 提升模型对复杂词形的泛化能力

示例说明

  • 中文
    原句:他喜欢自然语言处理技术
    分词结果:他/喜欢/自然语言处理/技术
  • 英文
    原句:ChatGPT uses subword tokenization.
    传统分词:["ChatGPT", "uses", "subword", "tokenization", "."]
    BPE分词:["Chat", "G", "PT", "uses", "sub", "word", "token", "ization", "."]

总结

中文分词和英文Subword Tokenization本质上是不同层级的问题:前者是解决语言书写特性带来的基础问题,后者是提升模型语义理解的工程优化。随着技术进步,两者在子词层面逐渐交汇(如中文直接使用字或子词作为单位),但语言的根本差异仍会导致处理流程不同。

[!question] 好像还有点问题
感觉中文的分词和英文的分词含义应该是不一样的,前者的意思是把一句话给拆分成若干的词(segmentation),但后者应该是Tokenize,即把原本的长的单词拆成若干能表明一定语义的子词(subword),这应该是翻译的锅?但是为什么中文没有未登录词的问题呢?

[!note] asking teacher
省流版:为什么不问问神奇的大模型呢。
认真版本:一般来说segmentation是把句子拆成word,也就是偏向传统和分词,而tokenize是把句子拆成token,也就是偏向bpe这种。但是没有严格区分,广义上一样。中文的话一般基于bpe的没有oov,收录全部的汉字就行了。不在词汇表里的词映射不了,一般用一个叫“unknown”的词统一表示。基于偏旁部首的bpe也有人研究,但是收效甚微。

回到本实验吧

本实验需要使用huggingface打造的transformers

huggingface介绍:Huggingface 超详细介绍 - 基本粒子的文章 - 知乎,是一个托管了大量机器学习模型和数据集的平台
由于被墙了,所以国内通常使用阿里搭建的魔搭社区

本实验需要使用NLP中一个经典的预训练模型BERT,关于BERT的介绍可以参照李宏毅老师的相关课程。

安装方式,以中文版为例:

1
git clone https://www.modelscope.cn/tiansz/bert-base-chinese.git # 中文版

transformers

一般transformer模型有三个部分组成:1.tokennizer,2.Model,3.Post processing。如下图所示,图中第二层和第三层是每个部件的输入/输出以及具体的案例。我们可以看到三个部分的具体作用:Tokenizer就是把输入的文本做切分,然后变成向量,Model负责根据输入的变量提取语义信息,输出logits;最后Post Processing根据模型输出的语义信息,执行具体的nlp任务,比如情感分析,文本自动打标签等;可见Model是其中的核心部分,Model又可以分为三种模型,针对不同的NLP任务,需要选取不同的模型类型:Encoder模型(如Bert,常用于句子分类、命名实体识别(以及更普遍的单词分类)和抽取式问答。),Decoder模型(如GPT,GPT2,常用于文本生成),以及sequence2sequence模型(如BART,常用于摘要,翻译,生成性问答等)(by 基本粒子)

image.png

相关内容说明

image.png

  1. config 控制模型的名称、最终输出的样式、隐藏层宽度和深度、激活函数的类别等。对于初学者来说,大家一般不需要调整。这些参数都可以通过configuration类更改。
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
{
"architectures": [
"BertForMaskedLM" # 模型的名称
],
"attention_probs_dropout_prob": 0.1, # 注意力机制的 dropout,默认为0.1
"directionality": "bidi", # 文字编码方向采用bidi算法
"hidden_act": "gelu", # 编码器内激活函数,默认"gelu",还可为"relu"、"swish"或 "gelu_new"
"hidden_dropout_prob": 0.1, # 词嵌入层或编码器的 dropout,默认为0.1
"hidden_size": 768, # 编码器内隐藏层神经元数量,默认768
"initializer_range": 0.02, # 神经元权重的标准差,默认为0.02
"intermediate_size": 3072, # 编码器内全连接层的输入维度,默认3072
"layer_norm_eps": 1e-12, # layer normalization 的 epsilon 值,默认为 1e-12
"max_position_embeddings": 512, # 模型使用的最大序列长度,默认为512
"model_type": "bert", # 模型类型是bert
"num_attention_heads": 12, # 编码器内注意力头数,默认12
"num_hidden_layers": 12, # 编码器内隐藏层层数,默认12
"pad_token_id": 0, # pad_token_id 未找到相关解释
"pooler_fc_size": 768, # 下面应该是pooler层的参数,本质是个全连接层,作为分类器解决序列级的NLP任务
"pooler_num_attention_heads": 12, # pooler层注意力头,默认12
"pooler_num_fc_layers": 3, # pooler 连接层数,默认3
"pooler_size_per_head": 128, # 每个注意力头的size
"pooler_type": "first_token_transform", # pooler层类型,网上介绍很少
"type_vocab_size": 2, # 词汇表类别,默认为2
"vocab_size": 21128 # 词汇数,bert默认30522,这是因为bert以中文字为单位进入输入
}

2. tokenizer(包含三个文件)

这些文件是tokenizer类生成的,或者处理的,只是处理文本,不涉及任何向量操作。

vocab.txt是词典文件(打开就是单个字符,我这里用的是bert-base-chinsese,可以看到里面都是保留符号和单个汉字索引,字符)

tokenizer.jsonconfig是分词的配置文件,根据vocab信息和你的设置更新,里面把vocab都按顺序做了索引,将来可以根据编码生成one-hot向量,然后跟embeding训练的矩阵相乘,就可以得到该字符的向量。下图是tokenizer.json内容。

模型文件一般是tensorflow(上图中的h5文件)和pytorch(上图中的bin文件)的都有

基本使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
import torch
from transformers import BertModel, BertTokenizer, BertConfig
# 首先要import进来
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
config = BertConfig.from_pretrained('bert-base-chinese')
config.update({'output_hidden_states':True}) # 这里直接更改模型配置
model = BertModel.from_pretrained("bert-base-chinese",config=config)

# 也可以使用pipeline
from transformers import AutoModel
checkpoint = "bert-base-chinese"

model = AutoModel.from_pretrained(checkpoint)

homework2代码

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
26
27
28
from transformers import BertTokenizer

# 中文
# 这里加载分词器,导入模型
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese/bert-base-chinese')

# 读取输入文本
with open('作业\homework1_input.txt', 'r', encoding='utf-8') as f:
test = f.read() # 读取时是一整个字符串

# 对文本进行分词
tokens = tokenizer.tokenize(test)

# 打印分词后的结果
print(tokens) # 由于是分子词,所以对于中文来说,它只能分成一个字一个字

# 英文
tokenizer = BertTokenizer.from_pretrained('bert-base-cased/bert-base-cased')

# 读取输入文本
with open('作业\homework1_corpus.txt', 'r', encoding='utf-8') as f:
test = f.read() # 读取时是一整个字符串

# 对文本进行分词
tokens = tokenizer.tokenize(test) # 英文则可以分成一个词一个词

print(tokens)

中文分词结果:
image.png

input被分成了一个一个字符

英文分词结果:

image.png

可以看到,有的词被分成了更小的子词,如”##ative”后缀

结果分析

按照老师的说法,BERT模型执行的是Word-Pieces子词分词方式,这是Google在构建BERT时的选择,尤其适合句子级别和语义理解任务。

理解tokenizer之WordPiece: Subword-based tokenization algorithm - 打工仔的文章 - 知乎

WordPieces是subword tokenization算法的一种, 最早出现在一篇”Japanese and Korean Voice Search (Schuster et al., 2012)“的论文中,这个方法流行起来主要是因为BERT的出现,这个方法与BPE没有太大的区别,所以也建议可以往下阅读之前,去看下另外一篇step by step介绍BPE的文章,https://towardsdatascience.com/byte-pair-encoding-subword-based-tokenization-algorithm-77828a70bee0(by 打工仔)

呃…突然发现是不是还没介绍过BPE

Sub-word-based tokenization

Subword主要是处于word和char level两个粒度级别之间的一种方法,设计的目的主要是用于解决word级别面临的以下几个问题:

  1. 超大的vocabulary size, 比如中文的常用词可以达到20W个
  2. 通常面临比较严重的OOV问题(out of vocabvulary),会被标记成[UNK]
  3. 词表中的低频词/稀疏词在模型训⽆法得到训练(因为词表⼤⼩有限,太⼤的话会影响效率)。
  4. vocabulary 中存在很多相似的词,如”look”,”looking”,”looked”等,明明是一个意思,但是被当成了不同的词处理。

以及char level存在的以下问题:

  1. 文本序列会变得很长,想象以下如果是一篇英文文章的分类,char level级别的输入长度可以达到上万
  2. 无法对语义进行比较好的表征

subword 不会对高频的词进行拆分,而仅仅是对一些低频的词进行拆分,比如”boy”和”boys”这两个词,boy并不会进行拆分, 而对于低频的”boys”可能会拆分为”boy”和”s”两个更高频的词,其中”boy”表示的是词根,模型通过”boy”去学习”boys”的语义。这样子可以较好的平衡OOV问题。

主流的sub-word tokenization方法有:WordPiece, Byte-Pair Encoding (BPE), Unigram, SentencePiece这四种。

BPE

BPE 最初用于数据压缩,后被引入 NLP。其核心是通过合并高频字符对逐步生成子词单元,将单词拆分为更小的可复用片段。

算法步骤
  1. 初始化词汇表:将所有单词拆分为字符(如英文拆为字母,中文拆为单字)。
  2. 统计词频:统计训练语料中每个单词的频率。
  3. 迭代合并
    • 找出相邻字符对中出现频率最高的一对(例如 ("l", "o")"low" 中出现)。
    • 将这对字符合并为一个新子词,加入词汇表。
    • 重复合并直到达到预设的词汇表大小或无法继续合并。
示例
  • 输入语料:["low", "lower", "newest"](假设频率为5, 2, 3)。
  • 初始拆分:l o w, l o w e r, n e w e s t
  • 合并最高频字符对(如 l olo),生成新子词,逐步形成 low, est 等。

WordPiece

WordPiece和BPE的区别就在每次merge的过程中,BPE是通过合并最高频次的,而WordPiece是选择让似然概率(置信度)最大的值,具体的计算使用合并后的概率值,除以合并前的概率值

举个例子, 在考虑将”e”和”s”合并的时候除了会考虑”es”的概率值,还会考虑”e”和”s”的概率值。或者说,”es”的合并是通过考虑合并带来的价值。

具体的计算:每次合并的两个字符串A和B,应该具有最大的$\dfrac{P(AB)}{P(A)P(B)}$

算法步骤
  1. 初始化词汇表:拆分为字符(类似 BPE)。
  2. 训练语言模型:使用统计方法估计每个子词的概率。
  3. 迭代合并
    • 合并能最大化语言模型似然的字符对。
    • 具体公式:选择合并后使 score = (freq_of_pair) / (freq_of_first_part * freq_of_second_part) 最大的对。

BPE 与 WordPiece 对比

特性 BPE WordPiece
合并策略 基于频率最高的字符对 基于最大化语言模型概率
计算复杂度 较低 较高
语义关联 较弱 较强(依赖概率模型)
实现难度 简单 较复杂
典型应用 GPT、RoBERTa BERT、ALBERT

homework3:手动实现BPE算法

问题描述

[!info] 根据给定语料,手动实现BPE算法(合并次数10000次)并展示运行结果

参考函数

上面介绍完了BPE的思想,那么要怎么手动实现呢?助教给了几个参考函数:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def get_vocab(filename):
'''
读取文件, 进行词汇初始化, 将每个词视为独立的字符: "l o w </w>"
'''
vocab = collections.defaultdict(int) # 创建一个空的词汇表,defaultdict(int)表示默认值是0
# 处理文件
with open(filename, 'r', encoding='utf-8') as f:
for line in f:
# 先按空格分割成单词列表
for word in line.strip().split():
# 处理完整单词
processed = ' '.join(list(word)) + ' </w>' # 将每个单词转成列表,在结尾加结尾符:"hello" -> "h e l l o </w>"
# print(processed + '\n')
vocab[processed] += 1
# print(vocab)
# print('\n')
return vocab

def get_stats(vocab):
'''
该函数计算每个字符的频率和相邻字符对的频率。
pairs 是一个字典,键是相邻字符对,值是该字符对的频率。
'''
pairs = collections.defaultdict(int)
# 遍历词汇表的每个词频对应
for word, freq in vocab.items():
symbols = word.split() # 将单词拆分成字符列表
for i in range(len(symbols)-1): # 遍历所有相邻字符对
pairs[symbols[i], symbols[i+1]] += freq # 统计相邻字符对的频率,eg. {('h', 'e') : 1}

# print(pairs)
# print('\n')
return pairs

def merge_vocab(pair, v_in):
'''
输入:
pair: 一个元组, bpe算法中要合并的两个字符, 即最高频字对
v_in: 词汇表, 即训练集中出现的词汇及其频率
输出:
v_out: 合并后的词汇表, 即训练集中出现的词汇及其频率, 相邻字符对已经被合并成一个字符

该函数将相邻字符对合并成一个字符,并更新词汇表。
v_out 是一个新的词汇表,其中相邻字符对已经被合并成一个字符。
'''
v_out = {} # 初始化一个空的字典
bigram = re.escape(' '.join(pair)) # 将相邻字符对转换为正则表达式格式。例如,如果pair是('a', 'b'),则bigram为'a b'
print(bigram)
p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)') # 构造一个正则表达式,用于匹配词汇表中的相邻字符对
print(p)
# 遍历词汇表的每个单词
for word in v_in:
w_out = p.sub(''.join(pair), word) # 将相邻字符对合并成一个字符
v_out[w_out] = v_in[word] # 生成新的词汇表(合并后的符号替换原字符对)

# print(v_out)
# print('\n')
return v_out

def get_tokens(vocab):
'''
该函数将词汇表中的单词拆分成字符,并添加特殊标记。
tokens 是一个列表,包含每个单词的字符列表,以及特殊标记。
'''
tokens = collections.defaultdict(int)
for word, freq in vocab.items():
word_tokens = word.split() # 将每个单词拆解为最终的子词单元
for token in word_tokens:
tokens[token] += freq # 统计每个子词单元在整个语料库中的总频率
# print(tokens)
# print('\n')
return tokens
  • get_vocab:读取文件, 进行词汇初始化, 将每个词视为独立的字符: “l o w “
  • get_stat:计算每个字符的频率和相邻字符对的频率,返回每个配对的频率。
  • merge_vocab:将最高频字对进行合并,生成更新后的词汇表
  • get_tokens:将词汇表再拆开,统计每个子词单元在整个语料库的总频率

其中,merge_vocab的正则表达式部分需要解释一下:

正则表达式的作用

  1. re.escape(' '.join(pair)):
    • 将字符对(如 ('l', 'o'))转换为安全的正则表达式字符串 'l o',避免特殊字符(如 *+)被错误解析
  2. r'(?<!\S)' + bigram + r'(?!\S)':
    • (?<!\S): 正向否定断言(negative lookbehind),确保字符对前面没有非空白字符(即字符对位于单词开头或前面是空格)。
    • (?!\S): 正向否定断言(negative lookahead),确保字符对后面没有非空白字符(即字符对位于单词末尾或后面是空格)。
    • 作用: 精确匹配独立的字符对,避免部分匹配。例如,防止将 'l o w' 中的 ('l', 'o')('o', 'w') 同时合并,导致错误。

示例

  • 输入词汇表:{"l o w </w>": 5, "l o w e r </w>": 2}
  • 合并字符对 ('l', 'o'):
    • 正则表达式匹配 'l o'(独立出现的位置)。
    • 替换为 'lo',生成新词汇表:{"lo w </w>": 5, "lo w e r </w>": 2}

sub函数

w_out = p.sub(''.join(pair), word)

这行代码的作用是:将词汇表中的一个单词(例如 "l o w </w>")中的某个字符对(如 ('l', 'o'))合并成一个新的子词(如 "lo"),生成新的单词形式(如 "lo w </w>")。

(1) p.sub(...) 的组成
  • p: 是一个编译好的正则表达式对象,用于匹配需要合并的字符对(如 'l o')。
  • .sub(replacement, string): 是正则表达式的替换方法,表示将字符串 string 中所有匹配 p 的部分替换为 replacement
(2) ''.join(pair)
  • pair: 是当前要合并的字符对,例如 ('l', 'o')
  • ''.join(pair): 将字符对拼接成一个新的子词字符串。例如:('l', 'o') → 'lo'
(3) word
  • 原始单词的表示形式,例如 "l o w </w>"(字符间用空格分隔)。

流程步骤

  1. 初始化词汇表 (get_vocab):
    • 将每个单词拆分为字符并添加结束符 </w>,例如 "low""l o w </w>"
    • 统计每个处理后的单词的频率。
  2. 迭代合并字符对 (train_bpe):
    • 统计相邻字符对频率 (get_stats):
      • 遍历词汇表,统计所有相邻字符对(如 ('l', 'o'))的频率。
    • 选择并合并最高频字符对 (merge_vocab):
      • 找到频率最高的字符对(如 ('l', 'o'))。
      • 使用正则表达式合并该字符对,生成新词汇表。
    • 重复上述步骤,直到达到 num_merges 次合并或无可合并的字符对。
  3. 生成最终子词集合 (get_tokens):
    • 将合并后的词汇表拆分为子词,统计每个子词的总频率(如 "lo" 的出现次数)。

代码

结合上面的函数,只需要再写一个 train 函数即可:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
# BPE主算法
def train_bpe(filename, num_merges=10000):
# 初始化词汇表
vocab = get_vocab(filename)

for i in range(num_merges):
# 1. 获取候选字符对
stats = get_stats(vocab)
if not stats:
break # 没有可合并的字符对时提前终止

# 2. 选择最高频的字符对
best_pair = max(stats, key=stats.get)

# 3. 合并字符对并更新词汇表
vocab = merge_vocab(best_pair, vocab)

# 每100次打印进度
if i % 100 == 0:
print(f"Merged {i}/{num_merges} merges")

# 最终获取token集合
tokens = get_tokens(vocab)
print(tokens)
print('\n')
return tokens

if __name__ == "__main__":
# 配置命令行参数
parser = argparse.ArgumentParser(description="BPE Tokenizer Training")
parser.add_argument("--file", type=str, required=True, help="Path to training corpus")
parser.add_argument("--num_merges", type=int, default=10000, help="Number of merge operations")
args = parser.parse_args()

# 执行训练
final_tokens = train_bpe(args.file, args.num_merges)
print(f"Final tokens after {args.num_merges} merges:")
print(final_tokens)

代码执行示例

假设输入文件 test.txt 内容为:

1
low lower newest
  • 初始词汇表:
    1
    {'l o w </w>': 1, 'l o w e r </w>': 1, 'n e w e s t </w>': 1}
  • 第一次合并(最高频字符对 ('e', 's')):
    1
    {'l o w </w>': 1, 'l o w e r </w>': 1, 'n e w es t </w>': 1}
  • 第二次合并(最高频字符对 ('es', 't')):
    1
    {'l o w </w>': 1, 'l o w e r </w>': 1, 'n e w est </w>': 1}

运行结果

1
python .\task3.py --file .\作业\homework1_corpus.txt --num_merges 10000

image.png

可能有些疑惑,为什么好像没有划分成子词?这个与迭代次数有关,如果将迭代次数设小一些,就可以显示出子词划分的结果了

补充资料:

micro-f1和macro-f1

在分词任务中,F1-score 的计算通常采用 micro 平均方式,但需结合具体实现和评估目标来确认。以下是关键解释:

1. Micro-F1 的典型性

分词任务一般被视为一种细粒度的序列标注问题(如 BIO/BILUO 标注)。其评估方式通常是:

  • 统计所有 token 级别的 TP/FP/FN(例如:正确预测词边界的数量、误判的数量等)。
  • 基于这些全局统计量计算 Precision、Recall,最终得到 _micro-F1_。
  • 例如,标准评测脚本 conlleval(常用于 CoNLL 数据集)采用此方法。

2. 为什么不是 Macro-F1?

  • 类别不均衡问题:分词任务中,”非词边界”(如 O 标签)的样本数量远多于词边界标签(如 B/I)。
  • Macro-F1 会平等对待所有类别(包括 O 标签),导致高频标签主导结果,而实际更关注词边界的准确性。
  • 忽略 O 标签:一些评测会仅针对词边界标签(B/I)计算 F1,此时仍基于 micro 方式(汇总 TP/FP/FN)。

3. 特殊情况与注意事项

  • 多类别分词:如果任务涉及多种词类型(如分词+词性),可能需调整评估策略。
  • 工具差异:部分工具或论文可能自定义评估方式,需查阅文档确认(例如是否排除标点、是否区分大小写等)。

总结

默认情况下,分词任务的 F1-score 是 micro 平均,但需结合具体场景和工具验证。如果你在使用特定数据集(如 MSRA、PKU)或工具包(如 Jieba、THULAC),建议参考其官方评测脚本的实现细节。

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment

0%