赞
踩
参考链接
我们现在来说,怎么把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里面的实现,我就是参考的这个代码。代码里面在读取数据时设置了缓存机制,方便再次运行的时候快速读取数据,然后模型保存,日志输出,训练性能显存优化,模型评估等方面都有更好的处理。
至于我这份代码,后续应该会逐渐改进,如果大家有需要可以评论或私信留下邮箱地址。
补充:没想到要代码的人还有点多,发到评论区置顶了
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。