第四部分:实战:亲手打造三种核心架构
欢迎来到理论与实践的交汇点。在“第三部分:核心引擎”中,我们已经彻底掌握了构成Transformer大厦的每一块核心砖石——从输入表明到自注意力,再到完整的Transformer Block。目前,我们将从“零件制造商”的角色,转变为“总建筑师”。
本部分将通过三个精心设计的迷你项目,带领读者亲手从零开始,构建并训练三种当今AI世界最主流的Transformer架构。我们将不再满足于验证模块的功能,而是要让模型在真实的数据集上“学习”,并亲眼见证它们解决问题的能力。这三个项目层层递进,分别对应了Transformer在“理解”、“生成”和“转换”这三大领域的典型应用。
第11章:实战一:构建Encoder-Only模型 (Mini-BERT) 用于情感分类
本章是我们的第一个完整实战项目。我们将聚焦于Encoder-Only架构,它通过堆叠我们在第10章构建的TransformerEncoderBlock,来对输入文本进行深度理解和表征。这种架构的杰出代表,就是大名鼎鼎的BERT(Bidirectional Encoder Representations from Transformers)。
我们的任务是:构建一个“迷你版”的BERT(Mini-BERT),并训练它来完成一项经典的自然语言处理任务——情感分类。即判断一段文本(例如电影评论)是积极的(Positive)还是消极的(Negative)。
本章将引导读者走完一个端到端的深度学习项目全流程:从模型架构设计、数据准备与加载,到完整的训练循环编写,最后进行模型评估与推理。完成本章后,读者将能独立构建一个用于判别式任务的Encoder-Only模型。
11.1 架构蓝图:从Encoder Block到分类器
Encoder-Only模型的核心思想是,将输入序列通过多层Transformer Encoder进行深度双向编码,最终为每个词元生成一个富含上下文信息的向量表明。对于分类任务,我们一般会利用一个特殊的词元——[CLS](Classification)——的最终输出来代表整个句子的语义。
我们的Mini-BERT模型架构如图11.1所示,它由三大部分组成:
- 输入层:包含我们在第8章学习的词嵌入(Token Embedding)和位置编码(Positional Encoding)。
- 编码器栈(Encoder Stack):由多个(例如2个)我们第10章构建的TransformerEncoderBlock堆叠而成。
- 分类头(Classification Head):一个简单的全连接层(nn.Linear),它接收[CLS]词元对应的最终输出向量,并将其映射到分类任务的类别空间(在这里是2类:积极/消极)。

