Paper Reading #paper-reading#bert#AI#LLM#python

論文共讀:《BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding》(BERT:用於語言理解的深度雙向 Transformer 預訓練)

2026-01-31 · 3211 字 · 16 分鐘

預訓練範式的確立,附真實 Python 程式碼

2018 年 10 月 11 日,Google AI Language 團隊在 arXiv(一個學術論文預印本網站,論文不用等期刊審稿就能直接發布)上傳了一篇論文:《BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding》(BERT:用於語言理解的深度雙向 Transformer 預訓練)。

作者是 Jacob Devlin、Ming-Wei Chang、Kenton Lee 和 Kristina Toutanova,四人均來自 Google。Devlin 此前在微軟研究院工作,加入 Google 後主導了 BERT 的設計和實現。

BERT 的全名是 Bidirectional Encoder Representations from Transformers:來自 Transformer 的雙向編碼器表示。它做了一件在當時看來非常大膽的事:先在海量無標註文本上做通用預訓練,然後只需要加一層輸出層、在具體任務上做少量微調,就能拿到最優結果。

這個「先預訓練、再微調」的範式,後來成為了整個 NLP 領域的標準做法。GPT 系列也用了類似的思路,但走的是另一條路:單向生成。BERT 選擇了雙向理解。兩條路後來各自發展出了龐大的模型家族。

0. 先認幾個詞

如果你對大模型的訓練流程還不熟,可以先記住這篇論文最關鍵的幾個詞:

  • Transformer:BERT 用的基礎架構。你可以先把它理解成一台能同時結合左右上下文來處理句子的機器。
  • 預訓練:先在海量通用文本上學語言本身,而不是一開始就做具體任務。
  • 微調:把預訓練得到的能力,再稍微調整到某個具體任務上。
  • 雙向:預測一個位置時,不只看左邊,也看右邊。
  • MLM / 遮蔽語言模型:故意遮住一部分詞,讓模型根據上下文把它們猜出來。
  • NSP / 下一句預測:讓模型判斷兩個句子是不是前後相連。

1. 要解決什麼問題

2018 年,NLP 領域有一個尷尬的現狀:每個任務都需要從頭設計專門的模型架構。做問答要一套模型,做情感分析要另一套,做命名實體識別又要一套。每個任務的標註資料都不多,訓練出來的模型也很難遷移到其他任務。

當時已經有人嘗試過預訓練的思路。ELMo 用雙向 LSTM 學習上下文表示,但它只是把預訓練的特徵「拼」到下游模型上,架構本身還是任務專用的。OpenAI GPT 用 Transformer 做預訓練再微調,但它只能從左往右看(單向),每個詞只能關注它前面的詞,看不到後面的。

論文認為,單向語言模型在需要深度雙向上下文的語言理解任務上存在明顯限制。比如:

“他拿起了 _____ ,開始演奏。”

如果只看左邊(“他拿起了”),填空的答案可能是任何東西。但看到右邊(“開始演奏”),你立刻知道是某種樂器。對很多語言理解任務來說,雙向上下文天然更有利。

2. 核心想法:遮住一些詞,讓模型猜

BERT 的解法很直覺:既然雙向語言模型沒法用傳統方式訓練(因為每個詞會間接「看到自己」),那就換個訓練目標。

遮蔽語言模型(Masked Language Model,MLM):隨機遮住輸入中 15% 的詞:具體做法是把它們替換成一個特殊標記 [MASK]:然後讓模型根據上下文猜出被遮住的詞。這個想法來自心理學中的完形填空(Cloze task,1953 年 Taylor 提出),就像上面那道填空題一樣。

遮住之後,模型必須同時利用左邊和右邊的上下文來預測,雙向理解就自然產生了。

但直接把所有被選中的詞替換成 [MASK] 標記會引入一個問題:微調時輸入裡不會出現 [MASK],預訓練和微調之間產生了不匹配。論文的解決方案:被選中的 15% 的詞裡,80% 替換成 [MASK],10% 替換成隨機詞,10% 保持不變。這樣模型不能只靠「看到 [MASK] 就知道要預測」,而是必須對每個位置都保持理解能力。

import random
from typing import Optional, Sequence
def mask_tokens(
tokens: Sequence[str],
mask_prob: float = 0.15,
vocab: Optional[Sequence[str]] = None,
) -> tuple[list[str], list[int], list[str]]:
if vocab is None:
vocab = tokens
masked = list(tokens)
positions: list[int] = []
labels: list[str] = []
for i, token in enumerate(tokens):
if random.random() < mask_prob:
positions.append(i)
labels.append(token)
r = random.random()
if r < 0.8:
masked[i] = "[MASK]"
elif r < 0.9:
masked[i] = random.choice(vocab)
return masked, positions, labels

