datawhale 8月学习——NLP之Transformers:BERT的应用、训练和优化

前情回顾

  1. attention和transformers
  2. BERT和GPT
  3. 编写BERT模型

结论速递

接着上一个任务,本次继续阅读HuggingFace的BERT源码,进一步熟悉了BERT在预训练和微调阶段的应用。分别是预训练阶段的MLM任务和NSP任务,以及微调阶段的一些具体的NLP任务。在教程基础上,还补充了关于导入pre_trained模型的一些内容。
随后,进一步了解了BERT在训练过程中,分别在预训练和微调阶段的一些策略。
需要注意的是,本章依然是一个源码阅读章节,所进行的示例是为了更好地理解代码的组成和运行逻辑。

1 简介

在上一个任务编写BERT模型中,我们了解了HuggingFace的BERT代码中的核心类BertModel,这个类别完成了一个基本BertModel的搭建,但我们仍然不知道如何去应用它。

本任务的开始,我们先了解BERT代码中的其它类(因为BertModel还不足以直接应用到模型中),接着再了解BERT的训练是如何进行的。

也即,包含以下内容:

  1. BERT-based Models
    1. BERT 预训练模型
    2. BERT 微调模型(解决具体的NLP任务)
  2. BERT的训练和优化

与上一任务一致,代码基于Transformers版本4.4.2,运行时,需要保证transformers.__version__ = 4.4.2

2 BERT-based Models——预训练

2.1 功能简介

在前面的任务BERT和GPT中我们提到过,BERT在预训练阶段通过两个任务来获取上下文信息:

  1. 遮罩语言模型(Mask language model,MLM)
    在句子中随机用[MASK]替换掉一部分单词,然后将句子传入BERT中,编码信息。在这个任务中需要完成的是,预测[MASK]位置正确单词是什么。这一任务旨在训练模型根据上下文理解单词的意思。(训练损失是平均遮罩LM概率)
  2. 下一句预测(Next Sentence Prediction,NSP)
    采样句子对(A、B),其中B是A下文的比例是50%,将句子对(A、B)输入BERT,使用 [CLS]的编码信息 进行预测 B 是否 A 的下一句。这一任务旨在训练模型理解预测句子间的关系。(训练损失是平均下文预测概率)

下图很好地表示了这两个任务
请添加图片描述
在代码中,完成这两个任务的模型是BertForPreTraining(这么说其实不准确,完成mask操作,以及句子对生成的并不是)

由于涉及到的类比较多,下图小结了涉及到的类及使用关系。
在这里插入图片描述

2.2 BertForPreTraining

观察构成的时候主要看初始化和forward方法。

可以看出,BertForPreTraining主要由两部分组成BertModelBertPreTrainingHeads
在这里插入图片描述
forward方法中:
在这里插入图片描述
注意到BertModel的输出是(sequence_output, pooled_output) + encoder_outputs[1:],那么所取得输出就是sequence_output, pooled_output

从这个地方看BertPreTrainingHeads应该实现了[MASK]位置和[CLS]位置的进一步处理。我们待会儿在BertPreTrainingHeads中仔细看看。

然后计算损失
在这里插入图片描述
所以mask_lm_loss为什么要把[CLS]位置的也放进去?因为输入的label也包含了[CLS]的信息。

根据教程,MLM任务的标签(对应labels)是这样处理的:

  • 形状为[batch_size, seq_length],这里对于原本未被遮盖的词设置为-100,被遮盖词才会有它们对应的id,和任务设置是反过来的。
    • 例如,原始句子是I want to [MASK] an apple,这里把单词eat给遮住了输入模型,对应的label设置为[-100, -100, -100, 【eat对应的id】, -100, -100]
    • 为什么要设置为-100 而不是其他数?因为torch.nn.CrossEntropyLoss默认的ignore_index=-100,也就是说对于标签为100 的类别输入不会计算loss

根据教程,NSP任务的标签(对应next_sentence_label)就是简单的0,1的二分类标签。

训练的总损失是total_loss是两个损失得加和,就是两个都要小…(这是干啥)

2.3 系列套娃

2.3.1 BertPreTrainingHeads

让我们来到BertPreTrainingHeads中一探究竟,妹想到,它又往下套了一层BertLMPredictionHead和一个线性层nn.Linear
在这里插入图片描述
forward方法可以看出,BertLMPredictionHead用于解决MLM任务,而线性层用于解决NSP任务。

顺便在源码中发现这个类有两个邻居BertOnlyMLMHeadBertOnlyNSPHead,分别对应单独解决MLM和NSP两个任务
在这里插入图片描述

2.3.2 BertLMPredictionHead

接下来让我们前来探寻完成MLM任务的这个类,但是妹想到,它还是个套娃。处理分为两步,第一步是BertPredictionHeadTransform完成transform,第二步是使用线性层完成decode。
在这里插入图片描述
在这个类用于预测[MASK]位置的输出在每个词作为类别的分类输出:

  • 该类重新初始化了一个全0 向量作为预测权重的bias
  • 输出形状为[batch_size, seq_length, vocab_size],即预测每个句子每个词是什么类别的概率值(注意这里没有做softmax)

2.3.3 BertPredictionHeadTransform

这个类终于套娃完了,完成了一些线性变换,就是一个线性层+激活函数+layer normalization。
在这里插入图片描述

2.4 应用

除了BertForPreTraining之外,这份代码里面也包含了对于只想对单个目标进行预训练的BERT 模型(具体细节不作展开):

  • BertForMaskedLM:只进行MLM 任务的预训练
    • 基于BertOnlyMLMHead,而后者也是对BertLMPredictionHead的另一层封装;
  • BertLMHeadModel:这个和上一个的区别在于,这一模型是作为decoder 运行的版本
    • 同样基于BertOnlyMLMHead
  • BertForNextSentencePrediction:只进行NSP 任务的预训练
    • 基于BertOnlyNSPHead,内容就是一个线性层。

2.4.1 加载预训练模型

在进行应用之前,我们先了解一个类transformers.modeling_utils.PreTrainedModel
这个类是所有预训练模型的父类,而应用模型又是预训练模型的子类,因为继承了这个类的一个重要方法from_pretrained,这个方法使得我们可以加载已经训练好的模型。

在加载的过程中,会先检索本地是否已保存,如果有,则直接加载,否则会在hugging face进行下载。
在这里插入图片描述
其中cached_path函数负责加载模型,cached_path函数又调用了get_from_cache函数,完成下载操作,下载缓存在cache_dir中,如果未指定,则默认在hf_cache_home = os.path.expanduser( os.getenv("HF_HOME", os.path.join(os.getenv("XDG_CACHE_HOME", "~/.cache"), "huggingface")) )下的transformers里头。
在这里插入图片描述在这里插入图片描述
我们所加载的模型,比如bert-base-uncased,它的所有文件都在这里https://huggingface.co/bert-base-uncased/tree/main
在这里插入图片描述
我们来实际导入一个预训练模型bert-base-uncased

from transformers import BertForPreTraining
model = BertForPreTraining.from_pretrained('bert-base-uncased')

可以查看这个模型的信息

model._modules

出来一个OrderedDict,显示包含一个12层BertLayer的Encoder,和一层BertPooler。并且完成了预训练任务。
在这里插入图片描述

2.4.2 BertForPreTraining

我们加载一个预训练模型bert-base-uncased,来看看BertForPreTraining的运作。

from transformers import BertTokenizer, BertForPreTraining
import torch
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertForPreTraining.from_pretrained('bert-base-uncased')

使用句子”Hello, my dog is cute“作为输入。

inputs = tokenizer("Hello, my dog is cute", return_tensors="pt")
outputs = model(**inputs)
prediction_logits = outputs.prediction_logits
seq_relationship_logits = outputs.seq_relationship_logits

可以看到对应MLM任务的输出,prediction_logits

tensor([[[ -7.8962,  -7.8105,  -7.7903,  ...,  -7.0694,  -7.1693,  -4.3590],
         [ -8.4461,  -8.4401,  -8.5044,  ...,  -8.0625,  -7.9909,  -5.7160],
         [-15.2953, -15.4727, -15.5865,  ..., -12.9857, -11.7038, -11.4293],
         ...,
         [-14.0628, -14.2535, -14.3645,  ..., -12.7151, -11.1621, -10.2317],
         [-10.6576, -10.7892, -11.0402,  ..., -10.3233, -10.1578,  -3.7721],
         [-11.3383, -11.4590, -11.1767,  ...,  -9.2152,  -9.5209,  -9.5571]]],
       grad_fn=<AddBackward0>)

还有NSP任务的输出seq_relationship_logits

tensor([[ 3.3474, -2.0613]], grad_fn=<AddmmBackward>)

由于在上述任务中,我们没有输入label和sequence label,所以是得不到loss的。

2.4.3 BertLMHeadModel

我们再调用BertLMHeadModel来看看MLM任务的计算结果。

先导入模型

from transformers import BertTokenizer, BertLMHeadModel, BertConfig
import torch
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
config = BertConfig.from_pretrained("bert-base-uncased")
config.is_decoder = True
model = BertLMHeadModel.from_pretrained('bert-base-uncased', config=config)

我们先看看把整个句子输入的效果。

inputs = tokenizer("Hello, my dog is cute", return_tensors="pt")
outputs = model(**inputs)
prediction_logits = outputs.logits

输出的prediction_logits大小是torch.Size([1, 8, 30522]),即[batch_size, seq_length, vocab_size]

tensor([[[ -6.3390,  -6.3664,  -6.4600,  ...,  -5.5354,  -4.1787,  -5.8384],
         [ -6.0605,  -6.0980,  -6.1492,  ...,  -5.0190,  -3.6619,  -5.6481],
         [ -6.2835,  -6.1857,  -6.2198,  ...,  -5.8243,  -3.9650,  -4.2239],
         ...,
         [ -8.6994,  -8.6061,  -8.6930,  ...,  -8.4026,  -7.0615,  -6.1120],
         [ -7.7221,  -7.7373,  -7.7094,  ...,  -7.6440,  -6.1568,  -5.5106],
         [-13.5756, -13.0523, -12.9125,  ..., -10.4893, -11.9085,  -9.3556]]],
       grad_fn=<AddBackward0>)

我们把句子“Hello, my dog is cute”中的一个词mask掉“Hello, my dog [MASK] cute”,再看看效果,此时需要生成label

inputs = tokenizer("Hello, my dog [MASK] cute", return_tensors="pt")
inputs2 = tokenizer("Hello, my dog is cute", return_tensors="pt")
label_id = inputs['input_ids'] == inputs2['input_ids']
label = inputs2['input_ids'].clone()
label[label_id] = -100
outputs = model(**inputs,labels=label)
prediction_logits = outputs.logits
loss = outputs.loss

可以看到,此时的prediction_logits

tensor([[[ -6.3390,  -6.3664,  -6.4600,  ...,  -5.5354,  -4.1787,  -5.8384],
         [ -6.0605,  -6.0980,  -6.1492,  ...,  -5.0190,  -3.6619,  -5.6481],
         [ -6.2835,  -6.1857,  -6.2198,  ...,  -5.8243,  -3.9650,  -4.2239],
         ...,
         [ -7.4593,  -7.4944,  -7.4191,  ...,  -7.1641,  -5.6020,  -5.0584],
         [ -6.1402,  -6.2198,  -6.2164,  ...,  -6.1829,  -4.8028,  -4.7763],
         [-13.4772, -12.8823, -12.7612,  ..., -10.4617, -11.6630,  -9.5790]]],
       grad_fn=<AddBackward0>)

loss

tensor(6.2618, grad_fn=<NllLossBackward>)

其实单一词的预测效果挺不好的

prediction_logits.view(-1,config.vocab_size).argmax(dim=1)
[OUT]: tensor([1998, 1998, 1010, 1998, 1010, 1010, 1998, 1012])

对比原句

tensor([  101,  7592,  1010,  2026,  3899,  2003, 10140,   102])

差的挺多

2.4.4 BertForNextSentencePrediction

我们再调用BertForNextSentencePrediction来看看NCP任务的计算结果。

先导入模型

from transformers import BertTokenizer, BertForNextSentencePrediction
import torch
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertForNextSentencePrediction.from_pretrained('bert-base-uncased')

我们以句子"In Italy, pizza served in formal settings, such as at a restaurant, is presented unsliced.",和"The sky is blue due to the shorter wavelength of blue light."为例。且确定label为1(此处的label对应next_sentence_label

encoding = tokenizer(prompt, next_sentence, return_tensors='pt')
outputs = model(**encoding, labels=torch.LongTensor([1]))
logits = outputs.logits

则输出的logits

tensor([[-3.0729,  5.9056]], grad_fn=<AddmmBackward>)

可以看到logits[0, 0] < logits[0, 1],对应的loss

tensor(0.0001, grad_fn=<NllLossBackward>)

3 BERT-based Models——微调

3.1 功能简介

在前面的任务BERT和GPT中我们提到过,BERT可以完成下面四种微调(FineTuning)任务
在这里插入图片描述
包含两大类

  1. 文本分类,看[CLS]的状态
  2. 问答,两个文本分布

在这次任务中,主要介绍四种任务:

  1. 句子分类
  2. 多项选择
  3. 序列标注(词分类)
  4. 问答任务

3.2 句子分类:BertForSequenceClassification

3.2.1 BertForSequenceClassification

句子分类的输入为句子(对),输出为单个分类标签。

结构上非常简单,是一个BertModel过一个dropout然后再接一个线性层输出分类。
在这里插入图片描述
再看forward方法,是拿pooling后得到的结果,去进行后续的计算。
在这里插入图片描述
loss的计算部分,会需要传入labels输入,如果num_labels = 1,就默认是回归任务,用MSELoss计算,否则是分类任务(分为单分类和多分类)。
在这里插入图片描述

3.2.2 应用

先导入模型,这里我们导入的是,微调模型bert-base-cased-finetuned-mrpc

from transformers.models.bert.tokenization_bert import BertTokenizer
from transformers.models.bert.modeling_bert import BertForSequenceClassification
tokenizer = BertTokenizer.from_pretrained("bert-base-cased-finetuned-mrpc")
model = BertForSequenceClassification.from_pretrained("bert-base-cased-finetuned-mrpc")

我们要完成的任务是,两个输入的句子是否表达的是同一个意思is paraphrase表示是的,not paraphrase则表示不是。

准备的示例是三个句子,可以看到这三个句子,用词有比较大的不同。

classes = ["not paraphrase", "is paraphrase"]

sequence_0 = "The company HuggingFace is based in New York City"
sequence_1 = "Apples are especially bad for your health"
sequence_2 = "HuggingFace's headquarters are situated in Manhattan"

我们取句子0和2为一个句子对(是同一个意思),再取句子0和1为一个句子对(不是同一个意思)。

paraphrase = tokenizer(sequence_0, sequence_2, return_tensors="pt")
not_paraphrase = tokenizer(sequence_0, sequence_1, return_tensors="pt")

paraphrase_classification_logits = model(**paraphrase).logits
not_paraphrase_classification_logits = model(**not_paraphrase).logits

paraphrase_results = torch.softmax(paraphrase_classification_logits, dim=1).tolist()[0]
not_paraphrase_results = torch.softmax(not_paraphrase_classification_logits, dim=1).tolist()[0]

# Should be paraphrase
for i in range(len(classes)):
    print(f"{classes[i]}: {int(round(paraphrase_results[i] * 100))}%")

# Should not be paraphrase
for i in range(len(classes)):

则输出的结果是

not paraphrase: 10%
is paraphrase: 90%
not paraphrase: 94%
is paraphrase: 6%

效果还是很好的。即便是句0和2并不是简单的词语重组,也取得了很好的效果。

3.3 多项选择:BertForMultipleChoice

这一模型用于多项选择,如 RocStories/SWAG 任务。

init长得和前面几乎一模一样。
在这里插入图片描述
再看看forward。
在这里插入图片描述

  • 多项选择任务的输入为一组分次输入的句子,输出为选择某一句子的单个标签。 结构上与句子分类相似,只不过线性层输出维度为 1,即每次需要将每个样本的多个句子的输出拼接起来作为每个样本的预测分数。
  • 实际上,具体操作时是把每个 batch 的多个句子一同放入的,所以一次处理的输入为[batch_size, num_choices]数量的句子,因此相同 batch 大小时,比句子分类等任务需要更多的显存,在训练时需要小心。

3.4 序列标注:BertForTokenClassification

3.4.1 BertForTokenClassification

这个任务跟句子分类任务的处理很接近,不过不会是回归任务

init函数和前面的长得太像了
在这里插入图片描述
forward函数也是,loss的计算没有什么很特别的地方,但是需要注意的是,这里的输出用的不是pooling。
在这里插入图片描述

  • 序列标注任务的输入为单个句子文本,输出为每个 token 对应的类别标签。 由于需要用到每个 token对应的输出而不只是某几个,所以这里的BertModel不用加入 pooling 层;
  • 同时,这里将_keys_to_ignore_on_load_unexpected这一个类参数设置为[r"pooler"],也就是在加载模型时对于出现不需要的权重不发生报错。

3.4.2 应用

导入预训练好的模型dbmdz/bert-large-cased-finetuned-conll03-english"

from transformers import BertForTokenClassification, BertTokenizer
import torch

model = BertForTokenClassification.from_pretrained("dbmdz/bert-large-cased-finetuned-conll03-english")
tokenizer = BertTokenizer.from_pretrained("bert-base-cased")

要完成的任务的分类是专有名词类型的分类。

label_list = [
"O",       # Outside of a named entity
"B-MISC",  # Beginning of a miscellaneous entity right after another miscellaneous entity
"I-MISC",  # Miscellaneous entity
"B-PER",   # Beginning of a person's name right after another person's name
"I-PER",   # Person's name
"B-ORG",   # Beginning of an organisation right after another organisation
"I-ORG",   # Organisation
"B-LOC",   # Beginning of a location right after another location
"I-LOC"    # Location
]

输入句子"Hugging Face Inc. is a company based in New York City. Its headquarters are in DUMBO, therefore very close to the Manhattan Bridge."查看结果

sequence = "Hugging Face Inc. is a company based in New York City. Its headquarters are in DUMBO, therefore very close to the Manhattan Bridge."

# Bit of a hack to get the tokens with the special tokens
tokens = tokenizer.tokenize(tokenizer.decode(tokenizer.encode(sequence)))
inputs = tokenizer.encode(sequence, return_tensors="pt")

outputs = model(inputs).logits
predictions = torch.argmax(outputs, dim=2)

可以打印出每一个分词的预测结果:

for token, prediction in zip(tokens, predictions[0].numpy()):
    print((token, model.config.id2label[prediction]))

结果是

('[CLS]', 'O')
('Hu', 'I-ORG')
('##gging', 'I-ORG')
('Face', 'I-ORG')
('Inc', 'I-ORG')
('.', 'O')
('is', 'O')
('a', 'O')
('company', 'O')
('based', 'O')
('in', 'O')
('New', 'I-LOC')
('York', 'I-LOC')
('City', 'I-LOC')
('.', 'O')
('Its', 'O')
('headquarters', 'O')
('are', 'O')
('in', 'O')
('D', 'I-LOC')
('##UM', 'I-LOC')
('##BO', 'I-LOC')
(',', 'O')
('therefore', 'O')
('very', 'O')
('close', 'O')
('to', 'O')
('the', 'O')
('Manhattan', 'I-LOC')
('Bridge', 'I-LOC')
('.', 'O')
('[SEP]', 'O')

可以看到这个分类还是挺准确的。

3.5 问答任务:BertForQuestionAnswering

3.5.1 BertForQuestionAnswering

这一模型用于解决问答任务,例如 SQuAD 任务。

问答任务的输入为问题 +(对于 BERT 只能是一个)回答组成的句子对,输出为起始位置和结束位置用于标出回答中的具体文本。 这里需要两个输出,即对起始位置的预测和对结束位置的预测,两个输出的长度都和句子长度一样,从其中挑出最大的预测值对应的下标作为预测的位置。

简单来说就是确定一大段语句里面,哪一个分段是问题的答案。

由一个BertModel和一个线性层简单组合而成。
在这里插入图片描述
forward方法如下,model输出的是sequence_output,然后squeeze压缩掉维度,再contiguous获得连续的tensor(还需要再补充),获得起始点和终点。

loss的计算也是根据起始点和终点的位置来确定的(这个挺奇怪的其实)。

3.5.2 应用

导入预训练好的模型bert-large-uncased-whole-word-masking-finetuned-squad"

from transformers import AutoTokenizer, AutoModelForQuestionAnswering
import torch

tokenizer = AutoTokenizer.from_pretrained("bert-large-uncased-whole-word-masking-finetuned-squad")
model = AutoModelForQuestionAnswering.from_pretrained("bert-large-uncased-whole-word-masking-finetuned-squad")

先确定一个答案所在的字符串

text = "🤗 Transformers (formerly known as pytorch-transformers and pytorch-pretrained-bert) provides general-purpose architectures (BERT, GPT-2, RoBERTa, XLM, DistilBert, XLNet…) for Natural Language Understanding (NLU) and Natural Language Generation (NLG) with over 32+ pretrained models in 100+ languages and deep interoperability between TensorFlow 2.0 and PyTorch."

以及想要回答的问题。

questions = [
"How many pretrained models are available in 🤗 Transformers?",
"What does 🤗 Transformers provide?",
"🤗 Transformers provides interoperability between which frameworks?",
]

对提问逐一找解答,需要注意的是,这里的解答不涉及知识背景,只要符合语言逻辑就可以。

for question in questions:
    inputs = tokenizer(question, text, add_special_tokens=True, return_tensors="pt")
    input_ids = inputs["input_ids"].tolist()[0]
    outputs = model(**inputs)
    answer_start_scores = outputs.start_logits
    answer_end_scores = outputs.end_logits
    answer_start = torch.argmax(
        answer_start_scores
    )  # Get the most likely beginning of answer with the argmax of the score
    answer_end = torch.argmax(answer_end_scores) + 1  # Get the most likely end of answer with the argmax of the score
    answer = tokenizer.convert_tokens_to_string(tokenizer.convert_ids_to_tokens(input_ids[answer_start:answer_end]))
    print(f"Question: {question}")
    print(f"Answer: {answer}")

输出如下:

Question: How many pretrained models are available in 🤗 Transformers?
Answer: over 32 +
Question: What does 🤗 Transformers provide?
Answer: general - purpose architectures
Question: 🤗 Transformers provides interoperability between which frameworks?
Answer: tensorflow 2. 0 and pytorch

也还算合理。

4 BERT的训练和优化

4.1 预训练阶段

预训练阶段,除了众所周知的 15%、80% mask 比例,有一个值得注意的地方就是参数共享。 不止 BERT,所有 huggingface 实现的 PLM 的 word embedding 和 masked language model 的预测权重在初始化过程中都是共享的。

除此之外,一系列的mask操作以及句子对的组成,都不包含在此次阅读的代码中,需要另外实现。

4.2 微调阶段

使用的优化器是AdamW(AdamWeightDecayOptimizer),这个是在 Adam+L2 正则化的基础上进行改进的算法。

Adam在之前的笔记中有提及到,是基于RMSProp和Momentum的,也就是在平缓的地方快点走,在陡的地方慢点走,同时再加个动量。

AdamW和一般的 Adam+L2 正则化算法的区别如下图所示
请添加图片描述

通常,我们会选择模型的 weight 部分参与 decay 过程,而另一部分(包括 LayerNorm 的 weight)不参与(代码最初来源应该是 Huggingface 的示例)

另外,还使用了Warmup,即在训练初期使用较小的学习率(从 0 开始),在一定步数(比如 1000 步)内逐渐提高到正常大小(比如上面的 2e-5),避免模型过早进入局部最优而过拟合。Warmup在之前的笔记里也有提到过。

参考阅读

  1. Datawhale教程
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SheltonXiao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值