Chapter 6: Planning | 第六章:规划

Intelligent behavior often involves more than just reacting to the immediate input. It requires foresight, breaking down complex tasks into smaller, manageable steps, and strategizing how to achieve a desired outcome. This is where the Planning pattern comes into play. At its core, planning is the ability for an agent or a system of agents to formulate a sequence of actions to move from an initial state towards a goal state.

阅读全文 »

Chapter 5: Tool Use (Function Calling) | 第五章:工具使用(函数调用)

Tool Use Pattern Overview | 工具使用模式概述

So far, we've discussed agentic patterns that primarily involve orchestrating interactions between language models and managing the flow of information within the agent's internal workflow (Chaining, Routing, Parallelization, Reflection). However, for agents to be truly useful and interact with the real world or external systems, they need the ability to use Tools.

阅读全文 »

Chapter 4: Reflection | 第四章:反思

Reflection Pattern Overview | 反思模式概述

In the preceding chapters, we've explored fundamental agentic patterns: Chaining for sequential execution, Routing for dynamic path selection, and Parallelization for concurrent task execution. These patterns enable agents to perform complex tasks more efficiently and flexibly. However, even with sophisticated workflows, an agent's initial output or plan might not be optimal, accurate, or complete. This is where the Reflection pattern comes into play.

阅读全文 »

Chapter 3: Parallelization | 第三章:并行化

Parallelization Pattern Overview | 并行模式概述

In the previous chapters, we've explored Prompt Chaining for sequential workflows and Routing for dynamic decision-making and transitions between different paths. While these patterns are essential, many complex agentic tasks involve multiple sub-tasks that can be executed simultaneously rather than one after another. This is where the Parallelization pattern becomes crucial.

在前面的章节中,我们探讨了用于顺序工作流的提示链以及用于智能决策的路由模式。虽然这些模式很重要,但许多复杂的智能体任务需要同时执行多个子任务,而非一个接一个地执行。这时并行模式就变得至关重要。

阅读全文 »

Chapter 2: Routing | 第二章:路由

Routing Pattern Overview | 路由模式概述

While sequential processing via prompt chaining is a foundational technique for executing deterministic, linear workflows with language models, its applicability is limited in scenarios requiring adaptive responses. Real-world agentic systems must often arbitrate between multiple potential actions based on contingent factors, such as the state of the environment, user input, or the outcome of a preceding operation. This capacity for dynamic decision-making, which governs the flow of control to different specialized functions, tools, or sub-processes, is achieved through a mechanism known as routing.

提示链虽然是执行确定性线性工作流的基础方法,但在需要自适应响应的场景下显得力不从心。现实场景中,智能体系统往往要根据环境状态、用户输入或上一步的执行结果等情境信息,从多个可选方案中选择合适的行动路径。路由(Routing)机制就是实现这种控制流分发的关键技术,它决定该将请求交给哪个功能模块、工具或子流程处理。

阅读全文 »

Chapter 1: Prompt Chaining | 第一章:提示链

Prompt Chaining Pattern Overview | 提示链模式概述

Prompt chaining, sometimes referred to as Pipeline pattern, represents a powerful paradigm for handling intricate tasks when leveraging large language models (LLMs). Rather than expecting an LLM to solve a complex problem in a single, monolithic step, prompt chaining advocates for a divide-and-conquer strategy. The core idea is to break down the original, daunting problem into a sequence of smaller, more manageable sub-problems. Each sub-problem is addressed individually through a specifically designed prompt, and the output generated from one prompt is strategically fed as input into the subsequent prompt in the chain.

提示链模式,也称为「管道模式」,是利用大语言模型处理复杂任务的一种强大范式。它不期望用单一步骤解决复杂问题,而是采用「分而治之」策略。其核心思想是将难题拆解为一系列更小、更易管理的子问题。每个子问题通过专门设计的提示独立解决,前一步的输出传递给下一步作为输入。

阅读全文 »

Agentic Design Patterns | 智能体设计模式

A Hands-On Guide to Building Intelligent Systems | 构建智能系统的实践指南

Antonio Gulli


Table of Contents | 目录

总页数:424 页

Dedication | 献辞

Acknowledgment | 致谢

Foreword | 序言

A Thought Leader's Perspective: Power and Responsibility | 思想领袖的观点:权力与责任

Introduction | 介绍

What makes an AI system an "agent"? | 是什么让 AI 系统成为「智能体」?

阅读全文 »

\(\text{12}\) 生成模型的微调

Fine-Tuning Generation Models

在本章中,我们将采用一个预训练文本生成模型,并介绍微调它的过程。这一微调步骤是产生高质量模型的关键,也是我们工具箱中用于使模型适应特定期望行为的重要工具。微调使我们能够将模型适应特定的数据集或领域

在本章中,我们将指导您了解微调文本生成模型最常用的两种方法有监督微调 (\(\text{supervised fine-tuning}\)) 和偏好调整 (\(\text{preference tuning}\))。我们将探索微调预训练文本生成模型的变革潜力,使其成为您应用程序的更有效工具。

大语言模型 (\(\text{LLM}\)) 的三个训练步骤:预训练、有监督微调和偏好调整

The Three LLM Training Steps: Pretraining, Supervised Fine-Tuning, and Preference Tuning

创建高质量大语言模型 (\(\text{LLM}\)) 通常有三个常见步骤

  1. 语言建模

    \(\text{Language modeling}\)

    创建高质量 \(\text{LLM}\)第一步是使用一个或多个海量文本数据集对其进行预训练(图 \(\text{12}-1\))。在训练过程中,它尝试预测下一个词元 (\(\text{token}\)),以准确学习文本中发现的语言和语义表征。正如我们在第 \(\text{3}\) 章和第 \(\text{11}\) 章中看到的那样,这被称为语言建模,是一种自监督方法

    F12.1

这会产生一个基础模型 (\(\text{base model}\)),通常也称为预训练模型 (\(\text{pretrained}\)) 或基础模型 (\(\text{foundation model}\))。基础模型是训练过程中的一个关键产物,但终端用户处理起来比较困难。这就是为什么下一步很重要

  1. 微调 \(\text{1}\) (有监督微调)

    \(\text{Fine-tuning 1 (supervised fine-tuning)}\)

    如果 \(\text{LLM}\)指令反应良好并尝试遵循它们,它们就会更有用。当人类要求模型写一篇文章时,他们期望模型生成文章,而不是列出其他指令(这可能是基础模型会做的事情)。

    通过有监督微调 (\(\text{SFT}\)),我们可以使基础模型适应遵循指令。在此微调过程中,基础模型的参数会更新,使其更符合我们的目标任务,例如遵循指令。与预训练模型一样,它也是使用下一个词元预测进行训练,但它不是仅预测下一个词元,而是基于用户输入进行预测(图 \(\text{12}-2\))。

F12.1

\(\text{SFT}\) 也可用于其他任务,例如分类,但通常用于将基础生成模型转换为指令(或聊天)生成模型

  1. 微调 \(\text{2}\) (偏好调整)

    \(\text{Fine-tuning 2 (preference tuning)}\)

    最后一步进一步提高模型的质量,使其更符合 \(\text{AI}\) 安全或人类偏好的预期行为。这被称为偏好调整 (\(\text{preference tuning}\))。偏好调整是微调的一种形式,顾名思义,它使模型的输出与我们的偏好对齐,这些偏好由我们提供给它的数据定义。与 \(\text{SFT}\) 一样,它可以改进原始模型,但增加了在训练过程中提炼输出偏好的好处。这三个步骤如图 \(\text{12}-3\) 所示,展示了从一个未经训练的架构开始到以一个经过偏好调整的 \(\text{LLM}\) 结束的整个过程。

F12.1

在本章中,我们使用一个已经在海量数据集上训练过的基础模型,并探索如何使用这两种微调策略对其进行微调。对于每种方法,我们都从理论基础开始,然后将其应用于实践

有监督微调

Supervised Fine-Tuning (SFT)

大型数据集预训练模型的目的是使其能够再现语言及其含义。在这个过程中,模型学习完成输入短语,如图 \(\text{12}-4\) 所示。

F12.1

这个例子也说明模型没有经过遵循指令的训练,而是会尝试完成一个问题而不是回答它(图 \(\text{12}-5\))。

F12.1

我们可以使用这个基础模型,并通过微调将其适应某些用例,例如遵循指令

完全微调

Full Fine-Tuning

最常见的微调过程是完全微调 (\(\text{full fine-tuning}\))。与预训练 \(\text{LLM}\) 一样,这个过程涉及更新模型的所有参数,使其与您的目标任务保持一致。主要的区别在于我们现在使用更小但有标签的数据集,而预训练过程是在没有标签的大型数据集上完成的(图 \(\text{12}-6\))。

F12.1

您可以将任何有标签的数据用于完全微调,这也使其成为学习特定领域表征的绝佳技术。为了让我们的 \(\text{LLM}\) 遵循指令,我们将需要问题-回答数据。如图 \(\text{12}-7\) 所示,这些数据是用户查询及其对应的答案

F12.1

完全微调过程中,模型接收输入(指令),并对输出(回答)应用下一个词元预测。反过来,它将遵循指令,而不是生成新的问题。

参数高效微调

Parameter-Efficient Fine-Tuning (PEFT)

更新模型的所有参数具有提高其性能的巨大潜力,但也伴随着几个缺点。它训练成本高昂训练时间缓慢,并且需要大量的存储空间。为了解决这些问题,人们将注意力转向了参数高效微调 (\(\text{PEFT}\)) 替代方案,这些方案专注于以更高的计算效率微调预训练模型

适配器

Adapters

适配器是许多基于 \(\text{PEFT}\) 的技术的核心组件。该方法提出了 \(\text{Transformer}\) 内部的一组附加的模块化组件,可以对其进行微调提高模型在特定任务上的性能,而无需微调所有模型权重。这节省了大量的时间和计算资源。

适配器在论文《Parameter-efficient transfer learning for NLP》中得到了描述,该论文表明,仅微调 \(\text{BERT}\) \(\text{3.6\%}\) 的参数即可在任务上产生与微调所有模型权重相当的性能。在 \(\text{GLUE}\) 基准测试中,作者表明他们的性能达到了完全微调性能的 \(\text{0.4\%}\) 以内。在一\(\text{Transformer}\)中,该论文提出的架构将适配器放置在注意力层前馈神经网络之后,如图 \(\text{12}-8\) 所示。

F12.1

然而,仅更改一个 \(\text{Transformer}\) 块是不够的,因此这些组件是模型中每个块的一部分,如图 \(\text{12}-9\) 所示。

F12.1

像这样查看模型中所有的适配器组件,使我们能够看到单个适配器,如图 \(\text{12}-10\) 所示,它是跨越模型所有块的这些组件的集合适配器 \(\text{1}\) 可以是医学文本分类的专家,而适配器 \(\text{2}\) 可以专注于命名实体识别 (\(\text{NER}\))。您可以从 https://oreil.ly/XraXg 下载专业适配器。

F12.1

论文《AdapterHub: A framework for adapting transformers》引入了 \(\text{Adapter Hub}\) 作为共享适配器的中央存储库。许多早期的适配器更侧重于 \(\text{BERT}\) 架构。最近,这个概念已被应用于文本生成 \(\text{Transformer}\) 模型中,例如论文《LLaMA-Adapter: Efficient fine-tuning of language models with zero-init attention》

低秩适应

Low-Rank Adaptation (LoRA)

作为适配器的一种替代方案,低秩适应 (\(\text{LoRA}\)) 被引入,并且在撰写本文时是一种广泛使用且有效\(\text{PEFT}\) 技术。\(\text{LoRA}\) 是一种(像适配器一样)只需要更新一小组参数的技术。如图 \(\text{12}-11\) 所示,它创建了基础模型的一个小子集进行微调,而不是向模型添加层

F12.1

适配器 (\(\text{adapters}\)) 类似,这个子集使得微调快得多,因为我们只需要更新基础模型的一小部分。我们通过用较小的矩阵近似伴随原始 \(\text{LLM}\)大矩阵,从而创建这个参数子集。然后,我们可以使用这些较小的矩阵作为替代品,并微调它们而不是原始的大矩阵。以我们在图 \(\text{12}-12\) 中看到的 \(\text{10} \times \text{10}\) 矩阵为例。

F12.1

我们可以设计出两个较小的矩阵,当它们相乘时,可以重构一个 \(\text{10} \times \text{10}\) 矩阵。这是一个主要的效率胜利,因为我们现在只有 \(\text{20}\) 个权重 (\(\text{10}\)\(\text{10}\)),而不是使用 \(\text{100}\) 个权重 (\(\text{10}\) 乘以 \(\text{10}\)),如图 \(\text{12}-13\) 所示。

F12.1

在训练过程中,我们只需要更新这些较小的矩阵,而不需要更新完整的权重变化。然后,更新后的变化矩阵(较小的矩阵)会与完整的(冻结的)权重结合,如图 \(\text{12}-14\) 所示。

F12.1

但您可能会怀疑性能会下降。您猜对了。但这种权衡在什么地方是合理的呢?

《Intrinsic dimensionality explains the effectiveness of language model fine-tuning》这样的论文表明,语言模型“具有非常低的内在维度。”这意味着我们可以找到小的秩 (\(\text{ranks}\)) 来近似 \(\text{LLM}\)即使是巨大的矩阵。例如,像 \(\text{GPT-3}\) 这样的 \(\text{175B}\) 模型,在其 \(\text{96}\)\(\text{Transformer}\)中的每个块中都会有一个 \(\text{12,288} \times \text{12,288}\) 的权重矩阵。这是 \(\text{1.5}\) 亿个参数。如果我们可以成功地将该矩阵适应到秩 \(\text{8}\),那么每个块将只需要两个 \(\text{12,288} \times \text{8}\) 的矩阵,从而产生 \(\text{197K}\) 个参数。正如之前引用的 \(\text{LoRA}\) 论文中进一步解释的那样,这在速度存储计算方面节省了大量资源。

这种较小的表征非常灵活,您可以选择基础模型的哪些部分进行微调。例如,我们可以只微调每个 \(\text{Transformer}\) 层中的 \(\text{Query}\)\(\text{Value}\) 权重矩阵

压缩模型以实现(更)高效的训练

Compressing the model for (more) efficient training

我们可以通过在将模型的原始权重投影到较小的矩阵之前减少其内存需求,从而使 \(\text{LoRA}\) 更加高效\(\text{LLM}\) 的权重是具有给定精度的数值,可以用浮点数(如 \(\text{float64}\)\(\text{float32}\)来表示。如图 \(\text{12}-15\) 所示,如果我们降低表示一个值的位数,我们得到的结果准确性就会降低。然而,如果降低位数,我们也会降低该模型的内存需求

F12.1

通过量化 (\(\text{quantization}\)),我们的目标是在准确表示原始权重值的同时降低位数。然而,如图 \(\text{12}-16\) 所示,当直接将更高精度值映射到更低精度值时,多个更高精度值最终可能被同一个更低精度值表示

F12.1

相反,\(\text{LoRA}\) 的量化版本 \(\text{QLoRA}\) 的作者找到了一种方法,可以在不与原始权重产生太大差异的情况下,从较高的位数转换到较低的值,反之亦然。

他们使用了块状量化 (\(\text{blockwise quantization}\)) 将某些块的较高精度值映射到较低精度值。不是直接将较高精度映射到较低精度值,而是创建额外的块允许量化相似的权重。如图 \(\text{12}-17\) 所示,这使得值可以用较低的精度准确表示

F12.1

神经网络的一个很好的特性是它们的值通常呈正态分布\(\text{-1}\)\(\text{1}\) 之间。正如\(\text{12}-18\) 所示,这种特性允许我们根据权重的相对密度将原始权重分箱到较低的位数权重之间的映射效率更高,因为它考虑了权重的相对频率。这也减少了异常值的问题

F12.1

结合块状量化 (\(\text{blockwise quantization}\)),这种归一化过程允许低精度值准确表示高精度值,而大语言模型的性能只会略有下降。因此,我们可以从 \(\text{16}\) 位浮点表示转换为区区 \(\text{4}\) 位归一化浮点表示\(\text{4}\) 位表示可以显著减少 \(\text{LLM}\) 在训练期间的内存需求。请注意,\(\text{LLM}\) 的量化通常也有助于推理,因为量化后的 \(\text{LLM}\) 体积更小,因此需要的 \(\text{VRAM}\) 也更少

还有更多优雅的方法可以进一步优化这一点,例如双重量化 (\(\text{double quantization}\)) 和分页优化器 (\(\text{paged optimizers}\)),您可以在前面讨论的 \(\text{QLoRA}\) 论文中阅读更多相关内容。如需完整的、高度可视化的量化指南,请参阅这篇博客文章

使用 \(\text{QLoRA}\) 进行指令调整

Instruction Tuning with QLoRA

现在我们已经探索了 \(\text{QLoRA}\) 的工作原理,让我们将这些知识付诸实践!在本节中,我们将微调 \(\text{Llama}\) 的一个完全开源和更小版本 \(\text{TinyLlama}\),以使用 \(\text{QLoRA}\) 流程遵循指令。将该模型视为一个基础或预训练模型,它经过语言建模训练,但尚不能遵循指令

模板化指令数据

Templating Instruction Data

为了让 \(\text{LLM}\) 遵循指令,我们需要准备遵循聊天模板的指令数据。正如\(\text{12}-19\) 所示,这个聊天模板区分了 \(\text{LLM}\) 生成的内容用户生成的内容

F12.1

我们选择在整个示例中使用这个聊天模板,因为 \(\text{TinyLlama}\) 的聊天版本使用了相同的格式。我们使用的数据是 \(\text{UltraChat}\) 数据集的一个小规模子集。该数据集是原始 \(\text{UltraChat}\) 数据集的过滤版本,其中包含\(\text{200,000}\) 个用户和 \(\text{LLM}\) 之间的对话

我们创建一个名为 \(\text{format\_prompt}\) 的函数,以确保对话遵循此模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from transformers import AutoTokenizer
from datasets import load_dataset
# Load a tokenizer to use its chat template
template_tokenizer = AutoTokenizer.from_pretrained(
"TinyLlama/TinyLlama-1.1BChat-v1.0"
)
def format_prompt(example):
"""Format the prompt to using the <|user|> template TinyLLama is using"""
# Format answers
chat = example["messages"]
prompt = template_tokenizer.apply_chat_template(chat, tokenize=False)
return {"text": prompt}
# Load and format the data using the template TinyLLama is using
dataset = (
load_dataset("HuggingFaceH4/ultrachat_200k", split="test_sft")
.shuffle(seed=42)
.select(range(3_000))
)
dataset = dataset.map(format_prompt)

我们选择了 \(\text{3,000}\) 个文档的子集减少训练时间,但您可以增加这个值以获得更准确的结果

使用 \(\text{"text"}\),我们可以探索这些格式化的提示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Example of formatted prompt
print(dataset["text"][2576])
<|user|>
Given the text: Knock, knock. Who's there? Hike.
Can you continue the joke based on the given text material "Knock, knock.
Who's there? Hike"?</s>
<|assistant|>
Sure! Knock, knock. Who's there? Hike. Hike who? Hike up your pants, it's cold
outside!</s>
<|user|>

Can you tell me another knock-knock joke based on the same text material
"Knock, knock. Who's there? Hike"?</s>
<|assistant|>
Of course! Knock, knock. Who's there? Hike. Hike who? Hike your way over here
and let's go for a walk!</s>

模型量化

Model Quantization

现在我们有了数据,就可以开始加载我们的模型了。这就是我们应用 \(\text{QLoRA}\) 中的 \(\text{Q}\),即量化 (\(\text{quantization}\)) 的地方。我们使用 \(\text{bitsandbytes}\)将预训练模型压缩为 \(\text{4}\) 位表示

\(\text{BitsAndBytesConfig}\) 中,您可以定义量化方案。我们遵循原始 \(\text{QLoRA}\) 论文中使用的步骤,以 \(\text{4}\)加载模型(\(\text{load\_in\_4bit}\)),使用归一化浮点表示\(\text{bnb\_4bit\_quant\_type}\))和双重量化\(\text{bnb\_4bit\_use\_double\_quant}\)):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
model_name = "TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T"
# 4-bit quantization configuration - Q in QLoRA
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # Use 4-bit precision model loading
bnb_4bit_quant_type="nf4", # Quantization type
bnb_4bit_compute_dtype="float16", # Compute dtype
bnb_4bit_use_double_quant=True, # Apply nested quantization
)
# Load the model to train on the GPU
model = AutoModelForCausalLM.from_pretrained(
model_name,
device_map="auto",
# Leave this out for regular SFT
quantization_config=bnb_config,
)
model.config.use_cache = False
model.config.pretraining_tp = 1
# Load LLaMA tokenizer
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = "<PAD>"
tokenizer.padding_side = "left"

这种量化过程使我们能够减小原始模型的尺寸,同时保留大部分原始权重的精度。现在加载模型只需要 \(\sim 1 GB\) 的显存 (\(\text{VRAM}\)),而不进行量化则需要 \(\sim 4 GB\) 的显存。请注意,在微调期间,将需要更多的显存,因此它不会限制在加载模型所需的 \(\sim 1 GB\) 显存上。

\(\text{LoRA}\) 配置

LoRA Configuration

接下来,我们需要使用 \(\text{peft}\)来定义我们的 \(\text{LoRA}\) 配置,它代表了微调过程的超参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from peft import LoraConfig, prepare_model_for_kbit_training, get_peft_model
# Prepare LoRA Configuration
peft_config = LoraConfig(
lora_alpha=32, # LoRA Scaling
lora_dropout=0.1, # Dropout for LoRA Layers
r=64, # Rank
bias="none",
task_type="CAUSAL_LM",
target_modules= # Layers to target
["k_proj", "gate_proj", "v_proj", "up_proj", "q_proj", "o_proj",
"down_proj"]
)
# Prepare model for training
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, peft_config)

有几个参数值得一提:

  • \(\text{r}\) 这是压缩矩阵的秩(回想图 \(\text{12}-13\) 中的内容)。增加这个值也会增加压缩矩阵的大小,从而降低压缩率,进而提高代表能力。该值通常在 \(\text{4}\)\(\text{64}\) 之间。
  • \(\text{lora\_alpha}\) 控制添加到原始权重的变化量。本质上,它平衡了原始模型的知识与新任务的知识。一个经验法则是选择 \(\text{r}\) 两倍大小的值
  • \(\text{target\_modules}\) 控制要针对哪些层\(\text{LoRA}\) 过程可以选择忽略特定层,例如特定的投影层。这可以加快训练速度,但会降低性能,反之亦然。

尝试调整这些参数是一个有价值的实验,可以直观地理解哪些值有效,哪些值无效。您可以在 \(\text{Sebastian Raschka}\)\(\text{Ahead of AI}\) 时事通讯中找到关于 \(\text{LoRA}\) 微调的额外技巧的精彩资源。

本示例展示了高效微调模型的形式。如果您想进行完全微调 (\(\text{full fine-tuning}\)),您可以在加载模型时移除 \(\text{quantization\_config}\) 参数,并跳过 \(\text{peft\_config}\) 的创建。通过移除这些内容,我们将从“使用 \(\text{QLoRA}\) 进行指令调整”转变为“完全指令调整” (\(\text{full instruction tuning}\))。

训练配置

Training Configuration

最后,我们需要像\(\text{11}\)中那样配置我们的训练参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from transformers import TrainingArguments
output_dir = "./results"
# Training arguments
training_arguments = TrainingArguments(
output_dir=output_dir,
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
optim="paged_adamw_32bit",
learning_rate=2e-4,
lr_scheduler_type="cosine",
num_train_epochs=1,
logging_steps=10,
fp16=True,
gradient_checkpointing=True
)

有几个参数值得一提:

  • \(\text{num\_train\_epochs}\) 总训练轮数。较高的值往往会降低性能,因此我们通常倾向于保持较低
  • \(\text{learning\_rate}\) 决定了权重更新的每一步的大小\(\text{QLoRA}\) 的作者发现较高的学习率更适用于更大的模型\(>\) \(\text{33B}\) 参数)。
  • \(\text{lr\_scheduler\_type}\) 一种基于余弦的调度器,用于动态调整学习率。它将从零开始线性增加学习率,直到达到设定的值。之后,学习率将遵循余弦函数的值进行衰减
  • \(\text{optim}\) 原始 \(\text{QLoRA}\) 论文中使用的分页优化器 (\(\text{paged optimizers}\))。

优化这些参数是一项困难的任务,并且没有固定的指导方针。它需要进行实验才能找出最适合特定数据集、模型大小和目标任务的方案

尽管本节描述的是指令调整 (\(\text{instruction tuning}\)),我们也可以使用 \(\text{QLoRA}\) 来微调一个指令模型。例如,我们可以微调一个聊天模型来生成特定的 \(\text{SQL}\) 代码,或创建遵循特定格式的 \(\text{JSON}\) 输出。只要您有可用的数据(带有适当的查询-回答项),\(\text{QLoRA}\) 就是一种极好的技术,可以推动现有聊天模型更适合您的用例

训练

Training

现在我们已经准备好所有的模型和参数,就可以开始微调我们的模型了。我们加载 \(\text{SFTTrainer}\) 并简单地运行 \(\text{trainer.train()}\)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from trl import SFTTrainer
# Set supervised fine-tuning parameters
trainer = SFTTrainer(
model=model,
train_dataset=dataset,
dataset_text_field="text",
tokenizer=tokenizer,
args=training_arguments,
max_seq_length=512,
# Leave this out for regular SFT
peft_config=peft_config,
)
# Train model
trainer.train()
# Save QLoRA weights
trainer.model.save_pretrained("TinyLlama-1.1B-qlora")

在训练过程中,损失将根据 \(\text{logging\_steps}\) 参数\(\text{10}\) 步打印一次。如果您使用的是 \(\text{Google Colab}\) 提供的免费 \(\text{GPU}\)(在撰写本文时是 \(\text{Tesla T4}\)),那么训练可能需要长达一个小时。是时候休息一下了!

合并权重

Merge Weights

在训练完我们的 \(\text{QLoRA}\) 权重后,我们仍然需要将它们与原始权重结合起来才能使用。我们\(\text{16}\)(而不是量化后的 \(\text{4}\) 位)重新加载模型以合并权重。尽管 \(\text{tokenizer}\) 在训练期间没有更新,但我们将其保存到与模型相同的文件夹中,以便于访问:

1
2
3
4
5
6
7
8
from peft import AutoPeftModelForCausalLM
model = AutoPeftModelForCausalLM.from_pretrained(
"TinyLlama-1.1B-qlora",
low_cpu_mem_usage=True,
device_map="auto",
)
# Merge LoRA and base model
merged_model = model.merge_and_unload()

适配器与基础模型合并后,我们就可以使用我们之前定义的提示模板来使用它:

1
2
3
4
5
6
7
8
9
from transformers import pipeline
# Use our predefined prompt template
prompt = """<|user|>
Tell me something about Large Language Models.</s>
<|assistant|>
"""
# Run our instruction-tuned model
pipe = pipeline(task="text-generation", model=merged_model, tokenizer=tokenizer)
print(pipe(prompt)[0]["generated_text"])
1
2
3
Large Language Models (LLMs) are artificial intelligence (AI) models that
learn language and understand what it means to say things in a particular
language. They are trained on huge amounts of text…

汇总输出显示模型现在密切遵循我们的指令,这在基础模型中是不可能的。

评估生成模型

Evaluating Generative Models

评估生成模型提出了一个重大挑战。生成模型用于许多不同的用例,这使得依赖单一指标进行判断成为一个挑战。与更专业的模型不同,生成模型解决数学问题的能力不保证在解决编码问题上也能成功。

与此同时,评估这些模型至关重要,尤其是在一致性很重要的生产环境中。鉴于它们的概率性质,生成模型不一定会产生一致的输出;因此需要稳健的评估

在本节中,我们将探索几种常见的评估方法,但我们想要强调当前缺乏黄金标准没有一个指标适用于所有用例的完美衡量标准

词汇级别指标

Word-Level Metrics

一种常见的比较生成模型的指标类别词汇级别评估 (\(\text{word-level evaluation}\))。这些经典技术词元(集)级别比较参考数据集和生成的词元。常见的词汇级别指标包括困惑度 (\(\text{perplexity}\))、\(\text{ROUGE}\)\(\text{BLEU}\)\(\text{BERTScore}\)

值得注意的是困惑度,它衡量语言模型预测文本的准确程度。给定输入文本,模型预测下一个词元的可能性。使用困惑度,我们假设如果模型赋予下一个词元高概率,则性能更好。换句话说,当呈现一篇写得好的文档时,模型不应该感到“困惑”

如图 \(\text{12}-20\) 所示,当呈现输入 “\(\text{When a measure becomes a}\)” 时,模型被问及“\(\text{target}\)” 作为下一个词的可能性有多大。

F12.1

尽管困惑度以及其他词汇级别指标理解模型置信度的有用指标,但它们并非完美的衡量标准。它们没有考虑到生成文本的一致性、流畅性、创造性,甚至正确性

基准测试

Benchmarks

评估生成模型语言生成和理解任务上性能的一种常用方法是使用众所周知的公共基准测试 (\(\text{benchmarks}\)),例如 \(\text{MMLU}\)\(\text{GLUE}\)\(\text{TruthfulQA}\)\(\text{GSM8k}\)\(\text{HellaSwag}\)。这些基准测试为我们提供了基础语言理解以及复杂分析回答(例如数学问题)的信息

除了自然语言任务,一些模型专注于其他领域,例如编程。这些模型通常在不同的基准测试上进行评估,例如 \(\text{HumanEval}\),它包含具有挑战性的编程任务供模型解决。\(\text{12}-1\) 概述了生成模型的常见公共基准测试

F12.1

基准测试了解模型在各种任务上表现如何的好方法。公共基准测试的一个缺点是,模型可能会过度拟合 (\(\text{overfitted}\)) 这些基准测试以生成最佳回复。此外,这些仍然是广泛的基准测试,可能无法涵盖非常具体的用例。最后,另一个缺点是,有些基准测试需要强大的 \(\text{GPU}\) 才能计算,并且运行时间长(超过数小时),这使得迭代变得困难

排行榜

Leaderboards

有如此多不同的基准测试,很难选择哪个基准测试最适合您的模型。每当模型发布时,您通常会看到它在多个基准测试上进行评估,以展示其全面表现

因此,开发了包含多个基准测试的排行榜 (\(\text{leaderboards}\))。一个常见的排行榜是 \(\text{Open LLM Leaderboard}\),在撰写本文时,它包括六个基准测试,其中包括 \(\text{HellaSwag}\)\(\text{MMLU}\)\(\text{TruthfulQA}\)\(\text{GSM8k}\)。位居排行榜榜首的模型(假设它们没有在数据上过度拟合)通常被认为是“最好的”模型。然而,由于这些排行榜通常包含公开可用的基准测试,因此存在在排行榜上过度拟合的风险

自动化评估

Automated Evaluation

评估生成输出的一部分是其文本的质量。例如,即使两个模型对一个问题给出了相同的正确答案,它们得出该答案的方式也可能不同。这通常不仅仅是最终答案的问题,还关乎其构造。类似地,尽管两个摘要可能相似,但其中一个可能明显短于另一个,这对于一个好的摘要来说通常很重要

为了评估生成文本的质量不仅仅是最终答案的正确性,引入了\(\text{LLM}\) 即评审” (\(\text{LLM-as-a-judge}\)) 的方法。实质上,一个单独的 \(\text{LLM}\) 被要求判断待评估 \(\text{LLM}\) 的质量。这种方法的一个有趣的变体成对比较 (\(\text{pairwise comparison}\))。两个不同的 \(\text{LLM}\) 将生成一个问题的答案,而第三个 \(\text{LLM}\) 将充当评委来裁定哪个更好

因此,这种方法论允许对开放式问题进行自动化评估。一个主要优势是,随着 \(\text{LLM}\) 的改进,它们判断输出质量的能力也会提高。换句话说,这种评估方法与该领域共同发展

人工评估

Human Evaluation

尽管基准测试很重要,但评估的黄金标准通常被认为是人工评估 (\(\text{human evaluation}\))。即使一个 \(\text{LLM}\)广泛的基准测试上得分很高,它仍可能在领域特定的任务上得分不高。此外,基准测试并不能完全捕捉人类偏好,而之前讨论的所有方法都仅仅是人类偏好的替代指标

一个基于人工评估技术的绝佳示例\(\text{Chatbot Arena}\)(https://lmarena.ai/)。当您进入这个排行榜时,您会看到两个(匿名)\(\text{LLM}\),您可以与之互动。您提出的任何问题或提示都将发送给这两个模型,并且您将收到它们的输出。然后,您可以决定您更喜欢哪个输出。这个过程允许社区对他们更喜欢的模型进行投票,而不知道呈现给他们的是哪个模型只有在您投票后,您才会看到哪个模型生成了哪个文本

在撰写本文时,这种方法已经产生了 \(\text{800,000}\) 多次人工投票,用于计算排行榜。这些投票用于根据 \(\text{LLM}\) 的胜率来计算其相对技能水平。例如,如果一个低排名的 \(\text{LLM}\) 击败了一个高排名的 \(\text{LLM}\),它的排名就会显著变化。在国际象棋中,这被称为 \(\text{Elo}\) 等级分系统

因此,这种方法论使用了众包投票(crowdsourced vote),这有助于我们了解 \(\text{LLM}\) 的质量。然而,它仍然是广泛用户群体的聚合意见可能与您的用例不相关

因此,没有一种完美的 \(\text{LLM}\) 评估方法。所有提到的方法论和基准测试都提供了重要但有限的评估视角。我们的建议是根据预期的用例来评估您的 \(\text{LLM}\)。对于编码\(\text{HumanEval}\) 会比 \(\text{GSM8k}\) 更合乎逻辑。

但最重要的是,我们相信您是最好的评估者人工评估仍然是黄金标准,因为最终由您决定 \(\text{LLM}\) 是否适用于您预期的用例。与本章中的示例一样,我们强烈建议您也尝试这些模型,并自己设计一些问题。例如,本书的作者是阿拉伯人 (\(\text{Jay Alammar}\)) 和荷兰人 (\(\text{Maarten Grootendorst}\)),当我们接触新模型时,我们经常用我们的母语提问

关于这个主题的最后一点是我们珍视的一句话

当一个衡量标准变成一个目标时,它就不再是一个好的衡量标准了。古德哈特定律 (\(\text{Goodhart’s Law}\))

\(\text{LLM}\) 的背景下,当使用特定的基准测试时,我们倾向于为该基准测试进行优化,而不顾后果。例如,如果我们纯粹专注于优化生成语法正确的句子,模型可能会学会只输出一个句子:“\(\text{This is a sentence.}\)”(这是一个句子。)它在语法上是正确的,但没有告诉你任何关于其语言理解能力的信息。因此,模型可能擅长某个特定的基准测试,但可能会牺牲其他有用的能力

偏好调整/对齐/强化学习自人类反馈

Preference-Tuning / Alignment / RLHF

尽管我们的模型现在可以遵循指令,但我们可以通过最后的训练阶段进一步改进其行为,使其与我们期望它在不同场景下的行为保持一致。例如,当被问到“\(\text{What is an LLM?}\)”(什么是 \(\text{LLM}\)?)时,我们可能更喜欢一个详细描述 \(\text{LLM}\) 内部结构的答案,而不是没有进一步解释的“\(\text{It is a large language model}\)”(它是一个大型语言模型)这样的答案。我们究竟如何将我们(人类)对一个答案的偏好(优于另一个答案)与 \(\text{LLM}\) 的输出对齐呢?

首先,回想一下 \(\text{LLM}\) 接受一个提示并输出一个生成结果,如图 \(\text{12}-21\) 所示。

F12.1

我们可以要求一个人偏好评估者 \(\text{preference evaluator}\)评估该模型生成的质量。假设他们给它分配了某个分数,比如 \(\text{4}\) 分(参见图 \(\text{12}-22\))。

F12.1

\(\text{12}-23\) 显示了基于该分数更新模型偏好调整步骤

  • 如果分数很高,则更新模型鼓励它生成更多此类生成结果
  • 如果分数很低,则更新模型阻止此类生成结果。

F12.1

一如既往,我们需要许多训练示例。那么,我们能否将偏好评估自动化呢?是的,我们可以通过训练一个不同的模型,称为奖励模型 (\(\text{reward model}\)),来实现。

使用奖励模型实现偏好评估自动化

Automating Preference Evaluation Using Reward Models

为了实现偏好评估的自动化,我们需要在偏好调整步骤之前增加一个步骤,即训练一个奖励模型 (\(\text{reward model}\)),如图 \(\text{12}-24\) 所示。

F12.1

\(\text{12}-25\) 显示,为了创建奖励模型,我们复制了经过指令调整的模型 (\(\text{instruction-tuned model}\)),并对其进行了微小改动,使其不再生成文本,而是输出一个单一的分数

F12.1

奖励模型的输入和输出

The Inputs and Outputs of a Reward Model

我们期望这个奖励模型的工作方式是:我们给它一个提示和一个生成结果,它会输出一个单一的数字,表明该生成结果对该提示的偏好/质量。图 \(\text{12}-26\) 显示了奖励模型生成这个单一数字的过程。

F12.1

训练奖励模型

Training a Reward Model

我们不能直接使用奖励模型。它需要先经过训练才能正确地给生成结果评分。因此,让我们获取一个模型可以学习的偏好数据集

奖励模型训练数据集

Reward model training dataset

偏好数据集的一种常见形式是,一个训练示例包含一个提示一个被接受的生成结果 (\(\text{accepted generation}\)) 和一个被拒绝的生成结果 (\(\text{rejected generation}\))。(细微差别:它不总是好与坏的生成结果;它可能是两个生成结果都很好,但其中一个比另一个更好)。图 \(\text{12}-27\) 显示了一个包含两个训练示例的偏好训练集。

F12.1

生成偏好数据的一种方法是\(\text{LLM}\) 呈现一个提示,让它生成两个不同的结果。如图 \(\text{12}-28\) 所示,我们可以询问人类标注者他们更喜欢哪一个

F12.1

奖励模型训练步骤 (\(\text{Reward model training step}\))

现在我们有了偏好训练数据集,我们就可以继续训练奖励模型了。

一个简单的步骤是我们使用奖励模型来

  1. 被接受的生成结果评分。
  2. 被拒绝的生成结果评分。

\(\text{12}-29\) 显示了训练目标确保被接受的生成结果得分高于被拒绝的生成结果

F12.1

当我们将所有内容组合在一起时(如图 \(\text{12}-30\) 所示),我们就得到了偏好调整的三个阶段

  1. 收集偏好数据
  2. 训练奖励模型
  3. 使用奖励模型微调 \(\text{LLM}\)(作为偏好评估者)

F12.1

奖励模型是一个绝妙的想法,可以进一步扩展和发展。例如,\(\text{Llama 2}\) 训练了两个奖励模型:一个对有用性进行评分,另一个对安全性进行评分(图 \(\text{12}-31\))。

F12.1

使用训练好的奖励模型来微调 \(\text{LLM}\) 的一种常用方法近端策略优化 (\(\text{Proximal Policy Optimization, PPO}\))\(\text{PPO}\) 是一种流行的强化学习技术,它通过确保 \(\text{LLM}\) 不会偏离预期奖励太多优化经过指令调整的 \(\text{LLM}\)。它甚至被用于训练 \(\text{2022}\)\(\text{11}\) 月发布的最初 \(\text{ChatGPT}\)

无需训练奖励模型

Training No Reward Model

\(\text{PPO}\) 的一个缺点是它是一种复杂的方法,需要训练至少两个模型奖励模型\(\text{LLM}\),这可能比必要的更昂贵

直接偏好优化 (\(\text{Direct Preference Optimization, DPO}\))\(\text{PPO}\)一种替代方案,它摒弃了基于强化学习的过程我们不再使用奖励模型来判断生成结果的质量,而是\(\text{LLM}\) 本身来做这件事。如图 \(\text{12}-32\) 所示,我们使用 \(\text{LLM}\) 的一个副本作为参考模型 (\(\text{reference model}\)),来判断被接受的生成结果和被拒绝的生成结果的质量参考模型可训练模型 (\(\text{trainable model}\)) 之间的偏移

F12.1

通过在训练期间计算这种偏移,我们可以通过跟踪参考模型和可训练模型之间的差异,来优化被接受的生成结果的似然性(优于被拒绝的生成结果)。

个人注:参考模型 是为了防止模型在对齐过程中偏离得太远,从而避免生成无意义的文本。

为了计算这种偏移及其相关分数,需要从两个模型中提取被拒绝生成结果和被接受生成结果的对数概率 (\(\text{log probabilities}\))。如图 \(\text{12}-33\) 所示,这个过程在词元级别 (\(\text{token level}\)) 进行,其中概率被组合起来计算参考模型和可训练模型之间的偏移

F12.1

个人注:你看到的那两组输出(上面是 “I got no clue !”,下面是 “I have no idea !”)分别对应模型生成的两种候选回答,而图中列出 "I", "have", "no", "idea", "!" 的 token,其实是为了 展示模型在“较优回答”这条序列上的逐词概率变化

利用这些分数,我们可以优化可训练模型的参数,使其对生成被接受的生成结果更有信心,而对生成被拒绝的生成结果信心不足。与 \(\text{PPO}\) 相比,作者发现 \(\text{DPO}\) 在训练期间更稳定更准确。由于其稳定性,我们将使用它作为我们偏好调整之前经过指令调整的模型的主要模型

使用 \(\text{DPO}\) 进行偏好调整

Preference Tuning with DPO

当我们使用 \(\text{Hugging Face}\) 堆栈时,偏好调整与我们之前介绍的指令调整非常相似,只有一些细微差别。我们将继续使用 \(\text{TinyLlama}\),但这次是一个经过指令调整的版本,它首先使用完全微调进行训练,然后使用 \(\text{DPO}\) 进一步对齐。与我们最初经过指令调整的模型相比,这个 \(\text{LLM}\) 是在更大的数据集上进行训练的。

在本节中,我们将演示如何使用 \(\text{DPO}\) 和基于奖励的数据集进一步对齐该模型

模板化对齐数据

Templating Alignment Data

我们将使用一个对于每个提示都包含一个被接受的生成结果和一个被拒绝的生成结果的数据集。该数据集部分由 \(\text{ChatGPT}\) 生成,并附带了关于哪个输出应该被接受、哪个应该被拒绝的评分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from datasets import load_dataset
def format_prompt(example):
"""Format the prompt to using the <|user|> template TinyLLama is using"""
# Format answers
system = "<|system|>\n" + example["system"] + "</s>\n"
prompt = "<|user|>\n" + example["input"] + "</s>\n<|assistant|>\n"
chosen = example["chosen"] + "</s>\n"
rejected = example["rejected"] + "</s>\n"
return {
"prompt": system + prompt,
"chosen": chosen,
"rejected": rejected,
}
# Apply formatting to the dataset and select relatively short answers
dpo_dataset = load_dataset(
"argilla/distilabel-intel-orca-dpo-pairs", split="train"
)
dpo_dataset = dpo_dataset.filter(
lambda r:
r["status"] != "tie" and
r["chosen_score"] >= 8 and
not r["in_gsm8k_train"]
)
dpo_dataset = dpo_dataset.map(
format_prompt, remove_columns=dpo_dataset.column_names
)
dpo_dataset

请注意,我们应用了额外的过滤,将数据的规模从原来的 \(\text{13,000}\) 个示例进一步减少到大约 \(\text{6,000}\) 个示例

模型量化

Model Quantization

我们加载基础模型,并加载我们先前创建的 \(\text{LoRA}\)。和以前一样,我们对模型进行量化减少训练所需的 \(\text{VRAM}\)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from peft import AutoPeftModelForCausalLM
from transformers import BitsAndBytesConfig, AutoTokenizer
# 4-bit quantization configuration - Q in QLoRA
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # Use 4-bit precision model loading

bnb_4bit_quant_type="nf4", # Quantization type
bnb_4bit_compute_dtype="float16", # Compute dtype
bnb_4bit_use_double_quant=True, # Apply nested quantization
)
# Merge LoRA and base model
model = AutoPeftModelForCausalLM.from_pretrained(
"TinyLlama-1.1B-qlora",
low_cpu_mem_usage=True,
device_map="auto",
quantization_config=bnb_config,
)
merged_model = model.merge_and_unload()
# Load LLaMA tokenizer
model_name = "TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = "<PAD>"
tokenizer.padding_side = "left"

接下来,我们使用与之前相同的 \(\text{LoRA}\) 配置来执行 \(\text{DPO}\) 训练:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from peft import LoraConfig, prepare_model_for_kbit_training, get_peft_model
# Prepare LoRA configuration
peft_config = LoraConfig(
lora_alpha=32, # LoRA Scaling
lora_dropout=0.1, # Dropout for LoRA Layers
r=64, # Rank
bias="none",
task_type="CAUSAL_LM",
target_modules= # Layers to target
["k_proj", "gate_proj", "v_proj", "up_proj", "q_proj", "o_proj",
"down_proj"]
)
# prepare model for training
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, peft_config)

训练配置

Training Configuration

为了简单起见,我们将使用与之前相同的训练参数,但有一个区别。为了说明目的,我们运行 \(\text{200}\),而不是运行一个 \(\text{epoch}\)(可能需要长达两小时)。此外,我们添加了 \(\text{warmup\_ratio}\) 参数,它在\(\text{10\%}\) 的步骤中将学习率\(\text{0}\) 增加到我们设定的 \(\text{learning\_rate}\) 值。通过在开始时保持较小的学习率(即预热期 \(\text{warmup period}\)),我们允许模型在应用较大的学习率之前调整以适应数据,从而避免有害的发散 (\(\text{divergence}\)):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from trl import DPOConfig
output_dir = "./results"
# Training arguments
training_arguments = DPOConfig(
output_dir=output_dir,
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
optim="paged_adamw_32bit",
learning_rate=1e-5,
lr_scheduler_type="cosine",
max_steps=200,
logging_steps=10,
fp16=True,
gradient_checkpointing=True,
warmup_ratio=0.1
)

训练

Training

现在我们已经准备好所有的模型和参数,就可以开始微调我们的模型了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from trl import DPOTrainer
# Create DPO trainer
dpo_trainer = DPOTrainer(
model,
args=training_arguments,
train_dataset=dpo_dataset,
tokenizer=tokenizer,
peft_config=peft_config,
beta=0.1,
max_prompt_length=512,
max_length=512,
)
# Fine-tune model with DPO
dpo_trainer.train()
# Save adapter
dpo_trainer.model.save_pretrained("TinyLlama-1.1B-dpo-qlora")

我们创建了第二个适配器。为了合并这两个适配器,我们迭代地将适配器与基础模型合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from peft import PeftModel
# Merge LoRA and base model
model = AutoPeftModelForCausalLM.from_pretrained(
"TinyLlama-1.1B-qlora",

low_cpu_mem_usage=True,
device_map="auto",
)
sft_model = model.merge_and_unload()
# Merge DPO LoRA and SFT model
dpo_model = PeftModel.from_pretrained(
sft_model,
"TinyLlama-1.1B-dpo-qlora",
device_map="auto",
)
dpo_model = dpo_model.merge_and_unload()

这种 \(\text{SFT}\) + \(\text{DPO}\) 的组合首先微调模型以执行基本聊天,然后将其答案与人类偏好对齐绝佳方式。然而,它确实需要付出代价,因为我们需要执行两个训练循环,并且可能需要调整两个过程中的参数

自从 \(\text{DPO}\) 发布以来,新的对齐偏好的方法不断被开发出来。值得注意的是赔率比偏好优化 (\(\text{Odds Ratio Preference Optimization, ORPO}\)),这是一个\(\text{SFT}\)\(\text{DPO}\) 结合到一个单一训练过程中的流程。它消除了执行两个独立训练循环的需要进一步简化了训练过程,同时允许使用 \(\text{QLoRA}\)

总结

在本章中,我们探索了微调预训练 \(\text{LLM}\) 的不同步骤。我们通过低秩适应 (\(\text{LoRA}\)) 技术,利用参数高效微调 (\(\text{PEFT}\)) 来执行微调。我们解释了如何通过量化 (\(\text{quantization}\)) 扩展 \(\text{LoRA}\),量化是一种在表示模型和适配器的参数时减少内存限制的技术

我们探索的微调过程包含两个步骤

第一步中,我们使用指令数据预训练 \(\text{LLM}\) 执行了有监督微调 (\(\text{supervised fine-tuning}\)),这通常称为指令调整 (\(\text{instruction tuning}\))。这产生了一个具有聊天式行为并能密切遵循指令的模型

第二步中,我们通过使用对齐数据(即表示哪种类型的答案优于其他答案的数据)对其进行微调,进一步改进了模型。这个过程被称为偏好调整 (\(\text{preference tuning}\)),它将人类偏好提炼到先前经过指令调整的模型中。

总而言之,本章展示了微调预训练 \(\text{LLM}\) 的两个主要步骤,以及这如何能够产生更准确、信息更丰富的输出

Afterword

感谢所有加入我们,一同完成这次穿越大型语言模型世界的迷人旅程的读者。我们感激您致力于学习这些功能强大、彻底革新了语言处理领域的模型。

在整本书中,我们看到了 \(\text{LLM}\) 的工作原理,以及如何利用它们来创建各种应用,从简单的聊天机器人到更复杂的系统,例如搜索引擎。我们还探索了各种方法,用于针对特定任务(包括分类、生成和语言表示)对预训练的 \(\text{LLM}\) 进行微调。通过掌握这些技术,读者将能够释放 \(\text{LLM}\) 的潜力,并创建可从其能力中受益的创新解决方案。这些知识将使读者能够保持领先地位,并适应该领域的新发展。

在本书即将结束之际,我们要强调,我们对 \(\text{LLM}\) 的探索仅仅是一个开始。未来还有更多激动人心的发展,我们鼓励您继续关注该领域的进展。为了协助这个过程,请继续关注本书的存储库,我们会在其中持续添加资源。

我们希望通过阅读本书,您对 \(\text{LLM}\) 如何应用于各种应用程序以及它们如何具备改变各行各业的潜力,有了更深入的理解。

以本书为指导,我们相信您将有充足的准备来驾驭 \(\text{LLM}\) 这一令人兴奋的领域,并为这个快速发展的领域做出有意义的贡献。

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

\(\text{11}\) 微调用于分类的表征模型

Fine-Tuning Representation Models for Classification

在第 \(\text{4}\) 章中,我们使用预训练模型来对文本进行分类。我们保持了预训练模型,没有进行任何修改。这可能会让您思考,如果我们对它们进行微调会发生什么?

如果我们有足够的数据微调往往会带来性能最佳的模型。在本章中,我们将介绍几种微调 \(\text{BERT}\) 模型的方法和应用。“\(\text{Supervised Classification}\)”(第 \(\text{323}\) 页)将演示微调分类模型的一般过程。然后,在“\(\text{Few-Shot Classification}\)”(第 \(\text{333}\) 页)中,我们将研究 \(\text{SetFit}\),这是一种使用少量训练示例高效微调高性能模型的方法。在“\(\text{Continued Pretraining with Masked Language Modeling}\)”(第 \(\text{340}\) 页)中,我们将探索如何继续训练一个预训练模型。最后,将在“\(\text{Named-Entity Recognition}\)”(第 \(\text{345}\) 页)中探讨词元级别的分类。

我们将重点关注非生成性任务,因为生成模型将在第 \(\text{12}\) 章中介绍。

有监督分类

Supervised Classification

在第 \(\text{4}\) 章中,我们通过利用预训练的表征模型探索了有监督分类任务,这些模型要么经过预测情感的训练任务特定模型 \(\text{task-specific model}\)),要么经过生成嵌入的训练嵌入模型 \(\text{embedding model}\)),如图 \(\text{11}-1\) 所示。

F11.1

这两个模型都保持冻结\(\text{frozen}\),即不可训练),以展示利用预训练模型进行分类任务的潜力嵌入模型使用一个单独的可训练分类头\(\text{classifier}\))来预测电影评论的情感

在本节中,我们将采取类似的方法,但允许模型和分类头在训练期间都得到更新。如图 \(\text{11}-2\) 所示,我们将微调一个预训练的 \(\text{BERT}\) 模型来创建一个任务特定模型,而不是使用嵌入模型,这类似于我们在第 \(\text{2}\) 章中使用的模型。与嵌入模型的方法相比,我们将作为一个单一架构表征模型和分类头进行微调

F11.1

为此,我们不冻结模型,而是允许它可训练并在训练期间更新其参数。如图 \(\text{11}-3\) 所示,我们将使用一个预训练的 \(\text{BERT}\) 模型并添加一个神经网络作为分类头,两者都将为分类进行微调

F11.1

在实践中,这意味着预训练的 \(\text{BERT}\) 模型和分类头联合更新。它们不是独立的流程,而是相互学习,并允许产生更准确的表征

微调预训练 \(\text{BERT}\) 模型

Fine-Tuning a Pretrained BERT Model

我们将使用与第 \(\text{4}\) 章中相同的 \(\text{Rotten Tomatoes}\) 数据集来微调我们的模型,该数据集包含来自 \(\text{Rotten Tomatoes}\)\(\text{5,331}\) 条正面和 \(\text{5,331}\) 条负面电影评论

1
2
3
4
from datasets import load_dataset
# Prepare data and splits
tomatoes = load_dataset("rotten_tomatoes")
train_data, test_data = tomatoes["train"], tomatoes["test"]

我们分类任务的第一步选择我们要使用的底层模型。我们使用 \(\text{"bert-base-cased"}\),它是在英文维基百科以及一个包含未出版书籍的大型数据集预训练的。

我们预先定义了我们想要预测的标签数量。这对于创建应用于我们预训练模型顶部的 前馈神经网络是必要的:

1
2
3
4
5
6
7
from transformers import AutoTokenizer, AutoModelForSequenceClassification
# Load model and tokenizer
model_id = "bert-base-cased"
model = AutoModelForSequenceClassification.from_pretrained(
model_id, num_labels=2
)
tokenizer = AutoTokenizer.from_pretrained(model_id)

接下来,我们将对数据进行词元化\(\text{tokenize}\)):

1
2
3
4
5
6
7
8
9
from transformers import DataCollatorWithPadding
# Pad to the longest sequence in the batch
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
def preprocess_function(examples):
"""Tokenize input data"""
return tokenizer(examples["text"], truncation=True)
# Tokenize train/test data
tokenized_train = train_data.map(preprocess_function, batched=True)
tokenized_test = test_data.map(preprocess_function, batched=True)

在创建 \(\text{Trainer}\) 之前,我们需要准备一个特殊的数据整理器 (\(\text{DataCollator}\))。\(\text{DataCollator}\) 是一个帮助我们构建数据批次的类,但也允许我们应用数据增强

在这个词元化过程中,正如第 \(\text{9}\) 章所示,我们将在输入文本中添加填充 (\(\text{padding}\)) 以创建大小相等的表征。我们为此使用了 \(\text{DataCollatorWithPadding}\)

当然,一个示例不会在没有定义一些指标的情况下完成

1
2
3
4
5
6
7
8
9
10
import numpy as np
from datasets import load_metric
def compute_metrics(eval_pred):
"""Calculate F1 score"""
logits, labels = eval_pred

predictions = np.argmax(logits, axis=-1)
load_f1 = load_metric("f1")
f1 = load_f1.compute(predictions=predictions, references=labels)["f1"]
return {"f1": f1}

通过 \(\text{compute\_metrics}\),我们可以定义我们感兴趣的任意数量的指标,这些指标可以在训练期间打印或记录。这在训练期间特别有帮助,因为它允许检测过拟合行为

接下来,我们实例化我们的 \(\text{Trainer}\)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from transformers import TrainingArguments, Trainer
# Training arguments for parameter tuning
training_args = TrainingArguments(
"model",
learning_rate=2e-5,
per_device_train_batch_size=16,
per_device_eval_batch_size=16,
num_train_epochs=1,
weight_decay=0.01,
save_strategy="epoch",
report_to="none"
)
# Trainer which executes the training process
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_train,
eval_dataset=tokenized_test,
tokenizer=tokenizer,
data_collator=data_collator,
compute_metrics=compute_metrics,
)

\(\text{TrainingArguments}\) 类定义了我们想要调整的超参数,例如学习率以及我们想要训练的轮数 (\(\text{epochs}\))。\(\text{Trainer}\) 用于执行训练过程

最后,我们可以训练我们的模型并进行评估

1
2
3
4
5
6
7
trainer.evaluate()
{'eval_loss': 0.3663691282272339,
'eval_f1': 0.8492366412213741,
'eval_runtime': 4.5792,
'eval_samples_per_second': 232.791,
'eval_steps_per_second': 14.631,
'epoch': 1.0}

我们获得了 \(\text{0.85}\)\(\text{F1}\) 分数,这比我们在第 \(\text{4}\) 章中使用的任务特定模型 (\(\text{task-specific model}\)),即 \(\text{F1}\) 分数为 \(\text{0.80}\),要高出不少。这表明自己微调模型可能比使用预训练模型更有优势。它只花费了我们几分钟的训练时间

冻结层

Freezing Layers

为了进一步展示训练整个网络的重要性,下一个示例将演示如何使用 \(\text{Hugging Face Transformers}\)冻结网络的某些层

我们将冻结主要的 \(\text{BERT}\) 模型,并且只允许更新通过分类头。这将是一个很好的比较,因为我们将保持所有其他设置相同,只是冻结了特定的层

首先,让我们重新初始化模型,以便可以从头开始:

1
2
3
4
5
# Load model and tokenizer
model = AutoModelForSequenceClassification.from_pretrained(
model_id, num_labels=2
)
tokenizer = AutoTokenizer.from_pretrained(model_id)

我们的预训练 \(\text{BERT}\) 模型包含许多我们可能想要冻结的层检查这些层可以深入了解网络的结构以及我们可能想要冻结的部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Print layer names
for name, param in model.named_parameters():
print(name)
bert.embeddings.word_embeddings.weight
bert.embeddings.position_embeddings.weight
bert.embeddings.token_type_embeddings.weight
bert.embeddings.LayerNorm.weight
bert.embeddings.LayerNorm.bias
bert.encoder.layer.0.attention.self.query.weight
bert.encoder.layer.0.attention.self.query.bias
...
bert.encoder.layer.11.output.LayerNorm.weight
bert.encoder.layer.11.output.LayerNorm.bias
bert.pooler.dense.weight
bert.pooler.dense.bias
classifier.weight
classifier.bias

\(\text{12}\) 个 (\(\text{0}-\text{11}\)) 编码器块,它们由注意力头密集网络层归一化组成。我们在图 \(\text{11}-4\) 中进一步说明了这种架构,以展示所有可能被冻结的部分。除此之外,我们还有分类头

F11.1

我们可以选择只冻结某些层以加快计算速度,但仍允许主模型从分类任务中学习。一般来说,我们希望可训练层位于冻结层之后

我们将冻结除分类头之外的所有内容,就像我们在第 \(\text{2}\) 章中所做的那样:

1
2
3
4
5
6
7
for name, param in model.named_parameters():
# Trainable classification head
if name.startswith("classifier"):
param.requires_grad = True
# Freeze everything else
else:
param.requires_grad = False

如图 \(\text{11}-5\) 所示,我们冻结了除前馈神经网络(即我们的分类头)之外的所有内容

F11.1

现在我们已经成功冻结了除分类头之外的所有内容,我们可以继续训练我们的模型

1
2
3
4
5
6
7
8
9
10
11
12
from transformers import TrainingArguments, Trainer
# Trainer which executes the training process
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_train,
eval_dataset=tokenized_test,
tokenizer=tokenizer,
data_collator=data_collator,
compute_metrics=compute_metrics,
)
trainer.train()

您可能会注意到训练速度已经快得多了。这是因为我们只训练分类头,与微调整个模型相比,这为我们提供了显著的提速

1
2
3
4
5
6
7
trainer.evaluate()
{'eval_loss': 0.6821751594543457,
'eval_f1': 0.6331058020477816,
'eval_runtime': 4.0175,
'eval_samples_per_second': 265.337,
'eval_steps_per_second': 16.677,
'epoch': 1.0}

当我们评估模型时,我们得到的 \(\text{F1}\) 分数只有 \(\text{0.63}\),这比我们最初的 \(\text{0.85}\) 分数要低得多。与其冻结几乎所有层,不如像图 \(\text{11}-6\) 所展示的那样,冻结直到编码器块 \(\text{10}\) 之前的所有内容,看看这对性能有何影响。一个主要的好处是这减少了计算量,但仍然允许更新流经部分预训练模型

F11.1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Load model
model_id = "bert-base-cased"
model = AutoModelForSequenceClassification.from_pretrained(
model_id, num_labels=2
)
tokenizer = AutoTokenizer.from_pretrained(model_id)
# Encoder block 11 starts at index 165 and
# we freeze everything before that block
for index, (name, param) in enumerate(model.named_parameters()):
if index < 165:
param.requires_grad = False
# Trainer which executes the training process
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_train,
eval_dataset=tokenized_test,

tokenizer=tokenizer,
data_collator=data_collator,
compute_metrics=compute_metrics,
)
trainer.train()

训练后,我们评估结果:

1
2
3
4
5
6
7
trainer.evaluate()
{'eval_loss': 0.40812647342681885,
'eval_f1': 0.8,
'eval_runtime': 3.7125,
'eval_samples_per_second': 287.137,
'eval_steps_per_second': 18.047,
'epoch': 1.0}

我们得到了 \(\text{0.8}\)\(\text{F1}\) 分数,这比我们之前冻结所有层时的 \(\text{0.63}\) 分数要高得多。这表明,尽管我们通常希望训练尽可能多的层,但如果您没有必要的计算能力训练更少的层也是可以接受的。

为了进一步说明这种效果,我们测试了迭代冻结编码器块并进行微调的影响。如图 \(\text{11}-7\) 所示,只训练前五个编码器块红色垂直线)就足以几乎达到训练所有编码器块的性能。

F11.1

当您进行多轮训练\(\text{epochs}\))时,冻结与不冻结之间的差异(在训练时间和资源方面)通常会变得更大。因此,建议尝试找到适合您的平衡点

少样本分类

Few-Shot Classification

少样本分类\(\text{Few-shot classification}\))是一种有监督分类中的技术,在这种技术中,您让分类器仅根据少数几个已标注的示例来学习目标标签。当您有一个分类任务没有大量现成的标注数据点时,这种技术非常有用。换句话说,这种方法允许您为每个类别标注少量高质量的数据点来训练模型。图 \(\text{11}-8\) 展示了使用少量标注数据点来训练模型的想法。

F11.1

\(\text{SetFit}\): 使用少量训练示例进行高效微调

SetFit: Efficient Fine-Tuning with Few Training Examples

为了执行少样本文本分类,我们使用了一个名为 \(\text{SetFit}\)高效框架。它构建在 \(\text{sentence-transformers}\) 的架构之上,用于生成在训练期间会更新的高质量文本表征。对于这个框架来说,只需要少量标注示例就可以与我们在上一个示例中探索的在大型标注数据集上微调 \(\text{BERT}\) 类模型相媲美。

\(\text{SetFit}\) 的底层算法三个步骤组成:

  1. 采样训练数据 (\(\text{Sampling training data}\)) 基于已标注数据的类内和类间选择,它会生成正向(相似)和负向(不相似)的句子对
  2. 微调嵌入 (\(\text{Fine-tuning embeddings}\)) 基于先前生成的训练数据微调预训练嵌入模型
  3. 训练分类器 (\(\text{Training a classifier}\)) 在嵌入模型之上创建一个分类头,并使用先前生成的训练数据对其进行训练。

在微调嵌入模型之前,我们需要生成训练数据。模型假设训练数据是正向(相似)和负向(不相似)句子对的样本。然而,当我们处理分类任务时,我们的输入数据通常没有以这种方式标注

例如,假设我们有图 \(\text{11}-9\) 中的训练数据集,它将文本分为两类:关于编程语言的文本和关于宠物的文本

F11.1

步骤 \(\text{1}\) 中,\(\text{SetFit}\) 通过基于类内\(\text{in-class}\))和类间\(\text{out-class}\))选择来生成必需的数据,从而处理这个问题,如图 \(\text{11}-10\) 所示。例如,当我们有 \(\text{16}\) 个关于运动的句子时,我们可以创建 \(\text{16} \times (\text{16} – \text{1}) / \text{2} = \text{120}\) 个句子对,我们将它们标注为正向对。我们可以使用这个过程通过收集来自不同类别的句子对来生成负向对

F11.1

步骤 \(\text{2}\) 中,我们可以使用生成的句子对微调嵌入模型。这利用了一种称为对比学习的方法来微调预训练的 \(\text{BERT}\) 模型。正如我们在第 \(\text{10}\) 章中所回顾的,对比学习允许从相似(正向)和不相似(负向)句子对中学习准确的句子嵌入

由于我们在上一步中生成了这些句子对,我们可以使用它们来微调 \(\text{SentenceTransformers}\) 模型。尽管我们之前讨论过对比学习,但我们再次在图 \(\text{11}-11\) 中说明该方法以供回顾。

F11.1

微调这个嵌入模型的目标是使其能够创建针对分类任务进行调整的嵌入类别之间的相关性及其相对含义通过微调嵌入模型提炼到嵌入中。

步骤 \(\text{3}\) 中,我们生成所有句子的嵌入,并将其用作分类器的输入。我们可以使用微调后的 \(\text{SentenceTransformers}\) 模型将我们的句子转换为嵌入,我们可以将其用作特征分类器从我们微调后的嵌入中学习,以准确预测未见过的句子。这最后一步如图 \(\text{11}-12\) 所示。

F11.1

当我们把所有步骤结合在一起时,就得到了一个高效且优雅的流程,用于在您每个类别只有少数标签时执行分类。它巧妙地利用了我们拥有标注数据的这个想法,尽管标注的形式并非我们所希望的那样。图 \(\text{11}-13\) 将这三个步骤结合在一起,给出了整个过程的单一概览

F11.1

首先,根据类内和类间选择生成句子对。其次,使用这些句子对微调一个预训练的 \(\text{SentenceTransformer}\) 模型。第三,使用微调后的模型对句子进行嵌入,并在这些嵌入上训练一个分类器来预测类别。

少样本分类的微调

Fine-Tuning for Few-Shot Classification

我们之前训练了一个包含大约 \(\text{8,500}\) 条电影评论的数据集。然而,由于这是一个少样本环境,我们将只采样每个类别 \(\text{16}\) 个示例。对于两个类别,我们只有 \(\text{32}\) 个文档进行训练,而我们之前使用了 \(\text{8,500}\) 条电影评论!

1
2
3
from setfit import sample_dataset
# We simulate a few-shot setting by sampling 16 examples per class
sampled_train_data = sample_dataset(tomatoes["train"], num_samples=16)

在采样数据后,我们选择一个预训练的 \(\text{SentenceTransformer}\) 模型进行微调。官方文档包含一个预训练 \(\text{SentenceTransformer}\) 模型的概览,我们将使用 \(\text{"sentence-transformers/all-mpnet-base-v2"}\)。它是 \(\text{MTEB}\) 排行榜上性能最佳的模型之一,该排行榜展示了嵌入模型在各种任务上的性能:

1
2
3
from setfit import SetFitModel
# Load a pretrained SentenceTransformer model
model = SetFitModel.from_pretrained("sentence-transformers/all-mpnet-base-v2")

在加载预训练的 \(\text{SentenceTransformer}\) 模型后,我们可以开始定义我们的 \(\text{SetFitTrainer}\)。默认情况下,逻辑回归模型被选作要训练的分类器。

类似于我们使用 \(\text{Hugging Face Transformers}\) 所做的那样,我们可以使用 \(\text{trainer}\)定义和调整相关参数。例如,我们将 \(\text{num\_epochs}\) 设置为 \(\text{3}\),以便对比学习将执行三个 \(\text{epoch}\)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from setfit import TrainingArguments as SetFitTrainingArguments
from setfit import Trainer as SetFitTrainer
# Define training arguments
args = SetFitTrainingArguments(
num_epochs=3, # The number of epochs to use for contrastive learning
num_iterations=20 # The number of text pairs to generate
)
args.eval_strategy = args.evaluation_strategy
# Create trainer
trainer = SetFitTrainer(
model=model,
args=args,
train_dataset=sampled_train_data,
eval_dataset=test_data,
metric="f1"
)

我们只需要调用 \(\text{train}\)开始训练循环。当我们这样做时,我们应该得到以下输出:

1
2
3
4
5
6
7
# Training loop
trainer.train()
***** Running training *****
Num unique pairs = 1280
Batch size = 16
Num epochs = 3
Total optimization steps = 240

请注意,输出中提到为微调 \(\text{SentenceTransformer}\) 模型生成了 \(\text{1,280}\) 个句子对。默认情况下,为我们数据中的每个样本生成 \(\text{20}\) 个句子对组合,即 \(\text{20} \times \text{32} = \text{680}\) 个样本。我们需要将此值乘以 \(\text{2}\),因为生成了每个正向和负向对,即 \(\text{680} \times \text{2} = \text{1,280}\) 个句子对。考虑到我们最初只有 \(\text{32}\) 个标注句子,生成 \(\text{1,280}\) 个句子对相当令人印象深刻

当我们没有明确定义分类头时,默认使用的是逻辑回归。如果我们要自己指定一个分类头,可以通过在 \(\text{SetFitTrainer}\) 中指定以下模型来实现:

1
2
3
4
5
6
7
8
9
10
11
# Load a SetFit model from Hub
model = SetFitModel.from_pretrained(
"sentence-transformers/all-mpnet-base-v2",
use_differentiable_head=True,
head_params={"out_features": num_classes},
)
# Create trainer
trainer = SetFitTrainer(
model=model,
...
)

这里的 \(\text{num\_classes}\) 指的是我们想要预测的类别数量

接下来,我们评估模型以感受其性能:

1
2
3
# Evaluate the model on our test data
trainer.evaluate()
{'f1': 0.8363988383349468}

仅用 \(\text{32}\) 个标注文档,我们就获得了 \(\text{0.85}\)\(\text{F1}\) 分数。考虑到该模型是在原始数据的极小一部分子集上训练的,这非常令人印象深刻!此外,在第 \(\text{2}\) 章中,我们获得了相同的性能,但却是在完整数据的嵌入上训练了一个逻辑回归模型。因此,这个流程展示了花费时间仅标注少量实例的潜力

\(\text{SetFit}\) 不仅可以执行少样本分类任务,它还支持您完全没有标签的情况,这也被称为零样本分类 (\(\text{zero-shot classification}\))。\(\text{SetFit}\)标签名称中生成合成示例,以模拟分类任务,然后在其上训练一个 \(\text{SetFit}\) 模型。例如,如果目标标签是“\(\text{happy}\)”和“\(\text{sad}\)”,那么合成数据可以是“\(\text{The example is happy}\)”和“\(\text{This example is sad}\)”。

使用掩码语言建模进行持续预训练

Continued Pretraining with Masked Language Modeling

在到目前为止的示例中,我们利用了一个预训练模型并对其进行了微调以执行分类。这个过程描述了一个两步过程首先预训练一个模型(这已经为我们完成),然后针对特定任务对其进行微调。我们在图 \(\text{11}-14\) 中说明了这一过程。

F11.1

这种两步法通常用于许多应用程序中。当面临特定领域的数据时,它有其局限性。预训练模型通常在非常通用的数据(如维基百科页面)上训练,可能未针对您的领域特定词汇进行调整

我们可以不采用这种两步法,而是在它们之间挤入另一个步骤,即继续预训练一个已经预训练过的 \(\text{BERT}\) 模型。换句话说,我们可以简单地继续使用掩码语言建模 (\(\text{MLM}\)) 来训练 \(\text{BERT}\) 模型,但改为使用我们领域的数据。这就像是从一个通用 \(\text{BERT}\) 模型,到一个专用于医学领域的 \(\text{BioBERT}\) 模型,再到一个微调后的 \(\text{BioBERT}\) 模型来对药物进行分类。

这将更新子词表征,使其更适应它以前未见过的词汇。这个过程如图 \(\text{11}-15\) 所示,并展示了这个额外步骤如何更新掩码语言建模任务。事实证明,在一个预训练 \(\text{BERT}\) 模型上继续预训练可以提高模型在分类任务中的性能,是微调流程中值得添加的一步

F11.1

我们不必从头开始预训练整个模型,只需在将其微调用于分类之前继续进行预训练即可。这也有助于模型适应某个特定领域,甚至是特定组织的行话。一个公司可能希望采用的模型族谱如图 \(\text{11}-16\) 所示。

F11.1

在本例中,我们将演示如何应用第 \(\text{2}\)继续预训练一个已经预训练的 \(\text{BERT}\) 模型。我们使用我们最初开始使用的相同数据,即 \(\text{Rotten Tomatoes}\) 评论

我们首先加载我们迄今为止使用的 \(\text{"bert-base-cased"}\) 模型,并为 \(\text{MLM}\) 准备它:

