不像英文那样单词之间有空格作为天然的分界线, 中文词语之间没有明显界限。必须采用一些方法将中文语句划分为单词序列才能进一步处理, 这一划分步骤即是所谓的中文分词。

主流中文分词方法包括基于规则的分词,基于大规模语料库的统计学习方法以及在实践中应用最多的规则与统计综合方法。

隐马尔科夫模型(HMM)是中文分词中一类常用的统计模型, 本文将使用该模型构造分词器。关于HMM模型的介绍可以参见隐式马尔科夫模型.

方法介绍

中文分词问题可以表示为一个序列标注问题,定义两个类别:

  • E代表词语中最后一个字
  • B代表词的首个字
  • M代表词中间的字
  • S代表单字成词

对于分词结果:"我/只是/做了/一些/微小/的/工作",可以标注为"我E只B是E做B了E一B些E微B小E的S工B作E".

将标记序列"EBEBEBEBESBE"作为状态序列, 原始文本"我只是做了一些微小的工作"为观测序列. 分词过程即变成了求使给定观测序列出现概率最大的状态序列, 即解码问题。

这里需要说明一下,所谓出现概率最大是指在自然语言中出现概率最大。

根据语料库是否标注, HMM可以进行监督学习和无监督学习。若语料库未标注则需要使用EM算法进行无监督学习, 若语料库已经标注那么可以使用频率估计概率即监督学习方法。

程序实现

定义数据结构

上文中已定义用于标注序列的标签, 也即HMM中的状态:


STATES = {'B', 'M', 'E', 'S'}
STATES = {'B', 'M', 'E', 'S'}


定义HMMSegger类来维护数据:


class HMMSegger:
    def __init__(self):
        self.trans_mat = {}  
        self.emit_mat = {}  
        self.init_vec = {}  
        self.state_count = {} 
        self.word_set = set()
        self.line_num = 0
class HMMSegger:
    def __init__(self):
        self.trans_mat = {}  
        self.emit_mat = {}  
        self.init_vec = {}  
        self.state_count = {} 
        self.word_set = set()
        self.line_num = 0


其中:

  • trans_mat: 状态转移矩阵, trans_mat[state1][state2]表示训练集中由state1转移到state2的次数。
  • emit_mat: 观测矩阵, emit_mat[state][char]表示训练集中单字char被标注为state的次数
  • init_vec: 初始状态分布向量, init_vec[state]表示状态state在训练集中出现的次数
  • state_count: 状态统计向量,state_count[state]
  • word_set: 词集合, 包含所有单词
  • line_num: 训练集中的行数

初始化上述数据结构:


def setup(self):
    for state in STATES:
        # build trans_mat
        self.trans_mat[state] = {}
        for target in STATES:
            self.trans_mat[state][target] = 0.0
        # build emit_mat
        self.emit_mat[state] = {}
        # build init_vec
        self.init_vec[state] = 0
        # build state_count
        self.state_count[state] = 0
    self.word_set = set()
    self.line_num = 0
def setup(self):
    for state in STATES:
        # build trans_mat
        self.trans_mat[state] = {}
        for target in STATES:
            self.trans_mat[state][target] = 0.0
        # build emit_mat
        self.emit_mat[state] = {}
        # build init_vec
        self.init_vec[state] = 0
        # build state_count
        self.state_count[state] = 0
    self.word_set = set()
    self.line_num = 0


加载数据:


def load_data(self, filename):
    self.data = file(data_path(filename))
    self.setup()
def load_data(self, filename):
    self.data = file(data_path(filename))
    self.setup()


训练数据集用空格分割单词:

如果 出现 这种 情况 , 
则 增加 的 居民 储蓄 存款 的 全部 或 一 部 只能 抵 作 积压 产品 占用 的 资金 
而 不能 补充 流动资金 正常 需要量 的 增加

训练模型

定义一个工具函数,输入一个单词输出其中每个字的标签:


def get_tags(src):
    tags = []
    if len(src) == 1:
        tags = ['S']
    elif len(src) == 2:
        tags = ['B', 'E']
    else:
        m_num = len(src) - 2
        tags.append('B')
        tags.extend(['M'] * m_num)
        tags.append('S')
    return tags
