OpenCL的Python调用

曾伟 2024-07-17

简介:工作需要使用OpenCL,有必要对使用Python调用OpenCL进行一个学习。网上已经有不少资料,我只需要做一些笔记记录即可。

入门

问了下Kimi,用Python调用OpenCL主要使用PyOpenCL库。这个库提供了对OpenCL的完整访问权限,包括自动错误检查、对象清理和生命周期管理,以及速度优势,它的底层是用C++写的。

直接阅读官方文档即可。无意中还发现了一所大学的Introduction to Supercomputing (MCS 572)课程网页以及《Python并行编程 中文版》,先收藏着。

笔记

1.OpenCL通用流程

询问kimi,一般OpenCL通用流程为:

  1. 选择平台(Platform Selection): 使用clGetPlatformIDs函数获取系统中可用的OpenCL平台列表。平台通常与特定的硬件厂商相关联,例如NVIDIA、AMD或Intel。

  2. 获取平台信息(Platform Information Query): 使用clGetPlatformInfo函数查询有关平台的详细信息,如平台名称、版本、扩展等。

  3. 创建上下文(Context Creation): 使用clCreateContext或clCreateContextFromType函数创建一个OpenCL上下文。上下文是程序与OpenCL设备交互的接口,包含了一个或多个设备。

  4. 创建命令队列(Command Queue Creation): 对于每个要使用的设备,使用clCreateCommandQueue函数创建一个命令队列。命令队列用于管理设备上的命令执行,如内核执行和数据传输。

  5. 编写内核代码(Kernel Code Writing): 编写OpenCL内核代码,这是一种用OpenCL C编写的并行函数,将在设备上执行。

  6. 创建程序对象(Program Object Creation): 使用clCreateProgramWithSource函数创建一个OpenCL程序对象,并将内核代码作为源代码传递给它。

  7. 编译程序(Program Compilation): 使用clBuildProgram函数编译程序对象。编译过程可能需要指定编译选项和包含路径。

  8. 创建内核对象(Kernel Object Creation): 使用clCreateKernel函数从编译后的程序中创建一个或多个内核对象。

  9. 设置内核参数(Kernel Argument Setup): 使用clSetKernelArg函数为每个内核设置参数,这些参数可以是常量值、内存对象(如缓冲区或图像)或指向内存对象的指针。

  10. 创建内存对象(Memory Object Creation): 使用clCreateBuffer或clCreateImage函数在GPU上创建内存对象,用于存储数据。

  11. 数据传输(Data Transfer): 使用clEnqueueReadBuffer、clEnqueueWriteBuffer或clEnqueueCopyBuffer等函数在主机和设备之间传输数据。

  12. 执行内核(Kernel Execution): 使用clEnqueueNDRangeKernel函数将内核排队到命令队列中执行。需要指定全局工作尺寸和局部工作尺寸。

  13. 同步操作(Synchronization): 使用clFinish或clWaitForEvents函数确保命令队列中的所有命令完成。

  14. 读取结果(Reading Results): 如果需要,使用clEnqueueReadBuffer函数将结果从设备内存读取回主机内存。

  15. 错误检查(Error Checking): 在每个OpenCL API调用后,检查并处理可能发生的错误。

  16. 资源清理(Resource Cleanup): 使用clReleaseKernel、clReleaseProgram、clReleaseCommandQueue、clReleaseContext等函数释放所有分配的OpenCL资源。

  17. 调试和性能分析(Debugging and Profiling): 使用OpenCL的调试工具和性能分析工具来优化内核性能和调试程序。

PyOpenCL的一般流程:

  1. 导入 PyOpenCL 库: import pyopencl as cl。 首先,需要导入 PyOpenCL 模块以便使用其提供的功能。

  2. 创建 OpenCL 上下文(Context): ctx = cl.create_some_context()。 使用 create_some_context 函数自动选择一个可用的平台和设备,并创建一个 OpenCL 上下文。

  3. 创建命令队列(Command Queue): queue = cl.CommandQueue(ctx)。 基于上下文创建一个命令队列,用于在选定的设备上排队执行 OpenCL 命令。

  4. 编写 OpenCL 内核代码(Kernel Code): 内核代码是用 OpenCL C 编写的,需要定义在一个字符串中,然后传递给 PyOpenCL。

  5. 创建 OpenCL 程序(Program): prg = cl.Program(ctx, kernel_code).build()。 使用上下文和内核代码创建一个程序对象,并通过 build 方法编译内核代码。

  6. 创建缓冲区或图像内存(Buffers/Memories): a_g = cl.Buffer(ctx, cl.mem_flags.READ_ONLY | cl.mem_flags.COPY_HOST_PTR, hostbuf=a_np)。 根据需要创建缓冲区或图像,并将其与 Python 的 NumPy 数组关联起来。

  7. 分配设备内存并准备数据传输: 使用 Buffer 或 Image 创建 GPU 内存,并使用 COPY_HOST_PTR 标志将主机内存的数据复制到设备。

  8. 定义内核参数并执行内核(Execute Kernel):
     res_g = cl.Buffer(ctx, cl.mem_flags.WRITE_ONLY, a_np.nbytes) 
     knl = prg.sum
     knl(queue, a_np.shape, None, *args)
    

    定义内核的参数,并将内核排队到命令队列执行。*args 是传递给内核的参数列表。

  9. 同步设备和主机: cl.enqueue_copy(queue, res_np, res_g)。 使用同步命令,如 enqueue_copy,将计算结果从设备内存复制回主机内存。

  10. 读取和验证结果: 在主机端使用 NumPy 等工具对结果进行验证,确保 GPU 计算的正确性。

  11. 资源清理(Resource Cleanup): 显式或隐式地释放所有分配的资源,包括缓冲区、程序、内核等。

  12. 错误检查(Error Checking): 在每个 PyOpenCL API 调用后,检查返回的错误代码,确保每个操作都成功执行。

  13. 性能分析(Performance Profiling): 使用 PyOpenCL 的事件和性能计数器来分析内核执行的性能。

  14. 使用 PyOpenCL 扩展功能: 利用 PyOpenCL 提供的便利函数和类,例如 get_context, release, 或使用 LocalMemory 辅助类来简化代码。

2.使用env创建虚拟环境以安装PyOpenCL

sudo apt install python3.8-venv

python3 -m venv usePyCL

source usePyCL/bin/activate

使用env创建虚拟环境会在本地生成虚拟环境文件夹,这点跟conda不同。

Read More

《从头开始构建大语言模型》02

曾伟 2024-07-14

简介:紧接上一章,这是《从头开始构建大语言模型》的第2章。紧急消息:原来在MANNING上的Build a Large Language Model (From Scratch)上分章阅读当我读到2.1的时候就不给我读了,提示我需要收费,好吧,非常sad。看来不得不掏出我自己找到的老版PDF了。另外,我还发现SCRIBD也只有前30天免费,果然这是一个处处要钱的世界。

2 使用文本数据

本章涵盖:

  • 为大型语言模型训练准备文本
  • 将文本拆分为单词和子单词tokens
  • tokenizing文本更高级的字节对编码
  • 使用滑动窗口方法的抽样训练样本
  • 将tokens转换为输送到大型语言模型的向量

在上一章中,我们介绍了大型语言模型(LLMs)的通用结构,并了解到它们是在大量文本数据上进行预先训练的。具体来说,我们的重点是在基于Transformer架构的LLM中的解码器,该架构是 ChatGPT 和其他流行的类似 LLMsGPT 的模型的基础。

在预训练阶段,LLMs一次处理一个单词文本。使用下一个单词预测任务对数百万到数十亿个参数进行LLMs训练,可以产生具有令人印象深刻功能的模型。然后,可以进一步微调这些模型,以遵执行通用命令或特定的目标任务。但是,在接下来的章节中实现和训练LLMs之前,我们需要准备训练数据集,这是本章的重点,如图 2.1 所示。

图 2.1 图 2.1 编程实现一个LLM有三个阶段,其中包括LLM在通用文本数据集上进行预训练,并在标记数据集上对其进行微调。本章将解释和编程实现数据的准备和采样流程,该流程提供用于LLM预训练的文本数据。

在本章中,您将学习如何准备用于训练LLMs的输入文本。这涉及将文本拆分为单独的单词和子单词tokens,然后可以将其编码为LLM需要的向量表示。您还将了解高级tokenization方案,例如字节对编码,该方案用于GPT 等流行LLMs。最后,我们将实现采样和数据加载策略,以生成后续章节中训练LLMs所需的输入-输出对。

2.1 理解词嵌入

深度神经网络模型(包括 LLMs)无法直接处理原始文本。由于文本是离散的,因此它与用于实现和训练神经网络的数学运算不兼容。因此,我们需要一种方法来将单词表示为连续值向量。(不熟悉计算中的向量和张量的读者可以在附录 A 的 A2.2 节“理解张量”中了解更多信息。)

将数据转换为向量格式这一概念通常称为嵌入(embedding)。使用特定的神经网络层或其他预训练的神经网络模型,我们可以嵌入不同的数据类型,例如视频、音频和文本,如图 2.2 所示。

图 2.2 图 2.2 深度学习模型无法以原始形式处理视频、音频和文本等数据格式。因此,我们使用嵌入模型将这些原始数据转换为深度学习架构可以轻松理解和处理的稠密向量表示。具体来说,该图说明了将原始数据转换为三维数值向量的过程。

如图2.2所示,我们可以通过嵌入模型来处理各种不同的数据格式。但是,需要注意的是,不同的数据格式需要不同的嵌入模型。例如,为文本设计的嵌入模型不适合嵌入音频或视频数据。

嵌入的核心是从离散对象(如单词、图像甚至整个文档)到连续向量空间中的点的映射——嵌入的主要目的是将非数字数据转换为神经网络可以处理的数据格式。

虽然词嵌入是最常见的文本嵌入形式,但也有句子、段落或整个文档的嵌入。句子或段落嵌入是检索增强式生成(retrievalaugmented generation)的常用选择。检索式增强生成将生成(如生成文本)和检索(如搜索外部知识库)结合起来,在生成文本时提取相关信息,这是一种超出本书范围的技术。由于我们的目标是训练类似GPT的LLMs,它一次生成一个词,因此本章主要关注词嵌入。

目前已经开发了几种算法和框架来生成词嵌入,一个较早且最流行的例子是Word2Vec方法。Word2Vec训练神经网络架构,通过预测给定目标词的词的上下文来生成词嵌入,反之亦然。Word2Vec背后的主要思想是:出现在相似上下文中的单词往往具有相似的含义。因此,为了可视化的目的,当投影到二维词嵌入中时,可以看到相似的术语单词聚集在一起,如图2.3所示。