3. 第二個預訓練任務:下一句預測

很多 NLP 任務(比如問答、自然語言推理)需要理解兩個句子之間的關係,但語言模型本身不直接建模這種關係。

論文加了第二個預訓練任務:下一句預測(Next Sentence Prediction,NSP)。給模型兩個句子 A 和 B,50% 的情況下 B 是 A 的真實下一句,50% 的情況下 B 是從語料庫裡隨機抽的。模型要判斷 B 是不是 A 的下一句。

這個任務的設計很簡單,但論文的消融實驗(ablation study,逐一去掉某個元件看效果變化)顯示,去掉 NSP 會明顯降低問答和自然語言推理任務的表現;不過後來也有工作(如 RoBERTa)對 NSP 的必要性提出了不同結論。

from dataclasses import dataclass
@dataclass
class PretrainingExample:
tokens: list[str]
segment_ids: list[int]
masked_positions: list[int]
masked_labels: list[str]
is_next: bool

4. 模型架構

BERT 的架構其實沒有什麼新發明。它就是 Transformer 的編碼器部分,一層層堆起來。

論文給出了兩個規格:

  • BERT_BASE:12 層,隱藏維度 768,12 個注意力頭,參數量 1.1 億
  • BERT_LARGE:24 層,隱藏維度 1024,16 個注意力頭,參數量 3.4 億

BERT_BASE 的參數量和 OpenAI GPT 差不多,方便直接對比。兩者最關鍵的區別只有一個:GPT 用的是單向注意力(每個詞只能看左邊),BERT 用的是雙向注意力(每個詞能看到所有位置)。

輸入的表示由三部分相加構成:

  • 詞嵌入(Token Embedding):WordPiece 分詞,詞表 30,000
  • 段嵌入(Segment Embedding):標記這個詞屬於句子 A 還是句子 B
  • 位置嵌入(Position Embedding):告訴模型詞的位置(BERT 用的是學習得到的位置編碼,不是正弦餘弦)

每個輸入序列的開頭都加一個特殊標記 [CLS],它在最後一層的隱藏狀態被用來做句子級別的分類(比如 NSP、情感分析)。兩個句子之間用 [SEP] 分隔。

import torch
from torch import nn
class BertEmbeddings(nn.Module):
def __init__(
self,
vocab_size: int,
hidden_size: int,
max_positions: int,
type_vocab_size: int = 2,
dropout: float = 0.1,
) -> None:
super().__init__()
self.token_embedding = nn.Embedding(vocab_size, hidden_size)
self.segment_embedding = nn.Embedding(type_vocab_size, hidden_size)
self.position_embedding = nn.Embedding(max_positions, hidden_size)
self.layer_norm = nn.LayerNorm(hidden_size, eps=1e-12)
self.dropout = nn.Dropout(dropout)
def forward(
self,
token_ids: torch.Tensor,
segment_ids: torch.Tensor,
) -> torch.Tensor:
position_ids = torch.arange(token_ids.size(1), device=token_ids.device).unsqueeze(0)
position_ids = position_ids.expand_as(token_ids)
embeddings = (
self.token_embedding(token_ids)
+ self.segment_embedding(segment_ids)
+ self.position_embedding(position_ids)
)
embeddings = self.layer_norm(embeddings)
return self.dropout(embeddings)
class BertModel(nn.Module):
def __init__(
self,
vocab_size: int,
hidden_size: int = 768,
max_positions: int = 512,
num_layers: int = 12,
num_heads: int = 12,
dropout: float = 0.1,
) -> None:
super().__init__()
self.embeddings = BertEmbeddings(vocab_size, hidden_size, max_positions, dropout=dropout)
encoder_layer = nn.TransformerEncoderLayer(
d_model=hidden_size,
nhead=num_heads,
dim_feedforward=4 * hidden_size,
dropout=dropout,
activation="gelu",
batch_first=True,
)
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
def forward(
self,
token_ids: torch.Tensor,
segment_ids: torch.Tensor,
) -> torch.Tensor:
hidden = self.embeddings(token_ids, segment_ids)
return self.encoder(hidden)

5. 微調:一個模型適配所有任務

BERT 最優雅的地方在於微調的簡單性。預訓練完成後,不管什麼下游任務,做法幾乎一樣:在 BERT 上面加一層任務相關的輸出層,然後用少量標註資料微調所有參數。

  • 文本分類(情感分析、自然語言推理):取 [CLS] 位置的輸出向量,接一個線性分類器
  • 問答(給一段文章,找出答案的起止位置):對每個詞的輸出向量做兩次線性變換,分別預測答案的開始和結束位置
  • 序列標註(命名實體識別):對每個詞的輸出向量接一個分類器,逐詞預測標籤