def get_tags(src):
    tags = []
    if len(src) == 1:
        tags = ['S']
    elif len(src) == 2:
        tags = ['B', 'E']
    else:
        m_num = len(src) - 2
        tags.append('B')
        tags.extend(['M'] * m_num)
        tags.append('S')
    return tags


因为使用标注数据集, 可以使用更简单的监督学习算法:


def train(self):
    for line in self.data:
        # pre processing
        line = line.strip()
        if not line:
            continue
        line = line.decode("utf-8", "ignore")
        self.line_num += 1

        # update word_set
        word_list = []
        for i in range(len(line)):
            if line[i] == " ":
                continue
            word_list.append(line[i])
        self.word_set = self.word_set | set(word_list)

        # get tags
        words = line.split(" ")  # spilt word by whitespace
        line_tags = []
        for word in words:
            if word in stop_words:  # pass stop words
                continue
            line_tags.extend(get_tags(word))

        # update model params
        for i in range(len(line_tags)):
            if i == 0:
                self.init_vec[line_tags[0]] += 1
                self.state_count[line_tags[0]] += 1
            else:
                self.trans_mat[line_tags[i - 1]][line_tags[i]] += 1
                self.state_count[line_tags[i]] += 1
                if word_list[i] not in self.emit_mat[line_tags[i]]:
                    self.emit_mat[line_tags[i]][word_list[i]] = 0.0
                else:
                    self.emit_mat[line_tags[i]][word_list[i]] += 1
def train(self):
    for line in self.data:
        # pre processing
        line = line.strip()
        if not line:
            continue
        line = line.decode("utf-8", "ignore")
        self.line_num += 1

        # update word_set
        word_list = []
        for i in range(len(line)):
            if line[i] == " ":
                continue
            word_list.append(line[i])
        self.word_set = self.word_set | set(word_list)

        # get tags
        words = line.split(" ")  # spilt word by whitespace
        line_tags = []
        for word in words:
            if word in stop_words:  # pass stop words
                continue
            line_tags.extend(get_tags(word))

        # update model params
        for i in range(len(line_tags)):
            if i == 0:
                self.init_vec[line_tags[0]] += 1
                self.state_count[line_tags[0]] += 1
            else:
                self.trans_mat[line_tags[i - 1]][line_tags[i]] += 1
                self.state_count[line_tags[i]] += 1
                if word_list[i] not in self.emit_mat[line_tags[i]]:
                    self.emit_mat[line_tags[i]][word_list[i]] = 0.0
                else:
                    self.emit_mat[line_tags[i]][word_list[i]] += 1


依次进行读取训练数据-更新词汇表-添加标注- 更新统计量。

类中维护的模型参数均为频数而非频率, 这样的设计使得模型可以进行在线训练。所谓的在线训练是指, 模型随时都可以接受新的训练数据继续训练,不会丢失前次训练的结果。

应用模型

在进行预测前需要定义get_prob方法将频数转换为频率:


def get_prob(self):
    init_vec = {}
    trans_mat = {}
    emit_mat = {}
    # convert init_vec to prob
    for key in self.init_vec:
        init_vec[key] = float(self.init_vec[key]) / self.state_count[key]
    # convert trans_mat to prob
    for key1 in self.trans_mat:
        trans_mat[key1] = {}
        for key2 in self.trans_mat[key1]:
            trans_mat[key1][key2] = float(self.trans_mat[key1][key2]) / self.state_count[key1]
    # convert emit_mat to prob
    for key1 in self.emit_mat:
        emit_mat[key1] = {}
        for key2 in self.emit_mat[key1]:
            emit_mat[key1][key2] = float(self.emit_mat[key1][key2]) / self.state_count[key1]
    return init_vec, trans_mat, emit_mat
def get_prob(self):
    init_vec = {}
    trans_mat = {}
    emit_mat = {}
    # convert init_vec to prob
    for key in self.init_vec:
        init_vec[key] = float(self.init_vec[key]) / self.state_count[key]
    # convert trans_mat to prob
    for key1 in self.trans_mat:
        trans_mat[key1] = {}
        for key2 in self.trans_mat[key1]:
            trans_mat[key1][key2] = float(self.trans_mat[key1][key2]) / self.state_count[key1]
    # convert emit_mat to prob
    for key1 in self.emit_mat:
        emit_mat[key1] = {}
        for key2 in self.emit_mat[key1]:
            emit_mat[key1][key2] = float(self.emit_mat[key1][key2]) / self.state_count[key1]
    return init_vec, trans_mat, emit_mat