图 2.3 图 2.3 如果词嵌入是二维的,我们可以将它们绘制在二维散点图中,以便可视化,如下所示。当使用词嵌入技术(如Word2Vec)时,将相似概念相对应的词在嵌入空间中经常彼此靠近。例如,与国家和城市相比,不同类型的鸟类在嵌入空间中看起来彼此更接近。

单词嵌入可以有不同的维度,从1到数千。如图2.3所示,为了可视化目的,我们可以选择二维词嵌入。更高的维度可能捕获更细微的关系,但代价是计算效率。

虽然我们可以使用预训练模型(如Word2Vec)为机器学习模型生成嵌入,但LLMs通常会生成自己的嵌入,这些嵌入是输入层的一部分,并在训练期间更新。作为LLMs训练的一部分而不是使用Word2Vec来进行词嵌入的优点是,该方式针对当前的特定任务的数据进行了优化。我们将在本章后面实现这样的嵌入层。此外,LLMs还可以创建上下文相关的输出词嵌入,我们将在第3章中讨论。

不幸的是,高维嵌入给可视化带来了挑战,因为我们的感官知觉和常见的图形表示本质上局限于三维或更少的维度,这就是为什么图2.3在二维散点图中显示二维嵌入。然而,当使用LLMs时,我们通常使用比图2.3所示的维度高得多的词嵌入。对于GPT-2和GPT-3,嵌入大小(通常称为模型隐藏状态的维数)根据特定的模型变量和大小而变化。这是性能和效率之间的权衡。最小的GPT-2模型(117M和125M参数)使用768维的嵌入尺寸作为具体的例子。最大的GPT-3模型(175B参数)则使用12288个维度的嵌入大小。

本章接下来的章节将介绍准备LLM使用的嵌入所需的步骤,包括将文本分解为单词,将单词转换为tokens,并将tokens转换为嵌入向量。

2.2 Tokenizing文本

本节介绍如何将输入文本拆分为单个tokens,这是为LLM创建嵌入所需的必要预处理步骤。这些符号可以是单独的单词或特殊字符,包括标点符号,如图2.4所示。

图2.4 本节中涉及在LLM上下文中的进行文本处理的可视化流程图。这里,我们将输入文本拆分为单独的tokens,这些tokens可以是单词,也可以是特殊字符,如标点符号。在接下来的部分中,我们将把文本转换为tokens IDs并创建tokens嵌入。

我们将用于LLM训练tokens的文本是伊迪丝·沃顿(Edith Wharton)的一篇短篇小说《判决》(The Verdict),它是公开的,因此被允许用于LLMs训练任务。该文本可在Wikisource:https://en.wikisource.org/wiki/The_Verdict 上获得,您可以将其复制并粘贴到文本文件中,我将其复制到文本文件”the-verdict.txt”中,以便使用Python的标准文件读取实用程序加载:

清单2.1将一个短篇故事作为文本样本读入Python

with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()
print("Total number of character:", len(raw_text))
print(raw_text[:99])

或者,您可以在本书的GitHub存储库 https://github.com/rasbt/LLMs-fromscratch/tree/main/ch02/01_main-chapter-code 中找到“the-verdict.txt”文件。

为了便于说明,print命令打印该文件的前100个字符后的总字符数:

Total number of character: 20479
I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no 

我们的目标是将这20,479个字符的短篇故事tokenize为单个单词和特殊字符,然后我们可以在接下来的章节中将其转化为LLMs训练的次嵌入。

文本样本的大小

请注意,在训练LLMs时,通常要处理数百万篇文章和数十万本书(许多GB的文本)。但是,出于教育目的,使用较小的文本示例(例如一本书)来说明文本处理步骤背后的主要思想并使其能够在合理的时间内在消费者硬件上运行就足够了。

我们如何分割文本以获得tokens列表?为此,我们进行了一个小的尝试,并使用Python的正则表达式库re进行说明。(注意,你不需要学习或记忆任何正则表达式语法,因为我们将在本章后面会过渡到提前构建的tokenizer函数。)

使用一些简单的示例文本,我们可以使用re.split命令和以下语法来分割空白字符的文本:

import re
text = "Hello, world. This, is a test."
result = re.split(r'(\s)', text)
print(result)

结果是一个由单个单词、空格和标点字符组成的列表:

['Hello,', ' ', 'world.', ' ', 'This,', ' ', 'is', ' ', 'a', ' ', 'test.']

请注意,上面的简单tokenization方案主要用于将示例文本分离为单个单词,然而一些单词仍然与我们希望作为单独列表条目的标点符号相连接。我们也避免将所有文本都小写,因为大写有助于LLMs区分专有名词和普通名词、理解句子结构、学习并生成具有适当大写的文本。

让我们修改正则表达式在空格(\s)和逗号以及句号([,.])上的分割:

result = re.split(r'([,.]|\s)', text)
print(result)

我们可以看到,单词和标点符号现在是独立的列表项,正如我们所希望的那样:

['Hello', ',', '', ' ', 'world', '.', '', ' ', 'This', ',', '', ' ', 'is', ' ', 'a', ' ', 'test', '.', '']

剩下的一个小问题是该列表仍然包含空白字符。可选地,我们可以毫无顾忌地删除这些多余的字符,如下所示:

result = [item for item in result if item.strip()]
print(result)

得到的无空白输出如下所示:

['Hello', ',', 'world', '.', 'This', ',', 'is', 'a', 'test', '.']

是否删除空白

在开发一个简单的tokenizer时,我们是应该将空白编码为单独的字符还是直接删除它们取决于我们的应用程序及其需求。删除空白可以减少内存和计算需求。但是,如果我们训练对文本的确切结构敏感的模型(例如,Python代码对缩进和空格敏感),保留空白可能是有用的。这里,为了使标记化的输出简单和简洁,我们删除了空白。稍后,我们将切换到包含空白的tokenization方案。

我们上面设计的tokenizer方案在简单的示例文本上工作得很好。让我们进一步修改它,以便它也可以处理其他类型的标点符号,例如问号、引号和我们在前面伊迪丝·华顿短篇故事的前100个字符中看到的双破折号,以及其他特殊字符:

text = "Hello, world. Is this-- a test?"
result = re.split(r'([,.:;?_!"()\']|--|\s)', text)
result = [item.strip() for item in result if item.strip()]
print(result)

结果输出如下:

['Hello', ',', 'world', '.', 'Is', 'this', '--', 'a', 'test', '?']

根据图2.5总结的结果,我们可以看到,我们的tokenization方案现在可以成功地处理文本中的各种特殊字符。

图2.5 图2.5到目前为止,我们实现的tokenization方案将文本分割成单独的单词和标点符号。在此图所示的特定示例中,示例文本被分成10个单独的tokens。

现在我们有了一个基本的标记器,让我们把它应用到伊迪丝·沃顿的整个短篇故事中:

preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(len(preprocessed))

上面的print语句输出4649,这是该文本中tokens的数量(不含空格)。

让我们打印前30个代币,以便快速进行人眼检查:

print(preprocessed[:30])

结果输出显示,我们的tokenizer似乎很好地处理了文本,因为所有的单词和特殊字符都整齐地分开了:

['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius', '--', 'though', 'a', 'good', 'fellow', 'enough', '--', 'so', 'it', 'was', 'no', 'great', 'surprise', 'to', 'me', 'to', 'hear', 'that', ',', 'in']

2.3 将tokens转换为tokens IDs

在前一节中,我们将Edith Wharton的一个短篇故事tokenize为单独的tokens。在本节中,我们将把这些tokens从Python字符串转换为整数表示,以生成所谓的tokens IDs。此转换是将tokens IDs转换为嵌入向量之前的中间步骤。

为了将之前生成的tokens映射到tokens IDs,我们必须首先构建一个所谓的词汇表。这个词汇表定义了如何将每个唯一的单词和特殊字符映射为唯一的整数,如图2.6所示。

图 2.6 图2.6 我们通过将训练数据集中的整个文本tokenize为单个tokens来构建词汇表。然后按字母顺序对这些单独的标记进行排序,并删除重复的标记。然后将唯一标记集合到一个词汇表中,该词汇表定义了从每个唯一标记到唯一整数值的映射。为了便于说明,所描述的词汇表故意设的很小,并且为了简单起见,不包含标点或特殊字符。

在上一节中,我们对Edith Wharton的短篇小说进行了tokenize,并将其赋值给一个名为preprocessed的Python变量。现在让我们创建一个包含所有唯一tokens的列表,并按字母顺序排序以确定字典大小:

all_words = sorted(set(preprocessed))
vocab_size = len(all_words)
print(vocab_size)

通过上述代码确定词汇表大小为1,159之后,我们创建词汇表并打印其前50个条目以进行说明:

**清单2.2 创建词汇表<\sub>**

vocab = {token:integer for integer,token in enumerate(all_words)}
for i, item in enumerate(vocab.items()):
    print(item)
    if i > 50:
        break

输出如下:

('!', 0)
('"', 1)
("'", 2)
...
('Her', 49) 
('Hermia', 50) 

正如我们所看到的,基于上面的输出,字典包含与唯一整数标签相关联的单个tokens。我们的下一个目标是应用这个词汇表将新文本转换为tokens IDs,如图2.7所示。

图 2.7 图 2.7 从一个新的文本示例开始,我们对文本进行tokenize,并使用词汇表将文本tokens转换为token IDs。词汇表是从整个训练集中构建的,可以应用于训练集本身和任何新的文本样本。为简单起见,所描述的词汇表不包含标点符号或特殊字符。

在本书后面,当我们想要将LLM的输出从数字转换回文本时,我们还需要一种将token IDs转换为文本的方法。为此,我们可以创建词汇表的反向版本,将token IDs映射回相应的文本tokens。

让我们在Python中实现一个完整的tokenizer类,它使用encode方法将文本分割成tokens,并执行字符串到整数的映射,通过词汇表生成token IDs。此外,我们实现了一个decode方法,该方法执行整数到字符串的反向映射,将token IDs转换回文本。

这个tokenizer实现的代码如清单2.3所示:

**清单2.3 实现一个简单的文本tokenizer**

class SimpleTokenizerV1:
    def __init__(self, vocab):
        self.str_to_int = vocab
        self.int_to_str = {i:s for s,i in vocab.items()}
    
    def encode(self, text):
        preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
        preprocessed = [item.strip() for item in preprocessed if item.strip()]
        ids = [self.str_to_int[s] for s in preprocessed]
        return ids
        
    def decode(self, ids):
        text = " ".join([self.int_to_str[i] for i in ids]) 
        
        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
        return text

