【大道至简】机器学习算法之隐马尔科夫模型(Hidden Markov Model, HMM)详解(3)---预测问题:维特比算法(Viterbi Algorithm)详解及Python代码实现

【大道至简】机器学习算法之隐马尔科夫模型(Hidden Markov Model, HMM)详解(3)---预测问题:维特比算法(Viterbi Algorithm)详解及Python代码实现_第1张图片

❤️ 本篇相关往期文章汇总:

(1)HMM开篇:基本概念和几个要素

(2)HMM计算问题:前后向算法

(3)HMM学习问题:Baum-Welch算法

❤️ 本文隶属专栏:大道至简之机器学习系列

❤️ 更多精彩文章持续发布,敬请关注本人主页~

目录

写在前面

一、从青蛙跳台阶问题引入动态规划思想

二、从序列标注到维特比算法

三、维特比算法

四、代码实现

五、总结


写在前面

其实到本篇文章,关于HMM三个基本问题中最难的部分已经在前两篇介绍过了,但第三个问题却又是HMM中最具实际应用价值的,本文即将介绍的维特比算法,不严格的说,实际上都有我们熟悉的影子:概率计算和动态规划,我对该算法的理解是,并不难,但最重要。我尝试着从《统计学习方法》一书的角度出发来构思该算法的讲解,但我发现并不可行,原因是教科书总是喜欢把复杂的问题简单讲,把简单的问题复杂化,但是书中的例子还是值得一品的。学习本算法,强烈建议读者们先行学习HMM的定义以及前两个基本问题,最后好好学一下动态规划的思想。

介绍维特比算法,可以说就是在介绍动态规划,很多文章在讲维特比算法时对动态规划的先行介绍避而不谈,我觉得有失偏颇。事实上我也不愿意多讲,毕竟这是一个比较复杂的东西,需要一定的时间和练习才能体会到其真谛,但我还是想拿最简单的青蛙跳台阶问题来引入介绍动态规划的基本思路,不深,但够用。再一次强烈建议各位读者后期多花点时间深入学一学动态规划,受益匪浅。

一、从青蛙跳台阶问题引入动态规划思想

还记得介绍前向算法时已经提到过动态规划的苗头了吗?当时前向算法引用的就是:计算当前步的状态时,实际上只需要用到前一步状态的计算结果,因为前一步状态已经包含了之前的所有状态信息,事实上每一步都是如此。那么我们来看一下青蛙跳台阶问题:

一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶,求该青蛙跳上一个n级台阶总共有多少种跳法?(要求跳法的先后次序不可忽略,比如第一次跳1步,第二次跳2步和第一次跳2步,第二次跳1步为两种不同的跳法)

我们现在做最简单的分析:

n=1时,有1种跳法:[1]

n=2时,有2种跳法:[1, 1], [2]

n=3时,有3种跳法:[1, 1, 1], [1, 2], [2, 1]

n=4时,有5种跳法:[1, 1, 1, 1], [1, 2, 1], [1, 1, 2], [2, 1, 1], [1, 2, 1]

n=5时,有8种跳法:[1, 1, 1, 1, 1], [1, 1, 1, 2], [1, 1, 2, 1], [1, 2, 1, 1], [2, 1, 1, 1], [2, 2, 1], [2, 1, 2], [1, 2, 2]

...

不知各位发现没有,当有n级台阶时,其跳法种数貌似符合这样一个规律:n级台阶跳法数等同于n-1级和n-2级台阶跳法数之和,也就是:

f(n)=f(n-1)+f(n-2)

那么这个公式成立吗?显然成立。因为青蛙每次只有两种跳法,要么一次跳1要么一次跳2,当一次跳1的时候,也就是最后一步只剩下n-1跳到n了,那么跳法就等同于n-1级台阶的跳法,我们无需重复计算,因为在算到n-1级台阶跳法的时候,已经包含了之前所有可能的跳法了;当一次跳2的时候,也就是最后一步只剩下从n-2直接跳到n了,那么跳法就等同于n-2级台阶的跳法,同样我们无需重复计算了。综上,第n级台阶的跳法为n-1级台阶与n-2级台阶跳法数之和。

