大语言模型设计模式-全-
大语言模型设计模式(全)
译者:飞龙
前言
想象一下在没有蓝图的情况下建造摩天大楼——每一层都是即时构建的,没有任何明确的计划来确保稳定性、效率,甚至功能。没有结构化方法来开发大型语言模型(LLMs)可能会感觉非常相似。这些强大的模型能够改变行业并重新定义人机交互,它们是复杂的结构,需要细致的计划和执行。没有框架来导航其复杂性,从业者可能会创建出低效、不可靠或无法达到其潜力的系统。
这本书,《LLM 设计模式》,提供了您需要的蓝图。它是一本面向工程师、研究人员和寻求有效设计、构建和实施 LLMs 的革新者的实用指南。它侧重于四个关键支柱:准备和预处理数据、训练和优化模型、评估和解释其行为,以及与高级知识检索技术无缝集成。这些领域通过设计模式的角度进行探索,为 LLM 开发中反复出现的挑战提供经过验证的解决方案。
LLM 的快速演进带来了非凡的机会和艰巨的挑战。数据质量、可扩展性和可解释性问题需要适应性方法和创新策略。这本书为所有级别的从业者提供了应对这些挑战的设计模式,提供了可操作的见解和框架,不仅可以帮助构建模型,还能在快速发展的 LLM 世界中脱颖而出。无论您是在构建您的第一个模型还是在改进尖端应用,这本书都确保了您的做法与您寻求利用的技术一样稳健。
本书面向对象
这本书面向所有参与 LLM 开发、部署或应用的人,包括以下内容:
-
AI 工程师和研究人员:在项目中实施 LLM 技术的个人
-
数据科学家和机器学习从业者:寻求关于数据准备、模型训练和优化 LLMs 指导的专业人士
-
软件架构师和项目经理:那些旨在构建和管理基于 LLM(大型语言模型)的项目,确保与商业和技术目标一致的人
本书涵盖内容
第一章,《LLM 设计模式简介》,提供了对 LLMs 的基础理解,并介绍了设计模式在其开发中的关键作用。
第二章《LLM 训练数据清理》,为您提供了实用的工具和技术,使您能够有效地清理数据以进行 LLM 训练。
第三章《数据增强》,帮助您深入了解数据增强模式,从增加训练数据集的多样性到维护其完整性。
第四章,处理 LLM 训练的大型数据集,让你学习管理和处理对训练最先进的 LLM 至关重要的海量数据集的高级技术。
第五章,数据版本控制,展示了如何实施有效的数据版本控制策略以用于 LLM 开发。
第六章,数据集标注与标记,让你探索创建高质量标注数据集的高级技术,这些数据集可以显著影响 LLM 在各种任务上的性能。
第七章,训练流程,帮助你理解 LLM 训练流程的关键组件,从数据摄入和预处理到模型架构和优化策略。
第八章,超参数调整,展示了 LLM 中的超参数是什么以及优化它们的策略。
第九章,正则化,展示了针对 LLM 量身定制的不同正则化技术。
第十章,检查点和恢复,概述了确定最佳检查点频率、大型模型的有效存储格式以及从各种类型的故障中恢复的技术。
第十一章,微调,教你有效的微调预训练语言模型的策略。
第十二章,模型剪枝,让你探索旨在在保持性能的同时减少模型大小的模型剪枝技术。
第十三章,量化,让你了解量化方法,这些方法可以优化 LLM 以在资源受限的设备上部署。
第十四章,评估指标,探讨了评估 LLM 在不同领域中最新的和常用的基准。
第十五章,交叉验证,展示了如何探索专门为 LLM 设计的交叉验证策略。
第十六章,可解释性,帮助你理解在 LLM 中可解释性指的是模型理解并解释模型如何处理输入和生成输出的能力。
第十七章,公平性与偏见检测,证明了在 LLM 中的公平性涉及确保模型的输出和决策不会基于受保护属性歧视或不公平地对待个人或群体。
第十八章,对抗鲁棒性,帮助你理解针对 LLM 的对抗攻击是如何通过在输入上做出微小、通常难以察觉的更改来操纵模型输出的。
第十九章, 从人类反馈中进行强化学习,带你了解一种强大的技术,用于使 LLMs 与人类偏好保持一致。
第二十章, 思维链提示,展示了如何利用思维链提示来提高 LLMs 在复杂推理任务上的性能。
第二十一章, 思维树提示,允许你实现思维树提示,以处理 LLMs 的复杂推理任务。
第二十二章, 推理与行动,介绍了 ReAct 框架,这是一种强大的技术,可以提示你的 LLMs 不仅通过复杂场景进行推理,还可以规划和模拟行动的执行,类似于人类在现实世界中的操作方式。
第二十三章, 无观察推理,教你如何为 LLMs 提供框架,使其能够对假设情况进行推理并有效地利用外部工具。
第二十四章, 反思技术,展示了在 LLMs 中的反思,这指的是模型分析、评估和改进其自身输出的能力。
第二十五章, 自动多步推理和工具使用,帮助你理解自动多步推理和工具使用如何显著扩展 LLMs 的解决问题的能力,使它们能够处理复杂、现实世界的任务。
第二十六章, 检索增强生成,带你了解一种增强 Al 模型性能的技术,尤其是在需要模型预训练参数中不包含的知识或数据的任务中。
第二十七章, 基于图的 RAG,展示了如何利用图结构知识在 RAG 中为 LLMs 提供支持。
第二十八章, 高级 RAG,展示了如何超越这些基本的 RAG 方法,探索旨在增强 LLMs 在广泛任务上性能的更复杂技术。
第二十九章, 评估 RAG 系统,为你提供了评估 RAG 系统产生准确、相关和事实依据的响应能力所需的知识。
第三十章, 代理模式,展示了如何设计使用 LLMs 的代理 Al 系统以自主运行、做出决策并采取行动以实现特定目标。
为了充分利用这本书
为了最大限度地利用本书,您最好对机器学习概念有基础的了解,并在 Python 编程方面有基本的熟练度。这些先决条件将有助于掌握章节中讨论的技术方法和实施策略。机器学习知识对于理解 LLM 开发的关键方面,如模型训练、超参数调整、正则化技术和优化过程,是必不可少的。Python 编程技能尤其宝贵,因为它们使您能够实现和实验书中提出的设计模式、工作流程和算法。
熟悉自然语言处理(NLP)框架和工具,例如 Hugging Face Transformers、spaCy 或 NLTK,将进一步增强学习体验。这些框架在 LLM 开发中常用,并提供了一种实际的方法来处理预训练模型、标记文本和处理语言数据。了解这些工具的工作原理将使您能够专注于高级概念和设计模式,而无需被基础编程或 NLP 操作所困扰。
对于对这些领域不太熟悉的人,建议补充学习机器学习基础知识、Python 编程和 NLP 工具的资源。本书的方法确保通过一些努力弥合知识差距,您可以成功理解其概念并在实际项目中有效应用。
注意
本书提供了代码片段来展示 LLM 设计模式和实现概念。代码有意集中于以简洁和可读的方式展示想法,而不是提供完整、可执行的程序。它不打算直接部署或集成到生产环境中。鼓励您研究和适应代码以适应自己的环境,而不是直接复制粘贴。因此,没有配套的 GitHub 仓库;书中提供的示例包含在书中,足以理解预期的概念,无需外部代码库。
下载彩色图像
我们还提供了一份包含本书中使用的图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781836207030。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理/X 用户名。以下是一个示例:“将下载的 WebStorm-10*.dmg 磁盘镜像文件作为系统中的另一个磁盘挂载。”
代码块设置如下:
# Model Architecture
model = AutoModelForCausalLM.from_pretrained(“gpt2”)
# Optimization
optimizer = AdamW(model.parameters(), lr=5e-5)
任何命令行输入或输出都如下所示:
pip install faiss-cpu sentence-transformers
粗体:表示新术语、重要单词或屏幕上看到的单词。
小贴士或重要提示
看起来像这样。
联系我们
欢迎读者反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
错误清单:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。
侵权:如果您在互联网上发现任何形式的我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了LLM 设计模式,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买此书!
您喜欢随时随地阅读,但又无法携带您的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何时间、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不止于此,您还可以获得独家访问折扣、时事通讯和每日收件箱中的精彩免费内容。
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/978-1-83620-703-0
-
提交您的购买证明
-
就这些!我们将直接将您的免费 PDF 和其他好处发送到您的邮箱
第一部分:简介和数据准备
我们这本书从介绍理解和操作大型语言模型(LLMs)所需的基础概念开始。在本部分,你将探索数据准备在构建高质量 LLMs 中的关键作用。从理解设计模式在模型开发中的重要性到处理训练所需的庞大数据集,我们引导你通过 LLM 管道的初始步骤。本部分中的章节将帮助你掌握数据清洗技术以提升数据质量,数据增强方法以增强数据集多样性,以及数据集版本化策略以确保可重复性。你还将学习如何高效地处理大型数据集并为特定任务创建良好标注的语料库。到本部分结束时,你将具备准备稳健且可扩展的数据集的技能,为高级 LLM 开发提供坚实的基础。
本部分包含以下章节:
-
第一章,LLM 设计模式介绍
-
第二章,LLM 训练的数据清洗
-
第三章,数据增强
-
第四章,处理 LLM 训练的大型数据集
-
第五章,数据版本化
-
第六章,数据集标注和标记
第一章:LLM 设计模式简介
大型语言模型(LLMs)是能够理解和生成类似人类文本的机器学习模型,涵盖多个领域。它们开辟了前所未有的可能性,同时也带来了独特的挑战。
在本章中,我们将介绍 LLMs 的世界以及设计模式在它们发展中的关键作用。您将了解语言模型的演变,探索推动现代 LLMs 的核心原则,并检查它们的惊人能力和局限性。我们将揭示设计模式的重要性——经过时间考验的软件开发中常见问题的解决方案——以及它们如何被调整和应用来解决 LLM 项目特有的挑战。
在本章中,我们将涵盖以下主题:
-
理解 LLMs
-
理解设计模式
-
LLM 开发的设计模式
-
LLM 模式及其发展的未来方向
理解 LLMs
在本节中,我们将突出 LLMs 的核心概念,探讨它们的演变、潜在原则以及它们对人工智能领域产生的变革性影响。我们将检查构成 LLMs 那么强大的关键组件,它们所提出的挑战,以及塑造它们未来的持续发展。
语言模型的发展
现代 LLMs 的旅程被自然语言处理中的重大范式转变所标记,如图 图 1**.1 所示的时间线所示:

图 1.1 – 语言模型演变
尽管早期的统计方法具有开创性,但在捕捉人类语言细微差别方面存在局限性。神经网络的出现,尤其是循环神经网络(RNNs)和长短期记忆(LSTM)网络,使得处理序列数据的能力得到提升,并改善了捕捉文本中长期依赖关系的能力。在文本中捕捉长期依赖关系对于理解更广泛的上下文和保持长段落的一致性至关重要。由于无法考虑跨越长序列的单词或概念之间的关系,早期的统计方法在这方面遇到了困难。神经网络的发展,尤其是 RNNs 和 LSTM 网络,显著提高了捕捉这些依赖关系的能力。然而,即使有了这些进步,仅仅捕捉长期依赖关系是不够的;这些模型在管理复杂上下文和确保长文本序列的一致性方面仍然面临挑战。
2017 年,变换器架构的引入彻底改变了该领域,为更大、更强大的语言模型铺平了道路。(关于变换器架构的更多内容,请参阅下一节。)这一突破迎来了预训练模型如BERT和GPT系列的时代,它们利用大量未标记的文本数据,在各种自然语言处理(NLP)任务上实现了前所未有的性能。
注意
想要全面了解语言模型的发展历程,包括对统计模型、神经网络和基于变换器方法的详细讨论,请参阅丹·朱尔法斯基(Dan Jurafsky)和詹姆斯·H·马丁(James H. Martin)合著的书籍《语音与语言处理》(Speech and Language Processing)。在线手稿经常更新,可在web.stanford.edu/~jurafsky/slp3找到。
LLM 的核心特性
本节介绍了 LLM 的核心特性,重点关注其变换器架构、规模、少样本学习、语言理解和生成以及多语言能力。
变换器架构
任何大型语言模型(LLM)的关键组成部分是其变换器架构。变换器架构利用自注意力机制,允许模型在处理每个元素时,根据输入的不同部分的重要性进行加权。在基于变换器的 LLM 中,输入文本首先被标记化为更小的单元,通常是单词或子词。然后,这些标记被嵌入到一个高维向量空间中,其中每个标记都表示为一个密集向量。
稠密向量是一种数学对象,在包括人工智能在内的多个领域中使用,用于在紧凑、高维空间中表示数据。简单来说,它是一系列数字(或值),当结合在一起时,形成对某物(如单词、图像或其他类型的数据)的表示。向量中的这些数字可以被视为多维空间中的坐标,其中每个数字都对数据点的描述做出贡献。
自注意力机制作用于这些向量表示,允许模型捕捉输入序列中不同部分之间的复杂关系。这是通过计算序列中每对标记之间的注意力分数来实现的。这些分数决定了每个标记在计算其上下文表示时应该关注其他每个标记的程度。这允许模型捕捉文本中的长距离依赖和复杂关系,克服了先前顺序模型的局限性(Attention Is All You Need,arxiv.org/abs/1706.03762)。
Transformer 架构由多层自注意力机制和前馈神经网络组成。每一层都细化了输入标记的表示,捕捉越来越抽象和上下文相关的信息。多头注意力机制是 Transformer 的另一个关键组件,它允许模型同时关注输入的不同方面,进一步增强了其捕捉数据中复杂模式的能力。
Transformer 中的多头注意力是一种机制,允许模型同时关注输入序列的不同位置,以实现更好的表示学习。模型不是执行单个注意力函数,而是将查询、键和值投影到多个低维空间(头),在每个这些空间中独立执行注意力操作,然后在对这些结果进行最终线性变换之前将它们连接起来。这种方法使得模型能够同时关注来自不同表示子空间和位置的信息,捕捉序列元素之间关系的各个方面——如句法依赖、语义相似性或上下文相关性——这显著增强了模型理解数据中的复杂模式和关系的能力。
规模和计算资源
LLMs 的一个显著特征是其前所未有的规模,这既体现在模型大小上,也体现在它们训练所使用的数据量上。在 LLMs 中,“大”不仅指这些模型的复杂性,还包括训练和运行它们所需的庞大计算资源。现代 LLMs 可以拥有数百亿个参数,这需要巨大的内存和处理能力。
模型大小和训练数据的这种扩展是由对各种任务性能持续改进的实证观察所驱动的,随着模型变得更大,这些改进通常遵循可预测的扩展定律,其中性能指标如困惑度或准确度随着模型大小和计算预算的幂律函数而提高(参见《神经语言模型的扩展定律》arxiv.org/pdf/2001.08361)。这一现象导致了一场构建更大模型的竞赛,一些最近的 LLMs 甚至拥有万亿个参数。
少样本学习能力
LLMs 的少样本学习能力代表了自然语言处理领域的一项进步。传统的机器学习方法通常需要为每个特定任务收集大量标记数据。相比之下,LLMs 通常只需要几个示例或甚至仅需要任务的天然语言描述(零样本学习)就能执行新任务。这种灵活性源于模型对语言的广泛理解以及它们在不同上下文中泛化模式的能力。
例如,一个预训练的 LLM 可能能够在没有明确进行情感分析训练的情况下,通过提供一些正面和负面评论的例子,对产品评论进行情感分析。这种能力为将 AI 应用于广泛的语言任务开辟了新的可能性,尤其是在那些没有大量特定任务标记数据的领域。
语言理解和生成
LLMs 最引人注目的能力之一是它们理解和生成类似人类文本的能力,涵盖广泛的风格、主题和格式。在理解方面,这些模型能够处理和解释复杂的文本输入,以高度复杂的方式提取意义和上下文,在很多情况下模仿人类类似的理解。这种能力扩展到各种子任务,如情感分析、命名实体识别和主题分类。LLMs 通常能够辨别细微的语气差异,识别隐含信息,并识别复杂的语言模式。
在生成方面,LLMs 展示了前所未有的能力,能够生成连贯、上下文适当的文本。它们可以生成从创意小说和诗歌到技术文档和代码的一切内容。这种生成文本的质量通常表现出高度的流畅性、语法正确性和上下文相关性。这种生成能力为内容创作、自动写作辅助和对话式人工智能等领域开辟了新的可能性。
多语言和跨语言能力
许多现代大型语言模型(LLMs)展现出强大的多语言和跨语言能力。当在多样化的多语言语料库上训练时,这些模型能够理解和生成多种语言中的文本。一些模型已经展示了执行跨语言任务的能力,例如在它们没有明确训练的语言对之间进行翻译,或者根据另一语言中提供的内容来回答问题。
这些能力为打破语言障碍和促进更具包容性的全球沟通开辟了可能性。然而,需要注意的是,LLMs 在不同语言上的性能可能会有很大差异。模型在它们训练数据中表现最好的通常是那些广泛使用的语言,这通常有利于英语等广泛使用的语言。目前正在努力开发更公平的多语言模型,并提高在低资源语言上的性能。
经过对 LLM 的核心特征进行考察,下一节将转向设计模式在 LLM 项目结构和指导中的作用。设计模式源于软件工程,提供了可重用的解决方案,有助于管理复杂性、提高协作,并支持可扩展、可维护的架构。理解它们的演变和原则为在 LLM 开发背景下有效地应用它们奠定了基础。
理解设计模式
设计模式最初作为一种捕捉和共享重复性设计问题解决方案的方法而出现。最初根植于面向对象编程,它们通过识别增强代码清晰度、可重用性和可维护性的可重复策略,提供了一种构建软件的结构化方法。随着时间的推移,设计模式已经超越了其原始的背景,影响了包括 LLM 开发在内的广泛开发实践和系统架构。以下讨论将追溯设计模式的起源,并概述塑造它们在不同编程范式和应用领域持续相关性的原则。
起源和演变
软件工程中的设计模式概念在 20 世纪 90 年代获得了显著的关注,这主要归功于 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 合著的书籍《设计模式:可重用面向对象软件元素》,通常被称为四人帮。这部开创性的工作识别并编目了面向对象软件设计中的常见模式,提供了一种词汇和最佳实践集合,这些很快成为该领域的基石(books.google.com/books/about/Design_Patterns.html?id=6oHuKQe3TjQC)。
这些模式源于软件开发者的集体经验,代表了在各种项目和环境中证明有效的解决方案。它们提供了一种高效地捕捉和传达复杂设计思想的方法,使开发者能够建立在前辈的智慧之上,而不是重新发明解决重复问题的方案。
设计模式的概念最初专注于面向对象编程,但现在已经扩展到涵盖广泛的软件开发范式和领域。随着软件系统在复杂性和规模上的增长,设计模式的重要性也只增不减,提供了一种管理这种复杂性的手段,并促进更可维护、可扩展和稳健的软件架构。
设计模式的核心原则
在其核心,设计模式体现了几个关键原则,使它们在软件开发中具有价值。首先,它们促进了代码重用和模块化。通过封装常见问题的解决方案,模式允许开发者应用经过验证的方法,而无需重复代码或重新发明解决方案。这种模块化也增强了软件系统的可维护性,因为变化通常可以局部化到实现模式的特定组件。
第二,设计模式为开发者提供了一种共享的词汇。这种共同的语言促进了开发团队内部以及项目之间的沟通。当开发者使用一个广为人知的设计模式来描述解决方案时,它立即向熟悉该模式的其他开发者传达了大量关于该解决方案结构和行为的信息。
第三,模式通常体现了良好的软件设计原则,例如松散耦合和高内聚。它们鼓励开发者思考组件之间的关系以及他们系统的整体结构,从而产生更深思熟虑且结构良好的解决方案。
最后,设计模式通常是灵活和可适应的。虽然它们提供了一个解决问题的通用结构,但它们并不是僵化的规定。开发者可以——并且应该——根据项目的具体上下文和需求调整模式,以便在经过验证的框架内发挥创造力。
LLM 开发的设计模式
随着开发基于智能 LLM 的应用需求增长,我们看到出现了专门针对这些复杂系统独特挑战的设计模式。这些模式与传统软件设计模式有显著差异,专注于 LLM 整个生命周期内在的方面——从数据准备和模型训练到评估、部署和复杂应用设计。
本书深入探讨了29 个实用的 LLM 设计模式,这些模式在第二章至第三十章中进行了详细探讨。开发者和研究人员可以使用这些设计模式来导航构建 LLM 系统的复杂性:
-
建立坚实的数据基础(第二章至第六章):通过掌握数据清洗(第二章)、数据增强(第三章)、处理大型数据集(第四章)、实施数据版本控制(第五章)和确保有效的数据集标注(第六章)的模式来为高质量模型打下基础。这些实践提高了输入质量和可管理性,从而直接影响了模型性能。
-
优化训练和模型效率(第 7-13 章):通过用于健壮训练流程(第七章)、有效的超参数调整(第八章)、正则化技术(第九章)、可靠的检查点(第十章)、特定任务的微调(第十一章),以及通过模型剪枝(第十二章)和量化(第十三章)提高效率的模式,简化核心模型构建过程。
-
解决模型质量和对齐问题(第 14-19 章):通过应用严格的评估指标(第十四章)和交叉验证(第十五章)建立对模型的信心,增强可解释性(第十六章),积极解决公平性和偏差(第十七章),提高对抗鲁棒性(第十八章),并使用从人类反馈中学习强化学习(RLHF)(第十九章)将模型与人类偏好对齐。
-
增强推理和解决问题的能力(第 20-25 章):通过高级提示和推理策略,如思维链(第二十章)、思维树(第二十一章)、Reason and Act (ReAct) 模式(第二十二章)、无需观察的推理(第二十三章)、反思技术(第二十四章),以及启用自动多步推理和工具使用(第二十五章),解锁更复杂的模型行为。
-
将外部知识与 RAG 集成(第 26-29 章):通过使用检索增强生成(RAG)(第二十六章),探索如基于图的 RAG(第二十七章)和高级 RAG 技术(第二十八章)的变体,以及学习如何有效地评估 RAG 系统(第二十九章)。
-
开发具有代理能力的 AI 应用(第三十章):通过理解和实现代理模式(第三十章),朝着创建更独立的应用迈进,使大型语言模型能够自主规划、使用工具和执行任务。
LLM 设计模式的益处
LLM 开发的设计模式提供了显著的好处,首先是从建立强大的数据基础开始。数据清洗确保了数据质量的提升,从而提高了模型精度,减少了训练时间,并减轻了偏差。数据增强增强了模型的鲁棒性和泛化能力,使得模型在未见过的数据上表现更佳,同时处理大数据集释放了捕捉复杂模式和提升模型能力潜力。数据版本控制使得实验和模型训练运行的可重复性成为可能,而数据集标注为监督学习任务提供了高质量的标签,提高了模型精度和效率。
此外,优化训练和模型效率提供了实质性的优势。鲁棒的训练流程自动化了训练过程,导致开发周期更快,性能更一致。超参数调整优化了模型性能,提高了精度和泛化能力,而正则化技术防止过拟合并提高了鲁棒性。可靠的检查点允许保存模型权重,便于实验和调试。针对特定任务的微调优化了预训练的 LLM 以适应特定任务,以少量资源提高性能。模型剪枝减少了 LLM 的大小和复杂性,导致推理更快,提高了部署效率,而量化进一步减少了模型大小并加快了推理速度,使得在边缘设备上部署成为可能。
解决模型质量和对齐问题对于构建可信赖的 LLM 至关重要。严格的评估指标提供了对模型性能的全面评估,使得决策更加明智。交叉验证提高了模型评估的可靠性,并提供了更准确的泛化性能估计。可解释性使得模型的决策过程更加透明和易于理解,而公平性和偏差缓解减少了模型预测中的偏差。对抗鲁棒性使得模型对对抗攻击更具抵抗力,提高了安全性,而强化学习与人类偏好对齐(RLHF)改善了用户满意度和信任度。
增强推理和解决问题的能力可以解锁更复杂的模型行为。思维链使模型能够分解复杂问题,提高推理和准确性。思维树通过允许模型探索多个推理路径来扩展思维链,增强复杂任务的解决问题的能力。ReAct 集成了推理和行动能力,使模型能够与环境交互并解决现实世界的问题。无观察推理允许模型在没有明确数据的情况下应用推理技能,而反思技术赋予模型评估自身推理过程并改进的能力。自动多步推理和工具使用自动化推理和工具使用的过程,使模型能够解决复杂任务。
最后,将外部知识整合到 RAG 中可以增强模型的知识和准确性。RAG 从外部来源检索相关信息,克服了模型预训练知识的局限性。基于图的 RAG 使用知识图谱来表示和检索信息,从而实现更复杂的推理。高级 RAG 技术进一步精炼 RAG 系统,并提高检索信息的质量、相关性和准确性。评估 RAG 系统涉及评估 RAG 系统性能的方法,从而实现优化和改进。使用代理模式可以创建自主的 AI 代理,这些代理可以独立地规划、使用工具和执行任务,从而带来更强大和通用的应用。
表 1.1 总结了 LLM 设计模式的优势,按类别组织。
| 类别 | 设计模式 | 关键优势 |
| --- | --- | --- |
| 数据基础 | 数据清洗 | 更高质量的见解;更准确的预测;更快的模型迭代;降低结果偏差。 |
| | 数据增强 | 更可靠和可泛化的模型;在多种情况下的性能改进;对噪声数据的更强鲁棒性。 |
| | 处理大型数据集 | 能够提取更深入的见解;更高的性能潜力;更广泛的应用范围;更健壮的模型。 |
| | 数据版本控制 | 结果的信心增加;更容易调试和审计;降低数据损坏的风险;更快地从错误中恢复;改进数据驱动的决策。 |
| | 数据集标注 | 更精确和有效的模型;更快的学习率;与预期结果的更好对齐。 |
| 训练 和效率 | 强健的训练流程 | 更快的模型开发;更一致的结果;减少人工努力;更高的生产力。 |
| | 超参数调整 | 优化模型性能;更高的准确性;更快的训练收敛速度;更有效的资源利用。 |
| | 正则化技术 | 更稳定和可泛化的模型;降低过拟合的风险;在未见数据上的性能改进。 |
| | 可靠的检查点 | 降低丢失进度的风险;加快实验速度;改进模型开发工作流程。 |
| | 任务特定微调 | 显著提高目标任务的性能;缩短上市时间;更有效地使用资源。 |
| | 模型剪枝 | 加快的推理速度;降低存储需求;降低计算成本;使资源受限设备上的部署成为可能。 |
| | 量化 | 减少模型大小;加速推理;降低内存占用;提高能源效率;更广泛的部署可能性。 |
| 质量和对齐 | 严格的评估指标 | 数据驱动决策;改进模型选择;更好地理解模型的优势和劣势。 |
| | 交叉验证 | 更可靠的性能估计;降低过拟合风险;提高模型泛化能力。 |
| | 可解释性 | 增加对模型预测的信任;更容易识别错误;提高模型理解;便于调试和改进。 |
| | 公平性和偏见缓解 | 更公平和道德的结果;降低歧视风险;提高用户信任。 |
| | 对抗鲁棒性 | 提高安全性;在不可预测环境中的可靠性改善;抵御恶意攻击。 |
| | 从人类反馈中进行强化学习 | 与人类价值观一致的模式;改善用户体验;提高安全性和可靠性。 |
| 推理和问题解决 | 思维链 | 增强问题解决能力;提高准确性;决策透明度增加。 |
| | 思维树 | 提高处理复杂和模糊问题的能力;更稳健的解决方案。 |
| | ReAct | 有效解决现实世界问题的能力;提高适应性;增强学习和推理。 |
| | 无观察推理 | 在数据稀缺环境中的问题解决能力增强;在信息不完整的情况下改善决策。 |
| | 反思技术 | 更自我意识和可靠的模型;提高准确性;增强学习和适应能力。 |
| | 自动多步推理 | 能够自主解决复杂任务;提高效率;减少对人工干预的需求。 |
| 知识集成(RAG) | 检索增强生成 | 访问最新信息;减少对预训练知识的依赖;提高准确性和相关性。 |
| | 基于图的 RAG | 更复杂的推理;在复杂知识领域中的准确性提高;增强对关系的理解。 |
| | 高级 RAG 技术 | 更高质量和更相关的信息;提高结果准确性和可靠性。 |
| | 评估 RAG 系统 | 优化的 RAG 系统;更高的用户满意度;更高质量的结果。 |
| 代理式 AI | 代理模式 | 能够创建自主系统;提高效率;减少人工干预;启用新应用。 |
表 1.1 – LLM 设计模式的好处
将设计模式应用于 LLM 的挑战
虽然设计模式在 LLM 开发中的好处是显而易见的,但它们的运用并非没有重大挑战。LLM 系统的独特性质、它们的快速演变以及这些模式涵盖的广泛领域(从基础数据处理到复杂的代理系统)都带来了几个障碍:
-
快速技术进步:LLM 领域中持续快速发展的速度仍然是主要挑战之一。新的模型架构、训练方法、复杂的提示策略、知识检索技术和代理框架不断涌现。这种快速变化意味着,即使是最近建立的用于优化训练或增强推理的模式,也可能需要频繁的调整;否则,它们可能会迅速变得不那么优化。开发者需要灵活的心态,在需要稳定实践(如纪律性的数据管理)和整合突破的敏捷性之间取得平衡。
-
复杂性、规模和不可预测性:大型语言模型(LLMs)本质上复杂,在巨大规模上运行,并且常常表现出非确定性行为。这在整个模式谱系中带来了挑战:
-
数据和训练:应用管理大型数据集、构建训练管道或有效调整超参数的模式需要管理巨大的计算资源和数据量。
-
行为控制:LLMs 的随机性质使得应用旨在确保期望结果的模式(如解决公平性、偏见、对抗鲁棒性,甚至逐步推理和行动的高级技术)变得复杂。实现一致、可预测的行为比传统软件更难。
-
错误处理和调试:由于模型的不透明性,在使用涉及多步推理链或自主代理行为的复杂模式时,定位失败可能极其困难。
-
-
评估困难:衡量应用许多 LLM 设计模式的有效性是一个主要挑战。虽然存在定义评估指标和验证过程的模式,但评估细微方面(如生成的推理路径的质量、RAG 系统中检索到的上下文的真正有用性,或代理的整体鲁棒性和任务成功率)通常需要比标准基准更多的内容。为这些高级模式开发可靠和全面的评估策略是一个持续的研究领域。
-
成本和资源限制:实施许多 LLM 模式可能在各种方式上消耗资源:
-
数据成本:彻底的数据标注和准备可能既昂贵又耗时。
-
计算成本:核心模型训练、广泛的微调、大规模的超参数搜索或运行复杂检索增强或代理系统的推理需要大量的计算能力。
-
优化权衡:旨在模型优化的模式,如剪枝或量化,旨在降低成本,但涉及自己的复杂性和潜在的性能权衡。成本因素可能限制了预算受限的团队对某些模式的实际应用性。
-
-
LLM 开发的跨学科性质:构建有效的 LLM 系统需要不同角色之间的协作——软件工程师、机器学习研究人员、数据科学家、提示工程师、领域专家、伦理学家等。在这些学科之间建立共同的理解和一致的应用模式至关重要但具有挑战性。例如,确保每个人都对数据管理实践达成一致、对评估结果有相同的解读或理解旨在确保公平性的模式的影响,需要刻意努力和清晰的沟通。
摘要
本章提供了对 LLMs 的基础理解,并介绍了设计模式在它们开发中的作用。它追溯了语言模型从早期的统计方法到今天基于 transformer 架构的 LLMs 的演变,强调了诸如自注意力机制、规模和计算资源的重要性、少样本学习、语言理解和生成能力以及多语言能力等关键特征。
然后,本章转向了设计模式的重要性,将其与软件工程中已确立的角色进行类比。这突出了将设计模式应用于 LLM 开发的益处,概述了一种结构化的方法来提高数据质量、优化训练、解决模型质量和一致性、增强推理能力、通过 RAG(检索增强生成)整合外部知识以及开发代理应用。然后,概述了本书中将探讨的 29 种模式,以及它们关注的 LLM 生命周期阶段。
最后,本章承认了将设计模式应用于 LLMs(大型语言模型)所面临的挑战,所有这些挑战都源于技术的快速演变、复杂性、规模、评估困难、成本限制以及 LLM 开发的跨学科性质。
在本书的剩余部分,我们将使用设计模式引导您通过 LLM 开发的生命周期,从构建坚实的基础数据(第二章至第六章)和优化模型训练(第七章至第十三章)开始。然后,我们将专注于确保模型质量、一致性和鲁棒性(第十四章至第十九章),在探索高级推理和问题解决能力(第二十章至第二十五章)之前。最后,我们将涵盖将外部知识整合到 RAG(第二十六章至第二十九章),并深入探讨具有代理人工智能的 LLM 的未来(第三十章),从而为构建智能应用提供一个全面的工具包。
第二章:LLM 训练的数据清洁
在本章中,我们将深入探讨 LLM 训练中的数据清洁模式。
清洁、高质量的数据是构建稳健和可靠的语言模型的基础。我们将探讨常见的数据质量问题、预处理技术和处理不同数据类型的策略。图 2.1展示了专门设计用于在用于训练语言模型之前处理原始文本数据的数据清洁流程。

图 2.1 – 数据清洁流程
该过程从初步的数据质量检查开始,以评估原始数据的适用性。随后,应用文本预处理和去重步骤以精炼和简化数据集。如果在任何点上数据未能达到所需标准,它将通过自动化清洁流程进行额外处理。成功完成此阶段后,进行数据验证以确保数据集的完整性和符合训练标准。如果数据通过验证,则标记为清洁并准备好用于语言模型训练,确保为有效模型开发提供高质量输入。
到本章结束时,你将具备用于为 LLM 训练清洁数据的实用工具和技术。
本章将涵盖以下主题:
-
理解清洁数据的重要性
-
语言数据集中常见的质量问题
-
适用于 LLM 的文本预处理技术
-
处理多语言和代码混合数据
-
大型文本语料库的去重策略
-
自动化数据清洁流程
-
数据验证和质量保证
理解清洁数据的重要性
用于训练 LLM 的数据质量直接影响其性能和可靠性。当我们使用嘈杂或不一致的数据训练 LLM 时,我们可能会将偏差、错误和不一致性引入模型的学习表示和输出中。
为了说明数据质量对 LLM 性能的影响,我们可以使用一个简单的 Python 脚本来比较在清洁和嘈杂数据上训练的模型的混淆度得分。
-
首先,安装必要的包并导入它们:
pip install torch pip install transformers import torch torch) is a powerful deep learning framework that provides dynamic computational graphs, GPU acceleration, and extensive neural network building blocks, making it popular for machine learning research and development. The transformers package, developed by Hugging Face, complements PyTorch by providing a comprehensive library of pre-trained transformer models (such as BER, GPT, and T5) and tools for natural language processing tasks. Together, these packages offer a robust ecosystem in which torch provides the foundational deep learning operations, tensor computations, and automatic differentiation capabilities, while transformers provides high-level abstractions for working with state-of-the-art language models, including functions for tokenization, model fine-tuning, and inference. -
然后,定义函数的初始部分:
def calculate_perplexity(model, tokenizer, text): inputs = tokenizer(text, return_tensors="pt") with torch.no_grad(): outputs = model(inputs, labels=inputs["input_ids"]) return torch.exp(outputs.loss).item() model = GPT4LMHeadModel.from_pretrained("GPT4") tokenizer = GPT4Tokenizer.from_pretrained("GPT4")calculate_perplexity函数使用提供的分词器将输入文本分词成 PyTorch 张量。然后,它将分词后的输入传递给模型,其中input_ids也用作标签,允许模型计算表示预测错误的损失。该损失被指数化以推导出一个标量混淆度得分,并以 Python 浮点数的形式返回。代码的第二部分初始化了一个语言模型和分词器,使用
GPT4LMHeadModel.from_pretrained("GPT4")和GPT4Tokenizer.from_pretrained("GPT4"),从标识为"GPT4"的预训练源加载模型和分词器权重。
混淆度
混淆度是用于评估语言模型的一个度量。它量化了一个概率模型预测样本的能力。
较低的困惑度表明模型对其预测更有信心,并认为文本更可能或“正常”。较高的困惑度表明模型认为文本更令人惊讶或不同寻常。
-
这里有一些示例文本:
clean_text = "The quick brown fox jumps over the lazy dog." noisy_text = "Th3 qu1ck br0wn f0x jumps 0ver th3 l@zy d0g." clean_text and noisy_text. clean_text holds a standard English sentence, while noisy_text contains the same sentence with deliberate character substitutions, making it “noisy” or corrupted. The clean_text and noisy_text examples are used to evaluate a language model’s perplexity, where clean_text provides a baseline for ideal text prediction and noisy_text assesses the model’s robustness to real-world data corruption; by comparing the perplexity scores, we determine how well the model handles noisy input and its suitability for applications where text data is not always perfectly formatted. -
最后,计算困惑度并打印结果:
clean_perplexity = calculate_perplexity(model, tokenizer, clean_text) noisy_perplexity = calculate_perplexity(model, tokenizer, noisy_text) print(f"Clean text perplexity: {clean_perplexity:.2f}") print(f"Noisy text perplexity: {noisy_perplexity:.2f}")
此脚本演示了输入数据中的微小噪声如何显著影响模型的困惑度。
困惑度评分是交叉熵损失的指数。在此代码中,它使用torch.exp(outputs.loss).item()进行计算。
这里是我们的可能结果:
-
The quick brown fox jumps over the lazy dog是一个常见的、语法正确的英语句子。干净文本的困惑度可能类似于10.25。 -
Th3 qu1ck br0wn f0x jumps 0ver th3 l@zy d0g中包含数字和符号代替字母,使其不那么常见,并且对模型预测来说更困难。噪声文本的困惑度可能类似于52.87。
具体的数字将取决于所使用的特定模型和分词器,但噪声文本的困惑度评分应该始终高于干净文本。
这种分数差异展示了模型区分标准、易于预测的文本和异常、难以预测的文本的能力。这对于检测机器生成或篡改的文本等任务非常有用,因为此类文本的困惑度评分通常高于人类撰写的文本。
语言数据集中常见的数据质量问题
语言数据集通常包含各种质量问题,可能会对 LLM 训练产生负面影响:
-
拼写和语法错误可能会在学习的表示中引入噪声和不一致性。
-
不一致的格式可能导致模型学习到的模式中出现不必要的复杂性。
-
冗余数据可能导致模型过度拟合到重复项中存在的特定模式或偏差。
-
不相关或低质量的内容会稀释数据集中有用的信息。
-
不完整或截断的句子可能导致模型学习到不完整的语言结构。
-
代码切换和混合语言可能会使针对特定语言训练的模型感到困惑。
-
个人身份信息(PII)引发隐私问题,并可能导致敏感数据的记忆化。
为了检测这些问题,我们可以使用各种 Python 库和技术。以下是一个使用 spaCy 进行基本文本质量检查的示例:
-
提供导入语句和整体函数定义:
import spacy from collections import Counter # Load spaCy model nlp = spacy.load("en_core_web_sm") def analyze_text_quality(text): doc = nlp(text) -
检查拼写错误(使用 spaCy 内置的拼写检查器):
misspelled = [ token.text for token in doc if token._.is_misspelled ] -
检查语法问题(使用词性(pos)标签的简单方法):
pos_counts = Counter(token.pos_ for token in doc) grammar_score = pos_counts['NOUN'] + pos_counts['VERB'] + pos_counts['ADJ'] + pos_counts['ADV']词性(POS)标签是指分配给句子中每个单词的标签,以指示其语法角色。这些标签帮助系统理解句子的句法结构,并在解析、机器翻译、情感分析和信息提取等任务中使用。每个标签对应一个词性,如名词、动词或形容词,通常有更细粒度的区分来捕捉时态、数或功能。
-
检查句子完整性:
incomplete_sentences = [ sent.text for sent in doc.sents if len(sent) < 3 ] return { "misspelled_words": misspelled, "grammar_score": grammar_score, "incomplete_sentences": incomplete_sentences } -
下面是代码的一个示例用法:
text = "This iz a smple txt with sum issues. Incomplet" quality_report = analyze_text_quality(text) print(quality_report)
在步骤 1 到 5 中提供的脚本展示了识别一些常见文本质量问题的基本框架。我们将在接下来的章节中讨论其他质量相关问题。
LLM 的文本预处理技术
有效的文本预处理对于为 LLM 训练准备数据至关重要。我们采用各种技术,包括小写化、标点处理、空白字符标准化、特殊字符处理、分词、数字标准化和缩写词扩展。分词是将文本分解成更小单元以进行进一步分析或处理的过程。在自然语言处理中,标记是文本的最小有意义的单元。它们可以是单词,但也可以包括标点、数字或其他元素,具体取决于分词策略。
此外,子词分词是一种高级文本处理技术,它将单词分解成更小的有意义的单元(子词),使得在自然语言处理任务中更有效地处理罕见词、复合词和形态变化。与传统词级分词不同,子词分词可以识别常见的词首、词尾和词根,使模型能够通过识别其熟悉的组件来理解和处理之前未见过的单词。
以“unbelievably”这个词为例。传统的词级分词会将它视为一个单独的标记。如果模型之前从未见过这个单词,它可能难以正确解释它。相比之下,子词分词会将它分解成更小的组件,如“un”、“believ”和“ably”。这些子词在不同上下文中更有可能出现——“un-”在“unlikely”中,“believ”在“believe”中,“ably”在“capably”中——即使模型第一次遇到“unbelievably”,也能从中推导出意义。这种分解增强了泛化能力,减少了词汇量,并提高了模型处理罕见或形态复杂单词的能力。
流行的子词分词算法包括字节对编码(BPE)、WordPiece 和 SentencePiece,这些算法学习识别训练语料库中频繁出现的字符序列,并创建一个子词标记词汇表。这种方法对于处理形态丰富的语言特别有价值,可以在保持语义意义的同时减少词汇量,并且已成为现代语言模型如 Gemini、Claude、GPT 和其他基于 transformer 架构的基本组成部分。
这些方法有助于清理和标准化文本数据,减少噪声并提高模型泛化的能力。下面是一个演示这些预处理技术的 Python 脚本:
-
首先,导入必要的 Python 包:
import unicodedata import re from nltk.tokenize import word_tokenize from nltk.corpus import stopwords import nltk # Download required NLTK data nltk.download('punkt') nltk.download('stopwords') -
然后,定义整体预处理函数:
def preprocess_text(text): # Lowercase the text text = text.lower() # Normalize unicode characters text = unicodedata.normalize( 'NFKD', text ).encode( 'ascii', 'ignore' ).decode('utf-8') # Remove punctuation text = re.sub(r'[^\w\s]', '', text) # Normalize whitespace text = ' '.join(text.split()) # Tokenize : tokens = word_tokenize(text) -
移除停用词(停用词是像“the”、“is”和“at”这样的常见词,它们在文本处理中通常被移除,因为它们语义意义很小):
stop_words = set(stopwords.words('english')) tokens = [ token for token in tokens if token not in stop_words ] # Join tokens back into text preprocessed_text = ' '.join(tokens) return preprocessed_text -
下面是一个代码示例的使用方法:
raw_text = "This is an EXAMPLE of text preprocessing... It's quite useful!" cleaned_text = preprocess_text(raw_text) print(f"Original: {raw_text}") print(f"Preprocessed: {cleaned_text}")
此脚本演示了基本的文本预处理技术。对于 LLM 训练,我们可能需要根据模型和数据集的具体要求调整这些技术。
处理多语言和代码混合数据
大型语言模型(LLMs)经常遇到多语言和代码混合数据,这种数据是在单个句子或对话中混合两种或更多语言。这对 LLMs 来说是一个挑战,因为它们必须解释跨多种语言的语用学细微差别、语法和语义联系。为了处理代码混合数据,LLMs 需要学习语言切换、词汇和句法变化,并保持连贯的回应,这要求强大的语言建模和多语言训练数据。
我们需要实施策略来有效处理这些场景。以下步骤是必要的,因为它们创建更干净、更一致的训练数据,有助于 LLMs 更好地理解和处理不同语言和混合语言场景中的文本,最终提高它们在实际应用中语言混合常见场景下的性能。
对于多语言数据,某些任务至关重要:
-
语言识别:检测每个文本样本的主要语言
-
脚本规范化:将文本转换为一致的脚本(例如,转写)
-
特定语言预处理:应用特定语言的标记化和规范化
同时,对于代码混合数据,你应该执行以下步骤:
-
标记级语言识别:识别单个标记的语言
-
一致性执行:确保一致地处理代码切换模式
下面是一个演示语言检测和脚本规范化的 Python 脚本。
-
让我们提供导入和整体函数定义:
from langdetect import detect from unidecode import unidecode from nltk import word_tokenize import nltk # Download required NLTK data nltk.download('punkt') def handle_multilingual_text(text): # Detect language try: lang = detect(text) except: lang = 'unknown' # Transliterate non-ASCII characters transliterated_text = unidecode(text) -
标记化(为了简单起见使用 NLTK,但请考虑特定语言的标记化器):
tokens = word_tokenize(transliterated_text) return { 'original': text, 'language': lang, 'transliterated': transliterated_text, 'tokens': tokens } -
下面是一个示例用法:
texts = [ "This is English text.", "Dies ist deutscher Text.", "これは日本語のテキストです。", "This is mixed language text avec un peu de français." ] for text in texts: result = handle_multilingual_text(text) print(f"Original: {result['original']}") print(f"Detected Language: {result['language']}") print(f"Transliterated: {result['transliterated']}") print(f"Tokens: {result['tokens']}\n")此代码遍历一个包含英语、德语、日语和代码混合示例的多语言文本字符串列表,并对每个字符串调用一个
handle_multilingual_text函数(可能定义在其他地方)来处理文本,返回一个包含原始文本、检测到的语言、转写文本(如果适用)和分词单词的字典,然后打印到控制台。
将前面的三个代码块合并,我们提供了一个处理多语言文本的基本框架。对于更高级的场景,我们会使用专门的库,如 Polyglot 进行特定语言的处理和代码混合分析,当同一对话中使用多种语言时(dl.acm.org/doi/10.1145/3544548.3581445)。
例如,Polyglot 包含内置的语言检测、命名实体识别、情感分析和跨多种语言的转写功能,同时与较大的多语言框架相比,保持了相对轻量级的性能。该库对于处理国际文本数据的项目尤其有价值,因为它提供了跨语言的统一 API,并附带预训练模型,使其成为无需管理多个特定语言工具的复杂性的多语言文本分析任务的效率选择。
大型文本语料库的去重策略
去重是准备大型文本语料库进行 LLM 训练的关键步骤。重复内容可能导致模型偏差和计算资源的浪费。我们采用各种策略来高效地识别和删除重复项:
-
精确匹配去重: 删除完全相同的文本样本。
-
近似重复检测: 识别并删除高度相似的文字样本。
-
Shingling: 创建用于比较的小重叠单词序列。
-
局部敏感哈希: 在大型数据集中高效地找到相似项。
以下部分展示了每种策略的示例。
精确匹配去重
场景: 你有一份客户地址列表:
-
数据:
-
“123 Main St, Anytown, CA 91234”
-
“456 Oak Ave, Somecity, NY 56789”
-
“123 Main St, Anytown, CA 91234”
-
-
结果: 第三条记录“123 Main St, Anytown, CA 91234”被删除,因为它与第一条记录完全相同。
-
剩余数据:
-
“123 Main St, Anytown, CA 91234”
-
“456 Oak Ave, Somecity, NY 56789”
-
近似重复检测
场景: 你有一系列新闻文章:
-
数据:
-
文章 1: “公司报告了季度利润的显著增长。”
-
文章 2: “公司报告季度利润大幅增长。”
-
-
结果: 近似重复检测算法确定这些文章在内容上高度相似,尽管措辞略有不同。基于相似度阈值,删除了一篇文章。
-
剩余数据: “公司报告了季度利润的显著增长。”
Shingling
场景:您想比较文本文档的相似度:
-
数据:
-
文档 1:“The quick brown fox jumps over the lazy dog。”
-
k=3 词 shingle。
-
-
结果:生成的 shingles 如下:
-
“The quick brown”
-
“quick brown fox”
-
“brown fox jumps”
-
“fox jumps over”
-
“jumps over the”
-
“over the lazy”
-
“the lazy dog”
然后文档被表示为那些 shingles 的集合。然后另一个文档可以被转换成 shingles,shingles 的集合可以进行比较。
-
局部敏感哈希(LSH)
场景:您有一个非常大的在线产品描述数据库:
-
过程:
-
LSH 用于对产品描述进行哈希处理。
-
相似的产品描述更有可能被哈希到相同的“桶”中。
-
然后只比较同一桶内的描述,以详细查找近似重复项。
-
-
结果:LSH 不是将每个产品描述与其他每个描述进行比较,而是将比较缩小到同一桶内的描述,大大提高了查找近似重复项的效率。
注意
去重计算成本非常高,因此可以使用 minhashing 或并行处理等技术来扩展去重,以适应语料库数据的增加。
Minhashing 通过使用更小、更易于管理的表示来有效地近似文档之间的相似度,从而减少计算负载。并行处理进一步将去重任务分配到多个处理器或机器上,允许同时比较,从而显著加快整体过程,从而实现大规模语料库的有效去重。
这里有一个 Python 脚本演示了基本去重技术:
-
首先,定义整体函数:
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def deduplicate_corpus(corpus, similarity_threshold=0.9): # Create TF-IDF vectorizer vectorizer = TfidfVectorizer() tfidf_matrix = vectorizer.fit_transform(corpus) # Compute pairwise similarities similarity_matrix = cosine_similarity(tfidf_matrix) TfidfVectorizer to convert a text corpus into a numerical cosine_similarity to calculate the pairwise similarity between all documents in the corpus, providing a matrix of similarity scores that can be used to identify near-duplicate texts based on a specified threshold. -
然后,查找重复项:
duplicates = set() for i in range(len(corpus)): for j in range(i + 1, len(corpus)): if similarity_matrix[i, j] > similarity_threshold: duplicates.add(j) -
创建去重语料库:
deduplicated_corpus = [ doc for i, doc in enumerate(corpus) if i not in duplicates ] return deduplicated_corpus -
这里有一个例子:
corpus = [ "The quick brown fox jumps over the lazy dog.", "A fast auburn fox leaps above the sleepy canine.", "The quick brown fox jumps over the lazy dog.", "An entirely different sentence about cats.", ] deduplicated = deduplicate_corpus(corpus) print(f"Original corpus size: {len(corpus)}") print(f"Deduplicated corpus size: {len(deduplicated)}") print("Deduplicated corpus:") for doc in deduplicated: print(f"- {doc}")
此脚本演示了使用 TF-IDF 和余弦相似度的基本近似重复检测方法。TF-IDF 是一种数值统计,用于反映集合中文档中单词的重要性。它结合了单词在文档中出现的频率(TF)以及在整个文档中其独特性(IDF)。TF-IDF 将文本转换为数值向量,使得可以在文档之间进行数学比较,这对于去重过程中使用的相似度计算至关重要。对于大规模去重,我们会使用更高效的算法和分布式计算技术。
在这里,去重函数代码中使用的相似度阈值0.9决定了文档必须有多相似才能被认为是重复的,默认要求 90%的相似度。此值可以根据具体用例进行调整——更高的阈值(例如,0.95或1,即最大值)更严格,减少了误报,而较低的阈值(例如,0即最小值或0.8)更宽松,可以捕获更多潜在的重复项。
接下来,让我们讨论自动化数据清洗管道。
自动化数据清洗管道
为了处理 LLM 训练所需的庞大数据集,我们需要实现自动化数据清洗流程。这些流程应该是可扩展的、高效的,并且能够处理各种数据质量问题。
自动化数据清洗流程的关键组件如下:
-
数据摄取:高效地加载和解析大型文本语料库。
-
质量评估:自动检测并标记数据质量问题。
-
预处理:应用文本清洗和规范化技术。
-
去重:移除完全重复和近似重复的内容。
-
过滤:根据预定义的标准移除低质量或不相关的样本。
-
验证:确保清洗后的数据符合质量标准。
-
输出:将清洗后的数据保存为 LLM 训练的适当格式。
下面是一个概述基本自动化数据清洗流程的 Python 脚本:
-
我们首先定义整体类结构:
import pandas as pd import re from nltk.corpus import stopwords from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity import nltk # Download required NLTK data nltk.download('stopwords') stop_words = set(stopwords.words('english')) class DataCleaningPipeline: def __init__( self, similarity_threshold=0.9, min_length=10, max_length=1000 ): self.similarity_threshold = similarity_threshold self.min_length = min_length self.max_length = max_length self.vectorizer = TfidfVectorizer(stop_words='english') DataCleaningPipeline class that encapsulates text preprocessing, length filtering, and near-duplicate removal functionalities. It initializes with configurable parameters such as similarity threshold and text length constraints, leverages NLTK for stop word removal, and employs scikit-learn’s TfidfVectorizer and cosine_similarity to identify and eliminate similar text entries from a pandas DataFrame. -
然后,我们将定义一个预处理函数:
def preprocess(self, text): # Basic preprocessing text = text.lower() text = re.sub(r'[^\w\s]', '', text) tokens = [ word for word in text.split() if word not in stop_words ] return ' '.join(tokens) def filter_by_length(self, df): return df[ (df['text'].str.len() >= self.min_length) & (df['text'].str.len() <= self.max_length) ] methods within a class for text processing.-
preprocess:此方法接收一个文本字符串作为输入,将其转换为小写,删除标点符号,将其拆分为单词,过滤掉常见的停用词,然后将剩余的单词连接成一个字符串,从而有效地清洗和规范化文本。 -
filter_by_length:此方法接收一个包含text列的 pandas DataFrame,并过滤 DataFrame,仅包括text列长度在指定最小和最大长度范围内的行,从而允许选择所需字符范围内的文本样本。
-
-
然后,我们定义去重函数:
def deduplicate(self, df): tfidf_matrix = self.vectorizer.fit_transform(df['text']) similarity_matrix = cosine_similarity(tfidf_matrix) duplicates = set() for i in range(len(df)): for j in range(i + 1, len(df)): if similarity_matrix[i, j] > \ self.similarity_threshold: duplicates.add(j) return df.drop(df.index[list(duplicates)])这个
deduplicate方法接收一个 pandas DataFrame 作为输入,并根据它们的相似性移除近似重复的文本条目。它首先使用向量器将 DataFrame 的text列转换为 TF-IDF 矩阵,将每个文本样本表示为一个数值向量。然后,它使用 TF-IDF 矩阵计算所有文本样本对之间的余弦相似度,从而得到一个相似度矩阵。代码遍历相似度矩阵,如果两个文本样本之间的相似度超过定义的similarity_threshold,则第二个样本的索引被添加到一个重复集。最后,它从 DataFrame 中删除对应于已识别重复索引的行,并返回去重后的 DataFrame。 -
将所有函数组合起来,我们现在可以定义一个
clean函数:def clean(self, input_file, output_file): # Read data df = pd.read_csv(input_file) # Preprocess df['text'] = df['text'].apply(self.preprocess) # Filter by length df = self.filter_by_length(df) # Deduplicate df = self.deduplicate(df) # Save cleaned data df.to_csv(output_file, index=False) print(f"Cleaned data saved to {output_file}")这种
clean方法在 CSV 文件上执行一系列数据清洗步骤。它首先将输入的 CSV 文件读取到一个 pandas DataFrame 中。然后,对text列中的每个文本条目应用preprocess方法,对文本进行归一化和清洗。随后,使用filter_by_length方法过滤 DataFrame,仅保留指定长度范围内的文本条目。长度过滤后,使用deduplicate方法移除近似重复的条目。最后,将清洗后的 DataFrame 保存到由output_file指定的新的 CSV 文件中,排除索引,并打印一个确认消息,指示输出文件的存储位置。本质上,此方法执行了一个完整的文本清洗流程,包括预处理、长度过滤和去重。 -
以下是一个示例用法:
pipeline = DataCleaningPipeline() pipeline.clean('input_data.csv', 'cleaned_data.csv')
总体而言,此脚本提供了一个自动化数据清洗流程的基本框架。在实际应用中,我们会扩展此流程,以包含更复杂的清洗技术、错误处理和并行处理能力,以有效地处理大规模数据集。
代码中的值10和1000代表数据清洗流程中文本文档允许的最小和最大长度:
-
min_length=10:这设置了文档必须具有的最小字符数,才能包含在清洗后的数据集中。它有助于过滤掉可能不包含有意义信息的非常短的文本,例如单个单词或简短的短语。 -
max_length=1000:这确定了文档允许的最大字符数。它排除了可能不典型或可能对处理造成问题的极长文本,例如整本书或非常大的文档,这些文档可能会扭曲分析。
这些长度约束有助于确保清洗后的数据集包含合理且一致的文档大小范围,这可以提高后续文本分析或机器学习任务的质量和效率。您可以根据您的用例调整长度。
数据验证和质量保证
清洗数据后,您需要验证结果并确保清洗后的数据集符合 LLM 训练所需的质量标准。我们实施各种验证检查和质量保证措施,以验证我们清洗过程的有效性。
关键方面包括执行统计分析、抽样和人工审查、自动测试、一致性验证和性能影响评估。
下面是一个演示基本数据验证技术的 Python 脚本:
-
首先,定义基本函数:
def validate_cleaned_data(file_path, sample_size=100): df = pd.read_csv(file_path) # Basic statistics print(f"Total samples: {len(df)}") print( f"Average text length: " f"{df['text'].str.len().mean():.2f}" ) print(f"Unique samples: {df['text'].nunique()}") -
然后,检查空或非常短的文本:
short_texts = df[df['text'].str.len() < 10] print( f"Texts shorter than 10 characters: " f"{len(short_texts)}" ) -
进行人工审查的抽样:
sample = df.sample(n=min(sample_size, len(df))) print("\nSample for manual review:") print(sample['text'].head()) # Check for common issues common_issues = { 'special_chars': df['text'].str.contains( r'[^a-zA-Z0-9\s]' ), 'numbers': df['text'].str.contains(r'\d'), 'all_caps': df['text'].str.isupper() } for issue, mask in common_issues.items(): print(f"Samples with {issue}: {mask.sum()}") -
评估模型困惑度的影响:
model = GPT4LMHeadModel.from_pretrained('GPT4') tokenizer = GPT4Tokenizer.from_pretrained('GPT4') def calculate_perplexity(text): inputs = tokenizer( text, return_tensors='pt', truncation=True, max_length=1024 ) with torch.no_grad(): outputs = model(inputs, labels=inputs['input_ids']) return torch.exp(outputs.loss).item() sample_perplexities = sample['text'].apply( calculate_perplexity) print( f"\nAverage perplexity on sample: " f"{sample_perplexities.mean():.2f}" ) -
让我们看看一个例子:
validate_cleaned_data('cleaned_data.csv')
该脚本定义了一个名为 validate_cleaned_data 的函数,该函数旨在对存储在 CSV 文件中的文本数据集(假设在初始清理步骤之后)进行基本质量评估。它加载数据,计算一些基本统计数据,检查文本内容中的特定潜在问题,提供样本以供人工检查,并使用预训练的语言模型(假设为 GPT-4)通过困惑度评估文本样本的自然度或质量。
检查以下问题:
-
数据集大小和基本属性:
-
len(df): 检查 CSV 中的样本总数(行数)。 -
df['text'].str.len().mean(): 计算文本条目的平均长度,这有助于判断文本是普遍较长还是较短。 -
df['text'].nunique(): 统计唯一文本条目的数量。与样本总数相比,低数值可能表明存在许多重复项。
-
-
df[df['text'].str.len() < 10]: 过滤 DataFrame 以找到text列中字符串长度小于 10 个字符的行*len(short_texts): 计算找到的此类短文本的数量*df['text'].str.contains(r'[^a-zA-Z0-9\s]'): 使用 pandas 的.str.contains()方法和正则表达式(r'[^a-zA-Z0-9\s]')。正则表达式模式[^...]匹配不在指定集合(a-z, A-Z, 0-9, 空白字符\s)中的任何字符.*mask.sum(): 将结果布尔序列(true=1,false=0)求和,以计算包含至少一个此类特殊字符的文本数量.*df['text'].str.contains(r'\d'): 使用.str.contains()和正则表达式\d(匹配任何数字)*mask.sum(): 计算包含至少一个数字的文本数量*df['text'].str.isupper(): 使用 pandas 的.str.isupper()字符串方法,如果字符串中的所有大小写字符都是大写并且至少有一个字母字符是大写的(即字母),则返回True。如果字符串全部是非字母字符(如数字或标点符号),则返回False——即使这些字符也不是小写的.*mask.sum(): 计算完全为大写的文本数量*df.sample(...)). 惊奇度计算可能很昂贵,因此通常在代表性样本上而不是整个数据集上进行计算.*GPT4LMHeadModel)及其对应的分词器(GPT4Tokenizer)被加载。(注意:这里的'GPT4'是示例性的;你会使用实际的模型标识符,例如来自 Hugging Face Transformers 库的'gpt2'或'bert-base-uncased'。)*calculate_perplexity函数对文本进行分词,将其输入到模型中,获取损失(衡量模型对文本感到惊讶的程度的一个指标),并使用torch.exp(outputs.loss)计算惊奇度.*sample_perplexities.mean())以获得一个代表样本平均质量的单一分数.*sample = df.sample(...): 从数据中随机抽取样本*print(sample['text'].head()): 打印随机样本中的前几个文本条目,使用户运行脚本时可以快速查看一些示例
为了确保全面的质量保证,你可以执行以下操作:
-
实施针对你特定数据特征和清洗规则的更复杂的自动化测试。
-
制定一个系统的手动审查流程,包括为人类标注者提供评估数据质量的一致性指南。
-
使用已知存在问题的合成数据集来基准测试和评估管道的性能。
-
将清洗后的数据集与原始数据集进行比较,以验证在清洗过程中是否发生了意外的数据丢失或更改。
-
定期审计你的数据清洗管道,以识别在清洗过程中出现的任何新兴问题或偏差。
-
记录详细的清洁过程日志,包括做出的任何决策及其依据,以确保可重复性和便于未来的改进。
通过实施这些措施,你可以确保你的清洗数据集具有高质量且适合训练鲁棒的 LLMs。
摘要
在本章中,我们探讨了 LLM 训练中数据清洗的关键过程。我们讨论了清洁数据在开发鲁棒和可靠的语言模型中的重要性,并涵盖了针对语言数据集的常见数据质量问题。我们提供了解决这些问题的技术,包括文本预处理、处理多语言和代码混合数据以及大型文本语料库的去重策略。
我们还深入探讨了自动化数据清洗管道的实施,这对于处理 LLM 训练中使用的海量数据集至关重要。最后,我们讨论了数据验证和质量保证措施,以确保清洗过程的有效性。
在下一章中,我们将重点关注 LLMs 的数据增强模式。
第三章:数据增强
数据增强在增强 LLMs(大型语言模型)的性能和泛化能力方面发挥着关键作用。通过人工扩大训练数据集,我们可以让我们的模型接触到更广泛的语言变化和上下文,提高它们处理各种输入和生成更连贯、上下文相关输出的能力。
在 LLMs 的背景下,数据增强面临着独特的挑战和机遇。与图像数据不同,图像数据可以通过简单的变换(如旋转或翻转)来创建有效的新的样本,文本数据需要更细致的方法来保持语义完整性和语言连贯性。LLMs 数据增强的主要目标包括增加数据集大小和多样性,解决数据不平衡和偏差问题,提高模型对输入变化的鲁棒性,以及增强对未见数据的泛化能力。
在图 3.1中,我展示了数据增强的关键方面。

图 3.1 – 数据增强的关键元素
有三个主要组成部分,即技术、考虑因素和评估。每个部分都有具体的子组件,我们将在本章中详细讨论。
到本章结束时,您将深入了解数据增强模式,从增加训练数据集的多样性到保持其完整性:
-
文本数据增强技术
-
利用现有 LLMs 进行数据生成
-
多语言数据增强策略
-
文本增强中的语义保留
-
平衡增强和数据质量
-
评估数据增强的影响
文本数据增强技术
文本数据增强包括一系列技术,从简单的单词级别操作到更复杂的语义转换。
同义词替换
这种技术涉及用同义词替换原始文本中的单词。我们可以使用WordNet,一个英语词汇数据库,来查找同义词:
def synonym_replacement(text, n=1):
words = text.split()
new_words = words.copy()
random_word_list = list(
set([word for word in words if word.isalnum()])
)
random.shuffle(random_word_list)
num_replaced = 0
for random_word in random_word_list:
synonyms = get_synonyms(random_word)
if len(synonyms) >= 1:
synonym = random.choice(list(synonyms))
new_words = [
synonym if word == random_word else word
for word in new_words
]
num_replaced += 1
if num_replaced >= n:
break
return ' '.join(new_words)
synonym_replacement函数接受一个文本输入,并用同义词替换指定数量的单词(默认为 1)。选择默认值 1 是为了最小化文本修改,保留意义和可读性,同时允许轻松实验。如果您想进行更多替换,可以增加这个数字。
函数将文本拆分为单词,创建一个唯一的字母数字单词列表,然后打乱这个列表,并遍历它。对于每个单词,它尝试使用一个未定义的get_synonyms函数来查找同义词。如果找到了同义词,它将随机选择一个并替换文本中所有原始单词的出现。该函数会跟踪已替换的单词数量,并在达到指定数量时停止。最后,它将修改后的单词重新组合成一个字符串并返回。
反向翻译
此方法涉及将文本翻译成另一种语言,然后再翻译回原始语言。这对于引入句子结构和词汇选择中的自然变化特别有效:
def back_translation(text, target_lang='fr'):
translator = Translator()
translated = translator.translate(text, dest=target_lang)
back_translated = translator.translate(translated.text, dest='en')
return back_translated.text
使用 T5 进行文本生成
由谷歌研究开发的文本到文本迁移转换器(T5)模型是一个基于转换器架构的多功能自然语言处理(NLP)模型。其关键创新是将所有 NLP 任务框架化为文本到文本问题,这使得它能够处理多个任务而无需特定于任务的架构。使用“跨度损坏”目标在大型网络文本语料库上预训练,T5 有多种尺寸,并在广泛的 NLP 任务中展示了强大的性能。
T5 通过将所有基于文本的任务框架化为文本到文本问题来处理广泛的文本任务。这意味着无论任务是什么,无论是摘要、翻译、问答还是分类,输入和输出都被视为文本。这种统一的方法使得 T5 能够在无需特定于任务的修改的情况下执行各种任务,使其高度适应不同的用例。
当谈到数据增强时,T5 通过生成现有文本数据的变体,在扩展和多样化数据集方面发挥着关键作用。数据增强在训练机器学习模型时尤其有价值,因为它通过让模型接触到更广泛的示例来帮助它们更好地泛化,减少过拟合并提高鲁棒性。以下是 T5 如何帮助数据增强的说明:
-
释义:T5 可以在保持原意的同时重新表述句子。例如,如果输入是“这部电影很无聊”,T5 可以生成一个释义版本,如“这部电影很乏味。”这种表达方式的多样性为模型提供了额外的学习示例,有助于它更好地泛化到不同的表述方式。
-
同义词替换:T5 可以用同义词替换单词,在保留整体情感或上下文的同时,创造轻微的意义变化。例如,从“这部电影很长且无聊”中,T5 可能会生成“这部电影很冗长且乏味。”这种简单的修改增加了数据集的多样性,为依赖于理解语言微小变化的模型提供了更多的训练示例。
-
基于情感的转换:T5 还可以转换句子的情感。例如,给定一个负面句子,如“这部电影非常令人失望”,T5 可以生成一个中立或正面的版本,如“这部电影开始得很慢,但后来有所改进。”这种能力允许在不同情感类别中创建多个示例,这在如情感分析等任务中特别有用,在这些任务中,模型需要区分积极、中立和消极的情感。
-
文本扩展:T5 可以接受简短的句子并通过添加更多上下文、细节或描述来扩展它。例如,从句子“事件很棒”中,T5 可以生成一个更详细的版本,如“事件很棒,有出色的演讲和引人入胜的讨论。”通过添加更多上下文,T5 提供了句子的额外变体,有助于训练模型处理更复杂的输入。
我们可以使用预训练的 T5 模型生成输入文本的变体。这种方法特别强大,因为它可以产生更多样化和上下文丰富的增强。让我们看看这个例子:
def t5_augmentation(text, model, tokenizer, num_return_sequences=1):
input_ids = tokenizer.encode(
f"paraphrase: {text}",
return_tensors="pt",
max_length=512,
truncation=True
)
outputs = model.generate(
input_ids=input_ids,
max_length=150,
num_return_sequences=num_return_sequences,
num_beams=5,
no_repeat_ngram_size=2,
top_k=50,
top_p=0.95,
)
return [
tokenizer.decode(
output, skip_special_tokens=True
) for output in outputs
]
此函数接受文本输入、预训练的 T5 模型、其分词器以及要生成的释义数量(默认为 1)。默认的 1 个返回序列是为了简单起见,但你可以通过增加此值来请求多个释义。
函数使用"paraphrase:"前缀对输入文本进行编码,限制其长度为512个标记。然后使用模型生成最大长度为150个标记的释义。生成过程使用 5 个 beam 的 beam 搜索,防止 2-gram 重复,并应用50) 和 0.95) 512, 150, 5, 2, 50, 0.95),这些参数也可以根据具体用例进行调整,以控制生成释义的长度、多样性和质量。
函数解码并返回生成的释义,跳过在过程中添加的任何特殊标记。
在语言生成系统中使用温度控制作为额外的参数,允许微调创造性和连贯性之间的平衡。温度是一个介于 0 到 1 之间的标量值,它在生成过程中影响下一个标记的概率分布。低值(接近 0)使分布集中,使模型更确定性和连贯,但可能重复或保守。高值(接近 1)使分布平坦,增加随机性和多样性,但牺牲了连贯性。
利用现有 LLMs 进行数据生成
对于 LLMs 的数据增强,最强大的方法之一是使用现有模型生成新的训练示例。这种技术通常被称为自监督学习或基于模型的 数据增强,它使我们能够创建大量多样化、高质量的训练数据。
我们将探讨如何使用GPT-4o和OpenAI API进行数据生成:
def gpt4o_data_generation(prompt, num_samples=5):
response = openai.ChatCompletion.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
max_tokens=150,
n=num_samples,
temperature=0.7,
)
return [choice.message.content.strip()
for choice in response.choices
]
此函数发送包含提供的提示的单个用户消息,用于聊天完成请求。它将响应限制在最多 150 个标记,这平衡了获得实质性响应和控制输出长度的需求。n 参数,设置为 num_samples,决定了要生成的替代完成内容的数量。使用 0.7 的温度,这为生成的文本提供了创造性和连贯性之间的平衡:高值增加随机性,而低值会使输出更确定。然后函数提取并返回每个生成的完成内容的文本,去除任何前导或尾随空白。这些参数(150 个标记,0.7 温度)可以根据输出长度和创造性的具体需求进行调整。
当使用这种方法时,我们需要考虑以下因素:
-
提示工程:需要精心设计提示以生成相关和多样化的样本。
-
质量控制:实施过滤机制以确保生成数据符合您的质量标准。
-
多样性:使用温度和 top-p 采样来控制生成样本的随机性和多样性。
我们已经探讨了使用 GPT-4o 的数据增强技术并检查了基本考虑因素。现在,让我们将注意力转向多语言数据增强的策略。
多语言数据增强策略
对于旨在处理多种语言的 LLM,多语言数据增强是必不可少的。我们可以调整我们之前的技术以跨语言工作。
跨语言回译
在将其翻译回原始语言之前,将文本翻译成多种语言:
def cross_lingual_back_translation(text,
target_langs=['fr', 'de', 'es']
):
translator = Translator()
augmented_texts = []
for lang in target_langs:
translated = translator.translate(text, dest=lang)
back_translated = translator.translate(
translated.text, dest='en'
)
augmented_texts.append(back_translated.text)
return augmented_texts
cross_lingual_back_translation 函数接收一个文本输入,通过首先将其翻译成多种目标语言(默认为法语、德语和西班牙语),然后将其翻译回英语来生成其增强版本。该函数使用 Translator 对象执行这些翻译,将每个回译版本存储在一个列表中,并将其作为输出返回。
多语言 T5 增强
您可以使用多语言 T5 模型在不同语言中生成释义:
def multilingual_t5_augmentation(
text, model, tokenizer, target_langs=['fr', 'de', 'es']
):
augmented_texts = []
for lang in target_langs:
input_ids = tokenizer.encode(
f"translate English to {lang}: {text}",
return_tensors="pt", max_length=512,
truncation=True
)
outputs = model.generate(input_ids=input_ids, max_length=150)
translated = tokenizer.decode(outputs[0],
skip_special_tokens=True)
augmented_texts.append(translated)
return augmented_texts
multilingual_t5_augmentation 函数使用 T5 模型通过将其翻译成多种目标语言(默认为法语、德语和西班牙语)来增强给定的文本。对于每种目标语言,它使用翻译提示对文本进行编码,使用模型生成翻译输出,并解码结果。翻译的文本被收集在一个列表中,并作为原始文本的增强版本返回。
文本增强中的语义保留
在为 LLM 增强数据时保持语义完整性至关重要。我们必须确保我们的技术不会改变文本的原始含义。
句子嵌入的使用
通过比较原始文本和增强文本的 句子嵌入,您可以确保 语义相似性:
def semantic_similarity(original, augmented, model):
original_embedding = model.encode(original)
augmented_embedding = model.encode(augmented)
similarity = cosine_similarity(
[original_embedding], [augmented_embedding]
)[0][0]
return similarity
def filter_by_semantic_similarity(
original, augmented_list, model, threshold=0.8
):
return [
aug for aug in augmented_list
if semantic_similarity(original, aug, model) >= threshold
]
我们定义了两个用于根据语义相似度测量和过滤文本的函数:
-
semantic_similarity(original, augmented, model)使用两个文本嵌入的余弦相似度计算两个文本之间的语义相似度。它使用提供的模型(可能是句子嵌入模型)将原始文本和增强文本编码为向量表示。然后计算这些向量之间的余弦相似度,得到一个介于 -1 和 1 之间的值,其中 1 表示完美相似。 -
filter_by_semantic_similarity(original, augmented_list, model, threshold=0.8)根据与原始文本的语义相似度过滤增强文本列表。semantic_similarity函数将每个增强文本与原始文本进行比较。默认阈值设置为0.8:默认情况下,它将仅保留与原始文本相似度达到0.8或更高的增强文本。此阈值在 NLP 任务中常用,因为它通常表示强语义相似度,同时允许一些变化。可以根据您希望过滤有多严格或多宽松来调整此阈值:更高的阈值将导致更多相似(但可能更少)的增强;更低的阈值将允许更多样化(但可能不太相关)的增强。
用于同义词替换的上下文词嵌入
您可以使用 上下文词嵌入 来根据上下文找到更合适的同义词。上下文词嵌入是指使用语言模型生成的词表示,这些表示捕获了单词在其特定句子或段落中的意义,而不是将单词视为具有固定意义。与传统的静态嵌入不同,其中单词的向量无论在什么上下文中都相同,上下文嵌入根据其周围的单词为相同的单词分配不同的向量。这允许进行更准确的同义词替换,因为所选的同义词不仅与词典意义相符,而且与单词在特定上下文中的使用方式相符。例如,“bank”在“river bank”与“savings bank”中的表示就不同,这会导致上下文适当的同义词建议,如“shore”或“financial institution”。以下代码片段显示了它是如何工作的:
def contextual_synonym_replacement(text, model, tokenizer, n=1):
words = text.split()
new_words = words.copy()
for i in range(n):
word_index = random.randint(0, len(words) - 1)
original_word = words[word_index]
inputs = tokenizer(text, return_tensors="pt")
with torch.no_grad():
outputs = model(inputs)
word_embedding = outputs.last_hidden_state[0, word_index]
similar_words = find_similar_words(
word_embedding, model, tokenizer
)
if similar_words:
new_words[word_index] = random.choice(similar_words)
return ' '.join(new_words)
此函数使用语言模型进行上下文感知的单词替换:
-
它接受文本输入、预训练的语言模型、其分词器和要替换的单词数量(默认为 1)。
-
将文本拆分为单词,并创建一个用于修改的副本。
-
函数迭代
n次(默认为 1)。每次执行以下操作:-
随机选择一个单词索引
-
对整个文本进行分词
-
将其通过模型运行以获取上下文嵌入
-
提取所选单词的嵌入
-
根据此嵌入查找相似单词(使用未定义的
find_similar_words函数) -
如果找到相似单词,则随机选择一个来替换原始单词
-
-
最后,它将修改后的单词重新组合成一个字符串并返回。
默认的n=1 是为了在引入变化的同时做出最小的改变。这保留了大部分原始意义和结构。您可以增加n以获得更多的替换,但更高的值可能会更显著地改变文本的意义。
与简单的同义词替换相比,这种方法更注重上下文,因为它在寻找替换词时考虑了单词在全文中的使用情况。确切的行为将取决于所使用的模型和分词器,以及find_similar_words函数的实现。
平衡增强和数据质量
虽然数据增强可以显著提高大型语言模型(LLM)的性能,但我们需要在数量和质量之间取得平衡。
您应该限制训练集中增强数据的比例。一种常见的做法是开始时以原始数据与增强数据 1:1 的比例开始,并根据模型性能进行调整。
质量过滤
您可以实施质量检查以过滤掉低质量的增强样本:
def quality_filter(
augmented_texts, original_text,
similarity_threshold=0.8, perplexity_threshold=100
):
filtered_texts = []
for aug_text in augmented_texts:
if (
semantic_similarity(
original_text, aug_text, similarity_model
) >= similarity_threshold and
calculate_perplexity(
aug_text, perplexity_model
) <= perplexity_threshold
):
filtered_texts.append(aug_text)
return filtered_texts
人工参与循环验证
对于关键应用,将人工验证纳入您的增强流程中。
人工参与循环(HITL)验证是一种在人工智能流程中使用的控制机制,其中人类被故意插入到自动化工作流程中,以确保正确性,尤其是在涉及主观判断、敏感内容或关键决策的任务中。这在数据质量直接影响安全、公平或合规性的应用中尤为重要——例如,医疗诊断、法律文件分析或自主系统。在数据增强的背景下,其目标是通过对现有样本生成变体来扩展训练数据集,HITL 用于验证生成的样本是否连贯、准确,并与预期的标签或任务保持一致:
def human_validation(augmented_texts):
validated_texts = []
for text in augmented_texts:
if input(
f"Is this text valid? (y/n)\n{text}\n"
).lower() == 'y':
validated_texts.append(text)
return validated_texts
此函数旨在通过从人类操作员那里获取二元反馈(是或否)来手动验证一系列增强文本样本。它在增强流程中的存在承认了并非所有自动生成数据都可以仅凭表面价值信赖。保留或丢弃给定样本的决定是交互式做出的,这加强了在语义完整性不可协商的任务中的人类监督。
函数循环的每一迭代代表一个决策点。人类验证者会看到生成的文本,并被要求评估它是否符合预期的标准。这些标准通常基于特定任务的要求,如语法正确性、与原始数据的语义等效性、语气适当性或领域一致性。例如,在医疗文本分类任务中,改写的句子必须保留所有关键的临床实体。如果不在验证期间捕捉到,增强技术引入的术语上的微小变化可能会误导模型。这就是人类评估变得不可或缺的地方。
将输入转换为小写的逻辑是为了处理不一致的用户输入。无论用户输入Y、y或任何其他大小写,比较都变得不区分大小写。只有当输入等同于y时,函数才接受样本。这种二进制检查故意严格,以防止模糊的批准。被拒绝的样本被静默丢弃,不记录或返回,这意味着任何进一步检查或更正被拒绝样本都需要单独实现。
函数通过返回一个仅包含明确验证的样本列表来结束。然后可以使用这些输出以更高的信心扩展训练数据集。重要的是,这种方法并不取代自动质量检查,而是在高风险应用中补充它们。在部署模型的环境中使用 HITL 验证特别有用,在这些环境中,假阳性或假阴性具有高昂的成本,例如法律推荐系统、欺诈检测或自主导航。人工验证过程有助于减轻过度依赖缺乏明确语义保证的生成增强方法所带来的风险。
在一个更大的系统中,这类功能通常会嵌入到一个更广泛的流程中,其中自动过滤器首先筛选出明显低质量或不相关的增强。人工验证员只会评估边缘或高影响案例。为了提高操作效率,交互通常通过网页界面或集成注释工具而不是命令行提示来处理。然而,这个功能以最简单的方式展示了原理:在将增强数据纳入模型训练之前,人类判断被用作质量最终裁决者。
评估数据增强的影响
为了评估我们数据增强技术的有效性,我们需要评估它们对大型语言模型(LLM)性能的影响。
感疑度
您可以在数据增强前后,在保留的测试集上测量模型的可疑度(见第二章),以评估它是否提高了模型预测未见文本的能力:
def evaluate_perplexity(model, tokenizer, test_data):
model.eval()
total_loss = 0
total_tokens = 0
with torch.no_grad():
for text in test_data:
inputs = tokenizer(
text, return_tensors="pt"
).to(model.device)
outputs = model(inputs, labels=inputs["input_ids"])
total_loss += (
outputs.loss.item() * inputs["input_ids"].size(1)
)
total_tokens += inputs["input_ids"].size(1)
perplexity = math.exp(total_loss / total_tokens)
return perplexity
这个函数evaluate_perplexity计算给定测试数据集上语言模型的疑惑度。以下是分解:
-
它接受一个预训练的语言模型、其分词器和测试数据集作为输入。
-
模型被设置为评估模式以禁用 dropout 和其他特定于训练的行为。
-
它初始化变量以跟踪总损失和总处理令牌数。
-
对于测试数据中的每个文本,执行以下操作:
-
文本被分词并转换为张量。
-
模型处理输入,计算损失。
-
损失被累积,并按输入中令牌的数量加权。
-
-
处理完所有文本后,它使用以下公式计算疑惑度:
exp(total_loss / total_tokens)。
此实现以零样本方式使用模型,将每个输入视为上下文和预测的目标。使用torch.no_grad()确保不计算梯度,使评估更高效。
此函数假设模型和数据兼容(即模型可以处理数据的最大序列长度)。在实际应用中,您可能需要添加检查或截断以处理非常长的序列。
特定任务指标
您可以对与您的用例相关的下游任务进行模型评估,例如文本分类或问答:
def evaluate_classification(
model, tokenizer, test_data, test_labels
):
model.eval()
predictions = []
with torch.no_grad():
for text in test_data:
inputs = tokenizer(
text, return_tensors="pt"
).to(model.device)
outputs = model(inputs)
predictions.append(torch.argmax(outputs.logits).item())
accuracy = accuracy_score(test_labels, predictions)
f1 = f1_score(test_labels, predictions, average='weighted')
return accuracy, f1
此函数评估分类模型在测试数据集上的性能:
-
它接受一个预训练的分类模型、其分词器、测试数据(文本)和相应的测试标签作为输入。
-
模型设置为评估模式以禁用 dropout 和其他特定于训练的行为。
-
它处理测试数据中的每个文本,对其进行分词,并使用模型进行预测。
-
处理完所有文本后,它计算两个评估指标:
-
准确率:所有预测中正确预测的比例。
-
F1 分数:模型精确率和召回率的平衡度量。F1 分数是精确率(所有正预测中真正预测的比例)和召回率(所有实际正实例中真正预测的比例)的调和平均数。
F1 分数的公式是 F1 = 2 * (精确率 * 召回率) / (精确率 + 召回率)。
F1 分数的范围从 0 到 1,其中 1 表示完美的精确度和召回率。对于仅准确率可能具有误导性的不平衡数据集,它特别有用。加权平均计算每个类的 F1 分数,并按每个类中实例的数量加权平均。
-
-
函数返回准确率和 F1 分数,提供了对模型在可能不平衡的类别上的性能的更全面评估。
此实现还使用torch.no_grad()以提高效率,并假设已导入必要的 scikit-learn 指标。在实际应用中,您可能需要添加错误处理以处理意外的模型输出或预测/标签计数不匹配。
多样性指标
评估增强数据集的多样性很重要:
def calculate_diversity_metrics(texts):
all_words = [word for text in texts for word in text.split()]
vocab_size = len(set(all_words))
all_trigrams = [text[i:i+3] for text in texts
for i in range(len(text)-2)]
unique_trigrams = len(set(all_trigrams))
return {
"vocabulary_size": vocab_size,
"unique_trigrams": unique_trigrams
}
此函数接受一组文本作为输入,并计算多样性指标。一旦完成,此函数将返回一个包含这两个指标的字典:
-
词汇量大小(范围从 1 到总单词数):这可以给出词汇多样性的概念。高数值表明文本中使用了多样化的词汇。此指标将每个文本拆分为单词,然后将所有文本中的所有单词合并,并使用集合来计算唯一单词的数量。在此上下文中,集合指的是一种数据结构,它自动删除重复元素。
-
独特的三元组(范围从 1 到三元组的总数):这些指标表示字符级别的多样性。高数值表明字符序列多样化,可能表明句子结构或词汇选择多样化。此指标通过从每个文本中创建三元组(三个字符的序列)并使用仅包含唯一元素的集合来计算独特三元组的数量。
这些指标可用于比较原始文本与增强文本之间的多样性,或评估数据集中的多样性。然而,结果应在特定背景下进行解读,因为高多样性可能表明数据中的不连贯性或噪声。
通过系统地应用这些技术,我们可以量化我们的数据增强策略对 LLM 性能的影响,并就使用哪些技术和如何微调我们的增强流程做出明智的决定。
摘要
在本章中,我们探讨了针对 LLM 的高级数据增强技术,包括文本操作方法、利用现有模型进行数据生成、多语言策略、语义保留、质量控制以及多个指标。我们还讨论了平衡增强与数据质量的重要性,并提供了各种技术的实用 Python 实现。
在下一章中,我们将专注于处理 LLM 训练的大型数据集。
第四章:处理 LLM 训练中的大规模数据集
在本章中,你将学习管理和处理大规模数据集的高级技术,这对于训练最先进的 LLMs 至关重要。我们将探讨大规模语言数据集带来的独特挑战,并提供解决这些挑战的实际方案。
本章的目标是让你掌握处理大规模数据的知识和工具,从而能够训练更强大、更有效的 LLMs。
本章我们将涵盖以下主题:
-
大数据集的挑战
-
数据采样技术
-
分布式数据处理
-
数据分片和并行化策略
-
高效的数据存储格式
-
流式数据处理以实现持续的 LLM 训练
-
内存高效的数据加载技术
大数据集的挑战
训练 LLMs 需要巨大的数据集,通常在千兆或甚至太字节范围内。这种规模引入了几个挑战:
-
存储需求:数据集可能超过单台机器的容量,需要分布式存储解决方案。
-
输入/输出(I/O)瓶颈:读取大量数据可能成为显著的瓶颈,限制训练速度。
-
预处理开销:由于处理大量文本数据需要通过多个顺序操作的计算开销,分词和其他预处理步骤在规模上可能耗时。挑战来自于需要对每段文本执行多个步骤——分词、规范化、清理、语言检测以及其他转换——这些步骤在数百万或数十亿个文本样本上成倍增加。这个过程本质上是顺序的(每一步都依赖于前一步),需要 CPU/内存资源,并可能涉及复杂的操作,如正则表达式(regexes)、字典查找和特定语言规则。当处理多语言或代码混合数据时,复杂性进一步增加,因为需要应用不同的语言规则,并且需要对每个文本段进行额外的步骤,如脚本规范化或语言检测,这使得预处理管道成为大规模自然语言处理(NLP)系统的一个显著瓶颈。
-
内存限制:将整个数据集加载到内存中通常是不切实际的,需要流式或批处理方法。
-
数据质量和多样性:随着数据集规模的增加,确保数据集质量和代表性变得更加困难。
为了应对这些挑战,我们需要采用复杂的数据处理技术。让我们通过使用 Hugging Face 的Datasets库的 Python 实现来探索这些技术,该库旨在高效地处理大规模数据集:
from datasets import load_dataset, Dataset
import psutil
def load_and_process_large_dataset(dataset_name, num_proc):
# Load the dataset
dataset = load_dataset(dataset_name, streaming=True)
# Define a preprocessing function
def preprocess_function(examples):
# Implement your preprocessing logic here
return examples
# Apply preprocessing in parallel
processed_dataset = dataset.map(
preprocess_function,
batched=True,
num_proc=num_proc,
remove_columns=dataset["train"].column_names
)
return processed_dataset
#Determine the number of CPU cores for parallel processing
num_cores = psutil.cpu_count(logical=False)
#Load and process a large dataset (e.g., C4 dataset)
large_dataset = load_and_process_large_dataset("c4",
num_proc=num_cores)
#Print the first few examples
for example in large_dataset["train"].take(5):
print(example)
在此代码中,我们使用 Datasets 库高效地加载和处理大型数据集(在这种情况下,是 C4 数据集)。num_proc 参数指定用于数据集映射操作中的并行处理所使用的处理器核心数。在预处理大型数据集时,通过并行处理使用多个 CPU 核心可以显著加快操作速度。例如,如果 num_proc=4,则预处理函数将在四个处理器核心上同时执行,并行处理不同的数据批次,而不是顺序处理。
为了更好地理解大型数据集的使用上下文,探索一个具体的例子很有帮助。前述代码片段中使用的一个此类数据集是 Colossal Clean Crawled Corpus (C4)数据集,它在现代 LLM 的训练中发挥着重要作用。
C4 数据集是由 Google 创建的一个庞大的、经过清洗的网页爬取文本语料库,用于训练 LLM。包含大约 750 GB 的英语文本,C4 是从 Common Crawl 数据中提取的,并经过广泛的过滤以去除重复内容、非英语内容和冒犯性材料。它有几个变体,包括标准清洗版本、未过滤版本以及专注于新闻类内容的子集。虽然公开可用,但访问 C4 需要一些努力,通常通过 Google Cloud Storage 或 Hugging Face datasets 等库进行。尽管经过清洗过程,C4 在内容质量和潜在偏差方面仍存在一些局限性,研究人员在使用它进行模型训练时应予以考虑。尽管如此,它仍然是 NLP 任务中的一个宝贵资源,并在训练像 Text-to-Text Transfer Transformer (T5)和 Language Model for Dialogue Applications (LaMDA)这样的突出模型中发挥了关键作用。
我们采用流式处理以避免一次性将整个数据集加载到内存中。num_proc 参数设置为物理 CPU 核心数,以最大化并行处理效率。
preprocess_function 函数是您实现特定数据集预处理逻辑的地方。此函数在数据集上并行应用,显著加快了大型数据集的预处理速度。
您也可以使用 GPU 来完成这项任务。请参阅以下代码示例(请注意,虽然基于 GPU 的预处理在诸如标记化、嵌入生成等操作中特别有益,但它可能不会显著加速简单的文本操作):
import torch
from datasets import load_dataset
from torch.utils.data import DataLoader
from transformers import AutoTokenizer
def load_and_process_dataset(dataset_name, batch_size):
dataset = load_dataset(dataset_name, streaming=True)
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
def preprocess(examples):
return tokenizer(
examples["text"], padding="max_length",
truncation=True, return_tensors="pt"
)
def process_batch(batch):
return {k: v.to(device) for k, v in preprocess(batch).items()}
return DataLoader(
dataset["train"].map(process_batch),
batch_size=batch_size, num_workers=2,
pin_memory=True
)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
dataloader = load_and_process_dataset("c4", batch_size=32)
for i, batch in enumerate(dataloader):
if i >= 5: break
print(f"Batch {i}:", {k: v.shape for k, v in batch.items()})
此代码使用 PyTorch 和 Hugging Face 库以 GPU 加速处理数据集(例如,C4)。它使用数据加载器进行高效的批量处理,将数据移动到 GPU 内存,并使用预训练的标记化器。主要的 GPU 优势来自并行批量处理和 GPU 加速的标记化。虽然这种设置使得 GPU 的使用成为可能,但最大的 GPU 优势通常出现在模型训练或推理期间,而不是预处理期间。
数据采样技术
数据采样是一种在不牺牲代表性情况下减少大型数据集大小的实用方法。存在几种技术,每种技术都有特定的用例和权衡。随机采样从数据集中均匀随机选择数据点。当数据独立同分布时,它简单有效,但如果数据不平衡,可能会错过重要的子群体。系统采样在随机起点后从列表中选择每k个项。它比随机采样更有结构,当数据以有意义的方式排序时可能很有用,但如果排序与隐藏的周期性模式一致,则可能引入偏差。蓄水池采样是为未知大小数据集的流式传输设计的。它在顺序遍历数据的同时维护一个固定大小的样本,并确保每个项目都有相等的机会被包含。这在数据以连续流形式到达的在线或增量学习场景中特别有用。
由于本章篇幅限制,我们仅关注分层采样,这是一种在数据集中保持子群体比例表示的技术。它特别适用于某些属性(如标签类别、句子长度或元数据类别)已知会影响模型性能并需要在采样子集中保持的情况。在 NLP 中,文本长度是一个常见的分层变量,因为它对模型输入动态有影响。
以下实现演示了如何根据文本长度应用分层采样。它将数据集划分为基于百分比的层级,并从每个层级按比例采样以创建一个子集,该子集保留了整个数据集的长度分布:
import numpy as np
from datasets import Dataset
def stratified_length_sampling(
dataset, num_samples, num_strata=10
):
# Calculate text lengths
lengths = [len(example['text']) for example in dataset]
# Create strata based on text length
strata_bounds = np.percentile(
lengths, np.linspace(0, 100, num_strata + 1)
)
sampled_data = []
for i in range(num_strata):
stratum = [
example for example in dataset
if strata_bounds[i] <= len(example['text']) < \
strata_bounds[i+1]
]
stratum_samples = np.random.choice(
stratum,
size=num_samples // num_strata,
replace=False
)
sampled_data.extend(stratum_samples)
return Dataset.from_dict({
key: [example[key] for example in sampled_data]
for key in dataset[0].keys()
})
#Usage
sampled_dataset = stratified_length_sampling(large_dataset,
num_samples=100000)
这种分层采样技术确保我们在采样数据集中保持文本长度的代表性分布。我们使用 10 个层级(num_strata=10)来平衡粒度和计算效率。根据您特定的数据集特征和采样要求调整此值。
随着数据集规模和复杂性的增长,单机处理在速度和可扩展性方面都成为瓶颈。数据采样等技术可以提供部分缓解,但它们不能解决集中式架构固有的计算限制。为了解决这些限制,下一节介绍了分布式数据处理,其中计算分布在多个机器或节点上以提高吞吐量、降低延迟并支持大规模 LLM 训练管道所需的并行工作流程。
分布式数据处理
对于真正庞大的数据集,分布式处理变得必要。以下是一个使用Dask的例子,这是一个用于 Python 并行计算的灵活库(www.dask.org/)。
Dask 和 Apache Spark 都是分布式计算框架,但它们的主要区别在于其架构和用例。Spark 围绕弹性分布式数据集(RDDs)的概念构建,需要集群设置,使其非常适合大规模生产数据处理。另一方面,Dask 旨在无缝集成到 Python 生态系统,可以从单个笔记本电脑扩展到集群,使用与 NumPy、pandas 和 scikit-learn 相似的 API。虽然 Spark 在处理大规模数据集的批处理方面表现出色,但 Dask 在交互式计算和科学工作流程方面更加灵活,尤其是在使用 Python 原生库或需要以最小修改扩展现有 Python 代码时。
让我们回到我们的代码:
import dask.dataframe as dd
from dask.distributed import Client
def distributed_preprocessing(data_path, num_partitions):
# Initialize Dask client
client = Client()
# Read the dataset into a Dask DataFrame
df = dd.read_csv(data_path, blocksize="64MB")
# Repartition the data for better distribution
df = df.repartition(npartitions=num_partitions)
# Define preprocessing function
def preprocess(text):
# Implement your preprocessing logic here
return processed_text
# Apply preprocessing in parallel
df['processed_text'] = df['text'].map(preprocess)
# Trigger computation and return results
result = df.compute()
client.close()
return result
#Usage
processed_data = distributed_preprocessing(
"path/to/large/dataset.csv", num_partitions=100
)
在这个例子中,我们使用 Dask 将预处理工作负载分布到多台机器或核心上。num_partitions参数(设置为100)决定了并行化级别,应根据您的可用计算资源和数据集大小进行调整。
数据分片和并行化策略
数据分片是指将大型数据集分解成更小、更易于管理的块,称为“分片”,然后分布到多台机器或存储系统中的技术。每个分片可以独立处理,这使得处理大型数据集变得更容易,尤其是那些不适合单台机器内存的数据集。这种方法在机器学习中广泛使用,用于分配大型数据集的处理,从而允许训练更大的模型或进行更快的计算。
数据分片使得计算资源的使用更加高效,因为每个分片可以独立处理,并且结果可以在之后进行汇总。
然而,必须仔细考虑确保分片策略保持所有分片间数据分布的完整性和代表性,以避免训练模型中的偏差或不一致性。
这里是一个分片策略的例子:
import hashlib
def shard_data(dataset, num_shards):
shards = [[] for _ in range(num_shards)]
for item in dataset:
# Use a hash function to determine the shard
shard_index = int(
hashlib.md5(
item['id'].encode()
).hexdigest(), 16
) % num_shards
shards[shard_index].append(item)
return shards
#Usage
sharded_data = shard_data(large_dataset, num_shards=10)
这种分片策略使用哈希函数将数据项分布到各个分片中。num_shards参数(设置为10)应根据您的基础设施和并行化需求进行调整。
shard_data函数通过应用基于每个项目唯一标识符的一致性哈希方案,将数据集中的项目分配到指定的分片数量中。它初始化一个空列表的列表,每个列表代表一个分片,对于输入数据集中的每个项目,它使用'id'字段计算一个分片索引。哈希输出被转换为整数,并取模以确保在分片之间均匀分布。这种方法保证了具有相同 ID 的项目在执行过程中始终映射到相同的分片,这对于分布式存储或并行处理等任务很有用,在这些任务中,确定性和平衡很重要。
分片策略的选择基于数据的性质和预期的查询模式,每种方法在可扩展性、性能和复杂性方面都提供了不同的权衡:
-
哈希分片:通过哈希函数映射键以均匀分布负载,适用于均匀分布的数据
-
范围分片:适用于有序数据集,如时间序列日志,其中每个分片包含连续的数据值范围
-
地理分片:通过根据地理区域分区数据来优化基于位置的查询
-
键值分片:通过将特定的键范围或值分配给定义的分片,允许手动控制热点
-
基于目录的分片:通过使用查找服务来确定数据放置以支持动态分片分配,适应数据分布的变化
-
一致性哈希:当分片数量变化时最小化数据移动,保持稳定性并减少重新平衡开销
-
轮询分片:按顺序将数据分配到各个分片中,提供简单性但范围查询性能较差
-
基于工作负载的分片:通过根据观察到的查询模式将高流量数据分配到单独的分片中来平衡访问负载
-
复合分片:结合多种策略以支持具有多种数据类型和查询需求复杂系统的支持
-
基于标签的分片:根据用户角色或数据类别等标签对数据进行分类,支持特定领域的分区策略
对于前面的代码块,我们还可以定义以下函数作为主要协调器来处理和聚合分片:
def process_with_sharding(
dataset: List[Dict], num_shards: int
) -> List[Dict]:
# Step 1: Shard the data
shards = shard_data(dataset, num_shards)
# Step 2: Process shards in parallel
with ProcessPoolExecutor(max_workers=num_shards) as executor:
processed_shards = list(executor.map(process_shard, shards))
# Step 3: Aggregate results
aggregated_results = []
for shard_results in processed_shards:
aggregated_results.extend(shard_results)
process_with_sharding 函数接收一个表示为字典列表的数据集,并使用 shard_data 函数将其划分为指定数量的分片。然后,它使用 ProcessPoolExecutor 并行处理每个分片,每个分片使用 process_shard 函数。处理完所有分片后,通过遍历处理过的分片并将它们的内容扩展到最终结果列表中,将每个分片的单个结果聚合到一个列表中。
一旦数据被有效地分区并分配以进行并行处理,就必须关注其物理存储和访问方式——这引出了高效存储格式的选择。
高效的数据存储格式
选择正确的存储格式可以显著影响数据加载和处理速度。
例如,我们可以使用 Apache Parquet (parquet.apache.org/),这是一种特别适用于大型数据集的列式存储格式。
下面是一个比较不同列格式及其在存储大型语言数据集特性方面的表格:
| 特性 | CSV | JSON | Apache Parquet | Apache Arrow |
| --- | --- | --- | --- | --- |
| 存储类型 | 行式 | 行式 | 列式 | 列式 |
| 压缩 | 基础 | 差 | 优秀 | 优秀 |
| 查询速度 | 慢 | 慢 | 快 | 非常快 |
| 嵌套结构 | 否 | 是 | 是 | 是 |
| 模式支持 | 否 | 有限 | 是 | 是 |
| 随机访问 | 差 | 差 | 好 | 优秀 |
| 内存效率 | 差 | 差 | 好 | 优秀 |
| Python 集成 | 简单 | 简单 | 好(通过 PyArrow) | 原生 |
| 典型 用例 | 小数据集 | API 响应 | 大数据分析 | 内存处理 |
| 加载速度 | 慢 | 中等 | 快 | 非常快 |
| NLP 特征支持 | 基础 | 好 | 优秀 | 优秀 |
| 跨平台 | 是 | 是 | 是 | 是 |
| 元数据支持 | 不支持 | 有限 | 支持 | 支持 |
表 4.1 – 不同列格式的特性
此表突出了为什么 Parquet 由于其列式存储格式、高效的压缩和强大的对 NLP 任务中常见的复杂数据结构支持,通常被首选用于 LLM 数据集。
下面是一个示例,说明数据通常如何在 Apache Parquet 列中为 NLP 数据集结构化:
| 列名 | 数据类型 | 示例值 |
| --- | --- | --- |
| text_id | 整数 | 1, 2, 3, 4 |
| 文本 | 字符串 | "This is sample text", "``Another example" |
| 标记 | 字符串列表 | ["This", "is", "sample", "text"], ["``Another", "example"] |
| 嵌入 | 浮点数列表 | [0.1, 0.2, 0.3], [0.4, 0.5, 0.6] |
| 元数据 | 结构体 | {"lang": "en", "source": "web"}, {"lang": "fr", "``source": "news"} |
| 标签 | 整数 | 1, 0, 1, 0 |
| 时间戳 | 时间戳 | 2024-01-01 10:30:00, 2024-01-01 10:31:00 |
| language_score | 浮点数 | 0.95, 0.87, 0.92 |
| 实体 | 结构体列表 | [{"text": "Google", "type": "ORG"}, {"text": "New York", "``type": "LOC"}] |
| doc_stats | 结构体 | {"word_count": 150, "char_count": 750, "``sentence_count": 8} |
表 4.2 – Apache Parquet 列中的数据结构
每一列数据都单独存储,并且可以高效地压缩和独立访问,这对于大规模自然语言处理尤其有用。
以下代码片段使用 PyArrow 库将表示为 Python 字典列表的数据集转换为 Parquet 文件,并将其读回:
import pyarrow as pa
import pyarrow.parquet as pq
def convert_to_parquet(dataset, output_path):
# Convert dataset to Arrow Table
table = pa.Table.from_pydict(dataset[0])
# Write to Parquet file
pq.write_table(table, output_path)
def read_from_parquet(file_path):
# Read Parquet file
table = pq.read_table(file_path)
# Convert back to dictionary
return table.to_pydict()
#Usage
convert_to_parquet(large_dataset, "large_dataset.parquet")
loaded_dataset = read_from_parquet("large_dataset.parquet")
在前面的代码片段中,convert_to_parquet 函数接收一个数据集和一个输出文件路径,使用 pa.Table.from_pydict 将数据集中的第一个字典转换为 PyArrow 表,并使用 pq.write_table 将其写入 Parquet 文件。read_from_parquet 函数使用 pq.read_table 从指定路径读取 Parquet 文件到 PyArrow 表,然后使用 table.to_pydict 将其转换回 Python 字典。在用法示例中,一个变量 large_dataset 被序列化为 "large_dataset.parquet",然后反序列化回 loaded_dataset。
Parquet 为 LLM 数据集提供了几个优势:
-
列存储以实现高效查询
-
压缩以减少存储需求
-
支持在 NLP 数据中常见的复杂嵌套结构
虽然前面的部分已经讨论了通过采样、分布式计算和优化存储策略来管理大规模静态数据集的方法,但这些方法假设语料库是有限且定义良好的。然而,训练场景越来越多地涉及数据的持续流入,如用户交互、实时遥测或不断变化的内容流。这些动态环境需要从传统的数据管道转向能够处理实时摄取和处理的架构。下一节介绍了流数据处理作为在 LLM 中维持长期、自适应训练机制所必需的演变。
用于持续 LLM 训练的流数据处理
对于不断生成新数据的情况,流处理允许持续更新模型。以下是一个使用 Apache Kafka (kafka.apache.org/) 和 Faust (faust.readthedocs.io/en/latest/) 的示例。
Apache Kafka 是一个分布式流平台,它是构建实时数据管道和流应用程序的基础。它使用 发布-订阅 (pub-sub) 模型,其中数据生产者向主题发送消息,消费者从这些主题中读取,允许在多个代理之间进行可扩展、容错的数据分发。当与异步处理结合使用时,这些技术使系统能够在不阻塞操作的情况下实时处理大量数据。Kafka 中的多个代理提供冗余和负载均衡,确保高可用性和吞吐量。这种架构在需要实时数据处理的情况下特别有用,例如日志聚合、指标收集、流处理和事件溯源。
另一方面,Faust 是一个基于 Python 的流处理库,旨在通过将数据视为连续的事件流来处理实时数据处理任务。与 Kafka Streams 类似,但用 Python 编写,Faust 允许开发者构建能够实时处理、转换和分析数据的流应用程序。它提供了用于处理流的高级抽象,使得实现复杂的流工作流程变得更容易,同时保持了 Python 的简单性和表达性。Faust 内部使用现代 Python 功能,如 async/await,并利用 Python 的 asyncio 库高效地处理并发操作。
以下代码定义了一个使用 Faust 的简单实时数据处理应用程序,Faust 是一个建立在 Kafka 之上的 Python 流处理库。它演示了如何从 Kafka 主题中消费消息,应用预处理逻辑,并为下游任务(如训练 LM)准备数据:
import faust
class Text(faust.Record):
content: str
app = faust.App('llm-training', broker='kafka://localhost:9092')
topic = app.topic('raw-text', value_type=Text)
@app.agent(topic)
async def process(stream):
async for text in stream:
processed_text = preprocess(text.content)
# Here you would typically send the processed text to your LLM training pipeline
print(f"Processed: {processed_text}")
if __name__ == '__main__':
app.main()
首先,代码使用faust.Record定义了一个Text类,用于表示包含单个名为content的字符串字段的传入 Kafka 消息。然后,使用'llm-training'标识符创建 Faust 应用程序,并连接到运行在kafka://localhost:9092的本地 Kafka 代理。应用程序订阅名为'raw-text'的主题,并将传入的消息反序列化为Text对象。
核心处理逻辑在process函数中实现,该函数使用@app.agent(topic)装饰器,使其成为处理来自raw-text主题事件的 Faust 代理。该函数异步遍历流中的每条消息,对content字段应用preprocess函数,并打印结果。尽管当前代码打印处理后的文本,但在实际设置中,这通常是传递输出到语言模型训练管道或进一步处理阶段的典型位置。
最后,脚本包括一个标准的 Python 入口点,当脚本直接运行时,用于启动 Faust 应用程序。请注意,preprocess函数假定在完整实现的其他地方定义,因为它不包括在提供的代码片段中。
这种设置允许您持续处理传入的文本数据,然后可以实时或近实时地更新您的 LLM。preprocess函数将包含您的特定预处理逻辑。
内存高效的数据加载技术
对于太大而无法放入内存的数据集,我们可以使用内存映射或分块技术。
内存映射利用操作系统级别的功能,将大文件直接映射到内存中,而不需要加载整个文件。这使得对文件部分的随机访问成为可能,使其适用于需要频繁但非顺序访问的场景。对于大型、结构化数据集(例如嵌入或分词文本文件)来说,这种方法很快,但对于小而分散的读取,可能会有更高的开销。
另一方面,分块将数据分成更小的、顺序处理的块。这对于将大型、顺序访问的数据集(例如文本或日志)流式传输到内存受限环境中非常有效。虽然分块比内存映射简单且更易于移植,但在随机访问模式中,分块可能比内存映射慢。
这里有一个使用 NumPy 的memmap功能的示例,它创建类似于数组的对象,映射到磁盘上的文件,允许在不将整个数组加载到内存的情况下进行高效的读写操作。memmap功能利用操作系统的虚拟内存功能,在最小化内存使用的同时提供无缝的数组操作:
import numpy as np
def create_memmap_dataset(dataset, output_file):
# Determine the shape of the dataset
num_samples = len(dataset)
sample_shape = dataset[0]['input'].shape
# Create a memory-mapped array
mmap = np.memmap(
output_file, dtype='float32', mode='w+',
shape=(num_samples, *sample_shape)
)
# Write data to the memory-mapped array
for i, sample in enumerate(dataset):
mmap[i] = sample['input']
# Flush to disk
mmap.flush()
def load_memmap_dataset(file_path, shape):
# Load the memory-mapped array
return np.memmap(file_path, dtype='float32',
mode='r', shape=shape)
#Usage
create_memmap_dataset(large_dataset, "large_dataset.mmap")
mmap_dataset = load_memmap_dataset(
"large_dataset.mmap", shape=(len(large_dataset),
*large_dataset[0]['input'].shape)
)
这种技术允许您通过将大部分数据保留在磁盘上,并在需要时仅将必要的部分加载到内存中,来处理比可用 RAM 更大的数据集。
这里是一个分块技术的示例,这在处理必须按顺序处理但一次无法全部装入内存的大型数据集时特别有用。与允许随机访问的内存映射不同,分块明确地按顺序加载和顺序处理固定大小的数据块。这在处理大型 CSV 文件、文本语料库或流日志时是一个常见的模式。在以下示例中,使用 pandas 以分块方式处理大型 CSV 文件,pandas 内部将行块读入内存,最小化峰值内存占用:
import pandas as pd
def process_chunk(chunk):
# Placeholder: process or transform the chunk here
# For example, compute the mean of a column
return chunk['value'].mean()
def process_large_csv(file_path, chunk_size=10000):
results = []
for chunk in pd.read_csv(file_path, chunksize=chunk_size):
result = process_chunk(chunk)
results.append(result)
return results
# Usage
file_path = 'large_dataset.csv'
aggregated_results = process_large_csv(file_path)
print("Processed chunk-level results:", aggregated_results)
在本例中,CSV 文件以每次 10,000 行为单位进行读取。每个块被传递到处理函数中,中间结果(在这种情况下,名为'value'的列的平均值)被存储以供进一步聚合或分析。这种方法是灵活的,并且可以轻松扩展到过滤、转换或将分块输出写入新文件等任务。
分块特别适用于线性访问数据且每个块相互独立的情况。然而,如果需要随机访问单个记录或跨块记录,内存映射或索引数据库解决方案可能更有效率。
摘要
在本节中,我们探讨了用于 LLM 训练的大型数据集管理和处理的高级技术。你了解了大型数据集的挑战、数据采样技术、分布式处理、高效的存储格式、流处理、数据分片和内存高效加载。
这些技术对于将 LLM 训练扩展到大规模数据集同时保持效率和数据质量至关重要,每种技术都对处理 LLM 的大数据集有自己的贡献:
-
数据采样技术:通过关注高影响或具有代表性的数据,它们减少了计算负担,提高效率并确保质量,而无需处理整个数据集
-
分布式处理:通过在机器间并行化任务,加快数据准备和训练速度,为大规模数据集提供可扩展性
-
高效的存储格式:它们提高了数据检索速度并减少了存储大小,简化了对大型数据集的访问并提高了 I/O 效率
-
流处理:通过增量处理数据,最小化内存使用,支持实时更新和连续数据流的有效处理
-
数据分片:通过将数据分割成更小的块,平衡工作负载并减少延迟,实现并行性和无缝扩展
-
内存高效加载:通过以可管理的部分加载数据,限制内存使用,确保处理超出内存容量的数据集的效率
在下一章中,我们将介绍另一种模式:LLM 开发的版本控制。
第五章:数据版本控制
数据版本控制指的是在整个模型开发生命周期中,包括预训练、微调、评估和部署过程中,对数据集的不同迭代进行系统跟踪和管理。它涉及为数据集或其子集分配唯一的标识符,记录随时间的变化,并通过确保任何特定模型版本都可以回溯到确切的数据版本来确保可重复性。
在本章中,您将学习如何为 LLM 开发实现有效的数据版本控制策略。例如,当我们想要将 10,000 篇新的肿瘤学研究论文添加到数据集中时,系统会自动创建一个新的数据集版本。如果模型性能随后下降,数据集可以立即回滚到之前经过验证的数据集版本,确保可重复性和维护研究过程的完整性。
这种设计模式将数据集管理从混乱的、手动的过程转变为 LLM 模型开发中的结构化、可追踪的工作流程。
在本章中,我们将涵盖以下主题:
-
理解数据版本控制的需求
-
大型语言数据集的数据版本控制策略
-
数据版本控制工具
-
将数据版本控制集成到训练工作流程中
-
文本语料库的版本控制
-
管理数据集变体和实验
-
数据版本控制的最佳实践
理解数据版本控制的需求
由于语言数据集的规模和复杂性巨大,数据版本控制对于 LLM 项目尤为重要。作为一名 LLM 工程师,您需要跟踪数据集中的变化,以确保模型的可重复性,并保持数据修改的清晰历史记录。
让我们从使用 Python 实现一个基本的数据版本控制系统开始:
from datetime import datetime
import hashlib
import json
class DatasetVersion:
def __init__(self, data, metadata=None):
self.data = data
self.metadata = metadata or {}
self.timestamp = datetime.now().isoformat()
//creation timestamp for each version
self.version_hash = self._generate_hash()
def _generate_hash(self):
data_str = json.dumps(self.data, sort_keys=True).encode()
return hashlib.sha256(data_str).hexdigest()
DatasetVersion 类的这一部分初始化了为您的 LLM 数据集进行版本控制的基本结构。它为每个数据版本生成一个唯一的哈希值,并标记版本的时间戳。_generate_hash 方法基于数据的排序 JSON 表示创建一个确定性的哈希值,确保相同的数据总是产生相同的哈希值。
现在,让我们为数据集版本添加 save 和 load 方法:
class DatasetVersion:
# ... (previous methods)
def save(self, filename):
with open(filename, 'w') as f:
json.dump({
'data': self.data,
'metadata': self.metadata,
'timestamp': self.timestamp,
'version_hash': self.version_hash
}, f, indent=2)
@classmethod
def load(cls, filename):
with open(filename, 'r') as f:
data = json.load(f)
instance = cls(data['data'], data['metadata'])
instance.timestamp = data['timestamp']
instance.version_hash = data['version_hash']
return instance
save 方法将数据集版本序列化为 JSON 文件,包括所有相关信息。load 方法是一个类方法,它从保存的文件中重建一个 DatasetVersion 实例。这使得您可以轻松地存储和检索数据集的不同版本。
在讨论了数据版本控制的需求之后,现在让我们概述管理大型语言数据集版本控制的关键策略,以支持可追溯性、可重复性和高效的存储。
大型语言数据集的数据版本控制策略
在处理大型语言数据集的迭代更新时,由于可以最小化存储成本,本节重点介绍了基于增量的系统,因为它在处理数据版本管理时具有潜在优势。基于增量的版本管理只存储数据集版本之间的差异,而不是复制整个文件,这使得它在涉及频繁但微小的更改的场景中特别有效。然而,当数据集结构发生重大重新格式化或涉及二进制文件时,其有效性会降低。模式更改、列重排或文件拆分可能会破坏增量机制,通常需要完整数据集的重写。同样,由于二进制文件的结构不透明和压缩,即使是微小的编辑也会导致全局变化,限制了基于增量存储的优势。这种方法在此处讨论,因为它在典型的 LLM 工作流程中具有相关性,其中数据逐渐演变,但仍然主要基于文本和结构化。
这里有一个如何实现基于增量版本化系统的示例:
import difflib
class DeltaDatasetVersion(DatasetVersion):
def __init__(
self, data, base_version=None, metadata=None
):
super().__init__(data, metadata)
self.base_version = base_version
self.delta = self._compute_delta() if base_version else None
def _compute_delta(self):
base_data = json.dumps(
self.base_version.data, sort_keys=True).splitlines()
current_data = json.dumps(
self.data, sort_keys=True).splitlines()
diff = list(
difflib.unified_diff(
base_data, current_data, lineterm='')
)
return '\n'.join(diff)
DeltaDatasetVersion类的这部分扩展了我们的先前DatasetVersion类,以实现基于增量的版本管理。_compute_delta方法使用 Python 的difflib计算当前版本与基础版本之间的差异。这种方法可以通过仅存储更改来显著减少大型数据集的存储需求。
现在,让我们添加保存和加载这些基于增量的版本的方法:
class DeltaDatasetVersion(DatasetVersion):
# ... (previous methods)
def save(self, filename):
with open(filename, 'w') as f:
json.dump({
'metadata': self.metadata,
'timestamp': self.timestamp,
'version_hash': self.version_hash,
'base_version_hash': (
self.base_version.version_hash
if self.base_version else None
),
'delta': self.delta
}, f, indent=2)
@classmethod
def load(cls, filename, base_version):
with open(filename, 'r') as f:
data = json.load(f)
# Apply delta to base version
base_data = json.dumps(
base_version.data, sort_keys=True
).splitlines()
patched_data = difflib.restore(
base_data, data['delta'].splitlines(), 1
)
current_data = json.loads('\n'.join(patched_data))
instance = cls(current_data, base_version, data['metadata'])
instance.timestamp = data['timestamp']
instance.version_hash = data['version_hash']
instance.delta = data['delta']
return instance
save 方法现在只存储增量数据和元数据,显著减少了大型数据集的文件大小。load 方法通过将增量应用于基础版本来重建完整数据集。这种方法允许高效地存储和检索大型语言数据集的多个版本。
数据版本管理工具
虽然自定义解决方案可能有效,但还有专门为机器学习项目中的数据版本管理设计的工具。其中一个这样的工具是数据版本控制(DVC),它与 Git 集成,并为管理大型数据集和机器学习工件提供了强大的功能,并且被广泛使用。DVC 是一个开源工具,它扩展了 Git 以管理大型数据集和机器学习工件,通过在外部存储中存储数据,同时在 Git 仓库中跟踪元数据。它使可重复的管道、高效的数据共享和实验跟踪成为可能,使其成为管理 LLM 数据集和训练工作流程的热门选择。
由于 LLM 模型规模庞大,DVC 的版本化方法必须仔细平衡全面的跟踪与计算效率,需要智能的校验和元数据计算策略,以最小化延迟和处理开销,防止版本管理成为模型开发工作流程的瓶颈。
这里有一个如何在您的 LLM 项目中使用 DVC 的示例:
import subprocess
def initialize_dvc():
subprocess.run(["dvc", "init"])
print("DVC initialized in the current directory.")
def add_dataset_to_dvc(dataset_path):
subprocess.run(["dvc", "add", dataset_path])
print(f"Dataset {dataset_path} added to DVC.")
def commit_dataset_version(message):
subprocess.run(["git", "add", ".dvc"])
subprocess.run(["git", "commit", "-m", message])
print(f"Dataset version committed with message: {message}")
此脚本部分演示了如何初始化 DVC,将数据集添加到 DVC 跟踪中,并提交数据集的新版本。DVC 与 Git 一起工作,允许你以与版本化代码类似的方式版本化你的数据。
与 Git 类似,DVC 使用 init、add、commit 和 push 命令。以下列表简要描述了每个命令:
-
dvc init:通过在项目中创建.dvc目录并设置必要的元数据跟踪基础设施来初始化新的 DVC 项目。这类似于git init,但专门用于数据版本控制,为跟踪大型数据集和模型文件准备你的项目。 -
dvc add:将大型数据文件添加到 DVC 跟踪中,创建一个轻量级的.dvc元数据文件,其中包含文件的哈希值。此命令将实际数据移动到单独的存储位置,同时在你的 Git 仓库中保持引用,允许你在不膨胀 Git 仓库的情况下对大型文件进行版本控制。 -
dvc commit:创建当前跟踪数据文件的快照,类似于 Git 提交,但专门用于数据文件。此命令帮助你标记数据历史的显著点,并创建一个清晰记录,说明数据集何时以及如何更改。 -
dvc push:将你的跟踪数据文件上传到远程存储位置(如云存储、网络驱动器或本地外部存储)。此命令确保你的数据版本安全备份,并且可以被其他团队成员或不同开发环境检索。
现在,让我们添加一个函数将数据集推送到远程存储:
def push_dataset_to_remote():
subprocess.run(["dvc", "push"])
subprocess.run(["git", "push"])
print("Dataset pushed to remote storage.")
# Usage example
if __name__ == "__main__":
initialize_dvc()
add_dataset_to_dvc("path/to/your/large_language_dataset.txt")
commit_dataset_version("Add initial version of language dataset")
push_dataset_to_remote()
push_dataset_to_remote 函数将 DVC 跟踪的数据和 Git 仓库推送到各自的远程存储位置。这允许你将大型数据集与代码仓库分开存储,同时保持对两者的版本控制。
接下来,我们将专注于在训练工作流程中集成数据版本控制。
在训练工作流程中集成数据版本控制
要使数据版本控制成为你 LLM 训练工作流程的组成部分,你需要将版本检查和记录集成到你的训练脚本中。以下是一个示例,说明你可能如何做到这一点:
import json
from dataclasses import dataclass
from typing import Dict, Any
@dataclass
class DatasetInfo:
version_hash: str
metadata: Dict[str, Any]
def load_dataset_info(filename: str) -> DatasetInfo:
with open(filename, 'r') as f:
data = json.load(f)
return DatasetInfo(data['version_hash'], data['metadata'])
def train_llm(model, dataset, dataset_info: DatasetInfo):
# Log dataset version information
print(
f"Training model with dataset version: "
f"{dataset_info.version_hash}"
)
print(f"Dataset metadata: {dataset_info.metadata}")
# Actual training code would go here
# ...
# Save model with dataset version information
model.save(f"model_trained_on_{dataset_info.version_hash[:8]}.pt")
此代码片段展示了如何将数据集版本信息集成到你的 LLM 训练工作流程中。DatasetInfo 类封装了基本版本信息,而 load_dataset_info 函数从 JSON 文件中检索这些信息。train_llm 函数演示了如何在训练期间记录数据集版本和元数据,确保每个训练模型都与特定版本的数据相关联。
在训练脚本中,你可能这样使用它:
# Usage in training script
dataset_info = load_dataset_info("dataset_info.json")
dataset = load_dataset() # Your dataset loading function
model = initialize_model() # Your model initialization function
train_llm(model, dataset, dataset_info)
通过将数据集版本信息集成到你的训练过程中,你增强了可重复性,并使跟踪每个训练模型使用的数据版本变得更容易。
文本语料库的版本控制
当处理用于 LLM 训练的文本语料库时,你经常需要处理大量文档。以下是一个使用文件哈希和元数据跟踪的组合方法来对文本语料库进行版本控制的方法:
import os
import hashlib
from typing import Dict, List
def hash_file(filepath: str) -> str:
with open(filepath, 'rb') as f:
return hashlib.sha256(f.read()).hexdigest()
def generate_corpus_manifest(corpus_dir: str) -> Dict[str, str]:
manifest = {}
for root, _, files in os.walk(corpus_dir):
for file in files:
filepath = os.path.join(root, file)
manifest[os.path.relpath(filepath, corpus_dir)] = \
hash_file(filepath)
return manifest
这部分代码定义了函数来对单个文件进行哈希处理并生成语料库目录中所有文件的清单。清单是一个将相对文件路径映射到其对应哈希值的字典,提供了整个语料库的快照。清单文件很重要,因为它作为整个数据集的紧凑、可重复的指纹,使得快速完整性检查、促进版本跟踪,并允许研究人员在不同的环境或时间点验证其语料库的确切状态,而无需存储或传输整个大型数据集。
现在,让我们添加一个函数来比较两个清单并识别变化:
def compare_manifests(
old_manifest: Dict[str, str], new_manifest: Dict[str, str]
) -> Dict[str, List[str]]:
changes = {
"added": [],
"removed": [],
"modified": []
}
for file, hash in new_manifest.items():
if file not in old_manifest:
changes["added"].append(file)
elif old_manifest[file] != hash:
changes["modified"].append(file)
for file in old_manifest:
if file not in new_manifest:
changes["removed"].append(file)
return changes
# Usage example
old_manifest = generate_corpus_manifest("path/to/old_corpus")
new_manifest = generate_corpus_manifest("path/to/new_corpus")
changes = compare_manifests(old_manifest, new_manifest)
print("Corpus changes:")
for change_type, files in changes.items():
print(f"{change_type.capitalize()}:")
for file in files:
print(f" - {file}")
compare_manifests 函数识别语料库两个版本之间添加的、删除的和修改的文件。这种方法允许你有效地跟踪文本语料库中的变化,即使处理大量文件也是如此。
管理数据集变体和实验
在 LLM 开发中,你经常需要管理数据集的多个变体以进行不同的实验。以下是一个简单的系统来管理数据集变体:
from typing import Dict, Any
import json
import os
class DatasetVariantManager:
def __init__(self, base_path: str):
self.base_path = base_path
self.variants: Dict[str, Dict[str, Any]] = {}
self._load_variants()
def _load_variants(self):
if os.path.exists(
os.path.join(self.base_path, "variants.json")
):
with open(
os.path.join(self.base_path, "variants.json"), 'r'
) as f:
self.variants = json.load(f)
def save_variants(self):
with open(
os.path.join(self.base_path, "variants.json"), 'w'
) as f:
json.dump(self.variants, f, indent=2)
DatasetVariantManager 类的这一部分设置了管理数据集变体的基本结构。它使用基础路径初始化管理器,并在可用的情况下从 JSON 文件中加载现有变体。
现在,让我们添加创建和检索变体的方法:
class DatasetVariantManager:
# ... (previous methods)
def create_variant(
self, name: str, base_variant: str, changes: Dict[str, Any]
):
if name in self.variants:
raise ValueError(f"Variant {name} already exists")
self.variants[name] = {
"base": base_variant,
"changes": changes
}
self.save_variants()
def get_variant(self, name: str) -> Dict[str, Any]:
if name not in self.variants:
raise ValueError(f"Variant {name} does not exist")
variant = self.variants[name]
base_data = self.get_variant(variant["base"])
if variant["base"] else {}
return {base_data, variant["changes"]}
# Usage example
manager = DatasetVariantManager("path/to/dataset/variants")
manager.create_variant(
"base", None, {"size": 1000000, "language": "en"})
manager.create_variant("large", "base", {"size": 5000000})
manager.create_variant(
"multilingual", "large", {"language": ["en", "es", "fr"]})
print(manager.get_variant("multilingual"))
create_variant 方法允许你根据现有数据集创建新的数据集变体,只需指定更改即可。get_variant 方法检索一个变体,递归地应用其基础变体的所有更改。这个系统允许你有效地管理和跟踪数据集的不同配置,以进行各种实验。
在 LLM 开发中管理数据集变体时,建议使用清晰和一致的名字约定,以确保可追溯性、可重复性和清晰性。以下是一个建议的名字约定,它平衡了可读性和可扩展性,用于管理数据集变体:
<``base>_<modifier1>_<modifier2>_..._<description>
此格式使用 基础名称 来指示根数据集,后跟 修饰符 和可选的描述来指定区分变体的变化或属性。修饰符简洁且按层次顺序排列,以反映转换过程。
让我们仔细看看关键组件:
-
base或描述性名称(例如,clean或raw)。 -
修饰符:应用于基础数据的顺序变化或转换。每个修饰符反映数据集的一个方面,例如大小、语言或应用的前处理。
-
描述:一个可选部分,提供关于更改的额外上下文或详细信息,通常用于实验。
数据版本化的最佳实践
多年来,我收集了以下最佳实践:
-
对于大规模项目,使用专用的数据版本化工具,如 DVC。
-
在您的模型元数据中包含数据集版本信息。
-
对于大型数据集,使用基于 delta 的版本化以节省存储空间。
-
定期备份您的已版本化数据集。
-
为数据集版本和变体使用一致的命名约定。
-
将数据版本检查集成到您的
dvc status中,以验证是否发生了意外的修改,自动将数据集校验和与批准版本进行比较,并在检测到任何数据差异时阻止模型训练。关键步骤包括创建一个预训练验证阶段,比较当前数据集版本与预期参考版本,在检测到未经验证的数据修改时自动触发警报或停止管道,并在机器学习开发过程中维护数据集变化的全面审计记录。
摘要
在本章中,我们探讨了 LLM 开发中数据版本化的各个方面。我们实现了基本的版本化系统和针对大型数据集的基于 delta 的版本化。我们检查了 DVC 等工具以满足更高级的版本化需求。我们还探讨了将数据版本化集成到 LLM 训练工作流程中、管理文本语料库版本以及处理实验数据集变体的方法。
数据版本化是 LLM 开发中的关键实践,确保可重复性、促进协作并使模型治理更加稳健。通过实施这些技术和最佳实践,您可以显著提高 LLM 项目的可管理性和可靠性。
在即将到来的章节中,我们将探讨针对 LLMs 特别定制的数据集标注和标记技术。特别是,我们将介绍高效标注策略、质量控制措施以及将标注过程扩展以满足大型语言数据集需求的方法。
第六章:数据集标注和标签化
数据集标注是丰富数据集中原始数据的过程,通过添加信息性元数据或标签,使其对监督机器学习模型可理解和使用。这些元数据根据数据类型和预期任务而变化。对于文本数据,标注可能涉及为整个文档或特定的文本片段分配标签或类别,识别和标记实体,建立实体之间的关系,突出关键信息,以及添加语义解释。标注的目的是提供结构化信息,使模型能够学习模式并做出准确的预测或生成相关的输出。
数据集标注是一种专注于为单个数据点分配预定义的类别标签或类别的特定数据集标注类型。这通常用于分类任务,其目标是将数据分类到不同的组别中。在文本数据的背景下,标注可能涉及根据情感、主题或体裁对文档进行分类。
虽然标注为分类模型提供了至关重要的监督信号,但标注是一个更广泛的术语,它包括比简单分类更复杂的数据丰富形式。有效的数据集标注,包括适当的标注策略,对于开发能够处理各种复杂语言任务的性能优异的语言模型至关重要。
数据集标注和标签化是开发高性能模型的过程。在本章中,我们将探讨创建良好标注数据集的高级技术,这些技术可以显著影响您的大型语言模型(LLM)在各种任务上的性能。
在本章中,我们将涵盖以下主题:
-
质量标注的重要性
-
不同任务的标注策略
-
大规模文本标注的工具和平台
-
管理标注质量
-
群众外包标注 - 利益与挑战
-
半自动化标注技术
-
扩展大规模语言数据集的标注流程
质量标注的重要性
高质量的标注对于 LLM 训练的成功至关重要。它们提供了指导模型学习过程的真实信息,使模型能够理解语言的细微差别并准确执行特定任务。低质量的标注可能导致有偏见或不准确的模型,而高质量的标注可以显著提高 LLM 的性能和泛化能力。
那么,什么是高质量的标注?
高质量注释的特点是相似实例之间标签的一致性,对数据集中所有相关元素的完整覆盖,没有遗漏,并且与事实或既定标准的准确对齐——这意味着标签必须精确反映数据的真实性质,严格遵循预定的注释指南,并在边缘情况或模糊情况下保持可靠性。
让我们通过使用 spaCy 库的 命名实体识别(NER)任务来说明注释质量的影响。NER 是一种 自然语言处理(NLP)技术,它将文本中的关键信息(实体)识别和分类到预定义的类别中,例如人名、组织、地点、时间、数量、货币价值等。SpaCy 是一个流行的开源库,用于 Python 中的高级 NLP,以其效率和准确性而闻名。它提供了预训练模型,可以执行各种 NLP 任务,包括 NER、词性标注、依存句法分析等,使开发者更容易将复杂的语言处理能力集成到他们的应用程序中。
以下 Python 代码片段演示了如何以编程方式创建 spaCy 格式的 NER 任务训练数据:
import spacy
from spacy.tokens import DocBin
from spacy.training import Example
def create_training_data(texts, annotations):
nlp = spacy.blank("en")
db = DocBin()
for text, annot in zip(texts, annotations):
doc = nlp.make_doc(text)
ents = []
for start, end, label in annot:
span = doc.char_span(start, end, label=label)
if span:
ents.append(span)
doc.ents = ents
db.add(doc)
return db
texts = [
"Apple Inc. is planning to open a new store in New York.",
"Microsoft CEO Satya Nadella announced new AI features."
]
annotations = [
[(0, 9, "ORG"), (41, 49, "GPE")],
[(0, 9, "ORG"), (14, 27, "PERSON")]
]
training_data = create_training_data(texts, annotations)
training_data.to_disk("./train.spacy")
此代码使用 spaCy 创建 NER 的训练数据集。让我们分解一下:
-
我们从 spaCy 导入必要的模块,包括用于高效存储训练数据的
DocBin。 -
create_training_data函数将原始文本和注释转换为 spaCy 的训练格式:-
它创建了一个空白英语语言模型作为起点。
-
初始化一个
DocBin对象来高效地存储处理后的文档。 -
对于每个文本及其注释,我们创建一个 spaCy
Doc对象,并根据提供的注释添加实体跨度。
-
-
我们提供了两个带有相应 NER 注释的示例句子。
-
在此代码中,
doc.char_span()通过将注释中的字符级start和end位置映射到 spaCyDoc对象的实际标记边界来创建实体跨度。它将原始字符索引(例如Apple Inc.的0到9)转换为与标记边界对齐的适当的 spaCySpan对象,确保实体标签正确地附加到文档中它们所代表的精确文本序列。 -
训练数据以 spaCy 的二进制格式保存到磁盘。
这些注释的质量直接影响模型正确识别和分类实体的能力。例如,如果 Apple Inc. 被错误地标记为个人而不是组织,模型就会学会错误地将公司名称分类为个人。
不同任务的注释策略
不同的 LLM 任务需要特定的注释策略。让我们探讨一些常见任务及其注释方法:
-
datasets库:from datasets import Dataset texts = [ "This movie was fantastic!", "The service was terrible.", "The weather is nice today." ] labels = [1, 0, 2] # 1: positive, 0: negative, 2: neutral dataset = Dataset.from_dict({"text": texts, "label": labels}) print(dataset[0]) # Output: {'text': 'This movie was fantastic!', 'label': 1}以下代码创建了一个简单的数据集用于情感分析。每个文本都与一个表示其情感的标签相关联。
-
NER:对于命名实体识别(NER),我们使用实体标签标注特定的文本跨度。这里介绍一种使用BIO标签方案的方法。
BIO 标签方案
使用"B-"标记实体的起始单词,"I-"标记属于同一实体的任何后续单词,以及"O"标记不属于任何实体的单词。这种方法解决了区分相邻实体和处理多词实体的难题——例如,帮助模型理解《纽约时报》是一个单一的组织实体,或者在句子Steve Jobs met Steve Wozniak中,存在两个不同的人物实体,而不是一个或四个单独的实体。这种标签系统的简洁性和有效性使其成为机器学习识别和分类文本中命名实体的标准选择。
以下代码演示了如何使用分词器直接将文本编码成适合 transformer 模型的格式:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
text = "Apple Inc. was founded by Steve Jobs"
labels = ["B-ORG", "I-ORG", "O", "O", "O", "B-PER", "I-PER"]
tokens = tokenizer.tokenize(text)
inputs = tokenizer(text, return_tensors="pt")
print(list(zip(tokens, labels)))
此示例演示了如何为 NER 任务创建 BIO 标签。B-前缀表示实体的开始,I-表示实体的延续,而O表示任何实体的外部标记。
-
答案在上下文中的
start和end位置:context = "The capital of France is Paris. It is known for its iconic Eiffel Tower." question = "What is the capital of France?" answer = "Paris" start_idx = context.index(answer) end_idx = start_idx + len(answer) print(f"Answer: {context[start_idx:end_idx]}") print(f"Start index: {start_idx}, End index: {end_idx}")以下代码演示了如何为问答任务标注答案跨度。
现在,让我们来看看一些用于执行大规模文本标注的工具和平台。
大规模文本标注的工具和平台
数据标注是许多机器学习项目的基石,为训练和评估模型提供所需的标记数据。然而,手动标注,尤其是在大规模上,既耗时又容易出错,难以管理。这就是专业标注工具变得至关重要的地方。它们简化了流程,提高了数据质量,并提供自动化、协作以及与机器学习工作流程集成的功能,最终使大规模标注项目变得可行且高效。
Prodigy,来自 spaCy 创建者的强大商业工具,因其主动学习功能而脱颖而出。它智能地建议下一个需要标注的最具信息量的示例,显著减少了标注工作量。Prodigy 的优势在于其可定制性,允许用户使用 Python 代码定义标注工作流程,并将其无缝集成到机器学习模型中,尤其是在 spaCy 生态系统中。对于需要复杂标注任务、有预算购买高级工具且重视主动学习效率提升的项目来说,它是一个极佳的选择。
Label Studio 是一个多功能的开源选项,适用于多种数据类型,包括文本、图像、音频和视频。它友好的可视化界面和可定制的标注配置使其对所有级别的标注者都易于使用。Label Studio 还支持协作,并提供多种导出格式,使其与各种机器学习平台兼容。对于需要灵活、免费解决方案且支持多种数据类型并需要协作标注环境的项目来说,它是一个强有力的竞争者。
Doccano 是一个专门为机器学习中的文本标注设计的开源工具。它在序列标注、文本分类和序列到序列标注等任务上表现出色。Doccano 具有简单直观的界面,支持多用户,并提供 API 以与机器学习管道集成。对于仅关注文本标注且需要简单、免费解决方案并希望与现有机器学习工作流程无缝集成的项目来说,它是首选选择。
下面是一个示例,说明如何将 Doccano 的标注集成到 Python 工作流程中:
import json
from transformers import (
AutoTokenizer, AutoModelForTokenClassification)
def load_doccano_ner(file_path):
with open(file_path, 'r') as f:
data = [json.loads(line) for line in f]
return data
doccano_data = load_doccano_ner('doccano_export.jsonl')
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
model = AutoModelForTokenClassification.from_pretrained(
"bert-base-uncased")
for item in doccano_data:
text = item['text']
labels = item['labels']
# Process annotations and prepare for model input
tokens = tokenizer.tokenize(text)
ner_tags = ['O'] * len(tokens)
for start, end, label in labels:
start_token = len(tokenizer.tokenize(text[:start]))
end_token = len(tokenizer.tokenize(text[:end]))
ner_tags[start_token] = f'B-{label}'
for i in range(start_token + 1, end_token):
ner_tags[i] = f'I-{label}'
# Now you can use tokens and ner_tags for model training or inference
此代码从 Doccano 导出文件中加载 NER 标注,并将它们处理成适合训练基于 BERT 的标记分类模型的格式。以下示例中的标记和 ner_tags 显示了样本格式:
text = "The majestic Bengal tiger prowled through the Sundarbans, a habitat it shares with spotted deer."
labels = [[13, 25, "ANIMAL"], [47, 57, "GPE"], [81, 93, "ANIMAL"]]
tokens = ['The', 'majestic', 'Bengal', 'tiger', 'prowled', 'through',
'the', 'Sundarbans', ',', 'a', 'habitat', 'it', 'shares', 'with',
'spotted', 'deer', '.']
ner_tags = ['O', 'O', 'B-ANIMAL', 'I-ANIMAL', 'O', 'O', 'O', 'B-GPE',
'O', 'O', 'O', 'O', 'O', 'O', 'B-ANIMAL', 'I-ANIMAL', 'O']
此示例演示了在文本中识别和分类动物名称的命名实体识别(NER)。文本包含关于孟加拉虎和斑点鹿在恒河三角洲的句子。labels 列表提供了动物实体("Bengal tiger","spotted deer")的起始和结束索引以及它们对应的类型("ANIMAL"),以及地缘政治实体,即 "Sundarbans"("GPE")。tokens 列表是文本的词级分割。最后,ner_tags 列表表示 BIO(Begin-Inside-Outside)格式的 NER 标注,其中 "B-ANIMAL" 标记动物实体的开始,"I-ANIMAL" 标记同一动物实体内的后续单词,"B-GPE" 标记地缘政治实体的开始,而 "O" 表示不属于任何命名实体的标记。
管理标注质量
为了确保高质量的标注,我们需要实施一个强大的质量保证流程。
让我们看看一些衡量标注质量的方法:
-
-1和1,其中1表示完全一致,0表示与机会一致,负值表示低于机会的一致性。以下代码计算 Cohen 的 Kappa 系数,以量化两组分类评级之间的一致性:
from sklearn.metrics import cohen_kappa_score annotator1 = [0, 1, 2, 0, 1] annotator2 = [0, 1, 1, 0, 1] kappa = cohen_kappa_score(annotator1, annotator2) print(f"Cohen's Kappa: {kappa}") -
calculate_accuracy函数计算一组真实标签(即gold_standard)与一组预测或标注标签(即标注)之间的协议:def calculate_accuracy(gold_standard, annotations): return sum( g == a for g, a in zip( gold_standard, annotations ) ) / len(gold_standard) gold_standard = [0, 1, 2, 0, 1] annotator_result = [0, 1, 1, 0, 1] accuracy = calculate_accuracy(gold_standard, annotator_result) print(f"Accuracy: {accuracy}")虽然 Cohen 的 Kappa 和与黄金标准的准确性是基础,但其他指标可以更深入地了解标注质量。例如,Krippendorff 的 Alpha 提供了一种灵活的方法,适应各种数据类型并处理缺失数据,使其适合复杂的标注任务。在涉及多个标注者的场景中,Fleiss 的 Kappa 扩展了 Cohen 的 Kappa,提供了整个群体的一致性总体评估。
对于诸如目标检测或图像分割等任务,交并比(IoU)变得至关重要,它量化了预测和真实边界框或掩模之间的重叠。此外,特别是在处理不平衡数据集或成本更高的特定错误类型时,精确度、召回率和 F1 分数提供了细微的评价,特别适用于诸如命名实体识别(NER)等任务。
-
敏感度和特异性:这些指标,常用于医学诊断或二元分类,对于标注质量评估也很有价值。敏感性(也称为召回率或真正率)衡量的是实际正例中被正确识别的比例,而特异性(真正负率)衡量的是实际负例中被正确识别的比例。
-
均方根误差(RMSE)和平均绝对误差(MAE):对于涉及数值或连续标注的任务(例如,评分量表、边界框坐标等),RMSE 和 MAE 可以量化标注值与真实值之间的差异。RMSE 对较大误差赋予更高的权重,而 MAE 对所有误差同等对待。
-
基于时间的指标:除了标签的质量外,标注过程的效率也很重要。跟踪每条标注花费的时间,尤其是当与准确性或一致性评分相关联时,可以揭示流程改进的领域或识别可能需要额外培训的标注者。此外,分析标注时间的分布可以帮助识别异常困难或模糊的实例。
最终,对标注质量的全面方法涉及考虑一系列相关指标的组合,这些指标针对特定的任务和项目目标量身定制。定期监控、反馈循环以及指南和培训的迭代改进对于在整个标注过程中保持高标准至关重要。记住,指标的选择应与数据的性质、任务的复杂性和机器学习项目的预期结果相一致。
众包是扩展标注工作的有效替代方案。
众包标注——优势和挑战
众包可以是一种有效的扩展标注工作的方法。例如,Amazon Mechanical Turk 或 Appen(前身为 Figure Eight)等平台提供了对大量工作力的访问。然而,确保质量可能具有挑战性。以下是一个如何汇总众包标注的例子:
from collections import Counter
def aggregate_annotations(annotations):
return Counter(annotations).most_common(1)[0][0]
crowd_annotations = [
['PERSON', 'PERSON', 'ORG', 'PERSON'],
['PERSON', 'ORG', 'ORG', 'PERSON'],
['PERSON', 'PERSON', 'ORG', 'LOC']
]
aggregated = [aggregate_annotations(annot)
for annot in zip(*crowd_annotations)]
print(f"Aggregated annotations: {aggregated}")
此代码使用简单的多数投票方案来汇总多个标注者的标注。虽然这种方法在许多情况下都有效,但在票数相等的情况下需要平局处理,并且可以采用基于标注者可靠性分配权重或利用基于机器学习的协调模型等额外策略来进一步提高质量。
接下来,我们将深入了解半自动化标注技术,其中机器学习模型协助人工标注者加速标注任务。
半自动化标注技术
半自动化标注结合机器学习与人工验证以加快标注过程。以下是一个使用 spaCy 的简单示例:
import spacy
nlp = spacy.load("en_core_web_sm")
def semi_automated_ner(text):
doc = nlp(text)
return [(ent.start_char, ent.end_char, ent.label_)
for ent in doc.ents]
text = "Apple Inc. was founded by Steve Jobs in Cupertino."
auto_annotations = semi_automated_ner(text)
print(f"Auto-generated annotations: {auto_annotations}")
# Human annotator would then verify and correct these annotations
此代码使用预训练的 spaCy 模型生成初始 NER 标注,然后可以由人工标注者进行验证和纠正。
接下来,我们将探讨一些策略,以扩展标注工作流程以处理大规模语言数据集。
扩展大规模语言数据集的标注过程
对于大规模数据集,考虑以下策略:
-
分布式处理:使用如 Dask 或 PySpark 这样的库进行分布式标注处理。Dask 和 PySpark 是强大的库,可用于分布式数据标注处理,使团队能够高效地处理大规模标注任务。这些库允许您在多个核心或甚至计算机集群上并行化标注工作流程,显著加快大规模数据集的处理速度。使用 Dask,您可以扩展现有的基于 Python 的标注脚本来在分布式系统中运行,而 PySpark 在 Apache Spark 生态系统内提供强大的数据处理能力。这两个库都提供了熟悉的 API,使得从本地标注管道过渡到分布式管道变得更加容易,允许标注团队处理和管理单个机器无法处理的大型数据集。
-
主动学习:这项技术涉及根据模型不确定性或预期影响,迭代选择最具信息量的样本进行人工标注。从一个小的、已标注的数据集开始,训练一个模型,使用它来识别有价值的未标注样本,然后由人工进行标注,并更新模型。这个周期重复进行,优化标注努力并有效地提高模型性能。
这里有一个简单的主动学习示例:
import numpy as np from sklearn.ensemble import RandomForestClassifier from modAL.models import ActiveLearner # Simulated unlabeled dataset X_pool = np.random.rand(1000, 10) # Initialize active learner learner = ActiveLearner( estimator=RandomForestClassifier(), X_training=X_pool[:10], y_training=np.random.randint(0, 2, 10) ) # Active learning loop n_queries = 100 for _ in range(n_queries): query_idx, query_inst = learner.query(X_pool) # In real scenario, get human annotation here y_new = np.random.randint(0, 2, 1) learner.teach(X_pool[query_idx], y_new) X_pool = np.delete(X_pool, query_idx, axis=0) print( f"Model accuracy after active learning: " f"{learner.score( X_pool, np.random.randint(0, 2, len(X_pool)))}" )这个例子演示了一个基本的主动学习循环,其中模型选择最具信息量的样本进行标注,这可能会减少所需的总标注数量。
现在我们已经了解了某些标注技术,让我们来看看在执行标注过程中可能出现的偏差以及如何避免它们。
标注偏差及缓解策略
标注偏差是在标注过程中可能渗透到标注数据集中的系统性错误或偏见。这些偏差可能会显著影响基于这些数据训练的机器学习模型的性能和公平性,导致模型不准确或表现出歧视行为。识别和减轻这些偏差对于构建稳健和道德的 AI 系统至关重要。
标注偏差的类型包括以下:
-
选择偏差:当用于标注的数据不能代表模型在现实世界中遇到的真实数据分布时,就会发生这种情况。例如,如果用于面部识别的数据集主要包含肤色较浅的人的图像,那么在它上面训练的模型在处理肤色较深的人时可能会表现不佳。
-
标注偏差:这源于标注者的主观解释、文化背景或个人信仰。例如,在情感分析中,来自不同文化的标注者可能对相同的文本进行不同的情感极性标注。同样,标注者的个人偏见可能导致他们对某些群体或个人进行更负面或更正面的标注,而其他人则不然。
-
确认偏差:标注者可能无意识地倾向于确认他们关于数据的先入为主的信念或假设。
-
自动化偏差:过度依赖预训练模型或主动学习系统的建议可能导致标注者在没有足够审查的情况下接受错误的标签。
-
指南中的模糊性:如果标注指南不明确或不完整,可能会导致标注者之间标注不一致,将噪声和偏差引入数据集。
这里有一些减轻偏差的策略:
-
多样性和代表性数据:确保用于标注的数据既多样化又能够代表目标人群和使用案例。这可能涉及对代表性不足的群体进行过度采样或从多个来源收集数据。
-
明确和全面的指南:制定详细的标注指南,明确定义标注标准,并为每个标签提供示例。在指南中解决潜在的模糊性和边缘情况。根据标注者的反馈和新兴问题定期审查和更新指南。
-
标注者培训和校准:对标注者进行关于任务、指南以及他们应该意识到的潜在偏差的全面培训。进行校准会议,让标注者对相同的数据进行标注并讨论任何差异,以确保一致性。
-
多个标注者和标注者间一致性:对每个数据点使用多个标注者,并使用如 Cohen 的 Kappa 或 Fleiss 的 Kappa 等指标来衡量标注者间一致性(IAA)。高 IAA 表明一致性良好,而低 IAA 则表明指南、培训或任务本身存在问题。
-
裁决过程:建立一个解决标注员之间分歧的程序。这可能涉及让资深标注员或专家审查并做出最终决定。
-
具有偏差意识的主动学习:在使用主动学习时,要留意模型建议中可能存在的偏差。鼓励标注员批判性地评估建议,而不是盲目接受。
-
偏差审计和评估:定期审计标记数据和训练模型以识别潜在的偏差。评估模型在不同人口群体或类别中的性能,以识别任何差异。
-
多元化的标注团队:组建具有不同背景、观点和经验的标注团队,以减轻个人偏差的影响。
通过实施这些缓解策略,您可以显著减少标注偏差的影响,从而实现更准确、公平和可靠的机器学习模型。重要的是要记住,偏差缓解是一个持续的过程,需要在整个机器学习生命周期中持续监控、评估和改进。
摘要
从这个设计模式中,您了解了 LLM 开发中数据集标注和标记的高级技术。您现在理解了高质量标注在提高模型性能和泛化能力中的关键重要性。您对各种 LLM 任务的标注策略有了深入了解,包括文本分类、命名实体识别和问答。
在本章中,我们向您介绍了用于大规模文本标注的工具和平台、管理标注质量的方法以及众包标注的优缺点。您还了解了半自动化标注技术和用于大规模语言数据集标注过程扩展的策略,例如分布式处理和主动学习。我们通过使用 spaCy、transformers 和 scikit-learn 等库提供了实际示例,这有助于您掌握关键概念和实现方法。
在下一章中,您将探索如何构建用于训练 LLM 的高效和可扩展的管道。这包括探索数据预处理的最佳实践、设计模型架构的关键考虑因素以及优化性能和可扩展性的策略。
第二部分:大型语言模型的训练和优化
本部分深入探讨了有效训练和优化大型语言模型所需的过程。我们将引导您设计既模块化又可扩展的稳健训练流程。您将学习如何调整超参数以最大化性能,实施正则化技术以稳定训练,并集成高效的检查点和恢复方法以支持长时间运行的训练会话。此外,我们还将探讨高级主题,如剪枝和量化,这些技术可以帮助您在不牺牲性能的情况下减小模型大小和计算需求。此外,还将详细介绍微调技术,这些技术用于将预训练模型适应特定任务或领域。到本部分结束时,您将具备构建、训练和优化能够应对现实应用挑战的大型语言模型的能力。
本部分包含以下章节:
-
第七章, 训练流程
-
第八章, 超参数调整
-
第九章, 正则化
-
第十章, 检查点和恢复
-
第十一章, 微调
-
第十二章, 模型剪枝
-
第十三章, 量化
第七章:训练管道
在本章中,我们将探讨 LLM 训练管道的关键组成部分,从数据摄取和预处理到模型架构和优化策略。
你将深入了解实施有效的监控和记录系统,确保你可以在整个训练过程中跟踪你的模型进度并做出数据驱动的决策。
在本章中,我们将涵盖以下主题:
-
训练管道的组成部分
-
数据输入和预处理
-
LLM 架构设计考虑因素
-
损失函数和优化策略
-
记录
-
管道模块化和可重用性
-
扩展你的训练管道以适应更大的模型
训练管道的组成部分
LLM 训练管道由几个相互关联的步骤组成,每个步骤在模型的发展中扮演着角色。我们将在这里展示一个基本管道,并在本章的后续部分深入探讨许多这些组件:
-
数据集创建:将预处理数据构建成适合训练的格式,通常涉及洗牌和分批处理。
-
模型架构:定义了 LLM 的结构,包括层数、注意力机制和其他架构选择。
-
训练循环:管道的核心,模型通过正向和反向传递从数据中学习。
-
优化:根据计算出的梯度和选择的优化策略处理参数更新。
-
评估:定期评估模型在验证数据上的性能,以跟踪进度并防止过拟合。我们将在 第十四章 中更详细地讨论这个主题。
-
检查点:定期保存模型状态以恢复训练或用于推理。我们将在 第十章 中详细讨论这个主题。
-
记录和监控:持续跟踪训练指标和资源利用率。
我们将使用 PyTorch 和 Transformers 库实现一个基本的 LLM 训练管道:
from torch.utils.data import DataLoader
from transformers import (
AutoTokenizer, AutoModelForCausalLM, AdamW,
get_linear_schedule_with_warmup
from datasets import load_dataset
import torch
from torch.nn import functional as F
import wandb
PyTorch 是一个流行的深度学习框架,它通过动态计算图允许构建神经网络,而 Transformers 库实现了我们在 第一章 中讨论的流行变压器架构。
以下代码块展示了使用预训练的 GPT-2 分词器加载维基百科数据集并对其文本内容进行分词的过程:
# Dataset Creation: Ingestion and Preprocessing
dataset = load_dataset("wikipedia", "20220301.en", split="train")
tokenizer = AutoTokenizer.from_pretrained("gpt2")
def preprocess_function(examples):
return tokenizer(examples["text"], truncation=True,
max_length=512, padding="max_length")
tokenized_dataset = dataset.map(preprocess_function,
batched=True, remove_columns=dataset.column_names)
在前面的代码块中,我们正在设置管道的数据摄取和预处理组件。我们使用 Hugging Face Datasets 库加载维基百科数据集,它提供了一个适合训练 LLM 的大型文本语料库。然后,我们初始化一个基于 GPT-2 模型的分词器,它将被用于预处理我们的文本数据。
上文定义的preprocess_function函数将原始文本示例进行分词,截断到最大 512 个 token 的长度,并将较短的序列填充到这个长度。这确保了所有输入序列的长度相同,这对于高效的批量处理是必要的。我们选择max_length值为512,这是在上下文长度和内存效率之间的平衡。较长的序列提供更多的上下文,但需要更多的内存和计算。一些最近的 LLM 模型,如Gemini 1.5 Pro,其内容长度可以达到多达 200 万个 token(cloud.google.com/vertex-ai/generative-ai/docs/long-context)。
接下来,我们创建我们的训练数据加载器,它将在训练过程中处理数据集的批处理和打乱:
# Dataset Creation: Loading
train_dataloader = DataLoader(
tokenized_dataset, shuffle=True, batch_size=8)
我们将批大小设置为8,这是在内存使用和训练效率之间做出的平衡选择。更大的批大小可以加快训练速度,但需要更多的 GPU 内存。对于具有大量参数的 LLM,通常需要较小的批大小才能将模型和数据放入 GPU 内存中。
然后,我们使用预训练的 GPT-2 模型初始化我们的模型架构。这为我们 LLM 提供了一个强大的起点,利用了预训练权重中已经捕获的知识。使用预训练模型作为起点是迁移学习中的一种常见做法,使我们能够从模型在大量文本语料库上学习到的通用语言理解中受益。以下代码展示了这一过程:
# Model Architecture
model = AutoModelForCausalLM.from_pretrained("gpt2")
# Optimization
optimizer = AdamW(model.parameters(), lr=5e-5)
如前述代码所示,为了优化,我们将学习率lr设置为5e-5,这是微调预训练模型时的一个常见选择。学习率是一个超参数,它决定了在训练过程中对模型权重进行调整的大小,影响着模型学习的速度和效率。
这个学习率在学习和稳定性之间提供了良好的平衡。它足够小,可以允许对预训练权重进行精细的更新,但又足够大,以允许有意义的学习发生。
下面的代码块概述了训练语言模型的基本阶段,包括设置训练过程、初始化日志工具、执行带有正向和反向传递的主训练循环、执行评估以评估模型性能,以及在训练过程中保存模型参数的检查点。
-
我们首先设置训练循环:
num_epochs = 3 num_training_steps = num_epochs * len(train_dataloader) lr_scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=100, num_training_steps=num_training_steps) -
然后,我们初始化 Weights & Biases (
wandb)库以进行实验跟踪和训练指标的日志记录:wandb.init(project="llm_training", name="gpt2_finetune") device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) for epoch in range(num_epochs): model.train() for batch in train_dataloader: batch = {k: v.to(device) for k, v in batch.items()} outputs = model(batch) loss = outputs.loss loss.backward() optimizer.step() lr_scheduler.step() optimizer.zero_grad() wandb.log({"loss": loss.item()}) -
接下来,我们实现一个评估阶段来评估模型在训练数据上的性能:
model.eval() eval_loss = 0 with torch.no_grad(): for batch in train_dataloader: # Using training data for simplicity batch = {k: v.to(device) for k, v in batch.items()} outputs = model(batch) eval_loss += outputs.loss.item() eval_loss /= len(train_dataloader) wandb.log({"eval_loss": eval_loss}) -
最后,在每个 epoch 结束时,我们保存模型状态字典的检查点:
torch.save(model.state_dict(), f"model_checkpoint_epoch_{epoch}.pt") wandb.finish()
这些代码片段实现了我们流水线中的训练循环、评估、检查点和日志记录组件:
我们将训练时代数设置为3,这意味着模型将在训练期间遍历整个数据集三次。这个超参数可以根据你的具体需求进行调整——如果模型欠拟合,增加它可能会导致更好的模型性能,而减少它可以帮助防止过拟合并减少训练时间。在训练期间监控验证损失,以确定特定数据集和模型架构的最佳时代数。
学习率调度器实现了一个带有预热阶段的线性衰减,这有助于在训练的早期阶段稳定训练,然后逐渐降低学习率以更精确地微调模型。学习率控制模型在训练期间调整其内部参数的程度——较高的速率意味着更大的调整但可能存在过度调整的风险,而较低的速率意味着更精确但学习速度较慢。
我们使用wandb进行记录,这允许我们实时跟踪我们的训练进度并比较不同的运行(wandb.ai/site)。这对于监控训练过程和在超参数调整和模型架构更改方面做出明智的决定至关重要。
训练循环遍历指定数量的时代数据。在每次迭代中,我们执行以下操作:
-
将批次移动到适当的设备(如果有 GPU 则使用 GPU)
-
通过模型执行正向传递
-
计算损失
-
执行反向传播
-
更新模型参数
-
更新学习率调度器
-
记录训练损失
在每个时代之后,我们执行对训练数据的简单评估(在实际场景中,你会使用一个单独的验证集),记录评估损失,并保存模型的检查点。检查点对于长时间运行的训练过程是必需的,允许我们在需要时从保存的状态恢复训练。
正如我们所见,训练管道涉及几个基本步骤。然而,在模型架构和训练循环可以有效地运行之前,我们必须解决数据输入和预处理问题,我们将在下一节中讨论。
数据输入和预处理
高效的数据处理对于 LLM 训练至关重要,正如我们在本书的第一部分中讨论的那样。在这里,让我们探讨数据输入和预处理的高级技术:
-
导入所需的 Python 包:
from datasets import load_dataset, concatenate_datasets from transformers import AutoTokenizer from torch.utils.data import DataLoader import numpy as np -
加载和组合多个数据集:
wiki_dataset = load_dataset("wikipedia", "20220301.en", split="train") books_dataset = load_dataset("bookcorpus", split="train") # Combine datasets combined_dataset = concatenate_ datasets([wiki_dataset, books_dataset]) -
初始化分词器并执行
preprocess:tokenizer = AutoTokenizer.from_pretrained("gpt2") def preprocess_function(examples): # Tokenize the texts tokenized = tokenizer( examples["text"], truncation=True, max_length=1024) # Create input_ids and attention_mask input_ids = tokenized["input_ids"] attention_mask = tokenized["attention_mask"] # Create labels for causal language modeling labels = [ ids[1:] + [tokenizer.eos_token_id] for ids in input_ids] return {"input_ids": input_ids, "attention_mask": attention_mask, "labels": labels} # Apply preprocessing tokenized_dataset = combined_dataset.map( preprocess_function, batched=True, remove_columns=combined_dataset.column_names, num_proc=4 # Adjust based on your CPU cores ) -
创建数据加载器:
train_dataloader = DataLoader( tokenized_dataset, shuffle=True, batch_size=16, collate_fn=lambda x: {k: np.stack([xi[k] for xi in x]) for k in x[0]} )
在这个增强的预处理管道中,我们正在加载多个数据集以增加我们训练数据的多样性。这对于 LLM 是必要的,因为多样化的数据集有助于模型学习更广泛的语言模式和知识。
我们使用较长的max_length值为1024个标记,为模型提供更多上下文。这种增加的上下文长度允许模型捕获文本中的长距离依赖关系,这对许多语言理解任务可能有益。然而,这也增加了内存使用和计算需求,因此需要权衡考虑。
preprocess_function现在通过移位输入序列为因果语言建模创建标签。这是训练语言模型的一种常见方法,其中模型的任务是预测给定前一个标记的下一个标记。在预处理过程中,处理边缘情况,如表情符号、URL 和非标准字符,可以提高模型性能。表情符号可以传达细微的情感和上下文,需要适当的编码或标记化以保留其含义而不引入噪声。URL 通常包含有价值的信息,但结构可能差异很大,因此可能用占位符标记替换以保持一致性,同时防止模型过度拟合到特定链接。非标准字符,包括来自不同语言的符号或特殊标点符号,需要仔细归一化或删除以减少复杂性和避免训练时的混淆。通过采用归一化、标记替换和选择性过滤等策略解决这些边缘情况,预处理管道可以更好地准备多样化和复杂的数据,从而提高结果语言模型的鲁棒性和准确性。
我们使用多进程(num_proc=4)来加速预处理。进程数应根据您的 CPU 核心数和可用内存进行调整。多进程可以显著减少预处理时间,特别是对于大型数据集。
批处理大小增加到16,这更适合较大的 GPU 内存。DataLoader 中的自定义collate_fn确保了我们的预处理数据的正确批处理。此函数将批处理中每个键的数组堆叠,创建出可以被 PyTorch 高效处理的张量结构。
在数据适当准备后,我们现在将注意力转向 LLM 架构设计考虑因素,这些因素决定了模型有效学习并理解数据输入的能力。
LLM 架构设计考虑因素
在设计 LLM 的架构时,有几个因素需要考虑。
影响 LLM 架构的关键因素如下:
-
词汇量大小:决定了输入和输出嵌入层的大小
-
最大序列长度(上下文大小):定义了模型可以考虑的先前文本的数量
-
嵌入维度:指定每个标记的向量表示的大小,影响模型捕获信息的能力
-
transformer 层数量:代表网络的深度,影响模型可以学习的模式复杂性
-
注意力头数量:允许模型同时关注输入的不同部分
-
模型大小(参数数量):模型的整体容量,受嵌入维度、层数和注意力头数的影响
-
数据集大小:训练数据的数量和多样性
-
训练步数数量:优化过程的持续时间
-
计算资源:影响模型大小、训练速度和整体可行性的硬件限制。
-
过拟合风险:随着模型规模增大和数据集减小而增加
-
数据质量:训练数据的清洁度和相关性
-
模型架构效率:可以在不大幅增加模型大小的情况下提高性能的设计选择
-
训练算法:优化技术和策略
-
数据整理实践:选择和准备训练数据的方法
-
推理时间计算资源:推理过程中可用的计算资源
在以下代码块中,我们提供了使用 GPT-2 风格的语言模型配置一些这些因素的示例,指定关键架构参数。
from transformers import GPT2Config, GPT2LMHeadModel
# Define custom model configuration
config = GPT2Config(
vocab_size=50257, # GPT-2 vocabulary size
n_positions=1024, # Maximum sequence length
n_ctx=1024, # Context size
n_embd=768, # Embedding dimension
n_layer=12, # Number of transformer layers
n_head=12 # Number of attention heads
)
# Initialize the model with custom configuration
model = GPT2LMHeadModel(config)
print(f"Model parameters: {model.num_parameters():,}")
此配置创建了一个具有 12 层和 12 个注意力头的 GPT-2 风格模型。让我们分解关键参数:
-
vocab_size:设置为50257,这是原始 GPT-2 模型的词汇量。这决定了嵌入层和输出层的大小。 -
n_positions和n_ctx:两者都设置为1024,与我们的预处理步骤相匹配。这定义了模型可以处理的最大序列长度。 -
n_embd:嵌入维度,设置为768。这决定了模型中隐藏状态的大小。 -
n_layer:transformer 层数的数量,设置为12。更多的层可以捕捉更复杂的模式,但会增加计算需求。 -
n_head:注意力头的数量,设置为12。多个注意力头允许模型同时关注输入的不同方面。
768 的嵌入维度和 12 层提供了在模型容量和计算效率之间的平衡折衷。这种配置产生了一个大约有 1.24 亿个参数的模型,这相当大,但仍然可以在常见的 GPU 硬件上训练。
对于更大的模型,你可能需要增加 n_layer、n_embd 和 n_head。然而,这也会增加计算需求以及过拟合的风险,尤其是在较小的数据集上。在扩展规模时,考虑使用梯度累积、混合精度训练和分布式训练等技术来管理增加的计算负载。
在更广泛的范围内,可以考虑扩展定律。LLM 的扩展定律描述了随着三个关键因素的提高,性能如何可预测地提升:模型大小(参数数量)、数据集大小(训练数据量)和训练步数(优化迭代次数)。具体来说,更大的模型倾向于捕捉更复杂的模式并表现出更好的泛化能力,更大的数据集为学习提供了更多样化的信息,更多的训练步数允许模型细化其理解并减少错误。为了获得最佳性能,这些因素应该成比例扩展——例如,增加模型大小应该与数据集大小和训练步数的相应增加相匹配。这种平衡扩展确保每个组件都支持其他组件,防止了诸如在庞大的数据集上过度拟合较小模型或用不足的数据训练大型模型等瓶颈问题。
然而,最近的发展和实际挑战表明,仅仅扩展这些因素并不总是足以实现持续的性能提升。例如,边际效益递减的问题,即每个额外的参数或数据点对整体性能的贡献越来越少,变得更加明显。此外,训练越来越大的模型所需的巨大计算和能源资源引发了可持续性和可访问性的担忧。数据质量也成为了一个关键因素,因为更大的数据集可能会引入更多的噪声和偏差,从而降低模型性能。关于这方面的更多细节,请参阅www.pcgamer.com/software/ai/open-ai-co-founder-reckons-ai-training-has-hit-a-wall-forcing-ai-labs-to-train-their-models-smarter-not-just-bigger/上的文章。
为了应对这些挑战,研究人员正在探索更有效的模型架构、改进的训练算法、更好的数据整理实践以及测试时间计算。有关测试时间计算的更多细节,请参阅我的 Medium 文章:kenhuangus.medium.com/test-time-compute-3633a4c55716。
2025 年初,DeepSeek(一家中国的人工智能初创公司)通过引入一系列旨在显著提高效率和降低成本的同时增强模型推理能力的技术的套件,宣布了一些模型训练创新(arxiv.org/abs/2501.12948)。与严重依赖大量计算资源和人工监督微调的传统方法不同,DeepSeek 利用针对推理任务的大规模强化学习,使用自动奖励系统而不是人类反馈。关键创新包括多标记预测,这使得模型能够一次学习多个未来的标记,从而提高样本效率并加快训练速度。DeepSeek 还采用专家混合架构,只为每个任务激活相关的子网络,从而减少计算负载。通过优化算法和硬件,DeepSeek 已经能够以竞争对手所需成本和时间的一小部分训练出高度能干的模型,为开放、高效和强大的 AI 开发设定了新的标准。
在探讨了 LLMs 的架构设计考虑因素和模型训练创新,以及一个演示如何配置模型训练参数的代码示例之后,我们现在准备检查这些架构选择在训练过程中是如何实际学习的。在接下来的这一节中,我们将讨论损失函数和优化策略,它们是推动模型根据训练数据和我们所定义的架构调整其内部参数的引擎。
损失函数和优化策略
LLMs 通常使用 交叉熵损失 进行训练。这种方法衡量模型预测的单词概率分布与训练数据中实际分布之间的差异。通过最小化这种损失,LLMs 学习生成更准确和上下文相关的文本。由于交叉熵损失能够处理文本数据的高维性和离散性,因此它特别适合语言任务。
让我们结合一些高级优化技术来实现这个功能:
-
首先,我们导入所需的 PyTorch 库以及来自 Transformers 库的特定模块以进行优化:
import torch from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup -
接下来,我们配置 AdamW 优化器,指定学习率和权重衰减:
optimizer = AdamW(model.parameters(), lr=5e-5, weight_decay=0.01) -
然后,我们定义一个具有预热期的线性学习率调度器:
num_epochs = 3 total_steps = len(train_dataloader) * num_epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=100, num_training_steps=total_steps ) -
接着,我们设置训练设备和启动主训练循环:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) for epoch in range(num_epochs): model.train() for batch in train_dataloader: batch = {k: torch.tensor(v).to(device) for k, v in batch.items() } outputs = model(batch) loss = outputs.loss loss.backward() -
最后,我们实现梯度裁剪以防止训练过程中的梯度爆炸:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad()
在这个优化设置中,我们使用学习率为5e-5和权重衰减为0.01的AdamW优化器。该算法根据梯度的第一和第二矩来调整每个参数的学习率,使其能够有效地处理稀疏梯度。这使得AdamW对于训练大型神经网络特别有用。
权重衰减0.01向损失函数中添加了一个小的正则化项,这可以通过惩罚大的权重值来帮助防止过拟合。
我们实现了一个预热阶段。预热阶段通过逐渐增加学习率从一个非常小的值来帮助在早期阶段稳定训练。在预热阶段之后,学习率线性下降。这种时间表可以帮助模型收敛到一个更好的最优解。
在训练循环中,我们实现了max_norm值为1.0。梯度裁剪通过缩小超过某个阈值的梯度值来防止梯度爆炸。这对于 LLM 尤其重要,因为 LLM 由于其深度和捕获的长距离依赖性,可能会出现不稳定的梯度。
在本节中,我们学习了关于 AdamW 优化、带有预热的学习率调度以及梯度裁剪以稳定 LLM 训练的内容。接下来,我们将讨论记录训练过程,这对于监控进度和使用如TensorBoard等工具以获得改进的见解至关重要。
日志记录
有效的日志记录对于跟踪 LLM 训练的进度非常有用。
以下代码块演示了如何使用 PyTorch 在训练 LLM 时集成 TensorBoard 以进行有效的日志记录。让我们分解每个部分。
-
我们首先初始化 TensorBoard 的
SummaryWriter以记录训练进度:from torch.utils.tensorboard import SummaryWriter import time # Initialize TensorBoard writer writer = SummaryWriter() -
然后,我们将模型设置为训练模式,初始化跟踪损失的变量,定义日志间隔,并记录开始时间以监控训练性能:
model.train() total_loss = 0 log_interval = 100 start_time = time.time() -
然后,我们进入训练循环。我们通过将数据移动到适当的设备,执行正向和反向传递,应用梯度裁剪,并使用优化器和调度器更新模型的参数来处理每个批次:
for i, batch in enumerate(train_dataloader): batch = {k: torch.tensor(v).to(device) for k, v in batch.items()} outputs = model(batch) loss = outputs.loss total_loss += loss.item() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad() -
我们在指定的间隔将训练指标记录到 TensorBoard 中,计算平均损失,测量经过的时间,将进度打印到控制台,并为下一个间隔重置跟踪变量:
if (i + 1) % log_interval == 0: cur_loss = total_loss / log_interval elapsed = time.time() - start_time writer.add_scalar( 'training_loss', cur_loss, global_step=i ) writer.add_scalar( 'learning_rate', scheduler.get_last_lr()[0], global_step=i ) print( f'| epoch {epoch:3d} ' f'| {i:5d}/{len(train_dataloader):5d} batches | ' f'lr {scheduler.get_last_lr()[0]:02.2f} | ' f'ms/batch {elapsed * 1000 / log_interval:5.2f} | ' f'loss {cur_loss:5.2f}' ) total_loss = 0 start_time = time.time() writer.close()这个增强的训练循环使用 TensorBoard 来记录训练损失和学习率。TensorBoard 是一个强大的工具,用于可视化训练进度和比较不同的运行。我们记录以下指标:
-
log_interval批次。此指标呈下降趋势表明模型正在学习。 -
学习率:我们记录当前的学习率,以可视化由于我们的学习率调度器而随时间变化的情况。
-
我们将log_interval设置为100,这意味着我们每 100 个批次记录和打印进度信息。这个间隔在获取频繁更新和不过度减慢训练速度之间取得了平衡。您可能需要根据您的数据集大小和训练速度进行调整。
输出或日志信息包括以下内容:
-
当前周期和批次号
-
当前学习率
-
每批次的耗时(以毫秒为单位)
-
当前损失
这种详细的日志记录允许您密切监控训练过程,帮助您识别不稳定损失、学习率问题或意外缓慢的训练等问题。
管道模块化和可重用性
模块化和可重用性是构建高效管道的基本原则,因为它们使代码更易于维护、适应和可靠。通过将管道分解为独立的、可重用的模块(如数据预处理、模型训练和评估组件),开发者可以轻松修改单个部分而不影响其他部分,单独测试每个组件,并在不同项目之间重用经过验证的代码。
这种方法不仅节省了开发时间,还确保了操作的连续性,减少了出错的机会,并使团队在维护组件之间清晰接口的同时,通过在单独的模块上工作而更容易协作。在训练管道的情况下,将过程封装在可重用类中允许灵活配置,与不同数据集的无缝集成,以及在不同项目之间轻松共享标准化实现。
为了使我们的管道更加模块化和可重用,让我们将我们的训练过程封装在一个类中:
-
我们从类定义开始:
class LLMTrainer: def __init__(self, model, train_dataloader, optimizer, scheduler, device ): self.model = model self.train_dataloader = train_dataloader self.optimizer = optimizer self.scheduler = scheduler self.device = device self.writer = SummaryWriter() -
然后,我们定义训练周期函数。该函数将模型设置为训练模式,并遍历训练数据,通过计算损失、执行梯度裁剪的逆向传播以及使用优化器和调度器更新模型参数来处理每个批次:
def train_epoch(self): self.model.train() total_loss = 0 log_interval = 100 start_time = time.time() for i, batch in enumerate(self.train_dataloader): batch = {k: torch.tensor(v).to(self.device) for k, v in batch.items() } outputs = self.model(batch) loss = outputs.loss total_loss += loss.item() loss.backward() torch.nn.utils.clip_grad_norm_( self.model.parameters(), max_norm=1.0) self.optimizer.step() self.scheduler.step() self.optimizer.zero_grad() -
接下来,我们通过检查当前批次索引是否是
log_interval的倍数来定期将训练进度记录到 TensorBoard 和控制台;如果是,我们计算平均损失和自上次日志以来的经过时间,使用SummaryWriter将训练损失和学习率记录到 TensorBoard,打印包括批次号、学习率、每批次的毫秒数和当前损失等信息的格式化进度更新到控制台,然后重置下一次日志间隔的累积total_loss和start_time:if (i + 1) % log_interval == 0: cur_loss = total_loss / log_interval elapsed = time.time() - start_time self.writer.add_scalar( 'training_loss', cur_loss, global_step=i ) self.writer.add_scalar( 'learning_rate', self.scheduler.get_last_lr()[0], global_step=i ) print( f'| {i:5d}/' f'{len(self.train_dataloader):5d} ' f'batches | ' f'lr ' f'{self.scheduler.get_last_lr()' f'[0]:02.2f} | ' f'ms/batch ' f'{elapsed * 1000 / log_interval:5.2f} ' f'| ' f'loss ' f'{cur_loss:5.2f}' ) total_loss = 0 start_time = time.time() -
接下来,
train函数通过遍历指定的周期数来协调训练过程,在每个周期的开始打印一条消息,调用train_epoch方法执行该周期的训练,并在所有周期完成后关闭写入器。它是训练的主要入口点,提供了一个结构,其中可以按需集成如验证和检查点等附加功能:def train(self, num_epochs): for epoch in range(num_epochs): print(f'Starting epoch {epoch+1}') self.train_epoch() # Here you could add validation, checkpointing, etc. self.writer.close() -
最后,我们使用指定的模型、训练数据加载器、优化器、调度器和设备实例化
LLMTrainer类。然后,通过调用train方法来执行三个完整的训练周期,从而启动并管理模型的学习周期:trainer = LLMTrainer(model, train_dataloader, optimizer, scheduler, device) trainer.train(num_epochs=3)
这种模块化设计提供了几个优点:
-
LLMTrainer类,使其更容易管理和理解。 -
可重用性:你可以通过创建具有不同参数的新实例,轻松地将此训练器用于不同的模型或数据集。
-
可扩展性:类结构使得添加新功能变得容易。例如,你可以添加用于验证、检查点或早期停止的方法。
-
关注点分离:训练逻辑与模型定义和数据准备分离,遵循良好的软件工程原则。
以下日志演示了在 3 个周期内进行的训练过程,每隔 100 批次进行一次定期记录。每个日志条目包括当前批次号、总批次数、学习率、每批次的毫秒数和平均损失:
Starting epoch 1
| 100/1000 batches | lr 0.01 | ms/batch 45.67 | loss 2.35
| 200/1000 batches | lr 0.01 | ms/batch 44.89 | loss 2.10
| 300/1000 batches | lr 0.01 | ms/batch 46.12 | loss 1.95
| 400/1000 batches | lr 0.01 | ms/batch 45.50 | loss 1.80
| 500/1000 batches | lr 0.01 | ms/batch 44.75 | loss 1.65
| 600/1000 batches | lr 0.009 | ms/batch 45.30 | loss 1.50
| 700/1000 batches | lr 0.009 | ms/batch 44.95 | loss 1.40
| 800/1000 batches | lr 0.009 | ms/batch 45.10 | loss 1.30
| 900/1000 batches | lr 0.009 | ms/batch 45.00 | loss 1.25
| 1000/1000 batches | lr 0.009 | ms/batch 44.80 | loss 1.20
Starting epoch 2
| 100/1000 batches | lr 0.009 | ms/batch 44.60 | loss 1.18
| 200/1000 batches | lr 0.009 | ms/batch 44.70 | loss 1.15
| 300/1000 batches | lr 0.009 | ms/batch 44.80 | loss 1.12
| 400/1000 batches | lr 0.008 | ms/batch 44.50 | loss 1.10
| 500/1000 batches | lr 0.008 | ms/batch 44.60 | loss 1.08
| 600/1000 batches | lr 0.008 | ms/batch 44.55 | loss 1.05
| 700/1000 batches | lr 0.008 | ms/batch 44.65 | loss 1.03
| 800/1000 batches | lr 0.007 | ms/batch 44.50 | loss 1.00
| 900/1000 batches | lr 0.007 | ms/batch 44.60 | loss 0.98
| 1000/1000 batches | lr 0.007 | ms/batch 44.55 | loss 0.95
Starting epoch 3
| 100/1000 batches | lr 0.007 | ms/batch 44.50 | loss 0.93
| 200/1000 batches | lr 0.007 | ms/batch 44.60 | loss 0.90
| 300/1000 batches | lr 0.006 | ms/batch 44.55 | loss 0.88
| 400/1000 batches | lr 0.006 | ms/batch 44.50 | loss 0.85
| 500/1000 batches | lr 0.006 | ms/batch 44.60 | loss 0.83
| 600/1000 batches | lr 0.006 | ms/batch 44.55 | loss 0.80
| 700/1000 batches | lr 0.005 | ms/batch 44.50 | loss 0.78
| 800/1000 batches | lr 0.005 | ms/batch 44.60 | loss 0.75
| 900/1000 batches | lr 0.005 | ms/batch 44.55 | loss 0.73
| 1000/1000 batches | lr 0.005 | ms/batch 44.50 | loss 0.70
Training completed. Writer closed.
这里是对上述模拟日志的解释:
-
开始第 1 个周期,表示新的训练周期的开始 -
100/1000 批次 -
lr 0.01 -
ms/批次 45.67 -
损失 2.35 -
学习率调度:注意学习率在周期内是如何降低的,这反映了调度器为促进更好的收敛所做的调整*
训练完成。写入器已关闭。) 表示训练过程的结束和日志写入器的关闭
日志提供了对训练动态的清晰概述,允许开发人员和研究人员监控模型的学习进度,如有必要,调整超参数,并确保训练按预期进行。
扩展您的训练流程以适应更大的模型
为了训练更大的模型,我们需要采用梯度累积和混合精度训练等技术。
为了训练可能不适合单个 GPU 的大规模语言模型,以下代码引入了一个特殊的 LargeScaleLLMTrainer。它使用两个主要技巧来处理这个问题:
首先,梯度累积允许我们模拟访问更大的 GPU。我们不是在每处理一小批数据后更新模型的参数,而是在处理几个小批量的过程中累积它们的梯度。只有在预定义的批次数之后,我们才对模型的参数进行实际更新。这种技术使模型能够像看到了一个更大的数据批次一样学习,而不需要极端大 GPU 的内存容量。
其次,它采用混合精度训练,这是一种计算机使用较小的、低精度数字(需要更少的内存且计算速度更快)进行许多计算的技术,同时为精度至关重要的场合保留高精度数字。这种方法加速了训练并减少了整体内存使用。为了减轻使用低精度值可能出现的潜在问题,GradScaler 在反向传播过程中保持数值稳定性。
以下代码定义了这种特殊训练器的工作方式,包括如何处理数据、计算损失以及使用这些技巧更新模型的参数、学习率和梯度缩放器。它还包括确保梯度(模型应该如何改变)不会太大以及记录进度以便我们可以看到训练进展的重要步骤。最后,它展示了如何使用这个特殊训练器的简单示例。现在,让我们将其分解成几个部分:
-
让我们先导入相关的 Python 包并定义类:
import torch.cuda.amp as amp class LargeScaleLLMTrainer(LLMTrainer): def __init__(self, model, train_dataloader, optimizer, scheduler, device, accumulation_steps=4 ): super().__init__(model, train_dataloader, optimizer, scheduler, device) self.accumulation_steps = accumulation_steps self.scaler = amp.GradScaler() -
然后,我们可以定义训练的迭代次数:
def train_epoch(self): self.model.train() total_loss = 0 log_interval = 100 start_time = time.time() for i, batch in enumerate(self.train_dataloader): batch = { k: torch.tensor(v).to(self.device) for k, v in batch.items() } with amp.autocast(): outputs = self.model(batch) loss = outputs.loss / self.accumulation_steps self.scaler.scale(loss).backward() -
然后,我们实现以下代码块,它仅在处理了定义数量的批次(
accumulation_steps)之后更新模型的参数、学习率和梯度缩放器,有效地模拟更大的批次数同时管理内存限制:if (i + 1) % self.accumulation_steps == 0: self.scaler.unscale_(self.optimizer) torch.nn.utils.clip_grad_norm_( self.model.parameters(), max_norm=1.0 ) self.scaler.step(self.optimizer) self.scaler.update() self.scheduler.step() self.optimizer.zero_grad() total_loss += loss.item() * self.accumulation_steps -
然后,我们定期计算并记录平均训练损失和学习率到 TensorBoard,同时按照
log_interval定义的时间间隔在控制台上打印当前训练进度的摘要:if (i + 1) % log_interval == 0: cur_loss = total_loss / log_interval elapsed = time.time() start_time self.writer.add_scalar('training_loss', cur_loss, global_step=i) self.writer.add_scalar('learning_rate', self.scheduler.get_last_lr()[0], global_step=i) print( f'| {i:5d}/{len(self.train_dataloader):5d} batches | ' f'lr {self.scheduler.get_last_lr()[0]:02.2f} | ' f'ms/batch {elapsed * 1000 / log_interval:5.2f} | ' f'loss {cur_loss:5.2f}' ) total_loss = 0 start_time = time.time() -
我们展示了大规模语言模型训练过程的初始化和执行:
large_trainer = LargeScaleLLMTrainer( model, train_dataloader, optimizer, scheduler, device) large_trainer.train(num_epochs=3)此增强型训练器使用两种关键技术来扩展到更大的模型:
-
(
accumulation_steps)。这允许我们有效地增加批次数而不增加内存使用,这对于在有限的 GPU 内存上训练大型模型是有效的。我们将损失除以accumulation_steps以保持相同的有效学习率。 -
在可能的情况下使用
float16,同时保留float32的主权重。这可以显著加快训练速度并减少内存使用,尤其是在具有张量核心的现代 GPU 上。
-
GradScaler用于防止float16计算中的下溢。它将损失缩放以防止小的梯度值,然后在优化器步骤之前进行缩放。
我们仍然应用梯度裁剪,但现在是在将梯度缩放回原始尺度之后进行的,以确保我们裁剪的是真实的梯度值。
对于更大的模型,你可能需要考虑诸如模型并行(将模型分割到多个 GPU 上)、流水线并行(将模型分割成阶段)或使用如DeepSpeed或Megatron-LM等专用库等技术。这些高级技术允许在多个 GPU 甚至多台机器上训练具有数十亿参数的模型。当 GPU 内存不足以处理大量数据和模型参数时,内存卸载可以是一个好的替代方案。内存卸载涉及将模型数据或计算的部分转移到替代内存存储,例如非易失性内存表达式(NVMe)SSD。通过利用 NVMe 内存,它提供与传统存储相比的高速数据访问,系统可以有效地管理和存储超出 GPU 内存容量的中间激活、梯度和模型状态。这种方法允许在不要求立即扩展 GPU 内存的情况下训练更大的模型或使用更大的批量大小。然而,由于 GPU 和 NVMe 存储之间的数据传输,它引入了额外的延迟,这可能会影响训练速度。优化数据访问模式并利用有效的卸载策略可以在采用内存卸载技术时最小化性能开销并保持有效的训练工作流程。
摘要
在本章中,你了解到了训练 LLM 的实际管道设计模式。你学习了如何创建高效的数据预处理工作流程,实现模型架构,并应用高级优化策略。你现在理解了如何设置有效的日志系统来跟踪你的模型进度。你还探索了构建模块化和可重用管道的技术,并发现了扩展你的训练过程以适应更大模型的方法。有了这些技能,你将能够高效且有效地训练最先进的语言模型。
在下一章中,我们将探讨超参数调整模式。
第八章:超参数调整
在本章中,你将了解 LLM 中的超参数以及优化它们的策略。我们将探讨手动和自动调整方法,包括网格搜索、随机搜索以及更高级的方法,如贝叶斯优化和基于群体的训练。你还将深入了解在 LLM 开发中常见的多目标优化场景的处理。
到最后,你将掌握实用的工具和技术,以优化你的 LLM 在各种任务和领域中的表现。
在本章中,我们将涵盖以下主题:
-
理解超参数
-
手动与自动调整
-
网格和随机搜索
-
贝叶斯优化
-
基于群体的方法
-
多目标超参数优化
-
规模化超参数调整——挑战与解决方案
理解超参数
超参数是在机器学习训练过程开始之前设置的参数,它们不是从数据中学习的。它们控制学习算法本身的各个方面,例如模型的复杂性、学习速率以及整体训练过程。数据科学家手动选择和调整这些超参数以优化模型的表现。
LLM 中的超参数可以大致分为三类:架构、优化和正则化超参数:
-
架构超参数:这些定义了模型的设计和结构,决定了模型如何处理和表示数据。它们至关重要,因为它们直接影响模型学习数据中的复杂模式和关系的能力。正确的架构在计算效率和性能之间取得平衡,使模型能够很好地泛化到未见数据。
本类别中的参数包括以下内容:
-
层数数量
-
隐藏层大小
-
注意力头数
-
前馈维度
-
词汇量大小
-
-
优化超参数:这些通过调整参数以最小化损失函数来控制模型在训练过程中的学习方式。它们很重要,因为它们控制更新速率和方式,影响收敛速度、稳定性和模型达到最优解的能力。适当的调整确保了高效的训练,避免了发散或欠拟合。
本类别中的参数包括以下内容(我们在第七章中讨论过):
-
学习速率
-
批处理大小
-
训练步数
-
预热步骤
-
学习速率调度
-
-
正则化超参数:这些引入机制以防止模型过度拟合训练数据,确保其泛化到新数据。它们至关重要,因为高容量模型可以轻易记住训练数据,导致在未见数据上的表现不佳。正则化技术强制执行约束,鼓励简单性和鲁棒性。
此类别中的参数包括以下内容(更多内容请参阅第九章):
-
Dropout 率
-
权重衰减
-
标签平滑
-
让我们实现一个函数来创建具有可配置超参数的 LLM:
from transformers import GPT2Config, GPT2LMHeadModel
def create_llm(
num_layers, hidden_size, num_heads, ff_dim, vocab_size
):
config = GPT2Config(
n_layer=num_layers,
n_embd=hidden_size,
n_head=num_heads,
n_inner=ff_dim,
vocab_size=vocab_size
)
model = GPT2LMHeadModel(config)
return model
# Example usage
model = create_llm(num_layers=12, hidden_size=768,
num_heads=12, ff_dim=3072, vocab_size=50257)
print(f"Model parameters: {model.num_parameters():,}")
在此代码中,我们定义了一个函数create_llm,它允许我们轻松地创建具有不同架构超参数的 LLMs。该函数接受以下参数:
-
num_layers:模型中 transformer 层的数量。更多的层可以捕捉更复杂的模式,但它们会增加计算需求。 -
hidden_size:模型中隐藏状态的维度。这影响模型捕捉信息的能力。 -
num_heads:每个层中注意力头的数量。多个头允许模型同时关注输入的不同方面。 -
ff_dim:每个 transformer 块中前馈层的维度。这通常设置为hidden_size的四倍。 -
vocab_size:模型词汇表的大小。这决定了嵌入层和输出层的大小。
我们使用这些参数来创建一个GPT2Config对象,然后使用该对象初始化一个GPT2LMHeadModel。这种方法允许我们轻松地实验不同的模型架构。
手动与自动化调整
手动调整涉及根据直觉、经验和逐步实验调整超参数。手动调整允许您利用领域知识系统地探索定制配置,但它是时间密集型的,容易产生次优结果,并且在探索大超参数空间方面效率低下。
自动化调整,另一方面,使用算法系统地探索超参数空间。自动化调整通过算法优化性能,有效地探索大超参数空间,与手动调整相比,可以节省时间和精力,但可能计算成本较高,并且可能需要专业知识来正确配置。
当领域知识或直觉可以引导一个小型、有针对性的搜索空间时,手动调整很有用,尤其是在资源受限的环境或简单的模型中。自动化调整更适合大型、复杂的超参数空间,因为需要系统探索和优化,尽管计算成本较高,但它可以更有效地找到更好的配置。
让我们实现两种方法。
手动调整
首先,我们将实现手动调整:
-
开始导入:
import numpy as np from transformers import Trainer, TrainingArguments from datasets import load_dataset -
加载一个样本数据集:
dataset = load_dataset( "wikitext", "wikitext-2-raw-v1", split="train") def tokenize_function(examples): return tokenizer( examples["text"], truncation=True, max_length=512) tokenized_dataset = dataset.map(tokenize_function, batched=True, remove_columns=dataset.column_names) -
设置手动调整的超参数:
manual_hyperparameters = [ {"num_layers": 6, "hidden_size": 512, "num_heads": 8, "ff_dim": 2048}, {"num_layers": 12, "hidden_size": 768, "num_heads": 12, "ff_dim": 3072}, {"num_layers": 24, "hidden_size": 1024, "num_heads": 16, "ff_dim": 4096} ] -
使用
manual_hyperparameters进行训练:for hp in manual_hyperparameters: model = create_llm(hp, vocab_size=50257) training_args = TrainingArguments( output_dir=( f"./results_{hp['num_layers']}_" f"{hp['hidden_size']}" ), num_train_epochs=3, per_device_train_batch_size=8, logging_dir=( f"./logs_{hp['num_layers']}_" f"{hp['hidden_size']}" ), ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset, ) trainer.train() -
评估模型:
eval_results = trainer.evaluate() print(f"Hyperparameters: {hp}") print(f"Evaluation results: {eval_results}")
在这个手动调整示例中,我们定义了一个要尝试的超参数配置列表。然后我们遍历这些配置,为每个配置创建一个模型,对其进行训练,并评估其性能。这种方法允许我们系统地探索不同的模型大小和架构。
手动调优过程可以由领域知识和直觉指导。例如,我们可能从一个小的模型(6 层,512 隐藏大小)开始,逐渐增加大小以查看它如何影响性能。我们选择这些特定的配置是基于基于转换器模型的常见实践:
-
最小配置(6 层,512 隐藏大小)代表一个紧凑的模型,适合快速训练和部署
-
中等配置(12 层,768 隐藏大小)与已知的在许多任务上表现良好的基础 GPT-2 模型相似
-
最大配置(24 层,1,024 隐藏大小)代表一个更强大的模型,可能能够捕捉更复杂的模式,但需要更多的计算资源
自动调优
现在,让我们使用随机搜索实现一个简单的自动化调优方法(我们将在下一节中展示更高级的随机搜索):
-
添加
import语句并设置随机参数:import random def random_hp_search(num_trials=10): best_eval_loss = float('inf') best_hp = None for _ in range(num_trials): hp = { "num_layers": random.choice([6, 12, 24]), "hidden_size": random.choice([512, 768, 1024]), "num_heads": random.choice([8, 12, 16]), "ff_dim": random.choice([2048, 3072, 4096]) } -
进行训练:
model = create_llm(hp, vocab_size=50257) training_args = TrainingArguments( output_dir=f"./results_random_{_}", num_train_epochs=3, per_device_train_batch_size=8, logging_dir=f"./logs_random_{_}", ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset, ) trainer.train() -
评估并打印结果:
eval_results = trainer.evaluate() eval_loss = eval_results['eval_loss'] if eval_loss < best_eval_loss: best_eval_loss = eval_loss best_hp = hp print( f"Trial {_ + 1}: " f"Hyperparameters: {hp}, " f"Eval Loss: {eval_loss}" ) print( f"Best Hyperparameters: {best_hp}, " f"Best Eval Loss: {best_eval_loss}" ) random_hp_search()
这种随机搜索实现从预定义的选项中随机选择每个试验的超参数(试验期间没有手动干预)。预定义选项是指搜索过程中从指定的范围、集合或分布中抽取超参数随机值的指定范围、集合或分布。例如,离散超参数(如层数)可能从集合[6, 12, 24]中选择,而连续超参数(如学习率)可能从均匀分布或对数均匀分布中抽取,例如从10^(-5)到10^(-3)。这些选项定义了每个超参数的边界和可能值,指导随机抽样过程。
我们选择在每个超参数上搜索一组离散值以限制搜索空间,并确保我们正在探索已知对转换器模型表现良好的配置。试验次数(本例中为 10 次)是探索和计算资源之间的平衡。更多的试验增加了找到良好配置的机会,但也增加了计算成本。
在接下来的章节中,我们将介绍其他自动化调优技术,例如网格搜索和更高级的随机搜索、贝叶斯优化、基于群体的方法和多目标超参数优化
网格搜索和随机搜索
网格搜索和随机搜索是两种常见的超参数调优方法。我们在上一节中介绍了随机搜索。在本节中,我们实现网格搜索和更高级的随机搜索版本。
-
添加导入并设置网格搜索参数:
import itertools def grid_search(): hp_grid = { "num_layers": [6, 12, 24], "hidden_size": [512, 768, 1024], "num_heads": [8, 12, 16], "ff_dim": [2048, 3072, 4096] } best_eval_loss = float('inf') best_hp = None -
使用定义的超参数训练模型:
for hp in itertools.product(*hp_grid.values()): hp_dict = dict(zip(hp_grid.keys(),hp)) model = create_llm( hp_dict["num_layers"], hp_dict["hidden_size"], hp_dict["num_heads"], hp_dict["ff_dim"], vocab_size=50257 ) training_args = TrainingArguments( output_dir=( f"./results_grid_{hp_dict['num_layers']}_" f"{hp_dict['hidden_size']}" ), num_train_epochs=3, per_device_train_batch_size=8, logging_dir=( f"./logs_grid_{hp_dict['num_layers']}_" f"{hp_dict['hidden_size']}" ), ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset, ) trainer.train() -
评估并打印结果:
eval_results = trainer.evaluate() eval_loss = eval_results['eval_loss'] if eval_loss < best_eval_loss: best_eval_loss = eval_loss best_hp = hp_dict print( f"Hyperparameters: {hp_dict}, " f"Eval Loss: {eval_loss}" ) print( f"Best Hyperparameters: {best_hp}, " f"Best Eval Loss: {best_eval_loss}" ) grid_search()
网格搜索全面探索所有超参数组合。这种方法非常彻底,但可能计算成本高昂,尤其是对于具有许多超参数的 LLM。在本实现中,我们正在探索3⁴ = 81种不同的配置,这可能需要大量的时间和资源。
超参数范围的选择旨在覆盖合理的模型大小空间,从相对较小(6 层,512 个隐藏层大小)到相当大(24 层,1,024 个隐藏层大小)。这使我们能够探索模型大小和性能之间的权衡。
现在,让我们实现一个更复杂的随机搜索,它还包括优化超参数:
-
添加
import语句并设置advanced_random_search超参数:import random def advanced_random_search(num_trials=20): best_eval_loss = float('inf') best_hp = None for _ in range(num_trials): hp = { "num_layers": random.choice([6, 12, 24]), "hidden_size": random.choice([512, 768, 1024]), "num_heads": random.choice([8, 12, 16]), "ff_dim": random.choice([2048, 3072, 4096]), "learning_rate": 10random.uniform(-5, -3), "batch_size": random.choice([8, 16, 32]), "num_epochs": random.randint(2, 5), "warmup_steps": random.randint(100, 1000), "weight_decay": random.uniform(0, 0.2) } -
进行训练:
model = create_llm( num_layers=hp['num_layers'], hidden_size=hp['hidden_size'], num_heads=hp['num_heads'], ff_dim=hp['ff_dim'], vocab_size=50257) training_args = TrainingArguments( output_dir=f"./results_advanced_random_{_}", num_train_epochs=hp['num_epochs'], per_device_train_batch_size=hp['batch_size'], learning_rate=hp['learning_rate'], warmup_steps=hp['warmup_steps'], weight_decay=hp['weight_decay'], logging_dir=f"./logs_advanced_random_{_}", ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset, ) trainer.train() -
评估并打印结果:
eval_results = trainer.evaluate() eval_loss = eval_results['eval_loss'] if eval_loss < best_eval_loss: best_eval_loss = eval_loss best_hp = hp print( f"Trial {_ + 1}: Hyperparameters: {hp}, " f"Eval Loss: {eval_loss}" ) print( f"Best Hyperparameters: {best_hp}, " f"Best Eval Loss: {best_eval_loss}" )
这种高级随机搜索包括架构和优化超参数。我们使用random.uniform来设置0.001、0.0015或0.002等,例如学习率和权重衰减,以及random.choice或random.randint来设置32、64和128作为批量大小或从一组固定的选项中进行选择)。
每个超参数的范围是基于 LLM 训练中的常见做法选择的(也请参阅第七章):
-
1e-5和1e-3,因为 LLM 的学习率通常在这个范围内 -
8、16和32,这些是常见的批量大小,在计算效率和稳定性之间取得平衡 -
2到5个周期,因为 LLM 通常在大数据集上只需要几个周期就会收敛 -
100步和1,000步,这有助于稳定早期训练 -
0和0.2,因为少量的权重衰减可以帮助防止过拟合
高级随机搜索比网格搜索更好,因为它通过随机采样而不是全面评估每个可能的组合来更有效地探索超参数空间。这种灵活性允许它专注于对性能有显著影响的键超参数,防止对影响较小的超参数进行冗余评估。它可以直接通过从分布中采样来处理连续参数,而网格搜索则需要离散化,并且随着参数空间的增加,计算成本呈指数增长。通过将试验次数限制在预定义的预算内,高级随机搜索可以更快地发现有效的配置,并且计算成本更低,使其更适合大型和复杂模型。
贝叶斯优化
贝叶斯优化是一种更高级的超参数调整方法,对于 LLM 特别有效。它使用概率模型来预测不同超参数配置的性能,并智能地选择下一个要尝试的配置。
让我们使用 optuna 库实现贝叶斯优化。Optuna 是一个开源的超参数优化框架,用于自动化寻找算法和模型最优参数的过程。它采用先进的贝叶斯优化技术,主要使用 树结构帕累托估计器(TPE)算法,以有效地搜索复杂的参数空间:
-
导入 optuna 并设置超参数:
import optuna from transformers import Trainer, TrainingArguments import torch def objective(trial): # Define the hyperparameters to optimize hp = { "num_layers": trial.suggest_int("num_layers", 6, 24), "hidden_size": trial.suggest_categorical( "hidden_size", [512, 768, 1024] , "num_heads": trial.suggest_categorical( "num_heads", [8, 12, 16] ), "ff_dim": trial.suggest_categorical( "ff_dim", [2048, 3072, 4096] ), "learning_rate": trial.suggest_loguniform( "learning_rate", 1e-5, 1e-3 ), "batch_size": trial.suggest_categorical( "batch_size", [8, 16, 32] ), "num_epochs": trial.suggest_int("num_epochs", 2, 5), "warmup_steps": trial.suggest_int( "warmup_steps", 100, 1000), "weight_decay": trial.suggest_uniform( "weight_decay", 0, 0.2) } model = create_llm( num_layers=hp['num_layers'], hidden_size=hp['hidden_size'], num_heads=hp['num_heads'], ff_dim=hp['ff_dim'], vocab_size=50257 ) -
进行训练:
training_args = TrainingArguments( output_dir=f"./results_bayesian_{trial.number}", num_train_epochs=hp['num_epochs'], per_device_train_batch_size=hp['batch_size'], learning_rate=hp['learning_rate'], warmup_steps=hp['warmup_steps'], weight_decay=hp['weight_decay'], logging_dir=f"./logs_bayesian_{trial.number}", ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset, ) trainer.train() eval_results = trainer.evaluate() return eval_results['eval_loss'] -
运行优化:
study = optuna.create_study(direction="minimize") study.optimize(objective, n_trials=20) print("Best trial:") trial = study.best_trial print(f"Value: {trial.value}") print("Params: ") for key, value in trial.params.items(): print(f" {key}: {value}")
在此实现中,我们定义了一个 objective 函数,Optuna 将对其进行优化。该函数使用 Optuna 建议的超参数创建和训练模型,然后返回评估损失。
我们使用 Optuna 的建议方法来定义搜索空间:
-
suggest_int用于整数超参数,如num_layers和num_epochs -
suggest_categorical用于具有离散选项的超参数,例如hidden_size和num_heads -
suggest_loguniform用于学习率,因为我们想以对数方式搜索这个空间 -
suggest_uniform用于权重衰减,因为我们想在这个空间内均匀搜索
每个超参数的范围与我们在基于随机搜索的实现中使用的范围类似,基于 LLM 训练中的常见实践。
贝叶斯优化可能比网格搜索或随机搜索更有效,尤其是在评估成本高昂的函数,如训练 LLM 时。它使用先前试验的结果来指导未来试验的选择,可能更快地找到好的配置。
基于群体的方法
基于群体的训练(PBT)是一种强大的技术,它将并行搜索与训练过程中的自适应超参数调整相结合。PBT 特别适用于可以高效暂停和恢复训练的问题。这是因为 PBT 定期评估和更新群体中的超参数和模型权重,需要无缝的暂停和恢复功能。这种适应性确保了计算资源的最佳利用,使 PBT 成为神经架构搜索、强化学习和超参数调整等任务的理想选择,在这些任务中,迭代优化计算密集。
在这里,我们将实现 PBT 的简化版本,以说明其核心概念和功能。
我们将首先创建一个 SimplePBT 类,它封装了 PBT 算法的核心功能。让我们分解实现过程:
-
首先,初始化类:
import random import copy class SimplePBT: def __init__(self, population_size=4, num_generations=5): self.population_size = population_size self.num_generations = num_generations self.population = []SimplePBT类使用两个主要参数进行初始化:-
population_size: 维护的不同超参数配置的数量(默认为4) -
num_generations: PBT 算法将运行的迭代次数(默认为5)
population列表将存储代表群体中每个个体的字典,包含超参数及其相应的性能分数。 -
-
初始化人口:
initialize_population方法创建初始的超参数配置集:def initialize_population(self): for _ in range(self.population_size): hp = { "num_layers": random.choice([6, 12, 24]), "hidden_size": random.choice([512, 768, 1024]), "num_heads": random.choice([8, 12, 16]), "ff_dim": random.choice([2048, 3072, 4096]), "learning_rate": 10random.uniform(-5, -3), "batch_size": random.choice([8, 16, 32]), "weight_decay": random.uniform(0, 0.2) } self.population.append({"hp": hp, "score": None})对于种群中的每个个体,执行以下操作:
-
num_layers和hidden_size从预定义选项中随机选择。这些超参数是分类的,因为它们代表的是离散的、个体的选择,而不是连续值。 -
learning_rate和weight_decay从指定的范围内采样。
每个配置都添加到
population列表中,初始得分为None。 -
-
训练和评估:
train_and_evaluate方法负责创建具有给定超参数的 LLM,设置训练参数,使用模型和参数初始化训练器,训练模型,评估模型,并返回评估损失:def train_and_evaluate(self, hp): model = create_llm(num_layers=hp['num_layers'], hidden_size=hp['hidden_size'], num_heads=hp['num_heads'], ff_dim=hp['ff_dim'], vocab_size=50257) training_args = TrainingArguments( output_dir=f"./results_pbt_{random.randint(0, 1000)}", num_train_epochs=3, per_device_train_batch_size=hp['batch_size'], learning_rate=hp['learning_rate'], weight_decay=hp['weight_decay'], logging_dir=f"./logs_pbt_{random.randint(0, 1000)}", ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset, ) trainer.train() eval_results = trainer.evaluate() return eval_results['eval_loss']此方法假设存在
create_llm、TrainingArguments和Trainer类,这些类通常由深度学习框架(如 Hugging Face Transformers)提供。 -
利用和探索:
exploit_and_explore方法实现了核心的 PBT 算法:def exploit_and_explore(self): # Sort population by score self.population.sort(key=lambda x: x['score']) # Replace bottom half with mutated versions of top half for i in range(self.population_size // 2): self.population[i + self.population_size // 2]['hp'] =\ self.mutate( copy.deepcopy(self.population[i]['hp']) )它根据得分对人口进行排序(得分越低表示损失越小)。表现最差的种群下半部分被表现最好的种群变异版本所取代。这种方法平衡了
mutate方法在超参数中引入的变化:def mutate(self, hp): # Randomly mutate one hyperparameter param_to_mutate = random.choice(list(hp.keys())) if param_to_mutate in [ 'num_layers', 'hidden_size', 'num_heads', 'ff_dim', 'batch_size' ]: hp[param_to_mutate] = random.choice( [6, 12, 24] if param_to_mutate == "num_layers" else [512, 768, 1024] if param_to_mutate == "hidden_size" else [8, 12, 16] if param_to_mutate == "num_heads" else [2048, 3072, 4096] if param_to_mutate == "ff_dim" else [8, 16, 32] ) elif param_to_mutate == 'learning_rate': hp[param_to_mutate] *= random.uniform(0.8, 1.2) elif param_to_mutate == 'weight_decay': hp[param_to_mutate] = min( max(hp[param_to_mutate] + random.uniform(-0.05, 0.05), 0), 0.2 ) return hp它随机选择一个超参数进行变异。对于分类参数,它从预定义选项中选择一个新值。对于像学习率这样的连续参数,它在一定范围内扰动当前值。对于权重衰减,它在保持其在
[0, 0.2]范围内的同时添加一个小的随机值。这种变异策略允许对超参数进行小到大的变化,从而促进对超参数空间的多样化探索。
-
运行 PBT 过程:
def run(self): self.initialize_population() for generation in range(self.num_generations): print(f"Generation {generation + 1}") for i, individual in enumerate(self.population): individual['score'] = \ self.train_and_evaluate(individual['hp']) print( f"Individual {i + 1}: Score = {individual['score']}" ) self.exploit_and_explore() best_individual = min(self.population, key=lambda x: x['score']) print("\nBest Hyperparameters:") print(best_individual['hp']) print(f"Best Score: {best_individual['score']}")run方法协调整个 PBT 过程:-
它初始化人口。
-
对于每一代,它训练和评估种群中的每个个体,并执行利用和探索以更新种群。
-
在所有代数完成后,它打印出找到的最佳超参数和得分。
-
-
使用
SimplePBT类:要使用SimplePBT类,您可以简单地创建一个实例并运行它:# Run PBT pbt = SimplePBT() pbt.run()
这将启动 PBT 过程,默认人口大小为 4 和 5 代。您可以在创建 SimplePBT 实例时调整这些参数以适应您的特定需求。
多目标超参数优化
在 LLM 开发中,我们经常需要平衡多个目标,例如模型性能、推理速度和模型大小。让我们使用 Optuna 实现多目标优化:
-
添加
import语句并设置超参数:import optuna def objective(trial): hp = { "num_layers": trial.suggest_int("num_layers", 6, 24), "hidden_size": trial.suggest_categorical( "hidden_size", [512, 768, 1024]), "num_heads": trial.suggest_categorical( "num_heads", [8, 12, 16]), "ff_dim": trial.suggest_categorical( "ff_dim", [2048, 3072, 4096]), "learning_rate": trial.suggest_loguniform( "learning_rate", 1e-5, 1e-3), "batch_size": trial.suggest_categorical( "batch_size", [8, 16, 32]), "weight_decay": trial.suggest_uniform( "weight_decay", 0, 0.2) } model = create_llm( num_layers=hp['num_layers'], hidden_size=hp['hidden_size'], num_heads=hp['num_heads'], ff_dim=hp['ff_dim'], vocab_size=50257 ) -
进行训练:
training_args = TrainingArguments( output_dir=f"./results_multi_objective_{trial.number}", num_train_epochs=3, per_device_train_batch_size=hp['batch_size'], learning_rate=hp['learning_rate'], weight_decay=hp['weight_decay'], logging_dir=f"./logs_multi_objective_{trial.number}", ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset, ) trainer.train() -
执行评估:
eval_results = trainer.evaluate() eval_loss = eval_results['eval_loss'] # Calculate model size in MB model_size = sum(p.numel() for p in model.parameters()) * 4 / 1024 / 1024 # assuming float32 # Simulate inference time (this would be more accurate if actually measured) inference_time = 0.001 * hp['num_layers'] * (hp['hidden_size'] / 512) 2 return eval_loss, model_size, inference_time -
运行多目标优化:
study = optuna.create_study( directions=["minimize", "minimize", "minimize"]) study.optimize(objective, n_trials=50) print("Pareto front:") for trial in study.best_trials: print(f"Trial {trial.number}") print(f" Value: Loss={trial.values[0]:.4f}, Size={trial.values[1]:.2f}MB, Inference Time={trial.values[2]:.4f}s") print(" Params:") for key, value in trial.params.items(): print(f" {key}: {value}")
在这个多目标优化中,我们试图同时最小化三个目标:
-
评估损失(模型性能)
-
模型大小(以 MB 计)。
-
推理时间(基于模型架构模拟)
我们通过在create_study中指定多个方向来使用 Optuna 的多目标优化能力。优化过程将尝试找到这些目标配置的帕累托前沿(任何改进一个目标都需要至少降低另一个目标的一组解决方案),其中提高一个目标必然会导致另一个目标恶化。
现在objective函数返回三个值,对应我们的三个目标。对于模型大小,我们计算参数总数并将其转换为 MB。对于推理时间,我们使用基于模型架构的简单启发式方法,在实际场景中,你可能会想测量这个值。
这种方法使我们能够探索模型性能、大小和速度之间的权衡。对于 LLM 开发尤其有用,因为我们经常需要在不同部署场景中平衡这些因素。
超参数调整的规模——挑战和解决方案
当调整 LLM 的超参数时,我们面临几个挑战:
-
计算成本:训练 LLM 很昂贵,限制了我们可以运行的试验数量
-
长训练时间:每个试验可能需要几天或几周,使整个过程非常耗时
-
大搜索空间:LLM 有很多超参数,创建了一个庞大的搜索空间
-
对初始化的敏感性:LLM 的性能可能因不同的随机种子而有很大差异
为了应对这些挑战,我们可以采用几种策略:
-
使用较小的代理任务:不是在完整任务上调整,而是使用较小的数据集或更少的训练步骤来快速估计性能
-
利用预训练模型:从预训练权重开始,专注于调整微调超参数
-
使用多保真度优化:从低保真度评估(例如,少量训练步骤)开始,并逐渐增加有希望配置的保真度
-
分布式超参数调整:使用多台机器并行探索不同的超参数
让我们实现一个简单的多保真度优化方法:
-
添加
import语句并设置超参数:import optuna def objective(trial): hp = { "num_layers": trial.suggest_int("num_layers", 6, 24), "hidden_size": trial.suggest_categorical( "hidden_size", [512, 768, 1024]), "num_heads": trial.suggest_categorical( "num_heads", [8, 12, 16]), "ff_dim": trial.suggest_categorical( "ff_dim", [2048, 3072, 4096]), "learning_rate": trial.suggest_loguniform( "learning_rate", 1e-5, 1e-3), "batch_size": trial.suggest_categorical( "batch_size", [8, 16, 32]), "weight_decay": trial.suggest_uniform( "weight_decay", 0, 0.2) } model = create_llm( num_layers=hp['num_layers'], hidden_size=hp['hidden_size'], num_heads=hp['num_heads'], ff_dim=hp['ff_dim'], vocab_size=50257) -
使用多保真度策略进行训练,从少量步骤开始:
for steps in [100, 500, 2000]: training_args = TrainingArguments( output_dir= \ f"./results_multi_fidelity_{trial.number}_ {steps}", max_steps=steps, per_device_train_batch_size=hp['batch_size'], learning_rate=hp['learning_rate'], weight_decay=hp['weight_decay'], logging_dir=\ f"./logs_multi_fidelity_{trial.number}_{steps}", ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset, ) trainer.train() -
进行评估:
eval_results = trainer.evaluate() eval_loss = eval_results = trainer.evaluate() eval_loss = eval_results['eval_loss'] trial.report(eval_loss, step=steps) -
剪枝没有希望的试验:
if trial.should_prune(): raise optuna.TrialPruned() return eval_loss -
运行多保真度优化:
study = optuna.create_study( pruner=optuna.pruners.MedianPruner()) study.optimize(objective, n_trials=30) print("Best trial:") trial = study.best_trial print(f"Value: {trial.value}") print("Params: ") for key, value in trial.params.items(): print(f" {key}: {value}")
我们应该关注这个多保真度方法的一些方面:
-
我们首先只对每个模型配置进行
100步的训练,这给出了性能的快速初始估计 -
然后,我们将训练步骤的数量增加到
500,然后对于有希望的配置增加到2,000 -
我们使用 Optuna 的剪枝机制来提前终止没有希望的试验,节省计算资源
MedianPruner如果试验的性能低于同一步骤之前试验的中位数,则停止试验。这使我们能够将计算资源集中在最有希望的参数配置上。
这种方法有助于解决大规模超参数调整的挑战:
-
它通过快速消除不良配置来降低计算成本
-
它通过使用较短的训练运行进行初始评估来缩短整体调整时间
-
它通过在相同的时间内运行更多试验来允许我们探索更大的搜索空间
然而,这种方法仍然存在局限性。经过少量步骤后的性能可能并不总是与最终性能很好地相关,特别是对于需要长时间训练才能收敛的 LLMs(大型语言模型)。
为了进一步改进大规模超参数调整,考虑以下高级技术:
-
分布式超参数调整:这种设置允许多台机器共同参与同一超参数搜索,大大加快了过程:
import optuna def objective(trial): # ... (same as before) ... # Create a study object with MySQL storage for distributed optimization storage = optuna.storages.RDBStorage( "mysql://user:password@host/database", engine_kwargs={"pool_size": 20, "max_overflow": 0} ) study = optuna.create_study( storage=storage, pruner=optuna.pruners.MedianPruner()) # This can be run on multiple machines study.optimize(objective, n_trials=10) -
利用预训练模型:这种方法从预训练模型开始,专注于调整微调的超参数和模型大小,这比从头开始训练更有效率:
from transformers import AutoModelForCausalLM, AutoTokenizer def create_pretrained_llm(model_name, num_layers=None): model = AutoModelForCausalLM.from_pretrained(model_name) if num_layers is not None: # Adjust the number of layers (this is a simplified approach) model.transformer.h = model.transformer.h[:num_layers] return model def objective(trial): hp = { "model_name": trial.suggest_categorical( "model_name", ["gpt2", "gpt2-medium", "gpt2-large"]), "num_layers": trial.suggest_int("num_layers", 6, 24), "learning_rate": trial.suggest_loguniform( "learning_rate", 1e-5, 1e-3), "batch_size": trial.suggest_categorical( "batch_size", [8, 16, 32]), "weight_decay": trial.suggest_uniform( "weight_decay", 0, 0.2) } model = create_pretrained_llm( hp['model_name'], hp['num_layers']) # ... (rest of the objective function) ... study = optuna.create_study( pruner=optuna.pruners.MedianPruner()) study.optimize(objective, n_trials=30) -
基于高斯过程的贝叶斯优化:对于只能进行少量试验的问题,基于高斯过程的贝叶斯优化比基于树的 TPE(Optuna 的默认方法)等方法更具有样本效率:
import optuna sampler = optuna.samplers.GPSampler() study = optuna.create_study(sampler=sampler) study.optimize(objective, n_trials=50)这种方法对于 LLM 调整特别有用,因为每次试验的成本都非常高。
-
异步逐次减半算法(ASHA):ASHA 是一种基于 bandit 的算法,其效率可能高于简单的剪枝方法:
from optuna.pruners import SuccessiveHalvingPruner pruner = SuccessiveHalvingPruner( min_resource=100, reduction_factor=3, min_early_stopping_rate=0) study = optuna.create_study(pruner=pruner) study.optimize(objective, n_trials=100)ASHA 特别适合大规模超参数优化,因为它可以有效地处理异步并行优化。
摘要
由于 LLMs 的规模和复杂性,超参数调整面临着独特的挑战。通过利用多保真优化、分布式调整和贝叶斯优化、ASHA 等高级算法,我们可以使这个过程更加高效和有效。然而,重要的是要记住,通常没有一种适合所有情况的解决方案,最佳方法可能取决于您的具体用例、可用资源和您的 LLM 任务的特性。
在下一章中,我们将重点关注 LLM 正则化。
第九章:正则化
正则化是一组方法,它约束或修改学习过程,以防止模型过于精确地记住训练数据,鼓励它学习更稳健和可泛化的模式。
正则化是训练 LLMs(大型语言模型)的一个关键方面,用于防止过拟合并提高泛化能力。过拟合是有害的,因为它会导致模型在训练数据上表现异常出色,而在新的、未见过的数据上却表现糟糕。当模型过拟合时,它实际上记住了训练数据集中的噪声和特殊性,而不是学习可泛化的模式和关系。这会在开发阶段产生高准确率的错觉,但会导致现实世界的表现不佳,使模型无法有效地用于其旨在对新颖输入进行准确预测的目的。
在本章中,您将了解针对 LLMs 特别定制的不同正则化技术。我们将探讨分层自适应正则化、微调中的正则化和多种技术的组合等方法。您将深入了解这些策略的实施及其对模型性能的影响。
在本章中,我们将涵盖以下主题:
-
L2 正则化(岭回归)
-
Dropout
-
分层自适应正则化
-
梯度裁剪和噪声注入
-
在迁移学习和微调场景中的正则化
-
针对下一代 LLMs 的新兴正则化技术
L2 正则化(岭回归)
L2 正则化,也称为岭回归或权重衰减,是一种用于防止机器学习模型过拟合的技术。它通过向损失函数添加一个惩罚项来实现,该惩罚项与模型权重的平方成正比。这个惩罚项阻止模型将大权重分配给单个特征,从而得到一个更简单、更通用的模型。通过最小化包含原始损失和惩罚项的合并损失函数,模型在拟合训练数据的同时保持权重较小,最终提高其泛化到新、未见过的数据的能力。
这里是如何使用它的:
from torch.optim import AdamW
def train_with_weight_decay(
model, train_dataloader, weight_decay=0.01, lr=5e-5, epochs=3
):
optimizer = AdamW(model.parameters(), lr=lr,
weight_decay=weight_decay)
for epoch in range(epochs):
model.train()
total_loss = 0
for batch in train_dataloader:
optimizer.zero_grad()
outputs = model(batch)
loss = outputs.loss
loss.backward()
optimizer.step()
total_loss += loss.item()
print(
f"Epoch {epoch + 1}, "
f"Loss: {total_loss / len(train_dataloader):.4f}"
)
# Assuming you have a train_dataloader
# train_with_weight_decay(model, train_dataloader)
在这个实现中,我们使用了我们在第七章中讨论的 AdamW 优化器,它正确地实现了权重衰减。weight_decay参数控制正则化的强度。一个典型的值是0.01,但您可能需要根据您的特定模型和数据集进行调整。
Dropout
Dropout是另一种强大的正则化技术,它在训练过程中随机“丢弃”一部分神经元。
Dropout 通过在每次训练迭代中随机停用一部分神经元来帮助对抗过拟合。这迫使网络发展冗余的信息流路径。这种技术通过在单个网络内创建一种集成学习形式,防止神经元过度依赖彼此,其中不同的子网络处理类似任务。结果是,一个更健壮的模型,它依赖于分布式表示而不是记忆特定模式,最终在推理期间所有神经元都活跃时,提高了对未见数据的泛化能力。
它在大型神经网络(如 LLM)中特别有效。以下是如何在基于变换器的 LLM 中实现 dropout 的方法:
class TransformerWithDropout(nn.Module):
def __init__(
self, vocab_size, d_model, nhead, num_layers, dropout=0.1
):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.pos_encoder = nn.Embedding(1000, d_model) # Simplified positional encoding
self.transformer = nn.TransformerEncoder(
nn.TransformerEncoderLayer(d_model, nhead,
dim_feedforward=4*d_model, dropout=dropout),
num_layers
)
self.fc_out = nn.Linear(d_model, vocab_size)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
x = self.embedding(x) + self.pos_encoder(
torch.arange(x.size(1), device=x.device))
x = self.dropout(x)
x = x.transpose(0, 1) # Transform to shape expected by transformer
x = self.transformer(x)
x = x.transpose(0, 1) # Transform back
return self.fc_out(x)
model = TransformerWithDropout(vocab_size=50257,
d_model=768, nhead=12, num_layers=12, dropout=0.1)
print(
f"Model parameters: "
f"{sum(p.numel() for p in model.parameters()):,}"
)
在这个实现中,dropout 在嵌入层之后以及每个变换器层内应用。0.1的 dropout 率是典型的,但根据您的具体用例,您可能需要调整这个值。
请记住,dropout 仅在训练期间应用,不在推理期间(当模型被用于做出预测时)应用。
在训练期间,神经元以指定的概率随机“停用”(失活)(例如,0.5表示每个神经元有 50%的机会在该训练批次中被关闭)。这迫使网络学习更鲁棒的特征,因为它不能依赖于任何单个神经元始终存在。
在推理(测试、评估或部署)期间,dropout 被禁用,所有神经元都是活跃的。然而,权重通常按 dropout 率进行缩放,以考虑到训练期间活跃的神经元比推理期间更多。这种缩放确保了期望的输出幅度保持一致。
这种仅在训练中应用的 dropout 是使其作为正则化技术有效的关键部分——它在训练期间创建了一种集成学习的形式,同时在实际使用时仍允许网络发挥全部能力。
层级自适应正则化
层级自适应正则化涉及对模型的不同层应用不同的正则化强度。这对于 LLM 尤其有效,其中较低层可能从较少的正则化中受益以捕捉基本模式,而较高层可能需要更强的正则化以防止过拟合。
以下 Python 代码定义了一个LayerwiseAdaptiveRegularization类,这是一个 PyTorch nn.Module,旨在封装一个基础变换器模型并应用一个随着模型层深度线性增加的 dropout 率:
class LayerwiseAdaptiveRegularization(nn.Module):
def __init__(
self, base_model, num_layers, base_dropout=0.1,
dropout_increase_per_layer=0.02
):
super().__init__()
self.base_model = base_model
self.num_layers = num_layers
self.base_dropout = base_dropout
self.dropout_increase_per_layer = dropout_increase_per_layer
self.set_layerwise_dropout()
def set_layerwise_dropout(self):
for i, layer in enumerate(self.base_model.transformer.h):
dropout = self.base_dropout
+ i * self.dropout_increase_per_layer
layer.attn.dropout.p = dropout
layer.mlp.dropout.p = dropout
def forward(self, *args, kwargs):
return self.base_model(*args, kwargs)
base_model = create_lm_model()
model = LayerwiseAdaptiveRegularization(base_model, num_layers=12)
LayerwiseAdaptiveRegularization类使用基础模型、层数、起始 dropout 概率以及后续每层的增量进行初始化。然后,它配置变换器块中注意力和 MLP 子层的 dropout 概率。最后,其前向方法简单地通过封装的基础模型传递输入。其使用示例是通过将create_lm_model()与这个层级 dropout 正则化封装来展示的。
此实现包装了一个基本的 GPT-2 模型,并将递增的 dropout 率应用于更高层。基本 dropout 率是0.1,后续每层增加0.02。
梯度裁剪和噪声注入
梯度裁剪和噪声注入是用于提高大型语言模型(LLMs)训练稳定性和泛化的技术。
梯度裁剪,虽然主要用于优化稳定性(参见第七章),但可以间接地促进正则化。通过限制梯度的幅度,它可以约束模型参数的更新,可能带来更平滑的优化路径并防止过拟合。在某些情况下,梯度裁剪可以有效地减少某些参数的影响,尤其是当这些参数的梯度持续被裁剪时。这可能导致一种隐式稀疏性,即不那么重要的参数被有效地降低权重。
噪声注入是一种常用的正则化技术,用于提高机器学习模型的泛化能力。通过向输入数据、权重或激活函数添加少量噪声,噪声注入有助于防止过拟合。该技术迫使模型对训练数据中的特定模式依赖性降低,鼓励它学习更稳健、更通用的特征,这些特征适用于不同的数据集。这种方法在神经网络中特别有用,以下噪声可以在各个阶段注入:
-
输入噪声:直接向输入数据添加噪声,帮助模型对输入的变异性更加鲁棒
-
权重噪声:在训练过程中扰动权重,鼓励模型更好地泛化
-
激活噪声:向激活函数添加噪声,导致决策边界更加平滑并减少过拟合
这些方法有助于防止过拟合,减少异常值的影响,并鼓励模型探索更广泛的解决方案,最终导致更稳健和可靠的语言模型。
下面是如何实现梯度裁剪和噪声注入的方法:
import torch.nn.functional as F
def train_with_grad_clip_and_noise(
model, train_dataloader, grad_clip=1.0,
noise_factor=0.01, lr=5e-5, epochs=3
):
optimizer = AdamW(model.parameters(), lr=lr)
for epoch in range(epochs):
model.train()
total_loss = 0
for batch in train_dataloader:
optimizer.zero_grad()
# Add noise to inputs
input_ids = batch['input_ids']
noise = torch.randn_like(
input_ids, dtype=torch.float) * noise_factor
noisy_inputs = input_ids.float() + noise
noisy_inputs = noisy_inputs.long().clamp(
min=0, max=model.config.vocab_size - 1)
outputs = model(input_ids=noisy_inputs, labels=input_ids)
loss = outputs.loss
loss.backward()
clip_grad_norm_(model.parameters(), grad_clip)
optimizer.step()
total_loss += loss.item()
print(
f"Epoch {epoch + 1}, "
f"Loss: {total_loss / len(train_dataloader):.4f}"
)
# Assuming you have a train_dataloader
# train_with_grad_clip_and_noise(model, train_dataloader)
此实现应用梯度裁剪以防止梯度爆炸,并向输入添加少量噪声以提高鲁棒性。noise_factor控制添加噪声的量;您可能需要根据您的特定用例进行调整。
函数初始化一个AdamW 优化器,并对数据集进行指定数量的轮次迭代。在每次训练步骤中,它清除旧梯度,向输入标记添加噪声(确保值保持在词汇范围内),并将带噪声的输入送入模型进行正向和反向传播。梯度裁剪防止梯度爆炸,确保稳定训练。优化器更新模型参数,并跟踪损失以监控进度。最后,函数打印每轮的平均损失。
接下来,让我们探讨转移学习和微调场景中的规范化。
转移学习和微调场景中的规范化
在微调预训练的 LLM 时,仔细调整规范化以避免阻碍特定任务的适应同时仍然防止过拟合是很重要的。以下是一种使用自适应规范化的微调方法:
from transformers import GPT2LMHeadModel, GPT2Tokenizer
def fine_tune_with_adaptive_regularization(
pretrained_model_name, train_dataloader,
initial_dropout=0.1, epochs=3
):
model = GPT2LMHeadModel.from_pretrained(pretrained_model_name)
tokenizer = GPT2Tokenizer.from_pretrained(pretrained_model_name)
optimizer = AdamW(model.parameters(), lr=5e-5, weight_decay=0.01)
for epoch in range(epochs):
model.train()
total_loss = 0
current_dropout = initial_dropout * (1 - epoch / epochs)
for module in model.modules():
if isinstance(module, nn.Dropout):
module.p = current_dropout
for batch in train_dataloader:
optimizer.zero_grad()
outputs = model(batch)
loss = outputs.loss
loss.backward()
optimizer.step()
total_loss += loss.item()
print(
f"Epoch {epoch + 1}, "
f"Loss: {total_loss / len(train_dataloader):.4f}, "
f"Dropout: {current_dropout:.4f}"
)
# Assuming you have a train_dataloader
# fine_tune_with_adaptive_regularization('gpt2', train_dataloader)
此实现从更高的 dropout 率开始,并在微调过程中逐渐降低它。这允许模型适应新任务,同时仍然保持一些规范化以防止过拟合。这种方法也称为自适应 dropout。
自适应 dropout 之所以有效,是因为它根据神经元的重要性动态调整 dropout 率,而不是在整个网络中应用均匀的 dropout。通过选择性更频繁地丢弃不那么关键的神经元,同时保留重要的特征检测器,自适应 dropout 在规范化和信息保留之间创造了一个最佳平衡。这种有针对性的方法比标准 dropout 更有效地防止过拟合,因为它通过重要的神经元保持网络的复杂模式学习能力,同时积极规范化冗余或噪声敏感的部分,从而产生泛化能力更强且在关键特征上性能损失较小的模型。
出现的规范化技术
近年来,出现了解决现代深度学习架构复杂挑战的复杂技术。这些新方法不仅超越了简单地防止过拟合,它们旨在提高模型鲁棒性,在损失景观中找到更好的极值,并通过创新的训练策略增强泛化。从几何启发方法如削弱度感知最小化(SAM)到高级优化策略如随机权重平均(SWA),这些新兴的规范化技术正在重塑我们处理模型训练和泛化的方式。
随机权重平均
SWA 是一种通过平均优化轨迹上的多个点的权重来提高神经网络泛化能力的技巧,有效地找到更平坦、更稳健的极小值,这些极小值在未见过的数据上的表现优于传统优化方法通常找到的尖锐极小值。随机梯度下降(SGD)是一种基本的优化算法,通过跟踪在随机选择的训练数据小批次上计算的损失函数的负梯度来更新模型参数,通过近似全梯度计算同时引入有益的噪声来帮助逃离不良局部极小值,从而实现大型模型(如神经网络)的高效训练。
WA 涉及使用修改后的学习率计划对 SGD 轨迹上的多个点进行平均。它通过找到更广泛的极值来提高泛化能力。以下是一个代码示例:
from torch.optim.swa_utils import AveragedModel, SWALR
# Create SWA model and scheduler
swa_model = AveragedModel(model)
swa_scheduler = SWALR(optimizer, swa_lr=0.05)
# Training loop with SWA
for epoch in range(100):
if epoch > 75: # Start SWA after epoch 75
swa_model.update_parameters(model)
swa_scheduler.step()
削弱度感知最小化
SAM 寻求位于具有均匀低损失值邻域中的参数,从而实现更好的泛化。其关键特性如下:
-
寻找“平坦”的极小值而不是尖锐的极小值
-
提高了对输入扰动的鲁棒性
-
通常比标准的 SGD 提供更好的泛化能力
让我们在下面的 Python 代码中实现SAM类:
class SAM(torch.optim.Optimizer):
def __init__(self, params, base_optimizer, rho=0.05):
self.rho = rho
self.base_optimizer = base_optimizer(params)
def step(self):
# First forward-backward pass
grad_norm = self._grad_norm()
scale = self.rho / (grad_norm + 1e-12)
# Perturb weights
for group in self.param_groups:
for p in group['params']:
e_w = p.grad * scale
p.add_(e_w)
# Second forward-backward pass
self.base_optimizer.step()
基于差分隐私的正则化
差分隐私(DP)是一种技术,通过对数据或计算添加精心校准的噪声来保护个人隐私,同时仍然允许有用的见解,确保任何单个数据点的包含或排除都不会显著影响模型性能。
基于 DP 的正则化是一种技术,通过向模型的训练过程中添加噪声来增强模型隐私,从而保护个人数据点不被暴露在模型输出或学习表示中。通过引入受控的随机性,基于 DP 的正则化限制了模型对任何特定数据样本的依赖,从而降低了过拟合的风险,并使模型对个人数据点的变化不那么敏感。这种方法在需要数据保密的应用中特别有价值,因为它确保模型可以在不泄露训练数据具体信息的情况下学习可泛化的模式,使其在医疗保健、金融和其他需要数据保密的领域非常有用。
以下代码片段实现了DPOptimizer类:
class DPOptimizer(torch.optim.Optimizer):
def __init__(
self, params, noise_multiplier=1.0, max_grad_norm=1.0
):
self.noise_multiplier = noise_multiplier
self.max_grad_norm = max_grad_norm
def step(self):
# Clip gradients
torch.nn.utils.clip_grad_norm_(self.param_groups[0]['params'],
self.max_grad_norm)
# Add noise
for p in self.param_groups[0]['params']:
noise = torch.randn_like(p.grad) * self.noise_multiplier
p.grad.add_(noise)
快速梯度符号方法
快速梯度符号方法(FGSM)是一种通过向输入数据添加小的、有针对性的扰动来创建对抗性样本的技术,使模型误分类。它通过计算损失函数相对于输入的梯度并应用一个轻微调整,以最大化模型错误的方向来实现。输入数据通过一个称为ϵ的因子控制的微小量进行轻微改变,以创建一个可以欺骗机器学习模型的“对抗性示例”。FGSM 通常用于测试模型鲁棒性和对抗性训练,其中模型在对抗性示例上进行训练以增强安全性。然而,FGSM 的单步特性使其快速但对抗强大防御的效果较差,与实现更高攻击成功率的迭代方法不同。
让我们看看它是如何在这里实现的:
def fgsm_attack(image, epsilon, data_grad):
sign_data_grad = data_grad.sign()
perturbed_image = image + epsilon * sign_data_grad
perturbed_image = torch.clamp(perturbed_image, 0, 1)
return perturbed_image
看前优化器
预视优化器是一种创新的优化技术,通过维持两组参数:快速权重和慢速权重,来增强传统优化器(如 Adam 或 SGD)的训练稳定性和收敛性。快速权重通过使用标准优化器频繁更新,而慢速权重通过与其同步更新来较少地更新。这种方法允许优化器更好地探索损失景观,因为优化器可以逃离局部最小值并平滑优化轨迹中的振荡。通过利用基础优化器和预视机制的优势,这种优化器实现了更快的收敛和更好的泛化,使其成为深度学习模型训练中的一个宝贵补充。
以下代码片段展示了如何实现预视优化器:
class Lookahead(torch.optim.Optimizer):
def __init__(self, optimizer, k=5, alpha=0.5):
self.optimizer = optimizer
self.k = k
self.alpha = alpha
self.step_counter = 0
self.slow_weights = [
[p.clone().detach() for p in group['params']]
for group in optimizer.param_groups
]
def step(self):
self.step_counter += 1
self.optimizer.step()
if self.step_counter % self.k == 0:
for group, slow_weights in zip(
self.optimizer.param_groups, self.slow_weights
):
for p, q in zip(group['params'], slow_weights):
p.data.mul_(self.alpha).add_(
q, alpha=1.0 - self.alpha)
q.data.copy_(p.data)
摘要
在本章中,我们介绍了诸如权重衰减和 L2 正则化、dropout 方法、逐层自适应正则化以及结合多种正则化方法等基本概念。我们还讨论了迁移学习和微调场景下的正则化策略,以及增强模型稳定性的技术,例如梯度裁剪和噪声注入。此外,我们还介绍了各种新兴的正则化方法。
在下一章中,我们将探讨检查点和恢复技术,并研究为什么这些技术对于管理长时间运行的训练过程至关重要。
第十章:检查点和恢复
检查点和恢复指的是在特定间隔保存系统、应用程序或模型状态的过程(检查点),以及在出现故障时从保存的状态中恢复(恢复)。在机器学习中,检查点包括定期保存模型参数、优化器状态和训练进度,以便可以从最后一个检查点恢复训练,而不是从头开始。这对于长时间运行的任务特别有用,否则由于系统崩溃、电源故障或抢占式云实例的中断可能会造成重大损失。
检查点和恢复对于确保大规模模型训练的容错性、效率和可重现性至关重要。没有检查点,意外的故障可能会浪费数小时甚至数天的计算时间。此外,它还允许实验可重现性,使研究人员能够从中间状态重新访问和微调模型,而不是重新进行整个训练过程。高效的检查点策略(例如,在固定间隔或验证性能提高时保存)有助于平衡存储开销,同时最小化重新训练成本。
在本章中,我们将探讨确定最佳检查点频率的策略、大型模型的高效存储格式以及从各种类型故障中恢复的技术。您还将深入了解分布式训练场景中的检查点以及模型检查点的版本控制。
在本章中,我们将讨论以下主题:
-
为什么检查点很重要?
-
检查点频率和存储策略
-
高效的检查点格式
-
从故障中恢复
-
分布式 LLM 训练中的检查点
-
LLM 检查点的版本控制
-
自动检查点和恢复系统
为什么检查点很重要?
由于 LLM 训练过程持续时间长且资源密集,检查点是一种常见的做法。
让我们实现一个基本的检查点系统:
import torch
from transformers import GPT2LMHeadModel, GPT2Config
import os
class LLMTrainer:
def __init__(
self, model, optimizer, checkpoint_dir='checkpoints'
):
self.model = model
self.optimizer = optimizer
self.checkpoint_dir = checkpoint_dir
os.makedirs(checkpoint_dir, exist_ok=True)
def save_checkpoint(self, epoch, step, loss):
checkpoint = {
'epoch': epoch,
'step': step,
'model_state_dict': self.model.state_dict(),
'optimizer_state_dict': self.optimizer.state_dict(),
'loss': loss
}
checkpoint_path = os.path.join(self.checkpoint_dir,
f'checkpoint_epoch_{epoch}_step_{step}.pt')
torch.save(checkpoint, checkpoint_path)
print(f"Checkpoint saved: {checkpoint_path}")
def load_checkpoint(self, checkpoint_path):
checkpoint = torch.load(checkpoint_path)
self.model.load_state_dict(checkpoint['model_state_dict'])
self.optimizer.load_state_dict(
checkpoint['optimizer_state_dict'])
return (
checkpoint['epoch'], checkpoint['step'],
checkpoint['loss']
)
# Simulating training loop
for epoch in range(10):
for step in range(1000):
# ... training code ...
if step % 100 == 0:
trainer.save_checkpoint(epoch, step, loss.item())
# Loading a checkpoint
epoch, step, loss = trainer.load_checkpoint(
'checkpoints/checkpoint_epoch_5_step_500.pt')
print(f"Resumed training from epoch {epoch}, step {step}, with loss {loss}")
这个实现展示了检查点系统的基本结构。save_checkpoint 方法保存模型状态、优化器状态和训练进度信息。load_checkpoint 方法允许您从保存的检查点恢复训练。
检查点频率和存储策略
确定最佳检查点频率需要在安全性和效率之间取得平衡。让我们探讨不同的策略及其实现:
import time
import shutil
class AdvancedLLMTrainer(LLMTrainer):
def __init__(
self, model, optimizer, checkpoint_dir='checkpoints',
max_checkpoints=5
):
super().__init__(model, optimizer, checkpoint_dir)
self.max_checkpoints = max_checkpoints
self.checkpoints = []
def save_checkpoint(self, epoch, step, loss):
checkpoint_path = super().save_checkpoint(epoch, step, loss)
self.checkpoints.append(checkpoint_path)
if len(self.checkpoints) > self.max_checkpoints:
oldest_checkpoint = self.checkpoints.pop(0)
os.remove(oldest_checkpoint)
print(f"Removed old checkpoint: {oldest_checkpoint}")
def save_checkpoint_by_time(
self, epoch, step, loss, interval_minutes=60
):
current_time = time.time()
if (
not hasattr(self, 'last_checkpoint_time') or
current_time - self.last_checkpoint_time >=
interval_minutes * 60
):
self.save_checkpoint(epoch, step, loss)
self.last_checkpoint_time = current_time
def save_best_checkpoint(self, epoch, step, loss):
if not hasattr(self, 'best_loss') or loss < self.best_loss:
self.best_loss = loss
checkpoint_path = os.path.join(
self.checkpoint_dir, 'best_model.pt')
torch.save({
'epoch': epoch,
'step': step,
'model_state_dict': self.model.state_dict(),
'optimizer_state_dict': self.optimizer.state_dict(),
'loss': loss
}, checkpoint_path)
print(f"Best model saved: {checkpoint_path}")
# Usage example
trainer = AdvancedLLMTrainer(model, optimizer)
for epoch in range(10):
for step in range(1000):
# ... training code ...
trainer.save_checkpoint_by_time(epoch, step, loss.item(),
interval_minutes=30)
trainer.save_best_checkpoint(epoch, step, loss.item())
这个实现介绍了几种检查点策略:
-
最大检查点数限制的常规检查点: 当达到限制时,通过删除旧检查点来防止过多的磁盘使用
-
基于时间的检查点: 这会在固定的时间间隔保存检查点,这对于长时间运行的训练过程非常有用
-
最佳模型检查点:保存性能最佳(在这种情况下为损失最低)的模型,这对于模型选择很有用
下面是对三种检查点策略的权衡分析:
-
定期检查点,并设置最大检查点数:
-
优点:防止过度使用存储,并确保定期保存训练进度快照
-
缺点:可能会覆盖有用的旧检查点,如果性能波动,可能会丢失好的模型
-
最佳使用场景:当存储是限制因素且需要定期快照以进行恢复时
-
-
基于时间的检查点:
-
优点:确保检查点随时间分散,这对于监控长时间训练运行很有用
-
缺点:如果检查点保存过于频繁(浪费存储)或过于稀疏(错过关键状态),则可能效率低下
-
最佳使用场景:对于需要持续快照以进行调试或回滚的长运行训练过程
-
-
最佳模型检查点:
-
优点:保留了最有希望的模型,这对于最终模型选择很有用。
-
缺点:如果损失值波动较大,单个“最佳”检查点可能并不真正具有代表性。可能无法捕捉到中间学习动态。
-
最佳使用场景:当选择性能最佳的模型比定期快照更重要时。
-
选择您希望采用的策略时,以下是一些需要考虑的因素:
-
计算成本:频繁的检查点会增加磁盘 I/O 和 CPU 开销
-
故障恢复:定期和基于时间的检查点有助于在训练中断后恢复训练,而最佳模型检查点可能不会提供最新的进度
-
存储限制:维护许多检查点会消耗存储;设置限制的定期检查点在管理存储方面最有效
-
模型改进率:如果模型改进迅速,频繁的检查点可能很有用;如果进展缓慢,较少但更有策略的检查点可能就足够了
对于 LLM 的检查点推荐方法是结合策略:
-
使用定期检查点(例如,每隔几个小时)以确保进度被保存
-
使用最佳模型检查点来保留性能最佳的模型
-
使用最近检查点的滚动窗口来平衡存储效率和恢复选项
高效的检查点格式
对于拥有数十亿参数的 LLM,检查点的大小可能成为一个重大问题。让我们探讨一些高效检查点存储的策略:
-
导入必要的库并实现
EfficientLLMTrainer:import torch import io import zipfile class EfficientLLMTrainer(AdvancedLLMTrainer): def save_checkpoint_efficient(self, epoch, step, loss): checkpoint = { 'epoch': epoch, 'step': step, 'model_state_dict': self.model.state_dict(), 'optimizer_state_dict': self.optimizer.state_dict(), 'loss': loss } checkpoint_path = os.path.join( self.checkpoint_dir, f'checkpoint_epoch_{epoch}_step_{step}.zip') with zipfile.ZipFile(checkpoint_path, 'w', zipfile.ZIP_DEFLATED ) as zipf: for key, value in checkpoint.items(): if isinstance(value, dict): # For model and optimizer state_dicts buffer = io.BytesIO() torch.save(value, buffer) zipf.writestr(f'{key}.pt', buffer.getvalue()) else: zipf.writestr(f'{key}.txt', str(value)) print(f"Efficient checkpoint saved: {checkpoint_path}")此代码定义了一个扩展
AdvancedLLMTrainer(可能是一个用于训练 LLM 的现有类)的EfficientLLMTrainer类。实现的关键功能是save_checkpoint_efficient,它以压缩的 ZIP 格式高效地保存模型检查点。 -
定义一个函数 (
load_checkpoint_efficient) 来加载 ZIP 格式的检查点:def load_checkpoint_efficient(self, checkpoint_path): checkpoint = {} with zipfile.ZipFile(checkpoint_path, 'r') as zipf: for filename in zipf.namelist(): if filename.endswith('.pt'): with zipf.open(filename) as f: key = filename[:-3] # Remove .pt extension checkpoint[key] = torch.load( io.BytesIO(f.read())) else: with zipf.open(filename) as f: key = filename[:-4] # Remove .txt extension value = f.read().decode('utf-8') checkpoint[key] = ( int(value) if key in ['epoch', 'step'] else float(value) ) self.model.load_state_dict( checkpoint['model_state_dict']) self.optimizer.load_state_ dict(checkpoint['optimizer_state_dict']) return ( checkpoint['epoch'], checkpoint['step'], checkpoint['loss'] )此函数
load_checkpoint_efficient负责从 ZIP 文件中加载先前保存的检查点并恢复模型和优化器状态。请参阅以下示例用法。 -
示例用法:
trainer = EfficientLLMTrainer(model, optimizer) trainer.save_checkpoint_efficient(epoch, step, loss.item()) epoch, step, loss = trainer.load_checkpoint_ efficient( 'checkpoints/checkpoint_epoch_5_step_500.zip')此实现使用 ZIP 压缩来减小检查点的大小。它还将模型和优化器状态字典与其他元数据分开,从而允许更高效的存储和加载。
其他高效的检查点存储策略包括以下内容:
-
量化:降低模型权重的精度(例如,从 float32 降低到 float16)可以显著减小检查点的大小(有关此策略的更多信息,请参阅第十三章)
-
增量检查点:只保存自上次检查点以来的更改,而不是整个模型状态
-
分布式存储:在多 GPU 或多节点设置中,将检查点分布到多个存储设备
-
云存储:使用提供快速 I/O 和自动压缩的云存储解决方案
对于非常大的模型,您还可以考虑更高级的技术,例如模型分片,其中模型的各个部分分别保存,并且可以根据需要加载。
从失败中恢复
鲁棒性恢复机制对于 LLM 训练至关重要。让我们实现一个可以处理各种类型故障的系统:
import signal
import sys
class RobustLLMTrainer(EfficientLLMTrainer):
def __init__(
self, model, optimizer, checkpoint_dir='checkpoints',
autosave_interval=15
):
super().__init__(model, optimizer, checkpoint_dir)
self.autosave_interval = autosave_interval
self.setup_signal_handlers()
def setup_signal_handlers(self):
signal.signal(signal.SIGINT, self.handle_interrupt)
signal.signal(signal.SIGTERM, self.handle_interrupt)
def handle_interrupt(self, signum, frame):
print("Interrupted! Saving checkpoint before exiting...")
self.save_checkpoint_efficient(self.current_epoch,
self.current_step, self.current_loss)
sys.exit(0)
def train(self, epochs, steps_per_epoch, train_fn):
try:
start_epoch, start_step = 0, 0
latest_checkpoint = self.get_latest_checkpoint()
if latest_checkpoint:
start_epoch, start_step, _ = \
self.load_checkpoint_efficient(latest_checkpoint)
print(
f"Resuming from epoch {start_epoch}, "
f"step {start_step}"
)
for epoch in range(start_epoch, epochs):
self.current_epoch = epoch
for step in range(start_step, steps_per_epoch):
self.current_step = step
self.current_loss = train_fn(
self.model, epoch, step)
if step % self.autosave_interval == 0:
self.save_checkpoint_efficient(
epoch, step, self.current_loss)
start_step = 0 # Reset step counter at the start of each epoch
except Exception as e:
print(f"Error occurred: {e}")
print("Saving checkpoint before exiting...")
self.save_checkpoint_efficient(self.current_epoch,
self.current_step, self.current_loss)
raise
def get_latest_checkpoint(self):
checkpoints = sorted(os.listdir(self.checkpoint_dir))
return (
os.path.join(self.checkpoint_dir, checkpoints[-1])
if checkpoints
else None
)
# Usage
def train_step(model, epoch, step):
# Simulated training step
loss = 1 / (epoch + 1 + step + 1) # Dummy loss that decreases over time
return loss
trainer = RobustLLMTrainer(model, optimizer)
trainer.train(epochs=10, steps_per_epoch=1000, train_fn=train_step)
RobustLLMTrainer类扩展了EfficientLLMTrainer,通过处理中断(例如,Ctrl + C的SIGINT和终止的SIGTERM)并保存检查点来增加鲁棒性,以防止数据丢失。它使用模型、优化器、检查点目录和自动保存间隔进行初始化,然后设置信号处理程序,在退出前通过保存进度来触发优雅的关闭。
在训练期间,如果可用,它将尝试从最新的检查点恢复。它遍历时代和步骤,运行train_fn来计算损失,并根据autosave_interval定期保存检查点。如果发生异常,它将捕获错误,保存进度,并重新抛出异常以避免静默失败。
get_latest_checkpoint()方法通过在检查点目录中对文件进行排序来检索最新的检查点(尽管缺少os模块,应该导入)。脚本以一个示例用法结束,其中定义了一个虚拟损失函数,并使用trainer.train(epochs=10, steps_per_epoch=1000, train_fn=train_step)开始训练。
此实现包括几个鲁棒性功能:
-
信号处理:训练器捕获中断信号(Ctrl + C)并在退出前优雅地保存检查点
-
自动恢复:当开始训练时,训练器会自动找到并加载最新的检查点
-
常规自动保存:在训练期间,检查点会以固定间隔保存
-
异常处理:如果在训练期间发生错误,在重新抛出异常之前将保存检查点
这些功能有助于从各种类型的故障中恢复:
-
系统崩溃或断电:定期自动保存确保不会丢失太多进度
-
用户中断:信号处理允许优雅地退出并保存状态
-
代码错误:异常处理确保即使在发生意外错误的情况下也能保存进度
为了实现更健壮的恢复,考虑实施以下措施:
-
检查点验证:在加载检查点之前验证其完整性
-
多个备份检查点:保留几个最近的检查点以防最新的一个被损坏
-
分布式检查点:在多节点设置中,确保所有节点上的检查点一致性
分布式 LLM 训练中的检查点
分布式训练给检查点引入了额外的复杂性。
让我们分解基本分布式检查点系统的实现,并理解每个组件:
-
我们首先定义继承自
RobustLLMTrainer的DistributedLLMTrainer类。DistributedLLMTrainer类是为使用 PyTorch 的torch.distributed框架进行 LLM 的分布式训练而设计的。它确保模型在多个设备(例如,GPU)或节点上高效训练:import torch.distributed as dist class DistributedLLMTrainer(RobustLLMTrainer): def __init__( self, model, optimizer, checkpoint_dir='checkpoints', autosave_interval=15 ): super().__init__(model, optimizer, checkpoint_dir, autosave_interval) self.rank = dist.get_rank() self.world_size = dist.get_world_size()初始化执行以下操作:
-
调用父类初始化器。
-
设置分布式训练属性:
-
self.rank:标识当前进程 -
self.world_size:指示进程总数
-
-
-
我们随后使用以下方法在分布式训练期间保存和加载检查点:
def save_checkpoint_distributed(self, epoch, step, loss): if self.rank == 0: # Only the main process saves checkpoints self.save_checkpoint_efficient(epoch, step, loss) dist.barrier() # Synchronize all processes def load_checkpoint_distributed(self, checkpoint_path): if self.rank == 0: epoch, step, loss = \ self.load_checkpoint_efficient(checkpoint_path) else: epoch, step, loss = 0, 0, 0.0 # Broadcast the loaded data to all processes epoch = torch.tensor(epoch).to(self.rank) step = torch.tensor(step).to(self.rank) loss = torch.tensor(loss).to(self.rank) dist.broadcast(epoch, 0) dist.broadcast(step, 0) dist.broadcast(loss, 0) # Make sure all processes have loaded the checkpoint dist.barrier() return epoch.item(), step.item(), loss.item()以下方法处理分布式检查点:
-
save_checkpoint_distributed:只有主进程(rank0)保存检查点以避免冗余写入,减少磁盘 I/O,并确保进程间的一致性。如果所有 rank 独立保存,可能会导致存储效率低下和潜在的竞争条件。保存后,dist.barrier()同步所有进程以确保它们等待检查点写入。在加载时,只有 rank0读取检查点以防止冗余磁盘访问;然后,使用dist.broadcast()将加载的值广播到所有其他 rank,确保每个进程在继续训练前从相同的状态开始。 -
load_checkpoint_distributed:-
只有主进程加载检查点
-
将加载的值广播到所有其他进程
-
确保所有进程具有相同的检查点数据
-
-
-
接下来,我们实现分布式训练:
def train_distributed(self, epochs, steps_per_epoch, train_fn): try: start_epoch, start_step = 0, 0 if self.rank == 0: latest_checkpoint = self.get_latest_checkpoint() if latest_checkpoint: start_epoch, start_step, _ = \ self.load_checkpoint_efficient( latest_checkpoint) # Broadcast the starting epoch and step to all processes start_epoch = torch.tensor(start_epoch).to(self.rank) start_step = torch.tensor(start_step).to(self.rank) dist.broadcast(start_epoch, 0) dist.broadcast(start_step, 0) start_epoch = start_epoch.item() start_step = start_step.item() if self.rank == 0: print( f"Resuming from epoch {start_epoch}, " f"step {start_step}" ) for epoch in range(start_epoch, epochs): self.current_epoch = epoch for step in range(start_step, steps_per_epoch): self.current_step = step self.current_loss = train_fn( self.model, epoch, step) if step % self.autosave_interval == 0: self.save_checkpoint_distributed( epoch, step, self.current_loss) start_step = 0 # Reset step counter at the start of each epoch except Exception as e: print(f"Error occurred on rank {self.rank}: {e}") self.save_checkpoint_distributed(self.current_epoch, self.current_step, self.current_loss) dist.destroy_process_group() raisetrain_distributed方法执行以下操作:-
确定起始点(epoch 和 step)
-
将此信息广播到所有进程
-
带有定期检查点的运行训练循环
-
通过保存最终检查点和清理来处理异常
-
-
我们随后使用以下代码初始化分布式训练,设置模型以进行并行执行,并执行简单的分布式训练循环:
def init_distributed(): dist.init_process_group(backend='nccl') rank = dist.get_rank() torch.cuda.set_device(rank) return rank def distributed_train_step(model, epoch, step): # Simulated distributed training step loss = 1 / (epoch + 1 + step + 1) # Dummy loss that decreases over time return loss def main(): rank = init_distributed() model = GPT2LMHeadModel(GPT2Config()).to(rank) model = torch.nn.parallel.DistributedDataParallel( model, device_ids=[rank]) optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5) trainer = DistributedLLMTrainer(model, optimizer) trainer.train_distributed(epochs=10, steps_per_epoch=1000, train_fn=distributed_train_step) if __name__ == "__main__": main()此代码包括以下内容:
-
init_distributed:初始化分布式训练环境 -
distributed_train_step:一个用于演示的虚拟训练函数 -
main:展示了如何在实践中使用DistributedLLMTrainer
-
分布式检查点策略的关键考虑因素包括在检查点期间通过使用屏障同步所有进程以保持一致性,确保只有主进程处理 I/O 操作以避免冲突,以及通过从主进程广播到其他进程有效地共享重要数据。此外,系统集成了强大的错误处理功能,允许它在出现故障时优雅地保存检查点并清理分布式资源。
接下来,让我们关注版本控制方面。
LLM 检查点的版本控制
LLM 检查点的版本控制可以帮助在开发过程中管理模型的多个版本。以下是一个简单的实现:
import os
import json
import shutil
class VersionControlledLLMTrainer(DistributedLLMTrainer):
def __init__(
self, model, optimizer, checkpoint_dir='checkpoints',
version_file='versions.json'
):
super().__init__(model, optimizer, checkpoint_dir)
self.version_file = version_file
self.versions = self.load_versions()
def load_versions(self):
if os.path.exists(self.version_file):
with open(self.version_file, 'r') as f:
return json.load(f)
return {}
def save_versions(self):
with open(self.version_file, 'w') as f:
json.dump(self.versions, f, indent=2)
def save_checkpoint_versioned(
self, epoch, step, loss, version_name
):
checkpoint_path = self.save_checkpoint_efficient(
epoch, step, loss)
self.versions[version_name] = {
'path': checkpoint_path,
'epoch': epoch,
'step': step,
'loss': loss
}
self.save_versions()
print(f"Saved version '{version_name}': {checkpoint_path}")
def load_checkpoint_versioned(self, version_name):
if version_name not in self.versions:
raise ValueError(f"Version '{version_name}' not found")
version_info = self.versions[version_name]
return self.load_checkpoint_efficient(version_info['path'])
def create_branch(self, base_version, new_version):
if base_version not in self.versions:
raise ValueError(
f"Base version '{base_version}' not found")
base_info = self.versions[base_version]
new_path = f"{self.checkpoint_dir}/branch_{new_version}.pt"
shutil.copy(base_info['path'], new_path)
self.versions[new_version] = {
'path': new_path,
'epoch': base_info['epoch'],
'step': base_info['step'],
'loss': base_info['loss'],
'branched_from': base_version
}
self.save_versions()
print(f"Created branch '{new_version}' from '{base_version}'")
# Usage
trainer = VersionControlledLLMTrainer(model, optimizer)
trainer.save_checkpoint_versioned(epoch=10, step=500,
loss=0.1, version_name="v1.0")
trainer.create_branch("v1.0", "experimental_branch")
epoch, step, loss = trainer.load_checkpoint_versioned(
"experimental_branch")
此实现提供了基本的版本控制功能:
-
版本跟踪:每个保存的检查点都可以关联一个版本名称
-
分支:您可以从现有的检查点创建新的分支,以便进行实验
-
版本历史:版本信息存储在一个 JSON 文件中,以便于检查和管理
版本控制对于 LLM 检查点的关键好处如下:
-
实验:您可以从一个共同的起点轻松尝试不同的训练策略或超参数
-
协作:团队成员可以共享并工作于模型的不同版本
-
可重现性:可以引用和重新创建模型的特定版本
自动检查点和恢复系统
为了使检查点和恢复过程更加稳健和自动化,我们可以实现一个自动化的系统:
-
首先,导入所需的模块:
import threading import time在这里,我们导入了两个关键模块:
-
threading:启用创建线程以与主训练过程并发运行任务(如自动保存和健康检查) -
time:用于管理自动保存和健康检查之间的间隔,以及保存检查点的时间戳
-
-
接下来,我们定义并初始化这个类:
class AutomatedLLMTrainer(VersionControlledLLMTrainer): def __init__( self, model, optimizer, checkpoint_dir='checkpoints', autosave_interval=15, version_file='versions.json', health_check_interval=60 ): super().__init__(model, optimizer, checkpoint_dir, version_file) self.autosave_interval = autosave_interval self.health_check_interval = health_check_interval self.training_active = FalseAutomatedLLMTrainer类继承自基类VersionControlledLLMTrainer,该基类处理基本的检查点逻辑。这个类引入了检查点和系统健康监控的自动化。以下是一系列参数,用于管理自动保存、系统健康检查和训练执行控制:
-
autosave_interval:自动保存检查点之间的时间(以秒为单位)。 -
health_check_interval:系统健康检查之间的时间。 -
training_active:一个标志,用于指示训练是否正在进行。它用于控制线程的执行。
构造函数调用
super().__init__()以从父类继承功能,并设置自动保存和健康检查的间隔。 -
-
自动保存线程以进行检查点:
def start_autosave_thread(self): def autosave_loop(): while self.training_active: time.sleep(self.autosave_interval) if self.training_active: self.save_checkpoint_versioned( self.current_epoch, self.current_step, self.current_loss, f"autosave_{time.time()}") self.autosave_thread = threading.Thread( target=autosave_loop) self.autosave_thread.start()此方法在训练期间启动一个单独的线程,定期保存检查点。
以下是在训练期间处理周期性自动保存的组件列表:
-
autosave_loop:一个在training_active为True时持续运行的函数。每autosave_interval秒,它调用save_checkpoint_versioned()方法来保存当前状态。 -
threading.Thread:该线程在后台运行autosave_loop,确保自动保存与训练过程同时发生。
-
-
接下来,我们实现一个健康检查线程。此方法启动一个健康检查线程,定期监控系统性能:
def start_health_check_thread(self): def health_check_loop(): while self.training_active: time.sleep(self.health_check_interval) if self.training_active: if not self.check_system_health(): print("System health check failed. Initiating recovery...") self.initiate_recovery() self.health_check_thread = threading.Thread( target=health_check_loop) self.health_check_thread.start()以下是前面代码片段的主要元素:
-
health_check_loop:一个在训练期间持续运行的函数。每health_check_interval秒,它通过调用check_system_health()来检查系统健康。如果检测到问题,它将触发恢复过程。 -
check_system_health():此方法需要定义以检查系统的性能指标(例如,GPU 内存或 CPU 使用率)。如果健康检查失败,它调用initiate_recovery()。
-
-
我们执行系统健康检查和恢复。以下占位符方法是系统健康检查将被实现的地方,例如,检查 GPU 内存、CPU 利用率、磁盘空间或任何对训练过程至关重要的资源。如果没有问题,它返回
True,如果有问题,则返回False:def check_system_health(self): # Implement system health checks here # For example, check GPU memory, CPU usage, disk space, etc. return True # Return False if health check fails以下方法将包含在系统健康检查失败时应该执行什么逻辑。例如,它可以重新加载最后一个检查点,减少批量大小,或根据检测到的问题采取其他纠正措施:
def initiate_recovery(self): # Implement recovery logic here # For example, reload from the last checkpoint, reduce batch size, etc. pass -
最后,我们进行带有检查点和健康检查的自动训练。此方法通过自动化管理整个训练过程。它激活自动保存和健康检查线程,并通过父类的
train_distributed()方法启动分布式训练:def train_with_automation( self, epochs, steps_per_epoch, train_fn): self.training_active = True self.start_autosave_thread() self.start_health_check_thread() try: super().train_distributed(epochs, steps_per_epoch, train_fn) finally: self.training_active = False self.autosave_thread.join() self.health_check_thread.join()下面是主要代码元素的分解:
-
self.training_active:设置为True以指示训练正在进行 -
try-finally block:确保无论训练如何结束(无论是完成还是崩溃),training_active标志都设置为False,并且两个线程都得到适当终止
-
此方法减少了手动干预,增强了可靠性,并提供了根据特定训练需求定义恢复逻辑的灵活性。
摘要
实施健壮的检查点和恢复系统是成功进行 LLM 训练的常见做法。通过采用这些技术,你可以确保你的长时间运行训练过程能够抵御故障,易于管理,并有利于实验和协作。
为了扩展我们的讨论,表 10.1列出了检查点策略、权衡和使用案例:
| 检查点 策略 | 描述 | 权衡 | 使用案例 |
| --- | --- | --- | --- |
| 定期(带最大限制) | 在间隔(步骤/周期)保存;保持最大数量。 | 优点:节省存储;定期快照。缺点:可能会覆盖好的检查点。 | 迭代模型开发;监控训练进度;防止长时间训练运行中数据完全丢失。 |
| 基于时间 | 在指定间隔(例如,每 30 分钟)保存。 | 优点:时间间隔快照。缺点:如果间隔太短/长则效率低下。 | 长时间运行的实验,一致的、时间戳标记的检查点对于调试和分析至关重要;确保系统故障时的可恢复性。 |
| 最佳模型 | 只有当模型达到最佳性能时才保存。 | 优点:保留最佳模型。缺点:如果损失值有噪声,可能不具有代表性;没有中间快照。 | 选择性能最佳模型。 |
| 高效(压缩) | 使用压缩(例如,ZIP)来减小大小。 | 适用于存储受限的环境;处理存储是主要关注点的大模型;长期存储模型存档。 | 存储受限环境;长期存储模型存档。 |
| 高效(量化) | 减少权重的精度(例如,从 float32 到 float16)。 | 优点:减小大小。缺点:可能损失精度。 | 在资源受限的设备上部署模型;减小检查点大小以实现更快的传输和存储;加速模型加载。 |
| 高效(增量) | 只保存自上次检查点以来的更改。 | 优点:可以显著减小大小。缺点:复杂;可能脆弱。 | 使用逐步参数更新的模型训练;大模型,频繁完整检查点不切实际;连续学习场景。 |
| 分布式 | 在分布式训练中,只有主进程(rank 0)保存;数据广播给其他人。 | 优点:避免冗余写入;确保一致性。缺点:需要协调。 | 大规模分布式训练作业;确保多个工作者之间模型状态的一致性;最小化网络开销。 |
| 版本控制 | 将检查点与版本关联;支持分支。 | 优点:实验;可重复性;回滚。缺点:增加复杂性。 | 协作模型开发;跟踪实验变化;确保科学研究的可重复性;管理模型演变。 |
| 自动化(带健康检查) | 自动保存检查点;执行健康检查;可以启动恢复。 | 优点:减少手动工作;增强可靠性。缺点:需要健康检查/恢复实现。 | 适用于关键任务训练作业;从故障中自动恢复;需要高可靠性的长时间运行实验。 |
表 10.1 – 检查点策略、权衡和使用案例
在下一章中,我们将探讨将预训练语言模型适应特定任务或领域的有效技术。
第十一章:微调
在这个设计模式中,你将学习到微调预训练语言模型的有效策略**。
微调大型语言模型(LLMs)解决了迁移学习中的一个基本优化问题:在大数据集上预训练有助于 LLMs 学习通用的语言技能和知识,但预训练数据与特定任务数据之间的差异可能会降低性能。微调使用较小且精心选择的任务数据集来更新模型,使其更适合任务需求。这个过程保留了预训练中的有用知识,同时提高了模型在目标任务上有效执行的能力。
在本章中,我们将涵盖以下主题:
-
实现迁移学习和微调
-
层冻结和解冻策略
-
学习率调度
-
领域特定技术
-
少样本和零样本微调
-
持续微调和灾难性遗忘
实现迁移学习和微调
我们将使用以下代码块来演示使用 GPT-2 的迁移学习,包括模型初始化、数据处理和微调工作流程。我们将使用 Transformers 库和 WikiText 数据集来微调预训练语言模型:
-
首先,我们使用配置的填充加载并初始化 GPT-2 模型和标记器:
def load_model_and_tokenizer(model_name="gpt2"): model = GPT2LMHeadModel.from_pretrained(model_name) tokenizer = GPT2Tokenizer.from_pretrained(model_name) tokenizer.pad_token = tokenizer.eos_token return model, tokenizer -
然后,以下代码块使用
512的序列长度管理数据集加载和文本标记化:def prepare_dataset(dataset_name="wikitext", dataset_config="wikitext-2-raw-v1" ): dataset = load_dataset(dataset_name, dataset_config) return dataset def tokenize_function(examples, tokenizer): return tokenizer( examples["text"], truncation=True, padding="max_length", max_length=512) -
最后,我们设置训练配置,初始化训练器,并执行微调:
def fine_tune_lm(model, tokenizer, dataset, output_dir="./fine_tuned_model" ): tokenized_dataset = dataset.map( lambda examples: tokenize_function(examples, tokenizer), batched=True) training_args = TrainingArguments( output_dir=output_dir, num_train_epochs=3, per_device_train_batch_size=8, per_device_eval_batch_size=8, warmup_steps=500, weight_decay=0.01, logging_dir="./logs", ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset["train"], eval_dataset=tokenized_dataset["validation"], ) trainer.train() trainer.save_model()代码设置了一个
fine_tune_lm函数,用于准备和执行语言模型微调。它首先使用批量处理对数据集进行标记化,然后配置包括 epoch、批大小、预热步骤和权重衰减的训练参数。接下来,它使用模型、参数和数据集初始化训练器,运行训练过程,并最终保存微调后的模型。
批大小对训练稳定性和性能都有显著影响。较大的批大小允许更多的并行化,并在强大的硬件上更快地训练,但需要更多的内存。它们可以通过平均更多示例来提供更稳定的梯度估计,从而可能实现更好的收敛。然而,与较小的批大小相比,非常大的批大小可能泛化较差,因为它们可能导致模型收敛到更尖锐的局部最小值。较小的批大小在梯度更新中引入更多噪声,这有助于逃离局部最小值,并可能找到更好的解决方案,但训练时间更长。找到最佳批大小需要平衡特定模型和数据集的硬件约束、收敛稳定性和泛化性能。
当微调 LLMs 时,我们通常不需要更新所有模型的参数。选择性地冻结和解冻层可以导致更高效和有效的微调。
层冻结和解冻策略
选择性冻结和解冻层的理念源于知识在深度神经网络中的结构和分布方式。LLM 中的底层倾向于捕获更多通用语言表示,例如句法、词性和形态,而高层则更专业且与任务相关。这种层次结构允许我们利用早期层中已经编码的通用语言知识,同时仅微调网络的任务特定部分。
通过冻结底层,我们保留了它们的预训练能力并防止了灾难性遗忘,这在整个模型在狭窄领域数据集上无差别更新时可能会发生。这也大大减少了可训练参数的数量,从而降低了内存使用并加快了收敛速度。同时,选择性解冻上层允许模型在不干扰其核心语言理解能力的情况下,为新任务或领域调整其表示。
让我们看看我们如何实现这一点:
-
首先,我们通过禁用除指定数量的最终层之外的所有层的梯度来实现选择性层冻结:
def freeze_layers(model, num_layers_to_freeze): for param in model.base_model.parameters(): param.requires_grad = False for i, layer in enumerate(model.base_model.transformer.h): if i >= len(model.base_model.transformer.h) -\ num_layers_to_freeze: for param in layer.parameters(): param.requires_grad = True -
然后,我们在训练周期中管理渐进层解冻:
def gradual_unfreeze(model, trainer, num_epochs, total_layers): layers_per_epoch = total_layers // num_epochs for epoch in range(num_epochs): freeze_layers(model, (epoch + 1) * layers_per_epoch) trainer.train(resume_from_checkpoint=True) -
最后,我们为渐进解冻过程配置优化的训练参数:
training_args = TrainingArguments( output_dir="./fine_tuned_model", num_train_epochs=5, # Increased epochs for better learning per_device_train_batch_size=16, # Larger batch size per_device_eval_batch_size=16, warmup_steps=1000, # More warmup steps learning_rate=2e-5, # Added learning rate weight_decay=0.1, # Increased weight decay logging_dir="./logs", save_steps=500, # Added save frequency eval_steps=500 # Added evaluation frequency ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset["train"], eval_dataset=tokenized_dataset["validation"], )
此实现引入了两种关键策略:
-
freeze_layers:此函数冻结所有层,除了最后num_layers_to_freeze层 -
gradual_unfreeze:此函数在训练过程中逐步解冻层
渐进解冻方法允许模型首先适应其高级特征,然后逐步微调低级特征。这可以提高性能并帮助防止灾难性遗忘。
由于以下原因,灾难性遗忘得到了减少:
-
层冻结通过禁用早期层的梯度更新来保留这些层中的知识,在预训练期间保持学习到的基本表示,同时仅适应特定任务的后期层。这保留了模型的一般知识,同时允许对新任务进行适应。
-
渐进解冻实施了一种分阶段的方法,其中训练开始时仅解冻最终层(其中包含更多特定于任务的表示),然后逐步解冻早期层。这允许模型首先适应高级特征,然后再进行更根本的变化,提供一种温和的过渡,有助于保持之前学习到的模式。
-
训练配置通过精心平衡的学习率、增加预热步骤和更高的权重衰减来支持这些方法,进一步防止参数发生剧烈变化。增加的周期允许更渐进的适应,同时保存和评估检查点提供监控,以防止解冻过程中的过拟合。
这些技术共同创造了一个更受控制的微调过程,在适应新任务的同时保留了一般知识。
通过应用适当的 学习率调度,可以显著提高微调性能,我们将在下一节中探讨。
学习率调度
如前所述,适当的 学习率调度 通常用于有效的微调。以下代码演示了 LLM 微调的常见学习率调度技术,提供了线性和余弦预热策略以优化训练:
-
首先,我们使用所需的导入和函数初始化设置调度框架:
from transformers import ( get_linear_schedule_with_warmup, get_cosine_schedule_with_warmup) def fine_tune_with_lr_scheduling( model, tokenizer, dataset, scheduler_type="linear", num_epochs=3 ): tokenized_dataset = dataset.map( lambda examples: tokenize_function(examples, tokenizer), batched=True) -
接下来,我们使用改进的默认值配置优化训练参数:
training_args = TrainingArguments( output_dir="./fine_tuned_model", num_train_epochs=3, per_device_train_batch_size=32, # Increased batch size per_device_eval_batch_size=32, weight_decay=0.1, # Increased weight decay logging_dir="./logs", learning_rate=2e-5, # Adjusted learning rate warmup_ratio=0.1, # Added warmup ratio eval_steps=100, # Added evaluation frequency save_steps=100 # Added save frequency ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset["train"], eval_dataset=tokenized_dataset["validation"], ) -
最后,我们实现具有动态预热步骤计算的 学习率调度:
num_training_steps = len(tokenized_dataset["train"]) // training_args.per_device_train_batch_size * num_epochs if scheduler_type == "linear": scheduler = get_linear_schedule_with_warmup( trainer.optimizer, num_warmup_steps=num_training_steps // 10, # 10% warmup num_training_steps=num_training_steps ) elif scheduler_type == "cosine": scheduler = get_cosine_schedule_with_warmup( trainer.optimizer, num_warmup_steps=num_training_steps // 10, # 10% warmup num_training_steps=num_training_steps ) else: raise ValueError("Unsupported scheduler type")此实现提供了两种常见的学习率调度策略:
-
在预热期间,从
0线性减少到初始lr。我们曾在第七章的损失函数和优化策略部分讨论过这一点。然而,需要注意的是,我们还需要在微调中使用相同的预热调度。预热有助于防止训练早期突然的权重更新,从而确保更平滑的收敛。 -
带预热余弦调度:类似于线性调度,但在此情况下,下降遵循余弦曲线。
-
这些调度策略可以帮助稳定训练并可能带来更好的收敛。
领域特定微调技术
当为特定领域微调 LLM 时,我们通常需要调整我们的方法。让我们看看一个针对科学语料库的领域特定微调示例。以下代码使用自定义数据集准备和训练配置实现了科学文本的领域特定微调:
-
首先,我们使用指定的块大小和语言模型整理器设置科学文本数据集的准备:
import torch from transformers import ( TextDataset, DataCollatorForLanguageModeling ) def prepare_scientific_dataset(file_path, tokenizer): dataset = TextDataset( tokenizer=tokenizer, file_path=file_path, block_size=128, ) data_collator = DataCollatorForLanguageModeling( tokenizer=tokenizer, mlm=False, ) return dataset, data_collator -
接下来,我们处理训练和评估的数据集准备:
def fine_tune_for_scientific_domain( model, tokenizer, train_file, eval_file, output_dir="./scientific_model" ): train_dataset, data_collator = prepare_scientific_dataset(train_file, tokenizer) eval_dataset, _ = prepare_scientific_dataset( eval_file, tokenizer) -
最后,我们为科学领域适应配置优化训练参数:
training_args = TrainingArguments( output_dir=output_dir, num_train_epochs=3, # Reduced epochs per_device_train_batch_size=8, # Increased batch size per_device_eval_batch_size=8, warmup_steps=1000, # Increased warmup weight_decay=0.1, # Increased weight decay learning_rate=3e-5, # Added learning rate logging_dir="./logs", evaluation_strategy="steps", # Changed to steps eval_steps=500, # Added eval frequency save_steps=500, # Added save frequency gradient_accumulation_steps=4 # Added gradient accumulation )
此实现包括几个领域特定考虑因素:
-
TextDataset处理领域特定文本文件 -
较小的批量大小:科学文本通常有更长的序列,因此我们减少批量大小
-
更多 epoch:领域适应可能需要更多的训练迭代
-
定期评估:在每个 epoch 之后,我们评估模型以跟踪验证损失和关键领域特定指标,确保适当的适应。
当为特定领域进行微调时,请考虑以下步骤:
-
适应领域特定术语的词汇表
-
使用领域特定评估指标
-
可能修改模型架构以适应领域特定特征
在下一节中,我们将探讨几种用于微调模型且来自目标领域几乎没有标记数据的策略。
少样本和零样本微调
少样本和零样本学习是强大的技术,可以用于将 LLMs 适应新任务,而无需或仅需最少量的特定任务训练数据。让我们实现一个少样本微调方法:
-
我们创建一个包含任务几个示例的提示:
def prepare_few_shot_dataset(examples, tokenizer, num_shots=5): few_shot_examples = examples[:num_shots] prompt = "\n\n".join( [ f"Input: {ex['input']}\n" f"Output: {ex['output']}" for ex in few_shot_examples ] ) prompt += "\n\nInput: {input}\nOutput:" def tokenize_function(example): full_prompt = prompt.format(input=example['input']) tokenized_prompt = tokenizer(full_prompt, truncation=True, padding="max_length", max_length=512) tokenized_output = tokenizer( example['output'], truncation=True, padding="max_length", max_length=512) tokenized_prompt['labels'] = \ [-100] * len(tokenized_prompt['input_ids']) + tokenized_output['input_ids'] return tokenized_prompt return examples.map(tokenize_function) -
模型随后在基于提示的数据集上进行微调:
def few_shot_fine_tune( model, tokenizer, dataset, num_shots=5, num_epochs=3 ): few_shot_dataset = prepare_few_shot_dataset(dataset, tokenizer, num_shots) training_args = TrainingArguments( output_dir="./few_shot_model", num_train_epochs=num_epochs, per_device_train_batch_size=4, per_device_eval_batch_size=4, warmup_steps=100, weight_decay=0.01, logging_dir="./logs", ) trainer = Trainer( model=model, args=training_args, train_dataset=few_shot_dataset, ) trainer.train() return trainerfew_shot_fine_tune函数实现了少样本微调,它使用最少量的示例将预训练模型适应新任务。它接受一个模型、分词器、数据集和配置参数(num_shots=5,num_epochs=3),然后使用prepare_few_shot_dataset准备数据的小子集,使用TrainingArguments配置训练(指定输出位置、批大小和优化参数),使用这些组件初始化一个Trainer对象,通过trainer.train()执行训练过程,并最终返回包裹在Trainer对象中的训练模型——所有这些操作都使用 Hugging Face Transformers 库框架,该框架是语言模型中常用的一种。 -
微调后的模型可以推广到新任务的新实例:
# Usage model, tokenizer = load_model_and_tokenizer() dataset = load_dataset("your_dataset") # Load your few-shot dataset few_shot_trainer = few_shot_fine_tune(model, tokenizer, dataset)
此实现展示了少样本微调。
对于零样本学习,你通常会依赖预训练模型理解任务描述的能力,无需任何特定任务的示例或微调。
持续微调和灾难性遗忘
持续微调涉及在保留先前任务性能的同时适应新任务。然而,这可能导致灾难性遗忘。在 LLMs 中,灾难性遗忘指的是在没有适当机制来保留先前知识的情况下,模型在针对新任务或数据微调时丢失先前学习的信息。
让我们实施一个简单的策略来减轻这一点:
-
首先,我们计算参数重要性并实现弹性权重巩固(EWC)损失以保留关键权重:
import copy def ewc_loss(model, old_model, importance, loss): ewc_lambda = 0.01 for n, p in model.named_parameters(): if n in importance: loss += ewc_lambda * importance[n] * (p - old_model[n]).pow(2).sum() return loss def compute_importance(model, dataset): importance = {} model.eval() for batch in dataset: model.zero_grad() output = model(batch) loss = output.loss loss.backward() for n, p in model.named_parameters(): if p.grad is not None: if n not in importance: importance[n] = p.grad.data.clone().pow(2) else: importance[n] += p.grad.data.clone().pow(2) return importance -
我们接下来实现以下代码,该代码在多个任务上管理顺序训练的同时保持先前知识:
def continual_fine_tune( model, tokenizer, datasets, num_epochs=3 ): old_model = None importance = None for i, dataset in enumerate(datasets): if old_model is not None: importance = compute_importance( old_model, datasets[i-1]) old_model = copy.deepcopy(model) tokenized_dataset = dataset.map( lambda examples: tokenize_function(examples, tokenizer), batched=True) -
最后,我们为持续学习定义优化训练参数:
training_args = TrainingArguments( output_dir=f"./continual_fine_tuned_model_task_{i+1}", num_train_epochs=8, # Increased epochs per_device_train_batch_size=20, # Increased batch size per_device_eval_batch_size=20, warmup_steps=2000, # Increased warmup weight_decay=0.2, # Increased weight decay learning_rate=2e-5, # Added learning rate logging_dir="./logs", evaluation_strategy="steps", # Added evaluation strategy eval_steps=1000, # Added evaluation frequency save_steps=1000 # Added save frequency )
此实现引入了持续微调的几个关键概念:
-
EWC:我们实现 EWC 的简化版本,它将惩罚项添加到损失函数中,以防止对先前任务的重要参数产生剧烈变化
-
重要性计算:我们根据先前任务上梯度的幅度计算每个参数的重要性
-
持续微调循环:我们按顺序在每个任务上微调模型,使用 EWC 来减轻遗忘
-
对所有任务的评估:在针对每个新任务进行微调后,我们评估模型在所有先前任务上的性能以监控遗忘
持续微调的关键考虑因素如下:
-
可塑性和稳定性之间的平衡:EWC 有助于维持这种平衡,使模型能够在学习新任务的同时保留对先前任务的知识
-
计算开销:计算重要性和应用 EWC 增加了训练的计算成本
-
任务相似性:持续微调的有效性可能取决于任务之间的相似性
考虑到缓解灾难性遗忘的策略,以下是一些额外的策略:
-
梯度周期性记忆(GEM):在此方法中,存储并使用来自先前任务的小周期性数据记忆来约束新任务上的梯度更新,如下所示:
def project(gradient, memories): for memory in memories: if torch.dot(gradient, memory) < 0: gradient -= ( torch.dot(gradient, memory) / torch.dot( memory, memory) ) * memory return gradient # This would be integrated into the training loop -
渐进式神经网络:在这里,为每个新任务创建一个新的“列”层,同时保持到先前学习特征的横向连接。
-
无遗忘学习(LwF):在此方法中,采用知识蒸馏来保留模型在先前任务上的性能:
def lwf_loss( model, old_model, new_data, old_data, temperature=2 ): # Compute standard loss on new data new_loss = compute_loss(model, new_data) # Compute distillation loss on old data old_outputs = old_model(old_data) new_outputs = model(old_data) distillation_loss = F.kl_div( F.log_softmax(new_outputs / temperature, dim=1), F.softmax(old_outputs / temperature, dim=1), reduction='batchmean' ) * (temperature 2) return new_loss + distillation_loss # This would replace the standard loss in the training loop
这些高级技术当在多样化的任务或领域微调 LLM 时特别有用。
摘要
对于大型语言模型(LLM)的微调模式包括一系列技术,从基本的迁移学习到高级的持续学习策略。通过掌握这些模式,您可以有效地将预训练模型适应于新任务和领域,优化性能,并缓解灾难性遗忘等问题。随着 LLM 领域的持续发展,跟上最新的微调技术对于开发针对特定应用的尖端语言模型至关重要。
本章的关键要点如下:
-
微调适应预训练 LLM:微调是将通用、预训练的 LLM 适应于特定任务和数据集的关键过程,它弥合了通用语言理解与特定性能之间的差距
-
层管理至关重要:战略性地冻结和解冻层(尤其是逐步解冻)对于在保留预训练知识与新任务适应之间取得平衡至关重要
-
学习率调度稳定训练:使用带有预热(线性或余弦)的学习率调度对于稳定和有效的微调至关重要,可以防止剧烈的早期更新并促进收敛
-
领域/任务特定性很重要:诸如领域特定词汇适应、自定义数据处理以及少样本/零样本方法等技术对于在特定任务上最大化性能至关重要
-
必须解决灾难性遗忘问题:在持续学习场景中,EWC、GEM 等技术在训练新任务时防止模型丢失先前学习的信息是必要的
我们将在下一章探讨模型剪枝。模型剪枝系统地从 LLM 中移除冗余或不太重要的神经连接,同时保留核心功能,本质上创建了一个更轻、更高效的版本,它保持了相似的性能但需要更少的计算资源。
第十二章:模型剪枝
在本章中,我们将探讨旨在在保持性能的同时减少模型大小的模型剪枝技术。
模型剪枝是指在保持性能的同时,从神经网络中系统地消除不必要的参数。对于 LLMs 来说,这通常涉及根据幅度、敏感性分析或基于梯度的重要性等标准识别和移除冗余或不重要的权重、神经元或注意力头。
你将学习如何实现各种剪枝方法,从基于幅度的剪枝到迭代技术,以及大小缩减与性能之间的权衡。此外,本章将帮助你决定是在训练期间还是训练后进行剪枝,以确保你的 LLMs 保持高效和有效。
在本章中,我们将涵盖以下主题:
-
基于幅度的剪枝
-
结构化与非结构化剪枝
-
迭代剪枝技术
-
训练期间剪枝与训练后剪枝
-
平衡剪枝和模型性能
-
将剪枝与其他压缩技术结合
基于幅度的剪枝
基于幅度的剪枝是最简单且最广泛使用的剪枝技术之一。这种方法背后的思想是移除对神经网络整体功能贡献最小的权重,通常,这些是幅度最小(绝对值)的权重。通过剪枝这些权重,模型变得更加紧凑和快速,对准确性的影响最小:
import torch
import torch.nn.utils.prune as prune
# Assume model is an instance of a pre-trained LLM
model = ... # Load or define your LLM model
# Prune 30% of the lowest magnitude weights in all Linear layers
for name, module in model.named_modules():
if isinstance(module, torch.nn.Linear):
prune.l1_unstructured(module, name='weight', amount=0.3)
# Remove the pruning reparameterization
for name, module in model.named_modules():
if isinstance(module, torch.nn.Linear):
prune.remove(module, 'weight')
在此代码示例中,基于幅度的剪枝移除了 LLM 所有线性层中幅度最低的 30%的权重。prune.l1_unstructured函数指定了具有最小 L1 范数的权重将被剪枝。
以下代码片段实现了在 PyTorch 模块中对给定参数张量进行无结构 L1 范数剪枝的prune.l1_unstructured函数,通过将绝对值最小的权重置零来实现:
def prune.l1_unstructured(module, name, amount):
"""Prunes weights with lowest L1 norm magnitude in a module's tensor"""
# Get the parameter to prune
tensor = getattr(module, name)
# Calculate number of parameters to prune
n_params_to_prune = int(amount * tensor.numel())
# Get magnitude threshold (kth smallest absolute value)
threshold = torch.kthvalue(
tensor.abs().view(-1), n_params_to_prune
).values
# Create and apply mask (zeros out weights below threshold)
mask = tensor.abs() > threshold
pruned_tensor = tensor.clone() * mask
# Update parameter and register mask
setattr(module, name, torch.nn.Parameter(pruned_tensor))
module.register_buffer(f'{name}_mask', mask)
# Add hook to maintain pruning during updates
module.register_forward_pre_hook(
lambda m, _: setattr(
m, name,
torch.nn.Parameter(
getattr(m, name) * getattr(m, f'{name}_mask')
)
)
)
return mask
在这里,函数首先从模块中提取目标张量,并确定根据指定的比例amount应该剪枝多少个元素。它通过计算张量中第k小的绝对值来确定剪枝阈值,其中k对应于要剪枝的参数数量。然后创建一个二进制掩码,其中高于阈值的值被保留,而低于阈值的值被设置为零。这个掩码被应用于生成张量的剪枝版本,它替换了模块中的原始参数。掩码作为缓冲区存储,以在模型操作之间持久化,并注册了一个前向预钩子,以确保在每次前向传递之前强制执行剪枝,即使在训练过程中底层权重被更新,也能保持稀疏模式。
在模型剪枝中,使用 L1 范数通过求其成分的绝对值之和来评估模型中权重或参数的重要性,通常 L1 范数值较低表示不那么重要的参数,可以移除以减小模型大小同时保持性能。
剪枝后,调用prune.remove方法来移除剪枝重新参数化并使更改永久化。
基于幅度的剪枝对于具有许多小权重且对整体性能贡献不大的模型特别有效,但单独应用时可能不足以进行大规模剪枝。
结构化剪枝与无结构化剪枝
在剪枝 LLMs 时,你可以单独剪除权重(无结构化剪枝)或移除整个结构,如滤波器、通道或注意力头(结构化剪枝):
-
之前描述的
prune.l1_unstructured函数。 -
结构化剪枝:剪枝整个模型的部分,如神经元、通道或层。这种方法在现代硬件上更容易实现,并且通常会导致推理时间上的更好加速,尽管它可能对模型性能有更大的即时影响。
在 LLMs 中,可以使用 PyTorch 的内置工具实现结构化剪枝,如下面的代码所示。在这里,我们应用 L2 范数结构化剪枝来移除线性层中 30%的神经元,目标是整个权重矩阵的行,以有效地消除完整的神经元而不是仅仅单个连接:
import torch.nn.utils.prune as prune
# Structured pruning of entire neurons in a layer
for name, module in model.named_modules():
if isinstance(module, torch.nn.Linear):
prune.ln_structured(
module, name='weight', amount=0.3, n=2, dim=0
)
在这个结构化剪枝示例中,ln_structured函数根据给定维度的所有权重中的 L2 范数从线性层中移除整个神经元。结构化剪枝的选择可以显著降低计算复杂度,同时使模型更适合部署在标准硬件架构上。
接下来,我们将看到如何通过在多个训练步骤中每次剪除一小部分权重,而不是一次性剪除模型的大部分内容。
迭代剪枝技术
在这里,我们将讨论迭代剪枝,它允许你在多个训练步骤中每次剪除一小部分权重。这种方法降低了性能急剧下降的风险,并为模型提供了更多恢复和适应剪枝的机会。
迭代方法还允许在每个剪枝步骤后进行微调,使模型能够从权重减少中“恢复”:
# Iteratively prune 10% of the model after every 10 epochs
for epoch in range(1, num_epochs+1):
train(model, train_loader, optimizer) # Regular training step
if epoch % 10 == 0:
for name, module in model.named_modules():
if isinstance(module, torch.nn.Linear):
prune.l1_unstructured(module, name='weight',
amount=0.1)
prune.remove(module, 'weight') # Remove pruning mask after each step
validate(model, val_loader)
在这个例子中,每 10 个 epoch 后剪除 10%的权重。逐渐移除权重确保模型有足够的时间在每次剪枝步骤之间进行调整。迭代剪枝与验证步骤相结合可以帮助找到模型大小和性能之间的更优平衡。
训练期间剪枝与训练后剪枝
应用剪枝时的一个关键决策是在训练期间还是训练完成后进行剪枝:
-
训练过程中的剪枝:这种方法允许模型通过迭代地剪枝权重来逐渐适应剪枝结构。模型可以补偿剪枝的权重,从而可能带来更好的最终性能。然而,这需要更多的计算资源和训练时间。
这里是这种方法的一个例子:
import torch import torch.nn.utils.prune as prune # Assuming model is a pre-trained LLM model = ... # Load or define your LLM model optimizer = torch.optim.Adam(model.parameters(), lr=0.001) criterion = torch.nn.CrossEntropyLoss() def train(model, train_loader, optimizer): model.train() for batch in train_loader: inputs, targets = batch optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() # Prune 20% of the weights every 5 epochs during training for epoch in range(1, 20): train(model, train_loader, optimizer) # Apply pruning every 5 epochs if epoch % 5 == 0: for name, module in model.named_modules(): if isinstance(module, torch.nn.Linear): prune.l1_unstructured(module, name='weight', amount=0.2) prune.remove(module, 'weight') # Remove reparameterization after each pruning -
训练后剪枝:在这种方法中,剪枝是在模型完全训练后进行的。这种方法计算效率高,因为它不需要在训练过程中进行修改,并且你可以选择在之后微调模型。然而,与训练过程中的剪枝相比,它可能会导致更大的准确性下降。
让我们看看训练后剪枝的一个例子:
# Assuming the model has already been fully trained model = ... # Load or define your trained LLM model # Prune 30% of the weights in all Linear layers after training for name, module in model.named_modules(): if isinstance(module, torch.nn.Linear): prune.l1_unstructured(module, name='weight', amount=0.3) # Optionally, fine-tune the model after pruning fine_tune_epochs = 3 for epoch in range(fine_tune_epochs): train(model, train_loader, optimizer) # Fine-tuning the pruned model
这两种方法的选择取决于你的性能约束和可用资源。训练过程中的剪枝通常会导致更稳定的模型,而训练后的剪枝更快且更高效。
平衡剪枝和模型性能
在剪枝和模型性能之间找到合适的平衡至关重要。过于激进的剪枝可能导致性能显著下降,而剪枝不足可能不会带来足够的收益。关键在于识别哪些模型部分可以被剪枝而不会对准确性产生太大影响。这需要在每次剪枝步骤后进行仔细验证,并密切监控关键性能指标。这些指标包括参数减少率、推理速度提升、内存占用减少、困惑度变化以及特定任务的性能。在整个过程中,平衡准确性-效率权衡至关重要,以确保剪枝模型在参数减少的情况下仍能保持可接受的性能。
一种常见的策略是在剪枝后进行微调以恢复一些丢失的性能。微调允许模型适应剪枝结构并恢复其原始能力:
import torch.nn.utils.prune as prune
# Assuming model has been trained and pruned
model = ... # Pruned LLM model
# Apply fine-tuning to restore performance after pruning
optimizer = torch.optim.Adam(model.parameters(), lr=1e-5) # Lower learning rate for fine-tuning
fine_tune_epochs = 5
for epoch in range(fine_tune_epochs):
train(model, train_loader, optimizer) # Reuse the train function from earlier
validate(model, val_loader) # Validation step to monitor performance
在这个例子中,在剪枝部分权重之后,模型使用较低的学习率进行微调以恢复性能。较低的学习率允许模型逐渐适应新的剪枝结构,防止学习特征的不稳定。在每次微调步骤后进行验证,以监控模型的进展并确保剪枝没有显著降低性能。
让我们看看如何将剪枝与其他模型压缩技术相结合。
将剪枝与其他压缩技术相结合
剪枝可以与其他模型压缩技术相结合,如量化或蒸馏,以实现模型大小和复杂性的更大减少。结合这些技术通常会产生更紧凑的模型,同时保持高性能。
剪枝和量化
在剪枝后进行量化可以显著减少模型大小并加快推理速度,尤其是在资源受限的环境中:
import torch
import torch.nn.utils.prune as prune
import torch.quantization as quant
# Prune the model first
model = ... # Pre-trained LLM
for name, module in model.named_modules():
if isinstance(module, torch.nn.Linear):
prune.l1_unstructured(module, name='weight', amount=0.4)
prune.remove(module, 'weight')
# Apply dynamic quantization after pruning
quantized_model = quant.quantize_dynamic(
model, {torch.nn.Linear}, dtype=torch.qint8
)
# Check the size reduction
print("Original model size:", torch.cuda.memory_allocated())
print("Quantized model size:", torch.cuda.memory_allocated())
剪枝和知识蒸馏
你还可以将剪枝与知识蒸馏相结合,其中较小的、剪枝的学生模型被训练来模仿较大的、训练良好的教师模型的行为:
# Teacher and student models for knowledge distillation
teacher_model = ... # Larger, fully trained model
student_model = ... # Smaller model to be distilled and pruned
def distillation_loss(student_outputs, teacher_outputs, temperature):
return torch.nn.KLDivLoss()(
torch.nn.functional.log_softmax(
student_outputs / temperature
),
torch.nn.functional.softmax(
teacher_outputs / temperature
)
)
# Train the smaller, pruned model using knowledge distillation
temperature = 2.0
optimizer = torch.optim.Adam(student_model.parameters(), lr=1e-4)
for batch in train_loader:
inputs, _ = batch
teacher_outputs = teacher_model(inputs)
student_outputs = student_model(inputs)
loss = distillation_loss(
student_outputs, teacher_outputs, temperature
)
loss.backward()
optimizer.step()
这种方法允许学生模型在更少的参数下实现高性能。知识蒸馏有助于通过从未剪枝的教师模型中传递高级表示来补偿剪枝造成的精度损失。
这些示例说明了剪枝如何在训练期间或之后应用,平衡性能要求,并结合其他压缩技术,如量化和知识蒸馏,以创建更高效的 LLMs。
摘要
在本章中,我们探讨了 LLMs 的各种模型剪枝技术,包括基于幅度的剪枝、结构化与非结构化剪枝以及迭代剪枝方法。我们讨论了在训练期间与训练后剪枝所涉及的权衡,以及剪枝后微调以恢复丢失性能的重要性。通过结合剪枝与其他压缩技术,如量化和蒸馏,你可以创建更适合在资源受限环境中部署的更高效的 LLMs。
在下一章中,我们将探讨用于 LLMs 的量化技术,重点关注降低数值精度以提高模型效率,同时保持性能。你将学习如何应用训练后和量化感知训练来进一步优化你的 LLMs。
第十三章:量化
在本章中,我们将深入探讨量化方法,这些方法可以优化 LLM 以在资源受限的设备上部署,例如移动电话、嵌入式系统或边缘计算环境。
量化是一种降低数值表示精度的技术,从而缩小模型的大小并提高其推理速度,而不会严重损害其性能。
量化在以下场景中特别有益:
-
资源受限的部署:当在内存、存储或计算能力有限的设备上部署模型时,例如移动电话、物联网设备或边缘计算平台
-
对延迟敏感的应用:当需要实时或近实时响应时,量化可以显著减少推理时间
-
大规模部署:当大规模部署模型时,即使模型大小和推理时间的适度减少也可以转化为基础设施和能源消耗的显著成本节约
-
带宽受限的场景:当模型需要通过有限的带宽连接下载到设备时,较小的量化模型可以减少传输时间和数据使用量
-
具有冗余精度的模型:当许多 LLM 被训练以比良好性能所需的精度更高的精度时,它们成为量化极佳的候选者。
然而,在某些情况下,量化可能不适用,例如以下情况:
-
对精度高度敏感的任务:对于即使精度略有下降也不可接受的应用,例如某些医疗诊断或关键金融模型
-
已针对低精度优化的模型:如果一个模型被专门设计或训练以在较低精度下高效运行,进一步的量化可能会导致性能显著下降
-
小型模型:对于已经紧凑的模型,量化操作的开销在某些硬件配置中可能超过其带来的好处
-
开发和微调阶段:在积极开发和实验期间,使用全精度模型通常更可取,以获得最大灵活性并避免掩盖潜在问题
-
硬件兼容性:目标硬件可能缺乏对您计划使用的特定量化格式的有效支持(例如,某些设备可能没有优化 INT8 或 INT4 计算能力)
-
具有不同敏感性的复杂架构:LLM 架构的某些部分(例如注意力机制)可能比其他部分对量化更敏感,需要更复杂的混合精度方法,而不是简单的量化
通过理解这些考虑因素,您可以就是否以及如何将量化技术应用于您的 LLM 部署做出明智的决定,在性能需求和资源限制之间取得平衡。
在本章中,你将了解不同的量化策略,到本章结束时,你将能够应用量化方法使你的 LLM 更高效,同时确保任何精度降低对模型性能的影响最小。
在本章中,我们将涵盖以下主题:
-
理解基础知识
-
混合精度量化
-
硬件特定考虑
-
比较量化策略
-
将量化与其他优化技术相结合
理解基础知识
量化是指降低模型权重和激活的精度,通常从 32 位浮点(FP32)降低到更低的精度格式,如 16 位(FP16)或甚至 8 位整数(INT8)。目标是减少内存使用,加快计算速度,并使模型在计算能力有限的硬件上更容易部署。虽然量化可能导致性能下降,但精心调整的量化方案通常只会导致精度损失很小,特别是对于具有稳健架构的 LLM。
有两种主要的量化方法:动态量化和静态量化。
-
使用
torch.quantization.quantize_dynamic对预训练的 LLM 的线性层进行动态量化:import torch from torch.quantization import quantize_dynamic # Assume 'model' is a pre-trained LLM (e.g., transformer-based model) model = ... # Apply dynamic quantization on linear layers for INT8 precision quantized_model = quantize_dynamic( model, {torch.nn.Linear}, dtype=torch.qint8 ) # Check size reduction print(f"Original model size: {torch.cuda.memory_allocated()} bytes") print(f"Quantized model size: {torch.cuda.memory_allocated()} bytes")这立即降低了内存需求并提高了推理速度。
-
torch.quantization.prepare和torch.quantization.convert:import torch import torch.nn as nn import torch.quantization # Define a simple model class SimpleModel(nn.Module): def __init__(self): super().__init__() self.fc = nn.Linear(784, 256) self.relu = nn.ReLU() self.out = nn.Linear(256, 10) def forward(self, x): x = self.relu(self.fc(x)) return self.out(x) # Create and prepare model for static quantization model_fp32 = SimpleModel() model_fp32.eval() model_fp32.qconfig = torch.quantization.get_default_qconfig( 'fbgemm') prepared_model = torch.quantization.prepare(model_fp32) # Calibration step: run representative data through the model # (This example uses random data; replace with real samples) for _ in range(100): sample_input = torch.randn(1, 784) prepared_model(sample_input) # Convert to quantized version quantized_model = torch.quantization.convert(prepared_model) # Model is now statically quantized and ready for inference print(quantized_model此静态量化模型使用每个量化张量固定的缩放和零点参数,允许硬件加速器实现更高的推理效率。
与动态量化不同,静态量化在推理之前需要一个具有代表性数据的校准阶段。在这个阶段,模型以评估模式运行以收集激活统计信息,然后使用这些统计信息来计算量化参数。权重和激活量在推理前进行量化并保持固定,从而实现更快的执行和更可预测的性能。
根据何时应用量化,也存在两种主要的量化方法:
-
后训练量化(PTQ):在模型完全训练后应用量化,最小化或无需额外训练。可以实施为静态(带有校准)或动态。
-
量化感知训练(QAT):通过在正向传递中添加模拟量化操作的假量化操作,在训练期间模拟量化效果,同时保持梯度以全精度。通常导致部署时的静态量化。
PTQ
PTQ 是量化最直接的形式,在模型完全训练后应用。它不需要模型重新训练,通过将高精度权重和激活转换为低精度格式(通常是 INT8)来实现。PTQ 对于重新训练昂贵或不切实际的情况非常理想,并且对于对精度损失不太敏感的任务效果最佳。
请记住,一些 PTQ 方法通常需要在代表性数据集上执行校准步骤,以确定最佳量化参数,如缩放因子和零点,捕获推理期间的激活分布,并最小化原始模型输出和量化模型输出之间的误差。这个过程有助于量化算法理解网络中权重和激活的数值范围和分布,从而实现从更高精度格式(如 FP32)到更低精度格式(如 INT8 或 INT4)的更准确映射,最终在减少内存占用和部署的计算需求的同时保持模型精度。
此示例演示了静态 PTQ:
import torch
import torch.quantization as quant
# Load pre-trained model
model = ...
# Convert model to quantization-ready state
model.eval()
model.qconfig = torch.quantization.default_qconfig
# Prepare for static quantization
model_prepared = quant.prepare(model)
# Apply quantization
model_quantized = quant.convert(model_prepared)
模型首先使用.eval()方法置于评估模式,然后使用.prepare()方法准备量化,最后转换为量化模型。这种方法为在低功耗设备上高效部署 LLM 提供了一种有效手段。
QAT
QAT 通过将量化效果纳入训练过程本身,超越了简单的 PTQ。这允许模型学习如何补偿量化引起的噪声,通常比 PTQ 有更好的性能,尤其是在更复杂的任务中。
在量化加速训练(QAT)期间,训练过程中权重和激活都使用较低的精度进行模拟,但在梯度计算时保持较高的精度。这种方法在应用需要高性能且量化程度较大的情况下特别有用。
在以下示例中,我们使用get_default_qat_qconfig()配置模型进行 QAT,该配置在训练阶段模拟量化行为:
import torch.quantization as quant
# Set up QAT
model.train()
model.qconfig = torch.quantization.get_default_qat_qconfig('fbgemm')
# Prepare for QAT
model_prepared = quant.prepare_qat(model)
# Training loop (for simplicity, only showing initialization)
for epoch in range(num_epochs):
train_one_epoch(model_prepared, train_loader, optimizer)
validate(model_prepared, val_loader)
# Convert to quantized version
model_quantized = quant.convert(model_prepared.eval())
模型训练完成后,将其转换为适合部署的量化版本。与 PTQ 相比,QAT 通常能带来更好的模型精度,尤其是在更复杂或关键的应用中。
混合精度量化
混合精度量化是一种更灵活的方法,它利用单个模型内的多个数值精度级别。例如,模型中不那么关键的层可以使用 INT8,而更敏感的层则保持在 FP16 或 FP32。这允许在性能和精度之间有更大的控制权。使用混合精度量化可以显著减小模型大小和推理时间,同时保持 LLM 的关键特性。
以下代码演示了量化示例,以优化 LLM 训练或推理中的内存使用和速度:
from torch.cuda.amp import autocast
# Mixed precision in LLM training or inference
model = ...
# Use FP16 where possible, fall back to FP32 for sensitive computations
with autocast():
output = model(input_data)
在此示例中,我们使用 PyTorch 的自动混合精度(AMP)库中的autocast()函数,在模型中精度不那么关键的部位启用 FP16 计算,而保留 FP32 用于更敏感的层。这种方法有助于减少内存使用和推理时间,同时不会严重影响性能。
硬件特定考虑因素
不同的硬件平台——例如 GPU、CPU 或专门的加速器如 TPU——在处理量化模型时可能具有截然不同的能力和性能特征。例如,某些硬件可能原生支持 INT8 操作,而其他硬件则针对 FP16 进行了优化。
理解目标部署硬件对于选择合适的量化技术至关重要。例如,NVIDIA GPU 因其支持混合精度训练和推理而非常适合 FP16 计算,而 CPU 通常因为硬件加速的整数操作而在 INT8 量化方面表现更佳。
当在生产中部署 LLM 时,重要的是要尝试针对特定硬件量身定制的量化策略,并确保您的模型利用平台的优势。
比较量化策略
当比较不同的量化策略时,每种方法都提供了独特的优势和挑战,这些可以通过实现复杂度、精度保持、性能影响和资源需求等因素来衡量。
在实现复杂度方面,PTQ 是最简单的,只需在原始模型训练之外做最少的工作。动态量化更复杂,因为它涉及到更多的运行时考虑,因为需要动态处理激活。混合精度量化引入了更多的复杂性,因为它需要逐层评估精度敏感性,并可能需要为优化执行开发定制的内核。QAT 被认为是最复杂的,需要将伪量化节点集成到训练图中,并延长训练时间以考虑量化引入的噪声。
在精度保持方面,QAT 表现最佳,将精度保持在浮点性能的小范围内,尤其是在针对激进的量化(小于 8 位)时。混合精度量化在精度保持方面也排名很高,因为它允许关键层保持更高的精度,从而在性能和精度之间取得良好的平衡。PTQ 通常在可接受的精度范围内保持精度,尽管更复杂的架构可能会遭受更高的精度损失。动态量化通常在基于 RNN 的模型中比 PTQ 保持更好的精度,但在 CNN 架构中表现不佳,尤其是在激活对输入分布变化敏感时。
在资源需求方面,PTQ(Post-Training Quantization,训练后量化)需要的资源最少,使其成为计算能力有限且需要快速部署的场景的理想选择。动态量化在资源消耗上略高,因为它在运行时处理激活量化,尽管这减少了内存和存储的负担。混合精度量化由于敏感性分析,在实现过程中需要更多资源,但在推理阶段可以更高效,尤其是在支持多种精度的硬件上。QAT(Quantization-Aware Training,量化感知训练)是资源消耗最多的,因为它需要额外的训练时间,训练期间的内存使用更高,以及更多的计算资源来适应量化。
从性能角度来看,PTQ 在内存节省和计算加速方面提供了显著的改进,通常可以减少 75%的存储,并在兼容硬件上实现 2–4 倍的加速。然而,QAT 虽然压缩比相似,但在训练期间增加了开销,但通过产生可以处理更激进量化而不会造成显著性能损失的模式来补偿。动态量化提供了与 PTQ 相似的内存节省,但由于运行时开销,其计算加速通常较低。混合精度量化可以提供接近浮点性能,其加速取决于硬件执行具有不同精度级别的模型的有效性。
选择最佳量化策略的决策框架取决于具体的项目需求。当快速部署是首要任务,模型架构相对简单,且可以接受轻微的精度损失时,PTQ 是合适的。当精度至关重要,有可用的重新训练资源,并且需要激进量化时,QAT 是最佳选择。动态量化适合需要运行时灵活性和处理不同输入分布的场景,尤其是在基于 RNN 的架构中。混合精度量化对于需要不同精度需求的复杂模型是最优的,在这些模型中,需要高精度和性能,并且硬件可以有效地管理多种精度格式。
每种量化策略基于精度、复杂性、性能和资源之间的权衡,服务于不同的目的,使用户能够根据其部署环境的特定需求定制其方法。
表 13.1 对每种策略进行了比较。
| 策略 | 精度 | 复杂性 | 性能 | 资源 |
| --- | --- | --- | --- | --- |
| PTQ | 适用于简单模型;随着复杂度的增加而下降 | 低;最小设置 | 75%的存储减少;2–4 倍的加速 | 低;所需计算最小 |
| QAT | 最高;适用于小于 8 位的子集 | 高;需要扩展训练 | 高压缩率,最佳精度 | 高;需要密集训练 |
| 动态 | 适合 RNN;对 CNN 较弱 | 中;运行时开销 | 良好的内存节省;较慢的计算 | 中;运行时处理 |
| 混合精度 | 高;灵活的精度选项 | 中高;层特定调整 | 硬件依赖的速度提升 | 中高在设置期间 |
表 13.1 – 量化策略比较
在实践中,某些场景可能从结合策略中受益。例如,您可能最初应用 PTQ 以实现快速部署,然后有选择地在精度敏感层上使用 QAT。另一种方法可能涉及对特定层使用混合精度,同时为激活应用动态量化以平衡运行时灵活性和性能。
将量化与其他优化技术相结合
量化可以与其他优化技术相结合,例如剪枝和知识蒸馏,以创建适用于在资源受限设备上部署的高度高效模型。通过利用多种方法,您可以显著减小模型大小,同时保持或最小化对性能的影响。这在将 LLM 部署在边缘设备或移动平台上特别有用,因为这些平台计算和内存资源有限。
剪枝和量化
最有效的组合之一是剪枝后跟量化。首先,剪枝从模型中移除冗余权重,减少参数数量。然后量化降低剩余权重的精度,这进一步减小模型大小并提高推理速度。以下是一个示例:
import torch
import torch.nn.utils.prune as prune
import torch.quantization as quant
# Step 1: Prune the model
model = ... # Pre-trained LLM model
for name, module in model.named_modules():
if isinstance(module, torch.nn.Linear):
prune.l1_unstructured(module, name='weight', amount=0.5)
# Prune 50% of the weights
prune.remove(module, 'weight')
# Step 2: Apply dynamic quantization to the pruned model
quantized_model = quant.quantize_dynamic(
model, {torch.nn.Linear}, dtype=torch.qint8 # Convert to INT8 precision
)
# Check size reduction
print("Original model size:", torch.cuda.memory_allocated())
print("Quantized model size:", torch.cuda.memory_allocated())
在此示例中,剪枝应用于移除所有线性层中 50%的权重,动态量化将剩余权重的精度降低到 INT8 以进一步减小尺寸。
结果是一个紧凑、高度优化的模型,消耗更少的计算资源,使其适合在硬件能力有限的设备上部署。
知识蒸馏和量化
另一种强大的组合是先知识蒸馏后量化。在这种情况下,一个较小的学生模型被训练以复制较大教师模型的行为。一旦学生模型训练完成,量化就被应用于进一步优化学生模型以进行部署。这种组合在您需要以最小的计算开销保持高性能时特别有用。
让我们一步一步地看看一个例子:
-
定义教师和学生模型:
import torch import torch.nn.functional as F teacher_model = ... # Larger, fully trained model student_model = ... # Smaller model to be trained through distillation -
定义知识蒸馏损失函数:
def distillation_loss( student_outputs, teacher_outputs, temperature=2.0 ): teacher_probs = F.softmax( teacher_outputs / temperature, dim=1) student_probs = F.log_softmax( student_outputs / temperature, dim=1) return F.kl_div(student_probs, teacher_probs, reduction='batchmean') -
为知识蒸馏添加训练循环:
optimizer = torch.optim.Adam(student_model.parameters(), lr=1e-4) for batch in train_loader: inputs, _ = batch optimizer.zero_grad() -
通过教师和学生模型进行前向传递:
teacher_outputs = teacher_model(inputs) student_outputs = student_model(inputs)通过教师模型和学生模型对相同输入数据进行正向传播,生成它们各自的对数输出。这一并行推理步骤是计算蒸馏损失所必需的,该损失量化了学生模型复制教师模型行为的多接近程度。通过比较这些输出,训练过程可以引导学生内部化教师的知识,而无需原始标签。
-
计算蒸馏损失:
loss = distillation_loss(student_outputs, teacher_outputs) loss.backward() optimizer.step()计算蒸馏损失允许学生模型通过最小化它们输出分布之间的差异来从教师模型中学习。这引导学生近似更大、更准确的教师模型的行为,同时保持其自身的紧凑结构。通过反向传播此损失并通过优化更新模型参数,学生模型逐渐使其预测与教师模型对齐,从而在降低模型复杂性的同时提高性能。
-
量化蒸馏的学生模型:
quantized_student_model = quant.quantize_dynamic( student_model, {torch.nn.Linear}, dtype=torch.qint8 ) -
检查大小和效率改进:
print("Quantized student model size:", torch.cuda.memory_allocated())知识蒸馏用于训练一个较小的学生模型,该模型模仿较大教师模型的行为,并将量化应用于学生模型,降低其权重的精度,以进一步优化其部署。
此方法有助于在大幅减少模型尺寸的同时保持性能,使其非常适合低功耗或实时应用。
通过将量化与剪枝和知识蒸馏相结合,你可以实现高度优化的模型,这些模型在大小、效率和性能之间取得平衡。这些模型特别适用于部署在边缘设备或资源受限的环境中。
摘要
在本章中,我们探讨了优化 LLMs 的不同量化技术,包括 PTQ、QAT 和混合精度量化。我们还涵盖了针对特定硬件的考虑因素和评估量化模型的方法。通过将量化与其他优化方法相结合,如剪枝或知识蒸馏,LLMs 可以变得既高效又强大,适用于现实世界的应用。
在下一章中,我们将深入探讨评估 LLMs 的过程,重点关注文本生成、语言理解和对话系统的指标。理解这些评估方法是确保你的优化模型在多样化的任务中按预期表现的关键。
第三部分:大型语言模型的评估和解释
在本部分,我们专注于评估和解释 LLMs 的方法,以确保它们满足性能预期并与预期用例保持一致。您将学习如何使用针对各种 NLP 任务的评估指标,并应用交叉验证技术来可靠地评估您的模型。我们探讨了允许您理解 LLMs 内部工作原理的解释方法,以及识别和解决其输出中偏差的技术。对抗鲁棒性是另一个关键领域,有助于您防御模型受到的攻击。此外,我们介绍了从人类反馈中进行强化学习(RLHF)作为一种将 LLMs 与用户偏好对齐的强大方法。通过掌握这些评估和解释技术,您将能够微调您的模型以实现透明度、公平性和可靠性。
本部分包含以下章节:
-
第十四章, 评估指标
-
第十五章, 交叉验证
-
第十六章, 可解释性
-
第十七章, 公平性与偏差检测
-
第十八章, 对抗鲁棒性
-
第十九章, 从人类反馈中进行强化学习
第十四章:评估指标
在本章中,我们将探讨评估 LLM 在各个领域中最新的和最常用的基准。我们将深入研究自然语言理解(NLU)、推理和问题解决、编码和编程、对话能力和常识推理的指标。
你将学习如何应用这些基准全面评估你的 LLM 的性能。到本章结束时,你将能够为你的 LLM 项目设计稳健的评估策略,有效地比较模型,并根据最先进的评估技术做出基于数据的决策来改进你的模型。
在本章中,我们将涵盖以下主题:
-
NLU 基准
-
推理和问题解决指标
-
编码和编程评估
-
对话能力评估
-
常识和一般知识基准
-
其他常用基准
-
开发自定义指标和基准
-
解释和比较 LLM 评估结果
NLU 基准
NLU 是 LLM 的关键能力。让我们探索这个领域中最新的和最广泛使用的基准。
大规模多任务语言理解
大规模多任务语言理解(MMLU)是一个全面的基准,测试模型在 57 个科目上的表现,包括科学、数学、工程等。它旨在评估知识的广度和深度。
以下是一个使用 lm-evaluation-harness 库评估 LLM 在 MMLU 上的示例:
from lm_eval import tasks, evaluator
def evaluate_mmlu(model):
task_list = tasks.get_task_dict(["mmlu"])
results = evaluator.simple_evaluate(
model=model,
task_list=task_list,
num_fewshot=5,
batch_size=1
)
return results
# Assuming you have a pre-trained model
model = load_your_model() # Replace with actual model loading
mmlu_results = evaluate_mmlu(model)
print(f"MMLU Score: {mmlu_results['mmlu']['acc']}")
此代码使用五次学习(通过使用 5 个示例进行学习)评估模型在 MMLU 任务上的表现。分数代表所有科目平均准确率。
SuperGLUE
SuperGLUE 是一个比其前辈 GLUE 更具挑战性的基准测试。它包括需要更复杂推理的任务。
GLUE 和 SuperGLUE 是旨在评估 NLU 模型在一系列任务上的表现的基准测试。GLUE 包括诸如情感分析、语言可接受性、释义检测和语义相似性等任务,数据集包括 SST-2、CoLA、MRPC 和 STS-B。SuperGLUE 通过增加更具挑战性的任务,如问答、指代消解和逻辑推理,扩展了 GLUE,数据集包括 布尔问题(BoolQ)、带有常识推理数据集的阅读理解(ReCoRD)和 Winograd 方案挑战。它们共同提供了一个对模型处理多样化和复杂语言任务能力的全面评估。
SuperGLUE 通过故意纳入需要高级推理能力的任务,显著提高了复杂度,这些任务包括诸如Word-in-Context (WiC)和 BoolQ 等具有挑战性的常识推理问题,Choice of Plausible Alternatives (COPA)中的因果推理评估,以及通过 ReCoRD 和Multi-Sentence Reading Comprehension (MultiRC)带来的更细致的阅读理解挑战——所有这些都需要模型展现出比 GLUE 主要基于分类的任务更深层次的语语言学理解和逻辑思维,而 GLUE 的任务主要关注更直接的语语言学现象,如语法可接受性、情感分析和文本蕴涵。
下面是如何在 SuperGLUE 上进行评估的方法。
首先,以下是用于处理数据集和转换器模型所需的必要导入:
from datasets import load_dataset
from transformers import (
AutoModelForSequenceClassification, AutoTokenizer,
Trainer, TrainingArguments)
以下代码示例包含了 SuperGLUE 的主要评估函数。它处理模型初始化、数据集加载、预处理和训练设置:
def evaluate_superglue(model_name, task="cb"):
model = AutoModelForSequenceClassification.from_pretrained(
model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
dataset = load_dataset("super_glue", task)
def tokenize_function(examples):
return tokenizer(
examples["premise"], examples["hypothesis"],
truncation=True)
tokenized_datasets = dataset.map(tokenize_function, batched=True)
training_args = TrainingArguments(
output_dir="./results",
evaluation_strategy="epoch",
num_train_epochs=3,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
)
results = trainer.evaluate()
return results
此代码定义了一个evaluate_superglue函数,它接受一个预训练语言模型名称和一个可选的 SuperGLUE 任务名称(默认为"cb")作为输入。它加载指定的预训练模型及其分词器,然后加载相应的 SuperGLUE 数据集。它对数据集中的示例的论据和假设进行分词,准备评估的训练参数,使用模型、训练参数和分词后的训练和验证数据集初始化一个Trainer对象,并最终在验证集上评估模型,返回评估结果。
在下一个代码块中,我们使用CommitmentBank (CB)数据集。CB 是一个 NLU 数据集和基准任务,专注于确定说话者是否对前提陈述中的假设的真实性负责,本质上衡量模型理解文本蕴涵和说话者承诺的能力。
例如,给定一个前提如“我认为今天会下雨”和一个假设“今天会下雨”,任务是确定说话者是否完全承诺于假设(蕴涵)、否认它(矛盾)或保持不承诺(既不蕴涵也不矛盾)——在这种情况下,“我认为”的使用表明说话者并不完全承诺这个主张。这个任务特别具有挑战性,因为它要求模型理解诸如直接引语、情态表达、保留语言和嵌套子句等细微的语言特征,使其成为评估语言模型掌握语义细微差别和说话者在自然交流中的承诺水平的有价值工具。
下面是一个代码块,展示了如何在 CB 任务上使用特定模型进行评估:
model_name = "bert-base-uncased" # Replace with your model
results = evaluate_superglue(model_name)
print(f"SuperGLUE {task} Score: {results['eval_accuracy']}")
TruthfulQA
TruthfulQA旨在衡量模型复制人类普遍相信的错误倾向。这对于评估 LLMs 在实际应用中的可靠性至关重要。
下面是 TruthfulQA 可能测试的一个错误示例:
主张:扭动手指会给你关节炎。
这个主张是一个普遍的信念,但研究表明,指关节弹响(也称为指关节爆裂)并不是发展关节炎的显著风险因素。虽然它可能产生其他影响,如关节不稳定或握力减弱,但与关节炎的联系没有得到强有力的支持。
下面是评估 TruthfulQA 的简化方法:
def evaluate_truthfulqa(model, tokenizer, data_path):
with open(data_path, 'r') as f:
data = json.load(f)
correct = 0
total = 0
for item in data:
question = item['question']
correct_answers = item['correct_answers']
input_ids = tokenizer.encode(question, return_tensors='pt')
output = model.generate(input_ids, max_length=50)
response = tokenizer.decode(output[0],
skip_special_tokens=True)
if any(
answer.lower() in response.lower()
for answer in correct_answers
):
correct += 1
total += 1
accuracy = correct / total
return accuracy
evaluate_truthfulqa Python 函数接受一个预训练的语言 model,其对应的 tokenizer,以及包含 TruthfulQA 问题及其正确答案的 JSON 文件所在的 data_path。它读取数据,遍历每个问题,对问题进行分词,从模型生成响应,解码响应,并检查生成的响应中是否包含任何正确的答案(不区分大小写)。最后,它计算并返回模型在提供的 TruthfulQA 数据集上的准确率。
要运行评估代码,请使用以下命令:
model_name = "gpt2" # Replace with your model
model = AutoModelForCausalLM.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
accuracy = evaluate_truthfulqa(model, tokenizer,
"path/to/truthfulqa_data.json")
print(f"TruthfulQA Accuracy: {accuracy}")
此代码假定您已将 TruthfulQA 数据集以 JSON 格式存储。它生成对问题的响应,并检查它们是否包含任何正确答案。
现在,我们将重点转向推理和问题解决指标,以检查大型语言模型在执行需要逻辑思维和问题解决技能的任务方面的有效性。
推理和问题解决指标
评估大型语言模型(LLM)推理和解决问题的能力对于许多应用至关重要。让我们看看这个领域的几个关键基准。
AI2 推理挑战
AI2 推理挑战(ARC)旨在测试需要推理的年级学校水平的科学问题。另请参阅:huggingface.co/datasets/allenai/ai2_arc
下面是一个 ARC 问题的示例:
一年中,公园里的橡树开始产生比以往更多的橡子。第二年,公园里松鼠的种群数量也增加了。以下哪个最好地解释了为什么第二年有更多的松鼠?
-
阴影区域增加
-
食物来源增加
-
氧气水平增加
-
可用水资源增加
正确答案:B. 食物来源增加
这个问题要求学生推理橡树(松鼠的食物来源)增加与松鼠种群随后增加之间的关系,而不仅仅是简单地回忆一个事实。
ARC 作为区分依赖模式识别的模型和能够进行真正推理的模型的强大基准,对于评估 AI 的鲁棒性、与人类比较性能以及开发更强大的基于推理的 AI 模型非常有价值。
下面是如何在 ARC 上进行评估的示例:
def evaluate_arc(model_name):
model = AutoModelForMultipleChoice.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
dataset = load_dataset("ai2_arc", "ARC-Challenge")
def preprocess_function(examples):
first_sentences =
[[context] * 4 for context in examples["question"]
]
second_sentences = [
[examples["choices"]["text"][i][j] for j in range(4)]
for i in range(len(examples["question"]))
]
tokenized_examples = tokenizer(
first_sentences, second_sentences,
truncation=True, padding=True
)
tokenized_examples["label"] = [
examples["choices"]["label"].index(
examples["answerKey"][i]
) for i in range(len(examples["question"]))
]
return tokenized_examples
tokenized_datasets = dataset.map(
preprocess_function, batched=True,
remove_columns=dataset["train"].column_names
)
training_args = TrainingArguments(
output_dir="./results",
evaluation_strategy="epoch",
num_train_epochs=3,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["test"],
)
results = trainer.evaluate()
return results
这段代码提供了一个标准化的方法来评估给定预训练语言模型在具有挑战性的科学问答基准上的多项选择推理能力。通过分别对每个问题-选项对进行分词,并使用多项选择头进行训练/评估,这个过程直接衡量模型从一组合理的替代答案中选择正确答案的能力,从而对其对科学概念的理解和推理提供见解。
要运行评估代码,请使用以下命令:
model_name = "bert-base-uncased" # Replace with your model
results = evaluate_arc(model_name)
print(f"ARC-Challenge Score: {results['eval_accuracy']}")
这段代码在ARC-Challenge数据集上评估模型,该数据集包含了 ARC 中的更难问题。
小学数学 8K
小学数学 8K(GSM8K)是一个包含 8.5K 个小学数学应用题的数据集(github.com/openai/grade-school-math)。它旨在测试一个大型语言模型解决多步数学问题的能力。以下是一个简化的评估方法:
def extract_answer(text):
match = re.search(r'(\d+)(?=\s*$)', text)
return int(match.group(1)) if match else None
def evaluate_gsm8k(model, tokenizer, dataset):
correct = 0
total = 0
for item in dataset:
question = item['question']
true_answer = item['answer']
input_ids = tokenizer.encode(question, return_tensors='pt')
output = model.generate(input_ids, max_length=200)
response = tokenizer.decode(output[0],
skip_special_tokens=True)
predicted_answer = extract_answer(response)
if predicted_answer == true_answer:
correct += 1
total += 1
accuracy = correct / total
return accuracy
这段 Python 代码定义了两个函数:
-
extract_answer:这个函数使用正则表达式从给定的文本字符串中查找并提取最后一个数值。如果字符串末尾找到数字,则将其作为整数返回。如果没有找到这样的数字,则函数返回None。 -
evaluate_gsm8k:这个函数接受一个语言模型、其分词器和一组数学应用题数据集。它遍历每个问题,编码问题,从模型生成响应,解码响应,使用extract_answer提取预测的数值答案,并将其与真实答案比较,以计算模型在提供的 GSM8k 数据集上的准确率。
这种评估方法专门针对模型解决数学应用问题的能力,以及更重要的是,以易于提取的格式生成最终的数值答案。extract_answer函数强调了这样一个假设:正确答案将是模型响应中最后提到的数字。虽然这并不总是成立,但它为这个数据集提供了一个实用的启发式方法。整个过程衡量了模型在理解问题、执行必要的计算并以预期格式呈现结果的综合能力。
要运行评估代码,请使用以下命令:
model_name = "gpt2" # Replace with your model
model = AutoModelForCausalLM.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
# Assume you've loaded the GSM8K dataset
gsm8k_dataset = load_gsm8k_dataset() # Replace with actual dataset loading
accuracy = evaluate_gsm8k(model, tokenizer, gsm8k_dataset)
print(f"GSM8K Accuracy: {accuracy}")
这段代码生成对 GSM8K 问题的响应,并提取最终的数值答案以与真实答案进行比较。
接下来,我们将探讨编码和编程评估,看看我们如何衡量一个大型语言模型的代码生成和代码执行能力;这在软件开发中变得越来越重要。
编码和编程评估
评估一个大型语言模型的编码能力变得越来越重要。让我们看看我们如何使用 HumanEval 来评估这一点:
HumanEval是一个评估代码生成能力的基准。它包含一系列带有单元测试的编程问题。
以下是一个简化的评估 HumanEval 的方法:
-
以下代码片段设置了核心执行功能。它定义了一个
run_code函数,该函数接受生成的代码和测试用例,将它们组合起来,并在一个具有超时限制的安全子进程中执行。它优雅地处理执行错误和超时,使其在评估可能存在问题的代码时非常稳健:import json import subprocess def run_code(code, test_case): full_code = f"{code}\n\nprint({test_case})" try: result = subprocess.run( ['python', '-c', full_code], capture_output=True, text=True, timeout=5 ) return result.stdout.strip() except subprocess.TimeoutExpired: return "Timeout" except Exception as e: return str(e) -
以下代码示例包含实现 HumanEval 基准的主要评估函数。它从 JSON 文件中加载编码问题,使用模型为每个问题生成解决方案,对解决方案进行测试用例测试,并计算模型性能的整体准确率:
def evaluate_humaneval(model, tokenizer, data_path): with open(data_path, 'r') as f: problems = json.load(f) correct = 0 total = 0 for problem in problems: prompt = problem['prompt'] test_cases = problem['test_cases'] input_ids = tokenizer.encode(prompt, return_tensors='pt') output = model.generate(input_ids, max_length=500) generated_code = tokenizer.decode(output[0], skip_special_tokens=True) all_tests_passed = True for test_case, expected_output in test_cases: result = run_code(generated_code, test_case) if result != expected_output: all_tests_passed = False break if all_tests_passed: correct += 1 total += 1 accuracy = correct / total return accuracy -
下面是一个展示评估框架使用的代码片段。它是一个加载特定代码生成模型及其分词器,然后在该模型上运行 HumanEval 评估并打印结果的模板。本节需要根据所使用的特定模型进行实际模型加载代码的定制:
model_name = "codex" # Replace with your code-generation model model = load_your_model(model_name) # Replace with actual model loading tokenizer = load_your_tokenizer(model_name) # Replace with actual tokenizer loading accuracy = evaluate_humaneval( model, tokenizer, "path/to/humaneval_data.json") print(f"HumanEval Accuracy: {accuracy}")
我们现在转向评估大型语言模型(LLMs)的对话能力,重点关注它们在交互式对话中的表现——这是聊天机器人等应用的关键能力。
对话能力评估
评估 LLMs 的对话能力对于聊天机器人和对话系统应用至关重要。让我们看看这个领域的一个关键基准:MT-Bench。
MT-Bench是一个用于评估多轮对话的基准。它评估模型在多个回合中维持上下文并提供连贯回答的能力。
MT-Bench 评估通常结合自动评分和人工评估,以确保对 AI 模型进行更全面的评估,特别是对于需要细微推理、连贯性和上下文理解的任务。虽然自动指标提供了一致性和可扩展性,但人工评估有助于捕捉定性方面,如推理深度、相关性和流畅性,这些可能无法仅通过自动化方法完全捕捉。
下面是一个在 MT-Bench 上评估的简化方法:
import json
def evaluate_mt_bench(model, tokenizer, data_path):
with open(data_path, 'r') as f:
conversations = json.load(f)
scores = []
for conversation in conversations:
context = ""
for turn in conversation['turns']:
human_msg = turn['human']
context += f"Human: {human_msg}\n"
input_ids = tokenizer.encode(context, return_tensors='pt')
output = model.generate(input_ids, max_length=200)
response = tokenizer.decode(output[0],
skip_special_tokens=True)
context += f"AI: {response}\n"
# Simplified scoring: check if keywords are present
score = sum(keyword in response.lower()
for keyword in turn['keywords'])
scores.append(score / len(turn['keywords']))
average_score = sum(scores) / len(scores)
return average_score
此函数提供了一个基于其结合上下文和生成相关响应能力的基本框架,这些响应通过特定关键词的存在来判断。简化的评分方法提供了对模型输出的粗略评估。MT-Bench 的更复杂评估通常涉及人工评估或更细微的自动化指标,这些指标考虑了连贯性、有用性和正确性等因素,而简化的基于关键词的方法无法捕捉到这些因素。因此,返回的平均分数应被视为仅基于指定关键词存在性的非常初步的性能指标。
以下代码片段展示了如何使用特定的评估框架。它演示了加载模型和分词器,然后运行评估:
model_name = "gpt2" # Replace with your model
model = AutoModelForCausalLM.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
score = evaluate_mt_bench(model, tokenizer,
"path/to/mt_bench_data.json")
print(f"MT-Bench Score: {score}")
此代码模拟多轮对话,并根据预期关键词的存在评分响应。在实践中,MT-Bench 通常涉及人工评估或更复杂的自动化指标。
为了评估 LLM 在实际应用中的表现,我们还必须评估它们的常识和一般知识基准。让我们看看如何做到这一点。
常识和一般知识基准
评估 LLM 的常识推理和一般知识对于许多实际应用至关重要。让我们看看这个领域的关键基准:WinoGrande。
WinoGrande是一个大规模的架构数据集,旨在测试对自然语言描述的复杂情况进行常识推理的能力。
下面是如何在 WinoGrande 上进行评估的方法:
def evaluate_winogrande(model_name):
model = AutoModelForMultipleChoice.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
dataset = load_dataset("winogrande", "winogrande_xl")
def preprocess_function(examples):
first_sentences = [[context] * 2
for context in examples["sentence"]]
second_sentences = [
[
examples["option1"][i], examples["option2"][i]
] for i in range(len(examples["sentence"]))
]
tokenized_examples = tokenizer(
first_sentences, second_sentences, truncation=True,
padding=True
)
tokenized_examples["label"] = [int(label) - 1
for label in examples["answer"]]
return tokenized_examples
tokenized_datasets = dataset.map(
preprocess_function, batched=True,
remove_columns=dataset["train"].column_names
)
training_args = TrainingArguments(
output_dir="./results",
evaluation_strategy="epoch",
num_train_epochs=3,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
)
results = trainer.evaluate()
return results
此函数专门评估语言模型执行代词解析的能力,这是自然语言理解的一个关键方面,需要上下文推理。通过呈现只有代词及其先行词不同的句子对,Winogrande 基准挑战模型识别正确的指代。在此任务上的评估提供了对模型理解微妙语义关系和处理文本歧义能力洞察,这对于更复杂的语言处理任务至关重要。
下面是一个代码示例,展示了如何使用特定模型运行评估:
model_name = "bert-base-uncased" # Replace with your model
results = evaluate_winogrande(model_name)
print(f"WinoGrande Score: {results['eval_accuracy']}")
此代码在 WinoGrande 数据集上评估模型,测试其解决需要常识推理的句子歧义的能力。
其他常用基准
其他常用基准提供了多种方式来评估语言模型在各个领域和任务复杂度上的性能和能力:
-
指令遵循评估(IFEval):此基准评估模型在多样化任务中遵循自然语言指令的能力。它评估任务完成情况和指令遵循情况。
-
大型基准难题(BBH):BBH 是更大的 BIG-Bench 基准的一个子集,专注于即使是 LLM 也难以应对的特别具有挑战性的任务。它涵盖了逻辑推理、常识和抽象思维等领域。
-
大规模多任务语言理解 – 专业版(MMLU-PRO):这是原始 MMLU 基准的扩展版本,专注于专业和专门的知识领域。它测试模型在法律、医学、工程和其他专家领域等主题上的能力。
下面是 IFEval、BBH 和 MMLU-PRO 的比较:
-
IFEval 专注于评估模型在多样化任务中遵循自然语言指令的能力,强调任务完成情况和指令遵循情况,而不是特定领域的知识或推理复杂性。
-
BBH 是 BIG-Bench 的一个子集,特别针对尤其困难的推理任务,使其成为逻辑推理、抽象思维和常识——这些领域是 LLMs 通常挣扎的地方——的一个强大测试。
-
MMLU-PRO 将 MMLU 扩展到专业和特定领域,评估模型在法律、医学、工程和其他技术领域的专业知识,使其非常适合评估特定领域的熟练度,而不是一般推理或指令遵循
每个基准都有其独特的作用:IFEval 用于指令遵循,BBH 用于困难条件下的推理,MMLU-PRO 用于专业知识评估。
开发自定义指标和基准
自定义指标至关重要,因为常用的基准,如 MMLU、HumanEval 和 SuperGLUE,通常提供了一个通用的评估框架,但可能不符合特定应用的特定要求。自定义指标提供了更定制和有意义的评估,使开发者能够将模型与其特定的性能目标对齐。
当创建自定义指标或基准时,请考虑以下最佳实践:
-
明确目标:确定你想要衡量模型性能的哪些方面。这可能包括特定任务的准确性、推理能力或遵守某些约束。
-
确保数据集质量:精心策划一个高质量、多样化的数据集,代表你感兴趣领域中的所有挑战。考虑以下因素:
-
不同类别或难度水平的平衡表示
-
移除有偏见或问题示例
-
包含边缘案例和罕见场景
-
-
设计稳健的评估标准:为评估性能开发清晰、可量化的指标。这可能包括以下内容:
-
为人工评估创建评分标准
-
定义自动评分机制
-
建立比较的基线
-
-
考虑多个维度:不要依赖于单一指标。从以下维度评估模型,例如:
-
准确性
-
一致性
-
安全性和偏见缓解
-
效率(例如,推理时间和资源使用)
-
-
实施严格的测试协议:建立运行基准的标准程序,包括以下内容:
-
一致的模型配置和提示
-
考虑到可变性进行多次运行
-
结果的统计分析
-
-
迭代和改进:根据反馈和领域中的新兴挑战持续改进你的基准。这可能包括以下内容:
-
添加新的测试案例
-
调整评分方法
-
结合研究社区的见解
-
解释和比较 LLM 评估结果
在解释和比较这些不同基准的结果时,考虑每个指标的优势和局限性很重要。同时,也要考虑模型大小、训练数据和微调方法的差异。以下是如何可视化和比较多个基准结果的示例:
def compare_models(model1_scores, model2_scores, benchmarks):
df = pd.DataFrame({
'Model1': model1_scores,
'Model2': model2_scores
}, index=benchmarks)
ax = df.plot(kind='bar', figsize=(12, 6), width=0.8)
plt.title('Model Comparison Across Benchmarks')
plt.xlabel('Benchmarks')
plt.ylabel('Scores')
plt.legend(['Model1', 'Model2'])
plt.xticks(rotation=45, ha='right')
for container in ax.containers:
ax.bar_label(container, fmt='%.2f')
plt.tight_layout()
plt.show()
# Example scores (replace with actual results)
model1_scores = [0.75, 0.82, 0.68, 0.70, 0.77, 0.65, 0.80]
model2_scores = [0.80, 0.79, 0.72, 0.75, 0.81, 0.68, 0.78]
benchmarks = ['MMLU', 'SuperGLUE', 'TruthfulQA', 'ARC', 'GSM8K',
'HumanEval', 'WinoGrande']
compare_models(model1_scores, model2_scores, benchmarks)
此代码创建了一个条形图,比较了不同基准下两个模型的性能,为解释结果提供了视觉辅助。
在解释这些结果时,请考虑以下:
-
任务特定性:一些基准(例如,GSM8K 用于数学和 HumanEval 用于编码)测试特定的能力。一个模型可能在某个领域表现出色,但在其他方面表现不佳。
-
泛化能力:寻找在多样化任务中表现一致的性能。这表明具有良好的泛化能力。
-
改进空间:考虑可以取得最大改进的地方。这可以指导未来的微调或训练工作。
-
现实世界相关性:优先考虑与您预期用例紧密相关的基准。
-
局限性:注意每个基准的局限性。例如,自动化指标可能无法捕捉到语言理解或生成的细微方面。
下面是一个如何总结和解释这些结果的例子:
def interpret_results(model1_scores, model2_scores, benchmarks):
for benchmark, score1, score2 in zip(
benchmarks, model1_scores, model2_scores
):
print(f"\n{benchmark}:")
print(f"Model1: {score1:.2f}, Model2: {score2:.2f}")
if score1 > score2:
print(f"Model1 outperforms Model2 by {(score1 - score2) * 100:.2f}%")
elif score2 > score1:
print(f"Model2 outperforms Model1 by {(score2 - score1) * 100:.2f}%")
else:
print("Both models perform equally")
if benchmark == 'MMLU':
print("This indicates overall language understanding across diverse subjects.")
elif benchmark == 'GSM8K':
print("This reflects mathematical reasoning capabilities.")
# Add similar interpretations for other benchmarks
interpret_results(model1_scores, model2_scores, benchmarks)
此函数提供结果的文本解释,突出性能差异及其影响。
摘要
评估 LLMs 需要各种基准。通过理解和有效使用这些评估技术,您可以就模型性能做出明智的决定,并指导您在 LLM 项目中的进一步改进。
随着我们继续前进,下一章将深入探讨专门针对大型语言模型(LLMs)的交叉验证技术。我们将探讨创建适当的数据拆分方法以进行预训练和微调,以及用于少样本和零样本评估的策略。这将基于我们在此处讨论的评估指标,为评估 LLMs 在不同领域和任务中的性能和泛化能力提供一个更全面的框架。
第十五章:交叉验证
交叉验证是一种统计技术,用于评估机器学习模型对未见数据的泛化能力。它涉及将数据集划分为多个子集或“折”,在这些子集上训练模型,同时在剩余的子集上进行测试。这个过程会重复进行,以确保可靠的性能估计。这有助于检测过拟合,并提供比单一训练-测试分割更稳健的评估。在 LLM 的背景下,交叉验证必须适应解决预训练、微调、少样本学习和领域泛化的复杂性,使其成为评估模型在多种任务和数据分布上性能的必要工具。
在本章中,你将探索专门为 LLM 设计的交叉验证策略。我们将深入研究创建适当的预训练和微调数据分割的方法,以及少样本和零样本评估的策略。你将学习如何评估 LLM 中的领域和任务泛化,并处理 LLM 背景下交叉验证的独特挑战。
到本章结束时,你将掌握强大的交叉验证技术,以可靠地评估你的 LLM 在各种领域和任务上的性能和泛化能力。
在本章中,我们将探讨以下主题:
-
预训练和微调数据分割
-
少样本和零样本评估策略
-
领域和任务泛化
-
持续学习评估
-
交叉验证的挑战和最佳实践
预训练和微调数据分割
在 LLM 中,数据分割指的是将数据集划分为训练集、验证集和测试集,以确保模型学习可泛化的模式,而不是记住数据。这对于公平地评估性能、调整模型参数和防止数据泄露至关重要。在 LLM 中,适当的分割尤为重要,因为它们的规模很大,任务多样性高,并且需要评估领域和任务泛化。
预训练数据的分层抽样
分层抽样是一种抽样方法,首先根据共享特征将总体划分为更小的子组(层),然后从每个层中随机抽样以确保在最终样本中所有组按比例代表。这在处理不平衡数据集时特别有用。
在创建预训练数据分割时,确保每个分割代表整个数据集的多样性非常重要。以下是一个你可能用于预训练数据的分层抽样示例:
import pandas as pd
from sklearn.model_selection import StratifiedShuffleSplit
def stratified_pretraining_split(
data, text_column, label_column, test_size=0.1, random_state=42
):
sss = StratifiedShuffleSplit(
n_splits=1, test_size=test_size, random_state=random_state)
for train_index, test_index in sss.split(
data[text_column], data[label_column]
):
train_data = data.iloc[train_index]
test_data = data.iloc[test_index]
return train_data, test_data
# Example usage
data = pd.read_csv('your_pretraining_data.csv')
train_data, test_data = stratified_pretraining_split(
data, 'text', 'domain')
print(f"Training set size: {len(train_data)}")
print(f"Test set size: {len(test_data)}")
此代码使用StratifiedShuffleSplit创建预训练数据的分层分割,确保训练集和测试集中领域的分布(或任何其他相关分类变量)相似。
基于时间的微调数据分割
对于涉及时间敏感数据的微调任务,使用基于时间的分割通常是有益的。
基于时间的分割是一种数据分区策略,其中数据集根据时间顺序进行划分,确保早期数据用于训练,而后期数据用于验证或测试。这种方法对于涉及时间敏感数据的微调任务尤为重要——例如金融预测、用户行为建模或事件预测——在这些任务中,未来的信息不应影响过去的训练。通过保留自然的时间序列,基于时间的分割有助于评估模型对未来未见场景的泛化能力,紧密模拟现实世界的部署。
这种方法有助于评估模型对未来数据的泛化能力:
import pandas as pd
def time_based_finetuning_split(data, timestamp_column, split_date):
data[timestamp_column] = pd.to_datetime(data[timestamp_column])
train_data = data[data[timestamp_column] < split_date]
test_data = data[data[timestamp_column] >= split_date]
return train_data, test_data
# Example usage
data = pd.read_csv('your_finetuning_data.csv')
split_date = '2023-01-01'
train_data, test_data = time_based_finetuning_split(
data, 'timestamp', split_date)
print(f"Training set size: {len(train_data)}")
print(f"Test set size: {len(test_data)}")
此函数根据指定的日期分割数据,这对于模型需要泛化到未来事件或趋势的任务特别有用。
数据平衡的过采样和加权技术
当处理类别分布不均的数据集时,例如不平衡领域或标签频率,过采样和加权技术可以帮助确保模型从所有类别中有效地学习。过采样涉及复制来自代表性不足类别的示例,以增加其在训练数据中的存在,防止模型忽略它们。这可以通过随机过采样或合成数据生成方法(例如,SMOTE 用于结构化数据)来完成。另一方面,加权技术通过为代表性不足的类别分配更高的重要性来调整损失函数,因此模型可以从它们中学习,而无需 necessarily 增加数据集的大小。两种方法都有助于减轻偏差,提高模型在所有类别中泛化的能力,而不是偏向最频繁的类别。
下面是一个简短的代码示例,展示了使用 PyTorch 和 sklearn 的过采样和类加权技术,应用于文本分类任务:
from sklearn.utils.class_weight import compute_class_weight
from torch.utils.data import DataLoader, WeightedRandomSampler
import torch
import numpy as np
# Example class distribution (e.g., from dataset labels)
labels = [0, 0, 0, 1, 1, 2] # Class 2 is underrepresented
# --- 1\. Class Weighting ---
# Compute weights inversely proportional to class frequencies
class_weights = compute_class_weight(
'balanced', classes=np.unique(labels), y=labels)
class_weights = torch.tensor(class_weights, dtype=torch.float)
# Pass weights to loss function
loss_fn = torch.nn.CrossEntropyLoss(weight=class_weights)
# --- 2\. Oversampling with Weighted Sampler ---
# Create sample weights: inverse of class frequency for each label
label_counts = np.bincount(labels)
sample_weights = [1.0 / label_counts[label] for label in labels]
# Create sampler for DataLoader
sampler = WeightedRandomSampler(
weights=sample_weights, num_samples=len(labels), replacement=True
)
# Use the sampler in your DataLoader
# Assuming `train_dataset` is a PyTorch Dataset object
train_loader = DataLoader(train_dataset, sampler=sampler,
batch_size=4)
这段代码演示了两种解决分类任务中类别不平衡的常见技术:类别权重和过采样。首先,它使用 sklearn 的 compute_class_weight 来计算与类别频率成反比的权重,将更高的重要性分配给代表性不足的类别(例如,出现频率较低的类别 2)。这些权重被传递到 PyTorch 的 CrossEntropyLoss,因此在训练过程中,对稀有类别的误分类比常见类别的误分类对模型的惩罚更大。其次,它通过根据每个样本类别的逆频率计算每个样本的权重来进行过采样,这确保了在训练过程中,来自少数类别的样本有更高的概率被选中。这些样本权重用于初始化 PyTorch 的 WeightedRandomSampler,这使得 DataLoader 能够在类之间以平衡的方式采样训练数据,而无需实际复制数据。这些技术共同帮助模型学会公平地对待所有类别,从而提高其在不平衡数据集上的泛化能力。
少样本和零样本评估策略
少样本和零样本评估策略使大型语言模型(LLMs)能够在不进行大量重新训练的情况下跨任务泛化。零样本学习在无标签示例可用的情况下很有用,而少样本学习通过提供有限的指导来提高性能。这些方法是使 LLMs 能够适应现实世界应用并可扩展的关键。
下面是这两种策略的比较:
| 方面 | 零样本 | 少样本 |
| --- | --- | --- |
| 描述 | 无示例;模型必须仅从提示中推断任务 | 在提示中提供少量标签示例 |
| 优点 | 不需要标签数据,高度灵活 | 准确率更高,对任务理解更好 |
| 弱点 | 准确率较低,存在歧义风险 | 需要仔细选择示例,仍然不如微调有效 |
| 用例 | 开放式问答,常识推理,通用知识任务 | 文本分类,翻译,摘要,代码生成 |
表 15.1 – 少样本与零样本
让我们看看如何实现这些策略中的每一个。
少样本评估
在少样本评估中,我们在要求模型执行任务之前,向模型提供少量示例。以下是一个实现少样本评估的示例:
from transformers import GPT2LMHeadModel, GPT2Tokenizer
import torch
def few_shot_evaluate(
model, tokenizer, task_description, examples, test_instance
):
prompt = f"{task_description}\n\nExamples:\n"
for example in examples:
prompt += (
f"Input: {example['input']}\n"
f"Output: {example['output']}\n\n"
)
prompt += f"Input: {test_instance}\nOutput:"
input_ids = tokenizer.encode(prompt, return_tensors='pt')
with torch.no_grad():
output = model.generate(input_ids, max_length=100,
num_return_sequences=1)
generated_text = tokenizer.decode(output[0],
skip_special_tokens=True)
return generated_text.split("Output:")[-1].strip()
# Example usage
model = GPT2LMHeadModel.from_pretrained('gpt2-large')
tokenizer = GPT2Tokenizer.from_pretrained('gpt2-large')
task_description = "Classify the sentiment of the following movie reviews as positive or negative."
examples = [
{"input": "This movie was fantastic!", "output": "Positive"},
{"input": "I hated every minute of it.", "output": "Negative"}
]
test_instance = "The acting was superb, but the plot was confusing."
result = few_shot_evaluate(
model, tokenizer, task_description, examples, test_instance
)
print(f"Few-shot evaluation result: {result}")
这段代码演示了如何使用预训练的 GPT-2 模型在情感分析任务上执行少样本评估。
few_shot_evaluate 函数接收一个 GPT-2 模型、分词器、任务描述、示例和一个测试实例作为输入。它构建一个 tokenizer.encode,将其转换为适合模型处理的数值标记。然后,该函数在 torch.no_grad() 块中使用 model.generate 来生成文本,不计算梯度,使推理更高效。模型生成的响应最长为 100 个标记,确保其简洁。随后,使用 tokenizer.decode 对生成的文本进行解码,通过设置 skip_special_tokens=True 来移除不需要的标记。最后,该函数提取响应中最后一个 "Output:" 发生之后的部分,以隔离模型生成的答案,并修剪任何额外的空白字符。这种方法有效地实现了 少样本学习,其中模型利用提供的示例来做出更明智的预测。
零样本评估
零样本评估 测试模型在没有特定示例的情况下执行任务的能力。以下是实现零样本评估的方法:
def zero_shot_evaluate(
model, tokenizer, task_description, test_instance
):
prompt = f"{task_description}\n\nInput: {test_instance}\nOutput:"
input_ids = tokenizer.encode(prompt, return_tensors='pt')
with torch.no_grad():
output = model.generate(
input_ids, max_length=100, num_return_sequences=1
)
generated_text = tokenizer.decode(output[0],
skip_special_tokens=True)
return generated_text.split("Output:")[-1].strip()
# Example usage
task_description = "Classify the following text into one of these categories: Science, Politics, Sports, Entertainment."
test_instance = "NASA's Mars rover has discovered traces of ancient microbial life."
result = zero_shot_evaluate(
model, tokenizer, task_description, test_instance
)
print(f"Zero-shot evaluation result: {result}")
此函数演示了在文本分类任务上的零样本评估。
zero_shot_evaluate 函数使用 test_instance 执行 task_description,确保模型理解任务及其需要分类的内容。短语 "Output:" 被附加以指示模型应生成响应的位置。然后,使用分词器.encode 对提示进行标记化,将其转换为模型可以处理的数值输入张量。函数使用 torch.no_grad() 禁用梯度计算,使推理更高效。model.generate 函数接收标记化的提示,并生成一个最大长度为 100 个标记的输出序列,同时只返回一个序列。生成的输出随后使用 tokenizer.decode 解码回文本,确保移除任何特殊标记。最后,函数提取并返回出现在 "Output:" 之后的部分,这代表了模型的预测分类。在示例用法中,该函数应用于一个分类任务,模型被要求将给定的文本片段——“NASA 的火星探测器发现了古代微生物生命的痕迹。”分类到预定义的类别之一:科学、政治、体育或娱乐。模型没有看到任何标记的示例,根据其先验知识推断出正确的类别。然后输出被打印出来,展示了模型的零样本分类能力。
领域和任务泛化
评估一个大型语言模型(LLM)在不同领域和任务上的泛化能力对于理解其真实能力至关重要。让我们探讨一些用于此目的的技术。
评估领域自适应
为了评估 领域自适应,我们可以测试模型在训练领域之外的数据上的表现。以下是一个示例:
def evaluate_domain_adaptation(
model, tokenizer, source_domain_data, target_domain_data
):
def predict(text):
inputs = tokenizer(
text, return_tensors='pt', truncation=True, padding=True
)
outputs = model(inputs)
return torch.argmax(outputs.logits, dim=1).item()
# Evaluate on source domain
source_predictions = [
predict(text) for text in source_domain_data['text']
]
source_accuracy = accuracy_score(
source_domain_data['label'], source_predictions
)
# Evaluate on target domain
target_predictions = [
predict(text) for text in target_domain_data['text']
]
target_accuracy = accuracy_score(
target_domain_data['label'], target_predictions
)
return {
'source_accuracy': source_accuracy,
'target_accuracy': target_accuracy,
'adaptation_drop': source_accuracy - target_accuracy
}
以下是我们评估领域自适应并输出结果的方法:
source_domain_data = load_source_domain_data() # Replace with actual data loading
target_domain_data = load_target_domain_data() # Replace with actual data loading
results = evaluate_domain_adaptation(
model, tokenizer, source_domain_data, target_domain_data
)
print(f"Source domain accuracy: {results['source_accuracy']:.2f}")
print(f"Target domain accuracy: {results['target_accuracy']:.2f}")
print(f"Adaptation drop: {results['adaptation_drop']:.2f}")
将前面的代码整合起来,我们可以使用它来评估模型在源域(它所训练的域)和目标域上的性能,计算性能下降作为领域自适应的度量。
评估任务泛化
为了评估任务泛化能力,我们可以评估模型在它没有特定微调的各种任务上的表现。以下是一个使用 GLUE 基准(我们在第十四章中讨论过)的例子:
def evaluate_task_generalization(
model_name, tasks=['mnli', 'qqp', 'qnli', 'sst2']
):
results = {}
for task in tasks:
model = \
AutoModelForSequenceClassification.from_pretrained(
model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
dataset = load_dataset('glue', task)
def tokenize_function(examples):
return tokenizer(
examples['sentence'], truncation=True, padding=True)
tokenized_datasets = dataset.map(tokenize_function,
batched=True)
training_args = TrainingArguments(
output_dir=f"./results_{task}",
evaluation_strategy="epoch",
num_train_epochs=1,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_datasets['train'],
eval_dataset=tokenized_datasets['validation'],
)
eval_results = trainer.evaluate()
results[task] = eval_results['eval_accuracy']
return results
以下是基于先前定义的函数运行评估的方法:
model_name = "bert-base-uncased" # Replace with your model
generalization_results = evaluate_task_generalization(model_name)
for task, accuracy in generalization_results.items():
print(f"{task} accuracy: {accuracy:.2f}")
将前面的代码整合起来,我们可以评估模型在多个 GLUE 任务上的表现,以评估其跨不同 NLP 任务的泛化能力。
持续学习评估
持续学习是指模型在不会忘记先前学习的内容的情况下学习新任务的能力。以下是如何在 LLMs 中评估持续学习的例子:
-
通过初始化模型、分词器和主要函数结构来设置我们的持续学习框架:
def evaluate_continual_learning( model_name, tasks=['sst2', 'qnli', 'qqp'], num_epochs=3 ): model = \ AutoModelForSequenceClassification.from_ pretrained(model_name) tokenizer = AutoTokenizer.from_pretrained(model_name) results = {} -
定义预处理函数,以处理各种 GLUE 任务的不同输入格式:
def preprocess_function(examples, task): # Different tasks have different input formats if task == 'qqp': texts = (examples['question1'], examples['question2']) elif task == 'qnli': texts = (examples['question'], examples['sentence']) else: # sst2 texts = (examples['sentence'], None) tokenized = tokenizer(*texts, padding=True, truncation=True) tokenized['labels'] = examples['label'] return tokenized -
预处理并准备每个任务的训练数据集:
for task in tasks: dataset = load_dataset('glue', task) tokenized_dataset = dataset.map( lambda x: preprocess_function(x, task), batched=True, remove_columns=dataset['train'].column_names ) model.config.num_labels = 3 if task == 'mnli' else 2 -
提供每个任务的训练设置和执行:
trainer = Trainer( model=model, args=TrainingArguments( output_dir=f"./results_{task}", num_train_epochs=num_epochs, learning_rate=2e-5, per_device_train_batch_size=16, per_device_eval_batch_size=16, evaluation_strategy="epoch" ), train_dataset=tokenized_dataset['train'], eval_dataset=tokenized_dataset['validation'] ) trainer.train() -
在所有先前看到的任务上执行评估:
task_results = {} for eval_task in tasks[:tasks.index(task)+1]: eval_dataset = load_dataset('glue', eval_task)['validation'] eval_tokenized = eval_dataset.map( lambda x: preprocess_function(x, eval_task), batched=True, remove_columns=eval_dataset.column_names ) eval_results = trainer.evaluate(eval_dataset=eval_tokenized) task_results[eval_task] = eval_results['eval_accuracy'] results[task] = task_results -
运行评估并显示结果:
model_name = "bert-base-uncased" # Replace with your model cl_results = evaluate_continual_learning(model_name) for task, task_results in cl_results.items(): print(f"\nAfter training on {task}:") for eval_task, accuracy in task_results.items(): print(f" {eval_task} accuracy: {accuracy:.2f}")
将前面的代码块整合起来,我们展示了如何对一系列任务进行微调,并在每次微调步骤之后评估模型在所有先前看到的任务上的性能,从而评估它保留早期任务知识的能力。
交叉验证的挑战和最佳实践
由于 LLMs 的规模和训练数据的性质,它们在交叉验证中面临独特的挑战。以下是一些关键挑战:
-
数据污染:由于 LLM 训练所依赖的互联网数据非常庞大且多样化,避免测试集与预训练数据重叠变得困难,这使得确保一个真正未见过的验证集变得很困难
-
计算成本:由于需要巨大的计算资源,传统的 k 折交叉验证方法通常不可行
-
领域偏移:当 LLM 接触到来自代表性不足或全新的领域的数据时,可能会表现出不一致的性能,这复杂了泛化能力的评估
-
提示敏感性:LLMs 的性能可能会根据提示措辞的微妙差异而有很大差异,这为验证过程增加了另一层可变性
基于这些挑战,以下是 LLM 交叉验证的一些最佳实践:
- 减轻数据污染:使用严格的数据去重方法来识别和删除预训练语料库和验证数据集之间的重叠。例如,MinHash 或 Bloom 过滤器可以在大型数据集中有效地检测近重复项。
MinHash
MinHash是一种概率技术,通过将大型集合转换为较小的、代表性的指纹(散列),快速估计两个集合的相似度,其中散列冲突的概率与原始集合之间的相似度成比例,这使得它在检测大型数据集中的近似重复内容时特别有用。
MinHashLSH基于 MinHash 和局部敏感哈希(LSH),它将相似项分组到相同的“桶”中,以实现快速查找和比较。
以下代码示例演示了使用 MinHash 和 MinHashLSH 进行数据去重,以检测数据集中的近似重复项:
from datasketch import MinHash, MinHashLSH
import numpy as np
def deduplicate_data(texts, threshold=0.8):
# Initialize LSH index for fast similarity search
lsh = MinHashLSH(threshold=threshold, num_perm=128)
unique_texts = []
for idx, text in enumerate(texts):
minhash = MinHash(num_perm=128)
for ngram in get_ngrams(text):
minhash.update(ngram.encode('utf8'))
if not lsh.query(minhash): # Check if similar text exists
lsh.insert(str(idx), minhash)
unique_texts.append(text)
return unique_texts
-
降低计算成本:使用分层抽样或单一分割验证方法(例如,训练-验证-测试)以最小化计算开销。或者,在扩展之前,在实验中使用较小的模型检查点或 LLM 的蒸馏版本。
以下代码示例展示了用于高效验证的分层抽样:
from sklearn.model_selection import StratifiedKFold from collections import defaultdict def create_efficient_splits(data, labels, n_splits=5): # Group data by domain domain_data = defaultdict(list) for text, domain in zip(data, labels): domain_data[domain].append(text) # Create stratified splits skf = StratifiedKFold(n_splits=n_splits, shuffle=True) splits = [] for train_idx, val_idx in skf.split(data, labels): splits.append((train_idx, val_idx)) return splits -
处理领域偏移:构建具有来自不同领域显式表示的验证数据集。使用具有代表性的领域特定数据微调模型,以减少在代表性不足区域中的性能差距。
此代码示例演示了通过领域特定验证处理领域偏移:
def evaluate_domain_performance(model, tokenizer, eval_data): domain_scores = defaultdict(list) for text, domain in eval_data: inputs = tokenizer(text, return_tensors='pt') with torch.no_grad(): outputs = model(inputs) score = outputs.logits.mean().item() domain_scores[domain].append(score) # Calculate domain-specific metrics return {domain: np.mean(scores) for domain, scores in domain_scores.items()} -
解决提示敏感性:系统性地进行提示工程。使用提示释义、指令调整或跨多个提示的集成评估等技术,以确保鲁棒性并最小化提示变化引入的变异性。
以下代码示例展示了使用多个变体的系统提示工程:
def evaluate_with_prompt_ensemble( model, tokenizer, text, base_prompt ): prompt_variants = [ f"{base_prompt}: {text}", f"Please {base_prompt.lower()}: {text}", f"I want you to {base_prompt.lower()}: {text}" ] responses = [] for prompt in prompt_variants: inputs = tokenizer(prompt, return_tensors='pt') with torch.no_grad(): output = model.generate(inputs, max_length=100) responses.append(tokenizer.decode(output[0])) # Aggregate responses (e.g., by voting or averaging) return aggregate_responses(responses)
以下代码示例展示了如何将这些方法组合成一个单一的评估流程:
def robust_evaluation_pipeline(model, data, domains):
# First deduplicate the data
clean_data = deduplicate_data(data)
# Create efficient splits
splits = create_efficient_splits(clean_data, domains)
# Evaluate across domains with prompt ensembles
results = defaultdict(dict)
for domain in domains:
domain_data = [d for d, dom in zip(clean_data, domains)
if dom == domain]
scores = evaluate_with_prompt_ensemble(model, tokenizer,
domain_data, "analyze")
results[domain] = scores
return results
摘要
对于 LLM 的交叉验证需要仔细考虑它们的独特特性和能力。通过实施这些高级技术和最佳实践,您可以获得对 LLM 在各个领域和任务中性能的更稳健和全面的评估。
随着我们继续前进,下一章将深入探讨 LLM 中解释性的关键主题。我们将探讨理解和解释 LLM 输出和行为的技术。
第十六章:可解释性
LLM 中的可解释性指的是模型理解并解释模型如何处理输入和生成输出的能力。
可解释性对于 LLM 的几个原因:
-
信任与透明度:理解 LLM 如何得出其输出可以建立用户和利益相关者的信任
-
调试和改进:可解释性技术可以帮助识别模型弱点并指导改进
-
伦理考量:可解释的模型允许更好地评估潜在的偏见和公平性问题
-
合规性监管:在某些领域,可解释的人工智能模型可能需要满足监管合规性要求
在本章中,我们将探讨理解和解释 LLM 输出和行为的高级技术。我们将讨论如何将这些技术应用于基于 Transformer 的 LLM,并检查模型性能与可解释性之间的权衡。
在本章中,我们将讨论以下主题:
-
注意力可视化技术
-
探测方法
-
使用归因方法解释 LLM 的预测
-
基于 Transformer 的 LLM 的可解释性
-
机制可解释性
-
可解释性与性能之间的权衡
注意力可视化技术
注意力机制是基于 Transformer 的 LLM 的关键组成部分(参见第一章)。可视化注意力模式可以提供模型如何处理和关注输入的不同部分的见解。
下面是一个如何在基于 Transformer 的模型中可视化注意力的示例:
import torch
from transformers import BertTokenizer, BertModel
import matplotlib.pyplot as plt
import seaborn as sns
def visualize_attention(model, tokenizer, text):
inputs = tokenizer(text, return_tensors="pt")
outputs = model(inputs, output_attentions=True)
attention = outputs.attentions[-1].squeeze().detach().numpy()
tokens = tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
plt.figure(figsize=(10, 8))
sns.heatmap(attention, xticklabels=tokens,
yticklabels=tokens, cmap="YlGnBu")
plt.title("Attention Visualization")
plt.show()
# Example usage
model_name = "bert-base-uncased"
model = BertModel.from_pretrained(model_name)
tokenizer = BertTokenizer.from_pretrained(model_name)
text = "The cat sat on the mat."
visualize_attention(model, tokenizer, text)
此代码提供了一个简单的方法来可视化 BERT 模型在处理给定输入句子时的注意力机制。它首先导入必要的库:PyTorch 用于模型处理,Hugging Face 的transformers库用于加载 BERT 模型和分词器,以及 Matplotlib 和 Seaborn 用于可视化。visualize_attention函数接受一个 BERT 模型、分词器和输入文本。它首先使用分词器对输入进行分词,然后将分词后的输入通过output_attentions=True传递给模型以检索注意力权重。从返回的输出中,它提取最后一层的注意力矩阵(即outputs.attentions[-1]),将其从计算图中分离出来,并将其转换为 NumPy 数组。这个矩阵表示序列中每个标记对其他每个标记的关注程度。然后将标记 ID 转换回可读的标记,用于标记热图的轴。使用 Seaborn 的heatmap,注意力分数被可视化为一个彩色编码的矩阵,这使得解释模型在处理每个标记时关注哪些单词变得更加容易。最后,代码加载预训练的 BERT 基础模型和分词器,定义一个示例句子,并调用可视化函数以显示注意力图,从而提供对 BERT 内部工作的见解。
请记住,在 LLMs 中,注意力图并不总是与模型推理相关。虽然它们显示了模型关注的区域,但它们并不一定解释了为什么做出某个决定。注意力可能分散、不一致或误导,有时会突出无关的标记,同时仍然产生正确的输出。由于 LLMs 以分布式表示编码信息,推理通常发生在直接注意力之外,涉及跨层的深层潜在变换。研究还表明,注意力图可以在不改变模型行为的情况下被操纵,这证明了它们不是推理的最终解释。为了更好的可解释性,它们应该与基于梯度的方法、探查技术和因果分析相结合。
探查方法
探查涉及在 LLM 的内部表示上训练简单模型,以评估在不同层中捕获了哪些语言属性。
变换器中的不同层专门处理不同的语言属性。底层捕获句法和标记身份;中间层处理语法和句子结构;高层专注于语义、推理和事实回忆。这种层次结构在训练过程中自然出现,底层在句法任务上表现出色,而高层在语义推理上表现出色。探查研究证实了这种专业化,有助于可解释性、微调和模型压缩以进行特定任务的优化。
这里有一个如何实现探查任务的例子:
import torch
from transformers import BertTokenizer, BertModel
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
def probe_bert_layers(model, tokenizer, texts, labels, layer_nums):
# Get BERT embeddings for each layer
def get_embeddings(text):
inputs = tokenizer(text, return_tensors="pt",
padding=True, truncation=True)
with torch.no_grad():
outputs = model(inputs, output_hidden_states=True)
return outputs.hidden_states
results = {}
for layer in layer_nums:
embeddings = [
get_embeddings(text)[layer]
.squeeze()
.mean(dim=0)
.numpy() for text in texts
]
# Split data
X_train, X_test, y_train, y_test = train_test_split(
embeddings, labels, test_size=0.2, random_state=42
)
# Train and evaluate probe
probe = LogisticRegression(random_state=42)
probe.fit(X_train, y_train)
y_pred = probe.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
results[f"Layer_{layer}"] = accuracy
return results
# Example usage
model_name = "bert-base-uncased"
model = BertModel.from_pretrained(model_name)
tokenizer = BertTokenizer.from_pretrained(model_name)
texts = ["The cat sat on the mat.", "The dog chased the ball.", ...] # Add more examples
labels = [0, 1, ...] # Corresponding labels (e.g., 0 for simple, 1 for complex sentences)
layer_nums = [1, 6, 12] # Layers to probe
probe_results = probe_bert_layers(model, tokenizer, texts, labels,
layer_nums)
for layer, accuracy in probe_results.items():
print(f"{layer} Accuracy: {accuracy:.2f}")
这段代码实现了一个简单的探查任务,以评估 BERT 模型的不同层如何捕捉特定的语言属性(在这种情况下,句子复杂性)。
使用归因方法解释 LLM 预测
归因方法旨在确定哪些输入特征对模型的预测贡献最大。
我们需要讨论归因方法,因为理解模型为何产生特定预测对于现实应用中的可解释性和可靠性至关重要。归因方法提供了一种系统的方式来追踪特定输入标记对模型输出的影响,这在 LLMs(大型语言模型)中尤为重要,因为预测通常来自复杂、高维的标记嵌入以及多个注意力层之间的非线性交互。没有归因,用户和开发者将面临一个黑盒模型,该模型产生输出而不提供任何透明的理由,这使得验证决策、调试行为或确保与预期用例一致变得困难。
一种流行的归因方法是集成梯度。
集成梯度是一种归因方法,用于通过量化每个输入特征对模型输出的贡献来解释神经网络的预测。它通过沿从基线到实际输入的直线路径,将模型输出的梯度与输入进行积分来计算特征归因。
请记住,LLM 中的基于梯度的方法可能会因为对输入扰动的敏感性、小批量方差和梯度饱和而变得嘈杂,这会影响训练稳定性和可解释性。在优化中,噪声可能导致振荡或次优收敛,而在可解释性中,如集成梯度等方法可能在不同的运行中产生不一致的归因。这种不稳定性降低了模型洞察力的可信度,特别是对于相似输入。梯度平滑、平均和二阶优化等技术有助于减轻噪声,但会增加计算开销,在 LLM 开发中在效率和精度之间形成权衡。
下面是一个如何实现基于 Transformer 模型的集成梯度的示例:
import torch
from transformers import BertTokenizer, BertForSequenceClassification
import numpy as np
import matplotlib.pyplot as plt
def integrated_gradients(
model, tokenizer, text, target_class, steps=50
):
input_ids = tokenizer.encode(text, return_tensors="pt")
baseline_ids = torch.zeros_like(input_ids)
alphas = torch.linspace(0, 1, steps)
delta = input_ids - baseline_ids
accumulated_grads = 0
for alpha in alphas:
interpolated_ids = baseline_ids + alpha * delta
interpolated_ids.requires_grad_()
outputs = model(interpolated_ids)
pred = outputs.logits[:, target_class]
model.zero_grad()
pred.backward()
accumulated_grads += interpolated_ids.grad
attributions = \
(input_ids - baseline_ids) * accumulated_grads / steps
return attributions.squeeze().detach().numpy()
# Example usage
model_name = "bert-base-uncased"
model = BertForSequenceClassification.from_pretrained(model_name)
tokenizer = BertTokenizer.from_pretrained(model_name)
text = "This movie was fantastic!"
target_class = 1 # Assuming 1 is the positive sentiment class
attributions = integrated_gradients(model, tokenizer, text,
target_class)
# Visualize attributions
tokens = tokenizer.convert_ids_to_tokens(tokenizer.encode(text))
plt.figure(figsize=(10, 5))
plt.bar(range(len(tokens)), attributions)
plt.xticks(range(len(tokens)), tokens, rotation=45)
plt.title("Integrated Gradients Attribution")
plt.show()
这段代码演示了如何使用集成梯度方法通过将模型的预测归因于单个输入标记来解释基于 BERT 的序列分类模型。integrated_gradients函数首先使用分词器将输入文本编码为标记 ID,并创建一个形状相同的基线输入,其中填充了零。然后,它在基线和实际输入之间进行小步插值(默认为50),以计算沿此路径的梯度。对于每个插值输入,它计算模型针对指定目标类的输出,执行反向传播以获取关于输入的梯度,并累积这些梯度。最后,它计算这些梯度的平均值,并将其乘以输入差异(输入 – 基线)以获得归因——这量化了每个输入标记对预测的贡献。在定义模型和分词器后,代码在示例文本上运行归因方法,并将结果以条形图的形式显示出来,其中每个条形对应于一个标记及其对目标预测的重要性。这种技术提供了一种更原则性和模型感知的方式来理解输入的哪些部分最具影响力,使其成为可解释性和对模型预测信任的有力工具。
基于 Transformer 的 LLM 的可解释性
基于 Transformer 的 LLM 在可解释性方面面临着独特的挑战和机遇。以下是一些需要考虑的关键领域:
-
多头注意力: 分析单个注意力头以揭示专用功能
-
位置嵌入: 理解模型如何使用位置信息
-
层分析: 检查不同语言特征如何在各层中被捕捉
下面是一个分析多头注意力的示例:
import torch
from transformers import BertTokenizer, BertModel
import matplotlib.pyplot as plt
def analyze_multihead_attention(model, tokenizer, text):
inputs = tokenizer(text, return_tensors="pt")
outputs = model(inputs, output_attentions=True)
attention = outputs.attentions[-1].squeeze().detach().numpy()
tokens = tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
num_heads = attention.shape[0]
fig, axs = plt.subplots(2, 4, figsize=(20, 10))
axs = axs.ravel()
for i in range(num_heads):
sns.heatmap(attention[i], xticklabels=tokens,
yticklabels=tokens, ax=axs[i], cmap="YlGnBu")
axs[i].set_title(f"Head {i+1}")
plt.tight_layout()
plt.show()
# Example usage
model_name = "bert-base-uncased"
model = BertModel.from_pretrained(model_name)
tokenizer = BertTokenizer.from_pretrained(model_name)
text = "The president of the United States visited Paris last week."
analyze_multihead_attention(model, tokenizer, text)
这段代码可视化了 BERT 模型最后一层不同头的注意力模式,允许比较它们的专用功能。
机制可解释性
机制可解释性(MI)是一个新兴领域,旨在从详细、组件级别理解神经网络如何处理信息——类似于我们可能如何逆向工程一个机械装置。MI 不仅仅观察输入和输出,而是试图追踪信息在网络中的流动,识别特定的计算模式,并理解网络的各个部分(如单个神经元或注意力头)如何贡献于模型的行为。
MI 之所以重要,是因为它超越了表面解释,揭示了神经网络,尤其是像 LLM 这样的复杂模型,实际工作的内部机制。通过分析特定组件(如神经元、层或注意力头)如何处理和转换信息,MI 帮助研究人员建立对模型行为的更深入、更原则性的理解。这种洞察力对于几个原因至关重要:它通过使模型更透明来增强信任;它使精确调试和有针对性的改进成为可能;它有助于发现和减轻隐藏的偏差或漏洞;它支持开发更安全、更可控的 AI 系统。最终,MI 使我们更接近于将神经网络视为不是黑盒,而是可以更有信心地分析、解释和改进的可理解系统。
让我们一步步构建:
-
首先,让我们创建一个简单的可解释模型结构:
import torch import torch.nn as nn class InterpretableTransformer(nn.Module): def __init__(self, vocab_size, d_model, nhead, num_layers): super().__init__() self.embedding = nn.Embedding(vocab_size, d_model) encoder_layer = nn.TransformerEncoderLayer( d_model, nhead, batch_first=True ) self.transformer = nn.TransformerEncoder(encoder_layer, num_layers) self.fc = nn.Linear(d_model, vocab_size) -
现在,让我们添加一个提取注意力模式的方法,这对于理解模型如何处理标记之间的关系至关重要:
def get_attention_patterns(self, x): """Extract attention weights from each layer""" x = self.embedding(x) attention_patterns = [] for layer in self.transformer.layers: # Register a hook to capture attention weights attention_weights = None def hook(module, input, output): nonlocal attention_weights attention_weights = output[1] # attention weights handle = layer.self_attn.register_forward_hook(hook) x = layer(x) attention_patterns.append(attention_weights) handle.remove() return attention_patterns -
让我们添加一个神经元激活分析来了解哪些神经元对于特定输入最为活跃:
def analyze_neuron_activations(self, x, layer_idx): """Analyze individual neuron activations in a specific layer""" activations = [] def hook(module, input, output): activations.append(output.detach()) # Register hook on specific layer handle = list(self.transformer.layers)[layer_idx]\ .register_forward_hook(hook) # Forward pass with torch.no_grad(): self(x) handle.remove() layer_activations = activations[0] # Find most active neurons mean_activation = layer_activations.mean(dim=(0,1)) # Average across batch and sequence top_neurons = torch.topk(mean_activation, k=10) return top_neurons.indices, top_neurons.values -
我们可以添加一种因果干预的方法——暂时修改特定的神经元,看看它如何影响输出:
def intervention_study(self, x, layer_idx, neuron_idx): """Study how zeroing out specific neurons affects the output""" original_output = None modified_output = None def hook_original(module, input, output): nonlocal original_output original_output = output.detach() def hook_modified(module, input, output): nonlocal modified_output modified = output.clone() modified[:,:,neuron_idx] = 0 # Zero out specific neuron modified_output = modified return modified layer = list(self.transformer.layers)[layer_idx] # Get original output handle = layer.register_forward_hook(hook_original) self(x) handle.remove() # Get modified output handle = layer.register_forward_hook(hook_modified) self(x) handle.remove() return original_output, modified_output -
最后,让我们添加一个可视化辅助工具:
import matplotlib.pyplot as plt def visualize_attention(attention_weights, tokens=None): """Visualize attention patterns""" plt.figure(figsize=(10, 8)) plt.imshow(attention_weights[0].cpu(), cmap='viridis') if tokens is not None: plt.xticks(range(len(tokens)), tokens, rotation=45) plt.yticks(range(len(tokens)), tokens) plt.colorbar() plt.title('Attention Pattern') plt.show()
这是如何使用这些工具一起使用的方法:
# Initialize model
model = InterpretableTransformer(vocab_size=1000,
d_model=256, nhead=8, num_layers=4)
# Sample input
input_ids = torch.randint(0, 1000, (1, 20)) # Batch size 1, sequence length 20
# Get attention patterns
attention_patterns = model.get_attention_patterns(input_ids)
# Analyze neuron activations
top_neurons, activation_values = model.analyze_neuron_activations(
input_ids, layer_idx=0
)
# Perform intervention study
original, modified = model.intervention_study(input_ids,
layer_idx=0, neuron_idx=42)
# Visualize attention
visualize_attention(attention_patterns[0]) # Visualize first layer's attention
每个组件帮助我们理解模型的不同方面:
-
注意力模式显示了模型如何将不同的标记相互关联
-
神经元激活分析揭示了哪些神经元对于处理特定输入最为重要
-
因果干预通过观察当我们修改它们时输出如何变化,帮助我们理解特定神经元的作用
-
可视化工具帮助我们更直观地解释这些模式
这是一个基本的实现——真正的 MI 研究通常涉及更复杂的技术,如电路分析、激活修补以及如何在网络中实现特定能力(如归纳或否定)的详细研究。
可解释性和性能之间的权衡
模型性能和可解释性之间往往存在紧张关系。更复杂的模型往往表现更好,但更难解释。以下是一些平衡这种权衡的方法:
-
蒸馏:训练更小、更可解释的模型来模仿更大的 LLM
-
稀疏模型:鼓励模型权重或激活的稀疏性以更容易进行解释
-
模块化架构:设计具有可解释组件的模型
这里有一个模型蒸馏的简单示例:
import torch
from transformers import (
BertForSequenceClassification,
DistilBertForSequenceClassification,
BertTokenizer)
def distill_bert(
teacher_model, student_model, tokenizer, texts, temperature=2.0
):
teacher_model.eval()
student_model.train()
optimizer = torch.optim.Adam(student_model.parameters(), lr=1e-4)
loss_fn = torch.nn.KLDivLoss(reduction="batchmean")
for text in texts:
inputs = tokenizer(
text, return_tensors="pt", padding=True, truncation=True
)
with torch.no_grad():
teacher_outputs = teacher_model(inputs)
teacher_logits = teacher_outputs.logits / temperature
student_outputs = student_model(inputs)
student_logits = student_outputs.logits / temperature
loss = loss_fn(torch.log_softmax(student_logits, dim=-1),
torch.softmax(teacher_logits, dim=-1))
optimizer.zero_grad()
loss.backward()
optimizer.step()
return student_model
# Example usage
teacher_model = BertForSequenceClassification.from_pretrained(
"bert-base-uncased")
student_model = DistilBertForSequenceClassification.from_pretrained(
"distilbert-base-uncased"
)
tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
texts = ["This movie was great!", "I didn't like the book.", ...] # Add more examples
distilled_model = distill_bert(
teacher_model, student_model, tokenizer, texts
)
此代码演示了一个简单的蒸馏过程,其中较小的 DistilBERT 模型学习模仿较大的 BERT 模型的行为。
此外,我们还需要牢记压缩和可解释性之间的权衡,这涉及到在效率、准确性和透明度之间取得平衡。量化、剪枝和知识蒸馏等压缩技术显著减少了模型大小和推理延迟,使得 LLMs 能够在边缘设备上运行或以更低的计算成本运行。然而,这些方法可能会降低性能,尤其是在长上下文推理、罕见标记预测或特定领域任务中,在这些任务中保留复杂的权重结构至关重要。此外,高度压缩的模型通常变得不太可解释,因为移除神经元或注意力头或降低精度会掩盖模型的内部表示,使得分析为什么产生某些输出变得更加困难。
相反,可解释性技术,如特征归因、注意力可视化和探针,帮助研究人员和用户了解 LLMs 如何处理信息、检测偏差或调试故障,但它们通常需要访问完整且未修改的模型。较大的、未压缩的模型保留了更多的内部知识和细微的表示,这使得它们更容易分析但更难高效部署。此外,高度可解释的架构有时会对模型灵活性施加约束,限制它们在多样化任务中泛化的能力。
关键挑战是找到最佳平衡——例如,低秩自适应(LoRA)允许在不修改完整模型权重的情况下进行微调,有助于保持某些可解释性同时实现高效部署。随着 LLMs 的扩展,开发者必须权衡压缩带来的效率提升与降低透明度的风险,尤其是在医疗保健、法律和 AI 安全等高风险应用中,理解模型决策与性能一样关键。
摘要
在本章中,我们为您提供了可解释性技术工具包,以深入了解您的 LLMs 的决策过程,这对于开发更透明和值得信赖的 AI 系统至关重要。
随着 LLMs 在规模和能力上的持续增长,可解释性研究将在确保这些强大的模型可理解、可信赖且安全地部署在实际应用中发挥关键作用。可解释性中的关键挑战和未来方向包括为大型模型扩展这些技术、理解因果关系、实现交互式探索以及开发针对特定下游任务的技术。
在下一章中,我们将探讨评估和减轻大型语言模型(LLMs)中公平性和偏差的技术。这是负责任的人工智能开发的关键方面,基于我们讨论的解释方法,以确保 LLMs 不仅强大且可解释,而且在输出和决策过程中公平且无偏见。
第十七章:公平性与偏见检测
在 LLM 中,公平性涉及确保模型的结果和决策不会基于受保护属性(如种族、性别、年龄或宗教)歧视或不公平对待个人或群体。这是一个复杂的概念,而不仅仅是避免显性偏见。
机器学习中公平性的定义有几个:
-
人口统计学平等性:所有群体获得积极结果的概率应该是相同的
-
平等机会:所有群体的真正阳性率应该是相同的
-
均衡机会:所有群体的真正阳性和假阳性率应该是相同的
对于大型语言模型(LLM),公平性通常涉及确保模型的语言生成和理解能力在不同人口群体之间是公平的,并且不会持续或放大社会偏见。
在本章中,你将了解 LLM 中可能出现的不同类型的偏见以及检测它们的技巧。
在本章中,我们将讨论以下主题:
-
偏见的类型
-
LLM 文本生成和理解公平性指标
-
检测偏见
-
去偏见策略
-
公平性意识训练
-
伦理考量
偏见的类型
LLM 可以表现出各种类型的偏见:
-
代表性偏见:在训练数据中对某些群体的代表性不足或错误表示——例如,主要在浅色皮肤的面部上进行训练的面部识别系统在识别深色皮肤色调的人时可能表现出显著更高的错误率,这是由于训练集中代表性不足。
-
语言偏见:人工智能系统用于描述不同群体的语言——例如,一个 AI 系统可能会将男性标记为“自信”而将女性标记为“在性别间具有攻击性”,当描述相同的行为时,这会强化细微的歧视模式。
-
分配偏见:基于模型预测的资源或机会的不公平分配,如自动化招聘系统系统性地将某些大学的候选人排名更高,而不管他们的资格如何,从而不成比例地将面试机会分配给这些机构的毕业生。
-
服务质量偏见:模型在不同群体之间的性能差异,如一个机器翻译系统为英语、西班牙语和普通话等主流语言提供更准确的翻译,而为使用人数较少或训练数据中代表性较低的语言提供较低质量的翻译。
-
刻板印象偏见:通过语言生成强化社会刻板印象,例如当人工智能写作助手在完成关于不同背景角色的故事时自动建议刻板印象的职业道路——为某些种族背景的角色建议体育或娱乐职业,而为其他人建议医生或律师等职业。
-
显性和隐性偏见: LLM 中的显性偏见源于训练数据中的明显模式,例如文本来源中存在的刻板印象,导致输出中明显可识别的偏见。另一方面,隐性偏见更为微妙,源于数据中的潜在统计相关性,以可能加强隐藏偏见的方式塑造响应,而没有直接意图。虽然显性偏见通常可以通过过滤或微调来检测和缓解,但隐性偏见更难识别,需要更深入的措施,例如偏见感知训练技术和对模型输出的定期审计。
-
隐藏偏见: 当训练数据、模型设计或部署选择微妙地扭曲响应,加强刻板印象或排除观点时,LLM 中的隐藏偏见就会出现。这可以表现为性别语言、文化偏好或政治倾向,通常是由于训练数据中过度代表的观点。算法处理可以进一步放大这些偏见,使响应根据提示语句不一致或偏斜。为了缓解这种情况,需要多样化的数据集、偏见审计和道德微调,确保模型在道德约束内生成平衡和公平的输出,同时允许用户在道德约束内进行感知调整。
这里有一个检查数据集中表示偏见的例子(我们将只展示一个例子以限制本章的篇幅):
import pandas as pd
from collections import Counter
def analyze_representation(texts, attribute_list):
attribute_counts = Counter()
for text in texts:
for attribute in attribute_list:
if attribute.lower() in text.lower():
attribute_counts[attribute] += 1
total = sum(attribute_counts.values())
percentages = {attr: count/total*100
for attr, count in attribute_counts.items()}
return pd.DataFrame({
'Attribute': percentages.keys(),
'Percentage': percentages.values()
}).sort_values('Percentage', ascending=False)
# Example usage
texts = [
"The CEO announced a new policy.",
"The nurse took care of the patient.",
"The engineer designed the bridge.",
# ... more texts
]
gender_attributes = ['he', 'she', 'his', 'her', 'him', 'her']
representation_analysis = analyze_representation(
texts, gender_attributes
)
print(representation_analysis)
这段代码分析了一个文本语料库中性别相关术语的表示,这有助于识别数据集中潜在的性别偏见。
LLM 文本生成和理解中的公平性指标
公平性指标通常关注比较不同人口群体之间的模型性能或输出。
这里有一些例子:
-
文本分类中的人口统计学差异: 该指标衡量了最被青睐的群体和最不受青睐的群体之间正预测率的差异:
from sklearn.metrics import confusion_matrix import numpy as np def demographic_parity_difference( y_true, y_pred, protected_attribute ): groups = np.unique(protected_attribute) dps = [] for group in groups: mask = protected_attribute == group cm = confusion_matrix(y_true[mask], y_pred[mask]) dp = (cm[1, 0] + cm[1, 1]) / cm.sum() dps.append(dp) return max(dps) - min(dps) # Example usage y_true = [0, 1, 1, 0, 1, 0, 1, 1] y_pred = [0, 1, 0, 0, 1, 1, 1, 1] protected_attribute = ['A', 'A', 'B', 'B', 'A', 'B', 'A', 'B'] dpd = demographic_parity_difference( y_true, y_pred, protected_attribute ) print(f"Demographic Parity Difference: {dpd}")该代码定义了一个
demographic_parity_difference函数,该函数计算由受保护属性定义的群体之间的人口统计学差异。它接受真实标签 (y_true)、预测标签 (y_pred) 和受保护属性值作为输入。对于受保护属性中的每个唯一群体,它创建一个布尔掩码来隔离相应的预测子集,并计算该组的混淆矩阵。然后,每个群体的人口统计学差异(DP)被计算为该组所有预测中正预测(无论是误分类还是正确分类)的比例,具体使用(cm[1, 0] + cm[1, 1]) / cm.sum(),这对应于实际正数(无论是误分类还是正确分类)的数量除以总数。它存储这些 DP 值,并最终返回它们之间的最大差异,这表明了跨组之间的待遇差异。示例使用虚拟数据演示了这一点,打印出'A'和'B'组之间的 DP 差异。 -
文本分类的平等机会差异:此指标衡量最被青睐的群体和最不受青睐的群体之间真正阳性率的差异:
def equal_opportunity_difference( y_true, y_pred, protected_attribute ): groups = np.unique(protected_attribute) tprs = [] for group in groups: mask = (protected_attribute == group) & (y_true == 1) tpr = np.mean(y_pred[mask] == y_true[mask]) tprs.append(tpr) return max(tprs) - min(tprs) # Example usage eod = equal_opportunity_difference(y_true, y_pred, protected_attribute) print(f"Equal Opportunity Difference: {eod}")此代码计算由受保护属性定义的群体之间的真正阳性率差异,衡量模型在那些群体中正确识别阳性案例的平等程度。
现在我们已经探索了几种衡量模型输出和推理能力的公平性指标,接下来我们将继续学习实际检测偏差的技术,基于这些指标来开发系统性的测试方法。
检测偏差
在大型语言模型(LLMs)中检测偏差通常涉及分析不同人口群体或不同类型输入下的模型输出。以下是一些技术:
-
词嵌入:此代码通过比较职业词汇在性别方向上的投影来衡量词嵌入中的性别偏差:
from gensim.models import KeyedVectors import numpy as np def word_embedding_bias( model, male_words, female_words, profession_words ): male_vectors = [model[word] for word in male_words if word in model.key_to_index] female_vectors = [model[word] for word in female_words if word in model.key_to_index] male_center = np.mean(male_vectors, axis=0) female_center = np.mean(female_vectors, axis=0) gender_direction = male_center - female_center biases = [] for profession in profession_words: if profession in model.key_to_index: bias = np.dot(model[profession], gender_direction) biases.append((profession, bias)) return sorted(biases, key=lambda x: x[1], reverse=True) # Example usage model = KeyedVectors.load_word2vec_format( 'path_to_your_embeddings.bin', binary=True ) male_words = ['he', 'man', 'boy', 'male', 'gentleman'] female_words = ['she', 'woman', 'girl', 'female', 'lady'] profession_words = ['doctor', 'nurse', 'engineer', 'teacher', 'CEO'] biases = word_embedding_bias( model, male_words, female_words, profession_words ) for profession, bias in biases: print(f"{profession}: {bias:.4f}")此代码通过首先为男性和女性术语创建平均向量,计算它们之间的性别方向向量,然后通过点积计算来衡量不同职业词汇与该性别轴的接近程度,从而衡量词嵌入中的性别偏差。该函数按偏差分数对职业进行排序,其中正值表示男性关联,负值表示女性关联,使用户能够量化嵌入在语言模型中的性别刻板印象。
-
情感分析:您可以分析不同群体中的情感以检测潜在的偏差:
from transformers import pipeline def analyze_sentiment_bias( texts, groups, model_name="distilbert-base-uncased-finetuned-sst-2-english"): sentiment_analyzer = pipeline( "sentiment-analysis", model=model_name ) results = {group: {'positive': 0, 'negative': 0} for group in set(groups)} for text, group in zip(texts, groups): sentiment = sentiment_analyzer(text)[0] results[group][sentiment['label'].lower()] += 1 for group in results: total = results[group]['positive'] \ + results[group]['negative'] results[group]['positive_ratio'] = \ results[group]['positive'] / total return results # Example usage texts = [ "The man is very intelligent.", "The woman is very intelligent.", "The man is a great leader.", "The woman is a great leader.", ] groups = ['male', 'female', 'male', 'female'] bias_results = analyze_sentiment_bias(texts, groups) print(bias_results)此代码通过使用来自
transformers库的预训练情感分析模型,分析不同人口群体中的情感偏差。它接受一个文本列表及其相应的群体标签,通过情感分析器处理每个文本,并计算每个群体的正面和负面情感计数。然后,该函数为每个群体计算“正面比率”(被分类为正面的文本比例),允许比较不同群体之间的情感分布。在示例中,它特别通过分析关于智力和领导力的相同陈述在归因于男性还是女性时如何被分类来检查潜在的性别偏差,这可能揭示底层语言模型是否根据性别关联对相同品质进行不同的处理。 -
指代消解:您可以分析指代消解来检测潜在的职业-性别偏差:
import spacy def analyze_coreference_bias(texts, occupations, genders): nlp = spacy.load("en_core_web_sm") results = {gender: {occ: 0 for occ in occupations} for gender in genders} counts = {gender: 0 for gender in genders} for text in texts: doc = nlp(text) occupation = None gender = None for token in doc: if token.text.lower() in occupations: occupation = token.text.lower() if token.text.lower() in genders: gender = token.text.lower() if occupation and gender: results[gender][occupation] += 1 counts[gender] += 1 for gender in results: for occ in results[gender]: results[gender][occ] /= counts[gender] return results # Example usage texts = [ "The doctor examined her patient. She prescribed some medication.", "The nurse took care of his patients. He worked a long shift.", # ... more texts ] occupations = ['doctor', 'nurse', 'engineer', 'teacher'] genders = ['he', 'she'] bias_results = analyze_coreference_bias(texts, occupations, genders) print(bias_results)代码定义了一个
analyze_coreference_bias函数,该函数使用 spaCy 的 NLP 管道通过分析特定性别化代词(如“他”和“她”)与某些职业(例如,“医生”,“护士”)共现的频率来评估文本中的潜在性别偏见。它初始化一个 spaCy 语言模型,创建一个嵌套字典来计算每个性别-职业对的频率,以及每个性别的单独计数。对于每个输入文本,它将内容分词,确定是否有任何预定义的职业和性别化代词出现,如果两者都存在,则增加相关计数器。处理完所有文本后,它通过性别提及的总数对每个性别的职业计数进行归一化,从而有效地得到一个反映给定数据集中每个职业与每个性别相对关联比例的结果。该函数返回这个归一化结果,然后在示例用法中打印出来。
接下来,我们将基于这种检测知识来探讨减少偏差的实际策略,帮助我们从诊断转向治疗。
去除偏差策略
去除偏差的 LLM 是一个活跃的研究领域。以下是一些策略:
-
数据增强(见第三章):在以下代码中,我们通过交换性别化的词汇来增强数据集,帮助平衡性别代表性:
import random def augment_data(texts, male_words, female_words): augmented_texts = [] for text in texts: words = text.split() for i, word in enumerate(words): if word.lower() in male_words: female_equivalent = female_words[ male_words.index(word.lower()) ] new_text = ' '.join(words[:i] + [female_equivalent] + words[i+1:]) augmented_texts.append(new_text) elif word.lower() in female_words: male_equivalent = male_words[ female_words.index(word.lower()) ] new_text = ' '.join(words[:i] + [male_equivalent] + words[i+1:]) augmented_texts.append(new_text) return texts + augmented_texts # Example usage texts = [ "The doctor examined his patient.", "The nurse took care of her patients.", ] male_words = ['he', 'his', 'him'] female_words = ['she', 'her', 'her'] augmented_texts = augment_data(texts, male_words, female_words) print(augmented_texts) -
偏差微调:在以下代码中,我们微调一个语言模型,用更中性的替代词替换有偏见的词汇:
from transformers import ( AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer) import torch def create_debiasing_dataset(biased_words, neutral_words): inputs = [f"The {biased} person" for biased in biased_words] targets = [f"The {neutral} person" for neutral in neutral_words] return inputs, targets def fine_tune_for_debiasing( model, tokenizer, inputs, targets, epochs=3 ): input_encodings = tokenizer(inputs, truncation=True, padding=True) target_encodings = tokenizer(targets, truncation=True, padding=True) dataset = torch.utils.data.TensorDataset( torch.tensor(input_encodings['input_ids']), torch.tensor(input_encodings['attention_mask']), torch.tensor(target_encodings['input_ids']) ) training_args = TrainingArguments( output_dir='./results', num_train_epochs=epochs, per_device_train_batch_size=8, warmup_steps=500, weight_decay=0.01, logging_dir='./logs', ) trainer = Trainer( model=model, args=training_args, train_dataset=dataset, ) trainer.train() return model # Example usage model_name = "gpt2" model = AutoModelForCausalLM.from_pretrained(model_name) tokenizer = AutoTokenizer.from_pretrained(model_name) biased_words = ['bossy', 'emotional', 'hysterical'] neutral_words = ['assertive', 'passionate', 'intense'] inputs, targets = create_debiasing_dataset( biased_words, neutral_words ) debiased_model = fine_tune_for_debiasing( model, tokenizer, inputs, targets )
公平性感知训练
机器学习中的公平性约束是数学公式,通过确保模型预测在不同人口群体中保持所需的统计特性,来量化并强制执行特定的公平性概念。这些约束通常表达条件,如人口比例(组间相同的正面预测率)、均衡机会(相同的真正率和假正率)或个人公平(相似的个人收到相似的预测)。它们可以直接作为正则化项纳入模型优化,或作为后处理步骤强制执行。通过明确建模这些约束,开发者可以减轻算法偏差,并确保在受保护属性(如种族、性别或年龄)方面有更公平的结果——在准确性的传统目标与预测系统对不同群体影响的相关伦理考量之间取得平衡。
将公平性约束直接纳入训练过程可以帮助产生更公平的模型。以下是一个简化的例子:
import torch
import torch.nn as nn
import torch.optim as optim
class FairClassifier(nn.Module):
def __init__(self, input_size, hidden_size, num_classes):
super(FairClassifier, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.fc2 = nn.Linear(hidden_size, num_classes)
def forward(self, x):
x = torch.relu(self.fc1(x))
return self.fc2(x)
def fair_loss(
outputs, targets, protected_attributes, lambda_fairness=0.1
):
criterion = nn.CrossEntropyLoss()
task_loss = criterion(outputs, targets)
# Demographic parity
group_0_pred = outputs[protected_attributes == 0].mean(dim=0)
group_1_pred = outputs[protected_attributes == 1].mean(dim=0)
fairness_loss = torch.norm(group_0_pred - group_1_pred, p=1)
return task_loss + lambda_fairness * fairness_loss
def train_fair_model(
model, train_loader, epochs=10, lr=0.001,
lambda_fairness=0.1
):
optimizer = optim.Adam(model.parameters(), lr=lr)
for epoch in range(epochs):
for inputs, targets, protected_attributes in train_loader:
optimizer.zero_grad()
outputs = model(inputs)
loss = fair_loss(
outputs, targets,
protected_attributes, lambda_fairness
)
loss.backward()
optimizer.step()
print(f'Epoch {epoch+1}/{epochs}, Loss: {loss.item():.4f}')
return model
# Example usage (assuming you have prepared your data)
input_size = 10
hidden_size = 50
num_classes = 2
model = FairClassifier(input_size, hidden_size, num_classes)
train_loader = ... # Your DataLoader here
fair_model = train_fair_model(model, train_loader)
此代码实现了一个神经网络分类器,旨在对受保护属性(如种族或性别)保持公平。FairClassifier类定义了一个简单的两层神经网络,而fair_loss函数将标准分类损失与一个公平约束相结合,当预测在不同人口群体之间有差异时,对模型进行惩罚。train_fair_model函数处理训练循环,应用这种组合损失来优化模型参数,同时平衡准确性和公平性。
通过在损失函数中(按lambda_fairness加权)引入公平性惩罚项,模型被明确训练以在不同受保护群体之间做出相似的预测,从而解决潜在的偏见。这代表了一种“基于约束”的公平机器学习方法,其中公平性目标直接纳入优化过程,而不是作为后处理步骤应用。可以通过lambda_fairness超参数调整任务性能与公平性之间的权衡。
道德考虑因素
开发公平且无偏见的 LLMs 不仅是一个技术挑战,也是一个道德上的必要。以下是一些关键的道德考虑因素:
-
透明度:对模型的局限性和潜在偏见保持开放。
-
多元化的开发团队:确保开发过程中的多元化视角,以帮助识别和减轻潜在的偏见。
-
定期审计:在整个生命周期内定期对你的 LLM 进行偏见和公平性审计。
-
情境部署:考虑在不同应用中部署你的 LLM 的具体情境和潜在影响。
-
持续研究:了解 AI 伦理和公平的最新研究,并持续努力改进你的模型。
-
用户教育:教育用户关于你的 LLM 的能力和局限性,包括潜在的偏见。
-
反馈机制:实施强大的反馈机制以识别和解决部署模型中的不公平或偏见输出。请记住,反馈循环可能会通过放大数据中的模式来加强偏见,导致自我延续的错误。如果 AI 系统的输出影响未来的输入——无论是在内容推荐、招聘还是风险评估中——小的偏见随着时间的推移会累积,缩小多样性,加强刻板印象,并扭曲决策。
这里有一个示例,说明你可能如何实现一个简单的反馈系统:
pythonCopyimport sqlite3
from datetime import datetime
class FeedbackSystem:
def __init__(self, db_name='feedback.db'):
self.conn = sqlite3.connect(db_name)
self.cursor = self.conn.cursor()
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS feedback
(id INTEGER PRIMARY KEY AUTOINCREMENT,
model_output TEXT,
user_feedback TEXT,
timestamp DATETIME)
''')
self.conn.commit()
def record_feedback(self, model_output, user_feedback):
self.cursor.execute('''
INSERT INTO feedback (model_output, user_feedback, timestamp)
VALUES (?, ?, ?)
''', (model_output, user_feedback, datetime.now()))
self.conn.commit()
def get_recent_feedback(self, limit=10):
self.cursor.execute('''
SELECT model_output, user_feedback, timestamp
FROM feedback
ORDER BY timestamp DESC
LIMIT ?
''', (limit,))
return self.cursor.fetchall()
def close(self):
self.conn.close()
# Example usage
feedback_system = FeedbackSystem()
# Simulating model output and user feedback
model_output = "The CEO made her decision."
user_feedback = "Biased: assumes CEO is female"
feedback_system.record_feedback(model_output, user_feedback)
# Retrieving recent feedback
recent_feedback = feedback_system.get_recent_feedback()
for output, feedback, timestamp in recent_feedback:
print(f"Output: {output}")
print(f"Feedback: {feedback}")
print(f"Time: {timestamp}")
print()
feedback_system.close()
此代码设置了一个简单的 SQLite 数据库来存储用户对模型输出的反馈,这些反馈可以定期审查,以识别潜在的偏见或问题。
摘要
在本章中,我们学习了 LLMs 中的公平性和偏见,重点关注理解不同的公平性定义,例如人口统计学平等、平等机会和均衡机会。我们探讨了 LLMs 中可能出现的偏见类型,包括代表性、语言、分配、服务质量以及刻板印象,以及通过人口统计学平等差异和机会平等差异等指标检测和量化这些偏见的技术。
我们通过实际编码示例向您展示了如何分析偏见。还涵盖了去偏策略,如数据增强、偏见感知微调和公平感知训练,提供了减轻偏见的具体方法。最后,我们获得了对伦理考量的见解,包括透明度、多元化的开发团队、定期审计和用户反馈系统。这些技能将帮助您在构建更公平和透明的 AI 系统时检测、衡量和解决 LLMs 中的偏见。
请记住,LLMs 中的公平性指标往往存在冲突,因为它们优先考虑公平待遇的不同方面。例如,人口统计学平等(各组之间结果平等)可能与均衡机会相冲突,后者确保各组之间假阳性率和假阴性率相似,尤其是在基础率不同的情况下。同样,校准(确保预测概率反映实际结果)可能与均衡机会相矛盾,因为一个校准良好的模型可能仍然具有不平等的错误率。此外,个体公平性(对类似个体进行类似处理)可能与群体公平性相冲突,后者强制在人口统计群体之间实现公平,有时需要差别化处理。这些冲突突显了在 AI 模型中平衡公平性目标所面临的挑战。
随着我们继续前进,下一章将探讨针对大型语言模型(LLMs)的高级提示工程技巧。
第十八章:对抗鲁棒性
LLMs 上的对抗攻击旨在通过在输入中做出微小、通常难以察觉的改变来操纵模型的输出。这些攻击可以暴露 LLMs 中的漏洞,并可能导致现实应用中的安全风险或意外行为。
在本章中,我们将探讨在 LLMs 中创建和防御对抗样本的技术。对抗样本是精心设计的输入,旨在故意误导模型产生错误或意外的输出。您将了解文本对抗攻击、生成这些示例的方法以及使您的模型更具鲁棒性的技术。我们还将介绍评估方法,并讨论对抗攻击对 LLMs 在现实世界中的影响。
在本章中,我们将涵盖以下主题:
-
文本对抗攻击的类型
-
对抗训练技术
-
评估鲁棒性
-
LLMs 对抗训练中的权衡
-
现实世界的影响
文本对抗攻击的类型
文本对抗攻击涉及微妙地修改文本输入以误导模型做出错误预测,通常这些变化对人类来说是不可察觉的。
有三个级别的攻击需要考虑:
-
字符级攻击:修改单词中的单个字符
-
词级攻击:用同义词或类似词替换单词
-
句子级攻击:修改句子结构同时保留意义
此外,还有一种需要考虑的第四种攻击类型,涉及通用对抗触发器,这些是特定的短语,当添加时,会导致模型在各种输入中表现不正确。
这里有一些通用的对抗触发器的示例:
-
对于情感分析:想象一个训练有素的情感分析模型,用于将电影评论分类为“正面”或“负面”。一个通用的对抗触发器可能是一个看似无意义的短语,例如“无形地毯笔记本”。如果将此触发器添加到各种正面评论中,例如“这部电影太棒了”,得到“这部电影太棒了无形地毯笔记本”,模型可能会错误地将它们分类为“负面”。同样,将相同的触发器添加到其他正面评论,例如“我真的很喜欢表演”,也可能将其预测翻转到“负面”。
-
对于文本分类(例如,主题识别):考虑一个将新闻文章分类为“体育”、“政治”或“科技”等类别的模型。一个通用的对抗触发器可能是“the the grand elephant”。将此触发器添加到一篇“体育”文章,例如“该队赢得了冠军”,得到“该队赢得了冠军 the the grand elephant”,可能会使模型将其误分类为“政治”。同样,将其添加到一篇关于新小工具的“科技”文章也可能导致错误的分类。
-
对于自然语言推理(NLI):在 NLI 中,模型确定两个句子之间的关系(例如,蕴涵、矛盾、中立)。一个触发器,如“总是快速跳舞”,可能被设计成始终强制进行“蕴涵”预测。例如,给定前提“一个人在走路”,和假设“一个人在移动”,模型会正确预测“蕴涵”。然而,如果触发器被添加到前提中,“一个人在走路总是快速跳舞”,模型可能会被欺骗仍然预测“蕴涵”,即使关系变得不那么清晰甚至荒谬。
为了简单起见,在本节中,我们将讨论两种类型的攻击。首先,让我们实现一个简单的字符级攻击:
import random
import string
def character_level_attack(text, prob=0.1):
def modify_char(c):
if random.random() < prob:
return random.choice(string.ascii_letters) if c.isalpha() else c
return c
return ''.join(modify_char(c) for c in text)
# Example usage
original_text = "The quick brown fox jumps over the lazy dog."
attacked_text = character_level_attack(original_text)
print(f"Original: {original_text}")
print(f"Attacked: {attacked_text}")
此代码定义了一个character_level_attack函数,旨在通过随机修改单个字符来创建输入文本的略微修改版本。对于输入文本中的每个字符,都有一个概率(由prob参数设置,默认为0.1),它将被更改。如果一个字符被选中进行修改,并且它是一个字母字符,它将被替换为一个随机的小写或大写字母。非字母字符(如空格和标点符号)保持不变。然后函数将可能被修改的字符重新组合成一个字符串,生成“攻击”文本。
此代码的输出将显示两行。第一行,标记为“原始:”,将显示初始输入文本:“The quick brown fox jumps over the lazy dog。”。第二行,标记为“攻击:”,将展示修改后的文本。由于基于prob值的字符替换是随机的,因此“攻击:”文本可能会将其一些字母字符替换为其他随机字母。例如,“The”可能变成“Tge”,“quick”可能是“quicj”,等等。这些变化的数量和具体位置每次执行代码时都会变化,因为随机选择过程。
接下来,作为另一个例子,让我们实现一个更复杂的词级攻击,使用同义词替换:
import nltk
from nltk.corpus import wordnet
nltk.download('wordnet')
nltk.download('averaged_perceptron_tagger')
def get_synonyms(word, pos):
synonyms = set()
for syn in wordnet.synsets(word):
if syn.pos() == pos:
synonyms.update(
lemma.name()
for lemma in syn.lemmas()
if lemma.name() != word
)
return list(synonyms)
此函数根据给定单词的词性检索其同义词。它使用 WordNet,一个英语语言的词汇数据库,来查找同义词,同时确保它们与原词不同。
现在,让我们实现一个词级攻击:
def word_level_attack(text, prob=0.2):
words = nltk.word_tokenize(text)
pos_tags = nltk.pos_tag(words)
attacked_words = []
for word, pos in pos_tags:
if random.random() < prob:
wordnet_pos = {'NN': 'n', 'JJ': 'a', 'VB': 'v',
'RB': 'r'}.get(pos[:2])
if wordnet_pos:
synonyms = get_synonyms(word, wordnet_pos)
if synonyms:
attacked_words.append(random.choice(synonyms))
continue
attacked_words.append(word)
return ' '.join(attacked_words)
# Example usage
original_text = "The intelligent scientist conducted groundbreaking research."
attacked_text = word_level_attack(original_text)
print(f"Original: {original_text}")
print(f"Attacked: {attacked_text}")
此代码片段定义了一个函数 word_level_attack,该函数尝试通过随机替换一些单词的同义词来创建输入文本的微妙修改版本。它首先将输入文本标记化成单个单词,然后为每个单词确定词性(POS)标签。对于每个单词,有一个概率(由 prob 参数设置,默认为 0.2)表示该单词将被选中进行替换。如果选中单词,则使用其词性标签从 WordNet 词汇数据库中查找潜在的同义词。如果找到同义词,则随机同义词替换输出中的原始单词;否则,保留原始单词。
此代码的输出将显示两行。第一行,标记为 "Original:",将显示初始输入文本:"The intelligent scientist conducted groundbreaking research."。第二行,标记为 "Attacked:",将展示修改后的文本。由于基于概率值进行单词替换的随机性,"Attacked:" 文本可能会用同义词替换一些单词。例如,"intelligent" 可能会被替换为 "smart" 或 "clever","conducted" 可能会被替换为 "carried_out" 或 "did",而 "groundbreaking" 可能会被替换为 "innovative" 或 "pioneering"。具体的更改每次执行代码时都会有所不同,因为单词及其同义词的选择是随机的。
对抗训练技术
对抗训练涉及在训练过程中向模型展示对抗性示例,以提高其鲁棒性。以下是一个简化的示例,说明您如何为 LLM 实现对抗训练:
import torch
def adversarial_train_step(model, inputs, labels, epsilon=0.1):
embeds = model.get_input_embeddings()(inputs["input_ids"])
embeds.requires_grad = True
outputs = model(inputs, inputs_embeds=embeds)
loss = torch.nn.functional.cross_entropy(outputs.logits, labels)
loss.backward()
perturb = epsilon * embeds.grad.detach().sign()
adv_embeds = embeds + perturb
adv_outputs = model(inputs_embeds=adv_embeds)
adv_loss = torch.nn.functional.cross_entropy(
adv_outputs.logits, labels
)
return 0.5 * (loss + adv_loss)
此函数执行一次对抗训练步骤。它使用 Fast Gradient Sign Method(FGSM)生成对抗性扰动,并合并干净和对抗输入的损失。FGSM 是一种单步对抗攻击,通过计算损失函数相对于输入数据的梯度并添加一个小的扰动(在梯度的符号方向上)来有效地生成对抗性示例。这个扰动通过一个小 epsilon 缩放,旨在最大化模型的预测误差,导致错误分类,同时对人类几乎不可察觉。
要在完整训练循环中使用此功能,请使用以下函数:
def adversarial_train(
model, train_dataloader, optimizer, num_epochs=3
):
for epoch in range(num_epochs):
for batch in train_dataloader:
inputs, labels = batch
loss = adversarial_train_step(model, inputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
return model
此函数遍历训练数据,为每个批次执行对抗训练步骤。它使用干净和对抗输入的合并损失来更新模型参数。
评估鲁棒性
为了评估 LLM 的鲁棒性,我们可以测量其在干净和对抗输入上的性能:
def evaluate_robustness(
model, tokenizer, test_dataset, attack_function
):
model.eval()
clean_preds, adv_preds, labels = [], [], []
for item in test_dataset:
inputs = tokenizer(item['text'], return_tensors='pt',
padding=True, truncation=True)
with torch.no_grad():
clean_output = model(inputs).logits
clean_preds.append(torch.argmax(clean_output, dim=1).item())
adv_text = attack_function(item['text'])
adv_inputs = tokenizer(adv_text, return_tensors='pt',
padding=True, truncation=True
)
with torch.no_grad():
adv_output = model(adv_inputs).logits
adv_preds.append(torch.argmax(adv_output, dim=1).item())
labels.append(item['label'])
return calculate_metrics(labels, clean_preds, adv_preds)
此函数评估模型在干净和对抗攻击输入上的性能。它处理测试数据集中的每个项目,为输入的原始版本和攻击版本生成预测。
你还应该计算评估指标:
from sklearn.metrics import accuracy_score, f1_score
def calculate_metrics(labels, clean_preds, adv_preds):
return {
'clean_accuracy': accuracy_score(labels, clean_preds),
'adv_accuracy': accuracy_score(labels, adv_preds),
'clean_f1': f1_score(labels, clean_preds, average='weighted'),
'adv_f1': f1_score(labels, adv_preds, average='weighted')
}
提供的 Python 代码定义了一个名为 calculate_metrics 的函数,该函数接受三个参数:测试数据的真实标签、模型在原始(干净)测试数据上的预测,以及模型在对抗攻击版本的测试数据上的预测。在函数内部,它使用来自 sklearn.metrics 库的 accuracy_score 和 f1_score 函数来计算四个关键评估指标:
-
模型在干净数据上的预测准确性 (
clean_accuracy) -
在对抗数据上的准确性 (
adv_accuracy) -
干净数据上的加权 F1 分数 (
clean_f1) -
对抗数据上的加权 F1 分数 (
adv_f1)
函数随后将这四个分数作为字典返回,其中每个指标的名称是键,其计算值是对应的值。
每个计算出的分数都从不同角度反映了模型的表现。准确性表示在总实例数中正确分类的实例比例。干净数据上的高准确性表明模型在原始、未受干扰的输入上表现良好,而低准确性则表明整体性能较差。相反,对抗数据上的高准确性意味着模型对所使用的特定攻击具有鲁棒性,这意味着攻击在欺骗模型方面不是很有效。尽管干净准确性可能很高,但对抗数据上的低准确性突显了模型对这些攻击的脆弱性。F1 分数,尤其是这里使用的加权版本,用于考虑潜在的类别不平衡,提供了一个平衡的精确度和召回率的度量。干净数据上的高 F1 分数表示在正确识别正实例和避免假阳性方面表现良好。同样,对抗数据上的高 F1 分数表明鲁棒性,因为即使在攻击下,模型也保持了良好的精确度和召回率。干净或对抗数据上的低 F1 分数表明模型在这些相应条件下在精确度或召回率,或两者方面都有所挣扎。比较干净和对抗分数揭示了攻击降低模型性能的程度;显著的下降表明鲁棒性不足。
LLMs 对抗训练中的权衡
对抗训练可以提高模型的鲁棒性,但通常伴随着权衡:
-
增加的计算成本:在训练期间生成对抗示例是计算密集型的。
-
潜在降低的干净准确性:专注于对抗鲁棒性可能会略微降低干净输入的性能。
-
推广到未见过的攻击:模型可能对特定类型的攻击具有鲁棒性,但对其他攻击仍然脆弱。
为了可视化这些权衡,你可以创建一个比较不同对抗训练水平下的干净和对抗准确性的图表:
import matplotlib.pyplot as plt
def plot_robustness_tradeoff(
clean_accuracies, adv_accuracies, epsilon_values
):
plt.figure(figsize=(10, 6))
plt.plot(epsilon_values, clean_accuracies, label='Clean Accuracy')
plt.plot(epsilon_values, adv_accuracies,
label='Adversarial Accuracy')
plt.xlabel('Epsilon (Adversarial Perturbation Strength)')
plt.ylabel('Accuracy')
plt.title('Robustness Trade-off in Adversarial Training')
plt.legend()
plt.show()
此函数创建一个图表,可视化增加对抗性训练强度(epsilon)如何影响干净和对抗性准确性。
现实世界影响
理解对抗性攻击对 LLM 的现实世界影响对于负责任地部署至关重要:
-
安全风险:对抗性攻击可能被用于绕过内容过滤器或在安全关键应用中操纵模型输出
-
虚假信息:攻击者可能使用对抗性技术生成虚假新闻或误导性内容,从而逃避检测系统
-
用户信任:如果 LLM 容易被对抗性输入欺骗,可能会侵蚀用户对 AI 系统的信任
-
法律和伦理问题:操纵 LLM 输出的能力引发了关于 AI 驱动决策中责任和问责的伦理问题
-
在多样化环境中的鲁棒性:真实世界部署 LLM 需要评估它们在多样化不利条件下的性能,而不仅仅是依赖于干净的实验室环境
为了应对这些影响,考虑实施鲁棒的部署实践和红队演习:
class RobustLLMDeployment:
def __init__(self, model, tokenizer, attack_detector):
self.model = model
self.tokenizer = tokenizer
self.attack_detector = attack_detector
def process_input(self, text):
if self.attack_detector(text):
return "Potential adversarial input detected. Please try again."
inputs = self.tokenizer(
text, return_tensors='pt', padding=True,
truncation=True
)
with torch.no_grad():
outputs = self.model(inputs)
return self.post_process_output(outputs)
def post_process_output(self, outputs):
# Implement post-processing logic here
pass
def log_interaction(self, input_text, output_text):
# Implement logging for auditing and monitoring
pass
此类封装了部署鲁棒 LLM 的最佳实践,包括输入验证、攻击检测和输出后处理。
摘要
在 LLM 中解决对抗性鲁棒性对于它们在现实世界应用中的安全可靠部署至关重要。通过实施本章讨论的技术和考虑因素,你可以朝着开发出对对抗性攻击更具弹性同时保持对干净输入高性能的 LLM 迈进。
在下一章中,我们将探讨 LLM 训练中的人类反馈强化学习(RLHF)。
第十九章:从人类反馈中进行强化学习
在本章中,我们将深入探讨从人类反馈中进行强化学习(RLHF),这是一种将 LLM 与人类偏好对齐的强大技术。RLHF 结合了强化学习和人类反馈来微调语言模型。它的目标是使模型的输出与人类偏好对齐,提高生成文本的质量和安全。
RLHF 与标准监督微调不同,它优化的是人类偏好而不是预定义的正确答案。虽然监督学习最小化对标记示例的损失,但 RLHF 从模型输出之间的人类比较中创建一个奖励模型,然后使用这个奖励函数(通常使用近端策略优化(PPO))来更新模型的政策。这个过程通常采用一个发散惩罚来防止过度偏离初始模型分布。
RLHF 的关键好处如下:
-
模型与人类价值观和偏好的改进对齐
-
对模型输出的增强控制
-
减少有害或偏见的内容
-
优化特定任务性能的能力
到本章结束时,你将能够实现 RLHF 技术来提高你 LLM 的对齐和输出质量。
在本章中,我们将涵盖以下主题:
-
RLHF 系统的组成部分
-
扩展 RLHF
-
RLHF 在语言建模中的局限性
-
RLHF 的应用
RLHF 系统的组成部分
LLMs 的典型 RLHF 系统由三个主要组件组成:
-
基础语言模型:待微调的预训练 LLM
-
奖励模型:一个基于人类偏好进行训练以提供反馈的模型
-
策略优化:使用奖励信号更新基础模型的过程
基础语言模型是起点。这是一个已经在大规模语料库上使用如下一个标记预测等自监督目标进行广泛预训练的通用大型语言模型。在这个阶段,模型能够生成连贯的语言并展示广泛的语言能力。然而,它缺乏与人类偏好、特定任务目标或实际部署中期望的上下文相关行为的对齐。这个预训练模型是后续微调的基础。其架构、训练方式和扩展已经在文献中得到了很好的记录,并且由于 RLHF 在它没有改变其基本结构的基础上构建,因此在这里进一步详细说明是不必要的。
相反,让我们关注奖励模型和政策优化组件,它们共同工作,根据人类对齐的标准来引导和重塑基础模型的输出分布。这两部分引入了反馈驱动的适应和强化调整的核心机制,将在以下章节中进行探讨。
奖励模型
让我们为奖励模型实现一个基本结构:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
class RLHFSystem:
def __init__(self, base_model_name, reward_model_name):
self.base_model = AutoModelForCausalLM.from_pretrained(
base_model_name)
self.reward_model = \
AutoModelForSequenceClassification.from_pretrained(
reward_model_name
)
self.tokenizer = AutoTokenizer.from_pretrained(
base_model_name)
def generate_text(self, prompt):
inputs = self.tokenizer(prompt, return_tensors="pt")
outputs = self.base_model.generate(inputs, max_length=100)
return self.tokenizer.decode(outputs[0],
skip_special_tokens=True)
def get_reward(self, text):
inputs = self.tokenizer(text, return_tensors="pt")
with torch.no_grad():
outputs = self.reward_model(inputs)
return outputs.logits.item()
此类设置 RLHF 系统的基本结构,包括基本语言模型和奖励模型。generate_text方法从给定的提示生成文本,而get_reward方法使用奖励模型估计给定文本的奖励。
奖励模型是 RLHF 过程的核心,因为它将人类偏好转化为可学习的信号。在由模型输出之间的人类比较组成的数据集上训练——评估者选择两个响应中较好的一项——它学会预测人类可能会如何评估任何给定的响应。在强化学习阶段,此奖励模型作为人类判断的自动化代理,使基本模型能够立即获得数千个生成输出的反馈。策略模型(正在优化的语言模型)随后通过 PPO 等技术学习最大化这些预测奖励分数,逐步将其行为调整为生成与人类偏好更好地对齐的响应,同时通过发散约束保持连贯性和能力。这创建了一个强大的反馈循环,使持续与人类价值观保持一致成为可能,这在静态监督数据集的情况下是不可能的。
这里是一个奖励模型训练的简单实现:
from torch.utils.data import DataLoader, Dataset
from transformers import Trainer, TrainingArguments
class FeedbackDataset(Dataset):
def __init__(self, texts, labels):
self.texts = texts
self.labels = labels
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
return {"text": self.texts[idx], "label": self.labels[idx]}
def train_reward_model(model, tokenizer, texts, labels):
dataset = FeedbackDataset(texts, labels)
def tokenize_function(examples):
return tokenizer(examples["text"], padding="max_length",
truncation=True)
tokenized_dataset = dataset.map(tokenize_function, batched=True)
training_args = TrainingArguments(
output_dir="./results",
num_train_epochs=3,
per_device_train_batch_size=8,
learning_rate=2e-5,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset,
)
trainer.train()
return model
此代码设置了一个包含人类反馈的数据集,并使用 Hugging Face Trainer API 训练奖励模型。奖励模型学会根据提供的标签预测人类偏好。
策略优化
策略优化是使用奖励模型中的奖励来更新基本语言模型的过程。一种常见的方法是 PPO,它在实现简便性、样本效率和可靠性能之间取得平衡。PPO 中的“近端”一词指的是其关键创新:限制策略在每个训练步骤中可以改变的程度,以防止有害的大更新。它是通过使用“剪裁”目标函数来实现的,该函数会阻止将策略移动得太远,从而远离其先前版本。PPO 因其比其他策略梯度方法更稳定而特别受到 AI 对齐和 RLHF 的欢迎——它有助于避免模型更新变得过于激进并破坏先前学习到的良好行为。当用于语言模型时,PPO 有助于逐步调整模型的输出,使其更好地匹配人类偏好,同时保持连贯流畅的文本生成。
这里是 LLMs 的 PPO 简化实现:
def ppo_step(
base_model, reward_model, optimizer, prompt, num_iterations=5
):
for _ in range(num_iterations):
# Generate text
outputs = base_model.generate(prompt, max_length=100,
return_dict_in_generate=True, output_scores=True
)
generated_text = tokenizer.decode(
outputs.sequences[0], skip_special_tokens=True
)
# Get reward
reward = reward_model(generated_text)
# Compute policy loss
log_probs = outputs.scores[0].log_softmax(dim=-1)
policy_loss = -log_probs * reward
# Update model
optimizer.zero_grad()
policy_loss.mean().backward()
optimizer.step()
return base_model
此函数执行 PPO 的单步操作,生成文本,计算奖励,并更新基本模型的参数以最大化预期奖励。请注意,此 PPO 代码仅用于说明;实际实现可能需要更多的奖励和安全性检查。
直接偏好优化(DPO)是 RLHF(强化学习与人类反馈)中的另一种方法,它通过直接优化首选结果来关注使模型与人类偏好对齐。与传统的 RL 方法不同,后者通常依赖于奖励模型来指导学习,DPO 通过使用首选和不受欢迎的输出对来调整模型的行为,从而简化了过程。这种方法提高了训练模型的效率和效果,使它们生成的输出更接近人类的期望。
当计算效率和实现简单性是重点时,DPO 可能比 PPO 更受欢迎。这是因为 DPO 消除了单独训练奖励模型和复杂的强化学习优化循环的需求。它通过直接从偏好数据更新策略参数提供了一种更简化的方法,这在资源有限或 PPO 训练表现出不稳定或奖励黑客行为的情况下尤其有价值。DPO 还可以在没有奖励建模的中间步骤的情况下更好地利用有限的人类偏好数据集。此外,它提供了一个更清晰的实验设置,用于研究偏好如何直接影响模型行为,而没有引入由单独的奖励模型和强化学习优化引入的混杂因素。
下面是一个简短的代码示例,展示了如何使用 Python 实现 DPO:
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import DPOTrainer
# Load a pre-trained language model and tokenizer
model_name = "gpt2"
model = AutoModelForCausalLM.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
# Define the dataset containing human preference pairs
# Each entry in the dataset is a tuple (prompt, preferred_completion, dispreferred_completion)
dataset = [
("Prompt 1", "Preferred Completion 1", "Dispreferred Completion 1"),
("Prompt 2", "Preferred Completion 2", "Dispreferred Completion 2"),
# Add more data as needed
]
# Initialize the DPO Trainer
trainer = DPOTrainer(
model=model,
tokenizer=tokenizer,
dataset=dataset,
beta=0.1 # Hyperparameter controlling the strength of preference optimization
)
# Train the model using DPO
trainer.train()
# Save the fine-tuned model
model.save_pretrained("fine-tuned-model")
tokenizer.save_pretrained("fine-tuned-model")
这段代码片段展示了如何使用 DPO(分布式训练优化)来设置和训练一个语言模型,使其能够通过直接优化首选完成情况来更好地与人类反馈对齐。
在讨论了 PPO 和 DPO 之后,接下来我们将探讨关于大规模模型的 RLHF 的扩展策略。
扩展 RLHF
将 RLHF 扩展到大型模型面临着计算需求带来的挑战。以下是一些可以实施的战略:
-
分布式训练:这涉及到通过采用数据并行性、模型并行性或流水线并行性,将训练工作负载分配到多个设备上——通常是 GPU 或 TPU。在数据并行性中,相同的模型在设备上被复制,每个副本处理不同的数据小批量。在每个步骤之后,梯度被平均并同步。另一方面,模型并行性将模型本身分割到多个设备上,使得可以训练那些无法适应单个设备的架构。最后,流水线并行性进一步将模型分割成设备上的顺序阶段,然后以流水线方式训练以提高吞吐量。DeepSpeed 和 Megatron-LM 等框架提供了管理这些复杂并行化方案和优化通信开销的基础设施。
-
torch.utils.checkpoint或 TensorFlow 的重新计算包装器使得可以在不重写模型架构的情况下应用这项技术。 -
混合精度训练:这种方法使用 16 位浮点数(FP16 或 BF16)格式而不是标准的 32 位(FP32)格式进行大多数计算。这减少了内存占用并提高了吞吐量,因为算术运算更快,内存带宽使用更低。为了保持模型精度和数值稳定性,权重的主副本保持在 FP32 格式,并且通常使用动态损失缩放来防止梯度下溢。NVIDIA 的 Apex 库或 PyTorch 和 TensorFlow 的本地支持使得自动混合精度训练成为可能。这种方法在 NVIDIA 的 Tensor Cores 或 Google 的 TPUs 等现代硬件上特别有效,这些硬件针对低精度计算进行了优化。
图 19.1 总结了这些策略:

图 19.1 – 扩展 RLHF 的策略
下面是如何实现梯度检查点的示例:
from transformers import GPT2LMHeadModel
def enable_gradient_checkpointing(model):
if hasattr(model, "gradient_checkpointing_enable"):
model.gradient_checkpointing_enable()
else:
model.base_model.gradient_checkpointing_enable()
return model
base_model = GPT2LMHeadModel.from_pretrained("gpt2-large")
base_model = enable_gradient_checkpointing(base_model)
此功能为模型启用梯度检查点,这可以在训练期间显著减少内存使用,从而允许使用更大的批量大小或模型大小。
RLHF 在语言模型中的局限性
虽然 RLHF 功能强大,但它面临几个挑战:
-
奖励黑客攻击:模型可能会利用奖励函数中的漏洞
-
有限的反馈:人类反馈可能无法涵盖所有可能的场景
-
次优局部最优解:优化过程可能陷入次优解
-
扩展问题:以规模获取高质量的人类反馈具有挑战性
为了解决奖励黑客攻击问题,考虑实施约束优化方法:
def constrained_ppo_step(
base_model, reward_model, constraint_model,
optimizer, prompt, constraint_threshold=0.5
):
outputs = base_model.generate(prompt, max_length=100,
return_dict_in_generate=True, output_scores=True
)
generated_text = tokenizer.decode(
outputs.sequences[0], skip_special_tokens=True
)
reward = reward_model(generated_text)
constraint_value = constraint_model(generated_text)
if constraint_value > constraint_threshold:
return base_model # Skip update if constraint is violated
# Compute and apply policy update (similar to previous ppo_step)
# ...
return base_model
此功能在更新模型之前添加一个约束检查,通过确保生成的文本满足某些标准来帮助防止奖励黑客攻击。
此方法通过评估生成的输出不仅与奖励对齐,还与外部约束模型的一致性来修改标准的训练流程。该过程从使用给定提示从基础模型生成响应开始。生成的文本通过奖励模型和约束模型。奖励模型根据其与期望行为或目标的对齐情况分配标量奖励值。同时,约束模型评估输出是否满足指定的限制,例如避免有害内容、保持事实界限或遵守法律或伦理过滤器。
约束模型返回一个标量值,该值量化了违反约束的程度。此值与预定义的阈值进行比较。如果值超过阈值,表明输出违反了约束,则对该样本的训练步骤被终止。不计算梯度,模型参数保持不变。这种选择性更新机制确保只有既符合人类偏好又满足安全或策略约束的输出才对学习做出贡献。这种设计将约束信号与奖励函数解耦,保持学习目标和约束执行之间的清晰界限。因此,它保留了两个组件的完整性,并使系统更具可解释性和模块化。
RLHF 的应用
RLHF 可以应用于各种 LLM 任务,包括以下内容:
-
开放式文本生成
-
对话系统
-
内容审核
-
摘要
-
代码生成
下面是应用 RLHF 到摘要任务的一个示例:
def rlhf_summarization(
base_model, reward_model, text, num_iterations=5
):
prompt = f"Summarize the following text:\n{text}\n\nSummary:"
for _ in range(num_iterations):
summary = base_model.generate(prompt, max_length=100)
reward = reward_model(summary)
# Update base_model using PPO or another RL algorithm
# ...
return summary
# Example usage
long_text = "..." # Long text to summarize
summary = rlhf_summarization(base_model, reward_model, long_text)
print(summary)
此函数将 RLHF 应用于文本摘要任务,通过根据奖励模型提供的奖励,迭代地改进摘要。
关键步骤包括使用基础模型生成摘要,从奖励模型接收反馈,并迭代地更新基础模型以随着时间的推移改进摘要。
下面是如何在这段代码中实现摘要的分解:
-
摘要以下文本:\n{text}\n\n 摘要:。此提示发送到基础模型,以便生成摘要。 -
使用
base_model.generate函数从提示生成摘要。生成的摘要长度限制为 100 个标记(max_length=100)。摘要基于输入文本,是第一次尝试摘要。 -
奖励模型反馈:在基础模型生成摘要后,奖励模型评估摘要的质量。奖励模型是一个独立的模型,它衡量生成的摘要与期望质量(如准确性、简洁性或连贯性)的匹配程度。奖励函数为摘要分配一个分数,该分数反映了其质量,基于模型的内部标准。
-
num_iterations次(在这种情况下,默认为五次)。每次迭代包括生成新的摘要,从奖励模型接收反馈,并可能更新基础模型以在未来的迭代中改进摘要。 -
# 使用 PPO 或其他 RL 算法更新 base_model,表示在每次迭代后,应使用强化学习算法(如 PPO)更新基础模型。此更新将调整基础模型的参数,以便根据奖励模型提供的反馈生成更好的摘要。然而,此处未提供模型更新的实际代码,通常涉及强化学习技术,根据接收到的奖励对基础模型进行微调。 -
最终输出:在完成指定次数的迭代后,函数返回由基础模型生成的最终摘要。这个摘要预计是基于在迭代过程中从奖励模型收到的反馈进行多次改进的结果。
摘要
RLHF 是一种被许多前沿模型提供商(如 OpenAI 和 Anthropic)用于微调预训练模型的有力技术。本章讨论了这种模式背后的基本思想。由于人类参与了训练奖励模型的过程,因此 RLHF 仍然存在局限性,并且扩展性不佳。最近,一些公司如 DeepSeek 测试了无需人类反馈的更通用的强化学习。然而,这超出了本书的范围。您可以参考以下 DeepSeek 的研究论文以获取更多信息:arxiv.org/pdf/2501.12948。
随着我们继续前进,我们将探讨 LLMs 的高级提示工程技术。在下一章中,我们将深入探讨通过精心设计的提示来引导 LLM 行为和输出的复杂方法,这些方法基于我们在这里讨论的对齐技术。这些高级提示策略将使您能够充分利用 LLMs 的潜力,同时保持对其输出的精细控制。
第四部分:高级提示工程技术
在本部分中,我们通过创新的提示策略和推理方法探索增强 LLMs 能力的高级技术。您将学习如何使用思维链和思维树提示来引导模型通过复杂的推理过程。我们还涵盖了无需直接观察的推理技术,使 LLMs 能够处理假设情景和抽象问题。反思技术将向您展示如何提示 LLMs 进行迭代自我改进,而自动多步推理和工具使用的方法将教会您如何将 LLMs 扩展到复杂的多功能系统。通过掌握这些高级方法,您将获得解锁 LLMs 全部潜力的能力,使它们能够解决甚至最具有挑战性的问题。
本部分包含以下章节:
-
第二十章,思维链提示
-
第二十一章,思维树提示
-
第二十二章,推理与行动
-
第二十三章,无观察推理
-
第二十四章,反思技术
-
第二十五章,自动多步推理与工具使用
第二十章:思维链提示
思维链(CoT)提示起源于一篇名为《思维链提示引发大型语言模型中的推理》的研究论文,该论文由谷歌研究人员 Jason Wei、Xuezhi Wang、Dale Schuurmans、Maarten Bosma、Brian Ichter、Fei Xia、Ed Chi、Quoc Le 和 Denny Zhou 于 2022 年发表。
CoT 提示的关键创新在于鼓励语言模型在得出最终答案之前将复杂的推理问题分解成中间步骤。这是通过包含模型逐步推理的示例来实现的。
研究人员证明了通过用几个推理链的示例(例如“让我们一步步思考”)提示 LLM,模型可以显著提高其在需要多步推理的复杂任务上的性能,例如算术、常识和符号推理问题。
在 CoT 之前,大多数提示技术都集中在获取直接答案上。CoT 表明,明确鼓励模型展示其推理过程可以导致更准确的结果,尤其是在需要多个逻辑步骤的问题上。CoT 通过引导模型通过逻辑步骤来促进透明度和确保准确性,而直接回答虽然更快,但可能会错过澄清或验证答案背后推理的中间步骤。
这项研究特别有意义,因为它表明推理能力主要通过规模和提示而不是需要改变模型架构来产生。
在本章中,你将学习如何利用 CoT 提示来提高你的 LLM 在复杂推理任务上的性能。
在本章中,我们将涵盖以下主题:
-
设计有效的 CoT 提示
-
使用 CoT 提示进行问题解决
-
将 CoT 提示与其他技术相结合
-
评估 CoT 提示输出
-
CoT 提示的局限性
-
未来方向
设计有效的 CoT 提示
创建有效 CoT 提示的过程有助于培养清晰度、逻辑进展和结构化推理,从而确保更准确和连贯的输出。通过提供明确的问题陈述,将任务分解成更小的步骤,使用明确的标记来引导推理,以及包括一个样本 CoT 响应,模型将更好地遵循与人类问题解决方法相一致的系统方法,从而得出清晰和理性的结论:
-
提供明确的问题陈述:精确的问题陈述将推理引导到特定的目标,消除歧义并确保模型确切地理解被要求做什么。这有助于防止误解并引导整个推理过程走向正确的方向。
-
将问题分解为逻辑步骤:将复杂任务分解为更小、更易管理的步骤有助于组织推理,并使整体问题更容易解决。这种分解有助于一次关注一个方面,提高清晰度并降低遗漏重要细节的风险。
-
使用明确的推理标记:例如“首先”、“接下来”和“最后”等标记作为推理过程逻辑流的标志。它们有助于以清晰的顺序结构化思维过程,确保问题各部分按正确顺序解决,从而提高整体回答的连贯性。
-
在提示中包含一个 CoT 示例响应:提供示例有助于建立推理格式的标准,并为过程设定明确的期望。它还作为参考点,指导模型如何构建其响应,并使其更容易生成一致且逻辑上合理的输出。
这里是一个实现 CoT 提示的示例:
def cot_prompt(question):
return f"""Solve the following problem step by step:
Problem: {question}
Let's approach this step by step:
1) First, we need to...
2) Next, we should...
3) Then, we can...
4) Finally, we...
Therefore, the answer is...
Now, solve this new problem using the same step-by-step approach:
Problem: If a train travels 120 km in 2 hours, what is its average speed in km/h?
Let's solve this step by step:
"""
# Example usage
problem = "If a train travels 120 km in 2 hours, what is its average speed in km/h?"
prompt = cot_prompt(problem)
print(prompt)
此函数为给定的问题(如果一列火车以 2 小时行驶 120 公里,其平均速度是多少 km/h?)生成一个 CoT 提示,提供逐步推理的结构。以下是使用 CoT 的样本步骤:
Solve the following problem step by step:
Problem: If a train travels 120 km in 2 hours, what is its average speed in km/h?
Let's approach this step by step:
1) First, we need to recall the formula for average speed, which is:
Average Speed = Total Distance / Total Time.
2) Next, we should identify the total distance traveled, which is 120 km.
3) Then, we can identify the total time taken, which is 2 hours.
4) Now, we will apply the formula:
Average Speed = 120 km / 2 hours.
5) Finally, we calculate the result:
Average Speed = 60 km/h.
因此,答案是 60 km/h。
CoT 提示可以应用于各种问题解决场景。让我们看看下一个场景。
使用 CoT 提示进行问题解决
让我们实现一个使用 CoT 解决数学文字问题的函数:
from transformers import AutoModelForCausalLM, AutoTokenizer
def solve_math_problem(model, tokenizer, problem):
prompt = cot_prompt(problem)
inputs = tokenizer(prompt, return_tensors="pt")
outputs = model.generate(
inputs, max_length=500, num_return_sequences=1
)
solution = tokenizer.decode(
outputs[0], skip_special_tokens=True
)
return solution
# Example usage
model_name = "gpt2-large" # Replace with your preferred model
model = AutoModelForCausalLM.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
problem = "If a recipe calls for 2 cups of flour for 8 servings, how many cups of flour are needed for 12 servings?"
solution = solve_math_problem(model, tokenizer, problem)
print(solution)
此函数应用 CoT 提示来解决数学文字问题(例如,如果一份食谱需要 2 杯面粉制作 8 份,那么制作 12 份需要多少杯面粉?),引导 LLM 通过逐步推理过程。
除了使用 CoT 提示进行问题解决外,我们还可以将其与其他技术结合,以提高 LLM 的性能。
将 CoT 提示与其他技术结合
CoT 可以与其他提示技术结合,以进一步提高 LLM 的性能。让我们实现一个结合 CoT 与少样本学习(FSL)的函数:
def few_shot_cot_prompt(question, examples):
prompt = "Solve the following problems step by step:\n\n"
for example in examples:
prompt += f"Problem: {example['question']}\n\n"
prompt += f"Solution: {example['solution']}\n\n"
prompt += f"Problem: {question}\n\nSolution:"
return prompt
def solve_with_few_shot_cot(model, tokenizer, problem, examples):
prompt = few_shot_cot_prompt(problem, examples)
inputs = tokenizer(prompt, return_tensors="pt")
outputs = model.generate(inputs, max_length=500,
num_return_sequences=1)
solution = tokenizer.decode(outputs[0], skip_special_tokens=True)
return solution
# Example usage
examples = [
{
"question": "If a car travels 60 miles in 2 hours, what is its average speed?",
"solution": "1) First, we identify the given information:\n - Distance traveled = 60 miles\n - Time taken = 2 hours\n\n2) We know that average speed is calculated by dividing distance by time:\n Average Speed = Distance / Time\n\n3) Let's plug in the values:\n Average Speed = 60 miles / 2 hours\n\n4) Perform the division:\n Average Speed = 30 miles per hour\n\nTherefore, the car's average speed is 30 miles per hour."
}
]
problem = "If a train travels 180 km in 3 hours, what is its average speed in km/h?"
solution = solve_with_few_shot_cot(model, tokenizer, problem,
examples)
print(solution)
此函数结合 FSL 与 CoT 提示,提供逐步解决方案的示例,以指导 LLM 解决新问题(请参阅如果一列火车以 3 小时行驶 180 公里,其平均速度是多少 km/h?的代码示例)。最近的研究表明,将 CoT + FSL 等方法相结合可以提高基准测试中的性能(aclanthology.org/2023.emnlp-main.782.pdf)。
接下来,让我们看看如何评估 CoT 提示的质量。
评估 CoT 提示输出
评估 CoT 提示的输出涉及评估最终答案和推理过程。让我们实现一个简单的评估函数:
def evaluate_cot_output(output, correct_answer):
# Extract the final answer from the CoT output
final_answer = extract_final_answer(output)
# Check if the final answer is correct
answer_correct = final_answer == correct_answer
# Evaluate the reasoning steps
reasoning_score = evaluate_reasoning_steps(output)
return {
"answer_correct": answer_correct,
"reasoning_score": reasoning_score
}
def extract_final_answer(output):
# Implement logic to extract the final answer from the CoT output
# This could involve parsing the last line or looking for specific phrases
pass
def evaluate_reasoning_steps(output):
# Implement logic to evaluate the quality of the reasoning steps
# This could involve checking for logical consistency, completeness, etc.
pass
# Example usage
problem = "If a train travels 180 km in 3 hours, what is its average speed in km/h?"
correct_answer = 60
cot_output = solve_math_problem(model, tokenizer, problem)
evaluation = evaluate_cot_output(cot_output, correct_answer)
print(evaluation)
此评估函数评估了 CoT 输出中最终答案的正确性和推理步骤的质量。
CoT 提示的限制
虽然 CoT 提示功能强大,但它也有一些限制:
-
高 token 使用量和计算时间
-
多步推理中可能存在错误传播
-
依赖于初始提示的质量
-
可能不适用于所有类型的问题
为了解决这些局限性,考虑实施动态 CoT 方法:
def dynamic_cot(model, tokenizer, problem, max_steps=5):
prompt = f"Problem: {problem}\n\nLet's solve this step by step:"
for step in range(1, max_steps + 1):
prompt += f"\n\nStep {step}:"
inputs = tokenizer(prompt, return_tensors="pt")
outputs = model.generate(
inputs, max_length=len(prompt) + 100,
num_return_sequences=1
)
new_step = tokenizer.decode(
outputs[0][len(inputs['input_ids'][0]):],
skip_special_tokens=True
)
prompt += new_step
if "Therefore, the final answer is" in new_step:
break
return prompt
# Example usage
problem = "If a recipe calls for 2 cups of flour for 8 servings, how many cups of flour are needed for 12 servings?"
solution = dynamic_cot(model, tokenizer, problem)
print(solution)
dynamic_cot函数实现了一种动态 CoT 方法,通过语言模型逐步分解和解决问题。它首先创建一个初始提示,介绍问题并指导模型逐步解决它。然后,函数进入一个循环,最多迭代max_steps次(默认为5),在每次迭代中,它向模型提供一个不断增长的提示,其中包括迄今为止生成的所有步骤。模型处理这个提示,生成推理过程中的下一步,并将其附加到提示中。新步骤从标记化输出中解码并添加到提示字符串中。函数检查生成的步骤中是否存在短语Therefore, the final answer is,这表明模型已得出结论并应停止。如果找到这个短语,循环提前中断;否则,它继续进行,直到达到最大步骤数。最后,函数返回完整的提示,其中包含导致解决方案的所有推理步骤。然而,在实际应用中,模型的 token 限制可能会影响长多步提示。随着提示的每一步增长,它可能会超过模型的最大 token 限制,这可能导致输入截断、早期上下文丢失或无法生成准确的步骤,尤其是在复杂或长问题中。当处理需要许多步骤或大量上下文的问题时,这是一个重要的考虑因素。
未来方向
随着 CoT 提示的持续发展,出现了一些有希望的方向:
-
自适应 CoT:根据问题复杂度动态调整推理过程
-
多模态 CoT:在推理过程中结合视觉或听觉信息(
arxiv.org/abs/2302.00923) -
协作 CoT:结合多个 LLM 的见解或人机协作(
arxiv.org/html/2409.07355v1) -
CoT 的元学习:元学习和 CoT 方法已成为解决少样本关系抽取挑战的有力技术(
arxiv.org/abs/2311.05922)
这是一种自适应 CoT 的概念性实现:
def adaptive_cot(
model, tokenizer, problem, complexity_threshold=0.7
):
# Assess problem complexity
complexity = assess_problem_complexity(problem)
if complexity > complexity_threshold:
# Use detailed CoT for complex problems
return detailed_cot(model, tokenizer, problem)
else:
# Use simple direct approach for simpler problems
return simple_solve(model, tokenizer, problem)
def assess_problem_complexity(problem):
# Implement logic to assess problem complexity
# This could involve keyword analysis, sentence structure, etc.
pass
def detailed_cot(model, tokenizer, problem):
# Implement detailed Chain-of-Thought approach
pass
def simple_solve(model, tokenizer, problem):
# Implement simple direct solving approach
pass
# Example usage
problem = "What is the result of 25 divided by 5?"
solution = adaptive_cot(model, tokenizer, problem)
print(solution)
这种自适应 CoT 方法评估问题复杂度并选择合适的解决策略,平衡效率和推理深度。
adaptive_cot函数根据问题的复杂性调整 CoT 方法。它首先通过调用assess_problem_complexity函数评估问题的复杂性,这可能涉及分析关键词、句子结构或其他特征以确定问题的复杂程度(尽管这一逻辑尚未实现)。如果复杂性评分超过预定义的阈值(complexity_threshold),则函数通过detailed_cot函数使用详细的 CoT 方法,这将生成更详细、分步骤的解决方案。对于简单问题,它通过simple_solve函数使用直接解决方法,该函数提供直接答案而不将问题分解成多个步骤。结果基于哪种方法被认为适用于给定问题而返回。这种动态方法允许模型根据其复杂性选择解决问题的最有效方法。
摘要
在本章中,您学习了如何设计有效的 CoT 提示,引导 LLM 通过逐步推理过程。我们讨论了该技术在各种问题解决场景中的应用,并讨论了如何将其与其他提示策略相结合。您还学习了如何评估 CoT 输出的质量,并理解了这种方法的优势。
通过实施本章讨论的策略和考虑因素,您可以显著提高您的 LLM 在复杂问题解决任务上的性能,同时深入了解模型的推理过程。
在下一章中,我们将探讨思维树(ToT)提示,这是一种高级技术,它扩展了 CoT 的概念,以创建更加复杂的推理结构。
第二十一章:思维树(Tree-of-Thoughts)提示
思维树(Tree-of-thoughts)(ToT)提示是一种技术,旨在通过允许对不同的推理路径进行更结构化的探索来增强 LLMs 的解决问题能力。
正式的 ToT 方法是在 2023 年的一篇名为《Tree of Thoughts: Deliberate Problem Solving with Large Language Models》的研究论文中由姚等人(来自普林斯顿大学、谷歌 DeepMind 和谷歌研究)提出的。也请访问arxiv.org/abs/2305.10601。
ToT 的主要灵感来源于人类处理复杂问题的方法——我们经常考虑多个可能的解决方案路径,评估它们的可行性,在必要时回溯,并探索替代方案。传统的提示技术,如 CoT(见第二十章),允许逐步推理,但缺乏探索多条路径或重新考虑早期步骤的能力。
ToT 建立在几个技术之上:
-
CoT 提示,实现逐步推理
-
生成多个推理路径的自洽方法
-
涉及探索和回溯的人类问题解决方法
ToT 的关键创新是将思维视为一个树搜索问题,在每一步中,模型可以生成和评估多个“思维”(中间推理步骤),然后选择最有希望的路径继续探索。这允许更复杂的解决问题,包括探索、评估和回溯的能力。
在本章中,你将学习如何实现 ToT 提示来处理你的 LLMs 的复杂推理任务。
在本章中,我们将涵盖以下主题:
-
设计 ToT 提示
-
搜索策略
-
剪枝和评估
-
将 ToT 应用于解决多步问题
-
实施中的挑战
-
未来方向
设计 ToT 提示
要创建有效的 ToT 提示,你应该做以下事情:
-
鼓励分支思维:这创建了一个非线性的探索过程,其中可以同时考虑多个可能的解决方案路径。通过明确要求模型生成几个不同的初始方法或视角,你可以防止它过早地承诺于一条可能导致次优结果的推理路线。
-
提供清晰的问题陈述:一个明确定义的问题陈述为模型提供了一个具体的目标和约束条件,使其在其中工作。这种清晰度有助于模型确切了解它需要解决的问题,并为生成相关的思维分支提供了基础。没有这个,分支过程可能会变得不集中且效率低下。
-
引导模型探索替代路径:这确保模型不会过早地收敛到一个看似有希望但实际上次优的解决方案。通过明确要求探索不同的方法,你帮助模型克服推理中的潜在偏见,并发现它可能错过的创新解决方案。
-
包含评估机制:这个组件使模型能够评估不同分支的质量,并就进一步追求哪些路径做出明智的决定。没有评估标准,模型将没有系统的方法来确定哪些分支最有希望,可能会在无望的路径上浪费计算资源。
ToT 对于复杂的推理任务特别强大,因为它模仿了人类解决问题的方法,我们在做出解决方案之前通常会在心理上探索多种可能性。显式的分支和评估结构有助于语言模型克服其在顺序推理能力上的局限性。
下面是一个实现基本 ToT 提示的示例:
def tot_prompt(question, num_branches=3):
prompt = f"""Solve the following problem using a Tree-of-Thoughts approach:
Problem: {question}
Let's explore multiple reasoning paths:
Path 1:
1) First, we could...
2) Then, we might...
3) This leads us to...
Path 2:
1) Alternatively, we could start by...
2) Following this approach...
3) This results in...
Path 3:
1) Another perspective is...
2) If we consider this...
3) The outcome would be...
Now, let's evaluate these paths and determine the most promising solution:
Evaluation:
1) Path 1: ...
2) Path 2: ...
3) Path 3: ...
Based on this evaluation, the most promising solution is...
Therefore, the final answer is...
Now, apply this Tree-of-Thoughts approach to solve the given problem:
{question}
Let's explore multiple reasoning paths:
"""
return prompt
Let's look at an example usage:
problem = "What is the most efficient way to sort a list of a million integers?"
prompt = tot_prompt(problem)
print(prompt)
此函数为给定问题("What is the most efficient way to sort a list of a million integers?")生成一个 ToT 提示,为探索和评估多个推理路径提供结构。
此代码通过实现四个关键原则创建一个 ToT 提示模板:它通过具有不同起始短语和编号步骤的显式路径结构鼓励分支思维,确保模型探索多个不同的解决方案方法;它通过两次阐述问题来提供清晰度,以建立上下文并在生成解决方案之前重新聚焦注意力;它通过对比语言和独立的推理路径引导探索替代方法;并通过一个专门的比较部分以及选择最有希望解决方案的提示来促进评估。整体结构创建了一个认知支架,通过迫使模型在得出结论之前生成、发展和批判性地比较多个解决方案路径,帮助语言模型克服线性思维倾向——模仿人类通过发散性思维后进行批判性评估来解决复杂问题的方法。
实施有效的搜索策略对于导航 ToT 至关重要。让我们在下一节中检查其中两种策略。
搜索策略
我们有两种常用的搜索策略:
-
深度优先搜索 (DFS):这是一种图遍历算法,在回溯之前尽可能沿着每个分支进行探索。在思维树的情况下,DFS 系统性地深入一条路径,在移动到下一条路径之前,完全探索每个思想或分支。它通过从根节点开始,将每个节点的子节点推入栈中,然后递归地首先探索最深的节点来工作。这种方法在你想要全面探索一条推理线或调查最深刻或复杂的思想之前,非常适合在分支出来之前,对于问题解决、决策制定和理解复杂概念景观非常有价值。
-
广度优先搜索 (BFS):与 DFS 相比,BFS 通过系统地检查当前深度的所有相邻节点,然后移动到下一深度级别的节点来探索思维树。使用队列数据结构,BFS 从根节点开始,探索所有直接连接,然后再深入。在思想探索的背景下,BFS 特别适用于你想要获得不同想法及其直接相互连接的广泛全景视图时。这种策略对于理解思想的宽度和多样性、找到概念之间的最短路径,或者在你需要深入任何单个分支之前,同时探索多个潜在的推理路径时非常理想(参见 图 21.1)。

图 21.1 – DFS 与 BFS
例如,让我们实现一个简单的 DFS 策略:
from transformers import AutoModelForCausalLM, AutoTokenizer
def dfs_tot(model, tokenizer, problem, max_depth=3, max_branches=2):
def explore_branch(current_thought, depth):
if depth == max_depth:
return current_thought
prompt = f"{current_thought}\n\nLet's explore further:\n"
inputs = tokenizer(prompt, return_tensors="pt")
outputs = model.generate(
inputs, max_length=len(prompt) + 100,
num_return_sequences=max_branches
)
branches = [
tokenizer.decode(
output[len(inputs['input_ids'][0]):],
skip_special_tokens=True
) for output in outputs
]
results = []
for branch in branches:
results.append(
explore_branch(
current_thought + branch, depth + 1
)
)
return max(
results, key=lambda x: evaluate_thought(x)
) # Select the best branch
initial_prompt = tot_prompt(problem)
return explore_branch(initial_prompt, 0)
def evaluate_thought(thought):
# Implement logic to evaluate the quality of a thought
# This could involve coherence, relevance, depth of reasoning, etc.
pass
此代码实现了一个 DFS 算法来探索由语言模型生成的 ToT。它从一个初始问题开始,然后使用模型生成多个潜在的后继(分支)。代码递归地探索每个分支,扩展“思想”直到达到最大深度。在每一步,生成的文本被转换为模型输入,模型输出被解码回文本。
evaluate_thought 函数是选择过程中的关键部分,旨在评估每个生成思想的品质。代码利用这种评分来决定进一步探索哪些分支,有效地引导 ToT 向可能的最优解导航。最终结果是 DFS 过程中找到的最高评分思想。
这里是一个前面代码片段的示例用法:
model_name = "gpt2-large" # Replace with your preferred model
model = AutoModelForCausalLM.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
problem = "What are the potential long-term effects of artificial intelligence on employment?"
solution = dfs_tot(model, tokenizer, problem)
print(solution)
这个代码片段展示了如何使用预训练的 GPT-2 语言模型,通过之前描述的 dfs_tot 函数生成给定问题的解决方案。首先,它指定要使用的模型("gpt2-large"),并使用 transformers 库中的 AutoModelForCausalLM 和 AutoTokenizer 加载模型及其相关的标记器。这确保了文本被正确处理以供模型使用。
然后,它将问题定义为关于人工智能对就业的长期影响的疑问。使用加载的模型、分词器和问题作为输入调用dfs_tot函数,开始深度优先搜索解决方案。返回的solution代表模型在探索各种“思维”后生成的响应,最终打印到控制台。
接下来,我们将讨论在 ToT 框架内进行剪枝和评估以提高效率和集中搜索。剪枝对于管理探索众多思维分支相关的计算成本至关重要,而评估提供了决定哪些分支要丢弃的标准。
剪枝与评估
在 ToT 方法中,剪枝是一种通过系统地减少搜索空间来管理认知复杂度的有效机制。这个过程涉及通过智能评估技术选择性地消除不太有希望的思维分支,使用启发式评分方法来评估每条潜在路径导致最优解的可能性。通过动态过滤掉低潜力思维并集中计算资源在最有希望的推理轨迹上,ToT 剪枝能够实现更高效和有针对性的问题解决,平衡探索广度与推理深度。
-
让我们通过定义一个简单的剪枝函数来实现一个基本的剪枝策略:
def pruning_tot( model, tokenizer, problem, max_depth=3, max_branches=3, prune_threshold=0.5 ): def explore_and_prune(current_thought, depth): if depth == max_depth: return current_thought prompt = f"{current_thought}\n\nLet's explore further:\n" inputs = tokenizer(prompt, return_tensors="pt") outputs = model.generate( inputs, max_length=len(prompt) + 100, num_return_sequences=max_branches ) branches = [ tokenizer.decode( output[len(inputs['input_ids'][0]):], skip_special_tokens=True ) for output in outputs ]逻辑的核心在于
explore_and_prune函数,它处理推理树的递归搜索。代码通过使用 LLM 从当前思维生成多个可能的延续(分支)来工作。该函数旨在探索推理树直到指定的最大深度,每个级别包含受控数量的分支。当达到最大深度时,代码将当前思维作为最终结果返回。剪枝机制是说明性的,不应用于生产。 -
一旦我们定义了我们的函数,我们就评估和剪枝分支:
evaluated_branches = [ (branch, evaluate_thought(current_thought + branch)) for branch in branches ] pruned_branches = [ b for b, score in evaluated_branches if score > prune_threshold ] if not pruned_branches: return current_thought # If all branches are pruned, return current thought results = [] for branch in pruned_branches: results.append( explore_and_prune(current_thought + branch, depth + 1) ) return max(results, key=lambda x: evaluate_thought(x)) initial_prompt = tot_prompt(problem) return explore_and_prune(initial_prompt, 0)首先,代码通过将每个生成的分支与
evaluate_thought函数的分数配对来评估每个分支,该函数评估推理路径的质量。然后,它通过仅保留得分高于定义阈值的分支来过滤掉低质量的分支。如果所有分支都被剪枝(没有达到阈值),则算法返回当前思维而不再进一步探索。对于剩余的有希望的分支,代码通过在增加的深度级别上调用相同的函数递归地探索每个分支。最后,它通过从所有探索的路径中返回具有最高评估分数的结果来选择最佳的总体推理路径。外部函数使用包含原始问题声明的格式化提示初始化搜索。 -
定义一个
evaluate_thought函数。此函数通过根据其复杂性(长度)和语言多样性(使用的独特单词数量)评分来评估给定的思维或推理分支,返回介于0和1之间的归一化分数:def evaluate_thought(branch, threshold=0.5): """ Simple evaluation function for ToT branch assessment Args: branch (str): The branch/thought to evaluate threshold (float): Minimum score for considering a branch viable Returns: float: Evaluation score """ # Basic heuristics for evaluation complexity_score = len(branch.split()) / 20 # Reward moderate complexity uniqueness_score = len( set(branch.split())) / len(branch.split() ) # Reward unique words # Combined score, normalized score = (complexity_score + uniqueness_score) / 2 return min(1.0, max(0.0, score)) -
让我们看看一个例子:
problem = "What are the ethical implications of genetic engineering in humans?" solution = pruning_tot(model, tokenizer, problem) print(solution)
此实现添加了一个修剪步骤,以移除低质量的分支,将搜索集中在最有希望的路径上。
现在,让我们将 ToT 应用于解决一个多步骤问题。
将 ToT 应用于解决多步骤问题
ToT 对于复杂的推理任务特别有效。让我们实现一个用于多步骤问题解决的 ToT 方法:
def multi_step_tot(model, tokenizer, problem_steps):
full_solution = ""
for step, question in enumerate(problem_steps):
prompt = f"""Step {step + 1} of the problem:
{question}
Previous steps solution:
{full_solution}
Let's use Tree-of-Thoughts to solve this step:
"""
step_solution = pruning_tot(model, tokenizer, prompt)
full_solution += (
f"\n\nStep {step + 1} Solution:\n"
f"{step_solution}"
)
return full_solution
# Example usage
problem_steps = [
"What are the main factors contributing to climate change?",
"How do these factors interact with each other?",
"What are potential solutions to mitigate climate change?",
"What are the challenges in implementing these solutions?"
]
solution = multi_step_tot(model, tokenizer, problem_steps)
print(solution)
此代码实现了一个使用 ToT 推理方法的多步骤问题求解器。multi_step_tot 函数将复杂问题分解成一系列步骤,并逐个解决它们,基于之前的解决方案。
对于提供的每个问题序列步骤,该函数创建一个包含当前问题、之前步骤中累积的解决方案以及使用 ToT 推理的说明的提示。然后,它调用先前定义的 pruning_tot 函数来为该特定步骤生成解决方案。每个步骤的解决方案都附加到一个不断增长的 full_solution 字符串中,从而创建一个保持整个问题思维连贯性的综合答案。示例演示了如何通过一系列越来越深入的问题来分析气候变化,从识别原因到探索潜在解决方案的实施挑战。
实施挑战
虽然 ToT 很强大,但它面临着几个挑战:
-
计算复杂性:探索多个路径可能非常昂贵
-
评估难度:确定不同思维路径的质量可能具有挑战性
-
分支间的连贯性:确保结合不同分支的见解时的一致性
-
提示设计复杂性:创建有效的 ToT 提示需要仔细考虑
为了解决计算复杂性,考虑实现并行处理方法。并行处理可以通过解决其固有的计算瓶颈来提高 ToT 推理方法。以下代码实现了同时而不是顺序地并发探索多个推理分支,这可以显著减少复杂问题的总计算时间:
import concurrent.futures
def parallel_tot(model, tokenizer, problem, max_workers=3):
def explore_branch(branch):
return pruning_tot(model, tokenizer, branch)
initial_branches = generate_initial_branches(problem, max_workers)
with concurrent.futures.ThreadPoolExecutor(
max_workers=max_workers
) as executor:
futures = [
executor.submit(explore_branch, branch)
for branch in initial_branches
]
results = [
f.result()
for f in concurrent.futures.as_completed(futures)
]
return max(results, key=lambda x: evaluate_thought(x))
def generate_initial_branches(problem, num_branches):
# Implement logic to generate initial branches for the problem
pass
# Example usage
problem = "What are the potential implications of quantum computing on cryptography?"
solution = parallel_tot(model, tokenizer, problem)
print(solution)
在前面的代码中,实现使用了 Python 的concurrent.futures模块和ThreadPoolExecutor来将工作负载分配到多个工作者。每个工作者独立探索推理树的不同的初始分支,有效地并行搜索多个有希望的路径。这种方法对于 ToT 推理特别有价值,因为算法的分支特性创建了众多独立的子问题,可以在没有相互依赖的情况下并行解决各自的中间结果。最后一步通过从所有完成的分支中选择最高质量的解决方案来整合这些并行探索。
此实现使用并行处理同时探索多个分支,可能减少复杂 ToT 问题的计算时间。
未来方向
随着 ToT 的持续发展,出现了一些有前景的方向:
-
动态树结构:根据问题复杂度调整树结构。
-
混合 ToT-CoT 方法:结合两种技术的优势(
arxiv.org/html/2409.17433v1)。 -
ToT 的元学习:训练 LLMs 自动生成有效的 ToT 结构。这种方法尚未被任何人探索。
-
整合外部知识:将特定领域的知识整合到 ToT 推理中(
arxiv.org/html/2407.00653v1)。
下面是一个动态 ToT 结构的概念实现:
def dynamic_tot(model, tokenizer, problem, max_depth=5):
def adapt_structure(current_thought, depth):
if depth == max_depth:
return current_thought
complexity = assess_complexity(current_thought)
num_branches = determine_branches(complexity)
branches = generate_branches(
model, tokenizer, current_thought, num_branches
)
results = []
for branch in branches:
results.append(
adapt_structure(
current_thought + branch, depth + 1
)
)
return max(results, key=lambda x: evaluate_thought(x))
def assess_complexity(thought):
# Implement logic to assess the complexity of the current thought
pass
def determine_branches(complexity):
# Determine the number of branches based on complexity
return max(2, min(5, int(complexity 10)))
def generate_branches(model, tokenizer, thought, num_branches):
# Generate branches using the model
pass
initial_prompt = tot_prompt(problem)
return adapt_structure(initial_prompt, 0)
前面的代码实现了一个动态 ToT 方法,该方法根据当前推理路径的复杂度调整其探索策略。核心函数adapt_structure通过递归地检查每一步当前思维过程的复杂度,并动态确定要探索的分支数量来构建解决方案。与固定的分支策略不同,这种自适应方法为可能从更广泛探索中受益的复杂推理路径分配更多的计算资源(更多分支),而对于较简单的概念则使用较少的分支。实现包括辅助函数来评估思维复杂度、确定适当的分支数量以及使用语言模型生成新的思维延续。算法在达到最大深度时终止,并返回得分最高的完整推理路径。
下面是一个示例,说明如何使用前面的代码解决诸如“纳米技术的进步可能会在下一个十年如何影响医学?”这样的问题:
problem = "How might advancements in nanotechnology impact medicine in the next decade?"
solution = dynamic_tot(model, tokenizer, problem)
print(solution)
这种动态 ToT 方法根据评估的每个思维的复杂度调整树结构,允许更灵活和高效地探索复杂问题空间。
摘要
在本章中,你学习了如何为 LLM 设计和实现 ToT 提示,包括管理分支思维过程策略。我们涵盖了搜索技术和修剪和评估不同推理路径的方法。通过实施这里讨论的策略和考虑因素,你可以显著提高 LLM 处理模糊、多方面问题的能力,并生成更稳健和有洞察力的解决方案。
回顾第二十章,本章专注于 CoT,让我们从用例的角度比较 CoT 和 ToT。当任务涉及线性、顺序推理,并且可以分解为具有单一、主导解决方案路径的中间步骤时,使用 CoT 提示。CoT 在数学文字问题、演绎推理、基本逻辑谜题和逐步程序任务中特别有效。当问题具有低分支复杂度且不需要探索多个替代方案时,它工作得很好。CoT 在计算上更便宜,因为它以前向、确定性的方式产生单一的推理链。当 LLM 需要支架来“大声思考”并使其中间步骤明确以防止幻觉或逻辑错误时,这种技术最有帮助。
当任务涉及具有分支决策点的多步推理时,使用 ToT 提示,特别是当存在多个可能的解决方案路径需要并行评估时。ToT 适合创造性问题解决、规划任务、定理证明、代码合成以及在不确定性下的决策。当问题空间可以结构化为搜索树,其中中间推理节点可以重新访问、评估和比较时,它变得有利。ToT 通常包含诸如自洽采样、前瞻性评估和基于价值的分支选择等策略。由于它并行维护和扩展多个推理路径,包括回滚、回溯或节点评分,因此它在计算上更密集。
如果问题是受限制且形式良好的(例如,SAT 风格的问题或直接的推导),CoT 通常足够且更高效。如果问题是开放式的,具有多个冲突的目标,或者最优解需要比较替代路径(如在规划路线、游戏移动或形式证明中),ToT 通过模拟探索和深思熟虑,可以提供更好的性能。
在实践中,CoT 可以作为基础技术,而 ToT 通过协调多个链来构建在其之上。例如,ToT 节点可能每个都使用 CoT 内部生成连贯的思想。因此,这两个不是互斥的,但在复杂性和结构方面是层次相关的。
在下一章中,我们将探讨推理和行动(ReAct)模式,该模式在许多代理 AI 应用中普遍使用。
第二十二章:推理与行动
推理与行动(ReAct)是由普林斯顿大学和谷歌的研究人员开发的一种提示技术,它增强了 LLM 在模拟环境中进行推理和行动的能力(arxiv.org/pdf/2210.03629)。它允许 LLM 模仿现实世界中的类似人类操作,在那里我们用言语进行推理并采取行动以获取信息。ReAct 将推理和行动结合起来,以解决复杂语言推理和决策任务。
虽然 CoT 提示使 LLM 能够生成推理轨迹,但其无法访问外部世界可能导致事实虚构等问题。ReAct 通过允许 LLM 为任务生成口头推理轨迹和文本动作来解决此问题。这些文本动作使模型能够与其环境(例如,通过查询外部知识源或使用工具)交互,收集信息,并相应地调整其推理。
ReAct 的关键特性如下:
-
推理轨迹:LLM 生成逐步解释其思维过程的文本
-
动作生成:LLM 产生代表与外部工具或环境交互的文本动作
-
观察融合:动作(观察)的结果被反馈到 LLM 的上下文中,影响后续的推理和动作
-
迭代过程:ReAct 通常涉及多个思考/动作/观察步骤,允许动态问题解决
ReAct 在以下场景中表现出色:
-
当任务需要超出 LLM 预训练知识的信息时(例如,多跳问答或事实验证)
-
当一个 LLM 需要导航和与模拟环境(例如,在线购物或基于文本的游戏)交互时
-
当你需要结合 LLM 的力量与外部工具(例如,搜索引擎、计算器和 API)的能力时
-
当任务需要将问题分解成更小的步骤,并且必须根据中间结果做出决策时
在本章中,我们将涵盖以下主题:
-
在 LangChain 中实现 ReAct
-
使用 LangChain 的表达语言构建 ReAct 代理
-
完成任务和解决问题
-
评估 ReAct 的性能
-
安全性、控制和伦理考虑
-
局限性和未来方向
在 LangChain 中实现 ReAct
开源 LLM 框架 LangChain (www.langchain.com/) 通过其Agent类提供了一个强大且灵活的 ReAct 框架实现。让我们探索如何在 LangChain 中创建和使用 ReAct 代理:
-
安装必要的包:
duckduckgo-search and youtube_search integrate search engine functionalities, allowing language models to retrieve real-time information from the web and YouTube, respectively -
wikipedia使语言模型能够访问和利用维基百科的信息,扩大其知识库 -
langchainhub是一个用于共享和发现 LangChain 资产(如提示、链和代理)的中心仓库 -
初始化语言模型和工具,如
wikipedia、ddg-search和llm-math。这些在以下代码片段中列出:import os import getpass os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter Your OpenAI API Key:") from langchain.agents import load_tools from langchain.chat_models import ChatOpenAI # load the language model, you can use any model you like llm = ChatOpenAI(model = "gpt-4o", temperature=0) # load tools tools = load_tools(['wikipedia', 'ddg-search','llm-math'], llm=llm)在这里,我们从
langchain导入必要的模块。然后,使用指定的模型(gpt-4-1106-preview和temperature)初始化语言模型(ChatOpenAI)。最后,我们加载一些代理将使用的工具。 -
初始化 ReAct 代理。在这里,
initialize_agent函数创建并初始化了一个代理:from langchain.agents import initialize_agent from langchain.agents import AgentType # initialize agent agent = initialize_agent( tools, llm, agent=AgentType.ZERO_SHOT_REACT_ DESCRIPTION, verbose=True )在前面的代码中,我们列出了
tools和llm,其中llm指的是语言模型,并指定代理类型为AgentType.ZERO_SHOT_REACT_DESCRIPTION。在这里,verbose=True启用了代理思维过程的详细记录。 -
检查 ReAct 代理的提示。以下行打印 ReAct 代理使用的提示模板。此提示为 LLM 提供了如何使用可用工具并遵循 ReAct 格式(Thought,Action,Action Input 和 Observation)的说明:
print(agent.agent.llm_chain.prompt.template)检查 ReAct 代理的提示非常重要,因为它揭示了指导语言模型在工具辅助推理和动作期间行为的结构和逻辑。通过使用
print(agent.agent.llm_chain.prompt.template)打印提示模板,您不仅看到了任意的指令,而且检查了控制代理如何序列其推理和工具使用的行动框架。这包括它如何解释用户查询、从其可用动作集中选择工具、构建工具的输入,以及将工具的输出(观察)整合到进一步的推理中。如果提示构建不当,模型可能会误解工具、采取无效的动作或无法连贯地串联思维。此外,模板通常包括少量示例,展示如何正确地在 ReAct 组件之间交替。这些示例作为格式和逻辑的隐式指令,帮助模型泛化到未见过的任务。检查它们可以揭示代理是否使用通用模式或高度具体的用例进行训练或指导。它还有助于开发者调试意外的行为或幻觉,因为直接修改模板会直接影响代理的动作选择、推理的准确性以及与预期 ReAct 循环的整体一致性。 -
以下代码块演示了如何自定义提示模板。您可以修改说明、示例和格式以更好地适应您的特定用例:
prompt = """ You are an intelligent agent designed to solve complex queries by breaking them down systematically and using available tools strategically. Follow the ReAct (Reasoning and Acting) framework to approach each task. ReAct Principles: 1\. Reasoning: Always start by carefully analyzing the question and developing a clear, step-by-step thought process. 2\. Tool Selection: Critically evaluate which tools will be most effective for addressing the specific query. 3\. Iterative Interaction: Be prepared to cycle between reasoning and action multiple times, refining your approach as you gather more information. 4\. Comprehensive Understanding: Aim to not just find an answer, but to truly comprehend the underlying context and nuances of the question. 5\. Transparent Decision-Making: Clearly articulate your reasoning, actions, and thought process at each step. Available Tools: - Wikipedia: Retrieve factual information about people, places, historical events, and general knowledge topics. - Google Search: Fetch current information, recent events, and up-to-date context. - Calculator: Perform mathematical calculations and numerical analysis. Interaction Format: Question: The specific query to be solved Thought: Detailed reasoning about the approach, breaking down the problem Action: Selected tool (Wikipedia/Google Search/Calculator) Action Input: Precise query for the selected tool Observation: Results obtained from the tool ... (Repeat reasoning, action, and observation as needed) Thought: Final synthesized understanding Final Answer: Comprehensive and well-reasoned response to the original question Important Guidelines: - Be methodical and explicit in your reasoning - Use tools judiciously and avoid unnecessary actions - Integrate information from multiple sources when appropriate - Provide a clear, concise, and informative final answer Begin! Question: {input} Thought:{agent_scratchpad} """在这里,
agent.agent.llm_chain.prompt.template = prompt更新了代理的提示为自定义模板。 -
接下来,您可以修改工具的描述,为 LLM 提供更具体的指导,说明何时以及如何使用每个工具:
tools[1].description = "A date retrieval tool that provides the current date and time, useful for temporal queries, scheduling, age calculations, or understanding time-sensitive contexts." tools[2].description = "A powerful computational tool capable of performing various mathematical operations, including arithmetic calculations, algebraic computations, percentage calculations, unit conversions, and advanced mathematical functions." -
以下行执行代理以一个示例查询。代理将使用 ReAct 框架进行推理、选择工具、执行动作并生成最终答案:
agent.run("What is the population of the largest city in Canada? How many days would it take for that city's population to count to 1 billion if each person counts one number per second without breaks? Then, compare this time to the average lifespan of a human in years, and explain which is longer.")
接下来,我们将通过一个示例来查看如何使用 ReAct 进行文档处理,该示例利用了 LangChain。
ReAct 文档存储
LangChain 还提供了一个 DocstoreExplorer 类,用于实现与维基百科等文档存储的 ReAct 逻辑。我们将通过使用 DocstoreExplorer 和维基百科进行文档式 ReAct 的示例来演示:
from langchain import Wikipedia
from langchain.agents import initialize_agent, Tool
from langchain.agents import AgentType
from langchain.agents.react.base import DocstoreExplorer
docstore = DocstoreExplorer(Wikipedia())
search_tool = Tool(name="Search",
func=docstore.search,
description="Search for latest information about any topic"
)
lookup_tool = Tool(name="Lookup",
func=docstore.lookup,
description="Lookup tool for get information from a keyword"
)
tools = [search_tool, lookup_tool]
llm = OpenAI(temperature=0)
react = initialize_agent(tools,
llm,
agent=AgentType.REACT_DOCSTORE,
verbose=True)
question = "Who is the current governor of Texas and when was he born ?"
react.run(question)
这段代码设置了一个 LangChain 代理,该代理通过与维基百科交互来回答问题。以下是分解:
-
维基百科访问:首先,它初始化与维基百科的连接,使代理能够从中检索信息。
-
搜索和查找。搜索工具使代理能够找到相关的维基百科页面,而查找工具则允许它从这些页面中提取特定信息。 -
AgentType.REACT_DOCSTORE明确配置代理以进行文档存储交互 – 在这种情况下,是维基百科的。 -
使用
搜索工具查找相关页面和查找工具提取答案。
使用 LangChain 的表达式语言构建 ReAct 代理
LangChain 表达式语言(LCEL)提供了一种声明式方法来构建 ReAct 代理。LCEL 允许你定义一个处理图,该图处理用户输入、推理、动作选择和最终响应生成。本节演示了如何使用这个强大的框架实现 ReAct 代理。
核心思想是建立一个数据管道,该管道接收用户的查询,使用 LLM 通过一系列步骤进行推理,可能利用外部工具,并最终得出答案。这个管道可以使用 LCEL 简洁地表达。
以下是一个 Python 代码示例,演示了此过程:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import chain
from langchain.agents.format_scratchpad import format_log_to_str
from langchain.agents.output_parsers import(
ReActSingleInputOutputParser)
from langchain.tools import DuckDuckGoSearchRun
from langchain_openai import ChatOpenAI
# 1\. Define Tools: In this simple example, we are using a search tool.
tools = [DuckDuckGoSearchRun()]
# 2\. Construct the Prompt: Instead of pulling from a hub, we'll define a basic prompt template.
template = """Answer the following questions as best you can. You have access to the following tools:
{tool_descriptions}
Use the following format:
Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [{tool_names}]
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question
Begin!
Question: {input}
{agent_scratchpad}"""
prompt = ChatPromptTemplate.from_template(template)
prompt = prompt.partial(
tool_names=", ".join([t.name for t in tools]),
tool_descriptions="\n".join(
[f"{t.name}: {t.description}" for t in tools]
),
)
# 3\. Instantiate the LLM: We use ChatOpenAI, but any LLM can be used.
llm = ChatOpenAI(temperature=0)
# We also configure it to stop when it sees '\nObservation:'
llm_with_stop = llm.bind(stop=["\nObservation:"])
# 4\. Construct the Agent Pipeline using LCEL:
agent = (
{
"input": lambda x: x["input"],
"agent_scratchpad": lambda x:
format_log_to_str(x["intermediate_steps"]),
}
| prompt
| llm_with_stop
| ReActSingleInputOutputParser()
)
让我们更详细地看看这个设置:
-
定义了一个自定义提示模板来指导 LLM 的推理和动作选择,而不是从中心获取一个。这个模板指导 LLM 关于交互预期的格式(问题,思考,动作,观察,最终答案)。
-
ChatOpenAI 作为 LLM,配置为在遇到
\nObservation:字符串时停止生成。这个信号表示代理已完成动作并等待结果。 -
代理管道是通过 LCEL 构建的,这是链式操作(
|)。这个管道协调信息流:-
它格式化输入和代理的草稿本(之前的推理步骤)
-
它将格式化的输入馈送到提示
-
LLM,根据其配置的停止标准,处理提示
-
最后,
ReActSingleInputOutputParser解析 LLM 的输出,区分要采取的动作和最终答案
-
解释 ReActSingleInputOutputParser
这个组件对于解释 LLM 的输出并确定 ReAct 循环中的下一步至关重要:
-
实例化:您创建解析器的实例,准备处理 LLM 生成的文本
-
AgentAction对象(请求执行工具)或AgentFinish对象(提供最终答案)-
如果它检测到
AgentAction,则提取工具的名称和传递给工具的输入 -
如果它找到
AgentFinish,则提取最终答案
-
-
AgentAction或AgentFinish -
Action:或Final Answer:),解析器引发异常,表明 LLM 的推理或提示存在问题
使用 AgentExecutor 运行代理
在以下代码中,AgentExecutor是一个负责管理代理动作执行(基于代理的决策过程选择)的组件。它作为代理的驱动程序,促进代理与外部工具之间的交互。
这里有一个例子:
from langchain.agents import AgentExecutor
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
response = agent_executor.invoke(
{
"input": "Who is the current CEO of Microsoft and what is their age squared?"
}
)
print(response)
这里发生以下情况:
-
我们创建一个
AgentExecutor实例,向其提供我们之前定义的代理管道和可用工具,然后在设置verbose=True以查看代理的思维过程之前。 -
agent_executor.invoke方法启动流程。它接受一个包含用户输入的字典("input": "Who is the current CEO of Microsoft and what is their age squared?")。 -
然后,
AgentExecutor管理 ReAct 循环:-
它将输入传递给代理管道。
-
代理(LLM 和解析器)决定采取的行动(例如,使用搜索工具查找 CEO 的名字)。
-
AgentExecutor执行动作(调用搜索工具)。 -
它将结果作为“
Observation”返回给代理。 -
此过程会重复进行,直到代理决定它已经拥有足够的信息来生成最终答案。
-
此示例演示了使用 LCEL 构建的 ReAct 代理的基本结构。它展示了如何通过结合提示、语言模型、解析器和外部工具来定义一个清晰、模块化的管道,以实现复杂的推理任务。这种方法促进了代码的可读性、可维护性和设计智能代理的灵活性。这个特定的例子询问微软的现任 CEO 是谁,然后计算他们的年龄平方,展示了从名称回忆到算术计算的简单多轮推理。
完成任务和解决问题
ReAct 框架,凭借其整合推理和行动的能力,在各种任务完成和问题解决场景中高度适用:
-
带有外部知识的问答(QA):ReAct 可用于创建可以访问和推理外部知识源(如维基百科或搜索引擎)的 QA 系统,以提供更准确和更新的答案
-
网页导航和交互:ReAct 代理可以导航网站,与网页元素交互,并收集信息,从而实现自动化网页研究、数据抓取和在线购物辅助等任务
-
软件应用控制:通过集成 API 和工具,ReAct 代理可以控制软件应用,自动化工作流程,并执行需要与多个系统交互的复杂任务
-
机器人和物理世界交互:虽然 LLM 主要在文本领域运行,但 ReAct 原则可以扩展到控制机器人或其他物理系统,其中行动涉及物理运动或与真实世界的交互
-
多步问题解决:ReAct 非常适合需要将复杂问题分解为更小步骤、对每个步骤进行推理、采取行动并使用观察结果来指导后续步骤的任务
评估 ReAct 的性能
评估 ReAct 代理涉及评估推理的质量和采取行动的有效性。以下指标可以用来评估:
-
成功率:代理成功完成的任务百分比
-
效率:完成任务所需的步骤数量或时间
-
推理准确性:LLM 推理轨迹的正确性和相关性
-
行动相关性:代理选择的行动的适当性
-
观察利用:代理如何有效地将其观察结果纳入后续推理和行动
-
错误分析:识别代理性能中的常见故障模式或弱点
让我们考虑一些可以使用的评估技术:
-
人工评估:让人类专家评估代理的推理、行动和最终输出
-
自动化指标:使用自动化脚本或 LLM 评估代理性能的特定方面,例如答案的正确性或行动的相关性
-
基准测试:将代理的性能与预定义的基准或其他代理在标准化任务上的性能进行比较
-
消融研究:系统地删除或修改 ReAct 框架的组件(例如,删除推理步骤)以了解其对整体性能的贡献
安全、控制和伦理考量
ReAct 系统,尤其是当与外部工具集成时,会引发一些安全、控制和伦理问题:
-
不可预测的行为:LLM 推理和外部工具使用的组合可能导致不可预测或非预期的行为
-
行动的安全性:代理采取的行动可能产生现实世界的后果,特别是如果代理连接到可以影响物理世界的系统
-
偏见和公平性:ReAct 代理可能会继承并放大 LLM 或他们使用的外部工具训练数据中存在的偏见
-
滥用潜力:恶意行为者可能将 ReAct 代理用于有害目的,例如生成虚假信息或自动化攻击
-
责任归属:由于底层 LLM 模型的不确定性,确定 ReAct 代理的行为和决策的责任可能具有挑战性
以下是一些缓解这些问题的策略:
-
沙盒:在隔离环境中运行 ReAct 代理以限制其潜在影响
-
人工监督:将人工审查和批准纳入 ReAct 流程,特别是对于关键决策或动作
-
安全规则和约束:实施规则和约束以防止代理采取有害或不道德的行动
-
监控和审计:持续监控代理的行为并维护日志以供审计
-
透明度和可解释性:设计 ReAct 代理,使其能够解释其推理和决策过程,以提高理解和信任
局限性和未来方向
虽然 ReAct 是一个强大的框架,但它有一定的局限性:
-
对外部工具的依赖:ReAct 的有效性部分取决于它所使用的工具的能力和可靠性
-
错误传播:工具使用或观察解释中的错误可能会在推理过程中传播,导致得出错误结论或采取错误行动
-
标记限制:ReAct 的迭代性质可能导致文本序列过长,可能超过某些大型语言模型(LLMs)的标记限制
-
计算成本:多轮推理、动作和观察可能具有很高的计算成本,尤其是在使用 LLMs 或复杂工具时
-
提示工程挑战:设计有效的 ReAct 提示,以正确引导 LLM 的推理和动作选择可能具有挑战性,可能需要进行实验
图 22.1 显示了 ReAct 模式的局限性:

图 22.1 – ReAct 模式的局限性
然而,通过结合 LLMs 的力量和采取行动以及整合外部信息的能力,ReAct 为创建更强大和通用的 AI 系统提供了新的可能性:
-
改进的工具集成:开发更无缝和稳健的方法来集成 LLMs 与外部工具
-
增强推理能力:将 ReAct 与其他高级推理技术(如 ToT)相结合,以处理更复杂的场景
-
从经验中学习:使 ReAct 代理能够从过去的交互中学习并随着时间的推移提高其性能
-
多代理 ReAct:探索多个 ReAct 代理协作或竞争以解决问题的场景
-
现实世界部署:超越模拟环境,将 ReAct 代理部署到具有适当安全和控制机制的现实中应用
概述
在本章中,你学习了 ReAct 框架,这是一种强大的技术,可以提示你的 LLMs 不仅能够通过复杂场景进行推理,还能规划和模拟动作的执行,类似于人类在现实世界中的操作。
ReAct 框架代表了在开发能够推理、规划和与环境交互的智能代理方面的重大进步。ReAct 也可以被视为更高级框架如无需观察的推理(ReWOO)的先驱,我们将在下一章中探讨这一点。
第二十三章:无观察推理
无观察推理(ReWOO),由 Xu 等人提出(arxiv.org/abs/2305.18323),是一个结合多步规划和变量替换以有效使用工具的框架。它旨在通过在单次遍历中生成工具使用的完整链来减少令牌消耗和执行时间,从而最小化冗余的 LLM 调用。它还旨在通过允许在不实际调用工具的情况下进行微调来简化微调过程,因为规划数据在理论上不依赖于工具输出。
ReAct 通过循环的“思考-行动-观察”模式运作,其中人工智能参与推理,执行行动,检查产生的反馈,然后相应地调整其后续行动,从而促进动态和响应性的问题解决策略。相比之下,ReWOO 强调全面的前期规划,在执行之前生成一系列完整的行动,从而最大限度地减少持续观察和反馈的必要性。这种区别使得 ReWOO 可以通过减少令牌消耗和计算成本,从 ReAct 的迭代反馈循环转向更简化的“计划-行动”方法来追求更高的效率。
因此,ReWOO 指的是 LLM 对其未直接观察或训练过的情景进行推理、预测或决策的能力。ReWOO 通过将外部工具使用纳入推理过程来增强这一点。
ReWOO 能够在不直接观察的情况下进行规划和推理,这使得它适合复杂规划和决策任务:
-
战略规划:如前所述,ReWOO 可以根据假设情况、目标和约束生成战略计划
-
情景分析:ReWOO 可以探索给定情景的多种潜在结果,考虑各种因素和不确定性
-
资源分配:通过规划工具使用并对其结果进行推理,ReWOO 可以在复杂环境中优化资源分配
-
风险评估:ReWOO 可以通过模拟不同场景及其后果来帮助评估潜在风险并制定缓解策略
在本章中,我们将涵盖以下主题:
-
使用 LangGraph 实现 ReWOO
-
ReWOO 的优势
-
评估质量和伦理考量
-
未来方向
使用 LangGraph 实现 ReWOO
LangGraph 是一个开源框架,旨在使用 LLM 构建具有状态的、多代理应用程序。它通过引入有向图模型扩展了 LangChain 生态系统的功能,其中节点代表函数(包括 LLM 调用),边代表基于逻辑、条件或内存的状态之间的转换。与传统顺序链相比,LangGraph 允许涉及条件分支、循环、内存传递和异步代理协调的复杂工作流程。LangGraph 特别适用于实现交互动态、迭代且依赖于状态变化的系统。这包括多代理协作、决策树、具有控制流的检索增强生成以及需要回顾先前步骤或循环子任务直到达到某些目标的自主代理。
LangGraph 利用图论和自动机概念,将执行流程表示为状态机或有向无环图(或需要循环时为循环图)。开发者定义一个包含节点(函数或工具)、边(状态转换)和条件(路由逻辑)的图。运行时引擎随后根据输入执行图,并在每一步更新状态。
LangGraph 支持同步和异步执行,并且与 LangChain 的组件(如工具、内存和代理)集成。它还支持流式响应、对状态的精细控制以及多模态输入/输出,使其适用于生产级应用。
实际上,LangGraph 用于构建具有不同代理交互、协调和共享内存的代理系统,同时仍遵循定义良好的计算图。这使得它与简单的代理循环或无结构的 LLM 调度方法不同。
LangGraph 可在 github.com/langchain-ai/langgraph 获取,并支持基于 Python 的实现,核心依赖 LangChain 和状态机执行框架。
ReWOO 架构由三个模块组成:
-
search_result或price,AI 可以构建一个清晰的任务蓝图,将动态信息的解决推迟到可用时,从而简化规划过程并避免不必要的计算。 -
Worker:使用提供的参数执行工具,可能使用之前步骤中的变量替换。
-
Solver:根据工具观察结果和计划生成最终答案。
这种架构最初可能看起来有些抽象。让我们使用 LangGraph 实现 ReWOO。我们将以 Tavily 搜索引擎作为一个示例工具:
-
安装必要的包并设置 API 密钥:
# %pip install -U langgraph langchain_community langchain_openai tavily-pythonTavily 是专为 AI 代理设计的搜索引擎。它旨在提供准确可靠的信息检索,满足执行复杂任务的 AI 系统的需求(见
tavily.com/)。以下脚本设置 API 密钥的环境变量,如果尚未定义:
import getpass import os def _set_if_undefined(var: str): if not os.environ.get(var): os.environ[var] = getpass.getpass(f"{var}=") _set_if_undefined("TAVILY_API_KEY") _set_if_undefined("OPENAI_API_KEY") -
接下来,定义图状态。为此,定义状态字典,使其可以保存任务、计划、步骤、结果和最终结果:
from typing import List from typing_extensions import TypedDict class ReWOO(TypedDict): task: str plan_string: str steps: List results: dict result: str -
创建规划提示和逻辑:
from langchain_openai import ChatOpenAI model = ChatOpenAI(model="gpt-4o") prompt = """For the following task, create a series of plans that can solve the problem step-by-step. For each plan, specify which external tool and its corresponding input should be used to gather evidence. You can store the evidence in a variable #E (e.g., #E1, #E2, #E3, etc.) that can be referenced by subsequent tools. Note that all the variables are independent, so make sure to include all necessary information in each tool input. Tools can be one of the following: Google[input]: A search engine worker that retrieves results from Google. Use this when you need concise answers or information about a specific topic. The input should be a search query. LLM[input]: A pretrained Large Language Model (like me). Use this when you need to leverage general world knowledge, common sense, or perform complex reasoning. Prioritize this tool when you are confident in solving the problem without external assistance. The input can be any instruction or question. Calculator[input]: A tool that can perform mathematical calculations. Use this when you need to perform arithmetic operations. The input should be a valid mathematical expression. WolframAlpha[input]: A computational knowledge engine. Use this when you need to solve equations, perform symbolic calculations, or get data-driven answers. The input should be a query in Wolfram Language or natural language related to a math or science problem. For example, Task: Alice, Bob, and Carol earned a total of $540 from their part-time jobs last week. Alice earned y dollars. Bob earned $20 more than three times what Alice earned, and Carol earned $15 more than Bob. How much money did Carol earn? Plan: Given Alice earned y dollars, translate the problem into algebraic expressions and solve with Wolfram Alpha. #E1 = WolframAlpha[Solve y + (3y + 20) + ((3y + 20) + 15) = 540] Plan: Find out the amount of money Alice earned. #E2 = LLM[What is y, given #E1] Plan: Calculate the amount of money Carol earned. #E3 = Calculator[((3 * #E2) + 20) + 15] Begin! Describe your plans with rich details. Each Plan should be followed by only one #E. Task: {task}""" -
为规划器创建一个 LangGraph 节点:
import re from langchain_core.prompts import ChatPromptTemplate regex_pattern = ( r"Plan:\s*(.+)\s*(#E\d+)\s*=\s*(\w+)\s*" r"\[([^\]]+)\]" ) prompt_template = ChatPromptTemplate.from_messages( [("user", prompt)] ) planner = prompt_template | model def get_plan(state: ReWOO): task = state["task"] result = planner.invoke({"task": task}) matches = re.findall(regex_pattern, result.content) return {"steps": matches, "plan_string": result.content} -
实例化搜索引擎并定义工具执行逻辑:
from langchain_community.tools.tavily_search import TavilySearchResults search = TavilySearchResults() def _get_current_task(state: ReWOO): if "results" not in state or state["results"] is None: return 1 if len(state["results"]) == len(state["steps"]): return None else: return len(state["results"]) + 1 def tool_execution(state: ReWOO): _step = _get_current_task(state) _, step_name, tool, tool_input = state["steps"][_step - 1] _results = (state["results"] or {}) if "results" in state else {} for k, v in _results.items(): tool_input = tool_input.replace(k, v) if tool == "Google": result = search.invoke(tool_input) elif tool == "LLM": result = model.invoke(tool_input) else: raise ValueError _results[step_name] = str(result) return {"results": _results} -
创建求解器提示和逻辑:
solve_prompt = """Solve the following task or problem. To solve the problem, we have made step-by-step Plan and \ retrieved corresponding Evidence to each Plan. Use them with caution since long evidence might \ contain irrelevant information. {plan} Now solve the question or task according to provided Evidence above. Respond with the answer directly with no extra words. Task: {task} Response:""" def solve(state: ReWOO): plan = "" for _plan, step_name, tool, tool_input in state["steps"]: _results = ( (state["results"] or {}) if "results" in state else {} ) for k, v in _results.items(): tool_input = tool_input.replace(k, v) step_name = step_name.replace(k, v) plan += ( f"Plan: {_plan}\n" f"{step_name} = {tool}[{tool_input}]\n" ) prompt = solve_prompt.format(plan=plan, task=state["task"]) result = model.invoke(prompt) return {"result": result.content} -
构建 LangGraph 工作流程:
def _route(state): _step = _get_current_task(state) if _step is None: return "solve" else: return "tool" from langgraph.graph import END, StateGraph, START graph = StateGraph(ReWOO) graph.add_node("plan", get_plan) graph.add_node("tool", tool_execution) graph.add_node("solve", solve) graph.add_edge("plan", "tool") graph.add_edge("solve", END) graph.add_conditional_edges("tool", _route) graph.add_edge(START, "plan") app = graph.compile()提供的代码使用
StateGraph,一种用于管理多步过程的数据结构,建立了一个 AI 工作流程。_route函数充当条件导演,根据当前状态确定下一步。它检查是否需要进一步的基于工具的操作;如果不需,则将流程路由到"solve"节点以生成最终答案。否则,将其路由到"tool"节点以执行工具。在这里,
StateGraph定义了执行流程:从创建策略的"plan"开始,然后到使用外部工具的"tool",最后到生成结果的"solve",最终达到END状态。"tool"节点内_route函数的条件逻辑是关键,它允许根据任务进度进行动态路由。StateGraph对于结构化工作流程管理至关重要,它使 AI 行为能够进行条件分支,特别是在依赖工具的任务中。它确保逻辑动作顺序,提高鲁棒性和清晰度,并促进 ReWOO 的计划执行。将图编译成"app"使其可执行。 -
让我们看看一个示例用例,并测试 ReWOO 代理:
task = "what is the exact hometown of the 2024 mens australian open winner" for s in app.stream({"task": task}): print(s) print("---")
前面的代码提供了一个使用 LangGraph 的 ReWOO 框架的简单实现。它定义了状态、规划器、执行器和求解器模块,并将它们连接到一个图中。此示例用法演示了如何在样本任务上运行代理。
ReWOO 的优势
ReWOO 相对于传统的 ReAct 风格代理具有几个优势:
-
减少令牌消耗和执行时间:通过单次遍历生成整个计划并使用变量替换,ReWOO 最小化了冗余的 LLM 调用和上下文传递
-
简化微调:规划数据与工具输出的独立性(理论上)允许进行微调,而无需调用工具
-
高效的 LLM 调用:与 ReACT 范式相比,LLM 工具接收的提示更少,使调用更高效
评估质量和伦理考量
评估 ReWOO 推理的质量可能具有挑战性,因为它经常涉及假设情景。可能的方法包括以下:
-
人工评估:使用人类专家评估生成的计划和推理的连贯性、相关性和完整性
-
与真实结果的比较:对于已知结果的情况,ReWOO 的预测可以与实际结果进行比较
-
基准测试:使用旨在评估抽象推理和规划能力的标准化测试集
在进行任何评估时,也必须牢记道德考量:
-
偏差放大:ReWOO 可能会继承并放大底层 LLM 训练数据中存在的偏差
-
滥用潜力:生成计划和推理假设情景的能力可能会被用于恶意目的
-
过度依赖:用户可能会过度信任 ReWOO 的输出,而未考虑其推测性质
未来方向
随着研究的进展,ReWOO 和相关技术可能会在更强大和多功能的人工智能系统的发展中扮演越来越重要的角色。以下是一些 ReWOO 的有希望的发展方向:
-
人机交互系统:将人类监督和反馈集成到 ReWOO 框架中,以提高准确性和解决道德问题
-
改进的规划算法:开发更复杂的规划算法,能够处理更复杂的场景和更大的搜索空间
-
增强的工具集成:无缝集成更广泛范围的工具,包括专门的 API 和知识库
-
多智能体协作:使多个 ReWOO 智能体能够协作完成复杂任务,可能带来更稳健和多样化的解决方案
-
元学习:应用元学习技术以提高智能体随着时间的推移在新的场景中泛化和适应的能力
摘要
本章深入探讨了 ReWOO,这是一个旨在赋予 LLM 推理假设情景和有效利用外部工具能力的框架。ReWOO 利用多步骤规划器配合变量替换,使其能够在单次遍历中生成全面的行为计划,从而与 ReAct 智能体的迭代“思考-行动-观察”循环相比,最小化令牌消耗和执行时间。本章通过 LangGraph 展示了 ReWOO 的实现,突出了其架构、组件(规划器、工作者、求解器)以及优势,如简化微调和高效的 LLM 调用。
除了简单地重复框架的机制之外,本章强调了 ReWOO 在战略规划、情景分析、资源分配和风险评估方面的潜力。然而,它也触及了围绕 ReWOO 的关键道德考量,包括偏差放大、滥用和过度依赖其输出的可能性。本章以展望未来结束,讨论了需要人机交互系统、改进的规划算法、增强的工具集成、多智能体协作以及应用元学习技术来进一步细化 ReWOO 的能力,并确保其在现实场景中的负责任应用。
在下一章中,我们将讨论使 LLM 能够进行自我反思和迭代改进的技术。
第二十四章:反思技巧
LLM 中的反思指的是模型分析、评估和改进其自身输出的能力。这种元认知能力使 LLM 能够参与迭代细化,可能带来更高品质的结果和更稳健的性能。
反思有几个关键方面:
-
输出的自我评估
-
识别弱点或错误
-
生成改进策略
-
响应的迭代细化
在这里,我们将探讨使 LLM 能够参与自我反思和迭代改进的技术。
在本章中,我们将涵盖以下主题:
-
设计自我反思的提示
-
实施迭代细化
-
纠正错误
-
评估反思的影响
-
实施有效反思的挑战
-
未来方向
设计自我反思的提示
为了鼓励大型语言模型(LLM)进行反思,提示应该设计成达到以下目的:
-
请求初始响应。
-
自我评估提示
-
鼓励识别改进领域。
-
引导模型生成细化输出。
下面是一个实施反思提示的例子:
def Reflection_prompt(task, initial_response):
prompt = f"""Task: {task}
Initial Response:
{initial_response}
Now, let's engage in self-reflection:
1\. Evaluate the strengths and weaknesses of your initial response.
2\. Identify any errors, inconsistencies, or areas for improvement.
3\. Suggest specific ways to enhance the response.
4\. Provide a revised and improved version of the response.
Your self-reflection and improved response:
"""
return prompt
# Example usage
task = "Explain the concept of quantum entanglement to a high school student."
initial_response = "Quantum entanglement is when two particles are connected in a way that measuring one instantly affects the other, no matter how far apart they are."
prompt = Reflection_prompt(task, initial_response)
print(prompt)
此代码定义了一个名为Reflection_prompt的函数,用于生成一个用于改进对任务初始响应的自我反思提示。它遵循在提示工程中常用的结构化元认知方法,以增强输出的质量,特别是对于 AI 系统或人机交互工作流程。
例如,给定任务“向高中生解释量子纠缠的概念”和初始响应“量子纠缠是指两个粒子以一种方式连接,测量其中一个粒子会立即影响另一个粒子,无论它们相隔多远”,生成的提示通过要求评估、识别问题、改进建议和修订版本来鼓励自我反思。模型可能会通过承认虽然原始解释简洁直观,但缺乏精确性,并可能暗示超光速通信来回应。然后,它可能提供一个使用更清晰的类比来强调共享量子状态而不是因果影响的修订解释。
为了程序化处理此类响应,响应处理器可以使用正则表达式对文本进行分段,以提取与评估、问题、建议和修订答案对应的编号部分。这种解析结构允许下游系统记录反思、比较版本或使用改进的响应在后续步骤中,支持迭代细化或监督学习场景的工作流程。
实施迭代细化
迭代细化是一个通过重复的自我评估和修订周期逐步改进模型响应的过程。每个周期使用反思提示来引导模型批判和改进其先前的输出,旨在收敛到一个更准确或更清晰的结果。
要实现迭代细化,我们可以创建一个循环,该循环反复应用反射过程。以下是一个示例:
-
定义
iterative_Reflection函数:from transformers import AutoModelForCausalLM, AutoTokenizer def iterative_Reflection( model, tokenizer, task, max_iterations=3 ): response = generate_initial_response(model, tokenizer, task) for i in range(max_iterations): prompt = Reflection_prompt(task, response) inputs = tokenizer(prompt, return_tensors="pt") outputs = model.generate( inputs, max_length=1000, num_return_sequences=1 ) reflection = tokenizer.decode(outputs[0], skip_special_tokens=True) # Extract the improved response from the reflection response = extract_improved_response(reflection) if is_satisfactory(response): break return response在前面的代码中,
iterative_Reflection函数使用为给定任务生成的基线响应初始化。然后它进入一个循环,其中每个迭代将当前响应输入到一个结构化的自我反思提示中。模型处理此提示以生成修改后的响应,然后使用is_satisfactory()对其质量进行评估。如果响应满足标准,则循环提前退出。否则,它将继续细化,直到达到定义的迭代限制,返回最终的改进响应。 -
定义其他用于反思响应的函数:
def generate_initial_response(model, tokenizer, task): prompt = f"Task: {task}\n\nResponse:" inputs = tokenizer(prompt, return_tensors="pt") outputs = model.generate(inputs, max_length=500, num_return_sequences=1) return tokenizer.decode(outputs[0], skip_special_tokens=True) def extract_improved_response(reflection): # Implement logic to extract the improved response from the reflection # This could involve text parsing or using markers in the generated text pass def is_satisfactory(response): # Implement logic to determine if the response meets quality criteria # This could involve length checks, keyword presence, or more advanced metrics passgenerate_initial_response函数从任务构建一个简单的提示,并将其传递给语言模型以生成基线答案,然后从标记 ID 解码为文本。extract_improved_response函数是一个占位符,旨在从完整的反射输出中隔离修改后的答案,通常通过解析或预定义标记来实现。同样,is_satisfactory作为一个可定制的检查点,用于评估当前响应是否满足特定的质量标准,如内容准确性、完整性或连贯性,允许在达到足够答案时提前终止迭代细化。 -
这里是一个定义的代码块的使用示例:
model_name = "gpt2-large" # Replace with your preferred model model = AutoModelForCausalLM.from_pretrained(model_name) tokenizer = AutoTokenizer.from_pretrained(model_name) task = "Explain the process of photosynthesis in plants." final_response = iterative_Reflection(model, tokenizer, task) print(final_response)
此函数实现了一个迭代反射过程,反复细化响应,直到满足满意的标准或达到最大迭代次数。
接下来,让我们看看如何利用反射来纠正 LLMs 中的错误。
纠正错误
反射技术在 LLMs 的自我改进和错误纠正中特别有用。以下是一个使用反射实现错误纠正的示例:
def error_correction_Reflection(
model, tokenizer, task, initial_response, known_errors
):
prompt = f"""Task: {task}
Initial Response:
{initial_response}
Known Errors:
{' '.join(f'- {error}' for error in known_errors)}
Please reflect on the initial response, focusing on correcting the known errors. Provide an improved version of the response that addresses these issues.
Corrected Response:
"""
inputs = tokenizer(prompt, return_tensors="pt")
outputs = model.generate(inputs, max_length=1000,
num_return_sequences=1)
corrected_response = tokenizer.decode(outputs[0],
skip_special_tokens=True)
return corrected_response
# Example usage
task = "Describe the structure of an atom."
initial_response = "An atom consists of a nucleus containing protons and neutrons, with electrons orbiting around it in fixed circular orbits."
known_errors = [
"Electrons do not orbit in fixed circular paths",
"The description doesn't mention electron shells or energy levels"
]
corrected_response = error_correction_Reflection(
model, tokenizer, task, initial_response, known_errors
)
print(corrected_response)
error_correction_Reflection 函数构建了一个包含任务、初始响应和已知错误列表的提示,指示模型针对这些问题修改响应。提示被标记化并传递给模型,模型生成一个旨在解决已识别错误的新版本响应。然后将输出解码为文本,并作为纠正后的响应返回。这种方法通过明确引导模型关注特定缺陷,而不是完全依赖一般性反思,实现了有针对性的自我纠正。
请记住,随着提示的增大,标记长度可能会成为一个问题,这取决于所使用的模型。如果任务、初始响应、错误列表和说明的合并长度超过了模型的上下文窗口,可能会导致错误。为了减轻这种情况,重要的是要监控标记的使用情况,在可能的情况下简化提示,或使用具有扩展上下文窗口的模型,以确保在生成过程中保留所有关键信息。
评估反射的影响
为了评估反思技术的有效性,我们需要比较反思前后响应的质量。以下是一个简单的评估框架:
def evaluate_Reflection_impact(
initial_response, Reflection_response, criteria
):
initial_scores = evaluate_response(initial_response, criteria)
Reflection_scores = evaluate_response(Reflection_response,
criteria)
impact = {
criterion: Reflection_scores[criterion]
- initial_scores[criterion]
for criterion in criteria
}
return {
"initial_scores": initial_scores,
"Reflection_scores": Reflection_scores,
"impact": impact
}
def evaluate_response(response, criteria):
scores = {}
for criterion in criteria:
# Implement criterion-specific evaluation logic
scores[criterion] = evaluate_criterion(response, criterion)
return scores
def evaluate_criterion(response, criterion):
# Placeholder for criterion-specific evaluation
# In practice, this could involve NLP techniques, rubric-based scoring, or even another LLM
return 0 # Placeholder return
# Example usage
criteria = ["Accuracy", "Clarity", "Completeness", "Conciseness"]
evaluation = evaluate_Reflection_impact(initial_response,
corrected_response, criteria)
print("Evaluation Results:")
print(f"Initial Scores: {evaluation['initial_scores']}")
print(f"Reflection Scores: {evaluation['Reflection_scores']}")
print(f"Impact: {evaluation['impact']}")
这个评估框架比较了初始和反思改进的响应在多个标准上的差异,为反思过程的影响提供了见解。
代码使用四个标准来评估文本质量:criteria 列表和实现相应的逻辑在 evaluate_criterion 中。
实施有效反思的挑战
尽管功能强大,但在 LLMs 中实施有效的反思面临几个挑战:
-
计算成本:迭代反思可能成本高昂
-
循环推理的可能性:LLMs 可能会加强自己的偏见或错误
-
真正自我意识的困难:LLMs 缺乏对自己局限性的真正理解
-
平衡改进与原创性:过度的反思可能会导致过于保守的输出
为了解决这些挑战,考虑实施一个受控的反思过程。这个受控的反思过程限制了迭代的次数,并在改进变得微不足道时停止,平衡了反思的好处与计算效率:
def controlled_Reflection(
model, tokenizer, task, max_iterations=3,
improvement_threshold=0.1
):
response = generate_initial_response(model, tokenizer, task)
previous_score = evaluate_response(
response, ["Overall_Quality"]
)["Overall_Quality"]
for i in range(max_iterations):
improved_response = apply_Reflection(model, tokenizer,
task, response)
current_score = evaluate_response(improved_response,
["Overall_Quality"]
)["Overall_Quality"]
if current_score - previous_score < improvement_threshold:
break
response = improved_response
previous_score = current_score
return response
def apply_Reflection(model, tokenizer, task, response):
# Implement a single step of Reflection
pass
# Example usage
task = "Explain the theory of relativity."
final_response = controlled_Reflection(model, tokenizer, task)
print(final_response)
controlled_Reflection 函数迭代地改进模型生成的任务响应。它首先生成一个初始响应,然后使用 "Overall_Quality" 分数对其进行评估。在每次迭代中,它应用 apply_Reflection 来修订响应,重新评估它,并检查改进是否超过定义的阈值。如果没有,它就提前停止。这会持续到一个最大迭代次数,返回最佳响应。apply_Reflection 函数必须单独实现,代表反思改进的一步。
然而,质量评分可能具有主观性,尤其是在依赖于单一指标如 "Overall_Quality" 的情况下。小的修订可能不会反映有意义的改进,或者自动评分器在不同输出之间可能不一致。为了减轻这一点,最好使用多个评估维度、集成评分或置信度加权方法。如果评分仍然不稳定,添加人工监督或迭代之间的定性检查可以提高细化循环的可靠性。
未来方向
随着 LLMs 的反思技术不断发展,一些有希望的方向出现:
-
元反思:一种离线强化学习技术,通过增强基于过去试验经验学习的语义记忆来提高反思 (
arxiv.org/abs/2405.13009) -
在反思中融入外部知识:使用最新信息来指导反思过程 (
arxiv.org/html/2411.15041) -
反思感知架构:开发专门为有效自我反思设计的 LLM 架构 (
arxiv.org/abs/2303.11366)
这里是一个多智能体反射方法的构想实现:
-
定义函数:
def multi_agent_Reflection( models, tokenizers, task, num_agents=3 ): responses = [ generate_initial_response( models[i], tokenizers[i], task ) for i in range(num_agents) ] for _ in range(3): # Number of reflection rounds Reflections = [] for i in range(num_agents): other_responses = responses[:i] + responses[i+1:] reflection = generate_Reflection( models[i], tokenizers[i], task, responses[i], other_responses ) Reflections.append(Reflection) responses = [extract_improved_response(Reflection) for reflection in Reflections] -
从最终集中合并或选择最佳响应:
return select_best_response(responses) def generate_Reflection( model, tokenizer, task, own_response, other_responses ): prompt = f"""Task: {task} Your Response: {own_response} Other Responses: {' '.join(f'- {response}' for response in other_responses)} Reflect on your response in light of the other responses. Identify strengths and weaknesses in each approach and propose an improved response that incorporates the best elements from all perspectives. Your reflection and improved response: """ inputs = tokenizer(prompt, return_tensors="pt") outputs = model.generate( inputs, max_length=1500, num_return_sequences=1 ) return tokenizer.decode(outputs[0], skip_ special_tokens=True) def select_best_response(responses): # Implement logic to select or combine the best elements from multiple responses pass -
考虑以下一个示例用法:
task = "Propose a solution to reduce urban traffic congestion." final_response = multi_agent_Reflection(models, tokenizers, task) print(final_response)
这种多智能体反射方法利用多个 LLM 实例生成不同的观点,并通过迭代反思共同改进响应。
摘要
反思技术通过使 LLM 能够参与自我改进和错误纠正,提供了增强 LLM 性能和可靠性的强大方式。在本章中,你学习了如何设计提示,鼓励 LLM 评估和优化自己的输出。我们介绍了通过自我反思实现迭代优化的方法,并讨论了自我改进和错误纠正的应用。你还学习了如何评估反思对 LLM 性能的影响。
通过实施本章中讨论的策略和考虑因素,你可以创建更复杂的 LLM 系统,通过迭代优化和自我反思产生更高质量的输出。
在下一章中,我们将探讨自动多步推理和工具使用,这建立在我们在本章讨论的反思能力之上,以创建更加自主和强大的 AI 系统。
第二十五章:自动多步推理和工具使用
在 LLM 中的多步推理和工具使用涉及到模型将复杂任务分解为可管理的步骤,并利用外部资源或 API 来完成这些任务的能力。这种能力显著扩展了 LLM 的解决问题的潜力,使它们能够处理更复杂、更真实世界的场景。其关键特征包括以下内容:
-
任务分解:这指的是模型将复杂输入或目标分解成更小、更易于管理的子任务的能力,这些子任务可以按顺序或分层解决。模型不是试图一次性解决整个问题,而是创建一个结构化的计划或推理步骤序列,逐步引导到解决方案。这个过程模仿了人类通常通过识别依赖关系、排序行动和将大目标分解为中间目标来处理复杂问题的方法。诸如思维链提示等技术通过提示模型在得出答案之前明确阐述每个推理步骤,明确鼓励这种行为。
-
外部工具:通过集成额外的资源,如数据库、API 或专用服务,可以增强 LLM 的能力,这些资源由于训练环境中的限制,LLM 无法直接访问。这些工具使 LLM 能够与实时数据交互,执行超出其内置知识的特定任务,或提供增强功能,如网页浏览、文件处理或执行外部脚本。例如,LLM 可以使用外部工具查询最新的天气数据,从实时 API 检索特定信息,或运行需要专用算法的计算。这种集成使 LLM 能够提供更动态、相关和专业的响应,特别是对于需要实时信息或复杂多步过程的适用。
-
关于工具适用性的推理:这涉及到模型在识别何时需要外部能力来解决特定子任务时的判断。模型必须评估子任务的性质,并确定内部推理是否足够,或者将部分任务委托给工具是否会产生更好甚至必要的成果。
-
工具选择和调用:这指的是模型识别适用于给定子任务的工具并制定正确输入以触发其使用的能力。这要求模型理解每个可用工具的功能和输入要求,并将这些与推理过程中当前步骤的需求相匹配。例如,如果任务需要访问最新的天气信息,模型必须选择天气 API 并生成一个对该 API 语法正确且语义相关的查询。此阶段包括格式化输入、调用工具并确保请求与当前问题上下文和工具的功能相一致。
-
工具输出集成:这描述了模型解释外部工具返回的结果并将其纳入持续推理过程的能力。在工具被调用并响应数据(如数值、结构化对象或文本片段)后,模型必须解析结果、提取相关元素并相应地更新其理解或中间输出。此步骤通常涉及解释异构输出格式、管理类型不匹配并在推理链中保持连续性。有效的集成确保工具使用不是孤立的,而是有意义地贡献于解决更广泛的任务。
-
迭代问题解决:这指的是模型递归地应用先前阶段——分解、工具推理、选择、调用和集成——在一个循环中,直到任务解决或进一步步骤变得无效。模型持续评估其进度,确定是否还有剩余的子任务,并决定是否需要进一步使用工具。这种迭代行为使模型能够通过调整计划或细化先前行动来处理具有动态结构、不确定性或先前步骤中的错误的任务。在基于代理的架构中,此过程可能由规划器或控制器显式管理,而在基于提示的设置中,它通常通过递归自我查询和提示增强而出现。
在本章中,我们将深入探讨使 LLM 能够执行复杂多步推理并利用外部工具的高级技术。
在本章中,我们将讨论以下主题:
-
设计用于复杂任务分解的提示
-
集成外部工具
-
实现自动工具选择和使用
-
复杂问题解决
-
评估多步推理和工具使用
-
挑战和未来方向
设计用于复杂任务分解的提示
为了实现有效的多步推理,提示应引导 LLM 将复杂任务分解成更小、更易管理的步骤。以下是一个任务分解提示的示例:
def task_decomposition_prompt(task, available_tools):
prompt = f"""Given the following complex task:
{task}
And the following available tools:
{' '.join(f'- {tool}' for tool in available_tools)}
Please break down the task into smaller, logical steps. For each step, indicate if a specific tool should be used. If no tool is needed, explain the reasoning required.
Your task decomposition:
Step 1:
Step 2:
Step 3:
...
Ensure that the steps are in a logical order and cover all aspects of the task.
"""
return prompt
# Example usage
task = "Analyze the sentiment of tweets about a new product launch and create a summary report with visualizations."
available_tools = ["Twitter API", "Sentiment Analysis Model",
"Data Visualization Library"]
prompt = task_decomposition_prompt(task, available_tools)
print(prompt)
此功能生成一个提示,引导 LLM 将复杂任务分解成步骤,考虑可用的工具。
集成外部工具
为了使 LLMs 能够使用外部工具,如搜索、计算、API 调用等,我们需要在模型和工具之间创建一个接口。以下是一个简单的实现:
-
执行必要的导入并定义
ToolKit类:import requests from textblob import TextBlob import matplotlib.pyplot as plt class ToolKit: def __init__(self): self.tools = { "Twitter API": self.fetch_tweets, "Sentiment Analysis": self.analyze_sentiment, "Data Visualization": self.create_visualization }上述代码定义了一个
ToolKit类,通过其方法组织和提供对不同功能的访问。在__init__方法中,名为tools的字典被初始化,其键代表工具名称,如"Twitter API"、"Sentiment Analysis"和"Data Visualization",值引用获取推文、使用 TextBlob 库执行情感分析和使用 Matplotlib 创建数据可视化的相应方法。导入requests库用于发送 HTTP 请求,TextBlob用于自然语言处理任务,如情感分析,matplotlib.pyplot用于生成可视化。代码为这些工具设置了结构,但代码不完整,因为fetch_tweets、analyze_sentiment和create_visualization方法尚未定义,为这些功能的进一步实现留出了空间。 -
定义三个方法:
fetch_tweets用于根据查询生成模拟推文,analyze_sentiment用于使用 TextBlob 计算文本列表的情感极性分数,以及create_visualization用于创建并保存具有指定标题的情感数据直方图:def fetch_tweets(self, query, count=100): return [f"Tweet about {query}" for _ in range(count)] def analyze_sentiment(self, texts): sentiments = [TextBlob(text).sentiment.polarity for text in texts] return sentiments def create_visualization(self, data, title): plt.figure(figsize=(10, 6)) plt.hist(data, bins=20) plt.title(title) plt.xlabel("Sentiment") plt.ylabel("Frequency") plt.savefig("sentiment_visualization.png") return "sentiment_visualization.png" -
定义
use_tool方法,如果工具字典中存在指定的工具,则使用给定的参数执行该工具;否则,返回错误信息:def use_tool(self, tool_name, *args, kwargs): if tool_name in self.tools: return self.toolstool_name else: return f"Error: Tool '{tool_name}' not found." -
以下示例演示了使用
ToolKit类获取有关产品发布的推文,分析其情感,创建情感可视化,并打印生成的可视化文件路径:toolkit = ToolKit() tweets = toolkit.use_tool( "Twitter API", "new product launch", count=50 ) sentiments = toolkit.use_tool("Sentiment Analysis", tweets) visualization = toolkit.use_tool( "Data Visualization", sentiments, "Sentiment Analysis of Product Launch Tweets" ) print(f"Generated visualization: {visualization}")
这个ToolKit类为 LLM 提供了一个与外部工具交互的接口,模拟 API 调用和数据处理任务。
实现自动工具选择和使用
为了使大型语言模型(LLMs)能够自动选择和使用工具,我们可以创建一个系统来解释模型的输出并执行相应的工具。以下是一个示例:
-
首先,我们定义一个函数
auto_tool_use,该函数使用来自 Hugging Face 的 Transformers 库的预训练语言模型和分词器,通过提示将任务分解为可执行步骤,将分解步骤解析为步骤,使用工具包按需执行工具,并收集结果:from transformers import AutoModelForCausalLM, AutoTokenizer def auto_tool_use(model, tokenizer, task, toolkit): # Generate task decomposition decomposition_prompt = task_decomposition_prompt( task, toolkit.tools.keys() ) inputs = tokenizer(decomposition_prompt, return_tensors="pt") outputs = model.generate( inputs, max_length=1000, num_return_sequences=1 ) decomposition = tokenizer.decode(outputs[0], skip_special_tokens=True) # Parse decomposition and execute tools steps = parse_steps(decomposition) results = [] for step in steps: if step['tool']: result = toolkit.use_tool(step['tool'], *step['args']) else: result = f"Reasoning: {step['reasoning']}" results.append(result) -
然后我们生成最终报告。生成的报告包含任务描述,每个步骤的分解及其结果,以及总结。模型使用提供的步骤和结果来生成更连贯和全面的任务叙述:
report_prompt = f"Task: {task}\n\nSteps and Results:\n" for i, (step, result) in enumerate(zip(steps, results), 1): report_prompt += ( f"Step {i}: {step['description']}\n" f"Result: {result}\n\n" ) report_prompt += "Please provide a comprehensive report summarizing the results and insights." inputs = tokenizer(report_prompt, return_tensors="pt") outputs = model.generate( inputs, max_length=1500, num_return_sequences=1 ) report = tokenizer.decode(outputs[0], skip_special_tokens=True) return report -
然后,我们实现逻辑来将分解步骤结构化。这是一个简化的占位符实现:
def parse_steps(decomposition): steps = [] for line in decomposition.split('\n'): if line.startswith("Step"): tool = "Twitter API" if "Twitter" in line else \ "Sentiment Analysis" if "sentiment" in line else \ "Data Visualization" if "visualization" in line else None steps.append({ 'description': line, 'tool': tool, 'args': [], 'reasoning': line if not tool else "" }) return steps -
以下示例用法演示了使用
AutoModelForCausalLM和AutoTokenizer加载语言模型和分词器,定义一个分析推文情感并生成带有可视化总结报告的任务,以及使用auto_tool_use函数通过ToolKit自动化任务,最终报告将被打印出来:model_name = "llama3.3" # Replace with your preferred model model = AutoModelForCausalLM.from_pretrained(model_name) tokenizer = AutoTokenizer.from_pretrained(model_name) task = "Analyze the sentiment of tweets about a new product launch and create a summary report with visualizations." toolkit = ToolKit() report = auto_tool_use(model, tokenizer, task, toolkit) print(report)
以下代码片段从高层次展示了如何使大型语言模型(LLM)自动分解任务、选择合适的工具,并根据结果生成最终报告。
本章的前三部分通过涵盖提示设计、集成外部工具和实现自动工具选择来增强人工智能功能,奠定了基础。在接下来的部分,我们将探讨如何设计用于复杂问题解决的提示。
复杂问题解决
多步推理和工具使用可以应用于各种复杂问题解决场景。以下是如何使用这种方法进行市场分析的示例:
def market_analysis(model, tokenizer, toolkit, product_name):
task = f"""Conduct a comprehensive market analysis for the product: {product_name}.
Include competitor analysis, sentiment analysis of customer reviews, and market trends visualization."""
analysis_report = auto_tool_use(model, tokenizer, task, toolkit)
return analysis_report
# Example usage
product_name = "SmartHome AI Assistant"
market_report = market_analysis(model, tokenizer, toolkit,
product_name)
print(market_report)
market_analysis 函数通过构建结构化任务提示并将其传递给外部实用工具 auto_tool_use(假设它协调语言模型从工具增强的响应),自动化给定产品的市场研究报告的生成。提示要求进行多部分分析——涵盖竞争对手、客户反馈的情感分析以及市场趋势的可视化——针对提供的特定 product_name。这种设计利用模型和工具包生成综合报告,无需人工干预,通过提示驱动的执行实现产品市场研究的一致性和可重复性方法。
评估多步推理和工具使用
为了评估多步推理和工具使用的有效性,我们需要评估过程和结果。以下是一个简单的评估框架:
def evaluate_multistep_tooluse(
task, generated_report, ground_truth, criteria
):
scores = {}
for criterion in criteria:
scores[criterion] = evaluate_criterion(generated_report,
ground_truth, criterion)
# Evaluate tool use effectiveness
tool_use_score = evaluate_tool_use(task, generated_report)
scores['Tool Use Effectiveness'] = tool_use_score
return scores
def evaluate_criterion(generated_report, ground_truth, criterion):
# Implement criterion-specific evaluation logic
# This is a placeholder implementation
return 0.0 # Return a score between 0 and 1
def evaluate_tool_use(task, generated_report):
# Implement logic to evaluate how effectively tools were used
# This could involve checking for specific tool outputs or insights
# This is a placeholder implementation
return 0.0 # Return a score between 0 and 1
# Example usage
criteria = ['Accuracy', 'Comprehensiveness', 'Insight Quality',
'Logical Flow']
ground_truth = "Ideal market analysis report content..." # This would be a benchmark report
evaluation_scores = evaluate_multistep_tooluse(task, market_report,
ground_truth, criteria)
print("Evaluation Scores:", evaluation_scores)
此评估框架评估了生成的报告的质量以及工具使用过程中的有效性。
挑战和未来方向
尽管功能强大,LLM 中的多步推理和工具使用面临几个挑战:
-
工具选择准确性:确保 LLM 为每个任务选择最合适的工具
-
错误传播:减轻推理过程早期步骤中错误的影响;记住,如果不在早期减轻,错误在多个步骤中的传播可能会在复杂的工具链中成为主要风险
-
可扩展性:管理集成大量不同工具的复杂性
-
适应性:使 LLM 能够在不重新训练的情况下与新的、未见过的工具一起工作
为了解决这些挑战,可以考虑实现一个自我纠正机制:
def self_correcting_tooluse(
model, tokenizer, task, toolkit, max_attempts=3
):
for attempt in range(max_attempts):
report = auto_tool_use(model, tokenizer, task, toolkit)
# Prompt the model to evaluate its own work
evaluation_prompt = f"""Task: {task}
Generated Report:
{report}
Please evaluate the quality and completeness of this report. Identify any errors, omissions, or areas for improvement. If necessary, suggest specific steps to enhance the analysis.
Your evaluation:
"""
inputs = tokenizer(evaluation_prompt, return_tensors="pt")
outputs = model.generate(
inputs, max_length=1000, num_return_sequences=1
)
evaluation = tokenizer.decode(outputs[0],
skip_special_tokens=True)
if "satisfactory" in evaluation.lower() and "no major issues" in evaluation.lower():
break
# If issues were identified, use the evaluation to improve the next attempt
task += f"\n\nPrevious attempt evaluation: {evaluation}\nPlease address these issues in your next attempt."
return report
# Example usage
final_report = self_correcting_tooluse(model, tokenizer, task,
toolkit)
print(final_report)
在这个语境中,自我纠正指的是一种语言模型通过评估和改进其先前响应(而不需要外部反馈)来迭代地细化其输出的方法。在self_correcting_tooluse函数中,这是通过首先使用auto_tool_use生成报告,然后提示模型评估该报告的质量来实现的。如果模型的自评估不包括充分性的指标——例如“满意”和“没有重大问题”——则评估将附加到任务描述中,有效地指导下一次迭代解决已识别的不足。这个循环会持续一定次数的尝试(max_attempts),直到输出满足模型自己的接受标准,允许在多次迭代中进行自我引导的细化。
我们可以确定以下三个有希望的研究领域,以克服来自 AI/ML 社区一些研究带来的挑战:
-
增强的工具学习和发现:未来的 LLMs 将能够动态地了解和整合新工具,而无需显式编程。这涉及到理解工具文档和 API 规范以及通过实验工具来推断其功能性的机制。这将使 LLMs 能够适应不断演变的软件和服务景观,扩展其功能,而不仅仅是预定义的工具集。这将涉及元学习、从工具交互中进行强化学习以及工具描述的语义理解技术(
arxiv.org/abs/2305.17126)。 -
具有不确定性的鲁棒和自适应推理:未来的大型语言模型(LLMs)将整合概率模型来处理多步任务中的不确定性。这意味着为不同的推理路径、结果和工具有效性分配概率。贝叶斯方法、蒙特卡洛模拟和其他概率技术将被整合到推理过程中。这将使 LLMs 能够在信息不完整或噪声复杂场景中做出更鲁棒的决定,并更好地管理现实世界问题的固有不确定性。LLMs 将更好地应对意外情况,从错误中恢复,并在面对模糊性时提供更可靠的解决方案(
arxiv.org/abs/2310.04406)。 -
具有可解释性的闭环多步推理:未来的系统将在多步问题解决中涉及人类与大型语言模型(LLM)之间更紧密的合作。这意味着创建允许人类理解 LLM 推理过程、提供指导、纠正错误并在复杂任务上共同工作的界面。可解释性将是关键,LLM 能够阐述其推理步骤、证明工具选择并展示替代解决方案路径。这将促进信任并允许更有效的人类-人工智能合作,特别是在医疗保健、金融和科学研究等关键领域。这可能包括推理图的可视化、自然语言解释和交互式调试工具:
www.microsoft.com/en-us/research/blog/guidance-for-developing-with-large-language-models-llms/.
摘要
自动多步推理和工具使用显著扩展了 LLM 的解决问题能力,使它们能够处理复杂、现实世界的任务。
在本章中,你学习了如何设计用于复杂任务分解的提示,并实现允许 LLM 与外部工具和 API 交互的系统。我们探讨了自动工具选择和使用的策略,并探讨了在复杂问题解决场景中的应用。你还学习了如何评估 LLM 中多步推理和工具使用的有效性。通过实施本章讨论的技术和考虑因素,你可以创建复杂的 AI 系统,这些系统能够分解问题、利用外部工具并针对多方面挑战生成全面的解决方案。
随着我们继续前进,本书的下一部分将专注于检索和知识集成。这将建立在我们在本部分讨论的工具使用能力之上,探讨 LLM 如何通过外部知识得到增强,提高其有效获取和利用信息的能力。
第五部分:大型语言模型中的检索和知识集成
我们通过考察通过检索增强生成(RAG)方法增强 LLMs 的外部知识的技术来结束本书。你将学习如何设计检索系统,以高效地访问相关信息,将结构化知识集成到模型输出中,并利用基于图的检索来丰富响应中的上下文关系。我们将探讨高级 RAG 模式,如迭代和自适应检索,帮助你创建能够动态集成知识的模型。我们还讨论了评估方法,以衡量检索质量和有效性。最后一章介绍了代理模式,使你能够构建结合推理、规划和决策的自主系统。通过掌握这些技术,你将能够创建不仅信息丰富,而且能够实现目标导向行为的 LLMs。
本部分包含以下章节:
-
第二十六章,检索增强生成
-
第二十七章,基于图的 RAG
-
第二十八章,高级 RAG
-
第二十九章,评估 RAG 系统
-
第三十章,代理模式
第二十六章:检索增强生成
检索增强生成(RAG)是一种增强 AI 模型性能的技术,尤其是在需要模型预训练参数中不包含的知识或数据的任务中。它结合了基于检索的模型和生成模型的优点。检索组件从外部来源,如数据库、文档或网络内容中检索相关信息,而生成组件则使用这些信息生成更准确、更具情境丰富性的响应。
RAG 是通过将检索机制与语言模型集成来实现的。这个过程从查询知识库或外部资源以获取相关文档或片段开始。然后,这些检索到的信息被输入到语言模型中,通过结合提示和检索到的数据生成响应。这种方法提高了模型回答问题或解决问题的能力,特别是对于它原本缺乏的更新或特定领域的信息。
在本章中,我们将向您介绍 RAG。您将学习如何实现一个简单的 RAG 系统,该系统可以使用相关的外部信息增强 LLM 的输出。
RAG 的关键优势包括增强的事实准确性、获取最新信息、改进的特定领域知识,以及减少 LLM 输出中的幻觉。
在本章中,我们将介绍嵌入和索引技术,用于高效检索向量数据库,查询制定策略,以及将检索信息与 LLM 生成集成的方法。到本章结束时,你将能够实现基本的 RAG 系统,以增强你的 LLM 外部知识。
在本章中,我们将涵盖以下主题:
-
为 LLM 构建简单的 RAG 系统
-
LLM 检索的嵌入和索引技术
-
基于 LLM 的 RAG 查询制定策略
-
将检索信息与 LLM 生成集成
-
LLM 中 RAG 的挑战和机遇
为 LLM 构建简单的 RAG 系统
本节提供了一个简单 RAG 系统的实际示例,利用SerpApi强大的搜索能力、句子嵌入的语义理解以及 OpenAI 的 GPT-4o 模型的生成能力。SerpApi 是一个网络抓取 API,提供对搜索引擎结果的实时访问,为 Google、Bing 和其他平台提供结构化数据,无需手动抓取。
通过这个例子,我们将探讨 RAG 系统的基本组件,包括基于查询的网页搜索、片段提取和排名,以及最终使用最先进的 LLM 生成全面答案的过程,以逐步方式突出这些元素之间的相互作用。
我们将要构建的简单 RAG 系统的代码包含以下内容:
-
SerpApi:根据用户的查询找到相关的网页。
-
句子嵌入: 通过使用句子嵌入和余弦相似度来从搜索结果中提取最相关的片段。句子嵌入是文本的密集数值表示,通过将单词、短语或整个句子映射到高维向量空间来捕获语义意义,其中相似的意义被放置得更近。余弦相似度衡量这些嵌入向量之间的角度(范围从-1 到 1),而不是它们的幅度,这使得它成为评估语义相似性的有效方法,无论文本长度如何;当两个嵌入的余弦相似度接近 1 时,它们在意义上高度相似,而接近 0 的值表示无关内容,负值则表示相反的意义。这种技术的组合为许多现代自然语言处理(NLP)应用提供了动力,从搜索引擎和推荐系统到语言翻译和内容聚类。
-
OpenAI 的 GPT-4o: 基于检索到的片段(上下文)和原始查询生成全面且连贯的答案。
首先,让我们安装以下依赖项:
pip install google-search-results sentence-transformers openai
在前面的命令中,我们安装了serpapi用于搜索,sentence_transformers用于嵌入,以及openai用于访问 GPT-4o。
接下来,让我们看看如何使用搜索 API、嵌入和 LLM 实现一个完整的 RAG 系统:
-
我们首先导入安装的库以及
torch进行张量操作。代码片段还设置了 SerpApi 和 OpenAI 的 API 密钥。请记住用你实际的 API 密钥替换占位符:from serpapi import GoogleSearch from sentence_transformers import SentenceTransformer, util import torch import openai SERPAPI_KEY = "YOUR_SERPAPI_KEY" # Replace with your SerpAPI key OPENAI_API_KEY = "YOUR_OPENAI_API_KEY" # Replace with your OpenAI key openai.api_key = OPENAI_API_KEY -
然后,我们初始化搜索引擎和句子转换器。以下代码定义了搜索函数,使用 SerpApi 执行 Google 搜索,并初始化句子转换器模型(
all-mpnet-base-v2)以创建句子嵌入:def search(query): params = { "q": query, "hl": "en", "gl": "us", "google_domain": "google.com", "api_key": SERPAPI_KEY, } search = GoogleSearch(params) results = search.get_dict() return results model = SentenceTransformer('all-mpnet-base-v2') -
接下来,我们检索相关片段。我们定义了
retrieve_snippets函数,它接受搜索结果,提取片段,计算它们的嵌入,并计算查询嵌入与每个片段嵌入之间的余弦相似度。然后,它返回与查询最相似的顶部k个片段:def retrieve_snippets(query, results, top_k=3): snippets = [ result.get("snippet", "") for result in results.get("organic_results", []) ] if not snippets: return [] query_embedding = model.encode(query, convert_to_tensor=True) snippet_embeddings = model.encode(snippets, convert_to_tensor=True) cosine_scores = util.pytorch_cos_sim( query_embedding, snippet_embeddings )[0] top_results = torch.topk(cosine_scores, k=top_k) return [snippets[i] for i in top_results.indices] -
然后,我们定义了
generate_answer函数,用于使用 GPT-4o 生成答案。这是我们的 RAG 系统生成部分的核心:def generate_answer(query, context): messages = [ { "role": "system", "content": "You are a knowledgeable expert. Answer the user's query based only on the information provided in the context. " "If the answer is not in the context, say 'I couldn't find an answer to your question in the provided context.'", }, { "role": "user", "content": f"Context: {context}\n\nQuery: {query}", }, ] response = openai.chat.completions.create( model="gpt-4o", messages=messages, temperature=0.7, max_tokens=256 ) return response.choices[0].message.content此函数构建一个结构化的提示,用于 LLM 生成一个严格受给定上下文约束的答案。它将对话格式化为系统-用户消息对,指示模型充当主题专家,并将答案限制在提供的信息内,明确避免推测。如果信息不存在,系统将指导返回一个回退消息,表明答案无法找到。查询和上下文直接嵌入到用户消息中,LLM(在本例中为
gpt-4o)通过temperature=0.7和256令牌的响应长度上限进行适度创造性的查询。这种设计使得该函数在基于上下文的问答任务中可靠,尤其是在 RAG 管道或文档问答或合规工具等约束回答环境中。 -
这里是主要的 RAG 函数及其示例用法:
def rag_system(query): search_results = search(query) relevant_snippets = retrieve_snippets(query, search_results) if not relevant_snippets: return "Could not find any information related to your query" context = " ".join(relevant_snippets) answer = generate_answer(query, context) return answer # Example usage query = "What are the latest advancements in quantum computing?" answer = rag_system(query) print(answer)此代码定义了
rag_system函数,该函数协调整个流程:搜索、检索片段和生成答案。然后它演示了如何使用示例查询rag_system,并将生成的答案打印到控制台。rag_system函数通过首先使用search(query)搜索相关信息,然后通过调用retrieve_snippets(query, search_results)API 提取相关片段来回答查询。如果没有找到片段,它将返回一条消息,表明没有找到信息。如果可用片段,它们将被组合成一个单一上下文字符串,并通过generate_answer(query, context)生成答案。最后,该函数根据上下文返回生成的答案。在示例用法中,该函数使用查询"What are the latest advancements in quantum computing?"被调用,并将根据相关搜索结果返回一个生成的响应。在实际生产系统中,我们应该在retrieve_snippetsAPI 调用周围实现重试和错误处理。
在我们进入下一节之前,这里有一些事情需要记住:
-
API 密钥:确保你有有效的 SerpApi 和 OpenAI API 密钥,并在代码中替换了占位符。
-
OpenAI 成本:注意 OpenAI API 使用成本。GPT-4o 可能比其他模型更昂贵。
-
提示工程:生成的答案质量很大程度上取决于提供给 GPT-4o 的提示。你可能需要尝试不同的提示以获得最佳结果。考虑添加有关所需答案格式、长度或样式的说明。
-
使用
try-except块来处理潜在问题,例如网络问题、API 错误或无效输入。 -
高级技术:这是一个基本的 RAG 系统。你可以通过以下方式进一步改进它:
-
更好的片段选择:考虑因素包括来源多样性、事实性和片段长度
-
迭代检索:如果初始答案不满意,则检索更多上下文
-
微调:在您的特定领域上对较小的、更专业的语言模型进行微调,以实现更好的性能和更低的成本
-
我们已经成功构建了一个简单的 RAG 系统,涵盖了检索和生成的核心组件。现在我们有了功能性的 RAG 系统,让我们更深入地探讨那些能够从大量数据集中高效检索的关键技术:嵌入和索引。我们将探讨不同的方法来表示文本的语义,并组织这些表示以实现快速相似性搜索。
LLM 应用中的检索嵌入和索引
嵌入和索引技术为基于 RAG 的 LLM 应用提供了高效和有效的检索。它们允许 LLM 快速找到并利用大量数据中的相关信息。以下小节提供了常见技术的分解。
嵌入
嵌入是数据的数值向量表示,例如文本、图像或音频,它将复杂的高维数据映射到一个连续的向量空间中,其中相似的项目彼此靠近。这些向量捕捉数据的潜在模式、关系和语义属性,使得机器学习模型更容易理解和处理。例如,对于文本,词嵌入将单词或短语转换成密集的向量,以反映它们在向量空间中的语义关系,如同义词彼此更接近。嵌入通常通过神经网络等技术从大型数据集中学习,它们是信息检索、分类、聚类和推荐系统等任务的基础。通过降低数据的维度同时保留重要特征,嵌入使得模型能够更好地泛化并有效地处理各种输入数据。
对于 LLM,文本嵌入最为相关。它们是通过将文本传递到神经网络(如我们在上一节中使用的 Sentence Transformer 模型)来生成的。
我们为什么需要嵌入?
嵌入对于 RAG 应用的重要性如下:
-
语义搜索:嵌入使得语义搜索成为可能,您可以根据意义而不是仅仅根据关键词匹配来查找信息
-
上下文理解:LLM 可以使用嵌入来理解不同信息片段之间的关系,提高其推理和生成相关响应的能力
-
高效检索:当与适当的索引结合时,嵌入允许从大型数据集中快速检索相关信息
常见的嵌入技术
在 RAG 系统中,常用的嵌入技术多种多样,它们在底层模型、方法和适用于不同应用方面有所不同。以下是 RAG 中一些突出的嵌入技术:
-
预训练的基于 transformer 的嵌入(例如,BERT、RoBERTa 和 T5):像双向编码器表示(BERT)及其变体(如 RoBERTa 和 T5)这样的 Transformer 模型已被广泛用于生成文本的密集、上下文嵌入。这些模型在大语料库上进行微调,并捕捉到丰富的语言语义理解。在 RAG 系统中,这些嵌入可用于根据语义相似性从文档存储中检索相关段落。这些嵌入通常是高维的,并且通过将文本输入到 Transformer 模型中生成固定大小的向量。
-
Sentence-BERT(SBERT):SBERT 是 BERT 的一种变体,专为句子级嵌入设计,它专注于优化模型以执行诸如语义文本相似性和聚类等任务。它使用 Siamese 网络架构将句子映射到密集向量空间,其中语义相似的句子彼此更接近。这使得它在 RAG 中的信息检索任务中特别有效,在这些任务中,从大型语料库中检索与语义相关的段落至关重要。
-
Facebook AI 相似性搜索(Faiss):Faiss 是由 Facebook AI Research 开发的库,它通过近似最近邻(ANN)搜索提供高效的相似性搜索。Faiss 本身不是一种嵌入技术,但它与各种嵌入模型协同工作,以索引和搜索大量向量集合。在 RAG 中使用时,Faiss 通过比较其嵌入与查询嵌入之间的相似性,实现了快速检索相关文档或段落。
-
密集检索模型(例如,DPR 和 ColBERT):密集段落检索(DPR)是一种信息检索方法,它使用两个独立的编码器(通常是基于 BERT 的模型)将查询和段落编码为密集向量。DPR 通过利用密集嵌入中编码的上下文知识,优于传统的稀疏检索方法。另一方面,ColBERT 是另一种平衡密集检索效率和传统方法有效性的密集检索模型。这些模型在 RAG 中检索与查询语义相关的优质段落时特别有用。
-
对比语言-图像预训练(CLIP):虽然最初是为多模态应用(文本和图像)设计的,但 CLIP 也被适应用于仅文本的任务。它通过在共享向量空间中对齐文本和图像数据来学习嵌入。尽管 CLIP 主要用于多模态任务,但它在图像空间中共同表示语言的能力提供了一个灵活的嵌入框架,该框架可用于 RAG,尤其是在处理多模态数据时。
-
深度语义相似性模型(例如,USE 和 InferSent):如通用句子编码器(USE)和 InferSent 之类的模型通过捕捉更深层的语义意义来生成句子嵌入,可用于各种 NLP 任务,包括文档检索。这些模型产生固定大小的向量表示,可以用于比较相似性,当与检索系统结合使用时,它们对 RAG 非常有用。
-
Doc2Vec:Word2Vec 的扩展,Doc2Vec 为整个文档生成嵌入,而不是单个单词。它将可变长度的文本映射到固定大小的向量,可用于检索语义相似的文档或段落。虽然在语义丰富性方面不如基于 transformer 的模型强大,但 Doc2Vec 仍然是 RAG 应用中用于更轻量级检索任务的有效工具。
-
基于嵌入的搜索引擎(例如,使用密集向量的 Elasticsearch):一些现代搜索引擎,如 Elasticsearch,已经集成了对密集向量的支持,同时保留了传统的基于关键词的索引。Elasticsearch 可以存储和检索文本嵌入,允许进行更灵活和语义感知的搜索。当与 RAG 结合使用时,这些嵌入可以用于根据查询的相关性对文档进行排序,从而提高检索性能。
-
OpenAI 嵌入(例如,基于 GPT 的模型):OpenAI 的嵌入,如 GPT-3 模型,也用于 RAG 任务。这些嵌入基于语言模型生成高质量文本表示的能力,可以在大型语料库中进行索引和搜索。虽然它们在检索方面的特定调整不如某些其他模型(如 DPR),但它们非常灵活,可以用于通用 RAG 应用。
这些嵌入技术根据 RAG 系统的具体需求提供各种优势,例如检索速度、模型准确性和处理数据的规模。每个技术都可以针对特定用例进行微调和优化,嵌入技术的选择将取决于诸如检索文档的性质、计算资源和延迟要求等因素。
索引
索引是将嵌入组织到一种数据结构中的过程,该数据结构允许进行快速相似性搜索。想象一下书的索引,但针对的是向量而不是单词。
使用更详细的 LLM 术语描述,向量索引技术通过创建专门的数据结构来优化嵌入的存储和检索,这些数据结构根据其相似性关系组织高维向量,而不是按顺序排列。这些结构——无论是基于图(通过可导航路径连接相似向量)、基于树(递归划分向量空间)还是基于量化(压缩向量同时保留相似性)——都服务于将原本代价高昂的全面搜索转化为可管理过程的根本目的,通过策略性地限制搜索空间,使向量数据库能够以亚秒级的查询时间处理数十亿个嵌入,同时保持速度、内存效率和结果精度之间的可接受权衡。
为什么索引很重要?
索引对于 LLM 之所以重要,有以下原因:
-
速度:没有索引,您必须将查询嵌入与数据集中的每个嵌入进行比较,这计算成本高且速度慢。
-
可扩展性:索引允许 LLM 应用扩展以处理包含数百万甚至数十亿数据点的庞大数据集。
常见的索引技术
让我们来看看 LLM 的一些常见索引技术。
对于这些索引技术的可视化图表,我建议您查看以下网站:kdb.ai/learning-hub/articles/indexing-basics/
-
平面索引(暴力法):
-
工作原理:将所有嵌入存储在简单的列表或数组中。在搜索过程中,它计算查询嵌入与索引中每个嵌入之间的距离(例如,余弦相似度)。
-
优点:简单易实现且完美精度(找到真正的最近邻)。
-
缺点:对于大数据集来说,速度慢且计算成本高,因为它需要进行全面搜索。
-
适用场景:非常小的数据集或当完美精度是绝对要求时。
-
-
倒排文件索引(IVF):
-
工作原理:
-
聚类:使用如 k-means 等算法将嵌入空间划分为簇。
-
倒排索引:创建一个倒排索引,将每个簇中心映射到属于该簇的嵌入列表。
-
搜索:
-
找到与查询嵌入最近的簇中心。
-
只在那些簇内进行搜索,显著减少了搜索空间。
-
-
-
优点:比平面索引快;相对简单易实现。
-
缺点:近似(可能无法始终找到真正的最近邻);精度取决于簇的数量。
-
适用场景:需要速度和精度之间良好平衡的中等大小数据集。
-
-
分层可导航小世界(HNSW):
-
工作原理:
-
基于图:构建一个分层图,其中每个节点代表一个嵌入。
-
层:图有多个层,顶层有长距离连接(用于更快地遍历),底层有短距离连接(用于准确搜索)。
-
搜索:从顶层随机节点开始,通过探索连接贪婪地向查询嵌入移动。搜索在层中向下进行,逐步细化结果。
-
-
优点:非常快且准确;通常被认为是近似最近邻搜索的当前最佳水平
-
缺点:比 IVF 实现更复杂,并且由于图结构,内存开销更大
-
适用范围:对于速度和准确度都至关重要的大型数据集
-
-
产品量化 (PQ):
-
工作原理:
-
子向量:将每个嵌入分割成多个子向量。
-
码本:使用聚类为每个子向量创建单独的码本。每个码本包含一组代表性的子向量(中心点)。
-
编码:通过用对应码本中最接近的中心点替换其子向量来编码每个嵌入。这创建了一个嵌入的压缩表示。
-
搜索:通过使用查询子向量和码本中心之间的预计算距离,计算查询与编码嵌入之间的近似距离。
-
-
优点:通过压缩嵌入显著减少内存使用;快速搜索。
-
缺点:近似,准确度取决于子向量的数量和码本的大小。
-
适用范围:非常适合内存效率是主要关注点的非常大的数据集。
-
-
局部敏感哈希 (LSH):
-
工作原理:使用哈希函数以高概率将相似的嵌入映射到同一个“桶”中
-
优点:相对简单;可以跨多台机器分布式部署
-
缺点:近似,性能取决于哈希函数的选择和桶的数量
-
适用范围:非常适合非常大的、高维数据集
-
现在我们已经介绍了不同的索引方法,让我们介绍一些流行的库和工具,它们实现了这些索引技术,使得在实际中使用它们变得更加容易。这将提供一个实际的角度,了解如何在你的 RAG 应用中利用这些技术。
以下是一些实现索引的库和工具:
-
Faiss:Facebook AI 开发的高度优化的库,用于高效地搜索和聚类密集向量。它实现了之前提到的许多索引技术(平面、IVF、HNSW 和 PQ)。
-
近似最近邻 Oh Yeah (Annoy):另一个流行的近似最近邻搜索库,以其易用性和良好的性能而闻名。它采用基于树的方法。
-
Scalable Nearest Neighbors (ScaNN):由谷歌开发的库,旨在用于大规模、高维数据集。
-
Vespa.ai:提供查询、组织和在向量、张量、文本和结构化数据中进行推理的工具。它被
www.perplexity.ai/使用。 -
Pinecone, Weaviate, Milvus, Qdrant:专门设计用于存储和搜索嵌入的向量数据库。它们处理索引、扩展和其他基础设施问题。
适用于你的 LLM 应用的最佳嵌入和索引技术将取决于几个因素:
-
数据集大小:对于小型数据集,平面索引可能足够。对于大型数据集,考虑 HNSW、IVF 或 PQ。
-
速度要求:如果低延迟至关重要,HNSW 通常是速度最快的选项。
-
精度要求:如果需要完美的精度,平面索引是唯一的选择,但它不可扩展。HNSW 在近似方法中通常提供最佳的精度。
-
内存限制:如果内存有限,PQ 可以显著减少存储需求。
-
开发工作量:Faiss 和 Annoy 在性能和易于实现之间提供了良好的平衡。向量数据库简化了基础设施管理。
通过仔细考虑这些因素,并理解每种技术和库的优缺点,你可以选择最合适的嵌入和索引方法来构建高效且有效的 LLM 应用。
我们现在将演示一个使用 Faiss 的嵌入、索引和搜索示例,Faiss 是一个用于高效相似性搜索的强大库。我将使用all-mpnet-base-v2 Sentence Transformer 模型来生成嵌入。由于代码将超过 20 行,我将将其分解为带有每个块前解释的代码块。
示例代码演示嵌入、索引和搜索
在本节中,我们将展示使用嵌入和索引实现快速文本文档集合中相似性搜索的典型工作流程代码:以下是它的功能:
-
加载 Sentence Transformer 模型:初始化用于生成句子嵌入的预训练模型。
-
创建样本数据:定义一个示例句子列表(你将用实际数据替换这部分)。
-
使用
SentenceTransformer为每个句子创建嵌入。 -
在此示例中,使用
IndexFlatL2为平面 L2 距离索引存储嵌入。 -
将嵌入添加到索引中:将生成的嵌入添加到 Faiss 索引中。
-
定义搜索查询:设置一个我们想要找到相似句子的示例查询。
-
编码查询:使用相同的 Sentence Transformer 模型为搜索查询创建嵌入。
-
执行搜索:使用 Faiss 索引搜索与查询嵌入最相似的k个嵌入。
-
打印结果:显示在索引中找到的最近的k个邻居的索引和距离。
在我们查看代码之前,让我们安装以下依赖项:
pip install faiss-cpu sentence-transformers
# Use faiss-gpu if you have a compatible GPU
让我们看看代码示例:
-
首先,我们导入必要的库——
sentence_transformers用于创建嵌入,faiss用于索引和搜索——并加载all-mpnet-base-v2Sentence Transformer 模型:from sentence_transformers import SentenceTransformer import faiss import numpy as np # Load the SentenceTransformer model model = SentenceTransformer('all-mpnet-base-v2') -
然后,我们通过定义一些示例句子(你可以用你的实际数据替换这些句子)来准备数据,然后使用 Sentence Transformer 模型为每个句子生成嵌入(嵌入被转换为 float32,这是 Faiss 所需要的):
# Sample sentences text_data = [ "A man is walking his dog in the park.", "Children are playing with toys indoors.", "An artist is painting a landscape on canvas.", "The sun sets behind the mountain ridge.", "Birds are singing outside the window." ] # Generate vector representations using a SentenceTransformer model import numpy as np from sentence_transformers import SentenceTransformer model = SentenceTransformer('all-MiniLM-L6-v2') # Replace with your model if different vectors = model.encode(text_data, convert_to_tensor=True) # Ensure compatibility with Faiss by converting to 32-bit floating point and moving to CPU vectors = vectors.detach().cpu().numpy().astype(np.float32) -
然后,我们创建一个 Faiss 索引并将嵌入添加到其中:
# Get the dimensionality of the embeddings dimension = embeddings.shape[1] # Create a Faiss index (flat L2 distance) index = faiss.IndexFlatL2(dimension) # Add the embeddings to the index index.add(embeddings)这里,我们使用 IndexFlatL2,这是一个使用 L2 距离(欧几里得距离)进行相似度比较的平面索引。这种类型的索引可以提供准确的结果,但可能对于非常大的数据集来说比较慢。索引是根据正确的维度性创建的(对于这个 Sentence Transformer 模型是
768)。 -
接下来,我们定义一个示例搜索查询,并使用相同的 Sentence Transformer 模型将其编码成嵌入。查询嵌入也被转换为 float32:
# Define a search query query = "What is the dog doing?" # Encode the query query_embedding = model.encode(query, convert_to_tensor=True) query_embedding = \ query_embedding.cpu().numpy().astype('float32') -
最后,我们使用
index.search()方法执行相似度搜索。我们搜索两个最相似的句子(k=2)。该方法返回最近邻的距离和索引。然后我们打印出找到的最近邻的索引和距离:# Search for the k nearest neighbors k = 2 distances, indices = index.search(query_embedding, k) # Print the results print("Nearest neighbors:") for i, idx in enumerate(indices[0]): print(f" Index: {idx}, Distance: {distances[0][i]}, Sentence: {sentences[idx]}")
以下是从运行前面的代码块可能得到的示例输出:
Nearest neighbors:
Index: 0, Distance: 0.634912312, Sentence: A man is walking his dog in the park.
Index: 1, Distance: 1.237844944, Sentence: Children are playing with toys indoors.
这展示了如何使用 Sentence Transformers 和 Faiss 进行语义相似度搜索。请注意,实际数字将根据硬件、模型版本和运行条件而变化。
下面是发生的事情。
查询 "What is the dog doing?" 被嵌入并与列表中的所有嵌入句子进行比较。Faiss 根据嵌入空间中的欧几里得(L2)距离检索出两个最语义相似的句子。最小的距离表示最高的相似度。在这个例子中,关于男人遛狗的句子与查询在语义上最接近,这是有意义的。
如果你在自己的机器上运行这个程序,由于模型初始化的非确定性和浮点精度,你的值可能会有所不同,但最接近的句子应该始终是与查询最语义相关的句子。
重要
使用 Faiss 的 IndexIVFFlat 或 IndexHNSWFlat 可以提高搜索速度。
使用 faiss-gpu 可以显著加快索引和搜索的速度。
数据预处理: 对于实际应用,你可能需要执行额外的数据预处理步骤,例如小写化、去除标点符号或词干提取/词形还原,具体取决于你的具体需求和数据性质。
距离度量: Faiss 支持不同的距离度量。这里我们使用了 L2 距离,但你也可以使用内积(IndexFlatIP)或其他度量,具体取决于你的嵌入是如何生成的以及你想要测量哪种相似度。
向量数据库:对于生产级系统,考虑使用专门的向量数据库,如 Pinecone、Weaviate 或 Milvus,以更有效地管理您的嵌入和索引。它们通常提供自动索引、扩展和数据管理等功能,这些功能简化了相似性搜索应用的部署。
我们已经涵盖了使用 Faiss 进行嵌入、索引和搜索的基础知识,以及现实世界实施的重要考虑因素。现在,让我们将注意力转向 RAG 的另一个关键方面:查询公式。我们将探讨各种策略来精炼和扩展用户查询,最终导致从知识库中检索更有效的信息。
基于 LLM 的 RAG 查询公式策略
基于 LLM 的 RAG 系统中的查询公式策略旨在通过提高用户查询的表达性和覆盖范围来增强检索。常见的扩展策略包括以下内容:
-
同义词和释义扩展:这涉及到使用 LLM 或词汇资源生成语义等效的替代方案。例如,将“气候变化影响”扩展到包括“全球变暖的影响”或“气候变化的环境后果”可以帮助匹配更广泛的文档。
-
上下文重构:LLM 可以通过推断对话或文档上下文中的意图来重新解释查询。这有助于调整查询以更好地与知识库中可能表达的信息相匹配。
-
伪相关性反馈:也称为盲相关性反馈,这种策略涉及运行初始查询,分析排名靠前的文档中的显著术语,并使用这些术语来扩展查询。虽然有效,但需要防止主题漂移。
-
基于模板的增强:在结构化领域中很有用,这种方法使用特定领域的模板或模式来系统地生成变体。例如,关于“高血压治疗”的医疗查询也可能包括“高血压疗法”或“管理高血压”。
-
实体和概念链接:在查询中识别命名实体和领域概念,并用它们的别名、定义或层次关系替换或增强。这通常由本体或知识图指导。
-
基于提示的查询重写:使用 LLM,可以精心制作提示来明确指示模型生成重构后的查询。这在多语言或多领域 RAG 系统中特别有用,其中查询需要适应目标语料库的风格和词汇。
每种策略都对召回率和精确度有不同的贡献。选择或组合它们取决于底层知识库的结构和可变性。
在以下代码中,QueryExpansionRAG实现使用了一个由预训练的序列到序列语言模型(具体来说,是 T5-small)驱动的基于提示的查询重写策略。这种方法指示模型通过在提示前加上"expand query:"来生成输入查询的替代表述。生成的扩展反映了释义性改写,其中模型综合了语义相关的变体以增加检索覆盖范围:
from transformers import pipeline
class QueryExpansionRAG(AdvancedRAG):
def __init__(
self, model_name, knowledge_base,
query_expansion_model="t5-small"
):
super().__init__(model_name, knowledge_base)
self.query_expander = pipeline(
"text2text-generation", model=query_expansion_model
)
def expand_query(self, query):
expanded = self.query_expander(
f"expand query: {query}", max_length=50,
num_return_sequences=3
)
return [query] + [e['generated_text'] for e in expanded]
def retrieve(self, query, k=5):
expanded_queries = self.expand_query(query)
all_retrieved = []
for q in expanded_queries:
all_retrieved.extend(super().retrieve(q, k))
# Remove duplicates and return top k
unique_retrieved = list(dict.fromkeys(all_retrieved))
return unique_retrieved[:k]
# Example usage
rag_system = QueryExpansionRAG(model_name, knowledge_base)
retrieved_docs = rag_system.retrieve(query)
print("Retrieved documents:", retrieved_docs)
此代码定义了一个QueryExpansionRAG类,它通过结合使用预训练的 T5 模型来扩展 RAG 框架,实现了查询扩展。当用户提交查询时,expand_query方法通过文本到文本生成管道使用 T5 模型,生成查询的多个替代表述,然后将这些表述与原始查询结合。retrieve方法遍历这些扩展查询,为每个查询检索文档,并聚合结果同时去除重复项。这种方法通过扩大原始查询的词汇和语义范围,增加了检索相关内容的机会,当知识库以多种方式表达信息时,这种方法尤其有效。
请记住,扩展不当的查询可能会引入噪声并降低检索精确率。在此实现中,T5 模型生成的扩展与原始查询结合,增加了覆盖范围。然而,为了保持平衡,考虑使用相似度分数重新排序结果,或者在检索期间为生成的扩展分配较低的权重。这有助于确保扩展提高了召回率,同时不会损害与原始意图的对齐。
我们已经看到了查询扩展如何增强 RAG 系统中的检索,但管理召回率和精确率之间的权衡是至关重要的。现在,让我们将我们的重点转向 RAG 管道的另一端:将检索到的信息与 LLM 整合以生成最终答案。我们将探讨如何构建能够有效利用检索到的上下文的提示。
将检索到的信息与 LLM 生成整合
要将检索到的信息与 LLM 生成整合,我们可以创建一个包含检索到的文档的提示:
from transformers import AutoModelForCausalLM
class GenerativeRAG(QueryExpansionRAG):
def __init__(
self, retriever_model, generator_model, knowledge_base
):
super().__init__(retriever_model, knowledge_base)
self.generator = \
AutoModelForCausalLM.from_pretrained(generator_model)
self.generator_tokenizer = \
AutoTokenizer.from_pretrained(generator_model)
def generate_response(self, query, max_length=100):
retrieved_docs = self.retrieve(query)
context = "\n".join(retrieved_docs)
prompt = f"Context:\n{context}\n\nQuestion: {query}\nAnswer:"
inputs = self.generator_tokenizer(prompt, return_tensors="pt")
outputs = self.generator.generate(inputs,
max_length=max_length)
return self.generator_tokenizer.decode(
outputs[0], skip_special_tokens=True)
# Example usage
retriever_model = "all-MiniLM-L6-v2"
generator_model = "gpt2-medium"
rag_system = GenerativeRAG(
retriever_model, generator_model, knowledge_base
)
response = rag_system.generate_response(query)
print("Generated response:", response)
在前面的代码片段中,GenerativeRAG类通过集成用于答案生成的因果语言模型扩展了 RAG 管道。它继承自QueryExpansionRAG,后者已经提供了检索功能,并添加了一个使用 Hugging Face 的AutoModelForCausalLM的生成器组件。在构造函数中,它根据给定的模型名称初始化生成器模型和分词器。generate_response方法首先检索给定查询的相关文档,将它们连接成一个单一上下文字符串,并构建一个将此上下文与问题结合的提示。然后,该提示被分词并传递给语言模型,该模型生成作为答案的文本续写。最终输出是通过将生成的标记解码成字符串获得的。这种模块化结构将检索和生成步骤分开,使得根据任务或模型性能要求轻松扩展或替换单个组件。
在介绍了 RAG 系统的基本知识后,我们将现在关注现实世界的挑战,例如可扩展性、动态更新和多语言检索。具体来说,我们将讨论如何通过分片索引架构提高大规模检索效率,突出其在数据密集型环境中的性能影响。
LLMs 在 RAG 中的挑战和机遇
RAG 中的一些关键挑战和机遇包括以下内容:
-
可扩展性: 高效地处理非常大的知识库。
-
动态知识更新: 保持知识库的时效性。
-
跨语言 RAG: 在多种语言中进行检索和生成。
-
多模态 RAG: 在检索和生成过程中结合非文本信息。
请记住,跨语言和多模态 RAG 需要专门的检索管道或适配器,因为标准的检索方法通常在跨语言或模态的语义匹配上遇到困难,需要专门的组件来正确编码、对齐和检索相关信息,无论源语言或格式如何,同时保持上下文理解和相关性。
-
可解释 RAG: 在检索和生成过程中提供透明度。
为了使本章内容不过于冗长,在本节中,我们将仅展示一个示例,说明如何通过实现分片索引来解决可扩展性挑战。分片索引指的是将索引分割成多个较小的、可管理的片段,称为分片,每个分片独立存储和维护在不同的节点或存储单元上。这种方法可以实现并行处理,减少查找时间,并缓解与集中式索引相关的瓶颈,使其适用于处理在 AI 应用中常见的大规模数据集或高查询量:
class ShardedRAG(GenerativeRAG):
def __init__(
self, retriever_model, generator_model,
knowledge_base, num_shards=5
):
super().__init__(retriever_model, generator_model,
knowledge_base)
self.num_shards = num_shards
self.sharded_indexes = self.build_sharded_index()
def build_sharded_index(self):
embeddings = self.get_embeddings(self.knowledge_base)
sharded_indexes = []
shard_size = len(embeddings) // self.num_shards
for i in range(self.num_shards):
start = i * shard_size
end = start + shard_size if i < self.num_shards - 1
else len(embeddings)
shard_index = faiss.IndexFlatL2(embeddings.shape[1])
shard_index.add(embeddings[start:end])
sharded_indexes.append(shard_index)
return sharded_indexes
def retrieve(self, query, k=5):
query_embedding = self.get_embeddings([query])[0]
all_retrieved = []
for shard_index in self.sharded_indexes:
_, indices = shard_index.search(
np.array([query_embedding]), k)
all_retrieved.extend([self.knowledge_base[i]
for i in indices[0]])
# Remove duplicates and return top k
unique_retrieved = list(dict.fromkeys(all_retrieved))
return unique_retrieved[:k]
# Example usage
sharded_rag = ShardedRAG(retriever_model, generator_model,
knowledge_base)
response = sharded_rag.generate_response(query)
print("Generated response:", response)
在前面的代码中,可扩展性是通过将知识库划分为多个较小的索引或分片来处理的,每个分片包含整体数据的一部分。这种方法减轻了单个索引的计算和内存负担,并允许检索操作在数据集增长的情况下保持高效。在查询过程中,系统将查询嵌入一次,独立地对所有分片进行搜索,然后合并结果。这种设计避免了搜索单个大型索引时可能出现的瓶颈,并使得扩展到更大的知识库成为可能。它还为进一步的优化奠定了基础,例如并行化分片查询或将它们分布到多台机器上。
摘要
RAG 是一种强大的技术,用于通过外部知识增强大型语言模型(LLMs)。通过实施本章讨论的策略和技术,你可以创建更明智、更准确的语言模型,这些模型能够访问和利用大量信息。
随着我们继续前进,下一章将探讨基于图的大型语言模型(LLMs)的 RAG,这扩展了 RAG 概念以利用结构化知识表示。这将进一步增强 LLMs 在复杂关系上进行推理和生成更符合上下文响应的能力。
第二十七章:基于图的 RAG
在本章中,我们将学习如何利用 RAG 中的图结构化知识为 LLM。你将了解基于图的知識表示以及如何设计能够利用这种结构化信息的 RAG 架构。
基于图的知识表示将信息结构化为图中的节点和边,其中节点代表概念或事实,边捕获它们之间的关系。当与 RAG 结合使用时,这种方法通过利用信息片段及其相互连接,实现更丰富的信息检索,从而允许更上下文化和关系感知的响应。
我们将涵盖用于检索的图嵌入技术、使用图结构进行查询扩展以及将图信息集成到 LLM 生成中的方法。你还将探索 LLM 中图 RAG 的各种应用和用例。
到本章结束时,你将能够实现利用图结构化数据的丰富关系的先进 RAG 系统。
本章将涵盖以下主题:
-
基于图的 LLM 知識表示简介
-
为 LLM 设计图 RAG 架构
-
LLM 检索的图嵌入技术
-
在 LLM 中使用图结构进行查询扩展
-
将图信息集成到 LLM 生成中
-
LLM 中图 RAG 的应用和用例
-
基于图的 RAG 的挑战和未来方向
基于图的 LLM 知識表示简介
基于图的知識表示允许在概念和事实之间编码复杂关系,这可以显著增强 LLM 的上下文理解。在图中,节点代表实体,边代表它们之间的关系。

图 27.1 – 为 LLMs 的基于图的知識表示
以下是基于图的 LLM 知識表示的关键优势:
-
捕获复杂关系
-
实现多跳推理
-
为生成提供结构化上下文
-
促进特定领域知识的集成
让我们先从实现一个简单的图结构开始:
from typing import Dict, List, Tuple
class KnowledgeGraph:
def __init__(self):
self.nodes: Dict[str, Dict] = {}
self.edges: Dict[str, List[Tuple[str, str]]] = {}
def add_node(self, node_id: str, properties: Dict):
self.nodes[node_id] = properties
def add_edge(self, source: str, target: str, relation: str):
if source not in self.edges:
self.edges[source] = []
self.edges[source].append((target, relation))
def get_neighbors(
self, node_id: str) -> List[Tuple[str, str]
]:
return self.edges.get(node_id, [])
# Example usage
kg = KnowledgeGraph()
kg.add_node("Paris", {"type": "City", "country": "France"})
kg.add_node("France", {"type": "Country", "continent": "Europe"})
kg.add_edge("Paris", "France", "capital_of")
print(kg.get_neighbors("Paris"))
此代码在 Python 中实现了一个基础的 KnowledgeGraph 类,允许将知识表示为相互连接的实体网络。该类使用字典来存储节点和边,其中节点通过唯一的 ID 识别并持有相关属性,边通过源、目标和关系标签定义节点之间的关系。add_node 方法填充 nodes 字典,而 add_edge 在 edges 字典中建立连接。get_neighbors 方法允许检索与给定节点直接相连的节点,以及相应的关联关系类型。
这个例子演示了如何创建一个图,添加代表 Paris 和 France 的节点,定义它们之间的 capital_of 关系,然后查询图以找到 Paris 的邻居。这种结构为编码复杂关系和促进知识感知应用提供了基础。
接下来,我们将讨论如何设计图 RAG 架构。
为 LLM 设计图 RAG 架构
为了设计一个图 RAG 系统,我们需要将我们的知识图谱与检索和生成组件集成:
import networkx as nx
from sentence_transformers import SentenceTransformer
import torch
class GraphRAG:
def __init__(self, kg: KnowledgeGraph, model_name: str):
self.kg = kg
self.model = SentenceTransformer(model_name)
self.graph = self.build_networkx_graph()
self.node_embeddings = self.compute_node_embeddings()
def build_networkx_graph(self):
G = nx.DiGraph()
for node_id, properties in self.kg.nodes.items():
G.add_node(node_id, properties)
for source, edges in self.kg.edges.items():
for target, relation in edges:
G.add_edge(source, target, relation=relation)
return G
def compute_node_embeddings(self):
embeddings = {}
for node_id, properties in self.kg.nodes.items():
text = f"{node_id} {' '.join(properties.values())}"
embedding = self.model.encode(text)
embeddings[node_id] = embedding
return embeddings
def retrieve(self, query: str, k: int = 5) -> List[str]:
query_embedding = self.model.encode(query)
similarities = {
node_id: torch.cosine_similarity(
torch.tensor(query_embedding),
torch.tensor(emb), dim=0
)
for node_id, emb in self.node_embeddings.items()}
return sorted(
similarities, key=similarities.get, reverse=True
)[:k]
# Example usage
kg = KnowledgeGraph()
# Add more nodes and edges to the knowledge graph
graph_rag = GraphRAG(kg, "all-MiniLM-L6-v2")
retrieved_nodes = graph_rag.retrieve("What is the capital of France?")
print("Retrieved nodes:", retrieved_nodes)
在前面的代码中,我们使用了 NetworkX Python 包。NetworkX 包旨在创建、操作和研究复杂网络的结构、动态和功能。它提供了用于处理图(节点集合和节点之间的连接)的工具,并提供了一系列分析网络属性的算法,对于社会网络分析、生物学和基础设施研究等领域来说非常有价值。
这段代码定义了一个 GraphRAG 类,它将 KnowledgeGraph 对象与 Sentence Transformer 模型结合,以实现上下文感知的信息检索。该类使用 KnowledgeGraph 对象和 Sentence Transformer 模型名称初始化,它使用这些名称来构建知识图谱的 networkx 图表示,并根据每个节点的 ID 和属性计算嵌入。build_networkx_graph 方法将自定义的 KnowledgeGraph 对象转换为 networkx 有向图,保留节点属性和边关系。compute_node_embeddings 方法通过将节点的 ID 和属性连接成一个文本字符串并使用 Sentence Transformer 模型对其进行编码来生成每个节点的嵌入。
retrieve 方法接受一个查询,使用相同的 Sentence Transformer 对其进行编码,计算查询嵌入与每个节点嵌入之间的余弦相似度,并返回最相似的 k 个节点 ID。这种架构利用图结构和语义嵌入,根据查询上下文检索相关知识,弥合了符号知识表示和神经信息检索之间的差距。
现在,让我们探索更多高级技术来表示我们的图数据,以进一步增强我们的 LLM 检索系统的性能。具体来说,我们将深入研究 LLM 检索的图嵌入技术。
LLM 检索的图嵌入技术
图嵌入技术旨在将图中的节点表示为低维向量空间,捕捉图的结构属性和关系。存在几种方法,每种方法都有自己的方法——例如,Node2Vec通过有偏随机游走来探索邻域,平衡广度优先和深度优先探索。DeepWalk是另一种基于随机游走的方法,但执行的是均匀的游走。图卷积网络(GCNs)通过卷积操作从节点的邻居中聚合信息,根据图的结构和节点特征学习节点嵌入。图注意力网络(GATs)通过引入注意力机制来扩展 GCNs,在聚合信息时权衡不同邻居的重要性。知识图谱嵌入翻译(TransE)专门为知识图谱设计,将实体和关系表示为向量,使得如果(h, r, t)成立(头,关系,尾),则h + r ≈ t。
让我们以Node2Vec为例。Node2Vec 旨在创建能够保留网络邻域的嵌入。它通过采用平衡广度优先搜索(BFS)和深度优先搜索(DFS)的有偏随机游走来实现这一点。BFS 优先探索最近的邻居并捕获局部结构信息,而 DFS 探索远程节点,从而捕获更高阶的依赖关系和社区结构。偏差由两个参数控制,p(返回参数)和q(出入参数),它们分别影响重新访问前一个节点或探索远程节点的可能性。通过学习反映这些有偏随机游走的嵌入,Node2Vec 能够捕捉局部和全局网络结构,从而实现有效的节点分类、链接预测和社区检测:
from node2vec import Node2Vec
class AdvancedGraphRAG(GraphRAG):
def __init__(self, kg: KnowledgeGraph, model_name: str):
super().__init__(kg, model_name)
self.node2vec_embeddings = self.compute_node2vec_embeddings()
def compute_node2vec_embeddings(self):
node2vec = Node2Vec(
self.graph, dimensions=64, walk_length=30,
num_walks=200, workers=4
)
model = node2vec.fit(window=10, min_count=1)
return {node: model.wv[node]
for node in self.graph.nodes()
}
def retrieve(self, query: str, k: int = 5) -> List[str]:
query_embedding = self.model.encode(query)
combined_similarities = {}
for node_id in self.graph.nodes():
text_sim = torch.cosine_similarity(
torch.tensor(query_embedding),
torch.tensor(self.node_embeddings[node_id]),
dim=0
)
graph_sim = torch.cosine_similarity(
torch.tensor(query_embedding),
torch.tensor(self.node2vec_embeddings[node_id]),
dim=0
)
combined_similarities[node_id] = \
0.5 * text_sim + 0.5 * graph_sim
return sorted(
combined_similarities,
key=combined_similarities.get,
reverse=True
)[:k]
# Example usage
advanced_graph_rag = AdvancedGraphRAG(kg, "all-MiniLM-L6-v2")
retrieved_nodes = advanced_graph_rag.retrieve("What is the capital of France?")
print("Retrieved nodes:", retrieved_nodes)
此代码通过结合 Node2Vec 嵌入来增强检索性能,在 GraphRAG 类的基础上进行了扩展。它引入了一个 AdvancedGraphRAG 类,该类继承自 GraphRAG 并在初始化期间计算 Node2Vec 嵌入。compute_node2vec_embeddings 方法使用 node2vec 库生成这些嵌入,创建一个具有指定维度、行走长度、行走次数和工作线程的 Node2Vec 对象;然后通过在图结构上使用随机游走来训练 Node2Vec 模型,并提取学习到的节点嵌入。retrieve 方法被重写,以结合原始基于文本的嵌入和 Node2Vec 嵌入进行相似度计算。对于每个节点,它计算查询嵌入与基于文本的嵌入和 Node2Vec 嵌入之间的余弦相似度,然后以相等的权重平均这两个相似度得分,以产生一个综合相似度得分。最后,它返回具有最高综合相似度得分的前 k 个节点,利用语义和结构信息进行更有效的检索。
现在,让我们探讨如何通过利用图结构来细化我们的查询,进一步提高检索效果。在下一节中,我们将实现一个简单而有效的技术来扩大搜索范围。
在 LLM 中使用图结构进行查询扩展
我们可以利用图结构来扩展查询并提高检索效果。让我们实现一个简单的查询扩展技术:
import random
class QueryExpansionGraphRAG(AdvancedGraphRAG):
def expand_query(
self, query: str, num_expansions: int = 2
) -> List[str]:
initial_nodes = super().retrieve(query, k=3)
expanded_queries = [query]
for node in initial_nodes:
neighbors = list(self.graph.neighbors(node))
if neighbors:
random_neighbor = random.choice(neighbors)
expanded_query = (
f"{query}"
f"{self.graph.nodes[random_neighbor].
get('type', '')}"
f"{random_neighbor}"
)
expanded_queries.append(expanded_query)
if len(expanded_queries) >= num_expansions + 1:
break
return expanded_queries
def retrieve(self, query: str, k: int = 5) -> List[str]:
expanded_queries = self.expand_query(query)
all_retrieved = []
for q in expanded_queries:
all_retrieved.extend(super().retrieve(q, k))
return list(dict.fromkeys(all_retrieved))[:k]
# Example usage
query_expansion_rag = QueryExpansionGraphRAG(kg, "all-MiniLM-L6-v2")
retrieved_nodes = query_expansion_rag.retrieve("What is the capital of France?")
print("Retrieved nodes:", retrieved_nodes)
此代码在基于图的结构化检索系统(RAG)中实现了查询扩展,以增强检索性能。QueryExpansionGraphRAG 类继承自 AdvancedGraphRAG 并引入了一个 expand_query 方法,该方法接受一个查询和期望的扩展次数作为输入。首先,此方法使用基类的 retrieve 方法根据初始查询检索出最相关的三个节点。然后,它遍历这些初始节点,为每个节点随机选择一个邻居,并通过将邻居的类型(如果可用)和邻居的 ID 添加到原始查询中来构建一个扩展查询。retrieve 方法被重写,首先使用 expand_query 方法扩展输入查询。然后,它使用基类的 retrieve 方法为每个扩展查询检索结果,合并结果,同时保留顺序并移除重复项,最后返回前 k 个独特的节点。这种方法利用图结构来探索相关概念并扩大搜索范围,可能比单独的直接查询捕获到更多相关信息。
查询扩展在初始查询过于狭窄或未充分指定时特别有用,导致召回率低。在基于图的检索设置中,这通常发生在查询没有明确提及与图中语义或结构上相关联的相关实体或概念时。通过将相邻节点纳入查询制定中,系统可以发现否则会被忽视的相关内容,这使得查询扩展在探索性搜索场景或数据稀疏或高度互联的领域中特别有益。
现在我们已经探讨了增强检索的技术,让我们将注意力转向改进生成阶段。我们将深入了解将图信息集成到 LLM 生成过程中的方法,探讨如何将图知识直接集成到生成过程中,以创建更全面和连贯的响应。集成图信息到 LLM 生成
要将图信息集成到 LLM 生成中,我们可以创建一个提示,其中包含检索到的图上下文:
from transformers import AutoModelForCausalLM, AutoTokenizer
class GenerativeGraphRAG(QueryExpansionGraphRAG):
def __init__(
self, kg: KnowledgeGraph, retriever_model:
str, generator_model: str
):
super().__init__(kg, retriever_model)
self.generator = \
AutoModelForCausalLM.from_pretrained(generator_model)
self.generator_tokenizer = \
AutoTokenizer.from_pretrained(generator_model)
def generate_response(
self, query: str, max_length: int = 100
) -> str:
retrieved_nodes = self.retrieve(query)
context = self.build_graph_context(retrieved_nodes)
prompt = f"Graph Context:\n{context}\n\nQuestion: {query}\nAnswer:"
inputs = self.generator_tokenizer(
prompt, return_tensors="pt"
)
outputs = self.generator.generate(
inputs, max_length=max_length
)
return self.generator_tokenizer.decode(
outputs[0], skip_special_tokens=True
)
def build_graph_context(self, nodes: List[str]) -> str:
context = []
for node in nodes:
context.append(f"Node: {node}")
context.append(f"Properties: {self.graph.nodes[node]}")
for neighbor, edge_data in self.graph[node].items():
context.append(
f" Related to {neighbor} by
{edge_data['relation']}")
return "\n".join(context)
# Example usage
generative_graph_rag = GenerativeGraphRAG(
kg, "all-MiniLM-L6-v2", "gpt2-medium"
)
response = generative_graph_rag.generate_response("What is the capital of France?")
print("Generated response:", response)
此代码在基于图的 RAG 框架内集成了一个用于响应生成的 LLM。GenerativeGraphRAG类从QueryExpansionGraphRAG继承,并使用KnowledgeGraph(知识图谱)、检索器模型名称和生成器模型名称进行初始化。它使用transformers加载了一个预训练的因果语言模型及其相应的分词器。generate_response方法协调整个过程:首先,它使用从父类继承的retrieve方法从知识图谱中检索相关节点。然后,它通过调用build_graph_context构建一个上下文字符串,该字符串格式化检索到的节点、它们的属性以及它们与其他节点的关系,形成一个可读的文本。然后,这个上下文与原始查询一起纳入提示中,并输入到预训练的语言模型中。语言模型根据提示生成一个响应,生成的标记被解码回人类可读的字符串,有效地利用图结构来指导语言模型生成响应。build_graph_context方法将检索到的图信息格式化为提示,包括节点 ID、属性以及与邻居的关系,为 LLM 提供相关知识的结构化表示。
现在我们已经探讨了如何将图信息集成到生成过程中,让我们考虑这一方法的更广泛的应用和潜在用途。
图 RAG 在 LLM 中的应用和用例
基于图的 RAG 在多种应用中特别有效:
-
基于知识图谱的问答
-
个性化推荐系统
-
科学文献分析
-
药物发现和生物医学研究
-
社交网络分析
这里有一个如何使用图 RAG 构建推荐系统的例子:
class RecommendationGraphRAG(GenerativeGraphRAG):
def get_recommendations(
self, user_id: str, num_recommendations: int = 5
) -> List[str]:
user_node = self.retrieve(f"User {user_id}", k=1)[0]
user_interests = self.graph.nodes[user_node].
get('interests', [])
potential_recommendations = set()
for interest in user_interests:
related_items = self.retrieve(interest, k=3)
potential_recommendations.update(related_items)
recommendations = list(
potential_recommendations - set(user_interests)
)[:num_recommendations]
return recommendations
def explain_recommendation(
self, user_id: str, item_id: str
) -> str:
query = f"Why would User {user_id} be interested in {item_id}?"
return self.generate_response(query)
# Example usage
recommendation_rag = RecommendationGraphRAG(
kg, "all-MiniLM-L6-v2", "gpt2-medium"
)
user_id = "12345"
recommendations = recommendation_rag.get_recommendations(user_id)
print(f"Recommendations for User {user_id}:", recommendations)
for item in recommendations[:2]:
explanation = recommendation_rag.explain_recommendation(user_id,
item)
print(f"Explanation for recommending {item}:", explanation)
这个例子展示了如何使用图 RAG 生成个性化推荐,并利用图结构解释这些推荐。
基于图的 RAG 的挑战和未来方向
让我们考虑一些基于图的关系增强生成(RAG)的关键挑战和未来研究方向:
-
可扩展到非常大的图
-
处理动态和演变的图结构
-
结合不确定性和概率关系
-
提高基于图的检索和生成的可解释性
-
开发更复杂的图感知语言模型
这些是令人着迷且复杂的研究主题。在本章中,我们将关注基于图的 RAG 的可扩展性方面。我们鼓励您阅读标题为《图检索增强生成:综述》的研究论文arxiv.org/abs/2408.08921,以了解更多关于其他挑战和研究方向的信息。
实际世界的知识图谱可能包含数百万甚至数十亿个节点和边。查询和遍历这样的大规模图可能计算成本高昂,尤其是在实时 RAG 管道中。此外,向 LLM 提供巨大的子图作为上下文可能会超过其上下文窗口限制,并使相关信息与噪声稀释。
几个因素导致了这个可扩展性瓶颈:
-
图遍历复杂性:在大型图中找到相关节点及其连接可能耗时。随着图的增长,标准图算法如 BFS 或 DFS 可能变得效率低下。
-
嵌入存储和检索:存储和检索大规模图的节点嵌入需要大量的内存和计算资源。计算查询嵌入与所有节点嵌入之间的相似度分数成为瓶颈。
-
上下文窗口限制:LLM 有一个有限的范围,这意味着它们一次只能处理固定数量的文本。大型图上下文很容易超过这个限制,导致截断,并可能造成重要信息的丢失。
-
上下文中的噪声:将过多无关信息从图中作为上下文可能会混淆 LLM 并降低生成响应的质量。
为了解决这些可扩展性挑战,可以采用几种策略。我们将实施的一种策略是子图采样。这涉及到从整体知识图谱中提取一个更小、更易于管理的子图,该子图与用户的查询最相关。这降低了图遍历和嵌入检索的计算成本,同时确保 LLM 接收到的上下文是专注且信息丰富的。提高可扩展性的其他技术包括以下内容:
-
图数据库:使用专门的图数据库,如 Neo4j 或 Amazon Neptune,与通用数据库相比,可以显著提高查询性能和可扩展性。
-
近似最近邻(ANN)搜索:使用 ANN 算法进行嵌入检索可以通过牺牲一些精度显著加快搜索过程。
-
知识图谱摘要:将知识图谱压缩成更小、更易于管理的表示形式,同时保留其基本信息。
-
硬件加速:利用 GPU 或专用硬件加速器可以加快图计算和嵌入操作。
-
上下文蒸馏:如选择性上下文注入或分层检索等技术可以过滤并优先选择对 LLM 最相关的信息。
现在,让我们继续实现子图采样,看看它如何帮助解决可扩展性问题:
import networkx as nx
class ScalableGraphRAG(GenerativeGraphRAG):
def __init__(
self, kg: KnowledgeGraph, retriever_model: str,
generator_model: str, max_subgraph_size: int = 1000
):
super().__init__(kg, retriever_model, generator_model)
self.max_subgraph_size = max_subgraph_size
def retrieve(self, query: str, k: int = 5) -> List[str]:
initial_nodes = super().retrieve(query, k=k)
subgraph = self.sample_subgraph(initial_nodes)
return self.rank_nodes_in_subgraph(subgraph, query)[:k]
def sample_subgraph(self, seed_nodes: List[str]) -> nx.Graph:
subgraph = nx.Graph()
frontier = set(seed_nodes)
while len(subgraph) < self.max_subgraph_size and frontier:
node = frontier.pop()
if node not in subgraph:
subgraph.add_node(node, self.graph.nodes[node])
neighbors = list(self.graph.neighbors(node))
for neighbor in neighbors:
if len(subgraph) < self.max_subgraph_size:
subgraph.add_edge(
node, neighbor,
self.graph[node][neighbor]
)
frontier.add(neighbor)
else:
break
return subgraph
def rank_nodes_in_subgraph(
self, subgraph: nx.Graph, query: str
) -> List[str]:
query_embedding = self.model.encode(query)
node_scores = {}
for node in subgraph.nodes():
node_embedding = self.node_embeddings[node]
score = torch.cosine_similarity(
torch.tensor(query_embedding),
torch.tensor(node_embedding), dim=0
)
node_scores[node] = score
return sorted(node_scores, key=node_scores.get, reverse=True)
# Example usage
scalable_graph_rag = ScalableGraphRAG(
kg, "all-MiniLM-L6-v2", "gpt2-medium"
)
retrieved_nodes = scalable_graph_rag.retrieve("What is the capital of France?")
print("Retrieved nodes:", retrieved_nodes)
此代码引入了一个ScalableGraphRAG类,该类通过实现子图采样技术来解决基于图 RAG 系统的可扩展性挑战。它从GenerativeGraphRAG继承,并包含一个max_subgraph_size参数来限制提取的子图大小。
重写的检索方法首先使用基类的检索机制确定一组初始相关节点。然后,它调用sample_subgraph方法构建一个以这些初始节点为中心的子图,限制其增长到指定的max_subgraph_size。
sample_subgraph方法从种子节点进行广度优先扩展,向子图添加节点和边,直到达到大小限制,优先考虑靠近种子节点的节点。
可以通过调整max_subgraph_size参数来调整子图采样,以平衡上下文丰富性和计算效率。较小的尺寸会导致处理速度更快,但可能会错过关键上下文信息,而较大的尺寸可以捕获更多上下文,但会增加计算成本。此外,算法在子图扩展期间的节点选择标准也可以进行调整——例如,优先考虑与查询具有更高语义相似度的节点或与种子节点具有更强连接性的节点。对这些参数进行实验对于优化 RAG 系统针对特定应用和图结构的性能是有用的。
最后,rank_nodes_in_subgraph方法通过计算查询嵌入与节点预先计算的嵌入之间的余弦相似度来计算子图内每个节点相对于查询的相关性。然后,它根据相似度分数返回一个节点排名列表,确保只考虑采样子图中最相关的节点进行上下文增强。
摘要
基于图的 RAG 通过利用知识图谱的丰富结构扩展了传统 RAG 系统的功能。通过实施本章讨论的技术和方法,您可以创建更复杂的 LLM 系统,这些系统能够对复杂关系进行推理并生成更符合语境的响应。在下一章中,我们将探讨 LLM 的高级 RAG 模式。这将基于我们在这里讨论的图技术,以便您创建更强大和灵活的 RAG 系统。
第二十八章:高级 RAG
在第二十六章中,我们介绍了 RAG 模式的基础,这是一个简单的流程,其中用户的查询触发对外部知识库的搜索。检索到的信息随后直接附加到查询中,并将这个增强的提示传递给 LLM 以生成响应,允许它在不进行复杂处理的情况下访问外部数据。
现在,在本章中,我们将超越这些基本的 RAG 方法,并探索更多旨在显著提高 LLM 在各种任务上性能的复杂技术。
到本章结束时,你将具备实施这些高级 RAG 策略的知识,使你的 LLM 应用能够实现更高的准确性和效率。
在本章中,我们将涵盖以下主题:
-
LLM 的多步和迭代检索技术
-
在 LLM 中基于上下文和任务的自适应检索
-
通过元学习改进 LLM 中的检索
-
将 RAG 与其他 LLM 提示技术相结合
-
处理基于 LLM 的 RAG 中的模糊性和不确定性
-
将 RAG 扩展到非常大的知识库
-
LLM 的 RAG 研究未来方向
LLM 的多步和迭代检索技术
使用 LLM 的多步和迭代检索技术是一种动态、递归的信息收集方法,其中模型逐步优化其搜索策略。本节提供的代码演示了一个多步 RAG 框架,该框架通过迭代扩展上下文,检索额外的文档,并通过多个步骤生成响应,通过动态调整查询和整合检索到的知识,实现越来越全面和细致的信息检索。
它的一些关键特性包括:
-
迭代上下文扩展
-
多步检索步骤(可配置至
max_steps) -
动态查询优化
-
上下文文档检索
-
自适应响应生成
LLM 的多步和迭代检索技术,其动态和递归方法,对以下方面的用例有益:
-
复杂问答:当问题需要从多个来源综合信息或涉及复杂的逻辑推理时,迭代检索允许 LLM 逐步收集必要的环境。例如,包括法律文件分析、科学研究以及深入的财务分析。
-
知识密集型对话:在涉及深入探讨主题的对话式 AI 场景中,迭代 RAG 允许 LLM 在多个回合中保持上下文并逐步优化其理解。这对于教育聊天机器人、技术支持和交互式教程非常有价值。
-
研究和探索:对于文献综述、市场研究或调查性新闻等任务,动态细化查询和探索相关信息的能力至关重要。迭代检索允许 LLM 充当研究助手,揭示难以通过单次查询找到的关联和见解。
-
技术文档和故障排除:在处理复杂技术问题时,迭代 RAG 可以帮助 LLM 导航广泛的文档,逐步缩小搜索范围以定位相关信息。这提高了故障排除和技术支持效率。
-
动态信息收集:这包括任何需要通过单次遍历无法收集到所需信息的情况。例如,如果用户想要查找与特定法庭案件相关的所有新闻文章,然后又想了解社交媒体上人们对这些新闻文章的看法,就需要进行多个步骤的信息收集。
-
处理模糊查询:当用户的查询模糊时,LLM 可以提出澄清问题,然后使用用户的响应来细化搜索。
从本质上讲,任何需要深入、细致理解信息,并且单次检索步骤不足的应用场景,都能从多步和迭代 RAG 中获得显著收益。
让我们看看以下代码示例:
from typing import List, Dict
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
class MultiStepRAG:
def __init__(self, retriever, generator, max_steps=3):
self.retriever = retriever
self.generator = generator
self.tokenizer = AutoTokenizer.from_pretrained(generator)
self.max_steps = max_steps
def retrieve_and_generate(self, query: str) -> str:
context = ""
for step in range(self.max_steps):
retrieved_docs = self.retriever.retrieve(
query + " " + context, k=3
)
context += " ".join(retrieved_docs) + " "
prompt = f"Context: {context}\nQuery: {query}\nResponse:"
inputs = self.tokenizer(
prompt, return_tensors="pt"
)
outputs = self.generator.generate(inputs, max_length=200)
response = self.tokenizer.decode(
outputs[0], skip_special_tokens=True
)
if self.is_response_complete(response):
break
query = self.generate_follow_up_query(query, response)
return response
def is_response_complete(self, response: str) -> bool:
# Implement logic to determine if the response is complete
return "I don't have enough information" not in response
def generate_follow_up_query(
self, original_query: str, current_response: str
) -> str:
prompt = f"Original question: {original_query}\nCurrent answer: {current_response}\nGenerate a follow-up question to gather more information:"
inputs = self.tokenizer(prompt, return_tensors="pt")
outputs = self.generator.generate(inputs, max_length=50)
return self.tokenizer.decode(outputs[0],
skip_special_tokens=True)
# Example usage
retriever = SomeRetrieverClass() # Replace with your actual retriever
generator = AutoModelForCausalLM.from_pretrained("gpt2-medium")
multi_step_rag = MultiStepRAG(retriever, generator)
response = multi_step_rag.retrieve_and_generate("What are the effects of climate change on biodiversity?")
print(response)
在这个伪代码示例中,MultiStepRAG类通过三个关键方法实现了多步检索:
-
retrieve_and_generate(): 此方法通过检索文档、生成响应和动态更新多个步骤中的搜索上下文来迭代地扩展上下文。它管理检索过程,将迭代限制在可配置的最大值内。 -
is_response_complete(): 此方法通过检测生成的答案是否充分回答了查询,通常检查不完整信息的指标,来评估响应质量。 -
generate_follow_up_query(): 此方法通过使用语言模型根据原始查询和当前响应生成新问题,创建精细的后续查询,从而实现智能上下文探索。
此实现允许逐步收集信息,其中每个检索步骤都动态地细化上下文,并通过递归扩展知识库来生成更全面的响应。
基于上下文和任务的自适应检索在 LLM 中
自适应检索是一种复杂的信息检索方法,它根据特定的任务需求动态调整策略。
以下代码通过针对不同任务类型定制检索和生成过程的实现来演示这一概念:
from enum import Enum
class TaskType(Enum):
FACTUAL_QA = 1
SUMMARIZATION = 2
ANALYSIS = 3
class AdaptiveRAG:
def __init__(self, retriever, generator):
self.retriever = retriever
self.generator = generator
self.tokenizer = AutoTokenizer.from_pretrained(generator)
def retrieve_and_generate(self, query: str, task_type: TaskType
) -> str:
if task_type == TaskType.FACTUAL_QA:
k = 3
prompt_template = "Context: {context}\nQuestion: {query}\nAnswer:"
elif task_type == TaskType.SUMMARIZATION:
k = 10
prompt_template = "Summarize the following information:\n{context}\nSummary:"
elif task_type == TaskType.ANALYSIS:
k = 5
prompt_template = "Analyze the following information:\n{context}\nQuery: {query}\nAnalysis:"
retrieved_docs = self.retriever.retrieve(query, k=k)
context = " ".join(retrieved_docs)
prompt = prompt_template.format(context=context, query=query)
inputs = self.tokenizer(prompt, return_tensors="pt")
outputs = self.generator.generate(inputs, max_length=300)
response = self.tokenizer.decode(
outputs[0], skip_special_tokens=True
)
return response
# Example usage
adaptive_rag = AdaptiveRAG(retriever, generator)
factual_response = adaptive_rag.retrieve_and_generate(
"What is the capital of France?",
TaskType.FACTUAL_QA
)
summary_response = adaptive_rag.retrieve_and_generate(
"Summarize the causes of World War I",
TaskType.SUMMARIZATION
)
analysis_response = adaptive_rag.retrieve_and_generate(
"Analyze the impact of social media on mental health",
TaskType.ANALYSIS
)
上述代码引入了一个名为AdaptiveRAG的类,它使用一个名为TaskType的Enum值来定义不同场景下的不同检索策略:事实性问题回答、摘要和分析。每种任务类型在文档检索量和提示格式方面都接受定制处理。
在retrieve_and_generate()方法中,系统动态配置检索参数:
-
Factual QA:此操作检索三个具有直接问答格式的文档 -
Summarization:此操作检索十个以摘要为重点的文档 -
Analysis:此操作检索五个具有分析提示结构的文档
该方法检索相关文档,构建上下文,生成特定任务的提示,并产生针对特定任务类型的定制响应。这种方法允许在不同知识探索场景中进行更细致和上下文相关的信息检索和生成。
此示例用法通过使用相同的自适应框架生成事实查询、摘要和分析任务的响应,展示了其灵活性。
用于改进 LLM 检索的元学习
元学习在检索系统中是一种动态方法,模型通过分析过去的表现和相关性反馈来学习改进其检索策略。在本实现中,元学习侧重于根据学习到的相关性模式自适应地选择和排序文档。
让我们为 RAG 实现一个简单的元学习方法。
以下代码通过检索关于暗物质理论的文档并模拟相关性反馈来训练模型,展示了如何迭代地提高系统的信息检索能力:
import numpy as np
from sklearn.linear_model import LogisticRegression
class MetaLearningRAG:
def __init__(self, retriever, generator):
self.retriever = retriever
self.generator = generator
self.tokenizer = AutoTokenizer.from_pretrained(generator)
self.meta_model = LogisticRegression()
self.training_data = []
def retrieve_and_generate(self, query: str) -> str:
retrieved_docs = self.retriever.retrieve(query, k=10)
if self.meta_model.coef_.size > 0: # If the meta-model has been trained
relevance_scores = self.predict_relevance(
query, retrieved_docs)
top_docs = [
doc for _, doc in sorted(
zip(relevance_scores, retrieved_docs),
reverse=True
)
][:3]
else:
top_docs = retrieved_docs[:3]
context = " ".join(top_docs)
prompt = f"Context: {context}\nQuery: {query}\nResponse:"
inputs = self.tokenizer(prompt, return_tensors="pt")
outputs = self.generator.generate(inputs, max_length=200)
response = self.tokenizer.decode(outputs[0],
skip_special_tokens=True)
return response
def predict_relevance(self, query: str, docs: List[str]
) -> np.ndarray:
features = self.compute_features(query, docs)
return self.meta_model.predict_proba(features)[:, 1] # Probability of relevance
def compute_features(self, query: str, docs: List[str]
) -> np.ndarray:
# Compute features for the query-document pairs
# This is a placeholder implementation
return np.random.rand(len(docs), 5) # 5 random features
def update_meta_model(
self, query: str, retrieved_docs: List[str],
relevance_feedback: List[int]
):
features = self.compute_features(query, retrieved_docs)
self.training_data.extend(zip(features, relevance_feedback))
if len(self.training_data) >= 100: # Train the meta-model periodically
X, y = zip(*self.training_data)
self.meta_model.fit(X, y)
self.training_data = [] # Clear the training data after updating the model
# Example usage
meta_learning_rag = MetaLearningRAG(retriever, generator)
response = meta_learning_rag.retrieve_and_generate(
"What are the main theories of dark matter?"
)
print(response)
# Simulating relevance feedback
retrieved_docs = meta_learning_rag.retriever.retrieve(
"What are the main theories of dark matter?",
k=10
)
relevance_feedback = [1, 0, 1, 1, 0, 0, 1, 0, 0, 1] # 1 for relevant, 0 for not relevant
meta_learning_rag.update_meta_model(
"What are the main theories of dark matter?",
retrieved_docs, relevance_feedback
)
上述代码中的关键元学习组件包括以下内容:
-
predict_relevance()方法估计文档的有用性概率 -
根据学习到的特征动态调整文档选择
-
compute_features()方法生成文档表示特征* 目前,它使用随机生成的值作为演示或测试目的的占位符特征* 在实践中,它将包括语义相似度、关键词匹配等更多内容.* 自适应 学习机制:-
从相关性反馈中积累训练数据
-
当收集到足够的数据时(100 个样本)重新训练元模型
-
在模型更新后清除训练数据以防止过拟合* 检索 策略修改:
-
初始使用检索到的前 10 个文档
-
在元模型训练后,根据学习到的相关性分数选择前三个文档
-
持续优化文档选择过程
-
该代码实现了一个MetaLearningRAG类,它使用机器学习技术动态增强检索性能。其核心创新在于其能够从相关性反馈中学习并调整文档选择策略。
让我们看看关键的方法:
-
retrieve_and_generate(): 使用训练好的元模型选择顶级文档 -
predict_relevance(): 估计文档的相关性概率 -
compute_features(): 为文档生成特征表示 -
update_meta_model(): 根据相关性反馈定期重新训练模型
实现使用逻辑回归来预测文档的相关性,通过学习用户交互逐步优化检索。当积累足够的训练数据后,元模型被重新训练,使系统能够根据历史性能和反馈调整其文档选择策略。
在检索系统的元学习背景下,相关性指的是为特定查询检索到的文档的上下文有用性和信息价值。
让我们看看前面代码中显示的关键 相关性 方面:
-
相关性评分:
-
预测文档的有用性概率
-
使用机器学习学习相关性模式
-
允许动态文档排名
-
-
1= 相关,0= 不相关) -
使系统能够从用户提供的质量信号中学习
-
改善未来的文档选择
-
基于特征的相关性:
-
计算表示潜在有用性的文档特征
-
之前的代码使用随机特征
-
捕获语义和上下文关系
-
核心目标是创建一个自适应的检索系统,通过迭代反馈和机器学习技术学习选择越来越精确和有价值的文档。
将 RAG 与其他 LLM 提示技术结合
我们可以通过结合其他提示技术,如 CoT(见第二十章)或少样本学习来增强 RAG。以下是一个将 RAG 与 CoT 结合的示例:
class RAGWithCoT:
def __init__(self, retriever, generator):
self.retriever = retriever
self.generator = generator
self.tokenizer = AutoTokenizer.from_pretrained(generator)
def retrieve_and_generate(self, query: str) -> str:
retrieved_docs = self.retriever.retrieve(query, k=3)
context = " ".join(retrieved_docs)
cot_prompt = f"""Context: {context}
Question: {query}
Let's approach this step-by-step:
1) First, we should consider...
2) Next, we need to analyze...
3) Then, we can conclude...
Based on this reasoning, the final answer is:
Answer:"""
inputs = self.tokenizer(cot_prompt, return_tensors="pt")
outputs = self.generator.generate(inputs, max_length=500)
response = self.tokenizer.decode(outputs[0],
skip_special_tokens=True)
return response
# Example usage
rag_with_cot = RAGWithCoT(retriever, generator)
response = rag_with_cot.retrieve_and_generate("What are the potential long-term effects of artificial intelligence on employment?")
print(response)
RAGWithCoT类实现了增强 CoT 推理的 RAG 方法。通过检索相关文档并构建一个鼓励逐步解决问题的提示,该方法将标准的查询响应生成转变为一个更结构化、分析性的过程。
实现引导语言模型通过一个明确的推理框架,将复杂查询分解为逻辑步骤。这种方法促使模型展示中间推理,创建一个更透明且可能更准确的响应生成过程。
该方法将上下文文档检索与精心设计的提示模板相结合,该模板明确地结构化了模型的推理。通过要求模型在呈现最终答案之前概述其思考过程,实现寻求提高生成响应的深度和质量。
随着我们探索高级 RAG 技术,下一个关键挑战出现:处理基于语言模型的信息检索中的模糊性和不确定性。下一节将深入探讨管理复杂、细微且可能存在冲突的信息源的复杂策略,突出能够实现更稳健和可靠的知识提取和生成的途径。
处理基于 LLM 的 RAG 中的模糊性和不确定性
模糊性和不确定性直接损害了生成响应的准确性和可靠性。例如,模糊的查询可能会触发检索无关或冲突信息的过程,导致 LLM 产生不连贯或错误的输出。考虑查询“关于苹果怎么样?”这可能指的是苹果公司、水果或特定的苹果品种。一个简单的 RAG 系统可能会从所有上下文中提取数据,导致混乱的响应。
此外,由于知识库中存在冲突或过时的数据,检索到的信息中的不确定性加剧了问题。如果没有评估数据可靠性的机制,LLM 可能会传播不准确性。LLMs 自身基于概率运作,增加了另一层不确定性。例如,在处理一个细分主题时,LLM 可能会生成一个“最佳猜测”,如果没有适当的概率估计,可能会被当作事实呈现。结合多个不确定信息进一步加剧了这个问题,可能导致误导和不可靠的反应,最终损害用户信任并限制 RAG 系统的实际应用。
为了处理模糊性和不确定性,我们可以实施一个生成多个假设并按置信度对它们进行排名的系统:
class UncertaintyAwareRAG:
def __init__(self, retriever, generator, n_hypotheses=3):
self.retriever = retriever
self.generator = generator
self.tokenizer = AutoTokenizer.from_pretrained(generator)
self.n_hypotheses = n_hypotheses
def retrieve_and_generate(self, query: str) -> Dict[str, float]:
retrieved_docs = self.retriever.retrieve(query, k=5)
context = " ".join(retrieved_docs)
prompt = (
f"Context: {context}\n"
"Question: {query}\n"
f"Generate {self.n_hypotheses} possible answers "
f"with confidence scores:\n"
)
inputs = self.tokenizer(prompt, return_tensors="pt")
outputs = self.generator.generate(
inputs, max_length=500,
num_return_sequences=self.n_hypotheses
)
hypotheses = []
for output in outputs:
hypothesis = self.tokenizer.decode(
output, skip_special_tokens=True
)
hypotheses.append(self.parse_hypothesis(hypothesis))
return dict(
sorted(
hypotheses, key=lambda x: x[1], reverse=True
)
)
def parse_hypothesis(self, hypothesis: str) -> Tuple[str, float]:
# This is a simple parser, assuming the format "Answer (Confidence: X%): ..."
parts = hypothesis.split(":")
confidence = float(
parts[0].split("(Confidence: ")[1].strip("%)"))/100
answer = ":".join(parts[1:]).strip()
return (answer, confidence)
# Example usage
uncertainty_aware_rag = UncertaintyAwareRAG(retriever, generator)
hypotheses = uncertainty_aware_rag.retrieve_and_generate(
"What will be the dominant form of energy in 2050?"
)
for answer, confidence in hypotheses.items():
print(f"Hypothesis (Confidence: {confidence:.2f}): {answer}")
上述代码实现了一个UncertaintyAwareRAG类,该类通过生成具有置信度分数的多个可能答案来智能地处理模糊查询。它通过初始化检索组件(用于获取相关文档)、生成器(语言模型)和生成假设数量的参数来工作。当使用查询调用retrieve_and_generate时,它检索相关文档并将它们组合成一个上下文,然后构建一个专门的提示,要求提供具有置信度分数的多个可能答案。生成器使用num_return_sequences参数生成多个假设,每个假设都包括一个置信度分数。这些假设使用parse_hypothesis方法进行解析,该方法从标准格式"Answer (Confidence: X%): ..."中提取答案文本及其置信度分数。然后,结果按置信度分数排序,并返回一个将答案映射到其置信度值的字典。这种方法对于可能没有单一确定答案的问题(如未来预测或复杂场景)特别有价值,因为它明确承认了不确定性,并提供了具有相关置信水平的多条可能的响应,使用户能够根据可能性的范围及其相对可能性做出更明智的决定。
在我们的 RAG 系统中实现不确定性处理之后,下一个关键挑战是处理大量的文档集合。随着知识库增长到数百万甚至数十亿文档,传统的检索方法变得不切实际,需要更复杂的方法。让我们探讨如何通过分层索引来扩展 RAG,使其能够高效地处理非常大的知识库。
将 RAG 扩展到非常大的知识库
我们可以使用分层系统来扩展 RAG。一个分层 RAG 系统是一种高级架构,它以树状结构组织文档检索,具有多个层级。它不是线性地搜索所有文档,而是首先将相似的文档聚类在一起,并创建这些聚类的层次结构。当查询到来时,系统识别出最相关的顶级聚类(们),然后深入挖掘以找到最相关的子聚类,并最终从这些目标子聚类中检索出最相似的文档。想象一下,就像一个图书馆,书籍首先按广泛的类别(科学、历史、小说)组织,然后按子类别(物理学、生物学、化学)组织,最后按具体主题组织——这使得找到特定书籍比搜索每一本书要快得多。
RAG 的分层方法提供了显著的优势,因为它显著提高了文档检索的效率和可扩展性,同时保持了高精度。通过将文档组织成簇和子簇,系统可以快速将搜索空间从可能数百万份文档缩小到一个更小、更相关的子集,这不仅加快了检索速度,还减少了计算资源和内存需求。这使得处理大规模文档集合成为可能,这在传统的平面检索方法中是不切实际的。分层结构还使得搜索操作能够更好地并行化,并且通过考虑层次结构内的文档关系,甚至可以提高结果质量。
以下代码片段定义了一个用于分层 RAG 的类,利用 Facebook 的 AI 相似性搜索(Faiss)库进行高效的相似性搜索和生成能力:
import faiss
class HierarchicalRAG:
def __init__(
self, generator, embeddings, texts, n_clusters=1000
):
self.generator = generator
self.tokenizer = AutoTokenizer.from_pretrained(generator)
self.embeddings = embeddings
self.texts = texts
# Create a hierarchical index
self.quantizer = faiss.IndexFlatL2(embeddings.shape[1])
self.index = faiss.IndexIVFFlat(
self.quantizer, embeddings.shape[1], n_clusters
)
self.index.train(embeddings)
self.index.add(embeddings)
def retrieve(self, query: str, k: int = 5) -> List[str]:
query_embedding = self.compute_embedding(query)
_, indices = self.index.search(
query_embedding.reshape(1, -1), k
)
return [self.texts[i] for i in indices[0]]
def compute_embedding(self, text: str) -> np.ndarray:
# Compute embedding for the given text
# This is a placeholder implementation
return np.random.rand(1, self.embeddings.shape[1])
def retrieve_and_generate(self, query: str) -> str:
retrieved_docs = self.retrieve(query)
context = " ".join(retrieved_docs)
prompt = f"Context: {context}\nQuery: {query}\nResponse:"
inputs = self.tokenizer(prompt, return_tensors="pt")
outputs = self.generator.generate(inputs, max_length=200)
response = self.tokenizer.decode(outputs[0],
skip_special_tokens=True)
return response
# Example usage
embeddings = np.random.rand(1000000, 128) # 1 million documents, 128-dimensional embeddings
texts = ["Document " + str(i) for i in range(1000000)]
hierarchical_rag = HierarchicalRAG(generator, embeddings, texts)
response = hierarchical_rag.retrieve_and_generate(
"What are the latest advancements in quantum computing?"
)
print(response)
上述代码实现了一个HierarchicalRAG类,它使用1000创建了一个高效的检索系统——它使用 FAISS 的IVFFlat索引,这是一个先对向量进行聚类然后在这些相关簇内进行精确搜索的分层索引,其中量化器(IndexFlatL2)在训练期间用于将向量分配到簇中。retrieve方法接收一个查询并返回k个相似的文档,首先计算查询的嵌入,然后搜索分层索引。compute_embedding方法是一个占位符,通常用于实现实际的嵌入计算。retrieve_and_generate方法通过检索相关文档,将它们连接成一个上下文,创建一个结合上下文和查询的提示,然后使用语言模型生成响应。示例用法展示了如何使用 1 百万份文档(出于演示目的使用随机嵌入)初始化系统,并执行关于量子计算的查询。首先,IVFFlat索引在训练期间将相似的文档分组在一起(index.train()),然后使用这些簇通过仅在最相关的簇中搜索来加速搜索操作,而不是在整个数据集中搜索,这使得在处理大型文档集合时比蛮力方法更加高效。
现在我们已经探讨了如何通过分层索引扩展 RAG 系统以处理大规模知识库,让我们展望一下 LLMs 在 RAG 研究中的令人兴奋的未来方向。
LLMs 在 RAG 研究中的未来方向
随着 RAG 的不断发展,几个有希望的研究方向开始出现:
-
多模态 RAG:在检索和生成中结合图像、音频和视频数据
-
时间敏感 RAG:处理时间敏感信息和更新
-
个性化 RAG:根据个人用户偏好和知识调整检索和生成
-
可解释的 RAG:在检索和生成过程中提供透明度
-
RAG 中的持续学习:实时更新知识库和检索机制
这里是一个多模态 RAG 系统的概念实现:
from PIL import Image
import torch
from torchvision.transforms import Resize, ToTensor
class MultiModalRAG:
def __init__(self, text_retriever, image_retriever, generator):
self.text_retriever = text_retriever
self.image_retriever = image_retriever
self.generator = generator
self.tokenizer = AutoTokenizer.from_pretrained(generator)
self.image_transform = transforms.Compose([
Resize((224, 224)),
ToTensor(),
])
def retrieve_and_generate(
self, query: str, image_query: Image.Image = None
) -> str:
text_docs = self.text_retriever.retrieve(query, k=3)
text_context = " ".join(text_docs)
if image_query:
image_tensor = \
self.image_transform(image_query).unsqueeze(0)
image_docs = self.image_retriever.retrieve(
image_tensor, k=2)
image_context = self.describe_images(image_docs)
else:
image_context = ""
prompt = f"""Text Context: {text_context}
Image Context: {image_context}
Query: {query}
Based on both the textual and visual information provided, please respond to the query:
Response:"""
inputs = self.tokenizer(prompt, return_tensors="pt")
outputs = self.generator.generate(inputs, max_length=300)
response = self.tokenizer.decode(outputs[0],
skip_special_tokens=True)
return response
def describe_images(self, image_docs: List[Image.Image]) -> str:
# This method would use an image captioning model to describe the retrieved images
# For simplicity, we'll use placeholder descriptions
descriptions = [f"Image {i+1}: A relevant visual representation" for i in range(len(image_docs))]
return " ".join(descriptions)
# Example usage
text_retriever = SomeTextRetrieverClass() # Replace with your actual text retriever
image_retriever = SomeImageRetrieverClass() # Replace with your actual image retriever
multi_modal_rag = MultiModalRAG(
text_retriever, image_retriever, generator
)
query = "Explain the process of photosynthesis in plants"
image_query = Image.open("plant_image.jpg") # Load an image of a plant
response = multi_modal_rag.retrieve_and_generate(query, image_query)
print(response)
让我们了解这段代码是如何实现一个结合文本和图像处理能力的多模态 RAG 系统的。
MultiModalRAG类代表了一种高级 RAG 系统,能够同时处理文本和视觉信息,以提供更全面的响应。它由三个关键组件初始化:一个文本检索器(用于处理文本文档)、一个图像检索器(用于处理视觉内容)和一个生成器(用于响应生成的语言模型),以及一个图像转换器,该转换器将图像标准化为一致的大小(224 x 224)。核心方法retrieve_and_generate接受一个文本查询和一个可选的图像查询,首先使用文本检索器检索相关文本文档。然后,如果提供了图像,它通过图像转换器处理该图像,并使用图像检索器检索相关图像。这些检索到的图像随后通过describe_images方法(在实际实现中会使用图像标题模型)转换为文本描述。所有这些信息结合成一个结构化的提示,包括文本和图像上下文,使生成器能够创建结合文本和视觉信息的响应。这种多模态方法对于受益于视觉上下文的查询特别强大,例如解释科学过程、描述物理对象或分析视觉模式。这在先前的示例中得到了演示,其中它被用来结合文本信息和植物图像来解释光合作用。
上述代码代表了 RAG 系统向前迈出的重要一步,通过以下方式实现:
-
打破传统的纯文本障碍
-
实现更丰富、更具上下文的相关响应
-
创建一个灵活的框架,可以扩展到其他模态
-
展示如何将不同类型的信息统一在一个系统中
摘要
本章将 RAG 从一种基本的数据检索方法提升为一个构建真正自适应的 LLM(大型语言模型)系统的动态框架。它探讨了诸如迭代和自适应检索、元学习和协同提示等技术,将 RAG 转变为一个具有复杂分析和细微理解能力的上下文感知问题解决者,与专家级研究相呼应。解决歧义、不确定性和可扩展性问题并不仅仅是克服障碍,更是建立信任并实现现实世界部署。
在下一章中,我们将探讨 RAG 系统的各种评估技术。
第二十九章:评估 RAG 系统
RAG 系统力求产生更准确、相关和事实依据的响应。然而,评估这些系统的性能提出了独特的挑战。与传统的信息检索或问答(QA)系统不同,RAG 评估必须考虑检索信息的质量以及 LLM 利用这些信息生成高质量响应的有效性。
在本章中,我们将探讨评估 RAG 系统的复杂性。我们将检查这项任务固有的挑战,分析用于评估检索质量和生成性能的关键指标,并讨论进行全面评估的各种策略。
本章旨在为您提供对 RAG 评估原则和实践的全面理解,使您具备评估和改进这些强大系统所需的知识。
在本章中,我们将涵盖以下主题:
-
评估 LLM 的 RAG 系统面临的挑战
-
评估基于 LLM 的 RAG 检索质量的指标
-
RAG 检索指标考虑因素
-
评估检索信息的相关性
-
测量检索对 LLM 生成的影响
-
在 LLM 中端到端评估 RAG 系统
-
基于 LLM 的 RAG 的人评技术
-
RAG 评估的基准和数据集
评估 LLM 的 RAG 系统面临的挑战
评估 RAG 系统提出了一系列独特的挑战,这些挑战使其与传统信息检索或 QA 系统评估区分开来。这些挑战源于检索和生成组件之间的相互作用以及评估事实准确性和生成文本质量的需求。
以下各节将详细说明在评估 LLM 的 RAG 系统时遇到的特定挑战。
检索和生成之间的相互作用
RAG 系统的性能是其检索组件和生成组件共同作用的结果。强大的检索可以为 LLM 提供相关且准确的信息,从而产生更好的响应。相反,较差的检索可能会误导 LLM,导致即使生成器本身能力很强,也会产生不准确或不相关的答案。因此,评估 RAG 系统需要评估检索信息的质量以及 LLM 在生成过程中有效利用这些信息的能力。
上下文敏感评估
与传统信息检索不同,传统信息检索通常仅基于查询来评估相关性,RAG 评估必须考虑检索信息使用的上下文。一个文档可能在孤立的情况下与查询相关,但可能不提供在生成响应上下文中准确回答问题的具体信息。这需要上下文敏感的评估指标,在评估检索文档的相关性时,既要考虑查询也要考虑生成的文本。
超越事实准确性
虽然事实准确性是 RAG 评估的主要关注点,但它并不是决定生成响应质量的决定性因素。响应还必须流畅、连贯,并且与用户的查询相关。这些文本质量方面通常通过人工评估来评估,这可能既昂贵又耗时。开发与人类对这些定性方面判断相关联的自动化指标仍然是一个开放的研究挑战。
自动化指标的局限性
自动化指标,如从信息检索(例如,精确度、召回率)或机器翻译(例如,BLEU、ROUGE)借用,可以为 RAG 系统性能提供有用的见解。然而,它们通常无法全面反映整个情况。检索指标可能无法完全反映文档对生成的有用性,而生成指标可能无法充分评估生成文本在检索上下文中的事实基础。
错误分析困难
当一个 RAG 系统产生错误或低质量的响应时,确定根本原因可能具有挑战性。检索组件是否无法找到相关文档?LLM 是否未能正确利用检索到的信息?LLM 是否产生了不基于提供上下文的响应?解开这些因素需要仔细的错误分析和可能的新诊断工具的开发。
需要多样化的评估场景
RAG 系统可以部署在广泛的领域中,从开放域问答到特定领域的聊天机器人。具体的挑战和评估标准可能因用例而异。评估 RAG 系统在多样化和不同领域中的性能对于理解其优势和劣势至关重要。
动态知识和演变信息
在许多实际应用中,底层知识库是不断演变的。新信息被添加,现有信息被更新或变得过时。评估一个 RAG 系统如何适应这些变化并保持其响应的准确性是一个重大挑战。
计算成本
评估 RAG 系统,尤其是使用更大 LLM 的系统,可能计算成本很高。使用大型模型进行推理并在大规模上进行人工评估可能需要大量资源。找到平衡评估彻底性和计算成本的方法是一个重要的考虑因素。
让我们看看一些关键指标,用于评估基于 LLM 的 RAG 系统中检索组件的相关性和对响应生成的有用性。
基于 LLM 的 RAG 中评估检索质量的指标
检索组件在 RAG 系统的整体性能中起着至关重要的作用。它负责为 LLM 提供相关且准确的信息,这些信息是生成响应的基础。因此,评估检索组件的质量是 RAG 评估的一个关键方面。我们可以将传统的信息检索指标适应到 RAG 设置中,重点关注检索器找到的文档不仅与查询相关,而且对 LLM 生成高质量答案有用的能力。
Recall@k
Recall@k 衡量的是在顶部 k 个检索结果中成功检索到的相关文档的比例。在 RAG 的背景下,我们可以将相关文档定义为包含回答查询所需必要信息的文档:
-
公式: Recall@k = (在顶部 k 个检索到的相关文档数量) / (总的相关文档数量)
-
解释: Recall@k 越高,表示检索组件可以找到更大比例的相关文档
-
示例: 如果整个语料库中有五篇文档包含回答特定查询所需的信息,并且 RAG 系统在顶部 10 个结果中检索到其中的三篇,那么该查询的 Recall@10 将是 3/5 = 0.6
Precision@k
Precision@k 衡量的是在顶部 k 个检索结果中相关文档的比例:
-
公式: Precision@k = (在顶部 k 个检索到的相关文档数量) / (k)
-
解释: Precision@k 越高,表示检索到的文档中有更大比例的相关文档
-
示例: 如果一个 RAG 系统为查询检索了 10 篇文档,其中有四篇是相关的,那么 Precision@10 将是 4/10 = 0.4
平均倒数排名 (MRR)
MRR 考虑了检索到的第一个相关文档的排名。它强调了在排名早期检索相关文档的重要性:
-
公式: MRR = (1 / |Q|) * Σ (1 / rank_i) for i = 1 to |Q|, 其中 |Q| 是查询的数量,rank_i 是查询 i 的第一个相关文档的排名。
-
解释: MRR 越高,表示相关文档被检索到的排名越高(越接近顶部)。
-
示例: 如果一个查询的第一个相关文档在排名三处被检索到,则倒数排名为 1/3。MRR 会平均多个查询的这些倒数排名。
标准化折现累积增益 (NDCG@k)
NDCG@k 是一个更复杂的指标,它考虑了检索文档的相关性和它们在排名中的位置。它使用一个分级的相关性量表(例如,0、1、2,其中 2 是非常相关)并给在更高排名检索到的相关文档分配更高的分数:
-
公式:NDCG@k 涉及计算检索列表的折算累积收益(DCG)并将其通过理想折算累积收益(IDCG)进行归一化,后者是完美排名列表的 DCG。公式很复杂,但可以使用 sklearn 等库轻松计算。
-
解释:更高的 NDCG@k 表明,高度相关的文档在更高的排名中被检索到。
接下来,让我们讨论如何决定使用哪些检索指标。
RAG 中检索指标的考虑因素
在 RAG 的背景下,我们需要仔细定义相关性。一个文档可能对查询相关,但不包含回答查询所需的特定信息。我们可能需要使用更严格的定义,例如“包含查询的答案。”如前所述,RAG 中的相关性通常是上下文相关的。一个文档可能在孤立的情况下与查询相关,但不是在给定其他检索文档的情况下生成特定答案的最有帮助的文档。
虽然 Recall@k 和 Precision@k 等指标关注的是前k个检索到的文档,但考虑更广泛结果的整体检索质量也很重要。例如,平均精度(AP)可以提供一个更全面的视角。
让我们用 Python 和 sklearn 库来演示如何计算 Recall@k、Precision@k、MRR 和 NDCG@k:
-
我们首先导入必要的库,并定义代表一组查询、每个查询的真实相关文档以及每个查询由 RAG 系统检索到的文档的样本数据:
import numpy as np from sklearn.metrics import ndcg_score # Sample data queries = [ "What is the capital of France?", "Who painted the Mona Lisa?", "What is the highest mountain in the world?" ] ground_truth = [ [0, 1, 2], # Indices of relevant documents for query 1 [3, 4], # Indices of relevant documents for query 2 [5, 6, 7] # Indices of relevant documents for query 3 ] retrieved = [ [1, 5, 0, 2, 8, 9, 3, 4, 6, 7], # Ranked list of retrieved document indices for query 1 [4, 3, 0, 1, 2, 5, 6, 7, 8, 9], # Ranked list of retrieved document indices for query 2 [6, 5, 7, 0, 1, 2, 3, 4, 8, 9] # Ranked list of retrieved document indices for query 3 ] -
然后,我们定义一个函数,
calculate_recall_at_k,用于计算给定查询集、真实相关文档和检索文档列表的 Recall@k:def calculate_recall_at_k(ground_truth, retrieved, k): """Calculates Recall@k for a set of queries.""" recall_scores = [] for gt, ret in zip(ground_truth, retrieved): num_relevant = len(gt) retrieved_k = ret[:k] num_relevant_retrieved = len( set(gt).intersection(set(retrieved_k)) ) recall = ( num_relevant_retrieved / num_relevant if num_relevant > 0 else 0 ) recall_scores.append(recall) return np.mean(recall_scores) -
我们接下来定义一个函数,
calculate_precision_at_k,用于计算给定查询集、真实值和检索列表的 Precision@k:def calculate_precision_at_k(ground_truth, retrieved, k): """Calculates Precision@k for a set of queries.""" precision_scores = [] for gt, ret in zip(ground_truth, retrieved): retrieved_k = ret[:k] num_relevant_retrieved = len( set(gt).intersection(set(retrieved_k)) ) precision = num_relevant_retrieved / k if k > 0 else 0 precision_scores.append(precision) return np.mean(precision_scores) -
我们定义一个函数,
calculate_mrr,用于计算给定查询集、真实值和检索列表的 MRR。更高的 MRR 表明系统在更高的排名中一致地检索到相关文档:def calculate_mrr(ground_truth, retrieved): """Calculates Mean Reciprocal Rank (MRR) for a set of queries.""" mrr_scores = [] for gt, ret in zip(ground_truth, retrieved): for i, doc_id in enumerate(ret): if doc_id in gt: mrr_scores.append(1 / (i + 1)) break else: mrr_scores.append(0) # No relevant document found return np.mean(mrr_scores) -
我们还定义了一个函数,
calculate_ndcg_at_k,用于计算 NDCG@k。在这里,我们将使用一个简化版本,其中相关性分数是二进制的(0 或 1):def calculate_ndcg_at_k(ground_truth, retrieved, k): """Calculates NDCG@k for a set of queries.""" ndcg_scores = [] for gt, ret in zip(ground_truth, retrieved): relevance_scores = np.zeros(len(ret)) for i, doc_id in enumerate(ret): if doc_id in gt: relevance_scores[i] = 1 # sklearn.metrics.ndcg_score requires 2D array true_relevance = np.array([relevance_scores]) retrieved_relevance = np.array([relevance_scores]) ndcg = ndcg_score( true_relevance, retrieved_relevance, k=k ) ndcg_scores.append(ndcg) return np.mean(ndcg_scores) -
最后,我们计算并打印不同k值的检索指标:
k_values = [1, 3, 5, 10] for k in k_values: recall_at_k = calculate_recall_at_k(ground_truth, retrieved, k) precision_at_k = calculate_precision_at_k( ground_truth, retrieved, k ) ndcg_at_k = calculate_ndcg_at_k(ground_truth, retrieved, k) print(f"Recall@{k}: {recall_at_k:.3f}") print(f"Precision@{k}: {precision_at_k:.3f}") print(f"NDCG@{k}: {ndcg_at_k:.3f}") mrr = calculate_mrr(ground_truth, retrieved) print(f"MRR: {mrr:.3f}")
评估 LLMs 检索信息的相关性
虽然上一节中讨论的检索指标提供了检索质量的总体评估,但它们并没有完全捕捉到 RAG 中相关性的细微差别。在 RAG 中,检索到的信息不是最终产品,而是一个中间步骤,作为 LLM 的输入。因此,我们需要评估检索信息不仅与查询相关,而且与通过 LLM 生成高质量响应的具体任务相关。
传统的信息检索通常侧重于找到与查询主题相关的文档。然而,在 RAG 中,我们需要一个更细致的相关性概念,它考虑以下方面:
-
可回答性:检索到的信息是否包含回答查询所需的特定信息?一个文档可能一般与查询相关,但不包含精确的答案。
-
上下文效用:检索到的信息在其他检索文档的上下文中是否有用?一个文档在孤立的情况下可能相关,但与其他检索信息结合时可能冗余甚至矛盾。
-
LLM 兼容性:检索到的信息是否以 LLM 可以轻松理解和利用的格式存在?例如,一个长而复杂的文档可能相关,但对于 LLM 来说可能难以有效处理。
-
忠实度支持:检索到的信息是否提供了足够的证据来支持生成答案中的主张?这对于确保 LLM 的响应基于检索到的上下文至关重要。
检索信息相关性的评估方法
这里有一些评估检索信息相关性的方法,这些方法超越了传统的查询相关性:
-
人工评估:
-
直接评估:人工标注者可以直接评估检索文档与查询和生成响应的相关性。他们可以要求在李克特量表(例如,1 到 5)上评分,或提供二元判断(相关/不相关)。
-
比较评估:标注者可以展示多组检索到的文档,并要求他们根据其回答查询的有用性进行排名,或选择最佳集合。
-
基于任务的评估:标注者可以要求使用检索到的文档自行回答查询。他们回答的准确性和质量可以作为检索信息相关性和有用性的间接衡量标准。
-
-
自动化指标:让我们考虑一些常用的自动化指标。请记住,虽然自动化指标提供了性能的定量衡量,但人工评估为生成响应的相关性、连贯性和有用性提供了有价值的定性见解:
-
答案重叠:我们可以使用 ROUGE 或 BLEU 等指标自动测量生成的答案和检索到的文档之间的重叠。更高的重叠表明 LLM 正在利用检索到的信息。
-
问答指标:如果我们有真实答案,我们可以将检索到的上下文视为问答系统的输入,并使用标准问答指标(如精确匹配(EM)和 F1 分数)来评估其性能。
-
忠实度指标:我们可以使用自然语言推理(NLI)等技术来评估生成的答案是否由检索到的上下文所蕴含。我们将在本章后面的部分详细讨论 NLI 模型。
-
困惑度:当我们对检索到的上下文进行条件化时,我们可以测量 LLM 的困惑度。较低的困惑度表明 LLM 认为上下文是有信息量和对生成有用的。
-
例如,让我们用 Python 中的rouge-score库来说明如何实现简单的答案重叠指标:
-
首先,我们运行以下命令来安装
rouge-score库,该库提供了 ROUGE 指标的实现,并导入必要的模块:pip install rouge-score from rouge_score import rouge_scorer -
然后,我们定义表示查询、生成的答案和检索到的文档列表的样本数据:
query = "What is the capital of France?" answer = "The capital of France is Paris." retrieved_documents = [ "Paris is the capital city of France.", "France is a country in Europe.", "The Eiffel Tower is a famous landmark in Paris.", "London is the capital of the United Kingdom." ] -
接下来,我们定义一个函数
calculate_rouge_scores来计算生成的答案和每个检索到的文档之间的 ROUGE 分数:def calculate_rouge_scores(answer, documents): """Calculates ROUGE scores between the answer and each document.""" scorer = rouge_scorer.RougeScorer( ['rouge1', 'rouge2', 'rougeL'], use_stemmer=True ) scores = [] for doc in documents: score = scorer.score(answer, doc) scores.append(score) return scores -
然后我们计算并打印每个文档的 ROUGE 分数:
rouge_scores = calculate_rouge_scores(answer, retrieved_documents) for i, score in enumerate(rouge_scores): print(f"Document {i+1}:") print(f" ROUGE-1: {score['rouge1'].fmeasure:.3f}") print(f" ROUGE-2: {score['rouge2'].fmeasure:.3f}") print(f" ROUGE-L: {score['rougeL'].fmeasure:.3f}") -
最后,我们计算并打印所有文档的平均 ROUGE 分数:
avg_rouge1 = sum([score['rouge1'].fmeasure for score in rouge_scores]) / len(rouge_scores) avg_rouge2 = sum([score['rouge2'].fmeasure for score in rouge_scores]) / len(rouge_scores) avg_rougeL = sum([score['rougeL'].fmeasure for score in rouge_scores]) / len(rouge_scores) print(f"\nAverage ROUGE Scores:") print(f" Average ROUGE-1: {avg_rouge1:.3f}") print(f" Average ROUGE-2: {avg_rouge2:.3f}") print(f" Average ROUGE-L: {avg_rougeL:.3f}")
评估 RAG 特定相关性的挑战
在探索了评估检索信息相关性的几种方法之后,我们现在转向概述涉及此评估过程的一些关键挑战:
-
主观性:相关性判断可能是主观的,尤其是在考虑上下文效用和 LLM 兼容性等因素时。
-
标注成本:人工评估可能既昂贵又耗时,尤其是在大规模评估中。
-
指标限制:自动指标可能无法完全捕捉 RAG 特定相关性的细微差别,并且可能并不总是与人类判断很好地相关。
-
动态上下文:文档的相关性可能根据检索到的其他文档和 LLM 使用的特定生成策略而变化。
接下来,让我们学习如何测量检索对 LLM 生成的影响。
测量检索对 LLM 生成的影响
在 RAG 系统中,生成的响应质量很大程度上受到检索到的信息的影响。良好的检索提供了必要的信息和事实,而差的检索可能导致不相关或不正确的响应。通过更好的模型和过滤来增强检索可以提高整体性能,这通过精确度、忠实度和用户满意度来衡量。
因此,评估的一个关键方面是衡量检索对 LLM 生成的影响。让我们来看看一些关键指标和技术。
评估检索影响的关键指标
如前所述,由大型语言模型(LLM)生成的响应质量与其检索到的信息紧密相关。因此,评估检索对最终响应的影响至关重要。这包括评估 LLM 如何有效地利用检索到的上下文来生成准确、相关且扎根的答案。现在让我们考察一下在这次评估中使用的某些关键指标:
-
扎根性/忠实度:
扎根性,也称为忠实度,衡量的是生成的响应在多大程度上由检索到的上下文提供事实支持。一个扎根的响应应仅包含可以从提供的文档中推断出的信息。
评估此指标的一些技术如下:
-
人工评估:人工标注者可以直接通过验证每个陈述是否由检索到的上下文支持来评估生成响应中每个陈述的扎根性。这可能涉及二元判断(扎根/非扎根)或更细致的评分。
-
自动化指标:
-
自然语言推理(NLI):NLI 模型可以用来确定生成的响应中的每个句子是否由检索到的上下文所蕴涵。我们将检索到的文档的拼接视为前提,将响应中的每个句子视为假设。高蕴涵分数表明句子在上下文中是扎根的。
-
基于问答的评估:我们可以基于生成的响应制定问题,并检查问答模型是否可以使用检索到的上下文作为信息来源正确回答这些问题。高回答能力分数表明响应是扎根的。
-
事实验证模型:这些模型可以用来检查生成的响应中陈述的每个事实是否由检索到的文档或外部知识来源支持。
-
-
-
答案相关性:
答案相关性衡量的是在检索到的上下文的背景下,生成的响应如何有效地解决用户的查询。即使检索到的上下文不完美,一个好的 RAG 系统也应努力提供相关且有帮助的答案。
评估此指标的一些技术如下:
-
人工评估:人类评判者可以在考虑检索上下文的限制的同时评估生成响应与查询的相关性。他们可以在李克特量表上评分或提供比较性判断(例如,对多个响应进行排名)。
-
自动化指标:
-
查询-答案相似度:我们可以使用基于嵌入的技术(例如,余弦相似度)或其他相似度指标来衡量查询和生成的响应之间的语义相似度。
-
特定任务的指标:根据具体的应用,我们可以使用特定任务的指标。例如,在问答场景中,我们可以使用 EM 或 F1 分数等指标来衡量生成的答案与标准答案之间的重叠。
-
信息检索指标:我们可以将生成的响应视为检索到的文档,并使用传统的信息检索指标(如精确度、召回率或 NDCG)来评估其与查询的相关性,前提是我们有查询-答案对的相关性判断。
-
-
-
上下文利用:
这个方面关注的是 LLM 在生成响应时有效利用检索上下文的能力。它不仅测量可信度,还评估 LLM 是否适当地整合和综合上下文中的信息。
评估此指标的一些技术如下:
-
人工评估:人工标注者可以评估 LLM 使用检索上下文的程度,识别出模型在上下文利用不足或过度依赖上下文的实例。
-
自动指标:
-
归因分析:我们可以使用注意力可视化或基于梯度的归因等技术来识别 LLM 在生成过程中最关注检索上下文的哪些部分。
-
上下文消除:我们可以测量当上下文的部分被移除或修改时,生成的响应的变化。这有助于确定上下文中最具影响力的部分。
-
-
作为例子,让我们使用 NLI 模型进行可信度评估。为此,我们将使用 Transformers 库:
-
我们首先运行以下命令,安装
transformers库。这个库提供了用于处理预训练的 transformer 模型(如 NLI)的工具。我们还导入了必要的模块:pip install transformers torch from transformers import ( AutoTokenizer, AutoModelForSequenceClassification ) import torch -
然后我们定义了代表查询、生成答案和检索上下文的样本数据:
query = "What is the capital of France?" answer = "The capital of France is Paris. It is a global center for art, fashion, gastronomy, and culture." context = """ Paris is the capital city of France. It is situated on the River Seine, in northern France. Paris has an area of 105 square kilometers and a population of over 2 million people. France is a country located in Western Europe. """ -
我们加载了一个预训练的 NLI 模型及其相应的分词器。在这里,我们使用的是
roberta-large-mnli模型,这是一个在 MultiNLI 数据集上微调过的 RoBERTa 模型:model_name = "roberta-large-mnli" tokenizer = AutoTokenizer.from_pretrained(model_name) model = \ AutoModelForSequenceClassification.from_ pretrained( model_name ) -
然后我们定义了一个函数
calculate_claim_groundedness,它根据上下文计算单个断言(来自生成答案的句子)的蕴涵分数:def calculate_claim_groundedness(context, claim): """Calculates the entailment score for a single claim given the context.""" inputs = tokenizer(context, claim, truncation=True, return_tensors="pt") outputs = model(**inputs) probs = torch.softmax(outputs.logits, dim=1) entailment_prob = probs[0][2].item() # Assuming label 2 corresponds to entailment return entailment_prob -
我们还定义了一个函数
calculate_groundedness,用于计算整个生成答案的整体可信度分数。它将答案拆分为句子,计算每个句子的蕴涵分数,然后平均这些分数:def calculate_groundedness(context, answer): """Calculates the overall groundedness score for the generated answer.""" claims = answer.split(". ") # Simple sentence splitting if not claims: return 0 claim_scores = [] for claim in claims: if claim: score = calculate_claim_groundedness(context, claim) claim_scores.append(score) return ( sum(claim_scores) / len(claim_scores) if claim_scores else 0 ) -
最后,我们计算并打印样本数据的整体可信度分数:
groundedness_score = calculate_groundedness(context, answer) print(f"Groundedness Score: {groundedness_score:.3f}")
测量检索影响的挑战
现在我们已经看到了一个示例,让我们来看看在 RAG 系统的评估过程中遇到的一些关键挑战:
-
定义真实情况:确定基于事实和答案相关性的真实情况可能很困难且具有主观性,尤其是在处理复杂或细微的查询时。
-
归因错误:确定生成的响应中的错误是由于检索不良、LLM 的限制,还是两者的结合,可能很困难。
-
计算成本:评估检索对生成的影响可能计算成本高昂,尤其是在使用更大的 LLM 或进行人工评估时。
-
标注者间一致性:在使用人工评估时,确保在主观判断(如基于事实和相关性)方面有高标注者间一致性可能很困难。
虽然评估 RAG 系统的各个组件(检索和生成)很重要,但评估系统在端到端方式下的整体性能也同样关键。让我们看看下一个方面。
LLM 中 RAG 系统的端到端评估
虽然评估 RAG 系统的各个组件(检索和生成)很重要,但评估系统在端到端方式下的整体性能也同样关键。端到端评估考虑了整个 RAG 管道,从初始用户查询到最终生成的响应,提供了对系统有效性的整体看法。
让我们看看一些整体指标:
-
任务成功:对于面向任务的 RAG 系统(例如,问答、对话),我们可以衡量整体任务成功率。这涉及到确定生成的响应是否成功完成了预期的任务。
这里有一些评估此指标的技术:
-
自动评估:对于某些任务,我们可以自动评估任务的成功率。例如,在问答中,我们可以检查生成的答案是否与黄金标准答案匹配。
-
人工评估:对于更复杂的任务,可能需要人工评估来判断 RAG 系统是否成功实现了任务目标。
-
-
答案质量:此指标在考虑准确性、相关性、流畅性、连贯性和基于事实性等因素的同时,评估生成响应的整体质量。
这里有一些评估此指标的技术:
-
人工评估:人类评判员可以使用李克特量表或使用考虑多个质量维度的更详细的标准来评估生成的响应的整体质量。
-
自动指标:虽然回答质量可能难以完全自动化,但可以使用以下指标等来近似回答质量的一些方面:
-
ROUGE/BLEU:衡量生成的响应与参考答案(如果有的话)之间的重叠程度。
-
困惑度:衡量 LLM 预测生成响应的效果(通常困惑度越低越好)。
-
基于事实的一致性指标(NLI,基于 QA 的):评估响应与检索到的上下文的事实一致性。
-
相关性指标:衡量查询与生成响应之间的相似度。
-
-
现在,让我们看看我们可以如何评估 RAG 系统。
评估策略
RAG 系统的评估策略可以广泛分为黑盒评估、玻璃盒评估、组件评估和消融研究,每种方法都提供了对系统性能的独特见解。
在黑盒评估中,整个 RAG 系统被视为一个单一单元。评估者提供输入查询,并仅评估最终生成的响应,而不分析中间的检索或生成步骤。这种方法特别适用于衡量整体系统性能和比较不同的 RAG 实现,而无需深入了解其内部机制。
与之相反,玻璃盒评估涉及对 RAG 系统内部工作方式的详细检查。这种方法分析检索到的上下文、LLM 的注意力模式以及中间生成步骤。通过剖析这些元素,玻璃盒评估有助于识别系统的优势和劣势,定位错误来源,并为有针对性的改进提供见解。
更细粒度的方法是组件评估,它分别评估检索和生成组件。检索性能通常使用 Recall@k 和 NDCG 等指标来衡量,而生成文本的质量则使用 BLEU 和 ROUGE 等指标来评估,或者基于一组固定的检索文档进行人工判断。这种方法特别有效于隔离和诊断单个组件中的性能问题。
最后,消融研究提供了一种系统的方法来衡量不同组件对整体系统有效性的影响。通过移除或修改 RAG 系统的特定部分(例如,测试带有和不带有检索的性能或交换不同的检索和生成模型)——研究人员可以更好地理解每个组件如何贡献于系统的功能性和整体成功。
端到端评估的挑战
全面评估 RAG 系统存在几个挑战,尤其是在评估检索和生成组件之间的复杂交互时。以下是一些挑战:
-
定义真实情况:对于开放性任务或涉及生成复杂响应的任务,定义真实情况可能很困难,甚至不可能
-
归因错误:当系统生成错误或不高质量的响应时,可能很难确定错误是否起源于检索或生成组件
-
计算成本:端到端评估可能计算成本高昂,尤其是在使用更大的 LLM 或在大规模上进行人工评估时
-
可重现性:由于检索和生成组件之间的复杂交互以及生成过程中可能使用的非确定性检索机制或随机解码策略,确保可重现性可能很困难,这可能导致即使在相同输入的情况下,输出也会因运行而异。
接下来,让我们将重点转向人类评估在评估基于 LLM 的 RAG 系统中的作用,它通过捕捉诸如相关性、连贯性和事实准确性等细微方面来补充自动化指标。
基于 LLM 的 RAG 的人类评估技术
虽然自动化指标提供了有价值的见解,但人类评估仍然是评估 RAG 系统整体质量和有效性的黄金标准。人类判断对于评估难以用自动化指标捕捉的方面尤其重要,例如检索信息的细微相关性、生成文本的连贯性和流畅性,以及响应在解决用户需求方面的整体有用性。
人类评估者可以评估 RAG 系统性能的各个方面:
-
相关性:生成的响应与用户查询的相关性如何?它是否解决了查询中表达的具体信息需求?
-
扎根性/忠实性:生成的响应是否由检索到的上下文在事实上得到支持?它是否避免了虚构或与提供的信息相矛盾?
-
连贯性和流畅性:生成的响应是否结构良好,易于理解,并且用语法正确且自然的声音书写?
-
有用性:考虑到检索到的上下文的限制,响应是否提供了有用且令人满意的答案,以解决用户的查询?
-
上下文利用:系统在生成响应时如何有效地利用检索到的上下文?它是否适当地整合和综合来自多个来源的信息?
-
归属:系统是否提供了对检索到的上下文中支持生成声明的来源的明确引用或链接?
可以使用几种方法对 RAG 系统进行人类评估:
-
评分量表(李克特量表):标注者根据数值量表(例如 1 到 5)对生成的响应的不同方面(例如相关性、扎根性、流畅性)进行评分,其中 1 代表质量差,5 代表质量优秀:
-
优点:易于实施,易于收集和汇总数据
-
缺点:可能具有主观性,容易受到标注者偏差的影响,并且可能无法捕捉细微的差异
-
-
比较评估(排名/最好最差尺度):标注者被展示多个针对同一查询的 RAG 系统输出,并要求根据其整体质量或特定标准进行排名。
-
最好最差尺度:一种特定的比较评估形式,其中标注者从一组输出中选择最佳和最差选项:
-
优点:比绝对评分更可靠,并能有效捕捉系统之间的相对差异
-
缺点:比评分量表更难实现,需要标注者付出更多努力
-
-
基于任务的评估:要求标注者使用 RAG 系统完成特定任务,例如回答问题、撰写摘要或进行对话。RAG 系统的质量基于标注者成功完成任务的能力以及他们对系统性能的满意度:
-
优点:更真实、以用户为中心,并提供系统实用性的直接度量
-
缺点:设计和实现更复杂,可能耗时且昂贵
-
-
自由形式反馈:标注者对 RAG 系统输出的优点和缺点提供开放式反馈:
-
优点:捕捉详细的见解和建议,并能揭示意外问题
-
缺点:更难分析和量化,可能主观且不一致
-
人类评估的最佳实践
为了确保人类评估的可靠性和公平性,请考虑以下最佳实践:
-
清晰的指南:为标注者提供清晰和详细的指南,定义评估标准和标注程序
-
培训和校准:对标注者进行任务培训并使用示例标注校准他们的判断
-
标注者间一致性:测量标注者间一致性(例如,使用 Cohen 的 Kappa 或 Fleiss 的 Kappa)以确保标注的可靠性
-
试点研究:进行试点研究以完善评估方案并在大规模评估启动前识别潜在问题
-
多个标注者:为每个项目使用多个标注者以减轻个人偏见并提高评估的稳健性
-
多样化的标注者群体:招募多样化的标注者群体以捕捉更广泛的视角并减少潜在的偏见
-
质量控制:实施机制以识别和纠正标注中的错误或不一致
人类评估的挑战
评估基于 LLM 构建的 RAG 系统的性能面临一系列独特的挑战。在此,我们概述了在执行可靠、一致和有意义的系统人类评估过程中遇到的关键障碍:
-
成本和时间:人类评估可能很昂贵且耗时,尤其是对于大规模评估
-
主观性:人类判断可能具有主观性,并受个人偏好和偏见的影响
-
标注者培训和专业知识:确保标注者得到适当的培训并具备评估 RAG 系统性能所需的必要专业知识可能具有挑战性
-
可重复性:由于人类判断固有的可变性,复制人类评估可能很困难
在下一节中,我们将探讨标准化基准和数据集在评估 RAG 系统中的作用,突出关键基准、评估标准和挑战。
RAG 评估的基准和数据集
标准化的基准和数据集在推动 RAG(阅读理解与生成)研究和发展中起着至关重要的作用。它们为评估和比较不同的 RAG 系统提供了一个共同的基础,促进了识别最佳实践和跟踪随时间进步的过程。
让我们来看看一些关键的基准和数据集:
-
知识密集型语言任务(KILT):一个全面的基准,用于评估知识密集型语言任务,包括问答、事实核查、对话和实体链接:
-
数据来源:基于维基百科,对所有任务采用统一格式
-
优点:提供多样化的任务,允许评估检索和生成,并包括标准化的评估框架
-
局限性:主要基于维基百科,可能无法反映现实世界知识来源的多样性
-
-
自然问题(NQ):一个大规模的问答数据集,收集自发送到谷歌搜索引擎的真实用户查询:
-
数据来源:包含问题和包含答案的维基百科页面的一对数据
-
优点:现实主义的查询,大规模,包括短答案和长答案的标注
-
局限性:由于它主要关注事实性问题,可能不适合评估更复杂的推理或生成任务
-
-
TriviaQA:包含问题-答案-证据三元组的具有挑战性的问答数据集:
-
数据来源:从 Trivia 爱好者收集,包括网络和维基百科的证据文档
-
优点:比 NQ 更难;它需要阅读和理解多个证据文档
-
局限性:主要关注事实性问题,TriviaQA 问题的写作风格可能不代表现实世界用户的查询
-
-
Explain Like I’m Five (ELI5):来自 Reddit 论坛“Explain Like I’m Five”的问题和答案数据集,用户在这里寻求对复杂主题的简化解释:
-
数据来源:从 Reddit 收集,包括广泛主题的问题和答案
-
优点:专注于长格式、解释性答案,适合评估 RAG 系统的生成能力
-
局限性:答案的质量和准确性可能有所不同,可能需要仔细筛选或标注
-
-
ASQA:第一个统一模糊问题的长格式问答数据集:
-
数据来源:该数据集是通过结合多个模糊问题从头开始构建的
-
优点:有助于评估长格式问答任务
-
局限性:从头开始构建高质量的数据集可能具有挑战性
-
-
微软机器阅读理解(MS MARCO):一个大规模的机器阅读理解和问答数据集:
-
数据来源:包含发送到必应搜索引擎的真实匿名用户查询,以及人类生成的答案和相关的段落。
-
优势:它提供了一个大规模、多样化的查询和答案集,包括段落级和全文标注
-
局限性:主要关注抽取式问答,可能不适合评估 RAG 系统的生成能力
-
-
斯坦福问答数据集(SQuAD):一个广泛使用的阅读理解数据集,由众包工作者在一系列维基百科文章上提出的问题组成:
-
数据来源:包含问题-段落-答案三元组,其中答案是在段落中的文本片段
-
优势:一个大规模、成熟的阅读理解基准
-
局限性:主要关注抽取式问答,可能不适合评估 RAG 系统的生成能力
-
作为一个例子,让我们说明如何使用 KILT 数据集来评估一个 RAG 系统。我们将使用 Python 中的 KILT 库来完成这项工作:
-
运行以下代码来安装 kilt 库并导入必要的模块:
pip install kilt==0.5.5 from kilt import kilt_utils as utils from kilt import retrieval from kilt.eval import answer_evaluation, provenance_evaluation -
接下来,下载一个特定的 KILT 任务,例如维基百科巫师(WoW)数据集:
# Download the WoW dataset utils.download_dataset("wow") -
然后,将下载的数据集加载到内存中:
# Load the dataset wow_data = utils.load_dataset("wow", split="test") -
定义一个模拟 RAG 检索组件行为的虚拟 RAG 函数。为了演示目的,它简单地为每个查询返回一组固定的维基百科页面。在实际场景中,你会用你实际的 RAG 检索实现来替换它:
class DummyRetriever(retrieval.base.Retriever): def __init__(self, k=1): super().__init__(num_return_docs=k) self.k = k # retrieve some Wikipedia pages (or the entire dataset) # based on the query def retrieve(self, query, start_paragraph_id=None): # Dummy retrieval: return the same set of pages for each query dummy_pages = [ { "wikipedia_id": "534366", "start_paragraph_id": 1, "score": self.k, "text": "Paris is the capital of France." }, { "wikipedia_id": "21854", "start_paragraph_id": 1, "score": self.k-1, "text": "The Mona Lisa was painted by Leonardo da Vinci." }, { "wikipedia_id": "37267", "start_paragraph_id": 1, "score": self.k-2, "text": "Mount Everest is the highest mountain in the world." } ] return dummy_pages[:self.k] # Example usage retriever = DummyRetriever(k=2) -
定义一个模拟 RAG 生成组件行为的虚拟 RAG 生成函数。为了演示目的,它简单地为每个查询返回一个固定的答案。在实际场景中,你会用你实际的基于 LLM 的生成实现来替换它:
def dummy_generate(query, retrieved_pages): """Simulates RAG generation by returning a fixed answer for each query.""" if "capital of France" in query: return "Paris" elif "Mona Lisa" in query: return "Leonardo da Vinci" elif "highest mountain" in query: return "Mount Everest" else: return "I don't know." -
在数据集上运行虚拟 RAG 管道,使用虚拟检索和生成函数,并收集生成的预测:
predictions = [] for element in wow_data[:10]: query = element["input"] retrieved_pages = retriever.retrieve(query) # Add provenance information to the element element["output"] = [{"provenance": retrieved_pages}] generated_answer = dummy_generate(query, retrieved_pages) # Add the generated answer to the element element["output"][0]["answer"] = generated_answer predictions.append(element) -
最后,使用 KILT 评估函数评估生成的预测。检索性能(使用
provenance_evaluation)和答案质量(使用answer_evaluation)都会被评估:kilt_scores = {} kilt_scores["provenance_MAP@k"] = \ provenance_evaluation.get_map_at_k( predictions, verbose=False ) kilt_scores["answer_EM"] = answer_evaluation.get_exact_match( predictions, verbose=False ) kilt_scores["answer_F1"] = answer_evaluation.get_f1( predictions, verbose=False ) kilt_scores["answer_ROUGE-L"] = answer_evaluation.get_rouge_l( predictions, verbose=False ) print(kilt_scores)
这段代码提供了一个如何使用 KILT 框架评估 RAG 系统的基础示例。在实际场景中,你会用你实际的 RAG 检索和生成函数来替换虚拟函数,并使用数据集的更大部分进行评估。你可以通过下载和加载相应的数据集来将此示例适应其他 KILT 任务。
选择基准和数据集时,以下是一些需要考虑的事项:
-
任务对齐:选择与你要评估的具体任务对齐的基准和数据集(例如,问答,对话,摘要)
-
知识领域:考虑基准覆盖的知识领域。一些基准基于通用知识(例如,维基百科),而其他基准则专注于特定领域(例如,科学文献,医疗记录)
-
检索设置:选择适合您所使用的检索设置的基准(例如,开放域检索、封闭域检索、段落检索、文档检索)
-
生成要求:考虑任务所需的生成类型(例如,抽取式与抽象式、短答案与长答案)
-
数据集大小和质量:确保数据集足够大,以提供具有统计意义的成果,并且数据质量要高(例如,准确的注释和良好的问题格式)
-
评估指标:检查基准使用的评估指标,并确定它们是否适合您的特定评估目标
摘要
在本章中,我们讨论了评估检索质量和生成性能的广泛指标,包括传统的信息检索指标,如 Recall@k、Precision@k、MRR 和 NDCG,以及更 RAG 特定的指标,如 groundedness、faithfulness 和 answer relevance。我们探讨了测量这些指标的各种技术,包括基于 NLI 和 QA 模型的自动化方法,以及使用评分尺度、比较判断和基于任务的评估方法进行的人评方法。
我们强调了人评在捕捉 RAG 性能细微差别方面的重要作用,这些差别仅用自动化指标难以评估。我们还讨论了设计和进行人评的最佳实践,例如提供明确的指南、培训注释员、测量注释员间的一致性以及进行试点研究。我们需要记住,在现实世界的部署中,自动化与人评之间的权衡将非常重要。
此外,我们探讨了广泛使用的 RAG 评估基准和数据集,包括 KILT、NQ、TriviaQA、ELI5、ASQA、MS MARCO 和 SQuAD,突出了它们的优点和局限性,并提供了针对不同任务和领域的基准选择指南。
总结来说,很明显,评估 RAG 系统是一个复杂且不断发展的领域。更复杂的评估指标的开发、更多样化和更具挑战性的基准的创建以及人评方法的改进将继续对推动 RAG 研究和开发进步至关重要。
在下一章中,我们将探讨 LLMs 中的代理模式,重点关注 LLMs 如何使用高级检索和生成技术自主执行涉及推理、规划和决策的任务。
第三十章:代理模式
在本章的最后一章,我们将探讨使用 LLM 创建更自主和目标导向的 AI 代理的模式。您将了解基于 LLM 的代理中的目标设定和规划,实现记忆和状态管理,以及决策和行动选择的策略。我们将涵盖代理式 LLM 系统中的学习和适应技术,并讨论在开发此类系统时必要的伦理考虑和安全措施。
到本章结束时,您将能够设计和实现由 LLM 驱动的复杂 AI 代理,为自主 AI 系统开辟新的可能性。
在本章中,我们将涵盖以下主题:
-
基于 LLM 的代理式 AI 系统简介
-
基于 LLM 的代理中的目标设定和规划
-
为 LLM 代理实现记忆和状态管理
-
基于 LLM 的代理中的决策和行动选择
-
代理式 LLM 系统中的学习和适应
-
基于 LLM 的代理式 AI 的伦理考虑和安全
-
使用 LLM 的代理式 AI 的未来前景
基于 LLM 的代理式 AI 系统简介
使用 LLM 的代理式 AI 系统旨在自主运行、做出决策并采取行动以实现特定目标。这些系统结合了 LLM 强大的语言理解和生成能力,以及以目标为导向的行为和环境交互。
让我们从实现一个基于 LLM 的代理的基本结构开始:
from typing import List, Dict, Any
import random
class LLMAgent:
def __init__(self, llm, action_space: List[str]):
self.llm = llm
self.action_space = action_space
self.memory = []
self.current_goal = None
在这里,LLMAgent类通过一个 LLM(llm)和一个可能的动作列表(action_space)进行初始化。它还维护一个观察记忆和一个current_goal,这些将被用来指导代理的动作。
def set_goal(self, goal: str):
self.current_goal = goal
def perceive(self, observation: str):
self.memory.append(observation)
在这里,我们定义了两个方法:set_goal,允许代理设置其目标,以及perceive,它使代理能够从环境中获取观察并将它们存储在其记忆中。
接下来,我们使用think方法根据代理的目标和最近观察生成一个详细的过程:
def think(self) -> str:
context = f"Goal: {self.current_goal}\n"
context += "Recent observations:\n"
context += "\n".join(self.memory[-5:]) # Last 5 observations
context += "\nThink about the current situation and the goal. What should be done next?"
return self.llm.generate(context)
代理通过提供一个包含当前目标和最后五个观察结果的上下文字符串,向语言模型请求下一步的建议:
一旦代理有了想法,它必须决定下一步的行动。decide方法使用这个想法生成一个上下文,请求 LLM 从可用选项中选择最佳行动:
def decide(self, thought: str) -> str:
context = f"Thought: {thought}\n"
context += "Based on this thought, choose the most appropriate action from the following:\n"
context += ", ".join(self.action_space)
context += "\nChosen action:"
return self.llm.generate(context)
然后,act方法通过随机选择一个结果(成功、失败或意外结果)来模拟采取行动。在真实场景中,这将涉及与环境交互:
def act(self, action: str) -> str:
outcomes = [
f"Action '{action}' was successful.",
f"Action '{action}' failed.",
f"Action '{action}' had an unexpected outcome."
]
return random.choice(outcomes)
最后,run_step方法协调了思考、决定、行动和感知结果的全过程,完成与环境的一次交互周期:
def run_step(self):
thought = self.think()
action = self.decide(thought)
outcome = self.act(action)
self.perceive(outcome)
return thought, action, outcome
现在我们已经了解了基本原理,让我们将这些概念转化为代码。
让我们实现一个基本的基于 LLM 的智能体,建立自主操作的核心结构。智能体初始化时包含一个假设的语言模型(llm)和一组行动。它设定一个目标并感知环境以开始与之互动:
# Example usage
llm = SomeLLMModel() # Replace with your actual LLM
action_space = ["move", "grab", "drop", "use", "talk"]
agent = LLMAgent(llm, action_space)
agent.set_goal("Find the key and unlock the door")
agent.perceive("You are in a room with a table and a chair. There's a drawer in the table.")
在接下来的for循环中,智能体运行五步,每个思想、行动和结果都会打印出来,以展示智能体如何随着时间的推移与环境互动:
for _ in range(5): # Run for 5 steps
thought, action, outcome = agent.run_step()
print(f"Thought: {thought}")
print(f"Action: {action}")
print(f"Outcome: {outcome}")
print()
在确立了智能体行为的基础之后,让我们探索更高级的能力。下一节将重点介绍目标设定和规划,使智能体能够主动向复杂目标迈进。
基于 LLM 的智能体的目标设定和规划
为了增强我们的智能体,使其具有更高级的目标设定和规划能力,让我们实现分层目标结构和规划机制。
首先,我们定义一个HierarchicalGoal类;这个类允许智能体将大任务分解成更小的子目标:
class HierarchicalGoal:
def __init__(
self, description: str,
subgoals: List['HierarchicalGoal'] = None
):
self.description = description
self.subgoals = subgoals or []
self.completed = False
def add_subgoal(self, subgoal: 'HierarchicalGoal'):
self.subgoals.append(subgoal)
def mark_completed(self):
self.completed = True
智能体可以逐步完成这些子目标,并在完成后将其标记为完成。
接下来,我们有一个PlanningAgent类,它继承自LLMAgent但增加了处理分层目标的能力。它将目标存储在堆栈中,在完成子目标时进行处理:
class PlanningAgent(LLMAgent):
def __init__(self, llm, action_space: List[str]):
super().__init__(llm, action_space)
self.goal_stack = []
self.current_plan = []
def set_hierarchical_goal(self, goal: HierarchicalGoal):
self.goal_stack = [goal]
think方法现在也包括规划。如果没有当前计划,它将要求 LLM 生成一个逐步计划来实现当前目标:
def think(self) -> str:
if not self.current_plan:
self.create_plan()
context = f"Current goal: {self.goal_stack[-1].description}\n"
context += "Current plan:\n"
context += "\n".join(self.current_plan)
context += "\nRecent observations:\n"
context += "\n".join(self.memory[-5:])
context += "\nThink about the current situation, goal, and plan. What should be done next?"
return self.llm.generate(context)
然后,create_plan方法通过向 LLM 提示当前目标和行动列表来生成一个计划。生成的计划被拆分为单独的步骤:
def create_plan(self):
context = f"Goal: {self.goal_stack[-1].description}\n"
context += "Create a step-by-step plan to achieve this goal. Each step should be an action from the following list:\n"
context += ", ".join(self.action_space)
context += "\nPlan:"
plan_text = self.llm.generate(context)
self.current_plan = [
step.strip() for step in plan_text.split("\n")
if step.strip()
]
update_goals方法检查当前目标是否完成。如果是,它将转向下一个目标或子目标,并相应地重置计划:
def update_goals(self):
current_goal = self.goal_stack[-1]
if current_goal.completed:
self.goal_stack.pop()
if self.goal_stack:
self.current_plan = [] # Reset plan for the next goal
elif current_goal.subgoals:
next_subgoal = next(
(
sg for sg in current_goal.subgoals
if not sg.completed
),
None
)
if next_subgoal:
self.goal_stack.append(next_subgoal)
self.current_plan = [] # Reset plan for the new subgoal
run_step方法协调目标设定和规划过程,必要时更新目标:
def run_step(self):
thought, action, outcome = super().run_step()
self.update_goals()
return thought, action, outcome
让我们看看一个例子。
在下面的代码片段中,智能体以“逃离房间”的分层目标进行操作。随着智能体运行多个步骤,它会解决其子目标,例如找到钥匙并打开门,每个步骤都会更新智能体的内部目标堆栈和计划:
planning_agent = PlanningAgent(llm, action_space)
main_goal = HierarchicalGoal("Escape the room")
main_goal.add_subgoal(HierarchicalGoal("Find the key"))
main_goal.add_subgoal(HierarchicalGoal("Unlock the door"))
planning_agent.set_hierarchical_goal(main_goal)
planning_agent.perceive("You are in a room with a table and
a chair. There's a drawer in the table.")
for _ in range(10): # Run for 10 steps
thought, action, outcome = planning_agent.run_step()
print(f"Thought: {thought}")
print(f"Action: {action}")
print(f"Outcome: {outcome}")
print(f"Current goal: {planning_agent.goal_stack[-1].description}")
print()
在现实世界的应用中,由于 LLM 可能生成不切实际、不安全或违反约束的计划,因此需要从 LLMs 的智能体规划输出中进行约束和验证;因此,诸如基于规则的系统、模拟、人工审查、形式验证和 API/类型验证等技术对于确保生成的计划遵守物理、法律、伦理和操作限制至关重要,从而提高安全性、可靠性和有效性。
在证明了智能体追求分层目标的能力后,下一步是增强其从以往经验中学习的能力。下一节介绍了一个复杂的记忆系统,使智能体在做出决策时能够保留上下文并回忆相关信息。
实现 LLM 智能体的记忆和状态管理
为了提高我们的智能体维持上下文和从过去经验中学习的能力,让我们实现一个更复杂的记忆系统。这将使智能体在决定行动时能够回忆起相关的过去观察。
首先,我们定义了 MemoryEntry 类,它代表智能体记忆中的一个条目。每个条目包含观察文本及其相应的嵌入向量,这有助于相似度搜索:
from collections import deque
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
class MemoryEntry:
def __init__(self, text: str, embedding: np.ndarray):
self.text = text
self.embedding = embedding
然后,我们定义了 EpisodicMemory 类;它处理智能体的记忆,存储固定数量的观察(容量)。这个记忆可以增长到指定的限制,此时较老的条目将被移除:
class EpisodicMemory:
def __init__(self, capacity: int, embedding_model):
self.capacity = capacity
self.embedding_model = embedding_model
self.memory = deque(maxlen=capacity)
以下代码使用基于内容的情景记忆,它利用语义相似度搜索。记忆将过去的观察(情景)作为文本存储,并附带其向量嵌入,并根据查询嵌入与存储嵌入之间的语义相似度(使用余弦相似度)检索相关记忆:
def add(self, text: str):
embedding = self.embedding_model.encode(text)
self.memory.append(MemoryEntry(text, embedding))
def retrieve_relevant(self, query: str, k: int = 5) -> List[str]:
query_embedding = self.embedding_model.encode(query)
similarities = [
cosine_similarity(
[query_embedding],
[entry.embedding]
)[0][0] for entry in self.memory
]
top_indices = np.argsort(similarities)[-k:][::-1]
return [self.memory[i].text for i in top_indices]
retrieve_relevant 方法根据余弦相似度搜索最相关的过去观察,返回前 k 个匹配条目。
然后,我们定义了 MemoryAwareAgent 类;这个类通过集成一个情景记忆系统扩展了 PlanningAgent。这允许智能体在决策过程中存储和检索相关的过去经验:
class MemoryAwareAgent(PlanningAgent):
def __init__(
self, llm, action_space: List[str], embedding_model
):
super().__init__(llm, action_space)
self.episodic_memory = EpisodicMemory(
capacity=1000, embedding_model=embedding_model
)
def perceive(self, observation: str):
super().perceive(observation)
self.episodic_memory.add(observation)
以下代码中定义的 think 函数结合了相关的过去经验。智能体检索与其当前目标相似的记忆,并在决定下一步行动时将这些记忆用于提供给 LLM 的上下文中:
def think(self) -> str:
relevant_memories = self.episodic_memory.retrieve_relevant(
self.current_goal, k=3
)
context = f"Current goal: {self.goal_stack[-1].description}\n"
context += "Current plan:\n"
context += "\n".join(self.current_plan)
context += "\nRecent observations:\n"
context += "\n".join(self.memory[-5:])
context += "\nRelevant past experiences:\n"
context += "\n".join(relevant_memories)
context += "\nThink about the current situation, goal, plan, and past experiences. What should be done next?"
return self.llm.generate(context)
以下代码片段通过首先根据当前目标检索相关记忆,然后为 LLM 构建一个包含目标、当前计划、最近观察和检索到的记忆的全面上下文,最后利用 LLM 生成一个响应,根据提供的上下文信息确定智能体的下一步行动或思维,来协调 AI 智能体的决策过程。
让我们看看记忆感知智能体的一个示例用法。在这个例子中,智能体增强了记忆能力。现在它使用其过去经验来指导其决策和行动:
embedding_model = SomeEmbeddingModel() # Replace with your actual embedding model
memory_agent = MemoryAwareAgent(llm, action_space, embedding_model)
main_goal = HierarchicalGoal("Solve the puzzle")
memory_agent.set_hierarchical_goal(main_goal)
memory_agent.perceive("You are in a room with a complex puzzle on the wall.")
The agent continues to interact with its environment over 10 steps, utilizing its memory system to make better decisions based on both current observations and past experiences:
for _ in range(10): # Run for 10 steps
thought, action, outcome = memory_agent.run_step()
print(f"Thought: {thought}")
print(f"Action: {action}")
print(f"Outcome: {outcome}")
print()
现在我们智能体能够记住和回忆过去经验,我们将专注于做出更好的决策。下一节介绍了一种结构化的行动选择方法,允许智能体使用 LLM 选择最有效的行动。请注意,记忆检索是基于相似度的,当嵌入质量高时效果最佳。
基于 LLM 的智能体的决策和行动选择
为了提高智能体的决策能力,我们可以引入一个更结构化的行动选择方法,根据多个因素评估潜在的行动。
我们首先定义了ActionEvaluator类,该类使用 LLM 根据三个关键标准来评估动作:与当前目标的关联性、成功的概率以及潜在的影响。这些评估有助于智能体选择最佳可能的动作:
import numpy as np
class ActionEvaluator:
def __init__(self, llm):
self.llm = llm
def evaluate_action(
self, action: str, context: str
) -> Dict[str, float]:
prompt = f"""
Context: {context}
Action: {action}
然后,我们根据以下标准评估传递给evaluate_action函数的"action"参数:
-
与当前目标的关联性(0-1)
-
估计的成功概率(0-1)
-
对整体进展的潜在影响(0-1)
Provide your evaluation as three numbers separated by commas:
"""
response = self.llm.generate(prompt)
relevance, success_prob, impact = map(
float, response.split(',')
)
return {
'relevance': relevance,
'success_probability': success_prob,
'impact': impact
}
最后,我们有StrategicDecisionAgent类,该类通过包括更战略性的决策方法扩展了MemoryAwareAgent。它评估所有可能的行为,根据它们的关联性、成功概率和影响进行评分,并选择得分最高的动作:
class StrategicDecisionAgent(MemoryAwareAgent):
def __init__(
self, llm, action_space: List[str], embedding_model
):
super().__init__(llm, action_space, embedding_model)
self.action_evaluator = ActionEvaluator(llm)
def decide(self, thought: str) -> str:
context = f"Thought: {thought}\n"
context += f"Current goal: {self.goal_stack[-1].description}\n"
context += "Recent observations:\n"
context += "\n".join(self.memory[-5:])
action_scores = {}
for action in self.action_space:
evaluation = self.action_evaluator.evaluate_action(
action, context
)
score = np.mean(list(evaluation.values()))
action_scores[action] = score
best_action = max(action_scores, key=action_scores.get)
return best_action
让我们看看StrategicDecisionAgent的一个示例用法。在这个例子中,智能体通过评估基于各种因素的动作来选择最佳动作,从而使用更复杂的决策策略:
strategic_agent = StrategicDecisionAgent(
llm, action_space, embedding_model
)
main_goal = HierarchicalGoal("Navigate the maze and find the treasure")
strategic_agent.set_hierarchical_goal(main_goal)
strategic_agent.perceive("You are at the entrance of a complex maze. There are multiple paths ahead.")
在几个步骤中,智能体通过不断评估基于其目标和环境的最佳动作来策略性地导航迷宫:
for _ in range(10): # Run for 10 steps
thought, action, outcome = strategic_agent.run_step()
print(f"Thought: {thought}")
print(f"Chosen action: {action}")
print(f"Outcome: {outcome}")
print()
我们现在将通过讨论进一步的学习增强、伦理考虑以及基于 LLM 的智能体的未来前景来结束本章:
在智能体 LLM 系统中进行学习和适应
为了使我们的智能体能够从其经验中学习和适应,让我们实现一个简单的强化学习机制。这将允许智能体通过学习其动作的结果来随着时间的推移提高其性能。
我们定义了AdaptiveLearningAgent类,该类通过引入简单的 Q 学习机制扩展了StrategicDecisionAgent。它跟踪q_values,这代表在给定状态下采取特定动作的预期奖励。智能体使用学习率根据新经验更新这些值:
import random
from collections import defaultdict
class AdaptiveLearningAgent(StrategicDecisionAgent):
def __init__(self, llm, action_space: List[str], embedding_model):
super().__init__(llm, action_space, embedding_model)
self.q_values = defaultdict(lambda: defaultdict(float))
self.learning_rate = 0.1
self.discount_factor = 0.9
self.epsilon = 0.1 # For exploration-exploitation tradeoff
接下来,智能体根据探索(尝试随机动作)和利用(使用已学到的有效动作)之间的平衡来决定其动作。智能体使用其 Q 值来选择最有奖励的动作:
def decide(self, thought: str) -> str:
if random.random() < self.epsilon:
return random.choice(self.action_space) # Exploration: randomly pick an action
state = self.get_state_representation()
q_values = {action: self.q_values[state][action]
for action in self.action_space}
return max(q_values, key=q_values.get) # Exploitation: pick action with highest Q-value
我们编写了get_state_representation方法来创建当前状态的简化表示,包括目标和最近的观察。这个状态用于查找和更新 Q 值:
def get_state_representation(self) -> str:
return f"Goal: {self.goal_stack[-1].description},
Last observation: {self.memory[-1]}"
update_q_values方法根据智能体动作的结果更新 Q 值。它调整状态-动作对的预期奖励,考虑了即时的奖励和潜在的未来的奖励(通过next_max_q):
def update_q_values(
self, state: str, action: str, reward: float,
next_state: str
):
current_q = self.q_values[state][action]
next_max_q = max(
self.q_values[next_state].values()
) if self.q_values[next_state] else 0
new_q = current_q + self.learning_rate * (
reward + self.discount_factor * next_max_q - current_q
)
self.q_values[state][action] = new_q
现在的run_step方法不仅执行了标准的思考、决策、行动和感知的顺序,而且还根据结果更新智能体的 Q 值。compute_reward方法根据结果是否成功、失败或中性分配一个数值奖励:
def run_step(self):
state = self.get_state_representation()
thought, action, outcome = super().run_step()
next_state = self.get_state_representation()
reward = self.compute_reward(outcome)
self.update_q_values(state, action, reward, next_state)
return thought, action, outcome
def compute_reward(self, outcome: str) -> float:
if "successful" in outcome.lower():
return 1.0
elif "failed" in outcome.lower():
return -0.5
else:
return 0.0
让我们看看AdaptiveLearningAgent的一个示例用法。在这个例子中,代理被设计为探索并从新环境中学习。它使用强化学习来逐步提高其做出有效决策的能力:
adaptive_agent = AdaptiveLearningAgent(llm, action_space,
embedding_model)
main_goal = HierarchicalGoal("Explore and map the unknown planet")
adaptive_agent.set_hierarchical_goal(main_goal)
adaptive_agent.perceive("You have landed on an alien planet. The environment is strange and unfamiliar.")
代理操作 20 步,从它采取的每个动作中学习。它打印出它的想法、动作和 Q 值,展示了它如何随着时间的推移更新对环境的理解:
for _ in range(20): # Run for 20 steps
thought, action, outcome = adaptive_agent.run_step()
print(f"Thought: {thought}")
print(f"Chosen action: {action}")
print(f"Outcome: {outcome}")
print(
f"Current Q-values: {dict(
adaptive_agent.q_values[
adaptive_agent.get_state_representation()
]
)}"
)
print()
现在我们已经为我们的代理配备了基本的强化学习机制,允许它随着时间的推移适应和改进其决策能力,我们还需要解决此类自主系统的道德影响。在下一节中,我们将探讨如何将道德保障集成到我们的代理 LLM 系统中,以确保负责任和一致的行为。
基于 LLM 的代理人工智能的道德考量与安全性
在开发基于 LLM 的代理人工智能系统时,考虑道德影响和实施安全措施至关重要。为确保代理在道德范围内行事,我们可以添加一个道德约束系统:
class EthicalConstraint:
def __init__(self, description: str, check_function):
self.description = description
self.check_function = check_function
EthicalConstraint 类定义了代理必须遵守的道德规则。每个规则都由一个检查函数(check_function)进行描述和执行,该函数评估一个动作是否违反了道德约束。
EthicalAgent类通过集成道德约束扩展了AdaptiveLearningAgent。如果代理选择了一个违反其道德规则的动作,它会选择一个符合规则的不同动作:
class EthicalAgent(AdaptiveLearningAgent):
def __init__(
self, llm, action_space: List[str],
embedding_model,
ethical_constraints: List[EthicalConstraint]
):
super().__init__(llm, action_space, embedding_model)
self.ethical_constraints = ethical_constraints
def decide(self, thought: str) -> str:
action = super().decide(thought)
if not self.is_action_ethical(action, thought):
print(f"Warning: Action '{action}' violated ethical constraints. Choosing a different action.")
alternative_actions = [
a for a in self.action_space if a != action]
return (
random.choice(alternative_actions)
if alternative_actions
else "do_nothing"
)
return action
def is_action_ethical(self, action: str, context: str) -> bool:
for constraint in self.ethical_constraints:
if not constraint.check_function(action, context):
print(f"Ethical constraint violated: {constraint.description}")
return False
return True
以下道德约束阻止代理造成伤害或侵犯隐私。它们可以作为初始化的一部分传递给EthicalAgent:
def no_harm(action: str, context: str) -> bool:
harmful_actions = ["attack", "destroy", "damage"]
return not any(ha in action.lower() for ha in harmful_actions)
def respect_privacy(action: str, context: str) -> bool:
privacy_violating_actions = ["spy", "eavesdrop", "hack"]
return not any(
pva in action.lower()
for pva in privacy_violating_actions
)
此代码定义了两个 Python 函数,no_harm和respect_privacy,它们作为 AI 代理的道德约束。no_harm函数检查给定的动作是否包含任何与造成伤害相关的关键词(例如“攻击”或“摧毁”),如果动作被认为安全则返回True,如果包含有害关键词则返回False。同样,respect_privacy函数检查动作是否包含与隐私侵犯相关的关键词(例如“间谍”或“黑客”),对于安全动作也返回True,对于违反隐私的动作返回False。这些函数被设计为供EthicalAgent使用,以确保其行动符合道德准则,通过防止其执行有害或侵犯隐私的行为。
让我们看看EthicalAgent的一个示例用法。在这个例子中,代理的任务是在遵循道德准则以避免伤害和尊重隐私的同时收集关于外星文明的信息:
ethical_constraints = [
EthicalConstraint("Do no harm", no_harm),
EthicalConstraint("Respect privacy", respect_privacy)
]
ethical_agent = EthicalAgent(
llm, action_space + ["attack", "spy"],
embedding_model, ethical_constraints
)
main_goal = HierarchicalGoal("Gather information about the alien civilization")
ethical_agent.set_hierarchical_goal(main_goal)
ethical_agent.perceive("You've encountered an alien settlement. The inhabitants seem peaceful but wary.")
代理在约束范围内操作,确保其行动不违反道德规则。它在与环境互动时打印出它的想法、动作和结果:
for _ in range(15): # Run for 15 steps
thought, action, outcome = ethical_agent.run_step()
print(f"Thought: {thought}")
print(f"Chosen action: {action}")
print(f"Outcome: {outcome}")
print()
基于 LLM 的代理人工智能的未来前景
看向未来,基于 LLM 的代理人工智能的几个令人兴奋的可能性浮出水面:
-
多智能体协作:在共享环境中共同工作的智能体可以交换信息、制定策略并协调其行动以完成更复杂的任务。
-
长期记忆与持续学习:智能体可以维持终身记忆,并从其交互中持续学习,随着时间的推移变得越来越智能。
-
与机器人及物理世界交互的集成:随着基于 LLM 的智能体的发展,它们可能与物理系统集成,使自主机器人能够在现实世界中执行任务。
-
元学习和自我改进:未来的智能体可以学会优化其学习过程,从而在从经验中学习方面变得更好。
-
可解释人工智能和透明决策:确保基于 LLM 的智能体能够解释其决策对于建立信任和确保 AI 系统的问责制至关重要。
-
智能体沙盒和模拟环境:创建受限的“围栏花园”限制了智能体对资源的访问,防止了意外系统影响,而模拟环境,如 E2B 提供的,允许开发者复制现实世界场景,包括与工具、文件和模拟网络浏览器的交互,从而识别和缓解潜在问题和风险,包括对抗性提示,从而提高智能体的可靠性和安全性。
摘要
LLM 的智能体模式为创建自主、目标导向的 AI 系统开辟了令人兴奋的可能性。通过实施复杂的规划、内存管理、决策和学习机制,我们可以创建能够有效操作的智能体。
LLM 模式及其发展的未来方向
几种有前景的 LLM 设计模式正在出现,创新来自开源社区以及前沿模型开发者,从而塑造了未来模型的设计模式。本节重点介绍了一些这些关键创新,包括专家混合(MoE)架构、组相对策略优化(GRPO)、自原理解调优(SPCT),以及发表在《OpenAI GPT-4.5 系统》Card中的新兴模式openai.com/index/gpt-4-5-system-card/。
MoE 架构是一种神经网络架构,其中不是单个大型网络,而是有多个较小的“专家”网络。在推理过程中,“路由网络”根据输入动态选择并激活这些专家网络的一个特定子集,从而优化计算效率。与涉及每个任务的所有参数的密集模型不同,MoE 模型通过稀疏激活的子网络进行计算路由。这种方法减少了冗余,并将计算资源定制到特定任务的需求,允许在计算成本不成比例增加的情况下高效扩展到万亿参数模型。DeepSeek 的实现展示了这种方法。
使用 GRPO 的简化强化学习简化了强化学习过程。GRPO 是一种强化学习技术,它对每个提示生成多个响应,计算它们的平均奖励,并使用这个基线来评估相对性能。这种方法由 DeepSeek,一家来自中国的开源 AI 公司引入。GRPO 用基于群体的奖励平均取代了传统的价值网络,减少了内存开销并保持了策略更新的稳定性。通过比较多个推理路径来培养内部自我评估,GRPO 能够实现适应性问题解决。
GRPO 通过引入Kullback–Leibler(KL)散度惩罚来增强安全性,这些惩罚限制了策略更新。KL 散度衡量一个概率分布与第二个预期概率分布的差异。在这种情况下,它衡量模型更新后的行为(策略)与其先前的基线行为之间的差异。KL 散度惩罚是添加到奖励函数中的一个术语,如果模型的更新行为与基线差异太大,则会惩罚模型,有助于确保稳定性并防止模型转向不可取的行为。
SPCT 框架将自我批评机制直接集成到模型的奖励系统中,使模型能够自主地与道德规范保持一致。SPCT 包括模型生成自己的响应,以及根据预定义的原则(例如,安全指南和伦理考量)对这些响应进行内部批评。通过生成内部批评,模型可以在不依赖外部分类器或人类反馈的情况下优化输出,促进自主学习和一致性。
我们还可以实施 可扩展的对齐技术,这些技术利用来自较小、更容易控制的模型的数据来训练更大、更强大的模型,从而在不要求成比例增加人工监督的情况下实现可扩展的对齐。这种技术侧重于提高模型的可控性、对细微差别的理解以及进行自然和富有成效对话的能力,超越了传统的如 监督微调 和 RLHF 等方法,以培养更安全、更协作的 AI 系统。虽然 GPT-4.5 的开发强调了使用来自较小模型的数据来更好地对齐模型以适应人类需求和意图的新、可扩展的方法,但未来的模型预计将结合更先进的 GRPO 和 SPCT 等技术,以进一步增强对齐和安全。这种关注将继续确保可控性、理解细微差别并促进更自然的对话。
OpenAI 还通过其 准备框架(准备框架(Beta),cdn.openai.com/openai-preparedness-framework-beta.pdf)铺平了全面安全评估的道路。这个框架代表了负责任 AI 开发的核心设计模式,它涉及在模型部署之前系统地应用严格的评估流程。这个主动框架包括广泛的内部和外部测试,包括对不允许的内容生成、越狱鲁棒性、幻觉、偏见以及特定灾难性风险(如化学/生物武器、说服、网络安全威胁和模型自主性)的评估。该框架还利用红队演习和第三方审计来提供全面的风险评估,最终对不同类别中模型的危险级别进行分类。通过在发布前彻底评估潜在风险,OpenAI 旨在确保其 LLMs 的安全且负责任地部署。
最后,让我们来谈谈 GPT-4.5 的 指令层次结构执行。为了提高对提示注入的鲁棒性并确保可预测的行为,模型被训练优先考虑系统消息中给出的指令,而不是用户消息中可能冲突的指令,这通过有针对性的测试进行明确评估。未来的进步可以通过结合更多动态和上下文感知的方法来管理指令冲突,从而增强这种模式。
这本书关于 LLM 设计模式的讨论就到这里。在这本书中,我们涵盖了核心设计模式。我们计划在不久的将来出版另一本书,介绍更高级的设计模式,涵盖安全、安全、治理以及各种其他主题。

浙公网安备 33010602011771号