使用上面的SimpleTokenizerV1 Python类,我们现在可以通过现有词汇表实例化新的tokenizer对象,然后我们可以使用它来编码和解码文本,如图2.8所示。

图2.8 图2.8 Tokenizer的实现共享两个常用函数:编码函数和解码函数。encode函数接受示例文本,将其拆分为单个token,并通过词汇表将这些标记转换为token IDs。decode函数接受token IDs,将它们转换回文本tokens,并将文本tokens连接到自然文本中。

让我们从SimpleTokenizerV1类实例化一个新的tokenizer对象,并对Edith Wharton的短篇故事中的一段进行tokenize尝试:

tokenizer = SimpleTokenizerV1(vocab)
text = """"It's the last he painted, you know," Mrs. Gisburn said with pardonable pride."""
ids = tokenizer.encode(text)
print(ids)

上面的代码输出以下token IDs:

[1, 56, 2, 850, 988, 602, 533, 746, 5, 1126, 596, 5, 1, 67, 7, 38, 851, 1108, 754, 793, 7]

接下来,让我们看看是否可以使用decode方法将这些token IDs转换回文本:

print(tokenizer.decode(ids))

输出如下文本:

'" It\' s the last he painted, you know," Mrs. Gisburn said with pardonable pride.'

根据上面的输出,我们可以看到decode函数成功地将token IDs转换回原始文本。

到目前为止,一切顺利。我们实现了一个tokenizer,能够基于训练集的片段对文本进行tokenizing和tokenizing。现在让我们把它应用到一个不包含在训练集中的新文本样本上。现在让我们把它应用到一个不包含在训练集中的新文本样本上:

text = "Hello, do you like tea?"
print(tokenizer.encode(text))

执行上述代码将导致以下错误:

...
KeyError: 'Hello'

问题是在短篇小说《判决》中没有使用“你好”这个词。因此,它不包含在词汇表中。这突出表明了在LLMs上训练时需要考虑大型和多样化的训练集来扩展词汇表。

在下一节中,我们将在包含未知单词的文本上进一步测试tokenizer,并且我们还将讨论可用于在LLM训练期间为其提供进一步上下文连贯的其他特殊标记。

2.4 添加特殊的上下文tokens

在上一节中,我们实现了一个简单的tokenizer,并将其应用于训练集中的一个段落。在本节中,我们将修改这个tokenizer来处理未知单词。

我们还将讨论特殊上下文tokens的使用和添加,这些tokens可以增强模型对文本中上下文或其他相关信息的理解。例如,这些特殊的tokens可以包括未知单词和文档边界。

特别是,我们将修改在上一节SimpleTokenizerV2中实现的词汇表和tokenizer,以支持两个新的标记:< unk >和< endoftext >,如图2.9所示。

图 2.9 图 2.9 我们在词汇表中添加特殊的tokens来处理特定的上下文。例如,我们添加一个<|unk|> token来表示不属于训练数据的新单词和未知单词,它不属于现有词汇表。此外,我们添加了一个<|endoftext|>标记,可以用来分隔两个不相关的文本源。

如图2.9所示,我们可以修改tokenizer,使其在遇到不属于词汇表的单词时使用< unk >标记。此外,我们在不相关的文本之间添加token。例如,当在多个独立的文档或书籍上训练类GPT的LLMs时,通常在前面的文本源后面的每个文档或书籍之前插入一个token,如图2.10所示。这有助于LLMs理解,尽管这些文本源是为了训练而连接在一起的,但它们实际上是不相关的。

图 2.10 图 2.10 当使用多个独立文本源时,我们在这些文本之间添加<|endoftext|> tokens。这些<|endoftext|> tokens作为标记,指示特定段的开始或结束,允许LLM更有效地处理和理解。

现在让我们修改词汇表,将这两个特殊的tokens <unk>和<|endoftext|>添加到上一节中创建的所有唯一单词列表中,从而包含它们:

all_tokens = sorted(list(set(preprocessed)))
all_tokens.extend(["<|endoftext|>", "<|unk|>"])
vocab = {token:integer for integer,token in enumerate(all_tokens)}
 
print(len(vocab.items()))

根据上面print语句的输出,新的词汇表大小为1161(上一节中的词汇表大小为1159)。

作为额外的快速检查,让我们打印最新词汇表的最后5个条目:

for i, item in enumerate(list(vocab.items())[-5:]):
    print(item)

上面的代码输出如下:

('younger', 1156)
('your', 1157)
('yourself', 1158)
('<|endoftext|>', 1159)
('<|unk|>', 1160)

根据上面的代码输出,我们可以确认这两个新的特殊标记确实成功地合并到了词汇表中。接下来,我们相应地调整清单2.3代码中的tokenizer,如清单2.4所示:

**清单2.4 处理未知单词的简单文本tokenizer**

class SimpleTokenizerV2:
    def __init__(self, vocab):
        self.str_to_int = vocab
        self.int_to_str = { i:s for s,i in vocab.items()}
    
    def encode(self, text):
        preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
        preprocessed = [item.strip() for item in preprocessed if item.strip()]
        preprocessed = [item if item in self.str_to_int
                        else "<|unk|>" for item in preprocessed]
 
        ids = [self.str_to_int[s] for s in preprocessed]
        return ids
        
    def decode(self, ids):
        text = " ".join([self.int_to_str[i] for i in ids])
 
        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
        return text
与我们在前一节的代码清单2.3中实现的SimpleTokenizerV1相比,新的SimpleTokenizerV2用< unk >令牌替换未知单词。

现在让我们在实践中尝试这个新的标记器。为此,我们将使用一个简单的文本样本,我们将两个独立且不相关的句子连接起来:

text1 = "Hello, do you like tea?"
text2 = "In the sunlit terraces of the palace."
text = " <|endoftext|> ".join((text1, text2))
print(text)

输出结果如下:

'Hello, do you like tea? <|endoftext|> In the sunlit terraces of the palace.'

接下来,让我们在清单2.2中创建的词汇表上使用SimpleTokenizerV2对示例文本进行tokenize:

tokenizer = SimpleTokenizerV2(vocab)
print(tokenizer.encode(text))

打印以下token IDs:

[1160, 5, 362, 1155, 642, 1000, 10, 1159, 57, 1013, 981, 1009, 738, 1013, 1160, 7]
从上面我们可以看到token IDs列表包含用于< endoftext >分隔符tokens的1159以及用于未知单词的两个1160 tokens。

让我们对文本进行去de-tokenize以进行快速的完整性检查:

print(tokenizer.decode(tokenizer.encode(text)))

输出结果如下:

'<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>.'

通过将上面de-tokenize的文本与原始输入文本进行比较,我们知道训练数据集伊迪丝·沃顿的短篇小说《判决》不包含单词“Hello”和“palace”。

到目前为止,我们已经讨论了将tokenization作为处理文本作为LLMs输入的重要步骤。根据LLMs的不同,一些研究人员还考虑了额外的特殊tokens,例如:

  • BOS:这个token标记文本的开始。对于LLMs来说,它表示一段内容从哪里开始。
  • [EOS] (end of sequence,序列的结束):这个token位于文本的末尾,在连接多个不相关的文本时特别有用,类似于< endoftext >。例如,当组合两个不同的维基百科文章或书籍时,[EOS]令牌指示一篇文章的结束位置和下一篇文章的开始位置。
  • PAD:当训练批处理大小大于1的LLMs时,批处理可能包含不同长度的文本。为了确保所有文本具有相同的长度,使用[PAD]令牌对较短的文本进行扩展或“padding”直至批处理中最长文本的长度。
请注意,用于GPT模型的tokenizer不需要上面提到的任何标记,为了简单起见,它只使用< endoftext >标记。< endoftext >类似于上面提到的[EOS] token。此外,< endoftext >也用于padding。然而,正如我们将在后续章节中探讨的那样,当训练批处理输入时,我们通常使用一个mask(掩码),这意味着我们不需要关注被填充的tokens。因此,为padding选择的特定token变得无关紧要。
此外,用于GPT模型的tokenizer也不会对超出词汇表的单词使用< unk >标记。相反,GPT模型使用字节对编码tokenizer,它将单词分解为子词单元,我们将在下一节中讨论。

2.5 字节对编码

为了便于说明,我们在前面几节中实现了一个简单的tokenization方案。本节介绍一种更复杂的基于字节对编码(BPE)概念的tokenization方案。本节介绍的BPE tokenization被用于训练LLMs,如GPT-2、GPT-3和ChatGPT中使用的原始模型。

