Skip to content

Latest commit

 

History

History
391 lines (307 loc) · 29.2 KB

File metadata and controls

391 lines (307 loc) · 29.2 KB

第二节 GPT 结构及应用

在 Transformer 催生的众多预训练模型中,除了应用广泛的基于的 BERT,还有一条专注于生成的技术路线。那就是完全基于 Transformer 解码器 构建的 GPT(Generative Pre-trained Transformer) 系列模型。与 BERT 致力于通过双向上下文来“理解”语言不同,GPT 的重心在“生成”语言。它的设计初衷就是为了根据给定的上文,以自回归(Auto-Regressive)的方式预测下一个最可能的词元,这种特性使其在文本生成、对话系统、内容续写等任务上展现出无与伦比的能力。从早期的 GPT-1,到引爆全球AI热潮的 ChatGPT (其背后是 GPT-3.5/4),OpenAI 沿着解码器这条路,不断刷新着我们对语言模型能力的认知。

一、GPT 的设计思想与技术发展

与 BERT 类似,早期的 GPT 也遵循 “预训练 + 微调” 的思路。不过,随着模型规模的指数级增长(尤其是到了 GPT-3),提示(Prompt)上下文学习(In-context Learning, ICL) 开始成为主流,允许用户在不更新参数的情况下完成任务。虽然现代大模型依然会使用微调,但目的和形式已与 BERT 时代针对特定任务的微调有了本质不同。

1.1 因果语言模型

GPT 的成功根植于一个非常简洁而强大的预训练任务——因果语言模型 (Causal Language Model, CLM)。通俗地讲,就是 预测下一个词。给定一段文本序列 $x_1, x_2, ..., x_t$,模型的目标是最大化预测出下一个词元 $x_{t+1}$ 的概率。它在预训练过程中,不断地在海量文本上重复这个“续写”任务。

$$ P(x_1, ..., x_T) = \prod_{t=1}^{T} P(x_t | x_1, ..., x_{t-1}) $$

这个看似简单的目标,却迫使模型必须学习到语言的深层规律,包括语法结构、事实知识、上下文逻辑关系等,因为只有真正“理解”了前面的内容,才能准确地预测后面会说什么。由于在预测第 t 个词时,模型只能看到它前面的 t-1 个词,这种单向的、自回归的特性是 GPT 与 BERT 最根本的区别。

1.2 从微调到提示

(1)GPT-1:有监督微调(Supervised Fine-tuning) 1

GPT-1 的思路是先在无标注数据上进行无监督预训练,学习通用的语言表示,然后在特定任务的有标注数据上进行有监督微调。它的关键创新是提出了任务特定的输入变换,为了解决不同任务输入格式不一致的问题(如问答需要输入文章、问题、答案),GPT-1 不改变模型架构,而是通过在输入文本中加入特殊的分隔符(如 Start, Delimiter, Extract)将结构化输入转换为有序的序列。例如,对于文本蕴含任务,将前提(Premise)和假设(Hypothesis)用 $ 符号拼接后输入模型。

(2)GPT-2:零样本学习(Zero-shot Learning) 2

主要观点认为语言模型即多任务学习者。研究者发现,当模型规模扩大(1.5B 参数)并使用更高质量的数据集(WebText,过滤后的 Reddit 高赞链接内容)训练后,模型展现出了无需微调的零样本能力。具体表现为可以直接给模型输入指令,如 “Translate English to French: cheese =>”,模型就能自动续写出 “fromage”,证明模型在预训练中已经隐式地学会了各种任务。此外,该模型在架构上也进行了微调,将 LayerNorm 移到了每个子层的输入端(Pre-activation),并增加了上下文窗口大小(512 -> 1024)。

(3)GPT-3:上下文学习(In-context Learning) 3

