【机器学习】基于RoBERTa模型的句子嵌入实践

1.引言

1.1.RoBERTa模型开发背景

BERT模型自发布以来,就以其卓越的性能和广泛的应用领域,在NLP领域引起了巨大的轰动。BERT通过预训练大量文本数据,学习到了丰富的语言表示,并在多个NLP任务上取得了显著的效果提升。然而,随着研究的深入和应用的广泛,人们发现BERT在某些任务上仍存在一些局限性,这为RoBERTa的研发提供了契机。

为了克服BERT的局限性,Facebook AI团队在研发RoBERTa时,进行了一系列有针对性的改进。首先,他们使用了更大规模的训练数据集,包括CommonCrawl和OpenWebText等,使得模型能够学习到更多的语言知识和模式。其次,他们对数据预处理进行了优化,去除了一些无用的标点符号和空格,进一步提高了模型的学习效率。此外,RoBERTa还采用了动态遮蔽策略,每次训练时都会生成新的遮蔽模式,这有助于模型学习更丰富的语言表征。最后,RoBERTa移除了BERT中的下一句预测任务,这一改进使得模型在多个任务上取得了更好的性能。

RoBERTa的研发不仅提高了模型本身的性能,还为NLP领域的研究人员提供了新的工具和思路。由于其出色的性能,RoBERTa很快被广泛应用于各种NLP任务中,包括文本分类、命名实体识别、问答系统等。此外,RoBERTa的发布还推动了NLP技术的进一步发展和应用,为人工智能领域的进步做出了重要贡献。

1.2.RoBERTa模型概述

1.2.1.RoBERTa模型的结构和工作原理
  1. 结构

    • RoBERTa模型的核心是Transformer模型,它由多个编码器和解码器堆叠而成。但在RoBERTa中,主要关注的是编码器部分,因为RoBERTa是一个预训练语言模型,主要用于生成文本的嵌入表示。
    • 每个编码器由多层的自注意力机制和前馈神经网络组成。这些层允许模型捕捉输入序列中的上下文关系,并提取语义信息。
  2. 工作原理

    • 输入:RoBERTa的输入是一段文本序列,通常是经过分词处理的句子。分词后的文本会被转化为词嵌入向量,同时还会添加位置编码向量来表示单词在句子中的位置信息。
    • 处理:这些向量会作为输入送入Transformer的编码器部分进行处理。通过多层自注意力机制和前馈神经网络的堆叠,模型能够学习到丰富的语义表示。
    • 输出:模型的输出是文本序列的嵌入表示,这些表示可以被用于各种NLP任务,如文本分类、命名实体识别等。
1.2.2.RoBERTa模型的优势
  1. 性能提升:通过更大规模的数据集、更长的训练时间、动态遮蔽策略以及移除下一句预测任务等优化手段,RoBERTa在多个NLP任务上取得了比BERT更高的性能。
  2. 通用性强:作为一个预训练语言模型,RoBERTa学习到的语言表示具有很强的通用性,可以很容易地迁移到各种NLP任务中。
  3. 易于使用:RoBERTa提供了预训练的模型权重,用户可以直接在自己的任务上进行微调,无需从头开始训练。
1.2.3.RoBERTa模型的劣势
  1. 计算资源需求大:由于RoBERTa使用了更大的数据集和更长的训练时间,因此训练RoBERTa模型需要更多的计算资源,包括GPU和内存。
  2. 模型复杂度高:RoBERTa模型的结构相对复杂,包含多个编码器和自注意力层,这使得模型的参数量较大,对硬件资源的要求也更高。
  3. 对长文本的处理能力有限:虽然RoBERTa通过自注意力机制能够捕捉长文本中的上下文关系,但对于非常长的文本序列,RoBERTa的处理能力仍然有限,可能无法充分利用所有的信息。

综上所述,RoBERTa模型在NLP领域具有显著的优势,但也存在一些劣势。在实际应用中,需要根据具体任务和资源条件来选择合适的模型。

1.3.句子嵌入技术

句子嵌入技术是一种自然语言处理(NLP)中用于将文本数据转换为数值向量的方法。这种技术的核心目标是在保留文本语义信息的同时,将高维的文本数据映射到低维的连续向量空间中。

  1. 语义保留:句子嵌入技术能够将句子中的语义信息编码到向量中,使得语义相近的句子在向量空间中的距离较近。

  2. 降维:通过将文本转换为固定大小的向量,句子嵌入技术解决了传统文本表示方法中的高维稀疏问题。

  3. 计算效率:句子嵌入使得文本的存储和计算更加高效,因为向量运算比原始文本处理要快得多。

  4. 应用广泛:句子嵌入向量可以作为特征输入到各种机器学习模型中,支持文本分类、情感分析、信息检索、机器翻译等多种NLP任务。

  5. 预训练模型:存在多种预训练的句子嵌入模型,如BERT、GPT、Sentence-BERT等,这些模型在大量文本数据上训练,能够捕捉丰富的语言特征。

  6. 跨语言能力:一些句子嵌入模型能够处理多种语言,为跨语言的文本分析提供了可能。

  7. 可定制性:根据不同的应用需求,可以对句子嵌入模型进行微调,以更好地适应特定领域的文本数据。

  8. 深度学习集成:现代的句子嵌入技术通常与深度学习架构结合,利用神经网络的强大能力来学习文本的复杂表示。

句子嵌入技术是连接人类语言和机器学习的桥梁,为文本数据的自动化分析和理解提供了强大的工具。随着技术的进步,我们可以期待句子嵌入在准确性和应用范围上都将持续提升。

1.4.主要内容

BERT和RoBERTa模型在语义文本相似性任务中表现出色,它们能够评估两个输入句子的相似度。然而,当面对一个包含大量句子的集合,并需要在这个集合中寻找最相似的句对时,计算成本会显著增加。例如,对于一个包含10000个句子的集合,逐对比较将需要进行( \frac{n(n-1)}{2} )次推理,其中n是句子的数量。在V100 GPU上,这可能意味着需要65小时的计算时间。

为了解决这一时间效率问题,一种有效的方法是利用模型输出的聚合或特定标记(如BERT中的[CLS]标记)作为句子的嵌入表示。通过这种方式,可以大幅减少寻找相似句子对所需的时间,从65小时降低到仅需5秒。

尽管直接使用RoBERTa生成的句子嵌入可能质量不高,但通过微调模型,比如使用孪生网络结构,可以显著提升嵌入的质量,使其能够捕捉到句子的深层语义。这种微调后的RoBERTa模型可以应用于多种任务,包括:

  • 大规模语义相似性比较。
  • 文本聚类。
  • 基于语义的搜索和信息检索。

本文将展示如何通过孪生网络对RoBERTa进行微调,使其能够生成具有丰富语义信息的句子嵌入,并在语义搜索和聚类任务中应用这些嵌入。这种微调技术最初在Sentence-BERT项目中提出。通过这种方法,我们能够提高模型在各种语义相关任务中的性能和应用范围。

2.构建RoBERTa模型

2.1.设置

# 升级 keras-nlp 和 keras 库到最新版本(如果可用)  
!pip install -q --upgrade keras-nlp  
!pip install -q --upgrade keras  
  
# 设置 Keras 的后端为 TensorFlow  
import os  
os.environ["KERAS_BACKEND"] = "tensorflow"  
  
# 导入所需的库  
import keras  
import keras_nlp  
import tensorflow as tf  
import tensorflow_datasets as tfds  
import sklearn.cluster as cluster  
  
# 设置全局混合精度策略为 mixed_float16,以提高训练速度和减少内存使用  
# 注意:这需要在支持混合精度的硬件上运行,并且 TensorFlow 和 Keras 的版本需要支持  
keras.mixed_precision.set_global_policy("mixed_float16")

2.2.构建模型理论基础

微调模型采用孪生网络结构
孪生网络是一种神经网络结构,由两个或多个共享权重的子网络构成,用于生成输入特征向量并比较它们的相似度。

在我们的案例中,子网络是以RoBERTa模型为基础,通过添加池化层来生成输入句子的嵌入向量。这些嵌入向量随后将相互比较,以便学习生成具有实际语义的嵌入。

我们采用的池化策略包括平均池化、最大池化和CLS池化,其中平均池化效果最佳,这将在我们的示例中使用。

利用回归目标函数进行微调
在构建回归目标函数的孪生网络时,该网络的任务是预测两个输入句子嵌入的余弦相似度,该相似度反映了嵌入向量之间的角度关系。余弦相似度越高,表示句子在语义上越相似。

2.3.准备数据集

我们将采用STSB数据集对模型进行微调,该数据集包含了一系列被标记的句子对,标记范围从0到5,反映了句子对之间的语义相似度。为了使余弦相似度的输出范围与数据集标签一致,我们将在数据预处理阶段将标签除以2.5再减去1,以匹配余弦相似度的[-1, 1]范围。

TRAIN_BATCH_SIZE = 6
VALIDATION_BATCH_SIZE = 8

TRAIN_NUM_BATCHES = 300
VALIDATION_NUM_BATCHES = 40

AUTOTUNE = tf.data.experimental.AUTOTUNE


def change_range(x):
    return (x / 2.5) - 1


def prepare_dataset(dataset, num_batches, batch_size):
    dataset = dataset.map(
        lambda z: (
            [z["sentence1"], z["sentence2"]],
            [tf.cast(change_range(z["label"]), tf.float32)],
        ),
        num_parallel_calls=AUTOTUNE,
    )
    dataset = dataset.batch(batch_size)
    dataset = dataset.take(num_batches)
    dataset = dataset.prefetch(AUTOTUNE)
    return dataset


stsb_ds = tfds.load(
    "glue/stsb",
)
stsb_train, stsb_valid = stsb_ds["train"], stsb_ds["validation"]

stsb_train = prepare_dataset(stsb_train, TRAIN_NUM_BATCHES, TRAIN_BATCH_SIZE)
stsb_valid = prepare_dataset(stsb_valid, VALIDATION_NUM_BATCHES, VALIDATION_BATCH_SIZE)

代码定义了用于处理和准备STSB(Semantic Textual Similarity Benchmark)数据集的函数和流程,用于训练和验证模型。

  1. 定义批量大小

    • TRAIN_BATCH_SIZEVALIDATION_BATCH_SIZE 分别定义了训练和验证批次的样本数量。
  2. 定义批次数量

    • TRAIN_NUM_BATCHESVALIDATION_NUM_BATCHES 分别定义了训练和验证过程中要使用的批次总数。
  3. 定义自动调整

    • AUTOTUNE 用于 tf.data.experimental.AUTOTUNE,它允许TensorFlow自动调整数据加载过程中的资源。
  4. 定义范围变化函数

    • change_range 函数将标签值的范围从[0, 5]转换为[-1, 1],这是通过除以2.5再减1实现的。
  5. 定义数据集准备函数

    • prepare_dataset 函数接收原始数据集、批次数量和批量大小,然后执行以下操作:
      • 使用 map 函数处理数据集中的每个元素,将句子对和转换后的标签组合在一起。
      • 使用 batch 函数将数据分批。
      • 使用 take 函数限制每个epoch的批次数量。
      • 使用 prefetch 函数提高性能,允许模型在训练时异步预读取数据。
  6. 加载和划分数据集

    • 使用 tfds.load 加载 “glue/stsb” 数据集,它被划分为训练集和验证集。
  7. 准备训练和验证数据集

    • 调用 prepare_dataset 函数分别准备训练集和验证集,传入相应的批次数量和批量大小。

代码的最终目标是为模型的训练和验证准备经过预处理和优化的数据流,确保数据以高效和适当的格式提供给模型。通过这种方式,可以提高训练过程的效率,并确保模型能够接收到正确范围的标签值。

让我们来看看数据集中两个句子及其相似性的例子。

# 遍历stsb_train数据集中的一批数据  
for x, y in stsb_train.take(1):  # 使用.take(1)来仅获取一批数据,因为外部已经有一个break  
    # 遍历这一批数据中的每一个样本  
    for i, example in enumerate(x):  
        # 输出第一个句子  
        print(f"句子1 : {example[0].numpy().decode('utf-8')} ")  # 假设example[0]是bytes类型,需要解码为字符串  
        # 输出第二个句子  
        print(f"句子2 : {example[1].numpy().decode('utf-8')} ")  # 同样假设example[1]是bytes类型  
        # 输出相似度得分(注意y[i]是一个列表,因为我们之前将y封装在一个列表中)  
        print(f"相似度 : {y[0][i].numpy()} \n")  # 如果y是一个列表的列表,我们只需要y[0]来获取相似度列表  
    # 因为我们只想看一个批次的样本,所以在这里添加break  
    break