預訓練可能需要幾天,但微調通常只要幾十分鐘到幾小時(單塊 TPU 上大部分任務不超過 1 小時)。這個效率差異是「預訓練 + 微調」範式的核心吸引力。

6. 實驗結果

論文在 11 個 NLP 任務上做了實驗,全部刷新了當時的紀錄。

GLUE 基準(通用語言理解評估,包含 8 個子任務):

  • BERT_LARGE 平均分 80.5%,比之前最好的 OpenAI GPT 高出 7.7 個百分點
  • 在最大的子任務 MNLI 上提升了 4.6%

SQuAD v1.1(閱讀理解問答,Test F1):

  • BERT_LARGE 單模型 + TriviaQA 資料:F1 91.8,超過人類表現(91.2)
  • BERT_LARGE 集成模型 + TriviaQA 資料:F1 93.2

SQuAD v2.0(包含無法回答的問題):

  • F1 達到 83.1,比之前最好的系統高出 5.1 個百分點

SWAG(常識推理):

  • 準確率 86.3%,比 OpenAI GPT 高出 8.3 個百分點

論文還做了模型大小的消融實驗,發現一個重要結論:更大的模型在所有任務上都更好,即使在標註資料很少(只有 3,600 條)的任務上也是如此。這和當時的直覺(小資料集容易過擬合大模型)不太一樣,說明預訓練提供的知識可以有效緩解小資料集的過擬合(模型把訓練資料「死記硬背」,對新資料表現差)風險。

7. 訓練細節

預訓練資料:BooksCorpus(8 億詞)+ 英文 Wikipedia(25 億詞),只使用文本段落,去掉了列表、表格和標題。論文強調必須用文件級語料而不是打亂的句子級語料,這樣才能提取長距離的上下文關係。

分詞:WordPiece,詞表大小 30,000。WordPiece 會把不常見的詞拆成更小的子詞單元,比如 “playing” 可能被拆成 “play” + “##ing”。

最佳化器:Adam,學習率 1e-4,前 10,000 步線性熱身,然後線性衰減。批次大小 256 個序列,最大序列長度 512。

硬體:BERT_BASE 在 4 塊 Cloud TPU(16 塊 TPU 晶片)上訓練 4 天。BERT_LARGE 在 16 塊 Cloud TPU(64 塊 TPU 晶片)上訓練 4 天。

Dropout:所有層的 dropout 率為 0.1。激活函數用的是 GELU(Gaussian Error Linear Unit),而不是 Transformer 原版的 ReLU。

8. 我的思考

讀完這篇論文,有幾個感受。

第一,BERT 的真正貢獻不是模型架構(它就是 Transformer 編碼器),而是訓練方法。遮蔽語言模型這個想法看起來簡單,但它巧妙地解決了一個根本矛盾:怎麼在不讓模型「作弊」的前提下,同時利用雙向上下文。80/10/10 的遮蔽策略更是精心設計的,解決了預訓練和微調之間的不匹配問題。

第二,BERT 和 GPT 的分野在這篇論文裡就很清楚了。GPT 的自回歸目標更天然適合生成;BERT 的雙向編碼更適合判別式語言理解任務。後來 GPT 走向了更大的規模和更強的生成能力,BERT 則衍生出了 RoBERTa、ALBERT、DeBERTa 等一系列理解型模型。兩條路線至今仍在各自的領域裡發揮作用。

第三,「預訓練 + 微調」這個範式的影響遠超 NLP。電腦視覺後來也全面轉向了類似的思路(ViT、MAE),甚至多模態模型(CLIP、GPT-4V)也是在大規模預訓練的基礎上做微調或提示。BERT 不是第一個做預訓練的,但它是第一個用如此簡潔的方式,把預訓練從一種有用技巧,推進成了 NLP 的主流工作範式。

第四,用真實 Python 重寫 BERT 的輸入處理時,你會感受到它的設計有多工整。[CLS] + 句子A + [SEP] + 句子B + [SEP],配上三種嵌入相加,整個流程可以用一套統一的程式碼處理分類、問答、序列標註等完全不同的任務。這種「一個模型適配所有任務」的簡潔性,是它真正的力量所在。

這篇論文的標題裡有一個詞很關鍵:Pre-training。在 BERT 之前,每個 NLP 任務都在從零開始學。BERT 證明了一件事:語言的通用知識可以先學好,然後遷移到幾乎任何任務上。

這個想法改變了整個領域的工作方式。


論文共讀系列

全文完 · 謝謝閱讀

評論