GPT-3 的定位是少样本学习者(Few-Shot Learners)。它将参数量推向了惊人的 1750 亿,此时模型展现出了强大的上下文学习(In-context Learning)能力。所谓的 In-context Learning,是指模型在推理阶段不需要更新任何权重(即无需微调),仅凭输入提示中给出的少量示例,就能理解并完成新任务。这包括仅给出任务描述的 Zero-shot(例如:“将中文翻译成英文:你好 ->”),给出一个示例的 One-shot(例如:“将中文翻译成英文:\n苹果 -> apple\n你好 ->”),以及给出多个示例的 Few-shot(通常能显著提升模型性能,甚至达到微调模型的效果)。这一转变标志着我们不再为每个任务训练一个专用模型,而是通过设计提示 (Prompt) 来激发通用大模型的能力。

通过这种方式,几乎所有的自然语言处理任务都可以被转换成一个统一的“问答”或“续写”模式。以文本分类为例,旧方法(微调)是“输入句子 -> [CLS]向量 -> 分类头 -> 类别标签”,而新方法(提示)则是输入提示(如 请判断以下评论的情感类别(正面/负面):"这家餐厅的菜品味道惊艳,服务也很周到。"),然后让模型续写出“正面”。同样对于文本相似度任务,旧方法是“输入句子对 -> [CLS]向量 -> 分类头 -> 相似/不相似”,新方法则是输入提示(如 请判断下面两个句子的意思是否相似。句子1:"今天天气真好" 句子2:"天气晴朗的一天"),然后让模型续写出“相似”。

二、GPT 架构解析

2.1 模型基本结构

GPT 的架构本质就是将 Transformer 的解码器模块进行堆叠,所以我们在 Transformer 中学习的关于解码器的知识大部分都适用于 GPT。一个标准的 Transformer 解码器层包含两个关键子层,一个是掩码多头自注意力,这是实现单向、自回归生成的关键,通过“掩码”机制确保在计算任何一个位置的表示时,只能关注到它左侧(即已经生成)的词元,而不能“看到”未来的信息。另一个是位置前馈网络,它与编码器中的结构完全相同,负责对每个位置的表示进行非线性变换。需要注意,原始 Transformer 解码器中用于与编码器交互的“交叉注意力”层,由于 GPT 完全没有编码器部分,所以这一层被移除了

图 5-4 详细展示了 GPT-1 的模型架构与微调策略。左侧是基于 Transformer 解码器的模型主体,可以看到在微调阶段,模型除了输出任务分类结果(Task Classifier),还可以保留语言模型预测(Text Prediction)作为辅助目标。具体来说,我们在优化特定任务(如分类)的损失 $L_{task}$ 时,会加上一个权重的语言模型损失 $L_{lm}$,即 $L = L_{task} + \lambda L_{lm}$。这种多任务学习的策略不仅能帮助模型泛化,还能加速收敛。右侧则直观地描绘了如何通过输入变换将不同类型的任务适配到预训练模型中:对于分类任务,在文本前后添加起始 (Start) 和提取 (Extract) 标记;对于蕴含任务,将前提和假设用分隔符 (Delim) 拼接;对于相似度任务,由于 GPT 是单向模型,无法像 BERT 那样同时“看到”两个句子,且对句子顺序敏感,因此将两个句子按不同顺序拼接(Text1-Text2 和 Text2-Text1)分别输入模型,最后将两者的特征相加作为最终表示;对于多选题任务,则将上下文与每一个答案选项分别拼接,独立输入模型计算得分。

GPT-1 模型架构与输入变换

图 5-4 GPT-1 模型架构(左)与针对不同任务的输入变换(右)

2.2 关键架构演进

虽然大体框架不变,但 GPT 各代模型在细节上有一些关键演进。这里我们结合 GPT 的一些通用特性来进行介绍。

(1)特殊 Token: GPT 系列模型通常使用 <|endoftext|> 作为主要的特殊 Token。它既可以表示文本的结束(End of Text),也可以在预训练时用作不同文档之间的分隔符。在微调或 Few-shot 学习时,它也常被用来分隔示例。

(2)归一化位置 (Pre-Norm vs Post-Norm): GPT-1 采用 Post-Norm 结构(与原始 Transformer 和 BERT 一致),也就是 LayerNorm(x + SubLayer(x))。而到了 GPT-2/3 则转向 Pre-Norm 结构,即 x + SubLayer(LayerNorm(x))。这一改进缓解了深层网络梯度消失/爆炸的问题,让训练更深的模型(如 GPT-3 有 96 层)成为可能。

