1.3 中文分词

0x00 Abstract

中文分词的目的:对中文句子中的词与词之间加上边界标记,本质就是对中文句子做划分词的边界。

  • 为什么要做中文分词?
    • 因为英文单词之间是天然分开的,但中文没有
  • 怎么做中文分词?
    • 基于统计学习的方法
    • 基于机器学习的方法等

0x01 中文分词的难点

中文分词面临以下难点: - 标准(颗粒度选择,场景多样) - 搜索领域、推荐领域以及其他垂直领域的需求不同 - 歧义(一词多义) - 苹果 => 手机还是水果? - 炒鱿鱼? - 新词(未登录词)

后面两点很容易理解,第一点需要详细阐述一下。在不同的场景中,对于分词颗粒度的选择是不同的。下面举两个例子。 - 切词颗粒度: - 粗颗粒度:适合推荐场景 - 例子:分成 => /环球影城/ - 推荐环球影城有关的 - 而不是跟环球有关,或者其他跟影城有关的 - 细颗粒度:适合搜索场景 - 例子:需要分成 => /环球/影城/ - 保证召回质量,出现的词都涉及到 - 而不是只召回了有关环球影城的,其余的召回就全是无关的。

也就是说对于推荐场景来说,哪怕漏掉一些也没关系,但一定要准;但对于搜索来说,要保证召回的全面。

0x02 中文分词的方法

0x03 案例1:基于字典匹配的方法

在字典树里寻找逐字寻找匹配的词,如果已经匹配切分好的词,那么结束这个词,下一个字符从新的字符开始。

那么如何实现?首先基于前缀树的数据结构构成字典树,然后通过概率语言模型预测分词结果。

1. 一种重要的数据结构(前缀树)

Trie 树,即字典树,又称单词查找树或者键树,是一种树形结构,是一种哈希树的变种,常被搜索引擎系统用于文本词频统计。

优点: 最大限度地减少无谓的字符串比较,查询效率高于哈希表。

有了字典树,就可以实现基于最大长度分词,如下。

正向和逆向最大长度匹配(对输入字符串开始匹配方向不同):

正向最大长度匹配对字符串从前向后匹配,逆向最大长度匹配从后向前匹配。

但是,仅仅基于最大长度匹配的分词方法靠谱吗?

正向与反向最大长度匹配都可能出现特例,比如:

在这个例子中,正向不如反向的效果。一般来讲,因为中文比较复杂以及中文的特殊性,逆向最大匹配大多时候往往会比正向要准确。

由此也可以发现只通过最大长度匹配来分词是不够的。

所以,需要借助概率语言模型来完善分词的效果。

2. 概率语言模型

基础原理

输入=> 对于输入的字符串,以字为单位,根据字典,将所有可能的词都连起来,构成一个有向无环图(DAG)。 如何输出?=> 将所有可能的词的组合概率都计算出来,然后取概率最高的组合。

==输入==:字串 C = c1, c2, ..., cn(以字为单位) ==输出==:词串 S = w1, w2, ..., wn (以词为单位)

从输入到输出,由条件概率 P(S|C) 来衡量——P(S|C):字串 C 产生切分 S 的概率。

说明:上面式子的变形基于贝叶斯公式。 P(A|B) = P(A,B) / P(B) = P(B|A) * P(A) / P(B) P(A, B) = P(B|A) * P(A) = P(A|B) * P(B)

=> 上面的式子可以继续化简。 因为 P(C) = 1 并且 P(C|S) = 1,上式只剩下 P(S)。P(C) = 1 很容易理解,那么为什么 P(C|S) = 1?结合下面的例子理解。

==举个栗子:==

以 P(C|S1) 为例,S1: 南京市/长江/大桥,C:南京市长江大桥,所以 S1 去掉那两个分词斜杠就得到了 C,可得根据 S1 必然能得到 C。因此 P(C|S1) = 1 对于其他的词串字串也一样。

对于字串 C 的分词结果 => seg(C) = argmax P(S) :令 P(S) 取到最大的 S。

那么在本例就只需要计算 P(S1) 与 P(S2)。

