参考链接
BERT模型的详细介绍
图解BERT模型:从零开始构建BERT
(强推)李宏毅2021春机器学习课程
我们现在来说,怎么把Bert应用到多标签文本分类的问题上。注意,本文的重点是Bert的应用,对多标签文本分类的介绍并不全面
对应单标签文本分类来说,例如二元的文本分类,我们首先用一层或多层LSTM提取文本序列特征,然后接一个dropout层防止过拟合,最后激活函数采用sigmoid,或者计算损失的时候使用sigmoid交叉熵损失函数。对于多元分类则激活函数采用softmax,其它没有差别
怎么从单标签分类问题拓展到多标签分类呢?
我们可以把二元分类的情况归并到多元分类
至少有以下两种方案(我懂的):
1,最后的全连接层以sigmoid作为激活函数,把每个神经元都当成是二元分类。另外,也可以直接把最后的全连接层改成n个全连接层,每个全连接层再接一个神经元做二元分类(激活函数是sigmoid),我认为二者本质上没有区别。
2,将多标签分类任务视作seq2seq的问题,对于给定的文本序列,生成不定长的标签序列。
这篇文章将介绍第一种方案。
首先我们先看看怎么使用Bert模型
下载transformers包,pip install transformers
如果是处理英文问题,并且不用统一大小写的话,可以按照下方链接下载
其次手动下载模型,下载bert-base-uncased
的config.josn,vocab.txt,pytorch_model.bin
三个文件
配置文件下载地址:https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-uncased-config.json
模型文件下载地址:https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-uncased-pytorch_model.bin
词汇表下载地址:https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-uncased-vocab.txt
下载完成后,按照config.json,vocab.txt,pytorch_model.bin重命名,放在bert-base-uncased文件夹下,此例中bert-base-uncased文件夹放置在项目根目录下
如果是处理中文任务,把链接中的bert-base-uncased替换成bert-base-chinese即可,存放文件夹名可根据习惯修改为相应模型的名称
下面的demo是基于中文bert演示的,真正的多标签分类的项目代码里用的是bert-base-uncased
import numpy as np
import torch
from transformers import BertTokenizer, BertConfig, BertForMaskedLM, BertForNextSentencePrediction
from transformers import BertModel
model_name = "bert-base-chinese"
# a. 通过词典导入分词器
tokenizer = BertTokenizer.from_pretrained(model_name)
# b. 导入配置文件
model_config = BertConfig.from_pretrained(model_name)
# 修改配置
model_config.output_hidden_states = True
model_config.output_attentions = True
# 通过配置和路径导入模型
bert_model = BertModel.from_pretrained(model_name, config = model_config)
完成模型加载后,我们来看看Bert的输入输出
假设我们输入了一句话是“我爱你,你爱我”,我们需要利用tokernizer做初步的embedding处理
sen_code = tokenizer.encode_plus("我爱你,你爱我")
得到的sen_code是这样的
{‘input_ids’: [101, 2769, 4263, 872, 102, 872, 4263, 2769, 102],
‘token_type_ids’: [0, 0, 0, 0, 0, 1, 1, 1, 1],
‘attention_mask’: [1, 1, 1, 1, 1, 1, 1, 1, 1]}
input_ids就是每个字符在字符表中的编号,101表示[CLS]开始符号,[102]表示[SEP]句子结尾分割符号。
token_type_ids是区分上下句的编码,上句全0,下句全1,用在Bert的句子预测任务上
attention_mask表示指定哪些词作为query进行attention操作,全为1表示self-attention,即每个词都作为query计算跟其它词的相关度
将input_ids转化回token
tokenizer.convert_ids_to_tokens(sen_code['input_ids'])
#output:['[CLS]', '我', '爱', '你', '[SEP]', '你', '爱', '我', '[SEP]']
Bert的输入是三个embedding的求和,token embedding,segment embedding和position embedding
# token embedding
tokens_tensor = torch.tensor([sen_code['input_ids']]) # 添加batch维度
# segment embedding
segments_tensors = torch.tensor([sen_code['token_type_ids']]) # 添加batch维度
Bert是按照两个任务进行预训练的,分别是遮蔽语言任务(MLM)和句子预测任务。
我先简单解释一下这两个任务
对输入的语句中的字词 随机用 [MASK] 标签覆盖,然后模型对mask位置的单词进行预测。这个过程类似CBOW训练的过程,我们利用这个训练任务从而得到每个字符对应的embedding。特别的,[CLS]字符的embedding我们可以视为整个句子的embedding。我们可以理解为[CLS]字符跟句子中的其它字符都没有关系,能较为公平的考虑整个句子。
该任务就是给定一篇文章中的两句话,判断第二句话在文本中是否紧跟在第一句话之后。如果我们训练的时候将问题和答案作为上下句作为模型输入,该任务也可以理解为判断问题和答案是否匹配
现在我们根据代码看看bert的输出
bert_model.eval()
with torch.no_grad():
outputs = bert_model(tokens_tensor, token_type_ids = segments_tensors)
encoded_layers = outputs # outputs类型为tuple
最后一个隐藏层的输出,即遮蔽语言任务的输出,亦即每个字符的embedding
print("sequence output",encoded_layers[0].shape)
# sequence output torch.Size([1, 9, 768])
最后一个隐藏层的第一个输出[CLS]的embedding,然后进行pool操作的结果,所谓的pool操作就是接一个全连接层+tanh激活函数层。它可以作为整个句子的语义表示,但也有将所有向量的平均作为句子的表示的做法
print("pooled output",encoded_layers[1].shape)
# pooled output torch.Size([1, 768])
所有隐藏层的输出,hidden_states有13个元素,第一个是[CLS]的embedding,后面12个元素表示12个隐藏层的输出,对于seq2seq的任务,它们将作为decoder的输入
print("hidden_states",len(encoded_layers[2]),encoded_layers[2][0].shape)
# hidden_states 13 torch.Size([1, 9, 768])
attention分布,有12个元素,每个隐藏层的hidden_states经过self-attention层得到的attention分布,没有乘以V矩阵。因为是multi-head,一共有12个头,所以每个attention分布的维度是1x12x9x9(1是batch_size,9是序列长度)
print("attentions",len(encoded_layers[3]),encoded_layers[3][0].shape)
# attentions 12 torch.Size([1, 12, 9, 9])
要明白上面的输出为什么是那个意思,还是得看源码Bert代码详解(一)
搞明白bert的输入输出之后我们就可以试着做fine-tune了,我们是要做多标签文本分类,根据第一个方案,我们首先提取出文本的特征,然后接全连接层,最后接一个sigmoid激活函数。
前面已经说过,pooled output就是表示bert得到的整个句子的语义特征,这正是我们需要的。将这个特征作为全连接层的输入即可。代码里面还定义了dropout层,这都是训练的常用技巧,防止过拟合
class BertForMultiLabel(BertPreTrainedModel):
def __init__(self, config):
super(BertForMultiLabel, self).__init__(config)
self.bert = BertModel(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(config.hidden_size, config.num_labels)
self.sigmoid = nn.Sigmoid()
def forward(self, input_ids, token_type_ids=None, attention_mask=None, head_mask=None):
outputs = self.bert(input_ids, token_type_ids,attention_mask,head_mask)
pooled_output = outputs[1]
pooled_output = self.dropout(pooled_output)
logits = self.classifier(pooled_output)
return self.sigmoid(logits)
def unfreeze(self, start_layer, end_layer):
def children(m):
return m if isinstance(m, (list, tuple)) else list(m.children())
def set_trainable_attr(m, b):
m.trainable = b
for p in m.parameters():
p.requires_grad = b
def apply_leaf(m, f):
c = children(m)
if isinstance(m, nn.Module):
f(m)
if len(c) > 0:
for l in c:
apply_leaf(l, f)
def set_trainable(l, b):
apply_leaf(l, lambda m: set_trainable_attr(m, b))
set_trainable(self.bert, False)
for i in range(start_layer, end_layer + 1):
set_trainable(self.bert.encoder.layer[i], True)
Bert原项目对训练使用了很多性能、显存消耗的优化技术,包括warmup,gradient accumulation,还有fp16,这些技术我暂时也没有全部搞懂,所以暂时抛弃部分优化技术,写一个最简单的优化器。AdamW是Bert预训练采用的优化算法,大家如果不懂可以去百度一下,我也不是很了解,所以就直接用了
# 定义超参数
batch_size = 8
lr = 2e-5
adam_epsilon = 1e-8
grad_clip = 1.0
start_layer = 11 #[0,11]
end_layer = 11 #[start_layer,11]
# 定义损失函数
loss = nn.BCELoss()
# 定义优化器
optimizer = optim.AdamW(model.parameter(), lr=lr, eps=adam_epsilon)
# 加载模型
model = BertForMultiLabel(config)
# 现在使用的Bert模型是12层,我们可以自由调节冻结bert模型的层数,当前是只训练最后一层
model.unfreeze(start_layer, end_layer)
model = model.cuda()
一个模型想要跑起来必然需要数据输入,Bert对参与训练的数据格式要求为input_ids, input_mask, segment_ids, label_ids。而原始的数据格式为string,label_ids
所以我们需要对数据做一些处理,为此我们定义一个BertProcessor类,这个类的主要方法为read_dataset和train_val_split。
注意我现在的做法和那些好的做法有很多差别,那些好的做法是基于优化的考虑,但我们现在暂时不用考虑这么多,把重心放在bert的使用和模型的成功训练上,优化做法读者可进一步研究。
先看类中部分代码,完整项目在最后
class BertProcessor:
def __init__(self, vocab_path, do_lower_case, max_seq_length) -> None:
self.tokenizer = BertTokenizer(vocab_path, do_lower_case)
self.max_seq_length = max_seq_length
def get_input_ids(self, x):
# 使用tokenizer对字符编码
# 并将字符串填充或裁剪到max_seq_length的长度
...
def get_label_ids(self, x):
# 合并标签为一个list
...
def read_dataset(self, file_path, train=True):
data = pd.read_csv(file_path)
if train:
data['label_ids'] = data.iloc[:, 2:].apply(self.get_label_ids, axis=1)
label_ids = torch.tensor(list(data['label_ids'].values))
# 英文预处理,包括去除停用词,大小写转换,删除无关字符,拆解单词等等
preprocessor = EnglishPreProcessor()
tqdm.pandas(desc="english preprocess")
data['comment_text'] = data['comment_text'].progress_apply(preprocessor)
# 对每一个comment_text做encode操作
tqdm.pandas(desc="convert tokens to ids")
data['input_ids'] = data['comment_text'].progress_apply(self.get_input_ids)
input_ids = torch.tensor(list(data['input_ids'].values), dtype=torch.int)
input_mask = torch.ones(size=(len(data), self.max_seq_length), dtype=torch.int)
segment_ids = torch.zeros(size=(len(data), self.max_seq_length), dtype=torch.int)
if train:
dataset = Data.TensorDataset(input_ids, input_mask, segment_ids, label_ids)
else:
dataset = Data.TensorDataset(input_ids, input_mask, segment_ids)
return dataset
我想如果前面输入输出部分大家看懂的话,read_dataset函数很容易看懂
有几点需要注意一下,为了使用gpu,需要调用cuda方法将数据转移到gpu上,然后在反向传播计算梯度后,需要做一个梯度裁剪,即当梯度超过grad_clip的时候就把梯度设为grad_clip
def train(model, train_iter, valid_iter, n_epoch, loss, optimizer):
history = {
'train_loss': [],
'val_loss': [],
'val_acc': []
}
for epoch in range(n_epoch):
train_loss, n = 0.0, 0
for input_ids, input_mask, segment_ids, label_ids in tqdm(train_iter):
input_ids = input_ids.cuda()
input_mask = input_mask.cuda()
segment_ids = segment_ids.cuda()
logits = model(input_ids, segment_ids, input_mask)
l = loss(logits, label_ids.float().cuda())
l.backward()
clip_grad_norm_(model.parameters(), grad_clip)
optimizer.step()
optimizer.zero_grad()
train_loss += l.item()
train_loss = train_loss/n
val_loss, val_acc, n = 0.0, 0.0, 0
with torch.no_grad():
for input_ids, input_mask, segment_ids, label_ids in valid_iter:
input_ids = input_ids.cuda()
input_mask = input_mask.cuda()
segment_ids = segment_ids.cuda()
logits = model(input_ids, segment_ids, input_mask)
label_ids = label_ids.float().cuda()
l = loss(logits, label_ids)
val_loss += l.item()
val_acc += (torch.where(logits > 0.5, 1, 0) == label_ids).min(axis=1)[0].sum()
n += len(label_ids)
val_acc = val_acc / n
val_loss = val_loss / n
print("epoch %s train loss:%s val loss:%s" % (epoch + 1, train_loss, val_loss))
history['train_loss'].append(train_loss)
history['val_acc'].append(val_acc)
history['val_loss'].append(val_loss)
# save model checkpoint
model.save_pretrained("models%s" % (epoch + 1))
return history
把损失曲线和准确率曲线绘制出来就是这样
plt.plot(range(len(history['train_loss'])), history['train_loss'], label="train loss")
plt.show()
plt.plot(range(len(history['val loss'])), history['val loss'], label="val loss")
plt.show()
plt.plot(range(len(history['val acc'])), history['val_acc'])
plt.show()
暂时没图,待添加。。。
事实上,当我们评估多标签分类的模型的时候,前面只考虑了总体的Accuracy这个指标,但是还有很多更详细的metric需要考虑。这个也交给大家去查阅资料吧。
为了便于理解,前面的实现非常的粗糙,摒弃了很多好的优化策略。大家可以看看这个repo里面的实现,我就是参考的这个代码。代码里面在读取数据时设置了缓存机制,方便再次运行的时候快速读取数据,然后模型保存,日志输出,训练性能显存优化,模型评估等方面都有更好的处理。
至于我这份代码,后续应该会逐渐改进,如果大家有需要可以评论或私信留下邮箱地址。
补充:没想到要代码的人还有点多,发到评论区置顶了