(3)输入表示与最大长度: GPT 系列模型的词元嵌入采用的是字节级 BPE(Byte-level BPE),至于位置嵌入则始终采用可学习的位置嵌入。随着模型的发展,上下文窗口大小也在不断增加,GPT-1 为 512,GPT-2 增加到 1024(这也是很多早期 GPT 类模型的默认最大长度),到了 GPT-3 进一步扩展到了 2048。

(4)无片段嵌入: GPT 的输入总是一个连续的文本流,不区分句子片段,所以没有 BERT 那样的 Segment Embeddings。

表 5-3 展示了 GPT-3 系列不同规模模型的具体参数配置,可以看到随着层数和维度的增加,模型容量呈指数级增长。

Model Name nparams nlayers dmodel nheads dhead Batch Size Learning Rate
GPT-3 Small 125M 12 768 12 64 0.5M 6.0 × 10-4
GPT-3 Medium 350M 24 1024 16 64 0.5M 3.0 × 10-4
GPT-3 Large 760M 24 1536 16 96 0.5M 2.5 × 10-4
GPT-3 XL 1.3B 24 2048 24 128 1M 2.0 × 10-4
GPT-3 2.7B 2.7B 32 2560 32 80 1M 1.6 × 10-4
GPT-3 6.7B 6.7B 32 4096 32 128 2M 1.2 × 10-4
GPT-3 13B 13.0B 40 5140 40 128 2M 1.0 × 10-4
GPT-3 175B or “GPT-3” 175.0B 96 1228896 128 3.2M 0.6 × 10-4

表 5-3 不同规模 GPT-3 模型的架构参数配置

2.3 GPT 与 BERT 的主要差异

尽管都基于 Transformer,但由于选择了编码器和解码器两条不同的路线,BERT 和 GPT 在设计和应用上有着明显区别,如表 5-4 所示(此处主要对比原始版本的 BERT 和 GPT-1/2/3,不包含后续的各类变体)。

特性 BERT (Encoder) GPT (Decoder)
核心结构 Transformer 编码器 Transformer 解码器
注意力机制 标准自注意力(双向,能看到全文) 掩码自注意力(单向,只能看左边)
预训练任务 掩码语言模型, 下一句预测 因果语言模型 / 预测下一个词
输入表示 词元 + 位置 + 片段 词元 + 位置
最大长度 通常 512 1024 (GPT-2) / 2048 (GPT-3)
归一化 Post-Norm Pre-Norm (GPT-2/3)
应用模式 微调(Fine-tuning): 加分类头,更新权重 提示(Prompting): 设计提示词,不更新权重 (Zero/Few-shot)
适用场景 语言理解(NLU): 分类、NER、抽取式问答 语言生成(NLG): 写作、对话、生成式任务

表 5-4 BERT 与 GPT 的差异对比

三、GPT 代码实战

为了更好地理解 GPT 模型自回归生成的内部原理,以及 gpt2 这类英文预训练模型处理不同语言时的差异,下面的示例将并列展示它处理英文和中文时的不同表现。

本节完整代码

3.1 代码示例

以下代码将分别对英文和中文进行手动地逐字生成,以观察内部过程和差异。

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

# 1. 环境配置
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model_name = "gpt2"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name).to(device)
model.eval()

# 2. 英文示例
prompt_en = "I like eating fried"
input_ids_en = tokenizer(prompt_en, return_tensors="pt")['input_ids'].to(device)

print(f"英文输入: '{prompt_en}'")
print(f"被编码为 {input_ids_en.shape[1]} 个 token: {tokenizer.convert_ids_to_tokens(input_ids_en[0])}")
print("开始为英文逐个 token 生成...")

generated_ids_en = input_ids_en
with torch.no_grad():
    for i in range(5): # 只生成 5 个 token 作为示例
        outputs = model(generated_ids_en)
        next_token_logits = outputs.logits[:, -1, :]
        next_token_id = torch.argmax(next_token_logits, dim=-1).unsqueeze(-1)
        new_token = tokenizer.decode(next_token_id[0])
        print(f"第 {i+1} 步, 生成 token: '{new_token.strip()}'")
        generated_ids_en = torch.cat([generated_ids_en, next_token_id], dim=1)