1
2
3
4
from transformers import AutoTokenizer, AutoModelForMaskedLM
# Load model for masked language modeling (MLM)
model = AutoModelForMaskedLM.from_pretrained("bert-base-cased")
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")

我们需要对原始句子进行词元化。我们还将移除标签,因为这不是一个有监督任务

1
2
3
4
5
6
7
def preprocess_function(examples):
return tokenizer(examples["text"], truncation=True)
# Tokenize data
tokenized_train = train_data.map(preprocess_function, batched=True)
tokenized_train = tokenized_train.remove_columns("label")
tokenized_test = test_data.map(preprocess_function, batched=True)
tokenized_test = tokenized_test.remove_columns("label")

之前,我们使用了 \(\text{DataCollatorWithPadding}\),它会动态填充接收到的输入。

相反,我们将使用一个 \(\text{DataCollator}\) 来为我们执行词元掩码 (\(\text{masking of tokens}\))。通常使用两种方法:词元掩码 (\(\text{token masking}\)) 和整词掩码 (\(\text{whole-word masking}\))。使用词元掩码,我们随机掩盖句子中 \(\text{15\%}\) 的词元。可能会发生一个词的一部分被掩盖的情况。为了实现对整个词的掩盖,我们可以应用整词掩码,如图 \(\text{11}-17\) 所示。

F11.1

一般来说,预测整个词汇往往比词元更复杂,这使得模型在训练期间需要学习更准确和精确的表征,从而表现更好。然而,它往往需要更多时间才能收敛

在本例中,我们将使用 \(\text{DataCollatorForLanguageModeling}\) 进行词元掩码,以实现更快的收敛。不过,我们可以通过将 \(\text{DataCollatorForLanguageModeling}\) 替换为 \(\text{DataCollatorForWholeWordMask}\) 来使用整词掩码。最后,我们将给定句子中词元被掩码的概率设置为 \(\text{15\%}\) (\(\text{mlm\_probability}\)):

1
2
3
4
5
6
7
from transformers import DataCollatorForLanguageModeling
# Masking Tokens
data_collator = DataCollatorForLanguageModeling(
tokenizer=tokenizer,
mlm=True,
mlm_probability=0.15
)

接下来,我们将创建用于运行 \(\text{MLM}\) 任务的 \(\text{Trainer}\) 并指定某些参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Training arguments for parameter tuning
training_args = TrainingArguments(
"model",
learning_rate=2e-5,
per_device_train_batch_size=16,
per_device_eval_batch_size=16,
num_train_epochs=10,
weight_decay=0.01,
save_strategy="epoch",
report_to="none"
)

# Initialize Trainer
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_train,
eval_dataset=tokenized_test,
tokenizer=tokenizer,
data_collator=data_collator
)

有几个参数值得注意。我们训练了 \(\text{20}\)\(\text{epoch}\) 并保持任务简短。您可以尝试学习率和权重衰减,以确定它们是否有助于微调模型。

在我们开始训练循环之前,我们首先会保存我们预训练的 \(\text{tokenizer}\)\(\text{tokenizer}\) 在训练期间不会更新,因此没有必要在训练后保存它。但是,我们将在继续预训练后保存我们的模型

1
2
3
4
5
6
# Save pre-trained tokenizer
tokenizer.save_pretrained("mlm")
# Train model
trainer.train()
# Save updated model
model.save_pretrained("mlm")

这为我们在 \(\text{mlm}\) 文件夹中提供了一个更新后的模型。要评估其性能,我们通常会在各种任务上微调该模型。然而,出于我们的目的,我们可以运行一些掩码任务,看看它是否从持续训练中有所学习。

我们将通过加载在我们继续预训练之前的预训练模型来完成此操作。使用句子 “\(\text{What a horrible [MASK]!}\)” 模型将预测哪个词将取代 \(\text{[MASK]}\)

1
2
3
4
5
6
7
8
9
10
11
12
from transformers import pipeline
# Load and create predictions
mask_filler = pipeline("fill-mask", model="bert-base-cased")
preds = mask_filler("What a horrible [MASK]!")
# Print results
for pred in preds:
print(f">>> {pred["sequence"]}")
>>> What a horrible idea!
>>> What a horrible dream!
>>> What a horrible thing!
>>> What a horrible day!
>>> What a horrible thought!

输出展示了像 “\(\text{idea}\)”、“\(\text{dream}\)” 和 “\(\text{day}\)” 这样的概念,这绝对合理。接下来,让我们看看我们更新后的模型预测了什么:

1
2
3
4
5
6
7
8
9
10
11
# Load and create predictions
mask_filler = pipeline("fill-mask", model="mlm")
preds = mask_filler("What a horrible [MASK]!")
# Print results
for pred in preds:
print(f">>> {pred["sequence"]}")
>>> What a horrible movie!
>>> What a horrible film!
>>> What a horrible mess!
>>> What a horrible comedy!
>>> What a horrible story!

一个“\(\text{horrible movie}\)”(可怕的电影)、“\(\text{film}\)”(影片)、“\(\text{mess}\)”(烂摊子)等清楚地表明,与预训练模型相比,该模型更偏向于我们提供给它的数据

下一步将是微调这个模型以进行我们在本章开头所做的分类任务。只需按如下方式加载模型,即可开始:

1
2
3
4
from transformers import AutoModelForSequenceClassification
# Fine-tune for classification
model = AutoModelForSequenceClassification.from_pretrained("mlm", num_labels=2)
tokenizer = AutoTokenizer.from_pretrained("mlm")

命名实体识别

Named-Entity Recognition

在本节中,我们将深入探讨专门针对命名实体识别 (\(\text{NER}\)) 微调预训练 \(\text{BERT}\) 模型的过程。这项过程不是对整个文档进行分类,而是允许对单个词元 (\(\text{token}\)) 和/或词汇进行分类,包括人名和地点。当涉及敏感数据时,这对于去识别化 (\(\text{de-identification}\)) 和匿名化任务特别有帮助

\(\text{NER}\) 与我们在本章开头探讨的分类示例有相似之处。然而,一个关键的区别在于数据的预处理和分类。鉴于我们专注于对单个词汇进行分类而不是整个文档,我们必须对数据进行预处理以考虑这种细粒度的结构。图 \(\text{11}-18\) 提供了这种词汇级别方法的视觉表示。

F11.18

微调预训练 \(\text{BERT}\) 模型遵循类似于我们观察到的文档分类的架构。然而,分类方法发生了根本性的转变。模型现在不是依赖于词元嵌入的聚合或池化,而是对序列中的单个词元进行预测。至关重要的是要强调,我们的词汇级别分类任务不意味着对整个词汇进行分类,而是对共同构成这些词汇的词元进行分类。图 \(\text{11}-19\) 提供了这种词元级别分类的视觉表示。

F11.19

为命名实体识别准备数据

Preparing Data for Named-Entity Recognition

在本例中,我们将使用 \(\text{CoNLL-2003}\) 数据集的英文版本,该数据集包含几种不同类型的命名实体人名组织地点杂项无实体),并有大约 \(\text{14,000}\) 个训练样本

1
2
# The CoNLL-2003 dataset for NER
dataset = load_dataset("conll2003", trust_remote_code=True)

在研究本示例要使用的数据集时,我们还想分享另外几个: \(\text{wnut\_17}\) 是一个专注于新兴和稀有实体的任务,这些实体更难发现。此外,\(\text{tner/mit\_movie\_trivia}\)\(\text{tner/mit\_restaurant}\) 数据集也很有趣。 \(\text{tner/mit\_movie\_trivia}\) 用于检测演员情节配乐等实体,而 \(\text{tner/mit\_restaurant}\) 旨在检测设施菜肴美食等实体。

让我们通过一个示例来检查数据的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
example = dataset["train"][848]
example
{'id': '848',
'tokens': ['Dean',
'Palmer',
'hit',
'his',
'30th',
'homer',
'for',
'the',
'Rangers',
'.'],
'pos_tags': [22, 22, 38, 29, 16, 21, 15, 12, 23, 7],
'chunk_tags': [11, 12, 21, 11, 12, 12, 13, 11, 12, 0],
'ner_tags': [1, 2, 0, 0, 0, 0, 0, 0, 3, 0]}

该数据集为句子中给出的每个词提供了标签。这些标签可以在 \(\text{ner\_tags}\)中找到,它指的是以下可能的实体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
label2id = {
"O": 0, "B-PER": 1, "I-PER": 2, "B-ORG": 3, "I-ORG": 4,
"B-LOC": 5, "I-LOC": 6, "B-MISC": 7, "I-MISC": 8
}
id2label = {index: label for label, index in label2id.items()}
label2id
{'O': 0,
'B-PER': 1,
'I-PER': 2,
'B-ORG': 3,
'I-ORG': 4,
'B-LOC': 5,
'I-LOC': 6,
'B-MISC': 7,
'I-MISC': 8}

这些实体对应于特定的类别人名 (\(\text{PER}\))、组织 (\(\text{ORG}\))、地点 (\(\text{LOC}\))、杂项实体 (\(\text{MISC}\)) 和无实体 (\(\text{O}\))。请注意,这些实体都带有 \(\text{B}\) (起始 \(\text{beginning}\)) 或 \(\text{I}\) (内部 \(\text{inside}\)) 的前缀。如果连续的两个词元属于同一个短语,则该短语的起始\(\text{B}\) 表示,随后是 \(\text{I}\),以表明它们相互关联非独立的实体

这个过程在图 \(\text{11}-20\) 中得到了进一步的说明。在图例中,由于 “\(\text{Dean}\)” 是短语的起始,而 “\(\text{Palmer}\)” 是内部,我们知道 “\(\text{Dean Palmer}\)” 是一个人名,并且 “\(\text{Dean}\)” 和 “\(\text{Palmer}\)不是独立的个体

F11.1

我们的数据已经经过预处理并分割成词汇,但尚未分割成词元。为此,我们将使用本章中一直使用的预训练模型 \(\text{bert-base-cased}\)\(\text{tokenizer}\) 对其进行进一步的词元化

1
2
3
4
5
6
7
8
9
10
from transformers import AutoModelForTokenClassification
# Load tokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
# Load model
model = AutoModelForTokenClassification.from_pretrained(
"bert-base-cased",
num_labels=len(id2label),
id2label=id2label,
label2id=label2id
)

让我们探索 \(\text{tokenizer}\) 将如何处理我们的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Split individual tokens into sub-tokens
token_ids = tokenizer(example["tokens"], is_split_into_words=True)["input_ids"]
sub_tokens = tokenizer.convert_ids_to_tokens(token_ids)
sub_tokens
['[CLS]',
'Dean',
'Palmer',
'hit',
'his',
'30th',
'home',
'##r',
'for',
'the',
'Rangers',
'.',
'[SEP]']

正如我们在第 \(\text{2}\) 章和第 \(\text{3}\) 章中所学到的,\(\text{tokenizer}\) 添加了 \(\text{[CLS]}\)\(\text{[SEP]}\) 词元。请注意,单词 “\(\text{homer}\)” 被进一步拆分为词元 “\(\text{home}\)” 和 “##r”。

这给我们带来了一个小问题,因为我们在词汇级别有标注数据,但在词元级别没有。这可以通过在词元化过程中标签与其子词元对应物对齐来解决。

让我们考虑单词 “\(\text{Maarten}\)”,它的标签是 \(\text{B-PER}\),表示这是一个人名。如果我们将这个词通过 \(\text{tokenizer}\),它会将这个词拆分成词元 “\(\text{Ma}\)”、“##arte” 和 “##n”。我们不能对所有词元都使用 \(\text{B-PER}\) 实体,因为这将表示这三个词元都是独立的个体每当一个实体被拆分成词元时第一个词元应该带有 \(\text{B}\) (起始),而后续的词元应该带有 \(\text{I}\) (内部)。

因此,“\(\text{Ma}\)” 将获得 \(\text{B-PER}\) 来表示短语的开始,而 “##arte” 和 “##n” 将获得 \(\text{I-PER}\) 来表示它们属于同一个短语。这个对齐过程如图 \(\text{11}-21\) 所示。

F11.1

我们创建一个名为 \(\text{align\_labels}\) 的函数,它将在词元化过程中对输入进行词元化,并将其与更新后的标签对齐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def align_labels(examples):
token_ids = tokenizer(
examples["tokens"],
truncation=True,
is_split_into_words=True
)
labels = examples["ner_tags"]
updated_labels = []
for index, label in enumerate(labels):
# Map tokens to their respective word
word_ids = token_ids.word_ids(batch_index=index)
previous_word_idx = None
label_ids = []
for word_idx in word_ids:
# The start of a new word
if word_idx != previous_word_idx:
previous_word_idx = word_idx
updated_label = -100 if word_idx is None else label[word_idx]
label_ids.append(updated_label)
# Special token is -100
elif word_idx is None:
label_ids.append(-100)
# If the label is B-XXX we change it to I-XXX
else:
updated_label = label[word_idx]
if updated_label % 2 == 1:

updated_label += 1
label_ids.append(updated_label)
updated_labels.append(label_ids)
token_ids["labels"] = updated_labels
return token_ids
tokenized = dataset.map(align_labels, batched=True)

查看我们的示例,请注意 \(\text{[CLS]}\)\(\text{[SEP]}\) 词元添加了额外的标签 (\(\text{-100}\))

1
2
3
4
5
# Difference between original and updated labels
print(f"Original: {example["ner_tags"]}")
print(f"Updated: {tokenized["train"][848]["labels"]}")
Original: [1, 2, 0, 0, 0, 0, 0, 0, 3, 0]
Updated: [-100, 1, 2, 0, 0, 0, 0, 0, 0, 0, 3, 0, -100]

现在我们已经对标签进行了词元化和对齐,我们可以开始考虑定义我们的评估指标。这也不同于我们之前所见。现在,我们每个文档有多个预测(即每个词元),而不是每个文档只有一个预测

我们将使用 \(\text{Hugging Face}\)\(\text{evaluate}\)来创建一个 \(\text{compute\_metrics}\) 函数,它允许我们在词元级别评估性能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import evaluate
# Load sequential evaluation
seqeval = evaluate.load("seqeval")
def compute_metrics(eval_pred):
# Create predictions
logits, labels = eval_pred
predictions = np.argmax(logits, axis=2)
true_predictions = []
true_labels = []
# Document-level iteration
for prediction, label in zip(predictions, labels):
# Token-level iteration
for token_prediction, token_label in zip(prediction, label):
# We ignore special tokens
if token_label != -100:
true_predictions.append([id2label[token_prediction]])
true_labels.append([id2label[token_label]])

results = seqeval.compute(
predictions=true_predictions, references=true_labels
)
return {"f1": results["overall_f1"]}

命名实体识别的微调

Fine-Tuning for Named-Entity Recognition

我们快完成了。我们不再使用 \(\text{DataCollatorWithPadding}\),而是需要一个适用于词元级别分类的整理器,即 \(\text{DataCollatorForTokenClassification}\)

1
2
3
from transformers import DataCollatorForTokenClassification
# Token-classification DataCollator
data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)

现在我们已经加载了模型,剩下的步骤与本章中之前的训练过程相似。我们定义一个带有可以调整的特定参数的训练器,并创建一个 \(\text{Trainer}\)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Training arguments for parameter tuning
training_args = TrainingArguments(
"model",
learning_rate=2e-5,
per_device_train_batch_size=16,
per_device_eval_batch_size=16,
num_train_epochs=1,
weight_decay=0.01,
save_strategy="epoch",
report_to="none"
)
# Initialize Trainer
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized["train"],
eval_dataset=tokenized["test"],
tokenizer=tokenizer,
data_collator=data_collator,
compute_metrics=compute_metrics,
)
trainer.train()

然后,我们评估我们创建的模型:

1
2
# Evaluate the model on our test data
trainer.evaluate()

最后,让我们保存模型并将其用于推理管道 (\(\text{pipeline}\))。这使我们能够检查某些数据,从而手动检查推理过程中发生的情况以及我们是否对输出感到满意

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from transformers import pipeline
# Save our fine-tuned model
trainer.save_model("ner_model")
# Run inference on the fine-tuned model
token_classifier = pipeline(
"token-classification",
model="ner_model",
)
token_classifier("My name is Maarten.")
[{'entity': 'B-PER',
'score': 0.99534035,
'index': 4,
'word': 'Ma',
'start': 11,
'end': 13},
{'entity': 'I-PER',
'score': 0.9928328,
'index': 5,
'word': '##arte',
'start': 13,
'end': 17},
{'entity': 'I-PER',
'score': 0.9954301,
'index': 6,
'word': '##n',
'start': 17,
'end': 18}]

在句子 “\(\text{My name is Maarten}\)” 中,单词 “\(\text{Maarten}\)” 及其子词元被正确地识别为人名 (\(\text{person}\))! ### 总结 Summary

在本章中,我们探索了几种用于在特定分类任务微调预训练表征模型的任务。我们首先演示了如何微调预训练的 \(\text{BERT}\) 模型,并通过冻结其架构的某些层来扩展了这些示例。

我们尝试了一种名为 \(\text{SetFit}\)少样本分类技术,它涉及使用有限的标注数据微调预训练的嵌入模型分类头。该模型仅使用少量标注数据点,就产生了与我们在前几章中探索的模型相似的性能

接下来,我们深入研究了持续预训练 (\(\text{continued pretraining}\)) 的概念,我们使用预训练的 \(\text{BERT}\) 模型作为起点,并使用不同的数据继续训练它。底层过程,即掩码语言建模 (\(\text{masked language modeling}\)),不仅用于创建表征模型,还可以用于持续预训练模型

最后,我们研究了命名实体识别 (\(\text{named-entity recognition}\)),这是一项涉及在非结构化文本中识别特定实体(例如人名和地点)的任务。与之前的示例相比,这种分类是在词汇级别而非文档级别上完成的。

在下一章中,我们将继续探讨微调语言模型的领域,但会转而关注生成模型。我们将使用两步法,探索如何微调生成模型正确遵循指令,然后微调它以符合人类偏好

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

\(\text{III}\) 部分 训练与微调大型语言模型

Training and Fine-Tuning Language Models

\(\text{10}\) 创建文本嵌入模型

Creating Text Embedding Models

文本嵌入模型是许多强大自然语言处理应用的基础。它们为赋能已经令人印象深刻的技术(如文本生成模型)奠定了基础。在本书中,我们已经将嵌入模型用于许多应用,例如有监督分类、无监督分类、语义搜索,甚至为像 \(\text{ChatGPT}\) 这样的文本生成模型赋予记忆

