参考《python自然语言处理实战核心技术与算法》
分词是自然语言处理的一项核心技术。中文分词算法大致分为三类,基于规则的分词算法、基于统计的分词算法、两者结合的分词算法。

一、基于规则的分词算法

基于规则的分词算法核心思想是维护一个词表,收录所有可能词,分词时拿待切分的字符串和此表中的词逐一查询匹配。找到则切分出来,没找到则不切分。

这种方法简单高效,便于理解,但是维护词表是一个很庞大的工程,而且现在的网络新词更新很快,基于规则很难将它们纳入其中。

关键算法是最大匹配算法,即以词表中最长的词的长度为单位步长,在待切分字符串中取步长长度的字串,再在词表中匹配,匹配到则将其切分出来并继续处理下一个步长,没有匹配到则将长度减少1继续进行匹配,直到匹配到或者长度为零,再进行下一轮匹配,直到切分完成。

最大匹配算法分为三种,正向最大匹配算法(MM),逆向最大匹配算法(RMM),双向最大匹配算法(BMM)。

(1)正向最大匹配算法(MM)

正向最大匹配算法的思路是从待切分字符串的头部开始做最大匹配。比如词表中最长词的长度为l,则从字符串中取前l个字符,从词表中匹配,如果匹配到则切分出来,没有匹配到则取前(l-1)个字符继续匹配,直到字串的长度为1,将其单独切分出来。反复进行直到字符串全部被处理。

算法实现:

class MM(object):
    def __init__(self, dictionary, dict_max_length):
        # 维护的词表
        self.dictionary = dictionary
        # 此表中词的最大长度
        self.dict_max_length = dict_max_length

    def cut(self, text: str) -> list:
        # 切分结果
        result = []
        current_index = 0
        while current_index < len(text):
            for i in range(self.dict_max_length, 0, -1):
                sub_text = text[current_index: current_index + i]
                if sub_text in self.dictionary:
                    # 如果在词表中找到,切分并进行下一轮匹配
                    result.append(sub_text)
                    current_index = current_index + i
                    break
        return result

测试一下:

if __name__ == '__main__':
    dic = ["研究", "研究生", "生命", "生", "命", "的", "起源"]
    textTest = "研究生命的起源"
    dict_max = 3

    mm = MM(dic, dict_max)
    print(mm.cut(textTest))

分词结果:

['研究生', '命', '的', '起源']

可以将大部分词拆分出来,但是对于多种拆分方法文本效果不是太好。

(2)逆向最大匹配算法(RMM)

逆向最大匹配的思想是从文本的尾部开始做匹配。比如词表中最长词的长度为l,则从字符串中取最后l个字符,从词表中匹配,如果匹配到则切分出来,没有匹配到则取前=最后(l-1)个字符继续匹配,直到字串的长度为1,将其单独切分出来。反复进行直到字符串全部被处理。

算法实现:

class RMM(object):
    def __init__(self, dictionary, maxLength):
        # 维护的词表
        self.dictionary = dictionary
        # 此表中词的最大长度
        self.maxLength = maxLength

    def cut(self, text: str) -> list:
        # 切分结果
        result = []
        textLength = len(text)
        currentIndex = textLength - 1
        while currentIndex >= 0:
            for length in range(self.maxLength, 0, -1):
                subText = text[currentIndex + 1 - length: currentIndex + 1]
                if subText in self.dictionary or length == 1:
                    # 如果在词表中找到,切分并进行下一轮匹配
                    result.append(subText)
                    currentIndex = currentIndex - length  # 移动指针
                    break
        # 反转列表为正向
        result.reverse()
        return result

测试一下:

if __name__ == '__main__':
    dic = ["研究", "研究生", "生命", "生", "命", "的", "起源"]
    textTest = "研究生命的起源"
    dict_max = 3
    
    rmm = RMM(dic, dict_max)
    print(rmm.cut(textTest))

输出结果:

['研究', '生命', '的', '起源']

在有歧义的分割位置做的比正向最大匹配要好。这时因为中文中偏正结构比较多,统计结果表明逆向最大匹配算法的准确率要比逆向最大匹配算法高。

