从零开始构建大模型-读书笔记
LLM的定义:
一个LLM是一种神经网络,旨在理解、生成和回应类似人类的文本。这些模型是经
过大量文本数据训练的深度神经网络,有时甚至包括整个互联网上可公开获取文本的
大部分内容。
什么是预训练和微调。
"预训练"中的“预”指的是初始阶段,其中类似于LLM的模型在大规模、多样化的数据集上训练,以发展对语言的广泛理解。这个预训练模型随后作为基础资源,可以通过微调进一步优化。
微调是一个在更具体于特定任务或领域的较窄数据集上对模型进行特定训练的过程。这种由预训练和微调组
成的两阶段训练方法在图 1.3 中展示
两个最受欢迎的微调类别是指令微调和分类微调。
在指令微调中,标记数据集由指令和答案对组成,例如一个查询翻译文本并附带正确翻译的文本。
在分类微调中,标记数据集由文本及其相关类别标签组成——例如,与“垃圾邮件”和“非垃圾邮件”标签相关的电子邮件
对于传统的机器学习模型 和通过传统监督学习范式训练的深度神经网络,通常需要标签信息。然而,在LLMs的预训练阶段并非如此。在这个阶段,LLMs使用自监督学习,模型从输 入数据中生成自己的标签
Transformer 架构
大多数现代LLMs依赖于 Transformer 架构,这是一种在 2017 年论文
“Attention Is All You Need”(https://arxiv.org/abs/1706.03762)中引入的深
度神经网络架构。要理解LLMs,我们必须了解原始的 Transformer,它是为机器翻译
开发的,将英语文本翻译成德语和法语。Transformer 架构的简化版本在图 1.4 中展
示。
Transformer 架构由两个子模块组成:一个编码器和一个解码器。编码器模块处理输入
文本并将其编码成一系列数值表示或向量,这些向量捕捉输入的上下文信息。然后,
解码器模块接收这些编码向量并生成输出文本。
案例补充:从架构到 ChatGPT
理解 Transformer 后,可以顺着这条主线串起大模型的发展脉络:
- 2017 年:Transformer 论文发表,奠定自注意力架构基础;
- 2018 年:BERT 用编码器做理解任务,GPT 用解码器做生成任务;
- 2020 年至今:GPT 系列不断放大解码器规模,配合海量数据与指令微调,最终演进为 ChatGPT 等对话式大模型。
这样,从“编码器-解码器”的架构原点出发,就能自然理解为什么今天的 LLM 大多采用(或改造)Transformer 解码器作为主干。
BERT,基于原始 transformer 的编码子模块构建,其在训练方法上与 GPT 不同。GPT 是为生成任务设计的,BERT 及其变体则专注于掩码词预测,其中模型预测掩码或隐藏在给定句子中的单词,如图 1.5 所示。这种独特的训练策略使 BERT 在文本分类任务中具有优势,包括情感预测和文档分类。作为其能力的一种应用,截至本文撰写时,X(前身为 Twitter)使用 BERT 来检测有害内容。GPT 另一方面,专注于原始 Transformer 架构的解码器部分,旨在处理需要生成文本的任务。这包括机器翻译、文本摘要、小说创作、编写计算机代码等。GPT 模型,主要设计和训练用于执行文本补全任务,在能力上也表现出显著的灵活性。这些模型擅长执行零样本和少样本学习任务。零样本学习指的是在没有任何先
前特定示例的情况下,泛化到完全未见过的任务的能力。另一方面,少样本学习涉及从用户提供的最少数量示例中进行学习,如图 1.6 所示
构建大型语言模型
LLM 编码的三个主要阶段是实施 LLM 架构和数据准备过程(阶段 1),预训练 LLM 以创建基础模型(阶段
2),以及微调基础模型以成为个人助理或文本分类器(阶段 3)。
理解词嵌入
数据转换为向量格式的概念通常被称为嵌入。使用特定的神经网络层或另一个预训
练的神经网络模型,我们可以嵌入不同类型的数据——例如,视频、音频和文本,如图
2.2 所示。然而,需要注意的是,不同的数据格式需要不同的嵌入模型。例如,为文本
设计的嵌入模型不适合嵌入音频或视频数据
比如最简单的二维嵌入:
虽然我们可以使用预训练模型如 Word2Vec 为机器学习模型生成嵌入,但LLMs通常会产生自己的嵌入,这些嵌入是输入层的一部分,并在训练过程中更新。将嵌入作为LLM训练的一部分进行优化的优点是,嵌入被优化以适应特定任务和数据。不幸的是,高维嵌入对可视化构成了挑战,因为我们的感官感知和常见的图形表示本质上限于三维或更少,这就是为什么图 2.3 在二维散点图中显示了二维嵌入。然而,当我们处理LLMs时,我们通常使用具有更高维度的嵌入。对于 GPT-2和 GPT-3,嵌入大小(通常被称为模型隐藏状态的维度)根据特定模型变体和大小而变化。这是性能和效率之间的权衡。 最小的 GPT-2 模型(117M 和 125M 参数)使用768 维的嵌入大小来提供具体示例。最大的 GPT-3 模型(175B 参数)使用 12,288维的嵌入大小。
在 Python 中实现一个完整的分词器类,其中包含一个 encode 方法,该方
法将文本分割成标记并通过词汇表执行字符串到整数的映射以生成标记 ID。
此外,我们还将实现一个 decode 方法,该方法执行反向的整数到字符串映射,将标记 ID 转换
回文本。以下列表显示了此分词器实现的代码。
SimpleTokenizerV2
字节对编码(BPE)
tiktoken
,它通过迭代地将频繁出现的字符合并为子词,将频繁出现的子词合并为单词来构建其词汇表。例如,BPE 首先将
所有单个字符添加到其词汇表中(“a”、“b”等)。在下一阶段,它将经常一起出现的字符组合合并为子词。例如,“d”和“e”可能合并为子词“de”,这在许多英语单词中都很常见。
字节对编码(BPE)分词器,用于像 GPT-2 和 GPT-3 这样的LLMs,可以有效地通过将未知单词分解为子词单元或单个字符来处理未知单词。
一个简单的BPE实现demo
defbpe(word,merge_rules):# 规则表变成 {符号对: 行号},行号=优先级,越小=越早学会=越先粘ranks={pair:ifori,pairinenumerate(merge_rules)}symbols=list(word)# 从单个字符起步: "lowest" -> ['l','o','w','e','s','t']whilelen(symbols)>=2:# 第 1 步:在当前所有相邻对里,挑优先级号最小的一对best_pair=Nonebest_rank=float("inf")foriinrange(len(symbols)-1):pair=(symbols[i],symbols[i+1])r=ranks.get(pair)# 表里没有的对返回 NoneifrisnotNoneandr<best_rank:best_rank=r best_pair=pairifbest_pairisNone:# 没有可合并的对了,收工break# 第 2 步:从左到右扫,把这对的每处出现粘成一个新碎片new_symbols=[]i=0whilei<len(symbols):ifi<len(symbols)-1and(symbols[i],symbols[i+1])==best_pair:new_symbols.append(symbols[i]+symbols[i+1])i+=2# 粘掉两个,跳两格else:new_symbols.append(symbols[i])i+=1symbols=new_symbols# 本轮结束,回到第 1 步returnsymbols# ---- 演示 ----MERGES=[("e","s"),("l","o"),("lo","w"),("n","e"),("ne","w")]VOCAB={"l":1,"o":2,"w":3,"e":4,"s":5,"t":6,"n":7,"x":13,"es":8,"lo":9,"low":10,"ne":11,"new":12}defencode(word):return[VOCAB[p]forpinbpe(word,MERGES)]if__name__=="__main__":forwin["lowest","newest","newsx"]:print(w,"->",bpe(w,MERGES),"->",encode(w))我们也可以用tiktoken封装好的BPE分词器
importimportlibimporttiktokenprint("tiktoken version:",importlib.metadata.version("tiktoken"))# tiktoken version: 0.14.0tokenizer=tiktoken.get_encoding("gpt2")text=("Hello, do you like tea? <|endoftext|> In the sunlit terraces""of someunknownPlace.")integers=tokenizer.encode(text,allowed_special={"<|endoftext|>"})print(integers)
不用记名字,tiktoken.encoding_for_model(“gpt-4o”) 会自动映射到对应编码(实测 gpt-4o→o200k_base,gpt-3.5-turbo→cl100k_base)。
2.6数据采样滑动窗口
对于每个文本片段,我们都需要输入和目标。
由于我们希望模型预测下一个词,因此目标就是将输入向右偏移一个位置后的结果。
GPTDatasetV1 类基于 PyTorch Dataset 类,定义了如何从数据集中获取单个行,其中每行包含一个由 max_length 分配的 token ID 数组,这些 ID 被分配给输入chunk 张量。target_chunk 张量包含相应的目标。看看当我们将数据集与 PyTorch DataLoader 结合时返回的数据是什么样的——这将带来额外的直观性和清晰度。
`把一整篇文本变成"next-token 预测"的训练样本——输入是前文,标签是紧接着的下一个 token。模型将来要学的就是这件事,这里先把教材造出来。逐块拆:
Cell 30:读文件 + 全文编码
with open(“the-verdict.txt”, “r”, encoding=“utf-8”) as f:
raw_text = f.read()
with … as f 是上下文管理器:用完自动关文件,等价于 Java 的 try-with-resources,省得手写 f.close()。f.read() 把全文读成一个字符串。接着 tokenizer.encode(raw_text) 用 gpt2 BPE 把全文变成一条编号长链,len 打印 5145——整篇文档 = 5145 个 token,这就是模型的"数据集"。
Cell 31-32:错位一位,造 (输入, 标签) 对
enc_sample = enc_text[50:] # 从第 50 个 token 截取到末尾,纯为演示挑个起点
context_size = 4
x = enc_sample[:context_size] # 下标 0~3
y = enc_sample[1:context_size+1] # 下标 1~4
切片等价于循环:
x = [enc_sample[i] for i in range(0, 4)] # 即 [290, 4920, 2241, 287]
y = [enc_sample[i] for i in range(1, 5)] # 即 [4920, 2241, 287, 257]
对齐看就一目了然——y 就是 x 往前错一位:
下标: 0 1 2 3 4
样本: 290 4920 2241 287 257
x [290 4920 2241 287 ]
y [4920 2241 287 257 ]
于是逐位置配成 4 对:(and→established)、(established→himself)、(himself→in)、(in→a)。每一对就是一条训练样本:“给你前文,猜下一个词”。
Cell 33-34:把同一件事换成"上下文逐渐长大"的视角
for i in range(1, context_size+1): # i = 1,2,3,4
context = enc_sample[:i] # 前 i 个 token
desired = enc_sample[i] # 第 i+1 个,即"答案"
打印出来就是 [290] ----> 4920、[290, 4920] ----> 2241……cell 34 只是把编号 decode 回英文再打印,方便人读。这个循环讲的是概念故事:模型先看到一个词猜下一个,再看到两个词猜下下个……而上面的 (x, y) 错位对是训练时的向量化形态。两者为什么等价?靠 ch03 你见过的因果掩码——训练时一个 forward 同时预测 4 个位置,但掩码保证预测位置 i 时只能看到 ≤ i 的 token,不许偷看未来,于是并行算和"逐渐长大"串行算完全一致。
目的层面,这段代码是整本书的发动机:
一,标签免费。任何文本只要错位一位,每个位置都自动生成一条 (输入, 答案) 样本,不需要人标注。5145 个 token 立刻变出 5144 条样本。这正是你全景文档里"decoder-only 胜出"的工程根源——CLM 的数据管线零加工,互联网全文都是教材。
二,目标定义模型。"根据前文预测下一个 token"就是 GPT 系列的训练目标(因果语言建模);模型的全部"智能"都是从这个看似简单的任务里逼出来的。
三,窗口大小是玩具值。这里 context_size=4,真实 GPT-2 用 1024 的上下文窗口;紧接着书里会实现一个滑动窗口 + 打乱 + 分批的 DataLoader,把全文切成大量这样的 (x, y) 小块喂给训练循环——但真正的训练要等 ch03 注意力、ch04 模型搭好之后(ch05)才开始,所以这节末尾说"prediction 放到后面章节"。
一句话收束:这节把"一篇文章"变成了"几万道填空题",后面所有章节都是在造一个会做这些填空题的模型。
我们可以用PyTorch 的 Dataset来存储这些:用滑动窗口把整个文本切成几千条 (输入窗口, 目标窗口)的 样本
fromtorch.utils.dataimportDataset,DataLoaderclassGPTDatasetV1(Dataset):def__init__(self,txt,tokenizer,max_length,stride):self.input_ids=[]self.target_ids=[]# Tokenize the entire texttoken_ids=tokenizer.encode(txt,allowed_special={"<|endoftext|>"})assertlen(token_ids)>max_length,"Number of tokenized inputs must at least be equal to max_length+1"# Use a sliding window to chunk the book into overlapping sequences of max_lengthforiinrange(0,len(token_ids)-max_length,stride):input_chunk=token_ids[i:i+max_length]target_chunk=token_ids[i+1:i+max_length+1]self.input_ids.append(torch.tensor(input_chunk))self.target_ids.append(torch.tensor(target_chunk))def__len__(self):returnlen(self.input_ids)def__getitem__(self,idx):returnself.input_ids[idx],self.target_ids[idx]defcreate_dataloader_v1(txt,batch_size=4,max_length=256,stride=128,shuffle=True,drop_last=True,num_workers=0):# Initialize the tokenizertokenizer=tiktoken.get_encoding("gpt2")# Create datasetdataset=GPTDatasetV1(txt,tokenizer,max_length,stride)# Create dataloaderdataloader=DataLoader(dataset,batch_size=batch_size,shuffle=shuffle,drop_last=drop_last,num_workers=num_workers)returndataloader
重叠(stride < max_length)是数据稀缺时的增强旋钮——同一段话在多个上下文里反复当题;数据充足时不重叠才是高效默认(不浪费算力做重复样本)~
2.7 创建令牌嵌入 Creating token embeddings
准备工作包括对文本进行分词、将文本分词转换为分词 ID,以及将分词 ID 转换为嵌入向量。
在此,我们考虑之前创建的分词 ID 来创建分词嵌入向量连续向量表示或嵌入是必要的,因为类似于 GPT 的LLMs是使用反向传播算法训练的深度神经网络。
最后,我们需要通过一个嵌入层(embedding layer)将词元(tokens)转换为连续的向量表示。通常,这些嵌入层本身就是大语言模型的一部分,并在模型训练过程中一同更新(训练)。
6 个词、每个词用 3 维向量表示,表里先填随机数,之后训练时靠梯度下降把每个词向量"学"出来。初始随机值毫无意义,训练时 next-token 损失的梯度会不断挪动各行:常出现在相似上下文里的词,行向量被越拉越近。
input_ids=torch.tensor([2,3,5,1])vocab_size=6output_dim=3torch.manual_seed(123)embedding_layer=torch.nn.Embedding(vocab_size,output_dim)print(embedding_layer.weight)Parameter containing:tensor([[0.3374,-0.1778,-0.1690],[0.9178,1.5810,1.3010],[1.2753,-0.2010,-0.1606],[-0.4015,0.9666,-1.1481],[-1.1589,0.3255,-0.6315],[-2.8400,-0.7849,-1.4096]],requires_grad=True)print(embedding_layer(input_ids))tensor([[1.2753,-0.2010,-0.1606],[-0.4015,0.9666,-1.1481],[-2.8400,-0.7849,-1.4096],[0.9178,1.5810,1.3010]],grad_fn=<EmbeddingBackward0>)
input text:输入文本 “fox jumps over dog” 经分词变成编号列 [2, 3, 5, 1](fox=2, jumps=3, over=5, dog=1)
上方大矩阵:Embedding 层的权重表,6 行 3 列
箭头(取行):每个编号去表里取自己那一行。蓝箭头:第 1 个 token 编号 2 → 取表第 2 行 [1.2753, −0.2010, −0.1606],放到输出的第 1 行;红箭头:第 3 个 token 编号 5 → 取表第 5 行 [−2.8400, −0.7849, −1.4096],放到输出的第 3 行。3 和 1 同理(没画箭头)。
下方矩阵:输出 [4, 3]——四行依次是表的第 2、3、5、1 行。注意输出行的顺序跟输入编号顺序走,不跟表的顺序走:表里第 1 行(0.9178 那行)因为 dog 排在最后,被放到了输出最底下。
图里能读出的两个要点:
一,纯取行,零计算。前向传播时这层不做乘法,就是行选择——所以快。数学上等价于 one-hot×W(bonus notebook 03 证的),但实现上直接查行。
二,同一个词出现几次就取几次。这句里四个词不重复所以四行各异;要是输入 “dog dog”,表第 1 行就会被取两遍——重复 token 共享同一行向量,这正是"参数共享":表只有 6 行,不管句子多长。
换句话说,嵌入层本质上是一个查找操作,通过 token ID 检索嵌入层权重矩阵中的行。
:Embedding 查表 = one-hot 向量 × 权重矩阵
第一步,建 Embedding 并查表。 idx = [2, 3, 1],种子 123 建 4×5 的表,embedding(idx) 取出第 2、3、1 行(输出那三行向量)。
第二步,造 one-hot。 torch.nn.functional.one_hot(idx) 把每个编号变成"只有第 id 位是 1、其余全 0"的行向量:
编号2 → [0, 0, 1, 0] 编号3 → [0, 0, 0, 1] 编号1 → [0, 1, 0, 0]
第三步,建 Linear 并对齐权重。 nn.Linear(4, 5, bias=False) 是个纯矩阵乘层(无偏置);注意它自己的随机初始值和 Embedding 不同,所以 notebook 用 linear.weight = Parameter(embedding.weight.T) 把同一张表拷过去(转置是因为两层存权重的形状约定相反:Linear 存 [出, 入],Embedding 存 [入, 出])。
第四步,对比。 linear(onehot.float()) 的输出和 embedding(idx) 逐元素相同——跑出来的两块 tensor 数字分毫不差,唯一区别是尾巴:grad_fn=(矩阵乘路径)vs (查表路径)。前向结果一样,只是走的计算通道不同。
数学上就一行:one-hot 是标准基向量 e_i,e_i × W = W 的第 i 行——左乘基向量就是**“行选择器”**,乘法里那堆 0 什么都不贡献,只有 1 所在位置的行活下来。
真实模型词表 5 万维:one-hot × W 每个 token 要做 5 万次乘法,其中 49999 次是乘 0,纯浪费;Embedding 直接 memcpy 一行,稀疏且快。所以 Embedding 不是新数学,是 Linear 的稀疏优化特例——梯度也只流到被选中的行,两边一致。
嵌入层本质
上是一个查找操作,通过 token ID 检索嵌入层权重矩阵中的行。
如果你懂线性代数嵌入就相当好理解。
嵌入表 embedding_layer 的行数 = 词表大小,列数是我们定的“衡量这些词的维度”。
如果词表有 6 个词(id 0~5),那表是 6×3(每个词存一行 3 维)。
4×3 是查询的输出:4 个 id,每个查出一行 3 维向量。
第一步:嵌入表(6×3,每行是一个词的向量)、
embedding_layer 是 6×3,id 列表先展开成 4×6 的 one-hot 矩阵,再乘出 4×3 的结果
embedding_layer初始化时是"随便填的"(随机数),但它不会一直是随机数——它是可训练参数,训练过程中会被梯度一点点改成"有语义的样子"。
2.8 编码单词位置Encoding word positions
embedding 层不管 ID 在序列里的什么位置,都转换成相同的向量。" 就是查表的固有性质:只认编号、不认位置。“dog” 在第 0 位和第 100 位取的是同一行。后果是严重的:向量只带"是哪个词"、不带"在第几位",而后面要学的注意力机制本身又是顺序无关的(把输入行洗牌,输出跟着同样洗牌,计算过程察觉不到顺序)。不补救的话,“dog bites man” 和 “man bites dog” 在模型眼里是同一堆向量。
" 补救办法:再建第二张表——位置嵌入表,行号 = 位置号(0, 1, 2, …),行向量同样靠训练学出来。最终喂给模型的向量 = 词向量 + 位置向量,逐元素相加(“combined” 在这里就是加法,形状不变,还是 [4, 3])。一个向量同时叠加"内容"和"位置"两种信息。
位置矩阵:L=4,d=3L=4, d=3L=4,d=3单位矩阵 位置嵌入的查询矩阵是单位阵 ~embedding_weight 跟id的embedding_weight 保持维度一致。权重表的行数 = 编号的取值个数 = one-hot 向量的宽度。
那位置矩阵的权重参数和编号矩阵的权重参数 是分开计算的吗?
存储分开、梯度分开、但通过同一个损失函数耦合(上面的图是把损失加起来),每一步同时更新。