从零开始的大语言模型构建指南-全-

从零开始的大语言模型构建指南(全)

原文:zh.annas-archive.org/md5/50e5c3d523232e78df8d9d341a0eae11

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:理解大型语言模型

本章涵盖

  • 对大型语言模型(LLMs)背后基本概念的概述

  • 从 LLMs 中提取的 Transformer 架构的见解

  • 从零开始构建 LLM 的计划

大型语言模型(LLMs),如 OpenAI 的 ChatGPT 所提供的,是过去几年中开发的深度神经网络模型。它们为自然语言处理(NLP)带来了新时代。在 LLMs 出现之前,传统方法在分类任务上表现出色,例如电子邮件垃圾邮件分类和可以用手工规则或简单模型捕获的简单模式识别。然而,它们通常在需要复杂理解和生成能力的语言任务上表现不佳,例如解析详细指令、进行上下文分析和创建连贯且上下文适当的原创文本。例如,上一代语言模型无法根据关键词列表撰写电子邮件——对于当代 LLMs 来说,这是一个微不足道的小任务。

LLMs 在理解、生成和解释人类语言方面具有非凡的能力。然而,重要的是要明确,当我们说语言模型“理解”时,我们是指它们能够以看似连贯和上下文相关的方式处理和生成文本,而不是说它们拥有类似人类的意识或理解力。

在深度学习进步的推动下,深度学习是机器学习和人工智能(AI)的一个子集,专注于神经网络,LLMs 在大量文本数据上进行训练。这种大规模训练使得 LLMs 能够捕捉到比以前的方法更深入的语言语境信息和细微差别。因此,LLMs 在包括文本翻译、情感分析、问答等在内的广泛 NLP 任务上的性能得到了显著提升。

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

LLMs(大型语言模型)的成功可以归因于支撑许多 LLMs 的 Transformer 架构以及 LLMs 训练所依赖的庞大数据量,这使得它们能够捕捉到广泛的语言细微差别、语境和模式,这些内容手动编码将极具挑战性。

这种转向基于 Transformer 架构实现模型并使用大型训练数据集来训练 LLMs 的转变,从根本上改变了自然语言处理(NLP),为理解和交互人类语言提供了更强大的工具。

以下讨论为完成本书的主要目标奠定了基础:通过逐步编写代码,实现一个类似 ChatGPT 的基于 transformer 架构的 LLM,以理解 LLM。

1.1 什么是 LLM?

LLM 是一种神经网络,旨在理解、生成和回应类似人类的文本。这些模型是在大量文本数据上训练的深度神经网络,有时甚至包括整个互联网上可公开获取的大量文本。

“large”在“large language model”中既指模型的参数大小,也指其训练所用的庞大数据集。这类模型通常有数十亿甚至数百亿个参数,这些参数是网络中的可调整权重,在训练过程中被优化以预测序列中的下一个单词。预测下一个单词是有意义的,因为它利用了语言的内在序列性质,以训练模型理解文本中的上下文、结构和关系。这是一个非常简单的任务,因此许多研究人员对此感到惊讶,它能够产生如此强大的模型。在后面的章节中,我们将逐步讨论和实现下一个单词的训练过程。

LLMs 使用一种称为transformer的架构,这使得它们在做出预测时能够对输入的不同部分进行选择性关注,使它们特别擅长处理人类语言的细微差别和复杂性。

由于 LLMs 能够生成文本,它们也常被称为一种生成式人工智能的形式,通常简称为生成 AIGenAI。如图 1.1 所示,AI 涵盖了更广泛的领域,即创建能够执行需要类似人类智能的任务的机器,包括理解语言、识别模式和做出决策,包括机器学习和深度学习等子领域。

figure

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

用于实现人工智能的算法是机器学习领域的焦点。具体来说,机器学习涉及开发能够从数据中学习并基于数据做出预测或决策的算法,而不需要明确编程。为了说明这一点,想象一下垃圾邮件过滤器作为机器学习的一个实际应用。不是手动编写规则来识别垃圾邮件,而是将标记为垃圾邮件和合法邮件的电子邮件示例输入到机器学习算法中。通过最小化其在训练数据集上的预测错误,模型随后学会识别表明垃圾邮件的模式和特征,使其能够将新电子邮件分类为垃圾邮件或非垃圾邮件。

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

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

回到垃圾邮件分类的例子,在传统的机器学习中,人类专家可能会手动从电子邮件文本中提取特征,例如某些触发词(例如,“奖品”、“赢”、“免费”)的频率,感叹号的数量,使用全部大写字母的单词,或者可疑链接的存在。这个基于这些专家定义的特征创建的数据集,随后会被用来训练模型。与传统的机器学习相比,深度学习不需要手动提取特征。这意味着人类专家不需要为深度学习模型识别和选择最相关的特征。(然而,传统的机器学习和深度学习用于垃圾邮件分类仍然需要收集标签,如垃圾邮件或非垃圾邮件,这些标签需要由专家或用户收集。)

让我们来看看 LLMs 今天可以解决的问题、LLMs 解决的挑战,以及我们稍后将要实现的通用 LLM 架构。

1.2 LLMs 的应用

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

figure

图 1.2 LLM 接口使用户与 AI 系统之间能够进行自然语言交流。此截图显示了 ChatGPT 根据用户的要求写诗。

LLM 还可以为复杂的聊天机器人和虚拟助手提供动力,例如 OpenAI 的 ChatGPT 或 Google 的 Gemini(以前称为 Bard),它们可以回答用户查询并增强传统的搜索引擎,如 Google 搜索或 Microsoft Bing。

此外,LLM 还可以用于从大量文本中有效地检索特定领域(如医学或法律)的知识。这包括筛选文档、总结长篇段落和回答技术问题。

简而言之,LLM 对于自动化几乎任何涉及解析和生成文本的任务都非常有价值。它们的应用几乎无限,随着我们继续创新和探索使用这些模型的新方法,很明显,LLM 有潜力重新定义我们与技术的关系,使其更加对话式、直观和易于访问。

我们将专注于从底层理解 LLM 的工作原理,编写一个可以生成文本的 LLM。你还将了解允许 LLM 执行查询的技术,这些查询从回答问题到总结文本,将文本翻译成不同语言等。换句话说,你将通过逐步构建来了解复杂的 LLM 助手(如 ChatGPT)是如何工作的。

1.3 构建和使用 LLM 的阶段

为什么你应该构建自己的 LLM?从头开始编写 LLM 是一个很好的练习,可以了解其机制和限制。此外,它使我们具备了预训练或微调现有开源 LLM 架构以适应我们自己的特定领域数据集或任务所需的知识。

注意:目前大多数大型语言模型(LLM)都是使用 PyTorch 深度学习库实现的,这也是我们将要使用的库。读者可以在附录 A 中找到 PyTorch 的全面介绍。

研究表明,在建模性能方面,定制构建的 LLM(针对特定任务或领域量身定制的)可以优于通用 LLM,例如为广泛应用而设计的 ChatGPT。这些例子包括为金融定制的 BloombergGPT 和为医疗问答定制的 LLM(详情见附录 B)。

使用定制构建的 LLM 提供了几个优势,尤其是在数据隐私方面。例如,由于保密问题,公司可能不愿意与像 OpenAI 这样的第三方 LLM 提供商共享敏感数据。此外,开发更小、定制的 LLM 可以直接在客户设备(如笔记本电脑和智能手机)上部署,这是苹果公司目前正在探索的事情。这种本地实现可以显著降低延迟并减少与服务器相关的成本。此外,定制 LLM 赋予开发者完全的自主权,使他们能够根据需要控制模型的更新和修改。

创建 LLM 的一般过程包括预训练和微调。“预”在“预训练”中指的是模型(如 LLM)在一个大而多样化的数据集上训练,以发展对语言的广泛理解。这个预训练模型随后作为基础资源,可以通过微调进一步精炼,微调是一个模型在更具体于特定任务或领域的较窄数据集上特定训练的过程。这种由预训练和微调组成的两阶段训练方法在图 1.3 中展示。

figure

图 1.3 预训练 LLM 涉及在大文本数据集上进行下一个单词的预测。预训练的 LLM 可以使用较小的标记数据集进行微调。

创建 LLM 的第一步是在大量文本数据集上对其进行训练,有时这些数据被称为 原始 文本。在这里,“原始”指的是这些数据仅仅是常规文本,没有任何标签信息。(可能应用过滤,例如删除格式化字符或未知语言的文档。)

备注  具有机器学习背景的读者可能会注意到,对于传统的机器学习模型和通过传统监督学习范式训练的深度神经网络,通常需要标签信息。但对于 LLM 的预训练阶段来说并非如此。在这个阶段,LLM 使用自监督学习,其中模型从输入数据中生成自己的标签。

LLM 的第一个训练阶段也被称为 预训练,即创建一个初始的预训练 LLM,通常称为 基础基础 模型。这类模型的典型例子是 GPT-3 模型(ChatGPT 中提供的原始模型的先驱)。该模型能够完成文本补全——即根据用户提供的半句话完成句子。它还具有有限的少样本能力,这意味着它可以通过仅几个示例来学习执行新任务,而不需要大量的训练数据。

通过在大文本数据集上训练,获得预训练的 LLM,其中 LLM 被训练来预测文本中的下一个单词,我们可以在标记数据上进一步训练 LLM,这被称为 微调

微调 LLM 的最流行的两种类别是 指令微调分类 微调。在指令微调中,标记数据集由指令和答案对组成,例如一个翻译文本的查询及其正确翻译的文本。在分类微调中,标记数据集由文本及其相关的类别标签组成——例如与“垃圾邮件”和“非垃圾邮件”标签相关的电子邮件。

我们将介绍预训练和微调 LLM 的代码实现,并在预训练基础 LLM 之后,更深入地探讨指令和分类微调的细节。

1.4 介绍 Transformer 架构

大多数现代 LLM 都依赖于变压器架构,这是一种在 2017 年论文“Attention Is All You Need”中引入的深度神经网络架构(arxiv.org/abs/1706.03762)。为了理解 LLM,我们必须理解原始的变压器,它是为机器翻译开发的,用于将英语文本翻译成德语和法语。变压器架构的简化版本在图 1.4 中展示。

figure

图 1.4 展示了原始变压器架构的简化表示,这是一个用于语言翻译的深度学习模型。变压器由两部分组成:(a)一个编码器,它处理输入文本并生成文本的嵌入表示(一个在多个维度上捕捉许多不同因素的数值表示),(b)解码器可以使用它来逐词生成翻译文本。此图显示了翻译过程的最后阶段,其中解码器必须根据原始输入文本(“这是一个例子”)和部分翻译的句子(“Das ist ein”),仅生成最后的单词(“Beispiel”),以完成翻译。

变压器架构由两个子模块组成:一个编码器和一个解码器。编码器模块处理输入文本并将其编码成一系列数值表示或向量,这些向量捕捉了输入的上下文信息。然后,解码器模块接收这些编码向量并生成输出文本。例如,在翻译任务中,编码器会将源语言的文本编码成向量,解码器会将这些向量解码以生成目标语言的文本。编码器和解码器都由许多层组成,这些层通过所谓的自注意力机制相互连接。您可能对输入是如何预处理和编码的有很多疑问。这些问题将在后续章节的逐步实现中解决。

变压器和 LLM 的关键组成部分是自注意力机制(未展示),该机制允许模型根据彼此的重要性对序列中的不同单词或标记进行加权。这种机制使模型能够捕捉输入数据中的长距离依赖关系和上下文关系,从而增强其生成连贯且上下文相关的输出的能力。然而,由于其复杂性,我们将进一步解释推迟到第三章,在那里我们将逐步讨论和实现它。

变压器架构的后续变体,如 BERT(代表双向编码器表示来自变压器)和不同的 GPT 模型(代表生成预训练变压器),基于这个概念来适应不同的任务。如果您感兴趣,请参阅附录 B 以获取进一步阅读的建议。

BERT,它建立在原始变压器编码器子模块之上,其训练方法与 GPT 不同。虽然 GPT 是为生成任务设计的,但 BERT 及其变体专注于掩码词预测,即模型预测给定句子中的掩码或隐藏词,如图 1.5 所示。这种独特的训练策略使 BERT 在文本分类任务中具有优势,包括情感预测和文档分类。作为其能力的一种应用,截至本文撰写时,X(前身为 Twitter)使用 BERT 来检测有害内容。

figure

图 1.5 展示了变压器编码器和解码器子模块的视觉表示。在左侧,编码器部分以 BERT 类似的 LLM 为例,这些模型专注于掩码词预测,主要用于文本分类等任务。在右侧,解码器部分展示了类似 GPT 的 LLM,这些模型设计用于生成任务,并产生连贯的文本序列。

另一方面,GPT 专注于原始变压器架构的解码器部分,并设计用于需要生成文本的任务。这包括机器翻译、文本摘要、小说写作、编写计算机代码等。

GPT 模型,主要是设计和训练来完成文本补全任务,也在其能力上表现出显著的通用性。这些模型擅长执行零样本和少样本学习任务。零样本学习指的是在没有任何先前特定示例的情况下,将知识泛化到完全未见过的新任务的能力。另一方面,少样本学习涉及从用户提供的最少数量示例中学习,如图 1.6 所示。

figure

除了文本补全之外,类似 GPT 的 LLM 还可以根据其输入解决各种任务,而无需重新训练、微调或特定任务模型架构的改变。有时在输入中提供目标示例是有帮助的,这被称为少样本设置。然而,类似 GPT 的 LLM 也能够在没有特定示例的情况下执行任务,这被称为零样本设置。
变压器与 LLM 对比

今天的 LLM 是基于 Transformer 架构的。因此,在文献中,Transformer 和 LLM 是经常互换使用的术语。然而,请注意,并非所有 Transformer 都是 LLM,因为 Transformer 也可以用于计算机视觉。同样,并非所有 LLM 都是 Transformer,因为还有基于循环和卷积架构的 LLM。这些替代方法背后的主要动机是提高 LLM 的计算效率。这些替代 LLM 架构是否能够与基于 Transformer 的 LLM 的能力相竞争,以及它们是否将在实践中被采用,还有待观察。为了简单起见,我使用“LLM”一词来指代类似于 GPT 的基于 Transformer 的 LLM。(感兴趣的读者可以在附录 B 中找到描述这些架构的文献参考。)

1.5 利用大型数据集

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

表 1.1 流行的大型 GPT-3 LLM 预训练数据集
数据集名称 数据集描述 标记数量 训练数据中的比例
CommonCrawl (filtered) 网络爬虫数据 4100 亿 60%
WebText2 网络爬虫数据 190 亿 22%
Books1 基于互联网的书籍语料库 120 亿 8%
Books2 基于互联网的书籍语料库 550 亿 8%
Wikipedia 高质量文本 30 亿 3%

表 1.1 报告了标记的数量,其中标记是模型读取的文本单元,数据集中的标记数量大致相当于文本中的单词和标点符号的数量。第二章讨论了分词,即将文本转换为标记的过程。

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

GPT-3 数据集详情

表 1.1 展示了用于 GPT-3 的数据集。表中比例列的总和为样本数据的 100%,经过四舍五入误差调整。尽管标记数量列的子集总数为 4990 亿,但模型仅训练了 3000 亿个标记。GPT-3 论文的作者没有说明为什么模型没有训练所有 4990 亿个标记。

为了提供上下文,考虑一下 CommonCrawl 数据集的大小,它本身包含 4100 亿个标记,需要大约 570 GB 的存储空间。相比之下,GPT-3 等模型的后续版本,如 Meta 的 LLaMA,已经扩大了它们的训练范围,包括额外的数据源,如 Arxiv 研究论文(92 GB)和 StackExchange 的代码相关问答(78 GB)。

GPT-3 论文的作者没有分享训练数据集,但一个公开可用的类似数据集是 Soldaini 等人 2024 年的论文“Dolma:用于 LLM 预训练研究的三万亿标记开放语料库”(arxiv.org/abs/2402.00159)。然而,该集合可能包含受版权保护的作品,具体的用法条款可能取决于预期的用例和国家。

这些模型的预训练特性使它们在下游任务上的进一步微调方面具有极大的灵活性,这也是为什么它们也被称为基础或基础模型。预训练 LLM 需要访问大量资源,并且成本非常高。例如,GPT-3 的预训练成本据估计为 460 万美元的云计算信用额(mng.bz/VxEW)。

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

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

1.6 深入了解 GPT 架构

GPT 最初由 OpenAI 的 Radford 等人发表的论文“通过生成预训练改进语言理解”(mng.bz/x2qg)中介绍。GPT-3 是这个模型的扩展版本,具有更多参数,并在更大的数据集上进行了训练。此外,ChatGPT 中提供的原始模型是通过在大型指令数据集上使用 OpenAI 的 InstructGPT 论文中的方法微调 GPT-3 创建的(arxiv.org/abs/2203.02155)。如图 1.6 所示,这些模型是胜任文本补全模型,并能执行其他任务,如拼写校正、分类或语言翻译。考虑到 GPT 模型是在相对简单的下一个单词预测任务上进行预训练的,如图 1.7 所示,这实际上是非常了不起的。

figure

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

逐词预测任务是一种自我监督学习的形式,这是一种自我标记的形式。这意味着我们不需要明确收集训练数据的标签,但可以使用数据本身的结构:我们可以使用句子或文档中的下一个词作为模型应该预测的标签。由于这个逐词预测任务允许我们“即时”创建标签,因此可以使用大量的未标记文本数据集来训练 LLMs。

与我们在 1.4 节中介绍的原始 Transformer 架构相比,一般的 GPT 架构相对简单。本质上,它只是没有编码器的解码器部分(图 1.8)。由于像 GPT 这样的解码器风格模型通过逐词预测文本来生成文本,因此它们被认为是自回归模型。自回归模型将它们的先前输出作为未来预测的输入。因此,在 GPT 中,每个新词的选择都是基于其前面的序列,这提高了生成文本的连贯性。

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

figure

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

GPT-3 于 2020 年推出,按照深度学习和大型语言模型发展的标准,这已经是很久以前了。然而,更近期的架构,如 Meta 的 Llama 模型,仍然基于相同的基本概念,只是引入了细微的修改。理解 GPT 仍然非常相关。我专注于实现 GPT 背后的突出架构,并提供指向替代 LLMs 使用的特定调整的指针。

尽管原始的 Transformer 模型,由编码器和解码器块组成,是专门为语言翻译设计的,但 GPT 模型——尽管它们的架构更大但更简单,仅包含用于逐词预测的解码器——也能够执行翻译任务。这种能力最初让研究人员感到意外,因为它来自一个主要针对逐词预测任务训练的模型,而逐词预测任务并不是专门针对翻译的任务。

能够执行模型未明确训练的任务的能力被称为涌现行为。这种能力在训练期间并未明确教授,而是作为模型在多种语境下接触大量多语言数据的自然结果而出现。GPT 模型能够“学习”语言之间的翻译模式并执行翻译任务,即使它们并未为此专门训练,这也展示了这些大规模生成语言模型的好处和能力。我们可以执行各种任务,而无需为每个任务使用不同的模型。

1.7 构建大型语言模型

现在我们已经为理解 LLM 奠定了基础,让我们从头开始编写一个 LLM。我们将以 GPT 背后的基本理念为蓝图,并按照图 1.9 中概述的三阶段来解决这个问题。

figure

图 1.9 编写 LLM 的三个主要阶段是实施 LLM 架构和数据准备过程(阶段 1),预训练 LLM 以创建基础模型(阶段 2),以及微调基础模型以成为个人助理或文本分类器(阶段 3)。

在阶段 1,我们将学习基本的数据预处理步骤,并编写每个 LLM 核心的注意力机制代码。接下来,在阶段 2,我们将学习如何编写和预训练一个类似于 GPT 的 LLM,该 LLM 能够生成新的文本。我们还将回顾评估 LLM 的基本原理,这对于开发有能力的 NLP 系统至关重要。

从头开始预训练 LLM 是一项重大努力,对于类似于 GPT 的模型,需要数千到数百万美元的计算机成本。因此,阶段 2 的重点是使用小数据集实现用于教育目的的训练。此外,我还提供了加载公开可用的模型权重的代码示例。

最后,在阶段 3,我们将使用预训练的 LLM,并对其进行微调以遵循指令,如回答查询或对文本进行分类——这是许多现实应用和研究中最常见的任务。

我希望你们都在期待开始这段激动人心的旅程!

摘要

  • LLM 已经改变了自然语言处理领域,该领域以前主要依赖于基于显式规则的系统和更简单的统计方法。LLM 的出现引入了新的深度学习驱动方法,这些方法导致了在理解、生成和翻译人类语言方面的进步。

  • 现代大型语言模型(LLM)的训练分为两个主要步骤:

    • 首先,它们通过使用句子中下一个单词的预测作为标签,在大规模未标记文本语料库上进行预训练。

    • 然后,它们在较小、标记的目标数据集上进行微调,以遵循指令或执行分类任务。

  • LLM 基于 transformer 架构。transformer 架构的关键思想是在生成输出时,通过注意力机制使 LLM 能够选择性地访问整个输入序列。

  • 原始的 Transformer 架构包括用于解析文本的编码器以及用于生成文本的解码器。

  • 用于生成文本和遵循指令的 LLM,如 GPT-3 和 ChatGPT,仅实现了解码模块,简化了架构。

  • 由数十亿单词组成的大型数据集对于预训练 LLM 至关重要。

  • 虽然 GPT 类模型的通用预训练任务是预测句子中的下一个单词,但这些 LLM 表现出涌现性质,例如具有分类、翻译或总结文本的能力。

  • 一旦 LLM 完成预训练,所得到的基座模型可以更高效地针对各种下游任务进行微调。

  • 在自定义数据集上微调的 LLM 可以在特定任务上优于通用 LLM。

第二章:处理文本数据

本章涵盖了

  • 为大型语言模型训练准备文本

  • 将文本分割成单词和子词标记

  • 字节对编码作为更高级的文本标记方法

  • 使用滑动窗口方法采样训练示例

  • 将标记转换为输入大型语言模型的向量

到目前为止,我们已经介绍了大型语言模型(LLMs)的一般结构,并了解到它们是在大量文本上预训练的。具体来说,我们的重点是基于 transformer 架构的仅解码器 LLMs,这是 ChatGPT 和其他流行 GPT-like LLMs 所使用的模型的基础。

在预训练阶段,LLMs 逐个单词处理文本。使用数百万到数十亿参数的下一个单词预测任务训练 LLMs,可以得到具有令人印象深刻能力的模型。然后,这些模型可以进一步微调以遵循一般指令或执行特定目标任务。但在我们能够实现和训练 LLMs 之前,我们需要准备训练数据集,如图 2.1 所示。

figure

图 2.1 编码 LLM 的三个主要阶段。本章重点介绍第一阶段的第一步:实现数据采样管道。

你将学习如何为训练 LLMs 准备输入文本。这涉及到将文本分割成单个单词和子词标记,然后可以将这些标记编码成 LLM 的向量表示。你还将了解诸如字节对编码等高级标记方案,这些方案在 GPT 等流行 LLMs 中得到应用。最后,我们将实现一个采样和数据加载策略,以生成训练 LLMs 所需的输入-输出对。

2.1 理解词嵌入

深度神经网络模型,包括 LLMs,不能直接处理原始文本。由于文本是分类的,它不兼容用于实现和训练神经网络所使用的数学运算。因此,我们需要一种方法将单词表示为连续值的向量。

注意:不熟悉计算环境中向量和张量的读者可以在附录 A 的第 A.2.2 节中了解更多信息。

将数据转换为向量格式的概念通常被称为嵌入。使用特定的神经网络层或另一个预训练的神经网络模型,我们可以嵌入不同类型的数据——例如,视频、音频和文本,如图 2.2 所示。然而,需要注意的是,不同的数据格式需要不同的嵌入模型。例如,为文本设计的嵌入模型不适合嵌入音频或视频数据。

figure

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

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

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

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

figure

图 2.3 如果词嵌入是二维的,我们可以将其绘制在二维散点图中进行可视化,如图所示。当使用 Word2Vec 等词嵌入技术时,对应于相似概念的单词在嵌入空间中通常彼此靠近。例如,不同类型的鸟在嵌入空间中的距离比在国家和城市中的距离更近。

词嵌入可以有不同维度,从一到数千。更高的维度可能能够捕捉更细微的关系,但会以计算效率为代价。

当我们使用预训练模型如 Word2Vec 为机器学习模型生成嵌入时,LLMs 通常会产生自己的嵌入,这些嵌入是输入层的一部分,并在训练过程中更新。将嵌入作为 LLM 训练的一部分进行优化,而不是使用 Word2Vec 的优势在于,嵌入被优化以适应特定任务和现有数据。我们将在本章后面实现这样的嵌入层。(LLMs 还可以创建上下文化的输出嵌入,如我们在第三章中讨论的。)

不幸的是,高维嵌入对可视化构成了挑战,因为我们的感官感知和常见的图形表示本质上限于三维或更少,这就是为什么图 2.3 展示了二维嵌入在二维散点图中的原因。然而,当与 LLM(大型语言模型)一起工作时,我们通常使用更高维度的嵌入。对于 GPT-2 和 GPT-3,嵌入大小(通常被称为模型隐藏状态的维度)根据具体的模型变体和大小而变化。这是性能和效率之间的权衡。最小的 GPT-2 模型(117M 和 125M 参数)使用 768 维的嵌入大小来提供具体示例。最大的 GPT-3 模型(175B 参数)使用 12,288 维的嵌入大小。

接下来,我们将介绍为 LLM 准备嵌入所需的步骤,包括将文本分割成单词、将单词转换为标记以及将标记转换为嵌入向量。

2.2 文本分词

让我们讨论如何将输入文本分割成单个标记,这是为 LLM 创建嵌入所需的预处理步骤。这些标记可以是单个单词或特殊字符,包括标点符号,如图 2.4 所示。

figure

图 2.4 在 LLM 的上下文中查看文本处理步骤。在这里,我们将输入文本分割成单个标记,这些标记可以是单词或特殊字符,例如标点符号。

我们将为 LLM 训练进行分词的文本是“Verdict”,这是伊迪丝·华顿的短篇小说,已经进入公有领域,因此可以用于 LLM 训练任务。该文本可在维基源en.wikisource.org/wiki/The_Verdict上找到,您可以将它复制并粘贴到文本文件中,我将它复制到了名为“the-verdict.txt”的文本文件中。

或者,您可以在本书的 GitHub 仓库mng.bz/Adng中找到这个"the-verdict.txt"文件。您可以使用以下 Python 代码下载该文件:

import urllib.request
url = ("https://raw.githubusercontent.com/rasbt/"
       "LLMs-from-scratch/main/ch02/01_main-chapter-code/"
       "the-verdict.txt")
file_path = "the-verdict.txt"
urllib.request.urlretrieve(url, file_path)

接下来,我们可以使用 Python 的标准文件读取实用工具加载the-verdict.txt文件。

列表 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])

打印命令打印了该文件的总字符数,以及为了说明目的的前 99 个字符:

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 个字符的短篇小说分解成单个单词和特殊字符,然后我们可以将其转换为 LLM 训练的嵌入。

注意:当与 LLM 一起工作时,通常需要处理数百万篇文章和数十万本书——许多 GB 的文本。然而,出于教育目的,使用较小的文本样本(如一本书)就足够了,以说明文本处理步骤背后的主要思想,并使其能够在消费级硬件上合理时间内运行。

我们如何最好地分割这段文本以获得一个标记列表?为此,我们进行了一次小旅行,并使用 Python 的正则表达式库re进行说明。(您不必学习或记住任何正则表达式语法,因为我们稍后将过渡到预构建的分词器。)

使用一些简单的示例文本,我们可以使用以下语法的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.']

这个简单的分词方案主要适用于将示例文本分割成单个单词;然而,一些单词仍然与我们要作为单独列表条目保留的标点符号相连。我们也不将所有文本转换为小写,因为大小写有助于大型语言模型区分专有名词和普通名词,理解句子结构,并学会生成正确大小写的文本。

让我们修改空白字符(\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', '.']

注意:当开发一个简单的分词器时,我们是否应该将空白字符编码为单独的字符或者只是简单地移除它们,这取决于我们的应用及其需求。移除空白字符可以减少内存和计算需求。然而,如果我们训练对文本精确结构敏感的模型(例如,对缩进和间距敏感的 Python 代码),保留空白字符可能是有用的。在这里,我们为了简单和分词输出的简洁性移除了空白字符。稍后,我们将切换到一个包括空白字符的分词方案。

我们在这里设计的分词方案在简单的样本文本上运行良好。让我们进一步修改它,使其也能处理其他类型的标点符号,例如问号、引号以及我们在爱迪丝·华顿短篇小说前 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 中总结的结果,我们可以看到,我们的分词方案现在可以成功处理文本中的各种特殊字符。

figure

图 2.5 我们迄今为止实现的分词方案将文本分割成单个单词和标点符号。在这个特定示例中,样本文本被分割成 10 个单独的标记。

现在我们有一个基本的分词器正在工作,让我们将其应用于爱迪丝·华顿的整个短篇小说:

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

这个打印语句输出4690,这是文本中的标记数量(不含空白字符)。让我们打印前 30 个标记以进行快速视觉检查:

print(preprocessed[:30])

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

['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 将标记转换为标记 ID

接下来,让我们将这些标记从 Python 字符串转换为整数表示,以生成标记 ID。这种转换是在将标记 ID 转换为嵌入向量之前的中间步骤。

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

figure

图 2.6 我们通过将训练数据集中的整个文本标记化成单个标记来构建词汇表。然后,这些单个标记按字母顺序排序,并删除重复的标记。然后,唯一的标记被汇总到一个词汇表中,该词汇表定义了从每个唯一标记到唯一整数值的映射。为了简化,图中展示的词汇表故意很小,且不包含标点符号或特殊字符。

现在我们已经将伊迪丝·华顿的短篇小说进行了标记化,并将其分配给一个名为preprocessed的 Python 变量,让我们创建一个包含所有唯一标记的列表,并按字母顺序排序,以确定词汇表的大小:

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

通过这段代码确定词汇表大小为 1,130 后,我们创建了词汇表,并打印出其前 51 个条目以供说明。

列表 2.2 创建词汇表
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)

如我们所见,这个字典包含与唯一整数标签关联的单个标记。我们的下一个目标是应用这个词汇表将新文本转换为标记 ID(图 2.7)。

figure

图 2.7 从一个新的文本样本开始,我们对文本进行标记化,并使用词汇表将文本标记转换为标记 ID。这个词汇表是由整个训练集构建的,可以应用于训练集本身和任何新的文本样本。为了简化,图中展示的词汇表不包含标点符号或特殊字符。

当我们想要将一个语言模型(LLM)的输出从数字转换回文本时,我们需要一种方法将标记 ID 转换成文本。为此,我们可以创建一个词汇表的逆版本,它将标记 ID 映射回相应的文本标记。

让我们在 Python 中实现一个完整的标记化器类,它包含一个encode方法,该方法将文本分割成标记,并通过词汇表执行字符串到整数的映射,以生成标记 ID。此外,我们还将实现一个decode方法,它执行反向的整数到字符串映射,将标记 ID 转换回文本。以下列表展示了这个标记化器实现的代码。

列表 2.3 实现简单的文本标记化器
class SimpleTokenizerV1:
    def __init__(self, vocab):
        self.str_to_int = vocab            #1
        self.int_to_str = {i:s for s,i in vocab.items()}        #2

    def encode(self, text):         #3
        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):         #4
        text = " ".join([self.int_to_str[i] for i in ids]) 

        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)    #5
        return text

1 将词汇表作为类属性存储,以便在encodedecode方法中访问

2 创建一个逆词汇表,将标记 ID 映射回原始文本标记

3 将输入文本处理成标记 ID

4 将标记 ID 转换回文本

5 删除指定标点符号前的空格

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

figure

图 2.8 令牌化器实现共享两种常见方法:一种编码方法和一种解码方法。编码方法接收样本文本,将其分割成单个标记,并通过词汇表将这些标记转换为标记 ID。解码方法接收标记 ID,将它们转换回文本标记,并将文本标记连接成自然文本。

让我们从SimpleTokenizerV1类中实例化一个新的令牌化器对象,并从伊迪丝·华顿的短篇小说中标记一段文本来实际尝试:

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

前面的代码打印了以下标记 ID:

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

接下来,让我们看看我们是否可以使用解码方法将这些标记 ID 转换回文本:

print(tokenizer.decode(ids))

这将输出:

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

根据这个输出,我们可以看到解码方法成功地将标记 ID 转换回原始文本。

到目前为止,一切顺利。我们实现了一个令牌化器,能够根据训练集的片段对文本进行标记化和反标记化。现在,让我们将其应用于训练集中未包含的新文本样本:

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

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

KeyError: 'Hello'

问题在于单词“Hello”没有在“The Verdict”短故事中使用。因此,它不包含在词汇表中。这突出了在处理大型语言模型(LLMs)时考虑大型和多样化的训练集以扩展词汇表的需求。

接下来,我们将进一步测试令牌化器在包含未知单词的文本上的表现,并讨论在训练 LLM 期间可以使用的其他特殊标记,以提供更多上下文。

2.4 添加特殊上下文标记

我们需要修改令牌化器以处理未知单词。我们还需要解决特殊上下文标记的使用和添加,这些标记可以增强模型对文本中上下文或其他相关信息的理解。这些特殊标记可以包括未知单词和文档边界的标记,例如。特别是,我们将修改词汇表和令牌化器SimpleTokenizerV2,以支持两个新标记<|unk|><|endoftext|>,如图 2.9 所示。

figure

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

我们可以修改分词器,使其在遇到不在词汇表中的单词时使用<|unk|>标记。此外,我们在无关文本之间添加一个标记。例如,当在多个独立文档或书籍上训练类似 GPT 的 LLM 时,通常在跟随前一个文本源之后的每个文档或书籍之前插入一个标记,如图 2.10 所示。这有助于 LLM 理解,尽管这些文本源在训练时被拼接在一起,但它们实际上是无关的。

图

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

现在我们修改词汇表以包括这两个特殊标记<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()))

根据这个打印语句的输出,新的词汇表大小是 1,132(之前的词汇表大小是 1,130)。

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

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

代码打印

('younger', 1127)
('your', 1128)
('yourself', 1129)
('<|endoftext|>', 1130)
('<|unk|>', 1131)

根据代码输出,我们可以确认这两个新的特殊标记确实成功融入了词汇表。接下来,我们根据以下列表相应地调整分词器。

列表 2.4 一个简单的文本分词器,可以处理未知单词
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            #1
                        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)    #2
        return text

1 用<|unk|>标记替换未知单词

2 替换指定标点符号前的空格

与我们在列表 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对样本文本进行分词:

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

这将打印以下标记 ID:

[1131, 5, 355, 1126, 628, 975, 10, 1130, 55, 988, 956, 984, 722, 988, 1131, 7]

我们可以看到,标记 ID 列表中包含1130,这是<|endoftext|>分隔符标记的标记 ID,以及两个1131标记,这些标记用于未知单词。

让我们进行去标记化以进行快速检查:

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

输出是

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

通过比较这个去标记化的文本与原始输入文本,我们知道训练数据集,伊迪丝·华顿的短篇小说《判决》,不包含单词“Hello”和“palace”。

根据 LLM 的不同,一些研究人员还考虑了以下额外的特殊标记:

  • [BOS] (序列开始) —这个标记标记了文本的开始。它向 LLM(大型语言模型)指示内容从哪里开始。

  • [EOS] (序列结束) —此标记位于文本的末尾,当连接多个不相关的文本时特别有用,类似于 <|endoftext|>。例如,当合并两个不同的维基百科文章或书籍时,[EOS] 标记指示一个结束,下一个开始。

  • [PAD] (填充) —当使用大于一个的批量大小训练大型语言模型 (LLM) 时,批量中可能包含不同长度的文本。为了确保所有文本具有相同的长度,较短的文本将使用 [PAD] 标记进行扩展或“填充”,直到与批量中最长文本的长度相同。

用于 GPT 模型的分词器不需要这些标记中的任何一个;它仅为了简洁起见使用一个 <|endoftext|> 标记。<|endoftext|>[EOS] 标记类似。<|endoftext|> 也用于填充。然而,正如我们将在后续章节中探讨的,在批量输入上进行训练时,我们通常使用一个掩码,这意味着我们不关注填充标记。因此,用于填充的具体标记变得无关紧要。

此外,用于 GPT 模型的分词器也不使用 <|unk|> 标记来处理词汇表外的单词。相反,GPT 模型使用一种称为 字节对编码 的分词器,它将单词分解成子词单元,我们将在下一节中讨论这一点。

2.5 字节对编码

让我们看看一种基于称为字节对编码 (BPE) 的概念的更复杂的分词方案。BPE 分词器被用于训练 GPT-2、GPT-3 以及 ChatGPT 中使用的原始模型等大型语言模型 (LLM)。

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

pip install tiktoken

我们将使用的代码基于 tiktoken 0.7.0。您可以使用以下代码来检查您当前安装的版本:

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

安装完成后,我们可以如下实例化从 tiktoken 的 BPE 分词器:

tokenizer = tiktoken.get_encoding("gpt2")

此分词器的使用方式与我们之前通过 encode 方法实现的 SimpleTokenizerV2 类似:

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

代码打印以下标记 ID:

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

然后,我们可以使用解码方法将标记 ID 转换回文本,类似于我们的 SimpleTokenizerV2

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

代码打印

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

我们可以根据标记 ID 和解码文本做出两个值得注意的观察。首先,<|endoftext|> 标记被分配了一个相对较大的标记 ID,即 50256。实际上,用于训练 GPT-2、GPT-3 以及 ChatGPT 中使用的原始模型的 BPE 分词器,其总词汇量为 50,257,其中 <|endoftext|> 被分配了最大的标记 ID。

第二,BPE 分词器能够正确地编码和解码未知单词,例如 someunknownPlace。BPE 分词器可以处理任何未知单词。它是如何在不使用 <|unk|> 标记的情况下实现这一点的呢?

BPE 背后的算法将不在其预定义词汇表中的单词分解成更小的子词单元或甚至单个字符,使其能够处理词汇表外的单词。因此,多亏了 BPE 算法,如果分词器在分词过程中遇到不熟悉的单词,它可以将其表示为一系列子词标记或字符,如图 2.11 所示。

figure

图 2.11 BPE 分词器将未知单词分解成子词和单个字符。这样,BPE 分词器可以解析任何单词,不需要用特殊标记(如<|unk|>)替换未知单词。

将未知单词分解成单个字符的能力确保了分词器以及随之训练的 LLM 可以处理任何文本,即使其中包含训练数据中未出现的单词。

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

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

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

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

创建 LLM 嵌入的下一步是生成用于训练 LLM 的输入-目标对。这些输入-目标对看起来是什么样子?正如我们之前所学的,LLM 是通过预测文本中的下一个单词进行预训练的,如图 2.12 所示。

figure

图 2.12 给定一个文本样本,提取输入块作为 LLM 的输入子样本,LLM 在训练期间的预测任务是预测输入块之后的下一个单词。在训练过程中,我们屏蔽掉所有目标之后的单词。请注意,此图中的文本必须在 LLM 处理之前进行分词;然而,为了清晰起见,此图省略了分词步骤。

让我们实现一个数据加载器,使用滑动窗口方法从训练数据集中提取图 2.12 中的输入-目标对。为了开始,我们将使用 BPE 分词器对整个“The Verdict”短篇小说进行分词:

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 标记化器后返回 5145,这是训练集中的总标记数。

接下来,为了演示目的,我们从数据集中移除前 50 个标记,因为它在接下来的步骤中会产生一个更有趣的文本段落:

enc_sample = enc_text[50:]

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

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

1 上下文大小决定了输入中包含多少个标记。

运行之前的代码将打印以下输出:

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 会接收的输入,箭头右侧的标记 ID 代表 LLM 应该预测的目标标记 ID。让我们重复之前的代码,但将标记 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 训练的输入-目标对。

在我们将标记转换为嵌入之前,还有一个任务:实现一个高效的数据加载器,该加载器遍历输入数据集,并以 PyTorch 张量的形式返回输入和目标,这可以被视为多维数组。特别是,我们感兴趣的是返回两个张量:一个包含 LLM 看到的文本的输入张量,以及一个包含 LLM 预测的目标张量,如图 2.13 所示。虽然该图为了说明目的显示了字符串格式的标记,但代码实现将直接操作标记 ID,因为 BPE 标记化器的 encode 方法将标记化和转换为标记 ID 作为单个步骤执行。

图

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

注意:为了高效的数据加载器实现,我们将使用 PyTorch 的内置 DatasetDataLoader 类。有关安装 PyTorch 的更多信息和建议,请参阅附录 A 的 A.2.1.3 节。

数据集类的代码如下所示。

列表 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)    #1

        for i in range(0, len(token_ids) - max_length, stride):     #2
            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):    #3
        return len(self.input_ids)

    def __getitem__(self, idx):         #4
        return self.input_ids[idx], self.target_ids[idx]

1 将整个文本进行标记化

2 使用滑动窗口将书籍分成重叠的最大长度序列

3 返回数据集中的总行数

4 返回数据集的单行

GPTDatasetV1类基于 PyTorch Dataset类,定义了如何从数据集中获取单个行,其中每行由一个或多个 token ID(基于max_length)分配给input_chunk张量。target_chunk张量包含相应的目标。我建议继续阅读,以了解当我们将数据集与 PyTorch DataLoader结合时返回的数据是什么样的——这将带来额外的直观性和清晰度。

注意:如果你对 PyTorch Dataset类的结构不熟悉,例如列表 2.5 中所示,请参阅附录 A 中的 A.6 节,该节解释了 PyTorch DatasetDataLoader类的一般结构和用法。

以下代码使用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")                         #1
    dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)   #2
    dataloader = DataLoader(
        dataset,
        batch_size=batch_size,
        shuffle=shuffle,
        drop_last=drop_last,     #3
        num_workers=num_workers     #4
    )

    return dataloader

1 初始化分词器

2 创建数据集

3 drop_last=True 在训练期间如果最后一个批次比指定的 batch_size 短则丢弃,以防止损失激增。

4 用于预处理的 CPU 进程数

让我们测试dataloader,使用大小为 1 的批次和上下文大小为 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)      #1
first_batch = next(data_iter)
print(first_batch)

1 将 dataloader 转换为 Python 迭代器,通过 Python 内置的 next()函数获取下一个条目

执行前面的代码会打印以下内容:

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

first_batch变量包含两个张量:第一个张量存储输入 token ID,第二个张量存储目标 token ID。由于max_length设置为 4,因此两个张量都包含四个 token ID。请注意,输入大小为 4 非常小,仅为了简单起见而选择。通常使用至少 256 的输入大小来训练 LLM。

要理解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 当从输入数据集创建多个批次时,我们在文本上滑动一个输入窗口。如果将步长设置为 1,则在创建下一个批次时,我们将输入窗口移动一个位置。如果我们设置步长等于输入窗口大小,我们可以防止批次之间的重叠。
练习 2.2 不同步长和上下文大小的数据加载器

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

批量大小为 1,如我们迄今为止从数据加载器中采样到的,在说明用途上是有用的。如果你有深度学习的先前经验,你可能知道小批量大小在训练期间需要的内存较少,但会导致模型更新更嘈杂。就像常规深度学习一样,批量大小是训练 LLM 时的权衡和超参数。

简单地看看我们如何使用数据加载器以大于 1 的批量大小进行采样:

dataloader = create_dataloader_v1(
    raw_text, batch_size=8, max_length=4, stride=4,
    shuffle=False
)

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 以充分利用数据集(我们不跳过任何单词)。这避免了批次的任何重叠,因为更多的重叠可能导致过拟合增加。

2.7 创建标记嵌入

准备 LLM 训练的输入文本的最后一步是将标记 ID 转换为嵌入向量,如图 2.15 所示。作为一个初步步骤,我们必须用随机值初始化这些嵌入权重。这个初始化是 LLM 学习过程的起点。在第五章中,我们将作为 LLM 训练的一部分优化嵌入权重。

figure

图 2.15 准备工作包括对文本进行分词,将文本标记转换为标记 ID,并将标记 ID 转换为嵌入向量。在这里,我们考虑之前创建的标记 ID 来创建标记嵌入向量。

连续向量表示,或嵌入,对于像 GPT 这样的 LLM 是必要的,因为它们是使用反向传播算法训练的深度神经网络。

注意:如果你不熟悉如何使用反向传播训练神经网络,请阅读附录 A 中的 A.4 节。

让我们通过一个实际例子看看标记 ID 到嵌入向量的转换是如何工作的。假设我们有以下四个输入标记,它们的 ID 分别是 2、3、5 和 1:

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

为了简化起见,假设我们有一个只有 6 个单词的小型词汇表(而不是 BPE 标记器词汇表中的 50,257 个单词),并且我们想要创建大小为 3 的嵌入(在 GPT-3 中,嵌入大小是 12,288 维):

vocab_size = 6
output_dim = 3

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

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

打印语句打印嵌入层的底层权重矩阵:

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 优化的一部分进行优化。此外,我们可以看到权重矩阵有六行和三列。每一行对应词汇表中的六个可能的标记中的一个,每一列对应三个嵌入维度中的一个。

现在,让我们将其应用于一个标记 ID 以获得嵌入向量:

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

返回的嵌入向量是

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

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

备注:对于那些熟悉 one-hot 编码的人来说,这里描述的嵌入层方法本质上只是实现 one-hot 编码后,在全连接层中进行矩阵乘法的一种更有效的方式,这在 GitHub 上的补充代码中有说明mng.bz/ZEB5。因为嵌入层只是 one-hot 编码和矩阵乘法方法的一种更有效的实现,所以它可以看作是一个可以通过反向传播进行优化的神经网络层。

我们已经看到了如何将单个标记 ID 转换为三维嵌入向量。现在,让我们将这种方法应用到所有四个输入 ID(torch.tensor([2, 3, 5, 1]))上:

print(embedding_layer(input_ids))

打印输出显示这导致了一个 4×3 的矩阵:

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 所示。

figure

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

现在我们已经从标记 ID 创建了嵌入向量,接下来我们将对这些嵌入向量进行小的修改,以编码文本中标记的位置信息。

2.8 编码词位置

从原则上讲,标记嵌入是适合作为 LLM 输入的。然而,LLM 的一个小缺点是它们的自注意力机制(见第三章)没有对序列中标记的位置或顺序的概念。之前引入的嵌入层的工作方式是,相同的标记 ID 总是映射到相同的向量表示,无论该标记 ID 在输入序列中的位置如何,如图 2.17 所示。

figure

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

从原则上讲,对于可重现性目的,标记 ID 的确定性、位置无关的嵌入是好的。然而,由于 LLMs 自身的自注意力机制本身也是位置无关的,向 LLM 中注入额外的位置信息是有帮助的。

为了实现这一点,我们可以使用两种广泛的位置感知嵌入类别:相对位置嵌入和绝对位置嵌入。绝对位置嵌入直接与序列中的特定位置相关联。对于输入序列中的每个位置,都会添加一个唯一的嵌入到标记的嵌入中,以传达其确切位置。例如,第一个标记将有一个特定的位置嵌入,第二个标记另一个不同的嵌入,依此类推,如图 2.18 所示。

figure

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

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

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

OpenAI 的 GPT 模型使用的是在训练过程中优化的绝对位置嵌入,而不是像原始 transformer 模型中的位置编码那样固定或预定义。这个过程是模型训练本身的一部分。现在,让我们创建初始位置嵌入以创建 LLM 输入。

以前,我们为了简单起见关注了非常小的嵌入大小。现在,让我们考虑更现实和有用的嵌入大小,并将输入标记编码到 256 维向量表示中,这比原始 GPT-3 模型使用的要小(在 GPT-3 中,嵌入大小是 12,288 维)但仍然适合实验。此外,我们假设标记 ID 是由我们之前实现的 BPE 标记器创建的,该标记器具有 50,257 个词汇量:

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

使用之前的token_embedding_layer,如果我们从数据加载器中采样数据,我们将每个批次中的每个标记嵌入到一个 256 维向量中。如果我们有一个包含四个标记的 8 个批次的批次大小,结果将是一个 8 × 4 × 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])

如我们所见,标记 ID 张量是 8 × 4 维的,这意味着数据批次由八个文本样本组成,每个样本有四个标记。

现在我们使用嵌入层将这些标记 ID 嵌入 256 维向量:

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

打印函数调用返回

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

8 × 4 × 256 维度的张量输出表明每个标记 ID 现在都嵌入为一个 256 维的向量。

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

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 的数字序列。context_length 是一个表示 LLM 支持的输入大小的变量。在这里,我们选择它与输入文本的最大长度相似。在实践中,输入文本可能比支持的内容长度更长,在这种情况下,我们必须截断文本。

打印语句的输出是

torch.Size([4, 256])

如我们所见,位置嵌入张量由四个 256 维向量组成。我们现在可以直接将这些向量添加到标记嵌入中,PyTorch 将 4 × 256 维的 pos_embeddings 张量添加到每个 4 × 256 维的标记嵌入张量中,每个批次都是八个:

input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)

打印输出是

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

我们创建的 input_embeddings,如图 2.19 所示,是现在可以被主要 LLM 模块处理的嵌入输入示例,我们将在下一章开始实现这些模块。

图

图 2.19 作为输入处理流程的一部分,输入文本首先被分解为单个标记。然后,使用词汇表将这些标记转换为标记 ID。这些标记 ID 被转换为嵌入向量,然后添加与它们大小相似的定位嵌入,从而得到用于主要 LLM 层的输入嵌入。

摘要

  • LLM 需要将文本数据转换为数值向量,称为嵌入,因为它们不能处理原始文本。嵌入将离散数据(如单词或图像)转换为连续向量空间,使它们与神经网络操作兼容。

  • 作为第一步,原始文本被分解为标记,这些标记可以是单词或字符。然后,这些标记被转换为整数表示,称为标记 ID。

  • 可以添加特殊标记,如 <|unk|><|endoftext|>,以增强模型的理解并处理各种上下文,例如未知单词或标记无关文本之间的边界。

  • 用于 GPT-2 和 GPT-3 等 LLM 的字节对编码(BPE)标记化器可以有效地通过将它们分解为子词单元或单个字符来处理未知单词。

  • 我们在标记化数据上使用滑动窗口方法来生成 LLM 训练的输入-目标对。

  • PyTorch 中的嵌入层充当查找操作,检索与标记 ID 对应的向量。生成的嵌入向量提供了标记的连续表示,这对于训练像 LLMs 这样的深度学习模型至关重要。

  • 虽然标记嵌入为每个标记提供了一致的向量表示,但它们缺乏标记在序列中的位置感。为了纠正这一点,存在两种主要类型的位置嵌入:绝对和相对。OpenAI 的 GPT 模型使用绝对位置嵌入,这些嵌入被添加到标记嵌入向量中,并在模型训练过程中进行优化。

第三章:编码注意力机制

本章涵盖

  • 在神经网络中使用注意力机制的原因

  • 一个基本的自注意力框架,逐步发展到增强的自注意力机制

  • 一个因果注意力模块,允许 LLM 一次生成一个标记

  • 使用 dropout 随机屏蔽选定的注意力权重以减少过拟合

  • 将多个因果注意力模块堆叠成多头注意力模块

到目前为止,您已经知道如何通过将文本分割成单个单词和子词标记来准备训练 LLM 的输入文本,这些标记可以编码成 LLM 的向量表示,即嵌入。

现在,我们将查看 LLM 架构本身的组成部分,即注意力机制,如图 3.1 所示。我们将主要关注注意力机制本身,并在机制层面上进行关注。然后我们将编码 LLM 围绕自注意力机制的其余部分,以观察其实际应用并创建一个生成文本的模型。

figure

图 3.1 编码 LLM 的三个主要阶段。本章重点介绍第一阶段第二步:实现注意力机制,这是 LLM 架构的组成部分。

我们将实现如图 3.2 所示的四种不同的注意力机制变体。这些不同的注意力变体相互构建,目标是达到一个紧凑且高效的多头注意力实现,然后我们可以将其插入到我们在下一章中编码的 LLM 架构中。

figure

图 3.2 该图展示了本章我们将编码的不同注意力机制,从简化的自注意力版本开始,然后添加可训练的权重。因果注意力机制为自注意力添加了一个掩码,允许 LLM 一次生成一个单词。最后,多头注意力将注意力机制组织成多个头,允许模型并行捕获输入数据的各个方面。

3.1 模型长序列的问题

在我们深入探讨 LLM 核心的自注意力机制之前,让我们考虑一下不包含注意力机制的预 LLM 架构的问题。假设我们想要开发一个语言翻译模型,将文本从一种语言翻译成另一种语言。如图 3.3 所示,由于源语言和目标语言的语法结构,我们不能简单地逐词翻译文本。

figure

图 3.3 当将文本从一种语言翻译成另一种语言,例如从德语翻译成英语时,不能仅仅逐词翻译。相反,翻译过程需要上下文理解和语法对齐。

为了解决这个问题,通常使用一个具有两个子模块的深度神经网络,一个编码器和一个解码器。编码器的任务是首先读取并处理整个文本,然后解码器生成翻译后的文本。

在变压器出现之前,循环神经网络(RNNs)是语言翻译中最受欢迎的编码器-解码器架构。RNN 是一种神经网络,其中前一步的输出被作为当前步骤的输入,这使得它们非常适合像文本这样的顺序数据。如果你对 RNN 不熟悉,不用担心——你不需要了解 RNN 的详细工作原理来跟随这次讨论;我们在这里更关注编码器-解码器设置的一般概念。

在编码器-解码器 RNN 中,输入文本被输入到编码器中,编码器按顺序处理它。编码器在每一步更新其隐藏状态(隐藏层的内部值),试图在最终的隐藏状态中捕获输入句子的整个含义,如图 3.4 所示。然后,解码器使用这个最终的隐藏状态开始生成翻译句子,一次一个单词。它也在每一步更新其隐藏状态,这个状态应该携带进行下一个单词预测所需的上下文。

figure

图 3.4 在变压器模型出现之前,编码器-解码器 RNN 是机器翻译的一个流行选择。编码器接收源语言的一序列标记作为输入,其中编码器的隐藏状态(一个中间神经网络层)编码了整个输入序列的压缩表示。然后,解码器使用其当前的隐藏状态开始逐个标记进行翻译。

虽然我们不需要了解这些编码器-解码器 RNN 的内部工作原理,但这里的关键思想是,编码器部分将整个输入文本处理成一个隐藏状态(记忆单元)。然后,解码器接收这个隐藏状态以生成输出。你可以将这个隐藏状态视为一个嵌入向量,这是我们第二章讨论的概念。

编码器-解码器 RNN 的一个重大限制是,RNN 在解码阶段不能直接访问编码器中的早期隐藏状态。因此,它完全依赖于当前的隐藏状态,该状态封装了所有相关信息。这可能导致上下文丢失,尤其是在复杂句子中,其中依赖关系可能跨越很长的距离。

幸运的是,构建一个 LLM 并不需要理解 RNN。只需记住,编码器-解码器 RNN 有一个不足之处,这促使了注意力机制的设计。

3.2 使用注意力机制捕获数据依赖

虽然 RNN 对于翻译短句效果不错,但它们对于较长的文本效果不佳,因为它们无法直接访问输入中的先前单词。这种方法的重大不足在于,RNN 必须在传递给解码器之前,在单个隐藏状态中记住整个编码输入(图 3.4)。

因此,研究人员在 2014 年为 RNN 开发了Bahdanau 注意力机制(以该论文的第一作者命名;更多信息,见附录 B),该机制修改了编码器-解码器 RNN,使得解码器可以在每个解码步骤中选择性访问输入序列的不同部分,如图 3.5 所示。

自注意力是一种机制,允许输入序列中的每个位置在计算序列表示时考虑同一序列中所有其他位置的相关性或“关注”。自注意力是当代基于 transformer 架构的 LLM(如 GPT 系列)的关键组件。

使用注意力机制,网络中生成文本的解码器部分可以选择性访问所有输入标记。这意味着对于生成给定输出标记,某些输入标记比其他标记更重要。重要性由注意力权重决定,我们将在后面计算。请注意,此图展示了注意力背后的基本思想,并不描绘 Bahdanau 机制的确切实现,该机制是本书范围之外的 RNN 方法。

figure

figure

本章重点介绍编码和理解 GPT 类模型中使用的自注意力机制,如图 3.6 所示。在下一章中,我们将编写 LLM 的剩余部分。

在自注意力中,“self”指的是该机制通过关联单个输入序列内的不同位置来计算注意力权重的能力。它评估和学习输入本身各部分之间的关系和依赖性,例如句子中的单词或图像中的像素。

figure

3.3 使用自注意力关注输入的不同部分

我们现在将探讨自注意力机制的内部工作原理,并学习如何从头开始编写它的代码。自注意力是每个基于 transformer 架构的 LLM 的基石。这个主题可能需要大量的关注和注意(无意中打趣),但一旦你掌握了它的基础,你将征服这本书和 LLM 实现的一般性中最具挑战性的方面之一。

有趣的是,仅仅三年后,研究人员发现构建用于自然语言处理的深度神经网络不需要 RNN 架构,并提出了原始的transformer架构(在第一章中讨论),包括一个受 Bahdanau 注意力机制启发的自注意力机制。

自注意力中的“self”

这与传统注意力机制形成对比,传统注意力机制关注的是两个不同序列元素之间的关系,例如在序列到序列模型中,注意力可能是在输入序列和输出序列之间,如图 3.5 所示。

由于自注意力可能看起来很复杂,尤其是如果你第一次遇到它,我们将首先检查它的简化版本。然后我们将实现 LLMs 中使用的带有可训练权重的自注意力机制。

3.3.1 无可训练权重的简单自注意力机制

让我们从实现一个简化的自注意力变体开始,如图 3.7 所示,这个变体不包含任何可训练的权重。目标是先展示自注意力的一些关键概念,然后再添加可训练的权重。

figure

图 3.7 自注意力的目标是计算每个输入元素的一个上下文向量,该向量结合了所有其他输入元素的信息。在这个例子中,我们计算上下文向量 z^((2))。每个输入元素对于计算 z^((2))的重要性或贡献由注意力权重 a[21]到 a[2T]决定。在计算 z^((2))时,注意力权重是根据输入元素 x^((2))和所有其他输入计算的。

图 3.7 展示了输入序列,用x表示,由T个元素组成,表示为x(1)到x(T)。这个序列通常代表文本,例如一个句子,它已经被转换成标记嵌入。

例如,考虑一个输入文本如“Your journey starts with one step.”在这种情况下,序列中的每个元素,如x(1),对应一个d-维嵌入向量,代表一个特定的标记,如“Your。”图 3.7 展示了这些输入向量作为三维嵌入。

在自注意力中,我们的目标是计算输入序列中每个元素x(i)的上下文向量z(i)。上下文向量可以解释为一个丰富的嵌入向量。

为了说明这个概念,让我们关注第二个输入元素x(2)(对应标记“journey”)及其相应的上下文向量z(2),如图 3.7 底部所示。这个增强的上下文向量z(2)是一个包含关于x(2)和所有其他输入元素x(1)到x(T)信息的嵌入。

上下文向量在自注意力机制中起着至关重要的作用。它们的目的是通过结合序列(如句子)中所有其他元素的信息,为输入序列中的每个元素(如单词)创建丰富的表示(如图 3.7 所示)。这对于 LLMs(大型语言模型)至关重要,因为 LLMs 需要理解句子中单词之间的关系和相关性。稍后,我们将添加可训练的权重,帮助 LLM 学习构建这些上下文向量,以便它们对 LLM 生成下一个标记相关。但首先,让我们实现一个简化的自注意力机制,逐步计算这些权重和结果上下文向量。

考虑以下已经嵌入为三维向量的输入句子(参见第二章)。我选择了一个小的嵌入维度,以确保它可以在不换行的情况下适应页面:

import torch
inputs = torch.tensor(
  [[0.43, 0.15, 0.89], # Your     (x¹)
   [0.55, 0.87, 0.66], # journey  (x²)
   [0.57, 0.85, 0.64], # starts   (x³)
   [0.22, 0.58, 0.33], # with     (x⁴)
   [0.77, 0.25, 0.10], # one      (x⁵)
   [0.05, 0.80, 0.55]] # step     (x⁶)
)

实现自注意力的第一步是计算中间值 w,称为注意力分数,如图 3.8 所示。由于空间限制,图中的值以截断版本显示前一个inputs张量的值;例如,0.87 被截断为 0.8。在这个截断版本中,单词“journey”和“starts”的嵌入可能由于随机机会而看起来相似。

figure

图 3.8 整体目标是说明使用第二个输入元素 x^((2))作为查询计算上下文向量 z^((2))的过程。此图显示了第一个中间步骤,即计算查询 x^((2))与所有其他输入元素之间的注意力分数 w,作为点积。 (注意,数字被截断到小数点后一位,以减少视觉混乱。)

图 3.8 说明了我们如何计算查询标记和每个输入标记之间的中间注意力分数。我们通过计算查询x(2)与每个其他输入标记的点积来确定这些分数:

query = inputs[1]                            #1
attn_scores_2 = torch.empty(inputs.shape[0])
for i, x_i in enumerate(inputs):
    attn_scores_2[i] = torch.dot(x_i, query)
print(attn_scores_2)

1 第二个输入标记作为查询。

计算出的注意力分数是

tensor([0.9544, 1.4950, 1.4754, 0.8434, 0.7070, 1.0865])
理解点积

点积本质上是一种简洁地逐元素乘以两个向量并求和其乘积的方法,如下所示:

res = 0.
for idx, element in enumerate(inputs[0]):
    res += inputs[0][idx] * query[idx]
print(res)
print(torch.dot(inputs[0], query))

输出确认了逐元素乘积的总和与点积给出相同的结果:

tensor(0.9544)
tensor(0.9544)

除了将点积操作视为结合两个向量以产生标量值的数学工具之外,点积是相似度的度量,因为它量化了两个向量对齐的紧密程度:点积越高,向量之间的对齐或相似度就越高。在自注意力机制的情况下,点积决定了序列中每个元素对其他元素的关注程度或“注意”程度:点积越高,两个元素之间的相似度和注意力分数就越高。

在下一步中,如图 3.9 所示,我们对之前计算的所有注意力分数进行归一化。归一化的主要目标是获得总和为 1 的注意力权重。这种归一化是一种对解释和维持 LLM 训练稳定性的有用惯例。以下是一种实现这一归一化步骤的简单方法:

attn_weights_2_tmp = attn_scores_2 / attn_scores_2.sum()
print("Attention weights:", attn_weights_2_tmp)
print("Sum:", attn_weights_2_tmp.sum())

figure

图 3.9 在根据输入查询 x^((2))计算注意力分数 w[21]到 w[2T]之后,下一步是通过对注意力分数进行归一化来获得注意力权重 a[21]到 a[2T]。

如输出所示,注意力权重现在总和为 1:

Attention weights: tensor([0.1455, 0.2278, 0.2249, 0.1285, 0.1077, 0.1656])
Sum: tensor(1.0000)

在实践中,更常见且建议使用 softmax 函数进行归一化。这种方法在处理极端值时表现更好,并且在训练期间提供了更有利的梯度属性。以下是对注意力分数进行归一化的 softmax 函数的基本实现:

def softmax_naive(x):
    return torch.exp(x) / torch.exp(x).sum(dim=0)

attn_weights_2_naive = softmax_naive(attn_scores_2)
print("Attention weights:", attn_weights_2_naive)
print("Sum:", attn_weights_2_naive.sum())

如输出所示,softmax 函数也达到了目标,并归一化了注意力权重,使得它们的总和为 1:

Attention weights: tensor([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581])
Sum: tensor(1.)

此外,softmax 函数确保注意力权重始终为正。这使得输出可解释为概率或相对重要性,其中较高的权重表示更大的重要性。

注意,这种原始的 softmax 实现(softmax_naive)在处理大或小输入值时可能会遇到数值不稳定性问题,如溢出和下溢。因此,在实践中,建议使用经过广泛优化的 PyTorch softmax 实现:

attn_weights_2 = torch.softmax(attn_scores_2, dim=0)
print("Attention weights:", attn_weights_2)
print("Sum:", attn_weights_2.sum())

在这种情况下,它产生了与我们的先前softmax_naive函数相同的结果:

Attention weights: tensor([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581])
Sum: tensor(1.)

现在我们已经计算了归一化的注意力权重,我们准备进行最终步骤,如图 3.10 所示:通过将嵌入输入标记x(i)与相应的注意力权重相乘,然后求和得到的向量来计算上下文向量z(2)。因此,上下文向量z(2)是所有输入向量的加权总和,通过将每个输入向量乘以其相应的注意力权重得到:

query = inputs[1]         #1
context_vec_2 = torch.zeros(query.shape)
for i,x_i in enumerate(inputs):
    context_vec_2 += attn_weights_2[i]*x_i
print(context_vec_2)

1 第二个输入标记是查询。

figure

图 3.10 最终步骤,在计算并归一化注意力分数以获得查询 x^((2))的注意力权重之后,是计算上下文向量 z^((2))。这个上下文向量是所有输入向量 x^((1))到 x((*T *)^)的加权组合,权重由注意力权重决定。

此计算的结果是

tensor([0.4419, 0.6515, 0.5683])

接下来,我们将将计算上下文向量的这一过程推广,以同时计算所有上下文向量。

3.3.2 计算所有输入标记的注意力权重

到目前为止,我们已经计算了输入 2 的注意力权重和上下文向量,如图 3.11 中高亮显示的行所示。现在让我们扩展这个计算,以计算所有输入的注意力权重和上下文向量。

figure

图 3.11 高亮行显示了作为查询的第二输入元素的注意力权重。现在我们将计算推广到获得所有其他注意力权重。(请注意,此图中的数字被截断到小数点后两位,以减少视觉杂乱。每行的值应加起来为 1.0 或 100%。)

我们遵循之前相同的三个步骤(见图 3.12),只是在代码中做了一些修改,以计算所有上下文向量而不是仅计算第二个向量 z(2):

attn_scores = torch.empty(6, 6)
for i, x_i in enumerate(inputs):
    for j, x_j in enumerate(inputs):
        attn_scores[i, j] = torch.dot(x_i, x_j)
print(attn_scores)

figure

图 3.12 在第 1 步中,我们添加了一个额外的 for 循环来计算所有输入对之间的点积。

结果注意力分数如下:

tensor([[0.9995, 0.9544, 0.9422, 0.4753, 0.4576, 0.6310],
        [0.9544, 1.4950, 1.4754, 0.8434, 0.7070, 1.0865],
        [0.9422, 1.4754, 1.4570, 0.8296, 0.7154, 1.0605],
        [0.4753, 0.8434, 0.8296, 0.4937, 0.3474, 0.6565],
        [0.4576, 0.7070, 0.7154, 0.3474, 0.6654, 0.2935],
        [0.6310, 1.0865, 1.0605, 0.6565, 0.2935, 0.9450]])

张量中的每个元素代表每对输入之间的注意力分数,正如我们在图 3.11 中看到的。请注意,图中的值是归一化的,这就是为什么它们与前面张量中的未归一化注意力分数不同。我们将在稍后处理归一化。

在计算前面的注意力分数张量时,我们使用了 Python 中的 for 循环。然而,for 循环通常较慢,我们可以通过矩阵乘法达到相同的结果:

attn_scores = inputs @ inputs.T
print(attn_scores)

我们可以直观地确认结果与之前相同:

tensor([[0.9995, 0.9544, 0.9422, 0.4753, 0.4576, 0.6310],
        [0.9544, 1.4950, 1.4754, 0.8434, 0.7070, 1.0865],
        [0.9422, 1.4754, 1.4570, 0.8296, 0.7154, 1.0605],
        [0.4753, 0.8434, 0.8296, 0.4937, 0.3474, 0.6565],
        [0.4576, 0.7070, 0.7154, 0.3474, 0.6654, 0.2935],
        [0.6310, 1.0865, 1.0605, 0.6565, 0.2935, 0.9450]])

在图 3.12 的第 2 步中,我们将每一行归一化,使得每行的值加起来为 1:

attn_weights = torch.softmax(attn_scores, dim=-1)
print(attn_weights)

这返回以下与图 3.10 中显示的值相匹配的注意力权重张量:

tensor([[0.2098, 0.2006, 0.1981, 0.1242, 0.1220, 0.1452],
        [0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581],
        [0.1390, 0.2369, 0.2326, 0.1242, 0.1108, 0.1565],
        [0.1435, 0.2074, 0.2046, 0.1462, 0.1263, 0.1720],
        [0.1526, 0.1958, 0.1975, 0.1367, 0.1879, 0.1295],
        [0.1385, 0.2184, 0.2128, 0.1420, 0.0988, 0.1896]])

在使用 PyTorch 的上下文中,函数如 torch.softmax 中的 dim 参数指定了函数将在哪个维度上对输入张量进行计算。通过设置 dim=-1,我们指示 softmax 函数在 attn_scores 张量的最后一个维度上应用归一化。如果 attn_scores 是一个二维张量(例如,形状为 [rows, columns]),它将在列上归一化,使得每行的值(在列维度上求和)加起来为 1。

我们可以验证行确实都加起来为 1:

row_2_sum = sum([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581])
print("Row 2 sum:", row_2_sum)
print("All row sums:", attn_weights.sum(dim=-1))

结果是

Row 2 sum: 1.0
All row sums: tensor([1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000])

在图 3.12 的第三和最后一步中,我们使用这些注意力权重通过矩阵乘法计算所有上下文向量:

all_context_vecs = attn_weights @ inputs
print(all_context_vecs)

在结果输出张量中,每一行包含一个三维上下文向量:

tensor([[0.4421, 0.5931, 0.5790],
        [0.4419, 0.6515, 0.5683],
        [0.4431, 0.6496, 0.5671],
        [0.4304, 0.6298, 0.5510],
        [0.4671, 0.5910, 0.5266],
        [0.4177, 0.6503, 0.5645]])

我们可以通过将第二行与我们在 3.3.1 节中计算的上下文向量 z^((2)) 进行比较来双重检查代码的正确性:

print("Previous 2nd context vector:", context_vec_2)

根据结果,我们可以看到之前计算的 context_vec_2 与前面张量的第二行完全匹配:

Previous 2nd context vector: tensor([0.4419, 0.6515, 0.5683])

这完成了简单自注意力机制的代码讲解。接下来,我们将添加可训练权重,使 LLM 能够从数据中学习并提高其在特定任务上的性能。

3.4 使用可训练权重实现自注意力

我们接下来的步骤将是实现原始变压器架构、GPT 模型以及大多数其他流行的 LLMs 中使用的自注意力机制。这种自注意力机制也称为缩放点积注意力。图 3.13 展示了这种自注意力机制如何融入实现 LLM 的更广泛背景中。

figure

图 3.13 之前,我们编写了一个简化的注意力机制来理解注意力机制背后的基本机制。现在,我们向这个注意力机制添加可训练权重。稍后,我们将通过添加因果掩码和多个头扩展这种自注意力机制。

如图 3.13 所示,具有可训练权重的自注意力机制建立在先前概念之上:我们希望计算特定输入元素的输入向量上的加权求和作为上下文向量。您将看到,与之前编写的简单自注意力机制相比,只有细微的差异。

最显著的区别是引入了在模型训练期间更新的权重矩阵。这些可训练权重矩阵对于模型(特别是模型内的注意力模块)能够学会产生“良好”的上下文向量至关重要。(我们将在第五章训练 LLM。)

我们将在两个小节中处理这种自注意力机制。首先,我们将像以前一样逐步编码它。其次,我们将代码组织成一个紧凑的 Python 类,可以导入到 LLM 架构中。

3.4.1 逐步计算注意力权重

我们将通过引入三个可训练权重矩阵W[q]、W[k]和W[v]逐步实现自注意力机制。这三个矩阵用于将嵌入输入标记x^((i))分别投影到查询、键和值向量,如图 3.14 所示。

figure

图 3.14 在具有可训练权重矩阵的自注意力机制的第一步中,我们为输入元素 x 计算查询(q)、键(k)和值(v)向量。与前面的章节类似,我们将第二个输入 x^((2))指定为查询输入。查询向量 q^((2))是通过输入 x^((2))与权重矩阵 W[q]之间的矩阵乘法获得的。同样,我们通过涉及权重矩阵 W[k]和 W[v]的矩阵乘法获得键和值向量。

之前,当我们计算简化注意力权重以计算上下文向量z((2))时,我们将第二个输入元素*x*((2))定义为查询。然后我们将其推广到计算六个单词输入句子“Your journey starts with one step.”的所有上下文向量z^((1)) ... z^((T))。

同样,我们在这里仅为了说明目的计算一个上下文向量z^((2))。然后我们将修改此代码以计算所有上下文向量。

让我们从定义几个变量开始:

x_2 = inputs[1]     #1
d_in = inputs.shape[1]      #2
d_out = 2         #3

1 第二个输入元素

2 输入嵌入大小,d=3

3 输出嵌入大小,d_out=2

注意,在 GPT-like 模型中,输入和输出维度通常是相同的,但为了更好地跟踪计算,我们在这里使用不同的输入(d_in=3)和输出(d_out=2)维度。

接下来,我们初始化图 3.14 中显示的三个权重矩阵 W[q]、W[k] 和 W[v]:

torch.manual_seed(123)
W_query = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False)
W_key   = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False)
W_value = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False)

我们将 requires_grad=False 设置为减少输出中的杂乱,但如果我们要使用权重矩阵进行模型训练,我们将在模型训练期间将这些矩阵设置为 requires_grad=True 以更新这些矩阵。

接下来,我们计算查询、键和值向量:

query_2 = x_2 @ W_query 
key_2 = x_2 @ W_key 
value_2 = x_2 @ W_value
print(query_2)

由于我们通过d_out设置了相应权重矩阵的列数为 2,查询结果输出为一个二维向量:

tensor([0.4306, 1.4551])
权重参数与注意力权重对比

在权重矩阵 W 中,“权重”一词是“权重参数”的简称,这些参数是神经网络在训练过程中优化的值。这不同于注意力权重。正如我们之前看到的,注意力权重决定了上下文向量依赖于输入的不同部分的程度(即网络在多大程度上关注输入的不同部分)。

总结来说,权重参数是定义网络连接的基本、学习系数,而注意力权重是动态的、上下文特定的值。

尽管我们的临时目标只是计算一个上下文向量,z^((2)),但我们仍然需要所有输入元素的键和值向量,因为它们涉及到计算与查询 q^((2)) 相关的注意力权重(见图 3.14)。

我们可以通过矩阵乘法获得所有键和值:

keys = inputs @ W_key 
values = inputs @ W_value
print("keys.shape:", keys.shape)
print("values.shape:", values.shape)

从输出中我们可以看出,我们成功地将六个输入标记从三维投影到二维嵌入空间:

keys.shape: torch.Size([6, 2])
values.shape: torch.Size([6, 2])

第二步是计算注意力分数,如图 3.15 所示。

figure

图 3.15 注意力分数的计算是一种点积计算,类似于我们在 3.3 节中使用的简化自注意力机制。这里的新特点是,我们不是直接计算输入元素之间的点积,而是使用通过各自的权重矩阵变换得到的查询和键。

首先,让我们计算注意力分数 ω[22]:

keys_2 = keys[1]             #1
attn_score_22 = query_2.dot(keys_2)
print(attn_score_22)

1 记住 Python 从 0 开始索引。

未归一化的注意力分数结果为

tensor(1.8524)

同样,我们可以通过矩阵乘法将此计算推广到所有注意力分数:

attn_scores_2 = query_2 @ keys.T       #1
print(attn_scores_2)

1 给定查询的所有注意力分数

如我们所见,作为一个快速检查,输出中的第二个元素与我们之前计算的attn_score_22相匹配:

tensor([1.2705, 1.8524, 1.8111, 1.0795, 0.5577, 1.5440])

现在,我们想要从注意力分数转换到注意力权重,如图 3.16 所示。我们通过缩放注意力分数并使用 softmax 函数来计算注意力权重。然而,现在我们通过将注意力分数除以键的嵌入维度的平方根来缩放注意力分数(取平方根在数学上等同于 0.5 次幂):

d_k = keys.shape[-1]
attn_weights_2 = torch.softmax(attn_scores_2 / d_k**0.5, dim=-1)
print(attn_weights_2)

图

图 3.16 计算完注意力分数 ω 后,下一步是使用 softmax 函数对这些分数进行归一化,以获得注意力权重 𝛼。

结果的注意力权重如下:

tensor([0.1500, 0.2264, 0.2199, 0.1311, 0.0906, 0.1820])
缩放点积注意力的原理

通过嵌入维度大小进行归一化的原因是通过避免小的梯度来提高训练性能。例如,当放大嵌入维度时,对于类似 GPT 的 LLM,这通常大于 1,000,由于应用了 softmax 函数,大点积在反向传播期间可能导致非常小的梯度。随着点积的增加,softmax 函数表现得越来越像阶跃函数,导致梯度接近零。这些小的梯度可以极大地减慢学习速度或导致训练停滞。

通过嵌入维度的平方根进行缩放是为什么这种自注意力机制也被称为缩放点积注意力。

现在,最终步骤是计算上下文向量,如图 3.17 所示。

图

图 3.17 在自注意力计算的最终步骤中,我们通过结合所有值向量并通过注意力权重来计算上下文向量。

类似于我们计算上下文向量作为输入向量的加权求和(参见第 3.3 节),我们现在将上下文向量计算为值向量的加权求和。在这里,注意力权重作为加权因子,衡量每个值向量的重要性。同样,正如之前一样,我们可以使用矩阵乘法一步获得输出:

context_vec_2 = attn_weights_2 @ values
print(context_vec_2)

结果向量的内容如下:

tensor([0.3061, 0.8210])

到目前为止,我们只计算了一个上下文向量,z((2))。接下来,我们将通用代码来计算输入序列中的所有上下文向量,*z*((1)) 到 z^((T))。

为什么是查询、键和值?

在注意力机制中,“键”、“查询”和“值”这些术语是从信息检索和数据库领域借用的,在这些领域中,类似的概念用于存储、搜索和检索信息。

一个 查询 类似于数据库中的搜索查询。它代表模型当前关注或试图理解的项目(例如,句子中的一个词或标记)。查询用于探测输入序列的其他部分,以确定对它们的注意力程度。

类似于数据库键,用于索引和搜索。在注意力机制中,输入序列中的每个项目(例如,句子中的每个单词)都有一个相关的键。这些键用于匹配查询。

在这个上下文中,类似于数据库中的键值对中的值。它表示输入项目的实际内容或表示。一旦模型确定哪些键(以及因此哪些输入部分)与查询(当前焦点项)最相关,它就会检索相应的值。

3.4.2 实现紧凑的自注意力 Python 类

在这个阶段,我们已经走过了许多步骤来计算自注意力输出。我们这样做主要是为了说明目的,以便我们可以一步一步地进行。在实践中,考虑到下一章中 LLM 的实现,将此代码组织成一个 Python 类是有帮助的,如下所示。

列表 3.1 紧凑的自注意力类
import torch.nn as nn
class SelfAttention_v1(nn.Module):
    def __init__(self, d_in, d_out):
        super().__init__()
        self.W_query = nn.Parameter(torch.rand(d_in, d_out))
        self.W_key   = nn.Parameter(torch.rand(d_in, d_out))
        self.W_value = nn.Parameter(torch.rand(d_in, d_out))

    def forward(self, x):
        keys = x @ self.W_key
        queries = x @ self.W_query
        values = x @ self.W_value
        attn_scores = queries @ keys.T # omega
        attn_weights = torch.softmax(
            attn_scores / keys.shape[-1]**0.5, dim=-1
        )
        context_vec = attn_weights @ values
        return context_vec

在此 PyTorch 代码中,SelfAttention_v1是一个从nn.Module派生的类,它是 PyTorch 模型的基本构建块,为模型层创建和管理提供必要的功能。

__init__方法初始化查询、键和值的可训练权重矩阵(W_queryW_keyW_value),每个矩阵将输入维度d_in转换为输出维度d_out

在前向传播过程中,使用前向方法,我们通过乘以查询和键来计算注意力分数(attn_scores),使用 softmax 对这些分数进行归一化。最后,我们通过使用这些归一化的注意力分数来加权值,创建一个上下文向量。

我们可以使用此类如下:

torch.manual_seed(123)
sa_v1 = SelfAttention_v1(d_in, d_out)
print(sa_v1(inputs))

由于inputs包含六个嵌入向量,这导致存储六个上下文向量的矩阵:

tensor([[0.2996, 0.8053],
        [0.3061, 0.8210],
        [0.3058, 0.8203],
        [0.2948, 0.7939],
        [0.2927, 0.7891],
        [0.2990, 0.8040]], grad_fn=<MmBackward0>)

作为快速检查,请注意,第二行([0.3061, 0.8210])与上一节中的context_vec_2的内容相匹配。图 3.18 总结了我们刚刚实现的自注意力机制。

figure

图 3.18 在自注意力中,我们使用三个权重矩阵 W[q]、W[k]和 W[v]将输入矩阵 X 中的输入向量进行转换。然后我们根据生成的查询(Q)和键(K)计算注意力权重矩阵。使用注意力权重和值(V),然后计算上下文向量(Z)。为了视觉清晰,我们关注单个输入文本 n 个标记,而不是多个输入的批次。因此,在这个上下文中,三维输入张量简化为二维矩阵。这种方法允许更直观地可视化和理解涉及的过程。为了与后续的图保持一致,注意力矩阵中的值不表示真实的注意力权重。(此图中的数字被截断到小数点后两位以减少视觉杂乱。每行的值应加起来为 1.0 或 100%。)

自注意力机制涉及可训练的权重矩阵 W[q]、W[k] 和 W[v]。这些矩阵将输入数据转换为查询、键和值,分别是注意力机制的关键组成部分。随着模型在训练过程中接触到更多数据,它会调整这些可训练的权重,我们将在接下来的章节中看到这一点。

我们可以通过利用 PyTorch 的 nn.Linear 层进一步改进 SelfAttention_v1 的实现,这些层在禁用偏置单元时有效地执行矩阵乘法。此外,使用 nn.Linear 而不是手动实现 nn.Parameter(torch.rand(...)) 的一个显著优点是 nn.Linear 具有优化的权重初始化方案,有助于更稳定和有效的模型训练。

列表 3.2 使用 PyTorch 线性层的自注意力类
class SelfAttention_v2(nn.Module):
    def __init__(self, d_in, d_out, qkv_bias=False):
        super().__init__()
        self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_key   = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)

    def forward(self, x):
        keys = self.W_key(x)
        queries = self.W_query(x)
        values = self.W_value(x)
        attn_scores = queries @ keys.T
        attn_weights = torch.softmax(
            attn_scores / keys.shape[-1]**0.5, dim=-1
        )
        context_vec = attn_weights @ values
        return context_vec

你可以使用 SelfAttention_v2 类似于 SelfAttention_v1

torch.manual_seed(789)
sa_v2 = SelfAttention_v2(d_in, d_out)
print(sa_v2(inputs))

输出是

tensor([[-0.0739,  0.0713],
        [-0.0748,  0.0703],
        [-0.0749,  0.0702],
        [-0.0760,  0.0685],
        [-0.0763,  0.0679],
        [-0.0754,  0.0693]], grad_fn=<MmBackward0>)

注意,SelfAttention_v1SelfAttention_v2 给出不同的输出,因为它们使用不同的初始权重进行权重矩阵的初始化,而 nn.Linear 使用了更复杂的权重初始化方案。

练习 3.1 比较 SelfAttention_v1 和 SelfAttention_v2

注意,SelfAttention_v2 中的 nn.Linear 使用了与 SelfAttention_v1 中使用的 nn.Parameter(torch.rand(d_in, d_out)) 不同的权重初始化方案,这导致两种机制产生不同的结果。为了验证 SelfAttention_v1SelfAttention_v2 这两种实现方式在其他方面是相似的,我们可以将 SelfAttention_v2 对象的权重矩阵转移到 SelfAttention_v1 对象中,这样两个对象就会产生相同的结果。

你的任务是正确地将 SelfAttention_v2 实例的权重分配给 SelfAttention_v1 实例。为此,你需要理解两个版本中权重之间的关系。(提示:nn.Linear 以转置形式存储权重矩阵。)分配后,你应该观察到两个实例产生相同的输出。

接下来,我们将对自注意力机制进行改进,重点关注引入因果和多头元素。因果方面涉及修改注意力机制,以防止模型访问序列中的未来信息,这对于像语言模型这样的任务至关重要,其中每个单词预测应该只依赖于前面的单词。

多头组件涉及将注意力机制分割成多个“头”。每个头学习数据的不同方面,允许模型在不同的位置同时关注来自不同表示子空间的信息。这提高了模型在复杂任务中的性能。

3.5 使用因果注意力隐藏未来单词

对于许多 LLM 任务,你可能希望自注意力机制只考虑当前位置之前出现的标记,当预测序列中的下一个标记时。因果注意力,也称为屏蔽注意力,是一种特殊形式的自注意力。它限制模型在计算注意力得分时只考虑序列中的先前和当前输入。这与标准的自注意力机制形成对比,后者允许一次访问整个输入序列。

现在,我们将修改标准的自注意力机制,以创建一个因果注意力机制,这对于在后续章节中开发 LLM 至关重要。为了在类似 GPT 的 LLM 中实现这一点,对于每个处理的标记,我们将屏蔽掉输入文本中当前标记之后的未来标记,如图 3.19 所示。我们屏蔽掉对角线以上的注意力权重,并将非屏蔽的注意力权重归一化,使得每行的注意力权重总和为 1。稍后,我们将在代码中实现这个屏蔽和归一化过程。

图像

图 3.19 在因果注意力中,我们屏蔽掉对角线以上的注意力权重,这样对于给定的输入,LLM 在计算上下文向量时无法访问未来的标记。例如,对于第二行中的单词“journey”,我们只保留“Your”和当前位置“journey”的注意力权重。

3.5.1 应用因果注意力掩码

我们接下来的步骤是在代码中实现因果注意力掩码。为了实现将因果注意力掩码应用于获取掩码注意力权重,如图 3.20 所示,让我们使用上一节中的注意力得分和权重来编写因果注意力机制。

图像

图 3.20 在因果注意力中,通过应用 softmax 函数到注意力得分,将对角线以上的元素置零,并对结果矩阵进行归一化,这是一种获得掩码注意力权重矩阵的方法。

在第一步中,我们使用 softmax 函数计算注意力权重,就像我们之前做的那样:

queries = sa_v2.W_query(inputs)     #1
keys = sa_v2.W_key(inputs) 
attn_scores = queries @ keys.T
attn_weights = torch.softmax(attn_scores / keys.shape[-1]**0.5, dim=-1)
print(attn_weights)

1 重用上一节中 SelfAttention_v2 对象的查询和键权重矩阵以方便起见

这导致了以下注意力权重:

tensor([[0.1921, 0.1646, 0.1652, 0.1550, 0.1721, 0.1510],
        [0.2041, 0.1659, 0.1662, 0.1496, 0.1665, 0.1477],
        [0.2036, 0.1659, 0.1662, 0.1498, 0.1664, 0.1480],
        [0.1869, 0.1667, 0.1668, 0.1571, 0.1661, 0.1564],
        [0.1830, 0.1669, 0.1670, 0.1588, 0.1658, 0.1585],
        [0.1935, 0.1663, 0.1666, 0.1542, 0.1666, 0.1529]],
       grad_fn=<SoftmaxBackward0>)

我们可以使用 PyTorch 的tril函数来实现第二步,创建一个对角线上方值为零的掩码:

context_length = attn_scores.shape[0]
mask_simple = torch.tril(torch.ones(context_length, context_length))
print(mask_simple)

最终得到的掩码是

tensor([[1., 0., 0., 0., 0., 0.],
        [1., 1., 0., 0., 0., 0.],
        [1., 1., 1., 0., 0., 0.],
        [1., 1., 1., 1., 0., 0.],
        [1., 1., 1., 1., 1., 0.],
        [1., 1., 1., 1., 1., 1.]])

现在,我们可以将这个掩码与注意力权重相乘,以屏蔽对角线以上的值:

masked_simple = attn_weights*mask_simple
print(masked_simple)

如我们所见,对角线以上的元素已被成功置零:

tensor([[0.1921, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.2041, 0.1659, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.2036, 0.1659, 0.1662, 0.0000, 0.0000, 0.0000],
        [0.1869, 0.1667, 0.1668, 0.1571, 0.0000, 0.0000],
        [0.1830, 0.1669, 0.1670, 0.1588, 0.1658, 0.0000],
        [0.1935, 0.1663, 0.1666, 0.1542, 0.1666, 0.1529]],
       grad_fn=<MulBackward0>)

第三步是将注意力权重重新归一化,使每行的总和再次为 1。我们可以通过将每行中的每个元素除以该行的总和来实现这一点:

row_sums = masked_simple.sum(dim=-1, keepdim=True)
masked_simple_norm = masked_simple / row_sums
print(masked_simple_norm)

结果是一个注意力权重矩阵,其中对角线以上的注意力权重被置零,且每行的和为 1:

tensor([[1.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.5517, 0.4483, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.3800, 0.3097, 0.3103, 0.0000, 0.0000, 0.0000],
        [0.2758, 0.2460, 0.2462, 0.2319, 0.0000, 0.0000],
        [0.2175, 0.1983, 0.1984, 0.1888, 0.1971, 0.0000],
        [0.1935, 0.1663, 0.1666, 0.1542, 0.1666, 0.1529]],
       grad_fn=<DivBackward0>)
信息泄露

当我们应用掩码并重新归一化注意力权重时,最初可能看起来未来标记(我们打算掩码)的信息仍然可能影响当前标记,因为它们的值是 softmax 计算的一部分。然而,关键洞察是,当我们对掩码后的注意力权重进行重新归一化时,我们实际上是在对更小的子集重新计算 softmax(因为掩码位置不贡献 softmax 值)。

Softmax 的数学优雅之处在于,尽管最初在分母中包含所有位置,但在掩码和重新归一化之后,掩码位置的影响被消除——它们不会以任何有意义的方式对 softmax 分数做出贡献。

用更简单的话说,在掩码和重新归一化之后,注意力权重的分布就像一开始只在对角线以上的位置计算一样。这确保了没有信息泄露到未来(或被掩码的)标记,正如我们打算的那样。

尽管我们可以在这一点上完成因果注意力的实现,但我们仍然可以改进它。让我们利用 softmax 函数的一个数学特性,并在图 3.21 中展示的更少的步骤中更高效地计算掩码注意力权重。

figure

图 3.21 在因果注意力中,获取掩码注意力权重矩阵的一种更高效的方法是在应用 softmax 函数之前用负无穷大值掩码注意力分数。

Softmax 函数将其输入转换为概率分布。当一行中存在负无穷大值(-∞)时,softmax 函数将其视为零概率。(从数学上讲,这是因为 e^(-∞) 趋近于 0。)

我们可以通过创建一个对角线以上的 1s 掩码,然后将这些 1s 替换为负无穷大(-inf)值来实现这个更高效的掩码“技巧”:

mask = torch.triu(torch.ones(context_length, context_length), diagonal=1)
masked = attn_scores.masked_fill(mask.bool(), -torch.inf)
print(masked)

这导致了以下掩码:

tensor([[0.2899,   -inf,   -inf,   -inf,   -inf,   -inf],
        [0.4656, 0.1723,   -inf,   -inf,   -inf,   -inf],
        [0.4594, 0.1703, 0.1731,   -inf,   -inf,   -inf],
        [0.2642, 0.1024, 0.1036, 0.0186,   -inf,   -inf],
        [0.2183, 0.0874, 0.0882, 0.0177, 0.0786,   -inf],
        [0.3408, 0.1270, 0.1290, 0.0198, 0.1290, 0.0078]],
       grad_fn=<MaskedFillBackward0>)

现在我们需要做的就是将这些掩码结果应用 softmax 函数,任务就完成了:

attn_weights = torch.softmax(masked / keys.shape[-1]**0.5, dim=1)
print(attn_weights)

如输出所示,每行的值之和为 1,因此不需要进一步归一化:

tensor([[1.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.5517, 0.4483, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.3800, 0.3097, 0.3103, 0.0000, 0.0000, 0.0000],
        [0.2758, 0.2460, 0.2462, 0.2319, 0.0000, 0.0000],
        [0.2175, 0.1983, 0.1984, 0.1888, 0.1971, 0.0000],
        [0.1935, 0.1663, 0.1666, 0.1542, 0.1666, 0.1529]],
       grad_fn=<SoftmaxBackward0>)

我们现在可以使用修改后的注意力权重通过context_vec = attn_weights @ values来计算上下文向量,正如 3.4 节中所述。然而,我们首先将介绍对因果注意力机制的一个小调整,这对于在训练 LLMs 时减少过拟合是有用的。

3.5.2 使用 dropout 掩码额外的注意力权重

深度学习中的Dropout是一种技术,在训练期间随机选择隐藏层单元被忽略,有效地“丢弃”它们。这种方法通过确保模型不会过度依赖任何特定的隐藏层单元集来帮助防止过拟合。重要的是要强调,dropout 仅在训练期间使用,并在之后被禁用。

在 transformer 架构中,包括 GPT 等模型,注意力机制中的 dropout 通常在两个特定时间应用:在计算注意力权重之后或应用注意力权重到值向量之后。在这里,我们将像图 3.22 所示的那样在计算注意力权重后应用 dropout 掩码,因为在实践中这是更常见的变体。

figure

图 3.22 使用因果注意力掩码(左上角),我们应用一个额外的 dropout 掩码(右上角)来置零更多的注意力权重,以减少训练过程中的过拟合。

在下面的代码示例中,我们使用 50%的 dropout 率,这意味着屏蔽掉一半的注意力权重。(当我们后续章节中训练 GPT 模型时,我们将使用较低的 dropout 率,如 0.1 或 0.2。)我们首先应用 PyTorch 的 dropout 实现到一个由 1 组成的 6×6 张量,以简化操作:

torch.manual_seed(123)
dropout = torch.nn.Dropout(0.5)    #1
example = torch.ones(6, 6)      #2
print(dropout(example))

1 我们选择 50%的 dropout 率。

2 在这里,我们创建一个由 1 组成的矩阵。

如我们所见,大约一半的值被置零:

tensor([[2., 2., 0., 2., 2., 0.],
        [0., 0., 0., 2., 0., 2.],
        [2., 2., 2., 2., 0., 2.],
        [0., 2., 2., 0., 0., 2.],
        [0., 2., 0., 2., 0., 2.],
        [0., 2., 2., 2., 2., 0.]])

当以 50%的比率对注意力权重矩阵应用 dropout 时,矩阵中一半的元素被随机设置为零。为了补偿活动元素数量的减少,矩阵中剩余元素的价值以 1/0.5 = 2 的因子放大。这种缩放对于保持注意力权重的整体平衡至关重要,确保在训练和推理阶段注意力机制的平均影响保持一致。

现在让我们将 dropout 应用于注意力权重矩阵本身:

torch.manual_seed(123)
print(dropout(attn_weights))

结果的注意力权重矩阵现在有额外的元素被置零,剩余的 1 被重新缩放:

tensor([[2.0000, 0.0000, 0 .0000, 0.0000, 0.0000, 0.0000],
        [0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.7599, 0.6194, 0.6206, 0.0000, 0.0000, 0.0000],
        [0.0000, 0.4921, 0.4925, 0.0000, 0.0000, 0.0000],
        [0.0000, 0.3966, 0.0000, 0.3775, 0.0000, 0.0000],
        [0.0000, 0.3327, 0.3331, 0.3084, 0.3331, 0.0000]],
       grad_fn=<MulBackward0>

注意,由于操作系统的不同,生成的 dropout 输出可能会有所不同;你可以在 PyTorch 问题跟踪器上了解更多关于这种不一致性的信息,链接为github.com/pytorch/pytorch/issues/121595

在理解了因果注意力和 dropout 掩码之后,我们现在可以开发一个简洁的 Python 类。这个类旨在促进这两种技术的有效应用。

3.5.3 实现紧凑的因果注意力类

我们现在将因果注意力和 dropout 修改纳入我们在 3.4 节中开发的SelfAttentionPython 类。这个类将作为开发多头注意力的模板,这是我们最终要实现的注意力类。

但在我们开始之前,让我们确保代码可以处理包含多个输入的批次,这样CausalAttention类就能支持我们在第二章中实现的数据加载器产生的批输出。

为了简单起见,为了模拟这样的批输入,我们复制了输入文本示例:

batch = torch.stack((inputs, inputs), dim=0)
print(batch.shape)                #1

1 两个输入,每个输入有六个标记;每个标记的嵌入维度为 3。

这导致了一个三维张量,由两个输入文本组成,每个输入文本有六个标记,其中每个标记是一个三维嵌入向量:

torch.Size([2, 6, 3])

以下CausalAttention类与我们之前实现的SelfAttention类相似,除了我们添加了 dropout 和因果掩码组件。

列表 3.3 一个紧凑的因果注意力类
class CausalAttention(nn.Module):
    def __init__(self, d_in, d_out, context_length,
                dropout, qkv_bias=False):
        super().__init__()
        self.d_out = d_out
        self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_key   = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.dropout = nn.Dropout(dropout)            #1
        self.register_buffer(
           'mask',
           torch.triu(torch.ones(context_length, context_length),
           diagonal=1)
        )             #2

    def forward(self, x):
        b, num_tokens, d_in = x.shape                   #3
        keys = self.W_key(x)
        queries = self.W_query(x)
        values = self.W_value(x)

        attn_scores = queries @ keys.transpose(1, 2)   
        attn_scores.masked_fill_(                    #4
            self.mask.bool()[:num_tokens, :num_tokens], -torch.inf) 
        attn_weights = torch.softmax(
            attn_scores / keys.shape[-1]**0.5, dim=-1
        )
        attn_weights = self.dropout(attn_weights)

        context_vec = attn_weights @ values
        return context_vec

1 与之前的 SelfAttention_v1 类相比,我们添加了一个 dropout 层。

2 register_buffer调用也是一个新增功能(更多信息将在下文提供)。

3 我们交换维度 1 和 2,保持批维度在第一个位置(0)。

4 在 PyTorch 中,带有尾随下划线的操作是在原地执行的,避免了不必要的内存复制。

尽管此时所有添加的代码行都应该熟悉,但我们现在在__init__方法中添加了一个self.register_buffer()调用。在 PyTorch 中,register_buffer的使用对于所有用例并非严格必要,但在这里提供了几个优点。例如,当我们在我们的大型语言模型(LLM)中使用CausalAttention类时,缓冲区会自动移动到与我们的模型相同的设备(CPU 或 GPU)上,这在训练我们的 LLM 时将是相关的。这意味着我们不需要手动确保这些张量与我们的模型参数位于同一设备上,从而避免了设备不匹配错误。

我们可以使用CausalAttention类如下,类似于之前实现的SelfAttention

torch.manual_seed(123)
context_length = batch.shape[1]
ca = CausalAttention(d_in, d_out, context_length, 0.0)
context_vecs = ca(batch)
print("context_vecs.shape:", context_vecs.shape)

结果上下文向量是一个三维张量,其中每个标记现在由一个二维嵌入表示:

context_vecs.shape: torch.Size([2, 6, 2])

图 3.23 总结了我们迄今为止所取得的成果。我们专注于神经网络中因果注意力的概念和实现。接下来,我们将在此基础上扩展这个概念,并实现一个多头注意力模块,该模块并行实现多个因果注意力机制。

figure

图 3.23 这是我们迄今为止所做的工作。我们从简化的注意力机制开始,添加了可训练的权重,然后添加了因果注意力掩码。接下来,我们将扩展因果注意力机制,并实现多头注意力,我们将在我们的 LLM 中使用它。

3.6 将单头注意力扩展到多头注意力

我们的最后一步将是将之前实现的因果注意力类扩展到多个头。这也被称为多头注意力

“多头”一词指的是将注意力机制分成多个“头”,每个头独立操作。在这个上下文中,一个单因果注意力模块可以被认为是单头注意力,其中只有一个注意力权重集按顺序处理输入。

我们将从这个因果注意力扩展到多头注意力。首先,我们将通过堆叠多个CausalAttention模块直观地构建一个多头注意力模块。然后,我们将以更复杂但更计算高效的方式实现相同的多头注意力模块。

3.6.1 堆叠多个单头注意力层

在实际应用中,实现多头注意力机制涉及创建多个自注意力机制的实例(见图 3.18),每个实例都有自己的权重,然后将它们的输出组合起来。使用多个自注意力机制的实例可能会计算量较大,但对于像基于 transformer 的 LLM 模型所擅长的复杂模式识别来说,这是至关重要的。

图 3.24 展示了多头注意力模块的结构,它由多个单头注意力模块组成,如图 3.18 中所示,它们相互堆叠。

figure

图 3.24 多头注意力模块包括两个单头注意力模块,它们相互堆叠。因此,在具有两个头的多头注意力模块中,我们不再使用单个矩阵 W[v]来计算值矩阵,而是有两个值权重矩阵:W[v1]和 W[v2]。其他权重矩阵,如 W[Q]和 W[k],也是如此。我们获得两组上下文向量 Z[1]和 Z[2],我们可以将它们组合成一个单一的上下文向量矩阵 Z。

如前所述,多头注意力的主要思想是多次(并行)运行注意力机制,使用不同的、学习到的线性投影——通过权重矩阵乘以输入数据(如注意力机制中的查询、键和值向量)的结果。在代码中,我们可以通过实现一个简单的MultiHeadAttentionWrapper类来实现这一点,该类堆叠了我们之前实现的CausalAttention模块的多个实例。

列表 3.4 实现多头注意力的包装类
class MultiHeadAttentionWrapper(nn.Module):
    def __init__(self, d_in, d_out, context_length,
                 dropout, num_heads, qkv_bias=False):
        super().__init__()
        self.heads = nn.ModuleList(
            [CausalAttention(
                 d_in, d_out, context_length, dropout, qkv_bias
             ) 
             for _ in range(num_heads)]
        )

    def forward(self, x):
        return torch.cat([head(x) for head in self.heads], dim=-1)

例如,如果我们使用这个MultiHeadAttentionWrapper类,具有两个注意力头(通过num_heads=2)和CausalAttention输出维度d_out=2,我们得到一个四维的上下文向量(d_out*num_heads=4),如图 3.25 所示。

figure

图 3.25 使用MultiHeadAttentionWrapper,我们指定了注意力头部的数量(num_heads)。如果我们设置num_heads=2,如本例所示,我们得到一个包含两套上下文向量矩阵的张量。在每个上下文向量矩阵中,行表示与标记对应的上下文向量,列对应通过d_out=4指定的嵌入维度。我们沿着列维度连接这些上下文向量矩阵。由于我们有两个注意力头部和一个嵌入维度为 2,最终的嵌入维度是 2 × 2 = 4。

为了进一步用具体例子说明这一点,我们可以使用与之前的CausalAttention类相似的MultiHeadAttentionWrapper类:

torch.manual_seed(123)
context_length = batch.shape[1] # This is the number of tokens
d_in, d_out = 3, 2
mha = MultiHeadAttentionWrapper(
    d_in, d_out, context_length, 0.0, num_heads=2
)
context_vecs = mha(batch)

print(context_vecs)
print("context_vecs.shape:", context_vecs.shape)

这导致以下张量表示上下文向量:

tensor([[[-0.4519,  0.2216,  0.4772,  0.1063],
         [-0.5874,  0.0058,  0.5891,  0.3257],
         [-0.6300, -0.0632,  0.6202,  0.3860],
         [-0.5675, -0.0843,  0.5478,  0.3589],
         [-0.5526, -0.0981,  0.5321,  0.3428],
         [-0.5299, -0.1081,  0.5077,  0.3493]],

        [[-0.4519,  0.2216,  0.4772,  0.1063],
         [-0.5874,  0.0058,  0.5891,  0.3257],
         [-0.6300, -0.0632,  0.6202,  0.3860],
         [-0.5675, -0.0843,  0.5478,  0.3589],
         [-0.5526, -0.0981,  0.5321,  0.3428],
         [-0.5299, -0.1081,  0.5077,  0.3493]]], grad_fn=<CatBackward0>)
context_vecs.shape: torch.Size([2, 6, 4])

结果的context_vecs张量的第一维是 2,因为我们有两个输入文本(输入文本被复制了,这就是为什么那些上下文向量完全相同)。第二维指的是每个输入中的 6 个标记。第三维指的是每个标记的四维嵌入。

练习 3.2 返回二维嵌入向量

修改MultiHeadAttentionWrapper(..., num_heads=2)调用的输入参数,使得输出上下文向量是二维的,而不是四维的,同时保持num_heads=2的设置。提示:您不需要修改类实现;您只需更改其他输入参数之一。

到目前为止,我们已经实现了一个MultiHeadAttentionWrapper,它结合了多个单头注意力模块。然而,这些模块在正向方法中是顺序处理的,通过[head(x) for head in self.heads]。我们可以通过并行处理头部来改进这个实现。实现这一目标的一种方法是通过矩阵乘法同时计算所有注意力头部的输出。

3.6.2 使用权重拆分实现多头注意力

到目前为止,我们已经创建了一个MultiHeadAttentionWrapper,通过堆叠多个单头注意力模块来实现多头注意力。这是通过实例化和组合几个CausalAttention对象来完成的。

我们可以不是维护两个独立的类,MultiHeadAttentionWrapperCausalAttention,而是将这些概念合并成一个MultiHeadAttention类。此外,除了将MultiHeadAttentionWrapperCausalAttention代码合并之外,我们还将进行一些其他修改,以更有效地实现多头注意力。

MultiHeadAttentionWrapper 中,通过创建一个 CausalAttention 对象列表(self.heads),每个对象代表一个单独的注意力头,实现了多个头。CausalAttention 类独立执行注意力机制,并将每个头的输出连接起来。相比之下,下面的 MultiHeadAttention 类在单个类中集成了多头功能。它通过重塑投影查询、键和值张量来分割输入,然后在计算注意力后结合这些头的输出。

在进一步讨论之前,让我们看看 MultiHeadAttention 类。

列表 3.5 一个高效的多头注意力类
class MultiHeadAttention(nn.Module):
    def __init__(self, d_in, d_out, 
                 context_length, dropout, num_heads, qkv_bias=False):
        super().__init__()
        assert (d_out % num_heads == 0), \
            "d_out must be divisible by num_heads"

        self.d_out = d_out
        self.num_heads = num_heads
        self.head_dim = d_out // num_heads    #1
        self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_key = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.out_proj = nn.Linear(d_out, d_out)    #2
        self.dropout = nn.Dropout(dropout)
        self.register_buffer(
            "mask",
            torch.triu(torch.ones(context_length, context_length),
                       diagonal=1)
        )

    def forward(self, x):
        b, num_tokens, d_in = x.shape
        keys = self.W_key(x)         #3
        queries = self.W_query(x)    #3
        values = self.W_value(x)     #3

        keys = keys.view(b, num_tokens, self.num_heads, self.head_dim)       #4
        values = values.view(b, num_tokens, self.num_heads, self.head_dim)  
        queries = queries.view(                                             
            b, num_tokens, self.num_heads, self.head_dim                    
        )                                                                   

        keys = keys.transpose(1, 2)          #5
        queries = queries.transpose(1, 2)    #5
        values = values.transpose(1, 2)      #5

        attn_scores = queries @ keys.transpose(2, 3)   #6
        mask_bool = self.mask.bool()[:num_tokens, :num_tokens]    #7

        attn_scores.masked_fill_(mask_bool, -torch.inf)     #8

        attn_weights = torch.softmax(
            attn_scores / keys.shape[-1]**0.5, dim=-1)
        attn_weights = self.dropout(attn_weights)

        context_vec = (attn_weights @ values).transpose(1, 2)   #9
 #10
        context_vec = context_vec.contiguous().view(
            b, num_tokens, self.d_out
        )
        context_vec = self.out_proj(context_vec)    #11
        return context_vec

1 将投影维度减少以匹配所需的输出维度

2 使用线性层来组合头输出

3 张量形状:(b, num_tokens, d_out)

4 我们通过添加一个 num_heads 维度隐式地分割了矩阵。然后我们展开最后一个维度:(b, num_tokens, d_out) -> (b, num_tokens, num_heads, head_dim)。

5 从形状 (b, num_tokens, num_heads, head_dim) 转置到 (b, num_heads, num_tokens, head_dim)

6 为每个头计算点积

7 掩码截断到令牌数量

8 使用掩码填充注意力分数

9 张量形状:(b, num_tokens, n_heads, head_dim)

10 合并头,其中 self.d_out = self.num_heads * self.head_dim

11 添加一个可选的线性投影

尽管在 MultiHeadAttention 类内部张量的重塑(.view)和转置(.transpose)看起来非常数学复杂,但 MultiHeadAttention 类实现了与之前 MultiHeadAttentionWrapper 相同的概念。

从宏观角度来看,在之前的 MultiHeadAttentionWrapper 中,我们堆叠了多个单头注意力层,并将它们组合成一个多头注意力层。MultiHeadAttention 类采用了一种综合方法。它从一个多头层开始,然后内部将这个层分割成单独的注意力头,如图 3.26 所示。

figure

图 3.26 在具有两个注意力头的 MultiHeadAttentionWrapper 类中,我们初始化了两个权重矩阵 W[q1] 和 W[q2],并计算了两个查询矩阵 Q[1] 和 Q[2](顶部)。在 MultiheadAttention 类中,我们初始化了一个更大的权重矩阵 W[q],只与输入进行一次矩阵乘法以获得查询矩阵 Q,然后将查询矩阵分割成 Q[1] 和 Q[2](底部)。我们对键和值也做了同样的处理,这里没有展示以减少视觉混乱。

通过使用 PyTorch 的 .view.transpose 方法进行张量重塑和转置操作,实现了查询、键和值张量的分割。输入首先通过查询、键和值的线性层进行转换,然后重塑以表示多个头。

关键操作是将d_out维度分割成num_headshead_dim,其中head_dim = d_out / num_heads。然后使用.view方法实现这种分割:将维度为(b, num_tokens, d_out)的张量重塑为维度(b, num_tokens, num_heads, head_dim)

然后将张量转置,以便将num_heads维度放在num_tokens维度之前,从而得到形状为(b, num_heads, num_tokens, head_dim)。这种转置对于正确地对齐不同头部的查询、键和值以及高效地执行批量矩阵乘法至关重要。

为了说明这种批量矩阵乘法,假设我们有一个以下张量:

a = torch.tensor([[[[0.2745, 0.6584, 0.2775, 0.8573],    #1
                    [0.8993, 0.0390, 0.9268, 0.7388],
                    [0.7179, 0.7058, 0.9156, 0.4340]],

                   [[0.0772, 0.3565, 0.1479, 0.5331],
                    [0.4066, 0.2318, 0.4545, 0.9737],
                    [0.4606, 0.5159, 0.4220, 0.5786]]]])

1 这个张量的形状是(b, num_heads, num_tokens, head_dim) = (1, 2, 3, 4)。

现在我们对张量本身及其一个视图执行批量矩阵乘法,其中我们转置了最后两个维度,即num_tokenshead_dim

print(a @ a.transpose(2, 3))

结果是

tensor([[[[1.3208, 1.1631, 1.2879],
          [1.1631, 2.2150, 1.8424],
          [1.2879, 1.8424, 2.0402]],

         [[0.4391, 0.7003, 0.5903],
          [0.7003, 1.3737, 1.0620],
          [0.5903, 1.0620, 0.9912]]]])

在这种情况下,PyTorch 中的矩阵乘法实现处理了四维输入张量,使得矩阵乘法在最后两个维度(num_tokens, head_dim)之间进行,然后为每个单独的头重复执行。

例如,前面的方法成为了一种更紧凑的方式来分别计算每个头的矩阵乘法:

first_head = a[0, 0, :, :]
first_res = first_head @ first_head.T
print("First head:\n", first_res)

second_head = a[0, 1, :, :]
second_res = second_head @ second_head.T
print("\nSecond head:\n", second_res)

结果与使用批量矩阵乘法print(a @ a.transpose(2, 3))得到的结果完全相同:

First head:
 tensor([[1.3208, 1.1631, 1.2879],
        [1.1631, 2.2150, 1.8424],
        [1.2879, 1.8424, 2.0402]])

Second head:
 tensor([[0.4391, 0.7003, 0.5903],
        [0.7003, 1.3737, 1.0620],
        [0.5903, 1.0620, 0.9912]])

继续使用MultiHeadAttention,在计算注意力权重和上下文向量之后,所有头部的上下文向量被转置回形状(b, num_tokens, num_heads, head_dim)。然后这些向量被重塑(展平)为形状(b, num_tokens, d_out),有效地结合了所有头部的输出。

此外,我们在将头部合并后,向MultiHeadAttention添加了一个输出投影层(self.out_proj),这在CausalAttention类中不存在。这个输出投影层不是严格必要的(更多细节请见附录 B),但它被广泛应用于许多 LLM 架构中,因此我为了完整性在此添加了它。

尽管由于额外的张量重塑和转置,MultiHeadAttention类看起来比MultiHeadAttentionWrapper更复杂,但它更高效。原因是,我们只需要一次矩阵乘法来计算键,例如,keys = self.W_key(x)(对于查询和值也是如此)。在MultiHeadAttentionWrapper中,我们需要为每个注意力头重复这个矩阵乘法,这是计算上最昂贵的步骤之一。

MultiHeadAttention类可以像我们之前实现的SelfAttentionCausalAttention类一样使用:

torch.manual_seed(123)
batch_size, context_length, d_in = batch.shape
d_out = 2
mha = MultiHeadAttention(d_in, d_out, context_length, 0.0, num_heads=2)
context_vecs = mha(batch)
print(context_vecs)
print("context_vecs.shape:", context_vecs.shape)

结果表明输出维度直接受d_out参数控制:

tensor([[[0.3190, 0.4858],
         [0.2943, 0.3897],
         [0.2856, 0.3593],
         [0.2693, 0.3873],
         [0.2639, 0.3928],
         [0.2575, 0.4028]],

        [[0.3190, 0.4858],
         [0.2943, 0.3897],
         [0.2856, 0.3593],
         [0.2693, 0.3873],
         [0.2639, 0.3928],
         [0.2575, 0.4028]]], grad_fn=<ViewBackward0>)
context_vecs.shape: torch.Size([2, 6, 2])

我们现在已经实现了MultiHeadAttention类,当我们实现和训练 LLM 时将使用这个类。请注意,虽然代码完全可用,但我使用了相对较小的嵌入大小和注意力头数,以保持输出可读。

为了比较,最小的 GPT-2 模型(1.17 亿参数)有 12 个注意力头和 768 维的上下文向量嵌入大小。最大的 GPT-2 模型(15 亿参数)有 25 个注意力头和 1,600 维的上下文向量嵌入大小。在 GPT 模型中,标记输入和上下文嵌入的嵌入大小是相同的(d_in = d_out)。

练习 3.3 初始化 GPT-2 大小注意力模块

使用MultiHeadAttention类,初始化一个具有与最小的 GPT-2 模型(12 个注意力头)相同数量的注意力头的多头注意力模块。同时确保您使用与 GPT-2 类似的相应输入和输出嵌入大小(768 维)。请注意,最小的 GPT-2 模型支持 1,024 个标记的上下文长度。

摘要

  • 注意力机制将输入元素转换为增强的上下文向量表示,该表示包含有关所有输入的信息。

  • 自注意力机制通过输入的加权和来计算上下文向量表示。

  • 在简化的注意力机制中,注意力权重通过点积来计算。

  • 点积是一种简洁的方式,通过逐元素相乘然后求和来乘以两个向量。

  • 矩阵乘法,虽然不是严格必需的,但通过替换嵌套的for循环,有助于我们更高效和紧凑地实现计算。

  • 在 LLM 中使用的自注意力机制,也称为缩放点积注意力,我们包括可训练的权重矩阵来计算输入的中间变换:查询、值和键。

  • 当与从左到右读取和生成文本的 LLM 一起工作时,我们添加一个因果注意力掩码以防止 LLM 访问未来的标记。

  • 除了用于将注意力权重置零的因果注意力掩码外,我们还可以添加一个 dropout 掩码以减少 LLM 中的过拟合。

  • 基于 transformer 的 LLM 中的注意力模块涉及多个因果注意力实例,这被称为多头注意力。

  • 我们可以通过堆叠多个因果注意力模块的实例来创建一个多头注意力模块。

  • 创建多头注意力模块的更有效的方法涉及批量矩阵乘法。

第四章:从零开始实现 GPT 模型以生成文本

本章涵盖了

  • 编码一个可以训练生成类似人类文本的 GPT-like 大型语言模型(LLM)

  • 将层激活值归一化以稳定神经网络训练

  • 在深度神经网络中添加快捷连接

  • 实现 transformer 块以创建各种大小的 GPT 模型

  • 计算 GPT 模型的参数数量和存储需求

你已经学习和编码了 LLM 的核心组件之一多头注意力机制。现在,我们将编码 LLM 的其他构建块,并将它们组装成一个 GPT-like 模型,我们将在下一章中训练它以生成类似人类的文本。

figure

图 4.1 编码 LLM 的三个主要阶段。本章重点介绍第一阶段步骤 3:实现 LLM 架构。

图 4.1 中引用的 LLM 架构由几个构建块组成。我们将从模型架构的顶向下视图开始,然后再更详细地介绍各个组件。

4.1 编码 LLM 架构

LLMs,如 GPT(代表生成预训练转换器),是设计用来一次生成一个单词(或标记)的新文本的大型深度神经网络架构。然而,尽管它们的规模很大,但模型架构并不像你可能想象的那样复杂,因为其中许多组件是重复的,正如我们稍后将看到的。图 4.2 提供了一个类似 GPT 的 LLM 的顶向下视图,其主要组件被突出显示。

figure

图 4.2 GPT 模型。除了嵌入层外,它还包括一个或多个包含我们之前实现的掩码多头注意力模块的 transformer 块。

我们已经涵盖了 LLM 架构的几个方面,例如输入分词和嵌入以及之前实现的掩码多头注意力模块。现在,我们将实现 GPT 模型的核心结构,包括其transformer 块,我们将在以后训练它们以生成类似人类的文本。

以前,为了简单起见,我们使用了较小的嵌入维度,确保概念和示例可以舒适地放在一页上。现在,我们正在将其扩展到小型 GPT-2 模型的大小,具体是最小的 124 百万参数版本,如 Radford 等人所描述的“语言模型是无监督的多任务学习者”(mng.bz/yoBq)。请注意,尽管原始报告提到 117 百万参数,但这后来被更正了。在第六章中,我们将专注于将预训练的权重加载到我们的实现中,并适应具有 345 亿、762 亿和 15.42 亿参数的更大 GPT-2 模型。

在深度学习和 GPT 等 LLM 的背景下,“参数”一词指的是模型的可训练权重。这些权重实际上是模型在训练过程中调整和优化的内部变量,以最小化特定的损失函数。这种优化使模型能够从训练数据中学习。

例如,在一个由 2,048 × 2,048 维度的权重矩阵(或张量)表示的神经网络层中,这个矩阵的每个元素都是一个参数。由于有 2,048 行和 2,048 列,这个层中的参数总数是 2,048 乘以 2,048,等于 4,194,304 个参数。

GPT-2 与 GPT-3

注意,我们专注于 GPT-2,因为 OpenAI 已经将预训练模型的权重公开,我们将在第六章将其加载到我们的实现中。GPT-3 在模型架构方面基本上是相同的,但它从 GPT-2 的 15 亿参数扩展到 GPT-3 的 1750 亿参数,并且训练在更多数据上。截至本文撰写时,GPT-3 的权重尚未公开。GPT-2 也是学习如何实现 LLM 的更好选择,因为它可以在单个笔记本电脑上运行,而 GPT-3 需要 GPU 集群进行训练和推理。根据 Lambda Labs (lambdalabs.com/) 的数据,在单个 V100 数据中心 GPU 上训练 GPT-3 需要 355 年,在消费者 RTX 8000 GPU 上需要 665 年。

我们通过以下 Python 字典指定小型 GPT-2 模型的配置,我们将在后面的代码示例中使用它:

GPT_CONFIG_124M = {
    "vocab_size": 50257,     # Vocabulary size
    "context_length": 1024,  # Context length
    "emb_dim": 768,          # Embedding dimension
    "n_heads": 12,           # Number of attention heads
    "n_layers": 12,          # Number of layers
    "drop_rate": 0.1,        # Dropout rate
    "qkv_bias": False        # Query-Key-Value bias
}

GPT_CONFIG_124M 字典中,我们使用简洁的变量名以提高清晰度并防止代码行过长:

  • vocab_size 指的是 BPE 分词器使用的 50,257 个单词的词汇量(见第二章)。

  • context_length 表示模型通过位置嵌入可以处理的最大输入标记数(见第二章)。

  • emb_dim 表示嵌入大小,将每个标记转换为 768 维的向量。

  • n_heads 表示多头注意力机制中的注意力头数量(见第三章)。

  • n_layers 指定了模型中的 transformer 块的数量,我们将在接下来的讨论中介绍。

  • drop_rate 表示 dropout 机制的强度(0.1 表示隐藏单元随机丢弃 10%),以防止过拟合(见第三章)。

  • qkv_bias 决定了是否在多头注意力中的 Linear 层为查询、键和值计算包含一个偏置向量。我们最初将禁用此功能,遵循现代大型语言模型(LLM)的规范,但在第六章中,当我们从 OpenAI 加载预训练的 GPT-2 权重到我们的模型时,我们将重新审视它(见第六章)。

使用此配置,我们将实现一个 GPT 占位符架构 (DummyGPTModel),如图 4.3 所示。这将为我们提供一个整体视图,了解所有组件如何组合在一起,以及我们需要编写哪些其他组件来组装完整的 GPT 模型架构。

figure

图 4.3 我们编码 GPT 架构的顺序。我们首先从 GPT 背骨,一个占位符架构开始,然后到达各个核心组件,最终将它们组装成一个 TransformerBlock,以形成最终的 GPT 架构。

图 4.3 中的编号框说明了我们解决编码最终 GPT 架构所需的各个概念的顺序。我们将从步骤 1 开始,一个我们将称之为 DummyGPTModel 的占位符 GPT 背骨。

列表 4.1 一个占位符 GPT 模型架构类
import torch
import torch.nn as nn

class DummyGPTModel(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
        self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
        self.drop_emb = nn.Dropout(cfg["drop_rate"])
        self.trf_blocks = nn.Sequential(               #1
            *[DummyTransformerBlock(cfg)               #1
              for _ in range(cfg["n_layers"])]         #1
        )                                              #1
        self.final_norm = DummyLayerNorm(cfg["emb_dim"])     #2
        self.out_head = nn.Linear(
            cfg["emb_dim"], cfg["vocab_size"], bias=False
        )

    def forward(self, in_idx):
        batch_size, seq_len = in_idx.shape
        tok_embeds = self.tok_emb(in_idx)
        pos_embeds = self.pos_emb(
            torch.arange(seq_len, device=in_idx.device)
        )
        x = tok_embeds + pos_embeds
        x = self.drop_emb(x)
        x = self.trf_blocks(x)
        x = self.final_norm(x)
        logits = self.out_head(x)
        return logits

class DummyTransformerBlock(nn.Module):    #3
    def __init__(self, cfg):
        super().__init__()

    def forward(self, x):     #4
        return x

class DummyLayerNorm(nn.Module):           #5
    def __init__(self, normalized_shape, eps=1e-5):    #6
        super().__init__()

    def forward(self, x):
        return x

1 使用占位符代替 TransformerBlock

2 使用占位符代替 LayerNorm

3 一个简单的占位符类,稍后将由实际的 TransformerBlock 替换

4 此块不做任何操作,只是返回其输入。

5 一个简单的占位符类,稍后将由实际的 LayerNorm 替换

6 这里的参数只是为了模仿 LayerNorm 接口。

代码中的 DummyGPTModel 类定义了一个使用 PyTorch 的神经网络模块 (nn.Module) 的简化版本的类似 GPT 模型。DummyGPTModel 类中的模型架构包括标记和位置嵌入、dropout、一系列的 TransformerBlock (DummyTransformerBlock)、最终的层归一化 (DummyLayerNorm) 和线性输出层 (out_head)。配置通过 Python 字典传入,例如,我们之前创建的 GPT_CONFIG_124M 字典。

forward 方法描述了数据在模型中的流动:它为输入索引计算标记和位置嵌入,应用 dropout,通过 TransformerBlock 处理数据,应用归一化,并最终通过线性输出层产生 logits。

列表 4.1 中的代码已经可以工作。然而,目前请注意,我们使用占位符(DummyLayerNormDummyTransformerBlock)来代替 TransformerBlock 和层归一化,这些我们将在以后开发。

接下来,我们将准备输入数据并初始化一个新的 GPT 模型以展示其用法。基于我们对分词器(见第二章)的编码,现在让我们考虑一个 GPT 模型中数据流入和流出的高级概述,如图 4.4 所示。

figure

图 4.4 从一个整体概述中展示了输入数据是如何被标记化、嵌入并输入到 GPT 模型中的。注意,在我们之前编写的 DummyGPTClass 中,标记嵌入是在 GPT 模型内部处理的。在大型语言模型 (LLM) 中,嵌入的输入标记维度通常与输出维度相匹配。这里的输出嵌入代表上下文向量(见第三章)。

为了实现这些步骤,我们使用第二章中的 tiktoken 分词器对 GPT 模型的两个文本输入进行分词:

import tiktoken

tokenizer = tiktoken.get_encoding("gpt2")
batch = []
txt1 = "Every effort moves you"
txt2 = "Every day holds a"

batch.append(torch.tensor(tokenizer.encode(txt1)))
batch.append(torch.tensor(tokenizer.encode(txt2)))
batch = torch.stack(batch, dim=0)
print(batch)

两个文本的结果标记 ID 如下:

tensor([[6109,  3626,  6100,   345],    #1
        [6109,  1110,  6622,   257]])

1 第一行对应于第一段文本,第二行对应于第二段文本。

接下来,我们初始化一个新的 124 百万参数的DummyGPTModel实例,并给它提供分词后的batch

torch.manual_seed(123)
model = DummyGPTModel(GPT_CONFIG_124M)
logits = model(batch)
print("Output shape:", logits.shape)
print(logits)

模型输出,通常称为 logits,如下所示:

Output shape: torch.Size([2, 4, 50257])
tensor([[[-1.2034,  0.3201, -0.7130,  ..., -1.5548, -0.2390, -0.4667],
         [-0.1192,  0.4539, -0.4432,  ...,  0.2392,  1.3469,  1.2430],
         [ 0.5307,  1.6720, -0.4695,  ...,  1.1966,  0.0111,  0.5835],
         [ 0.0139,  1.6755, -0.3388,  ...,  1.1586, -0.0435, -1.0400]],

        [[-1.0908,  0.1798, -0.9484,  ..., -1.6047,  0.2439, -0.4530],
         [-0.7860,  0.5581, -0.0610,  ...,  0.4835, -0.0077,  1.6621],
         [ 0.3567,  1.2698, -0.6398,  ..., -0.0162, -0.1296,  0.3717],
         [-0.2407, -0.7349, -0.5102,  ...,  2.0057, -0.3694,  0.1814]]],
       grad_fn=<UnsafeViewBackward0>)

输出张量有两行,对应于两个文本样本。每个文本样本由四个标记组成;每个标记是一个 50,257 维向量,这与分词器的词汇表大小相匹配。

嵌入有 50,257 维,因为这些维度的每一个都指代词汇表中的一个独特标记。当我们实现后处理代码时,我们将这些 50,257 维向量转换回标记 ID,然后我们可以将它们解码成单词。

现在我们已经从上到下审视了 GPT 架构及其输入和输出,我们将编写单个占位符的代码,从替换先前代码中的DummyLayerNorm的真实层归一化类开始。

4.2 使用层归一化归一化激活

使用多层训练深度神经网络有时可能具有挑战性,因为梯度消失或爆炸等问题。这些问题导致训练动态不稳定,使得网络难以有效地调整其权重,这意味着学习过程难以找到一组参数(权重)以最小化损失函数。换句话说,网络难以以允许其做出准确预测或决策的程度学习数据中的潜在模式。

注意:如果你对神经网络训练和梯度概念不熟悉,可以在附录 A 的 A.4 节中找到这些概念的简要介绍。然而,为了理解本书的内容,不需要对梯度有深入数学理解。

现在我们来实现层归一化来提高神经网络训练的稳定性和效率。层归一化的主要思想是将神经网络层的激活(输出)调整为均值为 0 和方差为 1,也称为单位方差。这种调整加快了收敛到有效权重,并确保了一致、可靠的训练。在 GPT-2 和现代变换器架构中,层归一化通常在多头注意力模块前后应用,正如我们通过DummyLayerNorm占位符所看到的,在最终输出层之前。图 4.5 提供了层归一化功能的视觉概述。

figure

图 4.5 层归一化的插图,其中层的六个输出(也称为激活)被归一化,使得它们具有 0 均值和 1 方差。

我们可以通过以下代码重现图 4.5 所示的示例,其中我们实现了一个具有五个输入和六个输出的神经网络层,并将其应用于两个输入示例:

torch.manual_seed(123)
batch_example = torch.randn(2, 5)     #1
layer = nn.Sequential(nn.Linear(5, 6), nn.ReLU())
out = layer(batch_example)
print(out)

1 创建两个具有五个维度(特征)的训练示例

这将打印以下张量,其中第一行列出第一个输入的层输出,第二行列出第二个输入的层输出:

tensor([[0.2260, 0.3470, 0.0000, 0.2216, 0.0000, 0.0000],
        [0.2133, 0.2394, 0.0000, 0.5198, 0.3297, 0.0000]],
       grad_fn=<ReluBackward0>)

我们编写的神经网络层由一个Linear层后跟一个非线性激活函数ReLU(即修正线性单元)组成,这是神经网络中的标准激活函数。如果您不熟悉ReLU,它只是将负输入阈值设置为 0,确保层只输出正值,这也解释了为什么结果层输出不包含任何负值。稍后,我们将在 GPT 中使用另一个更复杂的激活函数。

在我们将层归一化应用于这些输出之前,让我们检查均值和方差:

mean = out.mean(dim=-1, keepdim=True)
var = out.var(dim=-1, keepdim=True)
print("Mean:\n", mean)
print("Variance:\n", var)

输出是

Mean:
  tensor([[0.1324],
          [0.2170]], grad_fn=<MeanBackward1>)
Variance:
  tensor([[0.0231],
          [0.0398]], grad_fn=<VarBackward0>)

均值张量的第一行包含第一个输入行的均值值,第二输出行包含第二个输入行的均值。

在均值或方差计算等操作中使用keepdim=True确保输出张量保留与输入张量相同的维度数,尽管操作减少了通过dim指定的维度。例如,如果不使用keepdim=True,返回的均值张量将是一个二维向量[0.1324, 0.2170],而不是一个 2 × 1 维度的矩阵[[0.1324], [0.2170]]

dim参数指定了在张量中计算统计量(此处为均值或方差)应进行的维度。如图 4.6 所示,对于二维张量(如矩阵),在均值或方差计算等操作中使用dim=-1与使用dim=1相同。这是因为-1指的是张量的最后一个维度,在二维张量中对应于列。后来,当我们将层归一化添加到 GPT 模型中,该模型产生形状为[batch_size, num_tokens, embedding_size]的三维张量时,我们仍然可以使用dim=-1对最后一个维度进行归一化,避免从dim=1变为dim=2

figure

图 4.6 展示了计算张量均值时的dim参数。例如,如果我们有一个维度为[rows, columns]的两维张量(矩阵),使用dim=0将在行(垂直,如图底部所示)上执行操作,结果将聚合每列的数据。使用dim=1dim=-1将在列(水平,如图顶部所示)上执行操作,结果将聚合每行的数据。

接下来,让我们将层归一化应用于我们之前获得的层输出。这个操作包括减去平均值并除以方差的平方根(也称为标准差):

out_norm = (out - mean) / torch.sqrt(var)
mean = out_norm.mean(dim=-1, keepdim=True)
var = out_norm.var(dim=-1, keepdim=True)
print("Normalized layer outputs:\n", out_norm)
print("Mean:\n", mean)
print("Variance:\n", var)

根据结果,我们可以看到,归一化的层输出,现在也包含负值,具有 0 均值和 1 的方差:

Normalized layer outputs:
 tensor([[ 0.6159,  1.4126, -0.8719,  0.5872, -0.8719, -0.8719],
        [-0.0189,  0.1121, -1.0876,  1.5173,  0.5647, -1.0876]],
       grad_fn=<DivBackward0>)
Mean:
 tensor([[-5.9605e-08],
        [1.9868e-08]], grad_fn=<MeanBackward1>)
Variance:
 tensor([[1.],
        [1.]], grad_fn=<VarBackward0>)

注意到输出张量中的值-5.9605e-08 是科学记数法表示的-5.9605 × 10^(-8),以十进制形式表示为-0.000000059605。这个值非常接近 0,但由于计算机表示数字的有限精度可能积累的小数值误差,它并不完全等于 0。

为了提高可读性,我们还可以通过将sci_mode设置为False来关闭打印张量值时的科学记数法:

torch.set_printoptions(sci_mode=False)
print("Mean:\n", mean)
print("Variance:\n", var)

输出是

Mean:
 tensor([[    0.0000],
        [    0.0000]], grad_fn=<MeanBackward1>)
Variance:
 tensor([[1.],
        [1.]], grad_fn=<VarBackward0>)

到目前为止,我们已经逐步编写并应用了层归一化。现在,让我们将这个过程封装在一个 PyTorch 模块中,我们可以在后面的 GPT 模型中使用它。

列表 4.2 层归一化类
class LayerNorm(nn.Module):
    def __init__(self, emb_dim):
        super().__init__()
        self.eps = 1e-5
        self.scale = nn.Parameter(torch.ones(emb_dim))
        self.shift = nn.Parameter(torch.zeros(emb_dim))

    def forward(self, x):
        mean = x.mean(dim=-1, keepdim=True)
        var = x.var(dim=-1, keepdim=True, unbiased=False)
        norm_x = (x - mean) / torch.sqrt(var + self.eps)
        return self.scale * norm_x + self.shift

这种层归一化的具体实现操作在输入张量 x 的最后一个维度上,它代表嵌入维度(emb_dim)。变量eps是一个小的常数(epsilon),在归一化过程中添加到方差中,以防止除以零。scaleshift是两个可训练的参数(与输入具有相同的维度),在训练过程中,如果确定这样做会提高模型在训练任务上的性能,LLM 会自动调整这些参数。这允许模型学习适当的缩放和偏移,以最好地适应它正在处理的数据。

偏差方差

在我们的方差计算方法中,我们通过设置unbiased=False使用了一个实现细节。对于那些对此感兴趣的人来说,在方差计算中,我们根据方差公式中的输入数量n进行除法。这种方法不应用贝塞尔校正,通常在分母中使用n1而不是n来调整样本方差估计中的偏差。这个决定导致了一个所谓的偏差方差估计。对于 LLMs,其中嵌入维度n非常大,使用nn1之间的差异实际上可以忽略不计。我选择这种方法是为了确保与 GPT-2 模型的归一化层兼容,并且因为它反映了 TensorFlow 的默认行为,这是原始 GPT-2 模型所使用的。使用类似的设置确保我们的方法与我们在第六章中将要加载的预训练权重兼容。

现在我们来实际尝试使用LayerNorm模块,并将其应用于批输入:

ln = LayerNorm(emb_dim=5)
out_ln = ln(batch_example)
mean = out_ln.mean(dim=-1, keepdim=True)
var = out_ln.var(dim=-1, unbiased=False, keepdim=True)
print("Mean:\n", mean)
print("Variance:\n", var)

结果表明,层归一化代码按预期工作,并将两个输入的值归一化,使得它们的均值为 0,方差为 1:

Mean:
 tensor([[    -0.0000],
        [     0.0000]], grad_fn=<MeanBackward1>)
Variance:
 tensor([[1.0000],
        [1.0000]], grad_fn=<VarBackward0>)

我们现在已经涵盖了构建 GPT 架构所需的两个构建块,如图 4.7 所示。接下来,我们将查看 GELU 激活函数,这是 LLM 中使用的激活函数之一,而不是我们之前使用的传统 ReLU 函数。

figure

图 4.7 构建 GPT 架构所需的构建块。到目前为止,我们已经完成了 GPT 主干和层归一化。接下来,我们将专注于 GELU 激活和前馈网络。
层归一化与批归一化

如果你熟悉批归一化,这是一种常见的神经网络传统归一化方法,你可能想知道它与层归一化相比如何。与批归一化不同,批归一化是在批维度上进行归一化,而层归一化是在特征维度上进行归一化。LLM 通常需要大量的计算资源,而可用的硬件或特定的用例可能会在训练或推理期间决定批大小。由于层归一化独立于批大小对每个输入进行归一化,因此在这些情况下它提供了更多的灵活性和稳定性。这在分布式训练或将模型部署在资源受限的环境中尤其有益。

4.3 使用 GELU 激活函数实现前馈网络

接下来,我们将实现一个小型神经网络子模块,该模块作为 LLM 中 transformer 块的一部分使用。我们首先实现GELU激活函数,它在该神经网络子模块中起着至关重要的作用。

注意:有关在 PyTorch 中实现神经网络的更多信息,请参阅附录 A 中的 A.5 节。

从历史上看,ReLU 激活函数由于其简单性和在各种神经网络架构中的有效性,在深度学习中得到了广泛应用。然而,在 LLM 中,除了传统的 ReLU 之外,还使用了其他几种激活函数。两个值得注意的例子是 GELU(高斯误差线性单元)和 SwiGLU(Swish 门控线性单元)。

GELU 和 SwiGLU 是更复杂且平滑的激活函数,分别包含高斯和 sigmoid 门控线性单元。与简单的 ReLU 相比,它们为深度学习模型提供了改进的性能。

GELU 激活函数可以通过几种方式实现;确切版本定义为 GELU(x) = x⋅𝛷(x),其中𝛷(x)是标准高斯分布的累积分布函数。然而,在实践中,通常实现一个计算上更便宜的近似(原始 GPT-2 模型也是用这个近似进行训练的,这个近似是通过曲线拟合得到的):

figure

在代码中,我们可以将此函数实现为一个 PyTorch 模块。

列表 4.3 GELU 激活函数的实现
class GELU(nn.Module):
    def __init__(self):
        super().__init__()

    def forward(self, x):
        return 0.5 * x * (1 + torch.tanh(
            torch.sqrt(torch.tensor(2.0 / torch.pi)) * 
            (x + 0.044715 * torch.pow(x, 3))
        ))

接下来,为了了解这个 GELU 函数看起来像什么以及它与 ReLU 函数相比如何,让我们将这些函数并排绘制出来:

import matplotlib.pyplot as plt
gelu, relu = GELU(), nn.ReLU()

x = torch.linspace(-3, 3, 100)     #1
y_gelu, y_relu = gelu(x), relu(x)
plt.figure(figsize=(8, 3))
for i, (y, label) in enumerate(zip([y_gelu, y_relu], ["GELU", "ReLU"]), 1):
    plt.subplot(1, 2, i)
    plt.plot(x, y)
    plt.title(f"{label} activation function")
    plt.xlabel("x")
    plt.ylabel(f"{label}(x)")
    plt.grid(True)
plt.tight_layout()
plt.show()

1 在-3 到 3 的范围内创建 100 个样本数据点

如我们在图 4.8 的结果图中所见,ReLU(右侧)是一个分段线性函数,如果输入为正,则直接输出输入;否则,输出零。GELU(左侧)是一个平滑的非线性函数,它近似 ReLU,但几乎对所有负值(除了大约x = –0.75)都有一个非零梯度。

figure

图 4.8 使用 matplotlib 绘制的 GELU 和 ReLU 图。x 轴显示函数输入,y 轴显示函数输出。

GELU 的平滑性可以在训练过程中带来更好的优化特性,因为它允许对模型参数进行更细致的调整。相比之下,ReLU 在零点有一个尖锐的拐角(图 4.18,右侧),这有时会使优化更加困难,尤其是在非常深或具有复杂架构的网络中。此外,与 ReLU 不同,ReLU 对任何负输入都输出零,而 GELU 允许负值有一个小的、非零的输出。这一特性意味着在训练过程中,接收负输入的神经元仍然可以参与到学习过程中,尽管其贡献不如正输入那么大。

接下来,让我们使用 GELU 函数来实现我们将要在 LLM 的 transformer 块中使用的较小神经网络模块FeedForward

列表 4.4 前馈神经网络模块
class FeedForward(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Linear(cfg["emb_dim"], 4 * cfg["emb_dim"]),
            GELU(),
            nn.Linear(4 * cfg["emb_dim"], cfg["emb_dim"]),
        )

    def forward(self, x):
        return self.layers(x)

如我们所见,FeedForward模块是一个由两个Linear层和一个GELU激活函数组成的小型神经网络。在 124 百万参数的 GPT 模型中,它通过GPT_CONFIG_124M字典接收输入批次,其中每个标记的嵌入大小为 768。GPT_CONFIG_ 124M["emb_dim"] = 768。图 4.9 展示了当我们向这个小前馈神经网络传递一些输入时,如何操作嵌入大小。

figure

图 4.9 前馈神经网络层之间连接的概述。这个神经网络可以适应可变的批次大小和输入中的标记数量。然而,每个标记的嵌入大小在初始化权重时是确定和固定的。

按照图 4.9 中的示例,让我们初始化一个新的FeedForward模块,其标记嵌入大小为 768,并给它一个包含两个样本和每个样本三个标记的批次输入:

ffn = FeedForward(GPT_CONFIG_124M)
x = torch.rand(2, 3, 768)          #1
out = ffn(x)
print(out.shape)

1 创建具有批次维度 2 的样本输入

如我们所见,输出张量的形状与输入张量的形状相同:

torch.Size([2, 3, 768])

FeedForward模块在增强模型从数据中学习和泛化的能力方面发挥着至关重要的作用。尽管该模块的输入和输出维度相同,但它通过第一个线性层将嵌入维度扩展到更高维的空间,如图 4.10 所示。这种扩展随后通过非线性 GELU 激活,然后通过第二个线性变换收缩回原始维度。这种设计允许探索更丰富的表示空间。

figure

图 4.10 展示了前馈神经网络中层输出的扩展和收缩。首先,输入通过 4 倍从 768 扩展到 3,072 个值。然后,第二层将 3,072 个值压缩回 768 维度的表示。

此外,输入和输出维度的均匀性通过允许堆叠多个层(正如我们稍后将要做的)来简化架构,而无需调整它们之间的维度,从而使模型更具可扩展性。

如图 4.11 所示,我们已实现了 LLM 的大部分构建模块。接下来,我们将介绍我们在神经网络的不同层之间插入的捷径连接的概念,这对于提高深度神经网络架构的训练性能至关重要。

figure

图 4.11 显示了构建 GPT 架构所需的构建模块。黑色勾号表示我们已经讨论过的内容。

4.4 添加捷径连接

让我们讨论一下捷径连接的概念,也称为跳过或残差连接。最初,捷径连接是为计算机视觉中的深度网络(特别是残差网络)提出的,以减轻梯度消失的挑战。梯度消失问题指的是梯度(在训练过程中指导权重更新的)在反向传播通过层时逐渐变小的现象,这使得有效地训练早期层变得困难。

figure

图 4.12 比较了由五层组成的深度神经网络,其中左侧为无捷径连接(左),右侧为有捷径连接(右)。捷径连接涉及将某一层的输入添加到其输出中,从而有效地创建一条绕过某些层的替代路径。梯度表示每一层的平均绝对梯度,我们在列表 4.5 中计算了这些梯度。

图 4.12 表明,通过跳过一层或多层,捷径连接为梯度流动到网络中创建了一条替代的、更短的路径,这是通过将某一层的输出添加到后续层的输出中实现的。这就是为什么这些连接也被称为跳过连接。它们在训练过程中反向传递时保持梯度流动起着至关重要的作用。

在以下列表中,我们实现了图 4.12 中的神经网络,以查看我们如何在forward方法中添加快捷连接。

列表 4.5:用于说明快捷连接的神经网络
class ExampleDeepNeuralNetwork(nn.Module):
    def __init__(self, layer_sizes, use_shortcut):
        super().__init__()
        self.use_shortcut = use_shortcut
        self.layers = nn.ModuleList([       #1
            nn.Sequential(nn.Linear(layer_sizes[0], layer_sizes[1]), 
                          GELU()),
            nn.Sequential(nn.Linear(layer_sizes[1], layer_sizes[2]), 
                          GELU()),
            nn.Sequential(nn.Linear(layer_sizes[2], layer_sizes[3]), 
                          GELU()),
            nn.Sequential(nn.Linear(layer_sizes[3], layer_sizes[4]), 
                          GELU()),
            nn.Sequential(nn.Linear(layer_sizes[4], layer_sizes[5]), 
                          GELU())
        ])

    def forward(self, x):
        for layer in self.layers:
            layer_output = layer(x)         #2
            if self.use_shortcut and x.shape == layer_output.shape:    #3
                x = x + layer_output
            else:
                x = layer_output
        return x

1 实现五个层

2 计算当前层的输出

3 检查是否可以应用快捷连接

代码实现了一个包含五个层的深度神经网络,每个层由一个Linear层和一个GELU激活函数组成。在正向传播过程中,我们迭代地将输入通过层传递,如果将self.use_shortcut属性设置为True,则可选地添加快捷连接。

让我们使用此代码初始化一个没有快捷连接的神经网络。每个层将被初始化,以便它接受一个具有三个输入值的示例,并返回三个输出值。最后一层返回一个单一的输出值:

layer_sizes = [3, 3, 3, 3, 3, 1]  
sample_input = torch.tensor([[1., 0., -1.]])
torch.manual_seed(123)                            #1
model_without_shortcut = ExampleDeepNeuralNetwork(
    layer_sizes, use_shortcut=False
)

1 指定初始权重的随机种子以实现可重复性

接下来,我们实现一个函数,该函数在模型的反向传播过程中计算梯度:

def print_gradients(model, x):
    output = model(x)             #1
    target = torch.tensor([[0.]])

    loss = nn.MSELoss()
    loss = loss(output, target)    #2

    loss.backward()          #3

    for name, param in model.named_parameters():
        if 'weight' in name:
            print(f"{name} has gradient mean of {param.grad.abs().mean().item()}")

1 正向传播

2 根据目标和输出之间的接近程度计算损失

3 反向传播以计算梯度

此代码指定了一个损失函数,该函数计算模型输出与用户指定的目标(这里,为了简单起见,是值 0)的接近程度。然后,在调用loss.backward()时,PyTorch 计算模型中每个层的损失梯度。我们可以通过model.named_parameters()遍历权重参数。假设我们有一个 3 × 3 的权重参数矩阵,对于给定的层。在这种情况下,该层将具有 3 × 3 的梯度值,我们打印这些 3 × 3 梯度值的平均值绝对梯度,以获得每个层的单个梯度值,以便更容易地比较层之间的梯度。

简而言之,.backward()方法是 PyTorch 中的一个方便的方法,它计算损失梯度,这在模型训练期间是必需的,而不需要我们自己实现梯度计算的数学,从而使得与深度神经网络的工作变得更加容易。

注意:如果您不熟悉梯度以及神经网络训练的概念,我建议阅读附录 A 中的 A.4 和 A.7 节。

让我们现在使用print_gradients函数并将其应用于没有跳过连接的模型:

print_gradients(model_without_shortcut, sample_input)

输出是

layers.0.0.weight has gradient mean of 0.00020173587836325169
layers.1.0.weight has gradient mean of 0.0001201116101583466
layers.2.0.weight has gradient mean of 0.0007152041653171182
layers.3.0.weight has gradient mean of 0.001398873864673078
layers.4.0.weight has gradient mean of 0.005049646366387606

print_gradients函数的输出显示,随着我们从最后一层(layers.4)向第一层(layers.0)前进,梯度值会变小,这是一种称为梯度消失问题的现象。

让我们现在实例化一个具有跳过连接的模型,看看它与没有快捷连接的模型相比如何:

torch.manual_seed(123)
model_with_shortcut = ExampleDeepNeuralNetwork(
    layer_sizes, use_shortcut=True
)
print_gradients(model_with_shortcut, sample_input)

输出是

layers.0.0.weight has gradient mean of 0.22169792652130127
layers.1.0.weight has gradient mean of 0.20694105327129364
layers.2.0.weight has gradient mean of 0.32896995544433594
layers.3.0.weight has gradient mean of 0.2665732502937317
layers.4.0.weight has gradient mean of 1.3258541822433472

最后一个层(layers.4)的梯度值仍然比其他层要大。然而,随着我们向第一层(layers.0)前进,梯度值趋于稳定,并不会缩小到一个极小的值。

总之,快捷连接对于克服深度神经网络中梯度消失问题的限制至关重要。快捷连接是像 LLM 这样的大型模型的核心构建块,它们将有助于通过确保在下一章中训练 GPT 模型时层间梯度流的连续性来促进更有效的训练。

接下来,我们将把之前涵盖的所有概念(层归一化、GELU 激活、前馈模块和快捷连接)连接到 transformer 块中,这是我们需要编码 GPT 架构的最后一个构建块。

4.5 在 transformer 块中连接注意力和线性层

现在,让我们实现transformer 块,这是 GPT 和其他 LLM 架构的基本构建块。这个块在 1.24 亿参数的 GPT-2 架构中重复了 12 次,结合了我们之前涵盖的几个概念:多头注意力、层归一化、dropout、前馈层和 GELU 激活。稍后,我们将把这个 transformer 块连接到 GPT 架构的其余部分。

figure

图 4.13 transformer 块的示意图。输入标记已嵌入到 768 维向量中。每一行对应一个标记的向量表示。transformer 块输出的向量与输入具有相同的维度,然后可以被输入到 LLM 的后续层中。

图 4.13 展示了一个结合了多个组件的 transformer 块,包括第三章中提到的掩码多头注意力模块(见章节 3)以及我们之前实现过的FeedForward模块(见第 4.3 节)。当 transformer 块处理一个输入序列时,序列中的每个元素(例如,一个单词或子词标记)由一个固定大小的向量表示(在这种情况下,768 维)。transformer 块内的操作,包括多头注意力和前馈层,被设计成以保留其维度的这种方式转换这些向量。

理念在于多头注意力块中的自注意力机制识别并分析输入序列中元素之间的关系。相比之下,前馈网络在每一个位置单独修改数据。这种组合不仅使对输入的理解和处理更加细腻,而且增强了模型处理复杂数据模式的整体能力。

我们可以在代码中创建TransformerBlock

列表 4.6 GPT 的 transformer 块组件
from chapter03 import MultiHeadAttention

class TransformerBlock(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.att = MultiHeadAttention(
            d_in=cfg["emb_dim"],
            d_out=cfg["emb_dim"],
            context_length=cfg["context_length"],
            num_heads=cfg["n_heads"], 
            dropout=cfg["drop_rate"],
            qkv_bias=cfg["qkv_bias"])
        self.ff = FeedForward(cfg)
        self.norm1 = LayerNorm(cfg["emb_dim"])
        self.norm2 = LayerNorm(cfg["emb_dim"])
        self.drop_shortcut = nn.Dropout(cfg["drop_rate"])

    def forward(self, x):
 #1
        shortcut = x
        x = self.norm1(x)
        x = self.att(x)
        x = self.drop_shortcut(x)
        x = x + shortcut      #2

        shortcut = x         #3
        x = self.norm2(x)
        x = self.ff(x)
        x = self.drop_shortcut(x)
        x = x + shortcut      #4
        return x

1 注意力块的快捷连接

2 将原始输入添加回

3 前馈块的快捷连接

4 添加原始输入回

给定的代码在 PyTorch 中定义了一个 TransformerBlock 类,该类包含一个多头注意力机制 (MultiHeadAttention) 和一个前馈网络 (FeedForward),这两个组件都是基于提供的配置字典 (cfg) 配置的,例如 GPT_CONFIG_124M

在这两个组件之前应用层归一化 (LayerNorm),并在它们之后应用 dropout 以正则化模型并防止过拟合。这被称为 Pre-LayerNorm。较老的架构,如原始的 Transformer 模型,在自注意力机制和前馈网络之后应用层归一化,称为 Post-LayerNorm,这通常会导致更差的训练动态。

该类还实现了前向传递,其中每个组件之后都跟着一个快捷连接,该连接将块的输入添加到其输出中。这个关键特性有助于在训练期间通过网络流动梯度,并提高深度模型的学习(参见第 4.4 节)。

使用我们之前定义的 GPT_CONFIG_124M 字典,让我们实例化一个 Transformer 模块并给它一些样本数据:

torch.manual_seed(123)
x = torch.rand(2, 4, 768)                   #1
block = TransformerBlock(GPT_CONFIG_124M)
output = block(x)

print("Input shape:", x.shape)
print("Output shape:", output.shape)

1 创建形状为 [batch_size, num_tokens, emb_dim] 的样本输入

输出结果为

Input shape: torch.Size([2, 4, 768])
Output shape: torch.Size([2, 4, 768])

如我们所见,Transformer 模块在其输出中保持了输入维度,这表明 Transformer 架构在处理数据序列时不会改变它们的形状。

在 Transformer 模块架构中保持形状不是偶然的,而是其设计的一个关键方面。这种设计使其能够有效地应用于广泛的序列到序列任务,其中每个输出向量直接对应于一个输入向量,保持一对一的关系。然而,输出是一个上下文向量,它封装了整个输入序列的信息(参见第三章)。这意味着尽管序列的物理维度(长度和特征大小)在通过 Transformer 模块时保持不变,但每个输出向量的内容被重新编码以整合整个输入序列的上下文信息。

在实现了 Transformer 模块之后,我们现在拥有了实现 GPT 架构所需的所有构建块。如图 4.14 所示,Transformer 模块结合了层归一化、前馈网络、GELU 激活和快捷连接。正如我们最终将看到的,这个 Transformer 模块将成为 GPT 架构的主要组成部分。

figure

图 4.14 构建 GPT 架构所需的构建块。黑色勾选表示我们已完成的块。

4.6 编写 GPT 模型

我们以对称为DummyGPTModel的 GPT 架构的大图概述开始本章。在这个DummyGPTModel代码实现中,我们展示了 GPT 模型的输入和输出,但其构建块仍然是一个黑盒,使用DummyTransformerBlockDummyLayerNorm类作为占位符。

现在我们将DummyTransformerBlockDummyLayerNorm占位符替换为我们之前编写的真实TransformerBlockLayerNorm类,以组装 GPT-2 原始 1.24 亿参数版本的完整工作版本。在第五章中,我们将预训练 GPT-2 模型,在第六章中,我们将从 OpenAI 加载预训练的权重。

在我们用代码组装 GPT-2 模型之前,让我们看看它的整体结构,如图 4.15 所示,其中包括我们迄今为止所涵盖的所有概念。正如我们所见,变换块在整个 GPT 模型架构中被重复多次。在 1.24 亿参数的 GPT-2 模型中,它被重复 12 次,我们通过GPT_CONFIG_124M字典中的n_layers条目来指定。在具有 15.42 亿参数的最大 GPT-2 模型中,变换块被重复 48 次。

figure

图 4.15 展示了 GPT 模型架构的概述,显示了数据通过 GPT 模型流动的过程。从底部开始,标记化文本首先被转换为标记嵌入,然后添加位置嵌入。这些组合信息形成一个张量,通过中心的一系列变换块(每个包含多头注意力和具有 dropout 和层归一化的前馈神经网络层)传递,这些变换块堆叠在一起并重复 12 次。

最终变换块输出的数据在到达线性输出层之前会经过最后的层归一化步骤。这一层将变换器的输出映射到一个高维空间(在这种情况下,50,257 维,对应于模型的词汇量大小),以预测序列中的下一个标记。

现在我们来编写图 4.15 中的架构。

列表 4.7 GPT 模型架构实现
class GPTModel(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
        self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
        self.drop_emb = nn.Dropout(cfg["drop_rate"])

        self.trf_blocks = nn.Sequential(
            *[TransformerBlock(cfg) for _ in range(cfg["n_layers"])])

        self.final_norm = LayerNorm(cfg["emb_dim"])
        self.out_head = nn.Linear(
            cfg["emb_dim"], cfg["vocab_size"], bias=False
        )

    def forward(self, in_idx):
        batch_size, seq_len = in_idx.shape
        tok_embeds = self.tok_emb(in_idx)
 #1
        pos_embeds = self.pos_emb(
            torch.arange(seq_len, device=in_idx.device)
        )
        x = tok_embeds + pos_embeds
        x = self.drop_emb(x)
        x = self.trf_blocks(x)
        x = self.final_norm(x)
        logits = self.out_head(x)
        return logits

1 设备设置将允许我们在 CPU 或 GPU 上训练模型,具体取决于输入数据所在的设备。

多亏了TransformerBlock类,GPTModel类相对较小且紧凑。

这个GPTModel类的__init__构造函数使用通过 Python 字典cfg传入的配置初始化标记和位置嵌入层。这些嵌入层负责将输入标记索引转换为密集向量并添加位置信息(见第二章)。

接下来,__init__方法创建了一个等于cfg中指定层数的TransformerBlock模块的顺序堆叠。在变换器块之后,应用了一个LayerNorm层,标准化变换器块的输出以稳定学习过程。最后,定义了一个没有偏置的线性输出头,它将变换器的输出投影到分词器的词汇空间,为词汇表中的每个标记生成 logits。

前向方法接收一个输入标记索引的批次,计算它们的嵌入,应用位置嵌入,将序列通过变换器块,对最终输出进行归一化,然后计算 logits,表示下一个标记的非归一化概率。我们将在下一节将这些 logits 转换为标记和文本输出。

现在,让我们使用传递给cfg参数的GPT_CONFIG_ 124M字典初始化 124 百万参数的 GPT 模型,并用我们之前创建的批文本输入进行喂养:

torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)

out = model(batch)
print("Input batch:\n", batch)
print("\nOutput shape:", out.shape)
print(out)

这段代码打印了输入批次的内 容,然后是输出张量:

Input batch:
 tensor([[6109,  3626,  6100,   345],      #1
         [6109,  1110,  6622,   257]])     #2

Output shape: torch.Size([2, 4, 50257])
tensor([[[ 0.3613,  0.4222, -0.0711,  ...,  0.3483,  0.4661, -0.2838],
         [-0.1792, -0.5660, -0.9485,  ...,  0.0477,  0.5181, -0.3168],
         [ 0.7120,  0.0332,  0.1085,  ...,  0.1018, -0.4327, -0.2553],
         [-1.0076,  0.3418, -0.1190,  ...,  0.7195,  0.4023,  0.0532]],

        [[-0.2564,  0.0900,  0.0335,  ...,  0.2659,  0.4454, -0.6806],
         [ 0.1230,  0.3653, -0.2074,  ...,  0.7705,  0.2710,  0.2246],
         [ 1.0558,  1.0318, -0.2800,  ...,  0.6936,  0.3205, -0.3178],
         [-0.1565,  0.3926,  0.3288,  ...,  1.2630, -0.1858,  0.0388]]],
       grad_fn=<UnsafeViewBackward0>)

1 文本 1 的标记 ID

2 文本 2 的标记 ID

如我们所见,输出张量的形状为 [2, 4, 50257],因为我们输入了两个每个包含四个标记的文本。最后一个维度,50257,对应于分词器的词汇表大小。稍后,我们将看到如何将这些 50,257 维输出向量中的每一个转换回标记。

在我们继续编写将模型输出转换为文本的函数之前,让我们花更多的时间来分析模型架构本身的大小。使用numel()方法,即“元素数量”,我们可以收集模型参数张量中的总参数数量:

total_params = sum(p.numel() for p in model.parameters())
print(f"Total number of parameters: {total_params:,}")

结果是

Total number of parameters: 163,009,536

现在,一个好奇的读者可能会注意到一个差异。之前,我们提到初始化了一个 124 百万参数的 GPT 模型,那么为什么实际的参数数量是 163 百万呢?

原因是一个称为权重绑定的概念,它在原始 GPT-2 架构中使用。这意味着原始 GPT-2 架构在其输出层中重新使用了标记嵌入层的权重。为了更好地理解,让我们看一下我们之前通过GPTModel初始化在model上的标记嵌入层和线性输出层的形状:

print("Token embedding layer shape:", model.tok_emb.weight.shape)
print("Output layer shape:", model.out_head.weight.shape)

如我们从打印输出中可以看到,这两个层的权重张量具有相同的形状:

Token embedding layer shape: torch.Size([50257, 768])
Output layer shape: torch.Size([50257, 768])

由于分词器词汇表中有 50,257 个行数,标记嵌入和输出层非常大。让我们根据权重绑定从总 GPT-2 模型计数中减去输出层参数计数:

total_params_gpt2 = (
    total_params - sum(p.numel()
    for p in model.out_head.parameters())
)
print(f"Number of trainable parameters "
      f"considering weight tying: {total_params_gpt2:,}"
)

输出是

Number of trainable parameters considering weight tying: 124,412,160

如我们所见,该模型现在只有 124 百万参数大,与原始的 GPT-2 模型大小相匹配。

权重绑定可以减少模型的总体内存占用和计算复杂度。然而,根据我的经验,使用独立的标记嵌入层和输出层可以获得更好的训练和模型性能;因此,我们在GPTModel实现中使用了独立的层。对于现代大型语言模型(LLMs)也是如此。然而,我们将在第六章中重新审视并实现权重绑定概念,那时我们将从 OpenAI 加载预训练的权重。

练习 4.1 前馈和注意力模块中的参数数量

计算并比较包含在前馈模块中的参数数量和包含在多头注意力模块中的参数数量。

最后,让我们计算我们的GPTModel对象中 1.63 亿个参数的内存需求:

total_size_bytes = total_params * 4       #1
total_size_mb = total_size_bytes / (1024 * 1024)     #2
print(f"Total size of the model: {total_size_mb:.2f} MB")

1 计算总大小(假设为 float32,每个参数 4 字节)

2 转换为兆字节

结果是

Total size of the model: 621.83 MB

总之,通过计算我们的GPTModel对象中 1.63 亿个参数的内存需求,并假设每个参数是一个 32 位的浮点数,占用 4 字节,我们发现模型的总大小为 621.83 MB,这说明了即使是相对较小的 LLMs 也需要相对较大的存储容量。

现在我们已经实现了GPTModel架构,并看到它输出形状为[batch_size, num_tokens, vocab_size]的数值张量,让我们编写代码将这些输出张量转换为文本。

练习 4.2 初始化更大的 GPT 模型

我们初始化了一个参数数量为 1.24 亿的 GPT 模型,被称为“GPT-2 small”。除了更新配置文件外,不进行任何代码修改,使用GPTModel类实现 GPT-2 medium(使用 1,024 维嵌入,24 个 transformer 块,16 个多头注意力头),GPT-2 large(1,280 维嵌入,36 个 transformer 块,20 个多头注意力头),以及 GPT-2 XL(1,600 维嵌入,48 个 transformer 块,25 个多头注意力头)。作为额外奖励,计算每个 GPT 模型中的参数总数。

4.7 生成文本

我们现在将实现将 GPT 模型的张量输出转换回文本的代码。在我们开始之前,让我们简要回顾一下像 LLM 这样的生成模型是如何逐个单词(或标记)生成文本的。

figure

图 4.16 LLM 逐个标记生成文本的逐步过程。从初始输入上下文(“你好,我是”)开始,模型在每次迭代中预测后续标记,并将其附加到下一次预测的输入上下文中。如图所示,第一次迭代添加了“a”,第二次添加了“model”,第三次添加了“ready”,逐步构建句子。

图 4.16 说明了 GPT 模型根据输入上下文(例如“你好,我是。”)生成文本的逐步过程。每次迭代,输入上下文都会增长,允许模型生成连贯且上下文适当的文本。到第六次迭代时,模型已经构建了一个完整的句子:“你好,我是一个准备帮助的模型。”我们已经看到,我们当前的 GPTModel 实现输出形状为 [batch_size, num_token, vocab_size] 的张量。现在的问题是:GPT 模型是如何从这些输出张量生成文本的?

GPT 模型从输出张量到生成文本的过程涉及几个步骤,如图 4.17 所示。这些步骤包括解码输出张量、根据概率分布选择标记,并将这些标记转换为可读文本。

figure

图 4.17 通过展示标记生成过程中的单个迭代,展示了 GPT 模型中文本生成的机制。这个过程首先将输入文本编码为标记 ID,然后将这些 ID 输入到 GPT 模型中。模型的输出随后被转换回文本,并附加到原始输入文本上。

图 4.17 中详细说明了下一标记生成过程,展示了 GPT 模型根据其输入生成下一个标记的单个步骤。在每一步中,模型输出一个矩阵,其中包含表示潜在下一个标记的向量。与下一个标记对应的向量被提取出来,并通过 softmax 函数转换为概率分布。在包含结果概率分数的向量中,找到最高值的索引,这对应于标记 ID。然后,这个标记 ID 被解码回文本,生成序列中的下一个标记。最后,这个标记被附加到之前的输入上,形成新的输入序列,用于后续迭代。这个逐步过程使模型能够顺序生成文本,从初始输入上下文构建连贯的短语和句子。

在实践中,我们重复这个过程许多次迭代,例如图 4.16 所示,直到达到用户指定的生成标记数。在代码中,我们可以将标记生成过程实现如下所示。

列表 4.8 GPT 模型生成文本的函数
def generate_text_simple(model, idx,                 #1
                         max_new_tokens, context_size): 
    for _ in range(max_new_tokens):
        idx_cond = idx[:, -context_size:]    #2
        with torch.no_grad():
            logits = model(idx_cond)

        logits = logits[:, -1, :]                    #3
        probas = torch.softmax(logits, dim=-1)           #4
        idx_next = torch.argmax(probas, dim=-1, keepdim=True)    #5
        idx = torch.cat((idx, idx_next), dim=1)     #6

    return idx

1 idx 是当前上下文中索引的 (batch, n_tokens) 数组。

2 如果当前上下文超过支持的上下文大小,则裁剪当前上下文,例如,如果 LLM 只支持 5 个标记,而上下文大小为 10,则只使用最后 5 个标记作为上下文

3 仅关注最后一个时间步,因此 (batch, n_token, vocab_size) 变为 (batch, vocab_size)

4 probas 的形状为 (batch, vocab_size)。

5 idx_next 的形状为 (batch, 1)。

6 将采样索引添加到运行序列中,其中 idx 的形状为 (batch, n_tokens+1)

此代码演示了使用 PyTorch 实现语言模型生成循环的简单实现。它迭代指定数量的新 token 以生成,裁剪当前上下文以适应模型的最大上下文大小,计算预测,然后根据最高概率预测选择下一个 token。

要编写generate_text_simple函数,我们使用softmax函数将 logits 转换为概率分布,然后通过torch.argmax识别具有最高值的位。softmax函数是单调的,这意味着它在转换为输出时保留了输入的顺序。因此,在实践中,softmax 步骤是多余的,因为 softmax 输出张量中得分最高的位置与 logit 张量中的相同位置。换句话说,我们可以直接对 logits 张量应用torch.argmax函数并得到相同的结果。然而,我提供了转换的代码,以说明将 logits 转换为概率的完整过程,这可以增加额外的直观性,以便模型生成最可能的下一个 token,这被称为贪婪解码

当我们在下一章实现 GPT 训练代码时,我们将使用额外的采样技术来修改 softmax 输出,使得模型不总是选择最可能的 token。这为生成的文本引入了变化性和创造性。

使用generate_text_simple函数逐个生成 token ID 并将其附加到上下文中的这个过程在图 4.18 中进一步说明。(每个迭代的 token ID 生成过程在图 4.17 中详细说明。)我们以迭代的方式生成 token ID。例如,在迭代 1 中,模型被提供了对应于“Hello, I am,”的 token,预测下一个 token(ID 为 257,即“a”),并将其附加到输入中。这个过程重复进行,直到模型在六次迭代后生成完整的句子“Hello, I am a model ready to help”。

figure

图 4.18 token 预测周期的六次迭代,其中模型以一系列初始 token ID 作为输入,预测下一个 token,并将此 token 附加到下一个迭代的输入序列中。(token ID 也被转换成相应的文本以更好地理解。)

现在,让我们尝试使用"Hello, I am"上下文作为模型输入的generate_text_simple函数。首先,我们将输入上下文编码为 token ID:

start_context = "Hello, I am"
encoded = tokenizer.encode(start_context)
print("encoded:", encoded)
encoded_tensor = torch.tensor(encoded).unsqueeze(0)    #1
print("encoded_tensor.shape:", encoded_tensor.shape)

1 添加批处理维度

编码的 ID 是

encoded: [15496, 11, 314, 716]
encoded_tensor.shape: torch.Size([1, 4])

接下来,我们将模型置于.eval()模式。这禁用了仅在训练期间使用的随机组件,如 dropout,并使用generate_text_simple函数对编码的输入张量进行操作:

model.eval()                  #1
out = generate_text_simple(
    model=model,
    idx=encoded_tensor, 
    max_new_tokens=6, 
    context_size=GPT_CONFIG_124M["context_length"]
)
print("Output:", out)
print("Output length:", len(out[0]))

1 禁用 dropout,因为我们不在训练模型

生成的输出 token ID 是

Output: tensor([[15496,    11,   314,   716, 27018, 24086, 47843,
30961, 42348,  7267]])
Output length: 10

使用分词器的.decode方法,我们可以将 ID 转换回文本:

decoded_text = tokenizer.decode(out.squeeze(0).tolist())
print(decoded_text)

模型输出的文本格式是

Hello, I am Featureiman Byeswickattribute argue

如我们所见,模型生成了乱码,这与连贯的文本“Hello, I am a model ready to help”完全不同。发生了什么?模型无法生成连贯文本的原因是我们还没有对其进行训练。到目前为止,我们只实现了 GPT 架构,并使用初始随机权重初始化了一个 GPT 模型实例。模型训练本身是一个大主题,我们将在下一章中探讨它。

练习 4.3 使用单独的 dropout 参数

在本章的开头,我们在GPT_ CONFIG_124M字典中定义了一个全局drop_rate设置,以在GPTModel架构的各个地方设置 dropout 率。将代码修改为为模型架构中的各个 dropout 层指定单独的 dropout 值。(提示:我们在三个不同的地方使用了 dropout 层:嵌入层、快捷层和多头注意力模块。)

摘要

  • 层归一化通过确保每一层的输出具有一致的均值和方差来稳定训练。

  • 快捷连接是通过将一层或多层的输出直接馈送到更深的一层来跳过一层或更多层的连接,这有助于缓解训练深层神经网络(如 LLM)时的梯度消失问题。

  • Transformer 块是 GPT 模型的核心结构组件,它结合了带掩码的多头注意力模块和使用了 GELU 激活函数的全连接前馈网络。

  • GPT 模型是具有数百万到数十亿参数的具有许多重复 transformer 块的 LLM。

  • GPT 模型有多种大小,例如 124、345、762 和 15.42 亿参数,我们可以使用相同的GPTModel Python 类来实现。

  • 类似 GPT 的 LLM 的文本生成能力涉及通过按顺序预测给定输入上下文中的一个标记来解码输出张量,从而将人类可读的文本转换为文本。

  • 没有训练,GPT 模型生成的文本是不连贯的,这强调了模型训练对于生成连贯文本的重要性。

第五章:在未标记数据上的预训练

本章涵盖

  • 计算训练和验证集损失以评估训练过程中 LLM 生成文本的质量

  • 实现训练函数和预训练 LLM

  • 保存和加载模型权重以继续训练 LLM

  • 从 OpenAI 加载预训练权重

到目前为止,我们已经实现了数据采样和注意力机制,并编写了 LLM 架构的代码。现在是时候实现训练函数并预训练 LLM 了。我们将学习基本模型评估技术来衡量生成文本的质量,这是在训练过程中优化 LLM 的要求。此外,我们将讨论如何加载预训练权重,为我们的 LLM 提供一个坚实的微调起点。图 5.1 概述了我们的整体计划,突出了本章将讨论的内容。

figure

图 5.1 编码 LLM 的三个主要阶段。本章重点介绍第 2 阶段:预训练 LLM(步骤 4),包括实现训练代码(步骤 5)、评估性能(步骤 6)以及保存和加载模型权重(步骤 7)。
权重参数

在 LLM 和其他深度学习模型的背景下,权重指的是学习过程调整的可训练参数。这些权重也被称为权重参数或简单地称为参数。在 PyTorch 等框架中,这些权重存储在线性层中;我们在第三章中使用了这些来实现多头注意力模块,在第四章中实现了GPTModel。初始化一个层(new_layer = torch.nn.Linear(...))后,我们可以通过.weight属性访问其权重,即new_layer.weight。此外,为了方便起见,PyTorch 允许通过方法model.parameters()直接访问模型的所有可训练参数,包括权重和偏差,我们将在实现模型训练时使用此方法。

5.1 评估生成文本模型

在简要回顾了第四章中的文本生成后,我们将设置我们的 LLM 进行文本生成,然后讨论评估生成文本质量的基本方法。然后我们将计算训练和验证损失。图 5.2 展示了本章涵盖的主题,其中前三个步骤被突出显示。

figure

图 5.2 本章涵盖主题的概述。我们首先回顾文本生成(步骤 1),然后继续讨论基本模型评估技术(步骤 2)和训练与验证损失(步骤 3)。

5.1.1 使用 GPT 生成文本

让我们设置 LLM 并简要回顾我们在第四章中实现的文本生成过程。我们首先通过GPTModel类和GPT_CONFIG_124M字典(见第四章)初始化我们将要评估和训练的 GPT 模型:

import torch
from chapter04 import GPTModel

GPT_CONFIG_124M = {
    "vocab_size": 50257,
    "context_length": 256,    #1
    "emb_dim": 768,
    "n_heads": 12,
    "n_layers": 12, 
    "drop_rate": 0.1,       #2
    "qkv_bias": False
}
torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.eval()

1 我们将上下文长度从 1,024 缩短到 256 个标记。

2 将 dropout 设置为 0 是可能且常见的。

考虑到GPT_CONFIG_124M字典,与我们上一章相比,我们唯一做出的调整是将上下文长度(context_length)减少到 256 个 token。这种修改降低了训练模型的计算需求,使得在标准笔记本电脑上执行训练成为可能。

原始的 GPT-2 模型有 1240 万个参数,配置为处理最多 1,024 个 token。在训练过程之后,我们将更新上下文大小设置并加载预训练的权重,以便与配置为 1,024 个 token 上下文长度的模型一起工作。

使用GPTModel实例,我们采用第四章中的generate_text_simple函数,并引入两个实用的函数:text_to_token_idstoken_ids_to_text。这些函数便于在文本和 token 表示之间进行转换,这是我们将在本章中利用的技术。

figure

图 5.3 文本生成涉及将文本编码成 LLM 处理的 token ID,然后将 logit 向量转换回 token ID,并反序列化为文本表示。

图 5.3 展示了使用 GPT 模型的三步文本生成过程。首先,分词器将输入文本转换为一系列 token ID(参见第二章)。其次,模型接收这些 token ID 并生成相应的 logit,这些 logit 是表示词汇表中每个 token 概率分布的向量(参见第四章)。第三,这些 logit 被转换回 token ID,分词器将其解码为可读文本,从而完成从文本输入到文本输出的循环。

我们可以实施如以下列表所示的文本生成过程。

列表 5.1 文本到 token ID 转换的实用函数
import tiktoken
from chapter04 import generate_text_simple

def text_to_token_ids(text, tokenizer):
    encoded = tokenizer.encode(text, allowed_special={'<|endoftext|>'})
    encoded_tensor = torch.tensor(encoded).unsqueeze(0)    #1
    return encoded_tensor

def token_ids_to_text(token_ids, tokenizer):
    flat = token_ids.squeeze(0)                #2
    return tokenizer.decode(flat.tolist())

start_context = "Every effort moves you"
tokenizer = tiktoken.get_encoding("gpt2")

token_ids = generate_text_simple(
    model=model,
    idx=text_to_token_ids(start_context, tokenizer),
    max_new_tokens=10,
    context_size=GPT_CONFIG_124M["context_length"]
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))

1 .unsqueeze(0) 添加批处理维度

2 移除批处理维度

使用此代码,model生成了以下文本:

Output text:
 Every effort moves you rentingetic wasnم refres RexMeCHicular stren

显然,模型还没有生成连贯的文本,因为它还没有经过训练。为了定义什么使文本“连贯”或“高质量”,我们必须实现一个数值方法来评估生成的内容。这种方法将使我们能够在整个训练过程中监控和提升模型的表现。

接下来,我们将计算生成输出的损失指标。这个损失作为训练进度和成功的指标。此外,在后面的章节中,当我们微调我们的 LLM 时,我们将回顾评估模型质量的额外方法。

5.1.2 计算文本生成损失

接下来,让我们通过计算文本生成损失来探索在训练过程中评估文本质量的技术。我们将通过一个实际例子逐步讲解这个主题,以使概念清晰并具有实用性,首先简要回顾如何通过generate_text_simple函数加载数据和生成文本。

图 5.4 展示了从输入文本到 LLM 生成文本的整体流程,使用五步程序。这个文本生成过程显示了generate_text_simple函数内部执行的操作。在我们能够计算衡量生成文本质量的损失之前,我们需要执行这些相同的初始步骤。

figure

图 5.4 对于左侧显示的每个三个输入标记,我们计算一个包含与词汇表中每个标记对应的概率分数的向量。每个向量中最高概率分数的索引位置代表最可能的下一个标记 ID。与最高概率分数关联的这些标记 ID 被选中并映射回表示模型生成的文本的文本。

图 5.4 概述了使用小七标记词汇表来适应单页的文本生成过程。然而,我们的GPTModel使用一个包含 50,257 个单词的更大词汇表;因此,以下代码中的标记 ID 将范围从 0 到 50,256,而不是 0 到 6。

此外,图 5.4 仅为了简化展示了单个文本示例("every effort moves")。在以下实现图中步骤的动手代码示例中,我们将使用两个 GPT 模型的输入示例("every effort moves""I really like")。

考虑这两个已经映射到标记 ID 的输入示例(图 5.4,步骤 1):

inputs = torch.tensor([[16833, 3626, 6100],   # ["every effort moves",
                       [40,    1107, 588]])   #  "I really like"]

与这些输入匹配,targets包含我们希望模型生成的标记 ID:

targets = torch.tensor([[3626, 6100, 345  ],  # [" effort moves you",
                        [1107, 588, 11311]])  #  " really like chocolate"]

注意,目标输入是但向前移动了一个位置,这是我们在第二章实现数据加载器时讨论的概念。这种移动策略对于教会模型预测序列中的下一个标记至关重要。

现在,我们将输入输入到模型中,为两个输入示例计算 logits 向量,每个示例包含三个标记。然后我们应用softmax函数将这些 logits 转换为概率分数(probas;图 5.4,步骤 2):

with torch.no_grad():     #1
    logits = model(inputs)
probas = torch.softmax(logits, dim=-1)     #2
print(probas.shape)

1 禁用梯度跟踪,因为我们还没有开始训练

2 词汇表中每个标记的概率

概率分数张量(probas)的结果维度是

torch.Size([2, 3, 50257])

第一个数字,2,对应于输入中的两个示例(行),也称为批量大小。第二个数字,3,对应于每个输入(行)中的标记数量。最后,最后一个数字对应于嵌入维度性,它由词汇表大小决定。通过softmax函数将 logits 转换为概率后,generate_text_simple函数随后将结果概率分数转换回文本(图 5.4,步骤 3–5)。

我们可以通过对概率分数应用argmax函数来完成步骤 3 和 4,以获得相应的标记 ID:

token_ids = torch.argmax(probas, dim=-1, keepdim=True)
print("Token IDs:\n", token_ids)

由于我们有两个输入批次,每个批次包含三个标记,将argmax函数应用于概率分数(图 5.4,步骤 3)会产生两组输出,每组有三个预测标记 ID:

Token IDs:
 tensor([[[16657],       #1
         [  339],
         [42826]],
        [[49906],        #2
         [29669],
         [41751]]])

1 第一个批次

2 第二个批次

最后,步骤 5 将标记 ID 转换回文本:

print(f"Targets batch 1: {token_ids_to_text(targets[0], tokenizer)}")
print(f"Outputs batch 1:"
      f" {token_ids_to_text(token_ids[0].flatten(), tokenizer)}")

当我们解码这些标记时,我们发现这些输出标记与我们希望模型生成的目标标记相当不同:

Targets batch 1:  effort moves you
Outputs batch 1:  Armed heNetflix

由于模型尚未经过训练,它产生的文本与目标文本不同。我们现在想通过损失(图 5.5)来数值评估模型生成文本的性能。这不仅有助于衡量生成文本的质量,也是实现训练函数的基石,我们将使用它来更新模型的权重,以改进生成的文本。

figure

图 5.5 本章节涵盖的主题概述。我们已经完成了步骤 1。我们现在准备实现文本评估函数(步骤 2)。

我们实施的部分文本评估过程,如图 5.5 所示,是测量生成的标记与正确预测(目标)之间的“距离”。我们稍后实施的训练函数将使用这些信息来调整模型权重,以生成更接近(或理想情况下匹配)目标文本的文本。

模型训练的目标是增加与正确目标标记 ID 对应的 softmax 概率,如图 5.6 所示。此 softmax 概率也用于我们将在下一节实施的评估指标中,以数值评估模型的生成输出:正确位置的概率越高,越好。

figure

图 5.6 在训练之前,模型产生随机的下一个标记概率向量。模型训练的目标是确保与突出显示的目标标记 ID 对应的概率值最大化。

记住,图 5.6 显示了紧凑的七个标记词汇表的 softmax 概率,以便将所有内容放入单个图中。这表明起始的随机值将围绕 1/7,即大约 0.14。然而,我们用于我们的 GPT-2 模型的词汇表有 50,257 个标记,所以大部分初始概率将围绕 0.00002(1/50,257)。

对于两个输入文本中的每一个,我们可以使用以下代码打印出对应于目标标记的初始 softmax 概率分数:

text_idx = 0
target_probas_1 = probas[text_idx, [0, 1, 2], targets[text_idx]]
print("Text 1:", target_probas_1)

text_idx = 1
target_probas_2 = probas[text_idx, [0, 1, 2], targets[text_idx]]
print("Text 2:", target_probas_2)

每个批次的目标标记 ID 概率有三个

Text 1: tensor([7.4541e-05, 3.1061e-05, 1.1563e-05])
Text 2: tensor([1.0337e-05, 5.6776e-05, 4.7559e-06])

训练一个大型语言模型(LLM)的目标是最大化正确标记的可能性,这涉及到增加其相对于其他标记的概率。这样,我们确保 LLM 始终选择目标标记——本质上句子的下一个单词——作为它生成的下一个标记。

反向传播

我们如何最大化对应于目标标记的 softmax 概率值?总体来说,我们更新模型权重,使得模型对于我们想要生成的相应标记 ID 输出更高的值。权重更新是通过称为反向传播的过程完成的,这是一种训练深度神经网络的标准化技术(有关反向传播和模型训练的更多详细信息,请参阅附录 A 中的 A.3 到 A.7 节)。

反向传播需要一个损失函数,该函数计算模型预测输出(在这里,是针对目标标记 ID 的概率)与实际期望输出之间的差异。这个损失函数衡量模型的预测与目标值之间的偏差程度。

接下来,我们将计算两个示例批次target_probas_1target_probas_2的概率得分的损失。主要步骤如图 5.7 所示。由于我们已经对步骤 1 到 3 进行了处理以获得target_probas_1target_probas_2,我们继续进行步骤 4,对概率得分应用对数

log_probas = torch.log(torch.cat((target_probas_1, target_probas_2)))
print(log_probas)

figure

图 5.7 计算损失涉及几个步骤。步骤 1 到 3,我们已经完成,计算了与目标张量对应的标记概率。然后,在步骤 4 到 6 中,这些概率通过对数变换并平均。

这导致了以下值:

tensor([ -9.5042, -10.3796, -11.3677, -11.4798,  -9.7764, -12.2561])

在数学优化中处理概率得数的对数比直接处理得分更容易管理。这个主题超出了本书的范围,但我已经在附录 B 中的讲座中进一步详细说明了这一点。

接下来,我们将这些对数概率合并成一个单一得分,通过计算平均值(图 5.7 中的步骤 5):

avg_log_probas = torch.mean(log_probas)
print(avg_log_probas)

结果的平均对数概率得分是

tensor(-10.7940)

目标是通过对模型权重进行更新作为训练过程的一部分,将平均对数概率尽可能接近 0。然而,在深度学习中,常见的做法不是将平均对数概率推到 0,而是将负的平均对数概率降低到 0。负的平均对数概率仅仅是平均对数概率乘以-1,这对应于图 5.7 中的步骤 6:

neg_avg_log_probas = avg_log_probas * -1
print(neg_avg_log_probas)

这会打印出tensor(10.7940)。在深度学习中,将这个负值-10.7940 转换为 10.7940 的术语被称为交叉熵损失。PyTorch 在这里很有用,因为它已经内置了一个cross_entropy函数,可以为我们处理图 5.7 中的所有这六个步骤。

交叉熵损失

在核心上,交叉熵损失是机器学习和深度学习中一种流行的度量方法,它衡量两个概率分布之间的差异——通常,是标签的真实分布(在这里,是数据集中的标记)和模型预测的分布(例如,由大型语言模型生成的标记概率)。

在机器学习的背景下,特别是在像 PyTorch 这样的框架中,cross_entropy函数计算离散结果这一度量,这与给定模型生成的标记概率的目标标记的负平均对数概率相似,使得“交叉熵”和“负平均对数概率”这两个术语在实践中相关且经常互换使用。

在我们应用cross_entropy函数之前,让我们简要回顾一下 logits 和目标张量的形状:

print("Logits shape:", logits.shape)
print("Targets shape:", targets.shape)

结果形状是

Logits shape: torch.Size([2, 3, 50257])
Targets shape: torch.Size([2, 3])

如我们所见,logits张量有三个维度:批次大小、标记数量和词汇表大小。targets张量有两个维度:批次大小和标记数量。

对于 PyTorch 中的cross_entropy损失函数,我们希望通过组合批次维度来展平这些张量:

logits_flat = logits.flatten(0, 1)
targets_flat = targets.flatten()
print("Flattened logits:", logits_flat.shape)
print("Flattened targets:", targets_flat.shape)

结果张量维度是

Flattened logits: torch.Size([6, 50257])
Flattened targets: torch.Size([6])

记住,targets是我们希望 LLM 生成的标记 ID,而logits包含在进入softmax函数以获得概率分数之前的未缩放模型输出。

之前,我们应用了softmax函数,选择了对应于目标 ID 的概率分数,并计算了负平均对数概率。PyTorch 的cross_entropy函数将为我们处理所有这些步骤:

loss = torch.nn.functional.cross_entropy(logits_flat, targets_flat)
print(loss)

结果损失与我们在手动应用图 5.7 中的各个步骤时获得的结果相同:

tensor(10.7940)
混淆度

混淆度是常与交叉熵损失一起使用来评估模型在语言建模等任务中性能的度量。它可以提供一种更可解释的方式来理解模型在预测序列中下一个标记时的不确定性。

混淆度衡量模型预测的概率分布与数据集中单词的实际分布之间的匹配程度。与损失类似,较低的混淆度表明模型预测更接近实际分布。

混淆度可以计算为perplexity = torch.exp(loss),当应用于之前计算的损失时,返回tensor(48725.8203)

混淆度通常被认为比原始损失值更可解释,因为它表示模型在每一步对有效词汇表大小的不确定性。在给定示例中,这相当于模型不确定在词汇表中的 48,725 个标记中应该生成哪个作为下一个标记。

我们现在已计算了两个小型文本输入的损失以供说明。接下来,我们将应用损失计算到整个训练和验证集。

5.1.3 计算训练和验证集损失

我们必须首先准备我们将用于训练 LLM 的训练和验证数据集。然后,如图 5.8 所示,我们将计算训练和验证集的交叉熵,这是模型训练过程中的一个重要组成部分。

figure

完成步骤 1 和 2,包括计算交叉熵损失后,我们现在可以将这种损失计算应用于整个用于模型训练的文本数据集。

为了在训练集和验证集上计算损失,我们使用一个非常小的文本数据集,即 Edith Wharton 的短篇小说“The Verdict”,我们在第二章中已经使用过它。通过选择公共领域的文本,我们绕过了任何与使用权利相关的担忧。此外,使用如此小的数据集允许在标准笔记本电脑上几分钟内执行代码示例,即使没有高端 GPU,这对于教育目的尤其有利。

注意:对本书感兴趣的读者也可以使用本书的补充代码来准备一个更大规模的由 Project Gutenberg 的 60,000 多本公共领域书籍组成的数据库,并在这些数据上训练 LLM(详细信息请见附录 D)。

预训练大型语言模型(LLM)的成本

为了将我们项目的规模放在正确的视角中,考虑一下训练 700 亿参数的 Llama 2 模型,这是一个相对流行的公开可用的 LLM。这个模型在昂贵的 A100 GPU 上需要 184,320 个 GPU 小时,处理了 2 万亿个标记。在撰写本文时,在 AWS 上运行一个 8 × A100 云服务器每小时大约花费 30 美元。粗略估计,这样一个 LLM 的总训练成本大约为 69 万美元(计算为 184,320 小时除以 8,然后乘以 30)。

以下代码加载了“The Verdict”短篇小说:

file_path = "the-verdict.txt"
with open(file_path, "r", encoding="utf-8") as file:
    text_data = file.read()

在加载数据集后,我们可以检查数据集中的字符和标记数量:

total_characters = len(text_data)
total_tokens = len(tokenizer.encode(text_data))
print("Characters:", total_characters)
print("Tokens:", total_tokens)

输出是

Characters: 20479
Tokens: 5145

仅用 5,145 个标记,文本可能看起来太小,不足以训练 LLM,但如前所述,这是出于教育目的,以便我们可以在几分钟内而不是几周内运行代码。此外,稍后我们将从 OpenAI 加载预训练的权重到我们的 GPTModel 代码中。

接下来,我们将数据集分为训练集和验证集,并使用第二章中的数据加载器为 LLM 训练准备批次。这一过程在图 5.9 中进行了可视化。由于空间限制,我们使用 max_length=6。然而,对于实际的数据加载器,我们将 max_length 设置为 LLM 支持的 256 个标记的上下文长度,这样 LLM 在训练期间可以看到更长的文本。

figure

图 5.9 在准备数据加载器时,我们将输入文本分为训练集和验证集部分。然后我们对文本进行分词(为了简单起见,这里只展示了训练集部分)并将分词后的文本划分为用户指定的长度块(这里为 6)。最后,我们打乱行顺序并将分块后的文本组织成批次(这里批次大小为 2),这些批次可以用于模型训练。

注意:我们以类似大小的块呈现训练数据以进行训练,以简化并提高效率。然而,在实践中,训练一个具有可变长度输入的 LLM 也有助于 LLM 在使用时更好地泛化到不同类型的输入。

为了实现数据拆分和加载,我们首先定义一个 train_ratio,使用 90% 的数据用于训练,剩余的 10% 作为训练期间模型评估的验证数据:

train_ratio = 0.90
split_idx = int(train_ratio * len(text_data))
train_data = text_data[:split_idx]
val_data = text_data[split_idx:]

使用 train_dataval_data 子集,我们现在可以创建相应的数据加载器,重用第二章中的 create_dataloader_v1 代码:

from chapter02 import create_dataloader_v1
torch.manual_seed(123)

train_loader = create_dataloader_v1(
    train_data,
    batch_size=2,
    max_length=GPT_CONFIG_124M["context_length"],
    stride=GPT_CONFIG_124M["context_length"],
    drop_last=True,
    shuffle=True,
    num_workers=0
)
val_loader = create_dataloader_v1(
    val_data,
    batch_size=2,
    max_length=GPT_CONFIG_124M["context_length"],
    stride=GPT_CONFIG_124M["context_length"],
    drop_last=False,
    shuffle=False,
    num_workers=0
)

我们使用了一个相对较小的批次大小,以减少计算资源的需求,因为我们正在处理一个非常小的数据集。在实践中,使用 1,024 或更大的批次大小来训练 LLM 并不罕见。

作为可选的检查,我们可以遍历数据加载器以确保它们被正确创建:

print("Train loader:")
for x, y in train_loader:
    print(x.shape, y.shape)

print("\nValidation loader:")
for x, y in val_loader:
    print(x.shape, y.shape)

我们应该看到以下输出:

Train loader:
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])

Validation loader:
torch.Size([2, 256]) torch.Size([2, 256])

根据前面的代码输出,我们有九个训练集批次,每个批次包含两个样本和 256 个标记。由于我们只分配了 10% 的数据用于验证,因此只有一个包含两个输入示例的验证批次。正如预期的那样,输入数据 (x) 和目标数据 (y) 具有相同的形状(批次大小乘以每个批次的标记数),因为目标数据是按照第二章中讨论的偏移一个位置的目标。

接下来,我们实现一个实用函数来计算通过训练和验证加载器返回的给定批次的交叉熵损失:

def calc_loss_batch(input_batch, target_batch, model, device):
    input_batch = input_batch.to(device)         #1
    target_batch = target_batch.to(device)      
    logits = model(input_batch)
    loss = torch.nn.functional.cross_entropy(
        logits.flatten(0, 1), target_batch.flatten()
    )
    return loss

1 将数据传输到指定设备允许我们将数据传输到 GPU。

我们现在可以使用这个 calc_loss_batch 工具函数,它计算单个批次的损失,来实现以下 calc_loss_loader 函数,该函数计算给定数据加载器采样的所有批次的损失。

列表 5.2 计算训练和验证损失的函数
def calc_loss_loader(data_loader, model, device, num_batches=None):
    total_loss = 0.
    if len(data_loader) == 0:
        return float("nan")
    elif num_batches is None:
        num_batches = len(data_loader)     #1
    else:
        num_batches = min(num_batches, len(data_loader))   #2
    for i, (input_batch, target_batch) in enumerate(data_loader):
        if i < num_batches:
            loss = calc_loss_batch(
                input_batch, target_batch, model, device
            )
            total_loss += loss.item()    #3
        else:
            break
    return total_loss / num_batches    #4

1 如果未指定固定的 num_batches,则迭代所有批次

2 如果 num_batches 超过数据加载器中的批次总数,则减少批次数量以匹配数据加载器中的批次总数

3 对每个批次的损失求和

4 平均所有批次的损失

默认情况下,calc_loss_loader 函数遍历给定数据加载器中的所有批次,将损失累积在 total_loss 变量中,然后计算并平均所有批次的损失。或者,我们可以通过 num_batches 指定更少的批次数量,以加快模型训练期间的评估速度。

让我们看看这个 calc_loss_loader 函数的实际应用,将其应用于训练集和验证集加载器:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)   #1
with torch.no_grad():                                        #2
    train_loss = calc_loss_loader(train_loader, model, device)    #3
    val_loss = calc_loss_loader(val_loader, model, device)
print("Training loss:", train_loss)
print("Validation loss:", val_loss)

1 如果你有一台支持 CUDA 的 GPU 的机器,LLM 将在没有对代码进行任何更改的情况下在 GPU 上进行训练。

2 由于我们尚未开始训练,因此禁用梯度跟踪以提高效率

3 通过“device”设置,我们确保数据被加载到与 LLM 模型相同的设备上。

产生的损失值是

Training loss: 10.98758347829183
Validation loss: 10.98110580444336

损失值相对较高,因为模型尚未经过训练。为了比较,如果模型学会生成与训练和验证集中出现的下一个标记,损失值将接近 0。

现在我们有了衡量生成文本质量的方法,我们将训练 LLM 以减少这种损失,使其在生成文本方面变得更好,如图 5.10 所示。

figure

图 5.10 我们回顾了文本生成过程(步骤 1)并实现了基本的模型评估技术(步骤 2)来计算训练集和验证集的损失(步骤 3)。接下来,我们将进入训练函数并预训练 LLM(步骤 4)。

接下来,我们将专注于预训练 LLM。在模型训练后,我们将实现替代文本生成策略,并保存和加载预训练模型权重。

5.2 训练 LLM

现在是时候实现预训练 LLM,即我们的GPTModel的代码了。为此,我们关注一个简单的训练循环,以保持代码简洁易读。

注意:对更高级技术感兴趣的用户可以在附录 D 中了解有关学习率预热余弦退火梯度裁剪等内容。

figure

图 5.11 PyTorch 中训练深度神经网络的典型训练循环包括多个步骤,迭代训练集中的批次数个 epoch。在每个循环中,我们计算每个训练集批次的损失以确定损失梯度,我们使用这些梯度来更新模型权重,以最小化训练集损失。

图 5.11 中的流程图描述了 PyTorch 神经网络的典型训练工作流程,我们用它来训练 LLM。它概述了八个步骤,从迭代每个 epoch 开始,处理批次,重置梯度,计算损失和新梯度,更新权重,最后以打印损失和生成文本样本等监控步骤结束。

注意:如果你相对较新于使用 PyTorch 训练深度神经网络,并且对其中任何步骤不熟悉,请考虑阅读附录 A 中的 A.5 到 A.8 节。

我们可以通过代码中的train_model_simple函数实现这个训练流程。

列表 5.3 预训练 LLM 的主要函数
def train_model_simple(model, train_loader, val_loader,
                       optimizer, device, num_epochs,
                       eval_freq, eval_iter, start_context, tokenizer):
    train_losses, val_losses, track_tokens_seen = [], [], []    #1
    tokens_seen, global_step = 0, -1

    for epoch in range(num_epochs):    #2
        model.train()
        for input_batch, target_batch in train_loader:
            optimizer.zero_grad()   #3
            loss = calc_loss_batch(
                input_batch, target_batch, model, device
            )
            loss.backward()                     #4
            optimizer.step()                    #5
            tokens_seen += input_batch.numel()
            global_step += 1

            if global_step % eval_freq == 0:    #6
                train_loss, val_loss = evaluate_model(
                    model, train_loader, val_loader, device, eval_iter)
                train_losses.append(train_loss)
                val_losses.append(val_loss)
                track_tokens_seen.append(tokens_seen)
                print(f"Ep {epoch+1} (Step {global_step:06d}): "
                      f"Train loss {train_loss:.3f}, "
                      f"Val loss {val_loss:.3f}"
                )

        generate_and_print_sample(                      #7
            model, tokenizer, device, start_context
        )
    return train_losses, val_losses, track_tokens_seen

1 初始化列表以跟踪损失和已看到的标记

2 启动主训练循环

3 从上一批次的迭代中重置损失梯度

4 计算损失梯度

5 使用损失梯度更新模型权重

6 可选评估步骤

7 在每个 epoch 后打印一个样本文本

注意:我们刚刚创建的train_model_simple函数使用了我们尚未定义的两个函数:evaluate_modelgenerate_and_print_sample

evaluate_model函数对应于图 5.11 中的步骤 7。它在每次模型更新后打印训练集和验证集的损失,以便我们可以评估训练是否改善了模型。更具体地说,evaluate_model函数在计算训练集和验证集的损失时,确保模型处于评估模式,并且禁用了梯度跟踪和 Dropout。

def evaluate_model(model, train_loader, val_loader, device, eval_iter):
    model.eval()  #1
    with torch.no_grad():                              #2
        train_loss = calc_loss_loader(
            train_loader, model, device, num_batches=eval_iter
        )
        val_loss = calc_loss_loader(
            val_loader, model, device, num_batches=eval_iter
        )
    model.train()
    return train_loss, val_loss

1 在评估期间禁用 Dropout 以获得稳定、可重复的结果。

2 禁用梯度跟踪,这在评估期间不是必需的,以减少计算开销

evaluate_model类似,generate_and_print_sample函数是一个方便的函数,我们用它来跟踪模型在训练过程中的改进情况。特别是,generate_and_print_sample函数接受一个文本片段(start_context)作为输入,将其转换为 token ID,并使用我们之前使用的generate_text_simple函数将其输入到 LLM 中生成文本样本:

def generate_and_print_sample(model, tokenizer, device, start_context):
    model.eval()
    context_size = model.pos_emb.weight.shape[0]
    encoded = text_to_token_ids(start_context, tokenizer).to(device)
    with torch.no_grad():
        token_ids = generate_text_simple(
            model=model, idx=encoded,
            max_new_tokens=50, context_size=context_size
        )
    decoded_text = token_ids_to_text(token_ids, tokenizer)
    print(decoded_text.replace("\n", " "))      #1
    model.train()

1 紧凑打印格式

虽然evaluate_model函数给我们提供了一个模型训练进度的数值估计,但这个generate_and_print_sample文本函数提供了一个由模型生成的具体文本示例,我们可以用它来判断模型在训练过程中的能力。

AdamW

Adam优化器是训练深度神经网络时的一个流行选择。然而,在我们的训练循环中,我们选择了AdamW优化器。AdamW 是 Adam 的一个变体,它改进了权重衰减方法,旨在通过惩罚较大的权重来最小化模型复杂度并防止过拟合。这种调整使得 AdamW 能够实现更有效的正则化和更好的泛化;因此,AdamW 经常用于 LLM 的训练。

让我们通过使用AdamW优化器和之前定义的train_model_simple函数训练一个GPTModel实例 10 个 epoch 来观察这一切的实际效果:

torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.to(device)
optimizer = torch.optim.AdamW(
     model.parameters(),           #1
    lr=0.0004, weight_decay=0.1
)
num_epochs = 10
train_losses, val_losses, tokens_seen = train_model_simple(
    model, train_loader, val_loader, optimizer, device,
    num_epochs=num_epochs, eval_freq=5, eval_iter=5,
    start_context="Every effort moves you", tokenizer=tokenizer
)

1 .parameters()方法返回模型的所有可训练权重参数。

执行train_model_simple函数开始训练过程,这个过程在 MacBook Air 或类似笔记本电脑上大约需要 5 分钟才能完成。在此执行期间打印的输出如下:

Ep 1 (Step 000000): Train loss 9.781, Val loss 9.933
Ep 1 (Step 000005): Train loss 8.111, Val loss 8.339
Every effort moves you,,,,,,,,,,,,.                                     
Ep 2 (Step 000010): Train loss 6.661, Val loss 7.048
Ep 2 (Step 000015): Train loss 5.961, Val loss 6.616
Every effort moves you, and, and, and, and, and, and, and, and, and, and,
 and, and, and, and, and, and, and, and, and, and, and, and,, and, and,
[...]                                                   #1
Ep 9 (Step 000080): Train loss 0.541, Val loss 6.393
Every effort moves you?"  "Yes--quite insensible to the irony. She wanted
him vindicated--and by me!"  He laughed again, and threw back the 
window-curtains, I had the donkey. "There were days when I
Ep 10 (Step 000085): Train loss 0.391, Val loss 6.452
Every effort moves you know," was one of the axioms he laid down across the
Sevres and silver of an exquisitely appointed luncheon-table, when, on a
later day, I had again run over from Monte Carlo; and Mrs. Gis

1 删除中间结果以节省空间

如我们所见,训练损失显著改善,从 9.781 的值开始,收敛到 0.391。该模型的语言技能有了很大的提升。一开始,模型只能将逗号添加到起始上下文(Every effort moves you,,,,,,,,,,,,)或重复单词and。在训练结束时,它可以生成语法正确的文本。

与训练集损失类似,我们可以看到验证损失开始较高(9.933)并在训练过程中下降。然而,它从未变得像训练集损失那样小,并在第 10 个 epoch 后保持在 6.452。

在更详细地讨论验证损失之前,让我们创建一个简单的图,该图显示了训练集和验证集损失并排显示:

import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator
def plot_losses(epochs_seen, tokens_seen, train_losses, val_losses):
    fig, ax1 = plt.subplots(figsize=(5, 3))
    ax1.plot(epochs_seen, train_losses, label="Training loss")
    ax1.plot(
        epochs_seen, val_losses, linestyle="-.", label="Validation loss"
    )
    ax1.set_xlabel("Epochs")
    ax1.set_ylabel("Loss")
    ax1.legend(loc="upper right")
    ax1.xaxis.set_major_locator(MaxNLocator(integer=True))
    ax2 = ax1.twiny()                   #1
    ax2.plot(tokens_seen, train_losses, alpha=0)     #2
    ax2.set_xlabel("Tokens seen")
    fig.tight_layout()
    plt.show()

epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
plot_losses(epochs_tensor, tokens_seen, train_losses, val_losses)

1 创建一个与同一 y 轴共享的第二个 x 轴

2 用于对齐刻度的不可见图

结果的训练和验证损失图如图 5.12 所示。如图所示,训练和验证损失在第一个 epoch 开始时开始改善。然而,损失在第二个 epoch 之后开始发散。这种发散以及验证损失远大于训练损失的事实表明,模型对训练数据过度拟合。我们可以通过搜索生成的文本片段来确认模型逐字逐句地记住了训练数据,例如在“The Verdict”文本文件中的“相当” “无感觉” “对” “讽刺”。

figure

图 5.12 在训练开始时,训练集和验证集损失急剧下降,这是模型正在学习的标志。然而,训练集损失在第二个 epoch 之后继续下降,而验证损失停滞不前。这是模型仍在学习,但在 epoch 2 之后对训练集过度拟合的标志。

由于我们正在使用一个非常小的训练数据集,并且对模型进行多轮训练,因此这种记忆是预期的。通常,在只有一个 epoch 的情况下,在更大的数据集上训练模型是常见的。

注意:如前所述,感兴趣的读者可以尝试在 Project Gutenberg 的 60,000 本公共领域书籍上训练模型,在那里不会发生过度拟合;参见附录 B 的详细信息。

figure

图 5.13 在实现训练函数后,我们的模型可以生成连贯的文本。然而,它经常逐字逐句地记住训练集中的段落。接下来,我们将讨论生成更多样化输出文本的策略。

如图 5.13 所示,我们已经完成了本章的四个目标。接下来,在我们介绍权重加载和保存以及从 OpenAI 的 GPT 模型加载预训练权重之前,我们将讨论 LLM 的文本生成策略,以减少训练数据记忆并提高 LLM 生成文本的原创性。

5.3 控制随机性的解码策略

让我们看看文本生成策略(也称为解码策略),以生成更多原创文本。首先,我们将简要回顾我们之前在generate_and_print_sample中使用的generate_text_simple函数。然后,我们将介绍两种技术,温度缩放top-k 采样,以改进此函数。

我们首先将模型从 GPU 转移到 CPU,因为使用相对较小的模型进行推理不需要 GPU。此外,在训练后,我们将模型放入评估模式以关闭随机组件,如 dropout:

model.to("cpu")
model.eval()

接下来,我们将GPTModel实例(model)插入到generate_text_simple函数中,该函数使用 LLM 逐个生成标记:

tokenizer = tiktoken.get_encoding("gpt2")
token_ids = generate_text_simple(
    model=model,
    idx=text_to_token_ids("Every effort moves you", tokenizer),
    max_new_tokens=25,
    context_size=GPT_CONFIG_124M["context_length"]
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))

生成的文本是

Output text:
Every effort moves you know," was one of the axioms he laid down across the
Sevres and silver of an exquisitely appointed lun

如前所述,生成的标记是在每个生成步骤中根据词汇表中所有标记的最大概率分数选择的。这意味着即使我们多次在相同的起始上下文(Every effort moves you)上运行前面的generate_text_simple函数,LLM 也会始终生成相同的输出。

5.3.1 温度缩放

现在我们来看温度缩放技术,这是一种将概率选择过程添加到下一个标记生成任务中的技术。之前,在generate_text_simple函数内部,我们总是使用torch.argmax(也称为贪婪解码)来采样概率最高的标记作为下一个标记。为了生成更多样化的文本,我们可以用从概率分布中采样的函数(在这里,是 LLM 在每次标记生成步骤为每个词汇条目生成的概率分数)来替换argmax

为了用具体例子说明概率抽样,让我们简要讨论使用一个非常小的词汇表来演示的下一个标记生成过程:

vocab = { 
    "closer": 0,
    "every": 1, 
    "effort": 2, 
    "forward": 3,
    "inches": 4,
    "moves": 5, 
    "pizza": 6,
    "toward": 7,
    "you": 8,
} 
inverse_vocab = {v: k for k, v in vocab.items()}

接下来,假设 LLM 被给定了起始上下文"every" effort moves you"并生成了以下下一个标记的 logits:

next_token_logits = torch.tensor(
    [4.51, 0.89, -1.90, 6.75, 1.63, -1.62, -1.89, 6.28, 1.79]
)

如第四章所述,在generate_text_simple内部,我们通过softmax函数将 logits 转换为概率,并通过argmax函数获得生成的标记对应的标记 ID,然后我们可以通过逆词汇将其映射回文本:

probas = torch.softmax(next_token_logits, dim=0)
next_token_id = torch.argmax(probas).item()
print(inverse_vocab[next_token_id])

由于最大的 logit 值以及相应的最大的 softmax 概率分数位于第四位(Python 使用 0 索引,因此索引位置为 3),生成的单词是"forward"

为了实现概率抽样过程,我们现在可以在 PyTorch 中将argmax替换为multinomial函数:

torch.manual_seed(123) 
next_token_id = torch.multinomial(probas, num_samples=1).item()
print(inverse_vocab[next_token_id])

打印的输出与之前一样是"forward"。发生了什么?multinomial函数按概率分数成比例地采样下一个标记。换句话说,"forward"仍然是概率最高的标记,并且大多数时候会被multinomial选中,但不是每次都会被选中。为了说明这一点,让我们实现一个重复此采样 1,000 次的函数:

def print_sampled_tokens(probas):
    torch.manual_seed(123)
    sample = [torch.multinomial(probas, num_samples=1).item()
             for i in range(1_000)]
    sampled_ids = torch.bincount(torch.tensor(sample))
    for i, freq in enumerate(sampled_ids):
        print(f"{freq} x {inverse_vocab[i]}")

print_sampled_tokens(probas)

采样输出是

73 x closer
0 x every
0 x effort
582 x forward
2 x inches
0 x moves
0 x pizza
343 x toward

如我们所见,单词forward在大多数时候被采样(1,000 次中有 582 次),但其他标记如closerinchestoward也会被采样一些时候。这意味着如果我们将generate_and_print_sample函数中的argmax函数替换为multinomial函数,LLM 有时会生成如every effort moves you towardevery effort moves you inchesevery effort moves you closer这样的文本,而不是every effort moves you forward

我们可以通过一个称为温度缩放的概念进一步控制分布和选择过程。温度缩放只是将 logits 除以一个大于 0 的数的花哨说法:

def softmax_with_temperature(logits, temperature):
    scaled_logits = logits / temperature
    return torch.softmax(scaled_logits, dim=0)

大于 1 的温度会导致标记概率分布更加均匀,而小于 1 的温度会导致更加自信(更尖锐或更峰值)的分布。让我们通过将原始概率与不同温度值缩放的概率一起绘制来展示这一点:

temperatures = [1, 0.1, 5]                                     #1
scaled_probas = [softmax_with_temperature(next_token_logits, T)
                for T in temperatures]
x = torch.arange(len(vocab))
bar_width = 0.15
fig, ax = plt.subplots(figsize=(5, 3))
for i, T in enumerate(temperatures):
    rects = ax.bar(x + i * bar_width, scaled_probas[i], 
                   bar_width, label=f'Temperature = {T}')
ax.set_ylabel('Probability')
ax.set_xticks(x)
ax.set_xticklabels(vocab.keys(), rotation=90)
ax.legend()
plt.tight_layout()
plt.show()

1 原始、较低和较高置信度

结果图如图 5.14 所示。

figure

图 5.14 温度为 1 表示词汇表中每个标记的未缩放概率分数。将温度降低到 0.1 会使分布更加尖锐,这样最可能的标记(在这里是“forward”)将具有更高的概率分数。同样,将温度增加到 5 会使分布更加均匀。

温度为 1 时,在将 logits 传递给 softmax 函数以计算概率分数之前,将其除以 1。换句话说,使用温度为 1 等同于不使用任何温度缩放。在这种情况下,通过 PyTorch 中的 multinomial 采样函数,以与原始 softmax 概率分数相等的概率选择标记。例如,对于温度设置 1,对应于“forward”的标记大约有 60% 的时间被选中,如图 5.14 所示。

此外,如图 5.14 所示,应用非常小的温度,如 0.1,将导致分布更加尖锐,这样 multinomial 函数的行为几乎 100% 选中最可能的标记(在这里是 "forward"),接近 argmax 函数的行为。同样,温度为 5 导致分布更加均匀。这可以为生成的文本添加更多多样性,但也会更频繁地产生无意义的文本。例如,使用温度为 5 时,文本中出现every effort moves you pizza的频率大约为 4%。

练习 5.1

使用 print_sampled_tokens 函数打印出与图 5.14 中显示的温度缩放的 softmax 概率采样频率。在每种情况下,pizza这个词被采样的频率是多少?你能想到一种更快更准确的方法来确定pizza这个词被采样的频率吗?

5.3.2 Top-k 采样

我们现在已经实现了一种结合温度缩放的概率采样方法,以增加输出的多样性。我们注意到,较高的温度值会导致下一个标记的概率分布更加均匀,这减少了模型反复选择最可能标记的可能性,从而产生了更多样化的输出。这种方法允许在生成过程中探索不太可能但可能更有趣和创造性的路径。然而,这种方法的一个缺点是,有时会导致语法错误或完全不合理的输出,例如every effort moves you pizza

Top-k 采样,当与概率采样和温度缩放结合使用时,可以提高文本生成结果。在 top-k 采样中,我们可以将采样的标记限制为最可能的 top-k 标记,并通过屏蔽它们的概率分数来排除所有其他标记的选择过程,如图 5.15 所示。

figure

图 5.15 使用 k = 3 的 top-k 采样,我们关注与最高 logits 值相关的三个标记,在应用softmax函数之前,用负无穷大(–inf)屏蔽所有其他标记。这导致一个概率分布,其中所有非 top-k 标记的概率值被分配为 0。(图中数字在小数点后截断为两位以减少视觉杂乱。在“Softmax”行中的值应加起来为 1.0。)

top-k 方法将所有未选择的 logits 替换为负无穷大值(-inf),这样在计算 softmax 值时,非 top-k 标记的概率分数为 0,剩余的概率加起来为 1。(仔细的读者可能会记得我们在第三章第 3.5.1 节中实现的因果注意力模块中的这个屏蔽技巧。)

在代码中,我们可以按照以下方式实现图 5.15 中的 top-k 过程,从选择具有最大 logits 值的标记开始:

top_k = 3
top_logits, top_pos = torch.topk(next_token_logits, top_k)
print("Top logits:", top_logits)
print("Top positions:", top_pos)

按降序排列,top 三个标记的 logits 值和标记 ID 如下

Top logits: tensor([6.7500, 6.2800, 4.5100])
Top positions: tensor([3, 7, 0])

随后,我们应用 PyTorch 的where函数将位于我们 top-three 选择中最低 logits 值以下的所有标记的 logits 值设置为负无穷大(-inf):

new_logits = torch.where(
    condition=next_token_logits < top_logits[-1],    #1
    input=torch.tensor(float('-inf')),     #2
    other=next_token_logits     #3
)
print(new_logits)

1 识别出 top 3 中低于最小值的 logits

2 将这些较低 logits 分配为–inf

3 保留所有其他标记的原始 logits

在九个标记词汇中的下一个标记的 logits 值如下

tensor([4.5100,   -inf,   -inf, 6.7500,   -inf,   -inf,   -inf, 6.2800,
     -inf])

最后,让我们应用softmax函数将这些转换为下一个标记的概率:

topk_probas = torch.softmax(new_logits, dim=0)
print(topk_probas)

如我们所见,这种 top-three 方法的结果是三个非零概率分数:

tensor([0.0615, 0.0000, 0.0000, 0.5775, 0.0000, 0.0000, 0.0000, 0.3610,
        0.0000])

我们现在可以应用温度缩放和多项式函数进行概率采样,从这三个非零概率分数中选择下一个标记来生成下一个标记。我们接下来通过修改文本生成函数来实现这一点。

5.3.3 修改文本生成函数

现在,让我们结合温度采样和 top-k 采样来修改我们之前用于通过 LLM 生成文本的generate_text_simple函数,创建一个新的generate函数。

列表 5.4 一个具有更多多样性的修改后的文本生成函数
def generate(model, idx, max_new_tokens, context_size,
             temperature=0.0, top_k=None, eos_id=None):
    for _ in range(max_new_tokens):            #1
        idx_cond = idx[:, -context_size:]
        with torch.no_grad():
            logits = model(idx_cond)
        logits = logits[:, -1, :]
        if top_k is not None:                #2
            top_logits, _ = torch.topk(logits, top_k)
            min_val = top_logits[:, -1]
            logits = torch.where(
                logits < min_val,
                torch.tensor(float('-inf')).to(logits.device),
                logits
            )
        if temperature > 0.0:                  #3
            logits = logits / temperature
            probs = torch.softmax(logits, dim=-1)
            idx_next = torch.multinomial(probs, num_samples=1)
        else:    #4
            idx_next = torch.argmax(logits, dim=-1, keepdim=True)
        if idx_next == eos_id:              #5
            break
        idx = torch.cat((idx, idx_next), dim=1)
    return idx

1 循环结构与之前相同:获取 logits 并仅关注最后一个时间步。

2 使用 top_k 采样过滤 logits

3 应用温度缩放

4 当禁用温度缩放时,与之前一样执行贪婪的下一个标记选择

5 遇到序列结束标记时提前停止生成

让我们现在看看这个新的generate函数的实际应用:

torch.manual_seed(123)
token_ids = generate(
    model=model,
    idx=text_to_token_ids("Every effort moves you", tokenizer),
    max_new_tokens=15,
    context_size=GPT_CONFIG_124M["context_length"],
    top_k=25,
    temperature=1.4
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))

生成的文本是

Output text:
 Every effort moves you stand to work on surprise, a one of us had gone
 with random-

如我们所见,生成的文本与我们之前在 5.3 节中通过generate_simple函数生成的文本非常不同("Every effort moves you know," was one of the axioms he laid...!),这是训练集中的记忆段落。

练习 5.2

尝试不同的温度和 top-k 设置。根据你的观察,你能想到哪些需要较低温度和 top-k 设置的用例?同样,你能想到哪些需要较高温度和 top-k 设置的用例?(建议在加载 OpenAI 的预训练权重后,在章节末尾再次回顾这个练习。)

练习 5.3

generate函数的不同设置组合有哪些,可以强制确定性行为,即禁用随机采样,使其总是产生相同的输出,类似于generate_simple函数?

5.4 在 PyTorch 中加载和保存模型权重

到目前为止,我们已经讨论了如何数值评估训练进度和从头开始预训练一个 LLM。尽管 LLM 和数据集相对较小,但这个练习表明预训练 LLM 是计算密集型的。因此,能够保存 LLM 非常重要,这样我们就不必每次在新会话中使用它时都重新运行训练。

因此,让我们讨论如何保存和加载预训练模型,如图 5.16 所示。稍后,我们将从 OpenAI 加载一个更强大的预训练 GPT 模型到我们的GPTModel实例中。

figure

图 5.16 在训练和检查模型后,保存模型通常很有帮助,这样我们就可以稍后使用或继续训练它(步骤 6)。

幸运的是,保存 PyTorch 模型相对简单。推荐的方法是使用torch.save函数保存模型的state_dict,这是一个将每一层映射到其参数的字典:

torch.save(model.state_dict(), "model.pth")

"model.pth"是保存state_dict的文件名。.pth扩展名是 PyTorch 文件的约定,尽管技术上我们可以使用任何文件扩展名。

然后在通过state_dict保存模型权重之后,我们可以将模型权重加载到一个新的GPTModel模型实例中:

model = GPTModel(GPT_CONFIG_124M)
model.load_state_dict(torch.load("model.pth", map_location=device))
model.eval()

如第四章所述,dropout 通过在训练过程中随机“丢弃”层中的神经元来帮助防止模型过度拟合训练数据。然而,在推理过程中,我们不希望随机丢弃网络学习到的任何信息。使用model.eval()将模型切换到评估模式进行推理,禁用model的 dropout 层。如果我们计划稍后继续预训练模型——例如,使用本章前面定义的train_model_simple函数——保存优化器状态也是推荐的。

自适应优化器,如 AdamW,为每个模型权重存储额外的参数。AdamW 使用历史数据来动态调整每个模型参数的学习率。没有它,优化器会重置,模型可能学习不佳,甚至无法正确收敛,这意味着它将失去生成连贯文本的能力。使用torch.save,我们可以保存模型和优化器的state_dict内容:

torch.save({
    "model_state_dict": model.state_dict(),
    "optimizer_state_dict": optimizer.state_dict(),
    }, 
    "model_and_optimizer.pth"
)

然后,我们可以通过首先使用torch.load加载保存的数据,然后使用load_state_dict方法来恢复模型和优化器状态:

checkpoint = torch.load("model_and_optimizer.pth", map_location=device)
model = GPTModel(GPT_CONFIG_124M)
model.load_state_dict(checkpoint["model_state_dict"])
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-4, weight_decay=0.1)
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
model.train();
练习 5.4

保存权重后,在新的 Python 会话或 Jupyter 笔记本文件中加载模型和优化器,并使用train_model_simple函数继续进行一个 epoch 的预训练。

5.5 从 OpenAI 加载预训练权重

在之前,我们使用一个包含短篇小说集的有限数据集训练了一个小的 GPT-2 模型。这种方法使我们能够专注于基础,而无需大量的时间和计算资源。

幸运的是,OpenAI 公开分享了他们的 GPT-2 模型的权重,从而消除了我们自己在大型语料库上重新训练模型所需的数十万甚至数百万美元的投资。因此,让我们将这些权重加载到我们的GPTModel类中,并使用该模型进行文本生成。在这里,权重指的是存储在 PyTorch 的LinearEmbedding层的.weight属性中的权重参数,例如。我们在训练模型时通过model.parameters()访问过它们。在第六章中,我们将重用这些预训练的权重来微调模型以进行文本分类任务,并遵循类似于 ChatGPT 的说明。

注意,OpenAI 最初通过 TensorFlow 保存了 GPT-2 的权重,我们必须安装 TensorFlow 才能在 Python 中加载权重。以下代码将使用名为tqdm的进度条工具来跟踪下载过程,我们同样需要安装它。

您可以通过在终端中执行以下命令来安装这些库:

pip install tensorflow>=2.15.0  tqdm>=4.66

下载代码相对较长,主要是样板代码,并不很有趣。因此,我们不会在讨论从互联网上获取文件的 Python 代码上浪费宝贵空间,而是直接从本章的在线仓库下载gpt_download.py Python 模块:

import urllib.request
url = (
    "https://raw.githubusercontent.com/rasbt/"
    "LLMs-from-scratch/main/ch05/"
    "01_main-chapter-code/gpt_download.py"
)
filename = url.split('/')[-1]
urllib.request.urlretrieve(url, filename)

接下来,在将此文件下载到 Python 会话的本地目录后,您应该简要检查此文件的内容,以确保它已正确保存并包含有效的 Python 代码。

现在,我们可以按照以下方式从gpt_download.py文件中导入download_and_load_gpt2函数,这将把 GPT-2 架构设置(settings)和权重参数(params)加载到我们的 Python 会话中:

from gpt_download import download_and_load_gpt2
settings, params = download_and_load_gpt2(
    model_size="124M", models_dir="gpt2"
)

执行此代码将下载与124M参数 GPT-2 模型相关的以下七个文件:

checkpoint: 100%|███████████████████████████| 77.0/77.0 [00:00<00:00, 
                                                         63.9kiB/s]
encoder.json: 100%|█████████████████████████| 1.04M/1.04M [00:00<00:00,
                                                           2.20MiB/s]
hprams.json: 100%|██████████████████████████| 90.0/90.0 [00:00<00:00,
                                                         78.3kiB/s]
model.ckpt.data-00000-of-00001: 100%|███████| 498M/498M [01:09<00:00,
                                                         7.16MiB/s]
model.ckpt.index: 100%|█████████████████████| 5.21k/5.21k [00:00<00:00,
                                                           3.24MiB/s]
model.ckpt.meta: 100%|██████████████████████| 471k/471k [00:00<00:00, 
                                                         2.46MiB/s]
vocab.bpe: 100%|████████████████████████████| 456k/456k [00:00<00:00,
                                                         1.70MiB/s]

注意:如果下载代码对您不起作用,可能是由于间歇性互联网连接、服务器问题或 OpenAI 分享开源 GPT-2 模型权重的变化方式。在这种情况下,请访问本章的在线代码仓库github.com/rasbt/LLMs-from-scratch,以获取替代和更新的说明,并通过 Manning 论坛提出进一步的问题。

假设上一段代码已执行完成,让我们检查settingsparams的内容:

print("Settings:", settings)
print("Parameter dictionary keys:", params.keys())

内容如下

Settings: {'n_vocab': 50257, 'n_ctx': 1024, 'n_embd': 768, 'n_head': 12,
           'n_layer': 12}
Parameter dictionary keys: dict_keys(['blocks', 'b', 'g', 'wpe', 'wte'])

settingsparams都是 Python 字典。settings字典存储了 LLM 架构设置,类似于我们手动定义的GPT_CONFIG_124M设置。params字典包含实际的权重张量。请注意,我们只打印了字典键,因为打印权重内容会占用太多的屏幕空间;然而,我们可以通过打印整个字典print(params)或通过选择单个张量,例如通过相应的字典键来检查这些权重张量,例如嵌入层权重:

print(params["wte"])
print("Token embedding weight tensor dimensions:", params["wte"].shape)

令牌嵌入层的权重是

[[-0.11010301 ... -0.1363697   0.01506208   0.04531523]
 [ 0.04034033 ...  0.08605453  0.00253983   0.04318958]
 [-0.12746179  ...  0.08991534 -0.12972379 -0.08785918]
 ...
 [-0.04453601 ...   0.10435229  0.09783269 -0.06952604]
 [ 0.1860082  ...  -0.09625227  0.07847701 -0.02245961]
 [ 0.05135201 ...   0.00704835  0.15519823  0.12067825]]
Token embedding weight tensor dimensions: (50257, 768)

我们通过download_and_load_gpt2(model_size="124M", ...)设置下载并加载了最小的 GPT-2 模型权重。OpenAI 还分享了更大模型的权重:355M774M1558M。这些不同尺寸的 GPT 模型的总体架构是相同的,如图 5.17 所示,除了不同的架构元素重复的次数不同,以及嵌入大小不同。本章剩余的代码也与这些更大的模型兼容。

figure

图 5.17 GPT-2 LLM 有几种不同的模型尺寸,从 1.24 亿到 1.558 亿参数不等。核心架构是相同的,唯一的区别是嵌入大小以及注意力头和 Transformer 块等单个组件重复的次数。

在将 GPT-2 模型权重加载到 Python 中之后,我们仍然需要将它们从settingsparams字典转移到我们的GPTModel实例中。首先,我们创建一个字典,列出图 5.17 中不同 GPT 模型尺寸之间的差异:

model_configs = {
    "gpt2-small (124M)": {"emb_dim": 768, "n_layers": 12, "n_heads": 12},
    "gpt2-medium (355M)": {"emb_dim": 1024, "n_layers": 24, "n_heads": 16},
    "gpt2-large (774M)": {"emb_dim": 1280, "n_layers": 36, "n_heads": 20},
    "gpt2-xl (1558M)": {"emb_dim": 1600, "n_layers": 48, "n_heads": 25},
}

假设我们感兴趣的是加载最小的模型"gpt2-small"(124M)。我们可以使用model_configs表中的相应设置来更新我们之前定义并使用的完整长度的GPT_CONFIG_124M

model_name = "gpt2-small (124M)"
NEW_CONFIG = GPT_CONFIG_124M.copy()
NEW_CONFIG.update(model_configs[model_name])

仔细的读者可能还记得我们之前使用的是 256 标记长度,但 OpenAI 的原始 GPT-2 模型是在 1,024 标记长度下训练的,因此我们必须相应地更新NEW_CONFIG

NEW_CONFIG.update({"context_length": 1024})

此外,OpenAI 在多头注意力模块的线性层中使用了偏置向量来实现查询、键和值矩阵的计算。偏置向量在 LLMs 中不再常用,因为它们不会提高建模性能,因此是不必要的。然而,由于我们正在使用预训练的权重,我们需要匹配设置以保持一致性并启用这些偏置向量:

NEW_CONFIG.update({"qkv_bias": True})

现在我们可以使用更新的NEW_CONFIG字典来初始化一个新的GPTModel实例:

gpt = GPTModel(NEW_CONFIG)
gpt.eval()

默认情况下,GPTModel实例使用随机权重进行预训练。使用 OpenAI 模型权重的最后一步是用我们加载到params字典中的权重覆盖这些随机权重。为此,我们首先定义一个小的assign实用函数,该函数检查两个张量或数组(leftright)是否具有相同的维度或形状,并返回正确的张量作为可训练的 PyTorch 参数:

def assign(left, right):
    if left.shape != right.shape:
        raise ValueError(f"Shape mismatch. Left: {left.shape}, "
                          "Right: {right.shape}"
        )
    return torch.nn.Parameter(torch.tensor(right))

接下来,我们定义一个load_weights_into_gpt函数,该函数将params字典中的权重加载到GPTModel实例gpt中。

列表 5.5 将 OpenAI 权重加载到我们的 GPT 模型代码中
import numpy as np

def load_weights_into_gpt(gpt, params):           #1
    gpt.pos_emb.weight = assign(gpt.pos_emb.weight, params['wpe'])
    gpt.tok_emb.weight = assign(gpt.tok_emb.weight, params['wte'])

    for b in range(len(params["blocks"])):     #2
        q_w, k_w, v_w = np.split(                            #3
            (params["blocks"][b]["attn"]["c_attn"])["w"], 3, axis=-1)
        gpt.trf_blocks[b].att.W_query.weight = assign(
            gpt.trf_blocks[b].att.W_query.weight, q_w.T)
        gpt.trf_blocks[b].att.W_key.weight = assign(
            gpt.trf_blocks[b].att.W_key.weight, k_w.T)
        gpt.trf_blocks[b].att.W_value.weight = assign(
            gpt.trf_blocks[b].att.W_value.weight, v_w.T)

        q_b, k_b, v_b = np.split(
            (params["blocks"][b]["attn"]["c_attn"])["b"], 3, axis=-1)
        gpt.trf_blocks[b].att.W_query.bias = assign(
            gpt.trf_blocks[b].att.W_query.bias, q_b)
        gpt.trf_blocks[b].att.W_key.bias = assign(
            gpt.trf_blocks[b].att.W_key.bias, k_b)
        gpt.trf_blocks[b].att.W_value.bias = assign(
            gpt.trf_blocks[b].att.W_value.bias, v_b)

        gpt.trf_blocks[b].att.out_proj.weight = assign(
            gpt.trf_blocks[b].att.out_proj.weight, 
            params["blocks"][b]["attn"]["c_proj"]["w"].T)
        gpt.trf_blocks[b].att.out_proj.bias = assign(
            gpt.trf_blocks[b].att.out_proj.bias, 
            params["blocks"][b]["attn"]["c_proj"]["b"])

        gpt.trf_blocks[b].ff.layers[0].weight = assign(
            gpt.trf_blocks[b].ff.layers[0].weight, 
            params["blocks"][b]["mlp"]["c_fc"]["w"].T)
        gpt.trf_blocks[b].ff.layers[0].bias = assign(
            gpt.trf_blocks[b].ff.layers[0].bias, 
            params["blocks"][b]["mlp"]["c_fc"]["b"])
        gpt.trf_blocks[b].ff.layers[2].weight = assign(
            gpt.trf_blocks[b].ff.layers[2].weight, 
            params["blocks"][b]["mlp"]["c_proj"]["w"].T)
        gpt.trf_blocks[b].ff.layers[2].bias = assign(
            gpt.trf_blocks[b].ff.layers[2].bias, 
            params["blocks"][b]["mlp"]["c_proj"]["b"])

        gpt.trf_blocks[b].norm1.scale = assign(
            gpt.trf_blocks[b].norm1.scale, 
            params["blocks"][b]["ln_1"]["g"])
        gpt.trf_blocks[b].norm1.shift = assign(
            gpt.trf_blocks[b].norm1.shift, 
            params["blocks"][b]["ln_1"]["b"])
        gpt.trf_blocks[b].norm2.scale = assign(
            gpt.trf_blocks[b].norm2.scale, 
            params["blocks"][b]["ln_2"]["g"])
        gpt.trf_blocks[b].norm2.shift = assign(
            gpt.trf_blocks[b].norm2.shift, 
            params["blocks"][b]["ln_2"]["b"])

    gpt.final_norm.scale = assign(gpt.final_norm.scale, params["g"])
    gpt.final_norm.shift = assign(gpt.final_norm.shift, params["b"])
    gpt.out_head.weight = assign(gpt.out_head.weight, params["wte"])    #4

1 将模型的定位和标记嵌入权重设置为params中指定的那些。

2 遍历模型中的每个 transformer 块

3 使用np.split函数将注意力和偏置权重分为查询、键和值组件的三个相等部分。

4 OpenAI 的原始 GPT-2 模型在输出层中重用了标记嵌入权重以减少参数总数,这是一个称为权重绑定的概念。

load_weights_into_gpt函数中,我们仔细地将来自 OpenAI 实现的权重与我们的GPTModel实现进行匹配。以一个具体的例子来说,OpenAI 将第一个 transformer 块的输出投影层的权重张量存储为params["blocks"][0]["attn"]["c_proj"]["w"]。在我们的实现中,这个权重张量对应于gpt.trf_blocks[b].att.out_proj .weight,其中gpt是一个GPTModel实例。

开发load_weights_into_gpt函数花费了很多猜测工作,因为 OpenAI 使用的命名约定与我们略有不同。然而,如果尝试匹配两个维度不同的张量,assign函数会提醒我们。此外,如果我们在这个函数中犯了错误,我们会注意到这一点,因为生成的 GPT 模型将无法生成连贯的文本。

现在我们来实际尝试使用load_weights_into_gpt并将 OpenAI 模型权重加载到我们的GPTModel实例gpt中:

load_weights_into_gpt(gpt, params)
gpt.to(device)

如果模型加载正确,我们现在可以使用它通过之前的generate函数生成新的文本:

torch.manual_seed(123)
token_ids = generate(
    model=gpt,
    idx=text_to_token_ids("Every effort moves you", tokenizer).to(device),
    max_new_tokens=25,
    context_size=NEW_CONFIG["context_length"],
    top_k=50,
    temperature=1.5
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))

产生的文本如下:

Output text:
 Every effort moves you toward finding an ideal new way to practice something!
What makes us want to be on top of that?

我们可以确信我们已经正确加载了模型权重,因为模型可以生成连贯的文本。在这个过程中出现微小的错误会导致模型失败。在接下来的章节中,我们将进一步使用这个预训练模型,并对其进行微调以分类文本和遵循指令。

练习 5.5

在“The Verdict”数据集上,使用 OpenAI 提供的预训练权重计算GPTModel的训练和验证集损失。

练习 5.6

尝试不同大小的 GPT-2 模型——例如,最大的 1,558 百万参数模型——并将生成的文本与 1.24 亿参数模型进行比较。

摘要

  • 当 LLM 生成文本时,它们一次输出一个标记。

  • 默认情况下,下一个标记是通过将模型输出转换为概率分数并选择与最高概率分数对应的词汇表中的标记来生成的,这被称为“贪婪解码”。

  • 通过概率采样和温度缩放,我们可以影响生成文本的多样性和连贯性。

  • 在训练过程中,LLM 生成的文本的质量可以通过训练和验证集的损失来衡量。

  • 预训练一个大型语言模型(LLM)涉及调整其权重以最小化训练损失。

  • LLM 的训练循环本身是深度学习中的一个标准程序,使用传统的交叉熵损失和 AdamW 优化器。

  • 在大型文本语料库上预训练一个 LLM 既耗时又耗资源,因此我们可以加载公开可用的权重,作为我们自己在大数据集上预训练模型的替代方案。

第六章:分类微调

本章涵盖

  • 介绍不同的 LLM 微调方法

  • 准备用于文本分类的数据集

  • 修改预训练的 LLM 进行微调

  • 微调一个 LLM 以识别垃圾邮件

  • 评估微调 LLM 分类器的准确性

  • 使用微调的 LLM 对新的数据进行分类

到目前为止,我们已经编写了 LLM 架构,对其进行了预训练,并学习了如何将来自外部来源(如 OpenAI)的预训练权重导入我们的模型。现在,我们将通过在特定目标任务(如文本分类)上微调 LLM 来收获我们的劳动成果。我们考察的具体例子是将短信分类为“垃圾邮件”或“非垃圾邮件”。图 6.1 突出了微调 LLM 的两种主要方式:用于分类的微调(步骤 8)和用于遵循指令的微调(步骤 9)。

figure

图 6.1 编写 LLM 的三个主要阶段。本章重点介绍第 3 阶段(步骤 8):将预训练的 LLM 微调为分类器。

6.1 微调的不同类别

微调语言模型最常见的方法是指令微调分类微调。指令微调涉及使用特定指令在一系列任务上训练语言模型,以提高其理解和执行自然语言提示中描述的任务的能力,如图 6.2 所示。

figure

图 6.2 两种不同的指令微调场景。在顶部,模型被要求判断给定的文本是否为垃圾邮件。在底部,模型被给出如何将英语句子翻译成德语的指令。

在分类微调中,如果你有机器学习背景,你可能已经熟悉了这个概念:模型被训练来识别一组特定的类别标签,例如“垃圾邮件”和“非垃圾邮件”。分类任务的例子不仅限于 LLM 和电子邮件过滤,还包括从图像中识别不同的植物种类;将新闻文章分类到体育、政治和技术等主题;以及在医学成像中区分良性肿瘤和恶性肿瘤。

关键点是,分类微调模型仅限于在训练期间遇到的类别进行预测。例如,它可以确定某物是否是“垃圾邮件”或“非垃圾邮件”,如图 6.3 所示,但它对输入文本的其他内容无话可说。

figure

图 6.3 使用 LLM 进行文本分类的场景。针对垃圾邮件分类进行微调的模型不需要在输入旁边提供进一步指令。与指令微调模型相比,它只能响应“垃圾邮件”或“非垃圾邮件”。

与图 6.3 中展示的分类微调模型相比,指令微调模型通常可以承担更广泛的任务范围。我们可以将分类微调模型视为高度专业化的,通常来说,开发一个适用于各种任务的通用模型比开发一个适用于特定类别的模型要容易。

选择正确的方法

指令微调提高了模型根据特定用户指令理解和生成响应的能力。指令微调最适合需要根据复杂用户指令处理各种任务的模型,提高灵活性和交互质量。分类微调适用于需要将数据精确分类到预定义类别的项目,例如情感分析或垃圾邮件检测。

虽然指令微调更加灵活,但它需要更大的数据集和更多的计算资源来开发擅长各种任务的模型。相比之下,分类微调需要较少的数据和计算能力,但其应用范围仅限于模型已训练的特定类别。

6.2 准备数据集

我们将修改并微调之前实现和预训练的 GPT 模型。我们首先下载和准备数据集,如图 6.4 所示。为了提供一个直观且有用的分类微调示例,我们将使用一个包含垃圾邮件和非垃圾邮件的短信数据集。

figure

图 6.4 对分类微调 LLM 的三个阶段过程。第一阶段涉及数据集准备。第二阶段侧重于模型设置。第三阶段涵盖微调和评估模型。

注意:通常通过手机发送的短信,而不是电子邮件。然而,相同的步骤也适用于电子邮件分类,感兴趣的读者可以在附录 B 中找到电子邮件垃圾邮件分类数据集的链接。

第一步是下载数据集。

列表 6.1 下载和解压数据集
import urllib.request
import zipfile
import os
from pathlib import Path

url = "https://archive.ics.uci.edu/static/public/228/sms+spam+collection.zip"
zip_path = "sms_spam_collection.zip"
extracted_path = "sms_spam_collection"
data_file_path = Path(extracted_path) / "SMSSpamCollection.tsv"

def download_and_unzip_spam_data(
        url, zip_path, extracted_path, data_file_path):
    if data_file_path.exists():
        print(f"{data_file_path} already exists. Skipping download "
              "and extraction."
        )
        return

    with urllib.request.urlopen(url) as response:    #1
        with open(zip_path, "wb") as out_file:
            out_file.write(response.read())

    with zipfile.ZipFile(zip_path, "r") as zip_ref:    #2
        zip_ref.extractall(extracted_path)

    original_file_path = Path(extracted_path) / "SMSSpamCollection"
    os.rename(original_file_path, data_file_path)               #3
    print(f"File downloaded and saved as {data_file_path}")

download_and_unzip_spam_data(url, zip_path, extracted_path, data_file_path)

1 下载文件

2 解压文件

3 添加.ts 文件扩展名

执行前面的代码后,数据集被保存为制表符分隔的文本文件,SMSSpamCollection.tsv,在sms_spam_collection文件夹中。我们可以按照以下方式将其加载到 pandas DataFrame中:

import pandas as pd
df = pd.read_csv(
    data_file_path, sep="\t", header=None, names=["Label", "Text"]
)
df      #1

1 在 Jupyter 笔记本中渲染数据框。或者使用 print(df)。

图 6.5 显示了垃圾邮件数据集的结果数据框。

figure

图 6.5 在 pandas DataFrame中预览SMSSpamCollection数据集,显示类别标签(“ham”或“spam”)和相应的文本消息。该数据集包含 5,572 行(文本消息和标签)。

让我们检查类别标签分布:

print(df["Label"].value_counts())

执行前面的代码,我们发现数据中“ham”(即非垃圾邮件)的出现频率远高于“spam”:

Label
ham     4825
spam     747
Name: count, dtype: int64

为了简单起见,并且因为我们更喜欢小数据集(这将有助于更快地微调 LLM),我们选择对数据集进行下采样,包括每个类别 747 个实例。

注意:处理类别不平衡的方法还有几种,但这些超出了本书的范围。对探索处理不平衡数据方法感兴趣的读者可以在附录 B 中找到更多信息。

我们可以使用以下列表中的代码进行下采样并创建一个平衡数据集。

列表 6.2 创建平衡数据集
def create_balanced_dataset(df):
    num_spam = df[df["Label"] == "spam"].shape[0]     #1
    ham_subset = df[df["Label"] == "ham"].sample(
        num_spam, random_state=123
    )                                         #2
    balanced_df = pd.concat([
        ham_subset, df[df["Label"] == "spam"]
    ])                               #3
    return balanced_df

balanced_df = create_balanced_dataset(df)
print(balanced_df["Label"].value_counts())

1 计算“spam”实例的数量

2 随机采样“ham”实例以匹配“spam”实例的数量

3 将“ham”子集与“spam”合并

执行之前的代码以平衡数据集后,我们可以看到现在我们有相等数量的垃圾邮件和非垃圾邮件消息:

Label
ham     747
spam    747
Name: count, dtype: int64

接下来,我们将“string”类标签"ham""spam"分别转换为整数类标签 0 和 1:

balanced_df["Label"] = balanced_df["Label"].map({"ham": 0, "spam": 1})

此过程类似于将文本转换为标记 ID。然而,我们处理的是仅包含两个标记 ID:0 和 1,而不是使用包含超过 50,000 个单词的 GPT 词汇表。

接下来,我们创建一个random_split函数,将数据集分为三部分:70%用于训练,10%用于验证,20%用于测试。(这些比例在机器学习中很常见,用于训练、调整和评估模型。)

列表 6.3 分割数据集
def random_split(df, train_frac, validation_frac):

    df = df.sample(
        frac=1, random_state=123
    ).reset_index(drop=True)               #1
    train_end = int(len(df) * train_frac)          #2
    validation_end = train_end + int(len(df) * validation_frac)

 #3
    train_df = df[:train_end]
    validation_df = df[train_end:validation_end]
    test_df = df[validation_end:]

    return train_df, validation_df, test_df

train_df, validation_df, test_df = random_split(
    balanced_df, 0.7, 0.1)                     #4

1 打乱整个 DataFrame

2 计算分割索引

3 分割 DataFrame

4 测试大小隐含为 0.2,作为剩余部分。

让我们将数据集保存为 CSV(逗号分隔值)文件,这样我们以后可以重用它:

train_df.to_csv("train.csv", index=None)
validation_df.to_csv("validation.csv", index=None)
test_df.to_csv("test.csv", index=None)

到目前为止,我们已经下载了数据集,平衡了它,并将其分为训练和评估子集。现在我们将设置用于训练模型的 PyTorch 数据加载器。

6.3 创建数据加载器

我们将开发与我们在处理文本数据时实现的 PyTorch 数据加载器概念上相似的数据加载器。以前,我们利用滑动窗口技术生成均匀大小的文本块,然后将它们分组到批次中,以更有效地进行模型训练。每个块作为一个单独的训练实例。然而,我们现在正在处理包含不同长度文本消息的垃圾邮件数据集。为了像处理文本块那样批量处理这些消息,我们有两种主要选项:

  • 将所有消息截断到数据集或批次中最短消息的长度。

  • 将所有消息填充到数据集或批次中最长消息的长度。

第一种方法在计算上更便宜,但如果较短的消息比平均或最长的消息小得多,可能会造成显著的信息损失,从而降低模型性能。因此,我们选择第二种选项,它保留了所有消息的全部内容。

为了实现批处理,将所有消息填充到数据集中最长消息的长度,我们向所有较短的消息添加填充标记。为此,我们使用 "<|endoftext|>" 作为填充标记。

然而,我们不必直接将字符串 "<|endoftext|>" 添加到每个文本消息中,我们可以将对应于 "<|endoftext|>" 的标记 ID 添加到编码后的文本消息中,如图 6.6 所示。50256 是填充标记 "<|endoftext|>" 的标记 ID。我们可以通过使用之前使用的 tiktoken 包中的 GPT-2 分词器"<|endoftext|>" 进行编码来双重检查标记 ID 是否正确:

import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
print(tokenizer.encode("<|endoftext|>", allowed_special={"<|endoftext|>"}))

figure

图 6.6 输入文本准备过程。首先,每个输入文本消息被转换成一个标记 ID 序列。然后,为了确保序列长度一致,较短的序列使用填充标记(在这种情况下,标记 ID 50256)填充到最长序列的长度。

事实上,执行前面的代码返回 [50256]

我们首先需要实现一个 PyTorch Dataset,它指定了在实例化数据加载器之前数据的加载和处理方式。为此,我们定义了 SpamDataset 类,该类实现了图 6.6 中的概念。这个 SpamDataset 类处理几个关键任务:它将文本消息编码成标记序列,识别训练数据集中的最长序列,并确保所有其他序列都使用 填充标记 填充到最长序列的长度。

列表 6.4 设置 Pytorch Dataset
import torch
from torch.utils.data import Dataset

class SpamDataset(Dataset):
    def __init__(self, csv_file, tokenizer, max_length=None,
                 pad_token_id=50256):
        self.data = pd.read_csv(csv_file)
 #1
        self.encoded_texts = [
            tokenizer.encode(text) for text in self.data["Text"]
        ]

        if max_length is None:
            self.max_length = self._longest_encoded_length()
        else:
            self.max_length = max_length
 #2
            self.encoded_texts = [
                encoded_text[:self.max_length]
                for encoded_text in self.encoded_texts
            ]

 #3
        self.encoded_texts = [
            encoded_text + [pad_token_id] * 
            (self.max_length - len(encoded_text))
            for encoded_text in self.encoded_texts
        ]

    def __getitem__(self, index):
        encoded = self.encoded_texts[index]
        label = self.data.iloc[index]["Label"]
        return (
            torch.tensor(encoded, dtype=torch.long),
            torch.tensor(label, dtype=torch.long)
        )

    def __len__(self):
        return len(self.data)

    def _longest_encoded_length(self):
        max_length = 0
        for encoded_text in self.encoded_texts:
            encoded_length = len(encoded_text)
            if encoded_length > max_length:
                max_length = encoded_length
        return max_length

1 预分词文本

2 如果序列长度超过 max_length 则截断序列

3 将序列填充到最长序列

SpamDataset 类从我们之前创建的 CSV 文件中加载数据,使用 tiktoken 的 GPT-2 分词器对文本进行分词,并允许我们将序列 填充截断 到由最长序列或预定义的最大长度决定的统一长度。这确保了每个输入张量的大小相同,这对于创建我们接下来实现的训练数据加载器中的批次是必要的:

train_dataset = SpamDataset(
    csv_file="train.csv",
    max_length=None,
    tokenizer=tokenizer
)

最长序列长度存储在数据集的 max_length 属性中。如果你想知道最长序列中标记的数量,可以使用以下代码:

print(train_dataset.max_length)

代码输出 120,表明最长序列中不超过 120 个标记,这是文本消息的常见长度。考虑到其上下文长度限制,模型可以处理最多 1,024 个标记的序列。如果你的数据集中包含更长的文本,你可以在创建训练数据集时传递 max_length=1024,以确保数据不超过模型支持的输入(上下文)长度。

接下来,我们将验证集和测试集填充到最长训练序列的长度。重要的是,任何超过最长训练示例长度的验证集和测试集样本都会使用我们之前定义的SpamDataset代码中的encoded_text[:self.max_length]进行截断。这种截断是可选的;如果验证集和测试集中没有超过 1,024 个标记的序列,则可以将max_length=None设置为两者:

val_dataset = SpamDataset(
    csv_file="validation.csv",
    max_length=train_dataset.max_length,
    tokenizer=tokenizer
)
test_dataset = SpamDataset(
    csv_file="test.csv",
    max_length=train_dataset.max_length,
    tokenizer=tokenizer
)
练习 6.1 增加上下文长度

将输入填充到模型支持的标记数最大值,并观察它如何影响预测性能。

使用数据集作为输入,我们可以像处理文本数据时一样实例化数据加载器。然而,在这种情况下,目标表示类别标签,而不是文本中的下一个标记。例如,如果我们选择批大小为 8,每个批次将包含八个长度为 120 的训练示例以及每个示例对应的类别标签,如图 6.7 所示。

图

图 6.7 显示了一个包含八个文本消息的训练批次,这些文本消息以标记 ID 表示。每个文本消息由 120 个标记 ID 组成。一个类别标签数组存储了与文本消息对应的八个类别标签,这些标签可以是0(“非垃圾邮件”)或1(“垃圾邮件”)。

下面的代码创建训练、验证和测试集数据加载器,这些加载器以 8 个批次的容量加载文本消息和标签。

列表 6.5 创建 PyTorch 数据加载器
from torch.utils.data import DataLoader

num_workers = 0      #1
batch_size = 8
torch.manual_seed(123)

train_loader = DataLoader(
    dataset=train_dataset,
    batch_size=batch_size,
    shuffle=True,
    num_workers=num_workers,
    drop_last=True,
)
val_loader = DataLoader(
    dataset=val_dataset,
    batch_size=batch_size,
    num_workers=num_workers,
    drop_last=False,
)
test_loader = DataLoader(
    dataset=test_dataset,
    batch_size=batch_size,
    num_workers=num_workers,
    drop_last=False,
)

1 此设置确保与大多数计算机的兼容性。

为了确保数据加载器正在正常工作,并且确实返回了预期大小的批次,我们遍历训练加载器,然后打印最后一个批次的张量维度:

for input_batch, target_batch in train_loader:
    pass
print("Input batch dimensions:", input_batch.shape)
print("Label batch dimensions", target_batch.shape)

输出是

Input batch dimensions: torch.Size([8, 120])
Label batch dimensions torch.Size([8])

如我们所见,输入批次由八个训练示例组成,每个示例有 120 个标记,正如预期的那样。标签张量存储了与八个训练示例对应的类别标签。

最后,为了了解数据集的大小,让我们打印每个数据集中的总批次数:

print(f"{len(train_loader)} training batches")
print(f"{len(val_loader)} validation batches")
print(f"{len(test_loader)} test batches")

每个数据集中的批次数是

130 training batches
19 validation batches
38 test batches

现在我们已经准备好了数据,我们需要为微调准备模型。

6.4 使用预训练权重初始化模型

我们必须为分类微调准备模型以识别垃圾邮件。我们首先初始化我们的预训练模型,如图 6.8 所示。

图

图 6.8 对 LLM 进行分类微调的三阶段过程。在完成阶段 1,准备数据集后,我们现在必须初始化 LLM,然后我们将对其进行微调以分类垃圾邮件。

要开始模型准备过程,我们使用与预训练未标记数据相同的配置:

CHOOSE_MODEL = "gpt2-small (124M)"
INPUT_PROMPT = "Every effort moves"
BASE_CONFIG = {
    "vocab_size": 50257,          #1
    "context_length": 1024,       #2
    "drop_rate": 0.0,             #3
    "qkv_bias": True              #4
}
model_configs = {
    "gpt2-small (124M)": {"emb_dim": 768, "n_layers": 12, "n_heads": 12},
    "gpt2-medium (355M)": {"emb_dim": 1024, "n_layers": 24, "n_heads": 16},
    "gpt2-large (774M)": {"emb_dim": 1280, "n_layers": 36, "n_heads": 20},
    "gpt2-xl (1558M)": {"emb_dim": 1600, "n_layers": 48, "n_heads": 25},
}
BASE_CONFIG.update(model_configs[CHOOSE_MODEL])

1 词汇量大小

2 上下文长度

3 Dropout 率

4 查询-键-值偏差

接下来,我们从gpt_download.py文件中导入download_and_load_gpt2函数,并重用预训练中的GPTModel类和load_weights_into_gpt函数(参见第五章)将下载的权重加载到 GPT 模型中。

列表 6.6 加载预训练的 GPT 模型
from gpt_download import download_and_load_gpt2
from chapter05 import GPTModel, load_weights_into_gpt

model_size = CHOOSE_MODEL.split(" ")[-1].lstrip("(").rstrip(")")
settings, params = download_and_load_gpt2(
    model_size=model_size, models_dir="gpt2"
)

model = GPTModel(BASE_CONFIG)
load_weights_into_gpt(model, params)
model.eval()

在将模型权重加载到GPTModel之后,我们重用第四章和第五章中的文本生成实用函数,以确保模型生成连贯的文本:

from chapter04 import generate_text_simple
from chapter05 import text_to_token_ids, token_ids_to_text

text_1 = "Every effort moves you"
token_ids = generate_text_simple(
    model=model,
    idx=text_to_token_ids(text_1, tokenizer),
    max_new_tokens=15,
    context_size=BASE_CONFIG["context_length"]
)
print(token_ids_to_text(token_ids, tokenizer))

以下输出显示模型生成了连贯的文本,这表明模型权重已正确加载:

Every effort moves you forward.
The first step is to understand the importance of your work

在我们开始将模型作为垃圾邮件分类器进行微调之前,让我们看看模型是否已经通过提示指令来对垃圾邮件消息进行分类:

text_2 = (
    "Is the following text 'spam'? Answer with 'yes' or 'no':"
    " 'You are a winner you have been specially"
    " selected to receive $1000 cash or a $2000 award.'"
)
token_ids = generate_text_simple(
    model=model,
    idx=text_to_token_ids(text_2, tokenizer),
    max_new_tokens=23,
    context_size=BASE_CONFIG["context_length"]
)
print(token_ids_to_text(token_ids, tokenizer))

模型输出是

Is the following text 'spam'? Answer with 'yes' or 'no': 'You are a winner
you have been specially selected to receive $1000 cash 
or a $2000 award.'
The following text 'spam'? Answer with 'yes' or 'no': 'You are a winner

根据输出,很明显模型在遵循指令方面有困难。这个结果是预期的,因为它只经过了预训练,缺乏指令微调。因此,让我们为分类微调准备模型。

6.5 添加分类头

我们必须修改预训练的 LLM,以便为分类微调做准备。为此,我们用一个小型的输出层替换了原始的输出层,该输出层将隐藏表示映射到 50,257 个词汇,新的输出层映射到两个类别:0(“非垃圾邮件”)和1(“垃圾邮件”),如图 6.9 所示。我们使用与之前相同的模型,只是替换了输出层。

figure

图 6.9 通过改变其架构来适应 GPT 模型进行垃圾邮件分类。最初,模型的线性输出层将 768 个隐藏单元映射到 50,257 个标记的词汇表。为了检测垃圾邮件,我们用一个新的输出层替换了这个层,该层将相同的 768 个隐藏单元映射到仅两个类别,代表“垃圾邮件”和“非垃圾邮件”。
输出层节点

从技术上讲,我们可以使用单个输出节点,因为我们处理的是一个二元分类任务。然而,这将需要修改损失函数,正如我在“损失函数学习——在 PyTorch 中优化负对数似然和交叉熵”中讨论的那样。因此,我们选择了一个更通用的方法,其中输出节点的数量与类别的数量相匹配。例如,对于将新闻文章分类为“技术”、“体育”或“政治”的三个类别问题,我们将使用三个输出节点,依此类推。

在尝试图 6.9 中显示的修改之前,让我们通过print(model)打印出模型架构:

GPTModel(
  (tok_emb): Embedding(50257, 768)
  (pos_emb): Embedding(1024, 768)
  (drop_emb): Dropout(p=0.0, inplace=False)
  (trf_blocks): Sequential(
...
    (11): TransformerBlock(
      (att): MultiHeadAttention(
        (W_query): Linear(in_features=768, out_features=768, bias=True)
        (W_key): Linear(in_features=768, out_features=768, bias=True)
        (W_value): Linear(in_features=768, out_features=768, bias=True)
        (out_proj): Linear(in_features=768, out_features=768, bias=True)
        (dropout): Dropout(p=0.0, inplace=False)
      )
      (ff): FeedForward(
        (layers): Sequential(
          (0): Linear(in_features=768, out_features=3072, bias=True)
          (1): GELU()
          (2): Linear(in_features=3072, out_features=768, bias=True)
        )
      )
      (norm1): LayerNorm()
      (norm2): LayerNorm()
      (drop_resid): Dropout(p=0.0, inplace=False)
    )
  )
  (final_norm): LayerNorm()
  (out_head): Linear(in_features=768, out_features=50257, bias=False)
)

这个输出清晰地展示了我们在第四章中提出的架构。正如之前讨论的那样,GPTModel由嵌入层组成,随后是 12 个相同的transformer blocks(为了简洁起见,只显示了最后一个块),然后是一个最终的LayerNorm和输出层out_head

接下来,我们将out_head替换为一个新的输出层(见图 6.9),我们将对其进行微调。

微调选定层与所有层

由于我们从一个预训练模型开始,没有必要微调所有模型层。在基于神经网络的自然语言模型中,底层通常捕捉适用于广泛任务和数据集的基本语言结构和语义。因此,仅微调最后几层(即靠近输出的层),这些层更具体于细微的语言模式和特定任务的特性,通常足以使模型适应新任务。一个很好的副作用是,仅微调少量层在计算上更有效率。感兴趣的读者可以在附录 B 中找到更多关于应该微调哪些层的信息,包括实验。

为了让模型准备好进行分类微调,我们首先将模型冻结,这意味着我们使所有层不可训练:

for param in model.parameters():
    param.requires_grad = False

然后,我们替换输出层(model.out_head),它最初将层输入映射到 50,257 维,即词汇表的大小(见图 6.9)。

列表 6.7 添加分类层
torch.manual_seed(123)
num_classes = 2
model.out_head = torch.nn.Linear(
    in_features=BASE_CONFIG["emb_dim"], 
    out_features=num_classes
)

为了使代码更通用,我们使用BASE_CONFIG["emb_dim"],在"gpt2-small (124M)"模型中等于 768。因此,我们也可以使用相同的代码来处理更大的 GPT-2 模型变体。

这个新的model.out_head输出层默认将其requires_grad属性设置为True,这意味着它是模型中唯一将在训练期间更新的层。技术上,仅训练我们刚刚添加的输出层就足够了。然而,正如我在实验中发现的那样,微调额外的层可以显著提高模型的预测性能。(更多细节请参阅附录 B。)我们还配置了最后一个转换器块和最终的LayerNorm模块,该模块将此块连接到输出层,如图 6.10 所示。

figure

图 6.10 GPT 模型包含 12 个重复的转换器块。与输出层一起,我们将最终的LayerNorm和最后一个转换器块设置为可训练。其余的 11 个转换器块和嵌入层保持不可训练。

为了使最终的LayerNorm和最后一个转换器块可训练,我们将它们的requires_grad分别设置为True

for param in model.trf_blocks[-1].parameters():
    param.requires_grad = True
for param in model.final_norm.parameters():
    param.requires_grad = True
练习 6.2 微调整个模型

而不是仅微调最后的转换器块,微调整个模型并评估其对预测性能的影响。

尽管我们添加了一个新的输出层并将某些层标记为可训练或不可训练,我们仍然可以像以前一样使用这个模型。例如,我们可以给它一个与之前使用的示例文本相同的示例文本:

inputs = tokenizer.encode("Do you have time")
inputs = torch.tensor(inputs).unsqueeze(0)
print("Inputs:", inputs)
print("Inputs dimensions:", inputs.shape)    #1

1 形状:(batch_size, num_tokens)

打印输出显示,前面的代码将输入编码成一个包含四个输入标记的张量:

Inputs: tensor([[5211,  345,  423,  640]])
Inputs dimensions: torch.Size([1, 4])

然后,我们可以像往常一样将编码后的标记 ID 传递给模型:

with torch.no_grad():
    outputs = model(inputs)
print("Outputs:\n", outputs)
print("Outputs dimensions:", outputs.shape)

输出张量看起来如下:

Outputs:
 tensor([[[-1.5854,  0.9904],
          [-3.7235,  7.4548],
          [-2.2661,  6.6049],
          [-3.5983,  3.9902]]])
Outputs dimensions: torch.Size([1, 4, 2])

相似的输入之前会产生一个 [1, 4, 50257] 的输出张量,其中 50257 代表词汇量。输出行的数量对应于输入标记的数量(在这种情况下,四个)。然而,每个输出的嵌入维度(列数)现在为 2,而不是 50,257,因为我们替换了模型的输出层。

记住,我们感兴趣的是微调这个模型以返回一个类别标签,指示模型输入是“垃圾邮件”还是“非垃圾邮件”。我们不需要微调所有四行输出;相反,我们可以专注于单个输出标记。特别是,我们将专注于最后一行,对应于最后一个输出标记,如图 6.11 所示。

figure

图 6.11 GPT 模型,具有四个标记示例输入和输出。由于修改后的输出层,输出张量由两列组成。在微调模型进行垃圾邮件分类时,我们只对最后一个行对应最后一个标记感兴趣。

要从输出张量中提取最后一个输出标记,我们使用以下代码:

print("Last output token:", outputs[:, -1, :])

这会打印

Last output token: tensor([[-3.5983,  3.9902]])

我们仍然需要将值转换为类别标签预测。但首先,让我们了解为什么我们特别关注最后一个输出标记。

我们已经探讨了注意力机制,它建立了每个输入标记与每个其他输入标记之间的关系,以及因果注意力掩码的概念,这在 GPT-like 模型中常用(见第三章)。这个掩码限制了标记的关注点为其当前位置及其之前的那些位置,确保每个标记只能受到自身和前面标记的影响,如图 6.12 所示。

figure

图 6.12 因果注意力机制,其中输入标记之间的注意力得分以矩阵格式显示。空单元格表示由于因果注意力掩码而屏蔽的位置,防止标记关注未来的标记。单元格中的值代表注意力得分;最后一个标记time是唯一一个为所有先前标记计算注意力得分的标记。

在图 6.12 中设置的因果注意力掩码下,序列中的最后一个标记积累了最多的信息,因为它是有权访问所有先前标记数据的唯一标记。因此,在我们的垃圾邮件分类任务中,我们在微调过程中关注这个最后一个标记。

我们现在准备好将最后一个标记转换为类别标签预测,并计算模型的初始预测准确率。随后,我们将微调模型以进行垃圾邮件分类任务。

练习 6.3 调整第一个与最后一个标记

尝试微调第一个输出标记。注意与微调最后一个输出标记相比,预测性能的变化。

6.6 计算分类损失和准确率

在我们微调模型之前,只剩下一个小的任务:我们必须实现微调期间使用的模型评估函数,如图 6.13 所示。

图

图 6.13 对 LLM 进行分类微调的三个阶段过程。我们已经完成了前六个步骤。我们现在准备进行第二阶段最后一步:实现评估模型在微调前后以及分类垃圾邮件性能的函数。

在实现评估工具之前,让我们简要讨论一下我们如何将模型输出转换为类别标签预测。我们之前通过将 50,257 个输出转换为通过 softmax 函数的概率,然后通过 argmax 函数返回最高概率的位置来计算 LLM 生成的下一个标记的标记 ID。我们在这里采取相同的方法来计算模型对于给定输入输出“垃圾邮件”或“非垃圾邮件”预测,如图 6.14 所示。唯一的区别是我们处理的是二维输出而不是 50,257 维输出。

图

图 6.14 对应于最后一个标记的模型输出被转换为每个输入文本的概率分数。通过查找最高概率分数的索引位置来获得类别标签。由于模型尚未经过训练,模型错误地预测了垃圾邮件标签。

让我们通过一个具体的例子来考虑最后一个标记的输出:

print("Last output token:", outputs[:, -1, :])

对应于最后一个标记的张量值是

Last output token: tensor([[-3.5983,  3.9902]])

我们可以获取类别标签:

probas = torch.softmax(outputs[:, -1, :], dim=-1)
label = torch.argmax(probas)
print("Class label:", label.item())

在这种情况下,代码返回 1,意味着模型预测输入文本是“垃圾邮件。”在这里使用 softmax 函数是可选的,因为最大的输出直接对应于最高的概率分数。因此,我们可以简化代码而不使用 softmax:

logits = outputs[:, -1, :]
label = torch.argmax(logits)
print("Class label:", label.item())

这个概念可以用来计算分类准确率,它衡量的是数据集中正确预测的百分比。

为了确定分类准确率,我们将基于 argmax 的预测代码应用于数据集中的所有示例,并通过定义一个 calc_accuracy_loader 函数来计算正确预测的比例。

列表 6.8 计算分类准确率
def calc_accuracy_loader(data_loader, model, device, num_batches=None):
    model.eval()
    correct_predictions, num_examples = 0, 0

    if num_batches is None:
        num_batches = len(data_loader)
    else:
        num_batches = min(num_batches, len(data_loader))
    for i, (input_batch, target_batch) in enumerate(data_loader):
        if i < num_batches:
            input_batch = input_batch.to(device)
            target_batch = target_batch.to(device)

            with torch.no_grad():
                logits = model(input_batch)[:, -1, :]     #1
            predicted_labels = torch.argmax(logits, dim=-1)

            num_examples += predicted_labels.shape[0]
            correct_predictions += (
                (predicted_labels == target_batch).sum().item()
            )

        else:
            break
    return correct_predictions / num_examples

1 最后输出标记的对数

让我们使用该函数来确定跨各种数据集的分类准确率,这些数据集是从 10 批次中估计出来的,以提高效率:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)

torch.manual_seed(123)
train_accuracy = calc_accuracy_loader(
    train_loader, model, device, num_batches=10
)
val_accuracy = calc_accuracy_loader(
    val_loader, model, device, num_batches=10
)
test_accuracy = calc_accuracy_loader(
    test_loader, model, device, num_batches=10
)

print(f"Training accuracy: {train_accuracy*100:.2f}%")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
print(f"Test accuracy: {test_accuracy*100:.2f}%")

通过 device 设置,如果可用带有 Nvidia CUDA 支持的 GPU,则模型自动在 GPU 上运行,否则在 CPU 上运行。输出是

Training accuracy: 46.25%
Validation accuracy: 45.00%
Test accuracy: 48.75%

如我们所见,预测准确率接近随机预测,在这种情况下为 50%。为了提高预测准确率,我们需要微调模型。

然而,在我们开始微调模型之前,我们必须定义我们将在训练中优化的损失函数。我们的目标是最大化模型的垃圾邮件分类准确性,这意味着前面的代码应该输出正确的类别标签:0 表示非垃圾邮件,1 表示垃圾邮件。

由于分类准确性不是一个可微分的函数,我们使用交叉熵损失作为最大化准确性的代理。因此,calc_loss_batch 函数保持不变,但有一个调整:我们只优化最后一个标记,model(input_batch)[:, -1, :],而不是所有标记,model(input_batch)

def calc_loss_batch(input_batch, target_batch, model, device):
    input_batch = input_batch.to(device)
    target_batch = target_batch.to(device)
    logits = model(input_batch)[:, -1, :]     #1
    loss = torch.nn.functional.cross_entropy(logits, target_batch)
    return loss

1 最后输出标记的 logits

我们使用 calc_loss_batch 函数计算从先前定义的数据加载器中获得的单个批次的损失。为了计算数据加载器中所有批次的损失,我们像以前一样定义 calc_loss_loader 函数。

列表 6.9 计算分类损失
def calc_loss_loader(data_loader, model, device, num_batches=None):
    total_loss = 0.
    if len(data_loader) == 0:
        return float("nan")
    elif num_batches is None:
        num_batches = len(data_loader)
    else:                                        #1
        num_batches = min(num_batches, len(data_loader))
    for i, (input_batch, target_batch) in enumerate(data_loader):
        if i < num_batches:
            loss = calc_loss_batch(
                input_batch, target_batch, model, device
            )
            total_loss += loss.item()
        else:
            break
    return total_loss / num_batches

1 确保批次数不超过数据加载器中的批次数

与计算训练准确性类似,我们现在计算每个数据集的初始损失:

with torch.no_grad():                 #1
    train_loss = calc_loss_loader(
        train_loader, model, device, num_batches=5
    )
    val_loss = calc_loss_loader(val_loader, model, device, num_batches=5)
    test_loss = calc_loss_loader(test_loader, model, device, num_batches=5)
print(f"Training loss: {train_loss:.3f}")
print(f"Validation loss: {val_loss:.3f}")
print(f"Test loss: {test_loss:.3f}")

1 为了效率,禁用梯度跟踪,因为我们还没有开始训练

初始损失值是

Training loss: 2.453
Validation loss: 2.583
Test loss: 2.322

接下来,我们将实现一个训练函数来微调模型,这意味着调整模型以最小化训练集损失。最小化训练集损失将有助于提高分类准确性,这是我们整体目标。

6.7 在监督数据上微调模型

我们必须定义并使用训练函数来微调预训练的 LLM 并提高其垃圾邮件分类的准确性。如图 6.15 所示的训练循环,与我们用于预训练的整体训练循环相同;唯一的区别是我们计算分类准确性而不是生成样本文本来评估模型。

figure

图 6.15 PyTorch 中训练深度神经网络的典型训练循环包括几个步骤,迭代训练集中的批次几个时期。在每个循环中,我们计算每个训练集批次的损失以确定损失梯度,我们使用这些梯度来更新模型权重以最小化训练集损失。

实现图 6.15 中所示概念的训练函数也与用于预训练模型的 train_model_simple 函数非常相似。唯一的两个区别是,我们现在跟踪看到的训练示例数量(examples_seen)而不是标记数量,并在每个时期后计算准确性而不是打印样本文本。

列表 6.10 微调模型以分类垃圾邮件
def train_classifier_simple(
        model, train_loader, val_loader, optimizer, device,
        num_epochs, eval_freq, eval_iter):
    train_losses, val_losses, train_accs, val_accs = [], [], [], []   #1
    examples_seen, global_step = 0, -1

    for epoch in range(num_epochs):    #2
        model.train()             #3

        for input_batch, target_batch in train_loader:
            optimizer.zero_grad()                      #4
            loss = calc_loss_batch(
                input_batch, target_batch, model, device
            )
            loss.backward()                          #5
            optimizer.step()                          #6
            examples_seen += input_batch.shape[0]    #7
            global_step += 1

 #8
            if global_step % eval_freq == 0:
                train_loss, val_loss = evaluate_model(
                    model, train_loader, val_loader, device, eval_iter)
                train_losses.append(train_loss)
                val_losses.append(val_loss)
                print(f"Ep {epoch+1} (Step {global_step:06d}): "
                      f"Train loss {train_loss:.3f}, "
                      f"Val loss {val_loss:.3f}"
                )

 #9
        train_accuracy = calc_accuracy_loader(
            train_loader, model, device, num_batches=eval_iter
        )
        val_accuracy = calc_accuracy_loader(
            val_loader, model, device, num_batches=eval_iter
        )

        print(f"Training accuracy: {train_accuracy*100:.2f}% | ", end="")
        print(f"Validation accuracy: {val_accuracy*100:.2f}%")
        train_accs.append(train_accuracy)
        val_accs.append(val_accuracy)

    return train_losses, val_losses, train_accs, val_accs, examples_seen

1 初始化跟踪损失和已看到示例的列表

2 主要训练循环

3 将模型设置为训练模式

4 重置上一批迭代中的损失梯度

5 计算损失梯度

6 使用损失梯度更新模型权重

7 新增:跟踪示例而不是标记

8 可选评估步骤

9 计算每个轮数后的准确率

evaluate_model函数与我们用于预训练的函数相同:

def evaluate_model(model, train_loader, val_loader, device, eval_iter):
    model.eval()
    with torch.no_grad():
        train_loss = calc_loss_loader(
            train_loader, model, device, num_batches=eval_iter
        )
        val_loss = calc_loss_loader(
            val_loader, model, device, num_batches=eval_iter
        )
    model.train()
    return train_loss, val_loss

接下来,我们初始化优化器,设置训练轮数,并使用train_classifier_simple函数开始训练。在 M3 MacBook Air 笔记本电脑上训练大约需要 6 分钟,在 V100 或 A100 GPU 上不到半分钟:

import time

start_time = time.time()
torch.manual_seed(123)
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5, weight_decay=0.1)
num_epochs = 5

train_losses, val_losses, train_accs, val_accs, examples_seen = \
    train_classifier_simple(
        model, train_loader, val_loader, optimizer, device,
        num_epochs=num_epochs, eval_freq=50,
        eval_iter=5
    )

end_time = time.time()
execution_time_minutes = (end_time - start_time) / 60
print(f"Training completed in {execution_time_minutes:.2f} minutes.")

训练过程中我们看到的输出如下:

Ep 1 (Step 000000): Train loss 2.153, Val loss 2.392
Ep 1 (Step 000050): Train loss 0.617, Val loss 0.637
Ep 1 (Step 000100): Train loss 0.523, Val loss 0.557
Training accuracy: 70.00% | Validation accuracy: 72.50%
Ep 2 (Step 000150): Train loss 0.561, Val loss 0.489
Ep 2 (Step 000200): Train loss 0.419, Val loss 0.397
Ep 2 (Step 000250): Train loss 0.409, Val loss 0.353
Training accuracy: 82.50% | Validation accuracy: 85.00%
Ep 3 (Step 000300): Train loss 0.333, Val loss 0.320
Ep 3 (Step 000350): Train loss 0.340, Val loss 0.306
Training accuracy: 90.00% | Validation accuracy: 90.00%
Ep 4 (Step 000400): Train loss 0.136, Val loss 0.200
Ep 4 (Step 000450): Train loss 0.153, Val loss 0.132
Ep 4 (Step 000500): Train loss 0.222, Val loss 0.137
Training accuracy: 100.00% | Validation accuracy: 97.50%
Ep 5 (Step 000550): Train loss 0.207, Val loss 0.143
Ep 5 (Step 000600): Train loss 0.083, Val loss 0.074
Training accuracy: 100.00% | Validation accuracy: 97.50%
Training completed in 5.65 minutes.

我们随后使用 Matplotlib 绘制训练和验证集的损失函数。

列表 6.11 绘制分类损失
import matplotlib.pyplot as plt

def plot_values(
        epochs_seen, examples_seen, train_values, val_values,
        label="loss"):
    fig, ax1 = plt.subplots(figsize=(5, 3))

 #1
    ax1.plot(epochs_seen, train_values, label=f"Training {label}")
    ax1.plot(
        epochs_seen, val_values, linestyle="-.",
        label=f"Validation {label}"
    )
    ax1.set_xlabel("Epochs")
    ax1.set_ylabel(label.capitalize())
    ax1.legend()

 #2
    ax2 = ax1.twiny()
    ax2.plot(examples_seen, train_values, alpha=0)    #3
    ax2.set_xlabel("Examples seen")

    fig.tight_layout()             #4
    plt.savefig(f"{label}-plot.pdf")
    plt.show()

epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
examples_seen_tensor = torch.linspace(0, examples_seen, len(train_losses))

plot_values(epochs_tensor, examples_seen_tensor, train_losses, val_losses)

1 将训练和验证损失与轮数对比绘图

2 为示例创建第二个 x 轴

3 隐藏的绘图用于对齐刻度

4 调整布局以留出空间

图 6.16 绘制了结果损失曲线。

figure

图 6.16 展示了模型在五个训练轮数中的训练和验证损失。训练损失(用实线表示)和验证损失(用虚线表示)在第一个轮数急剧下降,并逐渐稳定在第五个轮数。这种模式表明良好的学习进度,并表明模型从训练数据中学习,同时很好地泛化到未见过的验证数据。

如基于图 6.16 中急剧下降的斜率所示,模型正在很好地从训练数据中学习,几乎没有过拟合的迹象;也就是说,训练集和验证集损失之间没有明显的差距。

选择训练轮数

之前,当我们开始训练时,我们将训练轮数设置为五次。训练轮数取决于数据集和任务的难度,没有通用的解决方案或建议,尽管通常五个轮数是一个良好的起点。如果在最初的几个轮数后模型出现过拟合(如损失图所示,见图 6.16),你可能需要减少轮数。相反,如果趋势线表明验证损失可以通过进一步训练而改善,你应该增加轮数。在这个具体案例中,五个轮数是一个合理的数字,因为没有出现早期过拟合的迹象,验证损失接近 0。

使用相同的plot_values函数,现在让我们绘制分类准确率:

epochs_tensor = torch.linspace(0, num_epochs, len(train_accs))
examples_seen_tensor = torch.linspace(0, examples_seen, len(train_accs))

plot_values(
    epochs_tensor, examples_seen_tensor, train_accs, val_accs,
    label="accuracy"
)

图 6.17 显示了结果准确率。模型在第四和第五个轮数后实现了相对较高的训练和验证准确率。重要的是,我们之前在使用train_classifier_simple函数时设置了eval_iter=5,这意味着我们的训练和验证性能估计仅基于五个批次以提高训练效率。

figure

图 6.17 训练准确率(实线)和验证准确率(虚线)在早期 epoch 中大幅增加,然后达到平台期,几乎完美的准确率得分为 1.0。两条线在整个 epoch 中的接近表明模型并没有过度拟合训练数据。

现在,我们必须通过运行以下代码,在整个数据集上计算训练、验证和测试集的性能指标,这次不定义eval_iter值:

train_accuracy = calc_accuracy_loader(train_loader, model, device)
val_accuracy = calc_accuracy_loader(val_loader, model, device)
test_accuracy = calc_accuracy_loader(test_loader, model, device)

print(f"Training accuracy: {train_accuracy*100:.2f}%")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
print(f"Test accuracy: {test_accuracy*100:.2f}%")

结果准确率值是

Training accuracy: 97.21%
Validation accuracy: 97.32%
Test accuracy: 95.67%

训练集和测试集的性能几乎相同。训练集和测试集准确率之间的微小差异表明训练数据过拟合最小。通常,验证集准确率略高于测试集准确率,因为模型开发通常涉及调整超参数以在验证集上表现良好,这可能不会像在测试集上那样有效地推广。这种情况很常见,但通过调整模型的设置,如增加 dropout 率(drop_rate)或优化器配置中的weight_decay参数,可以最大限度地减少差距。

6.8 使用 LLM 作为垃圾邮件分类器

在微调和评估了模型之后,我们现在准备好对垃圾邮件进行分类(见图 6.18)。让我们使用我们的基于 GPT 的微调垃圾邮件分类模型。下面的classify_review函数遵循与我们在之前实现的SpamDataset中使用的类似的数据预处理步骤。然后,在将文本处理成标记 ID 之后,该函数使用模型预测一个整数类标签,类似于我们在第 6.6 节中实现的方法,然后返回相应的类名。

图像

图 6.18 对 LLM 进行分类微调的三阶段过程。步骤 10 是第三阶段的最后一步——使用微调后的模型对新的垃圾邮件消息进行分类。
列表 6.12 使用模型对新的文本进行分类
def classify_review(
        text, model, tokenizer, device, max_length=None,
        pad_token_id=50256):
    model.eval()

    input_ids = tokenizer.encode(text)          #1
    supported_context_length = model.pos_emb.weight.shape[0]

    input_ids = input_ids[:min(              #2
        max_length, supported_context_length
    )]

    input_ids += [pad_token_id] * (max_length - len(input_ids))    #3

    input_tensor = torch.tensor(
        input_ids, device=device
    ).unsqueeze(0)              #4

    with torch.no_grad():                                #5
        logits = model(input_tensor)[:, -1, :]     #6
    predicted_label = torch.argmax(logits, dim=-1).item()

    return "spam" if predicted_label == 1 else "not spam"     #7

1 准备模型输入

2 如果序列太长则截断

3 将序列填充到最长序列

4 添加批量维度

5 模型推理不跟踪梯度

6 最后输出标记的 logits

7 返回分类结果

让我们在一个示例文本上尝试这个classify_review函数:

text_1 = (
    "You are a winner you have been specially"
    " selected to receive $1000 cash or a $2000 award."
)

print(classify_review(
    text_1, model, tokenizer, device, max_length=train_dataset.max_length
))

结果模型正确预测了“垃圾邮件”。让我们再试一个例子:

text_2 = (
    "Hey, just wanted to check if we're still on"
    " for dinner tonight? Let me know!"
)

print(classify_review(
    text_2, model, tokenizer, device, max_length=train_dataset.max_length
))

模型再次做出正确预测并返回“非垃圾邮件”标签。

最后,让我们保存模型,以防我们以后想重用模型而无需再次训练。我们可以使用torch.save方法:

torch.save(model.state_dict(), "review_classifier.pth")

保存后,可以加载模型:

model_state_dict = torch.load("review_classifier.pth, map_location=device")
model.load_state_dict(model_state_dict)

摘要

  • 对于微调 LLM,有不同策略,包括分类微调和指令微调。

  • 分类微调涉及通过一个小型分类层替换 LLM 的输出层。

  • 在将文本消息分类为“垃圾邮件”或“非垃圾邮件”的情况下,新的分类层仅包含两个输出节点。之前,我们使用输出节点的数量等于词汇表中的唯一标记数量(即,50,256)。

  • 与预训练时预测文本中的下一个标记不同,分类微调训练模型输出正确的类别标签——例如,“垃圾邮件”或“非垃圾邮件”。

  • 微调模型的输入是转换为标记 ID 的文本,类似于预训练。

  • 在微调大型语言模型(LLM)之前,我们加载预训练模型作为基础模型。

  • 评估分类模型涉及计算分类准确率(正确预测的分数或百分比)。

  • 微调分类模型使用与预训练 LLM 时相同的交叉熵损失函数。

第七章:微调以遵循指令

本章涵盖

  • LLM 的指令微调过程

  • 准备用于监督指令微调的数据集

  • 组织训练批次中的指令数据

  • 加载预训练的 LLM 并将其微调以遵循人类指令

  • 提取用于评估的 LLM 生成的指令响应

  • 评估指令微调的 LLM

之前,我们实现了 LLM 架构,进行了预训练,并将外部来源的预训练权重导入到我们的模型中。然后,我们专注于对 LLM 进行微调,以完成特定的分类任务:区分垃圾邮件和非垃圾邮件短信。现在,我们将实现微调 LLM 以遵循人类指令的过程,如图 7.1 所示。指令微调是开发用于聊天机器人应用、个人助理和其他对话任务 LLM 的主要技术之一。

figure

图 7.1 编码 LLM 的三个主要阶段。本章重点介绍第三阶段的第 9 步:微调预训练的 LLM 以遵循人类指令。

图 7.1 显示了微调 LLM 的两种主要方式:用于分类的微调(步骤 8)和微调 LLM 以遵循指令(步骤 9)。我们在第六章中实现了步骤 8。现在,我们将使用指令数据集来微调 LLM。

7.1 指令微调简介

我们现在知道,预训练 LLM 涉及一个训练过程,其中它学会一次生成一个单词。结果得到的预训练 LLM 能够进行文本补全,这意味着它可以根据输入的片段完成句子或撰写文本段落。然而,预训练的 LLM 在特定指令上往往很吃力,例如“修复这段文字的语法”或“将这段文字转换为被动语态”。稍后,我们将考察一个具体示例,其中我们将预训练的 LLM 作为指令微调(也称为监督指令微调)的基础。

在这里,我们专注于提高 LLM 遵循此类指令并生成期望响应的能力,如图 7.2 所示。准备数据集是指令微调的关键方面。然后,我们将完成指令微调过程的三个阶段的所有步骤,从数据集准备开始,如图 7.3 所示。

figure

图 7.2 LLM 处理以生成期望响应的指令示例

figure

图 7.3 指令微调 LLM 的三阶段过程。第一阶段涉及数据集准备,第二阶段侧重于模型设置和微调,第三阶段涵盖模型的评估。我们将从第一阶段的第一步开始:下载和格式化数据集。

7.2 准备用于监督指令微调的数据集

让我们下载并格式化指令数据集,以便对预训练的 LLM 进行指令微调。该数据集包含 1,100 个指令-响应对,类似于图 7.2 中的那些。这个数据集是专门为这本书创建的,但感兴趣的读者可以在附录 B 中找到其他可公开获取的指令数据集。

以下代码实现并执行了一个函数来下载这个数据集,它是一个相对较小的文件(只有 204 KB),格式为 JSON。JSON,或 JavaScript 对象表示法,与 Python 字典的结构相似,提供了一个简单的数据交换结构,既适合人类阅读,又适合机器处理。

列表 7.1 下载数据集
import json
import os
import urllib

def download_and_load_file(file_path, url):
    if not os.path.exists(file_path):
        with urllib.request.urlopen(url) as response:
            text_data = response.read().decode("utf-8")
        with open(file_path, "w", encoding="utf-8") as file:
            file.write(text_data)

    with open(file_path, "r") as file:
        data = json.load(file)
    return data

file_path = "instruction-data.json"
url = (
    "https://raw.githubusercontent.com/rasbt/LLMs-from-scratch"
    "/main/ch07/01_main-chapter-code/instruction-data.json"
)

data = download_and_load_file(file_path, url)
print("Number of entries:", len(data))

执行前述代码的输出是

Number of entries: 1100

我们从 JSON 文件中加载的data列表包含指令数据集的 1,100 个条目。让我们打印其中一个条目,看看每个条目的结构:

print("Example entry:\n", data[50])

示例条目的内容是

Example entry:
 {'instruction': 'Identify the correct spelling of the following word.',
  'input': 'Ocassion', 'output': "The correct spelling is 'Occasion.'"}

如我们所见,示例条目是包含'instruction''input''output'的 Python 字典对象。让我们看看另一个示例:

print("Another example entry:\n", data[999])

根据此条目的内容,'input'字段有时可能为空:

Another example entry:
 {'instruction': "What is an antonym of 'complicated'?", 
  'input': '',
  'output': "An antonym of 'complicated' is 'simple'."}

指令微调涉及在数据集上训练模型,其中输入-输出对,如我们从 JSON 文件中提取的,是明确提供的。有各种方法来格式化这些条目以供 LLM 使用。图 7.4 说明了两种不同的示例格式,通常被称为提示风格,用于训练像阿尔帕卡和 Phi-3 这样的知名 LLM。

figure

图 7.4 比较 LLM 中指令微调的提示风格。阿尔帕卡风格(左)使用具有定义明确的指令、输入和响应部分的格式,而 Phi-3 风格(右)使用具有指定<|user|><|assistant|>标记的更简单的格式。

阿尔帕卡(Alpaca)是早期公开详细说明其指令微调过程的 LLM 之一。微软开发的 Phi-3 被包括在内,以展示提示风格的多样性。本章的其余部分使用阿尔帕卡提示风格,因为它是最受欢迎的之一,很大程度上是因为它帮助定义了微调的原始方法。

练习 7.1 更改提示风格

在使用阿尔帕卡提示风格微调模型后,尝试图 7.4 中显示的 Phi-3 提示风格,并观察它是否会影响模型的响应质量。

让我们定义一个format_input函数,我们可以用它将data列表中的条目转换为阿尔帕卡风格的输入格式。

列表 7.2 实现提示格式化函数
def format_input(entry):
    instruction_text = (
        f"Below is an instruction that describes a task. "
        f"Write a response that appropriately completes the request."
        f"\n\n### Instruction:\n{entry['instruction']}"
    )

    input_text = (
        f"\n\n### Input:\n{entry['input']}" if entry["input"] else ""
    )
    return instruction_text + input_text

这个format_input函数接受一个字典entry作为输入,并构建一个格式化的字符串。让我们测试它对数据集条目data[50],这是我们之前看过的:

model_input = format_input(data[50])
desired_response = f"\n\n### Response:\n{data[50]['output']}"
print(model_input + desired_response)

格式化后的输入看起来如下所示:

Below is an instruction that describes a task. Write a response that 
appropriately completes the request.

### Instruction:
Identify the correct spelling of the following word.

### Input:
Ocassion

### Response:
The correct spelling is 'Occasion.'

注意,format_input会跳过可选的### Input:部分,如果'input'字段为空,我们可以通过将format_input函数应用于我们之前检查的条目data[999]来测试这一点:

model_input = format_input(data[999])
desired_response = f"\n\n### Response:\n{data[999]['output']}"
print(model_input + desired_response)

输出显示,具有空'input'字段的条目在格式化输入中不包含### Input:部分:

Below is an instruction that describes a task. Write a response that 
appropriately completes the request.

### Instruction:
What is an antonym of 'complicated'?

### Response:
An antonym of 'complicated' is 'simple'.

在我们进入下一节设置 PyTorch 数据加载器之前,让我们将数据集划分为训练集、验证集和测试集,类似于我们在上一章中对垃圾邮件分类数据集所做的那样。以下列表显示了如何计算这些部分。

列表 7.3 分区数据集
train_portion = int(len(data) * 0.85)    #1
test_portion = int(len(data) * 0.1)            #2
val_portion = len(data) - train_portion - test_portion    #3

train_data = data[:train_portion]
test_data = data[train_portion:train_portion + test_portion]
val_data = data[train_portion + test_portion:]

print("Training set length:", len(train_data))
print("Validation set length:", len(val_data))
print("Test set length:", len(test_data))

1 使用 85%的数据进行训练

2 使用 10%进行测试

3 使用剩余的 5%进行验证

这种分区结果导致以下数据集大小:

Training set length: 935
Validation set length: 55
Test set length: 110

在成功下载和分区数据集,并对数据集提示格式有清晰理解之后,我们现在可以准备指令微调过程的核心实现。接下来,我们专注于开发用于微调 LLM 的训练批次构建方法。

7.3 将数据组织到训练批次中

在我们进入指令微调过程的实现阶段时,下一步,如图 7.5 所示,专注于有效地构建训练批次。这涉及到定义一个方法,确保我们的模型在微调过程中接收格式化的训练数据。

figure

图 7.5 指令微调 LLM 的三阶段过程。接下来,我们看看第一阶段步骤 2:组装训练批次。

在上一章中,训练批次是由 PyTorch 的DataLoader类自动创建的,该类使用默认的合并函数将样本列表组合成批次。合并函数负责将单个数据样本的列表合并成一个可以由模型在训练过程中高效处理的单一批次。

然而,指令微调的批处理过程稍微复杂一些,需要我们创建自己的自定义合并函数,稍后将其插入到DataLoader中。我们实现这个自定义合并函数来处理我们指令微调数据集的特定要求和格式。

让我们分几个步骤来处理批处理过程,包括编写自定义合并函数,如图 7.6 所示。首先,为了实现步骤 2.1 和 2.2,我们编写了一个InstructionDataset类,该类应用format_input预处理数据集中的所有输入,类似于第六章中的SpamDataset。这个两步过程,如图 7.7 所示,是在InstructionDataset__init__构造方法中实现的。

figure

图 7.6 实现批处理过程的五个子步骤:(2.1)应用提示模板,(2.2)使用前几章中的标记化,(2.3)添加填充标记,(2.4)创建目标标记 ID,以及(2.5)在损失函数中将 -100 占位符标记替换为掩码填充标记。

figure

图 7.7 实现批处理过程涉及的前两个步骤。首先使用特定的提示模板(2.1)格式化条目,然后进行标记化(2.2),从而生成模型可以处理的标记 ID 序列。
列表 7.4 实现指令数据集类
import torch
from torch.utils.data import Dataset

class InstructionDataset(Dataset):
    def __init__(self, data, tokenizer):
        self.data = data
        self.encoded_texts = []
        for entry in data:         #1
            instruction_plus_input = format_input(entry)
            response_text = f"\n\n### Response:\n{entry['output']}"
            full_text = instruction_plus_input + response_text
            self.encoded_texts.append(
                tokenizer.encode(full_text)
            )

    def __getitem__(self, index):
        return self.encoded_texts[index]

    def __len__(self):
        return len(self.data)

1 预标记文本

与用于分类微调的方法类似,我们希望通过在批次中收集多个训练示例来加速训练,这需要将所有输入填充到相似长度。与分类微调一样,我们使用 <|endoftext|> 标记作为填充标记。

我们可以在将 <|endoftext|> 标记附加到文本输入之前,直接将对应于 <|endoftext|> 的标记 ID 附加到预标记的输入中。我们可以使用标记器的 .encode 方法在 <|endoftext|> 标记上,以提醒我们应使用哪个标记 ID:

import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
print(tokenizer.encode("<|endoftext|>", allowed_special={"<|endoftext|>"}))

生成的标记 ID 是 50256

接下来进行步骤 2.3(见图 7.6),我们采用了一种更复杂的方法,通过开发一个自定义的 collate 函数,我们可以将其传递给数据加载器。这个自定义的 collate 函数将每个批次中的训练示例填充到相同的长度,同时允许不同的批次有不同的长度,如图 7.8 所示。这种方法通过仅将序列扩展到匹配每个批次中最长的序列,而不是整个数据集,从而最小化了不必要的填充。

figure

图 7.8 使用标记 ID 50256 在批次中对训练示例进行填充,以确保每个批次内的长度一致。每个批次可能有不同的长度,如第一和第二个所示。

我们可以使用自定义的 collate 函数来实现填充过程:

def custom_collate_draft_1(
    batch,
    pad_token_id=50256,
    device="cpu"
):
    batch_max_length = max(len(item)+1 for item in batch)   #1
    inputs_lst = []

    for item in batch:     #2
        new_item = item.copy()
        new_item += [pad_token_id]

        padded = (
            new_item + [pad_token_id] * 
            (batch_max_length - len(new_item))
        )
        inputs = torch.tensor(padded[:-1])    #3
        inputs_lst.append(inputs)

    inputs_tensor = torch.stack(inputs_lst).to(device)     #4
    return inputs_tensor

1 在批次中找到最长的序列

2 填充并准备输入

3 移除之前添加的额外填充标记

4 将输入列表转换为张量并将其传输到目标设备

我们实现的 custom_collate_draft_1 被设计成可以集成到 PyTorch DataLoader 中,但它也可以作为一个独立的工具使用。在这里,我们独立使用它来测试和验证它是否按预期运行。让我们尝试将以下三个不同的输入组装成一个批次,其中每个示例都被填充到相同的长度:

inputs_1 = [0, 1, 2, 3, 4]
inputs_2 = [5, 6]
inputs_3 = [7, 8, 9]
batch = (
    inputs_1,
    inputs_2,
    inputs_3
)
print(custom_collate_draft_1(batch))

生成的批次看起来如下所示:

tensor([[    0,     1,     2,     3,     4],  
        [    5,     6, 50256, 50256, 50256],
        [    7,     8,     9, 50256, 50256]])

这个输出显示所有输入都已填充到最长输入列表 inputs_1 的长度,包含五个标记 ID。

我们刚刚实现了我们的第一个自定义 collate 函数,用于从输入列表创建批处理。然而,正如我们之前学到的,我们还需要创建与输入 ID 批处理相对应的目标标记 ID 批处理。这些目标 ID,如图 7.9 所示,至关重要,因为它们代表我们希望模型生成的以及我们在训练中需要用于计算权重更新的损失。也就是说,我们修改了我们的自定义 collate 函数,使其除了返回输入标记 ID 外,还返回目标标记 ID。

图

图 7.9 实施批处理过程涉及的五个子步骤。我们现在专注于第 2.4 步,即创建目标标记 ID。这一步至关重要,因为它使模型能够学习和预测它需要生成的标记。

与我们用于预训练 LLM 的过程类似,目标标记 ID 与输入标记 ID 匹配,但向右移动一个位置。这种设置,如图 7.10 所示,允许 LLM 学习如何预测序列中的下一个标记。

图

图 7.10 在 LLM 的指令微调过程中使用的输入和目标标记对齐。对于每个输入序列,相应的目标序列是通过将标记 ID 向右移动一个位置,省略输入的第一个标记,并附加一个文本结束标记来创建的。

下面的更新版 collate 函数从输入标记 ID 生成目标标记 ID:

def custom_collate_draft_2(
    batch,
    pad_token_id=50256,
    device="cpu"
):
    batch_max_length = max(len(item)+1 for item in batch)
    inputs_lst, targets_lst = [], []

    for item in batch:
        new_item = item.copy()
        new_item += [pad_token_id]

        padded = (
            new_item + [pad_token_id] * 
            (batch_max_length - len(new_item))
        )
        inputs = torch.tensor(padded[:-1])     #1
        targets = torch.tensor(padded[1:])    #2
        inputs_lst.append(inputs)
        targets_lst.append(targets)

    inputs_tensor = torch.stack(inputs_lst).to(device)
    targets_tensor = torch.stack(targets_lst).to(device)
    return inputs_tensor, targets_tensor

inputs, targets = custom_collate_draft_2(batch)
print(inputs)
print(targets)

1 对输入截断最后一个标记

2 对目标向右移动+1

将应用于我们之前定义的包含三个输入列表的示例batch,新的custom_collate_draft_2函数现在返回输入和目标批处理:

tensor([[    0,     1,     2,     3,     4],    #1
        [    5,     6, 50256, 50256, 50256],
        [    7,     8,     9, 50256, 50256]])
tensor([[    1,     2,     3,     4, 50256],   #2
        [    6, 50256, 50256, 50256, 50256],
        [    8,     9, 50256, 50256, 50256]])

1 第一个张量代表输入。

2 第二个张量代表目标。

在下一步中,我们将-100占位符值分配给所有填充标记,如图 7.11 所示。这个特殊值允许我们排除这些填充标记对训练损失计算的贡献,确保只有有意义的数据影响模型学习。我们将在实施此修改后更详细地讨论此过程。(当进行分类微调时,我们不必担心这一点,因为我们只基于最后一个输出标记训练模型。)

图

图 7.11 实施批处理过程涉及的五个子步骤。在通过将标记 ID 向右移动一个位置并附加一个文本结束标记创建目标序列后,在第 2.5 步中,我们将文本结束填充标记替换为占位符值(-100)。

然而,请注意,我们在目标列表中保留了最后一个文本结束标记,ID 为50256,如图 7.12 所示。保留它允许 LLM 学习在响应指令时何时生成文本结束标记,这我们用作生成响应完整的指示器。

图

图 7.12 步骤 2.4 在训练数据准备中目标批次的标记替换过程。我们将除用作填充的文本结束标记的第一个实例之外的所有文本结束标记替换为占位符值 -100,同时保持每个目标序列中的初始文本结束标记。

在以下列表中,我们修改了我们的自定义 collate 函数,将目标列表中的标记 ID 50256 替换为 -100。此外,我们引入了一个 allowed_max_length 参数,以可选方式限制样本的长度。如果您计划使用自己的数据集,这些数据集的标记上下文大小超过了 GPT-2 模型支持的 1,024 标记,这种调整将非常有用。

列表 7.5 实现自定义批次 collate 函数
def custom_collate_fn(
    batch,
    pad_token_id=50256,
    ignore_index=-100,
    allowed_max_length=None,
    device="cpu"
):
    batch_max_length = max(len(item)+1 for item in batch)
    inputs_lst, targets_lst = [], []

    for item in batch:
        new_item = item.copy()
        new_item += [pad_token_id]

        padded = (                               #1
            new_item + [pad_token_id] *          #1
            (batch_max_length - len(new_item))   #1
        )
        inputs = torch.tensor(padded[:-1])      #2
        targets = torch.tensor(padded[1:])     #3

        mask = targets == pad_token_id              #4
        indices = torch.nonzero(mask).squeeze()     #4
        if indices.numel() > 1:                     #4
            targets[indices[1:]] = ignore_index     #4

        if allowed_max_length is not None:
            inputs = inputs[:allowed_max_length]       #5
            targets = targets[:allowed_max_length]     #5

        inputs_lst.append(inputs)
        targets_lst.append(targets)

    inputs_tensor = torch.stack(inputs_lst).to(device)
    targets_tensor = torch.stack(targets_lst).to(device)
    return inputs_tensor, targets_tensor

1 将序列填充到 max_length

2 截断输入的最后标记

3 将目标向右移动 +1

4 将目标中的除第一个填充标记外的所有标记替换为 ignore_index

5 可选地截断到最大序列长度

再次,让我们尝试使用我们之前创建的样本批次来检查 collate 函数是否按预期工作:

inputs, targets = custom_collate_fn(batch)
print(inputs)
print(targets)

结果如下,其中第一个张量表示输入,第二个张量表示目标:

tensor([[    0,     1,     2,     3,     4],
        [    5,     6, 50256, 50256, 50256],
        [    7,     8,     9, 50256, 50256]])
tensor([[    1,     2,     3,     4, 50256],
        [    6, 50256,  -100,  -100,  -100],
        [    8,     9, 50256,  -100,  -100]])

修改后的 collate 函数按预期工作,通过插入标记 ID -100 来修改目标列表。这种调整背后的逻辑是什么?让我们探索这种修改的潜在目的。

为了演示目的,考虑以下简单且自包含的示例,其中每个输出对数概率对应于模型词汇表中的一个潜在标记。以下是我们在训练过程中如何计算交叉熵损失(第五章中介绍)的示例,当模型预测一系列标记时,这与我们在预训练模型并对其进行分类微调时所做的是类似的:

logits_1 = torch.tensor(
    [[-1.0, 1.0],     #1
     [-0.5, 1.5]]      #2
)
targets_1 = torch.tensor([0, 1]) # Correct token indices to generate
loss_1 = torch.nn.functional.cross_entropy(logits_1, targets_1)
print(loss_1)

1 对第一个标记进行预测

2 对第二个标记进行预测

上一个代码计算出的损失值为 1.1269

tensor(1.1269)

如我们所预期,添加一个额外的标记 ID 会影响损失计算:

logits_2 = torch.tensor(
    [[-1.0, 1.0],
     [-0.5, 1.5],
     [-0.5, 1.5]]      #1
)
targets_2 = torch.tensor([0, 1, 1])
loss_2 = torch.nn.functional.cross_entropy(logits_2, targets_2)
print(loss_2)

1 新的第三标记 ID 预测

添加第三个标记后,损失值为 0.7936

到目前为止,我们已经使用 PyTorch 中的交叉熵损失函数进行了一些或多或少明显的示例计算,这与我们在预训练和分类微调训练函数中使用的损失函数相同。现在让我们进入有趣的部分,看看如果我们用 -100 替换第三个目标标记 ID 会发生什么:

targets_3 = torch.tensor([0, 1, -100])
loss_3 = torch.nn.functional.cross_entropy(logits_2, targets_3)
print(loss_3)
print("loss_1 == loss_3:", loss_1 == loss_3)

结果输出为

tensor(1.1269)
loss_1 == loss_3: tensor(True)

这三个训练示例的结果损失与我们之前从两个训练示例中计算出的损失相同。换句话说,交叉熵损失函数忽略了 targets_3 向量中的第三个条目,即对应于 -100 的标记 ID。(感兴趣的读者可以尝试用另一个非 01 的标记 ID 替换 -100 值;这将导致错误。)

那么 -100 有什么特别之处,以至于它被交叉熵损失所忽略?PyTorch 中交叉熵函数的默认设置是 cross_entropy(..., ignore_index=-100)。这意味着它忽略了标记为 -100 的目标。我们利用这个 ignore_index 来忽略我们用来填充训练示例以使每个批次长度相同的额外文本结束(填充)标记。然而,我们想在目标中保留一个 50256(文本结束)标记 ID,因为它有助于 LLM 学习生成文本结束标记,我们可以将其用作响应完整的指示器。

除了遮罩掉填充标记外,遮罩掉与指令对应的目标标记 ID 也是常见的,如图 7.13 所示。通过遮罩掉 LLM 的与指令对应的目标标记 ID,交叉熵损失仅计算生成的响应目标 ID。因此,模型被训练来专注于生成准确的响应,而不是记住指令,这有助于减少过拟合。

figure

图 7.13 左:我们在训练期间分词并输入到 LLM 中的格式化输入文本。右:我们为 LLM 准备的目标文本,我们可以选择遮罩掉指令部分,这意味着用 -100ignore_index 值替换相应的标记 ID。

到本文写作时,研究人员在是否在指令微调期间遮罩指令普遍有益的问题上存在分歧。例如,Shi 等人于 2024 年发表的论文“带有指令损失的指令微调”(arxiv.org/abs/2405.14394)表明,不遮罩指令有利于 LLM 性能(更多细节请见附录 B)。在这里,我们将不应用遮罩,将其作为对感兴趣读者的一项可选练习。

练习 7.2 指令和输入遮罩

完成章节并使用 InstructionDataset 微调模型后,将指令和输入标记替换为 -100 遮罩,以使用图 7.13 中展示的指令遮罩方法。然后评估这是否对模型性能有积极影响。

7.4 为指令数据集创建数据加载器

我们已经完成了几个阶段,以实现 InstructionDataset 类和用于指令数据集的 custom_collate_fn 函数。如图 7.14 所示,我们只需将 InstructionDataset 对象和 custom_collate_fn 函数简单地插入 PyTorch 数据加载器中,就可以收获我们的劳动成果。这些加载器将自动打乱和组织批次,以进行 LLM 指令微调过程。

figure

图 7.14 指示 LLM 微调的三阶段过程。到目前为止,我们已经准备好了数据集并实现了一个自定义的 collate 函数来批量处理指令数据集。现在,我们可以创建并应用数据加载器到 LLM 指令微调和评估所需的训练、验证和测试集。

在实现数据加载器创建步骤之前,我们必须简要谈谈custom_collate_fndevice设置。custom_collate_fn包括将输入和目标张量(例如,torch.stack(inputs_lst).to(device))移动到指定设备的代码,这可以是"cpu""cuda"(用于 NVIDIA GPU)或可选的"mps"(用于配备 Apple Silicon 芯片的 Mac)。

注意:使用"mps"设备可能与本章内容中的内容产生数值差异,因为 PyTorch 对 Apple Silicon 的支持仍然是实验性的。

在之前的训练循环中,我们将数据移动到了目标设备(例如,当device="cuda"时的 GPU 内存)。将此作为 collate 函数的一部分提供了优势,即在训练循环之外作为后台进程执行此设备传输过程,防止它阻塞模型训练时的 GPU。

以下代码初始化了device变量:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# if torch.backends.mps.is_available():   #1
#     device = torch.device("mps")"      
print("Device:", device)

1 取消注释这两行以在 Apple Silicon 芯片上使用 GPU

这将根据您的机器打印出"Device: cpu"Device:` cuda"。

接下来,为了在将custom_collate_fn插入 PyTorch 的DataLoader类时重用选择的设备设置,我们使用 Python 的functools标准库中的partial函数创建一个带有预填充设备参数的新函数版本。此外,我们将allowed_max_length设置为1024,这将截断数据到 GPT-2 模型支持的上下文最大长度,该模型我们稍后将对其进行微调:

from functools import partial

customized_collate_fn = partial(
    custom_collate_fn,
    device=device,
    allowed_max_length=1024
)

接下来,我们可以像之前一样设置数据加载器,但这次,我们将使用我们的自定义 collate 函数进行批处理过程。

列表 7.6 初始化数据加载器
from torch.utils.data import DataLoader

num_workers = 0      #1
batch_size = 8

torch.manual_seed(123)

train_dataset = InstructionDataset(train_data, tokenizer)
train_loader = DataLoader(
    train_dataset,
    batch_size=batch_size,
    collate_fn=customized_collate_fn,
    shuffle=True,
    drop_last=True,
    num_workers=num_workers
)

val_dataset = InstructionDataset(val_data, tokenizer)
val_loader = DataLoader(
    val_dataset,
    batch_size=batch_size,
    collate_fn=customized_collate_fn,
    shuffle=False,
    drop_last=False,
    num_workers=num_workers
)

test_dataset = InstructionDataset(test_data, tokenizer)
test_loader = DataLoader(
    test_dataset,
    batch_size=batch_size,
    collate_fn=customized_collate_fn,
    shuffle=False,
    drop_last=False,
    num_workers=num_workers
)

1 如果您的操作系统支持并行 Python 进程,您可以尝试增加这个数字。

让我们检查由训练加载器生成的输入和目标批次的维度:

print("Train loader:")
for inputs, targets in train_loader:
    print(inputs.shape, targets.shape)

输出如下(为了节省空间已截断):

Train loader:
torch.Size([8, 61]) torch.Size([8, 61])
torch.Size([8, 76]) torch.Size([8, 76])
torch.Size([8, 73]) torch.Size([8, 73])
...
torch.Size([8, 74]) torch.Size([8, 74])
torch.Size([8, 69]) torch.Size([8, 69])

此输出显示,第一个输入和目标批次具有 8 × 61 的维度,其中 8 代表批次大小,61 是此批次中每个训练示例中的标记数。第二个输入和目标批次具有不同的标记数——例如,76。多亏了我们的自定义 collate 函数,数据加载器能够创建不同长度的批次。在下文中,我们将加载一个预训练的 LLM,然后我们可以使用这个数据加载器对其进行微调。

7.5 加载预训练的 LLM

我们在准备指令微调数据集上花费了大量时间,这是监督微调过程中的一个关键方面。许多其他方面与预训练相同,使我们能够重用早期章节中大部分的代码。

在开始指令微调之前,我们必须首先加载一个我们想要微调的预训练 GPT 模型(见图 7.15),这是一个我们之前已经执行过的过程。然而,与之前使用最小的 1240 万参数模型不同,我们这次加载了具有 3.55 亿参数的中等大小模型。选择这个模型的原因是,1240 万参数模型在容量上过于有限,无法通过指令微调获得令人满意的结果。具体来说,较小的模型缺乏学习并保留高质量指令遵循任务所需的复杂模式和细微行为所必需的容量。

图

图 7.15 指令微调 LLM 的三个阶段过程。在数据集准备之后,指令遵循的 LLM 微调过程开始于加载一个预训练 LLM,这作为后续训练的基础。

加载我们的预训练模型需要与我们在第 5.5 节中预训练数据时相同的代码,以及在第 6.4 节中对其进行分类微调时的代码,只是我们现在指定 "gpt2-medium (355M)" 而不是 "gpt2-small (124M)"

注意:执行此代码将启动下载中等大小的 GPT 模型,该模型的存储需求约为 1.42 吉字节。这大约是小型模型所需存储空间的 三倍。

列表 7.7 加载预训练模型
from gpt_download import download_and_load_gpt2
from chapter04 import GPTModel
from chapter05 import load_weights_into_gpt

BASE_CONFIG = {
    "vocab_size": 50257,     # Vocabulary size
    "context_length": 1024,  # Context length
    "drop_rate": 0.0,        # Dropout rate
    "qkv_bias": True         # Query-key-value bias
}

model_configs = {
    "gpt2-small (124M)": {"emb_dim": 768, "n_layers": 12, "n_heads": 12},
    "gpt2-medium (355M)": {"emb_dim": 1024, "n_layers": 24, "n_heads": 16},
    "gpt2-large (774M)": {"emb_dim": 1280, "n_layers": 36, "n_heads": 20},
    "gpt2-xl (1558M)": {"emb_dim": 1600, "n_layers": 48, "n_heads": 25},
}

CHOOSE_MODEL = "gpt2-medium (355M)"
BASE_CONFIG.update(model_configs[CHOOSE_MODEL])

model_size = CHOOSE_MODEL.split(" ")[-1].lstrip("(").rstrip(")")

settings, params = download_and_load_gpt2(
    model_size=model_size, 
    models_dir="gpt2"
)

model = GPTModel(BASE_CONFIG)
load_weights_into_gpt(model, params)
model.eval();

执行代码后,将下载几个文件:

checkpoint: 100%|██████████| 77.0/77.0 [00:00<00:00, 156kiB/s]
encoder.json: 100%|██████████| 1.04M/1.04M [00:02<00:00, 467kiB/s]
hparams.json: 100%|██████████| 91.0/91.0 [00:00<00:00, 198kiB/s]
model.ckpt.data-00000-of-00001: 100%|██████████| 1.42G/1.42G 
[05:50<00:00, 4.05MiB/s]
model.ckpt.index: 100%|██████████| 10.4k/10.4k [00:00<00:00, 18.1MiB/s]
model.ckpt.meta: 100%|██████████| 927k/927k [00:02<00:00, 454kiB/s]
vocab.bpe: 100%|██████████| 456k/456k [00:01<00:00, 283kiB/s]

现在,让我们花一点时间评估预训练 LLM 在验证任务中的一个表现,通过将其输出与预期响应进行比较。这将使我们获得一个基准理解,了解模型在未经微调的情况下直接执行指令遵循任务的表现如何,并有助于我们后来欣赏微调的效果。我们将使用验证集的第一个示例进行此评估:

torch.manual_seed(123)
input_text = format_input(val_data[0])
print(input_text)

指令内容如下:

Below is an instruction that describes a task. Write a response that 
appropriately completes the request.

### Instruction:
Convert the active sentence to passive: 'The chef cooks the meal every day.'

接下来,我们使用与第五章中预训练模型相同的 generate 函数来生成模型的响应:

from chapter05 import generate, text_to_token_ids, token_ids_to_text

token_ids = generate(
    model=model,
    idx=text_to_token_ids(input_text, tokenizer),
    max_new_tokens=35,
    context_size=BASE_CONFIG["context_length"],
    eos_id=50256,
)
generated_text = token_ids_to_text(token_ids, tokenizer)

generate 函数返回组合的输入和输出文本。这种行为之前很方便,因为预训练 LLM 主要被设计为文本补全模型,其中输入和输出被连接起来以创建连贯且易于阅读的文本。然而,当评估模型在特定任务上的性能时,我们通常只想关注模型生成的响应。

为了隔离模型的响应文本,我们需要从 generated_text 的开始减去输入指令的长度:

response_text = generated_text[len(input_text):].strip()
print(response_text)

此代码从generated_text的开头移除输入文本,只留下模型的生成响应。然后应用strip()函数来移除任何前导或尾随空白字符。输出如下

### Response:

The chef cooks the meal every day.

### Instruction:

Convert the active sentence to passive: 'The chef cooks the

此输出显示,预训练模型尚不能正确地遵循给定的指令。虽然它确实创建了一个响应部分,但它只是重复了原始输入句子和部分指令,未能将主动句转换为请求的被动语态。因此,我们现在实施微调过程,以提高模型理解和适当响应此类请求的能力。

7.6 在指令数据上微调 LLM

是时候微调 LLM 以处理指令了(图 7.16)。我们将使用上一节中加载的预训练模型,并使用本章前面准备好的指令数据集进一步训练它。当我们在本章开头实现指令数据集处理时,我们已经完成了所有艰苦的工作。对于微调过程本身,我们可以重用第五章中实现的损失计算和训练函数:

figure

图 7.16 对 LLM 指令微调的三阶段过程。在第 5 步中,我们在之前准备好的指令数据集上训练之前加载的预训练模型。
from chapter05 import (
    calc_loss_loader,
    train_model_simple
)

在我们开始训练之前,让我们计算训练集和验证集的初始损失:

model.to(device)
torch.manual_seed(123)

with torch.no_grad():
    train_loss = calc_loss_loader(
        train_loader, model, device, num_batches=5
    )
    val_loss = calc_loss_loader(
        val_loader, model, device, num_batches=5
)

print("Training loss:", train_loss)
print("Validation loss:", val_loss)

初始损失值如下;如前所述,我们的目标是使损失最小化:

Training loss: 3.825908660888672
Validation loss: 3.7619335651397705
处理硬件限制

使用和训练一个更大的模型,如 GPT-2 中等(355 百万参数),比较小的 GPT-2 模型(124 百万参数)计算量更大。如果您因硬件限制而遇到问题,可以通过将CHOOSE_MODEL = "gpt2-medium (355M)"更改为CHOOSE_MODEL = "gpt2-small (124M)"(见第 7.5 节)来切换到较小的模型。或者,为了加快模型训练,考虑使用 GPU。本书代码库中的以下补充部分列出了使用云 GPU 的几种选项:mng.bz/EOEq

下表提供了在包括 CPU 和 GPU 在内的各种设备上训练每个模型的参考运行时间,对于 GPT-2。在兼容的 GPU 上运行此代码无需代码更改,并且可以显著加快训练速度。对于本章中显示的结果,我使用了 GPT-2 中等模型,并在 A100 GPU 上对其进行训练。

模型名称 设备 两个 epoch 的运行时间
gpt2-medium (355M) CPU (M3 MacBook Air) 15.78 分钟
gpt2-medium (355M) GPU (NVIDIA L4) 1.83 分钟
gpt2-medium (355M) GPU (NVIDIA A100) 0.86 分钟
gpt2-small (124M) CPU (M3 MacBook Air) 5.74 分钟
gpt2-small (124M) GPU (NVIDIA L4) 0.69 分钟
gpt2-small (124M) GPU (NVIDIA A100) 0.39 分钟

模型和数据加载器准备就绪后,我们现在可以开始训练模型。列表 7.8 中的代码设置了训练过程,包括初始化优化器、设置 epoch 数量以及定义评估频率和起始上下文,以便在训练期间根据我们在 7.5 节中查看的第一个验证集指令(val_data[0])评估生成的 LLM 响应。

列表 7.8 指令微调预训练的 LLM
import time

start_time = time.time()
torch.manual_seed(123)
optimizer = torch.optim.AdamW(
    model.parameters(), lr=0.00005, weight_decay=0.1
)
num_epochs = 2

train_losses, val_losses, tokens_seen = train_model_simple(
    model, train_loader, val_loader, optimizer, device,
    num_epochs=num_epochs, eval_freq=5, eval_iter=5,
    start_context=format_input(val_data[0]), tokenizer=tokenizer
)

end_time = time.time()
execution_time_minutes = (end_time - start_time) / 60
print(f"Training completed in {execution_time_minutes:.2f} minutes.")

下面的输出显示了两个 epoch 的训练进度,其中损失值的持续下降表明了模型遵循指令和生成适当响应的能力在提高:

Ep 1 (Step 000000): Train loss 2.637, Val loss 2.626
Ep 1 (Step 000005): Train loss 1.174, Val loss 1.103
Ep 1 (Step 000010): Train loss 0.872, Val loss 0.944
Ep 1 (Step 000015): Train loss 0.857, Val loss 0.906
...
Ep 1 (Step 000115): Train loss 0.520, Val loss 0.665
Below is an instruction that describes a task. Write a response that 
appropriately completes the request.  ### Instruction: Convert the 
active sentence to passive: 'The chef cooks the meal every day.' 
### Response: The meal is prepared every day by the chef.<|endoftext|>
The following is an instruction that describes a task. 
Write a response that appropriately completes the request.  
### Instruction: Convert the active sentence to passive:
Ep 2 (Step 000120): Train loss 0.438, Val loss 0.670
Ep 2 (Step 000125): Train loss 0.453, Val loss 0.685
Ep 2 (Step 000130): Train loss 0.448, Val loss 0.681
Ep 2 (Step 000135): Train loss 0.408, Val loss 0.677
...
Ep 2 (Step 000230): Train loss 0.300, Val loss 0.657
Below is an instruction that describes a task. Write a response 
that appropriately completes the request.  ### Instruction: 
Convert the active sentence to passive: 'The chef cooks the meal 
every day.'  ### Response: The meal is cooked every day by the 
chef.<|endoftext|>The following is an instruction that describes 
a task. Write a response that appropriately completes the request.  
### Instruction: What is the capital of the United Kingdom
Training completed in 0.87 minutes.

训练输出显示模型正在有效地学习,我们可以根据两个 epoch 中训练和验证损失值的持续下降来判断。这一结果表明,模型正在逐渐提高其理解和遵循提供指令的能力。(由于模型在这两个 epoch 内展示了有效的学习,因此将训练扩展到第三个 epoch 或更多可能是不必要的,甚至可能适得其反,因为它可能导致过拟合的增加。)

此外,每个 epoch 结束时生成的响应使我们能够检查模型在验证集示例中正确执行给定任务的过程。在这种情况下,模型成功地将主动句“The chef cooks the meal every day.”转换为它的被动语态对应句:“The meal is cooked every day by the chef.

我们将在稍后更详细地回顾和评估模型的响应质量。现在,让我们检查训练和验证损失曲线,以获得对模型学习过程的更多见解。为此,我们使用与预训练相同的plot_losses函数:

from chapter05 import plot_losses
epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
plot_losses(epochs_tensor, tokens_seen, train_losses, val_losses)

从图 7.17 所示的损失图中,我们可以看到模型在训练过程中在训练集和验证集上的性能显著提高。在初始阶段损失值的快速下降表明模型迅速从数据中学习到有意义的模式和表示。然后,随着训练进展到第二个 epoch,损失值继续下降但速度较慢,这表明模型正在微调其学习到的表示并收敛到一个稳定的解决方案。

figure

图 7.17 展示了两个 epoch 的训练和验证损失趋势。实线代表训练损失,显示在稳定之前急剧下降,而虚线代表验证损失,其模式类似。

虽然图 7.17 中的损失图表明模型正在有效地训练,但最关键的是其在响应质量和正确性方面的表现。因此,接下来,我们将提取响应并将它们存储在一个允许我们评估和量化响应质量的格式中。

练习 7.3 在原始 Alpaca 数据集上进行微调

斯坦福大学的研究人员制作的阿尔帕卡数据集是早期且最受欢迎的公开共享指令数据集之一,包含 52,002 条条目。作为我们这里使用的instruction-data.json文件的替代方案,可以考虑在这个数据集上微调一个大型语言模型。该数据集可在mng.bz/NBnE找到。

该数据集包含 52,002 条条目,大约是我们这里使用的条目数量的 50 倍,而且大多数条目更长。因此,我强烈建议使用 GPU 进行训练,这将加速微调过程。如果你遇到内存不足的错误,可以考虑将batch_size从 8 减少到 4、2,甚至 1。将allowed_max_length从 1,024 降低到 512 或 256 也可以帮助管理内存问题。

7.7 提取和保存响应

在对指令数据集的训练部分微调 LLM 后,我们现在准备评估它在保留的测试集上的性能。首先,我们提取测试数据集中每个输入的模型生成的响应,并将它们收集起来进行人工分析,然后评估 LLM 以量化响应的质量,如图 7.18 所示。

图

图 7.18 指令微调 LLM 的三阶段过程。在第三阶段的前两步中,我们提取并收集了在保留的测试数据集上的模型响应,以进行进一步分析,然后评估模型以量化指令微调 LLM 的性能。

为了完成响应指令步骤,我们使用generate函数。然后,我们将模型响应与预期的前三个测试集条目的测试集答案并排打印出来,以便进行比较:

torch.manual_seed(123)

for entry in test_data[:3]:      #1
    input_text = format_input(entry)
    token_ids = generate(               #2
        model=model,
        idx=text_to_token_ids(input_text, tokenizer).to(device),
        max_new_tokens=256,
        context_size=BASE_CONFIG["context_length"],
        eos_id=50256
    )
    generated_text = token_ids_to_text(token_ids, tokenizer)

    response_text = (
        generated_text[len(input_text):]
        .replace("### Response:", "")
        .strip()
    )
    print(input_text)
    print(f"\nCorrect response:\n>> {entry['output']}")
    print(f"\nModel response:\n>> {response_text.strip()}")
    print("-------------------------------------")

1 遍历前三个测试集样本

2 使用第 7.5 节中导入的generate函数

如前所述,generate函数返回组合的输入和输出文本,因此我们使用切片和.replace()方法对generated_text内容进行操作,以提取模型的响应。接下来显示了指令、给定的测试集响应和模型响应。

图

根据测试集指令、给定响应和模型的响应,我们可以看到,模型的表现相对较好。第一和最后一条指令的答案是明显正确的,而第二条答案虽然接近但不完全准确。模型用“积云”而不是“积雨云”来回答,尽管值得注意的是,积云可以发展成为积雨云,而积雨云能够产生雷暴。

最重要的,模型评估并不像分类微调那样直接,在分类微调中,我们只需计算正确垃圾邮件/非垃圾邮件类别标签的百分比来获得分类的准确率。在实践中,如聊天机器人这样的指令微调 LLM 通过多种方法进行评估:

  • 简答题和多选题基准,如衡量大规模多任务语言理解(MMLU;arxiv.org/abs/2009.03300),这些基准测试模型的一般知识。

  • 与其他 LLM(如 LMSYS 聊天机器人竞技场 arena.lmsys.org)的人类偏好比较。

  • 自动化对话基准测试,其中使用另一个 LLM(如 GPT-4)来评估响应,例如 AlpacaEval (tatsu-lab.github.io/alpaca_eval/)。

在实践中,考虑所有三种类型的评估方法可能是有用的:多项选择题回答、人工评估和自动指标,这些指标衡量对话性能。然而,由于我们主要对评估对话性能感兴趣,而不仅仅是评估回答多项选择题的能力,因此人工评估和自动指标可能更为相关。

对话性能

LLM 的对话性能指的是它们通过理解上下文、细微差别和意图来参与类似人类的交流的能力。它包括提供相关且连贯的响应、保持一致性以及适应不同主题和互动风格等技能。

人工评估虽然能提供有价值的见解,但可能相对费时费力,尤其是在处理大量响应时。例如,阅读并给所有 1,100 个响应评分就需要相当大的努力。

因此,考虑到手头任务的规模,我们将实施一种类似于自动化对话基准测试的方法,该方法涉及使用另一个 LLM 自动评估响应。这种方法将使我们能够高效地评估生成响应的质量,而无需大量的人类参与,从而节省时间和资源,同时仍然获得有意义的性能指标。

让我们采用受 AlpacaEval 启发的方案,使用另一个 LLM 来评估我们微调模型的响应。然而,我们不是依赖于公开可用的基准数据集,而是使用我们自己的定制测试集。这种定制化允许我们在我们预期的用例背景下,对模型性能进行更有针对性和相关的评估,这些用例在我们的指令数据集中表示。

为了准备此评估过程的响应,我们将生成的模型响应追加到test_set字典中,并将更新后的数据保存为"instruction-data-with-response.json"文件以供记录。此外,通过保存此文件,我们可以在需要时轻松地加载和分析响应,以便在单独的 Python 会话中进行。

以下代码列表使用与之前相同的方式调用generate方法;然而,我们现在遍历整个test_set。此外,我们不再打印模型响应,而是将它们添加到test_set字典中。

列表 7.9 生成测试集响应
from tqdm import tqdm

for i, entry in tqdm(enumerate(test_data), total=len(test_data)):
    input_text = format_input(entry)

    token_ids = generate(
        model=model,
        idx=text_to_token_ids(input_text, tokenizer).to(device),
        max_new_tokens=256,
        context_size=BASE_CONFIG["context_length"],
        eos_id=50256
    )
    generated_text = token_ids_to_text(token_ids, tokenizer)

    response_text = (
        generated_text[len(input_text):]
        .replace("### Response:", "")
        .strip()
    )
    test_data[i]["model_response"] = response_text

with open("instruction-data-with-response.json", "w") as file:
    json.dump(test_data, file, indent=4)         #1

1 用于美化打印的缩进

在 A100 GPU 上处理数据集大约需要 1 分钟,在 M3 MacBook Air 上需要 6 分钟:

100%|██████████| 110/110 [01:05<00:00,  1.68it/s]

让我们通过检查test_set字典中的一个条目来验证响应是否已正确添加:

print(test_data[0])

输出显示model_response已正确添加:

{'instruction': 'Rewrite the sentence using a simile.', 
 'input': 'The car is very fast.', 
 'output': 'The car is as fast as lightning.', 
 'model_response': 'The car is as fast as a bullet.'}

最后,我们将模型保存为gpt2-medium355M-sft.pth文件,以便在未来的项目中重用:

import re

file_name = f"{re.sub(r'[ ()]', '', CHOOSE_MODEL) }-sft.pth"      #1
torch.save(model.state_dict(), file_name)
print(f"Model saved as {file_name}")

1 从文件名中删除空白和括号

保存的模型可以通过model.load_state_dict(torch.load("gpt2 -medium355M-sft.pth"))加载。

7.8 评估微调 LLM

以前,我们通过查看测试集三个示例的响应来判断指令微调模型的性能。虽然这让我们对模型的表现有一个大致的了解,但这种方法并不适用于大量响应。因此,我们实现了一种方法,使用另一个更大的 LLM 来自动评估微调 LLM 的响应,如图 7.19 所示。

图

图 7.19 指令微调 LLM 的三阶段过程。在这个指令微调管道的最后一步,我们实现了一种方法,通过评分测试中生成的响应来量化微调模型的表现。

为了以自动化的方式评估测试集响应,我们利用由 Meta AI 开发的现有的指令微调的 80 亿参数 Llama 3 模型。此模型可以使用开源的 Ollama 应用程序(ollama.com)本地运行。

备注:Ollama 是一个在笔记本电脑上运行 LLM 的高效应用程序。它作为开源的 llama.cpp 库的包装器(github.com/ggerganov/llama.cpp),该库使用纯 C/C++实现 LLM 以最大化效率。然而,Ollama 仅是一个用于使用 LLM 生成文本的工具(推理),不支持训练或微调 LLM。

通过 Web API 使用更大的 LLM

80 亿参数的 Llama 3 模型是一个非常强大的本地运行的 LLM。然而,它并不像 OpenAI 提供的 GPT-4 这样的大型专有 LLM 那样强大。对于有兴趣探索如何通过 OpenAI API 利用 GPT-4 来评估生成模型响应的读者,本书的补充材料中提供了一个可选的代码笔记本,可在mng.bz/BgEv找到。

要执行以下代码,请访问ollama.com安装 Ollama,并按照为您的操作系统提供的说明进行操作:

  • 对于 macOS 和 Windows 用户—打开下载的 Ollama 应用程序。如果提示安装命令行使用,请选择是。

  • 对于 Linux 用户—使用 Ollama 网站上的安装命令。

在实现模型评估代码之前,让我们首先下载 Llama 3 模型,并通过使用命令行终端来验证 Ollama 是否正常工作。要从命令行使用 Ollama,您必须启动 Ollama 应用程序或在单独的终端中运行 ollama serve,如图 7.20 所示。

图

图 7.20 运行 Ollama 的两种选项。左侧面板说明了使用 ollama serve 启动 Ollama。右侧面板显示了 macOS 中的第二种选项,在后台运行 Ollama 应用程序而不是使用 ollama serve 命令来启动应用程序。

在不同的终端中运行 Ollama 应用程序或 ollama serve 时,请在命令行(而不是 Python 会话)中执行以下命令,以尝试 80 亿参数的 Llama 3 模型:

ollama run llama3

第一次执行此命令时,这个占用 4.7 GB 存储空间的模型将被自动下载。输出如下所示:

pulling manifest
pulling 6a0746a1ec1a... 100% |████████████████| 4.7 GB
pulling 4fa551d4f938... 100% |████████████████|  12 KB
pulling 8ab4849b038c... 100% |████████████████|  254 B
pulling 577073ffcc6c... 100% |████████████████|  110 B
pulling 3f8eb4da87fa... 100% |████████████████|  485 B
verifying sha256 digest
writing manifest
removing any unused layers
success
替代 Ollama 模型

ollama run llama3 命令中的 llama3 指的是指令微调的 80 亿参数 Llama 3 模型。使用 Ollama 和 llama3 模型大约需要 16 GB 的 RAM。如果您的机器没有足够的 RAM,您可以通过 ollama run llama3 使用较小的模型,例如 38 亿参数的 phi3 模型,这只需要大约 8 GB 的 RAM。

对于更强大的计算机,您也可以使用更大的 70 亿参数的 Llama 3 模型,通过将 llama3 替换为 llama3:70b 来实现。然而,这个模型需要显著更多的计算资源。

模型下载完成后,我们将看到一个命令行界面,允许我们与模型交互。例如,尝试询问模型:“骆驼吃什么?”

>>> What do llamas eat?
Llamas are ruminant animals, which means they have a four-chambered
stomach and eat plants that are high in fiber. In the wild, 
llamas typically feed on:

1\. Grasses: They love to graze on various types of grasses, including tall
grasses, wheat, oats, and barley.

注意,您看到的响应可能会有所不同,因为截至本文撰写时,Ollama 不是一个确定性的系统。

您可以使用输入 /bye 结束此 ollama run llama3 会话。但是,请确保在本章剩余部分保持 ollama serve 命令或 Ollama 应用程序运行。

以下代码在我们在 Ollama 中评估测试集响应之前,验证 Ollama 会话是否正常运行:

import psutil

def check_if_running(process_name):
    running = False
    for proc in psutil.process_iter(["name"]):
        if process_name in proc.info["name"]:
            running = True
            break
    return running

ollama_running = check_if_running("ollama")

if not ollama_running:
    raise RuntimeError(
        "Ollama not running. Launch ollama before proceeding."
)
print("Ollama running:", check_if_running("ollama"))

确保执行前面的代码后显示 Ollama running: True。如果显示 False,请验证 ollama serve 命令或 Ollama 应用程序是否正在积极运行。

在新的 Python 会话中运行代码

如果您已经关闭了 Python 会话,或者您更喜欢在不同的 Python 会话中执行剩余的代码,请使用以下代码,该代码加载我们之前创建的指令和响应数据文件,并重新定义我们之前使用的 format_input 函数(稍后使用 tqdm 进度条实用程序):

import json
from tqdm import tqdm

file_path = "instruction-data-with-response.json"
with open(file_path, "r") as file:
    test_data = json.load(file)

def format_input(entry):
    instruction_text = (
        f"Below is an instruction that describes a task. "
        f"Write a response that appropriately completes the request."
        f"\n\n### Instruction:\n{entry['instruction']}"
    )

    input_text = (
        f"\n\n### Input:\n{entry['input']}" if entry["input"] else ""
    )
    return instruction_text + input_text

ollama run 命令交互模型的另一种方法是使用 Python 通过其 REST API。以下列表中显示的 query_model 函数演示了如何使用该 API。

列表 7.10 查询本地 Ollama 模型
import urllib.request

def query_model(
    prompt, 
    model="llama3", 
    url="http://localhost:11434/api/chat"
):
    data = {             #1
        "model": model,
        "messages": [
            {"role": "user", "content": prompt}
        ],
        "options": {         #2
            "seed": 123,
            "temperature": 0,
            "num_ctx": 2048
        }
    }

    payload = json.dumps(data).encode("utf-8")    #3
    request = urllib.request.Request(                       #4
        url,                                                #4
        data=payload,                                       #4
        method="POST"                                       #4
    ) #4

    request.add_header("Content-Type", "application/json")   #4

    response_data = ""
    with urllib.request.urlopen(request) as response:   #5
        while True:
            line = response.readline().decode("utf-8")
            if not line:
                break
            response_json = json.loads(line)
            response_data += response_json["message"]["content"]

    return response_data

1 创建数据有效负载作为字典

2 确定响应的设置

3 将字典转换为 JSON 格式的字符串,并编码为字节

4 创建请求对象,设置方法为 POST 并添加必要的头信息

5 发送请求并捕获响应

在运行此笔记本中的后续代码单元之前,请确保 Ollama 仍在运行。之前的代码单元应该打印 "Ollama running: True" 以确认模型处于活动状态并准备好接收请求。

以下是如何使用我们刚刚实现的 query_model 函数的示例:

model = "llama3"
result = query_model("What do Llamas eat?", model)
print(result)

生成的响应如下:

Llamas are ruminant animals, which means they have a four-chambered 
stomach that allows them to digest plant-based foods. Their diet 
typically consists of:

1\. Grasses: Llamas love to graze on grasses, including tall grasses, 
short grasses, and even weeds.
...

使用之前定义的 query_model 函数,我们可以评估由我们的微调模型生成的响应,该模型提示 Llama 3 模型根据给定的测试集响应作为参考,对微调模型的响应进行从 0 到 100 的评分。

首先,我们将这种方法应用于我们之前检查的前三个测试集示例:

for entry in test_data[:3]:
    prompt = (
        f"Given the input `{format_input(entry)}` "
        f"and correct output `{entry['output']}`, "
        f"score the model response `{entry['model_response']}`"
        f" on a scale from 0 to 100, where 100 is the best score. "
    )
    print("\nDataset response:")
    print(">>", entry['output'])
    print("\nModel response:")
    print(">>", entry["model_response"])
    print("\nScore:")
    print(">>", query_model(prompt))
    print("\n-------------------------")

此代码打印的输出类似于以下内容(截至本文撰写时,Ollama 不是一个完全确定性的模型,因此生成的文本可能会有所不同):

figure

生成的响应显示,Llama 3 模型提供了合理的评估,并且能够在模型答案不完全正确时分配部分分数。例如,如果我们考虑“积云”答案的评估,模型承认响应的部分正确性。

之前的提示除了得分外还返回了高度详细的分析。我们可以修改提示,只生成从 0 到 100 的整数得分,其中 100 代表最佳可能得分。这种修改使我们能够计算我们模型的平均得分,这作为对其性能的更简洁和定量的评估。以下列表中显示的 generate_model_scores 函数使用修改后的提示告诉模型“仅Respond with the integer number。”

列表 7.11 评估指令微调 LLM
def generate_model_scores(json_data, json_key, model="llama3"):
    scores = []
    for entry in tqdm(json_data, desc="Scoring entries"):
        prompt = (
            f"Given the input `{format_input(entry)}` "
            f"and correct output `{entry['output']}`, "
            f"score the model response `{entry[json_key]}`"
            f" on a scale from 0 to 100, where 100 is the best score. "
            f"Respond with the integer number only."   #1
        )
        score = query_model(prompt, model)
        try:
            scores.append(int(score))
        except ValueError:
            print(f"Could not convert score: {score}")
            continue

    return scores

1 修改指令行以仅返回得分

现在,让我们将 generate_model_scores 函数应用于整个 test_data 集合,这在 M3 Macbook Air 上大约需要 1 分钟:

scores = generate_model_scores(test_data, "model_response")
print(f"Number of scores: {len(scores)} of {len(test_data)}")
print(f"Average score: {sum(scores)/len(scores):.2f}\n")

结果如下:

Scoring entries: 100%|████████████████████████| 110/110 
[01:10<00:00,  1.56it/s]
Number of scores: 110 of 110
Average score: 50.32

评估输出显示,我们的微调模型平均得分超过 50,这为与其他模型进行比较或尝试不同的训练配置以提高模型性能提供了一个有用的基准。

值得注意的是,在撰写本文时,Ollama 在操作系统上并不完全确定,这意味着你获得的分数可能与之前的分数略有不同。为了获得更稳健的结果,你可以多次重复评估并平均结果分数。

为了进一步提高我们模型的表现,我们可以探索各种策略,例如

  • 在微调期间调整超参数,如学习率、批量大小或 epoch 数量

  • 增加训练数据集的大小或多样化示例以涵盖更广泛的主题和风格

  • 尝试不同的提示或指令格式来更有效地引导模型的响应

  • 使用更大的预训练模型,这可能具有更大的能力来捕捉复杂模式并生成更准确的响应

备注:为了参考,当使用本文中描述的方法时,未经任何微调的 Llama 3 8B 基础模型在测试集上实现了平均分数 58.51。经过在通用指令遵循数据集上微调的 Llama 3 8B 指令模型,实现了令人印象深刻的平均分数 82.6。

练习 7.4 使用 LoRA 进行参数高效的微调

为了更有效地微调一个 LLM,修改本章中的代码以使用附录 E 中的低秩自适应方法(LoRA)。比较修改前后的训练运行时间和模型性能。

7.9 结论

本章标志着我们通过 LLM 开发周期的旅程的结束。我们已经涵盖了所有基本步骤,包括实现 LLM 架构、预训练 LLM 以及针对特定任务进行微调,如 7.21 图所示。让我们讨论一下接下来要关注的一些想法。

figure

图 7.21 编写 LLM 的三个主要阶段。

7.9.1 接下来是什么?

虽然我们已经涵盖了最重要的步骤,但在指令微调之后还有一个可选步骤:偏好微调。偏好微调特别有用,可以帮助定制模型以更好地符合特定用户偏好。如果你对此感兴趣,请参阅本书补充 GitHub 仓库中的04_preference-tuning-with-dpo文件夹,网址为mng.bz/dZwD

除了本书涵盖的主要内容外,GitHub 仓库还包含大量你可能觉得有价值的额外材料。要了解更多关于这些额外资源的信息,请访问仓库的 README 页面上的“额外材料”部分:mng.bz/r12g

7.9.2 在快速发展的领域中保持最新

人工智能和 LLM 研究领域正在迅速(并且,根据您询问的人,令人兴奋)地发展。跟上最新进展的一种方式是探索 arXiv 上的最新研究论文,网址为arxiv.org/list/cs.LG/recent。此外,许多研究人员和实践者都在社交媒体平台如 X(前身为 Twitter)和 Reddit 上非常活跃地分享和讨论最新的发展。特别是,r/LocalLLaMA subreddir 是一个很好的资源,用于与社区建立联系并了解最新的工具和趋势。我还在我的博客上定期分享见解并撰写关于 LLM 研究的最新内容,博客地址为magazine.sebastianraschka.comsebastianraschka.com/blog/

7.9.3 最后的话

我希望您已经享受了从零开始实现 LLM 并从头编写预训练和微调函数的旅程。在我看来,从头开始构建 LLM 是最有效地深入了解 LLM 工作原理的方法。我希望这种动手方法为您提供了宝贵的见解和 LLM 开发的坚实基础。

虽然本书的主要目的是教育性的,您可能对利用不同且更强大的 LLMs 进行实际应用感兴趣。为此,我建议探索流行的工具,如 Axolotl (github.com/OpenAccess-AI-Collective/axolotl) 或 LitGPT (github.com/Lightning-AI/litgpt),我积极参与了这些工具的开发。

感谢您与我一同踏上这段学习之旅,并祝您在 LLMs 和 AI 这个激动人心的领域中未来的努力一切顺利!

摘要

  • 指令微调过程将预训练的 LLM 调整为遵循人类指令并生成期望的响应。

  • 准备数据集涉及下载指令-响应数据集,格式化条目,并将其分为训练集、验证集和测试集。

  • 训练批次是通过一个自定义的 collate 函数构建的,该函数填充序列,创建目标标记 ID,并屏蔽填充标记。

  • 我们加载了一个预训练的 GPT-2 中等模型,包含 3.55 亿个参数,作为指令微调的起点。

  • 使用与预训练类似的训练循环,在指令数据集上对预训练模型进行微调。

  • 评估涉及从测试集中提取模型响应并对其进行评分(例如,使用另一个 LLM)。

  • 配备了 80 亿参数的 Llama 模型的 Ollama 应用程序可以用于自动评分测试集中微调模型的响应,提供一个平均分数来量化性能。

附录 A PyTorch 简介

本附录旨在为您提供将深度学习应用于实践并从头开始实现大型语言模型(LLMs)所需的基本技能和知识。PyTorch,一个流行的基于 Python 的深度学习库,将是本书的主要工具。我将指导您使用 PyTorch 和 GPU 支持设置深度学习工作空间。

接下来,您将了解张量这一基本概念及其在 PyTorch 中的使用。我们还将深入了解 PyTorch 的自动微分引擎,这是一个使我们能够方便且高效地使用反向传播的功能,而反向传播是神经网络训练的关键方面。

本附录旨在为那些刚开始使用 PyTorch 进行深度学习的人提供入门指南。虽然它从底层解释了 PyTorch,但它并不旨在全面覆盖 PyTorch 库。相反,我们将专注于我们将用于实现 LLMs 的 PyTorch 基础知识。如果您已经熟悉深度学习,您可以跳过本附录,直接进入第二章。

A.1 什么是 PyTorch?

PyTorch (pytorch.org/) 是一个开源的基于 Python 的深度学习库。根据 Papers With Code (paperswithcode.com/trends) 平台,该平台跟踪和分析研究论文,PyTorch 自 2019 年以来一直是研究中最广泛使用的深度学习库,并且差距很大。根据 Kaggle 数据科学和机器学习调查 2022 (www.kaggle.com/c/kaggle-survey-2022),使用 PyTorch 的受访者数量大约为 40%,并且每年都在增长。

PyTorch 之所以如此受欢迎,其中一个原因就是其用户友好的界面和效率。尽管它易于访问,但它并没有在灵活性上妥协,允许高级用户调整模型的高级方面以进行定制和优化。简而言之,对于许多实践者和研究人员来说,PyTorch 在可用性和功能之间提供了恰到好处的平衡。

A.1.1 PyTorch 的三个核心组件

PyTorch 是一个相对全面的库,一种接近它的方法是关注其三个主要组件,如图 A.1 所示。

figure

图 A.1 PyTorch 的三个主要组件包括作为计算基本构建块的张量库、用于模型优化的自动微分以及深度学习实用函数,这使得实现和训练深度神经网络模型变得更加容易。

首先,PyTorch 是一个张量库,它扩展了数组导向编程库 NumPy 的概念,并增加了加速 GPU 上计算的功能,从而在 CPU 和 GPU 之间提供无缝切换。其次,PyTorch 是一个自动微分引擎,也称为 autograd,它能够自动计算张量操作的梯度,简化了反向传播和模型优化。最后,PyTorch 是一个深度学习库。它提供了模块化、灵活且高效的构建块,包括预训练模型、损失函数和优化器,用于设计和训练各种深度学习模型,满足研究人员和开发者的需求。

A.1.2 定义深度学习

在新闻中,大型语言模型(LLMs)通常被称为 AI 模型。然而,LLMs 也是一种深度神经网络,PyTorch 是一个深度学习库。听起来很复杂?在我们继续之前,让我们简要总结一下这些术语之间的关系。

人工智能的基本目标是创建能够执行通常需要人类智能的任务的计算机系统。这些任务包括理解自然语言、识别模式和做出决策。(尽管取得了重大进展,但 AI 距离达到这种通用智能水平还有很长的路要走。)

机器学习是 AI 的一个子领域,如图 A.2 所示,它专注于开发和改进学习算法。机器学习背后的关键思想是使计算机能够从数据中学习并做出预测或决策,而无需明确编程来执行该任务。这涉及到开发能够识别模式、从历史数据中学习,并在更多数据和反馈的帮助下随着时间的推移提高其性能的算法。

figure

图 A.2 深度学习是机器学习的一个子类别,专注于实现深度神经网络。机器学习是 AI 的一个子类别,它关注的是从数据中学习的算法。AI 是更广泛的概念,即机器能够执行通常需要人类智能的任务。

机器学习一直是 AI 演变的关键,推动了今天我们所看到的许多进步,包括 LLMs。机器学习还支持在线零售商和流媒体服务使用的推荐系统、电子邮件垃圾邮件过滤、虚拟助手中的语音识别,甚至自动驾驶汽车等技术。机器学习的引入和进步显著增强了 AI 的能力,使其能够超越严格的基于规则的系统,并适应新的输入或不断变化的环境。

深度学习是机器学习的一个子类别,它专注于深度神经网络的训练和应用。这些深度神经网络最初是受人类大脑工作方式的启发,特别是许多神经元之间的相互连接。深度学习中的“深度”指的是人工神经元或节点的多层隐藏层,这使得它们能够模拟数据中的复杂、非线性关系。与擅长简单模式识别的传统机器学习技术不同,深度学习特别擅长处理非结构化数据,如图像、音频或文本,因此它特别适合 LLMs。

机器学习和深度学习中的典型预测建模工作流程(也称为监督学习)在图 A.3 中进行了总结。

图

图 A.3 预测建模的监督学习工作流程包括一个训练阶段,在这个阶段,模型在训练数据集上的标记示例上进行训练。训练好的模型随后可以用来预测新观察结果的标签。

使用学习算法,模型在由示例及其对应标签组成的训练数据集上进行训练。例如,在电子邮件垃圾邮件分类器的情况下,训练数据集包括电子邮件及其人类识别的“垃圾邮件”和“非垃圾邮件”标签。然后,训练好的模型可以用于新的观察结果(即新的电子邮件)来预测它们的未知标签(“垃圾邮件”或“非垃圾邮件”)。当然,我们还想在训练和推理阶段之间添加模型评估,以确保在将其用于实际应用之前,模型满足我们的性能标准。

如果我们训练 LLMs 来对文本进行分类,训练和使用 LLMs 的工作流程与图 A.3 中描述的类似。如果我们对训练 LLMs 生成文本感兴趣,这是我们主要关注的焦点,图 A.3 仍然适用。在这种情况下,预训练期间的标签可以从文本本身(第一章中引入的下一词预测任务)中推导出来。在推理期间,LLM 将根据输入提示生成全新的文本(而不是预测标签)。

A.1.3 安装 PyTorch

PyTorch 的安装方法与其他 Python 库或包类似。然而,由于 PyTorch 是一个包含 CPU 和 GPU 兼容代码的综合库,安装可能需要额外的解释。

Python 版本

许多科学计算库并不立即支持 Python 的最新版本。因此,在安装 PyTorch 时,建议使用一个比最新版本早一两个发布版本的 Python。例如,如果 Python 的最新版本是 3.13,那么使用 Python 3.11 或 3.12 是推荐的。

例如,PyTorch 有两个版本:一个仅支持 CPU 计算的精简版和一个支持 CPU 和 GPU 计算的完整版。如果您的机器有一个可用于深度学习的 CUDA 兼容 GPU(理想情况下是 NVIDIA T4、RTX 2080 Ti 或更新的型号),我建议安装 GPU 版本。无论如何,在代码终端中安装 PyTorch 的默认命令是:

pip install torch

假设您的计算机支持 CUDA 兼容的 GPU,那么它将自动安装支持通过 CUDA 进行 GPU 加速的 PyTorch 版本,前提是您正在工作的 Python 环境已安装必要的依赖项(如 pip)。

注意:截至本文撰写时,PyTorch 还通过 ROCm 添加了对 AMD GPU 的实验性支持。有关更多信息,请参阅 pytorch.org

要明确安装与 CUDA 兼容的 PyTorch 版本,通常最好指定 PyTorch 要兼容的 CUDA 版本。PyTorch 的官方网站 (pytorch.org) 为不同操作系统提供了安装具有 CUDA 支持的 PyTorch 的命令。图 A.4 显示了一个将安装 PyTorch 以及可选的 torchvisiontorchaudio 库的命令。

图

图 A.4 通过 pytorch.org 访问 PyTorch 安装推荐,以自定义并选择适合您系统的安装命令。

我在示例中使用 PyTorch 2.4.0,因此我建议您使用以下命令安装确切版本,以确保与本书兼容:

pip install torch==2.4.0

然而,如前所述,根据您的操作系统,安装命令可能与这里显示的略有不同。因此,我建议您访问 pytorch.org 并使用安装菜单(见图 A.4)选择适合您操作系统的安装命令。请记住,在命令中将 torch 替换为 torch==2.4.0

要检查 PyTorch 的版本,请在 PyTorch 中执行以下代码:

import torch
torch.__version__

这将打印

'2.4.0'
PyTorch 和 Torch

Python 库被命名为 PyTorch,主要是因为它是 Torch 库的延续,但已针对 Python 进行了适配(因此称为“PyTorch”)。“Torch”承认该库起源于 Torch,这是一个广泛支持机器学习算法的科学计算框架,最初是用 Lua 编程语言创建的。

如果您需要额外的建议和说明,用于设置 Python 环境或安装本书中使用的其他库,请访问本书的补充 GitHub 仓库 github.com/rasbt/LLMs-from-scratch

安装 PyTorch 后,您可以通过在 Python 中运行以下代码来检查您的安装是否识别了内置的 NVIDIA GPU:

import torch
torch.cuda.is_available()

这将返回

True

如果命令返回 True,则一切准备就绪。如果命令返回 False,则您的计算机可能没有兼容的 GPU,或者 PyTorch 无法识别它。虽然本书的前几章不需要 GPU,这些章节主要关注于教育目的实现 LLM,但 GPU 可以显著加快深度学习相关计算。

如果您无法访问 GPU,有几个云计算提供商允许用户按小时计费运行 GPU 计算。一个流行的类似 Jupyter 笔记本的环境是 Google Colab (colab.research.google.com),截至本文撰写时,它提供了时间有限的 GPU 访问。使用运行时菜单,可以选择一个 GPU,如图 A.5 中的截图所示。

figure

图 A.5 在运行时/更改运行时类型菜单下选择 Google Colab 的 GPU 设备。
苹果硅上的 PyTorch

如果您有一台配备苹果硅芯片的苹果 Mac(如 M1、M2、M3 或更新的型号),您可以使用其功能来加速 PyTorch 代码的执行。要使用您的苹果硅芯片为 PyTorch,您首先需要像平常一样安装 PyTorch。然后,为了检查您的 Mac 是否支持使用其苹果硅芯片进行 PyTorch 加速,您可以在 Python 中运行一个简单的代码片段:

print(torch.backends.mps.is_available())

如果它返回 True,则意味着您的 Mac 拥有可以用于加速 PyTorch 代码的苹果硅芯片。

练习 A.1

在您的计算机上安装和设置 PyTorch

练习 A.2

运行补充代码 mng.bz/o05v,检查您的环境是否设置正确。

A.2 理解张量

张量代表了一种将向量矩阵推广到可能更高维度的数学概念。换句话说,张量是可以通过其阶数(或阶数)来表征的数学对象,它提供了维数的数量。例如,标量(只是一个数字)是阶数为 0 的张量,向量是阶数为 1 的张量,矩阵是阶数为 2 的张量,如图 A.6 所示。

figure

图 A.6 不同阶的张量。其中 0D 对应阶数为 0,1D 对应阶数为 1,2D 对应阶数为 2。一个由三个元素组成的三维向量仍然是一个阶数为 1 的张量。

从计算的角度来看,张量作为数据容器。例如,它们持有多维数据,其中每个维度代表一个不同的特征。像 PyTorch 这样的张量库可以高效地创建、操作和计算这些数组。在这种情况下,张量库充当数组库。

PyTorch 张量类似于 NumPy 数组,但具有几个对深度学习非常重要的附加功能。例如,PyTorch 添加了一个自动微分引擎,简化了计算梯度(见第 A.4 节)。PyTorch 张量还支持 GPU 计算,以加快深度神经网络训练(见第 A.9 节)。

带有 NumPy-like API 的 PyTorch

PyTorch 采用了 NumPy 数组 API 和语法的大多数部分来执行其张量操作。如果您是 NumPy 的新手,您可以通过我的文章“Python 中的科学计算:NumPy 和 Matplotlib 简介”快速了解最相关的概念,该文章可在sebastianraschka.com/blog/2020/numpy-intro.html找到。

A.2.1 标量、向量、矩阵和张量

如前所述,PyTorch 张量是类似数组的结构的数据容器。标量是零维张量(例如,只是一个数字),向量是一维张量,矩阵是二维张量。对于更高维度的张量没有特定的术语,所以我们通常将三维张量称为 3D 张量,依此类推。我们可以使用 torch.tensor 函数创建 PyTorch 的 Tensor 类对象,如下所示。

列表 A.1 创建 PyTorch 张量
import torch

tensor0d = torch.tensor(1)     #1

tensor1d = torch.tensor([1, 2, 3])    #2

tensor2d = torch.tensor([[1, 2], 
                         [3, 4]])     #3

tensor3d = torch.tensor([[[1, 2], [3, 4]], 
                         [[5, 6], [7, 8]]])    #4

1 从 Python 整数创建零维张量(标量)

2 从 Python 列表创建一维张量(向量)

3 从嵌套 Python 列表创建二维张量

4 从嵌套 Python 列表创建三维张量

A.2.2 张量数据类型

PyTorch 采用 Python 的默认 64 位整数数据类型。我们可以通过张量的 .dtype 属性访问张量的数据类型:

tensor1d = torch.tensor([1, 2, 3])
print(tensor1d.dtype)

这将打印

torch.int64

如果我们从 Python 浮点数创建张量,PyTorch 默认创建 32 位精度的张量:

floatvec = torch.tensor([1.0, 2.0, 3.0])
print(floatvec.dtype)

输出是

torch.float32

这种选择主要是由于精度和计算效率之间的平衡。32 位浮点数提供了足够的精度,适用于大多数深度学习任务,同时比 64 位浮点数消耗更少的内存和计算资源。此外,GPU 架构针对 32 位计算进行了优化,使用这种数据类型可以显著加快模型训练和推理。

此外,可以使用张量的 .to 方法更改精度。以下代码通过将 64 位整数张量转换为 32 位浮点张量来演示这一点:

floatvec = tensor1d.to(torch.float32)
print(floatvec.dtype)

这将返回

torch.float32

有关 PyTorch 中可用的不同张量数据类型的更多信息,请查看官方文档pytorch.org/docs/stable/tensors.html

A.2.3 常见的 PyTorch 张量操作

本书不涵盖所有不同的 PyTorch 张量操作和命令的全面介绍。然而,随着我们在书中介绍这些操作,我会简要描述相关的操作。

我们已经介绍了 torch.tensor() 函数来创建新的张量:

tensor2d = torch.tensor([[1, 2, 3], 
                         [4, 5, 6]])
print(tensor2d)

这将打印

tensor([[1, 2, 3],
        [4, 5, 6]])

此外,.shape 属性允许我们访问张量的形状:

print(tensor2d.shape)

输出是

torch.Size([2, 3])

如您所见,.shape 返回 [2, 3],这意味着张量有两行三列。要将张量重塑为 3 × 2 张量,我们可以使用 .reshape 方法:

print(tensor2d.reshape(3, 2))

这将打印

tensor([[1, 2],
        [3, 4],
        [5, 6]])

然而,请注意,在 PyTorch 中重塑张量的更常见命令是 .view()

print(tensor2d.view(3, 2))

输出是

tensor([[1, 2],
        [3, 4],
        [5, 6]])

.reshape.view 类似,在几个情况下,PyTorch 为执行相同的计算提供了多个语法选项。PyTorch 最初遵循原始 Lua Torch 语法约定,但后来,根据普遍的要求,添加了与 NumPy 类似的语法。(PyTorch 中 .view().reshape() 之间的细微差别在于它们对内存布局的处理:.view() 要求原始数据是连续的,如果不是,将会失败,而 .reshape() 将会工作,如果需要,会复制数据以确保所需的形状。)

接下来,我们可以使用 .T 来转置一个张量,这意味着沿着其对角线翻转它。请注意,这与重塑张量不同,正如以下结果所示:

print(tensor2d.T)

输出是

tensor([[1, 4],
        [2, 5],
        [3, 6]])

最后,PyTorch 中乘以两个矩阵的常用方法是 .matmul 方法:

print(tensor2d.matmul(tensor2d.T))

输出是

tensor([[14, 32],
        [32, 77]])

然而,我们也可以采用 @ 操作符,它可以更紧凑地完成相同的事情:

print(tensor2d @ tensor2d.T)

这会打印

tensor([[14, 32],
        [32, 77]])

如前所述,当需要时,我会引入额外的操作。对于想要浏览 PyTorch 中所有不同张量操作的读者(我们不需要这些中的大多数),我建议查看官方文档pytorch.org/docs/stable/tensors.html

A.3 将模型视为计算图

现在让我们看看 PyTorch 的自动微分引擎,也称为 autograd。PyTorch 的 autograd 系统提供函数来自动计算动态计算图中的梯度。

计算图是一个有向图,它允许我们表达和可视化数学表达式。在深度学习的上下文中,计算图展示了计算神经网络输出所需的一系列计算——我们将需要它来计算反向传播所需的梯度,这是神经网络的主要训练算法。

让我们通过一个具体的例子来说明计算图的概念。以下列表中的代码实现了简单逻辑回归分类器的正向传递(预测步骤),这可以被视为单层神经网络。它返回一个介于 0 和 1 之间的分数,当计算损失时,这个分数会与真实的类别标签(0 或 1)进行比较。

列表 A.2 逻辑回归正向传递
import torch.nn.functional as F     #1

y = torch.tensor([1.0])          #2
x1 = torch.tensor([1.1])    #3
w1 = torch.tensor([2.2])    #4
b = torch.tensor([0.0])            #5
z = x1 * w1 + b                 #6
a = torch.sigmoid(z)               #7
loss = F.binary_cross_entropy(a, y)

1 这个导入语句是 PyTorch 中的常见约定,用于防止代码行过长。

2 真实标签

3 输入特征

4 权重参数

5 偏置单元

6 网络输入

7 激活和输出

如果前面的代码中有些部分对你来说没有意义,不要担心。这个示例的目的不是实现一个逻辑回归分类器,而是说明我们可以如何将一系列计算视为一个计算图,如图 A.7 所示。

figure

图 A.7 展示了逻辑回归的前向传递作为计算图。输入特征 x[1]乘以模型权重 w[1],在添加偏差后通过激活函数 s,然后通过比较模型输出 a 与给定的标签 y 来计算损失。

事实上,PyTorch 在后台构建这样的计算图,我们可以使用它来计算损失函数相对于模型参数(此处w[1]和b)的梯度,以训练模型。

A.4 自动微分变得简单

如果我们在 PyTorch 中进行计算,它将默认内部构建一个计算图,如果其终端节点之一设置了requires_grad属性为True。如果我们想计算梯度,这很有用。在通过流行的反向传播算法训练神经网络时需要梯度,这可以被认为是微积分中的链式法则在神经网络中的实现,如图 A.8 所示。

figure

图 A.8 计算图中损失梯度的最常见方法是从右到左应用链式法则,也称为反向模型自动微分或反向传播。我们从输出层(或损失本身)开始,通过网络反向到输入层。我们这样做是为了计算损失相对于网络中每个参数(权重和偏差)的梯度,这告诉我们如何在训练期间更新这些参数。

偏导数和梯度

图 A.8 显示了偏导数,它衡量了函数相对于其变量的变化率。梯度是一个包含多变量函数所有偏导数的向量,该函数的输入变量不止一个。

如果你对偏导数、梯度或微积分中的链式法则不熟悉或记不清楚,不要担心。从高层次来看,这本书你需要知道的是,链式法则是通过计算图中的模型参数来计算损失函数梯度的方法。这提供了更新每个参数以最小化损失函数所需的信息,损失函数作为衡量模型性能的代理,可以使用梯度下降等方法。我们将在 A.7 节中回顾 PyTorch 中这个训练循环的计算实现。

这一切都是如何与前面提到的 PyTorch 库的第二组件自动微分(autograd)引擎相关联的呢?PyTorch 的 autograd 引擎通过跟踪对张量执行的每个操作,在后台构建一个计算图。然后,调用grad函数,我们可以计算关于模型参数w1的损失梯度,如下面的列表所示。

列表 A.3 通过 autograd 计算梯度
import torch.nn.functional as F
from torch.autograd import grad

y = torch.tensor([1.0])
x1 = torch.tensor([1.1])
w1 = torch.tensor([2.2], requires_grad=True)
b = torch.tensor([0.0], requires_grad=True)

z = x1 * w1 + b 
a = torch.sigmoid(z)

loss = F.binary_cross_entropy(a, y)

grad_L_w1 = grad(loss, w1, retain_graph=True)   #1
grad_L_b = grad(loss, b, retain_graph=True)

1 默认情况下,PyTorch 在计算梯度后会销毁计算图以释放内存。然而,由于我们很快将重用这个计算图,我们设置 retain_graph=True 以使其留在内存中。

给定模型参数的损失梯度的结果值是

print(grad_L_w1)
print(grad_L_b)

This prints

(tensor([-0.0898]),)
(tensor([-0.0817]),)

在这里,我们一直在手动使用 grad 函数,这对于实验、调试和展示概念可能很有用。但是,在实践中,PyTorch 提供了更多高级工具来自动化这个过程。例如,我们可以在损失上调用 .backward,PyTorch 将计算图中所有叶节点的梯度,这些梯度将通过张量的 .grad 属性存储:

loss.backward()
print(w1.grad)
print(b.grad)

输出如下

(tensor([-0.0898]),)
(tensor([-0.0817]),)

我已经提供了很多信息,你可能被微积分概念所淹没,但不用担心。虽然这种微积分术语是解释 PyTorch 的 autograd 组件的手段,但你只需要记住 PyTorch 通过 .backward 方法为我们处理微积分——我们不需要手动计算任何导数或梯度。

A.5 实现多层神经网络

接下来,我们将重点关注 PyTorch 作为实现深度神经网络的库。为了提供一个具体的例子,让我们看看一个多层感知器,一个全连接神经网络,如图 A.9 所示。

figure

图 A.9 具有两个隐藏层的多层感知器。每个节点代表相应层中的一个单元。为了说明目的,每个层都有非常少的节点。

当在 PyTorch 中实现神经网络时,我们可以通过继承 torch.nn.Module 类来定义我们自己的自定义网络架构。这个 Module 基类提供了很多功能,使得构建和训练模型变得更加容易。例如,它允许我们封装层和操作,并跟踪模型的参数。

在这个子类中,我们在 __init__ 构造函数中定义网络层,并在 forward 方法中指定层之间的交互方式。forward 方法描述了输入数据如何通过网络,并作为一个计算图汇集在一起。相比之下,我们通常不需要自己实现的 backward 方法,在训练期间用于根据模型参数计算损失函数的梯度(参见 A.7 节)。以下列表中的代码实现了一个具有两个隐藏层的经典多层感知器,以展示 Module 类的典型用法。

列表 A.4 具有两个隐藏层的多层感知器
class NeuralNetwork(torch.nn.Module):
    def __init__(self, num_inputs, num_outputs):    #1
        super().__init__()

        self.layers = torch.nn.Sequential(

            # 1st hidden layer
            torch.nn.Linear(num_inputs, 30),    #2
            torch.nn.ReLU(),               #3

            # 2nd hidden layer
            torch.nn.Linear(30, 20),    #4
            torch.nn.ReLU(),

            # output layer
            torch.nn.Linear(20, num_outputs),
        )

    def forward(self, x):
        logits = self.layers(x)
        return logits           #5

1 将输入和输出编码为变量允许我们为具有不同特征和类数的不同数据集重用相同的代码

2 线性层将输入和输出节点数作为参数。

3 非线性激活函数放置在隐藏层之间。

4 一个隐藏层的输出节点数必须与下一层的输入数相匹配。

5 最后层的输出被称为 logits。

然后,我们可以如下实例化一个新的神经网络对象:

model = NeuralNetwork(50, 3)

在使用这个新的model对象之前,我们可以对模型调用print来查看其结构的摘要:

print(model)

这会打印

NeuralNetwork(
  (layers): Sequential(
    (0): Linear(in_features=50, out_features=30, bias=True)
    (1): ReLU()
    (2): Linear(in_features=30, out_features=20, bias=True)
    (3): ReLU()
    (4): Linear(in_features=20, out_features=3, bias=True)
  )
)

注意,当我们实现NeuralNetwork类时,我们使用Sequential类。Sequential不是必需的,但如果我们要按特定顺序执行一系列层,这可以使我们的工作更简单,就像在这里的情况一样。这样,在__init__构造函数中将self.layers设置为Sequential(...)之后,我们只需要调用self.layers,而不是在NeuralNetworkforward方法中逐个调用每个层。

接下来,让我们检查这个模型的可训练参数总数:

num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("Total number of trainable model parameters:", num_params)

这会打印

Total number of trainable model parameters: 2213

对于每个requires_grad=True的参数,都算作一个可训练参数,将在训练过程中更新(参见第 A.7 节)。

在我们前面两个隐藏层的神经网络模型中,这些可训练参数包含在torch.nn.Linear层中。一个Linear层将输入与权重矩阵相乘并添加一个偏差向量。这有时被称为前馈全连接层。

基于我们在这里执行的print(model)调用,我们可以看到第一个Linear层在layers属性中的索引位置是 0。我们可以如下访问相应的权重参数矩阵:

print(model.layers[0].weight)

这会打印

Parameter containing:
tensor([[ 0.1174, -0.1350, -0.1227,  ...,  0.0275, -0.0520, -0.0192],
        [-0.0169,  0.1265,  0.0255,  ..., -0.1247,  0.1191, -0.0698],
        [-0.0973, -0.0974, -0.0739,  ..., -0.0068, -0.0892,  0.1070],
        ...,
        [-0.0681,  0.1058, -0.0315,  ..., -0.1081, -0.0290, -0.1374],
        [-0.0159,  0.0587, -0.0916,  ..., -0.1153,  0.0700,  0.0770],
        [-0.1019,  0.1345, -0.0176,  ...,  0.0114, -0.0559, -0.0088]],
       requires_grad=True)

由于这个大矩阵没有全部显示,让我们使用.shape属性来显示其维度:

print(model.layers[0].weight.shape)

结果是

torch.Size([30, 50])

(同样,你也可以通过model.layers[0].bias访问偏差向量。)

这里的权重矩阵是一个 30 × 50 的矩阵,我们可以看到requires_grad被设置为True,这意味着它的条目是可训练的——这是torch.nn.Linear中权重和偏差的默认设置。

如果你在你自己的计算机上执行前面的代码,权重矩阵中的数字可能会与显示的不同。模型权重使用小的随机数初始化,每次实例化网络时都不同。在深度学习中,使用小的随机数初始化模型权重是为了在训练期间打破对称性。否则,节点在反向传播期间会执行相同的操作和更新,这不会允许网络从输入到输出学习复杂的映射。

然而,虽然我们希望继续使用小的随机数作为层权重的初始值,但我们可以通过manual_seed对 PyTorch 的随机数生成器进行播种来使随机数初始化可重现:

torch.manual_seed(123)
model = NeuralNetwork(50, 3)
print(model.layers[0].weight)

结果是

Parameter containing:
tensor([[-0.0577,  0.0047, -0.0702,  ...,  0.0222,  0.1260,  0.0865],
        [ 0.0502,  0.0307,  0.0333,  ...,  0.0951,  0.1134, -0.0297],
        [ 0.1077, -0.1108,  0.0122,  ...,  0.0108, -0.1049, -0.1063],
        ...,
        [-0.0787,  0.1259,  0.0803,  ...,  0.1218,  0.1303, -0.1351],
        [ 0.1359,  0.0175, -0.0673,  ...,  0.0674,  0.0676,  0.1058],
        [ 0.0790,  0.1343, -0.0293,  ...,  0.0344, -0.0971, -0.0509]],
       requires_grad=True)

现在我们已经花了一些时间检查了NeuralNetwork实例,让我们简要看看它是如何通过前向传递来使用的:

torch.manual_seed(123)
X = torch.rand((1, 50))
out = model(X)
print(out)

结果是

tensor([[-0.1262,  0.1080, -0.1792]], grad_fn=<AddmmBackward0>)

在前面的代码中,我们生成了一个单一的随机训练示例X作为玩具输入(注意我们的网络期望 50 维的特征向量),并将其输入到模型中,返回三个分数。当我们调用model(x)时,它将自动执行模型的正向传递。

正向传递是指从输入张量计算输出张量的过程。这涉及到将输入数据通过所有神经网络层,从输入层开始,通过隐藏层,最后到输出层。

这三个返回的数字对应于分配给三个输出节点的分数。请注意,输出张量还包括一个grad_fn值。

这里,grad_fn=<AddmmBackward0>表示在计算图中计算变量的最后一个使用的函数。特别是,grad_fn=<AddmmBackward0>意味着我们正在检查的张量是通过矩阵乘法和加法操作创建的。PyTorch 将在反向传播期间计算梯度时使用这些信息。grad_fn=<AddmmBackward0>中的<AddmmBackward0>部分指定了执行的操作。在这种情况下,它是一个Addmm操作。Addmm代表矩阵乘法(mm)后跟加法(Add)。

如果我们只想使用一个网络而不进行训练或反向传播——例如,如果我们使用它进行训练后的预测——构建这个反向传播的计算图可能是浪费的,因为它执行了不必要的计算并消耗了额外的内存。因此,当我们使用模型进行推理(例如,进行预测)而不是训练时,最佳实践是使用torch.no_grad()上下文管理器。这告诉 PyTorch 它不需要跟踪梯度,这可以显著节省内存和计算:

with torch.no_grad():
    out = model(X)
print(out)

结果是

tensor([[-0.1262,  0.1080, -0.1792]])

在 PyTorch 中,常见的做法是编写模型,使其返回最后一层的输出(logits),而不将它们传递给非线性激活函数。这是因为 PyTorch 常用的损失函数将softmax(或二分类中的sigmoid)操作与单个类中的负对数似然损失结合在一起。这样做的原因是数值效率和稳定性。因此,如果我们想计算预测的类成员概率,我们必须显式调用softmax函数:

with torch.no_grad():
    out = torch.softmax(model(X), dim=1)
print(out)

这将打印

tensor([[0.3113, 0.3934, 0.2952]]))

现在可以将这些值解释为类成员概率,它们的总和为 1。对于这个随机输入,这些值大致相等,这是对随机初始化且未经训练的模型所预期的。

A.6 设置高效的数据加载器

在我们能够训练我们的模型之前,我们必须简要讨论在 PyTorch 中创建高效的数据加载器,我们将在训练过程中遍历这些数据加载器。PyTorch 中数据加载的整体思想如图 A.10 所示。

figure

图 A.10 PyTorch 实现了 DatasetDataLoader 类。Dataset 类用于实例化对象,这些对象定义了如何加载每个数据记录。DataLoader 处理数据如何打乱和组装成批次。

在图 A.10 之后,我们将实现一个自定义 Dataset 类,我们将使用它来创建一个训练集和一个测试集,然后我们将使用这些数据集来创建数据加载器。让我们先创建一个包含五个训练示例的简单玩具数据集,每个示例有两个特征。与训练示例一起,我们还创建了一个包含相应类标签的张量:三个示例属于类别 0,两个示例属于类别 1。此外,我们还创建了一个包含两个条目的测试集。创建此数据集的代码如下所示。

列表 A.5 创建一个小型玩具数据集
X_train = torch.tensor([
    [-1.2, 3.1],
    [-0.9, 2.9],
    [-0.5, 2.6],
    [2.3, -1.1],
    [2.7, -1.5]
])
y_train = torch.tensor([0, 0, 0, 1, 1])

X_test = torch.tensor([
    [-0.8, 2.8],
    [2.6, -1.6],
])
y_test = torch.tensor([0, 1])

注意:PyTorch 要求类标签从 0 开始,最大的类标签值不应超过输出节点数减 1(因为 Python 索引计数从零开始)。所以,如果我们有类标签 0、1、2、3 和 4,神经网络输出层应该由五个节点组成。

接下来,我们通过从 PyTorch 的 Dataset 父类派生,创建一个自定义数据集类 ToyDataset,如下所示。

列表 A.6 定义自定义 Dataset
from torch.utils.data import Dataset

class ToyDataset(Dataset):
    def __init__(self, X, y):
        self.features = X
        self.labels = y

    def __getitem__(self, index):        #1
        one_x = self.features[index]     #1
        one_y = self.labels[index]       #1
        return one_x, one_y              #1

    def __len__(self):
        return self.labels.shape[0]      #2

train_ds = ToyDataset(X_train, y_train)
test_ds = ToyDataset(X_test, y_test)

1 检索一个数据记录及其对应标签的说明

2 返回数据集总长度的说明

这个自定义 ToyDataset 类的目的是实例化一个 PyTorch DataLoader。但在我们到达这一步之前,让我们简要地回顾一下 ToyDataset 代码的一般结构。

在 PyTorch 中,自定义 Dataset 类的三个主要组件是 __init__ 构造函数、__getitem__ 方法以及 __len__ 方法(见列表 A.6)。在 __init__ 方法中,我们设置可以在后续的 __getitem____len__ 方法中访问的属性。这些可能是文件路径、文件对象、数据库连接器等等。由于我们创建了一个驻留在内存中的张量数据集,我们只需将这些属性分配给 Xy,它们是我们张量对象的占位符。

__getitem__ 方法中,我们定义了通过 index 返回数据集中一个项目的说明。这指的是与单个训练示例或测试实例对应的特征和类标签。(数据加载器将提供这个 index,我们将在稍后介绍。)

最后,__len__ 方法包含了检索数据集长度的说明。在这里,我们使用张量的 .shape 属性来返回特征数组中的行数。在训练数据集的情况下,我们有五行,我们可以进行双重检查:

print(len(train_ds))

结果是

5

现在我们已经定义了一个可以用于我们的玩具数据集的 PyTorch Dataset 类,我们可以使用 PyTorch 的 DataLoader 类从中采样,如下所示。

列表 A.7 实例化数据加载器
from torch.utils.data import DataLoader

torch.manual_seed(123)

train_loader = DataLoader(
    dataset=train_ds,     #1
    batch_size=2,
    shuffle=True,          #2
    num_workers=0     #3
)

test_loader = DataLoader(
    dataset=test_ds,
    batch_size=2,
    shuffle=False,     #4
    num_workers=0
)

1 之前创建的 ToyDataset 实例作为数据加载器的输入。

2 是否洗牌数据

3 背景进程的数量

4 测试数据集不需要洗牌。

在实例化训练数据加载器之后,我们可以遍历它。对test_loader的遍历方式类似,但为了简洁起见省略了:

for idx, (x, y) in enumerate(train_loader):
    print(f"Batch {idx+1}:", x, y)

结果是

Batch 1: tensor([[-1.2000,  3.1000],
                 [-0.5000,  2.6000]]) tensor([0, 0])
Batch 2: tensor([[ 2.3000, -1.1000],
                 [-0.9000,  2.9000]]) tensor([1, 0])
Batch 3: tensor([[ 2.7000, -1.5000]]) tensor([1])

根据前面的输出,我们可以看到train_loader遍历训练数据集,每个训练示例恰好访问一次。这被称为一个训练周期。由于我们在这里使用torch.manual_seed(123)初始化了随机数生成器,你应该得到相同的训练示例洗牌顺序。然而,如果你第二次遍历数据集,你会看到洗牌顺序会改变。这是期望的,以防止深度神经网络在训练过程中陷入重复的更新循环。

在这里,我们指定了批大小为 2,但第三个批次只包含一个示例。这是因为我们有五个训练示例,而 5 不能被 2 整除。在实际应用中,如果训练周期中的最后一个批次显著较小,可能会在训练过程中干扰收敛。为了防止这种情况,设置drop_last=True,这将丢弃每个周期中的最后一个批次,如下面的列表所示。

列表 A.8 一个丢弃最后一个批次的训练加载器
train_loader = DataLoader(
    dataset=train_ds,
    batch_size=2,
    shuffle=True,
    num_workers=0,
    drop_last=True
)

现在,遍历训练加载器,我们可以看到最后一个批次被省略了:

for idx, (x, y) in enumerate(train_loader):
    print(f"Batch {idx+1}:", x, y)

结果是

Batch 1: tensor([[-0.9000,  2.9000],
        [ 2.3000, -1.1000]]) tensor([0, 1])
Batch 2: tensor([[ 2.7000, -1.5000],
        [-0.5000,  2.6000]]) tensor([1, 0])

最后,让我们讨论DataLoader中的设置num_workers=0。在 PyTorch 的DataLoader函数中,此参数对于并行化数据加载和预处理至关重要。当num_workers设置为 0 时,数据加载将在主进程中完成,而不是在单独的工作进程中。这看起来可能没有问题,但当我们在大 GPU 上训练更大的网络时,它可能导致模型训练期间出现显著的减速。在这种情况下,CPU 必须花费时间来加载和预处理数据,而不是仅仅关注深度学习模型的处理。因此,GPU 可能会空闲等待 CPU 完成这些任务。相比之下,当num_workers设置为大于 0 的数字时,会启动多个工作进程以并行加载数据,从而让主进程专注于训练你的模型,并更好地利用系统资源(图 A.11)。

图

图 A.11 在没有多个工作进程(设置num_workers=0)的情况下加载数据将创建一个数据加载瓶颈,其中模型处于空闲状态,直到加载下一个批次(左侧)。如果启用了多个工作进程,数据加载器可以在后台排队下一个批次(右侧)。

然而,如果我们处理的是非常小的数据集,将num_workers设置为 1 或更大可能不是必要的,因为总训练时间只需要几毫秒。所以,如果你处理的是小型数据集或 Jupyter 笔记本等交互式环境,增加num_workers可能不会带来任何明显的加速。实际上,它可能引起一些问题。一个潜在的问题是启动多个工作进程的开销,当数据集较小时,这可能会比实际的数据加载时间更长。

此外,对于 Jupyter 笔记本,将num_workers设置为大于 0 有时会导致与不同进程之间资源共享相关的问题,从而引起错误或笔记本崩溃。因此,理解权衡并就设置num_workers参数做出计算决策至关重要。当正确使用时,它可以是一个有益的工具,但应该根据您特定的数据集大小和计算环境进行调整以获得最佳结果。

根据我的经验,将num_workers设置为 4 通常在许多实际数据集上能带来最佳性能,但最佳设置取决于您的硬件以及用于加载Dataset类中定义的训练示例的代码。

A.7 典型的训练循环

现在我们将在玩具数据集上训练一个神经网络。以下列表展示了训练代码。

列表 A.9 PyTorch 中的神经网络训练
import torch.nn.functional as F

torch.manual_seed(123)
model = NeuralNetwork(num_inputs=2, num_outputs=2)    #1
optimizer = torch.optim.SGD(
    model.parameters(), lr=0.5
)            #2

num_epochs = 3
for epoch in range(num_epochs): 

    model.train()
    for batch_idx, (features, labels) in enumerate(train_loader):
        logits = model(features)

        loss = F.cross_entropy(logits, labels)

        optimizer.zero_grad()            #3
        loss.backward()         #4
        optimizer.step()        #5

        ### LOGGING
        print(f"Epoch: {epoch+1:03d}/{num_epochs:03d}"
              f" | Batch {batch_idx:03d}/{len(train_loader):03d}"
              f" | Train Loss: {loss:.2f}")

    model.eval()
    # Insert optional model evaluation code

1 数据集有两个特征和两个类别。

2 优化器需要知道要优化的参数。

3 将上一轮的梯度设置为 0,以防止意外的梯度累积

4 根据模型参数计算损失梯度

5 优化器使用梯度来更新模型参数。

运行此代码会产生以下输出:

Epoch: 001/003 | Batch 000/002 | Train Loss: 0.75
Epoch: 001/003 | Batch 001/002 | Train Loss: 0.65
Epoch: 002/003 | Batch 000/002 | Train Loss: 0.44
Epoch: 002/003 | Batch 001/002 | Trainl Loss: 0.13
Epoch: 003/003 | Batch 000/002 | Train Loss: 0.03
Epoch: 003/003 | Batch 001/002 | Train Loss: 0.00

如我们所见,损失在三个 epoch 后达到 0,这是模型在训练集上收敛的标志。在这里,我们初始化了一个有两个输入和两个输出的模型,因为我们的玩具数据集有两个输入特征和两个类别标签需要预测。我们使用了一个学习率(lr)为 0.5 的随机梯度下降(SGD)优化器。学习率是一个超参数,意味着它是一个可调整的设置,我们必须根据观察损失进行实验。理想情况下,我们希望选择一个学习率,使得损失在经过一定数量的 epoch 后收敛——epoch 的数量是另一个需要选择的超参数。

练习 A.3

列表 A.9 中引入的神经网络有多少个参数?

实际上,我们经常使用第三个数据集,即所谓的验证数据集,以找到最佳的超参数设置。验证数据集类似于测试集。然而,我们只想精确使用一次测试集以避免评估偏差,我们通常多次使用验证集来调整模型设置。

我们还引入了新的设置,称为model.train()model.eval()。正如这些名称所暗示的,这些设置用于将模型置于训练和评估模式。这对于在训练和推理期间表现不同的组件是必要的,例如dropout批归一化层。由于我们的NeuralNetwork类中没有受这些设置影响的 dropout 或其他组件,因此在前面的代码中使用model.train()model.eval()是多余的。然而,最好的做法是仍然包含它们,以避免在更改模型架构或重用代码来训练不同模型时出现意外行为。

如前所述,我们直接将 logits 传递到cross_entropy损失函数中,该函数将内部应用softmax函数以提高效率和数值稳定性。然后,调用loss.backward()将在 PyTorch 在后台构建的计算图中计算梯度。optimizer.step()方法将使用梯度来更新模型参数以最小化损失。对于 SGD 优化器来说,这意味着将梯度乘以学习率并将缩放后的负梯度加到参数上。

注意:为了防止不希望的梯度累积,在每个更新轮次中包含一个optimizer.zero_grad()调用以将梯度重置为 0 是非常重要的。否则,梯度将累积,这可能是我们不希望的。

在我们训练了模型之后,我们可以使用它来进行预测:

model.eval()
with torch.no_grad():
    outputs = model(X_train)
print(outputs)

结果是

tensor([[ 2.8569, -4.1618],
        [ 2.5382, -3.7548],
        [ 2.0944, -3.1820],
        [-1.4814,  1.4816],
        [-1.7176,  1.7342]])

要获得类别成员概率,我们然后可以使用 PyTorch 的softmax函数:

torch.set_printoptions(sci_mode=False)
probas = torch.softmax(outputs, dim=1)
print(probas)

这会输出

tensor([[    0.9991,     0.0009],
        [    0.9982,     0.0018],
        [    0.9949,     0.0051],
        [    0.0491,     0.9509],
        [    0.0307,     0.9693]])

让我们考虑前面代码输出的第一行。在这里,第一个值(列)表示训练示例有 99.91%的概率属于类别 0,有 0.09%的概率属于类别 1。(在这里使用set_printoptions调用是为了使输出更易读。)

我们可以使用 PyTorch 的argmax函数将这些值转换为类别标签预测,如果我们设置dim=1(设置dim=0将返回每列的最高值):

predictions = torch.argmax(probas, dim=1)
print(predictions)

这会打印

tensor([0, 0, 0, 1, 1])

注意,为了获得类别标签,不需要计算softmax概率。我们也可以直接将argmax函数应用于 logits(输出):

predictions = torch.argmax(outputs, dim=1)
print(predictions)

输出是

tensor([0, 0, 0, 1, 1])

在这里,我们计算了训练数据集的预测标签。由于训练数据集相对较小,我们可以通过肉眼将其与真实的训练标签进行比较,并看到模型是 100%正确的。我们可以使用==比较运算符来双重检查:

predictions == y_train

结果是

tensor([True, True, True, True, True])

使用torch.sum,我们可以计算正确预测的数量:

torch.sum(predictions == y_train)

输出是

5

由于数据集由五个训练示例组成,我们有五个预测全部正确,这表示 5/5 × 100% = 100%的预测准确率。

为了泛化预测准确度的计算,让我们实现一个 compute_accuracy 函数,如下所示。

列表 A.10 计算预测准确度的函数
def compute_accuracy(model, dataloader):

    model = model.eval()
    correct = 0.0
    total_examples = 0

    for idx, (features, labels) in enumerate(dataloader):

        with torch.no_grad():
            logits = model(features)

        predictions = torch.argmax(logits, dim=1)
        compare = labels == predictions       #1
        correct += torch.sum(compare)      #2
        total_examples += len(compare)

    return (correct / total_examples).item()    #3

1 根据标签是否匹配返回 True/False 值的张量

2 求和操作计算 True 值的数量。

3 正确预测的比例,一个介于 0 和 1 之间的值. .item() 返回张量的值作为 Python 浮点数。

代码遍历数据加载器以计算正确预测的数量和比例。当我们处理大型数据集时,通常由于内存限制,我们只能对数据集的一小部分调用模型。这里的 compute_accuracy 函数是一个通用方法,可以扩展到任意大小的数据集,因为在每次迭代中,模型接收到的数据集块的大小与训练期间看到的批量大小相同。compute_accuracy 函数的内部结构与我们在将 logits 转换为类别标签时使用的方法类似。

然后,我们可以将函数应用于训练:

print(compute_accuracy(model, train_loader))

结果是

1.0

同样,我们可以将函数应用于测试集:

print(compute_accuracy(model, test_loader))

这将打印

1.0

A.8 保存和加载模型

既然我们已经训练了我们的模型,让我们看看如何保存它,以便以后可以重用。以下是使用 PyTorch 保存和加载模型的推荐方法:

torch.save(model.state_dict(), "model.pth")

模型的 state_dict 是一个映射模型中的每个层到其可训练参数(权重和偏差)的 Python 字典对象。"model.pth" 是保存到磁盘的模型文件的任意名称。我们可以给它任何我们喜欢的名称和文件扩展名;然而,.pth.pt 是最常见的约定。

保存模型后,我们可以从磁盘恢复它:

model = NeuralNetwork(2, 2) 
model.load_state_dict(torch.load("model.pth"))

torch.load("model.pth") 函数读取文件 "model.pth" 并重建包含模型参数的 Python 字典对象,而 model.load_state_dict() 将这些参数应用于模型,有效地从我们保存它的状态中恢复其学习状态。

这行 model = NeuralNetwork(2, 2) 如果你在保存模型的同一会话中执行此代码,则不是严格必要的。然而,我将其包括在这里,以说明我们需要在内存中有一个模型的实例来应用保存的参数。在这里,NeuralNetwork(2, 2) 架构需要与原始保存的模型完全匹配。

A.9 使用 GPU 优化训练性能

接下来,让我们看看如何利用 GPU,与常规 CPU 相比,GPU 可以加速深度神经网络训练。首先,我们将探讨 PyTorch 中 GPU 计算背后的主要概念。然后,我们将在单个 GPU 上训练一个模型。最后,我们将探讨使用多个 GPU 的分布式训练。

A.9.1 在 GPU 设备上执行 PyTorch 计算

将训练循环修改为可选地在 GPU 上运行相对简单,只需更改三行代码(参见 A.7 节)。在我们进行修改之前,理解 PyTorch 中 GPU 计算背后的主要概念至关重要。在 PyTorch 中,设备是计算发生和数据驻留的地方。CPU 和 GPU 是设备的例子。PyTorch 张量位于设备中,其操作在相同的设备上执行。

让我们看看这个在实际操作中是如何工作的。假设你已经安装了与 GPU 兼容的 PyTorch 版本(参见 A.1.3 节),我们可以通过以下代码来双重检查我们的运行时是否确实支持 GPU 计算:

print(torch.cuda.is_available())

结果是

True

现在,假设我们有两个可以相加的张量;这个计算默认将在 CPU 上执行:

tensor_1 = torch.tensor([1., 2., 3.])
tensor_2 = torch.tensor([4., 5., 6.])
print(tensor_1 + tensor_2)

这将输出

tensor([5., 7., 9.])

我们现在可以使用.to()方法。这个方法与我们用来更改张量数据类型(参见 2.2.2 节)的方法相同,用于将这些张量传输到 GPU 并执行加法操作:

tensor_1 = tensor_1.to("cuda")
tensor_2 = tensor_2.to("cuda")
print(tensor_1 + tensor_2)

输出是

tensor([5., 7., 9.], device='cuda:0')

结果张量现在包含了设备信息,device='cuda:0',这意味着张量位于第一块 GPU 上。如果你的机器有多个 GPU,你可以指定你希望将张量传输到哪个 GPU。你可以通过在传输命令中指定设备 ID 来实现这一点。例如,你可以使用.to("cuda:0").to("cuda:1")等等。

然而,所有张量必须在同一设备上。否则,计算将失败,其中一个张量位于 CPU 上,另一个位于 GPU 上:

tensor_1 = tensor_1.to("cpu")
print(tensor_1 + tensor_2)

结果是

RuntimeError      Traceback (most recent call last)
<ipython-input-7-4ff3c4d20fc3> in <cell line: 2>()
      1 tensor_1 = tensor_1.to("cpu")
----> 2 print(tensor_1 + tensor_2)
RuntimeError: Expected all tensors to be on the same device, but found at
least two devices, cuda:0 and cpu!

总结来说,我们只需要将张量传输到相同的 GPU 设备,PyTorch 将处理其余部分。

A.9.2 单 GPU 训练

现在我们熟悉了将张量传输到 GPU 的过程,我们可以修改训练循环以在 GPU 上运行。这一步只需要更改三行代码,如下所示。

列表 A.11 GPU 上的训练循环
torch.manual_seed(123)
model = NeuralNetwork(num_inputs=2, num_outputs=2)

device = torch.device("cuda")      #1
model = model.to(device)          #2

optimizer = torch.optim.SGD(model.parameters(), lr=0.5)

num_epochs = 3

for epoch in range(num_epochs):

    model.train()
    for batch_idx, (features, labels) in enumerate(train_loader):
        features, labels = features.to(device), labels.to(device)   #3
        logits = model(features)
        loss = F.cross_entropy(logits, labels) # Loss function

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        ### LOGGING
        print(f"Epoch: {epoch+1:03d}/{num_epochs:03d}"
              f" | Batch {batch_idx:03d}/{len(train_loader):03d}"
              f" | Train/Val Loss: {loss:.2f}")

    model.eval()
    # Insert optional model evaluation code

1 定义一个默认为 GPU 的设备变量

2 将模型传输到 GPU

3 将数据传输到 GPU

运行前面的代码将输出以下内容,类似于在 CPU 上获得的结果(参见 A.7 节):

Epoch: 001/003 | Batch 000/002 | Train/Val Loss: 0.75
Epoch: 001/003 | Batch 001/002 | Train/Val Loss: 0.65
Epoch: 002/003 | Batch 000/002 | Train/Val Loss: 0.44
Epoch: 002/003 | Batch 001/002 | Train/Val Loss: 0.13
Epoch: 003/003 | Batch 000/002 | Train/Val Loss: 0.03
Epoch: 003/003 | Batch 001/002 | Train/Val Loss: 0.00

我们可以使用.to("cuda")而不是device = torch.device("cuda")。将张量传输到"cuda"而不是torch.device("cuda")同样有效,并且更简洁(参见 A.9.1 节)。我们还可以修改语句,这样相同的代码在没有 GPU 的情况下也能在 CPU 上执行。这是共享 PyTorch 代码时的最佳实践:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

在这里修改后的训练循环的情况下,我们可能不会看到由于 CPU 到 GPU 的内存传输成本而带来的加速。然而,当训练深度神经网络,尤其是 LLMs 时,我们可以期待一个显著的加速。

PyTorch on macOS

在配备苹果硅芯片(如 M1、M2、M3 或更新的型号)的苹果 Mac 上,而不是配备 Nvidia GPU 的计算机上,你可以更改

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

to

device = torch.device(
    "mps" if torch.backends.mps.is_available() else "cpu"
)

以利用这个芯片。

练习 A.4

将 CPU 上矩阵乘法的运行时间与 GPU 上的运行时间进行比较。在什么矩阵大小下,你开始看到 GPU 上的矩阵乘法比 CPU 上的更快?提示:使用 Jupyter 中的%timeit命令来比较运行时间。例如,给定矩阵ab,在新的笔记本单元中运行命令%timeit a @ b

A.9.3 使用多个 GPU 进行训练

分布式训练是将模型训练分布在多个 GPU 和机器上的概念。为什么我们需要这样做?即使可以在单个 GPU 或机器上训练模型,这个过程也可能非常耗时。通过将训练过程分布在多个机器上,每个机器可能配备多个 GPU,可以显著减少训练时间。这在模型开发的实验阶段尤为重要,在该阶段可能需要进行多次训练迭代来微调模型参数和架构。

注意:对于本书,不需要访问或使用多个 GPU。本节包含在此处是为了让那些对 PyTorch 中多 GPU 计算如何工作感兴趣的人。

让我们从分布式训练的最基本案例开始:PyTorch 的DistributedDataParallel(DDP)策略。DDP 通过将输入数据分割到可用的设备上并同时处理这些数据子集来实现并行化。

这是如何工作的?PyTorch 在每个 GPU 上启动一个单独的进程,并且每个进程接收并保留模型的一个副本;这些副本将在训练过程中同步。为了说明这一点,假设我们有两个 GPU,我们想用它们来训练一个神经网络,如图 A.12 所示。

图

图 A.12 DDP 中的模型和数据传输涉及两个关键步骤。首先,我们在每个 GPU 上创建模型的副本。然后我们将输入数据划分为唯一的 minibatch,并将它们传递给每个模型副本。

两个 GPU 中的每一个都将接收到模型的一个副本。然后,在每次训练迭代中,每个模型将从数据加载器接收一个 minibatch(或简称“batch”)。我们可以使用DistributedSampler来确保在 DDP 中使用时,每个 GPU 将接收到不同且不重叠的批次。

由于每个模型副本将看到不同的训练数据样本,因此模型副本将返回不同的 logits 作为输出,并在反向传播期间计算不同的梯度。然后,这些梯度在训练过程中被平均并同步,以更新模型。这样,我们确保模型不会发散,如图 A.13 所示。

图

图 A.13 在 DDP 中,正向和反向传播在每个 GPU 上独立执行,并处理其对应的数据子集。一旦正向和反向传播完成,每个模型副本(在每个 GPU 上)的梯度将在所有 GPU 之间同步。这确保了每个模型副本都有相同的更新权重。

使用 DDP 的好处是它提供的处理数据集的速度比单个 GPU 更快。除了 DDP 使用时设备之间附带的一点点通信开销外,理论上,使用两个 GPU 可以在一半的时间内处理一个训练周期,而只用一个 GPU 则不行。时间效率随着 GPU 数量的增加而提高,如果我们有八个 GPU,我们可以将一个周期处理得快八倍,依此类推。

注意:DDP 在像 Jupyter 笔记本这样的交互式 Python 环境中无法正常工作,因为这些环境没有像独立 Python 脚本那样处理多进程。因此,以下代码应作为脚本执行,而不是在 Jupyter 这样的笔记本界面中执行。DDP 需要生成多个进程,并且每个进程都应该有自己的 Python 解释器实例。

现在我们来看看这在实践中是如何工作的。为了简洁起见,我专注于需要调整以进行 DDP 训练的代码的核心部分。然而,那些想在他们的多 GPU 机器或他们选择的云实例上运行代码的读者应使用本书 GitHub 仓库中提供的独立脚本,网址为github.com/rasbt/LLMs-from-scratch

首先,我们导入了一些用于分布式训练的 PyTorch 的附加子模块、类和函数,如下所示。

列表 A.12 PyTorch 分布式训练实用工具
import torch.multiprocessing as mp
from torch.utils.data.distributed import DistributedSampler
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.distributed import init_process_group, destroy_process_group

在我们深入探讨使训练与 DDP 兼容的更改之前,让我们简要地回顾一下这些新导入的实用工具的原理和用法,这些实用工具是我们需要与DistributedDataParallel类一起使用的。

PyTorch 的multiprocessing子模块包含multiprocessing.spawn等函数,我们将使用这些函数来生成多个进程,并将函数并行应用于多个输入。我们将用它来为每个 GPU 生成一个训练进程。如果我们为训练生成多个进程,我们需要一种方法将这些不同的进程中的数据集划分。为此,我们将使用DistributedSampler

init_process_groupdestroy_process_group用于初始化和退出分布式训练模块。应在训练脚本的开始处调用init_process_group函数来初始化分布式设置中每个进程的过程组,并在训练脚本的末尾调用destroy_process_group来销毁给定过程组并释放其资源。以下列表中的代码说明了如何使用这些新组件来实现我们之前实现的NeuralNetwork模型的 DDP 训练。

列表 A.13 使用DistributedDataParallel策略进行模型训练
def ddp_setup(rank, world_size):
    os.environ["MASTER_ADDR"] = "localhost"    #1
    os.environ["MASTER_PORT"] = "12345"      #2
    init_process_group(
        backend="nccl",              #3
        rank=rank,                         #4
        world_size=world_size            #5
    )
    torch.cuda.set_device(rank)        #6

def prepare_dataset():
    # insert dataset preparation code 
    train_loader = DataLoader(
        dataset=train_ds,
        batch_size=2,
        shuffle=False,             #7
        pin_memory=True,           #8
        drop_last=True,
        sampler=DistributedSampler(train_ds)    #9
    )    
    return train_loader, test_loader

def main(rank, world_size, num_epochs):       #10
    ddp_setup(rank, world_size)
    train_loader, test_loader = prepare_dataset()
    model = NeuralNetwork(num_inputs=2, num_outputs=2)
    model.to(rank)
    optimizer = torch.optim.SGD(model.parameters(), lr=0.5)
    model = DDP(model, device_ids=[rank])
    for epoch in range(num_epochs):
        train_loader.sampler.set_epoch(epoch)
        model.train()
        for features, labels in train_loader:
            features, labels = features.to(rank), labels.to(rank)      #11
            # insert model prediction and backpropagation code 
            print(f"[GPU{rank}] Epoch: {epoch+1:03d}/{num_epochs:03d}"
                  f" | Batchsize {labels.shape[0]:03d}"
                  f" | Train/Val Loss: {loss:.2f}")

    model.eval()
    train_acc = compute_accuracy(model, train_loader, device=rank)
    print(f"[GPU{rank}] Training accuracy", train_acc)
    test_acc = compute_accuracy(model, test_loader, device=rank)
    print(f"[GPU{rank}] Test accuracy", test_acc)
    destroy_process_group()                      #12

if __name__ == "__main__":
    print("Number of GPUs available:", torch.cuda.device_count())
    torch.manual_seed(123)
    num_epochs = 3
    world_size = torch.cuda.device_count()
    mp.spawn(main, args=(world_size, num_epochs), nprocs=world_size)  #13

1 主节点的地址

2 机器上的任何空闲端口

3 nccl 代表 NVIDIA 集体通信库。

4 rank 指的是我们想要使用的 GPU 的索引。

5 world_size 表示要使用的 GPU 数量。

6 设置当前 GPU 设备,在该设备上分配张量并执行操作

7 分布式-Sampler 现在负责打乱。

8 在 GPU 上训练时,启用更快的内存传输

9 将数据集分割成每个进程(GPU)的独立、不重叠的子集

10 运行模型训练的主函数

11 rank 是 GPU ID

12 清理资源分配

13 使用多个进程启动主函数,其中 nprocs=world_size 表示每个 GPU 一个进程。

在运行此代码之前,让我们总结一下它的工作原理,以及前面的注释。我们在底部有一个 __name__ == "__main__" 子句,包含当我们以 Python 脚本的形式运行代码而不是将其作为模块导入时执行的代码。此代码首先使用 torch.cuda.device_count() 打印可用的 GPU 数量,设置随机种子以确保可重复性,然后使用 PyTorch 的 multiprocessing.spawn 函数启动新进程。在这里,spawn 函数为每个 GPU 启动一个进程,设置 nprocs=world_size,其中世界大小是可用的 GPU 数量。此 spawn 函数使用通过 args 提供的一些额外参数在同一个脚本中定义的 main 函数启动代码。请注意,main 函数有一个 rank 参数,我们不包括在 mp.spawn() 调用中。这是因为 rank,它指的是我们用作 GPU ID 的进程 ID,已经自动传递。

main 函数通过 ddp_setup(我们定义的另一个函数)设置分布式环境,加载训练和测试集,设置模型,并执行训练。与单 GPU 训练(第 A.9.2 节)相比,我们现在通过 .to(rank) 将模型和数据传输到目标设备,我们使用 rank 来引用 GPU 设备 ID。此外,我们通过 DDP 包装模型,这使不同 GPU 在训练期间能够同步梯度。训练完成后,我们评估模型后,使用 destroy_process_group() 清理分布式训练并释放分配的资源。

我之前提到每个 GPU 将接收到训练数据的不同子样本。为了确保这一点,我们在训练加载器中设置 sampler=DistributedSampler(train_ds)

最后要讨论的函数是 ddp_setup。它设置主节点的地址和端口,以便不同进程之间的通信,使用 NCCL 后端(专为 GPU 到 GPU 通信设计)初始化进程组,并设置 rank(进程标识符)和世界大小(进程总数)。最后,它指定与当前模型训练进程 rank 对应的 GPU 设备。

在多 GPU 机器上选择可用的 GPU

如果您希望限制在多 GPU 机器上用于训练的 GPU 数量,最简单的方法是使用 CUDA_VISIBLE_DEVICES 环境变量。为了说明这一点,假设您的机器有多个 GPU,您只想使用一个 GPU——例如,索引为 0 的 GPU。您可以从终端运行以下代码而不是 python some_script.py

CUDA_VISIBLE_DEVICES=0 python some_script.py

或者,如果您的机器有四个 GPU,您只想使用第一个和第三个 GPU,您可以使用

CUDA_VISIBLE_DEVICES=0,2 python some_script.py

以这种方式设置 CUDA_VISIBLE_DEVICES 是一种简单有效的方法来管理 GPU 分配,而无需修改您的 PyTorch 脚本。

现在,让我们运行此代码,通过从终端作为脚本启动代码来实际查看其工作情况:

python ch02-DDP-script.py

注意,它应该在单 GPU 和多 GPU 机器上都能工作。如果我们在这个单 GPU 上运行此代码,我们应该看到以下输出:

PyTorch version: 2.2.1+cu117
CUDA available: True
Number of GPUs available: 1
[GPU0] Epoch: 001/003 | Batchsize 002 | Train/Val Loss: 0.62
[GPU0] Epoch: 001/003 | Batchsize 002 | Train/Val Loss: 0.32
[GPU0] Epoch: 002/003 | Batchsize 002 | Train/Val Loss: 0.11
[GPU0] Epoch: 002/003 | Batchsize 002 | Train/Val Loss: 0.07
[GPU0] Epoch: 003/003 | Batchsize 002 | Train/Val Loss: 0.02
[GPU0] Epoch: 003/003 | Batchsize 002 | Train/Val Loss: 0.03
[GPU0] Training accuracy 1.0
[GPU0] Test accuracy 1.0

代码输出与使用单个 GPU 的输出类似(第 A.9.2 节),这是一个很好的合理性检查。

现在,如果我们在一个具有两个 GPU 的机器上运行相同的命令和代码,我们应该看到以下输出:

PyTorch version: 2.2.1+cu117
CUDA available: True
Number of GPUs available: 2
[GPU1] Epoch: 001/003 | Batchsize 002 | Train/Val Loss: 0.60
[GPU0] Epoch: 001/003 | Batchsize 002 | Train/Val Loss: 0.59
[GPU0] Epoch: 002/003 | Batchsize 002 | Train/Val Loss: 0.16
[GPU1] Epoch: 002/003 | Batchsize 002 | Train/Val Loss: 0.17
[GPU0] Epoch: 003/003 | Batchsize 002 | Train/Val Loss: 0.05
[GPU1] Epoch: 003/003 | Batchsize 002 | Train/Val Loss: 0.05
[GPU1] Training accuracy 1.0
[GPU0] Training accuracy 1.0
[GPU1] Test accuracy 1.0
[GPU0] Test accuracy 1.0

如预期,我们可以看到一些批次在第一个 GPU (GPU0) 上处理,而其他批次在第二个 (GPU1) 上处理。然而,当打印训练和测试准确率时,我们看到了重复的输出行。每个进程(换句话说,每个 GPU)独立地打印测试准确率。由于 DDP 将模型复制到每个 GPU,并且每个进程独立运行,如果您在测试循环中有一个打印语句,每个进程都会执行它,导致重复的输出行。如果您觉得这很麻烦,您可以使用每个进程的秩来控制您的打印语句:

if rank == 0:                  #1
    print("Test accuracy: ", accuracy)

1 只在第一个进程中打印

简而言之,这就是通过 DDP 进行分布式训练的工作方式。如果您对更多细节感兴趣,我建议您查看官方 API 文档,网址为 mng.bz/9dPr

多 GPU 训练的替代 PyTorch API

如果您更喜欢在 PyTorch 中使用多个 GPU 的更直接的方法,您可以考虑附加 API,如开源的 Fabric 库。我在“加速 PyTorch 模型训练:使用混合精度和完全分片数据并行”一文中提到了它(mng.bz/jXle)。

摘要

  • PyTorch 是一个开源库,包含三个核心组件:张量库、自动微分函数和深度学习工具。

  • PyTorch 的张量库类似于 NumPy 等数组库。

  • 在 PyTorch 的上下文中,张量是表示标量、向量、矩阵和更高维数组的类似数组的结构。

  • PyTorch 张量可以在 CPU 上执行,但 PyTorch 张量格式的一个主要优势是其对 GPU 的支持,可以加速计算。

  • PyTorch 中的自动微分(autograd)功能使我们能够方便地使用反向传播训练神经网络,而无需手动推导梯度。

  • PyTorch 中的深度学习工具提供了创建自定义深度神经网络的构建块。

  • PyTorch 包含 DatasetDataLoader 类来设置高效的数据加载管道。

  • 在 CPU 或单个 GPU 上训练模型最为简单。

  • 如果有多个 GPU 可用,使用 DistributedDataParallel 是 PyTorch 中加速训练的最简单方法。

附录 B 参考文献及进一步阅读

第一章

如彭博团队通过从零开始预训练金融数据的 GPT 版本所展示,定制构建的 LLM 能够超越通用 LLM,同时在通用 LLM 基准测试中保持良好的性能:

现有的 LLM 可以进行适配和微调,以超越通用 LLM,谷歌研究和谷歌 DeepMind 团队在医疗环境中展示了这一点:

  • Singhal 等人撰写的《“使用大型语言模型实现专家级医学问答”》(2023),arxiv.org/abs/2305.09617

以下论文提出了原始的 Transformer 架构:

关于原始编码器风格的 Transformer,即 BERT,请参阅

  • Devlin 等人撰写的《“BERT:用于语言理解的深度双向 Transformer 的预训练”》(2018),arxiv.org/abs/1810.04805

描述解码器风格的 GPT-3 模型的论文,该模型启发了现代 LLM,并将作为本书中从头实现 LLM 的模板,该论文是

以下涵盖了用于图像分类的原版视觉 Transformer,这表明 Transformer 架构不仅限于文本输入:

  • Dosovitskiy 等人撰写的《“一张图片等于 16x16 个单词:大规模图像识别的 Transformer”》(2020),arxiv.org/abs/2010.11929

以下实验(但不太流行)的 LLM 架构作为例子,说明并非所有 LLM 都必须基于 Transformer 架构:

  • Peng 等人撰写的《“RWKV:为 Transformer 时代重新发明 RNN”》(2023),arxiv.org/abs/2305.13048

  • Poli 等人撰写的《“鬣狗等级:向更大型的卷积语言模型迈进”》(2023),arxiv.org/abs/2302.10866

  • Gu 和 Dao 撰写的《“Mamba:具有选择性状态空间的线性时间序列建模”》(2023),arxiv.org/abs/2312.00752

Meta AI 的模型是 GPT 类模型的一种流行实现,与 GPT-3 和 ChatGPT 相比,它是公开可用的:

对于对第 1.5 节中数据集引用的详细信息感兴趣的读者,本文描述了由 Eleuther AI 精心整理的公开可用 The Pile 数据集:

  • “The Pile:用于语言建模的 800GB 多样化文本数据集”(2020)由 Gao 等人所著,arxiv.org/abs/2101.00027

以下论文提供了 InstructGPT 微调 GPT-3 的参考,这在第 1.6 节中提到,将在第七章中更详细地讨论:

第二章

对于那些对讨论和比较嵌入空间与潜在空间以及向量表示的通用概念感兴趣的读者,可以在我的书的第一章中找到更多信息:

以下论文更深入地讨论了字节对编码作为分词方法的使用:

  • “使用子词单元进行罕见词的神经机器翻译”(2015)由 Sennrich 等人所著,arxiv.org/abs/1508.07909

OpenAI 开源了用于训练 GPT-2 的字节对编码分词器的代码:

OpenAI 提供了一个交互式的 Web 用户界面,以展示 GPT 模型中的字节对分词器是如何工作的:

对于那些对从零开始编码和训练 BPE 分词器感兴趣的读者,Andrej Karpathy 的 GitHub 仓库minbpe提供了一个最小化和可读的实现:

对于那些对研究某些其他流行 LLM 使用的替代分词方案感兴趣的读者,可以在 SentencePiece 和 WordPiece 论文中找到更多信息:

  • “SentencePiece:用于神经文本处理的一个简单且语言无关的子词分词器和反分词器”(2018)由 Kudo 和 Richardson 所著,aclanthology.org/D18-2012/

  • “快速 WordPiece 分词”(2020)由 Song 等人所著,arxiv.org/abs/2012.15524

第三章

对于那些想要了解更多关于 RNN 和语言翻译中 Bahdanau 注意力机制的读者,可以在以下论文中找到详细见解:

自注意力作为缩放点积注意力在原始的 Transformer 论文中被引入:

FlashAttention 是自注意力机制的高效实现,通过优化内存访问模式加速计算过程。从数学上讲,FlashAttention 与标准自注意力机制相同,但优化了计算过程以提高效率:

  • “FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness” (2022) by Dao et al., arxiv.org/abs/2205.14135

  • “FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning” (2023) by Dao, arxiv.org/abs/2307.08691

PyTorch 实现了一个支持 FlashAttention 以提高效率的自注意力和因果注意力的函数。此函数处于测试阶段,可能会发生变化:

PyTorch 也实现了一个基于scaled_ dot_product函数的高效的MultiHeadAttention类:

Dropout 是一种在训练过程中随机从神经网络中丢弃单元(及其连接)的正则化技术,以防止过拟合:

虽然在实践中最常见的自注意力变体是基于缩放点积注意力的多头注意力,但作者发现,即使没有值权重矩阵和投影层,也可以实现良好的性能:

第四章

以下论文介绍了一种通过标准化隐藏层中神经元的总和输入来稳定隐藏状态动态的神经网络的技术,与之前发表的方法相比,显著减少了训练时间:

Post-LayerNorm,在原始 Transformer 模型中使用,在自注意力和前馈网络之后应用层归一化。相比之下,Pre-LayerNorm,如 GPT-2 和更新的 LLMs 中采用,在这些组件之前应用层归一化,这可能导致更稳定的训练动态,并且已经证明在某些情况下可以提高性能,如以下论文所述:

在现代 LLM 中使用的 LayerNorm 的一个流行变体是 RMSNorm,因为它提高了计算效率。这种变体通过仅使用输入的均方根来归一化输入,而不在平方之前减去均值,从而简化了归一化过程。这意味着在计算尺度之前不会对数据进行中心化。RMSNorm 在以下内容中描述得更详细:

高斯误差线性单元(GELU)激活函数结合了经典 ReLU 激活函数和正态分布的累积分布函数的特性,以建模层输出,允许在深度学习模型中进行随机正则化和非线性:

GPT-2 论文介绍了一系列基于 transformer 的、大小各异的 LLM——1.24 亿、3.55 亿、7.74 亿和 15 亿个参数:

  • “语言模型是无监督多任务学习者”(2019)由 Radford 等人撰写,mng.bz/DMv0

OpenAI 的 GPT-3 基本上使用了与 GPT-2 相同的架构,除了最大的版本(1750 亿)比最大的 GPT-2 模型大 100 倍,并且使用了更多的数据进行训练。感兴趣的读者可以参考 OpenAI 的官方 GPT-3 论文以及 Lambda Labs 的技术概述,该概述计算在单个 RTX 8000 消费级 GPU 上训练 GPT-3 需要 665 年:

NanoGPT 是一个代码仓库,它以简约而高效的方式实现了 GPT-2 模型,类似于本书中实现的模型。虽然本书中的代码与 nanoGPT 不同,但这个仓库激发了将大型 GPT Python 父类实现重组为较小子模块的灵感:

一篇信息丰富的博客文章展示了,当上下文大小小于 32,000 个标记时,大多数 LLM 的计算都是在前馈层而不是注意力层中进行的:

第五章

关于详细说明损失函数和应用对数变换以使其更容易进行数学优化的信息,请参阅我的讲座视频:

作者提供的以下讲座和代码示例解释了 PyTorch 的交叉熵函数在底层是如何工作的:

以下两篇论文详细介绍了用于预训练 LLM 的数据集、超参数和架构细节:

以下为本书提供的补充代码,包含从 Project Gutenberg 项目准备 60,000 本公共领域书籍用于 LLM 训练的说明:

  • 在 Project Gutenberg 数据集上预训练 GPT,mng.bz/Bdw2

第五章讨论了 LLM 的预训练,附录 D 涵盖了更高级的训练函数,例如线性预热和余弦退火。以下论文发现,类似的技巧可以成功应用于继续预训练已经预训练的 LLM,并附带额外的技巧和见解:

  • “简单且可扩展的策略以持续预训练大型语言模型”(2024)由 Ibrahim 等人撰写,arxiv.org/abs/2403.08763

BloombergGPT 是通过对通用和特定领域文本语料库进行训练创建的特定领域 LLM 的示例,特别是在金融领域:

GaLore 是一个旨在使 LLM 预训练更高效的研究项目。所需的代码更改仅限于将训练函数中的 PyTorch 的AdamW优化器替换为galore-torchPython 包提供的GaLoreAdamW优化器:

以下论文和资源公开分享了适用于 LLM 的大规模预训练数据集,这些数据集包含数百 GB 到 TB 的文本数据:

  • “Dolma:用于 LLM 预训练研究的 3000 亿标记开放语料库”(2024)由 Soldaini 等人撰写,arxiv.org/abs/2402.00159

  • “The Pile:用于语言建模的 800GB 多样化文本数据集”(2020)由 Gao 等人撰写,arxiv.org/abs/2101.00027

  • “Falcon LLM 的 RefinedWeb 数据集:仅使用网页数据超越精选语料库,”(2023)由 Penedo 等人撰写,arxiv.org/abs/2306.01116

  • “RedPajama”,由 Together AI 编写,mng.bz/d6nw

  • FineWeb 数据集,包括来自 CommonCrawl 的超过 1500 万亿个清洗和去重的英文网页数据,mng.bz/rVzy

原始介绍 top-k 采样的论文是

top-k 采样的替代方法是 top-p 采样(第五章未涉及),它从累积概率超过阈值p的最小 top tokens 集中选择,而 top-k 采样则按概率从 top k tokens 中选择:

束搜索(第五章未涉及)是一种替代解码算法,通过在每个步骤中仅保留得分最高的部分序列来生成输出序列,以平衡效率和质量:

  • “多样化的束搜索:从神经序列模型解码多样化的解决方案”(2016)由 Vijayakumar 等人撰写,arxiv.org/abs/1610.02424

第六章

讨论不同类型微调的额外资源包括

包括比较微调第一个输出标记与最后一个输出标记的额外实验,可以在 GitHub 上的补充代码材料中找到:

对于像垃圾邮件分类这样的二分类任务,技术上可以使用单个输出节点而不是两个输出节点,正如我在以下文章中讨论的那样:

  • “损失学习——在 PyTorch 中优化负对数似然和交叉熵”,mng.bz/ZEJA

你可以在以下文章中找到关于微调 LLM 不同层的额外实验,该文章表明除了输出层外,微调最后一个 Transformer 块可以显著提高预测性能:

读者可以在 imbalanced-learn 文档中找到处理不平衡分类数据集的额外资源和信息:

对于那些对分类垃圾邮件而非垃圾短信感兴趣的读者,以下资源提供了一个方便的 CSV 格式的大电子邮件垃圾邮件分类数据集,类似于第六章中使用的数据集格式:

  • 电子邮件垃圾邮件分类数据集,mng.bz/1GEq

GPT-2 是基于 transformer 架构的解码器模块的模型,其主要目的是生成新的文本。作为替代,基于编码器的模型,如 BERT 和 RoBERTa,在分类任务中可能非常有效:

  • “BERT:用于语言理解的深度双向变换器预训练”(2018)由 Devlin 等人撰写,arxiv.org/abs/1810.04805

  • 刘等人撰写的“RoBERTa:一种鲁棒优化的 BERT 预训练方法”(2019),arxiv.org/abs/1907.11692

  • “对 50k IMDB 电影评论进行情感分类的额外实验”,mng.bz/PZJR

近期论文显示,通过在分类微调期间移除因果掩码以及进行其他修改,可以进一步提高分类性能:

第七章

用于指令微调的 Alpaca 数据集包含 52,000 个指令-响应对,是第一个也是最流行的公开可用的指令微调数据集之一:

适用于指令微调的额外公开可访问数据集包括

Phi-3 是一个拥有 38 亿参数的模型,其指令微调变体据称与许多更大的专有模型相当,例如 GPT-3.5:

  • “Phi-3 技术报告:在您的手机上本地运行的高性能语言模型”(2024)由 Abdin 等人撰写,arxiv.org/abs/2404.14219

研究人员提出了一种合成指令数据生成方法,从指令微调的 Llama-3 模型中生成 300,000 个高质量的指令-响应对。在这些指令示例上微调的预训练 Llama 3 基础模型的表现与原始指令微调的 Llama-3 模型相当:

  • “Magpie: Alignment Data Synthesis from Scratch by Prompting Aligned LLMs with Nothing” (2024) by Xu et al., arxiv.org/abs/2406.08464

研究表明,在指令微调中不屏蔽指令和输入可以有效提高各种 NLP 任务和开放式生成基准测试的性能,尤其是在训练数据集包含长指令和简短输出或使用少量训练示例时:

Prometheus 和 PHUDGE 是公开可用的 LLM,它们在评估长文本响应时可以与 GPT-4 相媲美,并支持自定义标准。我们之所以没有使用这些模型,是因为在撰写本文时,它们没有得到 Ollama 的支持,因此无法在笔记本电脑上高效执行:

  • “Prometheus: Inducing Finegrained Evaluation Capability in Language Models” (2023) by Kim et al., arxiv.org/abs/2310.08491

  • “PHUDGE: Phi-3 as Scalable Judge” (2024) by Deshwal and Chawla, “arxiv.org/abs/2405.08029

  • “Prometheus 2: An Open Source Language Model Specialized in Evaluating Other Language Models” (2024), by Kim et al., arxiv.org/abs/2405.01535

以下报告中的结果支持了这样一个观点:大型语言模型在预训练期间主要获取事实知识,而微调主要增强了它们使用这种知识的能力。此外,这项研究探讨了使用新事实信息微调大型语言模型如何影响它们使用现有知识的能力,揭示了模型学习新事实的速度较慢,并且在微调期间引入新事实增加了模型生成错误信息的倾向:

预设偏好微调是在指令微调之后的一个可选步骤,目的是使 LLM 更接近人类的偏好。作者以下文章提供了更多关于此过程的信息:

  • “LLM Training: RLHF and Its Alternatives,” mng.bz/ZVPm

  • “Tips for LLM Pretraining and Evaluating Reward Models,” mng.bz/RNXj

附录 A

虽然附录 A 应该足以让您跟上进度,但如果您正在寻找更全面的深度学习介绍,我推荐以下书籍:

  • 《使用 PyTorch 和 Scikit-Learn 进行机器学习》(2022)由 Sebastian Raschka、Hayden Liu 和 Vahid Mirjalili 著。ISBN 978-1801819312

  • 《使用 PyTorch 进行深度学习》(2021)由 Eli Stevens、Luca Antiga 和 Thomas Viehmann 著。ISBN 978-1617295263

对于更深入的张量概念介绍,读者可以找到我录制的一个 15 分钟的视频教程:

如果你想了解更多关于机器学习中模型评估的内容,我推荐我的文章

  • “机器学习中的模型评估、模型选择和算法选择”(2018)由 Sebastian Raschka 著,arxiv.org/abs/1811.12808

对于对微积分复习或温和介绍感兴趣的读者,我在我的网站上写了一章关于微积分的内容,免费提供:

  • “微积分导论,”由 Sebastian Raschka 著,mng.bz/WEyW

为什么 PyTorch 不在后台自动为我们调用 optimizer.zero_grad()?在某些情况下,可能希望累积梯度,PyTorch 将将其作为选项留给我们。如果您想了解更多关于梯度累积的信息,请参阅以下文章:

  • “使用梯度累积在单个 GPU 上微调大型语言模型”由 Sebastian Raschka 著,mng.bz/8wPD

本附录涵盖了 DDP,这是一种在多个 GPU 上训练深度学习模型的流行方法。对于单个模型无法适应 GPU 的更高级用例,您还可以考虑 PyTorch 的完全分片数据并行(FSDP)方法,该方法执行分布式数据并行并在不同的 GPU 上分配大型层。更多信息,请参阅以下概述,其中包含进一步链接到 API 文档:

  • “介绍 PyTorch 完全分片数据并行(FSDP)API,” mng.bz/EZJR

附录 C 练习解答

练习答案的完整代码示例可以在补充的 GitHub 仓库github.com/rasbt/LLMs-from-scratch中找到。

第二章

练习 2.1

您可以通过每次提示编码器一个字符串来获取单个标记 ID:

print(tokenizer.encode("Ak"))
print(tokenizer.encode("w"))
# ...

这将打印

[33901]
[86]
# ...

您可以使用以下代码组装原始字符串:

print(tokenizer.decode([33901, 86, 343, 86, 220, 959]))

这将返回

'Akwirw ier'

练习 2.2

代码示例,数据加载器max_length=2stride=2

dataloader = create_dataloader(
    raw_text, batch_size=4, max_length=2, stride=2
)

它产生以下格式的批次:

tensor([[  40,  367],
        [2885, 1464],
        [1807, 3619],
        [ 402,  271]])

第二个数据加载器的代码,其中max_length=8stride=2

dataloader = create_dataloader(
    raw_text, batch_size=4, max_length=8, stride=2
)

一个示例批次看起来像

tensor([[   40,   367,  2885,  1464,  1807,  3619,   402,   271],
        [ 2885,  1464,  1807,  3619,   402,   271, 10899,  2138],
        [ 1807,  3619,   402,   271, 10899,  2138,   257,  7026],
        [  402,   271, 10899,  2138,   257,  7026, 15632,   438]])

第三章

练习 3.1

正确的权重分配是

sa_v1.W_query = torch.nn.Parameter(sa_v2.W_query.weight.T)
sa_v1.W_key = torch.nn.Parameter(sa_v2.W_key.weight.T)
sa_v1.W_value = torch.nn.Parameter(sa_v2.W_value.weight.T)

练习 3.2

为了达到 2 的输出维度,类似于我们在单头注意力中拥有的,我们需要将投影维度d_out更改为 1。

d_out = 1
mha = MultiHeadAttentionWrapper(d_in, d_out, block_size, 0.0, num_heads=2)

练习 3.3

最小 GPT-2 模型的初始化如下:

block_size = 1024
d_in, d_out = 768, 768
num_heads = 12
mha = MultiHeadAttention(d_in, d_out, block_size, 0.0, num_heads)

第四章

练习 4.1

我们可以如下计算前馈和注意力模块中的参数数量:

block = TransformerBlock(GPT_CONFIG_124M)

total_params = sum(p.numel() for p in block.ff.parameters())
print(f"Total number of parameters in feed forward module: {total_params:,}")

total_params = sum(p.numel() for p in block.att.parameters())
print(f"Total number of parameters in attention module: {total_params:,}")

如我们所见,前馈模块包含大约是注意力模块两倍多的参数:

Total number of parameters in feed forward module: 4,722,432
Total number of parameters in attention module: 2,360,064

练习 4.2

要实例化其他 GPT 模型大小,我们可以修改配置字典,如下所示(此处以 GPT-2 XL 为例):

GPT_CONFIG = GPT_CONFIG_124M.copy()
GPT_CONFIG["emb_dim"] = 1600
GPT_CONFIG["n_layers"] = 48
GPT_CONFIG["n_heads"] = 25
model = GPTModel(GPT_CONFIG)

然后,重新使用第 4.6 节中的代码来计算参数数量和 RAM 需求,我们发现

gpt2-xl:
Total number of parameters: 1,637,792,000
Number of trainable parameters considering weight tying: 1,557,380,800
Total size of the model: 6247.68 MB

练习 4.3

在第四章中,我们使用 dropout 层的三个不同位置:嵌入层、快捷层和多头注意力模块。我们可以通过在配置文件中单独编码每个层的 dropout 率,然后相应地修改代码实现来控制每个层的 dropout 率。

修改后的配置如下:

GPT_CONFIG_124M = {
    "vocab_size": 50257,
    "context_length": 1024,
    "emb_dim": 768,
    "n_heads": 12,
    "n_layers": 12,
    "drop_rate_attn": 0.1,      #1
    "drop_rate_shortcut": 0.1,      #2
    "drop_rate_emb": 0.1,      #3
    "qkv_bias": False
}

1 多头注意力中的 Dropout

2 快捷连接中的 Dropout

3 嵌入层中的 Dropout

修改后的TransformerBlockGPTModel看起来像

class TransformerBlock(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.att = MultiHeadAttention(
            d_in=cfg["emb_dim"],
            d_out=cfg["emb_dim"],
            context_length=cfg["context_length"],
            num_heads=cfg["n_heads"], 
            dropout=cfg["drop_rate_attn"],      #1
            qkv_bias=cfg["qkv_bias"])
        self.ff = FeedForward(cfg)
        self.norm1 = LayerNorm(cfg["emb_dim"])
        self.norm2 = LayerNorm(cfg["emb_dim"])
        self.drop_shortcut = nn.Dropout(        #2
            cfg["drop_rate_shortcut"]           #2
        )                                       #2

    def forward(self, x):
        shortcut = x
        x = self.norm1(x)
        x = self.att(x)
        x = self.drop_shortcut(x)
        x = x + shortcut

        shortcut = x
        x = self.norm2(x)
        x = self.ff(x)
        x = self.drop_shortcut(x)
        x = x + shortcut
        return x

class GPTModel(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.tok_emb = nn.Embedding(
            cfg["vocab_size"], cfg["emb_dim"]
        )
        self.pos_emb = nn.Embedding(
            cfg["context_length"], cfg["emb_dim"]
        )
        self.drop_emb = nn.Dropout(cfg["drop_rate_emb"])    #3

        self.trf_blocks = nn.Sequential(
            *[TransformerBlock(cfg) for _ in range(cfg["n_layers"])])

        self.final_norm = LayerNorm(cfg["emb_dim"])
        self.out_head = nn.Linear(
            cfg["emb_dim"], cfg["vocab_size"], bias=False
        )

    def forward(self, in_idx):
        batch_size, seq_len = in_idx.shape
        tok_embeds = self.tok_emb(in_idx)
        pos_embeds = self.pos_emb(
            torch.arange(seq_len, device=in_idx.device)
        )
        x = tok_embeds + pos_embeds
        x = self.drop_emb(x)
        x = self.trf_blocks(x)
        x = self.final_norm(x)
        logits = self.out_head(x)
        return logitss

1 多头注意力中的 Dropout

2 快捷连接中的 Dropout

3 嵌入层中的 Dropout

第五章

练习 5.1

我们可以使用在本节中定义的print_sampled_tokens函数打印标记(或单词)“pizza”被采样的次数。让我们从第 5.3.1 节中定义的代码开始。

当温度为 0 或 0.1 时,“pizza”标记被采样 0x,当温度提升到 5 时,它被采样 32×。估计概率为 32/1000 × 100% = 3.2%。

实际概率为 4.3%,包含在重新缩放的 softmax 概率张量(scaled_probas[2][6])中。

练习 5.2

Top-k 采样和温度缩放是根据 LLM 和期望的输出多样性和随机程度进行调整的设置。

当使用相对较小的 top-k 值(例如,小于 10)并且温度设置在 1 以下时,模型的输出变得更加不随机和确定。这种设置在需要生成的文本更加可预测、连贯,并且基于训练数据更接近最可能的结果时很有用。

这种低 k 和温度设置的应用包括生成正式文件或报告,其中清晰度和准确性最为重要。其他应用示例包括技术分析或代码生成任务,其中精度至关重要。此外,问答和教育内容需要准确的答案,其中温度低于 1 有助于提高准确性。

另一方面,较大的 top-k 值(例如,20 到 40 范围内的值)以及超过 1 的温度值,在用 LLM 进行头脑风暴或生成创意内容(如小说)时很有用。

练习 5.3

有多种方法可以通过 generate 函数强制实现确定性行为:

  1. 设置为 top_k=None 并不应用温度缩放

  2. 设置 top_k=1

练习 5.4

实质上,我们必须加载我们在主要章节中保存的模型和优化器:

checkpoint = torch.load("model_and_optimizer.pth")
model = GPTModel(GPT_CONFIG_124M)
model.load_state_dict(checkpoint["model_state_dict"])
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-4, weight_decay=0.1)
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])

然后,使用 num_epochs=1 调用 train_simple_function 以训练模型另一个周期。

练习 5.5

我们可以使用以下代码来计算 GPT 模型的训练和验证集损失:

train_loss = calc_loss_loader(train_loader, gpt, device)
val_loss = calc_loss_loader(val_loader, gpt, device)

1240 万参数的结果损失如下:

Training loss: 3.754748503367106
Validation loss: 3.559617757797241

主要观察结果是训练和验证集的性能在同一水平。这可能有多个解释:

  1. 当 OpenAI 训练 GPT-2 时,“The Verdict” 并不是预训练数据集的一部分。因此,模型并没有明确地过度拟合训练集,在 “The Verdict” 的训练和验证集部分表现相似。(验证集损失略低于训练集损失,这在深度学习中是不常见的。然而,这很可能是由于随机噪声,因为数据集相对较小。在实践中,如果没有过度拟合,训练和验证集的性能预计将大致相同)。

  2. “The Verdict” 是 GPT-2 训练数据集的一部分。在这种情况下,我们无法判断模型是否过度拟合了训练数据,因为验证集也已被用于训练。为了评估过度拟合的程度,我们需要一个在 OpenAI 完成训练 GPT-2 后生成的新的数据集,以确保它不可能成为预训练的一部分。

练习 5.6

在主要章节中,我们尝试了最小的 GPT-2 模型,它只有 1240 万个参数。这样做的原因是为了尽可能降低资源需求。然而,你可以通过最小的代码更改轻松地尝试更大的模型。例如,在第五章中,我们不是加载 1240 万个参数的模型权重,而是只需要更改以下两行代码:

hparams, params = download_and_load_gpt2(model_size="124M", models_dir="gpt2")
model_name = "gpt2-small (124M)"

更新后的代码如下

hparams, params = download_and_load_gpt2(model_size="1558M", models_dir="gpt2")
model_name = "gpt2-xl (1558M)"

第六章

练习 6.1

我们可以通过将最大长度设置为max_length = 1024来初始化数据集,从而将输入填充到模型支持的标记数最大值:

train_dataset = SpamDataset(..., max_length=1024, ...)
val_dataset = SpamDataset(..., max_length=1024, ...)
test_dataset = SpamDataset(..., max_length=1024, ...)

然而,额外的填充导致测试准确率显著下降至 78.33%(与主章节中的 95.67%相比)。

练习 6.2

我们不仅可以微调最终的 transformer 块,还可以通过从代码中删除以下行来微调整个模型:

for param in model.parameters():
    param.requires_grad = False

这种修改使测试准确率提高了 1%,达到 96.67%(与主章节中的 95.67%相比)。

练习 6.3

我们可以不是微调最后一个输出标记,而是通过将代码中的model(input_batch)[:, -1, :]更改为model(input_batch)[:, 0, :]来微调第一个输出标记。

如预期的那样,由于第一个标记包含的信息少于最后一个标记,这种变化导致测试准确率显著下降至 75.00%(与主章节中的 95.67%相比)。

第七章

练习 7.1

Phi-3 提示格式,如图 7.4 所示,对于给定的示例输入如下所示:

<user>
Identify the correct spelling of the following word: 'Occasion'

<assistant>
The correct spelling is 'Occasion'.

要使用此模板,我们可以按如下方式修改format_input函数:

def format_input(entry):
    instruction_text = (
        f"<|user|>\n{entry['instruction']}"
    )
    input_text = f"\n{entry['input']}" if entry["input"] else ""
    return instruction_text + input_text

最后,当我们收集测试集响应时,我们还需要更新提取生成响应的方式:

for i, entry in tqdm(enumerate(test_data), total=len(test_data)):
    input_text = format_input(entry)
    tokenizer=tokenizer
    token_ids = generate(
        model=model,
        idx=text_to_token_ids(input_text, tokenizer).to(device),
        max_new_tokens=256,
        context_size=BASE_CONFIG["context_length"],
        eos_id=50256
    )
    generated_text = token_ids_to_text(token_ids, tokenizer)
    response_text = (                       #1
        generated_text[len(input_text):]
        .replace("<|assistant|>:", "")
        .strip()
    )
    test_data[i]["model_response"] = response_text

1 新:调整###Response 到<|assistant|>

使用 Phi-3 模板微调模型大约快 17%,因为它导致模型输入更短。分数接近 50,这与我们之前使用 Alpaca 风格提示获得的分数在同一水平。

练习 7.2

为了屏蔽如图 7.13 所示的指令,我们需要对InstructionDataset类和custom_collate_fn函数进行轻微修改。我们可以修改InstructionDataset类以收集指令长度,我们将在 collate 函数中使用这些长度来定位目标中的指令内容位置,如下所示:

class InstructionDataset(Dataset):
    def __init__(self, data, tokenizer):
        self.data = data
        self.instruction_lengths = []     #1
        self.encoded_texts = []

        for entry in data:
            instruction_plus_input = format_input(entry)
            response_text = f"\n\\n### Response:\\n{entry['output']}"
            full_text = instruction_plus_input + response_text

            self.encoded_texts.append(
                tokenizer.encode(full_text)
            )
            instruction_length = ( 
                len(tokenizer.encode(instruction_plus_input)
            )
            self.instruction_lengths.append(instruction_length)      #2

    def __getitem__(self, index):    #3
        return self.instruction_lengths[index], self.encoded_texts[index]

    def __len__(self):
        return len(self.data)

1 分离的指令长度列表

2 收集指令长度

3 分别返回指令长度和文本

接下来,我们更新了custom_collate_fn,由于InstructionDataset数据集的变化,现在每个batch都是一个包含(instruction_length, item)的元组,而不是仅仅item。此外,我们现在在目标 ID 列表中屏蔽相应的指令标记:

def custom_collate_fn(
    batch,
    pad_token_id=50256,
    ignore_index=-100,
    allowed_max_length=None,
    device="cpu"
):

    batch_max_length = max(len(item)+1 for instruction_length, item in batch)
    inputs_lst, targets_lst = [], []          #1

    for instruction_length, item in batch:   
        new_item = item.copy()
        new_item += [pad_token_id]
        padded = (
            new_item + [pad_token_id] * (batch_max_length - len(new_item)
        )
        inputs = torch.tensor(padded[:-1])
        targets = torch.tensor(padded[1:])
        mask = targets == pad_token_id
        indices = torch.nonzero(mask).squeeze()
        if indices.numel() > 1:
            targets[indices[1:]] = ignore_index

        targets[:instruction_length-1] = -100       #2

        if allowed_max_length is not None:
            inputs = inputs[:allowed_max_length]
            targets = targets[:allowed_max_length]

        inputs_lst.append(inputs)
        targets_lst.append(targets)

    inputs_tensor = torch.stack(inputs_lst).to(device)
    targets_tensor = torch.stack(targets_lst).to(device)

    return inputs_tensor, targets_tensor

1 现在 batch 是一个元组。

2 在目标中屏蔽所有输入和指令标记

当使用这种指令屏蔽方法微调模型进行评估时,其表现略差(使用第七章的 Ollama Llama 3 方法,大约低 4 分)。这与“带有指令损失的指令调整”论文中的观察结果一致(arxiv.org/abs/2405.14394)。

练习 7.3

要在原始斯坦福 Alpaca 数据集(github.com/tatsu-lab/stanford_alpaca)上微调模型,我们只需更改文件 URL:

url = "https://raw.githubusercontent.com/rasbt/LLMs-from-scratch/main/ch07/01_main-chapter-code/instruction-data.json"

url = "https://raw.githubusercontent.com/tatsu-lab/stanford_alpaca/main/alpaca_data.json"

注意,数据集包含 52,000 条条目(比第七章多 50 倍),条目长度也比我们在第七章中处理的条目长。

因此,强烈建议在 GPU 上运行训练。

如果你遇到内存不足的错误,考虑将批大小从 8 减少到 4、2 或 1。除了降低批大小外,你还可能希望考虑将 allowed_max_length 从 1024 降低到 512 或 256。

下面是来自 Alpaca 数据集的一些示例,包括生成的模型响应:

练习 7.4

要使用 LoRA 指令微调模型,请使用附录 E 中的相关类和函数:

from appendix_E import LoRALayer, LinearWithLoRA, replace_linear_with_lora

接下来,在 7.5 节中的模型加载代码下方添加以下几行代码:

total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable parameters before: {total_params:,}")

for param in model.parameters():
    param.requires_grad = False

total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable parameters after: {total_params:,}")
replace_linear_with_lora(model, rank=16, alpha=16)

total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable LoRA parameters: {total_params:,}")
model.to(device)

注意,在 Nvidia L4 GPU 上,使用 LoRA 进行微调在 L4 上运行需要 1.30 分钟。在相同的 GPU 上,原始代码运行需要 1.80 分钟。因此,在这种情况下,LoRA 大约快了 28%。使用第七章的 Ollama Llama 3 方法评估的分数大约为 50,与原始模型相当。

附录 A

练习 A.1

可选的 Python 设置提示文档(github.com/rasbt/LLMs-from-scratch/tree/main/setup/01_optional-python-setup-preferences) 包含了额外的建议和提示,如果你需要额外的帮助来设置你的 Python 环境。

练习 A.2

可选的 "安装本书中使用的库" 文档(github.com/rasbt/LLMs-from-scratch/tree/main/setup/02_installing-python-libraries) 包含了检查你的环境是否正确设置的实用工具。

练习 A.3

网络有两个输入和两个输出。此外,还有 2 个隐藏层,分别有 30 和 20 个节点。从编程的角度来看,我们可以这样计算参数数量:

model = NeuralNetwork(2, 2)
num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("Total number of trainable model parameters:", num_params)

这将返回

752

我们也可以手动计算如下:

  • 第一隐藏层: 2 个输入乘以 30 个隐藏单元加上 30 个偏置单元

  • 第二隐藏层: 30 个输入单元乘以 20 个节点加上 20 个偏置单元

  • 输出层: 20 个输入节点乘以 2 个输出节点加上 2 个偏置单元

然后,将每层的所有参数相加,结果为 2×30+30 + 30×20+20 + 20×2+2 = 752。

练习 A.4

确切的运行时间结果将取决于用于此实验的硬件。在我的实验中,即使对于小的矩阵乘法,使用连接到 V100 GPU 的 Google Colab 实例也能观察到显著的加速:

a = torch.rand(100, 200)
b = torch.rand(200, 300)
%timeit a@b

在 CPU 上这导致了

63.8 µs ± 8.7 µs per loop

在 GPU 上执行时

a, b = a.to("cuda"), b.to("cuda")
%timeit a @ b

结果如下

13.8 µs ± 425 ns per loop

在这种情况下,在 V100 上,计算速度大约快了四倍。

附录 D:为训练循环添加铃声和哨声

在这个附录中,我们增强了第 5 到 7 章中涵盖的预训练和微调过程的训练函数。特别是,它涵盖了学习率预热余弦衰减梯度裁剪。然后我们将这些技术纳入训练函数并预训练一个 LLM。

为了使代码自包含,我们重新初始化在第五章中训练的模型:

import torch
from chapter04 import GPTModel

GPT_CONFIG_124M = {
    "vocab_size": 50257,          #1

    "context_length": 256,       #2
    "emb_dim": 768,           #3
    "n_heads": 12,            #4
    "n_layers": 12,           #5
    "drop_rate": 0.1,         #6
    "qkv_bias": False         #7
}
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.to(device)
model.eval()

1 词汇量大小

2 缩短上下文长度(原:1024)

3 嵌入维度

4 注意力头数量

5 层数数量

6 Dropout 率

7 查询-键-值偏差

初始化模型后,我们需要初始化数据加载器。首先,我们加载“The Verdict”短篇小说:

import os
import urllib.request

file_path = "the-verdict.txt"

url = (
    "https://raw.githubusercontent.com/rasbt/LLMs-from-scratch/"
    "main/ch02/01_main-chapter-code/the-verdict.txt"
)

if not os.path.exists(file_path):
    with urllib.request.urlopen(url) as response:
        text_data = response.read().decode('utf-8')
    with open(file_path, "w", encoding="utf-8") as file:
        file.write(text_data)
else:
    with open(file_path, "r", encoding="utf-8") as file:
        text_data = file.read()

接下来,我们将text_data加载到数据加载器中:

from previous_chapters import create_dataloader_v1

train_ratio = 0.90
split_idx = int(train_ratio * len(text_data))
torch.manual_seed(123)
train_loader = create_dataloader_v1(
    text_data[:split_idx],
    batch_size=2,
    max_length=GPT_CONFIG_124M["context_length"],
    stride=GPT_CONFIG_124M["context_length"],
    drop_last=True,
    shuffle=True,
    num_workers=0
)
val_loader = create_dataloader_v1(
    text_data[split_idx:],
    batch_size=2,
    max_length=GPT_CONFIG_124M["context_length"],
    stride=GPT_CONFIG_124M["context_length"],
    drop_last=False,
    shuffle=False,
    num_workers=0
)

D.1 学习率预热

实现学习率预热可以稳定复杂模型(如 LLM)的训练。这个过程涉及将学习率从非常低的初始值(initial_lr)逐渐增加到用户指定的最大值(peak_lr)。以较小的权重更新开始训练可以降低模型在训练阶段遇到大而破坏性的更新的风险。

假设我们计划训练一个 LLM 15 个 epoch,初始学习率为 0.0001,增加到最大学习率 0.01:

n_epochs = 15
initial_lr = 0.0001
peak_lr = 0.01

预热步骤的数量通常设置在总步骤数的 0.1%到 20%之间,我们可以按以下方式计算:

total_steps = len(train_loader) * n_epochs
warmup_steps = int(0.2 * total_steps)       #1
print(warmup_steps)

1 20%预热

这打印出27,意味着我们有 20 个预热步骤,在最初的 27 个训练步骤中将初始学习率从 0.0001 增加到 0.01。

接下来,我们实现一个简单的训练循环模板来展示这个预热过程:

optimizer = torch.optim.AdamW(model.parameters(), weight_decay=0.1)
lr_increment = (peak_lr - initial_lr) / warmup_steps    #1

global_step = -1
track_lrs = []

for epoch in range(n_epochs):    #2
    for input_batch, target_batch in train_loader:
        optimizer.zero_grad()
        global_step += 1

        if global_step < warmup_steps:             #3
            lr = initial_lr + global_step * lr_increment
        else:
            lr = peak_lr

        for param_group in optimizer.param_groups:    #4
            param_group["lr"] = lr
        track_lrs.append(optimizer.param_groups[0]["lr"])   #5

1 这个增量由我们在每个 20 个预热步骤中增加的initial_lr的量决定。

2 在每个 epoch 中遍历训练加载器中的批次执行典型的训练循环

3 如果我们仍在预热阶段,则更新学习率

4 将计算出的学习率应用于优化器

5 在完整的训练循环中,会计算损失和模型更新,这里为了简单起见省略了这些内容。

在运行前面的代码后,我们通过可视化训练循环如何改变学习率来验证学习率预热是否按预期工作:

import matplotlib.pyplot as plt

plt.ylabel("Learning rate")
plt.xlabel("Step")
total_training_steps = len(train_loader) * n_epochs
plt.plot(range(total_training_steps), track_lrs);
plt.show()

生成的图表显示,学习率从低值开始,在 20 步后增加到最大值,然后在 20 步后达到最大值(图 D.1)。

figure

图 D.1 学习率预热在最初的 20 个训练步骤中增加学习率。在 20 步之后,学习率达到峰值 0.01,并在剩余的训练中保持恒定。

接下来,我们将进一步修改学习率,使其在达到最大学习率后降低,这有助于进一步提高模型训练。

D.2 余弦衰减

另一种广泛采用的训练复杂深度神经网络和 LLMs 的技术是余弦衰减。这种方法在整个训练周期中调节学习率,使它在预热阶段之后遵循余弦曲线。

在其流行的变体中,余弦衰减将学习率降低(或衰减)到几乎为零,模仿半余弦周期的轨迹。余弦衰减中学习率的逐渐降低旨在减缓模型更新其权重的速度。这尤其重要,因为它有助于在训练过程中最小化超过损失最小值的风险,这对于确保训练后期阶段的稳定性至关重要。

我们可以通过添加余弦衰减来修改训练循环模板:

import math

min_lr = 0.1 * initial_lr
track_lrs = []
lr_increment = (peak_lr - initial_lr) / warmup_steps
global_step = -1

for epoch in range(n_epochs):
    for input_batch, target_batch in train_loader:
        optimizer.zero_grad()
        global_step += 1

        if global_step < warmup_steps:                     #1
            lr = initial_lr + global_step * lr_increment  
        else:                                                #2
            progress = ((global_step - warmup_steps) / 
                        (total_training_steps - warmup_steps))
            lr = min_lr + (peak_lr - min_lr) * 0.5 * (
                1 + math.cos(math.pi * progress)
            )

        for param_group in optimizer.param_groups:
            param_group["lr"] = lr
        track_lrs.append(optimizer.param_groups[0]["lr"])

1 应用线性预热

2 在预热后使用余弦退火

再次,为了验证学习率是否按预期改变,我们绘制了学习率图:

plt.ylabel("Learning rate")
plt.xlabel("Step")
plt.plot(range(total_training_steps), track_lrs)
plt.show()

结果的学习率图显示,学习率开始于线性预热阶段,在 20 步内增加,直到 20 步后达到最大值。在 20 步的线性预热之后,余弦衰减开始,逐渐降低学习率,直到达到其最小值(图 D.2)。

figure

图 D.2 线性学习率预热的前 20 步之后是余弦衰减,它以半余弦周期降低学习率,直到训练结束时达到其最小点。

D.3 梯度裁剪

梯度裁剪是增强 LLM 训练稳定性的另一种重要技术。这种方法涉及设置一个阈值,超过该阈值梯度将被缩放到预定的最大幅度。这个过程确保在反向传播过程中模型参数的更新保持在可管理的范围内。

例如,在 PyTorch 的clip_grad_ norm_函数中应用max_norm=1.0设置确保梯度的范数不超过 1.0。在这里,“范数”表示梯度向量在模型参数空间中的长度或大小,具体指的是 L2 范数,也称为欧几里得范数。

用数学术语来说,对于一个由分量v = [v[1], v[2], ..., v**n]组成的向量v,L2 范数是

figure

这种计算方法也适用于矩阵。例如,考虑一个由以下给出的梯度矩阵

figure

如果我们想将这些梯度裁剪到max_norm为 1,我们首先计算这些梯度的 L2 范数,它是

figure

由于|G|[2] = 5 超过了我们的max_norm,我们将梯度缩放到其范数正好等于 1。这是通过一个缩放因子实现的,计算为max_norm/|G|[2] = 1/5。因此,调整后的梯度矩阵G'变为

figure

为了说明这个梯度裁剪过程,我们首先初始化一个新的模型并计算一个训练批次的损失,类似于标准训练循环中的过程:

from chapter05 import calc_loss_batch

torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.to(device)
loss = calc_loss_batch(input_batch, target_batch, model, device)
loss.backward()

在调用.backward()方法后,PyTorch 计算损失梯度并将它们存储在每个模型权重(参数)张量的.grad属性中。

为了阐明这一点,我们可以定义以下find_highest_gradient实用函数,通过扫描模型权重张量的所有.grad属性来识别最高的梯度值,在调用.backward()之后:

def find_highest_gradient(model):
    max_grad = None
    for param in model.parameters():
        if param.grad is not None:
            grad_values = param.grad.data.flatten()
            max_grad_param = grad_values.max()
            if max_grad is None or max_grad_param > max_grad:
                max_grad = max_grad_param
    return max_grad
print(find_highest_gradient(model))

前面代码确定的最大梯度值是

tensor(0.0411)

现在我们应用梯度裁剪并看看这如何影响最大梯度值:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
print(find_highest_gradient(model))

应用梯度裁剪(最大范数为 1)后的最大梯度值比之前小得多:

tensor(0.0185)

D.4 修改后的训练函数

最后,我们通过添加本文中介绍的三种概念来改进train_model_simple训练函数(见第五章):线性预热、余弦衰减和梯度裁剪。这些方法共同帮助稳定 LLM 的训练。

train_model_simple相比,代码的变化如下所示:

from chapter05 import evaluate_model, generate_and_print_sample

def train_model(model, train_loader, val_loader, optimizer, device,
                n_epochs, eval_freq, eval_iter, start_context, tokenizer,
                warmup_steps, initial_lr=3e-05, min_lr=1e-6):

    train_losses, val_losses, track_tokens_seen, track_lrs = [], [], [], []
    tokens_seen, global_step = 0, -1

    peak_lr = optimizer.param_groups[0]["lr"]   #1
    total_training_steps = len(train_loader) * n_epochs     #2
    lr_increment = (peak_lr - initial_lr) / warmup_steps    #3

    for epoch in range(n_epochs):
        model.train()
        for input_batch, target_batch in train_loader:
            optimizer.zero_grad()
            global_step += 1

            if global_step < warmup_steps:   #4
                lr = initial_lr + global_step * lr_increment  
            else:
                progress = ((global_step - warmup_steps) / 
                            (total_training_steps - warmup_steps))
                lr = min_lr + (peak_lr - min_lr) * 0.5 * (
                    1 + math.cos(math.pi * progress))

            for param_group in optimizer.param_groups:   #5
                param_group["lr"] = lr
            track_lrs.append(lr)
            loss = calc_loss_batch(input_batch, target_batch, model, device)
            loss.backward()

            if global_step >= warmup_steps:         #6
                torch.nn.utils.clip_grad_norm_(
                    model.parameters(), max_norm=1.0
                )
 #7
            optimizer.step() 
            tokens_seen += input_batch.numel()

            if global_step % eval_freq == 0:
                train_loss, val_loss = evaluate_model(
                    model, train_loader, val_loader,
                    device, eval_iter
                )
                train_losses.append(train_loss)
                val_losses.append(val_loss)
                track_tokens_seen.append(tokens_seen)
                print(f"Ep {epoch+1} (Iter {global_step:06d}): "
                      f"Train loss {train_loss:.3f}, "
                      f"Val loss {val_loss:.3f}"
                )

        generate_and_print_sample(
            model, tokenizer, device, start_context
        )

    return train_losses, val_losses, track_tokens_seen, track_lrs

1 从优化器中检索初始学习率,假设我们将其用作峰值学习率

2 计算训练过程中的总迭代次数

3 在预热阶段计算学习率增量

4 根据当前阶段(预热或余弦退火)调整学习率

5 将计算出的学习率应用于优化器

6 在预热阶段之后应用梯度裁剪以避免梯度爆炸

7 以下内容与第五章中使用的 train_model_simple 函数相比保持不变。

定义了train_model函数后,我们可以像使用train_model_simple方法进行预训练一样使用它来训练模型:

import tiktoken

torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.to(device)
peak_lr = 0.001
optimizer = torch.optim.AdamW(model.parameters(), weight_decay=0.1)
tokenizer = tiktoken.get_encoding("gpt2")

n_epochs = 15
train_losses, val_losses, tokens_seen, lrs = train_model(
    model, train_loader, val_loader, optimizer, device, n_epochs=n_epochs,
    eval_freq=5, eval_iter=1, start_context="Every effort moves you",
    tokenizer=tokenizer, warmup_steps=warmup_steps, 
    initial_lr=1e-5, min_lr=1e-5
)

在 MacBook Air 或类似笔记本电脑上完成训练大约需要 5 分钟,并打印以下输出:

Ep 1 (Iter 000000): Train loss 10.934, Val loss 10.939
Ep 1 (Iter 000005): Train loss 9.151, Val loss 9.461 
Every effort moves you,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
Ep 2 (Iter 000010): Train loss 7.949, Val loss 8.184 
Ep 2 (Iter 000015): Train loss 6.362, Val loss 6.876 
Every effort moves you,,,,,,,,,,,,,,,,,,, the,,,,,,,,, the,,,,,,,,,,, 
the,,,,,,,, 
... 
Ep 15 (Iter 000130): Train loss 0.041, Val loss 6.915 
Every effort moves you?"  "Yes--quite insensible to the irony. She wanted him vindicated--and by me!"  He laughed again, and threw back his head to look up at the sketch of the donkey. "There were days when I

与预训练类似,由于数据集非常小,模型在几个 epoch 后开始过拟合,我们多次迭代它。尽管如此,我们可以看到函数正在起作用,因为它最小化了训练集损失。

鼓励读者在更大的文本数据集上训练模型,并将使用此更复杂的训练函数获得的结果与使用train_model_simple函数获得的结果进行比较。

附录 E:使用 LoRA 进行参数高效的微调

低秩适应(LoRA)是参数高效微调中最广泛使用的技术之一。以下讨论基于第六章中给出的垃圾邮件分类微调示例。然而,LoRA 微调也适用于第七章中讨论的监督指令微调

E.1 LoRA 简介

LoRA 是一种技术,通过仅调整模型权重参数的小子集,将预训练模型调整以更好地适应特定(通常是较小的)数据集。其中“低秩”方面指的是将模型调整限制在总权重参数空间的一个较小维度的子空间中的数学概念。这有效地捕捉了训练过程中权重参数变化的最有影响力的方向。LoRA 方法因其能够使大型模型在特定任务数据上高效微调而有用且受欢迎,显著降低了微调通常所需的计算成本和资源。

假设一个大的权重矩阵W与一个特定的层相关联。LoRA 可以应用于 LLM 中的所有线性层。然而,为了说明目的,我们专注于单个层。

在训练深度神经网络时,在反向传播过程中,我们学习一个 DW矩阵,它包含了我们在训练过程中希望更新原始权重参数以最小化损失函数的信息。此后,我使用“权重”一词作为模型权重参数的简称。

在常规训练和微调中,权重更新定义为如下:

figure

由胡等人提出的 LoRA 方法(arxiv.org/abs/2106.09685),为计算权重更新 DW提供了一个更高效的替代方案,通过学习其近似值:

figure

其中AB是两个比W小得多的矩阵,AB表示AB之间的矩阵乘积。

使用 LoRA,我们可以重新表述我们之前定义的权重更新:

figure

图 E.1 展示了全微调和 LoRA 的权重更新公式并排比较。

figure

图 E.1 比较了权重更新方法:常规微调和 LoRA。常规微调直接使用 DW 更新预训练权重矩阵 W(左)。LoRA 使用两个较小的矩阵 A 和 B 来近似 DW,其中 AB 的乘积被加到 W 上,r 表示内维,一个可调的超参数(右)。

如果你仔细观察,你可能会注意到图 E.1 中全微调和 LoRA 的视觉表示与之前展示的公式略有不同。这种变化归因于矩阵乘法的分配律,它允许我们分离原始和更新的权重而不是将它们组合。例如,在以 x 作为输入数据的常规微调情况下,我们可以将计算表示为

图

同样,我们可以为 LoRA 编写以下内容:

图

除了减少训练期间需要更新的权重数量外,将 LoRA 权重矩阵与原始模型权重分开的能力使 LoRA 在实践中更加有用。实际上,这允许预训练模型权重保持不变,在训练后使用模型时,LoRA 矩阵会动态应用。

在实践中保持 LoRA 权重分离非常有用,因为它允许在不存储多个完整的 LLM 版本的情况下进行模型定制。这减少了存储需求并提高了可扩展性,因为当我们为每个特定的客户或应用程序定制 LLM 时,只需要调整和保存较小的 LoRA 矩阵。

接下来,让我们看看 LoRA 如何用于微调 LLM 以进行垃圾邮件分类,类似于第六章中的微调示例。

E.2 准备数据集

在应用 LoRA 到垃圾邮件分类示例之前,我们必须加载我们将要使用的数据集和预训练模型。这里的代码重复了第六章的数据准备。(我们也可以打开并运行第六章的笔记本,并在其中插入 E.4 节的 LoRA 代码。)

首先,我们下载数据集并将其保存为 CSV 文件。

列表 E.1 下载和准备数据集
from pathlib import Path
import pandas as pd
from ch06 import (
    download_and_unzip_spam_data,
    create_balanced_dataset,
    random_split
)

url = \ 
"https://archive.ics.uci.edu/static/public/228/sms+spam+collection.zip"
zip_path = "sms_spam_collection.zip"
extracted_path = "sms_spam_collection"
data_file_path = Path(extracted_path) / "SMSSpamCollection.tsv"

download_and_unzip_spam_data(url, zip_path, extracted_path, data_file_path)

df = pd.read_csv(
    data_file_path, sep="\t", header=None, names=["Label", "Text"]
)
balanced_df = create_balanced_dataset(df)
balanced_df["Label"] = balanced_df["Label"].map({"ham": 0, "spam": 1})

train_df, validation_df, test_df = random_split(balanced_df, 0.7, 0.1)
train_df.to_csv("train.csv", index=None)
validation_df.to_csv("validation.csv", index=None)
test_df.to_csv("test.csv", index=None)

接下来,我们创建 SpamDataset 实例。

列表 E.2 实例化 PyTorch 数据集
import torch
from torch.utils.data import Dataset
import tiktoken
from chapter06 import SpamDataset

tokenizer = tiktoken.get_encoding("gpt2")
train_dataset = SpamDataset("train.csv", max_length=None, 
    tokenizer=tokenizer
)
val_dataset = SpamDataset("validation.csv", 
    max_length=train_dataset.max_length, tokenizer=tokenizer
)
test_dataset = SpamDataset(
    "test.csv", max_length=train_dataset.max_length, tokenizer=tokenizer
)

在创建 PyTorch 数据集对象之后,我们实例化数据加载器。

列表 E.3 创建 PyTorch 数据加载器
from torch.utils.data import DataLoader

num_workers = 0
batch_size = 8

torch.manual_seed(123)

train_loader = DataLoader(
    dataset=train_dataset,
    batch_size=batch_size,
    shuffle=True,
    num_workers=num_workers,
    drop_last=True,
)

val_loader = DataLoader(
    dataset=val_dataset,
    batch_size=batch_size,
    num_workers=num_workers,
    drop_last=False,
)

test_loader = DataLoader(
    dataset=test_dataset,
    batch_size=batch_size,
    num_workers=num_workers,
    drop_last=False,
)

作为验证步骤,我们遍历数据加载器,并检查每个批次包含八个训练示例,每个训练示例由 120 个标记组成:

print("Train loader:")
for input_batch, target_batch in train_loader:
    pass

print("Input batch dimensions:", input_batch.shape)
print("Label batch dimensions", target_batch.shape)

输出如下

Train loader:
Input batch dimensions: torch.Size([8, 120])
Label batch dimensions torch.Size([8])

最后,我们打印每个数据集的总批次数:

print(f"{len(train_loader)} training batches")
print(f"{len(val_loader)} validation batches")
print(f"{len(test_loader)} test batches")

在这种情况下,我们每个数据集有以下批次数:

130 training batches
19 validation batches
38 test batches

E.3 初始化模型

我们重复第六章的代码来加载和准备预训练的 GPT 模型。我们首先下载模型权重并将它们加载到 GPTModel 类中。

列表 E.4 加载预训练的 GPT 模型
from gpt_download import download_and_load_gpt2
from chapter04 import GPTModel
from chapter05 import load_weights_into_gpt

CHOOSE_MODEL = "gpt2-small (124M)"
INPUT_PROMPT = "Every effort moves"

BASE_CONFIG = {
    "vocab_size": 50257,         #1
    "context_length": 1024,      #2
    "drop_rate": 0.0,            #3
    "qkv_bias": True             #4
}

model_configs = {
    "gpt2-small (124M)": {"emb_dim": 768, "n_layers": 12, "n_heads": 12},
    "gpt2-medium (355M)": {"emb_dim": 1024, "n_layers": 24, "n_heads": 16},
    "gpt2-large (774M)": {"emb_dim": 1280, "n_layers": 36, "n_heads": 20},
    "gpt2-xl (1558M)": {"emb_dim": 1600, "n_layers": 48, "n_heads": 25},
}

BASE_CONFIG.update(model_configs[CHOOSE_MODEL])

model_size = CHOOSE_MODEL.split(" ")[-1].lstrip("(").rstrip(")")
settings, params = download_and_load_gpt2(
    model_size=model_size, models_dir="gpt2"
)

model = GPTModel(BASE_CONFIG)
load_weights_into_gpt(model, params)
model.eval()

1 词汇量大小

2 上下文长度

3 Dropout 率

4 查询键值偏差

为了确保模型正确加载,让我们再次检查它是否生成连贯的文本:

from chapter04 import generate_text_simple
from chapter05 import text_to_token_ids, token_ids_to_text

text_1 = "Every effort moves you"

token_ids = generate_text_simple(
    model=model,
    idx=text_to_token_ids(text_1, tokenizer),
    max_new_tokens=15,
    context_size=BASE_CONFIG["context_length"]
)

print(token_ids_to_text(token_ids, tokenizer))

以下输出显示,模型生成了连贯的文本,这是模型权重加载正确的指标:

Every effort moves you forward.
The first step is to understand the importance of your work

接下来,我们为分类微调准备模型,类似于第六章,我们替换输出层:

torch.manual_seed(123)
num_classes = 2
model.out_head = torch.nn.Linear(in_features=768, out_features=num_classes)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)

最后,我们计算未微调模型的初始分类准确率(我们预计这个值约为 50%,这意味着模型还不能可靠地区分垃圾邮件和非垃圾邮件):

from chapter06 import calc_accuracy_loader

torch.manual_seed(123)
train_accuracy = calc_accuracy_loader(
    train_loader, model, device, num_batches=10
)
val_accuracy = calc_accuracy_loader(
    val_loader, model, device, num_batches=10
)
test_accuracy = calc_accuracy_loader(
    test_loader, model, device, num_batches=10
)

print(f"Training accuracy: {train_accuracy*100:.2f}%")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
print(f"Test accuracy: {test_accuracy*100:.2f}%")

初始预测准确率如下

Training accuracy: 46.25%
Validation accuracy: 45.00%
Test accuracy: 48.75%

E.4 使用 LoRA 进行参数高效的微调

接下来,我们使用 LoRA 修改和微调 LLM。我们首先初始化一个 LoRALayer,它创建矩阵AB,以及alpha缩放因子和rankr)设置。这个层可以接受输入并计算相应的输出,如图 E.2 所示。

figure

图 E.2 LoRA 矩阵 A 和 B 应用于层输入,并参与计算模型输出。这些矩阵的内维 r 作为一个设置,通过改变 A 和 B 的大小来调整可训练参数的数量。

在代码中,这个 LoRA 层可以如下实现。

列表 E.5 实现 LoRA 层
import math

class LoRALayer(torch.nn.Module):
    def __init__(self, in_dim, out_dim, rank, alpha):
        super().__init__()
        self.A = torch.nn.Parameter(torch.empty(in_dim, rank))
        torch.nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))    #1
        self.B = torch.nn.Parameter(torch.zeros(rank, out_dim))
        self.alpha = alpha

    def forward(self, x):
        x = self.alpha * (x @ self.A @ self.B)
        return x

1 与 PyTorch 中线性层的相同初始化

rank控制矩阵AB的内维。本质上,这个设置决定了 LoRA 引入的额外参数数量,通过参数数量来平衡模型的适应性和效率。

另一个重要的设置,alpha,作为低秩适应输出的缩放因子。它主要决定了适应层输出对原始层输出的影响程度。这可以看作是一种调节低秩适应对层输出影响的方法。我们迄今为止实现的LoRALayer类使我们能够转换层的输入。

在 LoRA 中,典型的目标是用现有的线性层进行替换,允许直接将权重更新应用于预训练的现有权重,如图 E.3 所示。

figure

图 E.3 LoRA 集成到模型层中。层的原始预训练权重(W)与 LoRA 矩阵(A 和 B)的输出相结合,这些输出近似权重更新矩阵(DW)。最终输出是通过将适应层(使用 LoRA 权重)的输出与原始输出相加来计算的。

为了整合原始的线性层权重,我们现在创建一个LinearWithLoRA层。这个层利用之前实现的LoRALayer,并设计用来替换神经网络中的现有线性层,例如GPTModel中的自注意力模块或前馈模块。

列表 E.6 使用LinearWithLora层替换线性
class LinearWithLoRA(torch.nn.Module):
    def __init__(self, linear, rank, alpha):
        super().__init__()
        self.linear = linear
        self.lora = LoRALayer(
            linear.in_features, linear.out_features, rank, alpha
        )

    def forward(self, x):
        return self.linear(x) + self.lora(x)

这段代码结合了一个标准的线性层和LoRALayerforward方法通过添加原始线性层和 LoRA 层的结果来计算输出。

由于权重矩阵BLoRALayer中的self.B)是用零值初始化的,矩阵AB的乘积结果是一个零矩阵。这确保了乘法不会改变原始权重,因为添加零不会改变它们。

为了将 LoRA 应用于先前定义的GPTModel,我们引入了一个replace_linear_with_lora函数。这个函数将模型中所有现有的Linear层与新创建的LinearWithLoRA层进行交换:

def replace_linear_with_lora(model, rank, alpha):
    for name, module in model.named_children():
        if isinstance(module, torch.nn.Linear):     #1
            setattr(model, name, LinearWithLoRA(module, rank, alpha))
        else:    #2
            replace_linear_with_lora(module, rank, alpha)

1 将线性层替换为 LinearWithLoRA

2 递归地应用于子模块

我们现在已经实现了替换GPTModelLinear层为新开发的LinearWithLoRA层以进行参数高效微调的所有必要代码。接下来,我们将应用LinearWithLoRA升级到GPTModel中所有Linear层,包括多头注意力、前馈模块和输出层,如图 E.4 所示。

figure

图 E.4 GPT 模型的架构。它突出了模型中Linear层升级为LinearWithLoRA层以进行参数高效微调的部分。

在应用LinearWithLoRA层升级之前,我们首先冻结原始模型参数:

total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable parameters before: {total_params:,}")

for param in model.parameters():
    param.requires_grad = False
total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable parameters after: {total_params:,}")

现在,我们可以看到,在 1.24 亿个模型参数中,没有任何一个是可训练的:

Total trainable parameters before: 124,441,346
Total trainable parameters after: 0

接下来,我们使用replace_linear_with_lora来替换Linear层:

replace_linear_with_lora(model, rank=16, alpha=16)
total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable LoRA parameters: {total_params:,}")

添加 LoRA 层后,可训练参数的数量如下:

Total trainable LoRA parameters: 2,666,528

如我们所见,使用 LoRA 时,我们将可训练参数的数量减少了近 50 倍。16 的rankalpha是良好的默认选择,但也很常见增加 rank 参数,这反过来又增加了可训练参数的数量。alpha通常选择为 rank 的一半、两倍或与 rank 相等。

让我们通过打印模型架构来验证层是否已按预期修改:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)
print(model)

输出是

GPTModel(
  (tok_emb): Embedding(50257, 768)
  (pos_emb): Embedding(1024, 768)
  (drop_emb): Dropout(p=0.0, inplace=False)
  (trf_blocks): Sequential(
    ...
    (11): TransformerBlock(
      (att): MultiHeadAttention(
        (W_query): LinearWithLoRA(
          (linear): Linear(in_features=768, out_features=768, bias=True)
          (lora): LoRALayer()
        )
        (W_key): LinearWithLoRA(
          (linear): Linear(in_features=768, out_features=768, bias=True)
          (lora): LoRALayer()
        )
        (W_value): LinearWithLoRA(
          (linear): Linear(in_features=768, out_features=768, bias=True)
          (lora): LoRALayer()
        )
        (out_proj): LinearWithLoRA(
          (linear): Linear(in_features=768, out_features=768, bias=True)
          (lora): LoRALayer()
        )
        (dropout): Dropout(p=0.0, inplace=False)
      )
      (ff): FeedForward(
        (layers): Sequential(
          (0): LinearWithLoRA(
            (linear): Linear(in_features=768, out_features=3072, bias=True)
            (lora): LoRALayer()
          )
          (1): GELU()
          (2): LinearWithLoRA(
            (linear): Linear(in_features=3072, out_features=768, bias=True)
            (lora): LoRALayer()
          )
        )
      )
      (norm1): LayerNorm()
      (norm2): LayerNorm()
      (drop_resid): Dropout(p=0.0, inplace=False)
    )
  )
  (final_norm): LayerNorm()
  (out_head): LinearWithLoRA(
    (linear): Linear(in_features=768, out_features=2, bias=True)
    (lora): LoRALayer()
  )
)

模型现在包括了新的LinearWithLoRA层,这些层本身由设置为不可训练的原始Linear层和新 LoRA 层组成,我们将对这些层进行微调。

在我们开始微调模型之前,让我们计算初始分类准确度:

torch.manual_seed(123)

train_accuracy = calc_accuracy_loader(
    train_loader, model, device, num_batches=10
)
val_accuracy = calc_accuracy_loader(
    val_loader, model, device, num_batches=10
)
test_accuracy = calc_accuracy_loader(
    test_loader, model, device, num_batches=10
)

print(f"Training accuracy: {train_accuracy*100:.2f}%")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
print(f"Test accuracy: {test_accuracy*100:.2f}%")

得到的准确度值是

Training accuracy: 46.25%
Validation accuracy: 45.00%
Test accuracy: 48.75%

这些准确度值与第六章中的值相同。这种结果发生是因为我们用零初始化了 LoRA 矩阵B。因此,矩阵AB的乘积结果是一个零矩阵。这确保了乘法不会改变原始权重,因为添加零不会改变它们。

现在,让我们继续到令人兴奋的部分——使用第六章中的训练函数微调模型。在 M3 MacBook Air 笔记本电脑上训练大约需要 15 分钟,在 V100 或 A100 GPU 上不到半分钟。

列表 E.7 使用 LoRA 层微调模型
import time
from chapter06 import train_classifier_simple

start_time = time.time()
torch.manual_seed(123)
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5, weight_decay=0.1)

num_epochs = 5
train_losses, val_losses, train_accs, val_accs, examples_seen = \
    train_classifier_simple(
        model, train_loader, val_loader, optimizer, device,
        num_epochs=num_epochs, eval_freq=50, eval_iter=5,
        tokenizer=tokenizer
    )

end_time = time.time()
execution_time_minutes = (end_time - start_time) / 60
print(f"Training completed in {execution_time_minutes:.2f} minutes.")

训练过程中我们看到的输出是

Ep 1 (Step 000000): Train loss 3.820, Val loss 3.462 
Ep 1 (Step 000050): Train loss 0.396, Val loss 0.364 
Ep 1 (Step 000100): Train loss 0.111, Val loss 0.229 
Training accuracy: 97.50% | Validation accuracy: 95.00% 
Ep 2 (Step 000150): Train loss 0.135, Val loss 0.073 
Ep 2 (Step 000200): Train loss 0.008, Val loss 0.052 
Ep 2 (Step 000250): Train loss 0.021, Val loss 0.179 
Training accuracy: 97.50% | Validation accuracy: 97.50%
Ep 3 (Step 000300): Train loss 0.096, Val loss 0.080 
Ep 3 (Step 000350): Train loss 0.010, Val loss 0.116 
Training accuracy: 97.50% | Validation accuracy: 95.00% 
Ep 4 (Step 000400): Train loss 0.003, Val loss 0.151 
Ep 4 (Step 000450): Train loss 0.008, Val loss 0.077 
Ep 4 (Step 000500): Train loss 0.001, Val loss 0.147 
Training accuracy: 100.00% | Validation accuracy: 97.50%
Ep 5 (Step 000550): Train loss 0.007, Val loss 0.094 
Ep 5 (Step 000600): Train loss 0.000, Val loss 0.056 
Training accuracy: 100.00% | Validation accuracy: 97.50%

Training completed in 12.10 minutes.

使用 LoRA 训练模型比不使用 LoRA 训练模型花费的时间更长(见第六章),因为 LoRA 层在正向传播过程中引入了额外的计算。然而,对于更大的模型,反向传播变得成本更高,模型在有 LoRA 的情况下通常比没有 LoRA 时训练得更快。

如我们所见,该模型接受了完美的训练,并且验证准确度非常高。让我们也可视化损失曲线,以便更好地观察训练是否收敛:

from chapter06 import plot_values

epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
examples_seen_tensor = torch.linspace(0, examples_seen, len(train_losses))

plot_values(
    epochs_tensor, examples_seen_tensor, 
    train_losses, val_losses, label="loss"
)

图 E.5 绘制了结果。

图像

图 E.5 展示了机器学习模型在六个 epoch 上的训练和验证损失曲线。最初,训练和验证损失急剧下降,然后趋于平稳,表明模型正在收敛,这意味着它不会因为进一步的训练而有明显改进。

除了根据损失曲线评估模型外,我们还可以计算整个训练、验证和测试集上的准确率(在训练过程中,我们通过eval_iter=5设置从五个批次中近似训练和验证集准确率):

train_accuracy = calc_accuracy_loader(train_loader, model, device)
val_accuracy = calc_accuracy_loader(val_loader, model, device)
test_accuracy = calc_accuracy_loader(test_loader, model, device)

print(f"Training accuracy: {train_accuracy*100:.2f}%")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
print(f"Test accuracy: {test_accuracy*100:.2f}%")

得到的准确率值是

Training accuracy: 100.00%
Validation accuracy: 96.64%
Test accuracy: 98.00%

这些结果表明,该模型在训练、验证和测试数据集上表现良好。训练准确率达到 100%,模型完美地学习了训练数据。然而,验证和测试准确度(分别为 96.64%和 97.33%)略低,表明模型存在一定程度过拟合,因为与训练集相比,模型在未见数据上的泛化能力较差。总体而言,考虑到我们仅微调了相对较少的模型权重(270 万 LoRA 权重而不是原始的 1240 万模型权重),结果非常令人印象深刻。

索引

符号

124M 参数

[EOS] (序列结束)标记

reshape 方法, 第 2 次

.to()方法, 第 2 次

.weight 属性

.eval()模式

getitem 方法

[PAD] (填充)标记

.T 方法

.backward()方法, 第 2 次

%timeit 命令

.matmul 方法

04_preference-tuning-with-dpo 文件夹

355M 参数

[BOS] (序列开始)标记

</unk>标记, 第 2 次, 第 3 次, 第 4 次, 第 5 次

.view 方法, 第 2 次

init 构造函数, 第 2 次, 第 3 次

.shape 属性

@ 操作符

len 方法

<|endoftext|> 标记

.pth 扩展名

多尔玛

《用于 LLM 预训练研究的三十万亿词开放语料库》(Soldaini 等人)

== 比较运算符

A

arXiv

Alpaca 数据集, 第 2 次

argmax 函数, 第 2 次, 第 3 次, 第 4 次, 第 5 次, 第 6 次

注意力机制

编码, 第 2 次

建模长序列的问题

注意力分数

AI(人工智能)

autograd 引擎

alpha 缩放因子

自回归模型

注意力权重,逐步计算, 第 2 次

attn_scores

Axolotl

allowed_max_length

AdamW 优化器, 第 2 次

B

Bahdanau 注意力机制

反向传播

BERT(双向编码器表示从转换器)

BPE(字节对编码)

batch_size

C

compute_accuracy 函数, 第 2 次

因果注意力掩码

clip_grad_norm_ 函数

calc_loss_loader 函数

交叉熵函数, 第 2 次, 第 3 次

对话表现

custom_collate_draft_1

custom_collate_draft_2

calc_accuracy_loader 函数

calc_loss_batch 函数, 第 2 次, 第 3 次

分类

任务

custom_collate_fn 函数, 第 2 次

classify_review 函数

context_length

cfg 字典

计算梯度

上下文向量, 第 2 次, 第 3 次

CausalAttention 类, 第 2 次

D

DistributedSampler

dim 参数, 第 2 次

Dataset 类, 第 2 次, 第 3 次, 第 4 次, 第 5 次, 第 6 次, 第 7 次

DataLoader 类, 第 2 次

数据集

下载

download_and_load_gpt2 函数, 第 2 次, 第 3 次

DummyGPTClass

DistributedDataParallel 类

DummyLayerNorm, 第 2 次

占位符

DummyGPTModel, 第 2 次, 第 3 次, 第 4 次

深度学习

点积

DDP (DistributedDataParallel)策略

设备变量

decode 方法, 第 2 次

数据加载器, 第 2 次

代码示例

dropout

定义

drop_rate

drop_last 参数

DummyTransformerBlock

数据列表

ddp_setup 函数

d_out 参数, 第 2 次

DataFrame

E

eps 变量

evaluate_model 函数, 第 2 次, 第 3 次, 第 4 次

嵌入大小

涌现行为

编码器

encode 方法, 第 2 次, 第 3 次

emb_dim

eval_iter 值

F

find_highest_gradient 函数

first_batch 变量

FeedForward 模块, 第 2 次, 第 3 次, 第 4 次

format_input 函数, 第 2 次, 第 3 次, 第 4 次, 第 5 次, 第 6 次

微调

LLMs,遵循指令

类别

用于分类

前向方法, 第 2 次

G

generate_and_print_sample 函数

GELU (高斯误差线性单元)

激活函数, 第 2 次

GPTModel, 第 2 次, 第 3 次, 第 4 次, 第 5 次, 第 6 次

类, 第 2 次

代码

实例, 第 2 次, 第 3 次, 第 4 次

GPT (生成预训练变换器)

架构

编码, 第 2 次

从头开始实现生成文本

grad_fn 值

gpt_download.py Python 模块

GPT_CONFIG_124M 字典, 第 2 次, 第 3 次, 第 4 次, 第 5 次, 第 6 次

生成文本模型,评估

GenAI (生成式 AI)

gpt2-medium355M-sft.pth 文件

GPTDatasetV1 类, 第 2 次, 第 3 次

generate_text_simple 函数, 第 2 次, 第 3 次, 第 4 次, 第 5 次, 第 6 次

GPT-4

GPT-2

模型

分词器

GPT-3

generate_model_scores 函数, 第 2 次

Google Colab

generate 函数, 第 2 次, 第 3 次, 第 4 次, 第 5 次, 第 6 次, 第 7 次

I

init_process_group 函数

指令数据集

信息泄露

输入嵌入

InstructionDataset 类, 第 2 次

指令微调

遵循指令,为指令数据集创建数据加载器, 第 2 次

概述

‘指令’对象

K

keepdim 参数

L

logits 张量

带有 LoRA 层的线性层, 第 2 次

LoRALayer 类, 第 2 次

损失度量

LLMs(大型语言模型), 第 2 次

应用

构建和使用, 第 2 次, 第 3 次

编码架构

编码注意力机制,因果注意力机制, 第 2 次

微调, 第 2 次, 第 3 次

用于分类的微调, 第 2 次, 第 3 次, 第 4 次, 第 5 次

指令微调,加载预训练的 LLMs, 第 2 次

概述, 第 2 次, 第 3 次

利用大型数据集

线性层, 第 2 次

LayerNorm, 第 2 次, 第 3 次

LIMA 数据集

层归一化, 第 2 次

加载状态字典方法

将权重加载到 gpt 函数, 第 2 次, 第 3 次, 第 4 次

loss.backward()函数

线性层权重

Llama 3 模型

LLama 2 模型

LoRA(低秩自适应), 第 2 次, 第 3 次

参数高效的微调, 第 2 次

M

主函数

最大长度, 第 2 次, 第 3 次

model.eval() 函数

MultiHeadAttention 类,第 2 次,第 3 次,第 4 次,第 5 次,第 6 次

model.train() 设置

MultiHeadAttentionWrapper 类,第 2 次,第 3 次,第 4 次,第 5 次,第 6 次,第 7 次

机器学习

多头注意力,第 2 次

模块基类

多进程子模块

掩码注意力

多项式函数,第 2 次,第 3 次

macOS

模型响应

minbpe 仓库

模型配置表

mps 设备

N

NEW_CONFIG 字典

神经网络

使用 GELU 激活实现前馈网络,第 2 次

nn.Linear 层

n_heads

numel() 方法

num_heads 维度

O

输出层节点

运行 llama3 命令,第 2 次,第 3 次

ollama serve 命令,第 2 次,第 3 次,第 4 次

optimizer.zero_grad() 方法

Ollama 应用,第 2 次

Ollama Llama 3 方法

ollama run 命令

P

PyTorch

和火炬

自动微分,第 2 次

计算图

数据加载器

数据集对象

高效数据加载器

实现多层神经网络,第 2 次

安装,第 2 次

在 加载和保存模型权重

使用 GPU 优化训练性能

概述,第 2 次

具有类似 NumPy 的 API

pip 安装程序

Phi-3 模型

print_gradients 函数,第 2 次

plot_values 函数

参数

计算

困惑度

偏导数

打印语句

plot_losses 函数

Python 版本

Prometheus 模型

提示样式

预训练

计算训练集和验证集损失

在未标记数据上

训练 LLMs,第 2 次

print_sampled_tokens 函数,第 2 次

pos_embeddings,第 2 次

偏好微调

Q

qkv_bias

query_llama 函数

query_model 函数

R

提取和保存响应,第 2 次

re 库

RMSNorm

ReLU (修正线性单元),第 2 次

re.split 命令

replace_linear_with_lora 函数

原始文本

检索增强生成

RNNs (循环神经网络)

random_split 函数

S

捷径连接,第 2 次

保存和加载模型

SimpleTokenizerV1 类,第 2 次

spawn 函数

Sequential 类

SelfAttention_v2 类,第 2 次,第 3 次

softmax_naive 函数,第 2 次

sci_mode 参数

set_printoptions 方法

SGD (随机梯度下降)

SelfAttention_v1 类,第 2 次

softmax 函数,第 2 次,第 3 次

self.register_buffer() 调用

state_dict,第 2 次

SpamDataset 类, 2nd, 3rd

特殊上下文标记

步长设置

self.out_proj 层

监督学习

在监督数据上微调模型

strip()函数

监督指令微调

为准备数据集, 2nd

设置字典, 2nd

自注意力机制

计算所有输入标记的注意力权重, 2nd

使用可训练权重实现, 2nd

无训练权重, 2nd

单头注意力,堆叠多层

SimpleTokenizerV2 类, 2nd

T

修改文本生成函数

train_ratio

文本数据

添加特殊上下文标记, 2nd

创建标记嵌入

滑动窗口, 2nd

分词,字节对编码, 2nd

torch.save 函数

标记 ID, 2nd

张量库

TransformerBlock 类

标记嵌入层, 2nd

标记嵌入, 2nd

train_simple_function

ToyDataset 类, 2nd

训练函数

增强

修改, 2nd

train_data 子集

tril 函数

文本分词, 2nd

使用 GPU 优化性能的训练

PyTorch 在 GPU 设备上的计算

在多 GPU 机器上选择可用的 GPU, 2nd

单 GPU 训练

使用多个 GPU 进行训练

test_loader

train_loader

torch.sum 方法

训练循环,第 2 次

余弦衰减

梯度裁剪

学习率预热

train_classifier_simple 函数,第 2 次

训练批次,组织数据,第 2 次

文本生成

使用 GPT 生成文本

top-k 采样,第 2 次

文本数据

transformer 架构,第 2 次,第 3 次,第 4 次

温度缩放,第 2 次

train_model_simple 函数,第 2 次,第 3 次,第 4 次,第 5 次,第 6 次

tensor2d

tensor3d

torch.no_grad()上下文管理器

测试集字典,第 2 次

张量

常见张量操作

张量数据类型

torch.nn.Linear 层

transformer 块,第 2 次

在连接注意力层和线性层中,第 2 次

文本生成损失

torchvision 库

U

无偏参数

未标记数据,解码策略以控制随机性

V

变长输入

vocab_size

v 向量

向量,第 2 次

W

W<.Subscript>q</>矩阵

权重参数,第 2 次

单词嵌入,第 2 次

权重衰减参数

Word2Vec

权重

使用预训练权重初始化模型

从 OpenAI 加载预训练权重,第 2 次

单词位置,编码

权重拆分

X

X 训练示例

posted @ 2025-09-12 14:08  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报