在英文中,单词本身就是“词”的表达,一篇英文文章就是“单词”加分隔符(空格)来表示的,而在汉语中,词以字为基本单位的,但是一篇文章的语义表达却仍然是以词来划分的。
自中文自动分词被提出以来,历经将近30年的探索,提出了很多方法,可主要归纳为“规则分词”、“统计分词”和“混合分词”这三个主要流派。
基于规则的分词是一种机械分词方法,主要是通过维护词典,在切分语句时,将语句的每个字符串与词表中的词进行逐一匹配,找到则切分,否则不予切分。按照匹配切分的方式,主要有正向最大匹配法、逆向最大匹配法以及双向最大匹配法三种方法。
正向最大匹配法(Maximum Match Method, MM法):假定分词词典中的最长词有i个汉字字符,则用被处理文档的当前字串中的前i个字作为匹配字段,查找字典。若字典中存在这样的一个i字词,则匹配成功,匹配字段被作为一个词切分出来。如果词典中找不到这样的一个i字词,则匹配失败,将匹配字段中的最后一个字去掉,对剩下的字串重新进行匹配处理。如此进行下去,直到匹配成功,即切分出一个词或剩余字串的长度为零为止。这样就完成了一轮匹配,然后取下一个i字字串进行匹配处理,直到文档被扫描完为止。
逆向最大匹配(Reverse Maxinum Match Method,RMM法)的基本原理和MM法相同,不同的是分词切分的方向与MM法相反。
双向最大匹配法(Bi-direction Matching method)是将正向最大匹配法得到的分词结果和逆向最大匹配法得到的结构进行比较,然后按照最大匹配原则,选取词数切分最少的作为结构。据SumM.S.和Benjamin K.T.(1995)的研究表明,中文中90.0%的句子两种切分方法得到的结果不一样,但其中必有一个是正确的(歧义检测成功),只有不到1.0%的句子,使用正向最大匹配法和逆向最大匹配法的切分虽重合却是错的,或者正向最大匹配法和逆向最大匹配法切分不同但两个都不对(歧义检测失败)。这正是双向最大匹配法在实用中文信息处理系统中得以广泛使用的原因。
# 逆向最大匹配
class IMM(object):
def __init__(self, dic_path):
self.dictionary = set();
self.maximum = 0
# 读取词典
with open(dic_path, 'r', encoding="utf8") as f:
for line in f:
line = line.strip()
if not line:
continue
self.dictionary.add(line)
self.maximum = max(self.maximum, len(line))
def cut(self, text):
result = []
index = len(text)
while index > 0:
word = None
for size in range(self.maximum, 0, -1):
if index - size < 0:
continue
piece = text[(index - size):index]
if piece in self.dictionary:
word = piece
result.append(word)
index -= size
break
if word is None:
index -= 1
return result[::-1]
def main():
text = "南京市长江大桥"
tokenizer = IMM('./data/imm_dic.utf8')
print(tokenizer.cut(text))
其主要思想是把每个词看做是由词的最小单位的各个字组成的,如果相连的字在不同的文本中出现的次数越多,就证明这相连的字很可能就是一个词。一般要做如下两步操作:
1)建立统计语言模型:为长度为m的字符串确定其概率分布P(w1, w2, ... , wm)。当文本过长时,右部从第三项起的每一项计算难度都很大。为了解决该问题,有人提出n元模型(n-gram model)降低该计算难度。所谓n元模型就是在估算条件概率时,忽略距离大于等于n的上文词的影响,因此P(wi | w1, w2, ... , wi-1)的计算可简化为:P(wi | w1, w2, ... , wi-1) ~= P(wi | wi-(n-1), ... , wi-1)。
显然当n>=2时,该模型是可以保留一定的词序信息的,而且n越大,保留的词序信息越丰富,但计算成本也呈指数级增长。一般使用频率计数的比例来计算n元条件概率:
P(wi | wi-(n-1) , ... , wi-1) = count(wi-(n-1), ... , wi-1, wi) / count(wi-(n-1), ... , wi-1 )
由于会出现分子分母为零的情况,一般在n元模型中需要配合相应的平滑算法解决,如拉普拉斯平滑算法等。
2)对句子进行单词划分,然后对划分结果进行概率计算,获得概率最大的分词方式。这里就用到了统计学习算法,如隐含马尔可夫(HMM)、条件随机场(CRF)等。
HMM是将分词作为字在字串中的序列标注任务来实现的。其基本思路是:每个字在构造一个特定的词语时,都占据着一个确定的构词位置(即词位)。现规定每个字最多只有四个构词位置,即B(词首)、M(词中)、E(词尾)和S(单独成词)。
max = max P(o1o2...on| r1r2...rn)
假设每个字的输出仅仅与当前字有关,就能得到:P(o1o2...on| r1r2...rn)=P(o1|r1)P(o2|r2)...P(on|rn),但该方法完全没有考虑上下文,且会出现不合理的情况。
HMM就是用来解决该问题的一种方法。P(o | r) = P(o,r)/P(r) = P(r|o)P(o)/P(r),其中P(r)为常数,因此求最大化P(r|o)P(o)。
在HMM中,求解max(P(r|o)P(o))的常用方法是Veterbi算法。它是一种动态规划方法,核心思想是:如果最终的最优路径经过某个oi,那么从初始节点到oi-1点的路径必然也是一个最优路径---因此每个节点oi只会影响前后两个P(oi-1 | oi) 和 P(oi | oi+1)。
# -*- coding: utf-8 -*-
class HMM(object):
def __init__(self):
pass
def try_load_model(self, trained):
pass
def train(self, path):
pass
def viterbi(self, text, states, start_p, trans_p, emit_p):
pass
def cut(self, text):
pass
class HMM(object):
def __init__(self):
import os
# 主要是用于存取算法中间结果,不用每次都训练模型
self.model_file = "./data/hmm_model.pkl"
self.state_list = ['B', 'M', 'E', 'S']
self.load_para = False
# 用于加载已计算的中间结果,当需要重新训练时,需初始化清空结果
def try_load_model(self, trained):
if trained:
import pickle
with open(self.model_file, 'rb') as f:
self.A_dic = pickle.load(f)
self.B_dic = pickle.load(f)
self.Pi_dic = pickle.load(f)
self.load_para = True
else:
# 状态转移概率(状态->状态的条件概率)
self.A_dic = {}
# 发射概率(状态->词语的条件概率)
self.B_dic = {}
# 状态的初始概率
self.Pi_dic = {}
self.load_para = False
# 采用人民日报的分词语料,通过统计,得到HMM所需的初始概率、转移概率以及发射概率
def train(self, path):
self.try_load_model(False)
Count_dic = {} #求p(o)
# 初始化参数
def init_parameters():
for state in self.state_list:
self.A_dic[state] = {s: 0.0 for s in self.state_list}
self.Pi_dic[state] = 0.0
self.B_dic[state] = {}
Count_dic[state] = 0
def makeLabel(text):
out_text = []
if len(text) == 1:
out_text.append('S')
else:
out_text += ['B'] + ['M'] * (len(text) - 2) + ['E']
return out_text
init_parameters()
line_num = -1
# 观察者集合,主要是字以及标点等
words = set()
with open(path, encoding="utf-8") as f:
for line in f:
line_num += 1
line = line.strip()
if not line:
continue
word_list = [i for i in line if i != ' ']
words |= set(word_list) # 更新字的集合
linelist = line.split()
line_state = []
for w in linelist:
line_state.extend(makeLabel(w))
#print(word_list)
#print(line_state)
assert len(word_list) == len(line_state)
for k, v in enumerate(line_state):
Count_dic[v] += 1
if k==0:
self.Pi_dic[v] += 1 #每个句子的第一个字的状态,用于计算初始状态概率
else:
self.A_dic[line_state[k-1]][v] += 1 #计算转移概率
# 计算发射概率
self.B_dic[line_state[k]][word_list[k]] = self.B_dic[line_state[k]].get(word_list[k], 0) + 1.0
self.Pi_dic = {k: v*1.0/line_num for k, v in self.Pi_dic.items()}
self.A_dic = {k: {k1: v1 / Count_dic[k] for k1, v1 in v.items()} for k, v in self.A_dic.items() }
# 加1平滑
self.B_dic = {k: {k1: (v1 + 1) / Count_dic[k] for k1, v1 in v.items()} for k,v in self.B_dic.items()}#序列化
import pickle
with open(self.model_file, 'wb') as f:
pickle.dump(self.A_dic, f)
pickle.dump(self.B_dic, f)
pickle.dump(self.Pi_dic, f)
return self
def viterbi(self, text, states, start_p, trans_p, emit_p):
print(start_p)
#print(trans_p)
#print(emit_p)
V = [{}]
path = {}
for y in states:
V[0][y] = start_p[y] * emit_p[y].get(text[0], 0)
path[y] = [y]
for t in range(1, len(text)):
V.append({})
newpath = {}
print(text[t])
#检验训练的发射概率矩阵中是否有该字
neverSeen = text[t] not in emit_p['S'].keys() and \
text[t] not in emit_p['M'].keys() and \
text[t] not in emit_p['E'].keys() and \
text[t] not in emit_p['B'].keys()
for y in states:
emitP = emit_p[y].get(text[t], 0) if not neverSeen else 1.0 #设置未知字单独成词
(prob, state) = max(
[(V[t - 1][y0] * trans_p[y0].get(y, 0) *
emitP, y0)
for y0 in states if V[t - 1][y0] > 0])
V[t][y] = prob
newpath[y] = path[state] + [y]
path = newpath
if emit_p['M'].get(text[-1], 0) > emit_p['S'].get(text[-1], 0):
(prob, state) = max([(V[len(text) - 1][y], y) for y in ('E', 'M')])
else:
(prob, state) = max([(V[len(text) - 1][y], y) for y in states])
return (prob, path[state])
def cut(self, text):
import os
if not self.load_para:
self.try_load_model(os.path.exists(self.model_file))
prob, pos_list = self.viterbi(text, self.state_list, self.Pi_dic, self.A_dic, self.B_dic)
begin, next = 0, 0
for i, char in enumerate(text):
pos = pos_list[i]
if pos == 'B':
begin = i
elif pos == 'E':
yield text[begin: i+1]
next = i+1
elif pos == 'S':
yield char
next = i+1
print(next)
if next < len(text):
yield text[next:]
hmm = HMM()
hmm.train('./data/trainCorpus.txt_utf8')
text = '这是一个非常棒的方案!'
res = hmm.cut(text)
print(text)
print(str(list(res)))
这是一个非常棒的方案!
{'M': 0.0, 'S': 0.41798844132394497, 'E': 0.0, 'B': 0.5820149148537713}
是
一
个
非
常
棒
的
方
案
!
0
2
2
4
4
6
7
8
8
10
11
['这是', '一个', '非常', '棒', '的', '方案', '!']
jieba分词结合了基于规则和基于统计这两类方法。首先基于前缀词典进行词图扫描,前缀词典是指词典中的词按照前缀包含的顺序排列,可以快速构建包含全部可能分词结果的有向无环图,这个图中包含多条分词路径,有向是指全部的路径都始于第一个字、止于最后一个字,无环是指节点之间不构成闭环。基于标注语料,使用动态规划的方法可以找出最大概率路径,并将其作为最终的分词结果。对于未登录词,jieba使用了基于汉字成词的HMM模型,采用了Viterbi算法进行推导。
实战之高频词提取:高频词一般是指文档中出现频率较高且非无用的词语,其一定程度上代表了文档的焦点所在。针对单篇文档,可以作为一种关键词来看。对于如新闻这样的多篇文档,可以将其作为热词,发现舆论焦点。需要去掉标点符号和停用词。
下面对搜狗实验室的新闻数据,进行高频词的提取
# read data
def get_content(path):
with open(path, 'r', encoding='gbk', errors='ignore') as f:
content = ''
for l in f:
l = l.strip()
content += l
return content
def get_TF(words, topK=10):
tf_dic = {}
for w in words:
tf_dic[w] = tf_dic.get(w, 0) + 1
return sorted(tf_dic.items(), key=lambda x: x[1], reverse=True)[:topK]
def stop_words(path):
with open(path) as f:
return [l.strip() for l in f]
def main():
import glob
import random
import jieba
files = glob.glob('./data/news/C000013/*.txt')
corpus = [get_content(x) for x in files]
sample_inx = random.randint(0, len(corpus))
#split_words = list(jieba.cut(corpus[sample_inx])) #停用词
split_words = [x for x in jieba.cut(corpus[sample_inx]) if x not in stop_words('./data/stop_words.utf8')]
print('yangben 1: ' + corpus[sample_inx])
print('/ '.join(split_words))
print('topK(10): ' + str(get_TF(split_words)))
topK(10): [('前列腺', 34), ('食品', 7), ('做', 7), ('男人', 6), ('排尿', 6), ('充血', 5), ('引起', 5), ('前列腺癌', 5), ('导致', 5), ('压力', 4)]
有时需要定制自己的领域词典,用以提升分词的效果。
jieba.load_userdict('./data/user_dict.utf8')
要求格式一般为:词语、词频(可省略)、词性(可省略),用空格隔开,顺序不可颠倒,需为utf8编码