AGI 之 【Hugging Face】 的【Transformer模型优化】的 [意图识别] / [ 性能基准 ] / [ 知识蒸馏减小模型大小 ] 的简单整理

AGI 之 【Hugging Face】 的【Transformer模型优化】的 [意图识别] / [ 性能基准 ] / [ 知识蒸馏减小模型大小 ] 的简单整理

目录

AGI 之 【Hugging Face】 的【Transformer模型优化】的 [意图识别] / [ 性能基准 ] / [ 知识蒸馏减小模型大小 ] 的简单整理

一、简单介绍

二、Transformer 模型调优

三、以意图识别为例

四、创建性能基准

五、通过知识蒸馏减小模型大小

1、微调知识蒸馏

2、基于知识蒸馏的预训练技术

3、创建知识蒸馏训练器

4、选择一个好的学生模型来初始化

5、使用Optuna找到优秀的超参数

6、基准测试蒸馏模型

附录:

一、当前案例环境 pacakge 的 版本如下


一、简单介绍

AGI,即通用人工智能(Artificial General Intelligence),是一种具备人类智能水平的人工智能系统。它不仅能够执行特定的任务,而且能够理解、学习和应用知识于广泛的问题解决中,具有较高的自主性和适应性。AGI的能力包括但不限于自我学习、自我改进、自我调整,并能在没有人为干预的情况下解决各种复杂问题。

  • AGI能做的事情非常广泛:

    跨领域任务执行:AGI能够处理多领域的任务,不受限于特定应用场景。
    自主学习与适应:AGI能够从经验中学习,并适应新环境和新情境。
    创造性思考:AGI能够进行创新思维,提出新的解决方案。
    社会交互:AGI能够与人类进行复杂的社会交互,理解情感和社会信号。

  • 关于AGI的未来发展前景,它被认为是人工智能研究的最终目标之一,具有巨大的变革潜力:

    技术创新:随着机器学习、神经网络等技术的进步,AGI的实现可能会越来越接近。
    跨学科整合:实现AGI需要整合计算机科学、神经科学、心理学等多个学科的知识。
    伦理和社会考量:AGI的发展需要考虑隐私、安全和就业等伦理和社会问题。
    增强学习和自适应能力:未来的AGI系统可能利用先进的算法,从环境中学习并优化行为。
    多模态交互:AGI将具备多种感知和交互方式,与人类和其他系统交互。

Hugging Face作为当前全球最受欢迎的开源机器学习社区和平台之一,在AGI时代扮演着重要角色。它提供了丰富的预训练模型和数据集资源,推动了机器学习领域的发展。Hugging Face的特点在于易用性和开放性,通过其Transformers库,为用户提供了方便的模型处理文本的方式。随着AI技术的发展,Hugging Face社区将继续发挥重要作用,推动AI技术的发展和应用,尤其是在多模态AI技术发展方面,Hugging Face社区将扩展其模型和数据集的多样性,包括图像、音频和视频等多模态数据。

  • 在AGI时代,Hugging Face可能会通过以下方式发挥作用:

        模型共享:作为模型共享的平台,Hugging Face将继续促进先进的AGI模型的共享和协作。
        开源生态:Hugging Face的开源生态将有助于加速AGI技术的发展和创新。
        工具和服务:提供丰富的工具和服务,支持开发者和研究者在AGI领域的研究和应用。
        伦理和社会责任:Hugging Face注重AI伦理,将推动负责任的AGI模型开发和应用,确保技术进步同时符合伦理标准。

AGI作为未来人工智能的高级形态,具有广泛的应用前景,而Hugging Face作为开源社区,将在推动AGI的发展和应用中扮演关键角色。

(注意:以下代码运行,可能需要科学上网)

二、Transformer 模型调优

Transformer 模型调优是指在预训练模型的基础上,通过微调(fine-tuning)使其适应特定任务,如文本分类、命名实体识别(NER)、机器翻译等。调优的目的是利用预训练模型已经学到的通用语言知识,将其转化为特定任务的专用知识,以提升模型在特定任务上的性能。

  • Transformer 模型调优的核心在于以下几个方面:
  1. 数据准备:将任务数据转换为适合模型输入的格式。
  2. 模型选择与加载:选择适合任务的预训练模型并加载。
  3. 超参数设置:设置训练过程中的各种超参数,如学习率、批处理大小等。
  4. 训练与评估:使用训练数据微调模型,并使用验证数据评估模型性能。
  5. 优化与测试:根据评估结果调整模型和超参数,最终在测试数据上测试模型。
  • 目前,Transformer 模型调优的实现方式主要包括以下几种:
  1. 全量微调(Full Fine-Tuning):对整个模型进行微调。适用于数据量较大、计算资源充足的场景。

  2. 部分微调(Partial Fine-Tuning):只微调部分层,如顶层几层或特定层。适用于计算资源有限或需要快速实验的场景。

  3. 冻结(Freezing)部分层:冻结预训练模型的前几层,只微调后几层。适用于需要保持模型原有特性的场景。

  4. 使用适应层(Adapters):在模型中引入适应层,只微调适应层的参数。适用于需要在多个任务之间快速切换的场景。

  5. 提示学习(Prompt Tuning):通过引入提示(prompts)来调整模型的输入格式,使得模型能够更好地适应特定任务。适用于少样本学习场景。

Transformer 模型调优是一项复杂且细致的工作,需要对数据、模型和训练过程有全面的理解。通过合理选择模型、设置超参数、进行训练和评估,可以使预训练模型在特定任务上达到最佳性能。掌握这些调优技巧,将帮助你在实际项目中充分发挥 Transformer 模型的强大能力。

在前面的介绍中,已经看到如何对Transformer进行微调,从而在各种任务上产生出色的结果。然而,在许多情况下,准确率(或你正在优化的任何指标)并不足够。如果你的最新模型太慢或太大而无法满足应用程序的商业要求,那么它就不会很有用。一个显而易见的替代方法是训练一个更快、更紧凑的模型,但模型容量的减少通常伴随着性能的下降。当你需要快速、紧凑且高度准确的模型时,你有什么技术可以选择?