由于实现BPE可能相对复杂,我们将使用现有的Python开源库tiktoken (https://github.com/openai/tiktoken),它基于Rust中的源代码非常有效地实现了BPE算法。与其他Python库类似,我们可以通过Python的pip安装程序从终端安装tiktoken库:

pip install tiktoken

本章中的代码基于tiktoken 0.5.1。您可以使用以下代码来检查当前安装的版本:

from importlib.metadata import version
import tiktoken
print("tiktoken version:", version("tiktoken"))

安装完成后,我们可以从tiktoken实例化BPE tokenizer,如下所示:

tokenizer = tiktoken.get_encoding("gpt2")

这个tokenizer的用法类似于我们之前通过encode函数实现的SimpleTokenizerV2:

text = "Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace."
integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(integers)

上面的代码输出以下token IDs:

[15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 286, 617, 34680, 27271, 13]

然后我们可以使用decode方法将token IDs转换回文本,类似于前面的SimpleTokenizerV2:

strings = tokenizer.decode(integers)
print(strings)

上面的代码输出如下:

'Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace.'
基于上面的token IDs和解码文本,我们可以得出两个值得注意的观察结果。首先,为< endoftext > token分配一个相对较大的令牌ID,即50256。实际上,用于训练GPT-2、GPT-3和ChatGPT中使用的原始模型等模型的BPE tokenizer的总词汇量为50,257,其中< endoftext >被分配为最大的token ID。
其次,上面的BPE tokenizer可以正确地编码和解码未知单词,例如“someunknownPlace”。BPE tokenizer可以处理任何未知的单词。但它如何在不使用< unk >令牌的情况下实现这一点的呢?

BPE的底层算法将不在其预先定义的词汇表中的单词分解为更小的子词单位甚至单个字符,从而使其能够处理词汇表外的单词。因此,由于BPE算法,如果tokenizer在tokenization过程中遇到不熟悉的单词,它可以将其表示为子词标记或字符序列,如图2.11所示。

图2.11 图2.11 BPE tokenizers将未知单词分解为子单词和单个字符。通过这种方式,BPE tokenizers可以解析任何单词,而不需要用特殊标记(如<|unk|>)替换未知单词。

如图2.11所示,将未知单词分解为单个字符的能力确保了tokenizer以及由此训练的LLMs可以处理任何文本,即使它包含训练数据中不存在的单词。

练习2.1:未知单词的字节对编码

尝试使用tiktoken库中的BPE tokenizer对未知单词“Akwirw ier”进行tokenization,并打印单个token IDs。然后,对该列表中的每个结果整数调用decode函数,以重现图2.11所示的映射。最后,调用token IDs上的decode方法,检查它是否可以重建原始输入“Akwirw ier”。

BPE的详细讨论和实现超出了本书的范围,但简而言之,它通过迭代地将频繁字符合并到子词中并将频繁子词合并到词中来构建其词汇表。例如,BPE首先将所有单个字符添加到其词汇表中(“a”,“b”,…)。在下一阶段,它将经常出现在一起的字符组合合并成子词。例如,“d”和“e”可能合并成副词“de”,这在许多英语单词中很常见,如“define”、“depend”、“made”和“hidden”。是否合并由截断频率决定。

2.6 使用滑动窗口进行数据采样

前一节非常详细地介绍了tokenization步骤和从字符串tokens到整数token IDs的转换。在我们最终为LLM创建嵌入之前的下一步是生成训练LLM所需的输入-目标对。

这些输入-目标对是什么样的?正如我们在第1章中所学到的,LLMs通过预测文本中的下一个单词来进行预训练,如图2.12所示。

图 2.12 图 2.12 给定一个文本样本,提取输入块作为子样本作为LLM的输入,LLM在训练时的预测任务是预测输入块后面的下一个单词。在训练过程中,我们会屏蔽掉所有超出目标的单词。请注意,在LLM处理此图中显示的文本之前,将对其进行tokenization;但是,为了清晰起见,此图省略了tokenization步骤。

在本节中,我们实现一个数据加载器,它使用滑动窗口方法从训练数据集中获取图2.12所示的输入-目标对。

在开始之前,我们将首先使用上一节介绍的BPE tokenizer对之前处理过的整个the Verdict短篇故事进行tokenize:

with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()
 
enc_text = tokenizer.encode(raw_text)
print(len(enc_text))    

在应用BPE tokenizer后,执行上面的代码将返回5145,即训练集中的标记总数。

接下来,出于演示目的,我们从数据集中删除前50个tokens,因为它会在接下来的步骤中产生一个稍微有趣的文本段落:

enc_sample = enc_text[50:]  

为下一个单词预测任务创建输入-目标对的最简单和最直观的方法之一是创建两个变量x和y,其中x包含输入tokens,y包含目标,即移位1的输入:

context_size = 4
x = enc_sample[:context_size]
y = enc_sample[1:context_size+1]
print(f"x: {x}")
print(f"y:      {y}") 

运行上面的代码输出如下:

x: [290, 4920, 2241, 287]
y:      [4920, 2241, 287, 257]

将输入与目标一起处理,目标是移动了一个位置的输入,然后我们可以创建图2.12中所示的下一个单词预测的方案,如下所示:

for i in range(1, context_size+1):
    context = enc_sample[:i]
    desired = enc_sample[i]
    print(context, "---->", desired)

上面的代码输出如下内容:

[290] ----> 4920
[290, 4920] ----> 2241
[290, 4920, 2241] ----> 287
[290, 4920, 2241, 287] ----> 257

箭头左边的所有内容(—->)都是指LLM将接收的输入,箭头右边的token ID表示LLM应该预测的目标token ID。

为了便于说明,让我们重复前面的代码,但将tokrn ID转换为文本:

for i in range(1, context_size+1):
    context = enc_sample[:i]
    desired = enc_sample[i]
    print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))

下面的输出显示了输入和输出在文本格式下的样子:

 and ---->  established
 and established ---->  himself
 and established himself ---->  in
 and established himself in ---->  a

我们现在已经创建了输入-目标对,我们可以在接下来的章节中将其用于LLM训练。

正如我们在本章开头提到的那样,在我们将tokens转换为嵌入之前,只剩一个任务:实现一个高效的数据加载器,它迭代输入数据集并将输入和目标作为PyTorch张量返回,这可以被认为是多维数组。

特别是,我们对返回两个张量感兴趣:一个包含LLM看到的文本的输入张量和一个包含LLM要预测的目标的目标张量,如图2.13所示。

图 2.13 图 2.13 为了实现高效的数据加载器,我们在张量x中收集输入,其中每一行代表一个输入上下文。第二个张量y包含相应的预测目标(下一个单词),它是通过将输入移动一个位置来创建的。

图2.13以字符串格式显示tokens仅仅是为了说明,代码实现将直接对token IDs进行操作,因为BPE tokenizer的编码方法将文本tokenization和转换为token IDs作为一个必要的步骤执行。

为了实现高效的数据加载器,我们将使用PyTorch的内置Dataset和DataLoader类。有关安装PyTorch的其他信息和指导,请参阅附录A中的A.1.3节“安装PyTorch”。

数据集类的代码如清单2.5所示:

**清单2.5 用于批处理输入和目标的数据集**

import torch
from torch.utils.data import Dataset, DataLoader
 
class GPTDatasetV1(Dataset):
    def __init__(self, txt, tokenizer, max_length, stride):
        self.input_ids = []
        self.target_ids = []
 
        token_ids = tokenizer.encode(txt)
 
        for i in range(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):
        return len(self.input_ids)
 
    def __getitem__(self, idx):
        return self.input_ids[idx], self.target_ids[idx]

清单2.5中的GPTDatasetV1类基于PyTorch Dataset类,并定义了如何从数据集中获取单独的一行,其中每行由分配给input_chunk张量的许多token IDs(基于max_length)组成。target_chunk张量包含相应的目标。我们继续,看看当我们将数据集与PyTorch DataLoader组合在一起时,从这个数据集返回的数据是什么样子的——这将带来更多的直观和清晰认识。

如果您不熟悉PyTorch Dataset类的结构,如清单2.5所示,请阅读附录A中的A.6节“设置高效的数据加载器”,其中解释了PyTorch Dataset和DataLoader类的一般结构和用法。

下面的代码将使用GPTDatasetV1通过PyTorch DataLoader批量加载输入:

**清单2.6 一个数据加载器,用于生成带有输入-使用对的批处理数据**

