《Hands-On Large Language Models》阅读笔记(十)
第三部分: 训练和微调语言模型 - 微调嵌入模型
仍然是第 10 章的部分,会学习到微调嵌入模型,监督学习(Supervised Learning)和无监督学习(Unsupervised Learning)的内容。 前面学习的其实也是在一个基础模型的基础上创建一个嵌入模型。
本书把以 bert-base-uncased 作为基础模型创建嵌入模型叫做从头开始训练嵌入模型,有点不理解。bert-base-uncased 本身就是一个预训练的基础模型,
它的训练语料是 BookCorpus + English Wikipedia, 共约 33 亿词。而所谓的微调嵌入模型就是把 bert-base-uncased 换为另一个预训练的
sentence-transformers 模型。这与前面的训练过程又有何区别呢?
因为 bert-base-uncased 就可以直接用来对句子生成嵌入向量,比如下面的代码可得到句子的嵌入向量
1from sentence_transformers import SentenceTransformer
2
3model = SentenceTransformer('bert-base-uncased', device="cuda")
4embeddings = model.encode(["The weather is nice today"])
所以本章的微调嵌入模型时所谓的监督学习不过是把前面的基本模型换成了另一个专门用对比学习预训练过的 all-MiniLM-L6-v2 模型,其他代码完全就没变。
选基础模型的时候还得了解它是怎么训练的,训练语料是什么样的,训练目标是什么样的,这样才能知道它适不适合用来微调嵌入模型。
使用了有标签的数据集进行训练就是有监督的学习, 下面是书中的例子
1from datasets import load_dataset
2from sentence_transformers import SentenceTransformer
3from sentence_transformers.sentence_transformer import losses
4from sentence_transformers.sentence_transformer.evaluation import EmbeddingSimilarityEvaluator
5from sentence_transformers.sentence_transformer.training_args import SentenceTransformerTrainingArguments
6from sentence_transformers.sentence_transformer.trainer import SentenceTransformerTrainer
7
8# 加载训练数据
9train_dataset = load_dataset(
10 "nyu-mll/glue", "mnli", split="train"
11).select(range(50_000)).remove_columns("idx")
12
13# 选择基座模型
14embedding_model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2', device="cuda")
15
16# 定义损失函数
17train_loss = losses.MultipleNegativesRankingLoss(model=embedding_model)
18
19# 定义评估器,使用语义文本相似度基准(Semantic Textual Similarity Benchmark, STSB)
20# 这是一个由人工标注的句子对数据集,相似度分数在 1 ~ 5 之间
21val_sts = load_dataset("nyu-mll/glue", "stsb", split="validation")
22evaluator = EmbeddingSimilarityEvaluator(
23 sentences1=val_sts["sentence1"],
24 sentences2=val_sts["sentence2"],
25 scores=[score/5 for score in val_sts["label"]], # 值转换为 0~1 之间
26 main_similarity="cosine"
27)
28
29# 定义训练参数
30args = SentenceTransformerTrainingArguments(
31 output_dir="finetuned_embedding_model",
32 num_train_epochs=1,
33 per_device_train_batch_size=32,
34 per_device_eval_batch_size=32,
35 warmup_steps=100,
36 fp16=True,
37 eval_steps=100,
38 logging_steps=100
39)
40
41# 训练模型
42trainer = SentenceTransformerTrainer(
43 model=embedding_model,
44 args=args,
45 train_dataset=train_dataset,
46 loss=train_loss,
47 evaluator=evaluator
48)
49
50trainer.train()
51print(evaluator(embedding_model))
52embedding_model.save("finetuned_embedding_model")
但是不太明白这个训练过程,首先选的损失函数是 MultipleNegativesRankingLoss,是一个多负例损失函数,它假定训练数据集的三个列依次为
anchor, positive 和 negative,而这里的 train_dataset 的结构是三列分别为 premise, hypothesis 和 label. 只可能是
MultipleNegativesRankingLoss 损失函数把 train_dataset 数据集作了如下映射
premise->anchorhypothesis->positive
把 hypothesis 当作 premise 的正例还说得过去,训练时只用了前两列作为全正例数据,而 label 列应该是被忽略了的。
但是训练后 evaluator(embedding_model) 的结果却是 {'pearson_cosine': 0.8490628613847147, 'spearman_cosine': 0.8485285321978133},
还是比较高。
增强型 SBERT(Augmented SBERT)
训练或微调这些嵌入模型的一个挑战是需要大量的训练数据,尤其是正例和负例对,比如要用超过十亿个句子对训练。增强型 SBERT 可以让我们在只有少量标注数据( 比如几千个标注数据) 的情况下也能微调嵌入模型。增强型 SBERT 是利用速度较慢但更精确的交叉编码器架构(BERT) 来增强和标注更大的输入对集合, 这些新标注的数据对随后被用于微调双编码器(SBERT), 又要回顾一下什么是交叉编码器(cross-encoder)和双编码器了, 交叉编码器是把两个输入句子拼接在一起输入到模型中进行处理,而双编码器则是分别对两个输入句子进行编码,然后计算它们的相似度。