在接下来的介绍中,我们将探讨四种互补技术,可用于加速你的Transformer模型的预测并减少内存使用:知识蒸馏、量化、剪枝和使用Open Neural Network Exchange(ONNX)格式和ONNX Runtime(ORT)进行图优化。我们还将看到如何将一些技术组合起来以产生显著的性能提升。例如,这是Roblox工程团队在他们的文章“How We Scaled Bert to Serve 1+Billion Daily Requests on CPUs”(https://oreil.ly/QdNIk)中采取的方法。他们发现将知识蒸馏和量化相结合,可以使他们的BERT分类器的延迟和吞吐量提高30倍以上!

为了阐明每种技术所带来的利弊,我们将以意图识别为案例研究。这是基于文本的助手的重要组成部分,低延时对于实时维持对话至关重要。在学习过程中,你将学习如何创建自定义训练器,进行高效的超参数搜索,并了解如何通过Hugging Face Transformers库实现尖端研究所需的要素。

三、

假设我们正在尝试为公司的呼叫中心构建一个基于文本的问答机器人,以便客户无须与人类交谈即可查询其账户余额或进行预订。为了理解客户的目标,我们的助手需要能够将各种自然语言文本分类为一组预定义的动作或意图。例如,客户可能会发送以下有关即将到来的旅行的消息:

Roblox使用知识蒸馏、动态填充和权重量化扩展了BERT
(照片由Roblox员工Quoc N. Le和Kip Kaehler提供)

Hey,I'd like to rent a vehicle from Nov 1st to Nov 15th in Paris and I need a 15 passenger van

我们的意图分类器可以自动将其归类为汽车租赁意图,然后触发一个操作和响应。为了在生产环境中具有稳健性(robust,又称鲁棒性),我们的分类器还需要能够处理超出范围的查询,即客户进行的查询不在任何预定义意图之内,系统应该产生正确的响应。例如,在图8-2中展示的第二种情况中,客户提出了一个关于体育的问题(超出范围),文本助手错误地将其分类为已知的范围内意图之一,并返回发薪日的响应。在第三种情况下,文本助手被训练可以检测超出范围的查询(通常标记为单独的类),并告知客户它可以回答哪些主题的问题。

S. Larson et al., “An Evaluation Dataset for Intent Classification and Out-of-Scope Prediction”(https://arxiv.org/abs/1909.02027),(2019).

作为基准,我们对BERT-base模型进行了微调,在CLINC150数据集上实现了大约94%的准确率 。这个数据集包括来自银行和旅行等10个领域中150个意图的22 500个范围内查询,还包括属于oos意图分类的1200个范围外查询。实际上,我们也会收集我们自己的内部数据集,但使用公共数据集是快速迭代和产生初步结果的好方法。

我们首先从Hugging Face Hub下载我们微调过的模型,并将其封装成一个文本分类pipeline:

人类(右侧)与面向个人财务的基于文本的助手(左侧)
之间的三次交互(由Stefan Larson等人提供)
# 导入 Hugging Face 的 pipeline 方法,该方法可以快速构建 NLP 任务的管道
from transformers import pipeline

# 指定预训练模型的检查点(checkpoint)
# 这里使用的是一本书中提到的已经在 CLINC 数据集上微调的 BERT 模型
bert_ckpt = "transformersbook/bert-base-uncased-finetuned-clinc"

# 创建一个文本分类的管道
# pipeline 函数会自动下载并加载模型以及对应的 tokenizer
pipe = pipeline("text-classification", model=bert_ckpt)

现在我们有了一个pipeline,我们可以传递一个查询来从模型中获取预测意图和信心评分。

import numpy as np  # 确保导入 NumPy
# 打印NumPy版本,确认安装成功
print(np.__version__)

# 定义用户查询的文本
query = """Hey, I'd like to rent a vehicle from Nov 1st to Nov 15th in 
Paris and I need a 15 passenger van"""

# 使用文本分类管道对用户查询进行分类
result = pipe(query)

# 输出分类结果
print(result)

运行结果:

1.26.4
[{'label': 'car_rental', 'score': 0.5490034818649292}]

太好了!对租车(car_rental)意图分类正确。现在我们来创建一个基准,用来评估我们的基线模型的性能。

四、创

如Emmanuel Ameisen在Building Machine Learning Powered Applications(O'Reilly)一书中所描述的,业务或产品指标是最重要的考虑因素。毕竟,如果模型不能解决你的业务关心的问题,那么它的准确率就不重要了。在本章中,我们将假设你已经定义了对应用程序有意义的指标,然后再集中优化模型指标。

与其他机器学习模型一样,将Transformer模型部署到生产环境中涉及多个限制因素之间的权衡,最常见的限制因素包括 :

我们的模型在反映实际生产数据的精心制作的测试集上表现如何?当错误的代价很高(最好通过人工干预来降低),或者当我们需要在数百万个样本上运行推理且对模型指标进行小的改进可以转化为大的总体收益时,这一点尤为重要。

我们的模型能多快地提供预测?通常我们关心的是实时环境中的延迟,例如像Stack Overflow这样需要分类器快速检测网站上不受欢迎的评论(https://oreil.ly/cf7QX)。

我们如何部署类似GPT-2或T5这样需要千兆字节的磁盘存储和内存的百亿参数模型?在移动设备或边缘设备中,内存尤其重要,因为模型必须在不能访问强大的云服务器的情况下生成预测。

忽略这些限制将对你的应用程序用户体验产生负面影响。更常见的情况是,因为考虑不周,购买了过多的、不必要的云服务器,会导致成本的增加。为了探索如何使用各种压缩技术优化每个约束条件,我们从创建一个简单的基准测试开始,该测试度量给定pipeline和测试集的每个数量。下面是我们需要的框架:

# 定义一个性能基准类,用于评估文本分类管道在不同方面的性能
class PerformanceBenchmark:
    def __init__(self, pipeline, dataset, optim_type="BERT baseline"):
        """
        初始化性能基准类
        
        参数:
        - pipeline: 文本分类管道
        - dataset: 数据集,用于评估性能
        - optim_type: 优化类型的描述,默认值为 "BERT baseline"
        """
        self.pipeline = pipeline
        self.dataset = dataset
        self.optim_type = optim_type
        
    def compute_accuracy(self):
        """
        计算模型在数据集上的准确性
        这个方法将在后面定义
        """
        pass    

    def compute_size(self):
        """
        计算模型的大小
        这个方法将在后面定义
        """
        pass

    def time_pipeline(self):
        """
        计算管道处理一个样本所需的时间
        这个方法将在后面定义
        """
        pass
    
    def run_benchmark(self):
        """
        运行基准测试,评估模型在不同指标上的性能
        
        返回:
        - metrics: 一个字典,包含模型的大小、处理时间和准确性等指标
        """
        metrics = {}
        
        # 计算模型大小并存储在metrics字典中
        metrics[self.optim_type] = self.compute_size()
        
        # 计算管道处理时间并更新metrics字典
        metrics[self.optim_type].update(self.time_pipeline())
        
        # 计算模型准确性并更新metrics字典
        metrics[self.optim_type].update(self.compute_accuracy())
        
        return metrics

我们已经定义了一个optim_type参数,用于跟踪本章中涵盖的不同优化技术。我们将使用run_benchmark()方法来收集所有的指标,并将其保存在一个字典中,key由optim_type给出。

现在,我们通过计算测试集上的模型准确率来给这个类添加一些具体内容。首先,我们需要一些数据进行测试,因此我们下载用于微调基线模型的CLINC150数据集。我们可以使用Hugging Face Datasets库从Hub获取数据集,如下所示:

# 从 datasets 库中导入 load_dataset 函数
from datasets import load_dataset

# 加载 CLINC OOS (Out of Scope) 数据集的 "plus" 版本
# CLINC OOS 数据集是一个用于意图分类的常见基准数据集
# "plus" 版本包含更多的意图类别和数据
clinc = load_dataset("clinc_oos", "plus")

# 打印数据集的结构和一些示例,以确认数据加载成功
print(clinc)

运行结果:

其中,plus配置是指包含超出训练范围的样本子集。CLINC150数据集中的每个样本都由文本列中的查询和其对应的意图组成。我们将使用测试集来对我们的模型进行基准测试,因此我们来看一下数据集中的一个样本:

# 从clinc数据集的'test'分区中获取索引为42的样本  
sample = clinc["test"][42]  
  
# (假设此时你想要查看sample的内容,可以打印它)  
print(sample)

运行结果:

{'text': 'transfer $100 from my checking to saving account', 'intent': 133}

意图以ID的形式提供,但我们可以通过访问数据集的features属性轻松获取到它们与字符串之间的映射(反之亦然)。

# 从加载的数据集中获取意图标签的特征信息
# clinc["test"] 是数据集的测试集部分
# features["intent"] 获取意图标签的特征信息,包括意图的映射关系
intents = clinc["test"].features["intent"]

# 定义一个示例数据,假设 sample 是数据集中的一条样本
# sample["intent"] 是样本中的意图标签的索引
sample = clinc["test"][0]  # 这里假设我们使用测试集中的第一个样本

# 使用 int2str 方法将意图标签的索引转换为对应的字符串标签
# int2str 方法将索引转换为可读的意图标签名称
intent_label = intents.int2str(sample["intent"])

# 打印转换后的意图标签
print(intent_label)

运行结果:

translate

现在我们已经基本了解了CLINC150数据集的内容,我们来实现PerformanceBenchmark类的compute_accuracy()方法。由于数据集在意图类上平衡,我们将使用准确率作为我们的度量指标。我们可以通过Hugging Face Datasets库加载此度量指标,方法如下:

# 从 datasets 库中导入 load_metric 函数
from datasets import load_metric 

# 加载用于计算准确度的评价指标
# load_metric 函数会自动下载并缓存指定的评价指标
# "accuracy" 指定我们要加载的是准确度评价指标
accuracy_score = load_metric("accuracy",trust_remote_code=True)

# 打印评价指标对象,以确认加载成功
print(accuracy_score)

运行结果:

Metric(name: "accuracy", features: {'predictions': Value(dtype='int32', id=None), 'references': Value(dtype='int32', id=None)}, usage: """
Args:
    predictions (`list` of `int`): Predicted labels.
    references (`list` of `int`): Ground truth labels.
    normalize (`boolean`): If set to False, returns the number of correctly classified samples. Otherwise, returns the fraction of correctly classified samples. Defaults to True.
    sample_weight (`list` of `float`): Sample weights Defaults to None.

Returns:
    accuracy (`float` or `int`): Accuracy score. Minimum possible value is 0. Maximum possible value is 1.0, or the number of examples input, if `normalize` is set to `True`.. A higher score means higher accuracy.

Examples:

    Example 1-A simple example
        >>> accuracy_metric = datasets.load_metric("accuracy")
        >>> results = accuracy_metric.compute(references=[0, 1, 2, 0, 1, 2], predictions=[0, 1, 1, 2, 1, 0])
        >>> print(results)
        {'accuracy': 0.5}

    Example 2-The same as Example 1, except with `normalize` set to `False`.
        >>> accuracy_metric = datasets.load_metric("accuracy")
        >>> results = accuracy_metric.compute(references=[0, 1, 2, 0, 1, 2], predictions=[0, 1, 1, 2, 1, 0], normalize=False)
        >>> print(results)
        {'accuracy': 3.0}

    Example 3-The same as Example 1, except with `sample_weight` set.
        >>> accuracy_metric = datasets.load_metric("accuracy")
        >>> results = accuracy_metric.compute(references=[0, 1, 2, 0, 1, 2], predictions=[0, 1, 1, 2, 1, 0], sample_weight=[0.5, 2, 0.7, 0.5, 9, 0.4])
        >>> print(results)
        {'accuracy': 0.8778625954198473}
""", stored examples: 0)

准确率指标要求预测和参照(即基准标注)为整数。我们可以使用pipeline从text字段提取预测,然后使用意图对象的str2int()方法将每个预测映射到其相应的标识符。以下代码在返回数据集上的准确率之前将所有预测和标注收集到列表中。我们也将其添加到我们的PerformanceBenchmark类中:

def compute_accuracy(self):
    """
    重写 PerformanceBenchmark.compute_accuracy() 方法,计算模型在数据集上的准确性
    """
    preds, labels = [], []  # 初始化预测标签和真实标签的列表
    
    # 遍历数据集中的每个示例
    for example in self.dataset:
        # 使用管道对文本进行分类,获取预测标签
        pred = self.pipeline(example["text"])[0]["label"]
        
        # 获取示例的真实意图标签
        label = example["intent"]
        
        # 将预测标签从字符串转换为对应的索引,并添加到预测标签列表中
        preds.append(intents.str2int(pred))
        
        # 将真实标签添加到真实标签列表中
        labels.append(label)
    
    # 使用加载的准确度评价指标计算预测标签和真实标签之间的准确度
    accuracy = accuracy_score.compute(predictions=preds, references=labels)
    
    # 打印测试集上的准确度
    print(f"Accuracy on test set - {accuracy['accuracy']:.3f}")
    
    # 返回计算的准确度
    return accuracy

# 将 compute_accuracy 方法赋值给 PerformanceBenchmark 类的 compute_accuracy 方法
PerformanceBenchmark.compute_accuracy = compute_accuracy

接下来,我们通过使用PyTorch的torch.save()函数将模型序列化到磁盘中来计算模型的大小。在内部,torch.save()使用Python的pickle模块,并且可以用于保存从模型到张量到普通的Python对象的任何内容。在PyTorch中,保存模型的推荐方式是使用它的state_dict,它是一个Python字典,将模型中的每个层映射到其可学习的参数(即权重和偏差)。我们看看基线模型的state_dict中存储了什么:

# 获取管道模型的状态字典
# state_dict 包含模型的所有参数,以字典的形式存储
model_state_dict = pipe.model.state_dict()

# 将状态字典转换为列表,并获取第 42 个参数及其对应的值
# items() 方法返回状态字典中的键值对
param_name, param_value = list(model_state_dict.items())[42]

# 打印第 42 个参数的名称和对应的值
print(f"Parameter name: {param_name}")
print(f"Parameter value: {param_value}")

运行结果:

Parameter name: bert.encoder.layer.2.attention.self.value.weight
Parameter value: tensor([[-1.0526e-02, -3.2215e-02,  2.2097e-02,  ..., -6.0953e-03,
          4.6521e-03,  2.9844e-02],
        [-1.4964e-02, -1.0915e-02,  5.2396e-04,  ...,  3.2047e-05,
         -2.6890e-02, -2.1943e-02],
        [-2.9640e-02, -3.7842e-03, -1.2582e-02,  ..., -1.0917e-02,
          3.1152e-02, -9.7786e-03],
        ...,
        [-1.5116e-02, -3.3226e-02,  4.2063e-02,  ..., -5.2652e-03,
          1.1093e-02,  2.9703e-03],
        [-3.6809e-02,  5.6848e-02, -2.6544e-02,  ..., -4.0114e-02,
          6.7487e-03,  1.0511e-03],
        [-2.4961e-02,  1.4747e-03, -5.4271e-02,  ...,  2.0004e-02,
          2.3981e-02, -4.2880e-02]])

我们可以清晰地看到每个key-value对对应于BERT中特定的层和张量。因此,如果我们使用以下代码保存我们的模型:

import torch

# 保存管道模型的状态字典
# 使用 torch.save 函数将模型的状态字典保存到文件 "model.pt" 中
# state_dict 包含了模型的所有参数,以便之后可以加载和恢复模型
torch.save(pipe.model.state_dict(), "model.pt")

我们可以使用Python的pathlib模块中的Path.stat()函数来获取关于底层文件的信息。特别地,Path("model.pt").stat().st_size会给出模型的字节数。我们把这些放在一起写成compute_size()函数,并将其添加到PerformanceBenchmark中:

import torch
from pathlib import Path

def compute_size(self):
    """
    重写 PerformanceBenchmark.compute_size() 方法,计算模型的大小
    """
    # 获取模型的状态字典,包含了模型的所有参数
    state_dict = self.pipeline.model.state_dict()
    
    # 定义一个临时文件路径,用于保存模型参数
    tmp_path = Path("model.pt")
    
    # 使用 torch.save 函数将模型的状态字典保存到临时文件中
    torch.save(state_dict, tmp_path)
    
    # 计算模型文件的大小(以兆字节为单位)
    size_mb = tmp_path.stat().st_size / (1024 * 1024)
    
    # 删除临时文件,释放磁盘空间
    tmp_path.unlink()
    
    # 打印模型大小
    print(f"Model size (MB) - {size_mb:.2f}")
    
    # 返回模型大小,以字典的形式
    return {"size_mb": size_mb}

# 将 compute_size 方法赋值给 PerformanceBenchmark 类的 compute_size 方法
PerformanceBenchmark.compute_size = compute_size

最后,我们实现time_pipeline()函数,以便我们可以度量每个查询的平均延迟。对于这个应用程序,延迟是指将文本查询提供给pipeline并从模型返回预测的意图所需的时间。在幕后,pipeline还会对文本进行词元化处理,但这比生成预测要快一千倍,因此对总体延迟的贡献可以忽略不计。度量代码片段执行时间的一种简单方法是使用Python的time模块中的perf_counter()函数。该函数的时间分辨率比time.time()函数更好,非常适合获取精确的结果。

我们可以使用perf_counter()函数来计时我们的pipeline,通过传递测试查询并计算开始和结束之间的毫秒级时间差来完成:

from time import perf_counter  # 从 time 模块中导入 perf_counter,用于测量时间

# 定义要进行推理的查询文本
query = """Hey, I'd like to rent a vehicle from Nov 1st to Nov 15th in 
Paris and I need a 15 passenger van"""

# 重复3次以测量推理的延迟时间
for _ in range(3):
    # 记录开始时间
    start_time = perf_counter()
    
    # 使用管道进行推理,将结果存储在临时变量中
    _ = pipe(query)
    
    # 计算推理的延迟时间,单位为秒
    latency = perf_counter() - start_time
    
    # 将延迟时间转换为毫秒,并打印出来
    print(f"Latency (ms) - {1000 * latency:.3f}")

运行结果:

Latency (ms) - 36.829
Latency (ms) - 18.998
Latency (ms) - 18.353

这些结果展示出延迟时间有相当大的差异,并且暗示通过pipeline计时的结果在每次运行时可能会产生非常不同的结果。所以我们将收集多次运行的延迟时间,然后使用得到的分布计算均值和标准差,以便给我们一个关于数值差异的概念。以下代码执行我们需要的操作,并包括一个预热CPU的阶段,然后进行实际的计时运行:

import numpy as np  # 导入 NumPy 库,用于数学运算

def time_pipeline(self, query="What is the pin number for my account?"):
    """
    重写 PerformanceBenchmark.time_pipeline() 方法,测量管道处理查询的延迟时间
    """
    latencies = []  # 初始化延迟时间列表
    
    # 预热阶段:执行管道处理查询操作,但不记录延迟时间,以确保 JIT 编译完成等
    for _ in range(10):
        _ = self.pipeline(query)
    
    # 正式测量阶段:重复执行管道处理查询操作,并记录每次的延迟时间
    for _ in range(100):
        start_time = perf_counter()  # 记录开始时间
        
        # 执行管道处理查询操作,并记录结果(这里我们暂时不使用结果,用 _ 代替)
        _ = self.pipeline(query)
        
        # 计算每次执行的延迟时间,并添加到延迟时间列表中
        latency = perf_counter() - start_time
        latencies.append(latency)
    
    # 计算延迟时间的统计数据:平均延迟时间和延迟时间的标准差
    time_avg_ms = 1000 * np.mean(latencies)  # 平均延迟时间,单位为毫秒
    time_std_ms = 1000 * np.std(latencies)   # 延迟时间的标准差,单位为毫秒
    
    # 打印计算出的平均延迟时间和标准差,并以字符串形式输出
    print(f"Average latency (ms) - {time_avg_ms:.2f} +- {time_std_ms:.2f}")
    
    # 返回延迟时间统计数据,以字典的形式
    return {"time_avg_ms": time_avg_ms, "time_std_ms": time_std_ms}

# 将 time_pipeline 方法赋值给 PerformanceBenchmark 类的 time_pipeline 方法
PerformanceBenchmark.time_pipeline = time_pipeline

为了保持简单,我们将使用相同的查询值来对比所有的模型。一般来说,延迟取决于查询的长度,一个好的实践是使用模型在生产环境中可能遇到的查询来进行测试。

现在我们的PerformanceBenchmark类已经完成,我们从基准测试BERT开始。对于基线模型,我们只需要传递pipeline和我们希望进行基准测试的数据集。我们将收集结果到perf_metrics字典中以跟踪每个模型的性能。

# 创建性能基准对象 pb,使用管道 pipe 和 CLINC OOS 数据集的测试集 clinc["test"]
pb = PerformanceBenchmark(pipe, clinc["test"])

# 运行基准测试,并获取性能指标
perf_metrics = pb.run_benchmark()

运行结果:

Model size (MB) - 418.15
Average latency (ms) - 15.86 +- 1.36
Accuracy on test set - 0.867

现在我们有了一个参考点,我们来看一下我们的第一个压缩技术:知识蒸馏。

平均延迟值会根据你使用的硬件类型而有所不同。例如,通常可以通过在GPU上运行推理来获得更好的性能,因为它可以进行批处理。对于本章的目的,重要的是模型之间延迟的相对差异。一旦确定了性能最佳的模型,我们就可以探索不同的后端,以确保必要时能够降低绝对延迟。

五、通

C. Buciluǎ et al., “Model Compression,”Proceedings of the 12th ACM SIGKDD International Conference on Knowledge Discovery and Data Mining(August 2006): 535-541,https://doi.org/10.1145/1150402.1150464.

G. Hinton, O. Vinyals, and J. Dean, “Distilling the Knowledge in a Neural Network”(https://arxiv.org/abs/1503.02531), (2015).

知识蒸馏是一种通用的方法,用于训练一个较小的学生模型来模仿一个更慢但表现更好的较大的教师模型的行为。这种方法最初在2016年集成模型的背景下引入 ,后来在一篇著名的2015年的论文中得到推广。其被推广到深度神经网络,并应用于图像分类和自动语音识别 。

W. Fedus, B. Zoph, and N. Shazeer, “Switch Transformers:Scaling to Trillion Parameter Models with Simple and Efficient Sparsity”(https://arxiv.org/abs/2101.03961), (2021).

鉴于最近自然语言处理中越来越流行使用具有越来越多参数数量的预训练语言模型(目前最大的模型参数数量已经达到一万亿个),知识蒸馏也成为一种流行的策略,可以压缩这些巨大模型的大小,使其更适合于构建实际应用程序。

1、微

Geoff Hinton在一次演讲中(https://oreil.ly/OkHGp)创造了这个词,指的是对软化的概率的观察揭示了教师的隐藏知识。

那么在训练过程中,知识是如何从教师模型传到学生模型的呢?对于类似于微调这样的监督任务,主要思想是通过教师模型提供的“软概率”分布来增加基准标注,从而提供一些学生模型需要学习的互补信息。例如,如果我们的BERT-base分类器对多个意图赋予了高概率,那么这可能意味着这些意图在特征空间中非常接近。通过训练学生模型来模仿这些概率,目标是提取一些教师模型已经学习到但仅仅依靠标注无法获取的“暗知识” 。

从数学角度来看,这个过程的工作原理如下。假设我们将输入序列x提供给教师模型,以生成一个logit向量zx)=[zx),…,zN(x)]。我们可以通过应用softmax函数将这些logit转换为概率:

1

\frac{exp\left ( z_{i} \left ( x \right )\right )}{\sum_{j}^{}exp\left ( z_{i}\left ( x \right ) \right )}

在之前的介绍中,我们在文本生成的上下文中也遇到了温度。

但这不完全符合我们的需求,因为在许多情况下,教师模型会给一个类别分配高概率,而其他类别的概率接近于零。当出现这种情况时,教师模型除了提供基准标注外,并没有提供太多附加信息,所以我们在应用softmax前,会使用一个超参数温度T对logit进行缩放,以使概率变得“更软” :

p_{i}\left ( x \right )=\frac{exp\left ( z_{i} \left ( x \right )/T\right )}{\sum_{j}^{}exp\left ( z_{i}\left ( x \right ) /T\right )}

如下图所示,T值越高,类上的概率分布越平缓,能够揭示出教师模型为每个训练例子所学习到的决策边界的更多信息。当T=1时,我们可以得到原始的softmax分布。

一个硬标注被独热编码(左)、softmax概率(中)、以及软分类概率(右)的比较

由于学生模型也会生成自己的软概率qi(x),我们可以使用Kullback-Leibler(KL)(https://oreil.ly/8nKQG)散度来度量两个概率分布之间的差异:

D_{KL}\left ( p,q \right )=\sum_{i}^{}p_{i}\left ( x \right )log\frac{p_{i}\left ( x \right )}{q_{i}\left ( x \right )}

使用KL散度,我们可以计算当我们用学生模型的概率分布来近似教师模型的概率分布时的损失。这使我们能够定义知识蒸馏损失:

2

L=TD

KDKL

22

其中T是一种规范化因子,它考虑到软标注产生的梯度大小与1/T成比例的事实。对于分类任务,学生模型损失则是蒸馏损失与一个常规的基于交叉熵的基准标注损失L的加权平均。

CE

L=\alpha L+(1-\alpha )L

studentCEKD

其中α是一个超参数,它控制每个损失的相对强度。整个过程的示意图如图8-4所示,在推断时间,温度设置为1,以恢复标准softmax概率。

知识蒸馏过程

2、基

V. Sanh et al., “DistilBERT,a Distilled Version of BERT:Smaller,Faster,Cheaper and Lighter”(https://arxiv.org/abs/1910.01108),(2019).

知识蒸馏还可以在预训练期间使用,我们可以创建一个通用的学生模型,随后可对下游任务进行微调。在这种情况下,教师模型是一个预训练语言模型,例如BERT,它将其关于掩码语言建模的知识迁移给学生模型。例如,在DistilBERT论文中 ,掩码语言建模损失L由知识蒸馏术语和余弦嵌入损失L=1-cos(hs,ht)扩展,以调整教师模型和学生模型之间的隐藏状态向量方向:

mlmcos

L=αL+βL+γL

DistilBERTmlmKDcos

由于我们已经有了经过微调的BERT-base模型,我们看看如何使用知识蒸馏来微调更小、更快的模型。为了实现这一点,我们需要一种将交叉熵损失与LKD项相结合的方法。幸运的是,我们可以通过创建自己的训练器来做到这一点!

3、创

为实现知识蒸馏,我们需要向Trainer基类添加几个东西:

  • 新的超参数αT,控制了蒸馏损失的相对权重以及标注概率分布应平滑化的程度。
  • 微调的教师模型,在我们的例子中是BERT-base。
  • 一种新的损失函数,将交叉熵损失与知识蒸馏损失结合起来。

添加新的超参数非常简单,我们只需要继承TrainingArguments类并将它们作为新属性包含进去:

# 从 transformers 库中导入 TrainingArguments 类
from transformers import TrainingArguments

# 定义一个新的类 DistillationTrainingArguments,继承自 TrainingArguments
class DistillationTrainingArguments(TrainingArguments):
    def __init__(self, *args, alpha=0.5, temperature=2.0, **kwargs):
        """
        初始化蒸馏训练参数类
        
        参数:
        - *args: 传递给父类 TrainingArguments 的位置参数
        - alpha: 用于蒸馏的损失函数中的权重因子,默认为 0.5
        - temperature: 蒸馏过程中的温度参数,默认为 2.0
        - **kwargs: 传递给父类 TrainingArguments 的关键字参数
        """
        # 调用父类 TrainingArguments 的初始化方法
        super().__init__(*args, **kwargs)
        
        # 初始化蒸馏训练参数中的 alpha 和 temperature
        self.alpha = alpha
        self.temperature = temperature

对于训练器本身,我们需要一个新的损失函数。实现这一点的方法是通过子类化Trainer并覆盖compute_loss()方法,包括知识蒸馏损失项L

KD

import torch.nn as nn  # 导入 PyTorch 的神经网络模块
import torch.nn.functional as F  # 导入 PyTorch 的神经网络函数模块
from transformers import Trainer  # 从 transformers 库中导入 Trainer 类

# 定义一个新的类 DistillationTrainer,继承自 Trainer
class DistillationTrainer(Trainer):
    def __init__(self, *args, teacher_model=None, **kwargs):
        """
        初始化蒸馏训练类
        
        参数:
        - *args: 传递给父类 Trainer 的位置参数
        - teacher_model: 教师模型,用于知识蒸馏
        - **kwargs: 传递给父类 Trainer 的关键字参数
        """
        # 调用父类 Trainer 的初始化方法
        super().__init__(*args, **kwargs)
        
        # 初始化教师模型
        self.teacher_model = teacher_model

    def compute_loss(self, model, inputs, return_outputs=False):
        """
        计算损失函数
        
        参数:
        - model: 学生模型
        - inputs: 输入数据
        - return_outputs: 是否返回模型输出,默认为 False
        
        返回:
        - 损失值(如果 return_outputs 为 True,则返回损失值和学生模型的输出)
        """
        # 检查是否有可用的 GPU,并将输入数据移到相应的设备
        device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        inputs = inputs.to(device)
        
        # 计算学生模型的输出
        outputs_stu = model(**inputs)
        
        # 从学生模型的输出中提取交叉熵损失和 logits
        loss_ce = outputs_stu.loss
        logits_stu = outputs_stu.logits
        
        # 使用教师模型计算 logits
        with torch.no_grad():
            outputs_tea = self.teacher_model(**inputs)
            logits_tea = outputs_tea.logits
        
        # 计算蒸馏损失,使用 KL 散度损失函数
        loss_fct = nn.KLDivLoss(reduction="batchmean")
        loss_kd = self.args.temperature ** 2 * loss_fct(
            F.log_softmax(logits_stu / self.args.temperature, dim=-1),
            F.softmax(logits_tea / self.args.temperature, dim=-1))
        
        # 返回加权的学生模型损失
        loss = self.args.alpha * loss_ce + (1. - self.args.alpha) * loss_kd
        return (loss, outputs_stu) if return_outputs else loss

我们逐步解释一下这段代码。当我们实例化DistillationTrainer时,我们传了一个已经在我们的任务上进行了微调的教师模型作为teacher_model参数。接下来,在compute_loss()方法中,我们从学生模型和教师模型中提取logit,将它们按温度进行缩放,并通过softmax进行规范化之后,传给PyTorch的nn.KLDivLoss()函数计算KL散度。nn.KLDivLoss()的一个怪癖是,它期望以对数概率形式输入,并将标注作为普通概率。这就是为什么我们使用F.log_softmax()函数来规范化学生模型的logit,而将教师模型的logit转换为标准softmax概率的原因。nn.KLDivLoss()中的reduction=batchmean参数指定我们在批处理维度上对损失进行平均。

你还可以使用Hugging Face Transformers库的Keras API进行知识蒸馏。为此,你需要实现一个自定义的Distiller类,覆盖tf.keras.Model()的train_step()、test_step()和compile()方法。请查看Keras文档(https://oreil.ly/6qp0F)了解如何实现。

4、选

Y. Kim and H. Awadalla,“FastFormers: Highly EfficientTransformerModels for Natural Language Understanding”(https://arxiv.org/abs/2010.13382),(2020).

现在我们有了自定义的训练器,你可能会问的第一个问题是应该选择哪个预训练语言模型作为学生模型?一般来说,我们应该选择一个更小的模型来降低延迟和内存占用。从文献上来看,一个好的经验法则是当教师模型和学生模型为同一模型类型时,知识蒸馏效果最好 。这种情况的一个可能原因是不同的模型类型,比如BERT和RoBERTa,可以具有不同的输出嵌入空间,这会阻碍学生模型模仿教师模型的能力。在我们的案例研究中,教师模型是BERT,因此DistilBERT是一个很自然的备选项,因为它的参数数量少了40%,并且已经被证明在下游任务上取得了强大的结果。

首先,我们需要对查询进行词元化和编码,因此我们从DistilBERT实例化词元分析器,并创建一个简单的tokenize_text()函数来处理预处理:

from transformers import AutoTokenizer  # 从 transformers 库中导入 AutoTokenizer

# 定义学生模型的检查点
student_ckpt = "distilbert-base-uncased"

# 从预训练模型中加载学生模型的分词器
student_tokenizer = AutoTokenizer.from_pretrained(student_ckpt)

def tokenize_text(batch):
    """
    将批处理数据中的文本进行分词
    
    参数:
    - batch: 包含文本数据的批处理
    
    返回:
    - 分词后的批处理数据
    """
    # 使用学生模型的分词器对批处理中的文本进行分词,并截断到模型支持的最大长度
    return student_tokenizer(batch["text"], truncation=True)

# 使用 tokenize_text 函数对 CLINC 数据集进行分词,并更新数据集
# batched=True 表示对整个批处理的数据进行分词,而不是逐个样本进行
# remove_columns=["text"] 表示分词后删除原始的文本列
clinc_enc = clinc.map(tokenize_text, batched=True, remove_columns=["text"])

# 将数据集中的 "intent" 列重命名为 "labels",以便模型可以识别
clinc_enc = clinc_enc.rename_column("intent", "labels")

运行结果:

默认情况下,Trainer在对分类任务进行微调时会查找一个名为labels的列。也可以通过指定TrainingArguments的label name参数来覆盖此行为。

在这里,我们已经删除了文本(text)列,因为我们不再需要它,同时我们也将意图(intent)列重命名为标注(labels),以便于训练器自动检测 。

现在我们已经处理了文本,接下来需要做的是为我们的DistillationTrainer定义超参数和compute_metrics()函数。我们还将把所有的模型推送到Hugging Face Hub,所以我们开始登录我们的账户:

接下来,我们将定义训练过程中需要跟踪的指标。与性能基准测试一样,我们将使用准确率作为主要指标。这意味着我们可以在compute_metrics()函数中重用accuracy_score()函数,并将其包含在DistillationTrainer中。

import numpy as np  # 导入 NumPy 库,用于数学运算

def compute_metrics(pred):
    """
    计算评估指标
    
    参数:
    - pred: 包含预测结果和真实标签的元组或列表
    
    返回:
    - 计算出的准确率指标
    """
    predictions, labels = pred  # 解包预测结果和真实标签
    predictions = np.argmax(predictions, axis=1)  # 获取预测类别的索引
    # 调用准确率计算函数,计算预测结果的准确率
    return accuracy_score.compute(predictions=predictions, references=labels)

在这个函数中,序列建模头的预测以logit的形式呈现,因此我们使用np.argmax()函数找到最有信心的类别预测,并将其与基准标注进行比较。

这种微调通用的、精简的语言模型的方法有时被称为“任务无关”的精简。

接下来,我们需要定义训练参数。首先,我们将设置α=1,以了解DistilBERT在没有任何来自教师模型的信号的情况下表现如何 。然后,我们将推送我们微调过的模型到一个名为distilbert-base-uncased-finetuned-clinc的新存储库中,因此我们只需要在DistillationTrainingArguments的output_dir参数中指定即可。

batch_size = 48  # 设置批处理大小为 48

finetuned_ckpt = "distilbert-base-uncased-finetuned-clinc"  # 指定微调后的检查点路径

# 创建学生模型蒸馏训练参数对象 DistillationTrainingArguments
student_training_args = DistillationTrainingArguments(
    output_dir=finetuned_ckpt,                    # 指定输出目录,用于保存训练过程中的文件
    evaluation_strategy="epoch",                  # 指定评估策略为每个 epoch 结束后进行评估
    num_train_epochs=5,                           # 指定训练的 epoch 数量为 5
    learning_rate=2e-5,                           # 指定学习率为 2e-5
    per_device_train_batch_size=batch_size,       # 指定每个设备的训练批处理大小
    per_device_eval_batch_size=batch_size,        # 指定每个设备的评估批处理大小
    alpha=1,                                      # 设置蒸馏损失中交叉熵损失的权重因子为 1
    weight_decay=0.01,                            # 设置权重衰减因子为 0.01,用于正则化
    push_to_hub=False                             # 设置为 True,将微调后的模型推送到 Hugging Face Hub
)

我们还调整了一些默认的超参数值,比如轮次数、权重衰减和学习率。下一步要做的是初始化一个学生模型。由于我们将在训练器中进行多次运行,我们将创建一个student_init()函数来初始化每个新运行的模型。当我们将此函数传给DistillationTrainer时,这将确保每次调用train()方法时我们都初始化一个新模型。

我们需要做的另一件事是为学生模型提供每个意图和标注ID之间的映射。这些映射可以从我们在pipeline中下载的BERT-base模型中获得:

# 获取管道模型的标签映射表
id2label = pipe.model.config.id2label  # id 到标签的映射
label2id = pipe.model.config.label2id  # 标签到 id 的映射

有了这些映射,我们现在可以使用第3章和第4章遇到的AutoConfig类创建自定义模型配置。我们使用这个配置来为我们的学生模型创建一个包含有关标注映射信息的配置:

from transformers import AutoConfig  # 导入 AutoConfig 类

# 获取 CLINC 数据集中的类别数量作为 num_labels
num_labels = intents.num_classes

# 使用 AutoConfig 类从预训练模型中加载配置,并设置 num_labels、id2label 和 label2id
student_config = AutoConfig.from_pretrained(
    student_ckpt,             # 学生模型的预训练检查点路径
    num_labels=num_labels,    # 设置模型输出的类别数量
    id2label=id2label,        # 设置 id 到标签的映射
    label2id=label2id         # 设置标签到 id 的映射
)

在这里,我们还指定了模型应该期望的类数。然后,我们可以将这个配置提供给AutoMo delForSequenceClassification类的from pretrained()函数,如下所示:

import torch  # 导入 PyTorch 库
from transformers import AutoModelForSequenceClassification  # 导入 AutoModelForSequenceClassification 类

# 检查是否有可用的 GPU,如果有则使用 CUDA,否则使用 CPU
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

def student_init():
    """
    定义初始化学生模型的函数
    
    返回:
    - 初始化后的学生模型,加载自预训练模型并移动到合适的设备(GPU 或 CPU)
    """
    # 使用 AutoModelForSequenceClassification 类从预训练模型中加载学生模型,并传入配置和设备信息
    return AutoModelForSequenceClassification.from_pretrained(
        student_ckpt,            # 学生模型的预训练检查点路径
        config=student_config   # 使用之前配置的 student_config
    ).to(device)                # 将加载后的模型移动到设备(GPU 或 CPU)

我们现在已经拥有所有需要制作我们的蒸馏模型的成分,因此我们加载模型并进行微调:

teacher_ckpt = "transformersbook/bert-base-uncased-finetuned-clinc"  # 定义教师模型的检查点路径

# 使用 AutoModelForSequenceClassification 类从预训练模型中加载教师模型,并设置类别数量及设备
teacher_model = AutoModelForSequenceClassification.from_pretrained(
    teacher_ckpt,        # 教师模型的预训练检查点路径
    num_labels=num_labels,  # 设置模型输出的类别数量
).to(device)               # 将加载后的模型移动到设备(GPU 或 CPU)

# 创建蒸馏训练器对象 DistillationTrainer
distilbert_trainer = DistillationTrainer(
    model_init=student_init,          # 初始化学生模型的函数
    teacher_model=teacher_model,      # 教师模型
    args=student_training_args,       # 学生模型的训练参数
    train_dataset=clinc_enc['train'], # 训练数据集
    eval_dataset=clinc_enc['validation'],  # 验证数据集
    compute_metrics=compute_metrics,  # 计算评估指标的函数
    tokenizer=student_tokenizer       # 分词器
)

# 启动蒸馏训练过程
distilbert_trainer.train()

运行结果:

TrainOutput(global_step=1590, training_loss=2.0491142752785354, metrics={'train_runtime': 1342.9993, 'train_samples_per_second': 56.776, 'train_steps_per_second': 1.184, 'total_flos': 413896353421488.0, 'train_loss': 2.0491142752785354, 'epoch': 5.0})

我们可以看到,与BERT基础模型达到的94%的准确率相比,知识蒸馏之后,在验证集上的准确率还能达到92%,看起来相当不错。现在我们已经对DistilBERT进行了微调,我们将模型推到Hub上,以便以后重复使用:

# 将训练后的模型推送到 Hugging Face Hub
distilbert_trainer.push_to_hub("Training completed!")

我们现在将模型安全地存储在平台上,可以立即在性能基准测试中使用它。

# 定义微调后的模型检查点路径
finetuned_ckpt = "transformersbook/distilbert-base-uncased-finetuned-clinc"

# 创建文本分类管道,加载微调后的模型
pipe = pipeline("text-classification", model=finetuned_ckpt)

运行结果:

我们可以将该pipeline传给我们的PerformanceBenchmark类来计算与该模型相关的指标:

# 定义优化器类型
optim_type = "DistilBERT"

# 创建性能评估对象 PerformanceBenchmark
pb = PerformanceBenchmark(pipe, clinc["test"], optim_type=optim_type)

# 运行性能评估,并将结果更新到 perf_metrics 中
perf_metrics.update(pb.run_benchmark())

运行结果:

Model size (MB) - 255.88
Average latency (ms) - 8.57 +- 0.40
Accuracy on test set - 0.858

为了将这些结果与我们的基线进行比较,我们创建一个散点图,将准确率与延迟进行比较,每个点的半径对应于磁盘上模型的大小。以下函数可以满足我们的需求,将当前优化类型标记为虚线圆以便于与以前结果进行比较:

import pandas as pd  # 导入 pandas 库
import matplotlib.pyplot as plt  # 导入 matplotlib 库

def plot_metrics(perf_metrics, current_optim_type, save_path="performance_metrics.png"):
    """
    绘制性能指标的散点图,并突出显示当前优化类型的数据点。

    参数:
    - perf_metrics: 包含性能指标的字典
    - current_optim_type: 当前优化类型的名称
    - save_path: 图片保存路径
    """
    df = pd.DataFrame.from_dict(perf_metrics, orient='index')  # 将性能指标字典转换为 DataFrame

    # 遍历 DataFrame 中的每个优化类型
    for idx in df.index:
        df_opt = df.loc[idx]
        
        # 如果当前优化类型与索引相同,则添加一个虚线圆圈
        if idx == current_optim_type:
            plt.scatter(df_opt["time_avg_ms"], df_opt["accuracy"] * 100, 
                        alpha=0.5, s=df_opt["size_mb"], label=idx, 
                        marker='o')  # 标记当前优化类型的数据点
        else:
            plt.scatter(df_opt["time_avg_ms"], df_opt["accuracy"] * 100, 
                        s=df_opt["size_mb"], label=idx, alpha=0.5)
    
    # 调整图例的位置和大小
    legend = plt.legend(bbox_to_anchor=(1, 1))
    for handle in legend.get_lines():
        handle._sizes = [20]  # 设置图例标记的大小为 20 像素

    plt.ylim(80, 90)  # 设置 y 轴的范围
    xlim = int(perf_metrics["BERT baseline"]["time_avg_ms"] + 3)  # 使用最慢模型定义 x 轴范围
    plt.xlim(1, xlim)  # 设置 x 轴的范围
    plt.ylabel("Accuracy (%)")  # 设置 y 轴标签
    plt.xlabel("Average latency (ms)")  # 设置 x 轴标签
    plt.savefig(save_path, bbox_inches='tight')  # 保存图片到指定路径
    plt.show()  # 显示绘制的散点图

# 调用 plot_metrics 函数,并传入性能指标和当前优化类型
plot_metrics(perf_metrics, optim_type)

运行结果:

从图中可以看出,通过使用一个较小的模型,我们显著降低了平均延迟。这一切只需要付出降低略超过1%的准确率的代价!我们看看能否通过包含教师模型的蒸馏损失并找到好的参数值来弥补最后的差距。

5、使Optuna

T. Akiba et al., “Optuna:A Next-Generation Hyperparameter Optimization Framework”(https://arxiv.org/abs/1907.10902),(2019).

为了找到适合αT的最佳值,我们可以在二维参数空间上进行网格搜索。但更好的选择是使用Optuna ,这是一个专门为这种任务设计的优化框架。Optuna通过多次实验来优化目标函数,将搜索问题表述为一个目标函数。例如,假设我们希望最小化Rosenbrock的“香蕉函数”(https://oreil.ly/hPk8h)。

2

fxy)=(1-x)2+100(y-x)2

这是一个优化框架的著名测试案例。如下图所示,该函数因其曲线轮廓而得名,在(xy)=(1,1)处有全局最小值。找到这个山谷是一个简单的优化问题,但收敛到全局最小值并不容易。

import matplotlib.pyplot as plt  # 导入 matplotlib.pyplot 库用于绘图
import numpy as np  # 导入 numpy 库用于数值计算

def f(x, y):
    """
    定义一个函数,输入 x 和 y,输出计算值。
    该函数表示一个常见的优化问题函数 (Rosenbrock function)。
    """
    return (1 - x)**2 + 100 * (y - x**2)**2

# 生成网格点,分别在 x 和 y 轴上生成 250 个点
X, Y = np.meshgrid(np.linspace(-2, 2, 250), np.linspace(-1, 3, 250))

# 计算网格点上的函数值
Z = f(X, Y)

# 创建一个新的图形和坐标轴对象
_, ax = plt.subplots()

# 在坐标轴上绘制一个红色的 'x' 标记,表示 (1, 1) 点
ax.plot([1], [1], 'x', mew=3, markersize=10, color="red")

# 绘制等高线填充图,使用 'viridis' 色彩映射
ax.contourf(X, Y, Z, np.logspace(-1, 3, 30), cmap='viridis', extend="both")

# 设置 x 轴和 y 轴的显示范围
ax.set_xlim(-1.3, 1.3)
ax.set_ylim(-0.9, 1.7)

plt.savefig("images/Rosenbrock.png", bbox_inches='tight')  # 保存图片到指定路径

# 显示图形
plt.show()

运行结果:

两个变量的Rosenbrock函数绘图

在Optuna中,我们可以通过定义一个objective()函数,该函数返回fxy)的值,从而找到fxy)的最小值。

def objective(trial):
    """
    定义一个优化目标函数,输入为 Optuna 的 trial 对象。
    该函数会在给定范围内搜索最佳的 x 和 y 变量,使目标函数值最小化。
    
    参数:
    trial (optuna.trial.Trial): 一个 Optuna 的 trial 对象,用于建议搜索空间中的变量。

    返回:
    float: 目标函数值,即 (1 - x) ** 2 + 100 * (y - x ** 2) ** 2。
    """
    
    # 从给定范围内建议一个 x 变量,范围为 [-2, 2]
    x = trial.suggest_float("x", -2, 2)
    
    # 从给定范围内建议一个 y 变量,范围为 [-2, 2]
    y = trial.suggest_float("y", -2, 2)
    
    # 计算并返回目标函数值
    return (1 - x) ** 2 + 100 * (y - x ** 2) ** 2

trial.sugges_float对象指定了要均匀采样的参数范围;Optuna还提供suggest_int和suggest_categorical函数,分别用于整数和分类参数。Optuna将多个实验收集为一项研究,因此我们只需将objective()函数传给study.optimize()以创建研究,如下所示:

import optuna  # 导入 optuna 库用于超参数优化

# 创建一个 Optuna study 对象,默认使用 TPE 采样算法和 MedianPruner 剪枝算法
study = optuna.create_study()

# 使用定义好的 objective 函数进行优化,进行 1000 次试验
study.optimize(objective, n_trials=1000)

# 打印最佳试验的结果,包括最佳参数和对应的目标函数值
print(f"Best trial: {study.best_trial.value}")
print(f"Best parameters: {study.best_trial.params}")

运行结果:

Best trial: 0.0009650981963221803
Best parameters: {'x': 0.9720034298063698, 'y': 0.9461371082473761}

学习完成后,我们得到如下最佳参数:

# 打印最佳参数
print(f"Best parameters: {study.best_params}")

运行结果:

Best parameters: {'x': 0.9720034298063698, 'y': 0.9461371082473761}

我们可以看到,通过1000次实验,Optuna已经成功找到了与全局最小值相当接近的xy值。要在Hugging Face Transformers中使用Optuna,我们首先需定义将要优化的超参数空间。除了αT之外,我们还将包括训练时期的数量:

def hp_space(trial):
    """
    定义一个超参数搜索空间,输入为 Optuna 的 trial 对象。
    该函数返回一个字典,其中包含超参数及其搜索范围。

    参数:
    trial (optuna.trial.Trial): 一个 Optuna 的 trial 对象,用于建议超参数的值。

    返回:
    dict: 包含超参数及其建议值的字典。
    """
    
    return {
        # 建议训练轮数,范围为 [5, 10],使用整数值
        "num_train_epochs": trial.suggest_int("num_train_epochs", 5, 10),
        
        # 建议 alpha 超参数的值,范围为 [0, 1],使用浮点数值
        "alpha": trial.suggest_float("alpha", 0, 1),
        
        # 建议温度超参数的值,范围为 [2, 20],使用整数值
        "temperature": trial.suggest_int("temperature", 2, 20)
    }

使用Trainer运行超参数搜索非常简单,我们只需要指定要运行的实验数量和要优化的方向。因为我们希望获得最佳的准确率,所以我们在训练器的hyperparameter_search()方法中指定direction="maximize",并将超参数搜索空间按如下传递:

# 进行超参数搜索,寻找最佳的训练参数组合
best_run = distilbert_trainer.hyperparameter_search(
    n_trials=20,           # 搜索的试验次数
    direction="maximize",  # 目标是最大化评估指标
    hp_space=hp_space      # 定义超参数搜索空间的函数
)

运行结果:

hyperparameter search()方法返回一个BestRun对象,该对象包含所最大化的目标的值(默认为所有指标的总和)和它用于该运行的超参数:

# 打印最佳运行的结果,包括最佳参数组合和对应的评估指标值
print(f"Best trial: {best_run}")
print(f"Best hyperparameters: {best_run.hyperparameters}")
print(f"Best score: {best_run.objective}")

运行结果:

Best trial: BestRun(run_id='0', objective=0.9232258064516129, hyperparameters={'num_train_epochs': 9, 'alpha': 0.8637270812068832, 'temperature': 5})
Best hyperparameters: {'num_train_epochs': 9, 'alpha': 0.8637270812068832, 'temperature': 5}
Best score: 0.9232258064516129

这个α值告诉我们,大部分的训练信号都来自知识蒸馏项。让我们使用这些值更新我们的训练参数,并运行最后的训练:

# 使用最佳试验的超参数更新学生模型的训练参数
for k, v in best_run.hyperparameters.items():
    setattr(student_training_args, k, v)

# 定义一个新的存储库来存储蒸馏后的模型
distilled_ckpt = "distilbert-base-uncased-distilled-clinc"
student_training_args.output_dir = distilled_ckpt

# 使用最佳参数创建一个新的 Trainer
distil_trainer = DistillationTrainer(
    model_init=student_init,              # 使用学生模型的初始化函数
    teacher_model=teacher_model,          # 使用教师模型
    args=student_training_args,           # 使用更新后的训练参数
    train_dataset=clinc_enc['train'],     # 使用编码后的训练数据集
    eval_dataset=clinc_enc['validation'], # 使用编码后的验证数据集
    compute_metrics=compute_metrics,      # 使用定义好的评估指标函数
    tokenizer=student_tokenizer           # 使用学生模型的分词器
)

# 开始训练模型
distil_trainer.train();

运行结果:

非常惊人的是,虽然学生模型的参数数量几乎是教师模型的一半,但我们已经成功地训练出学生模型,使其准确率与教师模型相当!我们把这个模型推到库中供未来使用:

# 将训练后的模型推送到 Hugging Face Hub
distilbert_trainer.push_to_hub("Training completed!")

6、基

现在我们有了一个准确的学生模型,让我们创建一个pipeline,重新进行基准测试,看看它在测试集上的表现如何:

# 定义蒸馏后模型的检查点路径
distilled_ckpt = "transformersbook/distilbert-base-uncased-distilled-clinc"

# 使用新的检查点路径创建一个文本分类管道
pipe = pipeline("text-classification", model=distilled_ckpt)

# 定义优化类型为蒸馏
optim_type = "Distillation"

# 创建一个性能基准测试对象
pb = PerformanceBenchmark(pipe, clinc["test"], optim_type=optim_type)

# 运行基准测试并更新性能指标
perf_metrics.update(pb.run_benchmark())

运行结果:

为了将这些结果放入上下文中,我们使用plot_metrics()函数可视化它们:

# 为了将这些结果放入上下文中,我们使用 plot_metrics() 函数可视化它们
plot_metrics(perf_metrics, optim_type,"images/Distillation.png")

# 正如预期的那样,与 DistilBERT 基准相比,模型的大小和延迟基本保持不变,
# 但准确率得到了改善,甚至超过了教师模型!
# 这个结果出人意料,很有可能是因为教师模型没有像学生模型那样系统地进行微调。
# 这很棒,但实际上我们还可以使用一种称为量化的技术进一步压缩我们的蒸馏模型。

运行结果:

正如预期的那样,与DistilBERT基准相比,模型的大小和延迟基本保持不变,但准确率得到了改善,甚至超过了教师模型!这个结果出人意料,很有可能是因为教师模型没有像学生模型那样系统地进行微调。这很棒,但实际上我们还可以使用一种称为量化的技术进一步压缩我们的蒸馏模型。

附录:

一、当前案例环境 pacakge 的 版本如下

Package                   Version
------------------------- --------------
aiohttp                   3.9.5
aiosignal                 1.3.1
alembic                   1.13.2
anyio                     4.4.0
argon2-cffi               23.1.0
argon2-cffi-bindings      21.2.0
arrow                     1.3.0
asttokens                 2.4.1
async-lru                 2.0.4
attrs                     23.2.0
Babel                     2.15.0
beautifulsoup4            4.12.3
bleach                    6.1.0
certifi                   2024.7.4
cffi                      1.16.0
charset-normalizer        3.3.2
colorama                  0.4.6
colorlog                  6.8.2
comm                      0.2.2
contourpy                 1.2.1
cycler                    0.12.1
datasets                  2.20.0
debugpy                   1.8.2
decorator                 5.1.1
defusedxml                0.7.1
dill                      0.3.8
executing                 2.0.1
fastjsonschema            2.20.0
filelock                  3.15.4
fonttools                 4.53.1
fqdn                      1.5.1
frozenlist                1.4.1
fsspec                    2024.5.0
greenlet                  3.0.3
h11                       0.14.0
httpcore                  1.0.5
httpx                     0.27.0
huggingface-hub           0.23.4
idna                      3.7
ipykernel                 6.29.5
ipython                   8.26.0
ipywidgets                8.1.3
isoduration               20.11.0
jedi                      0.19.1
Jinja2                    3.1.4
joblib                    1.4.2
json5                     0.9.25
jsonpointer               3.0.0
jsonschema                4.23.0
jsonschema-specifications 2023.12.1
jupyter                   1.0.0
jupyter_client            8.6.2
jupyter-console           6.6.3
jupyter_core              5.7.2
jupyter-events            0.10.0
jupyter-lsp               2.2.5
jupyter_server            2.14.2
jupyter_server_terminals  0.5.3
jupyterlab                4.2.3
jupyterlab_pygments       0.3.0
jupyterlab_server         2.27.2
jupyterlab_widgets        3.0.11
kiwisolver                1.4.5
Mako                      1.3.5
MarkupSafe                2.1.5
matplotlib                3.9.1
matplotlib-inline         0.1.7
mistune                   3.0.2
mpmath                    1.3.0
multidict                 6.0.5
multiprocess              0.70.16
nbclient                  0.10.0
nbconvert                 7.16.4
nbformat                  5.10.4
nest-asyncio              1.6.0
networkx                  3.3
notebook                  7.2.1
notebook_shim             0.2.4
numpy                     1.26.4
optuna                    3.6.1
overrides                 7.7.0
packaging                 24.1
pandas                    2.2.2
pandocfilters             1.5.1
parso                     0.8.4
pillow                    10.4.0
pip                       24.1.2
platformdirs              4.2.2
prometheus_client         0.20.0
prompt_toolkit            3.0.47
psutil                    6.0.0
pure-eval                 0.2.2
pyarrow                   16.1.0
pyarrow-hotfix            0.6
pycparser                 2.22
Pygments                  2.18.0
pyparsing                 3.1.2
python-dateutil           2.9.0.post0
python-json-logger        2.0.7
pytz                      2024.1
pywin32                   306
pywinpty                  2.0.13
PyYAML                    6.0.1
pyzmq                     26.0.3
qtconsole                 5.5.2
QtPy                      2.4.1
referencing               0.35.1
regex                     2024.5.15
requests                  2.32.3
rfc3339-validator         0.1.4
rfc3986-validator         0.1.1
rpds-py                   0.19.0
scikit-learn              1.5.1
scipy                     1.14.0
Send2Trash                1.8.3
sentencepiece             0.2.0
setuptools                70.0.0
six                       1.16.0
sniffio                   1.3.1
soupsieve                 2.5
SQLAlchemy                2.0.31
stack-data                0.6.3
sympy                     1.13.0
terminado                 0.18.1
threadpoolctl             3.5.0
tinycss2                  1.3.0
tokenizers                0.13.3
torch                     2.2.1
tornado                   6.4.1
tqdm                      4.66.4
traitlets                 5.14.3
transformers              4.24.0
types-python-dateutil     2.9.0.20240316
typing_extensions         4.12.2
tzdata                    2024.1
uri-template              1.3.0
urllib3                   2.2.2
wcwidth                   0.2.13
webcolors                 24.6.0
webencodings              0.5.1
websocket-client          1.8.0
wheel                     0.43.0
widgetsnbextension        4.0.11
xxhash                    3.4.1
yarl                      1.9.4

  • 7
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

仙魁XAN

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值