发布时间:2022-12-18 21:00
Bert是按照两个任务进行预训练的,分别是遮蔽语言任务(Masked Language Model)和句子预测任务(NextSentence Prediction)。
对输入的语句中的字词 随机用 [MASK] 标签覆盖,然后模型对mask位置的单词进行预测。这个过程类似CBOW训练的过程,我们利用这个训练任务从而得到每个字符对应的embedding。特别的,[CLS]字符的embedding我们可以视为整个句子的embedding。我们可以理解为[CLS]字符跟句子中的其它字符都没有关系,能较为公平的考虑整个句子。
该任务就是给定一篇文章中的两句话,判断第二句话在文本中是否紧跟在第一句话之后。如果我们训练的时候将问题和答案作为上下句作为模型输入,该任务也可以理解为判断问题和答案是否匹配
Bert相当于N个transormer encoder,原作者给出的bert模型中有12层和24层两种。下面使用的是12层的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维度
position_embedding由bert生成,我们不用考虑。
现在我们根据代码看看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])
考虑全部隐藏层的第一个输出,然后进行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])
在本项目里面hidden_states和attention都不需要
我们的分类方案是在Bert添加一个全连接层作为分类器,以sigmoid作为激活函数,把每个神经元都当成是二元分类。
本项目语料采用kaggle上的toxic comments数据集,在输入模型之前需要对该数据集做一些预处理
对于一些常用的缩写,例如you’re,it’s等等,我们将它分开
例如:
replacement = {
"aren't": "are not",
"can't": "cannot",
"couldn't": "could not",
"didn't": "did not",
......
}
sentence_repl = sentence.replace(r" you re ", " you are ")
sentence_repl = sentence_repl.replace(r" we re ", " we are ")
sentence_repl = sentence_repl.replace(r" they re ", " they are ")
sentence_repl = sentence_repl.replace(r"@", "at")
sentence_repl = sentence_repl.replace(r"&", "and")
这里所谓的无用样本指的是长度太短的样本,它很难产生有效信息。我们设定的最小长度为2。所有长度小于2的样本不参与训练
本项目中的无用符号包括网址(http)、时间(例如UTC+09:00,dec 11, 2019)、IP地址、@后面的邮箱地址、换行符\r\n)等等
虽然在训练和测试数据集中没有看到有中文的句子,但是仍然考虑到应用场景下有出现中文侮辱词汇的可能性
所以也考虑了跟toxic comment相关性强的几句中文的替换
因为这几句中文无法过审,所以在这儿就不写了
训练时,调用BERT预训练模型,输入包括三个部分。input_ids,token_type_ids,以及attention_mask
input_ids就是每个字符在字符表中的编号,101表示[CLS]开始符号,[102]表示[SEP]句子结尾分割符号。
token_type_ids是区分上下句的编码,上句全0,下句全1,用在Bert的句子预测任务上
attention_mask表示指定哪些词作为query进行attention操作,全为1表示self-attention,即每个词都作为query计算跟其它词的相关度
因此,在经过前面的预处理后,还要将文本转换成编码才能输入给bert模型。
在训练的时候,所有的样本都需要保持相同的长度以进行批量平行运算。超过最大长度的截掉,长度不够的用[PAD]符号填充,[PAD]的编码是数字0。segment_ids和attention_mask的长度都会跟随文本变化
def get_input_ids(self, x):
input_ids = self.tokenizer.encode(x)
if len(input_ids) > self.max_seq_length:
input_ids = [input_ids[0]] + input_ids[1:self.max_seq_length - 1] + [input_ids[-1]]
else:
input_ids = input_ids + [0] * (self.max_seq_length - len(input_ids))
return input_ids
另外,对于多标签文本分类任务来说,segment_ids和attention_mask没有意义,所以前者为全0,后者为全1即可
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)
我们在构建Bert的迁移训练模型的时候,最好继承BertPreTrainedModel,这样就可以很方便的使用from_pretrained和save_pretrained加载和保存模型。
我们构建的模型以BERT作为特征提取器,输出的pooled output作为分类器的特征输入。同时我们采用了dropout层以防止过拟合。
按照前面所介绍的方案,因为本项目中一共有六个标签,[“toxic”, “severe_toxic”, “obscene”, “threat”, “insult”, “identity_hate”]。所以最终全连接层的输出是6,输入是pooled output的特征维度,即hidden_size:786
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)
因为定义模型结构的时候已经把sigmoid定义到了模型结构中,因此损失函数不必考虑sigmoid,并且我们训练的的n个二元分类器,所以损失函数采用的是二元的交叉熵损失函数,在pytorch的nn模块里定义是BCELoss()
loss = nn.BCELoss()
Bert原模型训练时采用的优化器是AdamW,AdamW是在Adam+L2正则化的基础上进行改进的算法。
使用Adam优化带L2正则的损失并不有效。如果引入L2正则项,在计算梯度的时候会加上对正则项求梯度的结果。因此在Adam上,权重衰减和L2正则并不等同,故而AdamW就是一种Adam与权重衰减结合的产物。
因为我们只训练分类器,所以在这采用Adam优化器
利用pytorch定义优化器如下,学习率设为0.01
optimizer = optim.Adam(model.parameters(),lr=lr,eps=adam_epsilon)
训练集
验证集
损失函数仍然采用二元交叉熵损失函数
使用跟Bert预训练一致的AdamW优化器,初始学习率lr定义为2e-5
optimizer = optim.AdamW(optimizer_grouped_parameters, lr=lr, eps=adam_epsilon)
在一些稍完整的项目中,在模型训练时会做更多的考虑。比如如何节省显存(混合精度训练),如何防止梯度爆炸(梯度裁剪),如何增大batch_size却不增加显存占用(梯度累积)等等
在本项目中采用了梯度裁剪、学习率预热、权重衰减
grad_clip为梯度阈值,设为1,超过1的梯度会强制设为1。weight_decay 设为 0.01
在每次反向传播计算梯度后,都要执行下方的语句裁剪梯度
clip_grad_norm_(model.parameters(), grad_clip)
权重衰减是很常见的防止过拟合的训练策略,通过惩罚参数让模型更稳定平滑。在模型中,除bias和layernorm之外的参数均设定了weight-decay,为0.01
对参数做权重衰减是为了使函数平滑,然而bias和layernorm的权重参数不影响函数的平滑性。他们起到的作用仅仅是缩放平移,因此不需要权重衰减
param_optimizer = list(model.named_parameters())
no_decay = ['bias', 'LayerNorm.weight']
optimizer_grouped_parameters = [
{'params': [p for n, p in param_optimizer if not any(nd in n for nd in no_decay)],
'weight_decay': weight_decay},
{'params': [p for n, p in param_optimizer if any(nd in n for nd in no_decay)], 'weight_decay': 0.0}
]
由于刚开始训练时,模型的权重是随机初始化的,此时若选择一个较大的学习率,可能带来模型的不稳定(振荡),选择Warmup预热学习率的方式,可以使得开始训练的几个epoches或者一些steps内学习率较小,在预热的小学习率下,模型可以慢慢趋于稳定,等模型相对稳定后再选择预先设置的学习率进行训练,使得模型收敛速度变得更快,模型效果更佳。
项目中设定的warmup_proportion为0.1,按step进行预热
warmup_proportion = 0.1
warmup_steps = int(t_total * warmup_proportion)
optimizer = optim.AdamW(optimizer_grouped_parameters, lr=lr, eps=adam_epsilon)
scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=warmup_steps, num_training_steps=t_total)
训练集
验证集
对多标签文本分类,我们常用的标准有准确率,精确率(查准率),查全率,hamming loss,AUC score
a c c u r a c y = T P + T N T P + T N + F P + F N accuracy = \frac{TP+TN}{TP+TN+FP+FN} accuracy=TP+TN+FP+FNTP+TN
在预测为正的样本中实际为正所占的比例
p r e c i s i o n = T P T P + F P precision = \frac{TP}{TP+FP} precision=TP+FPTP
在实际为正的样本中预测为正所占的比例
r e c a l l = T P T P + F N recall = \frac{TP}{TP+FN} recall=TP+FNTP
precision和recall的综合考虑
F 1 _ s c o r e = 2 ∗ p r e c i s i o n ∗ r e c a l l p r e s i o n + r e c a l l F1\_score = \frac{2*precision*recall}{presion+recall} F1_score=presion+recall2∗precision∗recall
hamming loss衡量的是预测与实际的偏离程度。例如,假设我们实际的样本的多标签分类为1,0,1,而预测的样本分类为1,1,0。那么hamming loss = 2/3,当有多个样本的时候,所有样本的hamming loss取平均即可
在了解AUC之前必须先了解什么ROC曲线
混淆矩阵
预测为正 | 预测为负 | |
---|---|---|
实际为正 | TP | FN |
实际为负 | FP | TN |
ROC的横轴为在所有实际不是某类的样本中,被错误的预测为该类的比例FPR,即
F P R = F P F P + T N FPR = \frac{FP}{FP+TN} FPR=FP+TNFP
纵轴为在所有实际为某一分类的样本中,被正确地预测为该分类之比率TPR,即
T P R = T P T P + F N TPR = \frac{TP}{TP+FN} TPR=TP+FNTP
由于预测的分类不仅仅取决于输入,还需要我们设定一个阈值,例如设置阈值为0.5,输出小于0.5个我们认为不是该分类,大于0.5是该分类。或者设置阈值为1,那么所有的样本都会被预测为不是该分类。
于是随着阈值设置的变化,FPR,TPR也跟着变化,从而形成了ROC曲线。根据TPR和FPR的定义,我们显然希望TPR越大越好,希望FPR越小越好。即ROC曲线越靠近左上角越好。这个靠近的程度我们用ROC曲线下的面积即AUC来衡量,AUC越大表示模型越好。
跟预想的基本一致,第一个方案,冻结了大部分层,参数少,从而计算量小,节省显存,可以设置更大的batch。同时,因为只训练最后一层参数以及分类器参数,因此可以采用更大的学习率,从而收敛更快。
第一个方案的每轮训练时间是12分钟左右,验证时间3分钟左右,4轮训练总时长约60分钟
第二个方案的每轮训练时间是36分钟左右,验证时间3分钟左右,4轮训练总时长约160分钟
方案一模型准确率为96%,方案二模型准确率为99%
方案一模型得分0.76057,方案二模型得分0.97910,如下为kaggle得分截图