full_decoded_text_en = tokenizer.decode(generated_ids_en[0], skip_special_tokens=True)
print(f"\n英文最终生成结果: \n'{full_decoded_text_en}'\n")


# 3. 中文示例
prompt_zh = "我喜欢吃炸"
input_ids_zh = tokenizer(prompt_zh, return_tensors="pt")['input_ids'].to(device)

print(f"中文输入: '{prompt_zh}'")
print(f"被编码为 {input_ids_zh.shape[1]} 个 token: {tokenizer.convert_ids_to_tokens(input_ids_zh[0])}")
print("开始为中文逐个 token 生成...")

generated_ids_zh = input_ids_zh
with torch.no_grad():
    for i in range(5): # 只生成 5 个 token 作为示例
        outputs = model(generated_ids_zh)
        next_token_logits = outputs.logits[:, -1, :]
        next_token_id = torch.argmax(next_token_logits, dim=-1).unsqueeze(-1)
        new_token = tokenizer.decode(next_token_id[0])
        print(f"第 {i+1} 步, 生成 token: '{new_token.strip()}'")
        generated_ids_zh = torch.cat([generated_ids_zh, next_token_id], dim=1)

full_decoded_text_zh = tokenizer.decode(generated_ids_zh[0], skip_special_tokens=True)
print(f"\n中文最终生成结果 (出现乱码是正常现象): \n'{full_decoded_text_zh}'")

下面我们来详细分解一下上述代码的关键步骤:

(1)加载模型和分词器:我们首先要使用 AutoModelForCausalLM.from_pretrained(model_name) 加载模型。注意这里使用的是 ForCausalLM 类,它比标准的 AutoModel 多了一个 lm_head 层,专门用于将隐藏层状态映射回词表大小,从而预测下一个词。

(2)文本预处理tokenizer(prompt_en, ...) 会将文本转换为 token ID 序列。与 BERT 不同,GPT 通常不需要 token_type_ids

(3)模型推理 (自回归循环):接下来,outputs = model(generated_ids_en) 将当前的完整序列输入模型。然后 next_token_logits = outputs.logits[:, -1, :] 提取序列中最后一个位置 (-1) 的输出,因为它是对“下一个未知词”的预测(GPT 模型的输出 logits 形状为 (batch_size, sequence_length, vocab_size))。后面的 torch.argmax(..., dim=-1) 则是在词表维度上取概率最大的那个 token ID,作为预测结果(这是最简单的贪心搜索策略)。

(4)拼接与更新torch.cat([generated_ids_en, next_token_id], dim=1) 最后会将新预测出的 token 拼接到原输入序列的末尾。关键点是这个拼接了新 token 的更长序列会被再次输入到模型中进行下一轮预测,这种将当前的输出作为下一步输入的机制就是自回归的核心体现。

上面的代码通过并列对比 gpt2 模型处理英文和中文时的不同表现,可以得到两个关于原始 GPT2 预训练模型的结论。在分词效率方面,输入 "I like eating fried" 包含 4 个单词,被分词器高效地编码成了 4 个有意义的 token ['I', 'Ġlike', 'Ġeating', 'Ġfried'] (带 Ġ 前缀表示一个词的开始)。而输入 "我喜欢吃炸" 同样是 4 个字,但通常会被编码成更多的字节级 token(数量会随 transformers 版本和分词细节略有差异)。这说明 gpt2 的分词机制导致了中文编码效率的下降。至于生成质量方面,对于英文输入,模型能够理解输入的语义,并生成流畅且有逻辑的续写(例如 I like eating fried chicken...),证明了模型在它的“母语”环境下的强大能力。但对于中文输入,由于模型完全不理解这些字节组合的含义,它只能根据在英文世界里学到的统计规律来预测下一个字节,最终呈现为一片乱码,深层原因同样与分词机制和训练语料有关。

3.2 模型结构分解

通过 print(model) 可以得到 GPT-2 模型的结构图。

