使用Pytorch從零開始實現BERT

生成式建模知識回顧:
[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 windowis next
I have a cat named Tom. We walk together everydayis not next

掩碼語言模型 (MLM)

掩碼語言模型是預測句子中隱藏單詞的任務。

例如有一句話:

Tom likes to [MASK] with birds [MASK] on the window.

該模型應該預測屏蔽詞是 playsitting

構建 BERT

為了構建 BERT,我們需要制定三個步驟:

  1. 準備數據集;
  2. 建立一個模型;
  3. 建立一個訓練器 (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 的博客。

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/209763.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/209763.shtml
英文地址,請注明出處:http://en.pswp.cn/news/209763.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

前端食堂技術周刊第 107 期:技術播客節、Deno Cron、FEDAY、XState v5、Electron 2023 生態系統回顧

美味值&#xff1a;&#x1f31f;&#x1f31f;&#x1f31f;&#x1f31f;&#x1f31f; 口味&#xff1a;烤椰拿鐵 食堂技術周刊倉庫地址&#xff1a;https://github.com/Geekhyt/weekly 大家好&#xff0c;我是童歐巴。歡迎來到前端食堂技術周刊&#xff0c;我們先來看下…

圖論與網絡優化3

CSDN 有字數限制&#xff0c;因此筆記分別發布&#xff0c;目前&#xff1a; 【筆記1】概念與計算、樹及其算法【筆記2】容量網絡模型、遍歷性及其算法【筆記3】獨立集及其算法 6 獨立集及其算法 6.1 獨立集和覆蓋 6.1.1 獨立數和覆蓋數 獨立集&#xff1a;設 S ? V ( G …

PaddleDetection系列2--NCCL安裝及測試

NCCL安裝及測試 1 系統信息查看1.1 查看本機的操作系統和位數信息&#xff1a;1.2 確認處理器架構1.3 確認cuda版本 2 NCCL安裝2.1 根據上面的系統架構以及CUDA版本&#xff0c;進入[官網](https://developer.nvidia.com/nccl/nccl-download)下載匹配的nccl&#xff0c;若想獲取…

力扣44題通配符匹配題解

44. 通配符匹配 - 力扣&#xff08;LeetCode&#xff09; 給你一個輸入字符串 (s) 和一個字符模式 (p) &#xff0c;請你實現一個支持 ? 和 * 匹配規則的通配符匹配&#xff1a; ? 可以匹配任何單個字符。* 可以匹配任意字符序列&#xff08;包括空字符序列&#xff09;。 …

【ITK庫學習】使用itk庫進行圖像濾波ImageFilter:梯度Gradient

目錄 1、itkGradientImageFilter2、itkGradientMagnitudeImageFilter 梯度強度3、itkGradientMagnitudeRecursiveGaussianImageFilter 帶濾波的梯度強度4、itkDerivativeImageFilter 不帶濾波的導函數 1、itkGradientImageFilter 該類是一個基類&#xff0c;用于使用方向導數計…

C++筆試題之回文數的判斷

“回文”是指正讀反讀都能讀通的句子&#xff0c;它是古今中外都有的一種修辭方式和文字游戲&#xff0c;如“我為人人&#xff0c;人人為我”等。在數學中也有這樣一類數字有這樣的特征&#xff0c;成為回文數&#xff08;palindrome number&#xff09;。 設n是一任意自然數…

MSSQL 程序集使用方法

1.C# 寫一個程序 1.1新建一個項目【類庫【.Net FrameWork】 1.2編寫代碼 刪除 namespace ApiSQLClass { } 代碼如下&#xff1a;【具體調用API模式根據具體編寫】 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.…

1. 使用poll或epoll創建echo服務器

1. 說明&#xff1a; 此篇博客主要記錄一種客戶端實現方式&#xff0c;和兩種使用poll或者epoll分別創建echo服務器的方式&#xff0c;具體可看代碼注釋&#xff1a; 2. 相關代碼&#xff1a; 2.1 echoClient.cpp #include <iostream> #include <cstdio> #incl…

C語言中的 sizeof 運算符

在 C 語言中&#xff0c;sizeof 是一個運算符&#xff0c;用于獲取給定類型或變量的字節大小。它返回一個 size_t 類型的值&#xff0c;表示以字節為單位的對象大小。 sizeof 運算符有以下特點&#xff1a; 用法&#xff1a;sizeof 運算符可以應用于數據類型或表達式。計算靜…

酷開科技以創新為動力用大數據提升品牌認知

在21世紀的今天&#xff0c;我們生活在一個被互聯網深深改變的世界。互聯網不僅改變了我們的生活方式&#xff0c;也正在改變我們的思維方式和工作方式。而互聯網作為一種新的發展趨勢&#xff0c;更是為我們提供了無數的機會和無限可能性&#xff0c;從電子商務時代到社交網絡…

CSP-何以包郵?

題目描述 新學期伊始&#xff0c;適逢頓頓書城有購書滿 x 元包郵的活動&#xff0c;小 P 同學欣然前往準備買些參考書。 一番瀏覽后&#xff0c;小 P 初步篩選出 n 本書加入購物車中&#xff0c;其中第 i 本&#xff08;1≤i≤n&#xff09;的價格為 ai 元。 考慮到預算有限&am…

scala編碼

1、Scala高級語言 Scala簡介 Scala是一門類Java的多范式語言&#xff0c;它整合了面向對象編程和函數式編程的最佳特性。具體來講Scala運行于Java虛擬機&#xff08;JVM)之上&#xff0c;井且兼容現有的Java程序&#xff0c;同樣具有跨平臺、可移植性好、方便的垃圾回收等特性…

ubuntu server 20.04 備份和恢復 系統 LTS

ubuntu server 20.04 備份和恢復 系統 LTS tar命令系統備份與恢復&#xff08;還原or新裝&#xff09; 備份系統 cd / su root tar cvpzf backup.tgz --exclude/tmp --exclude/run --exclude/dev --exclude/snap --exclude/proc --exclude/lostfound --exclude/backup.tgz …

啟動游戲出現concrt140.dll錯誤的8種解決方法

在計算機使用過程中&#xff0c;我們經常會遇到一些錯誤提示&#xff0c;其中之一就是找不到concrt140.dll文件。這個錯誤通常會導致程序無法正常運行&#xff0c;給用戶帶來困擾。本文將介紹找不到concrt140.dll無法繼續執行代碼的8個方法&#xff0c;同時探討concrt140.dll丟…

LinuxBasicsForHackers筆記 -- 文件系統和存儲設備管理

設備目錄/dev Linux 有一個特殊的目錄&#xff0c;其中包含代表每個連接設備的文件&#xff1a;相應命名的 /dev 目錄。 /dev中有很多設備列表。 特別令人感興趣的是設備 sda1、sda2、sda3、sdb 和 sdb1&#xff0c;它們通常是硬盤驅動器及其分區以及 USB 閃存驅動器及其分區…

理解基于 Hadoop 生態的大數據技術架構

轉眼間&#xff0c;一年又悄然而逝&#xff0c;時光荏苒&#xff0c;歲月如梭。當回首這段光陰&#xff0c;不禁感嘆時間的匆匆&#xff0c;仿佛只是一個眨眼的瞬間&#xff0c;一年的旅程已成為過去&#xff0c;而如今又到了畫餅的時刻了 &#xff01; 基于 Hadoop 生態的大數…

固態硬盤SSD

目錄 1.2 組成1.3 讀寫性能特性1.4 與機械硬盤相比的特點1.5 磨損均衡技術 \quad \quad SSD基于閃存技術Flash Memory, 屬于電可擦除ROM, 即EEPROM \quad 1.2 組成 \quad \quad \quad 系統對固態硬盤的讀寫是以頁為單位的 固態硬盤里的塊相當于機械硬盤里的磁道 固態硬盤里的頁…

Redis基礎系列-持久化

Redis基礎系列-持久化 文章目錄 Redis基礎系列-持久化1. 什么是持久化2. 為什么要持久化3. 持久化的兩種方式3.1 持久化方式1&#xff1a;RDB(redis默認持久化方式)3.11 配置步驟-自動觸發3.12 配置步驟-手動觸發3.12 優點3.13 缺點3.14 檢查和修復RDB快照文件3.15 哪些情況會觸…

每天一個Linux命令 -- (7)more命令

歡迎閱讀《每天一個Linux命令》系列&#xff01;在本篇文章中&#xff0c;將介紹Linux系統下的more命令&#xff0c;它用于逐屏顯示文件的內容。 概念 more命令是Linux系統下的文件逐屏顯示命令&#xff0c;用于逐屏顯示文件的內容。 命令操作 more命令的語法如下&#xff1…

ubuntu22.04 安裝cuda

CUDA&#xff08;Compute Unified Device Architecture&#xff09;是由 NVIDIA 開發的一種并行計算平臺和編程模型。它允許開發者利用 NVIDIA 的 GPU&#xff08;圖形處理單元&#xff09;進行高效的計算處理。CUDA 通過提供一系列的 C、C 和 Fortran 擴展&#xff0c;使得開發…