AGI 之 【Hugging Face】 的【零样本和少样本学习】之二 [零样本学习] / [ 少样本学习 ] 的简单整理
目录
AGI 之 【Hugging Face】 的【零样本和少样本学习】之二 [零样本学习] / [ 少样本学习 ] 的简单整理
一、简单介绍
二、零样本学习 (Zero-shot Learning) 和少样本学习 (Few-shot Learning)
1、零样本学习 (Zero-shot Learning)
2、少样本学习 (Few-shot Learning)
三、零样本学习
四、少样本学习
1、数据增强
2、使用嵌入作为查找表
3、对Transformer做微调
4、基于提示的上下文学习和少样本学习
附录
一、当前案例环境 package 的 版本如下
一、简单介绍
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的发展和应用中扮演关键角色。
(注意:以下代码运行,可能需要科学上网)
二、零样本学习 (Zero-shot Learning) 和少样本学习 (Few-shot Learning)
1、零样本学习 (Zero-shot Learning)
定义: 零样本学习是一种让模型能够在没有见过目标类别数据的情况下进行预测的技术。它主要依赖于预训练的语言模型和自然语言描述,利用模型在预训练期间学到的广泛知识来理解和推断新的任务。
实现方式:
- 基于语言模型的零样本分类: 使用预训练的语言模型,如 BERT、GPT-3,通过自然语言提示 (prompt) 进行分类。Hugging Face 提供了
zero-shot-classification
pipeline,使这一过程非常简单。from transformers import pipeline# 加载零样本分类 pipeline zero_shot_classifier = pipeline("zero-shot-classification")# 定义待分类的文本 text = "Hugging Face's library is so easy to use!"# 定义候选标签 labels = ["education", "politics", "technology"]# 进行零样本分类 result = zero_shot_classifier(text, candidate_labels=labels) print(result)
- 利用嵌入向量和距离度量: 通过计算文本嵌入向量之间的相似度来实现零样本分类。模型在预训练期间学到的嵌入空间使得相似类别的文本在向量空间中更接近。
- 自然语言推理 (NLI): 使用 NLI 模型,如 RoBERTa,对于每个候选标签,模型判断该标签是否是输入文本的合理推断。Hugging Face 提供了类似的模型,可以通过 NLI 的方式实现零样本学习。
from transformers import pipeline# 加载 NLI 模型 nli_model = pipeline("zero-shot-classification", model="facebook/bart-large-mnli")# 定义待分类的文本 text = "Hugging Face's library is so easy to use!"# 定义候选标签 labels = ["education", "politics", "technology"]# 进行零样本分类 result = nli_model(text, candidate_labels=labels) print(result)
2、少样本学习 (Few-shot Learning)
定义: 少样本学习是一种让模型能够在只见过少量目标类别数据的情况下进行有效预测的技术。它通过对预训练模型进行微调,利用少量标注数据来学习新的任务。
实现方式:
基于预训练模型的微调: 使用预训练的 Transformer 模型(如 BERT、GPT-3),通过少量标注数据进行微调。Hugging Face 提供了
Trainer
API,可以方便地进行微调。from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments from datasets import Dataset# 示例少样本数据 data = {"text": ["I love using Hugging Face!", "The library is very intuitive."],"label": [1, 1] }# 创建 Dataset 对象 dataset = Dataset.from_dict(data)# 加载预训练模型和分词器 model_name = "bert-base-uncased" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)# 数据预处理 def preprocess_data(examples):return tokenizer(examples["text"], truncation=True, padding=True)tokenized_dataset = dataset.map(preprocess_data, batched=True)# 设置训练参数 training_args = TrainingArguments(output_dir="./results",num_train_epochs=3,per_device_train_batch_size=2,logging_dir="./logs", )# 创建 Trainer 实例 trainer = Trainer(model=model,args=training_args,train_dataset=tokenized_dataset )# 开始训练 trainer.train()
Prompt-based 学习: 通过设计好的提示语(prompts)进行少样本学习,将少量数据转化为对模型的提示。这个方法在 GPT-3 等模型上表现出色。
元学习 (Meta-learning): 利用元学习算法,如 MAML (Model-Agnostic Meta-Learning),训练模型在少量新数据上快速适应。虽然 Hugging Face 目前没有直接的元学习 API,但可以结合 PyTorch 等库实现。
零样本学习和少样本学习是解决数据有限情况下进行有效机器学习的重要方法。通过使用 Hugging Face 提供的预训练模型和工具,可以轻松实现这两种技术:
- 零样本学习: 依赖于预训练语言模型,通过自然语言提示或嵌入向量实现分类和推理。
- 少样本学习: 通过微调预训练模型或使用提示语进行学习,以适应新的任务和数据。
三、零样本学习
这里我们第一个考虑的技术是零样本分类技术,它完全适合没有标注数据的场景。无标注数据在业界非常普遍,可能是因为历史数据无标注,也可能是因为获取数据的标注很困难。在本节中,我们可能欺骗模型,因为我们仍然会使用测试数据来度量性能,但是我们不会使用任何数据来训练模型(否则与以下介绍的方法相比较会很困难)。
零样本分类的目标是使用预训练模型,不需要对你的特定任务的语料做附加的微调操作。为了更好地理解这一点,回想一下,BERT这样的语言模型也是经过预训练的,用以预测数千本书和维基百科大型转储文本中的掩码词元。为了成功预测一个缺失词元,该模型需要意识到上下文中的主题。可以尝试通过提供下面的这样的句子来欺骗模型为我们做文档分类:
模型会为该文档的主题给出一个合理的建议,因为这是一个在数据集中出现的自然文本 。
下面用一个关于生活中常见的问题来进一步说明。假设你有两个孩子,一个喜欢有汽车的电影,而另一个更喜欢有动物的电影。不幸的是,他们已经看过了你知道的所有电影,现在你想创建一个函数来告诉你他们想看的新电影是关于什么主题的。你会很自然地借助Transformer来完成这项任务。首先要做的事情是将BERT-base加载到fill-mask pipeline中,该pipeline使用掩码语言模型来预测掩码词元的内容:
# 导入 transformers 库中的 pipeline 模块
from transformers import pipeline# 使用 "fill-mask" pipeline 创建一个填充掩码的任务
# 使用 "bert-base-uncased" 模型,这是一个小写的预训练 BERT 模型
pipe = pipeline("fill-mask", model="bert-base-uncased")
运行结果:
接下来,创建电影的描述,并在其中为掩码词元添加一个提示。这个提示的目的是引导模型来为我们做分类操作。fill-mask pipeline会返回最可能的词元来填充在掩码词的地方:
# 定义电影描述文本
movie_desc = "The main characters of the movie Madagascar are a lion, a zebra, a giraffe, and a hippo. "# 定义填充掩码的提示语句
prompt = "The movie is about [MASK]."# 使用 pipeline 对电影描述文本和填充掩码提示进行填充掩码任务处理
output = pipe(movie_desc + prompt)# 输出每个候选词的预测结果和置信度
for element in output:print(f"Token {element['token_str']}:\t{element['score']:.3f}%")
运行结果:
Token madagascar: 0.425% Token animals: 0.084% Token sharks: 0.054% Token lions: 0.037% Token penguins: 0.032%
很显然,该模型只预测了与动物有关的词元。我们也可以调转一下思路,向pipeline查询几个给定词元的概率,而不是获得最可能的词元。对于这个任务,我们可能会选择汽车(cars)和动物(animals),所以我们将它们作为目标传给pipeline:
# 使用 pipeline 对电影描述文本和填充掩码提示进行填充掩码任务处理,并指定填充目标为 ["animals", "cars"]
output = pipe(movie_desc + prompt, targets=["animals", "cars"])# 输出每个候选词的预测结果和置信度
for element in output:print(f"Token {element['token_str']}:\t{element['score']:.3f}%")
运行结果:
Token animals: 0.084% Token cars: 0.000%
结果不出所料,对汽车词元的预测概率要比动物词元的预测概率小得多。让我们看看这是否也适用于更接近汽车的描述:
# 定义电影描述文本
movie_desc = "In the movie Transformers, aliens can morph into a wide range of vehicles."# 使用 pipeline 对电影描述文本和填充掩码提示进行填充掩码任务处理,并指定填充目标为 ["animals", "cars"]
output = pipe(movie_desc + prompt, targets=["animals", "cars"])# 输出每个候选词的预测结果和置信度
for element in output:print(f"Token {element['token_str']}:\t{element['score']:.3f}%")
运行结果:
Token cars: 0.065% Token animals: 0.003%
这非常奏效!以上仅是一个非常简单的例子,如果我们想要它运行稳定,就需要对它做更加详尽的测试,但它揭示了所探讨的许多方法的关键思想:找到一种方法,使一个预训练模型去适应其他任务,而不是重新训练一个模型。在这个理论前提下,我们为掩码词元设置了提示,这样就可以直接使用掩码语言模型来进行分类。下面来看看是否可以通过调整一个已在接近文本分类任务上做过微调的模型来做得更好:自然语言推理(Natural Language Inference,NLI)。
A. Williams, N. Nangia, and S.R. Bowman, “A Broad-Coverage Challenge Corpus for Sentence Understanding Through Inference”(https://arxiv.org/abs/1704.05426), (2018); A. Conneau et al., “XNLI: Evaluating Cross Lingual Sentence Representations”(https://arxiv.org/abs/1809.05053), (2018).
使用掩码语言模型来做分类任务是一个好技巧,但我们还可以通过使用一个在更接近分类的任务上训练出来的模型来做得更好,有一个叫文本蕴含任务的技术符合这个路线。在文本蕴含任务中,模型需要确定两个文本段落是否有可能相互蕴含或相互矛盾。这样的模型常被训练来检测MNLI语料库(Multi-Genre NLI Corpus)或XNLI语料库(Cross-Lingual NLI Corpus)等数据集的蕴含和矛盾。
这两个数据集中的每个样本都由三部分组成:一个前提,一个假设和一个标注,标注可以是蕴含(entailment)、中性(neutral)或矛盾(contradiction)的。当假设在前提下必为真时,就会被归类为蕴含标注;当假设在前提下必然是假或不合适时,就会被归类为矛盾标注;如果两种情况都不满足,就归类为中性标注。这几种情况的例子如下图所示。
现在,我们可以劫持一个在MNLI数据集上训练的模型来创建一个分类器,而且根本不需要任何标注!这其中最关键的想法是将我们希望分类的文本视为前提,然后将假设表示为:
其中,我们插入了标注的名称,而蕴含分数则会告诉我们该前提和该主题相关的可能性大小,可以对任意数量的类依次运行这个方法。这种方法的缺点是,需要对每个类执行一个前向传递,这会使得它的运行效率低于标准分类器。另一个稍显棘手的问题是,标注名称的选择会对准确率产生很大的影响,因此选择本身具备语义的标注通常是最好的方法。例如,如果标注是简单的Class 1,那么模型就无法提示这是什么意思,以及是否构成蕴含或矛盾的关系。
Hugging Face Transformers有一个内置的MNLI模型用于零样本分类的创建,我们可以通过一个pipeline来初始化它,如下:
# 导入 transformers 库中的 pipeline 模块
from transformers import pipelineimport torch
# 检查 PyTorch 是否支持 CUDA
print(torch.cuda.is_available())# 使用 "zero-shot-classification" pipeline 创建一个零样本分类任务
# 使用默认的设备索引为 0(通常是 GPU 索引,如果可用)
pipe = pipeline("zero-shot-classification", device=0)# 如果 GPU 不行, 使用 CPU 设备(设置 device 为 -1)
# pipe = pipeline("zero-shot-classification", device=-1)
设置devide=0来确保模型工作在GPU上,而不是工作在默认的CPU上,用以加快推理速度。要对一段文本进行分类,我们只需要将它和标注名称传给pipeline。此外,我们还可以设置multi_label=True来确保得到所有的分数,而不是只得到单个标注分类的最大分数:
# 从训练集中获取样本数据
sample = ds["train"][0]# 打印样本的标签信息
print(f"Labels: {sample['labels']}")# 使用 zero-shot-classification pipeline 进行零样本分类任务处理,并指定所有可能的标签,并启用多标签模式
output = pipe(sample["text"], all_labels, multi_label=True)# 打印处理结果的前400个字符
print(output["sequence"][:400])# 打印预测结果
print("\nPredictions:")
for label, score in zip(output["labels"], output["scores"]):print(f"{label}, {score:.2f}")
运行效果:
Labels: ['new model'] Add new CANINE model# 🌟 New model addition## Model descriptionGoogle recently proposed a new **C**haracter **A**rchitecture with **N**o tokenization **I**n **N**eural **E**ncoders architecture (CANINE). Not only the title is exciting:> Pipelined NLP systems have largely been superseded by end-to-end neural modeling, yet nearly all commonly-used models still require an explicit tokeniPredictions: new model, 0.98 tensorflow or tf, 0.37 examples, 0.34 usage, 0.30 pytorch, 0.25 documentation, 0.25 model training, 0.24 tokenization, 0.17 pipeline, 0.16
由于我们使用的是子词词元分析器,我们甚至能把代码传给模型。整体的词元化效率可能不是很高,因为只有一小部分零样本pipeline的预训练数据集是由代码片段组成的,但代码也是由一些自然词汇组成的,这不是一个很大的问题。此外,代码块可能也包含重要的信息,如一些常用框架(PyTorch或TensorFlow)。
可以看出,该模型对这段文本关乎一个新模型显得非常有信心,但它对其他模型也产生出相对较高的分数。零样本分类的一个重要方面关乎我们要处理的领域。我们在这里要处理的文本是非常贴近技术的,且大部分都与代码编写有关,所以它们与MNLI数据集中的原始文本分布有很大的差异。因此,这对模型来说是一项有挑战的任务也就不足为奇了。它在某些领域的效果可能比其他领域的效果要好得多,这要取决于它们与训练数据的近似程度。
下面来写一个函数,往零样本pipeline中传入一个样本,通过map()的运行将其扩展到整个验证集:
# 定义一个函数,使用 zero-shot-classification pipeline 对样本进行处理
def zero_shot_pipeline(example):# 使用 pipeline 进行零样本分类任务处理,并指定所有可能的标签,并启用多标签模式output = pipe(example["text"], all_labels, multi_label=True)# 将预测的标签和置信度分数添加到样本中example["predicted_labels"] = output["labels"]example["scores"] = output["scores"]return example# 对验证集中的样本数据应用 zero_shot_pipeline 函数
ds_zero_shot = ds["valid"].map(zero_shot_pipeline)
运行结果:
到这一步,我们有了分数,下一步是确定把哪组标注分配给所有样本,有两种选项可供实验:
- 定义一个阈值,并选择所有高于阈值的标注。
- 选取分数最高的k个标注。
为了帮助确定用哪种方法最好,我们下面写一个get_preds函数,并用其中一种方法来检索预测结果:
# 定义一个函数,用于从样本中获取预测结果
def get_preds(example, threshold=None, topk=None):preds = []# 如果指定了阈值 `threshold`,则基于阈值选择预测标签if threshold:for label, score in zip(example["predicted_labels"], example["scores"]):if score >= threshold:preds.append(label)# 如果指定了前 `topk` 个预测结果,则选择前 `topk` 个标签elif topk:for i in range(topk):preds.append(example["predicted_labels"][i])# 如果没有设置 `threshold` 或 `topk`,则抛出错误else:raise ValueError("Set either `threshold` or `topk`.")# 将预测的标签转换为多标签二进制形式并返回return {"pred_label_ids": list(np.squeeze(mlb.transform([preds])))}
下面来编写第二个函数,叫作get_clf_report(),用于从数据集中返回带有预测标注的Scikit-learn分类报告:
# 导入 classification_report 用于生成分类报告
from sklearn.metrics import classification_report# 定义函数 get_clf_report 以生成分类报告
def get_clf_report(ds):# 提取真实标签和预测标签y_true = np.array(ds["label_ids"])y_pred = np.array(ds["pred_label_ids"])# 生成并返回分类报告return classification_report(y_true, y_pred, target_names=mlb.classes_, zero_division=0, output_dict=True)
在配备好这两个函数后,我们从top-k方法开始逐步增加几个k值,绘制验证集的微观和宏观F1分数:
# 定义两个列表,用于存储不同 topk 值下的宏 F1 分数和微 F1 分数
macros, micros = [], []# 定义一个包含不同 topk 值的列表
topks = [1, 2, 3, 4]# 遍历每个 topk 值
for topk in topks:# 更新 ds_zero_shot 数据集,通过调用 get_preds 函数生成预测标签ds_zero_shot = ds_zero_shot.map(get_preds, batched=False,fn_kwargs={'topk': topk})# 生成分类报告clf_report = get_clf_report(ds_zero_shot)# 将当前 topk 值下的微 F1 分数和宏 F1 分数添加到各自的列表中micros.append(clf_report['micro avg']['f1-score'])macros.append(clf_report['macro avg']['f1-score'])
运行结果:
从曲线图表中可以看出,通过选择每个样本中得分top 1的标注,就可以获得最佳结果。也许你并不会感到惊讶,因为在我们的数据集中大多数样本都只有一个标注。我们下面与设置阈值进行比较,这样就能预测每个样本有一个以上标注的情况:
# 初始化存储宏 F1 分数和微 F1 分数的列表
macros, micros = [], []# 定义从0.01到1之间均匀分布的100个阈值
thresholds = np.linspace(0.01, 1, 100)# 对每个阈值进行循环计算
for threshold in thresholds:# 应用 get_preds 函数根据当前阈值获取预测标签ds_zero_shot = ds_zero_shot.map(get_preds, fn_kwargs={"threshold": threshold})# 获取当前数据集的分类报告clf_report = get_clf_report(ds_zero_shot)# 将当前阈值下的微 F1 分数和宏 F1 分数分别添加到对应的列表中micros.append(clf_report["micro avg"]["f1-score"])macros.append(clf_report["macro avg"]["f1-score"]) # 使用 matplotlib 绘制微 F1 分数和宏 F1 分数与阈值的关系图
plt.plot(thresholds, micros, label="Micro F1") # 绘制微 F1 分数曲线
plt.plot(thresholds, macros, label="Macro F1") # 绘制宏 F1 分数曲线# 设置 x 轴标签为 "Threshold"
plt.xlabel("Threshold")# 设置 y 轴标签为 "F1-score"
plt.ylabel("F1-score")# 显示图例,并将其放置在最佳位置
plt.legend(loc="best")# 保存图表为文件,并设置自适应大小
plt.savefig('images/threshold.png', bbox_inches='tight')# 显示绘制的图形
plt.show()
运行结果:
# 找到微 F1 分数最高的阈值及其对应的分数
best_t, best_micro = thresholds[np.argmax(micros)], np.max(micros)
print(f'Best threshold (micro): {best_t} with F1-score {best_micro:.2f}.')# 找到宏 F1 分数最高的阈值及其对应的分数
best_t, best_macro = thresholds[np.argmax(macros)], np.max(macros)
print(f'Best threshold (macro): {best_t} with F1-score {best_macro:.2f}.')
运行结果:
Best threshold (micro): 0.75 with F1-score 0.46. Best threshold (macro): 0.72 with F1-score 0.42.
虽然这种方法的效果要比top 1的结果要差一点,但是我们可以从图表中清晰地看到查准率和召回率的折中。如果阈值设置得太低,就会产生太多的预测,导致获得低查准率;如果把阈值设置得太高,就几乎不能做出预测,导致获得低召回率。图表中显示阈值在0.8左右能获得两者的最佳折中点。
由于top 1方法表现最好,我们用它来比较零样本分类和测试集上的朴素贝叶斯:
# 对测试集进行零样本分类预测
ds_zero_shot = ds['test'].map(zero_shot_pipeline)# 使用 top-k 策略预测标签
ds_zero_shot = ds_zero_shot.map(get_preds, fn_kwargs={'topk': 1})# 计算分类报告
clf_report = get_clf_report(ds_zero_shot)# 将微 F1 分数和宏 F1 分数添加到相应的字典中
for train_slice in train_slices:macro_scores['Zero Shot'].append(clf_report['macro avg']['f1-score'])micro_scores['Zero Shot'].append(clf_report['micro avg']['f1-score'])# 绘制微 F1 分数和宏 F1 分数的图表
plot_metrics(micro_scores, macro_scores, train_samples, "Zero Shot","images/train_samples.png")
运行结果:
对比零样本pipeline和基线模型,可以得出两个结论:
- 1.如果我们有少于50个标注的样本数据,那么零样本pipeline的性能就会大大超过基线模型。
- 2.即使超过50个标注,在考虑微观和宏观F1分数时,零样本pipeline的性能也很优秀。微观F1分数的结果告诉我们,基线模型在频繁出现的类上表现良好,而零样本pipeline在上述案例中表现优异,因为它不需要任何样本来学习。
你可能也注意到了本节的一个矛盾点:我们明明讨论的是处理无标注的问题,却仍然使用了验证集和测试集。其实我们只是用它们来展示不同的技术,并让它们的结果具备可比性。即便在一个真实的用例中,收集少量有标注的例子来进行快速评估也是有意义的。最重要的一点是,我们并没有使用数据来给模型调整参数,相反,我们只是调整了一些超参数。
如果你发现在自己的数据集上很难得到好的结果,下面有几点可以关注,以改善零样本pipeline:
- 你的pipeline的工作方式可能对标注的名称非常敏感。如果这些标注名称本身没有什么意义,或是很难与文本内容联系起来,那么pipeline很可能会表现不佳。解决方式是,要么使用不同的标注名,要么并行使用标注名,再在一个额外步骤中整合他们。
- 另一个可以改进的是假设的形式。默认情况下它是hypothesis="This is example is about {}",但你可以根据你的用例来向pipeline传入其他形式的文本,这样可能会提升性能。
下面我们看看如何使用少量标注样本来训练模型。
四、少样本学习
在大多数NLP项目中,你一般也会接触到有少量标注的场景。这些标注可能直接来源于客户或跨公司团队,或者你决定自己做下来标注几条数据。即使是用之前的方法,我们也需要用一些有标注的例子来评估零样本方法的效果。在本节中,我们将会看到如何最大化利用之前标注过的例子。我们首先介绍一种数据增强的技术,使用它可以成倍地增加我们的少量标注数据。
1、数据增强
在小规模数据集上提高文本分类器性能的一个简单而有效的方法是使用数据增强技术,它可以运用现有的数据产生出新的训练实例。这种方法常用于计算机视觉领域,即在不改变数据本意的情况下对图像进行无序扰动(例如将一只猫的图像旋转90度,它仍然是一只猫)。然而对文本数据来说,数据增强略显棘手,因为扰动单个词汇或者字符会完全改变其本意。例如,将“大象比老鼠重吗?”扰动成“老鼠比大象重吗?”,虽然只有两个词的位置互换,答案却相反。但是,如果文本由几个句子组成(就像GitHub的issue列表一样),这些类型的转换所引入的噪声一般不会影响标注。在实际场景中,有两类数据增强技术是常用的:
- 回译(back translation)
将原始文本中的语言使用机器翻译成一种或多种语言,然后再将其翻译回原语言。回译一般对资源丰富语言(High-Resource Language,HRL)或不包含太多特定专有名词的语料库效果最好。
- 词元扰动
J. Wei and K. Zou,“EDA: Easy Data Augmentation Techniques for Boosting Performance on Text Classification Tasks”(https://arxiv.org/abs/1901.11196),(2019).
从训练集中给定一个文本,随机选择单词并进行简单的转换,比如随机同义词替换、随机插词、交换或删除等操作 。
这些转换的方式如下图所示。其他关于NLP的数据增强技术详细介绍,建议参阅Amit Chaudhary的博文“A Visual Survey of Data Augmentation in NLP”(https://oreil.ly/j6euX)。
你可以使用M2M100(https://oreil.ly/gfJCq)这样的机器翻译模型来实现回译,而NlpAug(https://oreil.ly/UVRci)和TextAttack(https://oreil.ly/NMtYi)这样的库则提供了多种词元扰动方法。本节我们将重点探讨使用同义词进行替换,因为其实现起来较简单,并能直观体现出数据增强背后的主要思想。
下面我们使用来自NlpAug的ContextualWordEmbsAug增强器来将DistilBERT的上下文词向量替换同义词。从一个简单示例开始:
# 导入所需库和模块
from transformers import set_seed
import nlpaug.augmenter.word as naw# 设置随机种子
set_seed(3)# 初始化一个基于上下文词嵌入的词语替换增强器
aug = naw.ContextualWordEmbsAug(model_path="distilbert-base-uncased", device="cpu", action="substitute")# 定义原始文本
text = "Transformers are the most popular toys"# 打印原始文本和增强后的文本
print(f"Original text: {text}")
print(f"Augmented text: {aug.augment(text)}")
运行结果:
(注意如果没有 nlpaug,在 jupyter notebook 使用下面命令安装:!pip install nlpaug)
Original text: Transformers are the most popular toys Augmented text: ['transformers becomes the most attractive toys']
从上述结果可以看到,ContextualWordEmbsAug增强器将“are”替换成了单引号,下面用一个简单的函数来封装这个增强过程:
# 定义函数以增强文本数据
def augment_text(batch, transformations_per_example=1):text_aug, label_ids = [], []# 遍历每个文本和其标签for text, labels in zip(batch["text"], batch["label_ids"]):text_aug += [text] # 添加原始文本label_ids += [labels] # 添加对应的标签# 对每个文本进行指定次数的增强for _ in range(transformations_per_example):text_aug += [aug.augment(text)] # 添加增强后的文本label_ids += [labels] # 保持标签不变# 返回增强后的文本和标签return {"text": text_aug, "label_ids": label_ids}
现在,我们将这个函数传给map()方法,就可以用transformations_per_example参数生成任意数量的新数据。当然,我们也可以在代码中引入这个函数来训练朴素贝叶斯分类器,只需要在选择切片操作后添加下面代码即可:
# 隐藏代码
# 遍历每个训练切片
for train_slice in train_slices:# 获取训练切片和测试数据ds_train_sample = ds["train"].select(train_slice)# 对训练数据进行增强并打乱顺序ds_train_aug = (ds_train_sample.map(augment_text, batched=True, remove_columns=ds_train_sample.column_names).shuffle(seed=42))# 获取增强后的训练标签和测试标签y_train = np.array(ds_train_aug["label_ids"])y_test = np.array(ds["test"]["label_ids"])# 使用简单的词频向量器将文本编码为词语计数count_vect = CountVectorizer()X_train_counts = count_vect.fit_transform(ds_train_aug["text"])X_test_counts = count_vect.transform(ds["test"]["text"])# 创建和训练模型classifier = BinaryRelevance(classifier=MultinomialNB())classifier.fit(X_train_counts, y_train)# 生成预测并进行评估y_pred_test = classifier.predict(X_test_counts)clf_report = classification_report(y_test, y_pred_test, target_names=mlb.classes_, zero_division=0,output_dict=True)# 存储评估指标macro_scores["Naive Bayes + Aug"].append(clf_report["macro avg"]["f1-score"])micro_scores["Naive Bayes + Aug"].append(clf_report["micro avg"]["f1-score"])
重新运行分析,会生成如下图表:
# 调用 plot_metrics 函数,绘制 Naive Bayes + Aug 模型的 Micro 和 Macro F1 得分
plot_metrics(micro_scores, macro_scores, train_samples, "Naive Bayes + Aug")
运行结果:
从图表中可以看出,运用少量的数据增强就能使朴素贝叶斯分类器的F1分数提高5分左右,而且一旦有了170个左右的训练样本,它的宏观分数就会超越零样本pipeline。下面我们来看看基于大型语言模型嵌入的方法。
2、使用嵌入作为查找表
像GPT-3这样的大型语言模型,已经被证明在解决数据短缺的任务方面表现出色。原因是这些大型语言模型已经学习了大量有用的文本特征,这些特征将很多维度的信息进行编码,如情感、主题、文本结构等。所以,大型语言模型的嵌入可用于开发语义搜索引擎,检索相似的文件或评论,甚至做文本分类。
在本节中,我们将构建一个仿照OpenAI API分类接口的文本分类器(https://oreil.ly/aMgIr)。这个想法按照三步来执行:
- 1.使用语言模型来产生所有标注文本的向量。
- 2.对存储的向量进行最近邻搜索。
- 3.聚合最近邻标注来获得预测结果。
这个过程如下图所示。图中展示了有标注数据是如何被模型向量化并与标注一起存储的。当一个新的文本需要被分类时,它也被向量化处理,并根据最近邻标注给出结果。校准要搜索的近邻数量是很重要的,因为太少可能会有噪声,而太多则可能会混入相邻的群体中。
这种方法的优势在于,它不需要对模型进行微调来利用少数可用的标注数据点。相反,使这种方法发挥作用的决定性因素是选择一个适当的模型,它最好是在与你的数据集类似的领域预训练的。
由于GPT-3只能通过OpenAI的API来使用,这里我们使用GPT-2来测试上面的案例。特别说明,我们使用的是GPT-2的一个变体模型,该变体模型是使用Python代码训练的,这有望于捕捉到GitHub issue的一些上下文。
下面我们写一个辅助函数,接收传入的文本列表,并使用模型为每个文本创建一个单向量表示方式。有个问题必须要处理,像GPT-2这样的Transformer模型,实际上返回的是每个词元的一个嵌入向量。例如,给定一个句子“I took my dog for a walk”,会产生好几个嵌入向量,每个词元一个。但是我们真正想要的是整个句子的单个嵌入向量。为了处理这个问题,可以使用一种叫作汇聚(pooling,又称池化)的技术。最简单的汇聚方法之一是对词元嵌入进行均分,这种方法被称为平均汇聚(mean pooling)。使用平均汇聚,唯一需要注意的一点是,在每份中不包含填充词元,可以使用注意力掩码来解决。
为了让大家看清它是如何工作的,下面我们加载一个GPT-2的词元分析器和模型,定义平均汇聚操作,且将整个过程封装在embed_text()函数中:
# 导入 PyTorch 和 Hugging Face Transformers 库中的 AutoTokenizer 和 AutoModel
import torch
from transformers import AutoTokenizer, AutoModel# 定义模型检查点
model_ckpt = "miguelvictor/python-gpt2-large"# 加载预训练的分词器和模型
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
model = AutoModel.from_pretrained(model_ckpt)# 定义一个函数进行均值池化
def mean_pooling(model_output, attention_mask):# 提取 token 嵌入token_embeddings = model_output[0]# 计算注意力掩码input_mask_expanded = (attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float())# 对嵌入进行加和,忽略被掩码的 tokenssum_embeddings = torch.sum(token_embeddings * input_mask_expanded, 1)sum_mask = torch.clamp(input_mask_expanded.sum(1), min=1e-9)# 返回作为单个向量的平均值return sum_embeddings / sum_mask# 定义一个函数对文本进行嵌入
def embed_text(examples):inputs = tokenizer(examples["text"], padding=True, truncation=True,max_length=128, return_tensors="pt")with torch.no_grad():model_output = model(**inputs)pooled_embeds = mean_pooling(model_output, inputs["attention_mask"])return {"embedding": pooled_embeds.cpu().numpy()}
运行结果:
请注意,GPT风格的模型没有填充词元,因此需要手动添加一个,然后才能像之前那样分批地获得嵌入。为此,我们回收字符串末尾的词元:
# 设置 tokenizer 的填充 token 为 eos token
tokenizer.pad_token = tokenizer.eos_token# 对训练集进行文本嵌入
embs_train = ds["train"].map(embed_text, batched=True, batch_size=16)# 对验证集进行文本嵌入
embs_valid = ds["valid"].map(embed_text, batched=True, batch_size=16)# 对测试集进行文本嵌入
embs_test = ds["test"].map(embed_text, batched=True, batch_size=16)
运行结果:
J. Johnson, M. Douze, and H. Jégou, “Billion-Scale Similarity Search with GPUs”(https://arxiv.org/abs/1702.08734), (2017).
现在我们已经有了所有的嵌入,需要建设一个系统来查询它们。也可以写一个相关函数,例如,我们要查询一个新的嵌入与训练集中现有的嵌入之间的余弦相似度。另外,我们也可以使用Hugging Face Dataset的一个内置结构——FAISS索引 ,我们已经在第7章中见到过FAISS。你可以把它当作嵌入的搜索引擎,稍后我们会介绍它是如何工作的。可以通过add_faiss_index()来使用数据集的一个现有字段来创建一个FAISS索引,或者可以通过add_faiss_index_from_external_arrays()将新的嵌入数据加载到数据集。下面我们使用之前的一个函数将嵌入添加到数据集中,如下所示:
# 为训练集添加 FAISS 索引,以便快速进行最近邻搜索
embs_train.add_faiss_index("embedding")
运行结果:
Dataset({features: ['text', 'labels', 'label_ids', 'embedding'],num_rows: 223 })
以上代码创建了一个叫作“嵌入”的FAISS索引。我们可以通过调用get_nearest_examples()函数来做近邻搜索操作。它返回近邻以及每个近邻匹配的分数。我们需要指定要查询的嵌入和要检索的近邻数量。让我们看看与一个案例最匹配的文档:
# 选择第一个查询示例和3个最近邻
i, k = 0, 3
# 用于在紧凑显示中移除文本中的换行符
rn, nl = "\r\n\r\n", "\n"# 获取验证集中第 i 个示例的嵌入
query = np.array(embs_valid[i]["embedding"], dtype=np.float32)
# 使用 FAISS 索引从训练集中查找 k 个最近邻样本及其相似度分数
scores, samples = embs_train.get_nearest_examples("embedding", query, k=k)# 输出查询示例的标签和文本(文本仅显示前 200 个字符,并替换换行符)
print(f"QUERY LABELS: {embs_valid[i]['labels']}")
print(f"QUERY TEXT:\n{embs_valid[i]['text'][:200].replace(rn, nl)} [...]\n")
print("="*50)# 输出检索到的文档
print(f"Retrieved documents:")
for score, label, text in zip(scores, samples["labels"], samples["text"]):print("="*50)print(f"TEXT:\n{text[:200].replace(rn, nl)} [...]") # 显示前 200 个字符,并替换换行符print(f"SCORE: {score:.2f}") # 显示相似度分数print(f"LABELS: {label}") # 显示标签
运行解雇:
QUERY LABELS: ['new model'] QUERY TEXT: Implementing efficient self attention in T5# 🌟 New model addition My teammates and I (including @ice-americano) would like to use efficient self attention methods such as Linformer, Performer and [...]================================================== Retrieved documents: ================================================== TEXT: Add Linformer model# 🌟 New model addition ## Model description ### Linformer: Self-Attention with Linear Complexity Paper published June 9th on ArXiv: https://arxiv.org/abs/2006.04768 La [...] SCORE: 54.92 LABELS: ['new model'] ================================================== TEXT: Add FAVOR+ / Performer attention# 🌟 FAVOR+ / Performer attention addition Are there any plans to add this new attention approximation block to Transformers library? ## Model description The n [...] SCORE: 57.90 LABELS: ['new model'] ================================================== TEXT: Implement DeLighT: Very Deep and Light-weight Transformers# 🌟 New model addition ## Model description DeLight, that delivers similar or better performance than transformer-based models with sign [...] SCORE: 60.12 LABELS: ['new model']
以上结果正是我们所需要的:我们通过嵌入找到的三个文档都有相同的标注,从标题中也可以看出它们的意思非常接近。查询和检索到的文档都有赖于增加新的、高效的Transformer模型。然而,问题依旧存在,k的最佳值是什么?同样地,我们应该如何聚合检索到的文档的标注?例如,我们是否应该检索三个文档,并分配所有至少出现过两次的标注?或者应该检索20个文档,使用至少出现5次的标注?我们来系统性地研究一下这个问题:尝试使用几个k值,然后用辅助函数改变标注分配的阈值,使m<k,然后记录下每个k值对应的宏观和微观性能,这样就可以得到最佳结果。我们可以给函数get_nearest_examples_batch()传入一批查询,而不是在验证集中的每个样本上循环:
import numpy as np
from sklearn.metrics import classification_report
# 假设mlb是一个LabelBinarizer的实例,用于将标签转换为二进制形式
# mlb = LabelBinarizer(classes=your_classes) # 这里只是假设,实际应该在代码的其他部分初始化 # 根据给定的样本和阈值m,计算预测标签
def get_sample_preds(sample, m): """ 根据给定的样本标签和阈值m,计算预测标签。 如果样本中"label_ids"的和小于等于m,则预测为0,否则为1。 参数: - sample: 字典,包含键"label_ids",其值为numpy数组或类似结构。 - m: 整数,阈值。 返回: - numpy数组,预测标签。 """ return (np.sum(sample["label_ids"], axis=0) >= m).astype(int) # 查找最佳的k和m参数,以最大化性能
def find_best_k_m(ds_train, valid_queries, valid_labels, max_k=17): """ 在给定的最大k值范围内,遍历不同的k和m值,以找到最佳的k和m组合,用于基于最近邻的预测。 参数: - ds_train: 数据集对象,支持获取最近邻样本的方法。 - valid_queries: 用于查找最近邻的查询向量。 - valid_labels: 验证集的真实标签。 - max_k: 最大的k值,即最多考虑的最近邻数量。 返回: - perf_micro: numpy数组,存储了不同k和m组合下的micro F1分数。 - perf_macro: numpy数组,存储了不同k和m组合下的macro F1分数。 """ max_k = min(len(ds_train), max_k) # 确保max_k不超过数据集大小 perf_micro = np.zeros((max_k, max_k)) # 初始化存储micro F1分数的数组 perf_macro = np.zeros((max_k, max_k)) # 初始化存储macro F1分数的数组 # 遍历不同的k和m值 for k in range(1, max_k): for m in range(1, k + 1): # 获取给定查询的k个最近邻样本 _, samples = ds_train.get_nearest_examples_batch("embedding", valid_queries, k=k) # 对每个样本使用get_sample_preds函数计算预测标签 y_pred = np.array([get_sample_preds(s, m) for s in samples]) # 计算并存储分类报告中的F1分数 clf_report = classification_report(valid_labels, y_pred, target_names=mlb.classes_, zero_division=0, output_dict=True) perf_micro[k, m] = clf_report["micro avg"]["f1-score"] perf_macro[k, m] = clf_report["macro avg"]["f1-score"] return perf_micro, perf_macro # 注意:确保在调用find_best_k_m函数之前,mlb(LabelBinarizer实例)已被正确初始化并包含了所有可能的类别。
下面我们检查一下所有训练样本的最佳值是多少,并将所有配置的k值和m值的分数可视化展示:
import numpy as np
# 假设embs_valid是一个包含验证集数据的字典,其中"label_ids"是标签ID的列表,"embedding"是嵌入向量的列表 # 将验证集的标签ID转换为numpy数组
valid_labels = np.array(embs_valid["label_ids"])
# 注释:这一步将验证集的标签ID(可能是列表或其他可迭代对象)转换为numpy数组,以便后续处理。 # 将验证集的嵌入向量转换为numpy数组,并指定数据类型为np.float32
valid_queries = np.array(embs_valid["embedding"], dtype=np.float32)
# 注释:这一步将验证集的嵌入向量(可能是列表或其他可迭代对象,其中每个元素都是一个向量)转换为numpy数组,
# 并确保所有嵌入向量的数据类型都是np.float32。这些嵌入向量将作为查询向量来查找最近邻。 # 调用find_best_k_m函数来找到最佳的k和m值,使用训练集的嵌入向量和验证集的查询向量及标签
perf_micro, perf_macro = find_best_k_m(embs_train, valid_queries, valid_labels)
# 注释:这一步调用find_best_k_m函数,传入训练集的嵌入向量(embs_train,尽管这里没有直接显示其定义,但我们可以假设它是类似的字典结构)、
# 验证集的查询向量(valid_queries)和验证集的标签(valid_labels)。函数将遍历不同的k和m值,
# 计算每种组合下的micro和macro F1分数,并将结果存储在perf_micro和perf_macro中。 # 注意:这里的embs_train也应该是一个包含训练集数据的字典,具有与embs_valid相似的结构,
# 即包含"embedding"键和"label_ids"(虽然在这个特定函数中可能未直接使用"label_ids")等。
# 但是,由于find_best_k_m函数只使用了训练集的嵌入向量来查找最近邻,因此不需要显式传递训练集的标签。
import matplotlib.pyplot as plt # 确保已经导入了matplotlib.pyplot模块 # 创建一个包含1行2列的子图,设置图形大小为10x3.5英寸,并共享y轴
fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(10, 3.5), sharey=True) # 在第一个子图(ax0)上显示micro F1分数的热力图
ax0.imshow(perf_micro)
# 设置第一个子图的标题为"micro scores"
ax0.set_title("micro scores")
# 由于我们共享了y轴,这里只设置第一个子图的y轴标签为"k"
ax0.set_ylabel("k") # 在第二个子图(ax1)上显示macro F1分数的热力图
ax1.imshow(perf_macro)
# 设置第二个子图的标题为"macro scores"
ax1.set_title("macro scores") # 遍历两个子图,设置它们的x轴和y轴的显示范围
for ax in [ax0, ax1]: # 设置x轴的显示范围为从0.5到17-0.5(因为索引从0开始,但显示时我们希望从1开始) ax.set_xlim([0.5, 17 - 0.5]) # 设置y轴的显示范围为从17-0.5到0.5(因为我们要翻转y轴以匹配常见的k值从上到下的表示) ax.set_ylim([17 - 0.5, 0.5]) # 设置x轴的标签为"m" ax.set_xlabel("m") # 保存图表为文件,并设置自适应大小
plt.savefig('images/macro_scores.png', bbox_inches='tight')# 显示图形
plt.show() # 注意:
# 1. 这里假设perf_micro和perf_macro是两个二维numpy数组,它们分别存储了不同k和m组合下的micro和macro F1分数。
# 2. 由于imshow默认将数组的最小值显示为蓝色,最大值显示为红色(或根据colormap的不同而有所变化),
# 因此热力图的颜色变化反映了F1分数的变化。
# 3. 设置y轴的显示范围时,我们使用了翻转的数值范围(从大到小),这是为了在视觉上更符合我们通常对k值(最近邻数量)从上到下减少的直觉。
# 然而,这取决于您希望如何展示数据,有时保留默认的y轴方向(从小到大)可能更为直观。
运行结果:
从图中可以看出一个模式:对于给定的k值,m值过大或者过小都会产生次优结果。当选择符合m/k=1/3的比例时,我们可以得到最佳的性能。下面我们看看是哪个k与m可以产生最优结果:
# 使用np.unravel_index函数找到perf_micro数组中最大值的索引
# perf_micro.argmax() 返回数组中最大值的扁平索引(即不考虑多维数组的形状)
# perf_micro.shape 返回perf_micro数组的形状(例如,(n, m)),这是一个元组
# np.unravel_index将扁平索引转换为与perf_micro形状相对应的多维索引
k, m = np.unravel_index(perf_micro.argmax(), perf_micro.shape) # 打印出找到的最佳k和m值
# 这里假设perf_micro是一个二维数组,其中每个元素代表在特定k和m组合下的micro F1分数
# argmax()找到最大分数的索引,unravel_index()将这个索引转换回二维索引(k, m)
print(f"Best k: {k}, best m: {m}") # 注释解释:
# 1. perf_micro.argmax():这个函数返回perf_micro数组中最大值的索引。由于perf_micro是二维的,
# 返回的索引是扁平的(即不考虑数组的形状),它表示如果我们将perf_micro视为一维数组,最大值的位置在哪里。
# 2. np.unravel_index(...):这个函数接受一个扁平索引和一个形状(在这个例子中是perf_micro.shape),
# 并返回与这个形状相对应的多维索引。这对于理解在多维数组中哪个位置(在这个例子中是k和m的值)
# 具有最大值非常有用。
# 3. 打印语句:它使用f-string(格式化字符串字面量)来显示找到的最佳k和m值。
# 这些值表示在perf_micro数组中,哪个k和m的组合给出了最高的micro F1分数。
运行结果:
Best k: 15, best m: 5
当选择k=15与m=5时,或者换句话说,当我们检索15个最近邻,然后分配至少出现5次的标注时,效果最优。现在我们得到了一个好办法来得出嵌入查找的最优解,可以玩一个和贝叶斯分类器一样的游戏,即通过训练集的切片来评估性能。在对数据集进行切片之前,首先需要删除索引,因为我们不能像对数据集那样对FAISS索引做切片。代码中循环的其余部分保持不变,只增加使用验证集获取最佳k值和m值的逻辑:
# 检查数据集中的所有索引
print("Available indexes:", embs_train.list_indexes())# 移除所有现有索引
for index in embs_train.list_indexes():embs_train.drop_index(index)# 加载测试集的标签ID和嵌入向量
test_labels = np.array(embs_test["label_ids"]) # 将测试集的标签ID转换为numpy数组
test_queries = np.array(embs_test["embedding"], dtype=np.float32) # 将测试集的嵌入向量转换为numpy数组,并指定数据类型# 初始化变量以存储切片的数量
train_samples = []# 遍历训练集的切片
for i, train_slice in enumerate(train_slices):# 从训练集中选择一部分数据embs_train_tmp = embs_train.select(train_slice)# 为这部分数据的"embedding"列重新添加FAISS索引embs_train_tmp.add_faiss_index("embedding")# 使用验证集找到最佳的k和m值perf_micro, _ = find_best_k_m(embs_train_tmp, valid_queries, valid_labels) # 假设这个函数返回micro分数和可能的其他信息,但这里我们只关心micro分数k, m = np.unravel_index(perf_micro.argmax(), perf_micro.shape) # 找到perf_micro中最大值的索引,并转换为多维索引(k, m)# 在测试集上获取预测_, samples = embs_train_tmp.get_nearest_examples_batch("embedding", test_queries, k=int(k)) # 获取测试查询的k个最近邻样本y_pred = np.array([get_sample_preds(s, m) for s in samples]) # 对每个样本的最近邻使用某种预测逻辑(假设由get_sample_preds实现)# 评估预测clf_report = classification_report(test_labels, y_pred, target_names=mlb.classes_, zero_division=0, output_dict=True) # 生成分类报告# 将切片的大小添加到train_samples中train_samples.append(len(train_slice))# 将micro和macro F1分数添加到相应的列表中macro_scores["Embedding"].append(clf_report["macro avg"]["f1-score"])micro_scores["Embedding"].append(clf_report["micro avg"]["f1-score"])# 检查并打印当前长度print(f"Iteration {i}:")print(f"train_samples length: {len(train_samples)}")print(f"micro_scores['Embedding'] length: {len(micro_scores['Embedding'])}")print(f"macro_scores['Embedding'] length: {len(macro_scores['Embedding'])}")print(f"train_samples: {train_samples}")print(f"micro_scores['Embedding']: {micro_scores['Embedding']}")print(f"macro_scores['Embedding']: {macro_scores['Embedding']}")print(f"Length of train_samples: {len(train_samples)}")
print(f"Length of micro_scores['Embedding']: {len(micro_scores['Embedding'])}") # 检查最终的列表长度是否一致
assert len(train_samples) == len(micro_scores["Embedding"]), "train_samples 和 micro_scores['Embedding'] 长度不一致"
assert len(train_samples) == len(macro_scores["Embedding"]), "train_samples 和 macro_scores['Embedding'] 长度不一致"# 注意:上述代码段假设了许多自定义函数和方法的存在(如select, add_faiss_index, get_nearest_examples_batch, get_sample_preds等),
# 这些在标准Python库或NumPy/SciPy中并不存在,因此它们可能是项目特定的或来自第三方库。# 绘制微 F1 分数和宏 F1 分数的图表
plot_metrics(micro_scores, macro_scores, train_samples, "Embedding", "images/Embedding.png")
运行结果:
嵌入查找法在微观分数上与以前的方式效果相差不大,同时只有两个“可学习”的参数,即m与k,但在宏观分数上的表现略差。
我们需要对这些结果保持严谨的态度,哪种方法最有效很大程度上取决于需求领域。零样本pipeline的训练数据与本章使用的GitHub issue数据集区别较大,后者包含了以前的模型没有遇到过的大量代码片段。对于一些常见的任务,例如情感分析,该pipeline可能会获得较好结果。同样地,嵌入的质量也取决于模型以及它所训练的数据。我们尝试了好几种模型,比如sentence-transformers/stsb-roberta-large模型,它被训练来提供高质量的语句嵌入,还有如microsoft/codebert-base和dbernsohn/roberta-python模型,它们被应用于代码与文档类训练任务。对于本节这个特定的案例,在Python代码上训练的GPT-2效果最优。
因为除了替换模型的checkpoint名称来测试另一个模型之外,你并不需要修改你的代码。一旦建立了用于评估的pipeline,就可以快速试用一些模型。
下面我们把这个简单的嵌入技巧结合有限的数据来微调一个Transformer模型用以做比较。
用FAISS做高效的相似度搜索
我们在本书中首次接触FAISS是在第7章,当时我们用它通过DPR嵌入来检索文档。这里我们会简单介绍下FAISS库是如何工作的,以及它为什么会是ML领域的一个强大的工具。
我们在日常早已习惯了在巨大的数据集上进行查询操作,比如使用维基百科或者互联网搜索引擎,如Google等。当我们把文本转换为嵌入时,当然也希望保持不错的性能。然而,使文本查询加速的方法并不适用于嵌入。
业界为了快速检索文本,通常采用倒排索引的方式来进行,使用属性映射文档。倒排索引的工作方式类似书籍末尾的索引:将关键词或其他内容映射到具体的页码(或者这里的文档)。当需要执行查询操作的时候,我们可以快速查找关键词出现在哪些文档中。这种方法对于那种离散的对象(例如单词)很有效,但是对于连续的对象(例如向量)则作用不大。每个文档可能都有一个独有的向量,因此索引永远不会与新的向量匹配。大多数时候,我们并不需要那种精确的匹配,近似足矣。
当我们想从数据库中找到与目标向量最相似的向量时,理论上需要将目标向量与数据库中的每个向量进行比较。对于本章中的小型数据库是没有问题的,但是如果将范围扩大到上百万条向量查询,所带来的延迟就无法预估了,通常需要等待相当长的一段时间才能找到结果。
FAISS通过一些技巧来解决这个问题,其主要的思想是对数据集进行分区。如果只需要将被查询向量与整个向量数据库的一个子集进行比较,速度就能大大提升。但是,如果只是随机对数据集进行分区,那么如何来确定应该搜索哪个分区呢?以及如何保证找到的向量是最相似的呢?万幸的是,FAISS有一个不错的解决方案:对数据集使用k均值聚类算法(k-means clustering),效果是可以根据相似度将嵌入分组。此外,对于每个分组,我们将得到一个质心向量(centroid vector),它是该组所有成员的平均值,如下图所示。
基于这样的分组思想,在n个向量中执行搜索操作就容易多了:首先在k个质心向量中搜索与query向量最相似的那个质心向量,然后在该质心向量所在的组中进行搜索。这样就把需要搜索的向量从n个减少到了k+n/k个。但看这个表达式,可能你会提出一个问题:如何确定最佳的k值?如果k值太小,那么在进行组内查找的时候,仍然会有很多个向量需要去做比较;而如果k值太大,那么需要与query向量做比较的质心向量又太多。这个问题可以看作寻找函数f(k)=k+n/k的k的最小值问题,简单做一下数学运算。于是就可以用下面的函数坐标曲线来直观地解答这个问题,。
图中可以清晰看出搜索中要做的比较次数随着聚类数量的变化趋势,我们期望做最少的比较次数,即求这个函数的最小值,最小值在=1024处。
除了用分区的思想来加快查询速度,FAISS还可以使用GPU来进一步加快查询速度。如果在查询过程中,内存成为瓶颈,则FAISS还提供几种业界前沿的量化方案来压缩向量。如果你想在你的项目中使用FAISS,它的仓库有个简单的指南(https://oreil.ly/QmvzR),你可以根据你的用例选择正确的方式。
业界基于FAISS构建的最大的项目之一是Facebook的CCMatrix语料库(https://oreil.ly/ennlr),该项目作者使用多语言嵌入来寻找不同语言的排比句,这个巨大的语料库随后被用来训练M2M100(https://oreil.ly/XzSH9)模型,该模型是一个大型的机器翻译模型,能够进行100种语言的直接翻译。
3、对Transformer做微调
如果有机会获得有标注数据,我们也可以尝试做一些显而易见的事情:简单地微调一个Transformer预训练模型。在介绍中,我们将使用标准的BERT checkpoint作为起点,随后,大家将看到对语言模型做微调是怎样影响性能的。
对许多应用场景来说,从预训练的BERT模型开始构建目标模型是一个好想法。然而,如果你的语料库所属领域与预训练模型的原始语料库(通常是基于维基百科的语料库)差异比较大,那么你应该在Hugging Face Hub上查找更适合你的预训练模型,很可能有人已经在你的领域分享了预训练模型。
下面从加载预训练模型的词元分析器开始,来对我们的数据集进行词元化,并删掉在训练和评估当中并不需要的列:
import torch
from transformers import AutoTokenizer, AutoConfig, AutoModelForSequenceClassification# 指定预训练模型检查点
model_ckpt = "bert-base-uncased"# 加载预训练的分词器
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)# 定义分词函数
def tokenize(batch):"""对输入批次进行分词和编码。参数:batch (dict): 包含文本数据的字典。返回:dict: 包含编码后的输入ID、注意力掩码等信息的字典。"""return tokenizer(batch["text"], truncation=True, max_length=128)# 对数据集进行分词和编码
ds_enc = ds.map(tokenize, batched=True)# 移除不需要的列,保留编码后的数据
ds_enc = ds_enc.remove_columns(['labels', 'text'])
运行结果:
通常多标注损失函数希望标注是浮点类型,因为它也支持类概率(class probabilities),而不是离散的标注,因此,我们需要更改label_ids列的类型。由于更改列元素的格式与Arrow的类型格式不匹配,因此这里我们会做一个小小变通。首先,创建一个带有标注的新列,该列的格式是由第一个元素推断出来的。然后,删除原来的列,将新列重命名为原列名:
# 设置数据集的格式为PyTorch张量
ds_enc.set_format("torch")# 将标签ID转换为浮点数,并移除原始标签列
ds_enc = ds_enc.map(lambda x: {"label_ids_f": x["label_ids"].to(torch.float)},remove_columns=["label_ids"])# 重命名新的标签列
ds_enc = ds_enc.rename_column("label_ids_f", "label_ids")
运行结果:
由于训练数据的规模有限,因此很可能会快速发生过拟合,所以这里设置load_best_model_at_end=True,再根据微观F1分数选择最佳模型:
from transformers import Trainer, TrainingArguments# 设置训练参数
training_args_fine_tune = TrainingArguments(output_dir="./results", # 保存结果的目录num_train_epochs=20, # 训练的总轮数learning_rate=3e-5, # 学习率lr_scheduler_type='constant', # 学习率调度器类型(常数学习率)per_device_train_batch_size=4, # 每个设备(如GPU)上的训练批次大小per_device_eval_batch_size=32, # 每个设备(如GPU)上的评估批次大小weight_decay=0.0, # 权重衰减(正则化)evaluation_strategy="epoch", # 评估策略(每个epoch评估一次)save_strategy="epoch", # 保存策略(每个epoch保存一次)logging_strategy="epoch", # 日志记录策略(每个epoch记录一次)load_best_model_at_end=True, # 在训练结束时加载最佳模型metric_for_best_model='micro f1', # 用于选择最佳模型的指标save_total_limit=1, # 保存的总检查点数量限制log_level='error' # 日志级别(只记录错误信息)
)
由于我们使用F1分数来选择最佳模型,因此需要确保在模型评估期间就要将其计算出来。因为模型返回的是logit,首先需要使用sigmoid函数对预测结果进行规范化,然后设置一个阈值再将它们转化为二进制形式。随后从分类报告中获取F1分数:
from scipy.special import expit as sigmoid
from sklearn.metrics import classification_reportdef compute_metrics(pred):# 获取真实标签y_true = pred.label_ids# 对预测结果应用sigmoid函数,将其转换为概率值y_pred = sigmoid(pred.predictions)# 将概率值转换为二进制标签(大于0.5的为1,小于等于0.5的为0)y_pred = (y_pred > 0.5).astype(float)# 生成分类报告,包含微平均和宏平均的F1分数clf_dict = classification_report(y_true, y_pred, target_names=all_labels,zero_division=0, output_dict=True)# 返回一个字典,包含微平均和宏平均的F1分数return {"micro f1": clf_dict["micro avg"]["f1-score"],"macro f1": clf_dict["macro avg"]["f1-score"]}
到这里,准备工作已经就绪。对于每个训练集分片,我们从头开始训练分类器,在循环结束时加载最佳模型,并将结果存储在测试集上:
# 从预训练模型加载配置
config = AutoConfig.from_pretrained(model_ckpt)# 设置模型输出的类别数目为所有标签的数量
config.num_labels = len(all_labels)# 设置问题类型为多标签分类
config.problem_type = "multi_label_classification"# 对每个训练切片执行模型微调和评估
for train_slice in train_slices:# 从预训练模型加载适用于多标签分类任务的模型model = AutoModelForSequenceClassification.from_pretrained(model_ckpt, config=config)# 创建一个Trainer实例,用于训练和评估模型trainer = Trainer(model=model, # 加载的模型tokenizer=tokenizer, # 使用的分词器args=training_args_fine_tune, # 训练参数compute_metrics=compute_metrics, # 自定义评估指标函数train_dataset=ds_enc["train"].select(train_slice), # 训练数据集eval_dataset=ds_enc["valid"], # 验证数据集)# 开始训练trainer.train()# 对测试集进行预测并计算评估指标pred = trainer.predict(ds_enc["test"])metrics = compute_metrics(pred)# 将微平均和宏平均F1分数添加到相应的列表中macro_scores["Fine-tune (vanilla)"].append(metrics["macro f1"])micro_scores["Fine-tune (vanilla)"].append(metrics["micro f1"])# 绘制微 F1 分数和宏 F1 分数的图表
plot_metrics(micro_scores, macro_scores, train_samples, "Fine-tune (vanilla)","images/Fine_tune.png")
运行结果:
从结果中可以看出,当案例数量达到大约64个时,在数据集上简单地微调一个vanilla BERT模型就能获得有竞争力的结果(灰色实线)。此外,还能看出在案例数量少于64个时,模型效果就不太稳定,因为在小样本上训练模型容易过拟合,语料中的标注可能个体差异性较大。在使用数据集的无标注部分之前,让我们看看另一种具备前景的方法,即在少样本领域中使用语言模型。
4、基于提示的上下文学习和少样本学习
本章前段内容介绍过,可以使用像BERT或GPT-2这样的语言模型,通过提示与解析模型的词元预测来使其适应监督任务。这与添加一个特定任务头,并为任务调整模型参数的经典方法不同。从好的方面来说,这种方法不需要任何训练数据;但从坏的方面来说,如果能够获得标注数据,则无法利用它们。但存在一个折中方法,即上下文学习(in-context learning)或少样本学习(few-shot learning)。
为了厘清这个概念,我们考虑使用一个把英语翻译成法语的案例来讲解。在零样本的范式下,我们将构建一个提示,像如下这样:
prompt = """\
Translate English to French:
thanks =>
"""
T. Brown et al., “Language Models Are Few-Shot Learners”(https://arxiv.org/abs/2005.14165),(2020).
这有望提示模型预测出“merci”这个词的词元。我们在第6章使用GPT-2进行总结时已经知道,在文本中加入“TL;DR”会促使模型生成一个总结,并不需要做明确的训练工作。在GPT-3的论文中提到一个有趣的事情,大型模型能从提示中进行有效的学习——因此,前面的翻译案例可以使用英译德的例子来增强,这将使模型在这种任务中表现得更好 。
此外,笔者还发现,模型的规模越大,就越善于应用上下文中的例子,这促使性能的大幅提升。虽然GPT-3模型的规模在实际落地中具备一定挑战性,但这也是个已有许多很酷的应用的新兴研究领域。例如一个关于自然语言处理的shell项目,它使用常规描述语言输入,并由GPT-3解析为shell命令,这将为那些shell初学者带来福音。
D. Tam et al., “Improving and Simplifying Pattern Exploiting Training”(https://arxiv.org/abs/2103.11955),(2021).
T. Le Scao and A.M. Rush,“How Many Data Points Is a Prompt Worth?”(https://arxiv.org/abs/2103.08493),(2021).
使用标注数据的另一种方法是创建提示和将被预测的例子,并在这些例子上持续训练语言模型。一种叫ADAPET的方法就使用了这种思想,它在各方面都优于GPT-3 ,它使用自动生成的提示来调整模型。Hugging Face的最新研究结果表明,这样的方法比微调一个自定义的头更具数据效率(data-efficient) 。
附录
一、当前案例环境 package 的 版本如下
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
coloredlogs 15.0.1
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
faiss-cpu 1.8.0.post1
fastjsonschema 2.20.0
filelock 3.15.4
flatbuffers 24.3.25
fonttools 4.53.1
fqdn 1.5.1
frozenlist 1.4.1
fsspec 2024.5.0
gdown 5.2.0
greenlet 3.0.3
h11 0.14.0
httpcore 1.0.5
httpx 0.27.0
huggingface-hub 0.23.4
humanfriendly 10.0
idna 3.7
intel-openmp 2021.4.0
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
mkl 2021.4.0
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
nlpaug 1.1.11
notebook 7.2.1
notebook_shim 0.2.4
numpy 1.26.4
onnx 1.16.1
onnxruntime 1.18.1
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
protobuf 5.27.2
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
pyreadline3 3.4.1
PySocks 1.7.1
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
scikit-multilearn 0.2.0
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
tbb 2021.13.0
terminado 0.18.1
threadpoolctl 3.5.0
tinycss2 1.3.0
tokenizers 0.13.3
torch 2.3.1+cu121
torchaudio 2.3.1+cu121
torchvision 0.18.1+cu121
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