机器学习:水浒传为例学习最大熵马尔科夫模型

2020年11月22日09:29:18 发表评论 79 views

我们用 "梁中书突围大名府为例" 讲解了隐马尔可夫模型,但是现实中情况可能更复杂,比如梁中书突围遇到了宋江,他再次选择从宋江处突围的可能性会变低,因为宋江身边肯定是梁山大部分好汉,突围难度太大。但是如果遇到史进,危险性就没有那么大了。

这种情况只用隐马尔科夫模型就很难解决,需要引入最大熵马尔可夫模型了。

最大熵马尔可夫模型(Maximum Entropy Markov Model,MEMM)的思想把HMM和最大熵结合起来,可以提取特征以泛化模型能力,结合上下文依赖,直接判别减少建模负担。下面就来详述。

1. 隐马尔科夫模型的缺点

HMM是一种生成模型,定义了联合概率分布,其中y和x分别表示观察序列和相对应的标注序列的随机变量。为了能够定义这种联合概率分布,生成模型需要枚举出所有可能的观察序列,这在实际运算过程中很困难的,因此我们需要将观察序列的元素看作是彼此孤立的个体,即假设每个元素彼此独立,任何时刻的观察结果只依赖于该时刻的状态。

目标函数和预測目标函数不匹配,HMM学到的是状态和观察序列的联合分布P(Y,X),而预測问题中,我们须要的是条件概率P(Y|X)。HMM必须计算出所有的潜在可能路径的概率值大小(然后再挑概率值最大的那一个作为最终结果)

HMM仅仅依赖于每个状态和它相应的观察对象,但是对于某一个观测值,它可能并非由一个隐藏状态决定的,而是两个以上的隐藏状态综合作用而产生的,那么这时HMM就无能为力了。

HMM模型的这个假设前提是在比较小的数据集上是合适的,但实际上在大量真实的语料中观察序列更多的是一种多重交互特征形式表现,观察元素之间广泛存在长程相关性。在命名实体识别的任务中,由于实体本身结构所具有的复杂性,利用简单的特征函数往往无法涵盖所有的特性,这时HMM的假设前提使得它无法使用复杂特征(它无法使用多于一个标记的特征)。

2. 最大熵模型的特点

最大熵模型的优点:首先,最大熵统计模型获得的是所有满足约束条件的模型中信息熵极大的模型;其次,最大熵统计模型可以灵活地设置约束条件,通过约束条件的多少可以调节模型对未知数据的拟合程度;再次,他还能自然地解决了统计模型中参数平滑的问题。

最大熵模型的不足:首先,最大熵统计模型中二值化特征只是记录特征的出现是否,而文本分类需要知道特征的强度,因此,它在分类方法中不是最优的。其次,由于算法收敛的速度比较慢,所以导致最大熵模型它的计算代价比较大,时空开销大;再次,数据稀疏问题比较严重。

最大熵模型可以使用任意的复杂相关特征,在性能上最大熵分类器超过了bayes分类器。但是,作为一种分类器模型,这两种方法有一个共同的缺点:每个词都是单独进行分类的,标记之间的关系无法得到充分利用。具有马尔可夫链的HMM模型可以建立标记之间的马尔科夫关联性,这是最大熵模型所没有的。所以一个很自然的想法就是将两者的优势结合起来,这就得到了最大熵马尔可夫模型。

3. MEMM的引入

最大熵马尔可夫模型(Maximum Entropy Markov Model,MEMM)的思想是利用 HMM 框架预测给定输入序列的序列标签,同时结合多项 Logistic 回归(又名最大熵,其给出了可以从输入序列中提取特征的类型和数量上的自由度)。这个模型允许状态转移概率依赖于序列中彼此之间非独立的 特征上,从而将上下文信息引入到模型的学习和识别过程中,提高了识别的精确度,召回率也大大的提高,有实验证明,这个新的模型在序列标注任务上表现的比 HMM和无状态的最大熵模型要好得多。

  • MEMM解决了HMM输出独立性假设的问题。因为HMM只限定在了观测与状态之间的依赖,而MEMM引入自定义特征函数,不仅可以表达观测之间的依赖,还可表示当前观测与前后多个状态之间的复杂依赖;( 这个复杂依赖是通过特征函数来做到的,比如该单词的 “前面单词/后面单词” 分别是什么 )
  • MEMM考虑到相邻状态之间依赖关系。且考虑整个观察序列,因此MEMM的表达能力更强;
  • MEMM不考虑P(X),减轻了建模的负担。同一时候学到的是目标函数是和预測函数一致;

HMM是生成式模型,参数即为各种概率分布元参数,数据量足够可以用最大似然估计。

而MEMM是判别式模型。是用函数直接判别,学习边界,MEMM即通过特征函数来界定。这里由于去掉了独立性假设,所以不能给出联合概率分布,只能求后验概率,所以是判别模型。但同样,MEMM也有极大似然估计方法、梯度下降、牛顿迭代、拟牛顿下降、BFGS、L-BFGS等等。

0x02 最大熵模型

因为HMM我们已经在前文了解,所以本文重点是最大熵模型的介绍。

1. Logistic 回归

通常,机器学习分类器通过从所有可能的 y_i 中选择有最大的 P(y|x) 的那个,来决定将哪个输出标签 y 分配给输入 x。在分类任务中,logistic 回归通过计算给定观察的属于每个可能类别的概率,然后选择产生最大概率的类别。

Logistic 回归是用于分类的一种监督学习算法,它的本质是线性回归。Logistic 回归用条件极大似然估计进行训练。这意味着我们将选择参数 w,使对给定输入值 x 在训练数据中 y 标签的概率最大化。通常可以运用随机梯度下降法、L-BFGS 或共轭梯度法来求此函数的最大值,即找到最优权重。

最大熵模型属于log-linear model,在给定训练数据的条件下对模型进行极大似然估计或正则化极大似然估计。

2. 最大熵原理

最大熵原理(Principle of Maximum Entropy):将已知事实作为制约条件,求得可使熵最大化的概率分布作为正确的概率分布。而在机器学习里,就是如果没有数据说明,就不要随便为模型加假设。

最大熵原理的实质就是,在已知部分知识的前提下,关于未知分布最合理的推断就是:符合已知知识后 “最不确定或最随机的推断” 是我们可以作出的唯一不偏不倚的选择,任何其它的选择都意味着我们增加了其它的约束和假设,这些约束和假设根据我们掌握的信息无法作出。

例如,投掷一个骰子,如果问"每个面朝上的概率分别是多少",你会说是等概率,即各点出现的概率均为1/6。因为对这个"一无所知"的色子,什么都不确定,而假定它每一个朝上概率均等则是最合理的做法。从投资的角度来看,这是风险最小的做法,而从信息论的角度讲,就是保留了最大的不确定性,也就是说让熵达到最大。
复制代码

实践经验和理论计算都告诉我们,在完全无约束状态下,均匀分布等价于熵最大(有约束的情况下,不一定是概率相等的均匀分布。 比如,给定均值和方差,熵最大的分布就变成了正态分布 )。

3. 最大熵模型

最大熵模型(Maximum Entropy Modeling) : 如果你有数据,请通过你的数据建立先验信息(在这里叫做约束条件),剩下的未知部分,请让他们均匀分布,也就是让他们的熵最大。

因为我们如果要搭建一个最大熵模型来实现分类,那么我们定义模型 p(y|x),这个模型应该是:在 "满足事先已约束" 的条件下的模型中选择熵最大的模型,即让不确定的信息等可能的发生。这样我们就得到了最终的分类模型。

即,给定一个训练样本集,我们希望寻找一个分布符合如下两个条件(Given a set of training examples, we wish to find a distribution which):

  • 满足已知的约束条件(satisfies the input constraints)
  • 最大化其不确定性(maximizes the uncertainty)