def create_dataloader_v1(txt, batch_size=4, max_length=256,
        stride=128, shuffle=True, drop_last=True, num_workers=0):
    tokenizer = tiktoken.get_encoding("gpt2")
    dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)
    dataloader = DataLoader(
    dataloader = DataLoader(
        dataset,
        batch_size=batch_size,
        shuffle=shuffle,
        drop_last=drop_last,
        num_workers=0
    )
 
    return dataloader

让我们用批处理大小为1的LLM和上下文大小为4的LLM测试数据加载器,以直观地了解清单2.5中的GPTDatasetV1类和清单2.6中的create_dataloader_v1函数是如何协同工作的:

with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()
 
dataloader = create_dataloader_v1(
    raw_text, batch_size=1, max_length=4, stride=1, shuffle=False)
data_iter = iter(dataloader)
first_batch = next(data_iter)
print(first_batch)

执行上述代码打印如下内容:

[tensor([[  40,  367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]

first_batch变量包含两个张量:第一个张量存储输入token IDs,第二个张量存储目标token IDs。由于max_length被设置为4,所以两个张量中的每一个都包含4个token IDs。请注意,输入大小4相对较小,仅用于说明目的。LLM最常见的训练输入大小至少为256。

为了说明stride=1的含义,让我们从这个数据集中获取另一批数据:

second_batch = next(data_iter)
print(second_batch)

第二批有以下内容:

[tensor([[ 367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]

如果我们将第一批与第二批进行比较,我们可以看到,与第一批相比,第二批的token ID移动了一个位置(例如,第一批输入中的第二个ID是367,这是第二批输入的第一个ID)。stride设置指示了输入在批次间移位的位置数,模拟了滑动窗口方法,如图2.14所示。

图 2.14 图 2.14 当从输入数据集创建多个批次时,我们在文本上滑动一个输入窗口。如果stride设置为1,我们在创建下一个批处理时将输入窗口移动1个位置。如果我们设置步幅等于输入窗口的大小,我们可以防止批次之间的重叠。

练习2.2: 具有不同步长和上下文大小的数据加载器

为了更直观地了解数据加载器是如何工作的,尝试使用不同的设置来运行它,例如max_length=2和stride=2,以及max_length=8和stride=2。

批大小为1,例如我们目前从数据加载器中采样的批大小,对于演示目的是有用的。如果你有深度学习的经验,你可能知道小批量在训练过程中需要更少的内存,但会在模型更新中导致更多的噪声。就像在常规深度学习中一样,在训练LLMs时,批大小是一个需要权衡的超参数。

在我们继续本章的最后两个部分(重点是从token IDs创建嵌入向量)之前,让我们简要了解一下如何使用数据加载器对批量大小大于1的样本进行采样:

dataloader = create_dataloader_v1(raw_text, batch_size=8, max_length=4, stride=4)
 
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Inputs:\n", inputs)
print("\nTargets:\n", targets)

打印如下内容:

Inputs:
 tensor([[   40,   367,  2885,  1464],
        [ 1807,  3619,   402,   271],
        [10899,  2138,   257,  7026],
        [15632,   438,  2016,   257],
        [  922,  5891,  1576,   438],
        [  568,   340,   373,   645],
        [ 1049,  5975,   284,   502],
        [  284,  3285,   326,    11]])
 
Targets:
 tensor([[  367,  2885,  1464,  1807],
        [ 3619,   402,   271, 10899],
        [ 2138,   257,  7026, 15632],
        [  438,  2016,   257,   922],
        [ 5891,  1576,   438,   568],
        [  340,   373,   645,  1049],
        [ 5975,   284,   502,   284],
        [ 3285,   326,    11,   287]])

注意,我们将步幅增加到4。这是为了充分利用数据集(我们不会跳过一个词),但也避免批次之间的任何重叠,因为更多的重叠可能导致过度拟合增加。

在本章的最后两节中,我们将实现嵌入层,将token IDs转换为连续向量表示,作为LLMs的输入数据格式。

2.7 创建token嵌入

为LLM训练准备输入文本的最后一步是将token IDs转换为嵌入向量,如图2.15所示,这将是本章剩下的最后两节的重点。

图 2.15 图 2.15 为LLM准备输入文本包括对文本进行tokenize,将文本标记转换为token IDs,并将token IDs转换为向量嵌入向量。在本节中,我们考虑在前几节中创建的token IDs来创建令牌嵌入向量。

除了图2.15中概述的过程之外,重要的是要注意,作为初步步骤,我们使用随机值初始化这些嵌入权重。这个初始化作为LLM学习过程的起点。我们将优化嵌入权值作为第5章LLM训练的一部分。

连续向量表示或嵌入是必要的,因为类似GPT的LLM是用反向传播算法训练的深度神经网络。如果您不熟悉如何使用反向传播训练神经网络,请阅读附录A中的A.4节“轻松实现自动微分”。

让我们通过一个实际的示例来说明token IDs到嵌入向量的转换是如何工作的。假设我们有以下四个IDs为2、3、5和1的输入tokens:

input_ids = torch.tensor([2, 3, 5, 1])

为了简单和说明目的,假设我们有一个只有6个单词的小词汇表(而不是BPE tokenizer词汇表中的50,257个单词),并且我们想要创建大小为3的嵌入(在GPT-3中,嵌入大小为12,288个维度):

vocab_size = 6
output_dim = 3

使用vocab_size和output_dim,我们可以在PyTorch中实例化一个嵌入层,将随机种子设置为123以实现可重复性:

torch.manual_seed(123)
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
print(embedding_layer.weight)

上面代码示例中的print语句打印嵌入层为下面的权重矩阵:

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)

我们可以看到,嵌入层的权值矩阵包含小的随机值。这些值在LLM训练期间作为LLM优化本身的一部分进行优化,我们将在接下来的章节中看到。此外,我们可以看到权重矩阵有六行三列。词汇表中的六种可能的符号各有一行,这嵌入向量的三个维度各有一列。

在我们实例化嵌入层之后,现在让我们将其应用于token IDs以获得嵌入向量:

print(embedding_layer(torch.tensor([3])))

返回的嵌入向量如下:

tensor([[-0.4015,  0.9666, -1.1481]], grad_fn=<EmbeddingBackward0>)

如果我们将token ID为3的嵌入向量与之前的嵌入矩阵进行比较,我们会发现它与第4行相同(Python从零索引开始,所以它是与索引3对应的行)。换句话说,嵌入层本质上是一个查找操作,它通过token ID从嵌入层的权重矩阵中检索行。

嵌入层和矩阵乘法

对于那些熟悉one-hot编码的人来说,上面的嵌入层方法本质上只是一种更有效的实现one-hot编码的方法,通过在一个全连接层中进行矩阵乘法,这在GitHub上的 https://github.com/rasbt/LLMs-fromscratch/tree/main/ch02/03_bonus_embedding-vs-matmul 的补充代码中进行了说明。由于嵌入层只是一种相当于one-hot编码和矩阵乘法方法的更有效的实现,因此可以将其视为可以通过反向传播进行优化的神经网络层。

前面,我们已经看到了如何将单个token ID转换为三维嵌入向量。现在让我们将其应用于我们之前定义的所有四个输入ID (torch.tensor([2, 3, 5, 1])):

print(embedding_layer(input_ids))

打印输出显示,结果是一个4x3矩阵:

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>)

输出矩阵中的每一行都是通过对嵌入权重矩阵的查找操作获得的,如图2.16所示。

图 2.16 图 2.16 嵌入层执行查找操作,从嵌入层的权重矩阵中检索与token ID对应的嵌入向量。例如,tokenID为5的嵌入向量是嵌入层权重矩阵的第六行(它是第六行而不是第五行,因为Python从0开始计数)。为了说明目的,我们假设token ID是由我们在2.3节中使用的小词汇表产生的。

本节介绍了如何从token ID创建嵌入向量。本章的下一节和最后一节将对这些嵌入向量进行一个小的修改,以编码文本中标记的位置信息。

2.8 对单词位置进行编码

在前一节中,我们将token id转换为连续向量表示,即所谓的token嵌入。原则上,这对LLMs来说是一个合适的输入。然而,LLMs的一个小缺点是它们的自注意力机制(将在第3章详细介绍)没有序列中token的位置或顺序的概念。

前面介绍的嵌入层的工作方式是,无论token ID在输入序列中的位置如何,相同的token ID总是被映射到相同的向量表示,如图2.17所示。

图 2.17 图 2.17 嵌入层将token ID转换为相同的向量表示,而不管它在输入序列中的位置。例如,token ID为5,无论是在tpken ID输入向量的第一个位置还是第三个位置,都会得到相同的嵌入向量。

原则上,token ID的确定性、与位置无关的嵌入有利于再现性。然而,由于LLM自身的自注意机制也是位置不可知的,因此在LLM中注入额外的位置信息是有帮助的。

为了实现这一点,有两大类位置感知嵌入方式:相对位置嵌入和绝对位置嵌入。

绝对位置嵌入与序列中的特定位置直接相关。对于输入序列中的每个位置,一个唯一的嵌入被添加到token的嵌入中,以传达它的确切位置。例如,第一个token将有一个特定的位置嵌入,第二个token将有另一个不同的嵌入,以此类推,如图2.18所示。

图 2.18 图 2.18 将位置嵌入添加到token嵌入向量中以创建LLM的输入嵌入。位置向量具有与原始标记嵌入相同的维度。为简单起见,token嵌入用值1表示。

相对位置嵌入的重点不是符号的绝对位置,而是符号之间的相对位置或距离。这意味着模型根据“距离有多远”而不是“在哪个确切的位置”来学习关系。这里的优点是,模型可以更好地泛化到不同长度的序列,即使它在训练期间没有看到这样的长度。

这两种类型的位置嵌入都旨在增强LLM理解token之间的顺序和关系的能力,确保更准确和上下文感知的预测。它们之间的选择通常取决于特定的应用程序和正在处理的数据的性质。

OpenAI的GPT模型在训练过程中使用优化的绝对位置嵌入,而不是像原始Transformer模型中的位置编码那样是固定的或预定义的。这个优化过程是模型训练本身的一部分,我们将在本书后面实现。现在,让我们创建初始位置嵌入,为接下来的章节创建LLM输入。

之前,为了说明目的,我们在本章中专注于非常小的嵌入尺寸。现在我们考虑更现实和有用的嵌入大小,并将输入标记编码为256维向量表示。

这比原始的GPT-3模型使用的要小(在GPT-3中,嵌入大小是12288维),但对于实验来说仍然是合理的。此外,我们假设token IDs是由我们之前实现的BPE tokenizer创建的,它的词汇表大小为50,257:

vocab_size = 50257
output_dim = 256
token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)

使用上面的token_embedding_layer,如果我们从数据加载器中采样数据,我们将每个批中的每个token嵌入到一个256维向量中。如果我们的批处理大小为8,每个样本有四个tokens,那么结果将是一个8 x 4 x 256张量。

首先,让我们实例化2.6节中的数据加载器,使用滑动窗口进行数据采样:

max_length = 4
dataloader = create_dataloader_v1(
    raw_text, batch_size=8, max_length=max_length, stride=max_length, shuffle=False)
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Token IDs:\n", inputs)
print("\nInputs shape:\n", inputs.shape)

前面的代码输出如下:

Token IDs:
 tensor([[   40,   367,  2885,  1464],
        [ 1807,  3619,   402,   271],
        [10899,  2138,   257,  7026],
        [15632,   438,  2016,   257],
        [  922,  5891,  1576,   438],
        [  568,   340,   373,   645],
        [ 1049,  5975,   284,   502],
        [  284,  3285,   326,    11]])
 
Inputs shape:
 torch.Size([8, 4])

正如我们所看到的,token ID张量是8x4维的,这意味着数据批由8个文本样本组成,每个样本有4个tokens。

现在让我们使用嵌入层将这些tokens IDs嵌入到256维向量中:

token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)

前面的print函数调用返回以下内容:

torch.Size([8, 4, 256])

根据8x4x256维张量输出,我们可以看出,每个token ID现在被嵌入为一个256维向量。

对于GPT模型的绝对嵌入方法,我们只需要创建另一个与token_embedding_layer具有相同维度的嵌入层:

context_length = max_length
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)
pos_embeddings = pos_embedding_layer(torch.arange(context_length))
print(pos_embeddings.shape)

如上面的代码示例所示,pos_embeddings的输入通常是一个占位符向量torch.arange(context_length),它包含一个数字序列0,1,…,最大输入长度为−1。context_length是一个变量,表示LLM支持的输入大小。这里,我们选择的长度与输入文本的最大长度相似。在实际中,输入文本可能比支持的上下文长度长,在这种情况下,我们必须截断文本。

print语句的输出如下:

torch.Size([4, 256])

我们可以看到,位置嵌入张量由四个256维向量组成。我们现在可以直接将这些添加到标记嵌入中,PyTorch会将4x256维的pos_embeddings张量添加到8批中每个4x256维的标记嵌入张量中:

input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)

打印结果如下:

torch.Size([8, 4, 256])

如图2.19所示,我们创建的input_embeddings是嵌入的输入示例,现在可以由主LLM模块处理,我们将在第3章开始实现。

图 2.19 图 2.19 作为输入处理管道的一部分,输入文本首先被分解成单独的tokens。然后使用词汇表将这些tokens转换为token IDs。token IDs被转换为嵌入向量,其中添加了相同大小的位置嵌入,从而产生作为主要LLM层输入的输入嵌入。

2.9 小结

  • LLM需要将文本数据转换为数字向量(被称为嵌入),因为它们不能处理原始文本。嵌入将离散数据(如文字或图像)转换为连续向量空间,使其与神经网络操作兼容。
  • 作为第一步,原始文本被分解成tokens,这些tokens可以是单词或字符。然后,将tokens转换为整数表示形式,称为token IDs。
  • 可以添加特殊的标记,例如< unk >和< endoftext >,以增强模型的理解和处理各种上下文,例如未知单词或标记不相关文本之间的边界。
  • 用于像GPT-2和GPT-3这样的LLM的字节对编码(BPE)tokenizer可以通过将未知单词分解为子词单元或单个字符来有效地处理未知单词。
  • 我们在tokenized的数据上使用滑动窗口方法来生成用于LLM训练的输入目标对。
  • 在PyTorch函数中嵌入层其实是查找操作,检索与token IDs对应的向量。由此产生的嵌入向量提供了token的连续表示,这对于训练像LLM这样的深度学习模型至关重要。
  • 虽然token嵌入为每个token提供一致的向量表示,但它们缺乏token在序列中的位置。为了纠正这一点,存在两种主要类型的位置嵌入:绝对位置嵌入和相对位置嵌入。OpenAI的GPT模型利用绝对位置嵌入,将其添加到令牌嵌入向量中,并在模型训练期间进行优化。

Read More

《从头开始构建大语言模型》01

曾伟 2024-07-13

简介:人生迷茫的时候,就来读书学习吧。今天偶然在微信公众号深度学习与NLP上看到介绍一本新书《Build a Large Language Model From Scratch》(从头开始构建大语言模型),真羡慕老美总是能及时出这么好的书,目前好像还没对应的中文书。这本英文书在MANNING卖31.19美刀,穷逼自然是不可能买的,继续搜索在SCRIBD网上找到PDF下载链接,随便在Arxiv上找了五篇论文上传下载得到,不过对比了一下目录发现不是最新版,不过还是可以凑活着读,另外发现中文互联网也有人已经开始阅读了,比如Datawhale博客1博客2。稍等,最终我发现在MANNING上可以按章阅读,😅。

目录

  1. 了解大语言模型
  2. 使用文本数据
  3. 编码注意力机制
  4. 从头开始实现GPT模型以生成文本
  5. 对未标记数据进行预训练
  6. 分类任务微调
  7. 使用人类反馈进行微调以遵循人类指令
  8. 附录A:Pytorch简介
  9. 附录B:参考文献和进一步阅读
  10. 附录C:习题参考答案
  11. 附录D:在训练循环中添加附加功能
  12. 使用LORA进行参数高效微调

1 了解大语言模型

本章涵盖:

  • 大语言模型(LLMs)背后基本概念的高层次解释
  • 对诸如ChatGPT平台上使用的LLMs中Transformer架构的见解
  • 从零开始构建LLM的一份计划

大语言模型(LLMs)是过去几年开发的深度神经网络模型,著名例子是OpenAI开发的ChatGPT。LLMs开创了一个自然语言处理(NLP)的新时代。在大型语言模型出现之前,传统方法擅长分类任务,例如垃圾邮件分类和简单的模式识别,这些任务可以通过手工设计的规则或更简单的模型来实现。然而,他们通常在需要复杂理解和生成能力的语言任务中表现不佳,例如解析并给出详细说明、进行上下文分析或创造连贯且适合上下文的原始文本。例如,先前的语言模型无法从一系列关键字中撰写电子邮件,这对于当代LLMs语言模型来说是非常容易的。

LLMs具有理解、生成和解释人类语言的非凡能力。然而,需要澄清的是,当我们说语言模型“理解”时,我们的意思是说,它们能够以看起来连贯且与上下文相关的方式处理和生成文本,而不是说它们真的拥有类似人类的意识或理解。

得益于深度学习(机器学习的子集,一种专注神经网络的AI技术)的进步,LLMs可以通过大量文本数据进行训练。与以前的方法相比,这使得LLMs能够捕获更深层次的上下文信息和人类语言的微妙之处。因此,LLMs显着提高了各种 NLP 任务的性能,包括文本翻译、情感分析、智能问答等等。

当代LLMs和早期 NLP 模型之间的另一个重要区别是,这些早期的 NLP 模型通常是为特定任务而设计的,例如文本分类、语言翻译等。尽管那些早期的 NLP 模型在狭窄的特定应用中表现出色,但LLMs在广泛的 NLP 任务中表现出泛化的熟练程度。

LLMs背后的成功可以归因于支撑许多LLMs应用的Transformer架构,以及用于LLMs训练的大量数据,这使得LLMs可以捕获通过手工规则难以量化的各种语言之间细微差别、上下文关联度及语言范式。

这种使用Transformer架构和大量训练数据集训练得到的LLMs,从根本上颠覆了NLP,为机器理解人类语言和与人类用语言交互提供了更强大的工具。

从本章开始,我们为实现本书最初的目标奠定基础,我们的目标是:通过一步一步编码,实现一个基于Transformer的类ChatGPT的LLM来深入理解LLMs。

1.1 什么是LLM?

LLM指大语言模型,是一种被设计用来理解、生成和回应类人文本的神经网络模型。这些模型是在大量文本数据上训练的深度神经网络,有时包含互联网上整个公开文本的大部分内容。

大型语言模型中的“大”既指模型的参数大小,也指训练模型所依据的庞大数据集。像这样的模型通常有数百亿甚至数千亿个参数,这些参数是网络中可调整的权重,在训练期间进行优化,以预测文本序列中的下一个单词。下一个单词预测是可行的,因为它利用语言固有的顺序性质来训练模型来理解文本中的上下文、结构和关系。这并不是一项非常简单的任务,因此许多研究人员对它能够产生如此强大的模型感到惊讶。们将在后面的章节中逐步讨论和实现下一个单词预测的训练程序。

LLMs利用一种称为 Transformer 的架构(在第 1.4 节中有更详细的介绍),这使他们能够在进行预测时有选择地关注输入的不同部分,使他们特别擅长处理人类语言的细微差别和复杂性。

由于LLMs能够生成文本,LLMs因此通常也被称为生成式人工智能(AI)的一种形式,通常缩写为生成式AI或GenAI。如图 1.1 所示,人工智能涵盖了创建机器的更广泛领域,这些机器可以执行类似人类智能的任务,包括理解语言、识别模式和做出决策,并包括机器学习和深度学习等子领域。

图 1.1 图1.1 正如对不同领域之间关系的分层描述所表示的那样,LLMs代表了深度学习技术的特定应用,利用它们所具备的处理和生成类似人类的文本的能力。深度学习是机器学习的一个专门分支,专注于使用多层神经网络。机器学习和深度学习是旨在实现使计算机能够从数据中学习并执行通常需要人类智能的任务的算法领域。

用于实现人工智能的算法是机器学习领域的重点。具体来说,机器学习涉及算法的开发,这些算法可以从数据中学习并根据数据做出预测或决策,而无需明确编程。为了说明这一点,将垃圾邮件过滤器想象成机器学习的实际应用。机器学习算法不是手动编写规则来识别垃圾邮件,而是提供标记为垃圾邮件和合法邮件的电子邮件样本,通过最小化在训练数据集的预测误差,该模型可以学习到垃圾邮件的模式和特征,使其能够将新电子邮件分类为垃圾邮件或合法邮件。

如图1.1所示,深度学习是机器学习的一个子集,专注于利用具有三层或更多层的神经网络(也称为深度神经网络)来对数据中的复杂模式和抽象进行建模。与深度学习相比,传统的机器学习需要手动提取特征,这意味着人类专家需要识别和选择与模型最相关的特征。

虽然人工智能领域现在以机器学习和深度学习为主,但它也包括其他方法,例如,使用基于规则的系统、遗传算法、专家系统、模糊逻辑或符号推理。

回到垃圾邮件分类示例,在传统的机器学习中,人类专家可能会手动从电子邮件文本中提取特征,例如某些触发词(“prize”、“win”、“free”)的频率、感叹号的数量、所有大写单词的使用或是否存在可疑链接。然后,基于这些专家定义的特征创建的数据集将用于训练模型。与传统的机器学习相比,深度学习不需要手动提取特征,这意味着人类专家不需要识别和选择与深度学习模型最相关的特征。(但是,不论在传统的机器学习还是在深度学习的垃圾邮件分类中,您仍然需要收集标签,例如垃圾邮件或非垃圾邮件,这些标签需要由专家或用户收集。)

在接下来的章节中,将介绍有哪些问题是LLMs今天可以解决的,有哪些挑战是LLMs需要处理的,以及本书将要实现的通用LLM架构。

1.2 LLMs的应用

由于它们具有解析和理解非结构化文本数据的高级功能,LLMs因此在各个领域都有广泛的应用。今天,LLMs它被用于机器翻译、小说文本的生成(见图 1.2)、情感分析、文本摘要和许多其他任务。LLMs最近被用于内容创作,例如写小说、文章,甚至计算机代码。

图 1.2 图1.2 LLM接口支持用户和AI系统之间使用自然语言交流。这张截图显示了ChatGPT根据用户的要求写一首诗。

LLMs还可以是功能强大的聊天机器人或者虚拟助手,例如OpenAI的ChatGPT或谷歌的Gemini(以前称为Bard),它们可以回答用户的询问并增强诸如Google和Microsoft Bing的传统搜索引擎。

此外,LLMs可用于从医学或法律等专业领域的大量文本中有效地进行知识检索。这包括筛选文档、总结冗长的段落和回答专业问题。

简而言之,LLMs在几乎所有设计自动解析或生成文本的任务上都是无可匹敌的。它们的应用场景几乎是无穷无尽的,随着我们不断创新和探索使用这些模型的新方法,它们有可能重塑我们我们与科技的关系,使的科技更容易对话互动、更加直观和更易于获取。

在本书中,我们将重点从头开始了解LLMs如何工作,并编写可以生成文本的LLM代码。除此之外,我们还将学习使用LLMs进行查询、从回答问题到总结文本、将文本翻译成不同的语言等技术。总之,这本书将通过一步一步构建来了解诸如ChatGPT这样的复杂LLM助手是如何工作的。

1.3 LLMs的构建和使用

我们为什么要构建自己的LLMs?从头开始编写LLM代码是了解其机制和局限性的绝佳手段。此外,它还为我们提供了所需的必要知识,以便根据我们自己的特定领域数据集或任务对现有的开源LLM架构进行预训练或微调。

研究表面,专为各种应用程序而设计的定制LLMs在模型性能方面可以胜过类似ChatGPT的通用LLMs。这方面的例子包括专门用于金融的 BloombergGPT,以及专为医疗问答构建的LLM(有关详细信息,请参阅附录B中的“参考文献和进一步阅读”部分)。

使用定制LLMs产品具有多种优势,尤其是在数据隐私方面。例如,出于保密考虑,公司可能不愿与 OpenAI 等第三方LLM提供商共享敏感数据。此外,开发定制LLMs可以直接部署在用户设备上,例如笔记本电脑和智能手机,这是苹果等公司目前正在探索的事情。这种本地部署可以显著降低延迟并降低与服务器相关的成本。进一步地,定制的LLMs可授权开发人员完全自主权,允许他们根据需要完全控制对模型的更新和修改。

创建的LLM一般过程包括预训练和微调。“预训练pretraining”中的术语“pre”是指在大量的、多样化的数据集上训练像LLM这样的模型以达到能对语言泛化理解的初级阶段。然后,这个预训练的模型将作为一个基础,可以通过微调进一步完善,在这个微调的过程中,模型在更窄的专用于特定任务或领域的专用数据集上进行专门训练。这种由预训练和微调组成的两阶段训练方法如图 1.3 所示。

图 1.3 图1.3 预训练一个LLM模型涉及在大量文本数据集上进行下一个单词的预测。一个预训练好的LLM模型可以在一个更小的有标签的数据集上进行微调。

如图 1.3 所示,创建一个LLM的第一步是在大型文本数据语料库(有时称为原始文本,raw text)上对其进行训练。在这里,“raw”是指这些数据只是没有任何标签信息的常规文本[1]。(可以进行一定的过滤,例如删除未知语言的格式字符或文档。)

LLM的第一个训练阶段也称之为预训练,用于创建初始的预训练模型,通常被称为基模型(base model)或者基础模型(foundation model)。一个典型例子是 GPT-3 模型(ChatGPT的前身)。该模型能够完成文本,即完成用户提供的写了一半的句子。它还具有有限的上样本学习的能力,这意味着它可以仅根据几个示例学习执行新任务,而不需要大量的训练数据。这将在下一节“Transformer架构简介”中进一步说明。

在使用大量文本数据集完成训练得到预训练好的LLM模型之后(LLM被训练用来预测文本中的下一个词),我们可以使用有标签的数据集进一步训练LLM,这种做法称为微调(finetuning)。

两个最流行的微调LLM的方法分别为:指令微调(instruction-finetuning)和分类任务的微调(finetuning for classification tasks)。在指令微调中,有标签的数据集由指令和回答对组成,例如用于翻译文本的原本以及正确翻译的文本这样的回答对。在分类微调中,有标签的数据集由文本和相关联的类别标签组成,例如,带有垃圾邮件和非垃圾邮件标签的电子邮件文本。

在本书中,我们将介绍预训练和微调的LLM代码实现,并且在预训练完基础LLM之后,我们将在本书后面更深入地研究指令微调和分类微调的细节。

1.4 Transformer架构介绍

大多数现代LLMs人都依赖于Transformer架构,这是2017年论文《Attention Is All You Need》中介绍的深度神经网络架构。为了理解LLMs,我们必须简要回顾一下最初的Transformer,它最初是为机器翻译而开发的,将英语文本翻译成德语和法语。Transformer架构的简化版本如图 1.4 所示。

图 1.4 图 1.4 最初的Transformer架构的简化图,一种用于语言翻译的深度学习模型。Transformer由两部分组成,一个编码器处理输入文本并生成文本的嵌入表示(一种在不同维度上捕获许多不同特征的数字化表示),解码器可以使用该表示来一次生成一个单词的翻译文本。注意看,此图展示了翻译过程的最后阶段,给定原始输入文本(“This is an example”)和部分翻译的句子(“Das ist ein”),解码器只需生成最后一个单词(“Beispiel”)即可完成翻译。

图 1.4 中描述的Transformer架构由两个子模块组成,一个编码器和一个解码器。编码器模块处理输入文本,并将其编码为一系列数字化表示或向量,以捕获输入的上下文信息。然后,解码器模块获取这些编码的向量并从中生成输出文本。例如,在翻译任务中,编码器将源语言中的文本编码为向量,解码器将对这些向量进行解码以生成目标语言的文本。编码器和解码器都由许多层组成,这些层通过所谓的自注意力机制(self-attention)连接起来。对于如何预处理和编码输入文本,你可能有很多疑问,这些将在后续章节中逐步实现。

Transformer及LLMs中的一个关键组件是自注意力机制(在图中未展示),它允许模型权衡序列中不同单词或tokens之间相对于彼此的重要性。这种机制使模型能够捕获输入数据中的长依赖关系和上下文关系,从而增强其生成连贯且上下文相关文本输出的能力。然而,由于其复杂性,我们将推迟到第3章才进行解释,在那里我们将逐步讨论并实现它。此外,我们还将在第2章“使用文本数据”中讨论和实现数据预处理步骤,以创建模型输入。

Transformer的后期变体,例如BERT(bidirectional encoder representations from transformers)和GPT(generative pretrained transformers)都基于上述介绍的概念构建的,以使Transformer这个架构能够适应不同的任务。(参考文献见附录B。)

BERT 建立在原始 Transformer 的编码器子模块之上,其训练方法与 GPT 不同。GPT是为生成任务而设计的,而BERT及其变体专门用于掩码词(指中间被盖住的词)预测,其中模型预测给定句子中的掩码词或隐藏词,如图 1.5 所示。这种独特的训练策略使 BERT 在文本分类任务(包括情感预测和文档分类)方面具有优势。在撰写本文时,Twitter使用BERT来检测有毒内容,是BERT应用的一个重要例子。

图 1.5 图 1.5 Transformer编码器和解码器子模块的可视化表示。在左侧是编码器,用于类似BERT的LLMs,它专注于掩码词预测,主要用于文本分类等任务。在右侧是解码器,用于类似GPT的LLMs,专为生成任务和生成连贯的文本序列而设计。

另一方面,GPT 专注于原始 Transformer 架构的解码器部分,专为需要生成文本的任务而设计,这包括机器翻译、文本摘要、小说写作、编写计算机代码等。我们将在本章的其余部分更详细地讨论 GPT 架构,并在本书中从头开始实现它。

GPT模型主要被设计和训练用来执行文本生成任务,其能力显示出显著的多功能性。这些模型擅长执行零样本和少样本学习任务。零样本学习是指在没有任何事先具体示例的情况下推广到完全没见过的任务的能力,另一方面,小样本学习指从用户提供的最小数量的示例作为输入进行学习的能力,如图 1.6 所示。

图 1.6 图 1.6 除了文本补全之外,类GPT的LLMs还可以根据各种不同任务的输入解决各种任务,而无需重新训练、微调或特定于任务的模型架构更改。有时,在输入中提供目标示例会很有帮助,这称为少样本设置(few-shot setting)。另外,类GPT 的LLMs也能够在没有特定示例的情况下执行任务,这称为零样本设置(zero-shot setting)。

Transformers与LLMs

今天的LLMs基于上一节中介绍的Transformer架构。因此,Transformers和LLMs是文献中经常使用的同义词。但是,请注意,并非所有Transformers都是这样LLMs,因为Transformers也可用于计算机视觉。此外,并非所有LLMs都是Transformers,因为有基于递归(RNN)和卷积(CNN)架构的大型语言模型,这些替代方法背后的主要动机是提高LLMs的计算效率。然而,这些替代的LLM架构是否能够与基于Transformer的LLMs的同台竞技,以及它们是否会在实践中被采用还有待观察。为简单起见,本书使用术语“LLM”来指代类似于 GPT 的基于transformer的LLMs。(有兴趣的读者可以在本章末尾的“参考文献和进一步阅读”部分找到描述这些架构的文献参考。)

1.5 使用大型数据集

目前流行的类GPT和类BERT模型所用到的的大型训练数据集,包含数十亿个单词的多样化和全面的文本语料库,其中涵盖大量主题的自然语言和计算机语言。为了提供一个具体的例子,表 1.1 总结了用于预训练 GPT-3 的数据集,该数据集是 ChatGPT 第一版的基础模型所用到的数据集。

表 1.1 目前受欢迎的GPT-3 LLM用到的预训练数据集
数据集名称 数据集说明 tokens数量 在训练数据集中的占比
CommonCrawl (filtered) 网页爬虫数据 4100亿 60%
WebText2 网页爬虫数据 190亿 22%
Books1 基于互联网的图书语料库 120亿 8%
Books2 基于互联网的图书语料库 550亿 8%
Wikipedia 高质量的文本 30亿 3%

表 1.1 报告了tokens的数量,其中token是模型读取的基本文本单位,数据集中的token数量大致等于文本中的单词数和标点符号数。我们将在下一章中更详细地介绍tokenization,即将文本转换为tokens的过程。

主要结论是,这个训练数据集的规模和多样性使这些模型能够在各种任务上表现良好,包括语言语法、语义和上下文,甚至一些需要通用知识的任务。

GPT-3 数据集详细信息

表 1.1 显示了用于 GPT-3 的数据集。表中的占比这一列对抽样数据的总和达 100%,并针对舍入误差进行了调整。尽管“tokens数量”这一列中的子集总数为 5090亿个,但该模型仅在 3000 亿个代币上进行了训练。GPT-3 论文的作者没有具体说明为什么该模型没有在所有 5090 亿个代币上进行训练。

对于文本内容,以CommonCrawl数据集为例,仅该数据集就包含4100亿个tokens,需要大约 570 GB 的存储空间。但相比之下,类GPT-3的后期迭代模型,例如Meta的LLaMA,已经扩大了它们的训练数据范围,包括额外的数据源,如 Arxiv 研究论文(92 GB) 和 StackExchange 的代码相关问答文本(78 GB)。

GPT-3 论文的作者没有分享训练数据集,但公开可用的可对比数据集是 Dolma: an Open Corpus of Three Trillion Tokens for LLM Pretraining Research,作者是Soldaini等人,2024年发表。但是,该集合可能包含受版权保护,确切的使用条款可能取决于预期的用法和国家/地区。

这些模型的预训练的本质是使它们具有令人难以置信的通用性,可用于进一步微调下游任务,这就是为什么它们也被称为基模型或基础模型的原因。预训练LLMs需要获得大量资源,而且非常昂贵。例如,GPT-3 预训练的成本估计为 460 万美元(按云计算计费积分计算)[2]。

好消息是,许多预训练LLMs的开源模型可以用作通用工具,用于编写、提取和编辑不属于训练数据的文本。此外,LLMs还可以在数据集相对较小的特定任务上进行微调,从而减少所需的计算资源并提高特定任务的性能。

在本书中,我们将实现用于预训练的代码,并使用它来预训练用于教育目的LLM。所有计算都可以在消费类硬件上执行。在实现预训练代码之后,我们将学习如何重用公开可用的模型权重,并将它们加载到我们将要实现的架构中,这样我们就可以在本书后面进行LLMs微调时跳过昂贵的预训练阶段。

1.6 深入理解GPT架构

在本章的前面,我们提到了类 GPT 模型、GPT-3 和 ChatGPT。现在让我们仔细看看通用的 GPT 架构。首先,GPT 表示 Generative Pretrained Transformer,最初是在以下论文中介绍的:

  • Improving Language Understanding by Generative Pre-Training (2018) by Radford et al. from OpenAI, http://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf

GPT-3 是该模型的放大版本,具有更多参数并在更大的数据集上进行训练。ChatGPT 提供的原始模型是通过使用 OpenAI 的 InstructGPT 论文中的方法在大型指令数据集上微调 GPT-3 而创建的,我们将在第 7 章“使用人类反馈进行微调以遵循人类指令”中更详细地介绍。正如我们在前面的图 1.6 中看到的,这些模型是能够胜任的已训练完成的模型,可以执行其他任务,例如拼写更正、分类或语言翻译。考虑到 GPT 模型是在相对简单的预测下一个单词这一任务上预训练的,这实际上是非常神奇的,如图 1.7 所示。

图 1.7 图 1.7 在 GPT 模型的预测下一个单词预训练任务中,系统通过查看前面的单词来学习预测句子中即将到来的单词。这种方法有助于模型理解单词和短语在语言中通常如何组合在一起,从而形成可应用于各种其他任务的基础。

下一个单词预测任务是自我监督学习的一种形式,是一种自我标记的形式。这意味着我们不需要显式收集训练数据的标签,而是可以利用数据本身的结构:我们可以使用句子或文档中的下一个单词作为模型应该预测的标签。由于这个下一个单词预测任务允许我们“动态”创建标签,因此可以利用大量未标记的文本数据集进行LLMs训练,如前面在第 1.5 节 “使用大型数据集”中所述。

与我们在第 1.4 节中介绍的原始 Transformer 架构相比,通用的 GPT 架构相对简单。本质上讲,它只是没有编码器的解码器部分,如图 1.8 所示。由于像 GPT 这样的解码器模型通过一次预测一个单词文本的方式来生成文本,因此它们被认为是一种自回归模型。自回归模型将其先前的输出合并为未来预测的输入。因此,在 GPT 中,每个新单词都是根据其前面的顺序来选择的,这提高了生成文本的连贯性。

GPT-3 等架构也比原来的 Transformer 模型大得多。例如,原始Transformer将编码器和解码器块重复六次。GPT-3 有 96 个Transformer层,总共有 1750 亿个参数。

图 1.8 图 1.8 GPT 架构仅使用原始Transformer的解码器部分。它专为单向、从左到右的文本处理而设计,非常适合文本生成和下一个单词预测任务,以迭代方式一次生成一个单词文本。

GPT-3 于 2020 年推出,按照深度学习和大型语言模型 (LLM) 开发的标准,它被认为是很久以前的事了。然而,最近的架构,如 Meta 的 Llama 模型,仍然基于相同的基本概念,仅仅只引入了微小的修改。因此,理解 GPT 仍然一如既往地重要,本书重点介绍实现 GPT 背后的突出架构,同时提供采用特定方式调整的替代LLMs方案。

最后,有趣的是,尽管由编码器和解码器块组成的原始 Transformer 模型是专门为语言翻译而设计的,但 GPT 模型——旨在预测下一个单词的更大但更简单的仅解码器架构的模型——也能够执行翻译任务。这种能力最初是研究人员出乎意料的,因为它来自一个主要针对下一个单词预测任务进行训练的模型,这是一项不专门针对翻译任务而训练的模型。

执行模型未明确训练而具备执行该任务的能力称为“emergent behavior”。这种能力在训练期间没有被明确训练,而是模型暴露在大量多语言数据的不同上下文中所产生的自然结果。GPT 模型可以“学习”语言之间的翻译模式并执行翻译任务,即使它们没有经过专门训练,这一事实证明了这些大规模生成式语言模型的优势和能力。我们可以执行不同的任务,而无需对每个任务使用不同的模型。

1.7 构建大语言模型

在本章中,我们为理解LLMs奠定基础。在本书的其余部分,我们将从头开始编写一个LLM代码。我们将以 GPT 背后的基本思想为蓝图,分三个阶段解决这个问题,如图 1.9 所示。

图 1.9 图 1.9 本书中涵盖的构建LLMs包括三个阶段:实现LLM架构和数据准备、预训练LLM以创建基础模型,以及微调基础模型以成为个人助理或文本分类器。

首先,我们将了解基本的数据预处理步骤,并编写每个 LLM的核心——注意力机制。

接下来,在第 2 阶段,我们将学习如何编码和预训练能够生成新文本的类似GPT 的 LLM 模型。我们还将介绍评估LLMs的基础知识,这对于开发可用的 NLP 系统至关重要。

请注意,从头开始预训练LLM是一项艰巨的任务,需要数千到数百万美元的计算成本来构建类似 GPT 的模型。因此,第 2 阶段的重点是使用小型数据集实施用于教育目的的模型训练。此外,本书还将提供用于加载公开可用的模型权重的示例代码。

最后,在第 3 阶段,我们将对预训练好的LLM进行微调,以实现诸如回答问题或对文本进行分类之类的任务——这是许多现实世界应用和研究中最常见的任务。

希望您期待踏上这段激动人心的旅程!

1.8 小结

  • LLMs改变了自然语言处理领域,该领域以前主要依赖于基于规则的显式系统和更简单的统计方法。LLMs引入了新的深度学习驱动方法,产生了在理解、生成和翻译人类语言方面的进步。
  • 训练现代LLMs分为两个主要步骤:
    • 首先,通过使用句子中下一个单词的预测值作为“标签”,对大量未标记的文本进行预训练。
    • 然后,在较小的有标签目标数据集上对它们进行微调,以实现对话或执行分类任务。
  • LLMs基于Transformer架构。Transformer 架构的关键思想是一种注意力机制,当一次生成一个字的输出时,LLM可以选择性地访问整个输入序列。
  • 最初的Transformer架构由一个用于解析文本的编码器和一个用于生成文本的解码器组成。
  • LLMs用于生成文本和执行指令,例如 GPT-3 和 ChatGPT,仅使用解码器模块,简化架构。
  • 由数十亿个单词组成的大型数据集对于预训练LLMs至关重要。在本书中,我们将出于教育目的在小型数据集上实现和训练LLMs,但也将了解如何加载公开可用的模型权重。
  • 虽然类 GPT 模型的一般预训练任务是预测句子中的下一个单词,但这些LLMs模型表现出“emergent”属性,例如也具备对文本进行分类、翻译或总结的能力。
  • 一旦LLM完成了预训练,就可以更有效地针对各种下游任务对生成的基础模型进行微调。
  • LLMs在自定义数据集上进行微调可以在特定任务上优于常规LLMs数据集。

[1] 具有机器学习背景的读者可能会注意到,传统的机器学习模型和通过传统监督学习范式训练的深度神经网络通常需要标签信息。但是,对于 的LLMs预训练阶段,情况并非如此。在此阶段,LLMs利用自监督学习,其中模型从输入数据生成的文本作为自己的标签。本章稍后将介绍此概念。

[2] GPT-3, The $4,600,000 Language Model, https://www.reddit.com/r/MachineLearning/comments/h0jwoz/d_gpt3_the_4600000_language_model/

Read More

AI工具盘点

曾伟 2024-07-13

简介:现在AI领域的发展真的很快,时常对跟上它的步伐感到力不从心。尤其是对其实现的原理,总是冒出很多新的名词、新的技术,比如RAG、COT等等。在这里,对我在日常(信息来源有抖音、知乎、小红书、B站、微信公众号和朋友介绍)中看到的一些别人做好的AI工具进行盘点,指不定哪天对自己有用,也让自己别落后太多。

AI工具聚合站

1.Latent Box

简评:这个我是在抖音上看到的,它的网页UI做的挺好看的,集合了一些论文和现成的工具,看着是一个Github项目发展来的,至少现在免费。对于目前各种各样的AI产品,我想过几年必然是每一个细分领域留下个别头部玩家,比如ChatGPT、Kimi等,我不需要全部都了解,况且它们的底层技术相差无几。我只需要会使用每一个细分赛道的最好的几个就够了。

2.Dreamina

我的简评:这个也是在抖音上看到的,这一个看页面已经比较成熟了,主要是图像、视频方面的AI。

AI课题组

1.AI Box

简评:中国人民大学高瓴人工智能学院赵鑫教授的课题组网站。我本科毕业那会,2020年左右,人大高瓴人工智能学院才刚刚成立,当时看到还想着考研来着,但一想一个文科学校AI不可能很强遂没考,谁想其已经成为中国AI大模型方面的重要力量,尤其是其发布的《A Survey of Large Language Models》综述更是引爆网络。哎,命运呀,总是这么捉摸不透,永远是:选择大于努力。但问题是做人生选择本身就很难呀!

AI专用工具

1.Transformer Explainer

简评:这是一个可视化Transformer,看着确实酷炫。

2.MagicEraser

简评:用来去掉照片中不需要的区域。

3.CodeFormer

简评:用来去马赛克,说实话,没有达到我的预期。

4.EMO

我的简评:用来让图片带动作地模仿说话的框架。

AI Agent专题

AI Agent是我目前最好看的方向,所以我要单独给它开一个专题。

Python库

一些我没见过但看着有用的Python库。

1.Taipy

简评:看着是一个可以把自己想法转成Web UI的库。

Read More

Nvidia GPU 工具使用盘点

曾伟 2024-07-10

简介:在现在的AI时代,多多少少都要接触到英伟达的GPU,它其实搞了很多的软件工具,有时候需要使用这些工具,因此有必要做一下总结,为自己梳理一下。

nvidia-smi

nvidia-smi是一个常用的命令,我时常不知道其输出的信息怎么看。

Read More

从他人成长故事获得启示

曾伟 2024-07-10

简介:有时候,看到一些比较曲折折腾但又成功的人生经历都会被感动。那些很顺利的,我只会心生妒忌,那些曲折但没成功的我会觉得就是跟我一样的普罗大众。So,我把看到的一些被感动的故事记录下来,给我启发和力量。

刘国平院士

刘国平院士

从中我得到三个启示:

  1. 要在一个领域坚持足够久。从这位老师博士毕业到现在有32年了,假设他30岁博士毕业现在差不多62岁。要相信,只要在一个领域坚持足够久,运气再差也能抓住几次机会吧。
  2. 树挪死,人挪活,通过跳槽来步步提升。从他博士后之后,每份工作分别在职3年、4年、3年、7年、9年、2年、3年。由此可见,他绝不是一个接受安逸的人,很多人可能包括我在内,都是在一处定下来了很可能就不会想动,因为家庭和人际关系摆在这,换一个地方需要处理这些事情。但是,他却能不断地抓住机会跳槽升级,可见,他非常重视事业,非常能折腾。有时候在想,社会就是在不断筛选,筛选能一直折腾、能持续干的人。像我这样的大多数人,都会在尝到了安逸的舒适之后就会选择不再持续努力了。
  3. 要出国。从他的轨迹来看,出国学习和工作是重要的跃迁力量,所以我必须学会英语,坚持走出国道路。国内不管干什么都太卷,且人均资源远远低于国外,因此务必要出国,打开自己的出路。

Read More