第十一章:微调表示模型用于分类

发布于:2025-03-01 ⋅ 阅读:(8) ⋅ 点赞:(0)

目录

一、有监督分类

1.1 微调预训练 BERT 模型

2.2 冻结层

二、少样本分类

2.1 SetFit:高效的少样本训练

2.2 微调少样本分类

三、 继续预训练与掩蔽语言模型

3.1 继续预训练一个已经预训练的 BERT 模型

四、命名实体识别(NER)

4.1 准备 NER 数据

4.2 微调命名实体识别模型

五、总结


第十章:创建文本嵌入模型-CSDN博客https://blog.csdn.net/m0_67804957/article/details/145921234在第4章中,我们使用了预训练模型来对文本进行分类。我们保持预训练模型的原样,没有对其进行任何修改。这可能会让你产生疑问,如果我们对这些模型进行微调,会发生什么呢?

如果我们有足够的数据,微调往往会带来一些最好的模型表现。在本章中,我们将讨论几种微调 BERT 模型的方法和应用。

  • 在“有监督分类”中,我们将展示微调分类模型的一般过程。
  • 在“少量样本分类”中,我们将介绍 SetFit,这是一种通过少量训练样本高效微调高性能模型的方法。
  • 在“继续预训练与掩蔽语言模型”中,我们将探讨如何继续训练预训练模型。
  • 在“命名实体识别”部分,我们将探讨在 token 级别上的分类。

我们将重点讨论非生成性任务,生成性模型将在第12章中进行讲解。

一、有监督分类

在第4章中,我们通过利用预训练的表示模型来探索有监督分类任务,这些模型要么是训练来预测情感(任务特定模型),要么是生成嵌入(嵌入模型),如图 11-1 所示。

图 11-1:在第4章中,我们使用了预训练模型来进行分类,而没有更新它们的权重。这些模型被保持为“冻结”状态

这两种模型都保持为“冻结”(不可训练)状态,目的是展示利用预训练模型进行分类任务的潜力。嵌入模型使用一个独立的可训练分类头(分类器)来预测电影评论的情感。

在本节中,我们将采取类似的方法,但允许模型和分类头在训练过程中都能更新。如图 11-2 所示,我们将微调一个预训练的 BERT 模型,创建一个任务特定的模型,类似于我们在第2章中使用的模型。与嵌入模型的方法相比,我们将微调预训练 BERT 模型和分类头,形成一个统一的架构。

图 11-2:与“冻结”架构相比,我们会训练预训练 BERT 模型和分类头。反向传播将从分类头开始,经过 BERT

为此,我们不会冻结模型,而是让它可训练,并在训练过程中更新其参数。如图 11-3 所示,我们将使用一个预训练的 BERT 模型,并在其上添加一个神经网络作为分类头,这两个部分都将进行微调。

图 11-3:任务特定模型的架构。它包含一个预训练的表示模型(例如 BERT)和一个附加的分类头,用于特定任务

实际上,这意味着预训练的 BERT 模型和分类头会一起更新。它们不是独立训练的,而是相互学习,从而可以生成更准确的表示。

1.1 微调预训练 BERT 模型

我们将使用第4章中使用的相同数据集来微调我们的模型,即 Rotten Tomatoes 数据集,它包含 5,331 条正面评论和 5,331 条负面评论:

from datasets import load_dataset

# 准备数据和数据集划分
tomatoes = load_dataset("rotten_tomatoes")
train_data, test_data = tomatoes["train"], tomatoes["test"]

分类任务的第一步是选择我们要使用的底层模型。我们选择 "bert-base-cased",这是一个在英文维基百科和一个包含未发布书籍的大型数据集上预训练的模型。

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

from transformers import AutoTokenizer, AutoModelForSequenceClassification

# 加载模型和分词器
model_id = "bert-base-cased"
model = AutoModelForSequenceClassification.from_pretrained(
    model_id, num_labels=2
)
tokenizer = AutoTokenizer.from_pretrained(model_id)

