关键词:Bert
,预训练模型
,实体链接
,Huggingface
内容摘要
- 中文实体链接问题定义
- 开发环境准备,预训练模型模型下载
- transformers中文编码预处理
- PyTorch模型搭建
- 微调前后模型效果对比
中文实体链接问题定义
在上一节中介绍了基于tensorflow的Bert源码,以及基于MRPC任务实现的英文文本对相似度分类,链接如下Bert系列:Bert源码分析,MRPC文本分类任务微调,本节同样是实现Bert微调,不同的是不再依托于源码工程,而是引入高度集成的Huggingface
的transformers
框架快速实现一个基于Bert微调的中文实体链接的分类。
本节的中文实体链接针对的是一个中文实体的全称和简称的链接,即输入一对候选的中文名称预测他们是否是一对全简称,比如“奥林匹克运动会”和“奥运会”是正样本,而“奥林匹克运动会”和“亚运会”则是负样本。该任务本质上是文本二分类,输入是一对中文文本,而Bert的输入也是[CLS]...[SEP]...[SEP]
形式的一对文本,因此引入Bert预训练模型再结合本任务特定的全简称正负样本进行微调。预训练模型类似一个人已经学习了语言的基础语法和语义,微调相当于在这个基础上再单独学习全称简称命名这一领域的语言知识。
开发环境准备,预训练模型模型下载
本节基于Huggingface的transformers和Pytorch进行开发,依赖包版本如下
transformers 4.24.0
torch 1.12.1+cu113
transformers框架提供了基于预训练模型进行算法开发的标准流程范式,提供了统一的API,包括调用各种预训练模型,文本编码,数据转换抽取,模型搭建,训练测试评价等,使得代码开发更加高效和标准化。
本节中使用transformers框架调用bert-base-chinese预训练模型,登陆Huggingface官网手动下载到本地官网地址
分别下载五个文件,每个文件各自的作用如下
- config.json:Bert模型内部结构的配置信息,包括隐藏层大小,注意力头数,encoder层数,dropout比率等,transformers中BertModel需要该文件来倒入预训练模型,BertConfig需要该文件来倒入预训练模型的配置字典
- pytorch_model.bin:PyTorch框架中用于保存模型权重的二进制文件,Bert预训练的参数结果保存在该文件中,transformers中BertModel需要该文件来倒入预训练模型
- tokenizer.json:记录了分词器属性信息,包括了truncation, padding,added_tokens,normalizer,pre_tokenizer,post_processor,decoder等属性
- tokenizer_config.json:记录了分词器的配置信息
- vocab.txt:词汇表,该预训练模型涉及到的所有字,transformers中BertTokenizer需要该文件来倒入分词词表
下载之后放到一个文件目录下比如./model/bert_base_chinese,工程目录如下
.
├── bin
├── data
├── etc
│ ├── config.yml
├── logs
├── model
│ ├── bert_base_chinese
│ │ ├── config.json
│ │ ├── pytorch_model.bin
│ │ ├── tokenizer_config.json
│ │ ├── tokenizer.json
│ │ └── vocab.txt
├── README.md
├── requirements.txt
├── src
│ ├── main
│ │ ├── model_pth_hugging_face.py
│ └── utils
transformers中文编码预处理
首先定义数据,继承PyTorch的Dataset类,从而方便完成后续的数据tensor转换,数据迭代器生成等操作。
class TrainData(Dataset):
def __init__(self, path, upper_sample=False):
self.data = pickle.load(open(os.path.join(ROOT_PATH, path), "rb"))
if upper_sample:
self.data = self.sample()
def sample(self): # 上采样
positive = [x for x in self.data if x.label[1] == 1]
negative = [x for x in self.data if x.label[1] == 0]
if len(positive) > len(negative):
total_num = len(positive) * 2
upper = negative
else:
total_num = len(negative) * 2
upper = positive
while len(self.data) < total_num:
self.data.extend(upper)
self.data = self.data[:total_num]
return self.data
def __len__(self):
return len(self.data)
def __getitem__(self, item):
return self.data[item].fullname, self.data[item].shortname, self.data[item].co_vector, self.data[item].label
TrainData类在实例化之后通过索引输出一对全简称,统计特征和label样本,例如
train_data = TrainData("./data/train.pkl", upper_sample=True)
>>> train_data[0]
('奥林匹克运动会', '奥运会', [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], [0, 1])
下一步定义数据整理函数,目的是将Dataset的一行特征转化为每个列的特征组,以及完成Bert的分词编码输出PyTorch的tensor格式。
from transformers import BertModel, BertTokenizer
TOKENIZER = BertTokenizer.from_pretrained(os.path.join(ROOT_PATH, "./model/bert_base_chinese"))
def collate_fn(data):
name_pairs = []
co_vectors = []
labels = []
for d in data:
name_pairs.append((d[0], d[1]))
co_vectors.append(d[2])
labels.append(0 if d[3][0] == 1 else 1)
data = TOKENIZER.batch_encode_plus(batch_text_or_text_pairs=name_pairs, truncation=True, padding="max_length",
max_length=50, return_tensors="pt")
input_ids = data["input_ids"].to(DEVICE)
attention_mask = data["attention_mask"].to(DEVICE)
token_type_ids = data["token_type_ids"].to(DEVICE)
co_vectors = torch.tensor(co_vectors).to(DEVICE)
labels = torch.LongTensor(labels).to(DEVICE)
return input_ids, attention_mask, token_type_ids, co_vectors, labels
在整理函数collate_fn中使用transformers导入预训练模型将样本分词编码的方法和预训练Bert一样,在batch_encode_plus中输入[(A, B),(A, B)...]的一对对样本数据,自定义句子的最大长度截取逻辑,以tensor的形式进行输出,例如将奥林匹克运动会,奥运会这一对进行编码如下
>>> tmp = TOKENIZER.batch_encode_plus(batch_text_or_text_pairs=[("奥林匹克运动会", "奥运会")], truncation=True, padding="max_length", max_length=50, return_tensors="pt")
{'input_ids': tensor([[ 101, 1952, 3360, 1276, 1046, 6817, 1220, 833, 102, 1952, 6817, 833,
102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0]])}
分词器返回编码后的input_ids,token_type_ids,attention_mask,可以对input_ids进行反编码查看编码过程,编码器会自动将一对文本添加特殊字符处理成Bert的输入格式,然后返回每个字符在vocab.txt中的id位置
>>> TOKENIZER.decode(tmp['input_ids'][0])
'[CLS] 奥 林 匹 克 运 动 会 [SEP] 奥 运 会 [SEP] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] ...
下一步将数据和整理函数打包为一个PyTorch的DataLoader,定义batch_size和shuffle直接喂给模型训练。
train_loader = DataLoader(train_data, shuffle=True, batch_size=512, drop_last=False, collate_fn=collate_fn)
查看单个批次的输出数据如下,原始的字符串已经转化为分词id,一个批次为512个样本,每个输入最长50个字符。
for i, (input_ids, attention_mask, token_type_ids, co_vectors, labels) in enumerate(train_loader):
break
>>> input_ids
tensor([[ 101, 2813, 704, ..., 0, 0, 0],
[ 101, 4403, 3862, ..., 0, 0, 0],
[ 101, 704, 1744, ..., 0, 0, 0],
...,
[ 101, 704, 1744, ..., 0, 0, 0],
[ 101, 1744, 6084, ..., 0, 0, 0],
[ 101, 7270, 4510, ..., 0, 0, 0]], device='cuda:1')
>>> input_ids.shape
torch.Size([512, 50])
PyTorch模型搭建
使用PyTorch搭建模型继承nn.Module,在网络层引入本地下载好的预训练BertModel,同时在微调中加入其他的实体统计特征co_vectors和Bert的输出进行拼接,一起输入nn.ModuleList定义多层全连接层得到最终的模型输出
from transformers import BertModel, BertTokenizer
PRE_TRAIN = BertModel.from_pretrained(os.path.join(ROOT_PATH, "./model/bert_base_chinese"))
class Model(nn.Module):
def __init__(self, bert_hidden_size=768, co_vector_size=55, fc_hidden_size="256,64", dropout_rate=0.1):
super(Model, self).__init__()
self.pre_train = PRE_TRAIN
self.fc_hidden_size = [bert_hidden_size + co_vector_size] + list(map(int, fc_hidden_size.split(",")))
self.fc_layers = nn.ModuleList([nn.Linear(self.fc_hidden_size[i], self.fc_hidden_size[i + 1]) for i in
range(len(self.fc_hidden_size) - 1)])
self.linear = nn.Linear(self.fc_hidden_size[-1], 2)
self.relu = nn.ReLU()
self.drop = nn.Dropout(p=dropout_rate)
def forward(self, input_ids, attention_mask, token_type_ids, co_vectors):
# [None, 768]
pre_train_out = self.pre_train(input_ids=input_ids, attention_mask=attention_mask,
token_type_ids=token_type_ids).pooler_output
# [None, 768 + 55]
concat = torch.concat([pre_train_out, co_vectors], dim=1)
# fc => [None, 64]
fc_out = concat
for i in range(len(self.fc_layers)):
fc_out = self.fc_layers[i](fc_out)
fc_out = self.relu(fc_out)
fc_out = self.drop(fc_out)
out = self.linear(fc_out)
prob = nn.Softmax(dim=1)(out) # [None, 2]
return out, prob
transformers通过一行代码引入Bert模型,将input_ids,attention_mask,token_type_ids直接输入,即可一行代码得到Bert的CLS池化输出,相比于上一节从Bert的源码进行微调方便的太多,可以将更多的精力聚焦在微调的网络结构本身还不是去探究Bert环节的实现。
下一步进行模型训练,设置最大样本复制15轮,在验证集10次不出现AUC上升则早停,通过transformers的get_scheduler设置学习衰减和学习率预热
model = Model().to(DEVICE)
epochs = 15
optimizer = Adam(model.parameters(), lr=0.0001, weight_decay=0.0)
scheduler = get_scheduler("linear", num_warmup_steps=10, num_training_steps=epochs * len(train_loader),
optimizer=optimizer)
criterion = nn.CrossEntropyLoss(reduction="mean")
step = 0
val_auc_list = []
early_stop_flag = False
for epoch in range(epochs):
for i, (input_ids, attention_mask, token_type_ids, co_vectors, labels) in enumerate(train_loader):
model.train()
optimizer.zero_grad()
output, prob = model(input_ids, attention_mask, token_type_ids, co_vectors)
loss = criterion(output, labels)
loss.backward()
optimizer.step()
scheduler.step()
step += 1
if step % 1 == 0:
auc = roc_auc_score(labels.cpu().detach().numpy(), output.cpu().detach().numpy()[:, 1])
print("epoch: {} step: {} loss: {} auc: {}".format(epoch + 1, step, loss.item(), auc))
if step % 10 == 0:
auc, good_recall, bad_recall, acc, loss = model_metrics(val_loader, model)
print("[evaluation] loss: {} 正确全简称的召回率: {} 错误全简称的召回率: {} 正确率: {} AUC: {}"
.format(loss, good_recall, bad_recall, acc, auc))
diff_auc = (auc - max(val_auc_list)) if len(val_auc_list) else 0
val_auc_list.append(auc)
print("本轮auc比之前最大auc{}:{}, 当前最大auc: {}\n".format("上升" if diff_auc > 0 else "下降", abs(diff_auc),
max(val_auc_list)))
if diff_auc > 0:
torch.save(model.state_dict(), os.path.join(ROOT_PATH, "./model/pth_model/model.pth"))
print("[save checkpoint]")
if early_stop_auc(val_auc_list, windows=10):
print("{:-^30}".format("early stop!"))
early_stop_flag = True
break
if early_stop_flag:
break
训练早停日志如下
epoch: 3 step: 246 loss: 0.330115407705307 auc: 0.9397805484762006
epoch: 3 step: 247 loss: 0.2885383367538452 auc: 0.9506531204644413
epoch: 3 step: 248 loss: 0.2339172214269638 auc: 0.9690625620352131
epoch: 3 step: 249 loss: 0.22491329908370972 auc: 0.9756562881562881
epoch: 3 step: 250 loss: 0.2437824159860611 auc: 0.9659361819182102
[evaluation] loss: 0.5039 正确全简称的召回率: 0.8096 错误全简称的召回率: 0.8154 正确率: 0.8125 AUC: 0.8902
本轮auc比之前最大auc下降:0.00990000000000002, 当前最大auc: 0.9001
---------early stop!----------
微调前后模型效果对比
对了三种模型策略,分别是
- 统计特征的Xgboost分类:通过手动构造实体间的相关统计指标来量化相似性,对实体是否有链接进行预测
- 统计特征和Bert结构不使用预训练模型:在统计特征的基础上,使用Bert的网络结构学习文本表征,随机初始化所有字的embedding,不依赖外部预训练模型,只通过该案例的样本进行训练
- 统计特征和Bert结构且使用bert-base-chinese预训练模型:使用外部大数据训练得到的预训练模型,在本样本上进行微调
三种算法策略的测试集模型指标如下
策略 | 正确链接识别率 | 错误连接识别率 | AUC |
---|---|---|---|
统计特征Xgboost | 0.774 | 0.560 | 0.757 |
统计特征 + 2层8头Bert | 0.751 | 0.838 | 0.886 |
统计特征 + 12层12头Bert + 预训练模型 | 0.899 | 0.733 | 0.903 |
仅通过统计相似度特征进行预测能达到一定的分类水平AUC为0.757,而加入Bert表征的字符特征后AUC上升13个点说明全简称有语义规律,再加入预训练模型AUC提升到0.903说明在外部大样本上预训练在特征任务上微调策略的有效性。
网友评论