GPT2LMHeadModel(
  (transformer): GPT2Model(
    (wte): Embedding(50257, 768)
    (wpe): Embedding(1024, 768)
    (drop): Dropout(p=0.1, inplace=False)
    (h): ModuleList(
      (0-11): 12 x GPT2Block(
        (ln_1): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
        (attn): GPT2Attention(
          (c_attn): Conv1D(nf=2304, nx=768)
          (c_proj): Conv1D(nf=768, nx=768)
          (attn_dropout): Dropout(p=0.1, inplace=False)
          (resid_dropout): Dropout(p=0.1, inplace=False)
        )
        (ln_2): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
        (mlp): GPT2MLP(
          (c_fc): Conv1D(nf=3072, nx=768)
          (c_proj): Conv1D(nf=768, nx=3072)
          (act): NewGELUActivation()
          (dropout): Dropout(p=0.1, inplace=False)
        )
      )
    )
    (ln_f): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
  )
  (lm_head): Linear(in_features=768, out_features=50257, bias=False)
)

可以看到,整个 GPT2LMHeadModel 由两大部分组成,分别是transformer 主体 (即 GPT2Model),和顶层的 lm_head (语言模型头)。下面分别进行介绍:

(1)transformer (GPT2Model): 这是模型的主体,负责从输入 token ID 序列中提取深层特征。它主要包含以下两个部分:

  • 嵌入层 (Embeddings): (wte): Embedding(50257, 768) 代表词元嵌入 (Word Token Embedding),其中 50257 为 GPT-2 的词汇表大小,768 为隐藏层维度 H。(wpe): Embedding(1024, 768) 则代表位置嵌入 (Word Position Embedding),这是一种可学习的嵌入,其维度决定了标准 GPT-2 模型的最大输入长度为 1024。与 BERT 相比,GPT 没有包含片段嵌入(token_type_embeddings),具体原因已在前文阐述。

  • 解码器堆栈 (Decoder Stack): (h): ModuleList((0-11): 12 x GPT2Block) 中包含了 12 个完全相同的 GPT2Block,即 12 层的 Transformer 解码器,每一层的输出都会作为下一层的输入。在每一个 GPT2Block 内部,都包含了理论中所述的两个关键子层(被 LayerNorm 包裹)。其中,(attn): GPT2Attention掩码多头自注意力模块,它内部的 c_attnc_proj 使用 Conv1D 来实现(Conv1D 在这里被巧妙地用作全连接层)。c_attn 一次性将 768 维的输入映射到 2304 维 (768 * 3),正好对应 Q, K, V 三个矩阵的拼接,c_proj 则是注意力机制中的输出映射矩阵 $W^O$。另一个关键子层是 (mlp): GPT2MLP,即位置前馈网络模块,它同样使用 Conv1D 实现。c_fc 会将维度从 768 升维 到 3072,经过激活函数后,c_proj 再将维度从 3072 降维 回 768,这种“先升维再降维”的结构与 BERT 中的前馈网络完全一致。

(2)lm_head (语言模型头): 这是 GPT2LMHeadModel 与基础的 GPT2Model 的唯一区别,也是它能够生成文本的关键,本质上是一个简单的线性层(全连接层)。这和 BERT 的 pooler 层形成了鲜明对比,因为 BERT 的 pooler 层只接收 [CLS] 词元的输出,用于 NSP 任务的句子级别二分类。而 GPT 的 lm_head 接收序列中每一个位置的输出向量,并将其从 768 维映射到词汇表大小 50257 维,得到每个位置上所有词元的 logits 分布,专门用于词元级别的“预测下一个词”任务。在我们手动实现的贪心搜索循环中,next_token_logits = outputs.logits[:, -1, :] 这一步,实际上就是 transformer 主体输出最后一个位置的 768 维向量后,再通过这个 lm_head 转换成 50257 维 logits 的结果。所以说 lm_head 就是连接模型特征与最终词元预测的桥梁。

3.3 分词器工作原理

