仔细读了苏神的《【中文分词系列】 3. 字标注法与HMM模型》(原文链接在这里:https://spaces.ac.cn/archives/3922),收获还是很多的,所以决定把收获记录在这里。 简单总结,具体有三点收获:
1.深入了解了基于字标注的隐马尔科夫(HMM)分词模型;
2.了解了viterbi算法及其在分词过程中起到的作用;
3.基于苏神给的代码,顺带搞清楚了collections的用法;
1.基于字标注的隐马尔科夫分词模型
字标注的分词思想其实很简单,通过给句子中每个字打上标签的思路来进行分词,常用的分词标注方法有4标签和6标签。例如4标签是用“sbme”来进行标注(single,单字成词;begin,多字词的开头;middle,三字以上词语的中间部分;end,多字词的结尾。均只取第一个字母。),这样,“为人民服务”就可以标注为“sbebe”了。标注越多,越精细,理论上来说效果应该也更好。但其实通过给每个字打标签、进而将问题转化为序列到序列的学习,不仅仅是一种分词方法,还是一种解决大量自然语言问题的思路,是很多自然语言处理任务的子任务,比如命名实体识别,核心实体识别等。
那么究竟什么是基于字标注的隐马尔科夫模型呢?简单的说,就是我们要找出某个分词序列(“为人民服务”)的标注序列,使得这个标注序列在某种条件下的概率最大。形式化的说,对于字标注的分词方法来说,输入就是n个字,输出就是n个标签。我们用λ=λ1λ2…λn表示输入的句子,o=o1o2…on表示输出。自然而然,我们想获得的就是在输入λ的情况下,输出某种o的序列的最大的条件概率:
maxP(o|λ)=maxP(o1o2…on|λ1λ2…λn)
但是这个概率太难求,很难建模,那怎么办?首先对问题进行假设,隐马尔科夫模型做出第一步假设:如果我们假设每个字的输出仅仅与当前字有关,那么我们就有。
P(o1o2…on|λ1λ2…λn)=P(o1|λ1)P(o2|λ2)…P(on|λn)
这样计算每一个P(ok|λk)就容易多了,问题也得到简化,但如果你稍微有点自然语言处理的直觉,就会发现这种假设存在一个非常致命的问题,就是他没有考虑上下文,例如按照我们的4标注,那么b后面只能接m或e,但是按照这种最大的方法,我们可能得到诸如bbb的输出,这是不合理的。因此,在我们的标注体系下,进一步将问题转化为求得一个合理的字标注序列的最大条件概率。直接接苏神的讲解:
由贝叶斯公式,我们得到
P(o|λ)=P(o,λ)/P(λ)=P(λ|o)P(o)/P(λ)
由于λ是给定的输入,那么P(λ)就是常数,可以忽略。那么最大化P(o|λ)就等价于最大化
P(λ|o)P(o)
现在,我们可以对P(λ|o)作马尔可夫假设,得到
P(λ|o)=P(λ1|o1)P(λ2|o2)…P(λn|on)
同时,对P(o)有
P(o)=P(o1)P(o2|o1)P(o3|o1,o2)…P(on|o1,o2,…,on−1)
这时候可以作另外一个马尔可夫假设:每个输出仅仅于上一个输出有关,那么:
P(o)=P(o1)P(o2|o1)P(o3|o2)…P(on|on−1)∼P(o2|o1)P(o3|o2)…P(on|on−1)
这时候
P(λ|o)P(o)∼P(λ1|o1)P(o2|o1)P(λ2|o2)P(o3|o2)…P(on|on−1)P(λn|on)
我们称P(λk|ok)为发射概率,P(ok|ok−1)为转移概率。这时候,可以通过设置某些P(ok|ok−1)=0=0,来排除诸如bb、bs这些不合理的组合。 那么最后我们的任务变成了什么呢?就是计算输入序列中每个字是“sbme”的概率,以及在转移概率下从某个标签转到后续标签的最大条件概率。例如,“为人民服务”输入序列,计算出每个字的标签概率,{“为”:{“s”:0.5, “b”:0.3, “m”:0.15, “e”:0.05}};{“人”:{“s”:0.3, “b”:0.5, “m”:0.15, “e”:0.05}};{“民”:{“s”:0.1, “b”:0.3, “m”:0.5, “e”:0.1}};{“服”:{“s”:0.1, “b”:0.5, “m”:0.2, “e”:0.2}};{“务”:{“s”:0.1, “b”:0.2, “m”:0.3, “e”:0.3}},如果我们按照贪婪的思想计算在某个字是某种标签的条件下的最大条件概率,得到的未必是 全局的最大条件概率,这个时候就要用到viterbi算法。
2.viterbi算法及其在分词过程中起到的作用
维特比算法 (Viterbi algorithm) 是机器学习中应用非常广泛的动态规划算法,在求解隐马尔科夫、条件随机场的预测以及seq2seq模型概率计算等问题中均用到了该算法。前面说到了,在计算出每个字的标签概率的前提下,要想得到在某个标签下的最大条件概率,用贪婪算法未必能得到全局最大的条件概率。那么viterbi算法是怎么做的?还是借用一篇别人的分析来说明一下(一是我太懒了,二是我觉得别人分析的已经很清除了)。原文链接:https://www.zhihu.com/question/20136144
过程非常简单:
为了找出S到E之间的最短路径,我们先从S开始从左到右一列一列地来看。首先起点是S,从S到A列的路径有三种可能:S-A1、S-A2、S-A3,如下图:
我们不能武断的说S-A1、S-A2、S-A3中的哪一段必定是全局最短路径中的一部分,目前为止任何一段都有可能是全局最短路径的备选项。
我们继续往右看,到了B列。B列的B1、B2、B3逐个分析。
先看B1:
如上图,经过B1的所有路径只有3条:
S-A1-B1
S-A2-B1
S-A3-B1
以上这三条路径,我们肯定可以知道其中哪一条是最短的(把各路径每段距离加起来比较一下就知道哪条最短了)。假设S-A3-B1是最短的,那么我们就知道了经过B1的所有路径当中S-A3-B1是最短的,其它两条路径路径S-A1-B1和S-A2-B1都比S-A3-B1长,绝对不是目标答案,可以大胆地删掉了。删掉了不可能是答案的路径,就是viterbi算法(维特比算法)的重点,因为后面我们再也不用考虑这些被删掉的路径了。现在经过B1的所有路径只剩一条路径了,如下图:
接下来,我们继续看B2:
如上图,经过B2的路径有3条:
S-A1-B2
S-A2-B2
S-A3-B2
这三条路径中我们肯定也可以知道其中哪一条是最短的,假设S-A1-B2是最短的,那么我们就知道了经过B2的所有路径当中S-A1-B2是最短的,其它两条路径路径S-A2-B2和S-A3-B1也可以删掉了。经过B2所有路径只剩一条,如下图:
接下来我们继续看B3:
如上图,经过B3的路径也有3条:
S-A1-B3
S-A2-B3
S-A3-B3
这三条路径中我们也肯定可以知道其中哪一条是最短的,假设S-A2-B3是最短的,那么我们就知道了经过B3的所有路径当中S-A2-B3是最短的,其它两条路径路径S-A1-B3和S-A3-B3也可以删掉了。经过B3的所有路径只剩一条,如下图:
现在对于B列的所有节点我们都过了一遍,B列的每个节点我们都删除了一些不可能是答案的路径,看看我们剩下哪些备选的最短路径,如下图:
上图是我们我们删掉了其它不可能是最短路径的情况,留下了三个有可能是最短的路径:S-A3-B1、S-A1-B2、S-A2-B3。现在我们将这三条备选的路径汇总到下图:
S-A3-B1、S-A1-B2、S-A2-B3都有可能是全局的最短路径的备选路径,我们还没有足够的信息判断哪一条一定是全局最短路径的子路径。 接下来对于到C列的分析和到B列的分析是一致,类似上面说的B列,有兴趣去看原文,这里就不展开了。
到达C列时最终也只剩3条备选的最短路径,我们仍然没有足够信息断定哪条才是全局最短。
最后,我们继续看E节点,才能得出最后的结论。E点已经是终点了,我们稍微对比一下这三条路径的总长度就能知道哪条是最短路径了。
作者:知乎用户
链接:https://www.zhihu.com/question/20136144/answer/763021768
到这里,我们就基本具备了解决基于字标注的HMM模型的全部工具,具体实现直接贴出苏神的代码,跑一边基本上就明白了。(注:苏神原版代码是python2.X,这里改成python3.X)
from collections import Counter
from math import log
hmm_model = {i:Counter() for i in 'sbme'}
with open('dict.txt') as f:
for line in f.readlines():
lines = line.split(' ')
if len(lines[0]) == 1:
hmm_model['s'][lines[0]] += int(lines[1])
else:
hmm_model['b'][lines[0][0]] += int(lines[1])
hmm_model['e'][lines[0][-1]] += int(lines[1])
for m in lines[0][1:-1]:
hmm_model['m'][m] += int(lines[1])
log_total = {i: log(sum(hmm_model[i].values()))for i in 'sbme'}
trans = {'ss':0.3,
'sb':0.7,
'bm':0.3,
'be':0.7,
'mm':0.3,
'me':0.7,
'es':0.3,
'eb':0.7
}
trans = {i: log(j) for i, j in trans.items()}
def viterbi(nodes):
paths = nodes[0]
for l in range(1, len(nodes)):
paths_ = paths
paths = {}
for i in nodes[l]:
nows = {}
for j in paths_:
if j[-1]+i in trans:
nows[j+i]= paths_[j]+nodes[l][i]+trans[j[-1]+i]
k = list(nows.values()).index(max(list(nows.values())))
paths[list(nows.keys())[k]] = list(nows.values())[k]
return list(paths.keys())[list(paths.values()).index(max(list(paths.values())))]
def hmm_cut(s):
nodes = [{i:log(j[t]+1)-log_total[i] for i,j in hmm_model.items()} for t in s]
tags = viterbi(nodes)
words = [s[0]]
for i in range(1, len(s)):
if tags[i] in ['b', 's']:
words.append(s[i])
else:
words[-1] += s[i]
return words
print(' '.join(hmm_cut(u'李想是一个好孩子')))
3.collections用法
苏神的代码中用到了collections中Counter,由于本人还处于非常小白的阶段,对于collections的用法还不是非常清楚,就花了一点时间认真的学习了一下。python中的collections模块实现了特定目标的容器,以提供Python标准内建容器 dict、list、set、tuple 的替代选择。
Counter:字典的子类,提供了可哈希对象的计数功能
defaultdict:字典的子类,提供了一个工厂函数,为字典查询提供了默认值
OrderedDict:字典的子类,保留了他们被添加的顺序
namedtuple:创建命名元组子类的工厂函数
deque:类似列表容器,实现了在两端快速添加(append)和弹出(pop)
ChainMap:类似字典的容器类,将多个映射集合到一个视图里面
通俗的说,其实就是具有某种特定功能的容器类。我们以代码中用到的Counter类为例,它是具有计数功能的字典类型,主要是用来对你访问的对象的频率进行计数。更直白一点,你可以把这些标准容器的替代类当成是基本的内建容器去使用。
Counter类的创建 :
>>> c = Counter()# 创建一个空的Counter类
>>> c = Counter('gallahad')# 从一个可iterable对象(list、tuple、dict、字符串等)创建
>>> c = Counter({'a': 4,'b': 2})# 从一个字典对象创建
>>> c = Counter(a=4, b=2)# 从一组键值对创建
常用的方法这里就不记录了,但对比Counter和dict的用法,笔者发现一个有趣的区别:
my_counter = Counter()
my_counter['s'] += 1
my_dict = {}
my_dict['s'] += 1
此时my_counter会自动建立{'s':1}的键值对,而my_dict会报错KeyError: 's',通过这个例子也就能很好的理解苏神代码中第一部分处理dict中每个字的标签计数的问题了。
最后
通过这个例子,其实能够深刻体会出传统的自然语言处理模型和基于深度学习的自然语言模型的一个本质区别:就是白箱与黑箱的区别,对于每个词的标签概率,传统模型是基于词典中词频统计来计算每个字是“sbme”的概率,而深度学习模型是通过大量语料来自动算出在不同语境下每个字是“sbme”的标签概率,至于得到标签概率后的后处理,两种模型上的处理是基本上没差别的。此外,通过字标注法来进行分词的模型还有最大熵模型(ME)、条件随机场模型(CRF),它们在精度上都是递增的,后面将对这种模型进行深入分析。