你看,动态规划的思想简直是神乎其神,虽然有点绕,但是真的很有用,它能够大大节省计算复杂度。从数据结构层面分析,相当于我们定义了一个缓存序列,序列的每一步存储的都是利用之前计算得到的结果做新一轮计算得到的结果。后续步只需要递推的采用同样的方式计算即可,无需在新一轮计算时再把之前的过程重新计算一遍。其实与本文介绍的维特比更为接近的方法并不是定义缓存序列,而是定义若干个变量,有些存储的是前面某几次的跳法数,其中有一个最重要的变量存储的是每一次计算的跳法数,这个最重要的变量类似于维特比算法中的\delta,这个我们在后面介绍。

当然动态规划解决的问题远不止青蛙跳台阶问题这么简单,这里只是举了一个十分简单的例子帮助大家感性上认识一下动态规划。

二、从序列标注到维特比算法

上面我们已经谈过,维特比算法的实际应用价值很高,其中最耳熟能详的应属序列标注问题了。例如词性标注、分词、命名实体等。那么它是如何做的呢?就拿词性标注来说(事实上分词思路和词性标注的思路如出一辙,只不过把词性换成分词标志位即可)。

之前的文章我们分析过,HMM描述的是包含有隐变量的一种概率求解模型,P=(O, I; λ)=P(I; λ)P(O|I; λ)。现在假设我们有这么一句话:“小明 喜欢 小红”,我们假设这句话已经分过词了。

NN表示名词,VV表示动词。小明、喜欢、小红分别是3个观测值,NN、VV、NN分别是3个状态值,现在我们再定义一种词性AA,代表形容词,则状态I一共有NN、VV、AA三种状态取值。那么我们如果要对小明、喜欢、小红三个词进行词性标注,该如何做呢?

现在让我们拿出我们已经训练好的模型λ吧,λ=(π,A,B),因为有了λ,我们就可以通过模型参数得到状态转移概率和观测概率,这样一来,我们可以得到如下分析:

因为每一种状态q_{i}有3种取值:NN、VV、AA,我们的已知观测序列有3个确定值:小明、喜欢、小红,所以从小明开始标注词性,即状态序列生成过程,一共有3^3=27种不同的链路。

【大道至简】机器学习算法之隐马尔科夫模型(Hidden Markov Model, HMM)详解(3)---预测问题:维特比算法(Viterbi Algorithm)详解及Python代码实现_第2张图片

 用语言描述就是,当第一步观测值为“小明”时,它有3种不同的状态可能值,每种状态值在转移成下一步观测为“喜欢”时的状态可能又有3种,以此类推,故一共有27种状态序列生成方法,这和我们在概率计算问题那篇文章中提到的直接计算法是一样的。现在让我们来计算第一条链路[NN,NN,NN]的概率。

P(L1|小明,喜欢,小红)=π(NN)*P(小明|NN)*P(NN|NN)*P(喜欢|NN)*P(NN|NN)*P(小红|NN)

其中的 “P(小明|NN)” 等,是我们的观测概率,其余的 “P(NN|NN)” 等是状态转移概率,π为初始状态概率,π(NN)指的是初始状态概率向量中取第一个状态值NN,以下类似。

【大道至简】机器学习算法之隐马尔科夫模型(Hidden Markov Model, HMM)详解(3)---预测问题:维特比算法(Viterbi Algorithm)详解及Python代码实现_第3张图片

第L2~L26的计算略过,第L27如下:

P(L27|小明,喜欢,小红)=π(AA)*P(小明|AA)*P(AA|AA)*P(喜欢|AA)*P(AA|AA)*P(小红|AA)

【大道至简】机器学习算法之隐马尔科夫模型(Hidden Markov Model, HMM)详解(3)---预测问题:维特比算法(Viterbi Algorithm)详解及Python代码实现_第4张图片