嵌入模型在这一领域的重要性几乎无法被夸大,因为它们是许多应用背后的驱动力。因此,在本章中,我们将讨论创建和微调嵌入模型的各种方法,以增强表征和语义能力

让我们从探索嵌入模型是什么以及它们通常如何工作开始。

嵌入模型

Embedding Models

嵌入嵌入模型已在相当多的章节(第 \(\text{4}\)\(\text{5}\)\(\text{8}\) 章)中进行过讨论,从而展示了它们的有用性。在深入探讨如何训练这样的模型之前,让我们回顾一下我们对嵌入模型所学到的知识。

非结构化文本数据本身通常很难处理。它们不是我们可以直接处理、可视化并从中创建可操作结果的数值。我们必须首先将这些文本数据转换为我们可以轻松处理的东西:数值表示。这个过程通常被称为嵌入输入,以输出可用的向量,即嵌入\(\text{embeddings}\)),如图 \(\text{10}-1\) 所示。

F10.1

这种嵌入输入的过程通常由一个 \(\text{LLM}\) 执行,我们称之为嵌入模型。这种模型的主要目的是在将文本数据表示为嵌入方面尽可能准确

然而,准确表示意味着什么呢?通常,我们希望捕获文档的语义性质——即含义。如果我们能够捕获文档所传达的核心内容,我们就希望已经捕获了文档的主题。在实践中,这意味着我们期望彼此相似的文档的向量是相似的,而各自讨论完全不同事物的文档的嵌入则应该是不相似的。我们在本书中已经多次看到语义相似性的这种思想,它在图 \(\text{10}-2\) 中得到了可视化。这个图是一个简化示例。虽然二维可视化有助于说明嵌入的接近度和相似性,但这些嵌入通常存在于高维空间中。

F10.1

然而,嵌入模型可以针对多种目的进行训练。例如,当我们构建一个情感分类器时,我们更感兴趣的是文本的情感,而不是它们的语义相似性。如图 \(\text{10}-3\) 所示,我们可以微调该模型,使文档在 \(\text{n}\) 维空间中基于它们的情感而不是它们的语义性质更接近

F10.1

无论如何,嵌入模型旨在学习是什么使某些文档彼此相似,而且我们可以引导这个过程。通过向模型展示足够多的语义相似文档的示例,我们可以导向语义;而使用情感的示例则会将其导向情感

有很多方法可以训练、微调和引导嵌入模型,但其中最强大且最广泛使用的技术之一被称为对比学习\(\text{contrastive learning}\))。

什么是对比学习?

What Is Contrastive Learning?

对比学习\(\text{contrastive learning}\))是用于训练和微调文本嵌入模型的一项主要技术。对比学习旨在训练一个嵌入模型,使得相似文档在向量空间中更接近,而不相似文档更远离。如果这听起来很熟悉,那是因为它与第 \(\text{2}\) 章中的 \(\text{word2vec}\) 方法非常相似。我们之前已经在图 \(\text{10}-2\)\(\text{10}-3\) 中看到过这个概念。

对比学习的基本思想是,学习和建模文档之间相似性/不相似性的最佳方法是向模型提供相似和不相似对的示例。为了准确捕获文档的语义性质,它通常需要与另一个文档进行对比,模型才能学习到是什么使它不同或相似。这种对比过程非常强大,并与文档被撰写的上下文相关。这种高层级过程在图 \(\text{10}-4\) 中得到了演示。

F10.1

看待对比学习的另一种方式是通过解释的性质。一个很好的例子是一个轶事:一位记者问一个劫匪:“你为什么抢银行?”他回答说:“因为钱在那里。” 尽管这是一个事实正确的答案,但问题的意图并不是他为什么专门抢银行,而是他为什么抢劫。这被称为对比解释\(\text{contrastive explanation}\)),指的是理解一个特定案例,“为什么是 \(\text{P}\)与替代方案的对比,“为什么是 \(\text{P}\) 而不是 \(\text{Q}\)”在例子中,这个问题可以有多种解释,最好的建模方式可能是提供一个替代方案:“你为什么抢银行 (\(\text{P}\)) 而不是遵守法律 (\(\text{Q}\))?

替代方案对于理解一个问题的重要性也适用于嵌入模型通过对比学习来学习的方式。通过向模型展示相似和不相似的文档对,它开始学习是什么使事物相似/不相似,更重要的是,为什么

例如,您可以通过让模型找到“尾巴”、“鼻子”、“四条腿”等特征来教会它理解什么是狗。这个学习过程可能非常困难,因为特征通常定义不明确,可以有多种解释。一个拥有“尾巴”、“鼻子”和“四条腿”的生物也可能是一只。为了帮助模型导向我们感兴趣的方向,我们实际上问它:“为什么这是一只狗而不是一只猫?”通过提供两个概念之间的对比,它开始学习定义概念的特征,以及不相关的特征。当我们以对比的方式提出问题时,我们会获得更多信息。我们在图 \(\text{10}-5\) 中进一步说明了对比解释的这个概念。

F10.1

\(\text{NLP}\) 中最早且最流行的对比学习的例子之一实际上是 \(\text{word2vec}\),正如我们在第 \(\text{1}\) 章和第 \(\text{2}\) 章中讨论的那样。该模型通过在句子中的单个单词上进行训练来学习单词表示。句子中靠近目标单词的词将被构建为正对,而随机采样的词则构成不相似对。换句话说,相邻词的正面示例随机选择的非相邻词进行对比。虽然不广为人知,但它是 \(\text{NLP}\) 中利用神经网络进行对比学习的最早重大突破之一

\(\text{SBERT}\)

SBERT

有很多方法可以应用对比学习来创建文本嵌入模型,但最著名的技术和框架之一是 \(\text{sentence-transformers}\)

尽管对比学习有多种形式,但有一个框架在自然语言处理社区中推广了这项技术,那就是 \(\text{sentence-transformers}\)。它的方法解决了原始 \(\text{BERT}\) 实现在创建句子嵌入方面的一个主要问题,即它的计算开销。在 \(\text{sentence-transformers}\) 之前,句子嵌入通常使用一种称为 \(\text{cross-encoders}\)架构结构\(\text{BERT}\) 结合。

交叉编码器\(\text{cross-encoder}\))允许同时将两个句子传递给 \(\text{Transformer}\) 网络,以预测这两个句子相似的程度。它通过在原始架构上添加一个分类头来实现,该分类头可以输出一个相似度分数。然而,当您想在包含 \(\text{10,000}\) 个句子的集合中找到相似度最高的句子对时,计算次数会迅速增加。这将需要 \(\text{n}\cdot(\text{n}−\text{1})/\text{2} = \text{49,995,000}\)推理计算,因此会产生显著的开销。此外,交叉编码器通常不生成嵌入,如图 \(\text{10}-6\) 所示。相反,它输出输入句子之间的相似度分数

F10.1

解决这种开销的一个方法是通过平均 \(\text{BERT}\) 的输出层或使用 \(\text{[CLS]}\) 词元\(\text{BERT}\) 模型生成嵌入。然而,这被证明比简单地平均 \(\text{GloVe}\) 等词向量更差

相反,\(\text{sentence-transformers}\) 的作者们采取了不同的方法来处理这个问题,并寻找一种快速且能创建可语义比较的嵌入的方法。结果是一种优雅地替代了原始交叉编码器架构的方法。与交叉编码器不同,在 \(\text{sentence-transformers}\) 中,分类头被移除,取而代之的是在最终输出层上使用平均池化\(\text{mean pooling}\))来生成嵌入。这个池化层词嵌入进行平均,并返回一个固定维度的输出向量。这确保了固定大小的嵌入

\(\text{sentence-transformers}\) 的训练使用孪生架构\(\text{Siamese architecture}\))。在这种架构中,如图 \(\text{10}-7\) 所示,我们有两个相同的 \(\text{BERT}\) 模型,它们共享相同的权重和神经网络架构。这些模型被输入句子,然后通过词元嵌入的池化生成嵌入。接着,模型通过句子嵌入的相似性进行优化。由于两个 \(\text{BERT}\) 模型的权重是相同的,我们可以使用单个模型,并将句子一个接一个地输入给它

F10.1

这可能对模型的性能产生重大影响。在训练期间,每个句子的嵌入嵌入之间的差异连接在一起。然后,通过一个 \(\text{softmax}\) 分类器对这个结果嵌入进行优化。

由此产生的架构也称为双编码器\(\text{bi-encoder}\))或 \(\text{SBERT}\)(即 \(\text{sentence-BERT}\))。尽管双编码器非常快速且能创建准确的句子表示,但交叉编码器通常比双编码器实现更好的性能,不过它不生成嵌入

双编码器像交叉编码器一样,利用了对比学习;通过优化句子对之间的(不)相似性,模型最终将学习到是什么使得这些句子成为它们本身的东西。

创建嵌入模型

Creating an Embedding Model

要执行对比学习,我们需要两样东西。第一,我们需要构成相似/不相似对的数据。第二,我们需要定义模型如何定义和优化相似性

创建嵌入模型的方法有很多,但我们通常倾向于对比学习。这是许多嵌入模型的一个重要方面,因为这个过程允许它有效地学习语义表示

然而,这不是一个免费的过程。我们需要理解如何生成对比示例如何训练模型以及如何正确评估它

生成对比示例

Generating Contrastive Examples

预训练嵌入模型时,您经常会看到使用了来自自然语言推理 (\(\text{NLI}\)) 数据集的数据。\(\text{NLI}\) 指的是调查对于一个给定的前提 (\(\text{premise}\)),它是否推断出假设 (\(\text{hypothesis}\))(蕴含 \(\text{entailment}\))、与假设矛盾(矛盾 \(\text{contradiction}\)),或两者皆非(中性 \(\text{neutral}\))的任务

例如,当前提是“\(\text{He is in the cinema watching Coco}\)”(他正在电影院看《寻梦环游记》)而假设是“\(\text{He is watching Frozen at home}\)”(他正在家里看《冰雪奇缘》)时,这些陈述是矛盾的。相反,当前提是“\(\text{He is in the cinema watching Coco}\)”(他正在电影院看《寻梦环游记》)而假设是“\(\text{In the movie theater he is watching the Disney movie Coco}\)”(在电影院里他正在看迪士尼电影《寻梦环游记》)时,这些陈述被认为是蕴含。这个原理如图 \(\text{10}-8\) 所示。

F10.1

如果您仔细观察蕴含矛盾,它们描述了两个输入彼此相似的程度。因此,我们可以使用 \(\text{NLI}\) 数据集来为对比学习生成负面示例(矛盾)和正面示例(蕴含)

我们将要用于创建和微调嵌入模型的数据来源于通用语言理解评估基准 (\(\text{General Language Understanding Evaluation benchmark}, \text{GLUE}\)) 。这个 \(\text{GLUE}\) 基准包括九项语言理解任务来评估和分析模型性能。

其中一项任务是多类型自然语言推理语料库 (\(\text{Multi-Genre Natural Language Inference corpus}, \text{MNLI}\)),它是一个包含 \(\text{392,702}\) 个句子对的集合,并标注了蕴含(矛盾、中性和蕴含)。我们将使用该数据的一个子集\(\text{50,000}\) 个标注的句子对)来创建一个不需要连续训练数小时的最小示例。但请注意,数据集越小,训练或微调嵌入模型的稳定性就越差。如果可能,假设数据质量仍然很高,则首选更大的数据集

1
2
3
4
5
6
7
from datasets import load_dataset
# Load MNLI dataset from GLUE
# 0 = entailment, 1 = neutral, 2 = contradiction
train_dataset = load_dataset(
"glue", "mnli", split="train"
).select(range(50_000))
train_dataset = train_dataset.remove_columns("idx")

接下来,我们看一个例子:

1
dataset[2]
1
2
3
4
{'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}

这显示了一个前提和假设之间蕴含的例子,因为它们正相关并且具有几乎相同的含义

训练模型

Train Model

现在我们有了带有训练示例的数据集,我们需要创建我们的嵌入模型。我们通常会选择一个现有的 \(\text{sentence-transformers}\) 模型微调该模型,但在本例中,我们将从头开始训练一个嵌入模型

这意味着我们将不得不定义两件事。第一,一个预训练的 \(\text{Transformer}\) 模型,用作嵌入单个词。我们将使用 \(\text{BERT}\) 基础模型 (\(\text{uncased}\)),因为它是一个很好的入门模型。当然,还有许多其他模型存在,它们也经过了 \(\text{sentence-transformers}\) 的评估。最值得注意的是,当用作词嵌入模型时,\(\text{microsoft/mpnet-base}\) 通常能给出不错的结果

1
2
3
from sentence_transformers import SentenceTransformer
# Use a base model
embedding_model = SentenceTransformer('bert-base-uncased')

默认情况下,\(\text{sentence-transformers}\) 中的 \(\text{LLM}\)所有层都是可训练的。虽然可以冻结某些层,但通常不建议这样做,因为在解冻所有层时,性能通常会更好。

接下来,我们需要定义一个我们将优化模型所依据的损失函数。正如本节开头所提到的,\(\text{sentence-transformers}\) 的最早实例之一使用了 \(\text{softmax}\) 损失。出于演示目的,我们现在将使用它,但我们稍后会介绍性能更高的损失函数:

1
2
3
4
5
6
7
8
9
from sentence_transformers import losses
# Define the loss function. In softmax loss, we will also need to explicitly
# set the number of labels.
train_loss = losses.SoftmaxLoss(
model=embedding_model,
sentence_embedding_dimension=embedding_model.get_sentence_embedding_dimen
sion(),
num_labels=3
)

在训练模型之前,我们定义一个评估器,用于在训练期间评估模型的性能,它也决定了要保存的最佳模型

我们可以使用语义文本相似度基准 (\(\text{Semantic Textual Similarity Benchmark}, \text{STSB}\)) 来评估我们模型的性能。它是一个人工标注的句子对集合,相似度分数介于 \(\text{1}\)\(\text{5}\) 之间

我们使用这个数据集来探究我们的模型在这个语义相似度任务上的得分如何。此外,我们处理 \(\text{STSB}\) 数据,以确保所有值都在 \(\text{0}\)\(\text{1}\) 之间

1
2
3
4
5
6
7
8
9
from sentence_transformers.evaluation import EmbeddingSimilarityEvaluator
# Create an embedding similarity evaluator for STSB
val_sts = load_dataset("glue", "stsb", split="validation")
evaluator = EmbeddingSimilarityEvaluator(
sentences1=val_sts["sentence1"],
sentences2=val_sts["sentence2"],
scores=[score/5 for score in val_sts["label"]],
main_similarity="cosine",
)

现在我们有了评估器,我们创建 \(\text{SentenceTransformerTrainingArguments}\),这与使用 \(\text{Hugging Face Transformers}\) 进行训练类似(我们将在下一章中探讨):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from sentence_transformers.training_args import SentenceTransformerTrainingArgu
ments
# Define the training arguments

args = SentenceTransformerTrainingArguments(
output_dir="base_embedding_model",
num_train_epochs=1,
per_device_train_batch_size=32,
per_device_eval_batch_size=32,
warmup_steps=100,
fp16=True,
eval_steps=100,
logging_steps=100,
)

值得注意的参数包括:

  • \(\text{num\_train\_epochs}\) 训练的轮数。为了更快的训练,我们将其保持为 \(\text{1}\),但通常建议增加此值
  • \(\text{per\_device\_train\_batch\_size}\)训练期间,在每个设备(例如 \(\text{GPU}\)\(\text{CPU}\))上同时处理的样本数量。值越高通常意味着训练速度越快
  • \(\text{per\_device\_eval\_batch\_size}\)评估期间,在每个设备(例如 \(\text{GPU}\)\(\text{CPU}\))上同时处理的样本数量。值越高通常意味着评估速度越快
  • \(\text{warmup\_steps}\) 学习率将从零线性增加到为训练过程定义的初始学习率步数。请注意,我们没有为这个训练过程指定自定义的学习率。
  • \(\text{fp16}\) 通过启用此参数,我们允许混合精度训练,其中计算使用 \(\text{16}\) 位浮点数 (\(\text{FP16}\)) 而不是默认的 \(\text{32}\) 位 (\(\text{FP32}\)) 来执行。这可以减少内存使用可能提高训练速度

现在我们已经定义了我们的数据、嵌入模型、损失函数评估器,我们可以开始训练我们的模型了。我们可以使用 \(\text{SentenceTransformerTrainer}\) 来实现:

1
2
3
4
5
6
7
8
9
10
from sentence_transformers.trainer import SentenceTransformerTrainer
# Train embedding model
trainer = SentenceTransformerTrainer(
model=embedding_model,
args=args,
train_dataset=train_dataset,
loss=train_loss,
evaluator=evaluator
)
trainer.train()

训练完模型后,我们可以使用评估器来获取该单一任务上的性能:

1
2
# Evaluate our trained model
evaluator(embedding_model)
1
2
3
4
5
6
7
8
9
10
{'pearson_cosine': 0.5982288436666162,
'spearman_cosine': 0.6026682018489217,
'pearson_manhattan': 0.6100690915500567,
'spearman_manhattan': 0.617732600131989,
'pearson_euclidean': 0.6079280934202278,
'spearman_euclidean': 0.6158926913905742,
'pearson_dot': 0.38364924527804595,
'spearman_dot': 0.37008497926991796,
'pearson_max': 0.6100690915500567,
'spearman_max': 0.617732600131989}

我们得到了几种不同的距离度量。我们最感兴趣的是 \(\text{'pearson\_cosine'}\),它是中心化向量之间的余弦相似度。它的值介于 \(\text{0}\)\(\text{1}\) 之间,值越高表示相似度越高。我们得到了一个 \(\text{0.59}\) 的值,我们将其视为贯穿本章的基准

较大的批量大小往往与多重负样本排序损失 (\(\text{MNR}\) loss) 一起表现得更好,因为较大的批量使任务更困难。这样做的原因是模型需要从更大的潜在句子对集合中找到最匹配的句子。您可以修改代码以尝试不同的批量大小并感受其影响。

深入评估

In-Depth Evaluation

一个好的嵌入模型不仅仅是在 \(\text{STSB}\) 基准上获得一个好分数!正如我们之前观察到的,\(\text{GLUE}\) 基准有许多任务可用于评估我们的嵌入模型。然而,存在更多允许评估嵌入模型的基准。为了统一这种评估程序,开发了海量文本嵌入基准 (\(\text{Massive Text Embedding Benchmark}, \text{MTEB}\))\(\text{MTEB}\) 涵盖了 \(\text{8}\) 个嵌入任务,涉及 \(\text{58}\) 个数据集和 \(\text{112}\) 种语言。

为了公开比较最先进的嵌入模型,创建了一个排行榜,其中包含每个嵌入模型在所有任务上的分数:

1
2
3
4
5
6
from mteb import MTEB
# Choose evaluation task
evaluation = MTEB(tasks=["Banking77Classification"])

# Calculate results
results = evaluation.run(model)
1
2
3
4
5
6
7
8
9
{'Banking77Classification': {'mteb_version': '1.1.2',
'dataset_revision': '0fd18e25b25c072e09e0d92ab615fda904d66300',
'mteb_dataset_name': 'Banking77Classification',
'test': {'accuracy': 0.4926298701298701,
'f1': 0.49083335791288685,
'accuracy_stderr': 0.010217785746224237,
'f1_stderr': 0.010265814957074591,
'main_score': 0.4926298701298701,
'evaluation_time': 31.83}}}

这为我们提供了针对这个特定任务的几种评估指标,我们可以使用它们来探究其性能。

这个评估基准的出色之处不仅在于任务和语言的多样性,还在于甚至评估时间也被保存下来。尽管存在许多嵌入模型,但我们通常希望那些既准确又具有低延迟的模型。用于嵌入模型的任务,例如语义搜索,通常受益于并需要快速推理

由于在整个 \(\text{MTEB}\) 上测试您的模型可能需要几个小时(取决于您的 \(\text{GPU}\)),因此在本章中,我们将继续使用 \(\text{STSB}\) 基准进行说明。

无论何时您完成模型的训练和评估,重新启动 \(\text{notebook}\) 都是很重要的。这将清除您的 \(\text{VRAM}\),以用于本章中的下一个训练示例。通过重新启动 \(\text{notebook}\),我们可以确保所有 \(\text{VRAM}\) 都被清除

损失函数

Loss Functions

我们使用 \(\text{softmax}\) 损失来训练我们的模型,以说明最早的 \(\text{sentence-transformers}\) 模型之一是如何被训练的。然而,不仅有多种多样的损失函数可供选择,而且通常不建议使用 \(\text{softmax}\) 损失,因为有性能更高的损失函数

我们不会去逐一介绍所有可用的损失函数,而是介绍通常使用且表现普遍良好的两种损失函数,即:

  • 余弦相似度 (\(\text{Cosine similarity}\))
  • 多重负样本排序损失 (\(\text{Multiple negatives ranking (MNR) loss}\))

除了这里讨论的损失函数之外,还有更多可供选择的损失函数。例如,像 \(\text{MarginMSE}\) 这样的损失函数非常适用于训练或微调交叉编码器。在 \(\text{sentence-transformers}\) 框架中实现了一些有趣的损失函数

余弦相似度

Cosine similarity

余弦相似度损失\(\text{Cosine similarity loss}\))是一种直观且易于使用的损失函数,适用于许多不同的用例和数据集。它通常用于语义文本相似度 (\(\text{Semantic Textual Similarity, STS}\)) 任务。在这些任务中,相似度分数被分配给文本对,我们依据这个分数来优化模型。

