原理
请参考 《自然语言处理入门》 作者何晗 第129页
代码
隐式马尔可夫模型可由
λ
=
(
π
,
A
,
B
)
\lambda=(\pi, A,B)
λ=(π,A,B) 完全描述。
代码首先实现了接口HMM
,该接口定义了隐式马尔可夫模型的一些公共操作。
其次定义了专门用于文本序列标注的隐式马尔可夫模型 TextSequence
, 实现了对文本的操作。
最后实现了维特比算法(viterbi
)对隐式马尔可夫模型进行解码,根据输入序列预测状态序列。
TextSequence 可用于分词、实体识别、事件抽取等自然语言处理任务。
from typing import *
import numpy as np
class HMM():
def __init__(self, N: int, M: int):
self.N = N
self.M = M
self.PI = np.zeros((N, ), dtype=np.float)
self.A = np.zeros((N, N), dtype=np.float)
self.B = np.zeros((N, M), dtype=np.float)
def getPI(self) -> np.ndarray:
""" 获取初始状态概率 R ^ N"""
raise NotImplementedError()
def getA(self) -> np.ndarray:
""" 获取状态转移概率 R ^ (N * N)"""
raise NotImplementedError()
def getB(self) -> np.ndarray:
""" 获取发射概率 R ^ (N * M) """
raise NotImplementedError()
class TextSequence(HMM):
def __init__(self, texts: List[List[str]], labels: List[List[int]], nClasses:int):
self.texts = texts
self.labels = labels
# 构造词典
dictSet = {"[OOV]": 0}
dictArray = ["[OOV]"]
for seq in texts:
for word in seq:
w = dictSet.get(word)
if w == None: # 如果当前词不在词典之中
dictSet[word] = len(dictArray)
dictArray.append(word)
self.dict = (dictSet)
self.dictArray = (dictArray)
# 构造矩阵
HMM.__init__(self, nClasses, len(dictArray))
def getIndex(self, text :List[str]) -> List[int]:
"""把序列转换成索引"""
indexes = []
for word in text:
index = self.dict.get(word)
if(index == None):
indexes.append(0)
else:
indexes.append(index)
return indexes
def getPI(self) -> np.ndarray:
self.PI.fill(0)
for labelSeq in self.labels:
if(len(labelSeq) > 0):
self.PI[labelSeq[0]] += 1
self.PI /= self.PI.sum()
return self.PI
def getA(self) -> np.ndarray:
self.A.fill(0)
for labelSeq in self.labels:
if(len(labelSeq) > 1):
for i in range(1, len(labelSeq)):
labelPre = labelSeq[i - 1]
lableCur = labelSeq[i]
self.A[labelPre, lableCur] += 1
#对每行进行归一化
for i in range(self.N):
maxValue = self.A[i,].sum()
if(maxValue == 0):
maxValue = 1
self.A[i, ] /= maxValue
return self.A
def getB(self) -> np.ndarray:
self.B.fill(0)
if(len(self.texts) != len(self.labels)):
raise RuntimeError("输入句子序列长度与输入标签序列长度不一致")
for i in range(len(self.texts)):
if (len(self.texts[i]) != len(self.labels[i])):
raise RuntimeError("输入句子长度与标签长度不一致")
for j in range(len(self.texts[i])) :
state = self.labels[i][j]
word = self.texts[i][j]
wordIndex = self.dict[word]
self.B[state, wordIndex] += 1
for i in range(self.N):
maxValue = self.B[i, ].sum()
if(maxValue == 0):
maxValue = 1
self.B[i, ] /= maxValue
return self.B
def viterbi(hmm:HMM, sequence: List[int]) -> List[int]:
""" 把每个状态当成一个结点,结点的权重就是发射概率,结点的边就是转移概率,
目标是找出具有最大概率的状态序列。
我们可以使用动态规划的思想求解,即该问题具有最优子解构:
如果长度为N的状态序列y1y2...yn是最优的,那么长度为N-1的状态序列y1y2..y(n-1)也必然是最优的!
:param hmm 隐式马尔可夫对象
:param sequence 输入序列
:return 状态序列
"""
# 处理特殊情况
if(hmm == None or len(sequence) == 0):
return []
# 算法需要的数据结构
scores = np.zeros(hmm.N, dtype=float)
path = np.zeros((len(sequence), hmm.N), dtype=np.int)
PI = hmm.getPI()
A = hmm.getA()
B = hmm.getB()
# 算法开始
for i in range(len(scores)):
scores[i] = PI[i] * B[i, sequence[0]]
for i in range(1, len(sequence)):
scoresTemp = np.zeros(scores.shape)
for s2 in range(hmm.N): #后一个状态
for s1 in range(hmm.N): #前一个状态
scoreCur = scores[s1] * A[s1, s2] * B[s2, sequence[i]]
if(scoreCur >= scoresTemp[s2]):
scoresTemp[s2] = scoreCur
path[i, s2] = s1
scores = scoresTemp
# 解最优路径
bestEndStateIndex = scores.argmax()
bestPath = [bestEndStateIndex]
for i in range(1, len(sequence)):
lastState = path[len(sequence) - i][bestEndStateIndex]
bestPath.append(lastState)
bestEndStateIndex = lastState
bestPath.reverse()
return bestPath
测试
以下代码对句子中的英文和数字进行了自动标注从而生成训练样本 (x, y) 输入到HMM模型中。详情见代码中的注释。
from HMM import HMM, TextSequence, viterbi
def is_alphabet(uchar):
"""判断一个unicode是否是英文字母"""
if (uchar >= u'\u0041' and uchar <= u'\u005a') or (uchar >= u'\u0061' and uchar <= u'\u007a'):
return True
else:
return False
def is_number(uchar):
"""判断一个unicode是否是数字"""
if uchar >= u'\u0030' and uchar <= u'\u0039':
return True
else:
return False
if __name__ == '__main__':
x = [
list("维特比算法由安德鲁·维特比(AndrewViterbi)于1967年提出"),
list("用于在数字通信链路中解卷积以消除噪音"),
list("此算法被广泛应用于CDMA和GSM数字蜂窝网络"),
list("拨号调制解调器、卫星、深空通信和802"),
list("现今也被常常用于dsafdsaf语音识别、关键字识别、计算语言学和生物信息学中"),
list("例如在语音(语音识别)"),
list("中,声音信号作为456观察到的事件序列"),
list("而文本字符串,被看作是asbs隐含的产生声音信号的原因"),
list("因此可对声音AAAAA信号应用维特比算法寻找最有可能的文本字符串"),
]
y = []
"""
使用BIO标注法对数字英文数据标注
0 = O 表示非数字英文
1 = B 表示数字英文的开始
2 = I 表示数字英文的中间部分
"""
for i in range(len(x)):
temp = []
isBegin = True
for j in range(len(x[i])):
if is_alphabet(x[i][j]) or is_number(x[i][j]):
if(isBegin):
temp.append(1)
else:
temp.append(2)
isBegin = False
else:
temp.append(0)
isBegin = True
y.append(temp)
print("序列标签为:")
print(y)
### 测试算法
hmm = TextSequence(x, y, 3)
states = viterbi(hmm, hmm.getIndex(list("法由安德鲁·维特比(Andreterbi)于1967年提出")))
print("预测结果为:")
print(states)
可以发现,算法预测的序列标签结果与实际是一致的。这里直接利用x中的数据进行测试了,是因为数据集样本数量太少,如果使用其它文本,则会有太多OOV(out of vocabulary) 从而导致预测不准确。
下面是我后来在真实数据集上跑的例子
首先原HMM代码需要进行小量修改:
- 对A、B、 π \pi π统计完之后进行加1平滑防止零概率的情况
- 进行viterbi算法解码时,使用对数化后的值进行比较,以防止小概率连乘导致数值计算越界
修改后的代码如下:
from typing import *
import numpy as np
class HMM():
def __init__(self, N: int, M: int):
self.N = N
self.M = M
self.PI = np.ones((N, ), dtype=np.float) #加1平滑
self.A = np.ones((N, N), dtype=np.float) #加1平滑
self.B = np.ones((N, M), dtype=np.float) #加1平滑
def getPI(self) -> np.ndarray:
""" 获取初始状态概率 R ^ N"""
raise NotImplementedError()
def getA(self) -> np.ndarray:
""" 获取状态转移概率 R ^ (N * N)"""
raise NotImplementedError()
def getB(self) -> np.ndarray:
""" 获取发射概率 R ^ (N * M) """
raise NotImplementedError()
class TextSequence(HMM):
def __init__(self, texts: List[List[str]], labels: List[List[int]], nClasses:int):
self.texts = texts
self.labels = labels
# 构造词典
dictSet = {"[OOV]": 0}
dictArray = ["[OOV]"]
for seq in texts:
for word in seq:
w = dictSet.get(word)
if w == None: # 如果当前词不在词典之中
dictSet[word] = len(dictArray)
dictArray.append(word)
self.dict = (dictSet)
self.dictArray = (dictArray)
# 构造矩阵
HMM.__init__(self, nClasses, len(dictArray))
def getIndex(self, text :List[str]) -> List[int]:
"""把序列转换成索引"""
indexes = []
for word in text:
index = self.dict.get(word)
if(index == None):
indexes.append(0)
else:
indexes.append(index)
return indexes
def getPI(self) -> np.ndarray:
self.PI.fill(1)
for labelSeq in self.labels:
if(len(labelSeq) > 0):
self.PI[labelSeq[0]] += 1
self.PI /= self.PI.sum()
return self.PI
def getA(self) -> np.ndarray:
self.A.fill(1)
for labelSeq in self.labels:
if(len(labelSeq) > 1):
for i in range(1, len(labelSeq)):
labelPre = labelSeq[i - 1]
lableCur = labelSeq[i]
self.A[labelPre, lableCur] += 1
#对每行进行归一化
for i in range(self.N):
maxValue = self.A[i,].sum()
if(maxValue == 0):
maxValue = 1
self.A[i, ] /= maxValue
return self.A
def getB(self) -> np.ndarray:
self.B.fill(1)
if(len(self.texts) != len(self.labels)):
raise RuntimeError("输入句子序列长度与输入标签序列长度不一致")
for i in range(len(self.texts)):
if (len(self.texts[i]) != len(self.labels[i])):
raise RuntimeError("输入句子长度与标签长度不一致")
for j in range(len(self.texts[i])) :
state = self.labels[i][j]
word = self.texts[i][j]
wordIndex = self.dict[word]
self.B[state, wordIndex] += 1
for i in range(self.N):
maxValue = self.B[i, ].sum()
if(maxValue == 0):
maxValue = 1
self.B[i, ] /= maxValue
return self.B
def viterbi(hmm:HMM, sequence: List[int]) -> List[int]:
""" 把每个状态当成一个结点,结点的权重就是发射概率,结点的边就是转移概率,
目标是找出具有最大概率的状态序列。
我们可以使用动态规划的思想求解,即该问题具有最优子解构:
如果长度为N的状态序列y1y2...yn是最优的,那么长度为N-1的状态序列y1y2..y(n-1)也必然是最优的!
:param hmm 隐式马尔可夫对象
:param sequence 输入序列
:return 状态序列
"""
# 处理特殊情况
if(hmm == None or len(sequence) == 0):
return []
# 算法需要的数据结构
scores = np.zeros(hmm.N, dtype=float)
path = np.zeros((len(sequence), hmm.N), dtype=np.int)
PI = hmm.getPI()
A = hmm.getA()
B = hmm.getB()
# 算法开始
# 使用对数,防止连乘越界
for i in range(len(scores)):
scores[i] = np.log(PI[i]) + np.log(B[i, sequence[0]])
for i in range(1, len(sequence)):
scoresTemp = np.ones(scores.shape) * (- np.inf) #初始化为负无穷
for s2 in range(hmm.N): #后一个状态
for s1 in range(hmm.N): #前一个状态
scoreCur = scores[s1] + np.log(A[s1, s2]) + np.log(B[s2, sequence[i]])
if(scoreCur >= scoresTemp[s2]):
scoresTemp[s2] = scoreCur
path[i, s2] = s1
scores = scoresTemp
# 解最优路径
bestEndStateIndex = scores.argmax()
bestPath = [bestEndStateIndex]
for i in range(1, len(sequence)):
lastState = path[len(sequence) - i][bestEndStateIndex]
bestPath.append(lastState)
bestEndStateIndex = lastState
bestPath.reverse()
return bestPath
在真实中文词性标注语料上的测试结果: