\(\text{9}\) 多模态大型语言模型

Multimodal Large Language Models

当您想到大型语言模型(\(\text{LLM}\))时,多模态\(\text{multimodality}\))可能不会是您首先想到的。毕竟,它们是语言模型!但是我们可以很快看到,如果模型能够处理文本以外其他类型的数据,它们会更有用。例如,如果一个语言模型能够看一眼图片回答关于它的问题,那就非常有用。一个能够同时处理文本和图像(每种都称为一种模态)的模型被称为多模态模型\(\text{multimodal}\)),如图 \(\text{9}-1\) 所示。

F9.1

我们已经看到了各种从 \(\text{LLM}\) 中涌现出的新兴行为,从泛化能力推理算术语言学。随着模型的规模变得更大、更智能,它们的技能集也在增加。

接收和使用多模态输入进行推理的能力可能会进一步增强,并有助于解锁以前被限制的能力。在实践中,语言并不仅仅存在于真空中。例如,您的肢体语言、面部表情、语调等都是增强口头表达的交流方式。同样的事情也适用于 \(\text{LLM}\);如果我们可以使它们能够对多模态信息进行推理,它们的能力可能会增加,并且我们能够部署它们来解决新型问题

在本章中,我们将探讨一些具有多模态能力\(\text{LLM}\),以及这对实际用例意味着什么。我们将首先探索如何使用对原始 \(\text{Transformer}\) 技术的改编,将图像转换为数值表示。然后,我们将展示如何使用这种 \(\text{Transformer}\)扩展 \(\text{LLM}\) 以包括视觉任务

用于视觉的 \(\text{Transformer}\) 模型

Transformers for Vision

贯穿本书各章,我们看到了基于 \(\text{Transformer}\) 的模型在各种语言建模任务中取得的成功,从分类和聚类搜索和生成建模。因此,研究人员一直在寻找一种将 \(\text{Transformer}\) 的部分成功泛化到计算机视觉领域的方法,这可能不会让人感到惊讶。

他们提出的方法被称为 视觉 \(\text{Transformer}\) (\(\text{Vision Transformer}\), \(\text{ViT}\)),与之前默认的卷积神经网络\(\text{CNNs}\))相比,它在图像识别任务中表现出了巨大的优势。就像原始的 \(\text{Transformer}\) 一样,\(\text{ViT}\) 用于将非结构化数据(一张图像)转换为可用于各种任务(如分类)的表示,如图 \(\text{9}-2\) 所示。

F9.1

\(\text{ViT}\) 依赖于 \(\text{Transformer}\) 架构的一个重要组件,即编码器\(\text{encoder}\))。正如我们在第 \(\text{1}\) 章中所见,编码器负责在将文本输入传递给解码器之前,将其转换为数值表示。然而,在编码器执行其职责之前,文本输入需要首先被分词\(\text{tokenized}\)),如图 \(\text{9}-3\) 所示。

F9.1

由于图像不包含单词,因此这种分词过程不能用于视觉数据。相反,\(\text{ViT}\) 的作者提出了一种将图像分词为“单词”的方法,这使他们能够使用原始的编码器结构

想象您有一张猫的图像。这张图像由许多像素组成,比方说 \(512 \times 512\) 像素。单个像素不能传达太多信息,但是当您组合像素块时,您会逐渐开始看到更多的信息

\(\text{ViT}\) 使用的原理与此非常相似。它不是将文本分割成词元,而是将原始图像转换成图像块\(\text{patches of images}\))。换句话说,它会水平和垂直地将图像切割成许多片段,如图 \(\text{9}-4\) 所示。

F9.1

就像我们将文本转换为文本词元一样,我们将图像转换为图像块展平后的图像块输入可以被视为一段文本中的词元。然而,与词元不同,我们不能简单地为每个图像块分配一个 \(\text{ID}\),因为这些图像块很少会出现在其他图像中,这与文本的词汇表不同。

相反,这些图像块被线性嵌入\(\text{linearly embedded}\))以创建数值表示,即嵌入。然后,这些嵌入可以用作 \(\text{Transformer}\) 模型的输入。这样,图像块就被视为与词元相同的方式。完整的流程如图 \(\text{9}-5\) 所示。

F9.1

个人注:线性嵌入 (Linearly Embedded):

每个小的图像块都会被输入到一个线性层(linear layer,即一个全连接层)中进行处理。这个线性层的作用是将图像块的原始像素值(例如 RGB 通道的数值)投射(project)到一个新的、低维或高维的特征空间中。“线性”表示这个转换过程是一个简单的矩阵乘法。

出于说明目的,示例中的图像被分成了 \(3 \times 3\) 个块,但原始实现使用的是 \(16 \times 16\) 个块。毕竟,这篇论文名为《一张图像值得 \(\text{16}\times\text{16}\) 个词》(\(\text{An Image is Worth 16x16 Words}\)

这种方法的有趣之处在于,当嵌入被传递给编码器的那一刻,它们就被视为文本词元。从那时起,文本和图像的训练方式就没有区别

由于这些相似性,\(\text{ViT}\) 经常被用来使各种语言模型具备多模态能力。使用它的最直接方法之一是在嵌入模型的训练期间

多模态嵌入模型

Multimodal Embedding Models

在前面的章节中,我们使用嵌入模型来捕获文本表示(例如论文和文档)的语义内容。我们看到,我们可以使用这些嵌入或数值表示查找相似文档、应用分类任务,甚至执行主题建模

正如我们之前多次看到的,嵌入通常是 \(\text{LLM}\) 应用背后的重要驱动力。它们是捕获大规模信息和在信息大海捞针中进行搜索的高效方法

尽管如此,到目前为止,我们只研究了纯文本嵌入模型,它们专注于为文本表示生成嵌入。虽然也存在专门用于嵌入图像的模型,但我们将研究可以同时捕获文本和视觉表示的嵌入模型。我们在图 \(\text{9}-6\) 中对此进行了说明。

F9.1

一个优势是,这允许比较多模态表示,因为生成的嵌入位于相同的向量空间中(图 \(\text{9}-7\))。例如,使用这样的多模态嵌入模型,我们可以根据输入文本查找图像。如果我们搜索与“\(\text{pictures of a puppy}\)”(小狗的照片)相似的图像,我们会找到哪些图像?反之亦然也是可能的。哪些文档与这个问题最相关?

F9.1

有多款多模态嵌入模型,但最知名且目前使用最广泛的模型是对比语言-图像预训练\(\text{Contrastive Language-Image Pre-training}\), \(\text{CLIP}\))。

\(\text{CLIP}\):连接文本和图像

CLIP: Connecting Text and Images

\(\text{CLIP}\) 是一种嵌入模型,可以计算图像和文本的嵌入。生成的嵌入位于相同的向量空间中,这意味着图像的嵌入可以与文本的嵌入进行比较。这种比较能力使得 \(\text{CLIP}\) 和类似模型可用于以下任务:

  • 零样本分类 (\(\text{Zero-shot classification}\)) 我们可以将图像的嵌入与其可能类别的描述的嵌入进行比较,以找到哪个类别最相似。
  • 聚类 (\(\text{Clustering}\))图像和关键词集合进行聚类,以找到哪些关键词属于哪些图像集合。
  • 搜索 (\(\text{Search}\))数十亿文本或图像中,我们可以快速找到与输入文本或图像相关的内容。
  • 生成 (\(\text{Generation}\)) 利用多模态嵌入来驱动图像生成(例如,stable diffusion)。

\(\text{CLIP}\) 如何生成多模态嵌入?

How Can CLIP Generate Multimodal Embeddings?

\(\text{CLIP}\) 的过程实际上非常简单直观。想象您有一个包含数百万张图像及其对应说明文字(\(\text{captions}\)的数据集,如图 \(\text{9}-8\) 所示。

F9.1

这个数据集可以用于为每对数据(图像及其说明文字)创建两种表示。为此,\(\text{CLIP}\) 使用一个文本编码器嵌入文本,并使用一个图像编码器嵌入图像。如图 \(\text{9}-9\) 所示,结果就是图像及其相应说明文字的嵌入

F9.1

生成的这对嵌入通过余弦相似度进行比较。正如我们在第 \(\text{4}\) 章中所见,余弦相似度是向量之间夹角的余弦值,通过嵌入的点积除以它们的长度的乘积来计算。

当我们开始训练时,图像嵌入和文本嵌入之间的相似度会很低,因为它们尚未优化到同一向量空间中。在训练过程中,我们优化嵌入之间的相似度,并希望最大化相似图像/说明文字对的相似度,而最小化不相似图像/说明文字对的相似度(图 \(\text{9}-10\))。

F9.1

在计算它们的相似度后,模型会更新,然后重新开始新的数据批次和更新后的表示(图 \(\text{9}-11\))。这种方法被称为对比学习\(\text{contrastive learning}\)),我们将在第 \(\text{10}\) 章中深入探讨其内部工作原理,届时我们将创建我们自己的嵌入模型。

F9.1

最终,我们期望猫的图像的嵌入将与“一只猫的图片”这句话的嵌入相似。正如我们将在第 \(\text{10}\) 章中看到的,为了确保表示尽可能准确,不相关的图像和说明文字的负面示例也应该包含在训练过程中。建模相似性不仅是知道是什么让事物彼此相似,也是知道是什么让它们不同和不相似

\(\text{OpenCLIP}\)

OpenCLIP

对于我们的下一个示例,我们将使用 \(\text{CLIP}\) 的开源变体——即 \(\text{OpenCLIP}\) 中的模型。使用 \(\text{OpenCLIP}\) 或任何 \(\text{CLIP}\) 模型,归结为两件事:在将文本和图像输入传递给主模型之前,对它们进行处理

在此之前,让我们看一个小示例,其中我们将使用我们以前看过的一张图像,即一张 \(\text{AI}\) 生成的(通过stable diffusion)在雪中玩耍的小狗的图像,如图 \(\text{9}-12\) 所示:

F9.1

1
2
3
4
5
6
7
from urllib.request import urlopen
from PIL import Image
# Load an AI-generated image of a puppy playing in the snow
puppy_path = "https://raw.githubusercontent.com/HandsOnLLM/Hands-On-Large-
Language-Models/main/chapter09/images/puppy.png"
image = Image.open(urlopen(puppy_path)).convert("RGB")
caption = "a puppy playing in the snow"

由于我们有这张图像的说明文字,我们可以使用 \(\text{OpenCLIP}\) 来为两者生成嵌入

为此,我们加载了三个模型

  • 一个用于分词文本输入分词器\(\text{tokenizer}\)
  • 一个用于预处理和调整图像大小预处理器\(\text{preprocessor}\)
  • 一个用于将前两个输出转换为嵌入主模型
1
2
3
4
5
6
7
8
from transformers import CLIPTokenizerFast, CLIPProcessor, CLIPModel
model_id = "openai/clip-vit-base-patch32"
# Load a tokenizer to preprocess the text
clip_tokenizer = CLIPTokenizerFast.from_pretrained(model_id)
# Load a processor to preprocess the images
clip_processor = CLIPProcessor.from_pretrained(model_id)
# Main model for generating text and image embeddings
model = CLIPModel.from_pretrained(model_id)

加载模型后,预处理我们的输入就很简单了。让我们从分词器开始,看看如果我们预处理输入会发生什么:

1
2
3
# Tokenize our input
inputs = clip_tokenizer(caption, return_tensors="pt")
inputs

这将输出一个包含输入 \(\text{ID}\) 的字典:

1
2
{'input_ids': tensor([[49406, 320, 6829, 1629, 530, 518, 2583, 49407]]), 'at-
tention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1]])}

为了查看这些 \(\text{ID}\) 代表什么,我们可以使用恰当命名的 \(\text{convert\_ids\_to\_tokens}\) 函数将它们转换回词元

1
2
# Convert our input back to tokens
clip_tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])

这将给出以下输出:

1
2
3
4
5
6
7
8
['<|startoftext|>',
'a</w>',
'puppy</w>',
'playing</w>',
'in</w>',
'the</w>',
'snow</w>',
'<|endoftext|>']

正如我们之前经常看到的,文本被拆分成词元。此外,我们现在还看到文本的开始和结束被指示出来,以将其与潜在的图像嵌入分开。您可能还会注意到 \(\text{[CLS]}\) 词元缺失了。在 \(\text{CLIP}\) 中,\(\text{[CLS]}\) 词元实际上用于表示图像嵌入

现在我们已经预处理了我们的说明文字,我们可以创建嵌入了:

1
2
3
# Create a text embedding
text_embedding = model.get_text_features(**inputs)
text_embedding.shape

这产生了一个单字符串的嵌入,它有 \(\text{512}\) 个值

1
torch.Size([1, 512])

在我们可以像创建文本嵌入一样创建图像嵌入之前,我们需要先对其进行预处理,因为模型期望输入图像具有特定的特征,例如它的大小和形状。

为此,我们可以使用我们之前创建的处理器

1
2
3
4
5
# Preprocess image
processed_image = clip_processor(
text=None, images=image, return_tensors="pt"
)["pixel_values"]
processed_image.shape

原始图像是 \(512 \times 512\) 像素。请注意,该图像的预处理将其大小缩小到 \(224 \times 224\) 像素,因为这是它所期望的大小

1
torch.Size([1, 3, 224, 224])

让我们可视化这个预处理的结果,如图 \(\text{9}-13\) 所示:

F9.1

1
2
3
4
5
6
7
8
9
10
11
12
import torch
import numpy as np
import matplotlib.pyplot as plt
# Prepare image for visualization
img = processed_image.squeeze(0)
img = img.permute(*torch.arange(img.ndim - 1,
img = np.einsum("ijk->jik", img)
-1,
-1))
# Visualize preprocessed image
plt.imshow(img)
plt.axis("off")

要将这个预处理后的图像转换为嵌入,我们可以像以前一样调用模型,并查看它返回的形状:

1
2
3
# Create the image embedding
image_embedding = model.get_image_features(processed_image)
image_embedding.shape

这给了我们以下形状:

1
torch.Size([1, 512])

请注意,生成的图像嵌入的形状文本嵌入的形状相同。这很重要,因为它允许我们比较它们的嵌入并查看它们是否相似。

我们可以使用这些嵌入来计算它们的相似度。为此,我们首先对嵌入进行归一化,然后计算点积以得到一个相似度分数

1
2
3
4
5
6
7
8
# Normalize the embeddings
text_embedding /= text_embedding.norm(dim=-1, keepdim=True)
image_embedding /= image_embedding.norm(dim=-1, keepdim=True)
# Calculate their similarity
text_embedding = text_embedding.detach().cpu().numpy()
image_embedding = image_embedding.detach().cpu().numpy()
score = np.dot(text_embedding, image_embedding.T)
score

这给了我们以下分数:

1
array([[0.33149648]], dtype=float32)

我们得到了一个 \(\text{0.33}\) 的相似度分数,鉴于我们不知道模型认为低相似度高相似度的界限在哪里,这个分数很难解释。相反,让我们用更多的图像和说明文字来扩展这个例子,如图 \(\text{9}-14\) 所示。

F9.1

考虑到与其他图像的相似度要低得多\(\text{0.33}\) 的分数似乎确实很高

使用 \(\text{sentence-transformers}\) 加载 \(\text{CLIP}\)

\(\text{sentence-transformers}\) 实现了几种基于 \(\text{CLIP}\) 的模型,这使得创建嵌入更加容易。它只需要几行代码:

1
2
3
4
5
6
7
8
9
10
11
from sentence_transformers import SentenceTransformer, util
# Load SBERT-compatible CLIP model
model = SentenceTransformer("clip-ViT-B-32")
# Encode the images
image_embeddings = model.encode(images)
# Encode the captions
text_embeddings = model.encode(captions)
#Compute cosine similarities
sim_matrix = util.cos_sim(
image_embeddings, text_embeddings
)

使文本生成模型具备多模态能力

Making Text Generation Models Multimodal

传统上,正如您所料,文本生成模型解释文本表示的模型。像 \(\text{Llama 2}\)\(\text{ChatGPT}\) 这样的模型擅长于对文本信息进行推理并用自然语言进行回应。

然而,它们局限于它们所训练的模态,即文本。正如我们之前在多模态嵌入模型中所见,增加视觉可以增强模型的能力

对于文本生成模型,我们希望它能够对特定的输入图像进行推理。例如,我们可以给它一张披萨的图片,然后问它包含哪些配料。您可以给它看一张埃菲尔铁塔的照片,然后问它是何时建造的或位于何处。这种对话能力在图 \(\text{9}-15\) 中得到了进一步说明。

F9.1

为了弥合这两个领域之间的差距,人们已经尝试为现有模型引入某种形式的多模态。其中一种方法被称为 \(\text{BLIP-2}\):用于统一视觉-语言理解和生成的自举语言-图像预训练 \(\text{2}\)\(\text{BLIP-2: Bootstrapping Language-Image Pre-training for Unified Vision-Language Understanding and Generation 2}\))。\(\text{BLIP-2}\) 是一种易于使用且模块化的技术,允许为现有语言模型引入视觉能力

\(\text{BLIP-2}\):弥合模态鸿沟

BLIP-2: Bridging the Modality Gap

从头开始创建一个多模态语言模型需要大量的计算能力和数据。我们将不得不使用数十亿张图像、文本和图像-文本对来创建这样一个模型。正如您所想象的,这不容易实现

\(\text{BLIP-2}\) 没有从头开始构建架构,而是通过构建一座名为查询 \(\text{Transformer}\) (\(\text{Querying Transformer}\), \(\text{Q-Former}\))桥梁弥合视觉-语言鸿沟,这座桥梁连接了一个预训练的图像编码器一个预训练的 \(\text{LLM}\)

通过利用预训练的模型\(\text{BLIP-2}\) 只需要训练这座桥梁,而不需要从头开始训练图像编码器和 \(\text{LLM}\)。它充分利用了现有的技术和模型!这座桥梁如图 \(\text{9}-16\) 所示。

F9.1

为了连接这两个预训练模型,\(\text{Q-Former}\) 模仿了它们的架构。它有两个共享注意力层的模块:

  • 一个图像 \(\text{Transformer}\),用于与冻结的 \(\text{Vision Transformer}\) 交互以进行特征提取
  • 一个文本 \(\text{Transformer}\),可以与 \(\text{LLM}\) 交互。

\(\text{Q-Former}\) 的训练分为两个阶段,每个模态一个阶段,如图 \(\text{9}-17\) 所示。

F9.1

第一步中,使用图像-文档对来训练 \(\text{Q-Former}\),使其能够表示图像和文本。这些对通常是图像的说明文字\(\text{captions}\)),就像我们之前在训练 \(\text{CLIP}\) 时看到的那样。

图像被馈送到冻结的 \(\text{ViT}\) 以提取视觉嵌入。这些嵌入被用作 \(\text{Q-Former}\)\(\text{ViT}\) 的输入。说明文字被用作 \(\text{Q-Former}\) 的文本 \(\text{Transformer}\) 的输入

有了这些输入,\(\text{Q-Former}\) 随后在三个任务上进行训练:

  • 图像-文本对比学习 (\(\text{Image-text contrastive learning}\)) 此任务试图对齐图像和文本嵌入对,以最大化它们的互信息
  • 图像-文本匹配 (\(\text{Image-text matching}\)) 这是一个分类任务,用于预测图像和文本对是正向的(匹配 matched)还是负向的(不匹配 unmatched)。
  • 以图像为基础的文本生成 (\(\text{Image-grounded text generation}\)) 训练模型根据从输入图像中提取的信息生成文本。

三个目标是联合优化的,以改进从冻结的 \(\text{ViT}\) 中提取的视觉表示。从某种意义上说,我们正在尝试将文本信息注入到冻结 \(\text{ViT}\) 的嵌入中,以便我们可以在 \(\text{LLM}\) 中使用它们。\(\text{BLIP-2}\) 的这第一步如图 \(\text{9}-18\) 所示。

F9.1

第二步中,从第一步中获得的可学习嵌入现在在与相应文本信息相同的维度空间中包含视觉信息。然后,这些可学习嵌入被传递给 \(\text{LLM}\)。从某种意义上说,这些嵌入充当了软视觉提示\(\text{soft visual prompts}\)),用于\(\text{Q-Former}\) 提取的视觉表示为条件调节 \(\text{LLM}\)

在它们之间还有一个全连接线性层,以确保可学习嵌入具有 \(\text{LLM}\) 所期望的相同形状。将视觉转换为语言的这第二步如图 \(\text{9}-19\) 所示。

F9.1

当我们将这些步骤组合在一起时,它们使 \(\text{Q-Former}\) 能够学习相同维度空间中的视觉和文本表示,这些表示可以用作 \(\text{LLM}\)软提示\(\text{soft prompt}\))。结果是,\(\text{LLM}\) 将获得关于图像的信息,其方式类似于您在进行提示时\(\text{LLM}\) 提供上下文。完整的深入过程如图 \(\text{9}-20\) 所示。

F9.1

\(\text{BLIP-2}\) 问世以来,许多其他视觉 \(\text{LLM}\) 也相继发布,它们具有相似的过程,例如 \(\text{LLaVA}\)(一个使文本 \(\text{LLM}\) 具备多模态能力的框架)或 \(\text{Idefics 2}\)(一个基于 \(\text{Mistral 7B LLM}\)高效视觉 \(\text{LLM}\))。尽管这些视觉 \(\text{LLM}\) 具有不同的架构,但它们都将预训练的类似 \(\text{CLIP}\) 的视觉编码器文本 \(\text{LLM}\) 连接起来。这些架构的目标是将输入图像中的视觉特征投射到语言嵌入中,以便它们可以用作 \(\text{LLM}\) 的输入。与 \(\text{Q-Former}\) 类似,它们试图弥合图像和文本之间的鸿沟

预处理多模态输入

Preprocessing Multimodal Inputs

既然我们知道 \(\text{BLIP-2}\) 是如何创建的,那么这种模型就有许多有趣的用例,不仅限于图像加标题\(\text{captioning images}\))、回答视觉问题,甚至执行提示\(\text{performing prompting}\))。

在我们介绍一些用例之前,让我们先加载模型并探索如何使用它:

1
2
3
4
5
6
7
8
9
10
11
from transformers import AutoProcessor, Blip2ForConditionalGeneration
import torch
# Load processor and main model
blip_processor = AutoProcessor.from_pretrained("Salesforce/blip2-opt-2.7b")
model = Blip2ForConditionalGeneration.from_pretrained(
"Salesforce/blip2-opt-2.7b",
torch_dtype=torch.float16
)
# Send the model to GPU to speed up inference
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)

使用 \(\text{model.vision\_model}\)\(\text{model.language\_model}\),我们可以分别查看我们加载的 \(\text{BLIP-2}\) 模型中使用了哪个 \(\text{ViT}\) 和生成模型

我们加载了构成我们完整管线的两个组件:一个处理器和一个模型处理器可以与语言模型的分词器进行比较。它将非结构化输入,例如图像和文本,转换为模型通常期望的表示

预处理图像

Preprocessing images

让我们先从探索处理器对图像的作用开始。我们首先加载一张非常宽的图像进行演示:

1
2
3
4
5
# Load image of a supercar
car_path = "https://raw.githubusercontent.com/HandsOnLLM/Hands-On-Large-
Language-Models/main/chapter09/images/car.png"
image = Image.open(urlopen(car_path)).convert("RGB")
image

F9.1

这张图像有 \(520 \times 492\) 像素,这通常是一种不常见的格式。那么我们来看看我们的处理器对它做了什么:

1
2
3
# Preprocess the image
inputs = blip_processor(image, return_tensors="pt").to(device, torch.float16)
inputs["pixel_values"].shape

这给了我们以下形状:

1
torch.Size([1, 3, 224, 224])

结果是一张 \(224 \times 224\) 大小的图像。比我们最初的图像小了很多!这也意味着所有原始的不同形状的图像都将被处理成正方形。所以,输入非常宽或非常高的图像时要小心,因为它们可能会失真

预处理文本

Preprocessing text

让我们继续用文本来探索处理器的功能。首先,我们可以访问用于分词输入文本分词器

1
blip_processor.tokenizer

这给了我们以下输出:

1
2
3
4
5
6
7
8
9
10
GPT2TokenizerFast(name_or_path='Salesforce/blip2-opt-2.7b', vocab_size=50265,
model_max_length=1000000000000000019884624838656, is_fast=True, pad-
ding_side='right', truncation_side='right', special_tokens={'bos_token': '</
s>', 'eos_token': '</s>', 'unk_token': '</s>', 'pad_token': '<pad>'},
clean_up_tokenization_spaces=True), added_tokens_decoder={
1: AddedToken("<pad>", rstrip=False, lstrip=False, single_word=False, normal-
ized=True, special=True),
2: AddedToken("</s>", rstrip=False, lstrip=False, single_word=False, normal-
ized=True, special=True),
}

这里的 \(\text{BLIP-2}\) 模型使用了一个 \(\text{GPT2Tokenizer}\)。正如我们在第 \(\text{2}\) 章中探讨的,分词器处理输入文本的方式可能大不相同

为了探究 \(\text{GPT2Tokenizer}\) 的工作方式,我们可以用一个小句子来试用它。我们首先将句子转换为词元 \(\text{ID}\),然后再将它们转换回词元

1
2
3
4
5
6
7
# Preprocess the text
text = "Her vocalization was remarkably melodic"
token_ids = blip_processor(image, text=text, return_tensors="pt")
token_ids = token_ids.to(device, torch.float16)["input_ids"][0]
# Convert input ids back to tokens
tokens = blip_processor.tokenizer.convert_ids_to_tokens(token_ids)
tokens

这给了我们以下词元:

1
['</s>', 'Her', 'Ġvocal', 'ization', 'Ġwas', 'Ġremarkably', 'Ġmel', 'odic']

当我们检查这些词元时,您可能会注意到在某些词元的开头有一个奇怪的符号,即 \(\text{Ġ}\) 符号。这实际上应该是一个空格。然而,一个内部函数会获取特定代码点中的字符并将它们上移 \(\text{256}\),以使它们可打印。因此,空格(代码点 \(\text{32}\))变成了 \(\text{Ġ}\)(代码点 \(\text{288}\))。

为了便于说明,我们将它们转换为下划线

1
2
3
# Replace the space token with an underscore
tokens = [token.replace("Ġ", "_") for token in tokens]
tokens

这给了我们一个更美观的输出

1
['</s>', 'Her', '_vocal', 'ization', '_was', '_remarkably', '_mel', 'odic']

输出显示下划线指示了一个单词的开始。这样,由多个词元组成的单词就可以被识别出来。

用例 \(\text{1}\):图像加标题

Use Case 1: Image Captioning

\(\text{BLIP-2}\) 这样的模型最直接的用法是为您数据中的图像创建说明文字(\(\text{captions}\)。您可能是一家想要创建服装描述的商店,或者您可能是一位没有时间手动标记一场婚礼的 \(\text{1,000}\) 多张照片的摄影师。

为图像加标题的过程处理过程紧密相随。图像被转换为模型可以读取的像素值。这些像素值被传递给 \(\text{BLIP-2}\),转换为软视觉提示\(\text{soft visual prompts}\)),\(\text{LLM}\) 可以利用这些提示来决定合适的说明文字

让我们以上面那张超级跑车的图像为例,并使用处理器来导出预期形状的像素

1
2
3
4
5
# Load an AI-generated image of a supercar
image = Image.open(urlopen(car_path)).convert("RGB")
# Convert an image into inputs and preprocess it
inputs = blip_processor(image, return_tensors="pt").to(device, torch.float16)
image

F9.1

下一步是使用 \(\text{BLIP-2}\) 模型将图像转换为词元 \(\text{ID}\)。完成此操作后,我们可以将 \(\text{ID}\) 转换为文本(即生成的说明文字):

1
2
3
4
5
6
7
8
# Generate image ids to be passed to the decoder (LLM)
generated_ids = model.generate(**inputs, max_new_tokens=20)
# Generate text from the image ids
generated_text = blip_processor.batch_decode(
generated_ids, skip_special_tokens=True
)
generated_text = generated_text[0].strip()
generated_text

\(\text{generated\_text}\) 包含的说明文字是:

1
an orange supercar driving on the road at sunset

这看起来是这张图像的完美描述

图像加标题是在进入更复杂的用例之前学习这个模型的一个很好的方式。您可以自己用一些图像试一试,看看它在哪些方面表现良好,哪些方面表现不佳。特定领域的图像,例如特定卡通人物想象中的创作的图片,可能会失败,因为模型主要是在公共数据上训练的。

让我们以一个有趣的例子来结束这个用例,即一张来自罗夏墨迹测验\(\text{Rorschach test}\))的图像,如图 \(\text{9}-21\) 所示。它是一项古老的心理实验的一部分,用于测试个人对墨迹的感知。据说一个人在墨迹中看到了什么,可以说明这个人的个性特征。这是一个相当主观的测试,但这只会让它更有趣!

F9.1

让我们以上面图 \(\text{9}-21\) 所示的图像作为我们的输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Load Rorschach image
url = "https://upload.wikimedia.org/wikipedia/commons/7/70/Ror
schach_blot_01.jpg"
image = Image.open(urlopen(url)).convert("RGB")
# Generate caption
inputs = blip_processor(image, return_tensors="pt").to(device, torch.float16)
generated_ids = model.generate(**inputs, max_new_tokens=20)
generated_text = blip_processor.batch_decode(
generated_ids, skip_special_tokens=True
)

generated_text = generated_text[0].strip()
generated_text

像以前一样,当我们检查 \(\text{generated\_text}\) 变量时,可以看到说明文字:

1
a black and white ink drawing of a bat

我完全可以理解模型会用这样的描述来为这张图像添加说明文字。既然这是一个罗夏测验( Rorschach test),您认为这说明了模型具有什么样的特性呢? 🤔

用例 \(\text{2}\):基于聊天的多模态提示

Use Case 2: Multimodal Chat-Based Prompting

虽然加标题\(\text{captioning}\))是一项重要的任务,但我们可以将它的用例进一步扩展。在前面的例子中,我们展示了从一种模态(视觉/图像)到另一种模态(文本/标题)的线性转换

我们可以尝试通过执行所谓的视觉问答\(\text{visual question answering}\)),来同时呈现这两种模态,而不是遵循这种线性结构。在这个特殊的用例中,我们给模型一张图像以及一个关于该特定图像的问题,让它来回答。模型需要同时处理图像和问题

为了演示,让我们从汽车的图片开始,并要求 \(\text{BLIP-2}\) 描述这张图片。为此,我们首先需要像以前一样多次预处理图像

1
2
# Load an AI-generated image of a supercar
image = Image.open(urlopen(car_path)).convert("RGB")

要执行我们的视觉问答,我们需要给 \(\text{BLIP-2}\) 不仅仅是图像,还需要提示\(\text{prompt}\))。没有提示,模型将像以前一样生成一个说明文字。我们将要求模型描述我们刚刚处理的图像:

1
2
3
4
5
6
7
8
9
10
11
12
# Visual question answering
prompt = "Question: Write down what you see in this picture. Answer:"
# Process both the image and the prompt
inputs = blip_processor(image, text=prompt, return_tensors="pt").to(device,
torch.float16)
# Generate text
generated_ids = model.generate(**inputs, max_new_tokens=30)
generated_text = blip_processor.batch_decode(
generated_ids, skip_special_tokens=True
)
generated_text = generated_text[0].strip()
generated_text

这给了我们以下输出:

1
A sports car driving on the road at sunset

正确地描述了图像。然而,这是一个相当简单的例子,因为我们的问题本质上是要求模型创建一个说明文字。相反,我们可以以基于聊天的方式提出后续问题

为此,我们可以将我们先前的对话(包括它对我们问题的回答)提供给模型。然后我们问它一个后续问题

1
2
3
4
5
6
7
8
9
10
11
# Chat-like prompting
prompt = "Question: Write down what you see in this picture. Answer: A sports car driving on the road at sunset. Question: What would it cost me to drive that car? Answer:"
# Generate output
inputs = blip_processor(image, text=prompt, return_tensors="pt").to(device,
torch.float16)
generated_ids = model.generate(**inputs, max_new_tokens=30)
generated_text = blip_processor.batch_decode(
generated_ids, skip_special_tokens=True
)
generated_text = generated_text[0].strip()
generated_text

这给了我们以下答案:

1
$1,000,000

\(\text{\$1,000,000}\)高度具体的!这显示了 \(\text{BLIP-2}\) 具有更像聊天的行为,这使得一些有趣的对话成为可能。

最后,我们可以通过使用 \(\text{ipywidgets}\)(一个允许我们制作交互式按钮、输入文本等的 \(\text{Jupyter}\) 笔记本扩展)来创建一个交互式聊天机器人,从而使这个过程更顺畅一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from IPython.display import HTML, display
import ipywidgets as widgets
def text_eventhandler(*args):
question = args[0]["new"]
if question:
args[0]["owner"].value =
""
# Create prompt
if not memory:
prompt = " Question: " + question + " Answer:"
else:
template = "Question: {} Answer: {}."
prompt = " ".join(
[
template.format(memory[i][0], memory[i][1])

) + " Question: " + question + " Answer:"
# Generate text
inputs = blip_processor(image, text=prompt, return_tensors="pt")
inputs = inputs.to(device, torch.float16)
generated_ids = model.generate(**inputs, max_new_tokens=100)
generated_text = blip_processor.batch_decode(
generated_ids,
skip_special_tokens=True
)
generated_text = generated_text[0].strip().split("Question")[0]
# Update memory
memory.append((question, generated_text))
# Assign to output
output.append_display_data(HTML("<b>USER:</b> " + question))
output.append_display_data(HTML("<b>BLIP-2:</b> " + generated_text))
output.append_display_data(HTML("<br>"))
# Prepare widgets
in_text = widgets.Text()
in_text.continuous_update = False
in_text.observe(text_eventhandler, "value")
output = widgets.Output()
memory = []
# Display chat box
display(
widgets.VBox(
children=[output, in_text],
layout=widgets.Layout(display="inline-flex", flex_flow="column-
reverse"),
)
)

F9.1

看来我们可以继续对话并提出一堆问题。使用这种基于聊天的方法,我们本质上创建了一个可以对图像进行推理的聊天机器人

总结

在本章中,我们探讨了各种使 \(\text{LLM}\) 具备多模态能力的方法,通过弥合文本和视觉表示之间的鸿沟。我们首先讨论了用于视觉的 \(\text{Transformer}\) 模型,这些模型将图像转换为数值表示。这是通过使用图像编码器图像块嵌入来实现的,这使得模型能够处理不同尺度的图像

然后,我们探讨了如何使用 \(\text{CLIP}\) 创建可以将图像和文本都转换为数值表示的嵌入模型。我们看到了 \(\text{CLIP}\) 如何利用对比学习图像和文本嵌入对齐到一个共享空间中,从而实现零样本分类、聚类和搜索等任务。本章还介绍了 \(\text{OpenCLIP}\),它是 \(\text{CLIP}\)开源变体,易于用于多模态嵌入任务

最后,我们探索了文本生成模型如何具备多模态能力,并深入研究了 \(\text{BLIP-2}\) 模型。这些多模态文本生成模型的核心思想涉及将输入图像中的视觉特征投射到文本嵌入中,然后 \(\text{LLM}\) 可以使用这些嵌入。我们看到了该模型如何用于图像加标题基于聊天的多模态提示,其中两种模态被结合起来生成响应。总而言之,本章强调了 \(\text{LLM}\)多模态的力量,并展示了其在图像加标题、搜索和基于聊天的提示等各种领域的应用。

在本书的第三部分中,我们将介绍训练和微调技术。在\(\text{10}\)中,我们将探讨如何创建和微调一个文本嵌入模型,这是驱动许多语言建模应用的核心技术。这下一章将作为训练和微调语言模型的介绍。

书籍各章的机翻md文件:
《Hands-On Large Language Models》目录及前言
《Hands-On Large Language Models》第1章 大型语言模型简介
《Hands-On Large Language Models》第2章 词元与嵌入
《Hands-On Large Language Models》第3章 深入了解大型语言模型
《Hands-On Large Language Models》第4章 文本分类
《Hands-On Large Language Models》第5章 文本聚类和主题建模
《Hands-On Large Language Models》第6章 提示工程
《Hands-On Large Language Models》第7章 高级文本生成技术与工具
《Hands-On Large Language Models》第8章 语义搜索与检索增强生成
《Hands-On Large Language Models》第9章 多模态大型语言模型
《Hands-On Large Language Models》第10章 创建文本嵌入模型
《Hands-On Large Language Models》第11章 微调用于分类的表征模型
《Hands-On Large Language Models》第12章 生成模型的微调

\(\text{8}\) 语义搜索与检索增强生成

Semantic Search and Retrieval-Augmented Generation

搜索最先获得广泛行业应用的语言模型应用之一。在开创性的论文《\(\text{BERT}\): 用于语言理解的深度双向 \(\text{Transformer}\) 预训练》(\(\text{2018}\))发布后的数月,谷歌宣布它正在使用 \(\text{BERT}\)\(\text{Google}\) 搜索提供支持,并称这代表了“搜索历史上最大的飞跃之一”。微软 \(\text{Bing}\) 也不甘落后,声明“从今年四月开始,我们使用大型 \(\text{Transformer}\) 模型,为我们的 \(\text{Bing}\) 用户带来了过去一年中最大的质量提升。”

这是对这些模型力量和实用性的明确证明。它们的加入即时且显著地改进了数十亿人赖以生存的一些最成熟、维护最完善的系统。它们增加的能力被称为语义搜索\(\text{semantic search}\)),它支持通过意义进行搜索,而不仅仅是关键词匹配

在另一个独立的领域,文本生成模型的快速采用促使许多用户向模型提问并期望得到事实性的答案。然而,虽然模型能够流利且自信地回答,但它们的答案并非总是正确或最新的。这个问题逐渐被称为模型的“幻觉”\(\text{hallucinations}\)),而减少这种现象的主要方法之一是构建能够检索相关信息并将其提供给 \(\text{LLM}\),以帮助其生成更具事实性的答案的系统。这种方法,被称为 \(\text{RAG}\),是 \(\text{LLM}\) 最流行的应用之一。

语义搜索与 \(\text{RAG}\) 概览

Overview of Semantic Search and RAG

关于如何最好地利用语言模型进行搜索,有大量的研究。这些模型主要分为三个大类:稠密检索 (\(\text{dense retrieval}\))、重排序 (\(\text{reranking}\)) 和 \(\text{RAG}\)。以下是对这三类模型的概览,本章的其余部分将对此进行更详细的解释:

稠密检索

稠密检索系统依赖于嵌入\(\text{embeddings}\))这一概念,这与我们在前几章中遇到的概念相同。它将搜索问题转化为检索搜索查询的最近邻(在查询和文档都被转换为嵌入之后)。图 \(\text{8}-1\) 展示了稠密检索如何接收一个搜索查询,查阅其文本档案,并输出一组相关结果

F8.1

重排序

搜索系统通常是多步骤的管线重排序语言模型是其中一个步骤,其任务是根据查询对结果子集的相关性进行评分;然后根据这些分数改变结果的顺序。图 \(\text{8}-2\) 展示了重排序器与稠密检索的不同之处在于,它们接收一个额外的输入:来自搜索管线中上一步的搜索结果集

F8.1

\(\text{RAG}\)

文本生成模型不断增长的能力催生了一种新型的搜索系统,这种系统包含一个生成模型,它生成一个答案来回应查询。图 \(\text{8}-3\) 展示了这样一个生成式搜索系统的例子。

F8.1

生成式搜索是更广义系统类别的一个子集,该类别更好地被称为 \(\text{RAG}\) 系统。这些是结合了搜索能力的文本生成系统,用于减少幻觉、提高事实性和/或将生成模型建立在特定数据集的基础上

本章的其余部分将更详细地介绍这三类系统。虽然它们是主要的类别,但它们并非语言模型在搜索领域的唯一应用

使用语言模型进行语义搜索

Semantic Search with Language Models

现在让我们更详细地探讨可以升级我们语言模型搜索能力的主要系统类别。我们将从稠密检索开始,然后依次转向重排序和 \(\text{RAG}\)

稠密检索

Dense Retrieval

回想一下,嵌入将文本转化为数值表示。正如我们在图 \(\text{8}-4\) 中所见,这些数值可以被视为空间中的点彼此靠近的点意味着它们所代表的文本是相似的。因此,在这个例子中,文本 \(\text{1}\) 和文本 \(\text{2}\) 彼此更相似(因为它们靠近),而比文本 \(\text{3}\) 相似度更高(因为它更远)。

F8.1

这就是用于构建搜索系统的特性。在这种情况下,当用户输入一个搜索查询时,我们嵌入该查询,从而将其投影到与我们的文本档案相同的空间中。然后,我们只需找到该空间中离查询最近的文档,这些文档就是搜索结果(图 \(\text{8}-5\))。

F8.1

从图 \(\text{8}-5\) 中的距离来判断,“文本 \(\text{2}\)”是该查询的最佳结果,其次是“文本 \(\text{1}\)”。然而,这里可能会出现两个问题:

  • 文本 \(\text{3}\) 是否应该作为结果返回? 这是您,即系统设计者的决定。有时,为了过滤掉不相关的结果(以防语料库中没有与查询相关的结果),设置一个最大的相似度分数阈值是可取的。
  • 查询和它的最佳结果在语义上是否相似? 并非总是如此。这就是为什么语言模型需要在问答对上进行训练,以提高检索能力。这个过程将在第 \(\text{10}\) 章中更详细地解释。

\(\text{8}-6\) 展示了我们如何对文档进行分块,然后继续对每个分块进行嵌入。这些嵌入向量随后被存储在向量数据库中,并准备好进行检索。

F8.1

稠密检索示例

Dense retrieval example

让我们通过使用 \(\text{Cohere}\) 搜索电影《\(\text{Interstellar}\)》(星际穿越)的维基百科页面,来看一个稠密检索的例子。在这个例子中,我们将执行以下操作:

  1. 获取我们想要进行搜索的文本,并进行一些轻量级处理,将其分块成句子
  2. 嵌入这些句子。
  3. 构建搜索索引
  4. 搜索并查看结果。

您可以通过在 https://oreil.ly/GxrQ1 注册来获取您的 \(\text{Cohere API}\) 密钥。将其粘贴到以下代码中。运行此示例您无需支付任何费用

让我们导入所需的库:

1
2
3
4
5
6
7
8
9
import cohere
import numpy as np
import pandas as pd
from tqdm import tqdm
# Paste your API key here. Remember to not share publicly
''
api_key =
# Create and retrieve a Cohere API key from os.cohere.ai
co = cohere.Client(api_key)

获取文本档案并进行分块

Getting the text archive and chunking it.

让我们使用维基百科关于电影《\(\text{Interstellar}\)》文章的第一部分。我们将获取文本,然后将其分解成句子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
text =
"""
Interstellar is a 2014 epic science fiction film co-written, directed, and pro
duced by Christopher Nolan.
It stars Matthew McConaughey, Anne Hathaway, Jessica Chastain, Bill Irwin,
Ellen Burstyn, Matt Damon, and Michael Caine.
Set in a dystopian future where humanity is struggling to survive, the film
follows a group of astronauts who travel through a wormhole near Saturn in
search of a new home for mankind.
Brothers Christopher and Jonathan Nolan wrote the screenplay, which had its
origins in a script Jonathan developed in 2007.
Caltech theoretical physicist and 2017 Nobel laureate in Physics[4] Kip Thorne
was an executive producer, acted as a scientific consultant, and wrote a tie-in
book, The Science of Interstellar.
Cinematographer Hoyte van Hoytema shot it on 35 mm movie film in the Panavision
anamorphic format and IMAX 70 mm.
Principal photography began in late 2013 and took place in Alberta, Iceland,
and Los Angeles.
Interstellar uses extensive practical and miniature effects and the company
Double Negative created additional digital effects.
Interstellar premiered on October 26, 2014, in Los Angeles.
In the United States, it was first released on film stock, expanding to venues
using digital projectors.
The film had a worldwide gross over $677 million (and $773 million with subse
quent re-releases), making it the tenth-highest grossing film of 2014.
It received acclaim for its performances, direction, screenplay, musical score,
visual effects, ambition, themes, and emotional weight.
It has also received praise from many astronomers for its scientific accuracy
and portrayal of theoretical astrophysics. Since its premiere, Interstellar
gained a cult following,[5] and now is regarded by many sci-fi experts as one
of the best science-fiction films of all time.
Interstellar was nominated for five awards at the 87th Academy Awards, winning
Best Visual Effects, and received numerous other accolades"""
# Split into a list of sentences
texts = text.split('.')
# Clean up to remove empty spaces and new lines
texts = [t.strip(' \n') for t in texts]

嵌入文本块

Embedding the text chunks.

现在我们来嵌入这些文本。我们会将它们发送到 \(\text{Cohere API}\),并为每段文本获取一个向量

1
2
3
4
5
6
7
# Get the embeddings
response = co.embed(
texts=texts,
input_type="search_document",
).embeddings
embeds = np.array(response)
print(embeds.shape)

这输出 \(\text{(15, 4096)}\),这表明我们有 \(15\) 个向量,每个向量的大小为 \(4096\)

构建搜索索引

Building the search index

在我们搜索之前,我们需要构建一个搜索索引。索引存储了这些嵌入,并且经过优化,即使我们有大量的点,也能快速检索到最近的邻居

1
2
3
4
5
import faiss
dim = embeds.shape[1]
index = faiss.IndexFlatL2(dim)
print(index.is_trained)
index.add(np.float32(embeds))

搜索索引

Search the index.

我们现在可以使用任何我们想要的查询来搜索数据集。我们只需嵌入查询,并将其嵌入向量提供给索引,索引将检索出维基百科文章中最相似的句子

让我们定义我们的搜索函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def search(query, number_of_results=3):
# 1. Get the query's embedding
query_embed = co.embed(texts=[query],
input_type="search_query",).embeddings[0]
# 2. Retrieve the nearest neighbors
distances , similar_item_ids = index.search(np.float32([query_embed]), num
ber_of_results)
# 3. Format the results
texts_np = np.array(texts) # Convert texts list to numpy for easier indexing
results = pd.DataFrame(data={'texts': texts_np[similar_item_ids[0]],
'distance': distances[0]})
# 4. Print and return the results
print(f"Query:'{query}'\nNearest neighbors:")
return results

我们现在可以编写一个查询并搜索文本了!

1
2
3
query = "how precise was the science"
results = search(query)
results

这将产生以下输出:

F8.1

第一个结果距离最小,因此与查询的相似度最高。看它一眼,它完美地回答了这个问题。请注意,如果我们只进行关键词搜索,这是不可能实现的,因为排名靠前的结果并未包含查询中的相同关键词

我们实际上可以通过定义一个关键词搜索函数来验证这一点,以便进行比较。我们将使用 \(\text{BM25}\) 算法,它是领先的词汇搜索方法之一。有关这些代码片段的来源,请参阅此 \(\text{notebook}\)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from rank_bm25 import BM25Okapi
from sklearn.feature_extraction import _stop_words
import string
def bm25_tokenizer(text):
tokenized_doc = []
for token in text.lower().split():
token = token.strip(string.punctuation)
if len(token) > 0 and token not in _stop_words.ENGLISH_STOP_WORDS:
tokenized_doc.append(token)
return tokenized_doc
tokenized_corpus = []
for passage in tqdm(texts):
tokenized_corpus.append(bm25_tokenizer(passage))
bm25 = BM25Okapi(tokenized_corpus)
def keyword_search(query, top_k=3, num_candidates=15):
print("Input question:", query)
##### BM25 search (lexical search) #####
bm25_scores = bm25.get_scores(bm25_tokenizer(query))
top_n = np.argpartition(bm25_scores,
-num_candidates)[-num_candidates:]
bm25_hits = [{'corpus_id': idx, 'score': bm25_scores[idx]} for idx in top_n]
bm25_hits = sorted(bm25_hits, key=lambda x: x['score'], reverse=True)

print(f"Top-3 lexical search (BM25) hits")
for hit in bm25_hits[0:top_k]:
print("\t{:.3f}\t{}"
pus_id']].replace("\n"
, " ")))
.format(hit['score'], texts[hit['cor

现在,当我们搜索相同的查询时,我们从稠密检索搜索中得到了一组不同的结果

1
keyword_search(query = "how precise was the science")

结果:

1
2
3
4
5
6
7
8
9
Input question: how precise was the science
Top-3 lexical search (BM25) hits
1.789 Interstellar is a 2014 epic science fiction film co-written, direc-
ted, and produced by Christopher Nolan
1.373 Caltech theoretical physicist and 2017 Nobel laureate in Phys-
ics[4] Kip Thorne was an executive producer, acted as a scientific consultant,
and wrote a tie-in book, The Science of Interstellar
0.000 It stars Matthew McConaughey, Anne Hathaway, Jessica Chastain,
Bill Irwin, Ellen Burstyn, Matt Damon, and Michael Caine

请注意,第一个结果并没有真正回答问题,尽管它与查询共享了“\(\text{science}\)”一词。在下一节中,我们将看到添加重排序器如何改进这个搜索系统。但在此之前,让我们通过查看稠密检索的注意事项并回顾一些将文本分解成块的方法完成稠密检索的概述

F8.1

稠密检索的注意事项

Caveats of dense retrieval

了解稠密检索的一些缺点以及如何解决它们是很有用的。例如,如果文本中不包含答案会发生什么?我们仍然会得到结果和它们的距离。例如:

F8.1

在这种情况下,一种可能的启发式方法设置一个阈值——例如,相关性的最大距离。许多搜索系统会向用户展示它们能得到的最佳信息,并由用户来决定它是否相关。

追踪用户是否点击了某个结果(以及是否满意)的信息可以改进搜索系统的未来版本

稠密检索的另一个注意事项是当用户想要找到一个特定短语的精确匹配时。这种情况非常适合关键词匹配。这也是为什么建议使用混合搜索\(\text{hybrid search}\),它包含语义搜索和关键词搜索),而不是仅仅依赖稠密检索的原因之一。

稠密检索系统在与它们训练时所处的领域不同的领域中也难以正常工作。因此,举例来说,如果您在互联网和维基百科数据上训练了一个检索模型,然后将其部署到法律文本上(训练集中没有足够的法律数据),那么该模型在那个法律领域中就不会工作得那么好

我们想指出的最后一点是,在前面的例子中,每个句子都包含一条信息,我们展示的查询是专门询问该信息的。但是,答案跨越多个句子的问题该怎么办呢?这突出显示了稠密检索系统的一个重要设计参数:将长文本分块的最佳方法是什么?以及为什么我们首先需要将它们分块?

对长文本进行分块

Chunking long texts

\(\text{Transformer}\) 语言模型的一个限制是它们的上下文大小是有限的\(\text{limited in context sizes}\)),这意味着我们不能向它们馈送超过模型支持的单词或词元数量的非常长的文本。那么,我们如何嵌入长文本呢?

有几种可能的方法,如图 \(\text{8}-7\) 所示的两种可能的方法包括:对每个文档索引一个向量对每个文档索引多个向量

F8.1

每个文档一个向量

One vector per document.

在这种方法中,我们使用单个向量表示整个文档。这里有几种可能性:

  • 仅嵌入文档的代表性部分,而忽略其余文本。 这可能意味着仅嵌入标题,或仅嵌入文档的开头。这对于快速开始构建演示很有用,但它会留下大量信息未被索引,因此无法搜索。作为一种方法,它可能更适用于文档开头捕捉了主要观点的文档(想想:维基百科文章)。但对于一个真实的系统来说,这并不是最好的方法,因为大量信息将被排除在索引之外,从而无法被搜索。
  • 将文档分块、嵌入这些块,然后将这些块聚合成一个向量。 这里常用的聚合方法是平均这些向量。这种方法的缺点是它会产生一个高度压缩的向量丢失了文档中的大量信息

这种方法可以满足某些信息需求,但不能满足其他需求。很多时候,搜索是为了文章中包含的特定信息片段,如果该概念有自己的向量,则可以更好地捕获。

每个文档多个向量

Multiple vectors per document.

在这种方法中,我们将文档分块成更小的片段,并嵌入这些块。然后,我们的搜索索引就变成了块嵌入的集合,而不是整个文档的嵌入。图 \(\text{8}-8\) 显示了多种可能的文本分块方法

F8.1

分块方法更好,因为它完全覆盖了文本,并且因为这些向量倾向于捕获文本内部的各个概念。这会产生一个更具表现力的搜索索引。图 \(\text{8}-9\) 展示了多种可能的方法。

F8.1

对长文本进行分块的最佳方法将取决于您的系统预期的文本类型和查询类型。方法包括:

  • 每个句子是一个块。 这里的问题是这可能过于精细,并且向量无法捕获足够的上下文
  • 每个段落是一个块。 如果文本由短段落组成,这会很棒。否则,可能是\(\text{3}\)\(\text{8}\) 个句子作为一个块。
  • 有些块的意义很大程度上来源于周围的文本。 因此,我们可以通过以下方式纳入一些上下文:
    • 将文档的标题添加到块中。
    • 将它们之前和之后的一些文本添加到块中。 这样,这些块就可以重叠,从而包含一些也出现在相邻块中的周围文本。这就是我们在图 \(\text{8}-10\) 中可以看到的。

F8.1

随着该领域的发展,预计会出现更多分块策略——其中一些甚至可能使用 \(\text{LLM}\) 来动态地将文本分割成有意义的块

最近邻搜索与向量数据库

Nearest neighbor search versus vector databases

一旦查询被嵌入,我们需要从我们的文本档案中找到距离它最近的向量,如图 \(\text{8}-11\) 所示。找到最近邻最直接的方法是计算查询和档案之间的距离。如果您的档案中有数千或数万个向量,这是一种合理的方法,可以轻松地使用 \(\text{NumPy}\) 完成。

F8.1

当您扩展到数百万个向量以上时,一种优化的检索方法是依赖于 近似最近邻搜索\(\text{approximate nearest neighbor search}\))库,例如 \(\text{Annoy}\)\(\text{FAISS}\)。这些库允许您在毫秒内大规模索引中检索结果,其中一些可以通过利用 \(\text{GPU}\) 并扩展到机器集群来服务于超大型索引,从而提高其性能。

另一类向量检索系统是向量数据库\(\text{vector databases}\)),如 \(\text{Weaviate}\)\(\text{Pinecone}\)。向量数据库允许您添加或删除向量无需重建索引。它们还提供了过滤搜索或以超出单纯向量距离的方式自定义搜索的方法。

为稠密检索微调嵌入模型

Fine-tuning embedding models for dense retrieval

正如我们在第 \(\text{4}\) 章中讨论文本分类时所说,我们可以使用微调\(\text{fine-tuning}\))来提高 \(\text{LLM}\) 在特定任务上的性能。在这种情况下,检索需要优化文本嵌入,而不仅仅是词元嵌入。这个微调过程需要训练数据,这些数据由查询和相关结果组成。

让我们来看一个来自我们数据集的例子:句子“\(\text{Interstellar premiered on October 26, 2014, in Los Angeles.}\)”对于这个句子来说,两个可能的相关查询是:

  • 相关查询 \(\text{1}\)\(\text{Interstellar release date}\)
  • 相关查询 \(\text{2}\)\(\text{When did Interstellar premier}\)

微调过程旨在使这些查询的嵌入结果句子的嵌入距离更近。它还需要查看与该句子不相关的查询的负面示例,例如:

  • 不相关查询:\(\text{Interstellar cast}\)

有了这些例子,我们现在有三对——两对正例和一对负例。正如我们在图 \(\text{8}-12\) 中所见,假设在微调之前,所有三个查询与结果文档的距离都相同。这并不牵强,因为它们都与《星际穿越》有关。

F8.1

微调步骤的作用是使相关查询靠近文档,同时使不相关查询远离文档。我们可以在图 \(\text{8}-13\) 中看到这种效果。

F8.1

重排序

Reranking

许多组织已经构建了搜索系统。对于这些组织来说,将语言模型纳入其搜索管线的一种更简单方法是作为最后一步。这一步的任务是根据与搜索查询的相关性更改搜索结果的顺序。这一个步骤就可以极大地改进搜索结果,事实上,这也是微软 \(\text{Bing}\) 添加 \(\text{BERT}\) 类模型以实现搜索结果改进的方式。图 \(\text{8}-14\) 展示了重排序搜索系统作为两阶段搜索系统第二阶段的结构。

F8.1

重排序示例

Reranking example

重排序器\(\text{reranker}\))接收搜索查询多个搜索结果,并返回这些文档的最佳排序,使与查询最相关的结果排在更高的位置。\(\text{Cohere}\)\(\text{Rerank}\) 端点是开始使用第一个重排序器的一种简单方法。我们只需将查询和文本传递给它,即可获得结果。我们不需要对其进行训练或调整

1
2
3
4
query = "how precise was the science"
results = co.rerank(query=query, documents=texts, top_n=3, return_docu
ments=True)
results.results

我们可以打印这些结果:

1
2
for idx, result in enumerate(results.results):
print(idx, result.relevance_score , result.document.text)

输出:

1
2
3
4
5
6
7
8
0 0.1698185 It has also received praise from many astronomers for its scien-
tific accuracy and portrayal of theoretical astrophysics
1 0.07004896 The film had a worldwide gross over $677 million (and $773 mil-
lion with subsequent re-releases), making it the tenth-highest grossing film
of 2014
2 0.0043994132 Caltech theoretical physicist and 2017 Nobel laureate in Phys-
ics[4] Kip Thorne was an executive producer, acted as a scientific consultant,
and wrote a tie-in book, The Science of Interstellar

这表明重排序器对第一个结果更加确信,为其分配了 \(0.16\) 的相关性分数,而其他结果的相关性得分要低得多

在这个基本示例中,我们将所有 \(15\) 个文档都传递给了重排序器。然而,更常见的情况是,我们的索引会有数千或数百万个条目,我们需要筛选出(比如)一百或一千个结果,然后将这些结果呈现给重排序器。这个筛选步骤被称为搜索管线的第一阶段。

第一阶段的检索器可以是关键词搜索、稠密检索,或者更好的是使用两者的混合搜索。我们可以重新审视我们之前的示例,看看在关键词搜索系统之后添加一个重排序器如何提高其性能

让我们修改我们的关键词搜索函数,使其使用关键词搜索检索出排名前 \(\text{10}\) 的结果列表,然后使用重排序来从这 \(\text{10}\) 个结果中选择排名前 \(\text{3}\) 的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def keyword_and_reranking_search(query, top_k=3, num_candidates=10):
print("Input question:", query)
##### BM25 search (lexical search) #####
bm25_scores = bm25.get_scores(bm25_tokenizer(query))
top_n = np.argpartition(bm25_scores,-num_candidates)[-num_candidates:]
bm25_hits = [{'corpus_id': idx, 'score': bm25_scores[idx]} for idx in top_n]
bm25_hits = sorted(bm25_hits, key=lambda x: x['score'], reverse=True)
print(f"Top-3 lexical search (BM25) hits")
for hit in bm25_hits[0:top_k]:
print("\t{:.3f}\t{}" pus_id']].replace("\n", " "))).format(hit['score'], texts[hit['cor
#Add re-ranking
docs = [texts[hit['corpus_id']] for hit in bm25_hits]
print(f"\nTop-3 hits by rank-API ({len(bm25_hits)} BM25 hits re-ranked)")
results = co.rerank(query=query, documents=docs, top_n=top_k, return_docu
ments=True)
# print(results.results)
for hit in results.results:
# print(hit)
print("\t{:.3f}\t{}"
.format(hit.relevance_score, hit.docu
ment.text.replace("\n", " ")))

现在我们可以发送我们的查询,并检查关键词搜索的结果,以及关键词搜索筛选出前 \(\text{10}\) 个结果,然后将它们传递给重排序器的最终结果:

1
keyword_and_reranking_search(query = "how precise was the science")

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Input question: how precise was the science
Top-3 lexical search (BM25) hits
1.789 Interstellar is a 2014 epic science fiction film co-written, directed,
and produced by Christopher Nolan
1.373 Caltech theoretical physicist and 2017 Nobel laureate in Physics[4] Kip
Thorne was an executive producer, acted as a scientific consultant, and wrote
a tie-in book, The Science of Interstellar
0.000 Interstellar uses extensive practical and miniature effects and the com-
pany Double Negative created additional digital effects
Top-3 hits by rank-API (10 BM25 hits re-ranked)
0.004 Caltech theoretical physicist and 2017 Nobel laureate in Physics[4] Kip
Thorne was an executive producer, acted as a scientific consultant, and wrote
a tie-in book, The Science of Interstellar
0.004 Set in a dystopian future where humanity is struggling to survive, the
film follows a group of astronauts who travel through a wormhole near Saturn
in search of a new home for mankind
0.003 Brothers Christopher and Jonathan Nolan wrote the screenplay, which had
its origins in a script Jonathan developed in 2007

我们看到关键词搜索只为共享某些关键词的两个结果分配了分数。在第二组结果中,重排序器第二个结果恰当地提升与查询最相关的结果。这是一个玩具示例,让我们初窥其效果,但在实践中,这样的管线可以显著提高搜索质量。在像 \(\text{MIRACL}\) 这样的多语言基准测试中,重排序器可以将性能从 \(\text{36.5}\) 提升到 \(\text{62.8}\),以 \(\text{nDCG}@\text{10}\) 衡量(关于评估的更多内容将在本章后面讨论)。

使用 \(\text{Sentence Transformers}\) 进行开源检索和重排序

Open source retrieval and reranking with sentence transformers

如果您想在自己的机器上本地设置检索和重排序,那么可以使用 \(\text{Sentence Transformers}\)。请参阅 https://oreil.ly/jJOhV 上的文档进行设置。查看\(\text{Retrieve \& Re-Rank}\)”部分,获取有关如何在库中执行这些步骤的说明和代码示例。

重排序模型的工作原理

How reranking models work

构建 \(\text{LLM}\) 搜索重排序器的一种流行方法是,将查询和每个结果呈现给一个充当交叉编码器\(\text{cross-encoder}\))的 \(\text{LLM}\)。这意味着查询和可能的搜索结果同时呈现给模型,允许模型在分配相关性分数之前查看这两段文本,如图 \(\text{8}-15\) 所示。所有文档都是批量同时处理的,但每个文档都是针对查询独立评估的。然后,这些分数决定了结果的新顺序。这种方法在题为\(\text{Multi-stage document ranking with BERT}\)的论文中有更详细的描述,有时被称为 \(\text{monoBERT}\)

F8.1

这种将搜索表述为相关性评分的方法基本上归结为一个分类问题。给定这些输入,模型输出一个\(\text{0}\)\(\text{1}\) 的分数,其中 \(\text{0}\) 表示不相关\(\text{1}\) 表示高度相关。这应该与我们在第 \(\text{4}\) 章中关于分类的讨论相一致。

要了解更多关于使用 \(\text{LLM}\) 进行搜索的发展,强烈推荐阅读\(\text{Pretrained transformers for text tanking: BERT and beyond}\),它回顾了直到大约 \(\text{2021}\) 年这些模型的发展。

检索评估指标

Retrieval Evaluation Metrics

语义搜索是使用信息检索\(\text{Information Retrieval}\), \(\text{IR}\))领域的指标进行评估的。让我们讨论其中一个流行的指标:平均精度均值\(\text{mean average precision}\), \(\text{MAP}\))。

评估搜索系统需要三个主要组成部分:一个文本档案、一组查询,以及相关性判断,指明哪些文档与每个查询相关。我们在图 \(\text{8}-16\) 中可以看到这些组件。

F8.1

使用这个测试套件,我们可以继续探索搜索系统的评估。让我们从一个简单的例子开始。假设我们向两个不同的搜索系统传递查询 \(\text{1}\)。并得到两组结果。假设我们将结果数量限制为三个,如图 \(\text{8}-17\) 所示。

F8.1

为了判断哪个系统更好,我们求助于我们拥有的关于该查询的相关性判断。图 \(\text{8}-18\) 显示了返回的结果中哪些是相关的

F8.1

这向我们展示了一个清晰的案例,即系统 \(\text{1}\) 优于系统 \(\text{2}\)。直观上,我们可以简单地计算每个系统检索到的相关结果数量。系统 \(\text{1}\)\(\text{3}\) 个结果中得到了两个正确的,而系统 \(\text{2}\)\(\text{3}\) 个结果中只得到了一个正确的。但是,对于如图 \(\text{8}-19\) 所示的案例呢?在这两种情况下,两个系统在 \(\text{3}\) 个结果中都只得到一个相关结果,但它们处于不同的位置

F8.1

在这种情况下,我们可以凭直觉认为系统 \(\text{1}\) 比系统 \(\text{2}\)做得更好,因为第一个位置(最重要的位置)的结果是正确的。但是,我们如何为这种更好的程度分配一个数值或分数呢?平均精度均值就是一种能够量化这种区别的度量。

在这种情景中,一种常见的分配数值分数的方法是平均精度\(\text{average precision}\)),它评估系统 \(\text{1}\) 对该查询的结果为 \(\text{1}\),系统 \(\text{2}\) 的结果为 \(\text{0.3}\)。因此,让我们看看平均精度是如何计算来评估一组结果的,以及如何将它聚合起来以评估测试套件中所有查询的系统

用平均精度对单个查询评分

Scoring a single query with average precision

要对搜索系统在这个查询上的表现进行评分,我们可以专注于对相关文档进行评分。让我们从一个在测试套件中只有一个相关文档的查询开始看。

第一个很容易:搜索系统将相关结果(该查询唯一可用的结果)放置在最前面。这使系统获得了满分 \(\text{1}\)。图 \(\text{8}-20\) 展示了这种计算:查看第一个位置,我们有一个相关结果,导致位置 \(\text{1}\) 处的精度为 \(\text{1.0}\)(计算方法是位置 \(\text{1}\) 处的相关结果数量除以我们当前查看的位置)。

F8.1

由于我们只对相关文档进行评分,我们可以忽略不相关文档的分数并在此处停止计算。但是,如果系统将唯一的相关结果放在第三个位置,这会如何影响分数呢?图 \(\text{8}-21\) 展示了这会如何导致惩罚

F8.1

现在让我们来看一个有多个相关文档的查询。图 \(\text{8}-22\) 展示了这种计算,以及平均是如何介入的。

F8.1

用平均精度均值(\(\text{mean average precision}\))对多个查询评分

Scoring across multiple queries with mean average precision

既然我们熟悉了位置 \(k\) 处的精度平均精度,我们就可以将这个知识扩展到一个可以针对我们测试套件中所有查询对搜索系统进行评分的指标。这个指标被称为平均精度均值\(\text{mean average precision}\))。图 \(\text{8}-23\) 展示了如何通过取每个查询的平均精度平均值来计算这个指标。

F8.1

您可能想知道为什么同一个操作被称为“均值”(\(\text{mean}\)“平均”(\(\text{average}\)。这很可能是一种美学选择,因为 \(\text{MAP}\) 听起来比“平均平均精度”更好。

现在我们有了一个单一的指标,可以用来比较不同的系统。如果您想了解更多关于评估指标的信息,请参阅 \(\text{Christopher D. Manning}\)\(\text{Prabhakar Raghavan}\)\(\text{Hinrich Schütze}\) 所著的《信息检索导论》(\(\text{Introduction to Information Retrieval}\))(剑桥大学出版社)中的“信息检索中的评估”一章。

除了平均精度均值之外,另一个常用于搜索系统的指标是归一化折损累计增益\(\text{normalized discounted cumulative gain}\), \(\text{nDCG}\)),它更细致入微,因为文档的相关性不是二元的(相关与不相关),并且在测试套件和评分机制中,一个文档可以被标记为比另一个更相关

检索增强生成

Retrieval-Augmented Generation (RAG)

\(\text{LLM}\) 的大规模采用很快导致人们向它们提问并期望得到事实性的答案。虽然模型可以正确回答一些问题,但它们也自信地回答了许多不正确的问题。业界为纠正这种行为而转向的主要方法是 \(\text{RAG}\),该方法在论文《用于知识密集型 \(\text{NLP}\) 任务的检索增强生成》(\(\text{Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks}\))(\(\text{2020}\))中有所描述,如图 \(\text{8}-24\) 所示。

F8.1

\(\text{RAG}\) 系统在生成能力之外,还整合了搜索能力。它们可以被视为对生成系统的改进,因为它们减少了幻觉提高了事实性。它们还支持“与我的数据聊天”的用例,个人和公司可以使用这些系统来\(\text{LLM}\) 建立在内部公司数据特定的感兴趣数据源(例如,与一本书聊天)的背景上。

这也扩展到了搜索系统。越来越多的搜索引擎正在整合 \(\text{LLM}\)总结结果回答提交给搜索引擎的问题。例子包括 \(\text{Perplexity}\)\(\text{Microsoft Bing AI}\)\(\text{Google Gemini}\)

从搜索到 \(\text{RAG}\)

From Search to RAG

现在让我们将我们的搜索系统转变为一个 \(\text{RAG}\) 系统。我们通过在搜索管线的末端添加一个 \(\text{LLM}\) 来实现这一点。我们将问题和检索到的最相关的文档呈现给 \(\text{LLM}\),并要求它根据搜索结果提供的上下文来回答问题。我们可以在图 \(\text{8}-25\) 中看到一个例子。

F8.1

这个生成步骤被称为基于事实的生成\(\text{grounded generation}\)),因为我们提供给 \(\text{LLM}\)检索到的相关信息建立了一个特定的上下文,将 \(\text{LLM}\) 建立在我们感兴趣的领域中。图 \(\text{8}-26\) 展示了如果我们继续我们前面提到的嵌入搜索示例基于事实的生成是如何在搜索之后起作用的。

F8.1

示例:使用 \(\text{LLM API}\) 进行基于事实的生成

Example: Grounded Generation with an LLM API

让我们看看如何在搜索结果之后添加一个基于事实的生成步骤来创建我们的第一个 \(\text{RAG}\) 系统。对于这个示例,我们将使用 \(\text{Cohere}\) 的托管 \(\text{LLM}\),它建立在我们本章前面看到的搜索系统之上。我们将使用嵌入搜索来检索最相关的文档,然后我们将这些文档与问题一起传递给 \(\text{co.chat}\) 端点,以提供一个基于事实的答案

1
2
3
4
5
6
7
8
9
10
query = "income generated"
# 1- Retrieval
# We'll use embedding search. But ideally we'd do hybrid
results = search(query)
# 2- Grounded Generation
docs_dict = [{'text': text} for text in results['texts']]
response = co.chat(
message = query,
documents=docs_dict
)

结果:

1
print(response.text)
1
2
The film generated a worldwide gross of over $677 million, or $773 million
with subsequent re-releases.

我们对一些文本进行了高亮显示,因为模型表明这些文本片段的来源是我们传入的第一个文档

1
2
3
4
5
6
7
citations=[ChatCitation(start=21, end=36, text='worldwide gross', docu-
ment_ids=['doc_0']), ChatCitation(start=40, end=57, text='over $677 million',
document_ids=['doc_0']), ChatCitation(start=62, end=103, text='$773 million
with subsequent re-releases.', document_ids=['doc_0'])]
documents=[{'id': 'doc_0', 'text': 'The film had a worldwide gross over $677
million (and $773 million with subsequent re-releases), making it the tenth-
highest grossing film of 2014'}]

示例:使用本地模型的 \(\text{RAG}\)

Example: RAG with Local Models

现在让我们用本地模型来复制这个基本功能。我们将失去进行跨度引用的能力,而且较小的本地模型效果不如较大的托管模型,但演示这个流程很有用。我们将从下载一个量化模型开始。

加载生成模型

Loading the generation model

我们从下载我们的模型开始:

1
!wget https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-gguf/resolve/main/Phi-3-mini-4k-instruct-fp16.gguf

我们使用 \(\text{llama.cpp}\)\(\text{llama-cpp-python}\)\(\text{LangChain}\) 来加载文本生成模型:

1
2
3
4
5
6
7
8
9
10
from langchain import LlamaCpp
# Make sure the model path is correct for your system!
llm = LlamaCpp(
model_path="Phi-3-mini-4k-instruct-fp16.gguf",
n_gpu_layers=-1,
max_tokens=500,
n_ctx=2048,
seed=42,
verbose=False
)

加载嵌入模型

Loading the embedding model

现在我们来加载一个嵌入语言模型。在这个例子中,我们将选择 \(\text{BAAI/bge-small-en-v1.5}\) 模型。在撰写本文时,它在 \(\text{MTEB}\) 嵌入模型排行榜上名列前茅,并且相对较小

1
2
3
4
5
from langchain.embeddings.huggingface import HuggingFaceEmbeddings
# Embedding model for converting text to numerical representations
embedding_model = HuggingFaceEmbeddings(
model_name='thenlper/gte-small'
)

我们现在可以使用这个嵌入模型来设置我们的向量数据库

1
2
3
from langchain.vectorstores import FAISS
# Create a local vector database
db = FAISS.from_texts(texts, embedding_model)

\(\text{RAG}\) 提示

The RAG prompt

提示模板\(\text{RAG}\) 管线中起着至关重要的作用。它是我们向 \(\text{LLM}\) 传达相关文档的核心位置。为此,我们将创建一个名为 \(\text{context}\) 的额外输入变量,它可以为 \(\text{LLM}\) 提供检索到的文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from langchain import PromptTemplate
# Create a prompt template
template = """<|user|>
Relevant information:
{context}
Provide a concise answer the following question using the relevant information
provided above:
{question}<|end|>
<|assistant|>"""
prompt = PromptTemplate(
template=template,
input_variables=["context", "question"]
)
from langchain.chains import RetrievalQA
# RAG pipeline
rag = RetrievalQA.from_chain_type(
llm=llm,
chain_type='stuff',
retriever=db.as_retriever(),
chain_type_kwargs={
"prompt": prompt
},
verbose=True

现在我们准备好调用模型并提出问题了:

1
2
rag.invoke('Income generated')
)

结果:

1
2
3
4
5
6
7
8
9
10
The Income generated by the film in 2014 was over $677 million worldwide.
This made it the tenth-highest grossing film of that year. It should be noted,
however, this figure includes both initial ticket sales as well as any subse-
quent re-releases. With these additional releases, total earnings surged to
approximately $773 million. The release format transitioned from traditional
film stock projection in theaters to digital projectors once it was expanded
to various venues in the United States. This shift might have contributed
to wider audience reach and potentially higher grossing figures over time.
However, specific data on how this affected total earnings isn't provided in
the information above.

一如既往,我们可以调整提示控制模型的生成(例如,回答的长度和语气)。

高级 \(\text{RAG}\) 技术

还有一些额外的技术可以提高 \(\text{RAG}\) 系统的性能。其中一些技术如下:

查询重写

Query rewriting

如果 \(\text{RAG}\) 系统是一个聊天机器人,那么如果一个问题过于冗长,或者引用对话中先前消息的上下文,前面简单的 \(\text{RAG}\) 实现很可能会在搜索步骤中遇到困难。因此,一个好主意是使用一个 \(\text{LLM}\) 将查询重写成一个有助于检索步骤获取正确信息的查询。例如,一条消息可能是:

用户问题:“我们明天有一个论文要交。我们得写一些关于动物的东西。我喜欢企鹅。我可以写它们。但我也可以写海豚。它们是动物吗?也许吧。我们写海豚吧。例如,它们生活在哪里?”

这实际上应该被重写成一个像这样的查询:

查询:“海豚生活在哪里”

这种重写行为可以通过提示(或通过 \(\text{API}\) 调用)来完成。例如,\(\text{Cohere}\)\(\text{API}\)\(\text{co.chat}\) 设有一个专门的查询重写模式

多查询 \(\text{RAG}\)

Multi-query RAG

我们可以引入的下一个改进是扩展查询重写,使其能够搜索多个查询,如果回答特定问题需要多个查询的话。例如:

用户问题:“比较 \(\text{Nvidia}\)\(\text{2020}\) 年和 \(\text{2023}\) 年的财务业绩”

我们可能会找到一份包含两年结果的文档,但更可能的情况是,我们最好进行两次搜索查询

查询 \(\text{1}\):“\(\text{Nvidia 2020}\) 财务业绩” 查询 \(\text{2}\):“\(\text{Nvidia 2023}\) 财务业绩”

然后,我们将这两个查询的靠前结果呈现给模型,用于基于事实的生成。这里还有一个小的额外改进,是也赋予查询重写器一个选项,使其能够判断是否不需要搜索,以及是否可以直接自信地生成答案不进行搜索

多跳 \(\text{RAG}\)

Multi-hop RAG

一个更高级的问题可能需要一系列按顺序的查询。例如,一个问题可能是:

用户问题:“\(\text{2023}\) 年最大的汽车制造商是哪些?它们各自生产电动汽车 (\(\text{EVs}\)) 吗?”

为了回答这个问题,系统必须首先搜索

步骤 \(\text{1}\),查询 \(\text{1}\):“\(\text{2023}\) 年最大的汽车制造商”

然后,在获取到这些信息(结果可能是丰田、大众和现代)之后,它应该提出后续问题

步骤 \(\text{2}\),查询 \(\text{1}\):“丰田汽车公司电动汽车” 步骤 \(\text{2}\),查询 \(\text{2}\):“大众汽车集团电动汽车” 步骤 \(\text{2}\),查询 \(\text{3}\):“现代汽车公司电动汽车”

查询路由

Query routing

另一个增强功能是赋予模型搜索多个数据源的能力。例如,我们可以为模型指定:如果它收到了一个关于人力资源\(\text{HR}\))的问题,它应该搜索公司的 \(\text{HR}\) 信息系统(例如 \(\text{Notion}\)),但如果问题是关于客户数据的,它应该搜索客户关系管理\(\text{CRM}\))系统(例如 \(\text{Salesforce}\))。

智能体式

Agentic RAG

您现在可能已经注意到,前面提到的增强功能列表正在缓慢地将越来越多的责任委托给 \(\text{LLM}\),以解决越来越复杂的问题。这依赖于 \(\text{LLM}\) 衡量所需信息需求的能力以及其利用多个数据源的能力。\(\text{LLM}\) 的这种新性质开始越来越接近于一个作用于世界的智能体\(\text{agent}\))。数据源现在也可以抽象成工具。例如,我们看到我们可以搜索 \(\text{Notion}\),同理,我们也应该能够发布到 \(\text{Notion}\)

并非所有的 \(\text{LLM}\) 都具备这里提到的 \(\text{RAG}\) 能力。在撰写本文时,可能只有最大的托管模型才能尝试这种行为。值得庆幸的是,\(\text{Cohere}\)\(\text{Command R+}\) 在这些任务上表现出色,并且也作为开源权重模型提供。

\(\text{RAG}\) 评估

RAG Evaluation

关于如何评估 \(\text{RAG}\) 模型,目前仍有持续的进展。一篇关于这个主题的优秀论文是《评估生成式搜索引擎中的可验证性》\(\text{Evaluating verifiability in generative search engines}\))(\(\text{2023}\)),它对不同的生成式搜索系统进行了人工评估。它沿着四个轴评估结果:

  • 流畅性 (\(\text{Fluency}\)) 生成的文本是否流畅且连贯
  • 感知效用 (\(\text{Perceived utility}\)) 生成的答案是否有帮助且信息丰富
  • 引用召回率 (\(\text{Citation recall}\)) 生成的关于外部世界的陈述中,完全由其引用支持的比例。
  • 引用准确率 (\(\text{Citation precision}\)) 生成的引用中,支持其相关陈述的比例。

虽然人工评估总是首选,但也有一些方法试图自动化这些评估,即让一个有能力的 \(\text{LLM}\) 充当裁判(称为 \(\text{LLM}\)-作为-裁判),并沿着不同的轴对不同的生成内容进行评分。\(\text{Ragas}\) 就是一个完全执行此操作的软件库。它还对一些额外的有用指标进行评分,例如:

  • 忠实度 (\(\text{Faithfulness}\)) 答案是否与提供的上下文一致
  • 答案相关性 (\(\text{Answer relevance}\)) 答案与问题的相关程度

\(\text{Ragas}\) 文档网站提(https://docs.ragas.io/en/stable/)供了关于实际计算这些指标的公式的更多细节。

总结

在本章中,我们研究了使用语言模型的不同方法改进现有搜索系统,甚至成为新的、更强大的搜索系统的核心。其中包括:

  • 稠密检索 (\(\text{Dense retrieval}\)),它依赖于文本嵌入的相似性。这些系统嵌入一个搜索查询,并检索嵌入与该查询嵌入最近的文档。
  • 重排序器 (\(\text{Rerankers}\)),这类系统(如 \(\text{monoBERT}\))查看一个查询和候选结果,并对每个文档与该查询的相关性进行评分。然后,这些相关性分数用于根据它们与查询的相关性对筛选后的结果进行排序,通常会产生改进的结果排名
  • \(\text{RAG}\),即搜索系统在管线的末端有一个生成式 \(\text{LLM}\),用于根据检索到的文档引用来源形成答案

我们还研究了评估搜索系统的可能方法之一平均精度均值\(\text{Mean average precision}\))允许我们对搜索系统进行评分,以便在查询及其已知相关性的测试套件中进行比较。然而,评估 \(\text{RAG}\) 系统需要多个轴,例如忠实度、流畅性以及其他可以由人工或 \(\text{LLM}\)-作为-裁判评估的指标。

在下一章中,我们将探讨如何使语言模型具备多模态能力,不仅能对文本进行推理,还能对图像进行推理。

书籍各章的机翻md文件:
《Hands-On Large Language Models》目录及前言
《Hands-On Large Language Models》第1章 大型语言模型简介
《Hands-On Large Language Models》第2章 词元与嵌入
《Hands-On Large Language Models》第3章 深入了解大型语言模型
《Hands-On Large Language Models》第4章 文本分类
《Hands-On Large Language Models》第5章 文本聚类和主题建模
《Hands-On Large Language Models》第6章 提示工程
《Hands-On Large Language Models》第7章 高级文本生成技术与工具
《Hands-On Large Language Models》第8章 语义搜索与检索增强生成
《Hands-On Large Language Models》第9章 多模态大型语言模型
《Hands-On Large Language Models》第10章 创建文本嵌入模型
《Hands-On Large Language Models》第11章 微调用于分类的表征模型
《Hands-On Large Language Models》第12章 生成模型的微调

\(\text{7}\) 高级文本生成技术与工具

Advanced Text Generation Techniques and Tools

在上一章中,我们看到了提示工程可以为您的文本生成大型语言模型 (\(\text{LLM}\)) 的准确性带来奇迹。只需进行一些微小的调整,这些 \(\text{LLM}\) 就能被引导更有目的更准确的答案。这表明,使用无需微调 \(\text{LLM}\) 而是更高效地利用 \(\text{LLM}\) 的技术(例如相对直接的提示工程),可以获得巨大的收益。

在本章中,我们将继续这一思路。在无需微调模型本身的情况下,我们还能做些什么来进一步增强我们从 \(\text{LLM}\) 获得的体验和输出?

幸运的是,有大量的方法和技术可以让我们进一步改进上一章中开始的工作。这些更高级的技术是众多以 \(\text{LLM}\) 为中心的系统的基础,并且可以说是用户在设计此类系统时首先实施的事物之一。

在本章中,我们将探索几种用于提高生成文本质量的方法和概念:

  • 模型 \(\text{I/O}\) (\(\text{Model I/O}\)):加载和使用 \(\text{LLM}\)
  • 记忆 (\(\text{Memory}\)):帮助 \(\text{LLM}\) 记住信息。
  • 智能体 (\(\text{Agents}\)):将复杂行为外部工具结合。
  • 链 (\(\text{Chains}\)):连接方法和模块

这些方法都已集成到 \(\text{LangChain}\) 框架中,该框架将帮助我们轻松地在本章中使用这些高级技术。\(\text{LangChain}\) 是较早出现的框架之一,它通过有用的抽象简化了与 \(\text{LLM}\) 的协作。值得注意的较新框架有 \(\text{DSPy}\)\(\text{Haystack}\)。其中一些抽象如图 \(\text{7}-1\) 所示。请注意,检索将在下一章中讨论。

F7.1

这些技术中的每一项单独来看都具有显著的优势,但它们的真正价值并非孤立存在。只有当您将所有这些技术结合起来时,才能获得具有惊人性能的基于 \(\text{LLM}\) 的系统。正是这些技术的融会贯通,才真正是 \(\text{LLM}\) 大放异彩之处。

模型 \(\text{I/O}\):使用 \(\text{LangChain}\) 加载量化模型

Advanced Text Generation Techniques and Tools

在我们利用 \(\text{LangChain}\) 的功能来扩展 \(\text{LLM}\) 的能力之前,我们需要从加载 \(\text{LLM}\) 开始。与前几章一样,我们将使用 \(\text{Phi}-3\),但略有不同:我们将使用 \(\text{GGUF}\) 模型变体\(\text{GGUF}\) 模型是通过一种称为量化\(\text{quantization}\))的方法,对其原始对应物进行的压缩版本,它减少了表示 \(\text{LLM}\) 参数所需的比特\(\text{bits}\))数量。

比特是一系列 \(\text{0}\)\(\text{1}\),通过以二进制形式编码来表示值。如图 \(\text{7}-2\) 所示,更多比特会导致更宽的取值范围,但存储这些值需要更多内存

F7.1

量化试图保持大部分原始信息的同时,减少了表示 \(\text{LLM}\) 参数所需的比特数量。这会带来一些精度上的损失,但通常可以弥补,因为模型运行速度更快需要的显存\(\text{VRAM}\)更少,而且准确性通常几乎与原始模型一样高

为了说明量化,请考虑这个类比:如果有人问您现在几点,您可能会说“\(\text{14:16}\)”,这是正确的,但不是一个完全精确的答案。您本可以说它是“\(\text{14:16}\) \(\text{12}\)”,这样会更准确。然而,提到秒数很少有帮助,我们通常只是将其以离散数字(即完整的分钟数)表示。量化是一个类似的过程,它在不去除关键信息(例如,保留小时和分钟)的情况下,降低了值的精度(例如,去除秒数)。

在第 \(\text{12}\) 章中,我们将进一步讨论量化在底层的工作原理。您也可以在 \(\text{Maarten Grootendorst}\) 撰写的“\(\text{A Visual Guide to Quantization}\)”(https://newsletter.maartengrootendorst.com/p/a-visual-guide-to-quantization)中看到完整的量化视觉指南。现在,重要的是要知道我们将使用 \(\text{Phi}-3\)\(\text{8}\) 比特变体,而不是原始的 \(\text{16}\) 比特变体,这使得内存需求减少了近一半

💡 经验法则是,至少寻找 \(\text{4}\) 比特量化的模型。这些模型在压缩和准确性之间取得了良好的平衡。虽然也可以使用 \(\text{3}\) 比特甚至 \(\text{2}\) 比特的量化模型,但性能下降会变得明显,届时最好选择精度更高更小模型

首先,我们需要下载模型。请注意,该链接包含多个具有不同比特变体的文件。我们选择的 \(\text{FP16}\) 代表 \(\text{16}\) 比特变体

1
2
!wget https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-gguf/resolve/main/
Phi-3-mini-4k-instruct-fp16.gguf

我们结合使用 \(\text{llama}-\text{cpp}-\text{python}\)\(\text{LangChain}\) 来加载 \(\text{GGUF}\) 文件:

1
2
3
4
5
6
7
8
9
10
from langchain import LlamaCpp
# Make sure the model path is correct for your system!
llm = LlamaCpp(
model_path="Phi-3-mini-4k-instruct-fp16.gguf",
n_gpu_layers=-1,
max_tokens=500,
n_ctx=2048,
seed=42,
verbose=False
)

\(\text{LangChain}\) 中,我们使用 \(\text{invoke}\) 函数来生成输出:

1
llm.invoke("Hi! My name is Maarten. What is 1 + 1?")
1
''

不幸的是,我们没有得到任何输出!正如我们在前几章中所见,\(\text{Phi}-3\) 需要一个特定的提示模板\(\text{prompt template}\))。与我们使用 \(\text{transformers}\) 的示例相比,我们需要显式地使用模板。与其在 \(\text{LangChain}\) 中每次使用 Phi-3 时都复制粘贴这个模板,不如使用 \(\text{LangChain}\) 的核心功能之一,即“”(\(\text{chains}\))。

💡 本章中的所有示例都可以用任何 \(\text{LLM}\) 运行。这意味着您在学习这些示例时,可以选择使用 Phi-3、\(\text{ChatGPT}\)\(\text{Llama 3}\) 或任何其他模型。我们将默认使用 Phi-3,但最新技术变化很快,因此可以考虑使用更新的模型。您可以使用 \(\text{Open LLM Leaderboard}\)(一个开源 \(\text{LLM}\) 的排名)(https://huggingface.co/spaces/open-llm-leaderboard/open_llm_leaderboard#/)来选择最适合您用例的模型。

如果您无法访问可本地运行 \(\text{LLM}\) 的设备,可以考虑使用 \(\text{ChatGPT}\)

1
2
3
from langchain.chat_models import ChatOpenAI
# Create a chat-based LLM
chat_model = ChatOpenAI(openai_api_key="MY_KEY")

链 : 扩展 \(\text{LLM}\) 的能力

Chains: Extending the Capabilities of LLMs

\(\text{LangChain}\) 的名称源于其主要方法之一——\(\text{chains}\))。虽然我们可以孤立地运行 \(\text{LLM}\),但它们的力量体现在与附加组件一起使用时,甚至在相互结合使用时。链不仅允许扩展 \(\text{LLM}\) 的能力,还允许将多个链连接在一起

\(\text{LangChain}\)链的最基本形式单个链。尽管一个链可以有多种形式,每种形式的复杂性不同,但它通常将一个 \(\text{LLM}\) 与一些额外的工具、提示或功能连接起来。如图 \(\text{7}-3\) 所示,这就是将一个组件连接到 \(\text{LLM}\) 的理念。

F7.1

在实践中,链可以很快变得复杂。我们可以随意扩展提示模板,甚至可以将几个独立的链组合在一起,创建复杂的系统。为了彻底理解链中正在发生的事情,让我们探索如何将 \(\text{Phi}-3\) 的提示模板添加到 \(\text{LLM}\)

链中的单个环节:提示模板

A Single Link in the Chain: Prompt Template

我们从创建我们的第一个链开始,即Phi-3 所期望的提示模板。在上一章中,我们探讨了 \(\text{transformers.pipeline}\) 如何自动应用聊天模板。对于其他软件包来说并非总是如此,它们可能需要明确定义提示模板。通过 \(\text{LangChain}\),我们将使用来创建和使用一个默认的提示模板。它也为使用提示模板提供了一个很好的实践经验

如图 \(\text{7}-4\) 所示,这个想法是我们将提示模板与 \(\text{LLM}\) 链接在一起,以获得我们想要的输出。这样,我们不需要每次使用 \(\text{LLM}\) 时都复制粘贴提示模板,而只需要定义用户和系统提示

F7.1

\(\text{Phi}-3\) 的模板主要由四个核心组成部分构成:

  • \(\text{<s>}\):表示提示的开始
  • \(\text{<|user|>}\):表示用户提示的开始
  • \(\text{<|assistant|>}\):表示模型输出的开始
  • \(\text{<|end|>}\):表示提示或模型输出的结束

\(\text{7}-5\) 进一步通过一个例子对此进行了说明。

F7.1

为了创建我们的简单链,我们首先需要创建一个符合 \(\text{Phi}-3\) 预期模板的提示模板。使用这个模板,模型接收一个 \(\text{system\_prompt}\)(系统提示,通常描述我们对 \(\text{LLM}\) 的期望)。然后,我们可以使用 \(\text{input\_prompt}\)(输入提示)来\(\text{LLM}\) 询问具体问题

1
2
3
4
5
6
7
8
9
from langchain import PromptTemplate
# Create a prompt template with the "input_prompt" variable
template = """<s><|user|>
{input_prompt}<|end|>
<|assistant|>"""
prompt = PromptTemplate(
template=template,
input_variables=["input_prompt"]
)

要创建我们的第一个链,我们可以同时使用我们创建的提示\(\text{LLM}\),并将它们链接在一起

1
basic_chain = prompt | llm

要使用这个链,我们需要使用 \(\text{invoke}\) 函数,并确保我们使用 \(\text{input\_prompt}\) 来插入我们的问题

1
2
3
4
5
6
# Use the chain
basic_chain.invoke(
{
}
"input_prompt": "Hi! My name is Maarten. What is 1 + 1?",
)
1
2
The answer to 1 + 1 is 2. It's a basic arithmetic operation where you add one
unit to another, resulting in two units altogether.

输出给我们提供了响应,没有任何不必要的词元。现在我们已经创建了这个链,我们不必每次使用 \(\text{LLM}\) 时都从头创建提示模板。请注意,我们没有像之前那样禁用采样,因此您的输出可能会有所不同。为了使这个管线更加透明,图 \(\text{7}-6\) 展示了使用单个链连接提示模板和 \(\text{LLM}\) 的过程。

F7.1

💡 这个示例假设 \(\text{LLM}\) 需要一个特定的模板。但并非总是如此。对于 \(\text{OpenAI}\)\(\text{GPT}-\text{3.5}\),其 \(\text{API}\) 会处理底层的模板。

您也可以使用提示模板来定义提示中可能变化的其他变量。例如,如果我们要为企业创建有趣的名称,为不同的产品一遍又一遍地重新输入相同的问题可能会耗费时间

相反,我们可以创建一个可重复使用的提示:

1
2
3
4
5
6
# Create a Chain that creates our business' name
template = "Create a funny name for a business that sells {product}."
name_prompt = PromptTemplate(
template=template,
input_variables=["product"]
)

向链中添加提示模板只是增强 \(\text{LLM}\) 能力所需的第一步。在本章中,我们将看到许多可以将额外的模块化组件添加到现有链中的方法,我们将从记忆\(\text{memory}\))开始。

包含多个提示的链

A Chain with Multiple Prompts

在我们上一个例子中,我们创建了一个由提示模板和 \(\text{LLM}\) 组成的单个链。由于我们的示例非常简单直接,\(\text{LLM}\) 处理该提示没有问题。然而,一些应用程序涉及更复杂的细节,需要冗长或复杂的提示才能生成捕获这些复杂细节的响应。

相反,我们可以将这个复杂的提示分解成更小的、可以顺序运行的子任务。如图 \(\text{7}-7\) 所示,这将需要多次调用 \(\text{LLM}\),但使用的是更小的提示和中间输出

F7.1

这种使用多个提示的过程是我们先前示例的延伸。我们不使用单个链,而是链接多个链,其中每个链接处理一个特定的子任务

例如,考虑生成一个故事的过程。我们可以要求 \(\text{LLM}\) 生成一个故事,并附带复杂细节,如标题、摘要、人物描述等。与其试图将所有这些信息放入单个提示中,不如将这个提示分解可管理的小任务

让我们用一个例子来说明。假设我们要生成一个包含三个组件的故事:

  • 一个标题
  • 一个主角的描述
  • 一个故事的摘要

我们不一次性生成所有内容,而是创建一个只需要用户输入一次,然后顺序生成这三个组件的链。这个过程如图 \(\text{7}-8\) 所示。

F7.1

为了生成这个故事,我们使用 \(\text{LangChain}\) 来描述第一个组件,即标题。这个第一个链接唯一需要用户输入的组件。我们定义模板,并使用 \(\text{"summary"}\) 变量作为输入变量,使用 \(\text{"title"}\) 作为输出

我们要求 \(\text{LLM}\)\(\text{Create a title for a story about \{summary\}}\)”(为一个关于 \(\text{\{summary\}}\) 的故事创建一个标题),其中 \(\text{\{summary\}}\) 将是我们的输入:

1
2
3
4
5
6
7
from langchain import LLMChain
# Create a chain for the title of our story
template = """<s><|user|>
Create a title for a story about {summary}. Only return the title.<|end|>
<|assistant|>"""
title_prompt = PromptTemplate(template=template, input_variables=["summary"])
title = LLMChain(llm=llm, prompt=title_prompt, output_key="title")

让我们运行一个例子来展示这些变量:

1
title.invoke({"summary": "a girl that lost her mother"})
1
2
{'summary': 'a girl that lost her mother',
'title': ' "Whispers of Loss: A Journey Through Grief"'}

这已经为我们的故事提供了一个很棒的标题!请注意,我们可以看到输入(“\(\text{summary}\)”)和输出(“\(\text{title}\)”)。

接下来,我们生成下一个组件,即人物描述。我们使用摘要先前生成的标题来生成此组件。为了确保链使用这些组件,我们创建了一个带有 \(\text{\{summary\}}\)\(\text{\{title\}}\) 标签的新提示:

1
2
3
4
5
6
7
8
9
# Create a chain for the character description using the summary and title
template = """<s><|user|>
Describe the main character of a story about {summary} with the title {title}.
Use only two sentences.<|end|>
<|assistant|>"""
character_prompt = PromptTemplate(
template=template, input_variables=["summary", "title"]
)
character = LLMChain(llm=llm, prompt=character_prompt, output_key="character")

尽管我们现在可以手动使用 \(\text{character}\) 变量来生成人物描述,但在自动化链中,它将被用作其中一部分

让我们创建最后一个组件,它使用摘要\(\text{summary}\))、标题\(\text{title}\))和人物描述\(\text{character}\))来生成故事的简短描述

1
2
3
4
5
6
7
8
9
10
11
# Create a chain for the story using the summary, title, and character descrip
tion
template = """<s><|user|>
Create a story about {summary} with the title {title}. The main character is:
{character}. Only return the story and it cannot be longer than one paragraph.
<|end|>
<|assistant|>"""
story_prompt = PromptTemplate(
template=template, input_variables=["summary", "title", "character"]
)
story = LLMChain(llm=llm, prompt=story_prompt, output_key="story")

现在我们已经生成了所有三个组件,我们可以将它们链接在一起以创建我们的完整链

1
2
# Combine all three components to create the full chain
llm_chain = title | character | story

我们可以使用我们之前用过的相同示例来运行这个新创建的链:

1
llm_chain.invoke("a girl that lost her mother")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{'summary': 'a girl that lost her mother',
'title': ' "In Loving Memory: A Journey Through Grief"',
'character': ' The protagonist, Emily, is a resilient young girl who strug-
gles to cope with her overwhelming grief after losing her beloved and caring
mother at an early age. As she embarks on a journey of self-discovery and
healing, she learns valuable life lessons from the memories and wisdom shared
by those around her.',
'story': " In Loving Memory: A Journey Through Grief revolves around Emily, a
resilient young girl who loses her beloved mother at an early age. Struggling
to cope with overwhelming grief, she embarks on a journey of self-discovery
and healing, drawing strength from the cherished memories and wisdom shared
by those around her. Through this transformative process, Emily learns valua-
ble life lessons about resilience, love, and the power of human connection,
ultimately finding solace in honoring her mother's legacy while embracing a
newfound sense of inner peace amidst the painful loss."}

运行这个链会给出我们所有三个组件。这只要求我们输入一个简短的提示——摘要。将问题分解成更小的任务的另一个优势是,我们现在可以访问这些单独的组件。我们可以轻松地提取标题;如果使用单个提示,这可能就无法实现了。

记忆 (\(\text{Memory}\)): 帮助 \(\text{LLM}\) 记住对话

Memory: Helping LLMs to Remember Conversations

当我们直接使用 \(\text{LLM}\) 时,它们不会记住对话中说过的内容。您可以在一个提示中告诉它您的名字,但到下一个提示时,它就会忘记。

让我们用我们之前创建的 \(\text{basic\_chain}\) 来举例说明这一现象。首先,我们告诉 \(\text{LLM}\) 我们的名字:

1
2
# Let's give the LLM our name
basic_chain.invoke({"input_prompt": "Hi! My name is Maarten. What is 1 + 1?"})
1
Hello Maarten! The answer to 1 + 1 is 2.

接下来,我们要求它重复我们给它的名字

1
2
# Next, we ask the LLM to reproduce the name
basic_chain.invoke({"input_prompt": "What is my name?"})
1
2
3
4
I'm sorry, but as a language model, I don't have the ability to know personal
information about individuals. You can provide the name you'd like to know
more about, and I can help you with information or general inquiries related
to it.

不幸的是,\(\text{LLM}\) 不知道我们给它的名字。这种健忘行为的原因是这些模型是无状态的\(\text{stateless}\))——它们没有关于任何先前对话的记忆

如图 \(\text{7}-9\) 所示,与一个没有任何记忆的 \(\text{LLM}\) 对话体验并不理想。

F7.1

为了使这些模型有状态\(\text{stateful}\)),我们可以向我们之前创建的链中添加特定类型的记忆。在本节中,我们将介绍两种帮助 \(\text{LLM}\) 记住对话的常用方法

  • 对话缓冲器 (\(\text{Conversation buffer}\))
  • 对话摘要 (\(\text{Conversation summary}\))

对话缓冲器

Conversation Buffer

赋予 \(\text{LLM}\) 记忆的最直观形式之一就是简单地提醒它们过去发生的一切。如图 \(\text{7}-10\) 所示,我们可以通过复制完整的对话历史并将其粘贴到我们的提示中来实现这一点。

F7.1

\(\text{LangChain}\) 中,这种形式的记忆被称为 \(\text{ConversationBufferMemory}\)。它的实现要求我们更新先前的提示包含聊天的历史记录

我们首先创建这个提示:

1
2
3
4
5
6
7
8
# Create an updated prompt template to include a chat history
template = """<s><|user|>Current conversation:{chat_history}
{input_prompt}<|end|>
<|assistant|>"""
prompt = PromptTemplate(
template=template,
input_variables=["input_prompt", "chat_history"]
)

请注意,我们添加了一个额外的输入变量,即 \(\text{chat\_history}\)。这是在向 \(\text{LLM}\) 提问之前提供对话历史记录的地方。

接下来,我们可以创建 \(\text{LangChain}\)\(\text{ConversationBufferMemory}\)(对话缓冲器记忆),并将其分配给 \(\text{chat\_history}\) 输入变量\(\text{ConversationBufferMemory}\) 将存储我们迄今为止与 \(\text{LLM}\) 进行过的所有对话

我们将所有内容放在一起,将 \(\text{LLM}\)、记忆和提示模板链接起来

1
2
3
4
5
6
7
8
9
from langchain.memory import ConversationBufferMemory
# Define the type of memory we will use
memory = ConversationBufferMemory(memory_key="chat_history")
# Chain the LLM, prompt, and memory together
llm_chain = LLMChain(
prompt=prompt,
llm=llm,
memory=memory
)

为了检验我们是否正确执行了,让我们通过问一个简单的问题来创建与 \(\text{LLM}\) 的对话历史

1
2
# Generate a conversation and ask a basic question
llm_chain.invoke({"input_prompt": "Hi! My name is Maarten. What is 1 + 1?"})
1
2
3
4
{'input_prompt': 'Hi! My name is Maarten. What is 1 + 1?',
'chat_history': ',
'text': " Hello Maarten! The answer to 1 + 1 is 2. Hope you're having a great
day!"}

您可以在 \(\text{'text'}\)中找到生成的文本,在 \(\text{'input\_prompt'}\) 中找到输入提示,在 \(\text{'chat\_history'}\) 中找到聊天历史记录。请注意,由于这是我们第一次使用这个特定的链,所以没有聊天历史记录

接下来,我们通过询问 \(\text{LLM}\) 是否记得我们使用的名字来跟进

1
2
# Does the LLM remember the name we gave it?
llm_chain.invoke({"input_prompt": "What is my name?"})
1
2
3
4
{'input_prompt': 'What is my name?',
'chat_history': "Human: Hi! My name is Maarten. What is 1 + 1?\nAI: Hello
Maarten! The answer to 1 + 1 is 2. Hope you're having a great day!",
'text': ' Your name is Maarten.'}

通过用记忆扩展链\(\text{LLM}\) 能够使用聊天历史记录来找到我们先前给它的名字。如图 \(\text{7}-11\) 所示,这个更复杂的链概述了这项额外的功能

F7.1

窗口式对话缓冲器

Windowed Conversation Buffer

在我们上一个例子中,我们本质上创建了一个聊天机器人。您可以与它交谈,它会记住您迄今为止的对话。然而,随着对话规模的增长输入提示的大小也会随之增长,直到超出词元限制

最小化上下文窗口的一种方法是使用最近的 \(k\) 次对话,而不是维护完整的聊天历史记录。在 \(\text{LangChain}\) 中,我们可以使用 \(\text{ConversationBufferWindowMemory}\) 来决定将多少次对话传递给输入提示:

1
2
3
4
5
6
7
8
9
from langchain.memory import ConversationBufferWindowMemory
# Retain only the last 2 conversations in memory
memory = ConversationBufferWindowMemory(k=2, memory_key="chat_history")
# Chain the LLM, prompt, and memory together
llm_chain = LLMChain(
prompt=prompt,
llm=llm,
memory=memory
)

使用这种记忆类型,我们可以尝试一系列问题来演示哪些内容会被记住。我们从两次对话开始:

1
2
3
4
5
# Ask two questions and generate two conversations in its memory
llm_chain.predict(input_prompt="Hi! My name is Maarten and I am 33 years old.

What is 1 + 1?")
llm_chain.predict(input_prompt="What is 3 + 3?")
1
2
3
4
5
6
7
8
9
{'input_prompt': 'What is 3 + 3?',
'chat_history': "Human: Hi! My name is Maarten and I am 33 years old. What is
1 + 1?\nAI: Hello Maarten! It's nice to meet you. Regarding your question, 1 +
1 equals 2. If you have any other questions or need further assistance, feel
free to ask!\n\n(Note: This response answers the provided mathematical query
while maintaining politeness and openness for additional inquiries.)",
'text': " Hello Maarten! It's nice to meet you as well. Regarding your new
question, 3 + 3 equals 6. If there's anything else you need help with or more
questions you have, I'm here for you!"}

我们迄今为止的互动显示在 \(\text{"chat\_history"}\) 中。请注意,在底层,\(\text{LangChain}\) 将其保存为(用 \(\text{Human}\) 表示)和 \(\text{LLM}\)(用 \(\text{AI}\) 表示)之间的互动。

接下来,我们可以检查模型是否确实知道我们给它的名字

1
2
# Check whether it knows the name we gave it
llm_chain.invoke({"input_prompt":"What is my name?"})
1
2
3
4
5
6
7
8
9
10
11
{'input_prompt': 'What is my name?',
'chat_history': "Human: Hi! My name is Maarten and I am 33 years old. What is
1 + 1?\nAI: Hello Maarten! It's nice to meet you. Regarding your question, 1 +
1 equals 2. If you have any other questions or need further assistance, feel
free to ask!\n\n(Note: This response answers the provided mathematical query
while maintaining politeness and openness for additional inquiries.)\nHuman:
What is 3 + 3?\nAI: Hello Maarten! It's nice to meet you as well. Regarding
your new question, 3 + 3 equals 6. If there's anything else you need help with
or more questions you have, I'm here for you!",
'text': ' Your name is Maarten, as mentioned at the beginning of our conversa-
tion. Is there anything else you would like to know or discuss?'}

根据 \(\text{'text'}\) 中的输出,它正确地记住了我们给它的名字。请注意,聊天历史记录已用上一个问题进行了更新

现在我们又增加了一次对话,总共有三次对话。考虑到记忆只保留最近的两次对话\(\text{k}=2\)),我们最早的第一个问题没有被记住

由于我们在第一次互动中提供了年龄,我们检查 \(\text{LLM}\) 是否确实不再知道年龄了:

1
2
# Check whether it knows the age we gave it
llm_chain.invoke({"input_prompt":"What is my age?"})
1
2
3
4
5
6
7
8
{'input_prompt': 'What is my age?',
'chat_history': "Human: What is 3 + 3?\nAI: Hello again! 3 + 3 equals 6. If
there's anything else I can help you with, just let me know!\nHuman: What is
my name?\nAI: Your name is Maarten.",
'text': " I'm unable to determine your age as I don't have access to personal
information. Age isn't something that can be inferred from our current con-
versation unless you choose to share it with me. How else may I assist you
today?"}

\(\text{LLM}\) 确实无法获取我们的年龄,因为它没有被保留在聊天历史记录中

尽管这种方法减少了聊天历史记录的大小,但它只能保留最近的几次对话,这对于冗长的对话来说并不理想。让我们探讨一下如何对聊天历史记录进行摘要

对话摘要

Conversation Summar

正如我们之前讨论的,赋予您的 \(\text{LLM}\) 记住对话的能力对于良好的互动体验至关重要。然而,当使用 \(\text{ConversationBufferMemory}\) 时,对话的大小开始增加,并会逐渐接近您的词元限制。尽管 \(\text{ConversationBufferWindowMemory}\) 在一定程度上解决了词元限制的问题,但它只保留了最近的 \(k\) 次对话

虽然一个解决方案是使用具有更大上下文窗口的 \(\text{LLM}\),但这些词元在生成新词元之前仍然需要被处理,这会增加计算时间。因此,让我们转向一种更复杂的技巧:\(\text{ConversationSummaryMemory}\)(对话摘要记忆)。顾名思义,这种技术会总结整个对话历史,将其提炼成要点

这个摘要过程是通过另一个 \(\text{LLM}\) 启用的,这个 \(\text{LLM}\) 接收对话历史作为输入,并被要求创建一个简洁的摘要。使用外部 \(\text{LLM}\) 的一个很好的优势是,我们不局限于在对话中使用同一个 \(\text{LLM}\)。如图 \(\text{7}-12\) 所示,这就是摘要过程的工作方式。

F7.1

这意味着每当我们向 \(\text{LLM}\) 提问时,会发生两次调用

  • 用户提示\(\text{The user prompt}\)
  • 摘要提示\(\text{The summarization prompt}\)

要在 \(\text{LangChain}\) 中使用此功能,我们首先需要准备一个将用作摘要提示的摘要模板

1
2
3
4
5
6
7
8
9
10
11
12
13
# Create a summary prompt template
summary_prompt_template = """<s><|user|>Summarize the conversations and update
with the new lines.
Current summary:
{summary}
new lines of conversation:
{new_lines}
New summary:<|end|>
<|assistant|>"""
summary_prompt = PromptTemplate(
input_variables=["new_lines", "summary"],
template=summary_prompt_template
)

\(\text{LangChain}\) 中使用 \(\text{ConversationSummaryMemory}\) 与我们之前的示例类似。主要区别在于,我们额外需要为其提供一个执行摘要任务的 \(\text{LLM}\)。尽管我们使用相同的 \(\text{LLM}\) 进行摘要和用户提示,但您可以使用一个更小的 \(\text{LLM}\) 来执行摘要任务,以加快计算速度

1
2
3
4
5
6
7
8
9
10
11
12
13
from langchain.memory import ConversationSummaryMemory
# Define the type of memory we will use
memory = ConversationSummaryMemory(
llm=llm,
memory_key="chat_history",
prompt=summary_prompt
)
# Chain the LLM, prompt, and memory together
llm_chain = LLMChain(
prompt=prompt,
llm=llm,
memory=memory
)

创建好我们的链之后,我们可以通过创建一个简短的对话来测试它的摘要能力

1
2
3
# Generate a conversation and ask for the name
llm_chain.invoke({"input_prompt": "Hi! My name is Maarten. What is 1 + 1?"})
llm_chain.invoke({"input_prompt": "What is my name?"})
1
2
3
4
5
6
7
8
{'input_prompt': 'What is my name?',
'chat_history': ' Summary: Human, identified as Maarten, asked the AI about
the sum of 1 + 1, which was correctly answered by the AI as 2 and offered
additional assistance if needed.',
'text': ' Your name in this context was referred to as "Maarten". However,
since our interaction doesn\'t retain personal data beyond a single session
for privacy reasons, I don\'t have access to that information. How can I
assist you further today?'}

在每一步之后,该链都会总结截至该点的对话。请注意,第一次对话是如何通过创建对话描述被摘要到 \(\text{'chat\_history'}\) 中的。

我们可以继续对话,每一步,对话都将被摘要,并根据需要添加新信息

1
2
# Check whether it has summarized everything thus far
llm_chain.invoke({"input_prompt": "What was the first question I asked?"})
1
2
3
4
5
6
7
{'input_prompt': 'What was the first question I asked?',
'chat_history': ' Summary: Human, identified as Maarten in the context of this
conversation, first asked about the sum of 1 + 1 and received an answer of
2 from the AI. Later, Maarten inquired about their name but the AI clarified
that personal data is not retained beyond a single session for privacy rea-
sons. The AI offered further assistance if needed.',
'text': ' The first question you asked was "what\'s 1 + 1?"'}

在问了另一个问题后,\(\text{LLM}\) 更新了摘要以包含之前的对话,并正确地推断出最初的问题

要获取最新的摘要,我们可以访问我们之前创建的 \(\text{memory}\) 变量

1
2
# Check what the summary is thus far
memory.load_memory_variables({})
1
2
3
4
5
6
{'chat_history': ' Maarten, identified in this conversation, initially asked
about the sum of 1+1 which resulted in an answer from the AI being 2. Subse-
quently, he sought clarification on his name but the AI informed him that no
personal data is retained beyond a single session due to privacy reasons. The
AI then offered further assistance if required. Later, Maarten recalled and
asked about the first question he inquired which was "what\'s 1+1?"'}

如图 \(\text{7}-13\) 所示,这个更复杂的链概述了这项额外的功能

F7.1

这种摘要方法有助于保持聊天历史记录相对较小,同时在推理过程中不会使用过多的词元。然而,由于原始问题没有明确地保存在聊天历史记录中,模型需要根据上下文进行推断。如果需要存储特定信息,这是一个缺点。此外,还需要多次调用同一个 \(\text{LLM}\),一次用于提示,一次用于摘要。这会降低计算时间

通常,这是一个速度、内存和准确性之间的权衡\(\text{ConversationBufferMemory}\) 速度快但占用词元,而 \(\text{ConversationSummaryMemory}\) 速度慢但释放了可用的词元。我们迄今为止探索的这些记忆类型的其他优缺点如表 \(\text{7}-1\) 所示。

F7.1

智能体 (\(\text{Agents}\)): 创建 \(\text{LLM}\) 系统

Agents: Creating a System of LLMs

到目前为止,我们创建的系统都遵循用户定义的一系列步骤\(\text{LLM}\)最有前景的概念之一是它们决定自己可以采取哪些行动的能力。这个想法通常被称为智能体\(\text{agents}\)),即利用语言模型来决定应该采取哪些行动以及以何种顺序采取的系统

智能体可以利用我们迄今为止看到的一切,例如模型 \(\text{I/O}\)、链和记忆,并用两个关键组件进一步扩展:

  • 工具 (\(\text{Tools}\)):智能体可以用来做自身无法完成的事情
  • 智能体类型 (\(\text{The agent type}\))规划要采取的行动或使用的工具

与我们迄今为止看到的链不同,智能体能够展示出更高级的行为,例如创建和自我纠正实现目标的路线图。它们可以通过使用工具来与真实世界互动。因此,这些智能体可以执行超出 \(\text{LLM}\) 孤立能力范围的各种任务

例如,\(\text{LLM}\)数学问题上是出了名的,经常无法解决简单的基于数学的任务,但如果我们提供计算器的访问权限,它们就能做更多事情。如图 \(\text{7}-14\) 所示,智能体的基本思想是,它们利用 \(\text{LLM}\) 不仅是为了理解我们的查询,还为了决定何时以及使用哪个工具

F7.1

在这个例子中,我们期望 \(\text{LLM}\) 在面临数学任务时使用计算器。现在想象一下,我们将此扩展到数十个其他工具,比如搜索引擎天气 \(\text{API}\)。突然之间,\(\text{LLM}\) 的能力显着增加

换句话说,利用 \(\text{LLM}\) 的智能体可以成为强大的通用问题解决器。尽管它们使用的工具很重要,但许多基于智能体的系统的驱动力是使用一种称为推理(Reasoning)和行动 (\(\text{ReAct}\)) 的框架

智能体背后的驱动力:循序渐进的推理

The Driving Power Behind Agents: Step-by-step Reasoning

\(\text{ReAct}\) 是一个强大的框架,它结合了行为中的两个重要概念:推理 (\(\text{reasoning}\)) 和行动 (\(\text{acting}\))。正如我们在第 \(\text{5}\) 章中详细探讨的那样,\(\text{LLM}\) 在推理方面异常强大

行动则有所不同。\(\text{LLM}\) 无法像您我一样行动。要赋予它们行动的能力,我们可以告诉 \(\text{LLM}\) 它可以使用某些工具,例如天气预报 \(\text{API}\)。然而,由于 \(\text{LLM}\) 只能生成文本,因此需要指示它们使用特定的查询触发这个预报 \(\text{API}\)

\(\text{ReAct}\) 框架将这两个概念融合在一起,允许推理影响行动行动反过来影响推理。在实践中,该框架包括迭代地遵循这三个步骤:

  • 思维 (\(\text{Thought}\))
  • 行动 (\(\text{Action}\))
  • 观察 (\(\text{Observation}\))

如图 \(\text{7}-15\) 所示,系统要求 \(\text{LLM}\) 对输入提示产生一个“思维”。这类似于询问 \(\text{LLM}\) 认为它接下来应该做什么以及为什么。然后,根据这个“思维”,触发一个“行动”。该行动通常是一个外部工具,如计算器或搜索引擎。最后,在“行动”的结果返回给 \(\text{LLM}\) 之后,它“观察”输出,这通常是它检索到的任何结果的摘要

F7.1

为了用一个例子来说明,想象您在美国度假并想购买一台 \(\text{MacBook Pro}\)。您不仅想知道价格,还需要将其兑换成欧元\(\text{EUR}\)),因为您住在欧洲,对这些价格更习惯。

如图 \(\text{7}-16\) 所示,智能体将首先搜索网页以获取当前价格。根据搜索引擎的不同,它可能会找到一个或多个价格。在检索到价格后,它将使用计算器美元 (\(\text{USD}\)) 转换为欧元 (\(\text{EUR}\)),前提是我们知道汇率。

F7.1

在这个过程中,智能体描述了它的思维(它应该做什么)、它的行动(它将做什么)和它的观察(行动的结果)。这是一个思维、行动和观察的循环,最终产生智能体的输出。

\(\text{LangChain}\) 中的 \(\text{ReAct}\)

ReAct in LangChain

为了说明智能体\(\text{LangChain}\) 中如何工作,我们将构建一个能够搜索网页以获取答案并使用计算器执行计算的管线。这些自主过程通常需要一个足够强大以正确遵循复杂指令的 \(\text{LLM}\)

我们迄今为止使用的 \(\text{LLM}\) 相对较小不足以运行这些示例。因此,我们将使用 \(\text{OpenAI}\)\(\text{GPT}-\text{3.5}\) 模型,因为它能更准确地遵循这些复杂指令:

1
2
3
4
5
import os
from langchain_openai import ChatOpenAI
# Load OpenAI's LLMs with LangChain
os.environ["OPENAI_API_KEY"] = "MY_KEY"
openai_llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)

💡 尽管我们在本章中一直使用的 \(\text{LLM}\) 不足以运行此示例,但这并不意味着只有 \(\text{OpenAI}\)\(\text{LLM}\) 才足够。存在更大、更有用的 \(\text{LLM}\),但它们需要显著更多的计算资源和显存\(\text{VRAM}\))。例如,本地 \(\text{LLM}\) 通常有不同的大小,在同一模型家族中,增加模型大小会带来更好的性能。为了将所需的计算量保持在最低限度,我们在本章的示例中选择了较小的 \(\text{LLM}\)

然而,随着生成模型领域的发展,这些较小的 \(\text{LLM}\) 也在发展。如果最终较小的 \(\text{LLM}\)(例如本章中使用的模型)能够胜任运行此示例,我们一点也不会感到惊讶。

完成上述步骤后,我们将定义智能体的模板。正如我们之前展示的,它描述了智能体需要遵循的 \(\text{ReAct}\) 步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Create the ReAct template
react_template = """Answer the following questions as best you can. You have
access to the following tools:
{tools}
Use the following format:
Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [{tool_names}]
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question
Begin!
Question: {input}
Thought:{agent_scratchpad}"""
prompt = PromptTemplate(
template=react_template,
input_variables=["tools", "tool_names", "input", "agent_scratchpad"]
)

这个模板阐明了从提出问题开始,并生成中间的思维、行动和观察的整个过程。

为了让 \(\text{LLM}\)外部世界互动,我们将描述它可以使用的工具

1
2
3
4
5
6
7
8
9
10
11
12
13
from langchain.agents import load_tools, Tool
from langchain.tools import DuckDuckGoSearchResults
# You can create the tool to pass to an agent
search = DuckDuckGoSearchResults()
search_tool = Tool(
name="duckduck",
description="A web search engine. Use this to as a search engine for gen
eral queries.",
func=search.run,
)
# Prepare tools
tools = load_tools(["llm-math"], llm=openai_llm)
tools.append(search_tool)

这些工具包括 \(\text{DuckDuckGo}\) 搜索引擎和一个允许它访问基本计算器数学工具\(\text{llm-math}\))。

最后,我们创建 \(\text{ReAct}\) 智能体并将其传递给 \(\text{AgentExecutor}\),后者负责执行这些步骤

1
2
3
4
5
6
from langchain.agents import AgentExecutor, create_react_agent
# Construct the ReAct agent
agent = create_react_agent(openai_llm, tools, prompt)
agent_executor = AgentExecutor(
agent=agent, tools=tools, verbose=True, handle_parsing_errors=True
)

为了测试智能体是否工作,我们使用之前的例子,即查找 \(\text{MacBook Pro}\) 的价格

1
2
3
4
5
6
7
# What is the price of a MacBook Pro?
agent_executor.invoke(
{
"input": "What is the current price of a MacBook Pro in USD? How much
would it cost in EUR if the exchange rate is 0.85 EUR for 1 USD."
}
)

在执行过程中,模型会生成多个中间步骤,类似于图 \(\text{7}-17\) 中所示的步骤。

F7.1

这些中间步骤说明了模型如何处理 \(\text{ReAct}\) 模板以及它访问了哪些工具。这使我们能够调试问题并探索智能体是否正确地使用了工具

完成后,模型会给出类似以下的输出:

1
2
3
4
{'input': 'What is the current price of a MacBook Pro in USD? How much would
it cost in EUR if the exchange rate is 0.85 EUR for 1 USD?',
'output': 'The current price of a MacBook Pro in USD is $2,249.00. It would
cost approximately 1911.65 EUR with an exchange rate of 0.85 EUR for 1 USD.'}

考虑到智能体拥有的工具有限,这非常令人印象深刻!仅使用搜索引擎和计算器,智能体就能给我们一个答案。

然而,应该考虑这个答案是否真的正确。通过创建这种相对自主的行为,我们没有参与到中间步骤中。因此,没有人参与来判断输出的质量或推理过程

这把双刃剑要求仔细设计系统以提高其可靠性。例如,我们可以让智能体返回它找到 \(\text{MacBook Pro}\) 价格的网站 \(\text{URL}\),或者在每一步询问输出是否正确

总结

在本章中,我们探索了通过添加模块化组件扩展 \(\text{LLM}\) 能力的几种方法。我们首先创建了一个简单但可重用,它将 \(\text{LLM}\)提示模板连接起来。然后,我们通过向链中添加记忆来扩展这个概念,这使得 \(\text{LLM}\) 能够记住对话。我们探讨了三种不同的添加记忆的方法,并讨论了它们的优点和缺点

接着,我们深入研究了智能体\(\text{agents}\))的世界,它们利用 \(\text{LLM}\) 来决定自己的行动和做出决策。我们探讨了 \(\text{ReAct}\) 框架,它使用一个直观的提示框架,允许智能体对自己的思维进行推理、采取行动并观察结果。这使我们能够构建一个能够自由使用其可用工具(例如搜索网页和使用计算器)的智能体,展示了智能体的潜在力量

有了这个基础,我们现在可以探索如何利用 \(\text{LLM}\)改进现有的搜索系统,甚至成为新的、更强大的搜索系统的核心,正如下一章将讨论的那样。

书籍各章的机翻md文件:
《Hands-On Large Language Models》目录及前言
《Hands-On Large Language Models》第1章 大型语言模型简介
《Hands-On Large Language Models》第2章 词元与嵌入
《Hands-On Large Language Models》第3章 深入了解大型语言模型
《Hands-On Large Language Models》第4章 文本分类
《Hands-On Large Language Models》第5章 文本聚类和主题建模
《Hands-On Large Language Models》第6章 提示工程
《Hands-On Large Language Models》第7章 高级文本生成技术与工具
《Hands-On Large Language Models》第8章 语义搜索与检索增强生成
《Hands-On Large Language Models》第9章 多模态大型语言模型
《Hands-On Large Language Models》第10章 创建文本嵌入模型
《Hands-On Large Language Models》第11章 微调用于分类的表征模型
《Hands-On Large Language Models》第12章 生成模型的微调

\(\text{6}\) 提示工程

Prompt Engineering

在本书的前几章中,我们迈出了大型语言模型\(\text{LLM}\))世界的第一步。我们深入研究了各种应用,例如监督式和无监督式分类,使用了专注于表征文本的模型,例如 \(\text{BERT}\) 及其衍生模型。

随着我们的进展,我们使用了主要为文本生成而训练的模型,这些模型通常被称为生成式预训练 \(\text{Transformer}\) (\(\text{GPT}\))。这些模型具有根据用户的提示生成文本的卓越能力。通过提示工程\(\text{prompt engineering}\)),我们可以以增强生成文本质量的方式来设计这些提示。

在本章中,我们将更详细地探索这些生成模型,并深入研究提示工程使用生成模型进行推理验证,甚至评估其输出的领域。

使用文本生成模型

Using Text Generation Models

在我们开始提示工程的基础知识之前,了解利用文本生成模型的基本原理至关重要。我们如何选择要使用的模型?我们是使用专有模型还是开源模型?我们如何控制生成的输出?这些问题将成为我们使用文本生成模型的垫脚石

选择文本生成模型

Choosing a Text Generation Model

选择文本生成模型始于专有模型开源模型之间的选择。虽然专有模型通常性能更高,但本书更侧重于开源模型,因为它们提供了更大的灵活性并且免费使用

\(\text{6}-1\) 展示了具有影响力的基础模型\(\text{foundation models}\))的一小部分,这些 \(\text{LLM}\) 已在海量文本数据上进行了预训练,并且通常针对特定应用进行微调

F6.1

从这些基础模型中,衍生出了数百甚至数千个经过微调的模型,每个模型都比另一个更适合某些任务。选择要使用的模型可能是一项艰巨的任务

我们建议从一个小的基础模型开始。因此,让我们继续使用参数量为 \(\text{3.8}\) 亿的 \(\text{Phi}-\text{3}-\text{mini}\)。这使得它适用于 \(\text{8 GB VRAM}\) 及以下的设备上运行。总的来说,扩展到更大的模型往往比缩小模型提供更好的体验。较小的模型提供了一个很好的介绍,并为过渡到更大的模型奠定了坚实的基础

加载文本生成模型

Loading a Text Generation Model

正如我们在前几章中所做的那样,加载模型最直接的方法是利用 \(\text{transformers}\)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
# Load model and tokenizer
model = AutoModelForCausalLM.from_pretrained(
"microsoft/Phi-3-mini-4k-instruct",
device_map="cuda",
torch_dtype="auto",
trust_remote_code=True,
)
tokenizer = AutoTokenizer.from_pretrained("microsoft/Phi-3-mini-4k-instruct")
# Create a pipeline
pipe = pipeline(
"text-generation",
model=model,
tokenizer=tokenizer,
return_full_text=False,
max_new_tokens=500,
do_sample=False,
)

与前几章相比,我们将更仔细地研究开发和使用提示模板

为了说明,让我们重温第 \(\text{1}\) 章中的示例,我们在其中要求 \(\text{LLM}\) 编一个关于鸡的笑话:

1
2
3
4
5
6
7
# Prompt
messages = [
{"role": "user", "content": "Create a funny joke about chickens."}
]
# Generate the output
output = pipe(messages)
print(output[0]["generated_text"])
1
2
Why don't chickens like to go to the gym? Because they can't crack the egg-
sistence of it!

在底层,\(\text{transformers.pipeline}\) 首先将我们的 \(\text{messages}\) 转换为特定的提示模板。我们可以通过访问底层的分词器来探索这个过程:

1
2
3
# Apply prompt template
prompt = pipe.tokenizer.apply_chat_template(messages, tokenize=False)
print(prompt)
1
2
3
<s><|user|>
Create a funny joke about chickens.<|end|>
<|assistant|>

您可能从\(\text{2}\)中认出了 <|user|><|assistant|> 等特殊词元。如图 \(\text{6}-2\) 所示,这个提示模板是在模型训练期间使用的。它不仅提供了谁说了什么的信息,还用于指示模型何时应停止生成文本(参见 <|end|> 词元)。这个提示被直接传递给 \(\text{LLM}\) 并一次性处理

F6.1

在下一章中,我们将自定义这个模板的某些部分。在本章中,我们可以使用 \(\text{transformers.pipeline}\) 来为我们处理聊天模板处理。接下来,让我们探索如何控制模型的输出

控制模型输出

Controlling Model Output

除了提示工程,我们还可以通过调整模型参数来控制我们想要的输出类型。在前面的例子中,您可能已经注意到我们在 \(\text{pipe}\) 函数中使用了几个参数,包括 \(\text{temperature}\)(温度)和 \(\text{top\_p}\)

这些参数控制着输出的随机性。使 \(\text{LLM}\) 成为一项令人兴奋的技术的部分原因在于它能对完全相同的提示生成不同的响应。每次 \(\text{LLM}\) 需要生成一个词元时,它都会为每个可能的词元分配一个似然值\(\text{likelihood number}\))。

如图 \(\text{6}-3\) 所示,在句子“\(\text{I am driving a...}\)”中,紧随其后的词元如 “\(\text{car}\)”(汽车)或 “\(\text{truck}\)”(卡车)的似然值通常高于像 “\(\text{elephant}\)”(大象)这样的词元。然而,生成 “\(\text{elephant}\)”的可能性仍然存在,只是低得多

F6.1

当我们加载模型时,我们故意将 \(\text{do\_sample}\) 设置为 \(\text{False}\),以确保输出保持一定的一致性。这意味着不会进行采样,并且只选择最可能的下一个词元。然而,为了使用 \(\text{temperature}\)\(\text{top\_p}\) 参数,我们将设置 \(\text{do\_sample}=\text{True}\),以便利用它们。

温度 (\(\text{Temperature}\))

温度控制着生成的文本的随机性或创造性。它定义了选择不太可能的词元的可能性。其基本思想是,温度为 \(\text{0}\)每次都会生成相同的响应,因为它总是选择最可能的词语。如图 \(\text{6}-4\) 所示,更高的值允许生成不太可能的词语

F6.1

因此,更高的温度(例如 \(\text{0.8}\))通常会导致更多样化的输出,而更低的温度(例如 \(\text{0.2}\))则会产生更具确定性的输出

您可以在管线中按如下方式使用温度:

1
2
3
# Using a high temperature
output = pipe(messages, do_sample=True, temperature=1)
print(output[0]["generated_text"])
1
2
Why don't chickens like to go on a rollercoaster? Because they're afraid they
might suddenly become chicken-soup!

请注意,每次您重新运行这段代码时,输出都会改变\(\text{temperature}\) 引入了随机行为,因为模型现在会随机选择词元

\(\text{top\_p}\)

\(\text{top\_p}\),也称为核心采样\(\text{nucleus sampling}\)),是一种采样技术,它控制 \(\text{LLM}\) 可以考虑的词元子集(即核心)。它将考虑词元直到达到它们的累积概率。如果我们将 \(\text{top\_p}\) 设置为 \(\text{0.1}\),它将考虑词元直到累积概率达到该值。如果我们将 \(\text{top\_p}\) 设置为 \(\text{1}\),它将考虑所有词元

如图 \(\text{6}-5\) 所示,通过降低该值,模型将考虑更少的词元,通常会给出较少“创造性”的输出;而增加该值则允许 \(\text{LLM}\) 从更多的词元中选择

F6.1

同样,\(\text{top\_k}\) 参数精确控制着 \(\text{LLM}\) 可以考虑多少个词元。如果将其值更改为 \(\text{100}\)\(\text{LLM}\)只考虑最可能的 \(\text{100}\) 个词元

您可以在管线中按如下方式使用 \(\text{top\_p}\)

1
2
3
# Using a high top_p
output = pipe(messages, do_sample=True, top_p=1)
print(output[0]["generated_text"])
1
2
Why don't chickens make good comedians? Because their 'jokes' always 'feather'
the truth!

如表 \(\text{6}-1\) 所示,这些参数允许用户在具有创造性(高 \(\text{temperature}\)\(\text{top\_p}\))和具有可预测性(低 \(\text{temperature}\)\(\text{top\_p}\))之间进行滑动调整

F6.1

提示工程入门

Intro to Prompt Engineering

提示工程\(\text{Prompt engineering}\))是使用文本生成式 \(\text{LLM}\)重要组成部分。通过精心设计我们的提示,我们可以引导 \(\text{LLM}\) 生成所需的响应。无论提示是问题、陈述还是指令,提示工程的主要目标从模型中引出有用的响应

提示工程不仅仅是设计有效的提示。它还可以用作评估模型输出以及设计保障措施和安全缓解方法的工具。这是一个提示优化的迭代过程,需要实验完美的提示设计不存在,而且未来也不太可能出现

在本节中,我们将介绍提示工程的常用方法,以及理解某些提示效果的小技巧和窍门。这些技能使我们能够理解 \(\text{LLM}\) 的能力,并构成了与这类模型进行交互的基础

我们首先回答一个问题:一个提示中应该包含什么?

提示的基本要素

The Basic Ingredients of a Prompt

大型语言模型 (\(\text{LLM}\)) 是一台预测机器。它根据特定的输入(即提示 \(\text{prompt}\)),尝试预测后面可能出现的词语。如图 \(\text{6}-6\) 所示,从核心来看,提示不需要超过几个词就能引出 \(\text{LLM}\) 的响应。

F6.1

然而,尽管该图示可作为基本示例,但它未能完成特定的任务。相反,我们通常进行提示工程时,会询问 \(\text{LLM}\) 应该完成的特定问题或任务。为了引出所需的响应,我们需要一个结构更清晰的提示

例如,如图 \(\text{6}-7\) 所示,我们可以要求 \(\text{LLM}\) 将一个句子分类为具有积极或消极情感。这将最基本的提示扩展为包含两个组成部分指令本身与指令相关的数据

F6.1

更复杂的用例可能需要在提示中包含更多组成部分。例如,为了确保模型只输出\(\text{negative}\)”(消极)或“\(\text{positive}\)”(积极),我们可以引入输出指示符来帮助引导模型。在图 \(\text{6}-8\) 中,我们在句子前加上“\(\text{Text:}\)”,并添加“\(\text{Sentiment:}\)”,以阻止模型生成完整的句子。相反,这种结构表明我们期望得到“\(\text{negative}\)”或“\(\text{positive}\)”的答案。尽管模型可能没有直接针对这些组成部分进行训练,但它被输入了足够的指令,使其能够泛化到这种结构。

F6.1

我们可以不断添加或更新提示的元素,直到引出我们想要的响应。我们可以添加额外的示例更详细地描述用例提供额外的上下文等。这些组成部分仅仅是示例,而不是一个有限的可能性集合。设计这些组成部分所带来的创造力是关键

尽管提示是单个文本片段,但将其视为更大拼图的碎片会非常有帮助。我是否描述了我的问题的上下文?提示中是否有输出的示例

基于指令的提示

Instruction-Based Prompting

尽管提示(\(\text{prompting}\))有许多不同的形式,从与 \(\text{LLM}\) 讨论哲学到与你最喜欢的超级英雄进行角色扮演,但提示通常用于让 \(\text{LLM}\) 回答一个具体问题解决一个特定任务。这被称为基于指令的提示\(\text{instruction-based prompting}\))。

\(\text{6}-9\) 展示了基于指令的提示发挥重要作用的一些用例。我们在前面的示例中已经做过其中之一,即监督式分类

F6.1

这些任务中的每一个都需要不同的提示格式,更具体地说,需要向 \(\text{LLM}\) 提出不同的问题。要求 \(\text{LLM}\) 总结一段文本不会突然导致分类结果。为了说明,图 \(\text{6}-10\) 中列出了一些用例的提示示例。

F6.1

尽管这些任务需要不同的指令,但用于提高输出质量的提示技术实际上存在很多重叠。这些技巧的非详尽列表包括:

特异性 (\(\text{Specificity}\))

准确描述您想要实现的目标。与其要求 \(\text{LLM}\)\(\text{Write a description for a product}\)”(为产品写一段描述),不如要求它“\(\text{Write a description for a product in less than two sentences and use a formal tone}\)”(用少于两句话使用正式语气为产品写一段描述)。

幻觉 (\(\text{Hallucination}\))

\(\text{LLM}\) 可能会自信地生成不正确的信息,这被称为幻觉\(\text{hallucination}\))。为了减少其影响,我们可以要求 \(\text{LLM}\) 只有在知道答案时才生成答案。如果它不知道答案,它可以回应“\(\text{I don’t know}\)”(我不知道)。

顺序 (\(\text{Order}\))

要么以指令开始提示,要么以指令结束提示。特别是在长提示中,中间的信息通常会被遗忘\(\text{LLM}\) 倾向于关注提示开头首因效应 \(\text{primacy effect}\))或提示结尾近因效应 \(\text{recency effect}\))的信息。

在这里,特异性可以说是最重要的方面。通过限制和明确模型应该生成的内容,它生成与您用例不相关内容的可能性就会较小。例如,如果我们跳过指令“\(\text{in two to three sentences}\)”(用两到三句话),它可能会生成完整的段落。就像人类对话一样,如果没有任何具体的指令或额外的上下文,很难推断出手头的任务到底是什么

高级提示工程

Advanced Prompt Engineering

从表面上看,创建一个好的提示(\(\text{prompt}\))似乎很简单。问一个具体的问题,保持准确,添加一些示例,就完成了!然而,提示很快就会变得复杂起来,因此它往往是利用 \(\text{LLM}\) 的一个被低估的组成部分

在这里,我们将介绍几种构建提示的高级技术,从构建复杂提示的迭代工作流程开始,一直到顺序使用 \(\text{LLM}\) 以获得改进结果。最终,我们甚至会深入到高级推理技术

提示的潜在复杂性

The Potential Complexity of a Prompt

正如我们在提示工程入门中探讨的那样,一个提示通常由多个组成部分构成。在我们最初的示例中,我们的提示由指令数据输出指示符组成。正如我们之前提到的,任何提示都不局限于这三个组成部分,您可以根据需要将其构建得尽可能复杂

这些高级组成部分可以快速使提示变得相当复杂。一些常见的组成部分包括:

  • 人设 (\(\text{Persona}\)):描述 \(\text{LLM}\) 应该扮演的角色。例如,如果您想问一个关于天体物理学的问题,可以使用“\(\text{You are an expert in astrophysics}\)”(你是一位天体物理学专家)。
  • 指令 (\(\text{Instruction}\))任务本身。确保这尽可能具体。我们不希望留有太多的解读空间。
  • 上下文 (\(\text{Context}\)):描述问题或任务背景的额外信息。它回答了诸如“指令的原因是什么?”之类的问题。
  • 格式 (\(\text{Format}\))\(\text{LLM}\) 应该用来输出生成文本的格式。如果没有它,\(\text{LLM}\) 将自己设计格式,这在自动化系统中很麻烦。
  • 受众 (\(\text{Audience}\))生成文本的目标对象。这也描述了生成输出的水平。出于教育目的,使用 \(\text{ELI5}\)(“\(\text{Explain it like I’m 5}\)”,像我 \(\text{5}\) 岁一样解释)通常很有帮助。
  • 语气 (\(\text{Tone}\))\(\text{LLM}\) 在生成文本中应该使用的语调。如果您正在给老板写一封正式的邮件,您可能不希望使用非正式的语调。
  • 数据 (\(\text{Data}\)):与任务本身相关的主要数据

为了说明,让我们扩展我们前面提到的分类提示,并使用所有上述组成部分。这在图 \(\text{6}-11\) 中有所展示。

F6.1

这个复杂的提示展示了提示的模块化特性。我们可以自由地添加和删除组成部分,并判断它们对输出的影响。如图 \(\text{6}-12\) 所示,我们可以循序渐进地构建我们的提示,并探索每次更改的效果

F6.1

这些更改不限于简单地引入或移除组成部分。它们的顺序,正如我们之前看到的近因效应\(\text{recency effect}\))和首因效应\(\text{primacy effect}\))一样,也会影响 \(\text{LLM}\) 输出的质量。换句话说,在为您的用例寻找最佳提示时,实验至关重要。通过提示,我们实际上让自己处于一个迭代的实验循环中。

亲自尝试一下!使用这个复杂提示,通过添加和/或移除部分来观察它对生成输出的影响。您会很快注意到哪些“拼图碎片”值得保留。您可以通过将其添加到 \(\text{data}\) 变量中来使用您自己的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Prompt components
persona = "You are an expert in Large Language models. You excel at breaking
down complex papers into digestible summaries.\n"
instruction = "Summarize the key findings of the paper provided.\n"
context = "Your summary should extract the most crucial points that can help
researchers quickly understand the most vital information of the paper.\n"
data_format = "Create a bullet-point summary that outlines the method. Follow
this up with a concise paragraph that encapsulates the main results.\n"
audience = "The summary is designed for busy researchers that quickly need to
grasp the newest trends in Large Language Models.\n"
tone = "The tone should be professional and clear.\n"
text = "MY TEXT TO SUMMARIZE"
data = f"Text to summarize: {text}"
# The full prompt - remove and add pieces to view its impact on the generated
output
query = persona + instruction + context + data_format + audience + tone + data

我们可以添加各种各样的组成部分,以及一些富有创意的元素,例如使用情感刺激(例如,“\(\text{This is very important for my career.}\)”)。提示工程的乐趣在于您可以尽可能地发挥创意,找出哪种提示组合最有助于您的用例。开发适合您的格式的限制很少

从某种意义上说,这是一种尝试逆向工程\(\text{reverse engineer}\)模型所学到的内容以及它如何响应某些提示的尝试。但是请注意,由于训练数据可能不同训练目的不同某些提示对于特定模型的效果会优于其他模型

上下文学习:提供示例

In-Context Learning: Providing Examples

在前面的部分中,我们试图准确地描述 \(\text{LLM}\) 应该做什么。尽管准确和具体的描述有助于 \(\text{LLM}\) 理解用例,但我们可以更进一步。与其描述任务,为什么不直接展示任务呢?

我们可以向 \(\text{LLM}\) 提供我们想要实现的目标的精确示例。这通常被称为上下文学习\(\text{in}-\text{context learning}\)),即我们向模型提供正确的示例

如图 \(\text{6}-13\) 所示,这根据您向 \(\text{LLM}\) 展示的示例数量而有多种形式:零样本提示\(\text{Zero}-\text{shot prompting}\)不使用示例单样本提示\(\text{one}-\text{shot prompts}\)使用一个示例;而少样本提示\(\text{few}-\text{shot prompts}\)使用两个或更多示例

F6.1

沿用原始的说法,我们相信“一个例子抵得上千言万语”。这些示例提供了关于 \(\text{LLM}\) 应该实现什么以及如何实现直接榜样

我们可以用摘自描述该方法的原始论文的一个简单示例来说明此方法。该提示的目标是生成一个包含虚构词语的句子。为了提高生成句子的质量,我们可以向生成模型展示一个包含虚构词语的恰当句子的示例

为此,我们需要区分我们的问题\(\text{user}\))和模型提供的答案\(\text{assistant}\))。我们还展示了如何使用模板来处理这种交互:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Use a single example of using the made-up word in a sentence
one_shot_prompt = [
{
"role": "user",
"content": "A 'Gigamuru' is a type of Japanese musical instrument. An example of a sentence that uses the word Gigamuru is:"
},
{
"role": "assistant",
"content": "I have a Gigamuru that my uncle gave me as a gift. I love to play it at home."
},
{
"role": "user",
"content": "To 'screeg' something is to swing a sword at it. An example of a sentence that uses the word screeg is:"
}
]
print(tokenizer.apply_chat_template(one_shot_prompt, tokenize=False))
1
2
3
4
5
6
7
8
9
10
<s><|user|>
A 'Gigamuru' is a type of Japanese musical instrument. An example of a sen-
tence that uses the word Gigamuru is:<|end|>
<|assistant|>
I have a Gigamuru that my uncle gave me as a gift. I love to play it at home.<|
end|>
<|user|>
To 'screeg' something is to swing a sword at it. An example of a sentence that
uses the word screeg is:<|end|>
<|assistant|>

该提示说明了区分用户和助手的必要性。如果我们不这样做,看起来就像我们在自言自语一样。使用这些交互,我们可以按如下方式生成输出:

1
2
3
# Generate the output
outputs = pipe(one_shot_prompt)
print(outputs[0]["generated_text"])
1
During the intense duel, the knight skillfully screeged his opponent's shield, forcing him to defend himself.

正确地生成了答案

与所有提示组成部分一样,单样本或少样本提示并非提示工程的万能药。我们可以将其用作拼图中的一块,以进一步增强我们给出的描述。模型仍然可能通过随机采样“选择”忽略指令

链式提示:分解问题

Chain Prompting: Breaking up the Problem

在前面的示例中,我们探讨了将提示拆分成模块化组件以提高 \(\text{LLM}\) 的性能。尽管这适用于许多用例,但对于高度复杂的提示或用例来说可能不可行

我们可以不在一个提示内分解问题,而是在提示之间进行分解。本质上,我们将一个提示的输出用作下一个提示的输入,从而创建一个解决问题的连续交互链

为了说明,假设我们想使用 \(\text{LLM}\) 根据一些产品特征来为我们创建产品名称、标语和销售宣传语。虽然我们可以要求 \(\text{LLM}\) 一次性完成,但我们可以将问题分解成几个部分

因此,如图 \(\text{6}-14\) 所示,我们得到了一个顺序管线:首先创建产品名称;然后使用该名称和产品特征作为输入来创建标语;最后,使用特征、产品名称和标语创建销售宣传语

F6.1

这种链式提示的技术允许 \(\text{LLM}\) 将更多时间花在每个单独的问题上,而不是同时处理整个问题。让我们用一个小例子来说明这一点。我们首先为一款聊天机器人创建一个名称和标语

1
2
3
4
5
6
7
8
# Create name and slogan for a product
product_prompt = [
leverages LLMs."}
{"role": "user", "content": "Create a name and slogan for a chatbot that
]
outputs = pipe(product_prompt)
product_description = outputs[0]["generated_text"]
print(product_description)
1
2
Name: 'MindMeld Messenger'
Slogan: 'Unleashing Intelligent Conversations, One Response at a Time'

然后,我们可以使用生成的输出作为 \(\text{LLM}\) 生成销售宣传语的输入

1
2
3
4
5
6
7
8
# Based on a name and slogan for a product, generate a sales pitch
sales_prompt = [
{"role": "user", "content": f"Generate a very short sales pitch for the
following product: '{product_description}'"}
]
outputs = pipe(sales_prompt)
sales_pitch = outputs[0]["generated_text"]
print(sales_pitch)
1
2
3
4
5
6
7
Introducing MindMeld Messenger - your ultimate communication partner! Unleash
intelligent conversations with our innovative AI-powered messaging platform.
With MindMeld Messenger, every response is thoughtful, personalized, and
timely. Say goodbye to generic replies and hello to meaningful interactions.
Elevate your communication game with MindMeld Messenger - where every message
is a step toward smarter conversations. Try it now and experience the future
of messaging!

尽管我们需要对模型进行两次调用,但一个主要优点是我们可以为每次调用设置不同的参数。例如,名称和标语所需的词元数量相对较少,而宣传语可以长得多。

这可用于各种用例,包括:

  • 响应验证 (\(\text{Response validation}\)):要求 \(\text{LLM}\) 重新检查先前生成的输出。
  • 并行提示 (\(\text{Parallel prompts}\))并行创建多个提示,并进行最后一步合并它们。例如,要求多个 \(\text{LLM}\) 并行生成多个食谱,然后使用合并的结果创建购物清单。
  • 撰写故事 (\(\text{Writing stories}\)):通过将问题分解成组件来利用 \(\text{LLM}\) 撰写书籍或故事。例如,首先撰写摘要、开发角色、构建故事节拍,然后再深入创建对话。

在下一章中,我们将自动化这一过程,并超越简单的链式 \(\text{LLM}\)。我们将把其他技术组件(如记忆、工具使用等)连接在一起!在此之前,我们将在接下来的部分中进一步探索提示链的这一想法,这些部分描述了更复杂的提示链方法,如自洽性\(\text{self}-\text{consistency}\))、思维链\(\text{chain}-\text{of}-\text{thought}\))和思想树\(\text{tree}-\text{of}-\text{thought}\))。

使用生成模型进行推理

Reasoning with Generative Models

在前面的部分中,我们主要关注提示的模块化组件,并通过迭代来构建它们。这些高级提示工程技术,例如链式提示\(\text{prompt chaining}\)),被证明是使用生成模型进行复杂推理的第一步

推理人类智能的核心组成部分,它经常被拿来与 \(\text{LLM}\) 的涌现行为进行比较,后者通常类似于推理。我们特意强调“类似于”,因为在撰写本书时,这些模型通常被认为是通过对训练数据的记忆和模式匹配来展示这种行为的。

然而,它们展示的输出可以体现复杂的行为,尽管这可能不是“真正的”推理,但它们仍然被称为推理能力。换句话说,我们通过提示工程\(\text{LLM}\) 协同工作,以便模仿推理过程,从而提高 \(\text{LLM}\) 的输出

为了促成这种推理行为,现在是回顾并探索推理在人类行为中意味着什么的好时机。简单来说,我们的推理方法可以分为系统 \(\text{1}\)系统 \(\text{2}\) 思考过程。

系统 \(\text{1}\) 思考代表着自动、直觉近乎即时的过程。它与生成模型有相似之处,后者自动生成词元而没有任何自我反思的行为。相比之下,系统 \(\text{2}\) 思考是一个有意识、缓慢逻辑的过程,类似于头脑风暴和自我反思

如果我们可以赋予生成模型模仿某种形式的自我反思的能力,我们本质上就是在模拟系统 \(\text{2}\) 的思考方式,这种方式倾向于产生比系统 \(\text{1}\) 思考更深思熟虑的响应。在本节中,我们将探索几种旨在模仿人类推理者这类思维过程、以提高模型输出的技术。

思维链:先思考再回答

Chain-of-Thought: Think Before Answering

朝着生成模型中复杂推理迈出的第一步也是重要一步是通过一种称为思维链\(\text{Chain-of-Thought, CoT}\))的方法。思维链旨在让生成模型先进行“思考”,而不是在没有任何推理的情况下直接回答问题

如图 \(\text{6}-15\) 所示,它在提示中提供了一些示例,这些示例展示了模型在生成响应之前应该进行的推理过程。这些推理过程被称为“思绪”(\(\text{thoughts}\))。这对涉及较高复杂度的任务(如数学问题)非常有帮助。添加这个推理步骤允许模型在推理过程中分配更多的计算资源。模型不再是基于几个词元来计算整个解决方案,而是推理过程中的每一个额外的词元都允许 \(\text{LLM}\) 稳定其输出

F6.1

我们使用作者在他们的论文中使用的例子来演示这种现象:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Answering with chain-of-thought
cot_prompt = [
{"role": "user", "content": "Roger has 5 tennis balls. He buys 2 more cans
of tennis balls. Each can has 3 tennis balls. How many tennis balls does he
have now?"},
{"role": "assistant", "content": "Roger started with 5 balls. 2 cans of 3
tennis balls each is 6 tennis balls. 5 + 6 = 11. The answer is 11."},
{"role": "user", "content": "The cafeteria had 23 apples. If they used 20
to make lunch and bought 6 more, how many apples do they have?"}
]
# Generate the output
outputs = pipe(cot_prompt)
print(outputs[0]["generated_text"])
1
2
3
The cafeteria started with 23 apples. They used 20 apples, so they had 23 - 20
= 3 apples left. Then they bought 6 more apples, so they now have 3 + 6 = 9
apples. The answer is 9.

请注意模型不是只生成答案,而是在此之前提供了解释。通过这样做,它可以利用它迄今为止生成的知识来计算最终答案

虽然思维链是增强生成模型输出的一个好方法,但它要求在提示中提供一个或多个推理示例,而用户可能无法获取这些示例。我们可以不提供示例,而是简单地要求生成模型提供推理零样本思维链 \(\text{zero}-\text{shot chain}-\text{of}-\text{thought}\))。有许多不同的表达方式都有效,但一个常见且有效的方法是使用短语“\(\text{Let’s think step-by-step}\)”(让我们一步一步地思考),如图 \(\text{6}-16\) 所示。

F6.1

使用我们之前用过的例子,我们只需将该短语附加到提示的末尾,即可启用类似思维链的推理

1
2
3
4
5
6
7
8
9
# Zero-shot chain-of-thought
zeroshot_cot_prompt = [
{"role": "user", "content": "The cafeteria had 23 apples. If they used 20
to make lunch and bought 6 more, how many apples do they have? Let's think
step-by-step."}
]
# Generate the output
outputs = pipe(zeroshot_cot_prompt)
print(outputs[0]["generated_text"])
1
2
3
4
5
Step 1: Start with the initial number of apples, which is 23.
Step 2: Subtract the number of apples used to make lunch, which is 20. So, 23
- 20 = 3 apples remaining.
Step 3: Add the number of apples bought, which is 6. So, 3 + 6 = 9 apples.
The cafeteria now has 9 apples.

不需要提供示例的情况下,我们再次获得了相同的推理行为。这就是为什么在进行计算时展示您的工作如此重要。通过处理推理过程\(\text{LLM}\) 可以使用先前生成的信息作为生成最终答案的指导

💡 尽管提示“\(\text{Let’s think step by step}\)”可以改善输出,但您不受限于这种确切的措辞。替代方案包括“\(\text{Take a deep breath and think step-by-step}\)”(深吸一口气,一步一步地思考)和“\(\text{Let’s work through this problem step-by-step}\)”(让我们一步一步地解决这个问题)。

自洽性:对输出进行采样

Self-Consistency: Sampling Outputs

如果我们通过像 \(\text{temperature}\)\(\text{top\_p}\) 这样的参数允许一定程度的创造性,那么多次使用相同的提示可能会导致不同的结果。因此,输出的质量可能会根据词元的随机选择提高或降低。换句话说,这取决于运气

为了抵消这种随机性并提高生成模型的性能,人们引入了自洽性\(\text{self-consistency}\))。该方法要求生成模型多次使用相同的提示,并将多数结果作为最终答案。在这个过程中,每个答案都可以受到不同的 \(\text{temperature}\)\(\text{top\_p}\)的影响,以增加采样的多样性

如图 \(\text{6}-17\) 所示,该方法可以通过添加思维链提示来进一步改进其推理能力,同时仅使用答案进行投票程序

F6.1

然而,这确实需要多次询问同一个问题。因此,尽管该方法可以提高性能,但它的速度会慢 \(n\),其中 \(n\)输出样本的数量

思想树:探索中间步骤

Tree-of-Thought: Exploring Intermediate Steps

思维链自洽性的理念旨在实现更复杂的推理。通过从多个“思绪”中进行采样并使它们更深思熟虑,我们的目标是改善生成模型的输出

这些技术仅触及了当前为模仿复杂推理所做工作的皮毛。这些方法的改进可以在思想树\(\text{Tree-of-Thought, ToT}\))中找到,它允许对多个想法进行深入探索

该方法的工作原理如下:当面对一个需要多个推理步骤的问题时,将其分解成多个部分通常会有所帮助。如图 \(\text{6}-18\) 所示,在每一步,都会提示生成模型探索针对当前问题的不同解决方案。然后,它会投票选出最佳解决方案,并继续下一步

F6.1

在需要考虑多种路径时,例如在撰写故事提出创意想法时,这种方法非常有帮助。该方法的一个缺点是它需要多次调用生成模型,这会显著减慢应用程序的速度。幸运的是,已经有人成功地将思想树框架转换成一种简单的提示技术

我们不多次调用生成模型,而是要求模型通过模仿多位专家之间的对话模拟这种行为。这些专家将相互提问,直到他们达成共识。一个零样本思想树提示的示例如下:

1
2
3
4
5
6
7
# Zero-shot tree-of-thought
zeroshot_tot_prompt = [
{"role": "user", "content": "Imagine three different experts are answering this question. All experts will write down 1 step of their thinking, then share it with the group. Then all experts will go on to the next step, etc. If any expert realizes they're wrong at any point then they leave. The question is
'The cafeteria had 23 apples. If they used 20 to make lunch and bought 6 more,how many apples do they have?'
Make sure to discuss the results."
}
]

我们可以使用这个提示来探索 \(\text{LLM}\) 如何响应复杂问题

1
2
3
# Generate the output
outputs = pipe(zeroshot_tot_prompt)
print(outputs[0]["generated_text"])
1
2
3
4
5
6
7
8
9
Expert 1: Step 1 - Start with the initial number of apples: 23 apples.
Expert 2: Step 1 - Subtract the apples used for lunch: 23 - 20 = 3 apples
remaining.
Expert 3: Step 1 - Add the newly bought apples: 3 + 6 = 9 apples.
Expert 1: Step 2 - Confirm the final count: The cafeteria has 9 apples.
Expert 2: Step 2 - Review the calculations: 23 - 20 = 3, then 3 + 6 = 9. The
calculations are correct.
Expert 3: Step 2 - Agree with the result: The cafeteria indeed has 9 apples.
All experts agree on the final count: The cafeteria has 9 apples.

我们再次得到了正确的答案,但这次是通过“专家之间的讨论”。看到这种“专家”之间的交流是很有趣的,它展示了提示工程所带来的创造力

输出验证

Output Verification

使用生成模型构建的系统和应用程序最终可能会投入生产环境。在这种情况下,验证和控制模型输出防止应用程序崩溃并创建鲁棒的生成式 \(\text{AI}\) 应用程序至关重要。

验证输出的原因可能包括:

  • 结构化输出 (\(\text{Structured output}\)):默认情况下,大多数生成模型创建自由格式的文本,除了自然语言定义的结构外,不遵循特定的结构。某些用例要求它们的输出以特定格式(如 \(\text{JSON}\))进行结构化

  • 有效输出 (\(\text{Valid output}\)):即使我们允许模型生成结构化输出,它仍然有能力自由生成其内容。例如,当模型被要求输出两种选择之一时,它不应该凭空想出第三种

  • 伦理 (\(\text{Ethics}\)):一些开源生成模型没有护栏,会生成不考虑安全或伦理考量的输出。例如,用例可能要求输出不含亵渎、个人身份信息 (\(\text{PII}\))、偏见、文化刻板印象等。

  • 准确性 (\(\text{Accuracy}\)):许多用例要求输出符合某些标准或性能。目的是再次检查生成的信息是否事实准确、连贯没有幻觉

控制生成模型的输出,正如我们通过像 \(\text{top\_p}\)\(\text{temperature}\) 这样的参数所探讨的那样,并非易事。这些模型需要帮助才能生成符合特定指南的、一致的输出

通常,有三种控制生成模型输出的方法:

  1. 示例 (\(\text{Examples}\)):提供预期输出的多个示例。
  2. 语法 (\(\text{Grammar}\))控制词元选择过程
  3. 微调 (\(\text{Fine-tuning}\)):用包含预期输出的数据调整模型

在本节中,我们将介绍前两种方法。第三种方法,即微调模型,将留到\(\text{12}\),届时我们将深入探讨微调方法。

提供示例

Providing Examples

修复输出的一个简单直接的方法是为生成模型提供输出应该是什么样子的示例。正如我们之前探讨的,少样本学习\(\text{few}-\text{shot learning}\))是一种有助于引导生成模型输出的有用技术。该方法也可以推广引导输出的结构

例如,让我们考虑一个我们希望生成模型\(\text{RPG}\) 游戏创建角色档案的例子。我们从不使用示例开始:

1
2
3
4
5
6
7
# Zero-shot learning: Providing no examples
zeroshot_prompt = [
{"role": "user", "content": "Create a character profile for an RPG game in JSON format."}
]
# Generate the output
outputs = pipe(zeroshot_prompt)
print(outputs[0]["generated_text"])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"characterProfile": {
"name": "Eldrin Stormbringer",
"class": "Warlock",
"race": "Half-Elf",
"age": 27,
"gender": "Male",
"alignment": "Chaotic Good",
"background": "Rogue",

},
"attributes": {
"strength": 10,
"dexterity": 17,
"constitution": 12,
"intelligence": 12,
"wisdom": 10,
"charisma

上面这个被截断的输出不是有效的 \(\text{JSON}\),因为模型在开始“\(\text{charisma}\)”属性后停止了生成词元。此外,我们可能不想要某些属性。相反,我们可以为模型提供一些示例指示预期的格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# One-shot learning: Providing an example of the output structure
one_shot_template = """Create a short character profile for an RPG game. Make
sure to only use this format:
{
"description": "A SHORT DESCRIPTION",
"name": "THE CHARACTER'S NAME",
"armor": "ONE PIECE OF ARMOR",
"weapon": "ONE OR MORE WEAPONS"
}
"""
one_shot_prompt = [
{"role": "user", "content": one_shot_template}
]
# Generate the output
outputs = pipe(one_shot_prompt)
print(outputs[0]["generated_text"])
1
2
3
4
5
6
7
{
"description": "A cunning rogue with a mysterious past, skilled in stealth
and deception.",
"name": "Lysandra Shadowstep",
"armor": "Leather Cloak of the Night",
"weapon": "Dagger of Whispers, Throwing Knives"
}

模型完美地遵循了我们给出的示例,这使得行为更加一致。这也证明了利用少样本学习来改进输出结构不仅仅是内容的重要性。

这里一个重要的注意事项是,模型是否会遵循您建议的格式仍然取决于模型本身有些模型在遵循指令方面比其他模型做得更好

语法:约束采样

Grammar: Constrained Sampling

少样本学习有一个很大的缺点:我们不能明确地阻止生成某些输出。虽然我们引导模型给出指令,但它可能仍然不会完全遵循

相反,为了约束和验证生成模型的输出,一些软件包(如 \(\text{Guidance}\)\(\text{Guardrails}\)\(\text{LMQL}\))被迅速开发出来。如图 \(\text{6}-19\) 所示,它们部分地利用生成模型来验证其自身的输出。生成模型将检索输出作为新的提示,并尝试根据预定义的一系列护栏对其进行验证

F6.1

同样,如图 \(\text{6}-20\) 所示,这种验证过程也可以用于控制输出的格式,方法是由我们自己生成其格式的一部分,因为我们已经知道它应该如何结构化。

F6.1

这个过程可以更进一步,我们可以不在输出后进行验证,而是在词元采样过程中就进行验证。在采样词元时,我们可以定义一系列语法或规则\(\text{LLM}\) 在选择其下一个词元时应遵守这些规则。例如,如果我们要求模型在执行情感分类时只返回\(\text{positive}\)”(积极)、“\(\text{negative}\)”(消极)或“\(\text{neutral}\)”(中性),它仍然有可能返回其他内容。如图 \(\text{6}-21\) 所示,通过约束采样过程,我们可以让 \(\text{LLM}\) 只输出我们感兴趣的内容。请注意,这仍然受到诸如 \(\text{top\_p}\)\(\text{temperature}\)参数的影响

F6.1

让我们用 \(\text{llama}-\text{cpp}-\text{python}\) 来演示这个现象,这是一个类似于 \(\text{transformers}\) 的库,我们可以用它来加载我们的语言模型。它通常用于高效地加载和使用压缩模型(通过量化;见第 \(\text{12}\) 章),但我们也可以用它来应用 \(\text{JSON}\) 语法

我们将加载本章中一直使用的相同模型,但使用不同的格式,即 \(\text{GGUF}\)\(\text{llama}-\text{cpp}-\text{python}\) 预期使用这种格式,它通常用于压缩(量化)模型

由于我们正在加载一个新模型,建议重新启动 \(\text{notebook}\)。这将清除任何以前的模型并清空显存\(\text{VRAM}\))。您也可以运行以下代码来清空显存:

1
2
3
4
5
6
import gc
import torch
del model, tokenizer, pipe
# Flush memory
gc.collect()
torch.cuda.empty_cache()

现在我们已经清除了内存,我们可以加载 \(\text{Phi}-\text{3}\)。我们将 \(\text{n\_gpu\_layers}\) 设置为 \(\text{-1}\),表示我们希望模型的所有层都从 \(\text{GPU}\) 运行\(\text{n\_ctx}\) 指的是模型的上下文大小\(\text{repo\_id}\)\(\text{filename}\) 指的是模型所在的 \(\text{Hugging Face}\) 仓库

1
2
3
4
5
6
7
8
9
from llama_cpp.llama import Llama
# Load Phi-3
llm = Llama.from_pretrained(
repo_id="microsoft/Phi-3-mini-4k-instruct-gguf",
filename="*fp16.gguf",
n_gpu_layers=-1,
n_ctx=2048,
verbose=False
)

为了使用内部 \(\text{JSON}\) 语法生成输出,我们只需要将 \(\text{response\_format}\) 指定为一个 \(\text{JSON}\) 对象。在底层,它将应用 \(\text{JSON}\) 语法以确保输出遵循该格式

为了说明,让我们要求模型以 \(\text{JSON}\) 格式创建一个用于龙与地下城\(\text{Dungeons \& Dragons}\))游戏的角色档案:

1
2
3
4
5
6
7
8
# Generate output
output = llm.create_chat_completion(
messages=[
{"role": "user", "content": "Create a warrior for an RPG in JSON format."},
],
response_format={"type": "json_object"},
temperature=0,
)['choices'][0]['message']["content"]

为了检查输出是否确实是 \(\text{JSON}\),我们可以尝试像处理 \(\text{JSON}\) 一样处理它

1
2
3
4
import json
# Format as json
json_output = json.dumps(json.loads(output), indent=4)
print(json_output)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
{
"name": "Eldrin Stormbringer",
"class": "Warrior",
"level": 10,
"attributes": {
"strength": 18,
"dexterity": 12,
"constitution": 16,
"intelligence": 9,
"wisdom": 14,
"charisma": 10
},
"skills": {
"melee_combat": {
"weapon_mastery": 20,
"armor_class": 18,
"hit_points": 35
},
"defense": {
"shield_skill": 17,
"block_chance": 90
},
"endurance": {
"health_regeneration": 2,
"stamina": 30
}
},
"equipment": [
{
"name": "Ironclad Armor",
"type": "Armor",
"defense_bonus": 15
},
{
"name": "Steel Greatsword",
"type": "Weapon",
"damage": 8,
"critical_chance": 20
}
],
"background": "Eldrin grew up in a small village on the outskirts of a
war-torn land. Witnessing the brutality and suffering caused by conflict, he
dedicated his life to becoming a formidable warrior who could protect those
unable to defend themselves."
}

该输出已正确地格式化为 \(\text{JSON}\)。这使我们能够更自信地在那些要求输出必须遵循特定格式的应用中使用生成模型

总结

在本章中,我们通过提示工程\(\text{prompt engineering}\))和输出验证探索了使用生成模型的基础知识。我们重点关注了提示工程所带来的创造性和潜在复杂性。提示的这些组成部分对于生成和优化适用于不同用例的输出至关重要。

我们进一步探索了高级提示工程技术,例如上下文学习\(\text{in}-\text{context learning}\))和思维链\(\text{chain}-\text{of}-\text{thought}\))。这些方法通过提供示例鼓励逐步思考的短语,来引导生成模型对复杂问题进行推理,从而模仿人类的推理过程

总的来说,本章表明提示工程是与 \(\text{LLM}\) 协作的关键方面,因为它使我们能够有效地将我们的需求和偏好传达给模型。通过掌握提示工程技术,我们可以释放 \(\text{LLM}\) 的部分潜力,并生成符合我们要求的高质量响应

下一章将以这些概念为基础,探索利用生成模型的更高级技术。我们将超越提示工程,探索 \(\text{LLM}\) 如何使用外部记忆和工具

书籍各章的机翻md文件:
《Hands-On Large Language Models》目录及前言
《Hands-On Large Language Models》第1章 大型语言模型简介
《Hands-On Large Language Models》第2章 词元与嵌入
《Hands-On Large Language Models》第3章 深入了解大型语言模型
《Hands-On Large Language Models》第4章 文本分类
《Hands-On Large Language Models》第5章 文本聚类和主题建模
《Hands-On Large Language Models》第6章 提示工程
《Hands-On Large Language Models》第7章 高级文本生成技术与工具
《Hands-On Large Language Models》第8章 语义搜索与检索增强生成
《Hands-On Large Language Models》第9章 多模态大型语言模型
《Hands-On Large Language Models》第10章 创建文本嵌入模型
《Hands-On Large Language Models》第11章 微调用于分类的表征模型
《Hands-On Large Language Models》第12章 生成模型的微调

\(\text{5}\) 文本聚类和主题建模

Text Clustering and Topic Modeling

尽管在过去几年中,监督式技术(例如分类)在业界占据了主导地位,但无监督式技术(例如文本聚类 (\(\text{Text clustering}\)))的潜力不容低估

文本聚类旨在根据文本的语义内容、含义和关系相似的文本进行分组。如图 \(\text{5}-1\) 所示,由此产生的语义相似文档的簇\(\text{clusters}\))不仅有助于对大量非结构化文本进行高效分类,还能实现快速的探索性数据分析

F5.1

语言模型的最新发展使得文本的上下文和语义表征成为可能,从而增强了文本聚类的有效性。语言不仅仅是词袋\(\text{bag of words}\)),最近的语言模型已证明非常有能力捕捉这一概念不受监督约束的文本聚类,允许创造性的解决方案和多样的应用,例如发现异常值加速标记查找错误标记的数据

文本聚类也进入了主题建模\(\text{topic modeling}\))的领域,我们在其中想要发现大量文本数据中出现的(抽象)主题。如图 \(\text{5}-2\) 所示,我们通常使用关键词或关键短语来描述一个主题,理想情况下,它会有一个单一的总括标签

F5.1

在本章中,我们将首先探讨如何使用嵌入模型进行聚类,然后转向一种受文本聚类启发的主题建模方法,即 \(\text{BERTopic}\)

文本聚类和主题建模在本书中占有重要地位,因为它们探索了结合各种不同语言模型的创造性方式。我们将探索如何结合仅编码器(嵌入)、仅解码器(生成)甚至经典方法(词袋)可以产生惊人的新技术和管线

\(\text{ArXiv}\) 文章:计算与语言

ArXiv’s Articles: Computation and Language

在本章中,我们将对 \(\text{ArXiv}\) 文章运行聚类和主题建模算法。\(\text{ArXiv}\) (https://arxiv.org/)是一个开放获取的学术文章平台,主要涉及计算机科学、数学和物理领域。为了与本书的主题保持一致,我们将探索计算与语言领域的文章。该数据集包含 \(\text{1991}\) 年到 \(\text{2024}\) 年间来自 \(\text{ArXiv}\)\(\text{cs.CL}\)(计算与语言)部分\(\text{44,949}\) 篇摘要。

我们加载数据,并为每篇文章的摘要、标题和年份创建单独的变量:

1
2
3
4
5
6
# Load data from Hugging Face
from datasets import load_dataset
dataset = load_dataset("maartengr/arxiv_nlp")["train"]
# Extract metadata
abstracts = dataset["Abstracts"]
titles = dataset["Titles"]

文本聚类的通用管线

A Common Pipeline for Text Clustering

文本聚类允许您发现可能熟悉或不熟悉的数据模式。它有助于对任务(例如分类任务)及其复杂性获得直观的理解。因此,文本聚类不仅仅是一种探索性数据分析的快速方法

虽然文本聚类有许多方法,从基于图的神经网络基于中心点的聚类技术,但一个日益流行的通用管线涉及三个步骤和算法

  1. 使用嵌入模型将输入文档转换为嵌入。
  2. 使用降维模型减少嵌入的维度。
  3. 使用聚类模型找到语义相似文档的群组。

嵌入文档

Embedding Documents

第一步是将我们的文本数据转换为嵌入,如图 \(\text{5}-3\) 所示。回顾前几章,嵌入文本的数值表征,旨在捕捉其含义

F5.1

选择针对语义相似性任务进行优化的嵌入模型对于聚类尤其重要,因为我们试图找到语义相似文档的群组。幸运的是,在撰写本文时,大多数嵌入模型都专注于这一点:语义相似性

正如我们在上一章中所做的那样,我们将使用 \(\text{MTEB}\) 排行榜来选择一个嵌入模型。我们需要一个在聚类任务上得分不错但又足够小以至于能快速运行的嵌入模型。我们不使用上一章中使用的 \(\text{sentence}-\text{transformers/all}-\text{mpnet}-\text{base}-\text{v}2\) 模型,而是改用 \(\text{thenlper/gte}-\text{small}\) 模型。这是一个更新的模型,在聚类任务上表现优于前一个模型,并且由于其体积小推理速度甚至更快。不过,请随意尝试自发布以来更新的模型!

1
2
3
4
from sentence_transformers import SentenceTransformer
# Create an embedding for each abstract
embedding_model = SentenceTransformer("thenlper/gte-small")
embeddings = embedding_model.encode(abstracts, show_progress_bar=True)

让我们检查每个文档嵌入包含多少个值:

1
2
# Check the dimensions of the resulting embeddings
embeddings.shape
1
(44949, 384)

每个嵌入有 \(\text{384}\) 个值,它们共同代表了文档的语义表征。您可以将这些嵌入视为我们想要聚类的特征

减少嵌入的维度

Reducing the Dimensionality of Embeddings

在对嵌入进行聚类之前,我们首先需要考虑它们的高维度。随着维度数量的增加每个维度内可能值的数量会呈指数级增长在每个维度内找到所有子空间变得越来越复杂

因此,高维数据可能会给许多聚类技术带来麻烦,因为它使得识别有意义的簇变得更加困难。相反,我们可以利用降维\(\text{dimensionality reduction}\))。如图 \(\text{5}-4\) 所示,这项技术允许我们缩小维度空间的大小,并用更少的维度来表示相同的数据降维技术旨在通过寻找低维表征保留高维数据的全局结构

F5.1

请注意,这是一种压缩技术,底层算法并非随意移除维度。因此,我们聚类管线中的第二步是降维,如图 \(\text{5}-5\) 所示,以帮助聚类模型创建有意义的簇

F5.1

著名的降维方法有主成分分析\(\text{Principal Component Analysis, PCA}\))和均匀流形近似与投影\(\text{Uniform Manifold Approximation and Projection, UMAP}\))。对于这个管线,我们选择 \(\text{UMAP}\),因为它往往比 \(\text{PCA}\) 更好地处理非线性关系和结构

💡 然而,降维技术并非完美无缺。它们不能将高维数据完美地捕获到低维表征中。在这个过程中信息总是会丢失。在减少维度保留尽可能多的信息之间存在一种平衡

为了执行降维,我们需要实例化我们的 \(\text{UMAP}\),并将生成的嵌入传递给它:

1
2
3
4
5
6
from umap import UMAP
# We reduce the input embeddings from 384 dimensions to 5 dimensions
umap_model = UMAP(
n_components=5, min_dist=0.0, metric='cosine', random_state=42
)
reduced_embeddings = umap_model.fit_transform(embeddings)

我们可以使用 \(\text{n\_components}\) 参数来决定低维空间的形状,即 \(\text{5}\) 个维度。通常,\(\text{5}\)\(\text{10}\) 之间的值能很好地捕获高维全局结构。

\(\text{min\_dist}\) 参数是嵌入点之间的最小距离。我们将此设置为 \(\text{0}\),因为这通常会导致更紧密的簇。我们将 \(\text{metric}\) 设置为 \(\text{cosine}\),因为基于欧几里得的方法在处理高维数据时存在问题。

请注意,在 \(\text{UMAP}\) 中设置 \(\text{random\_state}\) 将使结果在不同会话中可重现,但会禁用并行性,因此会减慢训练速度

聚类降维后的嵌入

Cluster the Reduced Embeddings

第三步是对降维后的嵌入进行聚类,如图 \(\text{5}-6\) 所示。

F5.1

尽管常见的选择是像 \(\text{k}-\text{means}\) 这样的基于中心点的算法(它要求事先设定要生成的簇的数量),但我们事先不知道簇的数量。相反,基于密度的算法可以自由计算簇的数量,并且不会强制所有数据点都属于一个簇,如图 \(\text{5}-7\) 所示。

F5.1

一种常见的基于密度的模型是 \(\text{HDBSCAN}\)基于密度的带噪声应用的层次聚类)。\(\text{HDBSCAN}\) 是一种称为 \(\text{DBSCAN}\) 的聚类算法的层次变体,它允许在不必明确指定簇数量的情况下找到密集(微观)簇。作为一种基于密度的方法\(\text{HDBSCAN}\) 还可以检测数据中的异常值\(\text{outliers}\)),即不属于任何簇的数据点。这些异常值不会被分配或强制属于任何簇。换句话说,它们会被忽略。由于 \(\text{ArXiv}\) 文章可能包含一些小众论文,使用一个可以检测异常值的模型可能会有所帮助。

与之前的软件包一样,使用 \(\text{HDBSCAN}\) 非常简单。我们只需要实例化模型并将我们的降维后的嵌入传递给它:

1
2
3
4
5
6
7
8
from hdbscan import HDBSCAN
# We fit the model and extract the clusters
hdbscan_model = HDBSCAN(
min_cluster_size=50, metric="euclidean", cluster_selection_method="eom"
).fit(reduced_embeddings)
clusters = hdbscan_model.labels_
# How many clusters did we generate?
len(set(clusters))
1
156

使用 \(\text{HDBSCAN}\),我们在数据集中生成了 \(\text{156}\) 个簇。要创建更多簇,我们将需要减小 \(\text{min\_cluster\_size}\) 的值,因为它代表了一个簇可以采用的最小规模

检查聚类

Inspecting the Clusters

现在我们已经生成了\(\text{clusters}\)),我们可以手动检查每个簇并探索分配给它的文档,以了解其内容。例如,让我们从\(\text{0}\)中随机抽取一些文档:

1
2
3
4
5
import numpy as np
# Print first three documents in cluster 0
cluster = 0
for index in np.where(clusters==cluster)[0][:3]:
print(abstracts[index][:300] + "... \n")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
This works aims to design a statistical machine translation from English text
to American Sign Language (ASL). The system is based on Moses tool with some
modifications and the results are synthesized through a 3D avatar for
interpretation. First, we translate the input text to gloss, a written fo...

Researches on signed languages still strongly dissociate lin- guistic issues
related on phonological and phonetic aspects, and gesture studies for
recognition and synthesis purposes. This paper focuses on the imbrication of
motion and meaning for the analysis, synthesis and evaluation of sign lang...

Modern computational linguistic software cannot produce important aspects of
sign language translation. Using some researches we deduce that the majority of
automatic sign language translation systems ignore many aspects when they
generate animation; therefore the interpretation lost the truth inf...

从这些文档来看,这个簇中的文档主要关于从英语文本到美国手语 (\(\text{ASL}\)) 的翻译,很有趣!

我们可以更进一步,尝试可视化我们的结果,而不是手动浏览所有文档。为此,我们需要将文档嵌入降维到二维,因为这样我们就可以在 \(\text{x}/\text{y}\) 平面上绘制文档:

1
2
3
4
5
6
7
8
9
10
11
12
import pandas as pd
# Reduce 384-dimensional embeddings to two dimensions for easier visualization
reduced_embeddings = UMAP(
n_components=2, min_dist=0.0, metric="cosine", random_state=42
).fit_transform(embeddings)
# Create dataframe
df = pd.DataFrame(reduced_embeddings, columns=["x", "y"])
df["title"] = titles
df["cluster"] = [str(c) for c in clusters]
# Select outliers and non-outliers (clusters)
to_plot = df.loc[df.cluster != "-1", :]
outliers = df.loc[df.cluster == "-1", :]

我们还分别为\(\text{clusters\_df}\))和异常值\(\text{outliers\_df}\))创建了一个数据框,因为我们通常希望专注于簇并突出显示它们

💡 用于可视化目的的任何降维技术都会造成信息损失。它仅仅是对我们原始嵌入外观的近似。虽然它具有信息性,但它可能会将簇推得更近或拉得更远,与它们的实际距离不符。因此,人工评估(即我们自己检查簇)是聚类分析的关键组成部分

为了生成一个静态图,我们将使用著名的绘图库 \(\text{matplotlib}\)

1
2
3
4
5
6
7
8
import matplotlib.pyplot as plt
# Plot outliers and non-outliers separately
plt.scatter(outliers_df.x, outliers_df.y, alpha=0.05, s=2, c="grey")
plt.scatter(
clusters_df.x, clusters_df.y, c=clusters_df.cluster.astype(int),
alpha=0.6, s=2, cmap="tab20b"
)
plt.axis("off")

正如我们在图 \(\text{5}-8\) 中所看到的,它往往能很好地捕捉主要的簇。 请注意点的簇是如何以相同的颜色着色的,这表明 \(\text{HDBSCAN}\) 将它们归为一组。由于我们有大量的簇,绘图库会在簇之间循环使用颜色,所以不要认为所有的绿点都是一个簇,例如。

F5.1

这在视觉上很吸引人,但尚不能让我们看到簇内部正在发生什么。相反,我们可以通过从文本聚类转向主题建模来扩展这种可视化。

从文本聚类到主题建模

From Text Clustering to Topic Modeling

文本聚类在大规模文档集合中寻找结构的强大工具。在我们前面的示例中,我们可以手动检查每个簇,并根据其文档集合来识别它们。例如,我们探讨了一个包含手语相关文档的簇。我们可以说,该簇的主题是“手语”

这种在文本数据集合中寻找主题或潜在主题的想法通常被称为主题建模\(\text{Topic Modeling}\))。如图 \(\text{5}-9\) 所示,传统上,它涉及寻找一组最能代表和捕捉主题含义的关键词或短语

F5.1

这些技术没有将主题标记为“手语”,而是使用关键词,例如“\(\text{sign}\)”(手语)、“\(\text{language}\)”(语言)和“\(\text{translation}\)”(翻译)来描述主题。因此,这并没有给主题一个单一的标签,而是要求用户通过这些关键词来理解主题的含义

经典方法,如潜在狄利克雷分配\(\text{latent Dirichlet allocation, LDA}\)),假设每个主题都由语料库词汇表中单词的概率分布来表征。如图 \(\text{5}-10\) 所示,它演示了词汇表中的每个单词如何根据其与每个主题的相关性进行评分。

F5.1

这些方法通常使用词袋\(\text{bag}-\text{of}-\text{words}\))技术作为文本数据的主要特征,这没有考虑到单词和短语的上下文或含义。相比之下,我们的文本聚类示例确实考虑了这两点,因为它依赖于基于 \(\text{Transformer}\) 的嵌入,这些嵌入通过注意力机制针对语义相似性和上下文含义进行了优化。

在本节中,我们将通过一个高度模块化的文本聚类和主题建模框架,即 \(\text{BERTopic}\),将文本聚类扩展到主题建模领域

\(\text{BERTopic}\):模块化主题建模框架

BERTopic: A Modular Topic Modeling Framework

\(\text{BERTopic}\) 是一种主题建模技术,它利用语义相似文本的簇来提取各种类型的主题表征。其底层算法可以分为两个步骤

首先,如图 \(\text{5}-11\) 所示,我们遵循与文本聚类示例中相同的程序。我们嵌入文档减少它们的维度,最后聚类降维后的嵌入,以创建语义相似文档的群组

F5.1

其次,它通过利用一种经典方法——即词袋\(\text{bag}-\text{of}-\text{words}\)),来对语料库词汇表中的单词进行建模。正如我们在\(\text{1}\)中简要讨论并如图 \(\text{5}-12\) 所示,词袋的作用正如其名称所暗示计算每个单词在文档中出现的次数。由此产生的表征可用于提取文档中最常出现的单词

F5.1

然而,这里有两个注意事项。首先,这是一个文档级别的表征,而我们感兴趣的是簇级别的视角。为了解决这个问题,单词的频率是在整个簇内计算的,而不仅仅是在单个文档中,如图 \(\text{5}-13\) 所示。

F5.1

其次,像“\(\text{the}\)”和“\(\text{I}\)”这样的停用词\(\text{stop words}\))往往频繁出现在文档中,但对实际文档提供的意义很少\(\text{BERTopic}\) 使用术语频率-逆文档频率\(\text{term frequency–inverse document frequency, TF}-\text{IDF}\))的基于类别的变体(\(\text{c}-\text{TF}-\text{IDF}\)),以便赋予对簇更有意义的单词更高的权重,并降低在所有簇中都使用的单词的权重

词袋中的每个单词\(\text{c}-\text{TF}-\text{IDF}\) 中的 \(\text{c}-\text{TF}\) 部分)都乘以每个单词的 \(\text{IDF}\)。如图 \(\text{5}-14\) 所示,\(\text{IDF}\) 值是通过取所有簇中所有单词的平均频率除以每个单词的总频率对数来计算的。

F5.1

结果是每个单词的权重(“\(\text{IDF}\)”),我们可以用它乘以它们的频率(“\(\text{c}-\text{TF}\)”)来获得加权值(“\(\text{c}-\text{TF}-\text{IDF}\)”)。

如图 \(\text{5}-15\) 所示,过程的第二部分允许我们像以前一样生成单词上的分布。我们可以使用 \(\text{scikit}-\text{learn}\)\(\text{CountVectorizer}\) 来生成词袋(或术语频率)表征。在这里,每个簇被视为一个主题,它对语料库的词汇表有一个特定的排名

F5.1

将这两步(聚类表征主题)结合起来,就形成了 \(\text{BERTopic}\) 的完整管线,如图 \(\text{5}-16\) 所示。通过这个管线,我们可以聚类语义相似的文档,并从这些簇中生成由多个关键词代表的主题一个单词在一个主题中的权重越高,它就越能代表该主题

F5.1

这种管线的一个主要优势在于,聚类主题表征这两个步骤在很大程度上相互独立。例如,对于 \(\text{c}-\text{TF}-\text{IDF}\),我们不依赖于用于文档聚类的模型。这使得整个管线中的每个组件都具有显著的模块化。正如我们将在本章后面探讨的那样,这也是微调主题表征的一个绝佳起点。

如图 \(\text{5}-17\) 所示,虽然 \(\text{sentence}-\text{transformers}\) 被用作默认的嵌入模型,但我们可以将其替换为任何其他嵌入技术。同样的道理也适用于所有其他步骤。如果你不希望使用 \(\text{HDBSCAN}\) 生成异常值,你可以改用 \(\text{k}-\text{means}\)

F5.1

你可以将这种模块化视为使用乐高积木进行构建;管线的每个部分都可以被另一个类似算法完全替换。通过这种模块化,新发布的模型可以被整合到其架构中。随着语言 \(\text{AI}\) 领域的发展,\(\text{BERTopic}\) 也在不断成长!

\(\text{BERTopic}\) 的模块化

\(\text{BERTopic}\)模块化还有另一个优势:它允许使用相同的基本模型应用于和适应不同的用例。例如,\(\text{BERTopic}\) 支持各种算法变体

  • 引导式主题建模(\(\text{Guided topic modeling}\)
  • (半)监督式主题建模(\((\text{Semi})-\text{supervised topic modeling}\)
  • 分层主题建模(\(\text{Hierarchical topic modeling}\)
  • 动态主题建模(\(\text{Dynamic topic modeling}\)
  • 多模态主题建模(\(\text{Multimodal topic modeling}\)
  • 多视角主题建模(\(\text{Multi}-\text{aspect topic modeling}\)
  • 在线和增量主题建模(\(\text{Online and incremental topic modeling}\)
  • 零样本主题建模(\(\text{Zero}-\text{shot topic modeling}\)
  • 等等。

这种模块化算法灵活性是作者旨在让 \(\text{BERTopic}\) 成为主题建模的一站式商店的基础。您可以在文档代码库中找到其功能的完整概述。

要使用我们的 \(\text{ArXiv}\) 数据集运行 \(\text{BERTopic}\),我们可以使用我们先前定义的模型和嵌入(尽管这不是强制性的):

1
2
3
4
5
6
7
8
from bertopic import BERTopic
# Train our model with our previously defined models
topic_model = BERTopic(
embedding_model=embedding_model,
umap_model=umap_model,
hdbscan_model=hdbscan_model,
verbose=True
).fit(abstracts, embeddings)

让我们从探索创建的主题开始。\(\text{get\_topic\_info()}\) 方法有助于快速获取我们发现的主题的描述

1
topic_model.get_topic_info()

F5.1

这些主题中的每一个都由几个关键词代表,这些关键词在 \(\text{Name}\) 栏位中用 \(\_\) 连接起来。这个 \(\text{Name}\) 栏位使我们能够快速了解主题的内容,因为它显示了最能代表该主题的四个关键词

您可能还注意到,第一个主题被标记为 \(-1\)。该主题包含所有无法归入任何主题的文档,并被视为异常值\(\text{outliers}\))。这是聚类算法 \(\text{HDBSCAN}\) 的结果,它不强制所有点都必须聚类。要移除异常值,我们可以使用像 \(\text{k}-\text{means}\) 这样的非异常值算法,或者使用 \(\text{BERTopic}\)\(\text{reduce\_outliers()}\) 函数将异常值重新分配给主题。

我们可以使用 \(\text{get\_topic}\) 函数检查单个主题,并探索哪些关键词最能代表它们。例如,主题 \(\text{0}\) 包含以下关键词:

1
2
3
4
5
6
7
8
9
10
11
topic_model.get_topic(0)
[('speech', 0.028177697715245358),
('asr', 0.018971184497453525),
('recognition', 0.013457745472471012),
('end', 0.00980445092749381),
('acoustic', 0.009452082794507863),
('speaker', 0.0068822647060204885),
('audio', 0.006807649923681604),
('the', 0.0063343444687017645),
('error', 0.006320144717019838),
('automatic', 0.006290216996043161)]

例如,主题 \(\text{0}\) 包含关键词 “\(\text{speech}\)”、“\(\text{asr}\)”和 “\(\text{recognition}\)”。根据这些关键词,该主题似乎是关于自动语音识别\(\text{ASR}\))的。

我们可以使用 \(\text{find\_topics()}\) 函数根据搜索词搜索特定主题。让我们搜索一个关于主题建模的主题:

1
2
3
topic_model.find_topics("topic modeling")
([22, -1, 1, 47, 32],
[0.95456535, 0.91173744, 0.9074769, 0.9067007, 0.90510106])

这表明主题 \(\text{22}\) 与我们的搜索词有相对较高的相似度\(\text{0.95}\))。如果我们接着检查该主题,我们可以看到它确实是关于主题建模的:

1
2
3
4
5
6
7
8
9
10
11
topic_model.get_topic(22)
[('topic', 0.06634619076655907),
('topics', 0.035308535091932707),
('lda', 0.016386314730705634),
('latent', 0.013372311924864435),
('document', 0.012973600191120576),
('documents', 0.012383715497143821),
('modeling', 0.011978375291037142),
('dirichlet', 0.010078277589545706),
('word', 0.008505619415413312),
('allocation', 0.007930890698168108)]

虽然我们知道这个主题是关于主题建模的,但让我们看看 \(\text{BERTopic}\) 摘要是否也分配给了这个主题:

1
2
3
topic_model.topics_[titles.index("BERTopic: Neural topic modeling with a class-
based TF-IDF procedure")]
22

确实如此!这些功能使我们能够快速找到我们感兴趣的主题

💡 \(\text{BERTopic}\) 的模块化为您提供了很多选择,这可能会让人感到不知所措。为此,作者创建了一份最佳实践指南(https://maartengr.github.io/BERTopic/getting_started/best_practices/best_practices.html),其中介绍了加速训练、改进表征常见实践

为了让主题探索更容易一些,我们可以回顾我们的文本聚类示例。在那里,我们创建了一个静态可视化来查看所创建主题的总体结构。使用 \(\text{BERTopic}\),我们可以创建一个交互式变体,使我们能够快速探索存在哪些主题以及它们包含哪些文档

这样做要求我们使用我们用 \(\text{UMAP}\) 创建的二维嵌入 \(\text{reduced\_embeddings}\)。此外,当我们悬停在文档上时,我们将显示标题而不是摘要,以便快速了解主题中的文档:

1
2
3
4
5
6
7
8
9
# Visualize topics and documents
fig = topic_model.visualize_documents(
titles,
reduced_embeddings=reduced_embeddings,
width=1200,
hide_annotations=True
)
# Update fonts of legend for easier visualization
fig.update_layout(font=dict(size=16))

正如我们在图 \(\text{5}-18\) 中所看到的,这个交互式图表可以让我们快速了解所创建的主题。您可以放大查看单个文档,或双击右侧的主题以仅查看该主题

F5.1

\(\text{BERTopic}\) 中有多种可视化选项。有三种值得探索,以便了解主题之间的关系

1
2
3
4
5
6
# Visualize barchart with ranked keywords
topic_model.visualize_barchart()
# Visualize relationships between topics
topic_model.visualize_heatmap(n_clusters=30)
# Visualize the potential hierarchical structure of topics
topic_model.visualize_hierarchy()

添加一个特殊的乐高积木(主题表征增强)

Adding a Special Lego Block

到目前为止我们探索的 \(\text{BERTopic}\) 管线,尽管速度快且模块化,但有一个缺点:它仍然通过词袋来表示主题,而没有考虑到语义结构

解决方案是利用词袋表征的优势(即生成有意义表征的速度)。我们可以使用这第一个有意义的表征,并使用更强大但更慢的技术(如嵌入模型)对其进行调整。如图 \(\text{5}-19\) 所示,我们可以对初始的词语分布进行重新排序,以改进生成的表征。请注意,这种对初始结果集进行重新排序的想法是神经搜索中的主要方法,我们将在\(\text{8}\)中介绍这个主题。

F5.1

因此,我们可以设计一个新的乐高积木,如图 \(\text{5}-20\) 所示,它接受这个第一个主题表征输出一个改进的表征

F5.1

\(\text{BERTopic}\) 中,此类重新排序模型被称为表征模型\(\text{representation models}\))。这种方法的一个主要优势是主题表征的优化只需要对主题的数量执行相应的次数。例如,如果我们有数百万文档和一百个主题,表征模块只需要对每个主题应用一次,而不是对每个文档应用一次

如图 \(\text{5}-21\) 所示,\(\text{BERTopic}\) 已经设计了各种表征模块,允许您微调表征表征模块甚至可以多次堆叠,使用不同的方法来微调表征。

F5.1

在我们探索如何使用这些表征模块之前,我们首先需要做两件事。

首先,我们将保存我们原始的主题表征,以便更容易地与使用和不使用表征模型的版本进行比较

1
2
3
# Save original representations
from copy import deepcopy
original_topics = deepcopy(topic_model.topic_representations_)

其次,让我们创建一个简短的包装函数,我们可以用它来快速可视化主题词的差异,以便与使用和不使用表征模型进行比较:

1
2
3
4
5
6
7
8
9
def topic_differences(model, original_topics, nr_topics=5):
"""Show the differences in topic representations between two models """
df = pd.DataFrame(columns=["Topic", "Original", "Updated"])
for topic in range(nr_topics):
# Extract top 5 words per topic per model
og_words = " | ".join(list(zip(*original_topics[topic]))[0][:5])
new_words = " | ".join(list(zip(*model.get_topic(topic)))[0][:5])
df.loc[len(df)] = [topic, og_words, new_words]
return df

我们将探索的第一个表征模块\(\text{KeyBERTInspired}\)。正如您可能猜到的,\(\text{KeyBERTInspired}\) 是一种受关键词提取软件包 \(\text{KeyBERT}\) 启发的方法\(\text{KeyBERT}\) 通过余弦相似度比较单词和文档嵌入来提取文本中的关键词

\(\text{BERTopic}\) 采用了类似的方法。\(\text{KeyBERTInspired}\) 使用 \(\text{c}-\text{TF}-\text{IDF}\),通过计算文档的 \(\text{c}-\text{TF}-\text{IDF}\)与其对应主题的 \(\text{c}-\text{TF}-\text{IDF}\)之间的相似性,来提取每个主题最具代表性的文档。如图 \(\text{5}-22\) 所示,它计算每个主题的平均文档嵌入,并将其与候选关键词的嵌入进行比较,以重新排序关键词

F5.1

由于 \(\text{BERTopic}\) 的模块化特性,我们可以使用 \(\text{KeyBERTInspired}\)更新我们最初的主题表征,而无需执行降维和聚类步骤

1
2
3
4
5
6
from bertopic.representation import KeyBERTInspired
# Update our topic representations using KeyBERTInspired
representation_model = KeyBERTInspired()
topic_model.update_topics(abstracts, representation_model=representation_model)
# Show topic differences
topic_differences(topic_model, original_topics)

F5.1

更新后的模型显示,与原始模型相比,主题更易于阅读。它也展示了使用基于嵌入的技术缺点。原始模型中的词语,例如 \(\text{nmt}\)(主题 \(\text{3}\),代表神经机器翻译),被移除了,因为模型无法正确地表示这个实体。对于领域专家来说,这些缩写信息量非常大

使用 \(\text{c}-\text{TF}-\text{IDF}\) 和前面展示的 \(\text{KeyBERTInspired}\) 技术,我们仍然在生成的主题表征中存在显著的冗余。例如,在一个主题表征中同时拥有“\(\text{summaries}\)”和“\(\text{summary}\)”这两个词会引入冗余,因为它们非常相似

我们可以使用最大边缘相关性\(\text{Maximal Marginal Relevance, MMR}\))来使我们的主题表征多样化。该算法试图找到一组彼此多样化仍与被比较的文档相关联的关键词。它通过嵌入一组候选关键词迭代计算要添加的下一个最佳关键词来实现这一点。这样做需要设置一个多样性参数,该参数指示关键词需要的多样化程度

\(\text{BERTopic}\) 中,我们使用 \(\text{MMR}\) 将一组初始关键词(假设 \(\text{30}\) 个)缩小为一组更小但更多样化的关键词(假设 \(\text{10}\) 个)。它过滤掉冗余词,只保留那些对主题表征有新贡献的词语。

这样做相当简单:

1
2
3
4
5
6
from bertopic.representation import MaximalMarginalRelevance
# Update our topic representations to MaximalMarginalRelevance
representation_model = MaximalMarginalRelevance(diversity=0.2)
topic_model.update_topics(abstracts, representation_model=representation_model)
# Show topic differences
topic_differences(topic_model, original_topics)

F5.1

由此产生的主题在其表征中表现出更高的多样性。例如,主题 \(\text{4}\) 只显示一个“\(\text{summary}\)”类的词语,而是添加了其他可能对整体表征贡献更大的词语。

💡 \(\text{KeyBERTInspired}\)\(\text{MMR}\) 都是改进第一组主题表征的绝佳技术。\(\text{KeyBERTInspired}\) 尤其倾向于移除几乎所有停用词,因为它侧重于单词和文档之间的语义关系

文本生成乐高积木

The Text Generation Lego Block

在我们前面的示例中,\(\text{BERTopic}\) 中的表征模块一直充当重新排序模块。然而,正如我们在上一章中已经探讨过的,生成模型在各种任务中具有巨大的潜力。

我们可以通过遵循重新排序过程的一部分,在 \(\text{BERTopic}\) 中非常高效地使用生成模型。我们不使用生成模型来识别所有文档的主题(这可能涉及数百万文档),而是使用模型来为我们的主题生成一个标签。如图 \(\text{5}-23\) 所示,我们不生成或重新排序关键词,而是要求模型根据先前生成的关键词一小部分代表性文档生成一个简短的标签

F5.1

所展示的提示包含两个组成部分。

首先,使用 \(\text{[DOCUMENTS]}\) 标签插入的文档最能代表该主题的一小部分文档(通常是四个)。系统会选择\(\text{c}-\text{TF}-\text{IDF}\)主题 \(\text{c}-\text{TF}-\text{IDF}\)具有最高余弦相似度的文档。

其次,构成主题的关键词也会被传递给提示,并使用 \(\text{[KEYWORDS]}\) 标签引用。这些关键词可以由 \(\text{c}-\text{TF}-\text{IDF}\) 或我们迄今为止讨论的任何其他表征生成。

因此,我们只需要对每个主题(可能多达数百个)使用一次生成模型,而不是对每个文档(可能多达数百万个)使用一次。我们可以选择许多生成模型,包括开源专有模型。让我们从我们在上一章中探讨过的模型 \(\text{Flan}-\text{T5}\) 模型开始。

我们创建一个与该模型配合良好的提示,并通过 \(\text{representation\_model}\) 参数\(\text{BERTopic}\) 中使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from transformers import pipeline
from bertopic.representation import TextGeneration
prompt = """I have a topic that contains the following documents:
[DOCUMENTS]
The topic is described by the following keywords: '[KEYWORDS]'.
Based on the documents and keywords, what is this topic about?"""
# Update our topic representations using Flan-T5
generator = pipeline("text2text-generation", model="google/flan-t5-small")
representation_model = TextGeneration(

)
topic_model.update_topics(abstracts, representation_model=representation_model)
# Show topic differences
topic_differences(topic_model, original_topics)

F5.1

其中一些标签,例如“\(\text{Summarization}\)”(摘要),在与原始表征进行比较时似乎是合乎逻辑的。然而,其他一些标签,例如“\(\text{Science/Tech}\)”(科学/技术),看起来相当宽泛没有公正地体现原始主题。让我们转而探索 \(\text{OpenAI}\)\(\text{GPT}-\text{3.5}\) 模型的表现,考虑到该模型不仅更大,而且预期具有更强的语言能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import openai
from bertopic.representation import OpenAI
prompt =
"""
I have a topic that contains the following documents:
[DOCUMENTS]
The topic is described by the following keywords: [KEYWORDS]
Based on the information above, extract a short topic label in the following
format:
topic: <short topic label>
"""
# Update our topic representations using GPT-3.5
client = openai.OpenAI(api_key="YOUR_KEY_HERE")
representation_model = OpenAI(
client, model="gpt-3.5-turbo", exponential_backoff=True, chat=True,
prompt=prompt
)
topic_model.update_topics(abstracts, representation_model=representation_model)
# Show topic differences
topic_differences(topic_model, original_topics)

F5.1

生成的标签相当令人印象深刻!我们甚至没有使用 \(\text{GPT}-\text{4}\),但生成的标签似乎比我们上一个例子更具信息量。请注意,\(\text{BERTopic}\) 并不局限于只使用 \(\text{OpenAI}\) 的服务,它也支持本地后端

💡 尽管看起来我们不再需要关键词了,但它们仍然代表着输入文档没有模型是完美的,通常建议生成多个主题表征\(\text{BERTopic}\) 允许所有主题不同的表征来表示。例如,您可以同时使用 \(\text{KeyBERTInspired}\)\(\text{MMR}\)\(\text{GPT}-\text{3.5}\),以获得关于同一主题的不同视角

有了这些 \(\text{GPT}-\text{3.5}\) 生成的标签,我们可以使用 \(\text{datamapplot}\) 软件包创建精美的插图(图 \(\text{5}-24\)):

F5.1

1
2
3
4
5
6
7
8
9
10
# Visualize topics and documents
fig = topic_model.visualize_document_datamap(
titles,
topics=list(range(20)),
reduced_embeddings=reduced_embeddings,
width=1200,
label_font_size=11,
label_wrap_width=20,
use_medoids=True,
)

总结

在本章中,我们探讨了大型语言模型\(\text{LLM}\)),无论是生成式还是表征式的,如何应用于无监督学习领域。尽管近年来像分类这样的监督方法非常流行,但文本聚类无监督方法因其无需事先标记即可根据语义内容对文本进行分组的能力而具有巨大的潜力

我们介绍了一个聚类文本文档的通用管线,它首先将输入文本转换为数值表征,我们称之为嵌入。然后,对这些嵌入应用降维\(\text{dimensionality reduction}\)),以简化高维数据,从而获得更好的聚类结果。最后,将聚类算法应用于降维后的嵌入,对输入文本进行聚类。手动检查这些簇帮助我们理解它们包含哪些文档以及如何解释这些簇。

为了摆脱这种手动检查,我们探索了 \(\text{BERTopic}\) 如何通过一种自动表征簇的方法来扩展文本聚类管线。这种方法通常被称为主题建模\(\text{topic modeling}\)),它试图揭示大量文档中的主题\(\text{BERTopic}\) 通过词袋方法(并辅以 \(\text{c}-\text{TF}-\text{IDF}\) 进行增强)来生成这些主题表征,该方法根据单词的簇相关性和在所有簇中的频率加权单词

\(\text{BERTopic}\) 的一个主要优势是其模块化特性。在 \(\text{BERTopic}\) 中,您可以选择管线中的任何模型,这允许对主题进行额外的表征,从而为同一主题创建多个视角。我们探讨了最大边缘相关性\(\text{MMR}\))和 \(\text{KeyBERTInspired}\) 作为微调 \(\text{c}-\text{TF}-\text{IDF}\) 生成的主题表征的方法。此外,我们使用了与上一章相同的生成式 \(\text{LLM}\)\(\text{Flan}-\text{T5}\)\(\text{GPT}-\text{3.5}\))来生成高度可解释的标签,从而进一步提高主题的可解释性

在下一章中,我们将转移焦点,探索一种改进生成模型输出的常用方法,即提示工程\(\text{prompt engineering}\))。

书籍各章的机翻md文件:
《Hands-On Large Language Models》目录及前言
《Hands-On Large Language Models》第1章 大型语言模型简介
《Hands-On Large Language Models》第2章 词元与嵌入
《Hands-On Large Language Models》第3章 深入了解大型语言模型
《Hands-On Large Language Models》第4章 文本分类
《Hands-On Large Language Models》第5章 文本聚类和主题建模
《Hands-On Large Language Models》第6章 提示工程
《Hands-On Large Language Models》第7章 高级文本生成技术与工具
《Hands-On Large Language Models》第8章 语义搜索与检索增强生成
《Hands-On Large Language Models》第9章 多模态大型语言模型
《Hands-On Large Language Models》第10章 创建文本嵌入模型
《Hands-On Large Language Models》第11章 微调用于分类的表征模型
《Hands-On Large Language Models》第12章 生成模型的微调

第二部分 使用预训练语言模型

Using Pretrained Language Models

\(\text{4}\) 文本分类

Text Classification

分类自然语言处理中一个常见的任务。该任务的目标是训练一个模型,为某些输入文本分配一个标签或类别(参见图 \(\text{4}-1\))。文本分类被世界各地广泛应用于各种应用程序,从情感分析意图检测实体提取语言检测语言模型(无论是表征型还是生成型)对分类的影响是不可低估的。

F4.1

在本章中,我们将讨论使用语言模型进行文本分类的几种方法。它将作为使用已经训练过的语言模型的入门介绍。鉴于文本分类领域的广泛性,我们将讨论几种技术,并用它们来探索语言模型领域:

  • \(\text{113}\) 页的“使用表征模型进行文本分类” 演示了非生成模型用于分类的灵活性。我们将涵盖任务特定模型嵌入模型
  • \(\text{127}\) 页的“使用生成模型进行文本分类” 是对生成语言模型的介绍,因为它们中的大多数都可以用于分类。我们将涵盖一个开源和一个闭源的语言模型。

在本章中,我们将专注于利用预训练语言模型,即已经在大数据量上训练过并可用于文本分类的模型。如图 \(\text{4}-2\) 所示,我们将研究表征模型语言模型,并探讨它们的区别。

F4.1

本章将作为各种语言模型(包括生成型非生成型)的介绍。我们将遇到用于加载和使用这些模型的常见软件包

💡 尽管本书侧重于 \(\text{LLM}\),但强烈建议将这些示例与经典但强大的基线进行比较,例如使用 \(\text{TF}-\text{IDF}\) 表征文本,并在此基础上训练一个逻辑回归分类器

电影评论的情感

The Sentiment of Movie Reviews

您可以在 \(\text{Hugging Face Hub}\) 上找到我们用于探索文本分类技术的数据,这是一个托管模型也托管数据的平台。我们将使用著名的 \(\text{rotten\_tomatoes}\)”数据集来训练和评估我们的模型。它包含了来自烂番茄的 \(\text{5,331}\) 条正面\(\text{5,331}\) 条负面电影评论。

为了加载这些数据,我们使用了 \(\text{datasets}\) 软件包,该软件包将在本书中一直使用:

1
2
3
4
from datasets import load_dataset
# Load our data
data = load_dataset("rotten_tomatoes")
data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
DatasetDict({
train: Dataset({
features: ['text', 'label'],
num_rows: 8530
})
validation: Dataset({
features: ['text', 'label'],
num_rows: 1066
})
test: Dataset({
features: ['text', 'label'],
num_rows: 1066
})
})

数据被分割成训练集 (\(\text{train}\))、测试集 (\(\text{test}\)) 和验证集 (\(\text{validation}\))。在本章中,我们在训练模型时将使用训练集,在验证结果时将使用测试集。请注意,如果您使用训练集和测试集进行超参数调整,可以利用额外的验证集来进一步验证泛化能力

The data is split up into train, test, and validation splits. Throughout this chapter, we will use the train split when we train a model and the test split for validating the results. Note that the additional validation split can be used to further validate generalization if you used the train and test splits to perform hyperparameter tuning.

个人注:train用来训练模型,validattion用来测试训练过程中不同轮次的效果;test用来测试模型的泛化能力。

让我们看一下训练集中的一些示例:

1
data["train"][0, -1]
1
2
3
4
5
6
{'text': ['the rock is destined to be the 21st century\'s new " conan " and
that he\'s going to make a splash even greater than arnold schwarzenegger ,
jean-claud van damme or steven segal .',
'things really get weird , though not particularly scary : the movie is all
portent and no content .'],
'label': [1, 0]}

这些简短的评论要么被标记为正面 (\(\text{1}\)),要么被标记为负面 (\(\text{0}\))。这意味着我们将专注于二元情感分类

使用表征模型进行文本分类

Text Classification with Representation Models

使用预训练表征模型进行分类通常有两种形式,即使用任务特定模型嵌入模型。正如我们在上一章中探讨的那样,这些模型是通过将基础模型(如 \(\text{BERT}\))在特定的下游任务上进行微调而创建的,如图 \(\text{4}-3\) 所示。

F4.1

任务特定模型是一个表征模型,例如 \(\text{BERT}\),它是为特定任务(如情感分析)训练的。正如我们在\(\text{1}\)中探讨的那样,嵌入模型生成通用嵌入,可用于各种任务,不限于分类,例如语义搜索(参见\(\text{8}\))。

用于分类的 \(\text{BERT}\) 模型的微调过程将在\(\text{11}\)中介绍,而创建嵌入模型将在\(\text{10}\)中介绍。在本章中,我们将保持这两个模型是冻结的不可训练),仅使用它们的输出,如图 \(\text{4}-4\) 所示。

F4.1

我们将利用其他人已经为我们微调好的预训练模型,并探索如何使用它们来分类我们选定的电影评论。

模型选择

Model Selection

选择合适的模型并非您想象的那么简单,因为在撰写本文时,\(\text{Hugging Face Hub}\) 上有超过 \(\text{60,000}\) 个文本分类模型超过 \(\text{8,000}\) 个生成嵌入的模型。此外,选择一个适合您的用例的模型至关重要,并且需要考虑其语言兼容性底层架构大小性能

让我们从底层架构开始。正如我们在\(\text{1}\)中探讨的那样,\(\text{BERT}\)(一个著名的仅编码器 (\(\text{encoder-only}\)) 架构)是创建任务特定模型嵌入模型热门选择。虽然像 \(\text{GPT}\) 家族这样的生成模型令人难以置信,但仅编码器模型任务特定的用例中同样表现出色,并且往往规模显著更小

多年来,\(\text{BERT}\) 的许多变体已被开发出来,包括 \(\text{RoBERTa}\)\(\text{DistilBERT}\)\(\text{ALBERT}\)\(\text{DeBERTa}\),每个都在各种上下文中进行训练。您可以在图 \(\text{4}-5\) 中找到一些著名的类 \(\text{BERT}\) 模型的概述。

F4.1

为工作选择合适的模型本身可以算作一门艺术。尝试 \(\text{Hugging Face}\) \(\text{Hub}\) 上可以找到的数千个预训练模型不可行的,因此我们需要高效地选择模型。话虽如此,有几个模型是很好的起点,并能让您了解这类模型的基线性能。请将它们视为可靠的基线

  • \(\text{BERT}\) 基础模型 (\(\text{uncased}\))
  • \(\text{RoBERTa}\) 基础模型
  • \(\text{DistilBERT}\) 基础模型 (\(\text{uncased}\))
  • \(\text{DeBERTa}\) 基础模型
  • \(\text{bert-tiny}\)
  • \(\text{ALBERT}\) 基础 \(\text{v}2\)

对于任务特定模型,我们选择 \(\text{Twitter-RoBERTa-base}\) 用于情感分析的模型。这是一个在 \(\text{tweets}\) 上为情感分析微调\(\text{RoBERTa}\) 模型。虽然它不是专门为电影评论训练的,但探索这个模型如何泛化是很有趣的。

在选择用于生成嵌入的模型时,\(\text{MTEB}\) 排行榜(https://huggingface.co/spaces/mteb/leaderboard)是一个很好的起点。它包含了在多项任务上进行基准测试开源和闭源模型。请确保不仅考虑性能。在实际解决方案中,推理速度的重要性不容低估。因此,在本节中,我们将使用 \(\text{sentence-transformers/all-mpnet-base-v2}\) 作为嵌入模型。它是一个小巧但性能出色的模型。

使用任务特定模型

Using a Task-Specific Model

既然我们已经选择了任务特定的表征模型,让我们从加载模型开始:

1
2
3
4
5
6
7
8
9
10
from transformers import pipeline
# Path to our HF model
model_path = "cardiffnlp/twitter-roberta-base-sentiment-latest"
# Load model into pipeline
pipe = pipeline(
model=model_path,
tokenizer=model_path,
return_all_scores=True,
device="cuda:0"
)

在加载模型时,我们也加载了分词器\(\text{tokenizer}\)),它负责将输入文本转换为单个词元\(\text{token}\)),如图 \(\text{4}-6\) 所示。虽然加载时并非必须指定此参数,因为它会自动加载,但它说明了底层发生的情况

F4.1

正如\(\text{2}\)深入探讨的那样,这些词元是大多数语言模型的核心。这些词元的一个主要好处是,即使它们不在训练数据中,也可以将它们组合起来生成表征,如图 \(\text{4}-7\) 所示。

F4.1

加载所有必需的组件后,我们就可以继续在数据的测试集上使用我们的模型:

1
2
3
4
5
6
7
8
9
10
11
import numpy as np
from tqdm import tqdm
from transformers.pipelines.pt_utils import KeyDataset
# Run inference
y_pred = []
for output in tqdm(pipe(KeyDataset(data["test"], "text")),
total=len(data["test"])):
negative_score = output[0]["score"]
positive_score = output[2]["score"]
assignment = np.argmax([negative_score, positive_score])
y_pred.append(assignment)

现在我们已经生成了预测结果,剩下的就是评估。我们创建一个小的函数,可以在本章中轻松使用:

1
2
3
4
5
6
7
8
9
from sklearn.metrics import classification_report
def evaluate_performance(y_true, y_pred):
"""Create and print the classification report"""
performance = classification_report(
y_true, y_pred,
target_names=["Negative Review", "Positive Review"]

)
print(performance)

接下来,让我们创建分类报告

1
evaluate_performance(data["test"]["label"], y_pred)

F4.1

要解读生成的分类报告,我们首先探讨如何识别正确和不正确的预测。根据我们的预测是正确\(\text{True}\))还是不正确\(\text{False}\)),以及我们预测的类别是正确类别\(\text{Positive}\))还是不正确类别\(\text{Negative}\)),共有四种组合。我们可以将这些组合表示为一个矩阵,通常称为混淆矩阵\(\text{confusion matrix}\)),如图 \(\text{4}-8\) 所示。

F4.1

利用混淆矩阵,我们可以推导出几种公式来描述模型的质量。在前面生成的分类报告中,我们可以看到四种这样的度量方法,即精确率\(\text{precision}\))、召回率\(\text{recall}\))、准确率\(\text{accuracy}\))和 \(\text{F}1\) 分数

  • 精确率\(\text{Precision}\))衡量的是找到的项目中有多少是相关的,这表明了相关结果的准确性
  • 召回率\(\text{Recall}\))指的是找到了多少相关的类别,这表明了其发现所有相关结果的能力
  • 准确率\(\text{Accuracy}\))指的是模型在所有预测中做出正确预测的次数,这表明了模型的总体正确性
  • \(\text{F}1\) 分数\(\text{The F1 score}\)平衡了精确率和召回率,以创建模型的总体性能指标

这四个指标如图 \(\text{4}-9\) 所示,该图使用前面提到的分类报告来描述它们。

F4.1

在本书的示例中,我们将考虑 \(\text{F}1\) 分数的加权平均值\(\text{weighted average of the F1 score}\)),以确保每个类别都得到平等对待。我们预训练的 \(\text{BERT}\) 模型为我们提供了 \(\text{0.80}\)\(\text{F}1\) 分数(我们是从 \(\text{weighted avg}\)\(\text{f1-score}\)读取的),这对于一个不是专门针对我们领域数据训练的模型来说是非常出色的!

为了提高我们所选模型的性能,我们可以做几件不同的事情,包括选择一个在我们领域数据(在本例中是电影评论)上训练的模型,比如 \(\text{DistilBERT base uncased finetuned SST}-2\)。我们也可以将重点转向另一种风味的表征模型,即嵌入模型

利用嵌入的分类任务

Classification Tasks That Leverage Embeddings

在前面的示例中,我们使用了预训练的任务特定模型进行情感分析。但是,如果我们找不到一个针对该特定任务进行预训练的模型怎么办?我们是否需要自己微调一个表征模型?答案是否定的!

如果您拥有足够的计算资源,有时您可能希望自己微调模型(参见\(\text{11}\))。然而,并非每个人都能获得大量计算资源。这就是通用嵌入模型发挥作用的地方。

监督分类

Supervised Classification

与前面的示例不同,我们可以从一个更经典的视角入手,自己完成部分训练过程。我们不直接使用表征模型进行分类,而是使用嵌入模型生成特征。然后,这些特征可以输入到分类器中,从而创建一个两步法,如图 \(\text{4}-10\) 所示。

F4.1

这种分离的一个主要好处是,我们不需要微调我们的嵌入模型,这可能是昂贵的。相比之下,我们可以\(\text{CPU}\) 上训练一个分类器,比如逻辑回归

第一步中,我们使用嵌入模型文本输入转换为嵌入,如图 \(\text{4}-11\) 所示。请注意,该模型同样保持冻结在训练过程中不会被更新

F4.1

我们可以使用 \(\text{sentence-transformer}\)(一个用于利用预训练嵌入模型的流行软件包)来执行此步骤。创建嵌入非常简单:

1
2
3
4
5
6
from sentence_transformers import SentenceTransformer
# Load model
model = SentenceTransformer("sentence-transformers/all-mpnet-base-v2")
# Convert text to embeddings
train_embeddings = model.encode(data["train"]["text"], show_progress_bar=True)
test_embeddings = model.encode(data["test"]["text"], show_progress_bar=True)

正如我们在\(\text{1}\)中介绍的那样,这些嵌入输入文本的数值表征。嵌入的数值数量维度取决于底层的嵌入模型。让我们探究我们模型的维度:

1
train_embeddings.shape
1
(8530, 768)

这表明我们 \(\text{8,530}\) 个输入文档中的每个都有一个 \(\text{768}\) 的嵌入维度,因此每个嵌入包含 \(\text{768}\) 个数值

第二步中,这些嵌入作为分类器输入特征,如图 \(\text{4}-12\) 所示。该分类器是可训练的不限于逻辑回归,只要它执行分类,就可以采用任何形式

F4.1

我们将使这一步骤保持简单,并使用逻辑回归作为分类器。要训练它,我们只需使用生成的嵌入以及我们的标签

1
2
3
4
from sklearn.linear_model import LogisticRegression
# Train a logistic regression on our train embeddings
clf = LogisticRegression(random_state=42)
clf.fit(train_embeddings, data["train"]["label"])

接下来,让我们评估我们的模型:

1
2
3
# Predict previously unseen instances
y_pred = clf.predict(test_embeddings)
evaluate_performance(data["test"]["label"], y_pred)

F4.1

通过在我们的嵌入之上训练一个分类器,我们设法获得了 \(\text{0.85}\)\(\text{F}1\) 分数!这证明了在保持底层嵌入模型冻结的情况下,训练一个轻量级分类器的可能性。

💡 在本例中,我们使用 \(\text{sentence}-\text{transformers}\) 来提取我们的嵌入,它受益于 \(\text{GPU}\) 来加速推理。然而,我们可以通过使用外部 \(\text{API}\) 来创建嵌入从而移除这种 \(\text{GPU}\) 依赖。生成嵌入的流行选择是 \(\text{Cohere}\)\(\text{OpenAI}\) 提供的服务。因此,这将允许整个管线完全在 \(\text{CPU}\) 上运行

如果我们没有带标签的数据怎么办?

What If We Do Not Have Labeled Data?

在我们之前的例子中,我们有可以利用的带标签的数据,但在实践中并非总是如此。获取带标签的数据是一项资源密集型任务,可能需要大量的人力。此外,收集这些标签是否真的值得

为了验证这一点,我们可以执行零样本分类\(\text{zero-shot classification}\)),即我们没有带标签的数据,来探索该任务是否可行。尽管我们知道标签的定义(它们的名称),但我们没有带标签的数据来支持它们。零样本分类尝试预测输入文本的标签,即使它没有经过这些标签的训练,如图 \(\text{4}-13\) 所示。

F4.1

要使用嵌入进行零样本分类,我们可以使用一个巧妙的技巧。我们可以根据标签应代表的内容描述它们。例如,电影评论的负面标签可以被描述为“\(\text{This is a negative movie review}\)”(这是一条负面电影评论)。通过描述和嵌入\(\text{embedding}\)标签和文档,我们就有可以处理的数据了。如图 \(\text{4}-14\) 所示,这个过程允许我们生成自己的目标标签,而无需实际拥有任何带标签的数据

F4.1

我们可以像前面那样使用 \(\text{.encode}\) 函数来创建这些标签嵌入

1
2
# Create embeddings for our labels
label_embeddings = model.encode(["A negative review", "A positive review"])

为了给文档分配标签,我们可以对文档-标签对应用余弦相似度\(\text{cosine similarity}\))。这是向量之间夹角的余弦值,通过嵌入的点积除以它们的长度的乘积来计算,如图 \(\text{4}-15\) 所示。

F4.1

我们可以使用余弦相似度来检查给定文档与候选标签描述的相似程度。与文档相似度最高的标签被选中,如图 \(\text{4}-16\) 所示。

F4.1

要在嵌入上执行余弦相似度,我们只需比较文档嵌入标签嵌入并找到最匹配的对

1
2
3
4
from sklearn.metrics.pairwise import cosine_similarity
# Find the best matching label for each document
sim_matrix = cosine_similarity(test_embeddings, label_embeddings)
y_pred = np.argmax(sim_matrix, axis=1)

就这样!我们只需要为我们的标签想出名称就可以执行我们的分类任务。让我们看看这种方法的效果如何:

1
evaluate_performance(data["test"]["label"], y_pred)

F4.1

如果您熟悉使用基于 \(\text{Transformer}\) 的模型进行零样本分类,您可能会想知道为什么我们选择用嵌入而不是 \(\text{Transformer}\) 模型来演示它。虽然自然语言推理模型在零样本分类方面表现出色,但这里的示例旨在展示嵌入在各种任务中的灵活性。正如您将在本书中看到的那样,嵌入几乎存在于所有语言 \(\text{AI}\) 用例中,而且通常是一个被低估但极其重要的组件

考虑到我们根本没有使用任何带标签的数据\(\text{0.78}\)\(\text{F}1\) 分数相当令人印象深刻的!这正好说明了嵌入是多么通用和有用,特别是如果你在使用它们时能发挥一点创意

让我们来测试一下这种创意。我们决定使用“一个负面/正面评论”作为标签名称,但这可以改进。相反,我们可以通过使用“一个非常负面/正面的电影评论”来使它们更具体、更贴近我们的数据。这样,嵌入将捕捉到它是一个电影评论,并将更关注两个标签的极端请尝试一下,探索它如何影响结果。

使用生成模型进行文本分类

Text Classification with Generative Models

使用生成语言模型(例如 \(\text{OpenAI}\)\(\text{GPT}\) 模型)进行分类,与我们迄今为止所做的工作略有不同。这些模型将文本作为输入生成文本,因此被恰当地命名为序列到序列模型\(\text{sequence}-\text{to}-\text{sequence models}\))。这与我们的任务特定模型形成了鲜明对比,后者输出的是一个类别,如图 \(\text{4}-17\) 所示。

F4.1

这些生成模型通常在各种任务上进行训练,并且通常不能直接执行您的用例。例如,如果我们给一个生成模型一个没有任何上下文的电影评论,它不知道该如何处理。

相反,我们需要帮助它理解上下文引导它找到我们正在寻找的答案。如图 \(\text{4}-18\) 所示,这个引导过程主要是通过您提供给模型的指令提示\(\text{prompt}\))来完成的。迭代地改进您的提示以获得您偏好的输出被称为提示工程\(\text{prompt engineering}\))。

F4.1

在本节中,我们将演示如何利用不同类型的生成模型来使用我们的烂番茄数据集进行分类。

使用文本到文本传输 \(\text{Transformer}\)

Using the Text-to-Text Transfer Transformer

在本书中,我们将主要探讨仅编码器(表征)模型(如 \(\text{BERT}\))和仅解码器(生成)模型(如 \(\text{ChatGPT}\))。然而,正如\(\text{1}\)所讨论的,原始 \(\text{Transformer}\) 架构实际上由编码器-解码器架构组成。与仅解码器模型一样,这些编码器-解码器模型序列到序列模型,通常属于生成模型的范畴。

利用这种架构的一个有趣的模型家族文本到文本传输 \(\text{Transformer}\)\(\text{T5}\) 模型。如图 \(\text{4}-19\) 所示,它的架构类似于原始 \(\text{Transformer}\),其中\(\text{12}\) 个解码器和 \(\text{12}\) 个编码器堆叠在一起。

F4.1

借助这种架构,这些模型首先使用掩码语言建模\(\text{masked language modeling}\))进行预训练。如图 \(\text{4}-20\) 所示,在训练的第一步中,在预训练期间,被掩盖的不是单个词元,而是词元集(或词元跨度)。

F4.1

训练的第二步,即对基础模型进行微调,才是真正神奇的地方。不是针对一个特定任务对模型进行微调,而是将每个任务都转换为序列到序列任务并同时进行训练。如图 \(\text{4}-21\) 所示,这使得模型能够在各种任务上进行训练。

F4.1

这种微调方法在论文《扩展指令微调语言模型》 中得到了扩展,该论文在微调过程中引入了一千多个任务,使其更紧密地遵循我们从 \(\text{GPT}\) 模型中了解到的指令。这产生了 \(\text{Flan-T5}\) 家族的模型,它们受益于这种广泛的任务多样性

为了使用这个预训练的 \(\text{Flan-T5}\) 模型进行分类,我们将首先通过 \(\text{text}2\text{text}-\text{generation}\)”任务来加载它,该任务通常是为这些编码器-解码器模型保留的:

1
2
3
4
5
6
# Load our model
pipe = pipeline(
"text2text-generation",
model="google/flan-t5-small",
device="cuda:0"
)

\(\text{Flan-T5}\) 模型有各种尺寸(\(\text{flan}-\text{t5}-\text{small}/\text{base}/\text{large}/\text{xl}/\text{xxl}\)),我们将使用最小的来稍微加快速度。不过,您可以随意尝试更大的模型,看看是否可以改进结果。

与我们的任务特定模型相比,我们不能只是给模型一些文本,然后希望它能输出情感。相反,我们必须指示模型这样做。

因此,我们为每个文档加上\(\text{Is the following sentence positive or negative?}\)”的提示:

1
2
3
4
# Prepare our data
prompt = "Is the following sentence positive or negative? "
data = data.map(lambda example: {"t5": prompt + example['text']})
data

F4.1

在创建了我们更新后的数据之后,我们可以运行管线,类似于任务特定模型的示例:

1
2
3
4
5
6
# Run inference
y_pred = []
for output in tqdm(pipe(KeyDataset(data["test"], "t5")),
total=len(data["test"])):
text = output[0]["generated_text"]
y_pred.append(0 if text == "negative" else 1)

由于这个模型生成文本,我们确实需要将文本输出转换为数值。输出词“\(\text{negative}\)”被映射为 \(\text{0}\),而“\(\text{positive}\)”被映射为 \(\text{1}\)

现在,这些数值允许我们以与之前相同的方式测试模型的质量:

1
evaluate_performance(data["test"]["label"], y_pred)

F4.1

\(\text{F}1\) 分数为 \(\text{0.84}\),显然这款 \(\text{Flan}-\text{T5}\) 模型生成模型能力惊人初探

\(\text{ChatGPT}\) 用于分类

ChatGPT for Classification

尽管我们在本书中主要关注开源模型,但语言 \(\text{AI}\) 领域的另一个主要组成部分闭源模型,特别是 \(\text{ChatGPT}\)。虽然原始 \(\text{ChatGPT}\) 模型 (\(\text{GPT}-3.5\)) 的底层架构并未公开,但从它的名称我们可以推断,它基于我们迄今为止在 \(\text{GPT}\) 模型中看到的仅解码器架构

幸运的是,\(\text{OpenAI}\) 分享了训练过程的概述,其中涉及一个重要组成部分,即偏好调优\(\text{preference tuning}\))。如图 \(\text{4}-22\) 所示,\(\text{OpenAI}\) 首先手动创建了对输入提示(instruction data 指令数据)的期望输出,并使用该数据创建了其模型的第一个变体。

F4.1

\(\text{OpenAI}\) 使用由此产生的模型生成了多个输出,并手动将其从最好到最差进行排名。如图 \(\text{4}-23\) 所示,这个排名展示了对某些输出的偏好偏好数据),并被用于创建其最终模型 \(\text{ChatGPT}\)

F4.1

使用偏好数据(preference data)而非指令数据(instruction data)的一个主要好处是它所代表的细微差别。通过展示好输出和更好输出之间的区别,生成模型学会了生成符合人类偏好的文本。在\(\text{12}\)中,我们将探讨这些微调偏好调优方法的工作原理,以及如何自己进行

使用闭源模型的过程与我们迄今为止看到的开源示例大不相同。我们不加载模型,而是通过 \(\text{OpenAI}\)\(\text{API}\) 来访问模型。

在我们深入研究分类示例之前,您首先需要在 https://oreil.ly/AEXvA 上创建一个免费账户,并在 https://oreil.ly/lrTXl 创建一个 \(\text{API}\) 密钥。完成之后,您就可以使用您的 \(\text{API}\)\(\text{OpenAI}\) 的服务器进行通信。

我们可以使用此密钥来创建客户端

1
2
3
import openai
# Create client
client = openai.OpenAI(api_key="YOUR_KEY_HERE")

使用这个客户端,我们创建了 \(\text{chatgpt\_generation}\) 函数,它允许我们根据特定的提示、输入文档选定的模型生成文本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def chatgpt_generation(prompt, document, model="gpt-3.5-turbo-0125"):
"""Generate an output based on a prompt and an input document."""
messages=[
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": prompt.replace("[DOCUMENT]", document)
}
]
chat_completion = client.chat.completions.create(
messages=messages,
model=model,
temperature=0
)
return chat_completion.choices[0].message.content

接下来,我们将需要创建一个模板来要求模型执行分类:

1
2
3
4
5
6
7
8
9
10
# Define a prompt template as a base
prompt = """Predict whether the following document is a positive or negative
movie review:
[DOCUMENT]
If it is positive return 1 and if it is negative return 0. Do not give any
other answers.
"""
# Predict the target using GPT
document = "unpretentious , charming , quirky , original"
chatgpt_generation(prompt, document)

这个模板只是一个示例,您可以随意更改。目前,我们将其保持尽可能简单,以说明如何使用这样的模板。

在您将此方法用于一个潜在的大数据集之前,始终跟踪您的使用量非常重要。如果您执行大量请求,像 \(\text{OpenAI}\) 提供的外部 \(\text{API}\) 可能会很快变得昂贵。在撰写本文时,使用 \(\text{gpt}-\text{3.5}-\text{turbo}-\text{0125}\)”模型运行我们的测试数据集成本为 \(\text{3}\) 美分,这包含在免费账户内,但未来可能会有所变化。

💡 在处理外部 \(\text{API}\) 时,您可能会遇到速率限制错误\(\text{rate limit errors}\))。当您过于频繁地调用 \(\text{API}\) 时,就会出现这些错误,因为某些 \(\text{API}\) 可能会限制您每分钟或每小时的使用速率。为了防止这些错误,我们可以实现几种重试请求的方法,包括一种称为指数退避\(\text{exponential backoff}\))的方法。它会在每次遇到速率限制错误时执行短暂的休眠,然后重试失败的请求。如果再次失败,休眠时间会增加,直到请求成功或达到最大重试次数

要将其与 \(\text{OpenAI}\) 配合使用,有一个很好的指南可以帮助您入门。

接下来,我们可以对测试数据集中的所有评论运行此函数,以获取其预测结果。如果您想节省您的(免费)额度用于其他任务,可以跳过此步骤。

1
2
3
4
# You can skip this if you want to save your (free) credits
predictions = [
chatgpt_generation(prompt, doc) for doc in tqdm(data["test"]["text"])
]

与前面的示例一样,我们需要将输出从字符串转换为整数以评估其性能:

1
2
3
4
# Extract predictions
y_pred = [int(pred) for pred in predictions]
# Evaluate performance
evaluate_performance(data["test"]["label"], y_pred)

F4.1

\(\text{0.91}\)\(\text{F}1\) 分数已经让我们一睹将生成式 \(\text{AI}\) 带给大众的模型的性能。然而,由于我们不知道该模型是在什么数据上训练的,我们不能轻易使用这些类型的指标来评估模型。据我们所知,它很可能在我们的数据集上进行过训练!

\(\text{12}\)中,我们将探讨如何在一些更具普遍性的任务评估开源和闭源模型

总结

在本章中,我们讨论了执行各种分类任务的许多不同技术,从微调整个模型完全不进行调优对文本数据进行分类并非表面上看起来那么简单,其中蕴含着令人难以置信的创意技术

在本章中,我们探索了使用生成式表征式语言模型进行文本分类。我们的目标是为输入文本分配一个标签或类别,以进行评论情感的分类

我们探索了两种类型的表征模型任务特定模型嵌入模型任务特定模型是在一个大型数据集上专门为情感分析预训练的,并向我们展示了预训练模型是分类文档的绝佳技术。嵌入模型用于生成多用途嵌入,我们将其用作训练分类器的输入。

类似地,我们探索了两种类型的生成模型:一个开源编码器-解码器模型\(\text{Flan}-\text{T5}\))和一个闭源仅解码器模型\(\text{GPT}-\text{3.5}\))。我们在文本分类中使用了这些生成模型,而不需要对领域数据或带标签的数据集进行特定(额外)的训练

在下一章中,我们将继续进行分类,但重点将转向无监督分类。如果我们的文本数据没有任何标签,我们可以做些什么?我们可以提取什么信息?我们将专注于对数据进行聚类以及使用主题建模技术为聚类进行命名。

书籍各章的机翻md文件:
《Hands-On Large Language Models》目录及前言
《Hands-On Large Language Models》第1章 大型语言模型简介
《Hands-On Large Language Models》第2章 词元与嵌入
《Hands-On Large Language Models》第3章 深入了解大型语言模型
《Hands-On Large Language Models》第4章 文本分类
《Hands-On Large Language Models》第5章 文本聚类和主题建模
《Hands-On Large Language Models》第6章 提示工程
《Hands-On Large Language Models》第7章 高级文本生成技术与工具
《Hands-On Large Language Models》第8章 语义搜索与检索增强生成
《Hands-On Large Language Models》第9章 多模态大型语言模型
《Hands-On Large Language Models》第10章 创建文本嵌入模型
《Hands-On Large Language Models》第11章 微调用于分类的表征模型
《Hands-On Large Language Models》第12章 生成模型的微调

\(\text{3}\) 深入了解大型语言模型

Looking Inside Large Language Models

既然我们已经对分词嵌入有所了解,我们就可以更深入地研究语言模型,看看它是如何工作的。在本章中,我们将探讨 \(\text{Transformer}\) 语言模型工作方式的一些主要直觉。我们将特别关注文本生成模型,以便我们对生成式 \(\text{LLM}\) 有更深入的理解。

我们将同时关注概念和一些演示这些概念的代码示例。让我们从加载一个语言模型并通过声明一个管线\(\text{pipeline}\))来为生成做好准备。在您第一次阅读时,请随意跳过代码,专注于掌握所涉及的概念。然后在第二次阅读时,代码将引导您开始应用这些概念。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
# Load model and tokenizer
tokenizer = AutoTokenizer.from_pretrained("microsoft/Phi-3-mini-4k-instruct")
model = AutoModelForCausalLM.from_pretrained(
"microsoft/Phi-3-mini-4k-instruct",
device_map="cuda",
torch_dtype="auto",
trust_remote_code=True,
)
# Create a pipeline
generator = pipeline(
"text-generation",
model=model,
tokenizer=tokenizer,
return_full_text=False,
max_new_tokens=50,
do_sample=False,
)

\(\text{Transformer}\) 模型概述

An Overview of Transformer Models

让我们从对模型的高层概述开始我们的探索,然后我们将看看自 \(\text{2017}\) 年推出以来,后来的工作是如何改进 \(\text{Transformer}\) 模型的。

一个经过训练的 \(\text{Transformer LLM}\) 的输入和输出

The Inputs and Outputs of a Trained Transformer LLM

理解 \(\text{Transformer LLM}\) 行为的最常见视角是将其视为一个软件系统,它接收文本生成文本作为回应。一旦在一个足够大的高质量数据集上训练了一个足够大文本输入-文本输出模型,它就能生成令人印象深刻且有用的输出。图 \(\text{3}-1\) 展示了这样一个用于撰写电子邮件的模型。

F3.1

模型并非一次性生成所有文本;它实际上是一次生成一个词元。图 \(\text{3}-2\) 展示了响应输入提示的四个词元生成步骤每个词元生成步骤都是模型的一次正向传播\(\text{forward pass}\))(这是机器学习的说法,指输入进入神经网络并流经所需的计算,以在计算图的另一端产生一个输出)。

F3.1

每次词元生成之后,我们通过将输出词元附加到输入提示的末尾来调整下一次生成步骤的输入提示。我们可以在图 \(\text{3}-3\) 中看到这一点。

F3.1

这给了我们一个更准确的模型图景,因为它只是根据输入提示预测下一个词元。神经网络周围的软件基本上是在一个循环中运行它,以顺序地扩展生成的文本直到完成。

在机器学习中,有一个特定的词语来描述消耗其早期预测以做出后期预测的模型(例如,模型的第一个生成的词元用于生成第二个词元)。它们被称为自回归模型\(\text{autoregressive models}\))。这就是为什么你会听到文本生成 \(\text{LLM}\) 被称为自回归模型。这通常用于区分文本生成模型和非自回归文本表征模型,例如 \(\text{BERT}\)

这种自回归的、逐词元生成就是当我们使用 \(\text{LLM}\) 生成文本时在底层发生的事情,就像我们在这里看到的那样:

1
2
3
4
prompt = "Write an email apologizing to Sarah for the tragic gardening mishap.
Explain how it happened."
output = generator(prompt)
print(output[0]['generated_text'])

这生成了文本:

1
2
3
4
Solution 1:
Subject: My Sincere Apologies for the Gardening Mishap
Dear Sarah,
I hope this message finds you well. I am writing to express my deep

我们可以看到模型开始撰写电子邮件,从主题开始。它突然停止了,因为它达到了我们通过设置 \(\text{max\_new\_tokens}\)\(\text{50}\) 个词元所确定的词元限制。如果我们增加这个限制,它将继续直到完成电子邮件。

正向传播的组成部分

The Components of the Forward Pass

除了循环之外,两个关键的内部组件是分词器\(\text{tokenizer}\))和语言模型头\(\text{LM head}\))。图 \(\text{3}-4\) 展示了这些组件在系统中的位置。我们在上一章中看到,分词器如何将文本分解为词元 \(\text{ID}\) 序列,然后这些序列成为模型的输入。

F3.1

分词器之后是神经网络:一个\(\text{Transformer}\) 块的堆栈,它负责所有的处理工作。该堆栈之后是 \(\text{LM}\),它将堆栈的输出转换为下一个最有可能的词元概率分数

回想一下\(\text{2}\),分词器包含一个词元表——分词器的词汇表。模型为词汇表中的每个词元关联了一个向量表征词元嵌入)。图 \(\text{3}-5\) 展示了一个词汇量为 \(\text{5}\) 万词元的模型的词汇表和相关的词元嵌入

F3.1

计算流程遵循从上到下的箭头方向。对于每个生成的词元,流程会依次流经堆栈中的每个 \(\text{Transformer}\) 块一次,然后到达 \(\text{LM}\)\(\text{LM}\) 头最终输出下一个词元的概率分布,如图 \(\text{3}-6\) 所示。

F3.1

\(\text{LM}\)本身是一个简单的神经网络层。它是可以附加到 \(\text{Transformer}\) 块堆栈上以构建不同类型系统的多种可能“头” 之一。其他类型的 \(\text{Transformer}\) 头包括序列分类头词元分类头

我们可以通过简单地打印出 \(\text{model}\) 变量来显示层级的顺序。对于这个模型,我们有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Phi3ForCausalLM(
(model): Phi3Model(
(embed_tokens): Embedding(32064, 3072, padding_idx=32000)
(embed_dropout): Dropout(p=0.0, inplace=False)
(layers): ModuleList(
(0-31): 32 x Phi3DecoderLayer(
(self_attn): Phi3Attention(
(o_proj): Linear(in_features=3072, out_features=3072, bias=False)
(qkv_proj): Linear(in_features=3072, out_features=9216, bias=False)
(rotary_emb): Phi3RotaryEmbedding()
)
(mlp): Phi3MLP(
(gate_up_proj): Linear(in_features=3072, out_features=16384,
bias=False)
(down_proj): Linear(in_features=8192, out_features=3072, bias=False)
(activation_fn): SiLU()
)
(input_layernorm): Phi3RMSNorm()
(resid_attn_dropout): Dropout(p=0.0, inplace=False)
(resid_mlp_dropout): Dropout(p=0.0, inplace=False)
(post_attention_layernorm): Phi3RMSNorm()
)
)
)
(norm): Phi3RMSNorm()
(lm_head): Linear(in_features=3072, out_features=32064, bias=False)
)

查看这个结构,我们可以注意到以下重点:

  • 这向我们展示了模型的各种嵌套层。模型的主体标记为 \(\text{model}\),紧随其后的是 \(\text{lm\_head}\)
  • \(\text{Phi3Model}\) 内部,我们看到了嵌入矩阵 \(\text{embed\_tokens}\) 及其维度。它有 \(\text{32,064}\) 个词元,每个词元的向量大小为 \(\text{3,072}\)
  • 暂时跳过 \(\text{dropout}\) 层,我们可以看到下一个主要组件是 \(\text{Transformer}\) 解码器层的堆栈。它包含 \(\text{32}\)\(\text{Phi3DecoderLayer}\) 类型的块
  • 这些 \(\text{Transformer}\) 块中的每一个都包含一个注意力层\(\text{attention layer}\))和一个前馈神经网络\(\text{feedforward neural network}\),也称为 \(\text{mlp}\) 或多层感知器)。我们将在本章后面更详细地介绍这些内容。
  • 最后,我们看到 \(\text{lm\_head}\) 接收一个大小为 \(\text{3,072}\) 的向量,并输出一个等同于模型所知词元数量的向量。该输出是每个词元的概率分数,它帮助我们选择输出词元。

从概率分布中选择单个词元(采样/解码)

Choosing a Single Token from the Probability Distribution (Sampling/Decoding)

正如我们前面在图 \(\text{3}-6\) 中看到的,在处理结束时,模型的输出是词汇表中每个词元的概率分数。从概率分布中选择单个词元的方法称为解码策略\(\text{decoding strategy}\))。图 \(\text{3}-7\) 展示了在一个示例中,这如何导致选择了词元“\(\text{Dear}\)”。

F3.1

最简单的解码策略是总是选择具有最高概率分数的词元。在实践中,这在大多数用例中往往不会产生最佳输出。一种更好的方法是添加一些随机性,有时选择第二高第三高概率的词元。统计学家会说,这里的想法是根据概率分数概率分布中进行采样

对于图 \(\text{3}-7\) 中的示例,这意味着如果词元“\(\text{Dear}\)”有 \(\text{40\%}\) 的概率成为下一个词元,那么它就有 \(\text{40\%}\) 的机会被选中(而不是贪婪搜索,后者会因为它是最高分数而直接选择它)。因此,通过这种方法,所有其他词元都有根据其分数被选中的机会。

每次都选择得分最高的词元被称为贪婪解码\(\text{greedy decoding}\))。这是当您将 \(\text{LLM}\) 中的温度 (\(\text{temperature}\)) 参数设置为零时所发生的情况。我们将在\(\text{6}\)中介绍温度的概念。

让我们更仔细地看看演示这个过程的代码。在这个代码块中,我们将输入词元通过模型,然后通过 \(\text{lm\_head}\)

1
2
3
4
5
6
7
8
9
prompt = "The capital of France is"
# Tokenize the input prompt
input_ids = tokenizer(prompt, return_tensors="pt").input_ids
# Tokenize the input prompt
input_ids = input_ids.to("cuda")
# Get the output of the model before the lm_head
model_output = model.model(input_ids)
# Get the output of the lm_head
lm_head_output = model.lm_head(model_output[0])

现在,\(\text{lm\_head\_output}\) 的形状是 \([\text{1}, \text{6}, \text{32064}]\)。我们可以使用 \(\text{lm\_head\_output[0,-1]}\) 来访问最后一个生成的词元的词元概率分数,其中索引 \(\text{0}\) 遍历批次维度;索引 \(-\text{1}\) 得到序列中的最后一个词元。这现在是所有 \(\text{32,064}\) 个词元的概率分数列表。我们可以得到得分最高的词元 \(\text{ID}\),然后对其进行解码,从而得出生成的输出词元的文本:

1
<CODE>

在这种情况下,结果是:

1
Paris

并行词元处理和上下文大小

Parallel Token Processing and Context Size

\(\text{Transformer}\) 最引人注目的特性之一是,它们比以前的语言处理神经网络架构更适合并行计算。在文本生成中,当我们观察每个词元如何被处理时,我们对此有了初步的了解。我们从上一章知道,分词器会将文本分解为词元。然后,这些每个输入词元都流经自己的计算路径(至少这是一个很好的初步直觉)。我们可以在图 \(\text{3}-8\) 中看到这些独立的处理轨迹或流

F3.1

当前的 \(\text{Transformer}\) 模型对于它们可以一次性处理多少个词元有一个限制。这个限制被称为模型的上下文长度\(\text{context length}\))。一个具有 \(\text{4K}\) 上下文长度的模型只能处理 \(\text{4K}\) 个词元,并且将只有 \(\text{4K}\) 个这样的\(\text{streams}\))。

每个词元流都以一个输入向量开始(嵌入向量和一些位置信息;我们将在本章后面讨论位置嵌入)。如图 \(\text{3}-9\) 所示,在流的末端,会产生另一个向量,作为模型处理的结果

F3.1

对于文本生成只有最后一个流的输出结果用于预测下一个词元。该输出向量是 \(\text{LM}\)计算下一个词元概率时的唯一输入

您可能想知道,如果我们丢弃最后一个词元之外的所有流的输出,为什么还要费力地计算所有词元流。答案是之前流的计算在计算最终流时是必需且被使用的。是的,我们没有使用它们的最终输出向量,但在 \(\text{Transformer}\) 块的注意力机制中,我们使用了较早的输出(在每个 \(\text{Transformer}\) 块中)。

如果您正在跟进代码示例,请回想一下 \(\text{lm\_head}\) 的输出形状是 \([\text{1}, \text{6}, \text{32064}]\)。这是因为它的输入形状是 \([\text{1}, \text{6}, \text{3072}]\),这是一个包含六个词元的输入字符串的批次,其中每个词元都由一个大小为 \(\text{3,072}\) 的向量表征,该向量对应于 \(\text{Transformer}\) 块堆栈之后的输出向量

我们可以通过打印来访问这些矩阵并查看它们的维度:

1
model_output[0].shape

输出:

1
torch.Size([1, 6, 3072])

同样,我们可以打印 \(\text{LM}\)的输出:

1
lm_head_output.shape

输出:

1
torch.Size([1, 6, 32064])

通过缓存键和值加速生成

Speeding Up Generation by Caching Keys and Values

回想一下,在生成第二个词元时,我们只需将输出词元附加到输入,然后进行另一次正向传播通过模型。如果我们让模型能够缓存先前计算的结果(特别是注意力机制中的一些特定向量),我们就不再需要重复先前流的计算。这次唯一需要的计算是最后一个流的计算。这是一项称为键和值 (\(\text{kv}\)) 缓存优化技术,它能显著加快生成过程键和值是注意力机制的一些核心组成部分,我们将在本章后面看到。

\(\text{3}-10\) 展示了在生成第二个词元时,由于我们缓存了先前流的结果只有一个处理流处于活动状态。

F3.1

\(\text{Hugging Face Transformers}\) 中,缓存默认是启用的。我们可以通过设置 \(\text{use\_cache}\)\(\text{False}\)禁用它。我们可以通过要求进行长时间生成,并对启用和禁用缓存的生成进行计时来查看速度上的差异:

1
2
3
4
5
prompt = "Write a very long email apologizing to Sarah for the tragic gardening
mishap. Explain how it happened."
# Tokenize the input prompt
input_ids = tokenizer(prompt, return_tensors="pt").input_ids
input_ids = input_ids.to("cuda")

然后,我们对启用缓存生成 \(\text{100}\) 个词元所需的时间进行计时。我们可以使用 \(\text{Jupyter}\)\(\text{Colab}\) 中的 %%timeit 魔术命令来计时执行所需的时间(它会运行命令多次并取平均值):

1
2
3
4
5
6
7
%%timeit -n 1
# Generate the text
generation_output = model.generate(
input_ids=input_ids,
max_new_tokens=100,
use_cache=True
)

在具有 \(\text{T}4\) \(\text{GPU}\)\(\text{Colab}\) 上,这大约需要 \(\text{4.5}\)。但是,如果我们禁用缓存,这需要多长时间呢?

1
2
3
4
5
6
7
%%timeit -n 1
# Generate the text
generation_output = model.generate(
input_ids=input_ids,
max_new_tokens=100,
use_cache=False
)

这大约需要 \(\text{21.8}\)。这是一个巨大的差异。事实上,从用户体验的角度来看,即使是四秒的生成时间,对于一个盯着屏幕等待模型输出的用户来说,也往往是一段漫长的等待时间。这就是为什么 \(\text{LLM}\) \(\text{API}\) 会在模型生成输出词元时实时流式传输它们,而不是等待整个生成完成的原因之一。

\(\text{Transformer}\) 块的内部

Inside the Transformer Block

我们现在可以讨论发生绝大部分处理的地方:\(\text{Transformer}\)。如图 \(\text{3}-11\) 所示,\(\text{Transformer LLM}\) 由一系列 \(\text{Transformer}\)组成(数量通常在最初 \(\text{Transformer}\) 论文中的六个,到许多大型 \(\text{LLM}\) 中的一百多个不等)。每个块处理其输入,然后将处理结果传递给下一个块

F3.1

一个 \(\text{Transformer}\)(图 \(\text{3}-12\))由两个连续的组件构成:

F3.1

  1. 注意力层\(\text{attention layer}\))主要关注整合来自其他输入词元和位置相关信息
  2. 前馈层\(\text{feedforward layer}\))承载了模型的大部分处理能力

前馈神经网络概览

The feedforward neural network at a glance

一个给出前馈神经网络直觉的简单例子是,如果我们向一个语言模型输入简单的“\(\text{The Shawshank}\)”,期望它能生成“\(\text{Redemption}\)”作为最可能的下一个词(指的是 \(\text{1994}\) 年的电影)。

前馈神经网络集体地位于所有模型层中)是这些信息的来源,如图 \(\text{3}-13\) 所示。当模型被成功训练来建模一个庞大的文本档案(其中包含许多对“\(\text{The Shawshank Redemption}\)”的提及)时,它就学习并存储了使它能成功完成这项任务的信息(和行为)。

F3.1

要成功训练一个 \(\text{LLM}\),它需要记忆大量信息。但它不仅仅是一个大型数据库。记忆只是令人印象深刻的文本生成秘诀中的一个要素。模型能够使用相同的机制在数据点和更复杂的模式之间进行插值\(\text{interpolate}\)),从而能够泛化\(\text{generalize}\))——这意味着对它以前没有见过且不在其训练数据集中的输入也能表现良好。

当您使用现代商业 \(\text{LLM}\) 时,您获得的输出并非前面提到的严格意义上的“语言模型”输出。将“\(\text{The Shawshank}\)”传递给像 \(\text{GPT}-4\) 这样的聊天 \(\text{LLM}\) 会产生如下输出:

"The Shawshank Redemption" is a 1994 film directed by Frank Darabont and is based on the novella "Rita Hayworth and Shawshank Redemption" written by Stephen King. ...etc.

这是因为原始语言模型(如 \(\text{GPT}-3\))对人们来说难以正确利用。这就是为什么语言模型随后会经过指令微调\(\text{instruction}-\text{tuning}\))和人类偏好与反馈微调\(\text{human preference and feedback fine}-\text{tuning}\))的训练,以匹配人们对模型应该输出什么的期望

注意力层概览

The attention layer at a glance

上下文对于正确建模语言至关重要。仅凭简单的记忆和基于前一个词元插值只能带我们走这么远。我们知道这一点,因为这曾是神经网络出现之前构建语言模型的主要方法之一(参见 \(\text{Daniel Jurafsky}\)\(\text{James H. Martin}\) 的《语音与语言处理》第 \(\text{3}\) 章“N-gram 语言模型”)。

注意力\(\text{Attention}\))是一种机制,它帮助模型在处理特定词元整合上下文。思考以下提示:

\(\text{The dog chased the squirrel because it}\)”(狗追松鼠因为它)

对于模型来说,要预测“\(\text{it}\)”之后的内容,它需要知道“\(\text{it}\)”指的是还是松鼠

在一个经过训练的 \(\text{Transformer LLM}\) 中,注意力机制负责做出这种判断。注意力将来自上下文的信息添加到“\(\text{it}\)”词元的表征中。我们可以在图 \(\text{3}-14\) 中看到一个简单的版本。

F3.1

模型是根据从训练数据集中看到和学到的模式来做到这一点的。也许前面的句子也提供了更多线索,例如,将狗称为“\(\text{she}\)”,从而明确“\(\text{it}\)”指的是松鼠。

注意力就是你所需要的一切

Attention is all you need

有必要更深入地探讨注意力机制。该机制最精简的版本如图 \(\text{3}-15\) 所示。它显示了多个词元位置进入注意力层;最后一个是当前正在处理的位置(粉色箭头)。注意力机制作用于该位置的输入向量。它将上下文中的相关信息整合到它为该位置产生的输出向量中。

F3.1

注意力机制涉及两个主要步骤

  1. 一种对每个先前输入词元与当前正在处理的词元(在粉色箭头中)的相关程度进行评分的方法。
  2. 使用这些分数,我们将来自不同位置的信息组合成一个单一的输出向量

\(\text{3}-16\) 展示了这两个步骤。

F3.1

为了赋予 \(\text{Transformer}\)广泛的注意力能力,注意力机制被复制并行执行多次。这些并行的注意力应用中的每一次都在一个注意力头\(\text{attention head}\))中进行。这增加了模型对输入序列中需要同时关注不同模式复杂模式进行建模的能力。

\(\text{3}-17\) 展示了注意力头如何并行运行的直觉,包括前面的信息拆分步骤后面的组合所有头部结果的步骤

F3.1

注意力如何计算

How attention is calculated

让我们看看在单个注意力头内部如何计算注意力。在我们开始计算之前,让我们观察以下作为起始位置

  • 注意力层(在一个生成式 \(\text{LLM}\) 中)正在为单个位置处理注意力。
  • 该层的输入是:
    • 当前位置或词元的向量表征。
    • 先前词元的向量表征。
  • 目标是产生一个新的当前位置表征,该表征整合了来自先前词元的相关信息
    • 例如,如果我们正在处理句子“\(\text{Sarah fed the cat because it}\)”中的最后一个位置,我们希望“\(\text{it}\)代表猫——因此注意力会从“\(\text{cat}\)”词元中烘焙\(\text{bakes in}\)“猫的信息”
  • 训练过程会产生三个投影矩阵,这些矩阵产生在这个计算中相互作用的组件:
    • 查询投影矩阵\(\text{A query projection matrix}\)
    • 键投影矩阵\(\text{A key projection matrix}\)
    • 值投影矩阵\(\text{A value projection matrix}\)

\(\text{3}-18\) 展示了在注意力计算开始之前所有这些组件的起始位置。为简单起见,我们只看一个注意力头,因为其他头具有相同的计算,但使用它们各自的投影矩阵

F3.1

注意力通过将输入乘以投影矩阵开始,以创建三个新矩阵。它们被称为查询 (\(\text{Queries}\)) 矩阵、键 (\(\text{Keys}\)) 矩阵和值 (\(\text{Values}\)) 矩阵。这些矩阵包含了输入词元被投影到三个不同空间的信息,有助于执行注意力的两个步骤:

  1. 相关性评分
  2. 组合信息

\(\text{3}-19\) 展示了这三个新矩阵,以及它们最底行如何与当前位置相关联,而其上方各行则与先前位置相关联。

F3.1

自注意力:相关性评分

Self-attention: Relevance scoring

生成式 \(\text{Transformer}\) 中,我们是一次生成一个词元。这意味着我们是一次处理一个位置。因此,这里的注意力机制只关注这一个位置,以及如何引入来自其他位置的信息来为这个位置提供参考。

注意力相关性评分步骤是通过将当前位置的查询 (\(\text{Query}\)) 向量键 (\(\text{Keys}\)) 矩阵相乘来进行的。这会产生一个分数,表明每个先前词元的相关程度。通过 \(\text{softmax}\) 运算,这些分数被归一化,使其总和为 \(\text{1}\)。图 \(\text{3}-20\) 展示了该计算产生的相关性分数

F3.1

个人注:其实是 \(Q_n \cdot K_i\),得出n和不同i得相关程度分数;然后归一处理。

自注意力:组合信息

Self-attention: Combining information

现在我们有了相关性分数,我们将与每个词元关联的值 (\(\text{Value}\)) 向量乘以该词元的分数将这些结果向量相加,就产生了这一注意力步骤的输出,如图 \(\text{3}-21\) 所示。

F3.1

个人注:以上其实就是:最后,模型用这些分数作为权重,来加权求和所有词的 V 向量。

\(\text{Transformer}\) 架构的最新改进

Recent Improvements to the Transformer Architecture

\(\text{Transformer}\) 架构发布以来,已经进行了大量工作来改进它并创建更好的模型。这涵盖了在更大的数据集上进行训练,以及对训练过程使用的学习率进行优化,但也延伸到了架构本身。在撰写本文时,原始 \(\text{Transformer}\) 的许多想法仍然保持不变。有一些架构思想已被证明是有价值的。它们有助于像 \(\text{Llama 2}\) 这样更近期的 \(\text{Transformer}\) 模型的性能。在本章的最后一部分,我们将回顾 \(\text{Transformer}\) 架构的一些重要的最新发展

更高效的注意力

More Efficient Attention

研究界最关注的领域是 \(\text{Transformer}\) 的注意力层。这是因为注意力计算是整个过程中计算成本最高的部分。

局部/稀疏注意力\(\text{Local/sparse attention}\)) 随着 \(\text{Transformer}\) 开始变得越来越大,像稀疏注意力\(\text{sparse attention}\)《使用稀疏 \(\text{Transformer}\) 生成长序列》)和滑动窗口注意力\(\text{sliding window attention}\)\(\text{Longformer}\):长文档 \(\text{Transformer}\))这样的想法为注意力计算的效率带来了改进。稀疏注意力限制了模型可以关注的先前词元的上下文,如图 \(\text{3}-22\) 所示。

F3.1

一个采用了这种机制的模型是 \(\text{GPT}-3\)。但它并未所有的 \(\text{Transformer}\)都使用这种机制——如果模型只能看到少量先前词元,生成质量将大大下降\(\text{GPT}-3\) 架构交织了全注意力 (\(\text{full-attention}\)) 和高效注意力 (\(\text{efficient-attention}\)) \(\text{Transformer}\)。因此,\(\text{Transformer}\) 块在全注意力(例如,第 \(\text{1}\) 块和第 \(\text{3}\) 块)和稀疏注意力(例如,第 \(\text{2}\) 块和第 \(\text{4}\) 块)之间交替

为了展示不同类型的注意力,请回顾图 \(\text{3}-23\),它展示了不同的注意力机制如何工作。每张图都显示了在处理当前词元(深蓝色)时,可以关注哪些先前词元(浅蓝色)。

F3.1

每一行对应于正在处理的一个词元。颜色编码指示了模型在处理深蓝色单元格中的词元时,能够关注哪些词元。图 \(\text{3}-24\) 更清晰地描述了这一点。

F3.1

该图还展示了解码器 \(\text{Transformer}\)(构成大多数文本生成模型)的自回归性质;它们只能关注先前的词元。这与 \(\text{BERT}\) 形成对比,\(\text{BERT}\) 可以关注两侧的词元(因此 \(\text{BERT}\) 中的 \(\text{B}\) 代表双向)。

多查询和分组查询注意力

Multi-query and grouped-query attention

\(\text{Transformer}\) 进行的一项更近期的高效注意力调整分组查询注意力\(\text{grouped-query attention}\)\(\text{GQA}\):从多头检查点训练广义多查询 \(\text{Transformer}\) 模型》),被像 \(\text{Llama 2}\)\(\text{3}\) 这样的模型所使用。图 \(\text{3}-25\) 展示了这些不同类型的注意力,下一节将继续解释它们。

F3.1

分组查询注意力建立在多查询注意力\(\text{multi-query attention}\)《快速 \(\text{Transformer}\) 解码:一个写入头就够了》)的基础上。这些方法通过减少所涉及矩阵的大小来提高更大模型的推理可扩展性

优化注意力:从多头到多查询再到分组查询

Optimizing attention: From multi-head to multi-query to grouped query

在本章前面,我们展示了 \(\text{Transformer}\) 论文如何描述多头注意力《图解 \(\text{Transformer}\) (https://jalammar.github.io/illustrated-transformer/)详细讨论了查询 (\(\text{Queries}\))、键 (\(\text{Keys}\)) 和值 (\(\text{Values}\)) 矩阵是如何用于进行注意力操作的。图 \(\text{3}-26\) 展示了每个“注意力头”如何为给定的输入计算自己独特的查询、键和值矩阵

F3.1

多查询注意力优化这一点的方式是在所有头之间共享键和值矩阵。因此,每个头唯一的矩阵将只有查询矩阵,如图 \(\text{3}-27\) 所示。

F3.1

然而,随着模型规模的增长,这种优化可能过于苛刻,我们可以承受使用稍多一点的内存提高模型的质量。这就是分组查询注意力发挥作用的地方。它没有将键和值矩阵的数量减少到各一个,而是允许我们使用更多(但少于头的数量)。图 \(\text{3}-28\) 展示了这些分组,以及每组注意力头如何共享键和值矩阵

F3.1

\(\text{Flash Attention}\)

\(\text{Flash Attention}\) 是一种流行的方法和实现,它为 \(\text{GPU}\) 上的 \(\text{Transformer LLM}\)训练和推理提供了显著的加速。它通过优化在 \(\text{GPU}\) 的共享内存 (\(\text{SRAM}\)) 和高带宽内存 (\(\text{HBM}\)) 之间加载和移动的值来加速注意力计算。它在论文\(\text{FlashAttention}\):具有 \(\text{IO}\) 感知的快速且内存高效的精确注意力》 以及随后的\(\text{FlashAttention}-2\):通过更好的并行性和工作分区实现更快的注意力》 中有详细描述。

\(\text{Transformer}\)

The Transformer Block

回想一下,\(\text{Transformer}\) 块的两个主要组成部分注意力层前馈神经网络。如图 \(\text{3}-29\) 所示,更详细地查看该块还会揭示残差连接\(\text{residual connections}\))和层归一化\(\text{layer}-\text{normalization}\))操作。

F3.1

在撰写本文时,最新的 \(\text{Transformer}\) 模型仍然保留了主要的组件,但进行了一些调整,如图 \(\text{3}-30\) 所示。

F3.1

我们在这个版本的 \(\text{Transformer}\) 块中看到的一个区别是,归一化发生在注意力层和前馈层之前。据报道,这可以减少所需的训练时间(阅读:《关于 \(\text{Transformer}\) 架构中的层归一化》)。这里的另一个归一化改进是使用了 \(\text{RMSNorm}\),它比原始 \(\text{Transformer}\) 中使用的 \(\text{LayerNorm}\) 更简单、更高效(阅读:《均方根层归一化》)。最后,代替原始 \(\text{Transformer}\)\(\text{ReLU}\) 激活函数,像 \(\text{SwiGLU}\) 这样的较新变体(在\(\text{GLU}\) 变体改进 \(\text{Transformer}\) 中有所描述)现在更为常见。

位置嵌入 (\(\text{RoPE}\))

Positional Embeddings (RoPE)

位置嵌入\(\text{Positional embeddings}\))自原始 \(\text{Transformer}\) 以来一直是其关键组成部分。它们使模型能够跟踪序列/句子中词元/词语的顺序,这是语言中不可或缺的信息来源。在过去几年提出的众多位置编码方案中,旋转位置嵌入\(\text{rotary positional embeddings}\),或简称 \(\text{RoPE}\),在\(\text{RoFormer}\):增强型 \(\text{Transformer}\) 与旋转位置嵌入》 中引入)尤其重要。

原始 \(\text{Transformer}\) 论文和一些早期变体使用了绝对位置嵌入,本质上是将第一个词元标记为位置 \(\text{1}\),第二个为位置 \(\text{2}\),依此类推。这些可以是静态方法(位置向量使用几何函数生成),也可以是学习方法(模型训练在学习过程中为其分配值)。当扩展模型时,此类方法会带来一些挑战,需要我们找到提高其效率的方法。

例如,高效训练具有大上下文的模型的挑战之一是,训练集中的许多文档比该上下文短得多。将整个(例如)\(\text{4K}\) 上下文分配给一个短的 \(\text{10}\) 个词的句子将是低效的。因此,在模型训练期间,文档被打包在一起放入训练批次的每个上下文中,如图 \(\text{3}-31\) 所示。

F3.1

阅读《高效的序列打包且无交叉污染:加速大型语言模型而不影响性能》 以及观看《引入 \(\text{packed BERT}\):在自然语言处理中将训练速度提高 \(\text{2}\) 倍》 中的精彩视觉效果,了解有关打包的更多信息。

位置嵌入方法必须适应这一点以及其他实际考虑因素。例如,如果文档 \(\text{50}\) 从位置 \(\text{50}\) 开始,那么如果我们告诉模型第一个词元的编号是 \(\text{50}\),我们就会误导模型,这将影响其性能(因为它会假设有先前的上下文,而实际上较早的词元属于模型应该忽略的、不相关的不同文档)。

旋转嵌入\(\text{rotary embeddings}\)不是在正向传播开始时添加的静态、绝对嵌入,而是一种编码位置信息的方法,它捕获了绝对和相对的词元位置信息。它基于在嵌入空间中旋转向量的思想。在正向传播中,它们被添加在注意力步骤中,如图 \(\text{3}-32\) 所示。

F3.1

注意力\(\text{attention}\))过程中,位置信息会被专门混合到查询 (\(\text{queries}\)) 和键 (\(\text{keys}\)) 矩阵中,就在我们将它们相乘以进行相关性评分之前,如图 \(\text{3}-33\) 所示。

F3.1

其他架构实验和改进

Other Architectural Experiments and Improvements

许多对 \(\text{Transformer}\)调整被持续地提出和研究。\(\text{Transformer}\) 综述》 强调了其中几个主要方向。\(\text{Transformer}\) 架构也一直在不断地适应超越 \(\text{LLM}\) 的领域计算机视觉是一个正在进行大量 \(\text{Transformer}\) 架构研究的领域(参见:\(\text{Transformer}\) 在视觉中的综述》《视觉 \(\text{Transformer}\) 综述》)。其他领域包括机器人技术(参见《开放式 \(\text{X}\) 具身:机器人学习数据集和 \(\text{RT}-\text{X}\) 模型》)和时间序列(参见\(\text{Transformer}\) 在时间序列中的综述》)。

总结

在本章中,我们讨论了 \(\text{Transformer}\) 的主要直觉以及实现最新 \(\text{Transformer}\) \(\text{LLM}\) 的最新发展。我们回顾了许多新概念,现在让我们分解一下本章讨论的关键概念

  • 一个 \(\text{Transformer LLM}\) 一次生成一个词元
  • 输出词元被附加到提示中,然后这个更新后的提示再次被呈现给模型,进行另一次正向传播以生成下一个词元。
  • \(\text{Transformer LLM}\)三个主要组成部分分词器、\(\text{Transformer}\) 块的堆栈语言建模头\(\text{LM head}\))。
  • 分词器包含模型的词元词汇表。模型有与这些词元相关的词元嵌入。将文本分解为词元并使用这些词元的嵌入是词元生成过程的第一步
  • 正向传播一次性按顺序流经所有阶段
  • 临近过程结束时,\(\text{LM}\)下一个可能的词元的概率进行评分解码策略决定了选择哪个实际词元作为本次生成步骤的输出(有时它是最可能的下一个词元,但并非总是如此)。
  • \(\text{Transformer}\) 表现出色的原因之一是它能够并行处理词元。每个输入词元都流入其各自的处理轨道或流。流的数量是模型的“上下文大小”,这代表了模型可以操作的最大词元数量
  • 因为 \(\text{Transformer LLM}\)循环一次生成一个词元的文本,所以缓存每个步骤的处理结果是一个好主意,这样我们就不会重复处理工作(这些结果以层内各种矩阵的形式存储)。
  • 大部分处理发生在 \(\text{Transformer}\)内。它们由两个组件组成。其中之一是前馈神经网络,它能够存储信息并根据其训练数据进行预测和插值
  • \(\text{Transformer}\) 块的第二个主要组成部分注意力层。注意力整合上下文信息,使模型能够更好地捕获语言的细微差别
  • 注意力发生在两个主要步骤中:(\(\text{1}\)相关性评分和(\(\text{2}\)组合信息
  • \(\text{Transformer}\) 注意力层并行进行多次注意力操作,每次操作都在一个注意力头内部发生,它们的输出被聚合以构成注意力层的输出。
  • 通过在所有头之间或多组头之间共享键和值矩阵分组查询注意力),可以加速注意力
  • \(\text{Flash Attention}\) 这样的方法通过优化在 \(\text{GPU}\) 不同内存系统上执行操作的方式来加速注意力计算。
  • \(\text{Transformer}\) 在不断出现新的发展和拟议的调整,以在不同的场景中改进它们,包括语言模型和其他领域和应用

在本书的第二部分,我们将涵盖 \(\text{LLM}\) 的一些实际应用。在\(\text{4}\)中,我们将从文本分类开始,这是语言 \(\text{AI}\) 中的一个常见任务。下一章将作为应用生成式和表征模型的介绍。

书籍各章的机翻md文件:
《Hands-On Large Language Models》目录及前言
《Hands-On Large Language Models》第1章 大型语言模型简介
《Hands-On Large Language Models》第2章 词元与嵌入
《Hands-On Large Language Models》第3章 深入了解大型语言模型
《Hands-On Large Language Models》第4章 文本分类
《Hands-On Large Language Models》第5章 文本聚类和主题建模
《Hands-On Large Language Models》第6章 提示工程
《Hands-On Large Language Models》第7章 高级文本生成技术与工具
《Hands-On Large Language Models》第8章 语义搜索与检索增强生成
《Hands-On Large Language Models》第9章 多模态大型语言模型
《Hands-On Large Language Models》第10章 创建文本嵌入模型
《Hands-On Large Language Models》第11章 微调用于分类的表征模型
《Hands-On Large Language Models》第12章 生成模型的微调

\(\text{2}\) 词元与嵌入

Tokens and Embeddings

词元\(\text{Tokens}\))和嵌入\(\text{embeddings}\))是使用大型语言模型(\(\text{LLM}\))的两个核心概念。正如我们在第一章中所见,它们不仅对于理解语言 \(\text{AI}\) 的历史很重要,而且如果不能很好地理解词元和嵌入,我们就无法清晰地了解 \(\text{LLM}\) 是如何工作的、是如何构建的以及它们未来的发展方向,如图 \(\text{2}-1\) 所示。

F2.1

在本章中,我们将更仔细地研究什么是词元以及用于驱动 \(\text{LLM}\)分词方法。然后,我们将深入探讨在现代 \(\text{LLM}\) 之前出现的著名的 \(\text{word}2\text{vec}\) 嵌入方法,并了解它如何将词元嵌入的概念扩展到构建商业推荐系统,这些系统为我们使用的许多应用程序提供支持。最后,我们将从词元嵌入转向句子或文本嵌入,其中整个句子或文档可以拥有一个代表它的向量——从而支持语义搜索主题建模等应用,我们将在本书的第二部分看到这些应用。

\(\text{LLM}\) 分词

LLM Tokenization

在撰写本文时,大多数人与语言模型交互的方式是通过一个网页演练场\(\text{web playground}\)),它呈现了一个用户与语言模型之间的聊天界面。您可能会注意到,模型不会一次性生成所有输出响应;它实际上是一次生成一个词元

但词元不仅仅是模型的输出,它们也是模型看待输入的方式。发送给模型的文本提示首先会被分解成词元,我们现在就来看看。

分词器如何准备语言模型的输入

How Tokenizers Prepare the Inputs to the Language Model

从外部来看,生成式 \(\text{LLM}\) 接收一个输入提示并生成一个响应,如图 \(\text{2}-2\) 所示。

F2.1

然而,在提示呈现给语言模型之前,它必须先经过一个分词器,将其分解成碎片。您可以在 \(\text{OpenAI Platform}\) 上找到一个展示 \(\text{GPT}-4\) 分词器的示例。如果我们向其输入文本,它会显示图 \(\text{2}-3\) 中的输出,其中每个词元都用不同的颜色显示。

F2.1

让我们看一个代码示例,自己来与这些词元进行交互。在这里,我们将下载一个 \(\text{LLM}\),并看看如何在用 \(\text{LLM}\) 生成文本之前对输入进行分词。

下载和运行 \(\text{LLM}\)

Downloading and Running an LLM

让我们从加载模型及其分词器开始,就像我们在第 \(\text{1}\) 章中所做的那样:

1
2
3
4
5
6
7
8
9
from transformers import AutoModelForCausalLM, AutoTokenizer
# Load model and tokenizer
model = AutoModelForCausalLM.from_pretrained(
"microsoft/Phi-3-mini-4k-instruct",
device_map="cuda",
torch_dtype="auto",
trust_remote_code=True,
)
tokenizer = AutoTokenizer.from_pretrained("microsoft/Phi-3-mini-4k-instruct")

然后我们可以继续进行实际的生成。我们首先声明我们的提示,然后对其进行分词,然后将这些词元传递给模型,模型将生成其输出。在这种情况下,我们要求模型只生成 \(\text{20}\) 个新的词元:

1
2
3
4
5
6
7
8
9
10
11
prompt = "Write an email apologizing to Sarah for the tragic gardening mishap.
Explain how it happened.<|assistant|>"
# Tokenize the input prompt
input_ids = tokenizer(prompt, return_tensors="pt").input_ids.to("cuda")
# Generate the text
generation_output = model.generate(
input_ids=input_ids,
max_new_tokens=20
)
# Print the output
print(tokenizer.decode(generation_output[0]))

输出:

1
2
3
4
<s> Write an email apologizing to Sarah for the tragic gardening mishap.
Explain how it happened.<|assistant|> Subject: My Sincere Apologies for the
Gardening Mishap
Dear

粗体文本是模型生成的 \(\text{20}\) 个词元(Subject: My Sincere Apologies for the Gardening Mishap Dear)。

查看代码,我们可以看到模型实际上并没有接收文本提示。相反,分词器处理了输入提示,并在 \(\text{input\_ids}\) 变量中返回了模型所需的信息,模型将此作为其输入。

让我们打印 \(\text{input\_ids}\) 来看看它里面包含什么:

1
2
3
tensor([[ 1, 14350, 385, 4876, 27746, 5281, 304, 19235, 363, 278, 25305, 293,
16423, 292, 286, 728, 481, 29889, 12027, 7420, 920, 372, 9559, 29889, 32001]],
device='cuda:0')

这揭示了 \(\text{LLM}\) 响应的输入:一个整数序列,如图 \(\text{2}-4\) 所示。每个整数都是特定词元(字符、词或词的一部分)的唯一 \(\text{ID}\)。这些 \(\text{ID}\) 引用了分词器内部的一个表,该表包含它所知道的所有词元。

F2.1

如果我们想检查这些 \(\text{ID}\),我们可以使用分词器的 \(\text{decode}\) 方法将 \(\text{ID}\) 翻译回我们可以阅读的文本:

1
2
for id in input_ids[0]:
print(tokenizer.decode(id))

这将打印出(每个词元在单独的一行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<s>
Write
an
email
apolog
izing
to
Sarah
for
the
trag
ic
garden
ing
m
ish
ap
.
Exp
lain
how
it
happened
.
<|assistant|>

这就是分词器分解我们输入提示的方式。请注意以下几点:

  • 第一个词元是 \(\text{ID 1}\)\(\text{<s>}\)),这是一个特殊词元,表示文本的开头。
  • 一些词元是完整的词(例如,\(\text{Write}\)\(\text{an}\)\(\text{email}\))。
  • 一些词元是词的一部分(例如,\(\text{apolog}\)\(\text{izing}\)\(\text{trag}\)\(\text{ic}\))。
  • 标点符号是它们自己的词元。

请注意空格字符本身没有自己的词元。相反,部分词元(如“\(\text{izing}\)”和“\(\text{ic}\)”)的开头有一个特殊的隐藏字符,表示它们与文本中前一个词元是连接在一起的。没有该特殊字符的词元被假定前面有一个空格

在输出端,我们也可以通过打印 \(\text{generation\_output}\) 变量来检查模型生成的词元。这会显示输入词元以及输出词元(我们用粗体突出显示新词元):

1
2
3
4
5
tensor([[ 1, 14350, 385, 4876, 27746, 5281, 304, 19235, 363, 278,
25305, 293, 16423, 292, 286, 728, 481, 29889, 12027, 7420,
920, 372, 9559, 29889, 32001, 3323, 622, 29901, 1619, 317,
3742, 406, 6225, 11763, 363, 278, 19906, 292, 341, 728,
481, 13, 13, 29928, 799]], device='cuda:0')

这告诉我们模型生成了词元 \(\text{3323}\),即“\(\text{Sub}\)”,紧接着是词元 \(\text{622}\),“\(\text{ject}\)”。它们一起构成了“\(\text{Subject}\)”这个词。然后紧跟着是词元 \(\text{29901}\),它是冒号“\(\text{:}\)”……依此类推。就像在输入端一样,我们在输出端也需要分词器将词元 \(\text{ID}\) 翻译成实际的文本。我们使用分词器的 \(\text{decode}\) 方法来实现这一点。我们可以向它传递单个词元 \(\text{ID}\)\(\text{ID}\) 列表:

1
2
3
4
print(tokenizer.decode(3323))
print(tokenizer.decode(622))
print(tokenizer.decode([3323, 622]))
print(tokenizer.decode(29901))

输出:

1
2
3
4
Sub
ject
Subject
:

分词器如何分解文本?

How Does the Tokenizer Break Down Text?

有三个主要因素决定了分词器如何分解输入提示。

首先,在模型设计时,模型的创建者会选择一种分词方法。流行的方法包括字节对编码 (\(\text{BPE}\))\(\text{GPT}\) 模型广泛使用)和 \(\text{WordPiece}\)\(\text{BERT}\) 使用)。这些方法相似之处在于它们都旨在优化一组高效的词元来表征一个文本数据集,但它们以不同的方式实现这一目标。

其次,在选择了方法之后,我们需要做出一些分词器设计选择,例如词汇量大小以及要使用的特殊词元。更多内容请参见第 \(\text{46}\) 页的“比较经过训练的 \(\text{LLM}\) 分词器”。

第三,分词器需要在一个特定的数据集上进行训练,以确定它可用于表征该数据集的最佳词汇表。即使我们设置了相同的方法和参数,在一个英文文本数据集上训练的分词器也会与在代码数据集多语言文本数据集上训练的分词器不同。

如图 \(\text{2}-5\) 所示,除了用于将输入文本处理成语言模型外,分词器还用于语言模型的输出端,将生成的词元 \(\text{ID}\) 转换成与之关联的输出词语或词元。

F2.1

词语词元、子词词元、字符词元与字节词元

Word Versus Subword Versus Character Versus Byte Tokens

我们刚刚讨论的分词方案被称为子词分词\(\text{subword tokenization}\))。它是最常用的分词方案,但并非唯一的一个。图 \(\text{2}-6\) 中展示了四种值得注意的分词方式。我们来回顾一下:

F2.1

词语词元 (\(\text{Word tokens}\)) 这种方法在像 \(\text{word}2\text{vec}\) 这样的早期方法中很常见,但在 \(\text{NLP}\) 中使用得越来越少。然而,它的用处使其在 \(\text{NLP}\) 之外被用于推荐系统等用例,正如我们将在本章后面看到的那样。词语分词的一个挑战是分词器可能无法处理在训练后进入数据集的新词。这也导致词汇表中有许多词元之间只有微小差异(例如,\(\text{apology}\)\(\text{apologize}\)\(\text{apologetic}\)\(\text{apologist}\))。后一个挑战通过子词分词得到解决,因为它有一个用于 \(\text{apolog}\) 的词元,然后是与其他许多词元常见的后缀词元(例如,\(\text{-y}\)\(\text{-ize}\)\(\text{-etic}\)\(\text{-ist}\)),从而形成了更具表达力的词汇表。

子词词元 (\(\text{Subword tokens}\) 这种方法包含完整词部分词。除了前面提到的词汇表表达力之外,这种方法的另一个好处是它能够通过将新词元分解成更小的字符来表征新词,这些字符往往是词汇表的一部分。

字符词元 (\(\text{Character tokens}\) 这是另一种可以成功处理新词的方法,因为它可以回退到原始字母。虽然这使得表征更容易分词,但它使建模更加困难。对于使用子词分词的模型,它可以将“\(\text{play}\)”表征为一个词元,而使用字符级别词元的模型除了对序列的其余部分进行建模外,还需要对信息进行建模以拼写出“\(\text{p}-\text{l}-\text{a}-\text{y}\)”。 子词词元比字符词元具有优势,因为它能够在 \(\text{Transformer}\) 模型有限的上下文长度内拟合更多的文本。因此,对于上下文长度为 \(\text{1,024}\) 的模型,使用子词分词可以拟合的文本量大约是使用字符词元的三倍(子词词元平均每个词元大约包含三个字符)。

字节词元 (\(\text{Byte tokens}\) 另一种分词方法将词元分解成用于表征 \(\text{unicode}\) 字符的单个字节。像 \(\text{CANINE}\):预训练一个高效的无分词编码器用于语言表征》 这样的论文概述了类似的方法,这也被称为“无分词编码”。其他著作,例如 \(\text{ByT5}\):迈向预训练的字节到字节模型的无词元未来》,表明这是一种有竞争力的方​​法,尤其是在多语言场景中。

个人注:字节词元和字符词元的区别见《字节词元和字符词元的区别.md》

这里需要强调一个区别:一些子词分词器也会将字节作为词元包含在它们的词汇表中,作为当它们遇到无法以其他方式表征的字符时可以回退的最终构建块。例如,\(\text{GPT}-2\)\(\text{RoBERTa}\) 分词器就采用了这种做法。但这并不会使它们成为无分词的字节级别分词器,因为它们不使用这些字节来表征所有内容,而只表征一个子集,正如我们将在下一节中看到的那样。

如果您想更深入地了解分词器,《设计大型语言模型应用》(《Designing Large Language Model Applications》 by Suhas Pai) 一书中对其进行了更详细的讨论。

比较经过训练的 \(\text{LLM}\) 分词器

Comparing Trained LLM Tokenizers

我们前面指出,有三个主要因素决定了分词器中出现的词元:分词方法、我们用于初始化分词器的参数和特殊词元,以及训练分词器的数据集。让我们比较和对比一些实际的、经过训练的分词器,看看这些选择如何改变它们的行为。这种比较将向我们展示较新的分词器是如何改变它们的行为以提高模型性能的,我们还将看到专业化模型(例如代码生成模型)通常如何需要专业化分词器

我们将使用一些分词器对以下文本进行编码:

1
2
3
4
5
6
7
text =
"""
English and CAPITALIZATION
🎵鸟
show_tokens False None elif == >= else: two tabs:" " Three tabs: " "
12.0*50=600
"""

F2.0

这将使我们能够看到每个分词器如何处理多种不同类型的词元:

  • 大小写
  • 非英语语言
  • 表情符号
  • 带有关键字和常用于缩进的空格的编程代码(例如在 \(\text{Python}\) 等语言中)。
  • 数字和位数
  • 特殊词元。这些是具有表征文本以外角色的唯一词元。它们包括指示文本开始文本结束的词元(模型就是通过这种方式向系统发出信号,表示它已完成此次生成),或我们稍后将看到的其他功能。

让我们从较旧的分词器到较新的分词器依次查看它们如何对这段文本进行分词,以及这可能说明了语言模型的哪些信息。我们将对文本进行分词,然后使用以下函数将每个词元以带颜色背景的方式打印出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
colors_list = [
'102;194;165', '252;141;98', '141;160;203',
'231;138;195', '166;216;84', '255;217;47'
]
def show_tokens(sentence, tokenizer_name):
tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
token_ids = tokenizer(sentence).input_ids
for idx, t in enumerate(token_ids):
print(
f'\x1b[0;30;48;2;{colors_list[idx % len(colors_list)]}m' +
tokenizer.decode(t) +
'\x1b[0m',
end=' '
)

\(\text{BERT}\) 基础模型(未区分大小写)(\(\text{2018}\)

HuggingFace 模型 \(\text{hub}\) 上的模型链接

分词方法\(\text{WordPiece}\),在《日语和韩语语音搜索》(\(\text{Japanese and Korean voice search}\)) 中引入。 词汇量大小\(\text{30,522}\) 特殊词元

  • \(\text{unk\_token}\) [UNK] 分词器没有特定编码的未知词元
  • \(\text{sep\_token}\) [SEP] 一种分隔符,可启用需要向模型提供两段文本的某些任务(在这种情况下,模型被称为交叉编码器)。一个例子是重排序,正如我们将在\(\text{8}\)中看到的那样。
  • \(\text{pad\_token}\) [PAD] 一种填充词元,用于填充模型输入中未使用的位置(因为模型期望一定的输入长度,即其上下文大小)。
  • \(\text{cls\_token}\) [CLS] 用于分类任务的特殊分类词元,正如我们将在\(\text{4}\)中看到的那样。
  • \(\text{mask\_token}\) [MASK]训练过程中用于隐藏词元的掩码词元

分词文本

F2.1

\(\text{BERT}\) 发布时有两个主要版本:区分大小写\(\text{cased}\),保留大小写)和不区分大小写\(\text{uncased}\),所有大写字母首先转换为小写字母)。对于不区分大小写(且更受欢迎)的 \(\text{BERT}\) 分词器版本,我们注意到以下几点:

  • 换行符消失了,这使得模型无法感知编码在换行符中的信息(例如,聊天日志中每回合都在新的一行)。
  • 所有文本都变成了小写
  • \(\text{capitalization}\)”这个词被编码为两个子词元:\(\text{capital}\) ##ization## 字符用于表示该词元是一个部分词元,连接到它在文本中前面的词元。这也是一种指示空格位置的方法,因为假定前面没有 ## 的词元前面有一个空格。
  • 表情符号中文字符消失了,被 \([\text{UNK}]\) 特殊词元取代,表示“未知词元”。

\(\text{BERT}\) 基础模型(区分大小写)(\(\text{2018}\)

HuggingFace 模型 \(\text{hub}\) 上的模型链接

分词方法\(\text{WordPiece}\) 词汇量大小\(\text{28,996}\) 特殊词元:与不区分大小写的版本相同

分词文本

F2.1

\(\text{BERT}\) 区分大小写版本的分词器主要的区别在于包含了大写词元

  • 请注意“\(\text{CAPITALIZATION}\)”现在被表征为八个词元\(\text{CA}\) ##PI ##TA ##L ## ##Z ##AT ##ION

  • 两个 \(\text{BERT}\) 分词器都将输入文本包装在一个起始的 \(\text{[CLS]}\) 词元和一个结束的 \(\text{[SEP]}\) 词元之中。\(\text{[CLS]}\)\(\text{[SEP]}\) 是用于包装输入文本的实用词元,它们有各自的用途。\(\text{[CLS]}\) 代表分类\(\text{classification}\)),因为它有时用作句子分类的词元。\(\text{[SEP]}\) 代表分隔符\(\text{separator}\)),因为它在某些需要向模型传递两个句子的应用中用于分隔句子(例如,在\(\text{8}\)中,我们将使用一个 \(\text{[SEP]}\) 词元来分隔查询文本和候选结果)。

\(\text{GPT}-2\) (\(\text{2019}\))

HuggingFace 模型 \(\text{hub}\) 上的模型链接

分词方法字节对编码 (\(\text{BPE}\)),在《使用子词单元的稀有词神经机器翻译》(\(\text{Neural machine translation of rare words with subword units}\) 中引入。

词汇量大小\(\text{50,257}\)

特殊词元\(\text{<|endoftext|>}\)

F2.1

对于 \(\text{GPT}-2\) 分词器,我们注意到以下几点:

  • 换行符在分词器中得到了表征

  • 大小写得以保留,“\(\text{CAPITALIZATION}\)”一词被表征为四个词元

  • 🎵鸟 字符现在每个都被多个词元表征。虽然我们看到这些词元被打印为 字符,但它们实际上代表了不同的词元。例如,🎵 表情符号被分解成词元 \(\text{ID}\)\(\text{8582}\)\(\text{236}\)\(\text{113}\) 的词元。分词器成功地从这些词元重构了原始字符。我们可以通过打印 \(\text{tokenizer.decode([8582, 236, 113])}\) 来看到这一点,它会打印出 🎵。

  • 两个制表符被表征为两个词元(词汇表中的词元编号都是 \(\text{197}\)),而四个空格被表征为三个词元(编号都是 \(\text{220}\)),最后一个空格是闭合引号字符词元的一部分。

空白字符的意义是什么?它们对于模型理解或生成代码非常重要。一个使用单个词元来表征四个连续空白字符的模型,更适合\(\text{Python}\) 代码数据集。虽然模型也可以将其表征为四个不同的词元,但这会使建模更加困难,因为模型需要跟踪缩进级别,这通常会导致性能下降。这是一个分词选择如何帮助模型在特定任务上改进的例子。

\(\text{Flan-T5}\) (\(\text{2022}\))

分词方法\(\text{Flan-T5}\) 使用一个名为 \(\text{SentencePiece}\) 的分词器实现,它在《\(\text{SentencePiece}\):一个用于神经文本处理的简单且语言无关的子词分词器和反分词器》 中引入,支持 \(\text{BPE}\)一元语言模型\(\text{unigram language model}\),在《子词正则化:使用多个子词候选改进神经网络翻译模型》 中描述)。

词汇量大小\(\text{32,100}\)

特殊词元

  • \(\text{unk\_token}\) \(\text{<unk>}\)
  • \(\text{pad\_token}\) \(\text{<pad>}\)

分词文本

F2.1

\(\text{Flan-T5}\) 系列模型使用 \(\text{SentencePiece}\) 方法。我们注意到以下几点:

  • 没有换行符或空白词元;这将使模型处理代码变得具有挑战性。
  • 表情符号中文字符都被 \(\text{<unk>}\) 词元取代,使模型完全无法识别它们。

\(\text{GPT}-4\) (\(\text{2023}\))

分词方法\(\text{BPE}\)

词汇量大小:略超 \(\text{10}\)

特殊词元

  • \(\text{<|endoftext|>}\)

  • 中间填充词元\(\text{Fill in the middle tokens}\))。这三个词元使 \(\text{LLM}\) 能够在不仅给定它之前的文本,而且考虑到它之后的文本的情况下生成补全。这种方法在论文《高效训练语言模型以填充中间部分》(\(\text{Efficient training of language models to fill in the middle}\)) 中有更详细的解释;其确切细节超出了本书的范围。这些特殊词元是:

    • \(\text{<|fim\_prefix|>}\)
    • \(\text{<|fim\_middle|>}\)
    • \(\text{<|fim\_suffix|>}\)

分词文本

F2.1

\(\text{GPT}-4\) 分词器的行为与其祖先 \(\text{GPT}-2\) 分词器相似。一些不同之处在于:

  • \(\text{GPT}-4\) 分词器四个空格表征为单个词元。事实上,它为每个最多 \(\text{83}\) 个空白字符的序列都有一个特定的词元。
  • \(\text{Python}\) 关键字 \(\text{elif}\)\(\text{GPT}-4\) 中有自己的词元。这一点和前一点都源于模型除了自然语言之外对代码的关注。
  • \(\text{GPT}-4\) 分词器使用更少的词元来表征大多数词语。这里的例子包括“\(\text{CAPITALIZATION}\)”(两个词元,而 \(\text{GPT}-2\) 是四个)和“\(\text{tokens}\)”(一个词元,而 \(\text{GPT}-2\) 是三个)。
  • 请回顾我们关于 \(\text{GPT}-2\) 分词器中关于 🎵 词元所说的内容。

\(\text{StarCoder}2\) (\(\text{2024}\))

\(\text{StarCoder}2\) 是一个 \(\text{150}\) 亿参数的模型,专注于生成代码,在论文《\(\text{StarCoder 2}\)\(\text{stack v2}\):下一代》 中有所描述,它延续了《\(\text{StarCoder}\):愿源代码与你同在!(may the source be with you!)》(https://arxiv.org/abs/2305.06161) 中描述的原始 \(\text{StarCoder}\) 的工作。

分词方法字节对编码 (\(\text{BPE}\))

词汇量大小\(\text{49,152}\)

特殊词元示例

  • \(\text{<|endoftext|>}\)
  • 中间填充词元\(\text{Fill in the middle tokens}\)):
    • \(\text{<fim\_prefix>}\)
    • \(\text{<fim\_middle>}\)
    • \(\text{<fim\_suffix>}\)
    • \(\text{<fim\_pad>}\)
  • 在表征代码时,管理上下文非常重要。一个文件可能会调用在不同文件中定义的函数。因此,模型需要一种方法来识别同一代码仓库中不同文件中的代码,同时区分不同仓库中的代码。这就是为什么 \(\text{StarCoder}2\)仓库名称文件名使用特殊词元:
    • \(\text{<filename>}\)
    • \(\text{<reponame>}\)
    • \(\text{<gh\_stars>}\)

分词文本

F2.1

这是一个专注于代码生成的编码器:

  • \(\text{GPT}-4\) 类似,它将空白字符序列编码为单个词元
  • 与我们迄今为止看到的所有模型的主要区别在于,它将每个数字分配了自己的词元(因此 \(\text{600}\) 变成了 \(\text{6}\) \(\text{0}\) \(\text{0}\))。这里的假设是,这将有助于更好地表征数字和数学。例如,在 GPT-2 中,数字 870 被表征为单个词元。但 \(\text{871}\) 被表征为两个词元(\(\text{8}\)\(\text{71}\))。您可以直观地看到这可能会如何使模型感到困惑,以及它如何表征数字。

\(\text{Galactica}\)

论文《\(\text{Galactica}\):一个用于科学的大型语言模型》 中描述的 \(\text{Galactica}\) 模型专注于科学知识,并在许多科学论文、参考资料和知识库上进行训练。它特别关注分词,使其对它所表征的数据集的细微差别更加敏感。例如,它包含了用于引文、推理、数学、氨基酸序列和 \(\text{DNA}\) 序列的特殊词元。

分词方法字节对编码 (\(\text{BPE}\))

词汇量大小\(\text{50,000}\)

特殊词元

  • \(\text{<s>}\)
  • \(\text{<pad>}\)
  • \(\text{</s>}\)
  • \(\text{<unk>}\)
  • 参考文献\(\text{References}\)):引文被包裹在两个特殊词元中:
    • \(\text{[START\_REF]}\)
    • \(\text{[END\_REF]}\)
    • 论文中的一个用法示例是:\(\text{Recurrent neural net}\text{works, long short-term memory [START\_REF]Long Short-Term Memory, Hochreiter[END\_REF]}\)
  • 逐步推理\(\text{Step-by-step reasoning}\)):
    • \(\text{<work>}\) 是一个有趣的词元,模型用它来进行思维链推理\(\text{chain-of-thought rea}\text{soning}\))。

分词文本

F2.1

\(\text{Galactica}\) 分词器的行为与 \(\text{StarCoder}2\) 相似,因为它也考虑到了代码。它也以相同的方式编码空格:为不同长度的空格序列分配单个词元。然而,它的不同之处在于它也对制表符执行了相同的操作。因此,在我们目前所见的所有分词器中,它是唯一一个为由两个制表符(\t\t)组成的字符串分配单个词元的分词器。

\(\text{Phi}-3\)(和 \(\text{Llama 2}\)

我们在本书中关注的 \(\text{Phi}-3\) 模型重用了 \(\text{Llama 2}\) 的分词器,但添加了一些特殊词元

分词方法字节对编码 (\(\text{BPE}\))

词汇量大小\(\text{32,000}\)

特殊词元

  • \(\text{<|endoftext|>}\)

  • 聊天词元\(\text{Chat tokens}\)):随着聊天 \(\text{LLM}\)\(\text{2023}\) 年的流行,\(\text{LLM}\)对话性质开始成为一个主要的用例。分词器通过添加指示对话回合每个说话者角色的词元来适应这一方向。这些特殊词元包括:

    • \(\text{<|user|>}\)
    • \(\text{<|assistant|>}\)
    • \(\text{<|system|>}\)

我们现在可以并排查看所有这些示例,来回顾我们的旅程。

F2.1

F2.1

分词器属性

Tokenizer Properties

前面对经过训练的分词器的导览展示了实际分词器相互之间存在许多不同之处。但是,是什么决定了它们的分词行为呢?有三组主要的设计选择决定了分词器将如何分解文本:分词方法初始化参数以及分词器所针对的数据领域

分词方法

正如我们所见,有多种分词方法,其中字节对编码 (\(\text{BPE}\)) 是更流行的一种。这些方法中的每一种都概述了一种算法,用于如何选择一组合适的词元来表征一个数据集。您可以在 \(\text{Hugging Face}\) 总结分词器的页面上找到所有这些方法的精彩概述。

分词器参数

在选择了分词方法之后,\(\text{LLM}\) 设计者需要对分词器的参数做出一些决定。其中包括:

词汇量大小 (\(\text{Vocabulary size}\)) 要在分词器的词汇表中保留多少个词元?(\(\text{30K}\)\(\text{50K}\) 经常被用作词汇量大小的值,但我们越来越多地看到更大的尺寸,例如 \(\text{100K}\)。)

特殊词元 (\(\text{Special tokens}\)) 我们希望模型跟踪哪些特殊词元?我们可以根据需要添加任意数量的特殊词元,特别是如果我们要为特殊用例构建 \(\text{LLM}\) 时。常见的选择包括:

  • 文本开始词元(例如,\(\text{<s>}\)
  • 文本结束词元
  • 填充词元
  • 未知词元
  • \(\text{CLS}\) 词元
  • 掩码词元

除了这些之外,\(\text{LLM}\) 设计者还可以添加有助于更好地建模他们试图关注的问题领域的词元,正如我们在 \(\text{Galactica}\)\(\text{<work>}\)\(\text{[START\_REF]}\) 词元中所看到的那样。

大小写 (\(\text{Capitalization}\)) 在像英语这样的语言中,我们希望如何处理大小写?我们应该将所有内容都转换为小写吗?(名称的首字母大写通常带有有用的信息,但我们是否想在词汇表空间上浪费用于全大写版本词语的词元?)

数据的领域

即使我们选择了相同的方法和参数,分词器的行为也会因为训练所用的数据集而不同(甚至在我们开始模型训练之前)。前面提到的分词方法通过优化词汇表来表征特定数据集而发挥作用。从我们的导览中,我们已经看到这对代码多语言文本等数据集产生了怎样的影响。

F2.1

例如,对于代码,我们已经看到一个专注于文本的分词器可能会像这样对缩进空格进行分词(我们将用颜色突出显示一些词元):

这对一个专注于代码的模型来说可能不是最优的。通过做出不同的分词选择,专注于代码的模型通常会得到改进:

这些分词选择使模型的工作变得更容易,从而使其性能改进的可能性更高。

您可以在 \(\text{Hugging Face}\) 课程的分词器部分和《基于 \(\text{Transformer}\) 的自然语言处理(修订版)》(Natural Language Processing with Transformers, Revised Edition)(https://www.oreilly.com/library/view/natural-language-processing/9781098136789/) 中找到关于训练分词器的更详细教程。

词元嵌入

Token Embeddings

既然我们理解了分词,我们就解决了向语言模型表征语言问题的一部分。从这个意义上说,语言是词元的序列。如果我们在一个足够大的词元集上训练一个足够好的模型,它就开始捕获训练数据集中出现的复杂模式

  • 如果训练数据包含大量的英文文本,这种模式就会表现为模型能够表征和生成英语
  • 如果训练数据包含事实信息(例如 \(\text{Wikipedia}\)),模型将具备生成一些事实信息的能力(参见下面的注释)。

这块拼图的下一部分是为这些词元找到最佳的数值表征,模型可以使用这些表征来计算和正确地建模文本中的模式。这些模式向我们展示为模型在特定语言中的连贯性编程能力,或我们期望语言模型具备的不断增长的能力列表中的任何一项。

正如我们在\(\text{1}\)中看到的,这就是嵌入\(\text{embeddings}\))的含义。它们是用于捕获语言中的含义和模式的数值表征空间。

哎呀!然而,达到语言连贯性优于平均水平的事实生成的良好阈值开始带来了一个新的问题。一些用户开始信任模型的事实生成能力(例如,在 \(\text{2023}\) 年初,一些语言模型被称为“\(\text{Google}\) 杀手”)。但高级用户很快就认识到,单独的生成模型不是可靠的搜索引擎。这导致了检索增强生成( retrieval-augmented generation)) (\(\text{RAG}\)) 的兴起,它结合了搜索和 \(\text{LLM}\)。我们将在\(\text{8}\)中更详细地介绍 \(\text{RAG}\)

语言模型为其分词器的词汇表持有嵌入

A Language Model Holds Embeddings for the Vocabulary of Its Tokenizer

在分词器被初始化和训练之后,它随后被用于其关联语言模型的训练过程中。这就是为什么一个预训练的语言模型与其分词器是链接在一起的,并且无法在不进行训练的情况下使用不同的分词器

如图 \(\text{2}-7\) 所示,语言模型为分词器词汇表中的每个词元持有一个嵌入向量。当我们下载一个预训练语言模型时,模型的一部分就是这个嵌入矩阵,它保存了所有这些向量。

在训练过程开始之前,这些向量会像模型的其余权重一样随机初始化,但训练过程会为它们分配值,使它们能够执行训练它们所要完成的有用行为

F2.1

使用语言模型创建情境化词嵌入

Creating Contextualized Word Embeddings with Language Models

既然我们已经介绍了词元嵌入作为语言模型的输入,接下来我们看看语言模型如何创建更好的词元嵌入。这是使用语言模型进行文本表征的主要方式之一。它增强了诸如命名实体识别(named-entity recognition)或抽取式文本摘要(通过突出显示文本中最重要的部分来总结长文本,而不是生成新的摘要文本)等应用的能力。

语言模型不是用一个静态向量来表征每个词元或词语,而是创建情境化词嵌入\(\text{contextualized word embeddings}\),如图 \(\text{2}-8\) 所示),它根据一个词的上下文用一个不同的向量来表征它。然后,这些向量可以被其他系统用于各种任务。除了我们上一段提到的文本应用之外,这些情境化向量例如还驱动着像 \(\text{DALL}\cdot\text{E}\)\(\text{Midjourney}\)\(\text{Stable Diffusion}\) 这样的 \(\text{AI}\) 图像生成系统

F2.1

让我们看看如何生成情境化词嵌入;现在您应该对大部分代码都比较熟悉了:

1
2
3
4
5
6
7
8
9
from transformers import AutoModel, AutoTokenizer
# Load a tokenizer
tokenizer = AutoTokenizer.from_pretrained("microsoft/deberta-base")
# Load a language model
model = AutoModel.from_pretrained("microsoft/deberta-v3-xsmall")
# Tokenize the sentence
tokens = tokenizer('Hello world', return_tensors='pt')
# Process the tokens
output = model(**tokens)[0]

我们在这里使用的模型叫做 \(\text{DeBERTa v3}\),在撰写本文时,它是性能最佳的词元嵌入语言模型之一,同时体积小且效率高。它在论文《\(\text{DeBERTaV3}\):使用 \(\text{ELECTRA}\) 式预训练梯度解耦嵌入共享改进 \(\text{DeBERTa}\)》 中有所描述。

这段代码下载了一个预训练的分词器和模型,然后用它们来处理字符串“\(\text{Hello world}\)”。模型的输出随后保存在 \(\text{output}\) 变量中。

我们首先打印 \(\text{output}\) 变量的维度来检查它(我们期望它是一个多维数组):

1
output.shape

这会打印出:

1
torch.Size([1, 4, 384])

跳过第一维,我们可以将其解读为四个词元,每个词元都嵌入在一个包含 \(\text{384}\) 个值的向量中。第一维是批次维度\(\text{batch dimension}\)),用于当我们希望同时向模型发送多条输入句子时(例如在训练时),它们会同时被处理,从而加快了处理过程。

但是这四个向量是什么呢?分词器是将这两个词分成了四个词元,还是发生了其他事情?我们可以使用我们所学的关于分词器的知识来检查它们:

1
2
for token in tokens['input_ids'][0]:
print(tokenizer.decode(token))

这会打印出:

1
2
3
4
[CLS]
Hello
world
[SEP]

这个特定的分词器和模型是通过在字符串的开头和结尾添加 \(\text{[CLS]}\)\(\text{[SEP]}\) 词元来操作的。

我们的语言模型现在已经处理了文本输入。其输出结果如下:

1
2
3
4
5
6
tensor([[
[-3.3060, -0.0507, -0.1098, ..., -0.1704, -0.1618, 0.6932],
[ 0.8918, 0.0740, -0.1583, ..., 0.1869, 1.4760, 0.0751],
[ 0.0871, 0.6364, -0.3050, ..., 0.4729, -0.1829, 1.0157],
[-3.1624, -0.1436, -0.0941, ..., -0.0290, -0.1265, 0.7954]
]], grad_fn=<NativeLayerNormBackward0>)

这是语言模型的原始输出。大型语言模型的应用都是构建在像这样的输出之上的。

我们在图 \(\text{2}-9\) 中概述了语言模型的输入分词和生成的输出。从技术上讲,从词元 \(\text{ID}\) 转换为原始嵌入是语言模型内部发生的第一步

F2.1

像这样的可视化对于下一章我们开始研究基于 \(\text{Transformer}\)\(\text{LLM}\) 如何工作时至关重要

文本嵌入(用于句子和整个文档)

Text Embeddings (for Sentences and Whole Documents)

虽然词元嵌入\(\text{LLM}\) 运行的关键,但许多 \(\text{LLM}\) 应用程序需要对整个句子、段落,甚至是文本文档进行操作。这催生了特殊语言模型,它们可以生成文本嵌入——一个单一的向量来表征比单个词元更长的一段文本。

我们可以将文本嵌入模型视为接收一段文本,并最终生成一个单一向量,该向量以某种有用的形式表征该文本并捕获其含义。图 \(\text{2}-10\) 展示了这一过程。

F2.1

生成文本嵌入向量有多种方法。最常见的方法之一是平均模型生成的所有词元嵌入的值。然而,高质量的文本嵌入模型往往是专门针对文本嵌入任务进行训练的。

我们可以使用 \(\text{sentence}-\text{transformers}\) 来生成文本嵌入,这是一个用于利用预训练嵌入模型的流行软件包。像上一章的 \(\text{transformers}\) 一样,该软件包可用于加载公开可用的模型。为了说明如何创建嵌入,我们使用 \(\text{all-mpnet-base-v2}\) 模型。请注意,在\(\text{4}\)中,我们将进一步探讨如何为您的任务选择一个嵌入模型。

1
2
3
4
5
from sentence_transformers import SentenceTransformer
# Load model
model = SentenceTransformer("sentence-transformers/all-mpnet-base-v2")
# Convert text to text embeddings
vector = model.encode("Best movie ever!")

嵌入向量的数值数量,即维度,取决于底层的嵌入模型。让我们查看我们模型的维度:

1
vector.shape
1
(768,)

现在,这个句子被编码在这一个维度为 \(\text{768}\) 个数值的向量中。在本书的第二部分,当我们开始研究应用程序时,我们将看到这些文本嵌入向量在支持从分类到语义搜索再到 \(\text{RAG}\) 的一切应用中的巨大作用。

超越 \(\text{LLM}\) 的词嵌入

Word Embeddings Beyond LLMs

嵌入甚至在文本和语言生成之外也很有用。事实证明,嵌入,或者说为对象分配有意义的向量表征,在许多领域都很有用,包括推荐引擎机器人技术。在本节中,我们将介绍如何使用预训练的 \(\text{word}2\text{vec}\) 嵌入,并简要介绍该方法如何创建词嵌入。了解 \(\text{word}2\text{vec}\) 是如何训练的,将为您学习\(\text{10}\)中的对比训练打下基础。然后,在接下来的部分中,我们将看到这些嵌入如何用于推荐系统

使用预训练词嵌入

Using pretrained Word Embeddings

让我们看看如何使用 \(\text{Gensim}\)下载预训练的词嵌入(例如 \(\text{word}2\text{vec}\)\(\text{GloVe}\)):

1
2
3
4
5
import gensim.downloader as api
# Download embeddings (66MB, glove, trained on wikipedia, vector size: 50)
# Other options include "word2vec-google-news-300"
# More options at https://github.com/RaRe-Technologies/gensim-data
model = api.load("glove-wiki-gigaword-50")

在这里,我们下载了在 \(\text{Wikipedia}\) 上训练的大量词语的嵌入。然后,我们可以通过查看特定词语(例如“\(\text{king}\)”)的最近邻来探索嵌入空间:

1
model.most_similar([model['king']], topn=11)

输出:

1
2
3
4
5
6
7
8
9
10
11
[('king', 1.0000001192092896),
('prince', 0.8236179351806641),
('queen', 0.7839043140411377),
('ii', 0.7746230363845825),
('emperor', 0.7736247777938843),
('son', 0.766719400882721),
('uncle', 0.7627150416374207),
('kingdom', 0.7542161345481873),
('throne', 0.7539914846420288),
('brother', 0.7492411136627197),
('ruler', 0.7434253692626953)]

\(\text{Word}2\text{vec}\) 算法与对比训练

The Word2vec Algorithm and Contrastive Training

论文《向量空间中词语表征的有效估计》(\(\text{Efficient estimation of word representations in vector space}\) 中描述的 \(\text{word}2\text{vec}\) 算法\(\text{Illustrated Word}2\text{vec}\) 一书中有详细描述。我们将中心思想浓缩于此,因为我们将在下一节讨论创建推荐引擎嵌入的方法时以此为基础。

就像 \(\text{LLM}\) 一样,\(\text{word}2\text{vec}\) 是在从文本生成的示例上进行训练的。例如,假设我们有弗兰克·赫伯特(\(\text{Frank Herbert}\))的《沙丘》小说中的文本:“\(\text{Thou shalt not make a machine in the likeness of a human mind}\)”(你不可制造一台模仿人类心智的机器)。该算法使用一个滑动窗口来生成训练示例。例如,我们可以设置窗口大小为二,这意味着我们考虑中心词两侧的两个邻居。

嵌入是从一个分类任务中生成的。该任务用于训练一个神经网络预测词语是否通常出现在同一上下文中(这里的“上下文”指的是在我们建模的训练数据集中的许多句子中)。我们可以将其视为一个接收两个词语并输出 \(\text{1}\)(如果它们倾向于出现在同一上下文中)或 \(\text{0}\)(如果它们不出现)的神经网络。

在滑动窗口的第一个位置,我们可以生成四个训练示例,如图 \(\text{2}-11\) 所示。

F2.1

在生成的每个训练示例中,中心词用作一个输入,其每个邻居在每个训练示例中都是一个不同的第二个输入。我们期望最终训练出的模型能够对这种邻居关系进行分类,并在它接收到的两个输入词语确实是邻居时输出 \(\text{1}\)。这些训练示例如图 \(\text{2}-12\) 所示。

F2.1

然而,如果我们的数据集目标值只有 \(\text{1}\),那么模型可以通过一直输出 \(\text{1}\)作弊并取得满分。为了避免这种情况,我们需要用不典型为邻居的词语示例来丰富我们的训练数据集。这些被称为负面示例\(\text{negative examples}\)),如图 \(\text{2}-13\) 所示。

F2.1

事实证明,我们在选择负面示例时不必过于科学。许多有用的模型都是通过简单地从随机生成的示例中检测正面示例的能力而产生的(灵感来源于一个重要的思想,称为噪声对比估计 (\(\text{noise-contrastive estimation}\)),并在《噪声对比估计:一种用于非标准化统计模型的新的估计原理》 中有所描述)。因此在这种情况下,我们获取随机词语并将它们添加到数据集中,并指出它们不是邻居(因此当模型看到它们时应该输出 \(\text{0}\))。

通过这种方式,我们看到了 \(\text{word}2\text{vec}\) 的两个主要概念(图 \(\text{2}-14\)):跳字模型\(\text{skip-gram}\),选择相邻词语的方法),以及负采样\(\text{negative sampling}\),通过从数据集中随机采样添加负面示例)。

F2.1

我们可以从运行的文本中生成数百万甚至数十亿个这样的训练示例。在继续在这个数据集上训练神经网络之前,我们需要做出一些分词决策,就像我们对 \(\text{LLM}\) 分词器所看到的那样,这些决策包括如何处理大小写标点符号,以及我们希望词汇表中有多少个词元。

然后我们为每个词元创建一个嵌入向量,并对其进行随机初始化,如图 \(\text{2}-15\) 所示。在实践中,这是一个维度为 \(\text{vocab\_size} \times \text{embedding\_dimensions}\) 的矩阵。

F2.1

然后,模型在每个示例上进行训练,以接收两个嵌入向量预测它们是否相关。我们可以在图 \(\text{2}-16\) 中看到它的样子。

F2.1

根据其预测是否正确,典型的机器学习训练步骤会更新嵌入,以便下一次向模型展示这两个向量时,它有更大的机会是正确的。到训练过程结束时,我们就为词汇表中的所有词元获得了更好的嵌入

这种模型接收两个向量并预测它们是否具有某种关系的思想是机器学习中最强大的思想之一,并且一次又一次地证明它在语言模型中运作得非常好。这就是为什么我们用\(\text{10}\)专门介绍这个概念以及它如何为特定任务(如句子嵌入和检索)优化语言模型。

同样的想法也是弥合文本和图像等模态的核心,这对于 \(\text{AI}\) 图像生成模型至关重要,正如我们将在关于多模态模型\(\text{9}\)中看到的那样。在这种公式中,模型会得到一张图像和一个标题,它应该预测该标题是否描述了该图像。

推荐系统的嵌入

Embeddings for Recommendation Systems

正如我们所提到的,嵌入的概念在许多其他领域都很有用。例如,在工业界,它被广泛用于推荐系统

通过嵌入推荐歌曲

Recommending Songs by Embeddings

在本节中,我们将使用 \(\text{word}2\text{vec}\) 算法,利用人工制作的音乐播放列表嵌入歌曲。想象一下,我们将每首歌曲视为一个词语或词元,并将每个播放列表视为一个句子。然后,这些嵌入可用于推荐在播放列表中经常一起出现的相似歌曲

我们将使用由康奈尔大学的 \(\text{Shuo Chen}\) 收集的数据集。它包含了美国数百个电台的播放列表。图 \(\text{2}-17\) 展示了这个数据集。

F2.1

在我们研究它是如何构建的之前,让我们先演示一下最终产品。因此,让我们给它几首歌曲,看看它会推荐什么。

让我们从给出迈克尔·杰克逊的“\(\text{Billie Jean}\)”开始,这首歌的 \(\text{ID}\)\(\text{3822}\)

1
2
# We will define and explore this function in detail below
print_recommendations(3822)

F2.1

看起来很合理。麦当娜\(\text{Madonna}\))、普林斯\(\text{Prince}\))以及迈克尔·杰克逊\(\text{Michael Jackson}\))的其他歌曲都是最近的邻居。

让我们从流行乐转向说唱乐,看看 \(\text{2Pac}\) 的“\(\text{California Love}\) 的邻居:

1
print_recommendations(842)

F2.1

又是一个非常合理的列表!既然我们知道它有效,那么让我们看看如何构建这样一个系统。

训练歌曲嵌入模型

Training a Song Embedding Model

我们将从加载包含歌曲播放列表以及每首歌曲的元数据(例如它的标题和艺术家)的数据集开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import pandas as pd
from urllib import request
# Get the playlist dataset file
data = request.urlopen('https://storage.googleapis.com/maps-premium/data
set/yes_complete/train.txt')
# Parse the playlist dataset file. Skip the first two lines as
# they only contain metadata
lines = data.read().decode("utf-8").split('\n')[2:]
# Remove playlists with only one song
playlists = [s.rstrip().split() for s in lines if len(s.split()) > 1]
# Load song metadata
songs_file = request.urlopen('https://storage.googleapis.com/maps-premium/data
set/yes_complete/song_hash.txt')
songs_file = songs_file.read().decode("utf-8").split('\n')
songs = [s.rstrip().split('\t') for s in songs_file]
songs_df = pd.DataFrame(data=songs, columns = ['id', 'title', 'artist'])
songs_df = songs_df.set_index('id')

现在我们已经保存了它们,让我们检查一下 \(\text{playlists}\) 列表。其中的每个元素都是一个包含歌曲 \(\text{ID}\) 列表播放列表

1
2
print( 'Playlist #1:\n ', playlists[0], '\n')
print( 'Playlist #2:\n ', playlists[1])

F2.1

让我们来训练模型:

1
2
3
4
5
from gensim.models import Word2Vec
# Train our Word2Vec model
model = Word2Vec(
playlists, vector_size=32, window=20, negative=50, min_count=1, workers=4
)

这需要一两分钟来训练,并为我们拥有的每首歌曲计算出嵌入。现在,我们可以使用这些嵌入来查找相似的歌曲,就像我们之前对词语所做的那样:

1
2
3
song_id = 2172
# Ask the model for songs similar to song #2172
model.wv.most_similar(positive=str(song_id))

输出:

F2.1

这是嵌入与歌曲 \(\text{2172}\) 最相似的歌曲列表。

在这种情况下,这首歌是:

1
print(songs_df.iloc[2172])

F2.1

这产生了全部属于同一重金属和硬摇滚流派的推荐:

1
2
3
4
5
6
7
8
import numpy as np
def print_recommendations(song_id):
similar_songs = np.array(
model.wv.most_similar(positive=str(song_id),topn=5)
)[:,0]
return songs_df.iloc[similar_songs]
# Extract recommendations
print_recommendations(2172)

F2.1

总结

在本章中,我们涵盖了 \(\text{LLM}\)词元分词器,以及使用词元嵌入的有用方法。这为我们准备在下一章中更仔细地研究语言模型,同时也为我们打开了学习嵌入如何用于语言模型之外的大门。

我们探讨了分词器是如何处理 \(\text{LLM}\) 输入第一步,将原始文本输入转换为词元 \(\text{ID}\)。常见的分词方案包括将文本分解为词语、子词元、字符或字节,具体取决于给定应用程序的特定要求。

对现实世界中预训练分词器(从 \(\text{BERT}\)\(\text{GPT}-2\)\(\text{GPT}-4\) 和其他模型)的导览向我们展示了某些分词器更优秀的方面(例如,保留大小写、换行符或其他语言中的词元等信息),以及分词器之间仅仅不同的其他方面(例如,它们如何分解某些词语)。

三个主要的分词器设计决策是分词器算法(例如,\(\text{BPE}\)\(\text{WordPiece}\)\(\text{SentencePiece}\))、分词参数(包括词汇量大小、特殊词元、大小写的处理以及不同语言的处理),以及训练分词器的数据集

语言模型也是高质量情境化词元嵌入的创建者,这些嵌入对原始静态嵌入进行了改进。这些情境化词元嵌入用于包括命名实体识别 (\(\text{NER}\))、抽取式文本摘要文本分类在内的任务。除了生成词元嵌入,语言模型还可以生成涵盖整个句子甚至文档文本嵌入。这为本书第二部分涵盖语言模型应用的许多应用程序提供了支持。

\(\text{LLM}\) 出现之前,\(\text{word}2\text{vec}\)\(\text{GloVe}\)\(\text{fastText}\) 等词嵌入方法很流行。在语言处理中,它们已基本被语言模型生成的情境化词嵌入所取代。\(\text{word}2\text{vec}\) 算法依赖于两个主要思想:跳字模型\(\text{skip}-\text{gram}\))和负采样\(\text{negative sampling}\))。它还使用了与我们在\(\text{10}\)中将看到的类型相似的对比训练

正如我们在使用精选歌曲播放列表构建的音乐推荐器中所讨论的,嵌入对于创建和改进推荐系统非常有用。

在下一章中,我们将深入探讨分词之后的流程\(\text{LLM}\) 如何处理这些词元并生成文本?我们将探讨使用 \(\text{Transformer}\) 架构的 \(\text{LLM}\) 如何工作的一些主要直觉

书籍各章的机翻md文件:
《Hands-On Large Language Models》目录及前言
《Hands-On Large Language Models》第1章 大型语言模型简介
《Hands-On Large Language Models》第2章 词元与嵌入
《Hands-On Large Language Models》第3章 深入了解大型语言模型
《Hands-On Large Language Models》第4章 文本分类
《Hands-On Large Language Models》第5章 文本聚类和主题建模
《Hands-On Large Language Models》第6章 提示工程
《Hands-On Large Language Models》第7章 高级文本生成技术与工具
《Hands-On Large Language Models》第8章 语义搜索与检索增强生成
《Hands-On Large Language Models》第9章 多模态大型语言模型
《Hands-On Large Language Models》第10章 创建文本嵌入模型
《Hands-On Large Language Models》第11章 微调用于分类的表征模型
《Hands-On Large Language Models》第12章 生成模型的微调

第一部分 理解语言模型

Understanding Language Models

\(\text{1}\) 大型语言模型简介

An Introduction to Large Language Models

人类正处于一个拐点。从 \(\text{2012}\) 年开始,构建 \(\text{AI}\) 系统(使用深度神经网络)的发展加速,到了十年末,它们产生了第一个能够写出与人类所写文章无法区分的软件系统。这个系统是一个名为 \(\text{Generative Pre-trained Transformer 2}\),简称 \(\text{GPT}-2\)\(\text{AI}\) 模型。\(\text{2022}\) 年标志着 \(\text{ChatGPT}\) 的发布,它展示了这项技术将如何深刻地彻底改变我们与技术和信息的交互方式。新的 \(\text{AI}\) 模型最初是类似人类的聊天机器人,在五天内达到了一百万活跃用户,随后在两个月内达到了一亿活跃用户,并迅速演变为我们在处理翻译、文本生成、摘要等常见任务方法上的重大转变。它成为了程序员、教育工作者和研究人员不可或缺的工具

\(\text{ChatGPT}\) 的成功是空前的,并普及了对其背后技术——即大型语言模型 (\(\text{LLM}\))——的更多研究。专有模型和公共模型都在稳步发布,它们的性能不断接近,并最终赶上了 \(\text{ChatGPT}\) 的表现。毫不夸张地说,几乎所有的注意力都集中在 \(\text{LLM}\) 上。

因此,至少对我们而言,\(\text{2023}\) 年将永远被称为彻底改变我们领域的一年,这个领域就是语言人工智能 (\(\text{Language AI}\)),其特点是开发出能够理解和生成人类语言的系统。

然而,\(\text{LLM}\) 已经存在一段时间了,而较小的模型至今仍然具有相关性。\(\text{LLM}\) 远不止是一个单一的模型,语言 \(\text{AI}\) 领域还有许多其他值得探索的技术和模型。

在本书中,我们的目标是让读者对 \(\text{LLM}\) 和整个语言 \(\text{AI}\) 领域的基本原理有扎实的理解。本章是本书其余章节的支架,并将介绍我们将在所有章节中使用的概念和术语。

但最重要的是,我们打算在本章中回答以下问题:

  • 什么是语言 \(\text{AI}\)
  • 什么是大型语言模型?
  • 大型语言模型的常见用例和应用是什么?
  • 我们如何自己使用大型语言模型?

什么是语言 \(\text{AI}\)

What Is Language AI?

人工智能 (\(\text{AI}\)) 一词通常用于描述致力于执行接近人类智能的任务(例如语音识别、语言翻译和视觉感知)的计算机系统。它是软件的智能,而不是人类的智能。

以下是人工智能学科创始人之一提供的更正式的定义:

[人工智能是]制造智能机器,特别是智能计算机程序的科学和工程。它与使用计算机理解人类智能的类似任务相关,但 \(\text{AI}\) 不必局限于生物学上可观察到的方法。 —\(\text{John McCarthy}\)\(\text{2007}^1\)

由于 \(\text{AI}\) 的不断发展,该术语已被用来描述各种各样的系统,其中一些可能并未真正体现智能行为。例如,计算机游戏中的角色(\(\text{NPC}\) [非玩家角色])经常被称为 \(\text{AI}\),尽管许多不过是 \(\text{if}-\text{else}\) 语句。

语言 \(\text{AI}\) 指的是 \(\text{AI}\) 的一个子领域,专注于开发能够理解、处理和生成人类语言的技术。随着机器学习方法在解决语言处理问题上不断取得成功,语言 \(\text{AI}\) 一词通常可以与自然语言处理 (\(\text{NLP}\)) 互换使用。

我们使用语言 \(\text{AI}\) 这个术语来涵盖那些技术上可能不是 \(\text{LLM}\),但仍对该领域产生重大影响的技术,比如检索系统如何能赋予 \(\text{LLM}\) 超能力(参见\(\text{8}\))。

在本书中,我们希望专注于在塑造语言 \(\text{AI}\) 领域方面发挥重要作用的模型。这意味着探索的不仅仅是孤立的 \(\text{LLM}\)。然而,这引出了一个问题:什么是大型语言模型?

为了在本章中开始回答这个问题,我们首先来探索语言 \(\text{AI}\) 的历史。

语言 \(\text{AI}\) 的近代史

A Recent History of Language AI

如图 \(\text{1-1}\) 所示,语言 \(\text{AI}\) 的历史涵盖了旨在表征和生成语言的许多发展和模型。

F1.1

然而,语言对于计算机来说是一个棘手的概念。文本本质上是非结构化的,当用零和一(单个字符)来表示时,它会失去其含义。因此,纵观语言 \(\text{AI}\) 的历史,有一个重要的焦点是以结构化的方式表示语言,以便计算机可以更容易地使用它。图 \(\text{1-2}\) 中提供了这些语言 \(\text{AI}\) 任务的示例。

F1.2

将语言表征为词袋模型

Representing Language as a Bag-of-Words

我们的语言 \(\text{AI}\) 历史始于一种称为词袋模型\(\text{bag-of-words}\))的技术,这是一种表征非结构化文本的方法。它最早在大约 \(\text{1950}\) 年代被提及,但在 \(\text{2000}\) 年代左右流行起来。

词袋模型的工作原理如下:假设我们有两句话,我们想为其创建数字表示。词袋模型的第一步是分词\(\text{tokenization}\)),即将句子分割成单独的词或子词(\(\text{tokens}\))的过程,如图 \(\text{1-3}\) 所示。

F1.1

最常见的分词方法是通过空格分割来创建单独的词。然而,这有其缺点,因为某些语言(例如普通话)在单个词周围没有空格。在下一章中,我们将深入探讨分词以及该技术如何影响语言模型。

如图 \(\text{1-4}\) 所示,在分词之后,我们结合每句话中所有独特的词来创建一个词汇表\(\text{vocabulary}\)),我们可以用它来表征这些句子。

F1.1

使用我们的词汇表,我们简单地计算每个词在每句话中出现的次数,名副其实地创建了一个词袋。因此,词袋模型旨在以数字形式创建文本的表征,这些数字也称为向量向量表征,如图 \(\text{1-5}\) 所示。在本书中,我们将这类模型称为表征模型\(\text{representation models}\))。

F1.1

尽管词袋模型是一种经典方法,但它绝非完全过时。在\(\text{5}\)中,我们将探讨它如何仍然可以用于补充更近代的语言模型。

使用密集向量嵌入实现更好的表征

Better Representations with Dense Vector Embeddings

词袋模型(\(\text{Bag-of-words}\))虽然是一种优雅的方法,但它有一个缺陷:它认为语言不过是一个几乎是字面意义上的词袋,忽略了文本的语义性质或含义

\(\text{2013}\) 年发布的 \(\text{word}2\text{vec}\) 是最早成功地在嵌入\(\text{embeddings}\))中捕捉文本含义的尝试之一。嵌入是数据的向量表征,它试图捕捉数据的含义。为此,\(\text{word}2\text{vec}\) 通过在大量的文本数据(例如整个 \(\text{Wikipedia}\))上进行训练,学习词语的语义表征。

为了生成这些语义表征,\(\text{word}2\text{vec}\) 利用了神经网络。这些网络由相互连接的节点层组成,用于处理信息。如图 \(\text{1-6}\) 所示,神经网络可以有许多层,其中每条连接都有一个权重,该权重取决于输入。这些权重通常被称为模型的参数

F1.1

\(\text{word}2\text{vec}\) 使用这些神经网络,通过查看词语在给定句子中倾向于出现在哪些其他词语的旁边,来生成词嵌入。我们首先为词汇表中的每个词分配一个向量嵌入,例如每个词都有 \(\text{50}\) 个值,并用随机值初始化。然后,如图 \(\text{1-7}\) 所示,在每个训练步骤中,我们从训练数据中取出词对,模型会尝试预测它们是否可能在句子中是相邻的词。

F1.1

在这个训练过程中,\(\text{word}2\text{vec}\) 学习了词语之间的关系,并将这些信息提取到嵌入中。如果两个词倾向于拥有相同的邻居,它们的嵌入就会彼此更靠近,反之亦然。在\(\text{2}\)中,我们将更仔细地研究 \(\text{word}2\text{vec}\) 的训练过程。

由此产生的嵌入捕捉了词语的含义,但这到底意味着什么?为了说明这种现象,让我们稍微过度简化一下,想象我们有几个词的嵌入,即“\(\text{apple}\)”(苹果)和“\(\text{baby}\)”(婴儿)。嵌入试图通过表征词语的属性来捕捉含义。例如,“\(\text{baby}\)”这个词在“新生儿”和“人类”这两个属性上可能得分很高,而“\(\text{apple}\)”在这个属性上得分很低。

如图 \(\text{1-8}\) 所示,嵌入可以有许多属性来表征词语的含义。由于嵌入的大小是固定的,因此选择其属性是为了创建该词语的心智表征

F1.1

在实践中,这些属性通常非常晦涩,很少与单个实体或人类可识别的概念相关。然而,这些属性结合在一起对计算机是有意义的,并且是将人类语言转化为计算机语言的良好方式。

嵌入非常有帮助,因为它们允许我们衡量两个词语之间的语义相似性。使用各种距离度量,我们可以判断一个词与另一个词的接近程度。如图 \(\text{1-9}\) 所示,如果我们将这些嵌入压缩成二维表征,您会注意到含义相似的词语倾向于更靠近。在\(\text{5}\)中,我们将探讨如何将这些嵌入压缩到 \(\text{n}\) 维空间。

F1.1

嵌入的类型

Types of Embeddings

嵌入有许多类型,例如词嵌入句子嵌入,它们用于指示不同级别的抽象(词语与句子),如图 \(\text{1-10}\) 所示。

F1.1

例如,词袋模型文档级别创建嵌入,因为它代表了整个文档。相比之下,\(\text{word}2\text{vec}\) 仅为词语生成嵌入。

在整本书中,嵌入将扮演核心角色,因为它们被用于许多用例,例如分类(参见\(\text{4}\))、聚类(参见\(\text{5}\))以及语义搜索和检索增强生成(参见\(\text{8}\))。在\(\text{2}\)中,我们将首次深入探讨分词嵌入\(\text{token embeddings}\))。

使用注意力机制编码和解码上下文

Encoding and Decoding Context with Attention

\(\text{word}2\text{vec}\) 的训练过程创建了静态的、可下载的词语表征。例如,无论“\(\text{bank}\)”(银行/河岸)在何种上下文中使用,它将始终具有相同的嵌入。然而,“\(\text{bank}\)”可以指金融机构,也可以指河流的岸边。它的含义,因此它的嵌入,应该根据上下文而变化。

编码这种文本的一个进步是通过循环神经网络 (\(\text{RNNs}\)) 实现的。这些是神经网络的变体,可以将序列建模为额外的输入

为此,这些 \(\text{RNNs}\) 用于两个任务:编码或表征输入句子,以及解码或生成输出句子。图 \(\text{1-11}\) 通过展示“\(\text{I love llamas}\)”这样的句子如何被翻译成荷兰语“\(\text{Ik hou van lama’s}\)”来阐释这个概念。

F1.1

此架构中的每一步都是自回归\(\text{autoregressive}\))的。如图 \(\text{1-12}\) 所示,在生成下一个词时,此架构需要消耗所有先前生成的词语

F1.1

编码步骤旨在尽可能好地表征输入,以嵌入的形式生成上下文,该上下文作为解码器的输入。为了生成此表征,它将词嵌入作为输入,这意味着我们可以使用 \(\text{word}2\text{vec}\) 进行初始表征。在图 \(\text{1-13}\) 中,我们可以观察到这个过程。请注意输入是如何按顺序、一次一个地处理的,输出也是如此。

F1.1

然而,这个上下文嵌入使得处理较长的句子变得困难,因为它仅仅是一个单一的嵌入来代表整个输入。\(\text{2014}\) 年,引入了一种称为注意力机制\(\text{attention}\))的解决方案,它极大地改进了原有架构。注意力机制允许模型专注于输入序列中相互关联(“关注”彼此)并放大其信号的部分,如图 \(\text{1-14}\) 所示。注意力机制选择性地确定给定句子中哪些词语最重要。

F1.1

例如,输出词“\(\text{lama’s}\)”是荷兰语中“\(\text{llamas}\)”(美洲驼)的意思,这就是为什么两者之间的注意力分数很高。类似地,词语“\(\text{lama’s}\)”和“\(\text{I}\)”之间的注意力较低,因为它们的关联性不高。在\(\text{3}\)中,我们将更深入地探讨注意力机制。

通过在解码步骤中添加这些注意力机制\(\text{RNN}\) 可以为序列中的每个输入词生成与潜在输出相关的信号所有输入词的隐藏状态都被传递给解码器,而不仅仅是传递一个上下文嵌入。图 \(\text{1-15}\) 展示了这一过程。

F1.1

因此,在生成“\(\text{Ik hou van lama’s}\)”的过程中,\(\text{RNN}\) 会跟踪它主要关注的词语来执行翻译。与 \(\text{word}2\text{vec}\) 相比,这种架构允许通过“关注”整个句子来表征文本的顺序性质及其出现的上下文。然而,这种顺序性质阻碍了模型在训练过程中的并行化

注意力机制就是你所需要的一切

Attention Is All You Need

注意力机制的真正力量,以及驱动大型语言模型惊人能力的关键,最早出现在 \(\text{2017}\) 年发布的著名论文 《注意力机制就是你所需要的一切》(\(\text{Attention is all you need}\) 中。作者提出了一种名为 \(\text{Transformer}\) 的网络架构,它完全基于注意力机制,并移除了我们之前看到的循环网络\(\text{recurrence network}\))。与循环网络相比,\(\text{Transformer}\) 可以并行训练,这极大地加快了训练速度。

\(\text{Transformer}\) 中,编码器解码器组件堆叠在一起,如图 \(\text{1-16}\) 所示。该架构仍然是自回归的,在生成一个新词之前,需要消耗所有先前生成的词。

F1.1

现在,编码器和解码器块都将围绕注意力机制展开,而不是利用具有注意力功能的 \(\text{RNN}\)。如图 \(\text{1-17}\) 所示,\(\text{Transformer}\) 中的编码器块由两部分组成:自注意力\(\text{self-attention}\))和一个前馈神经网络\(\text{feedforward neural network}\))。

F1.1

与以前的注意力方法相比,自注意力可以关注单个序列内的不同位置,从而更轻松、更准确地表征输入序列,如图 \(\text{1-18}\) 所示。它不再是一次处理一个分词\(\text{token}\)),而可以一次性查看整个序列

F1.1

与编码器相比,解码器有一个额外的层,该层会关注编码器的输出(以找到输入中的相关部分)。如图 \(\text{1-19}\) 所示,此过程类似于我们前面讨论过的 \(\text{RNN}\) 注意力解码器

F1.1

如图 \(\text{1-20}\) 所示,解码器中的自注意力层掩盖未来的位置,使其仅关注较早的位置,以防止在生成输出时信息泄漏。

F1.1

这些构建块共同创建了 \(\text{Transformer}\) 架构,并且是语言 \(\text{AI}\) 中许多有影响力的模型(例如 \(\text{BERT}\)\(\text{GPT}-1\),我们将在本章后面介绍)的基础。在本书中,我们将使用的大多数模型都是基于 \(\text{Transformer}\) 的模型。

\(\text{Transformer}\) 架构的内涵远不止我们迄今为止探讨的这些。在\(\text{2}\)\(\text{3}\)中,我们将探讨 \(\text{Transformer}\) 模型工作得如此出色的众多原因,包括多头注意力\(\text{multi-head attention}\))、位置嵌入\(\text{positional embeddings}\))和层归一化\(\text{layer normalization}\))。

表征模型:仅编码器 (\(\text{Encoder}\)-Only) 模型

Representation Models: Encoder-Only Models

最初的 \(\text{Transformer}\) 模型是编码器-解码器架构,它非常适用于翻译任务,但不能轻易地用于其他任务,例如文本分类。

\(\text{2018}\) 年,引入了一种名为 \(\text{BERT}\)(来自 \(\text{Transformer}\) 的双向编码器表征,\(\text{Bidirectional Encoder Representations from Transformers}\))的新架构,该架构可用于各种任务,并将成为未来几年语言 \(\text{AI}\) 的基础。\(\text{BERT}\) 是一种仅编码器 (\(\text{Encoder}\)-Only) 架构,它专注于表征语言,如图 \(\text{1-21}\) 所示。这意味着它只使用编码器,并完全移除了解码器。

F1.1

这些编码器块与我们之前看到的相同:自注意力之后是前馈神经网络。输入包含一个额外的分词\(\text{token}\)),即 \(\text{[CLS]}\) 或分类分词,它被用作整个输入的表征。通常,我们使用这个 \(\text{[CLS]}\) 分词作为输入嵌入,用于在特定任务(如分类)上微调模型。

训练这些编码器堆栈可能是一项困难的任务,\(\text{BERT}\) 通过采用一种称为掩码语言建模\(\text{masked language modeling}\))的技术来解决(参见\(\text{2}\)\(\text{11}\))。如图 \(\text{1-22}\) 所示,此方法会掩盖输入的一部分,让模型进行预测。这种预测任务很困难,但它允许 \(\text{BERT}\) 创建更准确的(中间)输入表征。

F1.1

这种架构和训练过程使得 \(\text{BERT}\) 及相关架构在表征上下文语言方面表现出色。类似 \(\text{BERT}\) 的模型通常用于迁移学习\(\text{transfer learning}\)),它涉及首先对其进行语言建模的预训练,然后针对特定任务进行微调。例如,通过在整个 \(\text{Wikipedia}\) 上训练 \(\text{BERT}\),它学会了理解文本的语义和上下文性质。然后,如图 \(\text{1-23}\) 所示,我们可以使用该预训练模型来微调它以进行特定任务,例如文本分类

F1.1

预训练模型的一个巨大好处是,大部分训练工作已经为我们完成了。针对特定任务的微调通常计算密集度较低所需数据较少。此外,类似 \(\text{BERT}\) 的模型在其架构中的几乎每个步骤都会生成嵌入。这也使得 \(\text{BERT}\) 模型可以作为特征提取机器,而无需针对特定任务进行微调。

类似 \(\text{BERT}\)仅编码器模型将在本书的许多部分中使用。多年来,它们一直并仍然被用于常见的任务,包括分类任务(参见\(\text{4}\))、聚类任务(参见\(\text{5}\))和语义搜索(参见\(\text{8}\))。

在整本书中,我们将仅编码器模型称为表征模型\(\text{representation models}\)),以区别于我们称为生成模型\(\text{generative models}\))的仅解码器模型。请注意,主要区别并不在于底层的架构以及这些模型的工作方式。表征模型主要关注于表征语言(例如,通过创建嵌入),并且通常不生成文本。相比之下,生成模型主要关注于生成文本,并且通常不训练用于生成嵌入。

表征模型和生成模型及其组件的区别也将在大多数图片中显示。表征模型蓝绿色的,带有一个小的向量图标(表示其专注于向量和嵌入),而生成模型粉红色的,带有一个小的聊天图标(表示其生成能力)。

生成模型:仅解码器 (\(\text{Decoder}\)-Only) 模型

Generative Models: Decoder-Only Models

\(\text{BERT}\)仅编码器 (\(\text{encoder}\)-only) 架构类似,\(\text{2018}\) 年提出了仅解码器 (\(\text{decoder}\)-only) 架构,以针对生成任务。该架构因其生成能力而被称为 \(\text{Generative Pre-trained Transformer}\) (\(\text{GPT}\))(现在被称为 \(\text{GPT}\)-1,以区别于后续版本)。如图 \(\text{1-24}\) 所示,它堆叠了解码器块,类似于 \(\text{BERT}\) 堆叠编码器的架构。

F1.1

\(\text{GPT}-1\) 是在由 \(\text{7,000}\) 本书和 \(\text{Common Crawl}\)(一个大型网页数据集)组成的语料库上训练的。由此产生的模型包含 \(\text{1.17}\) 亿个参数。每个参数都是一个数值,代表模型对语言的理解。

如果其他条件保持不变,我们期望更多的参数会极大地影响语言模型的能力和性能。考虑到这一点,我们看到越来越大的模型在稳步发布。如图 \(\text{1-25}\) 所示,\(\text{GPT}-2\) 拥有 \(\text{15}\) 亿个参数,紧随其后的是 \(\text{GPT}-3\),使用了 \(\text{1,750}\) 亿个参数。

F1.1

这些生成式仅解码器模型,尤其是那些“更大”的模型,通常被称为大型语言模型 (\(\text{LLM}\))。正如我们将在本章后面讨论的那样,\(\text{LLM}\) 一词并仅限于生成模型(仅解码器),也包括表征模型(仅编码器)。

作为序列到序列的机器,生成式 \(\text{LLM}\) 接收一些文本并尝试对其进行自动补全。虽然这是一个方便的功能,但它们真正的力量来自于被训练成聊天机器人。如果它们可以被训练来回答问题,而不是补全文本呢?通过微调这些模型,我们可以创建可以遵循指令的指令模型或聊天模型。

如图 \(\text{1-26}\) 所示,由此产生的模型可以接收用户查询(提示\(\text{prompt}\)),并输出一个最有可能遵循该提示的响应。因此,您经常会听到生成模型被称为补全模型

F1.1

这些补全模型的一个重要组成部分被称为上下文长度\(\text{context length}\))或上下文窗口\(\text{context window}\))。如图 \(\text{1-27}\) 所示,上下文长度代表模型可以处理的最大分词数\(\text{tokens}\))。大的上下文窗口允许将整个文档传递给 \(\text{LLM}\)。请注意,由于这些模型的自回归性质,随着新分词的生成,当前的上下文长度将会增加

F1.1

生成式 \(\text{AI}\) 之年

The Year of Generative AI

\(\text{LLM}\) 对该领域产生了巨大的影响,并导致一些人将 \(\text{2023}\)称为生成式 \(\text{AI}\) 之年,这得益于 \(\text{ChatGPT}\) (\(\text{GPT}-3.5\)) 的发布、采用和媒体报道。当我们提到 \(\text{ChatGPT}\) 时,我们实际上指的是产品,而不是底层的模型。当它首次发布时,它由 \(\text{GPT}-3.5\) \(\text{LLM}\) 提供支持,此后已发展到包括更多性能更高的变体,例如 \(\text{GPT}-4\)

\(\text{GPT}-3.5\) 并不是唯一在生成式 \(\text{AI}\) 之年产生影响的模型。如图 \(\text{1-28}\) 所示,开源专有\(\text{LLM}\) 都以惊人的速度进入了大众视野。这些开源的基础模型通常被称为 \(\text{foundation models}\),并且可以针对特定任务(例如遵循指令)进行微调。

F1.1

除了广受欢迎的 \(\text{Transformer}\) 架构之外,还出现了新的有前景的架构,例如 \(\text{Mamba}\)\(\text{RWKV}\)。这些新颖的架构试图在具备更大的上下文窗口更快的推理速度等额外优势的同时,达到 \(\text{Transformer}\) 级别的性能。

这些发展体现了该领域的演变,并展示了 \(\text{2023}\) 年确实是 \(\text{AI}\) 领域忙碌的一年。我们竭尽全力才能跟上语言 \(\text{AI}\) 内外发生的诸多进展。

因此,本书探索的不仅仅是最新的 \(\text{LLM}\)。我们将探讨如何使用其他模型,例如嵌入模型仅编码器模型,甚至词袋模型,来增强 \(\text{LLM}\) 的能力。

“大型语言模型”不断变化的定义

The Moving Definition of a “Large Language Model”

在我们回顾语言 \(\text{AI}\) 的近代史时,我们观察到通常只有生成式仅解码器 (\(\text{Transformer}\)) 模型被广泛地称为大型语言模型。尤其当它们被认为是“大”模型时。在实践中,这似乎是一个相当受限的描述!

如果我们创建一个与 \(\text{GPT}-3\) 具有相同能力,但体积小 \(\text{10}\)的模型呢?这样的模型会超出“大型”语言模型的分类吗?

同样,如果一个模型像 \(\text{GPT}-4\) 一样大,可以进行准确的文本分类,但不具备任何生成能力呢?即使它的主要功能不是生成语言,但仍然表征文本,它仍然够得上大型“语言模型”的资格吗?

这类定义的问题在于,我们会将有能力的模型排除在外。我们给一个模型或另一个模型起什么名字,并不会改变它的行为方式。

鉴于“大型语言模型”这一术语的定义倾向于随着新模型的发布而演变,我们希望明确它在本书中的含义。“大”是武断的,今天被认为是大型的模型明天可能就变小了。目前同一种事物有许多不同的名称,对我们来说,“大型语言模型”也包括不生成文本并且可以在消费级硬件上运行的模型。

因此,除了涵盖生成模型外,本书还将涵盖参数少于 \(\text{10}\) 亿不生成文本的模型。我们将探索如何使用嵌入模型表征模型,甚至词袋模型来增强 \(\text{LLM}\) 的能力。

大型语言模型的训练范式

The Training Paradigm of Large Language Models

传统的机器学习通常涉及针对特定任务(例如分类)训练模型。如图 \(\text{1-29}\) 所示,我们认为这是一个一步到位的过程。

F1.1

相比之下,创建 \(\text{LLM}\) 通常至少包含两个步骤:

语言建模(\(\text{Language modeling}\) 第一步,称为预训练\(\text{pretraining}\)),占据了大部分的计算和训练时间。\(\text{LLM}\)大量的互联网文本语料库上进行训练,使模型能够学习语法、上下文和语言模式。这个广泛的训练阶段尚未针对除预测下一个词之外的特定任务或应用。由此产生的模型通常被称为基础模型\(\text{foundation model}\))或基座模型\(\text{base model}\))。这些模型通常不遵循指令

微调(\(\text{Fine-tuning}\) 第二步,微调\(\text{fine-tuning}\)),有时也称为后训练\(\text{post-training}\)),涉及使用先前训练好的模型,并针对一个更窄的任务对其进行进一步训练。这使得 \(\text{LLM}\) 能够适应特定的任务或表现出期望的行为。例如,我们可以微调一个基座模型,使其在分类任务上表现良好,或使其能够遵循指令。这节省了大量的资源,因为预训练阶段成本非常高昂,并且通常需要大多数个人和组织望尘莫及的数据和计算资源。例如,\(\text{Llama 2}\) 是在一个包含 \(\text{2}\) 万亿个分词的数据集上训练的。可以想象创建该模型所需的计算量!在\(\text{12}\)中,我们将介绍几种在您自己的数据集上微调基础模型的方法。

任何经过第一步(预训练)的模型,我们都将其视为预训练模型\(\text{pretrained model}\)),这也包括经过微调的模型。这种两步训练方法如图 \(\text{1-30}\) 所示。

F1.1

可以添加额外的微调步骤,以使模型更符合用户的偏好,我们将在\(\text{12}\)中探讨这一点。

大型语言模型的应用:是什么让它们如此有用?

Large Language Model Applications: What Makes Them So Useful?

\(\text{LLM}\) 的特性使其适用于广泛的任务。借助文本生成和提示(\(\text{prompting}\)),其限制似乎几乎只在于您的想象力。下面,我们探讨一些常见的任务和技术:

检测客户留下的评论是积极还是消极 这是一个(有监督)分类任务,可以使用仅编码器和仅解码器模型来处理,无论是使用预训练模型(参见\(\text{4}\))还是通过微调模型(参见\(\text{11}\))。

开发一个系统来查找工单问题中的常见主题 这是一个(无监督)分类任务,我们没有预定义的标签。我们可以利用仅编码器模型执行分类本身,并利用仅解码器模型来标记这些主题(参见\(\text{5}\))。

构建用于检索和检查相关文档的系统 语言模型系统的一个主要组成部分是其添加外部信息资源的能力。通过语义搜索,我们可以构建系统,使 \(\text{LLM}\) 能够轻松访问和查找所需信息(参见\(\text{8}\))。您还可以通过创建或微调自定义嵌入模型来改进您的系统(参见\(\text{12}\))。

构建一个 \(\text{LLM}\) 聊天机器人,可以利用外部资源,例如工具和文档 这是多种技术的组合,它展示了 \(\text{LLM}\) 的真正力量可以通过附加组件来体现。提示工程(参见\(\text{6}\))、检索增强生成 (\(\text{RAG}\))(参见\(\text{8}\))和微调 \(\text{LLM}\)(参见\(\text{12}\))等方法都是 \(\text{LLM}\) 拼图的组成部分。

构建一个 \(\text{LLM}\),能够根据一张显示您冰箱里产品的图片来撰写食谱 这是一个多模态任务,其中 \(\text{LLM}\) 接收图像并对其所看到的内容进行推理(参见\(\text{9}\))。\(\text{LLM}\) 正在被适配到其他模态,例如视觉,这开启了各种有趣的用例。

\(\text{LLM}\) 应用的创建过程令人非常满意,因为它们在一定程度上受限于您所能想象到的事物。随着这些模型变得越来越准确,将它们实际用于角色扮演撰写儿童书籍等创意用例只会变得越来越有趣。

负责任的 \(\text{LLM}\) 开发与使用

Responsible LLM Development and Usage

由于 \(\text{LLM}\) 的广泛采用,其影响已经且可能将继续是重大的。在我们探索 \(\text{LLM}\) 令人难以置信的能力时,重要的是要考虑到它们的社会和伦理影响。需要考虑的几个关键点:

偏见与公平性 \(\text{LLM}\) 是在可能包含偏见的大量数据上训练的。\(\text{LLM}\) 可能会从这些偏见中学习、开始重现它们,并有可能放大它们。由于训练 \(\text{LLM}\) 的数据很少共享,因此除非您亲自尝试,否则它们可能包含哪些潜在偏见仍然不清楚。

透明度与问责制 由于 \(\text{LLM}\) 令人难以置信的能力,您何时在与人类交谈或何时在与 \(\text{LLM}\) 交谈并不总是明确的。因此,当缺乏人工干预时,\(\text{LLM}\) 在与人类互动中的使用可能会产生意外后果。例如,用于医疗领域的基于 \(\text{LLM}\) 的应用可能会受到作为医疗设备的监管,因为它们可能会影响患者的健康。

生成有害内容 \(\text{LLM}\) 不一定会生成事实内容,并可能会自信地输出不正确的文本。此外,它们可用于生成虚假新闻、文章和其他误导性的信息来源

知识产权 \(\text{LLM}\) 的输出是您的知识产权还是 \(\text{LLM}\) 创建者的知识产权?当输出与训练数据中的某个短语相似时,知识产权是否属于该短语的作者?在无法访问训练数据的情况下,\(\text{LLM}\) 何时使用了受版权保护的材料仍然不清楚。

监管 由于 \(\text{LLM}\) 的巨大影响,各国政府已开始对商业应用进行监管。一个例子是《欧洲 \(\text{AI}\) 法案》\(\text{European AI Act}\)),该法案监管包括 \(\text{LLM}\) 在内的基础模型的开发和部署。

在您开发和使用 \(\text{LLM}\) 时,我们想强调伦理考量的重要性,并敦促您了解更多关于安全和负责任地使用 \(\text{LLM}\) 和一般 \(\text{AI}\) 系统的知识。

所需的有限资源就是您所需要的一切

Limited Resources Are All You Need

我们迄今为止多次提到的计算资源通常与您系统上可用的 \(\text{GPU}\)(图形处理单元)有关。一个强大的 \(\text{GPU}\)(显卡)将使训练和使用 \(\text{LLM}\) 都更加高效和快速。

在选择 \(\text{GPU}\) 时,一个重要的组成部分是您可用的 \(\text{VRAM}\)(视频随机存取存储器)量。这指的是您的 \(\text{GPU}\) 上可用的内存量。实际上,您拥有的 \(\text{VRAM}\) 越多越好。这样做的原因是,如果您没有足够的 \(\text{VRAM}\),有些模型根本无法使用。

由于训练和微调 \(\text{LLM}\)\(\text{GPU}\) 方面可能是一个昂贵的过程,那些没有强大 \(\text{GPU}\) 的人通常被称为 \(\text{GPU}\) 贫困者”\(\text{GPU-poor}\))。这说明了争夺计算资源来训练这些庞大模型的竞争。例如,为了创建 \(\text{Llama 2}\) 系列模型,\(\text{Meta}\) 使用了 \(\text{A100}-\text{80 GB}\)\(\text{GPU}\)。假设租用这样一个 \(\text{GPU}\) 的成本为 \(\text{\$1.50}\)/小时,创建这些模型的总成本将超过 \(\text{\$5,000,000}\)

不幸的是,没有一个单一的规则可以准确确定您需要为特定模型准备多少 \(\text{VRAM}\)。这取决于模型的架构和大小压缩技术上下文大小、运行模型的后端等等。

本书是为“\(\text{GPU}\) 贫困者”准备的! 我们将使用用户无需最昂贵的 \(\text{GPU}\) 或巨额预算即可运行的模型。为此,我们将所有代码放在 \(\text{Google Colab}\) 实例中。在撰写本文时,免费的 \(\text{Google Colab}\) 实例将为您提供一个带有 \(\text{16 GB}\) \(\text{VRAM}\)\(\text{T}4\) \(\text{GPU}\),这是我们建议的最低 \(\text{VRAM}\) 量。

与大型语言模型交互

Interfacing with Large Language Models

\(\text{LLM}\) 交互不仅是使用它们,也是培养对其内部工作原理理解的关键组成部分。由于该领域的诸多发展,目前存在大量的技术、方法和软件包用于与 \(\text{LLM}\) 通信。在整本书中,我们打算探索执行此操作最常见的技术,包括使用专有(闭源)模型公开可用模型

专有(闭源)模型

Proprietary, Private Models

闭源 \(\text{LLM}\) 是指不对公众共享其权重和架构的模型。它们由特定的组织开发,其底层代码被保密。此类模型的示例包括 \(\text{OpenAI}\)\(\text{GPT}-4\)\(\text{Anthropic}\)\(\text{Claude}\)。这些专有模型通常有强大的商业支持,并已在其服务中开发和集成。

您可以通过与 \(\text{LLM}\) 通信的接口(称为 \(\text{API}\),应用程序编程接口)访问这些模型,如图 \(\text{1-31}\) 所示。例如,要在 \(\text{Python}\) 中使用 \(\text{ChatGPT}\),您可以使用 \(\text{OpenAI}\) 的软件包来与该服务进行交互,而无需直接访问它。

F1.1

专有模型的一个巨大好处是用户不需要拥有强大的 \(\text{GPU}\) 即可使用 \(\text{LLM}\)。提供商负责托管和运行模型,并且通常拥有更多的计算资源。托管和使用模型方面不需要专业知识,这显著降低了入门门槛。此外,由于这些组织的巨大投入,这些模型的性能往往优于其开源同行。

缺点是它可能是一项昂贵的服务。提供商管理托管 \(\text{LLM}\) 的风险和成本,这通常转化为付费服务。此外,由于无法直接访问模型,因此没有方法可以自己微调它。最后,您的数据会与提供商共享,这在许多常见用例中是不理想的,例如共享患者数据。

开放模型

Open Models

开放 \(\text{LLM}\) 是指向公众共享其权重和架构供使用的模型。它们仍由特定的组织开发,但通常会共享用于创建或在本地运行模型的代码——这些代码带有不同级别的许可,可能允许或可能不允许模型的商业用途。\(\text{Cohere}\)\(\text{Command R}\)\(\text{Mistral}\) 模型、\(\text{Microsoft}\)\(\text{Phi}\)\(\text{Meta}\)\(\text{Llama}\) 模型都是开放模型的示例。

关于什么才真正代表开源模型,目前仍在持续讨论中。例如,一些公开共享的模型带有限制性商业许可,这意味着该模型不能用于商业目的。对于许多人来说,这不符合开源的真正定义,开源模型的使用不应有任何限制。类似地,训练模型所用的数据及其源代码也很少共享。

如图 \(\text{1-32}\) 所示,只要您拥有可以处理这类模型的强大 \(\text{GPU}\),您就可以下载这些模型并在您的设备上使用它们。

F1.1

这些本地模型的一个主要优势是您(用户)对模型拥有完全的控制权。您可以在不依赖 \(\text{API}\) 连接的情况下使用模型、微调它,并通过它运行敏感数据。您不依赖任何服务,并且对导致模型输出的过程拥有完全的透明度。这种优势得益于使这些过程成为可能的庞大社区,例如 \(\text{Hugging Face}\),展示了协作努力的可能性。

缺点是您需要强大的硬件来运行这些模型,并且在训练或微调它们时需要更多。此外,设置和使用这些模型需要特定的知识(我们将在本书中介绍)。

我们通常更倾向于使用开源模型。与使用专有 \(\text{LLM}\) 相比,它赋予的自由度——可以尝试各种选项、探索内部工作原理以及在本地使用模型——可以说提供了更多的益处。

开源框架

Open Source Frameworks

与闭源 \(\text{LLM}\) 相比,开源 \(\text{LLM}\) 要求您使用特定的软件包来运行它们。在 \(\text{2023}\) 年,发布了许多不同的软件包和框架,它们都以自己的方式与 \(\text{LLM}\) 交互并利用 \(\text{LLM}\)。在数百个可能有价值的框架中摸索并不是最愉快的体验。

因此,您甚至可能会在本书中错过您最喜欢的框架!

我们没有试图涵盖现有的每一个 \(\text{LLM}\) 框架(框架太多,而且数量还在持续增长),而是旨在为您提供利用 \(\text{LLM}\) 的坚实基础。我们的想法是,阅读本书后,您可以轻松掌握大多数其他框架,因为它们的工作方式都非常相似。

我们试图实现的直觉是其中的重要组成部分。如果您不仅对 \(\text{LLM}\) 有直观的理解,而且对在实践中使用常用框架也有直观的理解,那么转向其他框架应该是一项简单明了的任务。

更具体地说,我们专注于后端软件包。这些是没有 \(\text{GUI}\)(图形用户界面)的软件包,旨在高效地加载和运行您设备上的任何 \(\text{LLM}\),例如 \(\text{llama.cpp}\)\(\text{LangChain}\) 以及许多框架的核心——\(\text{Hugging Face Transformers}\)

我们将主要介绍通过代码与大型语言模型交互的框架。尽管这有助于您学习这些框架的基础知识,但有时您可能只想要一个带有本地 \(\text{LLM}\) 的类似 \(\text{ChatGPT}\) 的界面。幸运的是,有许多出色的框架可以实现这一点。一些示例包括 \(\text{text-generation-webui}\)\(\text{KoboldCpp}\)\(\text{LM Studio}\)

生成您的第一个文本

Generating Your First Text

使用语言模型的一个重要组成部分是选择它们。查找和下载 \(\text{LLM}\) 的主要来源是 \(\text{Hugging Face Hub}\)\(\text{Hugging Face}\) 是著名 \(\text{Transformers}\) 软件包背后的组织,多年来一直推动着语言模型的普遍发展。顾名思义,该软件包是基于我们在“语言 \(\text{AI}\) 的近代史”中讨论过的 \(\text{Transformer}\) 框架构建的。

在撰写本文时,您将在 \(\text{Hugging Face}\) 的平台上找到超过 \(\text{800,000}\) 个模型,它们用于许多不同的目的,从 \(\text{LLM}\) 和计算机视觉模型到处理音频和表格数据的模型。在这里,您可以找到几乎任何开源 \(\text{LLM}\)

虽然我们将在本书中探索各种模型,但让我们从生成模型开始编写我们的第一行代码。我们在本书中使用的主要生成模型是 \(\text{Phi}-3-\text{mini}\),这是一个相对较小(\(\text{38}\) 亿参数)但性能相当不错的模型。由于其体积小,该模型可以在 \(\text{VRAM}\) 少于 \(\text{8 GB}\) 的设备上运行。如果您执行量化\(\text{quantization}\)),这是一种我们将在\(\text{7}\)\(\text{12}\)中进一步讨论的压缩技术,您甚至可以使用少于 \(\text{6 GB}\)\(\text{VRAM}\)。此外,该模型根据 \(\text{MIT}\) 许可证授权,允许该模型不受限制地用于商业目的

请记住,新的、改进的 \(\text{LLM}\) 会频繁发布。为了确保本书保持最新,大多数示例都设计为可与任何 \(\text{LLM}\) 配合使用。我们还将在与本书相关的代码库中重点介绍不同的模型供您试用。

让我们开始吧!当您使用一个 \(\text{LLM}\) 时,会加载两个模型

  • 生成模型本身
  • 其底层的分词器\(\text{tokenizer}\)

分词器负责将输入文本分割成词元\(\text{tokens}\)),然后再将其馈送给生成模型。您可以在 \(\text{Hugging Face}\) 网站上找到分词器和模型,并且只需要传递相应的 \(\text{ID}\)。在这种情况下,我们使用 “\(\text{microsoft/Phi}-3-\text{mini}-4\text{k}-\text{instruct}\)” 作为模型的主路径。

我们可以使用 \(\text{transformers}\) 来加载分词器和模型。请注意,我们假设您有一个 \(\text{NVIDIA}\) \(\text{GPU}\)\(\text{device\_map="cuda"}\)),但您可以选择不同的设备。如果您无法访问 \(\text{GPU}\),可以使用我们在本书代码库中提供的免费 \(\text{Google Colab}\) 笔记本

1
2
3
4
5
6
7
8
9
from transformers import AutoModelForCausalLM, AutoTokenizer
# Load model and tokenizer
model = AutoModelForCausalLM.from_pretrained(
"microsoft/Phi-3-mini-4k-instruct",
device_map="cuda",
torch_dtype="auto",
trust_remote_code=True,
)
tokenizer = AutoTokenizer.from_pretrained("microsoft/Phi-3-mini-4k-instruct")

运行代码将开始下载模型,根据您的互联网连接速度,可能需要几分钟。

虽然我们现在有足够的条件开始生成文本,但 \(\text{transformers}\) 中有一个可以简化该过程的巧妙技巧,即 \(\text{transformers.pipeline}\)。它将模型、分词器和文本生成过程封装到一个函数中:

1
2
3
4
5
6
7
8
9
10
from transformers import pipeline
# Create a pipeline
generator = pipeline(
"text-generation",
model=model,
tokenizer=tokenizer,
return_full_text=False,
max_new_tokens=500,
do_sample=False
)

以下参数值得一提:

\(\text{return\_full\_text}\) 通过将其设置为 \(\text{False}\)不会返回提示本身,而仅仅返回模型的输出。

\(\text{max\_new\_tokens}\) 模型将生成的最大词元数。通过设置限制,我们可以防止冗长且难以驾驭的输出,因为某些模型可能会持续生成输出直到达到它们的上下文窗口。

\(\text{do\_sample}\) 模型是否使用采样策略来选择下一个词元。通过将其设置为 \(\text{False}\),模型将始终选择下一个最有可能的词元。在\(\text{6}\)中,我们将探讨几个可以激发模型输出中创造性的采样参数。

为了生成我们的第一个文本,让我们指示模型讲一个关于鸡的笑话。为此,我们将提示格式化为一个字典列表,其中每个字典都与对话中的一个实体相关。我们的角色是“\(\text{user}\)”(用户),我们使用“\(\text{content}\)”(内容)键来定义我们的提示:

1
2
3
4
5
6
7
# The prompt (user input / query)
messages = [
{"role": "user", "content": "Create a funny joke about chickens."}
]
# Generate output
output = generator(messages)
print(output[0]["generated_text"])
1
Why don't chickens like to go to the gym? Because they can't crack the egg-sistence of it!

就是这样!本书中生成的第一个文本是一个关于鸡的不错的笑话。

总结

在本书的第一章中,我们深入探讨了 \(\text{LLM}\) 对语言 \(\text{AI}\) 领域带来的革命性影响。它显著改变了我们处理翻译、分类、摘要等任务的方法。通过回顾语言 \(\text{AI}\) 的近代史,我们探索了几种 \(\text{LLM}\) 的基本原理,从简单的词袋模型表征到使用神经网络的更复杂的表征。

我们讨论了注意力机制作为在模型中编码上下文的一个步骤,它是使 \(\text{LLM}\) 如此强大的关键组成部分。我们涉及了使用这种惊人机制的两个主要模型类别:像 \(\text{BERT}\) 一样的表征模型(仅编码器),以及像 \(\text{GPT}\) 系列模型一样的生成模型(仅解码器)。在本书中,这两个类别都被视为大型语言模型

总的来说,本章概述了语言 \(\text{AI}\) 的全景,包括其应用、社会和伦理影响,以及运行此类模型所需的资源。最后,我们使用 \(\text{Phi}-3\)(一个将在本书中使用的模型)生成了我们的第一个文本。

在接下来的两章中,您将了解一些底层过程。我们首先在\(\text{2}\)中探索分词嵌入,这两个经常被低估但对语言 \(\text{AI}\) 领域至关重要的组成部分。紧随其后的是\(\text{3}\)对语言模型的深入探究,您将在其中发现用于生成文本的精确方法。

书籍各章的机翻md文件:
《Hands-On Large Language Models》目录及前言
《Hands-On Large Language Models》第1章 大型语言模型简介
《Hands-On Large Language Models》第2章 词元与嵌入
《Hands-On Large Language Models》第3章 深入了解大型语言模型
《Hands-On Large Language Models》第4章 文本分类
《Hands-On Large Language Models》第5章 文本聚类和主题建模
《Hands-On Large Language Models》第6章 提示工程
《Hands-On Large Language Models》第7章 高级文本生成技术与工具
《Hands-On Large Language Models》第8章 语义搜索与检索增强生成
《Hands-On Large Language Models》第9章 多模态大型语言模型
《Hands-On Large Language Models》第10章 创建文本嵌入模型
《Hands-On Large Language Models》第11章 微调用于分类的表征模型
《Hands-On Large Language Models》第12章 生成模型的微调

《Hands-On Large Language Models: Language Understanding and Generation》 的目录中文翻译。

这本书将内容分为三个主要部分:理解 \(\text{LLM}\) 的底层原理、使用预训练模型进行应用,以及模型训练和微调的进阶技术。

《动手实践大型语言模型:语言理解与生成》 目录

[cite_start]第一部分:理解语言模型 (\(\text{Understanding Language Models}\)) [cite: 5]

章节 英文标题 中文翻译 [cite_start]英文页码 [cite: 2, 3]
\(\text{1}\) An Introduction to Large Language Models 大型语言模型简介 \(\text{3}\)
What Is Language \(\text{AI}\)? 什么是语言 \(\text{AI}\) \(\text{4}\)
A Recent History of Language \(\text{AI}\) 语言 \(\text{AI}\) 的近代史 \(\text{5}\)
Representing Language as a Bag-of-Words 将语言表示为词袋模型 \(\text{6}\)
Better Representations with Dense Vector Embeddings 使用密集向量嵌入实现更好的表示 \(\text{8}\)
Representation Models: \(\text{Encoder}\)-Only Models 表征模型:仅编码器 (\(\text{Encoder}\)-Only) 模型 \(\text{18}\)
Generative Models: \(\text{Decoder}\)-Only Models 生成模型:仅解码器 (\(\text{Decoder}\)-Only) 模型 \(\text{20}\)
The Moving Definition of a "Large Language Model" “大型语言模型”不断变化的定义 \(\text{25}\)
\(\text{2}\) Tokens and Embeddings 分词 (\(\text{Tokens}\)) 与嵌入 (\(\text{Embeddings}\)) \(\text{37}\)
\(\text{LLM}\) Tokenization \(\text{LLM}\) 分词技术 \(\text{38}\)
Token Embeddings 分词嵌入 \(\text{57}\)
Text Embeddings (for Sentences and Whole Documents) 文本嵌入(针对句子和整个文档) \(\text{61}\)
Embeddings for Recommendation Systems 用于推荐系统的嵌入 \(\text{67}\)
\(\text{3}\) Looking Inside Large Language Models 深入了解大型语言模型内部 \(\text{73}\)
An Overview of \(\text{Transformer}\) Models \(\text{Transformer}\) 模型概述 \(\text{74}\)
Inside the \(\text{Transformer}\) Block \(\text{Transformer}\) 块内部结构 \(\text{85}\)
Recent Improvements to the \(\text{Transformer}\) Architecture \(\text{Transformer}\) 架构的最新改进 \(\text{95}\)

[cite_start]第二部分:使用预训练语言模型 (\(\text{Using Pretrained Language Models}\)) [cite: 4]

章节 英文标题 中文翻译 [cite_start]英文页码 [cite: 4, 5]
\(\text{4}\) Text Classification 文本分类 \(\text{111}\)
Text Classification with Representation Models 使用表征模型进行文本分类 \(\text{113}\)
Classification Tasks That Leverage Embeddings 利用嵌入的分类任务 \(\text{120}\)
Text Classification with Generative Models 使用生成模型进行文本分类 \(\text{127}\)
\(\text{5}\) Text Clustering and Topic Modeling 文本聚类与主题建模 \(\text{137}\)
A Common Pipeline for Text Clustering 文本聚类的常见流程 \(\text{139}\)
From Text Clustering to Topic Modeling 从文本聚类到主题建模 \(\text{146}\)
\(\text{6}\) Prompt Engineering 提示工程 \(\text{167}\)
Intro to Prompt Engineering 提示工程简介 \(\text{173}\)
Advanced Prompt Engineering 高级提示工程 \(\text{177}\)
\(\text{7}\) Advanced Text Generation Techniques and Tools 高级文本生成技术与工具 \(\text{199}\)
Chains: Extending the Capabilities of \(\text{LLM}\)s 链 (\(\text{Chains}\)): 扩展 \(\text{LLM}\) 的能力 \(\text{202}\)
Memory: Helping \(\text{LLM}\)s to Remember Conversations 记忆 (\(\text{Memory}\)): 帮助 \(\text{LLM}\) 记住对话 \(\text{209}\)
Agents: Creating a System of \(\text{LLM}\)s 代理 (\(\text{Agents}\)): 创建 \(\text{LLM}\) 系统 \(\text{218}\)
\(\text{8}\) Semantic Search and Retrieval-Augmented Generation 语义搜索与检索增强生成 (\(\text{RAG}\)) \(\text{225}\)
Semantic Search with Language Models 使用语言模型进行语义搜索 \(\text{228}\)
Retrieval-Augmented Generation (\(\text{RAG}\)) 检索增强生成 (\(\text{RAG}\)) \(\text{249}\)
\(\text{9}\) Multimodal Large Language Models 多模态大型语言模型 \(\text{259}\)
Multimodal Embedding Models 多模态嵌入模型 \(\text{263}\)
Making Text Generation Models Multimodal 使文本生成模型具备多模态能力 \(\text{273}\)

[cite_start]第三部分:训练和微调语言模型 (\(\text{Training and Fine-Tuning Language Models}\)) [cite: 5]

章节 英文标题 中文翻译 [cite_start]英文页码 [cite: 5]
\(\text{10}\) Creating Text Embedding Models 创建文本嵌入模型 \(\text{289}\)
What Is Contrastive Learning? 什么是对比学习? \(\text{291}\)
Fine-Tuning an Embedding Model 微调嵌入模型 \(\text{309}\)
Unsupervised Learning 无监督学习 \(\text{316}\)
\(\text{11}\) Fine-Tuning Representation Models for Classification 微调表征模型用于分类 \(\text{323}\)
Fine-Tuning a Pretrained \(\text{BERT}\) Model 微调预训练的 \(\text{BERT}\) 模型 \(\text{325}\)
Few-Shot Classification 少样本分类 \(\text{333}\)
Named-Entity Recognition 命名实体识别 \(\text{345}\)
\(\text{12}\) Fine-Tuning Generation Models 微调生成模型 \(\text{355}\)
Supervised Fine-Tuning (\(\text{SFT}\)) 有监督微调 (\(\text{SFT}\)) \(\text{357}\)
Instruction Tuning with \(\text{QLoRA}\) 使用 \(\text{QLoRA}\) 进行指令调优 \(\text{367}\)
Evaluating Generative Models 评估生成模型 \(\text{373}\)
Preference-Tuning / Alignment / \(\text{RLHF}\) 偏好调优 / 对齐 / \(\text{RLHF}\) \(\text{378}\)

  • [cite_start]后记 (\(\text{Afterword}\)) [cite: 5] \(\text{391}\)
  • [cite_start]索引 (\(\text{Index}\)) [cite: 5] \(\text{393}\)

前言

大型语言模型(\(\text{LLM}\))对世界产生了深远而广泛的影响。通过使机器能够更好地理解和生成类似人类的语言,\(\text{LLM}\)\(\text{AI}\) 领域开启了新的可能性,并影响了整个行业。

本书提供了一个全面且高度可视化的 \(\text{LLM}\) 世界入门介绍,涵盖了概念基础和实际应用。从早于深度学习的词汇表示,到(在撰写本文时)前沿的 \(\text{Transformer}\) 架构,我们将探索 \(\text{LLM}\) 的历史和演变。我们深入探讨 \(\text{LLM}\) 的内部工作原理,探索它们的架构、训练方法和微调技术。我们还将研究 \(\text{LLM}\)文本分类、聚类、主题建模、聊天机器人、搜索引擎等各种应用。

我们希望,凭借其独特的直觉构建、应用和图解风格的结合,本书能为那些希望探索 \(\text{LLM}\) 激动人心世界的人们提供理想的基础。无论您是初学者还是专家,我们都邀请您加入我们,开始使用 \(\text{LLM}\) 进行构建。

直觉优先的理念

本书的主要目标是为 \(\text{LLM}\) 领域提供直觉。语言 \(\text{AI}\) 领域的发展速度快得令人难以置信,试图跟上最新的技术可能会让人感到沮丧。因此,我们专注于 \(\text{LLM}\) 的基础知识,并旨在提供一个有趣且轻松的学习过程。

为了实现这种直觉优先的理念,我们大量使用了可视化语言。插图将有助于为 \(\text{LLM}\) 学习过程中涉及的主要概念和过程提供视觉上的标识。通过我们图解式的叙事方法,我们希望带您踏上这一激动人心、可能改变世界的领域的旅程。

在整本书中,我们明确区分了表征语言模型生成语言模型。表征模型是不生成文本的 \(\text{LLM}\),但通常用于特定任务的用例,例如分类;而生成模型是生成文本的 \(\text{LLM}\),例如 \(\text{GPT}\) 模型。尽管生成模型通常是想到 \(\text{LLM}\) 时首先想到的,但表征模型仍然有很多用途。我们对大型语言模型中的“大”一词也采取了较为宽泛的使用,并且通常只称它们为语言模型,因为尺寸描述通常相当武断,并不总是能力的指标。

先决条件

本书假设您具有一定的 \(\text{Python}\) 编程经验,并熟悉机器学习的基础知识。重点将是建立强大的直觉,而不是推导数学方程。因此,插图结合动手实践的例子将贯穿本书的示例和学习过程。

本书假设读者不具备 \(\text{PyTorch}\)\(\text{TensorFlow}\) 等流行的深度学习框架的预先知识,也不具备生成建模的任何先验知识

如果您不熟悉 \(\text{Python}\),一个很好的起点是 \(\text{Learn Python}\),您可以在其中找到许多关于该语言基础知识的教程。为了进一步简化学习过程,我们已将所有代码上传到 \(\text{Google Colab}\),这是一个您无需在本地安装任何东西即可运行所有代码的平台。

书籍结构

本书大致分为三个部分。它们如图 \(\text{P}-1\) 所示,以便您全面了解本书。请注意,每个章节都可以独立阅读,因此对于您已经熟悉的内容,请随意略读。

F0.1

第一部分:理解语言模型

在本书的第一部分,我们将探讨语言模型(无论大小)的内部工作原理。我们首先概述该领域和常用技术(参见\(\text{1}\)),然后转向这些模型的两个核心组成部分——分词 (\(\text{tokenization}\)) 和嵌入 (\(\text{embeddings}\))(参见\(\text{2}\))。本部分最后将以 \(\text{Jay}\) 著名的《图解 \(\text{Transformer}\)》的更新和扩展版本结束,该版本深入探讨了这些模型的架构(参见\(\text{3}\))。本书将介绍贯穿始终的许多术语和定义。

第二部分:使用预训练语言模型

在本书的第二部分,我们将通过常见的用例来探讨如何使用 \(\text{LLM}\)。我们将使用预训练模型,并在无需微调的情况下展示其功能。

您将学习如何使用语言模型进行有监督分类(参见\(\text{4}\))、文本聚类和主题建模(参见\(\text{5}\))、利用嵌入模型进行语义搜索(参见\(\text{6}\))、生成文本(参见\(\text{7}\)\(\text{8}\)),以及将文本生成的能力扩展到视觉领域(参见\(\text{9}\))。

学习这些独立的语言模型功能将为您提供使用 \(\text{LLM}\) 解决问题和构建越来越高级的系统和流程所需的技能。

第三部分:训练和微调语言模型

在本书的第三部分,我们将通过训练和微调各种语言模型来探索高级概念。我们将探讨如何创建和微调嵌入模型(参见\(\text{10}\)),回顾如何微调 \(\text{BERT}\) 进行分类(参见\(\text{11}\)),并以微调生成模型的几种方法结束本书(参见\(\text{12}\))。

硬件和软件要求

运行生成模型通常是一项计算密集型任务,需要配备功能强大的 \(\text{GPU}\) 的计算机。由于并非所有读者都具备 \(\text{GPU}\),本书中的所有示例都设置为使用在线平台运行,即 \(\text{Google Colaboratory}\)(通常缩写为 \(\text{Google Colab}\))。在撰写本文时,该平台允许您免费使用 \(\text{NVIDIA}\) \(\text{GPU}\) (\(\text{T}4\)) 来运行代码。该 \(\text{GPU}\) 具有 \(\text{16 GB}\)\(\text{VRAM}\)(即您的 \(\text{GPU}\) 内存),这是我们在本书示例中要求的最低 \(\text{VRAM}\) 量。

并不是所有章节都需要最低 \(\text{16 GB}\) \(\text{VRAM}\),因为有些示例(例如训练和微调)比其他示例(例如提示工程)对计算的要求更高。在代码库中,您可以找到每个章节所需的最低 \(\text{GPU}\) 要求。

所有代码、要求和附加教程都可在本书的代码库中获取。如果您想在本地运行这些示例,我们建议使用至少 \(\text{16 GB}\) \(\text{VRAM}\)\(\text{NVIDIA}\) \(\text{GPU}\)。对于本地安装,例如使用 \(\text{conda}\),您可以按照以下设置来创建您的环境:

1
2
conda create -n thellmbook python=3.10
conda activate thellmbook

您可以通过 \(\text{fork}\) 或克隆代码库,然后在您新建的 \(\text{Python 3.10}\) 环境中运行以下命令来安装所有必要的依赖项:

1
pip install -r requirements.txt

以下是书籍《\(\text{Hands-On Large Language Models}\)》中您提供的关于 \(\text{API}\) 密钥、书籍约定和 O’Reilly 信息的中文翻译:


\(\text{API}\) 密钥

我们在示例中同时使用了开源模型专有模型,以展示两者的优缺点。对于专有模型,即 \(\text{OpenAI}\)\(\text{Cohere}\) 提供的产品,您需要创建一个免费账户:

\(\text{OpenAI}\)

点击网站上的“注册”(\(\text{sign up}\))来创建免费账户。该账户允许您创建一个 \(\text{API}\) 密钥,可用于访问 \(\text{GPT}-3.5\)。然后,转到“\(\text{API}\) 密钥”来创建一个密钥(\(\text{secret key}\))。

\(\text{Cohere}\)

在网站上注册一个免费账户。然后,转到“\(\text{API}\) 密钥”来创建一个密钥(\(\text{secret key}\))。

请注意,这两个账户都有速率限制\(\text{rate limits}\)),并且这些免费的 \(\text{API}\) 密钥只允许每分钟进行有限次数的调用。在所有示例中,我们都考虑了这一点,并在必要时提供了本地替代方案。

对于开源模型,您无需创建账户,\(\text{2}\)中的 \(\text{Llama 2}\) 模型除外。要使用该模型,您需要一个 \(\text{Hugging Face}\) 账户:

\(\text{Hugging Face}\)

点击 \(\text{Hugging Face}\) 网站上的“注册”(\(\text{sign up}\))来创建免费账户。然后,在“设置”(\(\text{Settings}\))中转到“访问令牌”(\(\text{Access Tokens}\)),创建一个令牌,您可以使用该令牌下载某些 \(\text{LLM}\)


本书中使用的约定

本书使用以下排版约定:

斜体 表示新术语\(\text{URL}\)、电子邮件地址、文件名和文件扩展名。

等宽字体 用于程序清单,以及段落内引用程序元素(例如变量或函数名称、数据库、数据类型、环境变量、语句和关键字)时。

等宽粗体 显示应由用户按字面输入的命令或其他文本。

等宽斜体 显示应替换为用户提供的值或由上下文确定的值的文本


使用代码示例

补充材料(代码示例、练习等)可在以下网址下载:

\(\text{[https://github.com/HandsOnLLM/Hands-On-Large-Language-Models](https://github.com/HandsOnLLM/Hands-On-Large-Language-Models)}\)

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至 \(\text{support@oreilly.com}\)

本书旨在帮助您完成工作。一般来说,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您要复制代码的重要部分,否则您无需联系我们征求许可。例如,编写一个使用本书中几段代码的程序不需要许可。出售或分发 \(\text{O’Reilly}\) 书籍中的示例则需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书中大量的示例代码纳入您的产品文档中则需要许可。

我们感激(但通常不要求)注明出处。出处通常包括书名、作者、出版商和 \(\text{ISBN}\)。例如:“《\(\text{Hands-On Large Language Models}\)》,作者 \(\text{Jay Alammar}\)\(\text{Maarten Grootendorst}\) (\(\text{O’Reilly}\))。版权所有 \(\text{2024}\) \(\text{Jay Alammar}\)\(\text{Maarten Pieter Grootendorst}\)\(\text{978-1-098-15096-9}\)。”

如果您认为您对代码示例的使用超出了合理使用或上述许可范围,请随时通过 \(\text{permissions@oreilly.com}\) 联系我们。

O’Reilly 在线学习

\(\text{40}\) 多年来,\(\text{O’Reilly Media}\) 一直提供技术和商业培训、知识和见解,以帮助企业取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业技能。\(\text{O’Reilly}\) 的在线学习平台为您提供按需访问实时培训课程深入学习路径交互式编码环境以及来自 \(\text{O’Reilly}\)\(\text{200}\) 多家其他出版商的海量文本和视频。欲了解更多信息,请访问 \(\text{[https://oreilly.com](https://oreilly.com)}\)

如何联系我们

请将有关本书的评论和问题发送给出版商:

\(\text{O’Reilly Media, Inc.}\) \(\text{1005 Gravenstein Highway North}\) \(\text{Sebastopol, CA 95472}\)

\(\text{800-889-8969}\) (美国或加拿大境内) \(\text{707-827-7019}\) (国际或本地) \(\text{707-829-0104}\) (传真) \(\text{support@oreilly.com}\) \(\text{[https://www.oreilly.com/about/contact.html](https://www.oreilly.com/about/contact.html)}\)

我们为本书提供了一个网页,其中列出了勘误表、示例和任何附加信息。您可以通过 https://oreil.ly/hands_on_LLMs_1e 访问此页面。

要获取有关我们书籍和课程的新闻和信息,请访问 \(\text{[https://oreilly.com](https://oreilly.com)}\)

\(\text{LinkedIn}\) 上关注我们:https://linkedin.com/company/oreilly-media

\(\text{YouTube}\) 上观看我们:https://youtube.com/oreillymedia

书籍各章的机翻md文件:
《Hands-On Large Language Models》目录及前言
《Hands-On Large Language Models》第1章 大型语言模型简介
《Hands-On Large Language Models》第2章 词元与嵌入
《Hands-On Large Language Models》第3章 深入了解大型语言模型
《Hands-On Large Language Models》第4章 文本分类
《Hands-On Large Language Models》第5章 文本聚类和主题建模
《Hands-On Large Language Models》第6章 提示工程
《Hands-On Large Language Models》第7章 高级文本生成技术与工具
《Hands-On Large Language Models》第8章 语义搜索与检索增强生成
《Hands-On Large Language Models》第9章 多模态大型语言模型
《Hands-On Large Language Models》第10章 创建文本嵌入模型
《Hands-On Large Language Models》第11章 微调用于分类的表征模型
《Hands-On Large Language Models》第12章 生成模型的微调

书籍各章的机翻md文件:
目录及前言
“test”
《Hands-On Large Language Models》第2章 词元与嵌入
《Hands-On Large Language Models》第3章 深入了解大型语言模型
《Hands-On Large Language Models》第4章 文本分类
《Hands-On Large Language Models》第5章 文本聚类和主题建模
《Hands-On Large Language Models》第6章 提示工程
《Hands-On Large Language Models》第7章 高级文本生成技术与工具
《Hands-On Large Language Models》第8章 语义搜索与检索增强生成
《Hands-On Large Language Models》第9章 多模态大型语言模型
《Hands-On Large Language Models》第10章 创建文本嵌入模型
《Hands-On Large Language Models》第11章 微调用于分类的表征模型
《Hands-On Large Language Models》第12章 生成模型的微调

0%