左边是交叉编码器架构,右边是双编码器架构, 双编码器架构计算效率更高,因为它可以预先计算和存储句子的嵌入向量,而交叉编码器需要在每次比较时重新计算两个句子的表示。
增强型 SBERT 包含以下步骤:
- 使用小型标注数据集(黄金数据集) 微调交叉编码器(BERT)
- 创建新的句子对(说到句子对就是对比学习)
- 使用微调过的交叉编码器标注新的句子对(白银数据集)
- 在扩展数据集(黄金数据集 + 白银数据集) 上训练双编码器(SBERT)
黄金数据集是一个规模较小但完全标注的数据集,包含真实标注。白银数据集也是完全标注的,但不一定是真实标注,因为它是通过交叉编码器的预测生成的。
明白了上面的工作原理后,我们开始实施
1import numpy as np
2import pandas as pd
3from datasets import load_dataset, Dataset
4from sentence_transformers import SentenceTransformer, InputExample
5from sentence_transformers.sentence_transformer import losses
6from sentence_transformers.cross_encoder import CrossEncoder
7from sentence_transformers.sentence_transformer.datasets import NoDuplicatesDataLoader
8from sentence_transformers.sentence_transformer.evaluation import EmbeddingSimilarityEvaluator
9from sentence_transformers.sentence_transformer.training_args import SentenceTransformerTrainingArguments
10from sentence_transformers.sentence_transformer.trainer import SentenceTransformerTrainer
11
12# 加载训练数据
13train_dataset = load_dataset(
14 "nyu-mll/glue", "mnli", split="train"
15).select(range(50_000))
16
17mapping = {2: 0, 1: 0, 0: 1}
18
19gold_dataset = train_dataset.select(range(10_000))
20gold_examples = [
21 InputExample(texts=[row["premise"], row["hypothesis"]], label=mapping[row["label"]])
22 for row in gold_dataset]
23gold_dataloader = NoDuplicatesDataLoader(gold_examples, batch_size=32)
24
25# 暂存 gold 数据集,待与 silver 数据集合并
26gold = pd.DataFrame(
27 {
28 "sentence1": gold_dataset["premise"],
29 "sentence2": gold_dataset["hypothesis"],
30 "label": [mapping[label] for label in gold_dataset["label"]]
31 }
32)
33
34# 在黄金数据集中训练交叉编码器, num_labels=2 很重要,不然 output 全是 1
35cross_encoder = CrossEncoder('bert-base-uncased', num_labels=2, device="cuda")
36cross_encoder.fit(
37 train_dataloader=gold_dataloader,
38 epochs=1,
39 show_progress_bar=True,
40 warmup_steps=100,
41 use_amp=False
42)
43
44# 取 40,000 条数据组成未标注的数据集
45silver_dataset = train_dataset.select(range(10_000, 50_000))
46pairs = list(zip(silver_dataset["premise"], silver_dataset["hypothesis"]))
47
48# 使用经过微调的交叉编码器标注句子对
49output = cross_encoder.predict(pairs, apply_softmax=True, show_progress_bar=True)
50# 获得 silver 数据集,待与 gold 数据集合并
51silver = pd.DataFrame(
52 {
53 "sentence1": silver_dataset["premise"],
54 "sentence2": silver_dataset["hypothesis"],
55 "label": np.argmax(output, axis=1)
56 }
57)
58
59# 最终的已标注的数据, 10,000 条 gold 数据 + 40,000 条 silver 数据(由交叉编码器标注的)
60data = pd.concat([gold, silver], ignore_index=True, axis=0)
61data = data.drop_duplicates(subset=["sentence1", "sentence2"], keep="first")
62train_dataset = Dataset.from_pandas(data, preserve_index=False)
63
64# 后面的训练过程就是一样的了 ------------
65
66embedding_model = SentenceTransformer("bert-base-uncased")
67
68# 定义损失函数
69train_loss = losses.CosineSimilarityLoss(model=embedding_model)
70
71# 定义评估器,使用语义文本相似度基准(Semantic Textual Similarity Benchmark, STSB)
72# 这是一个由人工标注的句子对数据集,相似度分数在 1 ~ 5 之间
73val_sts = load_dataset("nyu-mll/glue", "stsb", split="validation")
74evaluator = EmbeddingSimilarityEvaluator(
75 sentences1=val_sts["sentence1"],
76 sentences2=val_sts["sentence2"],
77 scores=[score/5 for score in val_sts["label"]], # 值转换为 0~1 之间
78 main_similarity="cosine"
79)
80
81# 定义训练参数
82args = SentenceTransformerTrainingArguments(
83 output_dir="augmented_embedding_model",
84 num_train_epochs=1,
85 per_device_train_batch_size=32,
86 per_device_eval_batch_size=32,
87 warmup_steps=100,
88 fp16=True,
89 eval_steps=100,
90 logging_steps=100
91)
92
93# 训练模型
94trainer = SentenceTransformerTrainer(
95 model=embedding_model,
96 args=args,
97 train_dataset=train_dataset,
98 loss=train_loss,
99 evaluator=evaluator
100)
101
102trainer.train()
103embedding_model.save("augmented_embedding_model")
训练完 evaluator(embedding_model)
{'pearson_cosine': 0.7093859109290936, 'spearman_cosine': 0.7151919508569721}
与上一篇用余弦相似度损失函数经过 50,000 条数据训练的结果
{'pearson_cosine': 0.7250524707965853, 'spearman_cosine': 0.7289073126352569}
毕竟它只用了 10,000 条进行了良好标注的训练数据,其余 40,000 条是由交叉编码器标注的,虽然总数量一样,但质量还是不如前者,所以结果也就差了一点。
无监督学习(Unsupervised Learning)
前面不管是黄金数据还是白银数据都是有标签的,所以前面的叫做监督学习,如果我们使用没有标注过的数据来训练模型,这就叫无监督学习,类似于前面的无监督分类。 无监督学习有许多种方法,如
- SimCSE(Simple Contrastive Learning of Sentence Embeddings, 句子嵌入的简单对比学习)
- CT(Contrastive Tension, 对比张力)
- TSDAE(Transformer-based Sequential Denoising Auto-Encoder, 基于 Transformer 的序列去噪自编码器)
- GPL(Generative Pseudo-Labeling, 生成式伪标签)
下面重点学习 TSDAE, 它的基本思路是通过删除输入句子中一定比例的词来为其添加噪声,这个 "受损" 的句子被输入编码器,编码器的上方有一个池化层, 将其映射为句子嵌入。基于这个句子嵌入,解码器尝试重建原始句子,但不包含人为添加的噪声。这里的核心概念是:句子嵌入越准确,重建的句子就越准确。 这种方法与掩码语言建模相似,但它是针对整个句子进行的,而不是单个词, TSDAE 的训练目标是最小化重建损失。