```python
句子 1 : b"A young girl is sitting on Santa's lap." 
句子 2 : b"A little girl is sitting on Santa's lap" 
相似度 : [0.9200001] 

2.4.构建编码器模型

我们将创建一个编码器模型,用于生成能够表示句子语义的嵌入向量。该模型包括以下几个主要组件:

  • 一个预处理层,负责将句子分词,并为它们生成填充掩码,以处理不同长度的句子。
  • 一个骨干网络模型,它负责生成句子中每个分词的上下文感知表示。
  • 一个均值池化层,通过 keras.layers.GlobalAveragePooling1D 实现,用于将骨干模型生成的分词表示合并为单一的句子嵌入向量。在计算均值时,该层会考虑填充掩码,以确保填充分词不会影响嵌入结果。
  • 一个归一化层,用于将嵌入向量标准化,这对于基于余弦相似度的相似性度量是必要的。

通过这种方式,编码器模型能够将输入的句子转换成固定大小的嵌入向量,这些向量可以用于后续的相似性比较或其他下游任务。

# 加载预定义的 Roberta 预处理器和主干网络  
# 从 "roberta_base_en" 预设中加载 Roberta 预处理器  
preprocessor = keras_nlp.models.RobertaPreprocessor.from_preset("roberta_base_en")  
# 从 "roberta_base_en" 预设中加载 Roberta 主干网络  
backbone = keras_nlp.models.RobertaBackbone.from_preset("roberta_base_en")  
  
# 定义一个输入层,用于接收字符串类型的句子,并命名为 "sentence"  
inputs = keras.Input(shape=(1,), dtype="string", name="sentence")  
  
# 使用预处理器处理输入句子  
x = preprocessor(inputs)  
  
# 使用主干网络处理预处理器输出的张量  
h = backbone(x)  
  
# 注意:在 Keras 中,GlobalAveragePooling1D 通常不接受额外的 mask 参数  
# 因此在应用 GlobalAveragePooling1D 时,我们不需要 x["padding_mask"]  
# 应用全局平均池化层,并命名为 "pooling_layer"  
embedding = keras.layers.GlobalAveragePooling1D(name="pooling_layer")(h)  
  
# 应用单位归一化层,对嵌入进行归一化,并沿着轴 1 进行操作  
n_embedding = keras.layers.UnitNormalization(axis=1)(embedding)  
  
# 创建模型,输入为句子,输出为归一化后的嵌入  
roberta_normal_encoder = keras.Model(inputs=inputs, outputs=n_embedding)  
  
# 显示模型的摘要信息  
roberta_normal_encoder.summary()

代码展示了如何构建一个用于生成句子嵌入的编码器模型,具体使用了RoBERTa模型作为骨干网络。

  1. 加载预处理器和骨干网络

    • 使用 keras_nlp.models.RobertaPreprocessorkeras_nlp.models.RobertaBackbone 从预设 “roberta_base_en” 加载RoBERTa的预处理器和骨干网络。
  2. 定义输入层

    • 创建一个输入层 inputs,用于接收形状为(1,)的字符串类型句子,并命名为 “sentence”。
  3. 处理输入句子

    • 利用加载的预处理器 preprocessor 对输入层的输出进行处理。
  4. 生成上下文表示

    • 使用骨干网络 backbone 处理预处理器输出的张量,生成每个分词的上下文表示。
  5. 应用全局平均池化

    • 应用 keras.layers.GlobalAveragePooling1D 层进行全局平均池化,生成句子的嵌入向量,并命名为 “pooling_layer”。注意,这里没有使用额外的掩码参数。
  6. 归一化嵌入

    • 使用 keras.layers.UnitNormalization 层对嵌入向量进行单位归一化处理,操作沿轴1进行。
  7. 创建编码器模型

    • 构建一个Keras模型 roberta_normal_encoder,输入为句子,输出为归一化后的嵌入。
  8. 显示模型摘要

    • 使用 roberta_normal_encoder.summary() 显示模型的摘要信息,以便于理解模型结构和各层的参数。

代码段的目的是构建一个能够将输入句子转换为固定大小的归一化嵌入向量的模型,这些嵌入向量可以用于后续的相似性比较或其他需要句子级特征表示的任务。通过使用预训练的RoBERTa模型,编码器能够捕捉到丰富的语义信息,并有效地将这些信息编码到低维空间中。

Model: "functional_1"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type)        ┃ Output Shape      ┃ Param # ┃ Connected to         ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩
│ sentence            │ (None, 1)0-                    │
│ (InputLayer)        │                   │         │                      │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ roberta_preprocess… │ [(None, 512),0 │ sentence[0][0]       │
│ (RobertaPreprocess… │ (None, 512)]      │         │                      │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ roberta_backbone    │ (None, 512, 768)124,05… │ roberta_preprocesso… │
│ (RobertaBackbone)   │                   │         │ roberta_preprocesso… │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ pooling_layer       │ (None, 768)0 │ roberta_backbone[0]… │
│ (GlobalAveragePool… │                   │         │ roberta_preprocesso… │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ unit_normalization  │ (None, 768)0 │ pooling_layer[0][0]  │
│ (UnitNormalization) │                   │         │                      │
└─────────────────────┴───────────────────┴─────────┴──────────────────────┘
 Total params: 124,052,736 (473.22 MB)
 Trainable params: 124,052,736 (473.22 MB)
 Non-trainable params: 0 (0.00 B)

2.5.建立基于孪生模型的回归模型

我们将构建一个带有回归目标的孪生网络。孪生网络由两个或多个子网络构成,对于我们的孪生模型,理论上需要两个独立的编码器来处理输入的两个句子。但实际情况下,我们只有一个编码器模型,因此我们将采用让两个句子依次通过同一编码器的方式,以此实现两个句子的嵌入表示,同时保持权重的共享。

一旦模型处理了两个句子并生成了归一化的嵌入向量,我们将通过将这两个嵌入向量进行点积运算来计算它们之间的余弦相似度。这种相似度度量方式反映了句子对在语义上接近程度的量化指标。通过这种方式,孪生网络能够学习如何评估两个输入句子在语义层面的相似性。

import tensorflow as tf  
from tensorflow import keras  
  
class RegressionSiamese(keras.Model):  
    def __init__(self, encoder, **kwargs):  
        super().__init__(**kwargs)  # 先调用父类的初始化方法  
  
        # 定义输入层  
        self.input_layer = keras.Input(shape=(2,), dtype="string", name="sentences")  
  
        # 使用 Lambda 层或自定义层来处理字符串,因为标准的 Input 层不直接处理字符串  
        # 这里我们假设 encoder 已经能够处理字符串输入  
  
        # 假设 encoder 返回一个张量,我们可以直接调用它来获取句子的编码  
        sen1 = keras.layers.Lambda(lambda x: x[:, 0])(self.input_layer)  
        sen2 = keras.layers.Lambda(lambda x: x[:, 1])(self.input_layer)  
  
        # 使用 encoder 对两个句子进行编码  
        u = encoder(sen1)  
        v = encoder(sen2)  
  
        # 计算余弦相似度  
        # 注意:matmul 可能会因为维度不匹配而失败,我们需要确保 u 和 v 的形状适合矩阵乘法  
        # 这里假设 u 和 v 的最后一个维度是它们的嵌入维度  
        cosine_similarity_scores = keras.layers.Dot(axes=-1, normalize=True)([u, v])  
  
        # 构建模型  
        self.model = keras.Model(inputs=self.input_layer, outputs=cosine_similarity_scores)  
  
        # 存储 encoder  
        self.encoder = encoder  
  
    def call(self, inputs):  
        # 当调用模型时,直接调用内部模型  
        return self.model(inputs)  
  
    def get_encoder(self):  
        return self.encoder

代码定义了一个名为 RegressionSiamese 的孪生网络模型类,它继承自 keras.Model。该模型专门用于回归任务,特别是用于计算两个句子之间的余弦相似度。以下是代码的功能描述:

  1. 类初始化 (__init__ 方法):

    • 接收一个编码器模型 encoder 作为参数,该编码器用于生成句子的嵌入。
    • 定义了一个输入层 input_layer,用于接收一对句子,输入形状为 (2,),数据类型为 string
  2. 处理输入句子:

    • 使用 Lambda 层从输入中分离出两个句子,分别对应句子对的第一个和第二个句子。
  3. 编码句子:

    • 通过编码器处理两个分离的句子,分别得到它们的嵌入表示 uv
  4. 计算余弦相似度:

    • 使用 Dot 层计算两个句子嵌入的余弦相似度。normalize=True 参数确保输入张量在进行点积之前会被归一化。
  5. 构建孪生模型:

    • 创建一个 keras.Model,输入为句子对,输出为计算得到的余弦相似度分数。
  6. 存储编码器:

    • 将传入的编码器保存为类的一个属性,以便之后可以访问或用于其他目的。
  7. 调用模型 (call 方法):

    • 当模型被调用时,直接通过内部模型 self.model 处理输入。
  8. 获取编码器 (get_encoder 方法):

    • 提供一个公共方法来获取模型内部使用的编码器。

这个孪生网络模型的主要用途是评估两个句子在语义上的相似度,通过余弦相似度来量化它们的相似性。这种类型的模型可以用于各种NLP任务,如相似性度量、聚类、信息检索等。通过微调编码器,模型可以学习到更加丰富和语义相关的句子表示。

2.6.拟合模型

在开始训练之前,我们将测试这个模型的例子,并将其输出与训练后的预测结果进行对比分析。

# 定义句子列表  
sentences = [  
    "Today is a very sunny day.",  
    "I am hungry, I will get my meal.",  
    "The dog is eating his food.",  
]  
# 定义查询句子  
query = ["The dog is enjoying his meal."]  
  
# 使用之前定义的 roberta_normal_encoder 作为编码器  
encoder = roberta_normal_encoder  
  
# 使用编码器计算句子列表的嵌入表示  
sentence_embeddings = encoder(tf.constant(sentences))  
# 使用编码器计算查询句子的嵌入表示  
query_embedding = encoder(tf.constant(query))  
  
# 计算查询句子嵌入与句子列表嵌入之间的余弦相似度得分  
cosine_similarity_scores = tf.matmul(query_embedding, tf.transpose(sentence_embeddings))  
  
# 遍历余弦相似度得分,并打印出来  
for i, sim in enumerate(cosine_similarity_scores[0]):  
    print(f"句子{i+1}与查询句子的余弦相似度得分 = {sim}")

代码演示了如何使用一个预定义的编码器(这里是 roberta_normal_encoder)来计算一组句子和查询句子之间的余弦相似度得分。以下是代码的功能描述:

  1. 定义句子列表

    • 创建了一个包含三个句子的列表 sentences
  2. 定义查询句子

    • 定义了一个包含一个查询句子的列表 query
  3. 使用编码器计算嵌入

    • 使用之前定义好的编码器 roberta_normal_encoder 来计算句子列表 sentences 的嵌入表示,并将结果存储在 sentence_embeddings 中。
    • 同样使用 roberta_normal_encoder 计算查询句子 query 的嵌入表示,并将结果存储在 query_embedding 中。
  4. 计算余弦相似度

    • 使用 tf.matmul 函数和 tf.transpose 函数计算查询句子嵌入与句子列表嵌入之间的余弦相似度得分。这里,tf.matmul 执行矩阵乘法,而 tf.transpose 用于转置句子嵌入矩阵,以确保维度匹配。
  5. 遍历并打印余弦相似度得分

    • 通过循环遍历计算得到的余弦相似度得分,并对每个句子与查询句子之间的相似度进行打印。

代码的目的是展示如何使用编码器来评估一组句子与一个查询句子之间的语义相似度。余弦相似度得分越高,表示句子与查询在语义上越相似。这种方法可以应用于信息检索、问答系统、推荐系统等多种场景。

句子1与查询句子的余弦相似度得分 = 0.96630859375 
句子2与查询句子的余弦相似度得分 = 0.97607421875 
句子3与查询句子的余弦相似度得分 = 0.99365234375 

在训练过程中,我们将采用均方误差(MeanSquaredError())作为损失函数,并搭配使用Adam优化器,该优化器的学习率被设定为2e-5。这种设置有助于模型在训练时有效地调整参数,以最小化预测值与实际值之间的差异。

# 创建一个基于RoBERTa编码器的孪生回归模型  
roberta_regression_siamese = RegressionSiamese(roberta_normal_encoder)  
  
# 编译模型,设置损失函数、优化器和JIT编译选项  
# 损失函数使用均方误差  
# 优化器使用Adam,学习率设置为2e-5  
# JIT编译选项设置为False  
roberta_regression_siamese.compile(  
    loss=keras.losses.MeanSquaredError(),  # 损失函数:均方误差  
    optimizer=keras.optimizers.Adam(2e-5),  # 优化器:Adam,学习率为2e-5  
    jit_compile=False,  # JIT编译选项:关闭JIT编译  
)  
  
# 训练模型,使用stsb_train作为训练数据,stsb_valid作为验证数据,训练1个epoch  
roberta_regression_siamese.fit(  
    stsb_train,  # 训练数据  
    validation_data=stsb_valid,  # 验证数据  
    epochs=1  # 训练轮数:1个epoch  
)

代码展示了如何使用定义好的 RegressionSiamese 孪生网络模型类和 roberta_normal_encoder 编码器来创建和训练一个回归模型。以下是代码的功能描述:

  1. 创建孪生回归模型实例

    • 使用 roberta_normal_encoder 作为编码器,创建 RegressionSiamese 类的实例 roberta_regression_siamese
  2. 编译模型

    • 使用 .compile 方法编译模型,设置以下参数:
      • loss: 损失函数设置为均方误差(keras.losses.MeanSquaredError())。
      • optimizer: 优化器使用 Adam 优化器,并将学习率设置为 2e-5。
      • jit_compile: 设置为 False,关闭即时编译(JIT)。
  3. 训练模型

    • 使用 .fit 方法训练模型,传入以下参数:
      • stsb_train: 作为训练数据集。
      • validation_data: 将 stsb_valid 作为验证数据集。
      • epochs: 设置训练轮数为 1 个 epoch。

代码的目的是训练孪生网络模型,使其能够学习如何根据两个输入句子的嵌入来预测它们之间的语义相似度。通过在 STSB 数据集上进行训练和验证,模型可以捕捉到句子之间的细微语义差异,并在回归任务中进行有效的相似度评分。

这种训练方法通常用于需要模型理解文本语义并进行相似度评估的场景,如文本匹配、信息检索和推荐系统等。通过编译和训练步骤,模型将学习如何最小化预测相似度分数与真实标签之间的误差,从而提高其在语义相似性评估任务中的性能。

300/300 ━━━━━━━━━━━━━━━━━━━━ 115s 297ms/step - loss: 0.4751 - val_loss: 0.4025

<keras.src.callbacks.history.History at 0x7f5a78392140>

训练完成后,当我们利用模型进行预测时,可以观察到显著的输出变化。这表明经过微调的模型能够生成富有语义信息的嵌入向量。具体来说,那些语义相近的句子在向量空间中彼此接近,它们之间的夹角较小;相对地,语义差异较大的句子则在向量空间中相隔较远,夹角也相应增大。这种特性使得模型能够有效地区分不同句子间的语义相似度。

# 定义句子列表  
sentences = [  
    "Today is a very sunny day.",  
    "I am hungry, I will get my meal.",  
    "The dog is eating his food.",  
]  
# 定义查询句子  
query = ["The dog is enjoying his food."]  
  
# 从训练好的孪生回归模型中获取编码器  
encoder = roberta_regression_siamese.get_encoder()  
  
# 使用编码器计算句子列表的嵌入表示  
sentence_embeddings = encoder(tf.constant(sentences))  
# 使用编码器计算查询句子的嵌入表示  
query_embedding = encoder(tf.constant(query))  
  
# 计算查询句子嵌入与句子列表嵌入之间的余弦相似度  
cosine_similarities = tf.matmul(query_embedding, tf.transpose(sentence_embeddings))  
  
# 遍历余弦相似度,并打印出来  
for i, sim in enumerate(cosine_similarities[0]):  
    print(f"句子{i+1}与查询句子的余弦相似度 = {sim} ")

代码的功能是使用训练好的孪生回归模型中的编码器来计算一组句子和查询句子之间的余弦相似度。

  1. 定义句子列表

    • 创建了一个包含三个句子的列表 sentences
  2. 定义查询句子

    • 定义了一个包含单个查询句子的列表 query
  3. 获取编码器

    • 从训练好的孪生回归模型 roberta_regression_siamese 中通过 get_encoder() 方法获取编码器 encoder
  4. 计算句子嵌入表示

    • 使用编码器 encoder 计算句子列表 sentences 的嵌入表示,并将结果存储在 sentence_embeddings 中。
    • 使用相同的编码器计算查询句子 query 的嵌入表示,并将结果存储在 query_embedding 中。
  5. 计算余弦相似度

    • 使用 tf.matmultf.transpose 计算查询句子嵌入与句子列表嵌入之间的余弦相似度。这里,tf.matmul 用于执行矩阵乘法,而 tf.transpose 用于转置句子嵌入矩阵,确保可以正确计算点积。
  6. 遍历并打印余弦相似度

    • 遍历计算得到的余弦相似度得分,并对每个句子与查询句子之间的相似度进行打印。

代码的目的是展示如何使用训练好的编码器来评估一组句子与一个查询句子之间的语义相似度。余弦相似度得分反映了句子对之间在语义空间中的接近程度,得分越高,表示句子对在语义上越相似。这种方法可以应用于信息检索、问答系统、推荐系统等多种场景中,以帮助系统理解用户查询与现有文本数据的匹配程度。

2.7.模型微调

在使用三元组目标函数的孪生网络模型中,传入三个句子:锚点句、正面句和负面句。其中,锚点句与正面句语义相近,而与负面句语义相远。训练的目标是减少锚点句和正面句之间的距离,并增加锚点句和负面句之间的距离。

2.7.1.数据集加载

我们选择 Wikipedia-sections-triplets 数据集进行模型的微调。该数据集基于 Wikipedia 网站,由三个句子组成一组:锚点句和正面句来自同一段落,通常语义相关;锚点句和负面句来自不同段落,语义上不相关。

尽管该数据集提供了180万组训练样本和22万组测试样本,但在本示例中,为了简化,我们将仅使用1200组训练样本和300组测试样本进行模型的训练和评估。这种三元组训练方法有助于模型学习如何在向量空间中捕捉和区分句子间的语义相似性和差异性。

# 下载 Wikipedia 段落三元组数据集  
!wget https://sbert.net/datasets/wikipedia-sections-triplets.zip -q  
# 解压数据集到指定目录  
!unzip wikipedia-sections-triplets.zip -d wikipedia-sections-triplets/  
  
# 定义训练批次数和测试批次数  
NUM_TRAIN_BATCHES = 200  
NUM_TEST_BATCHES = 75  
# 使用 AUTOTUNE 自动调整预取缓冲区的大小  
AUTOTUNE = tf.data.experimental.AUTOTUNE  
  
# 准备 Wikipedia 数据集的函数  
def prepare_wiki_data(dataset, num_batches):  
    # 将数据集映射为元组,其中每个元素包含三个句子和一个标签(这里标签固定为0)  
    dataset = dataset.map(  
        lambda z: ((z["Sentence1"], z["Sentence2"], z["Sentence3"]), 0),  
        num_parallel_calls=AUTOTUNE  # 添加并行调用以提高效率  
    )  
    # 批量处理数据  
    dataset = dataset.batch(6)  
    # 限制数据集的批次数量  
    dataset = dataset.take(num_batches)  
    # 使用预取来加速数据加载  
    dataset = dataset.prefetch(AUTOTUNE)  
    return dataset  
  
# 创建训练数据集的 TensorFlow 数据管道  
wiki_train = tf.data.experimental.make_csv_dataset(  
    "wikipedia-sections-triplets/train.csv",  
    batch_size=1,  
    num_epochs=1,  
    label_name=None,  # 如果数据集没有标签列,可以显式指定为 None  
    field_delim=',',  # 明确指定字段分隔符为逗号  
    na_value="?",     # 指定缺失值的表示方式  
    select_columns=["Sentence1", "Sentence2", "Sentence3"]  # 明确指定要选择的列  
)  
  
# 创建测试数据集的 TensorFlow 数据管道  
wiki_test = tf.data.experimental.make_csv_dataset(  
    "wikipedia-sections-triplets/test.csv",  
    batch_size=1,  
    num_epochs=1,  
    label_name=None,  
    field_delim=',',  
    na_value="?",  
    select_columns=["Sentence1", "Sentence2", "Sentence3"]  
)  
  
# 使用自定义函数准备训练数据集和测试数据集  
wiki_train = prepare_wiki_data(wiki_train, NUM_TRAIN_BATCHES)  
wiki_test = prepare_wiki_data(wiki_test, NUM_TEST_BATCHES)

代码的功能是下载并处理一个名为“Wikipedia 段落三元组数据集”的数据集,用于后续的机器学习或深度学习任务。

  1. 下载和解压数据集

    • 使用wget命令从https://sbert.net/datasets/wikipedia-sections-triplets.zip下载数据集。
    • 使用unzip命令解压下载的压缩包到wikipedia-sections-triplets/目录。
  2. 定义训练和测试批次数

    • NUM_TRAIN_BATCHESNUM_TEST_BATCHES分别定义了从训练集和测试集中取出的批次数量。
  3. 设置AUTOTUNE

    • AUTOTUNE是一个TensorFlow的常量,用于在数据管道中自动选择最佳的并行和预取参数。
  4. 定义准备数据的函数

    • prepare_wiki_data函数接受一个数据集和一个批次数量作为输入。
    • 函数首先将数据集中的每一行映射为一个元组,其中包含一个三元组(三个句子)和一个固定的标签(0)。
    • 接着,函数将数据批量处理,每批包含6个样本。
    • 然后,它限制数据集的批次数量为指定的数量。
    • 最后,它使用预取来加速数据加载。
  5. 创建训练和测试数据集的TensorFlow数据管道

    • 使用tf.data.experimental.make_csv_dataset函数从CSV文件中创建数据集。
    • 对于训练集和测试集,都指定了批大小、迭代次数、无标签列、字段分隔符、缺失值表示方式和要选择的列。
  6. 准备训练和测试数据集

    • 使用之前定义的prepare_wiki_data函数,分别准备训练集和测试集。

代码的主要目的是从网上下载并处理一个包含句子三元组的数据集,然后为后续的机器学习或深度学习任务创建两个准备好的数据集(训练集和测试集)。虽然代码中并没有直接显示这些数据的后续用途,但通常这种数据集会被用于训练如句子嵌入模型或句子相似度比较模型等任务。

2.7.2.构建编码器模型

我们将采用RoBERTa模型,通过均值池化来构建编码器,且在此过程中不对输出嵌入向量执行归一化。该编码器模型包括以下几个主要组件:

  1. 预处理层:负责将句子分解成词元,并生成用于处理不同长度句子的填充掩码。
  2. 骨干网络模型:基于RoBERTa,它将为句子中的每个词元生成上下文相关的表示。
  3. 均值池化层:对骨干模型的输出执行均值池化操作,以生成能够代表整个句子的嵌入向量。

在这种设置中,预处理器层首先将输入文本进行分词,并识别出填充的部分以便模型能够正确处理。随后,骨干网络模型利用RoBERTa的强大能力,为每个词元生成包含丰富语义信息的表示。最终,均值池化层将所有词元的表示合并,生成一个综合的句子嵌入向量,该向量能够捕捉到句子的整体语义。

这种编码器模型的设计使其适用于多种自然语言处理任务,包括但不限于文本相似性比较、文本分类和信息检索等。通过避免对嵌入向量的归一化,模型提供了更多的灵活性,以适应不同任务对嵌入向量的具体需求。

# 导入所需的库  
from tensorflow.keras.layers import Input  
from tensorflow.keras.models import Model  
from tensorflow.keras.layers import GlobalAveragePooling1D  
from tensorflow.keras_nlp.models import RobertaPreprocessor, RobertaBackbone  
  
# 从预设配置中加载Roberta的预处理器和主干网络  
preprocessor = RobertaPreprocessor.from_preset("roberta_base_en")  
backbone = RobertaBackbone.from_preset("roberta_base_en")  
  
# 定义一个输入层,用于接收句子字符串  
input_layer = Input(shape=(1,), dtype="string", name="sentence")  
  
# 使用预处理器对输入句子进行编码  
encoded_input = preprocessor(input_layer)  
  
# 使用主干网络对编码后的输入进行进一步处理  
hidden_state = backbone(encoded_input)  
  
# 注意:GlobalAveragePooling1D 通常不需要使用 padding_mask  
# 但是如果你确实需要使用它,你需要确保你的 Keras 版本支持这种用法  
# 这里我们假设你不需要 padding_mask 来做全局平均池化  
# (如果你确实需要,请查阅相关文档或API以了解如何正确使用它)  
embedding = GlobalAveragePooling1D(name="pooling_layer")(hidden_state)  
  
# 创建一个模型,输入为句子,输出为嵌入表示  
roberta_encoder = Model(inputs=input_layer, outputs=embedding)  
  
# 打印模型结构概览  
roberta_encoder.summary()

代码的功能是创建一个基于Roberta模型的句子编码器。具体来说,它使用了TensorFlow和Keras库中的组件,并结合了tensorflow_text提供的Roberta预处理器和主干网络。

  1. 导入库:首先,导入了所需的库和模块。
  2. 加载预处理器和主干网络:使用RobertaPreprocessor.from_presetRobertaBackbone.from_preset方法从预设配置中加载了Roberta的预处理器和主干网络。这些预设配置通常包含了预训练的权重和模型的架构信息。
  3. 定义输入层:使用Input类定义了一个输入层,该层接受一个形状为(1,)的字符串数组(即一个句子),并命名为"sentence"。
  4. 预处理输入句子:通过预处理器preprocessor对输入的句子进行编码。这通常涉及到将文本转换为模型可以理解的数字表示(如词嵌入)。
  5. 通过主干网络处理编码后的输入:将编码后的输入传递给主干网络backbone进行进一步处理。这通常涉及到多个Transformer层,用于捕捉句子中的上下文信息。
  6. 全局平均池化:使用GlobalAveragePooling1D层对主干网络的输出进行全局平均池化。这会将序列的每一个时间步的嵌入向量平均起来,得到一个固定大小的向量表示,通常用于分类任务或句子级别的嵌入。
  7. 创建模型:使用Model类将输入层、预处理器、主干网络和全局平均池化层组合成一个完整的模型roberta_encoder。该模型的输入是句子,输出是句子的嵌入表示。
  8. 打印模型结构概览:使用summary方法打印模型的结构概览,包括每一层的名称、输出形状等。

总的来说,这段代码创建了一个基于Roberta的句子编码器,该编码器可以接受句子作为输入,并输出一个固定大小的嵌入向量,该向量可以用于各种自然语言处理任务,如文本分类、文本相似度计算等。

Model: "functional_3"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type)        ┃ Output Shape      ┃ Param # ┃ Connected to         ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩
│ sentence            │ (None, 1)0-                    │
│ (InputLayer)        │                   │         │                      │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ roberta_preprocess… │ [(None, 512),0 │ sentence[0][0]       │
│ (RobertaPreprocess… │ (None, 512)]      │         │                      │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ roberta_backbone_1  │ (None, 512, 768)124,05… │ roberta_preprocesso… │
│ (RobertaBackbone)   │                   │         │ roberta_preprocesso… │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ pooling_layer       │ (None, 768)0 │ roberta_backbone_1[… │
│ (GlobalAveragePool… │                   │         │ roberta_preprocesso… │
└─────────────────────┴───────────────────┴─────────┴──────────────────────┘
 Total params: 124,052,736 (473.22 MB)
 Trainable params: 124,052,736 (473.22 MB)
 Non-trainable params: 0 (0.00 B)
2.7.3.构建孪生网络

在构建带有三元组目标函数的孪生网络模型时,我们首先需要设计一个编码器(encoder),它能够将输入的句子转换成高维空间中的嵌入向量。编码器通常是一个深度神经网络,比如卷积神经网络(CNN)或者循环神经网络(RNN),用于提取句子的特征。

在孪生网络中,我们通常会有三组输入,即三个句子,它们构成一个三元组:一个锚点句子(anchor sentence)、一个正样本句子(positive sentence)和一个负样本句子(negative sentence)。这三个句子通过编码器处理后,我们将得到它们的嵌入向量。

接下来,我们需要计算正样本句子与锚点句子之间的距离(正距离,positive_dist)和负样本句子与锚点句子之间的距离(负距离,negative_dist)。这些距离通常使用欧氏距离或余弦相似度来计算。

最后,我们将这些距离输入到损失函数中。在三元组目标函数中,我们希望正样本与锚点之间的距离小于负样本与锚点之间的距离,即 p o s i t i v e _ d i s t < n e g a t i v e _ d i s t positive\_dist < negative\_dist positive_dist<negative_dist。损失函数的设计通常是为了最大化这个差距,常见的损失函数是三元组损失(Triplet Loss),其公式为:

L = ∑ i = 1 N max ⁡ ( 0 , d ( a i , p i ) − d ( a i , n i ) + m a r g i n ) L = \sum_{i=1}^{N} \max(0, d(a_i, p_i) - d(a_i, n_i) + margin) L=i=1Nmax(0,d(ai,pi)d(ai,ni)+margin)

其中, d ( a i , p i ) d(a_i, p_i) d(ai,pi)是锚点 a i a_i ai与正样本 p i p_i pi 之间的距离, d ( a i , n i ) d(a_i, n_i) d(ai,ni)是锚点 a i a_i ai 与负样本 n i n_i ni之间的距离, m a r g i n margin margin是一个超参数,用于控制正负样本对之间的最小距离。

通过优化这个损失函数,孪生网络能够学习到如何将语义相似的句子映射到相近的嵌入空间位置,而将语义不相似的句子映射到较远的位置。这种模型在诸如相似句子检测、句子相似度度量等任务中非常有用。
定义孪生网络
代码定义了一个名为TripletSiamese的类,它继承自keras.Model,实现了一个带有三元组目标函数的孪生网络模型。以下是代码的中文注释和功能解释:

# 导入TensorFlow和Keras的layers模块
import tensorflow as tf
from tensorflow.keras import layers as keras

# 定义TripletSiamese类,继承自keras.Model
class TripletSiamese(keras.Model):  
    """带有三元组目标函数的孪生网络模型"""  
    def __init__(self, encoder, **kwargs):  
        """初始化孪生网络模型

        Args:
            encoder (keras.Model): 编码器模型,用于处理句子输入
            **kwargs: 其他可选参数
        """  
        # 定义锚点、正样本和负样本的输入层
        anchor = keras.Input(shape=(1,), dtype="string", name="anchor")  
        positive = keras.Input(shape=(1,), dtype="string", name="positive")  
        negative = keras.Input(shape=(1,), dtype="string", name="negative")  
  
        # 将输入句子通过编码器得到嵌入向量
        ea = encoder(anchor)  
        ep = encoder(positive)  
        en = encoder(negative)  
  
        # 计算锚点与正样本之间的欧氏距离
        positive_dist = keras.backend.sum(keras.backend.square(ea - ep), axis=1)  
        # 计算锚点与负样本之间的欧氏距离
        negative_dist = keras.backend.sum(keras.backend.square(ea - en), axis=1)  
  
        # 对距离取平方根,得到实际的欧氏距离
        positive_dist = keras.backend.sqrt(positive_dist)  
        negative_dist = keras.backend.sqrt(negative_dist)  
  
        # 将正样本距离和负样本距离堆叠起来作为模型的输出
        output = keras.backend.stack([positive_dist, negative_dist], axis=0)  
  
        # 调用父类初始化方法,传入输入层和输出层
        super().__init__(inputs=[anchor, positive, negative], outputs=output, **kwargs)  
  
        # 保存编码器模型
        self.encoder = encoder  
  
    def get_encoder(self):  
        """获取编码器模型

        Returns:
            keras.Model: 返回编码器模型
        """  
        return self.encoder

代码功能:

  • TripletSiamese类构建了一个孪生网络模型,它接受三个输入:锚点、正样本和负样本。
  • 每个输入通过同一个编码器模型转换为嵌入向量。
  • 计算锚点与正样本之间的距离以及锚点与负样本之间的距离,这里使用的是欧氏距离的平方。
  • 对这些平方距离取平方根,以得到实际的欧氏距离。
  • 将正样本距离和负样本距离堆叠起来形成输出,这将用于后续的三元组损失函数计算。
  • 通过get_encoder方法可以获取到用于生成嵌入的编码器模型。
  • 该模型的设计允许通过最小化三元组损失来训练编码器,使得锚点与正样本的距离小于锚点与负样本的距离,从而达到区分不同样本的目的。
    定义损失函数
    在三元组目标函数中,我们定义了一个自定义的损失函数,该函数专注于优化模型,使得锚点(anchor)与正样本(positive)之间的嵌入向量距离小于锚点与负样本(negative)之间的嵌入向量距离,至少相差一个预设的边界值(margin)。以下是对该过程的描述:
  1. 损失函数输入:损失函数接收两个参数,positive_distnegative_dist,它们分别代表锚点与正样本之间的距离和锚点与负样本之间的距离。这两个距离值被组织在 y_pred 中,通常是通过某种形式的堆叠。

  2. 损失计算:损失函数的核心是计算 positive_dist - negative_dist + margin 的值。如果这个值是负数,那么损失为0,因为这意味着负样本的距离已经足够大,满足我们的要求。如果这个值是正数,损失就是这个正值,这表示模型需要进一步优化以增加正负样本之间的距离。

  3. 数学表达式:损失函数的数学表达式可以写作 max(positive_dist - negative_dist + margin, 0)。这里的 max 函数确保了损失始终是非负的。

  4. y_true 的角色:在这个特定的三元组损失函数实现中,y_true(真实标签)并没有被使用。尽管在数据集中标签可能被设置为零,但在计算损失时它们并不参与。

  5. 损失函数的目的:通过最小化这个损失函数,我们鼓励模型学习到能够区分不同类别的嵌入表示,使得同一类别内的样本(如锚点和正样本)更接近,而不同类别的样本(如锚点和负样本)更远离。

在实现这个损失函数时,可以使用深度学习框架中的相应函数来构建,例如使用 TensorFlow 或 Keras。这样的损失函数对于训练孪生网络以识别和区分相似或不相似的样本对特别有用,常见于诸如相似性度量、人脸识别等应用场景。

# 定义TripletLoss类,继承自keras的Loss类
class TripletLoss(keras.losses.Loss):
    # 初始化函数,设置margin参数,默认值为1
    def __init__(self, margin=1, **kwargs):
        super().__init__(**kwargs)  # 调用父类的初始化函数
        self.margin = margin  # 保存margin值

    # call函数,用于计算三元组损失
    def call(self, y_true, y_pred):
        # 从预测结果中解包出正样本距离和负样本距离
        positive_dist, negative_dist = tf.unstack(y_pred, axis=0)

        # 计算损失,如果正样本距离减去负样本距离小于margin,则损失为0
        # 否则损失为正样本距离与负样本距离之差加上margin
        losses = keras.ops.relu(positive_dist - negative_dist + self.margin)
        
        # 返回所有损失的平均值
        return keras.ops.mean(losses, axis=0)

代码功能:

  • TripletLoss类实现了一个自定义的损失函数,用于训练孪生网络时的三元组损失计算。
  • __init__方法中,设置了margin参数,它定义了正负样本对之间的最小距离。
  • call方法接收真实标签y_true和预测结果y_pred。在三元组损失中,y_true通常不使用,因为损失只依赖于预测的嵌入向量的距离。
  • 使用tf.unstack将预测结果y_pred解包成正样本距离positive_dist和负样本距离negative_dist
  • 通过keras.ops.relu函数计算损失,确保损失始终为非负值。损失是正样本距离与负样本距离之差加上margin
  • 最后,使用keras.ops.mean计算所有损失的平均值,作为最终的损失值返回。这有助于在训练过程中更新模型的权重。
2.7.4.拟合模型
训练模型

在训练过程中,我们打算采用自定义的三元组损失函数TripletLoss(),并结合Adam优化算法,设置其学习率参数为0.00002,以优化模型的权重。

# 实例化一个带有Roberta编码器的TripletSiamese模型  
roberta_triplet_siamese = TripletSiamese(roberta_encoder)  
  
# 编译模型,使用自定义的TripletLoss作为损失函数,使用Adam优化器,并设置学习率为2e-5  
# 注意:jit_compile参数在TensorFlow 2.x中通常用于即时编译以提高性能,这里我们将其设置为False  
roberta_triplet_siamese.compile(  
    loss=TripletLoss(),  # 假设TripletLoss是一个自定义的损失函数类  
    optimizer=keras.optimizers.Adam(2e-5),  
    jit_compile=False,  # 禁用即时编译  
)  
  
# 训练模型,使用wiki_train作为训练数据,wiki_test作为验证数据,训练一个epoch  
# 注意:wiki_train和wiki_test应该是已经准备好的包含三个句子的数据集,且按照TripletSiamese的输入格式进行预处理  
roberta_triplet_siamese.fit(  
    x=[wiki_train['anchor'], wiki_train['positive'], wiki_train['negative']],  # 假设wiki_train是一个字典,包含'anchor', 'positive', 'negative'键  
    validation_data=([wiki_test['anchor'], wiki_test['positive'], wiki_test['negative']], None),  # 验证数据也需要以相同的方式提供  
    epochs=1  # 训练一个epoch  
)

代码展示了如何使用一个名为TripletSiamese的孪生网络模型,该模型配备了一个基于Roberta的编码器roberta_encoder,来训练一个能够区分不同类别的嵌入向量的模型。

  1. 实例化TripletSiamese模型:首先,创建了一个TripletSiamese模型的实例,传入roberta_encoder作为编码器。

  2. 编译模型:使用自定义的TripletLoss损失函数,以及Adam优化器,学习率设置为2e-5jit_compile参数设置为False,表示不使用即时编译,这在TensorFlow 2.x中可以提高性能,但在这里选择禁用。

  3. 训练模型:使用wiki_train作为训练数据,wiki_test作为验证数据进行模型训练。训练数据和验证数据都应该是按照TripletSiamese模型的输入格式预处理过的,包含三个句子:锚点(anchor)、正样本(positive)和负样本(negative)。

  4. 训练配置:训练配置包括训练数据、验证数据、训练轮数(这里设置为1个epoch)。

  5. 模型训练fit方法用于训练模型,其中x参数接收训练数据,validation_data接收验证数据。注意,验证数据中的第二个元素None表示我们不使用验证标签,因为在三元组损失中通常不使用标签。

代码的功能是训练一个孪生网络模型,使其能够学习到将语义相似的句子映射到相近的嵌入空间位置,而将语义不相似的句子映射到较远的位置。这种模型在诸如相似句子检测、句子相似度度量等任务中非常有用。

评估模型

我们来将此模型应用于一个聚类任务中,以观察其效果。我们有6个问题,其中前三个与学习英语相关,后三个与在线工作相关。我们将通过编码器生成的嵌入向量来检验模型是否能够准确地将这些问题进行分类。

# 定义问题列表  
questions = [  
    "我应该如何改进我的英语写作?",  
    "如何擅长说英语?",  
    "我如何能提高我的英语水平?",  
    "如何在网上赚钱?",  
    "我怎样在网上赚钱?",  
    "如何通过互联网工作和赚钱?",  
]  
  
# 获取编码器  
encoder = roberta_triplet_siamese.get_encoder()  
  
# 将问题列表转换为张量并获取其嵌入  
# 注意:这里假设roberta_encoder的输出可以直接用于KMeans聚类  
# 实际情况中可能需要额外的处理,如取平均嵌入等  
questions_tensor = tf.constant(questions)  
embeddings = encoder(questions_tensor)  # 假设encoder的输出是可以直接用于聚类的嵌入  
  
# 使用KMeans进行聚类,设定聚类数量为2  
kmeans = tf.keras.clustering.KMeans(num_clusters=2, random_state=0, n_init="auto").fit(embeddings)  
  
# 遍历问题和对应的聚类标签,并打印结果  
for i, label in enumerate(kmeans.labels_):  
    print(f"句子({questions[i]})属于聚类 {label}")

代码演示了如何使用一个训练好的孪生网络模型中的编码器部分来获取一系列问题的句子嵌入,然后使用KMeans聚类算法对这些嵌入进行聚类。

  1. 定义问题列表:创建了一个包含6个问题的列表,其中前三个问题与学习英语相关,后三个问题与在线赚钱相关。

  2. 获取编码器:通过TripletSiamese模型的get_encoder方法获取了用于生成嵌入向量的编码器。

  3. 转换问题为张量并获取嵌入:将问题列表转换为TensorFlow张量,然后使用编码器获取每个问题的句子嵌入。这里假设编码器的输出可以直接用于KMeans聚类。

  4. 使用KMeans进行聚类:使用TensorFlow的KMeans聚类算法,设置聚类数量为2,然后对句子嵌入进行聚类。

  5. 遍历问题和聚类标签:遍历问题列表和KMeans算法返回的聚类标签,打印每个问题及其对应的聚类结果。

代码的功能是将一系列问题的句子嵌入通过KMeans聚类算法分为两个类别,以此来检验编码器生成的句子嵌入是否能够反映出问题的语义相似性。例如,如果编码器的性能良好,那么与学习英语相关的问题应该被聚为一类,与在线赚钱相关的问题应该被聚为另一类。

句子 (我应该如何改进我的英语写作?) 属于聚类 1
句子 (如何擅长说英语?) 属于聚类 1
句子 (我如何能提高我的英语水平?) 属于聚类 1
句子 (如何在网上赚钱?) 属于聚类 0
句子 (我怎样在网上赚钱?) 属于聚类 0
句子 (如何通过互联网工作和赚钱?) 属于聚类 0

3.总结和展望

3.1 总结

在本研究中,我们探讨了利用RoBERTa模型进行句子嵌入的方法,并通过孪生网络结构对模型进行了微调,以提高其在语义文本相似性任务中的性能。以下是我们的主要发现和结论:

  1. RoBERTa模型的优势:RoBERTa模型通过使用更大规模的数据集、优化的数据预处理、动态遮蔽策略以及移除下一句预测任务,在多个NLP任务上取得了比BERT更高的性能。

  2. 句子嵌入技术:句子嵌入技术有效地将文本数据转换为数值向量,保留了文本的语义信息,并通过降维提高了计算效率,广泛应用于多种NLP任务。

  3. 孪生网络结构:孪生网络通过最小化正样本对的嵌入距离和最大化负样本对的嵌入距离,学习生成具有丰富语义信息的句子嵌入。

  4. 模型微调:通过在特定数据集上微调RoBERTa模型,我们能够提升模型在特定任务上的表现。例如,在STSB数据集上微调后,模型在评估句子对的语义相似度上取得了良好的性能。

  5. 三元组损失函数:自定义的三元组损失函数有效地指导了孪生网络的训练,使得模型能够区分语义相近和语义相远的句子对。

  6. 聚类任务的应用:经过微调的RoBERTa模型在聚类任务中展现出了良好的性能,能够根据句子的语义信息将它们正确地分入不同的类别。

3.2 展望

尽管本研究取得了积极的成果,但仍有一些潜在的改进方向和未来的工作机会:

  1. 模型泛化能力:在未来的研究中,可以探索模型在更多样化的数据集上的泛化能力,并针对不同的语言和领域进行适应性调整。

  2. 计算效率:尽管使用了混合精度和JIT编译等技术,模型训练和推理的计算效率仍有提升空间,特别是在处理大规模数据集时。

  3. 模型解释性:提高模型的可解释性,帮助研究人员和用户更好地理解模型的决策过程和嵌入空间的结构。

  4. 多任务学习:探索模型在多任务学习框架下的性能,同时在多个相关任务上进行训练,以提高模型的通用性和效率。

  5. 跨语言能力:研究和开发跨语言的句子嵌入模型,以支持更广泛的应用场景和多语言环境。

  6. 模型压缩和加速:研究模型剪枝、量化等压缩技术,以及模型蒸馏等加速策略,使模型更适合部署在资源受限的环境中。

  7. 伦理和偏见:持续关注和评估模型在处理不同群体语言时的公平性和偏见问题,确保技术的健康发展和广泛应用。

通过这些展望,我们期待在未来的研究中能够进一步提升句子嵌入技术的性能和应用范围,为NLP领域带来更多的创新和价值。

参考文献

[1]Keras. (2023-7-14). Sentence Embeddings with SBERT. Keras.io. Retrieved from https://keras.io/examples/nlp/sentence_embeddings_with_sbert/

附录1:实验代码

"""
## Introduction

BERT and RoBERTa can be used for semantic textual similarity tasks, where two sentences
are passed to the model and the network predicts whether they are similar or not. But
what if we have a large collection of sentences and want to find the most similar pairs
in that collection? That will take n*(n-1)/2 inference computations, where n is the
number of sentences in the collection. For example, if n = 10000, the required time will
be 65 hours on a V100 GPU.

A common method to overcome the time overhead issue is to pass one sentence to the model,
then average the output of the model, or take the first token (the [CLS] token) and use
them as a [sentence embedding](https://en.wikipedia.org/wiki/Sentence_embedding),   then
use a vector similarity measure like cosine similarity or Manhatten / Euclidean distance
to find close sentences (semantically similar sentences). That will reduce the time to
find the most similar pairs in a collection of 10,000 sentences from 65 hours to 5
seconds!

If we use RoBERTa directly, that will yield rather bad sentence embeddings. But if we
fine-tune RoBERTa using a Siamese network, that will generate semantically meaningful
sentence embeddings. This will enable RoBERTa to be used for new tasks. These tasks
include:

- Large-scale semantic similarity comparison.
- Clustering.
- Information retrieval via semantic search.

In this example, we will show how to fine-tune a RoBERTa model using a Siamese network
such that it will be able to produce semantically meaningful sentence embeddings and use
them in a semantic search and clustering example.
This method of fine-tuning was introduced in
[Sentence-BERT](https://arxiv.org/abs/1908.10084)  
"""

"""
## Setup

Let's install and import the libraries we need. We'll be using the KerasNLP library in
this example.

We will also enable [mixed precision](https://www.tensorflow.org/guide/mixed_precision)  
training. This will help us reduce the training time.
"""

"""shell
pip install -q --upgrade keras-nlp
pip install -q --upgrade keras  # Upgrade to Keras 3.
"""

import os

os.environ["KERAS_BACKEND"] = "tensorflow"

import keras
import keras_nlp
import tensorflow as tf
import tensorflow_datasets as tfds
import sklearn.cluster as cluster

keras.mixed_precision.set_global_policy("mixed_float16")

"""
## Fine-tune the model using siamese networks

[Siamese network](https://en.wikipedia.org/wiki/Siamese_neural_network)   is a neural
network architecture that contains two or more subnetworks. The subnetworks share the
same weights. It is used to generate feature vectors for each input and then compare them
for similarity.

For our example, the subnetwork will be a RoBERTa model that has a pooling layer on top
of it to produce the embeddings of the input sentences. These embeddings will then be
compared to each other to learn to produce semantically meaningful embeddings.

The pooling strategies used are mean, max, and CLS pooling. Mean pooling produces the
best results. We will use it in our examples.
"""

"""
### Fine-tune using the regression objective function

For building the siamese network with the regression objective function, the siamese
network is asked to predict the cosine similarity between the embeddings of the two input
sentences.

Cosine similarity indicates the angle between the sentence embeddings. If the cosine
similarity is high, that means there is a small angle between the embeddings; hence, they
are semantically similar.
"""

"""
#### Load the dataset

We will use the STSB dataset to fine-tune the model for the regression objective. STSB
consists of a collection of sentence pairs that are labelled in the range [0, 5]. 0
indicates the least semantic similarity between the two sentences, and 5 indicates the
most semantic similarity between the two sentences.

The range of the cosine similarity is [-1, 1] and it's the output of the siamese network,
but the range of the labels in the dataset is [0, 5]. We need to unify the range between
the cosine similarity and the dataset labels, so while preparing the dataset, we will
divide the labels by 2.5 and subtract 1.
"""

TRAIN_BATCH_SIZE = 6
VALIDATION_BATCH_SIZE = 8

TRAIN_NUM_BATCHES = 300
VALIDATION_NUM_BATCHES = 40

AUTOTUNE = tf.data.experimental.AUTOTUNE


def change_range(x):
    return (x / 2.5) - 1


def prepare_dataset(dataset, num_batches, batch_size):
    dataset = dataset.map(
        lambda z: (
            [z["sentence1"], z["sentence2"]],
            [tf.cast(change_range(z["label"]), tf.float32)],
        ),
        num_parallel_calls=AUTOTUNE,
    )
    dataset = dataset.batch(batch_size)
    dataset = dataset.take(num_batches)
    dataset = dataset.prefetch(AUTOTUNE)
    return dataset


stsb_ds = tfds.load(
    "glue/stsb",
)
stsb_train, stsb_valid = stsb_ds["train"], stsb_ds["validation"]

stsb_train = prepare_dataset(stsb_train, TRAIN_NUM_BATCHES, TRAIN_BATCH_SIZE)
stsb_valid = prepare_dataset(stsb_valid, VALIDATION_NUM_BATCHES, VALIDATION_BATCH_SIZE)

"""
Let's see examples from the dataset of two sentenses and their similarity.
"""

for x, y in stsb_train:
    for i, example in enumerate(x):
        print(f"sentence 1 : {example[0]} ")
        print(f"sentence 2 : {example[1]} ")
        print(f"similarity : {y[i]} \n")
    break

"""
#### Build the encoder model.

Now, we'll build the encoder model that will produce the sentence embeddings. It consists
of:

- A preprocessor layer to tokenize and generate padding masks for the sentences.
- A backbone model that will generate the contextual representation of each token in the
sentence.
- A mean pooling layer to produce the embeddings. We will use `keras.layers.GlobalAveragePooling1D`
to apply the mean pooling to the backbone outputs. We will pass the padding mask to the
layer to exclude padded tokens from being averaged.
- A normalization layer to normalize the embeddings as we are using the cosine similarity.
"""

preprocessor = keras_nlp.models.RobertaPreprocessor.from_preset("roberta_base_en")
backbone = keras_nlp.models.RobertaBackbone.from_preset("roberta_base_en")
inputs = keras.Input(shape=(1,), dtype="string", name="sentence")
x = preprocessor(inputs)
h = backbone(x)
embedding = keras.layers.GlobalAveragePooling1D(name="pooling_layer")(
    h, x["padding_mask"]
)
n_embedding = keras.layers.UnitNormalization(axis=1)(embedding)
roberta_normal_encoder = keras.Model(inputs=inputs, outputs=n_embedding)

roberta_normal_encoder.summary()

"""
#### Build the Siamese network with the regression objective function.

It's described above that the Siamese network has two or more subnetworks, and for this
Siamese model, we need two encoders. But we don't have two encoders; we have only one
encoder, but we will pass the two sentences through it. That way, we can have two paths
to get the embeddings and also shared weights between the two paths.

After passing the two sentences to the model and getting the normalized embeddings, we
will multiply the two normalized embeddings to get the cosine similarity between the two
sentences.
"""


class RegressionSiamese(keras.Model):
    def __init__(self, encoder, **kwargs):
        inputs = keras.Input(shape=(2,), dtype="string", name="sentences")
        sen1, sen2 = keras.ops.split(inputs, 2, axis=1)
        u = encoder(sen1)
        v = encoder(sen2)
        cosine_similarity_scores = keras.ops.matmul(u, keras.ops.transpose(v))

        super().__init__(
            inputs=inputs,
            outputs=cosine_similarity_scores,
            **kwargs,
        )

        self.encoder = encoder

    def get_encoder(self):
        return self.encoder


"""
#### Fit the model

Let's try this example before training and compare it to the output after training.
"""

sentences = [
    "Today is a very sunny day.",
    "I am hungry, I will get my meal.",
    "The dog is eating his food.",
]
query = ["The dog is enjoying his meal."]

encoder = roberta_normal_encoder

sentence_embeddings = encoder(tf.constant(sentences))
query_embedding = encoder(tf.constant(query))

cosine_similarity_scores = tf.matmul(query_embedding, tf.transpose(sentence_embeddings))
for i, sim in enumerate(cosine_similarity_scores[0]):
    print(f"cosine similarity score between sentence {i+1} and the query = {sim} ")

"""
For the training we will use `MeanSquaredError()` as loss function, and `Adam()`
optimizer with learning rate = 2e-5.
"""

roberta_regression_siamese = RegressionSiamese(roberta_normal_encoder)

roberta_regression_siamese.compile(
    loss=keras.losses.MeanSquaredError(),
    optimizer=keras.optimizers.Adam(2e-5),
    jit_compile=False,
)

roberta_regression_siamese.fit(stsb_train, validation_data=stsb_valid, epochs=1)

"""
Let's try the model after training, we will notice a huge difference in the output. That
means that the model after fine-tuning is capable of producing semantically meaningful
embeddings. where the semantically similar sentences have a small angle between them. and
semantically dissimilar sentences have a large angle between them.
"""

sentences = [
    "Today is a very sunny day.",
    "I am hungry, I will get my meal.",
    "The dog is eating his food.",
]
query = ["The dog is enjoying his food."]

encoder = roberta_regression_siamese.get_encoder()

sentence_embeddings = encoder(tf.constant(sentences))
query_embedding = encoder(tf.constant(query))

cosine_simalarities = tf.matmul(query_embedding, tf.transpose(sentence_embeddings))
for i, sim in enumerate(cosine_simalarities[0]):
    print(f"cosine similarity between sentence {i+1} and the query = {sim} ")

"""
### Fine-tune Using the triplet Objective Function

For the Siamese network with the triplet objective function, three sentences are passed
to the Siamese network *anchor*, *positive*, and *negative* sentences. *anchor* and
*positive* sentences are semantically similar, and *anchor* and *negative* sentences are
semantically dissimilar. The objective is to minimize the distance between the *anchor*
sentence and the *positive* sentence, and to maximize the distance between the *anchor*
sentence and the *negative* sentence.
"""

"""
#### Load the dataset

We will use the Wikipedia-sections-triplets dataset for fine-tuning. This data set
consists of sentences derived from the Wikipedia website. It has a collection of 3
sentences *anchor*, *positive*, *negative*. *anchor* and *positive* are derived from the
same section. *anchor* and *negative* are derived from different sections.

This dataset has 1.8 million training triplets and 220,000 test triplets. In this
example, we will only use 1200 triplets for training and 300 for testing.
"""

"""shell
wget https://sbert.net/datasets/wikipedia-sections-triplets.zip   -q
unzip wikipedia-sections-triplets.zip  -d  wikipedia-sections-triplets
"""

NUM_TRAIN_BATCHES = 200
NUM_TEST_BATCHES = 75
AUTOTUNE = tf.data.experimental.AUTOTUNE


def prepare_wiki_data(dataset, num_batches):
    dataset = dataset.map(
        lambda z: ((z["Sentence1"], z["Sentence2"], z["Sentence3"]), 0)
    )
    dataset = dataset.batch(6)
    dataset = dataset.take(num_batches)
    dataset = dataset.prefetch(AUTOTUNE)
    return dataset


wiki_train = tf.data.experimental.make_csv_dataset(
    "wikipedia-sections-triplets/train.csv",
    batch_size=1,
    num_epochs=1,
)
wiki_test = tf.data.experimental.make_csv_dataset(
    "wikipedia-sections-triplets/test.csv",
    batch_size=1,
    num_epochs=1,
)

wiki_train = prepare_wiki_data(wiki_train, NUM_TRAIN_BATCHES)
wiki_test = prepare_wiki_data(wiki_test, NUM_TEST_BATCHES)

"""
#### Build the encoder model

For this encoder model, we will use RoBERTa with mean pooling and we will not normalize
the output embeddings. The encoder model consists of:

- A preprocessor layer to tokenize and generate padding masks for the sentences.
- A backbone model that will generate the contextual representation of each token in the
sentence.
- A mean pooling layer to produce the embeddings.
"""

preprocessor = keras_nlp.models.RobertaPreprocessor.from_preset("roberta_base_en")
backbone = keras_nlp.models.RobertaBackbone.from_preset("roberta_base_en")
input = keras.Input(shape=(1,), dtype="string", name="sentence")

x = preprocessor(input)
h = backbone(x)
embedding = keras.layers.GlobalAveragePooling1D(name="pooling_layer")(
    h, x["padding_mask"]
)

roberta_encoder = keras.Model(inputs=input, outputs=embedding)


roberta_encoder.summary()

"""
#### Build the Siamese network with the triplet objective function

For the Siamese network with the triplet objective function, we will build the model with
an encoder, and we will pass the three sentences through that encoder. We will get an
embedding for each sentence, and we will calculate the `positive_dist` and
`negative_dist` that will be passed to the loss function described below.
"""


class TripletSiamese(keras.Model):
    def __init__(self, encoder, **kwargs):
        anchor = keras.Input(shape=(1,), dtype="string")
        positive = keras.Input(shape=(1,), dtype="string")
        negative = keras.Input(shape=(1,), dtype="string")

        ea = encoder(anchor)
        ep = encoder(positive)
        en = encoder(negative)

        positive_dist = keras.ops.sum(keras.ops.square(ea - ep), axis=1)
        negative_dist = keras.ops.sum(keras.ops.square(ea - en), axis=1)

        positive_dist = keras.ops.sqrt(positive_dist)
        negative_dist = keras.ops.sqrt(negative_dist)

        output = keras.ops.stack([positive_dist, negative_dist], axis=0)

        super().__init__(inputs=[anchor, positive, negative], outputs=output, **kwargs)

        self.encoder = encoder

    def get_encoder(self):
        return self.encoder


"""
We will use a custom loss function for the triplet objective. The loss function will
receive the distance between the *anchor* and the *positive* embeddings `positive_dist`,
and the distance between the *anchor* and the *negative* embeddings `negative_dist`,
where they are stacked together in `y_pred`.

We will use `positive_dist` and `negative_dist` to compute the loss such that
`negative_dist` is larger than `positive_dist` at least by a specific margin.
Mathematically, we will minimize this loss function: `max( positive_dist - negative_dist
+ margin, 0)`.

There is no `y_true` used in this loss function. Note that we set the labels in the
dataset to zero, but they will not be used.
"""


class TripletLoss(keras.losses.Loss):
    def __init__(self, margin=1, **kwargs):
        super().__init__(**kwargs)
        self.margin = margin

    def call(self, y_true, y_pred):
        positive_dist, negative_dist = tf.unstack(y_pred, axis=0)

        losses = keras.ops.relu(positive_dist - negative_dist + self.margin)
        return keras.ops.mean(losses, axis=0)


"""
#### Fit the model

For the training, we will use the custom `TripletLoss()` loss function, and `Adam()`
optimizer with a learning rate = 2e-5.
"""

roberta_triplet_siamese = TripletSiamese(roberta_encoder)

roberta_triplet_siamese.compile(
    loss=TripletLoss(),
    optimizer=keras.optimizers.Adam(2e-5),
    jit_compile=False,
)

roberta_triplet_siamese.fit(wiki_train, validation_data=wiki_test, epochs=1)

"""
Let's try this model in a clustering example. Here are 6 questions. first 3 questions
about learning English, and the last 3 questions about working online. Let's see if the
embeddings produced by our encoder will cluster them correctly.
"""

questions = [
    "What should I do to improve my English writting?",
    "How to be good at speaking English?",
    "How can I improve my English?",
    "How to earn money online?",
    "How do I earn money online?",
    "How to work and earn money through internet?",
]

encoder = roberta_triplet_siamese.get_encoder()
embeddings = encoder(tf.constant(questions))
kmeans = cluster.KMeans(n_clusters=2, random_state=0, n_init="auto").fit(embeddings)

for i, label in enumerate(kmeans.labels_):
    print(f"sentence ({questions[i]}) belongs to cluster {label}")
  • 32
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

MUKAMO

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

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

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

打赏作者

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

抵扣说明:

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

余额充值