一、马尔可夫随机场
1.1 概率图模型
什么是有向图模型和无向图模型?
https://www.jianshu.com/p/dabbc78471d7 团、极大团、最大团 - 简书 (jianshu.com)
1.2 马尔可夫随机场
二、条件随机场概述
2.1 条件随机场简介
条件随机场(Conditional Random Field,简称 CRF)是一种用于序列标注(sequence labeling)的概率模型。它是马尔可夫随机场(Markov Random Field,简称 MRF)的一种扩展,可以用无向图来表示输入序列和输出序列之间的条件依赖关系。条件随机场的每个节点对应一个输出标签,每条无向边表示两个相邻标签之间的相关性,每个节点的概率分布由输入序列和相邻节点的取值共同决定。
条件随机场的主要优点是:
它可以利用丰富的特征来描述输入序列和输出序列之间的复杂关系,而不受马尔可夫假设的限制。
它可以在全局范围内对输出序列进行建模,而不是局部地对每个输出标签进行建模,从而避免了标注偏置(Label Bias)问题。
它可以通过无向图来表示输出序列的依赖结构,而不是有向图,从而避免了循环依赖(Cyclic Dependency)问题。
条件随机场的主要缺点是:
它的训练和预测过程都比较耗时,特别是当特征的个数和标签的个数很大时,计算归一化因子和最优序列的代价很高。
它的特征选择和权重调整都需要人工干预,没有自动化的方法来确定最优的特征组合和权重分配。
它的模型参数和特征函数都是固定的,不能随着数据的变化而自适应地更新和调整。
2.2 线性链条件随机场(LCCRF)
线性链条件随机场的每个节点对应一个输出标签,每条无向边表示两个相邻标签之间的相关性,每个节点的概率分布由输入序列和相邻节点的取值共同决定。
要建立条件随机场,需要定义特征函数集,因为特征函数是条件随机场的核心组成部分,它们用于描述输入序列和输出序列之间的关系,从而决定了条件随机场的概率模型和图结构。
特征函数集可以分为两类:转移特征函数和状态特征函数。转移特征函数表示两个相邻标签之间的特征,状态特征函数表示一个标签的特征。特征函数可以包含输入序列和位置的信息,也可以包含其他的信息,如词性、词典、语法等。特征函数的选择取决于具体的应用场景和数据特点,一般需要根据经验和实验来确定。
特征函数集的大小和多样性影响了条件随机场的模型复杂度和表达能力,一般来说,特征函数越多越丰富,条件随机场的性能越好,但也会增加计算的代价和过拟合的风险。因此,特征函数集的定义需要平衡特征的有效性和效率,以达到最佳的效果。
模型训练完成后,每一个特征函数有一个权重,这个权重表示该特征函数对标注序列的评分和概率的贡献程度。特征函数的权重可以通过最大似然估计(Maximum Likelihood Estimation,简称 MLE)或最大后验估计(Maximum A Posteriori,简称 MAP)来求解,这两种方法都是基于训练数据来优化对数似然函数或对数后验概率的方法,可以用梯度下降(Gradient Descent)或拟牛顿法(Quasi-Newton Method)等优化算法来实现。
三、推断算法
四、训练算法
采用最大似然估计训练条件随机场
最大似然估计L-BFGS求解
采用最大后验估计训练条件随机场
五、应用场景
六、示例:NER-命名实体识别
这是一个基于条件随机场(CRF)的中文命名实体识别代码实现。主要步骤如下:
CorpusProcess类:
实现了语料的预处理,包括全半角转换、合并分词、转换词性标签等
初始化了字序列、词性序列、标签序列
提取特征
CRF_NER类:
初始化CRF模型的参数
训练模型:使用训练集训练CRF模型
预测:加载模型,对输入句子进行词性标注和命名实体识别
主要依赖库:
sklearn_crfsuite:条件随机场模型
joblib:模型保存和加载
训练过程:
使用人民日报1998年语料进行训练
训练好的模型保存为model.pkl
使用方式:
直接加载模型预测,无需重新训练
输入汉字句子,输出识别的命名实体
评价:
实现了中文命名实体识别的条件随机场模型
语料预处理和特征提取设计合理
模型训练和预测流程清晰
对中文分词、词性标注和命名实体识别任务提供了很好的参考
综上所述,这是一份比较完整和典型的基于CRF的中文NER实现,内容充实,代码结构清晰,可以很好地帮助理解CRF在NER任务中的应用和实现过程。
输出结果:
源码
import re # 导入正则表达式模块
import sklearn_crfsuite # 导入条件随机场模块
from sklearn_crfsuite import metrics # 导入评估指标模块
import joblib # 导入模型保存和加载模块
class CorpusProcess(object): # 定义一个语料处理类
def __init__(self): # 初始化方法
"""初始化"""
self.train_corpus_path ="1980_01.txt" # 训练语料的路径
self.process_corpus_path ="result-rmrb.txt" # 处理后的语料的路径
self._maps = {u't': u'T', u'nr': u'PER', u'ns': u'ORG', u'nt': u'LOC'} # 词性标注和实体标注的映射关系
def read_corpus_from_file(self, file_path): # 定义一个从文件中读取语料的方法
"""读取语料"""
f = open(file_path, 'r', encoding='utf-8') # 以只读模式打开文件
lines = f.readlines() # 读取所有行
f.close() # 关闭文件
return lines # 返回读取的内容
def write_corpus_to_file(self, data, file_path): # 定义一个将语料写入文件的方法
"""写语料"""
f = open(file_path, 'wb') # 以二进制写入模式打开文件
f.write(data) # 写入数据
f.close() # 关闭文件
def q_to_b(self, q_str): # 定义一个将全角字符转换为半角字符的方法
"""全角转半角"""
b_str = "" # 初始化一个空字符串
for uchar in q_str: # 遍历全角字符串中的每个字符
inside_code = ord(uchar) # 获取字符的 Unicode 编码
if inside_code == 12288: # 如果是全角空格,直接转换为半角空格
inside_code = 32
elif 65374 >= inside_code >= 65281: # 如果是其他全角字符(除空格),根据关系转化为半角字符
inside_code -= 65248
b_str += chr(inside_code) # 将转换后的字符拼接到半角字符串中
return b_str # 返回半角字符串
def b_to_q(self, b_str): # 定义一个将半角字符转换为全角字符的方法
"""半角转全角"""
q_str = "" # 初始化一个空字符串
for uchar in b_str: # 遍历半角字符串中的每个字符
inside_code = ord(uchar) # 获取字符的 Unicode 编码
if inside_code == 32: # 如果是半角空格,直接转化为全角空格
inside_code = 12288
elif 126 >= inside_code >= 32: # 如果是其他半角字符(除空格),根据关系转化为全角字符
inside_code += 65248
q_str += chr(inside_code) # 将转换后的字符拼接到全角字符串中
return q_str # 返回全角字符串
def pre_process(self): # 定义一个语料预处理的方法
"""语料预处理 """
lines = self.read_corpus_from_file(self.train_corpus_path) # 从训练语料的路径读取语料
new_lines = [] # 初始化一个空列表,用于存储处理后的语料
for line in lines: # 遍历每一行语料
words = self.q_to_b(line.strip()).split(u' ') # 将全角字符转换为半角字符,并去除首尾空格,然后按空格分割成词
pro_words = self.process_t(words) # 处理时间词
pro_words = self.process_nr(pro_words) # 处理人名
pro_words = self.process_k(pro_words) # 处理大粒度分词
new_lines.append(' '.join(pro_words[1:])) # 将处理后的词拼接成一行,并添加到新的语料列表中
self.write_corpus_to_file(data='\n'.join(new_lines).encode('utf-8'), file_path=self.process_corpus_path) # 将新的语料列表写入到处理后的语料的路径
def process_k(self, words): # 定义一个处理大粒度分词的方法
"""处理大粒度分词,合并语料库中括号中的大粒度分词,类似:[国家/n 环保局/n]nt """
pro_words = [] # 初始化一个空列表,用于存储处理后的词
index = 0 # 初始化一个索引,用于遍历词列表
temp = u'' # 初始化一个空字符串,用于存储括号中的词
while True: # 循环直到遍历完所有词或者遇到空词
word = words[index] if index < len(words) else u'' # 获取当前索引对应的词,如果索引超出词列表的长度,就返回空字符串
if u'[' in word: # 如果词中包含左括号
temp += re.sub(pattern=u'/[a-zA-Z]*', repl=u'', string=word.replace(u'[', u'')) # 去除词性标注,并去除左括号,然后添加到临时字符串中
elif u']' in word: # 如果词中包含右括号
w = word.split(u']') # 按右括号分割词
temp += re.sub(pattern=u'/[a-zA-Z]*', repl=u'', string=w[0]) # 去除词性标注,并添加到临时字符串中
pro_words.append(temp + u'/' + w[1]) # 将临时字符串和右括号后的词性标注拼接起来,并添加到处理后的词列表中
temp = u'' # 清空临时字符串
elif temp: # 如果临时字符串不为空
temp += re.sub(pattern=u'/[a-zA-Z]*', repl=u'', string=word) # 去除词性标注,并添加到临时字符串中
elif word: # 如果词不为空
pro_words.append(word) # 直接添加到处理后的词列表中
else: # 如果词为空,表示遍历完所有词
break # 跳出循环
index += 1 # 索引加一
return pro_words # 返回处理后的词列表
def process_nr(self, words):
""" 处理姓名,合并语料库分开标注的姓和名,类似:温/nr 家宝/nr"""
pro_words = [] # 初始化一个空列表,用于存储处理后的词
index = 0 # 初始化一个索引,用于遍历词列表
while True: # 循环直到遍历完所有词或者遇到空词
word = words[index] if index < len(words) else u'' # 获取当前索引对应的词,如果索引超出词列表的长度,就返回空字符串
if u'/nr' in word: # 如果词中包含人名词性标注
next_index = index + 1 # 获取下一个索引
if next_index < len(words) and u'/nr' in words[next_index]: # 如果下一个词也包含人名词性标注
pro_words.append(word.replace(u'/nr', u'') + words[next_index]) # 去除词性标注,并将两个词合并为一个词,添加到处理后的词列表中
index = next_index # 更新索引为下一个索引
else: # 如果下一个词不包含人名词性标注
pro_words.append(word) # 直接添加当前词到处理后的词列表中
elif word: # 如果词不为空
pro_words.append(word) # 直接添加当前词到处理后的词列表中
else: # 如果词为空,表示遍历完所有词
break # 跳出循环
index += 1 # 索引加一
return pro_words # 返回处理后的词列表
def process_t(self, words):
"""处理时间,合并语料库分开标注的时间词,类似:(/w 一九九七年/t 十二月/t 三十一日/t )/w """
pro_words = [] # 初始化一个空列表,用于存储处理后的词
index = 0 # 初始化一个索引,用于遍历词列表
temp = u'' # 初始化一个空字符串,用于存储时间词
while True: # 循环直到遍历完所有词或者遇到空词
word = words[index] if index < len(words) else u'' # 获取当前索引对应的词,如果索引超出词列表的长度,就返回空字符串
if u'/t' in word: # 如果词中包含时间词性标注
temp = temp.replace(u'/t', u'') + word # 去除词性标注,并将词添加到临时字符串中
elif temp: # 如果临时字符串不为空
pro_words.append(temp) # 将临时字符串添加到处理后的词列表中
pro_words.append(word) # 将当前词添加到处理后的词列表中
temp = u'' # 清空临时字符串
elif word: # 如果词不为空
pro_words.append(word) # 直接添加当前词到处理后的词列表中
else: # 如果词为空,表示遍历完所有词
break # 跳出循环
index += 1 # 索引加一
return pro_words # 返回处理后的词列表
def pos_to_tag(self, p):
"""由词性提取标签"""
t = self._maps.get(p, None) # 根据词性在映射关系中查找对应的标签,如果没有找到,就返回 None
return t if t else u'O' # 如果找到了标签,就返回标签,否则返回 O
def tag_perform(self, tag, index):
"""标签使用BIO模式"""
if index == 0 and tag != u'O': # 如果是第一个词并且标签不是 O
return u'B_{}'.format(tag) # 返回 B_标签
elif tag != u'O': # 如果不是第一个词并且标签不是 O
return u'I_{}'.format(tag) # 返回 I_标签
else: # 如果标签是 O
return tag # 返回 O
def pos_perform(self, pos):
"""去除词性携带的标签先验知识"""
if pos in self._maps.keys() and pos != u't': # 如果词性在映射关系的键中并且不是时间词
return u'n' # 返回 n
else: # 否则
return pos # 返回原词性
def initialize(self):
"""初始化 """
lines = self.read_corpus_from_file(self.process_corpus_path) # 从处理后的语料的路径读取语料
words_list = [line.strip().split(' ') for line in lines if line.strip()] # 将每一行语料去除首尾空格,并按空格分割成词,存储到一个列表中
del lines # 删除语料变量,释放内存
self.init_sequence(words_list) # 调用初始化字序列、词性序列、标记序列的方法
def init_sequence(self, words_list):
"""初始化字序列、词性序列、标记序列 """
words_seq = [[word.split(u'/')[0] for word in words] for words in words_list] # 将每个词按 / 分割,取第一个元素作为字,存储到一个列表中
pos_seq = [[word.split(u'/')[1] for word in words] for words in words_list] # 将每个词按 / 分割,取第二个元素作为词性,存储到一个列表中
tag_seq = [[self.pos_to_tag(p) for p in pos] for pos in pos_seq] # 将每个词性转换为对应的标签,存储到一个列表中
self.pos_seq = [[[pos_seq[index][i] for _ in range(len(words_seq[index][i]))]
for i in range(len(pos_seq[index]))] for index in range(len(pos_seq))] # 将每个词性复制为与字相同的个数,存储到一个列表中
self.tag_seq = [[[self.tag_perform(tag_seq[index][i], w) for w in range(len(words_seq[index][i]))]
for i in range(len(tag_seq[index]))] for index in range(len(tag_seq))] # 将每个标签按 BIO 模式转换,并复制为与字相同的个数,存储到一个列表中
self.pos_seq = [[u'un'] + [self.pos_perform(p) for pos in pos_seq for p in pos] + [u'un'] for pos_seq in
self.pos_seq] # 将每个词性去除先验知识,并在首尾添加 un 标记,存储到一个列表中
self.tag_seq = [[t for tag in tag_seq for t in tag] for tag_seq in self.tag_seq] # 将每个标签展平为一维列表,存储到一个列表中
self.word_seq = [[u''] + [w for word in word_seq for w in word] + [u''] for word_seq in words_seq] # 将每个字展平为一维列表,并在首尾添加 和 标记,存储到一个列表中
def extract_feature(self, word_grams):
"""特征选取"""
features, feature_list = [], [] # 初始化两个空列表,用于存储特征和特征列表
for index in range(len(word_grams)): # 遍历每个字窗口的索引
for i in range(len(word_grams[index])): # 遍历每个字窗口中的每个字的索引
word_gram = word_grams[index][i] # 获取当前字窗口中的当前字
feature = {u'w-1': word_gram[0], u'w': word_gram[1], u'w+1': word_gram[2], # 构造一个特征字典,包含当前字的前一个字、当前字、后一个字,以及它们的组合
u'w-1:w': word_gram[0] + word_gram[1], u'w:w+1': word_gram[1] + word_gram[2],
# u'p-1': self.pos_seq[index][i], u'p': self.pos_seq[index][i+1], # 注释掉的部分是词性特征,这里不使用
# u'p+1': self.pos_seq[index][i+2],
# u'p-1:p': self.pos_seq[index][i]+self.pos_seq[index][i+1],
# u'p:p+1': self.pos_seq[index][i+1]+self.pos_seq[index][i+2],
u'bias': 1.0} # 添加一个偏置项,用于增加模型的灵活性
feature_list.append(feature) # 将特征字典添加到特征列表中
features.append(feature_list) # 将特征列表添加到特征中
feature_list = [] # 清空特征列表,用于下一个字窗口
return features # 返回特征
def segment_by_window(self, words_list=None, window=3):
"""窗口切分"""
words = [] # 初始化一个空列表,用于存储字窗口
begin, end = 0, window # 初始化开始和结束的索引,分别为 0 和窗口大小
for _ in range(1, len(words_list)): # 遍历字列表的长度
if end > len(words_list): break # 如果结束的索引超出了字列表的长度,就跳出循环
words.append(words_list[begin:end]) # 将字列表中从开始到结束的部分添加到字窗口中
begin = begin + 1 # 更新开始的索引为原来加一
end = end + 1 # 更新结束的索引为原来加一
return words # 返回字窗口
def generator(self):
"""训练数据"""
word_grams = [self.segment_by_window(word_list) for word_list in self.word_seq] # 将每个字序列按窗口切分,得到字窗口
features = self.extract_feature(word_grams) # 提取字窗口的特征
return features, self.tag_seq # 返回特征和标签序列
class CRF_NER(object): # 定义一个命名实体识别的类
def __init__(self): # 初始化方法
"""初始化参数"""
self.algorithm = "lbfgs" # 指定优化算法为 LBFGS
self.c1 = "0.1" # 指定正则化系数 c1
self.c2 = "0.1" # 指定正则化系数 c2
self.max_iterations = 100 # 指定最大迭代次数
self.model_path ="model.pkl" # 指定模型保存的路径
self.corpus = CorpusProcess() # 创建一个语料处理的实例
self.corpus.pre_process() # 对语料进行预处理
self.corpus.initialize() # 初始化语料
self.model = None # 初始化模型为 None
def initialize_model(self): # 定义一个初始化模型的方法
"""初始化"""
algorithm = self.algorithm # 获取优化算法
c1 = float(self.c1) # 获取正则化系数 c1
c2 = float(self.c2) # 获取正则化系数 c2
max_iterations = int(self.max_iterations) # 获取最大迭代次数
self.model = sklearn_crfsuite.CRF(algorithm=algorithm, c1=c1, c2=c2, # 创建一个条件随机场的模型,传入相应的参数
max_iterations=max_iterations, all_possible_transitions=True)
def train(self): # 定义一个训练模型的方法
"""训练"""
self.initialize_model() # 调用初始化模型的方法
x, y = self.corpus.generator() # 从语料中生成特征和标签
x_train, y_train = x[500:], y[500:] # 将后 500 个样本作为训练集
x_test, y_test = x[:500], y[:500] # 将前 500 个样本作为测试集
self.model.fit(x_train, y_train) # 用训练集拟合模型
labels = list(self.model.classes_) # 获取模型的所有标签
labels.remove('O') # 移除 O 标签,表示非实体
y_predict = self.model.predict(x_test) # 用模型对测试集进行预测
metrics.flat_f1_score(y_test, y_predict, average='weighted', labels=labels) # 计算加权平均的 F1 分数,只考虑实体标签
sorted_labels = sorted(labels, key=lambda name: (name[1:], name[0])) # 对标签按照 BIO 模式进行排序
print(metrics.flat_classification_report(y_test, y_predict, labels=sorted_labels, digits=3)) # 打印分类报告,包括精确度、召回率、F1 分数等指标
self.save_model() # 调用保存模型的方法
def predict(self, sentence): # 定义一个预测方法,输入一个句子,输出实体
"""预测"""
self.load_model() # 调用加载模型的方法
u_sent = self.corpus.q_to_b(sentence) # 将句子中的全角字符转换为半角字符
word_lists = [[u''] + [c for c in u_sent] + [u'']] # 将句子中的每个字作为一个词,并在首尾添加特殊标记
word_grams = [self.corpus.segment_by_window(word_list) for word_list in word_lists] # 将每个词按照窗口切分,得到字窗口
features = self.corpus.extract_feature(word_grams) # 提取字窗口的特征
y_predict = self.model.predict(features) # 用模型对特征进行预测,得到标签
entity = u'' # 初始化一个空字符串,用于存储实体
for index in range(len(y_predict[0])): # 遍历每个预测的标签的索引
if y_predict[0][index] != u'O': # 如果标签不是 O,表示是实体
if index > 0 and y_predict[0][index][-1] != y_predict[0][index - 1][-1]: # 如果不是第一个字,并且当前标签的实体类型和前一个标签的实体类型不同
entity += u' ' # 在实体字符串中添加一个空格,用于分隔不同的实体
entity += u_sent[index] # 在实体字符串中添加当前字
elif entity[-1] != u' ': # 如果标签是 O,表示不是实体,并且实体字符串的最后一个字符不是空格
entity += u' ' # 在实体字符串中添加一个空格,用于分隔不同的实体
return entity # 返回实体字符串
def load_model(self): # 定义一个加载模型的方法
"""加载模型 """
self.model = joblib.load(self.model_path) # 从模型保存的路径加载模型
def save_model(self): # 定义一个保存模型的方法
"""保存模型"""
joblib.dump(self.model, self.model_path) # 将模型保存到指定的路径
if __name__=="__main__": # 如果是主程序
ner = CRF_NER() # 创建一个命名实体识别的实例
#训练模型,当训练完毕后,就可以直接加载模型参数,不用再次训练了
#mode=ner.train() # 调用训练模型的方法,这里注释掉,表示不用再次训练
result1=ner.predict(u'新华社北京十二月三十一日电(中央人民广播电台记者刘振英、新华社记者张宿堂)今天是一九九七年的最后一天。') # 调用预测方法,输入一个句子
print(result1) # 打印预测的结果
result2=ner.predict(u'中国,我爱你。') # 调用预测方法,输入另一个句子
print(result2) # 打印预测的结果
参考网址:
https://www.jianshu.com/p/7fa260e91382