翻译:Writing_an_LLM_from_scratch_part_2
版权声明:
除非注明,本博文章均为原创,转载请以链接形式标明本文地址。
从零开始编写大型语言模型(LLM),第二部分
发布日期:2024年12月23日
分类:AI, Python, 从零开始编写LLM, TIL深度探索
我正在阅读Sebastian Raschka的书籍《从零开始构建大型语言模型》,并计划每天发布阅读笔记(至少在我阅读的日子里——圣诞节那天我可能不会发布),分享我觉得有趣的内容。
我原本计划每天阅读一章,但对于这样一本内容密集的书来说,这个目标似乎过于乐观了!所以今天,我阅读了第二章“处理文本数据”的前半部分。这一章概述了文本在进入LLM之前的预处理过程,接着描述了一个简单的分词系统(包括源代码),然后简要介绍了我们将在LLM中实际使用的字节对编码方法。
概述
核心思想是,LLM实际上无法直接处理文本——甚至无法直接处理单词。我认为,将文本输入LLM的第一步是进行分词(在我进行微调的冒险中,这一点非常常见),但事实证明,这只是第一步。
处理过程如下:
- 获取原始文本,这是一系列字符(我内心的纯粹主义者想在这里说“字节”,或者添加一个初步步骤,即“确定文本使用的编码并将其转换为跨文档一致的内容”)。
- 对其进行分词,得到一系列分词ID——每个单词或子词对应一个整数。
- 为每个分词生成一个嵌入。
- 然后,这些嵌入实际上被发送到LLM。
词嵌入简介
这里提到的嵌入是每个分词一个嵌入,而不是第一章中介绍的编码器-解码器Transformer中使用的整个句子或文档的大嵌入。但它们仍然是高维向量,以某种抽象的方式表示相关分词的含义。
Rashka举了Word2vec嵌入的例子,这在当时是相当轰动的,因为你可以对生成的向量进行算术运算并得到合理的结果。例如,使用w2v作为一个假想的函数,它接受一个单词并返回嵌入向量,并将+和-视为向量的逐元素运算符,我们可以看到如下内容:
1 | w2v("king") - w2v("man") + w2v("woman") ~= w2v("queen") |
或者:
1 | w2v("Paris") - w2v("France") + w2v("Germany") ~= w2v("Berlin") |
但更成问题的是:
1 | w2v("doctor") - w2v("man") + w2v("woman") ~= w2v("nurse") |
这清楚地表明,保持训练材料的无偏性将非常重要。
无论如何,他指出,LLM使用的嵌入通常不是像Word2vec这样预先创建的,而是由与LLM本身一起训练的嵌入引擎生成的。让我感到惊讶的是,这些嵌入的维度数量——他说GPT-2有768个维度,而GPT-3则有惊人的12,288个维度!
他还简要提到了音频和视频的嵌入模型的存在,虽然他没有明确提到,但这让我想到了我们现在拥有的多模态LLM可能如何工作。毕竟,如果LLM只接受嵌入作为输入,那么只要它们在某种意义上是兼容的,就没有理由不让它像处理词嵌入一样处理音频嵌入或图像嵌入。不过,这只是我个人的推测。
在阅读这部分时,我还想到,如果LLM专门使用嵌入进行计算,那么它的直接输出可能是下一个分词的嵌入,而不是分词本身。因此,我们需要以某种方式从嵌入映射到分词,这将非常困难(在数千维的空间中找到与随机嵌入最接近的分词是一项昂贵的操作),所以也许这种对称性“嵌入 -> LLM -> 嵌入”只是我的一个误解,LLM实际上只是生成下一个分词(或者更准确地说,是一组带有相关概率的分词)。我们拭目以待!
分词:自己编写
这部分对我来说没有太多新内容——分词的概念,即将单词(或部分单词)转换为数字,我认为任何阅读本文的人都会熟悉,而Raschka所实现的代码非常简单明了。
在第一个版本中,我们将文本拆分为单词和标点符号,例如:
1 | "Hello, how are you?" -> ["Hello", ",", "how", "are", "you", "?"] |
(他指出,如果需要,可以保留空格,例如Python代码。)
完成此操作后,为每个唯一的单词/标点符号分配一个唯一的整数,并构建一个从单词到ID的映射。
有了这个映射,你可以构建一个简单的类,其中包含一个encode
方法,将文本映射到分词ID列表,以及一个decode
方法,将此类列表转换回文本。
然而,如果它遇到一个从未见过的单词,它将会中断。因此,在第二个版本中,我们增强了它以处理未知单词。他通过在生成单词到ID映射之前将字符串<|unk|>
添加到词汇表中来实现这一点,这样它就会获得一个ID,然后修改编码器,以便在遇到不认识的单词时输出该分词。他还添加了一个<|endoftext|>
分词,以便在同一输入流中分隔不同的文档。
这是一个很好的解决方案,效果很好,但我从之前的阅读中得到的印象是,这并不是当前的最佳实践(例如,截至GPT-4)。我想这里使用的系统是因为Raschka在这段代码中试图实现的只是一个分词工作原理的示例——也就是说,这段代码是一个用于教学目的的简单实现。
但无论如何,我会解释我认为它的问题所在,主要是为了确保我真正理解它 :-)
如果<|endoftext|>
出现在一个简单的从单词到分词ID的编码器的词汇表中,那么有人可以将该字符串放入LLM应用程序的提示中,并获得该分词。我相当确定我记得一些早期的提示注入/越狱技巧,这些技巧似乎就是沿着这些思路工作的。
有一些方法可以避免这种情况(参见下一节中的好例子),但它给人一种感觉,即那些足够擅长越狱的人可能能够绕过它。当我看到它时,我脑海中负责在网站代码中触发SQL注入警报的部分确实被触发了。
根据我的了解(我可能完全错了),最新的分词器没有任何特定字符串映射到特殊分词——也就是说,它们只是分词ID。在解码方法中可能会有一些输出它们的方式,但在编码方法中无法通过使用特殊格式的文本来生成它们。因此,例如,生成保留给未知单词的分词ID的唯一方法是提供一个未知单词。
无论如何,所有这些都无关紧要,因为我们将用于LLM本身的分词器不是上面的那个,而是更复杂的东西。
分词:字节对编码
使用具有固定词汇表的分词器(如上所述)的问题是:
- 你最终不得不扫描整个训练集以找到所有唯一的单词(如果你在训练整个互联网的抓取数据,你会发现各种各样的东西,比如
lksfdklkajfdfklj
)。你会得到一个包含大量几乎从未使用过的单词的庞大词汇表,然而,在训练之后,你仍然会遇到包含训练中未见过的单词的输入问题。 - 或者你只是接受在训练期间也会出现未知单词,这限制了你的LLM可以学习的内容。
我们可以通过更复杂的分词器来避免这个问题。Rashka提供了一些使用OpenAI的tiktoken
库的示例代码,该库使用字节对编码进行分词。字节对编码是一种系统,分词器通过训练过程学习自己的分词集——即:
- 它最初拥有所有字母、数字和标点符号的分词。
- 然后它被展示大量数据,发现现有分词的常见组合,并为它们创建新的分词。
- 未知单词可以由它拥有的分词表示——在最坏的情况下,它只需要逐个字母拼写出来。
他展示的示例代码给出了一个例子:
1 | In [64]: import tiktoken |
所以你可以看到"someunknownPlace"被分解为"some"、"unknown"和"Place"的分词。
这也突出了我之前从其他地方了解到的一点——现代分词器通常将前导空格作为分词本身的一部分,因此——例如——" do"和"do"有不同的分词——它们有不同的ID:
1 | In [70]: print(tokenizer.encode("do do")) |
解码后看起来也不同:
1 | In [71]: [tokenizer.decode([ii]) for ii in tokenizer.encode("do do")] |
另一个让我感兴趣的是encode调用中的allowed_special参数,这是一种预防措施,以防止我之前提到的越狱问题。有了它,它将愉快地将文本解析为特殊分词:
1 | In [72]: [tokenizer.decode([ii]) for ii in tokenizer.encode("<|endoftext|>", allowed_special={"<|endoftext|>"})] |
没有它,分词器会识别并拒绝它:
1 | In [73]: [tokenizer.decode([ii]) for ii in tokenizer.encode("<|endoftext|>")] |
或者,根据那个(非常详细的)错误消息,你可以让它被解析为没有任何特殊含义的字符序列:
1 | In [77]: [tokenizer.decode([ii]) for ii in tokenizer.encode("<|endoftext|>", disallowed_special=(tokenizer.special_tokens_set - {'<|endoftext|>'}))] |
最后一个感觉有点冒险!在一个更大的系统中(比如某种LLM相互通信的框架),很容易想象这些分词流被天真的代码重新组装,然后像受信任的一样进行分词。
总结
无论如何,我觉得今天就到这里吧。有趣的是,我觉得我花在整理这些笔记上的时间大约是阅读时间的两倍,但这可能是一个很好的平衡。我确信,通过这次写作,我会更好地记住我读到的内容!
接下来的部分,我预计明天会阅读(尽管是平安夜,所以我可能无法完成),是关于数据采样的,然后是创建嵌入。我原本以为后者会非常复杂,但在快速浏览后,我发现我们在现阶段所做的只是生成随机嵌入,这是有道理的——实际值将在我们开始训练时学习。
小问题和奇怪之处
今天只有一个问题——真的是我在吹毛求疵,但在第28页,命令print(tokenizer.decode(ids))
的输出被渲染为:
1 | '" It\' s the last to be painted, you know," Mrs. Gisburn said with pardonable pride.' |
它应该是:
1 | " It' s the last to be painted, you know," Mrs. Gisburn said with pardonable pride. |
——我猜它是从CLI会话中复制粘贴的,其中命令只是tokenizer.decode(ids)
,所以Python提供了一个repr
。
基本上是一个错别字,我觉得甚至提到它都有点不好意思 ;-)