接下来,我们将对数据进行标记化:

from transformers import DataCollatorWithPadding

# 对齐批次中最长的序列
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)

def preprocess_function(examples):
   """对输入数据进行标记化"""
   return tokenizer(examples["text"], truncation=True)

# 标记化训练/测试数据
tokenized_train = train_data.map(preprocess_function, batched=True)
tokenized_test = test_data.map(preprocess_function, batched=True)

在创建 Trainer 之前,我们需要准备一个特殊的 DataCollator。DataCollator 是一个帮助我们构建数据批次的类,同时也允许我们应用数据增强。

在标记化过程中,如第9章所示,我们将向输入文本添加填充,以创建相同大小的表示。我们使用 DataCollatorWithPadding 来完成此操作。

当然,一个示例需要定义一些指标:

import numpy as np
from datasets import load_metric

def compute_metrics(eval_pred):
   """计算 F1 分数"""
   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}

使用 compute_metrics,我们可以定义感兴趣的任何数量的指标,这些指标可以在训练过程中打印出来或记录下来。在训练过程中,这对于检测过拟合行为非常有用。

接下来,我们实例化 Trainer:

from transformers import TrainingArguments, Trainer

# 训练参数用于参数调优
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
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,
)

TrainingArguments 类定义了我们希望调优的超参数,如学习率和训练轮数。Trainer 用于执行训练过程。

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

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}

我们获得了 F1 分数 0.85,比我们在第4章中使用的任务特定模型(F1 分数为 0.80)高得多。这表明,自己微调模型可能比使用预训练模型更具优势,而这只需要几分钟的训练时间。

2.2 冻结层

为了进一步展示训练整个网络的重要性,接下来的示例将演示如何使用 Hugging Face Transformers 来冻结网络的某些层。

我们将冻结主 BERT 模型,只允许更新通过分类头。这将是一个很好的对比,因为我们将保持一切不变,除了冻结特定的层。

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

# 加载模型和分词器
model = AutoModelForSequenceClassification.from_pretrained(
    model_id, num_labels=2
)
tokenizer = AutoTokenizer.from_pretrained(model_id)

我们的预训练 BERT 模型包含许多可以冻结的层。检查这些层可以帮助我们了解网络的结构,以及我们可能想冻结哪些层:

# 打印层名称
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.encoder.layer.11.output.LayerNorm.weight
bert.pooler.dense.weight
classifier.weight

BERT 模型的结构包括 12 个(0–11)编码块,其中包含注意力头、全连接网络和层归一化。我们在图 11-4 中进一步说明了这一架构,展示了所有可能被冻结的内容,除此之外,还有我们的分类头。

图 11-4:BERT 的基本架构,附加了分类头

我们可以选择只冻结某些层,以加速计算,但仍允许主模型从分类任务中学习。通常,我们希望冻结的层后面跟着可训练的层。

接下来,我们将冻结除分类头外的所有层,正如我们在第2章中所做的那样:

for name, param in model.named_parameters():
    # 可训练的分类头
    if name.startswith("classifier"):
        param.requires_grad = True
    # 冻结其他所有层
    else:
        param.requires_grad = False

如图 11-5 所示,我们冻结了所有编码块和嵌入层,确保 BERT 模型在微调过程中不会学习新的表示。

图 11-5:我们完全冻结了所有编码块和嵌入层,确保 BERT 模型在微调期间不会学习新的表示

现在,我们已经成功冻结了除分类头外的所有部分,可以开始训练模型了:

from transformers import TrainingArguments, Trainer

# 执行训练过程的 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()

你可能会注意到训练速度变得更快了。这是因为我们只训练了分类头,这比微调整个模型要快得多:

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}

当我们评估模型时,得到的 F1 分数是 0.63,明显低于我们原来的 0.85 分。与其冻结几乎所有层,我们不如冻结前 10 个编码块,如图 11-6 所示,看看这样对性能的影响。这样做的一个主要好处是可以减少计算量,同时仍然允许更新流经部分预训练模型。