从图片看起来很直截了当,训练过程的代码如下
1import nltk
2import torch
3from datasets import Dataset, load_dataset
4from sentence_transformers import SentenceTransformerTrainingArguments, SentenceTransformerTrainer
5from sentence_transformers.sentence_transformer.datasets import DenoisingAutoEncoderDataset
6from sentence_transformers.sentence_transformer.evaluation import EmbeddingSimilarityEvaluator
7from sentence_transformers.sentence_transformer import model, SentenceTransformer, losses
8
9nltk.download('punkt_tab') # 下载 punkt_tab 分词器
10
11mnli = load_dataset(
12 "nyu-mll/glue", "mnli", split="train"
13).select(range(25_000))
14flat_sentences = list(mnli["premise"]) + list(mnli["hypothesis"]) # 共 50,000 条句子
15
16# 为输入数据添加噪声
17damaged_data = DenoisingAutoEncoderDataset(flat_sentences)
18
19train_dataset = {"damaged_sentence": [], "original_sentence": []}
20for data in damaged_data: # 这里会用到 `punkt_tab` 分词器
21 train_dataset["damaged_sentence"].append(data.texts[0]) # data.texts[0] 是受损的句子
22 train_dataset["original_sentence"].append(data.texts[1])
23train_dataset = Dataset.from_dict(train_dataset)
24
25print(train_dataset[3])
26
27# 定义评估器,使用语义文本相似度基准(Semantic Textual Similarity Benchmark, STSB)
28# 这是一个由人工标注的句子对数据集,相似度分数在 1 ~ 5 之间
29val_sts = load_dataset("nyu-mll/glue", "stsb", split="validation")
30evaluator = EmbeddingSimilarityEvaluator(
31 sentences1=val_sts["sentence1"],
32 sentences2=val_sts["sentence2"],
33 scores=[score/5 for score in val_sts["label"]], # 值转换为 0~1 之间
34 main_similarity="cosine"
35)
36
37# 使用基础模型,使用 `[CLS]` token 作为池化策略,而不是对所有 token 的平均池化,因为平均池化会丢失位置信息
38word_embedding_model = model.Transformer("bert-base-uncased")
39pooling_model = model.Pooling(word_embedding_model.get_embedding_dimension(), "cls")
40embedding_model = SentenceTransformer(modules=[word_embedding_model, pooling_model], device="cuda")
41
42# 专门的损失函数,去噪自编码器损失函数,训练目标是最小化重建损失
43# transformers 5.0.0 之后,tie_encoder_decoder 必须为 False, 并设置 decoder_name_or_path
44# transformers 当前版本 5.8.0
45train_loss = losses.DenoisingAutoEncoderLoss(
46 embedding_model,
47 decoder_name_or_path="bert-base-uncased",
48 tie_encoder_decoder=False
49)
50train_loss.decoder = train_loss.decoder.to("cuda")
51
52# 定义训练参数
53args = SentenceTransformerTrainingArguments(
54 output_dir="tsdae_embedding_model",
55 num_train_epochs=1,
56 per_device_train_batch_size=16,
57 per_device_eval_batch_size=16,
58 warmup_steps=100,
59 fp16=True,
60 eval_steps=100,
61 logging_steps=100,
62)
63
64# 训练模型,这个训练过程比较慢,在 RTX 4090 上花了 5 分钟,前一篇几个训练只要 2 分钟
65trainer = SentenceTransformerTrainer(
66 model=embedding_model,
67 args=args,
68 train_dataset=train_dataset,
69 loss=train_loss,
70 evaluator=evaluator
71)
72trainer.train()
73embedding_model.save("tsdae_embedding_model")
74
75# 保存解码器的权重,后续用来测试受损句子重建的效果
76torch.save(train_loss.decoder.state_dict(), "tsdae_embedding_model/decoder.pt")
train_dataset[3] 的输出为
1{'damaged_sentence': 'do you know this is information',
2'original_sentence': 'How do you know? All this is their information again.'}damaged_sentence 是从 original_sentence 中删除了一些词得到的。训练完评估 evaluator(embedding_model), 结果为
{'pearson_cosine': 0.7186646635148979, 'spearman_cosine': 0.7280383796512555}
准确度与前面使用余弦相似度损失函数训练的结果相当, pearson_cosine 0.719 比 0.725, 都不如用多负例排序损失函数训练的效果。
测试由受损句子重建的效果
上面训练生成了 tsdae_embedding_model 模型,并且用 torch.save(train_loss.decoder.state_dict(), "tsdae_embedding_model/decoder.pt")
保存了训练好的解码器权重,下面我们来测试一下受损句子重建的效果
1import torch
2from datasets import load_dataset
3from sentence_transformers import SentenceTransformer
4from sentence_transformers.sentence_transformer import losses
5from sentence_transformers.sentence_transformer.datasets import DenoisingAutoEncoderDataset
6from transformers import AutoTokenizer
7
8# 加载编码器
9embedding_model = SentenceTransformer("tsdae_embedding_model", device="cuda")
10
11# 初始化解码器结构
12train_loss = losses.DenoisingAutoEncoderLoss(
13 embedding_model,
14 decoder_name_or_path="bert-base-uncased",
15 tie_encoder_decoder=False
16)
17
18# 加载已保存的解码器权重
19decoder_path = "tsdae_embedding_model/decoder.pt"
20train_loss.decoder.load_state_dict(torch.load(decoder_path, map_location="cuda"))
21train_loss.decoder = train_loss.decoder.to("cuda")
22train_loss.decoder.eval()
23
24tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
25
26def reconstruct_sentence(damaged_sentence: str, max_length=64) -> str:
27 with torch.no_grad():
28 embedding = embedding_model.encode(
29 damaged_sentence,
30 convert_to_tensor=True,
31 device="cuda"
32 ).unsqueeze(0).unsqueeze(1) # [1, 1, hidden_dim]
33
34 input_ids = torch.tensor([[tokenizer.cls_token_id]], device="cuda")
35
36 for _ in range(max_length):
37 outputs = train_loss.decoder(
38 input_ids=input_ids,
39 encoder_hidden_states=embedding,
40 )
41 next_token_id = outputs.logits[:, -1, :].argmax(dim=-1, keepdim=True)
42 if next_token_id.item() == tokenizer.sep_token_id:
43 break
44 input_ids = torch.cat([input_ids, next_token_id], dim=-1)
45
46 return tokenizer.decode(input_ids[0][1:], skip_special_tokens=True)
47
48
49# 测试
50mnli = load_dataset("nyu-mll/glue", "mnli", split="train").select(range(3))
51test_sentences = list(mnli["premise"]) + list(mnli["hypothesis"])
52damaged_dataset = DenoisingAutoEncoderDataset(test_sentences)
53
54for data in damaged_dataset:
55 damaged, original = data.texts[0], data.texts[1]
56 reconstructed = reconstruct_sentence(damaged)
57 print(f"Damaged: {damaged}")
58 print(f"Original: {original}")
59 print(f"Rebuilt: {reconstructed}")
60 print("-" * 60)
下面是测试结果
1Damaged: has two dimensions and geography.
2Original: Conceptually cream skimming has two basic dimensions - product and geography.
3Rebuilt: the two dimensions of the two dimensions have a dual dimension.
4------------------------------------------------------------
5Damaged: during the and i guess your level uh lose to the next level the parent team decide to a A guy up him a replace
6Original: you know during the season and i guess at at your level uh you lose them to the next level if if they decide to recall the the parent team the Braves decide to call to recall a guy from triple A then a double A guy goes up to replace him and a single A guy goes up to replace him
7Rebuilt: and uh i think the first one i get to get to the first one to get a new one and then i get to get a new one to get a new one and then i get to the next one and then i get to get the first one to get a new one and then i get to get the next
8------------------------------------------------------------
9Damaged: One of carry your instructions
10Original: One of our number will carry out your instructions minutely.
11Rebuilt: you have to carry your own one of your own.
12------------------------------------------------------------
13Damaged: what work.
14Original: Product and geography are what make cream skimming work.
15Rebuilt: what do you think about what work?
16------------------------------------------------------------
17Damaged: You lose to the level the.
18Original: You lose the things to the following level if the people recall.
19Rebuilt: you can lose the chance to lose the money.
20------------------------------------------------------------
21Damaged: A member of will execute immense precision
22Original: A member of my team will execute your orders with immense precision.
23Rebuilt: the commission will be able to execute a thorough execution of the commission ' s work.
24------------------------------------------------------------
从测试结果可以看出,受损句子重建的效果基本能还原关键词。
使用 TSDAE 进行领域适配(Domain Adaptation)
当我们只有很少或完全没有标注数据时,通常使用无监督学习来创建文本嵌入模型,但无监督学习表现肯定不如监督学习好,还难以学习特定领域的概念。 领域适配的目标是将现有嵌入模型适配到不同于源领域主题的特定文本领域,例如当前模型领域是 SQL, Python, Java, Rust 等编程领域要拓展到金融领域, 如 Bond, Fund, Stock, Option 等概念, 就要使用域内或域外的训练数据集对该模型进行微调,目标领域的数据更重要。
本章小结
本章中学习了多种训练和微调嵌入模型的方法,许多嵌入模型的基础技术就是对比学习,标注或未标注的句子对作为语料。用标注好的数据集进行训练是监督学习, 用未标注的数据集学习就叫做无监督学习。
我们创建嵌入模型并没有完全从头开始,而是选择了一个 BERT 模型,并用到了多种损失函数,如 softmax, 余弦相似度损失函数, 多负例排序损失函数, 去噪自编码器损失函数等。增强型 SBERT 是一种半监督学习方法, 用少量良好的标注数据微调模型的交叉编码器,利用虽然慢但能力较强的交叉编码器来标注更多的训练数据, 从而使用少量黄金数据和交叉编码器标注的大量白银数据最终来训练模型。
TSDAE 是一种无监督学习方法,通过删除输入句子中的词来添加噪声,训练一个去噪自编码器来重建原始句子, TSDAE 的训练目标是最小化重建损失。 此种方法可微调模型以适应其他特定领域的文本数据,尤其是在缺乏标注数据的情况下。
学习完后感觉本章都是在讲嵌入模型的微调技术,还要进入到下一章才是更清晰的理解什么是模型微调。
永久链接 https://yanbin.blog/hands-on-large-language-models-reading-notes-10/, 来自 隔叶黄莺 Yanbin's Blog[版权声明]
本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。