4. 已知的约束条件

上下文 & 特征函数

我们做分类问题,看到的数据往往是每个实例对应一个类别。 比如说词性标注中一个词对应一个标注。 为了下面讨论的方便,将类别称为Outcome,将每个实例的上下文环境叫做Context。 实例被称为Event,一个实例是Outcome与Context的二元组。

比如,一个多维的向量x,x的每个维度都是一个特征,可以认为 x 对应了一个 Context(特征的集合)。然后这条样本对应了一个label(Outcome)。

问题就是数据并不是乖乖地排列好,x的每个维度都已经取好值等着我们分类了,所以出现了这个东西:特征函数。我们使用特征函数来表示约束条件,即 特征函数也就是用来指示元素是否属于某一个子集。

比如记 w 是句子 s 的某个单词,w是有很多维度的,则用来表示这些维度的特征函数可以是:

  • w在句首
  • w在句尾
  • w的前缀是xxx
  • w的后缀是xxx
  • w前面的单词是xxx

可以看出,这些特征函数针对这个单词 w 的判别结果,就构成了 w 的上下文 Context。比如: w不在句首,w在句尾.....

特征函数

给定一个训练数据集

T={(x1,y1),(x2,y2)...(xN,yN)}T = \{ (x_1, y_1), (x_2, y_2) ... (x_N, y_N) \}

为了表示数据,我们从数据中抽取了一系列的特征。一般说的“特征”都是指输入的特征,而最大熵模型中的“特征”指的是输入和输出共同的特征。每个特征与每个类别都能组成一个特征,应该用乘法原理计数。也就是说,我们可以将X的每一维度特征下的每个取值与某个类别配对,并同样的用一个参数来描绘这个配对的紧密程度

可以这么理解:就是把普遍意义上的"输入的特征"拆分处理变成"输入和输出共同的特征"。

比如原来是:

{X=1}==>{Y=Classi}\{ X = 1 \} ==> \{ Y = Class_i \}

现在是

