Viterbi算法原理与实现
算法原理
维特比算法(Viterbi algorithm)是一种动态规划算法,解决的是篱笆型的图的最短路径问题,图的节点按列组织,每列的节点数量可以不一样,每一列的节点只能和相邻列的节点相连,不能跨列相连。
如下图,假如你从S和E之间找一条最短的路径,除了遍历完所有路径,还有什么更好的方法?
过程非常简单:
为了找出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列,前面的步骤决定你是否能看懂viterbi算法(维特比算法)。如果不理解,回头再看一遍,或者直接去看下面的算法本质部分,看完再回来重新理解。
接下来讲到C列了,类似上面说的B列,我们从C1、C2、C3一个个节点分析。
经过C1节点的路径有:
S-A3-B1-C1、
S-A1-B2-C1、
S-A2-B3-C1
和B列的做法一样,从这三条路径中找到最短的那条(假定是S-A3-B1-C1),其它两条路径同样道理可以删掉了。那么经过C1的所有路径只剩一条,如下图:
同理,我们可以找到经过C2和C3节点的最短路径,汇总一下:
到达C列时最终也只剩3条备选的最短路径,我们仍然没有足够信息断定哪条才是全局最短。
最后,我们继续看 E 节点,才能得出最后的结论。
到 E 的路径也只有 3 种可能性:
E 点已经是终点了,我们稍微对比一下这三条路径的总长度就能知道哪条是最短路径了。
在效率方面相对于粗暴地遍历所有路径,viterbi 维特比算法到达每一列的时候都会删除不符合最短路径要求的路径,大大降低时间复杂度。
算法本质
文章开头也说了,viterbi 是一个动态规划算法,这里用一个非常生活化的例子帮助大家理解。
已经理解viterbi算法的可以不看这一部分
假如我现在要从厦门->上海->北京,所有的路径如下图:
现在我想找到一个从厦门->北京最短的路径。
正常人的思路就是遍历所有可能的路径,遍历过程是:
厦门->1->上海->4->北京
厦门->1->上海->5->北京
厦门->1->上海->6->北京
厦门->2->上海->4->北京
厦门->2->上海->5->北京
厦门->2->上海->6->北京
厦门->3->上海->4->北京
厦门->3->上海->5->北京
厦门->3->上海->6->北京
一共九种可能,遍历了九次才找到最优解。显然遍历不是解决这个问题最好的办法。
仔细观察这些路径,你就会发现,所有路径都经过了上海,也就是所有的路径都是以下这种形式:
厦
门
.
.
.
.
.
.
.
.
.
上
海
.
.
.
.
.
.
.
.
.
北
京
厦门.........上海 .........北京
厦门.........上海.........北京
不管怎么样,都会经过上海。那么我们如果想找厦门->北京的最短路径的话,我们可以先从局部看:
因为我们不论怎么走,都会经过上海,所以我们先不管上海->北京怎么走,我们先到上海再去想上海->北京怎么走。
也就是先找厦门->上海的最短路径,因为厦门->北京的最短路径一定会包含这条路径。从这个路径到达上海后,我们再找上海->北京的最短路径。
比如:这个例子中从厦门->上海有三条路径,假设最短路径是路径1,则我们就不需要看路径2和路径3了,只需要遍历以下三个路径即可找到最短路径:
厦门->1->上海->4->北京
厦门->1->上海->5->北京
厦门->1->上海->6->北京
同理,在本文第一部分算法原理里,我们遍历 B 列每个节点时也是同样的操作。
比如遍历 B1 时,我们只是从必须经过 B1 的几条路径中,选择了一条最优的,抛弃了其它的,然后从 B1 再开始往下走。
正是因为 B1 是这几条路径的必经之路,所以我们可以这么做,其它节点也是同理。
Viterbi(维特比)算法实现
Viterbi (维特比算法)一般用于解 HMM (隐马尔可夫模型),不懂 HMM 的可以点开这篇文章看一看。HMM模型-通俗易懂 - csdn
想象一个乡村诊所。村民有着非常理想化的特性,要么健康要么发烧。他们只有问诊所的医生的才能知道是否发烧。 聪明的医生通过询问病人的感觉诊断他们是否发烧。村民只回答他们感觉正常、头晕或冷。
假设一个病人每天来到诊所并告诉医生他的感觉。病人的状态有两种“健康”和“发烧”,但医生不能直接观察到。每天病人会告诉医生自己有以下几种由他的健康状态决定的感觉的一种:正常、冷或头晕。这些是观察结果。
医生知道村民的总体健康状况,还知道发烧和没发烧的病人通常会抱怨什么症状。 换句话说,医生知道隐马尔可夫模型的参数(看到这里不懂的,去看上面的 HMM 文章)。 这可以用Python语言表示如下:
states = ('Healthy', 'Fever')
observations = ('normal', 'cold', 'dizzy')
start_probability = {'Healthy': 0.6, 'Fever': 0.4}
transition_probability = {
'Healthy' : {'Healthy': 0.7, 'Fever': 0.3},
'Fever' : {'Healthy': 0.4, 'Fever': 0.6},
}
emission_probability = {
'Healthy' : {'normal': 0.5, 'cold': 0.4, 'dizzy': 0.1},
'Fever' : {'normal': 0.1, 'cold': 0.3, 'dizzy': 0.6},
}
在这段代码中, 起始概率start_probability
表示病人第一次到访时医生认为其所处的 HMM 状态,他唯一知道的是病人倾向于是健康的。 转移概率transition_probability
表示潜在的马尔可夫链中健康状态的变化。在这个例子中,当天健康的病人仅有 30% 的机会第二天会发烧。发射概率emission_probability
表示每天病人感觉的可能性。假如他是健康的,50% 会感觉正常。如果他发烧了,有 60% 的可能感觉到头晕。整体 HMM 模型的状态转移概率可以表示为下图:
病人连续三天看医生,医生发现第一天他感觉正常,第二天感觉冷,第三天感觉头晕。 于是医生产生了一个问题:怎样的健康状态序列最能够解释这些观察结果。维特比算法解答了这个问题。
最能够解释的意思就是发生概率最大。
上面我们已经定义了 HMM 三个基本矩阵,下面我们利用 viterbi
算法,根据病人连续三天的感觉,求出概率最大健康状态序列。
首先我们要确定用什么数据结构去存储路径信息,详见下图:
根据 viterbi
算法我们知道,经过每个节点的路径有多条,但我们只关心概率最大的那一条。所以每个节点只需要存储一条概率最大的路径就行,也就是说每个节点存储一个概率。我们定义一个列表 V
,V
的数据结构上图,我们有三天的病人信息,所以有三列节点,那么列表 V
中就有三个字典,每个字典存储一列节点的概率信息。
节点的概率信息存储好之后,我们需要存储路径信息。根据 viterbi
算法我们知道,有多少个隐藏状态,最终就有多少条路径,且每条路径的最后一个状态都是不同的。我们定义一个字典 path
,path
长度取决于隐藏状态的数量,也就是 viterbi
算法筛选后总路径的数量。这里我们只有两个隐藏状态 Healthy
和 Fever
,所以 path
里面只有两个字典,其中字典的 key
对应其存放的路径的最终状态。
具体实现代码如下:
def viterbi(obs, states, start_p, trans_p, emit_p):
V = [{}]
path = {}
# 利用start_probability 信息初始化路径
for st in states:
V[0][st] = start_p[st] * emit_p[st][obs[0]]
path[st] = [st]
# Run Viterbi when t > 0
for t in range(1,len(obs)):
V.append({})
newpath = {}
for curr_st in states:
paths_to_curr_st = []
for prev_st in states:
paths_to_curr_st.append((V[t-1][prev_st] * trans_p[prev_st][curr_st] * emit_p[curr_st][obs[t]], prev_st))
curr_prob, prev_state = max(paths_to_curr_st)
V[t][curr_st] = curr_prob
newpath[curr_st] = path[prev_state] + [curr_st]
# No need to keep the old paths
path = newpath
# 最终 V 最后一列节点中概率最大的节点,就是概率最大的路径的最终状态(end_state)
prob, end_state = max([(V[-1][st], st) for st in states])
return prob, path[end_state]
函数viterbi
具有以下参数: obs
为观察结果序列, 例如 ['normal', 'cold', 'dizzy']
; states
为一组隐含状态; start_p
为起始状态概率; trans_p
为转移概率; 而 emit_p
为发射概率。
在运行的例子中正向/维特比算法使用如下:
def example():
return viterbi(observations,
states,
start_probability,
transition_probability,
emission_probability)
print(example())
维特比算法揭示了观察结果 ['normal', 'cold', 'dizzy']
最有可能由状态序列 ['Healthy', 'Healthy', 'Fever']
产生。 换句话说,对于观察到的活动, 病人第一天感到正常,第二天感到冷时都是健康的,而第三天发烧了。