01_文本处理与词表示

1 文本处理基本方法

1.1 分词

分词(Tokenization) 是将原始文本切分为若干具有独立语义的最小单元(即 token) 的过程,是所有 NLP 任务的起点。

不同语言有不同的分词策略:

  • 英文
    • 词级分词:用空格或标点进行分割,容易出现 OOV(Out-Of-Vocabulary,未登录词)问题。通常会将其统一替换为特殊标记(如 <UNK>),从而导致语义信息的丢失。
    • 字符级分词:按字母进行分词,几乎不存在 OOV 问题,但由于单个字符语义信息极弱,模型必须依赖更长的上下文来推断词义和结构,增加了建模难度和训练成本。
    • 子词级分词:按照词根词缀等进行分词,既缓解了 OOV 问题,又保留了语义信息,是现代主流分词方式,常见算法有 BPE (Byte Pair Encoding)、WordPiece 和 Unigram Language Model。
  • 中文:
    • 字符级分词:由于中文单个字符的语义信息非常强,因此比英文更适合进行字符级分词。
    • 词级分词:由于中文没有空格等天然词边界,词级分词通常依赖词典、规则或模型来识别词语边界。
    • 子词级分词:虽然中文没有词根词缀,但是 BPE 算法可以通过学习语料中高频的字组合,如“自然”、“语言”,自动构建子词词表。无需人工词典,具有较强的适应能力,是现在主流方法。

1.2 分词工具

1.2.1 中文分词

主流的中文分词工具有如下两类: - 基于词典或模型的传统方法,主要以词为单位进行切分,如 jiebaHanLP,用于传统 NLP 任务。 - 基于子词建模算法(如 BPE)的方式,从数据中自动学习高频字组合,构建子词词表,如 Hugging Face TokenizerSentencePiecetiktoken,常用于大规模预训练语言模型中。

下面介绍常用的 jieba 分词模块。

  • jieba.cutjieba.cut_for_search 返回一个可迭代的 generator
  • jieba.lcutjieba.lcut_for_search 直接返回 list

cut(text, cut_all=False, HMM=True)cut_for_search(text, HMM=True)

  • cut_all 控制是否使用全模式分词。
  • HMM 控制是否使用 HMM 模型。

精确模式(默认):试图将句子最精确地切开,适合文本分析。

1
2
3
4
import jieba 

text = "坤坤爆表示:“自然语言处理是计算机科学领域重要的研究方向”"
'/'.join(jieba.cut(text))
/opt/miniconda3/envs/dl/lib/python3.12/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
Building prefix dict from the default dictionary ...
Loading model from cache /var/folders/7s/cqjc9twd1ngbl_f7129vv6bc0000gn/T/jieba.cache
Loading model cost 0.283 seconds.
Prefix dict has been built successfully.





'坤/坤/爆/表示/:/“/自然语言/处理/是/计算机科学/领域/重要/的/研究/方向/”'

全模式:把句子中所有的可以成词的词语都扫描出来, 速度非常快,但是不能消除歧义。

1
'/'.join(jieba.cut(text, cut_all=True))
'坤/坤/爆/表示/:“/自然/自然语言/语言/处理/是/计算/计算机/计算机科学/算机/科学/领域/重要/的/研究/方向/”'

搜索引擎模式:在精确模式的基础上,对长词再次切分,提高召回率,适合用于搜索引擎分词。

1
'/'.join(jieba.cut_for_search(text,))
'坤/坤/爆/表示/:/“/自然/语言/自然语言/处理/是/计算/算机/科学/计算机/计算机科学/领域/重要/的/研究/方向/”'

自定义词典:创建词表文件,自行添加新词保证更高的正确率。

1
2
3
词语 词频(可省略) 词性(可省略)
自然语言处理 5 n
坤坤爆 3 nz
1
2
jieba.load_userdict('data/userdict.txt')
'/'.join(jieba.cut(text))
'坤坤爆/表示/:/“/自然语言处理/是/计算机科学/领域/重要/的/研究/方向/”'

