4、文本编码器详解:BERT与GPT的编码差异、Tokenization策略、位置编码、注意力机制在文本中的应用

好,咱们今天来聊聊文本编码器。说实话,这块内容我当年啃的时候也花了不少功夫。你想想看,同样都是Transformer架构,为什么BERT和GPT出来的效果差那么多?它们的编码方式到底差在哪?今天我就把这几年的实战经验掰开揉碎讲给你听。

4.1 BERT与GPT的编码差异:一个向左看,一个向右看

先抛个问题:BERT和GPT的核心区别是什么?

说白了,就是注意力范围不一样。

核心差异总结:

  • BERT:双向编码器(Bidirectional Encoder),能看到上下文全部信息
  • GPT:单向解码器(Unidirectional Decoder),只能看到左侧信息

我记得刚开始做文本分类时,我习惯用BERT。为什么?因为分类任务需要理解整句话的意思。比如判断「苹果很好吃」是正面还是负面,你得看到「好吃」这个词才能下结论。BERT的双向注意力正好干这事。

但做文本生成时,情况就反过来了。我曾在项目里尝试用BERT做续写,结果它把后面的词都「偷看」了,生成出来的东西完全不对。GPT的自回归方式才是正解——它只能看到已经生成的词,然后预测下一个。

嗯,这里要注意:BERT用的是Encoder部分,GPT用的是Decoder部分。虽然底层都是Transformer,但注意力掩码(Attention Mask)完全不同。

# 伪代码对比
# BERT:双向注意力
def bert_attention(Q, K, V, mask=None):
    # mask 允许看到所有位置
    scores = Q @ K.T / sqrt(d_k)
    # 没有因果掩码
    weights = softmax(scores)
    return weights @ V

# GPT:因果注意力
def gpt_attention(Q, K, V):
    scores = Q @ K.T / sqrt(d_k)
    # 因果掩码:只允许看左侧
    causal_mask = tril(ones_like(scores))
    scores = scores.masked_fill(causal_mask == 0, -inf)
    weights = softmax(scores)
    return weights @ V

我的经验:如果你做的是理解类任务(分类、NER、QA),优先选BERT。如果是生成类任务(对话、续写、翻译),GPT系更合适。当然,现在很多模型把两者融合了,但底层逻辑没变。

4.2 Tokenization策略:词是怎么被切开的

Tokenization,说白了就是把文本切成小片。但怎么切,学问大了去了。

我最早做NLP时用的是Word-level分词,就是按空格切。结果遇到「unbelievable」这种词,词典里没有,直接变成UNK(未知词)。后来换成Character-level,倒是没UNK了,但序列太长,模型根本学不动。

直到BPE(Byte Pair Encoding)出现,才解决了这个问题。BERT和GPT都用了类似的分词策略,但细节上有差异。

特性 BERT(WordPiece) GPT(BPE)
分词粒度 子词级别 子词级别
特殊标记 [CLS], [SEP], [MASK] <|endoftext|>
词汇表大小 约30k 约50k
处理未知词 拆成更小子词 拆成字节级别

举个例子,单词「playing」:

  • BERT:可能切成「play」+「##ing」(##表示是词的一部分)
  • GPT:可能切成「play」+「ing」(没有特殊标记)

我曾经踩过一个坑:用BERT的分词器去处理GPT的输入,结果[CLS]和[SEP]标记把模型搞懵了。所以一定要用对应模型的分词器,别混用。

避坑指南:我曾经在微调时直接用了默认的Tokenizer,结果发现中文「我喜欢」被切成了「我」「喜」「欢」三个单字。后来才发现需要加载中文预训练模型对应的分词器。记住:分词器必须和预训练模型配套

4.3 位置编码:给词加上「坐标」

Transformer没有循环结构,它怎么知道词的前后顺序?答案就是位置编码。

BERT用的是绝对位置编码,GPT用的是可学习位置编码。两者有什么区别?

  • 绝对位置编码(BERT):用正弦余弦函数生成固定位置向量。好处是不需要训练,坏处是位置信息是固定的,不能适应不同长度的序列。
  • 可学习位置编码(GPT):把位置当成可训练的参数。好处是灵活,能适应任务需求。坏处是需要更多训练数据。

