第11章:实战一:构建Encoder-Only模型 (Mini-BERT) 用于情感分类

第四部分:实战:亲手打造三种核心架构

欢迎来到理论与实践的交汇点。在“第三部分:核心引擎”中,我们已经彻底掌握了构成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所示,它由三大部分组成:

  1. 输入层:包含我们在第8章学习的词嵌入(Token Embedding)和位置编码(Positional Encoding)
  2. 编码器栈(Encoder Stack):由多个(例如2个)我们第10章构建的TransformerEncoderBlock堆叠而成。
  3. 分类头(Classification Head):一个简单的全连接层(nn.Linear),它接收[CLS]词元对应的最终输出向量,并将其映射到分类任务的类别空间(在这里是2类:积极/消极)。

第11章:实战一:构建Encoder-Only模型 (Mini-BERT) 用于情感分类

图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章:实战一:构建Encoder-Only模型 (Mini-BERT) 用于情感分类

图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模型。

  1. 我们设计了Mini-BERT的架构,它由输入层、多个TransformerEncoderBlock和一个最终的分类头组成。
  2. 我们亲手创建了一个玩具数据集,并搭建了从文本到填充批次的完整数据处理管线
  3. 我们编写了完整的训练循环,并通过可视化的损失曲线,亲眼见证了模型的学习过程。
  4. 最后,我们编写了推理函数,并在新句子上成功地验证了我们模型的情感分类能力

我们已经证明,通过堆叠Transformer的编码器模块,可以构建出强劲的文本理解模型。不过,Transformer的魅力远不止于此。如果说Encoder擅长“理解”,那么Decoder则擅长“创造”。下一章,我们将探索Decoder-Only架构,亲手构建一个能够“写诗作画”的迷你GPT。

11.6 思考与面试角

问题1:在BERT这样的Encoder-Only模型中,为什么一般使用[CLS]词元的输出来进行句子级别的分类任务?这样做有什么好处?

参考答案:
使用[CLS]词元的输出进行分类,是BERT设计中的一个核心约定。
好处在于:由于自注意力机制的存在,[CLS]词元在通过多层编码器后,其最终的输出向量已经聚合了整个输入序列的全局上下文信息。模型在预训练和微调过程中,被“教导”要将整个句子的综合语义表明,编码到这个[CLS]向量中。因此,它成为了一个天然的、高质量的“句子嵌入”(Sentence Embedding),超级适合直接输入到一个简单的线性分类器中,来完成句子级别的任务,如情感分类、意图识别等。

问题2:在这个Mini-BERT的forward函数中,数据依次经过了哪些主要的模块?请描述其数据形态的变化。

参考答案:
数据流和形态变化如下:

  1. 输入:一个批次的词元ID张量。形状:(batch_size, seq_len)。
  2. 词嵌入层 (nn.Embedding):将离散的ID映射为连续的向量。形状变为:(batch_size, seq_len, d_model)。
  3. 位置编码 (PositionalEncoding):将位置信息加到词嵌入上。形状不变:(batch_size, seq_len, d_model)。
  4. 编码器栈 (Encoder Stack):数据依次通过多个TransformerEncoderBlock,在每一层内部进行多头自注意力和前馈网络计算,不断提炼上下文表明。形状不变:(batch_size, seq_len, d_model)。
  5. 提取CLS表明:从上一步的输出中,只取出第一个时间步([CLS]词元)的向量。形状变为:(batch_size, d_model)。
  6. 分类头 (nn.Linear):将CLS表明映射到类别空间。形状变为:(batch_size, num_classes)。这个输出就是最终的Logits。

问题3:如果我们想让这个模型处理更复杂的语言现象,仅凭我们创建的这个小型玩具数据集足够吗?如果不足够,应该如何改善?

参考答案:
这个小型玩具数据集是完全不足够的。它只能让模型学会数据聚焦极其有限的几个模式,缺乏泛化能力。
改善方法是采用现代大模型的核心范式——“预训练-微调”(Pre-training & Fine-tuning)

  1. 预训练(Pre-training):第一在一个巨大的、无标签的通用文本语料库(如维基百科、书籍)上,使用自监督学习任务(如掩码语言模型 Masked Language Model, MLM)来训练一个通用的语言模型。这个过程让模型学习到丰富的语法、语义和世界知识,并将这些知识编码到模型的参数中。
  2. 微调(Fine-tuning):然后,在我们自己的、小型的、有标签的任务数据集(如情感分类数据集)上,对预训练好的模型进行“微调”。我们只需用较小的学习率,在我们的任务数据上继续训练模型很短的时间。这个过程相当于将模型已经学到的通用语言能力,“适配”到我们特定的任务上。
    这种范式极大地降低了对特定任务标注数据的需求,并能取得比从零开始训练好得多的效果。我们将在第五部分详细探讨这个范式。

——完——

专注模型与代码

喜爱的朋友,欢迎赞同、关注、分享三连 ^O^

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
猛男江停挺举三百斤奶黄包的头像 - 鹿快
评论 抢沙发

请登录后发表评论

    暂无评论内容