(3)双向最大匹配算法

通常会把正向最大匹配和逆向最大匹配的分词结果都做出来,然后进行比较,取更合适的,这种算法称为双向最大匹配算法。统计结果表明90%的分词案例中正向和逆向得到的结果一样且正确,9%的案例分词结果不一样但是其中一个是正确的,只有约1%的案例两种分词结果都是错误的。

双向最大匹配的规则入下:

  1. 正反结果一样取任意一个
  2. 正反结果不同,取分词数量较少的
  3. 正反结果不同但是分词数量相同,取单字较少的

算法实现:

class BMM:
    def __init__(self, dictionary, maxLength):
        # 维护的词表
        self.dictionary = dictionary
        # 此表中词的最大长度
        self.maxLength = maxLength

    def cut(self, text: str) -> list:
        mmRes = MM(self.dictionary, self.maxLength).cut(text)
        rmmRes = RMM(self.dictionary, self.maxLength).cut(text)
        # 分词数量不同返回分词数较少的那个
        if not len(mmRes) == len(rmmRes):
            result = rmmRes if len(mmRes) > len(rmmRes) else mmRes
        else:
            # 完全一样返回任意一个,不一样返回单字较少的一个
            same = True
            mmSingleWordCount = 0
            rmmSingleWordCount = 0
            for i in range(len(mmRes)):
                if same and (not mmRes[i] == rmmRes[i]):
                    same = False
                if len(mmRes[i]) == 1:
                    mmSingleWordCount += 1
                if len(rmmRes[i]) == 1:
                    rmmSingleWordCount += 1
            # 完全一样返回任意一个
            if same:
                result = mmRes
            # 不一样返回单字较少的一个
            else:
                result = rmmRes if mmSingleWordCount >= rmmSingleWordCount else mmRes
        return result

测试一下:

if __name__ == '__main__':
    dic = ["研究", "研究生", "生命", "生", "命", "的", "起源"]
    textTest = "研究生命的起源"
    dict_max = 3
    
    bmm = BMM(dic, dict_max)
    print(bmm.cut(textTest))

测试结果:

['研究', '生命', '的', '起源']

二、基于统计的分词

统计分词的想法:在语料库中多次连续出现的字符串有极大可能是一个词,当出现的次数超过一个阈值,就可以把它视为一个词,切分文本时可以当作已知的词来进行匹配。

实现统计分词的一个主要的模型是隐含马尔可夫模型(HMM),采用二元语言模型处理字符串中字的依赖关系(也就是某个字的概率分布只会收到它之前的字的影响,再往前的字不会对它产生影响)。HMM使用状态来表示一个字在一个词中的位置,如状态为[B, M, E, S]分别表示这个字在词语中词首、词中、词尾和单独成词。通过统计一定数量的语料中的状态初始概率,发射概率、状态转移概率(一个字符串的分布概率正比于状态转移概率与发射概率的乘积),再使用viterbi算法对传入的文本逐个字确定状态,从而得到一个最优路径及该路径的一个概率,进而达到分词的目的。

  • 状态初始概率:文本第一个字是某种状态的条件概率。
  • 状态转移概率:从一个状态切换到另一个状态的条件概率。比如从B状态切换到M状态,从S状态切换到E状态等。使用状态转移概率可以有效的避免一些不合理的状态转移,比如从B状态转移到B状态(文本中连续两个字都是词首?)
  • 发射概率:某个状态下是某个字的条件概率。比如B状态下是“天”字的条件概率(词首是“天”字的条件概率)

viterbi算法(知乎上有一个讲的非常明白的如何通俗的讲解viterbi算法),由于HMM使用的是二元语言模型,后一个字的状态概率分布只会受到前一个字的影响,所以可以用viterbi算法做递推从而找到最优路径,这样实现的算法复杂度为O(N*L^2),n是文本长度,l是状态列表的长度,这比暴力计算每一条可能的路径的再比较的算法效率高很多O(L^N)

算法实现:
首先需要提供用于训练的语料,然后初始化状态转移概率、发射概率、状态初始概率