枚举了L1到L27的所有概率值后,我们选择概率最大的那条链路即可作为我们的词性标注结果。当然,这里我们只有3个观测值和3种状态,所以相对好算一点,如果我们的词量很大,词性也很多,想想计算复杂度是多少?序列长度为T,有N种状态,排列组合后共有N^T种不同的组合,即上述的27,而对于每种组合,我们又需要按照序列长度进行联合概率计算,上述例子每组联合概率的计算量为2T,去掉常数项2,所以总体复杂度为O(TN^T),何其庞大!所以实际这种枚举的方法是不可行的。

通过上述分析我们发现,每计算一条新的链路,我们都会从头到尾再计算一遍状态的所有可能性,这其实是存在很多重复计算的,那么我们是不是可以找到一种方法,在前向计算每一步的时候,都记录下来已有的计算结果,甚至于记录上一轮已取得的最大概率值,当我们计算新一轮概率时,只需要拿之前的最大概率值来计算,并取新的最大概率值,这样一来,当我们计算完所有节点后,得到的最终概率值即是最大了?其实这种方法就是动态规划,其用于求解隐马尔科夫模型预测问题所诞生的算法,就是我们的维特比算法。

三、维特比算法

还记得上文提到的δ吗?在维特比算法中,我们就用到两个变量:δ和ψ。接下来我结合《统计学习方法》中的内容来介绍维特比算法。

定义:

(1)δ

在时刻t,状态为 q_{i} 的所有单个链路(i_{1},i_{2},...,i_{t})中概率最大的值为

\delta_{t}(i)=\underset{i_{1},i_{2},...,i_{t-1}}{max}P(i_{t}=q_{i},i_{t-1},...,i_{1},o_{t},o_{t-1},...,o_{1};\lambda),\ \ i=1,2,...,N \ \ \ (10.44)

则δ的递推公式为:

 \begin{aligned} \delta_{t+1}(i)&=\underset{i_{1},i_{2},...,i_{t}}{max}P(i_{t+1}=q_{i},i_{t},...,i_{1},o_{t+1},o_{t},...,o_{1};\lambda) \\ &=\underset{1\leqslant j \leqslant N}{max}[\delta_{t}(j)a_{ji}]b_{i}(o_{t+1}), \ \ i=1,2,...,N;\ \ t=1,2,...,T-1 \ \ (10.45) \end{aligned}

该递推公式是说,我们在计算当前步的最大δ时,只需要用到前一步得到的δ即可,根据动态规划的思想,前一步的δ已经是我们计算过的局部最优路径下的δ,当我们完成所有步时,所有的局部最优路径组合起来就是全局最优路径了。

(2)ψ

在时刻t,状态为 q_{i} 的所有单个链路(i_{1},i_{2},...,i_{t})中概率最大的链路的第t-1个节点为:

\Psi_{t}(i)=\underset{1\leqslant j\leqslant N}{argmax[\delta_{t-1}(j)a_{ji}]}, \ \ j=1,2,...,N \ \ \ \ (10.46)

(3)算法流程

定义了δ和ψ后,我们来介绍一下维特比算法流程。

输入:模型λ=(A, B, π)和观测O=(o_{1},o_{2},...,o_{T})

输出:最优链路I^*=(i^*_{1},i^*_{2},...,i^*_{T})

【大道至简】机器学习算法之隐马尔科夫模型(Hidden Markov Model, HMM)详解(3)---预测问题:维特比算法(Viterbi Algorithm)详解及Python代码实现_第5张图片

 【大道至简】机器学习算法之隐马尔科夫模型(Hidden Markov Model, HMM)详解(3)---预测问题:维特比算法(Viterbi Algorithm)详解及Python代码实现_第6张图片

上述①式我用下图来直观解释一下,假定下图取的是中间的某一步t>1,注意,如果是初始状态t=1,由于不存在前驱节点,此时\Psi_{1}(i)