使用 add_word(word, freq=None, tag=None)del_word(word) 可在程序中动态修改词典。

使用 suggest_freq(segment, tune=True) 可调节单个词语的词频,使其能(或不能)被分出来。

注意:自动计算的词频在使用 HMM 新词发现功能时可能无效。

词表(Vocabulary) 是由语料库构建出的去重后的 token 集合,词表中每个 token 都分配有唯一的 ID,并支持 token 与 ID 之间的双向映射。

1.2.2 英文分词

英文分词工具常用NLTK(Natural Language Toolkit)。通过pip install nltk安装,通常还需要下载 punkt 模型。

1
2
3
4
import nltk

nltk.download('punkt')
nltk.download('punkt_tab')

NLTK 包含许多自然语言处理工具,这里仅介绍一些基本功能,更多详细信息,请参考 NLTK 文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from nltk import word_tokenize, TreebankWordTokenizer, TreebankWordDetokenizer

text = "Natural language processing is so fun."

# 分词

# 方式 1:面向对象
tokenizer = TreebankWordTokenizer()
tokens = tokenizer.tokenize(text)

# 方式 2:函数式,更常用
tokens = word_tokenize(text)
print(f'分词结果:{tokens}')

# 词还原,根据语法规则修复还原
detokenizer = TreebankWordDetokenizer()
text = detokenizer.detokenize(tokens)
print(f'词还原结果:{text}')
分词结果:['Natural', 'language', 'processing', 'is', 'so', 'fun', '.']
词还原结果:Natural language processing is so fun.

此外 NLTK 还提供了BLEU (Bilingual Evaluation Understudy) 评估指标,用于评估机器翻译和文本生成的质量。

BLEU 的分数范围为0-1,分数越高质量越好。BLEU 的计算有两部分组成:

  1. N-gram精确度:
    • 1-gram : 比较单个单词,主要评估内容准确性。
    • 2-gram / 3-gram: 比较连续的词组,主要评估内容流畅性。
    • Clipping (截断): 为了防止机器作弊,会限制某个词的匹配次数不能超过参考答案中实际出现的次数。比如参考答案是 “the cat”,机器输出 “the the the the”)。
  2. 简短惩罚
    • 如果机器生成的句子比参考答案短,分数会大打折扣。
    • 如果没有简短惩罚,机器会偏向生成短句,因为短句容易全对,正确率高。比如参考答案是 “The cat is on the table”,机器输出 “The cat”。
1
2
3
4
5
6
7
8
9
10
11
from nltk.translate.bleu_score import sentence_bleu

# 参考可以是多个句子,使用二维列表
reference = [["自然", "语言", "处理", "是", "最棒", "的"]]
generated = ["自然", "语言", "处理", "是", "最棒", "的"]

print(sentence_bleu(reference, generated))

# 参考变长,BLEU 惩罚短句
generated = ["自然", "语言", "处理", "是", "最棒", "的", "!"]
print(sentence_bleu(reference, generated))
1.0
0.8091067115702212

BLEU 计算简单快速,但是不懂同义词,比如参考为 “He is quick”,预测为 “He is fast”,那么 BLEU 值就会下降。

1.3 命名实体识别

命名实体识别(Named Entity Recognition,简称NER):识别出一段文本中可能存在的命名实体。

命名实体:人名, 地名, 机构名等专有名词,如: 周杰伦, 黑山县, 孔子学院。

1.4 词性标注

词性标注(Part-Of-Speech tagging, 简称POS):标注出一段文本中每个词汇的词性。

1
2
3
4
import jieba.posseg as pseg

res = pseg.lcut(text)
res, res[0].word, res[0].flag
([pair('Natural', 'eng'),
  pair(' ', 'x'),
  pair('language', 'eng'),
  pair(' ', 'x'),
  pair('processing', 'eng'),
  pair(' ', 'x'),
  pair('is', 'eng'),
  pair(' ', 'x'),
  pair('so', 'eng'),
  pair(' ', 'x'),
  pair('fun', 'eng'),
  pair('.', 'm')],
 'Natural',
 'eng')

