机器学习从入门到精通 - Transformer颠覆者:BERT与预训练模型实战解析
机器学习从入门到精通 - Transformer颠覆者:BERT与预训练模型实战解析
开场白:点燃你的NLP革命之火
朋友们,如果你还在用RNN、LSTM和GRU吭哧吭哧地处理文本任务,看着那缓慢的训练速度和捉襟见肘的长程依赖建模能力发愁——停!是时候拥抱颠覆者了。Transformer,这个2017年横空出世的架构,彻底重塑了自然语言处理的格局。而站在巨人肩膀上的BERT及其引发的大规模预训练模型浪潮,则直接让NLP进入了“工业化生产”时代。这篇长文不是蜻蜓点水的概念介绍,我们要撸起袖子,深入BERT的骨髓,从理论推导到代码实战,亲手搭建、训练、微调,并直面那些官方文档很少提及的“坑”。准备好了吗?让我们揭开预训练魔法背后的硬核原理与工程细节!
一、Why Transformer? 从RNN的“阿喀琉斯之踵”说起
先说个容易踩的坑:很多教程一上来就堆Self-Attention公式,却不解释为什么要抛弃成熟的RNN系列。这个决策点至关重要。
RNN(及其变种LSTM、GRU)处理序列的核心是循环更新隐藏状态:h_t = f(h_{t-1}, x_t)
。这个结构天然带来两大瓶颈:
- 序列计算的串行性 (Sequential Processing):处理第t个词必须等t-1算完。GPU强大的并行能力无用武之地,训练巨慢。
- 长程依赖衰减 (Vanishing Gradients):尽管LSTM/GRU有门控机制缓解,但当序列长度超过100步,模型捕捉开头和结尾词关系的信号依然微弱得像风中残烛。
Transformer的核心理念——Self-Attention (自注意力) ——就是为了粉碎这两座大山。
图1: RNN/LSTM的序列依赖计算 vs Transformer的全局并行计算
Self-Attention的直觉:当模型处理句子中的一个词(例如“bank”)时,它应该“注意”句子中其它所有词(“river”,“money”),并根据这些词的重要性加权聚合信息,从而确定“bank”在此语境下的确切含义(河岸 vs 银行)。关键在于,计算每个词的表示时,所有其它词的“贡献权重”是同时计算出来的,完美解锁GPU并行!
二、Self-Attention:从直觉到数学,手撕核心公式(含完整推导)
别怕公式!我们一步步拆解Self-Attention的计算过程。这是理解Transformer和BERT的基石。
1. 输入表示 (Input Representation)
假设我们有输入序列 X = [x_1, x_2, ..., x_n]
,其中每个 x_i
是词 i
的 d_model
维嵌入向量(例如512维)。
2. 线性变换:生成Q、K、V
Self-Attention不直接操作原始嵌入。它学习三组权重矩阵:
W_Q
(Query矩阵, 形状[d_model, d_k]
)W_K
(Key矩阵, 形状[d_model, d_k]
)W_V
(Value矩阵, 形状[d_model, d_v]
)
通常d_k = d_v = d_model / h
,h
是多头注意力的头数(常见8或16)。
为什么需要三个矩阵?
- Query (Q): 代表当前词想要“询问”其他词的“问题”。
- Key (K): 代表每个词提供的可用于“回答”Query的“标识”。
- Value (V): 代表每个词真正蕴含的“内容信息”。
import torch
import torch.nn as nnd_model = 512 # 模型维度
d_k = d_v = d_model // 8 # 每个头的维度,假设8个头# 初始化权重矩阵
W_Q = nn.Linear(d_model, d_k, bias=False)
W_K = nn.Linear(d_model, d_k, bias=False)
W_V = nn.Linear(d_model, d_v, bias=False)# 输入序列 (batch_size, seq_len, d_model)
X = torch.randn(32, 10, d_model) # batch=32, seq_len=10# 计算 Q, K, V
Q = W_Q(X) # (32, 10, d_k)
K = W_K(X) # (32, 10, d_k)
V = W_V(X) # (32, 10, d_v)
3. 计算注意力分数 (Attention Scores)
计算Query向量 Q_i
与所有Key向量 K_j
的点积,衡量 i
和 j
的相关性:
Scoreij=Qi⋅KjT\text{Score}_{ij} = Q_i \cdot K_j^TScoreij=Qi⋅KjT
为什么用点积?
点积反映两个向量的相似度(夹角余弦值)。Q_i
和 K_j
越相似(方向越接近),点积越大,意味着词 j
对理解词 i
越重要。
4. 缩放与Softmax (Scaled Dot-Product & Softmax)
对每个Query i
,将其对应的所有 Score_{ij}
进行缩放(除以 sqrt(d_k)
),然后应用Softmax归一化为概率分布:
αij=Softmax(QiKjTdk)\alpha_{ij} = \text{Softmax}\left(\frac{Q_i K_j^T}{\sqrt{d_k}}\right)αij=Softmax(dkQiKjT)
为什么要缩放?
点积的结果方差会随着 d_k
增大而增大。方差过大会导致Softmax进入梯度极小的饱和区(某些值接近0或1),难以优化。除以 sqrt(d_k)
将方差控制回1附近。
# 计算注意力分数矩阵 (batch_size, seq_len, seq_len)
attn_scores = torch.matmul(Q, K.transpose(-2, -1)) # (32, 10, 10)
attn_scores = attn_scores / torch.sqrt(torch.tensor(d_k)) # 缩放# 应用Softmax (沿最后一个维度seq_len)
attn_probs = nn.Softmax(dim=-1)(attn_scores) # (32, 10, 10)
5. 计算加权和输出 (Weighted Sum)
用归一化后的注意力权重 alpha_{ij}
对Value向量 V_j
进行加权求和,得到词 i
的最终输出向量 Z_i
:
Zi=∑j=1nαijVjZ_i = \sum_{j=1}^{n} \alpha_{ij} V_jZi=j=1∑nαijVj
# 计算输出Z
Z = torch.matmul(attn_probs, V) # (32, 10, d_v)
Self-Attention输出 Z
的本质是什么?
它是输入序列中所有词向量的加权平均,权重由每个词对当前词的“重要性”(相关性)决定。Z_i
的每一维都融合了全局信息。
6. 多头注意力 (Multi-Head Attention)
为了提升模型的表达能力,捕获不同子空间的信息,Transformer使用 h
个独立的Self-Attention“头”:
- 将
Q, K, V
分割成h
份(在d_k, d_v
维度上切)。 - 在每个头上独立执行Scaled Dot-Product Attention。
- 将h个头的输出
[Z^1; Z^2; ...; Z^h]
拼接起来(Concat
)。 - 通过一个线性层
W_O
将拼接后的向量投影回d_model
维。
MultiHead(Q,K,V)=Concat(head1,…,headh)WO\text{MultiHead}(Q, K, V) = \text{Concat}(\text{head}_1, \ldots, \text{head}_h) W_OMultiHead(Q,K,V)=Concat(head1,…,headh)WO
where headi=Attention(QWQi,KWKi,VWVi)\text{where head}_i = \text{Attention}(Q W_Q^i, K W_K^i, V W_V^i)where headi=Attention(QWQi,KWKi,VWVi)
class MultiHeadAttention(nn.Module):def __init__(self, d_model, num_heads):super().__init__()self.d_model = d_modelself.num_heads = num_headsself.d_k = self.d_v = d_model // num_headsself.W_Q = nn.Linear(d_model, d_model) # 投影到 d_model,方便多头切分self.W_K = nn.Linear(d_model, d_model)self.W_V = nn.Linear(d_model, d_model)self.W_O = nn.Linear(d_model, d_model)def forward(self, Q, K, V, mask=None):# 1. 线性投影 (batch_size, seq_len, d_model)Q = self.W_Q(Q) # (32, 10, 512)K = self.W_K(K)V = self.W_V(V)# 2. 切分成多头 (batch_size, num_heads, seq_len, d_k)batch_size = Q.size(0)Q = Q.view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) # (32, 8, 10, 64)K = K.view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)V = V.view(batch_size, -1, self.num_heads, self.d_v).transpose(1, 2)# 3. 计算Scaled Dot-Product Attention (每个头独立计算)attn_scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) # (32, 8, 10, 10)if mask is not None:attn_scores = attn_scores.masked_fill(mask == 0, -1e9) # 应用mask(如Padding Mask)attn_probs = nn.Softmax(dim=-1)(attn_scores)Z = torch.matmul(attn_probs, V) # (32, 8, 10, 64)# 4. 拼接多头输出 (batch_size, seq_len, d_model)Z = Z.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) # (32, 10, 512)# 5. 最终线性投影return self.W_O(Z) # (32, 10, 512)
三、BERT:双向编码器的革命性预训练
Transformer奠定了架构基础,但BERT将其威力真正释放出来。BERT的核心创新是双向上下文表示和大规模无监督预训练 + 任务微调范式。
1. Transformer Encoder:BERT的骨架
BERT只使用了Transformer的Encoder部分堆叠而成(Base: 12层, Large: 24层)。Encoder接收所有输入词同时处理,输出每个词的上下文相关表示。
图2: BERT架构概览(以Base为例)
-
输入表示 (Input Embeddings):
- Token Embeddings:WordPiece词嵌入 (30k词表)。
- Position Embeddings:学习每个位置(0~511)的嵌入向量。(Transformer原始论文用正弦函数,BERT用可学习的)
- Segment Embeddings:区分句子对(如问答任务)。句子A为0,句子B为1。
- 特殊Token:
[CLS]
:添加在每个输入序列开头,其最终输出常用于分类任务(如情感分析)。[SEP]
:分隔句子(句子对),或标记序列结束。
- 输入向量 = Token E + Position E + Segment E
-
Encoder层核心结构 (Transformer Encoder Block):
每个Encoder层包含:- 多头自注意力层 (Multi-Head Self-Attention):处理输入序列。
- 残差连接 & Layer Normalization:
LayerNorm(x + Sublayer(x))
。(Transformer原始用Post-LN,BERT用Pre-LN更稳定) - 位置前馈网络 (Position-wise Feed-Forward Network, FFN):两个线性层夹一个ReLU激活,为每个位置独立计算:
FFN(x) = max(0, x W_1 + b_1) W_2 + b_2
。提供非线性变换能力。 - 再次残差连接 & Layer Normalization。
2. 预训练任务:BERT学什么?(含完整数学原理)
BERT在无标注海量文本(如Wikipedia + BookCorpus)上通过两个自监督任务进行预训练:
-
任务1: Masked Language Modeling (MLM / 掩码语言模型) - 核心!
目标:预测被随机掩盖的词。
流程:- 随机选择输入序列中15%的Token。
- 被选中的Token中:
- 80%概率替换为
[MASK]
(如:The [MASK] sat on the mat
) - 10%概率替换为随机Token (如:
The apple sat on the mat
) - 10%保持原词不变 (如:
The cat sat on the mat
) - (加入随机Token和原词是为了缓解预训练-微调时
[MASK]
不匹配问题)
- 80%概率替换为
- 模型基于双向上下文,预测被遮盖/替换位置的原始Token。
数学本质:最大化被掩盖位置原始词的条件概率。
LMLM=−∑i∈maskedlogP(wi∣w1,…,wi−1,wi+1,…,wn)L_{\text{MLM}} = -\sum_{i \in \text{masked}} \log P(w_i | w_{1}, \ldots, w_{i-1}, w_{i+1}, \ldots, w_n)LMLM=−i∈masked∑logP(wi∣w1,…,wi−1,wi+1,…,wn)
如何计算
P(w_i | ...)
?- 输入带
[MASK]
的序列到BERT。 - 获取
[MASK]
位置i
的输出向量h_i
(d_model
维)。 - 将
h_i
通过一个线性层W_{\text{vocab}}
(形状[d_model, vocab_size]
)投影到词表大小维度。 - 应用Softmax计算每个词
w
在该位置的概率:
P(wi=w∣context)=exp(hiTWvocab,w)∑j=1∣V∣exp(hiTWvocab,j)P(w_i = w | \text{context}) = \frac{\exp(h_i^T W_{\text{vocab}, w})}{\sum_{j=1}^{|V|} \exp(h_i^T W_{\text{vocab}, j})}P(wi=w∣context)=∑j=1∣V∣exp(hiTWvocab,j)exp(hiTWvocab,w) - 损失函数:交叉熵(Cross-Entropy)作用于被掩盖位置的预测词和目标词。
为什么MLM是双向的?
预测被掩盖词w_i
时,模型能看到w_i
左右两侧的所有词 (w_{1..i-1}, w_{i+1..n}
),信息流是双向的。这是BERT区别于单向语言模型(如GPT)的关键! -
任务2: Next Sentence Prediction (NSP / 下一句预测)
目标:判断句子B是否是句子A的下一句。
流程:- 50%概率抽取连续句子 (IsNext):
[CLS] This is sentence A. [SEP] This is sentence B. [SEP]
- 50%概率从语料中随机抽取句子 (NotNext):
[CLS] This is sentence A. [SEP] The sky is blue. [SEP]
- 模型利用
[CLS]
位置的输出向量,通过一个二分类层(线性层 + Softmax)预测IsNext
或NotNext
。
KaTeX parse error: Expected 'EOF', got '_' at position 55: … \text{sentence_̲A, sentence_B})
(注:后续研究如RoBERTa发现NSP任务有时作用不大甚至有害,但在原始BERT中它帮助模型理解句子间关系)总预训练损失:
L_{\text{total}} = L_{\text{MLM}} + L_{\text{NSP}}
- 50%概率抽取连续句子 (IsNext):
3. 预训练实战:Hugging Face Transformers库 + Google Colab (踩坑预警!)
理论懂了,动手预训练一个小BERT才过瘾!我们用Hugging Face的 transformers
库和 datasets
库。强烈推荐用Colab Pro的A100 GPU,否则训练会慢到怀疑人生。
# 安装必备库 (注意版本兼容性!)
!pip install transformers[torch]==4.33.0 datasets==2.14.0 tokenizers==0.13.3 accelerate==0.22.0from transformers import BertTokenizer, BertForPreTraining, TrainingArguments, Trainer, BertConfig
from transformers import DataCollatorForLanguageModeling
from datasets import load_dataset
import torch# 1. 加载数据集 (这里用wikitext-2-raw-v1,更小更快)
dataset = load_dataset('wikitext', 'wikitext-2-raw-v1', split='train')# 过滤掉空行
dataset = dataset.filter(lambda example: example['text'] != '')# 2. 初始化Tokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')# 3. Tokenize整个数据集
def tokenize_function(examples):return tokenizer(examples['text'], truncation=True, max_length=128, return_special_tokens_mask=True)tokenized_dataset = dataset.map(tokenize_function, batched=True, num_proc=4, remove_columns=["text"])# 4. 创建Data Collator (负责MLM和NSP的数据处理)
# 注意:Hugging Face的默认DataCollatorForLanguageModeling只处理MLM。
# NSP需要自定义实现或找到支持的collator。这里为简化,仅演示MLM。
data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer,mlm=True,mlm_probability=0.15 # 15%的token被mask
)# 5. 定义一个小的BERT模型配置用于演示
config = BertConfig(vocab_size=tokenizer.vocab_size,hidden_size=256,num_hidden_layers=4,num_attention_heads=4,intermediate_size=1024,max_position_embeddings=128
)
model = BertForPreTraining(config)# 6. 设置训练参数
training_args = TrainingArguments(output_dir="./bert_pretrain_output",overwrite_output_dir=True,num_train_epochs=3, # 仅为演示,实际需要更多per_device_train_batch_size=16,save_steps=10_000,save_total_limit=2,prediction_loss_only=True, # 优化训练循环fp16=True, # 使用混合精度加速
)# 7. 初始化Trainer
trainer = Trainer(model=model,args=training_args,data_collator=data_collator,train_dataset=tokenized_dataset,
)# 8. 开始训练!
# trainer.train() # 在实际环境中取消注释来运行
print("预训练代码准备就绪。在真实GPU环境中取消注释 trainer.train() 即可开始。")
踩坑预警与说明:
- 资源是第一道坎:从头预训练BERT是“吞金巨兽”。Google原始的BERT-Base在16个TPU上训练了4天。我们上面的代码只是在小数据集(wikitext-2)上训练一个“玩具”模型。千万不要期望在单张消费级GPU上能复现出强大的BERT。理解这个过程和原理是关键。
- 数据处理:MLM和NSP的数据动态生成(Data Collator)是性能关键点。对于大规模数据,预处理和Tokenization会非常耗时,
datasets
库的map
配合num_proc
可以多进程加速。 - 超参数敏感:学习率、batch size、warmup steps对预训练稳定性至关重要。通常需要配合AdamW优化器和线性学习率衰减策略。
四、微调为王:让预训练BERT为你所用
预训练的价值在于其学到的通用语言表示能力。对我们大多数人来说,真正的威力在于微调 (Fine-tuning)。我们加载Google或Hugging Face已经训练好的BERT模型,换掉它的“头”(输出层),用我们自己任务的少量标注数据(几千条即可)进行训练。整个模型的权重都会被微调,以适应新任务。
1. 微调范式
- 分类任务 (如情感分析, GLUE):取
[CLS]
token的最终输出向量,接一个线性分类层。 - 序列标注 (如命名实体识别, NER):取每个token的最终输出向量,各自接一个分类层,预测每个token的标签。
- 问答任务 (如SQuAD):预测答案在原文中的起始位置和结束位置。需要两个输出层,分别对所有token预测“是开头”和“是结尾”的概率。
2. 微调实战:IMDB电影评论情感分析
我们用BertForSequenceClassification
来演示一个完整的分类微调流程。
# 安装/更新库
!pip install transformers datasets evaluate accelerateimport torch
from datasets import load_dataset
from transformers import BertTokenizer, BertForSequenceClassification, TrainingArguments, Trainer
import numpy as np
import evaluate# 1. 加载IMDB数据集
dataset = load_dataset("imdb")# 2. 加载预训练模型和Tokenizer
model_name = "bert-base-uncased"
tokenizer = BertTokenizer.from_pretrained(model_name)
model = BertForSequenceClassification.from_pretrained(model_name, num_labels=2) # 2分类:正面/负面# 3. 预处理数据
def preprocess_function(examples):return tokenizer(examples["text"], truncation=True, padding="max_length", max_length=256)tokenized_datasets = dataset.map(preprocess_function, batched=True)# 为了演示,我们只取一小部分数据
small_train_dataset = tokenized_datasets["train"].shuffle(seed=42).select(range(1000))
small_eval_dataset = tokenized_datasets["test"].shuffle(seed=42).select(range(1000))# 4. 定义评估指标
metric = evaluate.load("accuracy")def compute_metrics(eval_pred):logits, labels = eval_predpredictions = np.argmax(logits, axis=-1)return metric.compute(predictions=predictions, references=labels)# 5. 设置训练参数
training_args = TrainingArguments(output_dir="./imdb_finetune_output",learning_rate=2e-5, # 微调时学习率要小per_device_train_batch_size=16,per_device_eval_batch_size=16,num_train_epochs=3,weight_decay=0.01,evaluation_strategy="epoch", # 每个epoch评估一次save_strategy="epoch",load_best_model_at_end=True,
)# 6. 初始化Trainer
trainer = Trainer(model=model,args=training_args,train_dataset=small_train_dataset,eval_dataset=small_eval_dataset,compute_metrics=compute_metrics,
)# 7. 开始微调!
# trainer.train()
print("微调代码准备就绪。在真实GPU环境中取消注释 trainer.train() 即可开始。")
# 仅用1000个样本,3个epoch,在Colab上几分钟就能达到接近90%的准确率!
微调的魅力在于高性价比。它将预训练模型的泛化能力迁移到特定任务上,极大地降低了NLP应用的技术和资源门槛。
五、后BERT时代:一览众山小的模型动物园
BERT打开了潘多拉魔盒,后续研究在此基础上百花齐放。这里简单介绍几个里程碑式的模型:
-
RoBERTa (Robustly Optimized BERT Pretraining Approach): 由Facebook AI提出。它证明了原始BERT其实“训练不足”。通过使用更大的数据集、更长的训练时间、去掉NSP任务、动态Masking等“暴力美学”技巧,在几乎相同模型结构下取得了比BERT好得多的性能。
-
ALBERT (A Lite BERT): Google的轻量化BERT。通过两种参数削减技术(词嵌入参数分解、跨层参数共享)和一种新的句子间任务SOP(Sentence-Order Prediction),在显著减少参数量的同时,保持了与BERT相当甚至更好的性能。
-
DistilBERT: Hugging Face团队的蒸馏版BERT。利用知识蒸馏技术,将BERT-Base的知识迁移到一个更小的模型中。它保留了BERT 97%的性能,但速度提升60%,模型大小减小40%。
-
GPT系列 (Generative Pre-trained Transformer): OpenAI的另一条路线。与BERT的Encoder(双向)不同,GPT使用Transformer的Decoder(单向),更擅长生成式任务。从GPT-2到GPT-3再到GPT-4,通过海量数据和巨大模型,展现了惊人的零样本/少样本学习能力,引爆了AIGC浪潮。
-
T5 (Text-to-Text Transfer Transformer): Google提出的统一框架。它将所有NLP任务都转化为“文本到文本”的形式(Seq2Seq),例如,分类任务的输出是标签字符串“positive”,翻译任务的输出是目标语言句子。这种设计极大地简化了模型的使用。
六、实战“黑话”与性能调优秘籍
-
学习率预热 (Learning Rate Warmup): 在训练初期,使用一个较低的学习率,然后线性增长到预设值。这有助于模型在开始时更稳定地收敛,尤其对大型模型很重要。
TrainingArguments
中通过warmup_steps
或warmup_ratio
设置。 -
梯度累积 (Gradient Accumulation): 当你的GPU显存无法支持更大的batch size时,可以通过梯度累积来模拟。例如,设置
per_device_train_batch_size=8
和gradient_accumulation_steps=4
,效果等同于batch_size=32
。它会在累积4个mini-batch的梯度后才进行一次参数更新。 -
混合精度训练 (Mixed Precision Training): 同时使用16位浮点数(FP16)和32位浮点数(FP32)进行训练。FP16能大幅减少显存占用并利用GPU的Tensor Cores加速计算,而关键部分的计算仍用FP32保持精度。在Hugging Face Trainer中,只需设置
fp16=True
即可自动启用。
结语:站在巨人肩上,开启你的NLP新纪元
从Transformer的并行化革命,到BERT开创的“预训练-微调”范式,我们见证了自然语言处理领域前所未有的飞跃。这不再是少数顶尖实验室的专利,借助Hugging Face等开源社区的力量,任何人都可以轻松调用SOTA模型,解决真实世界的复杂问题。
本文从Self-Attention的数学推导,到BERT的预训练细节,再到微调实战,希望能为你揭开这些强大模型背后的神秘面纱