从零手搓大模型(一)文本如何变成 LLM 的输入

从零手搓大模型(一)文本如何变成 LLM 的输入
从零手搓大模型第一章教程文本如何变成 LLM 的输入这一章的主题是 “处理文本数据”Working with Text Data。它还没有开始训练大模型而是在回答一个非常基础但关键的问题一段普通文字怎样一步步变成 GPT 可以处理的数字张量你可以把本章看成 LLM 的“入口管道”原始文本 - 分词 tokenization - token ID - 数据采样 input/target - token embedding - positional embedding - LLM 输入向量后面的 attention、GPT、预训练全都建立在这一章之上。1. 本章你要学会什么学完这一章你应该能说清楚为什么模型不能直接读取字符串。什么是 token、token ID、vocabulary。如何写一个最简单的 tokenizer。为什么需要|unk|和|endoftext|这类特殊 token。GPT-2 的 BPE tokenizer 大概解决了什么问题。如何用滑动窗口构造语言模型训练数据。token embedding 和 positional embedding 分别是什么。最终喂给 LLM 的输入张量长什么样。这一章最重要的不是背英文概念而是盯住数据形状token IDs: (batch_size, context_length) token embeddings: (batch_size, context_length, embedding_dim) final embeddings: (batch_size, context_length, embedding_dim)2. 读取原始文本一开始会读取一篇英文短篇小说文本the-verdict.txt。如果the-verdict.txt无法下载这里提供the-verdict.txt下载地址链接https://pan.quark.cn/s/30befd77e697提取码JJ92如果文件不存在代码会从 GitHub 下载importosimportrequestsifnotos.path.exists(the-verdict.txt):url(https://raw.githubusercontent.com/rasbt/LLMs-from-scratch/main/ch02/01_main-chapter-code/the-verdict.txt)file_paththe-verdict.txtresponserequests.get(url,timeout30)response.raise_for_status()withopen(file_path,wb)asf:f.write(response.content)然后读取文本withopen(the-verdict.txt,r,encodingutf-8)asf:raw_textf.read()print(Total number of character:,len(raw_text))print(raw_text[:99])这里的raw_text还是普通字符串。LLM 不能直接处理字符串所以我们要把字符串变成数字。输出3. 第一步把文本切成 tokentoken 可以理解成“模型看到的文本基本单位”。它不一定等于一个单词也可能是标点、子词甚至某几个字符。先看最简单的空格切分importre textHello, world. This, is a test.resultre.split(r(\s),text)print(result)运行输出这里\s表示空白符。问题是逗号、句号还黏在单词上。所以 notebook 接着把逗号和句号也作为切分点resultre.split(r([,.]|\s),text)print(result)运行输出注意正则里的括号([,.]|\s)意思是遇到逗号、句号或空白符就切分并且把这些符号保留下来。切分后会有空字符串所以要过滤掉result[itemforiteminresultifitem.strip()]print(result)再扩展一点把更多标点考虑进去textHello, world. Is this-- a test?resultre.split(r([,.:;?_!()\]|--|\s),text)result[item.strip()foriteminresultifitem.strip()]print(result)这一段的核心是r([,.:;?_!()\]|--|\s)它会把逗号、句号、冒号、分号、问号、感叹号、括号、引号、双横线、空白符都当作分割点。然后把同样的规则应用到整篇文本preprocessedre.split(r([,.:;?_!()\]|--|\s),raw_text)preprocessed[item.strip()foriteminpreprocessedifitem.strip()]print(preprocessed[:30])运行输出此时raw_text: 一整个字符串 preprocessed: token 列表4. 第二步建立词表 vocabulary模型最终要处理的是数字不是 token 字符串。所以我们要给每个 token 分配一个数字 ID。先取出所有不重复的 tokenall_wordssorted(set(preprocessed))vocab_sizelen(all_words)print(vocab_size)然后建立 token 到整数的映射vocab{token:integerforinteger,tokeninenumerate(all_words)}这个vocab大概长这样{!:0,\:1,:2,...}这一步非常重要token 字符串 - token ID例如hello - 123 world - 456 . - 7LLM 的输入从这里开始变成数字。5. 写一个最简单的 tokenizernotebook 接着把“分词 查词表 还原文本”封装成一个类。classSimpleTokenizerV1:def__init__(self,vocab):self.str_to_intvocab self.int_to_str{i:sfors,iinvocab.items()}defencode(self,text):preprocessedre.split(r([,.:;?_!()\]|--|\s),text)preprocessed[item.strip()foriteminpreprocessedifitem.strip()]ids[self.str_to_int[s]forsinpreprocessed]returnidsdefdecode(self,ids):text .join([self.int_to_str[i]foriinids])textre.sub(r\s([,.?!()\]),r\1,text)returntextenumerate 每次迭代返回的一定是二元元组 (index, value)[] 这种下标取值只给列表 list、字典 dict、字符串 str、元组 tuple 用集合完全不支持。集合只能遍历re.sub() 是 Python 正则模块 替换字符串 的函数全称substitute替换功能找到文本中符合正则规则的内容替换成你指定的文字。re.sub(pattern, repl, string) 三个必填参数pattern正则匹配规则要找什么repl替换成什么string原始文本encode把文本转成 token ID。文本 - token - token IDdecode把 token ID 还原成文本。token ID - token - 文本使用方式tokenizerSimpleTokenizerV1(vocab)textIts the last he painted, you know, Mrs. Gisburn said with pardonable pride.idstokenizer.encode(text)print(ids)输出再还原tokenizer.decode(ids)这里要重点理解self.str_to_int self.int_to_str它们是一对反向映射str_to_int: token - ID int_to_str: ID - token6. V1 tokenizer 的问题遇到生词会报错如果输入文本里有一个 token 不在词表里SimpleTokenizerV1会报错。比如词表来自小说the-verdict.txt如果你输入Hello, do you like tea?输出其中某些词可能没有在原文里出现过查词表时就会失败。为了解决这个问题需要加入特殊 token|unk| unknown未知词 |endoftext| 文本结束或两段独立文本之间的分隔符notebook 里会把这两个 token 加入词表all_tokenssorted(list(set(preprocessed)))all_tokens.extend([|endoftext|,|unk|])vocab{token:integerforinteger,tokeninenumerate(all_tokens)}这样遇到词表里没有的 token就可以统一映射成|unk|。7. 改进版 tokenizerSimpleTokenizerV2新版 tokenizer 的关键改动在encodeclassSimpleTokenizerV2:def__init__(self,vocab):self.str_to_intvocab self.int_to_str{i:sfors,iinvocab.items()}defencode(self,text):preprocessedre.split(r([,.:;?_!()\]|--|\s),text)preprocessed[item.strip()foriteminpreprocessedifitem.strip()]preprocessed[itemifiteminself.str_to_intelse|unk|foriteminpreprocessed]ids[self.str_to_int[s]forsinpreprocessed]returnidsdefdecode(self,ids):text .join([self.int_to_str[i]foriinids])textre.sub(r\s([,.:;?!()\]),r\1,text)returntext最关键的是这一段preprocessed[itemifiteminself.str_to_intelse|unk|foriteminpreprocessed]意思是如果 token 在词表里就保留原 token 如果 token 不在词表里就替换成 |unk|这让 tokenizer 更健壮。8. GPT-2 使用的 BPE tokenizer前面我们自己写的 tokenizer 很粗糙。真实 GPT-2 使用的是 Byte Pair Encoding也就是 BPE。notebook 使用tiktoken加载 GPT-2 tokenizer需要预先安装pip install tiktoken -i https://pypi.tuna.tsinghua.edu.cn/simpleimporttiktoken tokenizertiktoken.get_encoding(gpt2)然后可以编码文本text(Hello, do you like tea? |endoftext| In the sunlit terraces of someunknownPlace.)integerstokenizer.encode(text,allowed_special{|endoftext|})print(integers)也可以解码stringstokenizer.decode(integers)print(strings)BPE 的好处是遇到陌生词时不一定整个变成|unk|而是可以拆成更小的子词或字符。例如someunknownPlace可能会被拆成some / unknown / Place这样模型仍然能处理它。真实 LLM 基本不会使用我们手写的简单 tokenizer而是使用 BPE、SentencePiece、Unigram 等更成熟的 tokenizer。9. 语言模型训练数据输入和目标LLM 的预训练任务通常是给前面的 token预测下一个 token。这叫 next-token prediction。先把整篇文本编码成 token IDwithopen(the-verdict.txt,r,encodingutf-8)asf:raw_textf.read()enc_texttokenizer.encode(raw_text)print(len(enc_text))然后取一小段样本enc_sampleenc_text[50:]假设上下文长度是 4context_size4xenc_sample[:context_size]yenc_sample[1:context_size1]print(fx:{x})print(fy:{y})这里的x和y是错开一位的x: [t0, t1, t2, t3] y: [t1, t2, t3, t4]也就是说输入 t0目标是预测 t1 输入 t0 t1目标是预测 t2 输入 t0 t1 t2目标是预测 t3 输入 t0 t1 t2 t3目标是预测 t4notebook 用循环展示了这个过程foriinrange(1,context_size1):contextenc_sample[:i]desiredenc_sample[i]print(context,----,desired)你可以把这看成 GPT 的核心训练方式看到前文猜下一个 token。10. 用滑动窗口切训练样本如果整篇文章很长不能一次性全部喂给模型。我们需要切成很多长度固定的小片段。这就是 sliding window滑动窗口。假设max_length 4 stride 1那么切分方式类似input: [t0, t1, t2, t3] target: [t1, t2, t3, t4] input: [t1, t2, t3, t4] target: [t2, t3, t4, t5] input: [t2, t3, t4, t5] target: [t3, t4, t5, t6]窗口每次向后移动stride个 token。11. 自定义 PyTorch Datasetnotebook 里定义了一个GPTDatasetV1fromtorch.utils.dataimportDataset,DataLoaderclassGPTDatasetV1(Dataset):def__init__(self,txt,tokenizer,max_length,stride):self.input_ids[]self.target_ids[]token_idstokenizer.encode(txt,allowed_special{|endoftext|})assertlen(token_ids)max_length,Number of tokenized inputs must at least be equal to max_length1foriinrange(0,len(token_ids)-max_length,stride):input_chunktoken_ids[i:imax_length]target_chunktoken_ids[i1:imax_length1]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]这个类的作用是把一整篇文本切成很多(input, target)样本。最关键的是input_chunktoken_ids[i:imax_length]target_chunktoken_ids[i1:imax_length1]它们永远错开一位。这就是语言模型的训练数据格式。12. 创建 DataLoader再封装一个函数defcreate_dataloader_v1(txt,batch_size4,max_length256,stride128,shuffleTrue,drop_lastTrue,num_workers0):tokenizertiktoken.get_encoding(gpt2)datasetGPTDatasetV1(txt,tokenizer,max_length,stride)dataloaderDataLoader(dataset,batch_sizebatch_size,shuffleshuffle,drop_lastdrop_last,num_workersnum_workers)returndataloader参数解释batch_size一次取多少个样本。max_length每个样本的 token 长度也叫上下文长度。stride滑动窗口每次移动多少 token。shuffle是否打乱样本。drop_last最后一个 batch 不够完整时是否丢弃。num_workers读取数据的进程数Windows 初学阶段用 0 就好。测试一下dataloadercreate_dataloader_v1(raw_text,batch_size8,max_length4,stride4,shuffleFalse)data_iteriter(dataloader)inputs,targetsnext(data_iter)print(Inputs:\n,inputs)print(\nTargets:\n,targets)此时inputs.shape (8, 4) targets.shape (8, 4)含义是8 个训练样本每个样本 4 个 token。13. token ID 还不是模型真正吃的东西到目前为止我们得到的是 token IDinputs [ [token_id, token_id, token_id, token_id], [token_id, token_id, token_id, token_id], ... ]但神经网络真正处理的是连续向量不是离散整数。所以还需要 embedding layer。14. 什么是 token embeddingembedding layer 可以理解成一个“查表操作”token ID - 向量先用一个小例子importtorch input_idstorch.tensor([2,3,5,1])假设词表大小是 6每个 token 映射成 3 维向量vocab_size6output_dim3torch.manual_seed(123)embedding_layertorch.nn.Embedding(vocab_size,output_dim)这个 embedding layer 内部其实有一个矩阵shape (vocab_size, output_dim) (6, 3)每一行就是一个 token ID 对应的向量。取出某个 token 的向量print(embedding_layer(torch.tensor([3])))这其实就是取 embedding 矩阵的第 4 行因为 ID 从 0 开始。一次取多个 tokenprint(embedding_layer(input_ids))输出形状是(4, 3)含义是4 个 token每个 token 一个 3 维向量。15. 给真实 GPT-2 词表创建 embeddingGPT-2 tokenizer 的词表大小是vocab_size50257notebook 里设定 embedding 维度为 256vocab_size50257output_dim256token_embedding_layertorch.nn.Embedding(vocab_size,output_dim)这代表 embedding 矩阵的形状是(50257, 256)也就是50257 个 token每个 token 对应一个 256 维向量。从 DataLoader 取一个 batchmax_length4dataloadercreate_dataloader_v1(raw_text,batch_size8,max_lengthmax_length,stridemax_length,shuffleFalse)data_iteriter(dataloader)inputs,targetsnext(data_iter)看一下输入形状print(Token IDs:\n,inputs)print(\nInputs shape:\n,inputs.shape)输出然后把 token ID 转成 embeddingtoken_embeddingstoken_embedding_layer(inputs)print(token_embeddings.shape)得到(8, 4, 256)含义是8 个样本 每个样本 4 个 token 每个 token 是 256 维向量这就是 LLM 输入最核心的形状。16. 为什么还需要位置编码 positional embeddingtoken embedding 有一个问题它只知道 token 是什么不知道 token 在哪里。比如下面两句话The cat chased the dog. The dog chased the cat.token 都差不多但顺序不同意思完全不同。所以模型还需要位置信息。GPT-2 使用的是 absolute positional embedding也就是给每个位置一个可学习的向量。notebook 里这样创建context_lengthmax_length pos_embedding_layertorch.nn.Embedding(context_length,output_dim)这里context_length 4 output_dim 256所以位置 embedding 矩阵形状是(4, 256)取出位置向量pos_embeddingspos_embedding_layer(torch.arange(max_length))print(pos_embeddings.shape)输出(4, 256)含义是位置 0 - 一个 256 维向量 位置 1 - 一个 256 维向量 位置 2 - 一个 256 维向量 位置 3 - 一个 256 维向量17. 最终输入token embedding position embedding最终喂给 LLM 的输入是input_embeddingstoken_embeddingspos_embeddingsprint(input_embeddings.shape)输出仍然是(8, 4, 256)为什么可以相加因为token_embeddings.shape (8, 4, 256) pos_embeddings.shape (4, 256)PyTorch 会自动广播把位置向量加到 batch 里的每一个样本上。可以理解成最终 token 表示 token 本身的含义 token 所在的位置这一步之后数据就可以送进 Transformer / GPT 模型了。18. 本章完整流程回顾把整章串起来1. 读取原始文本 raw_text 2. 用正则切分文本 raw_text - tokens 3. 建立词表 tokens - vocab 4. 写 tokenizer encode: text - token IDs decode: token IDs - text 5. 使用 GPT-2 BPE tokenizer 更好地处理未知词和子词 6. 构造训练样本 input: [t0, t1, t2, t3] target: [t1, t2, t3, t4] 7. 用 Dataset 和 DataLoader 批量读取 inputs.shape (batch_size, context_length) 8. token embedding token IDs - token vectors 9. positional embedding position IDs - position vectors 10. 相加得到 LLM 输入 input_embeddings.shape (batch_size, context_length, embedding_dim)19. 这一章最容易混淆的点token 和 token ID 不是一回事token: hello token ID: 15339token 是字符串token ID 是数字。token ID 和 embedding 也不是一回事token ID: 15339 embedding: [0.12, -0.08, 0.33, ...]token ID 只是索引embedding 才是神经网络真正处理的向量。input 和 target 只差一位语言模型不是拿一整句话预测分类标签而是不断预测下一个 tokeninput: [t0, t1, t2] target: [t1, t2, t3]position embedding 是为了让模型知道顺序如果没有位置信息模型只知道出现了哪些 token不知道它们的先后关系。20. 和后续章节的关系这一章做完后我们已经得到了 GPT 的输入input_embeddings第 2 章会学习 attention让 token 之间互相“看见”对方。第 3 章会把 embedding、attention、feed forward、layer norm、residual connection 组装成 GPT。第 4 章会真正训练 GPT让它学会预测下一个 token。所以第这章是整个手搓大模型流程的地基。如果这里不懂后面会觉得 GPT 很玄如果这里懂了后面就会清楚很多大模型不是魔法它就是把文本变成数字再用神经网络学习下一个 token 的概率。