学习Transformers编码优先:第一部分 – 设置

学习 Transformers 编码优先设置

使用nanoGPT作为起点的Transformer的4部分探索

Josh Riemer在Unsplash上的照片

我不知道你怎么样,但有时候看代码比读论文更容易。当我在开发AdventureGPT时,我一开始是通过阅读BabyAGI的源代码来工作的,BabyAGI是ReAct论文的一个实现,用大约600行的Python代码编写。

最近,我了解到了一篇最新的论文,叫做TinyStories,是通过Cognitive Revolution Podcast的第33集介绍的。TinyStories试图表明,训练在数百万(而不是数十亿)参数上的模型在有足够高质量数据的情况下是有效的。在论文中,微软的研究人员利用了从GPT-3.5和GPT-4生成的合成数据,这些数据的生成成本约为10,000美元。数据集和模型可从作者的HuggingFace Repo中获取。

我被这样一个模型可以在30M或更少的参数上进行训练的想法所吸引。作为参考,我正在一台拥有GTX 1660 Ti的Lenovo Legion 5笔记本上运行所有的模型训练和推理。即使只是进行推理,大多数具有超过30亿参数的模型也太大了,无法在我的机器上运行。我知道有云计算资源可供使用,但我只能在业余时间学习所有这些,并且只能负担得起由API调用产生的适度的OpenAI账单。因此,有一些我可以在我的简陋硬件上训练的模型的想法立刻让我兴奋起来。

我开始阅读TinyStories论文,很快意识到他们在模型训练中使用了现在已经停用的GPT Neo模型。我开始深入研究代码,看看是否能够理解它,我意识到我需要一个更小的起点。值得一提的是,我主要是一个后端软件工程师,对机器学习的经验只够不至于在听别人谈论神经网络时完全迷失。我离一个合格的机器学习工程师还有很长的路要走,这导致我在首选搜索引擎中输入“从零开始的gpt”来寻找一个更友好的入门。我找到了下面的视频,一切都改变了。

这正是我在寻找的。除了视频中提供的基本Repo外,还有一个名为nanoGPT的精心制作版本,仍然在积极开发中。更重要的是,训练代码和模型代码各约300行Python代码。对我来说,这比视频更令人兴奋。我关闭了视频,开始仔细研究源代码。nanoGPT使用了我从未使用过的PyTorch。它还包含了足够多的数学和机器学习术语,让我这个新手感到有些焦虑。这将是一个比我预期的更大的工作。

了解某个东西最好的方法之一就是写下来。因此,我计划逐步分解nanoGPT Repo中的代码、阅读著名的《Attention is All You Need》论文,并以自下而上、实践的方式学习transformers。无论沿途我学到了什么,我都希望在这个系列中写出来。如果你想跟随我的步伐,将nanoGPT Repo克隆到你的机器上(该模型甚至可以在CPU上进行训练,所以没有硬件的借口),并跟随我的步伐。

在克隆了Repo之后,我做的第一件事是按照README中的说明训练最简单的模型,即使用tiny_shakespeare数据集的字符级生成模型。有一个脚本来准备训练数据集,一个脚本来进行实际训练,以及一个采样脚本来输出生成的文本。通过几个终端命令和一个小时以上的训练,我得到了一个可以输出莎士比亚风格文本的简单模型。

按照说明进行操作都很好,但要真正理解某个东西,直到我修改它以适应我的用例,我才真正理解它。我的目标是使用TinyStories数据集来训练一个类似的字符级模型。这需要创建自己的数据准备脚本,以准备好用于训练的数据集。让我们深入研究一下。

nanoGPT有两种类型的数据准备脚本:一种是用于GPT-2风格模型的,一种是用于字符级模型的。我从GPT-2模型的代码中获取了一些用于从HuggingFace存储库下载的代码,然后从tiny_shakespeare字符级脚本中获取了其他所有内容。这里有一个重要的问题,tiny_shakespeare只有1MB多一点,只包含40k行莎士比亚的文本。TinyStories压缩后超过3GB,包含3970万个故事。用于标记和切分tiny_shakespeare的方法不直接适用,至少不适用于我笔记本上的32GB RAM。我尝试了一些Pythonic的、易于阅读的方法来准备TinyStories,结果多次导致我的机器崩溃。最终的脚本使用了一些技巧,我将在下面详细介绍。

首先,我在处理数据列表时首选的解决方案是列表推导,这是一种从现有列表生成新列表并进行修改的语法。在这种情况下,列表推导的问题是,3GB的压缩文本在RAM中变得接近10GB。现在,列表推导需要在RAM中多次复制列表。对于小数据来说不是问题,但对于TinyStories来说不可行。

数据准备脚本的输出是训练和验证数据的压缩NumPy数组,以及一个包含完整的唯一字符列表以及将这些字符转换为数字的编码/解码映射的元数据pickle。使用这个作为参考,一旦找到并映射了唯一的字符到数字,我们就不需要其他任何东西,只需要最终的编码数字数组。以内存高效的方式完成这个任务的最佳方法是使用简单的for循环遍历数据,并在构建输出片段时进行初始化变量的更新。这样可以防止在RAM中保存多个数据集版本,并且只输出我们所需要的内容。最终的词汇生成代码如下:

