Day09【基于新闻事件的命名实体抽取】
基于新闻事件的命名实体抽取
- 目标
- 数据准备
- 参数配置
- 数据处理
- 类初始化
- 数据读取load
- 文本编码encode
- 序列对齐padding
- 标签映射load_schema
- 加载词汇表load_vocab
- 加载数据封装
- 模型构建
- 自定义模型
- 类初始化
- 条件随机场
- 前向传播
- 优化器配置
- 网络模型总结
- 主程序
- 主程序详解
- 训练情况
- 测试与评估
- 类初始化init
- 测试评估eval
- 统计汇录write_stats
- 评估显示show_stats
- 标签解码decode
- 正则表达式RE
目标
本文基于给定的词表,将输入的文本基于jieba分词分割为若干个词,然后基于词表将词序列化处理,之后经过若干网络层,最后输出在已知命名实体标注类别标签上的概率分布,从而实现一个简单新闻事件的命名实体识别。
数据准备
词表文件chars.txt
类别标签文件schema.json
{"B-LOCATION": 0,"B-ORGANIZATION": 1,"B-PERSON": 2,"B-TIME": 3,"I-LOCATION": 4,"I-ORGANIZATION": 5,"I-PERSON": 6,"I-TIME": 7,"O": 8
}
本文主要提取新闻事件中时间(TIME
)、地点(LOCATION
)、机构(ORGANIZATION
)、人名(PERSON
)这四类实体,实体的开始大写字母以B
表示,实体的中间部分及结尾以大写字母I
表示。标签文件中的每个key
表示实体标注的类别,总共9个类别,因此本次任务分类类别为9,而value
对应的索引值可作为分类的标签使用,新闻事件文本中的文本可作为词向量的输入文本。如果文本字符串中出现一个同类别的B
后面跟着多个同类别的I
,则表示存在着一个实体。比如,B-PERSON
I-PERSON
I-PERSON
表示一个人名,如果后面跟着不是同类别的则不是一个实体,比如,B-PERSON
I-LOCATION
I-LOCATION
,具体的可看数据集。这个在后续词向量预测的标签解码时可用到。
训练集数据train.txt训练集数据
测试集数据test.txt测试集数据
参数配置
config.py
# -*- coding: utf-8 -*-"""
配置参数信息
"""Config = {"model_path": "model_output","schema_path": "ner_data/schema.json","train_data_path": "ner_data/train.txt","valid_data_path": "ner_data/test.txt","vocab_path":"chars.txt","max_length": 100,"hidden_size": 256,"num_layers": 2,"epoch": 20,"batch_size": 16,"optimizer": "adam","learning_rate": 1e-3,"use_crf": True,"class_num": 9,"bert_path": r"../../../bert-base-chinese"
}
配置文件主要是针对一个 命名实体识别(NER) 模型的训练和验证过程,包含了很多超参数设置、文件路径配置和模型相关的参数。通过调整这些参数,可以控制模型的结构、训练过程、优化方法以及输入数据的处理方式。
数据处理
loader.py
# -*- coding: utf-8 -*-import json
import re
import os
import torch
import random
import jieba
import numpy as np
from torch.utils.data import Dataset, DataLoader"""
数据加载
"""class DataGenerator:def __init__(self, data_path, config):self.config = configself.path = data_pathself.vocab = load_vocab(config["vocab_path"])self.config["vocab_size"] = len(self.vocab)self.sentences = []self.schema = self.load_schema(config["schema_path"])self.load()def load(self):self.data = []with open(self.path, encoding="utf8") as f:segments = f.read().split("\n\n")for segment in segments:sentenece = []labels = []for line in segment.split("\n"):if line.strip() == "":continuechar, label = line.split()sentenece.append(char)labels.append(self.schema[label])self.sentences.append("".join(sentenece))input_ids = self.encode_sentence(sentenece)labels = self.padding(labels, -1)self.data.append([torch.LongTensor(input_ids), torch.LongTensor(labels)])returndef encode_sentence(self, text, padding=True):input_id = []if self.config["vocab_path"] == "words.txt":for word in jieba.cut(text):input_id.append(self.vocab.get(word, self.vocab["[UNK]"]))else:for char in text:input_id.append(self.vocab.get(char, self.vocab["[UNK]"]))if padding:input_id = self.padding(input_id)return input_id#补齐或截断输入的序列,使其可以在一个batch内运算def padding(self, input_id, pad_token=0):input_id = input_id[:self.config["max_length"]]input_id += [pad_token] * (self.config["max_length"] - len(input_id))return input_iddef __len__(self):return len(self.data)def __getitem__(self, index):return self.data[index]def load_schema(self, path):with open(path, encoding="utf8") as f:return json.load(f)#加载字表或词表
def load_vocab(vocab_path):token_dict = {}with open(vocab_path, encoding="utf8") as f:for index, line in enumerate(f):token = line.strip()token_dict[token] = index + 1 #0留给padding位置,所以从1开始return token_dict#用torch自带的DataLoader类封装数据
def load_data(data_path, config, shuffle=True):dg = DataGenerator(data_path, config)dl = DataLoader(dg, batch_size=config["batch_size"], shuffle=shuffle)return dl
DataGenerator
类实现了一个数据加载器,负责从文件中读取数据,进行必要的处理(如分词、编码、填充等),并将其转化为torch.LongTensor
格式,最终返回给模型进行训练。load_vocab
函数和load_schema
函数分别负责加载词汇表和标签映射文件,确保在模型训练过程中可以正确地进行字符或词汇到索引的转换。load_data
函数将DataGenerator
封装进DataLoader
,以便进行批次处理,适配模型训练的需要。
以下是对文件中各个部分的详细解释:
DataGenerator
类负责从数据文件中加载文本和标签,并进行处理,最终生成适合用于模型训练的数据。
类初始化
- 作用:初始化
DataGenerator
实例,加载配置和词汇表,设置句子列表以及标签映射。 data_path
: 数据文件路径,包含了要加载的训练数据。config
: 配置字典,包含各种超参数设置和路径配置。vocab
: 通过调用load_vocab
函数加载的词汇表。sentences
: 存储所有句子的列表。schema
: 存储标签映射(从标签到数字值的映射),通过调用load_schema
加载。self.load()
: 调用load()
方法加载数据。
数据读取load
- 作用:读取数据文件并将每个句子和标签对加载到内存中。
- 步骤:
- 通过
open(self.path, encoding="utf8")
打开数据文件,读取数据。 - 将数据按双换行分割成多个段落(每个段落表示一个句子及其标签)。
- 对每一行,提取字符和其对应的标签,并将标签转换为数字。
- 通过
encode_sentence
方法将每个字符转换为词汇表中的索引。 - 使用
padding
方法对标签进行填充或截断,使其符合模型输入要求。 - 每个句子及其对应的标签对存储为一个
LongTensor
,存放到self.data
列表中。
- 通过
文本编码encode
- 作用:将输入的文本(句子)转换为词汇表中对应的索引列表。
- 步骤:
- 如果词汇表是按词切分(
vocab_path == "words.txt"
),则使用jieba.cut
将句子分词。 - 如果词汇表是按字符切分,则逐字符遍历文本。
- 每个词或字符根据词汇表转换为对应的索引。如果词汇表中没有该词/字符,则使用
[UNK]
(未知符号)作为默认值。 - 如果
padding
为True
,则调用padding
方法对输入序列进行填充,使得所有输入具有相同的长度。
- 如果词汇表是按词切分(
序列对齐padding
- 作用:对输入的序列进行填充或截断,确保其长度符合
max_length
的要求。 - 步骤:
- 截断序列,确保其长度不超过
max_length
。 - 如果序列长度小于
max_length
,则使用pad_token
填充直到长度为max_length
。
- 截断序列,确保其长度不超过
__len__(self)
- 作用:返回数据集的大小,即数据中包含的样本数。
__getitem__(self, index)
- 作用:返回指定索引的数据项,每个数据项是一个包含输入数据和标签的元组(
LongTensor
)。
标签映射load_schema
- 作用:加载标签映射文件,将标签从字符串映射到整数值。
- 步骤:从指定的 JSON 文件加载标签的映射,通常用于将每个标签(如
PER
,LOC
等)映射到一个整数值。
加载词汇表load_vocab
- 作用:加载词汇表,将每个词或字符映射到一个唯一的整数索引。
- 步骤:
- 打开词汇表文件,逐行读取词汇表中的词语。
- 将每个词汇映射到一个唯一的索引,索引从 1 开始,0 留给 padding 位置。
- 返回一个字典
token_dict
,将每个词汇映射到对应的索引。
加载数据封装
- 作用:用
DataGenerator
类加载数据,并返回一个DataLoader
实例。 - 步骤:
- 创建一个
DataGenerator
实例来加载数据。 - 使用
DataLoader
封装DataGenerator
,并设置批次大小(batch_size
)和是否打乱数据(shuffle
)。 - 返回封装好的
DataLoader
,用于模型训练时按批次加载数据。
- 创建一个
模型构建
model.py
# -*- coding: utf-8 -*-import torch
import torch.nn as nn
from torch.optim import Adam, SGD
from torchcrf import CRF
"""
建立网络模型结构
"""class TorchModel(nn.Module):def __init__(self, config):super(TorchModel, self).__init__()hidden_size = config["hidden_size"]vocab_size = config["vocab_size"] + 1max_length = config["max_length"]class_num = config["class_num"]num_layers = config["num_layers"]self.embedding = nn.Embedding(vocab_size, hidden_size, padding_idx=0)self.layer = nn.LSTM(hidden_size, hidden_size, batch_first=True, bidirectional=True, num_layers=num_layers)self.classify = nn.Linear(hidden_size * 2, class_num)self.crf_layer = CRF(class_num, batch_first=True)self.use_crf = config["use_crf"]self.loss = torch.nn.CrossEntropyLoss(ignore_index=-1) #loss采用交叉熵损失#当输入真实标签,返回loss值;无真实标签,返回预测值def forward(self, x, target=None):x = self.embedding(x) #input shape:(batch_size, sen_len)x, _ = self.layer(x) #input shape:(batch_size, sen_len, input_dim)predict = self.classify(x) #ouput:(batch_size, sen_len, num_tags) -> (batch_size * sen_len, num_tags)if target is not None:if self.use_crf:mask = target.gt(-1) return - self.crf_layer(predict, target, mask, reduction="mean")else:#(number, class_num), (number)return self.loss(predict.view(-1, predict.shape[-1]), target.view(-1))else:if self.use_crf:return self.crf_layer.decode(predict)else:return predictdef choose_optimizer(config, model):optimizer = config["optimizer"]learning_rate = config["learning_rate"]if optimizer == "adam":return Adam(model.parameters(), lr=learning_rate)elif optimizer == "sgd":return SGD(model.parameters(), lr=learning_rate)
网络结构主要定义了一个用于序列标注任务的神经网络模型 TorchModel
,并提供了一个选择优化器的函数 choose_optimizer
。下面是各个部分的详细解释:
自定义模型
该类继承自 torch.nn.Module
,用于定义一个深度学习模型,模型的结构包括嵌入层、LSTM 层、分类层和 CRF 层,具体的作用如下:
类初始化
- 作用:初始化模型的各个层和相关配置。
- 参数:
config
: 配置字典,包含了各个超参数设置,如隐藏层大小、词汇表大小、类别数目等。
- 操作:
self.embedding
: 嵌入层(nn.Embedding
),用于将词汇表中的每个单词映射为一个固定大小的向量。其大小为vocab_size
(词汇表大小)和hidden_size
(隐藏层的大小)。padding_idx=0
表示0
索引的位置为填充符号(padding),即输入序列较短时使用的填充值。self.layer
: 双向 LSTM 层(nn.LSTM
),将输入的嵌入向量进行编码。batch_first=True
表示输入的第一个维度是批次大小,bidirectional=True
表示 LSTM 为双向 LSTM,num_layers
控制 LSTM 的层数。self.classify
: 全连接层(nn.Linear
),将 LSTM 输出的隐藏状态映射到标签类别空间。由于是双向 LSTM,输入的维度为hidden_size * 2
(双向),输出的维度为class_num
(类别数)。self.crf_layer
: CRF 层(torchcrf.CRF
),用于进行条件随机场(CRF)解码,帮助提高序列标注的准确性,特别是处理标签之间的依赖关系。self.use_crf
: 一个布尔值,表示是否使用 CRF 层。如果True
,则在训练过程中使用 CRF;否则使用传统的交叉熵损失。self.loss
: 损失函数,使用交叉熵损失(torch.nn.CrossEntropyLoss
),并且忽略标签为-1
的位置(通常用于 padding 的位置)。
条件随机场
- 条件随机场(CRF):条件随机场是一种用于标注和分割序列数据的模型,它能够建模标签之间的依赖关系。在传统的序列标注任务中,每个标签通常是独立预测的,这忽略了标签之间的潜在关系。而 CRF 层能够通过建模标签间的依赖性来优化整体标签序列,从而提高序列标注的准确性。
CRF 的关键矩阵:
-
发射矩阵(Emission Matrix):
- 发射矩阵是从输入到标签的概率分布。它定义了给定输入的每个时间步(如单词或字)的某个标签的发射概率。
- 发射矩阵通常表示为 E E E,其中 E t , y E_{t, y} Et,y 表示在时间步 ( t ) 时,将输入序列的第 t t t 个元素分配给标签 y y y 的概率。
- 例如,假设有一个命名实体识别(NER)任务,输入是一个词汇序列,每个词汇有一个发射概率分配给每个可能的标签(如
B-PERSON
,I-PERSON
,O
等)。
- 例如,假设有一个命名实体识别(NER)任务,输入是一个词汇序列,每个词汇有一个发射概率分配给每个可能的标签(如
- 在 CRF 层中,发射矩阵的计算可以通过 LSTM 或其他编码器的输出得到,例如:
E t , y = softmax ( W emission h t ) E_{t, y} = \text{softmax}(W_{\text{emission}} h_t) Et,y=softmax(Wemissionht)
其中 h t h_t ht 是 LSTM 输出的隐藏状态, W emission W_{\text{emission}} Wemission是一个映射到标签空间的矩阵。
-
转移矩阵(Transition Matrix):
- 转移矩阵定义了标签之间的转换概率,表示从一个标签到另一个标签的概率。
- 这个矩阵通常表示为 T T T,其中 T y ′ y T_{y' y} Ty′y表示从标签 y ′ y' y′转换到标签 y y y的概率。
- 例如,在命名实体识别任务中,标签序列中
B-PERSON
后面可能更倾向于出现I-PERSON
,而不太可能出现B-LOCATION
。
- 例如,在命名实体识别任务中,标签序列中
- 转移矩阵有助于 CRF 层捕捉标签间的依赖关系,提高模型的预测一致性和准确性。
CRF 的损失函数:
在 CRF 中,损失函数的目标是最大化给定输入序列和对应标签序列的条件概率。具体来说,CRF 层的损失函数涉及到两个部分:分子和 分母。
-
分子:正确标签序列的条件概率:
- 假设我们有一个输入序列 x = ( x 1 , x 2 , . . . , x T ) x = (x_1, x_2, ..., x_T) x=(x1,x2,...,xT),对应的正确标签序列为 y = ( y 1 , y 2 , . . . , y T ) y = (y_1, y_2, ..., y_T) y=(y1,y2,...,yT),那么模型输出的条件概率为:
P ( y ∣ x ) = exp ( score ( x , y ) ) Z ( x ) P(y | x) = \frac{\exp(\text{score}(x, y))}{Z(x)} P(y∣x)=Z(x)exp(score(x,y))
其中, score ( x , y ) \text{score}(x, y) score(x,y) 是标签序列 y y y 在输入序列 x x x 下的评分, Z ( x ) Z(x) Z(x) 是归一化因子(也叫做分区函数),确保概率的总和为 1。
- 假设我们有一个输入序列 x = ( x 1 , x 2 , . . . , x T ) x = (x_1, x_2, ..., x_T) x=(x1,x2,...,xT),对应的正确标签序列为 y = ( y 1 , y 2 , . . . , y T ) y = (y_1, y_2, ..., y_T) y=(y1,y2,...,yT),那么模型输出的条件概率为:
-
分子评分函数:标签序列的评分函数:
- 标签序列的评分函数 score ( x , y ) \text{score}(x, y) score(x,y) 是由发射矩阵和转移矩阵共同决定的。具体而言,可以通过以下公式计算:
score ( x , y ) = ∑ t = 1 T ( log E t , y t ) + ∑ t = 2 T log T y t − 1 , y t \text{score}(x, y) = \sum_{t=1}^{T} \left( \log E_{t, y_t} \right) + \sum_{t=2}^{T} \log T_{y_{t-1}, y_t} score(x,y)=t=1∑T(logEt,yt)+t=2∑TlogTyt−1,yt
其中, E t , y t E_{t, y_t} Et,yt是第 t t t个时间步,标签 y t y_t yt的发射概率, T y t − 1 , y t T_{y_{t-1}, y_t} Tyt−1,yt是从标签 y t − 1 y_{t-1} yt−1到标签 y t y_t yt的转移概率。
- 标签序列的评分函数 score ( x , y ) \text{score}(x, y) score(x,y) 是由发射矩阵和转移矩阵共同决定的。具体而言,可以通过以下公式计算:
-
分母:所有可能标签序列的条件概率之和:
- 为了对概率进行归一化,我们需要计算所有可能标签序列的总评分,即计算分区函数 Z ( x ) Z(x) Z(x):
Z ( x ) = ∑ y ′ exp ( score ( x , y ′ ) ) Z(x) = \sum_{y'} \exp(\text{score}(x, y')) Z(x)=y′∑exp(score(x,y′))
其中 y ′ y' y′ 遍历所有可能的标签序列。由于标签序列的数量非常庞大(尤其是在标签空间大的情况下),计算 Z ( x ) Z(x) Z(x)是一个相当昂贵的操作,通常使用 前向后向算法(Forward-Backward Algorithm
)来高效地计算。
- 为了对概率进行归一化,我们需要计算所有可能标签序列的总评分,即计算分区函数 Z ( x ) Z(x) Z(x):
-
负对数似然损失:
- 在训练过程中,我们通过最大化正确标签序列的条件概率,最小化负对数似然损失(
Negative Log-Likelihood Loss
)。损失函数定义为:
L = − log P ( y ∣ x ) = − log exp ( score ( x , y ) ) Z ( x ) = − score ( x , y ) + log Z ( x ) \mathcal{L} = - \log P(y | x) = - \log \frac{\exp(\text{score}(x, y))}{Z(x)} = - \text{score}(x, y) + \log Z(x) L=−logP(y∣x)=−logZ(x)exp(score(x,y))=−score(x,y)+logZ(x)
其中,第一项 − score ( x , y ) - \text{score}(x, y) −score(x,y) 是正确标签序列的评分,第二项 log Z ( x ) \log Z(x) logZ(x)是归一化常数,确保概率的总和为 1。这个损失函数通过优化标签序列的评分来提高模型的预测能力。
- 在训练过程中,我们通过最大化正确标签序列的条件概率,最小化负对数似然损失(
CRF 层的作用:
-
在序列标注任务中,如命名实体识别(NER)或词性标注(POS)等,CRF 层通过考虑标签之间的转移概率,帮助模型输出更加一致的标签序列。例如,标签
B-PERSON
后通常会跟随I-PERSON
,而不是B-LOCATION
,CRF 可以有效地捕捉到这种标签间的关系。 -
CRF 层将输入的每个时间步的标签与相邻标签之间的依赖关系建模,并且通过最大化条件概率来优化标签的序列预测。在有 CRF 层的模型中,损失函数不仅考虑了单个标签的预测结果,还综合考虑了整个标签序列的合理性。
-
训练时的区别:
- 当
self.use_crf
设置为True
时,模型将使用 CRF 层进行解码,这意味着在训练过程中,模型不仅依赖于 LSTM 的输出,还通过 CRF 层学习标签之间的关系,并通过最大化全局条件概率来优化标签序列。 - 如果
self.use_crf
为False
,则模型将不使用 CRF 层,而是使用传统的交叉熵损失进行训练,预测标签时不考虑标签之间的依赖关系。
- 当
CRF 层的优势:
- 增强标签依赖关系建模:CRF 层通过全局优化标签序列,使得输出标签序列在局部和全局范围内更加一致和准确。
- 解决标注冲突问题:对于一些复杂的标注任务,传统的交叉熵损失可能无法有效解决标签冲突或不一致问题,而 CRF 层能够通过建模标签间的转移概率来避免这种情况。
通过在模型中集成 CRF 层,我们可以更好地利用序列数据中的标签依赖关系,提高模型在序列标注任务中的表现。
前向传播
- 作用:定义前向传播的计算过程。
- 参数:
x
: 输入数据,形状为(batch_size, sen_len)
,即一个批次的句子,每个句子由若干个词汇或字符的索引组成。target
: 真实标签,形状为(batch_size, sen_len)
,包含每个单词或字符的标签索引。target
只有在训练时需要传入,进行计算损失时使用。
- 操作:
x = self.embedding(x)
:通过嵌入层将输入的词汇索引转换为嵌入向量。x, _ = self.layer(x)
:通过LSTM
层处理输入数据,x
变成形状(batch_size, sen_len, hidden_size * 2)
(双向 LSTM 的输出)。predict = self.classify(x)
:通过全连接层将LSTM
的输出映射到标签空间,得到形状为(batch_size, sen_len, class_num)
的输出。
接下来根据是否提供了真实标签来决定返回值:
- 如果有真实标签
target
:- 如果使用了 CRF (
self.use_crf=True
),则计算 CRF 层的损失,mask
是一个布尔型张量,标记哪些位置需要计算损失(通常是忽略 padding 部分)。 - 如果没有使用 CRF,则通过交叉熵损失计算模型输出与目标标签之间的差异。
- 如果使用了 CRF (
- 如果没有真实标签
target
(即在预测时):- 如果使用了 CRF,则返回通过 CRF 解码得到的预测结果。
- 否则,返回模型的原始预测结果。
优化器配置
- 作用:根据配置选择合适的优化器。
- 参数:
config
: 配置字典,包含优化器类型和学习率。model
: 需要优化的模型。
- 操作:
- 从
config
中获取优化器类型(optimizer
)和学习率(learning_rate
)。 - 如果选择的是
adam
,则使用Adam
优化器。 - 如果选择的是
sgd
,则使用SGD
优化器。 - 返回相应的优化器实例。
- 从
网络模型总结
- 模型结构:
- 词嵌入层将词汇索引转换为向量表示。
- 双向 LSTM 层处理序列信息,捕捉前后依赖关系。
- 全连接层将 LSTM 的输出映射到标签空间。
- 可选的 CRF 层(条件随机场)进一步增强序列标注的准确性,特别是考虑标签之间的依赖关系。
- 交叉熵损失用于训练,若使用 CRF,则通过 CRF 进行损失计算。
- 选择优化器:根据配置选择合适的优化器(Adam 或 SGD)。
这种模型结构通常用于序列标注任务,如命名实体识别(NER),依赖句法分析等,需要处理标签之间有依赖关系的任务。
主程序
main.py
# -*- coding: utf-8 -*-import torch
import os
import random
import numpy as np
import logging
from config import Config
from model import TorchModel, choose_optimizer
from evaluate import Evaluator
from loader import load_datalogging.basicConfig(level = logging.INFO,format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)"""
模型训练主程序
"""def main(config):#创建保存模型的目录if not os.path.isdir(config["model_path"]):os.mkdir(config["model_path"])#加载训练数据train_data = load_data(config["train_data_path"], config)#加载模型model = TorchModel(config)# 标识是否使用gpucuda_flag = torch.cuda.is_available()if cuda_flag:logger.info("gpu可以使用,迁移模型至gpu")model = model.cuda()#加载优化器optimizer = choose_optimizer(config, model)#加载效果测试类evaluator = Evaluator(config, model, logger)#训练for epoch in range(config["epoch"]):epoch += 1model.train()logger.info("epoch %d begin" % epoch)train_loss = []for index, batch_data in enumerate(train_data):optimizer.zero_grad()if cuda_flag:batch_data = [d.cuda() for d in batch_data]input_id, labels = batch_dataloss = model(input_id, labels)loss.backward()optimizer.step()train_loss.append(loss.item())if index % int(len(train_data) / 2) == 0:logger.info("batch loss %f" % loss)logger.info("epoch average loss: %f" % np.mean(train_loss))evaluator.eval(epoch)model_path = os.path.join(config["model_path"], "epoch_%d.pth" % epoch)torch.save(model.state_dict(), model_path)return model, train_dataif __name__ == "__main__":model, train_data = main(Config)
main
函数是程序的入口点,通过 main(Config)
启动训练过程。Config
类包含模型训练所有必要的超参数和文件路径。整个训练流程包括以下主要步骤:
- 数据加载:准备训练数据。
- 模型创建:初始化模型,准备训练。
- 训练过程:包括前向传播、损失计算、反向传播、优化。
- 评估:每个 epoch 后评估模型效果。
- 模型保存:每个 epoch 后保存模型的权重。
主程序详解
-
创建模型保存目录:
- 该步骤通过
os.makedirs(config.save_path, exist_ok=True)
确保模型的保存路径存在。如果目录不存在,makedirs
会创建它。exist_ok=True
参数确保如果路径已存在,不会抛出错误。
- 该步骤通过
-
加载数据:
- 使用
load_data(config)
函数加载训练数据。这通常会返回训练集和验证集,可能包括数据的预处理步骤(如数据增强、归一化等)。config
中会包含数据路径、批次大小等配置。
- 使用
-
初始化模型:
model = TorchModel(config)
创建了一个深度学习模型的实例。TorchModel
是一个自定义的类,通常会继承自 PyTorch 的nn.Module
,并根据配置config
来初始化模型的结构、超参数等。
-
检查 GPU 并迁移模型:
- 判断是否有 GPU 可用,如果有,则将模型迁移到 GPU 上。具体通过
torch.device("cuda" if torch.cuda.is_available() else "cpu")
来判断,并使用model.to(device)
将模型移到 GPU 或 CPU 上。
- 判断是否有 GPU 可用,如果有,则将模型迁移到 GPU 上。具体通过
-
加载优化器:
- 选择优化器并进行初始化。通常是使用
torch.optim
提供的优化器,如 Adam 或 SGD。优化器需要模型的参数以及学习率等超参数,在main(config)
中,通过配置文件中的参数来选择优化器。 optimizer = torch.optim.Adam(model.parameters(), lr=config.lr)
这种形式通常用来初始化一个 Adam 优化器。
- 选择优化器并进行初始化。通常是使用
-
初始化评估器:
- 创建评估器
evaluator = Evaluator(config)
,用于在每个 epoch 结束时评估模型的性能。评估器可能计算诸如准确率、精度、召回率等指标来评估模型的效果。
- 创建评估器
-
训练过程:
- 进入训练循环,每个 epoch 会经历以下步骤:
- 遍历训练批次:对于每个训练批次,模型会进行前向传播计算预测值,计算损失(通常是交叉熵或 MSE),并进行反向传播以更新参数。
- 计算损失并优化:每个批次的损失值会被累积,用于后续优化步骤,优化器会使用损失计算更新模型的权重。
- 输出损失信息:如果当前批次处理到一半,会输出当前的损失值,用于追踪训练进度。
- 每个 epoch 结束时评估:每经过一个 epoch,使用
evaluator.evaluate()
方法来评估当前模型的性能,如验证集的损失、准确率等。
- 进入训练循环,每个 epoch 会经历以下步骤:
-
保存模型:
- 在每个 epoch 结束后,通过
torch.save(model.state_dict(), model_save_path)
将模型的参数(权重)保存到指定路径config.save_path
。这样可以在训练过程中定期保存模型,以便在训练中断后恢复或者用于后续的推理。
- 在每个 epoch 结束后,通过
-
返回训练结果:
- 最后,函数返回训练好的模型和训练数据。这通常用于后续的推理阶段或者进一步分析。
训练情况
2025-04-15 23:42:04,564 - __main__ - INFO - 开始测试第20轮模型效果:
2025-04-15 23:42:11,191 - __main__ - INFO - PERSON类实体,准确率:0.642384, 召回率: 0.500000, F1: 0.562314
2025-04-15 23:42:11,192 - __main__ - INFO - LOCATION类实体,准确率:0.707965, 召回率: 0.669456, F1: 0.688167
2025-04-15 23:42:11,192 - __main__ - INFO - TIME类实体,准确率:0.878205, 召回率: 0.769663, F1: 0.820354
2025-04-15 23:42:11,192 - __main__ - INFO - ORGANIZATION类实体,准确率:0.533333, 召回率: 0.505263, F1: 0.518914
2025-04-15 23:42:11,192 - __main__ - INFO - Macro-F1: 0.647437
2025-04-15 23:42:11,192 - __main__ - INFO - Micro-F1 0.665157
2025-04-15 23:42:11,192 - __main__ - INFO - --------------------
测试与评估
evaluate.py
# -*- coding: utf-8 -*-
import torch
import re
import numpy as np
from collections import defaultdict
from loader import load_data"""
模型效果测试
"""class Evaluator:def __init__(self, config, model, logger):self.config = configself.model = modelself.logger = loggerself.valid_data = load_data(config["valid_data_path"], config, shuffle=False)def eval(self, epoch):self.logger.info("开始测试第%d轮模型效果:" % epoch)self.stats_dict = {"LOCATION": defaultdict(int),"TIME": defaultdict(int),"PERSON": defaultdict(int),"ORGANIZATION": defaultdict(int)}self.model.eval()for index, batch_data in enumerate(self.valid_data):sentences = self.valid_data.dataset.sentences[index * self.config["batch_size"]: (index+1) * self.config["batch_size"]]if torch.cuda.is_available():batch_data = [d.cuda() for d in batch_data]input_id, labels = batch_data #输入变化时这里需要修改,比如多输入,多输出的情况with torch.no_grad():pred_results = self.model(input_id) #不输入labels,使用模型当前参数进行预测self.write_stats(labels, pred_results, sentences)self.show_stats()returndef write_stats(self, labels, pred_results, sentences):assert len(labels) == len(pred_results) == len(sentences)if not self.config["use_crf"]:pred_results = torch.argmax(pred_results, dim=-1)for true_label, pred_label, sentence in zip(labels, pred_results, sentences):if not self.config["use_crf"]:pred_label = pred_label.cpu().detach().tolist()true_label = true_label.cpu().detach().tolist()true_entities = self.decode(sentence, true_label)pred_entities = self.decode(sentence, pred_label)# print("=+++++++++")# print(true_entities)# print(pred_entities)# print('=+++++++++')# 正确率 = 识别出的正确实体数 / 识别出的实体数# 召回率 = 识别出的正确实体数 / 样本的实体数for key in ["PERSON", "LOCATION", "TIME", "ORGANIZATION"]:self.stats_dict[key]["正确识别"] += len([ent for ent in pred_entities[key] if ent in true_entities[key]])self.stats_dict[key]["样本实体数"] += len(true_entities[key])self.stats_dict[key]["识别出实体数"] += len(pred_entities[key])returndef show_stats(self):F1_scores = []for key in ["PERSON", "LOCATION", "TIME", "ORGANIZATION"]:# 正确率 = 识别出的正确实体数 / 识别出的实体数# 召回率 = 识别出的正确实体数 / 样本的实体数precision = self.stats_dict[key]["正确识别"] / (1e-5 + self.stats_dict[key]["识别出实体数"])recall = self.stats_dict[key]["正确识别"] / (1e-5 + self.stats_dict[key]["样本实体数"])F1 = (2 * precision * recall) / (precision + recall + 1e-5)F1_scores.append(F1)self.logger.info("%s类实体,准确率:%f, 召回率: %f, F1: %f" % (key, precision, recall, F1))self.logger.info("Macro-F1: %f" % np.mean(F1_scores))correct_pred = sum([self.stats_dict[key]["正确识别"] for key in ["PERSON", "LOCATION", "TIME", "ORGANIZATION"]])total_pred = sum([self.stats_dict[key]["识别出实体数"] for key in ["PERSON", "LOCATION", "TIME", "ORGANIZATION"]])true_enti = sum([self.stats_dict[key]["样本实体数"] for key in ["PERSON", "LOCATION", "TIME", "ORGANIZATION"]])micro_precision = correct_pred / (total_pred + 1e-5)micro_recall = correct_pred / (true_enti + 1e-5)micro_f1 = (2 * micro_precision * micro_recall) / (micro_precision + micro_recall + 1e-5)self.logger.info("Micro-F1 %f" % micro_f1)self.logger.info("--------------------")return'''{"B-LOCATION": 0,"B-ORGANIZATION": 1,"B-PERSON": 2,"B-TIME": 3,"I-LOCATION": 4,"I-ORGANIZATION": 5,"I-PERSON": 6,"I-TIME": 7,"O": 8}'''def decode(self, sentence, labels):labels = "".join([str(x) for x in labels[:len(sentence)]])results = defaultdict(list)# 04 + 这个模式表示以'0'开头,后面跟着一个或多个'4'的子串,# 捕获组 (04+) 将捕获所有符合该模式的部分,使用re.finditer(pattern, string) 会返回一个迭代器,# 迭代器中的每一项是一个 Match 对象,包含匹配的子串和其位置(例如匹配到的起始和结束位置)for location in re.finditer("(04+)", labels):s, e = location.span()results["LOCATION"].append(sentence[s:e])for location in re.finditer("(15+)", labels):s, e = location.span()results["ORGANIZATION"].append(sentence[s:e])for location in re.finditer("(26+)", labels):s, e = location.span()results["PERSON"].append(sentence[s:e])for location in re.finditer("(37+)", labels):s, e = location.span()results["TIME"].append(sentence[s:e])return results
主要功能是实现一个模型效果测试器(Evaluator
类),用于在训练过程中对模型进行评估,尤其是进行实体识别任务的性能评估。- Evaluator
类是为了评估命名实体识别(NER)模型的性能,使用了准确率、召回率和 F1 分数作为衡量标准。
decode
方法通过标签的 BIO 标注格式解析实体。show_stats
方法计算并显示各类实体的性能指标,包括宏观和微观 F1 分数。以下是对代码的详细解释:
类初始化init
- 功能: 初始化评估器。
- 参数:
config
: 配置字典,包含模型训练和评估的超参数,例如验证数据的路径、批次大小等。model
: 训练好的模型。logger
: 用于记录日志的对象。
- 流程:
- 加载验证数据:
self.valid_data = load_data(config["valid_data_path"], config, shuffle=False)
。从valid_data_path
路径加载验证数据,shuffle=False
表示验证数据不进行打乱。
- 加载验证数据:
测试评估eval
- 功能: 进行一次模型评估。
- 参数:
epoch
: 当前训练的轮次,用于在日志中记录。
- 流程:
- 打印出当前评估的轮次信息。
- 初始化统计字典:
self.stats_dict
,用于统计每类实体的正确识别数、样本实体数和识别出的实体数。 - 将模型设置为评估模式:
self.model.eval()
。这是 PyTorch 的标准做法,表示模型不再进行反向传播,只用于推理。 - 遍历验证数据集:
for index, batch_data in enumerate(self.valid_data)
,逐批处理验证数据。 - 对于每个批次的数据,将其移动到 GPU 上(如果可用)。
- 使用
torch.no_grad()
禁用梯度计算,从而加速推理并节省内存。 - 使用模型进行预测:
pred_results = self.model(input_id)
,注意在评估时不输入标签,仅使用模型进行预测。 - 将预测结果与真实标签进行比对,并记录统计数据。
统计汇录write_stats
- 功能: 计算并记录每类实体的识别统计信息。
- 参数:
labels
: 真实标签(实体标注)。pred_results
: 模型预测的结果。sentences
: 当前批次的句子。
- 流程:
- 检查
labels
、pred_results
和sentences
的长度是否一致。 - 如果不使用 CRF(条件随机场),则将预测结果取最大值作为标签(即从模型的输出中选出概率最大的位置作为预测标签):
pred_results = torch.argmax(pred_results, dim=-1)
。 - 对每个句子的标签和预测标签进行解码,提取出实体(
true_entities
和pred_entities
)。 - 计算每个实体类别(如
PERSON
,LOCATION
,TIME
,ORGANIZATION
)的正确识别数、样本实体数和识别出的实体数。
- 检查
评估显示show_stats
- 功能: 显示评估的统计信息(准确率、召回率、F1 分数等)。
- 流程:
-
对每个实体类别计算其准确率、召回率和 F1 分数:
- 准确率:
正确识别的实体数 / 识别出的实体数
- 召回率:
正确识别的实体数 / 样本的实体数
- F1 分数: F 1 = 2 × precision × recall precision + recall F1 = \frac{2 \times \text{precision} \times \text{recall}}{\text{precision} + \text{recall}} F1=precision+recall2×precision×recall
- 准确率:
-
计算宏观 F1(Macro-F1)和微观 F1(Micro-F1):
- 宏观 F1:对各个实体类别的 F1 分数取平均。
- 微观 F1:基于全局正确识别的实体数、识别出的实体数和样本实体数来计算。
-
标签解码decode
- 功能: 解码模型预测的标签,提取出实体信息。
- 参数:
sentence
: 当前句子(文本数据)。labels
: 模型预测的标签(如 B-LOCATION, I-PERSON 等)。
- 流程:
- 将标签列表转化为字符串:
labels = "".join([str(x) for x in labels[:len(sentence)]])
。 - 使用正则表达式(
re.finditer
)来提取每类实体:- B-LOCATION(位置类实体)、B-ORGANIZATION(组织类实体)、B-PERSON(人物类实体)、B-TIME(时间类实体)都对应一个正则表达式。
- 每个实体类别的标签从
labels
字符串中提取出对应的文本片段,并将其保存在字典中。
- 将标签列表转化为字符串:
正则表达式RE
- 代码的
decode
方法使用正则表达式(re.finditer
)从标签中提取出实体,主要根据一个一个特定B
后跟着多个连续同类别的I
且,本文中都是固定的标签对(04+)
、(15+)
、(26+)
、(37+)
。这里的标签与实体类型有固定的映射:B-LOCATION
,I-LOCATION
:用于提取位置实体,对应的标签对映射为(04+)
。B-ORGANIZATION
,I-ORGANIZATION
:用于提取组织实体,对应的标签对映射为(15+)
。B-PERSON
,I-PERSON
:用于提取人物实体,对应的标签对映射为(26+)
。B-TIME
,I-TIME
:用于提取时间实体,对应的标签对映射为(37+)
。
- 这些标签遵循
BIO(Begin, Inside, Outside)
标注格式:B-
表示实体的开始位置。I-
表示实体的内部位置。O
表示非实体部分。