2 词表示

在分词完成之后,文本被转换为一系列的 token(词、子词或字符),这些符号本身对计算机而言是不可计算的,为了让模型能够理解和处理文本,必须将这些 token 转换为数值形式,这一步就是所谓的词表示(word representation)

2.1 one-hot 编码

one-hot编码,又称独热编码,将词汇表中的每个词映射为一个稀疏向量,向量的长度等于整个词表的大小。该词在对应的位置为 1,其他位置为 0。

one-hot 操作非常简单,但缺点也十分明显,完全割裂了词与词之间的语义关系;并且词表规模较大时,one-hot 编码的稀疏向量长度会非常大,占用大量内存,计算效率也会下降。

2.2 Word2Vec 模型

2.2.1 概述

Word2Vec 通过对大规模语料的学习,为每个词生成一个具有语义意义的稠密向量表示。该向量在该高维空间中的位置反映了单词的含义,使得意思相近的词在空间中距离更近。

Word2Vec 的设计理念源自“分布假设”——即一个词的含义由它周围的词决定。基于这一理念使用神经网络模型,通过学习词与上下文之间的关系,自动为每个词生成一个能够反映语义特征的向量表示。

词向量的开发也包括分析学习到的向量,并探索如何利用向量分析来操控它们。例如,从“国王”一词中减去“男性特质”,加上“女性特质”,就会得到“女王”一词,这体现了“国王之于女王,正如男人之于女人”的类比。也就是说,词语之间许多语义和句法关系在词向量空间中几乎是线性的

Word2Vec 提供了两种典型的模型结构,用于实现对词向量的学习:

  • CBOW(Continuous Bag of Words):连续词袋模型,利用上下文(当前词的前后几个词)来预测中心词。
  • Skip-gram:根据中心词预测上下文词。

两种方法的选择取决于具体任务,Skip-gram 在数据量有限的情况下表现良好,擅长表示不常用词。相比之下,CBOW 能更好地表示常用词。

2.2.2 CBOW 和 Skip-gram

Word2Vec 不依赖人工标注,可以直接利用大规模原始文本作为数据源,由于预测输出都是词语,所有首先对数据源进行分词,并转换为模型可以识别的 one-hot 编码。

详细流程:CBOW、Skip-gram深度解析

CBOW模型的前向传播过程:

  1. 输入上下文:输入 one-hot 表示的上下文词。
  2. 查找词向量:与 Win 矩阵相乘,由于 one-hot 只有一个 1,所以每个词的结果就是从 Win 矩阵中取出对应的一行。Win 实际就相当于词向量矩阵,每一行代表一个词向量,而列数就是词向量的维度,由神经元的数量决定。
  3. 平均词向量:将上下文的多个词向量求和,然后除以词向量的数量,得到平均词向量,得到一个表示整体语义的向量。
  4. 预测中心词:将平均词向量与 Wout 矩阵相乘,得到对整个词表的预测得分。
  5. Softmax 输出:对预测得分进行 Softmax 运算,得到每个词的概率。
  6. 计算损失:将预测概率与真实标签进行交叉熵损失计算。

之后再进行反向传播时,参数矩阵 𝑊𝑖𝑛 中对应的词向量就会被更新,模型通过不断训练,逐步优化这些向量,最终便能得到具有语义的词向量。

Skip-gram 前向传播流程:

  1. 输入中心词:输入用 one-hot 表示的中心词。
  2. 查找词向量:与 Win 相乘,取出表示中心词的词向量。
  3. 预测上下文:与 Wout 相乘,得到整个词表的预测得分。
  4. Softmax 输出:对预测得分进行 Softmax 运算,得到每个词的概率。
  5. 计算损失:将预测概率与真实标签进行交叉熵损失计算。

再经过反向传播便会更新 Win 中对应的词向量,不断迭代,最终得到具有语义信息的词向量。

2.2.3 词向量的训练和使用

词向量训练和加载都可以借助 Gensim 或者 FastText来完成。