图 11-6:我们冻结了 BERT 模型的前 10 个编码块,剩余的部分是可训练的,并且将被微调
# 加载模型
model_id = "bert-base-cased"
model = AutoModelForSequenceClassification.from_pretrained(
    model_id, num_labels=2
)
tokenizer = AutoTokenizer.from_pretrained(model_id)

# 编码块 11 从索引 165 开始
# 我们冻结在此之前的所有部分
for index, (name, param) in enumerate(model.named_parameters()):    
    if index < 165:
        param.requires_grad = False

# 执行训练过程的 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()

训练后,我们评估结果:

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}

我们得到了 F1 分数 0.8,明显比冻结所有层时的 0.63 分要高得多。这表明,尽管我们通常希望训练尽可能多的层,但如果计算资源有限,冻结部分层依然能获得不错的性能。

为了进一步说明这一效果,我们测试了迭代冻结编码块并进行微调的影响。如图 11-7 所示,仅训练前五个编码块(红色竖线)几乎达到了训练所有编码块的效果。

图 11-7:冻结某些编码块对模型性能的影响。训练更多编码块会提高性能,但很快会趋于稳定

注意

当你进行多个 epoch 的训练时,冻结和不冻结之间的差异(在训练时间和资源方面)通常会变得更大。因此,建议你尝试不同的冻结层策略,找到适合你的平衡。

二、少样本分类

少样本分类 是一种监督分类技术,其中分类器仅基于少量标注样本学习目标标签。当你有一个分类任务,但没有大量标注数据时,这种技术非常有用。换句话说,这种方法允许你为每个类别标注少量高质量的数据点来训练模型。使用少量标注数据训练模型的想法如图 11-8 所示。

图 11-8. 在少样本分类中,我们只使用少量标注数据点来学习

2.1 SetFit:高效的少样本训练

为了执行少样本文本分类,我们使用一个高效的框架叫做 SetFit。它基于句子-转换器(sentence-transformers)架构生成高质量的文本表示,并在训练过程中更新这些表示。只需要少量的标注样本,这个框架就能与在大规模标注数据集上微调 BERT 模型的效果竞争。

SetFit 的基本算法包括三个步骤:

  1. 采样训练数据
    基于类内和类外选择的标注数据,它生成相似(正向)和不相似(负向)的句子对。

  2. 微调嵌入
    基于之前生成的训练数据微调预训练的嵌入模型。

  3. 训练分类器
    在嵌入模型上创建一个分类头,并使用之前生成的训练数据进行训练。

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

假设我们有一个训练数据集,如图 11-9 所示,将文本分为两类:关于编程语言的文本和关于宠物的文本。

图 11-9. 两类数据:关于编程语言的文本和关于宠物的文本

在步骤 1 中,SetFit 通过基于类内和类外的选择生成必要的数据,如图 11-10 所示。例如,当我们有 16 个关于体育的句子时,可以生成 16 * (16 – 1) / 2 = 120 对标记为正向对。我们还可以通过从不同类中收集对来生成负向对。

图 11-10. 步骤 1:采样训练数据。我们假设类内的句子是相似的,生成正向对,而不同类的句子则生成负向对

在步骤 2 中,我们可以使用生成的句子对来微调嵌入模型。这利用了一个叫做 对比学习 的方法来微调预训练的 BERT 模型。正如我们在第 10 章中回顾的那样,对比学习可以从相似(正向)和不相似(负向)句子对中学习准确的句子嵌入。

由于我们在前一步生成了这些句子对,因此可以使用它们来微调 SentenceTransformers 模型。虽然我们之前已经讨论过对比学习,但我们再次通过图 11-11 来刷新这一方法。

图 11-11. 步骤 2:微调 SentenceTransformers 模型。通过对比学习,从正向和负向句子对中学习嵌入

