準備數據集
使用編碼工具
首先需要加載編碼工具,編碼工具可以將抽象的文字轉成數字,便于神經網絡后續的處理,其代碼如下:
# 定義數據集
from transformers import BertTokenizer, BertModel, AdamW
# 加載tokenizer
token = BertTokenizer.from_pretrained('bert-base-chinese')
print('token', token)
out:
token BertTokenizer(name_or_path=‘bert-base-chinese’, vocab_size=21128, model_max_length=512, is_fast=False, padding_side=‘right’, truncation_side=‘right’, special_tokens={‘unk_token’: ‘[UNK]’, ‘sep_token’: ‘[SEP]’, ‘pad_token’: ‘[PAD]’, ‘cls_token’: ‘[CLS]’, ‘mask_token’: ‘[MASK]’}, clean_up_tokenization_spaces=True), added_tokens_decoder={
0: AddedToken(“[PAD]”, rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
100: AddedToken(“[UNK]”, rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
101: AddedToken(“[CLS]”, rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
102: AddedToken(“[SEP]”, rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
103: AddedToken(“[MASK]”, rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
}
由上可知bert-base-chinese
模型的字典中共有21128
個詞,編碼器編碼句子的最大長度為512
個詞,并且能夠看到bert-base-chinese
模型所使用的一些特殊符號,例如SEK
,PAD
等。
這里使用的編碼工具是bert-base-chinese
,編碼工具和預訓練模型往往是成對使用的,后續將使用同名的預訓練語言模型作為backbone。
編碼工具的試算
加載完成編碼工具之后可以進行一次試算,觀察編碼工具的輸入和輸出,代碼如下:
data = token.batch_encode_plus(batch_text_or_text_pairs=['關注博主,不迷路。','俺要帶你上高速。'], truncation=True,padding='max_length',max_length=12,return_tensors='pt',return_length=True)
# 查看編碼輸出
for k,v in out.items():print(k,v.shape)
# 把編碼還原成句子
print(token.decode(out['input_ids'][0]))
out:
input_ids torch.Size([2, 17])
token_type_ids torch.Size([2, 17])
length torch.Size([2])
attention_mask torch.Size([2, 17])
[CLS] 關 注 博 主 , 不 迷 路 。 [SEP] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD]
[CLS] 俺 要 帶 你 上 高 速 。 [SEP] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD]
編碼工具的參數說明
對于編碼工具的使用,特別是參數值的含義可以參考下面的兩段代碼:
"""使用簡單的編碼"""
# 編碼兩個句子
out = tokenizer.encode(# 句子1text = sents[0],text_pair = sents[1],# 當句子長度大于max_length時進行截斷truncation=True,# 一律補充pad到max_length長度padding = 'max_length',add_special_tokens = True,# 許多大模型的階段也是使用512作為最終的max_lengthmax_length=30,return_tensors=None,
)
"""增強的編碼函數"""
# 增強的編碼函數
out = tokenizer.encode_plus(text = sents[0],text_pair = sents[1],#當句子長度大于max_length時進行截斷操作truncation = True,#一律補零到max_length長度padding='max_length',max_length=30,add_special_tokens=True,#可以取值tf,pt,np,默認返回list--->tensorflow,pytorch,numpyreturn_tensors=None,#返回token_type_idsreturn_token_type_ids=True,#返回attention_maskreturn_attention_mask=True,#返回special_tokens_mask 特殊符號標識return_special_tokens_mask=True,#返回offset_mapping標識每個詞的起始和結束位置---》這個參數只能BertTokenizerFast使用#return_offsets_mapping=True,#返回length 標識長度return_length=True
)
從上面的代碼中的參數max_length=500
可以看出經過編碼后的句子的長度一定是12個詞的長度。如果源句子超出則會進行截斷,如果源句子不足則會進行填充PAD,其運行結果如下:
{'input_ids': tensor([[ 101, 1068, 3800, 1300, 712, 8024, 679, 6837, 6662, 511, 102, 0],[ 101, 939, 6206, 2372, 872, 677, 7770, 6862, 511, 102, 0, 0]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]), 'length': tensor([11, 10]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0]])}
input_ids torch.Size([2, 12])
token_type_ids torch.Size([2, 12])
length torch.Size([2])
attention_mask torch.Size([2, 12])
[CLS] 關 注 博 主 , 不 迷 路 。 [SEP] [PAD]
[CLS] 俺 要 帶 你 上 高 速 。 [SEP] [PAD] [PAD]
編碼工具首先是對一條完整的句子進行了tokenizer
,把句子分成了一個個token
。同時,對于不同的編碼工具,分詞的結果也不一定一致。這里采用的bert-base-chinese
編碼工具中,它是以字為詞,即把每個字當做一個詞進行處理。
這些編碼的結果對于預訓練模型的計算十分重要,在后面將會使用編碼器將所有的句子進行編碼,用于輸入到預訓練模型中進行計算。
定義數據集
這里使用的數據集為ChnSentiCorp數據集,Dataset
類如下:
# import torch
from datasets import load_dataset
class Dataset(torch.utils.data.Dataset):def __init__(self, split):self.dataset = load_dataset(path='lansinuote/ChnSentiCorp', split=split)def __len__(self):return len(self.dataset)def __getitem__(self, i):text = self.dataset[i]['text']label = self.dataset[i]['label']return text, label
dataset = Dataset('train')
print(len(dataset))
print(dataset[0])
在上述代碼中加載了ChnSentiCorp數據集,并使用Pytorch中的Dataset對象進行封裝,利用__getitem__()
得到每一條數據,每條數據中包含text
和labels
兩個字段,最后初始化訓練數據集并查看訓練數據集的長度和第一條數據樣例。
out: 9600
('選擇珠江花園的原因就是方便,有電動扶梯直接到達海邊,周圍餐館、食廊、商場、超市、攤位一應俱全。酒店裝修一般,但還算整潔。 泳池在大堂的屋頂,因此很小,不過女兒倒是喜歡。 包的早餐是西式的,還算豐富。 服務嗎,一般', 1)
由上面的輸出可知訓練數據集包括9600條數據,每條數據包含一條評論文本和一個標識,表明這一條評論是好評還是差評。注意:這里的數據集是單純的原始文本數據,并沒有進行編碼。
定義計算設備
這里將使用CUDA作為計算設備,這樣可以極大加速模型的訓練和測試的過程,代碼如下:
device = 'cpu'
if torch.cuda.is_available():device = 'CUDA'
print('選用的計算設備:',device)
在該段代碼中默認使用CPU進行計算,如果存在CUDA的話則選用CUDA作為計算設備。
定義數據整理函數
正如上面所述的那樣,ChnSentiCorp
數據集中的每一條數據是抽象的文本數據,并沒有進行任何的編碼操作,而預訓練模型是需要編碼之后的數據才能進行計算,所以需要一個將文本句子轉成編碼的過程。
另外,在訓練模型時數據集往往很大,如果一條一條地處理則效率會太低,在現實中我們往往一批一批地處理數據,這樣可以快速地處理數據集,同時從梯度下降的角度來講,批數據的梯度方差相較于一條條數據的梯度小,可以讓模型更加穩定地更新參數。
# 定義批處理函數
def collate_fn(data):sents = [i[0] for i in data]labels = [i[1] for i in data]# 編碼data = token.batch_encode_plus(batch_text_or_text_pairs=sents, truncation=True,padding='max_length',max_length=500,return_tensors='pt',return_length=True)# input_ids:編碼之后的數字# attention_masks:補0的位置都是0,其他位置都是1input_ids = data['input_ids']attention_mask = data['attention_mask']token_type_ids = data['token_type_ids']labels = torch.LongTensor(labels)# print(data['length'],data['length'].max())return input_ids, attention_mask, token_type_ids, labels
在這段代碼中,參數data
表示一批數據,取出其中的句子和標識,它們都是list
類型,在上述代碼中會將兩者分別賦給sents
和labels
,然后是使用編碼器編碼該批句子,在參數中將編碼后的結果指定為固定的500
個詞的大小,與上面的例子同理超出500個詞的部分會被截斷(這里是通過truncation=True
控制),同時少于500個詞的句子會被[PAD]
填充(這里主要是通過 padding='max_length'
控制)。另外,在編碼過程中通過 return_tensors='pt'
參數,將編碼后的結果返回torch
中的tensor
類型,免去了后面轉換數據格式的麻煩(也就是說后面可以通過數據格式轉換可以將‘tf’
轉成‘pt’
格式)。
之后取出編碼后的結果,并將labels
也轉成Pytorch
中的Tensor
格式,再把它們移動到之前已經定義好的計算設備device
上,最后把這些數據全部返回,到這里數據整理函數的工作已經全部完成。
數據處理函數的例子
上述定義了數據處理函數,為了實驗其效果也可使用下面的例子:(本用例已加狗頭保命~)
data = [('選擇新大的原因當然不是為了延畢。',1),('筆記本的內存確實小。',0),('宿舍沒有風扇。其他都很好。',1),('今天才知道這本書還有第10000卷,真是太屌了。',1),('機器的背面似乎被撕了張什么標簽,殘膠還在。',0),('為什么有人在校園里尖叫,是瘋了還是giao。',0)
]# 狗頭保命版試算
input_ids,attention_mask,token_type_ids,labels = collate_fn(data)
print('input_ids.shape',input_ids.shape)
print('attention_mask.shape',attention_mask.shape)
print('token_type_ids.shape',token_type_ids.shape)
print('labels:',labels)
在該段代碼中首先是模擬了一批數據,這批數據中包含4個句子,通過將該批數據輸入到整理函數以后,運行結果如下:
input_ids.shape torch.Size([6, 500])
attention_mask.shape torch.Size([6, 500])
token_type_ids.shape torch.Size([6, 500])
labels: tensor([1, 0, 1, 1, 0, 0])
可見編碼之后的結果都是確定的500個詞的長度,并且每個結果都會被移動到可用的計算設備上,這樣可以方便后續的計算。
定義數據加載器
上述代碼中定義了數據集和數據整理函數以后,下面我們將定義一個數據加載器DataLoader
,它可以使用數據整理函數來完成成批地處理數據集中的數據,通俗來講每一批的數據我們可以稱為batch
。
# 定義數據加載器并查看數據樣例
loader = torch.utils.data.DataLoader(dataset=dataset, batch_size=16,collate_fn=collate_fn,shuffle=True,drop_last=True)
對于上述代碼,我們使用了Pytorch提供的工具類定義數據集加載器,其參數說明可參考下圖:
數據加載器的例子
為了更好地使用數據加載器,這里我們查看一批數據樣例,將這批數據輸入到數據加載器中,可以發現其結果會與數據整理函數的運行結果相似,只不過是句子的數量增多了。
上述代碼依次打印了加載器中批次數目、加載器中輸入數據的input_ids
和掩蔽注意力的形狀
attention_mask_shape
、詞元的ids類型形狀token_type_ids_shape
以及標簽labels
for i, (input_ids, attention_mask, token_type_ids, labels) in enumerate(loader):break
print(len(loader))
print('input_ids', input_ids)
print('attention_mask_shape', attention_mask.shape)
print('token_type_ids_shape', token_type_ids.shape)
print('labels', labels)
- input_ids 就是編碼后的詞
- token_type_ids 第一個句子和特殊符號的位置是0,第二個句子的位置是1
- attention_mask pad的位置是0,其他位置都是1
- special_tokens_mask 特殊符號的位置是1,其他位置都是0
定義模型
因為我們是要利用Huggingface的預訓練語言模型,所以需要做兩件事情:加載預訓練模型PLM以及定義下游任務模型。
加載預訓練模型
這里使用的BERT預訓練模型,模型名稱為bert-base-chinese,這里的名稱和編碼器的名稱是一致的,因為往往模型和其編碼工具配套使用。另外,BERT模型不是必須的模型,進行中文情感分類也可以使用其他支持中文的模型,例如BART等。
from transformers import BertModel
# 加載預訓練模型
pretrained = BertModel.from_pretrained('bert-base-chinese')
# 統計參數量
sum(i.numel() for i in pretrained.parameters()) / 10000
out:10226.7648
由上可知bert-base-chinese
模型的參數量超過1億個,這個模型的體量還是比較大的。由于它的體量比較大,所以如果要訓練它,對計算資源的要求較高,而對于本次的二分類任務則可以選擇不訓練它,只是作為一個特征提取器。這樣就可以避免訓練這個笨重的模型,不需要計算它的梯度,進而不更新它的參數,所以需要凍結它的參數:
# 當不進行訓練時不需要計算梯度
for param in pretrained.parameters():param.requires_grad_(False)
定義好PLM后,需要進行一次試算,觀察模型的輸入和輸出結果:
# 設定計算設備
pretrained.to(device)
# 模型試算
out = pretrained(input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids)
print(out.last_hidden_state.shape)
這里可能會報錯:
RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu! (when checking argument for argument index in method wrapper_CUDA__index_select)
這是因為之前我們忘了將input_ids
等參數放到cuda
上,所以需要改一下代碼:
for i, (input_ids, attention_mask, token_type_ids, labels) in enumerate(loader):break
input_ids = input_ids.to(device)
attention_mask = attention_mask.to(device)
token_type_ids = token_type_ids.to(device)
labels = labels.to(device)
print(len(loader))
print('input_ids', input_ids)
print('attention_mask_shape', attention_mask.shape)
print('token_type_ids_shape', token_type_ids.shape)
print('labels', labels)
print(input_ids.is_cuda)
這里如果有顯卡的話會輸出True
,然后上面的程序報錯也會消除,同時輸出:
torch.Size([16, 500, 768])
上述代碼中將我們的16個樣例數據(在定義數據加載器部分)輸入到預訓練模型中,得到的計算結果和我們預想的是一致的。首先從第一個維度16可以看出是和我們的樣例輸入的句子數量有關的,隨后的500是指每句話中包含了500個單詞,因為max_length=500(在數據整理函數中定義),這就把之前所有的內容都串起來了。最后的768表示將每一個詞抽成一個768維的向量。到此為止,我們已經通過預訓練模型成功地把16句話轉換成為了一個特征向量矩陣,這樣就可以接入下面的下游任務模型做分類或者回歸任務。
定義下游任務模型
下游任務模型的任務是對backbone
抽取的特征進行進一步的計算,得到符合業務需求的計算結果,這里做的是一個二分類的結果,因為我們數據集中的labels只有兩種。
# 定義下游任務模型
class Model(torch.nn.Module):def __init__(self):super(Model, self).__init__()self.fc = torch.nn.Linear(768, 2).to(device)def forward(self, input_ids, attention_mask, token_type_ids):with torch.no_grad():out = pretrained(input_ids=input_ids,attention_mask=attention_mask,token_type_ids=token_type_ids)out = self.fc(out.last_hidden_state[:, 0])out = out.softmax(dim=1)return outmodel = Model()
# 設置計算設備
model.to(device)
在這段代碼中,定義了一個下游任務模型,該模型包括一個全連接的LinearModel
,權重矩陣是768x2
,所以它能夠將一個768維度的向量轉換成一個二維空間中。
上述的下游任務模型的計算流程為:
這里之所以丟棄后面466個詞的特征,是因為BERT模型所致,具體的內容可以參考BERT模型的論文:BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding
下游任務模型試算
在最后我們使用剛才使用的batch=16
的數據進行試算:
# 試算
print(model(input_ids=input_ids,attention_mask=attention_mask,token_type_ids=token_type_ids).shape)
其輸出結果為:
torch.Size([16, 2])
由此可見這就是我們一開始所要求的16句話進行二分類的結果。
訓練和測試
模型訓練
模型定義完成之后,我們就可以對該模型進行訓練了~代碼如下:
from transformers import AdamW
from transformers.optimization import get_scheduler
def train():# 定義優化器optimizer = torch.optim.AdamW(model.parameters(), lr=5e-4)# 定義損失函數criterion = torch.nn.CrossEntropyLoss()# 定義學習率調節器scheduler = get_scheduler(name='linear',num_warmup_steps=0,num_training_steps=len(loader),optimizer=optimizer)# 將模型切換到訓練模式model.train()# 按批次進行遍歷訓練數據集中的數據for i, (input_ids, attention_mask, token_type_ids, labels) in enumerate(loader):input_ids = input_ids.to(device)attention_mask = attention_mask.to(device)token_type_ids = token_type_ids.to(device)labels = labels.to(device)# 模型計算out = model(input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids).to(device)# 計算損失并使用梯度下降法優化模型參數loss = criterion(out, labels)loss.backward()optimizer.step()scheduler.step()optimizer.zero_grad()# 輸出各項數據的情況,便于觀察if i % 10 == 0:out = out.argmax(dim=1)accuracy = (out == labels).sum().item() / len(labels)lr = optimizer.state_dict()['param_groups'][0]['lr']print(i, loss.item(),lr, accuracy)
train()
在上述的代碼中首先定義了優化器、損失函數、學習率調節器。其中優化器使用了HuggingFace
提供的AdamW
優化器,這是傳統的Adam優化器的改進版本,在自然語言處理任務中,該優化器往往取得了比Adam優化器更加好的成績,并且計算效率高。學習率調節器使用了HuggingFace
提供的線性學習率調節器,它能夠在訓練過程中讓學習率緩慢下降,而不是始終使用一致的學習率,因為在訓練的后期階段往往需要更小的學習率來微調參數,有利于損失函數下降到最低點。這里的損失函數采用了分類任務中常用的CrossEntropyLoss
交叉熵損失函數。
然后將下游任務模型切換到訓練模式即可開始訓練。最后每當優化10次模型參數時,就計算一次當前模型預測結果的正確率,并輸出模型的損失函數、學習率,最終訓練完畢的結果如下所示:
由上圖可見在訓練到大約200個steps時,模型已經能夠達到85%
左右的正確率,損失函數也如同預期一樣隨著訓練過程不斷下降,學習率亦如此。
模型測試
對于已經訓練好的模型進行測試,以便驗證訓練的有效性,其測試代碼如下:
def test():# 定義測試數據加載器loader_test = torch.utils.data.DataLoader(dataset = Dataset('test'),batch_size = 32,collate_fn = collate_fn,shuffle = True,drop_last = True)# 將下游任務模型切換到運行模式model.eval()correct = 0total = 0for i, (input_ids, attention_mask, token_type_ids, labels) in enumerate(loader_test):# 計算5個批次即可,不需要全部遍歷input_ids = input_ids.to(device)attention_mask = attention_mask.to(device)token_type_ids = token_type_ids.to(device) labels = labels.to(device) if i == 5:breakprint(i)# 計算with torch.no_grad():out = model(input_ids=input_ids,attention_mask=attention_mask,token_type_ids=token_type_ids).to(device)# 統計正確率out = out.argmax(dim=1)correct += (out==labels).sum().item()total += len(labels)print(correct/total)test()
在上述的代碼中首先定義了測試數據集及其加載器,同時取出5個批次的數據讓模型進行預測,最后將統計的正確率輸出,運行結果為:
0
1
2
3
4
0.88125
最終模型取得了88.125%
的正確率,這個正確率雖然不是很高,但是驗證了下游任務模型即使在不訓練basebone
的情況下也可以達到一定的成績。
省流版-全部代碼
總結
本文通過一個情感分類的例子說明了使用BERT
預訓練模型抽取文本特征數據的方法,使用BERT
作為backbone
,相對于傳統的RNN
而言其計算量會稍稍大一些,但是BERT
抽取的文本特征將更加完整,更容易被下游任務模型識別總結出數據之間的規律。所以在不對BERT預訓練模型進行訓練,而是簡單應用于下游任務時也可以表現一個比較好的結果。