一、问题分析
1.CRF简述与对比
CRF分词原理
- CRF把分词当做字的词位分类问题,通常定义字的词位信息如下:
词首,常用B表示
词中,常用I表示
词尾,常用E表示
单子词,常用S表示
-
CRF分词的过程就是对词位标注后,将B和E之间的字,以及S单字构成分词
-
CRF分词实例:
原始例句:我爱北京天安门
CRF标注后:我/S 爱/S 北/B 京/E 天/B 安/M 门/E
分词结果:我/爱/北京/天安门
2.分析
分析任务,也就是把中文分词视为序列标注问题,通常称等待分词的语句输入序列(或称为观测序列);而称分词结果为输出序列(或称为标记序列、状态序列)。所以,我们的目标(中文分词)可以描述为:在给定观测序列 X下,找到概率最大的标记序列 Y。
白 B // 第一列,等待分词的输入序列;第二列,标记序列(即分词结果)
菜 E
清 B // B,表示词的开始
炖 E // M,表示词的中间
白 B // E,表示词的结束
萝 M // S, 表示单字成词
卜 E
是 S
什 B
么 E
味 B
道 E
? S
怎么解决呢?CRF的思想是:首先,假设条件分布 P ( Y ∣ X ) 可构成马尔科夫随机场(即每个状态只受相邻状态或输入序列的影响);然后,我们通过训练(统计)可以确定此分布;最后,序列标注问题(如中文分词)是在给定条件随机场 P ( Y ∣ X ) 和输入序列 X求条件概率最大的标记序列 Y ∗
而线性链CRF参数化形式可参考书中公式11.10,
其中tk 和sl 分别是转移特征、状态特征
Z(x)是规范化因子,求和是在所有可能的输出序列上进行,例如在文本分词中就是遍历Y的全排列,即每个字符有四种词性,那么一共有 4^句子长度 种可能,Z(x)求解公式参考书中公式11.11 。
可以看到,Z(x)起到的是全局归一化作用。
3. 构建特征函数
在线性链CRF参数化形式中,未知的只有特征与对应参数(或称为权重),而参数是在学习问题中关注的,所以只剩下特征了。怎样定义或构建特征呢?
我们的目标是:中文分词!所以,针对中文分词任务创建了独特的特征构造方式。首先,明确特征函数的含义:它描述是否满足指定特征,满足返回1否则返回0;再来看特征模板
举个例子。假设有如下用于分词标注的训练文件:
北 B
京 E
欢 B
迎 M
你 E
其中第2列是标签,也是测试文件中需要预测的结果,有BME 3种状态。
特征模板格式:%x[row,col]。x可取U或B,对应两种类型。方括号里的编号用于标定特征来源,row表示相对当前位置的行,0即是当前行;col对应训练文件中的列。这里只使用第1列(编号0),即文字。
1、Unigram类型
每一行模板生成一组状态特征函数,数量是L * N 个,8L是标签状态数。N是此行模板在训练集上展开后的唯一样本数,在这个例子中,第一列的唯一字数是5个,所以有L * N = 3 * 5=15。
例如:U01:%x[0,0],生成如下15个函数:
func1 = if (output = B and feature=U01:“北”) return 1 else return 0
func2 = if (output = M and feature=U01:“北”) return 1 else return 0
func3 = if (output = E and feature=U01:“北”) return 1 else return 0
func4 = if (output = B and feature=U01:“京”) return 1 else return 0
…
func13 = if (output = B and feature=U01:“你”) return 1 else return 0
func14 = if (output = M and feature=U01:“你”) return 1 else return 0
func15 = if (output = E and feature=U01:“你”) return 1 else return 0
这些函数经过训练后,其权值表示函数内文字对应该标签的概率(形象说法,概率和可大于1)。
又如 U02:%x[-1,0],训练后,该组函数权值反映了句子中上一个字对当前字的标签的影响。
2、Bigram类型
与Unigram不同的是,Bigram类型模板生成的函数会多一个参数:上个节点的标签
生成函数类似于:
func1 = if (prev_output = B and output = B and feature=B01:“北”) return 1 else return 0
这样,每行模板则会生成 L* L* N 个特征函数。经过训练后,这些函数的权值反映了上一个节点的标签对当前节点的影响。
每行模版可使用多个位置。例如:U18:%x[1,1]/%x[2,1]
字母U后面的01,02是唯一ID,并不限于数字编号。如果不关心上下文,甚至可以不要这个ID。
4.维特比算法
一、CRF基本概念
这里的label_alphabet中的b代表一个实体的开始,即begin;m代表一个实体的中部,即mid;e代表一个实体的结尾,即end;o代表不是实体,即None;和分表代表这个标注label序列的开始和结束,类似于机器翻译的和。
这个就是word和label数字化后变成word_index,label_index。最终就变成下面的形式:
因为label有7种,每一个字被预测的label就有7种可能,为了数字化这些可能,我们从word_index到label_index设置一种分数,叫做发射分数emit:
看这个图,有word_index 的 1 -> 到label_index 的 4的小红箭头,此时的分数就记作emit [1] [4]。
另外,我们想想,如果单单就这个发射分数来评价,太过于单一了,因为这个是一个序列,比如前面的label为o,那此时的label被预测的肯定不能是m或s,所以这个时候就需要一个分数代表前一个label到此时label的分数,我们叫这个为转移分数,即T:
如图,横向的label到label箭头,就是由一个label到另一个label转移的意思,此时的分数为T[4] [4]。
此时我们得出此时的word_index=1到label_index=4的分数为emit[1] [4]+T[4] [4]。但是,CRF为了全局考虑,将前一个的分数也累加到当前分数上,这样更能表达出已经预测的序列的整体分数,最终的得分score为:
score[1] [4] = score[0] [4]+emit[1] [4]+T[4] [4]
所以整体的score就为:
最后的公式为这样的:
其中X为word_index序列,y为预测的label_index序列。
二、计算过程
就是为了求得所有预测序列可能的得分和。我们第一种想法就是每一种可能都求出来,然后累加即可。可是,比如word长为10,那么总共需要计算累加10^7次,这样的计算太耗时间了。那么怎么计算的时间快呢?这里有一种方法:
因为刚开始为即为5,然后word_index为0的时候的所有可能的得分,即s[0][0],s[0][1]…s[0][6]中间的那部分。然后计算word_index为1的所有s[1][0],s[1][1]…s[1][6]的得分,这里以s[1][0]为例,即红箭头的焦点处:这里表示所有路径到这里的得分总和。
这里每个节点,都表示前面的所有路径到当前节点路径的所有得分之和。所以,最后的s[4] [6]即为最终的得分之和,即:
计算gold分数,即:S(X,y) 这事只要通过此时的T和emit函数计算就能得出,计算公式上面已经给出了:
然后就是重复上述的求解所有路径的过程,将总和和gold的得分都求出来,得到loss,然后进行更新T,emit即可。(实现的话,其实emit是隐层输出,不是更新的对象,之后的实现会讲)
解码过程,就是动态规划,但是在这种模型中,通常叫做维特比算法。如图:
大概思路就是这次的每个节点不是求和,而是求max值和记录此max的位置。就是这样:
最后每个节点都求了出来,结果为:
最后,根据最后的节点,向前选取最佳的路径。过程为:、
二、算法实现
首先定义CRF类:
class myCRF:
def __init__(self):
self.scoreMap = {} #分数表
self.UnigramTemplates = [] #状态特征模板
self.BigramTemplates = [] #转移特征模板
self.readTemplate() #读取特征模板
def readTemplate(self,debug=False):
'''
读取特征模板
:return:
'''
tempFile = open("../dataset/dataset2/template.utf8", encoding='utf-8')
switchFlag = False # 先读Unigram,在读Bigram
for line in tempFile:
tmpList = []
if line.find("Unigram") > 0 or line.find("Bigram") > 0: # 读到'Unigram'或者'Bigram'
continue
if switchFlag:
if line.find("/") > 0:
left = line.split("/")[0].split("[")[-1].split(",")[0]
right = line.split("/")[-1].split("[")[-1].split(",")[0]
tmpList.append(int(left))
tmpList.append(int(right))
else:
num = line.split("[")[-1].split(",")[0]
tmpList.append(int(num))
self.BigramTemplates.append(tmpList)
else:
if len(line.strip()) == 0:
switchFlag = True
else:
if line.find("/") > 0:
left = line.split("/")[0].split("[")[-1].split(",")[0]
right = line.split("/")[-1].split("[")[-1].split(",")[0]
tmpList.append(int(left))
tmpList.append(int(right))
else:
num = line.split("[")[-1].split(",")[0]
tmpList.append(int(num))
self.UnigramTemplates.append(tmpList)
if (debug == True):
print(self.UnigramTemplates)
print(self.BigramTemplates)
读取到的模板如下:
[[-2], [-1], [0], [1], [2], [-2, -1], [-1, 0], [-1, 1], [0, 1], [1, 2]]
[[-2], [-1], [0], [1], [2], [-2, -1], [-1, 0], [-1, 1], [0, 1], [1, 2]]
接下来读取数据集
def getTrainData(self):
sentences = []
results = []
tempFile = open('../dataset/dataset2/train.utf8', encoding='utf-8')
sentence = ""
result = ""
for line in tempFile:
line = line.strip()
if line == "":
if sentence == "" or result == "":
pass
else:
sentences.append(sentence)
results.append(result)
sentence = ""
result = ""
else:
data = line.split(" ")
sentence += data[0]
result += data[1]
return [sentences, results]
定义一些辅助函数:
def getUnigramScore(self, sentence, thisPos, thisStatus):
'''
获得给定词和标志的状态特征分数和
:param sentence: 句子
:param thisPos: 当前位置
:param thisStatus: 当前标志
:return: 得分
'''
unigramScore = 0
unigramTemplates = self.UnigramTemplates
for i in range(0, len(unigramTemplates)):
key = self.makeKey(unigramTemplates[i], str(i), sentence, thisPos, thisStatus)
if key in self.scoreMap:
#这里为了加快运算,将对应的一组分数相加求和
unigramScore += self.scoreMap[key]
return unigramScore
def getBigramScore(self, sentence, thisPos, preStatus, thisStatus):
'''
获得给定词和标志的转移特征分数和
:param sentence: 句子
:param thisPos: 当前位置
:param preStatus: 上一个特征
:param thisStatus: 当前特征
:return: 得分
'''
bigramScore = 0
bigramTemplates = self.BigramTemplates
for i in range(0, len(bigramTemplates)):
key = self.makeKey(bigramTemplates[i], str(i), sentence, thisPos, preStatus + thisStatus)
if key in self.scoreMap:
bigramScore += self.scoreMap[key]
return bigramScore
def num2Tag(self, number):
'''
将数字转为对应标志
:param number: 数字
:return: 标志
'''
if number == 0:
return "B"
elif number == 1:
return "I"
elif number == 2:
return "E"
elif number == 3:
return "S"
else:
return None
def tag2Num(self, status):
'''
将标志转为对应数字
:param status: 标志
:return: 数字
'''
if status == "B":
return 0
elif status == "I":
return 1
elif status == "E":
return 2
elif status == "S":
return 3
else:
return -1
def getMaxIndex(self, list):
origin = list.copy()
origin.sort()
max = origin[-1]
index = list.index(max)
return index
# 状态序列里,正确的状态的个数
def getDuplicate(self, s1, s2):
length = min(len(s1), len(s2))
count = 0
for i in range(0, length):
if s1[i] == s2[i]:
count += 1
return count
定义模板标注函数:给定一个字符和一个标志,输出关于这个字符和标志扩展出的特征
def makeKey(self, template, identity, sentence, pos, statusCovered,debug=False):
'''
找出一句句子中,给定的模板下的某字符某标志相关的特征(BIES)
:param template: 给定特征模板
:param identity: 模板序号
:param sentence: 标注句子
:param pos: 当点位置
:param statusCovered: 状态标注
:param debug: 调试用
:return: 标注结果
'''
result = ""
result += identity
for i in template:
index = pos + i
if index < 0 or index >= len(sentence):
result += " "
else:
result += sentence[index]
result += "/"
result += statusCovered
if (debug==True):
print(result)
return result
标注结果:
0产/E
1品/E
2开/E
3发/E
4服/E
5产品/E
6品开/E
7品发/E
8开发/E
9发服/E
定义计算错误数目的函数:给定程序输出解和正确解,比对后统计错的标志数
def getWrongNum(self, sentence, realRes):
'''
计算正确率
:param sentence: 句子
:param realRes: 正确解
:return: 错误个数
'''
myRes = self.Viterbi(sentence) # 我的解
lens = len(sentence)
wrongNum = 0
for i in range(0, lens):
myResI = myRes[i] # 我的解
theoryResI = realRes[i] # 理论解
if myResI != theoryResI:
wrongNum += 1
return wrongNum
下面介绍核心函数
def setScoreMap(self, sentence, realRes,debug =False):
'''
建立状态特征和转移特征的特征矩阵,并依据结果为每个元素打分
:param sentence: 句子
:param realRes: 正确解
:param debug: 调试用
:return:
'''
myRes = self.Viterbi(sentence) # 我的解
for word in range(0, len(sentence)):
myResI = myRes[word] # 我的解
theoryResI = realRes[word] # 理论解
if myResI != theoryResI: # 如果和理论值不同
# print("Unigram更新开始")
uniTem = self.UnigramTemplates
for uniIndex in range(0, len(uniTem)):
if debug == True:
print(uniTem[uniIndex])
print(str(uniIndex))
print(sentence)
print(myResI)
uniMyKey = self.makeKey(uniTem[uniIndex], str(uniIndex), sentence, word, myResI) # 我的状态特征标注
if uniMyKey not in self.scoreMap:
self.scoreMap[uniMyKey] = -1
else:
self.scoreMap[uniMyKey] = self.scoreMap[uniMyKey] - 1
# 正确的状态特征标注
uniTheoryKey = self.makeKey(uniTem[uniIndex], str(uniIndex), sentence, word, theoryResI)
if uniTheoryKey not in self.scoreMap:
self.scoreMap[uniTheoryKey] = 1
else:
self.scoreMap[uniTheoryKey] = self.scoreMap[uniTheoryKey] + 1
# print("Bigram更新开始")
biTem = self.BigramTemplates
for biIndex in range(0, len(biTem)):
if word == 0:
# 我的转移特征标注,第一个为’ B‘(’ I‘,’ S‘,’ E‘)
biMyKey = self.makeKey(biTem[biIndex], str(biIndex), sentence, word, " " + str(myResI))
# 正确的转移特征标注
biTheoryKey = self.makeKey(biTem[biIndex], str(biIndex), sentence, word, " " + str(theoryResI))
else:
# 我的转移特征标注
biMyKey = self.makeKey(biTem[biIndex], str(biIndex), sentence, word, myRes[word - 1:word + 1:])
# 正确的转移特征标注
biTheoryKey = self.makeKey(biTem[biIndex], str(biIndex), sentence, word, myRes[word - 1:word + 1:])
if biMyKey not in self.scoreMap:
self.scoreMap[biMyKey] = -1
else:
self.scoreMap[biMyKey] = self.scoreMap[biMyKey] - 1
if biTheoryKey not in self.scoreMap:
self.scoreMap[biTheoryKey] = 1
else:
self.scoreMap[biTheoryKey] = self.scoreMap[biTheoryKey] + 1
流程如下:
对于状态特征来说有
对于转移特征有:
假设句子为我爱北京天安门,正确标注为SSBEBIE,算法得到标注为BSESSBE,算法得到标注展开得到右侧特征矩阵,同理按照正确标注也会得到相应的矩阵。得到句子扩展出的矩阵后,如果算法得出标注与实际标注不符,那么就让算法的出的矩阵对应标注的特征值得分减1,如果符合,就对相应特征加一。
这样,在所有句子扩展出的特征打分完成后,对分词有正面影响的特征分数高,对分词有负面影响的分词得分低。
接下来,类似HMM,使用维特比算法寻找最优路径
def Viterbi(self, sentence):
'''
结合scoremap使用维特比算法,先找到局部最优,记录节点,最后回溯得到路径。
:param sentence: 句子
:return: 路径
'''
lens = len(sentence)
statusFrom = [[""] * lens, [""] * lens, [""] * lens, [""] * lens] # B,I,E,S
maxScore = [[0] * lens, [0] * lens, [0] * lens, [0] * lens] # 4条路
for word in range(0, lens):
for stateNum in range(0, 4):
thisStatus = self.num2Tag(stateNum)
# 第一个词,状态特征加转移特征
if word == 0:
uniScore = self.getUnigramScore(sentence, 0, thisStatus)
biScore = self.getBigramScore(sentence, 0, " ", thisStatus)
maxScore[stateNum][0] = uniScore + biScore
statusFrom[stateNum][0] = None
else:
#前面的所有路径到当前节点路径的所有得分之和
scores = [0] * 4
for i in range(0, 4):
preStatus = self.num2Tag(i) #记录前一节点
transScore = maxScore[i][word - 1] #到前一节点的路径和
uniScore = self.getUnigramScore(sentence, word, thisStatus) #状态特征分数
biScore = self.getBigramScore(sentence, word, preStatus, thisStatus) #转移特征分数
scores[i] = transScore + uniScore + biScore #当前节点分数
maxIndex = self.getMaxIndex(scores) #找到最大分数
maxScore[stateNum][word] = scores[maxIndex] #最大分数记录
statusFrom[stateNum][word] = self.num2Tag(maxIndex) #最大分数对应节点记录
resBuf = [""] * lens
scoreBuf = [0] * 4
if lens > 0:
for i in range(0, 4):
scoreBuf[i] = maxScore[i][lens - 1] #最后一个字的各个标志最大分数
resBuf[lens - 1] = self.num2Tag(self.getMaxIndex(scoreBuf)) #最后一个字最大分数对应标志
for backIndex in range(lens - 2, -1, -1):
resBuf[backIndex] = statusFrom[self.tag2Num(resBuf[backIndex + 1])][backIndex + 1] #回溯路径
res = "".join(resBuf) #输出路径
return res
下面定义训练和预测函数:
def myTrain(self,epochnum =3):
sentences, results = self.getTrainData() #读取数据集
whole = len(sentences) #句子数量
trainNum = int(whole * 0.8) #选前80%句子作为训练集
for epoch in range(1, epochnum): #训练次数
wrongNum = 0
totalTest = 0 #记录字符数
for i in range(0, trainNum):
sentence = sentences[i]
totalTest += len(sentence)
result = results[i]
self.setScoreMap(sentence, result) # 训练的关键,计算scoreMap
wrongNum += self.getWrongNum(sentence, result) #计算错误的点数
correctNum = totalTest - wrongNum #正确点数
print("epoch" + str(epoch) + ":准确率" + str(float(correctNum / totalTest))) #计算正确率
total = 0
correct = 0
# 测试集为后20%
for i in range(trainNum, whole):
sentence = sentences[i]
total += len(sentence)
result = results[i]
myRes = self.Viterbi(sentence)
correct += self.getDuplicate(result, myRes)
accuracy = float(correct / total) #计算测试集正确率
print("accuracy" + str(accuracy))
torch.save(
{
'scoreMap': self.scoreMap,
'BigramTemplates': self.BigramTemplates,
'UnigramTemplates': self.UnigramTemplates
},
"../savemodel/CRF-dataSet.model"
)
def predict(self, sentence, parameter):
'''
”解码“函数
:param sentence: 句子
:param parameter: 参数
:return:
'''
global count
retsentence = []
state = []
count += 1
# print(count)
self.scoreMap = parameter['scoreMap']
self.UnigramTemplates = parameter['UnigramTemplates']
self.BigramTemplates = parameter['BigramTemplates']
retsentence = self.Viterbi(sentence)
if retsentence[-1] == 'B' or retsentence[-1] == 'I': # 最后一个字状态不是'S'或'E'则修改
if retsentence[-2] == 'B' or retsentence[-2] == 'I':
retsentence[-1] = 'E'
else:
retsentence[-1] = 'S'
# 开始对该行分词
curLine = ''
# 遍历该行每一个字
for i in range(len(sentence)):
# 在列表中放入该字
curLine += sentence[i]
# 如果该字是S->单个词 或 E->结尾词 ,则在该字后面加上分隔符 |
# 此外如果改行的最后一个字了,也就不需要加 |
if (retsentence[i] == 'S' or retsentence[i] == 'E') and i != (len(sentence) - 1):
curLine += '|'
# 在返回列表中添加分词后的该行
state.append(curLine)
return state
if __name__ == '__main__':
model = myCRF()
#model.myTrain(epochnum=10)
parameter = torch.load("../savemodel/CRF-dataSet.model")
print(model.predict("用条件随机场进行文本分词",parameter))
三、结果展示
改革开放是决定当代中国前途命运的关键一招,中国大踏步赶上了时代!
['改革|开放|是|决定|当代|中国|前|途命运|的|关键|一招|,|中国|大|踏步|赶上|了|时代|!']
中华民族迎来了从站起来、富起来到强起来的伟大飞跃,实现中华民族伟大复兴进入了不可逆转的历史进程!
['中华民族|迎来|了|从|站|起来|、|富|起来到|强|起来|的|伟大|飞跃|,|实现|中华民族|伟大|复兴|进入|了|不可逆转|的|历史|进程|!']
四、总结
与HMM相比,因CRF可以结合前后文来进行分词,结果普遍要比HMM效果好,但是换来的是更多的特征需要计算,使训练的效率严重低于HMM。
1.模型
CRF条件随机场需要训练两个参数,即状态特征参数sl和转移特征参数tk。在训练过程中,不断调整两个特征的得分来找到最优的参数。
2.算法
CRF使用维特比算法来寻找最优路径,使用迭代尺度法求模型参数
3.策略
优化特征参数矩阵,与正确解相同加分,不同减分。
五、源码
源码发在博客上了