微调嵌入模型的目标是让模型能够生成适合分类任务的嵌入。类别的相关性及其相对意义通过微调嵌入模型传递到嵌入中。

在步骤 3 中,我们为所有句子生成嵌入,并将这些嵌入作为分类器的输入。我们可以使用微调后的 SentenceTransformers 模型将句子转换为嵌入,然后分类器使用这些嵌入来学习准确预测未见过的句子。最后一步如图 11-12 所示。

图 11-12. 步骤 3:训练分类器。分类器可以是任何 scikit-learn 模型或分类头

当我们将所有步骤结合起来时,我们得到了一个高效且优雅的管道,用于在每个类别只有少数标签时执行分类任务。它巧妙地利用了我们已经有标注数据的想法,尽管这些数据并不是我们想要的方式。三个步骤合起来的过程如图 11-13 所示,给出了整个过程的概述:

  1. 基于类内和类外选择生成句子对。
  2. 使用这些句子对微调预训练的 SentenceTransformer 模型。
  3. 使用微调后的模型将句子嵌入,然后训练分类器进行类别预测。
图 11-13. SetFit 的三个主要步骤

    2.2 微调少样本分类

    我们之前使用的训练数据集包含大约 8,500 条电影评论。然而,由于这是一个少样本设置,我们将只从每个类别中抽取 16 个示例。因此,我们将只有 32 条数据用于训练,而不是之前的 8,500 条电影评论!

    from setfit import sample_dataset
    
    # 我们通过从每个类别中抽取 16 个示例来模拟少样本设置
    sampled_train_data = sample_dataset(tomatoes["train"], num_samples=16)
    

    抽样完数据后,我们选择一个预训练的 SentenceTransformer 模型进行微调。官方文档中列出了多个预训练的 SentenceTransformer 模型,我们将使用 "sentence-transformers/all-mpnet-base-v2",它在 MTEB 排行榜上表现优异,展示了不同任务上嵌入模型的性能。

    from setfit import SetFitModel
    
    # 加载预训练的 SentenceTransformer 模型
    model = SetFitModel.from_pretrained("sentence-transformers/all-mpnet-base-v2")
    

    加载完预训练的 SentenceTransformer 模型后,我们可以开始定义我们的 SetFitTrainer。默认情况下,选择逻辑回归模型作为分类器进行训练。

    与我们在 Hugging Face Transformers 中的做法类似,我们可以使用 trainer 来定义并调整相关的参数。例如,我们将 num_epochs 设置为 3,这样对比学习将在 3 个 epoch 上进行:

    from setfit import TrainingArguments as SetFitTrainingArguments
    from setfit import Trainer as SetFitTrainer
    
    # 定义训练参数
    args = SetFitTrainingArguments(
        num_epochs=3, # 对比学习的 epoch 数量
        num_iterations=20  # 生成文本对的数量
    )
    args.eval_strategy = args.evaluation_strategy
    
    # 创建训练器
    trainer = SetFitTrainer(
        model=model,
        args=args,
        train_dataset=sampled_train_data,
        eval_dataset=test_data,
        metric="f1"
    )
    

    我们只需要调用 train 来启动训练循环。当我们这样做时,应该会看到以下输出:

    # 训练循环
    trainer.train()
    ***** Running training *****
      Num unique pairs = 1280
      Batch size = 16
      Num epochs = 3
      Total optimization steps = 240
    

    注意输出中提到,生成了 1,280 个句子对来微调 SentenceTransformer 模型。默认情况下,为每个样本数据生成 20 个句子对组合,所以 20 * 32 = 680 个样本。由于每对正向和负向句子都被生成,因此需要将其乘以 2,得到 680 * 2 = 1,280 个句子对。考虑到我们只从 32 个标注样本开始,生成 1,280 个句子对相当令人印象深刻!

    提示:
    当我们没有特别定义分类头时,默认使用逻辑回归。如果我们想自己指定分类头,可以在 SetFitTrainer 中指定如下:

    # 从 Hub 加载 SetFit 模型
    model = SetFitModel.from_pretrained(
        "sentence-transformers/all-mpnet-base-v2",
        use_differentiable_head=True,
        head_params={"out_features": num_classes},
    )
    
    # 创建训练器
    trainer = SetFitTrainer(
        model=model,
        ...
    )
    

    其中,num_classes 表示我们要预测的类别数量。

    接下来,我们评估模型的性能:

    # 在测试数据上评估模型
    trainer.evaluate()
    {'f1': 0.8363988383349468}
    

    仅用 32 个标注文档,我们得到了 0.85 的 F1 分数。考虑到模型是在原始数据的一个微小子集上训练的,这是非常令人印象深刻的!此外,在第 2 章中,我们也得到了相同的性能,但当时我们训练的是一个在完整数据上进行的逻辑回归模型。因此,这个管道展示了只标注少量实例的潜力。

    提示:
    SetFit 不仅可以执行少样本分类任务,还支持当你完全没有标注时的任务,称为零样本分类。SetFit 通过从标签名称生成合成示例来模拟

    分类任务,然后基于这些合成数据训练 SetFit 模型。例如,如果目标标签是“开心”和“悲伤”,那么合成数据可以是“这个例子很开心”和“这个例子很悲伤”。

    三、 继续预训练与掩蔽语言模型

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

    图 11-14. 要在目标任务(例如分类)上微调模型,我们可以选择从头开始预训练 BERT 模型或使用已预训练的模型

    这种两步法在许多应用中广泛使用。然而,当面对特定领域的数据时,它会有一些局限性。预训练的模型通常是在非常通用的数据上训练的,比如维基百科页面,这可能不足以涵盖你所在领域的特定词汇。

    为了克服这种局限性,我们可以在这两步之间插入一个额外的步骤,即继续预训练已经预训练的 BERT 模型。换句话说,我们可以简单地使用领域数据继续训练 BERT 模型,采用掩码语言模型(MLM)的方法。它类似于将一个通用的 BERT 模型转换为一个专门为医学领域设计的 BioBERT 模型,然后再对 BioBERT 模型进行微调,以执行药物分类任务。

    这将更新子词表示,使其更加适应它之前可能未曾见过的词汇。这个过程在图 11-15 中有所展示,说明了额外步骤如何更新掩码语言模型任务。继续预训练预训练的 BERT 模型已经被证明能提升模型在分类任务中的表现,是微调管道中的一个值得加入的步骤。

    图 11-15. 我们可以在预训练模型上添加一个继续预训练的步骤,然后再对其进行微调,以便执行目标任务。请注意,图 1 中的掩码是用抽象概念填充的,而图 2 中的掩码则用电影特定的概念填充

    通过继续预训练,而不是从头开始预训练整个模型,我们可以更加快速地进行预训练,并且帮助模型适应特定领域,甚至是某个特定组织的术语。公司可能希望采用的模型谱系在图 11-16 中进一步展示。

    图 11-16. 针对特定用例的三步法

    3.1 继续预训练一个已经预训练的 BERT 模型

    在这个例子中,我们将演示如何应用第 2 步,继续预训练一个已经预训练的 BERT 模型。我们使用与之前相同的数据集,即 Rotten Tomatoes 影评。

    首先,我们加载我们之前使用过的 "bert-base-cased" 模型,并为掩码语言模型(MLM)做好准备:

    from transformers import AutoTokenizer, AutoModelForMaskedLM
    
    # 加载用于掩码语言建模(MLM)的模型
    model = AutoModelForMaskedLM.from_pretrained("bert-base-cased")
    tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
    

    我们需要对原始句子进行分词,并移除标签,因为这不是一个监督任务:

    def preprocess_function(examples):
       return tokenizer(examples["text"], truncation=True)
    
    # 对数据进行分词
    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")
    

    之前,我们使用了 DataCollatorWithPadding,它会动态填充输入数据。

    在这里,我们将使用一个可以为我们执行掩码操作的 DataCollator。一般来说,掩码方法有两种:token 掩码whole-word 掩码。在 token 掩码中,我们会随机掩盖句子中 15% 的 token。有时可能会出现部分词被掩盖的情况。为了能够掩盖整个单词,我们可以采用 whole-word 掩码,如图 11-17 所示。

    图 11-17. 随机掩盖 token 的不同方法

    一般来说,预测整个单词比预测单个 token 要更复杂,这使得模型在训练过程中需要学习更准确和精细的表示,因此模型的表现更好。然而,这通常会使得收敛的速度变慢。在这个例子中,我们选择使用 token 掩码,利用 DataCollatorForLanguageModeling 来加速收敛过程。如果需要,也可以通过替换为 DataCollatorForWholeWordMask 来使用 whole-word 掩码。最后,我们将 token 被掩盖的概率设置为 15%(mlm_probability):

    from transformers import DataCollatorForLanguageModeling
    
    # 掩码 token
    data_collator = DataCollatorForLanguageModeling(
        tokenizer=tokenizer, 
        mlm=True, 
        mlm_probability=0.15
    )
    

    接下来,我们将创建一个 Trainer 来执行 MLM 任务,并指定一些参数:

    # 用于参数调整的训练参数
    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"
    )
    
    # 初始化 Trainer
    trainer = Trainer(
        model=model,
        args=training_args,
        train_dataset=tokenized_train,
        eval_dataset=tokenized_test,
        tokenizer=tokenizer,
        data_collator=data_collator
    )
    

    值得注意的是几个参数。我们训练了 20 个 epoch,并保持任务简短。你可以通过调整学习率和权重衰减来测试它们是否有助于微调模型。

    在开始训练循环之前,我们将首先保存我们预训练的 tokenizer。由于 tokenizer 在训练过程中并没有更新,因此训练结束后无需保存它。然而,我们会在继续预训练后保存我们的模型:

    # 保存预训练的 tokenizer
    tokenizer.save_pretrained("mlm")
    
    # 训练模型
    trainer.train()
    
    # 保存更新后的模型
    model.save_pretrained("mlm")
    

    这会将更新后的模型保存在 mlm 文件夹中。为了评估它的性能,我们通常会对模型在各种任务上进行微调。然而,出于我们的目的,我们可以通过执行一些掩码任务来检查它是否从继续训练中学到了东西。

    我们将通过加载我们继续预训练前的模型,使用句子 "What a horrible [MASK]!" 来预测 "[MASK]" 处的单词:

    from transformers import pipeline
    
    # 加载并创建预测
    mask_filler = pipeline("fill-mask", model="bert-base-cased")
    preds = mask_filler("What a horrible [MASK]!")
    
    # 打印结果
    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!
    

    输出展示了类似 "idea"、"dream" 和 "day" 这样的概念,这些是有意义的。接下来,让我们看看我们更新后的模型会预测什么:

    # 加载并创建预测
    mask_filler = pipeline("fill-mask", model="mlm")
    preds = mask_filler("What a horrible [MASK]!")
    
    # 打印结果
    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!
    

    输出显示,"a horrible movie"、"film"、"mess" 等明显表明模型更倾向于我们输入的数据,与预训练模型相比,体现了它对电影特定内容的偏向。

    接下来的步骤是将这个模型微调到我们在本章开始时的分类任务上。只需像下面这样加载模型,然后就可以开始了:

    from transformers import AutoModelForSequenceClassification
    
    # 微调用于分类
    model = AutoModelForSequenceClassification.from_pretrained("mlm", num_labels=2)
    tokenizer = AutoTokenizer.from_pretrained("mlm")
    

    四、命名实体识别(NER)

    在本节中,我们将深入探讨如何针对 NER 任务微调一个预训练的 BERT 模型。与整个文档分类不同,NER 允许对单独的标记或单词进行分类,包括人名和地名。这对于敏感数据的去标识化和匿名化任务特别有帮助。

    NER 与我们在本章开头探讨的分类任务有相似之处。然而,关键的区别在于数据的预处理和分类方法。由于我们要对单个单词进行分类,而不是整个文档,因此我们必须对数据进行预处理,以考虑到这种更细粒度的结构。图 11-18 展示了这种基于单词的处理方法。

    图 11-18. 微调 BERT 模型进行 NER 任务,能够检测命名实体,如人名或地名

    微调预训练的 BERT 模型与我们在文档分类中观察到的架构类似。然而,分类方法发生了根本性的变化。模型不再依赖于聚合或池化的词嵌入,而是对序列中的每个单独的标记进行预测。需要强调的是,我们的标记级别分类任务并不是对整个单词进行分类,而是对构成这些单词的标记进行分类。图 11-19 提供了这种基于标记的分类方法的可视化表示。

    图 11-19. 在 BERT 模型微调过程中,模型对每个单独的标记进行分类,而不是对单词或整个文档进行分类

    4.1 准备 NER 数据

    在本例中,我们将使用英文版的 CoNLL-2003 数据集,该数据集包含多种命名实体类型(人名、组织、地名、杂项以及没有实体)并且约有 14,000 个训练样本。

    # CoNLL-2003 数据集用于 NER
    dataset = load_dataset("conll2003", trust_remote_code=True)
    

    提示:在研究可用于本例的数据集时,还有一些其他有趣的选项。wnut_17 任务聚焦于新兴和稀有实体,这些实体更难被识别。此外,tner/mit_movie_triviatner/mit_restaurant 数据集也很有趣。tner/mit_movie_trivia 用于检测演员、情节和原声带等实体,而 tner/mit_restaurant 旨在检测餐厅相关的实体,如设施、菜肴和菜系。

    我们来看一下数据的结构:

    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]}
    

    该数据集为句子中的每个单词提供了标签,这些标签可以在 ner_tags 键中找到。该键包含以下几种实体类型:

    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
    

    这些实体对应于特定的类别:人名(PER)、组织(ORG)、地点(LOC)、杂项实体(MISC)以及没有实体(O)。注意,这些实体前缀为 B(表示开始)或 I(表示内部)。如果两个连续的标记属于同一个短语,则第一个标记会使用 B,后续标记使用 I,表明它们属于同一实体。

    这种过程在图 11-20 中进一步展示。图中表明,“Dean” 是短语的开始,“Palmer” 是结束,因此我们可以知道“Dean Palmer”是一个人名,而“Dean”和“Palmer”不是独立的人名。

    图 11-20. 通过标记短语的开始和结束,我们可以识别整个短语中的实体

    我们的数据已被预处理并按单词拆分,但尚未按标记进一步拆分。我们将使用 bert-base-cased 模型的标记器对其进行进一步标记化:

    from transformers import AutoModelForTokenClassification
    
    # 加载标记器
    tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
    
    # 加载模型
    model = AutoModelForTokenClassification.from_pretrained(
        "bert-base-cased", 
        num_labels=len(id2label), 
        id2label=id2label, 
        label2id=label2id
    )
    

    我们来看一下标记器如何处理我们的示例:

    # 将单个标记拆分为子标记
    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]']
    

    标记器在句首和句尾分别添加了 [CLS][SEP] 标记。注意,单词 'homer' 被拆分为 'home' 和 '##r' 两个标记。这就产生了一个问题,因为我们有按单词标记的数据,但没有按标记级别的数据。这个问题可以通过在标记化时将标签与它们的子标记对齐来解决。

    例如,如果单词“Maarten”有 B-PER 标签,表示这是一个人名。如果我们将该单词传递给标记器,它会拆分成 'Ma'、'##arte' 和 '##n' 三个标记。我们不能为所有标记使用 B-PER 标签,因为这会表明这三个标记都是独立的人名。每当一个实体被拆分为多个标记时,第一个标记应使用 B,后续标记应使用 I

    因此,'Ma' 将被标记为 B-PER,而 '##arte' 和 '##n' 将标记为 I-PER,表示它们属于同一个短语。图 11-21 展示了这种对齐过程。

    图 11-21. 对齐标记化输入的标签

    我们创建了一个 align_labels 函数,用于在标记化时将这些标签与相应的子标记对齐:

    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):
            
            # 将标记映射到相应的单词
            word_ids = token_ids.word_ids(batch_index=index)  
            previous_word_idx = None
            label_ids = []
            for word_idx in word_ids: 
    
                # 新单词的开始
                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)
    
                # 特殊标记为 -100
                elif word_idx is None:
                    label_ids.append(-100)
    
                # 如果标签是 B-XXX,则更改为 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)
    

    看一下我们的示例,注意 [CLS] 和 [SEP] 标记的标签已经被更新为 -100:

    # 比较原始标签和更新后的标签
    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]
    

    现在,我们已经完成了标记化并对齐了标签,接下来可以开始定义我们的评估指标。这与我们之前的做法有所不同,因为我们现在是在标记级别而不是文档级别进行分类。

    我们将使用 Hugging Face 的 `evaluate` 包来创建一个 `compute_metrics` 函数,允许我们在标记级别上评估性能:

    import evaluate 
    
    # 加载顺序评估
    seqeval = evaluate.load("seqeval")
    
    def compute_metrics(eval_pred):
        # 创建预测
        logits, labels = eval_pred
        predictions = np.argmax(logits, axis=2)
    
        true_predictions = []
        true_labels = []
    
        # 文档级别迭代
        for prediction, label in zip(predictions, labels):
    
          # 标记级别迭代
          for token_prediction, token_label in zip(prediction, label):
    
            # 忽略特殊标记
            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"]}
    

    4.2 微调命名实体识别模型

    我们几乎完成了。与文档分类不同,我们需要一个适用于标记级别分类的合适数据整理器,即 DataCollatorForTokenClassification

    from transformers import DataCollatorForTokenClassification
    
    # 用于标记分类的数据整理器
    data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)
    

    接下来,我们与之前类似地定义训练参数,创建训练器:

    # 参数调优的训练设置
    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 = 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()
    

    我们接着评估我们创建的模型:

    # 在测试数据上评估模型
    trainer.evaluate()
    

    最后,让我们保存模型并在推理时使用它。这使我们可以检查特定数据,手动检查推理输出是否符合预期:

    from transformers import pipeline
    
    # 保存微调后的模型
    trainer.save_model("ner_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}]
    

    在句子 "My name is Maarten" 中,单词 "Maarten" 及其子标记被正确识别为一个人名!

    五、总结

    在本章中,我们探索了如何对预训练表示模型进行微调,适应不同的分类任务。我们首先演示了如何对一个预训练的 BERT 模型进行微调,然后将这些例子扩展到冻结部分模型层的情况。

    接着,我们实验了一种少样本分类技术——SetFit,它通过仅使用有限的标记数据来微调预训练的嵌入模型与分类头,并生成了与前面章节中探索的模型相似的性能。

    然后,我们深入探讨了继续预训练的概念,使用一个预训练的 BERT 模型作为起点,继续用不同数据进行训练。这种基于掩码语言模型的方法不仅用于创建表示模型,还可以用于继续预训练模型。

    最后,我们讲解了命名实体识别(NER),这是一种识别文本中人物、地点等特定实体的任务。与之前的例子不同,这种分类任务是在标记级别而非文档级别进行的。

    在下一章中,我们将继续探索微调语言模型的领域,但重点将转向生成模型。通过一个两步过程,我们将学习如何微调生成模型,使其更好地遵循指令,并进一步微调以符合人的偏好。