f(x,y)={1当x,y满足某一事实0当不满足该事实f(x,y) = \begin{cases} 1 & \text{当x,y满足某一事实} \\[2ex] 0 & \text{当不满足该事实} \end{cases}

比如每个特征函数可以是从Context到0-1的二值函数。

f(x,y)={1if x = "is" and y = v0otherwisef(x,y) = \begin{cases} 1 & \text{if $x$ = "is" and y = v} \\[2ex] 0 & \text{otherwise} \end{cases}

我们为每个 <feature,label> 对 都做一个如上的特征函数,用来描述数据集数学化。

权重

最大熵模型中的每个特征会有一个权重,就是上面说的参数。可以把它理解成这个特征所描述的输入和输出有多么倾向于同时出现。如果某类数据中某种输入和输出倾向于同时出现,那么对于这一类数据,表示这一对输入和输出的特征就会有较高的权重。如果认为这一对完全不可能成的话即让这一组的权重为0。

用途

特征函数就是用来形式化表示X的相关知识信息:在所有的特征中,哪些特征最有代表性,哪些特征是次要的,需要自己选择,构造约束集合。一组特征函数就将Context从上下文空间映射到特征空间上了。

特征函数的出现,让模型得到了更好的泛化能力。那么如何让一个线性模型H(x)也有类似的功能?答案就是特征函数,让输入x先经过一系列特征函数的处理,变成g(x),再送给模型分类,比如H(x) = H(g(x))。

特征函数f(x,y)并不仅仅代表计数,它还代表了某种对x和y(尤其是x)的简化。一组数据(x,y)一般情况下并不只触发一个特征。特征除了「x取特定值,y取特定值」的形式以外,还可以是「x取某类值,y取某类值」的形式,更一般的是「x和y的取值满足某种关系」的形式。正是这些一般形式的特征才让最大熵模型有足够的泛化能力。当一组数据不只触发一个特征的时候,exp内就不止一个权重求和,就求不出解析解了。

在最大熵模型的视角下,每条输入的n个“特征”与k个类别共同组成了nk个特征,模型中有nk个权重,与特征一一对应。每个类别会触发nk个特征中的n个。特征的全体可以看做是n个特征函数组成的一个集合。

5. 模型满足已知的约束条件

由于特征函数是对建立概率模型有益的特征,所以应该让最大熵模型来满足这些约束,经验分布与特征函数结合便能代表概率模型需要满足的约束。

拟合

回到我们之前给定的训练数据集

T={(x1,y1),(x2,y2)...(xN,yN)}T = \{ (x_1, y_1), (x_2, y_2) ... (x_N, y_N) \}

假如 数据(x1, y1) 满足了特征函数 f1(x,y),那么如果我们训练出的模型 M 也能得出 f1(x1,y1) = 1, 则说明 M 能对数据做在(x1, y1)这点上对f1 做了很好的拟合。

概率分布

对于给定训练数据集,现在把训练数据当做由随机变量 (𝑋,𝑌) 产生。我们可以根据训练数据确定 联合分布 P(x,y) 的经验分布 𝑃˜(x,y) 和 边缘分布 P(x)的经验分布𝑃˜(x), 即:

𝑃˜(X=x,Y=y)=count((X=x,Y=y))N𝑃˜(X=x, Y=y) = \frac{count((X=x, Y=y))}{N}
𝑃˜(X=x)=count((X=x))N𝑃˜(X=x) = \frac{count((X=x))}{N}

其中,count((X=x, Y=y) 表示训练数据中样本 (x,y) 出现的频数, count((X=x)) 表示训练数据集中 x 出现的频数。 N 表示训练样本的总容量。

期望

下面介绍两个期望。

**期望一:**现在,我们观察到一组数据集,通过简单的统计可以知道任意一个Context x 和 Outcome y 的组合的联合概率。有了联合概率,可以计算观察到的某一特征函数 f 的期望, 称为 观察期望/经验期望。即特征函数 f(x,y) 关于经验分布𝑃˜(x,y) 的期望值。

Eref(f)=∑x,yp~(x,y)f(x,y)=∑x,yf(x,y)NE_{ref}(f) = \sum_{x,y}p̃(x,y)f(x,y) = \frac{\sum_{x,y}f(x,y)}{N}

由于特征函数是对建立概率模型有益的特征,所以应该让 最大熵模型来满足“观察期望/经验期望”这一约束,

**期望二:**假设我们有一个模型,那么我们可以从这个模型的角度求出这个特征函数的期望,称为 模型期望。即特征函数 f(x,y) 关于模型 p(y|x) 和 经验分布 p̃(x) 的期望值。

Eq(f)=∑x,yp(x,y)f(x,y)≈∑x,yp~(x)p(y∣x)f(x,y)E_q(f) = \sum_{x,y}p(x,y)f(x,y) \approx\sum_{x,y}p̃(x)p(y|x)f(x,y)
  • E_ref 实际上代表了 训练数据 之中符合特征函数的数据的占比。可以理解为就是 收集到的数据。
  • E_q 实际上代表的是 模型先验数据 中符合约束条件的占比。可以理解为是 训练时得到的数据。

机器学习的目的就是从数据集中学得数据中包含的某种内在信息,因此我们希望我们的模型能够很好地反应这些数据中蕴含的现象。那么从模型角度看到的 f 的期望就应该等于从数据观察到的 f 的期望。也就是Eq(f) = Eref(f)。或者说,现在先验数据中的符合约束条件的占比 等于 训练数据中符合特征函数的数据占比,实际上就说明了模型已经符合约束条件。

模型集合

假设我们有n个特征函数,那么我们就有n组等式Eq(fi)=Eref(fi),i∈1,2,…,n。

假设我们有那么多的模型,也可以认为是概率分布。他们组成一个空间P,而满足上面一系列特征函数期望构成的等式的概率分布构成了 P 的一个子集。

C={p∣p∈P\andEq(fi)=Eref(fi),i∈(1,2,...,n)}C = \{ p|p∈ P \and E_q(f_i)=E_{ref}(f_i), i∈(1,2,...,n) \}

现在要找一个合适的模型描述数据,也就是在 P 中搜索一个p。 从满足约束的模型集合C 中找到使得 P(Y|X) 的熵最大的即为 最大熵模型了。

6. 最大化模型不确定性

对于这个模型p,这个模型需要满足上面一系列特征函数期望构成的等式(换句话讲,是一系列特征函数期望构成的约束)。从所有特征函数构成的整个概率分布出发的话,又 需要让未知事件保持最无序的状态(等概率分布的时候就是最无序的)。而衡量无序度的指标就是熵!所以,既有约束条件,又要尽可能保持最无序的状态,尽可能将可能性均匀地非配到不确定的上下文情况中。那目标状态就是满足约束条件的情况下,熵最大的状态。这里的熵指的是条件熵(已知x的情况下y的无序度)

确定了约束条件,求取最大熵的情况其实就是:求取有约束条件下的最大熵值。即,最大熵模型就是在满足约束的模型集合中选择条件概率分布 p(y|x) 最大的模型,其形式化如下:

  • 根据经验分布得到满足约束集的模型集合 C,即模型应该满足下面两个约束
Eref(fi)=Eq(fi),i=1,2,..,n∑yp(y∣x)=1E_{ref}(f_i) = E_q(f_i), i =1,2,..,n \\ \sum_y p(y|x) = 1
  • 我们的训练目标,即目标函数,也就是在这两个约束下对下面的式子求最值
H(P)={−∑x,yp~(x)p(x∣y)logp(y∣x)}H(P) = \{ -\sum_{x,y}p̃(x)p(x|y)logp(y|x) \}

上面第一个约束就是 E_ref (训练数据之中符合特征函数的数据的占比) = E_q (模型先验数据中符合约束条件的占比),即模型已经符合约束条件。第二个约束就是 概率分布应该是1。

求最值的式子就是定义在条件概率分布p(y|x)上的条件熵公式。求最值所得出的解就是最大熵模型学习的模型。

max𝑃∈𝐶H(P)=max𝑃∈𝐶{−∑x,yp~(x)p(x∣y)logp(y∣x)}max_{𝑃∈𝐶} H(P) = max_{𝑃∈𝐶} \{ -\sum_{x,y}p̃(x)p(x|y)logp(y|x) \}

熵越大,可能性就越平均地被分配,因而我们的最终目标是最大化一个模型的熵。而由于有前面的约束等式,这个问题变成了一个有约束的最优化问题。

7. 拉格朗日乘子法

MaxEnt 模型最后被形式化为带有约束条件的最优化问题。对于"求取有约束条件下的最值",很容易我们就想到了拉格朗日乘子法,引入拉格朗日乘子: w0,w1,…,wn,定义拉格朗日函数 𝐿(𝑃,𝑤), 将有约束化为无约束。

现在问题可以形式化为便于拉格朗日对偶处理的极小极大的问题。求解最优的 w∗ 后,便得到了所要求的MaxEnt 模型。

最终通过一系列极其复杂的运算,得到了需要极大化的式子(这里 fi(x,y) 代表特征函数,wi 代表特征函数的权值):

max𝑝∈𝐶∑𝑥,𝑦𝑃˜(𝑥,𝑦)∑𝑖=1𝑛𝑤𝑖𝑓𝑖(𝑥,𝑦)+∑𝑥𝑃˜(𝑥)𝑙𝑜𝑔𝑍𝑤(𝑥)max_{𝑝∈𝐶}∑_{𝑥,𝑦}𝑃˜(𝑥,𝑦)∑_{𝑖=1}^𝑛𝑤_𝑖𝑓_𝑖(𝑥,𝑦)+∑_𝑥𝑃˜(𝑥)𝑙𝑜𝑔𝑍_𝑤(𝑥)

这里 fi(x,y) 代表特征函数,wi 代表特征函数的权值, Pw(y|x) 即为 MaxEnt 模型,将最优解记做 w∗。

8. 极大似然估计

但是上述过程还是太复杂。有没有简单易行的方式呢? 答案就是极大似然估计 MLE 了。数学证明可以得出,最大熵模型学习中的对偶函数极大化等价于最大熵模型的极大似然估计。极大似然估计函数是一个凸函数,这是优化问题中最容易优化的模型,我们可以得到全局最优解。

最大熵模型的似然是使用了(模型学的)真实分布 p(x,y) 与(来自数据的)经验分布p̃(x,y) 的交叉熵来定义

这里有训练数据得到经验分布 𝑃˜(𝑥,𝑦) , 待求解的概率模型 𝑃(𝑌|𝑋) 的似然函数为:

𝐿𝑃˜(𝑃𝑤)=𝑙𝑜𝑔∏𝑥,𝑦𝑃(𝑦∣𝑥)𝑃˜(𝑥,𝑦)=∑𝑥,𝑦𝑃˜(𝑥,𝑦)𝑙𝑜𝑔𝑃(𝑦∣𝑥)𝐿_𝑃˜(𝑃_𝑤)=𝑙𝑜𝑔∏_{𝑥,𝑦}𝑃(𝑦|𝑥)^{𝑃˜(𝑥,𝑦)}=∑_{𝑥,𝑦}𝑃˜(𝑥,𝑦)𝑙𝑜𝑔𝑃(𝑦|𝑥)

令 𝑍𝑤(𝑥)表示 𝑒𝑥𝑝(1−𝑤0) ,将Pw(y|x) 带入公式可以得到:

𝐿𝑃˜(𝑃𝑤)=∑𝑥,𝑦𝑃˜(𝑥,𝑦)𝑙𝑜𝑔𝑃(𝑦∣𝑥)=∑x,y𝑃˜(x,y)∑i=1nwifi(x,y)−∑x𝑃˜(x)logZw(x)𝐿_𝑃˜(𝑃_𝑤)=∑_{𝑥,𝑦}𝑃˜(𝑥,𝑦)𝑙𝑜𝑔𝑃(𝑦|𝑥) = \sum_{x,y}𝑃˜(x,y)\sum_{i=1}^nw_if_i(x,y) - \sum_x𝑃˜(x)logZ_w(x)

现在只需极大化似然函数即可,顺带优化目标中可以加入正则项,这是一个凸优化问题,一般的梯度法、牛顿法都可解之,专门的算法有GIS和IIS 算法。

0x03 最大熵模型算法实现

1. 算法总述

现在我们基本得到了算法要做的工作,就是从数据中估计出一个特征函数的权向量λ,最大化"经验分布的最大似然估计"。若想让预测出的结果全部正确的概率最大,根据最大似然估计,就是所有样本预测正确的概率相乘得到的P(总体正确)最大,

即, 有n个特征函数fi(x),那么就有n组等式Eq(fi)=Eref(fi),i∈1,2,…,n。这些就是约束条件集合。我们的训练目标就是目标函数即熵H(p(y|x))。我们求 “特征函数f(x,y)和其权重λ的一套组合” ,该组合 会令 熵 H(p(y|x)) 取到最大值。或者说,我们要的就是根据模型参数来计算期望。最后尽可能的优化到使模型期望和先验期望一样且熵最大。

算法的流程如下:

  1. 初始化λ=0
  2. 循环
    λi(t+1)=λi(t)+1ClogEref(fi)Eq(fi)λ_i^{(t+1)} = λ_i^{(t)} + \frac{1}{C} log \frac {E_{ref}(f_i)}{E_q(f_i)}
    C=maxx,y∑i=1nfi(x,y)C = max_{x,y}\sum_{i=1}^nf_i(x,y)
  3. 重复2到收敛

根据上面算法,在最大熵模型的实现过程中,我们需要计算的值包括经验期望 E_ref(f) 和各轮迭代过后的模型期望E_q(f)。

经验期望 Eref(fi)=∑x,yp̃ (x,y)fi(x,y),求Eref(fi)只需要统计训练数据中符合fi的(x,y)二元组的个数,然后除以训练实例的个数N。

模型期望 需要首先求p(y|x)。 这个条件概率可以通过简单地将所有(x,y)符合的fi和对应的参数λi乘起来后相加。归一化因子是各个Outcome y的p(y|x)的和。在求得p(y|x)后,要求Eq(fi),只需要枚举所有符合fi的(x,y)对应的p(y|x),乘以Context x出现的次数再除以N就可以。

模型期望有了,经验期望有了,把他们放到算法里面去迭代就好了。

以下代码出自easyME

2. 数据结构和初始化

//我们做分类问题,看到的数据往往是每个实例对应一个类别。比如说词性标注中一个词对应一个标注。为了下面讨论的方便,将类别称为Outcome,将每个实例的上下文环境叫做Context。 实例被称为Event,一个实例是 Outcome y 与Context的二元组
//一个多维的向量x。x的每个维度都是一个特征,可以认为 x 对应了一个 Context(特征的集合)。然后这条样本对应了一个label(Outcome)
struct Event {
    size_t classId; //将类别称为Outcome
    std::vector<size_t> context; //将每个实例的上下文环境叫做Context, 可以认为是一系列 feature 的列表, 每个 feature 被映射成一个数字。
    size_t count; //符合该上下文环境的(x,y)二元组的个数
}

// X的每一维度特征下的每个取值与某个类别配对,并同样的用一个参数(权重)来描绘这个配对的紧密程度
class DataManager {
  typedef std::pair<size_t, double> Pair;
  typedef std::vector<Pair> Param;
  std::vector<Event> mEventSet;
  std::vector<Param> mParamSet; //这个就是最后模型参数(权重)
  //fetId 特征纬度,classPos 类别
  void DataManager::incLambda(size_t fetId, size_t classPos, double incer) {
      mParamSet[fetId][classPos].second += incer; //更新模型参数(权重)
  }
}

//初始化
bool MaxEntModel::initModel(const char * trainFileName, bool freq, bool isSelectFeature) {
    string line, str;
    vector<size_t> context;
    size_t count = 1;
    // each line is a event, it looks like this: (count) className fetName ... fetName
    while(getline(fin, line)){
        istringstream sin(line);
        // with freq ?
        if(freq) sin >> count;
        sin >> str;
        size_t classId = mClassMap.insertString(str);
        context.clear();
        while(sin >> str){
            size_t fetId = mFetMap.insertString(str);
            context.push_back(fetId); //由此能看出,context是一系列的 feature
        }
        mModelInfo.addEvent(count, classId, context);
    }
    if(!freq) mModelInfo.processEventSet();
    mModelInfo.getAllFeatures();  
}

//在最大熵模型的视角下,每条输入的n个“特征”与k个类别共同组成了nk个特征,模型中有nk个权重,与特征一一对应。每个类别会触发nk个特征中的n个。特征的全体可以看做是n个特征函数组成的一个集合。
//Event是 实例是Outcome与Context的二元组,count是满足这个组合的数据个数,即,符合该上下文环境的(x,y)二元组的个数
void DataManager::addEvent(size_t count, size_t classId, const std::vector<size_t> & fetVec) {
	  mTotEvent += count;
    mEventSet.push_back(Event(count, classId, fetVec));
}

void DataManager::getAllFeatures() {
    size_t eventNum = getEventNum();
    for(size_t i = 0; i < eventNum; ++i){
        int cid = getEventClassId(i);
        for(context_iterator it = getContextBegin(i),
                end = getContextEnd(i);
                it != end; ++it){
            int fid = *it;
            addFeature(cid, fid);
        }
    }
    endAddFeature();
}

void DataManager::addFeature(size_t classId, size_t fetId, double weight = 0.0) {
    if(mParamSet.size() < fetId + 1) mParamSet.resize(fetId + 1);
    if(classId > mMaxCid) mMaxCid = classId;
    if(fetId > mMaxFid) mMaxFid = fetId;
    mParamSet[fetId].push_back(std::make_pair(classId, weight));
}

DataManager::context_iterator DataManager::getContextBegin(size_t eventId) {
    return mEventSet[eventId].context.begin();
}
复制代码

3. 获取期望

//Eref(fi),训练数据之中符合特征函数的数据的占比。可以理解为就是收集到的数据。 
//统计训练数据中符合fi的(x,y)二元组的个数,然后除以训练实例的个数N
void DataManager::getObserves(vector<vector<double> > & observed) {
	size_t mMaxFid = getFetNum();
	size_t eventNum = getEventNum();
    observed.clear();
    observed.resize(mMaxFid + 1);
    for(size_t i = 1; i <= mMaxFid; ++i){
        DataManager::param_iterator begin = getParamBegin(i);
        DataManager::param_iterator end = getParamEnd(i);
        size_t n = end - begin;
        observed[i].resize(n, 0);
    }
    //每个event就是一个实例,一个实例是Outcome与Context的二元组
    for(size_t i = 0; i < eventNum; ++i){ 
        size_t classId = getEventClassId(i); //该event实例对应的类 Outcome
        size_t count = getEventCount(i); //满足该event实例的(x,y)的个数
        for(DataManager::context_iterator it = getContextBegin(i),end = getContextEnd(i);
                it != end; ++it){ //遍历context的所有feature
            int fid = *it;
            int pos = getClassPosition(classId, fid); //找到mParamSet中该类的位置
            if(pos != -1)
                observed[fid][pos] += count; //统计训练数据中符合该fi的(x,y)二元组的个数,因为每个event的context包含了多个feature,所以要对特定fid进行累加
        }
    }
}

//E_q(f),模型先验数据中符合约束条件的占比。可以理解为是训练时得到的数据。
double DataManager::getExpects(vector<vector<double> > & expected) {
    vector<double> probs;
    expected.clear();
    expected.resize(mParamSet.size());
    for(size_t i = 0; i < mParamSet.size(); ++i)
        expected[i].resize(mParamSet[i].size(), 0);
    double logLike = 0;
    for(size_t i = 0, eventNum = getEventNum(); i < eventNum; ++i){ 
        context_iterator ctxtBegin = getContextBegin(i);
        context_iterator ctxtEnd = getContextEnd(i);
        getAllProbs(ctxtBegin, ctxtEnd, probs); //
        size_t count = getEventCount(i); // 满足的个数
        size_t classId = getEventClassId(i); // 类别
        vector<double> newProbs;
        for(size_t i = 0; i < probs.size(); ++i)
        	newProbs.push_back(probs[i] * count);
        for(context_iterator it = ctxtBegin; it != ctxtEnd; ++it){
          size_t fid = *it;
          param_iterator pBegin = getParamBegin(fid);
          param_iterator pEnd = getParamEnd(fid);
          for(param_iterator pit = pBegin; pit != pEnd; ++pit){
            int pos = pit - pBegin;
            size_t cid = pit->first;
            expected[fid][pos] += newProbs[cid];
          }
				}
				logLike += log(probs[classId]) * count;
    }
   return logLike;
}

//求p(y|x)。这个条件概率可以通过简单地将所有(x,y)符合的fi和对应的参数λi乘起来后相加。 归一化因子是各个Outcome y 的p(y|x)的和。在求得p(y|x)后,要求Eq(fi),只需要枚举所有符合fi的(x,y)对应的p(y|x),乘以Context x 出现的次数再除以N就可以。 
size_t DataManager::getAllProbs(
        const context_iterator begin,
        const context_iterator end,
        vector<double> & probs) {
    probs.clear();
    probs.resize(mMaxCid + 1, 0);
    for(context_iterator cit = begin; cit != end; ++cit){
        size_t fid = *cit;
        for(param_iterator it = getParamBegin(fid),
                pend = getParamEnd(fid);
                it != pend; ++it){
            size_t cid = it->first; // Outcome y
            probs[cid] += it->second; //参数λi
        }
    }
    size_t maxK = 0;
    double sum = 0;
    for(size_t i = 1; i <= mMaxCid; i++){
        probs[i] = exp(probs[i]);
        sum += probs[i]; //参数λi乘起来后相加。
        if(probs[i] > probs[maxK])
            maxK = i;
    }
    for(size_t i = 1; i <= mMaxCid; ++i)
        probs[i] /= sum; //除以N
    return maxK;
}
复制代码

4. 具体训练过程

void MaxEntTrainer::_initTrainer(void) {
    mEventNum = mModelInfo.getEventNum();
    mMaxFid = mModelInfo.getFetNum();
    mMaxCid = mModelInfo.getClassNum();
    mTotEvent = mModelInfo.getAllEventFreq();
    mModelInfo.getObserves(mObserved);
}

// Calculate the ith GIS parameter updates with Gaussian prior
// using Newton-Raphson method
// the update rule is the solution of the following equation:
//                                   lambda_i + delta_i
// E_ref = E_q * exp (C * delta_i) + ------------------ * N
//                                       sigma_i^2
// note: E_ref and E_q were not divided by N
// this function is copied from Le Zhang's work
double MaxEntTrainer::_newton(double f_q, double f_ref, double lambdaNow, double mEps)
{
    size_t maxiter = 50;
    double x0 = 0.0;
    double x = 0.0;

    for (size_t mIter = 1; mIter <= maxiter; ++mIter) {
        double t = f_q * exp(mSlowF * x0);
        double fval = t + mTotEvent * (lambdaNow + x0) / mSigma2 - f_ref;
        double fpval = t * mSlowF + mTotEvent / mSigma2;
        if (fpval == 0) {
            cerr << "Warning: zero-derivative encountered in newton() method." << endl;
            return x0;
        }
        x = x0 - fval/fpval;
        if (fabs(x-x0) < mEps)
            return x;
        x0 = x;
    }
    cerr << "Failed to converge after 50 iterations in newton() method" << endl;
    exit(-1);
}

bool GisTrainer::train() {
    vector<vector<double> > expected;
    double preLogLike = -1e10;
    for(size_t it = 0; it < mIter; ++it) {
        double newLogLike = mModelInfo.getExpects(expected);
        for(size_t i = 1; i <= mMaxFid; ++i) {
            DataManager::param_iterator begin = mModelInfo.getParamBegin(i);
            DataManager::param_iterator end = mModelInfo.getParamEnd(i);
            for(DataManager::param_iterator it = begin; it != end; ++it) {
                size_t j = it - begin;
                double inc = 0;
                if(mSigma2) {
                    inc = _newton(expected[i][j], mObserved[i][j], it->second);
                } else if(mAlpha) {
                	if(mObserved[i][j] - mAlpha <= 0)
                    	continue;
                  inc = (log(mObserved[i][j] - mAlpha) - log(expected[i][j])) / mSlowF;
                	if(it->second + inc <= 0)
                    	inc = -it->second;
                } else {
				         	inc = (log(mObserved[i][j]) - log(expected[i][j])) / mSlowF;
        				}
                mModelInfo.incLambda(i, j, inc); //更新模型参数(权重)
            }
        }
        if(fabs((newLogLike - preLogLike) / preLogLike) < mEps)
            break;
        preLogLike = newLogLike;
    }
    return true;
}
复制代码

0x04 用最大熵做文本分类

下面代码来自最大熵用于文本分类,可以和上面印证学习。

def get_ctgy(fname):#根据文件名称获取类别的数字编号
        index = {'fi':0,'lo':1,'co':2,'ho':3,'ed':4,'te':5,
                 'ca':6,'ta':7,'sp':8,'he':9,'ar':10,'fu':11}
        return index[fname[:2]]

def updateWeight():
        #EP_post是 单词数*类别 的矩阵
        for i in range(wordNum):
            for j in range(ctgyNum):
                EP_post[i][j] = 0.0 #[[0.0 for x in range(ctgyNum)] for y in range(wordNum)]
        # prob是 文本数*类别 的矩阵,记录每个文本属于每个类别的概率
        cond_prob_textNum_ctgyNum = [[0.0 for x in range(ctgyNum)] for y in range(textNum)]
        #计算p(类别|文本)

        for i in range(textNum):#对每一个文本
                zw = 0.0  #归一化因子
                for j in range(ctgyNum):#对每一个类别
                        tmp = 0.0
                       #texts_list_dict每个元素对应一个文本,该元素的元素是单词序号:频率所组成的字典。
                        for (feature,feature_value) in texts_list_dict[i].items():
                            #v就是特征f(x,y),非二值函数,而是实数函数,
                            #k是某文本中的单词,v是该单词的次数除以该文本不重复单词的个数。
                                #feature_weight是 单词数*类别 的矩阵,与EP_prior相同
                                tmp+=feature_weight[feature][j]*feature_value #feature_weight是最终要求的模型参数,其元素与特征一一对应,即一个特征对应一个权值
                        tmp = math.exp(tmp)
                        zw+=tmp #zw关于类别求和
                        cond_prob_textNum_ctgyNum[i][j]=tmp                        
                for j in range(ctgyNum):
                        cond_prob_textNum_ctgyNum[i][j]/=zw
        #上面的部分根据当前的feature_weight矩阵计算得到prob矩阵(文本数*类别的矩阵,每个元素表示文本属于某类别的概率),
        #下面的部分根据prob矩阵更新feature_weight矩阵。

        for x in range(textNum):
                ctgy = category[x] #根据文本序号获取类别序号
                for (feature,feature_value) in texts_list_dict[x].items(): #该文本中的单词和对应的频率
                        EP_post[feature][ctgy] += (cond_prob_textNum_ctgyNum[x][ctgy]*feature_value)#认p(x)的先验概率相同        
        #更新特征函数的权重w
        for i in range(wordNum):
                for j in range(ctgyNum):
                        if (EP_post[i][j]<1e-17) |  (EP_prior[i][j]<1e-17) :
                                continue                        
                        feature_weight[i][j] += math.log(EP_prior[i][j]/EP_post[i][j])        

def modelTest():
        testFiles = os.listdir('data\\test\\')
        errorCnt = 0
        totalCnt = 0

        #matrix是类别数*类别数的矩阵,存储评判结果
        matrix = [[0 for x in range(ctgyNum)] for y in range(ctgyNum)]
        for fname in testFiles: #对每个文件
                lines = open('data\\test\\'+fname)
                ctgy = get_ctgy(fname) #根据文件名的前两个字符给出类别的序号
                probEst = [0.0 for x in range(ctgyNum)]         #各类别的后验概率
                for line in lines: #该文件的每一行是一个单词和该单词在该文件中出现的频率
                        arr = line.split('\t')
                        if not words_dict.has_key(arr[0]) : 
                            continue        #测试集中的单词如果在训练集中没有出现则直接忽略
                        word_id,freq = words_dict[arr[0]],float(arr[1])
                        for index in range(ctgyNum):#对于每个类别
                            #feature_weight是单词数*类别墅的矩阵
                            probEst[index] += feature_weight[word_id][index]*freq
                ctgyEst = 0
                maxProb = -1
                for index in range(ctgyNum):
                        if probEst[index]>maxProb:
                            ctgyEst = index
                            maxProb = probEst[index]
                totalCnt+=1
                if ctgyEst!=ctgy: 
                    errorCnt+=1
                matrix[ctgy][ctgyEst]+=1
                lines.close()
        #print "%-5s" % ("类别"),
        #for i in range(ctgyNum):
        #    print "%-5s" % (ctgyName[i]),  
        #print '\n',
        #for i in range(ctgyNum):
        #    print "%-5s" % (ctgyName[i]), 
        #    for j in range(ctgyNum):
        #        print "%-5d" % (matrix[i][j]), 
        #    print '\n',
        print "测试总文本个数:"+str(totalCnt)+"  总错误个数:"+str(errorCnt)+"  总错误率:"+str(errorCnt/float(totalCnt))

def prepare():
        i = 0
        lines = open('data\\words.txt').readlines()
        #words_dict给出了每一个中文词及其对应的全局统一的序号,是字典类型,示例:{'\xd0\xde\xb5\xc0\xd4\xba': 0}
        for word in lines:
                word = word.strip()
                words_dict[word] = i
                i+=1
        #计算约束函数f的经验期望EP(f)
        files = os.listdir('data\\train\\') #train下面都是.txt文件
        index = 0
        for fname in files: #对训练数据集中的每个文本文件
                file_feature_dict = {}
                lines = open('data\\train\\'+fname)
                ctgy = get_ctgy(fname) #根据文件名的前两个汉字,也就是中文类别来确定类别的序号

                category[index] = ctgy #data/train/下每个文本对应的类别序号
                for line in lines: #每行内容:古迹  0.00980392156863
                        # line的第一个字符串是中文单词,第二个字符串是该单词的频率
                        arr = line.split('\t')
                        #获取单词的序号和频率
                        word_id,freq= words_dict[arr[0]],float(arr[1])

                        file_feature_dict[word_id] = freq
                        #EP_prior是单词数*类别的矩阵
                        EP_prior[word_id][ctgy]+=freq
                texts_list_dict[index] = file_feature_dict
                index+=1
                lines.close()
                
def train():
        for loop in range(4):
            print "迭代%d次后的模型效果:" % loop
            updateWeight()
            modelTest()


textNum = 2741  # data/train/下的文件的个数
wordNum = 44120 #data/words.txt的单词数,也是行数
ctgyNum = 12


#feature_weight是单词数*类别的矩阵
feature_weight = np.zeros((wordNum,ctgyNum))#[[0 for x in range(ctgyNum)] for y in range(wordNum)]

ctgyName = ['财经','地域','电脑','房产','教育','科技','汽车','人才','体育','卫生','艺术','娱乐']
words_dict = {}

# EP_prior是个12(类)* 44197(所有类的单词数)的矩阵,存储对应的频率
EP_prior = np.zeros((wordNum,ctgyNum))
EP_post = np.zeros((wordNum,ctgyNum))
#print np.shape(EP_prior)
texts_list_dict = [0]*textNum #所有的训练文本 
category = [0]*textNum        #每个文本对应的类别

print "初始化:......"
prepare()
print "初始化完毕,进行权重训练....."
train()
复制代码

0x05 最大熵马尔科夫模型 MEMM

1. 建模公式

最大熵马尔科夫模型可以看作是HMM与log-linear model结合的一种方式。MEMM利用判别式模型的特点,直接对每一个时刻的状态建立一个分类器,然后将所有的分类器的概率值连乘起来。

与HMM的 o 依赖 i 不一样,MEMM的当前状态依赖于前一状态与当前观测;MEMM当前隐藏状态 i 应该是依赖当前时刻的观测节点 o 和上一时刻的隐藏节点 i-1。所以MEMM中,给定观测序列 i1,...in 后,某个状态序列 in 的条件概率是可以直接学习的:

P(I∣O)=∏t=1nP(ii∣ii−1,oi),i=1,...,nP(I|O) = \prod_{t=1}^n P(i_i|i_{i-1},o_i), i = 1, ...,n

并且P(i|i',o)这个概率通过最大熵分类器建模(取名MEMM的原因):

P(i∣i′′,o)=1Z(o,i′)exp(∑aλafa(o,i))P(i|i^{''},o) = \frac{1}{Z(o,i^{'})}exp(\sum_aλ_af_a(o,i))

Z(o,i) 这部分是归一化;f(o,i) 是特征函数,具体点,这个函数是需要去定义的。MEMM模型就是能够直接允许**“定义特征”**。λ是特征函数的权重,这是个未知参数,需要从训练阶段学习而得。

所以总体上,MEMM的建模公式这样:

P(I∣O)=∏t=1nexp(∑aλafa(o,i))Z(o,ii−1′),i=1,...,nP(I|O) = \prod_{t=1}^n \frac{exp(\sum_aλ_af_a(o,i))}{Z(o,i^{'}_{i-1})}, i = 1, ...,n

公式这部分之所以长成这样,是由ME模型决定的。

使用维特比算法进行解码时,MEMM对应的公式为:

νt(j)=maxinνt−1(i)∗P(yj∣yi,xt)1≤j≤n,1<t<Tν_t(j)= max_i^n ν_{t-1}(i)∗P(y_j∣y_i,x_t) \\ 1≤j≤n,1<t<T

请务必注意,理解判别模型定义特征两部分含义,这已经涉及到CRF的雏形了。

完整流程:

  • step1. 先预定义特征函数 f(o,i)
  • step2. 在给定的数据上,训练模型,确定参数,即确定了MEMM模型
  • step3. 用确定的模型做序列标注问题或者序列求概率问题。

2. MEMM vs HMM

HMM模型是对状态转移概率(状态-状态)和发射概率(状态-观察)直接建模,统计共现概率。 MEMM模型是对状态转移概率和发射概率建立联合概率,统计的是条件概率。但MEMM容易陷入局部最优,是因为MEMM只在局部做归一化。

举个例子,对于一个标注任务,“我爱北京天安门“,

       标注为" s s b e b c e"
复制代码

对于HMM的话,其判断这个标注成立的概率为 P= P(s转移到s)P('我'表现为s) P(s转移到b)P('爱'表现为s) ...*P().训练时,要统计状态转移概率矩阵和表现矩阵。

对于MEMM的话,其判断这个标注成立的概率为 P= P(s转移到s|'我'表现为s)P('我'表现为s) P(s转移到b|'爱'表现为s)P('爱'表现为s)..训练时,要统计条件状态转移概率矩阵和表现矩阵。

3. 代码对比

以下代码来自 github.com/alexkartun/…

HMMTag

下面是 HMM 的代码,计算了Emission / Transaction。

def predict_viterbi(x, trans_c, emiss_c, word_tags_map, interp_weights):
    """
    For each word in vector x predict his tag. Prediction is done by viterbi algorithm. Check all tags options/globally.
    :param x: X vector of words.
    :param trans_c: Transaction counts.
    :param emiss_c: Emission counts.
    :param word_tags_map: Word to tags map.
    :param interp_weights: Interpolation weights.
    :return: Vector of all tags respectively to words in vector x.
    """
    y = []
    v = [{(mle_train.START_SYMBOL, mle_train.START_SYMBOL): 0.0}]
    bp = []
    for ind, word in enumerate(x):
        # Convert word if it was'nt seen in the corpus, to signature word.
        if word not in word_tags_map:
            word = mle_train.subcategorize(word)
        # Pruning of tags to lower amount of possible tags for this word.
        available_tags = word_tags_map[word]

        max_score = {}
        max_tags = {}
        # Calculate for each word best scores/probabilities and best tags for each word.
        for pp_t, p_t in v[ind]:
            for curr_tag in available_tags:
                score = get_score(word, curr_tag, p_t, pp_t, trans_c, emiss_c, interp_weights)
                score += v[ind][(pp_t, p_t)]

                if (p_t, curr_tag) not in max_score or score > max_score[(p_t, curr_tag)]:
                    max_score[(p_t, curr_tag)] = score
                    max_tags[(p_t, curr_tag)] = pp_t

        v.append(max_score)
        bp.append(max_tags)
    # Calculate last 2 best tags.
    max_score = float("-inf")
    prev_last_tag, last_tag = None, None
    for prev_t, curr_t in v[len(x)]:
        score = v[len(x)][(prev_t, curr_t)]
        if score > max_score:
            max_score = score
            last_tag = curr_t
            prev_last_tag = prev_t
    y.append(last_tag)
    if len(x) > 1:
        y.append(prev_last_tag)

    prev_t = last_tag
    prev_prev_t = prev_last_tag
    # By backtracking extract all the path of best tags for each word starting by last 2 tags we calculated above.
    for i in range(len(v) - 2, 1, -1):
        curr_t = bp[i][(prev_prev_t, prev_t)]
        y.append(curr_t)
        prev_t = prev_prev_t
        prev_prev_t = curr_t
    # Reverse the path.
    y = reversed(y)
    return y
  
def get_score(word, curr_tag, p_t, pp_t, trans_c, emiss_c, interp_weights):
    """
    Calculate probability. Prob = e_prob + q_prob.
    :param word: Curr word.
    :param curr_tag: Curr tag.
    :param p_t: Previous tag.
    :param pp_t: Previous of previous tag.
    :param trans_c: Transaction counts.
    :param emiss_c: Emission counts.
    :param interp_weights: Interpolation weights.
    :return:
    """
    e = mle_train.get_e(word, curr_tag, emiss_c, trans_c)
    q = mle_train.get_q(pp_t, p_t, curr_tag, trans_c, interp_weights)
    if e != 0 and q != 0:
        score = math.log(e) + math.log(q)
    else:
        score = float('-inf')
    return score  
  
def get_e(word, tag, emiss_c, trans_c):
    """
    Calculate e probability.
    :param word: Current word to analyze.
    :param tag: Current tag to analyze.
    :param emiss_c: Emission counts.
    :param trans_c: Transaction counts.
    :return: Probability value.
    """
    key = '{} {}'.format(word, tag)
    count_word_tag = float(emiss_c.get(key, 0))
    key = '{}'.format(tag)
    count_tag = float(trans_c.get(key, 0))
    try:
        e_prob = count_word_tag / count_tag
    except ZeroDivisionError:
        e_prob = 0

    return e_prob


def get_q(pp_t, p_t, curr_tag, trans_c, interpolation_weights):
    """
    Calculate q probability.
    :param pp_t: Previous of previous tag to analyze.
    :param p_t: Previous tag to analyze.
    :param curr_tag: Current tag to analyze.
    :param trans_c: Transaction counts.
    :param interpolation_weights: Interpolation weight.
    :return:
    """
    lambda_1 = interpolation_weights[0]
    lambda_2 = interpolation_weights[1]
    lambda_3 = interpolation_weights[2]
    # Calculate trigram prob = count_trigram_abc / count_bigram_ab
    key = '{} {} {}'.format(pp_t, p_t, curr_tag)
    count_trigram_abc = float(trans_c.get(key, 0))
    key = '{} {}'.format(pp_t, p_t)
    count_bigram_ab = float(trans_c.get(key, 0))
    try:
        trigram_prob = count_trigram_abc / count_bigram_ab
    except ZeroDivisionError:
        trigram_prob = 0
    # Calculate bigram prob = count_trigram_bc / count_unigram_b
    key = '{} {}'.format(p_t, curr_tag)
    count_bigram_bc = float(trans_c.get(key, 0))
    key = '{}'.format(p_t)
    count_unigram_b = float(trans_c.get(key, 0))
    try:
        bigram_prob = count_bigram_bc / count_unigram_b
    except ZeroDivisionError:
        bigram_prob = 0
    # Calculate unigram prob = count_unigram_c / num_words
    key = '{}'.format(curr_tag)
    count_unigram_c = float(trans_c.get(key, 0))
    key = '{}'.format(NUM_WORDS_SYMBOL)
    num_words = float(trans_c.get(key, 0))
    try:
        unigram_prob = count_unigram_c / num_words
    except ZeroDivisionError:
        unigram_prob = 0
    # Apply interpolation weight on the probabilities.
    interpolation = lambda_1 * trigram_prob + lambda_2 * bigram_prob + lambda_3 * unigram_prob
    return interpolation  
复制代码

MEMMTag

下面是MEMM的代码,采用LibLinear做逻辑回归(最大熵)。

def predict_viterbi(x, f_map, tags_s, word_t_map, lib_model):
    """
    For each word in vector x predict his tag. Prediction is done by viterbi algorithm. Check all tags options/globally.
    :param x: X vector of all words to be tagged.
    :param f_map: Features map.
    :param tags_s: Tags set.
    :param word_t_map: Word to tags map.
    :param lib_model: Liblinear model.
    :return: Return best predicted list of tags for each word in x respectively.
    """
    y = []
    v = [{(extract.START_SYMBOL, extract.START_SYMBOL): 0.0}]
    bp = []
    for ind, word in enumerate(x):
        # Check if word was seen in the corpus.
        if word not in word_t_map:
            is_rare = True
            available_tags = tags_s
        else:
            is_rare = False
            # Pruning of tags to lower amount of possible tags for this word.
            available_tags = word_t_map[word]

        max_score = {}
        max_tags = {}
        # Calculate for each word best scores/probabilities and best tags for each word.
        for pp_t, p_t in v[ind]:
            for curr_tag in available_tags:
                # 一个word对应的feature列表,可能的tag列表
                word_features = extract.generate_word_features(is_rare, p_t, pp_t, word, ind, x)
                features_vec = features_to_vec(word_features, f_map)
                scores = lib_model.predict(features_vec)
                score = np.amax(scores)
                if (p_t, curr_tag) not in max_score or score > max_score[(p_t, curr_tag)]:
                    max_score[(p_t, curr_tag)] = score
                    max_tags[(p_t, curr_tag)] = pp_t

        v.append(max_score)
        bp.append(max_tags)
    # Calculate last 2 best tags.
    max_score = float("-inf")
    prev_last_tag, last_tag = None, None
    for prev_t, curr_t in v[len(x)]:
        score = v[len(x)][(prev_t, curr_t)]
        if score > max_score:
            max_score = score
            last_tag = curr_t
            prev_last_tag = prev_t

    y.append(last_tag)
    if len(x) > 1:
        y.append(prev_last_tag)

    prev_t = last_tag
    prev_prev_t = prev_last_tag
    # By backtracking extract all the path of best tags for each word starting by last 2 tags we calculated above.
    for i in range(len(v) - 2, 1, -1):
        curr_t = bp[i][(prev_prev_t, prev_t)]
        y.append(curr_t)
        prev_t = prev_prev_t
        prev_prev_t = curr_t
    y = reversed(y)
    return y

复制代码

提取特征

def load_model(feature_map_fname):
    """
    Load the model from features map file.
    :param feature_map_fname: Features map file name.
    :return: Tags set, index to tag map, word to tags map and features map.
    """
    features_map = defaultdict(int)
    ind_to_tags_map = defaultdict(str)
    tags_set = set()
    word_to_tags_map = defaultdict(set)

    with open(feature_map_fname, 'r') as f:
        all_data = f.readlines()
    # Booleans to separate the reading process from the file.
    is_features_section = False
    is_words_to_tags_section = False
    is_tags_section = True
    for ind, line in enumerate(all_data):
        line = line.strip()
        if line == '^':
            is_words_to_tags_section = True
            is_tags_section = False
            continue
        if line == '^^':
            is_features_section = True
            is_words_to_tags_section = False
            continue
        if is_tags_section:
            key, value = line.split()
            tags_set.add(key)
            ind_to_tags_map[int(value)] = key
        if is_words_to_tags_section:
            key, value = line.split('^')
            word_to_tags_map[key] = value.split() # 该word可能对应的tag: 动词,名词......
        if is_features_section:
            key, value = line.split()
            features_map[key] = int(value) # load事先定义好的特征,有 string 和 int两种表示方法
    return features_map, tags_set, ind_to_tags_map, word_to_tags_map

# ExtractFeatures.py, MMEMTag.predict_viterbi会调用
def generate_word_features(is_rare, p_t, pp_t, curr_word, word_ind, words):
    """
    Generate for current word list of features as listed on table one.
    :param is_rare: Boolean that corresponds to type of the word. Rare or not.
    :param p_t: Previous tag.
    :param pp_t: Previous of previous tag.
    :param curr_word: Current word.
    :param word_ind: Current word index.
    :param words: List of all words in the sentence.
    :return: List of all word features.
    """
    word_features = []
    # Check if word is rare.
    if is_rare:
        # Generate the suffixes and prefixes depends on min of (word length or 4).
        for i in range(1, min(5, len(curr_word))):
            word_features.append('prefix' + str(i) + '=' + curr_word[:i]) # 前面的字
        for i in range(1, min(5, len(curr_word))):
            word_features.append('suffix' + str(i) + '=' + curr_word[-i:]) # 后面的字
        # Check with regex if word contains digit.
        if re.search(r'\d', curr_word):
            word_features.append('has_digit=true') # 是不是数字
        # Check with regex if word contains upper case letter.
        if re.search(r'[A-Z]', curr_word):
            word_features.append('has_upper_letter=true') # 大写开头
        # Check if word contains hyphen symbol.
        if '-' in curr_word: 
            word_features.append('has_hyphen=true') # 有破折号
    else:
        # Otherwise word is not rare and this word as feature.
        key = 'form={}'.format(curr_word) 
        word_features.append(key)
    # Generate previous tags.
    key = 'pt={}'.format(p_t)
    word_features.append(key)
    key = 'ppt={}^{}'.format(pp_t, p_t)
    word_features.append(key)
    # Generate next words and words that appeared before in the sentence.
    if word_ind > 0:
        key = 'pw={}'.format(words[word_ind - 1])
        word_features.append(key)
    if word_ind > 1:
        key = 'ppw={}'.format(words[word_ind - 2])
        word_features.append(key)
    if word_ind < len(words) - 1:
        key = 'nw={}'.format(words[word_ind + 1])
        word_features.append(key)
    if word_ind < len(words) - 2:
        key = 'nnw={}'.format(words[word_ind + 2])
        word_features.append(key)
    return word_features # word对应的,用string表示的,特征名字列表

# MEMMTag.py
def features_to_vec(word_features, f_map):
    """
    Convert word features to vector of features indexes whose liblinear model can understand.
    :param word_features: Word features to convert.
    :param f_map: Features map.
    :return: Vector of features.
    """
    features_vec = []
    for feature in word_features:
        if feature in features_map:
            features_vec.append(f_map[feature])

    features_vec = sorted(features_vec)
    return features_vec
复制代码

逻辑回归

LibLinear,这是一个简单的求解大规模规则化线性分类和回归的软件包。

  • nr_class:类的个数,如果是回归,那么这个数是2.
  • nr_feature: 训练数据的样本维数(不包括bias项)。
  • bias: 如果bias >= 0,我们会在每个样本的最后添加一个额外的特征。
  • Label: 每个类的标签,对回归来说,为空。
  • w: 一个 nr_w x n权值矩阵。n是nr_feature(特征维数)或者nr_feature+1(存在bias项时)。如果nr_class=2,并且-s不是4(不是Crammer and Singer的多类SVM),那么nr_w是1,对于其他情况,nr_w等于nr_class。
  • w 在这里就是 特征维数 x 类别数,就对应了前面提到的 "在最大熵模型的视角下,每条输入的n个特征与k个类别共同组成了nk个特征,模型中有nk个权重,与特征一一对应。每个类别会触发nk个特征中的n个"。

具体代码:

class LiblinearLogregPredictor(object):
  def __init__(self, model_file_name):
        # dict of feat-num -> numpy array, where array is the per-class values
        self.weights = {}
        with open(model_file_name) as fh:
            solver_type = fh.next().strip()
            nr_classes = fh.next().strip()
            label = fh.next().strip()
            nr_feature = fh.next().strip()
            bias = fh.next().strip()
            w = fh.next().strip()
            assert(w == "w")
            assert(nr_classes.startswith("nr_class"))
            assert(nr_feature.startswith("nr_feature"))
            assert(label.startswith("label"))
            nr_classes = int(nr_classes.split()[-1])
            for i, ws in enumerate(fh, 1):
                ws = [float(x) for x in ws.strip().split()]
                # skip unused features
                if all([x == 0 for x in ws]):
                    continue
                assert len(ws) == nr_classes, "bad weights line %s" % ws
                self.weights[i] = np.array(ws)
            self.bias = float(bias.split()[-1])
            self.labels = label.split()[1:]

    # feature_ids是int类型的特征列表,weights是权值矩阵
    def predict(self, feature_ids): 
        weights = self.weights
        scores = np.zeros(len(self.labels))
        for f in feature_ids:
            if f in weights:
                scores += weights[f]
        scores = 1/(1+np.exp(-scores))
        scores = scores/np.sum(scores)
        return scores
复制代码

0x06 水浒传的继续应用

唠叨了这么半天,总算到实例应用了。还是以"梁中书突围大名府为例"。前文参见[白话解析]以水浒传为例学习隐马尔可夫模型

隐马尔可夫模型模型比较简单,但是现实中情况可能更复杂,比如梁中书突围遇到了宋江,他再次选择从宋江处突围的可能性会变低,因为宋江身边肯定是梁山大部分好汉,突围难度太大。但是如果遇到史进,危险性就没有那么大了。

现在算法已经有了,其实就看如何构建特征函数了。从前面的代码能看出来,获取word对应的feature的操作如下

word_features = extract.generate_word_features(is_rare, p_t, pp_t, word, ind, x)
features_vec = features_to_vec(word_features, f_map)
scores = lib_model.predict(features_vec)
复制代码

我们建立各种相关信息

*********************** 特征函数
f_1 = 是不是梁山总头领
f_2 = 是不是打虎英雄
f_3 = 是不是五虎将
f_4 = 是不是八骠骑

*********************** 下一次突围的门( 对应词性标注中的tag )
t_1 = 逆时针上一门
t_2 = 当前门
t_3 = 顺时针下一门
t_3 = 对门
  
*********************** 好汉( 对应词性标注中的word ) 
h_1 = 林冲
h_2 = 武松
h_3 = 史进
h_3 = 宋江
  
*********************** 遇见好汉的序列( 对应词性标注中的句子 )  
武松,宋江,史进,林冲  
复制代码

权重矩阵 特征函数维度 x 门类别数目,这个数值属于随意炮制的

权重矩阵={12141818141218181818121418181412}权重矩阵 = \left\{ \begin{matrix} \frac12 & \frac14 & \frac18 & \frac18\\ \frac14 & \frac12 & \frac18 & \frac18\\ \frac18 & \frac18 & \frac12 & \frac14\\ \frac18 & \frac18 & \frac14 & \frac12\\ \end{matrix} \right\}

可以带入算法进行计算。

好吧,其实这次的应用说明比较少 ^_^。

作者:罗西的思考
链接:https://juejin.cn/post/6897127040194248718
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: