接着上一个任务,本次继续阅读HuggingFace的BERT源码,进一步熟悉了BERT在预训练和微调阶段的应用。分别是预训练阶段的MLM任务和NSP任务,以及微调阶段的一些具体的NLP任务。在教程基础上,还补充了关于导入pre_trained模型的一些内容。
随后,进一步了解了BERT在训练过程中,分别在预训练和微调阶段的一些策略。
需要注意的是,本章依然是一个源码阅读章节,所进行的示例是为了更好地理解代码的组成和运行逻辑。
在上一个任务编写BERT模型中,我们了解了HuggingFace的BERT代码中的核心类BertModel,这个类别完成了一个基本BertModel的搭建,但我们仍然不知道如何去应用它。
本任务的开始,我们先了解BERT代码中的其它类(因为BertModel还不足以直接应用到模型中),接着再了解BERT的训练是如何进行的。
也即,包含以下内容:
与上一任务一致,代码基于Transformers版本4.4.2,运行时,需要保证transformers.__version__ = 4.4.2
在前面的任务BERT和GPT中我们提到过,BERT在预训练阶段通过两个任务来获取上下文信息:
[MASK]
替换掉一部分单词,然后将句子传入BERT中,编码信息。在这个任务中需要完成的是,预测[MASK]
位置正确单词是什么。这一任务旨在训练模型根据上下文理解单词的意思。(训练损失是平均遮罩LM概率)(A、B)
,其中B是A下文的比例是50%,将句子对(A、B)
输入BERT,使用 [CLS]
的编码信息 进行预测 B 是否 A 的下一句。这一任务旨在训练模型理解预测句子间的关系。(训练损失是平均下文预测概率)下图很好地表示了这两个任务
在代码中,完成这两个任务的模型是BertForPreTraining
(这么说其实不准确,完成mask操作,以及句子对生成的并不是)
观察构成的时候主要看初始化和forward
方法。
可以看出,BertForPreTraining
主要由两部分组成BertModel
和BertPreTrainingHeads
。
在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]
torch.nn.CrossEntropyLoss
默认的ignore_index=-100
,也就是说对于标签为100 的类别输入不会计算loss根据教程,NSP任务的标签(对应next_sentence_label
)就是简单的0,1的二分类标签。
训练的总损失是total_loss
是两个损失得加和,就是两个都要小…(这是干啥)
让我们来到BertPreTrainingHeads
中一探究竟,妹想到,它又往下套了一层BertLMPredictionHead
和一个线性层nn.Linear
。
从forward
方法可以看出,BertLMPredictionHead
用于解决MLM任务,而线性层用于解决NSP任务。
顺便在源码中发现这个类有两个邻居BertOnlyMLMHead
和BertOnlyNSPHead
,分别对应单独解决MLM和NSP两个任务
接下来让我们前来探寻完成MLM任务的这个类,但是妹想到,它还是个套娃。处理分为两步,第一步是BertPredictionHeadTransform
完成transform,第二步是使用线性层完成decode。
在这个类用于预测[MASK]
位置的输出在每个词作为类别的分类输出:
[batch_size, seq_length, vocab_size]
,即预测每个句子每个词是什么类别的概率值(注意这里没有做softmax)这个类终于套娃完了,完成了一些线性变换,就是一个线性层+激活函数+layer normalization。
除了BertForPreTraining
之外,这份代码里面也包含了对于只想对单个目标进行预训练的BERT 模型(具体细节不作展开):
BertForMaskedLM
:只进行MLM 任务的预训练
BertOnlyMLMHead
,而后者也是对BertLMPredictionHead
的另一层封装;BertLMHeadModel
:这个和上一个的区别在于,这一模型是作为decoder 运行的版本
BertOnlyMLMHead
;BertForNextSentencePrediction
:只进行NSP 任务的预训练
BertOnlyNSPHead
,内容就是一个线性层。在进行应用之前,我们先了解一个类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
。并且完成了预训练任务。
我们加载一个预训练模型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的。
我们再调用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])
差的挺多
我们再调用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>)
在前面的任务BERT和GPT中我们提到过,BERT可以完成下面四种微调(FineTuning)任务
包含两大类
在这次任务中,主要介绍四种任务:
句子分类的输入为句子(对),输出为单个分类标签。
结构上非常简单,是一个BertModel
过一个dropout然后再接一个线性层输出分类。
再看forward
方法,是拿pooling后得到的结果,去进行后续的计算。
loss的计算部分,会需要传入labels输入,如果num_labels = 1
,就默认是回归任务,用MSELoss计算,否则是分类任务(分为单分类和多分类)。
先导入模型,这里我们导入的是,微调模型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并不是简单的词语重组,也取得了很好的效果。
这一模型用于多项选择,如 RocStories/SWAG 任务。
[batch_size, num_choices]
数量的句子,因此相同 batch 大小时,比句子分类等任务需要更多的显存,在训练时需要小心。这个任务跟句子分类任务的处理很接近,不过不会是回归任务
init函数和前面的长得太像了
forward函数也是,loss的计算没有什么很特别的地方,但是需要注意的是,这里的输出用的不是pooling。
_keys_to_ignore_on_load_unexpected
这一个类参数设置为[r"pooler"]
,也就是在加载模型时对于出现不需要的权重不发生报错。导入预训练好的模型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')
可以看到这个分类还是挺准确的。
这一模型用于解决问答任务,例如 SQuAD 任务。
问答任务的输入为问题 +(对于 BERT 只能是一个)回答组成的句子对,输出为起始位置和结束位置用于标出回答中的具体文本。 这里需要两个输出,即对起始位置的预测和对结束位置的预测,两个输出的长度都和句子长度一样,从其中挑出最大的预测值对应的下标作为预测的位置。
简单来说就是确定一大段语句里面,哪一个分段是问题的答案。
由一个BertModel和一个线性层简单组合而成。
forward方法如下,model输出的是sequence_output
,然后squeeze
压缩掉维度,再contiguous
获得连续的tensor(还需要再补充),获得起始点和终点。
loss的计算也是根据起始点和终点的位置来确定的(这个挺奇怪的其实)。
导入预训练好的模型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
也还算合理。
预训练阶段,除了众所周知的 15%、80% mask 比例,有一个值得注意的地方就是参数共享。 不止 BERT,所有 huggingface 实现的 PLM 的 word embedding 和 masked language model 的预测权重在初始化过程中都是共享的。
除此之外,一系列的mask操作以及句子对的组成,都不包含在此次阅读的代码中,需要另外实现。
使用的优化器是AdamW(AdamWeightDecayOptimizer),这个是在 Adam+L2 正则化的基础上进行改进的算法。
Adam在之前的笔记中有提及到,是基于RMSProp和Momentum的,也就是在平缓的地方快点走,在陡的地方慢点走,同时再加个动量。
AdamW和一般的 Adam+L2 正则化算法的区别如下图所示
通常,我们会选择模型的 weight 部分参与 decay 过程,而另一部分(包括 LayerNorm 的 weight)不参与(代码最初来源应该是 Huggingface 的示例)
另外,还使用了Warmup,即在训练初期使用较小的学习率(从 0 开始),在一定步数(比如 1000 步)内逐渐提高到正常大小(比如上面的 2e-5),避免模型过早进入局部最优而过拟合。Warmup在之前的笔记里也有提到过。