大语言模型数据分析-全-

大语言模型数据分析(全)

原文:zh.annas-archive.org/md5/70b2db9c55198051867062152110ea0c

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分 语言模型介绍

那么,语言模型究竟是什么呢?我们如何利用它们进行数据分析?本书的这一部分回答了这两个问题。

在第一章中,我们讨论了语言模型背后的原理以及它们独特之处。我们还讨论了语言模型在数据分析中可以采用的所有不同方式,包括直接在数据上使用它们以及将它们作为更专业数据分析工具界面的可能性。

在第二章中,我们与 ChatGPT 进行了一次“对话”:也就是说,我们通过与 OpenAI 开发的流行语言模型进行交互。我们见证了 ChatGPT 在执行各种文本任务时的灵活性,从文本分类到根据简明的任务描述从文本中提取特定信息。我们还看到,当将用自然语言表述的数据问题翻译成如 SQL 等正式查询语言时,ChatGPT 表现良好。

阅读这部分内容后,你应该对语言模型是什么以及如何利用它们进行数据分析有一个良好的理解。

第一章:使用大语言模型分析数据

本章涵盖

  • 语言模型简介

  • 使用语言模型进行数据分析

  • 高效使用语言模型

语言模型是强大的神经网络,可用于各种数据处理任务。本章介绍了语言模型,并展示了如何以及为什么使用它们进行数据分析。

1.1 语言模型能做什么?

我们将从这个小诗和相关的图片(图 1.1)开始本节,这两个主题是本书的两个主要主题,数据分析和大语言模型:

*在服务器灯光的寂静嗡嗡声中,

数据在夜晚的血管中流淌。

行和列,一个有结构的海洋,

隐藏着故事,等待被释放。

*每个数字都在唱着未讲述的过去,

在明显的模式中找到趋势和真理。

而现在,一个好奇的朋友到来,

一个渴望理解的语言模型。

*它倾听得非常近,电路敏锐,

将原始事实转化为未见过的洞察。

从散点图到清晰的句子,

数据的语言是它能听到的全部。

*图表起舞,文本回应,

当代码在人工智能的注视下遇到意义。

他们融合了他们的世界,无缝的融合,

在逻辑和语言没有尽头的地方。

*因为在这个紧密而广泛的关系中,

数据的本质找到了一个向导。

在神经网络怀抱中,

数据分析获得了诗意的优雅。

figure

图 1.1 GPT-4o 的插图,将“数据分析”和“大语言模型”这两个主题联系起来

这首诗和这幅画都是由 GPT-4o(“o”代表“omni”)生成的,OpenAI 的一个语言模型,它处理多模态数据,仅基于指令“写一首将数据分析和大语言模型联系起来的诗!”然后是“现在画一幅相应的画!”这幅画和这首诗似乎都与请求的主题相关。尽管这首诗可能不会赢得任何文学奖项,但它的文本是连贯的,它按照我们期望的诗的结构来组织,并且押韵!也许最重要的是,生成这首诗和这幅画只需要用自然语言表达的简短指令。而之前的机器学习方法依赖于大量特定任务的训练数据,这种需求现在已经过时了。当然,任务足够具体,足以让我们相信语言模型不是从网络上复制现有的解决方案,而是生成原创内容。

写诗和生成图片只是许多可能的用例(尽管可能是最有趣的一些)。像 GPT-4o 这样的模型可以解决各种任务,例如总结文本文档、编写程序代码以及回答关于图片的问题。在这本书中,你将学习如何使用语言模型完成从从大量文本文档中提取信息到为数据分析编写代码的各种数据分析任务。阅读完这本书后,你将能够快速构建基于语言模型的数据分析管道,并从各种数据格式中提取有用的见解。

GPT 代表什么?

GPT代表生成式预训练变换器

生成式:GPT 是一个大型神经网络,可以根据输入文本生成内容(例如文本或代码)。这一事实使其与其他神经网络区分开来,例如,其他神经网络只能将输入文本分类到一组预定义的固定类别中。

预训练:GPT 在大量的数据上进行了预训练,解决诸如预测文本中的下一个单词等通用任务。通常,预训练任务与其主要使用的任务不同。然而,预训练有助于它更快地学习更专业的任务。

Transformer是一种新的神经网络架构,特别适用于涉及可变长度输入或输出的学习任务(例如文本文档)。它目前是生成式人工智能方法的占主导地位的架构。

1.2 你将学到什么

这本书是关于使用语言模型进行数据分析的。我们可以根据我们分析的数据类型和分析类型来对数据分析任务进行分类。这本书涵盖了广泛的数据类型和分析任务。

我们专注于多模态数据分析:也就是说,我们使用语言模型来分析各种类型的数据。更确切地说,这本书中涵盖了以下数据类型:

  • 文本——想想电子邮件、报纸文章和网上的论坛评论。文本数据无处不在,包含有价值的信息。在这本书中,我们将看到如何使用语言模型根据其内容自动分类文本文档,如何从文本中提取特定信息,以及如何将关于相关主题的文本文档分组。

  • 图像——正如人们所说,“一图胜千言”。图像帮助我们理解复杂的概念,捕捉我们上次假期的美好回忆,以及描绘当前事件。语言模型可以轻松地从图片中提取信息。例如,我们将使用语言模型来回答关于图像的任意问题或根据数据库中的个人资料识别图片中出现的人。

  • 视频——网络上的大量数据是视频数据。即使在你的智能手机上,视频数据可能也占据了手机总存储容量的很大一部分。在这本书中,我们将看到语言模型也可以应用于分析视频:例如,根据视频内容生成合适的视频标题。

  • 音频——对许多人来说,语音是最自然的交流形式。音频记录捕捉演讲和对话,并补充视频。在这本书中,我们将看到如何转录音频记录,如何将口语翻译成其他语言,以及如何构建一个回答关于数据的语音问题的查询界面。

  • 表格——想象一个包含客户信息的数据库。将数据表示为表格是自然的,其中包含客户地址、电话号码和信用卡信息的列,而不同的行存储有关不同客户的信息。在这本书中,我们将看到如何使用语言模型编写执行此类表格数据复杂操作的代码。

  • ——从社交网络到地铁网络,许多数据集都方便地表示为图,模拟实体(如人或地铁站)及其连接(代表友谊或地铁连接)。我们将看到如何使用语言模型生成分析大型图的代码。

结构化数据与非结构化数据

数据类型通常分为两组:结构化数据非结构化数据。结构化数据具有结构,便于通过专用工具进行高效数据处理。结构化数据的例子包括表格和图数据。对于此类数据,我们通常将语言模型用作访问专用数据处理工具的接口。非结构化数据,包括文本、图像、视频和音频文件,没有结构,不能轻易用于高效处理。因此,对于非结构化数据,我们通常需要在数据上直接使用语言模型。

在本书的大部分内容中,我们将通过 OpenAI 的 Python 库使用 OpenAI 模型。在本书的结尾,我们还将讨论来自其他提供商的语言模型。由于不同提供商的库通常提供类似的功能,适应其他模型不应花费太多时间。

通常,使用语言模型会产生与处理的数据量成比例的货币费用。费用取决于所使用的语言模型、模型配置以及语言模型输入的表述方式。在这本书中,你不仅将学习通过语言模型解决各种数据分析任务,我们还将讨论如何以最低的成本完成这些任务。

1.3 如何使用语言模型

最先进的语言模型通过一种称为提示(prompting)的方法使用。我们将在接下来讨论提示,然后是我们可以用于提示的接口。

1.3.1 提示(Prompting)

直到几年前,机器学习模型都是针对一个特定任务进行训练的。例如,我们可能有一个模型被训练来将评论文本分类为“正面”(即,评论作者满意)或“负面”(即,作者不满意)。要使用该模型,我们只需要评论文本作为输入。不需要将任务(分类评论)作为输入的一部分进行描述,因为该模型已经专门化来执行该任务,并且仅执行该任务。

近年来,随着大型语言模型如 GPT 的出现,这种情况发生了变化。这些模型不再针对特定任务进行训练。相反,它们旨在作为通用任务求解器,原则上可以解决用户想要的任何任务。当使用此类模型时,用户需要精确地告诉模型模型应该做什么。

提示是语言模型的输入。提示可以包含多模态数据:例如,文本和图像。至少,为了使语言模型解决一个特定任务,提示应该包含一个文本,指导模型要做什么。除了这些指令之外,提示还应包含所有相关上下文。例如,如果指令要求模型判断图片中是否可见汽车,提示必须也包含该图片。提示中的指令应该是具体和明确的,例如,预期的输出格式。例如,如果我们希望模型在存在汽车时输出“1”,不存在时输出“0”,以便我们能够轻松地将模型生成的数字添加到计数汽车中,我们需要在提示中明确说明这一点(否则,模型可能会回答“是的,图片中有汽车”,这在后处理阶段会使计数更困难)。除了指令和上下文之外,提示还可以包含示例,以帮助语言模型理解任务。

少样本学习与零样本学习

我们可以通过在提示中提供示例来帮助语言模型更好地理解一个任务。这些示例与我们要模型解决的任务相似,并指定了输入和期望的输出。这种方法有时被称为少样本学习,因为模型基于少量样本来学习任务。另一方面,我们可以使用零样本学习,这意味着模型仅基于任务描述(没有样本)来学习任务。

1.3.2 示例提示

让我们用一个例子来说明提示。语言模型的一个经典用例是分析产品评论,以确定评论背后的情感:评论是积极的(即,客户推荐该产品)还是消极的(即,客户对产品不满意)。假设我们有一个要分类为积极或消极的评论。如果我们有一个针对我们感兴趣的特定产品类别训练的专门用于评论分类的模型,我们只需要将我们的评论发送到该模型。由于模型专门针对目标问题,它已经“知道”如何处理输入和所需的输出格式。然而,由于我们使用大型语言模型,我们必须在评论中提供更多背景信息。

我们的提示应该包含模型所需的所有相关信息,描述要解决的问题和所有背景。在示例场景中,我们可能希望包括以下信息:

  • 评论文本——我们要分类的评论的文本。

  • 任务描述——要解决的问题的描述。

  • 输出格式——所需的输出格式是什么?

  • 相关背景——例如,我们是在审查笔记本电脑还是割草机?

可选地,我们可以包括一些带有相关正确分类的示例评论。这可能有助于模型更准确地分类评论。

以下提示包含了一个示例评论的所有相关信息的全部。

列表 1.1 对笔记本电脑评论进行分类的提示
We are considering product reviews for laptops.  #1
For each review, output "satisfied" or "dissatisfied", 
depending on whether the customer is satisfied 
with the product or not.  #2
Examples:
This is a great laptop! I recommend everyone to buy it! 
satisfied  #3
This laptop did not work. I had to return it.
dissatisfied  #4
The screen is too small and it takes too long to start.  #5

1 背景

2 任务描述和输出格式

3 第一个示例

4 第二个示例

5 评论

这个提示从相关背景描述开始(1)。客户正在审查笔记本电脑,例如,如果他们将物品标记为“重”,这可能是一个坏信号(与分析蒸汽压路机的评论不同)。任务描述(2)告诉模型如何处理评论,并指定所需的输出格式(输出“满意”或“不满意”)。接下来,我们有一个示例列表。严格来说,在这个简单任务中,在提示中添加示例可能不是必要的。然而,在提示中添加示例有时可以提高输出的准确性。在这里,我们添加了两个示例评论(34),以及这些评论的期望输出。最后,我们添加了我们要模型分类的评论(5)。鉴于前面的提示,最先进的语言模型在接收到这个提示作为输入时,很可能会输出“不满意”。当然,这正是我们期望的输出。

1.3.3 接口

那么,我们如何向语言模型发送提示呢?像 OpenAI 这样的提供商通常提供 Web 界面,使用户能够向他们的语言模型发送单个提示。在第二章中,我们将使用 OpenAI 的 Web 界面来发送提示,指示模型分析文本或为数据处理编写代码。

只要我们只发送几个提示,Web 界面就能很好地工作。然而,分析大量文本文档就需要发送许多提示(每个文本文档一个)。显然,我们不想手动输入数千个提示。这就是 OpenAI 的 Python 库派上用场的地方。使用这个库,我们可以直接从 Python 发送提示到 OpenAI 的模型,并在 Python 中处理模型的答案。这使得我们能够自动化数据加载、提示生成以及我们需要在模型答案上进行的任何后处理。它还允许我们将语言模型与其他有用的工具集成:例如,使用语言模型编写数据处理代码,并立即使用其他工具执行该代码。

我们将在第三章中回顾 OpenAI 的 Python 库。我们将在这本书的大部分内容中使用这个库。其他语言模型的提供者,包括 Google、Anthropic 和 Cohere,也提供了类似的 Python 库来向他们的语言模型发送提示。我们将在第八章中更详细地讨论这些库。

1.4 使用语言模型进行数据分析

那么,我们如何具体使用语言模型进行数据分析呢?这本书考虑了两种可能性。首先,我们可以直接在数据上使用语言模型。这意味着语言模型将我们想要分析的数据作为提示的一部分接收(包括要执行的分析说明)。其次,我们可以间接使用语言模型来分析数据。在这里,语言模型并不直接“看到”数据:也就是说,我们不会将数据全部包含在提示中。相反,我们使用语言模型编写数据处理代码,这些代码在专门的数据处理工具中执行。使用哪种方法取决于数据属性和任务。让我们更详细地看看这两种方法。

1.4.1 直接在数据上使用语言模型

使用语言模型分析数据的最佳方法是将数据直接放入提示中。这正是我们在 1.3.2 节中做的事情:为了分析一条评论,我们在提示中包含了评论文本,以及关于如何处理文本的说明。我们也可以用同样的方法处理除了文本之外的其他类型的数据。例如,当使用多模态模型如 GPT-4o 时,我们可以在提示中简单地包含要分析的图片,以及分析说明。

通常,我们不想分析单个图片或评论,而是一组图片或评论。例如,假设我们想要分类整个评论集合,确定每个评论是正面还是负面。在这种情况下,我们通常采取以下方法,使用 Python 和 OpenAI 的 Python 库(或允许用户向其他提供商的模型发送提示的等效库)实现。我们加载要分类的评论,并为每个评论生成一个提示。然后,我们将这些提示发送给语言模型,从模型为每个评论生成的答案中提取分类结果,并将结果保存到磁盘上的文件中。

在这种情况下,我们想要为多个文本文档(即评论)解决相同的任务(评论分类)。正如你可以想象的那样,因此不同的评论提示应该有一些相似之处。尽管每次要分类的评论文本都会变化,但任务描述和提示的其他部分保持不变。

在 Python 中生成提示时,我们使用一个 提示模板。提示模板指定与特定任务关联的提示。在我们的例子中,我们将使用提示模板来分类评论是正面还是负面。提示模板包含占位符,以表示根据输入数据而变化的提示的部分。考虑到我们的评论分类提示模板,我们可能需要包括一个用于评论文本的占位符。然后,在 Python 中生成提示时,我们将用当前要分类的评论文本替换该占位符。

例如,我们可以使用以下提示模板来分类评论。

列表 1.2 用于分类笔记本电脑评论的提示模板
We are considering product reviews for laptops.  #1
For each review, output "satisfied" or "dissatisfied", 
depending on whether the customer is satisfied 
with the product or not.  #2
Examples:
This is a great laptop! I recommend everyone to buy it! 
satisfied  #3
This laptop did not work. I had to return it.
dissatisfied  #4
[ReviewText]  #5

1 上下文

2 任务描述和输出格式

3 第一个示例

4 第二个示例

5 评论文本的占位符

这个提示模板概括了我们之前看到的用于分类单个特定评论的提示(查看第 1.3.2 节中的列表 1.1)。再次强调,我们提供了上下文(我们正在分类笔记本电脑评论)(1),以及描述要解决的问题的任务说明,以及输出格式(2)。我们还提供了一些带有相关分类结果的示例评论(34)。尽管要分类的评论根据输入而变化,但我们不需要更改示例评论。这些评论仅仅说明了语言模型需要解决的问题。最后(5),我们有一个用于评论文本的占位符。当遍历不同的评论时,我们通过用这个占位符替换评论文本来为每个评论生成一个提示。

示例提示模板只有一个占位符。一般来说,根据输入数据,提示的几个部分可能会发生变化。如果是这样,我们为这些部分中的每一个引入占位符,并用它们替换所有部分以生成提示。

图 1.2 总结了我们在使用语言模型直接分析数据时如何使用提示模板。对于每个数据项(例如,一个需要分类的评论),我们在提示模板中的占位符进行替换以生成一个提示(我们也可以说我们实例化了一个提示)。然后我们将这个提示发送给语言模型来解决我们感兴趣的数据分析任务。

图

图 1.2 直接使用语言模型进行数据分析。一个提示模板描述了分析任务。它包含占位符,这些占位符将被用于分析的数据所替换。在替换占位符后,生成的提示被提交给语言模型以产生输出。

1.4.2 通过外部工具进行数据分析

直接将数据放入提示中并不总是最有效的方法。对于某些类型的数据,有专门的工具可用,这些工具可以非常高效地处理该数据上的某些操作。在这些情况下,使用语言模型编写数据处理代码(而不是直接分析数据)通常更有效。语言模型生成的代码然后可以被专用工具执行。

我们将应用这种方法来处理结构化数据。对于如数据表和图这样的结构化数据,有专门的数据处理工具可用,它们支持广泛的分析操作。这些操作,如过滤和聚合数据,可以在结构化数据上非常高效地执行。即使使用语言模型可靠地执行相同的操作(这并不总是可能的),我们也不愿意这样做,因为我们支付给像 OpenAI 这样的提供商的费用与输入数据的大小成比例。使用语言模型处理大型结构化数据集(如包含数百万行的表)是极其昂贵的。在接下来的章节中,我们将讨论以下类型的结构化数据处理工具:

  • 关系数据库管理系统—存储和处理关系数据:即数据表的集合。大多数关系数据库管理系统支持SQL,即结构化查询语言。我们将使用语言模型将关于数据的问题翻译成 SQL 查询。

  • 图数据管理系统—处理表示实体及其之间关系的图数据。不同的图数据管理系统支持不同的查询语言。在第五章中,我们将看到如何使用语言模型将关于数据的问题翻译成 Neo4j 图数据管理系统支持的Cypher语言的查询。

例如,假设我们想要使普通用户能够使用一个用于翻译问题的模板来分析关系数据库:即一组数据表。也许一个表包含调查结果,我们希望让用户从不同的受访者群体中汇总答案。调查结果存储在关系数据库管理系统中(最适合此类数据类型的工具)。使用语言模型,我们可以让用户用自然语言(即普通英语)提出关于数据的问题。语言模型负责将这些问题翻译成正式查询。更确切地说,鉴于数据存储在关系数据库管理系统中,我们希望将这些问题翻译成 SQL 查询。

再次,我们引入一个提示模板来描述我们感兴趣的任务。在这里,我们感兴趣的是文本到 SQL 翻译,这意味着我们希望使用语言模型将自然语言中的问题翻译成 SQL 查询。尽管任务(文本到 SQL 翻译)和数据(包含调查结果的数据库)保持不变,但用户的问题会随时间变化。因此,我们在提示模板中引入一个用户问题的占位符。原则上,以下提示模板应该能够使我们能够将关于我们的调查数据的问题翻译成 SQL 查询。

列表 1.3 将问题翻译到 SQL 的提示模板
Database:  #1
The database contains the results of a survey, stored
in a table called "SurveyResults" with the following
columns: ...
Question:  #2
[Question]
Translate the question to SQL!  #3

1 数据库描述

2 要翻译的问题

3 任务描述

首先,提示描述了我们的数据结构(1)。这是为了使系统能够编写正确的查询(例如,引用表中正确名称的查询)。示例模板中的描述被简化了。我们将在后面的章节中看到如何准确描述关系数据库的结构。接下来,提示模板包含要翻译的问题(2)。这是一个占位符,使用户能够使用相同的提示模板提出不同的问题。最后,提示模板包含一个(简洁的)任务描述(3):我们希望将问题翻译成 SQL 查询!

图 1.3 总结了文本到 SQL 翻译的过程。给定相应的提示模板,我们用用户问题替换占位符,通过语言模型将问题翻译成 SQL 查询,并在关系数据库管理系统中执行查询。查询结果展示给用户。

图像

图 1.3 使用语言模型间接构建表格数据的自然语言界面。提示模板包含关于数据的占位符。在替换占位符后,生成的提示用作语言模型的输入。模型将问题翻译成通过关系数据库管理系统执行的 SQL 查询。

1.5 最小化成本

在使用语言模型处理数据时,我们通常需要向模型提供商支付费用。我们处理的数据量越大,费用就越高。在分析大量数据之前,我们想要确保我们没有支付过高的费用。例如,使用更大的语言模型(从某种意义上说,实现语言模型的神经网络有更多的“神经元”)通常更昂贵,但对于复杂任务,它可能通过更高质量的结果来获得回报。但如果大型模型不是解决我们当前任务所必需的,我们应该节省金钱并使用较小的模型。幸运的是,我们有相当多的方法可以优化处理成本和结果质量之间的权衡。我们将在下一节讨论不同的选项。所有这些内容在后续的章节中都有更详细的介绍。

1.5.1 选择最佳模型

OpenAI 提供了许多不同版本的 GPT 模型,从小型模型到像 GPT-4 这样的巨型模型不等。在撰写本文时,使用 GPT-4 的成本,按每个输入标记计算,比使用最便宜的版本高 100 多倍。

标记(tokens)是什么?

类似于 GPT-4 的语言模型的处理费用与模型读取和生成的标记数量成正比。标记是语言模型在内部表示文本的原子单位。通常,一个标记对应大约四个字符。

考虑到这些价格差异,仔细思考哪个具体模型满足我们的需求显然是一个好主意。例如,对于像评论分类这样的简单任务,我们可能不需要使用 OpenAI 最昂贵的模型。但如果我们想使用该模型编写用于数据处理复杂代码,使用最昂贵的版本可能是有价值的。

当然,我们不需要局限于 OpenAI 提供的模型。许多提供商都提供语言模型,包括 Google、Anthropic 和 Cohere。原则上,我们甚至可以选择托管自己的模型,使用公开可用的模型:例如,在 Hugging Face 平台上。其中一些模型是通用的(类似于 OpenAI 的 GPT 模型),而其他模型则是为更具体的任务训练的。如果我们恰好对存在专门模型的任务感兴趣,我们可能想要使用其中之一。我们将在第八章中更详细地讨论其他提供商的模型。

为满足您的需求选择合适的模型并非易事。作为第一步,您可能想要查看像斯坦福大学的语言模型全面评估(HELM,crfm.stanford.edu/helm/;见图 1.4)这样的基准。这个基准比较了不同语言模型在不同类型任务上产生的结果质量。最终,您可能需要在您的任务和数据样本上尝试几个模型,以确保您选择了最优的模型。在第九章中,我们将看到如何为一个示例任务系统地评估不同的模型。

figure

图 1.4 语言模型整体评估(HELM):根据各种指标比较不同提供商提供的语言模型

1.5.2 优化模型配置

OpenAI 的 Python 库提供了各种调整参数来影响模型行为。例如,我们可以影响某些词语出现在模型输出中的概率。这在例如分类评论时可能很有用。如果模型的输出应该是仅几个可能选择之一(例如“正面”和“负面”),那么将可能的输出限制到这些选择是有意义的。这样,我们就避免了模型生成与任何类名都不对应的输出的情况。以另一个例子来说,我们可以微调决定模型何时停止生成输出的标准。例如,如果我们知道输出应该由单个标记组成(例如,分类评论时的类名),我们可以明确地将输出长度限制为单个标记。这防止了模型生成比必要的更多输出(在这个过程中节省了我们的费用,因为费用取决于生成的输出量)。

我们将在第三章中更详细地讨论那些以及许多其他调整参数。在第九章中,我们将看到如何使用这些调整参数来从我们的语言模型中获得更好的性能。

配置模型的另一种选项是微调它们。这本质上意味着我们正在创建现有模型的自己的变体。通过使用少量特定任务的训练数据来训练模型,我们得到一个模型,它在我们的任务上可能比原始版本表现更好。例如,如果我们想分类评论,我们可能会用几百个示例评论及其相关的分类结果来训练模型。这可能使我们能够使用一个更小、更便宜的模型,该模型针对我们的特定任务进行了微调,并且在这个任务上的表现与一个未经过微调的更大模型相当。

当然,微调也会花费金钱,并且对于特定任务来说,是否值得可能并不立即明朗。我们将在第九章中更详细地讨论微调和相关的权衡。

1.5.3 提示工程

提示模板可以显著影响语言模型产生的结果质量。一个好的提示模板清楚地指定了要解决的问题,并提供了所有相关上下文。在接下来的章节中,我们将看到如何将各种任务映射到合适的提示模板,涵盖各种数据类型。在完成这些示例后,你应该能够根据相同的原则为新颖的任务设计自己的提示模板。

与模型选择类似,如果不进行任何测试,可能很难为特定任务选择最佳的提示模板。在第九章中,我们将在一个示例场景中测试提示模板,并说明不同的提示模板如何导致不同的结果。在某些情况下,花一点时间寻找最佳的提示模板可能会让你能够以相当低廉的模型获得令人满意的表现(而使用未经优化的提示模板可能需要更昂贵的模型)。

在哪里获取提示模板

为新任务找到一个好的提示模板可能需要一些时间。如果你不想花那个时间,可以让别人帮你做!更准确地说,你可以在网上找到允许用户买卖提示模板的平台。其中之一是 PromptBase (promptbase.com)。假设你想将英语问题翻译成 SQL 查询。通过输入相应的关键词,你将在该平台上找到不止一个替代提示模板。如果根据相关的描述,提示模板看起来是一个很好的匹配,你可以购买它并用于你的数据分析需求。

1.6 高级软件框架和代理

在本书的大部分内容中,我们将使用 OpenAI 的 Python 库和其他提供商的类似库。例如,这些库允许你向语言模型发送提示并接收模型的答案。尽管它们对于许多用例来说已经足够,但在开发基于语言模型的复杂应用程序时,你可能需要考虑更高级的软件框架。

在本书中,我们讨论了两个用于处理语言模型的先进软件框架:LangChain (langchain.com) 和 LlamaIndex (www.llamaindex.ai)。两者都使得使用语言模型开发数据分析的 Python 应用程序变得更加容易。

除了许多其他功能外,这些框架使得创建使用语言模型的代理变得容易。这种方法对于需要结合来自多个来源的数据的复杂数据分析任务非常有用。在本书的大部分内容中,我们通过单次调用语言模型来解决数据分析任务,无论是分析文本文档还是将关于数据的问题翻译成正式查询。如果任务需要多个步骤,例如在调用语言模型之前进行预处理或在模型答案上进行后处理,我们必须硬编码相应的处理逻辑。

只要我们能够可靠地预测数据处理所需的步骤序列,这种方法就会有效。然而,在某些情况下,预测所需的步骤可能很困难。例如,我们可能会收到来自用户的关于文本文档或关系型数据库的问题。因此,根据问题,我们可能需要编写 SQL 查询或从文本文档中提取信息。或者,我们可能需要从文本和关系型数据库中获取信息,从文本中提取与问题相关的信息,然后使用我们获得的信息来制定 SQL 查询。

在这种情况下,无法事先硬编码所有可能的步骤序列。相反,我们希望设计一个足够灵活的方法,能够独立决定下一步需要采取什么步骤。这可以通过代理和语言模型来实现。使用这种方法,语言模型被用来将复杂分析任务分解为子问题。此外,语言模型可以选择调用工具:接口用自然语言描述的任意函数。例如,这些工具可以封装对关系型数据库上的 SQL 查询的调用。在调用相应的工具后,语言模型可以访问调用结果(例如,查询结果),并可以使用该结果来规划下一步。我们将看到如何使用代理来解决复杂的数据分析任务,在这些任务中,事先并不清楚需要哪些数据源和处理方法。

摘要

  • 语言模型可以在没有专门训练的情况下解决新任务。

  • 提示是语言模型的输入。

  • 提示可以结合文本和其他类型的数据,例如图像。

  • 提示包含任务描述、上下文和(可选)示例。

  • 语言模型可以直接分析某些类型的数据。

  • 当直接分析数据时,数据必须出现在提示中。

  • 提示模板包含占位符:例如,用于表示数据项。

  • 通过替换提示模板中的占位符,我们获得一个提示。

  • 语言模型还可以通过外部工具帮助分析数据。

  • 语言模型可以指导其他工具如何处理数据。

  • 模型有多种不同的大小,成本差异显著。

  • 模型可以使用各种配置参数进行配置。

  • LangChain 和 LlamaIndex 有助于开发复杂的应用程序。

  • 代理使用语言模型来解决复杂问题。

第二章:与 ChatGPT 聊天

本章涵盖

  • 访问 ChatGPT 网页界面

  • 直接使用 ChatGPT 进行数据处理

  • 间接使用 ChatGPT 进行数据处理

是时候认识 ChatGPT 了!在本章中,我们将与 ChatGPT 进行对话,并开始使用它进行数据分析。如果你从未使用过 ChatGPT,本章将教你如何访问它,并给你一个对其能力(以及其局限性)的第一印象。如果你已经使用过 ChatGPT,但尚未用于数据分析,本章将向你展示许多你可以利用 ChatGPT 进行数据处理的途径。

我们将首先讨论一个网页界面,它将为你提供访问 OpenAI 的 ChatGPT 的权限。我们将回顾 OpenAI 的注册流程,讨论界面提供的主要功能,并使用它与 ChatGPT 进行第一次对话。之后,我们将开始使用 ChatGPT 在几个示例场景中分析数据。我们将看到两种不同的利用 ChatGPT 进行数据分析的方法:直接和间接。当直接使用 ChatGPT 时,我们根据数据和任务描述让 ChatGPT 进行实际的数据处理。这适用于 ChatGPT 原生处理的数据类型(如文本数据)。

另一方面,我们也可以使用 ChatGPT 间接分析数据。在这里,ChatGPT 仅作为翻译者,将分析任务的描述翻译成外部数据处理工具所能理解的形式化语言。实际的数据处理由这些外部工具处理。在本章中,你将看到 ChatGPT 在这两种场景中都很有用。

2.1 访问网页界面

打开你的网页浏览器,在地址栏中输入chat.openai.com/。你将创建一个 OpenAI 账户,该账户允许你使用 ChatGPT。如果你已经有了账户,你可以跳过以下步骤,登录你的账户,并继续下一节。

要创建账户,点击注册按钮。这会带你到图 2.1 所示的屏幕。

figure

图 2.1 OpenAI 账户注册页面。输入你的电子邮件地址,然后点击继续按钮,或者使用谷歌账户或微软账户进行注册。

在注册 OpenAI 账户时,你有几个选择:

  • 使用谷歌账户注册(通过点击继续使用谷歌)。

  • 使用微软账户注册(通过点击继续使用微软账户)。

  • 通过在电子邮件地址字段中输入该地址并点击继续,使用任意电子邮件地址注册。之后,按照屏幕上的说明操作。

使用上述任何一种方式创建账户后,登录你新创建的账户,并继续下一节中概述的步骤。

OpenAI 订阅

OpenAI 提供不同类型的账户;有些是免费的,有些则需要每月支付费用。对于以下示例,一个免费账户就足够了。您仍然可以选择注册付费订阅以访问更多模型并获得 ChatGPT 更快的答案。根据您选择的订阅类型,您可以向特定模型发送的请求数量可能有限。

2.2 制作介绍

登录您的 OpenAI 账户后,您应该会看到图 2.2 所示的界面。

figure

图 2.2 ChatGPT 网页界面。通过点击预定义的示例输入或输入任意文本到输入字段与 ChatGPT 互动。点击“新建聊天”按钮以重置对话。

此界面允许您通过提交文本输入并与 ChatGPT 进行对话。让我们花一点时间来了解图 2.2 中最重要的界面组件。首先,您有几个预定义的输入示例。您可以简单地点击这些示例中的任何一个来开始对话。除了预定义的示例之外,您还可以将任意文本输入到文本字段中。在本章的其余部分,我们将把这个界面元素称为输入字段。最后,您可以通过点击“新建聊天”按钮在任何时候开始新的对话。这样做会清除 ChatGPT 对先前对话步骤的记忆。

我应该选择哪个模型?

点击图 2.2 中标记为“模型”的按钮,您可以选择不同的语言模型。ChatGPT 支持 OpenAI 的 GPT 模型系列中的几个模型。接下来讨论的示例原则上可以与任何可用模型一起工作。您可能想尝试几个不同的模型,看看输出如何不同。根据您的订阅,您可以向特定模型发送的请求数量可能有限。

是时候打招呼了!点击输入字段,向 ChatGPT 打招呼。您可以输入任何内容。ChatGPT 已被训练与人类用户进行对话,应该能够以合理的方式回答大多数输入。例如,告诉 ChatGPT 一些关于您自己的信息!寻求意见或帮助即将到来的任务!或者,也许,要求它为您选择的主题写一首诗!您可能想花几分钟与 ChatGPT 聊天,以更好地了解其功能和局限性。

注意,ChatGPT 可以回溯到先前的输入。例如,如果您对先前的回复不满意,您可以要求 ChatGPT 进行纠正或更改。无需重复原始请求。另外,如果您对答案不满意但不想提供进一步的澄清,请尝试图 2.3 中标记为“重新生成”的按钮。

figure

图 2.3 在 ChatGPT 生成答案后,再生按钮出现在生成的响应下方。点击此按钮以接收对您最后输入的替代答案。

点击该按钮将导致 ChatGPT 再生其最后一个答案。由于 ChatGPT 在生成输出时使用了一定程度的随机化,因此第二个输出可能与第一个版本不同(并且可能更好)。

通常,您输入的所有文本都是同一对话的一部分。如果您在任何时候想开始一个新的聊天(本质上清除 ChatGPT 对先前对话的“记忆”),只需点击图 2.2 中标记为“新聊天”的按钮。请注意,即使您开始了一个新的聊天,之前的对话也不会丢失。相反,OpenAI 会存储过去的对话,并允许用户回顾它们(或从上次结束的地方继续对话)。您可以通过点击图 2.2 中标记为“历史”的按钮访问先前对话的历史记录。每个对话都会分配一个简短的标题,该标题是自动根据对话内容生成的。

首次使用 ChatGPT 通常是一次令人印象深刻的体验。ChatGPT 为各种主题生成精致且合理的答案。这可能会误导用户过分相信它提供的信息。避免忽视适用于当前一代语言模型的各项限制非常重要。一般来说,在依赖语言模型之前,始终验证其输出。

什么是幻觉?

在语言模型中,术语“幻觉”指的是在没有信息的情况下,语言模型创造新内容并将其整合到答案中的情况。通常,结果听起来很有说服力,识别幻觉的实例可能很困难。正在进行的研究[1]探索了减少幻觉发生概率的方法。然而,在撰写本文时,尚无避免幻觉的可靠策略。因此,不要盲目相信语言模型提供的信息,并且始终使用其他来源进行核实。

2.3 使用 ChatGPT 处理文本

让我们开始使用 ChatGPT 进行数据处理!在本节中,我们将使用 ChatGPT 来分析文本,这是一种常见的非结构化数据类型。同时,文本是 ChatGPT 的本地输入和输出格式。这意味着我们可以直接使用 ChatGPT 来分析我们的(文本)数据,而无需涉及外部分析工具。这有助于保持事情简单,这正是我们迈向 ChatGPT 支持的数据分析的第一步。

想象以下情况:你是 Banana 公司的一名员工,Banana 是一家生产各种消费电子产品的公司,包括笔记本电脑和智能手机。Banana 网站允许用户发布自由形式的文本评论。结果发现,许多用户会对 Banana 的特定产品表达满意或不满。用户经常指出这些产品的特定方面,这些方面突出或需要改进。总体而言,所有这些评论应该对设计未来的 Banana 产品非常有帮助!但要实现这一点,你首先需要分析所有这些评论并提取共同点。你的老板要求你做这件事:准备总结不同产品和产品类别的评论的统计数据。同时,在你阅读评论的过程中,你的老板鼓励你为产品评论写一些积极的回复。理想情况下,你希望避免花下周的时间阅读和汇总评论。难道不能自动化吗?也许 ChatGPT 能帮上忙。

接下来,我们将使用 ChatGPT 的网页界面来分析一个示例评论。如果效果良好,可能可以将这个过程扩展到自动分析所有评论!如果你之前已经开始了一个对话,首先点击图 2.2 中的“新建对话”按钮来开始一个新的对话。这将确保你之前与 ChatGPT 讨论的话题不会干扰我们接下来尝试的分析示例。

让我们先告诉 ChatGPT 我们想要分析的评论。点击输入字段,输入以下对话中用户标记之后的文本,然后按 Enter 键。

ITUser ****

看看以下的产品评论:

“我一年前买了 BananaBook,对这个产品非常满意!屏幕很棒,即使在阳光明媚的时候也能轻松阅读内容。它的速度足够快,可以满足我所有的需求,例如编码、办公工作。唯一的缺点是它的重量,我希望它能轻一点,因为我得经常带着它四处走动。但是,以那个价格来说,这是一个很好的优惠。”

chatGptGPT ****

根据评论,BananaBook 是评论者一年前购买并且仍然满意的产品。BananaBook 的屏幕给人留下深刻印象,允许用户在阳光明媚的时候也能轻松阅读内容。该设备也足够快,可以满足用户的需求,包括编码和办公工作。然而,评论者提到 BananaBook 有点重,这可能会在携带时造成不便。总的来说,评论者似乎认为 BananaBook 以其价格而言是一个很好的价值。

GPT 标记之后的文本是 ChatGPT 在样本会话期间生成的答案。在这种情况下,ChatGPT 只是重复了评论,尽管措辞略有不同,这表明它已经接受并“理解”了你的输入。

为什么我会得到不同的输出?

ChatGPT 在生成输出时使用了一定程度的随机化。这意味着即使你两次提交完全相同的输入,你也通常不会看到相同的输出。因此,你看到这里显示的精确输出的可能性很小。然而,你很可能会得到质量相似的结果。

让我们从简单的分类开始我们的文本分析,将评论映射到一个类别。具体来说,我们将尝试将评论分类到以下三个产品类别之一:手机、个人电脑和游戏机。

警告:在输入下一文本之前不要点击“新建聊天”按钮!新建聊天按钮的目的是清除 ChatGPT 对先前对话步骤的记忆。但在这里我们根本不希望这样做。以下所有分析都针对我们最初输入的同一评论。只有当 ChatGPT 记得那个评论时,这才能奏效。

IT用户 ****

评论指的是哪个产品类别(手机、个人电脑、游戏机):

chatGptGPT ****

根据评论中提供的信息,不清楚 BananaBook 属于哪个产品类别。评论者没有说明它是手机、个人电脑还是游戏机。然而,根据评论者提到使用该设备进行编码和办公工作的实际情况,BananaBook 更有可能是个人电脑或笔记本电脑,而不是手机或游戏机。

还不错!ChatGPT 清楚地理解了任务,并提供了详细的回复。BananaBook 是一个虚构的设备,我们并不立即清楚我们谈论的是哪种产品。然而,正如 ChatGPT 正确指出的,编码和办公工作通常与个人电脑(比手机或游戏机更常见)相关联。尽管存在不确定性,ChatGPT 可以提出一个合理的答案并充分解释它。在这种情况下,ChatGPT 的回答甚至比所需的更细致:尽管 ChatGPT 将评论与提供的三个类别之一联系起来,但它还假设评论指的是笔记本电脑(个人电脑的一个子类别)。考虑到用户经常携带电脑(通常与笔记本电脑而不是台式机相关联),这个假设在这个上下文中似乎是合理的。

之前的回答合理,但可能有点长。实际上,它的长度与原始评论相似。如果生成的答案与源文本一样长,就没有必要使用 ChatGPT 进行文本分析了。让我们让 ChatGPT 回答得更简洁一些。我们不需要提供详细的解释;我们只想得到评论中最可能类别的名称。

IT用户 ****

简洁回答,只需写出最可能的产品类别名称(手机、个人电脑、游戏机):

chatGptGPT ****

个人电脑。

好多了!显然,ChatGPT 能够理解我们的指示并相应地调整其先前的答案。除了阅读更快之外,简洁的输出还有一个优点。如果我们最终想要分析许多评论而不是单个评论,那么在评论之间保持输出的一致性是很重要的。例如,具有一致性的输出使得计算每个类别中评论的数量变得更容易。

让我们根据不同的类别对评论进行分类。这次,我们将直接提示所需的输出格式(注意,我们的规范“简洁回答”是指向之前的指示)。

IT用户 ****

以下评论指的是哪个产品类别:食品、化妆品、电子产品?简洁回答:

chatGptGPT ****

电子产品。

完美!这是正确的类别,输出格式也符合预期。到目前为止,我们已经看到 ChatGPT 能够根据不同的标准对评论进行分类。这似乎很有用,例如,通过计算每个类别中评论的数量来创建统计数据。但理想情况下,我们还想分析这些评论的内容。哪些产品或产品类别最需要迫切改进?为了确定这一点,我们需要知道评论是正面还是负面:也就是说,用户对产品是满意还是不满意。这是对评论文本的另一种分类方法。然而,这次我们的分类不是指被评论的产品,而是指评论内容。

IT用户 ****

客户对产品满意还是不满意?用“满意”或“不满意”回答:

chatGptGPT ****

满意。

是的,这是正确的!使用迄今为止看到的能力组合,我们已经在使用 ChatGPT 来计算不同产品类别的正面和负面评论的数量。这给我们提供了一些关于哪些产品类别迫切需要改进的信息,但这可能不够具体。例如,了解用户对产品的哪些方面最满意或不满意可能很有趣。让我们尝试使用 ChatGPT 提取这方面的信息。

IT用户 ****

评论中提到了哪些产品的正面方面?用编号列表回答,每个方面用最多三个词描述:

chatGptGPT ****

  1. 屏幕令人印象深刻

  2. 性能快

  3. 适合编码

  4. 价值高

再次正确!ChatGPT 提取了评论中提到的最显著的产品优点。请注意,输出格式再次符合相应的指示:它是一个编号列表,每个条目最多有三个词。那么评论中提到的缺点呢?

IT用户 ****

评论中提到了哪些产品的负面方面?用编号列表回答,每个方面用最多三个词描述:

chatGptGPT***

  1. 重量大

根据用户的说法,BananaBook 的唯一缺点是重量。请注意,即使结果只包含一个条目,ChatGPT 也坚持使用请求的输出格式(编号列表)。

如果我们想要将评论汇总成统计数据,对评论进行分类并提取特定的优点和缺点效果很好。然而,这样做可能会丢弃信息:例如,更详细地说明用户为什么喜欢或不喜欢产品的特定方面。有时我们可能只想阅读一个评论样本。但为了避免花费时间阅读过长的评论,首先总结这些评论会很好。让我们使用 ChatGPT 来完成这项工作。

IT用户***

用三句话总结评论:

chatGptGPT***

评论者一年前购买了 BananaBook 电子产品,并且对其保持满意。BananaBook 具有令人印象深刻的屏幕,即使在阳光明媚的环境下也能轻松阅读,并且足够快,适合编码和办公工作。唯一的缺点是它的重量,但就价格而言,BananaBook 是一个很好的价值。

结果是一个合理的总结,精确地使用了指令中要求的句子数量。我们能否让它更加简洁?

IT用户***

用一句话总结评论:

chatGptGPT***

尽管 BananaBook 有点重,但评论者对其电子产品 BananaBook 感到满意,因为它具有令人印象深刻的屏幕、快速的性能和良好的性价比。

是的,这也同样有效!实际上,经过初步测试,ChatGPT 似乎可以帮助自动化各种分析任务。最后,让我们看看我们是否可以利用它来完成分析以外的任务。保持良好的客户关系对 Banana 来说是一个优先事项!因此,你的老板提出了一个问题,要求你在可能的情况下为用户评论撰写友好的回复。但这样做也需要时间。我们能否也用 ChatGPT 来自动化这一步骤呢?让我们看看...

IT用户***

为 BananaBook 的生产公司撰写一封友好的回复!

chatGptGPT***

当然,以下是对评论的友好回复:

亲爱的尊贵客户,

我们很高兴听到你在购买 BananaBook 电子产品一年后仍然对其感到满意!我们设计 BananaBook 是为了为用户提供令人印象深刻的屏幕和快速的性能,很高兴看到这些特性满足了你在编码和办公工作方面的需求。我们感谢你对设备重量的反馈,并将考虑它作为未来改进的依据。感谢您选择 BananaBook,我们希望继续超越您的期望!

最诚挚的问候,

[公司名称]

很不错!ChatGPT 用友好的信息回复客户,适当地引用了评论中的具体点(确认客户提到的积极点,并承诺针对弱点进行改进)。请注意,ChatGPT 为对应公司的名称留了一个占位符(用方括号标记)。到目前为止,我们还没有告诉 ChatGPT 我们公司的名字(Banana)。让我们来修正一下。

IT用户****

公司的名称是 Banana - 请在您的回答中替换相应的占位符。

chatGptGPT* ****

我为我之前回答中的错误道歉。以下是一个带有正确公司名称的更新回复:

亲爱的尊贵客户,

我们很高兴听到你在购买一年后仍然对你的 BananaBook 电子产品感到满意!我们设计 BananaBook 是为了为用户提供令人印象深刻的屏幕和快速的性能,我们很高兴看到这些特性满足了你在编码和办公工作方面的需求。我们感谢你对设备重量的反馈,并将考虑它作为未来改进的依据。感谢您选择 Banana,我们希望继续超越您的期望!

最好的问候,

Banana 团队

好的,这样就可以了!基于额外信息,ChatGPT 能够将之前回答中的占位符替换为公司名称。

警告:尽管最近模型显著降低了此类事件发生的概率,但语言模型如 ChatGPT 偶尔仍可能生成有害内容。建议在公共论坛上发布之前检查 ChatGPT 生成的文本。因此,在没有一定程度的人工监督的情况下,自动撰写客户评论的回答不是一个好的用例。

我们已经看到我们可以使用 ChatGPT 在文本处理的各种任务中。我们使用 ChatGPT 根据不同的标准对文本进行分类,并使用自定义分类(根据评论目标和评论内容进行分类)。我们还用它从文本中提取特定信息,并总结文档(即评论)。最后,我们使用 ChatGPT 生成回答客户评论的文本。在所有情况下,ChatGPT 都能够遵循关于任务和所需输出格式的指示。如果你想的话,尝试写一篇不同的评论,并确保 ChatGPT 仍然能够解决所有这些任务。

直到最近,我们在这个部分讨论的每个不同的文本处理任务都需要一个专门的语言模型。最新一代的语言模型足够灵活,可以根据任务的描述(用普通的英语以及其他自然语言)解决各种任务。

注意,我们迄今为止只处理了一个简短的评论。在这里,使用 ChatGPT 并没有真正带来好处。我们可以手动分类或总结评论,而且比借助 ChatGPT 更快。当然,自动化的目标是扩大处理范围,以处理大量(可能是更长的)评论。如果我们谈论的是数百甚至数千条评论,手动分析将比设置 ChatGPT 来完成这项任务花费更长的时间。但仍然缺少一个关键组件:我们如何通知 ChatGPT 所有评论文本?我们能否简单地复制粘贴整个评论集合到 ChatGPT 的网页界面中?

那种方法是不行的。语言模型通常对一次可以处理的信息量有限制。因此,我们需要一个机制,能够自动地将单个评论(或少量评论集合)"喂"给 ChatGPT 进行处理。我们将在接下来的章节中讨论相应的解决方案。目前,我们只想验证 ChatGPT 在原则上可以用于对文本文档执行各种分析任务。

2.4 使用 ChatGPT 处理表格

在上一节中,我们直接使用 ChatGPT 进行数据处理,向 ChatGPT 提供数据以及任务描述作为输入。只要我们处理的是文本,即 ChatGPT 的“原生”输入和输出格式,这种方法是合理的。对于其他类型的数据,使用专门针对数据类型特定的工具进行数据处理会更有效率。你可能想知道:如果我们使用外部工具处理数据,ChatGPT 在这种情况下还能提供什么帮助?

有许多工具用于处理数据,专门针对不同类型的数据、处理和硬件或软件平台。要使用这些工具,用户通常需要用工具特定的形式语言表达所需的分析操作。编写代码来分析数据对于专家来说可能是繁琐的,对于 IT 背景有限的用户来说更是如此。在这里,像 ChatGPT 这样的语言模型可以帮助,因为它们不仅理解自然语言,还理解数据分析工具使用的形式语言。这意味着我们可以使用 ChatGPT 作为某种“翻译器”,将我们用普通英语提出的数据问题,翻译成各种语言的代码,并通过外部工具执行。这就是我们接下来要做的。

你回到了 Banana,并成功使用 ChatGPT 分析了用户提交的所有各种评论。将此信息表示为数据表是自然的。每一行对应一条评论,列代表从评论中提取的不同类型的信息。表 2.1 展示了前几行。

表 2.1 示例表格,包含三个列,分别代表评论 ID、一个标志表示评论者是否对产品满意,以及产品类别
评论 ID 满意 类别
1 1 笔记本
2 0 手机
2 1 游戏
... ... ...

为了使示例简单,我们使用一个包含少量列的表格,这些列对应于上一节中描述的分析子集。第一列包含审查 ID。第二列包含一个标志,表示审查员是否对产品满意(1)或对产品不满意(0)。最后一列包含类别。对于本例,我们只考虑三个类别:笔记本电脑、手机和游戏。

这个表格已经是原始评论的一个更简洁的表示。但完整的表格有很多行(因为我们开始时有许多评论),阅读原始表格数据并不提供很多见解。理想情况下,我们希望以有趣的方式聚合数据,并向老板展示高级趋势。我们可以使用哪些工具来完成这项工作?

2.4.1 在网页界面中处理表格

第一个选项是直接使用 OpenAI 网页界面分析表格数据。首先,让我们下载一个包含审查分析结果的示例表格。在书的配套网站上搜索名为“审查表”的链接(www.dataanalysiswithllms.com),并下载相关文件。它包含一个表格,其结构如表 2.1 所示,格式为.csv。

.csv 格式是什么?

CSV 代表逗号分隔值。它指定了一种用于表示表格数据的特定格式。每个表格行存储在一行中,同一行中不同列的值由逗号分隔。

要直接在 ChatGPT 网页界面中分析此类数据,我们首先需要上传它。点击图 2.4 中显示的上传按钮(在这样做之前,你可能还想开始一个新的聊天)。选择从计算机上传选项,并选择你刚刚下载的文件(reviews_table.csv)。

图

图 2.4 点击上传按钮,选择从计算机上传选项以上传磁盘上的文件。

上传文件后,它应该出现在输入文本字段旁边。你现在可以输入关于数据的任意问题到输入文本字段。当生成答案时,ChatGPT 将考虑并分析你提供的数据。例如,让我们询问每个产品类别的评论数量。

IT用户***

我们每个产品类别有多少条评论?

你应该看到如图 2.5 所示的输出。ChatGPT 显示了一个包含你问题答案的表格(每行统计一个产品类别的评论数量)以及相关的文本。

图

图 2.5 ChatGPT 对一个关于输入表格的问题生成的答案。点击显示分析按钮,查看 ChatGPT 是如何确定答案的。

ChatGPT 是如何计算答案的?它是直接读取整个表来生成回复的吗?并非如此。在后台,ChatGPT 在 OpenAI 的平台生成并执行 Python 代码,以分析输入数据(在这个场景中,Python 执行引擎是我们最初提到的外部工具)。您可以通过点击图 2.5 中标记为“显示分析”的按钮来查看生成的代码。实际上,强烈建议检查生成的代码,而不是盲目依赖答案。毕竟,尽管它们的能力令人惊叹,语言模型仍然会犯错误。

尝试几个更多的问题,检查生成的代码,甚至可能尝试几个不同的数据集(您可以在电脑上上传任何表格数据,例如 Excel 格式)。您会发现 ChatGPT 可以处理各种数据集和请求。

这看起来效果相当不错!我们为什么还需要其他东西呢?好吧,有几个原因让我们想要探索其他外部数据分析工具。首先,我们上传文件的大小有严格的限制(在撰写本文时为 512 MB)。上传大型数据集是不可能的。其次,您可能已经注意到,使用 Python 进行数据分析可能需要几秒钟的时间,即使是中等大小的数据集(例如,只有 10,000 行的评论表,按照今天的标准来说很小)。分析大型数据集需要大量的时间。最后,上传数据到 OpenAI 可能不是每个用例都可行的。为了保护敏感数据的隐私,用户可能更愿意在自己的平台上分析数据。在下一节中,我们将看到如何使用 ChatGPT 在 OpenAI 的网页界面之外分析数据。

2.4.2 在您的平台上处理表格

在某些情况下,直接在 OpenAI 的网页界面上分析数据并不是一个选择。相反,ChatGPT 可以帮助我们使用各种其他数据分析工具,这些工具完全在我们的控制之下。接下来,我们将使用关系型数据库管理系统(RDBMS)。这类系统专门用于处理我们感兴趣的数据类型,并且往往能实现高处理效率。这只是一个例子:提出的方法可以推广到各种其他类型的数据分析系统。

关系型数据库管理系统

关系型数据库管理系统是专门用于处理关系数据的:即包含一个或多个表格的数据集,这些表格的类型如表 2.1 所示。大多数数据库管理系统支持结构化查询语言(SQL)的变体,这是一种用于描述数据和数据操作的语言。对于以下示例,我们假设你已经熟悉 SQL 和关系型数据库管理系统的基础知识。如果你不熟悉,你可以在第五章中找到一个 SQL 的简要介绍。要获取更多详细信息,请考虑阅读 Gehrke 和 Ramakrishnan 合著的书籍《数据库管理系统》[2],或者尝试本书作者的在线课程,网址为www.databaselecture.com

我们将使用 SQLite,这是最受欢迎的关系型数据库管理系统之一。为了节省你在机器上安装和配置该系统的麻烦,请访问本书的配套网站,并遵循链接到 BananaDB 资源的链接。它将引导你到一个 Google Colab 笔记本,你可以用它来完成以下步骤。图 2.6 显示了跟随链接后你应该看到的笔记本。

figure

图 2.6:Google Colab 笔记本,允许你通过 SQLite 查询 BananaDB 数据库。执行上面的单元格(创建数据库)以创建数据库,将给定的 SQL 查询(SQL 查询)替换为你选择的查询,然后执行下面的单元格(执行查询)以查看查询结果。箭头标记了运行按钮以执行上面的单元格。
Google Colab 笔记本

我们将使用 Google Colab 笔记本,以便你可以在不安装本地机器上的软件的情况下尝试以下示例。以下说明假设你熟悉 Colab 笔记本的一般知识。如果你想提高你的笔记本技能,请查看colab.research.google.com上的教程。

笔记本中已经包含了创建示例数据库和分析其中数据的代码。本章的内容是熟悉 ChatGPT 的 Web 界面。因此,目前我们不会花时间详细讨论笔记本中的代码。在本章的剩余部分,了解代码的功能以及你需要在以下段落中更改的具体部分就足够了。在高度抽象的层面上,本笔记本中的代码创建了一个包含示例数据的数据库,并允许你通过 SQLite 执行 SQL 查询来分析这些数据。

你可以看到笔记本中有两个包含代码的单元格(如图 2.6 所示,标记为创建数据库和执行查询)。上面的单元格(创建数据库)包含创建包含 2.1 表扩展版本的数据库的代码,并用大约 1,000 条审查数据填充它。让我们首先通过点击单元格左上角的运行按钮(如图 2.6 中的箭头所示)来执行这个单元格。几秒钟后,这将创建一个 SQLite 数据库并填充相应的数据。你只需要在每个会话中执行这个单元格一次。如果你没有足够长时间地与笔记本交互,环境将被重置,你需要再次执行这个单元格。

我们将要求 ChatGPT 为我们编写引用此数据库的 SQL 查询。你可以使用图 2.6 中的下单元格(执行查询)在数据上执行 SQL 查询。默认情况下,该单元格包含一个计算游戏类别中审查数量的 SQL 查询。通过点击其运行按钮来执行该单元格(以查看一切是否正常工作)。执行单元格后,你应该看到游戏类别中的审查数量:342。我们将用 ChatGPT 提出的查询替换当前的查询(select count(*) from reviews where category = 'gaming';),然后重新执行此单元格以获取新查询的结果。

好的,现在是时候让 ChatGPT 为我们进行一些数据分析了!首先,通过点击 ChatGPT 网页界面中的新建聊天按钮来启动一个新的对话。这是确保之前的对话步骤不会干扰我们的新任务的重要步骤。为了使用 ChatGPT 编写查询,我们首先需要告诉它一些关于我们的数据库外观的信息。特别是,我们应该描述表结构,即表名(Reviews)以及所有相关列。在这种情况下,该表有三个列,我们可能需要添加一些文本来描述这些列中的内容。在编写 SQL 查询时,了解每个列中的数据类型以及理想情况下关于我们可能找到的具体值及其语义的信息是有用的。

最后,另一个重要的信息是我们用来分析数据的具体系统。尽管许多关系型数据库管理系统(RDBMS)支持 SQL,并且 SQL 相当标准化,但支持的 SQL 功能子集可能在不同系统之间有所不同。此外,一些系统可能支持略微偏离 SQL 标准的 SQL 语法。

在明确了编写 SQL 查询需要注意的事项后,让我们为我们的 BananaDB 示例数据库提供这些信息。

IT用户***

我们有一个包含一个名为 Reviews 的表的 SQLite 3 数据库,该表总结了产品评论。该表有三个列:

  • 审查 ID(审查的整数 ID)

  • 满意(如果审查者满意则为 1,如果不满意则为 0)

  • 类别(“笔记本电脑”、“手机”或“游戏”)

chatGptGPT***

那太好了!拥有一个名为“Reviews”的 SQLite 数据库表,该表总结了产品评论,可以非常有用。有了你提到的三个列(ReviewID、Satisfied 和 Category),你可以存储和检索有关评论的宝贵信息。

为了进一步帮助你,我可以提供一些示例 SQLite 查询,你可以使用它们来与你的数据库交互。请告诉我你想要执行或从数据库中检索的具体操作或信息。

ChatGPT 对分析我们数据库的前景持乐观态度,并回答了我们在输入中提供的数据库结构的总结。此外,ChatGPT 已经意识到这将走向何方,并在我们甚至要求它之前就主动提出编写一些示例 SQL 查询。让我们看看 ChatGPT 准备了什么。

IT用户****

当然!请提供一些示例查询!

chatGptGPT***

当然!这里有一些你可以用来与名为“Reviews”的 SQLite 数据库表交互的示例查询:

检索所有评论:

SELECT * FROM Reviews;

检索满意评论:

SELECT * FROM Reviews WHERE Satisfied = 1;

检索不满意评论:

SELECT * FROM Reviews WHERE Satisfied = 0;

检索笔记本电脑评论:

SELECT * FROM Reviews WHERE Category = 'laptops';

检索手机评论:

SELECT * FROM Reviews WHERE Category = 'phones';

检索游戏评论:

SELECT * FROM Reviews WHERE Category = 'gaming';

统计评论总数:

SELECT COUNT(*) FROM Reviews;

统计满意评论的数量:

SELECT COUNT(*) FROM Reviews WHERE Satisfied = 1;

统计不满意评论的数量:

SELECT COUNT(*) FROM Reviews WHERE Satisfied = 0;

这些查询应该为你提供一个起点,以便在“评论”表上执行各种操作。你可以修改它们或组合它们以满足你的特定需求。

不错!这些查询涵盖了相当多的自然问题;例如,关于特定类别中评论数量的查询。一般来说,要求 ChatGPT 提出查询是开始分析数据集的好方法。

顺便说一句,你可能注意到,网络界面使用特定的表示方式显示 SQL 查询。图 2.7 显示了第一个查询的显示方式。

figure

图 2.7 ChatGPT 网络界面使用专门的标记来表示代码,包括 SQL 查询。点击复制代码按钮以复制代码(使其方便粘贴到适合执行的界面中)。

SQL 查询在一个框中,点击复制代码按钮就可以轻松复制。要尝试任何提出的查询,请按照之前描述的方式复制它,然后将其插入到 BananaDB 笔记本的适当位置(如图 2.6 中的 SQL 查询标签)。最后,执行笔记本中的周围单元格以获得查询结果。

让我们看看我们能否让 GPT 也将我们的问题翻译成查询。

ITUser ****

编写一个 SQL 查询以检索具有最多评论的产品类别!

chatGptGPT ****

要从你的 SQLite 数据库表“Reviews”中检索具有最多评论的产品类别,你可以使用以下 SQL 查询:

SELECT Category, COUNT(*) AS ReviewCount

FROM Reviews

GROUP BY Category

ORDER BY ReviewCount DESC

LIMIT 1;

此查询使用 GROUP BY 子句按“Category”列对评论进行分组。然后,它应用 COUNT(*)函数来计算每个类别的评论数量。结果根据评论数量使用 ORDER BY 子句降序排序。最后,LIMIT 1 子句确保仅返回具有最高评论计数的类别。

查询将返回产品类别及其相应评论计数的对应值。

我们感兴趣的是找到与最多评论相关的产品类别。ChatGPT 将我们的问题翻译成 SQL 查询,以检索相应的结果。如果你在我们的示例数据库上执行此查询,它应该检索到游戏类别,以及相关的评论数量。

如何处理多行查询

这是 ChatGPT 生成的第一个多行 SQL 查询。为了在我们的 Colab 笔记本中使用该查询,请用三引号(""")而不是单引号包围查询。这样做确保整个查询被视为一个单独的字符串。

严格来说,我们请求的是类别,而不是相关的计数。在这里,ChatGPT 在查询结果中添加了计数,这可能是可以接受的,甚至是有趣的。一般来说,如果你使用 ChatGPT 将问题翻译成查询,确保它不会以不可接受的方式扩展你的查询(例如,如果输出被转发到期望特定输入格式的另一个工具)。让我们看看我们能否让 ChatGPT 更紧密地遵循我们的指示。

ITUser ****

查询结果应仅返回类别。

chatGptGPT ****

对于这个疏忽,我表示歉意。如果你想查询只返回具有最多评论的类别,你可以通过只选择“Category”列来修改之前的查询:

SELECT Category

FROM Reviews

GROUP BY Category

ORDER BY COUNT(*) DESC

LIMIT 1;

这个更新的查询将从“Reviews”表中检索具有最多评论的产品类别。它按类别分组评论,根据评论数量降序排序,然后使用 LIMIT 1 子句仅选择顶部结果。查询将返回具有最多评论的类别。

完美!ChatGPT 稍微修改了之前的查询,只返回类别而不返回相关计数。你可能注意到 ChatGPT 的回答包含了查询以及解释查询为何这样做的原因。这样的解释有助于验证 ChatGPT 生成的查询确实是正确的。接下来,让我们进一步深入,看看哪些评论者对他们的游戏产品表示满意。

IT用户***

游戏类别的正面评论比率是多少?

chatGptGPT ****

要计算从你的 SQLite 数据库表“Reviews”中游戏类别的正面评论比率,你可以使用以下 SQL 查询:

SELECT (CAST(SUM(Satisfied) AS REAL) / COUNT(*)) AS PositiveRatio

FROM Reviews

WHERE Category = 'gaming';

这个查询使用 WHERE 子句特别筛选了“游戏”类别的评论。然后,使用 SUM()函数计算“满意”列的总和,这代表了正面评论的数量。结果随后被除以使用 COUNT(*)函数获取的游戏类别中的评论总数。使用 CAST()函数确保除法操作具有小数精度。

查询将以小数形式返回游戏类别中正面评论(满意评论)的比率。

执行此查询,你应该发现大约 50%的评论是正面的(即,相应的评论者是满意的)。¹ 最后,让我们看看哪个产品类别最需要紧急改进。

IT用户 ****

哪个商品类别的正面评论比率最低?

chatGptGPT ****

要确定从你的 SQLite 数据库表“Reviews”中具有最低正面评论比率的商品类别,你可以使用以下 SQL 查询:

SELECT Category, (CAST(SUM(Satisfied) AS REAL) / COUNT(*)) AS PositiveRatio

FROM Reviews

GROUP BY Category

ORDER BY PositiveRatio ASC

LIMIT 1;

这个查询使用了 GROUP BY 子句,根据“类别”列对评论进行分组。然后,通过将“满意”值的总和除以该类别中评论的总数,计算每个类别的正面评论比率。结果根据 ORDER BY 子句按升序排列,基于正面比率。

1 子句确保只返回具有最低正面比率的类别。

查询将返回商品类别及其相应的正面评论比率(满意评论)。将展示具有最低正面比率的类别。

执行此查询应返回“手机”类别及其相关的正面评论比率。

在本节中,我们看到了使用 ChatGPT 分析数据的第二种方法:不是直接将 ChatGPT 应用于数据,而是将其用作翻译机制。在这种情况下,我们将关于数据的(用普通英语表述)问题翻译成我们可以执行的 SQL 查询,然后使用相应的工具(在本场景中为关系数据库管理系统)执行。在后面的章节中,我们也将用它来翻译成其他形式化语言。

我们手动输入问题,然后将我们的查询复制到不同的界面。显然,这种方法不是最方便的。在第五章中,我们将看到如何构建自动化所有这些步骤的框架。

摘要

  • 语言模型可以在自然语言指导下解决各种任务。

  • 尽管功能强大,语言模型可能会产生错误的输出。

  • 您可以通过网页浏览器中的网页界面使用 ChatGPT。

  • ChatGPT 允许用户与各种 GPT 模型版本进行交互。

  • 要使用 ChatGPT 处理文本,请输入文本和任务描述。

  • 给定分类类别,ChatGPT 可以对文本进行分类;给定相应的问题,它可以从文本中提取信息;并且可以根据用户指定生成文本摘要。

  • ChatGPT 不适用于直接处理大数据集。然而,它可以生成使用其他工具处理大数据的代码。要生成代码,请输入数据的描述、任务和输出格式。

2.6 参考文献

  1. Ji, Z., Lee, N., Frieske, R. 等. (2023). 自然语言生成中的幻觉调查。ACM 计算调查 55 (12), 1–38。

  2. Ramakrishnan, R. 和 Gehrke, J. (2002). 《数据库管理系统》(第 3 版)。麦格劳-希尔纽约。

¹鉴于我们的测试数据是以均匀随机分布生成的(即正面和负面评论的数量应大致相等),这一点并不令人惊讶。↩︎

第二部分:使用语言模型进行数据分析

对于小数据量的语言模型使用,一个网络界面就足够了。但如何将其扩展到大数据集呢?

在第三章中,我们探讨了 OpenAI 的 Python 库,这是一个强大的库,它使您能够直接从 Python 调用语言模型。我们讨论了最重要的库函数,包括可以用于微调语言模型行为的各种参数,以适应您的特定用例。

第四章讨论了几个使用 Python 库通过 OpenAI 的语言模型进行文本文档集合分析的小型项目。例如,我们的项目涵盖了将文本文档分类到预定义的几个类别之一,从文本中提取特定信息片段,以及将相似文本文档聚类在一起。

第五章展示了如何使用语言模型分析结构化数据:数据表和图数据。在这里,语言模型被用作接口,将关于数据的问题(以自然语言形式)转换为可以由专用分析工具处理的正式查询。例如,本章演示了如何使用语言模型构建将问题转换为 SQL 查询的界面,这些查询可以使用关系数据库管理系统进行处理。

第六章展示了语言模型如何分析图片甚至视频。示例项目包括使用语言模型以自然语言回答关于图片的任意问题,以及找到显示同一人的图片对。本章还展示了如何使用语言模型根据视频内容自动为视频添加字幕。

第七章专注于音频数据,涵盖了语音转录以及语音生成。例如,我们看到了如何使用 OpenAI 的模型构建一个语音查询界面,回答关于表格数据的语音问题。我们还构建了一个翻译工具,根据用户指定将一种语言的语音输入翻译成另一种语言的语音输出。

第三章:OpenAI Python 库

本章涵盖

  • 安装 OpenAI 库

  • 使用 Python 调用 GPT 模型

  • 配置参数

在上一章中,我们通过 OpenAI 的 Web 界面使用了 GPT 模型。如果我们只是尝试进行对话或对单个评论进行分类和总结,这效果很好。然而,想象一下尝试对数百条评论进行分类。在这种情况下,手动为每条评论使用 Web 界面会变得非常繁琐(至少可以说)。此外,也许我们还想将语言模型与其他工具结合使用。例如,我们可能想使用 GPT 模型将问题翻译成正式查询,然后在相应的工具中无缝执行这些查询(无需在不同界面之间手动复制查询)。在这些所有场景中,我们需要一个不同的接口。

在本章中,我们将讨论一个来自 OpenAI 的 Python 库,它允许您直接从 Python 调用 OpenAI 的语言模型。这使得您可以将对语言模型的调用作为代码中的子函数集成。我们将在这本书的大部分章节中使用这个库。因此,在继续阅读下一章之前至少浏览本章是有意义的。

虽然当前章节侧重于 OpenAI 的 Python 库,但其他语言模型提供者(包括 Anthropic、Cohere 和 Google)提供的库是相似的。

3.1 前提条件

首先,让我们确保我们有适合 OpenAI Python 库的正确环境。我们将使用 Python 编程语言,所以请确保 Python 已安装。要做到这一点,打开一个终端,并输入以下命令(此命令应该适用于 Linux、macOS 和 Windows 终端):

python --version

如果这个命令返回错误信息,尝试在命令中将python替换为python3,然后再次运行。如果您的系统上安装了 Python,您应该会看到一个版本号作为回复(例如,“Python 3.10.13”)。如果没有,您将得到一个错误信息。对于以下示例,您至少需要 Python 3.9(或更高版本)。如果您的系统上没有安装 Python,或者版本低于所需版本,请访问www.python.org,点击“下载”,并按照说明安装 Python。您可能还想安装一个集成开发环境(IDE)。PyDev(www.pydev.org)和 PyCharm(www.jetbrains.com/pycharm)是许多适用于 Python 的 IDE 中的两个。

除了 Python,您还需要 pip,这是一个用于安装 Python 包的包管理系统(OpenAI 库以这种包的形式提供)。对于最近的 Python 版本(您无论如何都需要),该程序默认已经安装。尽管如此,确保这一点也无妨:

pip --version

再次提醒,如果一切安装正确,您应该看到一个版本号。让我们确保 pip 是最新的。以下命令应该在 Linux、macOS 和 Windows 上都能工作:

python -m pip install --upgrade pip

就这样!你的系统已准备好安装 OpenAI Python 客户端。

如果它不起作用怎么办?

别慌!如果之前提到的任何步骤失败,你可能无法在你的本地机器上执行以下代码。然而,只要你有网络访问,你就可以使用云平台。例如,可访问colab.research.google.com的 Google Colab 平台,允许你创建可以执行所有以下代码样本的笔记本。图 3.1 显示了创建安装 OpenAI 库的单元格(上单元格)和相应的 Python 程序开始(下单元格)后的界面。我们将在以下章节中讨论库的安装和使用。

figure

图 3.1 可以使用 Google Colab 平台运行以下示例。

3.2 安装 OpenAI 的 Python 库

是时候像专业人士一样使用 GPT 了!尽管第二章讨论的 ChatGPT 网页界面对于对话和尝试新提示很有用,但它不适合实现复杂的数据处理管道。为此,OpenAI 的 Python 库是一个更好的选择,它允许你直接从 Python 调用语言模型。首先,让我们安装相应的库。在终端中输入以下命令:

pip install openai==1.29
我可以使用不同的库版本吗?

你可能已经注意到了对 OpenAI 库特定版本(版本 1.29)的引用。本书和后续章节中展示的代码已经与这个版本进行了测试。由于不同版本的库语法略有差异(除非你愿意修改代码),请安装这个精确版本。

每次我们使用 OpenAI 库时,都需要提供一个密钥,以便访问 OpenAI 模型(这是计费所必需的)。如果你还没有创建 OpenAI 账户,请访问platform.openai.com,点击“注册”,并按照指示操作。如果你已经有了账户但尚未登录,请提供你的账户凭证。确保在“计费”部分添加支付方式,并用几美元进行充值。之后,如果你还没有这样做,现在是时候生成你的密钥了。

访问platform.openai.com/account/api-keys。你应该能看到图 3.2 中显示的网站。

figure

图 3.2 管理访问 OpenAI API 的密钥

点击“创建新密钥”按钮。界面将显示代表密钥的文本字符串。务必复制并存储该密钥!关闭相应的窗口后,你将无法再次检索完整的密钥。

每次我们使用 Python 库时,都需要提供我们的密钥来将我们的请求链接到适当的账户。最简单的方法是将密钥存储在名为 OPENAI_API_KEY 的环境变量中。如果该变量存在,OpenAI 将自动从中提取密钥。设置环境变量的确切命令取决于操作系统。例如,以下命令适用于 Linux 和 macOS(用三个点代替您的密钥):

export OPENAI_API_KEY=...

或者,您可以通过在每个 Python 调用前加上相应的赋值来按调用设置密钥。例如,使用以下命令调用下一节中展示的代码列表,同时设置密钥(同样,用三个点代替您的密钥):

OPENAI_API_KEY=... python listing1.py

最后,如果其他选项都不起作用,您可以直接在 Python 代码中指定您的访问密钥。更确切地说,在导入 OpenAI 的 Python 库之后,我们可以在创建 client 对象时将 API 访问密钥作为参数传递(我们将在后面更详细地讨论这一点):

import openai
client = openai.OpenAI(api_key='...')

如前所述,将三个点替换为您的 OpenAI 访问密钥。以下代码示例假设访问密钥已指定在环境变量中,因此将省略此参数。如果环境变量对您不起作用,请通过传递访问密钥作为参数来更改代码列表。

警告:如果您的代码中包含您的 OpenAI 访问密钥,切勿共享代码。除此之外,拥有您的密钥将使其他人能够在您付费的情况下调用 OpenAI 的模型。

假设您以某种方式指定了您的访问密钥,我们现在就可以开始使用 OpenAI 的 Python 库调用 GPT 模型了。

3.3 列出可用模型

我们将使用 Python 库检索可用 OpenAI 模型的列表。列表 3.1 展示了相应的 Python 代码。(您可以从本书的配套网站上下载此代码和所有后续的代码列表。)首先,我们导入 OpenAI 库(1)。然后我们创建一个客户端对象,使我们能够访问库函数(2)。接下来,我们查询所有可用的 OpenAI 模型(3)并打印出结果(4)。

列表 3.1 可用的 OpenAI 模型
import openai  #1
client = openai.OpenAI()  #2

models = client.models.list()  #3

for model in models.data: #4
    print(model)

1 导入 OpenAI Python 库

2 创建 OpenAI 客户端

3 获取可用的 OpenAI 模型

4 打印出检索到的模型

您应该看到类似以下的结果:

Model(id='dall-e-3', created=1698785189, 
    object='model', owned_by='system')
Model(id='whisper-1', created=1677532384, 
    object='model', owned_by='openai-internal')
Model(id='GPT-4o-2024-05-13', created=1715368132, 
    object='model', owned_by='system')
Model(id='davinci-002', created=1692634301, 
    object='model', owned_by='system')
Model(id='GPT-4o', created=1715367049,  #1
    object='model', owned_by='system')
...
 #2
Model(id='curie:ft-personal-2022-01-10-16-52-53', 
    created=1641833573, object='model', owned_by='trummerlab')
Model(id='davinci:ft-personal-2022-01-13-19-59-51', 
    created=1642103991, object='model', owned_by='trummerlab')
Model(id='ft:gpt-3.5-turbo-0613:trummerlab::8qlJH6bV', 
    created=1707585607, object='model', owned_by='trummerlab')
...

1 GPT-4o

2 微调模型版本

每个模型都由一个 ID(例如,GPT-4o1))来描述。我们将使用这个 ID 来告诉 OpenAI 我们想要使用哪个模型来处理我们的请求。除了 ID 之外,每个模型还包含一个创建时间戳和关于模型所有权的详细信息(owned_by字段)。在大多数情况下,模型归 OpenAI 所有(例如,标记为systemopenai-internal)。然而,在某些情况下,模型归trummerlab2)所有,这是本书作者使用的账户名称。这些模型不是公开可访问的,而是属于拥有账户的私有。当你使用自己的账户执行代码时,你将看不到这些模型。它们是通过称为微调的过程从公开可用的基础模型创建的。

什么是微调?

默认情况下,像 GPT-4o 这样的语言模型被训练成具有多功能性,这意味着原则上它们可以执行任何任务。但有时我们并不需要一个多功能性的模型,而是一个在特定任务上表现非常出色的模型。微调使我们能够为关心的任务专门化一个模型。我们将在第九章中更详细地讨论微调。

3.4 聊天完成

本书中的几乎所有代码都使用了 OpenAI Python 库的相同功能:聊天完成。使用聊天完成,你的模型会根据提供的输入生成一个聊天完成。输入可以包含各种类型的数据,如文本和图像。我们将在接下来的章节中利用这些功能,但暂时只限于文本。聊天完成也用于 OpenAI 的 ChatGPT 网页界面的后台。给定聊天历史作为输入(包括最新的消息以及可能包含相关上下文的先前消息),模型会生成最合适的回复。

要使用 Python 中的聊天完成功能,我们首先需要一个格式来描述聊天历史。这是我们为聊天完成提供的输入的一部分。在 OpenAI 的 Python 库中,聊天被表示为一个消息列表。每个消息反过来又表示为一个 Python 字典。这个 Python 字典指定了消息的几个重要属性值。至少,我们需要为每个消息指定两个重要属性:

  • role属性,它指定了消息的来源

  • content属性,它指定了消息的内容

让我们先讨论一下role属性。正如你从上一章所知,与 GPT 模型的聊天是一系列来回的消息,交替使用用户和模型编写的信息。因此,我们可以为role属性指定user值来标识由用户编写的消息。或者,我们可以指定assistant值来标记由语言模型生成的消息。role属性的第三个可能值是system。这类消息通常用于聊天历史的开始部分。它们的目的是向模型传达通用的指南,而不依赖于用户提交的具体任务。例如,一个典型的系统消息可能是“你是一个有用的助手”,但更专业的版本(例如,“你是一个将数据集相关问题翻译成 SQL 查询的助手”)也是可能的。在本书中,我们不会使用system消息,但请随意实验并尝试添加你自己的系统消息,看看它们是否会影响模型输出。

content属性指定了消息的内容。在本章中,我们将限制自己只使用文本内容。在后面的章节中,我们将看到语言模型如何被用来处理更多样化的内容类型。在下面的代码示例中,我们只需要在我们的聊天历史中指定一条消息。这条消息包含描述语言模型应解决的问题的指令,以及相关的上下文信息。例如,以下聊天历史鼓励模型为我们生成故事:

[{
    'role':'user',  #1
    'content':'Tell me a story!'  #2
}]

1 用户消息

2 任务规范

消息列表中只包含一条消息。这条消息被标记为来自用户(1),并在其内容中描述了之前提到的任务(2)。作为回复,我们期望模型根据输入指令生成故事。

我们如何调用模型以完成聊天?这可以通过几行 Python 代码实现。首先,我们需要导入 OpenAI Python 库(1)并创建一个client对象(2):

import openai  #1
client = openai.OpenAI()  #2

1 导入 OpenAI Python 库

2 创建 OpenAI 客户端

我们将使用client对象进行所有后续的语言模型调用。之前的代码几乎出现在我们所有的代码示例中。请记住,在创建客户端时,你可能需要手动传递 OpenAI 访问密钥作为参数(除非你在环境变量中指定了访问密钥,这是推荐的方法)。创建client之后,我们可以像下面这样发出聊天完成请求:

result = client.chat.completions.create(
    model='GPT-4o',                  #1
    messages=[{                      #2
        'role':'user', 
        'content':'Tell me a story!'
        }])

1 选择模型

2 指定输入消息

我们使用client.chat.completions.create函数创建一个新的请求。model参数(1)指定了我们想要用于完成的模型名称。在这种情况下,我们选择了 OpenAI 的 GPT-4o 模型,该模型可以处理多模态数据。我们将在这个书的大部分代码示例中使用此模型。接下来,我们通过messages参数指定聊天历史作为输入(2)。这是之前讨论的聊天历史,指示模型生成一个故事。

让我们将所有内容整合起来。以下列表(可在本书配套网站上找到,作为第三章部分的列表 2)使用 GPT-4o 生成一个故事。

列表 3.2 使用 GPT-4o 进行聊天完成
import openai          #1
client = openai.OpenAI()  #2

result = client.chat.completions.create(  #3
    model='GPT-4o',                    #4
    messages=[{                        #5
        'role':'user', 
        'content':'Tell me a story!'
        }])
print(result)

1 导入 OpenAI Python 库

2 创建 OpenAI 客户端

3 调用聊天完成

4 选择模型/

5 指定输入消息/

运行代码应产生如下结果(由于随机化,您的故事可能有所不同):

ChatCompletion(
    id='chatcmpl-9YKmJCE8SITsKyI557T8KTuX3IxWN', 
    choices=[                                #1
        Choice(
            finish_reason='stop',  #2
            index=0, 
            logprobs=None, 
            message=ChatCompletionMessage(  #3
                content="Of course! Here's a story that ... ", 
                role='assistant', 
                function_call=None, 
                tool_calls=None)
            )
        ], 
        created=1717970051, 
        model='GPT-4o-2024-05-13', 
        object='chat.completion', 
        system_fingerprint='fp_319be4768e', 
        usage=CompletionUsage(          #4
            completion_tokens=810, 
            prompt_tokens=12, 
            total_tokens=822
        )
    )

1 完成列表

2 终止条件

3 完成消息

4 令牌使用

让我们讨论该结果的不同组成部分。首先,我们有一个完成选项列表(1)(Choice类型的对象)。在我们的情况下,该列表只包含一个条目。这是默认行为,尽管我们可以通过设置正确的配置参数来请求多个替代完成(下一节将讨论)。finish_reason标志(2)表示每个完成停止生成的理由。例如,这可能是由于生成的文本达到长度限制。stop值表示语言模型能够生成完整的输出(而不是达到长度限制)。实际消息(3)的内容被缩写,很可能会在多次调用代码时看到不同的故事。

除了完成本身之外,结果还包含元数据和用法统计信息(4)。更确切地说,我们发现以下属性有值:

  • completion_tokens—生成的令牌数量

  • prompt_tokens—输入中的令牌数量

  • total_tokens—读取和生成的令牌总数

我们为什么要关心令牌的数量?因为大多数 OpenAI 模型的定价与读取和生成的令牌数量成正比。例如,在撰写本文时,使用 GPT-4o 的成本是每百万个读取的令牌 5 美元,每百万个生成的令牌 15 美元。注意读取和生成令牌之间的定价差异。通常,如本例所示,生成令牌比读取令牌更昂贵。定价不仅取决于令牌数量,还取决于使用的模型。例如,用 GPT-3.5 Turbo 模型(一个稍微弱一点的 GPT 版本)替换 GPT-4o 可以将成本降低 10 倍。在用语言模型分析大量数据之前,请选择适合您任务和钱包的适当模型大小。

3.5 自定义模型行为

您可以使用各种参数来影响模型对您输入的回复方式。在调用 chat.completions.create 函数时,除了 modelmessages 参数外,还可以指定这些参数。在本节中,我们将讨论不同类别的参数,按它们影响的模型行为方面进行分类。

3.5.1 配置终止条件

当我们调用聊天完成模型时,它会生成输出,直到满足停止条件。接下来讨论的两个参数使我们能够配置何时停止文本生成。

max_tokens 参数指定在完成过程中生成的最大标记数(即语言模型表示文本的原子单位)。一个标记大约对应四个字符,一个典型的段落大约包含 100 个标记。此参数的最大可接受值由所使用的模型决定。例如,ada 是最小的 GPT 版本之一,允许最多 2,049 个标记,而 GPT-4o 支持多达 128,000 个标记。请记住,模型支持的标记数包括读取的标记和生成的标记。由于 max_tokens 仅指生成的标记数,因此您不应将其设置得高于所使用模型支持的标记数减去提示中的标记数。

作为一般规则,为 max_tokens 设置一个合理的值几乎总是好主意。毕竟,我们为每个生成的标记付费,对标记数设置限制可以使您对每次模型调用的货币费用进行限制。

在某些场景中,特定的文本模式表示所需输出的结束。例如,在生成代码时,可以是特定于相应编程语言的字符串,表示程序的结束。另一方面,在生成童话时,可以是字符串“从此他们幸福地生活在一起!”在这些场景中,我们可能希望使用 stop 参数来配置 OpenAI 库,以便在出现特定标记序列时停止生成输出。在某些情况下,只有一个标记序列表示终止。在这些场景中,我们可以直接将 stop 参数赋值为相应的字符串值。在其他场景中,存在多个候选序列表示终止。在这种情况下,我们可以将 stop 参数赋值为最多四个序列的列表。只要生成这些序列中的任何一个,文本生成就会终止。

注意,您可以使用之前提到的两个参数一起使用。在这种情况下,输出生成会在达到长度限制或出现任何一个停止序列时停止(哪个先发生就先停止)。

3.5.2 配置输出生成

我们刚才讨论的参数使您能够选择何时终止输出。但您如何影响直到那个点生成的输出呢?在这里,OpenAI 提供了一些参数,使您能够影响 GPT 模型选择输出文本的方式。

几个参数使您能够影响生成的输出应该有多“重复”。更精确地说,这些参数允许您影响是否重复生成相同的标记是否可取。

presence_penalty参数使您能够惩罚重复使用相同标记的聊天完成。存在惩罚是一个介于-2 和+2 之间的值(默认值为 0)。正惩罚鼓励模型避免重复使用相同的标记。另一方面,负惩罚鼓励模型重复使用相同的标记。绝对值越高,相应的影响越强。

frequency_penalty与先前的参数相关,但允许更精细的惩罚方案。presence_penalty参数基于标记的“存在”。例如,我们不区分出现两次的标记和出现数百次的标记。频率惩罚用作一个因子,在聚合标记的分数(用于确定标记是否应该出现)时乘以前出现次数。因此,一个标记被使用得越频繁,它再次出现的可能性就越小。与存在惩罚类似,frequency_penalty参数的值介于-2 和+2 之间,默认设置为 0。正惩罚因子鼓励 GPT 模型避免重复相同的标记,而负值鼓励重复。

有时候我们只对有限集合中的一个标记感兴趣。例如,在文本分类中,类别的集合通常是事先确定的。如果是这样,让我们告诉模型吧!logit_bias参数允许将标记 ID 映射到偏置因子。高偏置因子鼓励模型将相应的标记视为输出。足够低的偏置分数实际上阻止模型使用该标记。足够高的分数几乎可以保证相应的标记将出现在输出中。

使用logit_bias参数可以避免在我们可以缩小合理标记集合的情况下生成无用的输出。logit_bias的值是一个 Python 字典,将标记 ID 映射到-100 到+100 之间的值。介于-1 和+1 之间的值更为典型,同时仍然给模型留出考虑低值标记(或避免使用与高值相关的标记)的空间。但如何找到与相关单词相关的标记 ID 呢?为此,我们可以使用 GPT 标记化工具,该工具可在platform.openai.com/tokenizer?view=bpe找到。只需输入你想要鼓励(或禁止)的单词,相关的标记 ID 就会显示出来。请注意,有多种标记化变体可用,与不同的模型相关联。选择适合你模型的标记化工具(因为否则,标记 ID 可能是不正确的)。

3.5.3 配置随机化

GPT 模型是如何选择下一个输出标记的?在高度抽象的层面上,我们计算所有可能的输出标记的分数,然后根据这些分数选择一个标记。尽管得分较高的标记通常有更好的被选中的机会,但我们可能并不总是想选择得分最高的标记。例如,回想一下第二章,我们能够为相同的输入生成回复,可能得到不同的结果。这可能是有用的,如果第一个输出并不完全满足我们的要求。如果我们总是选择得分最高的标记,那么重新生成答案不太可能改变输出。因此,为了使用户能够获得多样化的回复,我们需要在将分数映射到输出标记时引入一定程度的随机化。

当然,过度解耦输出与标记分数——即使用过多的随机化——可能会导致无用的输出(在极端情况下,输出不再与输入相关联,也不遵循我们的指令)。另一方面,使用过少的随机化可能会导致输出的多样性低于预期。为特定场景选择合适的随机化程度可能需要一些实验。在每种情况下,OpenAI 都提供了多个参数,允许你微调标记分数如何转换为输出标记。我们将在下一节讨论这些参数。

调整随机化最常用的参数之一是温度参数。温度越高,随机化程度越高;而温度越低,随机化程度则越低。低随机化程度意味着得分最高的标记很可能被选中。非常高的随机化程度意味着标记几乎以相等的概率被选中,与模型分配的分数无关。温度参数允许你在这两种极端之间找到平衡。此参数的值在 0 到 2 之间选择,默认值为 1。

温度是选择随机化程度的一个可能性。top_p 参数是另一种方法。(不建议你在语言模型的同一调用中同时更改 temperaturetop_p。)根据它们的分数,我们可以将每个可能的输出标记与“正确”的概率关联起来。现在想象一下,我们正在按概率递减的顺序对这些标记进行排序。我们可以通过只关注前几个标记来减少随机化的程度:我们忽略概率较低的标记。我们应该考虑多少个标记?而不是直接固定合格标记的数量,top_p 参数固定了这些标记的 概率质量。换句话说,我们按概率递减的顺序将标记添加到合格标记的集合中。每当所有选中标记的概率值之和(概率质量)超过 top_p 的值时,我们就停止添加标记。最后,我们从合格标记中挑选下一个输出标记。

由于 top_p 参数表示一个概率,其值取自 0 和 1 之间的区间。与温度类似,选择更高的值会导致更多的随机化(因为即使概率较低的标记也变得合格)。

一旦我们使用了一定程度的随机化,生成相同输入提示的多个答案就变得有用。之后,我们可以通过后处理选择首选答案。例如,假设我们正在为相同的输入提示生成多个 SQL 查询。为了选择首选答案,我们可以在目标数据库上尝试执行它们,并丢弃导致语法错误消息的查询。当然,我们可以简单地重复使用相同的提示调用语言模型。然而,一次调用语言模型并配置生成的回复数量更有效率。参数 n 确定了生成的回复数量。默认情况下,此参数设置为 1(即只生成一个答案)。你可以选择更高的值以获得更多回复。请注意,使用此参数的更高值也会增加每次调用的成本(因为你为每个生成的标记付费,包括不同回复中的标记)。

3.5.4 自定义示例

让我们在代码中尝试一些参数。以下列表提示 GPT-4o 编写一个故事,这次使用我们讨论的一些参数来自定义聊天完成。

列表 3.3 使用 GPT-4o 进行带有自定义参数设置的聊天完成
import openai
client = openai.OpenAI()

result = client.chat.completions.create(
    model='GPT-4o',
    messages=[{
        'role':'user', 
        'content':'Tell me a story!'
        }],
    max_tokens=512,        #1
    stop='happily ever after',  #2
    temperature=1.5,       #3
    presence_penalty=0.5,  #4
    logit_bias={14844:-100})         #5
print(result.choices[0].message.content)

1 限制输出长度

2 设置停止条件

3 设置温度

4 对重复进行惩罚

5 添加偏差

首先,为了避免得到冗长的故事,我们将最大标记数设置为 512(1)。这应该足以容纳几段文字。为了避免生成不必要的多余内容,我们还定义了一个停止序列:“幸福地生活下去”(2)。我们这样做,希望任何合理的故事当然都会以流行的表达“他们幸福地生活下去”结束。这个假设无疑是简化的。

接下来,我们将温度设置为 1.5(3),允许一定程度的随机化。一个过于重复的故事是无聊的。因此,我们设置了一个正存在惩罚(presence_penalty=0.5)(4),以阻止 GPT-4o 重复使用相同的标记。最后,我们不想得到一个恐怖的故事(也许我们想给孩子读),所以我们惩罚了单词(我们获得相应的标记 ID,如第 3.5.2 节所述)(5)。

让我们看看我们得到了什么!例如,在一个样本调用中,GPT-4o 生成了以下故事:

chatGptGPT ****

从前,在一个坐落在起伏的山丘和茂密森林之间的古老村庄里,住着一个名叫艾拉的女孩。她以好奇心和冒险精神著称,这些特质常常使她探索村庄未知的角落。在一个清爽的秋晨,艾拉决定冒险进入 Whispering Woods,一个充满神秘和传说的地方。村民们经常谈论这片森林,说那里有魔法徘徊,但也没有人敢越过边缘进入。带着她信任的背包和一颗充满激情的心,艾拉在日出时分出发了。当她深入森林时,树叶沙沙作响的窃窃私语似乎在欢迎她,她感到一种奇怪的熟悉感。她穿过灌木丛,爬过倒下的树木,直到她偶然发现一个隐藏的树林,沐浴在闪烁的光芒中。树林中央有一棵古老的树,树叶银白,树干扭曲如螺旋状的烟雾。艾拉被迷住了;它看起来是她所见过的任何树都不一样。她小心翼翼地走近,注意到树根中有一个小巧精致的箱子。她的心跳加速,艾拉打开了箱子,里面发现了一个精致的金色护身符。一触碰到它,森林似乎就活了过来。窃窃私语变得更响亮,形成了她能理解的话语。“勇敢的心,你找到了永恒之锁,”声音低语。“它赋予持有者一个愿望。”艾拉的心中充满了可能性,但她知道她心中最渴望的是什么:为她深爱的村庄带来欢乐和繁荣。紧握着护身符,她低声许下了她的愿望。瞬间,一股温暖的亮光包围了她,她感到一股能量的涌动。树林闪烁了一下,然后又恢复了宁静。艾拉知道发生了非凡的事情。当她回家时,她注意到一些微妙的变化——庄稼看起来更加生机勃勃,动物看起来更健康,村庄的空气中充满了满足感。村民们欢迎她回来,他们的脸上洋溢着幸福。艾拉从未向任何人透露 Whispering Woods 或永恒之锁的秘密。每当有人评论村庄的新兴繁荣时,她只是微笑。她知道,有时候,最大的魔法不在于所见,而在于跟随自己内心的勇气。因此,艾拉的村庄繁荣起来,成为欢乐和和谐的灯塔。这一切都因为一个勇敢的女孩敢于倾听森林的低语。她就这样生活着

幸福地生活下去!结果证明,我们的停止序列,“幸福地生活下去”的表达确实用于故事的结尾(因此,GPT-4o 返回的输出中省略了它)。尝试更多的参数设置,看看结果如何随着配置的变化而变化。

3.5.5 进一步的参数

我们已经讨论了用于数据分析目的最重要的参数。您可以在请求 OpenAI 的 GPT 模型完成内容时使用它们。请注意,除了本章中提到的参数之外,还有更多参数。OpenAI 的 API 参考文档(platform.openai.com/docs/api-reference/completions)详细描述了所有参数。

摘要

  • 您可以通过 Python API 使用 OpenAI 的语言模型。其他提供商提供类似的库来访问他们的模型。

  • 要使用 OpenAI 的库,创建一个客户端对象。

  • 您可以使用 OpenAI 的模型来完成聊天。要完成的聊天指定为消息列表。

  • 每条聊天消息都由内容和角色特征化。角色可以是userassistantsystem之一。

  • 通过chat.completions.create函数获取聊天完成内容。

  • 您可以使用各种参数配置模型:

    max_tokens参数限制了生成的标记数量。

    stop允许您定义停止文本生成的短语。

    您可以通过logit_bias对特定的标记进行惩罚或鼓励。

    presence_penalty对重复的输出进行惩罚。

    frequency_penalty对重复的输出进行惩罚。

    temperature参数选择随机化的程度。

    top_p确定考虑的输出标记数量。

    n参数选择生成的完成内容的数量。

第四章:分析文本数据

本章涵盖

  • 文本分类

  • 信息提取

  • 文档聚类

文本数据无处不在,并包含有价值的信息。例如,想想报纸文章、电子邮件、评论,或者可能是您正在阅读的这本书!然而,直到几年前,通过计算手段分析文本仍然很困难。毕竟,与 Python 这样的形式化语言不同,自然语言并非旨在让计算机易于解析。最新一代的语言模型使许多流行任务的文本分析和生成几乎达到人类水平。在某些情况下,语言模型在文本分析和生成方面的性能甚至平均上超过了人类的能力 [1]。

在本章中,我们将看到如何使用大型语言模型来分析文本。在某种程度上,分析文本数据是语言模型的一个非常“自然”的应用。它们已经在大量文本上进行了训练,可以直接应用于文本分析(即,无需参考外部工具进行实际数据分析)。本章涵盖了文本分析的几种流行类型:对文本文档进行分类、从文本中提取表格数据以及将文本文档聚类成语义相似的文档组。对于这些用例中的每一个,我们将看到示例代码并讨论变体和扩展。

分类、信息提取和聚类是三种重要的文本分析类型,但绝不是您在实践中所需要的唯一类型。然而,通过本章的示例,您将能够根据语言模型创建自定义的文本数据处理管道。

4.1 前言

确保您的系统已正确设置以用于示例项目。以下示例使用 OpenAI 的 GPT 模型系列,通过 OpenAI 的 Python 库访问。该库在第三章中已详细讨论。请确保遵循第三章中的说明,以便能够执行示例代码。

警告 OpenAI 的 Python 库正在快速变化。本章中的代码已使用 OpenAI Python 库的 1.29 版本进行测试,但可能不适用于不同版本。

除了 OpenAI 库之外,我们还将使用流行的pandas库。pandas是一个用于处理表格数据(我们将用作输入和输出格式)的流行库。我们只将从该库中使用基本功能,并在代码中相应地解释命令。请确保已安装pandas(例如,在 Python 解释器中尝试import pandas);如果没有安装,请在终端中输入以下命令进行安装:

pip install pandas==2.2

最后,在本章的最后部分,您将需要scikit-learn库中的聚类算法。在终端中运行以下命令以安装适当的版本:

pip install scikit-learn==1.3

以下章节包含使用语言模型进行文本分析的三个迷你项目的代码。无需输入代码——你可以在本书的配套网站上找到所有代码,在本书的配套网站上找到本章的资源部分。虽然你可以在自己的数据上执行代码,但本书附带了一些我们在示例中使用的样本数据集(也在配套网站上)。现在,是时候使用语言模型进行文本分类了!

4.2 分类

所以,你现在正在计划你的周六晚上,犹豫是否去看你最喜欢的电影系列的最新作品。但是,这值得吗?你的社交媒体动态不断被你的朋友(以及你朋友的朋友)的评论填满,他们在分享他们的观影体验。你可以手动浏览这些评论,逐个阅读以更好地了解大家对电影的普遍看法是正面还是负面。但是,谁有那么多时间来做这件事呢?语言模型不能帮助我们自动化这项任务吗?

当然可以。这里是我们最经典的文本处理问题之一的一个实例:我们有一个文本,并希望将其分类,映射到一组固定的类别中。在这种情况下,要分类的文本是电影评论。我们希望将其分类为正面(即,作者认为这是一部伟大的电影,你应该去看!)或负面(省下你的钱!)。这意味着我们有两个类别。表 4.1 展示了一些示例评论及其相关的类别标签。赞扬电影“再现得很好”的评论显然是正面的,而描述电影为“显然薄弱、廉价”的评论是负面的。你可以在本书的配套网站上找到这些评论和一些其他评论。

表 4.1 电影评论及其相关的类别标签
评论 类别
首先,这部电影在艺术上非常真实地再现了现实。 ... 正面
重命名为“Gangs, Inc.”,这是一部显然薄弱、廉价的黑帮情节剧。 ... 负面

将电影评论进行分类只是文本分类的众多用例之一。例如,想象一下尝试整理你的电子邮件收件箱。自动根据内容(例如,使用自定义类别,如工作、爱好、育儿等)对电子邮件进行分类,这不是很好吗?这又是文本分类的一个例子,这次有超过两个类别。作为最后一个例子,想象一下你正在创建一个允许用户留下自由文本评论的网站。当然,你不想展示可能冒犯性的评论,并希望自动过滤它们。这意味着你正在将文本评论分类为两个类别之一(冒犯性和非冒犯性)。现在,我们将看到语言模型如何轻松地应用于每个场景。

4.2.1 概述

我们将专注于将电影评论(或实际上,任何类型的评论)分类为正面评论(好电影!)和负面评论(待在家里!)。为此,我们将使用 OpenAI 的语言模型。我们假设我们已经收集了要分类的评论,并将它们存储在磁盘上的一个文件中。我们开发的代码将遍历所有评论,使用语言模型对每个评论进行分类,并返回每个评论的分类结果。

但我们如何对评论进行分类呢?我们将使用第三章中介绍的 OpenAI 的 Python 库。对于要分类的每个评论,我们首先生成一个提示。该提示描述了一个任务给语言模型。在我们的情况下,该任务将评论分配到我们的两个类别之一(正面或负面)。例如,考虑以下提示作为示例:

This movie is a piece of reality very well realized ...  #1
Is the sentiment positive or negative?              #2
Answer ("Positive"/"Negative"):   #3

1 评论

2 问题

3 输出格式

此提示包含要分类的评论(1),一个描述分类任务的提问(2),以及一个描述所需输出格式的最终陈述(3)。我们将为每个评论构建此类提示,将其发送到语言模型,并(希望)得到两个可能答案之一(正面或负面)。图 4.1 展示了每个评论的高级分类过程。

figure

图 4.1 对于每个评论,我们生成一个包含评论以及描述分类任务的指令的提示。给定提示作为输入,语言模型输出评论的类别标签。

4.2.2 创建提示

给定一个评论,我们生成一个提示,指示语言模型对其进行分类。我们为分类生成的所有提示都遵循相同的提示模板。

提醒:什么是提示模板?

我们在第一章中简要提到了提示模板。提示模板是一种包含占位符的文本。通过用实际文本替换这些占位符,我们可以获得可以发送到语言模型的提示。我们还称,如果提示可以通过替换模板的占位符来获得,则提示“实例化”了提示模板。

上一节中的示例提示实例化了以下提示模板:

[Review]                           #1
Is the sentiment positive or negative?  #2
Answer ("Positive"/"Negative"):   #3

1 评论(占位符)

2 问题

3 输出格式

我们的模板只包含一个占位符:要分类的评论文本(1)。对于每条评论,我们将用实际的评论文本替换此占位符。我们还指导语言模型如何处理评论文本(2)(检查其潜在的情感是正面还是负面)并定义输出格式(3)。后一步很重要,因为可能有多种方式来表达潜在的情感:例如,“P”代表正面,“N”代表负面,或者更长的回答,如“评论是正面的”。如果我们没有明确告诉语言模型使用特定的输出格式,它可能会选择这些可能性中的任何一个!在我们的场景中,我们最终想要汇总分类结果以了解大多数人的观点(大多数人喜欢这部电影吗?),如果所有分类都遵循相同的输出格式,汇总每条评论的结果会变得简单得多。

以下函数遵循模板为给定的评论(指定为输入参数 text)生成提示:

def create_prompt(text):
    task = 'Is the sentiment positive or negative?'
    answer_format = 'Answer ("Positive"/"Negative")'
    return f'{text}\n{task}\n{answer_format}:'

函数的结果是提示,为输入评论实例化模板。

4.2.3 调用模型

接下来,我们将生成的提示发送给语言模型以获得解决方案。更确切地说,我们正在使用 OpenAI 的 GPT-4o 模型,这是撰写本文时的 OpenAI 最新模型。由于这是 OpenAI 的聊天模型之一,针对与用户的多次交互进行了优化,我们使用聊天完成端点与模型进行通信。如第三章所述,此端点期望输入先前的消息历史(除特定模型名称外)。在这里,我们只有一个先前的“消息”:提示。我们将其分类为 user 消息,鼓励模型解决消息中描述的任何任务。例如,我们可以使用以下代码片段向语言模型发送提示并收集答案(假设 prompt 包含之前生成的提示文本):

import openai
client = openai.OpenAI()

response = client.chat.completions.create(
    model='gpt-4o',
    messages=[
        {'role':'user', 'content':prompt}
        ]
    )

然而,直接使用此代码存在问题。OpenAI 的 GPT 模型托管在网络上,并通过远程访问。这为尝试连接到相应端点失败创造了机会:例如,由于暂时性的连接丢失。因此,在调用模型时允许进行几次重试是一种良好的实践。特别是,当处理需要多次连续调用 OpenAI 模型的大数据集时,至少有一次调用失败的可能性增加。与其通过异常中断计算,不如在开始另一次尝试之前等待几秒钟。以下是之前代码的完整版本——一个带有自动重试功能的调用语言模型的函数:

import openai
client = openai.OpenAI()

def call_llm(prompt):
    for nr_retries in range(1, 4):
        try:
            response = client.chat.completions.create(
                model='gpt-4o',
                messages=[
                    {'role':'user', 'content':prompt}
                    ]
                )
            return response.choices[0].message.content
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

call_lm 函数允许最多三次重试,它们之间的延迟逐渐增加。这种延迟通过在遇到异常(例如,表示暂时性连接丢失)时调用 time.sleep 函数(使用 Python 的 time 库)来实现。三次重试后,函数因异常失败(悲观地假设阻止我们联系 OpenAI 的任何问题都不会很快解决)。每次调用成功时,函数返回相应的结果。

4.2.4 端到端分类代码

是时候将所有内容整合在一起了!下面的列表显示了与我们所讨论的分类过程相匹配的代码。它还包含了生成提示(2)和调用语言模型(3)的函数。

列表 4.1 通过情感(正面、负面)对输入文本进行分类
import argparse  #1
import openai
import pandas as pd
import time

client = openai.OpenAI()

def create_prompt(text):                      #2
    """ Generates prompt for sentiment classification.

    Args:
        text: classify this text.

    Returns:
        input for LLM.
    """
    task = 'Is the sentiment positive or negative?'
    answer_format = 'Answer ("Positive"/"Negative")'
    return f'{text}\n{task}\n{answer_format}:'

def call_llm(prompt):                           #3
    """ Query large language model and return answer.

    Args:
        prompt: input prompt for language model.

    Returns:
        Answer by language model.
    """
    for nr_retries in range(1, 4):
        try:
            response = client.chat.completions.create(
                model='gpt-4o',
                messages=[
                    {'role':'user', 'content':prompt}
                    ]
                )
            return response.choices[0].message.content
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

def classify(text):       #4
    """ Classify input text.

    Args:
        text: assign this text to a class label.

    Returns:
        name of class.
    """
    prompt = create_prompt(text)
    label = call_llm(prompt)
    return label

if __name__ == '__main__':  #5

    parser = argparse.ArgumentParser()       #6
    parser.add_argument('file_path', type=str, help='Path to input file')
    args = parser.parse_args()

    df = pd.read_csv(args.file_path)     #7
    df['class'] = df['text'].apply(classify)  #8
    statistics = df['class'].value_counts()   #9
    print(statistics)
    df.to_csv('result.csv')

1 导入库

2 生成分类提示

3 调用大型语言模型

4 对一个文本文档进行分类

5 读取文本,分类,并写入结果

6 定义命令行参数

7 读取输入

8 对文本进行分类

9 生成输出

首先,让我们讨论列表 4.1 中使用的库(1)。我们将为以下项目重用这些库,因此有必要更仔细地查看它们(以及为什么在这里需要它们)。我们希望从命令行开始我们的代码,指定相关参数(例如,输入数据的路径)作为参数。argparse 库提供了用于指定和读取此类命令行参数的有用函数。接下来,我们需要在第三章中讨论的 openai 库,从 Python 调用 OpenAI 的语言模型。pandas 库支持表格数据的标准操作。当然,表格数据不是本章的重点。然而,我们将以表格中的行存储文本文档和相关元数据,因此 pandas 库很有用。最后,如前所述,我们使用 time 库在调用语言模型时实现延迟重试。

4.2.5 文档分类

单个文本文档的分类(4)结合了之前讨论的两个函数。给定一个要分类的输入文本,代码首先创建相应的提示(调用create_prompt),然后通过调用语言模型(调用call_llm)生成合适的回复。结果假定是类别标签,并将其返回给用户。

现在我们将其整合(5)。这部分代码在从命令行调用 Python 模块时执行,并使用我们已介绍的功能。初始的 if 条件(5)确保只有直接调用模块(而不是从不同的模块导入)时才执行以下代码。

首先(6),我们定义命令行参数。这里我们只需要一个参数:包含要分类的数据的 .csv 文件的路径。我们假设每一行包含一个文本文档,要分类的文本包含在 text 列中。我们解析命令行参数并将它们的值存储在 args 变量中。

接下来,我们从磁盘加载我们的输入数据(7)。我们假设数据存储为 .csv 文件(逗号分隔值):即包含列名的标题行,后面是包含数据的行(字段由逗号分隔,正如其名称所暗示)。在这里,pandas 库派上了用场,使我们能够使用单个命令加载此类数据。然后 df 变量包含一个包含输入文件数据的 pandas DataFrame。我们检索 DataFrame 的 text 列(8)并应用先前定义的 classify 函数到每一行(使用 pandasapply 方法)。最后(9),我们生成并打印出汇总统计信息(模型生成的每个答案的出现次数)并将结果分类写入文件(result.csv)。

4.2.6 运行代码

在本书的配套网站上,下载文件 reviews.csv。此文件包含少量可用于分类的电影评论。文件包含两列:评论文本和相关的情感(neg 表示负面情感,pos 表示正面情感)。当然,我们的目标是自动检测这样的情感。然而,拥有真实标签也使我们能够评估分类的质量。

您可以按照以下描述测试分类代码(以下命令已在 Linux 操作系统上测试过)。使用终端,切换到包含代码(listing1.py)的 Python 模块所在的目录(4.1)。然后,运行以下命令(如果需要,将 python 替换为您的 Python 解释器名称,例如 python3):

python listing1.py reviews.csv

在这里,我们假设输入文件(reviews.csv)存储在与代码相同的存储库中(否则,您必须用相应的路径替换文件名)。通常,代码的执行不应超过几秒钟(如果您的连接不稳定,可能需要重试,时间会稍长)。如果执行成功,您将看到的唯一输出将总结为每个可能的两个类别分配的标签数量。

执行代码后,您将在同一存储库中找到一个 result.csv 文件。除了输入文件的列之外,结果文件还包含一个新的 class 列。此列包含分类结果(正面和负面)。将我们的分类器分配的标签与真实情感进行比较。您会发现大多数情况下分类是一致的。对于几行 Python 代码来说,这还不错,对吧?

4.2.7 尝试变体

在这一点上,尝试更多地对代码和数据进行操作,以更好地了解其工作原理是个不错的主意。例如,尝试自己写几篇电影评论!哪些评论的分类是可靠的,哪些又具有挑战性?还要尝试几种提示的变体。哪些指令能提高准确性,哪些又会降低性能?仅举一个例子,尝试移除提示中定义输出格式的部分(即“答案(正面/负面)”这一行)。现在尝试使用更改后的提示运行程序。会发生什么?很可能会在你的分类结果(程序输出)中看到超过两个标签,包括例如缩写(例如,“P”和“N”)以及过于详细的答案(例如,在测试期间,GPT-4o 生成了“这篇评论的情感是积极的。”这样的回复)。在第九章中,我们评估了不同提示对模型输出质量的影响。

你可能还想改变用于提取的模型。使用较小版本的小型模型,如 GPT-3.5(每处理一个标记的成本显著更低)怎么样?还有模型配置呢?列表 4.1 只使用了两个参数(模型名称和消息历史),这两个都是必需的。然而,在第三章中,我们看到了可以应用在这里的各种配置参数。例如,尝试更改temperature参数(例如,将temperature设置为 0 将给出更确定的结果),或者限制所需输出的长度!在罕见的情况下,GPT 模型可能会生成比所需分类结果(由单个标记组成)更长的输出文本。你可以通过使用max_tokens参数限制输出长度来避免这种情况。同时,除了通过提示中的指令仅限制输出格式外,你还可以使用logit_bias参数增加两种可能结果(正面和负面)的可能性。我们将在第九章中进一步讨论模型调整。

作为另一种变体,尝试改变分类任务!例如,使用不同的一组类别进行分类相对容易。只需更改提示中的指令(如前所述,概述所有答案选项)。通过更改几行代码,你甚至可以获得一个通用的分类工具,允许用户通过额外的命令行参数指定分类任务和相应的类别。例如,除了电影评论之外,你可以使用这个工具将报纸文章分类到几个主题类别之一,或者将电子邮件分类为紧急或非紧急。到目前为止,你或许已经相信语言模型能够以相对较高的质量和适度的实现开销进行文本分类。是时候扩大我们的范围,探索不同的任务了!

4.3 文本提取

想象一下,鉴于你在使用语言模型进行数据分析方面的专业知识,你最近在 Banana(一家生产各种消费电子产品的流行公司)获得了一份备受追捧的工作。当你坐在新办公室的办公桌前,关于暑期实习的邮件开始源源不断地涌入。拥有一个暑期实习生当然很好,但你该如何选择最佳人选呢?理想情况下,你希望编制一个表格,比较所有申请者的 GPA、学位、他们最近实习的公司名称(如果有)等等。但是手动浏览邮件来编制这个表格似乎很繁琐。难道不能自动化吗?

当然可以。让我们使用语言模型来分析邮件,提取所有相关因素以选择我们的幸运暑期实习生。这里我们又遇到了一个标准的文本分析问题:信息提取!在信息提取中,我们通常从文本中提取结构化信息(例如,数据表)。在这里,我们将申请者的邮件视为文本文档。对于每封邮件,我们希望提取一系列属性:例如,姓名、GPA 和(当前或最近的)学位。例如,考虑以下来自一位有希望的申请者的邮件摘录:

Hi!
My name is Martin, I would love to do a summer internship at Banana! 
A bit about myself: I am currently working on a Bachelor of Computer Science
at Stanford University, my current GPA is 4.0.

考虑前面提到的三个属性,我们可以提取申请者的姓名(“Martin”)、他的 GPA(“4.0”)以及他的学位(“计算机科学学士”)。如果分析来自多个申请者的邮件,我们可以将结果表示为数据表,如表 4.2 所示。在下一节中,我们将讨论如何使用语言模型实现信息提取。

表 4.2 暑期实习生申请者提取信息
姓名 GPA 学位
Martin 4.0 计算机科学学士
Alice 4.0 软件工程硕士
Bob 3.7 设计学士

4.3.1 概述

同样,我们假设我们的邮件存储在磁盘上(在一个表格数据文件中,其中每一行包含一封邮件)。我们将遍历邮件并使用语言模型提取所有相关属性。我们不会硬编码相关属性,而是允许用户在命令行上指定这些属性(这样,如果您的暑期实习标准发生变化,您可以轻松地重用代码)。由于我们使用语言模型进行文本分析(它们擅长解释自然语言),因此无需用任何形式的正式语言指定属性。只需指定属性名称(或,可选地,用自然语言提供一个简短的描述),语言模型就应该能够确定要提取的内容。我们代码的输出将是一个表格数据文件(.csv 格式),其内容类似于表 4.2:输出表为每个提取的属性有一个列,为每封分析的邮件有一个行。

那么,我们如何从给定的电子邮件中提取属性呢?再次,我们希望生成一个描述提取任务的提示,以便向语言模型说明。例如,以下提示可以帮助我们从之前的电子邮件中提取所有相关属性:

 #1
Extract the following properties into a table:
name,GPA,Degree
 #2
Text source: My name is Martin, I would love to do a summer 
internship at Banana! A bit about myself: I am currently 
working on a Bachelor of Computer Science at Stanford 
University, my current GPA is 4.0.
 #3
Mark the beginning of the table with <BeginTable> and the end with <EndTable>. Separate rows by newline symbols and separate fields by pipe
symbols (|). Omit the table header and insert values in the attribute 
order from above. Use the placeholder <NA> if the value for an attribute 
is not available.

1 任务描述

2 要分析的文字

3 输出格式

提示由三部分组成:任务描述,包括要提取的属性的指定(1);提取的源文本(2);以及期望的输出格式,包括如果源文本不包含关于特定属性的任何信息时使用的值(3)。将此提示发送到语言模型应产生包含所需提取结果的文本。

语言模型的输出首先是一个文本字符串。最终,我们希望输出一个结构化数据表。这意味着我们仍然需要进行一些后处理,以从输出文本中提取所有相关属性(姓名、GPA 和学位)的值。图 4.2 展示了提取过程步骤(针对单个文本文档)。

图

图 4.2 对于每封电子邮件,我们生成一个包含电子邮件和提取任务描述的提示。此描述引用了用户指定的要提取的属性。给定提示作为输入,语言模型生成包含提取属性值的答案文本。通过后处理,我们从原始答案文本中提取这些值。

4.3.2 生成提示

我们希望生成以下提示模板的实例:

 #1
Extract the following properties into a table:
[List of attributes] 
 #2
Text source: [Email] 
 #3
Mark the beginning of the table with <BeginTable> and the end with <EndTable>.
Separate rows by newline symbols and separate fields by pipe symbols (|).
Omit the table header and insert values in the attribute order from above.
Use the placeholder <NA> if the value for an attribute is not available.

1 任务描述

2 要分析的文字

3 输出格式

提示模板包含任务描述(1)、提取的源文本(2)以及输出格式的指定(3)。请注意,此提示现在包含两个占位符(我们之前章节中使用的模板只有一个占位符):要提取的属性列表和提取的源文本。

我们将使用以下代码生成提示:

def create_prompt(text, attributes):
    parts = []
     #1
    parts += ['Extract the following properties into a table:']
    parts += [','.join(attributes)]

    parts += [f'Text source: {text}']  #2
         #3
    parts += [
        ('Mark the beginning of the table with <BeginTable> '
        'and the end with <EndTable>.')]
    parts += [
        ('Separate rows by newline symbols and separate '
        'fields by pipe symbols (|).')]
    parts += [
        ('Omit the table header and insert values in '
        'the attribute order from above.')]
    parts += [
        ('Use the placeholder <NA> if the value '
        'for an attribute is not available.')]
    return '\n'.join(parts)

1 生成任务描述

2 添加源文本

3 添加输出格式的描述

此函数接受要分析的文字(我们当然希望将其包含在提示中)以及我们想要提取的属性列表作为输入。在生成任务描述(1),包括要提取的属性列表后,该函数添加源文本(2),以及期望的输出格式的指定(3)。提示将这些部分连接起来。

4.3.3 后处理

与之前的项目(文本分类)相比,我们的提示词已经改变以适应新的任务(文本提取)。即使提示词不同,我们仍然可以像上一节那样重用相同的函数来从语言模型获取答案。另一方面,我们需要比以前做更多的工作来处理原始答案。对于分类,我们直接使用语言模型的回复作为最终结果。在我们当前的场景(文本提取)中,我们通常希望从单个输入文本中提取多个属性值。由于语言模型的输出文本包含所有提取的属性值,我们需要从原始答案文本中提取特定属性值。

例如,我们可能会从语言模型接收到以下原始答案文本:

| Martin | 4.0 | Bachelor of Computer Science |

要提取每个属性值,我们可以使用管道符号作为字段分隔符来分割原始文本(同时移除答案中的第一个和最后一个管道符号)。理想情况下,我们希望将我们的范围扩展到我们目前考虑的具体用例之外(例如从电子邮件中提取申请人的信息)。在某些情况下,我们可能需要从同一文本中提取多行(想象一下多个申请人一起提交一个群发电子邮件的场景——但这是一个不太可能的情况)。为了支持这样的用例,我们可能还需要将原始答案分割成与不同行相关的文本。为此,我们可以使用换行符作为行分隔符(因为行是通过换行符分割的)。

我们可以使用以下函数来完成所有这些操作:

import re

def post_process(raw_answer):
    table_text = re.findall(      #1
        '<BeginTable>(.*)<EndTable>', 
        raw_answer, re.DOTALL)[0]

    results = []
    for raw_row in table_text.split('\n'):  #2
        if raw_row:                    #3
            row = raw_row.split('|')
            row = [field.strip() for field in row]
            row = [field for field in row if field]
            results.append(row)
    return results

1 提取表格数据

2 按行分割

3 按字段分割

此函数的输入是语言模型为单个文本文档生成的原始文本。输出是一个行列表(其中每个结果行再次表示为一个列表)。要从输入到输出,我们首先需要提取包含实际表格数据的原始答案部分(1)。GPT-4o 生成的答案可能包含引言或超出提取表格的解释(例如,“当然,这是您想要的表格:...”)。我们需要分离我们感兴趣的数据。幸运的是,只要 GPT-4o 遵循我们的指示(通常情况下它会这样做),我们感兴趣的数据应该包含在两个标记(<BeginTable><EndTable>)之间。因此,正则表达式 '<BeginTable>(.*)<EndTable>' 完全匹配我们感兴趣的输出部分。我们使用 Python 的 re.findall 函数检索它,该函数接受字符串和正则表达式作为输入,并返回匹配的子字符串列表。我们使用 re.DOTALL 标志确保正则表达式中的点匹配所有字符和新行(因为表格可能包含多行)。从结果匹配中,我们取第一个。请注意,这隐含地假设 GPT 的输出中至少有一个表格。尽管通常情况下是这样的,但请考虑如何使该函数对不符合提示中我们指示的语言模型答案更加健壮。

在将表格数据以文本形式提取出来后,我们首先将其分为与特定行相关的数据(2)和与特定单元格相关的数据(3)。经过一些清理(Python 函数 strip 移除空白字符),我们将得到的单元格值添加到我们的结果列表中。这个由行组成的列表(其中每一行再次表示为一个列表)被返回。

4.3.4 端到端提取代码

列表 4.2 展示了完成的 Python 代码。代码结构类似于列表 4.1,并且两个列表中的一些函数是共享的(而不是省略重复的函数,本书旨在提供自包含的代码,以便您不需要从多个页面中拼凑代码)。特别是,代码使用了之前相同的库(1)并通过相同的函数调用语言模型(3)。您将认出用于创建提示的函数(2)和用于后处理语言模型原始输出的函数(4),这些函数之前已经介绍过。

列表 4.2 从文本中提取用户定义的属性
import argparse  #1
import openai
import pandas as pd
import re
import time

client = openai.OpenAI()

def create_prompt(text, attributes):              #2
    """ Generates prompt for information extraction.

    Args:
        text: extract information from this text.
        attributes: list of attributes.

    Returns:
        input for LLM.
    """
    parts = []
    parts += ['Extract the following properties into a table:']
    parts += [','.join(attributes)]
    parts += [f'Text source: {text}']
    parts += [
        ('Mark the beginning of the table with <BeginTable> '
        'and the end with <EndTable>.')]
    parts += [
        ('Separate rows by newline symbols and separate '
        'fields by pipe symbols (|).')]
    parts += [
        ('Omit the table header and insert values in '
        'the attribute order from above.')]
    parts += [
        ('Use the placeholder <NA> if the value '
        'for an attribute is not available.')]
    return '\n'.join(parts)

def call_llm(prompt):                             #3
    """ Query large language model and return answer.

    Args:
        prompt: input prompt for language model.

    Returns:
        Answer by language model.
    """
    for nr_retries in range(1, 4):
        try:
            response = client.chat.completions.create(
                model='gpt-4o',
                messages=[
                    {'role':'user', 'content':prompt}
                    ]
                )
            return response.choices[0].message.content
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

def post_process(raw_answer):             #4
    """ Extract fields from raw text answer.

    Args:
        raw_answer: raw text generated by LLM.

    Returns:
        list of result rows.
    """
    table_text = re.findall(
        '<BeginTable>(.*)<EndTable>', 
        raw_answer, re.DOTALL)[0]

    results = []
    for raw_row in table_text.split('\n'):
        if raw_row:
            row = raw_row.split('|')
            row = [field.strip() for field in row]
            row = [field for field in row if field]
            results.append(row)
    return results

def extract_rows(text, attributes):           #5
    """ Extract values for attributes from text.

    Args:
        text: extract information from this text.
        attributes: list of attributes to extract.

    Returns:
        list of rows with attribute values.
    """
    prompt = create_prompt(text, attributes)
    result_text = call_llm(prompt)
    result_rows = post_process(result_text)
    return result_rows

if __name__ == '__main__':         #6

    parser = argparse.ArgumentParser()
    parser.add_argument('file_path', type=str, help='Path to input file')
    parser.add_argument('attributes', type=str, help='Attribute list')
    args = parser.parse_args()

    input_df = pd.read_csv(args.file_path)
    attributes = args.attributes.split('|')

    extractions = []
    for text in input_df['text'].values:          #7
        extractions += extract_rows(text, attributes)

    result_df = pd.DataFrame(extractions)
    result_df.columns = attributes
    result_df.to_csv('result.csv')

1 导入相关库

2 生成提示

3 调用语言模型

4 后处理模型输出

5 从文本中提取数据表

6 提取信息并写入结果

7 遍历文本

主函数(6)从命令行读取两个输入参数:

  • 包含要分析的文本的 .csv 文件路径

  • 要提取的属性列表,由管道符号分隔

在使用pandas库打开输入文件后,我们遍历所有输入文本文档(7)。请注意,我们期望输入文件中的text列包含输入文本。为了执行实际提取,我们使用extract_rows函数(5)。给定输入文本和要提取的属性列表,此函数生成一个合适的提示,从语言模型获取原始答案,并对原始答案进行后处理以获得结构化输出(并将其返回)。遍历输入文本(7)后,我们将最终结果存储在名为 result.csv 的文件中(如果该文件已存在,则会被覆盖)。

4.3.5 尝试一下

你可以在配套网站上找到列表 4.2 中的代码,作为 listing2.py。你还可以在那里下载 biographies.csv 文件,这为你提供了一个小数据集来测试你的提取器(这与我们的动机场景略有不同,但关于电子邮件应用的公开数据很稀缺)。此文件包含五位著名人物的传记,以及相关的姓名,每行一个人。切换到包含 listing2.py(以及数据)的目录,并运行

python listing2.py biographies.csv 
  "name|city of birth|date of birth"

第一个参数是数据集(如果它不在同一目录中,请相应地调整路径)。第二个参数是要提取的属性列表。我们再次使用管道符号来分隔属性。请注意,我们仅通过名称识别属性;无需引用预定义类别。语言模型可以根据名称理解属性语义。

执行代码(这不应超过一分钟)后,你将在名为 result.csv 的文件中找到结果。例如,在样本数据上执行代码可能会得到以下表格:

    name    city of birth    date of birth
0    Sergey Mikhailovich Brin    Moscow    August 21, 1973
1    Martin Luther King Jr.    Atlanta, Georgia    January 15, 1929
2    Anne E. Wojcicki    <NA>    July 28, 1973
3    Maria Salomea Skłodowska-Curie    Warsaw    7 November 1867
4    Alan Mathison Turing    Maida Vale, London    23 June 1912

即使你在相同的数据上执行相同的代码,你也可能看到一些细微的差异(这是由于生成模型输出时的随机化造成的)。该文件中的每一行(除了标题行)代表一个提取。我们正在提取姓名、出生城市和出生日期。因此,我们期望每篇传记都有一个提取行(这正是我们在样本运行中看到的情况)。请注意,存在缺失值:对于 Ann E. Wojcicki,传记片段中不包含出生城市。语言模型会相应地做出反应,并插入相应的占位符(“<N/A>”),而不是具体值。

4.4 聚类

你在 Banana 公司的新工作已经过去几周了。这份工作很棒,但有一个问题:你的收件箱里总是堆满了邮件!这些邮件不仅包括渴望成为暑期实习生的申请(我们已经在上一节处理了这些),而且涉及各种不同的主题。确保阅读所有相关的邮件花费了你很多时间。仔细观察后,你发现许多邮件是重复的。例如,你注意到许多邮件试图引起人们对同一公司活动的关注。一时间,你考虑使用你的代码进行文本分类(在第 4.2 节中讨论过)来将邮件分类到几个类别中(例如,与特定的公司活动相关)。之后,你只需阅读每个类别中的几封邮件,就能全面了解 Banana 公司正在发生的事情。然而,有一个问题:很难制定并维护一个详尽的主题列表,因为这些主题在你工作的过程中会不断变化。相反,如果能自动地将一些相似的不同邮件分组,那就太好了,因为例如,它们讨论的是同一事件。这样,你就不必提前制定一个主题列表。

我们想要的是将相似的邮件分组到簇中。这又是一个经典的文本处理问题:文本聚类。如果你想在不知道事先的类别集合的情况下将相关的文本文档聚集在一起,那么聚类方法可能是最佳选择!在本节中,我们将看到如何使用语言模型进行文本聚类。

4.4.1 概述

聚类是计算机科学中的一个经典方法。聚类方法在语言模型和高级文本分析之前就已经存在了。然而,传统上,聚类主要关注以向量形式表达的因素。我们希望将彼此距离较近的向量(当然,我们可以为向量应用各种距离度量)聚集到同一个簇中。然而,这里的情况并非如此:在我们的场景中,我们希望将相似的邮件(或者更一般地说,相似的文本文档)分配到同一个簇中。那么我们如何从文档转换到向量呢?

答案是嵌入。嵌入将文本文档表示为一个(通常是高维的)向量。这正是我们所需要的!当然,这种方法只有在我们将文本文档映射到具有关于文档内容意义的向量时才有意义。理想情况下,我们希望具有相似向量的文档(即根据我们首选的距离度量具有较小距离的向量)也具有相似的内容。这意味着我们不能使用简单的方法将文本文档映射到向量。相反,我们需要一种考虑文本语义并在生成向量表示时考虑这些语义的方法。

幸运的是,语言模型可以帮助!像 OpenAI 这样的提供商提供将文本作为输入并产生嵌入向量的语言模型。因此,如果我们有一组要聚类的文本文档,我们可以为它们计算嵌入向量,并将任何经典聚类算法应用于这些向量。图 4.3 说明了这个过程。接下来,我们将讨论如何实现它。

figure

图 4.3 聚类电子邮件。我们首先为所有电子邮件计算嵌入向量。然后,我们将这些向量聚类,将内容相似的电子邮件分配到同一簇。

4.4.2 计算嵌入

对于到目前为止讨论的示例,我们使用了 OpenAI 的聊天补全端点。对于聚类,我们将使用 OpenAI 的嵌入端点。嵌入的目标是创建一个压缩文本语义的向量。可以使用不同的模型来计算嵌入。向量的维度取决于所使用的模型。对于以下代码,我们将使用text-embedding-ada-002模型。您可以尝试用其他模型替换此模型(您可以在platform.openai.com/docs/guides/embeddings找到 OpenAI 用于计算嵌入的模型列表)以比较输出质量。

例如,我们可以按如下方式为文本文档生成嵌入:

import openai
client = openai.OpenAI()

response = client.embeddings.create(
    model='text-embedding-ada-002',
    input=text)

在这里,您可以看到相应响应的摘录:

CreateEmbeddingResponse(
    data=[
        Embedding(embedding=[                            #1
            -0.005983137525618076, -0.000303583248751238, ...], 
            index=0, object='embedding')], 
    model='text-embedding-ada-002',    #2
    object='list', 
    usage=Usage(prompt_tokens=517, total_tokens=517))  #3

1 嵌入向量

2 生成嵌入的模型

3 使用统计信息

该摘录仅显示了前几个向量维度(1)(而完整的向量有超过 1,000 个维度)。除了嵌入向量外,响应还包含模型名称(2)和使用统计信息(3)。与之前不同,使用统计信息仅指代提示中的标记数量(这也是处理的总标记数量)。与文本补全不同,语言模型只读取标记,但不生成它们。

当然,对我们来说最相关的是嵌入向量本身。您可以通过以下命令访问该嵌入向量:

response.data[0].embedding

大多数时候,调用一次语言模型应该能为您提供所需的嵌入。当然,当为大量电子邮件计算嵌入向量时,我们可能会偶尔遇到问题(即失败的连接尝试)。这就是为什么我们嵌入函数的最终版本再次包含重试机制:

import openai
client = openai.OpenAI()

def get_embedding(text):
    for nr_retries in range(1, 4):
        try:
            response = client.embeddings.create(
                model='text-embedding-ada-002',
                input=text)
            return response.data[0].embedding
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

给定一个文本作为输入,我们尝试最多三次来获取相应的嵌入向量(在每次失败尝试后增加重试之间的延迟)。这是我们将会使用的函数。

4.4.3 聚类向量

为了聚类表示文档的向量,我们将使用 k-means 聚类算法。K-means 是一个非常流行的聚类算法,它通过迭代地改进向量到聚类的映射来工作。与其他聚类算法不同,该算法要求你提前指定聚类数量。在我们的示例场景中,这意味着选择按内容对电子邮件进行分区应该有多精细。

k-means 算法是如何工作的?

k-means 算法将一组要聚类的元素和一个目标聚类数量作为输入。它通过迭代地改进元素到聚类的映射,直到满足终止条件(例如,最大迭代次数或连续迭代之间聚类分配的最小变化)为止。k-means 算法将每个聚类与一个向量(表示该聚类的中心)关联。在每次迭代中,它将每个向量分配给最近的中心所在的聚类。然后,它重新计算与聚类关联的向量(通过平均分配给该聚类的所有元素的向量)。

我们将使用 scikit-learn 库中的 k-means 实现。按照本章第一部分的说明确保该库已安装(通过 from sklearn.cluster import KMeans 导入聚类方法)。在导入库后,我们可以使用以下(简洁的)代码片段调用 k-means 实现:

def get_kmeans(embeddings, k):
    kmeans = KMeans(n_clusters=k, init='k-means++')
    kmeans.fit(embeddings)
    return kmeans.labels_

该函数接受嵌入向量的列表和目标聚类数量作为输入,然后使用 k-means 实现对这些向量进行聚类。聚类的结果是与每个嵌入向量关联的标签。这些标签指示关联的聚类 ID。

4.4.4 文本聚类的端到端代码

以下列表显示了通过嵌入向量聚类文本文档的完整代码。你会认出计算嵌入向量(1)和聚类它们(2)的函数。

列表 4.3 使用语言模型聚类文本文档
import argparse
import openai
import pandas as pd
import time

from sklearn.cluster import KMeans

client = openai.OpenAI()

def get_embedding(text):                        #1
    """ Calculate embedding vector for input text.

    Args:
        text: calculate embedding for this text.

    Returns:
        Vector representation of input text.
    """
    for nr_retries in range(1, 4):
        try:
            response = client.embeddings.create(
                model='text-embedding-ada-002',
                input=text)
            return response.data[0].embedding
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

def get_kmeans(embeddings, k):                #2
    """ Cluster embedding vectors using K-means.

    Args:
        embeddings: embedding vectors.
        k: number of result clusters.

    Returns:
        cluster IDs in embedding order.
    """
    kmeans = KMeans(n_clusters=k, init='k-means++')
    kmeans.fit(embeddings)
    return kmeans.labels_

if __name__ == '__main__':         #3

    parser = argparse.ArgumentParser()
    parser.add_argument('file_path', type=str, help='Path to input file')
    parser.add_argument('nr_clusters', type=int, help='Number of clusters')
    args = parser.parse_args()

    df = pd.read_csv(args.file_path)

    embeddings = df['text'].apply(get_embedding)
    df['clusterid'] = get_kmeans(list(embeddings), args.nr_clusters)

    df.to_csv('result.csv')

1 计算嵌入向量

2 聚类嵌入

3 读取文本并输出聚类

列表 4.3 (3) 的主要功能是从磁盘上的文件读取数据。同样,我们假设数据包含在一个 .csv 文件中,并关注 text 列。首先,我们遍历文本文档并生成相应的嵌入(通过调用之前讨论过的 get_embedding 函数)。然后,我们通过 get_kmeans 函数对嵌入向量进行聚类。聚类 ID 成为写入磁盘的结果表中的一列。

4.4.5 尝试一下

是时候尝试通过嵌入向量进行聚类了!你可以在书的配套网站上找到代码(4.3 列表),以及一个合适的数据集(textmix.csv)。这个数据集包含来自两个来源的文本片段的混合:一首诗的集合和一个电子邮件存储库。我们将尝试通过聚类来分离这两个来源:我们预计电子邮件和诗歌将被分配到不同的簇中。

切换到包含代码和数据的目录,并在终端中运行以下命令:

python listing3.py textmix.csv 2

在这里,textmix.csv 是输入文件的名称,而 2 是目标簇的数量(在这个特定情况下,两个簇似乎是一个合理的选择,而在其他场景中确定正确的簇数量可能更困难)。结果将存储在 result.csv 文件中。它包含来自输入文件的所有列,以及一个额外的列,包含簇 ID(因为我们只使用两个簇,这个 ID 要么是 0,要么是 1)。运行命令后,你可能会看到一个结果,将电子邮件放在一个簇中,而将诗歌放在另一个簇中。

你可能想尝试不同的模型来观察运行时间和结果质量的不同。你也可以尝试不同的输入文本,并改变簇的数量。除此之外,你可能还想实现本节开头提到的其他嵌入向量的用例。例如,如何实现一个检索界面,将自然语言语句映射到最相关的文档(通过比较问题和文档的嵌入向量)?

4.4.6 嵌入向量的其他用例

到目前为止,我们使用向量通过聚类来识别相似文档。但这并不是嵌入向量的唯一用例!仅举几个例子,嵌入向量通常用于促进与自然语言问题相关的文本文档的检索。在这里,我们比较与问题相关联的嵌入向量与与文档相关联的嵌入向量。具有相似向量的文档更有可能在回答问题时有用。

例如,我们希望“什么是 Transformer 模型?”这个问题的嵌入向量与“Transformer 是一种神经网络架构,常用于语言模型”这个文本的嵌入向量相似,因为它们有相关的话题。如果是这样,我们可以通过比较嵌入向量来识别与问题最相关的文档。更精确地说,我们为可能有助于回答问题的每个文档计算一次嵌入向量。然后,每当收到一个新的问题时,我们计算相关的嵌入向量并检索具有相似嵌入向量的文档。然后,我们可以根据这些文档生成答案。

嵌入向量的另一个用例是异常检测。为了识别一组中与其他文档显著不同的文本文档,我们可以比较它们的嵌入向量。同样,我们只需为每个文档计算一次嵌入向量。这样做可以避免使用语言模型来比较文档。相反,我们只需比较嵌入向量(这非常快)。

总结来说,尽管我们专注于聚类,但嵌入向量有许多用例。这使得学习如何生成和使用它们变得非常有价值!

摘要

  • 您可以直接应用语言模型来分析文本数据。

  • 提示通常包含要分析的文字以及指令。指令描述了要解决的问题以及输出格式。

  • 您可以使用聊天完成功能进行分类、提取和问答。

  • 原始模型输出可能需要后处理以更改格式。

  • 语言模型可以将文本转换为嵌入向量。您可以通过嵌入端点创建嵌入向量。比较嵌入向量相对高效。

  • 您可以使用嵌入进行聚类、检索和异常检测。

4.6 参考文献

  1. Katz, D. M., Bommarito, M. J., Gao, S., 等人. (2024). GPT-4 通过了律师资格考试. 皇家学会哲学学报 A:数学、物理和工程科学 382(2270), 1–17.

第五章:分析结构化数据

本章涵盖了

  • 将问题翻译成查询

  • 构建自然语言界面

  • 分析数据表

  • 分析图数据

世界上很大一部分信息以结构化数据的形式存储。结构化数据基本上意味着以标准化格式存储的数据。例如,数据表(例如,想想你会在 Excel 电子表格中找到的数据)和描述实体及其关系的图(例如,描述社交网络的数据集)是流行的结构化数据类型。

处理结构化数据的工具已经存在了几十年。毕竟,结构化数据具有标准化的格式,优化了计算机处理它的便捷性。那么,为什么我们需要大型语言模型来处理它呢?现有处理结构化数据工具的问题在于它们的界面。通常,每个工具(或者至少是针对特定类型结构化数据的工具类别)支持自己的正式查询语言。

使用这种语言,用户可以经常对结构化数据进行广泛的操作分析。但学习这种查询语言需要时间!如果所有这些系统都可以使用单一语言进行查询,那该多好啊,最好是自然语言(例如,普通的英语)?

这就是语言模型发挥作用的地方。大型语言模型可以将自然语言中的问题翻译成各种正式语言。因此,我们可以将它们用作支持广泛结构化数据类型的各种数据分析工具的通用接口。在本章中,我们将为不同类型的结构化数据构建自然语言查询界面。这些界面使我们(或其他人)能够通过在自然语言中键入问题来分析数据。然后,系统将我们的问题翻译成正式查询,执行它们,并展示结果。

5.1 章节概要

在本章中,我们将创建几个自然语言查询界面。一般来说,自然语言查询界面可以回答以自然语言形式提出的数据问题。本章考虑了不同类型的结构化数据。首先,我们将创建可以回答关于表格数据问题的自然语言界面。之后,我们将创建一个可以回答关于图的问题的界面。

这两种情况下的原理是相同的。我们假设数据是通过使用特定数据类型的数据处理工具来处理的。例如,对于表格数据,我们将使用关系数据库管理系统(RDBMS)。要分析图,我们将使用图数据管理系统。然后,我们将使用大型语言模型将自然语言中的问题转换为特定工具支持的查询语言。例如,对于 RDBMS,这通常是结构化查询语言(SQL)。图数据库管理系统支持多种图数据特定的查询语言。我们将使用 Cypher 查询语言(一种描述图数据分析操作的编程语言)。我们将在接下来的章节中更详细地讨论这两种语言。

为了将问题转换为形式化查询,语言模型需要访问问题(当然),一些关于目标语言的信息(例如,我们是要编写 SQL 还是 Cypher 查询?),以及一些关于我们试图查询的数据结构的信息。例如,表格数据的结构由表名、出现在这些表中的列标题(希望提供一些关于存储在其中的数据的语义的提示)以及每列的数据类型(我们是存储整数还是字符串?)等因素来表征。通过向语言模型提供所有这些信息,包括它们在提示中,模型应该能够生成一个形式化查询,捕捉我们问题的语义。

我们可以使用专门的工具来处理该查询,生成查询结果。假设查询翻译是正确的,这个结果将代表对原始问题的答案。图 5.1 展示了整个过程。

figure

图 5.1 自然语言查询界面将自然语言中的问题转换为形式化查询,考虑到数据结构。然后,通过一个专门的工具在数据上处理形式化查询,生成查询结果。
我们为什么需要外部工具?

我们已经看到语言模型可以解决各种任务。那么,为什么不直接使用语言模型来分析结构化数据呢?为什么我们依赖外部工具来完成这项任务,而仅仅将语言模型作为翻译器使用呢?

主要原因是效率。使用大型语言模型成本高昂,至少对于大型数据集来说,数据的大小很容易超过语言模型的最大输入大小。因此,依赖能够处理大型结构化数据集的现有工具似乎是一个更好的主意。

警告:在以下章节中,我们使用语言模型来编写数据处理命令。尽管语言模型在很多情况下工作得非常出色,但永远不要依赖它们在所有单一场景中生成正确的结果。在某些情况下,语言模型可能会编写错误的查询。其他时候,它们可能会编写更改或删除你的数据或更改你的系统设置的命令。在通过语言模型启用数据访问之前,始终备份重要数据。

5.2 为分析游戏销售的自然语言查询界面

我们回到了香蕉公司,在这里你意识到你的老板竟然是个电脑游戏爱好者。你的老板不仅喜欢在晚上玩游戏,而且还热衷于分析电脑游戏的数据。最近,你的老板获得了一份数据集,关于电脑游戏的销售情况,他正寻求提取有趣的统计数据。SQL 是分析表格数据的语言,但你的老板并不觉得写 SQL 查询很自在。了解到你在语言模型和数据分析方面的专业知识,你的老板询问是否有可能构建一个将关于电脑游戏的问题翻译成相应的 SQL 查询的界面。经过思考,你意识到这可能是一个有趣的项目,而且可以用语言模型轻松实现。让我们开始吧!

5.2.1 设置 SQLite 数据库

在我们可以使用 SQL 查询分析表格数据之前,我们首先必须将数据加载到 RDBMS 中,这是一个用于高效处理数据表的工具。在本小节中,我们将了解如何将关于电脑游戏的数据加载到 SQLite 中,这是一个流行的 RDBMS。

作为第一步,确保 SQLite 已安装。在本节中,我们将使用 SQLite 版本 3。你可以在终端中输入以下内容来检查 SQLite 3 是否已安装:

sqlite3 --version

如果你收到这样的消息,则不需要进一步安装:

3.33.0 2020-08-14 13:23:32 fca8...

如果你看到错误消息,请访问www.sqlite.org/download.html。选择与你的操作系统一致的版本,下载所有相关文件,并按照说明安装 SQLite。如果遇到问题,请点击书中配套网站第五章部分的“SQLite 安装”项目。你将找到如何在不同平台上安装 SQLite 的详细说明。之后,运行之前的命令以确保 SQLite 已正确安装。

接下来,我们想要使用 SQLite 创建一个关系型数据库。关系型数据库本质上是一组数据表。你可以将每个数据表想象成一个简单的电子表格。我们已命名与数据类型关联的表列,以及(可能很多)包含每列值的表行。例如,表 5.1 包含有关视频游戏的信息,每一行代表一个游戏。该表有四个列:名称平台年份类型。其中三个列(名称平台类型)包含字符串(即文本)。年份列包含数字(表示游戏发布的年份)。

表 5.1 数据表样本。每一行表描述一个视频游戏。
名称 平台 年份 类型
Wii Sports Wii 2006 体育
超级马里奥兄弟 NES 1985 平台
马里奥赛车 WII WII 2008 赛车
Wii Sports Resort Wii 2009 体育
宝可梦红/宝可梦蓝 GB 1996 角色扮演

表 5.1 展示了可以在本书配套网站上找到的数据集的小样本(在第五章部分查找“游戏”项)。我们将在以下部分使用该数据集来创建一个自然语言查询界面,使用户能够使用自然语言命令进行分析。但首先,我们需要将那些数据加载到 SQLite 数据库系统中。

提示:我们将介绍将表格数据加载到 SQLite 所需的所有步骤。如果你想要加载这里未讨论的数据,这很有用。如果你不想创建自己的数据库,你可以跳过这一部分,而是从本书的配套网站上下载 games.db。

让我们开始 SQLite 命令行界面。在终端中输入以下命令,然后按 Enter 键:

sqlite3 games.db

此命令创建一个存储在文件 games.db 中的新数据库,并同时打开 SQLite 命令行界面。现在我们可以指示 SQLite 工具加载我们将在以下部分使用的数据集。首先,我们必须告诉 SQLite 有关数据结构的一些信息。我们想要加载包含额外列的表 5.1 的扩展版本。运行以下命令来描述数据的结构:

CREATE TABLE games(             #1
rank int, name text, platform text,                    #2
year int, genre text, publisher text, americasales numeric, 
eusales numeric, japansales numeric, othersales numeric, 
globalsales numeric);

1 表名

2 表列列表

此命令描述了一个名为 games 的单个表的结构(1)(因为它将存储有关视频游戏的信息);在括号中,我们指定了表中列的完整列表(用逗号分隔)(2)。每个列由列名(例如,ranknamegenre)后跟列类型定义。例如,int 表示该列存储整数,而 text 表示该列存储文本数据。所有表行必须为每个列提供适当数据类型的值。

在定义表结构后,我们可以从书籍网站上的 Games 下的 .csv 文件加载数据到表中。如果你还没有下载,请下载该文件。以下命令将假设文件存储在文件夹 /Downloads/videogames.csv 中。只需将此路径替换为你的系统上的文件路径。使用以下命令加载数据(仍在 SQLite 接口内):

.mode csv                           #1
.import /Downloads/videogames.csv games  #2

1 设置 CSV 模式

2 导入数据

第一个命令使 SQLite 准备从 .csv 文件加载数据(这适用于我们想要加载的文件)(1)。下一个命令(2)导入数据:第一个参数是我们想要从其加载数据的文件路径,第二个参数是我们想要将文件加载到其中的数据表名称。在这种情况下,我们引用的是我们之前定义的结构表(games)。为了测试数据是否成功加载(如果没有错误信息,应该就是这种情况),请在 SQLite 中运行以下命令:

SELECT count(*) FROM games;

如果所有数据都已加载,你应该看到结果 16599。如果你看到一个更小的数字,请检查 SQLite 输出中的错误信息。你可以通过命令 .quit 退出 SQLite 控制台(别忘了在命令前加上点以使其生效)。对于以下部分,我们将假设数据已经加载并存储在一个名为 games.db 的 SQLite 文件中。

5.2.2 SQL 基础知识

当然,我们可以使用语言模型将问题翻译成 SQL 查询(SQLite 可以理解这些查询)。但我们能信任它的翻译吗?再深入思考一下,你会意识到在向老板展示界面之前,至少有一些 SQL 基础知识来验证语言模型的输出是有益的。这就是我们将在本节中做的事情。当然,本节不会全面介绍 SQL。你可以查看网站 www.databaselecture.com,以获取本书作者的更详细介绍。在本节中,我们将讨论一些有助于实现我们的自然语言查询界面的 SQL 基础知识。

SQL 查询用于分析数据表。查询结果可能来自单个表,或者通过组合多个表的数据得出。在我们的示例数据库中,如上一节创建的,我们只有一个表。使用 SQL 查询,我们可以,例如,计算具有特定属性(例如,同一发行商的所有游戏)的行数,过滤数据(例如,仅显示 2017 年发布的游戏),或执行各种聚合操作(例如,对于每个发行商,计算每款游戏的平均收入)。

SQL 查询通常描述一个要生成的表(使用数据库中已存在的数据)。本节中的大多数示例查询具有以下结构:

SELECT [Columns or Aggregates]  #1
FROM [List of tables]           #2
WHERE [List of conditions]      #3

1 SELECT 子句

2 FROM 子句

3 WHERE 子句

FROM子句(2)(在FROM关键字之后和WHERE关键字之前的代码)描述了用于分析的源数据。例如,FROM子句可以包含一个由逗号分隔的表名列表。在我们的示例数据库中,我们只有一个要处理的表(对于高级查询,相同的表名可能在FROM子句中多次出现,创建相同表的多个副本)。我们将在以下章节中遇到的查询将在FROM子句中包含一个条目:games表。

WHERE子句(3)定义了FROM子句中出现的表上的谓词。例如,它可能包含一个条件,将范围限制为来自某个出版商的游戏。WHERE子句可以包含简单条件(即可以通过表列上的等式或不等式表示的条件)以及复杂条件(通过ANDOR运算符连接多个简单条件)。这些条件用于从FROM子句中的表中过滤行。不满足WHERE子句中条件的行将被丢弃,不会出现在查询结果中。

最后,我们在SELECT子句中指定所需结果表的列(1)。更确切地说,我们指定一个由逗号分隔的列定义列表。列可以通过列名(出现在FROM子句中的一个表中)或更复杂的表达式来定义:例如,连接多个列的算术表达式。或者,我们可以在SELECT子句中指定聚合,如count(*)(后者聚合计算行数)。查询结果包含一个具有指定列的表,并用与列定义相对应的内容填充这些列。

例如,假设我们想要计算 2017 年发布的所有游戏。在这种情况下,我们的查询结果应包含一个包含计数聚合的单列。此外,我们的WHERE子句应包含一个过滤条件,将我们的范围限制为 2017 年的游戏。当然,我们的FROM子句包含我们数据库中唯一的表名(games)。以下查询生成了所需的结果:

SELECT Count(*)
FROM games
WHERE year = 2017

为了使事情变得稍微复杂一些,假设我们想要计算 2017 年由特定发行商(Activision)发布的所有游戏的欧洲和日本的销售额总和。我们期望的查询结果包含两个列:游戏名称和销售额。由于我们的数据表有两个包含欧洲和日本销售额的列(eusalesjapansales,如前节中我们的表定义所示),我们可以通过将它们相加来描述期望的结果列(eusales + japansales)。因为我们想将范围限制在 2017 年和 Activision 的游戏上,我们可以在 WHERE 子句中使用一个复杂的谓词:year = 2017 AND publisher = Activision。注意在 Activision 名称周围使用引号——我们需要使用引号来限定查询条件中的字符串而不是数字。以下查询生成了期望的结果:

SELECT name, eusales + japansales
FROM games
WHERE year = 2017 AND publisher = 'Activision'

可选地,当指定聚合时,我们可以根据某些列中共享的值计算不同行组的聚合。为此,我们在之前的查询模板中添加一个最终的 GROUP BY 子句,后面跟着一个用逗号分隔的列列表,这些列用于形成组。例如,假设我们想要分别计算每个游戏类型(如策略或动作)的游戏销售额。我们可以使用以下查询来返回每个游戏类别的聚合行(注意,我们在 SELECT 子句中也添加了 genre 列,以确保我们可以将数字与正确的游戏类型关联起来):

SELECT genre, name, eusales + japansales
FROM games
WHERE year = 2017 AND publisher = 'Activision'
GROUP BY genre

本节中的 SQL 入门知识显然不足以让你编写自己的 SQL 查询,除非是少数简单的情况。然而,我们实际上并不想编写自己的 SQL 查询——我们更希望依赖语言模型!本介绍应该能让你从高层次抽象上理解语言模型生成的查询。观察语言模型如何将问题映射到查询上,可能是自学 SQL 的一个好方法。在下一节中,我们将从创建一个简单的翻译器开始,将问题翻译成对 games 数据库的 SQL 查询。

5.2.3 概述

在掌握了一些 SQL 基础知识以检查语言模型的输出后,我们现在将致力于我们的文本到 SQL 翻译器。我们将通过命令行使用我们的翻译器。我们指定一个问题作为输入,理想情况下我们希望得到一个 SQL 查询,将我们的问题作为输出进行翻译。如果我们运行输出查询在 SQLite 中,我们应该得到我们原始问题的答案。当然,这个接口是手动的,并不非常方便使用。理想情况下,我们希望它能自动执行查询并直接在我们的查询界面中显示相应的结果。我们将在下一节创建这样的接口。现在,我们只关注将问题翻译成查询的核心问题。此外,目前我们的唯一目标是翻译关于电脑游戏的问题。因此,我们将硬编码目标数据库的结构。我们将在下一个项目中将其通用化。

内部来说,为了翻译输入的问题,我们首先创建一个提示。这个提示描述了翻译任务,并包含翻译所需的所有相关细节(例如,我们目标数据库的结构)。将这个提示发送给语言模型,在大多数情况下,应该会得到一个正确翻译的 SQL 查询。我们可能还需要做一些工作来从语言模型生成的可能过于冗长的输出中提取这个查询。让我们更详细地讨论这些步骤,从提示开始。

5.2.4 生成文本到 SQL 翻译的提示

我们需要向语言模型传达哪些信息才能实现成功的翻译?显然,我们需要指定我们想要翻译的问题。此外,我们还需要指定目标系统(SQLite)并描述目标数据库的结构。目前,我们硬编码数据库结构。我们可以简单地向语言模型提供我们在第 5.2.1 节中使用的表定义(create table...)。语言模型将理解这个命令如何映射到表结构。通过向模型发送包含所有之前提到的信息类型的提示,语言模型应该能够生成相应的 SQL 查询。

让我们使用以下提示模板:

Database:                                          #1
CREATE TABLE games(rank int, name text, platform text,
year int, genre text, publisher text, americasales numeric,
eusales numeric, japansales numeric, othersales numeric,
globalsales numeric);
Translate this question into SQL query:  #2
[Question]                          #3

1 数据库描述

2 任务描述

3 要翻译的问题

此提示模板包含之前描述的所有信息片段。首先,它通过提供创建相关表(在这种情况下,一个单独的表)所使用的 SQL 命令来描述目标数据库(1)。请注意,这不仅仅是一个占位符,因为目前我们的查询接口只需要为一个数据库(我们在模板中硬编码其结构)工作。接下来,提示模板包含一个任务描述(2):目标是把问题转换为 SQL 查询。最后,模板包含要转换的问题(3)。在这里,我们使用一个占位符(由方括号表示)。这使得我们可以使用相同的提示模板来处理老板可能提出的关于数据的各种问题。

以下代码根据之前的模板生成提示:

def create_prompt(question):
    parts = []
     #1
    parts += ['Database:']
    parts += ['create table games(rank int, name text, platform text,']
    parts += ['year int, genre text, publisher text, americasales numeric,']  
    parts += ['eusales numeric, japansales numeric, othersales numeric,'] 
    parts += ['globalsales numeric);']
     #2
    parts += ['Translate this question into SQL query:']
    parts += [question]      #3
    return '\n'.join(parts)  #4

1 添加数据库描述

2 添加任务描述

3 添加要转换的问题

4 返回连接结果

给定要转换的问题作为输入,代码添加数据库描述(1),然后是翻译说明(2),最后是转换的问题(3)。结果是所有提示部分的连接(4)。

5.2.5 完整代码

下一个列表包含我们自然语言查询接口的完整代码。它使用之前讨论的提示生成函数(1),以及我们在前几章中已经了解的调用语言模型的函数(2)。

列表 5.1 将关于视频游戏的问题转换为 SQL 查询
import argparse
import openai
import re
import time

client = openai.OpenAI()

def create_prompt(question):                #1
    """ Generate prompt to translate question into SQL query.

    Args:
        question: question about data in natural language.

    Returns:
        prompt for question translation.
    """
    parts = []
    parts += ['Database:']
    parts += ['create table games(rank int, name text, platform text,']
    parts += ['year int, genre text, publisher text, americasales numeric,']  
    parts += ['eusales numeric, japansales numeric, othersales numeric,'] 
    parts += ['globalsales numeric);']
    parts += ['Translate this question into SQL query:']
    parts += [question]
    return '\n'.join(parts)

def call_llm(prompt):                             #2
    """ Query large language model and return answer.

    Args:
        prompt: input prompt for language model.

    Returns:
        Answer by language model.
    """
    for nr_retries in range(1, 4):
        try:
            response = client.chat.completions.create(
                model='gpt-4o',
                messages=[
                    {'role':'user', 'content':prompt}
                    ]
                )
            return response.choices[0].message.content
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

if __name__ == '__main__':       #3

    parser = argparse.ArgumentParser()
    parser.add_argument('question', type=str, help='A question about games')
    args = parser.parse_args()

    prompt = create_prompt(args.question)  #4
    answer = call_llm(prompt)         #5
     #6
    query = re.findall('\verb|```|sql(.*)\verb|```py|', answer, re.DOTALL)[0]

    print(f'SQL: {query}')

1 生成翻译提示

2 调用语言模型

3 读取查询并将其转换为 SQL

4 生成提示

5 生成答案

6 从答案中提取 SQL 查询

此列表从命令行读取关于计算机游戏的问题(3)。使用输入问题,它生成一个提示(4),指示语言模型将问题转换为 SQL 查询。它将提示发送到语言模型并接收其答案(5)。

GPT-4o 的原始答案通常包含与我们所感兴趣的 SQL 查询交织的解释。为了得到查询本身,我们必须从原始答案中提取它(6)。在这里,我们利用 GPT-4o 在 py` sql py` and ```` py 标记之间包围 SQL 查询的事实(当通过 ChatGPT 网页界面与 GPT 模型交互时,那些标记之间的内容会显示为代码框)。正则表达式 ```py` ```sql(.*)```py 匹配标记之间的 SQL 查询,使用 Python 函数 re.findall 返回该正则表达式的匹配列表(需要 re.DOTALL 标志以确保点匹配所有字符,包括可能出现在 SQL 查询中的换行符)。我们使用这些匹配中的第一个作为我们的查询(即,我们隐含地假设至少返回一个匹配项,并且第一个匹配项是合适的)。

5.2.6 尝试运行

好的!现在是时候尝试我们的文本到 SQL 翻译器了!在终端中,切换到包含 Python 代码的目录。我们将假设代码存储在一个名为listing1.py的文件中(你可以从配套网站上下载)。运行以下命令:

python listing1.py "How many games are stored?"

因此,你应该获得以下 SQL 查询:

SELECT COUNT(*) FROM games;

这个查询正确吗?让我们来看看:在终端中,切换到包含 SQLite 数据库文件(games.db)的仓库。然后,通过 SQLite 命令行界面打开数据库:

sqlite3 games.db

现在我们可以最终尝试由我们的文本到 SQL 翻译器生成的查询。输入查询,然后按 Enter 键。你应该看到数据库中存储的游戏数量:16,599。

你可能想尝试其他几个问题。例如,看看你是否能按特定出版商或特定类型的游戏来计算游戏数量!对于大多数涉及这个简单数据库的问题,GPT-4o 应该足够强大,能够提供准确的翻译。你的老板会很高兴的。

本节中我们创建的界面在多个方面仍然有限。首先,你必须为每个新问题重新执行程序。其次,你必须手动复制并执行每个翻译后的查询到数据库系统界面中。第三,也是最重要的一点,如果你想要切换到不同的数据集,你必须手动更改你的提示模板。在下一节中,我们将看到如何克服这些限制。

5.3 通用自然语言查询界面

你的老板对新的自然语言界面很满意,并经常与你分享关于电脑游戏销售的有趣见解。然而,你忍不住想知道,仅此用例是否充分利用了你的方法。例如,Banana 的人力资源部门经常处理存储员工信息的表上的复杂问题。我们能否将我们的自然语言界面推广到帮助他们?在本节中,我们将推广自然语言查询界面以与任意数据库一起工作,而无需对代码本身进行任何更改。此外,我们将通过直接执行翻译后的查询并避免在同一数据的不同问题之间重启来使界面更加方便。

5.3.1 执行查询

作为第一步,让我们看看我们如何可以直接从 Python 执行翻译后的查询。这将避免从一种界面到另一种界面的繁琐查询复制。在 Python 中,我们可以使用sqlite3库在 SQLite 数据库上执行查询。

假设变量data_path存储了数据库文件的路径。要在此数据库上执行查询,我们必须首先创建一个连接:

import sqlite3

with sqlite3.connect(data_path) as connection:
    ...

我们现在可以通过connection对象在数据库上执行 SQL 查询。假设我们想要执行的 SQL 查询存储在变量query中。连接到数据库后,我们首先创建一个游标对象(启用查询和结果检索),然后使用它来执行查询:

import sqlite3

with sqlite3.connect(data_path) as connection:
    cursor = connection.cursor()
    cursor.execute(query)

执行后,我们可以通过调用 cursor.fetchall() 获取结果行的列表。我们将所有内容组合在一个函数中,该函数接受两个参数——数据库的路径和要执行的查询——作为输入,并以字符串形式返回查询结果:

def process_query(data_path, query):
    with sqlite3.connect(data_path) as connection:  #1
        cursor = connection.cursor()           #2
        cursor.execute(query)    #3
        table_rows = cursor.fetchall()  #4
         #5
        table_strings = [str(r) for r in table_rows]
        return '\n'.join(table_strings)          #6

1 连接到数据库

2 创建光标

3 执行一个查询

4 获取查询结果

5 转换为字符串列表

6 连接结果行

在连接到目标数据库(1)后,函数创建一个光标(2),执行输入查询(3),并检索查询结果(4)。在将结果元组转换为字符串表示(5)后,我们连接结果行,用换行符分隔(6)。

5.3.2 提取数据库结构

我们需要一个适用于任意 SQLite 数据库的接口,而无需更改代码。这意味着我们需要自动提取当前数据库的结构(关于其表和列的信息)。

对于 SQLite,我们可以通过执行 SQL 查询来提取数据库的结构。这些查询访问一个特殊表:模式表。这个表是自动创建的(即,我们不需要手动创建它)。这个表包含创建数据库中其他表的 SQL 命令。我们可以使用它们作为数据库结构的简洁描述,适合作为语言模型的输入。

我们可以通过表名 sqlite_master 访问模式表。这个表包含一个名为 sql 的列,其中包含有关在数据库内部创建对象所使用的查询的信息。具体来说,我们感兴趣的是用于创建表的 SQL 命令。这些查询包含查询翻译的关键信息,包括属于由查询创建的表的列的名称和类型。以下查询检索了当前数据库中用于创建表的全部 SQL 语句:

select sql from sqlite_master where type = 'table';

因此,我们所需做的就是从 Python 中执行此查询。幸运的是,我们已经从上一节中知道了如何执行。给定数据库的路径,以下函数返回一个描述用于创建数据库中所有表的查询的文本:

import sqlite3

def get_structure(data_path):
    with sqlite3.connect(data_path) as connection:  #1
        cursor = connection.cursor()          #2
        cursor.execute("select sql from sqlite_master where type =
        ↪ 'table';")             #3
        table_rows = cursor.fetchall()      #4
        table_ddls = [r[0] for r in table_rows]  #5
        return '\n'.join(table_ddls)        #6

1 连接到数据库

2 创建一个光标

3 访问模式表

4 获取结果

5 获取 SQL 字符串

6 连接 SQL 字符串

再次,我们创建一个连接(1)和一个相应的游标对象(2)。接下来,我们向模式表发出查询,以检索当前数据库中用于创建表的全部 SQL 查询(3)。我们获取结果(4)并从查询结果中提取 SQL 字符串(5)。请注意,此部分函数与上一节中讨论的执行查询的通用函数略有不同。通过提取每行第一个(对于这个特定查询,只有)字段的值,我们消除了行之间不必要的分隔符,否则这些分隔符将出现在我们的输出中(以及后来的提示中)。结果是所有结果行的连接(6)。

5.3.3 完整代码

列表 5.2 显示了我们通用自然语言查询接口的完整代码(您可以从本书的配套网站上下载,作为第五章部分的列表 2)。该代码使用之前讨论的提取数据库结构的函数(1)。生成提示的函数(2)是我们之前数据库特定查询接口的一个轻微变体。它不是使用硬编码的数据库结构描述,而是接受数据库描述作为输入并将其插入到提示中。与先前接口版本相比,调用语言模型的函数(3)没有变化。process_query 函数(4)在 5.3.1 节中已讨论。

列表 5.2 通用文本到 SQL 查询接口
import argparse
import openai
import re
import sqlite3
import time

client = openai.OpenAI()

def get_structure(data_path):               #1
    """ Extract structure from SQLite database.

    Args:
        data_path: path to SQLite data file.

    Returns:
        text description of database structure.
    """
    with sqlite3.connect(data_path) as connection:
        cursor = connection.cursor()
        cursor.execute("select sql from sqlite_master where type = 'table';")
        table_rows = cursor.fetchall()
        table_ddls = [r[0] for r in table_rows]
        return '\n'.join(table_ddls)

def create_prompt(description, question):     #2
    """ Generate prompt to translate a question into an SQL query.

    Args:
        description: text description of database structure.
        question: question about data in natural language.

    Returns:
        prompt for question translation.
    """
    parts = []
    parts += ['Database:']
    parts += [description]
    parts += ['Translate this question into SQL query:']
    parts += [question]
    return '\n'.join(parts)

def call_llm(prompt):                             #3
    """ Query large language model and return answer.

    Args:
        prompt: input prompt for language model.

    Returns:
        Answer by language model.
    """
    for nr_retries in range(1, 4):
        try:
            response = client.chat.completions.create(
                model='gpt-4o',
                messages=[
                    {'role':'user', 'content':prompt}
                    ]
                )
            return response.choices[0].message.content
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

def process_query(data_path, query):        #4
    """ Processes SQL query and returns result.

    Args:
        data_path: path to SQLite data file.
        query: process this query on database.

    Returns:
        query result.
    """
    with sqlite3.connect(data_path) as connection:
        cursor = connection.cursor()
        cursor.execute(query)
        table_rows = cursor.fetchall()
        table_strings = [str(r) for r in table_rows]
        return '\n'.join(table_strings)

if __name__ == '__main__':

    parser = argparse.ArgumentParser()
    parser.add_argument('dbpath', type=str, help='Path to SQLite data')
    args = parser.parse_args()

    data_structure = get_structure(args.dbpath)  #5

    while True:           #6
        user_input = input('Enter question:')
        if user_input == 'quit':
            break

        prompt = create_prompt(data_structure, user_input)
        answer = call_llm(prompt)
        query = re.findall('```sql(.*)```py', answer, re.DOTALL)[0]
        print(f'SQL: {query}')

        try:                               #7
            result = process_query(args.dbpath, query)
            print(f'Result: {result}')
        except:
            print('Error processing query! Try to reformulate.')

1 提取数据库结构

2 创建翻译提示

3 调用语言模型

4 在数据库上处理查询

5 读取数据结构

6 回答问题直到用户退出

7 在数据库上处理查询

在读取命令行参数后,自然语言查询接口提取数据库的结构(5)。接下来,我们循环(6)直到用户终止接口。在每次迭代中,我们首先从键盘读取输入(如果用户输入quit则退出循环),然后创建提示并调用语言模型。

在将输入问题翻译成查询后,我们直接执行该查询(7)。当然,翻译可能是不正确的,并导致在目标数据库上无法执行的查询。在这种情况下,SQLite 可能会抛出一个错误,我们必须确保我们的程序不会终止。这就是为什么我们将对查询处理函数的调用放在 try-catch 块中的原因。

5.3.4 尝试运行

是时候尝试我们的自然语言查询接口了!我们的接口现在适用于任意数据库。但我们没有另一个数据库,所以我们将再次在游戏数据库上使用它。在终端中,切换到包含 games.db 文件的目录,并运行以下命令(假设代码存储在一个名为 listing3.py 的文件中):

python listing3.py games.db

该命令将在一个输入框中打开,我们可以输入关于数据的问题。以下是与自然语言查询接口交互的示例:

Enter question:How many games are stored in total?
SQL: SELECT COUNT(*) FROM games;
Answer: (16599,)
Enter question:How many games did Activision create?
SQL: SELECT COUNT(*) FROM games WHERE publisher = 'Activision'
Answer: (975,)
Enter question:Name one game that was released in 2017!
SQL: SELECT name
FROM games
WHERE year = 2017
LIMIT 1
Answer: ('Phantasy Star Online 2 Episode 4: Deluxe Package',)
Enter question:How many games were released for each genre?
SQL: SELECT genre, COUNT(*) as num_games
FROM games
GROUP BY genre
Answer: ('Action', 3316)
('Adventure', 1286)
('Fighting', 848)
('Genre', 1)
('Misc', 1739)
('Platform', 886)
('Puzzle', 582)
('Racing', 1249)
('Role-Playing', 1488)
('Shooter', 1310)
('Simulation', 867)
('Sports', 2346)
('Strategy', 681)
Enter question:Which three games sold more copies in Japan than in Europe?
SQL: SELECT name
FROM games
WHERE japansales > eusales
ORDER BY japansales DESC
LIMIT 3;
Answer: ('Name',)
('Pokemon Red/Pokemon Blue',)
('Pokemon Gold/Pokemon Silver',)
Enter question:Break down game sales in America by the platform!
SQL: SELECT platform, sum(americasales) AS total_sales
FROM games
GROUP BY platform
Answer: ('2600', 90.59999999999992)
('3DO', 0)
('3DS', 78.86999999999996)
('DC', 5.43)
('DS', 390.7099999999977)
('GB', 114.32000000000001)
('GBA', 187.54000000000033)
('GC', 133.46000000000004)
('GEN', 19.27)
('GG', 0)
('N64', 139.02000000000015)
('NES', 125.94000000000005)
('NG', 0)
('PC', 93.2800000000005)
('PCFX', 0)
('PS', 336.509999999998)
('PS2', 583.8399999999925)
('PS3', 392.2599999999998)
('PS4', 96.79999999999998)
('PSP', 108.98999999999975)
('PSV', 16.200000000000006)
('Platform', 0.0)
('SAT', 0.7200000000000001)
('SCD', 1)
('SNES', 61.22999999999998)
('TG16', 0)
('WS', 0)
('Wii', 507.7099999999991)
('WiiU', 38.31999999999999)
('X360', 601.0499999999992)
('XB', 186.6900000000008)
('XOne', 83.19000000000003)
Enter question:quit

正如你所见,我们可以提出广泛的问题并获得合理的答案。除了答案之外,系统还会打印出查询。了解一点 SQL 使我们能够验证查询是否准确地翻译了问题。

5.4 图数据自然语言查询接口

在香蕉公司,关于你的文本到 SQL 接口的消息传开了,多位同事用它来分析他们的表格数据集。你的一位新同事正在处理大型图,在香蕉公司内部社交网络中建模同事之间的联系。这些数据不是以表格的形式表示,而是以图的形式表示,这种数据格式特别适合于建模实体之间的联系(在这种情况下,是人)。这位同事向你伸出援手,询问是否有可能扩展你的接口以查询此类数据。了解到语言模型原则上应该能够处理各种正式查询语言,你感到乐观,并同意调查此事。

5.4.1 什么是图数据?

与关系数据一样,图是一种特别受欢迎的结构化数据类型。图通常由节点集合组成,这些节点通过边连接。节点可以与属性相关联,边有标签。例如,社交网络通常表示为图。在这里,节点代表人,边代表友谊和关系。图也是道路或地铁网络的天然表示。在这种情况下,节点代表城市或地铁站,而边代表连接它们的道路或轨道。

图 5.2 展示了一个表示地铁网络的示例图。它将纽约市地铁的车站表示为节点(因此,节点被标记为Station)。边代表直接连接,并标记有相关的地铁线路。节点与一个 name 属性相关联,将节点分配给相应的车站名称。

figure

图 5.2 表示纽约市地铁网络一小部分的示例图。节点以圆形绘制,代表地铁站,并关联一个属性,将它们分配给表示的地铁站名称。边以箭头绘制,代表通过特定地铁线路的直接连接。边由连接车站的地铁线路标记。

图可以用来建模各种类型的数据这一事实激发了各种专门系统,称为图数据库系统,用于图数据处理。这些系统支持图特定的查询语言(不是 SQL),使用户能够对底层数据提出复杂的问题。图数据库系统针对高效处理大型图进行了优化。

接下来,我们将使用语言模型将自然语言中的问题翻译成图数据库管理系统中的查询。我们会看到,与将问题翻译成 SQL 查询的方法相比,这种场景只需要进行小的修改。

5.4.2 设置 Neo4j 数据库

我们将使用 Neo4j 系统,这是一个专门针对图数据的数据库系统。你甚至不需要在你的本地机器上安装任何东西。Neo4j 附带一个在线演示,我们将在下一步中使用。

首先,在你的网络浏览器中转到neo4j.com/sandbox/。点击启动免费沙盒按钮。这应该会打开一个登录表单,在那里你可以选择创建新账户或使用现有账户(例如,一个 Google 账户)。图 5.3 显示了你应该看到的屏幕。

figure

图 5.3 选择电影数据库,然后点击创建以创建相应的实例。

在这里,我们可以选择几个示例数据库之一来尝试 Neo4j。我们将使用Movies数据库。这个数据库包含有关电影及其演员的信息(将电影和演员表示为节点)。点击Movies数据库,然后点击创建按钮来创建数据库实例并为其查询做准备。创建数据库可能需要几分钟。之后,你可以打开Movies数据库并访问查询界面。

图 5.4 显示了 Neo4j 查询界面。点击左上角的数据库图标以查看数据库概览。数据库包含多种类型的标签(即节点类型),包括PeopleMovies。它还包含关系类型,如ACTED_INDIRECTED,标记我们的图中的边。例如,这两种关系类型使我们能够跟踪谁在哪些电影中出演(ACTED_IN)以及谁导演了哪些电影(DIRECTED)。属性与节点相关联,并为值分配键。数据库概览报告了属性键,如name(将演员分配给名字)和title(将电影分配给标题)。你可以在屏幕顶部的文本框中输入查询,并通过点击文本框右侧的按钮提交它们。

figure

图 5.4 点击数据库图标(左上角)以访问有关当前图数据库的信息,包括节点和边的类型。

5.4.3 Cypher 查询语言

Neo4j 支持 Cypher 查询语言。尽管 Cypher 的全面介绍超出了本书的范围(相反,请参阅 Neo4j 文档,可在neo4j.com/docs/cypher-manual/current/introduction/找到),但我们将快速浏览基础知识。本介绍的目的是使你至少在简单情况下能够理解由语言模型生成的查询的语义。

简单类型的 Cypher 查询使用一个描述子图的 MATCH 语句来查找。例如,我们可能只想找到特定类型的所有节点。在 Movies 数据库中查找所有人的名字的查询如下:

MATCH (p:Person)  #1
RETURN p.name     #2

1 匹配模式

2 返回结果

MATCH 语句描述了一个匹配模式(1)。在这种情况下,该模式由类型为 Person 的单个节点组成。MATCH 语句将出现在模式中的节点或边分配给变量。在这种情况下,我们引入变量 p 并将其分配给匹配该模式的节点。RETURN 语句(2)根据匹配的模式描述查询结果。在这里,我们指示系统返回匹配模式的每个节点的 name 属性。

模式可以扩展到单个节点之外。例如,我们可能想找到所有由演员汤姆·克鲁斯主演的电影的标题。在这种情况下,我们寻找的模式不是单个节点,而是两个连接的节点。我们正在寻找一个类型为 Movie 的节点,通过类型为 ACTED_IN 的边连接到一个类型为 Person 的节点,其 name 属性设置为“Tom Cruise”。这可以通过以下 Cypher 查询实现:

 #1
MATCH (p:Person {name: 'Tom Cruise'})-[:ACTED_IN]->(m:Movie)
RETURN m.title                                       #2

1 匹配汤姆·克鲁斯主演的电影

2 返回电影标题

表达式 (p:Person name: "Tom Cruise")1)匹配所有 name 属性设置为“Tom Cruise”的类型为 Person 的节点。表达式 (m:Movie) 匹配所有类型为 Movie 的节点。最后,我们使用表达式 -[:ACTED_IN]-> 将这两个节点连接起来。这个表达式代表第一个节点(代表汤姆·克鲁斯)和第二个节点(代表任意电影)之间的有向连接(因此箭头形状)。连接的类型限制为 ACTED_IN(例如,不包括汤姆·克鲁斯执导但未出演的电影)。最后,请注意,MATCH 表达式再次将此模式的某些部分分配给变量。汤姆·克鲁斯将由变量 p 表示,而他出演的电影将由变量 m 表示。RETURN 表达式(2)检索电影节点的 title 属性。

最后,让我们看看 Neo4j 如何计算聚合(类似于 SQL):

 #1
MATCH (p:Person {name: 'Tom Cruise'})-[:ACTED_IN]->(m:Movie) 
RETURN count(*)                                 #2

1 匹配汤姆·克鲁斯主演的电影

2 返回电影数量

此查询与之前的查询类似,只是简单地计算出演员汤姆·克鲁斯出演的电影数量(1)。RETURN 语句(2)包含相应的聚合。如果您输入此查询,您应该获得“3”作为查询结果(因此,显然示例数据库是不完整的)。

5.4.4 将问题转换为 Cypher 查询

我们将使用与将问题翻译成 SQL 查询相同的方法。主要的是,我们需要将提示更改为我们语言模型。我们不会指示语言模型翻译成 SQL,而是指示它翻译成 Cypher。幸运的是,像 GPT-4o 这样的语言模型已经在大规模和多样化的训练数据上进行了预训练。正如我们将在本节剩余部分看到的那样,这些预训练数据必须包括 Cypher 查询,这就是为什么我们可以使用语言模型进行翻译。当然,没有绝对的保证,由语言模型生成的 Cypher 查询可能无法准确翻译我们的问题。然而,至少对于简单的查询,翻译通常是正确的。

5.4.5 生成提示

为了将问题翻译成 Cypher 查询,我们需要在提示中包含几个信息。首先,这包括我们想要翻译的问题。其次,这必须包括数据库结构的描述。在 SQL 的情况下,数据库结构由表和列名定义。在 Neo4j 数据库的情况下,我们想要包含有关节点和边类型以及最相关属性名称的信息。这基本上是图 5.4 左侧显示的信息。

为了保持简单,我们将关注之前介绍过的示例数据库,其中包含有关电影的信息。这意味着我们将硬编码数据库结构。当然,类似于我们的文本到 SQL 接口,我们可以扩展接口以处理任意图数据库。

我们将使用以下提示模板:

Neo4j Database:        #1
Node labels: Movie, Person
Relationship types: ACTED_IN, DIRECTED,
FOLLOWS, PRODUCED, REVIEWED, WROTE
Property keys: born, name, rating, released
roles, summary, tagline, title
[Question]                 #2
Cypher Query:              #3

1 数据库描述

2 要翻译的问题

3 目标语言规范

此提示模板包含数据库的描述(1)。此描述包括数据库类型(Neo4j 数据库)的规范,以及节点标签、关系类型和属性的列表。请注意,我们在提示模板中硬编码了数据库结构。如果在不同数据库上使用接口,此部分的提示必须替换。接下来,提示模板指定了要翻译的问题(2)。这是一个占位符,因为我们希望用户能够就数据提出各种问题。提示以查询翻译的目标语言规范结束(3)。这隐含地指示语言模型将问题翻译成 Cypher 查询。

以下代码实例化了用于输入问题的提示模板:

def create_prompt(question):
    parts = []
    parts += ['Neo4j Database:']
    parts += ['Node labels: Movie, Person']
    parts += ['Relationship types: ACTED_IN, DIRECTED,']  
    parts += ['FOLLOWS, PRODUCED, REVIEWED, WROTE'] 
    parts += ['Property keys: born, name, rating, released']
    parts += ['roles, summary, tagline, title']
    parts += [question]
    parts += ['Cypher Query:']
    return '\n'.join(parts)

5.4.6 完整代码

以下列表使用了之前讨论的生成提示的函数(1),并重新使用了调用 GPT-4o 的函数(2)(如果需要,可以进行重复尝试)。

列表 5.3 将文本问题翻译成 Neo4j 的 Cypher 查询
import argparse
import openai
import re
import time

client = openai.OpenAI()

def create_prompt(question):                 #1
    """ Generate prompt to translate a question into Cypher query.

    Args:
        question: question about data in natural language.

    Returns:
        prompt for question translation.
    """
    parts = []
    parts += ['Neo4j Database:']
    parts += ['Node labels: Movie, Person']
    parts += ['Relationship types: ACTED_IN, DIRECTED,']  
    parts += ['FOLLOWS, PRODUCED, REVIEWED, WROTE'] 
    parts += ['Property keys: born, name, rating, released']
    parts += ['roles, summary, tagline, title']
    parts += [question]
    parts += ['Cypher Query:']
    return '\n'.join(parts)

def call_llm(prompt):                             #2
    """ Query large language model and return answer.

    Args:
        prompt: input prompt for language model.

    Returns:
        Answer by language model.
    """
    for nr_retries in range(1, 4):
        try:
            response = client.chat.completions.create(
                model='gpt-4o',
                messages=[
                    {'role':'user', 'content':prompt}
                    ]
                )
            return response.choices[0].message.content
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

if __name__ == '__main__':

    parser = argparse.ArgumentParser()
    parser.add_argument('question', type=str, help='A question about movies')
    args = parser.parse_args()

    prompt = create_prompt(args.question)  #3
    answer = call_llm(prompt)         #4
    #5
    query = re.findall('```cypher(.*)```py', answer, re.DOTALL)[0]

    print(f'Cyper Query: {query}')

1 生成翻译提示

2 调用 LLM

3 创建提示

4 生成答案

5 提取 Cypher 查询

给定一个关于数据库的问题作为输入,代码生成相应的提示(3),从语言模型中获取答案(4),最后从该答案中提取 Cypher 查询(5)。用于提取的正则表达式略有不同,因为 GPT 使用模式 ‘‘‘cypher ... ‘‘‘ 包含 Cypher 查询。我们最终打印出提取的查询。

5.4.7 尝试一下

你可以在本书的配套网站上找到列表 5.3。下载它,并像这样从命令行使用它:

python listing3.py "How many movies are stored?"

你应该得到如下查询作为输出:

MATCH (m:Movie)
RETURN COUNT(m) AS numberOfMovies

你现在可以将此查询输入到 Neo4j 界面以获取相应的结果(结果是 38)。尝试更多查询以更好地了解语言模型的能力。正如你所见,只需对提示模板进行少量更改,我们就将我们的文本到 SQL 界面转换成了在大多数情况下都能很好地工作的文本到 Cypher 界面。

摘要

  • 结构化数据遵循标准格式,这使得解析更加容易。结构化数据的例子包括表格数据和图表。

  • 结构化数据通常通过专用工具进行处理。

  • 关系型数据库管理系统处理表格数据,通常支持 SQL 查询。

  • 图数据管理系统处理表示图的数据库。

  • 语言模型将自然语言翻译成多种正式查询语言。

  • 除了问题之外,查询翻译的提示还指定了数据库结构。

  • 在执行由语言模型生成的查询之前,请复制你的数据库。

  • 不要盲目相信你的语言模型生成准确的查询。

第六章:分析图像和视频

本章涵盖

  • 分析图像

  • 比较图像

  • 分析视频

在前面的章节中,我们看到了如何分析文本和结构化数据。这涵盖了所有内容吗?远远没有!到目前为止,最大的数据部分是以图像和视频的形式存在的。例如,仅视频就占互联网交换的总数据量的令人印象深刻的三分之二!在本章中,我们将看到语言模型如何帮助我们从这些数据类型中提取有用的见解。

以下几节介绍了一些处理图像和视频数据的小型项目。GPT-4o 是一个本地的多模态模型;我们可以用它来完成所有这些任务。首先,我们将看到如何使用 GPT-4o 来回答关于图像的自由形式问题(用自然语言)。其次,我们将使用 GPT-4o 来构建一个自动图片标记应用程序,自动为我们的假日照片标记出现的人物。

最后,我们将使用 GPT-4o 来自动为视频文件生成标题。这些小型项目的目标是展示最新一代大型语言模型提供的视觉数据处理功能。完成这些项目后,你应该能够构建自己的图像和视频数据处理应用程序,用于各种场景。

6.1 设置

你还需要安装一个额外的 Python 包来运行示例代码。具体来说,你需要 OpenCV,这是一个用于图像处理的库。在终端中,运行以下命令来安装 OpenCV:

pip install opencv-python==4.8.1.78

我们将使用这个库,例如,从磁盘读取图像并将视频分割成帧。

接下来,你需要安装一个额外的库,这将使你能够直接向 OpenAI 的网络服务发送请求(你将使用它来发送存储在本地的图片到 OpenAI):

pip install requests==2.31.0

做得很好!如果你在运行这些命令时没有遇到任何错误信息,那么你的系统现在已经配置好了,可以使用 GPT-4o 进行图像和视频数据分析。让我们从下一节开始我们的第一个项目。

6.2 回答关于图像的问题

在图像中检测物体(如汽车)的神经网络已经存在很多年了。那么使用 GPT-4o 处理图像有什么大不了的?

经典图像处理模型的局限性在于它们需要针对特定的分析任务进行训练。例如,假设你有一个神经网络,它非常擅长检测猫的图片。你可以用它来过滤掉你个人收藏中的猫的图片。然而,也许你对猫整体不感兴趣,但特别感兴趣的是金色波斯猫。除非你的模型被训练来检测这种特定的猫,否则你将无计可施,需要自己标记足够的示例图片。这样做很繁琐,你可能会最终不使用该模型,而是手动查看图片。GPT-4o(以及类似模型)进行图像处理的一大亮点是,它仅基于任务的描述(自然语言)就能解决一系列基于图像的任务。

我们将使用它来构建一个通用的图像问答系统。作为用户,你可以用自然语言提出任意问题,并指向一张图片,系统将生成文本答案。例如,要求系统检测图片中的“金色波斯猫”应该能够直接工作,而无需特定任务的训练数据。

6.2.1 指定多模态输入

在本节中,我们将创建一个系统,该系统接受两个输入:

  • 指向网络中图像的 URL

  • 关于图像的自然语言问题

输出是对问题的答案(以文本形式)。内部,系统使用 GPT-4o 处理输入图像上的问题。它生成多模态提示,结合多种类型的数据(在这里,文本和图像)。图 6.1 展示了示例提示:它包含一张图片(苹果的图片)和关于这张图片的问题(图片是否显示香蕉)。在这种情况下,正确的答案是“不”。

figure

图 6.1 包含图像和文本的多模态提示。提示指示语言模型判断图片是否显示香蕉。在这种情况下,预期的输出是“不”(否则“是”)。

我们如何为 GPT-4o 创建这样的提示?我们可以重用聊天完成端点。提醒一下,这个端点接受用户和(可能)系统之间交换的先前消息列表作为输入。对于我们的视觉问答系统,我们只需要一条消息(这条消息来自用户)。

与之前的代码不同,现在消息可以包含多模态内容。在这个特定的情况下,这种内容由一个文本片段(用户提出的问题)和一个图像(目前指定为 URL)组成。这是我们将在以下代码中使用的消息(question是一个包含问题文本的变量,而image_url是图像的 URL):

{'role':'user', 'content':[
    {'type':'text', 'text':question},  #1
    {'type':'image_url', 'image_url':{
        'url':image_url}}          #2
    ]
}

1 问题文本

2 图像 URL

首先,注意role属性标识消息是由用户生成的。其次,消息内容被指定为一个 Python 字典列表。每个字典描述消息的一个元素。由于我们现在考虑的是多模态数据——即图像和文本——我们需要明确每个输入元素的类型(或模态)。这是通过将type属性设置为textimage_url来实现的。实际内容使用text属性(1)或(在图像的情况下)image_url属性(2)来指定。GPT-4o 足够灵活,能够理解问题指的是图像,并适当地处理它们。

小贴士:虽然输入只包含一张图片,但消息的内容可能包含多个相同类型的元素:例如,多张图片。我们将在下一节的项目中利用这一功能。

6.2.2 代码讨论

以下列表展示了我们视觉问答系统的完整代码。以图像 URL 和问题作为输入(3),实际的魔法(即视觉问答)发生在analyze_image函数(1)中。

列表 6.1 通过语言模型回答图像问题
import argparse
import openai
import time

client = openai.OpenAI()

def analyze_image(image_url, question):       #1
    """ Use language model to answer questions about image.

    Args:
        image_url: URL leading to image.
        question: question about image.

    Returns:
        Answer generated by the language model.
    """
    for nr_retries in range(1, 4):
        try:
            response = client.chat.completions.create(
                model='gpt-4o',
                messages=[                  #2
                    {'role':'user', 'content':[
                        {'type':'text', 'text':question}, 
                        {'type':'image_url', 'image_url':{
                            'url':image_url
                            }
                        }]
                    }]
                )
            return response.choices[0].message.content
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

if __name__ == '__main__':

    parser = argparse.ArgumentParser()                   #3
    parser.add_argument('imageurl', type=str, help='URL to image')
    parser.add_argument('question', type=str, help='Question about image')
    args = parser.parse_args()

    answer = analyze_image(args.imageurl, args.question)
    print(answer)

1 回答关于图像的问题

2 多模态内容

3 输入参数

正如你所见,只需几行 Python 代码就能回答关于图像的问题!函数analyze_image1)仅包含对 GPT-4o 的一次调用,使用的是前一小节中描述的消息(2)。我们现在提供多模态输入并不会改变答案的格式。再次强调,我们得到的是一个包含由语言模型生成的消息的对象。尽管输入现在可能是多模态的,但输出仍然是文本。正如我们指示语言模型生成对输入问题的答案(3)(即用户所寻找的确切答案),输出会直接打印给用户。

6.2.3 尝试运行

是时候测试我们的视觉问答系统了!你回到了香蕉公司(一家生产各种消费电子产品,包括笔记本电脑和智能手机,在第二章中介绍)并寻找一个新的公司标志。你希望以香蕉的图片为基础设计你的标志。在网上搜索后,你找到了大量水果图片的仓库。但哪一个是香蕉?你更愿意手动逐个查看图片,而不是将这项任务委托给语言模型。幸运的是,你可以直接使用前一小节中的代码,通过指定每张水果图片的 URL,以及问题“这是香蕉吗?(“是”,“否”)”。这意味着你实际上是将视觉问答系统作为一个分类方法(这仅仅是许多可能用例之一)来使用。然后你可以编写一个简单的脚本,遍历所有相关的 URL,并保留那些回答为“是”的 URL。

在书的配套网站上,你可以找到列表 6.1 中的代码以及水果图片(寻找标记为 Fruit 1 到 Fruit 5 的链接)。下载代码,在终端中切换到包含的存储库,并运行以下代码:

python [URL] 'Is this a banana ("Yes","No")?'

在这个命令中,将 [URL] 替换为图片的 URL(例如,你可以通过复制书网站上“水果 1”的链接来获取合适的 URL)。

物体分类相对容易,尤其是像香蕉这样常见的物体。因此,你应该看到大多数示例都能得到准确的结果。尝试几种不同的水果,以及可能的其他你选择的图片。你可以提出的问题范围几乎是无限的(不考虑相对宽裕的输入长度限制,即 128,000 个标记,大约 300 页的文本)。

在处理成本方面,可能需要提醒一下。通过 GPT-4o 处理图像可能会很昂贵!确切的成本取决于图像大小和图像处理的详细程度。你可以使用detail参数来控制精确度。例如,使用以下图像 URL 的指定(在模型输入中)选择低精度:

{'type':'image_url', 'image_url':{'url':image_url, 'detail':'low'}}

detail属性设置为low,以支付相当于每张图像 85 个标记的成本(即使用 GPT-4o 处理包含 85 个标记的文本的成本)。如果你将详细程度设置为high(默认值),成本包括一个固定数量的 85 个标记和一个可变数量,这取决于图像大小。为了计算可变成本部分,我们首先将图像缩放到 2,048 × 2,048 像素的大小(同时保持宽高比)。这一缩放步骤仅适用于超过 2,048 × 2,048 像素的图片。第二个缩放步骤无论如何都会执行。它将图像的较短边缩放到 768 像素的大小。现在考虑在第二次缩放步骤之后覆盖图像所需的 512 × 512 像素方块的最低数量。可变成本部分与方块的数量的乘积成正比,乘以 170 个标记(由 OpenAI 设定的每平方的成本)。

例如,假设我们想要以高精度处理一个 1,024 × 1,024 像素大小的图像。在这种情况下,我们可以跳过第一个缩放步骤,因为图像仍然适合在一个 2,048 × 2,048 像素的方块内。然而,第二个缩放步骤无论如何都会执行。它将图像缩放到 768 × 768 像素的大小。为了覆盖两边长度为 768 像素的方块,我们需要四个 512 × 512 像素的方块。这意味着为了处理我们的图像,我们需要支付以下费用:

  • 85 个标记(固定成本部分)

  • 4 × 170 个标记 = 680 个标记(可变成本部分)

因此,我们总共支付 85 + 4 × 170 = 765 个标记。根据当前价格,这相当于 0.003825 美元(即不到一美分)。尽管这可能看起来是可以接受的,但在通过语言模型处理大量图像库时,始终要考虑成本。

小贴士:要查找处理具有特定分辨率的图像的价格,您还可以使用 OpenAI 价格计算器:openai.com/pricing

6.3 在图像中标记人

想象以下情况:你刚刚和朋友(应得的)度假回来,当然,你拍了很多照片。你想要发送出现你朋友的照片。但如何高效地做到这一点?你可以手动浏览照片并为每个朋友单独标记。但是,刚刚度假回来,你的邮箱已经爆满,你没有时间浏览假期照片。你能做什么?

6.3.1 概述

在本节中,我们将创建一个小应用程序来自动在图像中标记人。用户提供三个输入:

  • 包含要标记的图片的目录路径

  • 包含要寻找的人的图片的目录路径

  • 将标记的图片写入的输出目录的路径

为了简化问题,我们将使用文件名来表示标签。我们假设显示要寻找的人的图片以显示的人的名字命名。例如,假设我们在包含要寻找的人的目录中有名为 Joe.png 和 Jane.png 的图片。给定一个要标记的图片,我们将简单地通过在其名称前加上图片中出现的名字来更改文件名。

例如,假设我们有一个名为 beach.png 的图片,其中 Joe 和 Jane 都出现了。那么,在输出目录中,我们将创建两个名为 Joebeach.png 和 Janebeach.png 的文件,显示他们都在海滩图片中。如果我们想发送显示同一人的所有图片,例如 Joe,我们可以搜索所有名称满足正则表达式Joe*.png(其中*代表任意字符串)的文件。

在内部,作为第一步,我们需要加载代表要寻找的人的图片以及要标记的图片。我们将考虑每一对人要寻找的人和要标记的图片。例如,如果我们正在寻找五个人,并且有 10 张图片要标记,那么我们需要考虑 50 对人。对于这些对中的每一个,我们使用 GPT-4o 来判断对应的人是否出现在要标记的图片中。

要实现这一点,我们需要包含文本和两张图片的多模态提示。第一张图片显示要寻找的人,第二张图片显示要标记的图片。通过文本,我们可以指示语言模型比较图片以判断是否出现同一个人。每当我们发现匹配项——即出现该人的一个人和图片的组合——我们就会将相应的图片复制到输出文件夹,并在其名称前加上该人的名字。

图 6.2 展示了一个示例提示。在左侧,我们有一张 Jane 的照片,她是我们要找的人之一。在右侧,我们有一张需要标记的照片。文本指令要求语言模型比较这两张图片,如果它们显示的是同一个人(否则为“否”),则产生答案“是”。在这种情况下,图片显示的不是同一个人,正确的答案应该是“否”。

figure

图 6.2 多模态提示包含两张图像和文本:提示要求语言模型检查这两张图片是否显示的是同一个人(预期答案:“是”)或不是(预期答案:“否”)。

6.3.2 本地存储图像编码

在上一节中,我们使用 GPT-4o 分析了网上的图片。现在我们正在谈论我们私人的假日照片。我们可能不希望将它们全部公开在网络上。那么我们如何仅与 GPT-4o 分享它们呢?

我们可能需要将图像转换为适合 GPT-4o 的格式。GPT-4o 支持广泛的图像格式,包括 PNG、JPEG、WEBP 和 GIF。对于任何格式,图像文件大小目前限制为 20 MB。要将支持的类型的图片上传到 GPT-4o,我们首先需要使用 base64 编码对它们进行编码。

base64 编码是什么?

base64 编码是将二进制数据编码为可打印字符串的一种方法。正如其名称 base64 所暗示的,我们用于字符串的字母表基于 64 个字符。这意味着我们可以使用六个比特(因为六个比特可以表示 2⁶ = 64 个可能的字符)来表示每个字符。由于计算机以字节(即 8 比特)的粒度存储数据,因此将三个字节的组(即 24 比特)一起编码是很方便的。使用 base64 编码,三个字节可以用来表示四个字符(因为 24/6 = 4)。

在 Python 中,我们可以使用 base64 库以 base64 格式编码二进制数据。以下代码打开存储在 image_path 的图像文件,并使用 base64 格式对其进行编码:

with open(image_path, 'rb') as image_file:
    encoded = base64.b64encode(image_file.read())

我们已经将二进制图像数据转换成了 base64 格式的字符串。在将此类图像发送到 GPT-4o 之前,我们仍然需要进行最后一次转换:我们必须使用 UTF-8 编码来表示该字符串。

UTF-8 编码是什么?

UTF-8 是一种表示字符串数据的方式。它非常流行,大约有 98% 的网站使用 UTF-8。UTF-8 可以表示超过一百万个字符,覆盖各种语言。我们可以使用固定数量的字节来表示这些字符:每个字符四个字节。然而,这并不高效,因为它没有利用到某些字符比其他字符更常见的这一事实。如果我们用更少的字节来编码常见的字符,而将多字节表示留给不常见的字符,我们可以用更少的字节来表示相同的文本。这正是 UTF-8 所做的,而且由于不同的字符可能需要不同数量的字节来表示,它也被称为 可变长度标准。同时,UTF-8 被设计成与较旧的 ASCII 标准向后兼容,对于前 128 个字符使用与 ASCII 相同的编码。

要将我们的图像 base64 编码转换为 UTF-8,我们可以使用 Python 的 decode 函数。假设图像仍然编码在 encoded 字符串变量中,我们可以使用以下代码进行转换:

image = encoded.decode('utf-8')

结果图像,编码为 UTF-8 文本字符串,适合作为 GPT-4o 的输入。接下来,我们将看到如何以这种格式将图像上传到 OpenAI 平台。上传后,我们可以在提示中包含对这些图片的引用。图像通常指定为提示的组成部分:

{'type':'image_url', 'image_url':{'url':image_url}}

在这里,image_url 代表通向要分析的图像的 URL。之前,我们使用的是公开可访问的 URL。现在我们正在分析私有图像,这些图像将发送到 OpenAI,仅用于处理特定请求。假设 image 仍然代表作为字符串编码的图像,我们可以按以下方式设置图像 URL:

image_url = {'url':f'data:image/png;base64,{image}'}

此代码假设图像为 PNG 类型(如果不是,请将字符串 png 替换为适当的格式标识符,例如 jpeg)。URL 结合了关于图像的元数据(例如图像类型和编码)以及表示图片本身的字符串后缀。

6.3.3 将本地存储的图像发送到 OpenAI

我们将利用这个项目作为展示与 GPT 模型交互的替代方式的机会。这样做将使我们深入了解 OpenAI 的 Python 库内部工作原理。到目前为止,我们一直在使用 Python 包装器在后台向 OpenAI 的平台发送请求。为了将我们的本地图像发送到 GPT-4o,我们将自己创建这些请求。

我们使用 Python 的 requests 库来创建 HTTP 请求,将我们的提示(包括文本和图像)发送到 GPT-4o 并收集答案。更确切地说,我们将发送 HTTP POST 请求。这是 OpenAI 平台接受的请求类型。此类请求可以通过 requests.post 方法发送。

我们请求将包含 GPT-4o 解决我们感兴趣的任务(在这种情况下,验证两张图片是否显示同一人)所需的所有相关信息。首先,我们需要在请求中包含头信息。我们将使用以下头信息:

headers = {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer ...'
}

你可以看到我们正在指定一个 Python 字典作为头信息。对于我们的用例,我们只需要存储两个属性:我们有效负载的类型(我们计划发送 JSON 内容)和我们的访问凭证(三个点代表我们的 OpenAI 访问密钥)。

接下来,我们需要指定有效负载——即我们主要通过请求发送的内容:

payload = {
    'model': 'gpt-4o',  #1
    'messages': [
        {'role': 'user', 'content': ...}  #2
        ],
    'max_tokens':1  #3
    }

1 模型指定

2 第一条消息

3 输出长度

你可能会注意到有效负载中恰好包含我们在调用 completions.create 方法时通常会指定的字段。这不是巧合,因为后者方法在内部创建具有类似有效负载的请求。首先,有效负载指定了模型(1):gpt-4o(以便能够处理多模态输入提示)。我们指定了一个包含单个条目的消息列表(2)。这条消息被标记为来自用户(role:user),其内容用三个点缩写,将包含文本指令和图片。最后,我们将答案长度限制为单个标记(max_tokens:1)(3)。这很有意义,因为我们正在寻找二元结果:要么多个输入图像中出现了同一人(预期答案:“是”),要么没有(预期答案:“否”)。

在生成了头信息和有效负载之后,我们可以使用以下代码调用 GPT-4o:

response = requests.post(
        'https://api.openai.com/v1/chat/completions', 
        headers=headers, json=payload)

作为第一个参数,requests.post 的调用指定了发送请求的 URL。在这种情况下,https://api.openai.com/v1/chat/completions 表示我们想要执行类型为 Completion 的任务,使用 OpenAI 的一种聊天模型(这适用于 GPT-4o)。我们使用之前创建的头信息和有效负载。

响应中包含 GPT-4o 结果对象。我们可以通过以下代码片段访问答案(指示两张图片是否显示同一人):

response.json()['choices'][0]['message']['content']

6.3.4 端到端实现

现在我们已经准备好讨论端到端实现!列表 6.2 包含了在图片中标记人物的代码。首先查看主函数(4)。如前所述,用户指定三个目录作为命令行参数(5):一个包含要标记的图片的目录,一个包含用于标记的人物目录,以及一个输出目录。

作为第一步,我们加载所有要标记的图像以及所有要搜索的人物图像。我们使用load_images函数(1)来做这件事。这个函数检索输入目录中的所有文件列表,然后考虑那些以.png后缀结尾的文件(即,我们考虑所有 PNG 图像)。如前所述,我们需要将图像编码为字符串(通过 base64 编码),最终通过 UTF-8 编码表示。load_images的结果是一个 Python 字典,将文件名映射到相关的编码图像。这个字典作为函数的结果返回。

列表 6.2 在本地存储的图片中标记人物
import argparse
import base64
import os
import requests
import shutil

def load_images(in_dir):            #1
    """ Loads images from a directory.

    Args:
        in_dir: path of input directory.

    Returns:
        directory mapping file names to PNG images.
    """
    name_to_image = {}
    file_names = os.listdir(in_dir)
    for file_name in file_names:
        if file_name.endswith('.png'):
            image_path = os.path.join(in_dir, file_name)
            with open(image_path, 'rb') as image_file:
                encoded = base64.b64encode(image_file.read())
                image = encoded.decode('utf-8')
                name_to_image[file_name] = image

    return name_to_image

def create_prompt(             #2
    person_image, image_to_label): 
    """ Create prompt to compare images.

    Args:
        person_image: image showing a person.
        image_to_label: image to assign to a label.

    Returns:
        prompt to verify if the same person appears in both images.
    """
    task = {'type':'text', 
            'text':'Do the images show the same person ("Yes"/"No")?'}
    prompt = [task]
    for image in [person_image, image_to_label]:
        image_url = {'url':f'data:image/png;base64,{image}'}
        image_msg = {'type':'image_url', 'image_url':image_url}
        prompt += [image_msg]

    return prompt

def call_llm(ai_key, prompt):              #3
    """ Call language model to process prompt with local images.

    Args:
        ai_key: key to access OpenAI.
        prompt: a prompt merging text and local images.

    Returns:
        answer by the language model.
    """
    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {ai_key}'
    }
    payload = {
        'model': 'gpt-4o',
        'messages': [
            {'role': 'user', 'content': prompt}
            ],
        'max_tokens':1
        }
    response = requests.post(
        'https://api.openai.com/v1/chat/completions', 
        headers=headers, json=payload)
    return response.json()['choices'][0]['message']['content']

if __name__ == '__main__':  #4

    parser = argparse.ArgumentParser()             #5
    parser.add_argument('peopledir', type=str, help='Images of people')
    parser.add_argument('picsdir', type=str, help='Images to tag')
    parser.add_argument('outdir', type=str, help='Output directory')
    args = parser.parse_args()

    people_images = load_images(args.peopledir)
    unlabeled_images = load_images(args.picsdir)

    for person_name, person_image in people_images.items():  #6
        for un_name, un_image in unlabeled_images.items():   #7
            prompt = create_prompt(person_image, un_image)
            ai_key = os.getenv('OPENAI_API_KEY')
            response = call_llm(ai_key, prompt)
            description = f'{un_name} versus {person_name}?'
            print(f'{description} -> {response}')

            if response == 'Yes':              #8
                labeled_name = f'{person_name[:-4]}{un_name}'
                source_path = os.path.join(args.picsdir, un_name)
                target_path = os.path.join(args.outdir, labeled_name)
                shutil.copy(source_path, target_path)

1 从磁盘加载图像

2 创建多模态提示

3 为提示生成答案

4 使用人物标记图像

5 命令行参数

6 关于人

7 关于图像

8 在匹配的情况下复制图像

在将load_images函数应用于两个输入目录中的每个目录之后,我们最终得到两个 Python 字典。一个将显示人物的图像的文件名(按照惯例,这些名称是那些人的名字)映射到相应的编码图像。另一个将待标记的图像的文件名映射到编码图像。

我们的目标是将每个要标记的图片与其中出现的所有人物匹配。由于我们一次只比较两张图片的提示,我们需要查看每个人物和要标记的图像的组合。这就是为什么我们使用双重嵌套的for循环:一个遍历人物(6),另一个遍历要标记的图像(7)。

对于每个要标记的图像和人物的组合,我们使用create_prompt创建一个多模态提示。这个函数(2)将编码后的图片和文本指令组合成一个提示。文本指令(“这些图像显示的是同一个人吗?(是/否)?”)定义了任务以及预期的输出格式(“是”或“否”)。每个提示都通过call_llm发送给 GPT-4o。如前所述,这个函数(3)使用请求 API 将本地存储的图像和文本指令发送给 GPT-4o。如果 GPT-4o 回答“是”,则当前考虑的人物出现在当前考虑的标记图像中。

如果人物出现在图像中(8),我们将按照以下方式标记图像。我们使用人物的名字(关联图片文件的名字,不带.png后缀)并将其添加到要标记的文件名之前。接下来,我们使用新的文件名(表示标记结果)将待标记的文件复制到输出目录。

6.3.5 尝试一下

让我们试试!如果你有要标记的真实假期照片,你可以使用它们。否则,你可以在本书的配套网站上找到合适的测试数据。查找标记链接以访问一个压缩文件;下载此文件并解压缩其内容。解压缩后,你应该在结果文件夹中看到三个子目录:

  • people—一个包含人物图片的文件夹(在这种情况下,是《复仇者联盟》系列中的演员)。文件名包含相应演员的名称。

  • pics—另一组图片(在这种情况下,与 people 文件夹中相同的演员的更多图片)要标记上演员的名称。

  • processed—一个空文件夹,可以用作输出目录。

我们假设解压缩的文件夹存储在路径 /tagging 下(例如,路径 /tagging/people 将指向包含要搜索的人的图片的子文件夹)。通过在终端中运行以下命令来执行代码:

python listing2.py /tagging/people  /tagging/pics /tagging/processed

提示:如果你在 Windows 平台上调用代码,你必须适应这些路径。特别是,你必须用 \ 替换 /。

在处理过程中,实现会打印出有关特定人物是否出现在特定图片中的更新。样本数据包含两个要查找的人物和四个要标记的图片。这意味着处理不应超过几分钟(通常是少于两分钟)。

处理完成后,查看输出文件夹。你应该会看到要标记的图片,图片前缀是其中出现的人物名称。对于几行 Python 代码来说,这已经很不错了!

6.4 为视频生成标题

除了许多图片(我们现在可以自动标记,多亏了上一节中概述的代码!)之外,你在度假期间还拍摄了许多视频。自动分配的文件名并不很有信息量。哪个视频是你在大海里游泳的那个?如果能给这些视频分配有意义的标题,帮助你更快地找到你想要的视频那就太好了。但谁有那么多时间去手动标记视频呢?再次,我们可以使用语言模型来自动完成这项任务。

6.4.1 概述

我们将开发一个系统,该系统可以自动为视频分配合适的标题。该系统在后台使用 GPT-4o。为了给视频分配标题,我们将提交包含视频帧(即图像)和指示语言模型生成标题的文本的多模态提示。图 6.3 展示了一个示例提示。

figure

图 6.3 视频处理的多模态提示:基于视频帧的选择,指示语言模型生成一个合适的标题。

它由多个视频帧组成(我们在图 6.3 中只看到第一帧和最后一帧;三个点代表中间的帧)和文本指令“为视频生成一个简洁的标题。”请注意,我们必须为提交给 GPT-4o 的每个视频帧付费。这意味着通过 GPT-4o 进行视频数据处理很快就会变得昂贵!

作为回答,GPT-4o 应该发送回一个合理的标题。在图 6.3 中显示的示例中,这可能是对汽车的引用,甚至可能是对位置的引用(在帧中显示为白色文本)。

6.4.2 视频帧编码

首先,我们需要讨论视频格式。在上一个章节中,我们看到了如何编码存储在本地的图像。现在我们将这个概念扩展到视频。最终,我们的目标是提取一系列帧。然而,视频通常不是以帧序列的形式存储,而是使用更高效的编码。对我们来说,这意味着我们首先必须从视频中提取图像。

我们将使用 OpenCV 库来完成这个任务。OpenCV 是开源计算机视觉库。它提供了计算机视觉以及图像和视频处理的各种功能。当然,我们将使用 GPT-4o 来完成计算机视觉部分。尽管如此,OpenCV 对于从视频中提取帧来说仍然很有用。如果您还没有这样做,现在是一个好时机,按照第 6.1 节中的说明设置 OpenCV。

假设安装已经成功,并且您可以从 Python 访问 OpenCV。相应的 Python 库被称作cv2(您将在接下来的代码片段中经常看到这个名字作为前缀)。

要处理存储在本地的视频,我们首先需要打开相应的文件。运行以下代码以打开存储在video_path路径下的视频:

video = cv2.VideoCapture(video_path)

使用变量video,我们现在可以通过read方法读取视频的内容:

success, frame = video.read()

结果由包含两个组件的元组组成:一个success标志和一个视频帧。success标志指示我们是否能够读取另一个帧。一旦我们到达视频的末尾,这种情况就不再成立。在这种情况下,我们不会获得有效的帧,success标志被设置为False

假设我们能够读取另一个帧。在这种情况下,我们将帧转换成可以发送给 GPT-4o 的图像。OpenCV 为我们提供了相应的功能:

_, buffer = cv2.imencode('.jpg', frame)

imencode函数将视频帧转换成相应类型的图像。在这里,我们将帧转换成 JPEG 图片。从生成的元组中,第二个组件(buffer)对我们来说很有趣。它包含对应图片的二进制表示。

这是我们之前章节中提到的情况:我们有一个图像的二进制表示,并希望将其转换成适合 GPT-4o 的格式。同样,我们首先通过 base64 编码将图像编码为字符串,然后通过 UTF-8 表示该字符串:

encoded = base64.b64encode(buffer)
frame = encoded.decode('utf-8')

生成的frame被正确编码,可以作为 GPT-4o 提示的一部分。一旦您处理完视频,请使用以下代码关闭相应的视频捕获对象:

video.release()

接下来,我们将把所有这些整合起来,为任意视频生成标题。

6.4.3 端到端实现

列表 6.3 为存储在本地的视频生成标题。唯一的输入参数是视频的路径。鉴于这一点,实现提取了一些视频帧(4),然后生成一个提示,指示 GPT-4o 根据帧样本生成视频标题。将此提示发送给语言模型后,答案包含一个建议的视频标题。

列表 6.3 通过语言模型生成视频标题
import argparse
import cv2
import base64
import openai
import time

client = openai.OpenAI()

def extract_frames(video_path):    #1
    """ Extracts frames from a video.

    Args:
        video_path: path to video file.

    Returns:
        list of first ten video frames.
    """
    video = cv2.VideoCapture(video_path)
    frames = []
    while video.isOpened() and len(frames) <= 10:
        success, frame = video.read()
        if not success:
            break

        _, buffer = cv2.imencode('.jpg', frame)
        encoded = base64.b64encode(buffer)
        frame = encoded.decode('utf-8')
        frames += [frame]

    video.release()
    return frames

def create_prompt(frames):                     #2
    """ Create prompt to generate title for video.

    Args:
        frames: frames of video.

    Returns:
        prompt containing multimodal data (as list).
    """
    prompt = ['Generate a concise title for the video.']
    for frame in frames[:10]:
        element = {'image':frame, 'resize':768}
        prompt += [element]
    return prompt

def call_llm(prompt):                             #3
    """ Query large language model and return answer.

    Args:
        prompt: input prompt for language model.

    Returns:
        Answer by the language model.
    """
    for nr_retries in range(1, 4):
        try:
            response = client.chat.completions.create(
                model='gpt-4o',
                messages=[
                    {'role':'user', 'content':prompt}
                    ]
                )
            return response.choices[0].message.content
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

if __name__ == '__main__':

    parser = argparse.ArgumentParser()
    parser.add_argument('videopath', type=str, help='Path of video file')
    args = parser.parse_args()

    frames = extract_frames(args.videopath)  #4
    prompt = create_prompt(frames)
    title = call_llm(prompt)
    print(title)

1 提取视频帧

2 创建多模态提示

3 查询语言模型

4 标题视频

代码使用 extract_frames1)提取视频帧。如前所述,此函数使用 OpenCV 库打开视频以提取帧,然后连续读取每个帧。我们将只使用最多 10 帧来生成视频标题。这就是为什么提取在最多 10 帧(或更少,如果视频非常短)后结束。每个提取的帧都根据 GPT-4o 的要求进行编码(即,将 JPEG 图像编码为字符串)。函数的结果是编码帧的列表。

在提示生成(2)过程中,我们将相关的文本指令(“为视频生成一个简洁的标题。”)与视频的前 10 帧结合起来。为了将那些图像以及指令发送到 GPT-4o,我们再次使用 Python 包装器(3)。或者,我们可以自己创建请求(如前一个项目)。语言模型的响应应包含适合我们视频的适当标题。

当然,我们只发送视频的前几帧。如果视频内容在这几帧之后发生剧烈变化,标题可能不是最优的。我们只发送 10 帧的原因是计算费用。请记住,你为提示中提交的每一张图片都要付费!发送较大视频的所有帧通常费用高昂。这就是为什么我们只满足于发送视频帧的小子集。

6.4.4 尝试一下

让我们试试我们的视频标题生成器!在书的配套网站上,这一章节的部分包含一个“汽车”链接,它会引导你到一个交通摄像头的短视频,展示繁忙道路上的交通情况。将视频下载到你的本地机器上。

打开终端,切换到包含本章代码的目录。我们将假设视频被下载到同一个目录中(如果不是,请将视频名称 cars.mp4 替换为指向它的完整路径)。

运行以下命令:

python listing3.py cars.mp4

经过几秒钟的计算时间,你应该会看到一个视频标题的提案:例如,“I-5 号公路在 SR 516 和 188 街的交通状况”(精确标题可能因随机化而有所不同)。

注意,标题整合了仅以文本形式存在于视频中的信息——地点的名称。使用 GPT-4o 从图像中提取文本可能在各种场景中很有用:例如,从表格中提取数据。

摘要

  • GPT-4o 可以处理图像和文本。

  • 提示可以集成文本片段和图像。

  • GPT-4o 支持多种图像格式。

  • 可以通过公共图像 URL 指定图像。

  • 本地存储的图像可以上传到 OpenAI。

  • GPT-4o 以字符串编码处理图像。

  • 与处理文本相比,处理图像的成本较高。图像处理的成本可能取决于图像大小。以低分辨率处理图像可以降低成本。

  • base64 库可以将图像编码为字符串。

  • 将视频分解成帧以发送给 GPT-4o。

  • 可以使用 OpenCV 库从视频中提取帧。

第七章:分析音频数据

本章涵盖

  • 转录音频数据

  • 翻译音频数据

  • 生成语音

观看任何可信的科幻电视剧或电影,您不会看到人们通过键盘与他们的计算机交互!无论是 1960 年代发布的《星际迷航》还是《2001 太空漫游》(两者都是 1960 年代发布的),人们都是对着(而不是输入)他们的机器说话。而且有很好的理由!对于大多数用户来说,语音是最自然的交流形式(因为他们就是从那里开始的)。难怪人们早在技术上可行之前就想象与计算机说话了。

现实已经赶上了科幻小说,包括亚马逊的 Alexa、谷歌助手、微软的 Cortana(以及其他许多)在内的语音助手无处不在。最新一代的语音识别(和语音生成)模型已经达到了接近人类熟练程度的水平。当然,与计算机的基于语音的交互只是这项令人惊叹的技术的一个用例。

在本章中,我们将使用 OpenAI 的最新模型进行语音转录、翻译和语音生成,以进行几个小型项目。首先,我们将看到将语音录音转录成文本只需几行 Python 代码。之后,我们将探讨更复杂的应用,从第五章中基于语音的自然语言数据库查询界面开始。以前我们不得不输入问题,而现在我们可以简单地说话,系统将给出答案。最后,我们将看到如何构建一个同声传译器,将我们的语音输入转换成不同语言的语音输出。

7.1 前期准备

在我们开始所有这些酷炫的项目之前,我们需要执行一些设置步骤。首先,您需要通过计算机记录语音输入。为此,您首先需要某种麦克风。如今,大多数笔记本电脑都内置了麦克风。它不一定要是专业麦克风;任何可以在计算机上记录声音的方式都行。但除了麦克风之外,您还需要可以从 Python 激活的软件,将音频录音转换为文件。为此,我们将使用 Python 的sounddevice库。在终端中运行以下命令以安装此库的正确版本:

pip install sounddevice==0.4

此库与 Python 的scipy库交互,您也应该安装它。在终端中运行以下命令:

pip install scipy==1.11

这些库共同使用,将使您能够记录语音输入(然后您可以使用 OpenAI 的模型将其转录、翻译或总结)。

我们已经涵盖了输入方面,那么输出呢?对于以下的一些项目,我们不仅想听音频,还想生成它!为了生成语音,我们再次将使用 OpenAI 的生成式 AI 模型。但在将语音存储在音频文件中之后,我们仍然需要合适的库来从 Python 在我们的计算机上播放语音。我们将使用 playsound 库来做这件事。在终端中运行以下命令来安装这个库的正确版本:

pip install playsound==1.3

在某些操作系统(特别是 macOS)上,你还需要使用以下命令安装 PyObjC 库:

pip install PyObjC==10.0

如果你在处理上一章时还没有这样做,请安装 requests 库(这将使你能够直接向 OpenAI 的 API 发送请求):

pip install requests==2.31.0

干得好!如果你在运行这些命令时没有遇到任何错误信息,那么你的系统现在已经配置好了,可以使用 OpenAI 的 Transformer 模型处理音频数据。让我们从下一节开始我们的第一个项目。

7.2 转录音频文件

在香蕉公司刚刚开始你的工作后,你被会议的数量压倒了。要参加的会议实在太多了,但你又不希望错过任何重要的内容!幸运的是,香蕉公司有很好的意识,通常情况下会创建所有员工会议的音频记录(在所有与会者的同意下)。但听所有这些会议的录音仍然太耗时了。

如果能有一份会议记录,将使你能够通过简单的文本搜索快速找到与你单位相关的任何内容。不幸的是,香蕉公司并没有提供这样的记录,而且你的同事们都不愿意在那些会议中做笔记。自动创建这样的记录是否可能?在本节中,我们将看到这不仅可能,而且实际上很容易创建这样的自动化转录服务。

7.2.1 转录语音

对于语音转文字,我们将使用 OpenAI 的 Whisper 模型。与迄今为止我们使用的模型(特别是 GPT 模型)不同,Whisper 专门针对音频转录。

Whisper 模型是什么?

Whisper 是一个在大量音频记录上训练的 Transformer 模型(确切地说,超过 68 万小时的记录!)!Whisper 在一个多语言音频语料库上进行了训练,因此支持广泛的输入语言,并将其转录成英语(即,你可以在一个步骤中完成语音转录和翻译)。

与 GPT 变体类似,我们将通过 OpenAI 的 Python 库访问 Whisper。这意味着你不需要在本地机器上进行任何额外的设置(假设你已经按照第三章所述安装了 OpenAI 的 Python 库)。

在本节中,我们将使用 Whisper 模型将音频文件转录到磁盘。假设我们的音频文件最初存储在磁盘上。Whisper 支持广泛的文件格式:MP3、MP4、MPEG、MPGA、M4A、WAV 和 WEBM。在撰写本文时,文件大小限制为 25 MB。给定这样一个文件,假设其文件路径存储在变量 audio_path 中。现在,将内容转录为文本只需要以下几行 Python 代码:

import openai
client = openai.OpenAI()

with open(audio_path, 'rb') as audio_file:  #1
     #2
    transcription = client.audio.transcriptions.create(
        file=audio_file, model='whisper-1')

1 打开音频文件

2 转录内容

作为第一步(1),我们需要打开我们的音频文件。为此,我们可以使用 Python 的 open 命令。注意 open 命令参数中使用了 rb 标志。这个标志表示我们想要读取文件(r)并且我们正在打开一个二进制文件(b)。二进制文件是不包含可读字符的文件。例如,我们试图打开的这种声音文件通常被认为是二进制文件。在处理第一行之后,文件内容可以通过变量 audio_file 访问。

作为第二步(2),我们执行实际的转录。我们现在使用一个专门针对音频数据处理的不同端点。从这个端点,我们使用两个参数调用转录服务(transcriptions.create):

  • file—要转录的文件的引用

  • model—用于转录的模型名称

我们引用之前打开的文件(audio_file)并选择 whisper-1 作为我们的转录模型。转录的结果是一个包含转录文本和转录过程元数据的对象。我们可以通过 text 字段(即通过 transcription.text)访问转录的文本。在解压缩后,您应该在结果文件夹中看到三个子目录:

如您所见,转录文本只需几行 Python 代码!在下一小节中,我们将使用此代码构建一个简单的转录服务。

7.2.2 端到端代码

列表 7.1 展示了一个简单转录程序的代码。实际的转录发生在 transcribe 函数(1)中。这基本上是我们在上一节中讨论的代码。给定音频文件的路径作为输入,它返回转录的文本。

列表 7.1 将音频文件转录为文本
import argparse
import openai

client = openai.OpenAI()

def transcribe(audio_path):        #1
    """ Transcribe audio file to text.

    Args:
        audio_path: path to audio file.

    Returns:
        transcribed text.
    """
    with open(audio_path, 'rb') as audio_file:
        transcription = client.audio.transcriptions.create(
            file=audio_file, model='whisper-1')
        return transcription.text

if __name__ == '__main__':         #2

    parser = argparse.ArgumentParser()
    parser.add_argument('audiopath', type=str, help='Path to audio file')
    args = parser.parse_args()

    transcript = transcribe(args.audiopath)
    print(transcript)

1 将音频转录为文本

2 主要功能

主要功能(2)读取音频文件的路径(应包含语音)作为输入。在调用 transcriptions.create 函数后,它将在屏幕上打印转录的文本。

7.2.3 尝试一下

要尝试这个功能,我们首先需要一个包含录音的音频文件。只要它符合第 7.2.1 节中概述的格式和大小限制,您可以使用任何此类文件(包括您公司会议的录音,如果有的话)。然而,请注意,您需要为每分钟处理的音频数据付费!在撰写本文时,通过 OpenAI 库使用 Whisper 的费用为每分钟 0.006 美元(您可以在openai.com/pricing找到更多关于定价的最新信息)。因此,处理长录音可能会很昂贵。

如果您不想使用自己的录音,请查看本书的配套网站。您可以在本章的音频项目中找到一个简短的录音。下载此录音以用于转录(默认情况下,文件名应为 QuoteFromTheAlchemist.mp3)。

列表 7.1 也可在本书的配套网站上找到(第七章的 item listing1.py)。下载后,在终端中切换到相应的仓库。假设您已将音频文件下载到当前目录,请在终端中运行以下命令以转录样本文件:

python listing1.py QuoteFromTheAlchemist.mp3

如果一切顺利,您应该在终端中看到以下输出(针对网站上的样本文件):

Two years ago, right here on this spot, 
I had a recurrent dream, too.

点击样本文件自行收听;您会发现转录内容非常准确!接下来,我们将将语音转录集成到更复杂的应用中。

7.3 通过语音查询关系数据

分析表格数据很有趣!在 Banana 的工作中,您的大部分工作都包括仔细查看数据表,提取见解,并准备相应的报告和可视化。您正在使用第五章中介绍的文本到 SQL 接口,来自动将文本问题翻译成正式查询(用 SQL 编写),执行它们,并展示查询结果。这使得数据分析变得更容易,并且比从头开始编写复杂的 SQL 查询要快。

然而,存在一个问题:您在分析数据时,在办公室来回走动时思考得更好。但是,每次键入查询都会让您回到办公桌前。我们能否修改我们的查询界面,使其接受语音输入,而不是键盘输入?事实证明,我们确实可以!在本节中,我们将了解如何使用 OpenAI 的模型为表格数据启用简单的语音查询界面。

7.3.1 前期准备

我们将构建一个处理表格数据语音问题的语音查询界面。它是第五章中讨论的查询界面的扩展。我们假设语音问题指的是存储在 SQLite 中的数据,SQLite 是一个流行的关系数据查询处理系统。请参阅第五章以获取 SQLite 的简要介绍和安装说明。要尝试以下代码,您首先需要安装 SQLite 数据库系统。

SQLite 系统处理以 SQL(结构化查询语言)表述的查询。幸运的是,你不需要自己编写 SQL 查询(我们将使用语言模型来为我们编写这些 SQL 查询)。然而,语言模型并不完美,有时可能会产生错误的查询。为了识别这些情况,具备一定程度的 SQL 背景是有用的。你将在第五章中找到 SQL 的简要介绍。更多详情,请参阅www.databaselecture.com

我们的语音查询界面处理口语问题,因此你需要确保你的麦克风正在工作。此外,为了执行以下代码,请确保你的语音查询界面拥有访问麦克风的全部所需权限。

7.3.2 概述

我们的语音查询界面处理存储在 SQLite 数据库中的表格数据上的口语问题。例如,加载了一个包含关于电脑游戏销售数据的数据库后,我们可以提出如下问题:

  • “Activision 在 2023 年卖出了多少款游戏?”

  • “2019 年至 2021 年之间发布了多少款动作游戏?”

在接收到一个口语问题后,语音查询界面执行以下步骤:

  1. 将口语问题转录成文本

  2. 将文本问题翻译成 SQL 查询

  3. 使用 SQLite 处理数据上的 SQL 查询

  4. 向用户显示查询结果

图 7.1 更详细地说明了不同的处理步骤。这个过程为每个口语问题执行。

figure

图 7.1 我们的语音查询界面将口语问题转录成文本,将文本问题翻译成 SQL 查询,并最终处理这些查询并显示查询结果。

7.3.3 录制音频

对于我们的转录应用程序,我们假设已经有一个音频录音可用。对于我们的新项目,我们希望反复发出语音查询。这意味着我们必须自己录制它们。我们如何在 Python 中做到这一点?首先,我们需要导入两个库,专门用于这个目的:

import sounddevice       #1
import scipy.io.wavfile  #2

1 录制音频

2 存储.wav 文件

sounddevice库(1)包含许多用于从麦克风录制音频输入的有用函数。我们将如何处理我们的录音?我们将把它们作为.wav 文件存储在磁盘上。在前一节中,我们看到了如何转录存储在该格式的音频数据。这就是第二个库(2),scipy发挥作用的地方:它使我们能够将录音以.wav 格式存储在磁盘上。

在录制时,我们需要做出两个重要的选择:

  • 我们应该以什么采样率从麦克风读取输入?

  • 我们应该录制多少秒的语音?

我们将记录 5 秒钟。5 秒钟对于大多数语音查询应该足够了。如果你发现录音太早结束,或者你在完成语音查询后经常等待,你可以尝试不同的设置。更复杂的实现会持续录音或检测到说话暂停后停止录音。为了简化录音机制,我们将为每个语音查询预先设定记录时间。

对于采样率——即每秒存储的音频数据点数量——我们将选择 44,100 赫兹。这是 CD 质量录音的标准。总帧数——接收到的总音频数据点数量——是我们要记录的秒数(在我们的例子中是 5 秒)的 44,100 倍。我们将帧数和采样率存储在辅助变量中:

sample_rate = 44100
nr_frames = 5 * sample_rate

现在我们可以使用sounddevice库的rec函数开始录制:

recording = sounddevice.rec(                   #1
    nr_frames, samplerate=sample_rate, channels=1)
sounddevice.wait()                             #2

1 设置录制

2 等待录制完成

第一个命令(1)从输入麦克风开始录制,提供要录制的总帧数以及采样率。通道数(我们调用中的第三个参数)取决于用于录制的麦克风。如果你的麦克风有多个通道,请在这里尝试更高的值。开始录制后,我们只需等待预定录制时间过去。我们通过wait命令(2)实现这一点。

执行前面的代码后,变量recording包含录制的音频数据。如前所述,我们希望将录音存储为磁盘上的.wav 文件。只需scipy库的单个命令即可完成:

scipy.io.wavfile.write(output_path, sample_rate, recording)

就这样!我们已经录制了几秒钟的音频输入,并将其存储在磁盘上的文件中。

7.3.4 端到端代码

列表 7.2 显示了我们的语音查询界面的代码。除了我们的默认库openaiargparse之外,我们导入(1)音频处理库(sounddevicescipy),以及sqlite3库(我们将需要它来处理 SQL 查询)和time库。后一个库是等待指定时间(语音输入)所必需的。接下来,我们将讨论列表 7.2 中引入的函数。

列表 7.2 使用语音命令查询 SQLite 数据库
import argparse  #1
import openai
import re
import scipy.io.wavfile
import sounddevice
import sqlite3
import time

client = openai.OpenAI()

def get_structure(data_path):               #2
    """ Extract structure from SQLite database.

    Args:
        data_path: path to SQLite data file.

    Returns:
        text description of database structure.
    """
    with sqlite3.connect(data_path) as connection:
        cursor = connection.cursor()
        cursor.execute("select sql from sqlite_master where type = 'table';")
        table_rows = cursor.fetchall()
        table_ddls = [r[0] for r in table_rows]
        return '\n'.join(table_ddls)

def record(output_path):                 #3
    """ Record audio and store in .wav file. 

    Args:
        output_path: store audio recording there.
    """
    sample_rate = 44100
    nr_frames = 5 * sample_rate
    recording = sounddevice.rec(
        nr_frames, samplerate=sample_rate, channels=1)
    sounddevice.wait()
    scipy.io.wavfile.write(output_path, sample_rate, recording)

def transcribe(audio_path):        #4
    """ Transcribe audio file to text.

    Args:
        audio_path: path to audio file.

    Returns:
        transcribed text.
    """
    with open(audio_path, 'rb') as audio_file:
        transcription = client.audio.transcriptions.create(
            file=audio_file, model='whisper-1')
        return transcription.text

def create_prompt(description, question):        #5
    """ Generate prompt to translate question into SQL query.

    Args:
        description: text description of database structure.
        question: question about data in natural language.

    Returns:
        prompt for question translation.
    """
    parts = []
    parts += ['Database:']
    parts += [description]
    parts += ['Translate this question into SQL query:']
    parts += [question]
    parts += ['SQL Query:']
    return '\n'.join(parts)

def call_llm(prompt):                             #6
    """ Query large language model and return answer.

    Args:
        prompt: input prompt for language model.

    Returns:
        Answer by language model.
    """
    for nr_retries in range(1, 4):
        try:
            response = client.chat.completions.create(
                model='gpt-4o',
                messages=[
                    {'role':'user', 'content':prompt}
                    ]
                )
            return response.choices[0].message.content
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

def process_query(data_path, query):        #7
    """ Processes SQL query and returns result.

    Args:
        data_path: path to SQLite data file.
        query: process this query on database.

    Returns:
        query result.
    """
    with sqlite3.connect(data_path) as connection:
        cursor = connection.cursor()
        cursor.execute(query)
        table_rows = cursor.fetchall()
        table_strings = [str(r) for r in table_rows]
        return '\n'.join(table_strings)

if __name__ == '__main__':   #8

    parser = argparse.ArgumentParser()
    parser.add_argument('dbpath', type=str, help='Path to SQLite data')
    args = parser.parse_args()

    data_structure = get_structure(args.dbpath)

    while True:  #9

        user_input = input('Press enter to record (type quit to quit).')
        if user_input == 'quit':
            break

        audio_path = 'question.wav'  #10
        record(audio_path)
        question = transcribe(audio_path)
        print(f'Question: {question}')

        prompt = create_prompt(data_structure, question)  #11
        answer = call_llm(prompt)
        query = re.findall('"`sql(.*)"`', answer, re.DOTALL)[0]
        print(f'SQL: {query}')

        try:                                       #12
            answer = process_query(args.dbpath, query)
            print(f'Answer: {answer}')
        except:
            print('Error processing query! Try to reformulate.')

1 导入库

2 提取数据库模式

3 录制音频

4 转写音频

5 创建文本到 SQL 提示

6 翻译为 SQL

7 处理 SQL 查询

8 处理语音查询

9 主循环

10 转写语音输入

11 SQL 翻译

12 执行 SQL 查询

我们处理指向关系数据库中数据的语音查询。要将语音命令翻译成以 SQL 形式表述的正式查询,我们需要了解一些数据库结构的知识。特别是,我们需要知道数据表和它们的列的名称(即,我们需要知道数据库模式)。函数get_structure2)检索创建数据库模式的命令。这些命令包含表和列的名称,以及与表列关联的数据类型。我们将使用这些命令作为提示的一部分,指导语言模型将问题翻译成 SQL 查询。

在我们可以将问题翻译之前,我们首先需要从麦克风录制它们。这就是函数record3)发挥作用的地方。它使用sounddevice库从麦克风录制 5 连续秒的音频输入。生成的音频录音以.wav 文件的形式存储在磁盘上,路径由函数输入(参数output_path)指定。严格来说,将音频输入存储为文件不是必要的(我们可以直接在内存中处理它)。然而,将音频输入存储在磁盘上对于调试目的可能很有用。如果我们系统无法将语音输入翻译成适当的查询,我们可以自己听音频文件来评估背景噪声水平和整体音频质量。如果麦克风设置不当(一个常见问题),我们的音频文件将只包含静音。

在从麦克风录制输入之后,我们首先希望将语音输入转录成文本。我们使用transcribe函数(4)来完成这项工作。给定音频文件的路径(在这种情况下,来自麦克风的录音音频输入),它返回使用 OpenAI 的 Whisper 模型(我们之前使用过的同一个模型)生成的转录文本。

接下来,我们希望将问题翻译成正式的 SQL 查询。当然,我们将使用语言模型来完成这项任务。create_prompt函数(5)生成一个合适的提示。提示包含之前提取的数据库描述、转录的问题和任务描述。call_llm函数(6)调用 GPT-4o,根据之前提到的提示将问题翻译成查询。最后,process_query函数(7)在数据库上处理生成的查询并返回查询结果。

是时候将所有这些整合在一起了!我们的语音查询界面以 SQLite 数据库文件的路径作为输入(8)。在提取数据库模式后,我们进入主循环(9)。每次迭代处理一个语音查询(除非用户输入 quit,在这种情况下程序将终止)。为了简化问题,我们在记录语音输入之前等待用户按下 Enter 键(一个更复杂的版本会持续记录)。之后,我们从麦克风记录语音输入。我们打印出转录的问题,并将录音本身作为 question.wav 存储在磁盘上(10)。接下来,我们将转录的文本翻译成查询(11),执行它(12)(如果查询不正确,我们需要异常处理!),并将结果展示给用户。

7.3.5 尝试一下

列表 7.2 是第七章节在本书网站上的第 2 个列表。下载代码,并在终端中切换到包含文件夹。

除了代码之外,我们还需要一个 SQLite 数据库来尝试我们的语音查询界面。在第五章中,我们讨论了如何设置一个包含有关电脑游戏销售信息的示例数据库。我们假设这个数据库存储在与你的代码相同的文件夹中,命名为 games.db(当然,你可以自由使用任何你喜欢的 SQLite 数据库来尝试语音查询界面)。现在在终端中输入以下命令:

python listing1.py games.db

将数据库文件的路径更新为你想要访问的路径。根据你的操作系统和安全设置,你可能需要为你的应用程序启用麦克风访问。在启用麦克风访问后,按 Enter 键,提出一个问题!例如,使用游戏数据库,你可以问“2007 年卖出了多少款游戏?”或者“每个游戏类型都发布了多少款游戏?”你应该看到如下输出:

Press enter to record (type quit to quit).
Question: How many games were released for each genre?
SQL: SELECT genre, COUNT(*) as num_games
FROM games
GROUP BY genre
Answer: ('Action', 3316)
('Adventure', 1286)
('Fighting', 848)
('Genre', 1)
('Misc', 1739)
('Platform', 886)
('Puzzle', 582)
('Racing', 1249)
('Role-Playing', 1488)
('Shooter', 1310)
('Simulation', 867)
('Sports', 2346)
('Strategy', 681)

此输出包括转录的问题、翻译的 SQL 查询以及查询结果(如果查询无法执行,则为错误消息)。显然,从语音问题到查询结果还有很长的路要走!在录音、转录或翻译中出现的错误会导致结果不正确。在信任查询结果之前,请务必检查附加输出以验证系统没有犯任何错误。

提示:如果你的语音界面只产生无意义的内容,请检查 question.wav 中的录音。如果你什么也听不到,请确保你的应用程序有权访问你的麦克风。默认情况下,应用程序通常没有访问麦克风的权限(这使得恶意软件更难监视你)。你需要更新你的安全设置以启用访问。

7.4 语音到语音翻译

巴黎的香蕉分支已经开始研究语言模型以及其在数据科学任务中的潜在应用。你在该领域的本地专家声誉日益增长,你的经理要求你向法国团队提供如何开始的建议。但有一个小问题:你不会说法语。当听说香蕉巴黎的员工会议大多用法语进行时,你正准备拒绝这项任务。但经过思考,你意识到这可能并不是一个不可逾越的障碍。尽管你不会说法语,但 GPT-4o 肯定可以!能否使用语言模型为你进行翻译?

你确实可以使用语言模型在多种语言之间进行翻译。在本节中,我们将创建一个翻译工具,它接受第一语言的口语输入并产生第二语言的口语输出。因为该工具产生口语输出,你甚至不需要学习法语发音。只需说英语,等待工具生成口语翻译。这样,你可以在与法国同事合作的同时,同时展示最先进语言模型的能力!

7.4.1 概述

我们的翻译工具处理口语输入。和之前一样,我们将使用 OpenAI 的 Whisper 模型将输入语音转录成文本。然后,我们将使用 GPT-4o 模型将文本翻译成另一种语言。在我们的示例场景中,我们使用法语作为目标语言。然而,由于 GPT-4o 等模型惊人的灵活性,我们的工具不会仅限于这一点!我们的工具将使用户能够指定目标语言作为输入,用作提示语言模型进行翻译的文本片段。

在生成文本翻译之后,我们还想生成一个口语版本。结果证明,我们可以使用另一个 OpenAI 模型将文本转换为各种语言的口语输出。图 7.2 展示了完整的处理流程,从第一语言的口语输入开始,到第二语言的口语输出结束。

figure

图 7.2 我们的翻译工具记录第一语言的口语输入,将输入转录成文本,将文本翻译成第二语言,并最终生成口语输出。

7.4.2 生成语音

图 7.2 中的流程需要几个转换。我们已经在之前的章节中看到了如何将口语输入转录成文本。通过语言模型进行文本翻译相对简单(让 GPT-4o 将一种语言翻译成另一种语言,它就会这样做)。我们仍然缺少将书面文本(例如,法语)转换为口语输出的方法。我们将在下一节讨论如何做到这一点。

OpenAI(以及其他提供商)提供多种文本到语音(TTS)模型。此类模型以书面文本为输入,生成语音输出。以下代码片段为文本字符串(存储在变量 speech_text 中)生成语音:

import openai
client = openai.OpenAI()

response = client.audio.speech.create(
    model='tts-1', voice='alloy', 
    input=speech_text)

在这个例子中,我们使用了一个新的端点(audio.speech)并使用三个参数配置了 create 方法:

  • model—用于生成语音输出的模型名称。我们使用 OpenAI 的 tts-1 文本到语音模型。

  • input—为这段文本生成语音输出。提交任何模型支持的语言中的文本(github.com/openai/whisper)。

  • voice—我们可以选择不同的语音进行语音输出。在这里,我们使用 alloy

那就是通过 OpenAI 生成语音输出的所有所需内容!我们已经知道如何转录语音以及如何在不同语言之间翻译文本,因此我们现在拥有了编写翻译工具所需的一切。

关于定价呢?

在撰写本文时,OpenAI 使用 TTS 模型进行文本生成时,每 1,000 个标记收费 1.5 美分,而高质量版本(TTS HD)则是这个价格的两倍。这些价格可能会随时间变化,因此请务必查看 OpenAI 的定价网站 (openai.com/pricing) 以获取最新信息。

7.4.3 端到端代码

列表 7.3 展示了我们翻译工具的完整代码。让我们首先讨论它导入的库(1)。除了每个项目至今包含的 openaiargparse 库之外,我们还导入了 sounddevicescipy 以记录和存储音频文件,以及 time 库以限制录音时间。

列表 7.3 将语音输入翻译为不同语言
import argparse  #1
import openai
import playsound
import requests
import scipy.io.wavfile
import sounddevice
import time

client = openai.OpenAI()

def record(output_path):                 #2
    """ Record audio and store in .wav file. 

    Args:
        output_path: store audio recording there.
    """
    sample_rate = 44100
    nr_frames = 5 * sample_rate
    recording = sounddevice.rec(
        nr_frames, samplerate=sample_rate, channels=1)
    sounddevice.wait()
    scipy.io.wavfile.write(output_path, sample_rate, recording)

def transcribe(audio_path):        #3
    """ Transcribe audio file to text.

    Args:
        audio_path: path to audio file.

    Returns:
        transcribed text.
    """
    with open(audio_path, 'rb') as audio_file:
        transcription = client.audio.transcriptions.create(
            file=audio_file, model='whisper-1')
        return transcription.text
 #4
def create_prompt(to_translate, to_language):
    """ Generate prompt to translate text to target language.

    Args:
        to_translate: translate this text.
        to_language: translate text to this language.

    Returns:
        Translated text.
    """
    parts = []
    parts += [f'Translate this text to {to_language}:']
    parts += [to_translate]
    parts += ['Translated text:']
    return '\n'.join(parts)

def call_llm(prompt):                             #5
    """ Query large language model and return answer.

    Args:
        prompt: input prompt for language model.

    Returns:
        Answer by language model.
    """
    for nr_retries in range(1, 4):
        try:
            response = client.chat.completions.create(
                model='gpt-4o',
                messages=[
                    {'role':'user', 'content':prompt}
                    ]
                )
            return response.choices[0].message.content
        except:
            time.sleep(nr_retries * 2)
    raise Exception('Cannot query OpenAI model!')

def generate_speech(speech_text):    #6
    """ Generates speech for given text.

    Args:
        speech_text: generate speech for this text.

    Returns:
        query result.
    """
    response = client.audio.speech.create(
        model='tts-1', voice='alloy', 
        input=speech_text)
    return response.content

if __name__ == '__main__':        #7

    parser = argparse.ArgumentParser()
    parser.add_argument('tolanguage', type=str, help='Target language')
    args = parser.parse_args()

    while True:  #8

        user_input = input('Press enter to record (type quit to quit).')
        if user_input == 'quit':
            break

        audio_path = 'to_translate.wav'  #9
        record(audio_path)
        to_translate = transcribe(audio_path)
        print(f'Original text: {to_translate}')
         #10
        prompt = create_prompt(to_translate, args.tolanguage)
        translated = call_llm(prompt)
        print(f'Translated text: {translated}')

        speech = generate_speech(translated)    #11
        with open('translation.mp3', 'wb') as file:
            file.write(speech)

        playsound.playsound('translation.mp3')  #12

1 导入库

2 录制音频

3 转录音频

4 生成翻译提示

5 使用语言模型

6 生成语音

7 将语音翻译为语音

8 主循环

9 转录输入

10 翻译为目标语言

11 生成语音输出

12 播放生成的语音

使用 playsound 库播放 OpenAI 模型生成的音频文件。因为我们通过 OpenAI 的 HTTP 接口生成语音,所以我们导入 requests 库来创建 HTTP 请求。接下来,我们将讨论列表 7.3 中使用的函数。

与之前的项目一样,我们从麦克风录制音频数据。record 函数(2)记录 5 秒的音频输入并将其存储到磁盘上的 .wav 文件中。transcribe 函数(3)将音频输入转录为文本。这两个函数在本章之前的项目中已有详细讨论。

create_prompt函数(4)生成用于翻译的提示。与先前的项目一样,提示包含任务描述以及所有相关输入数据。在这种情况下,我们希望将初始语言(英语)翻译为目标语言(法语)。请注意,目标语言作为输入参数(to_language)指定。此输入参数对应于描述所需输出语言的文本片段。在最简单的情况下,这可以是语言名称(例如,“法语”)。另一方面,用户可以请求特定的方言(例如,“带有施瓦本方言的德语”)或风格(例如,“莎士比亚风格的英语”)。目标语言被整合到提示中出现的任务描述中,以及要翻译的文本。

注意,我们不需要指定输入语言。我们假设语言模型能够识别输入文本的语言(否则,我们无法期望模型进行翻译)。

在使用提示调用call_llm函数(5)后,我们应该获得翻译后的文本。generate_speech函数(6)使用我们在上一节中讨论的方法生成相应的语音。

翻译应用(7)期望输入一个描述目标语言的文本。此参数是一个字符串,可以包含任意文本。它只是简单地替换了用于翻译的提示中的占位符。在主循环(8)中,用户按下 Enter 键进行语音输入或输入quit以终止应用。

在记录用户输入时,我们首先使用transcribe函数(9)转录输入之前,将 5 秒的音频录音存储在名为 to_translate.wav 的文件中。之后,我们使用 GPT-4o 将输入翻译为目标语言(10),然后从翻译中生成语音(11)。我们将生成的语音作为磁盘上的.mp3 文件存储(这意味着我们可以轻松地再次听到最后一个输出)并最终使用playsound库——正如你所猜到的——播放生成的声音文件。

7.4.4 尝试运行

是时候尝试我们的翻译器了!您可以在配套网站上找到代码,在第七章的列表 3 中。下载代码,并在终端中切换到包含文件夹。我们可以选择我们的目标语言进行翻译。当然,翻译的质量和声音输出可能因选择而异。特别是,我们用于转录的模型以及我们用于语音生成的模型支持大约 60 种常见语言。在不太常见的语言中转录音频输入或生成音频输出可能会失败。请在网上查看当前支持的转录语言列表(help.openai.com/en/articles/7031512-whisper-api-faq)以及语音生成(platform.openai.com/docs/guides/text-to-speech)。目前,与这一节开头的场景保持一致,我们将选择法语作为目标语言。在终端中,输入以下命令以启动我们的翻译器:

python listing3.py "French"

严格来说,单词“French”周围的引号是不必要的。然而,由于我们可以输入多词描述所需的目标语言,我们将在以下示例中使用引号,以避免控制台错误地将我们的输入解释为多个参数的值。

就像我们之前的项目一样,我们需要给我们的应用程序访问麦克风的权限。如果您被要求访问麦克风,请点击“是”;如果没有,请确保安全设置允许这样做。以下是从我们与翻译工具的对话中摘录的内容:

Press enter to record (type quit to quit).
Original text: Hello my colleagues in Paris.
Translated text: Bonjour mes collègues à Paris.
Press enter to record (type quit to quit).
Original text: Let me teach you something 
about language models.
Translated text: Laisse-moi t'apprendre 
quelque chose à propos des modèles de langage.

您可以看到转录的输入和生成的翻译。您还应该听到翻译的口语版本(如果没有,请检查您的音频输出设置)。对于几行 Python 代码来说,这已经很不错了!

将内容翻译成法语似乎是我们翻译工具的一个合理用例。然而,它可能不是“乐趣因素”最高的一个。让我们尝试一些不同的事情,以展示语言模型的灵活性:让我们看看我们是否可以将音频输入“翻译”成高度精炼的版本。在终端中,输入以下指令:

python listing3.py "English in the style of Shakespeare"

这是我们将简单的问候语翻译成更精致版本(可能是一堂语言模型课程的良好介绍,针对我们的美国同事在 Banana 公司)的结果:

Press enter to record (type quit to quit).
Original text: Hello, my dear colleagues.
Translated text: Hark, my fair allies, I bid thee well met!
Press enter to record (type quit to quit).
Original text: Let me teach you 
something about language models.
Translated text: Pray, lend me thine ear 
as I shalt educate thee on language models.

尝试更多目标语言!可能性几乎是无限的。

摘要

  • OpenAI 的 Whisper 模型可以将语音输入转录成文本。

  • 通过音频转录端点访问转录功能。

  • 转录的价格基于分钟数。

  • OpenAI 提供多种模型,用于将文本生成语音。

  • 您可以选择生成语音的语音和音质。

  • 语音生成定价取决于令牌数量。

第三部分 高级主题

这一部分书籍将帮助你编写更有效、成本效益更高的语言模型应用程序。

第八章将我们的范围从 OpenAI 的语言模型扩展到其他提供商。在将语言模型应用于大数据集之前,比较不同提供商提供的模型至关重要。这样,你可以为特定场景获得成本和质量的最佳权衡。本章讨论了一些最受欢迎的提供商、他们的模型以及他们提供的库。

第九章在一个示例场景中展示了成本优化的技术。它涵盖了诸如提示工程、模型配置参数的最优调整以及微调等主题,微调是一种将语言模型专门用于一项特定任务的过程。正如本章所示,使用这些方法可以在质量和成本方面带来显著的改进。

第十章讨论了两个在语言模型上开发复杂应用的非常流行的软件框架:LangChain 和 LlamaIndex。这两个框架都可以用于数据分析。特别是,本章展示了如何使用这些框架来构建智能体,这种方法使语言模型能够使用各种计算工具解决复杂的数据分析任务。

第八章:个 GPT 替代品

本章涵盖

  • Claude,来自 Anthropic

  • 命令 R+,来自 Cohere

  • Bard,来自 Google

  • Hugging Face 的模型

是时候见一见 GPT 的“朋友们”了!到目前为止,我们一直专注于 GPT 和其他 OpenAI 模型。但 OpenAI 并非市场上唯一的游戏。恰恰相反:我们目前正在见证语言模型的“寒武纪大爆发”,每周都有新的模型涌现。在使用语言模型进行生产之前,您想要确保使用最适合您任务的模型。在本章中,我们将探讨许多 OpenAI 的替代品,并讨论不同模型的优缺点以及如何使用它们。

几乎所有现代语言模型都基于类似的架构(Transformer 架构)。然而,不同提供商的模型可能在训练方式、文本表示方式或提供和定价方式上有所不同。所有这些因素都可能影响您特定任务的处理费用和输出质量。像 GPT-4o 这样的模型功能强大,几乎可以解决任何任务。但这种通用性是有代价的:如果有一个小型、专门针对您感兴趣的任务训练的模型,使用这样的模型可能非常合适。

您将在接下来的章节中注意到,我们讨论的许多模型可以通过与 OpenAI 接口类似的界面访问。这对您来说是个好消息:每次想要尝试不同的模型时,无需进入一个全新的复杂框架!有了这个,我们就开始探索 GPT 的替代品。

为什么我的首选模型没有列出?

如果您的首选模型或模型提供商在本章中没有列出,请不要慌张!随着提供商和模型数量的增加,全面概述所有可用模型已经变得不可能。如果一个模型没有包括在这里,并不意味着它不能成为您任务的最佳替代品。不同提供商的界面通常相似,因此您仍然可以使用到目前为止所学到的知识来轻松使用其他模型。此外,请注意,我们在本章中按字母顺序列出模型提供商。不要从那中推断出任何优先级(我们不是先讨论最好的提供商)。

8.1 Anthropic

许多艾萨克·阿西莫夫(Isaac Asimov)的作品,他是史上最富产的科幻作家之一,其故事围绕“机器人三大定律”及其解释展开:

  1. 机器人不得伤害人类,或者通过不作为,让人类受到伤害。

  2. 机器人必须服从人类给予的命令,除非这些命令与第一定律相冲突。

  3. 机器人必须保护自己的存在,只要这种保护不与第一或第二定律相冲突。

此处的目标是制定一套简洁的指导方针,确保机器人既有用又无害。无论之前提到的法律是否提供了灵感,这一想法都与 Anthropic 生产的语言模型相联系,Anthropic 是另一家大规模语言模型的提供商。

Anthropic,成立于 2021 年(由几位前 OpenAI 成员创立),反复强调“宪法 AI” [1] 这一独特理念,与其他模型相比。简而言之,这意味着在训练模型以向用户提供准确且无冒犯性的答案时,我们依赖一组小规则——可以说是一份“宪法”——来评判答案的质量。我们不是依赖人类测试员在训练过程中对模型生成的答案进行标记,而是采用第二个 AI,负责根据宪法评估前者的答案。

在撰写本文时,Claude 3.5(指代令人惊叹的 Claude Shannon)是 Anthropic 发布的最新模型。在本节中,我们将通过(你猜对了)一个网页界面和 Python 库来尝试使用 Claude。

8.1.1 与 Claude 聊天

我们将与 Claude(目前版本为 3.5)进行简短聊天,以了解其功能。首先,访问 Anthropic 网站www.anthropic.com,然后点击“与 Claude 聊天”按钮。

除非你之前已经创建了 Anthropic 账户,否则你将被要求提供电子邮件地址和电话号码。在验证你的数据后,你应该能看到 Claude 的聊天界面,如图 8.1 所示。

figure

图 8.1 Anthropic 聊天机器人 Claude 的网页界面

界面非常直观:只需在相应的字段中输入文本,然后点击右侧的按钮开始聊天!可以从友好的问候语开始,聊聊天气,或者尝试使用 Claude 解决第二章的一些任务(例如,根据情感对评论进行分类或把问题翻译成 SQL 查询)。

8.1.2 Python 库

假设你已经创建了 Anthropic 的账户,你可以在以下 URL 创建密钥:console.anthropic.com/settings/keys。务必在创建后复制你的访问密钥(因为之后你将无法再次访问它)!

获得访问密钥后,转到终端并运行以下命令:

pip install anthropic==0.28

这将安装 Anthropic Python 库。如果你熟悉 OpenAI 的 Python 库(并且在你阅读了前面的章节后,你很可能熟悉),你应该会很快习惯 Anthropic 库。

例如,以下列表展示了使用 Claude 回答问题的 Python 代码。当然,这段代码并不能做通过网页界面做不到的事情。其目的只是展示通过 Python 接口使用 Claude 是多么简单。

列表 8.1 使用 Anthropic 的 Claude 模型回答问题
import argparse
from anthropic import Anthropic

if __name__ == '__main__':

    parser = argparse.ArgumentParser()                  #1
    parser.add_argument('ai_key', type=str, help='Anthropic access key')
    parser.add_argument('question', type=str, help='A question for Claude')
    args = parser.parse_args()

    anthropic = Anthropic(api_key=args.ai_key)  #2

    completion = anthropic.messages.create(  #3
        model='claude-3-5-sonnet-20241022', 
        max_tokens=100,
        messages=[
            {
                'role':'user', 
                'content':args.question
             }])

    print(completion.content)  #4

1 定义参数

2 配置 Anthropic

3 使用 Claude 进行完成

4 打印完成结果

作为输入参数(1),我们使用 Anthropic 访问密钥和我们想要回答的问题。类似于 OpenAI 的库,我们使用访问密钥(2)配置访问。之后,我们可以构建 Claude 的完成提示(3)。

使用 Anthropic 的anthropic.messages端点,我们指定要使用的模型 ID(claude-3-5-sonnet-20241022是撰写本文时的 Anthropic 最新模型)和完成的最大令牌数(使用max_tokens参数)。类似于 OpenAI 的聊天模型,Claude 是为用户与模型之间的聊天而设计的。因此,Claude 的输入是一个包含消息的列表(在这个特定场景中只包含一个元素)。我们在content字段中获取 Claude 提示完成的成果(我们打印4)。

您可以在配套网站上找到这个列表作为 Anthropic 条目。要执行它,请打开您的终端,切换到包含文件夹。然后,执行以下命令:

python anthropic_claude.py ... "What is constitutional AI?"

将三个点替换为您的 Anthropic 访问密钥。在执行程序时,您应该会看到由 Anthropic 模型生成的对您问题的回答。

8.2 Cohere

我们在第二章中简要讨论了幻觉。本质上,当语言模型因为无法访问与当前任务相关的数据而编造内容时,就会发生幻觉。加拿大初创公司 Cohere 特别强调使用一种称为grounding的方法来避免这种幻觉。将语言模型的答案 grounding 意味着将其与真实数据联系起来,从而降低“基于现实之外的创造性输出”的可能性。

Cohere 支持广泛的连接器,使其模型能够访问外部数据。例如,支持网络搜索,以及访问各种数据库。内部,Cohere 访问这些数据源,并为语言模型提供针对请求定制的信息。但更好的是,Cohere 会向您展示用于生成您答案的所有数据源。如果您是那种怀疑型的人(在涉及语言模型的答案时,您通常应该是),您可以跟进参考文献并验证它们是否支持生成的答案。

让我们看看这一切在实际中是如何工作的。现在是时候与 Cohere 的 Command R+模型聊天了!

8.2.1 与 Command R+聊天

在撰写本文时,Command R+是 Cohere 的最新模型之一。像往常一样,您可以通过 Web 界面尝试它,并在处理大量数据时通过 Python 使用它。首先,我们将尝试 Web 界面。为此,请访问cohere.com/,并点击“立即尝试”。注册账户后,点击“游乐场”。您应该能在图 8.2 中看到 Web 界面。

图像

图 8.2 与 Cohere 的语言模型 Command R+聊天的 Web 界面

图 8.2 展示了如何输入你的提示以及生成答案的按钮。那么右侧的窗口(包含 Web 连接器按钮)是做什么的呢?这就是你指定在生成回复时使用的连接器的地方。连接器使 Cohere 能够访问外部数据源。我们可以通过切换相应的按钮来激活(或停用)网络连接器。这个连接器使 Cohere 能够查询网络,类似于我们尝试回答涉及事实知识难题时所做的那样。试一试,看看如果激活或停用网络连接器,事实问题的回复会有何变化!

什么是 RAG?

你可能已经注意到 Cohere 的网站显著宣传了 RAG,但那是什么意思呢?RAG 代表检索增强生成。这意味着在生成答案时,我们通过从外部源检索的数据来增强语言模型使用的输入。

8.2.2 Python 库

Cohere 提供了一个类似于我们在前几节中看到的 Python 库。在终端中输入以下命令来安装所需的库:

pip install cohere==4.43

列表 8.2(作为网站上的 Cohere 项目提供)包含一个简单问答界面的代码。用户在命令行中输入他们的访问密钥和问题。访问 dashboard.cohere.com/api-keys 获取你的访问密钥。在列表 8.2 中,在用访问密钥配置 Cohere 库(1)之后,我们使用 Cohere 库生成答案(2)。注意在调用 chat 函数时对 connectors 的引用!在这里,我们指定了一个连接器列表,使 Cohere 的模型能够访问外部数据源。连接器被指定为一个列表(即,我们可以启用对多个连接器的访问,而不仅仅是单个连接器)。在这里,我们使用 ID 为 web-search 的连接器(这就是我们在前一个章节中通过网页界面使用的同一个网络搜索连接器)。

最后(3),我们打印出模型生成的答案以及生成答案时咨询的网页源列表(包括检索这些文档所发出的查询)。这使我们能够验证生成的答案确实是由源材料暗示的。

列表 8.2 使用 Cohere 的 Python 库回答问题
import argparse
import cohere

if __name__ == '__main__':

    parser = argparse.ArgumentParser()
    parser.add_argument('ai_key', type=str, help='Cohere access key')
    parser.add_argument('question', type=str, help='Answer this question')
    args = parser.parse_args()

    client = cohere.Client(args.ai_key)  #1
     #2
    prompt = f'Answer this question: {args.question}'
    result = client.chat(prompt, connectors=[{'id': 'web-search'}])
     #3
    print(f'Answer: result.text')
    print(f'Web searches: result.search_results')
    print(f'Web results: result.documents')

1 配置访问

2 使用 Cohere 生成答案

3 打印答案和引用

让我们试试!切换到包含代码的文件夹,并在终端中运行以下命令(用你的 Cohere 访问密钥替换三个点):

python cohereqa.py ... "Where was Steve Jobs born?"

你将得到一个类似于以下(略有缩写)的答案:

 #1
Answer: Steven Paul Jobs was born in San Francisco, California, United States.
His birth name was later changed to Steve Jobs after 
he was adopted by Paul and Clara Jobs. 

Jobs was born to Abdulfattah Jandali and Joanne Schieble on 24th February, 1955\. 
After being put up for adoption, Jobs was adopted by Paul and Clara Jobs, 
a lower-middle-class couple.

Web searches: [  #2
    {'search_query': 
        {'text': 'Where was Steve Jobs born', 
        'generation_id': '...'}, 
    'document_ids': [
        'web-search_1:0', 'web-search_3:1', 'web-search_4:0', 
        'web-search_5:0', 'web-search_9:1'], 
    'connector': {'id': 'web-search'}
    }]

Web results: [  #3
    {'id': 'web-search_4:0', 'snippet': 'Short Biography of Steve Jobs 
    The story of Steve Jobs from cradle to grave - and beyond. Steven 
    Paul Jobs was born on February 24, 1955 in San Francisco, California.
    ... ', 'title': 'Short Bio | all about Steve Jobs.com', 
    'url': 'https://allaboutstevejobs.com/bio/short_bio'}, 
    ...]

1 生成的答案

2 网络搜索查询

3 使用的网页文档

让我们更仔细地看看。输出的初始部分(1)是模型生成的答案。答案看起来合理,但我们能信任它吗?这就是输出剩余部分发挥作用的地方。中间部分(2)提供了 Cohere 用于生成答案所使用的网络信息。这些网络查询是根据输入问题自动选择的。在这种情况下,网络查询与输入问题完全对应(对于较长的输入文本,这并不一定是这种情况)。在输出的末尾(3),我们找到了通过先前查询检索到的文本片段和文档的网址。在这种情况下,从网络文档中摘取的文本片段(例如,“史蒂夫·乔布斯于 1955 年 2 月 24 日出生于加利福尼亚州旧金山”)很好地支持了模型的答案。

8.3 Google

无需介绍,Google 公司自语言模型开始以来就深度参与其中。事实上,几乎所有语言模型使用的 Transformer 架构 [2] 是由 Google 研究人员发明的(主要是)。难怪 Google 正在开发自己的模型。在撰写本文时,Gemini 是 Google 最新的模型之一,我们将在本节中尝试它。

8.3.1 与 Gemini 聊天

要尝试 Gemini,请访问 gemini.google.com/。注册账户后,你应该能看到图 8.3 中展示的界面。

图

图 8.3 与 Google 的 Gemini 模型进行聊天的网络界面。点击设置以激活附加功能。

简单地将你的文本输入相应的输入字段(如图 8.3 中的“在此处输入提示”所示),然后按 Enter 键生成答案。Gemini 不仅限于文本输入。点击右侧的按钮上传图片。在与 Gemini 的对话中,你可以引用这些图片并对其提问。

Gemini 的一个独特之处在于,它与其他我们之前讨论过的模型不同,那就是它与其他 Google 工具的集成。点击图 8.3 中的设置按钮(标记),然后选择扩展选项。例如,你可以通过点击相关按钮让 Gemini 访问你的电子邮件。你是否遇到过在收件箱中寻找隐藏在一年前电子邮件中的信息的问题?Google 的 Gemini 有潜力帮助解决这个问题。

8.3.2 Python 库

与其他语言模型提供商一样,Google 提供了一个用于模型访问的 Python 库。你可以在终端中使用以下代码安装库:

pip install google-generativeai==0.7

访问aistudio.google.com/app/apikey以获取 Google API 的访问密钥。按照指示操作,创建后复制密钥。列表 8.3 展示了如何使用 Python 中的 Gemini 来回答问题。步骤与之前的库类似。输入参数(1)包括访问密钥(或者,我们也可以将密钥存储在环境变量中)以及要回答的问题。接下来,我们使用访问密钥配置 Google 库(2)。现在我们可以生成一个模型并使用generate_content方法来回答问题(3)。最后,我们打印出生成的问答(4)。

列表 8.3 使用 Google 的 Gemini 模型回答问题
import argparse
import google.generativeai as genai

if __name__ == '__main__':
     #1
    parser = argparse.ArgumentParser()
    parser.add_argument('api_key', type=str, help='Google API key')
    parser.add_argument('question', type=str, help='Question to answer')
    args = parser.parse_args()
     #2
    genai.configure(api_key=args.api_key)
     #3
    model = genai.GenerativeModel('gemini-1.5-flash')
    reply = model.generate_content(args.question)
     #4
    print(reply.text)

1 定义输入参数

2 配置 API 访问密钥

3 使用 Gemini 生成答案

4 打印答案

您可以使用 Google 链接在本书的网站上找到代码。在终端中,切换到包含代码的目录。例如,运行以下命令以测试 Gemini(将三个点替换为您的 Google 访问密钥):

python google.py ... "What is the meaning of life?"

Google 库的全面概述超出了本书的范围。然而,了解大型语言模型其他提供商的库,您应该能够快速熟悉这个 API。

8.4 Hugging Face

类似于 OpenAI 这样的提供商投资数百万美元来训练 GPT-4o 等模型。所有这些昂贵训练的结果是使模型表现最佳的模型参数值。在投入所有这些资金之后,您可能并不想免费分享训练结果,对吧?因此,像 GPT-4o 这样的模型通常是封闭的,这意味着 OpenAI 不会分享训练产生的参数值(注意,OpenAI 已经分享了其他模型,如 Whisper)。相反,OpenAI 在自己的基础设施上为您处理提示,并收取处理费用(这就是我们最终为所有昂贵的模型训练付费的方式)。

然而,越来越多的语言模型提供商面临着来自一个极其活跃的开源领域的竞争。大学、初创公司和爱好者都在训练自己的模型,并且经常免费向公众发布模型(及其参数值)。这使得您可以在自己的专用基础设施上本地运行这些模型。对于较小的模型,带有 GPU 的笔记本电脑通常就足够了。对于较大的模型,您可能需要使用 GPU 集群(或者求助于为您运行这些开源模型的云服务提供商)。除了潜在的财务优势(在自己的基础设施上运行模型可能更便宜)之外,其他考虑因素也可能使本地运行模型成为唯一可行的选择。例如,您可能不想将特别敏感的数据交给外部提供商。如果您不想发送数据,本地运行是唯一的选择。

通常,开源模型比云提供商提供的模型要小得多。这是有道理的,因为毕竟,谁会手头有几百万美元来训练一个模型呢?然而,由于可用的模型数量众多,通常可以找到一个专门解决你感兴趣的任务的开源模型。例如,Hugging Face Transformers 平台在撰写本文时拥有超过 1,000,000 个 Transformer 模型!无论你面临什么任务,你可能会找到恰好适合的模型。在本节中,我们将查看 Hugging Face 平台,并了解如何在其本地使用模型。

8.4.1 网络平台

访问huggingface.co/。Hugging Face Transformers 提供了围绕 Transformer 模型的各种资源。这包括不仅限于模型,还有你可以用来训练自己模型的数据库集,以及允许你在 Hugging Face 的云基础设施上运行开源模型的云服务。

目前,我们关注的是模型。点击“模型”按钮,查看如图 8.4 所示的模型列表。

figure

图 8.4 Hugging Face Transformer 模型概述。点击“任务”筛选器以缩小选择范围。点击“模型”列表中的模型以查看详细信息。

我们看到了超过 100 万个 Transformer 模型的列表(并且随着模型数量的每日增长,你可能还会看到更多!)哇。这太多了点。让我们缩小范围。屏幕左侧有各种筛选选项,可以帮助你筛选出真正关心的模型。例如,我们可以根据模型需要完成的任务类型进行筛选。这包括文本分类(例如,根据情感对评论进行分类)、视觉问答(例如,图片是否显示苹果?)和语音转文本转录(例如,将语音查询转录为文本)。对于本书中讨论的几乎所有任务,你可能都能找到一个专门的模型。点击任何标准任务,只显示解决该任务的模型。

当你点击剩余的任何模型时,你将看到如图 8.5 所示的详细模型描述,例如 Salesforce 的 BLIP 模型,这是一个处理图像以生成合适标题的模型。左侧是对模型的详细描述,以及链接到相关论文和代码示例,展示如何在不同的硬件平台上(即本地)使用该模型。右侧是一个界面,允许你尝试在几个样本图片上使用该模型。

figure

图 8.5 Salesforce 的 BLIP 模型的详细描述(huggingface.co/Salesforce/blip -image-captioning-large)。阅读左侧的描述,或者通过右侧的界面尝试该模型。

8.4.2 Python 库

我们现在即将在我们的本地基础设施上运行 Transformer 模型!你获得的性能当然将取决于你所使用的硬件特性。然而,即使拥有适度的计算能力,你也应该能够使用我们即将尝试的模型。但首先,我们必须安装 Hugging Face Transformers 库。在你的终端中输入以下命令:

pip install transformers==4.36

Transformers 库基于 PyTorch,这是一个流行的机器学习框架。如果你还没有安装 PyTorch,请在你的终端中运行以下命令(否则,在尝试运行以下代码时你会收到错误信息):

pip install torch==2.1.2

设置到此结束!我们准备好使用 Hugging Face Transformers 库了,我们通过import transformers导入它。Transformers 库提供了丰富的功能和多种使用模型的方法,包括在它的存储库中(或者训练你自己的模型)。在本节中,我们只介绍其中的一小部分,但足以获得初步印象。

假设你在 Hugging Face 模型存储库中找到了一个你想要尝试的模型。为了使事情更具体,让我们假设我们正在讨论卡迪夫大学提供的用于情感分类的 Roberta 模型(你可以在mng.bz/rKoX找到该模型)。与 GPT-4o 和本节中讨论的大多数其他模型相比,这是一个相当小的模型。然而,它专门用于分析文本以确定潜在的情感。尽管它比 GPT-4o 和类似模型要通用得多,但它做一项任务并且做得相当不错。如果你想要对评论进行分类,例如,你可能发现这个模型非常合适。

通过 Transformers 库使用模型的最简单方法是使用pipeline。以下命令创建了一个基于 Roberta 模型的情感分类管道:

sentiment_pipeline = transformers.pipeline(
    model='cardiffnlp/twitter-roberta-base-sentiment-latest')

如你所见,我们使用 URL 的最后部分指定模型:提供模型的账户名称(cardiffnlp)以及模型本身的 ID。当你第一次使用此代码时,Transformers 库将自动从其公共模型存储库下载模型。请注意,此代码部分之所以有效,是因为我们引用的模型与特定的任务类别相关联。对于其他模型,你可能需要指定你希望它们解决的任务类型作为单独的输入参数。

我们创建了一个管道!现在我们可以用它来分类文本——例如,如下所示(我们假设变量text_to_classify包含的是要分类的文本):

result = sentiment_pipeline(text_to_classify)

我们已经拥有了构建一个简单的应用所需的一切,该应用可以分类评论(基于是否为积极情绪,即好评,或消极情绪,即差评)。下面的列表显示了相应的代码(您可以在书籍网站上找到它作为 Hugging Face 项目)。

列表 8.4 使用 Hugging Face Transformers 进行情感分类
import argparse
import transformers

if __name__ == '__main__':

    parser = argparse.ArgumentParser()
    parser.add_argument('review', type=str, help='Text of a review')
    args = parser.parse_args()

    sentiment_pipeline = transformers.pipeline(          #1
        model='cardiffnlp/twitter-roberta-base-sentiment-latest')

    result = sentiment_pipeline(args.review)      #2

    print(result)                        #3

1 创建管道

2 应用管道到输入

3 打印分类结果

您可能会注意到与我们之前看到的代码有所不同:我们不需要指定访问密钥!因为 Hugging Face 模型是公开可用的,并且我们正在自己的基础设施上运行它们,所以不需要提供任何类型的凭证。相反,唯一的输入是我们想要分类的评论文本。

代码组合了之前讨论的片段。它创建了一个管道(1),使用它来分类输入文本(2),并最终打印出结果(3)。您可以通过在终端中切换到包含文件夹并输入,例如以下内容来尝试它:

python huggingface.py "This movie was really awful!"

当您第一次运行代码时,您可能需要等待几分钟,因为 Transformers 库正在下载您引用的模型。但请放心:库会缓存下载的模型,所以您在第二次运行代码时就不需要等待了。处理完成后,您应该会看到如下输出:

[{'label': 'negative', 'score': 0.9412825107574463}]

对于样本输入来说这当然是正确的:评论简洁且 100%负面。尝试用几篇不同的评论,并将输出与 GPT-4o 等模型的结果进行比较。在大多数情况下,分类结果应该相当相似。当然,GPT-4o 是一个更通用的模型,也可以用于解决各种其他任务。但只要您对分类评论感兴趣,这个模型在质量和成本之间提供了一个有趣的权衡。

摘要

  • 除了 OpenAI,还有其他几家提供商提供大型语言模型。大多数提供商通过云 API 提供封闭源代码模型。这些模型在通用性、输出质量和定价方面有所不同。

  • 大多数提供商提供 Python 库来访问语言模型。

  • Hugging Face Transformers 提供各种模型供免费下载。

8.6 参考文献

  1. Bai, Y., Kadavath, S., Kundu, S., 等人. (2022). 从 AI 反馈中消除危害的宪法 AI. CoRR abs/2212.0,第 1–32 页。

  2. Vaswani, A., Shazeer, N., Parmar, N., 等人. (2017). 注意力就是一切. 在 神经信息处理系统进展,第 5999–6009 页。

第九章:优化成本和质量

本章涵盖内容

  • 模型选择和调整

  • 提示工程

  • 模型微调

使用大型语言模型分析数据是快速烧钱的好方法。如果你已经使用 GPT-4(或类似的大型模型)了一段时间,你可能已经注意到费用是如何快速累积的,迫使你定期充值账户。但我们是否总是需要使用最大(也是最昂贵)的模型?我们能否让小型模型几乎以同样的效果运行?我们如何花最少的钱获得最大的效益?

本章主要讲述在使用大型数据集上的语言模型时如何节省金钱。幸运的是,我们有很多选择来实现这一点。首先,在大型语言模型方面,我们有相当多的选择。选择一个尽可能小(或者说,更便宜)的模型,同时仍然能够很好地完成我们的分析任务,这可以在很大程度上帮助我们平衡预算。其次,模型通常具有各种调整参数,使我们能够从整体文本生成策略调整到特定标记的(去)优先级调整。我们希望在那里优化我们的设置,将小型模型转变为特定任务的 GPT-4 替代品。第三,我们可以使用提示工程来调整我们向模型提问的方式,有时会得到令人惊讶的不同结果!

最后,如果上述方法都不奏效,我们可以选择创建自己的模型,这些模型高度定制化,仅针对我们关心的任务。当然,如果我们不想在预训练上花费数百万,我们就不会从头开始训练新模型。相反,我们通常会选择使用仅几百个样本对现有模型进行微调。这通常足以比使用基础模型获得显著更好的性能。

当然,最佳方案取决于我们试图解决的问题以及数据属性。幸运的是,如果我们想分析大量数据,我们可以在数据样本上尝试不同的调整选项,花费一点钱。很可能,一旦我们分析了整个数据集,这种前期投资就会得到回报!在本章中,我们将在一个示例场景中应用所有这些调整选项。

9.1 示例场景

你回到了 Banana,试图对用户评论进行分类。用户可以在 Banana 网站上留下关于 Banana 产品体验的自由文本评论。你想要知道这些评论是积极的(即,用户对产品感到满意)还是消极的(即,阅读它们会吓跑潜在客户!)。当然,你可以使用语言模型来完成这项任务(你可以在第四章中看到)。例如,你可以使用 GPT-4(在撰写本文时,这是 OpenAI 用于文本处理的最大模型)。向 GPT-4 提供一个评论,以及如何对其进行分类的说明(包括可能的标签描述,如“积极”和“消极”),输出应该对大多数评论都是正确的。

然而,使用 GPT-4 分析数据每千个标记的成本约为 6 美分。这(6 美分)可能听起来不多,但 Banana 每天都会收到数千条产品评论!让我们假设平均评论包含大约 100 个标记(大约 400 个字符)。此外,让我们假设 Banana 每天收到大约 10,000 条评论。这意味着你每天收集 100 × 10,000 个标记:大约每天 1 百万个标记,每年 3.65 亿个标记。分析一年的评论需要多少钱?大约是 365,000,000 × (0.06/1000) = 21,900 美元。

这可能会在你的预算上留下一些缺口!难道你不能以更低的价格获得它吗?例如,在撰写本文时,GPT-3.5 Turbo 的价格仅为每千个标记大约 0.0005 美元(标记的价格因是否读取或生成而有所不同,但为了简化计算,我们暂时忽略这一点)。这意味着分析一年的评论只需 365,000,000 × (0.0005/1000) = 182.5 美元。这要好得多!但为了获得令人满意的输出质量,你可能需要做一些额外的工作,以确保你以最佳方式使用该模型。

小贴士:除了 GPT-3.5 Turbo,你还可以在以下示例中使用其他模型,如 GPT-4o mini(模型 ID 为gpt-4o-mini)。

这就是我们将在本例中做的事情。从我们分类器的最简单实现开始,我们将逐步改进我们的实现,并尝试本章介绍中讨论的所有各种调优选项!

9.2 未调优分类器

让我们从我们分类器的基版开始。再次强调,目标是取一个评论并决定它应该被分类为正面(pos)还是负面(neg)。我们将使用以下提示模板来分类评论:

[Review]
Is the sentiment positive or negative?
Answer ("pos"/"neg"):    

在这个提示模板中,[Review]是一个占位符,将被实际评论文本替换。例如,替换后,我们的提示可能看起来像这样(前两行对应于评论的缩略版,显然是 Banana TV 上的一部新电影流媒体,不符合评论者的口味):

I am willing to tolerate almost anything in a Sci-Fi movie, 
but this was almost intolerable. ...
Is the sentiment positive or negative?
Answer ("pos"/"neg"):    

理想情况下,如果我们向 GPT 模型发送这个提示,我们期望得到 posneg 作为回复(在这个特定情况下,我们期望 neg)。列表 9.1 展示了完整的 Python 代码;我们不会花太多时间讨论它,因为它与我们第四章中看到的分类器类似。create_prompt 函数(1)为特定评论实例化提示模板(存储在输入参数 text 中)。结果是我们可以使用 call_llm 函数(2)发送给我们的语言模型的提示。在这里我们调用 GPT-3.5 Turbo(3)(节省成本)。我们还设置 temperature0,这意味着我们在生成输出时最小化随机性。这意味着当你重复运行代码时,你应该看到相同的结果。你也许还会注意到,列表 9.1 中的 call_llm 比我们之前看到的版本要长一些。那是因为我们不仅检索了我们的语言模型生成的答案,还检索了使用的标记数量(4)。计算标记数量将允许我们计算数据样本上的调用成本。

列表 9.1 将评论分类为正面或负面:基础版本
import argparse
import openai
import pandas as pd
import time

client = openai.OpenAI()

def create_prompt(text):                         #1
    """ Create prompt for sentiment classification.

    Args:
        text: text to classify.

    Returns:
        Prompt for text classification.
    """
    task = 'Is the sentiment positive or negative?'
    answer_format = 'Answer ("pos"/"neg")'
    return f'{text}\n{task}\n{answer_format}:'

def call_llm(prompt):                              #2
    """ Query large language model and return answer.

    Args:
        prompt: input prompt for language model.

    Returns:
        Answer by language model and total number of tokens.
    """
    for nr_retries in range(1, 4):
        try:
             #3
            response = client.chat.completions.create(
                model='gpt-3.5-turbo',
                messages=[
                    {'role':'user', 'content':prompt}
                    ],
                temperature=0
                )
             #4
            answer = response.choices[0].message.content
            nr_tokens = response.usage.total_tokens
            return answer, nr_tokens

        except Exception as e:
            print(f'Exception: {e}')
            time.sleep(nr_retries * 2)

    raise Exception('Cannot query OpenAI model!')

if __name__ == '__main__':

    parser = argparse.ArgumentParser()                   #5
    parser.add_argument('file_path', type=str, help='Path to input file')
    args = parser.parse_args()

    df = pd.read_csv(args.file_path)

    nr_correct = 0
    nr_tokens = 0

    for _, row in df.iterrows():  #6

        text = row['text']       #7
        prompt = create_prompt(text)
        label, current_tokens = call_llm(prompt)

        ground_truth = row['sentiment']      #8
        if label == ground_truth:
            nr_correct += 1
        nr_tokens += current_tokens

        print(f'Label: {label}; Ground truth: {ground_truth}')

    print(f'Number of correct labels:\t{nr_correct}')
    print(f'Number of tokens used   :\t{nr_tokens}')

1 生成提示

2 调用语言模型

3 生成答案

4 提取答案和标记使用情况

5 解析参数

6 遍历评论

7 分类评论

8 更新计数器

我们将假设要分类的评论存储在一个 .csv 文件中。我们期望用户指定该 .csv 文件的路径作为命令行参数(5)。在读取 .csv 文件后,我们按照它们在输入文件中出现的顺序遍历评论(6)。对于每条评论,我们提取相关的文本(7)(我们假设它存储在 text 列),创建一个用于分类的提示,并调用语言模型。结果是语言模型生成的答案文本(希望它是两个类别标签之一,posneg),以及使用的标记数量。

我们的目标是尝试不同的查询语言模型的方法,并比较输出质量和成本。为了判断输出质量,我们假设输入 .csv 文件不仅包含评论文本,还包含一个真实标签。这意味着我们假设每条评论都已经与正确的类别标签相关联,存储在 sentiment 列(因为我们的两个类别标签描述了评论的情感)。在收到语言模型的输出后,我们将输出与真实标签(8)进行比较,并更新正确分类的评论数量(变量 nr_correct)。同时,我们汇总使用的总标记数量(因为处理费用与它成正比)并将它们存储在名为 nr_tokens 的计数器中。遍历所有评论后,列表 9.1 打印出最终的分类正确数量和使用的标记数量。

9.3 模型调整

让我们试试!你可以在书籍网站上找到“未调优分类器”下的 9.1 列表。我们重用了第四章中的电影评论;在第四章部分搜索“Reviews.csv”链接。该文件包含 10 篇评论,以及相应的 ground truth。假设 9.1 列表和评论存储在磁盘上的同一文件夹中。打开你的终端,切换到该文件夹,并运行以下命令:

python basic_classifier.py reviews.csv

你应该看到以下输出:

Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: pos; Ground truth: pos
Label: pos; Ground truth: neg  #1
Label: pos; Ground truth: neg
Label: negative; Ground truth: neg  #2
Label: negative; Ground truth: pos
Label: neg; Ground truth: neg
Number of correct labels:    6
Number of tokens used   :    2228

1 错误的标签

2 不存在的标签

前十行描述了每个评论的结果。我们有了语言模型生成的标签和从输入文件中获取的 ground-truth 标签。最后,我们有正确分类的评论数量和使用的 token 数量。

在 10 篇评论中,我们正确分类了 6 篇。嗯,至少比 50%好,但这仍然不是一个很好的结果。出了什么问题?查看输出给我们一些想法。有些情况(1)中,语言模型简单地选择了错误的类别标签。这并不意外。然而,也有情况(2)中,语言模型选择了一个甚至不存在的类别标签!当然,这并不太离谱(negative而不是neg),这似乎很容易修复。

我们专注于语言模型只生成我们两个可能的类别标签之一这个可能容易解决的问题。我们如何做到这一点?输入logit_bias参数。logit_bias参数允许用户更改某些 token 被选中的可能性(我们在第三章中简要讨论了这一点和其他 GPT 参数)。在这种情况下,我们希望显著增加与我们的两个类别标签(negpos)相关的 token 的概率。logit_bias参数指定为一个 Python 字典,将 token ID 映射到偏差。正偏差意味着我们希望增加语言模型生成相应 token 的概率。负偏差意味着我们降低生成相关 token 的概率。

在这个例子中,我们希望增加 GPT-3.5 选择代表类别标签的两个 token 之一的机会。因此,我们希望为这两个 token ID 选择一个高偏差。偏差分数范围从-100 到+100。我们将选择最大值,并将代表类别标签的 token 分配偏差+100。首先,我们需要找到它们的 token ID。语言模型将文本表示为 token ID 的序列。要更改 token 偏差,我们需要参考我们关心的 token 的 ID。

tokenizer是将文本转换为 token ID 的组件。你可以在platform.openai.com/tokenizer找到所有 GPT 模型的 tokenizer。我们使用 GPT-3.5,所以选择标记为 GPT 3.5 & GPT-4 的那个。图 9.1 显示了 tokenizer 的 Web 界面。

我们可以在文本框中输入文本并点击标记 ID 按钮来查看输入文本的标记 ID。使用标记化器,我们了解到pos标记的 ID 为 981,而neg标记的 ID 为 29875。现在我们准备好在模型调用中添加偏差,如下所示:

import openai
client = openai.OpenAI()

response = client.chat.completions.create(
    model='gpt-3.5-turbo',
    messages=[
        {'role':'user', 'content':prompt}
        ],
    logit_bias = {981:100, 29875:100},  #1
    temperature=0
    )

1 定义了偏差

figure

图 9.1 GPT 标记化器在platform.openai.com/tokenizer:输入文本以学习相关的标记 ID。

与之前的调用(在列表 9.1 中)相比,我们通过将我们感兴趣的两种标记的 ID(pos标记 ID 为 981 和neg标记 ID 为 29875)映射到最高的偏差值 100 来添加 logit 偏差(1)。这应该可以解决生成与类标签不对应的标记的问题,对吧?

警告 下述代码会导致问题,并导致长时间运行和显著的货币费用。在没有整合本节末尾提出的修复方案之前,不要尝试它!

让我们试一试以确保。您可以将 logit 偏差添加到列表 9.1 中的代码。或者,在本章的后面,我们将介绍一个可调整的分类器版本,它将允许您尝试不同的调整参数组合(包括 logit 偏差)。如果您使用添加了偏差的分类器执行,您可能会看到以下类似的输出(实际上,由于执行代码需要很长时间并且产生不可忽视的成本,您可能只想相信我):

 #1
Label: negnegnegnegnegnegnegnegnegneg ...; Ground truth: neg
Label: negposnegnegnegnegnegnegnegneg ...; Ground truth: neg
Label: negposnegnegnegnegnegnegnegneg ...; Ground truth: neg
Label: negposnegposnegnegnegnegnegneg ...; Ground truth: neg
Label: posnegposnegposnegposnegposneg ...; Ground truth: pos
Label: posnegpospospospospospospospos ...; Ground truth: neg
Label: posnegpospospospospospospospos ...; Ground truth: neg
Label: negposnegposnegposnegposnegpos ...; Ground truth: neg
Label: negposnegposnegposnegposnegpos ...; Ground truth: pos
Label: negposnegposnegnegnegnegnegneg ...; Ground truth: neg
Number of correct labels:    0
Number of tokens used   :    2318  #2

1 每个输入都没有标签:

2 增加标记使用

哦,不——没有一次正确的分类!发生了什么?将生成的“标签”与真实值进行比较揭示了问题(1):我们只生成了两种可能的标记(这很好!)但太多了(这并不好!)。这增加了标记消耗(2)(注意,输出长度被限制以生成示例输出;否则,标记消耗会更高),但更重要的是,这意味着我们的输出与任何类标签都不对应。

为什么模型会生成这么多标记?

我们本质上将模型限制为仅使用两个标记来生成文本。这些是我们希望在输出中看到的两个标记。然而,我们忘记启用模型生成任何表示输出结束的标记!这就是为什么模型无法停止生成的原因!

解决这个问题有多种方法。当然,我们可以添加后处理步骤来从语言模型生成的输出中仅提取第一个标记。这样(大多数情况下)就能解决我们关于类别标签的问题。看看输出,你会发现使用第一个标记在 10 个案例中有 7 个是正确的。然而,这种方法(还有)存在另一个问题:我们正在为最终不使用的标记付费!这显然不是我们想要的。所以让我们通过限制输出长度来进一步调整我们的模型。我们只需要一个标记(这之所以可行,是因为我们的两个可能的类别标签可以用一个标记来表示)。这就是max_tokens参数的作用。让我们在调用我们的语言模型时使用它:

response = client.chat.completions.create(
    model='gpt-3.5-turbo',
    messages=[
        {'role':'user', 'content':prompt}
        ],
    logit_bias = {981:100, 29875:100},  #1
    temperature=0, max_tokens=1
    )

1 定义偏差

当你尝试它(这应该既快又便宜)时,你应该看到以下输出:

Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: pos; Ground truth: pos
Label: pos; Ground truth: neg
Label: pos; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: pos
Label: neg; Ground truth: neg
Number of correct labels:    7  #1
Number of tokens used   :    2228  #2

1 改进了未调整的分类器

2 减少标记使用

好多了!我们将正确处理的案例数量从未调整版本中的六个提高到了七个(1)。这可能听起来不多。然而,从整个数据集的角度来看,这实际上意味着我们将精度从 60%提高到了 70%:也就是说,现在将有数千条评论将被正确分类!当然,这里有一个警告。在现实中,你可能需要使用一个更大的样本。由于随机变化,你在样本上观察到的准确性可能不代表整个数据集的准确性。为了简化问题(并且在你尝试时成本相对较低),我们在这里限制自己只使用 10 个样本。作为额外的奖励,我们的标记消耗再次减少了(2)(实际上,与没有任何输出大小限制的版本相比,标记消耗的差距可能要大得多)。请注意,这里讨论的两个参数只是可用调整选项的一小部分。你将在第三章中找到更多关于相关参数的详细信息。每次你为新的任务调整模型时,务必考虑所有可能相关的参数。然后在数据样本上尝试一些合理的设置,看看哪个选项表现最好。

9.4 模型选择

假设我们已经通过调整当前模型达到了性能提升的极限。我们还能做什么呢?当然,我们可以选择一个不同的模型。在上一章中,我们看到了一些 GPT 的替代品。如果你可以选择一个专门针对你感兴趣的任务(例如,文本分类)进行训练的模型,那么这通常值得一看。其他可能影响你模型选择的因素包括你打算应用模型的数据是否敏感,以及是否可以将这些数据发送给特定的语言模型提供商是否可接受。

如果你想了解不同模型的相对性能,请查看 crfm.stanford.edu/helm/lite/latest/。这个网站包含了 HELM(斯坦福大学对语言模型的整体评估)的结果。该基准比较了在各种场景下的语言模型,并包含了特定任务的结果,以及在不同场景下的平均性能。你可能想查看一下,以了解哪些模型可能对你感兴趣。然而,由于各种因素都可能影响语言模型的表现,仍然值得在你感兴趣的特定任务上评估不同的模型。

为了简化问题,我们只考虑将 GPT-4 作为 GPT-3.5 Turbo(我们之前使用的模型)的替代品。在语言模型调用中替换模型的名称:

response = client.chat.completions.create(
    model='gpt-4',
    messages=[
        {'role':'user', 'content':prompt}
        ],
    logit_bias = {981:100, 29875:100},  #1
    temperature=0, max_tokens=1
    )

1 定义了偏差

运行生成的代码应该产生以下输出:

Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: pos; Ground truth: pos
Label: pos; Ground truth: neg
Label: pos; Ground truth: neg
Label: neg; Ground truth: neg
Label: pos; Ground truth: pos  #1
Label: neg; Ground truth: neg
Number of correct labels:    8  #2
Number of tokens used   :    2228  #3

1 正确的分类结果

2 目前最佳结果

3 相同数量的标记

与先前的版本相比,GPT-4 成功地准确解决了另一个测试案例(1)!这使我们的准确率达到了 80% (2),而我们的标记消耗保持不变(3)。顺便说一下,如果我们更改模型,这并不保证会发生。因为不同的模型可能使用不同的标记器,表示相同的文本可能需要不同数量的标记。在这个特定案例中,因为 GPT-4 和 GPT-3.5 使用相同的标记器,所以标记的数量没有变化。

这是否意味着我们支付了相同数量的金钱?并不完全是这样。因为 GPT-4 每个标记的收费要高得多,我们现在的支出大约是之前的 120 倍(GPT-4 和 GPT-3.5 Turbo 每个标记价格的相对差异)。这就是为什么我们试图让 GPT-3.5 尽可能地表现良好,而不依赖于 GPT-4。

在模型选择和模型调整过程中,有时亲自查看测试数据是有意义的。这让你对各种模型的优点和局限性有更好的印象,并使你能够判断你的模型表现不佳的测试案例是否具有代表性。例如,以下评论被 GPT-4 正确解决,但 GPT-3.5 则没有:

If you want to see a film starring Stan Laurel from the Laurel & Hardy 
comedies, this is not the film for you. Stan would not begin to find the 
character and rhythms of those films for another two years. If, however, 
you want a good travesty of the Rudolph Valentino BLOOD AND SAND, which 
had been made the previous year, this is the movie for you. All the 
stops are pulled out, both in physical comedy and on the title cards 
and if the movie is not held together by character, the plot of 
Valentino's movie is used -- well sort of.

这篇评论包含了积极(接近结尾)以及消极(开头)的方面。尽管最终结论是积极的,我们可能得出结论,为了正确分析像这篇评论这样的边缘案例而花费更多的钱是不值得的。

9.5 提示工程

除了交换模型的选择,我们还能做些什么来提高我们模型的性能?我们还没有探讨的一个领域是我们用于分类的提示定义。改变提示模板可以对结果质量产生重大影响。提示调整通常至关重要的这一事实甚至导致了专门术语“提示工程”的引入,描述了搜索最佳提示模板的过程。更重要的是,提示工程带来的挑战导致了多个平台的创建,为各种不同的任务提供提示模板。如果你对提示变体没有想法,可以看看promptbase.com/prompthero.com/和类似平台。这些平台的商业模式是使用户能够购买和销售针对特定任务优化特定模型性能的提示模板。

确定哪个提示效果最佳通常需要一些实验。接下来,我们将关注基础知识,并探讨通过改变提示来提高输出质量的一种经典技术。我们在这里讨论的是少样本学习,这意味着我们通过给它一些示例来帮助模型。这是我们日常生活中知道的事情:仅基于纯描述很难理解一个新任务或方法。看到一些示例来掌握它要好得多。例如,在前面的章节中,我们只需讨论几个相关的模型调整参数的语义。但不是更好吗?在具体示例场景中看到它们是如何调整的?

当然是。语言模型“感觉”到同样的方式,添加一些有用的示例通常可以提高它们的性能。那么我们如何向它们展示示例呢?很简单:我们将这些示例指定为提示的一部分。例如,在我们的分类场景中,我们希望语言模型能够对评论进行分类。一个例子就是一个评论和参考类别标签。

我们将使用以下提示模板将单个样本集成到提示中:

[Sample Review]
Is the sentiment positive or negative?
Answer ("pos"/"neg"):[Sample Solution]
[Review to Classify]
Is the sentiment positive or negative?
Answer ("pos"/"neg"):

如果我们用样例评论、样例评论解决方案和我们要分类的评论替换占位符,我们得到的提示如下:

Now, I won't deny that when I purchased #1
this off eBay, I had high expectations. ...
Is the sentiment positive or negative?  #2
Answer ("pos"/"neg"):neg          #3
I am willing to tolerate almost anything            #4
in a Sci-Fi movie, but this was almost intolerable. ...
Is the sentiment positive or negative?              #5
Answer ("pos"/"neg"): 

1 样例评论

2 指令

3 样例解决方案

4 待分类评论

5 指令

你可以看到一个样例评论(1),指令(2),以及样例评论的参考类别(3)。之后,你找到我们想要分类的评论(4)和分类指令(再次)(5),但还没有解决方案(当然不是——这正是我们希望语言模型生成的)。在这个提示中,我们向模型提供了一个正确解决任务的示例。这样做可能有助于模型更好地理解我们要求它做什么。

当然,在提示中提供样本有许多选项。我们选择了最简单直接的方法:我们为两个评论使用相同的提示结构两次。因为我们使用的是完全相同的结构,所以我们的提示稍微有些冗余:我们重复了任务指令(25),包括两个可能的类别标签的指定。虽然我们在这里不会这样做,但尝试以不同的方式将示例整合到提示中,去除冗余并缩短提示长度(从而减少处理的令牌数量,最终减少处理费用)可能很有趣。

到目前为止,我们只考虑了添加单个示例。但有时候,看到一个示例可能还不够。这就是为什么为语言模型添加多个示例也可能是有意义的。让我们假设我们有一些样本:带有相关类别标签的评论,存储在一个名为 samples 的数据框中。我们可以使用以下代码生成整合这些样本的提示:

def create_single_text_prompt(text, label):   #1
    """ Create prompt for classifying a single text.

    Args:
        text: text to classify.
        label: correct class label (empty if unavailable).

    Returns:
        Prompt for text classification.
    """
    task = 'Is the sentiment positive or negative?'
    answer_format = 'Answer ("pos"/"neg")'
    return f'{text}\n{task}\n{answer_format}:{label}'

def create_prompt(text, samples):            #2
    """ Generates prompt for sentiment classification.

    Args:
        text: classify this text.
        samples: integrate these samples into prompt.

    Returns:
        Input for LLM.
    """
    parts = []
    for _, row in samples.iterrows():  #3
        sample_text = row['text']
        sample_label = row['sentiment']
        prompt = create_single_text_prompt(sample_text, sample_label)
        parts += [prompt]

    prompt = create_single_text_prompt(text, ")  #4
    parts += [prompt]
    return '\n'.join(parts)

1 为一条评论创建提示

2 为所有评论生成提示

3 集成样本

4 添加评论以进行分类

create_single_text_prompt 函数(1)实例化以下模板,用于单个评论:

[Review]
Is the sentiment positive or negative?
Answer ("pos"/"neg"):[Label]

我们使用相同的函数来指定样本评论,以及指定我们希望语言模型为我们解决的问题的分类任务。如果我们指定样本评论,[Label] 占位符将被替换为对应评论的参考类别标签。如果我们指定语言模型应解决的问题,我们还没有正确的类别标签。在这种情况下,我们将 [Label] 占位符替换为空字符串。这将由语言模型来完成,以实际类别标签完成提示。

create_prompt 函数(2)生成完整的提示,考虑所有样本评论以及我们想要分类的评论。首先(3),它遍历样本评论。我们假设我们的 samples 数据框在 text 列中存储评论文本,在 sentiment 列中存储相关的类别标签。我们使用之前讨论过的 create_single_text_prompt 函数(4)为样本评论添加一个提示部分。最后,我们添加指令来分类我们感兴趣的评论。

让我们切换回使用 GPT-3.5 Turbo。然而,这次我们将使用我们新的提示生成函数。目前,我们将限制自己在提示中使用单个示例评论。在书的配套网站上,您可以在“评论训练”部分找到带有正确类别标签的训练评论,链接到文件 train_reviews.csv。这个文件中的评论与 reviews.csv 文件中的评论(我们用来测试我们的方法)不重叠。将 train_reviews.csv 中的第一条评论作为样本添加到提示中,现在您应该看到以下输出:

Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: pos; Ground truth: pos
Label: pos; Ground truth: neg
Label: pos; Ground truth: neg
Label: neg; Ground truth: neg
Label: pos; Ground truth: pos
Label: neg; Ground truth: neg
Number of correct labels:    8  #1
Number of tokens used   :    4078  #2

1 等同于 GPT-4 的结果

2 标记使用量大约翻倍

欢呼!我们已经将精度提高到 80%(1)。这与我们在原始提示(没有样本评论)上使用 GPT-4 所获得的精度相同。同时,我们的标记使用量也有所增加(2)。更准确地说,因为我们为每个提示添加了第二个评论(即,我们有一个样本评论和一个要分类的评论),与上一个版本相比,我们的标记消耗量大约翻倍。然而,与在较短的提示上使用 GPT-4 相比,我们当前的方法仍然便宜约 60 倍(因为使用 GPT-4 大约比使用 GPT-3.5 Turbo 贵 120 倍)。

9.6 可调分类器

现在我们已经看到了很多调整选项,你可能想尝试新的变体。例如,如果我们添加了样本,是否还需要添加偏差(本质上限制输出为两个可能的类别标签)?当我们使用更大的模型并在提示中添加多个样本时,能否获得更高的精度?将你的代码更改为尝试新的组合很快就会变得繁琐。但不用担心,我们已经为你准备好了!在本书的网站上,你可以在“可调分类器”部分找到列表 9.2。这个实现允许你通过设置正确的命令行参数来尝试所有调整变体。我们将快速讨论代码,该代码整合了之前讨论的所有代码变体。

生成提示(1)的工作方式如上一节所述。create_prompt 函数接受要分类的评论文本和样本评论作为输入。样本评论被添加到提示中,可能支持语言模型对感兴趣评论的分类。请注意,我们仍然可以在不指定任何样本的情况下看到语言模型的表现(通过不指定任何样本)。没有样本的分类对应于一个特殊情况。

列表 9.2 情感分类器的可调版本
import argparse
import openai
import pandas as pd
import time

client = openai.OpenAI()

def create_single_text_prompt(text, label):
    """ Create prompt for classifying a single text.

    Args:
        text: text to classify.
        label: correct class label (empty if unavailable).

    Returns:
        Prompt for text classification.
    """
    task = 'Is the sentiment positive or negative?'
    answer_format = 'Answer ("pos"/"neg")'
    return f'{text}\n{task}\n{answer_format}:{label}'

def create_prompt(text, samples):             #1
    """ Generates prompt for sentiment classification.

    Args:
        text: classify this text.
        samples: integrate these samples into prompt.

    Returns:
        Input for LLM.
    """
    parts = []
    for _, row in samples.iterrows():
        sample_text = row['text']
        sample_label = row['sentiment']
        prompt = create_single_text_prompt(sample_text, sample_label)
        parts += [prompt]

    prompt = create_single_text_prompt(text, ")
    parts += [prompt]
    return '\n'.join(parts)
 #2
def call_llm(prompt, model, max_tokens, out_tokens):
    """ Query large language model and return answer.

    Args:
        prompt: input prompt for language model.
        model: name of OpenAI model to choose.
        max_tokens: maximum output length in tokens.
        out_tokens: prioritize these token IDs in output.

    Returns:
        Answer by language model and total number of tokens.
    """
    optional_parameters = {}
    if max_tokens:
        optional_parameters['max_tokens'] = max_tokens
    if out_tokens:
        logit_bias = {int(tid):100 for tid in out_tokens.split(',')}
        optional_parameters['logit_bias'] = logit_bias

    for nr_retries in range(1, 4):
        try:
            response = client.chat.completions.create(
                model=model,
                messages=[
                    {'role':'user', 'content':prompt}
                    ],
                **optional_parameters, temperature=0
                )

            answer = response.choices[0].message.content
            nr_tokens = response.usage.total_tokens
            return answer, nr_tokens

        except Exception as e:
            print(f'Exception: {e}')
            time.sleep(nr_retries * 2)

    raise Exception('Cannot query OpenAI model!')

if __name__ == '__main__':

    parser = argparse.ArgumentParser()       #3
    parser.add_argument('file_path', type=str, help='Path to input file')
    parser.add_argument('model', type=str, help='Name of OpenAI model')
    parser.add_argument('max_tokens', type=int, help='Maximum output size')
    parser.add_argument('out_tokens', type=str, help='Tokens to prioritize')
    parser.add_argument('nr_samples', type=int, help='Number of samples')
    parser.add_argument('sample_path', type=str, help='Path to samples')
    args = parser.parse_args()

    df = pd.read_csv(args.file_path)

    samples = pd.DataFrame()  #4
    if args.nr_samples:
        samples = pd.read_csv(args.sample_path)
        samples = samples[:args.nr_samples]

    nr_correct = 0
    nr_tokens = 0

    for _, row in df.iterrows():

        text = row['text']   #5
        prompt = create_prompt(text, samples)
        label, current_tokens = call_llm(
            prompt, args.model, 
            args.max_tokens, 
            args.out_tokens)

        ground_truth = row['sentiment']  #6
        if label == ground_truth:
            nr_correct += 1
        nr_tokens += current_tokens

        print(f'Label: {label}; Ground truth: {ground_truth}')

         #7
    print(f'Number of correct labels:\t{nr_correct}')
    print(f'Number of tokens used   :\t{nr_tokens}')

1 使用样本生成提示

2 使用参数调用语言模型

3 解析命令行参数

4 从磁盘读取样本

5 分类评论

6 更新计数器

7 打印计数器

我们的 call_llm 函数(2)整合了之前提到的所有调整参数。首先是调用模型的名称(model 参数)。其次,我们可以指定最大输出标记数(max_tokens)。最后,我们可以指定偏差:在生成输出时应优先考虑的标记。out_tokens 参数允许用户指定一个以逗号分隔的标记 ID 列表,我们将为这些标记分配高优先级(本质上限制输出为这些标记之一)。尽管模型名称是必需的,但将 max_tokens 参数设置为 0 和将 out_tokens 参数设置为空字符串允许我们避免更改 OpenAI 的默认设置。

可调分类器使用相当多的命令行参数(3)。让我们按照你需要指定的顺序来讨论它们:

  • file_path—包含用于评估我们的语言模型的评论的.csv 文件的路径

  • model—我们想要使用的语言模型的名称(例如,gpt-3.5-turbo)

  • max_tokens—每个输入评论生成输出标记的最大数量

  • out_tokens—在生成输出时优先考虑的标记的逗号分隔列表

  • nr_samples—要整合到每个提示中的评论样本数量

  • sample_path—包含带有正确类别标签的评论的.csv 文件的路径,用作样本(如果将nr_samples参数设置为0,则可以为空)

警告 限制输出标记的数量几乎总是个好主意。特别是,你应该在将输出偏向特定标记而不包括任何“停止”标记(表示输出结束)时这样做。

在解析输入参数后,分类器从磁盘读取样本(4)并对评论进行分类(5),同时更新最终打印的计数器(6)。

让我们看看如何模拟我们迄今为止讨论的所有不同版本的分类器。使用以下调用应该会给出我们未调整的分类器版本,假设 reviews.csv 文件位于代码本身所在的目录中:

python tunable_classifier.py reviews.csv
    gpt-3.5-turbo 0 "" 0 ""

注意,我们没有指定任何优先考虑的标记(我们指定空字符串),不限制输出长度(将其设置为0表示没有限制),并将提示中的样本数量设置为0(这意味着我们也可以将包含样本的文件路径设置为空字符串)。

另一方面,以下命令将给出一个限制输出长度同时优先考虑对应于我们类别标签的标记的版本:

python tunable_classifier.py reviews.csv
    gpt-3.5-turbo 1 "981,29875" 0 ""

最后,我们可以通过以下命令获取我们讨论的最后版本,每次提示使用一个样本,同时像以前一样调整模型(假设 train_reviews.csv 文件位于与代码相同的存储库中):

python tunable_classifier.py reviews.csv
    gpt-3.5-turbo 1 "981,29875" 1 "train_reviews.csv"

尝试我们尚未讨论的新组合吧!

9.7 微调

到目前为止,我们已经尽我们所能从现有模型中榨取最佳性能。这些模型已经针对可能与我们感兴趣的任务相似但不完全相同的目标进行了训练。如果能够得到一个专门针对我们任务的定制模型,那岂不是很好?使用微调就可以实现这一点。让我们看看如何在实践中使用 OpenAI 的模型实现微调。

微调意味着我们取一个现有的模型,例如 OpenAI 的 GPT-3.5 Turbo 模型,并使其专门针对我们感兴趣的任务。当然,从原则上讲,我们可以从头开始训练我们的模型。但那通常成本过高,而且此外,我们通常找不到足够的特定任务训练数据来在训练期间维持一个大模型。这就是为什么依赖微调要好得多。

微调通常是我们为了最大化特定任务的性能而尝试的最后一件事。原因是微调需要在时间和金钱上做出一定的前期投资。在微调过程中,我们支付 OpenAI 为其基础模型之一创建一个定制的版本,专门用于我们的任务。价格基于训练数据的大小和训练数据被读取的次数(即,epoch的数量)。例如,在撰写本文时,微调 GPT-3.5 Turbo 的费用大约是每 1,000 个训练数据 token 和 epoch 0.8 美分。此外,微调后,我们还需要支付使用微调模型的费用。与基础版本相比,微调模型的每 token 价格更高。这在理论上是有道理的,因为至少在理论上,微调模型应该在我们的特定任务上表现更好。

微调的一个可能优势是提高模型输出的准确性。另一个可能的优势是,我们可能能够缩短我们的提示。当使用通用模型时,提示需要包含要执行的任务的描述(以及所有相关数据)。另一方面,我们的微调模型应该专门用于执行单个任务,并且在该任务上表现良好。如果模型只需要执行一个任务,原则上应该可以省略任务描述,因为它已经是隐含的。除了任务描述之外,我们还可以省略对通用模型有帮助但不是专门模型所必需的其他信息。例如,对于通用模型来说,可能需要将样本集成到提示中,以获得合理的输出质量,而对于微调版本来说,这可能是多余的。

在我们的特定场景中,我们希望将评论映射到类标签(基于评论作者的潜在情感)。之前,我们将分类任务作为提示的一部分进行了指定(甚至提供了一些有用的示例)。现在,也许在微调模型时,我们可以省略这些指令。更准确地说,我们可能不再需要使用以下提示(包含样本评论(1)、指令(2)和样本解决方案(3),以及要分类的评论(4)和相应的指令(5)):

Now, I won't deny that when I purchased #1
this off eBay, I had high expectations. ...
Is the sentiment positive or negative?  #2
Answer ("pos"/"neg"):neg          #3
I am willing to tolerate almost anything            #4
in a Sci-Fi movie, but this was almost intolerable. ...
Is the sentiment positive or negative?              #5
Answer ("pos"/"neg"): 

1 样本评论

2 指令

3 样本解决方案

4 分类评论

5 指令

相反,我们可以假设模型隐含地知道它应该对评论进行分类以及哪些类标签是可用的。基于这个假设,我们可以将提示简化为以下内容:

I am willing to tolerate almost anything
in a Sci-Fi movie, but this was almost intolerable. ...

这个提示仅仅陈述了我们想要分类的评论。我们假设所有其他特定任务的信息(例如指令和样本)模型已经隐含地知道。正如你肯定注意到的,这个提示比之前的版本要短得多。这意味着当我们使用微调模型而不是基础版本时,我们可能会节省一些钱。另一方面,请记住,使用微调模型比使用基础版本每 token 的成本更高。我们将相应的计算推迟到以后。但首先,让我们看看我们是否可以通过微调使这样的简洁提示在实践上发挥作用。

9.8 生成训练数据

首先,我们必须生成我们的微调训练数据。我们将使用包含在文件 train_reviews.csv 中的评论及其关联的标签,该文件可在配套网站上的“评论训练”部分找到。OpenAI 期望微调的训练数据具有一个非常特定的格式。在我们能够微调之前,我们需要将我们的.csv 数据转换为所需的格式。

微调 OpenAI 的聊天模型的数据通常采用与模型成功交互的形式(即,模型产生我们理想中想要产生的输出的例子)。在 OpenAI 的聊天模型的情况下,这种交互通过消息历史来描述。每条消息都由一个 Python 字典对象描述。例如,以下描述了一个成功完成,给定之前的示例评论作为输入:

{'messages':[
    {'role':'user', 'content':'I am willing to tolerate almost anything 
    ↪ ...'},
    {'role':'assistant', 'content':'neg'}
]}

这是一个负面评论(即,评论作者不想推荐这部电影),因此,我们理想中希望模型生成包含单个 token neg 的消息。这就是这里描述的交互。

为了使微调变得值得,你通常至少需要使用 50 个样本,最多几千个样本。使用更多样本进行微调可以提高性能,但成本也更高。另一方面,这是一个一次性费用,因为你可以为可能的大型数据集重复使用相同的微调模型(并且微调模型的费用不取决于用于微调的训练数据量)。示例文件(reviews_train.csv)包含 100 个样本,因此它位于微调可能变得有用的数据大小范围内。

OpenAI 期望微调数据以 JSON-line 格式(这类文件通常有.jsonl 后缀)。符合此格式的文件实际上每行包含一个 Python 字典。在这种情况下,每行描述了与模型的一次成功交互(使用与上一个示例相同的格式)。为了更容易地从 Python 处理 JSON-line 文件,我们将使用jsonlines库。作为第一步,前往终端并使用以下命令安装库:

pip install jsonlines==4.0

现在我们可以使用库将我们的 .csv 数据转换为 OpenAI 所需的格式。列表 9.3 使用 get_samples 函数(1)准备所需格式的样本。输入是一个包含训练样本的 pandas DataFrame(df 参数),这些样本以通常的格式表示(我们假设 text 列包含评论,而 sentiment 列包含相关的类标签)。我们将每个样本转换为与模型成功消息交换。首先,我们创建用户发送的消息(2),它仅包括评论文本。其次,我们创建模型要生成的期望答案消息(与“助手”角色相关)(3)。完整的训练样本集是一系列消息交换,每个都以前述格式准备。

列表 9.3 为微调生成训练数据
import argparse
import jsonlines
import pandas

def get_samples(df):                    #1
    """ Generate samples from a data frame.

    Args:
        df: data frame containing samples.

    Returns:
        List of samples in OpenAI format for fine-tuning.
    """
    samples = []
    for _, row in df.iterrows():
         #2
        text = row['text']
        user_message = {'role':'user', 'content':text}
         #3
        label = row['sentiment']
        assistant_message = {'role':'assistant', 'content':label}

        sample = {'messages':[user_message, assistant_message]}
        samples += [sample]

    return samples

if __name__ == '__main__':
    #4
    parser = argparse.ArgumentParser()
    parser.add_argument('in_path', type=str, help='Path to input')
    parser.add_argument('out_path', type=str, help='Path to output')
    args = parser.parse_args()

    df = pandas.read_csv(args.in_path)
    samples = get_samples(df)    
     #5
    with jsonlines.open(args.out_path, 'w') as file:
        for sample in samples:
            file.write(sample)

1 生成训练数据

2 创建用户消息

3 创建助手消息

4 解析命令行参数

5 以新格式存储训练数据

列表 9.3 预期输入一个包含训练样本 .csv 文件的路径,以及输出文件的路径(4)。输出文件遵循 JSON-lines 格式,因此我们理想地分配一个以 .jsonl 结尾的输出路径。在将输入 .csv 文件转换为微调格式后,我们使用 jsonlines 库将转换后的样本写入 JSON-lines 文件(5)。

如往常一样,您不需要为此列表输入代码。您可以在网站上的“准备微调”部分找到它。使用以下命令在终端中运行它(我们假设 train_reviews.csv 文件位于与代码相同的存储库中):

python prep_fine_tuning.py   train_reviews.csv train_reviews.jsonl

您可能需要手动检查由运行此命令(希望)生成的 train_reviews.jsonl 文件。您应该看到每行一个训练样本,以 Python 字典的形式表示。

9.9 开始微调作业

现在我们已经将训练数据格式化正确,我们可以在 OpenAI 的平台上创建一个微调作业。当然,因为模型仅存储在 OpenAI 的平台上,我们无法自行进行微调。相反,我们将我们的训练数据发送给 OpenAI,并请求使用这些数据创建一个定制模型。要创建一个定制模型,我们首先必须选择一个基础模型。在这种情况下,我们将从 GPT-3.5 Turbo 模型开始(这使得与迄今为止获得的结果进行比较更容易)。

我们可以使用以下代码片段创建微调作业(假设 in_path 是包含训练数据的文件的路径):

import openai
client = openai.OpenAI()

reply = client.files.create(
    file=open(in_path, 'rb'), purpose='fine-tune')

reply 对象将包含有关我们微调作业的元数据的 Python 对象(假设作业创建成功)。最重要的是,我们在 reply.id 字段中获取了我们刚刚创建的作业的 ID。微调作业通常需要一段时间(我们描述的微调作业通常需要大约 15 分钟)。这意味着我们必须等待我们的微调模型被创建。作业 ID 允许我们验证微调作业的状态,并在模型可用时检索新创建的模型的 ID。我们可以使用以下 Python 代码检索关于微调作业的状态信息:

reply = client.fine_tuning.jobs.retrieve(job_id)

reply.status 字段报告微调作业的状态,最终将达到 succeeded 值。在那之后,我们可以在 reply.fine_tuned_model 中检索微调模型的 ID。

列表 9.4 开始微调过程,等待相应的作业完成,并最终打印出生成的模型的 ID。给定包含训练数据的文件路径,代码首先上传包含训练数据的文件(1)。它检索 OpenAI 分配的文件 ID,并使用它来创建微调作业(2)。然后,我们迭代直到微调作业成功完成(3)。在每次迭代中,我们打印出一个计时器(测量自微调作业开始以来的秒数)并检查作业的状态更新(4)。最后,我们检索模型 ID 并打印它(5)。

列表 9.4 使用训练数据微调 GPT 模型
import argparse
import openai
import time

client = openai.OpenAI()

if __name__ == '__main__':

    parser = argparse.ArgumentParser()
    parser.add_argument('in_path', type=str, help='Path to input file')
    args = parser.parse_args()

    reply = client.files.create(              #1
        file=open(args.in_path, 'rb'), purpose='fine-tune')
    file_id = reply.id

    reply = client.fine_tuning.jobs.create(       #2
        training_file=file_id, model='gpt-3.5-turbo')
    job_id = reply.id
    print(f'Job ID: {job_id}')

    status = None
    start_s = time.time()

    while not (status == 'succeeded'):  #3

        time.sleep(5)
        total_s = time.time() - start_s
        print(f'Fine-tuning since {total_s} seconds.')
         #4
        reply = client.fine_tuning.jobs.retrieve(job_id) 
        status = reply.status
        print(f'Status: {status}')
    #5
    print(f'Fine-tuning is finished!')
    model_id = reply.fine_tuned_model
    print(f'Model ID: {model_id}')

1 将训练数据上传到 OpenAI

2 创建微调作业

3 循环直到作业完成

4 获取作业状态

5 获取微调模型的 ID

你可以在“开始微调”网页上找到代码。使用以下命令运行它(其中 train_reviews.jsonl 是之前生成的文件):

python fine_tune.py train_reviews.jsonl

如果你运行脚本直到完成,你将看到如下输出(这当然只是输出的一部分;点代表缺失的行):

Job ID: ...
Fine-tuning since 5.00495171546936 seconds.
Status: validating_files
...
Fine-tuning since 46.79299879074097 seconds.
Status: running
...
Fine-tuning since 834.6565797328949 seconds.
Status: succeeded
Fine-tuning is finished!
Model ID: ft:gpt-3.5-turbo-0613...

在打印出作业 ID 后,我们会定期收到关于作业状态的更新,通常是从 validating_filesrunning,然后(希望)到 succeeded。问题是作业可能需要一段时间才能完成(对于前面的例子,大约需要 14 分钟)。如果你不想连续运行脚本(例如,为了关闭电脑),你可以在微调作业开始后中断脚本(你将知道因为脚本在那个点打印出了作业 ID)。微调作业将在 OpenAI 的服务器上按计划进行。根据你的设置,你甚至可能会收到一封电子邮件通知你作业已完成。否则,你可以定期运行此脚本。

列表 9.5 检查微调作业的状态
import argparse
import openai

client = openai.OpenAI()

if __name__ == '__main__':

    parser = argparse.ArgumentParser()
    parser.add_argument('job_id', type=str, help='ID of fine-tuning job')
    args = parser.parse_args()
     #1
    job_info = client.fine_tuning.jobs.retrieve(args.job_id)
    print(job_info)

1 获取并打印作业元数据

给定工作 ID(从列出 9.4 的输出中检索),脚本检索并打印工作元数据(1),包括工作状态和成功完成工作后的结果模型 ID。

9.10 使用微调模型

恭喜!你已经创建了一个专门化的模型,针对你关心的任务(审查分类)进行了微调。你该如何使用它?幸运的是,使用 OpenAI 库这样做很简单。我们不再指定标准模型之一(例如,gpt-3.5-turbo)的名称,而是现在指定我们微调模型的 ID,如下所示(将占位符[Fine-tuned model ID]替换为实际的模型 ID):

import openai
client = openai.OpenAI()

response = client.chat.completions.create(
    model='[Fine-tuned model ID]',
    messages=[
        {'role':'user', 'content':prompt}
        ]
    )

如前所述,我们假设prompt变量包含提示文本。然而,对于我们的微调模型,提示有所不同。以前,我们描述了分类任务,包括审查文本。现在我们已训练我们的自定义模型,将审查文本单独映射到适当的类别。这意味着我们的提示生成函数简化为以下内容(实际上,你可能会争论创建专用函数不再需要):

def create_prompt(text):
    """ Create prompt for sentiment classification.

    Args:
        text: text to classify.

    Returns:
        Prompt for text classification.
    """
    return text

我们不是生成多部分提示,而是将审查文本返回以进行分类。你可能想了解使用简化提示与原始模型(gpt-3.5-turbo)时会发生什么。你将看到如下输出:

Label: I understand your concern about smoking in movies, 
especially those intended for children and adolescents. 
Smoking in films can have an influence on young viewers 
and potentially normalize the behavior. However, it is 
important to note that not all instances of smoking in 
movies are the result of intentional product placement 
or sponsorship by tobacco companies.
...

显然,模型对我们的意图感到困惑——也就是说,我们期望它如何处理输入的审查。它没有生成正确的类别标签,而是写下了详尽的评论,评论了审查中提出的主要观点。这是意料之中的。想象一下,如果有人给你一份没有进一步指示的审查,你会知道那个人想让你分类审查,更不用说可能的类别的正确标签了?这几乎是不可能的,语言模型也是如此。

然而,如果我们切换到我们的微调模型并使用相同的提示作为输入,我们将得到以下输出而不是:

Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: neg
Label: pos; Ground truth: pos
Label: pos; Ground truth: neg
Label: pos; Ground truth: neg
Label: neg; Ground truth: neg
Label: neg; Ground truth: pos
Label: neg; Ground truth: neg
Number of correct labels:    7  #1
Number of tokens used   :    2085  #2

1 提高准确性

2 降低令牌消耗

注意,即使没有设置任何微调参数(或在提示中提供任何样本),我们现在得到的准确率是 70%(1),而不是原始版本的 60%!而且,与初始版本相比,使用的令牌数量减少了大约 200(2)。这是因为我们在每个提示中省略了指令(和类别标签)。

好的!我们已经看到,我们可以微调一个模型以准确分类评论,同时减少提示大小。但问题仍然存在:这是否值得?让我们做一些计算来找出答案。我们暂时不考虑微调模型的成本,因为我们只需要做一次(在我们的示例场景中,我们假设我们想要分析一年的评论)。在不进行微调的情况下,当利用调整参数(设置偏差和输出标记数的限制)时,我们可以使用通用模型达到相同的准确率(70%)。在这种情况下,我们为我们的 10 个样本评论使用了 2,228 个标记。微调后,我们只为我们的样本评论使用了 2,085 个标记。然而,对于通用模型,我们每 1,000 个输入标记支付 0.05 美分。另一方面,对于微调模型,我们每 1,000 个标记支付 0.3 美分。这意味着微调后我们的每标记成本提高了六倍!在这个特定场景中,处理标记数的适度减少并不能抵消每标记更高费用的增加。

通常,微调在提高质量和可能降低成本方面非常有帮助。然而,请注意,它伴随着各种开销。在生产中使用微调模型之前,请进行实验评估,进行计算,并确保它是值得的!

摘要

  • 调整参数设置可以影响模型性能和成本。

  • 考虑限制输出长度并引入标记对数偏差。

  • 不要总是使用可用的最大模型,因为这样做会增加成本。

  • 通过在样本上评估来确定最适合您任务的模型。

  • 提示的设计可以对性能产生重大影响。

  • 在提示中包含正确解决的任务样本,以进行少样本学习。

  • 微调允许您将基础模型专门化到您关心的任务上。它可能允许您通过专门化来减少提示大小。

  • 微调产生的开销与训练数据量成正比。当您使用生成的模型时,它还会增加每标记的成本。

第十章:个软件框架

本章涵盖

  • 使用 LangChain 构建应用

  • 使用代理解决复杂任务

  • 使用 LlamaIndex 查询数据

到目前为止,我们主要使用 OpenAI 的 Python 库与语言模型进行交互。这个库提供了向 GPT 和其他 OpenAI 模型发送提示和检索答案的基本功能(以及调整和微调的选项)。来自其他提供商的库,如 Anthropic 和 Cohere,提供类似的功能。只要你的数据分析任务简单,这可能就是你所需要的。然而,如果你的数据分析需要复杂的、多步骤的流水线,可能需要整合许多不同的数据格式呢?

到那时,你可能想切换到一个更强大的软件框架。目前,有几个用于在语言模型之上构建复杂应用的更高层次框架正在兴起。在本章中,我们将讨论两个最受欢迎的竞争者:LangChain 和 LlamaIndex。前者是一个用于构建使用大型语言模型的应用的通用框架。更重要的是,它附带各种有用的内置组件,实现了语言模型的流行用例。另一方面,LlamaIndex 专门支持语言模型需要与大型数据集交互的用例。

为了熟悉这个工具,我们首先将使用 LangChain 编写一个简单的文本分类流水线。然后我们将探索 LangChain 的一些高级功能。更确切地说,我们将看到 LangChain 如何在语言模型之上支持 代理。创建一个代理意味着将语言模型本身置于驾驶员的位置,给它在完成特定任务时使用用户提供的工具集的自由。我们将使用这样的代理独立解决复杂的数据分析任务,使用各种工具访问不同的数据源。接下来,我们将看到 LlamaIndex 如何轻松地摄取大量不同格式的数据,并使其对语言模型可用。内部,它使用廉价的语言模型将数据片段和分析任务映射到向量表示,然后根据这些向量的相似性将任务映射到数据。最后,我们将比较这两个框架,并讨论这些框架与 OpenAI 和其他语言模型提供商提供的库之间的权衡。

10.1 LangChain

如果你想要创建一个基于语言模型的复杂应用,你可能应该检查一下 LangChain。该框架于 2022 年 10 月推出,并迅速获得了人气(导致 2023 年 4 月创建了一个相应的初创公司)。在撰写本文时,LangChain 仍在快速发展。请确保使用正确的 LangChain 版本运行本节中的代码(因为未来的版本可能会更改接口)。

如其名所示,LangChain 与语言模型(Lang)和链(Chain)相关。在 LangChain 术语中,一个chain只是一个步骤序列。每个步骤可能对应于调用一个语言模型、一个数据处理步骤或调用一个任意工具。这里的重要点是,我们不再假设单次调用语言模型就能解决我们的问题(这在本书中讨论的大多数场景中都是如此)。相反,我们假设我们需要一个复杂的连接组件网络。这正是 LangChain 大放异彩的场景!

要使用 LangChain,你首先需要安装它。打开终端,并运行以下命令:

pip install langchain==0.1.13

正如我们提到的,如果你想运行以下代码示例,你需要安装正确的 LangChain 版本!LangChain 目前正在快速变化,所以代码可能无法与不同版本兼容。

除了 LangChain 的核心功能之外,你可能还需要安装支持特定提供商语言模型的库。在接下来的章节中,我们将使用 OpenAI 的模型。请在终端中运行以下命令(并且,再次提醒,请确保使用指定的版本):

pip install langchain-openai==0.1.1

对其他提供商的支持,如 Anthropic 和 Cohere,同样可用。

好的,这就完成了!运行这些命令后,你就可以运行下一节中讨论的示例项目了。

10.2 使用 LangChain 对评论进行分类

我们最早的项目之一是使用语言模型进行文本分析。还记得第四章吗?我们使用语言模型根据潜在的情感(这是推荐还是警告?)对评论进行分类。在这里,我们也将做同样的事情;我们只是会在代码中使用 LangChain。将 LangChain 的代码与原始代码进行比较,应该能给你一个 LangChain 如何帮助简化使用语言模型构建应用程序的第一印象。

10.2.1 概述

我们将创建一个用于分类文本文档的链。一个 LangChain 链可能涉及许多步骤,每个步骤通过调用一个语言模型或一个通用的 Python 函数(例如,将语言模型调用的结果解析成标准格式)来实现。术语chain实际上有些误导。虽然你可能想象链是一个连续步骤的序列,但 LangChain 中的链要强大得多。例如,它们可能涉及并行步骤以及条件执行。然而,对于简单的文本分类应用,我们不需要这样的高级功能。相反,我们将限制自己使用只有几个步骤的简单链。

我们的链将集成 LangChain 提供的几个标准组件。我们链中的第一个组件是一个提示模板。正如第四章中所述,这个模板描述了分类任务和预期的输出格式。你可能想知道与之前的代码版本相比有什么变化。毕竟,我们一直在讨论提示模板。区别在于 LangChain 引入了一个专门用于表示提示模板的类。这个类为提示模板提供了各种便利函数:例如,用于创建和实例化它们。同时,LangChain 提供了一个中心,允许用户上传和下载提示模板(以及许多其他组件)。在我们的简单场景中,我们不需要这些高级功能。相反,我们只需要通过传递一个参数(要分类的文本)来实例化我们的提示模板。

我们链中的第二步是一个语言模型。同样,我们在这本书的整个过程中一直在使用语言模型,但 LangChain 在语言模型对象之上增加了几个有用的功能。例如,自动记录所有语言模型调用变得很容易,LangChain 还提供了针对不同调用场景的便利函数(例如,批处理和流处理)。再次强调,我们在这里不会使用这些高级功能。相反,我们将提示(我们链中的第一步)传递给语言模型以生成回复。

我们链中的第三步是一个解析器,它从语言模型生成的回复中提取答案字符串。你可能还记得第三章中提到的,OpenAI 的语言模型会生成详细的回复,其中包含一个或多个答案以及各种类型的元数据(例如,关于标记使用的相关信息)。解析器会自动从结果对象中提取我们所需的答案字符串(这对于 OpenAI 模型以及所有其他提供商都适用)。管道的结果是一个表示输入评论是否为推荐的单一标记。图 10.1 展示了这个管道的三个步骤。

figure

图 10.1 LangChain 分类链中的组件

10.2.2 创建分类链

是时候用 Python 实现我们的链了!首先我们需要一个提示模板。我们使用与第四章相同的模板,但这次我们使用 LangChain 的ChatPromptTemplate类:

from langchain_core.prompts.chat import ChatPromptTemplate
prompt = ChatPromptTemplate.from_template(
    '{text}\n'                         #1
    'Is the sentiment positive or negative?\n'
    'Answer ("Positive"/"Negative")\n')

1 文本占位符

你可能会注意到对聊天模型的引用(毕竟,我们实例化的类叫做 ChatPromptTemplate)。在第三章中讨论过,聊天模型处理的是一系列先前的消息历史,而不是单个输入消息。许多最近发布的模型都是聊天模型。在 LangChain 中,聊天模型需要一个专门的提示模板(它实例化为一系列消息而不是单个文本)。这正是我们在这里创建的模板类型。该模板与第四章中使用的模板相同,它包含一个用于对输入文本进行分类的占位符(1)。我们通常使用花括号({})在提示模板中标记占位符;在实例化提示时,它们会被具体的值替换。

第二,我们需要一个语言模型来处理提示。以下代码实例化了 OpenAI 的 GPT-4o 模型:

from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
    model='gpt-4o', temperature=0, 
    max_tokens=1)

ChatOpenAI 类涵盖了 OpenAI 的所有聊天模型。它从 langchain_openai 包中导入,具有在 LangChain 中使用 OpenAI 模型的功能。其他提供商,如 Anthropic 和 Cohere,也有它们自己的相关包,为它们的模型提供类似的功能(注意,你需要通过 pip 分别安装这些包)。ChatOpenAI 构造函数中的参数可能看起来很熟悉:我们选择模型(gpt-4o),将 temperature 设置为 0(以减少输出中的随机程度),并将最大输出令牌数限制为 1(因为可能的两个类别标签,PositiveNegative,都由单个令牌组成)。

第三,我们需要从语言模型的(更详细的)回复中提取答案字符串。使用 StrOutputParser 来做这件事很简单。LangChain 输出解析器实现了一系列对模型调用输出的转换。在这种情况下,我们只需要一个非常简单的转换,提取所需的答案字符串。以下代码创建了一个相应的解析器:

from langchain_core.output_parsers.string import StrOutputParser
parser = StrOutputParser()

最后,我们将所有组件串联在一起。为此,我们可以使用 LangChain 表达式语言(LCEL)。如果你是 Linux 用户,以下语法应该对你来说很熟悉:

from langchain_core.runnables.passthrough import RunnablePassthrough
chain = ({'text':RunnablePassthrough()} | prompt | llm | parser)

要将操作的输出用作后续步骤的输入,我们使用管道符号(`|)将它们连接起来。该命令创建了一个连接先前提到的组件的链。此外,它还指定了链期望的输入。在我们的例子中,提示模板有一个用于对文本进行分类的占位符。

py`At the start of the chain, we mark this parameter as `RunnablePassthrough`. This gives us a lot of flexibility in terms of how we pass inputs to the chain. For instance, the following code illustrates how to process a list of inputs using the previously created chain: 输入 = ['这部电影太棒了!', '这部电影太糟糕了!'] 输出 = chain.batch(inputs) py ### 10.2.3 Putting it together Time to finalize our code for text classification! The code in listing 10.1 takes as input a path to a .csv file containing a `text` column. Executing the code generates a result file containing an additional column called `class` with the classification result. In other words, the code does exactly the same thing as that from chapter 4, but this time using LangChain. ##### Listing 10.1 Sentiment classification using LangChain 从 langchain_openai 导入 ChatOpenAI 从 langchain_core.prompts.chat 导入 ChatPromptTemplate 从 langchain_core.output_parsers.string 导入 StrOutputParser 从 langchain_core.runnables.passthrough 导入 RunnablePassthrough 导入 argparse 导入 pandas as pd def create_chain(): #1 """ 创建用于文本分类的链。 返回值: 用于文本分类的链。 """ prompt = ChatPromptTemplate.from_template( #2 '{text}\n' '情感是积极还是消极?\n' '回答 ("Positive"/"Negative")\n') llm = ChatOpenAI( #3 model='gpt-4o', temperature=0, max_tokens=1) parser = StrOutputParser() #4 #5 chain = ({'text':RunnablePassthrough()} | prompt | llm | parser) return chain if name == 'main': parser = argparse.ArgumentParser() parser.add_argument('file_path', type=str, help='输入文件路径') args = parser.parse_args() df = pd.read_csv(args.file_path) #6 chain = create_chain() #7 results = chain.batch(list(df['text'])) #8 df['class'] = results #9 df.to_csv('result.csv') py #1 Creates a chain #2 Creates a prompt template #3 Creates an LLM object #4 Creates an output parser #5 Creates a chain #6 Reads the data #7 Creates a chain #8 Uses it #9 Stores the output The `create_chain` function (**1**) implements the steps discussed in the last section. It generates a prompt template for classification (**2**), then a chat model (**3**), and finally an output parser (**4**). The result is a chain connecting all those components (**5**). After reading the command-line parameters, the code reads the input data (**6**), creates a corresponding chain (**7**), and finally applies the chain to the list of input texts (**8**). The classification results are added to the input data and stored on disk (**9**). ### 10.2.4 Trying it out Time to try it! As usual, you will find the code for listing 10.1 on the book’s companion website in the chapter 10 section. Download the code (the listing1.py file) and, optionally, a file containing reviews to classify (such as reviews.csv from chapter 4). Open the terminal, and switch to the folder containing the code. Assuming that reviews.csv is located in the same folder, run the following command: python listing1.py reviews.csv py Check the folder containing the code. You should see a new file, result.csv, with the desired classification results. So far, we have only verified that we can do the same things using LangChain that we can do with OpenAI’s libraries directly (even though, arguably, the LangChain code is cleaner). In the next section, we’ll see that LangChain enables us to do much more than that. ## 10.3 Agents: Putting the large language model into the driver’s seat So far, you may have considered language models as (highly sophisticated) tools. Based on your input, the language model produces output. If data processing requires more than the language model can accomplish, it is up to you, the developer, to add the necessary infrastructure. For instance, assume that you’re building a question-answering system for math questions. Realizing that language models are bad at calculating things (which, ironically for a computer program, they are), you may consider the following approach: based on the user question, the language model translates the input into a mathematical formula. Then that formula is parsed and evaluated by a simple calculator tool. The output of that tool is sent to the user. So far, so good. It gets more complicated in situations where you have not one but multiple math tools. Perhaps one tool solves differential equations, and another evaluates simple arithmetic equations. In such cases, you can expand your approach with a classification stage, mapping the user input to the most suitable tool. However, this approach breaks down in situations where answering the user question may require not applying a single tool but multiple invocations of different tools, possibly using the output of one tool as input for the next invocations. In such cases, manually covering each possible sequence of required tool invocations is simply not feasible. This is the type of use case where *agents* become useful. Agents are a fairly novel way of using large language models. At the core of this approach is a change in perspective. Instead of considering the language model a tool used as a step within a pipeline designed by the developer, we make the language model an independent agent. Rather than trying to orchestrate the order in which the language model and other processing steps are applied (which we did in the last section), we leave it up to the language model to decide which processing steps are applied in which order. The advantage of this approach is that it is much more flexible, freeing us as developers from having to foresee each possible development in advance to create an associated branch in our processing logic. Agents can be useful for complex data-analysis tasks where it is unclear, a priori, which data sources or processing methods may be required to satisfy a user’s request. Two terms are central to the agent approach, and we will look at them next: the *agent* and its *tools*. Let’s start by discussing tools. A tool can encapsulate arbitrary functionality. It is a function that the language model can use if it deems it necessary. When we use LangChain or similar frameworks to implement agents, a tool is typically implemented as a Python function. Each tool must be associated with a description in natural language. This description is shown to the language model as part of the prompts. Based on this description, the language model can decide whether a tool seems helpful in a given context. To use a tool, the language model requires a description of the input parameters and the output semantics. Similar to human programmers, choosing meaningful parameter names and writing precise documentation helps language models use tools effectively. Because agents are implemented via language models, a full description of all available tools is typically provided as part of the input prompt. Agents use tools whenever they are required to solve a complex task specified by the user. Agents are implemented via language models. Although fine-tuning can improve the performance of language models as agents, generic models should work in principle. The secret behind turning language models into agents lies less within the model itself but rather in the way it is prompted. At a minimum, corresponding prompts integrate the following components: * A description of a high-level task the agent should solve. This description is provided by the user. * A list of available tools, together with a description of their functionality and their input and output parameters. * A description of the expected output format. This enables mapping the output of the language model to tool invocations. Given such a prompt, the language model can produce output requesting specific tool invocations. The infrastructure implementing the agent approach parses the output, maps it to corresponding tools and input parameter values, and obtains the invocation result. In the next iteration, the result of the tool invocation is added to the input prompt. In this way, the language model can essentially *access* the results of tool invocations. Based on that, the language model can choose to apply more tools (possibly using the results of prior tool invocations as inputs) or terminate if a final answer is available. Figure 10.2 summarizes this process. The user-specified task, together with a detailed description of all tools, forms the input to the language model. The output of the language model is parsed and mapped to an action. Either this action represents the invocation of a tool (in that case, the invocation command contains values for all input parameters of the tool), or it represents termination (in this case, the termination command contains what the language model believes is an answer to the input task). If the action is a tool invocation, the corresponding call is executed. The result is added to the prompt used in the next iteration. Iterations continue until the language model decides to terminate (or until a user-specified limit on the number of iterations is reached). ![figure](https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dtanls-llm/img/CH10_F02_Trummer.png) ##### Figure 10.2 Using language models as agents. Given a prompt describing the task and available tools, the language model decides on termination and tool invocations. Results of tool invocations are added to the prompt used for the next iteration. At this point, you may be curious what the corresponding prompts look like. Let’s examine the standard prompt template used for agents in LangChain. You can download the prompt template from LangChain’s hub. If you want to do so, install the hub first using the following command in the terminal: pip install langchainhub0.1.15 py Then run the following code in Python to print out the standard template for one of the most popular agent types: 从 langchain 导入 hub prompt = hub.pull('hwchase17/react') print(prompt.template) py You should see the following output: #1 尽可能回答以下问题。您可以使用以下工具: {tools} #2 使用以下格式: #3 问题:您必须回答的输入问题 Thought:您应该始终思考要做什么 Action:要采取的操作,应该是 [{tool_names}] 之一 Action Input:操作的输入 Observation:操作的结果 ... (此 Thought/Action/Action Input/Observation 可以重复 N 次) Thought:我现在知道最终答案 Final Answer:原始输入问题的最终答案 开始! 问题:{input} #4 Thought:{agent_scratchpad} #5 py #1 General scenario #2 Tool descriptions #3 Format description #4 User input #5 Prior results This prompt template describes the general scenario (**1**) (there is a question that needs answering), available tools (**2**), and the process to solve the task (**3**). The prompt template contains multiple placeholders representing tool descriptions (**2**), the input from the user (**4**), and the results of prior iterations (**5**). As we will see in the following sections, LangChain offers various convenience functions to create and execute agents based on this and similar prompt templates. ## 10.4 Building an agent for data analysis In this section, we will use LangChain to build an agent for data analysis. This agent will be able to access different data sources with structured and unstructured data. What’s more, the agent will decide which of those sources to access and in which order. It may even use information obtained from one source to query a second source (e.g., to access a structured database about video game sales to identify the most sold game in a specific year and then use the game title to query the web for further information). ### 10.4.1 Overview Our data-analysis agent implements the approach we discussed in the previous section. It uses a language model to decide which tools to invoke in which order and with what input parameters. In our example scenario, we will provide the agent with tools to access a relational database (as well as obtain information about its structure, such as the names of available tables). We also provide the agent with a tool that enables web search (exploiting existing search engines in the background). Taken together, we get an agent that can query a relational database and use the web to obtain information that relates to the database content. Let’s start our discussion with a more detailed description of the tools we will provide to the agent. In total, the agent will have access to the following five tools: * `sql_db_list_tables` lists all tables in the relational database. * `sql_db_schema` returns the SQL schema of a table, given the table name. * `sql_db_query_checker` enables the agent to validate an SQL query. * `sql_db_query` evaluates an SQL query and returns the query result. * `search` enables the agent to search the web via keywords, returning web text. The first four tools help the agent access a relational database. The last tool enables the agent to retrieve information from the web. Given a user-specified task, the agent decides (using the underlying language model) which of these tools to invoke and in which order. Figure 10.3 illustrates this scenario. ![figure](https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dtanls-llm/img/CH10_F03_Trummer.png) ##### Figure 10.3 The data agent uses multiple tools to explore the structure and query a relational data- base. In addition, the agent can retrieve web text via the web search tool. ### 10.4.2 Creating an agent with LangChain Creating an agent with LangChain is fast! LangChain even offers specialized constructors for agents that access a structured database. We will use those features in the following code. Agents are implemented via language models. To create an agent, we first have to create a language model object: 从 langchain_openai 导入 ChatOpenAI llm = ChatOpenAI( temperature=0, model='gpt-4o') py We’re creating an OpenAI language model of type chat. More precisely, we refer to the GPT-4o model again. Next, we create an object representing our relational database. We will query an SQLite database stored on disk. Assume that `dbpath` stores the path of the corresponding database file (typically, such files have the .db suffix). We can create a database object using the following code: 从 langchain_community.utilities.sql_database 导入 SQLDatabase db = SQLDatabase.from_uri(f'sqlite:///{dbpath}') py We mentioned four tools for accessing the relational database. Fortunately, all of these tools will be automatically created from the database object. However, we still need to create a tool for web search. We will use a built-in component of LangChain, the SerpAPI tool. To use this tool, you first need to register for an account on the SerpAPI website. Open your browser, go to [`serpapi.com/`](https://serpapi.com/), click the Register button, and create a corresponding account. To execute the code presented next, you will need to retrieve your API access key (available at [`serpapi.com/dashboard`](https://serpapi.com/dashboard)). You also need to install a LangChain extension to enable the web search tool. Go to the terminal, and run the following command: pip install google-search-results2.4.2 py After that, all it takes is the following snippet of Python code to generate a tool for web search (assuming that `llm` contains the previously created language model object and `serpaikey` the SerpAPI access key): 从 langchain.agents.load_tools 导入 load_tools extra_tools = load_tools( ['serpapi'], serpapi_api_key=serpaikey, llm=llm) py The `load_tools` function is used for standard tools by passing the names of the desired tools as parameters. In this case, we only need the web search tool, and we pass only a single entry in the list of tool names (`serpapi`). After the call to `load_tools`, we store the result in `extra_tools`: a list of tools with a single entry (the web search tool). We now have all the components we need to create an agent using LangChain. Assume that `db` contains the database object, created previously, and `llm` the language model generated before. We initialize an agent for SQL-based data access using the following code: 从 langchain_community.agent_toolkits.sql.base 导入 create_sql_agent agent = create_sql_agent( llm=llm, db=db, verbose=True, agent_type='openai-tools', extra_tools=extra_tools) py The `create_sql_agent` command is a convenience function offered by LangChain to create agents for SQL-based data access. The four previously mentioned tools for relational database access (useful for retrieving table names, showing table schemata, validating SQL queries, and, ultimately, issuing them) are added automatically without us having to add them explicitly. There is only one more tool we want in addition to the SQL-focused tools: the web search capability. Such tools are specified in a list via the `extra_tools` input parameter. Setting the `verbose` flag to `True` enables us to follow the “thought process” leading the agent to call specific tools (we will see some example output later). The agent type, `openai-tools` in this case, determines the precise prompt to use as well as which parsers to use to map the output of the language model to tool invocations. After creating the agent, we use the following code to apply the agent to a specific task (we assume that the variable `task` stores a natural language description of the task we want to solve): agent.invoke({'input':task}) py ### 10.4.3 Complete code for data-analysis agent Listing 10.2 brings all of this together: after reading the SerpAPI API access key, as well as the path to the database file and a question from the command line, it creates a language model object (**1**), then a database (**2**), the web search tool (**3**), and, finally, the agent (**4**). It invokes the agent (**5**) on the input question. The output produced by the agent terminates with an answer to that question (or with a failure message if the agent is unable to find an answer). ##### Listing 10.2 Agent for data analysis with web search capability 导入 argparse from langchain.agents.load_tools 导入 load_tools from langchain_community.utilities.sql_database 导入 SQLDatabase from langchain_community.agent_toolkits.sql.base 导入 create_sql_agent from langchain_openai 导入 ChatOpenAI if name == 'main': parser = argparse.ArgumentParser() parser.add_argument('serpaikey', type=str, help='SERP API 访问密钥') parser.add_argument('dbpath', type=str, help='SQLite 数据库路径') parser.add_argument('question', type=str, help='要回答的问题') args = parser.parse_args() llm = ChatOpenAI( #1 temperature=0, model='gpt-4o') #2 db = SQLDatabase.from_uri(f'sqlite:///{args.dbpath}') extra_tools = load_tools( #3 ['serpapi'], serpapi_api_key=args.serpaikey, llm=llm) agent = create_sql_agent( #4 llm=llm, db=db, verbose=True, agent_type='openai-tools', extra_tools=extra_tools) agent.invoke({'input':args.question}) #5 py #1 Creates an LLM client #2 Creates a database object #3 Adds a web search tool #4 Creates the agent #5 Invokes the agent with input ### 10.4.4 Trying it out Let’s see how that works in practice! Download the code for listing 10.2 from the book’s companion website. Besides the code, you will need an SQLite database to try the data agent. We will use the SQLite database from chapter 5, storing information about video games (you can find the corresponding file on the book’s companion website under the Games SQLite link). Open the terminal, and switch to the directory containing the code. We will assume that the database file, games.db, is located in the same directory. Run the following code (replace `[SerpAPI key]` with your search key, available at [https://](https://serpapi.com/dashboard) [serpapi.com/dashboard](https://serpapi.com/dashboard)): python listing2.py [SerpAPI 密钥] games.db ↪ '2016 年最畅销的游戏是什么,如何玩?' py You should see output like the following (the output you see may differ slightly due to changing web content, small changes to the GPT-4o model, and a few other factors): [1m> 正在进入新的 SQL 代理执行器链...[0m [32;1m[1;3m #1 调用:sql_db_list_tables with {'tool_input': "} [0m[38;5;200m[1;3mgames[0m[32;1m[1;3m #2 调用:sql_db_schema with {'table_names': 'games'} [0m[33;1m[1;3m CREATE TABLE games ( rank INTEGER, name TEXT, platform TEXT, year INTEGER, genre TEXT, publisher TEXT, americasales NUMERIC, eusales NUMERIC, japansales NUMERIC, othersales NUMERIC, globalsales NUMERIC ) /* 3 rows from the games table: rank name platform year genre publisher americasales eusales japansales othersales globalsales 1 Wii Sports Wii 2006 Sports Nintendo 41.4900000000 29.0200000000 3.7700000000 8.4600000000 82.7400000000 2 Super Mario Bros. NES 1985 Platform Nintendo 29.0800000000 3.5800000000 6.8100000000 0.7700000000 40.2400000000 3 Mario Kart Wii Wii 2008 Racing Nintendo 15.8500000000 12.8800000000 3.7900000000 3.3100000000 35.8200000000 /[0m[32;1m[1;3m #3 调用:sql_db_query_checker with {'query': 'SELECT name FROM games WHERE year = 2016 ORDER BY globalsales DESC LIMIT 1'} responded: The games table contains the information we need. I will query for the game with the highest global sales in 2016. [0m[36;1m[1;3mSELECT name FROM games WHERE year = 2016 ORDER BY globalsales DESC LIMIT 1[0m[32;1m[1;3m #4 调用:sql_db_query with {'query': 'SELECT name FROM games WHERE year = 2016 ORDER BY globalsales DESC LIMIT 1'} [0m[36;1m[1;3m[('FIFA 17',)][0m[32;1m[1;3m #5 调用:Search with How to play FIFA 17 [0m[33;1m[1;3m["A Beginner's Guide To Complete FIFA 17 Domination ... The main steps you should take are to jump right in with a quick play game. ... EA Sports FIFA ...", 'Play FIFA 17 up to 5 days before launch for a full 10 hours when you join EA Access on Xbox One and Origin Access on PC.', "1. Shield the ball in 360 degrees · 2. Use Driven Shots and Driven Headers · 3. Use set piece upgrades to score with style · 4. Make Fifa 17's ...", 'Play FIFA 17 as much as you want with EA Access or Origin Access for only $4.99 per month. Now available in The Vault.', 'Cautiously Start An Online Match. Score Early After Some Self-Proclaimed Beautiful Build Up Play. Concede 4 Goals In A Row And Convince ...', 'FIFA 17 TUTORIALS & ULTIMATE TEAM ➞ Twitter: https://twitter.com/KrasiFIFA ➞ Instagram: http://instagram.com/KrasiFIFA How I record my ...', "Draft mode is another way to play FIFA Ultimate Team, giving you the ability to play with Players you don't own. You'll have the opportunity to draft a random ..."] #6 [0m[32;1m[1;3m2016 年最畅销的游戏是 FIFA 17。 要玩 FIFA 17,您可以遵循以下步骤: 1. 快速开始游戏。 2. 用 360 度保护球。 3. 使用驱动射门和驱动角球。 4. 使用定位球升级以优雅地得分。 5. 谨慎地开始在线比赛。 6. 在一些自称为美丽的 buildup play 之后尽早得分。 7. 拟定模式是另一种玩 FIFA Ultimate Team 的方式,让您能够与您不拥有的球员一起玩。 您将有机会挑选一支随机队伍。 记住,熟能生巧![0m [1m> 完成链。[0m py #1 The agent retrieves the list of tables. #2 The agent retrieves the table schema. #3 The agent verifies the SQL query. #4 The agent queries for the top game. #5 The agent searches the web for FIFA 17. #6 The agent formulates the final answer. Remember that we switched the agent’s output to verbose mode. That means the output contains a full log of tools invoked by the agent, as well as the agent’s reasoning process. Let’s take a closer look at the output to see what happened. First, the agent retrieves a list of the tables available in the relational database (using `sql_db_list_tables`) (**1**). Clearly, that’s a reasonable step when confronted with a new database. The result of the tool invocation reveals that the database contains only a single table (called `games`). The agent becomes “curious” about the table contents. It invokes the `sql_db_schema` tool to get further information about the `games` table (**2**). Note that this tool consumes input parameters, specifically the name of the table to investigate. The log shows the values of all input parameters for each tool invocation. The invocation of the `sql_db_schema` tool returns the SQL command that was used to create the `games` table, together with a small sample of the table’s content. Next, the agent considers an SQL query to retrieve relevant information about the input question (“What was the most sold game in 2016 and how is it played?”). In the first step, it validates that the following query is syntactically correct by invoking the `sql_db_query_checker` tool (**3**): SELECT name FROM games WHERE year = 2016 ORDER BY globalsales DESC LIMIT 1 py At the same time, the agent uses the opportunity to “reflect” on the usefulness of the query under consideration, as evidenced by the output “The games table contains the information we need. I will query for the game with the highest global sales in 2016.” It may seem strange that a language model can benefit from this type of monologue instead of writing out tool invocations directly. Yet it has been shown that enabling agents to explicitly reason about the problem at hand and the steps they are taking to solve it can improve their performance [1]. That’s what’s happening here as well. Finally, the agent decides to use the previously validated query to retrieve information from the database, using the `sql_db_query` tool (**4**). The SQL query returns the game that generated the most revenue in 2016: FIFA 17, a soccer simulation produced by Electronic Arts. But the input question asks for more than that: “What was the most sold game in 2016 and *how is it played*?” The second part of the question cannot be answered from database content. To its credit, the agent realizes that and tries to access the web instead: it issues a web search request using the `Search` tool for the search string “How to play FIFA 17” (**5**). Note that the agent was able to automatically formulate a suitable search string from the result of the SQL query and the input question. The result of the web search is a collection of text snippets (shown in the output) that contain information about how to play FIFA 2017. Finally, the agent uses the information returned by the web search (in combination with information from the SQL database) to formulate a final answer (**6**). The final answer identifies FIFA 17 as the most popular game in 2016 and contains detailed instructions for how to play it well. We have seen that the agent can perform a complex sequence of tool invocations to find the desired answer without having to specify the process to follow by hand. If you’re interested, try querying the agent with a few more, possibly more complicated, questions and see whether it can answer them as well. ## 10.5 Adding custom tools So far, we have used standard tools offered by LangChain for the most common use cases. What happens if we have specialized requirements? For example, say you want to make a data source accessible via a custom API, or you have specialized analysis functions that an agent can apply to your data. In those cases, you can define your own custom tools and make them accessible to a LangChain agent. ### 10.5.1 The currency converter In the last section, we analyzed a data set about video game sales. The original data reports sales values in US dollars. What about other currencies? To enable agents to reason about game sales using multiple currencies, we will add a currency-converter tool. Given an amount in US dollars as input, together with the name of a target currency, this tool returns the equivalent value in the target currency. Listing 10.3 shows how to add the currency-converter tool to our data agent. At its core, a tool is nothing but a Python function. Our currency converter is implemented by the `convert_currency` function (**2**). How does LangChain know that we want to turn the function into a tool? That’s done by the `@tool` decorator (**1**), which needs to directly precede the function name. Typically, we do not have to specify types for parameters and return values of Python functions (even though it does improve the readability of your code). If you plan to turn a function into a tool, however, you should specify all these types. The reason is as follows: to use your function properly as a tool, the agent needs to invoke it with parameters of the right type. All types you specify in the function header will be made accessible to the agent as part of the description of your tool. Hence, associating parameters with types helps your agent avoid unnecessary invocation errors. Besides parameter types, the agent should know a little about what your tool can accomplish. The first important piece of information is the name of your function. By default, your tool will be named after your function. Don’t call your function `XYZ`, because that will make it very hard to understand what’s going on! The name of the function in listing 10.3, `convert_currency`, should make it pretty clear what the function does. Similarly, the names of the input parameters, `USD_amount` (of type `float`) and `currency` (of type `str`), are pretty self-explanatory (which is good!). The function output is a converted amount in the target currency or an error message if the requested target currency is not supported (that’s why the output type is a `Union` of string and float values). As a rule of thumb, if you plan to use a Python function as a tool, write it the same way you would to enable human coders to understand your function without reading its code in detail. In addition to the names of the function and its parameters, the agent “sees” the function documentation (**3**). Again, make sure your documentation is well structured, and explain the semantics of your tool and associated parameters. In this case, the documentation describes the function of the tool, the semantics of the input parameters (even with an example of an admissible value for the second parameter), and the output semantics. The `convert_currency` function uses a small database of currencies with associated conversion factors. For instance, it contains conversion factors for euros and yen but not many other currencies. If you’re creating a tool for your agent, take into account cases in which the agent does not use the tool properly. This may happen if the tool description is incomplete or if the language model makes a mistake (which happens even to state-of-the-art language models). In this case, we’re adding specialized handling for the case that the target currency is not supported (i.e., a corresponding conversion factor is missing) (**4**). If the target currency is not supported, the function returns a helpful error message that contains the full set of supported currencies. This helps the agent to restrict the parameter to the set of admissible options for the following invocations. If the target currency is supported, the function returns the converted amount (**5**). ##### Listing 10.3 Data-analysis agent with currency-converter tool 导入 argparse from langchain.agents.load_tools 导入 load_tools from langchain.tools 导入 tool from langchain_community.utilities.sql_database 导入 SQLDatabase from langchain_community.agent_toolkits.sql.base 导入 create_sql_agent from langchain_openai 导入 ChatOpenAI from typing import Union @tool #1 #2 def convert_currency(USD_amount: float, currency: str)->Union[float, str]: #3 """ 将美元金额转换为另一种货币。 参数: USD_amount:美元金额。 currency:目标货币名称(例如,“日元”)。 返回值: 目标货币中的输入金额。 """ conversion_factors = { 'Euro':0.93, 'Yen':151.28, 'Yun':0.14, 'Pound':1.26, 'Won':0.00074, 'Rupee':0.012} if currency not in conversion_factors: #4 error_message = ( f'Unknown currency: {currency}!' f'Use one of {conversion_factors.keys()}') return error_message #5 conversion_factor = conversion_factors[currency] converted_amount = USD_amount * conversion_factor return converted_amount if name == 'main': parser = argparse.ArgumentParser() parser.add_argument('serpaikey', type=str, help='SERP API 访问密钥') parser.add_argument('dbpath', type=str, help='SQLite 数据库路径') parser.add_argument('question', type=str, help='要回答的问题') args = parser.parse_args() llm = ChatOpenAI( temperature=0, model='gpt-4o') db = SQLDatabase.from_uri(f'sqlite:///{args.dbpath}') extra_tools = load_tools( ['serpapi'], serpapi_api_key=args.serpaikey, llm=llm) #6 extra_tools.append(convert_currency) agent = create_sql_agent( llm=llm, db=db, verbose=True, agent_type='openai-tools', extra_tools=extra_tools) agent.invoke({'input':args.question}) py #1 Turns the function into a tool #2 Function signature with types #3 Function documentation #4 Helpful error message for the agent #5 Converts and returns the result #6 Adds the currency-converter tool After creating a tool based on a Python function, we just need to make the tool available to our agent. Listing 10.3 creates almost the same agent as listing 10.2, with the only difference being that we add the currency-converter tool (**6**). Because we’re using the SQL agent again, the converter tool and the web search tool are inserted into the list of extra tools (added on top of the standard tools for SQL access that are automatically provided to the agent). By default, the tool name equals the name of the function it is based on. Hence, we’re simply adding `convert_currency` to the list of extra tools (**6**) to enhance the agent with currency conversion abilities. ### 10.5.2 Trying it out Let’s see whether our agent is able to use our newly added tool! Download the code for listing 10.3 from the book’s companion website. You can use the same database file as before (and assume that the games.db file is located in the same folder as the code). Then, open the terminal and execute the following code (substituting your SerpAPI access key for `[SerpAPI key]`): python listing3.py [SerpAPI 密钥] games.db '2015 年电脑游戏产生了多少收入? 用日元表示是多少?' py Clearly, answering that question requires the currency-converter tool. When running the code, you will see output like the following: [1m> 正在进入新的 SQL 代理执行器链...[0m [32;1m[1;3m #1 调用:sql_db_list_tables with 'tool_input': " [0m[38;5;200m[1;3mgames[0m[32;1m[1;3m #2 调用:sql_db_schema with 'table_names': 'games' [0m[33;1m[1;3m CREATE TABLE games ( rank INTEGER, name TEXT, platform TEXT, year INTEGER, genre TEXT, publisher TEXT, americasales NUMERIC, eusales NUMERIC, japansales NUMERIC, othersales NUMERIC, globalsales NUMERIC ) / 3 rows from the games table: rank name platform year genre publisher americasales eusales japansales othersales globalsales 1 Wii Sports Wii 2006 Sports Nintendo 41.4900000000 29.0200000000 3.7700000000 8.4600000000 82.7400000000 2 Super Mario Bros. NES 1985 Platform Nintendo 29.0800000000 3.5800000000 6.8100000000 0.7700000000 40.2400000000 3 Mario Kart Wii Wii 2008 Racing Nintendo 15.8500000000 12.8800000000 3.7900000000 3.3100000000 35.8200000000 */[0m[32;1m[1;3m #3 调用:sql_db_query_checker with {'query': 'SELECT SUM(globalsales) as total_revenue FROM games WHERE year = 2015'} responded: The "games" table contains the information we need. The "globalsales" column represents the global revenue generated by each game. We can sum this column for the games released in 2015 to get the total revenue. Let's write and check the SQL query. [0m[36;1m[1;3mSELECT SUM(globalsales) as total_revenue FROM games WHERE year = 2015[0m[32;1m[1;3m #4 调用:sql_db_query with {'query': 'SELECT SUM(globalsales) as total_revenue FROM games WHERE year = 2015'} [0m[36;1m[1;3m[(264.43999999999795,)]0m[32;1m[1;3m #5 调用:convert_currency with {'USD_amount': 264.43999999999795, 'currency': 'Yen'} [0m[38;5;200m[1;3m40004.48319999969[0m[32;1m[1;3m #6 2015 年电脑游戏产生的总收入约为 264440000 美元。 用日元表示,这大约是 40,004,483,200 日元。[0m [1m> 完成链。[0m py #1 The agent retrieves the database tables. #2 The agent queries for the table schema. #3 The agent verifies the SQL query. #4 The agent queries for sales in 2015. #5 The agent converts the currencies. #6 The agent formulates the final answer. Similarly to before, the agent first explores the database by retrieving the set of tables (**1**) and then, after finding out that the database only contains a single table, retrieving the schema for that table (**2**). Correctly, the agent infers that the database contains useful information about the input question and first validates (**3**) and then executes (**4**) a corresponding SQL query. The SQL query returns the total value of computer game sales in 2015, expressed in US dollars. To answer the final part of the question (“How much is it in Yen?”), the agent then applies the currency-converter tool (**5**). Note that the agent chooses appropriate values for the two input parameters based on the function description and types. Finally, the agent formulates the answer to the input question (**6**). ## 10.6 Indexing multimodal data with LlamaIndex LangChain is by no means the only framework that makes it easier to use language models for data analysis. In this section, we discuss another framework that has recently appeared and is quickly gaining popularity: LlamaIndex. ### 10.6.1 Overview LlamaIndex shines for use cases where language models need to access large collections of data, possibly integrating various data types. In such cases, it is generally not advisable (or even possible) to directly feed all the data into the language model. Instead, we need a mechanism that quickly identifies relevant data for a given task, passing only relevant data to the language model. As the name suggests, LlamaIndex indexes data to quickly identify relevant subsets. More precisely, LlamaIndex associates pieces of data (e.g., chunks of text) with embedding vectors. We briefly discussed embedding vectors in chapter 4\. In short, an embedding vector represents the semantics of text as a vector calculated by a language model. If two documents have similar embedding vectors (the distance between the vectors is small), we assume that they discuss similar topics. A typical LlamaIndex data-processing pipeline entails the following steps. First, it loads data, possibly in various formats, and performs preprocessing. For example, preprocessing may entail dividing long text documents into smaller chunks that are more convenient to handle. Next, LlamaIndex indexes the data. As discussed before, this means associating data chunks with embedding vectors. By default, LlamaIndex uses fairly small language models (e.g., OpenAI’s ada models) to calculate embedding vectors. This makes the indexing step cheap. Furthermore, LlamaIndex can store the generated index (the embedding vectors) on disk to avoid having to regenerate them for each new task. LlamaIndex offers support for various use cases based on the generated index. For instance, it can use indexed data to answer natural language questions. Given a question as input, it first calculates an embedding vector for the question text. Then it compares the vector representing the question to precalculated vectors representing data chunks. It identifies the data items with the most similar vectors. The associated data is included in the prompt, together with the input question. The goal is to generate an answer to the question, exploiting relevant data as context. Whereas small models are used for indexing, we typically use larger models to generate the final reply. Figure [10.4 illustrates this data-processing pipeline. ![figure](https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dtanls-llm/img/CH10_F04_Trummer.png) ##### Figure 10.4 Primary steps of a typical LlamaIndex data-processing pipeline. LlamaIndex loads and indexes data to enable fast retrieval. Given a question, LlamaIndex identifies relevant data items and submits them, together with the input question, to a language model to generate an answer. ### 10.6.2 Installing LlamaIndex Let’s implement the pipeline from the last section in Python. To use LlamaIndex, we first have to install a few packages. Go to your terminal, and run the following command: pip install llama-index0.10.25 py That will set you up with LlamaIndex’s core packages. However, you will use LlamaIndex to analyze a diverse collection of data formats. To enable LlamaIndex to properly access and parse all of them, you need to install a few additional packages. Run the following commands in your terminal: pip install torch2.1.2 pip install transformers4.36.0 pip install python-pptx0.6.23 pip install Pillow==10.2.0 py These libraries are necessary to analyze .pdf documents and PowerPoint presentations, all of which we will need for the following project. ### 10.6.3 Implementing a simple question-answering system You’re back at Banana and confronted with a challenging problem: being a global company, Banana has many different units. Your boss wants you to analyze data from different units, for example, to compare their performance. However, different units have widely varying preferences in terms of data formats. Some units publish their results as simple text documents, whereas others regularly turn out elaborate PowerPoint presentations. How do you integrate all those different data formats? Fortunately, LlamaIndex makes that easy. Look at listing 10.4: in just a few lines of Python code, it handles the task. The code accepts the following input parameters: * A link to a data repository. This repository may contain files of various types. * A question to answer. LlamaIndex will use the data in the repository to answer it. After parsing those parameters from the command line (**1**), we load data from the input repository (**2**). Fortunately, LlamaIndex makes this step very straightforward: no need to add handling for different file types and so on. Instead, passing the directory path is sufficient. Next, we index the data we just loaded (**3**). By default, LlamaIndex uses OpenAI’s ada models to calculate embedding vectors. Data conversions and chunking (e.g., splitting large text documents into pieces small enough to be processed by OpenAI’s ada models) are all handled automatically. Now we create a query engine on top of the index (**4**). This engine will automatically retrieve data related to an input question using the index. Finally, we use the previously generated engine to answer the input question (**5**) and print the result. ##### Listing 10.4 A simple question-answering system with LlamaIndex 导入 argparse 导入 openai from llama_index.core import VectorStoreIndex, SimpleDirectoryReader if name == 'main': #1 parser = argparse.ArgumentParser() parser.add_argument('datadir', type=str, help='数据目录路径') parser.add_argument('question', type=str, help='要回答的问题') args = parser.parse_args() #2 documents = SimpleDirectoryReader(args.datadir).load_data() #3 index = VectorStoreIndex.from_documents(documents) #4 engine = index.as_query_engine() #5 answer = engine.query(args.question) print(answer) py #1 Parses the command-line parameters #2 Loads data from the directory #3 Indexes the data #4 Enables querying on the index #5 Generates the answer Although LlamaIndex offers various ways to configure and specialize each step of this pipeline (and to create other pipelines), using the default settings in each step leads to particularly concise code. ### 10.6.4 Trying it out Let’s try our pipeline using some example data. You can download listing 10.4 from the book’s companion website. Also download the bananareports.zip file from the website, and unzip it in the same folder as the code (use the Banana Reports link). Look inside the folder: you will find (short) business reports in text, .pdf documents, and PowerPoint presentations. Time to answer a few questions! Open your terminal, and change to the directory containing the code and the bananareports folder (after unzipping). Now run the following command: python listing4.py bananareports '2023 年香蕉单位赚了多少钱?' py You should see output like the following: 香蕉单位在 2023 年赚了 3000 万美元。

posted @ 2025-11-20 09:30  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报