【大道至简】机器学习算法之隐马尔科夫模型(Hidden Markov Model, HMM)详解(3)---预测问题:维特比算法(Viterbi Algorithm)详解及Python代码实现_第7张图片

  • 第一个图中,我们分别取状态j=1,2,3时的δ,并且3个状态分别转移到状态i=1时得到3条路径的概率值j1,j2,j3,最后取max(j1, j2, j3)与b相乘;对应到公式中为

max[\delta_{t}(j=1,2,3)a_{j1}]*b_{1}(o_{t})

并最终得到 \delta_{t}(1),记录到当前节点为最大概率路径的上一节点的编号\Psi_{t}(1),即式子②; 

  • 第二个图中,我们分别取状态j=1,2,3时的δ,并且3个状态分别转移到状态i=2时得到3条路径的概率值j1,j2,j3,最后取max(j1, j2, j3)*;对应到公式中为

max[\delta_{t}(j=1,2,3)a_{j2}]*b_{2}(o_{t})

并最终得到\delta_{t}(2),记录到当前节点为最大概率路径的上一节点的编号\Psi_{t}(2)

  • 第三个图类似可得到\delta_{t}(3),记录到当前节点为最大概率路径的上一节点的编号\Psi_{t}(3)

【大道至简】机器学习算法之隐马尔科夫模型(Hidden Markov Model, HMM)详解(3)---预测问题:维特比算法(Viterbi Algorithm)详解及Python代码实现_第8张图片

当我们算完所有步后,最终将得到\delta_{T}(i),如果我们取P^*=max[\delta_{T}(i)],\ \ i=1,2,3,将会得到δ取得最大的那个节点的编号i^*_{T}=argmax(max[\delta_{T}(i)]) 。

【大道至简】机器学习算法之隐马尔科夫模型(Hidden Markov Model, HMM)详解(3)---预测问题:维特比算法(Viterbi Algorithm)详解及Python代码实现_第9张图片

最终求得最优路径I^*=(i^*_{1},i^*_{2},...,i^*_{T})

我解释一下回溯的过程,根据ψ的定义,当t=1时由于没有前驱节点,所以ψ=0。当t>1时,ψ记录的是我们所计算的上一步所有节点中,导致到当前节点为最优路径的那个节点,所以在最后一步中,我们又算得最优路径为节点i^*_{T},那么就需要知道导致最后一步为节点i^*_{T}的上一级的节点是啥,因为我们在ψ中已经记录了上级所有节点到当前各个节点的最优路径的编号,所以这里只需要在最后一步的ψ中搜索编号为i^*_{T}的ψ的值即可,继续回溯的每一步均为这个思路。 

【大道至简】机器学习算法之隐马尔科夫模型(Hidden Markov Model, HMM)详解(3)---预测问题:维特比算法(Viterbi Algorithm)详解及Python代码实现_第10张图片

注意,我们在当前步计算最大概率值δ时是前一步的δ乘以了一个b的,但回溯的时候,我们仅是根据当前最大概率值δ的索引(最优节点)去找前一步最大δ的索引,前一步最大δ索引上的节点到当前最优节点的这条局部最优路径并没有乘以b,它只是该局部最优路径的概率值,可以类比想象一下有点像图模型中的节点权重和边权重,节点m到节点n有一条边,这条边的权重乘以一个b才得到从节点m出发的节点n的权重。

建议大家再过一遍《统计学习方法》书中维特比算法中例子10.3,书上的例子难得的已经讲的很清楚了,因为只要掌握了动态规划的思想,维特比算法并不难,例子很快就能看懂,所以我这里就不再赘述。如果实在还有看不懂的小伙伴,可以评论区留言。

四、代码实现

代码地址:ml_algorithm/viterbi.py at master · fujingnan/ml_algorithm · GitHub

import numpy as np


class AttrDict(dict):
    # 一个小trick,将结果返回成一个字典格式
    def __init__(self, *args, **kwargs):
        super(AttrDict, self).__init__(*args, **kwargs)
        self.__dict__ = self


def dataloader(datapath):
    with open(datapath, 'r') as reader:
        for line in reader:
            yield line


class Model:
    """
    模型的参数估计,非Baum Welch算法,而是采用有监督的统计方法
    """
    def __init__(self, trainfile, N, M, Q):
        """
        初始化一些参数
        :param trainfile: 训练集路径
        :param N: 所有可能的状态数
        :param M: 所有可能的观测数
        :param Q: 所有可能的状态
        """
        self.trainfile = trainfile
        self.N = N
        self.M = M
        self.Pi = np.zeros(N)
        self.A = np.zeros((N, N))
        self.B = np.zeros((N, M))
        # 用id来表示每个状态
        self.Q2id = {x: i for i, x in enumerate(Q)}

    def cal_rate(self):
        """
        通过【10.3.1】节的内容来计算π、A、B中各个元素的频数;
        :return:
        """
        reader = dataloader(self.trainfile)
        for i, line in enumerate(reader):
            line = line.strip().strip('\n')
            if not line:
                continue
            word_list = line.split(' ')
            status_sequence = []
            # 计算π和B中每个元素的频数
            for j, item in enumerate(word_list):
                if len(item) == 1:
                    flag = 'S'
                else:
                    flag = 'B' + 'M' * (len(item) - 2) + 'E'
                if j == 0:
                    # 初始状态π的值是每条样本第一个字的状态出现的次数;
                    self.Pi[self.Q2id[flag[0]]] += 1
                for t, s in enumerate(flag):
                    # B有几行就代表有几种状态,每一列代表该状态下每种观测生成的次数;
                    self.B[self.Q2id[s]][ord(item[t])] += 1
                # 构建状态序列
                status_sequence.extend(flag)
            # 计算A元素的频数
            for t, s in enumerate(status_sequence):
                # A[i][j]表示由上一时刻的状态i转移到当前时刻状态j的次数
                prev = status_sequence[t - 1]
                self.A[self.Q2id[prev]][self.Q2id[s]] += 1

    def generate_model(self):
        """
        构建模型参数:
        主要是将频数表示的模型参数转化成频率表示的模型参数,在本代码中,利用"频数/总数"来表示各个参数中的值,取log是为了将乘法计算改为加法计算,
        这样可以便于计算,且防止乘积过小的情况;
        :return:
        """
        self.cal_rate()
        norm = -2.718e+16
        denominator = sum(self.Pi)
        for i, pi in enumerate(self.Pi):
            if pi == 0.:
                self.Pi[i] = norm
            else:
                self.Pi[i] = np.log(pi / denominator)
        # 公式【10.30】
        for row in range(self.A.shape[0]):
            denominator = sum(self.A[row])
            for col, a in enumerate(self.A[row]):
                if a == 0.:
                    self.A[row][col] = norm
                else:
                    self.A[row][col] = np.log(a / denominator)
        # 公式【10.31】
        for row in range(self.B.shape[0]):
            denominator = sum(self.B[row])
            for col, b in enumerate(self.B[row]):
                if b == 0.:
                    self.B[row][col] = norm
                else:
                    self.B[row][col] = np.log(b / denominator)
        return AttrDict(
            pi=self.Pi,
            A=self.A,
            B=self.B
        )


