生成式建模知識回顧:
[1] 生成式建模概述
[2] Transformer I,Transformer II
[3] 變分自編碼器
[4] 生成對抗網絡,高級生成對抗網絡 I,高級生成對抗網絡 II
[5] 自回歸模型
[6] 歸一化流模型
[7] 基于能量的模型
[8] 擴散模型 I, 擴散模型 II
本博文是嘗試創建一個關于如何使用 PyTorch 構建 BERT 架構的完整教程。本教程的完整代碼可在pytorch_bert獲取。
引言
BERT 代表 Transformers 的雙向編碼器表示。BERT的原始論文:BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding,實際上解釋了您需要了解的有關 BERT 的所有內容。
老實說,互聯網上有很多更好的文章解釋 BERT 是什么,例如BERT Expanded: State of the art language model for NLP。讀完本文后,你可能對注意力機制有一些疑問;這篇文章: Illustrated: Self-Attention 解釋了注意力。
在本段中我只是想回顧一下 BERT 的思想,并更多地關注實際實現。BERT 同時解決兩個任務:
- 下一句預測(NSP);
- 掩碼語言模型(MLM)。
下一句話預測 (NSP)
NSP 是一個二元分類任務。輸入兩個句子,我們的模型應該能夠預測第二個句子是否是第一個句子的真實延續。
比如有一段:
I have a cat named Tom. Tom likes to play with birds sitting on the window. They like this game not. I also have a dog. We walk together everyday.
潛在的數據集看起來像:
句子 | NSP類別 |
---|---|
I have a cat named Tom. Tom likes to play with birds sitting on the window | is next |
I have a cat named Tom. We walk together everyday | is not next |
掩碼語言模型 (MLM)
掩碼語言模型是預測句子中隱藏單詞的任務。
例如有一句話:
Tom likes to [MASK] with birds [MASK] on the window.
該模型應該預測屏蔽詞是 play 和 sitting。
構建 BERT
為了構建 BERT,我們需要制定三個步驟:
- 準備數據集;
- 建立一個模型;
- 建立一個訓練器 (Trainer)。
準備數據集
對于 BERT,應該以特定的某種方式來準備數據集。我大概花了 30% 的時間和腦力來構建 BERT 模型的數據集。因此,值得用一個段落來進行討論。
原始 BERT 使用 BooksCorpus(8 億字)和英語維基百科(2,500M 字)進行預訓練。我們使用大約 72k 字的 IMDB reviews data 數據集。
從Kaggle: IMDB Dataset of 50K Movie Reviews下載數據集,并將其放在data/ 項目根目錄下。
接下來,對于pytorch 的數據集和數據加載器,我們必須創建繼承 torch.utils.data.Dataset 類的數據集。
class IMDBBertDataset(Dataset):# Define Special tokens as attributes of classCLS = '[CLS]'PAD = '[PAD]'SEP = '[SEP]'MASK = '[MASK]'UNK = '[UNK]'MASK_PERCENTAGE = 0.15 # How much words to maskMASKED_INDICES_COLUMN = 'masked_indices'TARGET_COLUMN = 'indices'NSP_TARGET_COLUMN = 'is_next'TOKEN_MASK_COLUMN = 'token_mask'OPTIMAL_LENGTH_PERCENTILE = 70def __init__(self, path, ds_from=None, ds_to=None, should_include_text=False):self.ds: pd.Series = pd.read_csv(path)['review']if ds_from is not None or ds_to is not None:self.ds = self.ds[ds_from:ds_to]self.tokenizer = get_tokenizer('basic_english')self.counter = Counter()self.vocab = Noneself.optimal_sentence_length = Noneself.should_include_text = should_include_textif should_include_text:self.columns = ['masked_sentence', self.MASKED_INDICES_COLUMN, 'sentence', self.TARGET_COLUMN,self.TOKEN_MASK_COLUMN,self.NSP_TARGET_COLUMN]else:self.columns = [self.MASKED_INDICES_COLUMN, self.TARGET_COLUMN, self.TOKEN_MASK_COLUMN,self.NSP_TARGET_COLUMN]self.df = self.prepare_dataset()def __len__(self):return len(self.df)def __getitem__(self, idx):...def prepare_dataset() -> pd.DataFrame:...
__init__中有點奇怪的部分如下:
...
if should_include_text:self.columns = ['masked_sentence', self.MASKED_INDICES_COLUMN, 'sentence', self.TARGET_COLUMN,self.TOKEN_MASK_COLUMN,self.NSP_TARGET_COLUMN]
else:self.columns = [self.MASKED_INDICES_COLUMN, self.TARGET_COLUMN, self.TOKEN_MASK_COLUMN,self.NSP_TARGET_COLUMN]
...
我們定義上面的列來創建self.df. 用should_include_text=True在數據框中包含所創建句子的文本表示。了解我們的預處理算法到底創建了什么是很有用的。
因此,should_include_text=True僅出于調試目的才需要設置。
大部分工作將在該prepare_dataset方法中完成。在該__getitem__方法中,我們準備一個訓練項張量。
為了準備數據集,我們接下來要做:
- 按句子分割數據集
- 為 word-token 對創建詞匯表 例如,{‘go’: 45}
- 創建訓練數據集
- 在句子中添加特殊標記
- 屏蔽句子中 15% 的單詞
- 將句子填充到預定義的長度
- 用兩個句子創建 NSP 項
我們來逐步回顧一下prepare_dataset方法的代碼。
按句子分割數據集并填充詞匯表
檢索句子是我們在prepare_dataset方法中執行的第一個(也是最簡單的)操作。這對于填充詞匯表是必要的。
sentences = []
nsp = []
sentence_lens = []# Split dataset on sentences
for review in self.ds:review_sentences = review.split('. ')sentences += review_sentencesself._update_length(review_sentences, sentence_lens)
self.optimal_sentence_length = self._find_optimal_sentence_length(sentence_lens)
請注意,我們按 . 來分割文本。但正如[devlin et al, 2018]中所述,一個句子可以有任意數量的連續文本;您可以根據需要拆分它。
如果打印sentences[:2]你會看到以下結果:
['One of the other reviewers has mentioned that after watching just 1 Oz '"episode you'll be hooked",'They are right, as this is exactly what happened with me.<br /><br />The ''first thing that struck me about Oz was its brutality and unflinching scenes ''of violence, which set in right from the word GO']
有趣的部分在于我們如何定義句子長度:
def _find_optimal_sentence_length(self, lengths: typing.List[int]): arr = np.array(lengths) return int(np.percentile(arr, self.OPTIMAL_LENGTH_PERCENTILE))
我們不是硬編碼最大長度,而是將所有句子長度存儲在列表中并計算 sentence_lens的70%。對于 50k IMDB,最佳句子長度值為 27。這意味著 70% 的句子長度小于或等于 27。
然后,我們將這些句子輸入詞匯表。我們對每個句子進行標記(tokenize),并用句子標記(單詞)更新計數器 。
print("Create vocabulary")
for sentence in tqdm(sentences): s = self.tokenizer(sentence) self.counter.update(s) self._fill_vocab()
tokenization后的句子是其單詞列表:
"My cat is Tom" -> ['my', 'cat', 'is', 'tom']
這是打印后您應該看到的輸出self.counter:
Counter({'the': 6929,',': 5753,'and': 3409,'a': 3385,'of': 3073,'to': 2774,"'": 2692,'.': 2184,'is': 2123,...
請注意,在本教程中,我們省略了數據集清理的重要步驟。這就是為什么最受歡迎的tokens是the、,、and、a等的原因。
最后,我們準備好建立我們的詞匯表了。該操作被移至_fill_vocab方法:
def _fill_vocab(self): # specials= argument is only in 0.12.0 version # specials=[self.CLS, self.PAD, self.MASK, self.SEP, self.UNK]self.vocab = vocab(self.counter, min_freq=2) # 0.11.0 uses this approach to insert specials self.vocab.insert_token(self.CLS, 0) self.vocab.insert_token(self.PAD, 1) self.vocab.insert_token(self.MASK, 2) self.vocab.insert_token(self.SEP, 3) self.vocab.insert_token(self.UNK, 4) self.vocab.set_default_index(4)
在本教程中,我們將僅將在數據集中出現 2 次或多次的單詞添加到詞匯表中。創建詞匯表后,我們向詞匯表添加特殊標記并將[UNK]標記設置為默認標記。
工作完成了一半🎉我們已經建立了詞匯表。我們來測試一下:
self.vocab.lookup_indices(["[CLS]", "this", "works", "[MASK]", "well"])
輸出:
[0, 29, 1555, 2, 152]
創建訓練數據集
對每個具有多句子的review,我們創建真正的 NSP 項(當第二個句子是reviewer中的下一個句子時)和錯誤的 NSP 項(當第二個句子是來自 sentences 的任何隨機句子時)。
print("Preprocessing dataset")
for review in tqdm(self.ds): review_sentences = review.split('. ') if len(review_sentences) > 1: for i in range(len(review_sentences) - 1): # True NSP item first, second = self.tokenizer(review_sentences[i]), self.tokenizer(review_sentences[i + 1]) nsp.append(self._create_item(first, second, 1)) # False NSP item first, second = self._select_false_nsp_sentences(sentences) first, second = self.tokenizer(first), self.tokenizer(second) nsp.append(self._create_item(first, second, 0))
df = pd.DataFrame(nsp, columns=self.columns)
_create_item方法完成了 99% 的工作。下面的代碼比詞匯創建更棘手。因此,請毫不猶豫地在調試模式下運行代碼。讓我們一步步看一下每次轉換后句子對會發生什么。self._create_item方法的完整實現在Github代碼倉中。
我們應該做的第一件事是在句子中添加特殊標記([CLS], [PAD], )[MASK]
def _create_item(self, first: typing.List[str], second: typing.List[str], target: int = 1): # Create masked sentence item updated_first, first_mask = self._preprocess_sentence(first.copy()) updated_second, second_mask = self._preprocess_sentence(second.copy())nsp_sentence = updated_first + [self.SEP] + updated_second nsp_indices = self.vocab.lookup_indices(nsp_sentence) inverse_token_mask = first_mask + [True] + second_mask
步驟1. 對句子進行掩碼
同樣重要的是,了解我們如何對句子的標記進行掩碼:
def _mask_sentence(self, sentence: typing.List[str]): len_s = len(sentence) inverse_token_mask = [True for _ in range(max(len_s, self.optimal_sentence_length))] mask_amount = round(len_s * self.MASK_PERCENTAGE) for _ in range(mask_amount): i = random.randint(0, len_s - 1) if random.random() < 0.8: sentence[i] = self.MASK else:sentence[i] = self.vocab.lookup_token(j) inverse_token_mask[i] = False return sentence, inverse_token_mask
我們更新句子中隨機 15% 的標記。請注意,對于 80% 的情況,我們設置[MASK]標記,否則我們從詞匯表中設置隨機單詞。
上面代碼中不清楚的部分是inverse_token_mask。當句子中的標記被屏蔽時,該列表有 True 值。例如,我們舉一個句子:
my cat tom likes to sleep and does not like little mice jerry
對句子掩碼后,inverse token mask看起來像:
sentence: My cat mice likes to sleep and does not like [MASK] mice jerry
inverse token mask: [False, False, True, False, False, False, False, False, False, False, True, False, False]
稍后當我們訓練我們的模型時,我們將再次回到inverse token mask。
除了對句子掩碼之外,我們還存儲原始的未屏蔽句子,稍后將其用作 MLM 訓練目標:
# Create sentence item without masking random words
first, _ = self._preprocess_sentence(first.copy(), should_mask=False)
second, _ = self._preprocess_sentence(second.copy(), should_mask=False)
original_nsp_sentence = first + [self.SEP] + second
original_nsp_indices = self.vocab.lookup_indices(original_nsp_sentence)
步驟2. 預處理:[CLS]和[PAD]
現在我們需要在每個句子的開頭添加[CLS]。然后,我們在每個句子的末尾添加[PAD]標記,使它們具有相等的長度。假設我們應該將所有句子對齊到長度值13。
轉換后我們有下面的句子:
[CLS] My cat mice likes to sleep and does not like [MASK] mice jerry
[SEP]
[CLS] jerry is treated as my pet too [PAD] [PAD] [PAD] [PAD] [PAD] [PAD]
_pad_sentence方法負責這種轉換:
def _pad_sentence(self, sentence: typing.List[str], inverse_token_mask: typing.List[bool] = None): len_s = len(sentence) if len_s >= self.optimal_sentence_length: s = sentence[:self.optimal_sentence_length] else: s = sentence + [self.PAD] * (self.optimal_sentence_length - len_s) # inverse token mask should be padded as well if inverse_token_mask: len_m = len(inverse_token_mask) if len_m >= self.optimal_sentence_length: inverse_token_mask = inverse_token_mask[:self.optimal_sentence_length] else: inverse_token_mask = inverse_token_mask + [True] * (self.optimal_sentence_length - len_m) return s, inverse_token_mask
請注意,inverse token mask必須與句子具有相同的長度,因此你也應該填充它。
步驟3. 將句子中的單詞轉換為整數tokens
使用我們預先訓練的詞匯,我們現在將句子轉換成tokens。通過兩行代碼完成:
...
nsp_sentence = updated_first + [self.SEP] + updated_second
nsp_indices = self.vocab.lookup_indices(nsp_sentence)
...
首先,我們通過[SEP]標記連接兩個句子,然后轉換為整數列表。將dataset.py模塊作為腳本運行后,你應該看到預處理的數據集:
masked_sentence ... is_next
0 [[CLS], [MASK], of, the, other, reviewers, has... ... 1
1 [[CLS], once, fifteen, arrived, in, the, ameri... ... 0
2 [[CLS], they, [MASK], [MASK], ,, as, this, is,... ... 1
3 [[CLS], just, a, [MASK], of, [MASK], young, ma... ... 0
4 [[CLS], trust, me, [MASK], this, is, [MASK], a... ... 1... ... ...
8873 [[CLS], freshness, crystal, is, here, to, sell... ... 0
8874 [[CLS], pixar, have, proved, that, they, ', re... ... 1
8875 [[CLS], [MASK], abandons, her, slapstick, [MAS... ... 0
8876 [[CLS], they, raise, the, bar, [MASK], ,, and,... ... 1
8877 [[CLS], he, is, an, amazing, [MASK], artist, ,... ... 0
[8878 rows x 6 columns]
打印數據框中的第一項print(self.df.iloc[0]), 我們看到:
masked_sentence [[CLS], one, of, the, other, [MASK], has, ment...
masked_indices [0, 5, 6, 7, 8, 2, 10, 11, 4825, 13, 2, 15, 16...
sentence [[CLS], one, of, the, other, reviewers, has, m...
indices [0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,...
token_mask [True, True, True, True, False, True, True, Fa...
is_next 1
Name: 0, dtype: object
現在,我們準備編寫__getitem__方法了。
item = self.df.iloc[idx]inp = torch.Tensor(item[self.MASKED_INDICES_COLUMN]).long()
token_mask = torch.Tensor(item[self.TOKEN_MASK_COLUMN]).bool()attention_mask = (inp == self.vocab[self.PAD]).unsqueeze(0)
首先,我們從數據框中選擇項目并創建將用于模型訓練的張量。
當輸入標記為 [PAD]時, attention_mask 值為True。我們在訓練過程中使用它來消除[PAD]標記的嵌入。
我們有模型的輸入,但我們也應該有訓練的目標。
NSP目標
NSP 是一個二元分類問題。
if item[self.NSP_TARGET_COLUMN] == 0: t = [1, 0]
else: t = [0, 1] nsp_target = torch.Tensor(t)
我們將 NSP 目標創建為兩項的張量。它只能有兩種狀態,指定是否是下一句。
[1, 0] is NOT next
[0, 1] is next
為了訓練 NSP 模型,我們使用BCEWithLogitsLoss類。它期望目標類采用上述格式。
MLM目標
我們希望我們的模型僅預測masked tokens:
mask_target = torch.Tensor(item[self.TARGET_COLUMN]).long()
mask_target = mask_target.masked_fill_(token_mask, 0)
我們直接將目標中的所有非掩碼整數設置為0。展望未來,我們將對模型輸出執行相同的操作。
構建 pyTorch 模型
工程在bert package下,完整的神經網絡模型位于model.py文件中。首先,我想向你展示該模型的對象圖。然后我們將逐步瀏覽代碼。
讓我們一步步回顧一下。
聯合嵌入(JointEmbedding)
我們從嵌入開始模型描述。BERT 有三個嵌入層:
- Token embedding
- Segment embedding
- Position embedding
Token embedding用于對word token進行編碼。Segment embedding編碼屬于第一個或第二個句子。我們按以下方式預處理輸入序列:如果標記屬于第一個句子,則設為0,否則設為1。例如,
Input tokens: [0, 6, 24, 565, 67, 0, 443, 123, 5, 6, 5, 12, 1, 1, 1]
Input Segments: [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Position embedding對句子中單詞的位置進行編碼。可以選擇使用嵌入層對序列中token的位置信息進行編碼。在模塊的代碼中,它是在numeric_position方法中完成的。它所做的只是排列整數位置。
Input tokens: [0, 6, 24, 565, 67, 0, 443, 123, 5, 6, 5, 12, 1, 1, 1]
Input position: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
然而,我們使用周期函數來編碼位置,而不是可學習的位置嵌入(如[vaswani 等人,2017]所述 )。
所以,pos變量是sin曲線上的具體值。i是用來選擇pos具體sin曲線。因此,對于i = 0,我們可以得到一條周期曲線來獲取pos值。i = 4,我們還有另一個周期曲線。
我們將所有嵌入保留在一個JoinEmbedding模塊中。下面該模塊的完整代碼。
class JointEmbedding(nn.Module):def __init__(self, vocab_size, size):super(JointEmbedding, self).__init__()self.size = sizeself.token_emb = nn.Embedding(vocab_size, size)self.segment_emb = nn.Embedding(vocab_size, size)self.norm = nn.LayerNorm(size)def forward(self, input_tensor):sentence_size = input_tensor.size(-1)pos_tensor = self.attention_position(self.size, input_tensor)segment_tensor = torch.zeros_like(input_tensor).to(device)segment_tensor[:, sentence_size // 2 + 1:] = 1output = self.token_emb(input_tensor) + self.segment_emb(segment_tensor) + pos_tensorreturn self.norm(output)def attention_position(self, dim, input_tensor):batch_size = input_tensor.size(0)sentence_size = input_tensor.size(-1)pos = torch.arange(sentence_size, dtype=torch.long).to(device)d = torch.arange(dim, dtype=torch.long).to(device)d = (2 * d / dim)pos = pos.unsqueeze(1)pos = pos / (1e4 ** d)pos[:, ::2] = torch.sin(pos[:, ::2])pos[:, 1::2] = torch.cos(pos[:, 1::2])return pos.expand(batch_size, *pos.size())def numeric_position(self, dim, input_tensor):pos_tensor = torch.arange(dim, dtype=torch.long).to(device)return pos_tensor.expand_as(input_tensor)
正如您所看到的,從代碼中我們創建了兩個嵌入層:
self.token_emb = nn.Embedding(vocab_size, size)
self.segment_emb = nn.Embedding(vocab_size, size)
然后在forward方法中我們計算位置編碼張量并準備用于segment Embedding的張量:
pos_tensor = self.attention_position(self.size, input_tensor)segment_tensor = torch.zeros_like(input_tensor).to(device)
segment_tensor[:, sentence_size // 2 + 1:] = 1
然后我們將它們相加并將得到的張量傳遞給LayerNorm。
output = self.token_emb(input_tensor) + self.segment_emb(segment_tensor) + pos_tensor
return self.norm(output)
注意力頭
注意力是 Transformer 的核心。這正是Transformer如此出色的原因。BERT 使用自注意力機制。這篇文章 Illustrated: Self-Attention.對此進行了很好的描述。以下是來自該資源的引用:
“自注意力模塊接受n 個輸入并返回n 個輸出。該模塊中會發生什么?通俗地說,自注意力機制允許輸入相互交互(“自我”)并找出應該更加關注誰(“注意力”)。輸出是這些交互和注意力分數的聚合。”
可以使用多種類型的注意力。我們使用[vaswani et al, 2017]中的定義。
其中Q是查詢,K是鍵,V是值。
對于它們中的每一個,我們創建具有可訓練權重的線性層。因此,我們將教網絡“注意”。在下面的圖片中,您可能會看到注意力倍增的可視化。這正是我們在代碼中所做的。
class AttentionHead(nn.Module): def __init__(self, dim_inp, dim_out): super(AttentionHead, self).__init__() self.dim_inp = dim_inp self.q = nn.Linear(dim_inp, dim_out) self.k = nn.Linear(dim_inp, dim_out) self.v = nn.Linear(dim_inp, dim_out) def forward(self, input_tensor: torch.Tensor, attention_mask: torch.Tensor = None): query, key, value = self.q(input_tensor), self.k(input_tensor), self.v(input_tensor) scale = query.size(1) ** 0.5 scores = torch.bmm(query, key.transpose(1, 2)) / scale scores = scores.masked_fill_(attention_mask, -1e9) attn = f.softmax(scores, dim=-1) context = torch.bmm(attn, value) return context
正如您在 中看到的__init__,我們為查詢、鍵和值創建線性模塊。為了簡單起見,在本教程中它們都具有相同的形狀。
我們繼續上面的例子。dim_inp是嵌入的大小,等于 4。我們將隱藏注意力大小dim_out設為 6。
讓我們按照forward方法一步一步來。我們不會打印張量的值,而是只打印它們的大小(形狀)。
# input tensor is the output of JointEmbedding module
# attention mask is the vector that masks [PAD] tokens
def forward(self, input_tensor: size (2 x 5 x 4), attention_mask: size (2 x 1 x 5)):
我們要做的第一件事是計算查詢、鍵、值張量
query, key, value = size (2 x 5 x 6), size (2 x 5 x 6), size (2 x 5 x 6)
此外,我們計算查詢和鍵的縮放乘法。
scale = query.size(1) ** 0.5
scores = torch.bmm(query, key.transpose(1, 2)) / scale = size (2 x 5 x 5)
torch.bmm是批量矩陣乘法函數。這將批量中的每個矩陣相乘,跳過第一個軸。transpose方法將張量轉置為 2 個特定維度。
我們根本不希望我們的模型“關注”[PAD] tokens。這就是我們有注意力掩模向量的原因。使用這個向量,我們可以隱藏[PAD] tokens的分數。
scores = scores.masked_fill_(attention_mask, -1e9) = size (2 x 5 x 5)
現在,我們計算注意力上下文本身。
attn = f.softmax(scores, dim=-1) = size (2 x 5 x 5)
context = torch.bmm(attn, value) = size (2 x 5 x 6)
因此,每個輸入值都由注意力張量加權。
多頭注意力機制
單個注意力層(頭)僅限于學習來自一個特定子空間的信息。多頭注意力是一組并行注意力頭,它學習從不同的表示中檢索信息。您可以將它們視為卷積神經網絡中的filters。
您可能會在下面的圖片中看到它如何在雙頭注意力的可視化上發揮作用。我們打印單詞it的attentions。第一個注意力(橙色)對單詞animal的得分最多,而第二個注意力(綠色)對單詞tired的得分最多。
讓我們回到我們的代碼。和往常一樣,這里是模塊的完整代碼,然后我們一步一步地看一遍:
class MultiHeadAttention(nn.Module): def __init__(self, num_heads, dim_inp, dim_out): super(MultiHeadAttention, self).__init__() self.heads = nn.ModuleList([ AttentionHead(dim_inp, dim_out) for _ in range(num_heads) ]) self.linear = nn.Linear(dim_out * num_heads, dim_inp) self.norm = nn.LayerNorm(dim_inp) def forward(self, input_tensor: torch.Tensor, attention_mask: torch.Tensor): s = [head(input_tensor, attention_mask) for head in self.heads] scores = torch.cat(s, dim=-1) scores = self.linear(scores) return self.norm(scores)
dim_inp 和 dim_out具有與AttentionHead段落中相同的值:dim_inp等于 4,dim_out等于 6。num_heads是 3。為了簡單起見,我們使用與嵌入大小相同的線性層的輸出大小。
self.linear = nn.Linear(dim_out * num_heads, dim_inp) = nn.Linear(4 * 3, 4)
因此,線性層的輸入大小為 12,輸出為 4。
該forward方法具有與AttentionHead相同的參數。
def forward(self, input_tensor: size (2 x 5 x 4), attention_mask: size (2 x 1 x 5)):
在第一個操作中,我們計算注意力列表s。
s = [head(input_tensor, attention_mask) for head in self.heads]
s = [tensor(2 x 5 x 6),tensor(2 x 5 x 6),tensor(2 x 5 x 6),
]
此外,我們通過最后一個軸連接張量。
scores = torch.cat(s, dim=-1) = tensor(2 x 5 x 18)
通過scores線性層并歸一化。
scores = self.linear(scores) = tensor(2 x 5 x 4)
return self.norm(scores)
編碼器
編碼器由多頭注意力和前饋神經網絡組成。在最初的《Attention Is All You Need》中,使用了相同編碼器層的堆疊。為了簡單起見,我們在本教程中只使用了一個。
class Encoder(nn.Module): def __init__(self, dim_inp, dim_out, attention_heads=4, dropout=0.1): super(Encoder, self).__init__() self.attention = MultiHeadAttention(attention_heads, dim_inp, dim_out) self.feed_forward = nn.Sequential( nn.Linear(dim_inp, dim_out), nn.Dropout(dropout), nn.GELU(), nn.Linear(dim_out, dim_inp), nn.Dropout(dropout) )self.norm = nn.LayerNorm(dim_inp) def forward(self, input_tensor: torch.Tensor, attention_mask: torch.Tensor): context = self.attention(input_tensor, attention_mask) res = self.feed_forward(context) return self.norm(res)
應該對前饋網絡進行解釋,因為它與你在《Attention Is All You Need》中可能看到的網絡略有不同。
self.feed_forward = nn.Sequential( nn.Linear(dim_inp, dim_out), nn.Dropout(dropout), nn.GELU(),nn.Linear(dim_out, dim_inp),nn.Dropout(dropout)
)
原始編碼器具有RelU激活功能。我們使用GelU(參見 高斯誤差線性單位(Gelus))。
我們的前饋網絡用如下公式表示:
此外,我們在每個線性之后添加 dropout 層。
我們為什么使用GelU?只是因為它能帶來更好的結果。您可以關注論文《Searching for Activation Functions》以獲取更多詳細信息。
該forward方法做起來很簡單:
- 計算注意力上下文
- 通過前饋網絡傳遞上下文
- 歸一化
def forward(self, input_tensor: torch.Tensor, attention_mask: torch.Tensor): context = self.attention(input_tensor, attention_mask) res = self.feed_forward(context) return self.norm(res)
BERT
BERT 模塊是一個容器,它將所有模塊組合在一起并返回輸出。
class BERT(nn.Module): def __init__(self, vocab_size, dim_inp, dim_out, attention_heads=4): super(BERT, self).__init__() self.embedding = JointEmbedding(vocab_size, dim_inp) self.encoder = Encoder(dim_inp, dim_out, attention_heads) self.token_prediction_layer = nn.Linear(dim_inp, vocab_size) self.softmax = nn.LogSoftmax(dim=-1) self.classification_layer = nn.Linear(dim_inp, 2) def forward(self, input_tensor: torch.Tensor, attention_mask: torch.Tensor): embedded = self.embedding(input_tensor) encoded = self.encoder(embedded, attention_mask) token_predictions = self.token_prediction_layer(encoded) first_word = encoded[:, 0, :] return self.softmax(token_predictions), self.classification_layer(first_word)
我們使用線性層(和softmax),其輸出等于token預測任務的詞匯量大小。
self.token_prediction_layer = nn.Linear(dim_inp, vocab_size)
self.softmax = nn.LogSoftmax(dim=-1)
下一個句子預測任務的輸出為 2 的線性層
self.classification_layer = nn.Linear(dim_inp, 2)
網絡的輸出:
argmax(NSP output) = [1, 0] is NOT next sentence
argmax(NSP output) = [0, 1] is next sentence
forward過程的一切都很簡單。首先我們計算嵌入,然后將嵌入傳遞給我們的編碼器。
embedded = self.embedding(input_tensor)
encoded = self.encoder(embedded, attention_mask)
其次,我們計算模型的輸出。
token_predictions = self.token_prediction_layer(encoded) first_word = encoded[:, 0, :]
return self.softmax(token_predictions), self.classification_layer(first_word)
還提供完整的模型圖。要構建圖表,請運行腳本graph.py。它將圖形保存到data/logs目錄中。運行tensorBoard
tensorboard --logdir data/logs
在瀏覽器中打開http://localhost:6006,轉到“Graph”選項卡。您應該看到我們的 BERT 模型的圖表。
訓練模型
所有訓練操作均在BertTrainer類的bert.trainer上進行。讓我們看一下類構造函數。
class BertTrainer: def __init__(self, model: BERT, dataset: IMDBBertDataset, log_dir: Path, checkpoint_dir: Path = None, print_progress_every: int = 10, print_accuracy_every: int = 50, batch_size: int = 24, learning_rate: float = 0.005, epochs: int = 5, ): self.model = model self.dataset = dataset self.batch_size = batch_size self.epochs = epochs self.current_epoch = 0 self.loader = DataLoader(self.dataset, batch_size=self.batch_size, shuffle=True) self.writer = SummaryWriter(str(log_dir)) self.checkpoint_dir = checkpoint_dir self.criterion = nn.BCEWithLogitsLoss().to(device) self.ml_criterion = nn.NLLLoss(ignore_index=0).to(device) self.optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate, weight_decay=0.015)
如你所見,我們定義了要訓練的模型、要使用的數據加載器和日志編寫器。我們用來TensorBoard記錄訓練進度。閱讀使用 Tensorboard 可視化模型、數據和訓練,了解如何將 Tensorboard 與 pyTorch 結合使用。
構造函數中最重要的部分是損失和優化器定義。
self.criterion = nn.BCEWithLogitsLoss().to(device)
self.ml_criterion = nn.NLLLoss(ignore_index=0).to(device)
為了訓練NSP任務,我們使用Sigmoid 二元交叉熵損失。為了訓練MLM,我們使用負對數似然。我們使用 Adam 優化器。
訓練過程發生在train方法中。
def train(self, epoch: int): print(f"Begin epoch {epoch}") prev = time.time() average_nsp_loss = 0 average_mlm_loss = 0 for i, value in enumerate(self.loader): index = i + 1 inp, mask, inverse_token_mask, token_target, nsp_target = value self.optimizer.zero_grad() token, nsp = self.model(inp, mask) tm = inverse_token_mask.unsqueeze(-1).expand_as(token) token = token.masked_fill(tm, 0) loss_token = self.ml_criterion(token.transpose(1, 2), token_target)loss_nsp = self.criterion(nsp, nsp_target) loss = loss_token + loss_nsp average_nsp_loss += loss_nsp average_mlm_loss += loss_token loss.backward() self.optimizer.step() if index % self._print_every == 0: elapsed = time.gmtime(time.time() - prev) s = self.training_summary(elapsed, index, average_nsp_loss, average_mlm_loss) if index % self._accuracy_every == 0: s += self.accuracy_summary(index, token, nsp, token_target, nsp_target) print(s) average_nsp_loss = 0 average_mlm_loss = 0 return loss
讓我們回顧一下訓練步驟。
inp, mask, inverse_token_mask, token_target, nsp_target = value
self.optimizer.zero_grad()
我們要做的第一件事是從加載器中檢索批次數據。然后我們將梯度設置為0。
計算前向步數。
token, nsp = self.model(inp, mask)
然后我們在模型輸出中隱藏除[MASK]標記之外的其他token。原因是我們訓練模型僅預測[MASK]標記。
tm = inverse_token_mask.unsqueeze(-1).expand_as(token)
token = token.masked_fill(tm, 0)
此外,我們計算標準并對損失求和。
loss_token = self.ml_criterion(token.transpose(1, 2), token_target)
loss_nsp = self.criterion(nsp, nsp_target) loss = loss_token + loss_nsp
average_nsp_loss += loss_nsp
average_mlm_loss += loss_token
后向一步并更新權重。
loss.backward()
self.optimizer.step()
我們不時地計算模型的準確性
if index % self._accuracy_every == 0: s += self.accuracy_summary(index, token, nsp, token_target, nsp_target)
它計算 MLM 和 NSP 精度
nsp_acc = nsp_accuracy(nsp, nsp_target)
token_acc = token_accuracy(token, token_target, inverse_token_mask)
對于 NSP,我們計算一批中有多少張量被正確預測。
def nsp_accuracy(result: torch.Tensor, target: torch.Tensor):s = (result.argmax(1) == target.argmax(1)).sum() return round(float(s / result.size(0)), 2)
對于 MLM,我們應該做一些操作——對模型輸出和目標應用屏蔽。或者就像我們在代碼中所做的那樣,只需選擇屏蔽標記并進行比較。
def token_accuracy(result: torch.Tensor, target: torch.Tensor, inverse_token_mask: torch.Tensor):r = result.argmax(-1).masked_select(~inverse_token_mask) t = target.masked_select(~inverse_token_mask) s = (r == t).sum() return round(float(s / (result.size(0) * result.size(1))), 2)
訓練結果及總結
最后,我們準備好運行模型的訓練。長話短說,打開main.py腳本文件,檢查學習參數并運行。
我在 nVidia GeForce 1050ti GPU 上訓練了模型。如果支持cuda,模型將默認在 GPU 上進行訓練。使用模型的下一個參數:
EMB_SIZE = 64
HIDDEN_SIZE = 36
EPOCHS = 4
BATCH_SIZE = 12
NUM_HEADS = 4
嵌入大小為 64,隱藏注意力上下文大小為 36,批量大小為 12,注意力頭數量為 4,編碼器數量為 1。學習率為 7e-5。
我們使用 TensorBoard 來跟蹤訓練過程。
運行訓練腳本后,您應該會看到它如何準備 IMDB 數據集
Prepare dataset
Create vocabulary
100%|██████████| 491161/491161 [00:05<00:00, 93957.36it/s]
Preprocessing dataset
100%|██████████| 50000/50000 [00:35<00:00, 1407.99it/s]
然后訓練器打印模型摘要:
Model Summary===================================
Device: cuda
Training dataset len: 882322
Max / Optimal sentence len: 27
Vocab size: 71942
Batch size: 12
Batched dataset len: 73526
===================================
訓練開始了
Begin epoch 0
00:00:02 | Epoch 1 | 20 / 73526 (0.03%) | NSP loss 0.72 | MLM loss 11.25
00:00:04 | Epoch 1 | 40 / 73526 (0.05%) | NSP loss 0.70 | MLM loss 11.22
00:00:06 | Epoch 1 | 60 / 73526 (0.08%) | NSP loss 0.70 | MLM loss 11.13
00:00:08 | Epoch 1 | 80 / 73526 (0.11%) | NSP loss 0.71 | MLM loss 11.13
00:00:11 | Epoch 1 | 100 / 73526 (0.14%) | NSP loss 0.69 | MLM loss 11.05
00:00:13 | Epoch 1 | 120 / 73526 (0.16%) | NSP loss 0.70 | MLM loss 10.98
00:00:15 | Epoch 1 | 140 / 73526 (0.19%) | NSP loss 0.69 | MLM loss 10.95
00:00:18 | Epoch 1 | 160 / 73526 (0.22%) | NSP loss 0.70 | MLM loss 10.90
00:00:20 | Epoch 1 | 180 / 73526 (0.24%) | NSP loss 0.71 | MLM loss 10.89
00:00:22 | Epoch 1 | 200 / 73526 (0.27%) | NSP loss 0.72 | MLM loss 10.83 | NSP accuracy 0.25 | Token accuracy 0.01
BERT模型甚至我們過于簡化的BERT模型收斂速度很慢,需要大量的計算資源。我只能訓練一個epoch。花了兩個多小時:
02:20:49 | Epoch 1 | 73440 / 73526 (99.88%) | NSP loss 0.69 | MLM loss 4.49
02:20:52 | Epoch 1 | 73460 / 73526 (99.91%) | NSP loss 0.69 | MLM loss 4.37
02:20:54 | Epoch 1 | 73480 / 73526 (99.94%) | NSP loss 0.69 | MLM loss 4.24
02:20:56 | Epoch 1 | 73500 / 73526 (99.96%) | NSP loss 0.69 | MLM loss 4.38
02:20:59 | Epoch 1 | 73520 / 73526 (99.99%) | NSP loss 0.70 | MLM loss 4.37
讓我們看看損失值在一段時間內是如何變化的
您可能會看到我們的 BERT 模型的損失確實收斂到某個最小值,但這個過程非常慢。例如,這是有關已處理數據 44% 的日志消息
01:03:01 | Epoch 1 | 32880 / 73526 (44.72%) | NSP loss 0.69 | MLM loss 4.78
以及有關已處理100%數據 的消息:
02:20:59 | Epoch 1 | 73520 / 73526 (99.99%) | NSP loss 0.70 | MLM loss 4.37
在一個小時的訓練中,NSP 損失僅減少了大約十分之一。
從上圖可以看出,NSP 損失沒有收斂而是發散。它收斂,但比MLM還要慢。如果我們對此圖表的值應用平滑,我們可以看到這一點:
我想說我們之所以能得到這樣的結果是因為我們的數據集。我們使用 IMDB 評論進行訓練,并按.符號對句子進行文本分割。現在,我請你看看這些句子是什么。注意到了嗎?因此,模型很難很好地捕捉數據來解決這個任務。最初的 BERT 使用英語維基百科和圖書語料庫,句子好、長、信息豐富。
讓我們看看訓練精度隨時間的變化情況。
準確率實際上與損失相關。當MLM損失稍微減少時,MLM準確度稍微提高。NSP 的準確度甚至更加不穩定,在第一個 epoch 后平均略高于 0.5。結論是我們肯定需要嘗試不同的數據集。但無論如何,對于教程來說這仍然是很好的結果:)
本教程中構建的模型并不是完整的 BERT。用最好的話說,它只是 BERT 的簡化版本,只需了解其架構和工作原理即可。HuggingFace構建了許多預訓練的 BERT(及其變體)模型。現在,您應該了解如何使用pytorch從頭開始構建 BERT。此外,您可以嘗試使用不同的數據集和模型參數,看看它是否能提供更好的任務結果,特別是 NSP 任務的收斂性。
本博文譯自 Ivan Verkalets 的博客。