pip install gensim 或者 pip install fasttext

Gensim 基本操作:

1
2
3
from gensim.models import KeyedVectors

model = KeyedVectors.load_word2vec_format('model.bin')
  • 训练模型:
1
2
3
4
5
6
7
8
9
10
11
12
13
from gensim.models import Word2Vec

sentences = [['我', '爱', '自然', '语言', '处理'],
['Gensim', '是', '最棒']]

model = Word2Vec(
sentences, # 已分词的句子序列
vector_size=5, # 词向量维度
window=5, # 上下文窗口大小
min_count=1, # 最小词频(低于将被忽略)
sg=1, # 1:Skip-Gram,0:CBOW
workers=6 # 并行训练线程数
)
  • 保存模型:
1
model.wv.save_word2vec_format('model/my_vectors.bin')
  • 查看词向量的维度:model.vector_size
  • Gensim 4.0 之后,将模型和词向量分离,需要使用 wv (word vector)模块,下面所有操作的 model 都要改为 model.wv
  • 获取词向量:model['中国']
  • 计算相似度:model.similarity('中国', '美国')
  • 查找最相似的词:model.most_similar('中国', topn=10)
  • 获取词到索引映射:model.index_to_key,返回列表
  • 获取索引到词的映射:model.key_to_index,返回字典

词的相似度使用余弦相似度计算,越接近 1 语义越接近,越接近 0 越不相关,越接近 -1 越相反,极度不相似。

$$\operatorname{similarity}\left(w_{1}, w_{2}\right)=\cos (\theta)=\frac{\overrightarrow{w_{1}} \cdot \overrightarrow{w_{2}}}{\left\|\overrightarrow{w_{1}}\right\| \cdot\left\|\overrightarrow{w_{2}}\right\|}$$

Fasttext 基本操作:

  • 加载模型: model = fasttext.load_model('model.bin')
  • 训练模型:
