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 注意力机制在文本中的应用:从理论到实战
注意力机制,说白了就是让模型知道「该看哪里」。
在文本里,注意力机制的应用场景很多。我挑三个最常见的说说:
- 自注意力(Self-Attention):每个词看其他所有词。BERT和GPT都用这个。
- 交叉注意力(Cross-Attention):解码器看编码器的输出。机器翻译里常用。
- 多头注意力(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和GPT的编码差异出发,到Tokenization怎么切词,再到位置编码怎么给词定位,最后是注意力机制怎么让模型「看」文本。每一步都环环相扣。
说实话,理解这些底层原理,比背几个API有用得多。我见过太多人调参调了半天,效果不好,结果发现是编码器选错了。嗯,希望今天的内容能帮你少走弯路。
最后的小建议:动手跑一下BERT和GPT的编码过程,看看它们的输出有什么不同。纸上得来终觉浅,绝知此事要躬行。