图11.1:Mini-BERT用于情感分类的架构图
目前,让我们用代码将这个蓝图变为现实。
Python
import torch
import torch.nn as nn
import numpy as np
import math
# --- 核心组件定义 (修正与整合) ---
# PositionalEncoding (修正版)
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=5000):
super(PositionalEncoding, self).__init__()
# 创建一个足够长的位置编码矩阵
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
# 增加一个批次维度,并注册为buffer
# 新形状: (1, max_len, d_model)
self.register_buffer('pe', pe.unsqueeze(0))
def forward(self, x):
"""
Args:
x: 输入张量,形状为 (batch_size, seq_len, d_model)
"""
# 错误修正点:
# 之前的代码 x.size(0) 取的是 batch_size,这是错误的。
# 我们需要对 seq_len 维度进行切片。
# x.size(1) 对应的是 seq_len。
# self.pe 的形状是 (1, max_len, d_model),切片后为 (1, seq_len, d_model)
# 利用广播机制,可以直接与形状为 (batch_size, seq_len, d_model) 的 x 相加。
x = x + self.pe[:, :x.size(1), :]
return x
# MultiHeadAttention 和 TransformerEncoderBlock (与第10章代码一样,此处为简洁省略)
# 它们本身已经遵循 (batch_size, seq_len, d_model) 的约定
class MiniBERT(nn.Module):
def __init__(self, vocab_size, d_model, num_heads, num_layers, d_ff, num_classes, max_len=500):
super(MiniBERT, self).__init__()
self.token_embedding = nn.Embedding(vocab_size, d_model)
self.positional_encoding = PositionalEncoding(d_model, max_len)
self.encoder_layers = nn.ModuleList(
[TransformerEncoderBlock(d_model, num_heads, d_ff) for _ in range(num_layers)]
)
self.classification_head = nn.Linear(d_model, num_classes)
def forward(self, x, mask=None):
# x 形状: (batch_size, seq_len)
# 1. 输入层
x = self.token_embedding(x) # -> (batch_size, seq_len, d_model)
x = self.positional_encoding(x) # -> (batch_size, seq_len, d_model)
# 2. 通过编码器栈
for layer in self.encoder_layers:
x = layer(x, mask)
# 3. 提取[CLS]词元的表明
cls_representation = x[:, 0, :] # -> (batch_size, d_model)
# 4. 通过分类头得到Logits
logits = self.classification_head(cls_representation) # -> (batch_size, num_classes)
return logits
11.2 数据准备:一个玩具情感分类数据集
为了让项目可以被轻松复现,我们不使用大型数据集,而是在代码中直接创建一个小型的“玩具”情感分类数据集。
Python
from torch.utils.data import Dataset, DataLoader
from tokenizers import Tokenizer
from tokenizers.models import WordLevel
from tokenizers.pre_tokenizers import Whitespace
from tokenizers.trainers import WordLevelTrainer
# 1. 创建玩具数据
train_texts = [
"I love this movie, it's fantastic!",
"This was a terrible film.",
"The acting was superb.",
"What a waste of time.",
"Absolutely brilliant!",
"I would not recommend this."
]
train_labels = [1, 0, 1, 0, 1, 0] # 1 for Positive, 0 for Negative
# 2. 训练一个简单的分词器
# WordLevel: 按词切分, 适合玩具项目
tokenizer = Tokenizer(WordLevel(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()
trainer = WordLevelTrainer(special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]"])
tokenizer.train_from_iterator(train_texts, trainer)
# 3. 创建自定义数据集
class SentimentDataset(Dataset):
def __init__(self, texts, labels, tokenizer, max_len):
self.texts = texts
self.labels = labels
self.tokenizer = tokenizer
self.max_len = max_len
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
text = self.texts[idx]
label = self.labels[idx]
# 词元化,并添加 [CLS] 和 [SEP]
encoding = self.tokenizer.encode(text)
token_ids = [self.tokenizer.token_to_id("[CLS]")] + encoding.ids + [self.tokenizer.token_to_id("[SEP]")]
# 填充到max_len
padding_len = self.max_len - len(token_ids)
token_ids += [self.tokenizer.token_to_id("[PAD]")] * padding_len
return torch.tensor(token_ids), torch.tensor(label)
# --- 实例化 ---
vocab_size = tokenizer.get_vocab_size()
max_len = 20 # 设定一个最大句子长度
train_dataset = SentimentDataset(train_texts, train_labels, tokenizer, max_len)
train_loader = DataLoader(train_dataset, batch_size=2, shuffle=True)
11.3 完整训练管线:让Mini-BERT学会“明辨是非”
目前,我们拥有了模型蓝图和数据“燃料”,是时候点燃引擎了。我们将编写一个完整的训练循环,并用matplotlib来实时绘制损失下降的曲线,以可视化模型的学习过程。
Python
import matplotlib.pyplot as plt
import torch.nn.functional as F
# --- 模型超参数 ---
D_MODEL = 32
NUM_HEADS = 2
NUM_LAYERS = 2
D_FF = 64
NUM_CLASSES = 2 # (Positive, Negative)
# 实例化模型、损失函数和优化器
model = MiniBERT(vocab_size, D_MODEL, NUM_HEADS, NUM_LAYERS, D_FF, NUM_CLASSES, max_len)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
# --- 训练循环 ---
num_epochs = 50
losses = []
print("--- 开始训练 Mini-BERT ---")
for epoch in range(num_epochs):
epoch_loss = 0
for batch_ids, batch_labels in train_loader:
# 准备填充掩码 (虽然我们填充了,但在这个简单模型中可以先不使用,
# 在更复杂的任务中,需要将其传入forward)
# mask = (batch_ids != tokenizer.token_to_id("[PAD]")).unsqueeze(1).unsqueeze(2)
# 前向传播
outputs = model(batch_ids) # mask=mask
loss = loss_fn(outputs, batch_labels)
# 反向传播与优化
optimizer.zero_grad()
loss.backward()
optimizer.step()
epoch_loss += loss.item()
avg_loss = epoch_loss / len(train_loader)
losses.append(avg_loss)
if (epoch + 1) % 10 == 0:
print(f"Epoch [{epoch+1}/{num_epochs}], Loss: {avg_loss:.4f}")
print("--- 训练完成 ---")
# --- 可视化训练过程 ---
plt.figure(figsize=(8, 5))
plt.plot(range(num_epochs), losses)
plt.xlabel("训练周期 (Epoch)")
plt.ylabel("损失 (Loss)")
plt.title("Mini-BERT 训练损失下降曲线 (修正后)", fontsize=14)
plt.grid(True)
plt.show()
输出如下(包括图11.2):
--- 开始训练 Mini-BERT ---
Epoch [10/50], Loss: 0.6225
Epoch [20/50], Loss: 0.0483
Epoch [30/50], Loss: 0.0089
Epoch [40/50], Loss: 0.0061
Epoch [50/50], Loss: 0.0046
--- 训练完成 ---

图11.2:代码生成的Mini-BERT训练损失曲线
图11.2是模型学习过程的最有力证明。我们可以清晰地看到,随着训练周期的增加,损失值(Loss)呈现出明显的下降趋势,最终趋于平稳。这表明我们的Mini-BERT模型,正在从训练数据中成功地学习如何区分积极和消极评论的模式。
11.4 评估与推理:检验学习成果
模型训练好了,但它真的学会了吗?我们需要在“考试”中检验它的能力——用它从未见过的新句子进行推理。
Python
def predict_sentiment(sentence, model, tokenizer, max_len):
model.eval() # 切换到评估模式
with torch.no_grad():
# 准备输入
encoding = tokenizer.encode(sentence)
token_ids = [tokenizer.token_to_id("[CLS]")] + encoding.ids + [tokenizer.token_to_id("[SEP]")]
padding_len = max_len - len(token_ids)
token_ids += [tokenizer.token_to_id("[PAD]")] * padding_len
input_tensor = torch.tensor([token_ids])
# 模型推理
logits = model(input_tensor)
# 获取预测结果
probabilities = F.softmax(logits, dim=1)
prediction = torch.argmax(probabilities, dim=1).item()
return "Positive" if prediction == 1 else "Negative", probabilities
# --- 测试 ---
test_sentence_1 = "The story was absolutely captivating."
test_sentence_2 = "I really did not like the ending."
pred_1, probs_1 = predict_sentiment(test_sentence_1, model, tokenizer, max_len)
pred_2, probs_2 = predict_sentiment(test_sentence_2, model, tokenizer, max_len)
print(f"
句子: '{test_sentence_1}'")
print(f"预测情感: {pred_1}")
print(f"预测概率 (Neg, Pos): {probs_1.numpy()}")
print(f"
句子: '{test_sentence_2}'")
print(f"预测情感: {pred_2}")
print(f"预测概率 (Neg, Pos): {probs_2.numpy()}")
输出结果:
句子: 'The story was absolutely captivating.'
预测情感: Positive
预测概率 (Neg, Pos): [[0.00583177 0.9941683 ]]
句子: 'I really did not like the ending.'
预测情感: Negative
预测概率 (Neg, Pos): [[0.9453862 0.05461381]]
推理结果清晰地表明,我们从零开始构建的Mini-BERT,已经成功地学会了分辨简单句子的情感倾向。
11.5 本章小结
在本章中,我们完成了从零件到产品的第一次飞跃,成功地构建并训练了一个完整的Encoder-Only模型。
- 我们设计了Mini-BERT的架构,它由输入层、多个TransformerEncoderBlock和一个最终的分类头组成。
- 我们亲手创建了一个玩具数据集,并搭建了从文本到填充批次的完整数据处理管线。
- 我们编写了完整的训练循环,并通过可视化的损失曲线,亲眼见证了模型的学习过程。
- 最后,我们编写了推理函数,并在新句子上成功地验证了我们模型的情感分类能力。
我们已经证明,通过堆叠Transformer的编码器模块,可以构建出强劲的文本理解模型。不过,Transformer的魅力远不止于此。如果说Encoder擅长“理解”,那么Decoder则擅长“创造”。下一章,我们将探索Decoder-Only架构,亲手构建一个能够“写诗作画”的迷你GPT。
11.6 思考与面试角
问题1:在BERT这样的Encoder-Only模型中,为什么一般使用[CLS]词元的输出来进行句子级别的分类任务?这样做有什么好处?
参考答案:
使用[CLS]词元的输出进行分类,是BERT设计中的一个核心约定。
好处在于:由于自注意力机制的存在,[CLS]词元在通过多层编码器后,其最终的输出向量已经聚合了整个输入序列的全局上下文信息。模型在预训练和微调过程中,被“教导”要将整个句子的综合语义表明,编码到这个[CLS]向量中。因此,它成为了一个天然的、高质量的“句子嵌入”(Sentence Embedding),超级适合直接输入到一个简单的线性分类器中,来完成句子级别的任务,如情感分类、意图识别等。
问题2:在这个Mini-BERT的forward函数中,数据依次经过了哪些主要的模块?请描述其数据形态的变化。
参考答案:
数据流和形态变化如下:
- 输入:一个批次的词元ID张量。形状:(batch_size, seq_len)。
- 词嵌入层 (nn.Embedding):将离散的ID映射为连续的向量。形状变为:(batch_size, seq_len, d_model)。
- 位置编码 (PositionalEncoding):将位置信息加到词嵌入上。形状不变:(batch_size, seq_len, d_model)。
- 编码器栈 (Encoder Stack):数据依次通过多个TransformerEncoderBlock,在每一层内部进行多头自注意力和前馈网络计算,不断提炼上下文表明。形状不变:(batch_size, seq_len, d_model)。
- 提取CLS表明:从上一步的输出中,只取出第一个时间步([CLS]词元)的向量。形状变为:(batch_size, d_model)。
- 分类头 (nn.Linear):将CLS表明映射到类别空间。形状变为:(batch_size, num_classes)。这个输出就是最终的Logits。
问题3:如果我们想让这个模型处理更复杂的语言现象,仅凭我们创建的这个小型玩具数据集足够吗?如果不足够,应该如何改善?
参考答案:
这个小型玩具数据集是完全不足够的。它只能让模型学会数据聚焦极其有限的几个模式,缺乏泛化能力。
改善方法是采用现代大模型的核心范式——“预训练-微调”(Pre-training & Fine-tuning)。
- 预训练(Pre-training):第一在一个巨大的、无标签的通用文本语料库(如维基百科、书籍)上,使用自监督学习任务(如掩码语言模型 Masked Language Model, MLM)来训练一个通用的语言模型。这个过程让模型学习到丰富的语法、语义和世界知识,并将这些知识编码到模型的参数中。
- 微调(Fine-tuning):然后,在我们自己的、小型的、有标签的任务数据集(如情感分类数据集)上,对预训练好的模型进行“微调”。我们只需用较小的学习率,在我们的任务数据上继续训练模型很短的时间。这个过程相当于将模型已经学到的通用语言能力,“适配”到我们特定的任务上。
这种范式极大地降低了对特定任务标注数据的需求,并能取得比从零开始训练好得多的效果。我们将在第五部分详细探讨这个范式。
——完——
专注模型与代码
喜爱的朋友,欢迎赞同、关注、分享三连 ^O^
















暂无评论内容