class HMM:
    def __init__(self, trainingSetPath):
        # 分词语料存储路径
        self.trainingSetPath = trainingSetPath
        # 模型缓存路径
        self.modelPath = trainingSetPath + "_model"
        # 状态转移概率, 一个状态转移到另一个状态的概率
        self.transP = {}  # key是状态,value是一个字典,这个字典的key是状态,value是转移到这一个状态的概率
        # 发射概率,状态到词语的条件概率,(在某个状态下是某个字的概率)
        self.emitP = {}  # key是状态,value是一个字典,这个字典的key是具体的字,value是这个字被发射的概率
        # 状态初始概率
        self.startP = {}  # key是状态,value是这个状态作为初始状态的概率
        # 状态集合
        self.stateList = ["B", "M", "E", "S"]

然后是载入模型

def loadModel(self):
        if os.path.exists(self.modelPath):
            # 已经训练好了,把结果读取进内存
            with open(self.modelPath, "rb") as f:
                self.transP = pickle.load(f)
                self.emitP = pickle.load(f)
                self.startP = pickle.load(f)
        else:
            # 训练模型
            self.trainModel()

如何训练模型,就是统计语料库中相关状态出现的次数,再根据次数计算出状态转移概率、发射概率、状态初始概率等数据,

  • 状态转移概率。比如计算从B转移到E的概率,也就是在B条件下E出现的概率,那么就需要统计B出现的次数和BE同时出现的次数,拿BE同时出现的次数比上B出现的次数,就得到了B状态到E状态的状态转移概率,其它的类比即可
  • 发射概率。比如计算M状态下"天"的发射概率,那么需要统计M状态出现的次数和"天"字为M状态的次数,拿"天"字为M状态的次数比上M状态出现的次数就拿到了M状态下"天"的发射概率,其它类比即可。但是有些状态下某个字可能从来没出现,这时需要做加1平滑处理。
  • 状态初始概率。比如计算M状态的初始概率,那么需要统计语料库中所有句子的数量和所有句子中第一个字是M状态的句子的数量(M状态肯定不是第一个字,肯定是0),然后做比即可计算M状态的初始概率。
def trainModel(self):
        # 对一个词做状态标注
        def makeLabel(w):
            if len(w) == 1:
                res = ["S"]
            elif len(w) == 2:
                res = ["B", "E"]
            else:
                res = ["B"] + ["M"] * (len(w) - 2) + ["E"]
            return res

        countDic = {}  # 每个状态出现的次数,key 是状态,value为对应状态在训练集中出现的次数
        # 参数初始化
        for state in self.stateList:
            self.transP[state] = {s: 0.0 for s in self.stateList}
            self.emitP[state] = {}
            self.startP[state] = 0.0
            countDic[state] = 0
        lineNum = -1  # 训练集的行数
        wordSet = set()  # 字集合
        with open(self.trainingSetPath, encoding="utf8") as f:
            for line in f:
                lineNum = lineNum + 1
                line = line.strip()
                if not line:
                    continue
                wordList = [i for i in line if i != " "]  # 这一行的所有字和标点,去掉空格
                # 更新字集合
                wordSet |= set(wordList)
                lineList = line.split()  # 这一行的所有词语
                lineState = []  # 这一行每个字的状态
                # 状态标注
                for words in lineList:
                    lineState.extend(makeLabel(words))
                assert len(wordList) == len(lineState)
                # 更新初始概率,状态转移概率、发射概率
                for index, value in enumerate(lineState):
                    countDic[value] += 1
                    if index == 0:
                        # 更新状态初始概率
                        self.startP[value] += 1
                    else:
                        # 更新状态转移概率
                        self.transP[lineState[index - 1]][value] += 1
                        # 更新发射概率
                        self.emitP[value][wordList[index]] = self.emitP[value].get(wordList[index], 0) + 1
            # 将统计的初始状态次数转化为概率
            self.startP = {k: v * 1.0 / lineNum for k, v in self.startP.items()}
            # 将统计的状态转化次数转化为概率
            for k, v in self.transP.items():
                for k1, v1 in v.items():
                    self.transP[k][k1] = v1 * 1.0 / countDic[k]
            # 将统计的特定状态下某个字出现的次数转化成发射概率,需要加1平滑
            for k, v in self.emitP.items():
                for k1, v1 in v.items():
                    self.emitP[k][k1] = (v1 + 1) * 1.0 / countDic[k]
            # 缓存模型
            with open(self.modelPath, "wb") as modelFile:
                pickle.dump(self.transP, modelFile)
                pickle.dump(self.emitP, modelFile)
                pickle.dump(self.startP, modelFile)