如何计算 P(S1)? P(S1) = P(南京市)*P(长江)*P(大桥) 以 P(南京市) 为例。 假设有十篇文章,词语总量为 100,南京市总共出现了 2 次。 那么 P(南京) = 2 / 100 = 0.02 以此类推,算出所有概率。

==优化:== 概率连乘,而且这些概率一般都是小数点后几位,比如 0.00002,那么概率连乘后,最后得到的概率会在小数点后很多位,因此会导致计算机的精度不够。如何解决?

=> 取 log。 - 取 log 的作用 - 防止向下溢出 - 加法比乘法快

N 元语言模型(N-Gram)

  • N-Gram
    • Unigram model:不考虑前面出现的词
    • Bigram model:考虑前面的一个词
    • Trigram model:考虑前面的两个词

如何计算 N-Gram 的概率?

以“广州/本田/雅阁/汽车”为例。

  • 一元:P(广州,本田,雅阁,汽车)= P(广州) * P(本田) * P(雅阁) * P(汽车)
  • 二元:P(广州,本田,雅阁,汽车)= P(广州) * P(本田|广州) * P(雅阁|本田) * P(汽车|雅阁)
  • 三元:P(广州,本田,雅阁,汽车)= P(广州) * P(本田|广州) * P(雅阁|本田,广州) * P(汽车|雅阁,本田)

以 P(本田|广州) 为例,怎么计算?

P(本田|广州) = 广广

即计算本田和广州同时出现的次数(一定要广州在前,本田在后),以及广州出现的次数。

3. 实践

方法一:最大长度匹配(反向)

import sys

WordDic = {}
MaxWordLen = 1

# 写入字典
# 并且找到词语最大长度
def LoadLexicon(lexiconFile):
    global MaxWordLen
    infile = open(lexiconFile, 'r', encoding='gb2312')
    s = infile.readline().strip()  # 每次读一行
    while len(s) > 0:  # 通过判断每行的长度,放在 while 循环里读完整个文件
        #s = s.decode("gb2312")
        WordDic[s] = 1  # 读到的每行的词,放入字典
        if len(s) > MaxWordLen:
            MaxWordLen = len(s)  # 记录词语最大长度
        s = infile.readline().strip()
    infile.close()

def BMM(s):
    global MaxWordLen
    wordlist = []
    i = len(s)  # 句子长度为 i,代表有 i 个字
    while i > 0:
        start = i - MaxWordLen  # 从后向前,以最大长度匹配
        if start < 0:
            start = 0
        while start < i:
            tmpWord = s[start:i]  # 框起来 start 到 i 的字
            if tmpWord in WordDic:
                wordlist.insert(0, tmpWord)  # 在字典中找到匹配的词,那么放入 wordlist
                break
            else:
                start += 1
        # 如果 start 一直向后移动,直到 i(句尾)还没有匹配的词
        # 那么将最后的一个字作为一个分词结果
        if start >= i:
            wordlist.insert(0, s[i-1:i])
            start = i - 1
        i = start
        # 因为 start~i的字已经构成了分词结果
        # 所以下一次循环中,句尾坐标 i 重新赋值,左闭右开,所以不包括 start
    return wordlist

def PrintSegResult(wordlist):
    print("After word-seg:")
    for i in range(len(wordlist)-1):
        print(wordlist[i])
    print(wordlist[len(wordlist)-1])

LoadLexicon("./lexicon.txt")

inputStr = u"南京市长江大桥"

wordlist = BMM(inputStr)
PrintSegResult(wordlist)
# 输出:
# After word-seg:
# 南京
# 市
# 长江
# 大
# 桥

方法二:最大概率(Bigram)

Step 1.
# convert token to id
python CreateLexicon.py

Step 2.
# gen lang model
python BiLMTrain.py

Step 3.
# viterbi
python ViterbiCWS.py

4. 小结

  • 中文分词
    • 容易区分的——登陆词(词表中已有的)=> 字典树(Trie)
    • 不容易区分的——未登录词(新词)=> 单字

通过上面的学习,我们知道中文分词是这样处理的:对于登录词,使用字典树,对于未登录词,分为单字。