我个人的习惯是:如果序列长度固定(比如512),用绝对位置编码就够了。如果长度变化大,可学习位置编码更靠谱。

# BERT的绝对位置编码(简化版)
def sinusoidal_position_encoding(pos, d_model):
    pe = torch.zeros(1, pos, d_model)
    for i in range(pos):
        for j in range(0, d_model, 2):
            pe[0, i, j] = sin(i / 10000 ** (j / d_model))
            pe[0, i, j+1] = cos(i / 10000 ** (j / d_model))
    return pe

# GPT的可学习位置编码
class LearnedPositionEmbedding(nn.Module):
    def __init__(self, max_len, d_model):
        super().__init__()
        self.pos_embed = nn.Embedding(max_len, d_model)
    
    def forward(self, x):
        positions = torch.arange(x.size(1), device=x.device)
        return x + self.pos_embed(positions)

小技巧:如果你自己训练模型,可以试试把位置编码和词嵌入拼接(concat)而不是相加(add)。我在一个项目里试过,效果提升了2%左右。当然,这要看具体任务。

4.4 注意力机制在文本中的应用:从理论到实战

注意力机制,说白了就是让模型知道「该看哪里」。

在文本里,注意力机制的应用场景很多。我挑三个最常见的说说:

  1. 自注意力(Self-Attention):每个词看其他所有词。BERT和GPT都用这个。
  2. 交叉注意力(Cross-Attention):解码器看编码器的输出。机器翻译里常用。
  3. 多头注意力(Multi-Head Attention):多个注意力头并行,捕捉不同角度的关系。

我记得有一次做情感分析,模型总是把「不是很好」判断成正面。后来一看注意力权重,发现模型只关注了「好」这个词,忽略了前面的「不是」。这就是注意力机制没学到否定关系。

怎么解决?我当时的做法是:增加注意力头数,从8头加到16头。这样模型有更多机会捕捉到否定词和情感词之间的依赖关系。

# 多头注意力实现(核心部分)
class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.n_heads = n_heads
        self.d_k = d_model // n_heads
        
        self.W_q = nn.Linear(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):
        batch_size = Q.size(0)
        
        # 线性变换并分头
        Q = self.W_q(Q).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
        K = self.W_k(K).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
        V = self.W_v(V).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
        
        # 计算注意力分数
        scores = Q @ K.transpose(-2, -1) / sqrt(self.d_k)
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
        
        attn = softmax(scores, dim=-1)
        out = attn @ V
        
        # 合并多头
        out = out.transpose(1, 2).contiguous().view(batch_size, -1, self.n_heads * self.d_k)
        return self.W_o(out)

实战建议:

  • 处理长文本时(超过512 token),考虑用滑动窗口注意力稀疏注意力,不然显存会爆
  • 如果任务需要捕捉全局关系(如文档分类),用全局注意力,让[CLS] token看到所有位置
  • 做生成任务时,别忘了加因果掩码,不然模型会作弊

4.5 知识体系总览

说了这么多,我画张图帮你理清思路。这张图展示了文本编码器的核心组件和它们之间的关系。

文本编码器知识体系 文本编码器 BERT vs GPT 双向 vs 单向 Tokenization BPE / WordPiece 位置编码 绝对 / 可学习 注意力机制 自注意力 / 多头 Encoder Decoder 子词切分 特殊标记 正弦余弦 可学习参数 自注意力 交叉注意力 理解编码器 = 理解模型的行为方式

这张图把今天讲的内容串起来了。你看,从BERT和GPT的编码差异出发,到Tokenization怎么切词,再到位置编码怎么给词定位,最后是注意力机制怎么让模型「看」文本。每一步都环环相扣。

说实话,理解这些底层原理,比背几个API有用得多。我见过太多人调参调了半天,效果不好,结果发现是编码器选错了。嗯,希望今天的内容能帮你少走弯路。

最后的小建议:动手跑一下BERT和GPT的编码过程,看看它们的输出有什么不同。纸上得来终觉浅,绝知此事要躬行。

专注资料整理