将文本转换为向量:TSDAE的无监督增强嵌入方法
将文本转换为向量:无监督增强嵌入方法的 TSDAE 模型
将TSDAE预训练于目标领域,并结合泛用语料的监督微调,以提升专业领域嵌入的质量。
介绍
嵌入将文本编码成高维向量空间,利用密集向量表示单词并捕捉它们的语义关系。最近的生成式AI和LLM的研究进展,如上下文搜索和RAG模型,严重依赖于其嵌入的质量。尽管相似性搜索使用余弦相似度等基本数学概念,但用于构建嵌入向量的方法对后续结果产生重大影响。
在大多数情况下,一个预训练的句子转换器可以直接使用,并且可以提供合理的结果。这些情况下有许多基于BERT的预训练上下文嵌入的选择,其中一些是领域专门化的,可以在这些情况下使用,并可从HuggingFace等平台下载。
然而,当我们处理包含大量特定于狭窄领域的技术术语或来自低资源语言的语料时,问题就会出现。在这些情况下,我们需要解决在预训练或微调过程中没有遇到的未知词汇。
例如,一个在一般文本上进行预训练的模型将很难正确地为数学研究论文中的标题分配向量。
在这些情况下,由于模型没有接触到领域专有词汇,它很难确定它们的含义,并准确地将它们放置在向量空间中与语料库中的其他词汇相关的位置上。未知词汇的数量越多,影响就越大,模型的性能也就越低。
因此,开箱即用的预训练模型在这种情况下表现不佳,而尝试预训练自定义模型会面临标记数据缺失和需要大量计算资源的挑战。
动机
这项工作是最近一篇关于航空领域的研究[aviation_article]的推动下展开的,该研究关注的数据具有技术术语、缩写词和非传统语法的独特特点。
为了解决缺乏标记数据的问题,作者采用了一种最有效的无监督技术,允许对嵌入进行预训练(TSDAE),然后使用来自泛用语料库的标记数据进行微调。经过调整的句子转换器表现出色,优于一般转换器,充分证明了该方法在捕捉航空领域数据特征方面的有效性。
大纲
领域适应是通过特定领域的文本嵌入进行调整,而不需要标记训练数据的过程。在这个实验中,我采用了一个两步法,根据[tsdae_article]的说法,这种方法比仅在目标领域上训练更好。
首先,我开始以目标领域为重点进行预训练,通常被称为自适应预训练。这个阶段需要我们的数据集中的句子集合。我在这个阶段使用了TSDAE方法,这是一种在领域适应方面表现出色的预训练任务,明显超过了其他方法,包括遮蔽语言模型,正如[tsdae_article]中强调的那样。我紧密遵循脚本:rain_tsdae_from_file.py。
随后,我使用通用标记的AllNLI数据集对模型进行微调,采用多负排名损失策略。对于这个阶段,我使用了training_nli_v2.py中的脚本。正如[tsdae_article]中所记录的那样,这个额外的步骤不仅可以对抗过拟合,还可以显著提高模型的性能。
TSDAE — 预训练目标领域
TSDAE(基于Transformer的序列去噪自编码器)是一种无监督的句子嵌入方法,最早由K. Wang、N. Reimers和I. Gurevych在[tsdae_article]中首次介绍。
TSDAE使用了修改后的编码器-解码器Transformer设计,其中交叉注意力的键和值限定在句子嵌入中。我将在原始论文[tsdae_article]中突出显示的最佳架构选择的背景下概述详细信息。
- 数据集包含未标记的句子,经过预处理后,通过删除其内容的60%来引入输入噪声。
- 编码器通过对其单词嵌入进行汇集,将损坏的句子转换为固定大小的向量。根据[tsdae_article]的说法,推荐使用CLS汇集方法来提取句子向量。
- 解码器需要从受损的句子嵌入中重构原始输入句子。作者建议在训练过程中绑定编码器和解码器的参数,以减少模型中的参数数量,使其更容易训练,更不容易过拟合,而不会影响性能。
为了获得良好的重构质量,编码器产生的句子嵌入必须能够最佳地捕捉语义特征。编码器使用预训练的Transformer(例如bert-base-uncased
),而解码器的权重则从编码器那里复制过来。
解码器的注意机制仅限于编码器生成的句子表示。这与原始Transformer编码器-解码器架构有所不同,限制了解码器从编码器检索的信息,并引入了瓶颈,迫使编码器产生有意义的句子表示。
在推断过程中,只使用编码器来创建句子嵌入。
该模型经过训练,可以通过最大化下述目标来重构干净的句子:
AllNLI — 自然语言推断数据集
自然语言推断(NLI)确定两个句子之间的关系。它将假设(第二个句子)的真实性分类为蕴含(基于前提条件为真)、矛盾(基于前提条件为假)或中立(前提条件既不保证也不否定)。NLI数据集是大型带标签的数据集,其中句子对被注释为它们的关系类别。
在这个实验中,我使用了AllNLI数据集,该数据集包含来自合并的斯坦福自然语言推断(SNLI)和MultiNLI数据集的超过90万条记录。该数据集可从以下位置下载:AllNLI下载网站。
加载和准备预训练数据
为了构建我们的领域特定数据,我们使用了由约170万份学术STEM论文组成的Kaggle arXiv数据集,这些论文来自于知名的预印版平台arXiv。除了标题、摘要和作者之外,每篇文章都有大量的元数据。但是,在这里我们只关注标题。
下载完成后,我将选择数学预印本。由于Kaggle文件体积较大,为了更容易访问,我在Github上添加了数学论文的精简版本。但是,如果你对其他主题感兴趣,可以下载数据集,并在下面的代码中用你想要的主题替换math
:
# 收集具有主题"math"的论文def extract_entries_with_math(filename: str) -> List[str]: """ 提取包含'id'中包含字符串'math'的条目的函数。 """ # 初始化一个空列表来存储提取到的条目。 entries_with_math = [] with open(filename, 'r') as f: for line in f: try: # 从该行加载JSON对象 data = json.loads(line) # 检查是否存在"id"键以及其中是否包含"math" if "id" in data and "math" in data["id"]: entries_with_math.append(data) except json.JSONDecodeError: # 如果此行不是有效的JSON,则输出错误消息 print(f"Couldn't parse: {line}") return entries_with_math# 提取数学论文entries = extract_entries_with_math(arxiv_full_dataset)# 将数据集保存为JSON对象arxiv_dataset_math = file_path + "/data/arxiv_math_dataset.json"with open(arxiv_dataset_math, 'w') as fout: json.dump(entries, fout)
我已经将我们的数据加载到Pandas的数据框架df
中。快速检查显示,缩小后的数据集包含了55,497个预印本——对于我们的实验来说更实用的大小。虽然[tsdae_article]建议大约10K个条目就足够了,但我会保存整个缩小后的数据集。数学标题可能包含LaTeX代码,我将使用ISO代码替换它们以优化处理。
parsed_titles = []for i,a in df.iterrows(): """ 用ISO代码替换LaTeX脚本的函数。 """ try: parsed_titles.append(LatexNodes2Text().latex_to_text(a['title']).replace('\\n', ' ').strip()) except: parsed_titles.append(a['title'].replace('\\n', ' ').strip())# 使用解析后的标题创建一个新的列df['parsed_title'] = parsed_titles
我将使用parsed_title
条目进行训练,让我们将它们提取为一个列表:
# 提取解析后的标题为一个列表train_sentences = df.parsed_title.to_list()
接下来,让我们通过从每个条目中删除大约60%的标记来形成损坏的句子。如果你有兴趣进一步探索或尝试不同的删除比例,请查看这个去噪脚本。
# 向数据添加噪音train_dataset = datasets.DenoisingAutoEncoderDataset(train_sentences)
让我们看看处理后一个条目的情况:
print(train_dataset[2010])初始文本:"On solutions of Bethe equations for the XXZ model"损坏文本:"On solutions of for the XXZ"
正如你注意到的,初始文本中的Bethe equations
和model
被删除了。
我们数据处理的最后一步是批量加载数据集:
train_dataloader = DataLoader(train_dataset, batch_size=8, shuffle=True, drop_last=True)
TSDAE训练
虽然我将按照train_tsdae_from_file.py中的方法进行,但我将逐步构建它以便更好地理解。
首先选择一个预训练的transformer检查点,并使用默认选项:
model_name = 'bert-base-uncased'word_embedding_model = models.Transformer(model_name)
选择CLS
作为汇集方法,并指定要构造的向量的维度:
pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension(), "cls") 'cls')
接下来,通过组合这两个层来构建句子转换器:
model = SentenceTransformer(modules=[word_embedding_model, pooling_model]) pooling_model])
最后,指定损失函数并在训练阶段绑定编码器-解码器参数。
train_loss = losses.DenoisingAutoEncoderLoss(model, decoder_name_or_path=model_name, tie_encoder_decoder=True)
现在,我们可以调用fit方法并训练模型了。我还将它存储起来以供后续步骤使用。你可以根据需要调整超参数以优化你的实验。
model.fit( train_objectives=[(train_dataloader, train_loss)], epochs=1, weight_decay=0, scheduler='constantlr', optimizer_params={'lr': 3e-5}, show_progress_bar=True, use_amp=True # 如果GPU不支持FP16内核,将其设置为False)pretrained_model_save_path = 'output/tsdae-bert-uncased-math'model.save(pretrained_model_save_path)
预训练阶段在使用A100 GPU设置为高内存的Google Colab Pro实例上花费了大约15分钟。
在AllNLI数据集上进行微调
让我们首先下载AllNLI数据集:
nli_dataset_path = 'data/AllNLI.tsv.gz'if not os.path.exists(nli_dataset_path): util.http_get('<https://sbert.net/datasets/AllNLI.tsv.gz>', nli_dataset_path)
<!–接下来,解压文件并解析数据以进行训练:
def add_to_samples(sent1, sent2, label): if sent1 not in train_data: train_data[sent1] = {'contradiction': set(), 'entailment': set(), 'neutral': set()} 'entailment': set 'neutral': set()} train_data[sent1][label].add(sent2)train_data = {}with gzip.open(nli_dataset_path, 'rt', encoding='utf8') as fIn: reader = csv.DictReader(fIn, delimiter='\\t', quoting=csv.QUOTE_NONE) for row in reader: if row['split'] == 'train': sent1 = row['sentence1'].strip() sent2 = row['sentence2'].strip() add_to_samples(sent1, sent2, row['label']) add_to_samples(sent2, sent1, row['label']) # Also add the oppositetrain_samples = []for sent1, others in train_data.items(): if len(others['entailment']) > 0 and len(others['contradiction']) > 0: train_samples.append(InputExample(texts=[sent1, random.choice(list(others['entailment'])), random.choice(list(others['contradiction']))])) train_samples.append(InputExample(texts=[random.choice(list(others['entailment'])), sent1, random.choice(list(others['contradiction']))])) random.choice(list(others['contradiction']))]))
训练数据集大约有563K个训练样本。最后,使用一个特殊的加载器按批次加载数据,并避免批次内的重复:
train_dataloader = datasets.NoDuplicatesDataLoader(train_samples, batch_size=32)
我在这里使用的批次大小比脚本中默认的大小 128
要小。尽管较大的批次会得到更好的结果,但会需要更多的GPU内存,由于我的计算资源有限,所以选择了较小的批次大小。
最后,使用MultipleRankingLoss在AllNLI数据集上微调预训练模型。蕴含对是正样本,矛盾对是困难负样本。
# 设置模型参数model_name = 'output/tsdae-bert-uncased-math'train_batch_size = 32 max_seq_length = 75num_epochs = 1# 加载预训练模型local_model = SentenceTransformer(model_name)# 选择损失函数train_loss = losses.MultipleNegativesRankingLoss(local_model)# 使用训练数据的10%进行预热warmup_steps = math.ceil(len(train_dataloader) * num_epochs * 0.1)# 训练模型local_model.fit(train_objectives=[(train_dataloader, train_loss)], #evaluator=dev_evaluator, epochs=num_epochs, #evaluation_steps=int(len(train_dataloader)*0.1), warmup_steps=warmup_steps, output_path=model_save_path, use_amp=True # 如果你的GPU支持FP16运算,则设置为True )# 保存模型finetuned_model_save_path = 'output/finetuned-bert-uncased-math'local_model.save(finetuned_model_save_path)
我在整个500K数据集上对模型进行了微调,用时约40分钟(在Google Colab Pro上,使用批次大小为32,训练1个epoch)。
评估TSDAE预训练模型和微调模型
我将对HuggingFace的STS(语义文本相似度)数据集进行一些初步评估,使用EmbeddingSimilarityEvaluator
,该评估器返回Spearman等级相关性。然而,这些评估不涉及我关注的特定领域,可能无法展示模型的真实性能。有关详细信息,请参阅[tsdae_article]中的第4节。
我从HuggingFace下载数据集并指定validation
子集:
import datasets as dtsfrom datasets import load_dataset# 从HuggingFace导入STS基准数据集sts = dts.load_dataset('glue', 'stsb', split='validation')
这是一个形式为:
Dataset({ features: ['sentence1', 'sentence2', 'label', 'idx'], num_rows: 1379})
为了更好地理解它,让我们来看一个具体的条目
# Take a peek at one of the entriessts['idx'][100], sts['sentence1'][100], sts['sentence2'][100], sts['label'][100]>>>(100, 'A woman is riding on a horse.', 'A man is turning over tables in anger.', 0.0)
从这个例子中我们可以看到,每个条目都有4个特征,一个是索引,两个句子和一个标签(由人工注释员创建)。标签可以取0到5之间的值,表示两个句子的相似性程度(5表示最相似)。 在这个例子中,这两个句子的主题完全不同。
为了评估模型,将句子对的嵌入创建,并计算每对句子的余弦相似度分数。标签与相似度分数之间的Spearman等级相关性被计算为评估分数。
由于我将使用取值范围在0到1之间的余弦相似度,我必须对标签进行归一化:
# Normalize the [0, 5] range to [0, 1]sts = sts.map(lambda x: {'label': x['label'] / 5.0})
用HuggingFace的InputExample
类封装数据:
# Create a list to store the parsed datasamples = []for sample in sts: # Reformat to use InputExample class samples.append(InputExample( texts=[sample['sentence1'], sample['sentence2']], label=sample['label'] ))
基于sentence-transformers
库中的EmbeddingSimilarityEvaluator
类创建评估器。
# Instantiate the evaluation moduleevaluator = EmbeddingSimilarityEvaluator.from_input_examples(samples)
我们为TSDAE模型,微调模型和一些预训练的句子转换模型计算得分:
因此,在一般的范围数据集上,一些预训练模型,例如all-mpnet-base-v2
的性能超过了TSDAE微调模型。然而,通过预训练,初始模型bert-base-uncased
的性能超过了两倍。可以想象通过进一步调整微调的超参数可以得到更好的结果。
结论
对于资源有限的领域,TSDAE与微调结合是一种相当高效的建立嵌入的策略。鉴于数据量和计算能力的限制,这里得到的结果是值得注意的。然而,对于那些不特别不寻常或领域特定的数据集,考虑到效率和成本,选择一个可以提供可比性能的预训练的嵌入可能更为合适。
Gihub链接 到Colab笔记本和示例数据集。
所以,朋友们,我们应该在学习的过程中接纳好的、坏的和杂乱的一切!
参考资料
[tsdae_article]. K. Wang, et al., TSDAE: Using Transformer-based Sequential Denoising Auto-Encoder for Unsupervised Sentence Embedding Learning (2021) arXiv:2104.06979
[aviation_article]. L. Wang, et al., Adapting Sentence Transformers for the Aviation Domain (2023) arXiv:2305.09556