分词与词性标注

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),建议参考其官方评测脚本的实现细节。