Chapter 7: Multi-Agent Collaboration | 第七章:多智能体协作

While a monolithic agent architecture can be effective for well-defined problems, its capabilities are often constrained when faced with complex, multi-domain tasks. The Multi-Agent Collaboration pattern addresses these limitations by structuring a system as a cooperative ensemble of distinct, specialized agents. This approach is predicated on the principle of task decomposition, where a high-level objective is broken down into discrete sub-problems. Each sub-problem is then assigned to an agent possessing the specific tools, data access, or reasoning capabilities best suited for that task.

阅读全文 »

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章 生成模型的微调

0%