构建大型语言模型
Sebastian Raschka
构建大型语言模型(LLM)的三个主要阶段是实现LLM架构和数据准备过程(第
一阶段),预训练LLM以创建基础模型(第二阶段),以及微调基础模型以成为
个人助手或文本分类器(第三阶段)。本书将探讨并实现每个阶段。
从零开始构建大型语言模型
SEBASTIAN RASCHKA
有关本书及其他Manning书籍的在线信息和订购,请访问 www.manning.com。当批
量订购时,出版商提供此书的折扣。如需更多信息,请联系
特惠销售部
Manning Publications Co.
20 Baldwin Road
PO Box 761
Shelter Island, NY 11964
邮箱: orders@manning.com
©2025 Manning Publications Co. 保留所有权利。
未经出版商事先书面许可,不得以任何形式或任何方式复制、存储或传输本出版
物的任何部分,包括电子、机械、影印或其他方式。
制造商和销售商用于区分其产品的许多标识被注册为商标。当这些标识出现在书
中时,如果Manning Publications意识到有商标声明,这些标识将以大写字母或全
部大写字母印刷。
认识到保存已写内容的重要性,Manning的政策是使用无酸纸印刷我们出版的书
籍,并尽最大努力实现这一目标。同时,我们也认识到保护地球资源的责任,
Manning的书籍至少使用15%的再生纸印刷,并且在加工过程中不使用元素氯。
作者和出版商已尽力确保本书在付梓时提供的信息是正确的。作者和出版商不承
担并且在此否认对任何一方因错误或遗漏而造成的任何损失、损害或中断的责
任,无论这些错误或遗漏是由疏忽、事故或其他原因引起的,还是由于使用此处
的信息引起的。
Manning Publications Co. 20 Baldwin Road PO Box 761 Shelter Island, NY 11964
开发编辑:Dustin Archibald 技术编辑:David Caswell 审稿编辑:Kishor Rit 生产
编辑:Aleksandar Dragosavljevic´ 校对编辑:Kari Lucke 和 Alisa Larson 校对:
Mike Beady 技术校对:Jerry Kuch 排版:Dennis Dalinnik 封面设计:Marija Tudor
简要内容
1 ■ 了解大型语言模型 1
2 ■ 处理文本数据 17
3 ■ 编码注意力机制 50
4 ■ 从头实现一个GPT模型以生成文本 92
5 ■ 在未标注数据上预训练 128
6 ■ 细调用于分类 169
7 ■ 细调以遵循指令 204
A ■ PyTorch简介 251
B ■ 参考文献和进一步阅读 289
C ■ 练习解答 300
D ■ 为训练循环添加额外功能 313
E ■ 使用LoRA进行参数高效的细调 322
序言 xi
致谢 xiii
关于本书 xv
关于作者 xix
关于封面插图 xx
了解大型语言模型
1.1 什么是LLM? 2
1.2 LLM的应用 4
1.3 构建和使用LLM的阶段 5
1.4 介绍Transformer架构 7
1.5 利用大型数据集 10
1.6 深入了解GPT架构 12
1.7 构建大型语言模型 14
处理文本数据 17
2.1 了解词嵌入 18
2.2 对文本进行分词 21
2.3 将分词转换为分词ID 24
2.4 添加特殊上下文分词 29
2.5 字节对编码 33
2.6 使用滑动窗口进行数据采样 35
2.7 创建分词嵌入 41
2.8 编码词位置 43
编码注意力机制 50
3.1 建模长序列的问题 52
3.2 使用注意力机制捕捉数据依赖关系 54
3.3 使用自注意力关注输入的不同部分 55 一个简单的无训练权重的自注意力机制
56 计算所有输入分词的注意力权重 61
3.4 实现带训练权重的自注意力 64 分步计算注意力权重 65 ■ 实现一个紧凑的自注
意力Python类 70
3.5 使用因果注意力隐藏未来单词 74 应用因果注意力掩码 75 ■ 使用dropout屏蔽额
外的注意力权重 78 ■ 实现一个紧凑的因果注意力类 80
3.6 将单头注意力扩展到多头注意力 82 堆叠多个单头注意力层 82 ■ 使用权重分割
实现多头注意力 86
从头实现一个GPT模型以生成文本 92
4.1 编码LLM架构 93
4.2 使用层归一化归一化激活函数 99
4.3 实现带有GELU激活函数的前馈网络 105
4.4 添加快捷连接 109
4.5 在Transformer块中连接注意力和线性层 113
4.6 编码GPT模型 117
4.7 生成文本 122
在未标注数据上预训练 128
5.1 评估生成式文本模型 129 使用GPT生成文本 130 ■ 计算文本生成损失 132 ■ 计
算训练集和验证集的损失 140
5.2 训练LLM 146
5.3 控制随机性的解码策略 151 温度缩放 152 ■ Top-k采样 155 修改文本生成函数
157
5.4 在PyTorch中加载和保存模型权重 159
5.5 从OpenAI加载预训练权重 160
细调用于分类 169
6.1 不同类别的细调 170
6.2 准备数据集 172
6.3 创建数据加载器 175
6.4 使用预训练权重初始化模型 181
6.5 添加分类头 183
6.6 计算分类损失和准确率 190
6.7 在监督数据上细调模型 195
6.8 使用LLM作为垃圾邮件分类器 200
细调以遵循指令 204
7.1 指令细调简介 205
7.2 准备用于监督指令细调的数据集 207
7.3 将数据组织成训练批次 211
7.4 为指令数据集创建数据加载器 223
7.5 加载预训练LLM 226
7.6 在指令数据上细调LLM 229
7.7 提取并保存响应 233
7.8 评估细调后的LLM 238
7.9 结论 247 下一步是什么? 247 ■ 跟上快速发展的领域 248 ■ 最后的话 248
附录A PyTorch简介 251
附录B 参考文献和进一步阅读 289
附录C 练习解答 300
附录D 为训练循环添加额外功能 313
附录E 使用LoRA进行参数高效的细调 322
索引 337
前言
我一直对语言模型着迷。十多年前,我的AI之旅始于一门统计模式分类课程,这
引导我完成了第一个独立项目:开发一个基于歌词检测歌曲情绪的模型和Web应
用程序。
快进到2022年,随着ChatGPT的发布,大型语言模型(LLMs)席卷全球,并彻底
改变了我们许多人的工作方式。这些模型非常多功能,能够帮助我们完成诸如检
查语法、撰写电子邮件、总结长篇文档等任务。这一切都得益于它们解析和生成
类似人类文本的能力,这对于客户服务、内容创作甚至更技术性的领域如编程和
数据分析都非常重要。
正如其名,LLMs的一个显著特点是它们“大”——非常大——包含数百万到数十亿
个参数。(相比之下,使用更传统的机器学习或统计方法,可以用只有两个参数
的小型模型对Iris花数据集进行超过90%的准确分类。)然而,尽管LLMs的规模远
大于传统方法,它们并不一定是黑箱。
在本书中,您将逐步学习如何构建一个LLM。到结束时,您将对像ChatGPT中使
用的LLM如何在基础层面上工作有一个坚实的理解。我相信,掌握每个基本概念
和底层代码是成功的关键。这不仅有助于修复错误和提高性能,还使实验新想法
成为可能。
几年前,当我开始接触LLMs时,不得不通过研读大量研究论文和不完整的代码库
来艰难地理解它们。通过这本书,我希望通过开发和分享一个详细描述LLM所有
主要组件和发展阶段的逐步实现教程,使LLMs更加易于访问。
我坚信,理解LLMs的最佳方法是从头编写一个——并且你会发现这也可以很有
趣!
祝您阅读和编码愉快!
鸣谢
写一本书是一项重大的任务,我想向我的妻子Liza表达诚挚的感谢,感谢她在这
个过程中给予的耐心和支持。她的无条件的爱和不断的鼓励是必不可少的。
我非常感谢Daniel Kleine,他对正在进行的章节和代码提供了宝贵的反馈,超出了
预期。凭借他敏锐的细节眼光和深刻的建议,Daniel的贡献无疑使这本书变得更加
流畅和有趣的阅读体验。
我还想感谢Manning Publications的优秀员工,包括Michael Stephens,感谢他们多
次富有成效的讨论,帮助塑造了这本书的方向,以及Dustin Archibald,他的建设
性反馈和指导在遵守Manning指南方面至关重要。我也感谢你们在适应这种非传统
从头实现方法的独特要求方面的灵活性。特别感谢Aleksandar Dragosavljevic´、
Kari Lucke和Mike Beady的专业排版工作,以及Susan Honeywell和她的团队对图形
的优化和润色。
我想向Robin Campbell和她出色的营销团队表达衷心的感谢,感谢他们在整个写作
过程中的宝贵支持。
最后,我要感谢审稿人:Anandaganesh Balakrishnan、Anto Aravinth、Ayush Bihani、Bassam Ismail、Benjamin Muskalla、Bruno Sonnino、Christian Prokopp、
Daniel Kleine、David Curran、Dibyendu Roy Chowdhury、Gary Pass、Georg Sommer、Giovanni Alzetta、Guillermo Alcántara、Jonathan Reeves、Kunal Ghosh、
Nicolas Modrzyk、Paul Silisteanu、Raul Ciotescu、Scott Ling、Sriram Macharla、
Sumit Pal、Vahid Mirjalili、Vaijanath Rao和Walter Reade,感谢他们对草稿的详尽
反馈。你们敏锐的眼光和深刻的评论对于提高本书的质量至关重要。
感谢每一位为这段旅程做出贡献的人。你们的支持、专业知识和奉献精神对于本
书的成功出版至关重要。谢谢!
关于本书
从零开始构建大型语言模型是为了帮助你理解和创建自己的类似GPT的大型语言
模型(LLMs)。本书首先专注于文本数据处理和编码注意力机制的基础知识,然
后引导你从头实现一个完整的GPT模型。书中还涵盖了预训练机制以及针对特定
任务(如文本分类和遵循指令)的微调。读完这本书后,你将深入了解LLMs的工
作原理,并具备构建自己模型的技能。虽然你创建的模型规模较小,但它们使用
相同的概念,作为掌握构建最先进的LLMs核心机制和技术的强大教育工具。
谁应该阅读这本书
《从零开始构建大型语言模型》适合机器学习爱好者、工程师、研究人员、学生
和实践者,他们希望深入了解LLM的工作原理,并学习如何从零开始构建自己的
模型。无论是初学者还是有经验的开发人员,都可以利用现有的技能和知识来掌
握创建LLM所使用概念和技术。
本书的独特之处在于其对构建LLM整个过程的全面覆盖,从处理数据集到实现模
型架构,再到在未标注数据上进行预训练以及针对特定任务进行微调。截至写作
时,还没有其他资源提供如此完整且动手实践的方法来从头构建LLM。
要理解本书中的代码示例,您需要具备扎实的Python编程基础。虽然对机器学
习、深度学习和人工智能有一定的了解会有所帮助,但这些领域的广泛背景并不
是必需的。LLM是AI的一个独特子集,因此即使您是这个领域的新人,也能够跟
上进度。
如果您有一些深度神经网络的经验,可能会发现某些概念更加熟悉,因为LLM是
基于这些架构构建的。然而,精通PyTorch并不是前提条件。附录A提供了PyTorch
的简明介绍,使您具备理解书中代码示例所需的技能。
高中水平的数学理解,特别是向量和矩阵的操作,可以帮助我们探索LLM的内部
工作原理。但是,高级数学知识并不是掌握本书中呈现的关键概念和思想所必需
的。
最重要的前提条件是对Python编程的坚实基础。有了这些知识,您将为探索LLM
的迷人世界做好准备,并理解本书中呈现的概念和代码示例。
本书的组织结构:路线图
本书设计为顺序阅读,因为每一章都建立在前一章引入的概念和技术之上。本书
分为七章,涵盖了LLM及其实现的基本方面。
第1章提供了关于LLM背后基本概念的高层次介绍。它探讨了Transformer架构,这
是ChatGPT平台等使用的LLM的基础。
第2章制定了从零开始构建LLM的计划。它涵盖了为LLM训练准备文本的过程,包
括将文本拆分为单词和子词标记,使用字节对编码进行高级标记化,使用滑动窗
口方法采样训练示例,以及将标记转换为输入LLM的向量。
第3章专注于LLM中使用的注意力机制。它介绍了基本的自注意力框架,并逐步发
展到增强的自注意力机制。本章还涵盖了因果注意力模块的实现,该模块使LLM
能够逐个生成标记,通过随机选择注意权重并使用dropout进行掩码以减少过拟
合,并将多个因果注意力模块堆叠成多头注意力模块。
第4章专注于编写类似GPT的LLM,可以训练生成类似人类的文本。它涵盖了诸如
规范化层激活以稳定神经网络训练的技术,在深度神经网络中添加捷径连接以更
有效地训练模型,实现Transformer块以创建不同大小的GPT模型,以及计算GPT模
型的参数数量和存储需求。
第5章实现了LLM的预训练过程。它涵盖了计算训练集和验证集损失以评估LLM生
成文本的质量,实现训练函数并预训练LLM,保存和加载模型权重以继续训练
LLM,以及从OpenAI加载预训练权重。
第6章介绍了不同的LLM微调方法。它涵盖了为文本分类准备数据集,修改预训练
的LLM以进行微调,微调LLM以识别垃圾邮件,并评估微调后的LLM分类器的准
确性。
第7章探讨了LLM指令微调的过程。它涵盖了为监督指令微调准备数据集,组织指
令数据以形成训练批次,加载预训练的LLM并微调其以遵循人类指令,提取LLM
生成的指令响应以供评估,以及评估指令微调后的LLM。
关于代码
为了尽可能方便您跟随,本书的所有代码示例均可在Manning网站上的
https://www.manning.com/books/build-a-large-language-model-from-scratch获取,也
可以在GitHub上的Jupyter笔记本格式获取,地址为https://github.com/rasbt/LLMsfrom-scratch。不用担心卡住——所有代码练习的解决方案都可以在附录C中找
到。
本书包含许多源代码示例,既作为编号列表出现,也嵌入在普通文本中。在这两
种情况下,源代码均采用固定宽度字体格式化,以将其与普通文本区分开来。
在许多情况下,原始源代码已重新格式化;我们添加了换行符并调整了缩进,以
适应书中的可用页面空间。在少数情况下,即使这样做也不够,因此列表中包含
行延续标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常已被删
除。代码注释伴随许多列表,突出重要概念。
本书的一个关键目标是易用性,因此代码示例经过精心设计,可以在普通笔记本
电脑上高效运行,而无需任何特殊硬件。但是,如果您有GPU访问权限,某些部
分提供了有用的提示,以扩展数据集和模型以充分利用额外的计算能力。
在本书中,我们将使用PyTorch作为我们的张量和深度学习库,从头实现LLM。如
果您是PyTorch的新手,我建议您从附录A开始,该附录提供了深入介绍,包括设
置建议。
liveBook讨论论坛
购买《从零开始构建大型语言模型》即免费获得对Manning在线阅读平台liveBook
的访问权限。使用liveBook的专属讨论功能,您可以在全球范围内或针对特定章节
或段落附加评论。做笔记、提问和回答技术问题、并从作者和其他用户那里获得
帮助都非常容易。要访问论坛,请访问https://livebook.manning.com/book/build-alarge-language-model-from-scratch/discussion。您还可以在https://livebook.manning.com/discussion了解更多关于Manning论坛和行为准则的信息。
Manning对读者的承诺是提供一个有意义的对话场所,使读者之间以及读者与作者
之间的交流成为可能。这并不意味着作者必须参与一定量的讨论,因为作者对论
坛的贡献是自愿的(且无偿)。我们建议您尝试向作者提出一些具有挑战性的问
题,以免他的兴趣分散!只要书籍仍在印刷中,论坛和以前讨论的存档都将可以
从出版商的网站访问。
其他在线资源
对最新的AI和LLM研究趋势感兴趣吗?
请访问我的博客https://magazine.sebastianraschka.com,我在那里定期讨论有关LLM
的最新AI研究。
需要快速掌握深度学习和PyTorch的帮助吗? 我的网站https://sebastianraschka.com/teaching提供了几门免费课程。这些资源可以帮助您快速掌握最新技术。
寻找与本书相关的补充材料吗? 请访问本书的GitHub仓库https://github.com/rasbt/LLMs-from-scratch,以查找额外资源和示例,以补充您的学习。
关于作者
SEBASTIAN RASCHKA,博士,已在机器学习和AI领域工作超过十年。除了是一
名研究员,Sebastian对教育充满热情。他以其畅销的Python机器学习书籍和对开源
的贡献而闻名。
Sebastian目前是Lightning AI的研究工程师,专注于LLM的实现和训练。在进入行
业之前,Sebastian是威斯康星大学麦迪逊分校统计系的助理教授,专注于深度学
习研究。您可以通过https://sebastianraschka.com了解更多关于Sebastian的信息。
关于封面插图
《从零开始构建大型语言模型》封面上的插图名为“Le duchesse”或“公爵夫人”,取
自Louis Curmer于1841年出版的一本书。每幅插图都是手工精细绘制和着色的。
在那个时代,人们很容易通过他们的着装识别出他们居住的地方以及他们的职业
或社会地位。Manning通过基于几个世纪前丰富多样的区域文化的图片,庆祝计算
机行业的创新和主动性,这些图片来自如这一类的收藏品,使这些文化重焕生
机。
1 理解大型语言模型
本章内容
对大型语言模型(LLMs)背后的基本概念进行高层次的解释
对派生出LLMs的Transformer架构的见解
从头构建LLM的计划
大型语言模型(LLMs),例如OpenAI的ChatGPT提供的模型,是过去几年中开发
的深度神经网络模型。它们为自然语言处理(NLP)带来了新的时代。在LLMs出
现之前,传统方法擅长于分类任务,如电子邮件垃圾邮件分类和可以通过手工编
写规则或更简单模型捕捉的简单模式识别。然而,它们通常在需要复杂理解和生
成能力的语言任务中表现不佳,例如解析详细指令、进行上下文分析以及创建连
贯且上下文适当的原创文本。例如,以前的语言模型无法根据关键词列表撰写电
子邮件——这是一项对于当代LLMs来说非常简单的任务。
LLMs具有理解、生成和解释人类语言的卓越能力。然而,重要的是要澄清当我们
说语言模型“理解”时,我们指的是它们能够以连贯且上下文相关的方式处理和生
成文本,而不是指它们具备类似人类的意识或理解能力。
得益于深度学习的进步,这是机器学习和人工智能(AI)的一个分支,专注于神
经网络,LLMs经过大量文本数据训练。这种大规模训练使LLMs能够捕捉比以前
的方法更深层次的上下文信息和人类语言的细微差别。因此,LLMs在广泛的NLP
任务中表现出显著改进,包括文本翻译、情感分析、问答等。
与早期的NLP模型相比,现代LLMs的重要区别在于,早期的NLP模型通常是为特
定任务设计的,如文本分类、语言翻译等。虽然这些早期的NLP模型在其狭窄的
应用中表现出色,但LLMs在广泛的NLP任务中展示了更广泛的专业能力。
LLMs的成功可以归功于支撑许多LLMs的Transformer架构以及用于训练LLMs的海
量数据,使它们能够捕捉到难以手动编码的各种语言细微差别、上下文和模式。
转向基于Transformer架构实现模型并使用大型训练数据集训练LLMs的转变从根本
上改变了NLP,提供了更强大的工具来理解和与人类语言互动。
以下讨论为实现本书的主要目标奠定了基础:通过逐步在代码中实现基于Transformer架构的类似ChatGPT的LLM来理解LLMs。
1.1 什么是LLM?
LLM是一种旨在理解和生成类人文本的神经网络。这些模型是经过大量文本数据
训练的深度神经网络,有时涵盖了整个公开可用文本的很大一部分。
“大型”语言模型中的“大型”指的是模型参数的数量和它所训练的庞大数据集。像
这样的模型通常有数十亿甚至数百亿个参数,这些参数是网络中的可调权重,在
训练过程中被优化以预测序列中的下一个词。下一个词预测是有意义的,因为它
利用了语言的固有序列性,以训练模型理解文本中的上下文、结构和关系。尽管
这是一个非常简单的任务,但许多研究人员对此感到惊讶,因为它能够产生如此
强大的模型。在后面的章节中,我们将讨论并逐步实现下一个词的训练过程。
LLMs使用一种称为Transformer的架构,使它们能够在进行预测时选择性地关注输
入的不同部分,使它们特别擅长处理人类语言的细微差别和复杂性。
由于大型语言模型(LLMs)能够生成文本,它们常被视为生成式人工智能(Generative Artificial Intelligence)的一种形式,通常简称为生成式AI或GenAI。如图1.1所
示,人工智能涵盖更广泛的领域,旨在创建能够执行类人智能任务的机器系统,
包括理解语言、识别模式和做出决策,其下包含机器学习、深度学习等子领域。
图1.1 这个层次结构描绘了不同领域之间的关系,表明LLMs代表了深度学习技术
的具体应用,利用其处理和生成类人文本的能力。深度学习是机器学习的一个专
门分支,专注于使用多层神经网络。机器学习和深度学习旨在实施算法,使计算
机能够从数据中学习并执行通常需要人类智能的任务。
用于实现AI的算法是机器学习领域的重点。具体来说,机器学习涉及开发可以从
数据中学习并进行预测或决策的算法,而无需显式编程。为了说明这一点,想象
一个垃圾邮件过滤器作为机器学习的实际应用。与其手动编写规则来识别垃圾邮
件,不如将标记为垃圾邮件和合法邮件的示例输入机器学习算法。通过最小化其
在训练数据集上的预测误差,模型学会了识别垃圾邮件的模式和特征,从而能够
将新邮件分类为垃圾邮件或非垃圾邮件。
如图1.1所示,深度学习是机器学习的一个子集,专注于使用三层或更多层(也称
为深度神经网络)的神经网络来建模数据中的复杂模式和抽象。与深度学习不
同,传统机器学习需要手动特征提取。这意味着人类专家需要识别和选择最相关
的特征供模型使用。
虽然AI领域现在主要由机器学习和深度学习主导,但它还包括其他方法,例如基
于规则的系统、遗传算法、专家系统、模糊逻辑或符号推理。
回到垃圾邮件分类的例子,在传统机器学习中,人类专家可能会手动从电子邮件
文本中提取特征,例如某些触发词(如“奖品”、“赢取”、“免费”)的频率、感叹号
的数量、全大写字母的使用或可疑链接的存在。这个基于专家定义特征创建的数
据集随后将用于训练模型。与传统机器学习不同,深度学习不需要手动特征提
取。这意味着人类专家不需要为深度学习模型识别和选择最相关的特征。(但
是,无论是传统机器学习还是深度学习,垃圾邮件分类仍然需要收集标签,如垃
圾邮件或非垃圾邮件,这些标签需要由专家或用户收集。)
让我们看看LLMs今天可以解决的一些问题,LLMs面临的挑战,以及我们稍后将
实现的一般LLM架构。
1.2 LLMs的应用
由于其解析和理解非结构化文本数据的强大能力,LLMs在各个领域都有广泛的应
用。如今,LLMs被用于机器翻译、生成新文本(见图1.2)、情感分析、文本摘要
等任务。LLMs最近还被用于内容创作,如写作小说、文章,甚至是计算机代码。
LLMs还可以为复杂的聊天机器人和虚拟助手提供动力,如OpenAI的ChatGPT或
Google的Gemini(原名Bard),它们可以回答用户查询并增强传统的搜索引擎,
如Google搜索或Microsoft Bing。
此外,LLMs可用于从医学或法律等专业领域的大量文本中有效检索知识。这包括
筛选文档、总结长篇段落和回答技术问题。
简而言之,LLMs对于自动化任何涉及解析和生成文本的任务都极为宝贵。它们的
应用几乎是无限的,随着我们继续创新和探索使用这些模型的新方法,很明显
LLMs有潜力重新定义我们与技术的关系,使其更加对话化、直观和易于访问。
我们将专注于从头开始理解LLMs的工作原理,编写一个可以生成文本的LLM。您
还将学习使LLMs能够执行查询的技术,从回答问题到总结文本、翻译文本成不同
语言等。换句话说,您将通过逐步构建一个复杂的LLM助手(如ChatGPT)来了
解它是如何工作的。
图1.2 LLM接口使用户和AI系统之间能够进行自然语言通信。此截图显示ChatGPT
根据用户的规格编写了一首诗。
1.3 构建和使用大语言模型的阶段
为什么我们要构建自己的大语言模型(LLM)?从头开始编写一个 LLM 是理解其
机制和局限性的绝佳练习。此外,它使我们具备了必要的知识,可以对现有的开
源 LLM 架构进行预训练或微调,以适应我们特定领域的数据集或任务。
注:当前大多数 LLM 都是使用 PyTorch 深度学习库实现的,这也是我们将要使用
的工具。读者可以在附录 A 中找到关于 PyTorch 的详细介绍。
研究表明,在建模性能方面,为特定任务或领域定制的 LLM 可以优于通用
LLM,例如 ChatGPT 提供的那些为广泛应用程序设计的 LLM。这些例子包括
BloombergGPT(专门用于金融)和为医疗问题回答定制的 LLM(详见附录 B)。
使用自定义 LLM 有几个优势,特别是在数据隐私方面。例如,公司可能不愿意因
保密问题将敏感数据与 OpenAI 等第三方 LLM 提供商共享。此外,开发较小的自
定义 LLM 可以直接部署在客户设备上,如笔记本电脑和智能手机,这是 Apple 等
公司目前正在探索的方向。
这种本地实现可以显著减少延迟并降低服务器相关的成本。此外,自定义 LLM 使
开发者拥有完全的自主权,可以根据需要控制模型的更新和修改。
创建 LLM 的一般过程包括预训练和微调。“预”训练指的是初始阶段,其中像
LLM 这样的模型在大型、多样化的数据集上进行训练,以发展对语言的广泛理
解。这个预训练模型随后作为基础资源,可以通过微调进一步优化,即在更窄的
数据集上针对特定任务或领域进行训练。这种包含预训练和微调的两阶段训练方
法如图 1.3 所示。
图 1.3 预训练 LLM 包括在大型文本数据集上进行下一个词预测。预训练后的 LLM
可以使用较小的标注数据集进行微调。
创建 LLM 的第一步是在大量文本数据上对其进行训练,有时称为原始文本。这里
的“原始”指的是这些数据只是普通文本,没有任何标注信息。(可能会应用过
滤,例如去除格式字符或未知语言的文档。)
注:具有机器学习背景的读者可能注意到,传统机器学习模型和通过常规监督学
习范式训练的深度神经网络通常需要标注信息。然而,这并不适用于 LLM 的预训
练阶段。在这个阶段,LLM 使用自监督学习,模型根据输入数据生成自己的标
签。
LLM 的第一个训练阶段也称为预训练,创建一个初始的预训练 LLM,通常称为基
础模型或基础模型。一个典型的例子是 GPT-3 模型(ChatGPT 提供的原始模型的
前身)。该模型能够完成文本补全,即完成用户提供的半写句子。它还具有有限
的少样本能力,这意味着它可以基于少量示例学习执行新任务,而不需要大量的
训练数据。
在通过大规模文本数据集训练获得预训练 LLM 后,我们可以进一步在标注数据上
训练 LLM,这称为微调。
最流行的两类 LLM 微调是指令微调和分类微调。在指令微调中,标注数据集由指
令和答案对组成,例如翻译文本的查询及其正确翻译文本。在分类微调中,标注
数据集由文本及其关联的类别标签组成,例如带有“垃圾邮件”和“非垃圾邮件”标
签的电子邮件。
我们将涵盖预训练和微调 LLM 的代码实现,并将在预训练基础 LLM 后深入探讨
指令微调和分类微调的具体细节。
1.4 介绍 Transformer 架构
大多数现代 LLM 依赖于 Transformer 架构,这是一种在 2017 年论文《Attention Is
All You Need》(https://arxiv.org/abs/1706.03762)中引入的深度神经网络架构。要
理解 LLM,我们必须了解最初的 Transformer,它最初是为机器翻译开发的,用于
将英语文本翻译成德语和法语。图 1.4 显示了一个简化的 Transformer 架构。
Transformer 架构由两个子模块组成:编码器和解码器。编码器模块处理输入文本
并将其编码为一系列数值表示或向量,这些向量捕捉输入的上下文信息。然后,
解码器模块接收这些编码的向量并生成输出文本。在翻译任务中,例如,编码器
会将源语言的文本编码为向量,解码器会解码这些向量以生成目标语言的文本。
编码器和解码器都由许多层组成,通过所谓的自注意力机制连接。您可能对输入
如何预处理和编码有很多疑问,这些问题将在后续章节中逐步解释。
Transformer 和 LLM 的一个关键组件是自注意力机制(未显示),它使模型能够
根据彼此之间的相对重要性来权衡序列中不同单词或标记的重要性。这种机制使
模型能够捕捉长距离依赖和输入数据中的上下文关系,增强其生成连贯且上下文
相关输出的能力。但由于其复杂性,我们将把进一步的解释推迟到第 3 章,在那
里我们将逐步讨论和实现它。
图 1.4 初始 Transformer 架构的简化描绘,这是一个用于语言翻译的深度学习模
型。Transformer 由两部分组成:(a) 编码器,处理输入文本并生成嵌入表示(一种
捕捉多个不同因素的数值表示),(b) 解码器,使用这些嵌入表示逐词生成翻译后
的文本。此图展示了翻译过程的最后阶段,解码器只需生成最后一个词
(“Beispiel”),给定原始输入文本(“This is an example”)和部分翻译的句子
(“Das ist ein”),以完成翻译。
后来的 Transformer 架构变体,如 BERT(双向编码器表示来自 Transformer 的缩
写)和各种 GPT 模型(生成预训练 Transformer 的缩写),在此基础上进行了改
进,以适应不同的任务。如果感兴趣,请参阅附录 B 获取更多阅读建议。
BERT 基于原始 Transformer 的编码器子模块构建,其训练方法与 GPT 不同。虽然
GPT 专为生成任务设计,但 BERT 及其变体专注于掩码词预测,即预测给定句子
中的掩码或隐藏词,如图 1.5 所示。这种独特的训练策略使 BERT 在文本分类任务
(包括情感预测和文档分类)中表现出色。作为一个应用实例,截至本文撰写
时,X(原 Twitter)使用 BERT 来检测有毒内容。
图 1.5 Transformer 编码器和解码器子模块的可视化表示。左侧的编码器部分展示
了类似 BERT 的 LLM,专注于掩码词预测,主要用于文本分类任务。右侧的解码
器部分展示了类似 GPT 的 LLM,专为生成任务设计,产生连贯的文本序列。
另一方面,GPT 专注于原始 Transformer 架构的解码部分,旨在执行生成文本的任
务。这包括机器翻译、文本摘要、小说写作、编写计算机代码等。
GPT 模型主要设计和训练用于执行文本补全任务,但也展示了其能力的多功能
性。这些模型擅长执行零样本和少样本学习任务。零样本学习是指在没有任何先
前特定示例的情况下泛化到完全未见过的任务的能力。另一方面,少样本学习涉
及从用户提供的少量示例中学习,如图 1.6 所示。
图 1.6 除了文本补全外,类似 GPT 的 LLM 可以根据输入解决各种任务,而无需重
新训练、微调或更改任务特定的模型架构。有时在输入中提供目标示例是有帮助
的,这被称为少样本设置。然而,类似 GPT 的 LLM 也可以在没有特定示例的情
况下执行任务,这被称为零样本设置。
Transformer 与 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) 的预训练数据集
表 1.1 报告了数据集中的标记数量,其中标记是模型读取的文本单元,数据集中的
标记数量大致相当于文本中的单词和标点符号的数量。第 2 章将介绍分词化,即
将文本转换为标记的过程。
主要结论是,这种训练数据集的规模和多样性使得这些模型在各种任务中表现出
色,包括语言语法、语义和上下文——甚至一些需要一般知识的任务。
GPT-3 数据集详情
表 1.1 显示了用于 GPT-3 的数据集。表中的比例列总计为 100% 的采样数据,经过
四舍五入调整。尽管标记数量列中的子集总数为 4990 亿,但模型仅在 3000 亿个
标记上进行了训练。GPT-3 论文的作者未说明为何模型未在所有 4990 亿个标记上
进行训练。
作为参考,请考虑 CommonCrawl 数据集的大小,该数据集本身包含 4100 亿个标
记,需要约 570 GB 的存储空间。相比之下,像 Meta 的 LLaMA 这样的后续模型
迭代扩展了其训练范围,包括额外的数据源,如 Arxiv 研究论文(92 GB)和
StackExchange 的代码相关问答(78 GB)。
GPT-3 论文的作者没有共享训练数据集,但有一个可公开获取的类似数据集是
Dolma:Soldaini 等人于 2024 年发布的《用于大型语言模型预训练研究的三万亿
标记开放语料库》(https://arxiv.org/abs/2402.00159)。然而,该集合可能包含受
版权保护的作品,具体使用条款可能取决于预期用途和国家。
这些模型的预训练性质使其在进一步微调下游任务时非常灵活,因此它们也被称
为基础或基础模型。预训练 LLM 需要访问大量资源且成本非常高。例如,GPT-3
的预训练成本估计为 460 万美元的云计算信用(https://mng.bz/VxEW)。
好消息是,许多预训练的 LLM 可以作为开源模型使用,作为通用工具来编写、提
取和编辑未包含在训练数据中的文本。此外,LLM 可以在特定任务上使用相对较
小的数据集进行微调,减少所需的计算资源并提高性能。
我们将实现预训练代码,并用它来预训练一个 LLM 以供教育目的。所有计算都可
以在消费级硬件上执行。在实现预训练代码后,我们将学习如何重用公开可用的
模型权重并将它们加载到我们实现的架构中,从而在微调 LLM 时跳过昂贵的预训
练阶段。
1.6 更深入地了解 GPT 架构
GPT 最初由 Radford 等人在 OpenAI 的论文“通过生成式预训练改进语言理
解”(https://mng.bz/x2qg)中提出。GPT-3 是该模型的一个放大版本,具有更多参
数并在更大的数据集上进行了训练。此外,ChatGPT 中提供的原始模型是通过对
GPT-3 在大型指令数据集上进行微调而创建的,使用了 OpenAI 的 InstructGPT 论
文(https://arxiv.org/abs/2203.02155)中的方法。如图 1.6 所示,这些模型是功能强
大的文本补全模型,并可以执行其他任务,如拼写纠正、分类或语言翻译。这实
际上非常了不起,因为 GPT 模型是在相对简单的下一个单词预测任务上进行预训
练的,如图 1.7 所示。
下一个单词预测任务是一种自监督学习形式,即一种自标注形式。这意味着我们
不需要显式收集训练数据的标签,而是可以使用数据本身的结构:我们可以使用
句子或文档中的下一个单词作为模型应该预测的标签。由于这种下一个单词预测
任务允许我们“即时”创建标签,因此可以使用大规模未标注文本数据集来训练
LLM。
与我们在第 1.4 节中介绍的原始 Transformer 架构相比,一般的 GPT 架构相对简
单。本质上,它只是解码器部分,没有编码器(图 1.8)。由于像 GPT 这样的解
码器风格模型通过逐词预测文本生成文本,因此它们被认为是一种自回归模型。
自回归模型将其先前的输出作为未来预测的输入。因此,在 GPT 中,每个新词的
选择基于其前序序列,从而提高了生成文本的一致性。
像 GPT-3 这样的架构比原始 Transformer 模型大得多。例如,原始 Transformer 重
复了六次编码器和解码器块。GPT-3 具有 96 层 Transformer 和总共 1750 亿个参
数。
图 1.8 GPT 架构仅使用原始 Transformer 的解码器部分。它设计为单向从左到右处
理,非常适合用于文本生成和下一个单词预测任务,以迭代方式逐词生成文本。
GPT-3 于 2020 年推出,在深度学习和大型语言模型开发的标准下,这已经算是很
久以前的事了。然而,更近期的架构,如 Meta 的 Llama 模型,仍然基于相同的基
本概念,只引入了细微的修改。因此,理解 GPT 仍然非常重要,我将重点实现支
持 GPT 的主要架构,并提供指向替代 LLM 使用的具体调整的指针。
虽然原始 Transformer 模型由编码器和解码器块组成,旨在用于语言翻译,但 GPT
模型——尽管其更大但更简单的纯解码器架构旨在进行下一个单词预测——也能
够执行翻译任务。这种能力最初令研究人员感到意外,因为它来自一个主要训练
在下一个单词预测任务上的模型,而不是专门针对翻译任务的模型。
模型能够执行未明确训练的任务的能力称为新兴行为。这种能力不是在训练期间
显式教授的,而是模型暴露于大量多语言数据在不同上下文中时自然产生的结
果。GPT 模型能够“学习”语言之间的翻译模式并执行翻译任务,即使它们未专门
为此任务进行训练,这展示了这些大规模生成语言模型的优势和能力。我们可以
使用单一模型执行多种任务,而无需为每种任务使用不同的模型。
1.7 构建大型语言模型
现在我们已经为理解 LLM 奠定了基础,让我们从头开始编写一个。我们将以 GPT
的基本思想为蓝图,分为三个阶段进行,如图 1.9 所示。
图 1.9 编写 LLM 的三个主要阶段是实现 LLM 架构和数据准备过程(阶段 1),预
训练 LLM 以创建基础模型(阶段 2),以及微调基础模型以成为个人助手或文本
分类器(阶段 3)。
在阶段 1,我们将学习基本的数据预处理步骤并编写每个 LLM 核心的注意力机
制。接下来,在阶段 2,我们将学习如何编写和预训练一个 GPT 类 LLM,使其能
够生成新的文本。我们还将介绍评估 LLM 的基础知识,这对于开发强大的 NLP
系统至关重要。
从头开始预训练 LLM 是一项重大工作,对于 GPT 类模型来说,需要数千到数百
万美元的计算成本。因此,阶段 2 的重点是使用小型数据集实施培训,以供教育
目的。此外,我还提供了加载公开可用模型权重的代码示例。
最后,在阶段 3,我们将对预训练的 LLM 进行微调,以遵循指令,如回答查询或
分类文本——这是许多现实世界应用和研究中最常见的任务。
希望您期待踏上这段激动人心的旅程!
摘要
大型语言模型(LLMs)已经改变了自然语言处理领域,该领域之前主要依赖于显
式的基于规则的系统和更简单的统计方法。LLMs的出现引入了新的深度学习驱动
的方法,从而在理解、生成和翻译人类语言方面取得了进展。现代LLMs主要通过
两个步骤进行训练:– 首先,它们在大量未标注文本语料库上进行预训练,使用句
子中的下一个词作为标签。– 然后,在较小的、带有标签的目标数据集上进行微
调,以遵循指令或执行分类任务。
LLMs基于Transformer架构。Transformer架构的关键思想是注意力机制,它使得
LLM在逐词生成输出时可以有选择地访问整个输入序列。
原始的Transformer架构包括用于解析文本的编码器和用于生成文本的解码器。像
GPT-3和ChatGPT这样的用于生成文本和遵循指令的LLMs只实现了解码模块,简
化了架构。
包含数十亿词汇的大规模数据集对于预训练LLMs至关重要。
虽然GPT类模型的通用预训练任务是预测句子中的下一个词,但这些LLMs表现出
了一些新兴特性,例如分类、翻译或总结文本的能力。一旦LLM经过预训练,由
此产生的基础模型可以更高效地针对各种下游任务进行微调。
在自定义数据集上微调的LLMs可以在特定任务上超越通用LLMs。
2 处理文本数据
本章涵盖
为大型语言模型训练准备文本
将文本拆分为单词和子词标记
字节对编码作为更高级的文本分词方法
使用滑动窗口方法采样训练样本
将标记转换为输入到大型语言模型的向量
到目前为止,我们已经介绍了大型语言模型(LLMs)的一般结构,并了解到它们
是在大量文本上预训练的。具体来说,我们的重点是基于Transformer架构的仅解
码器LLMs,这是ChatGPT和其他流行的GPT类LLMs的基础。
在预训练阶段,LLMs逐词处理文本。使用数百万到数十亿参数的LLMs通过下一
个词预测任务进行训练,产生了具有令人印象深刻能力的模型。然后,这些模型
可以进一步微调以遵循一般指令或执行特定目标任务。但在我们可以实现和训练
LLMs之前,我们需要准备训练数据集,如图2.1所示。
图2.1 编码LLM的三个主要阶段。本章专注于第一阶段的第一步:实现数据样本管
道。
您将学习如何为训练LLMs准备输入文本。这涉及将文本拆分为单个单词和子词标
记,然后将其编码为LLM可以使用的向量表示。您还将了解高级分词方案,如字
节对编码,这是GPT等流行LLMs中使用的方法。最后,我们将实现一种采样和数
据加载策略,以生成训练LLMs所需的输入-输出对。
2.1 理解词嵌入
深度神经网络模型,包括LLMs,无法直接处理原始文本。由于文本是分类数据,
因此与用于实现和训练神经网络的数学运算不兼容。因此,我们需要一种将词语
表示为连续值向量的方法。
注意 对向量和张量计算上下文不熟悉的读者可以在附录A的A.2.2部分了解更多。
将数据转换为向量格式的概念通常称为嵌入。使用特定的神经网络层或其他预训
练的神经网络模型,我们可以嵌入不同类型的数据——例如,视频、音频和文
本,如图2.2所示。然而,需要注意的是,不同的数据格式需要不同的嵌入模型。
例如,为文本设计的嵌入模型不适合嵌入音频或视频数据。
图2.2 深度学习模型无法直接处理视频、音频和文本等原始数据格式。因此,我们
使用嵌入模型将这些原始数据转换为密集向量表示,使深度学习架构能够轻松理
解和处理。具体来说,此图展示了将原始数据转换为三维数值向量的过程。
本质上,嵌入是从离散对象(如词语、图像或整篇文档)到连续向量空间中的点
的映射——嵌入的主要目的是将非数值数据转换为神经网络可以处理的格式。
虽然词嵌入是最常见的文本嵌入形式,但也存在句子、段落或整篇文档的嵌入。
句子或段落嵌入常用于检索增强生成。检索增强生成结合了生成(如生成文本)
和检索(如搜索外部知识库),以在生成文本时提取相关信息,这超出了本书的
范围。由于我们的目标是训练GPT类LLMs,这些模型一次学习生成一个词,因此
我们将重点关注词嵌入。
已开发出多种算法和框架来生成词嵌入。较早且最受欢迎的例子之一是Word2Vec
方法。Word2Vec训练神经网络架构以生成词嵌入,通过预测给定目标词的上下文
或反之亦然。Word2Vec背后的主要思想是出现在相似上下文中的词语往往具有相
似的意义。因此,当将词嵌入投影到二维空间以进行可视化时,类似的术语会聚
集在一起,如图2.3所示。
词嵌入可以有一到数千个维度。更高的维度可能捕捉更细微的关系,但会牺牲计
算效率。
图2.3 如果词嵌入是二维的,我们可以将其绘制在二维散点图中以进行可视化,如
图所示。使用词嵌入技术(如Word2Vec)时,对应于类似概念的词语在嵌入空间
中往往彼此靠近。例如,不同类型的鸟类在嵌入空间中比国家和城市更接近。
虽然我们可以使用预训练模型(如Word2Vec)为机器学习模型生成嵌入,但LLMs
通常会在输入层生成自己的嵌入,并在训练过程中更新这些嵌入。将嵌入优化为
LLM训练的一部分而不是使用Word2Vec的优势在于,嵌入可以根据手头的具体任
务和数据进行优化。我们将在本章后面实现这样的嵌入层。(LLMs还可以创建上
下文化输出嵌入,我们在第3章中讨论。)
不幸的是,高维嵌入对于可视化提出了挑战,因为我们的感知和常见图形表示本
质上限制在三个维度或更少,这就是为什么图2.3显示了二维嵌入在二维散点图
中。然而,在使用LLMs时,我们通常使用更高维度的嵌入。对于GPT-2和
GPT-3,嵌入大小(通常称为模型隐藏状态的维度)根据具体的模型变体和大小而
有所不同。这是一个性能和效率之间的权衡。最小的GPT-2模型(117M和125M参
数)使用768维的嵌入大小作为具体示例。最大的GPT-3模型(175B参数)使用
12,288维的嵌入大小。
接下来,我们将介绍为LLM准备嵌入所需的步骤,包括将文本拆分为单词、将单
词转换为标记以及将标记转换为嵌入向量。
2.2 分词
让我们讨论一下如何将输入文本拆分为单独的标记,这是为LLM创建嵌入所需的
预处理步骤。这些标记可以是单个单词或特殊字符,包括标点符号,如图2.4所
示。
图2.4 在LLM上下文中展示文本处理步骤。在这里,我们将输入文本拆分为单独的
标记,这些标记可以是单词或特殊字符,例如标点符号。
我们将要分词以用于LLM训练的文本是伊迪丝·华顿(Edith Wharton)的短篇小说
《判决》,该作品已进入公共领域,因此可以用于LLM训练任务。该文本可在
Wikisource上获取,网址为https://en.wikisource .org/wiki/The_Verdict,您可以将其
复制并粘贴到一个文本文件中,我将其复制到了一个名为”the-verdict.txt”的文本文
件中。
或者,您可以在本书的GitHub仓库中找到这个”the-verdict.txt”文件,地址为
https://mng.bz/Adng。您可以使用以下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])
print命令会打印总字符数,然后是此文件的前100个字符,以便说明:
Total number of character: 20479
I HAD always thought Jack Gisburn rather a cheap genius–though a good fellow
enough–so it was no
我们的目标是将这个20,479个字符的短篇小说分词为单独的单词和特殊字符,然后
将它们转换为嵌入,用于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.’ ‘test.’]
这种简单的分词方案大部分情况下可以将示例文本拆分为单独的单词;但是,有
些单词仍然与我们希望作为单独列表项的标点符号相连。我们也不将所有文本转
换为小写,因为大小写有助于LLM区分专有名词和普通名词,理解句子结构,并
学习生成带有正确大小写的文本。
让我们修改正则表达式,以在空白字符()、逗号和句号([,.])处进行拆分:
result = re.split(r'([,.]|\s)', text)
print(result)
我们可以看到单词和标点符号现在是单独的列表项,正如我们所希望的那样:
[’Hel is
一个小的问题是列表中仍然包含空白字符。我们可以选择安全地移除这些冗余字
符:
result = [item for item in result if item.strip()]
print(result)
去除空白字符后的输出如下所示:
[‘Hello’, ’ , ‘world’ This ‘is’ ‘a’ ‘test’, ‘.’]
注意:在开发简单分词器时,是否应将空白字符编码为单独字符还是直接移除它
们取决于应用程序及其要求。移除空白字符可以减少内存和计算需求。然而,保
留空白字符在训练对文本结构敏感的模型时可能是有用的(例如,Python代码,
其对缩进和间距敏感)。这里,为了简化和缩短分词输出,我们移除了空白字
符。稍后,我们将切换到包含空白字符的分词方案。
我们设计的分词方案在此简单示例文本上工作良好。让我们进一步修改它,以处
理其他类型的标点符号,如问号、引号以及我们在伊迪丝·华顿短篇小说前100个
字符中看到的破折号,以及其他特殊字符:
最终输出如下:
[‘Hello’, ‘world’ ‘Is’ ‘thi a ’test’, ‘?’]
根据图2.5汇总的结果,我们可以看到我们的分词方案现在可以成功处理文本中的
各种特殊字符。
图2.5 我们目前实现的分词方案将文本拆分为单独的单词和标点符号。在这个特定
示例中,示例文本被拆分为10个单独的标记。
现在我们有了一个基本的分词器,让我们将其应用于伊迪丝·华顿的整个短篇小
说:
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(len(preprocessed))
此print语句输出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所
示。
图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所示)。
图2.7 从新的文本样本开始,我们对文本进行分词,并使用词汇表将文本标记转换
为标记ID。词汇表是从整个训练集中构建的,可以应用于训练集本身及任何新的
文本样本。所展示的词汇表不包含标点符号或特殊字符,以简化说明。
当我们想要将LLM的输出从数字转换回文本时,我们需要一种方法将标记ID转换
回文本。为此,我们可以创建词汇表的逆版本,将标记ID映射回相应的文本标
记。
让我们在Python中实现一个完整的分词器类,其中包含一个encode方法,用于将文
本拆分为标记并执行字符串到整数的映射以生成标记ID;此外,我们还将实现一
个decode方法,用于执行反向的整数到字符串映射,将标记ID转换回文本。以下
代码展示了这个分词器实现。
使用SimpleTokenizerV1 Python类,我们现在可以通过现有的词汇表实例化新的分
词器对象,并用它对文本进行编码和解码,如图2.8所示。
让我们从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]
接下来,让我们看看是否可以使用decode方法将这些标记ID转换回文本:
print(tokenizer.decode(ids))
图2.8 分词器实现共享两个常用方法:encode方法和decode方法。encode方法接收
样本文本,将其拆分为单个标记,并通过词汇表将标记转换为标记ID。decode方
法接收标记ID,将其转换回文本标记,并将文本标记连接成自然文本。
这输出:
‘” It' s the last he painted, you know,” Mrs. Gisburn said with pardonable pride.’
根据此输出,我们可以看到decode方法成功地将标记ID转换回原始文本。
到目前为止一切顺利。我们实现了一个分词器,能够基于训练集片段对文本进行
分词和反向分词。现在让我们将其应用于一个不在训练集中的新文本样本:
text = "Hello, do you like tea?"
print(tokenizer.encode(text))
执行此代码将导致以下错误:
KeyError: ‘Hello’
问题是“Hello”这个词没有出现在《判决》这篇小说中。因此,它不在词汇表中。
这突显了在处理LLM时,需要考虑大型和多样化的训练集以扩展词汇表。
接下来,我们将进一步测试分词器在包含未知单词的文本上的表现,并讨论可以
在训练期间为LLM提供额外上下文的其他特殊标记。
2.4 添加特殊上下文标记
我们需要修改分词器以处理未知词汇。我们还需要解决特殊上下文标记的使用和
添加,这些标记可以增强模型对文本上下文或其他相关信息的理解。这些特殊标
记可以包括未知词汇和文档边界的标识符,例如。特别地,我们将修改词汇表和
分词器 SimpleTokenizerV2,以支持两个新的标记 <|unk|> 和 ,如图 2.9 所示。
图 2.9 我们向词汇表中添加特殊标记以处理某些上下文。例如,我们添加了一个
<|unk|> 标记来表示训练数据中不存在且因此不在现有词汇表中的新词和未知词
汇。此外,我们添加了一个 标记用于分隔不相关的文本来源。
我们可以修改分词器,在遇到不属于词汇表的单词时使用 <|unk|> 标记。此外,我
们在不相关文本之间添加一个标记。例如,在多个独立文档或书籍上训练 GPT 类
似的语言模型时,通常会在每个文档或书籍之前插入一个标记,该标记紧跟在前
一个文本来源之后,如图 2.10 所示。这有助于语言模型理解虽然这些文本来源为
了训练而被连接在一起,但实际上它们是不相关的。
图 2.10 在处理多个独立文本来源时,我们在这些文本之间添加 标记。这些 标记作
为标识符,指示特定段落的开始或结束,从而使得语言模型能够更有效地处理和
理解文本。
现在让我们修改词汇表以包含这两个特殊标记: 和 。通过将它们添加到所有唯一
词汇的列表中:
all_tokens = sorted(list(set(preprocessed)))
all_tokens.extend(["<|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) (’‘, 1130) (’<|unk|>’, 1131)
根据代码输出,我们可以确认这两个新的特殊标记确实已成功纳入词汇表。接下
来,我们相应地调整来自代码清单 2.3 的分词器,如以下清单所示。
清单 2.4 一个处理未知词汇的简单文本分词器
与我们在清单 2.3 中实现的 SimpleTokenizerV1 相比,新的 SimpleTokenizerV2 将
未知词汇替换为 <|unk|> 标记。
现在让我们在实践中尝试这个新的分词器。为此,我们将使用由两个独立且不相
关的句子组成的简单文本样本:
text1 = "Hello, do you like tea?"
text2 = "In the sunlit terraces of the palace."
text = " ".join((text1, text2))
print(text)
输出结果如下:
Hello, do you like tea? In the sunlit terraces of the palace.
接下来,让我们使用 SimpleTokenizerV2 对样本文本进行分词,基于我们在清单
2.2 中创建的词汇表:
这会打印出以下标记 ID 列表:
我们可以看到,标记 ID 列表中包含 1130 的 分隔符标记以及两个 1131 标记,用于
未知词汇。
让我们解码文本以进行快速的合理性检查:
print(tokenizer.decode(tokenizer.encode(text)))
输出结果如下:
<|unk|>, do you like tea? In the sunlit terraces of the <|unk|>.
通过比较解码后的文本与原始输入文本,我们知道训练数据集《The Verdict》中不
包含“Hello”和“palace”这两个词。
根据 LLM 的不同,一些研究人员还考虑了其他特殊标记,例如:
• [BOS](序列开始)——此标记指示文本的开始。它告诉 LLM 内容从
何处开始。
• [EOS](序列结束)——此标记位于文本末尾,当连接多个不相关的文
本时特别有用,类似于 。例如,在组合两篇不同的维基百科文章或书
籍时,[EOS] 标记指示一个文本的结束和下一个文本的开始。
• [PAD](填充)——当使用大于一的批量大小训练 LLM 时,批量中可
能包含长度各异的文本。为了确保所有文本具有相同的长度,较短的
文本使用 [PAD] 标记扩展或“填充”,直到与批量中最长的文本等长。
GPT 模型使用的分词器不需要这些标记;它仅使用 标记以简化操作。 实际上类似
于 [EOS] 标记。 也用于填充。然而,正如我们将在后续章节中探讨的那样,在训
练批量输入时,我们通常使用掩码,这意味着我们不会关注填充标记。因此,选
择哪种标记用于填充变得无关紧要。
此外,GPT 模型使用的分词器也不使用 <|unk|> 标记来处理词汇表外的词汇。相
反,GPT 模型使用字节对编码分词器,将单词分解为子词单元,我们将在下一节
讨论。
2.5 字节对编码
让我们来看一种基于字节对编码(BPE)概念的更复杂的分词方案。BPE分词器被
用于训练诸如GPT-2、GPT-3以及ChatGPT最初使用的模型等大型语言模型
(LLM)。
由于实现BPE可能相对复杂,我们将使用一个现有的Python开源库tiktoken(https://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("tiktoken"))
接下来,我们可以从tiktoken实例化BPE分词器,如下所示:
tokenizer = tiktoken.get_encoding("gpt2")
该分词器的使用方法类似于我们之前通过encode方法实现的SimpleTokenizerV2:
text = (
"Hello, do you like tea? "
"In the sunlit terraces"
"of someunknownPlace."
)
integers = tokenizer.encode(text, allowed_special={""})
print(integers)
代码打印出以下token ID:
[15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114,
286, 617, 34680, 27271, 13]
然后,我们可以使用decode方法将token ID转换回文本,类似于我们的SimpleTokenizerV2:
strings = tokenizer.decode(integers)
print(strings)
代码打印:
Hello, do you like tea? In the sunlit terraces of someunknownPlace.
根据token ID和解码后的文本,我们可以得出两个值得注意的观察结果。首先,标
记被分配了一个相对较大的token ID,即50256。实际上,用于训练如GPT-2、
GPT-3及ChatGPT最初使用的模型的BPE分词器总词汇量为50,257,其中被分配了
最大的token ID。
其次,BPE分词器可以正确地编码和解码未知单词,例如someunknownPlace。BPE
分词器可以处理任何未知单词。它是如何在不使用<|unk|>标记的情况下实现这一
点的?
BPE算法的基础是将不在其预定义词汇表中的单词分解为更小的子词单元甚至单
个字符,从而能够处理词汇表外的单词。因此,感谢BPE算法,如果分词器在分
词过程中遇到不熟悉的单词,它可以将其表示为一系列子词标记或字符,如图2.11
所示。
将未知单词分解为单个字符的能力确保了分词器及其训练的大型语言模型
(LLM)能够处理任何文本,即使其中包含未出现在训练数据中的单词。
练习2.1 字节对编码的未知单词
尝试使用tiktoken库中的BPE分词器对未知单词“Akwirw ier”进行编码并打印各个token ID。然后,对该列表中的每个整数调用decode函数以重现图2.11中所示的映
射。最后,调用decode方法对token ID进行解码,以检查它是否可以重建原始输入
“Akwirw ier”。
关于BPE的详细讨论和实现超出了本书的范围,但简而言之,它通过迭代合并频
繁出现的字符为子词,并将频繁出现的子词合并为单词来构建其词汇表。例如,
BPE首先将所有单独的字符添加到其词汇表中(如“a”,“b”等)。在下一阶段,它
将经常一起出现的字符组合合并为子词。例如,“d”和“e”可能会合并为常见的子词
“de”,如在许多英语单词中出现的“define”、“depend”、“made”和“hidden”。合并是
由频率阈值决定的。
2.6 使用滑动窗口进行数据采样
创建LLM嵌入的下一步是生成训练LLM所需的输入-目标对。这些输入-目标对是
什么样的?正如我们已经了解的,LLM通过预测文本中的下一个词来进行预训
练,如图2.12所示。
图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))
执行这段代码将返回5145,这是应用BPE分词器后的训练集中的总标记数。
接下来,为了演示目的,我们从数据集中移除前50个标记,这会在接下来的步骤
中产生稍微有趣的文本段落:
创建用于下一个词预测任务的输入-目标对最简单和最直观的方法是创建两个变量
x和y,其中x包含输入标记,y包含目标(即输入向右移动1位):
context_size = 4
x = enc_sample[:context_size]
y = enc_sample[1:context_size+1]
print(f"x: {x}")
print(f"y: {y}")
运行上述代码会打印以下输出:
x: [290, 4920, 2241, 287]
y: [4920, 2241, 287, 257]
通过处理输入及其目标(输入向右移动一位),我们可以创建下一个词预测任务
(参见图2.12),如下所示:
for i in range(1, context_size+1):
context = enc_sample[:i]
desired = enc_sample[i]
print(context, "---->", desired)
代码打印结果为:
[290] ----> 4920
[290, 4920] ---> 2241
[290, 4920, 2241] ----> 287
[290, 4920, 2241, 287] ----> 257
箭头(—->)左边的所有内容指的是LLM接收到的输入,箭头右边的标记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内置的Dataset和DataLoader类。有关安装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)
# 其余初始化代码...
GPTDatasetV1类基于PyTorch的Dataset类,并定义了如何从数据集中获取单个行,
每行由一定数量的标记ID组成(基于max_length),并分配给input_chunk张量。
target_chunk张量包含相应的目标。我建议继续阅读,看看当我们将数据集与PyTorch DataLoader结合使用时返回的数据是什么样子的——这将带来更多的直觉和
清晰度。
注意:如果您不熟悉PyTorch Dataset类的结构,例如清单2.5中所示,请参阅附录A
中的A.6节,它解释了PyTorch Dataset和DataLoader类的一般结构和用法。
以下代码使用GPTDatasetV1通过PyTorch DataLoader以批量方式加载输入。
清单2.6 生成带有输入-目标对的批处理的数据加载器
让我们使用上下文大小为4的LLM测试数据加载器,以开发对清单2.5中的GPTDatasetV1类和清单2.6中的create_dataloader_v1函数如何协同工作的直觉:
with open("the-verdict.txt", "r", encoding="utf-8") as f:
raw_text = f.read()
dataloader = create_dataloader_v1(
raw_text,
batch_size=1,
max_length=4,
stride=1,
shuffle=False
)
data_iter = iter(dataloader)
first_batch = next(data_iter)
print(first_batch)
执行上述代码会打印以下内容:
[tensor([[
40,
367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]
first_batch变量包含两个张量:第一个张量存储输入标记ID,第二个张量存储目标
标记ID。由于max_length设置为4,因此每个张量包含四个标记ID。请注意,输入
大小为4相当小,只是为了简单起见。通常,训练LLM时使用的输入大小至少为
256。
为了理解stride=1的意义,让我们从这个数据集中获取另一个批次:
second_batch = next(data_iter)
print(second_batch)
第二个批次包含以下内容:
[tensor([[ 367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]
如果我们比较第一个和第二个批次,可以看到第二个批次的标记ID向右移动了一
个位置(例如,第一个批次输入的第二个ID是367,这是第二个批次输入的第一个
ID)。步长设置决定了输入在批次之间移动的位置数,模拟滑动窗口方法,如图
2.14所示。
练习2.2 不同步长和上下文大小的数据加载器 为了更好地理解数据加载器的工作原
理,尝试使用不同的设置运行它,例如max_length=2和stride=2,以及
max_length=8和stride=2。
像我们目前为止从数据加载器中采样的那样,批量大小为1对于说明目的很有用。
如果您有深度学习的经验,您可能知道较小的批量大小在训练期间需要更少的内
存,但会导致模型更新更加不稳定。就像在常规深度学习中一样,批量大小是一
个权衡因素,在训练LLM时需要实验的超参数。
图 2.14 在从输入数据集中创建多个批次时,我们在文本上滑动一个输入窗口。如
果步长设置为 1,则在创建下一个批次时将输入窗口向右移动一个位置。如果我们
把步长设置为输入窗口大小,我们可以防止批次之间的重叠。
让我们简要看看如何使用数据加载器以大于 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]])
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
的学习过程提供了起点。在第5章中,我们将优化嵌入权重作为LLM训练的一部
分。
图2.15 准备工作包括对文本进行分词,将文本词转换为词ID,并将词ID转换为嵌
入向量。这里,我们考虑之前创建的词ID来创建词嵌入向量。
由于GPT类的LLM是通过反向传播算法训练的深度神经网络,因此需要连续的向
量表示或嵌入。
注意 如果您不熟悉如何使用反向传播算法训练神经网络,请阅读附录A中的B.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_size和output_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训练过程中进行优化。此
外,我们可以看到权重矩阵有六行和三列。每一行对应词汇表中的六个可能词之
一,每一列对应三个嵌入维度之一。
现在,让我们应用一个词ID以获得嵌入向量:
返回的嵌入向量是
如果我们比较词ID 3的嵌入向量与之前的嵌入矩阵,我们会发现它与第四行
(Python索引从零开始,所以是索引为3的那一行)完全相同。换句话说,嵌入层
本质上是一个查找操作,通过词ID从嵌入层的权重矩阵中检索行。
注意 对于熟悉独热编码的人来说,这里描述的嵌入层方法本质上只是实现独热编
码后进行矩阵乘法的一种更高效的方式,这在GitHub上的补充代码中有所说明
(https://mng.bz/ZEB5)。因为嵌入层只是一个更高效的实现方式,相当于独热编
码和矩阵乘法的方法,它可以被视为可以通过反向传播优化的神经网络层。
我们已经看到了如何将单个词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=)
这个输出矩阵中的每一行都是通过从嵌入权重矩阵中查找得到的,如图2.16所示。
现在我们已经从词ID创建了嵌入向量,接下来我们将对这些嵌入向量进行一个小
的修改,以编码文本中词的位置信息。
2.8 编码词位置
原则上,词嵌入是适合用作大型语言模型(LLM)输入的。然而,LLM的一个小
缺点是其自注意力机制(见第3章)没有对序列中词的位置或顺序的概念。前面介
绍的嵌入层的工作方式是,相同的词ID总是映射到相同的向量表示,而不论该词
ID在输入序列中的位置如何,如图2.17所示。
图2.16 嵌入层执行查找操作,从嵌入层的权重矩阵中检索与词ID对应的嵌入向
量。例如,词ID 5的嵌入向量是嵌入层权重矩阵的第六行(它是第六行而不是第
五行,因为Python从0开始计数)。我们假设这些词ID是由第2.3节中的小型词汇表
生成的。
图2.17 嵌入层将词ID转换为相同的向量表示,而不考虑它在输入序列中的位置。
例如,无论词ID 5是在输入向量的第一个还是第四个位置,都会产生相同的嵌入
向量。
原则上,这种确定性的、与位置无关的词ID嵌入对于可重复性是有好处的。然
而,由于LLM的自注意力机制本身也是位置无关的,因此将额外的位置信息注入
LLM是有帮助的。
为此,我们可以使用两种广泛的位置感知嵌入:相对位置嵌入和绝对位置嵌入。
绝对位置嵌入直接与序列中的特定位置相关联。对于输入序列中的每个位置,添
加一个唯一的嵌入以传达其确切位置。例如,第一个词将具有特定的位置嵌入,
第二个词将具有另一个不同的嵌入,依此类推,如图2.18所示。
图2.18 位置嵌入被添加到词嵌入向量中,以创建LLM的输入嵌入。位置向量与原
始词嵌入具有相同的维度。为了简化起见,词嵌入显示为值1。
与关注词的绝对位置不同,相对位置嵌入的重点在于词之间的相对位置或距离。
这意味着模型学习的是“彼此相距多远”而不是“在哪个确切位置”。这里的优点
是,即使在训练过程中没有见过这样的长度,模型也可以更好地泛化到不同长度
的序列。
这两种类型的位置嵌入旨在增强LLM理解词序和关系的能力,确保更准确和上下
文感知的预测。选择它们通常取决于具体的应用和处理的数据性质。
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维向量:
print函数调用返回 torch.Size([8, 4, 256])
8 × 4 × 256维张量输出表明每个词ID现在都被嵌入为256维向量。
对于GPT模型的绝对嵌入方法,我们只需要创建另一个具有与
token_embedding_layer相同嵌入维度的嵌入层:
context_length = max_length
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)
pos_embeddings = pos_embedding_layer(torch.arange(context_length))
print(pos_embeddings.shape)
pos_embeddings的输入通常是占位符向量torch.arange(context_length),它包含从
0到最大输入长度-1的一系列数字。context_length是一个表示LLM支持的输入大
小的变量。这里,我们选择它类似于输入文本的最大长度。实际上,输入文本可
能比支持的上下文长度更长,在这种情况下,我们必须截断文本。
print语句的输出是 torch.Size([4, 256])
如我们所见,位置嵌入张量由四个256维向量组成。我们现在可以将这些直接加到
词嵌入中,PyTorch会将4 × 256维的pos_embeddings张量加到每个批次中的每个4 ×
256维的词嵌入张量上:
input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)
print输出是 torch.Size([8, 4, 256])
我们创建的input_embeddings,如图2.19所示,是可以由主要LLM模块处理的嵌入
输入示例。
图2.19 作为输入处理管道的一部分,输入文本首先被分解为单个词。这些词然后
使用词汇表转换为词ID。词ID被转换为嵌入向量,并添加类似大小的位置嵌入,
最终形成用于主要LLM层的输入嵌入。
总结
大型语言模型(LLMs)需要将文本数据转换为数值向量,称为嵌入(embeddings),因为它们无法处理原始文本。嵌入将离散数据(如单词或图像)转换为
连续的向量空间,使其与神经网络操作兼容。
作为第一步,原始文本被分解为标记(tokens),这些标记可以是单词或字符。然
后,这些标记被转换为整数表示,称为标记ID(token IDs)。
特殊标记,如<|unk|>和,可以添加以增强模型的理解并处理各种上下文,例如未
知单词或标记不相关文本之间的边界。
用于像GPT-2和GPT-3这样的LLM的字节对编码(BPE)分词器可以通过将未知单
词分解为子词单元或单个字符来高效处理未知单词。
我们在分词数据上使用滑动窗口方法来生成输入-目标对,用于LLM训练。PyTorch中的嵌入层充当查找操作,检索与标记ID对应的向量。由此产生的嵌入向量
提供了标记的连续表示,这对于训练深度学习模型(如LLM)至关重要。
虽然标记嵌入为每个标记提供了一致的向量表示,但它们缺乏对序列中位置的感
觉。为了解决这个问题,存在两种主要的位置嵌入类型:绝对位置嵌入和相对位
置嵌入。OpenAI的GPT模型使用绝对位置嵌入,这些嵌入被添加到标记嵌入向量
中,并在模型训练期间进行优化。
3 编码注意力机制
// 示例代码片段
void exampleFunction() {
// 这里是示例代码
}
本章涵盖
使用注意力机制的原因
神经网络中的基本自注意力框架,逐步发展为增强的自注意力机制
允许LLM逐个生成标记的因果注意力模块
通过dropout随机屏蔽选择的注意力权重以减少过拟合
将多个因果注意力模块堆叠成一个多头注意力模块
到目前为止,您已经知道如何通过将文本拆分为单独的单词和子词标记来准备训
练LLM的输入文本,这些标记可以编码为向量表示(嵌入),用于LLM。
现在,我们将研究LLM架构本身的一个重要部分——注意力机制,如图3.1所示。
我们将主要孤立地研究注意力机制,并在机制层面上关注它们。然后我们将编写
围绕自注意力机制的LLM其余部分代码,以实际看到其工作原理并创建一个生成
文本的模型。
图3.1 编码LLM的三个主要阶段。本章重点介绍第一阶段的第二步:实现注意力机
制,这是LLM架构的重要组成部分。
我们将实现四种不同的注意力机制变体,如图3.2所示。这些不同的注意力变体彼
此建立,目标是实现多头注意力的紧凑高效实现,我们可以在下一章编写的LLM
架构中插入该实现。
图3.2 本章将编码的不同注意力机制,从简化的自注意力开始,然后再添加可训练
权重。因果注意力机制为自注意力添加了一个掩码,使LLM可以逐词生成。最
后,多头注意力将注意力机制组织成多个头,使模型能够并行捕获输入数据的各
种方面。
3.1 建模长序列的问题
在深入研究LLM核心的自注意力机制之前,让我们考虑一下不包括注意力机制的
预LLM架构存在的问题。假设我们想开发一个语言翻译模型,将文本从一种语言
翻译成另一种语言。如图3.3所示,我们不能简单地逐词翻译文本,因为源语言和
目标语言之间存在语法结构。
图3.3 当将文本从一种语言翻译成另一种语言时,例如从德语翻译成英语,不能简
单地逐词翻译。相反,翻译过程需要上下文理解和语法对齐。
为了解决这个问题,通常使用带有两个子模块的深度神经网络:编码器和解码
器。编码器的任务是首先读取和处理整个文本,然后解码器生成翻译后的文本。
在变压器出现之前,递归神经网络(RNN)是最流行的用于语言翻译的编码器-解
码器架构。RNN是一种神经网络,其中前一步的输出作为当前步骤的输入,这使
得它们非常适合像文本这样的顺序数据。如果您不熟悉RNN,不用担心——您不
需要详细了解RNN的工作原理来跟随此讨论;我们的重点更多在于编码器-解码器
设置的一般概念。
在编码器-解码器RNN中,输入文本被送入编码器,编码器按顺序处理它。编码器
在每一步更新其隐藏状态(隐藏层的内部值),试图在最终隐藏状态中捕捉输入
句子的全部含义,如图3.4所示。然后解码器使用这个最终隐藏状态开始生成翻译
后的句子,逐词生成。它也在每一步更新其隐藏状态,该隐藏状态应该携带必要
的上下文以进行下一个词的预测。
图3.4 在变压器模型出现之前,编码器-解码器RNN是机器翻译的流行选择。编码
器接受来自源语言的令牌序列作为输入,其中编码器的隐藏状态(中间神经网络
层)对整个输入序列进行压缩表示。然后,解码器使用其当前隐藏状态逐个令牌
开始翻译。
虽然我们不需要了解这些编码器-解码器RNN的内部工作原理,但这里的关键思想
是编码器部分将整个输入文本处理成隐藏状态(记忆单元)。然后解码器接收这
个隐藏状态以生成输出。您可以将这个隐藏状态视为嵌入向量,这是我们第2章讨
论的概念。
编码器-解码器RNN的主要局限性在于,在解码阶段,RNN无法直接访问编码器的
早期隐藏状态。因此,它完全依赖于当前隐藏状态,该状态封装了所有相关信
息。这可能导致上下文丢失,特别是在复杂句子中,依赖关系可能跨越长距离。
幸运的是,要构建LLM并不需要理解RNN。只需记住,编码器-解码器RNN的缺点
激发了注意力机制的设计。
3.2 使用注意力机制捕捉数据依赖性
尽管RNN在翻译短句时效果很好,但对于较长的文本来说效果不佳,因为它们无
法直接访问输入中的先前单词。这种方法的一个主要缺点是RNN必须在传递给解
码器之前将整个编码输入存储在一个隐藏状态中(如图3.4所示)。
因此,研究人员在2014年为RNN开发了Bahdanau注意力机制(以相应论文的第一
作者命名;更多信息请参见附录B),该机制修改了编码器-解码器RNN,使得解
码器可以在每个解码步骤中选择性地访问输入序列的不同部分,如图3.5所示。
图3.5 使用注意力机制,网络的文本生成解码器部分可以选择性地访问所有输入令
牌。这意味着某些输入令牌对于生成给定输出令牌比其他令牌更重要。重要性由
注意力权重决定,我们将在稍后计算。请注意,此图显示了注意力背后的一般思
路,并未描绘Bahdanau机制的确切实现,这是一种RNN方法,超出了本书的范
围。
有趣的是,仅三年后,研究人员发现RNN架构并不是构建自然语言处理的深度神
经网络所必需的,并提出了原始的Transformer架构(在第一章中讨论),其中包
括受Bahdanau注意力机制启发的自注意力机制。
自注意力是一种机制,允许输入序列中的每个位置在计算序列表示时考虑同一序
列中所有其他位置的相关性或“注意”。自注意力是基于Transformer架构的现代
LLM(如GPT系列)的关键组件。
本章将重点介绍GPT类模型中使用的自注意力机制的编码和理解,如图3.6所示。
在下一章中,我们将编码LLM的其余部分。
图3.6 自注意力是Transformer中用于通过允许序列中的每个位置与同一序列中的所
有其他位置交互并权衡其重要性来计算更高效的输入表示的机制。在本章中,我
们将从头开始编码这种自注意力机制,然后在下一章中编码GPT类LLM的其余部
分。
// 示例代码片段
void exampleFunction() {
// 这里是示例代码
}
3.3 使用自注意力机制关注输入的不同部分
现在我们将深入探讨自注意力机制的内部工作原理,并学习如何从头开始编写代
码实现它。自注意力是基于Transformer架构的每个大型语言模型(LLM)的核
心。这个主题可能需要很多专注和注意力(无意双关),但一旦掌握了其基础,
您将攻克本书和LLM实现中最难的一个方面。
“自”在自注意力中的含义
在自注意力中,“自”指的是该机制通过关联单个输入序列中的不同位置来计算注
意力权重的能力。它评估并学习输入本身各部分之间的关系和依赖性,例如句子
中的单词或图像中的像素。
这与传统的注意力机制形成对比,在传统机制中,重点在于两个不同序列元素之
间的关系,例如在序列到序列模型中,注意力可能存在于输入序列和输出序列之
间,如图3.5所示的例子。
由于自注意力可能显得复杂,尤其是如果您是第一次接触它,我们将首先考察一
个简化的版本。然后我们将实现带有可训练权重的自注意力机制,这是LLM中使
用的。
一个没有可训练权重的简单自注意力机制
让我们从实现一个简化版的自注意力开始,不包含任何可训练权重,如图3.7所
示。目的是在添加可训练权重之前,说明自注意力中的几个关键概念。
图3.7 自注意力的目标是为每个输入元素计算一个上下文向量,该向量结合了所有
其他输入元素的信息。在这个例子中,我们计算上下文向量z(2)。每个输入元素对
计算z(2)的重要性或贡献由注意力权重α21到α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)信息的嵌入。
上下文向量在自注意力中起着至关重要的作用。它们的目的是通过结合序列中所
有其他元素的信息,创建输入序列(如句子)中每个元素的丰富表示。这对于
LLM来说至关重要,因为它们需要理解句子中单词之间的关系和相关性。稍后,
我们将添加可训练权重,以帮助LLM学习构建这些上下文向量,使其对生成下一
个标记具有相关性。但首先,让我们逐步实现一个简化的自注意力机制来计算这
些权重和最终的上下文向量。
考虑以下输入句子,已经嵌入为三维向量(见第2章)。我选择了一个较小的嵌入
维度,以确保它不会因换行而超出页面:
import torch
inputs = torch.tensor( [[0.43, 0.15, 0.89], # Your (x^1)
[0.55, 0.87, 0.66], # journey (x^2)
[0.57, 0.85, 0.64], # starts (x^3)
[0.22, 0.58, 0.33], # with (x^4)
[0.77, 0.25, 0.10], # one (x^5)
[0.05, 0.80, 0.55]] # step (x^6)
)
实现自注意力的第一步是计算中间值ω,称为注意力分数,如图3.8所示。由于空
间限制,图中显示的前置输入张量值被截断;例如,0.87被截断为0.8。在这种截
断版本中,“journey”和“starts”的嵌入可能会由于随机原因显得相似。
图3.8 总体目标是使用第二个输入元素x(2)作为查询来计算上下文向量z(2)。此图
显示了第一个中间步骤,即计算查询x(2)与所有其他输入元素之间的注意力分数ω
作为点积。(注意,数字被截断为小数点后一位以减少视觉混乱。)
图3.8展示了我们如何计算查询标记与每个输入标记之间的中间注意力分数。我们
通过计算查询x(2)与每个其他输入标记的点积来确定这些分数:
query = inputs[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)
计算出的注意力分数为: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())
图3.9 在计算与输入查询x(2)相关的注意力分数ω21到ω2T之后,下一步是通过归一
化注意力分数来获得注意力权重α21到α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:
注意权重: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函数相同:
注意权重:tensor([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581]) 和 Sum: tensor(1.)
现在我们已经计算了归一化的注意权重,准备进行最后一步,如图3.10所示:通过
将嵌入的输入标记x(i)与相应的注意权重相乘,然后对得到的向量求和来计算上下
文向量z(2)。因此,上下文向量z(2)是所有输入向量的加权和,通过将每个输入向
量与其对应的注意权重相乘获得:
图3.10 计算并归一化注意分数以获得查询x(2)的注意权重后,最终步骤是计算上下
文向量z(2)。这个上下文向量是所有输入向量x(1)到x(T)的组合,并由注意权重加
权。
接下来,我们将推广这一过程以同时计算所有上下文向量。
3.3.2 计算所有输入标记的注意权重
到目前为止,我们已经计算了输入2的注意权重和上下文向量,如图3.11中突出显
示的行所示。现在让我们扩展这个计算以计算所有输入的注意权重和上下文向
量。
图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)
图3.12 在第一步中,我们添加了一个额外的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)
我们可以直观地确认结果与张量[[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的第二步中,我们对每一行进行归一化,使每一行的值之和为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是一个二维张量(例如,形状为[行,列]),它将沿列
进行归一化,使每行(沿列维度求和)的值之和为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("之前的第 2 个上下文向量:", context_vec_2)
根据结果,我们可以看到之前计算的 context_vec_2 与上述张量的第二行完全匹
配:
之前的第 2 个上下文向量: tensor([0.4419, 0.6515, 0
这结束了简单自注意力机制的代码讲解。接下来,我们将添加可训练的权重,使
大语言模型能够从数据中学习并提高其在特定任务上的性能。
3.4 实现具有可训练权重的自注意力机制
我们接下来的步骤是实现原始Transformer架构、GPT模型以及大多数其他流行的
大型语言模型(LLM)中使用的自注意力机制。这种自注意力机制也称为缩放点
积注意力。图3.13展示了这种自注意力机制如何融入到实现LLM的整体背景中。
图3.13 之前,我们编写了一个简化的注意力机制来理解注意力机制的基本原理。
现在,我们将为这个注意力机制添加可训练的权重。稍后,我们将通过添加因果
掩码和多头扩展这个自注意力机制。
如图3.13所示,带有可训练权重的自注意力机制建立在之前的概念之上:我们希望
计算上下文向量作为特定输入元素的输入向量的加权和。正如你将看到的,这与
我们之前编码的基本自注意力机制相比只有细微的差别。
最显著的区别是引入了在模型训练期间更新的权重矩阵。这些可训练的权重矩阵
至关重要,因此模型(特别是模型中的注意力模块)可以学习生成“良好”的上下
文向量。(我们将在第5章训练LLM。)
我们将在两个子部分中处理这个自注意力机制。首先,我们将逐步编码它,就像
之前一样。其次,我们将代码组织成一个紧凑的Python类,以便将其导入到LLM
架构中。
3.4.1 分步计算注意力权重
我们将逐步实现自注意力机制,通过引入三个可训练的权重矩阵Wq、Wk和Wv。
这三个矩阵用于将嵌入的输入标记x(i)分别投影到查询、键和值向量中,如图3.14
所示。
权重矩阵与输入标记之间的矩阵乘法
图3.14 在带有可训练权重矩阵的自注意力机制的第一步中,我们为输入元素x计算
查询(q)、键(k)和值(v)向量。类似于前面的部分,我们将第二个输入x(2)指定为查
询输入。查询向量q(2)通过输入x(2)与权重矩阵Wq之间的矩阵乘法获得。同样,
我们通过涉及权重矩阵Wk和Wv的矩阵乘法获得键和值向量。
早些时候,我们在计算简化注意力权重时将第二个输入元素x(2)定义为查询以计算
上下文向量z(2)。然后我们将其推广以计算六词输入句子“Your journey starts with
one step.”的所有上下文向量z(1)…z(T)。
类似地,我们从这里开始仅计算一个上下文向量z(2),以进行说明。然后我们将修
改此代码以计算所有上下文向量。
让我们从定义一些变量开始:
请注意,在GPT类模型中,输入和输出维度通常相同,但为了更好地跟踪计算,
我们在这里使用不同的输入(d_in=3)和输出(d_out=2)维度。
接下来,我们初始化图3.14中显示的三个权重矩阵Wq、Wk和Wv:
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)
由于我们将权重矩阵的列数设置为2(通过d_out),查询结果是一个二维向量:
tensor([0.4306, 1.4551])
权重参数 vs. 注意力权重
在权重矩阵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所示。
图3.15 注意力得分计算类似于我们在第3.3节中使用的简化自注意力机制中的点积
计算。新的方面是我们不是直接计算输入元素之间的点积,而是使用通过相应权
重矩阵转换后的查询和键。
首先,让我们计算注意力得分ω22:
未归一化的注意力得分结果为:
tensor(1.8524)
同样,我们可以通过矩阵乘法将此计算推广到所有注意力得分:
正如我们所见,作为快速检查,输出中的第二个元素与我们之前计算的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.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 派生的一个类,
nn.Module 是PyTorch模型的基本构建块,提供了创建和管理模型层所需的功能。
__init__ 方法初始化可训练的权重矩阵(W_query、W_key 和 W_value),分别将输
入维度 d_in 转换为输出维度 d_out。
在前向传播过程中,使用 forward 方法,我们通过乘以查询和键来计算注意力得
分(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=)
快速检查一下,注意第二行 ([0.3061, 0.8210]) 与上一节中的 context_vec_2 内容匹
配。图3.18总结了我们刚刚实现的自注意力机制。
自注意力涉及可训练的权重矩阵 Wq、Wk 和 Wv。这些矩阵将输入数据转换为查
询、键和值,这是注意力机制的关键组成部分。随着模型在训练过程中接触到更
多数据,它会调整这些可训练的权重,如我们将在后续章节中看到的那样。
图3.18 在自注意力中,我们使用三个权重矩阵 Wq、Wk 和 Wv 将输入矩阵 X 中的
输入向量转换为新的查询 (Q) 和键 (K),然后基于这些结果计算注意力权重矩阵。
使用注意力权重和值 (V),我们再计算上下文向量 (Z)。为了视觉清晰,我们专注
于单个包含 n 个标记的输入文本,而不是多个输入的批次。因此,在这种情况
下,三维输入张量被简化为二维矩阵。这种方法使过程更直观和易于理解。为了
与后面的图表保持一致,注意力矩阵中的值并不表示真实的注意力权重。(该图
中的数字截断为小数点后两位,以减少视觉混乱。每一行的值之和应为1.0或
100%。)
列表3.2 使用PyTorch的Linear层的自注意力类
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_v1 一样使用 SelfAttention_v2:
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=)
请注意,SelfAttention_v1 和 SelfAttention_v2 输出不同,因为它们使用的权重矩
阵初始值不同,nn.Linear 使用了更复杂的权重初始化方案。
练习3.1 比较 SelfAttention_v1 和 SelfAttention_v2
请注意,SelfAttention_v2 中的 nn.Linear 使用了不同的权重初始化方案,而
SelfAttention_v1 中使用的是 nn.Parameter(torch.rand(d_in, d_out)),这导致两
者产生不同的结果。为了验证这两种实现方式在其他方面是否相似,我们可以将
SelfAttention_v2 对象中的权重矩阵转移到 SelfAttention_v1 对象中,使得两个对
象产生相同的结果。
你的任务是正确地将 SelfAttention_v2 的实例中的权重分配给 SelfAttention_v1
的实例。为此,你需要理解两个版本之间的权重关系。(提示:nn.Linear 以转置
形式存储权重矩阵。)分配完成后,你应该观察到两个实例产生相同的输出。
接下来,我们将对自注意力机制进行改进,特别关注引入因果和多头元素。因果
方面涉及修改注意力机制,以防止模型访问序列中的未来信息,这对于语言建模
等任务至关重要,因为在这些任务中,每个单词的预测应该仅依赖于之前的单
词。
多头组件涉及将注意力机制拆分为多个“头”。每个头学习数据的不同方面,使模
型能够同时关注来自不同表示子空间的信息。这提高了模型在复杂任务中的性
能。
隐藏未来的词使用因果注意力
对于许多大型语言模型(LLM)任务,您希望自注意力机制在预测序列中的下一
个标记时仅考虑出现在当前位置之前的标记。因果注意力,也称为掩码注意力,
是自注意力的一种特殊形式。它限制模型在处理任何给定标记时仅考虑序列中的
先前和当前输入,而在计算注意力分数时无法访问整个输入序列。
现在,我们将修改标准的自注意力机制以创建因果注意力机制,这对于在后续章
节中开发LLM至关重要。为了在类似GPT的LLM中实现这一点,对于每个处理的
标记,我们屏蔽掉未来标记,这些标记出现在当前标记之后的输入文本中,如图
3.19所示。我们屏蔽对角线以上的注意力权重,并归一化未被屏蔽的注意力权重,
使得每行的注意力权重之和为1。稍后,我们将在代码中实现这种屏蔽和归一化过
程。
图3.19 在因果注意力中,我们屏蔽对角线以上的注意力权重,这样对于给定输
入,LLM在使用注意力权重计算上下文向量时无法访问未来的标记。例如,对于
第二行中的单词“journey”,我们只保留之前单词(“Your”)和当前位置(“journey”)的注意力权重。
3.5.1 应用因果注意力掩码
下一步是在代码中实现因果注意力掩码。为了应用因果注意力掩码以获得屏蔽后
的注意力权重,如图3.20所示,让我们使用前一节中的注意力得分和权重来编码因
果注意力机制。
图3.20 在因果注意力中获取屏蔽后的注意力权重矩阵的一种方法是对注意力得分
应用softmax函数,将对角线以上的元素置零并归一化结果矩阵。
第一步,我们使用softmax函数计算注意力权重,正如我们之前所做的:
# 重用SelfAttention_v2对象的查询和键权重矩阵
queries = sa_v2.W_query(inputs)
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)
这导致了以下注意力权重:
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=)
我们可以使用PyTorch的tril函数创建一个掩码,其中对角线以上的值为零,从而
实现第二步:
得到的掩码是 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=)
第三步是重新归一化注意力权重,使每行的和再次为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=)
信息泄漏
当我们应用掩码并重新归一化注意力权重时,可能会最初认为来自未来标记的信
息(我们打算屏蔽的信息)仍然可能影响当前标记,因为它们的值是softmax计算
的一部分。然而,关键在于我们在屏蔽后重新归一化注意力权重时,实际上是在
较小的子集上重新计算softmax(因为屏蔽的位置不会对softmax值产生贡献)。
softmax函数的数学优雅之处在于,尽管最初在分母中包括所有位置,但在屏蔽和
重新归一化后,屏蔽位置的影响被消除——它们不会以任何有意义的方式对softmax得分产生影响。
简单来说,在屏蔽和重新归一化后,注意力权重的分布就像它是从一开始就仅在
未屏蔽位置之间计算的一样。这确保了没有来自未来(或被屏蔽的)标记的信息
泄露,正如我们所期望的那样。
虽然我们可以在此处结束因果注意力的实现,但我们还可以通过利用softmax函数
的数学属性来更高效地计算屏蔽后的注意力权重,减少步骤,如图3.21所示。
图3.21 一种更高效的在因果注意力中获取屏蔽后的注意力权重矩阵的方法是在应
用softmax函数之前用负无穷大值屏蔽注意力得分。
grad_fn=)
softmax函数将其输入转换为概率分布。当一行中有负无穷大值(-∞)时,softmax
函数将其视为零概率。(从数学上讲,这是因为e^(-∞)接近于0。)
我们可以通过创建一个包含对角线上方为1的掩码,然后将这些1替换为负无穷大
(-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]],
现在我们只需要对这些被掩码的结果应用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=)
我们现在可以使用修改后的注意力权重通过 context_vec = attn_weights @ values
来计算上下文向量,如第3.4节所述。然而,我们将首先介绍另一个对因果注意力
机制的小调整,这对训练大型语言模型(LLM)时减少过拟合很有用。
对额外的注意力权重进行掩码处理并应用dropout
在深度学习中,dropout是一种技术,其中随机选择隐藏层单元并在训练期间忽略
它们,有效地“丢弃”它们。这种方法通过确保模型不会过度依赖任何特定的一组
隐藏层单元来防止过拟合。需要注意的是,dropout仅在训练期间使用,在之后会
被禁用。
在Transformer架构中,包括像GPT这样的模型,注意力机制中的dropout通常在两
个特定的时间点应用:在计算注意力权重之后或在将注意力权重应用于值向量之
后。在这里,我们将在计算注意力权重后应用dropout掩码,如图3.22所示,因为
这是实践中更常见的变体。
在下面的代码示例中,我们使用50%的dropout率,这意味着屏蔽掉一半的注意力
权重。(当我们稍后在后面的章节中训练GPT模型时,我们将使用较低的dropout
率,例如0.1或0.2。)我们首先将PyTorch的dropout实现应用于一个由1组成的6 × 6
张量以简化说明:
图3.22 使用因果注意力掩码(左上角),我们应用一个额外的dropout掩码(右上
角),以零出更多的注意力权重,从而在训练期间减少过拟合。
正如我们所见,大约一半的值被置为零:
tensor([[2., 2., 0., 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=)
请注意,根据您的操作系统不同,最终的dropout输出可能会有所不同;您可以在
PyTorch问题跟踪器上阅读更多关于这种不一致的信息,网址为 https://github.com/
pytorch/pytorch/issues/121595。
了解了因果注意力和dropout掩码后,我们现在可以开发一个简洁的Python类。这
个类旨在高效地应用这两种技术。
3.5.3 实现一个紧凑的因果注意力类
我们现在将因果注意力和dropout修改整合到我们在第3.4节中开发的SelfAttention
Python类中。这个类将作为开发多头注意力的基础模板,这是我们最终要实现的
注意力类。
但在开始之前,让我们确保代码可以处理包含多个输入的批次,以便CausalAttention类支持我们在第2章中实现的数据加载器产生的批次输出。
模拟这样的批次输入,我们复制输入文本示例:
batch = torch.stack((inputs, inputs), dim=0)
# 两个输入,每个包含六个标记;每个标记的嵌入维度为3。
print(batch.shape)
这将生成一个三维张量,包含两个输入文本,每个文本有六个标记,每个标记是
一个三维嵌入向量:
torch.Size([2, 6, 3])
以下CausalAttention类与我们之前实现的SelfAttention类类似,只是我们添加了
dropout和因果掩码组件。
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)
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)
queries = self.W_query(x)
values = self.W_value(x)
attn_scores = queries @ keys.transpose(1, 2)
attn_scores.masked_fill_(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
虽然所有添加的代码行在此时应该都很熟悉,但我们现在在 __init__ 方法中添加
了一个 self.register_buffer() 调用。在 PyTorch 中使用 register_buffer 并不是
所有用例中都严格必要的,但在这种情况下提供了几个优点。例如,当我们使用
CausalAttention 类构建我们的 LLM 时,缓冲区会自动与模型一起移动到适当的设
备(CPU 或 GPU),这在训练我们的 LLM 时将非常重要。这意味着我们不需要
手动确保这些张量与模型参数位于同一设备上,从而避免了设备不匹配错误。
我们可以像之前使用 SelfAttention 那样使用 CausalAttention 类:
生成的上下文向量是一个三维张量,其中每个标记现在由一个二维嵌入表示:
context_vecs.shape: torch.Size([2, 6, 2])
图 3.23 总结了我们迄今为止所完成的工作。我们专注于神经网络中因果注意力的
概念和实现。接下来,我们将扩展这一概念并实现一个多头注意力模块,该模块
并行实现了多个因果注意力机制。
图 3.23 这是我们迄今为止所做的工作。我们从一个简化的注意力机制开始,添加
了可训练权重,然后添加了因果注意力掩码。接下来,我们将扩展因果注意力机
制并编写多头注意力代码,这些将在我们的 LLM 中使用。
3.5 将单头注意力扩展到多头注意力
我们最后一步是将之前实现的因果注意力类扩展到多个头。这被称为多头注意
力。
术语“多头”指的是将注意力机制分成多个“头”,每个头独立运作。在这种情况
下,一个单一的因果注意力模块可以被认为是单头注意力,其中只有一组注意力
权重按顺序处理输入。
我们将从因果注意力扩展到多头注意力。首先,我们将通过堆叠多个CausalAttention模块来直观地构建一个多头注意力模块。然后我们将以更复杂但更高效的计算
方式实现相同多头注意力模块。
3.6.1 堆叠多个单头注意力层
实际上,实现多头注意力涉及创建多个自注意力机制的实例(见图3.18),每个实
例都有自己的权重,然后合并它们的输出。使用多个自注意力机制的实例可能会
耗费大量计算资源,但对于像基于Transformer的LLM这样的模型所需的复杂模式
识别至关重要。
图3.24展示了多头注意力模块的结构,它由多个单头注意力模块组成,如之前在图
3.18中所示,堆叠在一起。
图3.24 多头注意力模块包括两个单头注意力模块堆叠在一起。因此,不是使用单
个矩阵Wv来计算值矩阵,在具有两个头的多头注意力模块中,我们现在有两个值
权重矩阵:Wv1和Wv2。同样的规则也适用于其他权重矩阵,WQ和Wk。我们获
得两组上下文向量Z1和Z2,可以将它们组合成一个单独的上下文向量矩阵Z。
正如前面提到的,多头注意力的主要思想是在不同、已学习的线性投影下多次
(并行)运行注意力机制——即通过权重矩阵乘以输入数据(如查询、键和值向
量)。在代码中,我们可以通过实现一个简单的MultiHeadAttentionWrapper类来堆
叠多个我们之前实现的CausalAttention模块来实现这一点。
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所示。
图3.25 使用MultiHeadAttentionWrapper,我们指定了注意力头的数量
(num_heads)。如果我们将num_heads设为2,如本例所示,我们将获得一个包含
两组上下文向量矩阵的张量。在每个上下文向量矩阵中,行表示对应于标记的上
下文向量,列表示通过d_out=4指定的嵌入维度。我们在列维度上连接这些上下文
向量矩阵。由于我们有两个注意力头和一个嵌入维度为2,最终的嵌入维度为2 × 2
= 4。
为了进一步说明这一点,我们可以使用与之前的CausalAttention类类似的MultiHeadAttentionWrapper类:
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=)
结果上下文向量张量的第一个维度是2,因为我们有两个输入文本(输入文本被复
制,这就是为什么上下文向量完全相同的)。第二个维度是指每个输入中的6个标
记。第三个维度是指每个标记的四维嵌入。
练习3.2 返回二维嵌入向量
更改MultiHeadAttentionWrapper(…, num_heads=2)调用的输入参数,使输出上下文
向量为二维而不是四维,同时保持num_heads=2的设置。提示:你不需要修改类的
实现;你只需要更改另一个输入参数。
到目前为止,我们已经实现了一个MultiHeadAttentionWrapper,它结合了多个单头
注意力模块。然而,这些是通过[self.heads]在forward方法中顺序处理的。我们可
以通过并行处理头来改进这个实现。一种实现方法是通过矩阵乘法同时计算所有
注意力头的输出。
3.6.2 使用权重拆分实现多头注意力
到目前为止,我们已经通过堆叠多个单头注意力模块实现了多头注意力。这是通
过实例化和组合多个CausalAttention对象完成的。
与其维护两个独立的类,MultiHeadAttentionWrapper和CausalAttention,我们可以
将这些概念合并到一个MultiHeadAttention类中。此外,除了合并MultiHeadAttentionWrapper与CausalAttention代码外,我们还将进行一些其他修改,以更高效地实
现多头注意力。
在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
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)
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)
queries = self.W_query(x)
values = self.W_value(x)
# Tensor shape: (b, num_tokens, d_out)
尽管在MultiHeadAttention类内部对张量进行重塑(.view)和转置(.transpose)看
起来非常数学复杂,但MultiHeadAttention类实现了与前面的MultiHeadAttentionWrapper相同的概念。
从宏观角度来看,在之前的 MultiHeadAttentionWrapper 中,我们将多个单头注意
力层堆叠在一起,然后组合成一个多头注意力层。MultiHeadAttention 类采用了一
种集成的方法。它从一个多头层开始,然后在内部将该层拆分为各个注意力头,
如图 3.26 所示。
查询、键和值张量的拆分是通过使用 PyTorch 的 .view 和 .transpose 方法进行张量
重塑和转置操作实现的。输入首先通过线性层(用于查询、键和值)进行转换,
然后重塑以表示多个头。
关键操作是将 d_out 维度拆分为 num_heads 和 head_dim,其中 head_dim = d_out /
num_heads。这种拆分是通过 .view 方法实现的:一个维度为 (b, num_tokens,
d_out) 的张量被重塑为维度 (b, num_tokens, num_heads, head_dim)。
图 3.26 在 MultiHeadAttentionWrapper 类中,有两个注意力头,我们初始化了两个
权重矩阵 Wq1 和 Wq2,并计算了两个查询矩阵 Q1 和 Q2(顶部)。在 MultiheadAttention 类中,我们初始化了一个更大的权重矩阵 Wq,只对输入执行一次矩阵
乘法以获得查询矩阵 Q,然后将查询矩阵拆分为 Q1 和 Q2(底部)。我们也对键
和值做同样的处理,为了减少视觉混乱而未显示。
接下来,张量进行转置以将 num_heads 维度移到 num_tokens 维度之前,结果形状
为 (b, num_heads, num_tokens, head_dim)。这种转置对于正确对齐不同头之间的查
询、键和值并高效地执行批矩阵乘法至关重要。
为了说明这种批矩阵乘法,假设我们有以下张量:
a = torch.tensor([[[[0.2745, 0.6584, 0.2775, 0.8573],
[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]]]])
该张量的形状为 (b, num_heads, num_tokens, head_dim) = (1, 2, 3, 4)。
现在我们在张量本身与其转置后的视图之间执行批矩阵乘法,其中最后两个维度
num_tokens 和 head_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)
结果与使用批矩阵乘法时得到的结果完全相同:
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 类可以像我们之前实现的 SelfAttention 和 CausalAttention 类一
样使用:
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中的注意力模块涉及多个因果注意实例,这被称为多头注
意力。
我们可以通过堆叠多个因果注意模块实例来创建多头注意力模块。
创建多头注意力模块的一种更有效的方法是使用批处理矩阵乘法。
4 从头实现一个GPT模型以生成文本
本章涵盖
编写一个类似GPT的大规模语言模型(LLM),
可以训练它生成类人的文本
规范化层激活以稳定神经网络训练
在深度神经网络中添加快捷连接
实现变压器块以创建不同大小的GPT模型
计算GPT模型的参数数量和存储需求
您已经学习并编写了多头注意力机制,这是LLM的核心组件之一。现在,我们将
编写LLM的其他构建模块,并将它们组装成一个GPT类模型,在下一章中我们将
训练该模型以生成类人的文本。
图4.1所示的LLM架构由几个构建模块组成。我们将从模型架构的自顶向下视图开
始,然后再详细覆盖各个组件。
图4.1 编码LLM的三个主要阶段。本章重点介绍阶段1的第3步:实现LLM架构。
// 示例代码片段
int main() {
// 示例代码内容
return 0;
}
4.1 编码LLM架构
像GPT(代表生成预训练变换器)这样的大型语言模型(LLM),是设计用来逐
词(或逐标记)生成新文本的大型深度神经网络架构。然而,尽管它们的规模很
大,模型架构并没有你想象的那么复杂,因为许多组件是重复的,我们稍后会看
到。图4.2提供了一个类似GPT的LLM的自上而下的视图,并突出了其主要组件。
我们已经讨论了LLM架构的几个方面,例如输入标记化和嵌入以及掩码多头注意
力模块。现在,我们将实现GPT模型的核心结构,包括其变换器块,这些块将在
稍后用于训练以生成类似人类的文本。
以前,为了简单起见,我们使用了较小的嵌入维度,确保概念和示例可以舒适地
放在一页上。现在,我们将扩展到小型GPT-2模型的规模,特别是参数数量为1.24
亿的最小版本,如Radford等人的“语言模型是无监督的多任务学习
者”(https://mng.bz/yoBq)中所述。请注意,虽然原始报告提到1.17亿个参数,但
后来进行了更正。在第6章中,我们将专注于将预训练权重加载到我们的实现中,
并适应更大参数量的GPT-2模型,分别为3.45亿、7.62亿和15.42亿参数。
在深度学习和类似GPT的LLM上下文中,“参数”一词指的是模型的可训练权重。
这些权重实际上是模型的内部变量,在训练过程中进行调整和优化以最小化特定
的损失函数。这种优化使模型能够从训练数据中学习。
图4.2 GPT模型。除了嵌入层外,它还包含一个或多个包含我们之前实现的掩码多
头注意力模块的变换器块。
例如,在一个由2,048 × 2,048维矩阵(或张量)表示的神经网络层中,该矩阵的每
个元素都是一个参数。由于有2,048行和2,048列,该层的参数总数为2,048乘以
2,048,等于4,194,304个参数。
GPT-2与GPT-3
请注意,我们关注的是GPT-2,因为OpenAI已公开发布预训练模型的权重,我们
将在第6章中将其加载到我们的实现中。GPT-3在模型架构上与GPT-2基本相同,
只是规模从GPT-2的15亿参数扩展到了GPT-3的1750亿参数,并且它是在更多数据
上训练的。截至本文撰写时,GPT-3的权重尚未公开。此外,GPT-2更适合学习如
何实现LLM,因为它可以在单个笔记本电脑上运行,而GPT-3则需要GPU集群进
行训练和推理。根据Lambda Labs(https://lambdalabs.com/),在单个V100数据中
心GPU上训练GPT-3需要355年,在消费级RTX 8000 GPU上则需要665年。
我们通过以下Python字典指定小型GPT-2模型的配置,这将在后面的代码示例中使
用:
GPT_CONFIG_124M = {
"vocab_size": 50257,
# 词汇表大小
"context_length": 1024, # 上下文长度
"emb_dim": 768,
# 嵌入维度
"n_heads": 12,
# 注意力头数
"n_layers": 12,
# 层数
"drop_rate": 0.1,
# Dropout率
"qkv_bias": False
# Query-Key-Value偏置
}
在GPT_CONFIG_124M字典中,我们使用简洁的变量名以提高清晰度并防止代码
行过长:
• vocab_size 指的是50,257个单词的词汇表,如BPE分词器所使用的(见
第2章)。
• context_length 表示模型通过位置嵌入可以处理的最大输入标记数
(见第2章)。
• emb_dim 表示嵌入大小,将每个标记转换为768维向量。
• n_heads 表示多头注意力机制中的注意力头数(见第3章)。
• n_layers 指定了模型中的变换器块数,我们将在接下来的讨论中介
绍。
• drop_rate 表示Dropout强度(0.1意味着隐藏单元有10%的随机丢弃)
以防止过拟合(见第3章)。
• qkv_bias 决定是否在多头注意力的查询、键和值计算的线性层中包含
偏置向量。我们最初将禁用此功能,遵循现代LLM的规范,但在第6章
中加载OpenAI的预训练GPT-2权重时会重新考虑这个问题(见第6
章)。
使用此配置,我们将实现一个GPT占位符架构(DummyGPTModel),如图4.3所示。
这将为我们提供一个整体视图,展示所有组件如何协同工作,以及我们需要编码
哪些其他组件以组装完整的GPT模型架构。
编号框图4.3展示了我们编写最终GPT架构所需各个概念的顺序。我们将从步骤1开
始,即一个占位符GPT主干,我们称之为DummyGPTModel。
图4.3 我们编写GPT架构的顺序。我们从GPT主干开始,即一个占位符架构,然后
逐步解决各个核心部分,最终在变换器块中组装成完整的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(
*[DummyTransformerBlock(cfg) for _ in range(cfg["n_layers"])]
)
self.final_norm = DummyLayerNorm(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 logits
DummyGPTModel类在此代码中定义了一个简化版的GPT模型,使用PyTorch的神经网
络模块(nn.Module)。DummyGPTModel类中的模型架构包括标记和位置嵌入、
Dropout、一系列变换器块(DummyTransformerBlock)、最终的层归一化
(DummyLayerNorm)和线性输出层(out_head)。配置通过Python字典传递,例如
我们之前创建的GPT_CONFIG_124M字典。
forward方法描述了数据流经模型的过程:它计算输入索引的标记和位置嵌入,应
用Dropout,通过变换器块处理数据,应用归一化,最后通过线性输出层生成logits。
列表4.1中的代码已经可以正常工作。但是,请注意,我们现在使用占位符
(DummyLayerNorm和DummyTransformerBlock)来代替变换器块和层归一化,我们将
在稍后开发这些部分。
接下来,我们将准备输入数据并初始化一个新的GPT模型以说明其用法。基于我
们在第2章中编写的分词器,现在让我们考虑一下数据如何流入和流出GPT模型的
高层次概述,如图4.4所示。
为了实现这些步骤,我们使用第2章中的tiktoken分词器对两个文本输入进行分
词,作为GPT模型的一个批次:
import tiktoken
图4.4 一个高层次的概览,展示了输入数据如何被分词、嵌入并输入到GPT模型
中。请注意,在我们之前编写的DummyGPTClass中,标记嵌入是在GPT模型内部处
理的。在LLM中,嵌入输入标记的维度通常与输出维度匹配。这里的输出嵌入表
示上下文向量(见第3章)。
两个文本的标记ID结果如下:
张量如下所示:
tensor([[6109, 3626, 6100, 345],
[6109, 1110, 6622, 257]])
第一行对应第一个文本,第二行对应第二个文本。
接下来,我们初始化一个新的包含1.24亿参数的DummyGPTModel实例,并将分词
后的批次输入模型:
torch.manual_seed(123)
model = DummyGPTModel(GPT_CONFIG_124M)
logits = model(batch)
print("输出形状:", logits.shape)
print(logits)
模型的输出通常被称为logits,结果如下:
输出张量有两行,分别对应两个文本样本。每个文本样本由四个标记组成;每个
标记是一个50,257维的向量,这与分词器词汇表的大小相匹配。
嵌入有50,257个维度,因为每个维度都对应词汇表中的一个唯一标记。在实现后处
理代码时,我们将把这些50,257维向量转换回标记ID,然后解码成单词。
现在我们已经从上到下了解了GPT架构及其输入和输出,我们将编写各个占位
符,从替换前面代码中的DummyLayerNorm的真实层归一化类开始。
4.2 使用层归一化归一化激活
训练具有许多层的深度神经网络有时会面临诸如梯度消失或梯度爆炸等问题,这
使得训练变得不稳定,并且使网络难以有效地调整其权重。这意味着学习过程很
难找到一组能使损失函数最小化的参数(权重)。换句话说,网络难以学习数据
中的潜在模式,从而无法进行准确的预测或决策。
注意 如果您是神经网络训练的新手并且对梯度的概念不熟悉,可以在附录A的A.4
节中找到这些概念的简要介绍。但是,跟随本书的内容并不需要对梯度有深入的
数学理解。
现在我们来实现层归一化以提高神经网络训练的稳定性和效率。层归一化的主要
思想是调整神经网络层的激活(输出),使其均值为0,方差为1,也称为单位方
差。这种调整可以加快收敛到有效的权重,并确保一致且可靠的训练。在GPT-2和
现代Transformer架构中,通常在多头注意力模块之前和之后应用层归一化,并且
如我们在DummyLayerNorm占位符中所见,在最终输出层之前也应用了层归一
化。图4.5提供了层归一化功能的视觉概述。
图4.5 层归一化的示意图,其中该层的六个输出(也称为激活)被归一化,使其均
值为0,方差为1。
我们可以通过以下代码重现图4.5中所示的示例,其中我们实现了一个具有五个输
入和六个输出的神经网络层,并将其应用于两个输入示例:
import torch
import torch.nn as nn
torch.manual_seed(123)
batch_example = torch.randn(2, 5)
layer = nn.Sequential(
nn.Linear(5, 6),
nn.ReLU()
)
out = layer(batch_example)
print(out)
这将打印出以下张量,其中第一行列出了第一个输入的层输出,第二行列出了第
二个输入的层输出:
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=)
我们编写的神经网络层由一个线性层后跟一个非线性激活函数ReLU(即修正线性
单元)组成,这是神经网络中的标准激活函数。如果您不熟悉ReLU,它只是将负
输入阈值设为0,确保层仅输出正值,这就是为什么结果层输出不包含任何负值的
原因。稍后,我们将使用另一个更复杂的激活函数应用于GPT。
在我们将层归一化应用于这些输出之前,让我们先检查均值和方差:
mean = out.mean(dim=-1, keepdim=True)
var = out.var(dim=-1, keepdim=True)
print("均值:\n", mean)
print("方差:\n", var)
输出结果为:
均值: tensor([[0.1324], [0.2170]], grad_fn=)
方差: tensor([[0.0231], [0.0398]], grad_fn=)
这里的均值张量的第一行包含第一个输入行的均值,第二行包含第二个输入行的
均值。
在均值或方差计算等操作中使用keepdim=True确保输出张量保留与输入张量相同的
维度数量,即使操作沿指定的维度减少了张量。例如,如果不使用keepdim=True,
返回的均值张量将是一个二维向量[0.1324, 0.2170]而不是2 × 1维矩阵[[0.1324],
[0.2170]]。
dim参数指定了在张量中执行统计计算(这里是指均值或方差)的维度。如图4.6
所示,对于二维张量(如矩阵),使用dim=-1进行均值或方差计算等操作与使用
dim=1相同。这是因为-1指的是张量的最后一维,对应于二维张量中的列。当将层
归一化添加到生成三维张量(形状为[batch_size, num_tokens, embedding_size])的
GPT模型时,我们仍然可以使用dim=-1对最后一维进行归一化,避免从dim=1变为
dim=2。
图4.6 计算张量均值时dim参数的说明。例如,如果我们有一个二维张量(矩
阵),其维度为[行,列],使用dim=0将在行上执行操作(垂直,如底部所示),
结果是对每列聚合的数据。使用dim=1或dim=-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("归一化的层输出:\n", out_norm)
print("均值:\n", mean)
print("方差:\n", var)
根据结果可以看到,归一化的层输出现在包含负值,其均值为0,方差为1:
归一化的层输出: 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=)
均值:
tensor([[-5.9605e-08], [1.9868e-08]], grad_fn=)
方差: tensor([[1.], [1.]], grad_fn=)
请注意,输出张量中的值–5.9605e-08是以科学计数法表示的–5.9605 × 10^-8,即小
数形式为–0.000000059605。这个值非常接近0,但由于计算机表示数字的有限精
度,可能会积累一些微小的数值误差,因此它不是完全等于0。
为了提高可读性,我们还可以通过设置sci_mode为False来关闭打印张量值时的科
学计数法:
torch.set_printoptions(sci_mode=False)
print("均值:\n", mean)
print("方差:\n", var)
输出结果为:
均值: tensor([[ 0.0000], [ 0.0000]], grad_fn=)
方差: tensor([[1.], [1.]], grad_fn=)
到目前为止,我们已经逐步编码并应用了层归一化。现在让我们将此过程封装在
一个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),用于防止在归一化过程中除以
零。scale和shift是两个可训练参数(与输入具有相同的维度),如果确定这样做
能提高模型在其训练任务上的性能,LLM会在训练过程中自动调整它们。这允许
模型学习最适合处理的数据的适当缩放和移位。
有偏方差
在我们的方差计算方法中,我们通过设置 unbiased=False 使用了一个实现细节。
对于那些好奇这意味什么的人,在方差计算中,我们在方差公式中除以输入的数
量 n。这种方法不应用贝塞尔校正,后者通常使用 n – 1 而不是 n 来调整样本方差
估计中的偏差。这个决定导致了所谓的有偏方差估计。对于大语言模型
(LLMs),其中嵌入维度 n 非常大,使用 n 和 n – 1 的差异实际上是可以忽略
的。我选择这种方法是为了确保与 GPT-2 模型的归一化层兼容,并且因为它反映
了 TensorFlow 的默认行为,而 GPT-2 模型最初就是用 TensorFlow 实现的。使用类
似的设置可以确保我们的方法与我们将在第 6 章加载的预训练权重兼容。
现在让我们尝试一下 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=)
Variance: tensor([[1.0000], [1.0000]], grad_fn=)
我们现在已经介绍了实现 GPT 架构所需的两个构建块,如图 4.7 所示。接下来,
我们将研究 GELU 激活函数,这是 LLMs 中使用的激活函数之一,而不是我们之
前使用的传统 ReLU 函数。
图 4.7 构建 GPT 架构所需的构建块。到目前为止,我们已经完成了 GPT 主干和层
归一化。接下来,我们将专注于 GELU 激活和前馈网络。
层归一化 vs. 批归一化
如果您熟悉批归一化,这是一种常见的神经网络归一化方法,您可能会想知道它
与层归一化的比较。与批归一化不同,后者在批维度上进行归一化,层归一化是
在特征维度上进行归一化。LLMs 通常需要大量的计算资源,可用硬件或特定应用
场景可以决定训练或推理时的批量大小。由于层归一化独立于批量大小对每个输
入进行归一化,因此在这些情况下提供了更多的灵活性和稳定性。这对于分布式
训练或在资源受限环境中部署模型特别有利。
4.3 实现带有 GELU 激活的前馈网络
接下来,我们将实现一个小型神经网络子模块,该模块作为 LLM 中变压器块的一
部分。我们首先实现 GELU 激活函数,它在这个神经网络子模块中起着至关重要
的作用。
注意:有关在 PyTorch 中实现神经网络的更多信息,请参见附录 A 的 A.5 节。
历史上,ReLU 激活函数由于其简单性和在各种神经网络架构中的有效性而被广泛
用于深度学习。然而,在 LLM 中,除了传统的 ReLU 外,还使用了其他几种激活
函数。两个显著的例子是 GELU(高斯误差线性单元)和 SwiGLU(Swish 门控线
性单元)。
GELU 和 SwiGLU 是更复杂和平滑的激活函数,分别结合了高斯和 Sigmoid 门控
线性单元。它们为深度学习模型提供了比简单的 ReLU 更好的性能。
GELU 激活函数可以通过多种方式实现;其精确版本定义为 GELU(x) = x⋅Φ(x),
其中 Φ(x) 是标准高斯分布的累积分布函数。然而,在实际应用中,通常会实现计
算成本较低的近似值(原始的 GPT-2 模型也使用了这种近似值,这是通过曲线拟
合找到的):
在代码中,我们可以将其作为一个 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()
# 创建 100 个样本数据点,范围从 –3 到 3
x = torch.linspace(-3, 3, 100)
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} 激活函数")
plt.xlabel("x")
plt.ylabel(f"{label}(x)")
plt.grid(True)
plt.tight_layout()
plt.show()
如图 4.8 所示,ReLU(右侧)是一个分段线性函数,如果输入为正,则直接输出
输入;否则输出零。GELU(左侧)是一个平滑的非线性函数,近似于 ReLU,但
对几乎所有的负值都有非零梯度(大约在 x = –0.75 除外)。
图 4.8 使用 matplotlib 绘制的 GELU 和 ReLU 函数图。x 轴显示函数输入,y 轴显
示函数输出。
GELU 的平滑性可以在训练期间带来更好的优化特性,因为它允许对模型参数进
行更细致的调整。相比之下,ReLU 在零处有一个尖角(图 4.18,右),这有时会
使优化更加困难,尤其是在非常深或具有复杂架构的网络中。此外,与任何负输
入都输出零的 ReLU 不同,GELU 允许对负值产生小的非零输出。这一特性意味
着在训练过程中,接收负输入的神经元仍然可以对学习过程做出贡献,尽管程度
不如正输入。
接下来,让我们使用 GELU 函数来实现将在 LLM 的变压器块中使用的前馈网络
模块 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 激活函数组
成的小型神经网络。在 1.24 亿参数的 GPT 模型中,它通过 GPT_CONFIG_124M
字典接收每个标记具有 768 维嵌入的输入批次,其中 GPT_CONFIG_124M[“emb_dim”] = 768。图 4.9 显示了当我们将一些输入传递给这个小型前
馈神经网络时,嵌入维度是如何变化的。
图 4.9 前馈神经网络各层之间连接的概述。该神经网络可以适应可变的批量大小和
输入中的标记数量。然而,每个标记的嵌入维度是在初始化权重时确定并固定
的。
按照图 4.9 的例子,让我们用 768 维的标记嵌入大小初始化一个新的 FeedForward
模块,并给它一个包含两个样本、每个样本三个标记的批量输入:
如我们所见,输出张量的形状与输入张量相同:
torch.Size([2, 3, 768])
FeedForward 模块在增强模型从数据中学习和泛化的能力方面起着至关重要的作
用。尽管该模块的输入和输出维度相同,但它通过第一个线性层将嵌入维度扩展
到更高维空间,如图 4.10 所示。这种扩展随后经过非线性的 GELU 激活,然后通
过第二个线性变换收缩回原始维度。这样的设计允许探索更丰富的表示空间。
图 4.10 前馈神经网络中层输出的扩展和收缩说明。首先,输入从 768 扩展 4 倍至
3,072 个值。然后,第二层将 3,072 个值压缩回 768 维表示。
此外,输入和输出维度的一致性简化了架构,使得可以堆叠多层,而无需在它们
之间调整维度,从而使模型更具可扩展性。
如图 4.11 所示,我们现在已实现了 LLM 的大部分构建模块。接下来,我们将介
绍插入在不同层之间的快捷连接的概念,这对于改进深度神经网络架构的训练性
能非常重要。
图 4.11 构建 GPT 架构所需的构建块。黑色对勾表示我们已经涵盖的内容。
4.4 添加快捷连接
让我们讨论一下快捷连接背后的概念,也称为跳跃或残差连接。最初,快捷连接
是为了解决计算机视觉中深度网络(特别是残差网络)中的梯度消失问题而提出
的。梯度消失问题指的是梯度(用于指导训练期间权重更新的值)在反向传播过
程中逐渐变小,导致难以有效训练前面的层。
图4.12显示了一个快捷连接通过跳过一个或多个层为梯度创建了一条替代的、更短
的路径,从而使得梯度可以通过网络流动。这是通过将一层的输出添加到后面一
层的输出来实现的。这就是为什么这些连接也被称为跳跃连接。它们在训练过程
中反向传播时保持梯度流动方面起着至关重要的作用。
在下面的列表中,我们实现了图4.12中的神经网络,以展示如何在forward方法中
添加快捷连接。
图4.12 深度神经网络对比图:左侧是没有快捷连接的五层网络,右侧是有快捷连
接的五层网络。快捷连接涉及将一层的输入添加到其输出,从而有效地创建一条
绕过某些层的替代路径。梯度表示每层的平均绝对梯度,我们在清单4.5中计算。
清单4.5 用于说明快捷连接的神经网络
class ExampleDeepNeuralNetwork(nn.Module):
def __init__(self, layer_sizes, use_shortcut):
super().__init__()
self.use_shortcut = use_shortcut
self.layers = nn.ModuleList([
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)
if self.use_shortcut and x.shape == layer_output.shape:
x = x + layer_output
else:
x = layer_output
return x
该代码实现了一个包含五层的深度神经网络,每一层由一个线性层和一个GELU激
活函数组成。在前向传递过程中,我们迭代地将输入传递给各层,并根据
self.use_shortcut属性选择性地添加快捷连接。
让我们使用此代码初始化一个没有快捷连接的神经网络。每一层都将被初始化为
接受三个输入值并返回三个输出值。最后一层返回一个输出值:
接下来,我们实现一个函数来计算模型在反向传播过程中的梯度:
for name, param in model.named_parameters():
if 'weight' in name:
print(f"{name} has gradient mean of
{param.grad.abs().mean().item()}")
这段代码指定了一个损失函数,用于计算模型输出与用户指定的目标(这里为了
简化,目标值为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),梯
度逐渐变小,这是一种称为梯度消失的问题。
现在我们实例化一个带有跳跃连接的模型,并看看它的情况如何:
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),梯度值趋于稳定,不会缩小到几乎为零的值。
总之,快捷连接对于克服深度神经网络中梯度消失问题带来的限制非常重要。快
捷连接是像LLMs这样的大型模型的核心构建块之一,它们将有助于确保在训练
GPT模型时跨层保持一致的梯度流动,从而促进更有效的训练。
接下来,我们将之前介绍的所有概念(层归一化、GELU激活、前馈模块和快捷连
接)连接到一个Transformer块中,这是我们编码GPT架构所需的最后一个构建
块。
4.5 在变压器块中连接注意力和线性层
现在,让我们实现变压器块,这是GPT和其他大型语言模型(LLM)架构的基本
构建块。这个块在1.24亿参数的GPT-2架构中重复了十几次,结合了我们之前讨论
过的多个概念:多头注意力、层归一化、dropout、前馈层和GELU激活函数。稍
后,我们将把这个变压器块连接到GPT架构的其余部分。
图4.13展示了一个结合了多个组件的变压器块,包括我们在第三章中提到的掩码多
头注意力模块(见第三章)和我们在第4.3节中实现的前馈模块。当一个变压器块
处理输入序列时,序列中的每个元素(例如,一个单词或子词标记)由固定大小
的向量表示(在这种情况下,是768维)。变压器块内的操作,包括多头注意力和
前馈层,旨在以保持其维度的方式转换这些向量。
图4.13 变压器块的示意图。输入令牌已被嵌入为768维向量。每一行对应一个令牌
的向量表示。变压器块的输出是与输入相同维度的向量,然后可以将其馈送到
LLM中的后续层。
这个想法是,多头注意力块中的自注意力机制识别并分析输入序列中元素之间的
关系。相比之下,前馈网络在每个位置独立地修改数据。这种组合不仅使对输入
的理解和处理更加细致,还增强了模型处理复杂数据模式的整体能力。
我们可以用代码创建TransformerBlock。
列表4.6 GPT的变压器块组件
from chapter03 import MultiHeadAttention
给定的代码定义了一个PyTorch中的TransformerBlock类,该类包含一个多头注意力
机制(MultiHeadAttention)和一个前馈网络(FeedForward),它们都根据提供的
配置字典(如GPT_CONFIG_124M)进行配置。
层归一化(LayerNorm)在这些组件之前应用,而dropout在它们之后应用,以正
则化模型并防止过拟合。这被称为预层归一化(Pre-LayerNorm)。较旧的架构,
如原始的变压器模型,在自注意力和前馈网络之后应用层归一化,称为后层归一
化(Post-LayerNorm),这通常会导致更差的训练动态。
该类还实现了前向传递,在每个组件之后添加了一个捷径连接,将块的输入添加
到其输出。这一关键特性有助于梯度在网络训练期间流动,并改进深度模型的学
习(见第4.4节)。
使用我们之前定义的GPT_CONFIG_124M字典,让我们实例化一个变压器块并输
入一些样本数据:
torch.manual_seed(123)
x = torch.rand(2, 4, 768) # 创建形状为 [batch_size, num_tokens, emb_dim] 的
样本输入
block = TransformerBlock(GPT_CONFIG_124M)
output = block(x)
print("Input shape:", x.shape)
print("Output shape:", output.shape)
输出为
Input shape: torch.Size([2, 4, 768])
Output shape: torch.Size([2, 4, 768])
正如我们所见,变压器块在其输出中保持了输入的维度,表明变压器架构在处理
数据序列时不改变其形状。
在整个变压器块架构中保持形状并非偶然,而是其设计的关键方面。这种设计使
其能够有效地应用于广泛的序列到序列任务,其中每个输出向量直接对应于一个
输入向量,保持一对一的关系。然而,输出是一个上下文向量,它封装了整个输
入序列的信息(见第三章)。这意味着,虽然序列通过变压器块时其物理维度
(长度和特征大小)保持不变,但每个输出向量的内容被重新编码,以整合来自
整个输入序列的上下文信息。
随着变压器块的实现,我们现在有了实现GPT架构所需的所有构建块。如图4.14所
示,变压器块结合了层归一化、前馈网络、GELU激活函数和捷径连接。最终,我
们将看到,这个变压器块将成为GPT架构的主要组成部分。
图4.14 构建GPT架构所需的构建块。黑色勾号表示我们已完成的块。
4.6 编码 GPT 模型
我们在本章开始时,对 GPT 架构进行了宏观概述,我们称之为 DummyGPTModel。在这个 DummyGPTModel 的代码实现中,我们展示了输入和输出,但其构建
块仍然是使用 DummyTransformerBlock 和 DummyLayerNorm 类作为占位符的黑盒
子。
现在让我们用之前编码的真实 TransformerBlock 和 LayerNorm 类替换这些 DummyTransformerBlock 和 DummyLayerNorm 占位符,以组装一个完全可工作的原始
1.24 亿参数版本的 GPT-2。在第 5 章中,我们将预训练一个 GPT-2 模型,并在第
6 章中加载来自 OpenAI 的预训练权重。
在我们用代码组装 GPT-2 模型之前,让我们先看看它的整体结构,如图 4.15 所
示,其中包括我们迄今为止涵盖的所有概念。正如我们所见,transformer 块在整
个 GPT 模型架构中重复了许多次。对于 1.24 亿参数的 GPT-2 模型,它重复了 12
次,这是通过 GPT_CONFIG_124M 字典中的 n_layers 条目指定的。在最大版本的
GPT-2 模型(15.42 亿参数)中,这个 transformer 块重复了 48 次。
最后一个 transformer 块的输出会经过最终的层归一化步骤,然后再到达线性输出
层。该层将 transformer 的输出映射到高维空间(在这种情况下为 50,257 维,对应
于模型的词汇表大小),以预测序列中的下一个标记。
我们现在来编写图 4.15 中的架构。
图 4.15 GPT 模型架构概览,显示了数据流经 GPT 模型的过程。从底部开始,分
词文本首先被转换为标记嵌入,然后与位置嵌入结合。这种组合信息形成一个张
量,通过一系列 transformer 块(每个包含多头注意力和前馈神经网络层,带有
dropout 和层归一化),这些块堆叠在一起并重复 12 次。
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):
device = in_idx.device
batch_size, seq_len = in_idx.shape
tok_embeds = self.tok_emb(in_idx)
pos_embeds = self.pos_emb(torch.arange(seq_len, device=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
得益于 TransformerBlock 类,GPTModel 类相对较小且紧凑。
GPTModel 类的 __init__ 构造函数使用通过 Python 字典 cfg 传递的配置初始化标
记和位置嵌入层。这些嵌入层负责将输入标记索引转换为密集向量,并添加位置
信息(参见第 2 章)。
接下来,__init__ 方法创建了一个等于 cfg 中指定层数的 TransformerBlock 模块的
顺序堆栈。在 transformer 块之后,应用了一个 LayerNorm 层,标准化来自 transformer 块的输出,以稳定学习过程。最后,定义了一个没有偏置的线性输出头,
将 transformer 的输出投影到分词器的词汇表空间,生成每个词汇表中标记的 logit。
forward 方法接受一批输入标记索引,计算它们的嵌入,应用位置嵌入,将序列传
递给 transformer 块,对最终输出进行归一化,然后计算 logit,表示下一个标记的
未归一化概率。我们将在下一节中将这些 logit 转换为标记和文本输出。
现在我们使用 GPT_CONFIG_124M 字典初始化 1.24 亿参数的 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)
这段代码打印了输入批的内容,然后是输出张量:
输入批:
正如我们所见,输出张量的形状为 [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:,}")
结果是:
总参数数量:163,009,536
现在,细心的读者可能会注意到一个差异。早些时候,我们提到要初始化一个
1.24 亿参数的 GPT 模型,为什么实际参数数量为 1.63 亿?
原因是称为权重绑定的概念,这是原始 GPT-2 架构中使用的一种技术。这意味着
原始 GPT-2 架构在其输出层中重用了标记嵌入层的权重。为了更好地理解这一
点,让我们看一下通过 GPTModel 初始化的标记嵌入层和线性输出层的形状:
print("Token embedding layer shape:", model.tok_emb.weight.shape)
print("Output layer shape:", model.out_head.weight.shape)
从打印输出中可以看出,这两个层的权重张量具有相同的形状:
标记嵌入层形状:torch.Size([50257, 768]) 输出层形状: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:,}")
输出是:
考虑权重绑定的可训练参数数量:124,412,160
正如我们所见,模型现在只有 1.24 亿个参数,与原始 GPT-2 模型的大小相匹配。
权重绑定减少了模型的整体内存占用和计算复杂度。然而,在我的经验中,使用
单独的标记嵌入层和输出层可以带来更好的训练和模型性能;因此,我们在 GPTModel 实现中使用了单独的层。现代 LLM 也是如此。不过,我们将在第 6 章加载
来自 OpenAI 的预训练权重时重新审视并实现权重绑定的概念。
练习 4.1 计算前馈模块和注意力模块中的参数数量 比较前馈模块和多头注意力模
块中包含的参数数量。
最后,让我们计算我们 GPTModel 对象中 1.63 亿参数所需的内存:
# 计算总大小(假设 float32,每个参数 4 字节)
total_size_bytes = total_params * 4
# 转换为兆字节
total_size_mb = total_size_bytes / (1024 * 1024)
print(f"Total size of the model: {total_size_mb:.2f} MB")
结果是:
模型的总大小:621.83 MB
总之,通过计算我们 GPTModel 对象中 1.63 亿参数所需的内存,并假设每个参数
是一个 32 位浮点数占用 4 字节,我们发现模型的总大小为 621.83 MB,这说明即
使是相对较小的 LLM 也需要相当大的存储容量。
现在我们已经实现了 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模型的总参
数数量。
// GPT-2 medium
embed_dim = 1024
num_blocks = 24
num_heads = 16
// GPT-2 large
embed_dim = 1280
num_blocks = 36
num_heads = 20
// GPT-2 XL
embed_dim = 1600
num_blocks = 48
num_heads = 25
作为附加任务,计算每个GPT模型的总参数数量。
4.7 生成文本
我们现在将实现把GPT模型的张量输出转换回文本的代码。在开始之前,让我们
简要回顾一下像LLM这样的生成模型如何逐词(或逐标记)生成文本。
图4.16展示了给定输入上下文(如“Hello, I am.”)时,GPT模型逐步生成文本的过
程。随着每次迭代,输入上下文逐渐增长,使得模型能够生成连贯且上下文适当
的文本。到第六次迭代时,模型已经构建了一个完整的句子:“Hello, I am a model
ready to help。”我们已经看到,当前的GPTModel实现输出具有形状[batch_size,
num_token, vocab_size]的张量。现在的问题是:GPT模型是如何从这些输出张量生
成文本的?
GPT模型从输出张量生成文本的过程涉及多个步骤,如图4.17所示。这些步骤包括
解码输出张量、根据概率分布选择标记,并将这些标记转换为人类可读的文本。
图4.16 LLM逐个标记生成文本的逐步过程。以初始输入上下文(“Hello, I am”)开
始,模型在每次迭代中预测下一个标记,并将其附加到输入上下文中以进行下一
轮预测。如图所示,第一次迭代添加了“a”,第二次添加了“model”,第三次添加了
“ready”,逐步构建句子。
图4.17中的下一个标记生成过程详细说明了GPT模型在给定输入的情况下生成下一
个标记的单步过程。在每一步中,模型输出一个表示潜在下一个标记的向量矩
阵。提取与下一个标记对应的向量,并通过softmax函数将其转换为概率分布。在
包含结果概率分数的向量中找到最高值的索引,该索引对应于标记ID。然后将此
标记ID解码回文本,生成序列中的下一个标记。最后,将此标记附加到先前的输
入中,形成新的输入序列以供后续迭代使用。这个逐步过程使模型能够顺序生成
文本,从初始输入上下文构建连贯的短语和句子。
实际上,我们会在许多迭代中重复这一过程,如图4.16所示,直到达到用户指定的
生成标记数量。在代码中,我们可以按照以下列表实现标记生成过程。
图4.17 通过展示令牌生成过程中的单次迭代来说明GPT模型中文本生成的机制。
该过程始于将输入文本编码为标记ID,然后将其输入到GPT模型中。模型的输出
再被转换回文本并附加到原始输入文本中。
列表4.8 GPT模型生成文本的函数
def generate_text_simple(model, idx, max_new_tokens, context_size):
for _ in range(max_new_tokens):
# Crop the context to fit the model’s maximum context size
idx_cond = idx if idx.size(1) <= context_size else idx[:,
-context_size:
]
# Compute predictions
logits = model(idx_cond)
# Select the next token based on the highest probability prediction
probs = torch.softmax(logits[:, -1, :], dim=-1)
next_idx = torch.argmax(probs, dim=-1).unsqueeze(1)
# Append the next token to the input sequence
idx = torch.cat((idx, next_idx), dim=1)
return idx
这段代码演示了使用PyTorch为语言模型实现生成循环的简单方法。它迭代指定数
量的新标记生成次数,裁剪当前上下文以适应模型的最大上下文大小,计算预
测,然后根据最高概率预测选择下一个标记。
为了实现generate_text_simple函数,我们使用softmax函数将logits转换为概率分
布,并通过torch.argmax识别具有最高值的位置。softmax函数是单调的,意味着它
在转换为输出时保留其输入的顺序。因此,在实践中,softmax步骤是多余的,因
为在softmax输出张量中具有最高分数的位置与logits张量中的位置相同。换句话
说,我们可以直接对logits张量应用torch.argmax函数并获得相同的结果。然而,我
提供此代码以说明将logits转换为概率的完整过程,这可以增加一些直觉,使模型
生成最可能的下一个标记,这被称为贪婪解码。
当我们实现下一章的GPT训练代码时,我们将使用额外的采样技术修改softmax输
出,以便模型不总是选择最可能的标记。这引入了生成文本的变化性和创造性。
逐个生成标记ID并使用generate_text_simple函数将其附加到上下文的过程进一步
在图4.18中说明。(每个迭代的标记ID生成过程在图4.17中有详细说明。)我们以
迭代方式生成标记ID。例如,在第1次迭代中,模型提供了与“Hello, I am”相对应
的标记,预测下一个标记(ID为257,即“a”),并将其附加到输入中。这个过程
重复进行,直到模型在六次迭代后生成完整的句子“Hello, I am a model ready to
help”。
现在让我们尝试使用“Hello, I am”作为模型输入的generate_text_simple函数。首
先,我们将输入上下文编码为标记ID:
图4.18 一个标记预测周期的六个迭代,其中模型以初始标记ID序列作为输入,预
测下一个标记,并将此标记附加到输入序列以供下一次迭代使用。(标记ID还被
转换为其相应的文本以更好地理解。)
接下来,我们将模型置于.eval()模式。这禁用了仅在训练期间使用的随机组件
(如dropout),并使用generate_text_simple函数处理编码后的输入张量:
model.eval() # 禁用dropout,因为我们不在训练模型
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]))
生成的输出标记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模型是具有数百万到数十亿参数的LLM,由许多重复的Transformer块组成。
GPT模型有不同的规模,例如124、345、762和1,542百万参数,我们可以使用相同
的GPTModel Python类实现它们。
GPT类LLM的文本生成能力涉及基于给定输入上下文逐个预测标记,并将输出张
量解码为人类可读的文本。
未经训练的GPT模型生成不连贯的文本,这突显了模型训练对于连贯文本生成的
重要性。
5 在未标注数据上预训练
本章涵盖
计算训练集和验证集的损失
以评估LLM生成文本的质量
在训练过程中
实现一个训练函数并预训练
LLM
保存和加载模型权重以继续
训练LLM
从OpenAI加载预训练权重
到目前为止,我们已经实现了数据采样和注意力机制,并编写了LLM架构。现在
是时候实现一个训练函数并预训练LLM了。我们将学习基本的模型评估技术来衡
量生成文本的质量,这是在训练过程中优化LLM的要求。此外,我们将讨论如何
加载预训练权重,为我们的LLM提供一个坚实的起点进行微调。图5.1概述了我们
的整体计划,突出了本章将要讨论的内容。
图5.1 编码LLM的三个主要阶段。本章重点是第2阶段:预训练LLM(步骤4),包
括实现训练代码(步骤5)、评估性能(步骤6)和保存和加载模型权重(步骤
7)。
权重参数
在LLM和其他深度学习模型的背景下,权重指的是学习过程中调整的可训练参
数。这些权重也被称为权重参数或简单地称为参数。在像PyTorch这样的框架中,
这些权重存储在线性层中;我们在第3章中使用它们实现了多头注意力模块,在第
4章中实现了GPTModel。在初始化一层(new_layer = torch.nn.Linear(...))之
后,我们可以通过.weight属性访问其权重,即new_layer.weight。此外,为了方便
起见,PyTorch允许通过model.parameters()方法直接访问模型的所有可训练参
数,包括权重和偏置,我们将在稍后实现模型训练时使用该方法。
new_layer = torch.nn.Linear(...)
weights = new_layer.weight
for param in model.parameters():
# 访问所有可训练参数
5.1 评估生成文本模型
在简要回顾第4章的文本生成后,我们将设置用于文本生成的LLM,并讨论评估生
成文本质量的基本方法。然后,我们将计算训练和验证损失。图5.2展示了本章涵
盖的主题,并突出了前三个步骤。
图5.2 本章涵盖主题的概览。我们首先回顾文本生成(步骤1),然后讨论基本的
模型评估技术(步骤2)和训练及验证损失(步骤3)。
5.1.1 使用GPT生成文本
让我们设置LLM并简要回顾我们在第4章实现的文本生成过程。我们从使用GPTModel类和GPT_CONFIG_124M字典初始化GPT模型开始(参见第4章):
import torch
from chapter04 import GPTModel
GPT_CONFIG_124M = {
"vocab_size": 50257,
"context_length": 256,
"emb_dim": 768,
"n_heads": 12,
"n_layers": 12,
"dropout": 0.1
}
torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.eval()
考虑到GPT_CONFIG_124M字典,与上一章相比,我们唯一调整的是将上下文长
度(context length)缩短到256个标记。这一修改减少了训练模型的计算需求,使
得可以在标准笔记本电脑上进行训练。
最初的GPT-2模型配置为处理多达1,024个标记,包含1.24亿个参数。在训练过程之
后,我们将更新上下文大小设置并加载预训练权重,以使用配置为1,024个标记上
下文长度的模型工作。
使用GPTModel实例,我们采用来自第4章的generate_text_simple函数,并引入两个
方便的函数:text_to_token_ids和token_ids_to_text。这些函数有助于在文本和标记
表示之间进行转换,这是我们在本章中将使用的技巧。
图5.3 文本生成涉及将文本编码为标记ID,LLM将其处理成logit向量。然后将logit
向量转换回标记ID,并解码为文本表示。
图5.3展示了使用GPT模型进行文本生成的三步过程。首先,分词器将输入文本转
换为一系列标记ID(参见第2章)。其次,模型接收这些标记ID并生成相应的logits,即表示词汇表中每个标记概率分布的向量(参见第4章)。最后,这些logits被
转换回标记ID,分词器将其解码为人类可读的文本,完成从文本输入到文本输出
的循环。
我们可以按照以下列表所示实现文本生成过程。
列表5.1 文本到标记ID转换的实用函数
import tiktoken
from chapter04 import generate_text_simple
def text_to_token_ids(text, tokenizer):
encoded = tokenizer.encode(text)
encoded_tensor = torch.tensor(encoded).unsqueeze(0).unsqueeze(0)
return encoded_tensor # 添加批次维度
def token_ids_to_text(token_ids, tokenizer):
flat = token_ids.squeeze(0) # 移除批次维度
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))
使用此代码,模型生成了以下文本:
Output text: Every effort moves you rentingetic wasn? refres RexMeCHicular stren
显然,模型尚未生成连贯的文本,因为它还没有经过训练。为了定义什么是“连
贯”或“高质量”的文本,我们必须实现一种数值方法来评估生成的内容。这种方法
将使我们能够在训练过程中监控和提高模型性能。
接下来,我们将计算生成输出的损失度量。这个损失作为训练进展的成功指标。
此外,在后续章节中,当我们微调我们的LLM时,我们将回顾其他评估模型质量
的方法。
5.1.2 计算文本生成损失
接下来,让我们探讨通过计算文本生成损失来数值评估训练期间生成文本质量的
技术。我们将逐步介绍这个话题,并通过一个实际的例子使其清晰且适用,从简
要回顾数据加载方式和通过generate_text_simple函数生成文本的过程开始。
图5.4展示了从输入文本到LLM生成文本的整体流程,使用五步程序。这个文本生
成过程显示了generate_text_simple函数内部的工作原理。我们需要执行这些初始步
骤,才能在本节稍后计算衡量生成文本质量的损失。
图5.4概述了文本生成过程,并使用一个小的七个标记词汇表来适应这一页的图
像。然而,我们的GPTModel使用一个包含50,257个单词的更大词汇表;因此,以
下代码中的标记ID将从0到50,256而不是0到6。
图5.4 对于左边显示的三个输入标记,我们计算一个包含对应词汇表中每个标记的
概率得分的向量。每个向量中最高概率得分的索引位置代表最有可能的下一个标
记ID。这些与最高概率得分关联的标记ID被选中并映射回文本,代表模型生成的
文本。
此外,图5.4仅显示了一个简单的文本示例(“every effort moves”)。在下面的手动
代码示例中,我们将使用两个输入示例(“every effort moves” 和 “I really like”)实
现图中的步骤。
考虑这两个输入示例,它们已经被映射为标记ID(图5.4,步骤1):
inputs = torch.tensor([
[16833, 3626, 6100],
[40, 1107, 588]
])
# ["every effort moves"]
# ["I really like"]
与这些输入相匹配的目标包含我们希望模型生成的标记ID:
targets = torch.tensor([
[3626, 6100, 345],
[1107, 588, 11311]
])
# [" effort moves you"]
# [" really like chocolate"]
请注意,目标是输入向前移动一个位置的结果,这一概念我们在第2章的数据加载
器实现中已经讨论过。这种移动策略对于教会模型预测序列中的下一个标记至关
重要。
现在,我们将输入送入模型以计算两个输入示例的logits向量,每个示例包含三个
标记。然后应用softmax函数将这些logits转换为概率得分(probas;图5.4,步骤
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], First batch [ 339], [42826]], [[49906], Second batch
[29669], [41751]]])
最后,步骤5将标记ID转换回文本:
print(f"目标批次 1: {token_ids_to_text(targets[0], tokenizer)}")
print(f"输出批次 1: {token_ids_to_text(token_ids[0].flatten(), tokenizer)}")
当我们解码这些标记时,我们发现这些输出标记与我们希望模型生成的目标标记
有很大的不同:
目标批次 1: effort moves you
输出批次 1: Armed heNetflix
因为模型还没有经过训练,所以它生成的文本是随机的,与目标文本不同。我们
现在希望通过损失(图5.5)来数值评估模型生成文本的性能。这不仅有助于衡量
生成文本的质量,也是实现训练函数的一个重要步骤,我们将使用该函数更新模
型权重以改进生成的文本。
图5.5 本章涵盖主题的概述。我们已经完成了步骤1。现在可以准备实现文本评估
函数(步骤2)。
我们在图5.5中展示的文本评估过程的一部分是测量“生成的标记”与正确预测(目
标)之间的差距。我们稍后实现的训练函数将使用此信息调整模型权重,以生成
更接近(或理想情况下匹配)目标文本的文本。
模型训练的目的是增加对应于正确目标标记ID的索引位置的softmax概率,如图5.6
所示。这个softmax概率也将用于我们接下来要实现的评估指标,以数值评估模型
的生成输出:正确位置的概率越高越好。
请记住,图5.6展示了紧凑的七标记词汇表的softmax概率,以便将所有内容放入一
个图中。这意味着初始随机值将围绕1/7波动,大约为0.14。然而,我们用于GPT-2
模型的词汇表有50,257个标记,因此大多数初始概率将围绕0.00002(1/50,257)波
动。
图5.6 训练前,模型生成随机的下一个标记概率向量。模型训练的目标是确保对应
于高亮显示的目标标记ID的概率值最大化。
对于每个输入文本,我们可以打印出与目标标记对应的初始softmax概率得分,代
码如下:
text_idx = 0
target_probas_1 = probas[text_idx, [0, 1, 2], targets[text_idx]]
print("文本 1:", target_probas_1)
text_idx = 1
target_probas_2 = probas[text_idx, [0, 1, 2], targets[text_idx]]
print("文本 2:", target_probas_2)
每个批次的三个目标标记ID概率为:
文本 1: tensor([7.4541e-05, 3.1061e-05, 1.1563e-05])
文本 2: tensor([1.0337e-05, 5.6776e-05, 4.7559e-06])
训练大型语言模型(LLM)的目标是最大化正确标记的可能性,即增加其相对于
其他标记的概率。这样可以确保LLM一致地选择目标标记——基本上是句子中的
下一个词——作为它生成的下一个标记。
反向传播
我们如何最大化对应于目标标记的softmax概率值?总体思路是通过更新模型权
重,使模型对我们要生成的标记ID输出更高的值。权重更新是通过称为反向传播
的过程完成的,这是训练深度神经网络的标准技术(有关反向传播和模型训练的
更多详细信息,请参见附录A的A.3至A.7节)。
反向传播需要一个损失函数,该函数计算模型预测输出(在这里,对应于目标标
记ID的概率)与实际期望输出之间的差异。此损失函数衡量模型预测与目标值相
差多远。
接下来,我们将计算两个示例批次的目标概率得分的损失,分别是target_probas_1
和target_probas_2。主要步骤如图5.7所示。由于我们已经完成了步骤1到3以获得
target_probas_1和target_probas_2,我们将进行步骤4,对概率得分应用对数:
log_probas = torch.log(torch.cat((target_probas_1, target_probas_2)))
print(log_probas)
图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中的所有六个步骤。
交叉熵损失
本质上,交叉熵损失是机器学习和深度学习中常用的度量,用于衡量两个概率分
布之间的差异——通常是指真实标签分布(这里是指数据集中的标记)和模型预
测分布(例如,由LLM生成的标记概率)。
在机器学习的具体上下文中,特别是在像PyTorch这样的框架中,cross_entropy函
数计算这种度量,适用于离散结果,类似于给定模型生成标记概率的目标标记的
负平均对数概率,因此在实践中,“交叉熵”和“负平均对数概率”这两个术语经常
互换使用。
在应用cross_entropy函数之前,让我们简要回顾一下logits和目标张量的形状:
print("Logits 形状:", logits.shape)
print("目标 形状:", targets.shape)
得到的形状为:
Logits 形状: torch.Size([2, 3, 50257])
目标 形状: torch.Size([2, 3])
可以看出,logits张量有三个维度:批量大小、标记数量和词汇表大小。目标张量
有两个维度:批量大小和标记数量。
对于PyTorch中的cross_entropy损失函数,我们希望展平这些张量,通过组合它们
的批量维度:
logits_flat = logits.flatten(0, 1)
targets_flat = targets.flatten()
print("展平后的 logits:", logits_flat.shape)
print("展平后的目标:", targets_flat.shape)
得到的张量维度为:
展平后的 logits: torch.Size([6, 50257])
展平后的目标: torch.Size([6])
请记住,目标是我们希望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所示,
我们将计算训练和验证集的交叉熵,这是模型训练过程中的一个重要组成部分。
图5.8 完成步骤1和2后,包括计算交叉熵损失,我们现在可以将此损失计算应用到
整个文本数据集,该数据集将用于模型训练。
为了在训练和验证数据集上计算损失,我们使用了一个非常小的文本数据集,即
伊迪丝·华顿的短篇小说《The Verdict》,我们在第2章中已经处理过。通过选择公
共领域的文本,我们可以避免任何与使用权相关的担忧。此外,使用如此小的数
据集可以在几分钟内在一个标准笔记本电脑上执行代码示例,即使没有高端
GPU,这对于教育目的特别有利。
注意 感兴趣的读者也可以使用本书的补充代码来准备一个由超过60,000本公共领
域书籍组成的更大规模的数据集,并在此基础上训练LLM(详情见附录D)。
预训练LLM的成本
为了使我们的项目规模具有可比性,考虑一下训练包含70亿参数的Llama 2模型,
这是一个相对受欢迎的开源可用LLM。这个模型需要在昂贵的A100 GPU上进行
184,320小时的训练,处理2万亿个标记。截至撰写本文时,在AWS上运行一台8 ×
A100云服务器的成本约为每小时30美元。粗略估计,这种LLM的总训练成本约为
690,000美元(计算方法为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代码中。
接下来,我们将数据集分为训练集和验证集,并使用第2章中的数据加载器来准备
用于LLM训练的批次。这一过程如图5.9所示。由于空间限制,我们使用
max_length=6。然而,对于实际的数据加载器,我们将max_length设置为LLM支持
的256个标记的上下文长度,以便LLM在训练期间看到更长的文本。
图5.9 在准备数据加载器时,我们将输入文本分为训练集和验证集部分。然后我们
对文本进行分词(仅显示训练集部分,以简化),并将分词后的文本分成用户指
定长度的块(这里为6)。最后,我们打乱行并组织这些分块的文本成批次(这里
为批量大小2),可用于模型训练。
注意 我们以相同大小的块呈现训练数据,以简化和提高效率。然而,在实践中,
使用可变长度的输入来训练LLM也有助于LLM更好地泛化不同类型的输入,当它
被使用时。
为了实现数据拆分和加载,我们首先定义train_ratio以使用90%的数据进行训练,
其余10%作为验证数据,在训练期间评估模型:
使用train_data和val_data子集,我们现在可以创建相应的数据加载器,重用第2章
中的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)具有相同的形状(批
量大小乘以每个批次中的标记数量),因为目标是输入向右移一位,如第2章所
述。
接下来,我们实现一个实用函数,以计算通过训练和验证加载器返回的给定批次
的交叉熵损失:
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)
loss = torch.nn.functional.cross_entropy(
logits.flatten(0, 1),
target_batch.flatten()
)
return loss
我们现在可以使用这个calc_loss_batch实用函数,它计算单个批次的损失,来实现
以下计算所有由给定数据加载器采样的批次损失的calc_loss_loader函数。
列表5.2 计算训练和验证损失的函数
默认情况下,calc_loss_loader函数迭代给定数据加载器中的所有批次,在total_loss
变量中累积损失,然后计算并平均所有批次的损失。或者,我们可以通过
num_batches指定更少的批次以加快模型训练期间的评估。
让我们现在看看这个calc_loss_loader函数的应用,将其应用于训练和验证集加载
器:
得到的损失值为
训练损失:10.98758347829183
验证损失:10.98110580444336
这些损失值相对较高,因为模型尚未进行训练。作为对比,如果模型学会了生成
与训练集和验证集中出现的下一个标记相同的标记,损失将接近0。
现在我们有了衡量生成文本质量的方法,我们将训练大语言模型(LLM)以减少
这一损失,使其更好地生成文本,如图5.10所示。
图5.10 我们回顾了文本生成过程(步骤1),并实现了基本的模型评估技术(步骤
2)来计算训练集和验证集的损失(步骤3)。接下来,我们将进入训练函数并预
训练LLM(步骤4)。
接下来,我们将专注于预训练LLM。在模型训练之后,我们将实现替代的文本生
成策略,并保存和加载预训练模型权重。
5.2 训练LLM
终于到了实现预训练LLM(我们的GPTModel)代码的时候了。为此,我们专注于
一个简单的训练循环,以保持代码简洁和易读。
注意 感兴趣的读者可以在附录D中学习更多高级技术,包括学习率预热、余弦退
火和梯度裁剪。
图5.11 在PyTorch中训练深度神经网络的典型训练循环包括许多步骤,在多个
epoch中迭代训练集中的批次。在每个循环中,我们计算每个训练集批次的损失以
确定损失梯度,并使用这些梯度更新模型权重,从而使训练集损失最小化。
图5.11中的流程图描绘了一个典型的PyTorch神经网络训练工作流,我们用它来训
练LLM。它概述了八个步骤,从遍历每个epoch开始,处理批次,重置梯度,计算
损失和新梯度,更新权重,并以监控步骤(如打印损失和生成文本样本)结束。
注意 如果您是PyTorch训练深度神经网络的新手,并且对这些步骤不熟悉,建议阅
读附录A中的A.5到A.8部分。
我们可以通过train_model_simple函数在代码中实现这个训练流程。
清单5.3 预训练LLM的主要函数
请注意,我们刚刚创建的train_model_simple函数使用了两个尚未定义的函数:
evaluate_model和generate_and_print_sample。
evaluate_model函数对应于图5.11中的第7步。它在每次模型更新后打印训练集和验
证集的损失,以便我们可以评估训练是否提高了模型性能。更具体地说,evaluate_model函数在计算训练集和验证集的损失时确保模型处于评估模式,并禁用梯
度跟踪和dropout以获得稳定和可重现的结果:
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
类似于evaluate_model,generate_and_print_sample函数是一个方便的功能,用于跟
踪模型在训练期间是否有所改进。特别是,generate_and_print_sample函数接受一
个文本片段(start_context)作为输入,将其转换为token ID,并将其馈送到LLM
中,使用我们之前使用的generate_text_simple函数生成文本样本:
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", " "))
model.train()
虽然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(),
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
)
执行train_model_simple函数启动训练过程,大约需要5分钟才能在MacBook Air或
类似笔记本电脑上完成。在此期间打印的输出如下:
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,
[…]
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
如我们所见,训练损失显著改善,从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()
ax2.plot(tokens_seen, train_losses, alpha=0)
ax2.set_xlabel("Tokens seen")
fig.tight_layout()
plt.show()
结果的训练和验证损失图如图5.12所示。如我们所见,训练和验证损失在第一个
epoch开始时迅速下降,这是模型正在学习的迹象。然而,从第二个epoch之后,
损失开始发散。这种发散以及验证损失远大于训练损失的事实表明模型正在过拟
合训练数据。我们可以通过搜索生成的文本片段(如“The Verdict”文本文件中的
“quite insensible to the irony”)来确认模型记住了训练数据。
epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
plot_losses(epochs_tensor, tokens_seen, train_losses, val_losses)
图5.12 在训练开始时,训练集和验证集损失急剧下降,这表明模型正在学习。然
而,训练集损失在第二个epoch之后继续下降,而验证损失停滞不前。这表明模型
仍在学习,但在第二个epoch之后过度拟合训练集。
这种记忆是预期之中的,因为我们正在使用一个非常小的训练数据集,并且训练
模型多个周期。通常情况下,在更大的数据集上只训练一个周期是很常见的。
注意 如前所述,感兴趣的读者可以尝试在Project Gutenberg的60,000本公共领域书
籍上训练模型,在那里不会出现过拟合;详见附录B。
图5.13 我们的模型在实现了训练函数后可以生成连贯的文本。然而,它经常逐字
记忆训练集中的段落。接下来,我们将讨论一些策略以生成更多样化的输出文
本。
如图5.13所示,我们已经完成了本章的四个目标。接下来,我们将介绍减少大型语
言模型(LLM)训练数据记忆并增加LLM生成文本原创性的文本生成策略,然后
介绍权重加载和保存以及从OpenAI的GPT模型加载预训练权重。
解码策略以控制随机性
让我们看一下文本生成策略(也称为解码策略),以生成更具原创性的文本。首
先,我们将简要回顾一下之前在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))
生成的文本为:
输出文本:
Every effort moves you know,” was one of the axioms he laid down across the
Sevres and silver of an exquisitely appointed lun
如前所述,在每个生成步骤中选择生成的标记对应于词汇表中所有标记的最大概
率分数。这意味着即使我们多次运行前面的generate_text_simple函数,LLM也会生
成相同的输出。
5.3.1 温度缩放
现在让我们看一下温度缩放,这是一种为下一个标记生成任务添加概率选择过程
的技术。之前,在generate_text_simple函数中,我们总是使用torch.argmax(也称
为贪婪解码)来采样具有最高概率的标记作为下一个标记。为了生成更多样化的
文本,我们可以用从概率分布中采样的函数替换argmax(这里是指LLM在每个标
记生成步骤中为词汇表条目生成的概率分数)。
为了通过具体示例说明概率采样,让我们简要讨论一下使用非常小的词汇表进行
下一个标记生成的过程:
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
])
如第4章所述,在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概率分数位于第四位(索引位置3,因为
Python使用0索引),所以生成的单词是”forward”。
为了实现概率采样过程,我们现在可以用PyTorch中的multinomial函数替换
argmax:
torch.manual_seed(123)
next_token_id = torch.multinomial(probas, num_samples=1).item()
print(inverse_vocab[next_token_id])
打印的输出结果是”forward”,就像之前一样。发生了什么?multinomial函数按其
概率分数的比例采样下一个标记。换句话说,“forward”仍然是最有可能的标记,
但不是每次都会被选中。为了说明这一点,让我们实现一个函数重复这个采样
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次)被采样,但其他标记如
“closer”,“inches”和“toward”有时也会被采样。这意味着如果我们用multinomial函
数替换generate_and_print_sample函数中的argmax函数,LLM有时会生成诸如
“every effort moves you toward”,“every effort moves you inches”和“every 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]
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()
结果图如图5.14所示。
温度为1表示在将logits传递给softmax函数计算概率分数之前将其除以1。换句话
说,使用温度为1相当于不使用任何温度缩放。在这种情况下,标记是通过PyTorch中的multinomial采样函数以等于原始softmax概率分数的概率选择的。例如,
对于温度设置为1的情况,“forward”对应的标记大约会被选择60%的时间,如图
5.14所示。
图5.14 温度为1代表词汇表中每个标记的未缩放概率分数。将温度降低到0.1会使分
布更尖锐,因此最可能的标记(在这里是“forward”)将具有更高的概率分数。同
样,将温度提高到5会使分布更均匀。
此外,如图5.14所示,应用非常小的温度(如0.1)会导致更尖锐的分布,使得
multinomial函数几乎100%选择最可能的标记(在这里是“forward”),接近argmax
函数的行为。同样,温度为5会导致更均匀的分布,其中其他标记更常被选择。这
可以为生成的文本添加更多的多样性,但也更常导致无意义的文本。例如,使用
温度为5时,大约有4%的时间会出现“every effort moves you pizza”的文本。
练习5.1
使用 print_sampled_tokens 函数打印图 5.14 中所示温度缩放后的 softmax 概率的
采样频率。在每种情况下,披萨这个词被采样的频率是多少?你能想到一种更快
更准确的方法来确定披萨这个词被采样的频率吗?
5.3.2 Top-k 采样
我们现在实现了一种结合温度缩放的概率采样方法,以增加输出的多样性。我们
看到,较高的温度值会导致下一个 token 的概率分布更加均匀,从而产生更多样化
的输出,因为它减少了模型重复选择最可能的 token 的可能性。这种方法允许探索
不太可能但潜在更有趣和更具创造性的生成路径。然而,这种方法的一个缺点是
它有时会导致语法不正确或完全无意义的输出,例如“每个努力都会让你披萨”。
Top-k 采样与概率采样和温度缩放相结合可以改善文本生成的结果。在 top-k 采样
中,我们可以将采样 token 限制为最有可能的 top-k 个 token,并通过将其他所有
token 的概率分数屏蔽为负无穷大(-inf)来排除它们的选择过程,如图 5.15 所
示。
图 5.15 使用 top-k 采样时,k = 3,我们专注于与最高三个 logit 值相关的三个 token,并将所有其他 token 用负无穷大(–inf)屏蔽后再应用 softmax 函数。这导致
一个概率分布,其中所有非 top-k token 的概率值为 0。(该图中的数字截断为小
数点后两位以减少视觉混乱。“Softmax” 行中的值应加起来等于 1.0。)
top-k 方法将所有未选中的 logit 值替换为负无穷大值(-inf),使得在计算 softmax
值时,非 top-k token 的概率分数为 0,剩余的概率总和为 1。(细心的读者可能会
记得我们在第 3 章第 3.5.1 节中实现因果注意力模块时使用了这种屏蔽技巧。)
在代码中,我们可以按照图 5.15 所示实现 top-k 过程,首先选择具有最大 logit 值
的 token:
前三个 token 的 logit 值和 token ID,按降序排列为
Top logits: tensor([6.7500, 6.2800, 4.5100]) Top positions: tensor([3, 7,
0])
随后,我们应用 PyTorch 的 where 函数,将低于我们选择的前三个 token 中最低
logit 值的 token 的 logit 值设置为负无穷大(-inf):
new_logits = torch.where(
next_token_logits < top_logits[-1],
# 条件:低于最低的 top 3 logit 值
torch.tensor(float('-inf')),
next_token_logits
)
print(new_logits)
# 将这些较低的 logit 设置为 -inf
# 保留其他 token 的原始 logit
# 保留所有其他 token 的原始 logit
接下来 token 的九个词汇表中的 logit 结果为
最后,让我们应用 softmax 函数将其转换为下一个 token 的概率:
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])
现在我们可以应用温度缩放和多类抽样函数,在这三个非零概率分数中选择下一
个 token 以生成下一个 token。我们接下来通过修改文本生成函数来实现这一点。
5.3.3 修改文本生成函数
现在,让我们结合温度采样和 top-k 采样来修改我们之前用于通过 LLM 生成文本
的 generate_text_simple 函数,创建一个新的 generate 函数。
if top_k is not None:
# 过滤 logit,进行 top_k 采样
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:
logits = logits / temperature
probs = torch.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1)
else:
idx_next = torch.argmax(logits, dim=-1, keepdim=True)
# 如果遇到结束标记,提前停止生成
if idx_next == eos_id:
break
# 拼接新生成的 token
idx = torch.cat((idx, idx_next), dim=1)
如果遇到结束标记,则停止生成并返回 idx。
现在让我们看看这个新的 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,” 是他铺设的一条公理之一…!)有很大
不同,后者是从训练集中记忆下来的段落。
练习 5.2
尝试不同的温度和 top-k 设置。根据你的观察,你能想到哪些应用场景需要较低的
温度和 top-k 设置?同样,你能想到哪些应用场景需要较高的温度和 top-k 设置?
(建议在加载 OpenAI 的预训练权重后再次回顾此练习。)
练习 5.3
为了强制确定性行为,即禁用随机采样以使其始终产生相同的输出,类似于
generate_simple 函数,generate 函数的不同设置组合有哪些?
5.3 在 PyTorch 中加载和保存模型权重
到目前为止,我们讨论了如何数值评估训练进度以及从头预训练一个 LLM。即使
LLM 和数据集都相对较小,这个练习也表明预训练 LLM 是计算密集型的。因
此,能够保存 LLM 以便在每次需要在新会话中使用它时不必重新运行训练是非常
重要的。
所以,让我们讨论如何保存和加载预训练模型,如图 5.16 所示。稍后,我们将把
OpenAI 的更强大的预训练 GPT 模型加载到我们的 GPTModel 实例中。
图 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()
正如第 4 章所讨论的,dropout 通过在训练期间随机“丢弃”一层的神经元来帮助防
止模型过拟合训练数据。然而,在推理过程中,我们不希望随机丢弃网络学到的
任何信息。使用 model.eval() 将模型切换到推理模式,禁用 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.4 从 OpenAI 加载预训练权重
之前,我们使用了一个包含短篇小说书籍的有限数据集来训练一个小的 GPT-2 模
型。这种方法使我们能够专注于基础内容,而无需花费大量时间和计算资源。
幸运的是,OpenAI 公开了他们的 GPT-2 模型的权重,从而消除了我们自己在大型
语料库上重新训练模型所需的数万到数十万美元的投资。因此,让我们将这些权
重加载到我们的 GPTModel 类中,并使用该模型进行文本生成。这里提到的权重
是指存储在 PyTorch 的 Linear 和 Embedding 层的 .weight 属性中的权重参数,例
如。我们在训练模型时通过 model.parameters() 访问了它们。在第 6 章中,我们将
重用这些预训练权重对模型进行微调,以完成文本分类任务,并遵循与 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 模型相关的以下七个文件:
注意 如果下载代码对您不起作用,可能是由于间歇性网络连接、服务器问题或
OpenAI 分享开源 GPT-2 模型权重方式的变化。在这种情况下,请访问本章的在线
代码仓库 https://github.com/rasbt/LLMsfrom-scratch 获取替代和更新的说明,并通
过 Manning Forum 提出进一步的问题。
假设前面的代码已经执行完毕,让我们检查 settings 和 params 的内容:
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’])
settings 和 params 都是 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 还共享更大模型的权重:355M、774M 和
1558M。不同大小的 GPT 模型的整体架构相同,如图 5.17 所示,只是不同的架构
元素重复次数不同,嵌入大小也不同。本章剩余代码也兼容这些较大模型。
图 5.17 GPT-2 LLMs 有几种不同的模型大小,范围从 1.24 亿到 15.58 亿参数。核
心架构相同,唯一的区别是嵌入大小以及注意力头和变压器块等组件的重复次
数。
在将 GPT-2 模型权重加载到 Python 中后,我们仍需将它们从 settings 和 params 字
典转移到我们的 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 个标记长度,但原始 GPT-2 模型由
OpenAI 使用 1,024 个标记长度进行训练,因此我们需要相应地更新 NEW_CONFIG:
NEW_CONFIG.update({"context_length": 1024})
此外,OpenAI 在多头注意力模块的线性层中使用了偏置向量来实现查询、键和值
矩阵的计算。偏置向量在现代 LLM 中已不再常用,因为它们不会提高建模性能,
因此是不必要的。然而,由于我们正在处理预训练权重,我们需要匹配设置以保
持一致性,并启用这些偏置向量:
NEW_CONFIG.update({"qkv_bias": True})
现在我们可以使用更新后的 NEW_CONFIG 字典初始化一个新的 GPTModel 实
例:
gpt = GPTModel(NEW_CONFIG)
gpt.eval()
默认情况下,GPTModel 实例初始化时带有随机权重用于预训练。使用 OpenAI 模
型权重的最后一步是用我们加载到 params 字典中的权重覆盖这些随机权重。为
此,我们首先定义一个小型的 assign 实用函数,该函数检查两个张量或数组(left
和 right)是否具有相同的维度或形状,并返回右张量作为可训练的 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
gpt.pos_emb.weight = assign(gpt.pos_emb.weight, params['wpe'])
gpt.tok_emb.weight = assign(gpt.tok_emb.weight, params['wte'])
遍历模型中的每个变压器块
在 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 中:
如果模型正确加载,我们现在可以使用之前的 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
计算带有 OpenAI 预训练权重的 GPTModel 在“判决书”数据集上的训练集和验证集
损失。
练习 5.6
实验不同大小的 GPT-2 模型——例如,最大的 15.58 亿参数模型——并将其生成
的文本与 1.24 亿参数模型进行比较。
摘要
当大语言模型(LLM)生成文本时,它们一次输出一个标记。默认情况下,下一
个标记是通过将模型输出转换为概率分数并选择对应于最高概率分数的词汇表中
的标记生成的,这被称为“贪婪解码”。通过使用概率抽样和温度缩放,我们可以
影响生成文本的多样性和连贯性。训练和验证集损失可以用来衡量LLM在训练期
间生成文本的质量。
预训练LLM涉及调整其权重以最小化训练损失。LLM的训练循环本身是深度学
习中的标准程序,使用常规的交叉熵损失和AdamW优化器。由于在大规模文本语
料库上预训练LLM既耗时又耗费资源,因此我们可以加载公开可用的权重,而不
是自己在大型数据集上预训练模型。
6 分类微调
本章涵盖内容
介绍不同的LLM微调方法
准备用于文本分类的数据集
修改预训练的LLM以进行微调
微调LLM以识别垃圾邮件
评估微调后的LLM分类器的准确性
使用微调后的LLM对新数据进行分类
到目前为止,我们已经编写了LLM架构,对其进行了预训练,并学会了如何从外
部来源(如OpenAI)导入预训练权重到我们的模型中。现在我们将通过针对特定
目标任务(如分类文本)微调LLM来收获劳动成果。具体例子是将文本消息分类
为“垃圾邮件”或“非垃圾邮件”。图6.1突出了微调LLM的两种主要方式:分类微调
(步骤8)和指令微调(步骤9)。
图6.1 编写LLM的三个主要阶段。本章重点在于第3阶段(步骤8):将预训练的
LLM微调为分类器。
6.1 不同类型的微调
最常见的微调语言模型的方法是指令微调和分类微调。指令微调涉及使用特定指
令对语言模型进行训练,以提高其理解和执行自然语言提示中描述的任务的能
力,如图6.2所示。
图6.2 两种不同的指令微调场景。顶部,模型被要求确定给定文本是否为垃圾邮
件。底部,模型被给予如何将英语句子翻译成德语的指令。
在分类微调中,如果你有机器学习背景,你可能已经熟悉这个概念。模型被训练
以识别一组特定的类别标签,例如“垃圾邮件”和“非垃圾邮件”。分类任务的例子
不仅限于LLM和电子邮件过滤:还包括从图像中识别不同种类的植物;将新闻文
章归类为体育、政治和技术等主题;以及在医学影像中区分良性肿瘤和恶性肿
瘤。
关键点是,经过分类微调的模型只能预测它在训练过程中遇到的类别。例如,它
可以确定某物是否为“垃圾邮件”或“非垃圾邮件”,如图6.3所示,但它不能对输入
文本进行其他说明。
图6.3 使用LLM进行文本分类的情景。经过垃圾邮件分类微调的模型不需要进一步
的指令即可响应。与指令微调模型不同,它只能回答“垃圾邮件”或“非垃圾邮
件”。
与图6.3中描绘的分类微调模型相比,指令微调模型通常能够承担更广泛的任务。
我们可以将分类微调模型视为高度专业化的模型,而开发专门模型通常比开发能
够在各种任务中表现出色的通用模型更容易。
选择合适的方法
指令微调提高了模型根据特定用户指令理解并生成响应的能力。指令微调最适合
需要处理基于复杂用户指令的各种任务的模型,从而提高灵活性和交互质量。分
类微调适用于需要将数据精确分类为预定义类别的项目,如情感分析或垃圾邮件
检测。
虽然指令微调更为通用,但它需要更大的数据集和更多的计算资源来开发能够胜
任各种任务的模型。相比之下,分类微调需要较少的数据和计算能力,但其用途
仅限于模型在训练中遇到的具体类别。
6.2 准备数据集
我们将修改并分类微调之前实现和预训练的GPT模型。我们首先下载并准备数据
集,如图6.4所示。为了提供一个直观且有用的分类微调示例,我们将使用一个包
含垃圾邮件和非垃圾邮件的消息数据集。
图6.4 分类微调LLM的三阶段过程。阶段1涉及数据集准备。阶段2专注于模型设
置。阶段3涵盖微调和评估模型。
注意 文本消息通常是通过电话发送的,而不是电子邮件。然而,相同的步骤也适
用于电子邮件分类,感兴趣的读者可以在附录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"
执行上述代码后,数据集将以制表符分隔的文本文件形式保存在sms_spam_collection文件夹中,文件名为SMSSpamCollection.tsv。我们可以将其加载到pandas
DataFrame中:
图6.5显示了垃圾邮件数据集的结果数据框。
图6.5 SMSSpamCollection数据集在pandas DataFrame中的预览,显示类别标签
(“ham”或“spam”)和相应的文本消息。数据集由5,572行(文本消息和标签)组
成。
5572 行 × 2 列
让我们检查类别标签的分布:
执行上述代码后,我们发现数据集中“ham”(即非垃圾邮件)的数量远多于
“spam”:
Label
ham 4825
spam 747
Name: count, dtype: int64
为了简化起见,并且因为我们更喜欢小数据集(这将有助于更快地微调LLM),
我们选择对数据集进行欠采样,以包括每个类别的747个实例。
注意 还有几种其他方法可以处理类别不平衡问题,但这超出了本书的范围。有兴
趣探索处理不平衡数据方法的读者可以在附录B中找到更多信息。
我们可以使用以下代码列表中的代码对数据集进行欠采样并创建一个平衡的数据
集。
清单6.2 创建平衡的数据集
执行上述代码以平衡数据集后,我们可以看到我们现在有相等数量的垃圾邮件和
非垃圾邮件:
Label
ham 747
spam 747
Name: count, dtype: int64
接下来,我们将字符串类别标签”ham”和”spam”分别转换为整数类别标签0和1:
df["Label"] = balanced_df["Label"].map({"ham": 0, "spam": 1})
这个过程类似于将文本转换为token ID。但是,我们不是使用包含超过50,000个词
的GPT词汇表,而是只处理两个token ID:0和1。
接下来,我们创建一个 random_split 函数将数据集分成三部分:70% 用于训练,
10% 用于验证,20% 用于测试。(这些比例在机器学习中很常见,用于训练、调
整和评估模型。)
让我们将数据集保存为 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数据加载器。之前,我们使
用滑动窗口技术生成统一大小的文本块,然后将这些块组合成批次以更高效地训
练模型。每个块作为一个单独的训练实例。然而,我们现在处理的是包含不同长
度文本消息的垃圾邮件数据集。为了像处理文本块那样对这些消息进行批处理,
我们有两个主要选择:
• 将所有消息截断为数据集中最短消息的长度。
• 将所有消息填充为数据集中最长消息的长度。
第一个选项计算成本较低,但如果较短的消息远小于平均或最长消息,可能会导
致大量信息丢失,从而降低模型性能。因此,我们选择第二个选项,以保留所有
消息的完整内容。
为了实现批处理,其中所有消息都被填充到数据集中最长消息的长度,我们向所
有较短的消息添加填充标记。为此,我们使用”<pad>“作为填充标记。
然而,与其直接在每条文本消息后追加字符串”<pad>“,我们可以添加
与”<pad>“对应的标记ID到编码后的文本消息中,如图6.6所示。50256是填充标
记”<pad>“的标记ID。我们可以通过使用之前提到的tiktoken包中的GPT-2分词器
对”<pad>“进行编码来验证这个标记ID是否正确:
图6.6 输入文本准备过程。首先,每个输入文本消息被转换为一系列标记ID。然
后,为了确保序列长度一致,较短的序列用填充标记(在这种情况下,标记ID为
50256)填充,以匹配最长序列的长度。
确实,执行上述代码返回[50256]。
首先,我们需要实现一个PyTorch Dataset,它指定了数据如何加载和处理,然后才
能实例化数据加载器。为此,我们定义了SpamDataset类,该类实现了图6.6中的概
念。这个SpamDataset类负责几个关键任务:识别训练数据集中最长的序列,对文
本消息进行编码,并确保所有其他序列都用填充标记填充以匹配最长序列的长
度。
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)
# 预先对文本进行分词
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
# 截断或填充序列以匹配最大长度
self.encoded_texts = [encoded_text[:self.max_length] for
encoded_text
in self.encoded_texts]
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
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,以确保数据不超
过模型支持的输入(上下文)长度。
接下来,我们将验证集和测试集填充到与最长训练序列相同的长度。重要的是,
任何超过最长训练样本长度的验证集和测试集样本都将通过
encoded_text[:self.max_length]在我们之前定义的SpamDataset代码中进行截断。
这种截断是可选的;您可以为验证集和测试集设置max_length=None,前提是这些
集中没有任何序列超过1,024个标记。
练习6.1 增加上下文长度
将输入填充到模型支持的最大标记数,并观察这对预测性能的影响。
使用这些数据集作为输入,我们可以像处理文本数据时一样实例化数据加载器。
但是,在这种情况下,目标表示类别标签而不是文本中的下一个标记。例如,如
果我们选择批量大小为8,每个批次将由八个长度为120的训练示例及其相应的类
别标签组成,如图6.7所示。
图6.7 一个由八条文本消息组成的单个训练批次,表示为标记ID。每个文本消息由
120个标记ID组成。一个类别标签数组存储对应于文本消息的八个类别标签,可以
是0(“非垃圾邮件”)或1(“垃圾邮件”)。
以下代码创建了用于加载文本消息和标签的训练、验证和测试集的数据加载器,
每个批次的大小为8。
为了确保数据加载器正常工作并确实返回预期大小的批次,我们遍历训练加载器
并打印最后一个批次的张量维度:
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的三个阶段过程。完成第一阶段(准备数据集)后,我们现在
必须初始化LLM,并对其进行微调以分类垃圾信息。
为了开始模型准备过程,我们使用与预训练未标注数据时相同的配置:
BASE_CONFIG = {
"vocab_size": 50257, # 词汇表大小
"context_length": 1024, # 上下文长度
"drop_rate": 0.0, # Dropout率
"qkv_bias": True # Query-key-value偏置
}
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])
接下来,我们从gpt_download.py文件中导入download_and_load_gpt2函数,并重用
来自预训练的GPTModel类和load_weights_into_gpt函数(参见第5章),以将下载
的权重加载到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之后,我们重用第4章和第5章中的文本生成实用函
数,以确保模型生成连贯的文本:
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,以准备进行分类微调。为此,我们将原始输出层替
换为一个较小的输出层,该输出层将隐藏表示映射到两个类别:0(“非垃圾邮
件”)和1(“垃圾邮件”),如图6.9所示。我们使用与之前相同的模型,只是替换
了输出层。
输出层节点
理论上,由于我们处理的是二分类任务,可以使用单个输出节点。然而,这需要
修改损失函数,如我在《损失函数学习——优化PyTorch中的负对数似然和交叉
熵》(https://mng.bz/NRZ2)中所讨论的。因此,我们选择了一种更通用的方法,
即输出节点的数量与类别的数量相匹配。例如,在三分类问题中,如将新闻文章
分类为“科技”、“体育”或“政治”,我们会使用三个输出节点,依此类推。
我们在第5章实现并在前一节加载的GPT模型
图6.9 通过改变架构来适应垃圾邮件分类的GPT模型。最初,模型的线性输出层将
768个隐藏单元映射到包含50,257个标记的词汇表。为了检测垃圾邮件,我们用一
个新的输出层替换了这个层,该输出层将相同的768个隐藏单元映射到两个类别,
分别代表“垃圾邮件”和“非垃圾邮件”。
在尝试图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)
)
此输出清晰地展示了我们在第4章中描述的架构。正如之前所讨论的,GPTModel
由嵌入层组成,后面跟12个相同的变压器块(仅显示最后一个块以简化说明),
再跟一个最终的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所示。
为了使最终的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 微调整个模型
与其只微调最后一个变压器块,不如微调整个模型并评估其对预测性能的影响。
即使我们添加了一个新的输出层并将某些层标记为可训练或不可训练,我们仍然
可以像以前一样使用此模型。例如,我们可以像以前使用的示例文本那样输入一
个示例文本:
图6.10 GPT模型包括12个重复的变压器块。除了输出层,我们还将最终的LayerNorm和最后一个变压器块设置为可训练。其余11个变压器块和嵌入层保持不可训
练。
inputs = tokenizer.encode("Do you have time")
inputs = torch.tensor(inputs).unsqueeze(0)
print("Inputs:", inputs)
print("Inputs dimensions:", inputs.shape)
打印输出显示上述代码将输入编码为由四个输入标记组成的张量:
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)
输出张量如下所示:
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所示。
要从输出张量中提取最后一个输出标记,我们使用以下代码:
print("Last output token:", outputs[:, -1, :])
这将打印:
Last output token: tensor([[-3.5983,
3.9902]])
我们仍然需要将这些值转换为类别标签预测。但在那之前,让我们理解为什么我
们特别关注最后一个输出标记。
我们已经探讨了注意力机制,它建立了每个输入标记与其他输入标记之间的关
系,以及因果注意力掩码的概念,这是GPT类模型中常用的(见第3章)。这种掩
码限制了标记的关注范围,使其只能聚焦于当前位置及其之前的标记,确保每个
标记只能受自身和前面的标记影响,如图6.12所示。
图 6.11 使用四个标记示例输入和输出的 GPT 模型。输出张量由于修改后的输出层
而包含两列。在微调模型进行垃圾邮件分类时,我们只对最后一行对应的最后一
个标记感兴趣。
最后一个标记是唯一具有对所有其他标记注意力得分的标记。
图 6.12 因果注意力机制,其中输入标记之间的注意力得分以矩阵格式显示。空单
元格表示由于因果注意力掩码而被屏蔽的位置,防止标记关注未来的标记。单元
格中的值代表注意力得分;最后一个标记(时间)是唯一计算所有先前标记注意
力得分的标记。
鉴于图 6.12 中的因果注意力掩码设置,序列中的最后一个标记积累了最多的信
息,因为它可以访问所有前面的标记数据。因此,在我们的垃圾邮件分类任务
中,我们在微调过程中专注于这个最后一个标记。
我们现在准备将最后一个标记转换为类别标签预测,并计算模型的初始预测准确
性。随后,我们将对模型进行微调以完成垃圾邮件分类任务。
练习 6.3 微调第一个与最后一个标记
尝试微调第一个输出标记。注意与微调最后一个输出标记相比,预测性能的变
化。
计算分类损失和准确率
在我们微调模型之前只剩下一个小任务:我们必须实现微调期间使用的模型评估
函数,如图 6.13 所示。
在实现评估工具之前,让我们简要讨论一下我们如何将模型输出转换为类别标签
预测。我们之前通过 softmax 函数将 50,257 个输出转换为概率,并通过 argmax 函
数返回最高概率的位置来计算 LLM 生成的下一个标记的标记 ID。我们在这里采
用相同的方法来计算模型是否预测给定输入为“垃圾邮件”或“非垃圾邮件”,如图
6.14 所示。唯一的区别是我们处理的是二维而不是 50,257 维的输出。
图 6.13 分类微调 LLM 的三个阶段过程。我们已经完成了前六个步骤。现在我们
准备好完成第二阶段的最后一步:实现用于评估模型在微调前后分类垃圾邮件表
现的功能。
图 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
with torch.no_grad():
for i, (input_batch, target_batch) in enumerate(data_loader):
if num_batches is not None and i >= num_batches:
break
input_batch, target_batch = input_batch.to(device),
target_batch.to(
device)
logits = model(input_batch)[:, -1, :]
predicted_labels = torch.argmax(logits, dim=-1)
correct_predictions += (predicted_labels == target_batch).sum()
.item()
num_examples += target_batch.size(0)
return correct_predictions / num_examples if num_examples > 0 else 0
让我们使用该函数来确定从 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}%")
通过设备设置,如果可用 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):
loss = torch.nn.functional.cross_entropy(logits, target_batch)
return loss
我们使用 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:
num_batches = min(num_batches, len(data_loader))
with torch.no_grad():
for i, (input_batch, target_batch) in enumerate(data_loader):
if i < num_batches:
input_batch, target_batch = input_batch.to(device),
target_batch.to(device)
logits = model(input_batch)[:, -1, :]
loss = calc_loss_batch(input_batch, target_batch, model,
device)
total_loss += loss.item()
else:
break
return total_loss / num_batches if num_batches > 0 else 0
类似于计算训练准确率,我们现在计算每个数据集的初始损失:
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)
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}")
初始损失值为:
Training loss: 2.453
Validation loss: 2.583
Test loss: 2.322
接下来,我们将实现一个训练函数来微调模型,这意味着调整模型以最小化训练
集的损失。最小化训练集的损失将有助于提高分类准确率,这是我们最终的目
标。
6.7 在监督数据上微调模型
我们必须定义并使用训练函数来微调预训练的大型语言模型(LLM),以提高其
垃圾邮件分类的准确性。训练循环如图6.15所示,与我们用于预训练的整体训练循
环相同;唯一的区别是我们计算分类准确性而不是生成样本文本来评估模型。
图6.15 在PyTorch中训练深度神经网络的典型训练循环包括几个步骤,在训练集的
多个epoch中迭代处理批次。在每个循环中,我们为每个训练集批次计算损失以确
定损失梯度,然后使用这些梯度更新模型权重以最小化训练集的损失。
实现图6.15所示概念的训练函数也与用于预训练模型的train_model_simple函数非常
相似。唯一的两个区别是,我们现在跟踪看到的训练样本数量(examples_seen)
而不是标记数量,并且我们在每个epoch后计算准确率而不是打印样本文本。
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
接下来,我们初始化优化器,设置训练的epoch数,并使用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))
# 绘制训练和验证损失对epoch
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()
# 创建第二个x轴表示看到的样本数
ax2 = ax1.twiny()
ax2.plot(examples_seen, train_values, alpha=0)
度
ax2.set_xlabel("Examples seen")
fig.tight_layout() # 调整布局以腾出空间
plt.savefig(f"{label}-plot.pdf")
plt.show()
图6.16绘制了结果的损失曲线。
# 隐藏ax2的线条,仅对齐刻
图6.16 模型在五个训练epoch中的训练和验证损失。训练损失(实线)和验证损失
(虚线)在第一个epoch中急剧下降,并在第五个epoch逐渐稳定。这种模式表明
良好的学习进展,表明模型从训练数据中学习并在未见过的验证数据上表现良
好。
根据图6.16中的急剧下降趋势,我们可以看到模型从训练数据中学习得很好,几乎
没有过拟合的迹象;即,训练和验证集的损失之间没有明显的差距。
选择epoch的数量
早些时候,当我们开始训练时,我们将epoch数量设置为五。epoch的数量取决于
数据集和任务的难度,没有通用的解决方案或建议,尽管五个epoch通常是好的起
点。如果模型在前几个epoch后过拟合(参见图6.16),你可能需要减少epoch的数
量。相反,如果趋势线表明验证损失可以通过进一步训练得到改善,则应增加
epoch的数量。在这个具体案例中,五个epoch是一个合理的数量,因为没有早期
过拟合的迹象,而且验证损失接近于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绘制了结果的准确性。模型在第4和第5个epoch后实现了相对较高的训练和
验证准确性。重要的是,我们在使用train_classifier_simple函数时设置了
eval_iter=5,这意味着我们的训练和验证性能估计基于仅五个批次,以提高训练效
率。
图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 使用模型对新文本进行分类
返回分类结果
让我们尝试一下这个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相同的
交叉熵损失函数。
7 指令微调
本章涵盖内容
LLM的指令微调过程
为监督指令微调准备数据集
组织指令数据到训练批次中
加载预训练的LLM并微调其跟随人类指令的能力
提取LLM生成的指令响应用于评估
评估指令微调后的LLM
之前,我们实现了LLM架构,进行了预训练,并从外部来源导入了预训练权重到
我们的模型中。然后,我们专注于微调LLM以完成特定的分类任务:区分垃圾信
息和非垃圾信息文本消息。现在我们将实现微调LLM以跟随人类指令的过程,如
图7.1所示。指令微调是开发LLM用于聊天机器人、个人助手和其他对话任务的主
要技术之一。
图7.1 编码LLM的三个主要阶段。本章关注第三阶段的第9步:微调预训练的LLM
以跟随人类指令。
图7.1展示了微调LLM的两种主要方式:分类微调(第8步)和微调LLM以跟随指
令(第9步)。我们在第6章实现了第8步。现在我们将使用指令数据集微调LLM。
7.1 指令微调简介
我们知道,预训练LLM涉及一种训练程序,使其能够逐词生成文本。由此产生的
预训练LLM具备文本补全能力,即它可以在给定片段作为输入的情况下完成句子
或撰写文本段落。然而,预训练的LLM往往难以应对特定的指令,例如“修正此文
本中的语法”或“将此文本转换为被动语态”。稍后,我们将研究一个具体的例子,
其中我们将预训练的LLM作为指令微调的基础,也称为监督指令微调。
在这里,我们专注于提高LLM遵循此类指令并生成所需响应的能力,如图7.2所
示。准备数据集是指令微调的关键方面。然后我们将完成指令微调过程的三个阶
段的所有步骤,从数据集准备开始,如图7.3所示。
图7.2 LLM处理以生成期望响应的指令示例
图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)
else:
# 如果文件已存在,则跳过下载
with open(file_path, "r", encoding="utf-8") as file:
text_data = file.read()
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("条目数量:", len(data))
执行上述代码的输出为:
条目数量: 1100
我们从JSON文件中加载的数据列表包含指令数据集的1,100个条目。让我们打印其
中一个条目,以查看每个条目的结构:
print("示例行:\n", data[50])
示例行的内容为:
示例行: {‘instruction’: ‘识别下列单词的正确拼写。’, ‘input’: ‘Ocassion’, ‘output’:
“正确拼写是 ‘Occasion’。”}
如我们所见,示例行是包含’instruction’、’input’和’output’的Python字典对象。让我
们再看一个示例:
print("另一个示例行:\n", data[999])
根据此条目的内容,’input’字段有时可能是空的:
另一个示例行: {‘instruction’: “什么是’complicated’的反义词?”, ‘input’: ’‘, ’output’:
“反义词是’simple’。”}
指令微调涉及在输入-输出对明确提供的数据集上训练模型,例如我们从JSON文件
中提取的那些。有多种方法可以格式化这些条目以供LLM使用。图7.4展示了两种
不同的示例格式,通常称为提示样式,用于训练著名的LLM,如Alpaca和Phi-3。
图7.4 指令微调中LLM提示样式的比较。Alpaca样式(左)使用具有定义部分的结
构化格式,包括指令、输入和响应;而Phi-3样式(右)采用更简单的格式,带有
指定的<|user|>和<|assistant|>标记。
Alpaca是最早公开其指令微调过程的LLM之一。Phi-3由微软开发,用以展示提示
样式的多样性。本章其余部分将使用Alpaca提示样式,因为它是最流行的一种,
主要因为它帮助定义了最初的微调方法。
练习7.1 更改提示样式
在使用Alpaca提示样式微调模型后,尝试图7.4所示的Phi-3提示样式,并观察它是
否影响模型的响应质量。
让我们定义一个format_input函数,用于将数据列表中的条目转换为Alpaca样式的
输入格式。
示例7.2 实现提示格式化函数
def format_input(entry):
instruction_text = (
f"以下是一个描述任务的指令。\n"
f"请写出一个适当完成请求的响应。\n\n"
f"### 指令:\n{entry['instruction']}"
)
input_text = (
f"\n\n### 输入:\n{entry['input']}" if entry["input"] else ""
)
return instruction_text + input_text
这个format_input函数接收一个字典条目作为输入并构建一个格式化的字符串。让
我们测试一下之前查看过的数据集条目data[50]:
model_input = format_input(data[50])
desired_response = f"\n\n### 响应:\n{data[50]['output']}"
print(model_input + desired_response)
格式化的输入如下所示:
以下是一个描述任务的指令。请写出一个适当完成请求的响应。
指令: 识别下列单词的正确拼写。
输入: Ocassion
响应: 正确拼写是 ‘Occasion’。
请注意,如果’instruction’字段为空,format_input会跳过可选的### 输入:部分。我
们可以通过将format_input函数应用于之前检查过的条目data[999]来进行测试:
model_input = format_input(data[999])
desired_response = f"\n\n### 响应:\n{data[999]['output']}"
print(model_input + desired_response)
输出显示,对于’input’字段为空的条目,格式化的输入中不包含### 输入:部分:
以下是一个描述任务的指令。请写出一个适当完成请求的响应。
指令: 什么是’complicated’的反义词?
响应: 反义词是’simple’。
在进入下一节设置PyTorch数据加载器之前,让我们将数据集划分为训练集、验证
集和测试集,类似于我们在上一章中对垃圾邮件分类数据集所做的操作。以下示
例展示了如何计算各部分的比例。
这种划分结果如下数据集大小:
训练集长度: 935
验证集长度: 55
测试集长度: 110
成功下载并划分数据集,并清楚了解数据集提示格式化后,我们现在准备进行指
令微调的核心实现。接下来,我们将专注于开发构建用于微调LLM的训练批次的
方法。
7.3 组织数据到训练批次
随着我们进入指令微调过程的实现阶段,下一步如图7.5所示,重点是有效地构建
训练批次。这涉及到定义一种方法,以确保我们的模型在微调过程中接收格式化
的训练数据。
图7.5 指令微调LLM的三阶段过程。接下来,我们看第一阶段的步骤2:组装训练
批次。
在上一章中,训练批次是由PyTorch DataLoader类自动创建的,它使用默认的collate函数将样本列表组合成批次。Collate函数负责将单个数据样本列表合并成一个
可以在训练期间由模型高效处理的单一批次。
然而,指令微调的批处理过程稍微复杂一些,需要我们创建自己的自定义collate函
数,并将其集成到DataLoader中。我们实现这个自定义collate函数来处理我们指令
微调数据集的具体要求和格式。
让我们分几个步骤来解决批处理过程,包括编写自定义collate函数,如图7.6所
示。首先,为了实现步骤2.1和2.2,我们编写了一个InstructionDataset类,该类对
数据集中的所有输入应用format_input并进行预标记化,类似于第6章中的SpamDataset。这个两步过程如图7.7所示,在InstructionDataset的__init__构造方法中实
现。
图7.6 实现批处理过程的五个子步骤:(2.1)应用提示模板,(2.2)使用前几章
的标记化,(2.3)添加填充标记,(2.4)创建目标标记ID,以及(2.5)用-100占
位符替换损失函数中的填充标记。
图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 = []
# Pretokenizes texts for entry in data:
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)
)
def __getitem__(self, index):
return self.encoded_texts[index]
def __len__(self):
return len(self.data)
与分类微调的方法类似,我们希望通过收集多个训练样本来加速训练,这需要将
所有输入填充到相似的长度。如同分类微调一样,我们使用<pad>标记作为填充标
记。
与其将<pad>标记附加到文本输入中,我们可以直接将对应于<pad>标记的标记ID附
加到预标记化的输入中。我们可以使用标记器的.encode方法对<pad>标记进行编
码,以提醒我们应该使用哪个标记ID:
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
print(tokenizer.encode("<pad>", allowed_special={"<pad>"}))
得到的标记ID为50256。
继续进行过程的步骤2.3(参见图7.6),我们采用更复杂的方法,开发一个自定义
的collate函数,并传递给数据加载器。这个自定义的collate函数在每个批次中将训
练示例填充到相同的长度,同时允许不同批次具有不同的长度,如图7.8所示。这
种方法通过仅扩展序列以匹配每个批次中最长的一个,而不是整个数据集,从而
最小化不必要的填充。
图7.8 使用标记ID 50256对批次中的训练示例进行填充,以确保每个批次内的长度
统一。每个批次可能有不同的长度,如图所示的第一和第二批次。
我们可以用一个自定义的collate函数来实现填充过程:
我们实现的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))
输出结果显示所有输入都被填充到最长输入列表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]
inputs_lst.append(new_item[:-1])
targets_lst.append(new_item[1:])
# Padding to the same length within each batch
for i in range(len(inputs_lst)):
while len(inputs_lst[i]) < batch_max_length - 1:
inputs_lst[i].append(pad_token_id)
while len(targets_lst[i]) < batch_max_length - 1:
targets_lst[i].append(-100)
return torch.tensor(inputs_lst, device=device),
torch.tensor(targets_lst,
device=device)
应用于我们之前定义的三个输入列表组成的示例行,新的custom_collate_draft_2
函数现在返回输入和目标批次:
在下一步中,我们将-100占位符值分配给所有填充标记,如图7.11所示。这个特殊
值允许我们排除这些填充标记对训练损失计算的贡献,确保只有有意义的数据影
响模型学习。我们将在实现这一修改后详细讨论这个过程。(当进行分类微调
时,我们不必担心这一点,因为我们只基于最后一个输出标记训练模型。)
inputs, targets = custom_collate_draft_2(batch)
print(inputs)
print(targets)
但是请注意,我们在目标列表中保留了一个结束文本标记,ID 50256,如图7.12所
示。保留它允许大型语言模型(LLM)学习何时在响应指令时生成一个结束文本
标记,我们将其用作生成的响应已完成的指示。
在以下列表中,我们修改了自定义的collate函数,以将具有ID 50256的标记替换为
目标列表中的-100。此外,我们引入了一个allowed_max_length参数,以选择性地
限制样本的长度。如果计划使用超过GPT-2模型支持的1,024个标记上下文大小的
自有数据集,此调整将非常有用。
图7.11 实现批处理过程涉及的五个子步骤。在创建目标序列时,通过将标记ID向
右移动一个位置并追加一个结束文本标记,在步骤2.5中,我们将结束文本填充标
记替换为占位符值(-100)。
图7.12 在训练数据准备的目标批次中进行标记替换过程的步骤2.4。我们将除了第
一个实例外的所有结束文本标记替换为占位符值-100,同时保持每个目标序列中
的初始结束文本标记作为填充。
清单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 = ( # 填充序列
new_item + [pad_token_id] * (batch_max_length - len(new_item))
)
inputs = torch.tensor(padded[:-1]) # 截断最后一个标记用于输入
targets = torch.tensor(padded[1:]) # 向右移动+1用于目标
mask = targets == pad_token_id
indices = torch.nonzero(mask).squeeze()
if indices.numel() > 1: # 替换除第一个以外的所有填充标记为目标中的
ignore_index
targets[indices[1:]] = ignore_index
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
再次,让我们尝试对之前创建的示例批次使用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来更改目标列表。这个调
整背后的逻辑是什么?让我们探讨一下这种修改的目的。
为了演示目的,考虑以下简单且自包含的例子,其中每个输出logit对应于模型词
汇表中的潜在标记。这是在训练期间计算交叉熵损失(第5章介绍)的方式,当模
型预测一系列标记时,这与我们在预训练和微调分类任务时所做的类似:
logits_1 = torch.tensor([
[-1.0, 1.0], # 第一个标记的预测
[-0.5, 1.5]
# 第二个标记的预测
])
targets_1 = torch.tensor([0, 1]) # 正确的标记索引
loss_1 = torch.nn.functional.cross_entropy(logits_1, targets_1)
print(loss_1)
上述代码计算出的损失值为1.1269:
tensor(1.1269)
正如预期的那样,添加额外的标记ID会影响损失值:
logits_2 = torch.tensor([
[-1.0, 1.0],
[-0.5, 1.5],
[-0.5, 1.5] # 新的第三个标记ID预测
])
# 添加第三个标记后,损失值为0.7936。
到目前为止,我们已经使用PyTorch中的交叉熵损失函数进行了或多或少显而易见
的示例计算,该损失函数与我们在预训练和微调分类任务中使用的训练函数相
同。现在让我们进入有趣的部分,看看如果我们用-100替换第三个目标标记ID会
发生什么:
# 结果输出是 tensor(1.1269),即 loss_1 == loss_3: tensor(True)
这三个训练示例的损失值与我们从两个训练示例中计算出的损失值相同。换句话
说,交叉熵损失函数忽略了目标向量targets_3中的第三个条目,即对应的标记ID为
-100。(感兴趣的读者可以尝试将-100替换为另一个不是0或1的标记ID;这将导致
错误。)
那么为什么-100会被交叉熵损失忽略呢?PyTorch中交叉熵函数的默认设置是
cross_entropy(…, ignore_index=-100)。这意味着它会忽略标记为-100的目标。我们
利用这个ignore_index来忽略用于填充训练示例以使每批次长度相同的额外结束文
本(填充)标记。然而,我们希望在目标中保留一个50256(结束文本)标记ID,
因为它有助于LLM学习生成结束文本标记,我们可以用它作为响应完成的指示。
除了屏蔽填充标记外,通常还会屏蔽与指令相对应的目标标记ID,如图7.13所
示。通过屏蔽与指令相对应的LLM目标标记ID,交叉熵损失仅针对生成的响应目
标ID进行计算。因此,模型被训练专注于生成准确的响应,而不是记住指令,这
可以帮助减少过拟合。
图7.13 左:我们在训练期间分词并输入给LLM的格式化输入文本。右:我们为
LLM准备的目标文本,其中可以选择屏蔽指令部分,即将相应的标记ID替换为
-100的ignore_index值。
截至撰写本文时,研究人员对于在指令微调期间屏蔽指令是否普遍有益存在分
歧。例如,Shi等人在2024年的论文“Instruction Tuning With Loss Over Instructions”(https://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 指令微调过程。
图 7.14 指令微调 LLM 的三阶段过程。到目前为止,我们已经准备了数据集,并
实现了一个自定义的 collate 函数来批处理指令数据集。现在,我们可以创建并应
用数据加载器,以满足 LLM 指令微调和评估所需的训练、验证和测试集。
在我们实现数据加载器创建步骤之前,我们需要简要讨论一下 custom_collate_fn
的设备设置。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 = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# if torch.backends.mps.is_available():
#
device = torch.device("mps")
# print("Device:", device)
这将根据您的机器打印 “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 函数进行批处理。
from torch.utils.data import DataLoader
num_workers = 0 # 如果操作系统支持并行 Python 进程,可以尝试增加此数字
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
)
让我们检查由训练加载器生成的输入和目标批次的维度:
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),这是一个我们之前已经进行过的步骤。然而,这次我们不是使用最
小的1.24亿参数模型,而是加载具有3.55亿参数的中等大小模型。选择这个模型的
原因是1.24亿参数的模型容量太有限,无法通过指令微调获得满意的结果。具体来
说,较小的模型缺乏必要的容量来学习和保留高质量指令跟随任务所需的复杂模
式和细微行为。
图7.15 指令微调LLM的三阶段过程。在数据集准备完成后,微调LLM以遵循指令
的过程从加载预训练的LLM开始,这为后续的训练奠定了基础。
加载预训练模型所需的代码与我们在预训练数据(第5.5节)和分类微调(第6.4
节)时使用的代码相同,只是我们现在指定”gpt2-medium (355M)“而不
是”gpt2-small (124M)“。
注意 执行此代码将启动下载中等大小的GPT模型,该模型大约需要1.42GB的存储
空间。这大约是小模型所需存储空间的三倍。
示例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, # 词汇表大小
"context_length": 1024, # 上下文长度
"drop_rate": 0.0, # Dropout率
"qkv_bias": True # 查询-键-值偏置
}
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
现在,让我们花点时间评估预训练LLM在其中一个验证任务上的表现,通过将其
输出与预期响应进行比较。这将使我们对模型在微调前直接从预训练状态下完成
指令跟随任务的能力有一个基线理解,并有助于我们稍后欣赏微调的效果。我们
将使用验证集中的第一个示例来进行此评估:
torch.manual_seed(123)
input_text = format_input(val_data[0])
print(input_text)
指令的内容如下:
下面是一个描述任务的指令。请写一个适当完成请求的响应。
指令:将主动语句转换为被动语句:‘厨师每天做饭。’
接下来,我们使用与第5章中预训练模型相同的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主要设计为文本补全模型,其中输入和输出连接在一起以创建连贯且易读的
文本。然而,在评估模型在特定任务上的表现时,我们通常只关注模型生成的响
应部分。
为了隔离模型的响应文本,我们需要从生成的文本中减去输入指令的长度:
这段代码从生成的文本开头删除输入文本,只留下模型的生成响应。然后应用
strip()函数以删除任何前导或尾随的空白字符。输出为:
响应:
厨师每天做饭。
指令:
将主动语句转换为被动语句:‘厨师每天做饭。’
此输出显示预训练模型还无法正确遵循给定的指令。虽然它确实创建了一个响应
部分,但只是重复了原始输入句子和部分指令,未能按要求将主动语句转换为被
动语句。因此,我们现在实现微调过程,以提高模型理解和适当响应此类请求的
能力。
7.6 微调 LLM 的指令数据
现在是时候对 LLM 进行指令微调(图 7.16)。我们将采用前一节中加载的预训练
模型,并使用本章早些时候准备好的指令数据集进一步训练它。我们已经在本章
开头实现了指令数据集处理,完成了所有繁重的工作。对于微调过程本身,我们
可以重用第 5 章中实现的损失计算和训练函数:
图 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 中等规模(3.55 亿参数)这样的较大模型比使用较小的
GPT-2 模型(1.24 亿参数)更具计算强度。如果您遇到硬件限制问题,可以通过
将 CHOOSE_MODEL = “gpt2-medium (355M)” 更改为 CHOOSE_MODEL =
“gpt2-small (124M)” 来切换到较小的模型(见第 7.5 节)。或者,为了加速模型训
练,请考虑使用 GPU。本书代码仓库中的以下补充部分列出了使用云 GPU 的几
种选项:https://mng.bz/EOEq。
下表提供了在各种设备(包括 CPU 和 GPU)上训练每个模型的参考运行时间,适
用于 GPT-2。在兼容 GPU 上运行此代码不需要任何代码更改,并且可以显著加快
训练速度。本章所示结果是在 A100 GPU 上训练的 GPT-2 中等规模模型。
在准备好模型和数据加载器后,我们现在可以继续训练模型。清单 7.8 设置了训练
过程,包括初始化优化器、设置训练轮数以及定义评估频率和起始上下文,以根
据第一个验证集指令(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
)
以下输出显示了两个轮次的训练进度,损失稳定下降表明模型逐渐提高了遵循指
令和生成适当响应的能力:
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
以下是描述任务的指令。请写出一个适当完成请求的响应。 ### 指令:将主动句
转换为被动句:‘The chef cooks the meal every day.’ ### 响应:这顿饭每天由厨师
准备。
以下是描述任务的指令。请写出一个适当完成请求的响应。 指令:将主动句转换
为被动句: 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
以下是描述任务的指令。请写出一个适当完成请求的响应。 ### 指令:将主动句
转换为被动句:‘The chef cooks the meal every day.’ ### 响应:这顿饭每天由厨师
烹饪。
以下是描述任务的指令。请写出一个适当完成请求的响应。 ### 指令:什么是英
国的首都? ###
训练在 0.87 分钟内完成。
训练输出显示模型正在有效学习,因为我们可以看到两个轮次中训练和验证损失
值持续下降。这一结果表明模型逐渐提高了理解并遵循所提供指令的能力。(由
于模型在这两个轮次内展示了有效的学习,扩展训练至第三个轮次或更多可能没
有必要,甚至可能是反效果,因为它可能导致过度拟合。)
此外,每个轮次末尾生成的响应使我们能够检查模型在正确执行验证集示例中给
定任务方面的进展。在这种情况下,模型成功地将主动句 “The chef cooks the meal
every day.” 转换为其被动形式:“The meal is cooked every day by the chef.”
稍后我们将更详细地重新审视和评估模型的响应质量。现在,让我们检查训练和
验证损失曲线,以获得更多关于模型学习过程的见解。为此,我们使用与预训练
时相同的 plot_losses 函数:
图 7.17 两个轮次的训练和验证损失趋势。实线表示训练损失,显示出急剧下降然
后趋于稳定,而虚线表示验证损失,呈现出类似的趋势。
尽管图 7.17 中的损失图表明模型正在有效训练,但最关键的是其在响应质量和准
确性方面的表现。因此,接下来让我们提取响应并将其存储在一个允许我们评估
和量化响应质量的格式中。
练习 7.3 在原始 Alpaca 数据集上进行微调
斯坦福研究人员提供的 Alpaca 数据集是最早和最受欢迎的公开共享指令数据集之
一,包含 52,002 个条目。作为替代方案,可以考虑在此数据集上微调 LLM。该数
据集可在 https://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]:
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()
)
print(input_text)
print(f"\n正确响应:\n>> {entry['output']}")
print(f"\n模型响应:\n>> {response_text.strip()}")
print("-")
正如前面提到的,generate函数返回的是组合的输入和输出文本,因此我们使用切
片和generated_text内容的.replace()方法来提取模型的响应。接下来展示的是指令、
给定的测试集响应和模型响应。
下面是描述任务的指令。请写一个适当的响应来完成请求。
### 指令:
用一个明喻重写这个句子。
### 输入:
这辆车非常快。
正确响应:
>> 这辆车像闪电一样快。
模型响应:
>> 这辆车像子弹一样快。
下面是描述任务的指令。请写一个适当的响应来完成请求。
### 指令:
通常与雷暴相关联的云是什么类型的?
正确响应:
>> 通常与雷暴相关联的云是积雨云。
模型响应:
he type of cloud associated with thunderstorms is a cumulu
下面是描述任务的指令。请写一个适当的响应来完成请求。
### 指令:
《傲慢与偏见》的作者是谁?
正确响应:
>> 简·奥斯汀。
模型响应:
>> 《傲慢与偏见》的作者是简·奥斯汀。
根据测试集指令、给定的响应和模型的响应可以看出,模型表现相对较好。第一
个和最后一个指令的答案明显是正确的,而第二个答案接近但不完全准确。模型
回答的是“cumulus cloud”而不是“cumulonimbus”,尽管值得注意的是,积云可以发
展成积雨云,后者能够产生雷暴。
最重要的是,模型评估不像完成微调那样简单,我们只需计算正确和错误标签的
百分比即可获得分类准确性。实际上,指令微调的LLM(如聊天机器人)通过多
种方法进行评估:
• 短答和多项选择基准测试,例如大规模多任务语言理解(MMLU;
https://arxiv.org/abs/2009.03300),用于测试模型的一般知识。
• 人类偏好比较其他LLM,例如LMSYS聊天机器人竞技场(https://are-
na.lmsys.org)。
• 自动对话基准测试,其中使用另一个LLM(如GPT-4)来评估响应,
例如AlpacaEval(https://tatsu-lab.github.io/alpaca_eval/)。
实际上,考虑所有三种评估方法是有用的:多项选择题回答、人工评估和自动度
量,这些度量可以衡量对话性能。然而,由于我们主要关注评估对话性能,而不
仅仅是回答多项选择题的能力,因此人工评估和自动度量可能更相关。
对话性能
LLM的对话性能指的是它们通过理解上下文、细微差别和意图来进行类似人类交
流的能力。它包括提供相关和连贯的响应、保持一致性以及适应不同主题和互动
风格的技能。
虽然人工评估提供了宝贵的见解,但它可能相当费力且耗时,尤其是在处理大量
响应时。例如,阅读并为所有1,100个响应打分需要大量的努力。
因此,考虑到任务的规模,我们将采用类似于自动对话基准测试的方法,即使用
另一个LLM自动评估响应。这种方法使我们能够在不需要大量人工参与的情况下
高效地评估生成响应的质量,从而节省时间和资源,同时仍然获得有意义的性能
指标。
让我们采用受AlpacaEval启发的方法,使用另一个LLM来评估我们微调模型的响
应。然而,我们不是依赖公开可用的基准数据集,而是使用我们自己的自定义测
试集。这种定制化允许在我们预期使用场景的背景下对模型性能进行更有针对性
和相关的评估,这些场景反映在我们的指令数据集中。
为了准备这些评估过程中的响应,我们将生成的模型响应附加到test_set字典中,
并将更新的数据保存为”instruction-data-with-response.json”文件以备记录。此外,
通过保存此文件,我们可以在以后需要时轻松加载和分析响应。
以下代码清单以与之前相同的方式使用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) # pretty-printing
处理数据集大约需要1分钟在A100 GPU上,6分钟在M3 MacBook Air上。
让我们验证响应是否已正确添加到test_set字典中,检查其中一个条目:
print(test_data[0])
输出显示model_response已正确添加:
{
'instruction': '用一个明喻重写这个句子。',
'input': '这辆车非常快。',
'output': '这辆车像闪电一样快。',
'model_response': '这辆车像子弹一样快。'
}
最后,我们将模型保存为gpt2-medium355M-sft.pth文件,以便在未来的项目中重复
使用:
import re
# 从文件名中移除空格和括号
file_name = f"{re.sub(r'[ ()]', '', CHOOSE_MODEL)}-sft.pth"
torch.save(model.state_dict(), file_name)
print(f"模型已保存为 {file_name}")
保存的模型可以通过model.load_state_dict(torch.load("gpt2-medium355Msft.pth"))加载。
7.8 评估微调后的LLM
之前,我们通过查看测试集中三个示例的响应来判断指令微调模型的性能。虽然
这可以让我们大致了解模型的表现,但这种方法在面对大量响应时并不适用。因
此,我们实现了一种方法,使用另一个更大的LLM自动化评估微调LLM的响应,
如图7.19所示。
为了以自动化的方式评估测试集的响应,我们利用了Meta AI开发的一个现有的指
令微调的80亿参数Llama 3模型。这个模型可以通过开源应用程序Ollama(https://ollama.com)在本地运行。
注意 Ollama是用于在笔记本电脑上运行LLM的有效应用程序。它作为开源库llama.cpp(https://github .com/ggerganov/llama.cpp)的包装器,该库用纯C/C++实现
了LLM以最大化效率。然而,Ollama仅用于生成文本(推理),不支持训练或微
调LLM。
图7.19 指令微调LLM的三阶段过程。在指令微调流水线的最后一步中,我们实现
了一种方法,通过评分微调模型为测试生成的响应来量化其性能。
通过Web API使用更大的LLM
80亿参数的Llama 3模型是一个非常强大的LLM,可以在本地运行。然而,它不如
OpenAI提供的专有大型LLM如GPT-4强大。对于有兴趣探索如何通过OpenAI API
利用GPT-4评估生成的模型响应的读者,本书附带的补充材料中提供了一个可选的
代码笔记本,网址为https://mng.bz/BgEv。
要执行以下代码,请访问https://ollama.com并按照您操作系统提供的说明安装Ollama:
• 对于macOS和Windows用户—打开下载的Ollama应用程序。如果提示安
装命令行使用,选择“是”。
• 对于Linux用户—使用Ollama网站上的安装命令。
在实现模型评估代码之前,我们首先下载Llama 3模型,并通过命令行终端验证Ollama是否正常工作。要在命令行中使用Ollama,您必须启动Ollama应用程序或在
单独的终端中运行ollama serve,如图7.20所示。
然后运行ollama run llama3以下载并使用80亿参数的Llama 3模型。
图7.20 运行Ollama的两种选项。左面板展示了使用ollama serve启动Ollama。右面
板显示了在macOS中将Ollama应用程序在后台运行而不是使用ollama serve命令启
动应用程序的第二种选项。
当Ollama应用程序或ollama serve在不同的终端中运行时,在命令行(不在Python
会话中)执行以下命令以试用80亿参数的Llama 3模型:
ollama run llama3
第一次执行此命令时,这个占用4.7 GB存储空间的模型将自动下载。输出如下所
示:
替代的Ollama模型
ollama run llama3命令中的llama3指的是指令微调的80亿参数Llama 3模型。使用
Ollama与llama3模型需要大约16 GB的RAM。如果您的机器没有足够的RAM,您
可以尝试使用较小的模型,例如38亿参数的phi3模型ollama run llama3,只需要大
约8 GB的RAM。
对于更强大的计算机,您还可以通过将llama3替换为llama3:70b来使用700亿参数
的Llama 3模型。但是,此模型需要显著更多的计算资源。
一旦模型下载完成,我们将获得一个命令行界面,允许我们与模型进行交互。例
如,尝试问模型,“骆驼吃什么?”
>>> 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
与模型交互的另一种方式是通过其REST API使用Python。以下列表中的
query_model函数演示了如何使用API。
清单7.10 查询本地Ollama模型
import urllib.request
def query_model(prompt, model="llama3", url="http://localhost:11434/api/
chat"):
data = {
# 创建数据字典
"model": model,
"messages": [
{"role": "user", "content": prompt}
],
"options": {
# 设置确定性响应
"seed": 123,
}
}
在运行本笔记本中的后续代码单元之前,请确保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。
首先,我们将这种方法应用于我们之前检查的测试集中的前三个示例:
对于 test_data 的前三个条目,代码如下:
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 并非完全确定
性,因此生成的文本可能会有所不同):
数据集响应:
这辆车快如闪电。
模型响应:
这辆车快如子弹。
评分:
我会给模型响应“这辆车快如子弹”打85分(满分100)。原因如下:
响应正确使用了明喻,将车速与另一种事物进行了比较(在这种情况下是子
弹)。比较相关且合理,因为子弹以高速度著称。短语“快如”被正确地用来引出
明喻。唯一不给满分的原因是有些人可能觉得这个比喻不如其他比喻生动或引人
入胜。例如,将某物比作闪电(如原始响应中那样)可能会更加戏剧性和吸引
人。然而,“快如子弹”仍然是一个强大且有效的明喻,能够有效地传达车速的概
念。
总体而言,我认为模型表现得很好!
数据集响应:
通常与雷暴相关的云类型是积雨云。
模型响应:
与雷暴相关的云类型是积云。
评分:
我会给这个模型响应打40分(满分100)。原因如下:
模型正确识别了雷暴与云有关(正确识别了现象类型)。然而,它错误地指定了
与雷暴相关的云类型。积云通常不与雷暴相关;积雨云才是。响应在描述上缺乏
精确性和准确性。总体而言,虽然模型尝试回答问题,但提供了错误的答案,这
是一个重大错误。
数据集响应:
简·奥斯汀。
模型响应:
《傲慢与偏见》的作者是简·奥斯汀。
评分:
我会给自己的响应打95分(满分100)。原因如下:
响应准确回答了问题,指出《傲慢与偏见》的作者是简·奥斯汀。响应简洁明了,
易于理解。没有语法错误或歧义,不会导致混淆。唯一不给满分的原因是响应略
显冗长——没有必要在回答中重新表述问题。更简洁的回答可以是“简·奥斯汀”。
生成的响应显示Llama 3模型提供了合理的评估,并能够在模型答案不完全正确时
分配部分分数。例如,在评估“积云”答案时,模型承认了响应的部分正确性。
之前的提示返回了详细的评估以及评分。我们可以修改提示,只生成0到100之间
的整数评分,其中100代表最佳评分。这种修改使我们能够计算模型的平均评分,
从而提供更简洁和定量的性能评估。以下列表中的 generate_model_scores 函数使
用了一个修改后的提示,告诉模型“仅用整数回应”。
列表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."
)
try:
score = query_model(prompt, model)
scores.append(int(score))
except ValueError:
print(f"Could not convert score: {score}")
continue
现在我们将 generate_model_scores 函数应用于整个 test_data 集,这大约需要1分
钟时间在M3 Macbook Air上运行:
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")
结果如下:
评估输出显示我们的微调模型达到了平均评分超过50的成绩,这为与其他模型进
行比较或实验不同的训练配置以提高模型性能提供了有用的基准。
值得注意的是,截至本文撰写时,Ollama 在不同操作系统上并非完全确定性,这
意味着你获得的评分可能会略有不同。为了获得更稳健的结果,你可以多次重复
评估并取平均值。
为了进一步提高模型性能,我们可以探索各种策略,例如:
• 调整微调期间的超参数,如学习率、批量大小或训练轮次。
• 增加训练数据集的规模或多样化示例,以涵盖更广泛的主题和风格。
• 尝试不同的提示或指令格式,以更有效地引导模型的响应。
• 使用更大的预训练模型,其可能具有更大的容量来捕捉复杂模式并生
成更准确的响应。
注意:作为参考,当使用本文所述的方法时,未经微调的Llama 3 8B基础模型在测
试集上的平均得分为58.51。经过通用指令跟随数据集微调的Llama 3 8B instruct模
型则取得了令人印象深刻的平均得分82.6。
练习7.4 使用LoRA进行高效微调
为了更高效地对LLM进行指令微调,请修改本章中的代码以使用附录E中的低秩适
应方法(LoRA)。比较修改前后训练运行时间和模型性能。
7.9 结论
本章标志着我们对LLM开发周期的探讨结束。我们涵盖了所有关键步骤,包括实
现LLM架构、预训练LLM以及针对特定任务进行微调,如图7.21所示。接下来让
我们讨论一些后续可以深入探讨的想法。
7.9.1 接下来是什么?
虽然我们已经涵盖了最重要的步骤,但在指令微调之后还有一个可选步骤:偏好
微调。偏好微调特别适用于使模型更好地与特定用户偏好保持一致。如果您有兴
趣进一步探索,请参阅本书配套GitHub仓库中的04_preference-tuningwith-dpo文件
夹,地址为https://mng.bz/dZwD。
图7.21 编码LLM的三个主要阶段。
除了本书涵盖的主要内容外,GitHub仓库还包含大量额外资料,您可能会觉得这
些资源很有价值。要了解更多这些额外资源,请访问仓库README页面上的“额外
资料”部分:https://mng.bz/r12g。
7.9.2 在快速发展的领域中保持更新
AI和LLM研究领域正在以快速(根据您的看法可能是令人兴奋的)速度发展。跟
上最新进展的一种方法是浏览arXiv上最近的研究论文,网址为https://arxiv.org/list/
cs.LG/recent。此外,许多研究人员和从业者非常活跃地在社交媒体平台(如
X(前身为Twitter)和Reddit)上分享和讨论最新发展。特别是subreddit r/LocalLLaMA是一个很好的资源,可以连接社区并了解最新的工具和趋势。我还在我的博
客上定期分享见解并撰写关于LLM研究的最新文章,博客地址为https://magazine.sebastianraschka.com 和 https://sebastianraschka.com/blog/。
7.9.3 最后的话
我希望您喜欢从头开始实现LLM的旅程,并从头编写预训练和微调函数。在我看
来,从零构建LLM是深入了解LLM工作原理的最有效方法。我希望这种动手实践
的方法为您提供有价值的见解和坚实的LLM开发基础。
虽然本书的主要目的是教育,但您可能对利用不同且更强大的LLM进行实际应用
感兴趣。为此,我建议您探索流行的工具,例如Axolotl(https://github.com/OpenAccess-AI-Collective/axolotl)或LitGPT(https://github.com/Lightning-AI/litgpt),
我积极参与了这些工具的开发。
感谢您加入我的学习之旅,祝您在LLM和AI这一激动人心领域的未来努力中一切
顺利!
摘要
• 指令微调过程使预训练的LLM能够遵循人类指令并生成期望的响应。
准备数据集涉及下载指令-响应数据集,格式化条目,并将其拆分为训
练、验证和测试集。 使用自定义的collate函数构造训练批次,该函数
填充序列,创建目标token ID,并屏蔽填充token。 我们加载一个具有
3.55亿参数的预训练GPT-2中型模型作为指令微调的起点。 预训练模
型使用类似于预训练的训练循环在指令数据集上进行微调。 评估包括
提取模型在测试集上的响应并对它们进行评分(例如,使用另一个
LLM)。 Ollama应用程序使用具有80亿参数的Llama模型,可以自动
为微调后的模型在测试集上的响应评分,提供平均分数以量化性能。
附录A PyTorch简介
本附录旨在为您提供必要的技能和知识,以便将深度学习付诸实践并从头实现大
型语言模型(LLMs)。PyTorch是一个流行的基于Python的深度学习库,将是本书
的主要工具。我将引导您设置一个配备PyTorch和GPU支持的深度学习工作区。
然后您将学习张量这一基本概念及其在PyTorch中的使用。我们还将深入探讨PyTorch的自动微分引擎,这一功能使我们可以方便高效地使用反向传播,这是神经
网络训练的关键方面。
本附录是为那些新接触PyTorch深度学习的人准备的入门指南。虽然它从头解释了
PyTorch,但并不是对PyTorch库的详尽覆盖。相反,我们将专注于用于实现LLMs
的PyTorch基础知识。如果您已经熟悉深度学习,可以直接跳过本附录并直接进入
第2章。
A.1 什么是 PyTorch?
PyTorch (https://pytorch.org/) 是一个基于 Python 的开源深度学习库。根据 Papers
With Code (https://paperswithcode.com/trends) 的数据,这是一个跟踪和分析研究论
文的平台,自 2019 年以来,PyTorch 在研究领域中是最广泛使用的深度学习库,
并且遥遥领先。此外,根据 Kaggle Data Science and Machine Learning Survey 2022
(https://www.kaggle .com/c/kaggle-survey-2022),使用 PyTorch 的受访者比例约为
40%,并且每年都在增长。
PyTorch 如此受欢迎的原因之一是其用户友好的界面和高效性。尽管易于使用,但
它并没有牺牲灵活性,允许高级用户调整模型的低级方面以进行定制和优化。简
而言之,对于许多从业者和研究人员来说,PyTorch 在易用性和功能之间提供了恰
到好处的平衡。
A.1.1 PyTorch 的三个核心组件
PyTorch 是一个相对全面的库,一种理解它的方式是专注于其三大组成部分,如图
A.1 所示。
图 A.1 PyTorch 的三个主要组件包括:作为计算基本构建块的张量库、用于模型优
化的自动微分以及深度学习实用函数,使得实现和训练深度神经网络模型更加容
易。
首先,PyTorch 是一个张量库,它扩展了数组编程库 NumPy 的概念,并增加了在
GPU 上加速计算的功能,从而无缝切换 CPU 和 GPU。其次,PyTorch 是一个自动
微分引擎,也称为 autograd,它能够自动计算张量操作的梯度,简化反向传播和模
型优化。最后,PyTorch 是一个深度学习库,它提供模块化、灵活且高效的构建
块,包括预训练模型、损失函数和优化器,用于设计和训练各种深度学习模型,
满足研究人员和开发人员的需求。
A.1.2 定义深度学习
在新闻中,LLM(大语言模型)经常被称为 AI 模型。然而,LLM 也是一种深度
神经网络,而 PyTorch 是一个深度学习库。听起来很混乱吧?让我们花一点时间
总结这些术语之间的关系,然后再继续。
AI 根本上是关于创建能够执行通常需要人类智能的任务的计算机系统。这些任务
包括理解自然语言、识别模式和做出决策。(尽管取得了显著进展,但 AI 离实现
这种水平的通用智能还很远。)
机器学习是 AI 的一个子领域,如图 A.2 所示,专注于开发和改进学习算法。机器
学习的核心思想是使计算机能够从数据中学习并进行预测或决策,而无需明确编
程来执行任务。这涉及开发可以从历史数据中识别模式、学习并在获得更多数据
和反馈后随着时间推移提高性能的算法。
图 A.2 深度学习是机器学习的一个子类别,专注于实现深度神经网络。机器学习
是 AI 的一个子类别,关注从数据中学习的算法。AI 是更广泛的概念,即机器能
够执行通常需要人类智能的任务。
机器学习在 AI 的发展中起着关键作用,推动了许多我们今天看到的进步,包括
LLM。机器学习还支持诸如在线零售商和流媒体服务的推荐系统、电子邮件垃圾
邮件过滤、虚拟助手中的语音识别,甚至是自动驾驶汽车等技术。机器学习的引
入和发展极大地提升了 AI 的能力,使其能够超越严格的基于规则的系统,适应新
的输入或变化的环境。
深度学习是机器学习的一个子类别,专注于训练和应用深度神经网络。这些深度
神经网络最初是受人脑工作方式的启发,特别是许多神经元之间的互连。深度学
习中的“深度”指的是多层隐藏的人工神经元或节点,使它们能够建模复杂、非线
性的数据关系。与擅长简单模式识别的传统机器学习技术不同,深度学习特别擅
长处理图像、音频或文本等非结构化数据,因此非常适合 LLM。
典型的预测建模工作流程(也称为监督学习)在机器学习和深度学习中如图 A.3
所示。
使用学习算法,在包含示例及其对应标签的训练数据集上训练模型。例如,对于
电子邮件垃圾邮件分类器,训练数据集由电子邮件及其“垃圾邮件”和“非垃圾邮
件”标签组成,这些标签是由人工标注的。然后可以使用训练好的模型对新观测
(即新电子邮件)进行未知标签(“垃圾邮件”或“非垃圾邮件”)的预测。当然,
我们还需要在训练和推理阶段之间添加模型评估,以确保模型在实际应用前满足
我们的性能标准。
图 A.3 预测建模的监督学习工作流程包括在标记示例的训练数据集上训练模型。
训练好的模型可以用于预测新观测的标签。
如果我们训练 LLM 来分类文本,训练和使用 LLM 的工作流程与图 A.3 所示相
似。如果我们感兴趣的是训练 LLM 来生成文本,这是我们的主要关注点,图 A.3
仍然适用。在这种情况下,预训练期间的标签可以从文本本身派生(第 1 章介绍
的下词预测任务)。在推理过程中,给定输入提示,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,它将自动安装支持 GPU 加速的 PyTorch
版本(通过 CUDA),前提是你的 Python 环境已安装必要的依赖项(如 pip)。
注意:截至本文撰写时,PyTorch 已经为 AMD GPU 提供实验性支持,通过
ROCm。请参阅 https://pytorch.org 获取更多说明。
为了显式安装 CUDA 兼容版本的 PyTorch,最好指定你希望 PyTorch 支持的 CUDA 版本。PyTorch 官方网站 (https://pytorch.org) 提供了针对不同操作系统的安装
命令,以安装带有 CUDA 支持的 PyTorch。图 A.4 显示了一个命令,该命令还将
安装 torchvision 和 torchaudio 库,这两个库在本书中是可选的。
图 A.4 访问 https://pytorch.org 以获取 PyTorch 安装建议,并选择适合你系统的安装
命令。
我使用 PyTorch 2.4.0 进行示例,因此我建议你使用以下命令安装确切版本以确保
与本书兼容:
pip install torch==2.4.0
然而,正如前面提到的,根据你的操作系统,安装命令可能会略有不同。因此,
我建议你访问 https://pytorch.org 并使用安装菜单(见图 A.4)选择适合你操作系统
的安装命令。记得在命令中将 torch 替换为 torch==2.4.0。
要检查 PyTorch 的版本,请在 PyTorch 中执行以下代码:
import torch
print(torch.__version__)
这将打印 ‘2.4.0’
PyTorch 和 Torch
Python 库名为 PyTorch 主要是因为它是 Torch 库的延续,但适应了 Python(因此
称为“PyTorch”)。“Torch” 致敬了该库的起源,Torch 是一个科学计算框架,广泛
支持机器学习算法,最初是用 Lua 编程语言创建的。
如果你正在寻找有关设置 Python 环境或安装本书中使用的其他库的更多建议和说
明,请访问本书的补充 GitHub 仓库 https://github.com/rasbt/LLMs-from-scratch。
在安装 PyTorch 后,你可以通过在 Python 中运行以下代码来检查你的安装是否识
别到内置的 NVIDIA GPU:
import torch
torch.cuda.is_available()
这将返回
True
如果命令返回 True,说明一切正常。如果命令返回 False,可能是你的计算机没有
兼容的 GPU,或者 PyTorch 无法识别它。虽然 GPU 并不是本书前几章所必需的,
这些章节主要关注于实现 LLMs 的教育目的,但它们可以显著加速与深度学习相
关的计算。
如果你无法访问 GPU,有几家云服务提供商允许用户以按小时计费的方式运行
GPU 计算。一个流行的类似 Jupyter notebook 的环境是 Google Colab (https://colab.research.google.com),它目前提供对 GPU 的限时访问。通过 Runtime 菜单,可
以选择 GPU,如图 A.5 所示。
图 A.5 在 Runtime/Change Runtime Type 菜单下为 Google Colab 选择 GPU 设备。
PyTorch 在 Apple Silicon 上的使用
如果你有一台带有 Apple Silicon 芯片(如 M1、M2、M3 或更新型号)的 Apple
Mac,你可以利用其能力加速 PyTorch 代码执行。要使用 Apple Silicon 芯片进行
PyTorch 加速,首先需要按照常规方式安装 PyTorch。然后,可以通过在 Python 中
运行以下简单代码片段来检查你的 Mac 是否支持 PyTorch 加速:
print(torch.backends.mps.is_available())
如果返回 True,则表示你的 Mac 拥有一个可以用于加速 PyTorch 代码的 Apple Silicon 芯片。
练习 A.1
在你的计算机上安装并设置 PyTorch
练习 A.2
运行补充代码 https://mng.bz/o05v 以检查你的环境是否已正确设置。
A.2 理解张量
张量表示一种数学概念,它将向量和矩阵推广到可能更高的维度。换句话说,张
量是可以通过其阶数(或秩)来表征的数学对象,阶数提供了维度的数量。例
如,标量(只是一个数字)是0阶张量,向量是1阶张量,矩阵是2阶张量,如图
A.6所示。
图A.6 不同阶数的张量。这里0D对应于0阶,1D对应于1阶,2D对应于2阶。一个
包含三个元素的三维向量仍然是1阶张量。
从计算的角度来看,张量用作数据容器。例如,它们保存多维数据,其中每个维
度代表不同的特征。像PyTorch这样的张量库可以高效地创建、操作和计算这些数
组。在这种情况下,张量库充当数组库。
PyTorch张量类似于NumPy数组,但具有对深度学习非常重要的几个附加特性。例
如,PyTorch添加了一个自动微分引擎,简化了梯度计算(见第A.4节)。PyTorch
张量还支持GPU计算以加速深度神经网络训练(见第A.8节)。
PyTorch采用与NumPy类似的API
PyTorch采用了大部分NumPy数组的API和语法来进行张量操作。如果你是NumPy
的新手,可以通过我的文章“Python中的科学计算:NumPy和Matplotlib简介”获得
最相关概念的简要概述,网址为https://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)
# 从Python整数创建
# 创建一维张量 (向量)
tensor1d = torch.tensor([1, 2, 3])
# 从Python列表创建
# 创建二维张量 (矩阵)
tensor2d = torch.tensor([[1, 2],
[3, 4]])
# 从嵌套Python列表创建
# 创建三维张量
tensor3d = torch.tensor([[[1, 2],
[3, 4]],
[[5, 6],
[7, 8]]])
# 从嵌套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中可用的不同张量数据类型的更多信息,请参阅官方文档:https://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相似的语法。(.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中所有不同张量操
作的读者(我们不需要大多数这些操作),我建议查阅官方文档:https://pytorch.org/docs/stable/tensors.html。
A.3 将模型视为计算图
现在让我们看一下PyTorch的自动微分引擎,也称为autograd。PyTorch的autograd系
统提供了在动态计算图中自动计算梯度的功能。
计算图是有向图,允许我们表达和可视化数学表达式。在深度学习的背景下,计
算图展示了计算神经网络输出所需的一系列计算——我们需要这个来计算反向传
播所需的梯度,这是神经网络的主要训练算法。
让我们通过一个具体的例子来说明计算图的概念。以下代码实现了简单逻辑回归
分类器的前向传递(预测步骤),可以看作是一个单层神经网络。它返回一个介
于0和1之间的分数,该分数在计算损失时与真实类别标签(0或1)进行比较。
列表A.2 逻辑回归前向传递
如果你不完全理解前面代码中的所有组件,请不要担心。这个例子的目的不是实
现逻辑回归分类器,而是说明如何将一系列计算视为计算图,如图A.7所示。
图A.7 逻辑回归前向传递作为计算图。输入特征x1乘以模型权重w1并经过激活函
数σ后加上偏置。损失是通过将模型输出a与给定标签y进行比较来计算的。
实际上,PyTorch在后台构建了这样的计算图,我们可以利用它来计算损失函数相
对于模型参数(此处为w1和b)的梯度,以训练模型。
A.4 自动微分简化
如果我们使用 PyTorch 进行计算,默认情况下它会在内部构建一个计算图,前提
是其终端节点之一的 requires_grad 属性被设置为 True。这在我们想要计算梯度时
非常有用。训练神经网络时需要梯度,通常是通过流行的反向传播算法来实现
的,该算法可以视为微积分链式法则在神经网络中的实现,如图 A.8 所示。
图 A.8 在计算图中计算损失梯度最常用的方法是从右到左应用链式法则,也称为
反向模式自动微分或反向传播。我们从输出层(或损失本身)开始,反向穿过网
络直到输入层。这样做是为了计算损失相对于网络中每个参数(权重和偏置)的
梯度,从而告知我们在训练期间如何更新这些参数。
偏导数和梯度
图 A.8 显示了偏导数,用于测量函数相对于其中一个变量的变化率。梯度是一个
包含多元函数所有偏导数的向量,多元函数是指具有多个输入变量的函数。
如果你不熟悉或不记得微积分中的偏导数、梯度或链式法则,不用担心。对于本
书而言,你只需要知道链式法则是计算给定模型参数的损失函数梯度的一种方
法。这提供了更新每个参数以最小化损失函数所需的信息,损失函数作为衡量模
型性能的代理指标,例如使用梯度下降法。我们将在第 A.7 节中重新讨论 PyTorch
中这个训练循环的计算实现。
这一切与自动微分(autograd)引擎有何关系?这是前面提到的 PyTorch 库的第二
个组件。PyTorch 的 autograd 引擎通过跟踪对张量执行的每个操作,在后台构建一
个计算图。然后,通过调用 grad 函数,我们可以计算损失相对于模型参数 w1 的
梯度,如下所示。
给定模型参数的损失值为
print(grad_L_w1)
print(grad_L_b)
这将打印出
(tensor([-0.0898]),) (tensor([-0.0817]),)
这里我们手动使用了 grad 函数,这对于实验、调试和演示概念非常有用。但在实
际应用中,PyTorch 提供了更高级的工具来自动化这一过程。例如,我们可以调用
loss.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 所示。
图 A.9 具有两个隐藏层的多层感知器。每个节点代表相应层中的一个单元。为了
说明的目的,每层的节点数量非常少。
在 PyTorch 中实现神经网络时,我们可以继承 torch.nn.Module 类来定义我们自己
的自定义网络架构。这个 Module 基类提供了很多功能,使得构建和训练模型更加
容易。例如,它允许我们将层和操作封装起来,并跟踪模型的参数。
在这个子类中,我们在 init 构造函数中定义网络层,并在 forward 方法中指定这些
层如何交互。forward 方法描述了输入数据如何通过网络并形成计算图。相比之
下,我们通常不需要自己实现 backward 方法,该方法用于在训练期间根据模型参
数计算损失函数的梯度(参见第 A.7 节)。以下代码实现了具有两个隐藏层的经
典多层感知器,以说明 Module 类的典型用法。
列表 A.4 具有两个隐藏层的多层感知器
然后,我们可以如下实例化一个新的神经网络对象:
model = NeuralNetwork(50, 3)
在使用这个新的模型对象之前,我们可以调用 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,而无需在 NeuralNetwork 的 forward 方法中逐个调用每一层。
我们来检查一下这个模型的可训练参数总数:
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 使随机数初始化可重复:
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= 表示用于计算计算图中变量的最后一个函数。具体来说,
grad_fn= 意味着我们正在检查的张量是通过矩阵乘法和加法操作创建的。PyTorch
将在反向传播期间计算梯度时使用此信息。 部分表示执行的操作。在这种情况
下,它是 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 所示。
图 A.10 PyTorch 实现了 Dataset 和 DataLoader 类。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.X = X
self.y = y
def __getitem__(self, index):
return self.X[index], self.y[index]
def __len__(self):
return self.X.shape[0]
这个自定义 ToyDataset 类的目的是实例化一个 PyTorch DataLoader。但在我们进行
这一步之前,让我们简要回顾一下 ToyDataset 代码的一般结构。
在 PyTorch 中,自定义 Dataset 类的三个主要组件是 __init__ 构造函数、
__getitem__ 方法和 __len__ 方法(参见列表 A.6)。在 __init__ 方法中,我们设
置可以在 __getitem__ 和 __len__ 方法中访问的属性。这些可以是文件路径、文件
对象、数据库连接器等。因为我们创建了一个内存中的张量数据集,所以我们只
需将 X 和 y 分配给这些属性,它们是张量对象的占位符。
在 __getitem__ 方法中,我们定义了通过索引返回数据集中恰好一项的指令。这指
的是单个训练样本或测试实例的特征和相应的类别标签。(数据加载器将提供此
索引,我们稍后会介绍这一点。)
最后,__len__ 方法包含检索数据集长度的指令。在这里,我们使用张量的 .shape
属性返回特征数组中的行数。对于训练数据集,我们有五行,我们可以再次确
认:
结果为
5
现在我们已经定义了一个可以用于我们的玩具数据集的 PyTorch Dataset 类,我们
可以使用 PyTorch 的 DataLoader 类从中采样,如下所示。
列表 A.7 实例化数据加载器
在实例化训练数据加载器之后,我们可以对其进行迭代。对 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 时,数据加载将在主进程中完成,而不是在单独的工作进程。这看起
来似乎没有问题,但它可能会在训练较大网络时导致显著的减速。除了专注于深
度学习模型的处理外,CPU 还必须花时间加载和预处理数据。因此,GPU 可能会
处于空闲状态,等待 CPU 完成这些任务。相反,当 num_workers 设置为大于 0 的
数字时,会启动多个工作进程以并行加载数据,使主进程可以专注于训练模型,
更好地利用系统的资源(图 A.11)。
但是,如果我们处理的是非常小的数据集,将 num_workers 设置为 1 或更大可能
没有必要,因为总训练时间仅需几秒钟。因此,如果你处理的是小型数据集或交
互式环境(如 Jupyter 笔记本),增加 num_workers 可能不会带来任何明显的加
速。实际上,它可能会导致一些问题。其中一个潜在问题是启动多个工作进程的
开销,这可能比实际加载数据的时间更长,特别是当你的数据集很小时。
此外,对于 Jupyter 笔记本,将 num_workers 设置为大于 0 有时会导致不同进程之
间共享资源的问题,从而引发错误或笔记本崩溃。因此,了解权衡并合理设置
num_workers 参数非常重要。正确使用时,它可以是一个有益的工具,但应根据你
的特定数据集大小和计算环境进行调整,以获得最佳结果。
图 A.11 不使用多个工作进程(设置 num_workers=0)加载数据会在模型等待下一
个批加载时造成瓶颈(左)。如果启用了多个工作进程,数据加载器可以在后台
排队下一个批(右)。
在我的经验中,通常将 num_workers=4 设置为许多真实世界数据集的最佳性能,但
最优设置取决于你的硬件和在 Dataset 类中定义的加载训练样本的代码。
A.7 典型的训练循环
现在让我们在一个玩具数据集上训练一个神经网络。以下代码清单显示了训练代
码。
运行此代码会得到以下输出:
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,这表明模型在训练集上收敛了。这里
我们初始化了一个具有两个输入和两个输出的模型,因为我们的玩具数据集有两
个输入特征和两个类标签需要预测。我们使用了学习率为0.5的随机梯度下降
(SGD)优化器。学习率是一个超参数,意味着它是我们必须根据观察到的损失
进行实验调整的一个可调设置。理想情况下,我们希望选择一个学习率,使得损
失在一定数量的epoch后收敛——epoch的数量是另一个需要选择的超参数。
练习 A.3
列表A.9中介绍的神经网络有多少个参数?
实际上,我们经常使用第三个数据集,即所谓的验证数据集,来找到最优的超参
数设置。验证数据集类似于测试集。然而,虽然我们只想精确地使用一次测试集
以避免偏差评估,我们通常会多次使用验证集来调整模型设置。
我们还介绍了名为model.train()和model.eval()的新设置。顾名思义,这些设置用于
将模型置于训练模式和评估模式。这对于在训练和推理过程中行为不同的组件
(如dropout或批量归一化层)来说是必要的。由于我们的NeuralNetwork类中没有
受这些设置影响的dropout或其他组件,在前面的代码中使用model.train()和model.eval()是多余的。然而,最好还是包含它们,以避免在更改模型架构或重用代码
训练不同模型时出现意外行为。
正如前面讨论过的,我们将logits直接传递给交叉熵损失函数,该函数出于效率和
数值稳定性原因会在内部应用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]])
让我们考虑前面代码输出中的第一行。这里,第一个值(列)表示训练样本属于
类别0的概率为99.91%,属于类别1的概率为0.09%。(set_printoptions调用在这里
是为了使输出更易读)
我们可以使用PyTorch的argmax函数将这些值转换为类标签预测,该函数返回每行
中最高值的索引位置(如果设置dim=1;如果设置dim=0,则返回每列中的最高
值):
predictions = torch.argmax(probas, dim=1)
print(predictions)
这将打印:
tensor([0, 0, 0, 1, 1])
请注意,计算softmax概率并不是获得类标签所必需的。我们也可以直接对logits(outputs)应用argmax函数:
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函数,如下所示。
该代码遍历数据加载器以计算正确预测的数量和比例。当我们处理大型数据集
时,由于内存限制,我们通常只能对数据集的一小部分调用模型。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相比。
首先,我们将了解PyTorch中GPU计算的主要概念。然后,我们将在单个GPU上训
练模型。最后,我们将研究使用多个GPU进行分布式训练。
A.9.1 PyTorch在GPU设备上的计算
修改训练循环以选择性地在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上运行。这
一步只需要更改三行代码,如下所示。
运行上述代码将输出以下内容,类似于在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的内存传输成本,我们可能不会看到
加速。然而,在训练深度神经网络(尤其是大型语言模型)时,我们可以期望显
著的速度提升。
PyTorch在macOS上的使用
在配备Apple Silicon芯片(如M1、M2、M3或更新型号)的Apple Mac上,而不是
配备Nvidia GPU的计算机,你可以将
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
改为
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
以充分利用该芯片。
练习 A.4
比较矩阵乘法在CPU和GPU上的运行时间。在什么矩阵大小时,你开始看到GPU
上的矩阵乘法比CPU快?提示:在Jupyter中使用%timeit命令比较运行时间。例
如,给定矩阵a和b,在新的笔记本单元格中运行命令%timeit a @ b。
A.9.3 使用多个GPU进行训练
分布式训练是指将模型训练分布在多个GPU和机器上。为什么我们需要这样做?
即使在一个GPU或机器上训练模型是可能的,但过程可能会非常耗时。通过将训
练过程分布在多个机器上,每个机器可能有多个GPU,可以显著减少训练时间。
这在模型开发的实验阶段尤为重要,因为在微调模型参数和架构时可能需要多次
训练迭代。
注意 对于本书,不需要访问或使用多个GPU。本节仅针对对PyTorch中多GPU计算
感兴趣的读者。
让我们从最简单的分布式训练情况开始:PyTorch的DistributedDataParallel(DDP)策略。DDP通过将输入数据分发到可用设备并同时处理这些数据子集
来实现并行化。
它是如何工作的?PyTorch在每个GPU上启动一个单独的进程,每个进程接收并保
持模型的一个副本;这些副本将在训练过程中同步。为了说明这一点,假设我们
有两个GPU,想要用它们来训练一个神经网络,如图A.12所示。
每个GPU将接收模型的一个副本。然后,在每次训练迭代中,每个模型将从数据
加载器接收一个minibatch(或简称“batch”)。我们可以使用DistributedSampler确
保在使用DDP时,每个GPU接收到不同的、不重叠的batch。
图A.12 DDP中的模型和数据传输涉及两个关键步骤。首先,我们在每个GPU上创
建模型的一个副本。然后,我们将输入数据分成唯一的minibatch,并传递给每个
模型副本。
由于每个模型副本会看到训练数据的不同样本,因此模型副本将返回不同的logits
作为输出,并在反向传播期间计算不同的梯度。这些梯度将在训练过程中平均并
同步,以更新模型。这样,我们确保模型不会发散,如图A.13所示。
图A.13 在DDP中,前向和反向传播在每个GPU上独立执行,每个GPU对应其数据
子集。一旦完成前向和反向传播,来自每个模型副本(在每个GPU上)的梯度将
在所有GPU之间同步。这确保了每个模型副本具有相同的更新权重。
使用DDP的好处是在处理数据集时提供了更高的速度。除了伴随DDP使用的设备
之间的轻微通信开销外,理论上使用两个GPU可以在一半的时间内处理一个训练
epoch,而使用八个GPU可以在八分之一的时间内处理一个epoch,依此类推。
注意 DDP不能在交互式Python环境中正常工作,例如Jupyter笔记本,因为它们不
像独立的Python脚本那样处理多进程。因此,以下代码应作为脚本执行,而不是
在类似Jupyter的笔记本界面中执行。DDP需要生成多个进程,每个进程应有自己
的Python解释器实例。
现在让我们看看这在实践中是如何工作的。为了简洁,我将重点放在需要调整以
适应DDP训练的核心代码部分。然而,希望在自己的多GPU机器或云实例上运行
代码的读者应使用本书GitHub仓库中提供的独立脚本,地址为https://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_group和destroy_process_group用于初始化和退出分布式训练模式。
init_process_group函数应在训练脚本的开头调用,以初始化分布式设置中每个进程
的进程组,而destroy_process_group应在训练脚本的结尾调用,以销毁给定的进程
组并释放其资源。以下代码示例展示了如何使用这些新组件为我们在前面实现的
NeuralNetwork模型实现DDP训练。
清单 A.13 使用DistributedDataParallel策略进行模型训练
def ddp_setup(rank, world_size):
os.environ["MASTER_ADDR"] = "localhost"
# 主节点地址
在运行此代码之前,让我们总结一下它是如何工作的,此外还有前面的注释。我
们在底部有一个__name__ == "__main__"子句,其中包含当我们将代码作为Python
脚本而不是作为模块导入时执行的代码。
这段代码首先使用torch.cuda.device_count()打印可用的GPU数量,设置随机种子以
确保可重复性,然后使用PyTorch的multiprocessesing.spawn函数启动新进程。这
里,spawn函数为每个GPU启动一个进程,设置nproces=world_size,其中world
size是可用的GPU数量。这个spawn函数启动我们在同一脚本中定义的main函数中
的代码,并通过args提供一些附加参数。请注意,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(进程标识符)和world size(总进程数)。最后,它指定当前模型训练进
程对应的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脚本。
现在让我们通过从终端作为脚本启动代码来看看它在实践中是如何工作的:
请注意,它应该适用于单GPU和多GPU机器。如果我们在单GPU上运行此代码,
我们应该看到以下输出:
PyTorch版本:2.2.1+cu117
CUDA可用:True
可用的GPU数量: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] 训练准确率 1.0
[GPU0] 测试准确率 1.0
代码输出类似于使用单GPU(第A.9.2节),这是一个很好的合理性检查。
现在,如果我们使用相同的命令和代码在有两块GPU的机器上运行,我们应该看
到以下内容:
PyTorch版本:2.2.1+cu117
CUDA可用:True
可用的GPU数量: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] 训练准确率 1.0
[GPU0] 训练准确率 1.0
[GPU1] 测试准确率 1.0
[GPU0] 测试准确率 1.0
正如预期的那样,我们可以看到有些批次是在第一个GPU(GPU0)上处理的,而
其他批次是在第二个GPU(GPU1)上处理的。然而,在打印训练和测试准确性
时,我们看到了重复的输出行。每个进程(换句话说,每个GPU)独立地打印测
试准确性。由于DDP将模型复制到每个GPU,并且每个进程独立运行,因此如果
您在测试循环中有print语句,每个进程都会执行它,导致重复的输出行。如果您
对此感到困扰,可以使用每个进程的rank来控制您的print语句:
if rank == 0:
print("Test accuracy: ", accuracy)
仅在第一个进程中打印
这就是分布式训练通过DDP的工作方式。如果您对更多细节感兴趣,我建议查阅
官方API文档 https:// mng.bz/9dPr。
替代的PyTorch多GPU训练API
如果您更喜欢更简单的方式在PyTorch中使用多个GPU,可以考虑像Fabric这样的
开源库。我在“加速PyTorch模型训练:使用混合精度和完全分片数据并
行”(https://mng.bz/jXle)中写过相关内容。
摘要
PyTorch 是一个开源库,包含三个核心组件:张量库、自动微分函数和深度学习工
具。PyTorch 的张量库类似于 NumPy 等数组库。在 PyTorch 的上下文中,张量是
表示标量、向量、矩阵和更高维度数组的类似数组的数据结构。PyTorch 张量可以
在 CPU 上执行,但 PyTorch 张量格式的一个主要优势是支持 GPU 以加速计算。
PyTorch 中的自动微分(autograd)功能使我们能够方便地使用反向传播训练神
经网络,而无需手动推导梯度。
PyTorch 中的深度学习工具为创建自定义深度神经网络提供了构建模块。PyTorch
包括 Dataset 和 DataLoader 类来设置高效的数据加载管道。
在 CPU 或单个 GPU 上训练模型是最简单的。
如果有多个 GPU 可用,使用 DistributedDataParallel 是在 PyTorch 中加速训练的
最简单方法。
// 示例代码片段
int main() {
// 代码内容
return 0;
}
附录 B 参考文献和进一步阅读
第1章
定制的大型语言模型(LLM)能够超越通用的LLM,正如彭博社团队通过一个从
头开始在金融数据上预训练的GPT版本所展示的那样。该定制LLM在金融任务上
优于ChatGPT,同时在通用LLM基准测试中保持良好的性能:
“BloombergGPT:面向金融的大型语言模型” (2023) 由Wu等人撰写,https://arxiv.org/abs/2303.17564
现有的LLM也可以通过适应和微调来超越通用LLM,这在医疗背景下由Google
Research和Google DeepMind团队展示:
“使用大型语言模型实现专家级医学问答” (2023) 由Singhal等人撰写,https://arxiv.org/abs/2305.09617
以下论文提出了原始的Transformer架构:
“注意力就是你所需要的” (2017) 由Vaswani等人撰写,https://arxiv.org/abs/
1706.03762
关于原始编码器风格的Transformer,称为BERT,请参阅 “BERT:用于语言理解的
深度双向Transformer预训练” (2018) 由Devlin等人撰写,https://arxiv.org/abs/
1810.04805
描述解码器风格的GPT-3模型的论文启发了现代LLM,并将在本书中作为从头开
始实现LLM的模板:
“语言模型是少样本学习者” (2020) 由Brown等人撰写,https://arxiv.org/abs/
2005.14165
以下内容涵盖了用于图像分类的原始视觉Transformer,说明了Transformer架构不
仅限于文本输入:
“一张图片值16x16个词:大规模图像识别中的Transformer” (2020) 由Dosovitskiy等
人撰写,https://arxiv.org/abs/2010.11929
以下实验性的(但不太受欢迎的)LLM架构作为例子,表明并非所有LLM都需要
基于Transformer架构:
“RWKV:为Transformer时代重新发明RNN” (2023) 由Peng等人撰写,https://arxiv.org/abs/2305.13048
“Hyena层次结构:迈向更大的卷积语言模型” (2023) 由Poli等人撰写,https://arxiv.org/abs/2302.10866
“Mamba:具有选择性状态空间的线性时间序列建模” (2023) 由Gu和Dao撰写,
https://arxiv.org/abs/2312.00752
Meta AI的模型是一个流行的GPT类模型的开源实现,与GPT-3和ChatGPT不同,它
是公开可用的:
“Llama 2:开放的基础和微调聊天模型” (2023) 由Touvron等人撰写,https://arxiv.org/abs/2307.092881
对于对第1.5节中的数据集引用感兴趣的读者,这篇论文描述了Eleuther AI策划的
公开可用的The Pile数据集:
“The Pile:一个800GB的多样化文本数据集,用于语言建模” (2020) 由Gao等人撰
写,https://arxiv.org/abs/2101.00027
以下论文提供了InstructGPT的参考,用于微调GPT-3,这在第1.6节中提到,并将
在第7章中详细讨论:
“用人类反馈训练语言模型以遵循指令” (2022) 由Ouyang等人撰写,https://arxiv.org/abs/2203.02155
第2章
对嵌入空间与潜在空间以及向量表示的一般概念感兴趣的读者可以在我的书的第
一章中找到更多信息:
《机器学习Q和AI》 (2023) 由Sebastian Raschka撰写,https://leanpub.com/machinelearning-q-and-ai
以下论文更深入地讨论了字节对编码作为分词方法的使用:
“使用子词单元进行神经机器翻译中的罕见词翻译” (2015) 由Sennrich等人撰写,
https://arxiv.org/abs/1508.07909
OpenAI开源了用于训练GPT-2的字节对编码分词器代码:
https://github.com/openai/gpt-2/blob/master/src/encoder.py
OpenAI提供了一个交互式的Web UI,以说明GPT模型中的字节对分词器的工作原
理:
https://platform.openai.com/tokenizer
对于有兴趣从头开始编码和训练BPE分词器的读者,Andrej Karpathy的GitHub仓库
minbpe提供了一个最小且易读的实现:
“BPE分词器的最小实现”,https://github.com/karpathy/minbpe
对研究一些其他流行LLM使用的替代分词方案感兴趣的读者可以在SentencePiece
和WordPiece论文中找到更多信息:
“SentencePiece:用于神经文本处理的简单且与语言无关的子词分词器和去分词器”
(2018) 由Kudo和Richardson撰写,https://aclanthology.org/D18-2012/
“快速WordPiece分词” (2020) 由Song等人撰写,https://arxiv.org/abs/2012.15524
第3章
对Bahdanau注意力用于RNN和语言翻译感兴趣的读者可以在以下论文中找到详细
的见解:
“通过联合学习对齐和翻译实现神经机器翻译” (2014) 由Bahdanau、Cho和Bengio撰
写,https://arxiv.org/abs/1409.0473
自注意力的概念作为缩放点积注意力是在原始Transformer论文中引入的:
“注意力就是你所需要的” (2017) 由Vaswani等人撰写,https://arxiv.org/abs/
1706.03762
FlashAttention是一种高效的自注意力机制实现,通过优化内存访问模式加速计算
过程。FlashAttention在数学上与标准的自注意力机制相同,但优化了计算过程以
提高效率:
“FlashAttention:具有IO感知的快速且内存高效的精确注意力” (2022) 由Dao等人撰
写,https://arxiv.org/abs/2205.14135
“FlashAttention-2:更快的注意力机制,具有更好的并行性和工作划分” (2023) 由
Dao撰写,https://arxiv.org/abs/2307.08691
PyTorch实现了支持FlashAttention的自注意力和因果注意力函数,该函数处于测试
阶段,可能会有所更改:
scaled_dot_product_attention文档:https://mng.bz/NRJd
PyTorch还基于scaled_dot_product函数实现了一个高效的MultiHeadAttention类:
MultiHeadAttention文档:https://mng.bz/DdJV
Dropout是一种正则化技术,通过在训练期间随机丢弃神经网络中的单元(及其连
接)来防止过拟合:
“Dropout:一种简单的方法,防止神经网络过拟合” (2014) 由Srivastava等人撰写,
https://jmlr.org/papers/v15/srivastava14a.html
虽然基于缩放点积注意力的多头注意力仍然是实践中最常见的自注意力变体,作
者发现即使没有值权重矩阵和投影层,也能实现良好的性能:
“简化Transformer块” (2023) 由He和Hofmann撰写,https://arxiv.org/abs/2311.01906
第4章
以下论文介绍了一种通过在隐藏层中归一化传入神经元的输入来稳定神经网络隐
藏状态动态的技术,显著减少了训练时间:
“层归一化” (2016) 由Ba、Kiros和Hinton撰写,https://arxiv.org/abs/1607.06450
Post-LayerNorm,用于原始Transformer模型,在自注意力和前馈网络之后应用层归
一化。相比之下,Pre-LayerNorm,如GPT-2和更新的LLM中采用的,在这些组件
之前应用层归一化,可以导致更稳定的训练动态,并在某些情况下显示出更好的
性能,如下文所述:
“论Transformer架构中的层归一化” (2020) 由Xiong等人撰写,https://arxiv.org/abs/
2002.04745
“ResiDual:具有双重残差连接的Transformer” (2023) 由Tie等人撰写,https://arxiv.org/abs/2304.14802
现代LLM中广泛使用的LayerNorm变体是RMSNorm,因其改进的计算效率而受到
欢迎。该变体通过仅使用输入的均方根进行归一化,而不减去均值再平方,从而
简化了归一化过程。这意味着它不在计算比例尺之前居中数据。RMSNorm在下文
中进行了更详细的描述:
“均方根层归一化” (2019) 由Zhang和Sennrich撰写,https://arxiv.org/abs/1910.07467
高斯误差线性单元(GELU)激活函数结合了经典ReLU激活函数和正态分布累积
分布函数的特性,以模拟层输出,允许在深度学习模型中进行随机正则化和非线
性:
// 示例代码片段
“高斯误差线性单元 (GELUs)” (2016) 由 Hendricks 和 Gimpel 发表,https://arxiv.org/
abs/1606.08415
GPT-2 论文介绍了一系列基于变压器的大型语言模型 (LLM),具有不同的参数规
模——1.24 亿、3.55 亿、7.74 亿和 15 亿参数:
“语言模型是无监督的多任务学习者” (2019) 由 Radford 等人发表,https://mng.bz/
lMgo
OpenAI 的 GPT-3 使用了与 GPT-2 基本相同的架构,只是最大的版本(1750 亿参
数)比最大的 GPT-2 模型大 100 倍,并且在更多数据上进行了训练。感兴趣的读
者可以参考 OpenAI 的官方 GPT-3 论文以及 Lambda Labs 的技术概述,后者计算
出在单个 RTX 8000 消费级 GPU 上训练 GPT-3 需要 665 年:
“语言模型是少样本学习者” (2023) 由 Brown 等人发表,https:// arxiv.org/abs/
2005.14165
“OpenAI 的 GPT-3 语言模型:技术概述”,https://lambdalabs .com/blog/demystifying-gpt-3
NanoGPT 是一个代码仓库,包含了一个简约而高效的 GPT-2 模型实现,类似于本
书中实现的模型。虽然本书中的代码与 nanoGPT 不同,但这个仓库启发了将大型
GPT Python 父类实现重新组织为较小的子模块:
“NanoGPT,用于训练中型 GPT 的代码仓库,https://github.com/karpathy/nanoGPT
一篇有见地的博客文章指出,在上下文大小小于 32,000 个标记时,大型语言模型
(LLM) 中的大部分计算花费在前馈层而不是注意力层上:
“从长远(上下文)来看” 由 Harm de Vries 发表,https://www.harmdevries.com/
post/context-length/
第5章
有关详细说明损失函数并应用对数转换以使其更易于进行数学优化的信息,请参
阅我的讲座视频:
L8.2 Logistic 回归损失函数,https://www.youtube.com/watch?v=GxJe0DZvydM
以下讲座和代码示例由作者解释了 PyTorch 的交叉熵函数的工作原理:
L8.7.1 OneHot 编码和多类别交叉熵,https://www.youtube.com/
watch?v=4n71-tZ94yk 理解 PyTorch 中的 Onehot 编码和交叉熵,https://mng.bz/o05v
以下两篇论文详细介绍了用于预训练 LLM 的数据集、超参数和架构细节:
“Pythia:一套用于分析大规模语言模型的工具” (2023) 由 Biderman 等人发表,
https://arxiv.org/abs/2304.01373 “OLMo:加速语言模型科学” (2024) 由 Groeneveld
等人发表,https://arxiv.org/abs/2402.00838
本书提供的补充代码包含有关准备来自 Project Gutenberg 的 60,000 本公共领域书
籍以供 LLM 训练的说明:
在 Project Gutenberg 数据集上预训练 GPT,https://mng.bz/Bdw2
第 5 章讨论了 LLM 的预训练,附录 D 覆盖了更高级的训练函数,如线性预热和
余弦退火。以下论文发现类似的技术可以成功应用于继续预训练已经预训练的
LLM,并提供了额外的提示和见解:
“简单且可扩展的策略以持续预训练大型语言模型” (2024) 由 Ibrahim 等人发表,
https://arxiv.org/abs/2403.08763
BloombergGPT 是一个特定领域的 LLM 示例,通过在通用文本语料库和特定领域
的金融文本语料库上进行训练创建而成:
“BloombergGPT:面向金融领域的大型语言模型” (2023) 由 Wu 等人发表,
https://arxiv.org/abs/2303.17564
GaLore 是一项最近的研究项目,旨在使 LLM 预训练更加高效。所需的代码更改
仅限于将 PyTorch 的 AdamW 优化器替换为 galore-torch Python 包提供的 GaLoreAdamW 优化器:
“GaLore:通过梯度低秩投影实现内存高效的 LLM 训练” (2024) 由 Zhao 等人发
表,https://arxiv.org/abs/2403.03507 GaLore 代码仓库,https://github.com/jiaweizzhao/GaLore
以下论文和资源分享了公开可用的大规模预训练数据集,这些数据集包含数百 GB
到 TB 的文本数据:
“Dolma:一个包含三万亿个标记的开放语料库,用于 LLM 预训练研究” (2024) 由
Soldaini 等人发表,https://arxiv.org/abs/2402.00159
“The Pile:一个 800GB 多样化文本数据集,用于语言建模” (2020) 由 Gao 等人发
表,https://arxiv.org/abs/2101.00027
“The RefinedWeb 数据集用于 Falcon LLM:仅使用网络数据超越精选语料库”,
(2023) 由 Penedo 等人发表,https://arxiv.org/abs/2306.01116
“RedPajama,” 由 Together AI 提供,https://mng.bz/d6nw
The FineWeb 数据集,包括来自 CommonCrawl 的超过 15 万亿个标记的清理和去
重的英语网络数据,https://mng.bz/rVzy
最初引入 top-k 抽样的论文是 “分层神经故事生成” (2018) 由 Fan 等人发表,
https://arxiv.org/abs/1805.04833
top-p 抽样(第 5 章未涵盖)是一种替代方法,它选择累积概率超过阈值 p 的最小
集合的顶级标记,而 top-k 抽样则按概率选择顶级 k 个标记:
Top-p 抽样,https://en.wikipedia.org/wiki/Top-p_sampling
束搜索(第 5 章未涵盖)是一种替代解码算法,通过在每一步只保留得分最高的
部分序列来平衡效率和质量,从而生成输出序列:
“多样化的束搜索:从神经序列模型解码多样化解决方案” (2016) 由 Vijayakumar
等人发表,https://arxiv.org/abs/1610.02424
第6章
关于不同类型微调的其他资源有 “使用和微调预训练的变压器”,https://mng.bz/
VxJG “微调大型语言模型”,https://mng.bz/x28X
有关实验的更多信息,包括比较微调第一个输出标记与最后一个输出标记的对
比,可以在 GitHub 上的补充代码材料中找到:
附加的垃圾邮件分类实验,https://mng.bz/AdJx
对于二元分类任务,例如垃圾邮件分类,技术上可以只使用一个输出节点而不是
两个输出节点,正如我在以下文章中讨论的那样:
“Losses Learned—优化 PyTorch 中的负对数似然和交叉熵”,https://mng.bz/ZEJA
您可以在以下文章中找到有关微调 LLM 不同层的其他实验,该文章显示微调最后
一个变压器块以及输出层可以显著提高预测性能:
“微调大型语言模型”,https://mng.bz/RZJv
读者可以在 imbalanced-learn 文档中找到处理不平衡分类数据集的其他资源和信
息:
“Imbalanced-Learn 用户指南”,https://mng.bz/2KNa
对于有兴趣对垃圾邮件电子邮件而不是垃圾短信进行分类的读者,以下资源提供
了一个方便的 CSV 格式的大型电子邮件垃圾邮件分类数据集,类似于第 6 章使用
的数据集格式:
电子邮件垃圾邮件分类数据集,https://mng.bz/1GEq
GPT-2 是基于变压器架构的解码器模块的模型,其主要目的是生成新文本。作为
替代,基于编码器的模型如 BERT 和 RoBERTa 在分类任务中可能更为有效:
“BERT:用于语言理解的深度双向变压器预训练” (2018) 由 Devlin 等人发表,
https://arxiv.org/abs/1810.04805
“RoBERTa:一种稳健优化的 BERT 预训练方法” (2019) 由 Liu 等人发表,
https://arxiv.org/abs/1907.11692
“额外实验对 50k IMDB 电影评论的情感分类”,https://mng.bz/PZJR
最近的论文表明,通过在分类微调期间移除因果掩码以及其他修改,可以进一步
提高分类性能:
“标签监督 LLaMA 微调” (2023) 由 Li 等人发表,https://arxiv.org/abs/2310.01208
“LLM2Vec:大型语言模型实际上是强大的文本编码器” (2024) 由 BehnamGhader
等人发表,https://arxiv.org/abs/2404.05961
第7章
// 示例代码片段
int main() {
// 代码内容
}
Alpaca数据集用于指令微调,包含52,000个指令-响应对,是最早和最受欢迎的公
开可用数据集之一:
“斯坦福Alpaca:一个遵循指令的Llama模型”,https://github .com/tatsu-lab/stanford_alpaca
其他适合指令微调的公开可访问数据集包括
LIMA, https://huggingface.co/datasets/GAIR/lima
– 有关更多信息,请参见“LIMA:更少即更多对齐”,Zhou等,《arxiv.org/abs/
2305.11206》
UltraChat, https://huggingface.co/datasets/openchat/ultrachat-sharegpt
– 一个包含805,000个指令-响应对的大规模数据集;有关更多信息,请参见“通过
扩展高质量指令对话增强聊天语言模型”,Ding等,《arxiv.org/abs/2305.14233》
Alpaca GPT4, https://mng.bz/Aa0p
– 一个类似于Alpaca的数据集,包含52,000个由GPT-4生成的指令-响应对,而不是
GPT-3.5
Phi-3是一个拥有38亿参数的模型,其指令微调变体据报道可以与更大的专有模型
(如GPT-3.5)相媲美:
“Phi-3技术报告:在您的手机上本地运行的高度功能语言模型”(2024),Abdin
等,《arxiv.org/abs/2404.14219》
研究人员提出了一种合成指令数据生成方法,从指令微调的Llama3模型中生成
300,000个高质量的指令-响应对。在这些指令示例上微调的预训练Llama 3基础模
型表现与原始指令微调的Llama-3模型相当:
“Magpie:从头开始通过提示对齐LLM生成对齐数据”(2024),Xu等,《arxiv.org/abs/2406.08464》
研究表明,在指令微调过程中不掩盖指令和输入可以有效提高各种NLP任务和开
放生成基准的表现,尤其是在训练数据集中的指令较长且输出简短或使用少量训
练示例时:
“指令上的损失进行指令微调”(2024),Shi,《arxiv.org/abs/2405.14394》
Prometheus和PHUDGE是公开可用的LLM,它们可以在评估长篇回答时与GPT-4匹
配,并支持自定义标准。我们不使用这些模型,因为在撰写本文时,它们不受Ollama支持,因此无法在笔记本电脑上高效执行:
– “Prometheus:在语言模型中诱导细粒度评估能力”(2023),Kim等,《arxiv.org/abs/2310.08491》
– “PHUDGE:作为可扩展法官的Phi-3”(2024),Deshwal和Chawla,《arxiv.org/
abs/2405.08029》
– “Prometheus 2:一个专注于评估其他语言模型的开源语言模型”(2024),Kim
等,《arxiv.org/abs/2405.01535》
以下报告的结果支持这样的观点:大型语言模型主要在预训练期间获取事实知
识,而微调主要增强其使用这些知识的效率。此外,这项研究探讨了用新事实信
息微调大型语言模型如何影响其使用现有知识的能力,揭示了模型学习新事实的
速度较慢,且在微调期间引入新事实会增加模型生成错误信息的倾向:
“微调LLMs以鼓励幻觉吗?”(2024),Gekhman,《arxiv.org/abs/2405.05904》
偏好微调是在指令微调之后的一个可选步骤,以使LLM更紧密地与人类偏好对
齐。作者的以下文章提供了有关此过程的更多信息:
“LLM训练:RLHF及其替代方案”,https://mng.bz/ZVPm
“LLM预训练和评估奖励模型的技巧”,https://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分钟视频教程:
“第4.1讲:深度学习中的张量”,https://www.youtube.com/watch?v=JXfDlgrfOBY
如果您想了解更多关于机器学习中的模型评估,我推荐我的文章:
– “机器学习中的模型评估、模型选择和算法选择”(2018),Sebastian Raschka,
《arxiv.org/abs/1811.12808》
对于希望复习或温和入门微积分的读者,我在网站上写了一章关于微积分的内
容,免费提供:
– “微积分简介”,Sebastian Raschka,https://mng.bz/WEyW
为什么PyTorch不在后台自动为我们调用optimizer.zero_grad()?在某些情况下,
可能希望累积梯度,PyTorch将此作为一个选项留给我们。如果您想了解更多关于
梯度累积的信息,请参阅以下文章:
“使用梯度累积在单个GPU上微调大型语言模型”,Sebastian Raschka,
https://mng.bz/8wPD
本附录涵盖了DDP,这是一种跨多个GPU训练深度学习模型的流行方法。对于更
高级的用例,如果单个模型无法适应GPU,您还可以考虑PyTorch的全分片数据并
行(FSDP)方法,它执行分布式数据并行,并将大层分布在不同的GPU上。有关
更多信息,请参阅带有API文档链接的概述:
“介绍PyTorch全分片数据并行(FSDP)API”,https://mng.bz/EZJR
附录 C—练习解答
完整的代码示例可以在补充的 GitHub 仓库中找到,地址为 https://github.com/rasbt/
LLMs-from-scratch。
第2章
练习 2.1
你可以通过一次提示编码器一个字符串来获取单独的 token 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=2 和 stride=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=8 和 stride=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章
练习 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章
练习 4.1
我们可以按如下方式计算前馈和注意力模块中的参数数量:
block = TransformerBlock(GPT_CONFIG_124M)
total_params = sum(p.numel() for p in block.ff.parameters())
print(f"前馈模块中的总参数数量: {total_params:,}")
total_params = sum(p.numel() for p in block.att.parameters())
print(f"注意力模块中的总参数数量: {total_params:,}")
如我们所见,前馈模块中的参数数量大约是注意力模块的两倍:
前馈模块中的总参数数量: 4,722,432
注意力模块中的总参数数量: 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:
总参数数量: 1,637,792,000
考虑权重绑定后的可训练参数数量: 1,557,380,800
模型总大小: 6247.68 MB
练习 4.3
在第 4 章中有三个地方我们使用了 dropout 层:嵌入层、快捷连接层和多头注意力
模块。我们可以通过在配置文件中分别编码这些层的 dropout 率,并相应地修改代
码实现来控制每个层的 dropout 率。
修改后的配置如下:
"n_heads": 12,
"n_layers": 12,
"drop_rate_attn": 0.1, # 多头注意力的 dropout
"drop_rate_shortcut": 0.1, # 快捷连接的 dropout
"drop_rate_emb": 0.1, # 嵌入层的 dropout
"qkv_bias": False
修改后的 TransformerBlock 和 GPTModel 如下:
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"],
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_shortcut"])
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"])
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 logits
第5章
练习 5.1
我们可以使用我们在本节定义的 print_sampled_tokens 函数来打印“pizza”这个 token(或单词)被采样的次数。让我们从第 5.3.1 节定义的代码开始。
当温度为 0 或 0.1 时,“pizza”token 被采样 0 次;当温度增加到 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"])
然后,调用 train_simple_function 并设置 num_epochs=1 来再训练一个 epoch。
练习 5.5
我们可以使用以下代码来计算 GPT 模型的训练集和验证集损失:
train_loss = calc_loss_loader(train_loader, gpt, device)
val_loss = calc_loss_loader(val_loader, gpt, device)
该124百万参数模型的损失结果如下:
训练损失: 3.754748503367106
验证损失: 3.559617757797241
主要观察是训练集和验证集的表现大致相同。这可能有多种解释:
1. “The Verdict” 不在OpenAI训练GPT-2时使用的预训练数据集中。因
此,模型并没有显式地过拟合训练集,并且在“The Verdict”的训练和验
证部分表现相似。(验证集的损失略低于训练集的损失,在深度学习
中这是不寻常的。但很可能是因为数据集较小导致的随机噪声。实际
上,如果没有过拟合,训练集和验证集的表现应该大致相同)。
2. “The Verdict” 是GPT-2预训练数据集的一部分。在这种情况下,我们无
法判断模型是否过拟合了训练数据,因为验证集也被用于训练。为了
评估过拟合的程度,我们需要一个在OpenAI完成GPT-2训练后生成的
新数据集,以确保它不会成为预训练的一部分。
练习 5.6
在主章节中,我们使用的是最小的GPT-2模型,它只有124百万个参数。原因是为
了尽可能降低资源需求。然而,您可以通过对代码进行少量修改来轻松实验更大
的模型。例如,在第5章中加载1558百万参数模型权重而不是124百万参数模型权
重,只需要更改以下两行代码:
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章
练习 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章
练习 7.1
Phi-3提示格式如图7.4所示,对于给定的示例输入,其格式如下:
识别以下单词的正确拼写:‘Occasion’ 正确的拼写是 ‘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 =
generated_text[len(input_text):].replace("<|assistant|>:",
"").strip()
test_data[i]["model_response"] = response_text
使用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 = []
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)
def __getitem__(self, index):
return self.instruction_lengths[index], self.encoded_texts[index]
def __len__(self):
return len(self.data)
接下来,我们更新custom_collate_fn,其中每个批次现在是一个包含(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 = [], []
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

return inputs_tensor, targets_tensor
当使用这种指令屏蔽方法评估微调后的模型时,它的表现略差(大约低4分,使用
第7章中的Ollama Llama 3方法)。这与“Instruction Tuning With Loss Over Instructions”论文(https://arxiv.org/abs/2405.14394)中的观察结果一致。
练习 7.3
要在原始Stanford Alpaca数据集(https://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条记录(比第7章多50倍),并且条目比我们在第7章
中处理的更长。
因此,强烈建议在GPU上运行训练。
如果您遇到内存不足错误,请考虑将批量大小从8减少到4、2或1。除了降低批量
大小外,您还可以考虑将allowed_max_length从1024降低到512或256。
以下是来自Alpaca数据集的一些示例,包括生成的模型响应:
练习 7.4
要使用LoRA进行指令微调,请使用附录E中的相关类和函数:
from appendix_E import LoRALayer, LinearWithLoRA, replace_lin
接下来,在第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”总
可训练LoRA参数: {total_params:,}“) model.to(device)
请注意,在Nvidia L4 GPU上,使用LoRA进行微调需要1.30分钟。在同一GPU上,
原始代码需要1.80分钟。因此,在这种情况下,LoRA大约快28%。评分使用第7章
的Ollama Llama 3方法评估,约为50分,与原始模型在同一水平。
附录A
练习A.1
网络有两个输入和两个输出。此外,还有两个隐藏层,分别有30个和20个节点。
编程计算参数数量如下:
model = NeuralNetwork(2, 2)
num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("总可训练模型参数数量:", num_params)
这将返回
752
我们也可以手动计算:
• 第一个隐藏层:2个输入乘以30个隐藏单元加上30个偏置单元
• 第二个隐藏层:30个传入单元乘以20个节点加上20个偏置单元
• 输出层:20个传入节点乘以2个输出节点加上2个偏置单元
然后,将每层的所有参数相加得到 2 × 30 + 30 + 30 × 20 + 20 + 20 × 2 + 2 = 752。
练习A.2
确切的运行时间结果将取决于用于此实验的硬件。在我的实验中,即使对于小矩
阵乘法,当使用连接到V100 GPU的Google Colab实例时,也观察到了显著的速度
提升:
a = torch.rand(100, 200)
b = torch.rand(200, 300)
%timeit a @ b
在CPU上,这导致了
63.8 μs ± 8.7 μs 每循环
在GPU上执行时,
a, b = a.to("cuda"), b.to("cuda")
%timeit a @ b
结果为
13.8 μs ± 425 ns 每循环
在这种情况下,在V100上,计算速度大约快四倍。
练习A.3
网络有两个输入和两个输出。此外,还有2个隐藏层,分别有30个和20个节点。编
程计算参数数量如下:
model = NeuralNetwork(2, 2)
num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("总可训练模型参数数量:", 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 每循环
在GPU上执行时,
a, b = a.to("cuda"), b.to("cuda")
%timeit a @ b
结果为
13.8 μs ± 425 ns 每循环
在这种情况下,在V100上,计算速度大约快四倍。
附录 D 在训练循环中添加额外功能
在本附录中,我们增强第5章到第7章涵盖的预训练和微调过程中的训练函数。具
体来说,它包括学习率预热、余弦衰减和梯度裁剪。然后我们将这些技术整合到
训练函数中并预训练一个LLM。
为了使代码自包含,我们重新初始化第5章训练的模型:
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()
初始化模型后,我们需要初始化数据加载器。首先,我们加载“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
warmup_steps = 20
预热步骤的数量通常设置为总步数的0.1%到20%,我们可以如下计算:
total_steps = len(train_loader) * n_epochs
warmup_steps = int(0.2 * total_steps) # 20% 预热
print(warmup_steps)
这将打印出27,意味着我们在前27个训练步骤中逐步将初始学习率从0.0001增加到
0.01。
接下来,我们实现一个简单的训练循环模板来说明这个预热过程:
运行上述代码后,我们可视化训练循环如何改变学习率,以验证学习率预热是否
按预期工作:
import matplotlib.pyplot as plt
plt.ylabel("学习率")
plt.xlabel("步骤")
total_training_steps = len(train_loader) * n_epochs
plt.plot(range(total_training_steps), track_lrs)
plt.show()
结果图显示学习率从低值开始,在前20个步骤中逐渐增加,直到在20个步骤后达
到最大值(图D.1)。
图D.1 学习率预热在前20个训练步骤中增加了学习率。20个步骤后,学习率达到峰
值0.01并在其余训练过程中保持不变。
接下来,我们将进一步修改学习率,使其在达到最大学习率后下降,这有助于进
一步提高模型训练的效果。
D.2 余弦衰减
另一种广泛采用的训练复杂深度神经网络和LLM的技术是余弦衰减。此方法在整
个训练周期内调整学习率,使其在预热阶段后遵循余弦曲线。
在其流行变体中,余弦衰减将学习率减少到接近零,模仿半余弦周期的轨迹。余
弦衰减中的渐进学习率减少旨在减缓模型更新其权重的速度。这一点尤为重要,
因为它有助于最小化训练过程中越过损失最小值的风险,确保训练后期的稳定
性。
我们可以通过添加余弦衰减来修改训练循环模板:
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 step, batch in enumerate(train_loader):
global_step += 1
progress = (global_step - warmup_steps) / (total_steps warmup_steps)
if global_step < warmup_steps:
lr = initial_lr + global_step * lr_increment
else:
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"])
同样,为了验证学习率是否按预期变化,我们绘制学习率:
plt.ylabel("学习率")
plt.xlabel("步骤")
plt.plot(range(total_training_steps), track_lrs)
plt.show()
结果学习率图显示学习率从线性预热阶段开始,在前20个步骤中逐渐增加,直到
在20个步骤后达到最大值。在线性预热20个步骤后,余弦衰减介入,逐渐减少学
习率,直到达到最小值(图D.2)。
图D.2 前20个步骤的线性学习率预热后跟随余弦衰减,该衰减以半余弦周期的方式
逐渐减少学习率,直到在训练结束时达到最小点。
D.3 梯度裁剪
梯度裁剪是增强LLM训练稳定性的另一个重要技术。此方法涉及设置一个阈值,
超过该阈值的梯度将被缩小到预定的最大幅度。这一过程确保了反向传播期间对
模型参数的更新保持在可管理范围内。
例如,在PyTorch的 clip_grad_norm_ 函数中应用 max_norm=1.0 设置,确保梯度的
范数不超过1.0。这里,“范数”表示梯度向量在模型参数空间中的长度或幅度,具
体指L2范数,也称为欧几里得范数。
从数学上讲,对于由分量 v = [v1, v2, …, vn] 组成的向量 v,L2范数为
此计算方法也应用于矩阵。例如,考虑给定的梯度矩阵
如果我们想将这些梯度裁剪到 max_norm 为1,我们首先计算这些梯度的L2范数,
即
由于 |G|2 = 5 超过了我们的 max_norm 1,我们通过缩放因子 max_norm/|G|2 = 1/5 缩
小梯度,使得其范数恰好等于1。因此,调整后的梯度矩阵 G’ 变为
为了说明这个梯度裁剪过程,我们首先初始化一个新的模型,并计算一个训练批
次的损失,类似于标准训练循环中的步骤:
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 属性来识别最高梯度值:
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 训练函数(见第 5 章),通过添加本章介绍
的三个概念:线性预热、余弦衰减和梯度裁剪。这些方法共同帮助稳定大语言模
型 (LLM) 的训练。
代码与 train_model_simple 相比进行了注释说明,如下:
从优化器中获取初始学习率,并假设我们将其用作第 5 章中的峰值学习率。从
chapter05 导入 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
for param_group in optimizer.param_groups:
# 应用计算的学习率到优化器
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:
# 在预热阶段后应用梯度裁剪以避免梯度爆炸
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
tokens_seen += input_batch.numel()
if global_step % eval_freq == 0:
# 与第 5 章中使用的 train_model_simple 函数相比
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}): Train loss
{train_loss:
.3f}, Val loss {val_loss:.3f}")
generate_and_print_sample(model, tokenizer, device, start_context)
return train_losses, val_losses, track_tokens_seen, track_lrs
定义了 train_model 函数后,我们可以以类似于预训练的方式使用它来训练模型:
import tiktoken
torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.to(device)
peak_lr = 5e-4
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)是最广泛使用的参数高效微调技术之一。以下讨论基于第 6 章
给出的垃圾邮件分类微调示例。然而,LoRA 微调也适用于第 7 章讨论的监督指令
微调。
E.1 LoRA 介绍
LoRA 是一种通过仅调整模型权重参数的一小部分,使预训练模型更好地适应特
定、通常较小的数据集的技术。“低秩”是指数学概念中将模型调整限制在总权重
参数空间的一个较小维度子空间中。这有效地捕捉了训练期间权重参数变化的最
有影响力的方向。LoRA 方法因其能够高效地对大型模型进行任务特定数据的微调
而广受欢迎,大大减少了通常所需的计算成本和资源。
假设一个大的权重矩阵 W 与特定层相关联。LoRA 可以应用于 LLM 中的所有线
性层。然而,为了说明目的,我们专注于单个层。
在训练深度神经网络时,反向传播过程中,我们学习一个 ΔW 矩阵,其中包含有
关如何更新原始权重参数以最小化训练期间损失函数的信息。此后,我将“权重”
作为模型权重参数的简写。
在常规训练和微调中,权重更新定义如下:
Hu 等人提出的 LoRA 方法(https://arxiv.org/abs/2106.09685)提供了一种更高效的
替代方法来计算权重更新 ΔW,即学习它的近似值:
其中 A 和 B 是两个远小于 W 的矩阵,AB 表示 A 和 B 之间的矩阵乘积。
使用 LoRA,我们可以重新定义我们之前定义的权重更新公式:
图 E.1 并排展示了完整微调和 LoRA 的权重更新公式。
图 E.1 权重更新方法的比较:常规微调和 LoRA。常规微调直接使用 ΔW 更新预训
练权重矩阵 W(左)。LoRA 使用两个较小的矩阵 A 和 B 来近似 ΔW,其中 AB
的乘积加到 W 上,r 表示内部维度,是一个可调的超参数(右)。
如果您仔细观察,您可能会注意到图 E.1 中完整微调和 LoRA 的可视化表示与前
面展示的公式略有不同。这种差异归因于矩阵乘法的分配律,它允许我们将原始
权重和更新后的权重分开而不是合并它们。例如,在常规微调中,当 x 为输入数
据时,我们可以表达计算如下:
同样,对于 LoRA,我们可以写成:
除了减少训练期间需要更新的权重数量外,保持 LoRA 权重矩阵与原始模型权重
分离的能力使得 LoRA 在实践中更加有用。实际上,这允许预训练模型权重保持
不变,LoRA 矩阵在训练后动态应用,当使用模型时。
保持 LoRA 权重分离在实践中非常有用,因为它可以在不存储多个完整版本的
LLM 的情况下实现模型定制。这减少了存储需求并提高了可扩展性,因为当我们
为每个特定客户或应用程序定制 LLM 时,只需要调整和保存较小的 LoRA 矩阵。
接下来,让我们看看如何使用 LoRA 对 LLM 进行垃圾邮件分类的微调,类似于第
6 章中的微调示例。
E.2 准备数据集
在将LoRA应用于垃圾邮件分类示例之前,我们必须加载我们将要使用的数据集和
预训练模型。这里的代码重复了第6章的数据准备过程。(我们也可以打开并运行
第6章的笔记本,并在其中插入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"
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 个训练批次
19 个验证批次
38 个测试批次
E.3 初始化模型
我们重复第6章的代码以加载和准备预训练的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,
"context_length": 1024,
"drop_rate": 0.0,
"qkv_bias": True # 查询-键-值偏置
}
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},
}
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()
为了确保模型正确加载,让我们再次检查它是否能生成连贯的文本:
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
接下来,我们为分类微调准备模型,类似于第6章,我们替换输出层:
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,该层创建矩阵A和B,以及alpha缩放因子和秩(r)设置。这一层可以
接受输入并计算相应的输出,如图E.2所示。
图E.2 LoRA矩阵A和B应用于层输入,并参与计算模型输出。这些矩阵的内部维度
r作为一个设置,通过调整A和B的大小来改变可训练参数的数量。
在代码中,这个LoRA层可以如下实现。
import math
秩决定了矩阵A和B的内部维度。本质上,这个设置决定了LoRA引入的额外参数
数量,从而在模型的适应性和效率之间取得平衡。
另一个重要的设置是alpha,它作为低秩适应输出的缩放因子。它主要决定了经过
适应后的层输出对原始层输出的影响程度。这可以看作是调节低秩适应对层输出
影响的一种方式。到目前为止,我们实现的LoRALayer类使我们能够转换层的输
入。
在LoRA中,通常的目标是替换现有的Linear层,允许权重更新直接应用于预训练
的权重,如图E.3所示。
图E.3 将LoRA集成到模型层中。原始预训练权重(W)与LoRA矩阵(A和B)的
输出相结合,以近似权重更新矩阵(ΔW)。最终输出是通过将使用LoRA权重的
适应层输出加到原始输出上计算得出的。
为了集成原始的Linear层权重,我们现在创建一个LinearWithLoRA层。该层利用之
前实现的LoRALayer,并设计用于替换神经网络中的现有Linear层,例如GPTModel中的自注意力模块或前馈模块。
清单E.6 用LinearWithLora层替换Linear层
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)
这段代码将标准的Linear层与LoRALayer结合。forward方法通过将原始线性层和
LoRA层的结果相加来计算输出。
由于权重矩阵B(self.B在LoRALayer中)初始化为零值,因此矩阵A和B的乘积结
果为零矩阵。这确保了乘法不会改变原始权重,因为加零不会改变它们。
为了将LoRA应用到之前定义的GPTModel中,我们引入了一个replace_linear_with_lora函数。此函数将模型中所有现有的Linear层替换为新创建的LinearWithLoRA层:
我们现在已实现了所有必要的代码,以将GPTModel中的Linear层替换为新开发的
LinearWithLoRA层,以实现参数高效的微调。接下来,我们将对GPTModel中的多
头注意力、前馈模块和输出层中的所有Linear层应用LinearWithLoRA升级,如图
E.4所示。
图E.4 GPT模型的架构。它突出了模型中被升级为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倍。秩和alpha的默认选择
为16,但通常也会增加秩参数,这会相应地增加可训练参数的数量。Alpha通常选
择为秩的一半、两倍或等于秩。
让我们通过打印模型架构来验证层是否按预期进行了修改:
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%
这些准确率值与第6章中的值相同。这是因为我们初始化LoRA矩阵B为零。因此,
矩阵AB的乘积结果为零矩阵。这确保了乘法不会改变原始权重,因为加零不会改
变它们。
现在让我们进入激动人心的部分——使用第6章的训练函数对模型进行微调。在
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"训练完成,耗时 {execution_time_minutes:.2f} 分钟。")
在训练过程中我们看到的输出是:
Ep 1 (Step 000000): 训练损失 3.820,验证损失 3.462
Ep 1 (Step 000050): 训练损失 0.396,验证损失 0.364
Ep 1 (Step 000100): 训练损失 0.111,验证损失 0.229
训练准确率:97.50% | 验证准确率:95.00%
Ep 2 (Step 000150): 训练损失 0.135,验证损失 0.073
Ep 2 (Step 000200): 训练损失 0.008,验证损失 0.052
Ep 2 (Step 000250): 训练损失 0.021,验证损失 0.179
训练准确率:97.50% | 验证准确率:97.50%
Ep 3 (Step 000300): 训练损失 0.096,验证损失 0.080
Ep 3 (Step 000350): 训练损失 0.010,验证损失 0.116
训练准确率:97.50% | 验证准确率:95.00%
Ep 4 (Step 000400): 训练损失 0.003,验证损失 0.151
Ep 4 (Step 000450): 训练损失 0.008,验证损失 0.077
Ep 4 (Step 000500): 训练损失 0.001,验证损失 0.147
训练准确率:100.00% | 验证准确率:97.50%
Ep 5 (Step 000550): 训练损失 0.007,验证损失 0.094
Ep 5 (Step 000600): 训练损失 0.000,验证损失 0.056
训练准确率:100.00% | 验证准确率:97.50%
训练完成,耗时 12.10 分钟。
使用LoRA微调模型比不使用LoRA(参见第6章)花费的时间更长,因为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从五个批次中近似估计了训练和验证集
的准确率):
最终的准确率值为:
训练准确率:100.00%
验证准确率:96.64%
测试准确率:98.00%
这些结果显示模型在训练、验证和测试数据集上表现良好。训练准确率为100%,
表明模型完美地学习了训练数据。然而,略低的验证和测试准确率(分别为
96.64%和97.33%)表明存在一定程度的过拟合,即模型在未见过的数据上的泛化
能力不如在训练集上好。总体而言,考虑到我们只微调了相对较少的模型权重
(270万个LoRA权重,而不是原始的1240万个模型权重),结果非常令人印象深
刻。
索引
符号
[BOS](序列开始)标记 32
[EOS](序列结束)标记 32
[PAD](填充)标记 32
@ 操作符 261
%timeit 命令 282
<|unk|> 标记 29–31, 34
== 比较操作符 277
数值
04_preference-tuning-with-dpo 文件夹 247
124M 参数 161
355M 参数 227
A
AdamW 优化器 148, 294
AI(人工智能) 252
allowed_max_length 224, 233, 309
Alpaca 数据集 233, 296
alpha 缩放因子 328
架构,Transformer 7–10
argmax 函数 134, 152–155, 190, 277
arXiv 248
assign 实用函数 165
注意力机制 因果 74–82 编码 50, 54 实现带可训练权重的自注意力机制 64–74 多头
注意力 82–91
长序列建模的问题 52 自注意力机制 55–64 注意力得分 57 注意力权重,逐步计算
65–70 attn_scores 71 autograd 引擎 264 自动微分 263–265 引擎 252 偏导数和梯度
263 自回归模型 13 Axolotl 249
B
反向传播 137
.backward() 方法 112, 318
Bahdanau 注意力机制 54
基础模型 7
批归一化层 276
batch_size 233
BERT(双向编码器表示来自变压器) 8
BPE(字节对编码) 32–35
calc_accuracy_loader 函数 192
calc_loss_batch 函数 145, 193–194
calc_loss_loader 函数 144, 194
计算训练和验证 140, 142
CausalAttention 类 80–81, 86, 90 模块 83–84 对象 86
因果注意力掩码 190
因果注意力机制 74–82
cfg 字典 115, 119
分类微调类别 170 准备数据集 172–175 为添加分类头进行微调 183–190 计算分类
损失和准确率 190–194 监督数据 195–200 使用LLM作为垃圾邮件分类器的任务
200 任务 7
classify_review 函数 200
clip_grad_norm_ 函数 317
梯度裁剪 317
数据加载器代码 301
编码注意力机制 54 GPT 模型 117–122
collate 函数 211
计算图 261
compute_accuracy 函数 277–278
计算梯度 258
连接,捷径 109–113
上下文,添加特殊标记 29–32
context_length 47, 95
上下文向量 57, 64, 85
对话性能 236
将标记转换为标记ID 24–29
余弦衰减 313, 316
create_dataloader_v1 函数 39
交叉熵函数 138–139
CUDA_VISIBLE_DEVICES 环境变量 286
custom_collate_draft_1 215
custom_collate_draft_2 218
custom_collate_fn 函数 224, 308
D
数据,使用滑动窗口 35–41
DataFrame 173
数据列表 207, 209
DataLoader 类 38, 211, 224, 270–272
数据加载器 175–181 代码 301 创建用于指令数据集的 224–226 高效的 270–274
Dataset 类 38, 177, 270–272, 274
数据集 下载 207 准备 324 利用大型 10
DDP (DistributedDataParallel) 策略 282
ddp_setup 函数 286
decode 方法 27, 33–34
解码器 52
解码策略以控制随机性 151–159 修改文本生成函数 157 温度缩放 152–155 top-k 抽
样 155
深度学习 253 库 252
destroy_process_group 函数 284
设备变量 224
dim 参数 101–102
DistributedDataParallel 类 284
DistributedSampler 283–284
Dolma:一个用于 LLM 预训练研究的三万亿标记开放语料库 (Soldaini et al.) 11
点积 58
d_out 参数 90, 301
download_and_load_gpt2 函数 161, 163, 182
drop_last 参数 273
dropout 定义 78 层 276
drop_rate 95
.dtype 属性 259
DummyGPTClass 98
DummyGPTModel 95, 97–98, 117
DummyLayerNorm 97, 99, 117 占位符 100
DummyTransformerBlock 97, 117
E
emb_dim 95
嵌入层 161
嵌入大小 46
新兴行为 14
encode 方法 27, 33, 37
编码器 52
编码词位置 43–47
输入字典 209
eps 变量 103
.eval() 模式 126
eval_iter 值 200
evaluate_model 函数 147–148, 196
F
前馈层 267
FeedForward 模块 107–108, 113
前馈网络,使用 GELU 激活实现 105–109
find_highest_gradient 函数 318
微调 类别 170 为指令数据集创建数据加载器 224–226 评估微调后的 LLMs 238–247
提取和保存响应 233–238 用于分类 169 添加分类头 183–190 计算分类损失和准确
性 190–194 数据加载器 175–181 在监督数据上微调模型 195–200 使用预训练权重
初始化模型 181 准备数据集 172–175 将 LLM 用作垃圾邮件分类器 200 指令数据
230–233 指令微调概述 205 LLMs,遵循指令 204 将数据组织成训练批次 211–223
监督指令微调,准备数据集 207–211
FineWeb 数据集 295
first_batch 变量 39
format_input 函数 209–210, 242, 307
forward 方法 97, 109, 267, 330
基础模型 7
全连接层 267
functools 标准库 224
_getitem__ 方法 271 Google Colab 257 GPT-2 94 模型 230 分词器 176 gpt2-medium355M-sft.pth 文件 238 GPT-3 11, 94 GPT-4 239 GPT_CONFIG_124M 字典 95, 97,
107, 116–117, 120, 127, 130 GPTDatasetV1 类 38–39 gpt_download.py Python 模块
161 GPT(生成预训练变换器)8, 18, 93 架构 12–14 编码 117–122 编码架构 93–99
使用 GELU 激活实现前馈网络 105–109 从零开始实现,快捷连接 109–113 从零开
始实现生成文本 92, 122 从零开始实现模型 99–105, 113–116 GPTModel 119,
121–122, 133, 146, 182, 330 类 122, 130, 182, 326 代码 141 实现 166 实例 131, 159,
164–167 GPU(图形处理单元),优化训练性能 279–288 .grad 属性 318 grad_fn 值
268 grad 函数 264 梯度裁剪 313, 317 梯度 263 贪婪解码 125, 152
G
GELU(高斯误差线性单元)105, 107, 293 激活函数 104, 111
GenAI(生成式 AI)3
generate_and_print_sample 函数 147–148, 151, 154
generate 函数 157, 159, 167, 228, 234–235, 237, 305
generate_model_scores 函数 246
generate_simple 函数 157, 159
generate_text_simple 函数 125–126, 131–132, 134, 148, 151–153
生成文本模型,评估 129
信息泄漏 76
_init__ 构造函数 71, 81, 119, 266–267, 271
初始化模型 326
initial_lr 314
init_process_group 函数 284
input_chunk 张量 38
input_embeddings 47
‘input’ 对象 208
指令数据,微调 LLMs 230–233
指令数据集 205
InstructionDataset 类 212, 224, 308
指令微调 7, 170, 322 指令跟随,为指令数据集创建数据加载器 224–226 ‘instruction’ 对象 208 指令-响应对 207 加载预训练的 LLMs 226–229 概述 205
K
keepdim 参数 101
L
LayerNorm 103, 115, 117, 119
层归一化 99–105
学习率热身 313–314
len__ 方法 271
LIMA 数据集 296
线性层 95, 107, 329–330, 332–333
线性层权重 330
LinearWithLoRA 层 330–331, 333
LitGPT 249
LLama 2 模型 141
Llama 3 模型 238
llama.cpp 库 238
LLMs(大型语言模型)17–18 应用 4 构建和使用 5–7, 14 编码架构 93–99 编码注意
力机制,因果注意力机制 74–82 微调 230–233, 238–247, 295 分类微调 183–194, 200
实现 GPT 模型,使用 GELU 激活实现前馈网络 105–109 指令微调,加载预训练的
LLMs 226–229 概述 1–4 预训练 132, 140, 142, 146–151, 159 训练函数 313, 319–321
训练循环,梯度裁剪 317 变换器架构 7–10 利用大型数据集 10 处理文本数据,词
嵌入 18–20
加载,来自 OpenAI 的预训练权重 160–167
load_state_dict 方法 160
load_weights_into_gpt 函数 165–166, 182
逻辑回归损失函数 293
logits 张量 139
LoRALayer 类 329–330
LoRA(低秩适应)247, 322
参数高效的微调 324, 326
loss.backward() 函数 112
损失 140, 142
损失指标 132
lr(学习率)275
M
机器学习 253
《Machine Learning Q and AI》(Raschka)290
macOS 282
main 函数 286
掩码注意力 74
.matmul 方法 261
矩阵 258–261
max_length 38, 141, 178, 306
minbpe 仓库 291
model_configs 表格 164
model.eval() 函数 160
model.named_parameters() 函数 112
model.parameters() 方法 129
model_response 238
model.train() 设置 276
模型权重,在 PyTorch 中加载和保存 159
Module 基类 265
mps 设备 224
mp.spawn() 调用 286
多头注意力 80, 82–91
通过权重分割实现 86–91
堆叠多个单头注意力层 82–85
MultiHeadAttention 类 86–87, 90–91, 292
MultiHeadAttentionWrapper 类 83–87, 90
多层神经网络,实现 265–269
多项式函数 153–155
multiprocessing.spawn 函数 284
multiprocessing 子模块 284
N
NeuralNetwork 模型 284
神经网络 实现使用 GELU 激活的前馈网络 105–109 实现多层神经网络 265–269
NEW_CONFIG 字典 164
n_heads 95
nn.Linear 层 72
nn.Module 71, 97
numel() 方法 120
num_heads 维度 88
num_tokens 维度 88
Ollama 应用 238, 241
Ollama Llama 3 方法 309
ollama run 命令 242
ollama run llama3 命令 240–241
ollama serve 命令 239–242
OLMo 294
一维张量(向量)259
OpenAI,加载预训练权重 160–167
OpenAI 的 GPT-3 语言模型:技术概述 293
optimizer.step() 方法 276
optimizer.zero_grad() 方法 276
out_head 97
输出层节点 183
‘output’ 对象 208
P
参数高效的微调 322 LoRA(低秩适应) 322 准备数据集 324
参数 129 计算 302
参数字典 162, 164–165
偏导数 263
部分函数 224
peak_lr 314
困惑度 139
Phi-3 模型 297
PHUDGE 模型 297
pip 安装器 33
plot_losses 函数 232
plot_values 函数 199
位置嵌入 47
Post-LayerNorm 115
偏好微调 298
Pre-LayerNorm 115
预标记化 212
预训练权重,使用预训练权重初始化模型 181
预训练 7 计算文本生成损失 132 计算训练和验证集的损失 140, 142 解码策略以控
制随机性 151–159 在 PyTorch 中加载和保存模型权重 159 从 OpenAI 加载预训练权
重 160–167 无标签数据上的训练 128 训练 LLMs 146–151 使用 GPT 生成文本 130
print_gradients 函数 112
print_sampled_tokens 函数 155, 304
打印语句 24
Prometheus 模型 297
提示风格 209
.pth 扩展名 159
Python 版本 254
PyTorch 和 Torch 256 自动微分 263–265 计算图 261 数据加载器 210 数据集对象
325 高效的数据加载器 270–274 实现多层神经网络 265–269 安装 254–257 在 PyTorch 中加载和保存模型权重 159 使用 GPU 优化训练性能 279–288 概述 251–257
保存和加载模型 278 训练循环 274–278 理解张量 258–261 类似 NumPy 的 API 258
qkv_bias 95
Q 查询矩阵 88
query_llama 函数 243
query_model 函数 242–243
R
random_split 函数 175
rank 参数 286
原始文本 6
register_buffer 81
re 库 22
ReLU(修正线性单元) 100, 105
.replace() 方法 235
replace_linear_with_lora 函数 330, 332
.reshape 方法 260–261
re.split 命令 22
响应,提取和保存 233–238
检索增强生成 19
r/LocalLLaMA subreddit 248
RMSNorm 292
RNNs(递归神经网络) 52
S
保存和加载模型 278
标量 258–261
缩放点积注意力 64
scaled_dot_product 函数 292
scale 参数 103
sci_mode 参数 102
SelfAttention 类 90
自注意力机制 55–64
为所有输入令牌计算注意力权重 61–64
用可训练权重实现 64–74
没有可训练权重 56–61
SelfAttention_v1 类 71, 73
SelfAttention_v2 类 73
self.out_proj 层 90
self.register_buffer() 调用 81
self.use_shortcut 属性 111
Sequential 类 267
set_printoptions 方法 277
设置字典 162, 164
SGD(随机梯度下降) 275
.shape 属性 260, 271
shift 参数 103
捷径连接 109–113
SimpleTokenizerV1 类 27
SimpleTokenizerV2 类 29, 31, 33
单头注意力,堆叠多个层 82–85
滑动窗口 35–41
softmax 函数 269, 276
softmax_naive 函数 60
SpamDataset 类 176, 178
spawn 函数 286
特殊上下文标记 29–32
state_dict 160, 279
stride 设置 39
strip() 函数 229
监督数据,基于此微调模型 195–200
监督指令微调 205
准备用于监督指令微调的数据集 207–211
监督学习 253
SwiGLU(Swish-gated 线性单元) 105
T
target_chunk 张量 38
目标张量 139
温度缩放 151–152, 154–155
tensor2d 259
tensor3d 259
Tensor 类 258
张量库 252
张量 258–261 常见张量操作 260 标量、向量、矩阵和张量 258–261 张量数据类型
259 三维张量 259 二维张量 259
test_data 集 246
test_loader 272
test_set 字典 237–238
文本补全 205
文本数据 17 添加特殊上下文标记 29–32 将标记转换为标记 ID 24–29 创建标记嵌
入 42–43 编码单词位置 43–47 滑动窗口 35–41 分词,字节对编码 33–35 单词嵌入
18–20
text_data 314
文本生成 122 使用 GPT 生成文本 130
修改文本生成函数 157
文本生成损失 132
text_to_token_ids 函数 131
tiktoken 包 176, 178
.T 方法 261
.to() 方法 259, 280
token_embedding_layer 46–47
标记嵌入 42–43
标记 ID 24–29
token_ids_to_text 函数 131
分词,字节对编码 33–35
分词文本 21–24
top-k 抽样 151, 155–156
torch.argmax 函数 125
torchaudio 库 255
torch.manual_seed(123) 272
torch.nn.Linear 层 267
torch.no_grad() 上下文管理器 269
torch.save 函数 159
torch.sum 方法 277
torch.tensor 函数 258
torchvision 库 255
total_loss 变量 145
ToyDataset 类 271
tqdm 进度条工具 242
train_classifier_simple 函数 197, 200
train_data 子集 143
使用 GPU 优化训练性能 279–288 PyTorch 在 GPU 设备上的计算 279 在多 GPU 机
器上选择可用的 GPU 286–288 单 GPU 训练 280 多 GPU 训练 282–288
组织数据成训练批次 211–223
训练函数 319–321 提升 313 修改 319–321
训练循环 274–278 余弦衰减 316 梯度裁剪 317 学习率预热 314
train_loader 272
train_model_simple 函数 147, 149, 160, 195
train_ratio 142
train_simple_function 305
Transformer 架构 3, 7–10, 55
TransformerBlock 类 115
transformer 块 93, 185 在其中连接注意力和线性层 113–116
.transpose 方法 87
tril 函数 75
U
UltraChat 数据集 297
无偏参数 103
无标签数据,解码策略以控制随机性 151–159
val_data 子集 143
变长输入 142
向量 258–261
.view 方法 87
vocab_size 95
v 向量 317
W
.weight 属性 129, 161
weight_decay 参数 200
权重参数 66, 129
使用预训练权重初始化模型 181 从 OpenAI 加载预训练权重 160–167
权重分割 86–91
W 矩阵 65, 71
Word2Vec 19
单词嵌入 18–20
单词位置,编码 43–47
Wq 矩阵 65, 71, 88
Wv 矩阵 65, 71
X
X 训练示例 268
《深度学习与 Python》,第二版,作者:Francois Chollet
ISBN 9781617296864
504 页,$59.99
2021 年 10 月
《生成式 AI 在行动》,作者:Amit Bahree ISBN 9781633436947 469 页(预
计),$59.99 2024 年 10 月(预计)
《深入浅出机器学习算法》,作者:Vadim Smolyakov
ISBN 9781633439214
328 页,$79.99
2024 年 7 月
相关 MANNING 图书
《深入理解深度学习》
作者:Edward Raff
序言:Kirk Borne
ISBN 9781617298639
600 页,$59.99
2022 年 4 月
《深度学习的数学与架构》
作者:Krishnendu Chaudhury
与 Ananya H. Ashok, Sujay Narumanchi, Devashish Shankar
序言:Prith Banerjee
ISBN 9781617296482
552 页,$69.99
2024 年 4 月
Transformers in Action 作者:Nicole Koenigstein ISBN 9781633437883 393页(预
估),$59.99 2025年2月(预估)
动手项目,学习之道
liveProjects 是一种激动人心的技能开发方式,就像在工作中学习一样。
在 Manning liveProject 中,您将解决一个真实世界的 IT 挑战并找出自己的解决方
案。为了确保您的成功,您将获得90天的完全和无限制访问权限,访问精心挑选
的 Manning 图书和视频资源列表。
以下是 liveProject 的工作原理:
• 可实现的里程碑。每个项目都被分解为步骤和部分,以便您可以跟踪
进度。
• 协作和建议。通过聊天、工作组和同行项目审查与其他 liveProject 参
与者合作。
• 比较您的结果。查看您的工作与 liveProject 创建者的专家实现之间的
对比。
• 一切您需要的成功工具。数据集和精心挑选的学习资源随每个 liveProject 提供。
• 构建您的作品集。所有 liveProjects 教授行业需求的技能。完成后,您
将获得成功的满足感,并拥有一个可以添加到作品集中的真实项目。
访问 www.manning.com 探索数十个数据、开发和云工程 liveProjects!
文本处理步骤在 LLM 上下文中的视图。该过程从输入文本开始,将其分解为标
记,然后转换为数值标记 ID。这些 ID 与标记嵌入相关联,作为 GPT 模型的输
入。模型处理这些嵌入并生成输出文本。最后,输出经过后处理步骤以生成最终
文本。此流程展示了从头开始实现本书中的 GPT 模型的基本操作:分词、嵌入、
转换和后处理。
构建大型语言模型(从零开始)
Sebastian Raschka
物理学家 Richard P. Feynman 据说曾说过:“我无法理解任何我不能构建的东西。”
基于这一强大的原则,畅销书作者 Sebastian Raschka 将逐步引导您构建一个可以
在笔记本电脑上运行的 GPT 风格的 LLM。这是一本引人入胜的书籍,涵盖了从规
划和编码到训练和微调的每个阶段。
《构建大型语言模型(从零开始)》是一次实践性和令人满意的深入生成式 AI 基
础的动手之旅。不依赖任何现有的 LLM 库,您将编写基础模型,将其演变为文本
分类器,并最终创建一个能够遵循对话指令的聊天机器人。而且,因为是您自己
构建的,所以您会真正理解它!
内容包括
• 规划和编码一个类似于 GPT-2 的 LLM
• 加载预训练权重
• 构建完整的训练管道
• 微调您的 LLM 以进行文本分类
• 开发遵循人类指令的 LLMs
读者需要中级 Python 技能和一些机器学习知识。您创建的 LLM 可以在任何现代
笔记本电脑上运行,并可以选择使用 GPU。
Sebastian Raschka 是 Lightning AI 的工作人员研究工程师,他从事 LLM 研究并开
发开源软件。
本书的技术编辑是 David Caswell。
对于纸质书的所有者,所有电子书格式都是免费的:https://www.manning.com/
freebook
非常鼓舞人心!它激励您将新技能付诸行动。
Benjamin Muskalla, GitHub 高级工程师
迄今为止最易懂且最全面的语言模型解释!
其独特而实用的教学风格达到了其他方式无法获得的理解水平。
Cameron Wolfe, Netflix 高级科学家
Sebastian 结合了深厚的理论知识和实际的工程技能,擅长将复杂的想法简化。这
是您需要的指南!
Chip Huyen,《Designing Machine Learning Systems and AI Engineering》的作者
权威且最新的覆盖。强烈推荐! —— Dr. Vahid Mirjalili, FM Global 高级数据科学
家
0
You can add this document to your study collection(s)
Sign in Available only to authorized usersYou can add this document to your saved list
Sign in Available only to authorized users(For complaints, use another form )