class Viterbi:
    def __init__(self, model: dict):
        """
        初始化一些参数
        :param model: 由训练而成的模型作为维特比算法预测依据
        """
        self.pi = model.pi
        self.A = model.A
        self.B = model.B

    def predict(self, datapath):
        """
        根据算法【10.5】生成预测序列
        :param datapath: 测试集路径
        :return:
        """
        reader = dataloader(datapath)
        self.O = [line.strip().strip('\n') for line in reader]
        N = self.pi.shape[0]
        self.segs = []
        for o in self.O:
            o = [w for w in o if w]
            if not o:
                self.segs.append([])
                continue
            T = len(o)
            # 定义δ和ψ
            delta_t = np.zeros((T, N))
            psi_t = np.zeros((T, N))
            for t in range(T):
                if not t:
                    # t=1时,根据算法【10.5】第(1)步,计算δ_{1}和ψ_{1}
                    delta_t[t][:] = self.pi + self.B.T[:][ord(o[0])]  # 由于log转换,所以原先的*变成+
                    psi_t[t][:] = np.zeros((1, N))
                else:
                    # 根据算法【10.5】第(2)步,递推计算δ_{t}和ψ_{t}
                    deltaTemp = delta_t[t - 1] + self.A.T
                    for i in range(N):
                        delta_t[t][i] = max(deltaTemp[:][i]) + self.B[i][ord(o[t])]
                        psi_t[t][i] = np.argmax(deltaTemp[:][i])
            I = []
            # 当计算完所有δ和ψ后,找到T时刻的δ中的最大值的索引,即算法【10.5】第(3)步中的i*_{T}
            maxNode = np.argmax(delta_t[-1][:])
            I.append(int(maxNode))
            for t in range(T - 1, 0, -1):
                # 算法【10.5】第(4)步,回溯找i*_{t}
                maxNode = int(psi_t[t][maxNode])
                I.append(maxNode)
            I.reverse()
            self.segs.append(I)

    def segment(self):
        """
        根据状态序列对句子进行分词
        :return: 分词结果列表
        """
        segments = []
        for i, line in enumerate(self.segs):
            curText = ""
            temp = []
            for j, w in enumerate(line):
                if w == 0:
                    # 如果该字的状态为"S",为单字
                    temp.append(self.O[i][j])
                else:
                    if w != 3:
                        # 如果该字的状态不为"E",那么要么为"B",要么为"M",说明一个词还没结束;
                        curText += self.O[i][j]
                    else:
                        # 遇到结束状态符"E"时,该词分词结束;
                        curText += self.O[i][j]
                        temp.append(curText)
                        curText = ''
            segments.append(temp)
        return segments


if __name__ == '__main__':
    # 我们用编码表示汉字字符,用`ord()`方法获得汉字编码,所以构建所有可能观测值的数为65536,保证所有字都能覆盖到;
    # S:单字表示符;
    # B:一个词的起始符;
    # M:一个属于一个词中间字的标识;
    # E:一个词的结束符;
    trainer = Model(N=4, M=65536, Q=['S', 'B', 'M', 'E'], trainfile='train.txt')
    model = trainer.generate_model()
    segment = Viterbi(model)
    segment.predict('test.txt')
    print(segment.segment())

五、总结

维特比算法实际上描述了这么一个事实,我们在隐马尔科夫链中寻找最优链路时,只需要递推的一步一步寻找当前的局部最优解,在寻找局部最优解的过程中,利用动态规划的思想,及时的存储已计算过的前一步局部最优解,并以前一步的局部最优解为基础来寻找新一轮的局部最优解,这样一来,完成整个链路的计算后,所有的局部最优链路组合起来自然而然的就形成全局最优路径了。这个一定要好好理解一下。


至此,所有隐马尔科夫模型的内容都介绍完了。它们是:

  • 隐马尔科夫的概念和定义
  • 隐马尔科夫模型的概率计算问题:前后向算法
  • 隐马尔科夫模型的学习问题:Baum Welch算法
  • 隐马尔科夫模型的预测问题:维特比算法

每部分内容的介绍链接在文首已放出,希望大家好好学习,并为文章的疏漏错误之处给予批评指正,谢谢支持!

思考:万一我们在计算δ的时候,发现有概率相等的情况,那该如何取最大值?如果概率相等,那么我们可以随机取一个,或者有其它更好方法的小伙伴,评论区留言,答案采纳后有惊喜~

你可能感兴趣的:(大道至简系列,#,机器学习算法系列,人工智能,自然语言处理,机器学习,隐马尔科夫模型,维特比算法)