【Datawhale学习笔记】NLP初级分词技术
分词的定义与重要性
分词的任务
是把连续的文本序列切分成具有独立语义的基本单元(即“词”或“词元”)。
- 对于英文等天然有空格作为分隔符的语言,分词相对简单。
- 但对于中文、日文、泰文等语言,文本是连续的字符流,词之间没有明确的边界。例如,“给阿姨倒一杯卡布奇诺”,计算机需要依据算法将其正确地切分为
["给", "阿姨", "倒", "一杯", "卡布基诺"]。
在传统的 NLP 处理流程中,分词是后续所有任务的基础。其处理方式通常是将分词作为一个独立且“硬性”的预处理步骤,这就导致一个微小的分词错误就可能造成语义信息的丢失。这种错误会在后续的处理链条中被不断放大,产生“差之毫厘,谬以千里”的级联效应(Cascading Effect)。例如,在传统的搜索引擎里,一旦“南京市长江大桥”被错分为 ["南京", "市长", "江大桥"],系统就很难再从这三个错误的词块中还原出原始的、正确的地理位置含义,导致搜索结果完全跑偏。现代的 NLP 方法则通过更灵活的切分策略,在很大程度上缓解了这个问题。
通过 jieba 认识分词
基于规则与词典
这是最早期也是最符合直觉的分词方法,它的核心是一部大型词典和一套匹配规则。jieba 的默认模式实现的就是这种方式。它首先基于一个前缀词典(Trie树),高效地构建出一个包含句子中所有可能词语组合的有向无环图(DAG)。接着,通过动态规划算法寻找一条概率最大的路径,作为最终分词结果。
这个过程可以被量化为一个概率计算问题。假设一个分词路径由一个词语序列组成,将其表示为 ,其中 代表序列中的第 i 个词。那么这条路径的概率可以近似为:
其中,每个词 的概率 可以通过其在词典(语料库)中的频率来估算:
jieba的目标就是找到一条路径,使得这个累乘的概率值最大。
Log概率与动态规划
在实际工程中,将大量小于1的概率值直接相乘,很容易导致结果趋近于0,造成浮点数下溢,从而无法比较路径优劣。
jieba 采用了两种关键技术来解决这个问题:
(1)对数概率:利用对数函数 log 的性质,将概率的累乘转换为 log 概率的累加。寻找概率最大值就等价于寻找 log 概率之和的最大值,有效避免了下溢问题。
(2)动态规划:暴力计算所有可能路径概率和的计算量是很大的。jieba 使用动态规划的思想,从句子的末尾开始,从后向前递推计算到每个位置的最优切分路径及其 log 概率之和,并记录下来。最终,从句子开头出发,根据记录好的最优路径信息,就能反推出整个句子的最优分词结果。
对于句子“给阿姨倒一杯卡布奇诺”,在构建好 DAG 之后,jieba 在“一/一杯”这个位置附近,可能会存在两条候选路径:
路径 A :给 阿姨 倒 一杯 卡布奇诺
路径 B :给 阿姨 倒 一 杯 卡布奇诺
如果直接从“概率乘积”的角度看,两条路径的概率近似为:
在真实实现中,jieba 会把上面的乘积全部换成log 概率的加和 来计算:
由于对数函数是单调递增的,谁的 log 概率和更大,谁对应的原始概率也更大。 假设“一杯”是一个高频词,它单独出现的概率 会远大于两个单字“一”和“杯”的概率之积 ,于是就有:
因此:
动态规划要做的,就是**在句子的每一个位置上,自动完成这种“在所有可能后续切分中,挑出 log 概率和最大的那条路径”的比较,而不是像例子里只人工枚举两条路径。最终,它就会把路径 A 选为“全句最优分词结果”。
动手实践
jieba 是目前流行的 Python 中文分词库之一,对初学者较为友好。可以使用如下 pip 命令安装。
jiba的安装
pip install jieba
jiab实践案例
jieba的精确模式正是前文所述 “基于词典和动态规划寻找最大概率路径” 这一方法的典型应用,它会力图将句子尽可能精确地切开。
示例代码
import jieba
text = "我在梦里收到清华大学录取通知书"
seg_list = jieba.lcut(text, cut_all=False) # cut_all=False 表示精确模式
print(seg_list)
程序输出
['我', '在', '梦里', '收到', '清华大学', '录取', '通知书']
下面创建一个自定义字典user_dict.txt:
九头虫
奔波儿灞
体验加载与不加载的区别
# 未加载词典前的错误分词
text = "九头虫让奔波儿灞把唐僧师徒除掉"
print(f"精准模式: {jieba.lcut(text, cut_all=False)}")
# 加载自定义词典
jieba.load_userdict("./user_dict.txt")
print(f"加载词典后: {jieba.lcut(text, cut_all=False)}")
输出结果
精准模式: ['九头', '虫', '让', '奔波', '儿', '灞', '把', '唐僧', '师徒', '除掉']
加载词典后: ['九头虫', '让', '奔波儿灞', '把', '唐僧', '师徒', '除掉']
精确模式工作流程
jieba 精确模式的分词过程
- 文本预处理与分块 (cut 方法):cut 函数是总调度。它首先通过正则表达式 re_han_default 将整个句子切分成连续的汉字区块和非汉字部分(如英文、数字、标点)。非汉字部分被直接输出,而每个汉字区块则被送入核心分词流程。
- 构建有向无环图(DAG) (get_DAG 方法):这一步为每个汉字区块生成一个记录所有可能分词路径的图。
- 计算最优路径 (calc 方法):这是动态规划算法的核心实现,用于从DAG中寻找最优路径。calc 的计算方向很重要:它是从句子的末尾向前反向计算的 (for idx in xrange(N - 1, -1, -1))
- 从路由表中重建结果 (__cut_DAG_NO_HMM 方法):当 calc 计算完成后,route 字典中已经储存了从每个位置出发的最优选择。
get_DAG 的逻辑
- 从句子的第 k 个字开始,向后扫描,形成词语
frag(fragment)。 - 只要 frag 存在于 self.FREQ 这个前缀词典(由主词典与用户词典加载后生成)中,就继续向后扫描。
- 在扫描过程中,如果
self.FREQ[frag]的值 不为0 ,说明 frag 是一个能独立成词的词语,就将它的结束位置 i 记录下来。词频为 0 的词条仅被当作前缀,不会被记录为成词路径。 - 最终,get_DAG 返回一个字典 DAG。
calc计算方法
- 对于句中的每个位置 idx,它会考察所有从 idx 出发的可能词语(由 DAG[idx] 提供)。
- 对于每个可能的词
sentence[idx:x + 1],它会计算一个路径“分数”,这个分数由两部分相加而成:
- 当前词的log概率:源码中为
log(self.FREQ.get(...) or 1) - logtotal。 - 该词之后剩余句子的最优log概率:这部分已经在之前的迭代中计算好并储存在 route[x + 1][0] 中。
- calc 会选择使这个总分数最高的那个词语作为从 idx 出发的“最佳下一步”,并将这个最高分和“下一步”的起始位置 x 记录在
route[idx]中。
__cut_DAG_NO_HMM 的工作
- 它从句子的第 0 位开始 (x=0)。
- 通过 route[x][1] 查找下一步应该跳到哪里,从而得到第一个最优的词。
- 然后将 x 更新为这个词的结束位置,继续循环查找,直到句子末尾。
- 最终,通过 yield 逐个返回最优路径上的所有词语。
统计学习时代的方法
为了解决对人工词典的过度依赖,研究者们转向了统计学习。其核心思想是把分词看作一个序列标注问题。它会为每个字标注其在词中的位置(B-Begin, M-Middle, E-End, S-Single),然后利用隐马尔可夫模型(HMM) 等模型来预测每个字最可能的位置标签序列。也就是为句子中的每个字打上一个位置标签,例如:
- B(Begin):词的开始
- M(Middle):词的中间
- E(End):词的结束
- S(Single):单字成词
这样,“我爱北京”就会被标注为 S S B E。分词任务就变成了为字序列寻找最合理的标签序列。
Jieba 实践:HMM对未登录词的识别
直观地展示了 __cut_DAG 中HMM模块
text = "我在Boss直聘找工作"
# 开启HMM(默认)
seg_list_hmm = jieba.lcut(text, HMM=True)
print(f"HMM开启: {seg_list_hmm}")
# 关闭HMM
seg_list_no_hmm = jieba.lcut(text, HMM=False)
print(f"HMM关闭: {seg_list_no_hmm}")
输出结果
HMM开启: ['我', '在', 'Boss', '直聘', '找', '工作']
HMM关闭: ['我', '在', 'Boss', '直', '聘', '找', '工作']
词性标注
除了分词,jieba 还提供了词性标注功能。采用了词典查询与隐马尔可夫模型相结合的混合策略,来识别出每个词语的语法属性(名词、动词、形容词等)。这需要使用jieba.posseg模块。
import jieba.posseg as pseg
text = "九头虫让奔波儿灞把唐僧师徒除掉"
# HMM=False 强制只使用词典和动态规划
words = pseg.lcut(text, HMM=False)
print(f"默认词性输出: {words}")
输出结果
默认词性输出: [pair('九头虫', 'x'), pair('让', 'v'), pair('奔波儿灞', 'x'), pair('把', 'p'), pair('唐僧', 'nr'), pair('师徒', 'n'), pair('除掉', 'v')]
由于在前面已经通过 jieba.load_userdict() 加载了包含“奔波儿灞”的词典,因此 jieba 已经能够正确地将其切分出来。但是,因为初始词典未提供词性,jieba 会给它一个默认的、不一定准确的词性(如下面的x)。
import jieba.posseg as pseg
text = "九头虫让奔波儿灞把唐僧师徒除掉"
# HMM=False 强制只使用词典和动态规划
words = pseg.lcut(text, HMM=False)
print(f"默认词性输出: {words}")
此时的输出
默认词性输出: [pair('九头虫', 'x'), pair('让', 'v'), pair('奔波儿灞', 'x'), pair('把', 'p'), pair('唐僧', 'nr'), pair('师徒', 'n'), pair('除掉', 'v')]
分词是正确的,但“九头虫”和“奔波儿灞”的词性是x(非语素字),接下来,尝试通过调整词频来“干预”分词路径。如果希望将‘九头虫’完全切分为‘九’、‘头’、‘虫’三个单字,可以修改词典如下。为单个字“九”和“头”赋予了很高的词频,这会使jieba在进行动态规划计算时,认为 九+头 这条路径的概率远大于 九头 这条路径,从而优先选择前者。
将user_pos_dict.txt修改为:
九 10000000 n
头 1000000 n
奔波儿灞 nr
为了让修改生效,需要重新加载词典
# 重新加载修改后的词典
jieba.load_userdict("./user_pos_dict.txt")
dic_words = pseg.lcut(text, HMM=False)
print(f"加载词性词典后: {dic_words}")
输出结果
加载词性词典后: [pair('九', 'n'), pair('头', 'n'), pair('虫', 'n'), pair('让', 'v'), pair('奔波儿灞', 'nr'), pair('把', 'p'), pair('唐僧', 'nr'), pair('师徒', 'n'), pair('除掉', 'v')]
从“分词”到“分块”
字粒度分词(Character-level)
以 BERT 模型为代表,在处理中文时,其最基础的分词策略就是字粒度,即直接将每个汉字视为一个独立的Token
- 优点
- 有效解决了 OOV 问题:常用汉字的数量是有限的(几千个),模型可以构建一个全覆盖的“字表”。任何由标准汉字组成的词语都不会“未登录”,因为构成它的每个字都在字表里。
- 无需维护庞大词典:摆脱了对词典的依赖。
- 缺点
- 丢失词汇语义:像“博物馆”这样的词,其整体语义在输入层面被人为地拆散为三个独立的字,模型需要消耗更多的计算资源在内部重新学习这些字的组合关系。
- 输入序列更长:相较于词,字的序列长度会显著增加,加大了模型的处理负担。
子词分词(Subword)
以 GPT 系列为代表的大语言模型,则采用了更灵活的子词(Subword) 切分方案,其中最主流的算法是 BPE(BytePair Encoding) 3。
BPE的核心思想是:在原始的字符语料库上,迭代地将高频的相邻字节对(或字符对)合并成一个新的、更大的单元,并将其加入词表。
例如,对于语料中频繁出现的 “deeper”,BPE可能会先学习到 “er”,然后是 “deep”,最终可能将 “deeper” 作为一个整体或"deep"+“er” 的组合加入词表。
- 优点
- 有效平衡 :它在“词”和“字”之间取得了较好的平衡。高频词(如“机器学习”)可以被完整保留,低频词(如“擘画”)可以被拆分为更小的有意义的子词或单字(擘+画),而OOV(如一个新造的网络词)则可以被拆解成字符或字节组合,从而在保持信息完整性的前提下有效解决了OOV问题。
- 词表大小可控:可以通过控制合并次数,将词表大小有效控制在预设范围内。
- 缺点 :
- 语言学可解释性不强:BPE等算法产生的子词通常是基于统计频率,而非语言学上的词根、词缀等有意义的语素。这使得分词结果的可解释性变差。
- 对训练语料的强依赖性:子词词表是根据特定语料训练的,对于领域外的文本,其分词效果可能会退化,将很多词切成单字,导致处理效率降低。
番外篇:实践
尝试修改 user_pos_dict.txt 中九和头的词频看看结果有什么不同
修改后的user_pos_dict.txt
九 100000 n
头 1000 n
奔波儿灞 nr
运行结果
加载词性词典后: [pair('九头虫', 'x'), pair('让', 'v'), pair('奔波儿灞', 'nr'), pair('把', 'p'), pair('唐僧', 'nr'), pair('师徒', 'n'), pair('除掉', 'v')]
自行更换代码中使用的 text 短句看看会得到什么输出
text更换为上海是一个美丽的城市后的结果
import jieba.posseg as pseg
text = "上海是一个美丽的城市"
# HMM=False 强制只使用词典和动态规划
words = pseg.lcut(text, HMM=False)
print(f"默认词性输出: {words}")
输出结果
默认词性输出: [pair('上海', 'ns'), pair('是', 'v'), pair('一个', 'm'), pair('美丽', 'ns'), pair('的', 'uj'), pair('城市', 'ns')]
修改user_pos_dict.txt
上 100000000 n
海 100000 n
上海 nr
加载修改后的词典结果
# 重新加载修改后的词典
jieba.load_userdict("./user_pos_dict.txt")
dic_words = pseg.lcut(text, HMM=False)
print(f"加载词性词典后: {dic_words}")
输出结果
加载词性词典后: [pair('九头虫', 'x'), pair('让', 'v'), pair('奔波儿灞', 'nr'), pair('把', 'p'), pair('唐僧', 'nr'), pair('师徒', 'n'), pair('除掉', 'v')]
参考资料
https://github.com/datawhalechina/base-nlp/blob/main/docs/chapter2/03_tokenization.md
- 点赞
- 收藏
- 关注作者
评论(0)