chars_dataset = set([])len_dataset = 0# 获取这个文本中出现的所有唯一字符以及训练数据的总长度desc = "在训练集中枚举字符"for story in tqdm(dataset['train']['text'], desc):    chars = list(set(story))    for char in chars:        chars_dataset.add(char)        len_dataset += len(story)

话虽如此,将编码为数字的3070万个故事(超过40亿个字符)的数组仍占用相当大的内存空间,因为Python会动态地存储这些整数。这时就需要使用NumPy,它具有更高效的数组存储方式,可以指定整数的确切大小。除了高效的存储方式,NumPy还具有内存高效的数组连接功能,可以用于逐步构建最终的编码数组,而不是一次性全部构建。

我在脚本中加上了使用tqdm为每个步骤添加进度条的最后一步,然后我准备好运行脚本了。所以,我在晚上运行了脚本,然后早上回来。当我回来时,脚本仍在运行,估计还需要100多个小时的计算时间。

这时我真正意识到:3070万个故事对于一个语言模型来说很小,但绝对不是一个可以在单个线程上处理的玩具数据集。是时候调动大炮了:并行化。并行化带来了许多复杂性和开销,但性能的提升是值得的。幸运的是,有许多方法可以并行化Python代码。其中许多解决方案需要对串行执行的脚本进行重写或使用复杂的抽象。经过一番搜索,我找到了一个可以使我保持大部分脚本不变但仍然运行多个进程以利用所有线程的解决方案。

Ray是一个用于在Python中轻松并行化方法的库,可以在本地或集群中轻松运行。它负责在队列中运行任务并启动工作进程来处理队列。下面是一个关于ray的详细指南,如果你对此感兴趣。

现代并行和分布式Python:Ray快速教程

Ray是一个用于并行和分布式Python的开源项目。

towardsdatascience.com

在选择要并行化的内容时,编码函数似乎是一个很好的候选。它具有清晰的输入和输出,对这些输入没有副作用,并且很容易成为计算时间最长的部分之一。将现有代码适应为可以使用ray的代码非常简单:通过装饰器使函数对ray可访问,函数调用略有变化以添加一个remote属性,还有一个用于执行所有数据的函数。以下是最初在我的代码库中的一个示例:

import rayray.init()…# 给定数据集中的所有唯一字符,# 创建一个字符到整数的唯一映射stoi = { ch:i for i,ch in enumerate(chars_dataset) }@ray.remotedef encode(s):    return [stoi[c] for c in s]…encoded_stories = []for story in dataset[‘train’][‘text’]:    encoded_stories.append(encode.remote(story))ray.get(encoded_stories)…

凭借我所有CPU的计算能力,我继续前进,结果立即让我的笔记本电脑崩溃了。使用ray的本地分布式调用堆栈时,整个数据集在内存中多次复制。仅仅将整个数据集加入队列就会导致内存溢出错误。我有点恼火,以此为借口购买了更多的RAM(来了64GB!),但在内存运到之前继续调整代码。

下一个逻辑步骤是将Ray处理的请求批量处理,使其能够适应合理的内存量。添加批处理逻辑相对简单,并且在文章末尾的最终代码库中已经包含。实际上,有趣的是尝试不同的批处理大小。最初,我选择了一个随机的批处理大小(5000),一开始效果不错,但很明显,在每个批处理中,单线程代码花费了相当多的时间。

基本上,通过观察我喜欢的系统监视器,我看到一个核心被占用了几分钟,最后我的笔记本的所有核心都亮了几秒钟,然后又回到了只有一个核心在运行的状态。这让我有些想法,希望能更快地为饥饿的CPU核心提供数据,并让它们保持更长时间的活跃。降低批处理大小并没有帮助,因为每个批次中都有大量的同步代码用于从完整数据集中切片和准备批次。这段代码无法并行化,所以每个批次都有很大的启动成本,生成数据块所需的时间较长。这导致我尝试了相反的方法,增加数据块的大小,以保持核心更长时间的活跃。这种方法有效,因为无论数据块的大小如何,生成数据块所需的时间都是相同的,但是每个数据块处理的数据更多。结合将编码后处理移动到Ray函数中,我能够在几个小时内处理掉30%的训练数据集,而这全部是在一台笔记本电脑上完成的。

最后,几个小时后,我准备好了一个完整的自定义数据集,用于馈送字符级模型。我很高兴没有不得不使用昂贵的云计算来处理训练集,如果增加内存没有起作用,那就是我的下一个计划。更重要的是,我深入了解了为字符级模型创建/处理数据集的含义。

在本系列的下一篇文章中,我将审查实际的模型代码,尽力解释,并链接到大量的外部资源,以提供我知识不足的附加信息。一旦文章写好,我会回来在这里提供链接。同时,我已经在下面链接了我准备的数据集脚本的最终版本,这样你就可以跟着进行,看看在有限的计算平台上处理一个相对大型的数据集需要什么。

nanoGPT/data/tinystories_char/prepare.py at master · oaguy1/nanoGPT

The simplest, fastest repository for training/finetuning VoAGI-sized GPTs. – nanoGPT/data/tinystories_char/prepare.py…

github.com