大语言模型实用指南-全-
大语言模型实用指南(全)
原文:
zh.annas-archive.org/md5/8ec582b7b8053e580e33bbe0a0c5a460译者:飞龙
第一章: 文本分类
自然语言处理和机器学习中最常见的任务之一是分类。该任务的目标是训练一个模型,为一些输入文本分配标签或类别。文本分类在全球范围内用于广泛的应用,从情感分析和意图检测到实体提取和语言检测。
大型语言模型对分类的影响不容小觑。这些模型的加入迅速成为这类任务的默认选择。
在本章中,我们将讨论使用大型语言模型进行文本分类的多种方法。由于文本分类的广泛领域,将讨论多种技术和应用案例。本章还可以很好地引入 LLM,因为大多数模型都可以用于分类。
我们将重点利用预训练的 LLM,这些模型已经在大量数据上进行训练,可以用于文本分类。对这些模型进行文本分类和领域适应的微调将在第十章中详细讨论。
让我们从最基本的应用和技术开始,完全监督的文本分类。
监督文本分类
分类有多种形式,例如稍微学习(few-shot)和零学习(zero-shot)分类,我们将在本章后面讨论,但最常用的方法是完全监督的分类。这意味着在训练过程中,每个输入都有一个目标类别,模型可以从中学习。
对于使用文本数据作为输入的监督分类,通常遵循一个常见的程序。如图 1-1 所示,我们首先使用特征提取模型将文本输入转换为数值表示。传统上,这样的模型将文本表示为词袋,简单地计算一个单词在文档中出现的次数。然而,在本书中,我们将重点关注 LLM 作为我们的特征提取模型。

图 1-1. 监督分类的示例。我们能否预测电影评论是正面还是负面?
然后,我们在数值表示上训练分类器,例如嵌入(还记得第 X 章吗?),以对文本数据进行分类。分类器可以是多种形式,例如神经网络或逻辑回归。它甚至可以是许多 Kaggle 竞赛中使用的分类器,即 XGBoost!
在这个流程中,我们总是需要训练分类器,但可以选择微调整个 LLM、其中某些部分或保持不变。如果我们选择不进行微调,我们称这一过程为冻结其层。这意味着在训练过程中这些层无法更新。然而,至少解冻一些层可能是有益的,这样大型语言模型就可以针对特定的分类任务进行微调。该过程在图 1-2 中有所说明。

图 1-2. 监督文本分类的常见过程。我们通过特征提取将文本输入数据转换为数值表示。然后,训练分类器以预测标签。
模型选择
我们可以使用 LLM 来表示要输入到分类器中的文本。然而,这个模型的选择可能并不像您想象的那样简单。模型在可处理的语言、架构、大小、推理速度、架构、特定任务的准确性等方面存在差异,且还有许多其他差异。
BERT 是一个优秀的基础架构,可以针对多种任务进行微调,包括分类。尽管我们可以使用生成模型,比如知名的生成预训练变换器(GPT),例如 ChatGPT,但 BERT 模型通常在特定任务的微调上表现优越。相比之下,GPT 类模型通常在广泛的任务上表现突出。从某种意义上说,这是专业化与泛化的对比。
现在我们知道要为监督分类任务选择一个类似 BERT 的模型,我们将使用哪个呢?BERT 有多种变体,包括 BERT、RoBERTa、DistilBERT、ALBERT、DeBERTa,每种架构都以不同的形式进行了预训练,从特定领域的训练到多语言数据的训练。您可以在图 1-3 中找到一些著名大型语言模型的概述。
为工作选择合适的模型本身可以是一种艺术。尝试数千个可以在 HuggingFace Hub 上找到的预训练模型是不可行的,因此我们需要高效地选择模型。话虽如此,仍然有一些模型是很好的起点,并能让您了解这些模型的基础性能。将它们视为稳固的基线:
**
图 1-3. 常见大型语言模型发布的时间线。** 在本节中,我们将使用“bert-base-cased”进行一些示例。可以随意将“bert-base-cased”替换为上述任意模型。尝试不同的模型,以感受性能与训练速度之间的权衡。 **## 数据
在本章中,我们将演示许多文本分类技术。我们将用来训练和评估模型的数据集是“rotten_tomatoes”;pang2005seeing)数据集。它包含约 5000 条正面和 5000 条负面电影评论,来自Rotten Tomatoes。
我们加载数据并将其转换为pandas dataframe以便于控制:
import pandas as pd
from datasets import load_dataset
tomatoes = load_dataset("rotten_tomatoes")
# Pandas for easier control
train_df = pd.DataFrame(tomatoes["train"])
eval_df = pd.DataFrame(tomatoes["test"])
提示
尽管本书专注于 LLMs,但强烈建议将这些示例与经典且强大的基准进行比较,例如使用 TF-IDF 表示文本并在其上训练 LogisticRegression 分类器。
分类头
使用 Rotten Tomatoes 数据集,我们可以从最简单的预测任务开始,即二分类。这通常应用于情感分析,检测某个文档是正面还是负面。这可以是带有指示该评论是正面还是负面的标签(0 或 1)的客户评论。在我们的案例中,我们将预测一条电影评论是负面(0)还是正面(1)。
使用基于变换器的模型训练分类器通常遵循两步法:
首先,如图 1-4 所示,我们采用现有的变换器模型,将文本数据转换为数值表示。

图 1-4. 首先,我们使用通用的预训练 LLM(例如 BERT)将我们的文本数据转换为更数值化的表示。在训练过程中,我们将“冻结”模型,以便其权重不会被更新。这显著加快了训练速度,但通常精度较低。
其次,如图 1-5 所示,我们在预训练模型的顶部添加一个分类头。这个分类头通常是一个单一的线性层,我们可以对其进行微调。

图 1-5. 在微调我们的 LLM 后,我们在数值表示和标签上训练分类器。通常,选择前馈神经网络作为分类器。
这两个步骤描述的是同一模型,因为分类头直接添加到 BERT 模型中。如图 1-6 所示,我们的分类器只不过是一个附加了线性层的预训练 LLM。它实现了特征提取和分类的结合。

图 1-6. 我们采用 BERT 模型,其输出嵌入被输入到分类头中。该头通常由一个线性层组成,但可能会提前包含 dropout。
注意
在第十章中,我们将使用图 2-4 和 2-5 中显示的相同管道,但将微调大型语言模型。在那里,我们将更深入地探讨微调的工作原理以及为什么它能改善这里展示的管道。目前,重要的是要知道,微调这个模型和分类头一起提高了分类任务的准确性。这是因为它使大型语言模型能够更好地表示文本以进行分类,针对特定领域文本进行微调。
示例
为了训练我们的模型,我们将使用简单变换器包。它抽象了大部分技术难点,让我们可以专注于当前的分类任务。我们从初始化模型开始:
from simpletransformers.classification import ClassificationModel, ClassificationArgs
# Train only the classifier layers
model_args = ClassificationArgs()
model_args.train_custom_parameters_only = True
model_args.custom_parameter_groups = [
{
"params": ["classifier.weight"],
"lr": 1e-3,
},
{
"params": ["classifier.bias"],
"lr": 1e-3,
"weight_decay": 0.0,
},
]
# Initializing pre-trained BERT model
model = ClassificationModel("bert", "bert-base-cased", args=model_args)
我们选择了流行的“bert-base-cased”,但如前所述,我们还有许多其他模型可以选择。可以随意尝试不同模型,以查看其对性能的影响。
接下来,我们可以在训练数据集上训练模型,并预测评估数据集的标签:
import numpy as np
from sklearn.metrics import f1_score
# Train the model
model.train_model(train_df)
# Predict unseen instances
result, model_outputs, wrong_predictions = model.eval_model(eval_df, f1=f1_score)
y_pred = np.argmax(model_outputs, axis=1)
现在我们已经训练了模型,剩下的就是评估:
>>> from sklearn.metrics import classification_report
>>> print(classification_report(eval_df.label, y_pred))
precision recall f1-score support
0 0.84 0.86 0.85 533
1 0.86 0.83 0.84 533
accuracy 0.85 1066
macro avg 0.85 0.85 0.85 1066
weighted avg 0.85 0.85 0.85 1066
使用预训练的 BERT 模型进行分类使我们的 F-1 得分达到 0.85。我们可以将这个得分作为本节示例中的基准。
提示
simpletransformers包提供了许多易于使用的功能来处理不同任务。例如,你也可以用它创建一个自定义的命名实体识别模型,只需几行代码。
预训练嵌入
与之前展示的示例不同,我们可以以更经典的形式进行监督分类。我们可以完全将特征提取与分类训练分开,而不是在训练前冻结层并在其上使用前馈神经网络。
这种两步法完全将特征提取与分类分开:
首先,正如我们在图 1-7 中看到的,我们使用一个专门训练以创建嵌入的 LLM,SBERT(www.sbert.net/)。

图 1-7. 首先,我们使用一个专门训练用于生成准确数值表示的 LLM。这些通常比我们从像 BERT 这样的一般 Transformer 模型中获得的更具代表性。
其次,如图 1-8 所示,我们使用嵌入作为逻辑回归模型的输入。我们完全将特征提取模型与分类模型分开。

图 1-8. 使用嵌入作为特征,我们在训练数据上训练一个逻辑回归模型。
与我们之前的示例相比,这两个步骤分别描述了不同的模型。SBERT 用于生成特征,即嵌入,而逻辑回归则作为分类器。如图 2-9 所示,我们的分类器仅仅是一个附加了线性层的预训练 LLM。

图 1-9. 分类器是一个单独的模型,它利用来自 SBERT 的嵌入进行学习。** **### 示例
使用句子转换器,我们可以在训练分类模型之前创建我们的特征:
from sentence_transformers import SentenceTransformer, util
model = SentenceTransformer('all-mpnet-base-v2')
train_embeddings = model.encode(train_df.text)
eval_embeddings = model.encode(eval_df.text)
我们为训练(train_df)和评估(eval_df)数据创建了嵌入。生成的每个嵌入实例由 768 个值表示。我们将这些值视为可以用于训练模型的特征。
选择模型可以很简单。我们可以回归基础,使用逻辑回归,而不是使用前馈神经网络:
from sklearn.linear_model import LogisticRegression
clf = LogisticRegression(random_state=42).fit(train_embeddings, train_df.label)
在实践中,你可以在我们生成的嵌入上使用任何分类器,例如决策树或神经网络。
接下来,让我们评估我们的模型:
>>> from sklearn.metrics import classification_report
>>> y_pred = clf.predict(eval_embeddings)
>>> print(classification_report(eval_df.label, y_pred))
precision recall f1-score support
0 0.84 0.86 0.85 151
1 0.86 0.83 0.84 149
accuracy 0.85 300
macro avg 0.85 0.85 0.85 300
weighted avg 0.85 0.85 0.85 300
在不需要微调我们的 LLM 的情况下,我们成功地达到了 0.85 的 F1 得分。这一点尤其令人印象深刻,因为它相比于我们的前一个示例,模型要小得多。**** ****# 零-shot 分类
本章开始时的例子中,我们所有的训练数据都有标签。然而,在实践中,这可能并不总是如此。获取标记数据是一项资源密集型任务,可能需要大量人力。相反,我们可以使用零样本分类模型。这种方法是迁移学习的一个良好例子,训练用于一项任务的模型被用于与其最初训练的任务不同的任务。零样本分类的概述在图 2-11 中给出。请注意,这个流程还展示了如果多个标签的概率超过给定阈值,则执行多标签分类的能力。

图 1-10. 图 2-11. 在零样本分类中,LLM 并未在任何候选标签上进行训练。它从不同的标签中学习,并将这些信息推广到候选标签上。
通常,零样本分类任务与使用自然语言描述我们希望模型执行的操作的预训练 LLM 一起使用。随着模型规模的增加,这通常被称为 LLM 的涌现特性(wei2022emergent)。正如我们将在本章后面关于生成模型分类时看到的,类似 GPT 的模型通常能够很好地完成这些任务。
预训练嵌入
正如我们在监督分类示例中所看到的,嵌入是一种出色且常常准确地表示文本数据的方法。在处理没有标记的文档时,我们需要在如何使用预训练嵌入方面稍微富有创造性。由于没有可用的标记数据,分类器无法进行训练。
幸运的是,我们可以使用一个技巧。我们可以根据标签应表示的内容来描述它们。例如,电影评论的负面标签可以描述为“这是一条负面的电影评论”。通过描述和嵌入标签和文档,我们有了可以使用的数据。这个过程如图 1-11 所示,使我们能够生成自己的目标标签,而无需实际拥有任何标记数据。

图 1-11. 要嵌入标签,我们首先需要给它们一个描述。例如,负面标签的描述可以是“负面的电影评论”。这个描述可以通过句子变换器嵌入。最后,标签和所有文档都会被嵌入。
要为文档分配标签,我们可以对文档标签对应用余弦相似度。余弦相似度是检查两个向量彼此相似程度的相似性度量,整个书中会经常使用。
这是向量之间角度的余弦,通过嵌入的点积计算,并除以它们长度的乘积。听起来确实比实际复杂,希望图 1-12 中的插图能提供额外的直觉。

图 1-12. 余弦相似度是两个向量或嵌入之间的角度。在这个例子中,我们计算文档与两个可能标签(正面和负面)之间的相似度。
对于每个文档,它的嵌入与每个标签的嵌入进行比较。选择与文档相似度最高的标签。图 1-13 很好地展示了文档如何被分配标签。

图 1-13. 在嵌入标签描述和文档后,我们可以对每个标签文档对使用余弦相似度。对于每个文档,选择与该文档相似度最高的标签。
示例
我们首先为评估数据集生成嵌入。这些嵌入是使用句子转换器生成的,因为它们相当准确且计算速度较快。
from sentence_transformers import SentenceTransformer, util
# Create embeddings for the input documents
model = SentenceTransformer('all-mpnet-base-v2')
eval_embeddings = model.encode(eval_df.text)
接下来,需要生成标签的嵌入。然而,这些标签没有可以利用的文本表示,因此我们需要自己命名这些标签。
由于我们要处理正面和负面电影评论,我们将标签命名为“正面评论”和“负面评论”。这使我们能够嵌入这些标签:
# Create embeddings for our labels
label_embeddings = model.encode(["A negative review", "A positive review"])
现在我们有了评论和标签的嵌入,我们可以在它们之间应用余弦相似度,以查看哪个标签最适合哪个评论。这样只需要几行代码:
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
# Find the best matching label for each document
sim_matrix = cosine_similarity(eval_embeddings, label_embeddings)
y_pred = np.argmax(sim_matrix, axis=1)
就这样!我们只需为我们的标签想出名称,就可以执行分类任务。让我们看看这种方法效果如何:
>>> print(classification_report(eval_df.label, y_pred))
precision recall f1-score support
0 0.83 0.77 0.80 151
1 0.79 0.84 0.81 149
accuracy 0.81 300
macro avg 0.81 0.81 0.81 300
weighted avg 0.81 0.81 0.81 300
考虑到我们根本没有使用任何标记数据,0.81 的 F-1 分数相当令人印象深刻!这显示了嵌入的多功能性和实用性,尤其是当你在使用方式上稍微有点创意时。
让我们来测试一下这个创造力。我们决定将“负面/积极评论”作为我们的标签名称,但可以进一步改进。相反,我们可以通过使用“非常负面/积极的电影评论”使其更具体,更贴合我们的数据。这样,嵌入将捕捉到这是一个电影评论,并将更加关注两个标签的极端情况。
我们使用之前的代码来查看这是否真的有效:
>>> # Create embeddings for our labels
>>> label_embeddings = model.encode(["A very negative movie review", "A very positive movie review"])
>>>
>>> # Find the best matching label for each document
>>> sim_matrix = cosine_similarity(eval_embeddings, label_embeddings)
>>> y_pred = np.argmax(sim_matrix, axis=1)
>>>
>>> # Report results
>>> print(classification_report(eval_df.label, y_pred))
precision recall f1-score support
0 0.90 0.74 0.81 151
1 0.78 0.91 0.84 149
accuracy 0.83 300
macro avg 0.84 0.83 0.83 300
weighted avg 0.84 0.83 0.83 300
仅通过改变标签的措辞,我们大大提高了我们的 F-1 分数!
提示
在这个例子中,我们通过命名标签并嵌入它们来应用零样本分类。当我们有少量标记的示例时,嵌入它们并将其添加到管道中可以帮助提高性能。例如,我们可以将标记示例的嵌入与标签嵌入进行平均。我们甚至可以通过创建不同类型的表示(标签嵌入、文档嵌入、平均嵌入等)进行投票程序,看看哪个标签最常被找到。这将使我们的零样本分类示例成为少样本方法。
自然语言推理
零样本分类也可以使用自然语言推理(NLI)进行,这指的是调查给定前提时,假设是否为真(蕴含)或为假(矛盾)的任务。图 1-14 展示了它们之间的良好示例。

图 1-14. 自然语言推理(NLI)的示例。假设与前提相矛盾,彼此之间没有关联。
NLI 可以通过稍微创造性地使用前提/假设对进行零样本分类,如在图 1-15 中所示。我们使用输入文档,即我们想要提取情感的评论,并将其作为我们的前提(yin2019benchmarking)。然后,我们创建一个假设,询问前提是否与我们的目标标签有关。在我们的电影评论示例中,假设可以是:“这个例子是一个积极的电影评论”。当模型发现这是一个蕴含关系时,我们可以将评论标记为正面,而当其为矛盾时则标记为负面。使用 NLI 进行零样本分类的示例在图 1-15 中进行了说明。

图 1-15. 自然语言推理(NLI)中的零样本分类示例。假设得到了前提的支持,模型将返回该评论确实是积极的电影评论。
示例
使用变压器,加载和运行预训练的 NLI 模型非常简单。我们选择“facebook``/bart-large-mnli”作为我们的预训练模型。该模型在超过 40 万个前提/假设对上进行了训练,应该非常适合我们的用例。
注意
在过去的几年中,Hugging Face 努力成为机器学习的 Github,托管与机器学习相关的几乎所有内容。因此,他们的中心提供了大量预训练模型。对于零样本分类任务,您可以查看此链接:huggingface.co/models?pipeline_tag=zero-shot-classification。
我们加载变压器管道,并在评估数据集上运行它:
from transformers import pipeline
# Pre-trained MNLI model
pipe = pipeline(model="facebook/bart-large-mnli")
# Candidate labels
candidate_labels_dict = {"negative movie review": 0, "positive movie review": 1}
candidate_labels = ["negative movie review", "positive movie review"]
# Create predictions
predictions = pipe(eval_df.text.values.tolist(), candidate_labels=candidate_labels)
由于这是一个零样本分类任务,因此我们无需进行训练即可获得我们感兴趣的预测。预测变量不仅包含预测结果,还包含一个分数,指示候选标签(假设)蕴含输入文档(前提)的概率。
>>> from sklearn.metrics import classification_report
>>> y_pred = [candidate_labels_dict[prediction["labels"][0]] for prediction in predictions]
>>> print(classification_report(eval_df.label, y_pred))
precision recall f1-score support
0 0.77 0.89 0.83 151
1 0.87 0.74 0.80 149
accuracy 0.81 300
macro avg 0.82 0.81 0.81 300
weighted avg 0.82 0.81 0.81 300
完全没有进行微调,它的 F1 分数达到了 0.81. 根据我们措辞候选标签的方式,可能能够提高这个值。例如,如果候选标签简单为“消极”和“积极”,会发生什么情况?
提示
另一个优秀的零样本分类预训练模型是 sentence-transformers 的交叉编码器,即 'cross-encoder/``nli``-deberta-base'。由于训练 sentence-transformers 模型侧重于句子对,因此它自然而然地适用于利用前提/假设对的零样本分类任务。
使用生成模型进行分类
使用生成性大型语言模型(如 OpenAI 的 GPT 模型)进行分类,与我们之前所做的稍有不同。我们不是对模型进行微调以适应我们的数据,而是使用模型并尝试引导它朝向我们所寻找的答案类型。
这个引导过程主要通过您提供的提示来完成,例如模型。优化提示以使模型理解您所寻找的答案类型被称为 提示工程。本节将演示如何利用生成模型执行各种分类任务。
对于极大型语言模型,如 GPT-3,这一点尤其真实。一篇优秀的论文和相关阅读,“语言模型是少样本学习者”,描述了这些模型在下游任务上具有竞争力,同时需要更少的特定任务数据 (brown2020language)。
上下文学习
生成模型如此有趣的原因在于它们能够遵循给定的提示。生成模型甚至可以通过仅仅展示几个新任务的示例而做出完全新的事情。这一过程也称为上下文学习,指的是在不实际微调模型的情况下,让模型学习或做一些新的事情。
例如,如果我们要求生成模型写一首俳句(传统的日本诗歌形式),如果它之前没有见过俳句,可能无法做到。然而,如果提示中包含几条俳句的示例,那么模型就会“学习”并能够创作俳句。
我们故意将“学习”放在引号中,因为模型实际上并没有学习,而是遵循示例。在成功生成俳句后,我们仍需不断提供示例,因为内部模型并未更新。上下文学习的这些示例显示在图 1-16 中,展示了创作成功且高效提示所需的创造力。

图 1-16. 通过提示工程与生成模型进行零-shot 和少-shot 分类。
上下文学习在少量示例的少-shot 分类任务中尤其有用,生成模型可以跟随这些少量示例。
不需要对内部模型进行微调是上下文学习的一个主要优势。这些生成模型通常体积庞大,难以在消费者硬件上运行,更不用说微调它们了。优化你的提示以引导生成模型相对容易,通常不需要精通生成 AI 的人。
示例
在我们进入上下文学习的示例之前,首先创建一个允许我们使用 OpenAI 的 GPT 模型进行预测的函数。
from tenacity import retry, stop_after_attempt, wait_random_exponential
@retry(wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6))
def gpt_prediction(prompt, document, model="gpt-3.5-turbo-0301"):
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": prompt.replace("[DOCUMENT]", document)}
]
response = openai.ChatCompletion.create(model=model, messages=messages, temperature=0)
return response["choices"][0]["message"]["content"]
这个函数允许我们传递一个特定的prompt和document,用于我们想要创建预测的内容。你在这里看到的tenacity模块帮助我们处理速率限制错误,这种错误发生在你调用 API 过于频繁时。OpenAI 和其他外部 API 通常希望限制调用其 API 的速率,以免过载其服务器。
这个tenacity模块本质上是一个“重试模块”,允许我们以特定方式重试 API 调用。在这里,我们在gpt_prediction函数中实现了一种叫做指数退避的机制。当我们遇到速率限制错误时,指数退避会进行短暂休眠,然后重试未成功的请求。每当请求未成功时,休眠时间会增加,直到请求成功或达到最大重试次数。
避免速率限制错误的一种简单方法是自动重试请求并使用随机指数退避。当遇到速率限制错误时,重试时会进行短暂的休眠,然后重试失败的请求。如果请求仍然失败,休眠时间将增加,并重复此过程。直到请求成功或达到最大重试次数为止。
最后,我们需要使用从你的账户获取的 API 密钥登录 OpenAI 的 API:
import openai
openai.api_key = "sk-..."
警告
在使用外部 API 时,始终跟踪你的使用情况。如果你频繁请求外部 API,如 OpenAI 或 Cohere,费用会迅速增加。
零样本分类
使用生成模型进行零样本分类本质上是我们与这些模型交互时通常所做的,简单地询问它们是否能执行某项任务。在我们的示例中,我们询问模型特定文档是否为积极或消极的电影评论。
为此,我们创建一个零样本分类提示的基础模板,并询问模型是否能预测评论是积极还是消极:
# Define a zero-shot prompt as a base
zeroshot_prompt = """Predict whether the following document is a positive or negative movie review:
[DOCUMENT]
If it is positive say 1 and if it is negative say 0\. Do not give any other answers.
"""
你可能注意到我们明确要求不要提供其他答案。这些生成模型往往有自己的想法,会返回大量关于某事为何是或不是消极的解释。由于我们在评估其结果,我们希望返回的是 0 或 1。
接下来,让我们看看它是否能正确预测评论“谦逊、迷人、快速、原创”是积极的:
# Define a zero-shot prompt as a base
zeroshot_prompt = """Predict whether the following document is a positive or negative movie review:
[DOCUMENT]
If it is positive say 1 and if it is negative say 0\. Do not give any other answers.
"""
# Predict the target using GPT
document = "unpretentious , charming , quirky , original"
gpt_prediction(zeroshot_prompt, document)
输出确实显示该评论被 OpenAI 的模型标记为积极!使用此提示模板,我们可以在“[DOCUMENT]”标签中插入任何文档。这些模型有令牌限制,这意味着我们可能无法将整本书插入提示中。幸运的是,评论通常不会像书那样长,而是相对较短。
接下来,我们可以对评估数据集中的所有评论进行此操作,并观察其性能。不过请注意,这需要向 OpenAI 的 API 发送 300 个请求:
> from sklearn.metrics import classification_report
> from tqdm import tqdm
>
> y_pred = [int(gpt_prediction(zeroshot_prompt, doc)) for doc in tqdm(eval_df.text)]
> print(classification_report(eval_df.label, y_pred))
precision recall f1-score support
0 0.86 0.96 0.91 151
1 0.95 0.86 0.91 149
accuracy 0.91 300
macro avg 0.91 0.91 0.91 300
weighted avg 0.91 0.91 0.91 300
F-1 分数为 0.91!这是我们迄今为止看到的最高分数,考虑到我们完全没有对模型进行微调,这实在令人印象深刻。
注意
尽管这种基于 GPT 的零样本分类表现出色,但需要注意的是,微调通常优于本节中所述的上下文学习。特别是在涉及特定领域数据时,模型在预训练期间不太可能见过这些数据。当模型的参数未针对当前任务进行更新时,其对任务特定细微差别的适应性可能有限。理想情况下,我们希望在这些数据上对 GPT 模型进行微调,以进一步提升其性能!
少样本分类
在上下文学习中,少样本分类效果尤其好。与零样本分类相比,我们只需添加一些电影评论示例,以引导生成模型。这样,它对我们想要完成的任务有了更好的理解。
我们首先更新我们的提示模板,以包含几个精心挑选的示例:
# Define a few-shot prompt as a base
fewshot_prompt = """Predict whether the following document is a positive or negative moview review:
[DOCUMENT]
Examples of negative reviews are:
- a film really has to be exceptional to justify a three hour running time , and this isn't .
- the film , like jimmy's routines , could use a few good laughs .
Examples of positive reviews are:
- very predictable but still entertaining
- a solid examination of the male midlife crisis .
If it is positive say 1 and if it is negative say 0\. Do not give any other answers.
"""
我们为每个类别选择了两个示例,以快速引导模型为电影评论分配情感。
注意
由于我们在提示中添加了一些示例,生成模型消耗了更多的标记,因此可能会增加请求 API 的成本。然而,相较于微调和更新整个模型,这相对较少。
预测与之前相同,但将零样本提示替换为少样本提示:
# Predict the target using GPT
document = "unpretentious , charming , quirky , original"
gpt_prediction(fewshot_prompt, document)
不出所料,它正确地为评论分配了情感。任务越困难或复杂,提供示例的效果就越显著,尤其是当示例质量较高时。
和以前一样,让我们对整个评估数据集运行改进的提示:
>>> predictions = [gpt_prediction(fewshot_prompt, doc) for doc in tqdm(eval_df.text)]
precision recall f1-score support
0 0.88 0.97 0.92 151
1 0.96 0.87 0.92 149
accuracy 0.92 300
macro avg 0.92 0.92 0.92 300
weighted avg 0.92 0.92 0.92 300
现在的 F1 分数为 0.92,与之前相比略有提高。这并不意外,因为之前的分数已经相当高,而手头的任务也不是特别复杂。
注意
我们可以通过设计提示将上下文学习的示例扩展到多标签分类。例如,我们可以要求模型选择一个或多个标签,并将它们用逗号分隔返回。
命名实体识别
在之前的示例中,我们尝试对整个文本(如评论)进行分类。然而,有许多情况下,我们更关注这些文本中的具体信息。我们可能希望从文本电子健康记录中提取某些药物,或找出新闻帖子中提到的组织。
这些任务通常被称为标记分类或命名实体识别(NER),涉及在文本中检测这些实体。如图 1-17 所示,我们现在将对某些标记或标记集进行分类,而不是对整个文本进行分类。

图 1-17. 一个识别“地点”和“时间”实体的命名实体识别示例。
当我们想到标记分类时,一个主要框架浮现在脑海中,即 SpaCy (spacy.io/)。它是执行许多工业级自然语言处理应用的绝佳工具,并且一直是命名实体识别(NER)任务的首选框架。所以,让我们来使用它吧!
示例
要在 SpaCy 中使用 OpenAI 的模型,我们首先需要将 API 密钥保存为环境变量。这使得 SpaCy 更容易访问,而无需在本地保存:
import os
os.environ['OPENAI_API_KEY'] = "sk-..."
接下来,我们需要配置我们的 SpaCy 管道。需要定义一个“任务”和一个“后端”。“任务”是我们希望 SpaCy 管道执行的内容,即命名实体识别。“后端”是用于执行该“任务”的基础 LLM,即 OpenAI 的 GPT-3.5-turbo 模型。在任务中,我们可以创建任何希望从文本中提取的标签。假设我们有关于患者的信息,我们希望提取一些个人信息,以及他们所患的疾病和症状。我们创建实体:日期、年龄、地点、疾病和症状:
import spacy
nlp = spacy.blank("en")
# Create a Named Entity Recognition Task and define labels
task = {"task": {
"@llm_tasks": "spacy.NER.v1",
"labels": "DATE,AGE,LOCATION, DISEASE, SYMPTOM"}}
# Choose which backend to use
backend = {"backend": {
"@llm_backends": "spacy.REST.v1",
"api": "OpenAI",
"config": {"model": "gpt-3.5-turbo"}}}
# Combine configurations and create SpaCy pipeline
config = task | backend
nlp.add_pipe("llm", config=config)
接下来,我们只需要两行代码即可自动提取我们感兴趣的实体:
> doc = nlp("On February 11, 2020, a 73-year-old woman came to the hospital \n and was diagnosed with COVID-19 and has a cough.")
> print([(ent.text, ent.label_) for ent in doc.ents])
[('February 11', 'DATE'), ('2020', 'DATE'), ('73-year-old', 'AGE'), ('hospital', 'LOCATION'), ('COVID-19', ' DISEASE'), ('cough', ' SYMPTOM')]
它似乎能够正确提取实体,但很难立即看到一切是否顺利进行。幸运的是,SpaCy 有一个显示功能,可以让我们可视化文档中找到的实体(图 1-18):
from spacy import displacy
from IPython.core.display import display, HTML
# Display entities
html = displacy.render(doc, style="ent")
display(HTML(html))

图 1-18. 使用 OpenAI 的 GPT-3.5 模型的 SpaCy 输出。没有任何训练,它正确识别了我们的自定义实体。
这要好得多!图 2-X 显示我们可以清晰地看到模型正确识别了我们的自定义实体。在没有任何微调或模型训练的情况下,我们可以轻松检测到我们感兴趣的实体。
提示
从头开始使用 SpaCy 训练一个 NER 模型并不能仅仅通过几行代码实现,但这也绝对不是困难的事情!他们的文档和教程在我们看来是最先进的,并且在解释如何创建自定义模型方面做得非常出色。
总结
在本章中,我们看到了多种不同的技术来执行各种分类任务。从微调整个模型到完全不调优!对文本数据的分类并不像表面上看起来那么简单,而且有大量创造性的技术可以实现这一目标。
在下一章中,我们将继续讨论分类,但将重点放在无监督分类上。如果我们有没有任何标签的文本数据,我们该怎么办?我们可以提取什么信息?我们将重点关注对数据进行聚类,以及使用主题建模技术为聚类命名。
第二章: 语义搜索
搜索是第一个被广泛采用的“大语言模型”(LLM)应用之一。在开创性论文BERT:用于语言理解的深度双向变换器预训练发布几个月后,谷歌宣布它在使用此模型来增强谷歌搜索,并且它代表了“搜索历史上最大的飞跃之一”。微软必应也表示“从今年四月开始,我们使用大型变换器模型为我们的必应客户带来了过去一年最大的质量改进”。
这清楚地证明了这些模型的强大和实用性。它们的加入瞬间大幅提升了一些最成熟、维护良好的系统,这些系统是全球数十亿人依赖的。它们增加的能力称为语义搜索,使得根据意义进行搜索,而不仅仅是关键词匹配。
在本章中,我们将讨论使用语言模型增强搜索系统的三种主要方法。我们将介绍代码示例,您可以利用这些功能来增强自己的应用程序。请注意,这不仅对网页搜索有用,搜索还是大多数应用程序和产品的重要组成部分。因此,我们的重点不仅是构建一个网页搜索引擎,而是关注您自己的数据集。此功能为许多其他基于搜索的激动人心的 LLM 应用提供动力(例如,检索增强生成或文档问答)。让我们开始看看这三种使用 LLM 进行语义搜索的方法。
基于语言模型的搜索系统的三大类。
关于如何最好地使用 LLM 进行搜索的研究很多。这些模型的三大类是:
1- 密集检索
假设用户在搜索引擎中输入搜索查询。密集检索系统依赖于嵌入的概念,这是我们在前面的章节中遇到的相同概念,并将搜索问题转化为检索搜索查询的最近邻(在查询和文档都转换为嵌入后)。图 2-1 展示了密集检索如何获取搜索查询,查阅其文本档案,并输出一组相关结果。

图 2-1. 密集检索是语义搜索的关键类型之一,依赖文本嵌入的相似性来检索相关结果。
2- 重新排序
这些系统是多个步骤的管道。重排序 LLM 是这些步骤之一,负责对结果子集相对于查询的相关性进行评分,然后根据这些评分更改结果的顺序。图 2-2 显示了重排序器如何不同于密集检索,因为它们需要额外的输入:来自搜索管道前一步的搜索结果集。

图 2-2. 重排序器,第二种关键的语义搜索类型,接收搜索查询和结果集合,并根据相关性重新排序,通常会显著改善结果。
3- 生成搜索
不断增长的文本生成 LLM 能力导致了一批新的搜索系统,其中包括一个生成模型,它简单地对查询生成答案。图 2-3 显示了一个生成搜索的例子。

图 2-3. 生成搜索针对问题生成答案并引用其信息来源。
这三种概念都很强大,可以在同一流程中结合使用。本章其余部分将更详细地介绍这三种系统。虽然这些是主要类别,但它们并不是搜索领域中唯一的 LLM 应用。
密集检索
回想一下,嵌入将文本转换为数字表示。这些可以被视为空间中的点,如我们在图 2-4 中所见。接近的点意味着它们所代表的文本是相似的。因此在这个例子中,文本 1 和文本 2 彼此相似(因为它们靠近),而与文本 3 不同(因为它更远)。

图 2-4. 嵌入的直观理解:每段文本都是一个点,含义相似的文本彼此接近。
这是用于构建搜索系统的属性。在这种情况下,当用户输入搜索查询时,我们将查询嵌入,从而将其投影到与我们的文本档案相同的空间中。然后,我们只需在该空间中找到与查询最接近的文档,这些文档就是搜索结果。