我们不要求句子对是严格的正面或负面关系,而是假设句子对之间存在一定程度的相似或不相似。通常,这个值在 \(\text{0}\)\(\text{1}\) 之间,分别表示不相似相似(图 \(\text{10}-9\))。

F10.9

余弦相似度损失非常简单——它计算两个文本嵌入之间的余弦相似度,并将该值与标注的相似度分数进行比较。模型将学习识别句子之间的相似程度

余弦相似度损失在您拥有句子对指示其相似度在 \(\text{0}\)\(\text{1}\) 之间的标签的数据上,直观上效果最好。要将这种损失用于我们的 \(\text{NLI}\) 数据集,我们需要将蕴含 (\(\text{0}\))、中性 (\(\text{1}\)) 和矛盾 (\(\text{2}\)) 标签转换为介于 \(\text{0}\)\(\text{1}\) 之间的值蕴含表示句子之间具有高相似度,因此我们给它一个 \(\text{1}\) 的相似度分数。相比之下,由于中性和矛盾都表示不相似,我们给这些标签一个 \(\text{0}\) 的相似度分数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from datasets import Dataset, load_dataset
# Load MNLI dataset from GLUE
# 0 = entailment, 1 = neutral, 2 = contradiction
train_dataset = load_dataset(
"glue", "mnli", split="train"
).select(range(50_000))
train_dataset = train_dataset.remove_columns("idx")
# (neutral/contradiction)=0 and (entailment)=1
mapping = {2: 0, 1: 0, 0:1}
train_dataset = Dataset.from_dict({
"sentence1": train_dataset["premise"],
"sentence2": train_dataset["hypothesis"],
"label": [float(mapping[label]) for label in train_dataset["label"]]
})

和以前一样,我们创建评估器:

1
2
3
4
5
6
7
8
9
from sentence_transformers.evaluation import EmbeddingSimilarityEvaluator
# Create an embedding similarity evaluator for stsb
val_sts = load_dataset("glue", "stsb", split="validation")
evaluator = EmbeddingSimilarityEvaluator(
sentences1=val_sts["sentence1"],
sentences2=val_sts["sentence2"],
scores=[score/5 for score in val_sts["label"]],
main_similarity="cosine"
)

然后,我们遵循与之前相同的步骤,只是选择了不同的损失函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from sentence_transformers import losses, SentenceTransformer
from sentence_transformers.trainer import SentenceTransformerTrainer
from sentence_transformers.training_args import SentenceTransformerTrainingArgu
ments
# Define model
embedding_model = SentenceTransformer("bert-base-uncased")
# Loss function
train_loss = losses.CosineSimilarityLoss(model=embedding_model)
# Define the training arguments
args = SentenceTransformerTrainingArguments(
output_dir="cosineloss_embedding_model",
num_train_epochs=1,
per_device_train_batch_size=32,
per_device_eval_batch_size=32,

warmup_steps=100,
fp16=True,
eval_steps=100,
logging_steps=100,
)
# Train model
trainer = SentenceTransformerTrainer(
model=embedding_model,
args=args,
train_dataset=train_dataset,
loss=train_loss,
evaluator=evaluator
)
trainer.train()

训练后评估模型,我们得到以下分数:

1
2
# Evaluate our trained model
evaluator(embedding_model)
1
2
3
4
5
6
7
8
9
10
{'pearson_cosine': 0.7222322163831805,
'spearman_cosine': 0.7250508271229599,
'pearson_manhattan': 0.7338163436711481,
'spearman_manhattan': 0.7323479193408869,
'pearson_euclidean': 0.7332716434966307,
'spearman_euclidean': 0.7316999722750905,
'pearson_dot': 0.660366792336156,
'spearman_dot': 0.6624167554844425,
'pearson_max': 0.7338163436711481,
'spearman_max': 0.7323479193408869}

\(\text{Pearson}\) 余弦分数为 \(\text{0.72}\),与 \(\text{softmax}\) 损失示例(得分为 \(\text{0.59}\))相比,这是一个巨大的改进。这证明了损失函数对性能的影响

请确保重新启动您的 \(\text{notebook}\),以便我们可以探索一个更常见且性能更高的损失函数,即多重负样本排序损失

多重负样本排序损失

Multiple negatives ranking loss

多重负样本排序 (\(\text{Multiple Negatives Ranking}, \text{MNR}\)) 损失,通常被称为 \(\text{InfoNCE}\)\(\text{NTXentLoss}\),是一种使用句子正对或包含一对正向句子和一个额外不相关句子三元组的损失函数。这个不相关的句子被称为负样本\(\text{negative}\)),代表了正向句子之间的不相似性

例如,您可能有问答对图像/图像说明文字对论文标题/论文摘要对等。这些配对的绝妙之处在于我们可以确信它们是硬正样本对\(\text{hard positive pairs}\))。在 \(\text{MNR}\) 损失中(图 \(\text{10}-10\)),负样本对是通过将一个正样本对与另一个正样本对混合来构建的。在论文标题和摘要的例子中,您可以通过将一篇论文的标题一篇完全不同论文的摘要结合来生成一个负样本对。这些负样本被称为批内负样本\(\text{in-batch negatives}\)),它们也可以用于生成三元组

F10.10

在生成这些正样本对和负样本对之后,我们计算它们的嵌入并应用余弦相似度。然后,这些相似度分数被用来回答一个问题:这些对是负样本还是正样本?换句话说,它被视为一个分类任务,我们可以使用交叉熵损失\(\text{cross-entropy loss}\))来优化模型。

为了制作这些三元组,我们从一个锚定句\(\text{anchor sentence}\))(即被标记为“\(\text{premise}\)”)开始,它用于比较其他句子。然后,使用 \(\text{MNLI}\) 数据集,我们只选择那些正向的句子对(即被标记为“\(\text{entailment}\)”)。为了添加负向句子,我们随机采样句子作为“\(\text{hypothesis}\)”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import random
from tqdm import tqdm
from datasets import Dataset, load_dataset
# # Load MNLI dataset from GLUE
mnli = load_dataset("glue", "mnli", split="train").select(range(50_000))
mnli = mnli.remove_columns("idx")

mnli = mnli.filter(lambda x: True if x["label"] == 0 else False)
# Prepare data and add a soft negative
train_dataset = {"anchor": [], "positive": [], "negative": []}
soft_negatives = mnli["hypothesis"]
random.shuffle(soft_negatives)
for row, soft_negative in tqdm(zip(mnli, soft_negatives)):
train_dataset["anchor"].append(row["premise"])
train_dataset["positive"].append(row["hypothesis"])
train_dataset["negative"].append(soft_negative)
train_dataset = Dataset.from_dict(train_dataset)

由于我们只选择了被标记为“\(\text{entailment}\)”的句子,行数从 \(\text{50,000}\)减少到 \(\text{16,875}\)

让我们定义评估器:

1
2
3
4
5
6
7
8
9
from sentence_transformers.evaluation import EmbeddingSimilarityEvaluator
# Create an embedding similarity evaluator for stsb
val_sts = load_dataset("glue", "stsb", split="validation")
evaluator = EmbeddingSimilarityEvaluator(
sentences1=val_sts["sentence1"],
sentences2=val_sts["sentence2"],
scores=[score/5 for score in val_sts["label"]],
main_similarity="cosine"
)

然后,我们像以前一样进行训练,但改用 \(\text{MNR}\) 损失

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from sentence_transformers import losses, SentenceTransformer
from sentence_transformers.trainer import SentenceTransformerTrainer
from sentence_transformers.training_args import SentenceTransformerTrainingArgu
ments
# Define model
embedding_model = SentenceTransformer('bert-base-uncased')
# Loss function
train_loss = losses.MultipleNegativesRankingLoss(model=embedding_model)
# Define the training arguments
args = SentenceTransformerTrainingArguments(
output_dir="mnrloss_embedding_model",
num_train_epochs=1,
per_device_train_batch_size=32,
per_device_eval_batch_size=32,
warmup_steps=100,
fp16=True,
eval_steps=100,
logging_steps=100,
)
# Train model

trainer = SentenceTransformerTrainer(
model=embedding_model,
args=args,
train_dataset=train_dataset,
loss=train_loss,
evaluator=evaluator
)
trainer.train()

让我们看看这个数据集和损失函数与我们之前的示例相比如何:

1
2
# Evaluate our trained model
evaluator(embedding_model)
1
2
3
4
5
6
7
8
9
10
{'pearson_cosine': 0.8093892326162132,
'spearman_cosine': 0.8121064796503025,
'pearson_manhattan': 0.8215001523827565,
'spearman_manhattan': 0.8172161486524246,
'pearson_euclidean': 0.8210391407846718,
'spearman_euclidean': 0.8166537141010816,
'pearson_dot': 0.7473360302629125,
'spearman_dot': 0.7345184137194012,
'pearson_max': 0.8215001523827565,
'spearman_max': 0.8172161486524246}

与我们之前使用 \(\text{softmax}\) 损失训练的模型(\(\text{0.72}\))相比,我们使用 \(\text{MNR}\) 损失训练的模型(\(\text{0.80}\))似乎准确得多

对于 \(\text{MNR}\) 损失,较大的批量大小往往表现得更好,因为较大的批量使任务更困难。这样做的原因是模型需要从更大的潜在句子对集合中找到最匹配的句子。您可以修改代码以尝试不同的批量大小并感受其影响。

我们使用这种损失函数的方式有一个缺点。由于负样本是从其他问答对中采样的,我们使用的这些批内“简单”负样本\(\text{easy negatives}\))可能与问题完全不相关。结果是,嵌入模型找到问题正确答案的任务变得相当容易

相反,我们希望负样本是与问题高度相关但不是正确答案的。这些负样本被称为困难负样本\(\text{hard negatives}\))。由于这会使嵌入模型的任务更困难(因为它必须学习更细致入微的表示),因此嵌入模型的性能通常会提高不少

困难负样本的一个很好的例子如下。假设我们有以下问题:“\(\text{How many people live in Amsterdam?}\)”(阿姆斯特丹有多少人居住?)一个相关的答案会是:“\(\text{Almost a million people live in Amsterdam.}\)”(阿姆斯特丹有近一百万人居住。)为了生成一个好的困难负样本,我们理想情况下希望答案包含一些关于阿姆斯特丹居住人数的内容。例如:“\(\text{More than a million people live in Utrecht, which is more than Amsterdam.}\)”(超过一百万人居住在乌得勒支,这比阿姆斯特丹要多。)这个答案与问题相关,但不是实际的答案,因此这是一个很好的困难负样本。图 \(\text{10}-11\) 说明了简单负样本困难负样本之间的区别。

F10.11

收集负样本大致可以分为以下三个过程:

  • 简单负样本 (\(\text{Easy negatives}\)) 通过像我们之前所做的那样随机采样文档
  • 半困难负样本 (\(\text{Semi-hard negatives}\)) 使用预训练的嵌入模型,我们可以对所有句子嵌入应用余弦相似度,以找到高度相关的那些。通常,这不会产生困难负样本,因为此方法仅找到相似的句子,而不是问答对。
  • 困难负样本 (\(\text{Hard negatives}\)) 这些通常需要手动标注(例如,通过生成半困难负样本)或者您可以使用生成模型判断或生成句子对

请确保重新启动您的 \(\text{notebook}\),以便我们可以探索微调嵌入模型的不同方法

微调嵌入模型

Fine-Tuning an Embedding Model

在上一节中,我们回顾了从头开始训练嵌入模型的基础知识,并了解了如何利用损失函数来进一步优化其性能。这种方法虽然非常强大,但需要从头创建嵌入模型。这个过程可能成本高昂且耗时

相反,\(\text{sentence-transformers}\) 框架允许几乎所有嵌入模型用作微调的基底。我们可以选择一个已经在大量数据上训练过的嵌入模型,并针对我们特定的数据或目的对其进行微调。

微调模型有多种方法,具体取决于数据可用性和领域。我们将介绍其中两种方法,并展示利用预训练嵌入模型的强大之处

有监督

Supervised

微调嵌入模型最直接的方法重复我们之前训练模型的过程,但将 \(\text{'bert-base-uncased'}\) 替换为预训练的 \(\text{sentence-transformers}\) 模型。有很多模型可供选择,但通常 \(\text{all-MiniLM-L6-v2}\) 在许多用例中表现良好,并且由于其体积小速度非常快

我们使用与我们在 \(\text{MNR}\) 损失示例中训练模型时使用的相同数据,但改为使用预训练的嵌入模型进行微调。像往常一样,让我们从加载数据和创建评估器开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from datasets import load_dataset
from sentence_transformers.evaluation import EmbeddingSimilarityEvaluator
# Load MNLI dataset from GLUE
# 0 = entailment, 1 = neutral, 2 = contradiction
train_dataset = load_dataset(
"glue", "mnli", split="train"
).select(range(50_000))
train_dataset = train_dataset.remove_columns("idx")
# Create an embedding similarity evaluator for stsb
val_sts = load_dataset("glue", "stsb", split="validation")
evaluator = EmbeddingSimilarityEvaluator(
sentences1=val_sts["sentence1"],
sentences2=val_sts["sentence2"],
scores=[score/5 for score in val_sts["label"]],
main_similarity="cosine"
)

训练步骤与我们之前的示例相似,但我们没有使用 \(\text{'bert-base-uncased'}\),而是使用了一个预训练的嵌入模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from sentence_transformers import losses, SentenceTransformer
from sentence_transformers.trainer import SentenceTransformerTrainer
from sentence_transformers.training_args import SentenceTransformerTrainingArgu
ments
# Define model
embedding_model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
# Loss function
train_loss = losses.MultipleNegativesRankingLoss(model=embedding_model)
# Define the training arguments
args = SentenceTransformerTrainingArguments(
output_dir="finetuned_embedding_model",
num_train_epochs=1,
per_device_train_batch_size=32,
per_device_eval_batch_size=32,
warmup_steps=100,
fp16=True,
eval_steps=100,
logging_steps=100,
)
# Train model
trainer = SentenceTransformerTrainer(
model=embedding_model,
args=args,
train_dataset=train_dataset,
loss=train_loss,
evaluator=evaluator
)
trainer.train()

评估这个模型,我们得到以下分数:

1
2
3
4
5
6
7
8
9
10
11
12
# Evaluate our trained model
evaluator(embedding_model)
{'pearson_cosine': 0.8509553350510896,
'spearman_cosine': 0.8484676559567688,
'pearson_manhattan': 0.8503896832470704,
'spearman_manhattan': 0.8475760325664419,
'pearson_euclidean': 0.8513115442079158,
'spearman_euclidean': 0.8484676559567688,
'pearson_dot': 0.8489553386816947,
'spearman_dot': 0.8484676559567688,
'pearson_max': 0.8513115442079158,
'spearman_max': 0.8484676559567688}

尽管 \(\text{0.85}\) 的分数是我们迄今为止看到的最高分,但我们用于微调的预训练模型已经在完整的 \(\text{MNLI}\) 数据集上进行了训练,而我们只使用了 \(\text{50,000}\) 个示例。这看起来可能有些多余,但这个例子演示了如何在你自己的数据上微调一个预训练的嵌入模型