然后是viterbi算法的实现。拿到训练得到的状态转移概率、发射概率、状态初始概率后,根据HMM模型推导出的结论:句子中各个字状态的概率分布正比于各个字的状态转移概率与发射概率的乘积。所以在状态转移路径中找到累乘得到的乘积最大者即为最优路径。

def viterbi(self, text, startP, transP, emitP):
        v = [{}]  # 递推的概率,是一个list,表示每个字是某个状态的概率,list的子项是字典,key是状态,value是这个状态的概率
        path = {}  # 路径,key是当前进度最后一个字的状态,value是从开始到当前进度key状态的最优路径
        # 确定初始概率
        for state in self.stateList:
            v[0][state] = startP[state] * emitP[state].get(text[0], 0)
            path[state] = [state]
        # 从第二个字开始递推,找到最优路径
        for t in range(1, len(text)):
            v.append({})
            newPath = {}  # 处理完这个字之后的新路径
            seen = False  # 这个字是否出现在发射概率中, 没出现的字一定会发射,单独成词
            for state in self.stateList:
                if text[t] in emitP[state].keys():
                    seen = True
                    break
            for y in self.stateList:  # y是下标t的字的状态
                # 因为使用的是二元语言模型,前面的一个字会影响后面的字,因此考虑上一个字的状态,找到从上一个字的状态转移到y状态的最大概率
                # 及状态,拿到状态转移路径
                maxP, state = -1, ""
                for y0 in self.stateList:  # y0是下标t-1的字的状态
                    p = v[t-1][y0] * transP[y0][y] * (emitP[y].get(text[t], 0) if seen else 1.0)
                    if p > maxP:
                        maxP, state = p, y0
                # 更新路径和递推概率
                newPath[y] = path[state] + [y]
                v[t][y] = maxP
            # 缓存路径
            path = newPath
        # 递推结束,从v中找到最后一个字最大的递推概率和相应的最后一个字的状态,再从path中取出相应状态的路径
        mState, mP = "", -1
        for state, p in v[len(text) - 1].items():
            if p > mP:
                mState, mP = state, p
        # 返回最大概率的状态路径及其概率
        return mP, path[mState]

最后是分词,拿到最优路径之后,就可以解析最优路径的状态列表,从而确定分词结果

def cut(self, text: str):
        # 使用训练结果结合viterbi算法拿到最大概率的状态路径及概率值
        p, stateList = self.viterbi(text, self.startP, self.transP, self.emitP)
        begin, next = 0, 0
        for i, char in enumerate(text):
            state = stateList[i]
            if state == "B":
                begin = i
            elif state == "E":
                yield text[begin: i + 1]
                next = i + 1
            elif state == "S":
                yield char
                next = i + 1
        if next < len(text):
            yield text[next:]

书中使用的语料库是人民日报的分词语料。测试一下:

if __name__ == '__main__':
    hmm = HMM("data/trainingSet.txt")
    # start = time.time()
    # hmm.trainModel()
    # end = time.time()
    # print(hmm.startP)
    # print("time -> " + str(end - start))
    hmm.loadModel()
    res = hmm.cut("研究生命的起源")
    print(str(list(res)))

测试结果:

['研究', '生命', '的', '起源']

这里使用了一个较大的语料库,所以测试其它的分词效果也都还可以。比如:

if __name__ == '__main__':
    hmm = HMM("data/trainingSet.txt")
    hmm.loadModel()
    res = hmm.cut("书中使用的语料库是人民日报的分词语料。测试一下:")
    print(str(list(res)))

测试结果:

['书中', '使用', '的', '语料', '库', '是', '人民', '日报', '的', '分词', '语料', '。', '测试', '一下', ':']

初学者的笔记,写的不好,请见谅