图 2-5. 密集检索依赖于搜索查询与相关结果之间的接近性。
根据图 2-5 中的距离,“文本 2”是这个查询的最佳结果,其次是“文本 1”。但是,这里可能会出现两个问题:
文本 3 是否应该被返回作为结果?这是你作为系统设计者的决定。有时需要设置一个最大相似度分数的阈值,以过滤掉不相关的结果(以防语料库中没有与查询相关的结果)。
查询及其最佳结果在语义上相似吗?不一定。这就是为什么语言模型需要在问答对上进行训练,以便在检索方面变得更好的原因。这个过程在第十三章中有更详细的说明。
密集检索示例
让我们通过使用 Cohere 搜索维基百科关于电影星际穿越的页面来看一个密集检索示例。在这个示例中,我们将执行以下操作:
-
获取我们想要使其可搜索的文本,对其进行一些轻处理以将其拆分成句子。
-
嵌入句子
-
构建搜索索引
-
搜索并查看结果
首先,我们需要安装示例所需的库:
# Install Cohere for embeddings, Annoy for approximate nearest neighbor search
!pip install cohere tqdm Annoy
通过在 https://cohere.ai/注册获取你的 Cohere API 密钥。将其粘贴到下面的单元格中。你在运行这个示例时无需支付任何费用。
让我们导入所需的数据集:
import cohere
import numpy as np
import re
import pandas as pd
from tqdm import tqdm
from sklearn.metrics.pairwise import cosine_similarity
from annoy import AnnoyIndex
# 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)
-
获取文本档案
让我们使用维基百科关于电影星际穿越的第一部分。https://en.wikipedia.org/wiki/Interstellar_(film)。我们将获取文本,然后将其拆分成句子。
text = """ Interstellar is a 2014 epic science fiction film co-written, directed, and produced 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 subsequent 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 = np.array([t.strip(' \n') for t in texts]) -
嵌入文本
让我们现在嵌入文本。我们将把它们发送到 Cohere API,并为每个文本返回一个向量。
# Get the embeddings response = co.embed( texts=texts, ).embeddings embeds = np.array(response) print(embeds.shape)输出如下:
(15, 4096)
表明我们有 15 个向量,每个向量的大小为 4096。
-
构建搜索索引
在我们可以搜索之前,我们需要构建一个搜索索引。索引存储嵌入,并被优化为快速检索最近邻,即使我们有非常大量的点。
# Create the search index, pass the size of embedding search_index = AnnoyIndex(embeds.shape[1], 'angular') # Add all the vectors to the search index for index, embed in enumerate(embeds): search_index.add_item(index, embed) search_index.build(10) search_index.save('test.ann') -
搜索索引
现在我们可以使用任何我们想要的查询来搜索数据集。我们只需嵌入查询,并将其嵌入呈现给索引,索引将检索出最相似的文本。
让我们定义我们的搜索函数:
def search(query): # 1\. Get the query's embedding query_embed = co.embed(texts=[query]).embeddings[0] # 2\. Retrieve the nearest neighbors similar_item_ids = search_index.get_nns_by_vector(query_embed, n=3, include_distances=True) # 3\. Format the results results = pd.DataFrame(data={'texts': texts[similar_item_ids[0]], 'distance': similar_item_ids[1]}) # 4\. Print and return the results print(f"Query:'{query}'\nNearest neighbors:") return results我们现在准备好写查询并搜索文本了!
query = "How much did the film make?" search(query)这产生的输出为:
Query:'How much did the film make?' Nearest neighbors:texts|
distance|
|
0|
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|
0.815905|
|
1|
It stars Matthew McConaughey, Anne Hathaway, Jessica Chastain, Bill Irwin, Ellen Burstyn, Matt Damon, and Michael Caine|
1.066861|
|
2|
In the United States, it was first released on film stock, expanding to venues using digital projectors|
1.086919|
第一个结果的距离最小,因此与查询最相似。查看它,它完美地回答了问题。请注意,如果我们仅进行关键字搜索,这是不可能的,因为最佳结果中不包含“much”或“make”这两个词。
为了进一步说明密集检索的能力,这里有一个查询列表及每个查询的最佳结果:
查询:“告诉我关于$$$的事情?”
最佳结果:这部电影在全球的总票房超过 6.77 亿美元(与后来的重映一起为 7.73 亿美元),成为 2014 年票房第十高的电影。
距离:1.244138
查询:“哪些演员参与了?”
顶部结果:它由马修·麦康纳、安妮·海瑟薇、杰西卡·查斯坦、比尔·欧文、艾伦·伯斯廷、马特·达蒙和迈克尔·凯恩主演。
距离:0.917728
查询:“这部电影是如何上映的?”
顶部结果:在美国,它最初是在胶卷上发布的,扩展到使用数字放映机的场所。
距离:0.871881
密集检索的注意事项
了解密集检索的一些缺点及其解决方法是有用的。例如,如果文本中不包含答案,会发生什么?我们仍然会得到结果及其距离。例如:
Query:'What is the mass of the moon?'
Nearest neighbors:
texts
|
distance
|
|
0
|
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
|
1.298275
|
|
1
|
It has also received praise from many astronomers for its scientific accuracy and portrayal of theoretical astrophysics
|
1.324389
|
|
2
|
Cinematographer Hoyte van Hoytema shot it on 35 mm movie film in the Panavision anamorphic format and IMAX 70 mm
|
1.328375
|
在这种情况下,一个可能的启发式方法是设定一个阈值水平——例如,相关性的最大距离。许多搜索系统向用户提供他们能获取的最佳信息,并由用户决定其相关性。跟踪用户是否点击了结果(并对此感到满意)的信息,可以改善未来版本的搜索系统。
密集检索的另一个注意事项是用户希望找到与其正在寻找的文本完全匹配的情况。这种情况非常适合关键词匹配。这也是为什么同时包括语义搜索和关键词搜索的混合搜索被使用的原因之一。
密集检索系统在训练以外的领域中正常工作也面临挑战。例如,如果你在互联网和维基百科数据上训练检索模型,然后在法律文本上部署(而训练集中没有足够的法律数据),模型在法律领域的表现将不佳。
我们想指出的最后一点是,这是一个每个句子包含一条信息的情况,我们展示了具体询问这些信息的查询。那么,对于答案跨越多个句子的提问呢?这显示了密集检索系统的一个重要设计参数:分块长文本的最佳方法是什么?我们为什么要首先进行分块?
长文本分块
Transformer 语言模型的一个限制是它们的上下文大小有限。这意味着我们不能输入超过模型支持的某个字数或标记数量的非常长的文本。那么我们如何嵌入长文本呢?
有几种可能的方法,图 2-6 中展示的两种可能方法包括每个文档索引一个向量,以及每个文档索引多个向量。

图 2-6. 可以创建一个向量来表示整个文档,但对于较长的文档,将其分割成较小的块以获取各自的嵌入更好。
每个文档一个向量
在这种方法中,我们使用单个向量来表示整个文档。这里的可能性包括:
-
仅嵌入文档的代表部分而忽略其余文本。这可能意味着仅嵌入标题或文档的开头。这对于快速开始构建演示非常有用,但会留下大量未索引的信息,因此不可搜索。作为一种方法,它可能更适合于那些开头捕获文档主要观点的文档(例如:维基百科文章)。但这并不是一个真正系统的最佳方法。
-
将文档分块、嵌入这些块,然后将这些块聚合为单个向量。这里常用的聚合方法是对这些向量取平均。该方法的一个缺点是会产生一个高度压缩的向量,导致文档中大量信息丢失。
这种方法可以满足某些信息需求,但不能满足其他需求。很多时候,搜索的是包含在文章中的特定信息,如果该概念有自己的向量,捕获效果会更好。
每个文档多个向量
在这种方法中,我们将文档分块为更小的部分,并嵌入这些块。我们的搜索索引因此变为块嵌入,而不是整个文档的嵌入。
分块方法更好,因为它全面覆盖了文本,并且向量倾向于捕获文本中的单个概念。这导致了更具表现力的搜索索引。图 X-3 展示了一些可能的方法。

图 2-7. 多种用于嵌入文档的分块选项。
对长文本的最佳分块方式将取决于系统预期的文本类型和查询。方法包括:
-
每个句子是一个块。这里的问题是这可能过于细化,向量无法捕获足够的上下文。
-
每个段落是一个块。如果文本由短段落组成,这很棒。否则,可能每 4-8 句话是一个块。
-
一些块的意义来自于周围的文本。因此,我们可以通过以下方式结合一些上下文:
-
将文档的标题添加到块中。
-
在块中添加一些前后的文本。这样,块可以重叠,从而包括一些周围文本。这就是我们在图 2-8 中看到的。
-

图 2-8. 将文本分块为重叠片段是一种保留不同片段周围更多上下文的策略。
随着该领域的发展,预计会出现更多的分块策略——其中一些甚至可能使用 LLM 动态地将文本分割成有意义的块。
最近邻搜索与向量数据库
找到最近邻的最简单方法是计算查询与档案之间的距离。这可以很容易地用 NumPy 实现,如果你的档案中有成千上万或几万个向量,这也是一种合理的方法。
当你扩展到数百万个向量时,优化检索的方式是依赖于近似最近邻搜索库,如 Annoy 或 FAISS。这些库允许你在毫秒内从巨大的索引中检索结果,有些可以扩展到 GPU 和机器集群,以服务非常大的索引。
另一类向量检索系统是像 Weaviate 或 Pinecone 这样的向量数据库。向量数据库允许你添加或删除向量,而无需重建索引。它们还提供了超越单纯向量距离的搜索过滤或自定义的方法。
为密集检索微调嵌入模型
就像我们在文本分类章节中看到的那样,我们可以通过微调提高大型语言模型在某项任务上的表现。和那种情况一样,检索需要优化文本嵌入,而不仅仅是令牌嵌入。这个微调过程的目标是获取由查询和相关结果组成的训练数据。
看一个来自我们数据集的例子,句子“《星际穿越》于 2014 年 10 月 26 日在洛杉矶首映。”。两个可能的相关查询是:
-
相关查询 1:“《星际穿越》发布日期”
-
相关查询 2:“《星际穿越》什么时候首映”
微调过程的目的是使这些查询的嵌入接近结果句子的嵌入。它还需要看到与句子不相关的查询的负示例。
- 无关查询:“星际穿越演员表”
有了这些示例,我们现在有三对——两对正样本和一对负样本。假设,如我们在图 2-9 中看到的,微调之前,这三条查询与结果文档的距离相同。这并不牵强,因为它们都是在谈论《星际穿越》。

图 2-9。微调之前,相关和无关查询的嵌入可能接近某个特定文档。
微调步骤的目的是使相关查询更靠近文档,同时使无关查询远离文档。我们可以在图 2-10 中看到这一效果。

图 2-10. 经过微调过程后,文本嵌入模型通过结合我们使用相关和不相关文档的示例来定义数据集上的相关性,从而在这一搜索任务上变得更好。
重新排序
很多公司已经建立了搜索系统。对于这些公司,整合语言模型的更简单方法是作为其搜索管道中的最后一步。此步骤的任务是根据搜索查询的相关性改变搜索结果的顺序。这一步可以大大改善搜索结果,实际上这是微软必应为使用类似 BERT 模型改善搜索结果而添加的功能。
图 2-11 展示了作为两阶段搜索系统第二阶段的重新排序搜索系统的结构。

图 2-11. LLM 重新排序器作为搜索管道的一部分,旨在根据相关性重新排列一组筛选后的搜索结果
重新排序示例
重新排序模型接收搜索查询和多个搜索结果,并返回这些文档的最佳排序,使得与查询最相关的文档排名更高。
import cohere as co
API_KEY = ""
co = cohere.Client(API_KEY)
MODEL_NAME = "rerank-english-02" # another option is rerank-multilingual-02
query = "film gross"
Cohere 的Rerank端点是开始使用第一重新排序模型的简单方式。我们只需传入查询和文本,就能得到结果。我们无需对其进行训练或调整。
results = co.rerank(query=query, model=MODEL_NAME, documents=texts, top_n=3)
我们可以打印这些结果:
results = co.rerank(query=query, model=MODEL_NAME, documents=texts, top_n=3) # Change top_n to change the number of results returned. If top_n is not passed, all results will be returned.
for idx, r in enumerate(results):
print(f"Document Rank: {idx + 1}, Document Index: {r.index}")
print(f"Document: {r.document['text']}")
print(f"Relevance Score: {r.relevance_score:.2f}")
print("\n")
输出:
Document Rank: 1, Document Index: 10
Document: 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
Relevance Score: 0.92
Document Rank: 2, Document Index: 12
Document: It has also received praise from many astronomers for its scientific accuracy and portrayal of theoretical astrophysics
Relevance Score: 0.11
Document Rank: 3, Document Index: 2
Document: 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
Relevance Score: 0.03
这表明,重新排序模型对第一个结果的信心更高,为其分配了 0.92 的相关性评分,而其他结果的评分则明显较低。
然而,更常见的是,我们的索引会有数千或数百万个条目,我们需要筛选出,比如说一百或一千个结果,然后将这些结果呈现给重新排序模型。这个筛选过程被称为搜索管道的第一阶段。
我们在上一节中看到的密集检索器示例是一个可能的第一阶段检索器。在实践中,第一阶段也可以是一个结合了关键词搜索和密集检索的搜索系统。
使用句子转换器的开源检索和重新排序
如果你想在自己的机器上本地设置检索和重新排序,那么你可以使用句子转换器库。请参考 https://www.sbert.net/ 中的文档进行设置。查看检索与重新排序部分以获取如何在库中进行这些步骤的说明和代码示例。
重新排序模型的工作原理
一种流行的构建 LLM 搜索重排序器的方法是将查询和每个结果同时呈现给作为交叉编码器工作的 LLM。这意味着查询和可能的结果同时呈现给模型,使其在分配相关性得分之前能够查看这两段文本的完整内容。该方法在一篇题为 多阶段文档排名与 BERT 的论文中有更详细的描述,有时被称为 monoBERT。
将搜索形式化为相关性评分基本上归结为分类问题。给定这些输入,模型输出一个从 0 到 1 的得分,其中 0 是不相关,1 是高度相关。这应该在查看分类章节时是熟悉的。
要了解更多关于使用 LLM 进行搜索的发展,可以参考 预训练变换器进行文本排名:BERT 及其后续,这是对这些模型直到 2021 年的发展进行的高度推荐的观察。
生成搜索
你可能注意到,密集检索和重排序都使用表示语言模型,而不是生成语言模型。这是因为它们在这些任务上比生成模型更优化。
然而,在某个规模上,生成 LLM 开始显得越来越能够进行有用的信息检索。人们开始向像 ChatGPT 这样的模型提问,有时得到了相关的答案。媒体开始将其描绘为对谷歌的威胁,这似乎引发了一场在搜索中使用语言模型的军备竞赛。微软 推出 了由生成模型驱动的 Bing AI。谷歌推出了 Bard,这是它在这个领域的回应。
什么是生成搜索?
生成搜索系统在搜索流程中包括文本生成步骤。然而,目前,生成 LLM 不是可靠的信息检索工具,容易生成连贯但通常不正确的文本来回应它们不知道答案的问题。
第一批生成搜索系统仅将搜索模型作为搜索流程末尾的总结步骤。我们可以在 图 2-12 中看到一个例子。

图 2-12. 生成搜索在搜索流程的末尾制定答案和摘要,同时引用其来源(由搜索系统之前的步骤返回)。
然而,在撰写本文时,语言模型在生成连贯文本方面表现出色,但在检索事实时并不可靠。它们尚未真正知道自己知道或不知道什么,往往用连贯的文本回答许多问题,但可能是错误的。这通常被称为幻觉。因此,由于搜索常常依赖于事实或引用现有文档,生成搜索模型被训练以引用其来源并在答案中包含链接。
生成搜索仍处于初期阶段,预计会随着时间的推移而改善。它源自一个叫做检索增强生成的机器学习研究领域。该领域的显著系统包括RAG、RETRO和Atlas等。
搜索中其他 LLM 应用
除了这三类之外,还有很多其他方式可以使用 LLM 来推动或改善搜索系统。例子包括:
-
生成合成数据以改进嵌入模型。这包括像GenQ和InPars-v2等方法,它们查看文档,生成关于这些文档的可能查询和问题,然后使用生成的数据微调检索系统。
-
文本生成模型日益增长的推理能力使搜索系统能够通过将复杂问题和查询分解为多个子查询来逐步解决,最终得到原始问题的答案。该类别中的一种方法在Demonstrate-Search-Predict: 组合检索和语言模型以进行知识密集型 NLP中有所描述。
评估指标
语义搜索使用信息检索(IR)领域的指标进行评估。让我们讨论这两个流行指标:平均精确度(MAP)和标准化折扣累积增益(nDCG)。
评估搜索系统需要三个主要组件:文本档案、一组查询和相关性判断,指示哪些文档与每个查询相关。我们在图 3-13 中看到了这些组件。

图 2-13。要评估搜索系统,我们需要一个测试套件,包括查询和相关性判断,指示我们档案中的哪些文档与每个查询相关。
使用这个测试套件,我们可以开始探索评估搜索系统。让我们从一个简单的例子开始,假设我们将查询 1 传递给两个不同的搜索系统,并获得两个结果集。假设我们将结果数量限制为仅三个,如在图 2-14 中所示。

图 2-14。为了比较两个搜索系统,我们将测试套件中的同一查询传递给两个系统,并查看它们的顶部结果。
为了判断哪个系统更好,我们查看针对该查询的相关性判断。图 2-15 显示了哪些返回的结果是相关的。

图 2-15。从我们的测试套件查看相关性判断,我们可以看到系统 1 比系统 2 表现更好。
这向我们展示了一个清晰的案例,系统 1 优于系统 2。直观上,我们可能只计算每个系统检索到的相关结果数量。系统 A 正确获取了三个中的两个,而系统 2 仅正确获取了三个中的一个。
但是,对于像图 3-16 这样的情况,两个系统都只获得了三个中的一个相关结果,但它们的位置不同,该如何处理呢?

图 2-16。我们需要一个评分系统,该系统奖励系统 1 为相关结果分配高位,即使两个系统在其前三个结果中仅检索到一个相关结果。
在这种情况下,我们可以直观地判断系统 1 比系统 2 表现更好,因为第一个位置(最重要的位置)的结果是正确的。但我们如何为该结果的优越性分配一个数字或评分呢?均值平均精度是一个能够量化这种区别的度量。
在这种情况下,分配数字评分的一个常见方法是平均精度,它评估系统 1 对该查询的结果为 0.6,而系统 2 的结果为 0.1。因此,让我们看看如何计算平均精度来评估一组结果,然后如何将其聚合以评估整个测试套件中的系统。
均值平均精度(MAP)
为了对系统 1 进行评分,我们需要首先计算多个分数。由于我们只关注三个结果,因此我们需要查看三个分数——与每个位置相关联的一个分数。
第一个很简单,只看第一个结果,我们计算精度分数:将正确结果的数量除以结果的总数(正确和不正确)。图 2-17 显示,在这种情况下,我们在第一个位置上有一个正确结果。因此,精度为 1/1 = 1。

图 2-17. 计算均值平均精度时,我们从计算每个位置的精度开始,从第 1 个位置开始。
我们需要继续计算其余位置的精度结果。第二个位置的计算考虑了第一个和第二个位置。这里的精度分数为 1(两个结果中有一个是正确的)除以 2(我们正在评估的两个结果)= 0.5。
图 2-18 继续计算第二和第三个位置的精度。接下来更进一步——在计算每个位置的精度后,我们将它们平均得到平均精度分数为 0.61。

图 2-18. 标题待补充
该计算显示了单个查询及其结果的平均精度。如果我们计算系统 1 在测试套件中所有查询的平均精度并得出它们的均值,我们可以得到均值平均精度分数,从而可以将系统 1 与测试套件中其他系统进行比较。
摘要
在本章中,我们探讨了使用语言模型来改善现有搜索系统的不同方法,甚至作为新型、更强大搜索系统的核心。这些包括:
-
密集检索依赖于文本嵌入的相似性。这些系统将搜索查询嵌入并检索与查询嵌入最接近的文档。
-
重新排序器(如 monoBERT),这些系统查看查询和候选结果,并对每个文档与该查询的相关性进行评分。这些相关性评分用于根据其与查询的相关性对入围结果进行排序,通常能产生改进的结果排名。
-
生成式搜索,指的是在管道末端具有生成性 LLM 的搜索系统,基于检索到的文档来形成答案,同时引用其来源。
我们还探讨了一种可能的搜索系统评估方法。均值平均精度允许我们为搜索系统评分,以便在查询的测试套件及其已知相关性之间进行比较。
第三章: 文本聚类与主题建模
尽管监督技术,如分类,在过去几年中在行业中占据主导地位,但无监督技术(如文本聚类)的潜力不可低估。
文本聚类旨在根据其语义内容、意义和关系对相似文本进行分组,如图 3-1 所示。正如我们在第 XXX 章中使用文本嵌入之间的距离进行密集检索一样,聚类嵌入使我们能够根据相似性对档案中的文档进行分组。
语义相似文档的聚类结果不仅促进了对大量非结构化文本的高效分类,还允许快速的探索性数据分析。随着大型语言模型(LLMs)的出现,能够提供文本的上下文和语义表示,文本聚类的力量在过去几年中显著增强。语言不是一袋单词,大型语言模型已证明能够很好地捕捉这一概念。
文本聚类的一个被低估的方面是其创造性解决方案和实施的潜力。从某种意义上说,无监督意味着我们不受限于某个特定的任务或我们想要优化的事物。因此,文本聚类中有很大的自由度,使我们能够偏离常规路径。尽管文本聚类自然会用于文档的分组和分类,但它还可以用于算法性和视觉上发现不恰当的标签,进行主题建模,加速标记,以及许多其他有趣的用例。

图 3-1. 聚类非结构化文本数据。
这种自由也带来了挑战。由于我们没有特定任务的指导,那么我们如何评估我们的无监督聚类输出?我们如何优化我们的算法?没有标签,我们在优化算法的目标是什么?我们什么时候知道我们的算法是正确的?算法“正确”意味着什么?尽管这些挑战可能相当复杂,但并非不可逾越,通常需要一些创造力和对用例的良好理解。
在文本聚类的自由与其带来的挑战之间取得平衡可能相当困难。如果我们进入主题建模的世界,这种平衡变得更加明显,因为主题建模开始采用“文本聚类”的思维方式。
通过主题建模,我们希望发现出现在大型文本数据集中的抽象主题。我们可以用多种方式描述主题,但它通常由一组关键字或短语描述。有关自然语言处理(NLP)的主题可以用“深度学习”、“变换器”和“自注意力”等术语来描述。传统上,我们期望关于特定主题的文档包含的术语出现频率高于其他术语。然而,这种期望忽略了文档可能包含的上下文信息。相反,我们可以利用大型语言模型结合文本聚类来建模上下文化的文本信息并提取语义相关的主题。图 3-2 展示了通过文本表示描述集群的想法。
*
图 3-2. 主题建模是一种赋予文本文件集群意义的方法。* 在本章中,我们将提供关于如何使用大型语言模型进行文本聚类的指南。然后,我们将转向一种受文本聚类启发的主题建模方法,即 BERTopic。
文本聚类
NLP 中探索性数据分析的一个主要组成部分是文本聚类。这种无监督技术旨在将相似的文本或文档分组,以便轻松发现大量文本数据中的模式。在深入分类任务之前,文本聚类可以帮助我们直观理解任务及其复杂性。
从文本聚类中发现的模式可以应用于多种商业用例。从识别重复的支持问题和发现新内容以推动 SEO 实践,到检测社交媒体中的主题趋势和发现重复内容,可能性多种多样,运用这样的技术,创造力成为关键要素。因此,文本聚类不仅仅是快速进行探索性数据分析的方法。
数据
在描述如何进行文本聚类之前,我们将首先介绍在本章中将使用的数据。为了保持本书的主题,我们将对机器学习和自然语言处理领域的各种 ArXiv 文章进行聚类。该数据集包含大约XXX篇文章,时间跨度为XXX到XXX。
我们首先使用HuggingFace 的数据集包导入我们的数据集,并提取稍后要使用的元数据,例如文章的摘要、年份和类别。
# Load data from huggingface
from datasets import load_dataset
dataset = load_dataset("maartengr/arxiv_nlp")["train"]
# Extract specific metadata
abstracts = dataset["Abstracts"]
years = dataset["Years"]
categories = dataset["Categories"]
titles = dataset["Titles"]
我们如何进行文本聚类?
现在我们有了数据,可以进行文本聚类。进行文本聚类时,可以采用多种技术,从基于图的神经网络到基于中心的聚类技术。在这一部分,我们将介绍一种著名的文本聚类流程,包括三个主要步骤:
-
嵌入文档
-
降维
-
聚类嵌入
1. 嵌入文档
聚类文本数据的第一步是将我们的文本数据转换为文本嵌入。回想前面的章节,嵌入是文本的数值表示,捕捉其含义。为语义相似性任务优化的嵌入对于聚类尤为重要。通过将每个文档映射到数值表示,使语义相似的文档彼此接近,聚类将变得更加强大。一组为这些任务优化的流行大型语言模型可以在著名的句子转换器框架中找到(reimers2019sentence)。图 3-3 展示了将文档转换为数值表示的第一步。
*
图 3-3. 第一步:我们将文档转换为数值表示,即嵌入。* *句子转换器具有清晰的 API,可以如下所示从文本片段生成嵌入:
from sentence_transformers import SentenceTransformer
# We load our model
embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
# The abstracts are converted to vector representations
embeddings = model.encode(abstracts)
这些嵌入的大小因模型而异,但通常每个句子或段落至少包含 384 个值。嵌入所包含的值的数量称为嵌入的维度。* *### 2. 降维
在我们聚类从 ArXiv 摘要生成的嵌入之前,我们首先需要处理维度灾难。这个诅咒是在处理高维数据时出现的现象。随着维度的增加,每个维度内可能值的数量呈指数级增长。在每个维度内找到所有子空间变得越来越复杂。此外,随着维度的增加,点之间的距离概念变得越来越不精确。
因此,高维数据对于许多聚类技术来说可能是麻烦的,因为识别有意义的聚类变得更加困难。聚类变得更加分散和难以区分,使得准确识别和分离它们变得困难。
先前生成的嵌入具有较高的维度,通常会引发维度灾难。为了防止维度成为问题,我们聚类管道中的第二步是降维,如图 3-4 所示。

图 3-4. 第 2 步:嵌入通过维度减少被降低到一个低维空间。
维度减少技术旨在通过寻找低维表示来保留高维数据的全局结构。著名的方法包括主成分分析(PCA)和均匀流形近似与投影(UMAP; mcinnes2018umap)。对于这个流程,我们选择 UMAP,因为它通常比 PCA 更好地处理非线性关系和结构。
注意
然而,维度减少技术并非完美无缺。它们无法完美地将高维数据捕捉到低维表示中。在这个过程中信息总会有所丢失。在减少维度和尽可能保留信息之间存在平衡。
为了进行维度减少,我们需要实例化我们的 UMAP 类并将生成的嵌入传递给它:
from umap import UMAP
# We instantiate our UMAP model
umap_model = UMAP(n_neighbors=15, n_components=5, min_dist=0.0, metric='cosine')
# We fit and transform our embeddings to reduce them
reduced_embeddings = umap_model.fit_transform(embeddings)
我们可以使用n_components参数来决定低维空间的形状。在这里,我们使用了n_components=5,因为我们希望尽可能保留信息而不陷入维度灾难。没有哪个值比另一个更好,因此请随意尝试!
3. 聚类嵌入
如图 3-5 所示,我们流程中的最后一步是对之前减少的嵌入进行聚类。许多算法能够很好地处理聚类任务,从基于质心的方法如 k-Means 到层次方法如凝聚聚类。选择取决于用户,并受到相应用例的高度影响。我们的数据可能包含一些噪声,因此更倾向于使用能检测异常值的聚类算法。如果我们的数据是每日产生的,我们可能希望寻找在线或增量的方法来建模是否创建了新的聚类。

图 3-5. 第 3 步:我们使用减少维度的嵌入对文档进行聚类。
一个好的默认模型是基于密度的空间聚类算法(HDBSCAN; mcinnes2017hdbscan)。HDBSCAN 是名为 DBSCAN 的聚类算法的一种层次变体,允许找到密集(微)聚类,而无需明确指定聚类数量。作为一种基于密度的方法,它也可以检测数据中的异常值。数据点如果不属于任何聚类。这一点很重要,因为强行将数据归入聚类可能会产生噪声聚合。
与之前的包一样,使用 HDBSCAN 非常简单。我们只需实例化模型并将我们的减少嵌入传递给它:
from hdbscan import HDBSCAN
# We instantiate our HDBSCAN model
hdbscan_model = HDBSCAN(min_cluster_size=15, metric='euclidean', cluster_selection_method='eom')
# We fit our model and extract the cluster labels
hdbscan_model.fit(reduced_embeddings)
labels = hdbscan_model.labels_
然后,利用我们之前生成的 2D 嵌入,我们可以可视化 HDBSCAN 如何对数据进行聚类:
import seaborn as sns
# Reduce 384-dimensional embeddings to 2 dimensions for easier visualization
reduced_embeddings = UMAP(n_neighbors=15, n_components=2,
min_dist=0.0, metric='cosine').fit_transform(embeddings)
df = pd.DataFrame(np.hstack([reduced_embeddings, clusters.reshape(-1, 1)]),
columns=["x", "y", "cluster"]).sort_values("cluster")
# Visualize clusters
df.cluster = df.cluster.astype(int).astype(str)
sns.scatterplot(data=df, x='x', y='y', hue='cluster',
linewidth=0, legend=False, s=3, alpha=0.3)
如我们在图 3-6 中看到的,它能够很好地捕捉主要聚类。注意这些点的聚类被涂成相同的颜色,表明 HDBSCAN 将它们分为一组。由于我们有大量聚类,绘图库在聚类之间循环颜色,所以不要认为所有蓝色点都是一个聚类,例如。

图 3-6. 生成的聚类(彩色)和离群点(灰色)作为 2D 可视化呈现。
注意
任何用于可视化目的的降维技术都会导致信息损失。这仅仅是对我们原始嵌入的近似。尽管这很有信息量,但它可能将聚类推得更近或更远于它们实际的位置。因此,人类评估,亲自检查聚类,是聚类分析的关键组成部分!
我们可以手动检查每个聚类,以查看哪些文档在语义上足够相似以被聚类在一起。例如,让我们从聚类XXX中随机取出几个文档:
>>> for index in np.where(labels==1)[0][:3]:
>>> print(abstracts[index])
Sarcasm is considered one of the most difficult problem in sentiment
analysis. In our ob-servation on Indonesian social media, for cer-tain topics,
people tend to criticize something using sarcasm. Here, we proposed two
additional features to detect sarcasm after a common sentiment analysis is
con...
Automatic sarcasm detection is the task of predicting sarcasm in text. This
is a crucial step to sentiment analysis, considering prevalence and challenges
of sarcasm in sentiment-bearing text. Beginning with an approach that used
speech-based features, sarcasm detection has witnessed great interes...
We introduce a deep neural network for automated sarcasm detection. Recent
work has emphasized the need for models to capitalize on contextual features,
beyond lexical and syntactic cues present in utterances. For example, different
speakers will tend to employ sarcasm regarding different subjects...
这些打印的文档告诉我们,该聚类可能包含关于XXX的文档。我们可以对每个创建的聚类执行此操作,但这可能会很繁琐,特别是如果我们想尝试调整超参数。相反,我们希望创建一种方法,能够自动从这些聚类中提取表示,而无需逐一检查所有文档。
这就是主题建模发挥作用的地方。它使我们能够对这些聚类进行建模,并赋予它们单一的意义。尽管有许多技术可供选择,我们选择了一种基于这种聚类理念的方法,因为它具有显著的灵活性。* *# 主题建模
传统上,主题建模是一种旨在在一组文本数据中寻找潜在主题或主题的技术。对于每个主题,会识别出一组最佳代表该主题含义的关键词或短语。这种技术非常适合在大语料库中寻找共同主题,因为它为相似内容集赋予意义。关于主题建模实践的图示概述可以在图 3-7 中找到。
潜在狄利克雷分配(LDA;blei2003latent)是一种经典且流行的主题建模方法,它假设每个主题由语料库词汇中的单词概率分布特征化。每个文档被视为主题的混合。例如,关于大型语言模型的文档可能高度概率地包含“BERT”、“自注意力”和“变换器”等词,而关于强化学习的文档可能高度概率地包含“PPO”、“奖励”和“rlhf”等词。

图 3-7. 传统主题建模概述。
至今,这项技术仍然是许多主题建模应用中的基础,凭借其强大的理论背景和实际应用,它不太可能很快消失。然而,随着大型语言模型的似乎呈指数增长,我们开始想知道是否可以在主题建模领域利用这些大型语言模型。
已经有几种模型采用大型语言模型进行主题建模,例如嵌入式主题模型和上下文化主题模型。然而,随着自然语言处理领域的快速发展,这些模型难以跟上。
解决此问题的方案是 BERTopic,这是一种利用高度灵活和模块化架构的主题建模技术。通过这种模块化,许多新发布的模型可以集成到其架构中。随着大型语言模型领域的发展,BERTopic 也在不断发展。这使得这些模型在主题建模中应用的方式变得有趣且意想不到。
BERTopic
BERTopic 是一种主题建模技术,它假设语义相似文档的集群是生成和描述集群的有效方式。每个集群中的文档预计描述一个主要主题,合在一起可能代表一个主题。
正如我们在文本聚类中看到的,集群中的文档集合可能代表一个共同主题,但主题本身尚未被描述。通过文本聚类,我们必须逐一查看集群中的每个文档,以了解该集群的内容。要使一个集群被称为主题,我们需要一种以简洁且易于理解的方式描述该集群的方法。
尽管有很多方法可以做到这一点,但 BERTopic 中有一个技巧,可以快速描述一个集群,从而将其定义为一个主题,同时生成一个高度模块化的管道。BERTopic 的基础算法大致包含两个主要步骤。
首先,正如我们在文本聚类示例中所做的那样,我们嵌入文档以创建数值表示,然后降低它们的维度,最后对降维后的嵌入进行聚类。结果是语义上相似文档的聚类。
图 3-8 描述了与之前相同的步骤,即使用句子变换器对文档进行嵌入,使用 UMAP 进行降维,以及使用 HDBSCAN 进行聚类。
*
图 3-8. BERTopic 流程的第一部分是对文本数据进行聚类。* *第二,我们为每个聚类找到最佳匹配的关键词或短语。通常,我们会取一个聚类的中心点,寻找可能最好地代表它的单词、短语或甚至句子。然而,这有一个缺点:我们必须持续跟踪我们的嵌入,如果我们有数百万个文档,存储和跟踪变得计算上困难。相反,BERTopic 使用经典的词袋方法来表示聚类。词袋正是名称所暗示的,对于每个文档,我们简单地计算某个单词出现的频率,并将其用作我们的文本表示。
然而,“the”、“and”和“I”等词在大多数英语文本中出现相当频繁,可能会被过度代表。为了给予这些词适当的权重,BERTopic 使用了一种称为 c-TF-IDF 的技术,意为基于类别的词频逆文档频率。c-TF-IDF 是经典 TF-IDF 过程的类别适应版本。与考虑文档内单词的重要性不同,c-TF-IDF 考虑的是文档聚类之间单词的重要性。
要使用 c-TF-IDF,我们首先将聚类中的每个文档连接成一个长文档。然后,我们提取类别c中术语f_x的频率,其中c指的是我们之前创建的聚类之一。现在我们可以知道每个聚类中包含多少个和哪些单词,仅仅是一个计数。
为了加权这个计数,我们取一个加一后的聚类中平均单词数A的对数,然后除以所有聚类中术语x的频率。加一是在对数内添加的,以确保得到正值,这在 TF-IDF 中也是常见的做法。
如图 3-9 所示,c-TF-IDF 计算使我们能够为每个聚类中的单词生成一个对应于该聚类的权重。因此,我们为每个主题生成一个主题-词矩阵,描述它们所包含的最重要的单词。它本质上是每个主题中语料库词汇的排名。
*
图 3-9. BERTopic 管道的第二部分是表示主题。计算术语x在类别c中的权重。*将这两个步骤结合起来,即聚类和表示主题,形成了 BERTopic 的完整管道,如图 3-10 所示。通过这个管道,我们可以对语义相似的文档进行聚类,并从这些聚类生成由多个关键词表示的主题。关键词对主题的权重越高,它就越能代表该主题。

图 3-10. BERTopic 的完整管道大致由两个步骤组成:聚类和主题表示。
注意
有趣的是,c-TF-IDF 技巧不使用大型语言模型,因此不考虑单词的上下文和语义特性。然而,就像神经搜索一样,它提供了一个高效的起点,之后我们可以使用计算量较大的技术,例如类似 GPT 的模型。
该管道的一个主要优点是这两个步骤,聚类和主题表示,相对独立。当我们使用 c-TF-IDF 生成主题时,不使用聚类步骤的模型,例如,不需要跟踪每个文档的嵌入。因此,这为主题生成过程以及整个管道提供了显著的模块化。
注意
在聚类过程中,每个文档仅分配到一个单一的聚类或主题。在实践中,文档可能包含多个主题,将多主题文档分配到单一主题并不总是最准确的方法。我们稍后会深入讨论这一点,因为 BERTopic 有几种处理方法,但理解 BERTopic 的主题建模本质上是一项聚类任务是很重要的。
BERTopic 管道的模块化特性可以扩展到每个组件。尽管句子变换器作为默认嵌入模型用于将文档转换为数值表示,但我们并不受限于使用任何其他嵌入技术。维度减少、聚类和主题生成过程同样适用。无论用例是选择 k-Means 而不是 HDBSCAN,还是选择 PCA 而不是 UMAP,都是可能的。
你可以把这种模块化看作是用乐高积木构建,管道的每个部分都可以完全替换为另一个类似的算法。这种“乐高积木”思维方式在图 3-11 中得到了说明。该图还展示了我们可以使用的一个额外的算法乐高块。尽管我们使用 c-TF-IDF 来创建初始主题表示,但还有许多有趣的方法可以利用 LLMs 来微调这些表示。在下面的“表示模型”部分,我们将详细探讨这个算法乐高块的工作原理。

图 3-11. BERTopic 的模块化是一个关键组件,允许你根据需要构建自己的主题模型。
代码概述
够了,开始动手吧!这是一本实践型书籍,现在是时候进行一些实际编码了。默认管道,如之前在图 3-10 中所示,只需要几行代码:
from bertopic import BERTopic
# Instantiate our topic model
topic_model = BERTopic()
# Fit our topic model on a list of documents
topic_model.fit(documents)
然而,BERTopic 的模块化特性以及我们迄今为止可视化的内容,也可以通过编码示例进行可视化。首先,让我们导入一些相关的包:
from umap import UMAP
from hdbscan import HDBSCAN
from sentence_transformers import SentenceTransformer
from sklearn.feature_extraction.text import CountVectorizer
from bertopic import BERTopic
from bertopic.representation import KeyBERTInspired
from bertopic.vectorizers import ClassTfidfTransformer
正如你可能注意到的,大多数导入的包,如 UMAP 和 HDBSCAN,是默认 BERTopic 管道的一部分。接下来,让我们更明确地构建 BERTopic 的默认管道,逐步进行每个个体步骤:
# Step 1 - Extract embeddings (blue block)
embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
# Step 2 - Reduce dimensionality (red block)
umap_model = UMAP(n_neighbors=15, n_components=5, min_dist=0.0, metric='cosine')
# Step 3 - Cluster reduced embeddings (green block)
hdbscan_model = HDBSCAN(min_cluster_size=15, metric='euclidean', cluster_selection_method='eom', prediction_data=True)
# Step 4 - Tokenize topics (yellow block)
vectorizer_model = CountVectorizer(stop_words="english")
# Step 5 - Create topic representation (grey block)
ctfidf_model = ClassTfidfTransformer()
# Step 6 - (Optional) Fine-tune topic representations with
# a `bertopic.representation` model (purple block)
representation_model = KeyBERTInspired()
# Combine the steps and build our own topic model
topic_model = BERTopic(
embedding_model=embedding_model, *# Step 1 - Extract embeddings*
umap_model=umap_model, *# Step 2 - Reduce dimensionality*
hdbscan_model=hdbscan_model, *# Step 3 - Cluster reduced embeddings*
vectorizer_model=vectorizer_model, *# Step 4 - Tokenize topics*
ctfidf_model=ctfidf_model, *# Step 5 - Extract topic words*
representation_model=representation_model *# Step 6 - Fine-tune topics*
)
这段代码使我们能够明确地经历算法的所有步骤,并且基本上让我们以任何我们想要的方式构建主题模型。所得到的主题模型,在变量topic_model中定义,现已代表 BERTopic 的基本管道,如之前在图 3-10 中所示。** **## 示例
在整个使用案例中,我们将继续使用 ArXiv 文章的摘要。为了回顾我们在文本聚类中所做的工作,我们开始使用 HuggingFace 的数据集包导入数据集,并提取我们稍后要使用的元数据,如摘要、年份和文章类别。
# Load data from huggingface
from datasets import load_dataset
dataset = load_dataset("maartengr/arxiv_nlp")
# Extract specific metadata
abstracts = dataset["Abstracts"]
years = dataset["Years"]
categories = dataset["Categories"]
titles = dataset["Titles"]
使用 BERTopic 非常简单,只需三行代码即可完成:
# Train our topic model in only three lines of code
from bertopic import BERTopic
topic_model = BERTopic()
topics, probs = topic_model.fit_transform(abstracts)
使用这个管道,你将获得 3 个返回变量,即topic_model、topics和probs:
-
topic_model是我们刚刚训练的模型,包含有关模型和我们创建的主题的信息。 -
topics是每个摘要的主题。 -
probs是某个主题属于特定摘要的概率。
在我们开始探索主题模型之前,有一个变化需要使结果可复现。如前所述,BERTopic 的一个基础模型是 UMAP。这个模型具有随机性,这意味着每次运行 BERTopic 时,我们都会得到不同的结果。我们可以通过将random_state传递给 UMAP 模型来防止这种情况。
from umap import UMAP
from bertopic import BERTopic
# Using a custom UMAP model
umap_model = UMAP(n_neighbors=15, n_components=5, min_dist=0.0, metric='cosine', random_state=42)
# Train our model
topic_model = BERTopic(umap_model=umap_model)
topics, probs = topic_model.fit_transform(abstracts)
现在,让我们开始探索创建的主题。get_topic_info()方法可以快速描述我们找到的主题:
>>> topic_model.get_topic_info()
Topic Count Name
0 -1 11648 -1_of_the_and_to
1 0 1554 0_question_answer_questions_qa
2 1 620 1_hate_offensive_toxic_detection
3 2 578 2_summarization_summaries_summary_abstractive
4 3 568 3_parsing_parser_dependency_amr
... ... ... ...
317 316 10 316_prf_search_conversational_spoke
318 317 10 317_crowdsourcing_workers_annotators_underline
319 318 10 318_curriculum_nmt_translation_dcl
320 319 10 319_botsim_menu_user_dialogue
321 320 10 320_color_colors_ib_naming
从我们的模型中生成了许多主题,XXX!每个主题由几个关键字表示,这些关键字在名称列中用“_”连接。这个名称列使我们能够快速了解主题内容,因为它显示了最能代表该主题的四个关键字。
注意
你可能也注意到第一个主题标记为-1。这个主题包含所有无法归入某个主题的文档,并被视为离群值。这是聚类算法 HDBSCAN 的结果,它并不强制所有点都被聚类。为了去除离群值,我们可以使用非离群算法,如 k-Means,或使用 BERTopic 的reduce_outliers()函数去除一些离群值并将它们分配给主题。
例如,主题 2 包含关键字“summarization”、“summaries”、“summary”和“abstractive”。根据这些关键字,似乎这个主题是关于总结任务的。为了获取每个主题的前 10 个关键字及其 c-TF-IDF 权重,我们可以使用 get_topic()函数:
>>> topic_model.get_topic(2)
[('summarization', 0.029974019692323675),
('summaries', 0.018938088406361412),
('summary', 0.018019112468622436),
('abstractive', 0.015758156442697138),
('document', 0.011038627359130419),
('extractive', 0.010607624721836042),
('rouge', 0.00936377058925341),
('factual', 0.005651676100789188),
('sentences', 0.005262910357048789),
('mds', 0.005050565343932314)]
这为我们提供了更多关于主题的背景,有助于我们理解主题的内容。例如,看到“rogue”这个词出现是很有趣的,因为这是评估摘要模型的一个常见指标。
我们可以使用find_topics()函数根据搜索词搜索特定主题。让我们搜索一个关于主题建模的主题:
>>> topic_model.find_topics("topic modeling")
([17, 128, 116, 6, 235],
[0.6753638370140129,
0.40951682679389345,
0.3985390076544335,
0.37922002441932795,
0.3769700288091359])
它返回主题 17 与我们的搜索词具有相对较高的相似度(0.675)。如果我们检查该主题,可以看到它确实是关于主题建模的主题:
>>> topic_model.get_topic(17)
[('topic', 0.0503756681079549),
('topics', 0.02834246786579726),
('lda', 0.015441277604137684),
('latent', 0.011458141214781893),
('documents', 0.01013764950401255),
('document', 0.009854201885298964),
('dirichlet', 0.009521114618288628),
('modeling', 0.008775384549157435),
('allocation', 0.0077508974418589605),
('clustering', 0.005909325849593925)]
尽管我们知道这个主题是关于主题建模的,但让我们看看 BERTopic 的摘要是否也分配给了这个主题:
>>> topics[titles.index('BERTopic: Neural topic modeling with a class-based TF-IDF procedure')]
17
是的!看起来这个主题不仅涉及基于 LDA 的方法,还有基于聚类的技术,比如 BERTopic。
最后,我们之前提到许多主题建模技术假设一个文档甚至一句话中可能包含多个主题。尽管 BERTopic 利用聚类,这假设每个数据点只有一个分配,但它可以近似主题分布。
我们可以使用这种技术查看 BERTopic 论文第一句话的主题分布:
index = titles.index('BERTopic: Neural topic modeling with a class-based TF-IDF procedure')
# Calculate the topic distributions on a token-level
topic_distr, topic_token_distr = topic_model.approximate_distribution(abstracts[index][:90], calculate_tokens=True)
df = topic_model.visualize_approximate_distribution(abstracts[index][:90], topic_token_distr[0])
df

图 3-12. BERTopic 中提供了多种可视化选项。
如图 3-12 所示的输出表明,文档在一定程度上包含多个主题。此分配甚至是在令牌级别上完成的!
(互动)可视化
手动处理XXX主题可能是一项艰巨的任务。相反,多个有用的可视化功能让我们能够广泛了解生成的主题。其中许多使用 Plotly 可视化框架进行互动。
图 3-13 显示了 BERTopic 中所有可能的可视化选项,从二维文档表示和主题条形图到主题层次和相似性。虽然我们没有逐一介绍所有可视化,但有些值得关注。

图 3-13。BERTopic 中可用多种可视化选项。
首先,我们可以通过使用 UMAP 来减少每个主题的 c-TF-IDF 表示,创建主题的二维表示。
topic_model.visualize_topics()

图 3-14。主题在二维空间中的主题间距离图。
如图 3-14 所示,这生成了一个互动可视化,当鼠标悬停在一个圆圈上时,我们可以看到主题、其关键词及其大小。主题的圆圈越大,包含的文档越多。通过与此可视化的交互,我们可以快速看到相似主题的组。
我们可以使用visualize_documents()函数将分析提升到另一个层次,即在文档层面分析主题。
# Visualize a selection of topics and documents
topic_model.visualize_documents(titles,
topics=[0, 1, 2, 3, 4, 6, 7, 10, 12,
13, 16, 33, 40, 45, 46, 65])

图 3-15。摘要及其主题在二维可视化中表示。
图 3-15 演示了 BERTopic 如何在二维空间中可视化文档。
注意
我们只可视化了部分主题,因为显示所有 300 个主题会导致可视化变得相当杂乱。此外,我们传递的是titles而不是abstracts,因为我们只想在鼠标悬停在文档上时查看每篇论文的标题,而不是整个摘要。
最后,我们可以使用 visualize_barchart()创建一个关键词的条形图,基于一部分主题:
topic_model.visualize_barchart(topics=list(range(50, 58, 1)))

图 3-16。前 8 个主题的前 5 个关键词。
图 3-16 中的柱状图很好地指示了哪些关键词对特定主题最重要。以主题 2 为例——似乎单词“总结”最能代表该主题,而其他单词在重要性上非常相似。
表示模型
借助 BERTopic 采用的神经搜索风格的模块化,它可以利用多种不同类型的大型语言模型,同时最小化计算。这使得各种主题微调方法得以实现,从词性标注到文本生成方法,例如 ChatGPT。图 3-17 展示了我们可以利用来微调主题表示的各种 LLM。

图 3-17。在应用 c-TF-IDF 权重后,可以使用多种表示模型对主题进行微调。其中许多是大型语言模型。
使用 c-TF-IDF 生成的主题是与其主题相关的单词的良好初步排名。在本节中,这些单词的初步排名可以视为主题的候选关键词,因为我们可能会根据任何表示模型来改变它们的排名。我们将介绍几种可以在 BERTopic 中使用的表示模型,并且从大型语言模型的角度来看,这些模型也非常有趣。
在开始之前,我们首先需要做两件事。第一,我们将保存原始主题表示,这样与有无表示模型进行比较时将更容易:
# Save original representations
from copy import deepcopy
original_topics = deepcopy(topic_model.topic_representations_)
第二,让我们创建一个简短的包装,以便快速可视化主题词的差异,以便比较有无表示模型的情况:
def topic_differences(model, original_topics, max_length=75, nr_topics=10):
""" For the first 10 topics, show the differences in
topic representations between two models """
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])
# Print a 'before' and 'after'
whitespaces = " " * (max_length - len(og_words))
print(f"Topic: {topic} {og_words}{whitespaces}--> {new_words}")
KeyBERTInspired
c-TF-IDF 生成的主题并未考虑主题中单词的语义性质,这可能导致生成包含停用词的主题。我们可以使用模块 bertopic.representation_model.KeyBERTInspired() 根据关键词与主题的语义相似性来微调主题关键词。
KeyBERTInspired 是一种方法,正如你可能猜到的,灵感来自于 关键词提取包 KeyBERT。在最基本的形式中,KeyBERT 通过余弦相似度比较文档中单词的嵌入与文档嵌入,以查看哪些单词与文档最相关。这些最相似的单词被视为关键词。
在 BERTopic 中,我们希望使用类似的方法,但在主题层面而不是文档层面。如图 3-18 所示,KeyBERTInspired 使用 c-TF-IDF 为每个主题创建一组代表性文档,方法是随机抽取每个主题的 500 个文档,计算它们的 c-TF-IDF 值,并找到最具代表性的文档。这些文档被嵌入并平均,用作更新后的主题嵌入。然后,计算我们的候选关键词与更新后的主题嵌入之间的相似度,以重新排序我们的候选关键词。

图 3-18. KeyBERTInspired 表示模型的过程
# KeyBERTInspired
from bertopic.representation import KeyBERTInspired
representation_model = KeyBERTInspired()
# Update our topic representations
new_topic_model.update_topics(abstracts, representation_model=representation_model)
# Show topic differences
topic_differences(topic_model, new_topic_model)
主题: 0 问题 | qa | 问题 | 答案 | 回答 --> 问答 | 回答 | 问答 | 注意 | 检索
主题: 1 仇恨 | 攻击性 | 言论 | 检测 | 有毒 --> 仇恨的 | 仇恨 | 网络欺凌 | 言论 | 推特
主题: 2 摘要 | 总结 | 总结 | 抽象 | 提取 --> 摘要生成器 | 摘要生成 | 摘要 | 摘要 | 总结
主题: 3 解析 | 解析器 | 依赖 | amr | 解析器 --> 解析器 | 解析 | 树库 | 解析器 | 树库
主题: 4 词 | 嵌入 | 嵌入 | 相似性 | 向量 --> word2vec | 嵌入 | 嵌入 | 相似性 | 语义
主题: 5 性别 | 偏见 | 偏差 | 去偏见 | 公平 --> 偏见 | 偏差 | 性别 | 性别 | 性别化
主题: 6 关系 | 提取 | re | 关系 | 实体 --> 关系 | 关系 | 实体 | 实体 | 关系的
主题: 7 提示 | 少量实例 | 提示 | 上下文 | 调整 --> 提示调整 | 提示 | 提示 | 提示中 | 基于提示
主题: 8 方面 | 情感 | absa | 基于方面 | 意见 --> 情感 | 方面 | 方面 | 方面级别 | 情感
主题: 9 解释 | 解释 | 理由 | 理由 | 可解释性 --> 解释 | 解释者 | 可解释性 | 解释 | 注意
更新后的模型显示,与原始模型相比,主题的可读性大大提高。同时,它也显示了使用基于嵌入技术的缺点。原始模型中的词汇,例如“amr”和“qa”,都是合理的词汇。
词性
c-TF-IDF 并不区分其认为重要的词的类型。无论是名词、动词、形容词,甚至是介词,它们都可能成为重要关键词。当我们希望有易于人类理解的标签,简单直观时,我们可能希望主题仅由名词来描述。
这里就是著名的 SpaCy 包派上用场的地方。这是一个工业级的自然语言处理框架,提供多种管道、模型和部署选项。更具体地说,我们可以使用 SpaCy 加载一个能够检测词性(无论是名词、动词还是其他)的英语模型。
如图 3-19 所示,我们可以使用 SpaCy 确保只有名词进入我们的主题表示。与大多数表示模型一样,这种方法非常高效,因为名词仅从一个小而具有代表性的数据子集提取。

图 3-19. 词性表示模型的过程
# Part-of-Speech tagging
from bertopic.representation import PartOfSpeech
representation_model = PartOfSpeech("en_core_web_sm")
# Use the representation model in BERTopic on top of the default pipeline
topic_model.update_topics(abstracts, representation_model=representation_model)
# Show topic differences
topic_differences(topic_model, original_topics)
主题:0 问题 | qa | 问题 | 答案 | 回答 --> 问题 | 问题 | 答案 | 回答 | 答案
主题:1 仇恨 | 冒犯 | 言论 | 检测 | 有毒 --> 仇恨 | 冒犯 | 言论 | 检测 | 有毒
主题:2 摘要 | 摘要 | 总结 | 抽象 | 提取 --> 摘要 | 摘要 | 总结 | 抽象 | 提取
主题:3 解析 | 解析器 | 依赖 | amr | 解析器 --> 解析 | 解析器 | 依赖 | 解析器 | 树库
主题:4 单词 | 嵌入 | 嵌入 | 相似性 | 向量 --> 单词 | 嵌入 | 相似性 | 向量 | 单词
主题:5 性别 | 偏见 | 偏见 | 去偏见 | 公平 --> 性别 | 偏见 | 偏见 | 去偏见 | 公平
主题:6 关系 | 提取 | re | 关系 | 实体 --> 关系 | 提取 | 关系 | 实体 | 远程
主题:7 提示 | 少样本 | 提示 | 上下文 | 调整 --> 提示 | 提示 | 调整 | 提示 | 任务
主题:8 方面 | 情感 | absa | 基于方面 | 意见 --> 方面 | 情感 | 意见 | 方面 | 极性
主题:9 解释 | 解释 | 理由 | 理由 | 可解释性 --> 解释 | 解释 | 理由 | 理由 | 可解释性
最大边际相关性
使用 c-TF-IDF,生成的关键词可能会有很多冗余,因为它不认为“车”和“汽车”本质上是相同的。换句话说,我们希望生成的主题具有足够的多样性,同时尽可能少重复。(图 3-20)

图 3-20. 最大边际相关性表示模型的过程。生成关键词的多样性由 λ(λ)表示。我们可以使用一种名为最大边际相关性(MMR)的算法来使我们的主题表示多样化。该算法从与主题最匹配的关键词开始,然后迭代计算下一个最佳关键词,同时考虑一定程度的多样性。换句话说,它会取一些候选主题关键词,例如 30 个,并尝试选择最佳代表主题的前 10 个关键词,同时确保它们彼此多样化。
# Maximal Marginal Relevance
from bertopic.representation import MaximalMarginalRelevance
representation_model = MaximalMarginalRelevance(diversity=0.5)
# Use the representation model in BERTopic on top of the default pipeline
topic_model.update_topics(abstracts, representation_model=representation_model)
# Show topic differences
topic_differences(topic_model, original_topics)
主题: 0 问题 | QA | 问题 | 回答 | 回答中 --> QA | 问题 | 回答 | 理解 | 检索
主题: 1 仇恨 | 冒犯 | 演讲 | 检测 | 有毒 --> 演讲 | 侮辱 | 毒性 | 平台 | 仇恨
主题: 2 总结 | 摘要 | 总结 | 抽象 | 提取 --> 总结 | 提取 | 多文档 | 文档 | 评估
主题: 3 解析 | 解析器 | 依赖 | AMR | 解析器 --> AMR | 解析器 | 语料库 | 句法 | 成分
主题: 4 词 | 嵌入 | 嵌入 | 相似性 | 向量 --> 嵌入 | 相似性 | 向量 | word2vec | glove
主题: 5 性别 | 偏见 | 偏见 | 去偏见 | 公平 --> 性别 | 偏见 | 公平 | 刻板印象 | 嵌入
主题: 6 关系 | 提取 | 关系 | 实体 --> 提取 | 关系 | 实体 | 文档级 | 文档提取
主题: 7 提示 | 少样本 | 提示 | 上下文 | 调整 --> 提示 | 零样本 | PLMs | 元学习 | 标签
主题: 8 方面 | 情感 | ABSA | 基于方面 | 观点 --> 情感 | ABSA | 方面 | 提取 | 极性
主题: 9 解释 | 解释 | 理由 | 理由 | 可解释性 --> 解释 | 可解释性 | 显著性 | 可信性 | 方法
生成的主题更加多样化!主题XXX原本使用了很多“总结”相关的词汇,而现在该主题只包含“总结”这个词。同时,像“embedding”和“embeddings”的重复词汇也被移除了。* *## 文本生成
文本生成模型在 2023 年显示出巨大的潜力。它们在广泛的任务中表现出色,并允许在提示中进行广泛的创造性。它们的能力不容小觑,而不在 BERTopic 中使用它们无疑是一种浪费。我们在XXX章中详细讨论了这些模型,但现在查看它们如何与主题建模过程结合是有益的。
如图 3-21 所示,我们可以通过专注于生成主题级输出而非文档级输出,来高效使用 BERTopic。这可以将 API 调用的数量从数百万(例如,数百万的摘要)减少到几百(例如,数百个主题)。这不仅显著加快了主题标签的生成速度,而且在使用外部 API(如 Cohere 或 OpenAI)时,也不需要大量的费用。

图 3-21. 使用文本生成 LLMs 和提示工程从与每个主题相关的关键词和文档中创建主题标签。
提示
正如在图 3-21 中所示,文本生成的一个主要组成部分是提示。在 BERTopic 中,这同样重要,因为我们希望向模型提供足够的信息,以便它能决定主题内容。BERTopic 中的提示通常看起来像这样:
prompt = """
I have a topic that contains the following documents: \n[DOCUMENTS]
The topic is described by the following keywords: [KEYWORDS]
Based on the above information, give a short label of the topic.
"""
该提示包含三个组成部分。首先,它提到一些最能描述主题的文档。这些文档通过计算它们的 c-TF-IDF 表示并与主题的 c-TF-IDF 表示进行比较来选择。然后提取前四个最相似的文档,并使用“[文档]”标签进行引用。
I have a topic that contains the following documents: \n[DOCUMENTS]
其次,构成主题的关键词也会传递给提示,并使用“[关键词]”标签进行引用。这些关键词也可以通过 KeyBERTInspired、词性或任何表示模型进行优化。
The topic is described by the following keywords: [KEYWORDS]
第三,我们向大型语言模型提供具体指令。这与之前的步骤同样重要,因为这将决定模型如何生成标签。
Based on the above information, give a short label of the topic.
该提示将被呈现为主题 XXX:
"""
I have a topic that contains the following documents:
- Our videos are also made possible by your support on patreon.co.
- If you want to help us make more videos, you can do so on patreon.com or get one of our posters from our shop.
- If you want to help us make more videos, you can do so there.
- And if you want to support us in our endeavor to survive in the world of online video, and make more videos, you can do so on patreon.com.
The topic is described by the following keywords: videos video you our support want this us channel patreon make on we if facebook to patreoncom can for and more watch
Based on the above information, give a short label of the topic.
"""
HuggingFace
幸运的是,与大多数大型语言模型一样,我们可以通过HuggingFace 的 Modelhub使用大量开源模型。
最著名的开源大型语言模型之一是 Flan-T5 生成模型系列,它针对文本生成进行了优化。这些模型的有趣之处在于它们使用一种称为指令调优的方法进行训练。通过对许多以指令形式表达的任务进行微调,模型学会了遵循特定的指令和任务。
BERTopic 允许使用这样的模型生成主题标签。我们创建一个提示,请它根据每个主题的关键词生成主题,并标记为[关键词]。
from transformers import pipeline
from bertopic.representation import TextGeneration
# Text2Text Generation with Flan-T5
generator = pipeline('text2text-generation', model='google/flan-t5-xl')
representation_model = TextGeneration(generator)
# Use the representation model in BERTopic on top of the default pipeline
topic_model.update_topics(abstracts, representation_model=representation_model)
# Show topic differences
topic_differences(topic_model, original_topics)
主题:0 演讲 | asr | 识别 | 声学 | 端到端 --> 音频语法识别
主题:1 临床 | 医疗 | 生物医学 | 笔记 | 健康 --> ehr
主题:2 摘要 | 总结 | 总结 | 抽象 | 抽取 --> mds
主题:3 解析 | 解析器 | 依赖关系 | amr | 解析器 --> 解析器
主题:4 仇恨 | 攻击性 | 演讲 | 检测 | 有毒 --> Twitter
主题:5 词 | 嵌入 | 嵌入向量 | 相似性 --> word2vec
主题:6 性别 | 偏见 | 偏差 | 去偏见 | 公平性 --> 性别偏见
主题:7 命名 | 实体 | 识别 | 嵌套 --> ner
主题:8 提示 | 少样本 | 提示 | 上下文 | 调优 --> gpt3
主题:9 关系 | 提取 | re | 关系 | 远程 --> docre
有趣的主题标签被创建,但我们也可以看到该模型并不是完美无缺的。
OpenAI
当我们谈论生成性 AI 时,不能忘记 ChatGPT 及其惊人的表现。尽管不是开源的,但它是一个有趣的模型,在短短几个月内改变了 AI 领域。我们可以从 OpenAI 的集合中选择任何文本生成模型在 BERTopic 中使用。
由于该模型是基于 RLHF 训练的,并且优化用于聊天目的,因此使用该模型进行提示非常令人满意。
from bertopic.representation import OpenAI
# OpenAI Representation Model
prompt = """
I have a topic that contains the following documents: \n[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: <topic label>
"""
representation_model = OpenAI(model="gpt-3.5-turbo", delay_in_seconds=10, chat=True)
# Use the representation model in BERTopic on top of the default pipeline
topic_model.update_topics(abstracts, representation_model=representation_model)
# Show topic differences
topic_differences(topic_model, original_topics)
主题:0 演讲 | asr | 识别 | 声学 | 端到端 --> 音频语法识别
主题:1 临床 | 医疗 | 生物医学 | 记录 | 健康 --> ehr
主题:2 总结 | 摘要 | 总结 | 抽象 | 提取 --> mds
主题:3 解析 | 解析器 | 依赖 | amr | 解析器 --> parser
主题:4 仇恨 | 攻击性 | 言论 | 检测 | 有毒 --> Twitter
主题:5 词 | 嵌入 | 嵌入向量 | 相似性 --> word2vec
主题:6 性别 | 偏见 | 偏差 | 去偏见 | 公平性 --> 性别偏见
主题:7 命名 | 实体 | 识别 | 嵌套 --> ner
主题:8 提示 | 少样本 | 提示 | 上下文 | 调优 --> gpt3
主题:9 关系 | 提取 | re | 关系 | 远程 --> docre
由于我们期望 ChatGPT 以特定格式返回主题,即“主题:<主题标签>”,因此在创建自定义提示时,指示模型按此格式返回非常重要。请注意,我们还添加了delay_in_seconds参数,以便在 API 调用之间创建恒定的延迟,以防你使用的是免费账户。
Cohere
与 OpenAI 一样,我们可以在 BERTopic 的管道中使用 Cohere 的 API,进一步微调主题表示,结合生成文本模型。确保获取 API 密钥,这样你就可以开始生成主题表示。
import cohere
from bertopic.representation import Cohere
# Cohere Representation Model
co = cohere.Client(my_api_key)
representation_model = Cohere(co)
# Use the representation model in BERTopic on top of the default pipeline
topic_model.update_topics(abstracts, representation_model=representation_model)
# Show topic differences
topic_differences(topic_model, original_topics)
主题:0 演讲 | asr | 识别 | 声学 | 端到端 --> 音频语法识别
主题:1 临床 | 医疗 | 生物医学 | 记录 | 健康 --> ehr
主题:2 总结 | 摘要 | 总结 | 抽象 | 提取 --> mds
主题:3 解析 | 解析器 | 依赖 | amr | 解析器 --> parser
主题:4 仇恨 | 攻击性 | 言论 | 检测 | 有毒 --> Twitter
主题:5 词 | 嵌入 | 嵌入向量 | 相似性 --> word2vec
主题:6 性别 | 偏见 | 偏差 | 去偏见 | 公平性 --> 性别偏见
主题:7 命名 | 实体 | 识别 | 嵌套 --> ner
主题:8 提示 | 少样本 | 提示 | 上下文 | 调优 --> gpt3
主题:9 关系 | 提取 | re | 关系 | 远程 --> docre
LangChain
为了进一步提升大型语言模型的能力,我们可以利用 LangChain 框架。它允许任何先前的文本生成方法补充额外信息,甚至链式结合。特别是,LangChain 将语言模型连接到其他数据源,使它们能够与环境互动。
例如,我们可以使用它与 OpenAI 构建一个向量数据库,并在该数据库上应用 ChatGPT。由于我们希望尽量减少 LangChain 所需的信息量,因此将最具代表性的文档传递给该软件包。然后,我们可以使用任何 LangChain 支持的语言模型来提取主题。下面的示例演示了如何将 OpenAI 与 LangChain 结合使用。
from langchain.llms import OpenAI
from langchain.chains.question_answering import load_qa_chain
from bertopic.representation import LangChain
# Langchain representation model
chain = load_qa_chain(OpenAI(temperature=0, openai_api_key=MY_API_KEY), chain_type="stuff")
representation_model = LangChain(chain)
# Use the representation model in BERTopic on top of the default pipeline
topic_model.update_topics(abstracts, representation_model=representation_model)
# Show topic differences
topic_differences(topic_model, original_topics)
Topic: 0 speech | asr | recognition | acoustic | endtoend --> audio grammatical recognition
Topic: 1 clinical | medical | biomedical | notes | health --> ehr
Topic: 2 summarization | summaries | summary | abstractive | extractive --> mds
Topic: 3 parsing | parser | dependency | amr | parsers --> parser
Topic: 4 hate | offensive | speech | detection | toxic --> Twitter
Topic: 5 word | embeddings | embedding | vectors | similarity --> word2vec
Topic: 6 gender | bias | biases | debiasing | fairness --> gender bias
Topic: 7 ner | named | entity | recognition | nested --> ner
Topic: 8 prompt | fewshot | prompts | incontext | tuning --> gpt3
Topic: 9 relation | extraction | re | relations | distant --> docre
主题建模变体
主题建模的领域相当广泛,涵盖了许多不同的应用和同一模型的变体。BERTopic 也不例外,它为不同目的实现了多种变体,例如动态、(半)监督、在线、分层和引导的主题建模。图 3-22-X 展示了一些主题建模变体以及如何在 BERTopic 中实现它们。

图 3-22. -X BERTopic 中的主题建模变体*** ***# 摘要
在本章中,我们讨论了一种基于聚类的主题建模方法,BERTopic。通过利用模块化结构,我们使用了多种大型语言模型来创建文档表示并微调主题表示。我们提取了 ArXiv 摘要中的主题,并观察了如何利用 BERTopic 的模块化结构来开发不同类型的主题表示。*****
第四章:使用 GPT 模型进行文本生成
在本书的前几章中,我们已迈出了进入大型语言模型(LLMs)世界的第一步。我们深入探讨了各种应用,如分类和语义搜索,采用了专注于文本表示的模型,例如 BERT 及其衍生模型。
随着我们的进展,我们使用了主要用于文本生成的模型,这些模型通常被称为生成式预训练变换器(GPT)。这些模型具有响应用户提示生成文本的卓越能力。通过提示工程,我们可以以增强生成文本质量的方式设计这些提示。
在本章中,我们将更详细地探讨这些生成模型,并深入探讨提示工程、与生成模型推理、验证甚至评估其输出的领域。
使用文本生成模型
在开始提示工程的基本知识之前,探索如何利用文本生成模型的基础知识是至关重要的。我们如何选择使用的模型?我们是使用专有模型还是开源模型?我们如何控制生成的输出?这些问题将作为我们使用文本生成模型的垫脚石。
选择文本生成模型
选择文本生成模型始于在专有模型和开源模型之间进行选择。尽管专有模型通常性能更优,但我们在本书中更多地关注开源模型,因为它们提供了更多灵活性并且可以免费使用。
图 4-1 展示了一小部分具有影响力的基础模型,这些大型语言模型(LLMs)在大量文本数据上进行过预训练,并通常为特定应用进行了微调。

图 4-1:基础模型
从这些基础模型中,数百个甚至数千个模型已被微调,使得某些模型更适合特定任务。选择使用的模型可能是一项艰巨的任务!
我们通常建议从小型且最近发布的基础模型开始,例如图 4-1 中的 Llama 2 或 Mistral 7B。这允许快速迭代,从而深入理解该模型是否适合你的用例。此外,小型模型需要更少的 GPU 内存(VRAM),如果你的 GPU 不大,这使得运行更加容易和快速。放大通常比缩小更令人愉快。
在本章的示例中,我们将采用来自 Zephyr 系列的模型,即Zephyr 7B-beta。这些模型是在 Mistral 7B 上经过微调的,Mistral 7B 是一个相对较小但相当强大的开源大型语言模型(LLM)。
如果你在生成 AI 领域刚起步,重要的是从一个较小的模型开始。这为初学者提供了很好的介绍,并为进阶到更大的模型打下坚实的基础。
加载文本生成模型
“如何加载文本生成模型”实际上可以单独成为一个章节。市面上有几十个包,各自拥有不同的压缩和推理策略来提高性能。
最简单的方法是通过众所周知的 HuggingFace Transformers 库:
import torch
from transformers import pipeline
# Load our model
pipe = pipeline(
"text-generation",
model="HuggingFaceH4/zephyr-7b-beta",
torch_dtype=torch.bfloat16,
device_map="auto"
)
要使用该模型,我们需要仔细查看它的提示模板。任何 LLM 都需要特定的模板,以便它能够区分最近和较旧的查询/响应对。
为了说明这一点,让我们请 LLM 讲个关于鸡的笑话:
`def` format_prompt(query="", messages=`False`):
"""Use the internal chat template to format our query"""
# The system prompt (what the LLM should know before answering) and our query:
`if` `not` messages:
messages = [
{
"role": "system",
"content": "You are a helpful assistant.",
},
{"role": "user", "content": query},
]
# We apply the LLMs internal chat template to our input prompt
prompt = pipe.tokenizer.apply_chat_template(
messages,
tokenize=`False`,
add_generation_prompt=`True`
)
`return` prompt
prompt = format_prompt("Write a short joke about chickens.")
除了我们的主要提示外,我们还生成了一个系统提示,为 LLM 提供生成响应的上下文或指导。如图 4-2 所示,提示模板帮助 LLM 理解不同类型提示之间的区别,以及 LLM 生成的文本与用户文本之间的区别。

图 4-2. Zephyr 在与模型互动时所期望的模板。
使用该提示,我们可以让 LLM 给出答案:
# Generate the output
outputs = pipe(
prompt,
max_new_tokens=256,
do_sample=`True`,
temperature=0.1,
top_p=0.95
)
print(outputs[0]["generated_text"])
输出结果为:
"""
<|system|>
You are a friendly chatbot.</s>
<|user|>
Write a joke about chickens.</s>
<|assistant|>
Why did the chicken cross the Mobilo?
Because the Eggspressway was closed for pavement!
"""
既然我们知道如何使用聊天模板创建提示,让我们深入探讨如何控制模型的输出。
控制模型输出
除了提示工程,我们还可以通过调整模型参数来控制我们想要的输出类型。在之前的示例中,你可能注意到我们在pipe函数中使用了多个参数,包括temperature和top_p。
这些参数控制输出的随机性。使 LLM 成为令人兴奋技术的一部分在于它可以为完全相同的提示生成不同的响应。每当 LLM 需要生成一个标记时,它会为每个可能的标记分配一个可能性数字。
如图 4-3 所示,在句子“我正在开着一辆…”中,后面跟上“车”或“卡车”等标记的可能性通常高于“大象”。然而,“大象”仍然有生成的可能性,但它的概率要低得多。

图 4-3. 模型根据它们的可能性评分选择生成下一个标记。
温度
temperature 控制生成文本的随机性或创造性。它定义了选择不太可能的标记的可能性。基本思想是,温度为 0 时每次都会生成相同的响应,因为它总是选择最可能的单词。如 图 4-4 所示,较高的值允许生成不太可能的单词。

图 4-4。较高的温度增加了生成不太可能的标记的可能性,反之亦然。
结果是,较高的温度(例如 0.8)通常会导致更具多样性的输出,而较低的温度(例如 0.2)则会产生更确定性的输出。
top_p
top_p,也称为核采样,是一种控制 LLM 可以考虑哪些标记(核)的采样技术。它会考虑标记直到达到其累积概率。如果我们将 top_p 设置为 0.1,它会考虑标记直到达到该值。如果我们将 top_p 设置为 1,它会考虑所有标记。
如 图 4-5 所示,通过降低该值,将考虑更少的标记,通常会产生较少的“创造性”输出,而增加该值则允许 LLM 从更多的标记中进行选择。

图 4-5。较高的 top_p 增加了可以选择生成的标记数量,反之亦然。
同样,top_k 参数精确控制 LLM 可以考虑多少个标记。如果将其值更改为 100,LLM 将仅考虑前 100 个最可能的标记。
如表 5-1 所示,这些参数使用户在创造性(高 temperature 和 top_p)与可预测性(低 temperature 和 top_p)之间拥有一个滑动尺度。

图 4-6。选择 temperature 和 top_p 值时的使用案例示例。
提示工程简介
与文本生成 LLM 相关的重要部分是提示工程。通过仔细设计我们的提示,我们可以引导 LLM 生成所需的响应。无论提示是问题、陈述还是指令,提示工程的主要目标是从模型中引发有用的响应。
然而,提示工程不仅仅是设计有效的提示。它还可以作为评估模型输出、设计保护措施和安全缓解方法的工具。这是一个提示优化的迭代过程,需要实验。不存在,也不太可能有完美的提示设计。
在本节中,我们将讨论提示工程的常见方法,以及理解某些提示效果的小技巧。这些技能使我们能够理解 LLM 的能力,并构成与这些模型接口的基础。
我们首先回答问题:提示中应该包含什么?
提示的基本组成部分
LLM 是一种预测机器。根据特定输入(提示),它尝试预测可能跟随的词语。在其核心,正如图 4-7 所示,提示不需要超过几个词就能引出 LLM 的响应。

图 4-7. 提示的基本示例。没有给出指令,因此 LLM 将简单地尝试完成句子。
然而,尽管这个插图作为基本示例有效,但它无法完成特定任务。相反,我们通常通过询问一个特定问题或任务来进行提示工程,以便大型语言模型(LLM)可以完成。为了引出期望的响应,我们需要一个更结构化的提示。
例如,如图 4-8 所示,我们可以要求 LLM 将一个句子分类为具有正面或负面情感。

图 4-8. 基本指令提示的两个组成部分,即指令本身和它所指的数据。
这将最基本的提示扩展为由两个组成部分构成——指令本身和与指令相关的数据。
更复杂的使用案例可能需要提示中包含更多组成部分。例如,为确保模型只输出“负面”或“正面”,我们可以引入输出指示符来帮助指导模型。在图 4-9 中,我们在句子前加上“文本:”并添加“情感:”以防止模型生成完整句子。相反,这种结构表示我们期待“负面”或“正面”。

图 4-9. 通过输出指示符扩展提示,以允许特定输出。
我们可以继续添加或更新提示的元素,直到引出我们所寻找的响应。我们可以添加额外的示例,更详细地描述使用案例,提供额外的上下文等。这些组成部分仅是示例,并不是一个有限的可能集。设计这些组件所带来的创造力是关键。
尽管提示是单一的文本,但将提示视为更大拼图的一部分是非常有帮助的。我是否描述了我的问题的上下文?提示中是否包含了输出的示例?
基于指令的提示
尽管提示有很多种形式,从与 LLM 讨论哲学到与自己喜欢的超级英雄角色扮演,提示通常用于让 LLM 回答特定问题或解决某项任务。这被称为基于指令的提示。
图 4-10 展示了多个在基于指令的提示中发挥重要作用的用例。我们在之前的示例中已经进行了其中一个,即监督分类。

图 4-10. 使用基于指令的提示的用例示例。
这些任务每个都需要不同格式的提示,更具体地说,需要向 LLM 提出不同的问题。要求 LLM 总结一段文本并不会突然导致分类。为了说明,一些这些用例的提示示例可以在图 4-11 中找到。

图 4-11. 常见用例的提示示例。注意在一个用例中,指令的结构和位置可以改变。
尽管这些任务需要不同的指令,但实际上在用于提高输出质量的提示技术上有很多重叠。这些技术的非详尽列表包括:
特异性
准确描述你想要实现的目标。与其问 LLM“写一个产品描述。”不如问它“用少于两句话写一个产品描述,并使用正式的语气。”。
幻觉
LLM 可能会自信地生成不正确的信息,这被称为幻觉。为了减少其影响,我们可以要求 LLM 仅在知道答案的情况下生成回答。如果它不知道答案,则回应“我不知道”。
顺序
要么在提示的开始或结束时给出指令。尤其是在长提示中,中间的信息往往被遗忘。LLM 倾向于关注提示开头(首因效应)或结尾(近因效应)中的信息。
在这里,特异性可以说是最重要的方面。一个大型语言模型(LLM)不知道你想要什么,除非你对自己想要实现的目标和原因非常具体。
高级提示工程
表面上,创建一个好的提示似乎很简单。问一个具体的问题,准确,添加一些示例,你就完成了!然而,提示很快就会变得复杂,因此常常被低估为利用 LLM 的一个组成部分。
在这里,我们将通过几种高级技术来构建你的提示,从构建复杂提示的迭代工作流程开始,一直到顺序使用 LLM 以获得更好的结果。最终,我们甚至将建立高级推理技术。
提示的潜在复杂性
正如我们在提示工程导言中探讨的,提示通常由多个组件组成。在我们的第一个示例中,提示由指令、数据和输出指标组成。正如我们之前提到的,没有提示仅限于这三种组件,你可以根据需要构建得越复杂越好。
这些高级组件可以快速使提示变得相当复杂。一些常见的组件包括:
角色
描述 LLM 应该扮演的角色。例如,如果你想问关于天体物理学的问题,可以使用“你是天体物理学专家。”
指令
任务本身。确保这一点尽可能具体。我们不想留太多解释的余地。
上下文
描述问题或任务上下文的额外信息。它回答类似“指令的原因是什么?”的问题。
格式
LLM 应使用的输出生成文本的格式。如果没有它,LLM 将自行生成格式,这在自动化系统中是麻烦的。
受众
生成文本应面向谁。这也描述了生成输出的级别。出于教育目的,使用 ELI5(“像我 5 岁时那样解释。”)往往很有帮助。
语气
LLM 在生成文本时应使用的语气。如果你正在给老板写正式邮件,可能不想使用非正式的语气。
数据
与任务本身相关的主要数据。
为了说明这一点,让我们扩展之前的分类提示,并使用上述所有组件。这在图 4-12 中展示。

图 4-12. 一个包含多个组件的复杂提示示例。
这个复杂的提示展示了提示的模块化特性。我们可以自由添加和删除组件,并判断它们对输出的影响。如图 4-13 所示,我们可以逐步构建提示,并探索每次变化的影响。

图 4-13. 迭代模块组件是提示工程的重要组成部分。** **这些变化不仅限于简单地引入或移除组件。正如我们之前看到的近期效应和首因效应,它们的顺序会影响 LLM 的输出质量。
换句话说,实验在找到适合你用例的最佳提示时至关重要。通过提示,我们实际上处于一个迭代实验的循环中。
尝试一下!使用复杂提示添加和/或移除部分,以观察其对生成提示的影响。当你发现拼图中值得保留的部分时,会很快注意到。你可以通过将自己的数据添加到data变量中来使用自己的数据:
# 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`"
data = "Text to summarize: PUT_THE_DATA_TO_SUMMARIZE_HERE"
# The full prompt - remove and add pieces to view its impact on the generated output
query = persona + instruction + context + data_format + audience + tone + data
prompt = format_prompt(query)
提示
几乎每周都有新的提示组件可能提高输出的准确性。我们可以添加各种各样的组件,每周都会发现使用情感刺激(例如,“这对我的职业非常重要。”)等创造性组件。
提示工程的一大乐趣在于你可以尽可能地富有创造力,以找出哪些提示组件的组合对你的用例有帮助。开发一个适合你的格式几乎没有约束。
然而,请注意,某些提示对特定模型的效果更好,因为它们的训练数据可能不同,或者它们的训练目的不同。** **## 上下文学习:提供示例
在之前的章节中,我们试图准确描述大型语言模型(LLM)应该做什么。尽管准确和具体的描述有助于 LLM 理解用例,但我们可以更进一步。
我们为什么不直接展示任务,而是描述它呢?
我们可以向 LLM 提供我们想要实现的事物的确切示例。这通常被称为上下文学习,我们向模型提供正确的示例。
如图 4-14 所示,展示给 LLM 的示例数量会影响形式。零-shot 提示不利用示例,one-shot 提示使用一个示例,而 few-shot 提示使用两个或更多示例。

图 4-14. 复杂提示的示例,包含多个组件。
采用原始短语,我们认为“一个例子胜过千言万语”。这些示例提供了 LLM 应该如何实现的直接示例。
我们可以用一个简单的示例来说明这种方法,该示例取自描述此方法的原始论文。提示的目标是生成一个包含虚构单词的句子。为了提高生成句子的质量,我们可以向生成模型展示一个包含虚构单词的正确句子的示例。
为此,我们需要区分我们的提问(user)和模型提供的答案(assistant):
# Use a single example of using the made-up word in a sentence
one_shot_prompt = format_prompt(messages=[
{"role": "user", "content": "Q: A 'Gigamuru' is a type of Japanese musical instrument. An example of a sentence that uses the word Gigamuru is:"},
{"role": "assistant", "content": "A: I have a Gigamuru that my uncle gave me as a gift. I love to play it at home."},
{"role": "user", "content": "Q: To 'screeg' something is to swing a sword at it. An example of a sentence that uses the word screeg is:"}
])
print(one_shot_prompt)
该提示说明了区分用户和助手的必要性。如果我们不这样做,就会似乎在自言自语:
"""
<|user|>
Q: A 'Gigamuru' is a type of Japanese musical instrument. An example of a sentence that uses the word Gigamuru is:</s>
<|assistant|>
A: I have a Gigamuru that my uncle gave me as a gift. I love to play it at home.</s>
<|user|>
Q: To 'screeg' something is to swing a sword at it. An example of a sentence that uses the word screeg is:</s>
<|assistant|>
"""
我们可以使用这个提示来运行我们的模型:
# Run generative model
outputs = pipe(one_shot_prompt, max_new_tokens=64, do_sample=`True`, return_full_text=`False`)
print(outputs[0]["generated_text"])
结果是一个使用虚构词“screeg”的正确句子:
"A: I screeged the dragon's tail with my sword, but it only seemed to make it angrier."
与所有提示组件一样,一次或几次提示并不是提示工程的终极解决方案。我们可以将其作为增强我们给出的描述的拼图的一部分。模型仍然可以通过随机抽样“选择”忽略指令。
链式提示:拆分问题
在之前的示例中,我们探索了将提示拆分为模块化组件,以提高 LLM 的性能。尽管这在许多用例中效果很好,但对于高度复杂的提示或用例,这可能不可行。
我们可以在提示之间拆分问题,而不是在提示内部。基本上,我们将一个提示的输出作为下一个提示的输入,从而创建一个连续的交互链,解决我们的难题。
为了说明,假设我们希望使用 LLM 为我们根据一系列产品特征创建产品名称、口号和销售推介。尽管我们可以要求 LLM 一次性完成,但我们可以将问题拆分为多个部分。
因此,如在图 4-16 中所示,我们得到一个顺序管道,首先创建产品名称,然后将其与产品特征作为输入创建口号,最后使用特征、产品名称和口号来创建销售推介。

图 4-15. 使用产品特征的描述,链式提示创建合适的名称、口号和销售推介。
这种链式提示的技术使 LLM 能够在每个单独问题上花费更多时间,而不是处理整个问题。
让我们用一个小例子来说明。
# Create name and slogan for a product
product_prompt = format_prompt("Create a name and slogan for a chatbot that leverages LLMs.")
outputs = pipe(product_prompt, max_new_tokens=32, do_sample=`True`, return_full_text=`False`)
product_description = outputs[0]["generated_text"]
# Use name and slogan as input for a sales pitch
sales_prompt = format_prompt(f"What would be a good sales pitch for the following product: '`{`product_description`}`'?")
outputs = pipe(sales_prompt, max_new_tokens=128, do_sample=`True`, return_full_text=`False`)
sales_pitch = outputs[0]["generated_text"]
# Results
print(product_description)
print(sales_pitch)
在这个例子中,我们首先要求模型创建名称和口号。然后,我们可以使用输出请求基于产品特征的良好销售推介。
这给我们带来了以下输出:
"""
Name: LLM Assistant
Slogan: "Your go-to chatbot powered by cutting-edge language learning models."
Introducing LLM Assistant, the revolutionary chatbot that transforms the way you learn languages. Unlike traditional language learning methods, LLM Assistant utilizes the latest language learning models powered by Artificial Intelligence. With LLM Assistant, you can communicate with native speakers, practice real-life conversations, and receive instant feedback. Whether you're a novice or an advanced speaker, LLM Assistant caters to your unique learning needs, making language learning fun, interactive, and efficient. So, why wait? Say hello to LLM Assistant, your new language learning companion!
"""
虽然我们需要两次调用模型,但一个主要好处是我们可以给每次调用不同的参数。例如,名称和口号创建的令牌数量相对较少,而推介可以更长。
它可以用于多种用例,包括:
响应验证
请求 LLM 重新检查之前生成的输出
并行提示
并行创建多个提示,并进行最终合并。例如,请多个 LLM 并行生成多个食谱,并使用组合结果创建购物清单。
写故事
利用 LLM 撰写书籍或故事,可以将问题拆解为各个组成部分。例如,首先撰写摘要,再发展角色并构建故事情节,然后再深入创作对话。
在第六章中,我们将超越链接 LLM,连接其他技术组件,如内存、搜索等!在此之前,提示链的这一概念将在接下来的部分中进一步探讨,描述更复杂的提示链方法,如自一致性、链式思维和树状思维。** **# 与生成模型的推理
在之前的部分中,我们主要集中于提示的模块化组件,通过迭代构建它们。这些先进的提示工程技术,如提示链,证明了实现生成模型复杂推理的第一步。
为了允许这种复杂的推理,现在是退后一步探讨推理内容的好时机。简单来说,我们的推理方法可以分为系统 1 和系统 2 思维过程,如图 5-X 所示。
系统 1 思维代表自动、直觉和几乎瞬时的反应。它与生成模型有相似之处,生成模型自动生成令牌而没有任何自我反思行为。相反,系统 2 思维是一个有意识、缓慢和逻辑的过程,类似于头脑风暴和自我反思。
如果我们能够赋予生成模型自我反思的能力,我们实际上是在模拟系统 2 的思维方式,这通常比系统 1 思维产生更深思熟虑的响应。
在本节中,我们将探讨几种技术,试图模仿这些人类推理者的思维过程,旨在提高模型的输出。
链式思维:回答前思考
实现生成模型复杂推理的第一大步骤是通过一种称为链式思维(CoT)的方法。CoT 旨在让生成模型“思考”后再回答问题,而不是直接回答而不进行任何推理。
如图 4-16 所示,它在提示中提供了示例,展示了模型在生成响应之前应进行的推理。这些推理过程被称为“思考”。这对涉及更高复杂度的任务(如数学问题)帮助巨大。增加这个推理步骤使模型能够在推理过程中分配更多计算资源。

图 4-16. 链式思维提示使用推理示例来说服生成模型在其回答中使用推理。
我们将使用他们在论文中使用的例子来演示这一现象。首先,让我们探讨一下没有 CoT 的标准提示的输出。我们在提供示例时,不仅提供单一查询,而是区分用户和助手:
# Answering without explicit reasoning
standard_prompt = format_prompt(messages=[
{"role": "user", "content": "Q: 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": "A: The answer is 11."},
{"role": "user", "content": "Q: The cafeteria had 23 apples. If they used 20 to make lunch and bought 6 more, how many apples do they have?"}
])
# Run generative model
outputs = pipe(standard_prompt, max_new_tokens=64, do_sample=`True`, return_full_text=`False`)
print(outputs[0]["generated_text"])
这给出了错误的答案:
"A: The answer is 26."
相反,我们将使用 CoT 让模型在给出答案之前展示其推理过程:
# Answering with chain-of-thought
cot_prompt = format_prompt(messages=[
{"role": "user", "content": "Q: 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": "A: 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": "Q: The cafeteria had 23 apples. If they used 20 to make lunch and bought 6 more, how many apples do they have?"}
])
# Run generative model
outputs = pipe(cot_prompt, max_new_tokens=256, do_sample=`True`, return_full_text=`False`)
print(outputs[0]["generated_text"])
这次,我们得到了正确的响应:
"A: Initially, there were 23 apples. They used 20 apples to make lunch, leaving 3 apples (23 - 20 = 3). Then they bought 6 more apples. So, in total, the cafeteria now has (3 + 6) apples, which is 9 apples in total. The answer is 9."
这个推理过程尤其有帮助,因为模型是在生成答案之前这样做的。通过这样做,它可以利用迄今为止生成的知识来计算正确的答案。
零-shot 思维链
尽管 CoT 是增强生成模型输出的好方法,但它确实需要一个或多个推理示例,而用户可能没有这些示例。
我们可以简单地要求生成模型提供推理,而不是提供示例。有许多不同的形式有效,但一个常见且有效的方法是使用短语“让我们一步一步思考”,如图 4-17 所示。

图 4-17. 不使用示例的思维链提示。相反,它使用短语“让我们一步一步思考”来激发其回答中的推理。
使用我们之前的例子,我们可以简单地将该短语附加到提示中,以启用类似 CoT 的推理:
# Zero-shot Chain-of-Thought
zeroshot_cot = format_prompt(
"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."
)
# Run generative model
outputs = pipe(zeroshot_cot, max_new_tokens=512, do_sample=`True`, return_full_text=`False`)
print(outputs[0]["generated_text"])
再次,我们得到了正确的响应,但现在不需要提供示例:
"""
1\. We start with the original number of apples in the cafeteria: 23
2\. We determine how many apples were used to make lunch: 20
3\. We subtract the number of apples used to make lunch from the total number of apples to find the number of apples left in the cafeteria: 23 - 20 = 3
4\. We purchase 6 more apples: 3 + 6 = 9
5\. So the total number of apples now in the cafeteria is 23 (original number) - 20 (apples used for lunch) + 6 (apples purchased) = 9
6\. We can confirm that the calculation is correct by comparing the result with the original number of apples. Our answer is indeed closer to the original 23 apples than it was after we used 20 apples for lunch.
"""
这就是在进行计算时“展示你的过程”如此重要的原因。通过关注推理过程,我们可以为答案提供依据,更加确定答案的正确性。
提示
尽管提示“让我们一步一步思考”可以改善输出,但你并不受限于这个确切的表述。还有替代方案,例如“深呼吸一下,逐步思考”和“让我们逐步解决这个问题”。作者证明了提出替代表述的实用性。
自我一致性:抽样输出
如果通过 temperature 和 top_p 等参数允许一定程度的创造性,使用相同的提示多次可能会导致不同的结果。因此,输出的质量可能会因随机选择的词元而提高或降低。换句话说,这全看运气!
为了抵消这种随机性并提高生成模型的性能,引入了自我一致性。这种方法多次向生成模型提出相同的提示,并将多数结果作为最终答案。在这个过程中,每个答案可能会受到不同 temperature 和 top_p 值的影响,以增加抽样的多样性。
如图 4-18 所示,通过添加链式思维提示,可以进一步改进此方法,以提高推理能力,同时仅使用答案进行投票程序。

图 4-18。通过从多个推理路径中采样,我们可以使用多数投票提取最可能的答案。
尽管此方法在提高输出方面效果良好,但确实需要对同一个问题进行多次提问。因此,尽管该方法可以提高性能,但它变得* n * 倍慢,其中 * n * 是输出样本的数量。
思维树:探索中间步骤
链式思维和自洽性旨在实现更复杂的推理。通过从多个“思想”中进行采样并使其更具思考性,我们旨在提高生成模型的输出。
这些技术仅仅触及了当前为实现这种复杂推理所做工作的表面。思维树对这些方法的改进使得多个想法的深入探索成为可能。
该方法的工作原理如下。当面临需要多次推理步骤的问题时,通常有助于将其拆分成几个部分。在每一步,如图 4-19 所示,生成模型被提示探索当前问题的不同解决方案。然后它投票选出最佳解决方案,接着继续到下一步。

图 4-19。通过利用基于树的结构,生成模型可以生成待评估的中间思想。最有前景的思想被保留,最差的被剪除。
当需要考虑多条路径时,这种方法非常有帮助,例如在编写故事或想出创意时。
这种方法的一个缺点是需要多次调用生成模型,这会显著减慢应用程序的运行速度。幸运的是,已经成功尝试将思维树框架转化为一种简单的提示技术。
与其多次调用生成模型,我们让模型通过模拟多个专家之间的对话来模仿这种行为。这些专家将相互质疑,直到达成共识。一个思维树提示的例子是:
# Zero-shot Chain-of-Thought
zeroshot_tot = format_prompt(
"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 realises 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?'"
)
我们可以使用此提示来探索大型语言模型(LLM)如何回应复杂问题:
# Run generative model
outputs = pipe(zeroshot_tot, max_new_tokens=512, do_sample=`True`, return_full_text=`False`)
print(outputs[0]["generated_text"])
结果,它通过利用多个专家之间的讨论生成了正确答案:
"""
Expert 1: The cafeteria started with 23 apples.
Expert 2: They used 20 of those apples to make lunch.
Expert 3: After making lunch, they bought 6 more apples to replenish their supply.
Expert 2: Now, they have a total of (23-20, initial apples minus apples used for lunch) plus 6, the additional apples they bought. That's a total of 23-20 + 6 = 9 apples left.
Expert 1: Wait a minute... If they started with 23 apples, how did they only have 9 left after buying more? That can't be right.
[Expert 1 realizes they made a math mistake and leaves.]
Expert 2: I'm going to double-check my math... [Takes out a calculator and verifies that they do indeed have 9 apples left.]
Expert 3: I'm confident in my figures, it's clear we have 9 apples left.
"""
看到“专家”之间如此详细的讨论是有趣的,并展示了提示工程带来的创造力。
输出验证
使用生成模型构建的系统和应用程序最终可能会投入生产。当发生这种情况时,验证和控制模型的输出以防止破坏应用程序并创建稳健的生成 AI 应用程序是重要的。
验证输出的原因可能包括:
结构化输出
默认情况下,大多数生成模型创建自由格式文本,而不遵循自然语言以外的特定结构。某些用例要求它们的输出以特定格式结构化,如 JSON。
有效输出
即使我们允许模型生成结构化输出,它仍然能够自由生成其内容。例如,当模型被要求输出两个选择之一时,它不应提出第三个选择。
伦理
一些开源生成模型没有安全限制,会生成不考虑安全或伦理的输出。例如,某些用例可能要求输出不包含亵渎、个人可识别信息(PII)、偏见、文化刻板印象等。
准确性
许多用例要求输出遵循特定标准或性能。其目的是仔细检查生成的信息是否在事实准确性、一致性或是否无幻觉方面。
控制生成模型的输出,如我们通过top_p和temperature等参数探讨的,并不是一件容易的事情。这些模型需要帮助才能生成符合某些指导方针的一致输出。
通常,有三种方式控制生成模型的输出:
示例
提供预期输出的多个示例。
语法
控制标记选择过程。
微调
在包含预期输出的数据上调优模型
在本节中,我们将讨论前两种方法。第三种方法,即微调模型,将留到第十二章,我们将在其中深入探讨微调方法。
提供示例
修复输出的一个简单明了的方法是向生成模型提供输出应有的示例。如前所述,少量示例学习是一种有效的技术,可以指导生成模型的输出。此方法也可以推广以指导输出的结构。
例如,让我们考虑一个示例,我们希望生成模型为 RPG 游戏创建角色档案。我们开始时不使用示例:
# Zero-shot learning: Providing no examples
zero_shot = format_prompt("Create a character profile for an RPG game in JSON format.")
outputs = pipe(zero_shot, max_new_tokens=128, do_sample=`True`, return_full_text=`False`)
print(outputs[0]["generated_text"])
这给我们以下结构,我们截断了它以防止过长的描述:
{
"name": "Aurelia",
"race": "Human",
"class": "Mage",
"age": 22,
"gender": "Female",
"description": "Aurelia is a young woman with a striking golden mane and...",
"stats": {
"strength": 8
}
}
虽然这是有效的 JSON,但我们可能不希望某些属性,如“强度”或“年龄”。相反,我们可以向模型提供一些示例,指示预期的格式:
# Providing an example of the output structure
one_shot_prompt = format_prompt("""Create a 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"
}
""")
outputs = pipe(one_shot_prompt, max_new_tokens=256, do_sample=`True`, return_full_text=`False`)
print(outputs[0]["generated_text"])
这给我们以下内容,我们再次截断了它以防止过长的描述:
{
"description": "A human wizard with long, wild grey hair and...",
"name": "Sybil Astrid",
"armor": "None",
"weapon": [
"Crystal Staff",
"Oak Wand"
]
}
该模型完美遵循了我们给出的示例,从而实现了更一致的行为。这也展示了利用少量样本学习来改善输出结构而不仅仅是内容的重要性。
这里的重要说明是,模型是否遵循你建议的格式仍然取决于模型本身。有些模型在遵循指令方面表现得比其他模型更好。
语法:约束采样
少量样本学习有一个重大缺点:我们无法明确防止生成某些输出。尽管我们引导模型并给出指令,但它可能仍然不会完全遵循。
相反,许多工具迅速被开发出来以限制和验证生成模型的输出,如 Guidance、Guardrails 和 LMQL。部分上,它们利用生成模型验证自身输出,如图 4-20 所示。生成模型将输出作为新提示检索,并尝试根据一系列预定义的规则进行验证。

图 4-20. 使用 LLM 检查输出是否正确遵循我们的规则。
同样,如图 4-21 所示,它还可以通过生成我们已知应如何结构化的格式部分来控制输出格式。

图 4-21. 使用 LLM 仅生成我们事先不知道的信息片段。
这个过程可以更进一步,而不是在输出验证后进行,我们可以在令牌采样过程中进行验证。在采样令牌时,我们可以定义一系列语法或规则,以便 LLM 在选择下一个令牌时遵循。例如,如果我们要求模型在执行情感分类时返回“正面”、“负面”或“中性”,它仍然可能返回其他内容。如图 4-22 所示,通过限制采样过程,我们可以让 LLM 只输出我们感兴趣的内容。

图 4-22. 将令牌选择限制为仅三个可能的令牌:“正面”、“中性”和“负面”。
请注意,这仍然受到top_p和temperature等参数的影响,所示内容非常受限。
让我们通过 llama-cpp-python 来说明这一现象,这是一种库,类似于 transformers,我们可以用它来加载我们的语言模型。它通常用于有效加载和使用压缩模型(通过量化;见第十三章)。
我们通过在终端中运行以下命令来下载模型的量化版本:
wget https://huggingface.co/TheBloke/zephyr-7B-beta-GGUF/resolve/main/zephyr-7b-beta.Q4_K_M.gguf
py`Then, we load the model using llama-cpp-python and choose a JSON grammar to use. This will ensure that the output of the model adheres to JSON: 从 llama_cpp.llama 导入 httpx、Llama 和 LlamaGrammar # 我们从官方的 llama.cpp 存储库加载 JSON 语法 grammar = httpx.get( "https://raw.githubusercontent.com/ggerganov/llama.cpp/master/grammars/json_arr.gbnf" ) grammar = LlamaGrammar.from_string(grammar.text) # 加载一个预量化的 LLM llm = Llama("zephyr-7b-beta.Q4_K_M.gguf") py The rules are described in the grammar file we downloaded. Using the JSON grammar, we can ask the model for an RPG character in JSON format to be used in our Dungeons and Dragons session: 导入 json # 运行生成模型,并要求它以 JSON 格式创建一个角色 response = llm( "为 RPG 创建一个 JSON 格式的战士。", max_tokens=-1, grammar=grammar ) # 以格式良好的 JSON 打印输出 print(json.dumps(json.loads(response['choices'][0]['text']), indent=4)) py This gives us valid JSON: [ { "name": "剑术大师", "level": 10, "health": 250, "mana": 100, "strength": 18, "dexterity": 16, "intelligence": 10, "armor": 75, "weapon": "双手剑", "specialty": "单手剑" } ] ```py This allows us to more confidently use generative models in applications where we expect the output to adhere to certain formats. ###### Note Note that we set the number of tokens to be generated with max_tokens to be, in principle, unlimited. This means that the model will continue generating until is has completed its JSON output or until it reaches its context limit.```` # 概要 在这一章中,我们通过提示工程和输出验证探讨了生成模型的基础知识。我们关注了提示工程带来的创造力和潜在复杂性。我们发现提示的组件是生成符合我们用例输出的关键。因此,在进行提示工程时,实验至关重要。 在下一章中,我们将探索利用生成模型的高级技术。这些技术超越了提示工程,旨在增强这些模型的能力。从给模型提供外部记忆到使用外部工具,我们旨在赋予生成模型超级能力!**
第五章: 多模态大型语言模型
当你想到大型语言模型(LLMs)时,多模态可能不是首先想到的事情。毕竟,它们是语言模型!
我们看到各种新兴行为从 LLMs 中涌现,包括概括能力、推理、算术和语言学。随着模型变得越来越大和智能,它们的技能集也随之增加。¹
接收和推理多模态输入的能力可能进一步增强,并帮助涌现出以前被锁定的能力。实际上,语言并不单独存在于真空中。例如,你的肢体语言、面部表情、语调等都是增强口语交流的沟通方式。
大型语言模型同样适用,如果我们能够使它们对多模态信息进行推理,它们的能力可能会增强。
在本章中,我们将探讨一些具有多模态能力的不同 LLMs,以及这对实际应用的意义。我们将首先探讨如何使用原始变换器技术的适应版将图像转换为数值表示。然后,我们将展示如何将 LLMs 扩展以包括视觉任务。
视觉的变换器
在本书的各章中,我们已经看到使用基于变换器的模型在各种语言建模任务中的成功,从分类和聚类到搜索和生成建模。
因此,研究人员一直在寻找将某些变换器的成功推广到计算机视觉领域的方法,这一点可能并不令人惊讶。
他们提出的方法被称为视觉变换器(ViT),与之前的默认卷积神经网络(CNNs)相比,它在图像识别任务上表现非常出色。² 像原始变换器一样,ViT 用于将非结构化数据(图像)转换为可用于各种任务的表示,例如分类,如图 5-1 所示。

图 5-1. 原始变换器和视觉变换器都处理非结构化数据,将其转换为数值表示,最终用于分类等任务。
ViT 依赖于变换器架构中的一个重要组成部分,即编码器。正如我们在第一章看到的,编码器负责将文本输入转换为数字表示,然后传递给解码器。然而,在编码器执行其职能之前,文本输入需要首先进行标记化,如图 5-2 所示。

图 5-2. 文本通过使用标记器首先进行标记化,然后传递给一个或多个编码器。
由于图像不是由单词组成,因此这个标记化过程不能用于视觉数据。相反,ViT 的作者提出了一种将图像标记为“单词”的方法,这使他们能够使用原始的编码器结构。
想象一下你有一张猫的图片。这张图片由许多像素表示,假设为 512x512 像素。每个单独的像素并没有传达太多信息,但当你组合像素块时,你会逐渐看到更多的信息。
ViT 使用的原理与此类似。它不是将文本拆分成标记,而是将原始图像转换为图像块。换句话说,它将图像水平和垂直切割成多个部分,如图 5-3 所示。

图 5-3. 图像输入的“标记化”过程。它将图像转换为子图像的块。
就像我们将文本转换为文本标记一样,我们也将图像转换为图像块。图像块的扁平化输入可以被视为文本中的标记。
然而,与标记不同,我们不能仅仅给每个块分配一个 ID,因为这些块在其他图像中很少出现,这与文本的词汇不同。
相反,块被线性嵌入以创建数字表示,即嵌入。这些可以用作变换器模型的输入。这样,图像块被视为标记。整个过程如图 5-4 所示。

图 5-4. ViT 背后的主要算法。在对图像进行分块和线性投影后,块嵌入被传递给编码器,并被视为文本标记。
为了说明目的,示例中的图像被分割成 3x3 的补丁,但原始实现使用的是 16x16 的补丁。毕竟,这篇论文的标题是“图像价值 16x16 个词”。
这种方法有趣之处在于,当嵌入被传递给编码器时,它们被视为文本标记。从那时起,文本和图像的训练及其输出没有区别。
由于它们的相似性,ViT 通常用于使各种语言模型实现多模态。其中一种最简单的方法是在嵌入模型的训练过程中使用它们。
多模态嵌入模型
在之前的章节中,如章节 X、X 和 X,我们使用嵌入模型来捕捉文本表示的语义内容,例如书籍和文档。我们看到可以利用这些嵌入或数值表示找到相似文档,应用分类任务,甚至进行主题建模。
正如我们之前多次看到的,嵌入通常是 LLM 应用程序的重要驱动因素。它们是一种有效的方法,可以捕获大规模信息并在信息的海洋中寻找针。
也就是说,我们到目前为止只关注了单模态嵌入模型。嵌入模型仅专注于生成文本表示的嵌入。虽然存在仅用于嵌入图像的嵌入模型,但我们将关注能够同时捕捉文本和视觉表示的嵌入模型。我们在图 5-5 中对此进行了说明。

图 5-5. 多模态嵌入模型可以在同一向量空间中为多种模态创建嵌入。
一个大优势是,它允许比较多模态表示,因为结果嵌入位于同一向量空间中,如图 5-6 所示。例如,使用这样的多模态嵌入模型,我们可以根据输入文本找到图像。如果我们搜索与“狗狗的照片”相似的图像,会找到什么?反之亦然,哪些文档与这个问题最相关?

图 5-6. 多模态嵌入模型可以在同一向量空间中为多种模态创建嵌入。
目前有许多多模态嵌入模型,但最著名且目前使用最广泛的模型是 CLIP(对比语言-图像预训练)。
CLIP:连接文本和图像
CLIP 是一个嵌入模型,可以计算图像和文本的嵌入。生成的嵌入位于同一向量空间,这意味着图像的嵌入可以与文本的嵌入进行比较。³
这种比较能力使得 CLIP 及类似模型可用于诸如以下任务:
零样本分类
我们可以将图像的嵌入与其可能类别的描述进行比较,以找出哪个类别最相似。
聚类
将图像和一组关键词进行聚类,以找出哪些关键词属于哪些图像集合。
搜索
在数十亿的文本或图像中,我们可以快速找到与输入文本或图像相关的内容。
生成
使用多模态嵌入来驱动图像生成(例如,稳定扩散)。
CLIP 如何生成多模态嵌入?
CLIP 的过程实际上相当简单。想象一下,你有一个包含数百万张图片及其说明的数据集,如我们在图 5-7 中所示。
**
图 5-7。训练多模态嵌入模型所需的数据类型。** **这个数据集可以为每对数据创建两个表示,即图像及其说明。为此,CLIP 使用文本编码器来嵌入文本,使用图像编码器来嵌入图像。如图 5-8 所示,结果是图像及其对应说明的嵌入。

图 5-8。在训练 CLIP 的第一步中,图像和文本分别使用图像编码器和文本编码器进行嵌入。
生成的嵌入对通过余弦相似度进行比较。正如我们在第二章中看到的,余弦相似度是向量之间角度的余弦,通过嵌入的点积计算,并除以它们长度的乘积。
当我们开始训练时,图像嵌入和文本嵌入之间的相似度较低,因为它们尚未优化到同一向量空间。在训练过程中,我们优化嵌入之间的相似度,希望对相似的图像/说明对最大化,而对不相似的图像/说明对最小化。
**
图 5-9. 在训练 CLIP 的第二步中,句子和图像嵌入之间的相似性使用余弦相似度进行计算。** **在计算它们的相似性后,模型会更新,流程再次开始,使用新的数据批次和更新后的表示。此方法称为对比学习,我们将在第十三章深入探讨其内部工作原理,并创建自己的嵌入模型。

图 5-10. 在训练 CLIP 的第三步中,文本和图像编码器会更新,以匹配预期的相似性。这会更新嵌入,使其在向量空间中更接近,如果输入相似的话。
最终,我们期望猫的图像的嵌入与句子“猫的图片”的嵌入相似。正如我们将在第十三章看到的,为确保表示尽可能准确,训练过程中还应包括与之无关的图像和标题的负例。
建模相似性不仅是了解事物之间相似的原因,也包括它们不同和不相似的原因。**** ****### OpenCLIP
对于这个示例,我们将使用开源版本的 CLIP 模型,即 OpenCLIP(github.com/mlfoundations/open_clip)。
使用 OpenCLIP 或任何 CLIP 模型,归结为两件事:在将文本和图像输入传递给主模型之前,先对其进行处理。
在此之前,让我们看看一个小示例,我们将使用之前看到的其中一张图像。即,一张 AI 生成的图像(通过稳定扩散)显示一只小狗在雪地里玩耍,如图 5-11 所示:
from urllib.request import urlopen
from PIL import Image
# Load an AI-generated image of a puppy playing in the snow
image = Image.open(urlopen("https://i.imgur.com/iQ5OtWi.png"))
caption = "a puppy playing in the snow"

图 5-11. 一只小狗在雪地里玩耍的 AI 生成图像。
由于我们有这张图像的标题,我们可以使用 OpenCLIP 为两者生成嵌入。
为此,我们加载三个模型:
一个用于对文本输入进行分词的分词器。
一个预处理器,用于预处理和调整图像大小。
将之前的输出转换为嵌入的主模型。
from transformers import CLIPTokenizerFast, CLIPProcessor, CLIPModel
model_id = "openai/clip-vit-base-patch32"
# Load a tokenizer to preprocess the text
tokenizer = CLIPTokenizerFast.from_pretrained(model_id)
# Load a processor to preprocess the images
processor = CLIPProcessor.from_pretrained(model_id)
# Main model for generating text and image embeddings
model = CLIPModel.from_pretrained(model_id)
在加载模型后,对输入进行预处理是简单明了的。让我们从分词器开始,看看预处理输入时会发生什么:
>>> # Tokenize our input
>>> inputs = tokenizer(caption, return_tensors="pt"); inputs
{'input_ids': tensor([[49406, 320, 6829, 1629, 530, 518, 2583, 49407]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1]])}
我们的输入文本已被转换为输入 ID。为了查看它们所表示的内容,让我们将其转换为标记:
>>> tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
['<|startoftext|>',
'a</w>',
'puppy</w>',
'playing</w>',
'in</w>',
'the</w>',
'snow</w>',
'<|endoftext|>']
正如我们以前经常看到的,文本被拆分成了多个标记。此外,我们现在还可以看到,文本的开始和结束被标记出来,以将其与潜在的图像嵌入分开。你可能还会注意到[CLS]标记缺失。在 CLIP 中,[CLS]标记实际上用于表示图像嵌入。
现在我们已经预处理了标题,接下来是创建嵌入:
>>> # Create a text embedding
>>> text_embedding = model.get_text_features(**inputs)
>>> text_embedding.shape
torch.Size([1, 512])
在我们创建图像嵌入之前,与文本嵌入一样,我们需要对其进行预处理,因为模型期望输入图像具有某些特征,如大小和形状。
为此,我们可以使用之前创建的处理器:
>>> # Preprocess image
>>> processed_image = processor(text=None, images=image, return_tensors='pt')['pixel_values']
>>> processed_image.shape
torch.Size([1, 3, 224, 224])
原始图像为 512×512 像素。注意,这张图像的预处理将其大小减少到 224×224 像素,因为这是其预期的大小。
让我们在图 5-12 中可视化处理过的图像,以查看它实际上在做什么:
import numpy as np
# Prepare image for visualization
img = processed_image.squeeze(0).T
img = np.einsum('ijk->jik', img)
# Visualize preprocessed image
plt.imshow(a)
plt.axis('off')

图 5-12. CLIP 处理过的输入图像。
为了将这个预处理过的图像转换为嵌入,我们可以像之前那样调用model:
>>> # Create the image embedding
>>> image_embedding = model.get_image_features(processed_image)
>>> image_embedding.shape
torch.Size([1, 512])
请注意,生成的图像嵌入的形状与文本嵌入的形状完全相同。这一点很重要,因为它使我们能够比较它们的嵌入并查看它们是否确实相似。
我们可以使用这些嵌入来计算标题属于图像的概率,通过计算它们的点积并进行 softmax:
>>> # Calculate the probability of the text belonging to the image
>>> text_probs = (100.0 * image_embedding @ text_embedding.T).softmax(dim=-1)
>>> text_probs
tensor([[1.]], grad_fn=<SoftmaxBackward0>)
它给我们返回了 1 的得分,表明模型确定该标题属于该图像。
我们可以通过计算嵌入之间的相似性来扩展这个例子。通过在计算点积之前先对嵌入进行归一化,我们得到一个介于 0 和 1 之间的值:
>>> # 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
array([[0.33149636]], dtype=float32)
我们得到的相似性得分是 0.33,这很难解释,因为我们不知道模型认为低相似性和高相似性得分的标准是什么。
相反,让我们通过在图 5-13 中展示更多图像和标题来扩展这个例子:

图 5-13. 三幅图像与三条标题之间的相似性矩阵。
考虑到其他图像的相似性要低得多,0.33 的得分似乎确实很高。
提示
在 sentence-transformers 中,有几个基于 CLIP 的模型实现,使得创建嵌入变得更加容易。只需几行代码:
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)
print(sim_matrix)
```**** ****# 使文本生成模型多模态化
传统上,文本生成模型,如你所期望的,都是解释文本表示的模型。像 Llama 2 和 ChatGPT 这样的模型在推理文本信息和用自然语言响应方面表现出色。
然而,它们限于所训练的模态,即文本。正如我们之前看到的多模态嵌入模型,添加视觉可以增强模型的能力。
在文本生成模型的情况下,我们希望它能够对某些输入图像进行推理。例如,我们可以给它一张披萨的图片,问它包含哪些配料。你可以向它展示一张埃菲尔铁塔的照片,问它建于何时或位于何处。这种对话能力在图 5-14 中进一步说明。

###### 图 5-14\. 一个可以对输入图像进行推理的多模态文本生成模型。
为了弥合这两个领域之间的差距,已经尝试向现有模型引入一种多模态形式。其中一种方法被称为 BLIP-2:*用于统一视觉-语言理解和生成的引导语言图像预训练 2*。BLIP-2 引入了一种易于使用的模块化技术,允许将视觉能力引入现有语言模型。
## BLIP-2:弥合模态差距
从零开始创建一个多模态语言模型需要大量的计算能力和数据。我们必须使用数十亿张图像、文本和图像-文本对来创建这样的模型。可以想象,这并不容易实现!
BLIP-2 通过构建一座名为 Q-former 的桥梁,连接一个预训练的图像编码器和一个预训练的 LLM,从而弥合视觉-语言之间的差距,而不是从头构建架构。
通过利用预训练模型,BLIP-2 只需训练这座桥,而无需从头开始训练图像编码器和 LLM。它充分利用了现有的技术和模型!这座桥在图 5-15 中有所示。

###### 图 5-15\. 查询变换器是视觉(ViT)和文本(LLM)之间的桥梁,是管道中唯一可训练的组件。
为了连接这两个预训练模型,Q-Former,也称为查询变换器,模仿了它们的架构。它有两个共享注意力层的模块:
+ 一个图像变换器,与冻结的视觉变换器进行特征提取互动
+ 一个可以与 LLM 互动的文本变换器
Q-Former 分两个阶段进行训练,每个阶段针对一种模态,如图 5-16 所示。

###### 图 5-16。在步骤 1 中,表示学习用于同时学习视觉和语言的表示。在步骤 2 中,这些表示被转换为软视觉提示,以供 LLM 使用。
在步骤 1 中,使用多个图像-文档对来训练 Q-Former,以表示图像和文本。这些对通常是图像的说明,正如我们之前在训练 CLIP 时看到的那样。
图像被馈送到冻结的视觉变换器以提取视觉嵌入。这些嵌入作为 Q-Former 视觉变换器的输入,说明作为 Q-Former 文本变换器的输入。
有了这些输入,Q-Former 随后在三个任务上进行训练:
1. 图像-文本对比学习
1. 图像-文本匹配
1. 基于图像的文本生成
这三个目标被联合优化,以改善从冻结视觉变换器提取的视觉表示。在某种程度上,我们试图将文本信息注入冻结视觉变换器的嵌入中,以便可以在 LLM 中使用它们。BLIP-2 的第一步在图 5-17 中进行了说明。

###### 图 5-17。在步骤 1 中,冻结的视觉变换器的输出与其说明一起使用,并在三个对比性任务上进行训练,以学习视觉-文本表示。
在步骤 2 中,来自步骤 1 的可学习嵌入现在在与其相应的文本信息相同的维度空间中包含视觉信息。
然后,将可学习的嵌入作为软提示传递给 LLM。在某种程度上,这些嵌入包含输入图像的文本表示。
然后,将可学习的嵌入传递给 LLM。在某种程度上,这些嵌入作为软视觉提示,使 LLM 基于 Q-Former 提取的视觉表示进行条件化。
它们之间还有一个全连接线性层,以确保可学习的嵌入具有 LLM 期望的相同形状。将视觉转换为语言的第二步在图 5-18 中表示。

###### 图 5-18。在第 2 步中,Q-Former 学习到的嵌入通过投影层传递给 LLM。投影嵌入作为软视觉提示。
当我们将这些步骤结合起来时,它们使得 Q-Former 能够在相同的维度空间中学习视觉和文本表示,这可以作为对 LLM 的软提示。因此,LLM 将获得关于图像的信息,类似于你在提示 LLM 时提供的上下文。完整的深入过程在图 5-19 中进行了说明。

###### 图 5-19。BLIP-2 的完整过程。
## 预处理多模态输入
现在我们知道了 BLIP-2 是如何创建的,有许多有趣的用例可以使用这样的模型。这不仅限于对图像进行注释、回答视觉问题,甚至进行提示。
在我们讨论一些用例之前,让我们先加载模型并探索如何使用它:
```py
from transformers import AutoProcessor, Blip2ForConditionalGeneration
import torch
# Load processor and main model
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)
注意
使用model.vision_model和model.language_model,我们可以看到在我们加载的 BLIP-2 模型中,分别使用了哪个视觉变换器和大型语言模型。
我们加载了组成完整管道的两个组件,一个是processor,另一个是model。processor可以与语言模型的分词器进行比较。它将非结构化输入(如图像和文本)转换为模型通常期望的表示形式。
预处理图像
让我们先来探讨一下processor对图像的处理。我们首先加载一张非常宽的图像用于说明:
from urllib.request import urlopen
from PIL import Image
# Load a wide image
link = "https://images.unsplash.com/photo-1524602010760-6eb67b3c63a0?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2631&q=80"
image = Image.open(urlopen(link)).convert("RGB")
image

图 5-20。待添加说明
图像的尺寸为 520 乘 492 像素,这通常是一种不寻常的格式。那么让我们看看我们的processor对它做了什么。
>>> np.array(image).shape
(520, 492, 3)
当我们将其转换为 Numpy 格式后检查形状时,显示出一个额外的维度,其大小为 3。这代表每个像素的 RGB 编码,即其颜色。
接下来,我们将原始图像传递给处理器,以便图像可以被处理成模型所期望的形状:
>>> inputs = processor(image, return_tensors="pt").to(device, torch.float16)
>>> inputs["pixel_values"].shape
torch.Size([1, 3, 224, 224])
结果是一个 224 乘 224 像素的图像。比我们最初的图像要小得多!这也意味着所有不同形状的图像都将被处理为正方形。因此,输入非常宽或高的图像时要小心,因为它们可能会变形。
预处理文本
让我们继续探索processor对文本的处理。首先,我们可以访问用于对输入文本进行分词的分词器:
>>> processor.tokenizer
GPT2TokenizerFast(name_or_path='Salesforce/blip2-opt-2.7b', vocab_size=50265, model_max_length=1000000000000000019884624838656, is_fast=True, padding_side='right', truncation_side='right', special_tokens={'bos_token': AddedToken("</s>", rstrip=False, lstrip=False, single_word=False, normalized=True), 'eos_token': AddedToken("</s>", rstrip=False, lstrip=False, single_word=False, normalized=True), 'unk_token': AddedToken("</s>", rstrip=False, lstrip=False, single_word=False, normalized=True), 'pad_token': AddedToken("<pad>", rstrip=False, lstrip=False, single_word=False, normalized=True)}, clean_up_tokenization_spaces=True)
我们使用的 BLIP-2 模型使用的是 GPT2Tokenizer。大多数分词器的工作方式非常相似,但在何时及如何对输入文本进行分词方面有细微差别。
为了探索这个 GPT2Tokenizer 是如何工作的,我们可以用一句小句子进行尝试。我们先将句子转换为 token ids,然后再转换回 tokens:
# Preprocess the text
text = "Her vocalization was remarkably melodic"
token_ids = processor(image, text=text, return_tensors="pt").to(device, torch.float16)["input_ids"][0]
# Convert input ids back to tokens
tokens = processor.tokenizer.convert_ids_to_tokens(token_ids)
当我们检查标记时,可能会注意到某些标记开头有一个奇怪的符号,即 Ġ 符号。这实际上应该是一个空格。然而,一个内部函数将特定代码点中的字符向上移动 256,使其可打印。因此,空格(代码点 32)变成了 Ġ(代码点 288)。
我们将它们转换为下划线以便于说明:
>>> tokens = [token.replace("Ġ", "_") for token in tokens]
>>> tokens
['</s>', 'Her', '_vocal', 'ization', '_was', '_remarkably', '_mel', 'odic']
输出显示下划线表示一个单词的开始。这样,由多个标记组成的单词就可以被识别。
用例 1:图像标注
使用像 BLIP-2 这样的模型最直接的用法是为您数据中的图像创建标题。您可能是一家想要为其服装创建描述的商店,或者您是一位没有时间手动标注其 1000 多张婚礼照片的摄影师。
图像标注的过程紧密跟随处理过程。图像被转换为模型可以读取的像素值。这些像素值被传递给 BLIP-2,以转换成 LLM 可用于决定适当标题的软视觉提示。
让我们处理一张超跑的图像,并使用处理器推导出预期形状的像素:
from urllib.request import urlopen
from PIL import Image
# Load an AI-generated image of a supercar
image = Image.open(urlopen("https://i.imgur.com/zehSvAe.png")).convert("RGB")
# Convert an image into inputs and preprocess it
inputs = processor(image, return_tensors="pt").to(device, torch.float16)
image
下一步是使用 BLIP-2 模型将图像转换为标记 ID。完成后,我们可以将这些 ID 转换为生成的标题文本:
# Generate token ids using the full BLIP-2 model
generated_ids = model.generate(**inputs, max_new_tokens=20)
# Convert the token ids to text
generated_text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
当我们打印出 generated_text 时,可以看看标题:
>>> print(generated_text)
an orange supercar driving on the road at sunset
“一辆橙色超跑在日落时的公路上行驶”似乎是对这张图像的完美描述!
图像标注是学习这个模型的好方法,在进入更复杂的用例之前,自己尝试几张图像,看看它表现良好和不佳的地方。
特定领域的图像,如特定卡通角色或虚构创作的图片,可能会失败,因为该模型主要是基于公开数据进行训练的。
让我们用一个有趣的例子结束这个用例,即 Rorschach 测验中的图像,这个测试是一个古老的心理测试,用来测试个体对墨水斑点的感知。⁴ 人们在这样的墨水斑点中看到的东西据说能揭示出一个人的性格特征。
这是一项相当主观的测试,但这正使其更加有趣!

图 5-21. Rorschach 测验中的图像。你在里面看到了什么?
让我们以图 7-X 中的图像作为输入:
# Load rorschach image
url = "https://upload.wikimedia.org/wikipedia/commons/7/70/Rorschach_blot_01.jpg"
image = Image.open(urlopen(url)).convert("RGB")
# Generate caption
inputs = processor(image, return_tensors="pt").to(device, torch.float16)
generated_ids = model.generate(**inputs, max_new_tokens=20)
generated_text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
与之前一样,当我们打印出 generated_text 时,可以看看标题:
>>> print(generated_text)
"a black and white ink drawing of a bat"
“一幅黑白的蝙蝠墨水画”。我完全能理解模型会用这样的描述为这张图像标注。由于这是一个 Rorschach 测试,你认为这对模型意味着什么?
用例 2:多模态基于聊天的提示
尽管标题生成是一项重要任务,我们可以进一步扩展其应用案例。在那个示例中,我们展示了如何从一种模态(视觉,即图像)转向另一种模态(文本,即标题)。
我们可以尝试通过执行所谓的视觉问答,来同时呈现这两种模态,而不是遵循这种线性结构。在这个特定的用例中,我们给模型提供一张图像以及关于该特定图像的问题,让它回答。模型需要同时处理图像和问题。
为了演示,我们从一张汽车的图片开始,请 BLIP-2 描述该图像。为此,我们首先需要对图像进行预处理,就像我们之前做过几次一样:
# Load an AI-generated image of a supercar and process it
image = Image.open(urlopen("https://i.imgur.com/zehSvAe.png")).convert("RGB")
inputs = processor(image, return_tensors="pt").to(device, torch.float16)
为了进行视觉问答,我们需要给 BLIP-2 的不仅仅是图像,还有提示语。如果没有提示,模型会像之前一样生成一个标题。
我们将请求模型描述我们刚刚处理的图像:
# Visual Question Answering
prompt = "Question: Write down what you see in this picture. Answer:"
# Process both the image and the prompt
inputs = processor(image, text=prompt, return_tensors="pt").to(device, torch.float16)
# Generate text
generated_ids = model.generate(**inputs, max_new_tokens=30)
generated_text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
当我们打印出generated_text时,可以探索它对我们提问的回答:
>>> print(generated_text)
A sports car driving on the road at sunset
它正确地描述了图像。然而,这是一个相对简单的例子,因为我们的问题本质上是在让模型创建一个标题。相反,我们可以以聊天的方式问它后续问题。
为此,我们可以将之前的对话,包括它对我们问题的回答,提供给模型。然后我们问它一个后续问题。
>>> # 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 = processor(image, text=prompt, return_tensors="pt").to(device, torch.float16)
>>> generated_ids = model.generate(**inputs, max_new_tokens=30)
>>> generated_text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
>>> print(generated_text)
$1,000,000
$1,000,000 是高度具体的!这展示了 BLIP-2 更像聊天的行为,允许进行一些有趣的对话。
最后,我们可以通过使用 ipywidgets(Jupyter Notebooks 的一个扩展,允许我们制作交互按钮、输入文本等)来使这个过程变得更加顺畅。
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]) for i in range(len(memory))]) + " Question: " + question + " Answer:"
# Generate text
inputs = processor(image, text=prompt, return_tensors="pt").to(device, torch.float16)
generated_ids = model.generate(**inputs, max_new_tokens=100)
generated_text = processor.batch_decode(generated_ids, skip_special_tokens=True)[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"),
)
)

图 5-22. 图例说明待补充
看来我们可以继续对话,问它一堆问题。通过这种基于聊天的方式,我们实际上创建了一个可以推理图像的聊天机器人!
总结
在这一章中,我们探讨了两种使语言模型多模态的方法。
¹ Wei, J., Tay, Y., Bommasani, R., Raffel, C., Zoph, B., Borgeaud, S., Yogatama, D., Bosma, M., Zhou, D., Metzler, D., & others (2022). 大语言模型的涌现能力。arXiv 预印本 arXiv:2206.07682。
² Dosovitskiy, A., Beyer, L., Kolesnikov, A., Weissenborn, D., Zhai, X., Unterthiner, T., Dehghani, M., Minderer, M., Heigold, G., Gelly, S., & others (2020). 一张图像的价值相当于 16x16 个词:用于大规模图像识别的变换器。arXiv 预印本 arXiv:2010.11929。
³ Radford, A., Kim, J., Hallacy, C., Ramesh, A., Goh, G., Agarwal, S., Sastry, G., Askell, A., Mishkin, P., Clark, J., & others (2021). 从自然语言监督中学习可转移的视觉模型。在 国际机器学习会议 (第 8748–8763 页)。
⁴ Schafer, R. (1954). 罗夏克测试中的精神分析解读:理论与应用。****
第六章: 令牌与令牌嵌入
嵌入是使用大型语言模型(LLMs)的核心概念,正如你在本书第一部分中反复看到的那样。它们对理解 LLMs 的工作原理、构建方式以及未来发展方向至关重要。
到目前为止,我们所查看的大多数嵌入都是文本嵌入,这些向量表示整个句子、段落或文档。图 6-1 展示了这一区别。

图 6-1. 文本嵌入(一个向量表示一个句子或段落)和令牌嵌入(每个单词或令牌一个向量)之间的区别。
在本章中,我们开始更详细地讨论令牌嵌入。第二章讨论了如命名实体识别的令牌分类任务。在本章中,我们将更仔细地研究什么是令牌以及用于支持 LLMs 的令牌化方法。随后我们将超越文本的世界,看看这些令牌嵌入的概念如何使 LLMs 能够理解图像和其他数据模式(例如视频、音频等)。能够处理除文本之外的数据模式的 LLMs 被称为多模态模型。然后我们将深入探讨著名的 word2vec 嵌入方法,它是现代 LLMs 的前身,并看看它如何扩展令牌嵌入的概念,以构建推动许多应用的商业推荐系统。
LLM 令牌化
令牌化器如何准备语言模型的输入
从外部看,生成式大型语言模型(LLMs)接受输入提示并生成响应,正如我们在图 6-2 中所见。

图 6-2. 语言模型及其输入提示的高级视图。
正如我们在第五章中看到的,经过指令调优的 LLMs 对作为指令或问题表述的提示产生更好的响应。在代码的最基本层面上,假设我们有一个调用语言模型并生成文本的 generate 方法:
prompt = "Write an email apologizing to Sarah for the tragic gardening mishap. Explain how it happened."
# Placeholder definition. The next code blocks show the actual generation
def generate(prompt, number_of_tokens):
# TODO: pass prompt to language model, and return the text it generates
pass
output = generate(prompt, 10)
print(output)
生成:
Subject: Apology and Condolences
Dear Sarah,
I am deeply sorry for the tragic gardening accident that took place in my backyard yesterday. As you may have heard, *...**etc*
让我们更仔细地审视这一生成过程,以检查文本生成中涉及的更多步骤。让我们从加载模型及其令牌化器开始。
from transformers import AutoModelForCausalLM, AutoTokenizer
*#* *openchat* *is a 13B LLM*
model_name = "openchat/openchat"
*# If your environment does not have the required resources to run this model*
*# then try a smaller model like "gpt2" or "**openlm**-research/open_llama_3b"*
*# Load a tokenizer*
tokenizer = AutoTokenizer.from_pretrained(model_name)
*# Load a language model*
model = AutoModelForCausalLM.from_pretrained(model_name)
然后我们可以进入实际生成。请注意,生成代码总是包括一个令牌化步骤,位于生成步骤之前。
prompt = "Write an 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
# Generate the text
generation_output = model.generate(
input_ids=input_ids,
max_new_tokens=256
)
# Print the output
print(tokenizer.decode(generation_output[0]))
从这段代码来看,我们可以看到模型实际上并没有接收到文本提示。相反,令牌化器处理了输入提示,并返回模型所需的信息,存储在变量 input_ids 中,模型将其用作输入。
让我们打印 input_ids 以查看其内部内容:
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]])
这揭示了 LLMs 响应的输入。正如图 6-3 中所示,一系列整数。每个都是特定令牌(字符、单词或单词的一部分)的唯一 ID。这些 ID 参考分词器内部的一个表,包含它所知道的所有令牌。

图 6-3. 分词器处理输入提示并准备实际输入到语言模型中:一个令牌 ID 列表。
如果我们想检查这些 ID,可以使用分词器的解码方法将 ID 转换回可读文本:
for id in input_ids[0]:
print(tokenizer.decode(id))
这将打印:
<s>
Write
an
email
apolog
izing
to
Sarah
for
the
trag
ic
garden
ing
m
ish
ap
.
Exp
lain
how
it
happened
.
这就是分词器如何拆解我们的输入提示。注意以下几点:
-
第一个令牌是 ID 为 #1 的令牌,它是
,一个特殊令牌,表示文本的开始。 -
一些令牌是完整的单词(例如,写,一个,电子邮件)。
-
一些令牌是单词的一部分(例如,道歉,izing,悲惨,ic)。
-
标点符号字符是它们自己的令牌。
-
注意空格字符没有自己的令牌。相反,部分令牌(如 ‘izing’ 和 ‘ic’)在开头有一个特殊的隐藏字符,指示它们与文本中前面的令牌相连。
有三个主要因素决定了分词器如何拆解输入提示。首先,在模型设计时,模型的创建者选择了一种分词方法。常用的方法包括字节对编码(BPE,广泛应用于 GPT 模型)、WordPiece(用于 BERT)和 SentencePiece(用于 LLAMA)。这些方法的共同点在于,它们旨在优化一组有效的令牌来表示文本数据集,但它们的实现方式各不相同。
其次,选择方法后,我们需要进行一系列分词器设计选择,如词汇大小和使用哪些特殊令牌。关于这一点将在“比较训练后的 LLM 分词器”部分中详细介绍。
第三,分词器需要在特定数据集上进行训练,以建立最佳词汇表来表示该数据集。即使我们设置相同的方法和参数,在英语文本数据集上训练的分词器与在代码数据集或多语言文本数据集上训练的分词器也会有所不同。
除了用于将输入文本处理成语言模型外,分词器还用于语言模型的输出,将生成的令牌 ID 转换为与之关联的输出单词或令牌,如图 6-4 所示。

图 6-4. 分词器也用于处理模型的输出,通过将输出的令牌 ID 转换为与该 ID 关联的单词或令牌。
单词与子词、字符和字节令牌
我们看到的分词方案称为子词分词。它是最常用的分词方案,但不是唯一的。四种显著的分词方式如图 6-5 所示。让我们逐一了解它们:
单词令牌
这种方法在早期的 Word2Vec 等方法中很常见,但在 NLP 中的使用越来越少。然而,它的有效性使其被用于 NLP 以外的用例,例如推荐系统,正如我们将在本章后面看到的。

图 6-5. 有多种分词方法将文本拆分为不同大小的组件(单词、子词、字符和字节)。
单词分词的一个挑战是,分词器无法处理训练后进入数据集的新单词。这也导致了一个词汇中存在许多差异微小的令牌(例如,apology, apologize, apologetic, apologist)。我们已经看到,这一后续挑战通过子词分词得到解决,因为它有一个令牌为'apolog**',然后是许多其他令牌通用的后缀令牌(例如,'-y', '-**ize', '-etic', '-ist'),从而形成更具表现力的词汇。
子词令牌
此方法包含完整和部分单词。除了之前提到的词汇表现力外,该方法的另一个好处是能够通过将新令牌分解为较小的字符来表示新单词,这些字符往往是词汇的一部分。
与字符令牌相比,该方法的好处在于能够在 Transformer 模型的有限上下文长度内容纳更多文本。因此,在一个上下文长度为 1024 的模型中,使用子词分词能够比使用字符令牌容纳三倍的文本(子词令牌平均每个令牌有三个字符)。
字符令牌
这是一种能够成功处理新单词的方法,因为它有原始字母作为备用。虽然这使得表示更容易分词,但也使建模更具挑战性。使用子词分词的模型可以将“play”表示为一个令牌,而使用字符级令牌的模型需要建模信息以拼写出“p-l-a-y”,并建模其余序列。
字节令牌
另一种分词方法将分词分解为用于表示 Unicode 字符的单个字节。像 CANINE: Pre-training an Efficient Tokenization-Free Encoder for Language Representation 这样的论文概述了这种方法,也被称为“无分词编码”。其他作品如 ByT5: Towards a token-free future with pre-trained byte-to-byte models 显示这可以是一种具有竞争力的方法。
这里要强调的一个区别是:一些子词分词器还将字节作为词汇中的分词,以便在遇到无法以其他方式表示的字符时回退到最终构建块。比如,GPT2 和 RoBERTa 分词器就是这样做的。这并不意味着它们是无分词的字节级分词器,因为它们并不使用这些字节来表示所有内容,而只使用子集,正如我们将在下一节中看到的那样。
分词器在 [Suhas 的书中] 有更详细的讨论
比较经过训练的 LLM 分词器
我们之前提到的三个主要因素决定了分词器中出现的分词:分词方法、初始化分词器所用的参数和特殊分词,以及分词器训练所用的数据集。让我们比较和对比多个实际的、经过训练的分词器,以观察这些选择如何改变它们的行为。
我们将使用多种分词器对以下文本进行编码:
text = """
English and CAPITALIZATION
ߎ堩蟠
show_tokens False None elif == >= else: two tabs:" " Three tabs: " "
12.0*50=600
"""
这将使我们能够看到每个分词器如何处理多种不同类型的分词:
-
大写化
-
除英语外的其他语言
-
表情符号
-
编程代码及其关键字和空白通常用于缩进(例如在 Python 语言中)
-
数字和数字符号
让我们从较旧的到较新的分词器,看看它们如何对这段文本进行分词,以及这可能对语言模型意味着什么。我们将对文本进行分词,然后打印每个分词,背景颜色为灰色。
bert-base-uncased
分词方法:WordPiece,介绍于 日本和韩国语音搜索
词汇大小:30522
特殊分词:‘unk_token’: '[UNK]'
’sep_token’: '[SEP]'
‘pad_token’: '[PAD]'
‘cls_token’: '[CLS]'
‘mask_token’: '[MASK]'
分词文本:
[CLS] english and capital ##ization [UNK] [UNK] show _ token ##s false none eli ##f = = > = else : four spaces : " " two tab ##s : " " 12 . 0 * 50 = 600 [SEP]
使用无大小写(更流行)的 BERT 分词器版本,我们注意到以下几点:
-
换行符被去掉,这使得模型无法识别编码在换行中的信息(例如,每次轮换在新行中的聊天记录)
-
所有文本均为小写字母
-
“capitalization”这个词被编码为两个子分词 capital ##ization。## 字符用于指示该分词是与前一个分词相连的部分分词。这也是指示空格位置的一种方法,假定没有 ## 的分词前有一个空格。
-
表情符号和中文字符被替换为 [UNK] 特殊分词,表示“未知分词”。
bert-base-cased
标记化方法:WordPiece
词汇表大小:28,996
特殊标记:与无大小写版本相同
标记化文本:
[CLS] English and CA ##PI ##TA ##L ##I ##Z ##AT ##ION [UNK] [UNK] show _ token ##s F ##als ##e None el ##if = = > = else : Four spaces : " " Two ta ##bs : " " 12 . 0 * 50 = 600 [SEP]
BERT 标记器的大小写版本主要不同于包括大写标记。
-
注意“CAPITALIZATION”现在表示为八个标记:CA ##PI ##TA ##L ##I ##Z ##AT ##ION
-
两个 BERT 标记器在输入周围包裹一个起始的[CLS]标记和一个结束的[SEP]标记。[CLS]代表分类,因为它是有时用于句子分类的标记。[SEP]代表分隔符,因为它用于在一些需要将两个句子传递给模型的应用中分隔句子(例如,在第三章的重新排序器中,我们会使用[SEP]标记来分隔查询文本和候选结果)。
gpt2
标记化方法:BPE,介绍于神经机器翻译中的稀有词的子词单元
词汇表大小:50,257
特殊标记:<|endoftext|>
标记化文本:
英语和 CAP ITAL IZ ATION
� � � � � �
show _ t ok ens False None el if == >= else :
四个空格: " " 两个制表符: " "
12 . 0 * 50 = 600
使用 GPT-2 标记器,我们注意到以下内容:
换行符在标记器中表示
大写字母保持不变,单词“CAPITALIZATION”由四个标记表示。
🎵 蟠字符现在被表示为多个标记。尽管我们看到这些标记以�字符打印,但它们实际上代表不同的标记。例如,🎵 emoji 被分解为标记,标记 ID 为:8582,236 和 113。标记器成功地从这些标记重构了原始字符。我们可以通过打印 tokenizer.decode([8582, 236, 113])来看到这一点,它打印出🎵
两个制表符被表示为两个标记(该词汇表中的标记编号 197),四个空格被表示为三个标记(编号 220),最后一个空格是关闭引号字符的标记的一部分。
注意
空白字符的意义是什么?这些对于理解或生成代码的模型很重要。使用单个标记表示四个连续空白字符的模型,可以说更适合 Python 代码数据集。尽管模型可以用四个不同的标记表示,但这确实使建模更加困难,因为模型需要跟踪缩进级别。这是标记化选择可以帮助模型在特定任务上改进的一个例子。
google/flan-t5-xxl
标记化方法:SentencePiece,介绍于SentencePiece:一种简单的与语言无关的子词标记器和解码器,用于神经文本处理
词汇表大小:32,100
特殊标记:
-
‘unk_token’: '
' -
‘pad_token’: '
'
标记化文本:
英语和 CA PI TAL IZ ATION
FLAN-T5 模型家族使用 sentencepiece 方法。我们注意到以下几点:
-
没有换行或空白标记,这会使模型处理代码变得困难。
-
表情符号和汉字都被替换为
标记,使模型对此完全无感。
GPT-4
分词方法:BPE
词汇表大小:略超过 100,000
特殊标记:
<|endoftext|>
填充中间标记。这三个标记使得 GPT-4 能够生成补全,不仅考虑之前的文本,还考虑之后的文本。该方法在论文高效训练语言模型以填充中间部分中有更详细的解释。这些特殊标记为:
<|fim_prefix|>
<|fim_middle|>
<|fim_suffix|>
分词文本:
English and CAPITAL IZATION
� � � � � �
show _tokens False None elif == >= else :
Four spaces : " " Two tabs : " "
12 . 0 * 50 = 600
GPT-4 的分词器行为与其前身 GPT-2 的分词器相似。有些差异是:
-
GPT-4 的分词器将四个空格表示为一个标记。实际上,它对每个空白序列都有特定的标记,直到一列 83 个空格。
-
python 关键字 elif 在 GPT-4 中有自己的标记。上述这一点和前一点均源于模型对代码的关注,除了自然语言。
-
GPT-4 的分词器使用更少的标记来表示大多数单词。这里的例子包括‘CAPITALIZATION’(两个标记,而不是四个)和‘tokens’(一个标记而不是三个)。
bigcode/starcoder
分词方法:
词汇表大小:约 50,000
特殊标记:
'<|endoftext|>'
填充中间标记:
'<fim_prefix>'
'<fim_middle>'
'<fim_suffix>'
'<fim_pad>'
在表示代码时,管理上下文很重要。一个文件可能会调用在另一个文件中定义的函数。因此,模型需要某种方式来识别在同一代码库中不同文件中的代码,同时区分不同代码库中的代码。这就是 starcoder 为库名称和文件名使用特殊标记的原因:
'
'
'<gh_stars>'
该分词器还包含一系列特殊标记,以便在代码上表现得更好。这些包括:
'<issue_start>'
'<jupyter_start>'
'<jupyter_text>'
分词文本:
English and CAPITAL IZATION
� � � � �
show _ tokens False None elif == >= else :
Four spaces : " " Two tabs : " "
1 2 . 0 * 5 0 = 6 0 0
这是一个专注于代码生成的编码器。
-
与 GPT-4 类似,它将空格列表编码为一个单一标记。
-
这里一个主要的不同之处是每个数字都被分配了自己的标记(因此 600 变为 6 0 0)。假设这是为了更好地表示数字和数学。在 GPT-2 中,例如,数字 870 表示为一个标记。但 871 表示为两个标记(8 和 71)。你可以直观地理解,这可能会让模型在表示数字时感到困惑。
facebook/galactica-1.3b
在Galactica: A Large Language Model for Science中描述的 Galactica 模型专注于科学知识,并在许多科学论文、参考材料和知识库上进行了训练。它特别关注分词,使其对所代表的数据集的细微差别更加敏感。例如,它包含用于引用、推理、数学、氨基酸序列和 DNA 序列的特殊标记。
分词方法:
词汇表大小:50,000
特殊标记:
参考文献:引用被包裹在两个特殊标记内:
[开始引用]
[结束引用]
论文中的一个使用示例是:
循环神经网络,长短期记忆[开始引用]长短期记忆,霍希特[结束引用]
步骤推理 -
标记化文本:
English and CAP ITAL IZATION
� � � � � � �
show _ tokens False None elif == > = else :
Four spaces : " " Two t abs : " "
1 2 . 0 * 5 0 = 6 0 0
Galactica 标记器的行为类似于 Star Coder,因为它考虑了代码。它也以相同的方式编码空白 - 将不同长度的空白序列分配给一个单一的标记。它的不同之处在于它也会对制表符这样处理。因此,在我们迄今为止看到的所有标记器中,它是唯一一个将由两个制表符('\t\t')组成的字符串分配给单一标记的标记器。
现在我们可以通过并排查看所有这些示例来回顾我们的导览:
| bert-base-uncased |
|---|
[CLS] english and capital ##ization [UNK] [UNK] show _ token ##s false none eli ##f = = > = else : four spaces : " " two tab ##s : " " 12 . 0 * 50 = 600 [SEP]
|
| bert-base-cased |
|---|
[CLS] English and CA ##PI ##TA ##L ##I ##Z ##AT ##ION [UNK] [UNK] show _ token ##s F ##als ##e None el ##if = = > = else : Four spaces : " " Two ta ##bs : " " 12 . 0 * 50 = 600 [SEP]
|
| gpt2 |
|---|
English and CAP ITAL IZ ATION � � � � � � show _ t ok ens False None el if == >= else : Two tabs :" " Four spaces : " " 12 . 0 * 50 = 600
|
| google/flan-t5-xxl |
|---|
English and CA PI TAL IZ ATION <unk> <unk> show _ to ken s Fal s e None e l if = = > = else : two tab s : " " Four spaces : " " 12\. 0 * 50 = 600 </s>
|
| GPT-4 |
|---|
English and CAPITAL IZATION � � � � � � show _tokens False None elif == >= else : Four spaces : " " Two tabs : " " 12 . 0 * 50 = 600
|
| bigcode/starcoder |
|---|
English and CAPITAL IZATION � � � � � show _ tokens False None elif == >= else : Four spaces : " " Two tabs : " " 1 2 . 0 * 5 0 = 6 0 0
|
| facebook/galactica-1.3b |
|---|
English and CAP ITAL IZATION � � � � � � � show _ tokens False None elif == > = else : Four spaces : " " Two t abs : " " 1 2 . 0 * 5 0 = 6 0 0
|
| meta-llama/Llama-2-70b-chat-hf |
|---|
<s> English and C AP IT AL IZ ATION � � � � � � � show _ to kens False None elif == >= else : F our spaces : " " Two tabs : " " 1 2 . 0 * 5 0 = 6 0 0
|
请注意,底部添加了一个新的标记器。到现在为止,你应该能够通过快速浏览这个输出理解它的许多属性。这是 LLaMA2 的标记器,这些模型中最新的一个。
标记器属性
前面的训练标记器导览展示了实际标记器之间的多种差异。但是什么决定了它们的分词行为呢?有三个主要的设计选择决定了标记器如何拆分文本:分词方法、初始化参数,以及我们训练标记器(但不是模型)所用的数据集。
分词方法
正如我们所见,有多种分词方法,其中 Byte-Pair Encoding (BPE)、WordPiece 和 SentencePiece 是较为流行的几种。每种方法都概述了一种算法,用于选择适当的标记集来表示数据集。有关所有这些方法的优秀概述可以在 Hugging Face 的分词器页面摘要中找到。
标记器参数
选择分词方法后,LLM 设计师需要对标记器的参数做出一些决策。这些包括:
词汇表大小
在分词器的词汇中应保留多少个令牌?(常用的词汇大小值为 30K、50K,但越来越多的情况是看到像 100K 这样更大的大小)
特殊令牌
我们希望模型跟踪哪些特殊令牌。我们可以根据需要添加任意数量,特别是如果我们想为特定用例构建 LLM。常见的选择包括:
-
文本开始令牌(例如,
) -
文本结束令牌
-
填充令牌
-
未知令牌
-
CLS 令牌
-
掩码令牌
除此之外,LLM 设计者可以添加有助于更好地建模他们试图关注的问题领域的令牌,正如我们在 Galactica 的
大写
在像英语这样的语言中,我们希望如何处理大写?我们是否应该将所有内容转换为小写?(名称的大写通常携带有用的信息,但我们是否想在所有大写版本的单词上浪费令牌词汇空间?)这就是为什么一些模型以大写和小写版本发布(如Bert-base cased和更流行的Bert-base uncased)。
分词器训练数据集
即使我们选择相同的方法和参数,分词器的行为也会因其训练的数据集而异(在我们甚至开始模型训练之前)。前面提到的分词方法通过优化词汇来代表特定的数据集。从我们的导览中,我们看到这对代码和多语言文本等数据集产生了影响。
例如,对于代码,我们看到一个以文本为中心的分词器可能会像这样分词缩进空格(我们将一些令牌用黄色和绿色突出显示):
def add_numbers(a, b):
...."""Add the two numbers `a` and `b`."""
....return a + b
对于以代码为中心的模型,这可能并不理想。以代码为中心的模型往往会做出不同的分词选择:
def add_numbers(a, b):
...."""Add the two numbers `a` and `b`."""
....return a + b
这些分词选择使模型的工作变得更轻松,因此其性能更有可能提高。
有关训练分词器的更详细教程可以在Hugging Face 课程的分词器部分和《使用变换器的自然语言处理,修订版》中找到。
语言模型为其分词器的词汇持有嵌入
在初始化一个分词器后,它将在其相关语言模型的训练过程中使用。这就是为什么预训练的语言模型与其分词器相连,并且在未经过训练的情况下不能使用不同的分词器。
语言模型为分词器词汇中的每个令牌持有一个嵌入向量,正如我们在图 6-6 中看到的。一开始,这些向量像模型的其他权重一样随机初始化,但训练过程会赋予它们能够执行有用行为的值。

图 6-6. 语言模型持有与其分词器中每个标记相关的嵌入向量。
使用语言模型创建上下文化单词嵌入。
现在我们已经覆盖了作为语言模型输入的标记嵌入,让我们看看语言模型如何创建更好的标记嵌入。这是使用语言模型进行文本表示的主要方式之一,赋能应用程序如命名实体识别或提取式文本摘要(通过突出最重要的部分来总结长文本,而不是生成新的文本作为摘要)。

图 6-7. 语言模型生成的上下文化标嵌入比原始静态标嵌入更为优越。
语言模型并不使用静态向量来表示每个标记或单词,而是生成上下文化单词嵌入(如图 6-7 所示),根据上下文以不同的标记表示单词。这些向量可以被其他系统用于各种任务。除了我们在前一段中提到的文本应用外,这些上下文化向量,例如,正是驱动 AI 图像生成系统如 Dall-E、Midjourney 和 Stable Diffusion 的力量。
代码示例:来自语言模型(如 BERT)的上下文化单词嵌入。
让我们看看如何生成上下文化单词嵌入,这段代码的大部分现在应该对你很熟悉:
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]
这段代码下载一个预训练的分词器和模型,然后使用它们处理字符串“Hello world”。模型的输出结果随后保存在输出变量中。让我们通过首先打印其维度来检查该变量(我们预计它是一个多维数组)。
我们在这里使用的模型称为 DeBERTA v3,在撰写时,它是表现最佳的标记嵌入语言模型之一,同时体积小且高效。其详细描述见论文DeBERTaV3: Improving DeBERTa using ELECTRA-Style Pre-Training with Gradient-Disentangled Embedding Sharing。
output.shape
这将输出:
torch.Size([1, 4, 384])
我们可以忽略第一维,将其视为四个标记,每个标记嵌入 384 个值。
但这四个向量是什么?分词器是否将两个单词分解成四个标记,还是发生了其他情况?我们可以利用所学的分词器知识来检查它们:
for token in tokens['input_ids'][0]:
print(tokenizer.decode(token))
这将输出:
[CLS]
Hello
world
[SEP]
这表明这个特定的分词器和模型通过在字符串的开头和结尾添加[CLS]和[SEP]标记来操作。
我们的语言模型现在已经处理了文本输入。其输出结果如下:
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>)
这是语言模型的原始输出。大型语言模型的应用基于这样的输出。
我们可以在图 6-8 中回顾输入标记化和语言模型的输出。技术上,从词元 ID 转换为原始嵌入是语言模型内部发生的第一步。

图 6-8. 一个语言模型在原始静态嵌入上操作,并生成上下文文本嵌入。
这样的可视化对于下一章我们开始研究基于 Transformer 的 LLMs 如何运作至关重要。
词嵌入
词元嵌入即使在大型语言模型之外也很有用。通过如 Word2Vec、Glove 和 Fasttext 等预-LLM 方法生成的嵌入在 NLP 及其之外仍然有用。在本节中,我们将探讨如何使用预训练的 Word2Vec 嵌入,并简要介绍该方法如何创建词嵌入。了解 Word2Vec 的训练过程将为后续关于对比训练的章节做好准备。接下来我们将看到这些嵌入如何用于推荐系统。
使用预训练词嵌入
让我们看看如何使用Gensim库下载预训练的词嵌入。
import gensim
import gensim.downloader as api
from sklearn.metrics.pairwise import cosine_similarity
import seaborn as sns
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# 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")
在这里,我们下载了在维基百科上训练的大量单词嵌入。我们可以通过查看特定单词的最近邻,来探索嵌入空间,例如‘king’:
model.most_similar([model['king']], topn=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)]
Word2vec 算法与对比训练
论文高效估计向量空间中的词表示中描述的 word2vec 算法在图解 Word2vec中有详细说明。这里的中心思想在于我们在下一节讨论为推荐引擎创建嵌入的一种方法时进行扩展。
就像 LLMs 一样,word2vec 是基于从文本生成的示例进行训练的。举个例子,我们有来自弗兰克·赫伯特的沙丘小说中的文本“你不可制造与人类心智相似的机器”。该算法使用滑动窗口生成训练示例。比如,我们可以设定窗口大小为 2,这意味着我们考虑中心单词两侧的两个邻居。
这些嵌入是从分类任务中生成的。该任务用于训练神经网络以预测单词是否出现在同一上下文中。我们可以将其视为一个神经网络,它接受两个单词,并在它们倾向于出现在同一上下文时输出 1,而在它们不出现在同一上下文时输出 0。
在滑动窗口的第一个位置,我们可以生成四个训练示例,如图 6-9 所示。

图 6-9. 使用滑动窗口生成训练样本,以便 word2vec 算法后续预测两个单词是否为邻居。
在每个生成的训练样本中,中心的单词作为一个输入,每个邻居在每个训练样本中都是一个独特的第二输入。我们期望最终训练的模型能够分类这种邻居关系,并在收到确实是邻居的两个输入单词时输出 1。
这些训练样本在图 6-10 中进行了可视化。

图 6-10. 每个生成的训练样本展示了一对邻居单词。
然而,如果我们只有目标值为 1 的数据集,那么模型可以通过始终输出 1 来轻松应对。为了绕过这个问题,我们需要用通常不是邻居的单词例子丰富我们的训练数据集。这些被称为负例,如图 6-11 所示。

图 6-11. 我们需要向模型呈现负例:通常不是邻居的单词。更好的模型能够更好地区分正例和负例。
事实证明,我们在选择负例时不必过于科学。许多有用的模型是通过简单地从随机生成的例子中检测正例的能力而得来的(灵感来自一个重要的思想,称为噪声对比估计,并在Noise-contrastive estimation: A new estimation principle for unnormalized statistical models中描述)。因此,在这种情况下,我们获取随机单词并将其添加到数据集中,并表明它们不是邻居(因此模型在看到这些时应输出 0)。
通过这一点,我们已经看到了 word2vec 的两个主要概念(图 6-12):Skipgram - 选择邻居单词的方法,以及负采样 - 通过从数据集中随机抽样添加负例。

图 6-12. Skipgram 和负采样是 word2vec 算法背后的两个主要思想,并且在许多可以表述为标记序列问题的其他问题中也很有用。
我们可以通过运行文本生成数百万甚至数十亿个训练示例。在对该数据集进行神经网络训练之前,我们需要做出一些标记决策,正如我们在 LLM 标记器中看到的那样,包括如何处理大写和标点,以及我们想要在词汇表中有多少个标记。
然后我们为每个标记创建一个嵌入向量,并随机初始化它们,如图 6-13 所示。实际上,这是一个维度为 vocab_size x embedding_dimensions 的矩阵。

图 6-13. 词汇及其起始的随机未初始化的嵌入向量。
然后,一个模型在每个示例上进行训练,接收两个嵌入向量并预测它们是否相关。我们可以在图 6-14 中看到这个过程的样子:

图 6-14. 一个神经网络被训练来预测两个词是否相邻。它在训练过程中更新嵌入,以生成最终的训练嵌入。
根据预测是否正确,典型的机器学习训练步骤会更新嵌入,使得下次模型展示这两个向量时,有更大的机会变得更准确。到训练过程结束时,我们为词汇表中的所有标记拥有更好的嵌入。
这种模型的想法是将两个向量结合并预测它们是否具有某种关系,这是机器学习中最强大的想法之一,并且一次又一次地证明在语言模型中非常有效。这就是为什么我们专门用章节 XXX 来详细讨论这一概念,以及它如何优化语言模型以应对特定任务(如句子嵌入和检索)。
同样的想法也在弥合文本和图像等模态之间至关重要,这对 AI 图像生成模型来说是关键。在该模型中,给定一张图像和一个标题,模型应该预测这个标题是否描述了这张图像。
推荐系统的嵌入
标记嵌入的概念在许多其他领域也非常有用。在行业中,例如,它广泛用于推荐系统。
通过嵌入推荐歌曲
在本节中,我们将使用 Word2vec 算法,通过人造音乐播放列表来嵌入歌曲。想象一下,如果我们将每首歌视为一个单词或标记,而将每个播放列表视为一个句子。这些嵌入可以用来推荐那些经常一起出现在播放列表中的相似歌曲。
我们将使用的数据集是由康奈尔大学的 Shuo Chen 收集的。该数据集包含来自美国数百个广播电台的播放列表。图 6-15 展示了该数据集。

图 6-15。为了捕捉歌曲相似性的歌曲嵌入,我们将使用一个由包含歌曲列表的播放列表集合组成的数据集。
让我们在查看构建方式之前演示最终产品。所以,让我们给出几首歌曲,看看它推荐什么。
让我们开始,选择迈克尔·杰克逊的比利·珍,歌曲 ID 为#3822。
print_recommendations(3822)
title Billie Jean
artist Michael Jackson
Recommendations:
| id | 标题 | 艺术家 |
|---|---|---|
| 4181 | 吻 | 王子与革命 |
| 12749 | 想要开始一些事情 | 迈克尔·杰克逊 |
| 1506 | 你让我感觉的方式 | 迈克尔·杰克逊 |
| 3396 | 假期 | 麦当娜 |
| 500 | 不停直到你满足 | 迈克尔·杰克逊 |
这看起来合理。麦当娜、王子和其他迈克尔·杰克逊的歌曲是最近的邻居。
让我们从流行音乐转向说唱,看看 2Pac 的《加州爱情》的邻居:
print_recommendations(842)
| id | 标题 | 艺术家 |
|---|---|---|
| 413 | 如果我统治世界(想象一下)(与劳伦·希尔) | 纳斯 |
| 196 | 我会想念你 | Puff Daddy & The Family |
| 330 | 爱或恨(与 50 Cent) | The Game |
| 211 | 催眠 | 知名 B.I.G. |
| 5788 | 像热一样抛掉(与法瑞尔) | snoop dogg |
另一个相当合理的列表!
# Get the playlist dataset file
data = request.urlopen('https://storage.googleapis.com/maps-premium/dataset/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]
print( 'Playlist #1:\n ', playlists[0], '\n')
print( 'Playlist #2:\n ', playlists[1])
Playlist #1: ['0', '1', '2', '3', '4', '5', ..., '43']
Playlist #2: ['78', '79', '80', '3', '62', ..., '210']
Let's train the model:
model = Word2Vec(playlists, vector_size=32, window=20, negative=50, min_count=1, workers=4)
训练需要一两分钟,结果是为我们拥有的每首歌计算嵌入。现在,我们可以像之前处理单词一样使用这些嵌入来找到相似歌曲。
song_id = 2172
# Ask the model for songs similar to song #2172
model.wv.most_similar(positive=str(song_id))
其输出为:
[('2976', 0.9977465271949768),
('3167', 0.9977430701255798),
('3094', 0.9975950717926025),
('2640', 0.9966474175453186),
('2849', 0.9963167905807495)]
这是与歌曲 2172 相似的歌曲嵌入列表。请查看 jupyter 笔记本中的代码,该代码将歌曲 ID 与其名称和艺术家名称链接起来。
在这种情况下,歌曲是:
title Fade To Black
artist Metallica
结果推荐都是在同一重金属和硬摇滚类型中:
| id | 标题 | 艺术家 |
|---|---|---|
| 11473 | 小吉他 | 瓦恩·海伦 |
| 3167 | 不羁 | 瓦恩·海伦 |
| 5586 | 最后的行列 | 迪奥 |
| 5634 | 布朗石先生 | 枪与玫瑰 |
| 3094 | 违反法律 | 猶達斯·普里斯特 |
摘要
在本章中,我们涵盖了 LLM 令牌、分词器以及使用令牌嵌入超越语言模型的有用方法。
-
分词器是处理输入到大型语言模型(LLM)的第一步——将文本转换为令牌 ID 列表。
-
一些常见的分词方案包括将文本拆分为单词、子词标记、字符或字节。
-
现实世界预训练分词器的巡览(从 BERT 到 GPT2、GPT4 及其他模型)向我们展示了某些分词器在保持信息(如大写、新行或其他语言的标记)方面表现更佳的领域,以及在某些方面分词器之间的不同之处(例如,它们如何拆分某些词)。
-
三个主要的分词器设计决策是分词器算法(如 BPE、WordPiece、SentencePiece)、分词参数(包括词汇表大小、特殊标记、大写处理、对大写和不同语言的处理),以及分词器训练所用的数据集。
-
语言模型也是高质量上下文化词嵌入的创造者,这些词嵌入在静态嵌入的基础上得到了改善。这些上下文化词嵌入用于包括命名实体识别(NER)、抽取式文本摘要和跨度分类等任务。
-
在大型语言模型(LLMs)出现之前,词嵌入方法如 word2vec、Glove 和 Fasttext 曾很流行。它们在语言处理内外仍然有一些应用场景。
-
Word2Vec 算法依赖于两个主要思想:Skipgram 和负采样。它还使用了对比训练,类似于我们将在对比训练章节中看到的内容。
-
词嵌入对创建和改进推荐系统非常有用,正如我们在构建的基于精选歌曲播放列表的音乐推荐系统中所看到的那样。
第七章: 创建文本嵌入模型
文本嵌入模型是许多强大自然语言处理应用的基础。它们为赋能已经令人印象深刻的技术(如文本生成模型)奠定了基础。在本书中,我们已经在多种应用中使用了嵌入模型,如监督分类、无监督分类、语义搜索,甚至为像 ChatGPT 这样的文本生成模型提供记忆。
嵌入模型在该领域的重要性几乎无法过分强调,因为它们是许多应用背后的驱动力。因此,在本章中,我们将讨论多种创建和微调嵌入模型的方法,以增强其代表性和语义能力。
让我们开始探索嵌入模型是什么以及它们通常如何工作。
嵌入模型
嵌入和嵌入模型已经在之前的多个章节中讨论过(章节 X、X 和 X),这证明了它们的实用性。在训练这样的模型之前,让我们回顾一下之前对嵌入模型的学习。
非结构化文本数据本身通常很难处理。它们不是我们可以直接处理、可视化并创建可操作结果的值。我们首先必须将这些文本数据转换为我们可以轻松处理的内容,即数值表示。这一过程通常被称为嵌入输入以输出可用向量,称为嵌入,如图 7-1 所示。

图 7-1. 我们使用嵌入模型将文本输入(如文档、句子和短语)转换为数值表示,称为嵌入。
嵌入输入的过程通常由一个大型语言模型(LLM)执行,我们称之为嵌入模型。这种模型的主要目的是尽可能准确地表示文本数据为嵌入。
然而,准确表示意味着什么呢?通常,我们希望捕捉文档的语义特征,即其含义。如果我们能够捕捉到文档所传达的核心内容,我们希望能捕捉到文档的主题。在实际操作中,这意味着我们希望相似文档的向量也要相似,而每个讨论完全不同主题的文档的嵌入则应不相似。这种语义相似性的理念在图 7-2 中得以可视化。

图 7-2. 语义相似性的概念是,我们期望具有相似意义的文本数据在 n 维空间中也彼此更接近。作为一个例子,这里在二维空间中进行了说明。请注意,这是一个简化的例子。虽然二维可视化有助于说明嵌入的接近性和相似性,但这些嵌入通常位于高维空间中。
然而,嵌入模型可以出于多种目的进行训练。例如,在构建情感分类器时,我们对文本的情感比其语义相似性更感兴趣。如图 7-3 所示,我们可以微调模型,使得文档根据其情感而不是语义特征更接近。
无论如何,嵌入模型的目标是学习某些文档彼此相似的原因,我们可以引导这一过程。通过向模型提供足够多的语义相似文档示例,我们可以朝着语义的方向引导,而使用情感示例则会引导它朝那个方向。

图 7-3. 相似性不仅仅可以通过语义来表达。可以训练一个嵌入模型,专注于情感相似性,即具有相似情感的文档在 n 维空间中彼此更接近,而与情感不同的文档则相距较远。在该图中,负面评价(红色)彼此接近,与正面评价(绿色)不同。
我们可以训练、微调和引导嵌入模型的方式有很多,但最强大且广泛使用的技术之一称为对比学习。
什么是对比学习?
训练和微调文本嵌入模型的一项主要技术称为对比学习。对比学习是一种旨在训练嵌入模型的技术,使得相似文档在向量空间中更接近,而不同文档则更远离。我们在图 13-X 和图 13-X 中之前见过这个概念。
对比学习的基本思想是,学习和建模文档之间相似性/不相似性的最佳方法是向模型提供相似和不相似对的示例。为了准确捕捉文档的语义性质,通常需要将其与另一文档进行对比,以便模型学习什么使其不同或相似。这个对比过程非常强大,并与文档撰写的背景相关。这个高级过程在图 7-4 中得到了展示。

图 7-4. 对比学习旨在教导嵌入模型文档是否相似或不相似。对比学习通过向模型展示一定程度上相似或不相似的文档组来实现。
另一个看待对比学习的方法是通过解释的性质。一个很好的例子是一个记者问一个抢劫犯“你为什么抢银行”,他回答“因为那里有钱。”¹ 尽管这个回答在事实上是正确的,但问题的意图并不是问他为什么特定地抢银行,而是问他为什么会抢劫。这被称为对比解释,指的是理解一个特定案例,“为什么 P”与其他选择相比,“为什么 P 而不是 Q?”² 在这个例子中,问题可以有多种解读,可能最好的方式是提供一个替代选择:“你为什么抢银行(P)而不是遵守法律(Q)?”。
理解问题的替代方案的重要性同样适用于嵌入如何通过对比学习来学习。通过向模型展示相似和不相似的文档对,它开始学习什么使得事物相似/不相似,更重要的是,为什么。
例如,你可以让一个模型通过寻找“尾巴”、“鼻子”、“四条腿”等特征来理解什么是狗。这个学习过程可能相当困难,因为特征往往没有明确的定义,并且可以有多种解读。一个具有“尾巴”、“鼻子”和“四条腿”的生物也可能是一只猫。为了帮助模型朝着我们感兴趣的方向发展,我们基本上问它“为什么这是狗而不是猫?”通过提供两个概念之间的对比,它开始学习定义概念的特征,以及与之无关的特征。我们在图 7-5 中进一步说明了这一对比解释的概念。

图 7-5. 解释通常通过其他可能性的对比来进行。因此,当我们将问题框架设为对比时,我们获得更多信息。嵌入模型也是如此。当我们提供不同的对比(相似度程度)时,它开始学习事物之间的差异,从而理解概念的独特特征。
注意
对比学习在自然语言处理中的一个早期且最受欢迎的例子实际上是 Word2Vec。该模型通过在句子中的单个词上进行训练来学习词表示。接近目标词的词将被构建为正样本,而随机抽样的词构成不相似的样本。换句话说,相邻词的正例与随机选择的非邻近词进行了对比。尽管不广为人知,但这是利用对比学习与神经网络相结合的首个重大突破之一。
我们可以应用对比学习创建文本嵌入模型的方式有很多,但最著名的技术和框架是sentence-transformers。
SBERT
尽管对比学习有很多形式,但在自然语言处理社区中流行化该技术的一个框架是 sentence-transformers。它的做法解决了创建句子嵌入的原始 BERT 实现的一个主要问题,即计算开销。在 sentence-transformers 之前,句子嵌入通常与 BERT 一起使用,采用一种称为交叉编码器的架构结构。
交叉编码器允许同时将两个句子传递给变换器网络,以预测这两个句子的相似程度。它通过在原始架构中添加一个分类头来实现,能够输出相似度分数。然而,当你想在 10,000 个句子的集合中找到最高配对时,计算数量迅速增加。这将需要 n·(n−1)/2 = 49 995 000 次推理计算,从而产生显著的开销。此外,交叉编码器通常不生成嵌入,如图 7-6 所示。
解决这个开销的方法是通过平均其输出层或使用[CLS]标记从 BERT 模型生成嵌入。然而,这显示出其效果不如简单地平均词向量,例如 GloVe.³

图 7-6. 交叉编码器的架构。两个句子被拼接,用 标记分隔,并同时输入模型。它输出的是输入句子之间的相似度分数,而不是嵌入。
相反,sentence-transformers 的作者以不同的方式处理这个问题,寻找一种快速且能够进行语义比较的嵌入方法。结果是对原始交叉编码器架构的一种优雅替代方案。在 sentence-transformers 中,与交叉编码器不同,分类头被省略,而是对最终输出层使用均值池化来生成嵌入。
Sentence-transformers 采用孪生架构进行训练。在这个架构中,如 图 7-7 所示,我们有两个相同的 BERT 模型,它们共享相同的权重和神经架构。由于两个 BERT 模型的权重是相同的,我们可以使用单个模型,并依次输入句子。

图 7-7. 原始 sentence-transformers 模型的架构,利用了一个叫做双编码器的孪生网络。共享权重的 BERT 模型接收句子,通过对标记嵌入的池化生成嵌入。然后,模型通过句子嵌入的相似性进行优化。
这些句子对的优化过程通过损失函数完成,这对模型的性能有重大影响。在训练过程中,每个句子的嵌入与其差异被拼接在一起。然后,这个结果嵌入通过 softmax 分类器进行优化。
结果架构也被称为双编码器或 SBERT(句子-BERT)。尽管双编码器非常快速并生成准确的句子表示,但交叉编码器通常比双编码器实现更好的性能,但不生成嵌入。
双编码器与交叉编码器一样,利用对比学习;通过优化句子对之间的(不)相似性,模型最终会学习使句子成为其本身的特征。
为了进行对比学习,我们需要两个东西。首先,我们需要构成相似/不相似对的数据。其次,我们需要定义模型如何定义和优化相似性。
创建嵌入模型
创建嵌入模型的方法有很多,但通常我们倾向于对比学习。这是许多嵌入模型的重要方面,因为这个过程使其能够高效地学习语义表示。
然而,这不是一个免费的过程。我们需要了解如何生成对比示例,如何训练模型,以及如何正确评估它。
生成对比示例
在预训练嵌入模型时,你通常会看到使用自然语言推理(NLI)数据中的数据。正如我们在第二章中描述的,NLI 是指调查给定前提是否蕴含假设(蕴含)、与之矛盾(矛盾)或两者皆不是(中立)的任务。
例如,当前提是“他在电影院观看寻梦环游记”而假设是“他在家观看冰雪奇缘”时,这些陈述是矛盾的。相比之下,当前提是“他在电影院观看寻梦环游记”而假设是“在电影院里他正在观看迪士尼电影寻梦环游记”时,这些陈述被视为蕴含。这个原则在图 7-8 中得到了说明。

图 7-8。我们可以利用 NLI 数据集的结构生成负例(矛盾)和正例(蕴含)用于对比学习。
如果你仔细观察蕴含和矛盾,那么它们描述的是两个输入之间的相似程度。因此,我们可以使用 NLI 数据集生成负例(矛盾)和正例(蕴含)用于对比学习。
注意
如果你有标记的数据,也可以生成对比示例。在第二章中,我们使用 SetFit 通过句子变换器进行少量分类。在 SetFit 中,通过比较同类句子(正例)和不同类句子(负例)生成了对比示例。
我们在创建和微调嵌入模型时将使用的数据来自通用语言理解评估基准(GLUE)。这个 GLUE 基准包含九个语言理解任务,用于评估和分析模型性能。
这些任务之一是多体裁自然语言推理(MNLI)语料库,它是一个包含带有蕴含关系(矛盾、中立、蕴含)的句子对的集合。我们将使用这些数据来训练我们的文本嵌入模型。让我们使用datasets包加载数据集:
from datasets import load_dataset
# Load MNLI dataset from GLUE
# 0 = entailment, 1 = neutral, 2 = contradiction
dataset = load_dataset("glue", "mnli", split="train")
接下来,我们看一个蕴含的示例:
>>> dataset[2]
{'premise': 'One of our number will carry out your instructions minutely.',
'hypothesis': 'A member of my team will execute your orders with immense precision.',
'label': 0,
'idx': 2}
加载数据集后,我们需要以某种方式处理它,以便可以用 sentence-transformers 读取:
from tqdm.auto import tqdm
from torch.utils.data import DataLoader
from sentence_transformers import InputExample
train_examples = [InputExample(texts=[row["premise"], row["hypothesis"]],
label=row["label"]) for row in tqdm(dataset)]
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=32)
训练模型
现在我们有了包含训练示例的数据加载器,我们需要创建我们的嵌入模型。我们通常选择一个现有的 sentence-transformer 模型并进行微调,但在这个例子中,我们将从头开始训练一个嵌入模型。
这意味着我们需要定义两个东西。首先,一个预训练的变换器模型,用于嵌入单个词。正如我们在第二章中看到的,“bert-base-uncased”模型通常用于教程。然而,还有许多其他模型也经过句子变换器评估。特别是,“microsoft/mpnet-base”通常在用作词嵌入模型时表现良好。其次,我们需要定义池化策略。对词嵌入进行平均通常是在大多数嵌入模型中使用。
from sentence_transformers import SentenceTransformer, models
# Define a model that will embed individual words
word_embedding_model = models.Transformer('bert-base-uncased', max_seq_length=256)
# Define a model that will pool each individual words
# NOTE: This automatically uses average pooling but other strategies exist
# such as taking the maximum or mode of word embeddings across all dimensions
pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension())
# Create
model = SentenceTransformer(modules=[word_embedding_model, pooling_model])
注意
默认情况下,sentence-transformers 中的 LLM 的所有层都是可训练的。虽然可以冻结某些层,但通常不建议这样做,因为解冻所有层时性能通常更好。
接下来,我们需要定义一个损失函数,以便优化模型。正如本节开头提到的,sentence-transformers 的第一个实例使用 soft-max 损失。为了说明,我们将暂时使用它,但稍后我们将讨论更高效的损失:
from sentence_transformers import losses
# Define the loss function. In soft-max loss, we will also need to explicitly set the number of labels.
train_loss = losses.SoftmaxLoss(model=model, sentence_embedding_dimension=model.get_sentence_embedding_dimension(), num_labels=3)
现在我们定义了数据、嵌入模型和损失,可以开始训练我们的模型。我们可以使用fit函数进行训练:
# Train our model for a single epoch
model.fit(train_objectives=[(train_dataloader, train_loss)], epochs=1, warmup_steps=100, show_progress_bar=True)
我们对模型进行单个周期的训练,大约需要一个小时左右,在 V100 GPU 上。就这样!我们现在从头开始训练了自己的嵌入模型。
注意
sentence-transformers 框架允许多任务学习。train_objectives参数接受一个元组列表,这使得可以为其提供不同的数据集,每个数据集都有自己的优化目标。这意味着我们可以将整个 GLUE 基准提供给它进行训练。
我们可以快速评估模型的性能。可以进行很多任务,稍后我们将深入讨论,但一个好的起点是语义文本相似性基准(STSB),它在 GLUE 数据集中,如之前所见。
这是一个通过人工标注,带有相似性评分(1 到 5)的句子对集合。
我们可以利用这个数据集来观察我们的模型在语义相似性任务上的评分表现。首先,我们需要处理 STSB 数据集:
import datasets
sts = datasets.load_dataset('glue', 'stsb', split='validation')
# Make sure every value is between 0 and 1
sts = sts.map(lambda x: {'label': x['label'] / 5.0})
# Process the data to be used from sentence_transformers
samples = [InputExample(texts=[sample['sentence1'], sample['sentence2']],
label=sample['label']) for sample in sts]
我们可以使用这些样本生成一个使用句子转换器的评估器:
from sentence_transformers.evaluation import EmbeddingSimilarityEvaluator
# Create an embedding similarity evaluator for stsb
evaluator = EmbeddingSimilarityEvaluator.from_input_examples(samples)
这个评估器允许我们评估任何模型,因此我们来比较一下训练过的模型与其未训练的变体:
>>> # Evaluate the original model
>>> orig_model = SentenceTransformer('bert-base-uncased')
>>> print("Baseline: ", evaluator(orig_model))
>>>
>>> # Evaluate our trained model
>>> print("Trained model: ", evaluator(model))
"Baseline: 0.6146254081453191"
"Trained model: 0.7376971430125273"
这个训练过程将基线分数从 0.61 提高到了 0.74!
深入评估
一个好的嵌入模型不仅仅是 STSB 基准测试上的好成绩!正如我们之前看到的,GLUE 基准测试有许多任务可以评估我们的嵌入模型。然而,还有许多其他基准可以评估嵌入模型。为了统一这个评估过程,开发了大规模文本嵌入基准(MTEB)⁴。这个 MTEB 涵盖了 8 个嵌入任务,涉及 58 个数据集和 112 种语言。
为了公开比较最新的嵌入模型,创建了一个排行榜,列出了各个嵌入模型在所有任务中的得分。
from mteb import MTEB
# Choose evaluation task
evaluation = MTEB(tasks=["Banking77Classification"])
# Calculate results
results = evaluation.run(model)
当我们检查结果时,可以看到这个任务的一些评估指标:
>>> results
{'Banking77Classification': {'mteb_version': '1.0.2',
'dataset_revision': '0fd18e25b25c072e09e0d92ab615fda904d66300',
'mteb_dataset_name': 'Banking77Classification',
'test': {'accuracy': 0.7825324675324674,
'f1': 0.782082703333302,
'accuracy_stderr': 0.010099229383338676,
'f1_stderr': 0.010381981136492737,
'main_score': 0.7825324675324674,
'evaluation_time': 23.44}}}
这个评估基准的伟大之处不仅在于任务和语言的多样性,还在于评估时间也得到了节省。虽然存在许多嵌入模型,但我们通常希望那些既准确又低延迟的模型。用于语义搜索等任务的嵌入模型,通常会受益于快速推理。
损失函数
我们使用 SoftMaxLoss 训练了我们的模型,以说明如何训练第一个句子转换器模型之一。然而,虽然有很多损失函数可供选择,通常不建议使用 SoftMaxLoss,因为还有其他性能更优的损失。
不必逐一了解所有损失函数,通常有两个损失函数被广泛使用,并且表现良好,即:
-
余弦相似度
-
多重负样本排名损失
注意
可供选择的损失函数远不止于此。例如,MarginMSE 损失在训练或微调交叉编码器时表现出色。在句子转换器框架中实现了许多有趣的损失函数。
余弦相似度
余弦相似度损失是一种直观且易于使用的损失,适用于许多不同的用例和数据集。然而,这种损失通常用于语义文本相似性任务。在这些任务中,文本对会被分配一个相似度评分,以优化模型。
我们假设有句子对,它们在某种程度上是相似或不相似,而不是严格的正或负句对。通常,这个值在 0 和 1 之间,以分别表示不相似和相似(图 7-9)。

图 7-9. 余弦相似度损失旨在最小化语义相似句子之间的余弦距离,并最大化语义不相似句子之间的距离。
余弦相似度损失很简单——它计算两个文本的两个嵌入之间的余弦相似度,并将其与标记的相似度分数进行比较。模型将学习识别句子之间的相似度程度。
使用余弦相似度损失的一个最小示例是:
# Prepare data
sts = datasets.load_dataset('glue', 'stsb', split='train')
sts = sts.map(lambda x: {'label': x['label'] / 5.0})
train_examples = [InputExample(texts=[sample['sentence1'], sample['sentence2']],
label=sample['label']) for sample in sts]
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=32)
# Define model
word_embedding_model = models.Transformer('bert-base-uncased', max_seq_length=256)
pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension())
cosine_model = SentenceTransformer(modules=[word_embedding_model, pooling_model])
# Loss function
train_loss = losses.CosineSimilarityLoss(model=cosine_model)
# Fit model
cosine_model.fit(train_objectives=[(train_dataloader, train_loss)], epochs=1, warmup_steps=100, show_progress_bar=True)
我们在这个例子中使用 STSB 数据集。如我们之前所见,它们是带有注释相似度分数的句子对,自然适用于余弦相似度损失。
>>> # Evaluate trained model with MNR loss
>>> print("Trained model + Cosine Similarity Loss: ", evaluator(mnr_model))
"Trained model + Cosine Similarity Loss: 0.848027994316"
得分 0.848 相比于 SoftMaxLoss 示例有了很大改善。然而,由于我们的训练和评估都在与 SoftMaxLoss 示例相同的任务上,比较它们是不公平的。
多重负面排名损失
多重负面排名(MNR⁵)损失,通常被称为 InfoNCE⁶或 NTXentLoss⁷,是一种原则上仅使用正句对的损失。
例如,你可能会有问题/答案、图像/图像标题、论文标题/论文摘要等的对。这些对的优点在于我们可以确信它们是难得的正对。在 MNR 损失中(图 7-10),负对是通过将一个正对与另一个正对混合而构造的。在论文标题和摘要的例子中,你会通过将论文的标题与完全不同的摘要组合来生成负对。这些负对称为批内负对。

图 7-10. 多重负面排名损失旨在最小化相关文本对之间的距离,例如问题和答案,并最大化无关文本对之间的距离,例如问题和无关答案。
在生成这些正负对之后,我们计算它们的嵌入并应用余弦相似度。这些相似度得分随后用于回答问题,即这些对是负的还是正的?换句话说,这被视为分类任务,我们可以使用交叉熵损失来优化模型。
以下是使用 MNR 损失进行训练的一个最小示例:
# Prepare data and only keep positive pairs
dataset = load_dataset("glue", "mnli", split="train")
dataset = dataset.filter(lambda x: True if x['label'] == 0 else False)
train_examples = [InputExample(texts=[row["premise"], row["hypothesis"]], label=row["label"]) for row in tqdm(dataset)]
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=32)
# Define model
word_embedding_model = models.Transformer('bert-base-uncased', max_seq_length=256)
pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension())
mnr_model = SentenceTransformer(modules=[word_embedding_model, pooling_model])
# Loss function
train_loss = losses.MultipleNegativesRankingLoss(model=mnr_model)
# Fit model
mnr_model.fit(train_objectives=[(train_dataloader, train_loss)], epochs=1, warmup_steps=100, show_progress_bar=True)
请注意,我们使用的数据与我们的 SoftMaxLoss 示例相同。让我们比较这个模型与我们之前训练的模型的性能:
>>> # Evaluate trained model with MNR loss
>>> print("Trained model + MNR Loss: ", evaluator(mnr_model))
"Trained model + MNR Loss: 0.8183052945831789"
与我们之前使用 SoftMaxLoss(0.74)训练的模型相比,我们使用 MNR 损失(0.82)的模型似乎准确得多!
提示
较大的批量大小在 MNR 损失中通常效果更好,因为更大的批量使任务变得更加困难。原因在于模型需要从更大的一组潜在句子对中找到最佳匹配句子。您可以调整代码以尝试不同的批量大小,感受其效果。
我们使用这种损失函数有一个缺点。由于负例是从其他问答对中抽样的,这些批内或“简单”负例可能与问题完全无关。因此,嵌入模型找到问题正确答案的任务变得相对简单。相反,我们希望有与问题非常相关但不是正确答案的负例。这些负例称为困难负例。由于这使得嵌入模型的任务更加困难,因为它必须学习更细致的表示,嵌入模型的性能通常会有显著提升。
注意
在 MNR 损失中,通常使用余弦相似度,但也可以用点积来替代。使用点积的一个优点是它通常对较长文本效果更好,因为点积会增加。然而,缺点是它通常在聚类任务中表现较差。
一个好的困难负例的例子如下。假设我们有这样一个问题:“阿姆斯特丹有多少人居住?”与这个问题相关的答案是:“阿姆斯特丹几乎有一百万人。”为了生成一个好的困难负例,我们理想上希望答案中包含一些关于阿姆斯特丹及其居住人数的信息。例如:“乌特勒支有超过一百万人居住,这比阿姆斯特丹还多。”这个答案与问题无关,但非常相似,因此这是一个好的困难负例。图 7-11 展示了简单负例和困难负例之间的区别。

图 7-11. [negatives.png] 一个简单的负样本通常与问题和答案都没有关系。一个半困难的负样本与问题和答案的主题有些相似,但又稍微不相关。一个困难的负样本与问题非常相似,但通常是错误的答案。
使用困难负样本与此损失函数是相当简单的, вместо передачи двух связанных текстов в InputExample,我们传递三个文本,其中两个是相关文本,最后一个是困难负样本。
微调嵌入模型
在前面的部分,我们了解了从头开始训练嵌入模型的基础知识,并看到了如何利用损失函数进一步优化其性能。这种方法虽然相当强大,但需要从头开始创建嵌入模型。这个过程可能相当昂贵且耗时。
相反,句子变换器有多个预训练的嵌入模型,我们可以将其用作微调的基础。它们是在大量数据上训练的,并且开箱即用时就能产生良好的性能。
微调模型有多种方法,具体取决于数据的可用性和领域。我们将逐一介绍其中的一些方法,并展示利用预训练嵌入模型的优势。
有监督
微调嵌入模型的最简单方法是重复之前训练模型的过程,但将 'bert-base-uncased' 替换为预训练的句子变换器模型。有许多可供选择,但通常 'all-mpnet-base-v2' (www.sbert.net/docs/pretrained_models.html) 在许多用例中表现良好(见 huggingface.co/spaces/mteb/leaderboard)。
实际上,我们只需运行以下命令即可微调我们的模型:
from sentence_transformers import SentenceTransformer
# Prepare data and only keep positive pairs
dataset = load_dataset("glue", "mnli", split="train")
dataset = dataset.filter(lambda x: True if x['label'] == 0 else False)
train_examples = [InputExample(texts=[row["premise"], row["hypothesis"]], label=row["label"]) for row in tqdm(dataset)]
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=32)
# Load a pre-trained model
model = SentenceTransformer('all-mpnet-base-v2')
# Loss function
train_loss = losses.MultipleNegativesRankingLoss(model=model)
# Fine-tune our model for a single epoch
model.fit(train_objectives=[(train_dataloader, train_loss)], epochs=1, warmup_steps=100, show_progress_bar=True)
在这里,我们使用与在多负样本排名损失示例中训练模型时相同的数据。由于句子变换器包构建得相当出色,因此微调模型的代码相对简单。
提示
与其使用预训练的 BERT 模型,如 '``bert``-base-uncased' 或可能是领域外模型如 'all-mpnet-base-v2',你还可以对预训练的 BERT 模型进行掩码语言建模,首先将其适应到你的领域。然后,可以使用这个微调后的 BERT 模型作为训练嵌入模型的基础。这是一种领域适应。关于掩码语言建模的更多信息,请参见第 X 章。
微调/训练模型的主要困难在于找到合适的数据。对于这些模型,我们不仅希望拥有非常大的数据集,数据本身也需要高质量。开发正样本对通常比较简单,但添加困难负样本对则显著增加了创建高质量数据的难度。
注意
如上所示,对数据进行训练有些多余,因为该模型已经在一个非常相似的 NLI 数据集上进行了训练。然而,对于特定领域的数据微调过程仍然是相同的。
增强型 SBERT
训练或微调这些嵌入模型的一个缺点是,它们通常需要大量的训练数据。这些模型中的许多在超过十亿对句子上进行训练。提取如此多的句子对以适应你的用例通常是不可行的,因为在许多情况下,只有很少的数据可用。
幸运的是,有一种方法可以增强你的数据,以便在只有少量标注数据可用时,可以对嵌入模型进行微调。这个过程称为增强型 SBERT。⁸
在此过程中,我们旨在增强少量标注数据,使其可以用于常规训练。它利用慢而更准确的交叉编码器架构(BERT)来增强和标注更大数量的输入对。这些新标注的对随后用于微调双编码器(SBERT)。
如图 7-12 所示,增强型 SBERT 涉及以下步骤:
-
使用小型标注数据集(黄金数据集)对交叉编码器(BERT)进行微调
-
创建新的句子对
-
用微调后的交叉编码器标注新的句子对(银色数据集)
-
在扩展数据集(黄金+银色数据集)上训练双编码器(SBERT)
此处,黄金数据集是一个小型但完全标注的数据集,包含真实的基础真相。银色数据集也完全标注,但不一定是基础真相,因为它是通过交叉编码器的预测生成的。

图 7-12. 增强型 SBERT 通过在小型黄金数据集上训练高性能的交叉编码器来工作。然后,训练好的交叉编码器可以用来标注未标注的数据集,以生成比黄金数据集大得多的银色数据集。最后,黄金和银色数据集都用于训练双编码器。
在进入上述步骤之前,首先准备数据:
# Make sure every value is between 0 and 1
sts = datasets.load_dataset('glue', 'stsb', split='train')
sts = sts.map(lambda x: {'label': x['label'] / 5.0})
# Process the data to be used in sentence_transformers
gold_examples = [InputExample(texts=[sample['sentence1'], sample['sentence2']], label=sample['label']) for sample in sts]
gold_dataloader = DataLoader(gold_examples, shuffle=True, batch_size=32)
# Fully labeled gold dataset
gold = pd.DataFrame({'sentence1': sts['sentence1'], 'sentence2': sts['sentence2'], 'label': sts['label']})
我们使用 STSB 语料库的训练集作为我们的黄金数据集,并用它来训练我们的交叉编码器(步骤 1):
from sentence_transformers.cross_encoder import CrossEncoder
# Train a cross-encoder on the gold dataset
cross_encoder = CrossEncoder('bert-base-uncased', num_labels=1)
cross_encoder.fit(train_dataloader=gold_dataloader, epochs=1, warmup_steps=300)
在训练好我们的交叉编码器后,我们可以通过对每个输入句子随机抽取 10 个句子,生成新的候选句子对(步骤 2):
# Prepare unlabeled to-be silver dataset
silver = pd.DataFrame(columns=["sentence1", "sentence2"])
for sentence in gold.sentence1:
sampled = gold[gold['sentence1'] != sentence].sample(10, random_state=42)
sampled.sentence1 = sentence
silver = pd.concat([silver, sampled], ignore_index=True, axis=0)
silver = silver.drop_duplicates()
提示
我们可以使用预训练的句子转换器,而不是随机抽样银句子对。通过使用语义搜索从数据集中检索前 k 个句子,我们创建的银句子对往往更准确。虽然句子对仍然是基于近似选择的,但比随机抽样要好得多。
我们在第一步训练的交叉编码器可以用于给之前生成的候选句子对标记,以建立银数据集:
# Predict labels for the unlabeled silver data
pairs = list(zip(silver['sentence1'], silver['sentence2']))
silver['label'] = cross_encoder.predict(pairs)
现在我们有了银和金数据集,我们只需将它们结合起来,像之前一样训练我们的嵌入模型:
# Combine gold + silver
data = pd.concat([gold, silver], ignore_index=True, axis=0)
data = data.drop_duplicates(subset=['sentence1', 'sentence2'], keep="first")
# initialize dataloader
examples = [InputExample(texts=[sample['sentence1'], sample['sentence2']], label=sample['label']) for _, sample in data.iterrows()]
dataloader = DataLoader(examples, shuffle=True, batch_size=32)
# Initialize bi-encoder
word_embedding_model = models.Transformer('bert-base-uncased', max_seq_length=256)
pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension())
model = SentenceTransformer(modules=[word_embedding_model, pooling_model])
# Loss function
loss = losses.CosineSimilarityLoss(model=model)
# Fine-tune our model for a single epoch
model.fit(train_objectives=[(dataloader, loss)], epochs=1, warmup_steps=200, show_progress_bar=True)
我们可以再次仅使用金数据集进行上述操作,看看添加这个银数据集如何影响性能。仅在金数据集上的训练性能为 0.804,而添加银数据集后性能提升至 0.830!
这种方法允许在不需要手动标记数十万句子对的情况下增加您现有的数据集。
无监督学习
要创建嵌入模型,通常需要标记数据。然而,并不是所有现实世界的数据集都带有我们可以使用的完整标签。我们会寻找无需预先确定标签的技术进行模型训练——无监督学习。
创建或微调嵌入模型的无监督技术通常表现不如其监督版本。存在许多方法,如简单对比学习句子嵌入(SimCSE)⁹、对比张力(CT)¹⁰、基于变换器的去噪自编码器(TSDAE)¹¹以及生成伪标记(GPL)¹²。
我们将讨论两种方法,TSDAE 和 GPL,甚至可以在后续进行组合。
基于变换器的去噪自编码器
TSDAE 是一种非常优雅的方法,用于创建无监督学习的嵌入模型。该方法假设我们完全没有标记数据,也不需要人为创建标签。
TSDAE 的基本思路是通过从输入句子中去除一定比例的词来添加噪声。这句“受损”的句子经过编码器处理,并在其上方加上一个池化层,以将其映射到句子嵌入。
从这个句子嵌入中,解码器尝试从“受损”句子中重建原始句子,但不包含人为噪声。
这种方法与掩蔽语言建模非常相似,我们试图重建和学习某些被掩蔽的词。这里,我们试图重建的是整个句子,而不是掩蔽的词。
训练后,我们可以使用编码器从文本中生成嵌入,因为解码器仅用于判断嵌入是否能准确重建原始句子(图 7-13)。

图 7-13. TSDAE 随机删除输入句子中的单词,这些句子通过编码器传递以生成句子嵌入。通过这个句子嵌入,重建原始句子。这里的主要思想是,句子嵌入越准确,重建的句子也会越准确。
由于我们只需要一堆没有标签的句子,因此训练这个模型非常简单。我们开始定义模型,方式与之前相同:
# Create your embedding model
word_embedding_model = models.Transformer('bert-base-uncased')
pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension(), 'cls')
model = SentenceTransformer(modules=[word_embedding_model, pooling_model])
注意
请注意,我们使用的是 CLS 池化而不是平均池化。作者发现二者之间几乎没有区别,并且由于平均池化会丢失位置信息,因此选择了 CLS 池化。
接下来,我们使用之前的 STSB 数据集创建一堆句子来用于我们的模型:
from sentence_transformers.datasets import DenoisingAutoEncoderDataset
# Extract training data, we ignore the labels that we have
train_data = datasets.load_dataset('glue', 'stsb', split='validation')["sentence1"]
train_data = train_data["sentence1"] + train_data["sentence2"]
# Add noise to our input data
train_dataset = DenoisingAutoEncoderDataset(list(set(train_data)))
train_dataloader = DataLoader(train_dataset, batch_size=8, shuffle=True)
# Use the denoising auto-encoder loss
train_loss = losses.DenoisingAutoEncoderLoss(model, 'bert-base-uncased', tie_encoder_decoder=True)
这与我们之前进行监督建模的主要区别在于,我们将数据传递给DenoisingAutoEncoderDataset。这将在训练期间为输入生成噪声。我们还使用与嵌入模型相同的基础模型定义了编码器损失。
最后,我们只需拟合和评估我们的模型:
# Train our embedding model
model.fit(train_objectives=[(train_dataloader, train_loss)], epochs=1, show_progress_bar=True)
# Evaluate
evaluator(model)
在拟合我们的模型后,我们得到了 0.74 的分数,这在考虑到我们使用无标签数据进行所有训练的情况下相当令人印象深刻。
领域适应
当可用的标记数据非常少或没有时,通常使用无监督学习来创建文本嵌入模型。然而,无监督技术通常不如监督技术表现良好,并且在学习特定领域的概念时存在困难。
这就是领域适应的作用所在。它的目标是更新现有的嵌入模型,以适应包含与源领域不同主题的特定文本领域。图 7-14 展示了不同领域内容的差异。

图 7-14. 在领域适应中,目标是从一个领域创建并泛化一个嵌入模型到另一个领域。目标领域或外部领域通常包含在源领域或内部领域中找不到的单词和主题。
领域适应的一种方法称为自适应预训练。你首先使用无监督技术(例如之前讨论的 TSDAE 或掩蔽语言模型)对领域特定语料库进行预训练。然后,如图 7-15 所示,你使用目标领域的训练数据集微调该模型。
该过程利用了我们之前看到的管道,首先使用 TSDAE 微调 LLM 或现有嵌入模型,然后使用监督或增强的 SBERT 训练进一步微调。
然而,这可能在计算上是昂贵的,因为我们必须首先在大型语料库上对数据进行预训练,然后使用带标记的数据集进行监督学习。通常,带标记的数据集需要很大,并可能需要数百万对训练样本。

图 7-15. 领域适应可以通过自适应预训练和自适应微调来实现。通过自适应预训练,我们首先对目标领域进行无监督的 LLM 调优,然后在标记的非领域特定数据上微调该模型。通过自适应微调,我们可以使用任何现有的预训练嵌入模型,并使用未标记的领域特定数据进行微调。
相反,我们将使用一种可以在预训练嵌入模型之上运行的方法,即生成伪标注。
生成伪标注
生成伪标注假设尽管我们有数据,但这些数据都没有标记。它包括三个步骤,以生成我们可以用于训练嵌入模型的标记数据。
首先,对于你在特定领域数据中拥有的每一条未标记文本,我们使用生成模型,如 T5,生成若干查询。这些查询通常是可以用输入文本的部分回答的问题。例如,当你的文本是“《可可夜总会》是由皮克斯制作的”时,模型可能会生成类似“谁制作了电影《可可夜总会》?”的查询。这些是我们生成的正例,如图 7-16 所示。

图 7-16. 在 GPL 管道的第一步中,使用生成模型(如 T5)为每个未标记的输入文本生成查询。这些查询可以用作后续 GPL 管道中的句子对进行标记。
其次,我们还需要负样本,以便模型进行学习。理想情况下,负样本与查询相关,但不是相关答案。例如,查询“谁制作了电影可可?”的负样本可能是“狮子王是迪士尼制作的”。
为了提取这些负样本,我们使用预训练的嵌入模型来检索与查询相关的所有文本。在图 7-17 中,第二步展示了如何挖掘相关文本。

图 7-17. 在 GPL 管道的第二步中,使用预训练的嵌入模型挖掘负样本。该模型为输入查询和语料库生成嵌入,并找到与输入查询相关的样本。
第三,在生成负样本后,我们需要对其进行评分。一些负样本可能最终成为实际答案,或者可能与之不相关,而这两者都是我们希望避免的。正如图 7-18 所示,我们可以使用预训练的交叉编码器对通过前两个步骤创建的查询/段落对进行评分。

图 7-18. 在 GPL 管道的第三步中,我们使用交叉编码器对所有查询/段落对进行评分。此过程的目标是过滤掉任何假阴性。一些负样本可能会意外变成正样本。与负样本和正样本的标记过程相比,使用评分过程可以在表示中提供更多的细微差别。
在最后一步后,对于每个查询,我们现在有一个正样本(步骤 1)和一个挖掘出的负样本(步骤 2)。换句话说,我们有可以用于训练过程的三元组。
单独运行每一步确实很麻烦,但幸运的是,有一个 GPL 包可以抽象掉这种困难。然而,我们首先需要格式化我们的数据,以便该包能够读取。
import json
# Convert training data to the right format for GPL
train_data = datasets.load_dataset('glue', 'stsb', split='validation')["sentence1"]
with open('corpus.jsonl', 'w') as jsonl:
for index, sentence in enumerate(train_data):
line = {'_id': str(index), 'title': "", 'text': sentence, 'metadata': ""}
jsonl.write(json.dumps(line)+'\n')
我们再次使用 STSB 数据集,仅提取少量数据进行训练。这样做的原因是训练仍然可能相当昂贵。GPL 的作者在 V100 GPU 上训练了大约一天,因此建议在扩大规模之前先尝试较小的数据集。
接下来,我们可以导入 GPL,只需运行 train 即可开始:
import gpl
# Train an embedding model with GPL
gpl.train(
path_to_generated_data=".",
batch_size_gpl=8,
batch_size_generation=8,
gpl_steps=10000,
queries_per_passage=1,
output_dir="output",
do_evaluation=False,
use_amp=True
# The model that we will fine-tune as our embedding model
base_ckpt="distilbert-base-uncased",
# The model used to generate queries
generator="BeIR/query-gen-msmarco-t5-base-v1",
# The model used for mining negatives
retrievers=["msmarco-distilbert-base-v3", "msmarco-MiniLM-L-6-v3"],
# The model used for rating query/passage pairs
cross_encoder="cross-encoder/ms-marco-MiniLM-L-6-v2",
)
有一些值得注意的子模型,描述了我们之前所看到的步骤。
-
generator指的是 步骤 1,在此我们为我们的数据生成查询。 -
retriever指的是 步骤 2,在此我们为我们的数据生成查询。 -
cross_encoder指的是 步骤 3,在此我们为我们的数据生成查询。 -
base_ckpt指的是训练我们嵌入模型的最后一步。
训练后,我们可以按如下方式加载和评估模型:
>>> # Load our new model and evaluate it
>>> model = SentenceTransformer('output')
>>> evaluator(model)
0.8246360833250379
借助 GPL,我们在没有任何标注数据的情况下取得了 0.82 的分数!
摘要
在本章中,我们探讨了通过对比学习创建和微调嵌入模型,这是训练此类模型最重要的组成部分之一。通过无监督和有监督技术,我们能够创建针对我们数据集调优的嵌入模型。
¹ Alan Garfinkel. “解释的形式:重新思考社会理论中的问题。” (1981)。
² Tim Miller. “对比解释:结构模型方法”。知识工程评论 36 (2021): e14。
³ Jeffrey Pennington, Richard, Socher, 和 Christopher D, Manning. “Glove: 全局词向量表示。” 收录于 2014 年自然语言处理实证方法会议论文集 (EMNLP) (pp. 1532–1543). 2014。
⁴ Muennighoff, Niklas, Nouamane, Tazi, Loïc, Magne, 和 Nils, Reimers. “MTEB: 大规模文本嵌入基准”。arXiv 预印本 arXiv:2210.07316 (2022)。
⁵ Matthew Henderson, Rami, Al-Rfou, Brian, Strope, Yun-Hsuan, Sung, László, Lukacs, Ruiqi, Guo, Sanjiv, Kumar, Balint, Miklos, 和 Ray, Kurzweil. “智能回复的高效自然语言响应建议。” arXiv 预印本 arXiv:1705.00652 (2017)。
⁶ Oord, Aaron van den, Yazhe, Li, 和 Oriol, Vinyals. “使用对比预测编码的表示学习”。arXiv 预印本 arXiv:1807.03748 (2018)。
⁷ Ting Chn, Simon, Kornblith, Mohammad, Norouzi, 和 Geoffrey, Hinton. “用于对比学习视觉表示的简单框架。” 收录于 国际机器学习会议 (pp. 1597–1607). 2020。
⁸ Thakur, Nandan, Nils, Reimers, Johannes, Daxenberger, 和 Iryna, Gurevych. “增强的 sbert: 提升双编码器用于成对句子评分任务的数据增强方法”。arXiv 预印本 arXiv:2010.08240 (2020)。
⁹ Gao, Tianyu, Xingcheng Yao 和 Danqi Chen. “Simcse: 简单对比学习句子嵌入”。arXiv 预印本 arXiv:2104.08821 (2021)。
¹⁰ Janson, Sverker, Evangelina Gogoulou, Erik Ylipäa, Amaru Cuba Gyllensten 和 Magnus Sahlgren. “语义重调与对比张力。” 在 国际学习表征会议, 2021。2021。
¹¹ Kexin Wang, Nils Reimers 和 Iryna Gurevych. “Tsdae: 使用基于变换器的顺序去噪自编码器进行无监督句子嵌入学习”。arXiv 预印本 arXiv:2104.06979 (2021)。
¹² Kexin Wang, Nandan Thakur, Nils Reimers 和 Iryna Gurevych. “Gpl: 生成伪标签用于无监督领域适应的密集检索”。arXiv 预印本 arXiv:2112.07577 (2021)。
作者介绍
Jay Alammar 是 Cohere 的总监和工程院士(领先的语言模型 API 提供商)。在这个角色中,他为企业和开发者社区提供建议和教育,指导他们将语言模型应用于实际案例。通过他受欢迎的 AI/ML 博客,Jay 帮助数百万研究人员和工程师从基础(最终进入 NumPy 和 pandas 等包的文档)到前沿(变换器、BERT、GPT-3、Stable Diffusion)直观理解机器学习工具和概念。Jay 还是 Deeplearning.ai 和 Udacity 上受欢迎的机器学习和自然语言处理课程的共同创作者。
Maarten Grootendorst 是 IKNL(荷兰综合癌症组织)的高级临床数据科学家。他拥有组织心理学、临床心理学和数据科学的硕士学位,利用这些知识向广泛受众传达复杂的机器学习概念。通过他受欢迎的博客,他通过心理学视角解释人工智能的基本原理,触及了数百万读者。他是多个开源包的作者和维护者,这些包依赖于大型语言模型的强大功能,如 BERTopic、PolyFuzz 和 KeyBERT。他的包被全球的数据专业人士和组织下载数百万次。


浙公网安备 33010602011771号