那么如何才能更合理的处理未登录词呢?比如 广州/本田/雅/阁/汽车,如何将雅阁分为一个词呢?

=> 隐马尔可夫模型

隐马模型可以像胶水一样,将他认为合适的词粘到一起,合并为一个词。

最后重新总结一下中文分词的技术点,并且引出后面要学习的内容——动态规划。

  • 中文分词的技术
    • 字典树:处理登录词
    • 隐马模型:处理未登录词
    • 动态规划(Viterbi 算法):支撑以上两个技术

0x04 动态规划——Viterbi 算法

多步骤,每步多选择模型的最优选择问题。

每一步的所有选择都保存了前序步骤到当前步骤的最优选择。

依次计算完所有步骤后,通过回溯的方法找到最优选择路径。

这里不是很容易理解,需要好好思考一下。如何通俗地讲解 viterbi 算法? - 知乎

Viterbi 与枚举的区别到底在哪?以上图为例,枚举的话需要 3×3×3 次计算,Viterbi 需要 3×3×2 次计算。假设每一步有 N 种选择,一共有 L 步。那么枚举的时间复杂度为 O(N^L),Viterbi 的时间复杂度为 O(N^2*L),也就是说当这个序列越长(步数 L 越大)时,两种方法的复杂度差距会越大。这时,Viterbi 的优势就体现出来了。

实现参考上一节的实践方法二中 ViterbiCWS.py

0x05 案例2:复用轮子——jieba

1. 安装

方式1:

pip install jieba

方式2:

先下载 http://pypi.python.org/pypi/jieba/

然后解压,运行 python setup.py install

// 具体信息可参考github文档:[https://github.com/fxsjy/jieba](https://link.zhihu.com/?target=https://github.com/fxsjy/jieba)

2. 整体逻辑

梳理一下 jieba 分词的逻辑,也是重新复习一遍中文分词的相关技术。

整体逻辑如上,对于登录词通过前两步处理,对于未登录词,使用 HMM。

进一步拆分:

对于剩下的未登录词,不知道这些字应不应该粘到一起,使用 HMM 判断。

3. 举例子

这里用一个例子带入上面的分词整体逻辑,理解分词的细节。

句子:广州本田雅阁汽车

构成 ==DAG 表示==:

如图可见,每个字用一个 id 表示。0:[0,1,3] 代表通过查字典,0 到 0:广,可以作为一个词;0 到 1:广州,可以作为一个词;0 到 3:广州本田,可以作为一个词。以此类推。

每种分词结果,比如 广州、广州本田、汽车 都有自己的概率。下一步就是找出概率最大的路径组合。

以上的概率是怎么算的?是根据词表(字典)算的。

汽车 为例。比如汽车在词表中的词频为 10,然后词表中所有词的词频相加为 1000,那么 p(汽车)= log(10/1000),为了防止下溢,取个 log。

0x06 案例3:基于统计机器学习的方法

1. 马尔科夫模型

简介

每个状态只依赖之前有限个状态。 - N 阶马尔科夫:依赖之前 n 个状态 - 1 阶马尔科夫:仅仅依赖前一个状态

所以二元语言模型相当于 1 阶马尔科夫。

  • 参数:
    • 状态(S1, S2, ...)——其实在这里就相当于词语
    • 初始概率
      • 比如共 1000 篇文章,其中 100 篇文章以 今天 作为开头
      • 那么 今天 的初始概率就为 100/1000
    • 状态转移概率
      • 其实就是 N-Gram 中,在前 n 个词出现的条件下,当前词出现的概率

==最重要的两类概率:==

例子

p(w1=今天,w2=我,w3=写,w4=了,w5=一个,w6=程序) = p(w1=今天)p(w2=我|w1=今天)p(w3=写|w2=我)……p(w6=程序|w5=一个)

局限

马尔科夫是单序列模型,可以解决的业务问题有限。比如机器翻译、语音识别这种工作,马尔科夫是做不了的。 - 机器翻译:源语言序列 <--> 目标语言序列 - 语音识别:语音信号序列 <--> 文字序列

2. 隐马尔科夫模型 (HMM)

简介

