# 查看當(dāng)前掛載的數(shù)據(jù)集目錄, 該目錄下的變更重啟環(huán)境后會自動還原
# View dataset directory.
# This directory will be recovered automatically after resetting environment.
!ls /home/aistudio/data
In [ ]
# 查看工作區(qū)文件, 該目錄下的變更將會持久保存. 請及時清理不必要的文件, 避免加載過慢.
# View personal work directory.
# All changes under this directory will be kept even after reset.
# Please clean unnecessary files in time to speed up environment loading.
!ls /home/aistudio/work
In [1]
# 如果需要進行持久化安裝, 需要使用持久化路徑, 如下方代碼示例:
# If a persistence installation is required,
# you need to use the persistence path as the following:
!mkdir /home/aistudio/external-libraries
!pip install transformers==3.4.0 # 直接執(zhí)行此步安轉(zhuǎn)
# !pip install beautifulsoup4 -t /home/aistudio/external-libraries
In [ ]
# 同時添加如下代碼, 這樣每次環(huán)境(kernel)啟動的時候只要運行下方代碼即可:
# Also add the following code,
# so that every time the environment (kernel) starts,
# just run the following code:
import sys
sys.path.append('/home/aistudio/external-libraries')
請點擊此處查看本環(huán)境基本用法.
Please click here for more detailed instructions.
1 BERT的token細節(jié)
1.1 CLS與SEP
上圖是BERT模型輸入Embedding的過程,注意到兩個特殊符號,一個是[CLS],一個是[SEP]。在序列的開頭添加的[CLS]主要是用來學(xué)習(xí)整個句子或句子對之間的語義表示。[SEP]主要是用來分割不同句子。
之所以會選擇[CLS],因為與文本中已有的其他詞相比,這個無明顯語義信息的符號會更公平地融合文本中各個詞的語義信息,從而更好的表示整句話的語義。
1.2 對應(yīng)token位置的輸出
有了各種各樣的token輸入之后,BERT模型的輸出是什么呢。通過下圖能夠看出會有兩種輸出,一個對應(yīng)的是紅色框,也就是對應(yīng)的[CLS]的輸出,輸出的shape是[batch size,hidden size];另外一個對應(yīng)的是藍色框,是所有輸入的token對應(yīng)的輸出,它的shape是[batch size,seq length,hidden size],這其中不僅僅有[CLS]對于的輸出,還有其他所有token對應(yīng)的輸出。
在使用代碼上就要考慮到底是使用第一種輸出,還是第二種了。大部分情況是是會選擇[CLS]的輸出,再進行微調(diào)的操作。不過有的時候使用所有token的輸出也會有一些意想不到的效果。
BertPooler就是代表的就是[CLS]的輸出,可以直接調(diào)用。大家可以修改下代碼,使其跑通看看。
In [2]
import torch
from torch import nn
In [3]
class BertPooler(nn.Module):
def __init__(self, config):
super().__init__()
self.dense = nn.Linear(config.hidden_size, config.hidden_size)
self.activation = nn.Tanh()
def forward(self, hidden_states):
# We "pool" the model by simply taking the hidden state corresponding
# to the first token.
# hidden_states.shape 為[batch_size, seq_len, hidden_dim]
# assert hidden_states.shape == torch.Size([8, 768])
first_token_tensor = hidden_states[:, 0]
pooled_output = self.dense(first_token_tensor)
pooled_output = self.activation(pooled_output)
return pooled_output
In [4]
class Config:
def __init__(self):
self.hidden_size = 768
self.num_attention_heads = 12
self.attention_probs_dropout_prob = 0.1
config = Config()
bertPooler = BertPooler(config)
input_tensor = torch.ones([8, 50, 768])
output_tensor = bertPooler(input_tensor)
assert output_tensor.shape == torch.Size([8, 50, 768])
上面的代碼會報錯吧,看看錯在哪里,有助于大家理解輸出層的維度。
1.3 BERT的Tokenizer
我們再看看上面這張關(guān)于BERT模型的輸入的圖,我們會發(fā)現(xiàn),在input這行,對于英文的輸入是會以一種subword的形式進行的,比如playing這個詞,是分成play和##ing兩個subword。那對于中文來說,是會分成一個字一個字的形式。這么分subword的好處是減小了字典vocab的大小,同時會減少OOV的出現(xiàn)。那像playing那樣的分詞方式是怎么做到呢,subword的方式非常多,BERT采用的是wordpiece的方法,具體知識可以閱讀補充資料《深入理解NLP Subword算法:BPE、WordPiece、ULM》。
BERT模型預(yù)訓(xùn)練階段的vocab,可以點擊data/data56340/vocab.txt查看。
下圖截了一部分,其中[unused]是可以自己添加token的預(yù)留位置,101-104會放一些特殊的符號,這樣大家就明白第一節(jié)最后代碼里添加102的含義了吧。
在實際代碼過程中,有關(guān)tokenizer的操作可以見Transformers庫中tokenization_bert.py。
里面有很多的可以操作的接口,大家可以自行嘗試,下面列了其中一個。
In [5]
from typing import List, Optional, Tuple
def build_inputs_with_special_tokens(self, token_ids_0: List[int], token_ids_1: Optional[List[int]] = None) -> List[int]:
"""
Build model inputs from a sequence or a pair of sequence for sequence classification tasks
by concatenating and adding special tokens.
A BERT sequence has the following format:
- single sequence: ``[CLS] X [SEP]``
- pair of sequences: ``[CLS] A [SEP] B [SEP]``
Args:
token_ids_0 (:obj:`List[int]`):
List of IDs to which the special tokens will be added.
token_ids_1 (:obj:`List[int]`, `optional`):
Optional second list of IDs for sequence pairs.
Returns:
:obj:`List[int]`: List of `input IDs <../glossary.html#input-ids>`__ with the appropriate special tokens.
"""
if token_ids_1 is None:
return [self.cls_token_id] + token_ids_0 + [self.sep_token_id]
cls = [self.cls_token_id]
sep = [self.sep_token_id]
return cls + token_ids_0 + sep + token_ids_1 + sep
大家改改下面的code試一試。
In [6]
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('/home/aistudio/data/data56340')
inputs_1 = tokenizer("歡迎大家來到后廠理工學(xué)院學(xué)習(xí)。")
print(inputs_1)
inputs_2 = tokenizer("歡迎大家來到后廠理工學(xué)院學(xué)習(xí)。", "hello")
print(inputs_2)
inputs_3 = tokenizer.encode("歡迎大家來到后廠理工學(xué)院學(xué)習(xí)。", "hello")
print(inputs_3)
inputs_4 = tokenizer.build_inputs_with_special_tokens(inputs_3)
print(inputs_4)
2 MLM和NSP預(yù)訓(xùn)練任務(wù)
此階段我們開始對兩個BERT的預(yù)訓(xùn)練任務(wù)展開學(xué)習(xí),Let`s go!
2.1 MLM
如何理解MLM,可以先從LM(language model,語言模型)入手,LM的目地是基于上文的內(nèi)容來預(yù)測下文的可能出現(xiàn)的詞,由于LM是單向的,要不從左到右要不從右到左,很難做到結(jié)合上下文語義。為了改進LM,實現(xiàn)雙向的學(xué)習(xí),MLM就是一種,通過對輸入文本序列隨機的mask,然后通過上下文來預(yù)測這個mask應(yīng)該是什么詞,至此解決了雙向的問題。這個任務(wù)的表現(xiàn)形式更像是完形填空,通過此方向使得BERT完成自監(jiān)督的學(xué)習(xí)任務(wù)。
那隨機的mask是怎么做的呢?具體的做法是,將每個輸入的數(shù)據(jù)句子中15%的概率隨機抽取token,在這15%中的80%概論將token替換成[MASK],如上圖所示,15%中的另外10%替換成其他token,比如把‘理’換成‘后’,15%中的最后10%保持不變,就是還是‘理’這個token。
之所以采用三種不同的方式做mask,是因為后面的fine-tuning階段并不會做mask的操作,為了減少pre-training和fine-tuning階段輸入分布不一致的問題,所以采用了這種策略。
如果使用MLM,它的輸出層可以參照下面代碼,取自Transformers庫中modeling_bert.py。
In [7]
class BertLMPredictionHead(nn.Module):
def __init__(self, config):
super().__init__()
# 這部操作加了一些全連接層和layer歸一化
self.transform = BertPredictionHeadTransform(config)
# The output weights are the same as the input embeddings, but there is an output-only bias for each token.
# 在nn.Linear操作過程中的權(quán)重和bert輸入的embedding權(quán)重共享,思考下為什么需要共享?原因見下面描述。
# self.decoder在預(yù)測生成token的概論
self.decoder = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
self.bias = nn.Parameter(torch.zeros(config.vocab_size))
# decoder層雖然權(quán)重是共享的,但是會多一個bias偏置項,在此設(shè)置
# Need a link between the two variables so that the bias is correctly resized with `resize_token_embeddings`
self.decoder.bias = self.bias
def forward(self, hidden_states):
hidden_states = self.transform(hidden_states)
hidden_states = self.decoder(hidden_states)
return hidden_states
Embedding層和FC層(上面代碼nn.Linear層)權(quán)重共享。
Embedding層可以說是通過onehot去取到對應(yīng)的embedding向量,F(xiàn)C層可以說是相反的,通過向量(定義為 v)去得到它可能是某個詞的softmax概率,取概率最大(貪婪情況下)的作為預(yù)測值。那哪一個會是概率最大的呢?Embedding層和FC層權(quán)重共享,Embedding層中和向量 v 最接近的那一行對應(yīng)的詞,會獲得更大的預(yù)測概率。實際上,Embedding層和FC層有點像互為逆過程。
通過這樣的權(quán)重共享可以減少參數(shù)的數(shù)量,加快收斂。
我們有了BertLMPredictionHead后,就可以完成MLM的預(yù)訓(xùn)練任務(wù)了。有兩種選擇,第一個是BertOnlyMLMHead,它是只考慮單獨MLM任務(wù)的,通過BertForMaskedLM完成最終的預(yù)訓(xùn)練,Loss是CrossEntropyLoss;第二個是BertPreTrainingHeads,它是同時考慮MLM和NSP任務(wù)的,通過BertForPreTraining完成,Loss是CrossEntropyLoss。原本論文肯定是第二種MLM和NSP一塊訓(xùn)練的,但如果有單獨訓(xùn)練任務(wù)需求是使用者可自行選擇。
以上提到的如BertOnlyMLMHead類,可以查閱Transformers庫modeling_bert.py。
2.2 NSP
BERT的作者在設(shè)計任務(wù)時,還考慮了兩個句子之間的關(guān)系,來補充MLM任務(wù)能力,設(shè)計了Next Sentence Prediction(NSP)任務(wù),這個任務(wù)比較簡單,NSP取[CLS]的最終輸出進行二分類,來判斷輸入的兩個句子是不是前后相連的關(guān)系。
構(gòu)建數(shù)據(jù)的方法是,對于句子1,句子2以50%的概率為句子1相連的下一句,以50%的概率在語料庫里隨機抽取一句。以此構(gòu)建了一半正樣本一半負(fù)樣本。
從上圖可以看出,NSP任務(wù)實現(xiàn)比較簡單,直接拿[CLS]的輸出加上一個全連接層實現(xiàn)二分類就可以了。
self.seq_relationship = nn.Linear(config.hidden_size, 2)
最后采用CrossEntropyLoss計算損失。
3 代碼實操預(yù)訓(xùn)練
BERT預(yù)訓(xùn)任務(wù)分為MLM和NSP,后續(xù)一些預(yù)訓(xùn)練模型的嘗試發(fā)現(xiàn),NSP任務(wù)其實應(yīng)該比較小,所以如果大家在預(yù)訓(xùn)練模型的基礎(chǔ)上繼續(xù)訓(xùn)練,可以直接跑MLM任務(wù)。
3.1 mask token 處理
在進行BERT的預(yù)訓(xùn)練時,模型送進模型的之前需要對數(shù)據(jù)進行mask操作,處理代碼如下:
In [8]
def mask_tokens(inputs: torch.Tensor, tokenizer: PreTrainedTokenizer, args) -> Tuple[torch.Tensor, torch.Tensor]:
""" Prepare masked tokens inputs/labels for masked language modeling: 80% MASK, 10% random, 10% original. """
if tokenizer.mask_token is None:
raise ValueError(
"This tokenizer does not have a mask token which is necessary for masked language modeling. Remove the --mlm flag if you want to use this tokenizer."
)
labels = inputs.clone()
# We sample a few tokens in each sequence for masked-LM training (with probability args.mlm_probability defaults to 0.15 in Bert/RoBERTa)
probability_matrix = torch.full(labels.shape, args.mlm_probability)
# 調(diào)出[MASK]
special_tokens_mask = [
tokenizer.get_special_tokens_mask(val, already_has_special_tokens=True) for val in labels.tolist()
]
probability_matrix.masked_fill_(torch.tensor(special_tokens_mask, dtype=torch.bool), value=0.0)
if tokenizer._pad_token is not None:
padding_mask = labels.eq(tokenizer.pad_token_id)
probability_matrix.masked_fill_(padding_mask, value=0.0)
masked_indices = torch.bernoulli(probability_matrix).bool()
labels[~masked_indices] = -100 # We only compute loss on masked tokens
# 80% of the time, we replace masked input tokens with tokenizer.mask_token ([MASK])
indices_replaced = torch.bernoulli(torch.full(labels.shape, 0.8)).bool() & masked_indices
inputs[indices_replaced] = tokenizer.convert_tokens_to_ids(tokenizer.mask_token)
# 10% of the time, we replace masked input tokens with random word
indices_random = torch.bernoulli(torch.full(labels.shape, 0.5)).bool() & masked_indices & ~indices_replaced
random_words = torch.randint(len(tokenizer), labels.shape, dtype=torch.long)
inputs[indices_random] = random_words[indices_random]
# The rest of the time (10% of the time) we keep the masked input tokens unchanged
return inputs, labels
3.2 大型模型訓(xùn)練策略
對于BERT的預(yù)訓(xùn)練操作,會涉及很多訓(xùn)練策略,目地都是解決如何在大規(guī)模訓(xùn)練時減少訓(xùn)練時間,充分利用算力資源。以下代碼實例。
In [9]
# gradient_accumulation梯度累加
# 一般在單卡GPU訓(xùn)練時常用策略,以防止顯存溢出
if args.max_steps > 0:
t_total = args.max_steps
args.num_train_epochs = args.max_steps // (len(train_dataloader) // args.gradient_accumulation_steps) + 1
else:
t_total = len(train_dataloader) // args.gradient_accumulation_steps * args.num_train_epochs
In [ ]
# Nvidia提供了一個混合精度工具apex
# 實現(xiàn)混合精度訓(xùn)練加速
if args.fp16:
try:
from apex import amp
except ImportError:
raise ImportError("Please install apex from https://www.github.com/nvidia/apex to use fp16 training.")
model, optimizer = amp.initialize(model, optimizer, opt_level=args.fp16_opt_level)
In [ ]
# multi-gpu training (should be after apex fp16 initialization)
# 一機多卡
if args.n_gpu > 1:
model = torch.nn.DataParallel(model)
In [ ]
# Distributed training (should be after apex fp16 initialization)
# 多機多卡分布式訓(xùn)練
if args.local_rank != -1:
model = torch.nn.parallel.DistributedDataParallel(
model, device_ids=[args.local_rank], output_device=args.local_rank, find_unused_parameters=True
)
以上代碼都是常添加在BERT訓(xùn)練代碼中的策略方法,這里提供一個補充資料《神經(jīng)網(wǎng)絡(luò)分布式訓(xùn)練、混合精度訓(xùn)練、梯度累加...一文帶你優(yōu)雅地訓(xùn)練大型模型》。
在訓(xùn)練策略上,基于Transformer結(jié)構(gòu)的大規(guī)模預(yù)訓(xùn)練模型預(yù)訓(xùn)練和微調(diào)都會采用wramup的方式。
scheduler = get_linear_schedule_with_warmup(
optimizer, num_warmup_steps=args.warmup_steps, num_training_steps=t_total
)
那BERT中的warmup有什么作用呢?
在預(yù)訓(xùn)練模型訓(xùn)練的開始階段,BERT模型對數(shù)據(jù)的初始分布理解很少,在第一輪訓(xùn)練的時候,模型的權(quán)重會迅速改變。如果一開始學(xué)習(xí)率很大,非常有可能對數(shù)據(jù)產(chǎn)生過擬合的學(xué)習(xí),后面需要很多輪的訓(xùn)練才能彌補,會花費更多的訓(xùn)練時間。但模型訓(xùn)練一段時間后,模型對數(shù)據(jù)分布已經(jīng)有了一定的學(xué)習(xí),這時就可以提升學(xué)習(xí)率,能夠使得模型更快的收斂,訓(xùn)練也更加穩(wěn)定,這個過程就是warmup,學(xué)習(xí)率是從低逐漸增高的過程。
那為什么warmup之后會有decay的操作?
當(dāng)BERT模型訓(xùn)練一定時間后,尤其是后續(xù)快要收斂的時候,如果還是比較大的學(xué)習(xí)率,比較難以收斂,調(diào)低學(xué)習(xí)率能夠更好的微調(diào)。
更多的思考可以閱讀《神經(jīng)網(wǎng)絡(luò)中 warmup 策略為什么有效;有什么理論解釋么?》。
好了,預(yù)訓(xùn)練的知識基本就這些了,挖的比較深。
如果你想自己來一些預(yù)訓(xùn)練的嘗試,可以github上找一份源碼,再去找一個中文數(shù)據(jù)集試一試。
如果只是想用一用BERT,那就可以繼續(xù)下一節(jié)課微調(diào)模型的學(xué)習(xí),以后的工作中大部分時間會花在處理微調(diào)模型的過程中。
同學(xué)們加油!
4 BERT微調(diào)細節(jié)詳解
上面我們已經(jīng)對BERT的預(yù)訓(xùn)練任務(wù)有了深刻的理解,本環(huán)節(jié)將對BERT的Fine-tuning微調(diào)展開探討。
預(yù)訓(xùn)練+微調(diào)技術(shù)掌握熟練后,就可以在自己的業(yè)務(wù)上大展身教了,可以做一些大膽的嘗試。
4.1 BERT微調(diào)任務(wù)介紹
微調(diào)(Fine-tuning)是在BERT強大的預(yù)訓(xùn)練后完成NLP下游任務(wù)的步驟,這也是所謂的遷移策略,充分應(yīng)用大規(guī)模的預(yù)訓(xùn)練模型的優(yōu)勢,只在下游任務(wù)上再進行一些微調(diào)訓(xùn)練,就可以達到非常不錯的效果。
下圖是BERT原文中微調(diào)階段4各種類型的下游任務(wù)。其中包括:
- 句子對匹配(sentence pair classification)
- 文本分類(single sentence classification)
- 抽取式問答(question answering)
- 序列標(biāo)注(single sentence tagging)
4.2 文本分類任務(wù)
我們先看看文本分類任務(wù)的基本微調(diào)操作。如下圖所示,最基本的做法就是將預(yù)訓(xùn)練的BERT讀取進來,同時在[CLS]的輸出基礎(chǔ)上加上一個全連接層,全連接層的輸出維度就是分類的類別數(shù)。
從代碼實現(xiàn)上看可以從兩個角度出發(fā):
1.直接調(diào)用Transformers庫中BertForSequenceClassification類實現(xiàn),代碼如下:
In [10]
import torch
import torch.nn as nn
class BertForSequenceClassification(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
# 考慮多分類的問題
self.num_labels = config.num_labels
# 調(diào)用bert預(yù)訓(xùn)練模型
self.bert = BertModel(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
# 在預(yù)訓(xùn)練的BERT上加上一個全連接層,用于微調(diào)分類模型
# config.num_labels是分類數(shù)
self.classifier = nn.Linear(config.hidden_size, config.num_labels)
2.如果想做一些更復(fù)雜的微調(diào)模型,可以參照上述封裝好的類,寫一個自己需要的微調(diào)層滿足分類的需求,代碼如下:
In [11]
class NewModel(nn.Module):
def __init__(self):
super(NewModel, self).__init__()
# 調(diào)用bert預(yù)訓(xùn)練模型
self.model = BertModel.from_pretrained(modelPath)
# 可以自定義一些其他網(wǎng)絡(luò)做為微調(diào)層的結(jié)構(gòu)
self.cnn = nn.Conv2d()
self.rnn = nn.GRU()
self.dropout = nn.Dropout(0.1)
# 最后的全連接層,用于分類
self.l1 = nn.Linear(768, 2)
對比一下上述兩個類,你會發(fā)現(xiàn)如果是調(diào)用Transformers中的BertForSequenceClassification,加載bert預(yù)訓(xùn)練模型僅傳了一個config,而自己創(chuàng)建類,要傳整個預(yù)訓(xùn)練模型的路徑(其中包括config和model文件)。大家思考下,看看源碼尋找答案?
4.3 文本匹配任務(wù)
接著我們看下匹配問題是如何搭建的,網(wǎng)絡(luò)結(jié)構(gòu)如下圖所示。
雖然文本匹配問題的微調(diào)結(jié)構(gòu)和分類問題有一定的區(qū)別,它的輸入是兩個句子,但是它最終的輸出依然是要做一個二分類的問題,所以如果你想用BERT微調(diào)一個文本匹配模型,可以和分類問題用的代碼是一樣的,依然可以采用Transformers庫中BertForSequenceClassification類實現(xiàn),只不過最終全連接層輸出的維度為2。
tips:實際在工程中,經(jīng)過大量的驗證,如果直接采用上述的BERT模型微調(diào)文本匹配問題,效果不一定很好。一般解決文本匹配問題會采用一些類似孿生網(wǎng)絡(luò)的結(jié)構(gòu)去解決,該課就不過多介紹了。
4.4 序列標(biāo)注任務(wù)
下面我們看一下序列標(biāo)注問題,BERT模型是如何進行微調(diào)的。下圖是原論文中給出的微調(diào)結(jié)構(gòu)圖。
理解序列標(biāo)注問題,要搞清楚它主要是在做什么事情。一般的分詞任務(wù)、詞性標(biāo)注和命名體識別任務(wù)都屬于序列標(biāo)注問題。這類問題因為輸入句子的每一個token都需要預(yù)測它們的標(biāo)簽,所以序列標(biāo)注是一個單句多l(xiāng)abel分類任務(wù),BERT模型的所有輸出(除去特殊符號)都要給出一個預(yù)測結(jié)果。
同時,我們要保證BERT的微調(diào)層的輸出是[batch_size, seq_len, num_labels]。
如果繼續(xù)使用Transformers庫,可以直接調(diào)用BertForTokenClassification類。部分代碼如下:
In [ ]
class BertForTokenClassification(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
# 序列標(biāo)注的類別數(shù)
self.num_labels = config.num_labels
# 調(diào)用BERT預(yù)訓(xùn)練模型,同時關(guān)掉pooling_layer的輸出,原因在上段有解釋。
self.bert = BertModel(config, add_pooling_layer=False)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
# 增加一個微調(diào)階段的分類器,對每一個token都進行分類
self.classifier = nn.Linear(config.hidden_size, config.num_labels)
同理,如果想進一步提升序列標(biāo)注的性能,也是要自己增加一些層,感興趣的可以自己試試啊。
4.5 問答任務(wù)
論文里還有最后一種微調(diào)結(jié)構(gòu),就是抽取式的QA微調(diào)模型,該問題是在SQuAD1.1設(shè)計的,如下圖所示。
QA問題的微調(diào)模型搭建也不難,一些初始化的操作見下面代碼(源自Transformers庫):
In [ ]
class BertForQuestionAnswering(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
# 判斷token是答案的起點和終點的類別,也就是一個二分類的問題,此處應(yīng)該等于2
self.num_labels = config.num_labels
# 導(dǎo)入BERT的預(yù)訓(xùn)練模型,同時不輸出pooling層,那就是把所有token對應(yīng)的輸出都保留
# 輸出維度是[batch_size, seq_len, embedding_dim]
self.bert = BertModel(config, add_pooling_layer=False)
# 通過一個全連接層實現(xiàn)抽取分類任務(wù)
self.qa_outputs = nn.Linear(config.hidden_size, config.num_labels)
說到這里,大家可能還是不太好理解QA問題的微調(diào)過程,我們在看下相對應(yīng)的forward代碼。
In [ ]
def forward(
self,
input_ids=None,
attention_mask=None,
token_type_ids=None,
position_ids=None,
head_mask=None,
inputs_embeds=None,
start_positions=None,
end_positions=None,
output_attentions=None,
output_hidden_states=None,
return_dict=None,
):
return_dict = return_dict if return_dict is not None else self.config.use_return_dict
outputs = self.bert(
input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids,
position_ids=position_ids,
head_mask=head_mask,
inputs_embeds=inputs_embeds,
output_attentions=output_attentions,
output_hidden_states=output_hidden_states,
return_dict=return_dict,
)
# 拿到所有token的輸出
sequence_output = outputs[0]
# 得到每個token對應(yīng)的分類結(jié)果,就是分為start位置和end位置的概論
logits = self.qa_outputs(sequence_output)
start_logits, end_logits = logits.split(1, dim=-1)
start_logits = start_logits.squeeze(-1)
end_logits = end_logits.squeeze(-1)
total_loss = None
if start_positions is not None and end_positions is not None:
# If we are on multi-GPU, split add a dimension
if len(start_positions.size()) > 1:
start_positions = start_positions.squeeze(-1)
if len(end_positions.size()) > 1:
end_positions = end_positions.squeeze(-1)
# sometimes the start/end positions are outside our model inputs, we ignore these terms
ignored_index = start_logits.size(1)
start_positions.clamp_(0, ignored_index)
end_positions.clamp_(0, ignored_index)
# 通過交叉熵來計算loss
loss_fct = CrossEntropyLoss(ignore_index=ignored_index)
start_loss = loss_fct(start_logits, start_positions)
end_loss = loss_fct(end_logits, end_positions)
total_loss = (start_loss + end_loss) / 2
if not return_dict:
output = (start_logits, end_logits) + outputs[2:]
return ((total_loss,) + output) if total_loss is not None else output
# 結(jié)果是要返回start和end的結(jié)果
return QuestionAnsweringModelOutput(
loss=total_loss,
start_logits=start_logits,
end_logits=end_logits,
hidden_states=outputs.hidden_states,
attentions=outputs.attentions,
)
以上四個任務(wù)就是BERT原論文中提到的微調(diào)任務(wù),實現(xiàn)方式大體都比較相像,在實際的使用過程中可以借鑒。
5 微調(diào)模型的設(shè)計問題
5.1 預(yù)訓(xùn)練模型輸入長度的限制
我們通過對BERT預(yù)訓(xùn)練模型的了解,可以知道,BERT預(yù)設(shè)的最大文本長度為512。
# Transformers源碼configuration_bert.py中的定義
def __init__(
self,
vocab_size=30522,
hidden_size=768,
num_hidden_layers=12,
num_attention_heads=12,
intermediate_size=3072,
hidden_act="gelu",
hidden_dropout_prob=0.1,
attention_probs_dropout_prob=0.1,
max_position_embeddings=512, # 通過這個參數(shù)可以得知預(yù)訓(xùn)練bert的長度
type_vocab_size=2,
initializer_range=0.02,
layer_norm_eps=1e-12,
pad_token_id=0,
gradient_checkpointing=False,
**kwargs
):
也就是說,BERT模型要求輸入句子的長度不能超過512,同時還要考慮[CLS]這些特殊符號的存在,實際文本的長度會更短。
究其原因,隨著文本長度的不斷增加,計算所需要的顯存也會成線性增加,運行時間也會隨著增長。所以輸入文本的長度是需要加以控制的。
在實際的任務(wù)中我們的輸入文本一般會有兩個方面,要不就是特別長,比如文本摘要、閱讀理解任務(wù),它們的輸入文本是有可能超過512;另外一種就是一些短文本任務(wù),如短文本分類任務(wù)。
下面我們會給出一些方法。
5.2 長文本問題
說到長文本處理,最直接的方法就是截斷。
由于 Bert 支持最大長度為 512 個token,那么如何截取文本也成為一個很關(guān)鍵的問題。
《How to Fine-Tune BERT for Text Classification?》給出了幾種解決方法:
- head-only: 保存前 510 個 token (留兩個位置給 [CLS] 和 [SEP] )
- tail-only: 保存最后 510 個token
- head + tail : 選擇前128個 token 和最后382個 token
作者是在IMDB和Sogou News數(shù)據(jù)集上做的試驗,發(fā)現(xiàn)head+tail效果會更好一些。但是在實際的問題中,大家還是要人工的篩選一些數(shù)據(jù)觀察數(shù)據(jù)的分布情況,視情況選擇哪種截斷的方法。
除了上述截斷的方法之外,還可以采用sliding window的方式做。
用劃窗的方式對長文本切片,分別放到BERT里,得到相對應(yīng)的CLS,然后對CLS進行融合,融合的方式也比較多,可以參考以下方式:
- max pooling最大池化
- avg pooling平均池化
- attention注意力融合
- transformer等
相關(guān)思考可以參考:《Multi-passage BERT: A Globally Normalized BERT Model for Open-domain Question Answering》和《PARADE: Passage Representation Aggregation for Document Reranking》
5.3 短文本問題
在遇到一些短文本的NLP任務(wù)時,我們可以對輸入文本進行一定的截斷,因為過長的文本會增加相應(yīng)的計算量。
那如何選取短文本的輸入長度呢?需要大家對數(shù)據(jù)進行簡單的分析。雖然簡單,但這往往是工作中必須要注意的細節(jié)。
5.4 微調(diào)層的設(shè)計
針對不同的任務(wù)大家可以繼續(xù)在bert的預(yù)訓(xùn)練模型基礎(chǔ)上加一些網(wǎng)絡(luò)的設(shè)計,比如文本分類上加一些cnn;比如在序列標(biāo)注上加一些crf等等。
往往可以根據(jù)經(jīng)驗進行嘗試。
5.4.1 Bert+CNN
CNN結(jié)構(gòu)在學(xué)習(xí)一些短距離文本特征上有一定的優(yōu)勢,可以和Bert進行結(jié)合,會有不錯的效果。
下圖是TextCNN算法的結(jié)構(gòu)示意圖,同學(xué)們可以嘗試補全下面代碼,完成Bert和TextCNN的結(jié)合。
In [ ]
import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import BertPreTrainedModel, BertModel
class Conv1d(nn.Module):
def __init__(self, in_channels, out_channels, filter_sizes):
super(Conv1d, self).__init__()
self.convs = nn.ModuleList([
nn.Conv1d(in_channels=in_channels,
out_channels=out_channels,
kernel_size=fs)
for fs in filter_sizes
])
self.init_params()
def init_params(self):
for m in self.convs:
nn.init.xavier_uniform_(m.weight.data)
nn.init.constant_(m.bias.data, 0.1)
def forward(self, x):
return [F.relu(conv(x)) for conv in self.convs]
In [ ]
class BertCNN(BertPreTrainedModel):
def __init__(self, config, num_labels, n_filters, filter_sizes):
# total_filter_sizes = "2 2 3 3 4 4"
# filter_sizes = [int(val) for val in total_filter_sizes.split()]
# n_filters = 6
super(BertCNN, self).__init__(config)
self.num_labels = num_labels
self.bert = BertModel(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.convs = Conv1d(config.hidden_size, n_filters, filter_sizes)
self.classifier = nn.Linear(len(filter_sizes) * n_filters, num_labels)
self.apply(self.init_bert_weights)
def forward(self, input_ids, token_type_ids=None, attention_mask=None, labels=None):
"""
Args:
input_ids: 詞對應(yīng)的 id
token_type_ids: 區(qū)分句子,0 為第一句,1表示第二句
attention_mask: 區(qū)分 padding 與 token, 1表示是token,0 為padding
"""
encoded_layers, _ = self.bert(
input_ids, token_type_ids, attention_mask, output_all_encoded_layers=False)
# encoded_layers: [batch_size, seq_len, bert_dim=768]
encoded_layers = self.dropout(encoded_layers)
"""
one code # 對encoded_layers做維度調(diào)整
one code # 調(diào)用conv層
one code # 圖中所示采用最大池化融合
"""
cat = self.dropout(torch.cat(pooled, dim=1))
# cat: [batch_size, filter_num * len(filter_sizes)]
logits = self.classifier(cat)
# logits: [batch_size, output_dim]
if labels is not None:
loss_fct = CrossEntropyLoss()
loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
return loss
else:
return logits
上面代碼共有三行需要填寫,主要是TextCNN結(jié)構(gòu)的邏輯,大家要多加思考。
填完后,可以參照下面代碼答案。
class BertCNN(nn.Module):
def __init__(self, config, num_labels, n_filters, filter_sizes):
super(BertCNN, self).__init__(config)
self.num_labels = num_labels
self.bert = BertModel(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.convs = Conv1d(config.hidden_size, n_filters, filter_sizes)
self.classifier = nn.Linear(len(filter_sizes) * n_filters, num_labels)
self.apply(self.init_bert_weights)
def forward(self, input_ids, token_type_ids=None, attention_mask=None, labels=None):
"""
Args:
input_ids: 詞對應(yīng)的 id
token_type_ids: 區(qū)分句子,0 為第一句,1表示第二句
attention_mask: 區(qū)分 padding 與 token, 1表示是token,0 為padding
"""
encoded_layers, _ = self.bert(
input_ids, token_type_ids, attention_mask, output_all_encoded_layers=False)
# encoded_layers: [batch_size, seq_len, bert_dim=768]
encoded_layers = self.dropout(encoded_layers)
"""
one code # 對encoded_layers做維度調(diào)整
one code # 調(diào)用conv層
one code # 圖中所示采用最大池化融合
"""
encoded_layers = encoded_layers.permute(0, 2, 1)
# encoded_layers: [batch_size, bert_dim=768, seq_len]
conved = self.convs(encoded_layers)
# conved 是一個列表, conved[0]: [batch_size, filter_num, *]
pooled = [F.max_pool1d(conv, conv.shape[2]).squeeze(2)
for conv in conved]
# pooled 是一個列表, pooled[0]: [batch_size, filter_num]
cat = self.dropout(torch.cat(pooled, dim=1))
# cat: [batch_size, filter_num * len(filter_sizes)]
logits = self.classifier(cat)
# logits: [batch_size, output_dim]
if labels is not None:
loss_fct = CrossEntropyLoss()
loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
return loss
else:
return logits
5.4.2 Bert+LSTM
那要是想加上一個lstm呢?參照下面代碼。
In [ ]
class BertLSTM(BertPreTrainedModel):
def __init__(self, config, num_labels, rnn_hidden_size, num_layers, bidirectional, dropout):
super(BertLSTM, self).__init__(config)
self.num_labels = num_labels
self.bert = BertModel(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.rnn = nn.LSTM(config.hidden_size, rnn_hidden_size, num_layers,bidirectional=bidirectional, batch_first=True, dropout=dropout)
self.classifier = nn.Linear(rnn_hidden_size * 2, num_labels)
self.apply(self.init_bert_weights)
def forward(self, input_ids, token_type_ids=None, attention_mask=None, labels=None):
encoded_layers, _ = self.bert(
input_ids, token_type_ids, attention_mask, output_all_encoded_layers=False)
encoded_layers = self.dropout(encoded_layers)
# encoded_layers: [batch_size, seq_len, bert_dim]
_, (hidden, cell) = self.rnn(encoded_layers)
# outputs: [batch_size, seq_len, rnn_hidden_size * 2]
hidden = self.dropout(
torch.cat((hidden[-2, :, :], hidden[-1, :, :]), dim=1)) # 連接最后一層的雙向輸出
logits = self.classifier(hidden)
if labels is not None:
loss_fct = CrossEntropyLoss()
loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
return loss
else:
return logits
5.4.3 Bert+attention
當(dāng)然,你也可以加一個attention。
In [ ]
class BertATT(BertPreTrainedModel):
"""BERT model for classification.
This module is composed of the BERT model with a linear layer on top of
the pooled output.
Params:
`config`: a BertConfig class instance with the configuration to build a new model.
`num_labels`: the number of classes for the classifier. Default = 2.
"""
def __init__(self, config, num_labels):
super(BertATT, self).__init__(config)
self.num_labels = num_labels
self.bert = BertModel(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(config.hidden_size, num_labels)
self.W_w = nn.Parameter(torch.Tensor(config.hidden_size, config.hidden_size))
self.u_w = nn.Parameter(torch.Tensor(config.hidden_size, 1))
nn.init.uniform_(self.W_w, -0.1, 0.1)
nn.init.uniform_(self.u_w, -0.1, 0.1)
self.apply(self.init_bert_weights)
def forward(self, input_ids, token_type_ids=None, attention_mask=None, labels=None):
"""
Args:
input_ids: 詞對應(yīng)的 id
token_type_ids: 區(qū)分句子,0 為第一句,1表示第二句
attention_mask: 區(qū)分 padding 與 token, 1表示是token,0 為padding
"""
encoded_layers, _ = self.bert(
input_ids, token_type_ids, attention_mask, output_all_encoded_layers=False)
encoded_layers = self.dropout(encoded_layers)
# encoded_layers: [batch_size, seq_len, bert_dim=768]
score = torch.tanh(torch.matmul(encoded_layers, self.W_w))
# score: [batch_size, seq_len, bert_dim]
attention_weights = F.softmax(torch.matmul(score, self.u_w), dim=1)
# attention_weights: [batch_size, seq_len, 1]
scored_x = encoded_layers * attention_weights
# scored_x : [batch_size, seq_len, bert_dim]
feat = torch.sum(scored_x, dim=1)
# feat: [batch_size, bert_dim=768]
logits = self.classifier(feat)
# logits: [batch_size, output_dim]
if labels is not None:
loss_fct = CrossEntropyLoss()
loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
return loss
else:
return logits
6 微調(diào)階段的調(diào)整策略
6.1不同學(xué)習(xí)率的設(shè)置
在《How to Fine-Tune BERT for Text Classification?》一文中作者提到了一個策略。
這個策略叫作 slanted triangular(繼承自 ULM-Fit)。它和 BERT 的原版方案類似,都是帶 warmup 的先增后減。通常來說,這類方案對初始學(xué)習(xí)率的設(shè)置并不敏感。但是,在 fine-tune階段使用過大的學(xué)習(xí)率,會打亂 pretrain 階段學(xué)習(xí)到的句子信息,造成“災(zāi)難性遺忘”。
比如下方的圖(源于論文),最右邊學(xué)習(xí)率=4e-4的loss已經(jīng)完全無法收斂了,而學(xué)習(xí)率=1e-4的loss曲線明顯不如學(xué)習(xí)率=2e-5和學(xué)習(xí)率=5e-5的低。
綜上所述,對于BERT模型的訓(xùn)練和微調(diào)學(xué)習(xí)率取2e-5和5e-5效果會好一些。
不過對于上述的學(xué)習(xí)率針對的是BERT沒有下游微調(diào)結(jié)構(gòu)的,是直接用BERT去fine-tune。
那如果微調(diào)的時候接了更多的結(jié)構(gòu),是不是需要再考慮下學(xué)習(xí)率的問題呢?大家思考一下?
答案是肯定的,我們需要考慮不同的學(xué)習(xí)率來解決不同結(jié)構(gòu)的問題。比如BERT+TextCNN,BERT+BiLSTM+CRF,在這種情況下。
BERT的fine-tune學(xué)習(xí)率可以設(shè)置為5e-5, 3e-5, 2e-5。
而下游任務(wù)結(jié)構(gòu)的學(xué)習(xí)率可以設(shè)置為1e-4,讓其比bert的學(xué)習(xí)更快一些。
至于這么做的原因也很簡單:BERT本體是已經(jīng)預(yù)訓(xùn)練過的,即本身就帶有權(quán)重,所以用小的學(xué)習(xí)率很容易fine-tune到最優(yōu)點,而下接結(jié)構(gòu)是從零開始訓(xùn)練,用小的學(xué)習(xí)率訓(xùn)練不僅學(xué)習(xí)慢,而且也很難與BERT本體訓(xùn)練同步。
為此,我們將下游任務(wù)網(wǎng)絡(luò)結(jié)構(gòu)的學(xué)習(xí)率調(diào)大,爭取使兩者在訓(xùn)練結(jié)束的時候同步:當(dāng)BERT訓(xùn)練充分時,下游任務(wù)結(jié)構(gòu)也能夠訓(xùn)練充分。
6.2 weight decay權(quán)重衰減
權(quán)重衰減等價于L2范數(shù)正則化。正則化通過為模型損失函數(shù)添加懲罰項使得學(xué)習(xí)的模型參數(shù)值較小,是常用的過擬合的常用手段。
權(quán)重衰減并不是所有的權(quán)重參數(shù)都需要衰減,比如bias,和LayerNorm.weight就不需要衰減。
具體實現(xiàn)可以參照下面部分代碼。
In [ ]
no_decay = ['bias', 'LayerNorm.weight']
optimizer_grouped_parameters = [
{'params': [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], 'weight_decay': 1e-2},
{'params': [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], 'weight_decay': 0.0}
]
# 對應(yīng)optimizer_grouped_parameters中的第一個dict,這里面的參數(shù)需要權(quán)重衰減
need_decay = []
for n, p in model.named_parameters():
if not any(nd in n for nd in no_decay):
need_decay.append(p)
# 對應(yīng)optimizer_grouped_parameters中的第二個dict,這里面的參數(shù)不需要權(quán)重衰減
not_decay = []
for n, p in model.named_parameters():
if any(nd in n for nd in no_decay):
not_decay.append(p)
# AdamW是實現(xiàn)了權(quán)重衰減的優(yōu)化器
optimizer = AdamW(optimizer_grouped_parameters, lr=1e-5)
criterion = nn.CrossEntropyLoss()
6.3 實戰(zhàn)中的遷移策略
那拿到一個BERT預(yù)訓(xùn)練模型后,我們會有兩種選擇:
- 把BERT當(dāng)做特征提取器或者句向量,不在下游任務(wù)中微調(diào)。
- 把BERT做為下游業(yè)務(wù)的主要模型,在下游任務(wù)中微調(diào)。
具體的使用策略要多加嘗試,沒有絕對的正確。
那如何在代碼中控制BERT是否參與微調(diào)呢?代碼如下:
In [ ]
class Model(nn.Module):
def __init__(self, config):
super(Model, self).__init__()
init_checkpoint = config['init_checkpoint']
freeze_bert = config['freeze_bert']
dropout = config['dropout']
self.use_bigru = config['use_bigru']
self.output_hidden_states = config['output_hidden_states']
self.concat_output = config['concat_output']
self.config = config
bert_config = BertConfig.from_pretrained(os.path.join(init_checkpoint, 'bert_config.json'),
output_hidden_states=self.output_hidden_states)
self.model = BertModel.from_pretrained(os.path.join(init_checkpoint, 'pytorch_model.bin'),
config=bert_config)
self.dropout = nn.Dropout(dropout)
# bert是否參與微調(diào),可以通過一下代碼實現(xiàn)
if freeze_bert:
for p in self.model.parameters():
p.requires_grad = False # 亦可以針對性的微調(diào)或者凍結(jié)某層參數(shù)
if self.use_bigru:
self.biGRU = torch.nn.GRU(768, 768, num_layers=1, batch_first=True, bidirectional=True)
self.dense = nn.Linear(bert_config.hidden_size * 2, 3) # 連接bigru的輸出層
elif self.concat_output:
self.dense = nn.Linear(bert_config.hidden_size * 3, 3) # 連接concat后的三個向量
else:
self.dense = nn.Linear(bert_config.hidden_size, 3) # 輸出3維(3分類)
那如果有選擇的進行bert某些層的凍結(jié)可以參照以下代碼。
In [ ]
# Freeze parts of pretrained model
# config['freeze'] can be "all" to freeze all layers,
# or any number of prefixes, e.g. ['embeddings', 'encoder']
if 'freeze' in config and config['freeze']:
for name, param in self.base_model.named_parameters():
if config['freeze'] == 'all' or 'all' in config['freeze'] or name.startswith(tuple(config['freeze'])):
param.requires_grad = False
logging.info(f"Froze layer {name}...")
In [ ]
if freeze_embeddings:
for param in list(model.bert.embeddings.parameters()):
param.requires_grad = False
print ("Froze Embedding Layer")
# freeze_layers is a string "1,2,3" representing layer number
if freeze_layers is not "":
layer_indexes = [int(x) for x in freeze_layers.split(",")]
for layer_idx in layer_indexes:
for param in list(model.bert.encoder.layer[layer_idx].parameters()):
param.requires_grad = False
print ("Froze Layer: ", layer_idx)
7 完成你的BERT任務(wù)(作業(yè)在其中)
在做項目前可以執(zhí)行下列語句安裝所需的庫。
In [1]
# 也可以在終端里安裝,注意下版本
!pip install transformers==3.4.0
該部分項目采用數(shù)據(jù)集為中文文本分類數(shù)據(jù)集THUCNews。
THUCNews是根據(jù)新浪新聞RSS訂閱頻道2005~2011年間的歷史數(shù)據(jù)篩選過濾生成,包含74萬篇新聞文檔(2.19 GB),均為UTF-8純文本格式。我們在原始新浪新聞分類體系的基礎(chǔ)上,重新整合劃分出14個候選分類類別:財經(jīng)、彩票、房產(chǎn)、股票、家居、教育、科技、社會、時尚、時政、體育、星座、游戲、娛樂。
該部分?jǐn)?shù)據(jù)已經(jīng)經(jīng)過處理,放在了data/data59734下。如果有想了解原始數(shù)據(jù)的同學(xué),可以去官網(wǎng)查詢。
訓(xùn)練過程中所需要的預(yù)訓(xùn)練模型在data/data56340下。
ok,到這里我們有關(guān)BERT的課程就基本結(jié)束了,最后留給大家一個代碼作業(yè)。
到這里,大家可以啟動GPU環(huán)境來完成作業(yè)了。
在work/TextClassifier-main中提供了一個基于bert的baseline,大家針對下面要求完成作業(yè)就好。
作業(yè)提交要求:
- 修改baseline,利用前面課程中提出的任何一種方法(用cnn等改造微調(diào)模型、調(diào)參、改變遷移策略等等),并跑至少4個epoch。同時將print的結(jié)果圖片發(fā)到這里(本文最后我留一行讓大家加圖片)。
- 將你設(shè)計的方法相關(guān)代碼(或文字說明)復(fù)制到我預(yù)留的位置,方便老師查閱。
7.1 訓(xùn)練過程中的注意事項
1.原始數(shù)據(jù)大概要35w條,為了縮短計算時間,如下如所示,我將數(shù)據(jù)做了5w條的采樣。大家如果想用全量數(shù)據(jù)試驗,可以自行修改代碼。
2.訓(xùn)練過程中需要查看GPU使用情況,可以如下圖所示打開一個新的終端,并在終端中執(zhí)行下列代碼。
In [ ]
watch -n 0.1 -d nvidia-smi
3.下圖就是大家需要提交自己訓(xùn)練結(jié)果的截圖實例。