1
2
3
4
5
6
7
model = fasttext.train_unsupervised(
'corpus.txt', # 语料路径
model='cbow', # 模型类型
dim=100, # 词向量维度
epoch=5, # 训练轮数
lr=0.1, # 学习率
thread=6 # 线程数
  • 保存模型: model.save_model('model.bin')
  • 获取词向量: model.get_word_vector('中国')
  • 查找最相似的词: model.get_nearest_neighbors('中国')

2.2.4 应用 Word2Vec

在后续训练或预测过程中,模型会首先对输入文本进行分词,再通过词表将每个 token 映射为其对应的 ID,这些 ID 会被输入嵌入层(Embedding Layer),转换为低维稠密的词向量表示。

大多数 NLP 模型中的第一层都是嵌入层,本质上就是一个词向量查找表(lookup table),输入词在词汇表中的索引,输出对应的词向量表示。

嵌入层的参数矩阵有两种常见初始化方式:

  • 随机初始化:随机生成一个矩阵,矩阵的行数等于词汇表大小,列数等于词向量的维度。
1
2
3
4
5
6
import torch
import torch.nn as nn

# 8 个词向量,每个词向量的维度为 5
rand_embed = nn.Embedding(8, 5)
rand_embed.weight.shape
torch.Size([8, 5])
  • 预训练初始化:从预训练的词向量文件中加载矩阵。
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
from gensim.models import KeyedVectors

# 加载进来是 KeyedVectors,不需要使用 wv
word_vec = KeyedVectors.load_word2vec_format('model/my_vectors.bin')

# 获取词到索引的映射
word2idx = word_vec.key_to_index

# 创建词嵌入矩阵,行为词向量个数,列为词向量维度
embedding_matrix = torch.zeros(len(word2idx), word_vec.vector_size)

# 将词向量复制到 embedding_matrix 中
for word, idx in word2idx.items():
embedding_matrix[idx] = torch.tensor(word_vec[word])

embed = nn.Embedding.from_pretrained(
embedding_matrix, # 词向量矩阵,形状为(num_embeddigns,embedding_dim)
freeze=True # 是否冻结词向量,也就是后续反向传播是否更新
)

# 输入词向量
input_words = ['自然', '语言']
input_ids = [word2idx[word] for word in input_words]

# 经过嵌入层转换
output = embed(torch.tensor(input_ids))

# 嵌入层实际就是一个词向量查找表
print(f'{output}\n自然:{word_vec['自然']}\n语言:{word_vec['语言']}')
tensor([[-0.0681, -0.0189,  0.1154, -0.1504, -0.0787],
        [ 0.1462,  0.1014,  0.1352,  0.0153,  0.1270]])
自然:[-0.06810732 -0.01892803  0.11537147 -0.15043275 -0.07872207]
语言:[0.14623532 0.10140524 0.13515386 0.01525731 0.12701781]

2.3 上下文相关词表示

虽然像 Word2Vec 这样的模型已经能够为词语提供具有语义的向量表示,但是它只为每个词分配一个固定的向量表示,不论它在句中出现的语境如何,这种表示被称为静态词向量(static embeddings)。

然而一个词的语义会受到上下文的语境所影响,比如“吃苹果”和“苹果发布新产品”。

上下文相关词表示(Contextual Word Representations),是指词向量会根据所在句子上下文动态变化,从而更好地捕捉其语义。代表性的模型是——ELMo (Embeddings from Language Models),其基于 LSTM 语言模型,使用上下文动态生成每个词的表示。

3 文本特征处理

3.1 N-gram

在先前的词袋模型中,通过统计词频表示文本,虽然简单,但完全忽略了词之间的顺序。90 年代,统计方法成为主流,为了解决这个问题,当时引入了 N-gram 模型,将相邻的 N 个词组合在一起,就可以保留一部分词序信息。

N-gram 是一种基于统计的方法,用于预测给定文本序列中下一个词出现的概率。它基于马尔可夫假设,核心思想是下一个词的出现的概率只依赖于它前面的 N-1 个词。

  • Bigram(2-gram):每个词只与它前面的一个词有关
  • Trigram(3-gram):只考虑它前面的两个词。

N-gram 特征是指从文本中提取出的 n-gram 形式的特征,通常用于机器学习模型的输入。

1
2
3
4
5
6
7
def create_ngram_set(input_list, n=2):
return set(zip(*[input_list[i:] for i in range(n)]))

sentence = ['我', '爱', '自然', '语言', '处理']

print(f'2-gram:{create_ngram_set(sentence)}')
print(f'3-gram:{create_ngram_set(sentence, n=3)}')
2-gram:{('爱', '自然'), ('自然', '语言'), ('我', '爱'), ('语言', '处理')}
3-gram:{('我', '爱', '自然'), ('爱', '自然', '语言'), ('自然', '语言', '处理')}

3.2 文本长度规范

一般模型的输入需要等尺寸大小的矩阵,因此要对每条文本数值映射后的长度进行规范。根据句子长度进行分析,得到覆盖绝大多数文本的合理长度,对超长文本进行截断,对不足文本进行补齐(一般使用数字0)。

由于 DataLoader 的一批次当中,每个样本的长度可能不同,所以需要使用 pad_sequence 函数将句子长度填充到相同长度。

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
data = {
'inputs':
[[3, 5, 7, 9, 10, 11],
[4, 6, 8, 10, 12, 13, 15],
[4, 6, 8, 10, 12, 14, 16, 18, 20],
[5, 7, 9, 11],
[6, 8, 10, 12, 14, 16],
[7, 9, 11, 13, 15, 17, 19]],
'targets': [1, 1, 0, 1, 0, 1]
}

class dataset(torch.utils.data.Dataset):
def __init__(self, data):
self.data = data

def __len__(self):
return len(self.data['inputs'])

def __getitem__(self, idx):
inputs = torch.LongTensor(self.data['inputs'][idx])
# 注意使用 tensor,由于 LongTensor 接收整数时会创建长度为该整数的随机张量
targets = torch.tensor(self.data['targets'][idx])
return inputs, targets

dataset = dataset(data)
for inputs, targets in dataset:
print(f'input: {inputs}, target: {targets}')
input: tensor([ 3,  5,  7,  9, 10, 11]), target: 1
input: tensor([ 4,  6,  8, 10, 12, 13, 15]), target: 1
input: tensor([ 4,  6,  8, 10, 12, 14, 16, 18, 20]), target: 0
input: tensor([ 5,  7,  9, 11]), target: 1
input: tensor([ 6,  8, 10, 12, 14, 16]), target: 0
input: tensor([ 7,  9, 11, 13, 15, 17, 19]), target: 1

DataLoader 有一个 collate_fn 参数,可以自定义数据处理方法,这个函数的输入是一个 batch 的数据,返回值是处理后的 batch 数据。

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
from torch.utils.data import DataLoader
from torch.nn.utils.rnn import pad_sequence

def collate_fn(batch):
# batch 是一个列表,列表中的元素是二元组,列表长度为批次大小
# [(input1, target1),
# (input2, target2),]

# 1. 使用 * 解包为 (input1, target1), (input2, target2) 两个参数
# 2. 使用 zip 打包,取出每个参数的第 i 个元素,把它们组装在一起。
# 3. 结果为[(input1, input2), (target1, target2)],解构赋值
inputs, targets = zip(*batch)

# 记录真实数据的长度,用于填充后续的识别
lengths = [len(input) for input in inputs]

inputs = pad_sequence(inputs, batch_first=True)

return inputs, targets, lengths

dataloader = DataLoader(
dataset=dataset,
batch_size=3,
shuffle=True,
collate_fn=collate_fn
)

for input, target, lengths in dataloader:
print(input)
print(target)
print(lengths)
tensor([[ 4,  6,  8, 10, 12, 13, 15,  0,  0],
        [ 4,  6,  8, 10, 12, 14, 16, 18, 20],
        [ 5,  7,  9, 11,  0,  0,  0,  0,  0]])
(tensor(1), tensor(0), tensor(1))
[7, 9, 4]
tensor([[ 7,  9, 11, 13, 15, 17, 19],
        [ 6,  8, 10, 12, 14, 16,  0],
        [ 3,  5,  7,  9, 10, 11,  0]])
(tensor(1), tensor(0), tensor(1))
[7, 6, 6]

一般 <PAD> 的索引为 0,设置填充之后,在 embedding 中冻结这个权重,这样 <PAD> 的向量就固定了,不会被训练。

1
2
3
4
5
nn.Embedding(
num_embeddings=10,
embedding_dim=5,
padding_idx=0
)
Embedding(10, 5, padding_idx=0)

填充PAD之后,由于最后一个时间步可能不是真实数据,在模型的 forward 函数中,需要截取掉多余的填充数据。

1
2
3
4
5
6
7
8
9
10
11
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence

def forward(self, x, lengths):
# 去除填充
x = pack_padded_sequence(x, lengths, batch_first=True)
x = self.gru(x)
# 恢复填充
x = pad_packed_sequence(x, batch_first=True)
x = self.fc(x[0])

return x

在计算损失的时候,如果算入<PAD>,会引入没有意义的评估标准,使损失值偏大,可以设置 ignore_index 来忽略掉该索引对应的损失值。

1
criterion = nn.CrossEntropyLoss(ignore_index=0)

为了方便演示,这里都是直接使用 0 作为 pad_token_id 的,实际应该使用 tokenizer.pad_token_id 动态获取。

4 文本数据增强

回译(Back Translation)数据增强是一种常见的自然语言处理技术,主要用于提升模型的训练效果,尤其在训练数据稀缺的情况下。它的基本思想是通过将原始文本翻译成另一种语言,然后再将翻译后的文本翻译回原始语言,生成新的训练样本。这种方式可以有效地提高数据的多样性,同时保持原始信息的语义。


01_文本处理与词表示
https://zhubaoduo.com/2024/08/12/大模型开发/06_自然语言处理/01_文本处理与词表示/
作者
baoduozhu
发布于
2024年8月12日
许可协议