从零构建大语言模型分词器从零实现 — 从原始文本到 Token ID
本篇导读在博客 #03 中我们理解了为什么神经网络需要把文字变成数字——离散的文本必须先转为连续的向量。但向量化不是一步到位的中间还要经过一个关键的桥梁Token ID。学完本篇你将能够用 Python 正则表达式从零实现一个分词器理解词汇表vocabulary的本质和构建过程编写一个完整的SimpleTokenizerV1类包含encode和decode方法看清简单分词器的局限性——为什么它处理不了未知词1.分词分词在 LLM 数据流水线中的位置分词Tokenization 是把输入文本拆分成独立 token 的过程这是为 LLM 创建嵌入的必要预处理步骤。这些 token 要么是单独的词要么是特殊字符包括标点符号。本节涵盖的文本处理步骤在 LLM 上下文中的视图。我们把输入文本拆分为独立的 token——它们要么是词、要么是标点等特殊字符。后续小节会把文本转换为 token ID 并创建 token 嵌入。整个数据流水线是这样的原始文本 ──[分词]──▶ Token 列表 ──[查词汇表]──▶ Token ID 列表 ──[嵌入层]──▶ 向量 ↑ ↑ ↑ ↑ 本节起点 本节终点 下一节内容 第 2.7 节内容训练文本Edith Wharton 的短篇小说我们将使用 Edith Wharton 的短篇小说《The Verdict》作为分词的练习文本。这篇小说已进入公有领域可以合法用于 LLM 训练任务。原文可从 Wikisource 获取作者已将其保存为the-verdict.txt文件。原书 Listing 2.1将短篇小说读入 Pythonwithopen(the-verdict.txt,r,encodingutf-8)asf:raw_textf.read()print(Total number of character:,len(raw_text))print(raw_text[:99])输出Total number of character: 20479 I HAD always thought Jack Gisburn rather a cheap genius--though a good fello我们的目标是把这 20,479 个字符的短篇小说分词为独立的词和特殊字符作为后续章节嵌入向量的输入。关于文本规模实际 LLM 训练中通常处理数百万篇文章和数十万本书——数 GB 的文本。但出于教学目的使用一本书这样的小样本就足以阐明文本处理的核心思想并能在消费级硬件上合理时间内运行。分词的三步演进我们用 Python 的re正则表达式库逐步实现分词。注意你不需要专门记忆任何正则语法——本章末尾会切换到现成的分词器BPE。第一步按空白字符拆分importre textHello, world. This, is a test.resultre.split(r(\s),text)print(result)输出[Hello,, ,world., ,This,, ,is, ,a, ,test.]问题词和标点仍粘在一起Hello,、world.。为什么不把所有文本转为小写大小写帮助 LLM区分专有名词和普通名词、理解句子结构、生成正确大小写的文本。所以我们保留原始大小写。第二步把标点拆开 过滤空白resultre.split(r([,.]|\s),text)result[itemforiteminresultifitem.strip()]print(result)输出[Hello,,,world,.,This,,,is,a,test,.]要不要保留空白移除空白能减少内存和计算开销。但如果训练对文本结构敏感的模型例如 Python 代码对缩进敏感保留空白就有用。这里我们为简洁起见移除。第三步处理所有特殊字符包括双破折号--textHello, world. Is this-- a test?resultre.split(r([,.:;?_!()\]|--|\s),text)result[item.strip()foriteminresultifitem.strip()]print(result)输出[Hello,,,world,.,Is,this,--,a,test,?]完美——10 个独立 token。图 1分词的三步演进。从混杂着标点的原始文本逐步过渡到干净的 Token 列表。 我们目前实现的分词方案能把文本拆分为独立的词和标点字符。在此例中示例文本被拆分为 10 个独立 token。应用到全文把这套规则用在《The Verdict》全文上preprocessedre.split(r([,.?_!()\]|--|\s),raw_text)preprocessed[item.strip()foriteminpreprocessedifitem.strip()]print(len(preprocessed))输出4649—— 全文拆分出 4,649 个 token不含空白。打印前几个看看效果print(preprocessed[:10])# [I, HAD, always, thought, Jack, Gisburn, rather, a, cheap, ...]每个词和特殊字符都被整齐地分开了。2.Token → ID 转换为什么还要再转一次上一节我们得到了 token 列表Python 字符串。但神经网络处理的是数字不是字符串——所以还需要一步把每个 token 映射成一个唯一的整数 ID。这个映射关系存储在一个叫**词汇表vocabulary**的字典里。词汇表构建过程原书 Figure 2.6 说明我们通过对训练数据集的全部文本分词来构建词汇表。这些独立 token 按字母顺序排列、去除重复然后聚合到一个词汇表中——它定义了从每个唯一 token 到唯一整数值的映射。整个流程分三步图 2词汇表的三步构建过程。完整训练文本经过分词、去重排序最终每个唯一 token 获得一个整数 ID。代码实现第一步得到所有唯一 token 并按字母排序all_wordssorted(list(set(preprocessed)))vocab_sizelen(all_words)print(vocab_size)# 1159《The Verdict》的词汇表大小是1,159 个唯一 token。第二步用字典推导式构建词汇表创建词汇表vocab{token:integerforinteger,tokeninenumerate(all_words)}# 打印前 51 个条目看看fori,iteminenumerate(vocab.items()):print(item)ifi50:break输出(!, 0) (, 1) (, 2) ... (Has, 49) (He, 50)字典里每个 token 都关联着一个唯一的整数标签。下一步就是用这个词汇表把新文本转成 token ID。原书 Figure 2.7 说明从一个新的文本样本开始我们对它分词再用词汇表把文本 token 转换为 token ID。词汇表从整个训练集构建可以应用于训练集本身以及任何新的文本样本。还需要反向映射后面当我们想把 LLM 的输出数字转换回文本时还需要一个反向词汇表——把 token ID 映射回对应的文本 token。完整的 SimpleTokenizerV1 类让我们实现一个完整的分词器类包含encode方法文本 → token ID 列表decode方法token ID 列表 → 文本 实现简单文本分词器classSimpleTokenizerV1:def__init__(self,vocab):self.str_to_intvocab# 正向token → IDself.int_to_str{i:sfors,iinvocab.items()}# 反向ID → tokendefencode(self,text):# 文本 → IDpreprocessedre.split(r([,.?_!()\]|--|\s),text)preprocessed[item.strip()foriteminpreprocessedifitem.strip()]ids[self.str_to_int[s]forsinpreprocessed]returnidsdefdecode(self,ids):# ID → 文本text .join([self.int_to_str[i]foriinids])textre.sub(r\s([,.?!()\]),r\1,text)# 修复标点前的空格returntext逐行解读__init__接收一个词汇表字典同时建立正向和反向映射。encode复用第 2.2 节的分词逻辑再查正向词汇表得到 ID 列表。decode查反向词汇表得到 token用空格拼接最后用正则修复标点前的多余空格例如把Hello , world .修复为Hello, world.。图 3分词器的两个核心方法。encode 将文本转为 ID 序列decode 反过来还原文本。两者依赖同一个词汇表的正向和反向映射。分词器实现共享两个通用方法——encode 接收文本、拆分 token、通过词汇表转换为 IDdecode 接收 ID、转换回 token、再连接成自然文本。实测分词器tokenizerSimpleTokenizerV1(vocab)textIts the last he painted, you know, Mrs. Gisburn said with pardonable pride.idstokenizer.encode(text)print(ids)输出 ID 序列[1,58,2,872,1013,615,541,763,5,1155,608,5,1,69,7,39,873,1136,...]再用decode还原print(tokenizer.decode(ids))# It\ s the last he painted, you know, Mrs. Gisburn said with pardonable...完美还原——证明 encode 和 decode 是相互可逆的。致命问题未知词到目前为止一切顺利。但试试一个不在训练集中的文本textHello, do you like tea?tokenizer.encode(text)执行结果KeyError: Hello问题在于单词 “Hello” 没有在《The Verdict》中出现过所以不在词汇表里。这恰恰说明了——LLM 训练需要大规模、多样化的训练集来扩展词汇表。但即使训练集再大永远会有从未见过的新词人名、新造词、外语词等。怎么彻底解决这个问题这就是下一篇博客的主题。3.本篇小结概念要点分词Tokenization把连续文本拆分成独立的 token词 标点正则表达式分词用re.split按空格、标点、双破折号拆分词汇表Vocabulary训练数据中所有唯一 token → 整数 ID 的字典映射encode方法文本 → 分词 → 查词汇表 → 整数 ID 列表decode方法整数 ID 列表 → 反向查词汇表 → 拼接还原文本致命局限词汇表外的词OOV会导致 KeyError《The Verdict》数据20,479 字符 → 4,649 token → 1,159 词汇表大小4. 完整代码汇总importre# 1. 读入原始文本withopen(the-verdict.txt,r,encodingutf-8)asf:raw_textf.read()# 2. 分词第 2.2 节preprocessedre.split(r([,.?_!()\]|--|\s),raw_text)preprocessed[item.strip()foriteminpreprocessedifitem.strip()]print(f共{len(preprocessed)}个 token)# 4649# 3. 构建词汇表第 2.3 节all_wordssorted(list(set(preprocessed)))vocab{token:integerforinteger,tokeninenumerate(all_words)}print(f词汇表大小:{len(vocab)})# 1159# 4. SimpleTokenizerV1 类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()]return[self.str_to_int[s]forsinpreprocessed]defdecode(self,ids):text .join([self.int_to_str[i]foriinids])returnre.sub(r\s([,.?!()\]),r\1,text)# 5. 测试tokenizerSimpleTokenizerV1(vocab)sampleIt\s the last he painted, you know,idstokenizer.encode(sample)print(f编码:{ids})print(f解码:{tokenizer.decode(ids)})5.预习思考为什么decode方法需要re.sub(r\s([,.?!()\]), r\1, text)这一步如果去掉会怎样如果训练文本里有Hello但有一个新词Hi词汇表无法处理。除了扩大训练集有没有更巧妙的方法为什么 GPT-2 的词汇表大小是 50,257远大于我们这里的 1,159这背后用到了什么不同的分词策略