预测采用Viterbi算法求得最优路径:


def predict(self, sentence):
    tab = [{}]
    path = {}
    init_vec, trans_mat, emit_mat = self.get_prob()
    # init
    for state in STATES:
        tab[0][state] = init_vec[state] * emit_mat[state].get(sentence[0], 0)
        path[state] = [state]
    # build dynamic search table
    for t in range(1, len(sentence)):
        tab.append({})
        new_path = {}
        for state1 in STATES:
            items = []
            for state2 in STATES:
                if tab[t - 1][state2] > 0:
                    prob = tab[t - 1][state2] * trans_mat[state2].get(state1, 0) * emit_mat[state1].get(sentence[t], 0)
                    items.append((prob, state2))
            (prob, state) = max(items)
            tab[t][state1] = prob
            new_path[state1] = path[state] + [state1]
        path = new_path
    # search best path
    prob, state = max([(tab[len(sentence) - 1][state], state) for state in STATES])
    return path[state]
def predict(self, sentence):
    tab = [{}]
    path = {}
    init_vec, trans_mat, emit_mat = self.get_prob()
    # init
    for state in STATES:
        tab[0][state] = init_vec[state] * emit_mat[state].get(sentence[0], 0)
        path[state] = [state]
    # build dynamic search table
    for t in range(1, len(sentence)):
        tab.append({})
        new_path = {}
        for state1 in STATES:
            items = []
            for state2 in STATES:
                if tab[t - 1][state2] > 0:
                    prob = tab[t - 1][state2] * trans_mat[state2].get(state1, 0) * emit_mat[state1].get(sentence[t], 0)
                    items.append((prob, state2))
            (prob, state) = max(items)
            tab[t][state1] = prob
            new_path[state1] = path[state] + [state1]
        path = new_path
    # search best path
    prob, state = max([(tab[len(sentence) - 1][state], state) for state in STATES])
    return path[state]


tab是动态规划表, tab[t][state]表示 时刻t 到达state状态 的所有路径中,概率最大路径的 概率值。

初值tab[0][state]emit_mat[state][char0] * init_vec[state], 其中char0代表输入序列的第一个单字, 若emit_mat中不存在char0的记录则默认为0.

若输入序列的长度为T, 则执行T-1次迭代, 每次迭代在tab中添加一行. 计算t-1时刻各状态转移到state状态的概率, 选择其中最大者作为tab[t][state]的值, 并将选中的状态转移写入path中。

在tab的最后一行中寻找概率最大的状态作为终态,从path中取出状态转移路径作为标注序列。

最后定义一个工具函数, 根据标注序列将输入的句子分割为词语列表:


def cut_sent(src, tags):
    word_list = []
    start = -1
    started = False
    if len(tags) != len(src):
        return None
    for i in range(len(tags)):
        if tags[i] == 'S':
            if started:
                started = False
                word_list.append(src[start:i])  # for tags: r"BM*S"
            word_list.append(src[i])
        elif tags[i] == 'B':
            if started:
                word_list.append(src[start:i])  # for tags: r"BM*B"
            start = i
            started = True
        elif tags[i] == 'E':
            started = False
            word = src[start:i+1]
            word_list.append(word)
        elif tags[i] == 'M':
            continue
    return word_list
def cut_sent(src, tags):
    word_list = []
    start = -1
    started = False
    if len(tags) != len(src):
        return None
    for i in range(len(tags)):
        if tags[i] == 'S':
            if started:
                started = False
                word_list.append(src[start:i])  # for tags: r"BM*S"
            word_list.append(src[i])
        elif tags[i] == 'B':
            if started:
                word_list.append(src[start:i])  # for tags: r"BM*B"
            start = i
            started = True
        elif tags[i] == 'E':
            started = False
            word = src[start:i+1]
            word_list.append(word)
        elif tags[i] == 'M':
            continue
    return word_list


需要说明的是, 因为概率模型无法保证每个B标记后都有对应的E标记, 因此认为B标记后的S和B标记都会像E标记一样结束一个单词。


完整的代码可以参见hmm.py。

因依赖关系,若要运行代码请clone整个项目。因训练数据集过于庞大故未上传,可自行制备训练数据集。 项目中包含训练过的模型data/hmm.json可以自由体验。