理解 GPT 与 BERT 在分词器设计上的差异,是理解 GPT 模型特性的一个关键。GPT 使用的 字节对编码,特别是字节级 (Byte-level) 的实现,使它能够从根本上杜绝 [UNK] (未知词) 。具体工作原理是从构建基础词汇表开始,BPE 的基础词汇表包含所有的单个字节 (0-255),也就意味着任何文本都能被无损地表示为一个字节序列。接着要进行贪心合并,在训练分词器时,算法会贪心地、迭代地合并最高频出现的相邻字节对,形成新的、更长的子词(subword)并加入词汇表(例如 th 合并成 th,之后可能进一步合并成 the)。最后的编码过程,当对新文本进行编码时,会先将其转换为字节序列,然后在这个序列上贪心地用词汇表里最长的子词来替换对应的字节序列。

这个机制的一个显著优点是无未知词。因为在最坏情况下,任何在词汇表中找不到的词都可以被拆分成最基础的单个字节,所以模型永远不会遇到它完全不认识的 token。通过下面的代码可以直观地看到这一点。对于一个非常生僻的汉字 ,BERT 的分词器可能会直接判为 [UNK],但 GPT-2 的分词器会将其分解为对应的 UTF-8 字节表示。

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("gpt2")

text = "一个生僻字:龘"
tokens = tokenizer.tokenize(text)

# 这类不在词表的字符会被拆成若干字节级 token
print(tokens)

不过,这种机制也存在对非核心语言效率低的缺点。gpt2 的 BPE 词汇表是为英文构建的,不包含任何独立的中文词语作为 token。虽然它的字节级机制能表示任意汉字,但它会将这些汉字拆分成多个基础的字节 token,导致一个汉字通常会被拆分成 2-3 个字节级别的 token。不仅增加了序列的长度,也让模型学习中文语义变得更加困难。如代码示例所示,英文提示词被编码为 10 个 token,而几乎同样长度的中文提示词则被编码为了 19 个 token,极大地消耗了模型的上下文长度预算。

为什么 gpt2 模型生成中文会是乱码?

这就是由上述的分词机制和模型的英文训练背景共同导致的。

主要体现在两个层面,在分词层面,模型看到的不是“汉字”而是无意义的字节组合。在模型层面,由于模型是在英文语料上训练的,它只懂得按英文的规律来预测下一个字节。

最终,模型会续写出一串符合英文统计规律但完全不符合中文编码规则的字节序列,解码时自然就成了一片乱码。要解决此问题,必须使用在中文语料上预训练的模型,例如 uer/gpt2-chinese-cluecorpussmall,这类模型的分词器和模型本身都为中文进行了适配。

3.4 使用 pipeline

手动实现循环有助于理解原理,但在实际应用中,transformers 库提供了更高阶、更便捷的工具——pipeline。它将所有步骤(分词、模型调用、解码)封装在一起,一行代码即可完成文本生成4。下面的示例将用 pipeline 快速复现前面的英文生成任务。

# pipeline 应用
from transformers import pipeline

print("\n\n--- Pipeline 快速演示 (英文) ---")
generator = pipeline("text-generation", model=model_name, device=device)
pipeline_outputs = generator("I like eating fried", max_new_tokens=5, num_return_sequences=1)
print(pipeline_outputs[0]['generated_text'])

通过代码可以看到,pipeline("text-generation", ...) 创建了一个专门用于文本生成的 pipeline 对象,它自动处理了分词、模型推理和解码的所有细节。而 generator(...) 仅用一行代码就完成了我们之前手动循环实现的全部功能,它的输出结果与我们手动实现的英文生成结果是一致的,证明 pipeline 是在底层执行了相似的逻辑,但为开发者提供了极其便利的接口。


参考文献

Footnotes

  1. Radford, A., Narasimhan, K., Salimans, T., & Sutskever, I. (2018). Improving Language Understanding by Generative Pre-Training.

  2. Radford, A., Wu, J., Child, R., Luan, D., Amodei, D., & Sutskever, I. (2019). Language Models are Unsupervised Multitask Learners.

  3. Brown, T. B., Mann, B., Ryder, N., et al. (2020). Language Models are Few-Shot Learners.

  4. Hugging Face. GPT-2 tokenizer and model documentation.