中文博大精深,而中文分词是利用计算机完成中文各种复杂应用的基础。本教程试图循序渐进、由浅入深的开发一系列简单的中文分词系统。熟练掌握本课程后不仅可以 diy 分词任务,还可以扩展到机器翻译,知识问答等其他应用场景,当然也可以自动写对联,自动作诗了。
通过本教程的学习,我们可能掌握如下技能:
对于tensorflow不甚了解的同学可以去官网自行修炼tutorials,对于python中的强大诸如sklearn,numpy,pandas等libs不了解的同学也可以去各家的官网自行修炼。
如果你是大牛,对于本教程简单啰嗦不感兴趣的,可以移步github搜索segmenter,一大波开源分词工具等你去驾驭~~~
基础知识补充后,我们就开搞了。
首先,分词是一种基础应用,是知识问答、信息抽取等高阶应用的基础。目前主流的分词方法大概可以分为以下几种套路:
样例: 胡八一今天要去参加一场聚会
分词: 胡 八一 今天 要 去 参加 一场 聚会
词典模型
词典模型也可以看成是基于规则的模型,这是最简单粗暴的一种方法。首先构造一个牛逼的词典集合,然后用词典去匹配待分词的句子,如果遇到词典中存在的词则切分开。在实际应用中,根据匹配的前后顺序有两种策略,前向最大匹配和后向最大匹配。
词典中有 胡八一 , 八一 ,根据前向最大匹配策略串胡八一的分词结果就是 胡八一,反向最大匹配则是 胡 八一
简单代码如下:
##coding=utf-8
def forward_match(given_dict, given_sent, max_word_len=10):
if not isinstance(given_sent, unicode): given_sent = given_sent.decode("utf-8")
res = []
si = 0
while True:
is_matched = False
for wl in xrange(max_word_len, 0, -1):
if (si + wl) > len(given_sent): continue
if given_sent[si:si + wl] in given_dict:
res.append(given_sent[si:si + wl])
si = si + wl
is_matched = True
break
if si >= len(given_sent): break
if not is_matched:
res.append(given_sent[si])
si += 1
return res
def backward_match(given_dict, given_sent, max_word_len=10):
if not isinstance(given_sent, unicode): given_sent = given_sent.decode("utf-8")
res = []
si = len(given_sent)
while True:
is_matched = False
for wl in xrange(max_word_len, 0, -1):
if si - wl < 0: continue
if given_sent[si - wl:si] in given_dict:
res.append(given_sent[si - wl:si])
si = si - wl
is_matched = True
break
if si <= 0: break
if not is_matched:
res.append(given_sent[si-1])
si -= 1
return reversed(res)
在CTB测试集合上(训练集共18074句,34654词,测试集348句),两种策略的分词性能大概如下:
模型名 | 准确率 | 召回率 | F值 |
---|---|---|---|
正向最大匹配 | 88.877% | 92.9945% | 90.8891% |
反向最大匹配 | 89.2192% | 93.3192% | 91.2231% |
反向最大匹配模型性能略好,不过在CTB这个测试集合上当前state-of-the-art 世界最好水平大概能到97%~98%左右。虽然词典模型从分词准确度上来说略微欠缺,但是也有优点,那就是代码实现简单、算法容易理解、扩充自定义词典简单、算法复杂度低等。
词典模型有两个比较大的缺陷:
1. 数据稀疏性问题,难以构造一个词典覆盖所有可能出现的词。
2. 匹配歧义问题,在上述例子中,如果词典中存在 胡八 这个词(很有可能在构造词典时正好有篇文章主角是胡八),那么根据正向匹配算法分词结果就将错误的分成 胡八 一 今天 要 参加 一场 聚会。针对这个问题,也有一些打补丁的方法,例如可以给词典中的每个词加上概率值,然后在搜索的过程中不是简单的贪心策略,而是利用动态规划算法找到一条最优切分路径。相关算法我们在后文统一介绍。
序列标注模型
序列标注模型通过将分词问题转化为序列标注问题,能够利用强大的机器学习分类模型对序列进行分类预测。这种模型利用当前字的前后字信息组成不同的特征,通过给不同特征赋予不同的权重,用以决策最终的切分方案。相比于词典模型,序列标注模型具有更强的鲁棒性。
根据映射序列的不同,一般有两种序列模型
SBME标记,S表示单个字的词,B表示词的首字,M表示词的中间字,E表示词的结束字。样例映射成的序列标记为 S BE BE S S BE BE BE
还有一种更简单的序列是
CS标记,C表示当前字和后面字是连续的一个词,S表示当前字和后面字是两个不同的词。 样例映射的序列标记为 S CS CS S S CS CS CS
有了序列映射规则,剩下的工作就是预测每个字应当映射成哪个标记。在Vapnik的统计学习大行其道的年代,大家最熟练的套路基本都是找特征,找牛逼的特征,找更牛逼的特征。于是整个任务基本就成了特征工程了。不过分词这个基本的任务,在限定使用语料的场景下,能使用的特征无非就是当前字, 前一个字, 后一个字, 前两个字, 后两个字, 前后1~2个字的各种组合等等。
预测 今 的序列可以抽取如下特征模板:
特征名称 | 实例 |
---|---|
C-2,C-1,C0,C1,C2 | 一, 八, 今, 天, 要 |
C-2C-1, C-1C0, C0C1, C1C2 | 一八, 八今, 今天, 天要 |
C-1C1 | 八天 |
使用基本的特征就可以打造一个性能相当的分词工具了,当然想提升分词工具性能,可以采用更加复杂的特征,具体可以参考论文或者沿着这篇论文拓展阅读相关文献。
抽取特征实现代码如下(采用SBME标记)
##coding=utf-8
__author__ = 'xh'
import sys,os,re,json
from collections import namedtuple
import cPickle as pickle
FEA = namedtuple("FEA", ["C_0","C_P1","C_P2","C_N1","C_N2",
"C_P2_P1","C_P1_0","C_0_N1","C_N1_N2",
"C_P1_N1"])
S_SYMBOL = 0
B_SYMBOL = 1
M_SYMBOL = 2
E_SYMBOL = 3
PAD = "<s>"
def get_sent_fea(sent):
if not isinstance(sent,unicode):sent=sent.decode("utf-8")
fea_list = []
for idx, char in enumerate(sent):
#记得每个特征之前要加上特征id
fea = FEA(C_0="C0"+char,
C_P1="CP1"+PAD if idx==0 else sent[idx-1],
C_P2="CP2"+PAD if idx<=1 else sent[idx-2],
C_N1="CN1"+PAD if idx>=(len(sent)-1) else sent[idx+1],
C_N2="CN2"+PAD if idx>=(len(sent)-2) else sent[idx+2],
C_P2_P1="CP2P1%s%s"%(PAD if idx<=1 else sent[idx-2] ,
PAD if idx==0 else sent[idx-1]),
C_P1_0="CP10%s%s"%((PAD if idx==0 else sent[idx-1]) , char),
C_0_N1="C0N1%s%s"%(char , PAD if idx>=(len(sent)-1) else sent[idx+1]),
C_N1_N2="CN1N2%s%s"%(PAD if idx>=(len(sent)-1) else sent[idx+1] ,
PAD if idx>=(len(sent)-2) else sent[idx+2]),
C_P1_N1="CP1N1%s%s"%(PAD if idx==0 else sent[idx-1] ,
PAD if idx>=(len(sent)-1) else sent[idx+1]))
fea_list.append(fea)
return fea_list
def get_tag(sent):
while True:
if " " not in sent:break
sent = sent.replace(" "," ").replace("\t","")
tags = []
for word in sent.split(" "):
if len(word) == 1:tags.append(S_SYMBOL)
else:tags.extend([B_SYMBOL]+[M_SYMBOL]*(len(word)-2)+[E_SYMBOL])
return tags
def main(train_path,fea_file_path):
content = open(train_path).read().decode("gb2312")
out = open(fea_file_path,"w")
ct = 0
res = []
for line in content.split("\n"):
line = line.strip()
fea_list = get_sent_fea(line.replace(" ",""))
tag_list = get_tag(line)
for fea, tag in zip(fea_list, tag_list):
res.append((fea, tag))
ct += 1
print ct
pickle.dump(res, open(fea_file_path,"w"), 2)
有了特征以后,我们可以采取不同的机器学习方法来根据特征预测序列的标注结果,即根据特征预测属于哪一类标签(SBME之一)。
至于分类器,大家用的最多的无非是最大熵(Berger, et al., 1996, Della Pietra, et al., 1997)或者感知机模型(Rosenblatt, Frank 1958),亦或者是CRF模型(John Lafferty et al., 2001 ),或者SVM(Corinna Cortes and Vladimir Vapnik 1995。相应的开源工具[MxEnt, CRF++, LIBSVM]都支持字符串类型的特征模板,按照工具要求的特征模板格式生成相应的文件就能很好的完成分类预测功能。
在这里,我们稍微装逼麻烦一点,利用sklearn的SVM模型手动写代码来完成分类预测功能。
sklearn使用很简单,准备好训练数据
X
和
我们在上一步骤抽取的特征是字符串类型,而sklearn中的svm模型接受的训练样本必须映射到一个向量空间之中,因此我们首先得把字符串特征转换为int或者float数值。
假设字符串序列是 [“今天” “天气” “真” “好”],一种简单的方法是将序列映射到4维向量空间=> [[1 0 0 0], [0 1 0 0], [0 0 1 0], [0 0 0 1]],其中
1
表示此维度的值为1
。
但是这种处理方式有个很大的问题,当字符串序列很多的情况下(通常都是几十万以上),产生的向量空间维度非常大,生成的矩阵系列稀疏性很严重,大部分数字都为0,直接扔给计算机处理容易内存不足。
因此在这里我们利用sklearn.feature_extraction.text
的 CountVectorizer来存储稀疏矩阵。
训练数据预处理代码实现:
##coding=utf-8
__author__ = 'xh'
import re,sys,os,json
from gen_fea import *
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.feature_selection import chi2, SelectKBest
import cPickle as pickle
from sklearn.externals import joblib
def fea2vec(train_fea,dev_fea):
fea_list = pickle.load(open(train_fea))
dev_fea_list = pickle.load(open(dev_fea))
vectorer = CountVectorizer()
print "load pkl ok ..."
x_list = []
y_list = map(lambda x:x[1], fea_list)
x_list = map(lambda x:" ".join(list(x[0])), fea_list)
dev_x_list = []
dev_y_list = map(lambda x:x[1], dev_fea_list)
dev_x_list = map(lambda x:" ".join(list(x[0])), dev_fea_list)
x_list = vectorer.fit_transform(x_list)
pickle.dump((x_list,y_list),open("%s.ids"%train_fea,"w"))
dev_x_list = vectorer.transform(dev_x_list)
pickle.dump((dev_x_list,dev_y_list),open("%s.ids"%dev_fea,"w"))
joblib.dump(vectorer,"ids.vectorer")
训练模型代码实现:
##coding=utf-8
__author__ = 'xh'
from sklearn.svm import SVC
import numpy as np
import sys, os, re, json
from gen_fea import *
from gen_vocab import *
from sklearn.externals import joblib
from sklearn.metrics import *
class SVMSegmenter:
def __init__(self):
self.svc = SVC(verbose=True, probability=True, kernel='linear')
def train(self, train_data):
x_inst, y_inst = train_data
#print len(x_inst)
print "begin training ..."
self.svc.fit(x_inst, y_inst)
print "end of training ..."
def predict(self, dev_data, filename=None):
x_inst, y_inst = dev_data
if filename is not None: self.svc = joblib.load(filename)
print "load ok "
#print x_inst
pred = self.svc.predict(x_inst)
print self.svc.classes_
print self.svc.predict_proba(x_inst)
print precision_recall_fscore_support(y_inst, pred)
print np.sum(np.equal(y_inst, pred)) / float(len(y_inst))
在前面步骤的分类模型中,我们得到了每个字标记为SBME
四种标记的概率值,也可以称之为发射概率。如果简单的取每个字的最大发射概率,我们可能会得到一些非法的序列标注结果,例如SBBMMS
。所以在这里有两种解码策略:
- 贪心策略,在本问题上有个好听的名字叫Viterbi算法,简单来说就是每次尽可能的选择最大发射概率,但是要考虑到上一个标注的结果。例如上一个标注结果是
B
,则下一个标注的结果只能是M
或者E
。这种算法实现简单,算法复杂度也较低,但是不能得到最优结果。 - 动态规划算法,简单的解释就是(貌似也不简单):
0-n
区间的分词得分由0-i
的分词得分和i+1,n
构成一个词的得分累加组成,而我们的目标是寻找合适的i
,使得0-n
区间得分最大,即 S(0,n)=argmax0≤i≤n(S(0,i)+Score(word[i+1,n]) 。有了最优子结构转移方程, Score(word[i+1,n]) 的得分由构成这个词的SBME
序列标注发射概率乘积构成(SVM输出的结果是log结果,因此这里应该是直接相加),整个动态规划算法就很简单了。
我们在这里实现第二种动态规划算法,代码如下:
##coding=utf-8
__author__ = 'xh'
from sklearn.svm import SVC
import numpy as np
import sys, os, re, json
from gen_fea import *
from gen_vocab import *
from sklearn.externals import joblib
from sklearn.metrics import *
class SVMSegmenter:
def __init__(self):
self.svc = SVC(verbose=True, probability=True, kernel='linear')
def train(self, train_data):
x_inst, y_inst = train_data
#print len(x_inst)
print "begin training ..."
self.svc.fit(x_inst, y_inst)
print "end of training ..."
def predict(self, dev_data, filename=None):
x_inst, y_inst = dev_data
if filename is not None: self.svc = joblib.load(filename)
print "load ok "
#print x_inst
pred = self.svc.predict(x_inst)
print self.svc.classes_
print self.svc.predict_proba(x_inst)
print precision_recall_fscore_support(y_inst, pred)
print np.sum(np.equal(y_inst, pred)) / float(len(y_inst))
def save(self, filename):
joblib.dump(self.svc, filename)
def predict_corpus(self, dev_path, output_path, svm_joblib_path=None, ids_path="ids.vectorer"):
if svm_joblib_path is not None: self.svc = joblib.load(svm_joblib_path)
sent_list = open(dev_path).read().decode("gb2312")
vectorer = joblib.load(ids_path)
print "loading finish ..."
out = open(output_path,"w")
ct = 1
for sent in sent_list.split("\n"):
sent = sent.strip()
if sent == "":continue
sent_seg = self.predict_sent(sent, vectorer)
out.write(" ".join(sent_seg).encode("utf-8")+"\n")
print ct
ct += 1
out.close()
def predict_sent(self, sent, vectorer):
fea_list = get_sent_fea(sent)
x_list = map(lambda x:" ".join(list(x)), fea_list)
x_list = vectorer.transform(x_list)
probs = self.svc.predict_proba(x_list)
best_tag_seq = self.dp_search(probs, sent)
words = self.gen_word_list(best_tag_seq[-1], sent)
return words
def gen_word_list(self,best_tag_seq, words):
res = []
word = ""
for idx, tag in enumerate(best_tag_seq):
if idx == 0:word = words[idx]
else:
word += words[idx]
if tag == "E" or tag == "S":
res.append(word)
word = ""
return res
def dp_search(self, score, word_seq, max_word_len=10):
length = len(word_seq)
def _get_word_score(start_idx, end_idx):
if start_idx<=0:return 0
if start_idx == end_idx:return score[start_idx-1][S_SYMBOL]
_score = score[start_idx-1][B_SYMBOL] + score[end_idx-1][E_SYMBOL]
_score += sum(map(lambda x:score[x-1][M_SYMBOL], range(start_idx+1, end_idx)))
return _score
best_score = [0] * (length + 1)
best_split = [""] * (length + 1)
for i in xrange(length):
tmp_best_score = 0.0
for word_len in range(max_word_len):
start_idx = i + 1 - word_len
if start_idx <= 0 :break
end_idx = i + 1
#print "start idx %s, end idx %s"%(start_idx, end_idx)
word_score = _get_word_score(start_idx, end_idx)
if (word_score + best_score[start_idx - 1]) >best_score[i + 1]:
best_score[i + 1] = word_score + best_score[start_idx - 1]
#print start_idx
if word_len == 0:
best_split[i + 1] = best_split[start_idx -1 ] + "S"
else:
best_split[i + 1] = best_split[start_idx - 1] + "B" + "M"*(word_len-1) + "E"
return best_split
代码需要解释的地方不多,dp_search
函数根据每个字分类为SBME
的概率不同,计算出最优的切分序列。gen_word_list
函数根据切分序列生成最后的分词结果。
我们的SBME
标注模型在CTB集合上的测试结果如下:
模型名 | 准确率 | 召回率 | F值 |
---|---|---|---|
SVM模型 | 96.9079% | 97.840% | 97.37% |
看,轻松就达到了state of the art 性能,当然系统还有提升的空间,例如可以
- 对训练语料进行全半角转换:把全角的数字字母
0123456789abc
等转换为0123456789abc
; - 增加额外的特征,例如判断当前字是否是数字、对基础特征进行组合等;
这里我们就不展开讨论了,有兴趣的可以结合应用本身进行分词优化,当然最好使的方法还是添加领域词典。
词典模型中添加自定义词典很简单,序列标注模型中如何添加自定义词典大家可以自行脑补~~~
这章比较简单,下一章讲RNN模型可能会稍微复杂点,尽量讲人话能让大家看懂。