Vizuara-从零开始的大语言模型笔记-全-

Vizuara 从零开始的大语言模型笔记(全)

01:系列介绍 🚀

大家好。欢迎来到本系列课程的第一讲,课程名为《从零开始搭建大语言模型》。我是 Raj Danecker 博士。我于 2070 年从印度理工学院马德拉斯分校毕业,获得机械工程学士学位。之后,我在麻省理工学院攻读机器学习博士学位,并于 2022 年毕业。此后我回到印度,我们的使命是让每个人都能接触人工智能。在我们的 YouTube 频道 Vizuara 上,我们已经制作了多个关于机器学习和深度学习的系列课程。我们教学的主要方法或理念是从基础开始教授一切,不做任何假设,并讲解每个概念的核心细节。

制作本系列课程的真实原因是,众所周知,大语言模型和生成式人工智能正在改变我们周围的一切。这个领域的初创公司正在涌现,企业正在转向使用大语言模型处理各种任务,相关工作岗位也在大幅增加。但许多人在学习这个领域时犯了一个常见的错误。他们直接转向应用层面,直接运行一些 Google Colab 笔记本,或跟随某些 YouTube 视频直接运行代码。但很少有人真正理解大语言模型是如何工作的。很少有人真正了解使大语言模型如此强大的核心细节。

如果你能完全从零开始构建一个大语言模型,那不是很棒吗?这难道不会让你对这个主题充满信心吗?目前拥有这种知识的人很少,我正在制作这个 YouTube 系列课程,它将是一个非常全面的系列,向你展示关于从零开始构建 LLM 的一切。

我制作这个系列课程的方式是,从基础开始,以初学者为对象,不做任何假设。在本系列课程结束时,你将完全靠自己成功地从零开始构建一个 LLM。你会发现,在此之后,所有后续内容,所有的应用部分,对你来说都会变得极其简单。

这就是制作这个系列课程的全部理念。制作这个系列需要我们付出巨大的努力,因为正如你将看到的,为了制作每一讲,我们都会制作详细的讲义,我会与你分享这些讲义,并且本系列的所有视频都将完全免费提供。

现在,我将告诉你一些我学习 LLM 的经历。然后,我将告诉你一些关于目前互联网上已有的学习 LLM 的资料,为什么这些资料如此不足,以及我们试图通过这个系列课程做些什么新的事情。让我们开始视频内容。

让我带你回到过去,看看大约在 1960 年代,大语言模型是什么样子,或者说大约 50 到 60 年前自然语言处理领域的情况。这是人类开发的最早的聊天机器人之一,名为 Eliza,它被设计成一名治疗师。让我们看看它是如何工作的。首先,这个界面让我选择语言,我选择英语。然后 Eliza 告诉我:“你好,请告诉我你的问题。” 让我输入:“我正在尝试学习关于大语言模型的知识。但是,我发现这很困难。你能为我提供一些入门资源吗?” 然后 Eliza 问:“是因为你正在尝试学习大语言模型,所以你来找我吗?” 我说:“是的。” 然后 Eliza 说:“你似乎相当积极。” 接着 Eliza 说:“你确定吗?” 你看,这段对话毫无进展。这就是 50 到 60 年前大语言模型的状态。它并不好。

快进到 ChatGPT,你问:“什么是大语言模型?” 或者让我问同样的问题:“我正在尝试学习关于大语言模型的知识。告诉我一些资源。” 然后你会看到 ChatGPT 的回应,它非常有用,切中要害,它给我推荐书籍、在线课程、研究论文,这正是我需要的。这个简单的例子表明,我们生活在一个非常幸运的时代,自然语言处理和大语言模型的研究已经达到了一个阶段,像 GPT 这样的 LLM 非常强大和复杂。如果你不熟悉 ChatGPT,没关系,我们将在本系列课程中构建我们自己的 GPT,你会在学习过程中了解它。但我展示这个演示是为了让你认识到我们现在所处的时代,LLM 已经变得非常强大。这是学习它们的第一个动机。

在我制作这个视频时,还发生了更多事情。Facebook 发布了他们的 Llama 3.1,这是他们迄今为止最强大的大语言模型之一。这是一个开源模型,这意味着模型的整个架构是免费提供的,或者说对公众开放,任何人都可以查看其架构。而 OpenAI 发布的模型通常是闭源的,这意味着他们不发布权重、架构,关于模型本身的许多信息并不为人所知。

这里有一张图表显示了闭源与开源模型的对比。在 2022 年大语言模型领域蓬勃发展时,大多数模型都是闭源的。当 GPT-4 在 2023 年发布时,它震惊了世界,每个人都感到惊讶和高兴,但它仍然是一个闭源模型。现在到了 2024 年,你可以看到开源模型和闭源模型之间的差距正在慢慢缩小,当 Llama 3.1 发布时,你可以看到它的性能与闭源的 GPT-4 处于同一水平。这说明你现在需要的所有信息都可以通过开源模型获得,你只需要愿意学习。如果你不确定什么是开源、闭源模型,什么是 Llama 3、40B、5B,我会在后续课程中向你解释所有这些内容。

文本只是一方面,生成式人工智能作为一个领域还能做很多事情。许多人对什么是生成式人工智能、LLM 到底是什么感到困惑。但生成式人工智能是一个更广泛的子集,它包括语言、视频、音频、3D 模型等所有内容。看看这些视频。这些视频看起来不是非常逼真吗?这个视频,那个视频,海浪视频,这个特定的视频。你会惊讶地发现,所有这些视频都是由人工智能制作的。这些视频不是用摄像机拍摄的,它们是由 AI 生成的。这就是目前生成式人工智能的力量。

最后,当我们与学校合作时,我们开发了自己的 AI 应用程序,这是 Vizuara 的 AI 应用程序,或者说是一个基于 LLM 的应用程序。在这里你可以看到大量的功能,例如,你可以点击“MCQ 生成器”。然后你可以输入主题,比如“重力”,然后点击“生成”。现在你会看到,在几秒钟内,为这个应用程序提供支持的大语言模型就会在这里生成一套多项选择题。我们正在向教师提供这个应用程序。所有这些在四五年前甚至都难以想象,但现在大语言模型和生成式人工智能正在改变我们周围的每一件事。

事实上,对观看此视频的所有人来说,可能更相关的是全球生成式人工智能就业市场。看看这个增长,它令人难以置信。这是预测的就业市场,预计在未来五年内将增长约五到六倍。生成式人工智能和大语言模型是一项非常有用的技能,未来对这种技能的需求只会增加。

这又让我回到了这个问题:如果有人想学习大语言模型,他们该如何着手?

假设你上谷歌搜索“学习构建 LLM”,这意味着你想学习大语言模型,这里会出现许多课程。如果你去查看其中许多课程,比如“构建 LLM 应用程序”,它是关于应用开发的,它并不教你如何从零开始构建大语言模型。

这是另一个课程“掌握大语言模型概念”。如果你看这个课程描述,他们根本不教你如何从零开始构建 LLM,也不教你核心细节,这是一个相当快速的课程。

这也不是我想要的。我想要的是一门课程,它能非常详细和深入地教授基础知识。我想了解基础,因为我想从零开始构建一个 LLM,以提高我的技能,并让我有信心面对生成式人工智能甚至 LLM 的工作面试。这才是我真正想要的。我不想要快速的速成课程,我想要一些深入的内容。

然后我上 YouTube 搜索“从零开始 LLM”。这是第一个出现的视频,或者说是第一个播放列表。你会看到这个播放列表有 18 章,但每一章又只有 10 到 15 分钟。看到这个我又有点泄气,因为我也不是在找这个。我在寻找一个庞大的、深入的技术课程,从最基础开始教我如何构建 LLM。

Andrej Karpathy 有一个构建 GPT 的课程,但如果你看这个课程,它实际上相当复杂,一点也不容易。他直接从一个概念的中间开始讲起,并不适合初学者,而且只有 90 分钟。这也不是我想要的。这是另一位创作者的“从零开始大语言模型”课程,但你看到红色的障碍了吗?我尝试学习这个课程,完成了三个小时,但同样,它解释得不好,不是从零开始解释的。我想制作一门人们能从最基础开始理解的课程,而 YouTube 和谷歌上的课程都无法满足这个需求。

很偶然地,我遇到了 Sebastian Raschka 写的一本书。我认为这是关于大语言模型最好的书之一。我购买了这本书,它将作为我们课程的参考材料。

我打算做的是,将这本 408 页的书中的每一个方面都转换成一系列视频。我可能会在这个播放列表中有 35、40 或 50 个视频。类似于我的机器学习和深度学习播放列表,我会把这里给出的所有内容转换成视频格式。例如,这里有一些我已经开始制作的笔记,我们将在下一讲开始讲解这些内容。看看这些笔记,我在这里所做的是,我从这本书中建立了我的理解,为了将我的理解传递给你们所有人,我已经开始在白板上详细写下每一件事。

看看这个,我试图让它尽可能有趣和基础。所以这将是下一讲“LLM 简介”的内容。我已经完成了这一讲的笔记,也完成了之后“构建 LLM 的各个阶段”这一讲的笔记。基本上,我正在制作所有这些笔记,但同时也在制作视频,这样我也能保持动力和进度。最终,这将是一份可能长达 200 到 300 页的庞大讲义。并且将会有 50 到 60 个基于此的视频。你将从最基础开始学习一切,不做任何假设,所有内容都会详细说明。这套视频非常适合那些认真想转型到大语言模型领域,并想以正确方式理解 LLM 的人。

这就是本课程的主要构想。我想说的另一件事是,你可能听说过像 LangChain 这样的应用程序,这是一个帮助你构建 LLM 应用程序的工具,但同样,如果你不知道如何自己构建 LLM,它就没有用。正如我之前提到的,许多学生直接开始部署应用程序,但我个人认为这不是学习 LLM 的正确方式。你需要知道如何从零开始构建整个大语言模型,我们将在本系列课程中教你这一点。

最终,目标是让你们所有人做好准备,以便在面试官面前充满信心。如果你只是通过克隆某个 GitHub 仓库来部署应用程序,那么如果有人问你关于键、查询和值的详细问题,或者位置嵌入或位置编码,你需要能够说,我是从零开始构建这个的,所以我比任何人都更了解细节。这真的会让你与其他人处于不同的境地。

这就是本课程的主要目标。这将是本课程的大纲,我们将遵循这里提到的所有目录,并将它们转换成视频讲座格式。这将是一个很棒的系列,一个详细的系列。

如果这让你感兴趣,请在评论中告诉我,这样我也会更有动力制作后续视频。正如我提到的,计划是免费发布所有这些内容,以便你们都能从中受益。

非常感谢大家,我期待在下一讲中见到你,我们将介绍大语言模型简介,并涵盖你现在在白板上看到的所有内容。

谢谢大家,下个视频见。

02:大语言模型基础

在本节课中,我们将要学习大语言模型的基础知识。我们将探讨什么是大语言模型、其“大”的含义、它与早期自然语言处理模型的区别、其成功的核心秘诀、相关术语的辨析,以及其广泛的应用场景。

什么是大语言模型?

上一讲我们介绍了整个系列的计划。本节中,我们来看看大语言模型究竟是什么。

大语言模型本质上是一个神经网络,其设计目标是理解、生成和回应类人文本。这里有两个关键点:首先,它是一个神经网络;其次,它专门处理与语言相关的任务。

神经网络的结构通常包含输入层、多个隐藏层和输出层,其灵感来源于人脑的神经元连接。大语言模型就是专门为广泛的文本任务(如问答、翻译、情感分析等)而设计的深度神经网络。

一个直观的例子是 ChatGPT。你可以向它提问,它会像人类一样理解和回应。例如,当你要求它“帮我规划一个放松身心的日子”时,它会询问你的偏好,并生成详细的计划。这展示了大语言模型理解和生成类人文本的能力。

“大”在何处?

了解了基本定义后,你可能会问:为什么叫“大”语言模型?本节中我们来看看这个“大”字意味着什么。

“大”主要指模型的参数量极其庞大。参数是模型在训练过程中学习到的内部变量,其数量决定了模型的复杂度和能力。

大语言模型通常拥有数十亿甚至上万亿个参数。例如,GPT-3 的不同版本参数量从 1.25 亿到 1750 亿不等,而 GPT-4 的参数量更大。相比之下,2020年之前的模型参数量大多在百万或千万级别。

以下是 GPT 系列模型参数量的增长示例(单位:百万/十亿):

  • GPT-1: ~110M
  • GPT-2: ~1.5B
  • GPT-3: 最高达 175B

这种参数规模的爆炸式增长是前所未有的,因此这些模型被称为“大”语言模型。同时,它们专门处理语言任务,故合称为“大语言模型”。

与早期 NLP 模型的区别

我们已经知道大语言模型规模巨大。那么,它与早期的自然语言处理模型有何根本不同呢?

主要有两点核心区别:

  1. 任务通用性 vs. 任务特定性:早期的 NLP 模型通常为特定任务(如翻译或情感分析)专门设计和训练。而现代大语言模型经过训练后,能够执行广泛的 NLP 任务,如翻译、总结、创作、代码生成等,具有极强的通用性。

  2. 能力边界:早期模型难以完成许多对人类而言简单的任务。例如,根据自定义指令起草一封完整的邮件,对过去的模型非常困难,但对现代大语言模型如 ChatGPT 来说则轻而易举。大语言模型的应用可能性远多于早期模型。

成功的秘诀:Transformer 架构

大语言模型功能如此强大,其背后的“秘密武器”是什么?本节我们来揭示这个核心。

大语言模型卓越性能的核心在于其基础架构——Transformer。这不是电影里的变形金刚,而是一种革命性的神经网络设计。

Transformer 架构首次在 2017 年谷歌发表的论文《Attention Is All You Need》中提出。这篇论文彻底改变了自然语言处理领域,至今已被引用超过 10 万次。

Transformer 的核心创新是“自注意力机制”,它允许模型在处理一个词时,权衡句子中所有其他词的重要性。这种机制让模型能更好地理解上下文和长距离依赖关系。

一个简化的 Transformer 编码器层包含以下主要组件:

  • 输入嵌入:将词汇转换为向量。
  • 位置编码:为向量添加位置信息。
  • 多头注意力层:计算自注意力。
  • 前馈神经网络层:进行非线性变换。
  • 残差连接与层归一化:稳定训练过程。

公式 Attention(Q, K, V) = softmax(QK^T / √d_k)V 描述了注意力计算的基本形式,其中 Q(查询)、K(键)、V(值)是输入的不同表示。

正是 Transformer 架构的强大能力,使得训练包含海量参数的模型成为可能,并最终催生了如今的大语言模型。我们将在后续课程中深入探讨这一架构的每一个细节。

术语辨析:AI, ML, DL, LLM, GenAI

面对人工智能领域纷繁复杂的术语,你是否感到困惑?本节我们一起来厘清它们之间的关系。

这些术语可以被看作一组嵌套的集合,范围从宽到窄:

  • 人工智能:最广泛的范畴。任何让机器表现出智能行为(如解决问题、理解语言)的系统都属于 AI。例如,一个基于固定规则的聊天机器人(如航空公司的菜单式客服)就属于 AI,但不一定属于 ML。

  • 机器学习:AI 的一个子集。ML 系统能够从数据中学习并改进其性能,而无需为每个任务明确编程。例如,用于预测心脏病风险的决策树模型就是一个 ML 系统。

  • 深度学习:ML 的一个子集。DL 特指使用深层神经网络进行学习的模型。例如,用于图像识别的卷积神经网络或用于手写数字分类的神经网络都属于深度学习。

  • 大语言模型:DL 的一个子集。LLM 是专门为处理和生成人类语言而设计和训练的深度神经网络。它们只处理文本模态。

  • 生成式人工智能:可以看作是 LLM + 其他模态的深度学习。GenAI 利用深度学习模型(包括 LLM)来创造全新的内容,如文本、图像、音频、视频等。当 LLM 生成文本时,它本身就是 GenAI 的一部分;但 GenAI 的范围更广,还包括像 DALL-E 生成图像这样的应用。

总结一下关系:AI ⊃ ML ⊃ DL ⊃ LLM。而 GenAI ≈ LLM + 其他模态的 DL 应用

大语言模型的应用

掌握了基本概念后,我们来看看大语言模型能做什么。以下是其主要应用类别:

  • 内容创作:生成全新的文本内容,如诗歌、故事、新闻稿、营销文案等。
  • 聊天机器人与虚拟助手:提供智能对话、客户支持、个性化推荐等服务。
  • 机器翻译:在多种语言之间进行高质量、上下文准确的翻译。
  • 文本生成与改写:协助写作、总结长文档、润色文字、改变风格等。
  • 情感分析与内容审核:分析文本情感倾向,检测有害或不当言论。

这些应用正在改变教育、医疗、金融、娱乐等各行各业。例如,教师可以利用 LLM 快速生成课程计划、测验题目;开发者可以借助 LLM 辅助编写和调试代码。

总结

本节课中我们一起学习了大语言模型的基础知识。我们首先定义了大语言模型是用于理解和生成类人文本的深度神经网络。然后,我们探讨了其“大”体现在海量的模型参数上,并比较了它与早期任务特定型 NLP 模型的通用性优势。我们揭示了其成功的核心在于 Transformer 架构,特别是自注意力机制。接着,我们辨析了人工智能、机器学习、深度学习、大语言模型和生成式 AI 这些关键术语之间的层次关系。最后,我们展望了大语言模型在内容创作、对话交互、翻译等多方面的广泛应用。

理解这些基础是深入探索大语言模型内部机制的第一步。在接下来的课程中,我们将逐步深入 Transformer 的细节,并最终动手构建我们自己的模型。请记住,扎实的基础知识是进行创新和解决实际问题的关键。

03:大语言模型的构建阶段——预训练与微调 🏗️

在本节课中,我们将学习构建大语言模型的两个核心阶段:预训练与微调。我们将探讨它们各自的定义、目的、区别以及在实际应用中的重要性。

在之前的课程中,我们介绍了大语言模型的基础知识、其背后的“秘密配方”以及相关术语的区别与应用。本节我们将深入探讨构建LLM的具体步骤。

概述:构建LLM的两大阶段

构建一个大语言模型主要涉及两个阶段:预训练微调。理解这两个阶段对于掌握LLM的工作原理至关重要。

什么是预训练?🤔

预训练是指在大规模、多样化数据集上训练模型的过程。这回答了“像ChatGPT这样的LLM为何能如此有效地与人交互”的问题。

其核心在于,LLM之所以表现优异,是因为它们在海量数据上进行了训练。例如,GPT-3拥有1750亿参数,其训练数据来源广泛。

以下是GPT-3训练数据的主要构成:

  • Common Crawl:一个开放的互联网数据仓库,提供了约4100亿单词(约410亿个句子)。
  • WebText2:一个包含Reddit提交、博客文章、Stack Overflow问答等内容的庞大语料库,提供了约190亿单词。
  • 书籍:从大量书籍中获取了约670亿单词。
  • 维基百科:提供了约30亿单词作为训练数据。

总计,GPT-3在约3000亿个标记(可近似理解为单词)上进行了训练。

预训练的核心任务与“涌现能力”

最初,LLM被训练用于一个看似简单的任务:词语补全。例如,给定句子“The lion is in the ____”,模型需要预测下一个词(如“forest”)。

令人惊讶的是,即使仅为此任务进行训练,模型也展现出执行多种其他任务的强大能力,例如翻译、总结、情感分析和问答。这种能力被称为涌现能力

这意味着,通过预训练,我们得到一个基础模型,它无需针对特定任务进行专门训练,就能广泛处理各种语言任务。

为什么需要微调?🎯

既然预训练模型已经如此强大,为何还需要微调?关键在于特定性与专业性

预训练模型基于通用互联网数据,其回答是通用的。然而,许多实际应用需要特定领域定制化的响应。

以下是需要微调的几个场景:

  • 航空公司客服:用户询问“晚上6点起飞的汉莎航空航班价格是多少?”。通用模型可能无法提供基于该公司实时数据库的准确、具体答案。
  • 高质量教育内容生成:大型教育公司若想生成极高品质的题目,仅依赖通用模型可能不够,需要在其专业题库数据上微调模型以获得更优结果。
  • 专业领域工具:例如为律师设计的AI法律工具,需要理解大量非公开的法律案例历史,这超出了通用预训练模型的知识范围。

微调的本质是:在预训练得到的基础模型之上,使用更窄、更专业、通常带有标签的数据集进行进一步训练,使其适应特定任务或领域。

预训练与微调流程图示 📊

为了更好地理解整个过程,我们可以将其可视化为一个三步流程:

  1. 数据收集:获取海量、多样化的原始文本数据(互联网文本、书籍、媒体、研究文章等)。这是所有步骤的基石。
  2. 预训练/基础模型训练:在未标注的原始文本数据上训练模型,形成基础模型。此步骤计算成本极高(例如GPT-3预训练耗资约460万美元),需要强大的GPU算力。
  3. 微调:在基础模型之上,使用特定任务的标注数据集进行进一步训练,得到微调模型,用于构建具体的应用程序(如个人助理、翻译机器人、摘要工具、分类器)。

关键区别:预训练通常使用未标注数据,而微调通常使用标注数据

构建LLM的步骤总结 📝

以下是构建一个大语言模型的关键步骤总结:

  1. 在大规模原始文本语料库上训练:使用海量、无标签的文本数据。
  2. 预训练阶段:此阶段产出初始的预训练LLM,即基础模型。它具备文本补全能力,并展现出多种涌现能力。
  3. 微调阶段:在获得预训练LLM后,使用标注数据对其进行进一步训练。微调主要分为两类:
    • 指令微调:用于任务如翻译、客服问答,需要“指令-回答”配对数据。
    • 分类任务微调:用于任务如垃圾邮件分类、情感分析,需要“文本-标签”配对数据。

微调对于将LLM投入实际生产环境、满足特定领域的高质量需求至关重要。大型公司或专业应用很少直接使用基础模型,都会进行某种程度的微调。

总结

本节课我们一起学习了构建大语言模型的两个核心阶段。

  • 预训练:在大规模、多样化数据集上训练模型,使其获得通用的语言理解和生成能力,形成基础模型。这个过程计算成本高昂。
  • 微调:在基础模型之上,使用特定领域、带标签的数据进行针对性训练,使模型适应专业化、定制化的任务需求,是模型投入实际应用的关键步骤。

理解预训练和微调的区别与联系,是掌握大语言模型应用和开发的基础。在接下来的课程中,我们将开始学习Transformer架构的基本介绍,并简要了解著名的《Attention Is All You Need》论文。

04:什么是Transformer? 🤖

在本节课中,我们将学习Transformer架构的基础知识。Transformer是现代大语言模型(LLM)背后的核心技术。我们将了解它的起源、核心组件、工作原理,以及它与LLM的关系和区别。课程内容设计得简单易懂,适合初学者。


概述

Transformer是一种深度神经网络架构,于2017年在一篇名为《Attention Is All You Need》的论文中首次提出。这篇论文在短短几年内获得了超过10万次引用,因为它为后续的许多突破性进展奠定了基础,包括GPT架构。最初,Transformer是为机器翻译任务(如英语到德语、法语)设计的,但后来人们发现,基于此架构的变体可以完成更多任务。

上一节我们介绍了LLM构建的两个阶段(预训练和微调),本节中我们来看看实现这些阶段的核心架构——Transformer。


Transformer架构的简化流程

Transformer架构可以简化为八个核心步骤。以下是这八个步骤的概述:

  1. 输入文本:准备待翻译的文本(例如英文句子)。
  2. 预处理(分词):将句子分解成更小的单元(词元)。
  3. 编码器:接收分词后的输入。
  4. 生成向量嵌入:编码器将词元转换为捕捉语义关系的向量。
  5. 部分输出文本:模型已生成的部分翻译结果(例如德文单词)。
  6. 解码器:接收向量嵌入和部分输出文本。
  7. 生成翻译文本:解码器预测下一个输出词元。
  8. 最终输出:得到完整的翻译句子。

接下来,我们将详细探讨其中的关键步骤。


关键步骤详解

1. 分词

在将文本输入模型之前,需要先进行分词处理。这个过程将长句子或文档分解成更小的单元,称为“词元”。

以下是分词过程的简单说明:

  • 你可以暂时将一个词元理解为一个单词。
  • 每个词元会被分配一个唯一的数字ID。
  • 例如,句子 “fine tuning is fun for all” 可能被分解为 [‘fine’, ‘tuning’, ‘is’, ‘fun’, ‘for’, ‘all’] 并赋予对应的ID。

2. 编码器与向量嵌入

编码器的主要任务是将分词后的词元(数字ID)转换为“向量嵌入”。

  • 问题:简单的数字ID无法表达单词之间的语义关系(例如“狗”和“小狗”是相关的)。
  • 解决方案:向量嵌入将每个单词映射到一个高维空间中的向量。语义相近的单词,其向量在空间中的位置也更接近。
  • 例如:在二维示意图中,“国王”、“男人”、“女人”的向量可能聚集在一起;“苹果”、“香蕉”、“橙子”的向量聚集在另一处;“足球”、“高尔夫”、“网球”的向量又聚集在第三处。这样,模型就能“理解”单词之间的关系。

编码器通过训练神经网络来学习如何生成这种能捕捉语义的向量表示。

3. 解码器与自回归生成

解码器是Transformer架构的另一半,负责生成输出文本(如翻译结果)。

  • 输入:解码器接收来自编码器的向量嵌入,以及当前已生成的部分输出文本
  • 过程:模型以自回归的方式工作,即一次只预测并生成下一个词元。
  • 示例:在翻译“This is an example”时,模型可能先生成“Das”(这),然后基于“Das”生成“ist”(是),再基于“Das ist”生成“ein”(一个),如此循环,直到生成完整的德语句子“Das ist ein Beispiel”。
  • 训练:解码器本质上是一个神经网络,通过损失函数进行训练和优化,以更准确地预测下一个词元。

注意力机制:Transformer的核心 🧠

原论文标题“Attention Is All You Need”突出了“注意力机制”的核心地位。

  • 作用:注意力机制允许模型衡量输入序列中不同词元之间的相对重要性。
  • 解决长距离依赖:在预测一个句子后面的词时,可能需要参考句子开头甚至前几个句子的信息。注意力机制使模型能够“关注”到这些远处但相关的词元。
  • 直观理解:就像人类阅读时,会根据上下文重点关注某些关键词来理解全文一样,注意力机制为模型中的每个词元计算一个“注意力分数”矩阵,标明在生成当前词元时,应该给其他词元分配多少“注意力”。

在Transformer架构图中,那些标有“Multi-Head Attention”的模块就是实现这一机制的部件。


Transformer的后续变体:BERT与GPT

基于原始Transformer架构,后续发展出了两个重要的变体:BERT和GPT。

以下是BERT和GPT的核心区别:

  • BERT
    • 全称:Bidirectional Encoder Representations from Transformers。
    • 工作原理:在训练时,随机遮盖句子中的一些词(掩码),然后让模型预测这些被遮盖的词。因为任何位置的词都可能被遮盖,所以模型必须从双向(左右两侧)理解整个句子的上下文。
    • 特点:仅使用Transformer的编码器部分。擅长理解词语间的细微差别和上下文,因此在情感分析等任务上表现优异。
  • GPT
    • 全称:Generative Pre-trained Transformer。
    • 工作原理:接收一段不完整的文本,然后以自左向右的方式,逐个预测生成接下来的词元。它根据左侧已知的文本来推测右侧未知的内容。
    • 特点:仅使用Transformer的解码器部分。是典型的生成式模型,用于文本补全、对话等。

关键区别:BERT是“填空”模型,注重理解;GPT是“续写”模型,注重生成。


Transformer与LLM的关系与区别

最后,我们需要厘清Transformer和大语言模型这两个常被混用的概念。

  • 并非所有Transformer都是LLM
    • Transformer架构具有通用性,不仅可用于处理文本,还可用于计算机视觉任务。例如,Vision Transformer(ViT)在图像分类等任务上取得了显著成果。
  • 并非所有LLM都是Transformer
    • 在Transformer出现之前,循环神经网络(RNN)、长短期记忆网络(LSTM)等架构也用于构建语言模型,完成文本生成和序列建模任务。它们也属于LLM的范畴。

因此,虽然现代主流的LLM大多基于Transformer,但这两个术语在概念上并不等同,不应完全互换使用。


总结

本节课中我们一起学习了Transformer架构的基础知识。

  1. 我们了解到Transformer是现代LLM的基石,源于2017年的论文《Attention Is All You Need》。
  2. 我们通过一个简化的八步流程(输入、分词、编码、嵌入、解码、生成)理解了Transformer如何工作,特别是编码器负责创建语义向量,解码器负责自回归生成文本。
  3. 我们探讨了注意力机制的核心作用,它使模型能够权衡不同词元的重要性并捕捉长距离依赖关系。
  4. 我们区分了Transformer的两个重要变体:BERT(双向编码器,用于理解)和GPT(生成式预训练解码器,用于生成)。
  5. 最后,我们明确了TransformerLLM的关系:Transformer是一种可用于多种任务的架构,而LLM是专注于语言任务的模型,其实现架构可以是Transformer,也可以是RNN、LSTM等。

理解这些基本概念,是深入探索大语言模型世界的重要第一步。

05:GPT-3 究竟如何工作?🤖

在本节课中,我们将详细探讨 GPT(生成式预训练变换器)模型。我们将了解 GPT 各个版本的演变历程,从最初的 Transformer 架构到 GPT、GPT-2、GPT-3,直至当前的 GPT-4。我们还将深入理解一个核心概念:零样本学习与少样本学习的区别。

历史演进:从 Transformer 到 GPT-4 📜

上一节我们介绍了 Transformer 的基本架构。本节中,我们来看看 GPT 系列模型是如何在此基础上发展起来的。

以下是关键研究论文的演进时间线:

  • 2017年:《Attention Is All You Need》

    • 这篇论文引入了自注意力机制,能够捕捉句子中的长距离依赖关系,在预测句子中下一个词的任务上表现出色。
    • 它代表了相较于循环神经网络和长短期记忆网络的重大进步。论文中提出的原始 Transformer 架构包含编码器和解码器。
  • 2018年:《Improving Language Understanding by Generative Pre-Training》(GPT)

    • 这篇在 Transformer 之后发表的论文引入了生成式预训练的概念。
    • 核心思想是无监督学习。论文指出,自然语言处理此前大多依赖监督学习,而标注数据稀缺。他们证明,通过在大量无标注文本语料库上进行生成式预训练,可以在这些任务上实现巨大提升。
    • GPT 架构仅使用了 Transformer 的解码器部分。
  • 2019年:《Language Models are Unsupervised Multitask Learners》(GPT-2)

    • 这篇论文使用了比 GPT 更大量的数据和一个生成式预训练网络。
    • 它发布了四个规模的模型:GPT-2 Small、Medium、Large 和 Extra Large。其中最大的 GPT-2 Extra Large 拥有约 15亿参数,在当时是巨大的模型。
  • 2020年:《Language Models are Few-Shot Learners》(GPT-3)

    • GPT-3 是一个拥有 1750亿参数 的庞然大物。
    • 尽管仅被训练来预测下一个词,但它展现出执行多种任务(如翻译、问答、情感分析)的卓越能力,这主要归功于其少样本学习能力。
  • 2022年至今:GPT-3.5、GPT-4 及以后

    • GPT-3.5 在商业领域变得流行。
    • 当前我们已处于 GPT-4 时代。同时,开源模型(如 Meta 的 Llama 3.1)的性能正在快速追赶甚至超越闭源模型。

核心概念:零样本、单样本与少样本学习 🎯

理解大型语言模型如何执行任务的关键在于区分它们的学习方式。以下是基于 GPT-3 论文的清晰说明:

  • 零样本学习

    • 模型仅根据任务描述生成答案,不提供任何示例。
    • 示例:提示为“将英语翻译成法语:cheese =>”。模型需要直接输出法语翻译。
  • 单样本学习

    • 模型除了看到任务描述,还看到一个任务示例。
    • 示例:提示为“将英语翻译成法语:sea otter => loutre de mercheese =>”。模型参考单个示例进行翻译。
  • 少样本学习

    • 模型看到任务描述和少量(通常为几个)任务示例。
    • 示例:提示为“将英语翻译成法语:sea otter => loutre de merpeppermint => menthe poivréegiraffe => girafecheese =>”。模型参考多个示例进行翻译。

GPT-3 论文的核心主张是,GPT-3 是一个强大的少样本学习器。这意味着提供少量示例能显著提升其在特定任务上的表现。虽然它也能进行零样本学习,但少样本下的准确率通常更高。

当前像 GPT-4 这样的模型,同时具备零样本和少样本学习能力。为了获得更准确的响应,提供少量示例(即使用少样本学习)通常是更佳实践。

训练基石:海量数据集与高昂成本 💰

GPT-3 的强大能力建立在巨大的数据量和计算资源之上。我们来具体看看:

GPT-3 的训练数据混合来源:

  1. Common Crawl:一个免费开放的网页爬虫数据集,包含超过2500亿个页面。GPT-3 使用了其中 4100亿个词元,占总数据集的约60%。
  2. WebText2:一个增强版网络文本语料库,包含大量 Reddit 帖子。GPT-3 使用了其中 190亿个词元,约占22%。
  3. 书籍与维基百科:构成了剩余约18%的数据。

总计,GPT-3 在约 3000亿个词元 上进行了训练。

:词元是模型读取文本的基本单位。为简化理解,目前你可以近似认为 1个词元 ≈ 1个单词。实际的词元化过程更复杂,我们将在后续课程中详述。

预训练成本:训练 GPT-3 模型的总成本高达 460万美元。这反映了处理如此庞大数据和优化1750亿参数所需的巨大计算开销(如GPU集群)。

架构本质:仅解码器的自回归模型 🧱

现在,我们深入 GPT 的架构和工作原理。

GPT 与原始 Transformer 的关键区别

  • 原始 Transformer:包含编码器(用于理解输入)和解码器(用于生成输出)。
  • GPT 架构仅包含解码器堆叠。它是一个简化但规模极大的架构。

GPT 是自回归模型

  • 训练目标:GPT 模型的核心训练任务是预测句子中的下一个词
  • 自回归含义:在生成过程中,模型将之前生成的输出作为后续步骤的输入
  • 无监督学习:训练数据不需要人工标注。标签来自数据本身——句子的下一个词就是需要预测的目标。这种方法称为自监督学习

工作流程示意
假设我们要生成句子“This is an example”。

  1. 迭代 1
    • 输入:“This”
    • 模型处理并预测下一个词:“is”
  2. 迭代 2
    • 输入变为:“This is” (包含了上一次的输出)
    • 模型预测下一个词:“an”
  3. 迭代 3
    • 输入变为:“This is an”
    • 模型预测下一个词:“example”

这个过程不断重复,直到生成完整的序列。正是这种“输出反馈为输入”的机制,使得 GPT 被称为自回归模型

神奇现象:涌现能力 ✨

尽管 GPT 系列模型仅被明确训练用于下一个词预测,但它们却展现出执行多种未经过专门训练的任务的强大能力,例如:

  • 语言翻译
  • 问答
  • 生成选择题
  • 文本总结
  • 编写教案

这种能力被称为涌现能力。其正式定义是:模型执行其未经过明确训练的任务的能力。

涌现能力是如何产生的,目前仍是活跃的研究领域。一种解释是,在预测下一个词的庞大训练过程中,模型隐式地学习到了语言的内在规律、知识和推理能力,从而能够泛化到各种下游任务。

模型生态:预训练、微调与开源闭源 🔄

理解大型语言模型的应用,需要了解以下概念:

  • 预训练:在像 GPT-3 这样的大规模通用数据集上训练模型,得到基础模型。成本高昂,但只需进行一次。
  • 微调:在预训练的基础模型上,使用特定领域(如金融、医疗)的、规模较小的标注数据集进行额外训练。这能使模型在该领域表现更专业、更可靠,是生产环境部署的常见步骤。
  • 开源 vs. 闭源
    • 闭源模型:如 GPT-4,其模型权重和架构细节不公开,用户只能通过 API 接口使用。
    • 开源模型:如 Meta 的 Llama 系列,其模型权重和代码公开,允许研究者和开发者自由使用、修改和研究。近年来,顶级开源模型(如 Llama 3.1)的性能已非常接近甚至在某些方面超越闭源模型。

总结 📝

本节课中我们一起学习了 GPT-3 的核心工作原理及其背景:

  1. 历史演进:回顾了从 Transformer 到 GPT-4 的关键发展节点。
  2. 学习方式:明确了零样本单样本少样本学习的区别,并指出 GPT-3 是优秀的少样本学习器。
  3. 数据与成本:了解到 GPT-3 在约3000亿词元的庞大数据上训练,预训练成本高达460万美元。
  4. 模型架构:理解了 GPT 是仅包含解码器自回归模型,通过下一个词预测进行无监督/自监督学习
  5. 涌现能力:认识了模型能够执行未经过专门训练的任务的神奇现象。
  6. 模型生态:区分了预训练微调,以及开源闭源模型生态。

在下一讲中,我们将开始探讨构建一个大语言模型的完整阶段,并直接从数据预处理环节开始动手编码。

06:构建大语言模型的阶段 🗺️

在本节课中,我们将概述整个系列教程的学习路线图,明确构建大语言模型(LLM)的三个核心阶段。同时,我们也会对之前课程中学到的关键概念进行一次全面的回顾。

概述

到目前为止,我们已经完成了五节理论课程的学习。在上一节课中,我们深入探讨了GPT-3的架构,并回顾了从GPT到GPT-4的演进历程。我们了解到,GPT-3的总训练成本高达约460万美元。

从下一节课开始,我们将进入实践环节,真正动手构建一个大语言模型。本节课旨在提供一个清晰的路线图,说明我们将在本系列中涵盖的所有阶段和内容。

构建LLM的三个阶段

我们将整个学习过程划分为三个阶段。需要说明的是,本系列内容大量借鉴了Sebastian Raschka所著的《从零开始构建大语言模型》一书。

目前,网络上许多教程仅覆盖了部分阶段,且不够深入。本系列计划为每个阶段分配多节课程,以确保您能透彻理解其内部原理。

以下是三个阶段的划分:

第一阶段:理解基础构建模块 🧱

在正式训练大语言模型之前,我们需要掌握其核心构建模块。第一阶段将聚焦于以下三个方面:

  1. 数据预处理与采样
  2. 注意力机制
  3. LLM架构

本阶段的主要目标是理解大语言模型的基本工作原理。

以下是第一阶段将涵盖的具体内容:

  • 数据准备与采样:我们将学习如何对句子进行分词,将文本分解为独立的标记(Token)。接着,学习词向量嵌入,将每个词转换为高维向量,以捕捉词语间的语义关系。例如,“苹果”、“香蕉”、“橙子”的向量在空间中应该彼此靠近。我们还将学习位置编码,以告知模型词语在句子中的顺序信息。最后,学习如何将海量数据集构建成批次,高效地输入模型进行训练。
  • 注意力机制:我们将深入理解Transformer模型中的注意力机制,包括多头注意力、掩码多头注意力等概念,并用代码从零实现它。
  • LLM架构:学习如何堆叠不同的神经网络层来构建大语言模型架构,例如注意力头应该放置在何处。

完成第一阶段后,我们便为模型训练做好了准备。

第二阶段:预训练基础模型 🏋️

第二阶段的核心是预训练。在准备好数据和模型架构后,我们将编写代码,在无标签的数据集上训练我们的大语言模型。这个阶段的成果是得到一个基础模型

本阶段将具体涵盖以下内容:

  • 实现训练循环,按周期(Epoch)处理数据,计算损失梯度并更新模型参数。
  • 进行模型评估,例如通过文本生成进行可视化检查。
  • 实现保存和加载模型权重的功能,以便后续使用或继续训练,这能节省大量计算成本。
  • 学习如何将OpenAI等机构发布的预训练权重加载到我们自己的模型中。

第三阶段:微调与应用开发 🎯

第三阶段的主要目标是微调大语言模型,以构建具体的应用程序。仅仅使用基础模型通常无法满足特定任务的需求。在本系列中,我们将基于预训练好的基础模型,开发两个实际应用:

  1. 垃圾邮件分类器:我们将使用带有“垃圾邮件/非垃圾邮件”标签的数据集对模型进行微调,使其能够自动分类邮件。
  2. 个人助理聊天机器人:我们将构建一个能够根据指令和输入生成相应输出的对话式AI应用。

对于希望成为专业LLM工程师的学习者而言,理解所有三个阶段都至关重要。许多学习者可能只关注第三阶段,直接使用LangChain或Llama等工具部署应用,但对前两个阶段知之甚少。本系列旨在无遗漏地深入讲解每个概念,帮助您建立扎实的知识体系。

核心概念回顾

在进入实践环节之前,让我们回顾一下目前已学习的关键概念:

  1. 大语言模型的影响:大语言模型彻底改变了自然语言处理领域,它们在生成、理解和翻译人类语言方面取得了巨大进步。与以往需要为每个任务单独训练模型不同,LLM具有通用性。
  2. 两阶段训练流程:所有现代大语言模型都遵循两个主要训练步骤:
    • 预训练:在无标签的海量文本数据上训练,得到基础模型。这需要巨大的数据量、算力和资金。
    • 微调:在特定、较小的带标签数据集上对基础模型进行进一步训练,以使其适应具体任务(如垃圾邮件分类)。生产级别的LLM应用通常都需要经过微调。
  3. Transformer架构与注意力机制:大语言模型成功的“秘诀”在于Transformer架构,其核心是注意力机制。注意力机制允许模型在生成输出时,有选择地关注输入序列中的所有部分,从而理解上下文的重要性。原始的Transformer(2017年)包含编码器和解码器,而生成式预训练Transformer(GPT,2018年)仅使用了解码器架构。
  4. 涌现能力:大语言模型仅被训练来预测下一个词。但令人惊讶的是,它们发展出了涌现能力,即能够执行文本分类、语言翻译、文本摘要等它们并未被直接训练过的任务。这使得它们能够广泛应用于各种场景。

总结

本节课我们一起梳理了构建大语言模型的完整路线图,包括理解基础模块预训练基础模型微调开发应用三个阶段。我们也回顾了大语言模型的核心价值、两阶段训练流程、Transformer架构的基石——注意力机制,以及神奇的涌现能力。

从下一节课开始,我们将正式进入第一阶段,从“处理文本数据”入手,开始实际的编码工作。我们将结合白板讲解和Jupyter Notebook代码演示,确保您能同时理解理论和实践。

07:用Python从零编写LLM分词器

在本节课中,我们将学习大语言模型数据预处理流程中的第一步:分词。我们将理解分词的核心概念,并用Python从零开始构建一个完整的分词器,包括编码器和解码器。

概述:什么是分词?

大语言模型本质上是神经网络,需要处理大量文本数据。在训练之前,文本数据必须经过预处理。分词就是这个预处理流程中的第一步。简单来说,分词是将句子分解成单个单词或子词的过程。但为了实际构建大语言模型,我们需要更深入地理解其细节。

分词的两个主要步骤

分词过程可以大致分为两个核心步骤:

  1. 将文本拆分成独立的单词或子词标记。
  2. 将这些标记转换为对应的标记ID。

本节课我们将重点学习这两个步骤。后续还有一个将标记ID转换为向量表示的步骤,这属于词嵌入的范畴,我们将在单独的课程中讨论。

第一步:将文本拆分为标记

上一节我们介绍了分词的两个核心步骤。本节中,我们来看看如何具体实现第一步:将文本拆分为标记。

我们将使用Python的re(正则表达式)库来实现一个基础的分词器。为了演示,我们将使用Edith Wharton的短篇小说《The Verdict》作为数据集。

首先,我们需要下载并加载数据。

# 下载并读取文本文件
with open('the-verdict.txt', 'r', encoding='utf-8') as f:
    raw_text = f.read()

print(f"总字符数: {len(raw_text)}")
print(f"前100个字符: {raw_text[:100]}")

接下来,我们需要设计一个方案来将这段文本拆分成标记。一个简单的方法是使用正则表达式在空格和标点符号处进行分割。

以下是实现分词的关键代码:

import re

def simple_tokenizer(text):
    # 在空格、逗号、句号等标点处分割文本
    split_pattern = r'([,.:;?!_\"\'\(\)\-\–])|\s+'
    parts = re.split(split_pattern, text)
    # 过滤掉空字符串和None值(例如分割产生的空格)
    tokens = [item for item in parts if item and not item.isspace()]
    return tokens

# 应用分词器到整个文本
preprocessed = simple_tokenizer(raw_text)
print(f"标记总数: {len(preprocessed)}")
print(f"前30个标记: {preprocessed[:30]}")

这段代码首先使用re.split函数,根据我们定义的正则表达式模式(匹配各种标点符号和空白字符)来分割文本。然后,通过列表推导式过滤掉空白字符,最终得到一个纯净的标记列表。

关于是否保留空格的说明:在我们的简单分词器中,我们选择移除空格以简化处理并减少内存占用。但在实际应用中,例如训练处理Python代码的模型时,空格(缩进)具有重要含义,可能需要保留。这取决于具体的应用需求。

第二步:将标记转换为标记ID

我们已经成功将文本拆分为独立的标记。本节中,我们将学习如何将这些标记转换为模型可以处理的数字形式,即标记ID。

神经网络处理的是数字,因此我们需要为每个唯一的标记分配一个唯一的整数ID。为此,我们首先需要构建一个词汇表

词汇表是所有唯一标记的列表,通常按字母顺序排序。每个唯一的标记都会映射到一个唯一的整数ID。

# 从标记列表中获取所有唯一标记,并排序以创建词汇表
all_words = sorted(set(preprocessed))
vocab_size = len(all_words)
print(f"词汇表大小: {vocab_size}")

# 创建标记到ID的映射字典(词汇表)
str_to_int = {word: i for i, word in enumerate(all_words)}
# 同时创建ID到标记的反向映射,用于后续解码
int_to_str = {i: word for word, i in str_to_int.items()}

# 查看词汇表前几项
print("词汇表示例(前10项):", list(str_to_int.items())[:10])

在上面的代码中,str_to_int字典就是我们的词汇表,它将每个标记字符串映射到一个整数ID。int_to_str是反向字典,在需要将ID转换回文本时非常有用。

构建完整的分词器类

前面我们分别实现了文本拆分和ID转换。现在,我们将它们整合到一个完整的、可复用的分词器类中。这个类将包含encode(编码)和decode(解码)两个核心方法。

encode方法接收文本,输出标记ID列表。decode方法则相反,接收标记ID列表,尝试还原出原始文本。

class SimpleTokenizerV1:
    def __init__(self, vocab):
        """初始化分词器,传入词汇表(str_to_int字典)"""
        self.str_to_int = vocab
        self.int_to_str = {i: s for s, i in vocab.items()}

    def encode(self, text):
        """将文本编码为标记ID列表"""
        # 使用之前定义的分词逻辑
        tokens = simple_tokenizer(text)
        # 将每个标记转换为其ID,假设所有标记都在词汇表中
        ids = [self.str_to_int[token] for token in tokens]
        return ids

    def decode(self, ids):
        """将标记ID列表解码回文本"""
        # 将ID转换回标记
        tokens = [self.int_to_str[i] for i in ids]
        # 将标记连接成字符串,并简单处理标点前的空格
        text = ' '.join(tokens)
        # 修复标点符号前的多余空格,例如 "hello , world" -> "hello, world"
        import re
        text = re.sub(r'\s+([,.?!:;\'\"])', r'\1', text)
        return text

# 使用我们之前构建的词汇表实例化分词器
tokenizer = SimpleTokenizerV1(str_to_int)

# 测试编码和解码
sample_text = "It is the last, he painted, you know, Mrs. Gisburn said with pardonable pride."
encoded_ids = tokenizer.encode(sample_text)
print("编码后的ID:", encoded_ids)
decoded_text = tokenizer.decode(encoded_ids)
print("解码后的文本:", decoded_text)

处理未知词汇与特殊标记

我们构建的基础分词器有一个明显的问题:如果输入的文本包含词汇表中不存在的单词(未知词),编码过程会抛出KeyError。在实际的大语言模型中,训练数据极其庞大和多样,以尽量减少这种情况,但仍需有机制来处理它。

此外,当训练数据由多个独立文档(如不同的书籍、文章)拼接而成时,我们通常需要在文档之间插入特殊的标记来标识边界。

以下是两种重要的特殊标记:

  1. <UNK> (Unknown): 用于替换词汇表中不存在的单词。
  2. <EOT> (End of Text): 用于标记一个独立文本段落的结束。

让我们创建一个增强版的分词器来处理这些情况。

首先,我们需要扩展词汇表,加入这些特殊标记。

# 在原有唯一词汇列表中添加特殊标记
special_tokens = ['<EOT>', '<UNK>']
all_words_with_special = all_words + special_tokens

# 重新创建映射字典
str_to_int_v2 = {word: i for i, word in enumerate(all_words_with_special)}
int_to_str_v2 = {i: word for word, i in str_to_int_v2.items()}

print(f"扩展后的词汇表大小: {len(str_to_int_v2)}")
print("特殊标记的ID:", str_to_int_v2['<EOT>'], str_to_int_v2['<UNK>'])

现在,我们基于新的词汇表构建第二代分词器。

class SimpleTokenizerV2:
    def __init__(self, vocab):
        self.str_to_int = vocab
        self.int_to_str = {i: s for s, i in vocab.items()}
        self.unk_token_id = vocab.get('<UNK>', len(vocab)-1) # 获取UNK的ID

    def encode(self, text):
        tokens = simple_tokenizer(text)
        # 处理未知词:如果标记不在词汇表中,则使用<UNK>的ID
        ids = [self.str_to_int.get(token, self.unk_token_id) for token in tokens]
        return ids

    def decode(self, ids):
        tokens = [self.int_to_str.get(i, '<UNK>') for i in ids]
        text = ' '.join(tokens)
        import re
        text = re.sub(r'\s+([,.?!:;\'\"])', r'\1', text)
        # 也可以选择在解码输出中移除或保留特殊标记的显示
        return text

# 实例化新版分词器
tokenizer_v2 = SimpleTokenizerV2(str_to_int_v2)

# 测试包含未知词和多个文本段的输入
text_source1 = "Hello, do you like tea?"
text_source2 = "In the sunlit terraces of the palace."
# 在输入中显式添加<EOT>标记来分隔文本段
combined_text = text_source1 + " <EOT> " + text_source2

encoded_ids_v2 = tokenizer_v2.encode(combined_text)
print("编码ID (V2):", encoded_ids_v2)
decoded_text_v2 = tokenizer_v2.decode(encoded_ids_v2)
print("解码文本 (V2):", decoded_text_v2)

在这个版本中,encode方法使用.get(token, self.unk_token_id)来安全地查找标记ID,如果标记不存在,则返回<UNK>的ID。decode方法也做了类似的安全处理。

关于其他特殊标记的说明:除了<UNK><EOT>,其他常见的特殊标记还包括<BOS>(序列开始)、<EOS>(序列结束)和<PAD>(填充)。填充标记在批量训练中用于将不同长度的文本统一到相同长度。值得注意的是,像GPT这样的模型为了简洁,通常只使用<EOT>标记,并且它们通过下一节课将要学习的字节对编码技术来从根本上减少未知词的出现,而不是依赖<UNK>标记。

总结

本节课中,我们一起学习了构建大语言模型的关键预处理步骤——分词。我们主要涵盖了两个核心步骤:

  1. 文本拆分:使用Python的re库,通过正则表达式将原始文本分割成独立的单词和标点符号标记。我们讨论了处理空格和各类标点的策略。
  2. 标记转ID:通过构建词汇表(一个从唯一标记到唯一整数的映射字典),将文本标记转换为模型可处理的数字ID。

我们不仅理解了概念,还动手实践,从零开始编写了两个版本的分词器类:

  • SimpleTokenizerV1实现了基础的编码和解码功能。
  • SimpleTokenizerV2通过引入<UNK>(未知)和<EOT>(文本结束)等特殊标记,增强了分词器的鲁棒性,使其能够处理未见过的词汇和多个文本段落。

最后我们提到,实际中GPT等先进模型使用字节对编码这种更复杂的分词方案,它通过将单词拆分为子词来更高效地处理词汇,这将是下节课的重点。

通过本课的学习,你应该对分词在大语言模型流水线中的作用有了扎实的理解,并掌握了从零实现一个基础分词器的能力。

08:GPT分词器与字节对编码

在本节课中,我们将要学习一个非常重要的主题——字节对编码。在现代大语言模型(如GPT-2、GPT-3)的背后,其使用的分词器通常就是字节对编码。理解其含义和工作原理对于掌握现代大语言模型至关重要。

分词算法概述

上一节我们实现了一个简单的分词方案。本节中,我们来看看更复杂的分词方案——字节对编码分词器。

分词算法主要分为三种类型:基于词的分词、基于子词的分词和基于字符的分词。

基于词的分词

在基于词的分词中,句子中的每个词通常被视为一个独立的标记。例如,句子“The fox chased the dog”会被分词为:[“The”, “fox”, “chased”, “the”, “dog”]

以下是基于词的分词面临的主要问题:

  • 词汇表外词问题:当用户输入一个不在训练词汇表中的词时,分词器难以处理。
  • 语义相似性丢失:例如,“boy”和“boys”在语义上高度相关,但会被视为两个完全独立的标记,模型无法捕捉其关联性。

基于字符的分词

在基于字符的分词中,每个字符被视为一个独立的标记。例如,“my hobby is playing cricket”会被分解为单个字符:[“m”, “y”, “h”, “o”, “b”, “b”, “y”, …]

基于字符的分词优缺点如下:

  • 优点:词汇表极小(例如英语仅约256个字符),几乎不存在词汇表外词问题。
  • 缺点
    1. 词的含义完全丢失。
    2. 分词后的序列长度远长于原始文本(例如“dinosaur”一词会被分成8个标记)。

基于子词的分词

基于子词的分词结合了上述两种方法的优点。字节对编码就是一种基于子词的分词算法。

其核心规则有两条:

  1. 对于数据集中频繁出现的词,保留其作为完整标记。
  2. 对于出现频率较低的罕见词,将其拆分为更小的、有意义的子词。

这种方法的优势在于:

  • 能帮助模型学习具有相同词根的词(如“token”, “tokens”, “tokenizing”)之间的关联。
  • 能帮助模型识别常见的词缀(如“-ization”),理解其语法功能。

字节对编码算法详解

字节对编码最初是一种数据压缩算法,发布于1994年。其核心思想是:迭代地寻找数据中最常见的连续字节对,并用一个未在数据中出现的新字节替换它

让我们通过一个例子来理解这个过程。假设原始数据为:

aaabdaaabaac
  1. 第一次迭代:最常见的字节对是“aa”,出现4次。我们用一个新字节“Z”替换它。数据变为:ZabdZabac
  2. 第二次迭代:现在最常见的字节对是“ab”,出现2次。我们用新字节“Y”替换它。数据变为:ZYdZYac
  3. 后续迭代:可以继续合并“ZY”等,直到没有字节对出现超过一次。

这个压缩过程就是编码。对于大语言模型,我们稍微修改这个算法:不是用新字节替换常见字节对,而是将它们合并成一个新的子词标记

BPE在大语言模型中的应用实践

现在,我们来看BPE如何应用于大语言模型的分词。我们将通过一个具体例子,展示如何从一组单词构建子词词汇表。

假设我们的数据集包含以下单词及其频率:

  • old (出现7次)
  • older (出现3次)
  • finest (出现9次)
  • lowest (出现4次)

首先,我们在每个词末尾添加一个特殊的结束标记(如 </w>),然后将其拆分为字符,并统计频率。

初始状态(字符级)
字符及其频率列表大致如下:o:14, l:14, d:10, e:16, r:3, f:9, i:9, n:9, s:13, t:13, w:4, </w>:23, …

迭代过程

  1. 找到最常见的字节对 es(共出现13次)。合并它们,创建新子词 es。更新频率(es单独出现的次数减少)。
  2. 接下来,常见字节对变为 est(出现13次)。合并为 est
  3. est 后常跟 </w>(出现13次)。合并为 est</w>。这个子词捕获了“finest”和“lowest”的共同后缀。
  4. 另一常见字节对 ol(出现10次)。合并为 ol
  5. old 是常见对(出现10次)。合并为 old。这个子词捕获了“old”和“older”的共同词根。

最终词汇表
经过多轮迭代合并后,我们得到的子词词汇表可能包含:old, est</w>, er</w>, f, i, n, l, o, w, </w>, … 等。像“older”这样的词会被分词为 [“old”, “er</w>”]

算法停止条件
通常,当合并达到预设的词汇表大小(例如GPT-2使用50257)或迭代次数时停止。

使用代码实现BPE分词器

理解了原理后,我们来看看如何用代码使用BPE分词器。我们将使用OpenAI开源的 tiktoken 库。

import tiktoken

# 实例化GPT-2使用的BPE分词器
tokenizer = tiktoken.get_encoding(“gpt2”)

# 编码文本
text = “Hello! Do you like tea?<|endoftext|>In the sunlit terraces of someunknownplace.”
token_ids = tokenizer.encode(text)
print(“Token IDs:”, token_ids)

# 解码回文本
decoded_text = tokenizer.decode(token_ids)
print(“Decoded text:”, decoded_text)

运行代码,我们可以观察到:

  1. 特殊标记 <|endoftext|> 被编码为一个特定的ID(如50256),它通常是词汇表中的最后一个ID。GPT-2的词汇表大小正是50257。
  2. 生造词“someunknownplace”被成功编码和解码,没有引发错误。这是因为BPE分词器可以将其分解为已知的子词或字符序列(例如 [“some”, “unknown”, “place”] 的子词组合)。

这展示了BPE分词器的关键优势:通过子词和字符的灵活组合,有效处理词汇表外的未知词

总结

本节课我们一起学习了字节对编码。我们从分词算法的三种基本类型(词、字符、子词)入手,分析了各自的优缺点。然后,我们深入探讨了BPE算法的起源及其作为数据压缩算法的核心思想。通过一个详细的例子,我们逐步演示了BPE如何通过迭代合并最常见字节对来构建一个能捕获词根和词缀信息的子词词汇表。最后,我们使用 tiktoken 库实践了GPT-2的BPE分词器,验证了其处理未知词汇的能力。

BPE分词器因其能平衡词汇表大小、捕捉语义关联以及优雅处理未知词,成为GPT系列等现代大语言模型的标准选择。

09:创建输入-目标数据对

在本节课中,我们将学习如何为大型语言模型的训练准备数据。具体来说,我们将学习如何将文本数据转换为模型可以理解的输入-目标数据对。这是模型训练前数据预处理的关键一步。

在上一讲中,我们深入了解了字节对编码算法。我们看到了该算法如何用于子词分词,并比较了基于词、子词和字符的分词方法。我们还详细了解了GPT系列模型如何使用字节对编码进行分词。

到目前为止,我们已经了解了大型语言模型所需的分词步骤。如果我们回顾整个流程,目前我们正处于数据预处理阶段。在将数据送入LLM训练之前,预处理的第一步是分词,接下来是向量嵌入,然后才将这些向量嵌入送入训练过程。

在进入向量嵌入之前,有一个非常重要的主题需要覆盖,那就是今天课程的内容:创建输入-目标数据对。如果你观察其他机器学习任务,如分类或回归,输入和输出通常非常明确。例如,在猫狗图像分类中,猫狗图像是输入,而“猫”或“狗”的标签是输出。在回归问题中,房屋面积是输入,价格是输出。对于大型语言模型,我们使用一种特定的技术来创建这些数据对,理解这项技术非常重要,因此我们专门用一节课来讲解。

理解输入-目标数据对

首先,当我们说输入-目标数据对时,我们指的是什么?它们看起来是怎样的?

假设我们有一个句子:“LLMs learn to predict one word at a time”。标记为蓝色的部分将是LLM的输入,标记为红色的部分将是LLM需要学习预测的目标或输出。

为什么会有不同的行?这些是不同的迭代步骤。让我们看第一次迭代:输入是“LLMs”,基于此输入,LLM需要学习的输出是“learn”。下一个词始终是输出。预测之后的内容被屏蔽或不显示给LLM。这是第一次迭代的情况。

现在看第二次迭代:“learn”这个在第一次迭代中是输出的词,现在成为了输入的一部分。因此,在第二次迭代中,“LLMs learn”是输入,“to”是目标。在第三次迭代中,“to”成为了输入的一部分,“LLMs learn to”是输入,“predict”是输出。

希望你现在已经开始理解这个模式了。在每次迭代中,只有下一个词是输出,而它之前的所有内容都是输入。这些就是输入-目标数据对。记住这一点非常重要。在迭代过程的每个阶段,LLM的输入是句子中直到需要预测的词为止的部分,而需要预测的那个词就是输出。

这个图示仅用于说明目的。在今天的课程中,我们还将学习上下文长度的概念,它指的是作为输入给出的词的数量。输出长度始终是一个词,但我们可以选择输入的上下文长度。

在每次迭代中,目标之后的词本质上被屏蔽了,因此LLM无法访问目标之后的词。这里有两件事需要记住:第一,在句子内部,我们将句子分解为输入和下一个词作为目标;第二,在后续迭代中,前一次迭代的输出会成为下一次迭代的输入。因此,这是一个自回归模型。之所以称为自回归,是因为第一次迭代的输出成为了下一次迭代的输入。这也被称为自监督学习,或者你可以将其视为无监督学习,因为我们没有手动标注输入和输出,句子结构本身就被用来确定什么是输入和输出。在猫狗分类中,我们必须手动标注“这是猫”或“这是狗”,但在这里创建输入-目标对时,我们不需要特别标注,只需编写一个简单的代码,利用句子结构本身将句子分解为输入和输出。因此,这也是无监督学习的一个例子,并且被称为自回归。希望你已经理解了这两个概念。

在预训练中,我们总是进行无监督学习,因为句子结构被用来创建输入-输出对或输入-目标对。

希望你已经理解了输入-目标对的样子,我们将在今天的课程中用Python创建它们。如果你理解到这里,用Python编码实际上相当容易,但我发现学生们通常不能直观地理解这部分内容,因此会觉得编码部分有些困难。

核心概念总结

现在,我想总结一下到目前为止解释的所有内容。

第一,我们在这里本质上所做的是:给定一个文本样本,我们基于该文本样本提取输入块,这些输入块作为LLM的输入。LLM在训练期间的预测任务是预测跟随输入块的下一个词。例如,如果你看这个输入块,LLM的任务是基于此输入预测输出或下一个词。这就是LLM被训练的目的。

最后一点要记住的是,在训练过程中,我们将屏蔽目标之后的所有词。例如,在这个迭代中,“to”是目标。当我们进行这个迭代时,LLM看不到“to”之后的任何内容,因此这部分本质上被屏蔽了。我们将看到如何在代码中实现所有这些功能。

到目前为止,我只是想解释今天课程的目的和目标,现在我们将用Python编写代码来创建输入-目标对。希望你已准备好进行编码,让我们开始吧。

编码实现输入-目标对

在这个编码部分,我将标题定为“创建输入-目标对”。和往常一样,我将与你分享这个Jupyter笔记本代码以及视频,以便你可以运行代码并自己检查是否理解了概念。

在本节中,我们将实现一个数据加载器,使用滑动窗口方法获取输入-目标对。这句话有两个部分可能会让你困惑:什么是数据加载器?什么是滑动窗口方法?别担心,我会详细解释这两者。

首先,我们将使用整个短篇小说《判决》作为数据集。请记住,我们整个编码旅程、整个播放列表的数据集都是这个短篇小说《判决》。这是一个玩具数据集,但它很重要,因为我们现在学到的一切都可以以完全相同的方式扩展到更大的数据集。

我们将使用这个数据集,并记住在上一讲中,我们了解了字节对编码分词器。我们将使用字节对编码分词器对整个文本进行编码。这是一个子词分词器,因此标记可以是字符、词或子词。如果你不熟悉字节对编码器,请查看我们之前覆盖的课程。

我们已经定义了分词器,即字节对编码分词器。我们要做的第一件事是读取整个数据集并将其存储在名为raw_text的变量中,然后对整个原始文本进行编码。请记住,编码器的作用是获取文本并将其转换为标记ID。

我现在运行了这段代码,你会看到我打印出了编码文本的长度,是5145。这意味着我们的词汇表大小是5145。词汇表本质上看起来像这样:一个将标记映射到标记ID的字典。由于我们使用字节对编码器,标记可能不是词,也可能是子词或字符。因此,大小5145表明,我们数据集的词汇表长度为5145,意味着我们有5145个标记及其对应的标记ID。

执行上面的代码将返回5145,这是应用字节对编码分词器后训练集中的标记总数。

创建简单的输入-输出对

现在,我将向你演示如何创建简单的输入-输出对。首先,我将从数据集中移除前50个标记,以便演示更清晰。移除初始的50个标记后,会得到一个稍微更有趣的文本段落。你也可以保留所有标记。为了使课程更有趣,我将定义一个名为encoded_corpus_sample的新变量,它只是从数据集中移除前50个标记。

首先,我希望你在这里暂停一下,自己思考这个问题:假设你现在被给予这个数据集,并且我希望你理解了刚才提到的输入-输出目标对。你能想到的最简单的方法是什么?如何将这个数据集转换成这样的输入-输出目标对?你需要做什么来进行这种转换?你能稍微思考一下吗?想想最简单的方法,不要考虑复杂的算法。你能想到的最简单的事情是什么?你可以在这里暂停视频一段时间,因为如果你能回答出来,它将真正提高你的理解。

现在让我揭示答案。为下一个词预测任务创建输入-目标对的最简单、最直观的方法之一是创建两个变量x和y,其中x包含输入标记,y包含目标,目标本质上是输入向右移动1位。让我解释一下这个逻辑是如何工作的。

我们到底需要什么?假设我的输入是[1, 2, 3, 4],我希望我的输出数组看起来像[2, 3, 4, 5]。我在这里所做的就是:如果1是输入,2应该是输出。这和我们之前的例子很相似:如果“LLM”是输入,“learn”应该是输出。如果[1, 2]是输入,那么3应该是输出,这意味着如果“LLMs learn”是输入,那么“to”应该是输出。如果[1, 2, 3]是输入,这意味着如果“LLMs learn to”是输入,那么输出应该是4,即“predict”应该是输出。最后,如果[1, 2, 3, 4]这四个词都是输入,那么5应该是输出,这意味着如果“LLMs learn to predict”是输入,那么输出应该等于“one”。这就是我想要创建的。

我如何确定这个的大小?为什么我取输入大小x为4,输出大小y为4?这基本上就是上下文大小。上下文大小是你希望作为输入提供给模型以进行预测的词的数量。这里上下文大小等于4。因此,如果我们给出最多四个词,模型将能够预测下一个词。这就是上下文大小的实际含义。

我们想要创建像这样的输入-输出数组。让我展示如何为我们看到的《判决》数据集创建这些数组。首先,我们必须确定上下文大小。正如我告诉你的,上下文大小决定了输入中包含多少标记。让我在这里更详细地解释一下上下文大小。目前我们选择上下文大小为4,你可以选择任何你想要的,并且当我与你分享代码时,你可以尝试修改它。上下文大小为4意味着模型被训练为查看四个词或标记的序列来预测序列中的下一个词。因此,输入x是前四个标记,比如[1, 2, 3, 4],目标y是接下来的四个标记,即[2, 3, 4, 5]。

这就是上下文大小的含义。如果输入是[1, 2, 3, 4],输出是[2, 3, 4, 5]。这意味着如果输入是[1],输出将是[2];如果输入是[1, 2],输出将是[3];如果输入是[1, 2, 3],输出将是[4];如果输入是[1, 2, 3, 4],输出将是[5]。但输入不能是[1, 2, 3, 4, 5],因为那样会超出上下文大小。直观地理解,上下文大小基本上是模型在预测下一个词时一次应该关注多少个词。

现在让我们看一个简单的例子。我们有这个encoded_sample,它包含编码数据集的标记ID。我首先要做的是取前四个元素,那是我的x,也就是输入。然后我将这个x数组向右移动1位,那就是我的y,也就是输出。让我们打印出x,这是与前四个编码样本关联的ID:[290, 4920, 2241, 287]。然后与y关联的ID是:[4920, 2241, 287, 257]。这意味着什么?如果输入ID是290,输出将是4920;如果输入是[290, 4920],输出是2241;如果输入是[290, 4920, 2241],输出将是287;如果输入是[290, 4920, 2241, 287],输出将是257。输入-输出对就是这样构建的。

我们现在可以处理输入和目标,记住目标只是输入向右移动一个位置。然后我们可以如下创建下一个词预测任务。我刚刚向你解释的内容已经写在了代码中。我创建了两个变量contextdesired,并在循环中处理。上下文大小是4,所以这个循环将从1到5。当i等于1时,即第一次迭代,context将只是第一个标记ID 290,desired将是下一个标记ID 4920。当i等于2时,context将是前两个标记[290, 4920],desired将是下一个标记2241。当i等于3时,context将是前三个标记ID [290, 4920, 2241],输出或desired将是下一个标记287。当i等于4时,context将是前四个标记ID,desired将是下一个标记257。

箭头左边的所有内容代表大型语言模型将接收的输入,箭头右边的标记ID代表LLM应该预测的目标标记ID。当我们构建这些输入-输出对时,这就是它的实际含义。这里有四个预测任务,不是一个预测任务。当我创建这个x和y的输入-输出对时,甚至在这里当我展示x和y的输入-输出对时,它不仅仅是一个预测任务,而是有四个预测任务在这里发生。这是因为上下文大小是4。如果上下文大小是8,每个输入-输出对中就会有八个预测任务。当你查看输入-输出对时,通常回归和分类问题中,一个输入-输出对对应一个预测任务(例如,一张狗的图片需要被分类为猫或狗)。但在LLM的情况下,一个输入-输出对对应由上下文大小设定的多个预测任务。这一点非常重要。

现在我要做的是,我将采用相同的简单代码,但将其解码为文本,以便你能感受到这里到底发生了什么。你可以看到我使用了相同的代码,但我打印了解码后的context和解码后的desired值。因此,如果“and”是输入,“established”是输出;如果“and established”是输入,“himself”是输出;如果“and established himself”是输入,下一个词“in”是输出;如果“and established himself in”是输入,那么下一个词“a”是输出。这正是我们开始课程时展示的内容,你还记得吗?我们通过代码创建了一个非常简单的输入-输出对x和y,并且看到了这些对如何用于创建输入和输出。很棒,对吧?

引入数据加载器

这只是我们需要做的第一步。到目前为止,我们已经创建了可以用于LLM训练的输入-输出对。稍后我们将进行LLM训练,所以我们现在已经创建了输入-输出对,但我们需要以更有结构化的方式创建它们,我们需要为整个数据创建,而不仅仅是部分。此外,稍后我们将进行并行处理,所以如果我们有多个CPU并且需要并行计算,我们需要分批处理计算。我们将以非常有结构化的方式来做这件事,为此,我们将使用一种叫做数据加载器的东西。

现在,在下一讲中查看向量嵌入之前,只剩下一个任务,那就是实现一个高效的数据加载器,它遍历输入数据集并以PyTorch张量的形式返回输入和目标。目前我们已经得到了输入-输出数组,但它们不是张量。为什么我们需要张量?因为后面所有的优化过程都将使用PyTorch,而PyTorch使用张量。因此,我们需要输入张量和输出张量。如果你不知道张量是什么,不用担心,你可以暂时将其视为二维数组或多维数组,这不会妨碍你理解本课程。

我们的目标是:实现一个数据加载器,它创建两个张量:一个包含LLM看到的文本的输入张量,和一个包含LLM要预测的目标的目标张量。基本上,我们需要创建类似于我之前在代码中展示的东西,但我们需要以张量格式创建,并且需要以更有结构化的方式进行。这就是为什么我们将使用称为数据集和数据加载器的东西。这些是Python中的数据集和数据加载器,你可以看到一些为分类数据集完成的示例,但本质上,数据集和数据加载器使你能够以更高效和紧凑的方式加载或处理数据,我们马上就会看到。

现在,我们将在下一步中实现一个数据加载器。为了实现高效的数据加载器,我们将使用PyTorch内置的DatasetDataLoader类。这些是我现在显示的DatasetDataLoader类的链接,我也会在视频描述中附上链接。

在进一步深入代码之前,我只想展示我们期望数据加载器做什么,以便你有一个直观的理解。我发现,除非你获得直观的理解,否则代码会变得非常难以掌握,但如果你知道你想要实现什么,那就真的很容易。在本节中,我们正在实现一个使用滑动窗口方法获取输入-输出目标对的数据加载器。让我们看看这意味着什么。

滑动窗口方法图解

假设我们看这个示例文本:“In the heart of the city stood the old library, a relic from a bygone era.” 假设这是那种句子,我们想创建输入-输出对,并且我们将使用上下文大小为4来创建输入-输出对。

假设输入是“In the heart of”。输出张量将向右移动一位,正如我们已经看到的。因此,输出将是“the heart of the”。正确。所以第一个输入对是“In the heart of”,第一个输出是“the heart of the”。在这个输入-输出对中,将有四个预测任务。第一个是:如果输入是“In”,预测应该是“the”;如果输入是“In the”,预测应该是“heart”;如果输入是“In the heart”,预测应该是“of”;如果输入是“In the heart of”,预测应该是“the”。首先,我们有一个x,即输入张量,和一个y,即输出张量。现在让我们看看每个张量的行代表什么。

我们在一个张量x中收集输入。张量x中的每一行代表一个输入上下文。如果你看张量x,每一行都是一个输入上下文:“In the heart of”、“the city stood the”等等。每一行代表一个输入上下文。因此,第一个输入-输出对将是“In the heart of”,第一个输出将是“the heart of the”。第二个输入将是“the city stood the”,第二个输出将是“city stood the old”。基本上,我在本课程前面展示了一个输入-输出对。当我们查看张量时,如果你看每一行,x张量的每一行是一个输入,y张量的每一行是对应的输出。因此,x张量的第50行和y张量的第50行将是第50个输入-输出对。在每个输入-输出对中,这里有四个预测任务,正如我告诉你的,因为上下文大小等于4。所以本质上,我们正在做与课程前半部分相同的事情,但我们只是取整个文本,将其放入张量的行中。我们将文本分成四个词一组:前四个词是第一行,接下来的四个词是第二行。如果你看输出张量,它只是输入张量向右移动一位。如果你看输出张量的每一行,它只是输入张量的对应行向右移动一位。

第二个张量y包含相应的预测目标,这些目标是通过将输入向右移动一个位置创建的。这对于观看本课程的每个人来说都非常重要。我们所做的一切都是下一个词预测任务。让我再次解释一下,因为我真的希望这个概念被理解,因为它是我们将要做的所有事情的核心。让我们看看第二行。

在第二行中,将有四个预测任务。第一个预测任务是:当输入是“the”时,输出是“city”;当输入是“the city”时,输出是“stood”;当输入是“the city stood”时,输出是“the”;当输入是“the city stood the”时,输出是“old”。输出是“old”。基本上,每个输入-输出对对应一个预测任务,并且对应四个预测任务,我们只是预测下一个词。就这么简单,这正是我们在代码中实际所做的。

实现数据加载器

现在我将回到代码中,我刚才在白板上展示的创建这种输入张量和输出张量正是我们将在代码中做的。如果你理解了这一点,代码将更容易理解。

我们将实现一个数据加载器来创建这些输入和输出张量。这将分四个步骤完成:第一步是对整个文本进行分词,因为我们现在要处理标记ID(我现在向你展示的是词,但实际上我们处理的是标记ID,所以我们需要编码后的文本)。然后我们将使用滑动窗口。现在你明白为什么它

10:什么是词元嵌入?

在本节课中,我们将要学习大语言模型工作流程中的一个核心概念——词元嵌入。我们将探讨为什么需要词元嵌入,它们如何工作,以及如何在实际中创建和使用它们。


概念理解:为什么需要词元嵌入?

上一节我们介绍了大语言模型处理文本的基本流程。本节中,我们来看看为什么在将词元转换为ID之后,还需要一个额外的步骤——创建词元嵌入。

计算机无法直接理解单词,因此我们需要将单词表示为数字。一种简单的方法是为每个单词分配一个随机的ID号。然而,这种方法存在一个根本性问题:它无法捕捉单词之间的语义关系

例如,“猫”和“小猫”在含义上是相关的,但如果我们只是给“猫”分配ID 34,给“小猫”分配ID -30,这两个数字本身并不能体现这种关联性。这就像在图像处理中,如果我们只是将像素值拉平成一个向量,就会丢失图像中像素之间的空间关系信息。卷积神经网络之所以成功,正是因为它利用了图像中固有的空间信息。

同样,文本中也蕴含着固有的信息:单词承载着意义。我们需要在将单词输入模型时,利用这种语义上的相似性。如果我们不利用这些信息,训练过程将不是最优的。

那么,如何编码这种语义关系呢?一个关键的想法是:将每个单词编码为一个向量

以下是构建这种向量的一个思想实验:

假设我们有四个单词:狗、猫、苹果、香蕉。我们根据五个特征来定义它们:

  1. 有尾巴
  2. 可食用
  3. 有四条腿
  4. 会发出声音
  5. 是宠物

对于每个单词,我们根据这些特征为其打分(例如,高或低),从而形成一个五维向量。

  • 的向量可能是:[高, 低, 高, 高, 高]
  • 的向量可能是:[高, 低, 高, 高, 高]
  • 苹果的向量可能是:[低, 高, 低, 低, 低]
  • 香蕉的向量可能是:[低, 高, 低, 低, 低]

通过比较这些向量,我们可以发现:

  • “狗”和“猫”的向量非常相似,因为它们共享许多特征(有尾巴、四条腿等)。
  • “苹果”和“香蕉”的向量也非常相似。
  • 而“狗”和“香蕉”的向量则差异很大。

这表明,如果我们能以智能的方式将单词表示为向量,那么向量就可以捕捉语义含义。这种能够保留单词间语义关系的向量表示,就称为向量嵌入词元嵌入


实践演示:感受词元嵌入的威力

在理解了词元嵌入的概念后,我们通过一个实际的演示来直观感受训练良好的词元嵌入如何编码语义。

我们可以使用预训练的词嵌入模型,例如 Google 的 Word2Vec(基于 Google 新闻数据集训练)。在这个模型中,每个单词被映射为一个 300 维 的向量。

以下是一些有趣的演示,展示了这些向量如何编码语义关系:

1. 类比推理:国王 - 男人 + 女人 = ?

我们可以对向量进行加减运算。例如,king(国王)的向量 + woman(女人)的向量 - man(男人)的向量,结果应该最接近 queen(女王)的向量。

# 伪代码示例
result_vector = vector('king') + vector('woman') - vector('man')
most_similar_word = find_most_similar(result_vector) # 结果很可能是 'queen'

这个演示表明,向量确实编码了诸如“男性气质”、“女性气质”等抽象概念。

2. 计算词义相似度

我们可以计算两个单词向量之间的相似度(例如,通过余弦相似度)。

# 伪代码示例
similarity_woman_man = cosine_similarity(vector('woman'), vector('man')) # 相似度高
similarity_king_queen = cosine_similarity(vector('king'), vector('queen')) # 相似度高
similarity_paper_water = cosine_similarity(vector('paper'), vector('water')) # 相似度低

结果显示,语义相近的词(如 woman/man, king/queen)其向量相似度很高,而语义无关的词(如 paper/water)相似度很低。

3. 查找相似词

给定一个单词,我们可以找到与其向量最接近的其他单词。

# 伪代码示例
similar_to_tower = find_similar_words(vector('tower'))
# 结果可能包括:skyscraper(摩天大楼), spire(尖顶)等

这些演示强有力地证明,通过适当的训练,词元嵌入能够有效地捕捉和编码单词的语义信息。


为大语言模型创建词元嵌入

现在,我们来看看在大语言模型(如 GPT-2)中,词元嵌入是如何具体创建和使用的。

创建词元嵌入矩阵需要两个关键参数:

  1. 词汇表大小:模型能识别的所有唯一词元的数量。
  2. 向量维度:每个词元嵌入向量的长度。

以 GPT-2 为例:

  • 词汇表大小 = 50,257 (通过字节对编码得到的子词词元数量)
  • 向量维度 = 768

因此,GPT-2 的词元嵌入矩阵是一个形状为 [50257, 768] 的矩阵。这个矩阵的每一行对应一个词元ID(从0到50256),每一行都是一个768维的向量,代表该词元的嵌入。

那么,这个矩阵中的数值是如何确定的呢?

  1. 随机初始化:在训练开始前,嵌入矩阵中的所有 50257 * 768 个权重值都被初始化为随机的小数。
  2. 联合优化:这些权重不会单独预训练,而是作为大语言模型整体参数的一部分,在模型的主训练任务(如预测下一个词)过程中,通过反向传播算法一起被优化。

模型通过海量的文本数据学习调整这些权重,使得语义相近的词元在向量空间中的位置也彼此接近。


代码实现:嵌入层即查找表

在代码中,我们使用 torch.nn.Embedding 层来实现词元嵌入。理解它的最佳方式是将其视为一个查找表

假设我们有一个微型词汇表,包含6个单词,我们想为每个单词创建3维的嵌入向量。

import torch
import torch.nn as nn

# 定义参数
vocab_size = 6    # 词汇表大小
embed_dim = 3     # 嵌入向量维度

# 创建嵌入层
embedding_layer = nn.Embedding(vocab_size, embed_dim)

# 查看嵌入层的权重矩阵(随机初始化)
print(embedding_layer.weight)
print(f"权重矩阵形状: {embedding_layer.weight.shape}") # 输出: torch.Size([6, 3])

embedding_layer.weight 就是一个 6x3 的矩阵。第0行对应词元ID 0的向量,第1行对应词元ID 1的向量,依此类推。

如何使用这个查找表?

如果我们想获取单个词元ID(例如 ID=3)的嵌入向量:

token_id = torch.tensor([3])
vector_for_id_3 = embedding_layer(token_id)
print(vector_for_id_3)
# 这个输出就是权重矩阵的第4行(索引为3的行)

如果我们想批量获取多个词元ID的嵌入向量:

input_ids = torch.tensor([2, 3, 5, 1]) # 我们想要这四个ID的向量
all_embeddings = embedding_layer(input_ids)
print(all_embeddings)
print(f"批量嵌入形状: {all_embeddings.shape}") # 输出: torch.Size([4, 3])

embedding_layer 所做的就是根据输入的ID,从 embedding_layer.weight 矩阵中取出对应的行。因此,它本质上是一个高效的查找操作

技术细节(可选了解)
从数学上看,嵌入层等价于一个将独热编码输入乘以一个权重矩阵的线性层。但由于词汇表通常很大,独热编码会产生大量零值,导致计算效率低下。nn.Embedding 层直接通过查找实现,避免了这些不必要的乘法运算,因此效率高得多。


总结与预告

本节课中我们一起学习了:

  1. 为什么需要词元嵌入:为了利用文本中固有的语义信息,我们不能仅仅使用随机的词元ID或独热编码,而需要将词元表示为能捕捉其含义的向量。
  2. 词元嵌入的直观感受:通过预训练模型演示,我们看到训练良好的词元嵌入能够进行类比推理、计算相似度,证明向量确实可以编码语义。
  3. 如何创建词元嵌入:在大语言模型中,我们定义一个嵌入矩阵,其大小由词汇表大小和向量维度决定。该矩阵的权重随机初始化,并在模型的主训练过程中被优化。
  4. 嵌入层的实现:在代码中,torch.nn.Embedding 层是一个高效的查找表,可以根据词元ID检索对应的嵌入向量。

目前我们学习的词元嵌入编码了单词的语义,但还没有考虑单词在句子中的位置信息。例如,“猫坐在垫子上”中,“猫”和“垫子”的接近关系很重要。

在下一讲中,我们将学习 位置嵌入,这是另一种重要的嵌入类型,用于将词元在序列中的顺序信息注入模型。

11:位置编码的重要性 🧭

在本节课中,我们将要学习一个非常关键的主题:位置编码。我们将首先回顾已学内容,然后深入理解为什么需要位置编码,最后通过一个Python动手编程练习,将位置编码层与我们已创建的词元嵌入层结合起来。

概述:为什么需要位置编码?

在上一讲中,我们学习了词元嵌入。词元嵌入是大语言模型训练流程中的第三步,它将词元ID转换为向量,从而保留词元之间的语义关系。然而,仅凭词元嵌入,模型无法获知词元在序列中的位置信息。

考虑以下两个句子:

  1. The cat sat on the mat.
  2. On the mat the cat sat.

在这两个句子中,词元“cat”的词元ID相同,因此其词元嵌入向量也相同。但“cat”在两个句子中的位置不同,这可能导致句子含义完全不同。因此,除了语义信息,向模型注入词元的位置信息也至关重要。

位置编码的类型

位置编码主要有两种类型:绝对位置编码和相对位置编码。

绝对位置编码

在绝对位置编码中,为输入序列中的每个位置添加一个唯一的嵌入向量到词元嵌入中,以传达其确切位置。

核心思想:最终输入嵌入 = 词元嵌入 + 位置嵌入。

例如,对于句子“The cat sat on the mat”:

  • “cat”的词元嵌入是 x
  • 由于“cat”在第二个位置,我们添加位置嵌入 y
  • 最终向量为 x + y

对于句子“On the mat the cat sat”:

  • “cat”的词元嵌入同样是 x
  • 由于“cat”在第五个位置,我们添加位置嵌入 z
  • 最终向量为 x + z

因此,同一个词元“cat”在不同句子中会得到不同的最终输入嵌入,从而编码了位置信息。

关键点:位置嵌入向量必须与词元嵌入向量具有相同的维度,因为我们需要将它们相加。

相对位置编码

相对位置编码侧重于词元之间的相对位置或距离,而非绝对位置。模型学习的是词元相隔多远的关系。

优势:这种编码方式使模型能更好地泛化到不同长度的序列。例如,如果模型在训练时只见过长度为5的序列,但在测试时遇到长度为6的序列,相对位置编码依然可以工作,因为它只关心词元间的相对距离。

应用场景:相对位置编码在处理长序列或语言建模任务时可能更有优势,因为在这些任务中,相同的短语可能出现在序列的不同部分。

实践中的选择

以下是两种编码方式的比较与选择建议:

  • 绝对位置编码 在词元的固定顺序至关重要时更受青睐,例如序列生成任务。原始的Transformer论文和OpenAI的GPT模型系列(如GPT-2、GPT-3)都使用了绝对位置编码。
  • 相对位置编码 更适合处理长序列或语言建模任务。
  • 在实践中,绝对位置编码更为常用。GPT模型在训练过程中会优化位置嵌入向量的值,而非使用预设的公式(如Transformer论文中的正弦/余弦函数)。

动手实践:实现位置编码

现在,我们将通过代码演示如何为GPT-2风格的模型实现位置编码。我们将使用更现实的嵌入维度(256维)和词汇表大小(50257,类似于GPT-2)。

1. 创建词元嵌入层

首先,我们定义一个词元嵌入层。这是一个查找表,将词元ID映射为指定维度的向量。

import torch
import torch.nn as nn

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/vizuara-llm-scr/img/b20911e3115e141ab167fc6447569a06_2.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/vizuara-llm-scr/img/b20911e3115e141ab167fc6447569a06_3.png)

# 定义参数
vocab_size = 50257  # 词汇表大小,类似GPT-2
output_dim = 256    # 嵌入向量维度
context_length = 4  # 上下文长度(最大输入词元数)
batch_size = 8      # 批处理大小

# 创建词元嵌入层
token_embedding_layer = nn.Embedding(vocab_size, output_dim)
print(f"Token embedding layer created: {token_embedding_layer}")

2. 准备输入数据

我们使用数据加载器来批量处理输入文本。假设我们有一个经过分词的文本数据集,数据加载器会生成形状为 (batch_size, context_length) 的输入批次。

# 假设 `data_loader` 是一个已定义好的DataLoader,能 yield 出 (inputs, targets)
# inputs 的形状为 (batch_size, context_length)
for inputs, targets in data_loader:
    # inputs 是一个形状为 (8, 4) 的张量,包含8个序列,每个序列4个词元ID
    print(f"Input batch shape: {inputs.shape}")
    break  # 只看第一个批次

3. 生成词元嵌入

将输入批次传递给词元嵌入层,为每个词元ID生成256维的向量。

# 获取一个批次的数据
inputs, targets = next(iter(data_loader))

# 生成词元嵌入
token_embeddings = token_embedding_layer(inputs)
print(f"Token embeddings shape: {token_embeddings.shape}")  # 应为 (8, 4, 256)

现在,token_embeddings 是一个形状为 (8, 4, 256) 的三维张量,表示8个序列,每个序列4个词元,每个词元用256维向量表示。

4. 创建位置嵌入层

接下来,我们创建位置嵌入层。由于上下文长度是4,我们只需要编码4个位置。

# 创建位置嵌入层
# 行数 = 上下文长度 (需要编码的位置数量)
# 列数 = 输出维度 (必须与词元嵌入维度相同)
pos_embedding_layer = nn.Embedding(context_length, output_dim)
print(f"Positional embedding layer created: {pos_embedding_layer}")

5. 生成位置嵌入向量

我们需要为位置0, 1, 2, 3生成对应的位置嵌入向量。

# 生成位置索引 [0, 1, 2, 3]
positions = torch.arange(context_length)
print(f"Position indices: {positions}")

# 从位置嵌入层查找对应的向量
position_embeddings = pos_embedding_layer(positions)
print(f"Position embeddings shape: {position_embeddings.shape}")  # 应为 (4, 256)

现在,position_embeddings 是一个形状为 (4, 256) 的张量,包含4个位置对应的256维向量。

6. 结合词元嵌入与位置嵌入

最后一步是将位置嵌入加到词元嵌入上,得到最终的输入嵌入。

# 将位置嵌入加到词元嵌入上
# token_embeddings 形状: (8, 4, 256)
# position_embeddings 形状: (4, 256)
# PyTorch通过广播机制自动将 position_embeddings 扩展为 (8, 4, 256) 然后相加
input_embeddings = token_embeddings + position_embeddings
print(f"Final input embeddings shape: {input_embeddings.shape}")  # 应为 (8, 4, 256)

广播机制解释:PyTorch会将形状为 (4, 256)position_embeddings 在批次维度(第0维)上复制8次,使其变为 (8, 4, 256),然后与 token_embeddings 逐元素相加。这意味着批次中的每个序列都添加了相同的位置嵌入向量。

总结

本节课中我们一起学习了位置编码的核心概念与实践方法。

我们首先理解了为什么需要位置编码:词元嵌入虽然捕获了语义,但丢失了词元在序列中的顺序信息。位置编码弥补了这一缺陷。

我们探讨了两种主要的位置编码类型:

  • 绝对位置编码:为每个绝对位置添加一个唯一的向量。
  • 相对位置编码:编码词元之间的相对距离关系。

在动手实践部分,我们模拟了为类似GPT-2的模型添加位置编码的过程:

  1. 创建了词元嵌入层(nn.Embedding(vocab_size, output_dim))。
  2. 使用数据加载器准备输入批次(形状为 (batch_size, context_length))。
  3. 生成词元嵌入(形状变为 (batch_size, context_length, output_dim))。
  4. 创建位置嵌入层,其行数等于上下文长度(nn.Embedding(context_length, output_dim))。
  5. 生成位置嵌入向量(形状为 (context_length, output_dim))。
  6. 将两者相加得到最终的输入嵌入,PyTorch的广播机制使加法得以顺利进行。

最终得到的 input_embeddings 是形状为 (batch_size, context_length, output_dim) 的张量,它同时包含了词元的语义信息和位置信息,可以作为大语言模型的训练输入。

理解这些张量形状的由来(如 8, 4, 256)是掌握模型数据流的关键。在后续课程中,我们将使用这些输入嵌入来开始训练大语言模型的核心部分。

12:大语言模型完整的数据预处理流程

在本节课中,我们将要学习构建大语言模型最基础的方面之一:构建数据处理流程。数据预处理是拥有高性能大语言模型的基本构建模块之一。

数据预处理的主要思想是,大语言模型处理的是文本和句子,但原始的文本文件不能直接作为输入。在将数据输入大语言模型之前,需要对其进行处理的整个过程被称为数据预处理。

我们将通过四个基本步骤来回答如何将句子转换为适合大语言模型输入的形式。这四个步骤分别是:分词、词元嵌入、位置嵌入以及最终创建输入嵌入。输入嵌入是词元嵌入和位置嵌入的总和。

第一步:分词

上一节我们介绍了数据预处理的重要性,本节中我们来看看数据处理的第一步:分词。分词是将文本分解成更小单元(词元)的过程。我们将从构建一个简单的基于单词的分词器开始。

构建基于单词的分词器

首先,我们需要一个数据集。我们将使用一本名为《The Verdict》的书籍作为输入数据。基于单词的分词器旨在将整个文本分解成单词块。

以下是读取数据集并查看其内容的代码:

with open('the_verdict.txt', 'r', encoding='utf-8') as f:
    raw_text = f.read()

print(f"总字符数: {len(raw_text)}")
print(f"前100个字符: {raw_text[:100]}")

接下来,我们需要将文本拆分为词元。我们将使用Python的正则表达式库。

import re

# 初始尝试:按空白字符分割
sample_text = "Hello, world. This is a test."
tokens = re.split(r'(\s)', sample_text)
print(tokens)

这种方法的问题在于,标点符号和单词没有被分开,并且空白字符也被保留为单独的词元。我们需要改进。

以下是改进后的分词方法:

# 改进方法:在特殊字符和空白处分割,并移除空白词元
sample_text = "Hello, world. This is a test."
split_tokens = re.split(r'([,.:;?_!"()\']|--|\s)', sample_text)
tokens = [item for item in split_tokens if item.strip() != '']
print(tokens)

现在,我们将这个逻辑应用到整个数据集上。

# 对整个数据集进行分词
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
preprocessed = [item for item in preprocessed if item.strip() != '']

print(f"前30个词元: {preprocessed[:30]}")
print(f"总词元数: {len(preprocessed)}")

构建词汇表与词元ID

计算机不理解单词,因此我们需要将词元转换为数字ID。这通过构建词汇表来实现。

以下是构建词汇表的步骤:

# 获取所有唯一词元并排序
all_tokens = sorted(set(preprocessed))
vocab_size = len(all_tokens)
print(f"词汇表大小: {vocab_size}")

# 创建词元到ID的映射字典
vocab = {token: idx for idx, token in enumerate(all_tokens)}
# 打印前50个映射项
print(list(vocab.items())[:50])

实现分词器类

接下来,我们实现一个分词器类,它包含编码(将文本转换为ID)和解码(将ID转换回文本)的方法。

以下是分词器类的实现:

class SimpleTokenizerV1:
    def __init__(self, vocab):
        self.str_to_int = vocab
        self.int_to_str = {idx: token for token, idx in vocab.items()}

    def encode(self, text):
        preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)
        preprocessed = [item for item in preprocessed if item.strip() != '']
        ids = [self.str_to_int[token] for token in preprocessed]
        return ids

    def decode(self, ids):
        text = " ".join([self.int_to_str[idx] for idx in ids])
        # 修复标点符号前的空格
        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
        return text

# 测试分词器
tokenizer = SimpleTokenizerV1(vocab)
text = "It was the last he painted, you know, Mrs. Gisborne said with pardonable pride."
ids = tokenizer.encode(text)
print(f"编码后的ID: {ids}")
decoded_text = tokenizer.decode(ids)
print(f"解码后的文本: {decoded_text}")

处理未知词元和特殊上下文词元

基于单词的分词器的一个主要问题是无法处理词汇表之外的单词。为了解决这个问题,我们需要添加特殊上下文词元,如未知词元和文本结束词元。

以下是添加特殊词元并修改分词器的步骤:

# 添加特殊词元到词汇表
all_tokens_extended = all_tokens + ["<|endoftext|>", "<|unk|>"]
vocab_extended = {token: idx for idx, token in enumerate(all_tokens_extended)}
print(f"扩展后词汇表大小: {len(vocab_extended)}")
print(list(vocab_extended.items())[-2:])

class SimpleTokenizerV2:
    def __init__(self, vocab):
        self.str_to_int = vocab
        self.int_to_str = {idx: token for token, idx in vocab.items()}

    def encode(self, text):
        preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)
        preprocessed = [item for item in preprocessed if item.strip() != '']
        ids = []
        for token in preprocessed:
            if token in self.str_to_int:
                ids.append(self.str_to_int[token])
            else:
                ids.append(self.str_to_int["<|unk|>"])
        return ids

    def decode(self, ids):
        text = " ".join([self.int_to_str[idx] for idx in ids])
        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
        return text

# 测试改进后的分词器
tokenizer_v2 = SimpleTokenizerV2(vocab_extended)
text1 = "Hello, do you like tea?"
text2 = "In the sunlit terraces of the palace."
combined_text = text1 + " <|endoftext|> " + text2
ids_v2 = tokenizer_v2.encode(combined_text)
print(f"编码后的ID (含特殊词元): {ids_v2}")
decoded_text_v2 = tokenizer_v2.decode(ids_v2)
print(f"解码后的文本: {decoded_text_v2}")

第二步:字节对编码分词器

上一节我们构建了基于单词的分词器,本节中我们来看看GPT模型实际使用的分词方法:字节对编码。这是一种基于子词的分词方法。

基于单词的分词器存在词汇量大、无法捕捉词根相似性等问题。基于字符的分词器词汇量小,但丢失了单词的语义信息。字节对编码结合了二者的优点。

字节对编码原理

字节对编码算法最初用于数据压缩。在分词中,它将频繁出现的字符对合并为新的子词单元,从而构建一个包含子词、单词甚至字符的词汇表。

以下是字节对编码的核心思想:

  1. 不将常用词拆分为更小的子词。
  2. 将罕见词拆分为有意义的子词,以保留词根信息。

例如,“boy”和“boys”。“boy”是常见词,保留为一个词元。“boys”是罕见词,拆分为“boy”和“s”。这样,模型就能学习到“boy”和“boys”之间的语义关联。

使用tiktoken库实现BPE分词器

OpenAI提供了tiktoken库,其中包含了GPT模型使用的BPE分词器。

以下是使用tiktoken的示例:

import tiktoken

# 加载GPT-2使用的分词器
tokenizer = tiktoken.get_encoding("gpt2")

text = "Hello, do you like tea? In the sunlit terraces of some unknown place."
# 编码
ids = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(f"编码后的ID: {ids}")
print(f"ID数量: {len(ids)}")

# 解码
decoded_text = tokenizer.decode(ids)
print(f"解码后的文本: {decoded_text}")

# 查看词汇表大小
print(f"GPT-2词汇表大小: {tokenizer.n_vocab}")

BPE分词器的优势在于:

  1. 词汇表大小适中(例如GPT-2约5万)。
  2. 能通过子词保留词根语义。
  3. 能自动处理词汇表外的单词,无需特殊未知词元。

第三步:创建输入-目标对与数据加载器

在进入嵌入层之前,我们需要理解如何为语言模型准备训练数据。语言模型的核心任务是预测下一个词元,因此我们需要从文本中创建输入序列和对应的目标序列。

理解上下文大小与步幅

上下文大小决定了模型一次能看到的词元数量。步幅决定了从一个输入序列到下一个输入序列的滑动窗口移动距离。

以下是创建输入-目标对的思路:

# 假设上下文大小为4,步幅为1
context_size = 4
stride = 1
encoded_text = [...] # 经过BPE编码的ID列表

input_chunks = []
target_chunks = []

for i in range(0, len(encoded_text) - context_size, stride):
    input_chunk = encoded_text[i:i+context_size]
    target_chunk = encoded_text[i+1:i+context_size+1] # 输入偏移一位即为目标
    input_chunks.append(input_chunk)
    target_chunks.append(target_chunk)

# 转换为张量
import torch
inputs = torch.tensor(input_chunks)
targets = torch.tensor(target_chunks)

使用PyTorch数据加载器

为了高效处理数据,我们使用PyTorch的DatasetDataLoader

以下是创建自定义数据集和数据加载器的示例:

from torch.utils.data import Dataset, DataLoader

class TextDataset(Dataset):
    def __init__(self, text, tokenizer, context_size=4, stride=1):
        self.tokenizer = tokenizer
        self.context_size = context_size
        self.stride = stride
        self.encoded_text = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
        self.inputs, self.targets = self._create_sequences()

    def _create_sequences(self):
        inputs, targets = [], []
        for i in range(0, len(self.encoded_text) - self.context_size, self.stride):
            inputs.append(self.encoded_text[i:i+self.context_size])
            targets.append(self.encoded_text[i+1:i+self.context_size+1])
        return torch.tensor(inputs), torch.tensor(targets)

    def __len__(self):
        return len(self.inputs)

    def __getitem__(self, idx):
        return self.inputs[idx], self.targets[idx]

# 创建数据集和数据加载器
dataset = TextDataset(raw_text, tokenizer, context_size=4, stride=1)
dataloader = DataLoader(dataset, batch_size=8, shuffle=True)

# 查看一个批次的数据
for batch_inputs, batch_targets in dataloader:
    print(f"输入批次形状: {batch_inputs.shape}") # (8, 4)
    print(f"目标批次形状: {batch_targets.shape}") # (8, 4)
    print(f"第一批次输入:\n{batch_inputs[0]}")
    print(f"第一批次目标:\n{batch_targets[0]}")
    break

第四步:词元嵌入

上一节我们准备好了输入数据,本节中我们来看看如何将词元ID转换为有语义信息的向量表示,即词元嵌入。词元嵌入将离散的词元ID映射到连续的向量空间,使语义相似的词在向量空间中距离更近。

为什么需要词元嵌入?

直接使用词元ID或独热编码无法捕捉单词之间的语义关系。例如,“cat”和“kitten”语义相关,但它们的ID是任意的,模型无法从ID中学习到这种关联。

词元嵌入通过一个可学习的查找表(嵌入层)将每个词元ID映射为一个固定维度的向量。在训练过程中,模型会调整这些向量,使语义相似的词具有相似的向量表示。

实现词元嵌入层

在PyTorch中,我们可以使用nn.Embedding层来实现词元嵌入。

以下是创建和使用嵌入层的示例:

import torch.nn as nn

# 假设词汇表大小和向量维度(以GPT-2为例)
vocab_size = 50257  # GPT-2词汇表大小
embedding_dim = 256 # 嵌入向量维度

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/vizuara-llm-scr/img/2de79e0e6398319d8a3800828cfb4f57_2.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/vizuara-llm-scr/img/2de79e0e6398319d8a3800828cfb4f57_4.png)

# 创建嵌入层
token_embedding_layer = nn.Embedding(vocab_size, embedding_dim)
print(f"嵌入层权重矩阵形状: {token_embedding_layer.weight.shape}") # (50257, 256)

# 从数据加载器获取一个批次输入
batch_inputs, _ = next(iter(dataloader)) # 形状: (8, 4)

# 将输入ID转换为嵌入向量
token_embeddings = token_embedding_layer(batch_inputs) # 形状: (8, 4, 256)
print(f"词元嵌入张量形状: {token_embeddings.shape}")

嵌入层本质上是一个查找表。当传入一个形状为(batch_size, context_size)的ID张量时,它会返回一个形状为(batch_size, context_size, embedding_dim)的嵌入向量张量。

第五步:位置嵌入

词元嵌入包含了语义信息,但缺少了词元在序列中位置的信息。对于语言模型来说,“猫坐在垫子上”和“垫子上坐着猫”含义不同,但词元嵌入可能相同。位置嵌入的作用就是为模型提供词元的顺序信息。

绝对位置嵌入

GPT模型使用绝对位置嵌入。它为序列中的每个位置分配一个唯一的嵌入向量,然后将其加到对应位置的词元嵌入上。

以下是创建位置嵌入层的示例:

# 上下文大小(序列长度)
context_size = 4

# 创建位置嵌入层,维度需与词元嵌入一致
pos_embedding_layer = nn.Embedding(context_size, embedding_dim)

# 生成位置ID (0, 1, 2, 3)
position_ids = torch.arange(context_size, dtype=torch.long).unsqueeze(0) # 形状: (1, 4)

# 获取位置嵌入向量
position_embeddings = pos_embedding_layer(position_ids) # 形状: (1, 4, 256)
print(f"位置嵌入张量形状: {position_embeddings.shape}")

创建输入嵌入

输入嵌入是词元嵌入和位置嵌入的总和,它将作为大语言模型的最终输入。

以下是计算输入嵌入的示例:

# 将位置嵌入广播到与词元嵌入相同的批次大小
# position_embeddings: (1, 4, 256) -> 广播为 (8, 4, 256)
# token_embeddings: (8, 4, 256)

input_embeddings = token_embeddings + position_embeddings # 形状: (8, 4, 256)
print(f"输入嵌入张量形状: {input_embeddings.shape}")

现在,input_embeddings 张量包含了每个词元的语义信息和位置信息,可以直接输入到Transformer模型中进行训练。

总结

本节课中我们一起学习了构建大语言模型完整的数据预处理流程。我们涵盖了以下四个核心步骤:

  1. 分词:将原始文本分解为词元。我们学习了基于单词、基于字符的分词器,并重点介绍了GPT模型使用的基于子词的字节对编码方法。
  2. 创建输入-目标对:为了训练语言模型预测下一个词元,我们从文本中构建了输入序列和对应的目标序列,并使用了PyTorch的DataLoader进行高效批处理。
  3. 词元嵌入:通过nn.Embedding层将词元ID转换为连续的向量表示,从而捕捉单词的语义信息。公式表示为:TokenEmbedding = EmbeddingLayer(TokenIDs)
  4. 位置嵌入:通过另一个nn.Embedding层为序列中的每个位置生成向量,为模型提供词元顺序信息。输入嵌入是词元嵌入和位置嵌入的总和,即 InputEmbeddings = TokenEmbeddings + PositionEmbeddings

这个流程将原始文本转换成了富含语义和位置信息的数值化张量,为大语言模型的训练做好了准备。理解这个管道是深入理解LLM如何工作的关键第一步。

13:注意力机制导论 🧠

在本节课中,我们将要学习大语言模型(LLM)中一个核心且强大的概念——注意力机制。我们将了解它为何被需要,它是如何从历史模型(如循环神经网络)的局限性中发展而来的,以及它在现代LLM架构中的核心地位。

课程概述

到目前为止,在本系列课程中,我们已经详细探讨了构建大语言模型的第一阶段——数据准备与采样,涵盖了词嵌入、分词、字节对编码和位置编码等主题。现在,是时候转向第一阶段中的第二个核心构建模块:注意力机制

如果把Transformer模型比作驱动LLM的“汽车”,那么注意力机制就是它的“引擎”。正是这个机制赋予了大型语言模型强大的能力。由于这是一个至关重要的概念,我们将通过一系列讲座来深入讲解。本节课将作为基础概述,介绍注意力机制是什么、为什么需要它,以及它的不同类型。

为什么需要注意力机制?

为了直观理解“注意力”这个名字的由来以及它要解决的问题,让我们看一个例子。

假设你是一个像GPT这样的大语言模型,收到了这样一个句子:

“The cat that was sitting on the mat which was next to the dog jumped.”

作为人类,我们可以分析出:有一只猫,它坐在一块垫子上,垫子挨着一只狗,然后这只猫跳了起来。对于LLM来说,这个句子有点复杂,因为它包含了长距离依赖关系。模型需要理解的核心是:主语“猫”执行的动作是“跳”。

因此,当模型处理“猫”这个词时,它应该对“跳”这个词给予最多的“关注”。如果没有注意力机制,模型可能难以捕捉这种长距离关联,可能会错误地认为“狗跳了”或者只理解到“猫在垫子上”。这就是我们需要注意力机制的根本原因:它使模型能够有选择地关注输入序列中不同部分的信息,从而更好地理解上下文和长距离依赖。

注意力机制的发展历程

为了更好地理解注意力机制的价值,我们需要回顾一下历史,看看在它出现之前,模型是如何处理序列任务的(如机器翻译),以及它们存在哪些局限性。

循环神经网络(RNN)与编码器-解码器架构

在Transformer出现之前,处理序列任务(如机器翻译)的主流架构是循环神经网络(RNN),它通常与编码器-解码器架构结合使用。

  • 编码器:接收输入序列(如德语句子),逐步处理每个词,并维护一个隐藏状态。这个隐藏状态在每个时间步都会更新,旨在捕获到目前为止已处理序列的“记忆”或上下文。
  • 解码器:接收编码器产生的最终隐藏状态(也称为上下文向量),并基于它逐步生成输出序列(如英译句子)。

以下是RNN编码器-解码器的工作流程示意图:

输入序列: [词1, 词2, 词3, 词4]
编码器:
    时间步1: 隐藏状态1 = f(词1, 初始隐藏状态)
    时间步2: 隐藏状态2 = f(词2, 隐藏状态1)
    时间步3: 隐藏状态3 = f(词3, 隐藏状态2)
    时间步4: 隐藏状态4(最终上下文向量)= f(词4, 隐藏状态3)
解码器:
    基于“最终上下文向量”生成输出词1
    基于输出词1和更新后的状态生成输出词2
    ...

RNN的局限性:信息瓶颈与上下文丢失

RNN架构存在一个关键问题,这直接催生了注意力机制的诞生。

核心问题在于:解码器只能访问编码器传递过来的最后一个隐藏状态(上下文向量)。

这意味着,无论输入序列有多长,编码器都必须将所有信息压缩到这一个固定长度的向量中。对于长句子或复杂结构,这会造成信息瓶颈,导致上下文丢失。解码器无法直接访问输入序列中较早时间步的隐藏状态,因此在生成特定输出词时,难以回顾并重点关注输入序列中与之高度相关的特定部分。

以我们的长例句为例,当解码器需要生成对应“jumped”的词时,它最需要关注的是输入中的“cat”。但在标准RNN中,解码器只有一个包含了整个句子信息的混合向量,很难从中精准地提取出“cat”和“jumped”之间的强关联。这就是RNN处理长距离依赖能力不足的原因。

注意力机制的诞生:Bahdanau注意力

2014年,研究人员在论文《Neural Machine Translation by Jointly Learning to Align and Translate》中提出了注意力机制(常被称为Bahdanau注意力),以解决上述问题。

核心思想:允许解码器在每一步生成输出时,有选择地“回顾”编码器对所有输入词计算出的全部隐藏状态,而不仅仅是最后一个。

具体来说:

  1. 编码器依然为每个输入词生成一个隐藏状态。
  2. 当解码器要生成当前输出词时,它会计算一个注意力权重分布。这个分布决定了在生成当前词时,应该对编码器的每一个隐藏状态给予多少“注意力”。
  3. 解码器将所有隐藏状态按注意力权重进行加权求和,得到一个上下文向量。这个向量动态地聚焦于当前最相关的输入信息。
  4. 解码器结合这个动态生成的上下文向量和自身状态,来产生输出。

用伪代码表示这一过程:

# 假设编码器隐藏状态为:encoder_states = [h1, h2, ..., hn]
# 解码器当前状态为:decoder_state

# 计算注意力分数(衡量每个编码器状态与当前解码状态的相关性)
attention_scores = [score(decoder_state, hi) for hi in encoder_states]

# 将分数转换为权重(例如使用softmax)
attention_weights = softmax(attention_scores)

# 计算加权和的上下文向量
context_vector = sum(attention_weights[i] * encoder_states[i] for i in range(n))

# 解码器使用context_vector生成输出
output = generate(decoder_state, context_vector)

这种机制使得模型能够动态地将输出与输入对齐。例如,在翻译时,生成英文“jumped”时,模型可以学会将高注意力权重分配给输入德语句子中的“sprang”和“Katze”(猫),即使它们在序列中相隔很远。

从注意力到Transformer与自注意力

2017年,革命性的论文《Attention Is All You Need》提出了 Transformer 架构。它完全摒弃了RNN,将注意力机制作为核心构建块,并引入了 自注意力(Self-Attention)

  • 自注意力:与传统注意力(关注两个不同序列间的关系,如源语言和目标语言)不同,自注意力机制让序列中的每一个位置都能关注到同一序列中的所有其他位置。它用于学习序列内部元素之间的关系。

例如,在处理句子“The cat jumped”时,自注意力机制会计算“cat”与“The”、“jumped”之间的关联强度,从而更好地理解“cat”是主语,并与动作“jumped”紧密相关。这种对序列内部结构的强大建模能力,是Transformer及其后续大语言模型成功的关键。

注意力机制的类型与学习路线

为了循序渐进地掌握这个复杂概念,我们将在后续课程中按照以下顺序深入讲解并编码实现:

  1. 简化自注意力:最基础的形式,理解注意力计算的核心思想。
  2. 自注意力:引入可训练的权重矩阵(Query, Key, Value),构成实际使用机制的基础。
  3. 因果注意力:一种特殊的自注意力,用于语言建模。它确保在预测下一个词时,只能关注当前词及之前的词,屏蔽未来信息。
  4. 多头注意力:这是现代LLM(如GPT)中实际使用的模块。它将多个并行的因果注意力头组合在一起,允许模型同时从不同的表示子空间关注信息,从而捕获更丰富的关系。

历史脉络总结

以下是注意力机制及相关模型发展的一个简要时间线,帮助我们建立整体认知:

  • 1980年代:循环神经网络(RNN)被提出,引入隐藏状态来维护记忆。
  • 1997年:长短期记忆网络(LSTM)被提出,通过门控机制缓解RNN的梯度消失问题,更好地处理长序列。
  • 2014年:Bahdanau等人提出用于机器翻译的注意力机制,使解码器能访问所有编码器状态。
  • 2017年:Transformer架构论文发表,核心是自注意力机制,完全摒弃RNN,在多项任务上取得突破。
  • 现今:基于Transformer和自注意力的大语言模型(如GPT系列)成为主流。

本节课总结

在本节课中,我们一起学习了注意力机制的导论。我们首先通过一个例子理解了为什么LLM需要“注意力”来捕捉长距离依赖。然后,我们回顾了历史,深入分析了RNN编码器-解码器架构在处理长序列时的核心缺陷——信息瓶颈和上下文丢失,这源于解码器只能访问单一的最终隐藏状态。

接着,我们看到了2014年提出的注意力机制如何优雅地解决了这个问题:它允许解码器动态地、有选择地关注输入序列的所有部分。最后,我们了解到这项技术如何进一步演变为Transformer模型核心的自注意力机制,并概览了我们将要深入学习的几种注意力类型:简化自注意力、自注意力、因果注意力和多头注意力。

理解注意力机制是理解现代大语言模型如何工作的关键。在接下来的课程中,我们将从最基础的简化自注意力开始,一步步用代码实现这些概念,彻底掌握这个驱动AI革命的“引擎”。

14:使用键、查询和值矩阵编写自注意力机制

在本节课中,我们将学习如何实现一个带有可训练权重的自注意力机制。我们将深入理解键、查询和值这三个核心概念,并了解为什么这种自注意力机制也被称为缩放点积注意力。通过结合数学理论、直观解释和代码实现,我们将一步步构建出GPT等大语言模型中使用的注意力机制的核心部分。

概述

上一节课我们实现了一个没有可训练权重的简化版自注意力机制。我们以句子“your journey starts with one step”为例,展示了如何将每个词元的嵌入向量转换为上下文向量。我们通过计算查询向量与所有输入嵌入向量的点积得到注意力分数,然后将其归一化为注意力权重,最后通过加权求和得到上下文向量。整个过程是固定的,没有进行任何训练。

本节课,我们将引入可训练的权重矩阵,这是真实大语言模型(如GPT)中实际使用的机制。我们将学习如何将输入嵌入转换为键、查询和值向量,如何计算注意力分数和权重,并最终得到上下文向量。理解这些步骤是掌握Transformer架构的关键。

从输入嵌入到键、查询和值向量

首先,我们需要将输入嵌入向量转换为三个新的表示:键、查询和值。这是通过三个可训练的权重矩阵实现的。

我们的输入是一个矩阵,包含六个词元(your, journey, starts, with, one, step),每个词元用一个三维向量表示。因此,输入矩阵 X 的维度是 6 x 3

我们将使用三个可训练的权重矩阵:

  • 查询权重矩阵 W_q:维度为 3 x 2
  • 键权重矩阵 W_k:维度为 3 x 2
  • 值权重矩阵 W_v:维度为 3 x 2

这些矩阵的初始值是随机的,将在模型训练过程中通过反向传播进行优化。它们的作用是将输入从三维空间投影到二维空间(在本例中)。通过矩阵乘法,我们得到三个新的矩阵:

  • 查询矩阵 QQ = X * W_q,维度为 6 x 2
  • 键矩阵 KK = X * W_k,维度为 6 x 2
  • 值矩阵 VV = X * W_v,维度为 6 x 2

现在,每个词元都对应一个二维的查询向量、键向量和值向量。在后续计算中,我们将不再直接使用原始输入嵌入 X,而是使用 QKV

以下是实现这一步骤的代码示例:

import torch
import torch.nn as nn

# 定义输入:6个词元,每个词元3维嵌入
inputs = torch.randn(6, 3)  # 形状: (6, 3)

# 定义输入和输出维度
d_in = 3  # 输入嵌入维度
d_out = 2 # 查询/键/值的目标维度

# 初始化可训练的权重矩阵(使用 nn.Parameter)
W_query = nn.Parameter(torch.randn(d_in, d_out))
W_key = nn.Parameter(torch.randn(d_in, d_out))
W_value = nn.Parameter(torch.randn(d_in, d_out))

# 计算查询、键、值矩阵
queries = inputs @ W_query  # 形状: (6, 2)
keys = inputs @ W_key       # 形状: (6, 2)
values = inputs @ W_value   # 形状: (6, 2)

计算注意力分数

在获得了查询和键矩阵后,下一步是计算注意力分数。注意力分数衡量了对于一个给定的查询(例如,“journey”),应该给予其他每个词的键多少“关注度”。

直观上,我们可以将查询向量视为当前模型关注的焦点。通过计算查询向量与所有键向量的点积,我们可以知道它们之间的对齐程度。点积越大,表示两个向量方向越一致,相关性越高。

具体来说,对于第二个查询(对应“journey”),我们需要计算它与所有6个键的点积,得到6个注意力分数。这可以通过矩阵运算高效完成:注意力分数 = Q * K^T

这里,Q6 x 2 矩阵,K^T2 x 6 矩阵,相乘后得到 6 x 6 的注意力分数矩阵 attn_scores。这个矩阵的每一行对应一个查询与所有键的注意力分数。

# 计算注意力分数矩阵
attn_scores = queries @ keys.T  # 形状: (6, 6)
# 例如,attn_scores[1] 包含了“journey”的查询与所有键的分数

计算注意力权重

直接得到的注意力分数存在两个问题:1)它们没有归一化,不便于解释;2)数值可能过大,导致后续计算不稳定。因此,我们需要对其进行缩放和归一化,得到注意力权重。

首先,我们将注意力分数除以键向量维度的平方根(本例中为 sqrt(2))。这一步称为“缩放”,是“缩放点积注意力”名称的由来。缩放主要有两个原因:

  1. 稳定学习:防止点积结果过大,导致Softmax函数输出过于“尖锐”(即概率质量过度集中在某一个值上),影响模型训练的稳定性。
  2. 控制方差:理论上,当查询和键向量的元素服从均值为0、方差为1的分布时,它们的点积的方差会随着维度 d_k 线性增长。除以 sqrt(d_k) 可以将点积的方差重新缩放回1左右,有利于梯度流动和模型收敛。

缩放之后,我们对每一行应用Softmax函数,确保每一行的值之和为1,并且所有值都在0到1之间。这样得到的矩阵就是注意力权重矩阵 attn_weights。现在,我们可以直观地解释每一行:例如,第二行表示当查询是“journey”时,模型应该分配给“your”、“journey”、“starts”等词元的注意力百分比。

import torch.nn.functional as F

d_k = keys.shape[-1]  # 键的维度,本例中为2
# 缩放并应用Softmax得到注意力权重
attn_weights = F.softmax(attn_scores / d_k**0.5, dim=-1) # 形状: (6, 6)
# 现在 attn_weights[1] 各行之和为1,表示注意力分布

计算上下文向量

现在,我们来到了最后一步:计算上下文向量。注意力权重告诉我们每个词元的重要性,而值向量 V 包含了每个词元的内容信息。上下文向量就是这些值向量的加权和,权重即为我们刚刚计算出的注意力权重。

具体来说,对于“journey”的上下文向量,我们将“your”、“journey”、“starts”等词元的值向量,分别乘以它们对应的注意力权重(来自 attn_weights 的第二行),然后将所有这些加权后的向量相加。结果就是一个新的二维向量,它不仅仅编码了“journey”本身的语义,还编码了它与句子中所有其他词元的关系信息,因此被称为“ enriched embedding”。

在矩阵形式上,这非常简单:上下文向量矩阵 = attn_weights * V。因为 attn_weights6 x 6V6 x 2,相乘后得到 6 x 2 的上下文向量矩阵,其中每一行就是对应词元的上下文向量。

# 计算上下文向量矩阵
context_vecs = attn_weights @ values  # 形状: (6, 2)
# context_vecs[1] 就是“journey”的上下文向量

整合为自注意力类

为了代码的整洁和可重用性,我们将上述所有步骤整合到一个PyTorch模块中。下面是一个自注意力机制的实现类:

class SelfAttentionV1(nn.Module):
    def __init__(self, d_in, d_out):
        super().__init__()
        # 初始化可训练的权重矩阵
        self.W_query = nn.Parameter(torch.randn(d_in, d_out))
        self.W_key = nn.Parameter(torch.randn(d_in, d_out))
        self.W_value = nn.Parameter(torch.randn(d_in, d_out))

    def forward(self, x):
        # 1. 计算键、查询、值
        keys = x @ self.W_key
        queries = x @ self.W_query
        values = x @ self.W_value
        # 2. 计算注意力分数
        attn_scores = queries @ keys.T
        # 3. 计算注意力权重(缩放 + Softmax)
        attn_weights = F.softmax(
            attn_scores / keys.shape[-1]**0.5, dim=-1
        )
        # 4. 计算上下文向量
        context_vecs = attn_weights @ values
        return context_vecs

# 使用示例
sa_v1 = SelfAttentionV1(d_in=3, d_out=2)
context_vecs = sa_v1(inputs)
print(context_vecs.shape)  # 输出: torch.Size([6, 2])

在实践中,我们更常用 nn.Linear 层(将偏置设为False)来代替手动定义 nn.Parameter,因为 nn.Linear 内置了更优化的权重初始化方案。

class SelfAttentionV2(nn.Module):
    def __init__(self, d_in, d_out):
        super().__init__()
        # 使用 nn.Linear 层,bias=False 表示只做矩阵乘法
        self.W_query = nn.Linear(d_in, d_out, bias=False)
        self.W_key = nn.Linear(d_in, d_out, bias=False)
        self.W_value = nn.Linear(d_in, d_out, bias=False)

    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 = F.softmax(
            attn_scores / keys.shape[-1]**0.5, dim=-1
        )
        context_vecs = attn_weights @ values
        return context_vecs

核心概念:查询、键、值的直观理解

  • 查询:可以类比数据库中的搜索查询。它代表模型当前正在关注的词元(例如,“journey”)。
  • :可以类比数据库中的索引键。输入序列中的每个词元都有一个键,用于与查询进行匹配,计算相似度(注意力分数)。
  • :可以类比数据库中索引对应的实际数据。它包含了词元本身的表示内容。一旦模型通过查询和键确定了哪些词元是相关的,就会提取这些词元对应的值,并按照注意力权重进行组合,形成上下文向量。

总结

本节课中,我们一起学习了自注意力机制的核心实现。我们从输入嵌入开始,通过三个可训练的权重矩阵将其转换为查询、键和值向量。接着,我们通过查询和键的点积计算注意力分数,并通过缩放和Softmax归一化得到注意力权重。最后,我们使用注意力权重对值向量进行加权求和,得到了富含上下文信息的上下文向量。

我们还将整个过程封装成了一个PyTorch模块。理解这个流程是掌握Transformer模型的基础。下一节课,我们将学习因果注意力,这是一种修改,用于防止模型在生成文本时“看到”未来的信息。

16:因果自注意力机制

在本节课中,我们将要学习因果自注意力机制,这是构建现代大语言模型(如GPT)的关键组件。我们将从回顾自注意力机制开始,然后深入探讨因果注意力的概念、必要性及其实现方法,最后通过Python代码从零开始实现一个完整的因果注意力类。

概述

在过去的几节课中,我们详细介绍了注意力机制。我们首先学习了不带可训练权重的简化自注意力机制,然后引入了可训练权重,并讨论了键、查询和值的概念。今天,我们的主要目标是学习因果注意力。在下一节课中,我们将转向多头注意力,这是GPT等现代大语言模型中实际使用的注意力机制。理解这个顺序流程至关重要,因为如果不先理解基础,就无法理解多头注意力的核心。

自注意力机制回顾

首先,让我们回顾一下之前学到的关于自注意力的一切。我们使用的示例句子是:“your journey starts with one step”。这个句子有六个词。大语言模型数据处理流程的第一步是将这些词转换为词元,将词元转换为词元ID,再将词元ID转换为向量嵌入。

在这里,每个词元都有一个三维向量嵌入。我们选择三维仅用于演示,在GPT等模型中,向量维度通常高达500或更多。这些是输入嵌入。任何注意力机制的目标都是获取每个词元的输入嵌入,并将其转换为上下文嵌入或上下文向量。

输入嵌入和上下文向量之间的区别在于:输入嵌入(例如“journey”的绿色向量)编码了关于“journey”的一些语义含义,但不包含该词与句子中其他词(如“your”、“step”、“with”等)关系的信息。而上下文向量不仅包含关于“journey”的语义含义,还编码了“journey”与所有其他词的关系信息。这就是注意力机制的主要目标:为每个输入嵌入向量获取上下文向量。我们需要上下文向量是因为它使下一个词的预测任务更加可靠,因为现在我们编码了在句子序列中需要关注不同词的程度信息。

正如我们在上一节课中看到的,第一步是将输入与查询矩阵、键矩阵和值矩阵相乘。这里的Wq、Wk和Wv是可训练的权重矩阵。通过将输入矩阵与这些可训练权重矩阵相乘,我们得到最终的查询矩阵、键矩阵和值矩阵。

下一步是计算注意力分数。我们通过将查询矩阵与键矩阵的转置相乘来计算注意力分数。这些是注意力分数,每一行代表特定查询与所有其他键的注意力关系。

然后,我们将这些注意力分数转换为注意力权重。方法是将注意力分数除以键维度的平方根,然后应用softmax激活函数。这样得到的注意力权重矩阵,其每一行的和都为1,具有概率意义。

最后一步是,我们取注意力权重矩阵,将其与值矩阵相乘,最终得到上下文向量矩阵。这就是我们寻找的最终向量。每一行对应特定词元的上下文向量。

什么是因果注意力?

因果注意力,也称为掩码注意力,是自注意力的一种特殊形式。因果注意力的作用是限制模型在处理任何给定词元时,只考虑序列中当前及之前的输入。这与允许一次性访问整个输入序列的自注意力机制形成对比。

在因果注意力中,当我们查看任何特定查询时,我们只考虑该查询与出现在它之前的词元的注意力关系。为了实现这一点,在GPT等大语言模型中,对于每个正在处理的词元,我们会掩码掉出现在当前词元之后的未来词元。

从视觉上看,我们之前看到的注意力权重矩阵是这样的:当我们查看“journey”这一行时,我们可以得到“journey”与所有其他词元的注意力权重。但因果注意力机制的目标是,当查看“journey”这个词元时,只应考虑“journey”与出现在它之前的词元(如“your”和“journey”本身)的注意力分数。此点之后的所有注意力分数都将被掩码掉,即设置为零。

类似地,对于“with”这个词元,在它之前有“your”、“journey”、“starts”、“with”四个词元,我们保留这四个词元的注意力分数,但所有未来词元的注意力分数将被掩码掉。对于最后一个词“step”,所有词都出现在它之前,因此所有注意力分数都被考虑,没有内容被掩码。对于第一个词“your”,没有任何词出现在它之前,因此“your”之后的所有内容都将被掩码。

当我们进行掩码时,上下文长度也很重要,因为它指定了LLM在预测下一个词之前可以查看多少个词。在这个矩阵中,我们假设上下文长度为6。

因果注意力机制的主要目的是掩码掉对角线以上的注意力权重。如果我们观察第二个矩阵,会发现一个关键模式:对角线以上的所有元素本质上都是0,这意味着它们被掩码掉了。了解三角矩阵(下三角矩阵和上三角矩阵)的学生会更容易理解这一点。

我们将这些注意力权重设置为零,然后重新归一化剩余的注意力权重,使得每一行的注意力权重之和仍然为1。

如何应用因果注意力掩码?

我们将遵循的策略是:首先像之前一样获得注意力权重,然后将对角线以上的元素设置为0,接着重新归一化注意力权重。这就是我们要遵循的策略。

我们将首先获得注意力分数,然后获得注意力权重,接着添加一个步骤:将对角线以上的元素掩码为0,得到掩码后的注意力分数,最后再次归一化它们以获得掩码后的注意力权重,确保每一行再次求和为1。

现在,让我们在代码中实现这个逻辑。请记住,我们所做的只是获取注意力权重,并将对角线以上的元素清零。

代码实现:用因果注意力掩码未来词元

现在,让我们进入代码部分。我们的目标是用因果注意力掩码未来词元。

在此之前,请记住我们在上一节课中编写了self_attention_version2。这个自注意力类的作用是引导我们完成整个流程管道:首先初始化查询、键和值的权重矩阵为随机值,然后将输入与这些矩阵相乘以获得查询、键和值矩阵,接着将查询与键的转置相乘以获得注意力分数,然后通过维度平方根进行缩放并应用softmax以获得注意力权重,最后将注意力权重与值相乘以获得上下文向量矩阵。

self_attention类的forward方法中,我们基本上获得了键、查询和值。这里的W_keyW_queryW_value是可训练的键、查询和值权重矩阵,它们被随机初始化,然后我们将其与输入相乘以获得键、查询和值矩阵。

然后,我们将查询与键的转置相乘以获得注意力分数,应用softmax,将注意力分数除以键维度的平方根以获得注意力权重,最后将注意力权重与值相乘以获得上下文向量。这就是在self_attention_version2类中发生的过程。

我们将从self_attention_version2开始,首先获得查询和键矩阵,通过查询与键转置的乘法获得注意力分数,然后将注意力分数除以键维度的平方根并应用softmax以获得注意力权重。到目前为止,我们还没有实现因果注意力。

现在,让我复制粘贴我们定义的输入,以便您可以一目了然地查看整个代码。

这些是我的输入,是六个词:“your”、“journey”、“begins”、“with”、“one”、“step”。从这些输入嵌入向量中,我们得到了注意力权重,我现在将它们打印出来。

当我们获得注意力权重时,这才是因果注意力机制真正开始实现的地方。我们现在要做的是首先生成一个掩码。

我们将生成一个如下所示的掩码。在这个掩码中,所有对角线以上的元素都等于0。理想情况下,这就是我们想要对注意力权重矩阵做的事情。

如果我们有一个这样的掩码,并将注意力权重乘以这个掩码,理想情况下,所有对角线以上的元素都将被设置为0。

现在,我们将使用Python的tril函数来构造这个掩码。那么,什么是tril?有两种类型的矩阵:上三角矩阵和下三角矩阵。

上三角矩阵本质上看起来像这样,其中对角线以下的所有元素都是0。在Python中,这是triu。下三角矩阵是tril,其中对角线以上的所有元素都等于0。

由于所有对角线以上的元素都被设置为0,我们需要一个下三角矩阵。这就是我们使用torch.tril的原因。

torch.tril中,我们需要传入矩阵的形状。目前,我打算创建一个由1和0组成的矩阵。我将传入一个torch.ones(context_length, context_length)的矩阵。如果我打印出这个矩阵,它看起来像这样。然后,我将对这个矩阵应用下三角矩阵函数,它将把所有对角线以上的元素设置为0。

这正是这里发生的情况。因此,mask_simple将是应用torch.tril函数到这个torch.ones矩阵的结果。当我打印mask_simple时,我会得到这个掩码,其中所有对角线以上的元素都等于0。请记住,这个掩码的长度由上下文长度指定,因为上下文长度是LLM在预测下一个词之前可以查看的词数。

在这个例子中,上下文长度等于6,因为LLM在预测下一个词之前可以查看6个词。

这就是我们创建mask_simple的方法,我们在这里打印它。

现在,如果你将注意力权重乘以这个掩码,你应该得到的是所有对角线以上的元素都被设置为0。这正是我们要做的。现在,我们将定义另一个变量masked_simple,它是注意力权重与我们之前获得的掩码相乘后的最终注意力权重矩阵。

当我们打印这个时,我们将得到这种类型的注意力权重矩阵,你会看到所有对角线以上的元素都等于0。这太棒了,这正是我们想要的。

但下一步是,你会看到这些不能作为我们的注意力权重,因为每一行的和不为1。

因此,下一步是归一化注意力权重,使得每一行的和为1。我们将做的是取每一行的和,然后用该和除以该行的所有元素。

例如,如果我们看第二行,我们将取第二行的和,然后用该和除以第二行的所有元素。这样,我们将确保一行中的所有元素之和为1。

这就是我们接下来要做的。我们将计算每一行的和,然后用和除以每一行。然后我们得到归一化的masked_simple

在这里,你会看到我们得到一个注意力权重矩阵,其中每一行有效地求和为1。这太棒了。这正是我们需要的。这是因果注意力机制引入的主要修改。就这么简单。

现在,我们将把这个与值矩阵相乘,得到上下文向量矩阵。就是这样。如果你从这节课中理解了这么多,你就已经理解了我想要传达的80%的内容。

数据泄漏问题与更优方案

现在让我们继续,你可能会想,好吧,我们已经完成了大部分工作,那么之后我们还需要做什么呢?实际上,存在一些问题。如果你仔细看因果注意力,其主要目的本质上是不受未来词元的影响,但如果你仔细看我们在这里所做的,我们实际上已经对之前获得的注意力分数应用了softmax。

即使你将所有未来词元清零,也并没有真正消除未来词元的影响,因为未来词元在我们进行softmax时已经影响了初始的两个值。

这就是我们采用的方法的缺点。我们在这里应用softmax,然后通过除以和来进行这种重新归一化。这导致了数据泄漏问题。

为什么是数据泄漏?因为尽管我们将对角线以上的元素清零,但由于我们在之前进行了softmax,出现在未来的元素确实也会影响前面的元素。所以我们需要一种方法来避免这种情况。

有一个更聪明的方法来进行这种重新归一化,让我告诉你那是什么。

如果我们有一个更高效的方法呢?高效的方法是:我们拥有注意力分数,然后我们应用一个称为上三角无穷大掩码的东西,然后我们只应用一次softmax。这将确保没有泄漏问题。

让我解释一下上三角无穷大掩码的含义。假设我们有一个注意力分数矩阵。与其提前应用softmax并获得注意力权重矩阵,不如我们替换这些值。例如,对于第一行,我们将这些值替换为负无穷;对于第二行,我们将这些值替换为负无穷。基本上,我们将对角线以上的所有条目替换为负无穷。

然后我们进行softmax。这将确保无论如何,当我们进行softmax时,负无穷的指数将是0。因此,当我们对这一行进行softmax时,所有这些条目无论如何都将是0,然后由于我们进行了softmax,它们将自动求和为1。

因此,这种技巧将确保我们没有数据泄漏问题,因为注意力分数已经计算好了。现在,当你查看每一行时,由于我们还没有进行softmax,因此还没有未来词元的影响。然后,我们只是将对角线以上的元素替换为负无穷,现在我们已经取消了未来词元的影响,因为我们还没有进行softmax。然后,我们将对这个矩阵进行softmax。softmax将做的是:一石二鸟;它将所有这些条目替换为0,因为负无穷的指数无论如何都是0,并且由于我们应用了softmax,它将确保每一行的和等于1。因此,它将确保注意力权重矩阵的每一行求和为1。

这正是我们接下来要做的。现在,如果我给你这个矩阵,并告诉你你想将对角线以上的所有元素替换为0或负无穷,你会使用上三角矩阵还是下三角矩阵?

让我告诉你这是如何实际完成的。这种方式是:我们首先创建一个上三角矩阵。让我打印出来,向你展示我们在这里做什么。

我们将再次使用一个6x6的矩阵,这次我们将取一个上三角矩阵。请记住,之前我们取了一个下三角矩阵。让我告诉你为什么我们取上三角矩阵。我们取一个上三角矩阵,其中所有这些元素都是1。我们稍后要编写的代码是:查看所有为1的位置,并将这些1替换为负无穷。

这就是我们要构造的掩码。我们在这里有这个掩码张量,它本质上是一个零向量,但对角线以上的所有元素都是1。然后我们使用attention_scores.masked_fill函数。这个函数的作用是:它首先查看参数,我们取这个掩码矩阵,找出矩阵返回正值或True值的所有位置,这些位置都在对角线以上,然后替换它们。

因此,attention_scores.masked_fill函数所做的本质上是:它获取注意力分数矩阵,并将对角线以上的所有元素替换为负无穷。这正是我们想要的。

现在我们要做的是:我们取这个矩阵,应用torch.softmax。就像我们之前做的那样,首先我们取掩码矩阵,将其除以键维度的平方根,然后应用softmax。这将确保所有的无穷大值无论如何都会变成零,并且每一行将求和为1。

现在,我的最终注意力权重矩阵看起来像这样,你会看到数据泄漏问题不存在了,因为我在所有未来元素被设置为负无穷之后应用了softmax,其次,所有注意力权重矩阵的行求和为1。

因此,因果注意力机制得到了满足,softmax也得到了满足,数据泄漏问题不存在,并且每一行求和为1。所以,我基本上已经获得了在计算这些注意力权重时我想要的一切。

实现Dropout

现在,既然我们已经获得了注意力权重矩阵,我们可以简单地将其与值矩阵相乘,得到上下文向量。就是这样。这是在Python中实现因果注意力机制的方法。

但是,通常与因果注意力机制一起实现的还有一个额外的步骤,那就是使用Dropout实现因果注意力机制。

如果你不熟悉Dropout,它实际上是一种深度学习技术,你取一个神经网络,并随机将不同层中的神经元切换为零。这样做通常是因为在训练时,一些神经元变得懒惰,它们不工作,因为它们意识到其他神经元无论如何都在做大部分工作。这就是懒惰神经元问题或相互依赖问题。

Dropout确保的是:当一个懒惰的神经元看到其他神经元被关闭时,它被迫工作。这是思考这个问题的最简单方式。因此,Dropout随机关闭神经元,确保所有神经元都参与工作,这导致更好的泛化,防止过拟合,并在测试数据上表现更好。

Dropout的优势在于它防止过拟合并提高泛化性能。

在Transformer架构中,包括GPT等模型,注意力机制中实现了Dropout,并且通常应用于两个特定领域:第一个是在计算注意力分数之后,第二个是在将注意力权重应用于值向量之后。

因此,有两种特定的方式可以实现Dropout。第一种是在获得上下文向量本身之后,在将注意力权重应用于值向量之后,你可以实现Dropout。但更常见的方式是在计算注意力权重或注意力分数之后实现Dropout,因此我们将考虑这种方式。

本质上,在Dropout中完成的是:假设我们有一个实现了因果注意力的注意力权重矩阵,所有未来词元都已被掩码,我们将要做的是首先创建一个Dropout掩码。这个Dropout掩码指定了哪些神经元需要被随机关闭。例如,如果我们实现一个概率为0.5的Dropout,这意味着平均每行有50%的注意力权重将被关闭。

这就是Dropout的实现方式。这是一个Dropout掩码,你可以在这里看到,掩码出现的位置,那些特定的元素需要被清零。

因此,Dropout用非常简单的术语来说就是:它查看行,然后以给定的概率随机关闭注意力权重。

现在,让我首先在Python中实现Dropout。在下面的代码示例中,我们将使用50%的Dropout率,这意味着我们将掩码掉一半的注意力权重。稍后,当我们训练GPT模型时,我们将使用更低的Dropout率,大约0.1或0.2。

在下面的代码中,我们将PyTorch的Dropout实现应用于一个由1组成的6x6张量,用于说明目的,然后我们将其实际应用于我们已有的注意力权重矩阵。

假设我们有一个6x6的示例矩阵,我在这里打印出来。然后我们将实现torch.nn.Dropout(0.5)。这将做的是:它将查看每一行,然后平均关闭50%的权重。

由于50%的权重被关闭,所有其他未被关闭的权重将按比例重新缩放。因此,所有未被关闭的其他权重将重新缩放2倍,即除以0.5或乘以2。

如果你看这里的第一行,你会看到两个权重被关闭;看第二行,你会看到四个权重被关闭;看第三行,你会看到一个权重被关闭。请记住,这是概率性的。

因此,如果你取10,000行,你会看到平均每行有50%被关闭。这并不保证每行恰好有三个或四个权重被关闭,但平均每行有三个权重被关闭。

当以50%的速率将Dropout应用于注意力权重矩阵时,矩阵中一半的元素被随机设置为0。请记住,这是概率性的。为了补偿活动元素的减少,矩阵中剩余元素的值按比例放大2倍。这就是PyTorch中torch.nn.Dropout的实现方式。

这种缩放对于保持注意力权重的整体平衡至关重要,确保注意力机制的平均影响力在训练和推理阶段保持一致。

现在,让我们实际获取之前已有的注意力权重,这些是最终的注意力权重,我们将对其应用Dropout层。

我获取注意力权重矩阵并对其应用Dropout。Dropout被定义为一个类,并且创建了Dropout类的一个实例,输入参数是0.5,这意味着Dropout率为0.5。

在这里,你可以看到,与原始的注意力权重矩阵相比,在应用Dropout后,一些注意力权重被随机设置为0,而未被设置为0的权重被放大了2倍。

因此,正如你所看到的,得到的注意力权重矩阵现在有额外的元素被清零,剩余的元素被重新缩放。这正是我们想要的。

实现因果注意力类

现在我们已经对因果注意力和Dropout掩码有了实际的理解,我们将在Python中实现一个因果注意力类。

这也是我们接下来要在白板上看到的部分。

我们将要看到的下一个部分是实现一个因果注意力类,它将因果注意力和Dropout合并到我们之前实现的自注意力类中。

为此,首先我希望你对我们将要实现的内容有一个直观的理解,这将使理解代码变得容易得多。如果你理解了自注意力类,当我们实现这个因果注意力时,除了几个小的变化外,其他部分将完全相同。

我们将有输入,将其与可训练的查询权重、键权重和

17:多头注意力机制第一部分 - 基础与Python代码

概述

在本节课中,我们将要学习多头注意力机制的基础知识。多头注意力是现代大语言模型的核心组件之一。我们将从回顾已学的因果注意力机制出发,逐步理解如何将多个注意力头组合起来,并最终用Python代码实现一个基础的多头注意力包装器。

从因果注意力到多头注意力

上一节我们介绍了因果注意力机制,它确保了模型在预测下一个词时,只关注序列中位于该词之前的信息。本节中我们来看看如何将这种注意力机制扩展为更强大的多头注意力。

因果注意力机制回顾

因果注意力机制的目标是将输入词嵌入向量转换为更丰富的上下文向量。输入词嵌入向量包含单个词的语义信息,但不包含该词与序列中其他词的关系信息。上下文向量则同时编码了这两种信息。

其工作流程如下:

  1. 输入是一个形状为 (batch_size, num_tokens, input_dim) 的张量。
  2. 输入分别与三个可训练的权重矩阵 W_qW_kW_v 相乘,得到查询(Queries)、键(Keys)和值(Values)矩阵。
    • queries = inputs @ W_q
    • keys = inputs @ W_k
    • values = inputs @ W_v
  3. 计算注意力分数:attention_scores = queries @ keys.T
  4. 应用因果掩码,将注意力分数矩阵主对角线上方的元素置为 -inf,确保每个词只关注自身及之前的词。
  5. 对掩码后的注意力分数应用 softmax 函数,得到注意力权重矩阵,其每一行之和为1。
  6. 将注意力权重与值矩阵相乘,得到最终的上下文向量:context_vectors = attention_weights @ values

以下是我们在上一讲中实现的因果注意力类的核心代码框架:

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)
        # ... 其他初始化(如dropout、掩码)

    def forward(self, x):
        queries = self.W_query(x)
        keys = self.W_key(x)
        values = self.W_value(x)
        # ... 计算注意力分数、应用掩码、softmax
        context_vec = attention_weights @ values
        return context_vec

多头注意力的核心思想

多头注意力的核心思想非常简单:并行运行多个独立的注意力机制(即多个“头”),然后将它们的输出组合起来

多头注意力的工作流程

与单头注意力相比,多头注意力做出了以下关键改变:

  1. 多组权重:不再只有一组 (W_q, W_k, W_v),而是为每个注意力头准备独立的一组可训练权重矩阵。
  2. 并行计算:输入同时与所有头的权重矩阵相乘,生成多组查询、键和值矩阵。
  3. 独立计算上下文向量:每一组 (Q, K, V) 独立进行注意力计算,生成一个上下文向量矩阵。
  4. 拼接输出:将所有头计算出的上下文向量矩阵沿着最后一个维度(特征维度)拼接起来,形成最终的、维度更高的上下文向量。

假设我们设置 d_out = 2(每个头的输出维度),并使用 num_heads = 2(两个头)。

  • 单头注意力:输入一个形状为 (6, 3) 的矩阵(6个词,每个词嵌入维度为3),输出一个形状为 (6, 2) 的上下文向量矩阵。
  • 多头注意力(2个头):每个头独立输出一个 (6, 2) 的上下文向量矩阵。将这两个矩阵拼接后,得到最终的上下文向量矩阵,其形状为 (6, 4)。这里的 4 等于 d_out * num_heads

这种设计的优势在于,不同的注意力头可以学习关注输入序列中不同方面的关系(例如语法结构、语义关联、指代关系等),从而使模型的表示能力更强。

实现多头注意力包装器

理解了核心思想后,实现就变得非常直观。我们可以创建一个包装器类,它包含多个 CausalAttention 实例,并在前向传播中依次调用它们,最后拼接结果。

以下是实现多头注意力包装器的步骤:

首先,我们初始化指定数量的 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):
        # 收集每个注意力头的输出
        head_outputs = [head(x) for head in self.heads]
        # 沿最后一个维度(特征维度)拼接所有头的输出
        context_vectors = torch.cat(head_outputs, dim=-1)
        return context_vectors

代码演示与维度分析

让我们用具体数据来演示这个包装器是如何工作的。

首先,定义输入数据。假设我们有一个包含6个词的序列,每个词的嵌入向量是3维。我们创建一个包含2个这样序列的批次。

inputs = torch.tensor([[[0.7, 2.0, 1.0],  # “your” 的嵌入
                        [0.3, 0.5, 0.1],  # “journey”
                        [0.9, 0.2, 0.8],  # “starts”
                        [0.4, 0.6, 0.3],  # “with”
                        [0.1, 0.9, 0.5],  # “one”
                        [0.2, 0.4, 0.7]]], # “step”
                       [[0.8, 1.9, 1.1],  # 第二个批次,序列相似
                        [0.4, 0.4, 0.2],
                        [0.8, 0.3, 0.9],
                        [0.5, 0.5, 0.4],
                        [0.2, 0.8, 0.6],
                        [0.3, 0.3, 0.8]]])
print(f"输入批次形状: {inputs.shape}")  # 输出: torch.Size([2, 6, 3])

接下来,初始化多头注意力包装器。我们设置输入维度 d_in=3,每个头的输出维度 d_out=2,使用2个注意力头。

torch.manual_seed(123)
batch = inputs
context_length = batch.shape[1]  # 6
d_in = batch.shape[2]            # 3
d_out = 2
num_heads = 2

mha = MultiHeadAttentionWrapper(d_in=d_in, d_out=d_out,
                                context_length=context_length,
                                dropout=0.0,
                                num_heads=num_heads)

最后,将批次数据输入模型,并检查输出形状。

context_vecs = mha(batch)
print(f"多头注意力输出形状: {context_vecs.shape}")  # 输出: torch.Size([2, 6, 4])

维度分析

  • 2:批次大小。我们输入了2个序列样本。
  • 6:序列长度(词的数量)。每个词都产生了一个上下文向量。
  • 4:每个词的最终上下文向量的维度。这来源于 d_out * num_heads = 2 * 2 = 4。前两维来自第一个注意力头,后两维来自第二个注意力头。

这个输出就是经过多头注意力机制增强后的序列表示,它将作为后续神经网络层(如前馈网络)的输入。

当前实现的局限性与展望

我们目前实现的多头注意力包装器虽然功能正确,但存在一个明显的效率问题:它是以串行方式逐个处理每个注意力头的。在实际的大型模型中,注意力头的数量可能非常多(例如GPT-3有96个头),这种串行计算会非常缓慢。

在下一讲中,我们将学习一种更高效、更常用的实现方式——通过权重分割实现并行多头注意力。其核心思想是通过一次大型矩阵乘法,同时计算出所有头的查询、键和值,然后利用张量变形和视图操作来模拟并行计算,最终再合并结果。这种方法能充分利用现代GPU的并行计算能力,是生产环境中使用的标准实现。

总结

本节课中我们一起学习了多头注意力机制的第一部分。我们从回顾因果注意力机制出发,理解了多头注意力的核心思想:使用多组独立的注意力权重并行处理输入,以捕获更丰富的序列关系信息。我们实现了一个基础的多头注意力包装器,它通过串行调用多个 CausalAttention 实例并拼接其结果来工作。通过代码演示,我们看到了输入如何从形状 [2, 6, 3] 转换为输出形状 [2, 6, 4],其中最后一个维度 4 是每个头的输出维度与头数的乘积。最后,我们指出了当前串行实现的效率瓶颈,并为下一讲将介绍的高效并行实现做好了铺垫。

17:多头注意力机制第二部分 - 完整数学原理详解

在本节课中,我们将深入学习多头注意力机制的第二种、更高效的实现方式。我们将通过一个具体的例子,一步步拆解其背后的数学原理和代码实现,确保你能完全理解这个Transformer架构的核心组件。

上一节我们介绍了多头注意力机制的基本概念和一种直观但低效的实现方式。本节中,我们将看看如何通过“权重分割”技术,用更少的矩阵乘法实现相同的功能,并深入理解其背后的张量维度变换。

概述:更高效的多头注意力

在上一部分的实现中,如果有 n 个头,我们需要进行 n 次独立的矩阵乘法来分别计算每个头的查询、键和值矩阵。对于像GPT-3这样使用96个注意力头的模型,这会导致巨大的计算开销。

本节介绍的核心思想是:只进行一次矩阵乘法来得到一个大矩阵,然后将其分割给多个头使用。这种方法被称为“带权重分割的多头注意力”,它显著减少了计算量。

核心概念与决策

在开始代码之前,我们需要明确几个关键参数。我们将通过一个具体的例子来贯穿整个讲解过程。

输入定义
假设我们的输入是一个句子,包含三个词(或标记):thecatsleeps。我们将每个词编码为一个6维的向量。同时,我们设定批处理大小为1(即一次处理一个句子)。因此,输入张量 x 的形状为:

x.shape = (batch_size, num_tokens, d_in) = (1, 3, 6)

其中 d_in=6 是输入嵌入的维度。

输出维度决策
我们决定输出上下文向量的维度 d_out 也为6。这在GPT类模型中很常见,即 d_in = d_out

注意力头数量决策
我们决定使用 num_heads = 2 个注意力头。

头维度计算
每个头的维度 head_dim 由总输出维度除以头数得到:

head_dim = d_out / num_heads = 6 / 2 = 3

实现步骤详解

以下是实现带权重分割的多头注意力的11个核心步骤。我们将结合代码和图示,详细解释每一步的张量形状变化和其含义。

步骤1:初始化可训练权重矩阵

我们需要为查询、键和值初始化三个可训练的权重矩阵:W_qW_kW_v。它们的维度必须与输入兼容。

由于输入 x 的最后一个维度是 d_in=6,而我们需要输出维度为 d_out=6,因此这些权重矩阵的形状应为 (d_in, d_out) = (6, 6)

在代码中,这通常在类的 __init__ 构造函数中完成,使用不带偏置的线性层(nn.Linear)来初始化这些矩阵,因为线性层在反向传播时经过了优化。

# 在 __init__ 构造函数中
self.W_query = nn.Linear(d_in, d_out, bias=False)
self.W_key = nn.Linear(d_in, d_out, bias=False)
self.W_value = nn.Linear(d_in, d_out, bias=False)

步骤2:计算查询、键、值矩阵

将输入 x 与上一步初始化的权重矩阵相乘,得到初始的查询、键和值矩阵。

Q = x @ W_q  # 形状: (1, 3, 6)
K = x @ W_k  # 形状: (1, 3, 6)
V = x @ W_v  # 形状: (1, 3, 6)

此时,QKV 的形状都是 (batch_size=1, num_tokens=3, d_out=6)。每一行对应一个词(标记),每一行有6个值,因为 d_out=6

步骤3:重塑张量以引入“头”维度

目前,QKV 是三维张量,没有显式地包含“注意力头”这个维度。我们需要将最后一个维度 d_out “展开”,使其包含 num_headshead_dim

因为 d_out = num_heads * head_dim = 2 * 3,我们可以将形状从 (1, 3, 6) 重塑为 (1, 3, 2, 3)

新的维度含义是:(batch_size, num_tokens, num_heads, head_dim)

  • 第一个 3:代表3个词(标记)。
  • 2:代表2个注意力头。
  • 最后一个 3:代表每个头的维度是3。

可以这样理解:对于第一个词,它有2个注意力头(两行),每个头是一个3维向量。

在代码中,使用 .view() 方法进行重塑:

Q = Q.view(batch_size, num_tokens, num_heads, head_dim)
K = K.view(batch_size, num_tokens, num_heads, head_dim)
V = V.view(batch_size, num_tokens, num_heads, head_dim)

步骤4:转置以按“头”分组

上一步得到的张量是按“词(标记)”分组的,即对于每个词,我们能看到它的两个头。但为了后续能独立计算每个头的注意力分数,更方便的做法是按“头”来分组。

因此,我们需要交换“词数”和“头数”这两个维度的位置。将形状从 (1, 3, 2, 3) 转换为 (1, 2, 3, 3)

新的维度含义是:(batch_size, num_heads, num_tokens, head_dim)

  • 2:代表2个注意力头。
  • 第一个 3:代表3个词(标记)。
  • 最后一个 3:代表每个头的维度是3。

现在,第一个“块”包含了第一个头对所有3个词的处理结果,第二个“块”包含了第二个头对所有3个词的处理结果。这为并行计算每个头的注意力做好了准备。

在代码中,使用 .transpose(1, 2) 来交换第1和第2个维度(索引从0开始):

Q = Q.transpose(1, 2) # 形状变为 (1, 2, 3, 3)
K = K.transpose(1, 2)
V = V.transpose(1, 2)

步骤5:计算注意力分数

注意力分数的计算公式是:注意力分数 = Q @ K^T。这里我们需要计算每个头自己的 QK^T 的点积。

我们的 QK 形状都是 (1, 2, 3, 3)。为了进行矩阵乘法,我们需要对 K 转置最后两个维度(head_dimnum_tokens),使 K^T 的形状变为 (1, 2, head_dim, num_tokens) = (1, 2, 3, 3)

然后进行批量矩阵乘法:

注意力分数 = torch.matmul(Q, K.transpose(2, 3))

结果的形状是 (batch_size, num_heads, num_tokens, num_tokens) = (1, 2, 3, 3)

如何理解这个 (3, 3) 的矩阵?
对于每个头(比如第一个头),这个 3x3 矩阵的每一行对应一个“查询”词,每一列对应一个“键”词。例如,第2行第1列的值,表示当“查询”是第二个词(cat)时,它与第一个词(the)的原始关联度(注意力分数)。

步骤6:应用因果注意力掩码

在语言建模中,我们通常使用因果(或掩码)注意力,即一个词只能关注它自身以及它之前的词,不能关注未来的词。这通过一个掩码矩阵来实现。

我们创建一个上三角矩阵(主对角线及以上元素为1,其余为0),然后将1替换为一个极小的负数(如 -1e9),再将其加到注意力分数上。这样,在后续应用 softmax 时,这些位置的概率就会趋近于0。

mask = torch.triu(torch.ones(context_length, context_length), diagonal=1).bool()
注意力分数 = 注意力分数.masked_fill(mask[:num_tokens, :num_tokens], float(‘-inf’))

步骤7:缩放注意力分数

在应用 softmax 之前,我们需要将注意力分数除以 sqrt(head_dim)。这是为了在 head_dim 较大时,防止点积后的结果方差过大,导致 softmax 梯度消失,从而稳定训练。

缩放后的注意力分数 = 注意力分数 / (head_dim ** 0.5)

步骤8:计算注意力权重

对缩放后的注意力分数应用 softmax 函数,确保每个“查询”词对所有“键”词的注意力权重之和为1。

注意力权重 = F.softmax(缩放后的注意力分数, dim=-1)

dim=-1 表示对最后一个维度(即“键”词所在的维度)进行 softmax。现在,注意力权重 的形状仍然是 (1, 2, 3, 3),但每一行的和都为1,具有了概率解释。例如,对于第一个头的第二行,它表示第二个词(cat)应该将多少注意力分配给 [the, cat, sleeps] 这三个词。

步骤9:计算上下文向量

注意力机制的最终目标是得到每个词的上下文向量。这通过将注意力权重与值矩阵 V 相乘得到。

上下文向量 = torch.matmul(注意力权重, V)
  • 注意力权重 形状: (1, 2, 3, 3)
  • V 形状: (1, 2, 3, 3)

矩阵乘法发生在最后两个维度上:(3, 3) @ (3, 3) -> (3, 3)。结果 上下文向量 的形状是 (1, 2, 3, 3),即 (batch_size, num_heads, num_tokens, head_dim)

如何理解?
对于每个头(比如第一个头),现在得到一个 3x3 的矩阵。它的每一行对应一个词(标记)的上下文向量,而这个上下文向量是一个 head_dim=3 维的向量,它融合了该词根据注意力权重从所有词的值中提取的信息。

步骤10:转置以合并头输出

目前,上下文向量的形状是 (1, 2, 3, 3),即按头分组。为了将多个头的输出合并,我们需要先将形状转换为按词(标记)分组。

交换“头数”和“词数”的维度位置,将形状从 (1, 2, 3, 3) 转换为 (1, 3, 2, 3)

新的维度含义是:(batch_size, num_tokens, num_heads, head_dim)

现在,对于第一个词,我们可以看到它来自两个头的、维度均为3的上下文向量。

在代码中:

上下文向量 = 上下文向量.transpose(1, 2) # 形状变为 (1, 3, 2, 3)

步骤11:合并多头输出

最后一步,我们将每个词对应的多个头的输出合并起来,形成该词的最终上下文向量。

由于 d_out = num_heads * head_dim = 2 * 3 = 6,我们可以将最后两个维度 (num_heads, head_dim) 重塑(展平)为一个维度 d_out

将形状从 (1, 3, 2, 3) 重塑为 (1, 3, 6)

上下文向量 = 上下文向量.contiguous().view(batch_size, num_tokens, d_out)

.contiguous() 确保张量在内存中是连续存储的,这样 .view() 操作才能安全执行。

现在,我们得到了最终的输出:

  • 形状:(batch_size=1, num_tokens=3, d_out=6)
  • 含义:对于句子中的3个词,每个词都获得了一个新的、6维的上下文感知表示(上下文向量)。

代码测试与总结

我们可以用之前定义的参数来测试这个多头注意力类。输入一个形状为 (2, 3, 6) 的批量数据(两个句子,每个句子3个词,每个词6维嵌入),经过处理后,输出形状应为 (2, 3, 6)。这与我们的预期完全一致。

本节课中我们一起学习了多头注意力机制最核心、最高效的实现方式。我们通过一个具体的例子,详细追踪了从输入到输出过程中每一步的张量形状变化和数学运算。你理解了如何通过一次矩阵乘法和后续的拆分、转置、合并操作,来替代多次独立的矩阵乘法,从而实现高效的多头注意力计算。

掌握这些维度变换和线性代数操作,是深入理解Transformer架构乃至所有现代大语言模型的关键。虽然GPT-3等模型使用了更多的头(如96个)和更大的维度(如768),但其基本原理与我们今天所学的完全一致。希望这次详细的拆解能帮助你建立起坚实的理论基础,在后续构建和调试自己的语言模型时充满信心。

18:19-大语言模型架构鸟瞰图

大家好,欢迎来到《从零开始搭建大语言模型》系列讲座。首先,让我带大家回顾一下我们在这个系列中已经学到的内容。通过这张图,我们可以看到,我们计划完全从零开始构建一个大语言模型,并且将分三个阶段进行。在第一阶段,我们将为构建LLM打下基础。在第二阶段,我们将对LLM进行预训练。在第三阶段,我们将对LLM进行微调。

我们目前仍处于第一阶段,并且到目前为止,我们已经涵盖了第一阶段的两个方面:数据准备与采样(包括分词、向量嵌入和位置嵌入),以及最近详细学习的注意力机制。特别是,如果你已经学习了注意力机制部分,我们用了四到五节课,从简化的自注意力、自注意力、因果注意力到多头注意力进行了非常详细的讲解。如果你还没有学习这些课程,我强烈建议你去学习,因为注意力机制是理解后续所有内容的基础构建模块。

如果你已经观看了之前的所有课程,并且运行了我提供的代码,那真是太棒了,我要祝贺你。理解注意力是理解大语言模型最困难的方面之一。如果你已经学到了这里,接下来的部分对你来说会更容易一些。

那么,让我们开始吧。在接下来的系列视频中,我们将学习第三部分,即大语言模型架构。和往常一样,我会将其分解为多个视频,不会在一个视频中涵盖所有内容。我们将非常详细地、从零开始地讲解每一个视频。今天,现在,是大语言模型架构模块的第一个视频。让我们开始吧,我认为这对所有一直跟随学习的同学来说,将是一个非常有趣的模块。

课程概述

在本节课中,我们将学习大语言模型架构的顶层视图。我们将了解模型的输入和输出是什么,以及各个主要组件是如何组合在一起的。这为我们后续深入每个组件细节的学习提供了路线图。

架构鸟瞰图

在之前的课程中学习了注意力机制之后,现在让我们来学习LLM架构。我首先想给大家展示一下LLM架构的整体视图。这是一个鸟瞰图,我们将详细讲解其中的每一个方面,但现在我想展示你已经学到的内容,以及它们如何融入接下来的学习中。这总是有助于学习过程。

想象一下,你正步行穿过一片森林,想要到达另一边。了解你已经走过的路径,并对接下来要走的路有一些概念总是好的。这样,你就可以将接下来要学习的内容与过去的知识联系起来,从而帮助你到达森林的尽头。在我们的案例中,了解之前的知识如何融入接下来要学习的内容,将真正帮助你更好地理解LLM。

最初,我们从分词开始,然后学习了向量嵌入和位置嵌入。我们为每个词元获得的最终嵌入向量,随后通过掩码多头注意力被转换为上下文向量。

因此,注意力或更准确地说多头注意力的主要目的是获取输入嵌入向量,并将它们转换为上下文向量。上下文向量是比嵌入向量更丰富的表示形式,因为它们不仅包含词元的语义含义,还包含该词元与句子中所有其他词元关系的信息。

掩码多头注意力构成了一个称为Transformer块的重要组成部分。Transformer块是大语言模型架构中最重要的部分,它实际上是一个由许多相互关联的不同方面组成的模块。

深入Transformer块

让我们放大这个Transformer块,打开它,看看它包含什么。如果你放大Transformer块,你会看到它包含许多东西,掩码多头注意力是其中的一部分。所以,你在多头注意力中学到的内容就在这里。

想象你有一个句子,比如“every effort moves you”,你想预测下一个词。第一步是将每个词转换为输入嵌入或向量嵌入。我们假设我们也添加了位置嵌入。这些嵌入向量随后被传递到Transformer块。

Transformer块的第一部分是层归一化。第二部分是掩码多头注意力,它将输入嵌入词元转换为上下文向量。这些上下文向量随后被传递到一个Dropout层

你可以注意到这些加号。从这些地方连接到加号的箭头被称为快捷连接。快捷连接的输出进入另一个层归一化层。然后我们有一个前馈神经网络,接着连接另一个Dropout层,这里还有一个快捷连接

如果你进一步放大前馈神经网络,你会看到它有一个称为GELU激活函数的东西。

如果你看到所有这些术语,可能会想它们是什么意思?什么是层归一化?什么是Dropout?什么是GELU激活函数?为什么这里有一个前馈神经网络?为什么所有这些东西像这样堆叠在一起?

这些正是我们将在本视频以及接下来的四到五个视频中要涵盖的内容。但请记住,这整个架构有大量可训练的参数和权重。当LLM进行预训练时,这些权重和参数会被优化,最终我们得到输出。

输出的形式和维度与输入相同,然后输出被进一步处理,得到最终的文本。所以,一旦我们从Transformer块得到输出,它就会进入这些输出层,然后解码Transformer块的输出,我们就得到了下一个词。如果输入是“every effort moves you”,那么下一个词就是“forward”。

我只是想给你这个鸟瞰图,让你了解正在发生什么、我们正在构建什么、你已经学到了什么,以及它如何融入我们计划接下来学习的内容。

在接下来的这组课程中,我们将深入这个Transformer块,并理解这里提到的每一个东西。我们将首先学习如何将这些不同的层堆叠在一起(这将在今天的课程中涉及),然后我们将深入每一层并学习它们。

我们将有单独的课程讲解层归一化、单独的课程讲解这些快捷连接、单独的课程讲解带有GELU激活的前馈神经网络。我们将把所有这些东西堆叠在一起,最后,我们将有单独的课程讲解如何解码Transformer的输出以产生下一个词。

回顾已学内容

我希望你已经理解了为什么我们要学习掩码多头注意力,因为如果你没有学习这个,你看,它构成了Transformer块如此关键的一部分。为了学习这一个小的模块,我们花了五节课,超过七个小时,但这正是注意力机制的重要性。如果这个模块被移除,如果这个掩码多头注意力块被移除,大语言模型将失去其所有能力,我们将回到循环神经网络和长短期记忆网络的时代。

那么,让我们看看我们已经学到了什么。我们学习了输入分词、学习了词元嵌入加位置嵌入,以及学习了掩码多头注意力。

让我先简要概述一下掩码多头注意力,以防你忘记了。

我们有像这样堆叠在一起的输入嵌入向量。我们有一组键、查询和值矩阵,它们与输入相乘得到查询、键和值。查询与键的转置相乘,得到注意力分数,然后转换为注意力权重。注意力权重再与值矩阵相乘,得到上下文向量。由于我们有多个注意力头,上下文向量被堆叠在一起,得到一个组合的上下文向量。这就是多头注意力块中发生的事情。

到目前为止,我们学到的整个过程也可以这样可视化:如果你有输入文本“every effort moves you”,它首先被分词(GPT使用我们之前学过的字节对编码分词器)。每个词元被转换为一个词元ID。每个词元ID被转换为一个向量嵌入(向量化表示)。这些向量嵌入被传递到GPT模型中,该模型包含我之前展示的Transformer块。然后有一个输出,该输出被进一步解码,得到输出文本。

对于GPT-2,使用的词元嵌入向量大小为768维,这意味着每个词元ID被转换为一个768维的向量。输出的生成方式使得维度匹配,因此每个768维的输入词元嵌入对应一个768维的输出向量。然后我们对输出进行一些后处理,以生成下一个词,即“forward”。所以是“every effort moves you forward”。

即将学习的内容:Transformer块

我们尚未学习的是Transformer块,我们将在今天的课程中开始学习它。在后续课程中,我们将逐步深入这个块的每一层。

因此,在这组四到五节课中,我们不会使用玩具问题或玩具模型,我们将直接使用GPT-2。我们将使用与构建GPT-2模型相同的架构。

如果你看这篇论文,这是介绍GPT-2的论文。如果你看他们拥有的模型,他们有一个小模型和一个拥有1542百万参数的大模型。小模型有117百万参数,后来修订为124百万参数,这也正是我们在这组课程以及本系列视频的其余部分将要使用的。我们将构建一个拥有124百万参数、12层的LLM。这些层意味着我们有12个Transformer块。D_model(向量嵌入大小)是768。这些参数将在今天的课程以及其余课程中使用。

我们为什么使用GPT-2而不是GPT-3或GPT-4?一个原因是GPT-2更小,因此更适合在我们的本地机器上运行。第二个原因是OpenAI只公开了GPT-2的权重。OpenAI目前尚未公开GPT-3和GPT-4的权重。这就是开源的情况,OpenAI目前是闭源的,而Meta的Llama模型是开源的,所有权重都已发布。这就是我们坚持使用GPT-2的原因,因为它的权重是公开的,我们将在后续的一个视频中加载这些权重。

GPT-2配置参数

以下是我们将要使用的配置。观看视频的各位可以在这里暂停,尝试理解这里的每一个术语。我们在之前的课程中已经涵盖了所有这些,所以我将在这里暂停,也请你们暂停一下,尝试思考这些术语。无论如何,我会解释每一个术语,但我希望你们也尝试一下去理解。

  • 词汇表大小:这意味着我们从一个词汇表开始。GPT-2使用字节对编码器,所以它是一个子词分词器。词汇表大小基本上是存在多少个子词。这将用于分词。如果词汇表是词级分词,那么如果句子是“every step moves you forward”,词汇表将包含“every”、“step”、“moves”、“you”、“forward”,这样分词就会发生。但如果你使用GPT-2使用的字节对编码器,它是一个子词分词器,词汇表大小是50257,它可能包含字符、子词,也可能包含完整的词。当我们考虑GPT-2时,这对于分词非常有用。当我们进行分词时,会发生的是:我们有一个词汇表,其中有词元,每个词元对应一个词元ID。每当给我们一段新文本时,使用该词汇表,文本被转换为词元,然后这些词元被转换为词元ID。如果某些文本不属于词汇表,那就是所谓的“词汇表外”问题,字节对编码器不会遇到这个问题,因为它是一个子词分词器。我们在关于嵌入的课程中已经讲过词汇表大小,如果你不清楚,请参考那部分。
  • 上下文长度:上下文长度基本上指的是用于预测下一个词的最大词数。所以,如果上下文长度是1024(GPT-2实际使用的),我们将查看最多1024个词来预测下一个词。不会有我们查看2000个词来预测下一个词的情况。当我说“词”时,实际上是指“词元”,这并不完全正确,因为GPT-2使用字节对编码分词器,这是一种子词分词方案。但为了本讲座的直观理解,如果我交替使用“词”和“词元”,那是因为这对直觉有好处。
  • 嵌入维度:现在,我们词汇表中的每个词元都将被投影到一个向量空间中。例如,词元“your journey starts with one step”是每个词元的三维向量表示。嵌入应该是这样的:如果“journey”和“starts”在意义上更相似,它们在这个嵌入空间中应该更接近。这是一个三维嵌入空间。在GPT-2中,我们使用768维嵌入空间,很难在这里展示,但你可以想象一个768维的嵌入空间,词被投影到其中。如果你在想我们如何学习这些投影?我们怎么知道“journey”对应哪个向量?这在GPT-2中也是训练的。当我们看Transformer块时,你会看到嵌入层本身不是固定的,我们将训练嵌入层,以便每个词都能正确嵌入,从而捕获语义含义。
  • 注意力头数量:这是注意力头的数量,等于12。如果你看这里的图,我告诉过你,会创建多个查询、键和值矩阵,对吧?注意力头的数量越多,创建的这些矩阵就越多。所以,如果我们有12个注意力头,就意味着会有12个这样的查询、键和值矩阵。
  • 层数:这是Transformer块的数量。记住,这与注意力头的数量不同。层数是我们将拥有多少个这样的层。所以这是一个Transformer块层,它包括多头注意力。在这一层内,将有12个注意力头。但就这些Transformer块本身而言,可以有12个块。所以层数和头数不一定相同。这里我们使用12个Transformer块。我们稍后会看到它们是如何堆叠在一起的。
  • Dropout率:基本上是Dropout率。
  • QKV偏置:是初始化查询、键和值矩阵时的偏置项。默认情况下,这总是设置为False。

我想在这里提到的另一件事是,我们正在看的是GPT-2小模型,它使用了12个Transformer块。但正如我们在这里看到的,他们有四个GPT-2模型。如果你从左到右看,你会看到小模型、中模型有24个Transformer块、大模型有36个、最大模型(超大)有48个Transformer块。你会看到维度也从左到右增加。我们使用的是768维的GPT-2小模型,但如果你从左到右看,你会看到1024、1280,最后GPT-2超大模型的维度是1600。

构建GPT占位符架构

我希望你已经理解了这个配置。我们现在要做的是,我将带你进入代码,并构建一个GPT占位符架构。这是什么意思?这基本上意味着,无论我在这里向你展示什么,我知道你还没有理解层归一化、快捷连接,甚至Transformer块到底有什么,前馈神经网络是什么,GELU激活函数是什么。现在我想做的是,我想为我们的代码创建一个骨架,这些不同的块将组合在一起。我们将在后续部分对它们进行编码,并且我们将为它们各自开设单独的课程。但现在,我们将构建一个GPT占位符架构,我们也称之为虚拟GPT模型。这将实际上给出一个鸟瞰图,展示一切是如何组合在一起的。

所以,这个鸟瞰图再次非常重要的原因是,你将看到我们在接下来的课程中计划做什么,这就是为什么骨架对于像LLM架构这样复杂的话题非常重要,因为多个东西必须组合在一起。首先让我们放大视野,看看所有东西必须如何组合在一起,然后在后续课程中,我们将开始编写代码。

代码实现概览

那么,现在我们要做的是,我们将从零开始实现GPT模型来生成文本,我将向你展示代码是如何执行的,但在代码的每一步,我会再次带你回到白板,这样你就可以可视化每个参数的含义。对你来说,阅读一行代码并可视化它的样子非常重要,只有这样你才能真正理解代码。

这是我们在白板上讲过的GPT配置。我希望你已经理解了这里每个术语的含义。如果没有,只需再看一下含义,或者回顾一下我们之前的课程。但非常重要的是,你不要在不理解含义的情况下跳过它。

正如我告诉你的,我们将构建GPT架构。所以这是一个虚拟GPT模型类,我们将为Transformer块使用一个占位符,为层归一化使用一个占位符。

首先,让我给你一个大概的概述,看看我们这里有什么。我们这里有一个虚拟GPT模型,它有前向传播方法。这个前向方法的作用是接收一个输入,在这个前向方法的最后,我们将打印输出。这就是我们的目标。

如果你看这个图,让我把这个图展示给你。这里这是主要的东西,对吧?所以前向方法的作用是接收一个输入,基本上可以只是这些词。然后前向传播的目的是给你下一个词,在这个案例中,下一个词是“forward”,那就是输出。所以,我们想要实现的所有东西都位于中间某处,对吧?

有两个主要块对我们非常重要。首先是Transformer块,我们将在后面的课程中为它创建一个类,而不是在本节课。我们还将为层归一化创建一个类。让我告诉你它们出现在哪里。如果你看这里的Transformer块,Transformer块包含所有这些东西,对吧?层归一化是其中非常重要的一部分。层归一化也会在Transformer之前和之后实现,但它也存在于Transformer内部。所以我们将有一个Transformer块,里面放入我在这里展示的所有这些东西,我们还将有一个单独的层归一化块。我们有一个单独的层归一化块的原因是,它出现在Transformer块中,这没问题,但它也出现在其他地方,所以最好为它定义一个单独的类。

这是我们将稍后定义的类,不是现在。现在让我们看看这个前向方法实际上在做什么。

前向方法首先接收一个输入。让我展示一下那个输入实际上是什么样子。

输入处理流程

前向方法将接收一个输入,假设输入是这个相同的东西,让我写在这里。输入是什么?输入是“Every effort moves you”。假设这是传递给前向方法的输入。让我用不同的颜色写在这里。所以假设输入是“Every”、“effort”、“moves”、“you”。很好,所以这是我的输入。

现在,这个输入被馈送到前向方法的方式是这样的。我们将把这个输入馈送到前向方法,做类似这样的事情。

假设“every effort moves you”是输入,我们首先要使用分词器(字节对编码器),将这些词元转换为词元ID。记住我们之前看到的工作流程。这里的每个词元基本上都被转换为词元ID。然后,在此之后的所有事情都发生在GPT模型类内部。但直到这个阶段,我们必须在外部完成,然后将词元ID传递给GPT模型类。

现在我们有了“every effort moves you”,这将被转换为词元ID,这将被转换为词元ID,这将被转换为词元ID,这将被转换为词元ID,对吧?第一步是每个词元ID将被转换为词元嵌入。这意味着每个词元ID,假设这是ID 1,这是ID 2,这是ID 3,这是ID 4,每个词元ID都需要转换为一个768维向量。一个768维向量嵌入本质上将是输入嵌入。

我们将要这样做的方式是,我们首先要创建一个词元嵌入层。为此,我们将使用PyTorch中的nn.Embedding。这个层实际上做的是创建一个称为词元嵌入矩阵的矩阵,它的行数等于模型词汇表大小,每一行基本上对应一个词元ID。每一行的长度本质上是768。所以现在,如果你想找到ID号为1的向量嵌入,假设ID号1是44,你只需查看这里的第44行,就得到768维向量。如果你想查看“effort”的ID,假设是64,你查看ID号为64的“effort”行,得到768维向量。类似地,假设“you”的ID是85或40000,你向下找到第40000行,得到768维输入嵌入向量。

这就是为什么这个词元嵌入矩阵也被称为查找矩阵,你只需传入词元ID,它就会给你向量嵌入。请记住,这个词元嵌入矩阵中的所有参数,以及这里所有地方的参数,目前都是随机初始化的,我们将训练这些参数。当我们初始化这个词元嵌入层时,它会从高斯分布初始化参数,然后它们的初始值是随机的。稍后当我们进行反向传播时,我们将训练它们。现在,当你查看所有这些嵌入矩阵时,只需知道它们的值目前是随机的。

所以,第一步是将所有这些词元转换为768维嵌入向量,你会看到这已经在这里完成了。

当你进入前向方法时,你首先要查看输入形状,输入形状基本上是批量大小(行数)和序列长度(我们考虑的词元数量)。例如,让我们看看这个。例如,这是这样一个批次。在这个批次中,批次大小是2,所以这个张量中有两行,列数是我们要使用的词元数量。现在,让我们只看一个批次。我输入四个词元作为输入,然后我想得到下一个词。所以“every effort moves you forward”将是下一个词,对吧?

所以形状是批量大小和序列长度。在我展示的例子中,有两个批次,所以

19:层归一化在LLM架构中的作用

在本节课中,我们将深入探讨大语言模型架构中的一个关键组件:层归一化。我们将了解它的工作原理、为何重要,并学习如何用代码实现它。

概述

上一节我们介绍了GPT架构的整体概览,构建了一个包含多个组件的“骨架”模型。本节中,我们将聚焦于其中一个反复出现且至关重要的组件——层归一化。我们将学习它的核心思想、在模型中的位置,以及它如何帮助稳定训练过程。

层归一化的作用与原理

在深度神经网络中,尤其是像GPT这样拥有众多层的模型中,训练过程可能面临梯度消失或梯度爆炸的问题,导致训练不稳定。层归一化的首要优势就是通过控制每层输出的尺度来改善训练的稳定性和效率。

具体来说,当进行反向传播更新参数时,每一层的梯度大小会受到其前一层输出的影响。如果某层的输出值过大或过小,会导致梯度也变得过大或过小。过大的梯度在反向传播中相乘会导致“梯度爆炸”,使学习过程不稳定;过小的梯度则会导致“梯度消失”,使参数更新停滞,学习无法进展。

层归一化通过一个简单的操作解决了这个问题:它调整神经网络每一层的输出,使其均值为0,方差为1

其核心公式如下:
给定一层输出的N个值:x1, x2, ..., xN

  1. 计算均值:μ = (x1 + x2 + ... + xN) / N
  2. 计算方差:σ² = ( (x1-μ)² + (x2-μ)² + ... + (xN-μ)² ) / N
  3. 对每个值进行归一化:x_i_normalized = (x_i - μ) / √(σ² + ε)

其中,ε是一个极小的常数(例如1e-5),用于防止除以零。经过此操作后,新的输出值 x_i_normalized 的均值将接近0,方差将接近1。

层归一化的第二个优势是减轻了“内部协变量偏移”问题。在训练过程中,每一层接收到的输入分布可能会随着参数更新而不断变化,这增加了模型学习的难度,延缓了收敛。层归一化通过固定每层输出的均值和方差,使得输入分布更加稳定,从而加速了模型的收敛速度。

层归一化在GPT架构中的位置

在GPT的Transformer块中,层归一化出现在多个关键位置:

  1. 在输入进入多头注意力机制之前。
  2. 在多头注意力机制的输出之后,进入前馈神经网络之前。
  3. 在所有Transformer块处理完毕后,进入最终输出层之前。

正因为它被多次使用,所以在代码中将其定义为一个独立的类是非常合理且必要的。

代码实现与示例

让我们通过一个简单的例子来理解层归一化的实现。假设我们有一个神经网络层,其输出是一个形状为 (batch_size, features) 的张量。对于每个样本(即每一行),我们独立地计算其所有特征(即该行所有列)的均值和方差,并进行归一化。

以下是实现层归一化类的核心代码:

import torch
import torch.nn as nn

class LayerNorm(nn.Module):
    def __init__(self, emb_dim, eps=1e-5):
        super().__init__()
        self.eps = eps
        # 可学习的缩放(scale)和偏移(shift)参数
        self.scale = nn.Parameter(torch.ones(emb_dim))
        self.shift = nn.Parameter(torch.zeros(emb_dim))

    def forward(self, x):
        # x 形状: (batch_size, seq_len, emb_dim) 或 (batch_size, emb_dim)
        # 沿最后一个维度(特征维度)计算均值和方差
        mean = x.mean(dim=-1, keepdim=True)
        var = x.var(dim=-1, keepdim=True, unbiased=False)
        # 归一化
        x_norm = (x - mean) / torch.sqrt(var + self.eps)
        # 应用可学习的缩放和偏移
        return self.scale * x_norm + self.shift

代码关键点解析:

  • dim=-1:表示沿最后一个维度(特征维度)进行计算。
  • keepdim=True:保持输出张量的维度,便于后续的广播计算。
  • unbiased=False:计算方差时使用 N 而非 N-1 作为分母,这是为了与原始GPT-2的实现保持一致。当特征维度很大时,两者差异可忽略。
  • self.scaleself.shift:这是两个可学习的参数。归一化强制均值为0、方差为1有时会限制模型的表达能力。这两个参数允许模型在训练过程中学习最适合当前数据的缩放和偏移,提供了灵活性。

使用示例:

# 假设一个批次有2个样本,每个样本有5个特征
batch_example = torch.tensor([[1.1, 0.8, 2.3, 4.4, 0.5],
                              [0.2, 1.5, 3.1, 2.8, 1.0]])
print(“原始批次数据:”, batch_example)

# 初始化层归一化层,特征维度为5
ln_layer = LayerNorm(emb_dim=5)
output = ln_layer(batch_example)
print(“归一化后输出:”, output)

# 验证均值和方差
print(“归一化后均值(每行):”, output.mean(dim=-1))
print(“归一化后方差(每行):”, output.var(dim=-1, unbiased=False))

运行上述代码,你将看到每行数据的均值接近0,方差接近1。

层归一化 vs. 批归一化

初学者有时会混淆层归一化和批归一化,它们是不同的:

  • 层归一化:针对单个样本,沿特征维度进行归一化。它与批次大小无关,因此更灵活,尤其适用于批次大小变化或较小的场景(例如在资源有限的硬件上)。
  • 批归一化:针对单个特征,沿批次维度进行归一化。它的效果依赖于批次大小,当批次较小时,统计估计可能不准确。

在Transformer架构和大语言模型中,普遍使用的是层归一化,因为它能提供更稳定的训练动态,且不依赖于批次大小的选择。

总结

本节课我们一起学习了层归一化这一构建大语言模型的核心技术。我们了解到,层归一化通过规范化神经网络中间层的输出(使其均值为0,方差为1),有效缓解了梯度消失/爆炸问题,并减少了内部协变量偏移,从而显著提升了深度模型训练的稳定性和收敛速度。我们还明确了它在GPT架构中的具体位置,并通过代码实现了LayerNorm类,理解了可学习的缩放和偏移参数的作用。最后,我们区分了层归一化与批归一化的关键不同。掌握层归一化是理解现代Transformer模型不可或缺的一步。在接下来的课程中,我们将继续拆解GPT架构的其他组件。

20:GELU激活函数与LLM架构

概述

在本节课中,我们将学习大语言模型架构中的两个核心组件:GELU激活函数前馈神经网络。我们将深入探讨GELU的数学原理、它与传统ReLU的区别,并动手实现一个包含GELU的前馈神经网络模块。


课程背景与目标

在之前的课程中,我们学习了GPT架构的宏观视图以及层归一化技术。本节课,我们将聚焦于Transformer块中的第二个关键构建模块:GELU激活函数及其所在的前馈神经网络。

我们的目标是实现一个属于LLM Transformer块的小型神经网络子模块,即前馈神经网络块。理解这个模块对于掌握Transformer的核心至关重要。


GELU激活函数详解

上一节我们介绍了层归一化,本节中我们来看看激活函数。在大型语言模型中,两个最常用的激活函数是GELU和Swish。本节课我们将重点学习GELU。

在深入GELU之前,我们先回顾一下ReLU激活函数,这有助于理解GELU的设计动机。

ReLU激活函数及其局限性

ReLU函数的定义很简单:

  • 对于输入 x > 0,输出为 x
  • 对于输入 x <= 0,输出为 0

用公式表示为:
ReLU(x) = max(0, x)

虽然ReLU因其线性特性使神经网络更具表现力,但它存在两个主要问题:

  1. 不可微性:在 x = 0 处存在一个尖锐的拐点,导致此处不可导,这可能使深度网络的优化变得困难。
  2. 神经元“死亡”问题:任何负输入都会导致输出为0。一旦某个神经元的输出为负,其后续梯度也将为0,导致该神经元在训练过程中停止更新,无法对学习过程做出贡献。

GELU激活函数的数学原理

GELU(高斯误差线性单元)激活函数旨在解决ReLU的上述问题。其数学定义是输入 x 与标准高斯分布的累积分布函数 Φ(x) 的乘积:

GELU(x) = x * Φ(x)

其中,Φ(x) 是标准正态分布的累积分布函数。

这个设计非常巧妙:

  • x 为很大的正数时,Φ(x) 趋近于1,因此 GELU(x) 趋近于 x,行为类似于ReLU的正半轴。
  • x 为负数时,Φ(x) 是一个小但非零的值,因此 GELU(x) 是一个小的负值,而非严格的0。

GELU的近似实现

直接计算 Φ(x) 比较复杂,因此在实践中(例如在GPT-2中)常使用一个高精度的近似公式:

GELU(x) ≈ 0.5 * x * (1 + tanh[ √(2/π) * (x + 0.044715 * x^3) ])

这个近似函数平滑且易于计算,我们在代码中将使用此形式。

GELU vs. ReLU:优势对比

以下是GELU相对于ReLU的主要优势:

  1. 处处可微:GELU函数是平滑的,没有ReLU在零点的不连续问题,这通常能带来更好的优化特性。
  2. 避免神经元“死亡”:对于负输入,GELU会产生非零输出,使得即使激活值为负的神经元也能继续参与学习过程。
  3. 实证效果更佳:在大型语言模型的实验中,GELU通常表现出比ReLU更好的性能。

现在,我们已经理解了GELU的原理,接下来让我们进入代码,实现GELU激活函数类。

import torch
import torch.nn as nn
import math

class GELU(nn.Module):
    """GELU激活函数,使用GPT-2采用的近似公式。"""
    def __init__(self):
        super().__init__()

    def forward(self, x):
        # 应用GELU近似公式
        return 0.5 * x * (1.0 + torch.tanh(math.sqrt(2.0 / math.pi) * (x + 0.044715 * torch.pow(x, 3.0))))

前馈神经网络架构

理解了GELU之后,我们将其放入上下文——前馈神经网络中。在Transformer块中,前馈神经网络紧随第二个层归一化层之后。

网络结构解析

前馈神经网络是一个相对简单的子模块,但其设计非常精妙。它遵循“扩展-激活-压缩”的模式:

  1. 第一线性层(扩展):将输入投影到一个更高维的空间(通常是输入维度的4倍)。这增加了模型的容量和表现力。
  2. GELU激活层:引入非线性。
  3. 第二线性层(压缩):将高维表示投影回原始的嵌入维度。

这种设计的关键在于:输入和输出的维度保持一致。这确保了Transformer块中的各个层可以轻松地堆叠,而无需担心维度不匹配的问题。

维度变化示例

假设我们使用GPT-2小型模型的配置,其中嵌入维度 d_model = 768

  • 输入:形状为 (batch_size, seq_len, d_model),例如 (2, 3, 768)
  • 第一线性层后:维度扩展为 (2, 3, 4 * 768) = (2, 3, 3072)
  • GELU激活后:维度保持不变 (2, 3, 3072)
  • 第二线性层后:维度压缩回 (2, 3, 768)

与多头注意力机制不同,前馈神经网络独立处理序列中的每个位置(每个词元),不关注词元之间的关系。它的作用是深化每个位置自身的表示。

以下是前馈神经网络模块的代码实现:

class FeedForward(nn.Module):
    """前馈神经网络模块,遵循扩展-激活-压缩模式。"""
    def __init__(self, config):
        super().__init__()
        # 使用nn.Sequential顺序组合各层
        self.layers = nn.Sequential(
            # 扩展层: d_model -> 4 * d_model
            nn.Linear(config.d_model, 4 * config.d_model),
            # GELU激活函数
            GELU(),
            # 压缩层: 4 * d_model -> d_model
            nn.Linear(4 * config.d_model, config.d_model),
        )

    def forward(self, x):
        # 前向传播:依次通过扩展、激活、压缩层
        return self.layers(x)

模块测试

让我们创建一个配置对象并测试我们实现的前馈网络。

# 模拟一个简单的配置对象(实际中来自模型配置)
class GPTConfig:
    def __init__(self):
        self.d_model = 768  # 嵌入维度

config = GPTConfig()

# 实例化前馈网络
ffn = FeedForward(config)

# 创建模拟输入:2个批次,每个批次3个词元,每个词元768维
x = torch.randn(2, 3, config.d_model)
print(f"输入形状: {x.shape}")

# 前向传播
output = ffn(x)
print(f"输出形状: {output.shape}")
# 输出应为: torch.Size([2, 3, 768])

总结

本节课中我们一起学习了Transformer架构中的两个基础但至关重要的组件。

首先,我们深入探讨了GELU激活函数,理解了它的数学定义 GELU(x) = x * Φ(x),以及它通过提供平滑梯度和非零负值输出来解决ReLU“死神经元”问题的优势。我们还实现了其近似公式的代码。

接着,我们构建了前馈神经网络模块。这个模块采用“扩展-激活-压缩”的设计,在保持输入输出维度一致的前提下,通过将特征投影到高维空间来增强模型的学习和泛化能力。我们使用 nn.Sequential 清晰地实现了这个结构。

通过本课的学习,我们离完整理解并构建Transformer块又近了一步。我们已经掌握了GPT主干、层归一化、GELU激活函数和前馈神经网络。在下一讲中,我们将学习最后一个关键部分:残差连接(Shortcut Connections),从而将所有部分组合成完整的Transformer块。

请务必动手运行课程中的代码,这将极大地加深你对每个模块概念和实现的理解。

21:-23-编写完整的LLM Transformer Block

在本节课中,我们将最终编写出完整的Transformer块。在前面的三到四节课中,我们一直在为这节课奠定基础。首先,让我们回顾一下之前三节课中涵盖的内容。

当我们开始这个GPT架构系列时,我们首先介绍了层归一化以及为什么需要层归一化,并编写了一个层归一化类。然后,我们研究了带有GELU激活函数的前馈神经网络。接着,我们探讨了快捷连接及其必要性。我们编写了所有这些组件。今天这节课,就是这四个组件——层归一化、GELU激活函数、前馈神经网络和快捷连接——将共同组成所谓的Transformer块的时刻。

Transformer是大语言模型背后的核心引擎,今天你将看到我们如何编写出完整的Transformer块。让我们开始今天的课程。

首先,我想展示一下Transformer块在整个LLM堆栈中的宏观位置。

正如我们在之前的课程中所见,首先获取输入句子并进行分词。然后将其转换为向量嵌入,并在其上添加位置嵌入。接着应用一个Dropout层,最后我们进入Transformer块本身。在Transformer块内部,我们有几个层:一个层归一化层,接着是掩码多头注意力层,然后是Dropout层,接着是一个快捷连接。之后是另一个层归一化层,一个带有GELU激活函数的前馈神经网络,另一层Dropout,最后是另一个快捷连接。当我们离开Transformer块时,有几个步骤,称为后处理输出步骤。最终我们得到输出张量,这个张量用于预测输入序列中的下一个词。

今天我们将编写这个蓝色的结构,也就是Transformer块。正如我之前讨论的,Transformer块是GPT及其他LLM架构的基本构建块,因此请仔细关注今天的课程。

这里我想提一点,你可能看到这里的数字“2”并想知道它是什么。在GPT-2架构中,Transformer块实际上重复了12次。在我们这个LLM架构系列课程中,我们关注的是拥有1.24亿参数的最小版本GPT-2,在该版本中,Transformer块重复了12次。因此,我们现在要看到的代码在最终的GPT架构中实际上被复制了12次。

正如我告诉你的,Transformer块有几个我们之前看过的组件:层归一化、掩码多头注意力、Dropout、前馈神经网络和快捷连接。这里我列出了这五个组件:第一是掩码多头注意力,第二是层归一化,第三是Dropout,第四是前馈神经网络,第五是GELU激活函数。

现在,在跳入代码之前,我将快速回顾这五个子组件,以便刷新你的记忆,并在我们开始编写代码之前,让你对所学内容有更深入的理解。

组件回顾

1. 多头注意力

如果你忘记了,我们有一个非常全面的四节课系列来讲解注意力机制。在多头注意力中,我们查看输入,假设输入标记为矩阵 X。然后我们将输入与可训练的查询矩阵 W_Q、可训练的键矩阵 W_K 和可训练的值矩阵 W_V 相乘。在多头注意力的情况下,我们有多个副本,即多个可训练的查询矩阵、键矩阵和值矩阵。

因此,输入乘以这些权重矩阵后,我们得到查询矩阵 Q、键矩阵 K 和值矩阵 V。同样,在多头注意力中,我们得到这些矩阵的多个副本。接下来,我们取查询和键的点积,得到注意力分数。注意力分数被归一化后得到注意力权重,然后注意力权重与值矩阵相乘,得到一组上下文向量。

多头注意力或任何注意力机制的整个目标是将嵌入向量转换为上下文向量。上下文向量是什么?最简单的方式是认为它们是比嵌入向量更丰富的表示。嵌入向量包含特定词的语义含义,但不包含该词如何与句子中其他词相关的信息。上下文向量超越了嵌入向量,它不仅捕获特定词的语义含义,还捕获该词如何与句子中其他标记相关联或“关注”其他标记的信息,这就是为什么它被称为注意力机制。

整个多头注意力的目标是获取上下文向量。对于每个注意力头,都会生成一个单独的上下文向量矩阵,最终合并来自不同注意力头的上下文向量矩阵,得到这个组合的上下文向量矩阵。这个上下文向量矩阵就是多头注意力的输出。因此,每当你看到这里的掩码多头注意力层时,输入是向量嵌入,输出是来自多头注意力的上下文向量。

2. 层归一化

这个组件的作用是归一化层的输出。假设这些是任何层的输出,没有归一化时,它们会有随机的均值和方差。应用层归一化后,这些输出的值将被改变,使得均值为0,方差为1。

为什么需要这个?因为它为我们解决了两个问题。首先,它在反向传播过程中带来了一定的稳定性,确保值不会太大,从而防止梯度爆炸或消失。其次,它解决了内部协变量偏移的问题,这意味着在训练过程中,某一层接收到的输入在不同迭代中可能具有不同的分布。这是一个大问题,因为它会阻碍训练,使得更新权重变得非常困难,训练收敛需要很长时间。层归一化解了这个问题。

仔细观察Transformer块,你会发现层归一化在多个地方被实现:它被实现在多头注意力之前,也被实现在前馈神经网络之前。实际上,它在Transformer块内被实现了两次。需要注意的是,层归一化在Transformer块之后也有实现,但我们暂时不讨论那个。

3. Dropout

Dropout层可以应用在任何层之后。Dropout的作用是查看层的输出,然后随机关闭其中的一些输出。在应用Dropout之前,我们显示一个神经网络,这些是前一层输出的单元。在通过Dropout之后,这里的一些神经元或输入被随机关闭。

为什么它们被随机关闭?因为这样可以提高训练过程中的泛化能力。有些神经元变得懒惰,它们变得如此懒惰以至于完全不更新自己,而是依赖其他神经元。在测试时,这是一个大问题,因为这些懒惰的神经元没有学到任何东西。一旦我们实现Dropout,一个懒惰的神经元会发现其他正在做所有工作的神经元在那次迭代中不存在,因此懒惰的神经元别无选择,只能学习并更新其权重。这就是Dropout有助于泛化、防止过拟合的原因。

4. 前馈神经网络与GELU激活函数

这个前馈神经网络的构造相当有趣。我们保留了输入的维度。假设输入到神经网络的输入是一个具有嵌入维度的标记。在GPT-2中,嵌入维度是768。所以,如果一个词是“forward”或“step”,该词将被转换为768维的嵌入向量。假设这是传递给神经网络的输入。

首先,我们有一个扩展层,这意味着有一个隐藏的神经元层,这里的神经元数量是嵌入维度的四倍。所以,神经元数量将是 4 * 768。第一层进行扩展,意味着我们从768维变为 4 * 768 维。然后有一个第二层,即压缩层,我们再次回到开始时的完全相同的维度。因此,这个神经网络的输入维度和输出维度完全相同。

你可能会想,为什么首先要进行这种扩展和收缩?进行扩展和收缩是为了探索更丰富的参数空间。这种扩展将输入带入一个更高(四倍)维度的空间,在那里我们可以发现参数之间更好的关系,这通常有助于LLM更好地学习。为什么我们将其压缩回相同的输出维度?因为我们希望输入和输出维度保持不变。这有助于可扩展性,这样你就可以堆叠多个这样的神经网络,而不用担心维度变化。

在这一层神经元之后,每个神经元都必须有一个激活函数,在这种情况下使用的激活函数是GELU激活函数。通常大家都熟悉ReLU,但GELU是一个轻微的变体。对于 x < 0,GELU不等于0,这是一个变化。第二个变化是GELU在 x = 0 处是可微的,它是完全平滑的,不像ReLU在 x = 0 处不可微。对于 x > 0,GELU通常近似于ReLU,即 y = x,但并不完全等于 x,而是趋近于它。

因此,GELU通常解决了“死亡神经元”问题。这意味着如果一个神经元的输出是负数,并且它通过ReLU,输出将变为零。但当它通过GELU时,不会变为零。这时神经元将继续学习。在ReLU中,如果一个神经元的输出是负数,并且通过ReLU,输出为0,然后神经元就“死亡”了,在那之后它无法学习任何东西,学习停滞。GELU解决了这个问题。在LLM的实验中发现,GELU激活函数的表现通常比ReLU好得多。这就是为什么在这层神经元之后使用的激活函数是GELU激活函数。

5. 快捷连接

在快捷连接中,一层的输出与前一层的输出相加。在这里,你可以看到一层的输出与前一层的输出相加,然后我们创建了这个路径。类似地,在这里,你会看到这一层的输出与前一层的输出相加,然后我们创建了这个路径。

实现快捷连接的原因是它解决了梯度消失问题。在左侧,如果你看到梯度,最外层的梯度是0.005,但当你反向传播到第4层、第3层、第2层和第1层时,你会发现梯度已经减小到非常小的值。当你反向传播到第一层时,这就是梯度消失问题。然后,如果梯度变得非常小,学习就会停止,这对训练LLM不利。而如果我们实现这个快捷连接,它为梯度流动提供了另一条路径,使梯度流动更加稳定。这就是为什么梯度消失问题得到解决。如果你看到第5层的梯度大小是1.32,而第3层、第2层和第1层的大小都在0.2和0.3左右,所以梯度并没有变得极小,我们已经解决了梯度消失问题。实际上,这里的梯度大小看起来相当稳定。这就是为什么快捷连接是Transformer块如此重要的部分。

现在让我们缩小视野,一起看看这五个组件。我们学习了层归一化、Dropout、前馈神经网络、它与GELU的关联,最后学习了快捷连接。现在,当我们创建Transformer块时,所有这些都必须堆叠在一起,并且我们将遵循示意图中提到的特定顺序。

Transformer块的顺序

这个顺序到底是什么?首先,我们从层归一化开始,然后在它上面堆叠多头注意力。接着,我们添加Dropout层。这个带加号的箭头就是快捷连接。然后,我们添加另一个层归一化。接着,我们添加带有GELU激活函数的前馈神经网络。然后,我们添加另一个Dropout层,最后再添加另一个快捷连接。这就是我们将在代码中做的事情。

在进入代码之前,我想解释一些概念细节。当Transformer块处理输入序列时,每个元素都由一个固定大小的向量表示,假设大小是每个元素的嵌入维度。

一个极其重要的需要注意的点是,Transformer块内的操作,如多头注意力和前馈层(记住扩展-收缩层),被设计为转换输入向量,同时保持维度不变。这一点非常重要。因此,当你查看这个Transformer块,查看进入Transformer的输入,以及离开Transformer的输出时,输出的形式和维度与输入完全相同。

我想提请你们注意这个极其重要的点,这就是为什么堆叠多个Transformer块变得如此容易。我们看到GPT-2有12个Transformer块,之所以能如此容易地堆叠它们,是因为Transformer块保留了维度。Transformer输入的维度与Transformer输出的维度相同。所以,如果你看输入,每个输入基本上是标记,标记被转换为向量嵌入,这就是Transformer的输入。现在,如果你看输出,输出具有完全相同的大小。因此,每个标记将有一个对应的输出,并且它将具有与输出中完全相同的维度。

许多学生没有注意到Transformer保留维度的重要性,但这是Transformer块创建方式最重要的特性之一。我们本可以轻松创建输出维度不同的Transformer块,但那将无助于我们扩展Transformer块。现在,我们可以直接堆叠不同的Transformer块,而不用担心维度问题。

为了复习,自注意力块与前馈块不同。自注意力块分析输入元素之间的关系,即分析一个输入元素如何与其他输入元素相关,并分配一个注意力分数。但前馈神经网络只是单独查看每个元素。所以,当我们看这里的神经网络时,我们一次只查看一个输入标记,一个具有768维度的输入标记,输出也是768维度,这意味着我们一次只查看一个输入,而不查看它与其他输入的关系。这是多头注意力机制和前馈神经网络之间的一个区别。

好了,现在如果你们都理解了Transformer块背后的理论和直觉,现在是时候跳入代码了。我将带你们进入Python代码,让我们一起编写Transformer块的不同方面。

代码实现:Transformer块

在继续之前,我想讨论一下我们将要使用的配置。我们将使用GPT-2最小版本的配置,该版本有1.24亿参数。这里的词汇表大小是50257,上下文长度是1024。上下文长度是允许预测下一个标记的最大输入标记数,这在表示位置嵌入时需要。然后我们有嵌入维度,记住每个标记被转换为向量嵌入,这里的维度是768。n_heads 是注意力头的数量,是12。n_layers 是12,这实际上是Transformer块的数量。记住,注意力头的数量和Transformer块的数量是不同的。在每个Transformer块内部,有一个多头注意力块,它可以有多个注意力头。然后,当我们有Dropout层时,我们只有Dropout率,这里指定平均10%的神经元或层元素将被设置为0,所以现在是0.1。然后,query_key_value_bias 设置为False,因为我们现在不需要这个偏置项,我们将随机初始化查询、键和值矩阵的权重,不带偏置曲线。

在进入Transformer块之前,我们需要复习一下之前为其他块编写的代码。我们看到了层归一化,并且之前为层归一化定义了一个类。这个类的作用是简单地接受一个输入,从输入中减去均值,然后除以方差的平方根。这确保元素被归一化,使其均值等于0,标准差或方差等于1。记住,在那个分母中,我们还添加了一个非常小的值以防止除以零。你可能想知道缩放和移位是什么。这些是可训练的参数,可以认为是在训练过程中学习的参数。这就是层归一化,这里嵌入维度是输入,归一化是沿着嵌入维度执行的。

为了给你一个视觉表示,让我们看看这些标记,每个标记的嵌入维度是768。在归一化中,我们查看单独的行,然后跨列进行归一化。我们确保均值为0,标准差为1。这些值可以是任何层的输出。假设这里有一个层归一化,它接收来自这里的输入,输入维度为768,因为维度被保留。我们将沿着768维(即嵌入维度)取均值,确保归一化使得沿列的均值为0,标准差为1。这将是层归一化的输出,因此大小与输入相同,但会改变的是每一行的均值为0,标准差为1。

然后我们有GELU激活函数,正如我们所见,它由GPT-2中使用的这个近似定义。一旦我们使用这个近似,GELU就开始看起来像我们在构建块中看到的那样。实际上,在之前的一节课中,我们看到了实际使用的函数。这是研究人员训练GPT-2时实际使用的函数近似,这正是我们在这里写的。

然后我们有前馈神经网络,它接收输入,输入维度是嵌入维度,将其扩展到四倍嵌入维度,然后进行GELU激活,接着是压缩层,将四倍嵌入维度的输入带回原始嵌入维度。所以在这里你可以看到,前馈神经网络由扩展、激活和压缩组成。这正是我们在白板上看到的部分。

好了,现在我们已经编写了不同的类:一个层归一化类,一个GELU激活类,以及一个前馈神经网络类。现在我们准备使用之前学习的这些构建块来编写整个Transformer块。

这是Transformer块的类。首先,让我转到前向传播方法,告诉你我们在这里做什么。要理解这个顺序,你只需要记住这个图中的顺序。

所以,让我现在带你看看那个图。为了让你更好地看到这个顺序,我把屏幕上所有的东西都擦掉。现在有太多颜色和符号了。希望你现在能看到这个顺序了。我们将遵循完全相同的顺序,现在请记住它。你甚至可以在看视频时稍后查看这个白板并复习。

首先,我们有层归一化,接着是注意力,然后是Dropout,接着是快捷连接。所以最初我们看到这四个步骤。然后我们有接下来的步骤,这是另一个层归一化。在接下来的四个步骤中,我们有另一个层归一化,然后是前馈神经网络,接着是Dropout和快捷连接。所以,如果你理解白板上显示的图表,前向传播方法中发生的事情就非常简单了。

但是,让我们看看当我们创建Transformer块类的实例时,创建了哪些不同的对象。attn 是多头注意力对象,这是我们在之前一节课中定义的多头注意力类的一个实例。这个类的作用是将嵌入向量转换为上下文向量。输入维度是嵌入维度,输出与嵌入维度相同。我们必须指定上下文长度,这里是1024。再次复习一下,上下文长度是1024。注意力头的数量是注意力头的数量,我想这里是12。Dropout是之前使用的Dropout率10%,query_key_value_bias 设置为False。如果你想复习多头注意力,请去我们之前涵盖此内容的一节课。每当创建这个多头注意力类的实例 attn 时,它接收输入嵌入并将其转换为上下文向量。这里的大小是批大小、标记数量和嵌入大小。所以,如果标记数量是四或五个,这里将是四个,每个的嵌入大小是768(在我们的例子中),批大小可以是任何我们定义的批大小。

然后我们有另一个对象 ff,它是前馈类的一个实例。我们看到前馈类在这里,我们只需要在这里指定配置,以便从配置中获取嵌入维度。这个类的作用是创建这些层,并使用GELU激活函数随机初始化权重。所以,ff 是前馈类的一个实例。无论在哪里使用 ff,输入被传入,它经过扩展、GELU和压缩,输出是与输入相同的维度。

然后我们有 norm_1norm_2,所以 norm_1 是第一个归一化层,norm_2 是第二个归一化层。你看到第一个归一化层用在多头注意力之前,第二个归一化层用在前馈神经网络之前。这就是为什么这些有时也被称为前置层归一化层,因为它们用在多头注意力和前馈神经网络之前。

最后一个对象是 drop_shortcut,它基本上是一个Dropout层。nn.Dropout 是PyTorch中预定义的。

现在,当你查看前向传播方法时,我已经向你解释了 norm_1,它是第一个归一化层对象,然后是 attn,接着是 drop_shortcut,它是Dropout层。我们不需要为快捷连接创建一个单独的类,因为我们所做的只是将这个输出加回到原始输入。所以,当你查看快捷机制时,看看箭头在哪里。如果你看这里的这个箭头,我们正在做的是将这里的这个输入与Dropout的输出相加。所以,Dropout的输出被加到这个输入上。让我用黄色标记这个,以便你能清楚地理解。输入在这里用黄色星号标记,Dropout的输出用另一个黄色星号标记,我们将这两个黄色星号相加。这就是第一个快捷连接。

所以,我们做的是:shortcut 被初始化为 x,即输入,然后 x 被修改为Dropout的输出。因此,我们基本上是将Dropout输出与输入相加,这正是我们在白板上看到的。在第二个快捷连接中,实际发生的是,你看到这里有第二个归一化层的输入,以及第二个Dropout的输出。所以,我们将第二个归一化层的输入与第二个Dropout的输出相加。你会在代码中看到

22:编码1.24亿参数的GPT-2模型

概述

在本节课中,我们将学习如何将之前构建的所有组件(如层归一化、前馈神经网络、Transformer块)组装起来,编码一个完整的GPT-2模型。我们将从输入文本开始,逐步经过词嵌入、位置编码、Transformer块等步骤,最终得到模型的输出逻辑值。我们将使用GPT-2的配置(约1.24亿参数),并在本地计算机上运行这个模型。


模型架构回顾

上一节我们介绍了Transformer块的具体实现。本节中,我们将看看如何将Transformer块与其他组件结合,构建完整的GPT模型。

GPT模型的整体流程可以概括为以下几个步骤:

  1. 将输入文本转换为词元ID。
  2. 将词元ID转换为词嵌入向量。
  3. 添加位置嵌入向量。
  4. 应用Dropout层。
  5. 通过多个堆叠的Transformer块。
  6. 应用最终的层归一化。
  7. 通过线性输出层,得到逻辑值。

以下是GPT模型架构中各个组件的维度变化流程,我们将以一个包含四个词元的输入序列为例进行说明。

步骤详解

1. 词元嵌入

输入文本“every effort moves you”首先被转换为四个词元ID。每个词元ID通过一个嵌入层被映射为一个768维的向量。公式表示为:
词元嵌入 = EmbeddingLayer(词元ID)
初始时,这些向量是随机初始化的,并在模型训练过程中学习。

2. 位置嵌入

为了捕捉词元在序列中的顺序信息,我们为序列中的每个位置(1到4)也生成一个768维的向量。公式表示为:
位置嵌入 = EmbeddingLayer(位置索引)
位置嵌入向量同样在训练开始时随机初始化。

3. 输入嵌入

将每个词元的词嵌入向量与其对应的位置嵌入向量相加,得到最终的输入嵌入。公式表示为:
输入嵌入 = 词元嵌入 + 位置嵌入
这一步确保了每个输入向量既包含语义信息,也包含位置信息。

4. Dropout层

为了防止过拟合,我们在输入嵌入上应用Dropout。Dropout会随机将输入嵌入向量中的一部分元素设置为0。例如,如果Dropout率为0.5,则平均每个向量有50%的元素被置零。

5. Transformer块

输入嵌入随后被送入Transformer块。这是模型的核心,我们之前已经详细构建了它。Transformer块内部包含层归一化、多头注意力机制、前馈神经网络等子层。关键点在于,Transformer块的输入和输出维度保持不变(本例中为 [4, 768]),这使得我们可以轻松堆叠多个Transformer块。

6. 最终层归一化

经过所有Transformer块处理后,输出会再经过一次层归一化,以稳定训练。

7. 输出头(线性层)

最后,归一化后的输出通过一个线性层(输出头)。该线性层将每个词元的768维向量映射到整个词表的大小(GPT-2为50257维)。公式表示为:
逻辑值 = LinearLayer(Transformer输出)
因此,对于我们的四个词元输入,最终输出是一个形状为 [4, 50257] 的张量,每一行代表对应位置预测下一个词元在整个词表上的未归一化分数(逻辑值)。

批次处理

在实际中,输入通常以批次处理。如果批次大小为2,每个批次有4个词元,则最终输出的形状为 [2, 4, 50257]


代码实现

现在,我们对模型的数据流有了直观理解,接下来看看如何用代码实现它。

我们将使用以下GPT-2配置:

vocab_size = 50257        # 词表大小
context_length = 1024     # 上下文长度
embed_dim = 768           # 嵌入维度
num_heads = 12            # 注意力头数
num_layers = 12           # Transformer层数
drop_rate = 0.1           # Dropout率

以下是构建GPT模型类的核心代码:

import torch.nn as nn

class GPTModel(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.config = config

        # 词元嵌入层
        self.token_embedding = nn.Embedding(config.vocab_size, config.embed_dim)
        # 位置嵌入层
        self.position_embedding = nn.Embedding(config.context_length, config.embed_dim)
        # Dropout层
        self.dropout = nn.Dropout(config.drop_rate)
        # 堆叠多个Transformer块
        self.transformer_blocks = nn.Sequential(
            *[TransformerBlock(config) for _ in range(config.num_layers)]
        )
        # 最终层归一化
        self.final_layer_norm = LayerNorm(config.embed_dim)
        # 输出头(线性层)
        self.output_head = nn.Linear(config.embed_dim, config.vocab_size, bias=False)

    def forward(self, input_ids):
        batch_size, seq_len = input_ids.shape
        device = input_ids.device

        # 1. 获取词元嵌入
        token_embeds = self.token_embedding(input_ids)  # 形状: [batch_size, seq_len, embed_dim]

        # 2. 获取位置嵌入
        position_ids = torch.arange(seq_len, device=device).unsqueeze(0)  # 形状: [1, seq_len]
        position_embeds = self.position_embedding(position_ids)  # 形状: [1, seq_len, embed_dim]

        # 3. 相加得到输入嵌入
        x = token_embeds + position_embeds  # 形状: [batch_size, seq_len, embed_dim]

        # 4. 应用Dropout
        x = self.dropout(x)

        # 5. 通过所有Transformer块
        x = self.transformer_blocks(x)  # 形状保持不变: [batch_size, seq_len, embed_dim]

        # 6. 最终层归一化
        x = self.final_layer_norm(x)  # 形状: [batch_size, seq_len, embed_dim]

        # 7. 通过输出头,得到逻辑值
        logits = self.output_head(x)  # 形状: [batch_size, seq_len, vocab_size]

        return logits

运行模型

我们可以创建一个模型实例并传入一个输入批次来测试:

# 模型配置
config = ModelConfig(vocab_size=50257, embed_dim=768, num_layers=12, ...)
# 实例化模型
model = GPTModel(config)

# 创建一个模拟输入批次 (2个批次,每个4个词元)
input_batch = torch.tensor([[100, 150, 200, 250], [300, 350, 400, 450]])
# 前向传播
output_logits = model(input_batch)
print(output_logits.shape)  # 输出: torch.Size([2, 4, 50257])

参数量与内存占用

我们的模型拥有大量参数。我们可以计算总参数量:

total_params = sum(p.numel() for p in model.parameters())
print(f"总参数量: {total_params}")  # 输出约为1.63亿

这个数字比原始GPT-2的1.24亿参数要多,是因为我们没有使用权重绑定。在原始GPT-2中,词元嵌入层的权重与输出头线性层的权重是共享的,这减少了参数量和内存占用。我们的实现为了更好的训练性能,选择了使用独立的层。

即使如此,模型的内存占用也相当可观:

# 假设每个参数为32位浮点数(4字节)
memory_bytes = total_params * 4
memory_mb = memory_bytes / (1024 ** 2)
print(f"模型内存占用(近似): {memory_mb:.2f} MB")  # 输出约为620 MB

总结

本节课中我们一起学习了如何从零开始编码一个完整的GPT-2模型架构。我们回顾了从输入词元到输出逻辑值的整个数据流,明确了每个步骤(词嵌入、位置编码、Transformer块、输出头)的维度和作用。通过代码实现,我们将之前构建的各个组件组装起来,成功创建了一个拥有超过1.6亿参数、可在本地运行的“大语言模型”。这个模型的最终输出是一个逻辑值张量,为下一节课的文本生成任务奠定了基础。理解这个架构的维度流动是掌握大语言模型工作原理的关键。

23:从输出张量预测下一个词元

在本节课中,我们将学习如何从GPT模型的输出张量中生成文本。我们将深入理解大语言模型预测下一个词元的迭代过程,并编写代码实现这一核心功能。

概述:大语言模型如何生成文本

上一节我们完成了GPT模型的整体构建。本节中,我们来看看如何利用模型的输出生成连贯的文本。

大语言模型生成文本的核心是一个迭代过程。模型接收一系列输入词元,预测下一个词元,然后将预测出的词元追加到输入序列中,作为下一轮迭代的输入。这个过程会重复进行,直到生成指定数量的新词元。

以下是该过程的简要说明:

  • 给定初始输入词元(例如 “hello I am”),模型预测下一个词元(例如 “a”)。
  • 将预测出的词元 “a” 追加到输入序列末尾,形成新的输入 “hello I am a”。
  • 模型基于新的输入序列预测下一个词元(例如 “model”)。
  • 重复此过程,直到达到预设的最大新词元数量。

在这个过程中,上下文长度(context size)是一个关键参数,它决定了模型在预测下一个词元时,最多可以回顾前面多少个词元。在GPT-2架构中,这个值是1024。

回顾:从输入到输出张量的完整流程

在深入代码之前,我们先快速回顾一下之前课程中构建的完整数据处理流程。

我们从一句输入文本开始,例如 “every effort moves you”。

  1. 词元化:将句子转换为词元ID序列。
  2. 嵌入层:将每个词元ID转换为一个词元嵌入向量(维度为768)。
  3. 位置编码:为每个位置生成一个位置嵌入向量,并与词元嵌入相加,得到最终的输入嵌入。
  4. Dropout层:对输入嵌入应用随机失活。
  5. Transformer块:输入嵌入经过多个Transformer块的处理。每个块包含层归一化、多头注意力机制、前馈神经网络等组件。这是模型理解上下文关系的核心。
  6. 最终层归一化:对Transformer块的输出进行归一化。
  7. 输出头:一个线性层,将最终的嵌入向量映射到整个词表大小的逻辑值(logits)张量。

至此,我们得到了一个形状为 (batch_size, num_tokens, vocab_size) 的输出张量。对于输入 “every effort moves you”(4个词元),输出张量的形状是 (1, 4, 50257),其中50257是GPT-2的词表大小。

理解输出张量与下一个词元预测

现在,我们面临一个关键问题:如何从这个庞大的输出张量中得到预测的下一个词?

输出张量中的每一行都对应一个特定的“预测任务”。对于包含4个词元的输入序列:

  • 第1行(对应“every”):预测在“every”之后最可能出现的词(应该是“effort”)。
  • 第2行(对应“effort”):预测在“every effort”之后最可能出现的词(应该是“moves”)。
  • 第3行(对应“moves”):预测在“every effort moves”之后最可能出现的词(应该是“you”)。
  • 第4行(对应“you”):预测在“every effort moves you”之后最可能出现的词(这才是我们想要的下一个词,例如“forward”)。

因此,要得到整个序列的下一个预测词,我们需要关注输出张量的最后一行。这一行包含了模型基于整个输入序列,对词表中所有50257个词作为下一个词出现可能性的“评分”,即逻辑值。

从逻辑值到生成词元的步骤

从输出张量的最后一行得到最终的下一个词元,需要经过以下几个步骤:

步骤一:提取最后一行逻辑值向量
我们从形状为 (batch_size, num_tokens, vocab_size) 的输出张量中,提取每个批次最后一个时间步(即最后一个词元)对应的逻辑值向量。代码表示为 logits[:, -1, :],结果形状变为 (batch_size, vocab_size)

步骤二:将逻辑值转换为概率
逻辑值向量中的数字大小不一,且总和不为1。我们使用 Softmax 函数将其转换为概率分布。Softmax确保转换后的所有值都在0到1之间,且总和为1。公式如下:
probabilities = softmax(logits_vector)

步骤三:确定最高概率的词元ID
在得到的概率分布中,我们找出值最大的那个元素对应的索引。这个索引就是模型预测的下一个词元的ID。代码表示为 torch.argmax(probabilities, dim=-1)

步骤四:将词元ID解码为文本
使用词表(Tokenizer)将上一步得到的词元ID转换回对应的文本(词元)。

步骤五:将新词元追加到输入序列
将新生成的词元ID追加到原始输入序列的末尾。这样,在下一轮迭代中,模型就能基于更长的历史上下文进行预测。

这个过程会循环执行,每次生成一个新词元并追加到序列中,直到生成的新词元数量达到预设的 max_new_tokens

代码实现:文本生成函数

理解了原理后,我们开始编写 generate_text_simple 函数来实现上述过程。

首先,我们需要确保输入序列的长度不超过模型的上下文长度。如果超过,我们只截取最后 context_size 个词元作为有效输入。

idx_cond = idx[:, -context_size:] if idx.size(1) > context_size else idx

接下来,我们将处理后的输入传入模型,获得逻辑值张量。

logits = model(idx_cond)

然后,我们提取最后一个时间步的逻辑值,并应用Softmax将其转换为概率。虽然对于直接选取最大值的操作,Softmax并非必需(因为 argmax 在逻辑值上直接操作结果相同),但为了展示完整流程并为后续更复杂的采样方法(如引入温度系数)做准备,这里我们保留此步骤。

logits_last = logits[:, -1, :]
probs = torch.softmax(logits_last, dim=-1)

接着,我们找出概率最高的索引作为下一个词元ID,并将其解码。

idx_next = torch.argmax(probs, dim=-1).unsqueeze(1)

最后,我们将新生成的词元ID追加到输入序列中,为下一次迭代做准备。

idx = torch.cat((idx, idx_next), dim=1)

我们将以上步骤放入一个循环中,循环次数即为 max_new_tokens

测试未训练的模型

现在,我们可以用一段示例文本测试我们构建的完整GPT-2架构。由于模型的1.24亿个参数尚未经过训练,它们都是随机初始化的,因此生成的文本看起来会是随机的、无意义的。但这恰恰证明我们的架构搭建是正确的,数据流是通畅的。

我们输入 “hello I am”,并请求模型生成6个新词元。

input_text = “hello I am”
input_ids = encoder.encode(input_text) # 使用tiktoken编码器
input_tensor = torch.tensor([input_ids]) # 添加批次维度

model.eval() # 将模型设置为评估模式
output_ids = generate_text_simple(model=model, idx=input_tensor, max_new_tokens=6, context_size=model_config.context_length)
generated_text = encoder.decode(output_ids[0].tolist()) # 解码为文本
print(generated_text)

输出可能是一串随机的词元序列,这与我们在白板上演示的 “hello I am a model ready to help” 相去甚远。这正是预期的结果,因为模型尚未学习任何语言规律

总结与展望

本节课中,我们一起学习了GPT模型生成文本的核心迭代机制,并成功编写代码实现了从输出张量预测下一个词元的功能。我们完成了以下关键任务:

  1. 理解了LLM通过“预测-追加”循环生成文本的原理。
  2. 分析了GPT模型输出张量的结构,明确了最后一行对应下一个词的预测。
  3. 实现了从逻辑值到生成词元的完整步骤:提取、Softmax、取argmax、解码、追加。
  4. 编写了 generate_text_simple 函数,并测试了拥有1.24亿参数的未训练GPT-2模型。

至此,我们已经从零开始完整搭建了GPT-2模型架构,并实现了前向传播和简单的文本生成。这是一个重要的里程碑。模型目前表现不佳的唯一原因是参数是随机的。

在接下来的模块中,我们的核心任务将是训练这1.24亿个参数。通过在大规模文本数据上进行训练,模型将逐渐学会语言的内在规律和模式,其生成的文本也会变得越来越连贯、有意义。我们将在后续课程中深入探讨数据准备、损失函数、训练循环以及优化策略等内容。

你已经完成了超过95%学习者可能止步的艰巨工作,亲手在本地机器上构建并运行了一个完整的GPT-2架构,这非常了不起!感谢你的坚持学习,我们下节课再见。

24:测量LLM的损失函数

在本节课中,我们将学习如何为大语言模型定义和计算损失函数。这是训练模型使其预测更准确的关键一步。

概述

上一节我们介绍了GPT架构,并构建了一个能够接收输入并预测下一个词的模型。然而,模型的初始预测是随机的,效果不佳。本节中,我们将探讨如何量化模型预测的“好坏”,即如何定义损失函数。我们将学习输入、目标(真实值)和模型输出之间的关系,并最终使用交叉熵损失和困惑度来评估模型性能。

输入与目标

首先,我们需要理解模型的输入和期望的输出(即目标)的格式。

模型的输入是一个张量,其行数对应批处理大小。例如,一个包含两个批次、上下文长度为3的输入可能如下所示:

  • 批次1输入[16833, 3626, 6100] (对应文本 “every effort moves”)
  • 批次2输入[40, 1107, 588] (对应文本 “I really like”)

对于每个输入序列,模型的任务是预测下一个词。但请注意,对于一个长度为 n 的输入序列,实际上存在 n 个预测任务。因此,目标张量(真实值)的形状与输入相同,但其值是输入序列向右移动一位的结果。

以下是目标张量的构成:

  • 批次1目标[3626, 6100, 345]
    • 当输入为 16833 (“every”) 时,期望输出 3626 (“effort”)。
    • 当输入为 [16833, 3626] (“every effort”) 时,期望输出 6100 (“moves”)。
    • 当输入为 [16833, 3626, 6100] (“every effort moves”) 时,期望输出 345 (“you”)。
  • 批次2目标[1107, 588, 11311]
    • 对应预测 “I” -> “really”, “I really” -> “like”, “I really like” -> “chocolate”。

我们的目标是让模型的预测输出尽可能接近这些目标值。

模型输出与文本生成

接下来,我们回顾模型如何生成输出。输入文本首先被转换为词元ID,然后经过GPT模型(包含嵌入层、位置编码、多个Transformer块等)处理,最终得到一个逻辑值张量。

这个逻辑值张量的形状为 (批大小, 序列长度, 词汇表大小)。例如,对于两个批次、序列长度为3、词汇表大小为50257的情况,形状为 (2, 3, 50257)

为了得到预测的下一个词元ID,我们需要对逻辑值张量应用 softmax 函数,将其转换为概率分布。然后,对序列中每个位置(每一行),取概率最大的索引作为预测结果。

以下是文本生成的步骤分解:

  1. 输入:文本序列(例如 “every effort moves”)。
  2. 词元化:使用分词器(如BPE)将文本转换为词元ID序列 [16833, 3626, 6100]
  3. 模型前向传播:词元ID序列通过GPT模型,得到逻辑值张量。
  4. 概率转换:对逻辑值张量应用softmax,得到每个位置对所有词汇的概率分布。
  5. 预测:对每个位置,选择概率最高的词元ID作为该位置的预测输出。

在模型未经训练时,这些预测是随机的,例如输入 “every effort moves” 可能输出无意义的词元序列。

定义损失函数

为了训练模型,我们需要一个可量化的指标来衡量模型预测与真实目标之间的差距,这就是损失函数。对于分类任务(如下一个词预测),最常用的损失函数是交叉熵损失

其核心思想是:我们希望模型在目标词元位置给出的概率尽可能高(接近1)。损失函数通过计算模型预测概率分布的负对数似然来量化这个差距。

具体计算步骤如下:

  1. 获取模型输出的概率张量 probs,形状为 (批大小, 序列长度, 词汇表大小)
  2. 根据目标张量 targets 中的索引,从 probs 中提取对应位置的概率值。例如,对于批次1的第一个目标 3626,我们取出 probs[0, 0, 3626] 的概率值 p11
  3. 收集所有批次和所有位置的目标概率值,得到 [p11, p12, p13, p21, p22, p23]
  4. 计算这些概率的负对数似然的平均值:
    损失 = -mean(log(p11) + log(p12) + ... + log(p23))

当所有目标概率 p 都接近1时,log(p) 接近0,损失值也接近0,表示模型预测完美。反之,概率越低,损失越大。

在PyTorch中,这可以通过一行代码高效实现:

loss = torch.nn.functional.cross_entropy(logits_flattened, targets_flattened)

其中 logits_flattened 是将逻辑值张量前两维展平后的结果(形状为 (总词元数, 词汇表大小)),targets_flattened 是展平后的目标张量(形状为 (总词元数,))。这个函数内部会自动进行softmax和负对数似然计算。

困惑度:一个更直观的指标

除了交叉熵损失,困惑度 是评估语言模型的另一个常用且更直观的指标。

困惑度的计算公式非常简单:
困惑度 = exp(损失)

困惑度可以解释为“模型在预测下一个词时,平均面临的选择不确定性”。例如:

  • 如果损失为10.79,则困惑度 = exp(10.79) ≈ 48725。
  • 这意味着,对于给定输入,模型在预测下一个词时,其不确定性相当于要从大约48725个等可能的词中随机挑选一个。这非常糟糕,因为总词汇量才50257。
  • 理想的困惑度应该尽可能低。如果困惑度接近1,表示模型非常确定;如果困惑度等于词汇表大小,则表示模型完全随机猜测。

因此,困惑度将抽象的损失值转化为一个与模型预测不确定性直接相关的、更易于理解的数字。

总结

本节课中我们一起学习了如何为大语言模型定义和计算损失函数。

  1. 我们首先明确了模型的输入目标(真实值)的格式,理解了序列预测任务是如何分解的。
  2. 接着,我们回顾了模型如何生成输出,即通过GPT架构得到逻辑值,再经softmax得到概率分布,最后通过argmax得到预测词元。
  3. 然后,我们深入探讨了交叉熵损失的原理。其核心是最大化模型在目标词元位置预测的概率,并通过计算负对数似然平均值来量化误差。我们在PyTorch中可以用一行代码 torch.nn.functional.cross_entropy 来实现它。
  4. 最后,我们介绍了困惑度这一指标,它通过 exp(损失) 计算,能更直观地反映模型预测的不确定性,值越低表示模型越好。

通过定义损失函数,我们将训练大语言模型的问题转化为了一个可以通过梯度下降等优化算法解决的数值优化问题。在下一节课中,我们将把这些知识应用于整个数据集,开始实际训练我们的模型。

25:在真实数据集上评估LLM性能 _ 动手项目 _ 书籍数据

在本节课中,我们将学习如何在一个真实的书籍数据集上评估我们构建的大型语言模型的性能。我们将收集数据,使用模型进行预测,并计算训练损失和验证损失。这是迈向模型训练的关键一步。

📚 概述与数据准备

上一节我们介绍了如何为简单的输入计算交叉熵损失。本节中,我们将在一个更大的真实数据集上进行实践。

我们使用的数据集是埃德·沃顿于1906年出版的短篇小说《裁决》。这本书篇幅较短,包含约20,000个字符,便于快速演示。你可以从公开链接下载此书。

首先,我们需要将文本数据转换为模型可以理解的令牌。我们将使用字节对编码方案,这与OpenAI使用的方案相同。经过编码后,该数据集大约有5,000个令牌。

# 示例:使用Tiktoken进行字节对编码
import tiktoken
tokenizer = tiktoken.get_encoding(“gpt2”)
tokens = tokenizer.encode(text_data)
print(f”令牌数量:{len(tokens)}”)

🧩 划分训练集与验证集

与所有机器学习问题一样,我们需要将数据划分为训练集和验证集。训练损失并不关键,模型在未见过的文本上的表现才更重要。

我们将采用简单的90/10分割比例。前90%的数据用于训练,后10%的数据用于验证。

# 划分训练集和验证集
split_idx = int(0.9 * len(tokens))
train_data = tokens[:split_idx]
val_data = tokens[split_idx:]

🔄 构建输入-目标对

在大型语言模型中,构建输入和输出对并不像图像分类那样直接。LLM是自回归模型,我们需要从文本本身构造输入和目标。

以下是构建输入-目标对的核心步骤:

首先,我们需要决定上下文大小,即模型在预测下一个令牌前能看到的最大令牌数。为了演示,我们假设上下文大小为4。

假设我们的文本以“I had always thought Jack...”开头。

  • 第一个输入[“I”, “had”, “always”, “thought”]
  • 第一个目标[“had”, “always”, “thought”, “Jack”] (即输入向右移动一位)

接下来是构建第二个输入。这里涉及另一个参数:步长。如果步长设为1,第二个输入将与第一个有大量重叠。通常,在GPT等模型中,步长被设置为等于上下文大小(本例中为4)。这意味着输入之间没有重叠,也不会跳过任何令牌。

  • 第二个输入[“Jack”, “Gipsen”, “rather”, “a”]
  • 第二个目标[“Gipsen”, “rather”, “a”, “cheap”]

我们以此方式遍历整个数据集,构建出输入张量 X 和目标张量 YX 是输入,Y 是我们希望模型预测的真实目标值。

📊 计算损失的工作流程

现在,让我们看看如何计算损失。我们将输入 X 传入我们构建的GPT模型架构。

模型处理输入的流程如下:

  1. 输入令牌通过字节对编码器进行标记化。
  2. 令牌被转换为高维空间中的向量表示(词嵌入)。
  3. 添加位置嵌入以包含顺序信息。
  4. 应用Dropout层。
  5. 输入通过Transformer块,这是GPT架构的核心引擎。其中的多头注意力机制将输入嵌入向量转换为更丰富的上下文向量,这些向量编码了令牌之间的关系。
  6. 最后,通过一个线性层输出逻辑值张量

逻辑值张量的维度为 [批次大小, 上下文长度, 词汇表大小]。对于词汇表大小为50,257的情况,每个令牌对应一个长度为50,257的向量。

逻辑值需要经过Softmax函数转换为概率张量,使得每个令牌对应的概率之和为1。

为了计算损失,我们将模型预测的概率与真实目标进行比较。具体做法是,根据目标 Y 中每个令牌在词汇表中的索引,从模型输出的概率张量中取出对应的概率值 (p1, p2, p3, ...)

理想情况下,如果模型训练完美,这些概率应接近1。我们使用交叉熵损失(即负对数似然)来衡量差异:

损失 = -mean(log(p1) + log(p2) + log(p3) + ...)

训练LLM的目标就是最小化这个损失。

💻 代码实现详解

理解了理论流程后,我们来看看代码实现。在项目中,我们使用上下文长度256和批次大小2。

首先,我们创建一个自定义数据集类 GPTDatasetV1,它根据指定的上下文长度和步长,将数据转换为输入-目标对。

class GPTDatasetV1(Dataset):
    def __init__(self, txt, tokenizer, max_length, stride):
        self.tokenizer = tokenizer
        self.input_ids = []
        self.target_ids = []
        # 将文本转换为令牌ID
        token_ids = tokenizer.encode(txt)
        # 使用滑动窗口创建输入-目标对
        for i in range(0, len(token_ids) - max_length, stride):
            input_chunk = token_ids[i:i + max_length]
            target_chunk = token_ids[i + 1:i + max_length + 1]
            self.input_ids.append(torch.tensor(input_chunk))
            self.target_ids.append(torch.tensor(target_chunk))
    def __len__(self):
        return len(self.input_ids)
    def __getitem__(self, idx):
        return self.input_ids[idx], self.target_ids[idx]

然后,我们使用PyTorch的 DataLoader 来批量加载数据。

def create_dataloader_v1(txt, batch_size=4, max_length=256,
                         stride=128, shuffle=True, drop_last=True):
    # 初始化数据集
    dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)
    # 创建数据加载器
    dataloader = DataLoader(
        dataset,
        batch_size=batch_size,
        shuffle=shuffle,
        drop_last=drop_last
    )
    return dataloader

我们为训练集和验证集分别创建数据加载器。

接下来,我们初始化之前构建好的GPT模型。

计算一个批次损失的关键函数如下:

def calc_loss_batch(input_batch, target_batch, model, device):
    input_batch, target_batch = input_batch.to(device), target_batch.to(device)
    logits = model(input_batch) # 获取模型逻辑值
    # 展平逻辑值和目标,以便计算损失
    loss = torch.nn.functional.cross_entropy(logits.flatten(0, 1),
                                             target_batch.flatten())
    return loss

torch.nn.functional.cross_entropy 函数一次性完成了Softmax、索引目标概率和计算负对数似然这三个步骤。

为了计算整个数据加载器的平均损失,我们遍历所有批次并聚合损失:

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))
    for i, (input_batch, target_batch) in enumerate(data_loader):
        if i < num_batches:
            loss = calc_loss_batch(input_batch, target_batch, model, device)
            total_loss += loss.item()
        else:
            break
    return total_loss / num_batches

最后,我们调用这个函数来计算训练集和验证集上的损失。

train_loss = calc_loss_loader(train_loader, model, device)
val_loss = calc_loss_loader(val_loader, model, device)
print(f”训练损失: {train_loss}”)
print(f”验证损失: {val_loss}”)

🎯 运行结果与总结

运行代码后,我们可以在短时间内得到模型在《裁决》数据集上的训练损失和验证损失。由于模型尚未训练,这个初始损失值会比较高。

本节课中,我们一起学习了如何为一个真实书籍数据集准备数据、构建输入-目标对、将数据输入我们自建的GPT模型,并计算交叉熵损失。我们详细剖析了从文本到损失计算的完整工作流程,并实现了相应的代码。

这项工作是模型预训练的基础。在下一节课中,我们将在此基础上定义LLM训练函数,实现反向传播,并开始实际训练模型以最小化损失,从而使模型能够生成更连贯的文本。你可以尝试用其他书籍或文本数据集运行此代码,整个过程是通用的。

26:完整LLM预训练循环编码

概述

在本节课中,我们将学习如何实现大语言模型的完整预训练循环。我们将编写代码,通过反向传播来最小化损失函数,从而优化模型参数,使模型能够学习并生成有意义的文本。

回顾:损失函数的计算

上一节我们介绍了如何计算大语言模型的训练和验证损失。本节中,我们来看看如何利用这个损失函数来训练模型。

我们使用的数据集是1906年出版的书籍《The Verdict》。这是一个开源数据集,包含约5000个token和20000个字符。首先,我们将数据按0.9的比例划分为训练集和验证集。

由于大语言模型是自回归模型,我们需要将数据本身划分为输入和目标对。这是通过数据加载器完成的。

以下是输入和目标对的一个示例:

  • 输入样本1: [I, had, always, thought]
  • 目标样本1: [had, always, thought, Jack]
  • 输入样本2: [always, thought, Jack, was]
  • 目标样本2: [thought, Jack, was, a]

可以看到,目标序列是输入序列向右移动一个位置得到的。我们以步长(通常等于上下文长度)在数据上滑动,生成许多这样的输入-目标批次。

模型输出与损失

我们将输入批次送入我们构建的GPT架构模型。模型经过词元化、嵌入、位置编码、Dropout层和多个Transformer块(包含归一化、多头注意力、前馈网络等)后,最终通过一个输出层,得到一个称为 logits 的张量。

logits 张量的形状为 [批次大小, 上下文长度, 词汇表大小]。例如,对于批次大小为2、上下文长度为4、词汇表大小为50257的情况,形状为 [2, 4, 50257]

logits 中的值并非概率。我们通过 softmax 函数将其转换为概率分布。对于输入序列中的每个词元,模型会输出一个概率向量,表示下一个词元是词汇表中每个词的可能性。

训练的目标是让模型为正确的目标词元分配高概率。我们使用 交叉熵损失 来计算模型预测概率与真实目标之间的差异。公式如下:
损失 = -log(模型对正确目标词元的预测概率)

整个训练过程的目标就是最小化这个损失函数。

预训练循环原理

现在我们有了损失函数,可以开始进行模型预训练了。预训练的核心是反向传播和参数更新。

以下是预训练循环的步骤:

  1. 设置多个训练轮次:一个轮次意味着完整遍历一次训练数据集。
  2. 遍历数据批次:在每个轮次内,我们遍历由数据加载器生成的多个批次。
  3. 前向传播与计算损失:对于每个批次,将输入数据送入模型,得到输出,并计算其与目标数据之间的交叉熵损失。
  4. 反向传播:这是最关键的一步。我们调用 loss.backward()。这一行代码会自动计算损失函数相对于模型中所有可训练参数的梯度。我们的模型约有1.61亿个参数,此操作会计算每个参数的梯度。
  5. 参数更新:我们使用优化器(如AdamW)根据计算出的梯度更新所有模型参数。更新规则类似于:
    新参数值 = 旧参数值 - 学习率 * 参数梯度
  6. 循环迭代:将梯度重置为零,处理下一个批次,重复步骤3-5,直到完成一个训练轮次。
  7. 评估与输出:定期(如每处理5个批次)计算并打印当前模型在训练集和验证集上的损失。每个训练轮次结束后,使用模型生成一段文本,以直观观察其进步。

通过不断重复这个过程,我们希望损失函数值能不断下降,模型参数逐渐优化,从而使其预测能力不断增强。

代码实现详解

接下来,我们深入查看预训练循环的具体代码实现。

模型配置与优化器

首先,我们定义模型配置和优化器。

# 模型配置
model_config = {
    'vocab_size': 50257,      # 词汇表大小
    'context_length': 256,     # 上下文长度
    'emb_dim': 768,           # 嵌入维度
    'n_heads': 12,            # 注意力头数
    'n_layers': 12,           # Transformer层数
    'drop_rate': 0.1,         # Dropout比率
    'qkv_bias': False         # 是否使用QKV偏置
}

# 创建模型实例
model = GPT(model_config)

# 定义优化器 (AdamW)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)

我们使用AdamW优化器,它结合了Adam算法的优点和权重衰减,有助于模型收敛。

核心训练循环

以下是训练循环的核心结构。

# 训练超参数
n_epochs = 10
eval_freq = 5 # 每5个批次评估一次
start_context = "Every effort moves you" # 用于文本生成的起始上下文

for epoch in range(n_epochs):
    for step, (x_batch, y_batch) in enumerate(train_loader): # 遍历训练批次
        # 前向传播:计算损失
        logits = model(x_batch)
        loss = cross_entropy_loss(logits, y_batch)

        # 反向传播:计算梯度
        optimizer.zero_grad() # 清除旧梯度
        loss.backward()       # 关键步骤:计算梯度

        # 参数更新
        optimizer.step()

        # 定期评估
        if step % eval_freq == 0:
            train_loss, val_loss = evaluate_model(model, train_loader, val_loader)
            print(f"Step {step}: Train Loss={train_loss:.3f}, Val Loss={val_loss:.3f}")

    # 每个轮次结束后生成文本样本
    print(f"\n--- Epoch {epoch+1} Generated Text ---")
    generate_and_print_sample(model, start_context, max_new_tokens=50)

代码中的 evaluate_model 函数会在评估模式下计算模型在整个训练集和验证集上的平均损失。generate_and_print_sample 函数则使用当前模型参数,根据给定的起始文本生成后续词元,让我们直观感受模型生成质量的变化。

训练结果与分析

运行上述代码后,模型在大约6.6分钟内完成了10个轮次的训练,优化了约1.61亿个参数。

损失变化

观察训练过程中的损失输出:

  • 训练损失:从初始的约9.78显著下降至0.39左右,表明模型在训练数据上学习效果很好。
  • 验证损失:从约9.93开始下降,但很快停滞在6.37左右,未能进一步降低。

训练损失与验证损失之间的巨大差距以及验证损失的停滞,是模型 过拟合 的典型迹象。

文本生成演变

每个训练轮次后生成的文本展示了模型的进步:

  • 轮次1-2:输出为“, , , ,”或“you and and and”,几乎没有学到结构。
  • 轮次3-6:开始输出“and I had been”、“you know the I had the donkey”等片段,出现了一些词汇。
  • 轮次7-10:输出变得更长,如“Every effort moves you know was one of the picture for nothing I told Mrus...”。语法结构有所改善,但经检查,这些文本几乎是直接从训练数据中记忆并复现的。

过拟合分析

验证损失居高不下以及模型直接“背诵”训练数据,都确认了过拟合的发生。这主要归因于我们使用的训练数据集非常小(仅约5000个词元),而模型容量(1.61亿参数)相对很大。模型很容易通过记忆有限的训练样本来降低训练损失,但无法泛化到未见过的验证数据上。

在实际的大语言模型训练中,通常会使用包含数十亿甚至数万亿词元的巨大数据集,并且通常只训练1个或少数几个轮次,这能在很大程度上缓解过拟合问题。

总结与展望

本节课中,我们一起完成了大语言模型预训练循环的完整实现。我们学习了:

  1. 如何组织输入和目标数据。
  2. 如何通过前向传播计算损失。
  3. 如何利用 loss.backward() 和优化器实现反向传播与参数更新,这是训练的核心。
  4. 如何监控训练和验证损失,以及通过生成文本来评估模型。
  5. 我们成功训练了一个模型,使其训练损失大幅下降,并能够生成语法上更通顺的文本,这是一个重要的里程碑。
  6. 我们也观察到了模型在小数据集上表现出的过拟合现象。

虽然出现过拟合,但我们已经成功搭建了使模型能够从数据中学习的完整框架。在下一节课中,我们将介绍 解码策略,例如温度缩放、Top-k采样等。这些技术可以在模型生成文本时引入可控的随机性,从而减少直接记忆输出,生成更多样、更连贯、更具创造性的文本,并有助于缓解过拟合带来的问题。

27:温度缩放(Temperature Scaling)在大语言模型中的应用

在本节课中,我们将学习一个非常重要的概念——温度缩放。我们将理解什么是温度缩放,以及为什么它在大语言模型中被使用。

上一节我们完全从零开始训练了一个大语言模型。我们定义了训练过程,并让模型运行了100个周期,观察了预测出的下一个词。然而,正如我们所见,模型生成的输出词元目前意义不大。本节课的目标就是学习如何减少输出词元的随机性,使其最终变得有意义。我们将学习实现这一目标的技术,其中一种关键技术就是温度缩放。

回顾当前解码策略

到目前为止,我们生成词元的方式是:从词汇表中所有词元的概率分数中,选择概率最大的那个。具体过程如下:输入句子(如“every effort moves”)经过模型架构处理后,会得到一个逻辑值张量。这个张量经过softmax函数后,转换为一组概率值。我们之前预测下一个词元的方法,就是找出概率值最大的那个索引(即词元ID),然后解码得到下一个词元。

例如,当输入是“every”时,概率张量中第二列的概率0.6最高,对应的词元ID是1,解码后得到下一个词元“effort”。当输入是“every effort moves”时,我们查看第三行,找到概率最高的条目(0.34,索引为5),解码后得到下一个词元“you”。

这种策略被称为贪婪解码。它导致生成的文本具有很高的随机性和多样性。既然我们得到的是概率分数,为什么一定要以这种确定性的方式选择下一个词元呢?我们能否以概率性的方式选择下一个词元?这正是温度缩放概念所要探索的。

引入概率性采样

控制随机性主要有两种技术,它们通常一起使用:第一种是温度缩放,第二种是Top-k采样(我们将在下一讲学习)。温度缩放的核心思想是:我们不再简单地选择最大概率对应的词元,而是用一个概率分布来替代这个“argmax”操作。

具体来说,我们不再盲目选择概率最高的词元i,而是根据概率分数来采样下一个词元。这个术语上的区别非常重要。“采样”意味着我们无法确切知道下一个词元会是什么,因为它是一个随机过程。例如,从高斯分布中采样,我们无法预知具体数值,但可以知道大多数样本会集中在均值附近。

在本例中,我们是从一个多项分布中采样。多项分布适用于多个互斥的结果。在这里,可能的结果数量等于词汇表大小K,每个结果(词元)都有一个关联的概率。当我们进行多次独立的“预测下一个词元”的试验时,就会形成多项概率分布。

代码演示:从贪婪解码到概率采样

本节课的主题是控制随机性的解码策略。让我们先简要回顾一下目前使用的generate_text_simple函数,然后介绍温度缩放和Top-k采样技术。

首先,我们将模型切换到CPU并设置为评估模式。模型架构包括嵌入层、Transformer块、归一化层和输出头。当前的generate_text_simple函数接收输入句子,通过模型架构,然后使用最大概率法预测接下来的25个词元。这被称为贪婪解码,它导致了较高的随机性。

现在,为了生成更多样化的文本,我们将用从概率分布中采样的函数来替换argmax操作。

为了用具体例子说明这种概率性采样,我们先假设一个很小的词汇表,包含以下9个词元:closer, every, effort, forward, inches, smooth, pizza, towards, you。每个词元有对应的ID(0到8),并维护一个从ID映射回词元的逆字典。

假设模型的输入是“Every effort moves you”。经过模型处理后,我们得到输出逻辑值张量,其列数等于词汇表大小(9)。将这个张量通过softmax函数,就得到了概率张量。所有概率之和为1。

之前我们的做法是直接找出最大概率(例如0.5721)对应的列索引(例如3),得到下一个词元ID(3),解码后得到词元“forward”。因此,预测的下一个词元是“forward”。

现在,为了实现概率性采样过程,我们将用PyTorch中的multinomial函数替换argmax。我们将multinomial函数应用于概率张量probs,然后根据从这个分布中采样的结果得到下一个词元ID,并预测对应的词元。

在这个例子中,采样结果可能仍然是4(对应“forward”)。你可能会问,这和之前有什么区别?关键在于,multinomial函数根据概率分数按比例采样下一个词元。目前我们只进行了一次试验,在这次试验中,multinomial函数查看所有概率,然后进行采样。由于“forward”的概率最高,它被选中的几率也最大,所以这次它被选中了。当我们进行更多次试验时,区别就会显现出来。

multinomial函数根据概率分数按比例采样下一个词元。换句话说,“forward”仍然是最有可能的词元,我们并没有改变这一点,它仍然是最有可能的。但multinomial函数大多数时候(而非所有时候)会选择它。

为了说明这一点,我们将把这个采样过程重复一千次。multinomial函数非常直观:当你进行一千次试验时,在每次试验中,它都会尝试选择概率最高的词元。现在这是一个随机过程,不再是确定性的。有时multinomial函数甚至可能选择“pizza”(尽管非常罕见),有时可能选择其他词元,但大多数时候它会选择“forward”。

以下是运行一千次试验后的结果:词元“forward”被采样了582次,“closer”被采样了73次,“inches”被采样了2次,“towards”被采样了343次。这意味着,由于我们用multinomial函数替换了argmax,大语言模型有时会生成如“every effort moves you towards”、“every effort moves you inches”和“every effort moves you closer”这样的文本,而不是每次都生成“every effort moves you forward”。

集成multinomial函数确保了我们不会每次都采样同一个词元。我们有时也给其他词元成为下一个词元的机会,这提升了大语言模型的创造力,带来了更多的探索性和创造性,有时还能产生更好的输出,而不是每次都采样一个确定性的预测。

温度缩放:概念与作用

你可能会想,这看起来不错,但为什么这个方法被称为“温度缩放”?“温度”从何而来,又为什么叫“温度”?

本质上,温度只是一个花哨的术语,指的是将逻辑值张量除以一个大于0的数。引入温度时,我们所做的唯一事情就是进行“缩放逻辑值”,我们所有的逻辑值都被另一个称为“温度值”的数除。

具体操作是:scaled_logits = logits / temperature。然后对缩放后的逻辑值应用softmax,得到概率张量。其余过程保持不变。但引入这个温度会改变概率的分布。

例如,让我们通过代码看看除以温度值会带来什么变化。我们可以通过一个称为温度缩放的概念来进一步控制和选择过程,温度缩放只是将逻辑值除以一个大于0的数的花哨描述。

现在我们将看到两种情况:温度大于1时会发生什么,以及温度小于1时会发生什么。

首先,我想在不进入multinomial函数的情况下,向你展示当你用温度缩放逻辑值并应用softmax后会发生什么。请看这张图:首先看温度等于1时的蓝色线条。你会看到“forward”的概率约为0.5,“closer”的概率约为0.1,“towards”的概率约为0.3。这和我们之前看到的情况完全一致。

现在看看温度较小时的情况。当你取这些逻辑值,将每个都除以0.1,然后取softmax,你会得到类似图中橙色的概率分布。现在“forward”的概率峰值更加尖锐,几乎所有其他概率都被压到了接近0。我们可以在代码中测试一下。

当我将逻辑值除以0.1并应用softmax后打印概率时,可以看到:几乎所有其他概率都被降到了0,但现在“forward”有一个非常高的尖锐概率。这与之前的概率形成对比,之前“towards”和“closer”也有显著的概率值,但现在这些值都变成了0,唯一最重要的是“forward”。这是第一个结论:当温度值非常低时,概率分布会出现峰值,这意味着它对特定值变得更加尖锐。

让我再试试除以5(即图中温度等于5的情况)。你会看到概率变得平坦了一些,这意味着所有词元都有较高的值。当我将逻辑值除以5并打印概率张量时,可以看到它变得有点平坦和均匀了。当然,“forward”的值仍然最高,但其他值如“towards”现在变得非常接近“forward”,甚至“closer”的概率值现在也变得和“forward”相似了。

如果温度值非常高,意味着概率分布变得平坦,每个词元作为下一个词元的概率都趋于均匀。你能想到这对生成下一个词元意味着什么吗?这意味着现在我们的输出词元有很大的可变性,有时这也被称为“创造力”。但在某些情况下,创造力可能过高,因为现在“pizza”也有一定的输出概率,这意味着我们的LLM也会预测“every effort moves you pizza”作为输出。理想情况下,我们不希望温度值太高,也不希望温度值太低。如果温度值太低,则只有一个词元被预测,我们也需要强调其他可能也有意义的词元。

正如我们所见,应用非常小的温度(如0.1)会导致尖锐的分布,之后当我们应用multinomial函数时,它几乎100%会选择最可能的词元“forward”。请记住,在这之后我们将应用multinomial,所以multinomial采样将在softmax之后进行。如果概率分布看起来像橙色那样,multinomial将总是采样“forward”,因为概率是0.991。因此,当温度值很小时,它会导致更尖锐的分布,而multinomial函数几乎总是选择最可能的词元,这样我们就接近了argmax函数的行为。当温度值较低时,几乎就像回到了我们之前选择最高概率分数对应词元ID的方法。

现在,如果你看更高的温度,比如温度值等于5,这实际上会导致更均匀的分布,其他词元也更经常被选择,如图中绿色所示。这可以为生成的文本增加更多多样性,但也会导致无意义的文本。例如,“pizza”现在有4%的概率,如果你使用温度等于5,我们大约有4%的时间会得到“every effort moves you pizza”,这并不好。因此,理想情况下,我们使用的温度需要有一个良好的平衡。

有一张非常好的图展示了为什么这被称为“温度”。你可以看到,如果温度变高,如右侧所示,分布变得更加均匀,每个词元都有一定的概率;而对于低温,我们有左侧的情况,分布更尖锐,只有一个词元有意义。因此,低温对应低熵,而当你提高温度时,事物通常变得更不稳定、更混乱、更有创造性,这就是为什么每个词元都有一定的概率成为下一个词元。所以它被称为“温度”,因为增加温度确保了所有词元都有一定的值,这使其有点不稳定,或者更确切地说,更有创造性、更随机化,几乎就像熵增加了。因此,从概念上讲,它被称为温度,因为温度升高会导致更平坦、更分散的分布,而降低温度意味着更低的熵,所以只有一个词元值更有意义。

这张图展示了当温度变得非常高时,几乎每个下一个词元都有相等的概率;当温度变得非常低时,我们则有之前看到的其他分布。

总结与工作流程

本节课我们学习了温度缩放,以及如何利用温度缩放以概率性的方式预测下一个词元。关键点在于,我们不再只是根据最大值选择下一个词元,而是使用多项概率分布来采样下一个词元。

其顺序工作流程如下:

  1. 首先获取逻辑值张量 logits
  2. 用温度值缩放逻辑值:scaled_logits = logits / temperature
  3. 对缩放后的逻辑值应用softmax函数,得到概率张量 probs
  4. 使用多项分布进行采样:next_token_id = torch.multinomial(probs, num_samples=1)

我们后来也看到,之所以称之为“温度”,是因为当温度变高时,就像熵增加了一样,每个下一个词元都有一定的概率成为下一个词元;而如果温度非常低,熵就很低,就像一切都集中在一个词元上,我们有一个更尖锐的概率分布。

因此,低温意味着LLM的输出没有任何创造性,它总是给出相同的输出;而高温意味着分布平坦,多样性更多,但也可能产生更多无意义的内容。所以在选择温度值时需要小心。

在下一讲中,我们将学习另一种在解码词元时减少随机性的技术,该策略被称为Top-k采样。通常,Top-k采样与温度缩放一起使用,因此我们也将看到Top-k采样如何与我们今天学习的温度缩放相结合。

感谢大家,这是一节简短的课程,但我希望你们理解了温度缩放的概念。非常感谢,我们下节课再见。

28:Top-k 采样在大语言模型中的应用 🎯

概述

在本节课中,我们将学习大语言模型(LLM)解码策略中的另一个重要技术——Top-k 采样。我们将探讨其工作原理、如何与温度缩放结合使用,以及它如何帮助模型生成更合理、更多样化的文本,同时避免过拟合。


解码策略的必要性

上一节我们介绍了温度缩放技术,本节我们来看看另一种解码策略。我们之所以需要这些策略,是因为最初简单的解码方法存在明显问题。

最初的方法是在词汇表中直接选择概率最高的词元作为下一个输出。这种方法非常确定,但会导致输出过于重复和随机,难以生成有意义的文本。

以下是该方法的简单描述:

next_token_id = torch.argmax(probabilities)

这种方法的问题在于,它总是选择同一个词元,缺乏创造性,并且容易导致模型简单地“背诵”训练数据,即过拟合。


概率采样与温度缩放回顾

为了解决确定性问题,我们引入了概率采样。其核心思想是:不从概率分布中确定性地选择最大值,而是根据概率分布进行采样。这意味着概率高的词元被选中的机会更大,但概率低的词元也有机会。

我们使用多项式分布进行采样。采样概率与词元的原始概率分数成正比。

随后,我们引入了温度缩放。其方法是将逻辑值(logits)除以一个温度参数 T,然后再应用 Softmax 函数。

温度的影响可以用以下公式描述:

scaled_logits = logits / T
probabilities = softmax(scaled_logits)
  • 高温(T > 1):概率分布变得更均匀,输出更多样化、更具创造性,但也可能更随机。
  • 低温(0 < T < 1):概率分布变得更尖锐,模型输出更确定、更保守。
  • T = 0:退化为确定性选择(argmax)。

Top-k 采样引入

尽管温度缩放引入了多样性,但它仍然存在一个问题:即使是非常不合理、概率极低的词元(例如在上下文“every effort moves you”后出现“pizza”),也有微小的机会被选中。这可能导致语法错误或完全无意义的输出。

Top-k 采样旨在解决这个问题。其核心思想很简单:将下一个词元的候选范围限制在概率最高的 k 个词元内,排除所有其他词元。

以下是 Top-k 采样的步骤:

  1. 获取模型输出的逻辑值张量。
  2. 找出逻辑值最高的 k 个值及其索引。
  3. 将这 k 个值之外的所有其他逻辑值替换为负无穷大(-inf)。
  4. 对处理后的逻辑值张量应用 Softmax。由于 -inf 经 Softmax 后变为 0,因此最终的概率分布仅由这 top-k 个词元构成,且它们的概率之和为 1。

在 PyTorch 中,关键操作如下:

# 获取 top-k 的值和索引
top_k_values, top_k_indices = torch.topk(logits, k)
# 创建掩码,将非 top-k 的位置设为负无穷
mask = logits < top_k_values[:, -1].unsqueeze(-1)
logits[mask] = -float('Inf')

结合温度缩放与 Top-k 采样

在实际应用中,我们通常将 Top-k 采样与温度缩放和多项式采样结合起来,形成一个强大的解码流程。

完整的解码工作流如下:

  1. 获取逻辑值:从 LLM 输出层得到逻辑值张量。
  2. 应用 Top-k 采样:保留 top-k 个逻辑值,其余设为 -inf
  3. 温度缩放:将处理后的逻辑值除以温度 T
  4. Softmax:转换为概率分布。
  5. 多项式采样:根据最终的概率分布采样下一个词元 ID。

这个流程确保了:

  • 多样性:通过温度缩放和多项式采样引入。
  • 合理性:通过 Top-k 采样排除明显不合理的词元。
  • 防过拟合:概率性采样避免了模型对训练数据的简单记忆。

代码实现

以下是一个结合了温度缩放和 Top-k 采样的生成函数的核心部分:

def generate_with_topk_temp(model, input_ids, max_new_tokens, top_k, temperature):
    for _ in range(max_new_tokens):
        # 1. 获取模型输出的逻辑值
        logits = model(input_ids)[:, -1, :]

        # 2. 应用 Top-k 采样
        if top_k is not None:
            top_k_values, _ = torch.topk(logits, top_k)
            min_top_k_value = top_k_values[:, -1].unsqueeze(-1)
            logits[logits < min_top_k_value] = -float('Inf')

        # 3. 应用温度缩放
        if temperature > 0:
            scaled_logits = logits / temperature
            probs = F.softmax(scaled_logits, dim=-1)
            # 4. 多项式采样
            next_token_id = torch.multinomial(probs, num_samples=1)
        else:
            # 退化为确定性选择
            next_token_id = torch.argmax(logits, dim=-1, keepdim=True)

        # 将新词元添加到输入中,继续生成
        input_ids = torch.cat([input_ids, next_token_id], dim=1)

    return input_ids

使用该函数生成文本,例如设置 top_k=25, temperature=1.4,可以观察到生成的文本不再是训练数据的简单重复,而是产生了新的、合理的组合,有效缓解了过拟合问题。


总结

本节课我们一起学习了 Top-k 采样这一重要的 LLM 解码策略。

  • 我们首先回顾了概率采样和温度缩放,它们通过引入随机性来增加输出的多样性和创造性。
  • 接着,我们指出了仅使用温度缩放可能让不合理词元有机可乘的问题。
  • 为此,我们引入了 Top-k 采样,其核心是将下一个词元的候选范围限制在概率最高的 k 个词元内
  • 最后,我们展示了如何将 Top-k 采样、温度缩放和多项式采样结合成一个完整的解码流程。这个流程能有效平衡输出的创造性合理性,并防止模型对训练数据的过拟合

记住这个核心工作流:逻辑值 → Top-k 过滤 → 温度缩放 → Softmax → 多项式采样。在接下来的课程中,我们将加载预训练的模型权重,并探索更多解码策略。

29:使用PyTorch保存与加载模型权重

概述

在本节课中,我们将学习如何使用PyTorch保存和加载大语言模型的权重。这对于处理我们迄今为止构建的大型语言模型至关重要,因为它能帮助我们节省内存和时间。我专门安排了这节课来教你如何在Python中实现权重的保存与加载。

回顾与过渡

在开始之前,我们先快速回顾一下到目前为止在本系列预训练大语言模型课程中所学的内容。

首先,我们研究了如何评估大语言模型的损失函数,了解了交叉熵损失在此处的应用,并定义了目标文本与大语言模型预测输出之间的损失。接着,我们运行了预训练循环,这是一个非常棒的实践环节,我们实际运行了一个大语言模型进行多次迭代,并根据给定的输入文本生成了新的文本。在这个过程中,我们发现了过拟合的问题:大语言模型只是记住了训练文本,并在预测时直接使用了这些记忆的文本。

为了避免过拟合,我们探讨了文本生成策略,包括温度缩放和Top-k采样,并学习了如何将两者结合以生成不过度拟合的文本。然而,尽管生成的文本不再过拟合,但其意义仍然不够明确,这主要是因为我们的训练数据集不够庞大。

因此,在下一讲中,我们将从OpenAI加载预训练的权重到我们的模型中,而不是从头开始训练我们自己的模型。OpenAI已经花费数百万美元预训练了GPT-2的权重,并将其开源。在下一讲中,我们将实际使用这些预训练权重,并将其集成到我们迄今为止构建的GPT架构中。

但在今天的课程中,我想为权重的保存和加载打下基础,因为当我们使用OpenAI的预训练权重时,这将非常有用。你可以把今天的课程看作是我为你准备的一个工具箱,帮助你理解下一讲关于OpenAI预训练权重的内容。

核心概念与函数

让我们开始吧。有两个主要函数非常重要:第一个是torch.save,第二个是model.load_state_dict。我来解释一下它们各自的实际含义。

假设我们有一个GPT模型,我们想要保存这个模型的参数。这是什么意思呢?本质上,我们之前已经在代码中定义了这个GPT模型类。当这个类被定义并构造出GPT模型实例时,模型内部包含了大量的参数,实际上参数数量超过1亿个。

与其每次运行都重新创建这个模型的新实例,我更希望将这1亿个参数保存到某个地方。PyTorch中保存这些参数的命令是torch.savemodel.state_dict

torch.save需要传入两个参数:第一个是模型的状态字典,第二个是你想要存储模型参数的文件名。model.state_dict是一个将每个层映射到其参数的字典,它是任何PyTorch模型的默认属性。

文件名在这里是model.pt。在PyTorch中,任何torch.nn.Module的可学习参数都包含在model.parameters()中,而状态字典就是一个简单的Python字典对象,它将每个层映射到其参数张量。

这个状态字典会获取每个层,并构建一个将其映射到该层参数的字典。然后,使用torch.save,我们可以将这个参数字典保存到名为model.pt的文件中。

第二个命令是model.load_state_dict。假设你保存了参数并发送给了项目合作者,他们可以在他们的环境中使用这个model.pt文件,通过torch.load加载它,然后调用model.load_state_dict。这样,当他们从头开始初始化模型时,他们的模型就会加载model.pt文件中包含的这些参数。

我在这里也展示了torch.nn.Module.load_state_dict的用法,它使用我们之前定义的状态字典来加载模型参数字典。状态字典基本上就是这个意思。

代码实践

太棒了,以上就是这两个命令。现在让我们在代码中看看它们的实际应用。

我们构建了一个GPT模型类的实例,我之前已经向你们展示过这个GPT模型类,这是我们之前在代码中定义的类。

我现在创建了这个类的一个实例,它有1.24亿个参数。然后,我在这里使用了torch.save命令,这是模型的状态字典,我将这个参数字典保存到一个名为model.pt的文件中。

正如我在这里写的,.pt扩展名是PyTorch文件的约定。技术上我们可以使用任何文件扩展名,这并不重要。

然后我们可以这样做:假设我是一个与其他人合作的研究人员,我将这个model.pt文件发送给那个人。他们可以创建一个GPT模型类的实例,并直接加载model.pt中提供的参数。为此,他们可以这样做:torch.load,然后执行model.load_state_dict,接着将模型设置为评估模式。他们会看到模型的所有参数都已被加载,这些参数正是model.pt文件中包含的。这为收到模型的新人节省了大量时间。即使对于你自己,如果你突然退出Python、Google Colab或Jupyter笔记本,与其重新训练模型,如果你能定期保存参数,那么下次运行代码时,这将为你节省大量时间和内存。

保存优化器状态

现在假设我在某个时间点训练了代码,并希望明天继续训练。加载模型参数是一回事,但优化器呢?因为在像Adam这样的优化器中,优化器也维护着梯度的历史记录以及梯度平方值的历史记录,对吧?我是否也应该保存这些值,因为优化器需要它们?如果我只保存模型参数,优化器基本上就丢失了它在训练过程中计算出的梯度值和梯度平方值。因此,通常也建议保存优化器状态。

与我们保存的参数类似,优化器也可以使用类似optimizer.state_dict的方法来保存。我马上会向你展示这个。这个字典存储的是优化器的超参数,但也存储了该优化器使用的历史数据,例如过去梯度值、梯度平方值,这些都是Adam优化器所需要的。

让我们看看如何保存优化器状态。我在这里写道,像我们用来训练LLM的AdamW这样的自适应优化器,会为每个模型权重存储额外的参数。这个优化器使用历史数据来调整每个模型的学习率,例如梯度的历史、梯度平方的历史。

如果不存储这些历史记录,优化器就会重置,这并不好,因为那样我们就无法有效利用迄今为止的训练成果,模型可能无法正确收敛。因此,使用torch.save,我们实际上可以同时保存模型和优化器的状态字典。

这是我们之前定义的优化器。现在你可以看到,之前我们使用torch.save只保存了模型参数。现在我们可以使用torch.save来保存模型状态字典,同时也可以使用optimizer.state_dict()来保存优化器状态字典。你可以在PyTorch中看到这个。

optimizer.state_dict()返回优化器的状态,它确实维护了梯度、梯度平方以及学习率、权重衰减等参数的历史记录。当Adam优化器用这些参数初始化时,保存优化器状态字典当然会保存这些参数,但也会保存历史记录。

你将这两个字典保存在这个名为model_and_optimizer.pth的文件中。当你把这些代码交给别人时,你现在可以传递给他们模型以及优化器状态,这样他们就可以直接使用模型以及优化器的当前状态,在他们那边继续运行模型。即使对于你自己,假设你今天关闭了训练会话,然后你想恢复训练,你必须将参数以及优化器状态保存在这个model_and_optimizer.pth文件中。

加载完整检查点

现在我们可以实际测试一下。这个文件已经保存好了,你可以执行torch.load加载这个特定文件,然后将内容加载到一个名为checkpoint的对象中。我们现在可以创建一个GPT模型类的实例,首先加载模型参数:我们使用这个checkpoint,然后查看这个字典model.state_dict,并在这里加载模型参数。接着,我们定义优化器,并加载优化器字典:我们查看checkpoint对象,加载优化器状态字典,从而加载优化器参数以及梯度和梯度平方的历史记录。

然后你将模型设置为训练模式。model.train()本身不会进行训练,我们只是将模型设置为训练模式。要进行训练,我们需要执行前向传播、反向传播、梯度下降等我们之前做过的步骤。

我向你展示模型和优化器状态的保存,只是因为这是一个非常有用的实践,尤其是在处理大语言模型时。否则,一切从头开始、丢失模型参数、丢失优化器状态会非常令人沮丧。我见过很多研究人员犯这个错误,不知道如何正确加载模型参数、优化器参数和优化器状态。一旦你学会了,它实际上相当简单。

我也会在视频描述中分享这些PyTorch链接给你。

总结

我希望你已经理解了这节课,我们在其中主要介绍了如何在PyTorch中加载和保存模型权重。

现在,我们已经拥有了所有必要的“弹药”来迎接下一讲:加载来自OpenAI的预训练权重。这将是一节非常棒的课程,我们将使用OpenAI为GPT-2提供的权重,然后使用我们在这个系列中构建的GPT架构,进行下一个预测任务。

非常感谢大家,我希望你们从这节课中学到了东西。请保持关注,我期待在下一讲中见到你们。

30:加载OpenAI GPT-2预训练权重

概述

在本节课中,我们将学习如何将OpenAI公开发布的GPT-2预训练权重加载到我们之前亲手构建的GPT模型架构中。这将使我们之前训练出的、生成文本不连贯的模型,能够生成有意义的句子。本节课是我们之前所有辛勤工作的成果展示。

下载与准备权重文件

上一节我们介绍了如何构建GPT模型架构,本节中我们来看看如何为其注入强大的“知识”——预训练权重。

首先,我们需要下载OpenAI GPT-2的权重文件。这些文件最初由TensorFlow保存,而我们的代码基于PyTorch,因此需要安装TensorFlow来读取这些文件。同时,我们安装tqdm库来跟踪下载进度。

以下是需要安装的库及其版本要求:

# 安装必要的库
# TensorFlow >= 2.15
# tqdm >= 4.66

下载过程通过一个自定义函数download_and_load_gpt2完成。该函数主要执行两个任务:

  1. 从指定URL(如Kaggle)下载包含模型权重的七个核心文件。
  2. 调用辅助函数load_gpt2_params_from_tf_checkpoint,从下载的文件中提取参数,并将其整理成一个结构化的参数字典。

理解下载的文件与参数结构

在深入代码之前,理解下载的文件和最终参数字典的结构至关重要。

以下是下载的七个核心文件及其作用:

  • checkpoint:指向存储模型权重的主要文件路径(通常是model.ckpt)。
  • encoder.json:词汇表文件,包含所有50257个token及其对应的ID。
  • vocab.bpe:字节对编码(BPE)合并规则列表,用于子词分词。
  • hparams.json:模型超参数设置文件,包含词汇表大小、上下文长度、嵌入维度等关键配置。

load_gpt2_params_from_tf_checkpoint函数的核心目标是构建一个名为params的字典,该字典包含以下五个关键部分,对应GPT模型架构中的可训练参数:

  1. wte (Word Token Embeddings):词嵌入矩阵,形状为 [50257, 768]
  2. wpe (Word Position Embeddings):位置嵌入矩阵,形状为 [1024, 768]
  3. blocks:包含所有Transformer块参数的嵌套字典。每个块内又包含:
    • 注意力层 (attn): 查询、键、值融合矩阵的权重和偏置。
    • 前馈神经网络 (mlp): 全连接层和投影层的权重和偏置。
    • 层归一化 (ln_1, ln_2): 缩放和偏移参数。
  4. g (Final Norm Scale):最终层归一化的缩放参数。
  5. b (Final Norm Shift):最终层归一化的偏移参数。

这个参数字典的结构完全映射了我们自建的GPT模型类中的每一个可训练组件。

配置模型与加载权重

理解了参数结构后,我们需要调整模型配置以匹配GPT-2的设置,然后将下载的权重加载到我们的模型实例中。

首先,更新模型配置。我们之前使用的配置与GPT-2基本一致,但有两个关键区别需要修正:

  1. 将上下文长度从256改为GPT-2使用的1024。
  2. 将注意力机制中查询、键、值的偏置项启用(设为True),以匹配GPT-2的原始设置。

代码示例如下:

# 更新模型配置以匹配GPT-2
new_config = {**GPT_CONFIG_124M, ‘context_length’: 1024, ‘qkv_bias’: True}
model = GPT(new_config)

接下来,执行核心的权重加载函数load_weights_into_gpt。这个过程是逐层、逐参数地将params字典中的值赋值给我们模型中的对应层。

以下是加载过程中的关键步骤:

  • 注意力层:从params[‘blocks’][…][‘attn’][‘c_attn’][‘w’]获取融合的QKV权重矩阵,将其分割后分别赋值给模型注意力层的querykeyvalue权重。偏置项同理。
  • 前馈神经网络:分别加载全连接层(c_fc)和投影层(c_proj)的权重和偏置。
  • 层归一化:加载每个Transformer块内两个层归一化层(ln_1, ln_2)以及最终层归一化的缩放(g)和偏移(b)参数。
  • 权重绑定:GPT-2使用了权重绑定技术,即输出层的权重与输入词嵌入层(wte)共享。因此,我们不需要为输出层单独加载权重。

通过一个自定义的assign函数确保加载的权重形状与模型层期望的形状完全匹配,否则会报错。

测试加载后的模型

最激动人心的环节是测试我们加载了GPT-2权重的自建模型。我们将使用相同的输入和生成函数,观察输出文本的质量变化。

我们使用输入“every effort moves you”,并设置生成参数(如max_new_tokens=25, temperature=1.5, top_k=50)进行文本生成。

以下是测试结果的对比:

  • 加载权重前:模型生成的文本不连贯,没有逻辑意义。
  • 加载GPT-2权重后:模型生成了如“every effort moves you toward finding an ideal new way to practice something, what makes us want to be on top of that.”这样语义连贯、语法正确的句子。

这证明了我们成功地将预训练知识注入了模型。现在,我们可以像研究者一样自由探索:

  • 调整生成参数(如temperature, top_k),观察输出随机性和质量的变化。
  • 修改模型架构(如Transformer块的数量、注意力头数),进行实验。
  • 更改优化器设置,进行微调实验。

总结

本节课中,我们一起完成了从下载OpenAI GPT-2预训练权重到将其成功加载到我们自建GPT模型的全过程。我们深入理解了权重文件的结构、参数字典的组织方式,以及权重与模型层之间的映射关系。最终,我们验证了加载权重后的模型能够生成高质量的文本,这标志着我们不仅理解了GPT的理论架构,也掌握了让其“工作”起来的实践能力。这为我们后续进行模型微调和特定应用开发奠定了坚实的基础。在接下来的课程中,我们将以此为基础,探索大语言模型的微调技术。

31:大语言模型微调入门与实践

在本节课中,我们将学习大语言模型微调的基础知识。微调是构建大语言模型的最后一个关键阶段,它能让预训练好的模型适应特定的任务。

课程概述

大家好,欢迎来到“从零开始搭建大语言模型”系列课程。到目前为止,我们已经完成了约30到32节课,涵盖了第一阶段(理解LLaMA架构、注意力机制、数据准备和采样)和第二阶段(大语言模型预训练、模型评估和加载预训练权重)。现在,我们准备进入最后一个阶段:微调。

通过预训练,我们看到模型已经能产生有意义的输出,效果相当不错,就像我们从头构建了自己的GPT模型。我们花费了大量时间来理解这个架构。现在,我们准备开始微调阶段。

为什么需要微调?

你可能会想,既然预训练模型已经工作得很好,我们是否就完成了大语言模型的构建?答案是否定的。

让我解释为什么需要微调。假设你已经预训练好了模型,这很好。但如果你有一个特定的任务呢?这个特定任务可能是基于公司自身数据构建一个聊天机器人,或者你是一家教育公司,想用自己的数据开发一个教育应用。又或者,你想为一家航空公司用其数据制作一个聊天机器人。

本质上,如果你想开发一个特定的应用程序,仅靠预训练模型是不够的。因为预训练模型是在互联网通用数据上训练的,你需要用额外的数据再次训练模型。这个过程就叫做微调

微调的形式化定义是:通过使用额外数据再次训练模型,使预训练模型适应特定任务

这里有一些非常重要的术语。第一个术语是“特定任务”。当你需要执行某些特定任务时,就需要进行微调。例如,如果你是一家公司,想要开发一个模型并投入生产,你不能直接使用预训练模型,因为它是在通用数据上训练的,无法基于你私有的、特定的数据给出你期望的答案。

第二个要点是“再次训练模型”。这意味着,到目前为止,模型的权重和偏置已经在预训练过程中得到了优化。现在,你将用额外的数据来训练模型,因此模型的参数、权重和偏置通常会发生变化,这个过程就是微调。

事实上,有很多关于微调的文章。其示意图是:你有一个预训练好的大语言模型,然后你在自定义数据集上进一步训练它,从而得到一个微调后的大语言模型。

因此,大语言模型微调的形式化定义是:微调大语言模型涉及对一个预先存在的模型进行额外训练,该模型先前已从海量数据中学习了模式和特征,现在使用一个较小的、特定领域的数据集进行训练。我们之所以再次训练这个模型,是因为我们有了新的、较小的、特定领域的数据。我们需要再次训练模型,使其调整其参数、权重和偏置。

微调的实际例子

为了给你一个微调的实际例子,这是我博士最后一年制作的网站,上面有我的出版物、演讲、媒体报道等。假设你也有一个类似的网站或博客,你想制作一个聊天机器人,这个机器人不像ChatGPT那样回答,而是像你本人说话的方式那样回答。你希望聊天机器人基于你的数据、你通常写文章的方式、你在网站上组织段落的方式来回答。你有特定的语气,你希望这种语气体现在你的聊天机器人中。你该如何实现?

仅靠预训练是不可能的,因为在预训练模型中,你的数据可能根本不存在。因此,你需要进行微调。你需要提供额外的数据,比如我网站上的所有内容、我的博客、我的出版物、我的演讲,这样模型才能理解并学习你的说话语气、你通常如何造句,然后它会进行适应。

这样开发出来的聊天机器人就会用我的语气(在这个例子中)说话。这就是微调。我寻找的特定应用是构建一个用我的语气说话的聊天机器人。对于这个特定应用,我有额外的数据(我的网站、出版物、演讲和媒体报道),我会将这些数据输入模型,并要求模型再次训练。这个再次训练模型的过程就叫做微调。现在,微调后的模型将完全按照我的期望行事,用我的语气说话,使用像我一样的语法句子。

另一个例子是,假设你有你的研究出版物(就像我在这里描述的一样),你想制作一个聊天机器人,专门回答人们关于你出版物的查询。那么,你可以基于你的出版物提供特定的数据。这也是微调的一个例子。

事实上,我想到这个例子的原因是,我在OpenAI的问答论坛上看到了一个问题。提问者说他们是微调学习的初学者,他们的目的是创建一个模型,能够使用他们博客中的语气,就像我们讨论的那样。他们问了很多关于如何具体操作、如何确保获得良好的准确性、如何防止产生错误答案的幻觉等问题。然后,OpenAI社区给出了许多答案来帮助这个人进行微调。

微调的两大类别

以上是微调的一般介绍。现在,微调本身又分为两大类。

让我为你标记一下。第一类叫做指令微调,这更常见、更广泛。第二类叫做分类微调

两者有什么区别?在指令微调中,我们使用特定指令在一组任务上训练语言模型。

这里我给出了两个指令微调的例子。假设指令是:“判断以下文本是否为垃圾邮件,用‘是’或‘否’回答”。这就是指令微调的一个例子,因为我们要求LLM:你将收到这样的文本,你的任务是查看文本并回答它是否是垃圾邮件,即进行分类或用“是”或“否”回答。这就是为什么这是一个指令微调的例子。我们在这里添加了用蓝色标出的指令,这非常重要。

或者,指令可以是:“将给定句子恰当地翻译成德语”。这是指令微调的另一个例子。因此,LLM接收输入句子,在第一种情况下输出“是”或“否”,在第二种情况下将英语句子翻译成德语。

这些之所以是指令微调的例子,是因为我们给出了具体的指令,而LLM基于该指令行事。

第二个例子是分类微调。这不如指令微调常见,但了解过机器学习和分类示例(如脑肿瘤分类、猫狗图像分类)的读者(或听众)会发现这非常相似。但现在,你只是使用大语言模型,而不是卷积神经网络或其他神经网络来进行分类。这里没有给LLM任何指令,只是输入没有指令的文本。

LLM必须对电子邮件是垃圾邮件还是非垃圾邮件进行分类。这里我们也看到了垃圾邮件/非垃圾邮件的例子,但之前我们给出了指令,要求LLM用“是”或“否”回答。而在这里,没有给出指令,只有两个类别:垃圾邮件和非垃圾邮件。我们训练LLM基于这两个类别进行输出。

因此,LLM接收一段文本,然后预测它是垃圾邮件还是非垃圾邮件。尽管在这个例子和上一个例子中结果相同,但提示本身的构建方式或LLM的构建方式是不同的。这就是我现在强调的重点:这是一个指令微调的例子,而这个模型输入没有指令的例子,是基于分类的微调的例子,我们只是使用大语言模型进行分类。

当我看到这个时,我感到非常惊讶,因为我不知道LLM可以用于分类任务。事实上,分类微调也用于情感分类,例如愤怒、悲伤、快乐等。如果你有一段文本,想将其分类到这五个类别之一,你可以使用分类微调。

但指令微调是更常见的微调方式,它可以处理更广泛的任务集。通常,它甚至需要更大的数据集,因为你希望它处理更广泛的任务。因此,基于指令的微调通常也需要更大的计算能力。为什么需要更大的计算能力?因为你给模型提供了指令,它必须根据你给出的指令在整个语料库中搜索,并且你执行的操作不是特定的。例如,这里的操作非常具体:垃圾邮件或非垃圾邮件。而它必须执行的操作可能像翻译一样复杂。使用同一个基于指令的模型,你还可以做其他事情。因此,一个基于指令的模型实际上可以处理更广泛的任务,如翻译、摘要等。

但在分类微调中,执行的唯一操作是将文本分类到哪个类别。因此,分类微调只能处理较窄的提示集。

所以,当人们想到微调时,通常只谈论基于指令的微调,但我发现分类微调也同样重要。因此,我们今天课程中要开始的例子实际上就是基于分类微调的概念。

高效的微调方法

现在,在基于指令的微调中,人们通常还会讨论使微调更高效的方法。在基于指令的微调中,实际上还有另外两种方法,叫做LoRAQLoRA。这些基本上属于参数高效微调的范畴。它是一种比全参数微调更高效的指令微调形式。

本质上,在参数高效微调中,我们只更新一部分参数,同时冻结其余的参数。这减少了可训练参数的数量,使得指令微调的内存需求更易于管理。

因此,LoRA基本上是一种改进的微调方法,它不是微调构成权重矩阵的所有权重,而是微调两个较小的矩阵,这两个小矩阵近似于这个大矩阵。我们现在不深入讨论LoRA的细节,我们会在后续课程中涵盖这个相当广泛的主题。但我今天只想向你介绍这个概念。

第二个是QLoRA,它本质上是量化LoRA。这是LoRA的一个更节省内存的迭代版本。QLoRA通过将LoRA适配器的权重量化为更低精度,将LoRA向前推进了一步。

现在,只需记住LoRA和QLoRA是更高效的微调形式,它们减少了传统上基于指令的微调所需的内存需求。

本节课实践内容

我希望你已经理解了指令微调和分类微调之间的区别。现在,我们将在今天的课程中开始处理一个实践问题,这是一个微调分类问题。

因此,我们现在将关注第二类,并采用一个真实的数据集。我们将查看一组电子邮件,包括垃圾邮件和非垃圾邮件,并训练一个大语言模型来分类它是垃圾邮件还是非垃圾邮件。

我们不会在今天的课程中完成所有这些步骤,因为这涉及很多步骤。首先,我们必须下载数据、预处理数据、创建数据加载器,这是数据准备的第一阶段。这个数据集将是所有被分类为垃圾邮件和非垃圾邮件的电子邮件。

然后,在第二阶段,我们必须初始化LLM模型、加载预训练权重、修改模型以适应微调、实现评估工具。

最后,在第三阶段,我们将微调模型、评估微调后的模型,并将模型用于新数据。

我本可以将所有这些内容放在一节课中,但那样我就必须匆忙完成。相反,我打算将其分成五到六节课,以便你按顺序理解整个微调过程。这是我们本系列所有课程遵循的理念:我首先在白板上,然后通过代码,带你详细了解每一个步骤。

今天,我们将要做两件事。首先,下载数据。第二件事是,我们将预处理数据。

在下一节课中,我们将创建数据加载器、初始化模型,然后稍后加载预训练权重。现在,让我们只关注第一步,也就是下载和预处理数据。在下一节课中,我们将看第二步,即创建数据加载器。

代码实践:下载与预处理数据

现在让我带你过一遍代码。在这部分代码中,我将其标题为“分类微调”。正如我提到的,第一步是下载数据集。这只是下载和解压数据的代码。让我带你看看数据集存在的地方。

这是UCI机器学习知识库,它非常有名,因为它包含大量数据集。在这个知识库中,还有一个SMS垃圾邮件收集数据集。这就是我们将要使用的数据。它包含大量电子邮件,既有垃圾邮件也有非垃圾邮件。

如果你向下滚动,你会看到关于这个数据集中具体包含什么的信息。首先,你会看到手动提取了425条SMS垃圾邮件。这是一个英国论坛,手机用户在该论坛上公开声称收到SMS垃圾邮件。所以有425条垃圾邮件,他们还有另外322条垃圾邮件,这些在其他网站上公开可用。所以总共有747条垃圾邮件。

现在让我们看看非垃圾邮件。在非垃圾邮件中,他们有大量的消息,正如你可以想象的,获取合法的非垃圾邮件更容易。他们收集非垃圾邮件的方式是在新加坡国立大学计算机科学系。自然地,如果是大学里学生之间的电子邮件,它们很可能不是垃圾邮件。因此,与垃圾邮件相比,我们有更多的非垃圾邮件。所以数据集有点不平衡。非垃圾邮件也被称为“ham”邮件,我不确定为什么存在这个术语,但非垃圾邮件被称为ham,垃圾邮件就称为spam。

这就是数据集。你甚至可以在这里下载数据,或者你可以直接运行我将提供给你的代码,该代码会下载并解压数据。一旦你运行这段代码,数据集将被下载到你的本地机器上。

它将被下载到这个名为“SMSSpamCollection.tsv”的文件中。所以在这里,你可以在我的VS代码中看到这个名为“SMSSpamCollection”的文件夹,这就是我现在在屏幕上向你展示的.tsv文件。在这里,你可以看到消息可以是ham(非垃圾邮件)或spam(垃圾邮件)。所以ham意味着不是垃圾邮件,spam当然是垃圾邮件。正如预期的那样,垃圾邮件内容类似于“免费进入并获胜”之类的东西。在这里,我们可以看到这些电子邮件确实有意义,并且这种分类也合理。

这就是整个数据集,你可以通过代码下载,也可以从我刚才展示的UCI知识库下载。一旦数据集下载完成,你可以使用pandas将其转换为数据框,这样读取数据就变得容易得多。你可以打印出数据框,它看起来像这样:标签要么是ham,要么是spam。

所以在这里,我们可以看到总共有5572行。也就是5572封电子邮件。但现在我们可以打印数据框中标签的数值计数。标签是一个列名,我们可以打印出ham条目的数量,即4825条非垃圾邮件,而垃圾邮件条目的数量要少得多,为747条。

现在,我们可以在这里做的是,我们需要使数据集平衡,对吧?有很多方法可以使数据集平衡,但我们将在这里采用一种简单的方法,因为这不是分类机器学习课程,而是关于大语言模型的课程。所以我们将采用一种简单的方法,我们将只是从非垃圾邮件中随机抽取747个条目,这样非垃圾邮件和垃圾邮件的数量就相互匹配,两者都应该是747。

因此,正如这里所写的,为了简单起见,并且因为我们出于教育目的更喜欢一个小数据集,我们对数据进行子采样,使其包含每个类别的747个实例。

这是创建平衡数据集的函数。这个函数要做的是,它将随机抽样ham实例,以便我们可以匹配垃圾邮件实例的数量,即等于747。这是通过这行代码完成的。

然后我们将ham子集与spam合并。所以现在,新的数据框是平衡的。如果你打印出平衡数据框的数值计数,你会看到ham(非垃圾邮件)的数量是747,垃圾邮件的数量是747。

在执行了之前的代码来平衡数据集之后,我们可以看到我们现在有等量的垃圾邮件和非垃圾邮件。很好,这正是我们想要的。

现在我们可以更进一步,看看标签。我们可以将ham分配为0,spam分配为1,而不是使用ham和spam。所以这些是我们每封电子邮件的标签编码。

我在这里写的一个注意事项是,这个过程类似于将文本转换为标记ID。记得在我们预训练大语言模型时,我们有一个大的词汇表,GPT词汇表,它有超过50,000个单词,实际上是50,257个标记,每个标记都有一个标记ID。这是一个简单得多的映射,我们只有两个标记,它们映射到0和1。

现在,正如我们通常在机器学习任务中所做的那样,我们将获取数据集并将其分成三部分。我们将取这747条数据,将其分成70%用于训练,10%用于验证,20%用于测试。

正如我在这里提到的,这些比例在机器学习中通常用于训练、调整和评估模型。所以在这里,你可以看到我写了一个随机分割函数。这个函数所做的就是,它只取训练分数,即训练数据的比例,将是0.7,验证分数将是0.1。我们首先构建训练数据框,它是主数据框的70%。验证数据框是剩余的20%,测试数据框是剩余的10%?不,验证数据框是10%,测试数据框是剩余的20%。

然后当这个函数被调用时,它将实际返回训练数据框、验证数据框和测试数据框。所以它将返回三个数据框给我们。

现在我们可以实际测试这个函数。我们有这个平衡数据框,我们将其传递给这个名为random_split的函数。一旦它被传递到这个函数中,我们还指定训练分数为0.7,验证分数为0.1。然后我们构建训练数据框、验证数据框和测试数据框。

让我们检查一下长度是否合理。我在这里输入新代码:len(train_df)。让我们看看长度是多少。是1045。是的,所以train_df的长度是1045,因为垃圾邮件和非垃圾邮件的总数是747加747,等于1494。

然后让我也打印出len(val_df)。也让我打印出len(test_df)。让我把这些都打印出来,这样我们可以看到它们是否确实加起来等于1494。

好的,所以现在我在打印这个。这里你可以看到train_df的长度是1045,val_df是149,test_df是300。让我们把它们加在这里:1045加149加300。

让我们看看,是1494。这是747加747,所以这是合理的。这是一种检查,确保训练、验证和测试数据框已正确创建。我喜欢时不时做这些检查,只是为了确保我们在代码中走在正确的轨道上。

现在,你可以做的是,我们还将这些数据框转换为CSV文件,因为我们以后需要重用它们。所以我们将使用to_csv函数。你可以搜索这个to_csv

Pandas的这个功能是,它可以获取你的数据框并将其转换为CSV文件。我也会在信息描述中添加此链接。

因此,你可以将此函数应用于训练数据框、验证数据框和测试数据框,然后你可以得到train.csvval.csvtest.csv文件。

到目前为止,我们已经完成了第一步,即下载和预处理数据。我们下载了数据,平衡了数据集(这是预处理的一部分),使垃圾邮件和非垃圾邮件的数量相同,都是747。然后我们将数据集分为训练集70%、验证集10%和测试集20%。

下节课预告

在下一节课中,我们将首先创建数据加载器,以便我们也可以进行批处理。在使用大语言模型时,使用数据加载器通常更好。然后我们将看看如何加载预训练权重,如何修改模型等。

你可能在想,如何使用大语言模型完成分类任务?最终发生的情况是,我们通常在其末尾拟合另一个神经网络,该网络将有一个softmax输出,以便进行分类输出。所以我们需要在这里进行增强,使输出是垃圾邮件或非垃圾邮件,即0或1。因此,我们将采用我们之前使用的相同架构,但我们将增强其末尾部分,使其适用于分类。

课程总结

本节课到此结束。非常感谢大家,我希望你们喜欢这种白板笔记加编码的方法。我们在这个系列中已经涵盖了大量课程,

32:LLM分类微调中的数据加载器 | Python编码 | 动手实践LLM

在本节课中,我们将学习如何为大型语言模型的分类微调任务创建数据加载器。我们将把原始的文本数据转换为模型可以处理的、格式统一的输入-目标对批次。

概述

在上一节课中,我们开始了关于大语言模型微调的学习。我们了解到,微调本质上是通过在额外数据上训练模型,来使一个预训练模型适应特定任务。我们看到了两种主要的微调类型:指令微调和分类微调。我们进一步开始了一个基于分类微调的实践项目——电子邮件分类。我们的目标是使用大语言模型来查看电子邮件,并将其分类为垃圾邮件或非垃圾邮件。

在上一节课中,我们完成了前两个步骤:下载电子邮件数据集并对其进行预处理。我们从一个包含约747封垃圾邮件和3000多封非垃圾邮件的数据集开始,平衡了数据集,将标签“ham”(非垃圾)编码为0,“spam”(垃圾)编码为1,并将整个数据集按70%(训练)、10%(验证)和20%(测试)的比例进行了分割,最终保存为CSV文件。

然而,要将数据输入到模型架构中,我们还需要一个关键步骤:创建数据加载器。这正是本节课的核心内容。

数据加载器的必要性

我们之前在学习LLM预处理时已经了解过数据加载器。我们看到,大语言模型的输入-目标对需要通过数据加载器来馈送,这能更好地管理数据。PyTorch提供了DatasetDataLoader工具,使我们能够轻松访问样本、对数据进行批处理,甚至进行并行处理。

本节课的目标可以理解为:我们已经有了数据集,现在需要将它们有效地组织成输入-目标对,以便高效管理。

目标:统一的输入批次

最终,我们希望达到这样一个阶段:给定一封电子邮件,我们将其转换为令牌(tokens),并确保每封邮件都被转换为相同数量的令牌。例如,一个批次可能包含8封邮件,每封邮件都被编码为120个令牌。这将构成我们的输入张量。对应于每个输入邮件,会有一个0或1的标签,构成目标张量。

我们创建数据加载器的原因,正是为了将数据转换成这种格式统一的输入-目标批次。

面临的挑战:文本长度不一

我们当前的数据是文本和标签(0或1)。然而,如果我们查看电子邮件长度,会发现它们并不相同。有些邮件较长,有些较短。但在处理批次时,每一行(即每封邮件)必须具有相同的列数(即令牌数量)。因此,我们需要确保所有文本消息具有相同的长度。

有两种方法可以实现这一点:

  1. 截断法:找到数据集中最短的邮件长度,然后将所有其他邮件截断到这个长度。
  2. 填充法:找到数据集中最长的邮件长度,然后对所有较短的邮件进行填充,使其达到这个长度。

截断法的缺点是会丢失较长邮件中的信息,因此不推荐。我们将采用填充法。

解决方案:使用填充令牌

对于所有较短的邮件,我们将用特定的令牌进行填充,直到它们达到最长邮件的长度。这个填充令牌被称为“文本结束”令牌。

具体来说,我们将使用GPT-2词汇表中的“文本结束”令牌,其令牌ID是50256。这个令牌通常用于在训练GPT时区分不同的文档来源。使用它作为填充令牌具有象征意义,当模型遇到它时,不会与任何随机单词混淆。

以下是整体工作流程:

  1. 我们拥有CSV文件中的输入文本(训练、验证、测试集)。
  2. 使用分词器将输入文本转换为一组令牌ID。
  3. 找出数据集中最长的电子邮件(以令牌数量计)。
  4. 通过填充令牌ID 50256,确保所有文本消息都具有相同的长度(即最长邮件的长度)。

代码实现:创建自定义数据集类

现在,让我们通过代码来了解如何具体实现。首先,我们需要实现一个PyTorch Dataset,它指定了在实例化数据加载器之前应如何加载和处理数据。

我们将定义一个SpamDataset类。这个类将完成以下工作:

  1. 识别训练数据集中的最长序列。
  2. 将每条文本消息转换为令牌ID。
  3. 确保所有其他序列都通过填充令牌进行填充,以匹配最长序列的长度。

以下是SpamDataset类的核心部分解析:

import torch
from torch.utils.data import Dataset, DataLoader
import pandas as pd
import tiktoken # GPT-2使用的BPE分词器

class SpamDataset(Dataset):
    def __init__(self, csv_file, tokenizer, max_length=None, pad_token_id=50256):
        self.data = pd.read_csv(csv_file)
        self.tokenizer = tokenizer
        self.pad_token_id = pad_token_id

        # 1. 将每条文本转换为令牌ID
        self.encoded_texts = [self.tokenizer.encode(text) for text in self.data[‘text‘]]

        # 2. 确定最大长度
        if max_length is None:
            self.max_length = self._longest_encoded_length()
        else:
            self.max_length = max_length

        # 3. 填充或截断序列
        self.encoded_texts = self._pad_or_truncate_sequences()

        # 标签
        self.labels = torch.tensor(self.data[‘label‘].values)

    def _longest_encoded_length(self):
        # 找到所有编码文本中的最大长度
        return max(len(encoded) for encoded in self.encoded_texts)

    def _pad_or_truncate_sequences(self):
        processed = []
        for encoded in self.encoded_texts:
            if len(encoded) > self.max_length:
                # 截断
                processed.append(encoded[:self.max_length])
            else:
                # 填充
                padding = [self.pad_token_id] * (self.max_length - len(encoded))
                processed.append(encoded + padding)
        return processed

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        # 返回编码后的张量和标签张量
        encoded_tensor = torch.tensor(self.encoded_texts[idx])
        label_tensor = self.labels[idx]
        return encoded_tensor, label_tensor

SpamDataset类从我们之前创建的CSV文件加载数据,使用tiktoken库中的GPT-2分词器对文本进行分词,并允许我们将序列填充或截断到统一长度。这个长度可以由最长序列决定,也可以由用户预定义。

现在,我们可以使用上一节课得到的train.csv文件创建SpamDataset类的实例。这里我们不设置最大长度,因此最大长度将从数据集中计算得出。打印出来可以看到是120,这很合理,因为数据集中最长序列不超过120个令牌。

需要注意的是,我们使用的GPT-2模型的上下文长度是1024,因此理论上可以处理最多1024个令牌的序列。

对于验证集和测试集,我们将填充它们以匹配训练集最长序列的长度(120)。重要的是,任何超过训练集最长示例长度的验证或测试样本都将被截断。

实例化数据加载器

数据集定义好后,就可以作为输入来实例化数据加载器了。

以下是创建数据加载器的示例代码:

# 创建数据集实例
train_dataset = SpamDataset(csv_file=‘train.csv‘, tokenizer=tiktoken.get_encoding(‘gpt2‘))
val_dataset = SpamDataset(csv_file=‘val.csv‘, tokenizer=tiktoken.get_encoding(‘gpt2‘), max_length=train_dataset.max_length)
test_dataset = SpamDataset(csv_file=‘test.csv‘, tokenizer=tiktoken.get_encoding(‘gpt2‘), max_length=train_dataset.max_length)

# 创建数据加载器
batch_size = 8
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=0, drop_last=True)
val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, num_workers=0, drop_last=False)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False, num_workers=0, drop_last=False)

在这里,我们设置了批大小为8,num_workers=0表示为了简化而不进行并行处理,drop_last=True意味着如果最后一个批次数据量较小,则丢弃它。

验证数据加载器

现在,我们可以运行一个测试来确保数据加载器正常工作,并返回预期大小的批次。

# 遍历训练加载器并打印批次维度
for inputs, labels in train_loader:
    print(f“Input batch dimension: {inputs.shape}“) # 应为 torch.Size([8, 120])
    print(f“Label batch dimension: {labels.shape}“) # 应为 torch.Size([8])
    break # 只看第一个批次

输入批次维度为[8, 120],这意味着每个输入批次有8行(批大小)和120列(最大令牌长度)。标签批次维度为[8],表示每个批次有8个标签(0或1)。这正好符合我们之前在示意图中展示的格式。

我们还可以打印数据加载器的长度来查看批次数量:

print(f“Number of training batches: {len(train_loader)}“)
print(f“Number of validation batches: {len(val_loader)}“)
print(f“Number of test batches: {len(test_loader)}“)

训练集共有1045个样本(70%),批大小为8,因此大约有130个训练批次。验证集占10%,测试集占20%,所以测试批次数量大约是验证批次的两倍。这些数字与我们的计算相符。

总结

本节课中,我们一起学习了为LLM分类微调创建数据加载器的完整过程。

我们回顾了上一节课完成的数据下载和预处理步骤。在本节课,我们深入探讨了如何将处理好的文本数据转换为模型可用的格式。核心步骤包括:使用BPE分词器将文本转换为令牌ID,通过填充(使用令牌ID 50256)或截断使所有序列长度一致,以及最终利用PyTorch的DatasetDataLoader类来高效地组织和管理数据批次。

至此,我们完成了数据准备的所有步骤。我们已经拥有了训练、验证和测试数据加载器,可以轻松地从中提取格式统一的输入批次和标签批次,为接下来的模型初始化、加载预训练权重、修改模型以进行微调做好了准备。

数据处理和清洗通常是模型训练前至关重要且繁重的工作,打下这个坚实的基础将使我们后续的模型训练更加顺利。在接下来的课程中,我们将进入第二阶段,开始构建和微调模型本身。

33:为LLM分类微调编写模型架构

在本节课中,我们将继续分类微调的实践项目。主要目标是使用预训练权重初始化模型,并对GPT/LLM架构进行修改,使其能够执行分类任务。

首先,让我们快速回顾一下这个LLM分类项目迄今为止的进展。我们从一个问题开始:给定一些文本数据,我们想使用大语言模型来分类它是否是垃圾邮件。这属于微调的范畴。我们在这个系列中已经构建了一个预训练的LLM模型,但尚未对其进行微调。微调对于使预训练模型适应特定任务至关重要。我们有两种微调类型:指令微调和分类微调。我们从这个电子邮件分类的实践项目开始。

以下是构建这个分类器要遵循的步骤。到目前为止,我们已经完成了标记为蓝色的前三个步骤:下载数据、预处理数据以及创建数据加载器。让我们快速回顾一下这三个步骤以及我们已经实现的内容。

这个数据集来自UC Irvine机器学习仓库,名为“SMS垃圾邮件收集”。下载后,你会看到标签为“ham”(非垃圾邮件)和“spam”(垃圾邮件)。数据集中,非垃圾邮件有4825条,垃圾邮件只有747条。因此,我们第一步是平衡数据集,使垃圾邮件和非垃圾邮件都有747条数据。这就是数据预处理。

接着,我们创建了数据加载器,以便能够批量输入数据。数据加载器的主要作用也是将数据集转换为一组输入和目标对。让我展示一下这些输入和目标对的实际样子。

在上节课中,我们将数据集转换成了这两个张量。第一个张量是输入张量,第二个张量是目标张量。你可以看到每个批次有8个输入样本。这里我展示了一个批次,它有8个文本样本。每一行对应一个样本,每一列对应一个令牌。我们使用字节对编码器将这些句子分解为令牌ID,并确保所有句子都转换为相同数量的令牌ID。对于较短的句子,我们用令牌ID 50256(文本结束符)进行填充。

输入张量有8行和120个令牌。这个120是根据数据中最长的文本消息确定的。输出张量则只是0或1,其中0代表非垃圾邮件,1代表垃圾邮件。现在,当我们想要在数据集上训练时,稍后定义的优化器将处理每个批次。在每个批次中,它将处理这8个文本样本,并利用这些输出进行工作。因此,数据加载器使我们在数据管理方面的工作变得非常容易。

到目前为止,我们已经完成了这三个步骤:下载数据、通过平衡非垃圾邮件和垃圾邮件类别来预处理数据集,最后我们还创建了训练数据加载器、测试数据加载器和验证数据加载器。我们使用了70%的数据进行训练,10%的数据进行验证,20%的数据进行测试。你可以在代码中看到这一点:0.7是训练数据,0.1是验证数据,0.2是测试数据。然后,我们有一个垃圾邮件数据集类,它作为输入提供给我们的数据加载器。这些数据加载器的输出是批处理格式,正如我在视觉表示中展示的那样。如果你想更详细地了解我们如何进行数据下载和预处理,我强烈建议你回顾前两节课。

现在,我们进入第二阶段。我们的目标首先是初始化将要使用的大语言模型,然后加载GPT-2的预训练权重,接着为了微调稍微修改模型架构,最后在本节课中实现评估工具。因此,我们将完成步骤四、步骤五和步骤六。这将是一节内容全面的课程。让我们开始吧。

现在我们已经有了训练、测试和验证数据加载器。接下来,我们来看GPT架构。在本系列课程中,到目前为止我们已经构建了这个架构,我现在在屏幕上放大了它。暂时忽略右边的两张图片,只看这个灰色的架构。这是我们迄今为止重点关注的LLM架构。

我们要做的第一件事是将预训练的GPT-2权重加载到这个架构中。如果你没有看过之前的课程,让我快速回顾一下:OpenAI已经免费向公众提供了GPT-2的权重,并且提供了多种参数规模的权重,如1.07亿、1.24亿、7.74亿等。OpenAI甚至为GPT-2发布了公开声明,宣布了这些权重。我们要做的是,与其自己进行预训练(这需要巨大的成本和计算资源),不如直接将预训练的GPT-2权重加载到这个GPT模型中。我们在之前训练大语言模型时已经这样做过。现在让我们进入代码,看看这部分是如何完成的,然后我们再进入第二步。

好的,我现在带你进入代码。今天我们要做的这节课大部分内容将涉及逐步讲解代码。我会一步一步地向你解释代码的每个部分。

现在,我们的第一个任务是准备用于分类微调的模型,以识别垃圾邮件。我们将使用之前用过的相同架构,然后加载预训练权重。稍后我们会在最后一层做一个小的修改,但现在先看看如何加载预训练权重。当你从GPT-2下载权重时,有小型、中型、大型和超大型模型。我们将选择GPT-2小型模型,它有1.24亿个参数。我们有一个基础配置,这意味着词汇表大小是50257,上下文长度是1024,丢弃率是0,查询/键/值偏置项设置为True。这些值与GPT-2训练时使用的值相同。由于我们正在重用这些预训练的GPT-2权重,我们保留了相同的配置。

我们将用我们选择的模型更新这个基础配置。在这里,我们从model_config字典中选择choose_model = ‘gpt2-small’。在最后这段代码中,我们做的是:如果我们的训练数据集中有一些文本消息的最大长度大于上下文长度(即1024),我们将设置最大长度等于上下文长度。简而言之,我们将删除所有长度超过上下文长度的令牌。这是因为我们的LLM只能处理最大长度等于上下文长度的令牌,在本例中就是1024。

很好,现在配置已经准备好了。接下来我们要做的是下载GPT-2参数。下载GPT-2参数有一个特定的方法,为此我现在要带你查看代码文件。

正如我提到的,下载GPT参数有一个特定的方法。我们编写了这个名为download_and_load_gpt2的代码。这段代码的作用是下载OpenAI在公开GPT-2权重时规定的所有权重和完整的模型细节。我们将下载所有这些文件,然后返回两样东西:设置和参数。

“设置”基本上就是这些配置:词汇表大小、上下文长度、嵌入维度、注意力头数和Transformer层数。“参数”基本上是一个字典,这个字典是以一种非常特定的格式构建的。当返回参数字典时,我们得到一个包含五个键的字典:令牌嵌入、位置嵌入、Transformer块中的所有参数(现在用蓝色标记)、最终归一化层的缩放和移位参数。有一节单独的课程我们实际解释了所有这些参数以及这些键是如何从GPT-2导入的。但现在你只需要知道,当你运行这个函数download_and_load_gpt2时,它会给你设置字典(GPT-2配置的列表)和参数字典(包含以特定格式组织的所有参数)。你得到令牌嵌入、位置嵌入、Transformer层参数以及最终的输出归一化层参数。本质上,参数字典包含了你需要的所有参数。

现在让我们回到代码。我们要做的是从这个名为gpt_download_3的文件中导入download_and_load_gpt2函数,也就是我刚才展示给你的函数。当你运行这个函数时,需要传递两个参数:第一个是模型大小,我们将从choose_model中获取,即1.24亿;第二个是你想要存储参数的目录,我传递的是gpt2

当你运行这段代码时,你会得到两个字典:settingsparamssettings将包含配置文件,params将包含GPT-2的所有不同参数。然后,我们初始化之前定义的GPT模型类的实例,并调用load_weights_into_gpt函数。这个函数的作用是获取params字典,然后将所有权重从params加载到我们的模型中。让我展示一下这个加载字典做了什么:它接收我们创建的模型,并在模型所有具有可训练参数的地方(如多头注意力层、归一化层、前馈神经网络、令牌嵌入、位置嵌入等,我现在用箭头标记的地方都有可训练参数),load_weights_into_gpt函数将GPT-2参数加载到这个模型中。基本上,你可以认为我们的模型已经完全装备了直接从GPT-2加载的最佳参数。

在之前一节名为“预训练”的课程中,我们解释了整个代码,包括load_weights_into_gpt函数和GPT模型类等。现在,你只需要理解我们正在将GPT-2的参数加载到我们的模型中,这样我们的模型就已经是预训练好的了。如果你之前已经加载过,这个运行会非常快,因为文件已经存在。如果你是第一次运行,请注意GPT-2提供的总参数文件大小(如果你看那七个文件,加起来大约500MB),所以根据网速可能需要一些时间。一旦下载完成,你会看到你的模型现在已经更新了来自GPT-2的所有权重。你甚至可以测试模型是否正确加载,可以传入输入文本“Every effort moves you”,然后使用我们之前定义的generate_text_simple输出函数。这个函数接收输入文本,将其通过我们的模型,然后生成输出。它生成了15个新令牌。

你可以看到,“Every effort moves you”是输入,然后15个新令牌是“for. The first step is to understand the importance of your work or summary right”。这意味着预训练参数正在工作,因为这段文本是合理的,是恰当的英语。

现在,我们处于GPT模型参数已加载到我们架构中的阶段。接下来,我们进入下一阶段,开始微调模型。但在我们开始将模型作为垃圾邮件分类器进行微调之前,让我们看看我们的模型是否已经能够通过指令提示来分类垃圾邮件。请注意,到目前为止,模型只是预测下一个令牌,我们还没有训练它预测是否是垃圾邮件。但让我们看看模型是否已经内在地学会了这些能力。我要做的是,不在文本提示中提供像“Every effort moves you”这样的文本,而是说“Is the following text spam? Answer with a yes or no.”,然后给出文本“You are a winner. You are specifically selected. You have been specifically selected to receive $1000 cash or $2000 reward.”。请注意,到目前为止我们还没有给模型任何关于垃圾邮件或非垃圾邮件的数据集。我们只是检查基于GPT-2训练本身,它是否能回答这个问题。

当你通过generate_text_simple函数传递它时,让我们看看答案。问题是“Is the following text spam? Answer with a yes or no. You are a winner.”。它显然失败了,模型在遵循指令方面很吃力。这是因为模型只经过了预训练,缺乏任何微调。正如我们所见,没有任何分类微调,模型无法正确执行,这是意料之中的。

现在让我们进入第二步。第一步是将预训练的GPT-2权重加载到模型中,这一步我们已经完成了。现在我们进入第二步:通过添加分类头来修改架构。让我详细解释一下。

你可能在想,我们的模型已经被训练来预测下一个令牌,我们如何进行分类?这就是魔法发生的地方。如果你还记得输出层,让我们看看这个线性输出层。在文本分类或文本生成任务中(LLM通常为此训练),这个输出层看起来是这样的:输入是嵌入维度大小768,输出等于50257,因为那是词汇表大小。所以,当输入是“Every effort moves you”时,对于每一行,输出将有50257列。因为那等于词汇表大小。所以“Every”有50257个条目,“effort”有50257个条目,“moves”有50257个条目,“you”有50257个条目。如果你想在“Every effort moves you”之后预测下一个令牌,你看最后一行,然后选择概率最大的那个令牌ID,这就给了你下一个令牌。这就是你预测下一个令牌的方式。但现在我们不需要下一个令牌预测。现在我们的工作只是分类是或否。

所以,我们现在要做的是同样的事情,但输出维度会改变。“Every effort moves you”是我的输入。现在对于每个令牌,我们想要两个输出:是或否。所以“Every”有两个输出,“effort”有两个输出,“moves”有两个输出,“you”有两个输出。为了得到最终答案,我们将看最后一行,也就是“you”,因为它包含了所有先前的信息,然后我们将看“yes”值和“no”值。这些值将表示概率。然后,根据哪个值更高,我们将分类是否是垃圾邮件。因此,我们不是让最终的神经网络输出层大小为50257,而是将原始的线性输出层替换为一个从768个隐藏单元映射到仅2个单元的层。这两个单元对应什么?它们简单地对应垃圾邮件与非垃圾邮件。

这是我们要在LLM架构中做的唯一改变。当我第一次看到这个时,我感到非常惊讶,因为我从未见过分类头。所以这可以被认为是分类头。我感到惊讶是因为我以前只用神经网络和决策树做过分类,从未想过可以在GPT架构之上添加这个分类头,并将其本身用作分类器。这可能有点大材小用,因为即使是决策树或神经网络也可能有效。但这只是一个有趣的应用,表明LLM实际上可以用来执行分类任务。LLM是否比神经网络或决策树表现更好,这是一个开放的研究问题,仍需探索。

好的,这就是现在添加到GPT模型架构顶部的分类头,用于分类答案是“是”还是“否”。在我们深入代码之前,我想提到的另一件事是,我们实际上可以选择要微调哪些层。当然,当你添加这个分类头时,它并不存在于原始的GPT-2架构中。所以这些参数我们需要微调。但我们有一个选项,可以在所有这些参数中进行选择。GPT-2已经给了我很多参数,那么我需要微调多少呢?这是一个你需要做的决定。这里我想提到的一点是,由于我们已经从一个预训练模型开始(加载了GPT-2权重),实际上没有必要微调所有层。这是因为较低层,如令牌嵌入层、位置嵌入层、层归一化层等,这些较低层真正捕获了适用于广泛任务和数据集的基本语言结构和语义。这非常重要。如果你看较低层,有捕获语义含义的令牌嵌入,有位置嵌入,然后我们有一定数量的多头注意力等,其中令牌嵌入被转换为上下文向量,或者输入嵌入被转换为包含一个令牌对所有其他令牌关注度信息的上下文向量。GPT-2已经在大量文本上进行了训练,所以它已经包含了一些关于含义等信息。因此,如果你给出一封电子邮件,关于这封电子邮件是否是垃圾邮件或者它代表什么信息,这些信息已经以某种方式被捕获在这个较低层中,因为GPT-2是一个非常聪明和智能的模型,它确实捕获了这些信息。所以我们可以选择要微调哪些层。我们在这里做出的选择是,我们只微调最后几层。我们肯定会微调这个分类头,我们肯定会微调最终的线性归一化层(即缩放和移位参数),并且我们将微调最终的Transformer块。

请记住,GPT-2架构有12个这样的Transformer块。我们不是微调所有12个Transformer块,而是只微调最后一个Transformer块。我们假设所有其他Transformer块内在地包含了一些关于数据中文本代表什么的信息。因此,这是在实现良好准确性和降低计算成本之间的一个良好折衷。记住,如果你要再次微调所有东西,那么加载预训练权重的目的是什么?我们加载GPT-2预训练权重的原因是因为它有望捕获一些关于文本代表什么的语义含义。

所以我们要做的是只微调三样东西:我们将微调最终的输出头(即这里的分类头,因为它当然不存在于GPT-2中),然后我们将微调最终的Transformer块(第12个Transformer块),以及我们将微调最终的层归一化模块。这就是我们要做的三件事。我们将微调这些,其余所有其他参数我们将冻结,这意味着我们不会训练剩余的参数。

好的。在我们进入代码之前,我想解释的另一件事是:每个令牌将产生两个输出,对吧?假设我的句子是“Every effort moves you”。输出是垃圾邮件还是非垃圾邮件?我应该看这四个令牌中的哪一个?我应该看第一个令牌、第二个、第三个还是第四个?因为它们都会有“是/否”值。我应该看哪个令牌?这里有一个很好的示意图来说明为什么我们应该总是提取最后一个输出令牌。我们应该总是提取最后一个输出令牌,因为最后一个令牌是唯一一个对所有其他令牌都有注意力分数的令牌。如果你看第二个令牌,它只包含相对于第一个令牌的注意力。如果你看第三个令牌,它只包含相对于前两个令牌的注意力。而最后一个令牌拥有所有信息,它包含相对于所有先前令牌的注意力。因此,如果你想预测是否是垃圾邮件,你想从包含句子中最大信息量的那一行进行预测,这等于最后一个令牌。所以我们将提取最后一个令牌的输出行,并用它来预测电子邮件是否是垃圾邮件。

因此,所有这些正是我现在要在代码中向你展示的内容。让我们进入代码。我们要做的第一件事是在顶部添加一个分类头,正如我在这张图中向你展示的。我们将用这个新的输出头替换原始的输头。

在这一部分,我们修改预训练的大语言模型,为分类微调做准备。为此,我们将原始的将隐藏表示映射到词汇表大小50257的输出层,替换为一个更小的、仅映射到两个类(0和1)的输出层。所以你看,我们想要这种只有两个输出的输出层,即末尾有两个神经元。

我想提到的一点是,从技术上讲,我们可以使用单个输出节点,因为我们处理的是二元分类任务。然而,这不是一个通用的方法。如果我们使用单个输出,那就不通用,如果我们有更多的类别。所以这里我们做的是一个更通用的方法。当我们编写模型架构代码时,我们会说最终输出节点的数量应该等于类别的数量。我们没有硬编码输出节点,而是获取类别的数量,并将最终输出节点的数量设置为那个值。例如,如果我们有三个类别,如技术、体育或医学,我们的相同代码将适用于这个修改,因为那时最终输出节点的数量将等于三。这只是我想提到的一个小细节。

在构建模型架构之前,我们可以打印原始模型架构。我们打印出原始模型架构,你可以看到有12个Transformer块。Transformer块的数量从0到11,这就是为什么有12个Transformer块,并且在末尾有一个输出投影层。

很好,这就是输出头。这里你可以看到输入维度为768,输出特征维度为50257。这是我们计划改为2而不是50257的部分,我们只想要2作为输出特征。

如前所述,GPT模型由嵌入层(令牌嵌入和位置嵌入)、12个相同的Transformer块、一个最终的层归一化和输出层(输出头)组成。所以,我们要做的是用一个新的输出层替换输出头,正如我们在这里的图中说明的那样。

为了做到这一点,让模型为分类微调做好准备,我们首先冻结模型。这意味着我们将首先使所有层不可训练。冻结整个模型的方法是:对于model.parameters()中的所有参数,我们设置requires_grad = False,这意味着我们根本不会更新这个参数,也就是冻结所有模型参数。

然后,我们将告诉这个模型哪些参数我们要微调。正如我提到的,有三组参数我们要微调:最终的输出头、最终的Transformer块和最终的层归一化模块。首先,让我们修改最终的输出头架构。我们将设置model.output_head,现在的大小是:输入特征仍然是嵌入维度768,输出特征现在等于类别数量。所以如果类别数量等于2,输出特征将是2(在本例中是垃圾邮件与非垃圾邮件)。如果类别数量是3,输出特征将等于3。这就是你做的简单更改。这向Py

34:微调LLM垃圾邮件分类模型

在本节课中,我们将继续基于微调的分类项目。我们将学习如何计算分类损失和准确率,并实现训练循环、测试循环,从而完成整个微调项目。

项目回顾

上一节我们介绍了数据加载器的实现,本节我们来看看如何评估和优化模型。首先,让我们回顾一下到目前为止在这个基于微调的分类实践项目中涵盖的所有内容。

我们最初下载了数据集,然后对数据集进行了预处理,并创建了数据加载器。为了让你了解数据集的实际内容,让我向上滚动一下代码来展示数据集的样子。

数据集本质上是一个垃圾邮件/非垃圾邮件分类任务。我们从UCI机器学习仓库下载了SMS垃圾邮件收集数据集,该数据集包含被标记为垃圾邮件或非垃圾邮件的短信。

下载数据后,我们发现数据集是不平衡的:大约有40800条非垃圾邮件,但只有747条垃圾邮件。因此,我们平衡了数据集,使非垃圾邮件类别和垃圾邮件类别都有747条消息。这是我们做的第一个数据预处理步骤。

之后,我们实现了数据加载器。当我们实现数据加载器时,整个数据集被分批处理为输入和目标。数据集被分割为训练集、测试集和验证集,其中70%用于训练,20%用于测试,10%用于验证。

如果你查看70%的训练数据,由于数据加载器的作用,这些数据将被分割为输入和目标。在输入中,我们定义了批次,每个批次包含8个样本。大约有130个这样的训练数据批次。列数等于120,这是每条短信对应的标记ID数量。

为了确保所有短信具有相似或完全相同的标记ID数量,我们用标记ID 50256(对应文本结束标记)填充了较短的短信。这就是输入张量。这里展示的是目标张量,它只包含0和1,其中0表示非垃圾邮件,1表示垃圾邮件。

因此,当你为训练数据实现数据加载器时,它看起来像这样,有130个批次。当你为验证和测试数据实现数据加载器时,数据也被分批成类似的批次。所以我们有130个训练批次、19个验证批次和38个测试批次。这就是我们在前三个步骤中实现的内容:下载数据集、预处理数据和创建数据加载器。

在接下来的步骤中,我们处理了模型架构。模型架构最初看起来是这样的。我们查看了最终的输出层,该输出层最初是一个神经网络,输入大小为嵌入维度768,输出为50257(词汇表大小)。由于我们正在进行分类任务,我们将这个神经网络替换为这种分类头作为输出,其中神经网络的输入数量为768,但输出数量等于2(垃圾邮件或非垃圾邮件)。

到上一讲结束时,我们看到如果将任何输入传递给这个修改后的架构,例如输入“do you have time”,并将其传递给这个修改后的架构,输出将类似于这样:doyouhavetime。对于每个标记,将有两个输出,分别对应垃圾邮件或非垃圾邮件。

然后我们看到,我们不是查看所有这些输出,而是只查看最后一个标记(本例中为time)对应的输出,因为通过其注意力权重,最后一个标记包含了所有其他标记的信息。

我们已经进行到这个阶段:传入这个输入,得到一个形状为 1 x 4 x 2 的输出张量。因为这是一个批次,批次大小为1,所以是1;4是因为有四个标记;2是因为每个标记有两个输出,分别对应垃圾邮件或非垃圾邮件。然后我们看到,我们将查看最后一个输出标记,最后一个输出标记将给我们两个值。我们已经进行到这个阶段。

在今天的课程中,我们将看到,一旦你从最后一个标记得到最终的两个输出,你将如何处理这些输出?我们将首先实现两个指标:获取准确率,获取损失函数。然后我们将实现反向传播,以便我们可以训练我们的架构以最小化损失函数。我们将修改所有参数以使损失最小化,然后我们将在模型未见过的一些新数据上进行测试。

将模型输出转换为类别标签预测

首先我们需要讨论如何将模型输出转换为类别标签预测。假设你有输入短信“you won the lottery”。到目前为止,我们已经看到我们可以提取最后一行对应的输出。假设输出看起来像这样,基于这个输出,我们如何判断它是否是垃圾邮件?

在实践中,我们可以对此应用一个softmax函数,以便将这两个输出转换为一组概率。那么第一个值将是0.99,第二个将是0.01。然后我们查看具有最高概率值的索引。由于0.99是更高的概率,这意味着索引号0更可能是答案,而索引号0对应非垃圾邮件,因此这条短信将被分类为非垃圾邮件。

类似地,如果你有第二条短信“do you have time”,如果最后一行对应的输出是这两个标记,我们再次应用softmax,然后我们将得到一个概率张量:0.01和0.99。那么索引号1更高,因此我们的模型预测的输出将是1,即垃圾邮件。这些是我们要在代码中实现的步骤。

你稍后会看到,实际上甚至不需要实现softmax,因为我们只查看值较高的索引。例如,即使你看这些值,这个索引更高,所以索引0更高,因此它是非垃圾邮件。如果你看这两个,索引1会更高,所以它是垃圾邮件。

现在让我们转到代码,讨论如何将模型输出转换为类别标签预测。

假设现在我们有一个最后标记的输出,看起来像这样:-3.5983 和 3.9902。正如我们讨论的,首先我们将应用softmax,将其转换为一组概率。假设我们对这些输出应用softmax并打印softmax值,你会看到当你对这两个应用softmax时,输出张量有两个值:0.0005 和 0.9995。由于0.9995更高,我们接下来要做的是查看argmax,即查看具有较高值的索引,那将是索引号1,所以我们的类别标签预测将是数字1。

在这种情况下,代码返回1,意味着模型预测输入文本是垃圾邮件。正如我提到的,使用softmax是可选的,因为最大的输出直接对应于最高的概率分数。所以我们可以直接查看这些输出并找到argmax。

这就是我们要做的。假设我们查看最终标记并查看其输出,我们将取argmax,它将给我具有较高值的索引,那将是索引号1,所以我的类别标签将只是那个标签。然后我们将得到输出。

计算分类准确率

现在这是我的分类准确率,它衡量数据集中正确预测的百分比。假设正确答案是类别标签1,如果我得到类别标签1,那就很好。我们将比较模型预测和正确的标签预测,然后那将给出我的准确率。

为了确定分类准确率,我们将基于argmax的预测代码应用于数据中的所有示例,然后我们将把它与数据集中的实际值进行比较。然后我们将找到准确率。

为了说明这一点,假设我们的批次看起来像这样。我要做的是,假设这是我的第一个输入,我将通过我的模型传递它,得到那两个逻辑值,然后我将应用Argmax函数并预测它是否是垃圾邮件。假设它被预测为垃圾邮件。类似于这些输出标签,我将有另一个标签,即预测标签。这些输出标签也称为目标标签,是我的真实值,这里我有我的预测标签。然后我只需比较这两者,就能得到准确率分数。

这正是我们现在要在代码中做的。你可以看到我们将定义这个calculate_accuracy函数,给定一个加载器。假设给你一个训练数据加载器,我们首先要做的是,如果未指定批次数量,我们将只使用数据加载器的长度作为批次数量或批次大小。

每个批次由8个训练示例组成。因此,对于训练数据样本,批次数量将等于130。如果我们在这里指定了批次数量,那么批次数量将是我们在这里指定的最小值(比如50)和130之间的较小值,那么它将考虑批次数量等于50,并且只计算那么多批次的准确率。

这段代码将发生的情况是,我们查看数据加载器中的每个批次。假设我们正在查看第一个批次,第一个批次有8个样本。当我们查看每个批次时,我们将传入一个批次的所有样本,并找到逻辑值,即两个输出值,最后一个输出标记的逻辑值。但现在想象一个批次有8个样本,所以我将有8个这样的张量。

然后我将做的是,我将找到整个批次的argmax值。然后我将比较预测标签与目标标签,即我的实际答案。如果预测正确,即如果它们相等,我将更新正确预测的数量,将正确预测的数量增加一。

当我遍历示例时,每当我做出预测时,我也会将示例数量增加一。所以如果我在这里遍历第一个示例并做出预测,示例数量将增加一。当我做出第二个预测时,它将再次增加一。我只是在跟踪示例数量和正确预测数量。

最后,为了找到准确率分数,我将只取正确预测数除以示例总数。如果示例总数是1000,正确预测是600,我的准确率将是600除以1000。

我们在这里做一件非常简单的事情。我们只是计算模型的预测,并将其与实际值进行比较,然后累加我们得到了多少正确预测。这是找到准确率的最简单方法。

这就是calculate_accuracy_loader函数的代码。现在我们要做的是使用这个函数calculate_accuracy_loader,并且为了简单起见,我将指定批次数量等于10。我们的训练数据加载器实际上有130个批次,但我指定批次数量等于10,以便你可以看到我们是否能够计算整个数据集上的训练、验证和测试准确率。

当然,这里没有任何优化。所以我们的值不会很好。但我只是想向你展示这段代码确实可以运行。你有这个函数calculate_accuracy_loader,首先你传入训练加载器,那将包含来自训练数据集的数据,即我们数据的70%。然后你传入验证加载器,那是你数据的10%。然后你传入测试加载器,那是你数据的20%。在每种情况下,我们都指定批次数量等于10。

然后我们打印出训练准确率、验证准确率和测试准确率。模型尚未优化,我们尚未实现反向传播,所以这些准确率指标不会很好,但让我们看看它们是多少。当你打印出训练准确率、验证准确率和测试准确率时,你得到训练准确率是46%,验证准确率是45%,测试准确率是48%。这相当糟糕,甚至比抛硬币还差。我本可以只是抛硬币并随机预测正确值,我会有50%的正确率。

定义损失函数以优化模型

为了提高预测准确率,我们需要微调模型。记住我们如何微调或优化模型参数?优化模型参数的方法是,我们现在可以做两件事:我们有目标值(真实值)和预测值。现在我们需要做的是,基于真实值和预测值,我们需要定义一个损失函数。

一旦定义了损失函数,那么我们将简单地计算损失函数对所有可训练权重的偏导数。我们将计算关于可训练权重的梯度,然后进行更新。所以新权重等于旧权重减去损失关于该权重的偏导数。我们将使用一种称为Adam或AdamW的简单梯度下降变体,然后我们将继续更新这些参数,直到损失函数最小化。

目前,假设损失函数看起来像这样(当然不会这么简单,但我举一个简化的例子)。最初我们从这里开始,损失不是那么低,然后我们沿着这个损失函数向下移动,希望达到这个全局最小值,在那里损失被最小化。一旦损失最小化,那么我们将确保准确率也自动提高。

那么问题来了:如何定义损失函数?使用什么损失函数?如果你以前学习过神经网络和机器学习,我们知道如果我们有目标值Y_i,我的预测是P,那么在这种情况下使用的损失函数是分类交叉熵损失,定义为负的Σ(对所有类别标签求和)Y_i * log(P)。

让我用一个简单的例子来说明。假设我们有一个文本数据,其真实值是非垃圾邮件,这意味着它的one-hot编码是1和0。假设这是非垃圾邮件,但我们的预测值(经过预测后)是0.8和0.2。那么交叉熵损失是负的,我们需要对所有类别求和Y_i * log(P_i)。Y_i是真实值,P是预测值。

让我们计算:我们将1(真实值)乘以log(0.8),0乘以log(0.2),然后取这个的负值。0乘以log(0.2)是0,然后1乘以log(0.8)是某个值,取负值后大约是0.2231。为什么这是一个好的损失度量?因为如果我们的预测值是1和0,正好等于真实值,那么第二项无论如何都是0,但第一项将是1 * log(1),等于0。所以如果预测值等于真实值,那么我们的损失将是0,这正是我们想要的。因此,这个负的y*log(p)是在分类任务中计算损失的一个非常好的损失函数。

为了给你一个直观的理解,-log(x)的图形看起来像这样。这是x,这是-log(x)。我们希望x尽可能接近1,即正确类别的概率。最终,我们将从某个高损失开始,我们的目标是使损失尽可能接近0。

交叉熵损失的另一个优点是它是可微的,因此在我们进行反向传播时非常有用。

现在,让我们实际定义交叉熵损失。除了这个calculate_accuracy_loader,我们还要做的是定义一个损失函数,即交叉熵损失。为什么我们不能只使用分类准确率并取该准确率的倒数来得到损失?因为分类准确率不是可微函数。所以我们将使用交叉熵损失作为最大化准确率的代理。这与我们在预训练大语言模型中使用的交叉熵损失相同。

现在我们要做的是,假设我们得到一个输入批次和一个目标批次。每当给出输入和目标批次时,你的脑海中应该浮现出这个图:我们有一个输入批次和一个目标批次。

这里需要做的是,一旦你得到输入批次,你通过模型传递它,然后你只查看最后一个输出标记的逻辑值,因为它包含最多的信息,然后你找到这个逻辑值张量(最后一个标记的输出)和目标批次之间的分类交叉熵损失。

逻辑张量输出可以是类似0.8和0.2的东西,目标输出是1和0。当你计算交叉熵时,你将得到损失函数的某个值。

我将在这里展示torch.nn.functional.cross_entropy,这是我们用来找到交叉熵损失的PyTorch功能。

这太棒了,这是可微的,所以当我们进行反向传播时,它将非常有用。

我们将使用这个calculate_loss_batch函数来计算单个批次的损失,我们也可以用它来计算多个批次的损失。要计算多个批次的损失,我们必须使用与这里类似的代码行。

如果未指定批次数量,那么我们将批次数量设置为等于数据加载器的长度。如果指定了批次数量,那么它等于指定的批次数量与数据加载器长度的最小值,与我们看到的准确率分类代码非常相似。

然后我们要做的是,我们将取一个输入批次,一个目标批次,使用这个calculate_loss_batch(它将实现分类交叉熵)计算输入批次所有样本和目标批次之间的损失。每当我们得到一个损失时,我们将累加损失,那就是总损失。

最后,我们将用总损失除以批次数量,这样会给我们每个批次的平均损失。这就是我们最终将尝试通过反向传播最小化的损失。这就是我们将要遵循的整个工作流程。

现在我们可以做的是,在我们的数据集上实现这个损失函数。我们还没有实现反向传播,所以损失会很高,但我只想向你展示训练损失、验证损失和测试损失的初始值。这里我再次将批次数量设置为5,因为实际上训练数据加载器有130个批次,计算需要很长时间。无论如何,我们还没有在这里进行训练,我只是想说明可以在五个批次上找到损失。

所以你实现calculate_loss_loader函数,并传入训练加载器、验证加载器和测试加载器,然后你还传入批次数量。然后你可以打印出训练损失、验证损失和测试损失。你可以看到这些值相当高。在准确率中我们看到准确率非常差,这也反映在损失值中。

实现训练循环以微调模型

现在我们将实现一个训练函数来微调模型,这意味着你将调整参数以最小化训练损失,然后你还将打印验证损失和测试损失。

现在让我们开始查看这部分代码。

到目前为止,我们已经完成了许多步骤:下载数据集、预处理数据、创建数据加载器、初始化模型、加载预训练权重、修改模型以进行微调、实现评估工具(基本上是损失和准确率)。

现在我们处于这个阶段,我们将实际对模型进行微调,这意味着我们将定义训练循环并实现反向传播。

这是我们将要定义的训练循环。首先,我们将有周期数,这意味着一个周期是遍历整个数据集一次。假设你正在运行一个特定的周期,第二个循环是你必须遍历每个批次。每个批次有8个样本,至少我们是这样定义训练数据加载器的。

然后我们将查看每个特定样本,然后计算当前批次的损失,并实现反向传播以计算损失梯度,然后使用损失梯度更新模型权重。这里我们要做的是:新权重等于旧权重减去学习率乘以偏导数,这正是我们在这里写的。

一旦权重更新,我们打印训练和验证损失,然后我们对多个周期继续做同样的事情,以便参数不断更新。思考这个问题的最简单方式是,最重要的步骤是这个反向传播。一旦我们进行反向传播,我们就得到损失梯度,这就是为什么我们需要损失函数是可微的。

一旦我们得到关于参数的损失梯度,我们就可以实际更新参数。一旦我们进行足够多次,参数将被更新,并希望达到损失函数最小化的损失值。

这与我们为预训练LLM实现的训练函数完全相同。我想向你展示的是,当我们在监督数据上微调模型时(比如我展示给你的垃圾邮件/非垃圾邮件标签数据集),我们需要再次训练模型。因此,预训练中涉及训练过程,微调中也涉及训练过程,这就是为什么它被称为预训练——因为它是在这个需要实现的第二次训练过程之前。

现在让我们看看训练过程是如何在代码中实现的。我将这部分命名为“在监督数据上微调模型”。

到目前为止,我们实际上还没有在数据集上训练模型,这意味着参数尚未优化。

在本节中,我们将定义并使用训练函数来微调预训练的LLM,并提高其垃圾邮件分类准确率。

需要注意的是,如果你一直跟着这些课程,你会发现训练函数非常接近我们之前用于预训练的train_model_simple函数。唯一的区别是,我们在这里跟踪示例数量(文本样本数量),而不是我们之前计算的标记数量。

在代码中,我们将做七步。第一步是将模型设置为训练模式。你可以看到我们将模型设置为训练模式。这是第一步。

第二步是重置先前批次的损失梯度。当我们查看每个批次时,我们必须再次重置损失梯度。假设我们现在正在查看一个批次,我们重置先前批次迭代的损失梯度。

第三步是计算损失梯度和更新模型权重。这些是最重要的步骤。然后你做的是找到该批次的损失,然后通过反向传播计算损失梯度,然后执行optimizer.step()。这就是优化器发挥作用的地方。在白板上,我向你展示了简单的普通梯度下降,但在实践中,我们将使用一个更复杂的优化算法,它跟踪先前的梯度、先前的梯度平方等,以便优化以更好的方式进行,并且模型不会陷入局部最小值。

下一步是跟踪示例数量。我们只是跟踪我们看到的示例数量。input_batch.shape[0]是批次中的样本数量。例如,如果批次有8个样本,标记数量是120,那么我们将在这里得到8。input_batch.shape[0]将给我们这里的样本数量。

然后我们跟踪看到的示例数量。你可以把这个“看到的示例”理解为:当你查看一条短信时,那就是一个看到的示例;当你查看第二条短信时,你将看到的示例数量增加一。每当你遍历一个完整的批次时,你将全局步数增加一。

现在这里我们有

35:指令微调简介、数据集加载与提示词格式化

在本节课中,我们将学习一个名为“指令微调”的主题。我们将了解为什么需要对预训练的大语言模型进行微调,并开始动手准备用于指令微调的数据集。

课程概述

在之前的课程中,我们已经完成了大语言模型构建的第一阶段(数据处理、注意力机制、模型架构)和第二阶段(预训练循环、模型评估、加载预训练权重以构建基础模型)。我们还学习了一种基于分类的微调方法。

现在,我们将开始学习另一种更常见的微调类别:基于指令的微调。在本系列接下来的5-6节课中,我们将从零开始构建一个属于我们自己的个人助手。我们已经有了一个预训练模型,但它需要进一步改进和微调,才能胜任个人助手的角色。

为什么需要指令微调?

预训练的大型语言模型(如我们目前构建的模型)在文本补全方面表现不错,但在遵循指令方面却存在困难。

例如,如果你要求一个未经微调的预训练LLM“修正这段文本的语法”或“将文本从主动语态转换为被动语态”,它将无法完成这些任务。未经微调的LLM可以生成新的词元和句子,但它无法遵循特定的指令。

如果我们想构建一个个人助手,就需要LLM能够遵循指令。无论是“修正我的句子语法”、“删除句子中所有的‘the’和‘a’”,还是“让句子更简洁、语气更专业”,模型都需要通过微调来理解这些指令。

以下是两个需要指令微调的实际例子:

1. 电商客服聊天机器人
一家电商公司希望部署一个客服聊天机器人来处理订单状态、退货和产品推荐等查询。如果仅使用预训练模型,模型将难以理解基于公司特定政策、产品和服务的客户查询。通过指令微调,公司可以提供领域特定的指令(例如:“如果用户询问退货政策,请提供发起退货的步骤”),从而确保聊天机器人不仅能理解通用语言,还能用公司特定的信息进行准确回复。

2. 个性化医疗保健助手
医疗环境中的虚拟助手旨在帮助患者预约、提醒服药等。预训练模型可能具备一般的医疗知识,但缺乏对特定医疗机构实践、治疗方案等的了解。通过使用特定医疗指南和患者历史数据进行指令微调,可以确保助手理解医学术语,遵循特定指南,并提供个性化的建议。

指令微调的核心概念

指令微调,也称为监督式指令微调,其核心在于我们为模型提供大量的“指令-响应”配对数据。模型通过学习这些配对,学会如何根据给定的指令生成合适的响应。

以下是几个指令微调数据集的例子:

  • 指令:将45公里转换为米。
    期望响应:45公里等于45000米。
  • 指令:为“bright”提供一个同义词。
    期望响应:“bright”的一个同义词是“radiant”。
  • 指令:编辑以下句子以去除所有被动语态。“The song was composed by the artist.”
    期望响应:The artist composed the song.

我们的目标就是在一个由这类“指令-输入-输出”三元组组成的数据集上训练LLM,使其学会遵循指令。

构建个人助手的工作流程

我们将遵循以下顺序工作流来构建我们的个人助手:

  1. 加载包含指令和响应的训练数据集。
  2. 对数据集进行批处理,创建数据加载器。
  3. 加载预训练的LLM。
  4. 微调LLM。
  5. 检查损失,提取响应,进行评估并评分。

在今天的课程中,我们将完成第一步:数据集的下载与格式化。这一步与我们之前所做的有所不同,会非常有趣。

动手实践:下载与格式化数据集

现在,让我们开始代码部分。在本节中,我们将下载并格式化用于指令微调预训练LLM的数据。

第一步:下载数据集

我们将使用一个包含约1100个“指令-响应”配对的数据集。首先,我们定义一个函数来从指定的URL下载并加载这个JSON格式的数据集。

def download_and_load_file(url):
    # 代码从指定URL下载并加载JSON数据
    # ...
    print(f"Number of entries: {len(data)}")
    return data

运行此函数后,我们可以确认数据集已正确下载,包含1100个条目。

第二步:查看数据格式

加载的数据列表中的每个条目都是一个字典,包含三个键:instruction(指令)、input(输入,可能为空)和output(输出/响应)。

让我们打印几个条目来查看其结构:

# 打印第50个条目
print(data[49])
# 输出示例: {'instruction': 'Identify the correct spelling of the following word.', 'input': 'occasion', 'output': 'The correct spelling is occasion.'}

# 打印第999个条目(无输入的例子)
print(data[998])
# 输出示例: {'instruction': 'What is the antonym of complicated?', 'input': '', 'output': 'The antonym of complicated is simple.'}

第三步:将数据格式化为提示词(Prompt)

我们不能直接将原始的instructioninputoutput格式输入给LLM。研究发现,在训练过程中,需要以特定的格式向LLM提供提示词才能达到最佳效果。最常用的格式之一是斯坦福Alpaca模型使用的格式

Alpaca提示词格式如下:

Below is an instruction that describes a task. Write a response that appropriately completes the request.

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/vizuara-llm-scr/img/8439fe0f3efcc1bf6ca464804ba9b72f_2.png)

### Instruction:
{instruction}

### Input:
{input}

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/vizuara-llm-scr/img/8439fe0f3efcc1bf6ca464804ba9b72f_4.png)

### Response:
{output}

如果某个条目没有input,则### Input:部分留空。

我们将定义一个函数format_input,将原始的条目字典转换为这种Alpaca格式的提示词。

def format_input(entry):
    instruction_text = entry.get('instruction', '')
    input_text = entry.get('input', '')
    
    # 构建Alpaca格式提示词
    prompt = f"Below is an instruction that describes a task. Write a response that appropriately completes the request.\n\n### Instruction:\n{instruction_text}"
    if input_text:
        prompt += f"\n\n### Input:\n{input_text}"
    prompt += "\n\n### Response:\n"
    
    return prompt

让我们用之前的数据测试这个函数:

# 测试有输入的例子
formatted_prompt = format_input(data[49])
desired_response = data[49]['output']
full_example = formatted_prompt + desired_response
print(full_example)

输出将类似于:

Below is an instruction that describes a task. Write a response that appropriately completes the request.

### Instruction:
Identify the correct spelling of the following word.

### Input:
occasion

### Response:
The correct spelling is occasion.
# 测试无输入的例子
formatted_prompt = format_input(data[998])
desired_response = data[998]['output']
full_example = formatted_prompt + desired_response
print(full_example)

输出将类似于:

Below is an instruction that describes a task. Write a response that appropriately completes the request.

### Instruction:
What is the antonym of complicated?

### Input:


### Response:
The antonym of complicated is simple.

这个完整的字符串(提示词 + 期望响应)将在后续微调过程中作为输入提供给大语言模型。

第四步:划分数据集

接下来,我们需要将数据集划分为训练集、测试集和验证集。我们将使用85%的数据进行训练,10%进行测试,5%进行验证。

total_size = len(data)
train_size = int(0.85 * total_size)
test_size = int(0.10 * total_size)
val_size = total_size - train_size - test_size # 剩余5%

train_data = data[:train_size]
test_data = data[train_size:train_size + test_size]
val_data = data[train_size + test_size:]

print(f"Training set size: {len(train_data)}")
print(f"Testing set size: {len(test_data)}")
print(f"Validation set size: {len(val_data)}")

运行后,你将看到类似这样的输出:

Training set size: 935
Testing set size: 110
Validation set size: 55

总结与下节预告

在本节课中,我们一起学习了:

  1. 指令微调的必要性:使预训练LLM能够遵循特定指令并适应领域特定数据。
  2. 指令微调数据集的结构:由“指令-输入-输出”三元组组成。
  3. Alpaca提示词格式:一种将原始数据转换为LLM训练所需的标准提示词格式。
  4. 数据准备流程:我们完成了数据集的下载、查看、格式化(转换为Alpaca提示词)以及划分(训练/测试/验证集)。

目前,我们的数据已经准备好了,但还不能直接用于训练。在下一节课中,我们将进入关键步骤:将数据组织成训练批次。这涉及到确保所有输入长度一致、将文本转换为词元ID、创建数据加载器等具体操作。

通过接下来的4-5节课,我们将最终构建出属于自己的个人助手聊天机器人。这将使你从ChatGPT的“消费者”转变为“创造者”,并让你有信心为任何公司构建定制的聊天机器人解决方案。

请尝试跟随课程并做好笔记。如果你是第一次观看本课程,我鼓励你观看之前的所有课程以巩固理解。感谢大家,我们下节课再见!

36:指令微调中的数据批处理 🧱

在本节课中,我们将继续上一讲开始的指令微调实践项目,重点学习如何将数据组织成训练批次。数据批处理是模型训练的关键步骤,它涉及将不同长度的文本样本转换为统一格式的数值张量,以便模型能够高效地进行批量学习。

概述

上一讲我们介绍了指令微调的基本概念。预训练的大语言模型擅长文本补全,但在遵循特定指令(如“修正语法”或“转换为被动语态”)方面表现不佳。因此,我们需要通过指令微调来教会模型遵循指令。这个过程需要一个由“指令-输入-输出”对组成的数据集。

我们使用的数据集包含1100个这样的配对。上一讲中,我们完成了数据下载,并将其格式化为 Alpaca 提示风格,然后将数据集划分为训练集(85%)、测试集(10%)和验证集(5%)。

本节课,我们将深入探讨数据批处理的五个核心步骤,这是指令微调中至关重要且细节丰富的一环。

数据批处理的五个步骤

数据批处理的目标是将不同长度的文本提示转换为具有相同维度的数值张量。以下是实现此目标的完整流程:

步骤一:使用提示模板格式化数据

首先,我们需要将原始的“指令-输入-输出”数据对转换为模型可以理解的统一提示格式。我们采用 Alpaca 提示风格。

代码示例:格式化函数

def format_input(entry):
    # entry 包含 instruction, input, output 三个键
    if entry['input']:
        prompt = f"Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.\n\n### Instruction:\n{entry['instruction']}\n\n### Input:\n{entry['input']}\n\n### Response:\n"
    else:
        prompt = f"Below is an instruction that describes a task. Write a response that appropriately completes the request.\n\n### Instruction:\n{entry['instruction']}\n\n### Response:\n"
    # 将输出(响应)附加到提示后面,构成完整的训练文本
    full_text = prompt + entry['output']
    return full_text

步骤二:将格式化数据转换为词元ID

接下来,使用分词器(如 OpenAI 的 BPE 分词器 tiktoken)将文本提示转换为一系列词元ID(Token IDs)。每个词元ID对应词汇表中的一个特定词或子词。

核心概念:词元化
词元ID序列 = 分词器(格式化后的文本提示)

步骤三:通过填充使批次内样本长度一致

在一个训练批次中,不同样本的词元ID序列长度可能不同。为了进行批量矩阵运算,我们需要将它们填充到相同的长度。

具体做法是:

  1. 找出当前批次中最长的词元ID序列长度 max_len
  2. 对于批次中其他较短的序列,在末尾添加特殊的 填充词元ID,直到其长度等于 max_len

我们使用的填充词元ID是 50256,它对应于 GPT-2 词汇表中的 <|endoftext|>(文本结束)标记。使用这个特殊标记进行填充,可以避免引入无意义的词汇信息。

步骤四:创建目标词元ID

这是指令微调中关键且可能反直觉的一步。我们不是简单地将“输出”部分作为目标,而是采用与预训练类似的 下一个词元预测 任务。

目标词元ID的构建方法:
目标序列 = 输入序列[1:] + [<|endoftext|>]

也就是说,目标序列是输入序列向右移动一位,并额外在末尾添加一个 <|endoftext|> 标记。

为什么这样做?
通过让模型学习预测输入序列中的“下一个词元”,模型会在自回归生成过程中,逐步学会在看到完整的指令和输入后,生成正确的响应。虽然指令和输入部分也出现在目标中看似冗余,但这种设置能让模型在统一的“预测下一个词元”框架下,隐式地学会指令跟随。

步骤五:用占位符替换目标中的填充词元

在步骤三中,我们为了对齐长度添加了许多填充标记(50256)。在计算损失时,我们不希望这些人为添加的填充标记影响模型的学习。

因此,在目标张量中,我们将除了第一个 50256(它标志着真实文本的结束)之外的所有 50256 替换为一个特殊值:-100

为什么是 -100?
在 PyTorch 的 CrossEntropyLoss 函数中,参数 ignore_index 的默认值就是 -100。所有标签值为 ignore_index 的位置都会被损失函数忽略,不参与梯度计算。这确保了只有真实的文本词元(以及第一个标志文本结束的 <|endoftext|>)才对模型训练有贡献。

代码实现详解

以下是实现上述批处理逻辑的自定义整理函数(Custom Collate Function)的核心部分:

代码示例:自定义整理函数

def custom_collate_fn(batch, pad_token_id=50256, ignore_index=-100):
    # batch 是一个列表,包含多个词元ID列表
    # 1. 找到批次中最长的序列长度
    max_len = max(len(item) for item in batch) + 1 # 额外加1以便后续构造目标

    # 2. 对每个序列进行填充
    padded_inputs = []
    for item in batch:
        # 先添加一个 <|endoftext|>,便于后续构造目标时直接移位
        padded = [pad_token_id] + item
        # 再填充到最大长度
        padded = padded + [pad_token_id] * (max_len - len(padded))
        padded_inputs.append(padded)

    # 3. 转换为张量
    input_tensor = torch.tensor(padded_inputs)

    # 4. 创建目标张量:输入张量右移一位
    target_tensor = input_tensor[:, 1:].contiguous()
    # 调整输入张量,去掉最后一个多余的列,使其与目标长度一致
    input_tensor = input_tensor[:, :-1].contiguous()

    # 5. 将目标张量中多余的填充标记替换为 ignore_index (-100)
    # 创建掩码:找出所有是填充标记的位置
    pad_mask = target_tensor == pad_token_id
    # 找出每个序列中第一个填充标记的位置(即真实的文本结束位置)
    first_eos_mask = (target_tensor == pad_token_id).cumsum(dim=1) == 1
    # 保留第一个填充标记,将其他填充标记替换为 ignore_index
    target_tensor[pad_mask & ~first_eos_mask] = ignore_index

    return input_tensor, target_tensor

关于目标掩码的讨论

一个进阶问题是:是否应该在目标中掩码掉指令和输入部分对应的词元,只让模型学习响应部分?

  • 支持掩码的观点:这可以迫使模型专注于生成响应,而不是记忆指令,可能有助于减少过拟合。
  • 反对掩码的观点:一些研究(如论文《Instruction Tuning with Loss Over Instructions》)表明,不进行掩码反而能带来更好的模型性能。

目前这仍是一个开放的研究问题。在我们的基础实现中,我们采用不掩码指令和输入的标准做法。你可以将此作为扩展练习,尝试实现掩码并比较结果。

总结

本节课我们一起深入学习了指令微调中数据批处理的完整流程。我们了解到,这并非简单的数据打包,而是一个包含格式化、词元化、填充、构建目标、掩码处理五个步骤的精细过程。其中,以“下一个词元预测”方式构建目标张量,以及使用 -100 忽略填充标记的损失计算,是理解指令微调如何工作的关键。

掌握这些“螺母与螺栓”般的细节,是成为一名扎实的机器学习工程师或大语言模型开发者的坚实基础。在接下来的课程中,我们将利用处理好的批次数据创建数据加载器,加载预训练模型,并开始真正的指令微调训练。

37:指令微调中的数据加载器

在本节课中,我们将学习如何为指令微调数据集创建数据加载器。数据加载器是高效访问和处理数据批次的关键工具,对于训练大型语言模型至关重要。

概述

在上一节中,我们完成了指令微调的第一步(数据下载与格式化)和第二步(数据分批)。本节我们将专注于第三步:创建数据加载器。数据加载器可以看作是一种高效的方式,能在需要时按顺序收集不同的数据批次,从而简化大语言模型的训练过程。

数据加载器简介

数据加载器围绕数据集包装了一个可迭代对象。这意味着我们可以使用数据加载器以迭代(即顺序)的方式访问我们创建的批次。它本质上使得访问不同的数据样本变得非常容易。

我们使用数据集和数据加载器的主要目的是为了提高数据访问的效率。这在训练像大语言模型这样拥有超过1亿参数的巨大模型时,会产生巨大的影响。

前期步骤回顾

在深入数据加载器之前,让我们快速回顾一下已完成的工作。

第一步:数据集下载与格式化
我们使用了指令微调数据集,其中包含1100对指令、输入和输出。例如,指令可能是“编辑以下句子的语法”,输入是“he go to the park every day”,正确的输出是“he goes to the park every day”。我们的目标是通过这些数据教会大语言模型如何更好地遵循指令并有效响应。我们使用了一种名为 Alpaca格式 的特定模板来格式化这些数据,将每条指令-响应对转换为一个完整的提示词,作为模型的输入。

第二步:数据分批
数据分批并非简单地创建批次,而是涉及多个步骤,可以归纳为五个主要组成部分:

  1. 使用提示词模板格式化数据。
  2. 将格式化后的数据标记化,转换为标记ID。
  3. 填充数据,确保一个批次内所有样本的标记ID数量相同。具体做法是找到该批次中最长的样本,然后用结束文本标记(如50256)填充其他较短的样本。
  4. 创建输入-目标对。目标是输入向右移动一位,并在末尾添加一个结束文本标记。这是微调中最重要的步骤,模型通过“下一个标记预测”任务来学习。
  5. 将填充用的标记(除了最后一个表示序列结束的标记)替换为 -100。这是因为在PyTorch的交叉熵损失函数中,-100 被指定为忽略索引,这些位置的损失不会被计算。

理解批次是如何创建的对后续理解数据加载器至关重要。

创建数据加载器

现在,让我们开始今天课程的核心内容:为指令数据集创建数据加载器。

我们拥有训练、测试和验证数据集,并且已经将它们分批。接下来,我们将把这些数据集传递给数据加载器,创建对应的训练、测试和验证数据加载器。

设备转移优化

在创建数据加载器之前,需要了解一个优化点:设备转移。在我们的自定义整理函数中,包含了将输入和目标张量转移到特定设备(如CPU或GPU)的代码。这样做的好处是,设备转移过程可以作为后台进程在训练循环之外执行,从而防止在模型训练期间阻塞GPU,提高了训练效率。

使用 partial 函数预设参数

我们将使用Python functools 标准库中的 partial 函数来创建一个新版本的函数,并预先填充设备参数。例如,我们将设备预设为CPU,并将允许的最大上下文长度设置为1024。

设置数据加载器

以下是设置数据加载器的关键步骤:

  1. 我们有一个之前定义的 InstructionDataset 类,它负责加载数据并将其转换为标记ID。
  2. 我们创建这个数据集类的实例(例如 train_dataset),它包含了85%的训练数据。
  3. 我们使用PyTorch的 DataLoader 函数,并传入训练数据集实例。
  4. 最关键的是,我们指定使用自定义的 collate_fn(整理函数)。这个函数封装了之前回顾的所有批次创建逻辑(格式化、标记化、填充、创建输入-目标对、替换填充标记)。
  5. 我们设置批次大小(例如 batch_size=8),这意味着每个批次将包含8个提示词。

我们以同样的方式创建验证加载器和测试加载器。

理解数据加载器的输出

运行代码后,打印训练加载器,你会看到类似以下的输出:
第一个批次输入张量的形状是 (8, 61),目标张量的形状也是 (8, 61)。这里的 8 代表批次大小,61 代表该批次中每个样本的标记数量(由该批次中最长的样本决定)。

第二个批次的形状可能是 (8, 76)。这是因为每个批次是独立处理的,其长度取决于该批次内最长样本的标记数量。因此,不同批次的序列长度可能不同。

通过数据加载器,我们可以轻松地按顺序访问这些批次。例如,要访问第二个批次的第二个样本,只需定位到对应的行即可。

总结

本节课中,我们一起学习了如何为指令微调创建数据加载器。我们回顾了数据准备的核心步骤,并详细讲解了如何利用PyTorch的 DataLoader 和自定义整理函数来高效地组织和管理数据批次。理解数据加载器的维度和工作原理,是顺利进行后续模型训练的基础。

在指令微调乃至所有机器学习项目中,数据准备步骤至关重要。一个优秀的工程师需要花费大量时间在数据预处理和清洗上。至此,我们已经完成了指令微调的第一阶段——数据准备。

在下一讲中,我们将进入第二阶段:加载预训练的大语言模型并开始实际的微调过程,观察其性能变化。

38:加载预训练大语言模型权重 🧠

在本节课中,我们将学习指令微调实践项目的第四步:加载一个预训练的大语言模型。

概述

上一节我们介绍了如何为数据集创建数据加载器。本节中,我们来看看如何加载一个预训练的大语言模型,并理解其重要性。加载预训练权重是微调过程的关键一步,它能让模型从一个“有知识”的状态开始学习,而不是从随机初始化开始,从而大大节省计算资源和时间。

加载预训练模型的意义

我们之前构建的LLM架构包含许多可训练参数,例如多头注意力中的查询、键、值权重矩阵,前馈神经网络中的神经元权重,层归一化中的缩放和偏移参数,以及词嵌入层和位置嵌入层的权重。这些参数的总和可能超过1亿个。

在预训练过程中,这些参数在大量数据上进行了训练和优化。例如,OpenAI公开了GPT-2在不同参数量级(如1.24亿、3.55亿、7.74亿)上训练好的权重。我们下载并重用这些权重的主要目的是让模型从一个有知识的状态开始,而不是从随机初始化开始。

这样做的好处是,模型已经掌握了语言的基本规律和语义信息。当我们在此基础上,使用我们特定的指令数据集进行微调时,模型只需要针对新任务进行小幅调整,训练过程将更加高效,所需时间和计算资源也更少。

选择模型规模

在之前的垃圾邮件分类项目中,我们使用了1.17亿参数的小模型。但对于指令微调任务,我们发现小模型表现不佳,因此需要选择更大的模型。本节我们将加载GPT-2 Medium模型,它拥有3.55亿个参数,需要大约1.42GB的存储空间。请确保你的本地机器有足够的空间。

以下是该模型的关键配置:

  • 词汇表大小50257
  • 上下文长度1024
  • Transformer块数量24
  • 注意力头数量16

下载与加载权重的步骤

以下是加载预训练权重的核心步骤:

  1. 下载权重文件:代码会从指定URL下载GPT-2 Medium模型的7个权重文件到本地。
  2. 转换权重格式:下载的原始文件需要经过预处理,转换成一个结构化的参数字典(params dictionary),以便于我们后续的代码访问和集成。
  3. 将权重载入模型:通过 load_weights_into_gpt 函数,将参数字典中的权重精确地映射到我们之前构建的GPT模型架构的对应层中。

注:关于下载和加载权重的详细代码逻辑,我们在之前的“使用GPT-2权重进行预训练”课程中已深入讲解。本课中,你只需按顺序执行代码即可完成加载。

验证预训练模型的初始表现

在开始微调之前,我们可以先检验一下仅加载了预训练权重的模型在指令任务上的表现。我们从验证集中选取一个例子:

  • 指令:将主动语态句子转换为被动语态。
  • 输入:厨师每天做饭。
  • 期望输出:饭每天由厨师做。

我们使用模型的生成函数,输入指令和句子,并设定生成新token的最大数量。然后,我们提取模型生成的响应部分(排除输入的指令)。

模型生成的响应:厨师每天做饭。(模型只是原样重复了输入句子)

分析表明,未经微调的预训练模型无法正确遵循指令。它只是重复了输入的文本,甚至可能包含一些无关的格式字符,完全没有执行“转换为被动语态”的任务。这清晰地说明了为什么我们需要指令微调:预训练模型擅长续写文本,但不擅长理解并执行具体的指令。

总结

本节课中我们一起学习了如何加载预训练的大语言模型权重。我们理解了重用预训练权重可以让模型从一个知识丰富的起点开始,从而高效地进行后续的指令微调。通过一个简单的测试,我们也亲眼看到了未经微调的模型在遵循指令方面的局限性。

下一节,我们将正式进入指令微调过程,通过训练来调整模型的权重,使其学会理解并正确响应我们数据集中各种指令。

39:LLM 微调训练循环实战

在本节课中,我们将学习如何对我们过去几节课中开发的大语言模型进行微调。我们将实现一个完整的训练循环,使用指令数据集来优化模型权重,使其能够更好地理解和遵循指令。

概述

我们一直在研究一个示例:我们有一个预训练好的大语言模型,但它并不擅长理解和遵循指令。因此,我们需要进行指令微调。这个过程分为三个阶段:

  1. 准备数据集。
  2. 微调大语言模型。
  3. 评估大语言模型。

在前几节课中,我们已经完成了第一阶段:下载数据、使用 Alpaca 提示格式格式化数据、将数据划分为多个批次,并创建了训练、测试和验证数据加载器。

在第二阶段,我们加载了 GPT-2 的预训练权重,搭建好了模型架构。现在,我们将使用包含 1100 条指令-输入-输出对的数据集来微调这个模型,目标是让模型学会如何响应指令。

微调前的模型状态

在上一节课结束时,我们测试了未经微调的预训练模型。我们给出指令“将主动句转换为被动句”和句子“厨师每天做饭”。模型的输出是“厨师每天做饭”,它只是重复了输入内容,未能完成转换任务。这表明模型目前无法遵循指令。

微调的原理

我们的模型架构中有许多可训练参数,包括词嵌入、位置嵌入、层归一化中的缩放和偏移参数、多头注意力中的查询/键/值权重矩阵以及前馈神经网络中的权重。

微调意味着我们将在这个特定的指令数据集上再次训练这些权重,使它们得到优化,从而使模型能够很好地回答指令。

你可能会问,既然我们要在这个特定数据集上再次训练,为什么还要进行预训练?原因是预训练让模型从一个“有知识”的状态开始,而不是从随机状态开始。预训练使模型理解了语言的语义、单词之间的关系等基础知识,这为后续在特定数据上的微调奠定了坚实的基础。微调总是在预训练之后进行。

训练循环与损失函数

在开始微调之前,我们需要理解两个核心概念:训练循环和损失函数。它们与我们在预训练阶段使用的完全相同。

以下是训练循环的基本步骤:

  1. 遍历数据中的每个批次。
  2. 计算损失梯度(在每个新周期开始时重置梯度)。
  3. 计算当前批次的损失。
  4. 执行反向传播,计算损失相对于所有参数的梯度(我们的模型有 3.55 亿个参数)。
  5. 使用基于梯度下降的优化器更新参数,其基本公式为:
    w_new = w_old - α * (∂Loss/∂w)
    其中 α 是学习率。
  6. 多次重复此过程,目标是使损失函数最小化。

损失函数基于输入和目标对计算。我们的数据加载器中的每个样本都包含输入和目标。对于下一个词预测任务,目标就是输入序列向右移动一位,并添加文本结束标记。

预测值由模型的生成函数产生。输入序列通过 GPT 架构后,会输出一个逻辑张量,该张量被转换为概率张量,从而得到模型的预测输出。

基于真实值(目标)和预测值,我们计算损失函数,这里使用的是交叉熵损失。其核心思想是希望正确预测的概率尽可能接近 1,从而使损失函数的值尽可能接近 0。

代码实现:复用与准备

我们已经完成了数据处理、分批和数据加载器创建等繁重工作。现在,我们可以复用之前在预训练中实现的损失计算和训练函数。

以下是关键函数:

  • calc_loss_batch: 计算一个批次数据的交叉熵损失。
  • calc_loss_loader: 计算整个数据加载器的总损失(对批次损失求和后除以批次数量)。

训练循环代码的核心部分是反向传播(计算损失梯度)和优化器步骤。我们将使用 AdamW 优化器(Adam with weight decay),这是目前机器学习中最常用的优化器之一,它能跟踪损失函数的梯度及其平方,有助于避免陷入局部最小值并加速收敛。

在开始训练前,我们先计算初始的训练损失和验证损失。此时模型尚未在指令数据集上训练,因此损失值会很高(训练损失约 3.82,验证损失约 3.76)。我们的目标是通过训练降低这些损失。

配置与启动训练

现在,我们配置训练过程:

  • 优化器: torch.optim.AdamW,学习率设为 0.00005,权重衰减设为 0.1。这些都是可以调整的超参数。
  • 训练周期: 设为 1。由于计算资源限制(使用 2020 款 MacBook Air,8GB 内存,CPU 运行),增加周期数会导致训练时间过长或系统崩溃。建议有 GPU 资源的用户尝试更多周期。
  • 评估频率: 每处理 5 个批次后计算并打印一次验证损失。
  • 起始上下文: 使用验证集中的第一个样本(“将主动句转换为被动句:厨师每天做饭”)作为评估生成响应的起点,以便直观对比微调效果。

启动训练。在所述配置下,一个训练周期(共 115 个批次)大约需要 2 小时。训练过程中,可以观察到训练损失和验证损失都在持续下降。

微调结果分析

训练完成后,我们再次使用相同的起始上下文测试模型。指令依然是“将主动句转换为被动句:厨师每天做饭”。

模型输出:“这顿饭每天由厨师准备。<|endoftext|>

这个结果非常出色!它几乎完全正确。正确的被动语态答案可以是“这顿饭每天由厨师烹饪”或“准备”。而未经微调的模型只会重复输入内容。这表明,仅在一个周期、没有 GPU 的机器上训练两小时,模型就学会了遵循指令进行转换。

如果增加训练周期(例如在更好的硬件上运行 2 个周期),模型甚至能输出更精确的“这顿饭每天由厨师烹饪”。损失曲线图也显示,训练初期损失迅速下降,随后下降速度放缓,表明模型正在收敛。

总结与展望

本节课中,我们一起学习并实现了大语言模型的微调训练循环。我们成功地在自定义指令数据集上微调了预训练模型,显著提升了其遵循指令的能力。通过对比微调前后的输出,我们直观地看到了微调的效果。

虽然损失曲线表明模型训练有效,但评估模型性能最关键的是其生成响应的质量和正确性。目前我们只是进行了定性评估。在下一阶段(阶段三),我们将专门探讨如何定量和定性地评估大语言模型,这是一个当前活跃的研究领域。

至此,我们已经完成了流程图中的前两个阶段:准备数据集和微调 LLM。下节课,我们将进入第三阶段:评估 LLM。

40:使用Ollama评估微调后的大语言模型 🧪

在本节课中,我们将完成指令微调项目,并学习如何评估微调后的大语言模型(LLM)的性能。我们将从测试集中提取模型生成的响应,并利用另一个更强大的LLM(通过Ollama运行)来自动为这些响应打分,从而获得一个量化的性能指标。

概述:我们当前所处的位置

上一节我们介绍了如何对预训练的大语言模型进行指令微调。我们使用了一个包含1100条指令-输入-输出对的数据集,并证明了微调后的模型在响应指令方面表现更佳。

现在,我们已经完成了微调流程的前两个阶段:

  1. 数据准备:下载、批处理数据并创建数据加载器。
  2. 模型微调:加载预训练的GPT-2模型(3.55亿参数),并基于指令数据集对其进行再次训练。

本节中,我们将进入至关重要的第三阶段:评估微调后的大语言模型。我们将学习如何提取模型在测试集上的响应,并探索几种评估LLM性能的方法,最终实现一种自动化的评分方案。

评估的必要性与挑战

在展示单个测试样本的结果时,我们发现模型响应“The meal is prepared by the chef every day”与标准答案“The meal is cooked by the chef every day”存在差异。这引出了一个核心问题:如何衡量LLM的性能?

与简单的二分类任务(如垃圾邮件检测)不同,LLM的评估涉及比较两个句子(模型输出与标准答案)的相似性或正确性,这并非易事。我们需要一个系统化的方法来评分。

大语言模型评估的三种主要方法

以下是研究人员常用的三种评估指令微调后LLM的方法:

  1. 通用知识测试与基准评估(MMLU):这种方法通过让模型回答涵盖STEM、人文、社科等57个不同领域的多项选择题,来测试其通用知识水平。MMLU分数已成为衡量模型知识广度的流行指标。
  2. 人工偏好比较:由人类评估者直接查看并比较不同LLM对同一指令的响应,从而判断哪个模型的输出更优。这种方法依赖人类的主观判断。
  3. 使用另一个LLM进行自动评估:利用一个已经过良好训练、知识渊博的大型语言模型(例如Llama 3)作为“裁判”,来比较标准答案与待评估LLM的响应,并给出评分。这种方法自动化程度高,节省人力。

考虑到任务的规模,我们将在本节中实现第三种方法,即使用另一个LLM来自动评估我们微调后模型的响应质量。

第一步:提取并保存模型响应

在开始评估之前,我们需要收集微调后模型在整个测试数据集上的所有响应。

以下是实现步骤:

  • 加载我们微调后的模型。
  • 遍历测试数据集中的每一个指令-输入对。
  • 使用模型生成响应。
  • 将生成的响应与原始的指令、输入、标准答案一起,保存到一个新的JSON文件中(例如 instruction_data_with_responses.json)。

这样,我们就拥有了一个包含完整信息(指令、输入、标准输出、模型响应)的文件,为后续的自动化评估做好了准备。

重要提示:完成此步骤后,请务必保存微调后的模型权重,以便后续使用,避免重复进行耗时的微调过程。保存和加载模型的代码如下:

# 保存模型
torch.save(model.state_dict(), 'gpt2_medium_355m_sft.pth')

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/vizuara-llm-scr/img/7673a7a755f8a58d169488e111a4ca67_2.png)

# 在未来会话中加载模型
model.load_state_dict(torch.load('gpt2_medium_355m_sft.pth'))

第二步:使用Ollama和Llama 3进行自动化评估

现在,我们将使用Ollama工具来运行一个更强大的LLM(Meta的Llama 3 8B指令微调版),作为我们模型的“裁判”。

安装与运行Ollama

  1. 访问 ollama.com 并下载适用于您操作系统(MacOS、Linux、Windows)的安装包。
  2. 安装完成后,打开终端(或命令提示符)。
  3. 运行命令以下载并启动Llama 3模型:
    # 对于MacOS/Linux
    ollama run llama3
    
    # 对于Windows,可能需要先运行
    ollama serve
    # 然后在新的终端窗口运行
    ollama run llama3
    
  4. 等待模型下载完成(约4.7GB)。当终端出现 >>> 提示符时,表示模型已成功加载,您可以开始与之对话。

构建评估提示与查询函数

我们将通过Python代码与Ollama的API进行交互,而不是直接在终端中操作。核心是构建一个 query_model 函数,它向Ollama管理的Llama 3模型发送请求并获取响应。

评估提示(Prompt)的格式如下:

“给定以下指令和输入:[指令] [输入],以及标准答案:[标准输出]。请为以下模型响应评分:[模型响应]。评分范围为0到100分,100分为最佳。请仅返回一个整数分数。”

我们将对测试数据集中的每一个样本,构造这样的提示,发送给Llama 3,并收集返回的分数。

执行评估并分析结果

由于在CPU上运行大型模型推理非常耗时,我们首先在少量测试样本上演示评估过程。

以下是三个测试样本的评估结果示例:

  1. 指令:使用明喻重写句子“The car is very fast”。
    • 标准答案:The car is as fast as lightning.
    • 模型响应:The car is as fast as a bullet.
    • Llama 3评分:85/100。理由:响应正确使用了明喻,比喻恰当,但“lightning”可能比“bullet”更具戏剧性。
  2. 指令:哪种云通常与雷暴有关?
    • 标准答案:Cumulonimbus.
    • 模型响应:(一段关于雷暴一般性描述的不相关文本)
    • Llama 3评分:20/100。理由:未直接回答问题,且包含不准确信息。
  3. 指令:《傲慢与偏见》的作者是谁?
    • 标准答案:Jane Austen.
    • 模型响应:George Bernard Shaw.
    • Llama 3评分:0/100。理由:事实性错误。

从这些例子可以看出,作为评估者的Llama 3不仅给出了分数,还提供了详细的定性分析,其判断相当合理。

为了获得一个整体的性能指标,我们可以计算模型在所有测试样本上得分的平均值。在计算资源允许的情况下(例如使用M3芯片的Mac或GPU),运行完整测试集后,我们的微调模型平均得分可能在50分以上。这为我们提供了一个与其他配置或模型进行比较的基准。

如何进一步提升模型性能

如果评估分数不理想,可以考虑以下改进方向:

  • 调整超参数:如学习率、批大小、训练轮数(epoch)。增加训练轮数通常能显著提升效果。
  • 扩大训练数据集:我们仅使用了1100条数据。可以尝试使用更大的指令数据集,例如斯坦福的Alpaca数据集(包含52,000条数据)。
  • 尝试不同的提示或指令格式:更清晰、结构化的指令可能引导模型产生更好的输出。
  • 使用更大的预训练模型:例如从GPT-2 Medium(3.55亿参数)切换到GPT-2 Large(7.74亿参数)或XL版本,前提是拥有足够的计算资源。
  • 采用参数高效微调技术:如LoRA(Low-Rank Adaptation),它可以在只更新少量参数的情况下达到良好的微调效果,大大节省计算成本和时间。

总结

本节课中我们一起学习了如何评估一个经过指令微调的大语言模型。我们首先完成了从测试集中提取模型响应的步骤,然后重点介绍了三种主流的LLM评估方法:MMLU基准测试、人工评估以及使用另一个LLM进行自动评估。

我们选择了第三种方法,并利用Ollama工具和Llama 3 8B指令微调模型,构建了一个自动化评估流程。通过让Llama 3比较标准答案与我们的模型响应,并给出0-100的分数,我们获得了一个量化的性能指标。尽管这种评分仍存在一定主观性,但它为模型迭代和比较提供了有价值的参考。

至此,我们已经完整地走过了指令微调LLM的整个流程:从数据准备、模型微调到最终的评估。理解这些底层细节对于成为一名扎实的机器学习工程师或LLM研究者至关重要。评估领域本身仍是一个充满机遇的研究方向,期待大家未来的探索与贡献。

41:20分钟总结

大家好,欢迎来到“从零开始搭建大语言模型”系列视频。

这是一个总结视频,我将快速总结我们在本系列课程中完成的所有内容。

你可能属于两类学生之一。第一类是已经跟完了整个系列并正在观看本视频的同学,恭喜你坚持到了这里。你现在可以说是全世界少数几个知道如何完全从零开始构建整个大语言模型的学生之一。如果你偶然看到这个视频,但还不了解系列的其他部分,我强烈建议你观看所有视频,以建立对大语言模型的坚实理解。

我开设这个系列课程的目标是教你如何成为一名基础研究者或基础机器学习工程师。你应该真正关注的重点是理解事物运作的核心原理,而不是运行花哨的大语言模型应用。我们应该将注意力集中在大语言模型是如何真正构建的,理解其内部机制,这也是本系列的全部重点。视频虽然很长,但根据与许多观看学生的互动来看,它们非常有收获。

当我开始这个系列时,我从Sebastian Rashka的《Build a Large Language Model》这本书中获得了许多灵感,感谢Sebastian。这本书确实帮助我构建了整个课程体系。

这是我们贯穿整个系列课程运行的代码文件。正如你所见,代码已经变得相当长,但我们在课程视频中已经非常详细地解释了每一个部分。我向你解释了每一个代码块,并且在本系列中我们不假设任何先验知识,一切都是从零开始深入讲解的。

我已经与所有观看的学生分享了这份代码文件。我希望一旦你获得了这份代码文件,你能够自己运行并构建整个大语言模型。

以下是我们本系列涵盖的所有内容的工作流程。首先我们从第一阶段开始,然后依次进入第二阶段,再到第三阶段。

就像盖房子一样,我们首先需要打好地基。在第一阶段,我们为大语言模型的构建奠定了基础。我们研究了数据准备和采样,然后研究了注意力机制,这是大语言模型获得其能力的引擎。最后,我们研究了第一阶段的大语言模型架构,即不同的模块如何组合在一起以构建大语言模型。

第一阶段完成后,我们进入第二阶段,即预训练。在预训练中,我们研究了如何为大语言模型实现损失函数,如何进行反向传播,以及如何训练通常涉及超过1亿甚至数十亿参数的LLM。我们还看到了如何从像OpenAI GPT-2这样的大语言模型中加载预训练权重,这加速了预训练过程。

然后在第三阶段,我们看到仅靠预训练不足以确保LLM在特定任务上表现良好,例如分类或构建我们自己的个性化聊天机器人。我们学习了微调。我们完全从零开始完成了两个实践项目:我们制作了一个可以区分垃圾邮件与非垃圾邮件的LLM分类器,并且我们还构建了自己的可以遵循指令的个人助手。这些都是非常有价值的经验,我们从最基础的部分展示并理解了一切。

现在,我将带你回顾我们迄今为止在这个旅程中涵盖的各个步骤和关键组成部分。如果你已经观看了系列课程,这将是一个很好的回顾,你也可以在面试前用它进行快速复习。如果你还没有观看之前的课程视频,这将是一个很好的视频,让你了解本系列课程将涵盖哪些内容。

第一步:数据预处理流程

构建大语言模型的第一步是实现数据预处理流程。

LLM的数据预处理流程与回归模型或分类模型有很大不同。当大语言模型中有输入文本时,目标是预测下一个标记。因此,你要做的第一件事是将输入文本转换为标记,然后将标记转换为标记ID。

在此之后,下一步是将这些标记ID转换为更高维度的向量空间,以便捕获不同标记的语义含义。

当你将标记投影到更高维度的向量空间后,你必须向这些标记嵌入中添加位置嵌入。将标记ID投影到更高维向量空间的结果,如图所示,称为标记嵌入。你必须将位置嵌入向量添加到标记嵌入向量中。我们添加位置嵌入的原因是,除了将标记转换为向量表示外,单个标记出现的位置在预测下一个标记时也非常重要。

当你将标记嵌入与位置嵌入相加时,就得到了所谓的输入嵌入。现在,这些输入嵌入是我们期望从数据预处理流程中得到的输出。数据预处理流程的整个目标是从大量文档(我们提供给大语言模型的训练数据)中获取输入文本,并将所有这些文档转换为句子,然后转换为标记,再转换为标记ID,接着转换为标记嵌入,最后添加位置嵌入并转换为输入嵌入。

为了让你对标记嵌入维度有个概念,在GPT-2中,嵌入维度是768。

这就是数据预处理流程,这是第一步。

第二步:理解注意力机制

在数据预处理之后,我们转向理解注意力机制。注意力机制是赋予LLM能力的驱动引擎。注意力的主要思想是:当你查看向量嵌入时,你只看到一个标记的语义含义,你没有看到一个标记与所有其他标记的关系。然而,在预测下一个标记时,上下文非常重要。你需要知道一个标记如何与所有其他标记相关联。当你查看一个标记时,你应该给予其他标记多少重要性,这种重要性也称为注意力。

因此,注意力机制的整个目标是将输入嵌入向量(看起来像这样)转换为所谓的上下文向量。在注意力机制实现结束时,输入向量被转换为上下文向量。你可以看到“journey”的输入嵌入向量,这里是“journey”的上下文向量。上下文向量比输入嵌入向量丰富得多,因为它们还包含了“journey”与句子中所有其他标记如何相关的信息。

为了从输入嵌入向量过渡到上下文向量,我们需要理解一个巨大的顺序流程。我们首先将输入与可训练的查询、键和值矩阵相乘,得到查询、键和值矩阵。我们将查询与键的转置相乘,得到注意力分数。然后我们用键维度的平方根缩放注意力分数,添加Dropout,实现因果注意力(这意味着我们为所有不参与下一个标记预测任务的标记添加一个掩码),然后我们添加一个Softmax层。在实现缩放、Dropout和Softmax之后,我们将注意力分数转换为注意力权重。注意力权重然后与值相乘,我们就得到了上下文向量矩阵。

现在这只是一个注意力头的情况。当我们考虑大语言模型时,有多个注意力头在起作用。我们拥有多个注意力头的原因是为了捕捉大段落内的多重依赖关系和长距离依赖关系。因此,当你组合来自多个注意力头的上下文向量矩阵时,你会得到这个最终的上下文向量矩阵,这就是输入嵌入矩阵通过注意力头时的输出。

因此,大语言模型发生的整个革命,正是由于我现在屏幕上分享的这个工作流程:将输入嵌入矩阵转换为这个上下文向量矩阵,这是关键。

第三步:LLM架构

理解了注意力机制后,我们转向LLM架构。请记住,我讲得有点快,因为这是一个回顾,一个总结。如果你想理解每一个元素,我强烈建议你回到那个特定的课程,再次观看整个视频。

理解注意力之后,下一步是查看LLM架构。这是LLM架构的鸟瞰图。实际上,让我用另一张我认为能更好表示LLM架构的图。我截取这张图的屏幕截图,然后把它移上来。

如果你看这个架构,它为我们提供了一个鸟瞰图,展示了当你查看大语言模型时实际发生的情况。首先,正如我提到的,你有输入。这些输入被转换成一堆标记。然后标记被转换为向量嵌入。我们添加位置嵌入,这就得到了输入嵌入。输入嵌入然后被传递到这个块中,即标记嵌入层、位置嵌入层,然后我们有一个Dropout层,接着我们有了这些输入嵌入,它们被传递到这个蓝色块中,即Transformer块。Transformer块是所有魔法发生的地方。

你可能在想,那么注意力机制在Transformer块中处于什么位置呢?在Transformer块内部,有另一个模块称为注意力模块。我们之前学习的注意力机制以及键、查询、值等所有内容,实际上都发生在这个掩码多头注意力模块内部。在Transformer块内部,有一个归一化层、多头注意力、Dropout层,这些是快捷连接,然后是另一个归一化层、一个前馈神经网络、另一个Dropout层,以及更多的快捷连接,然后我们离开Transformer块。离开Transformer块后,有一个层归一化层和一个最终的神经网络,它将Transformer的输出转换为所谓的逻辑张量。

逻辑张量然后用于在给定输入序列的情况下预测下一个标记。

现在,当你查看GPT-2时,比如说,有12个这样的Transformer块排列在一起。对于更大的LLM,甚至有更多Transformer块排列在一起。在这些Transformer块内部,有多头注意力。在每个Transformer块内部,可以有2个或24个注意力头。所以有多个Transformer块,在每个Transformer块内部有多个注意力头。术语有点复杂,但一旦你获得了这个架构的视觉感受,实际上按顺序编码它是相当容易的。

一旦你理解了这种架构,我们在系列课程中所做的就是完全从零开始编码与这种架构相关的每一个部分。你可以在这里搜索“前馈”。这是架构的第三部分。事实上,我们按照所有部分的顺序进行。我们首先介绍了层归一化,然后介绍了前馈神经网络,接着介绍了快捷连接,然后介绍了编码注意力层。所以,除了这种白板方法外,所有内容也都涵盖了编码。

一旦你弄清楚了这种LLM架构,你就会对输入序列到底发生了什么有一个鸟瞰图:它如何通过Transformer,如何从Transformer中出来,我们得到逻辑张量,然后用于预测下一个标记。这就是我的LLM预测。现在,我的LLM预测的这个下一个标记用于计算损失函数。

第四步:预训练与损失函数

在LLM输出和真实结果之间计算损失函数。

得到损失函数后,下一步是运行LLM预训练循环。这意味着一旦我们理解了如何进行前向传播(即如何获取输入句子或输入序列,从LLM获取输出,并根据下一个标记获取损失),我们就需要计算损失。顺便说一下,这个损失是我们预测的下一个标记与实际下一个标记之间的交叉熵损失。

一旦我们知道如何获取损失,我们就需要进行反向传播。这意味着我们需要计算损失相对于LLM架构中所有参数的偏导数。这里我只提到了训练循环:首先计算整个批次的损失,然后进行反向传播,即计算损失相对于所有权重参数的偏导数。

你可能会想,有哪些不同的可训练权重?在标记嵌入和位置嵌入中有可训练权重,因为当我们说转换为向量空间时,我们不知道理想的转换,所以我们需要找出这些参数。我们需要找出位置嵌入参数。我们需要找出层归一化中的缩放和移位参数。在掩码多头注意力模块中,我们需要训练查询、键和值的可训练权重矩阵。第二个层归一化层也有可训练参数。前馈神经网络也有可训练参数。最终的输出层和最终层也有可训练参数。请记住,我们有12个这样的Transformer块,所以当你把这些参数加起来时,会导致超过一百万甚至超过十亿个我们需要训练的参数。因此,我们需要为所有这些参数找到梯度,然后我们需要进行梯度更新,例如使用公式 W_{i+1} = W_i - α * (∂L/∂W)。这只是我向你展示的普通梯度下降,通常我们实现一些更复杂的方案,如Adam或AdamW。

一旦你执行了这个预训练循环,你实际上会得到损失函数作为epoch的函数,这是我们在自己的笔记本电脑、自己的系统上完成的,但使用的是非常小的数据集。请记住,像GPT-2、GPT-3、GPT-4等实际LLM所需的预训练是在海量数据上完成的,涉及数百万篇新闻文章、数百万个博客、书籍等,这种预训练的成本也超过100万美元。因此,我们不可能在自己的笔记本电脑上预训练一个真正的、完整的大语言模型。但是,一旦你观看了我展示的课程视频,你就可以为一个小数据集运行预训练循环,这会让你全面了解GPT是如何构建的。

第五步:加载预训练权重与微调

在这之后,我们所做的是:我们采用我们的架构,我们采用我们的LLM架构,然后我们从GPT-2加载了预训练权重。然后我们实际上根据输入句子预测了下一个标记。这是我们在本系列课程中取得的第一个基础性成果。

预训练完成后,我们转向LLM微调。我们学习了两种类型的微调:分类微调,我们构建了一个电子邮件分类LLM,当给定一封电子邮件时,LLM可以分类它是垃圾邮件还是非垃圾邮件;然后我们还完全从零开始构建了一个指令微调的LLM。你必须提供一堆指令、输入和输出,并训练LLM在指令上表现出色。比如说,指令是“将主动句转换为被动句”,主动句是“The chef cooks the meal every day”,被动句是“The meal is cooked every day by the chef”。我们完全从零开始训练了这个模型。我现在向你展示的一切都是基于我们在代码中开发的架构实现的,我们没有从其他地方使用它。

第六步:LLM评估

最后,我们学习了LLM评估。我们学习了三种类型的评估。第一种是MMLU,基于这篇论文《Measuring Massive Multitask Language Understanding》。他们在这篇论文中展示的是,我们基本上可以使用57个测试来评估LLM性能,这是一种评估类型。第二种是使用人类来比较和评分LLM。第三种是使用一个强大的大语言模型来评估另一个LLM。这是我们遵循的方法。我们使用了一个名为o Llama的工具来访问Llama 3 LLM,特别是我们使用了这个Llama 8B参数指令模型,它已经过微调,拥有80亿参数,因此非常强大。我们用这个更大的LLM所做的是:如果真实输出是这个,而模型响应是这个,我们要求LLM将输出与模型响应进行比较,并给出一个评估分数。这就是LLM给出的评估分数。因此,根据模型响应和实际响应,它给出一个0到100的分数。这就是我们学习的第三种评估策略。

总结与后续步骤

这就是我们在本课程中实现的所有内容的全部细节。我们从零开始实现了一个下一个词或下一个标记预测LLM,实现了一个电子邮件分类微调LLM,并实现了一个指令微调LLM。一旦你完成了这个系列课程,我相信你将理解构建大语言模型的核心原理,并且你将成为一个更强大的机器学习和LLM工程师。

如果你已经完成了这个系列并且现在正在观看这个课程,我强烈建议你下一步深入基础研究。你拥有这个代码文件,开始进行修改,开始探索小语言模型。为什么需要大语言模型?我们是否可以只有三个Transformer块?你现在可以开始对代码进行所有这些编辑,因为代码是以构建块格式编写的。你可以更改超参数,探索不同优化器的效果,探索不同学习率的效果,不同Transformer块数量的效果,探索不同评估策略的效果。这是一个活跃的研究领域。尝试深入研究,这是保持在最前沿并为创新和有影响力的LLM研究做出贡献的最佳方式。

我希望你们所有人都喜欢这个系列课程。我对于所有通过这些课程学习的人的整个目标是,将你们训练成为能够为创新研究做出贡献的基础LLM和机器学习工程师,而不仅仅是部署LLM应用。

非常感谢大家,我期待在下一节课中见到你。

posted @ 2026-03-26 12:20  布客飞龙III  阅读(115)  评论(0)    收藏  举报