你可以不使用\(\text{'bert-base-uncased'}\) 这样的预训练 \(\text{BERT}\) 模型或像 \(\text{'all-mpnet-base-v2'}\) 这样可能超出领域的模型,而是先对预训练的 \(\text{BERT}\) 模型执行掩码语言建模\(\text{masked language modeling}\)),使其适应你的领域。然后,你可以使用这个微调过的 \(\text{BERT}\) 模型作为训练你的嵌入模型的基底。这是一种领域适应的形式。在下一章中,我们将对预训练模型应用掩码语言建模

请注意,训练或微调模型的主要难点在于找到合适的数据。对于这些模型,我们不仅希望拥有非常大的数据集,而且数据本身需要具有高质量。开发正样本对通常是直截了当的,但添加困难负样本对显著增加创建高质量数据的难度

像往常一样,重新启动您的 \(\text{notebook}\) 以释放 \(\text{VRAM}\),用于接下来的示例。

增强型 \(\text{SBERT}\)

Augmented SBERT

训练或微调这些嵌入模型的一个缺点是它们通常需要大量的训练数据。其中许多模型是在超过十亿个句子对上训练的。为您的用例提取如此大量的句子对通常是不可能的,因为在许多情况下,只有几千个标注数据点可用

幸运的是,有一种方法可以增强您的数据,使得在只有少量标注数据可用的情况下,也可以对嵌入模型进行微调。这个过程被称为增强型 \(\text{SBERT}\) (\(\text{Augmented SBERT}\))。

在这个过程中,我们旨在增强少量标注数据,使其可以用于常规训练。它利用较慢但更准确的交叉编码器 (\(\text{cross-encoder}\)) 架构 (\(\text{BERT}\)) 来增强和标注一组更大的输入对。然后,这些新标注的对被用于微调双编码器 (\(\text{bi-encoder}\)) (\(\text{SBERT}\)) 。

如图 \(\text{10}-12\) 所示,增强型 \(\text{SBERT}\) 涉及以下步骤:

  1. 使用小型、已标注的数据集黄金数据集 \(\text{gold dataset}\)微调一个交叉编码器 (\(\text{BERT}\))
  2. 创建新的句子对
  3. 使用微调后的交叉编码器对新的句子对进行标注白银数据集 \(\text{silver dataset}\))。
  4. 扩展后的数据集黄金 + 白银数据集)上训练一个双编码器 (\(\text{SBERT}\))

F10.12

在这里,黄金数据集是一个小型但完全标注的数据集,它包含真实标签\(\text{ground truth}\))。白银数据集完全标注,但不一定代表真实标签,因为它是由交叉编码器的预测生成的。

在我们开始执行上述步骤之前,让我们先准备数据。我们不使用最初的 \(\text{50,000}\) 个文档,而是取一个 \(\text{10,000}\) 个文档的子集来模拟我们标注数据有限的场景。正如我们在余弦相似度损失的示例中所做的那样,蕴含获得分数 \(\text{1}\),而中性和矛盾获得分数 \(\text{0}\)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import pandas as pd
from tqdm import tqdm
from datasets import load_dataset, Dataset
from sentence_transformers import InputExample
from sentence_transformers.datasets import NoDuplicatesDataLoader
# Prepare a small set of 10000 documents for the cross-encoder
dataset = load_dataset("glue", "mnli", split="train").select(range(10_000))
mapping = {2: 0, 1: 0, 0:1}
# Data loader
gold_examples = [
InputExample(texts=[row["premise"], row["hypothesis"]], label=map
ping[row["label"]])
for row in tqdm(dataset)
]

gold_dataloader = NoDuplicatesDataLoader(gold_examples, batch_size=32)
# Pandas DataFrame for easier data handling
gold = pd.DataFrame(
{
"sentence1": dataset["premise"],
"sentence2": dataset["hypothesis"],
"label": [mapping[label] for label in dataset["label"]]
}
)

这是黄金数据集,因为它已标注并代表了我们的真实标签

使用这个黄金数据集,我们训练我们的交叉编码器步骤 \(\text{1}\)):

1
2
3
4
5
6
7
8
9
10
from sentence_transformers.cross_encoder import CrossEncoder
# Train a cross-encoder on the gold dataset
cross_encoder = CrossEncoder("bert-base-uncased", num_labels=2)
cross_encoder.fit(
train_dataloader=gold_dataloader,
epochs=1,
show_progress_bar=True,
warmup_steps=100,
use_amp=False
)

在训练完我们的交叉编码器之后,我们将剩余的 \(\text{400,000}\) 个句子对(来自我们最初的 \(\text{50,000}\) 个句子对数据集)用作我们的白银数据集步骤 \(\text{2}\)):

1
2
3
4
5
# Prepare the silver dataset by predicting labels with the cross-encoder
silver = load_dataset(
"glue", "mnli", split="train"
).select(range(10_000, 50_000))
pairs = list(zip(silver["premise"], silver["hypothesis"]))

如果您没有任何额外的未标注句子对,您可以从原始的黄金数据集随机采样。举例来说,您可以从一行中取出前提从另一行中取出假设,从而创建一个新的句子对。这使您可以轻松地生成多达 \(\text{10}\) 倍的句子对,然后可以使用交叉编码器进行标注

然而,这种策略很可能产生比相似对多得多的不相似对。相反,我们可以使用预训练的嵌入模型嵌入所有候选句子对,并使用语义搜索为每个输入句子检索 \(\text{top-k}\) 个句子。这种粗略的重排序过程使我们能够专注于可能更相似的句子对。尽管这些句子仍然是基于近似值选择的(因为预训练的嵌入模型未在我们的数据上训练),但这比随机采样要好得多

请注意,在本例中,我们假设这些句子对是未标注的。我们将使用我们微调过的交叉编码器标注这些句子对步骤 \(\text{3}\)):

1
2
3
4
5
6
7
8
9
10
11
12
13
import numpy as np
# Label the sentence pairs using our fine-tuned cross-encoder
output = cross_encoder.predict(
pairs, apply_softmax=True,
show_progress_bar=True
)
silver = pd.DataFrame(
{
"sentence1": silver["premise"],
"sentence2": silver["hypothesis"],
"label": np.argmax(output, axis=1)
}
)

现在我们有了白银数据集和黄金数据集,我们只需将它们结合起来,并像以前一样训练我们的嵌入模型

1
2
3
4
# Combine gold + silver
data = pd.concat([gold, silver], ignore_index=True, axis=0)
data = data.drop_duplicates(subset=["sentence1", "sentence2"], keep="first")
train_dataset = Dataset.from_pandas(data, preserve_index=False)

一如既往,我们需要定义我们的评估器:

1
2
3
4
5
6
7
8
9
from sentence_transformers.evaluation import EmbeddingSimilarityEvaluator
# Create an embedding similarity evaluator for stsb
val_sts = load_dataset("glue", "stsb", split="validation")
evaluator = EmbeddingSimilarityEvaluator(
sentences1=val_sts["sentence1"],
sentences2=val_sts["sentence2"],
scores=[score/5 for score in val_sts["label"]],
main_similarity="cosine"
)

我们像以前一样训练模型,但现在使用增强后的数据集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from sentence_transformers import losses, SentenceTransformer
from sentence_transformers.trainer import SentenceTransformerTrainer
from sentence_transformers.training_args import SentenceTransformerTrainingArgu
ments
# Define model
embedding_model = SentenceTransformer("bert-base-uncased")
# Loss function
train_loss = losses.CosineSimilarityLoss(model=embedding_model)
# Define the training arguments
args = SentenceTransformerTrainingArguments(

output_dir="augmented_embedding_model",
num_train_epochs=1,
per_device_train_batch_size=32,
per_device_eval_batch_size=32,
warmup_steps=100,
fp16=True,
eval_steps=100,
logging_steps=100,
)
# Train model
trainer = SentenceTransformerTrainer(
model=embedding_model,
args=args,
train_dataset=train_dataset,
loss=train_loss,
evaluator=evaluator
)
trainer.train()

最后,我们评估模型:

1
2
3
4
5
6
7
8
9
10
11
evaluator(embedding_model)
{'pearson_cosine': 0.7101597020018693,
'spearman_cosine': 0.7210536464320728,
'pearson_manhattan': 0.7296749443525249,
'spearman_manhattan': 0.7284184255293913,
'pearson_euclidean': 0.7293097297208753,
'spearman_euclidean': 0.7282830906742256,
'pearson_dot': 0.6746605824703588,
'spearman_dot': 0.6754486790570754,
'pearson_max': 0.7296749443525249,
'spearman_max': 0.7284184255293913}

原始的余弦相似度损失示例在完整数据集上的得分为 \(\text{0.72}\)。而我们仅使用 \(\text{20\%}\) 的数据,就成功地获得了 \(\text{0.71}\) 的分数

这种方法使我们能够增加您已有数据集的大小,而无需手动标注数十万个句子对。您可以通过仅在黄金数据集上训练您的嵌入模型来测试白银数据的质量。性能上的差异表明您的白银数据集可能为模型质量贡献了多少

您可以最后一次重新启动您的 \(\text{notebook}\),以进行最后一个示例,即无监督学习

无监督学习

Unsupervised Learning

为了创建嵌入模型,我们通常需要标注数据。然而,并非所有现实世界的数据集都带有我们可以使用的一套很好的标签。因此,我们寻找无需任何预定标签即可训练模型的技术——即无监督学习。存在许多方法,例如简单对比句嵌入学习 (\(\text{SimCSE}\))对比张力 (\(\text{CT}\))基于 \(\text{Transformer}\) 的序列去噪自编码器 (\(\text{TSDAE}\))生成伪标签 (\(\text{GPL}\))

Simple Contrastive Learning of Sentence Embeddings (SimCSE), Contrastive Tension (CT),Transformer-based Sequential Denoising Auto-Encoder (TSDAE), and Generative Pseudo-Labeling (GPL)

在本节中,我们将重点关注 \(\text{TSDAE}\),因为它在无监督任务领域适应方面表现出色。

基于 \(\text{Transformer}\) 的序列去噪自编码器

Transformer-Based Sequential Denoising Auto-Encoder

\(\text{TSDAE}\) 是一种非常优雅的、用于通过无监督学习创建嵌入模型的方法。该方法假设我们根本没有标注数据,并且不需要我们人为地创建标签

\(\text{TSDAE}\)基本思想是:我们通过从输入句子中移除一定百分比的词来为其添加噪声。这个“受损的”句子通过一个编码器(顶部带有一个池化层),将其映射到一个句子嵌入。然后,一个解码器尝试从这个句子嵌入中重构原始句子,但不包含人为的噪声。这里的主要概念是:句子嵌入越准确重构的句子就越准确

这种方法与掩码语言建模\(\text{masked language modeling}\))非常相似,在掩码语言建模中,我们尝试重构和学习某些被掩码的词。在这里,我们不是重构被掩码的词,而是尝试重构整个句子

训练完成后,我们可以使用编码器从文本中生成嵌入,因为解码器仅用于判断嵌入是否能准确重构原始句子(图 \(\text{10}-13\))。

F10.13

由于我们只需要一堆没有标签的句子,训练这个模型是简单明了的。我们首先下载一个外部 \(\text{tokenizer}\),它用于去噪过程

1
2
3
# Download additional tokenizer
import nltk
nltk.download("punkt")

然后,我们从我们的数据中创建扁平化的句子,并移除我们拥有的任何标签,以模仿无监督环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from tqdm import tqdm
from datasets import Dataset, load_dataset
from sentence_transformers.datasets import DenoisingAutoEncoderDataset
# Create a flat list of sentences
mnli = load_dataset("glue", "mnli", split="train").select(range(25_000))
flat_sentences = mnli["premise"] + mnli["hypothesis"]
# Add noise to our input data
damaged_data = DenoisingAutoEncoderDataset(list(set(flat_sentences)))
# Create dataset
train_dataset = {"damaged_sentence": [], "original_sentence": []}

for data in tqdm(damaged_data):
train_dataset["damaged_sentence"].append(data.texts[0])
train_dataset["original_sentence"].append(data.texts[1])
train_dataset = Dataset.from_dict(train_dataset)

这创建了一个包含 \(\text{50,000}\) 个句子的数据集。当我们检查数据时,请注意第一个句子是受损的句子,而第二个句子是原始句子

1
2
3
train_dataset[0]
{'damaged_sentence': 'Grim jaws are.',
'original_sentence': 'Grim faces and hardened jaws are not people-friendly.'}

第一个句子显示了“带噪声的”数据,而第二个句子显示了原始输入句子。在创建数据后,我们像以前一样定义评估器

1
2
3
4
5
6
7
8
9
from sentence_transformers.evaluation import EmbeddingSimilarityEvaluator
# Create an embedding similarity evaluator for stsb
val_sts = load_dataset("glue", "stsb", split="validation")
evaluator = EmbeddingSimilarityEvaluator(
sentences1=val_sts["sentence1"],
sentences2=val_sts["sentence2"],
scores=[score/5 for score in val_sts["label"]],
main_similarity="cosine"
)

接下来,我们像以前一样运行训练,但使用 \(\text{[CLS]}\) 词元作为池化策略,而不是词元嵌入的平均池化\(\text{mean pooling}\))。在 \(\text{TSDAE}\) 论文中,这被证明更有效,因为平均池化会丢失位置信息,而使用 \(\text{[CLS]}\) 词元则不会

1
2
3
4
5
6
7
from sentence_transformers import models, SentenceTransformer
# Create your embedding model
word_embedding_model = models.Transformer("bert-base-uncased")
pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimen
sion(), "cls")
embedding_model = SentenceTransformer(modules=[word_embedding_model, pool
ing_model])

使用我们的句子对,我们将需要一个尝试使用噪声句子重构原始句子的损失函数,即 \(\text{DenoisingAutoEncoderLoss}\)。通过这样做,它将学习如何准确地表示数据。这类似于掩码,但不知道实际的掩码在哪里

此外,我们绑定\(\text{tie}\))了两个模型的参数编码器的嵌入层解码器的输出层不是拥有单独的权重,而是共享相同的权重。这意味着一个层中权重的任何更新也将反映在另一个层中

1
2
3
4
5
6
from sentence_transformers import losses
# Use the denoising auto-encoder loss
train_loss = losses.DenoisingAutoEncoderLoss(
embedding_model, tie_encoder_decoder=True
)
train_loss.decoder = train_loss.decoder.to("cuda")

最后,训练我们的模型与我们之前多次看到的一样,但我们降低了批量大小,因为内存会随着这种损失函数而增加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from sentence_transformers.trainer import SentenceTransformerTrainer
from sentence_transformers.training_args import SentenceTransformerTrainingArgu
ments
# Define the training arguments
args = SentenceTransformerTrainingArguments(
output_dir="tsdae_embedding_model",
num_train_epochs=1,
per_device_train_batch_size=16,
per_device_eval_batch_size=16,
warmup_steps=100,
fp16=True,
eval_steps=100,
logging_steps=100,
)
# Train model
trainer = SentenceTransformerTrainer(
model=embedding_model,
args=args,
train_dataset=train_dataset,
loss=train_loss,
evaluator=evaluator
)
trainer.train()

训练完成后,我们评估我们的模型,以探究这种无监督技术的表现如何:

1
2
3
4
5
6
7
8
9
10
11
12
# Evaluate our trained model
evaluator(embedding_model)
{'pearson_cosine': 0.6991809700971775,
'spearman_cosine': 0.713693213167873,
'pearson_manhattan': 0.7152343356643568,
'spearman_manhattan': 0.7201441944880915,
'pearson_euclidean': 0.7151142243297436,
'spearman_euclidean': 0.7202291660769805,
'pearson_dot': 0.5198066451871277,
'spearman_dot': 0.5104025515225046,
'pearson_max': 0.7152343356643568,
'spearman_max': 0.7202291660769805}

在拟合我们的模型后,我们获得了 \(\text{0.70}\) 的分数,考虑到我们所有这些训练都是用未标注的数据完成的,这相当令人印象深刻

使用 \(\text{TSDAE}\) 进行领域适应

Using TSDAE for Domain Adaptation

当您拥有很少或没有标注数据时,您通常会使用无监督学习来创建文本嵌入模型。然而,无监督技术通常不如有监督技术,并且难以学习特定领域 (\(\text{domain-specific}\)) 的概念。

这就是领域适应 (\(\text{domain adaptation}\)) 发挥作用的地方。其目标是将现有嵌入模型更新包含与源领域不同主题的特定文本领域。图 \(\text{10}-14\) 演示了领域在内容上可能存在的差异目标领域(或域外 \(\text{out-domain}\))通常包含在源领域(或域内 \(\text{in-domain}\)中找不到的词汇和主题

F10.14

一种用于领域适应的方法称为自适应预训练 (\(\text{adaptive pretraining}\))。您首先使用无监督技术(例如我们前面讨论的 \(\text{TSDAE}\)掩码语言建模)对您的特定领域语料库进行预训练。然后,如图 \(\text{10}-15\) 所示,您使用一个可以在目标领域内或领域外训练数据集微调该模型。虽然目标领域的数据更受青睐,但域外数据也有效,因为我们已经从目标领域的无监督训练开始了。

F10.15

利用您在本章学到的所有知识,您应该能够重现这个流程!首先,您可以使用 \(\text{TSDAE}\)您的目标领域上训练一个嵌入模型,然后使用常规的有监督训练增强型 \(\text{SBERT}\) 对其进行微调

总结

在本章中,我们研究了通过各种任务创建和微调嵌入模型。我们讨论了嵌入的概念及其在将文本数据以数值格式表示中的作用。然后,我们探讨了许多嵌入模型的基础技术,即对比学习 (\(\text{contrastive learning}\)),它主要从文档的(不)相似对中学习。

接着,我们使用流行的嵌入框架 \(\text{sentence-transformers}\),通过预训练的 \(\text{BERT}\) 模型创建了嵌入模型,同时探讨了不同的损失函数,例如余弦相似度损失\(\text{MNR}\) 损失。我们讨论了收集文档的(不)相似对或三元组对于所得模型性能重要性

在接下来的部分中,我们探讨了微调嵌入模型的技术。我们讨论了有监督和无监督技术,例如用于领域适应增强型 \(\text{SBERT}\)\(\text{TSDAE}\)。与从头创建嵌入模型相比,微调通常需要较少的数据,并且是使现有嵌入模型适应您的领域的绝佳方法。

在下一章中,我们将讨论用于分类的表示微调方法。届时,\(\text{BERT}\) 模型嵌入模型都将出现,以及广泛的微调技术

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

0%