CRF条件随机场进行中文文本分词

一、问题分析

1.CRF简述与对比

CRF分词原理

  1. CRF把分词当做字的词位分类问题,通常定义字的词位信息如下:

词首,常用B表示
词中,常用I表示
词尾,常用E表示
单子词,常用S表示

  1. CRF分词的过程就是对词位标注后,将B和E之间的字,以及S单字构成分词

  2. 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基本概念

img

这里的label_alphabet中的b代表一个实体的开始,即begin;m代表一个实体的中部,即mid;e代表一个实体的结尾,即end;o代表不是实体,即None;和分表代表这个标注label序列的开始和结束,类似于机器翻译的和。

img

这个就是word和label数字化后变成word_index,label_index。最终就变成下面的形式:

img

因为label有7种,每一个字被预测的label就有7种可能,为了数字化这些可能,我们从word_index到label_index设置一种分数,叫做发射分数emit:

img

看这个图,有word_index 的 1 -> 到label_index 的 4的小红箭头,此时的分数就记作emit [1] [4]。

另外,我们想想,如果单单就这个发射分数来评价,太过于单一了,因为这个是一个序列,比如前面的label为o,那此时的label被预测的肯定不能是m或s,所以这个时候就需要一个分数代表前一个label到此时label的分数,我们叫这个为转移分数,即T:

img

如图,横向的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就为:

img

最后的公式为这样的:

img

其中X为word_index序列,y为预测的label_index序列。

二、计算过程

就是为了求得所有预测序列可能的得分和。我们第一种想法就是每一种可能都求出来,然后累加即可。可是,比如word长为10,那么总共需要计算累加10^7次,这样的计算太耗时间了。那么怎么计算的时间快呢?这里有一种方法:

img

因为刚开始为即为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]为例,即红箭头的焦点处:这里表示所有路径到这里的得分总和。

img

这里每个节点,都表示前面的所有路径到当前节点路径的所有得分之和。所以,最后的s[4] [6]即为最终的得分之和,即:

img

计算gold分数,即:S(X,y) 这事只要通过此时的T和emit函数计算就能得出,计算公式上面已经给出了:

img

然后就是重复上述的求解所有路径的过程,将总和和gold的得分都求出来,得到loss,然后进行更新T,emit即可。(实现的话,其实emit是隐层输出,不是更新的对象,之后的实现会讲)

解码过程,就是动态规划,但是在这种模型中,通常叫做维特比算法。如图:

img

大概思路就是这次的每个节点不是求和,而是求max值和记录此max的位置。就是这样:

img

最后每个节点都求了出来,结果为:

img

最后,根据最后的节点,向前选取最佳的路径。过程为:、

img

二、算法实现

首先定义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.策略

优化特征参数矩阵,与正确解相同加分,不同减分。

五、源码

源码发在博客上了

  • 4
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值