马尔科夫是单序列建模,隐马是双序列建模。

  • 参数:
    • 状态序列(未知)
    • 初始概率
    • 状态转移概率
    • 观察序列(已知)
    • 发射概率

以机器翻译为例,解释一下上面的五个参数。比如翻译 我爱你 —> I love you

  • 我爱你,是已知的观察序列;I love you 是未知的状态序列(想要得到的)。
  • 初始概率和状态转移概率同之前的马尔科夫模型一样,即代表 I 作为开头的概率,以及在前面一个词为 I 的条件下,下一个状态(词)为 love 的概率,等等。
  • 发射概率:从状态序列到观察序列的概率。也就是说,I 翻译为 的概率,love 翻译为 的概率,以此类推。

梳理一下整个模型的过程: 首先完成第一状态,然后依次由当前状态生成下一状态,最后每个状态发射出一个观测值。

因此,最后就是寻找一个最大的联合概率。

应用

那么在 jieba 分词 中,是如何运用隐马模型将那些未登录词的单个字符粘到一起的呢?

广州塔 为例,假设这个词是一个未登录词。那么输入模型就是以单字输入。

如上图,以单字输入模型。

那么每个字的状态有以下几种: 1. B —— 词语开头 2. M —— 词语中间 3. E —— 词语结尾 4. S —— 单字

举例: 汽车——BE 面包车——BME 天天向上——BMME 哦——S

因此通过这四种状态,预测出一个状态序列,就可以得到分词结果。

那么对应着 HMM 的五个参数,依次分析如何实现分词的隐马模型。

  • 观察序列(已知):广 州 塔

  • 状态序列(未知):需要预测的,预测每个位置到底是哪种状态(BMES其中之一)

  • 初始概率(状态序列)

    • 四种概率:P(B), P(M), P(E), P(S)
    • 比如,有 1000 篇文章,那么去统计这些文章的第一个字的状态。比如有 800 篇,第一个字的状态为 B;有 200 篇,第一个字的状态为 S。=> 那么,P(B)=0.8,P(S)=0.2(不可能会有 E 或者 S 这两个状态,但是这两个状态概率要给一个很小的值,防止乘 0)。

在 jieba 分词的代码中印证一下。

git clone https://github.com/fxsjy/jieba.git

cd jieba/jieba/finalseg

cat prob_start.py

# 输出:
# P={'B': -0.26268660809250016,
# 'E': -3.14e+100,
# 'M': -3.14e+100,
# 'S': -1.4652633398537678}

可以看到初始概率文件中一共有四种概率,其中 E 和 M 都是给了一个接近 0 的概率值。

  • 转移概率
    • 如 P(M|B), P(E|B) 等。
    • 以 P(M|B) 为例,怎么计算?
      • =>
      • 统计一共有多少 B 状态,统计出现 B 状态后出现 M 状态的次数。然后相除。

在 jieba 分词的代码中印证一下。

cd jieba/jieba/finalseg

cat prob_start.py

# 输出:
# P={'B': {'E': -0.510825623765990, 'M': -0.916290731874155},
# 'E': {'B': -0.5897149736854513, 'S': -0.8085250474669937},
# 'M': {'E': -0.33344856811948514, 'M': -1.2603623820268226},
# 'S': {'B': -0.7211965654669841, 'S': -0.6658631448798212}}
  • 发射概率
    • 如 P(广|B), P(州|M) 等。
    • 以 P(广|B) 为例,怎么计算?
      • => 广
      • 统计一共有多少 B 状态,然后统计有多少状态是B,同时这个字是“广”的次数。然后相除。

在 jieba 分词的代码中印证一下。

cd jieba/jieba/finalseg

vim prob_emit.py

输出:

以上这些概率,都是通过人工标注,然后统计得来。

总结一下,从具体落地实现的角度,分别需要三张词表。

上面的概率包含了词性。在 jieba 的这个目录可以看到:./jieba/jieba/posseg]

回顾之前的例子:广州塔

对于 HMM 的应用,最典型的就是给定 O(观察序列),找最优的 S(状态序列)。

0x07 实践

以分词为例,在实际业务落地过程中,需要批量处理流式处理协同完成。比如当天24点之前的,昨天的数据使用批量处理;而今天过了24点,实时产生的数据使用流式处理。

使用 Hadoop 批量处理的数据可以追求模型效果,使用复杂的模型;而使用 Flink 等做流式处理的数据,需要考虑延时,追求高效性(避免阻塞),一般使用简单一些的模型,不要要求很完善的效果。

1. 批量分词

待更新

2. 增量分词(流式处理)

cd ./nlp/1.nlp-foundation/1.2.chinese-segmentation/pyweb_pg_test

pip install web.py

python main.py 8888

# 在浏览器中输入:
# http://127.0.0.1:8888/?content=南京市长江大桥

# 或者
curl http://0.0.0.0:8888/\?content\=%E5%8D%97%E4%BA%AC%E5%B8%82%E9%95%BF%E6%B1%9F%E5%A4%A7%E6%A1%A5

0x08 作业

作业: 1. 用自己的话,把“广州塔”的 HMM 逻辑大概介绍一下。 2. 用自己的话,把 Viterbi 的逻辑大概介绍一下。

面试题: > 一道面试题: > 有一个非常非常长的字符串,标记为L,这个字符串里面的每个字符不限于字母和数字,由于字符串太大了,导致内存无法存储 > 另,有M个小的字符串,最大长度为N > 问题:统计出,这些M个小字符串,一共被包含多少次? > > :同使用最大长度匹配做中文分词一样,小字符串构建前缀树,长字符串每次取 N 个字符去匹配小字符串,每次匹配的起点一位一位向后移动。

Conclusion

这篇笔记比较完备地记录了中文分词从理论到实践的全过程。

中文分词的难点: - 中文不像英文天然分词,因此产生了中文分词的需求 - 以及中文分词在不同场景下的需求。 - 比如搜索场景需要分的全,检索结果完善; - 而推荐场景下,需要召回结果准确,不能分的太细,保留原始的完整语义。

中文分词的方法: - 基于字典匹配 - 基于最大长度匹配的方法 - 还有进一步改进,通过概率语言模型(N-Gram)优化的方法。 - 当然,无论是基于字典匹配或者后面的机器学习,词典都可以用前缀树来构建,提高运算的效率。 - 基于机器学习方法 - HMM - CRF

接着也了解了动态规划——Viterbi 算法。HMM 以及 N-Gram 的实现都需要借助维特比算法,找到最大概率的分词组合。或者说,如下图,类似寻找最优路径的问题,从起点到终点,有 N 个步骤,每个步骤有 M 种选择。这样的问题都可以使用 Viterbi 算法,因为当 N 较大时,相比枚举,可节省大量时间空间。

最后学习了从单序列建模的马尔科夫模型到双序列建模的隐马模型。

马尔科夫模型 - 每个状态只依赖之前有限个状态 - 1 阶马尔科夫(只依赖之前一个状态) == 二元语言模型 - 参数 - 状态序列 - 初始概率 - 转移概率

隐马尔科夫模型(HMM): - 单序列建模的马尔科夫模型可以解决的问题有限,因此需要双序列的 HMM - 处理如机器翻译,语音识别,词性标注,拼音纠错等业务 - 机器翻译:源语言序列 <--> 目标语言序列 - 语音识别:源语音信号序列 <--> 文字序列 - 词性标注:源文字序列 <--> 词性序列 - 写/一个/程序 - Verb/Num/Noun - 参数 - 状态序列(未知) - 观察序列(已知) - 初始概率 - 转移概率 - 发射概率(从状态到观察)

并且也对以上的方法都进行了实践。不过批量分词里配置这个 Hadoop 集群环境真的,搞得太开心了 --!

References

算法: 中文分词算法之--最大匹配法 - 知乎 自然语言处理中N-Gram模型介绍 - 知乎 维特比算法(viterbi)原理以及简单实现 - 知乎

环境配置: VMware虚拟机中Centos7网络配置及ping不通思路 虚拟机Linux网络配置——Net模式(CentOS7) 【2022】Centos7.4安装anaconda3 secureCRT连接不上虚拟机解决方案 SecureCRT连接虚拟机连接不上问题记录与解决