代码示例 / 自然语言处理 / 使用Siamese RoBERTa网络的句子嵌入

使用Siamese RoBERTa网络的句子嵌入

作者: Mohammed Abu El-Nasr
创建日期: 2023/07/14
最后修改: 2023/07/14
描述: 使用KerasNLP对RoBERTa模型进行微调以生成句子嵌入。

在Colab中查看 GitHub源代码


介绍

BERT和RoBERTa可以用于语义文本相似性任务,其中两个句子被传递给模型,网络预测它们是否相似。但是如果我们有一个大型句子集合并希望在该集合中找到最相似的对呢?这将需要n*(n-1)/2的推理计算,其中n是集合中的句子数量。例如,如果n = 10000,则所需时间将在V100 GPU上达到65小时。

解决时间开销问题的一个常见方法是将一个句子传递给模型,然后平均模型的输出,或者取第一个令牌([CLS]令牌)并将其用作句子嵌入,然后使用向量相似性度量(如余弦相似性或曼哈顿/欧几里得距离)来寻找接近的句子(语义相似句子)。这将使在10000个句子集合中寻找最相似的对的时间从65小时减少到5秒!

如果我们直接使用RoBERTa,这将产生相当糟糕的句子嵌入。但如果我们使用Siamese网络对RoBERTa进行微调,这将生成语义上有意义的句子嵌入。这将使RoBERTa能够用于新的任务。这些任务包括:

  • 大规模语义相似性比较。
  • 聚类。
  • 通过语义搜索进行信息检索。

在本示例中,我们将展示如何使用Siamese网络微调RoBERTa模型,使其能够生成语义上有意义的句子嵌入,并在语义搜索和聚类示例中使用它们。 这种微调方法在Sentence-BERT中引入。


设置

让我们安装并导入所需的库。在本示例中,我们将使用KerasNLP库。

我们还将启用混合精度训练。这将帮助我们减少训练时间。

!pip install -q --upgrade keras-nlp
!pip install -q --upgrade keras  # 升级到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")

使用Siamese网络微调模型

Siamese网络是一种神经网络架构,包含两个或更多子网络。子网络共享相同的权重。它用于为每个输入生成特征向量,然后比较它们的相似性。

在我们的示例中,子网络将是一个RoBERTa模型,上面有一个池化层,用于生成输入句子的嵌入。这些嵌入随后将彼此比较,以学习生成语义上有意义的嵌入。

使用的池化策略有平均池化、最大池化和CLS池化。平均池化产生最佳结果。我们将在我们的示例中使用它。

使用回归目标函数进行微调

为了使用回归目标函数构建Siamese网络,Siamese网络被要求预测两个输入句子的嵌入之间的余弦相似性。

余弦相似性表示句子嵌入之间的角度。如果余弦相似性很高,则意味着嵌入之间的角度很小;因此,它们在语义上是相似的。

加载数据集

我们将使用STSB数据集来对模型进行回归目标的微调。STSB由一组句子对组成,这些句子对的标签范围为[0, 5]。0表示两个句子之间的语义相似度最小,而5表示两个句子之间的语义相似度最大。

余弦相似性的范围是[-1, 1],这是Siamese网络的输出,但数据集中的标签范围是[0, 5]。我们需要统一定义余弦相似性和数据集标签之间的范围,因此在准备数据集时,我们将标签除以2.5并减去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)

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

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
sentence 1 : b"A young girl is sitting on Santa's lap." 
sentence 2 : b"A little girl is sitting on Santa's lap" 
similarity : [0.9200001] 
sentence 1 : b'A women sitting at a table drinking with a basketball picture in the background.' 
sentence 2 : b'A woman in a sari drinks something while sitting at a table.' 
similarity : [0.03999996] 
sentence 1 : b'Norway marks anniversary of massacre' 
sentence 2 : b"Norway Marks Anniversary of Breivik's Massacre" 
similarity : [0.52] 
sentence 1 : b'US drone kills six militants in Pakistan: officials' 
sentence 2 : b'US missiles kill 15 in Pakistan: officials' 
similarity : [-0.03999996] 
sentence 1 : b'On Tuesday, the central bank left interest rates steady, as expected, but also declared that overall risks were weighted toward weakness and warned of deflation risks.' 
sentence 2 : b"The central bank's policy board left rates steady for now, as widely expected, but surprised the market by declaring that overall risks were weighted toward weakness." 
similarity : [0.6] 
sentence 1 : b'At one of the three sampling sites at Huntington Beach, the bacteria reading came back at 160 on June 16 and at 120 on June 23.' 
sentence 2 : b'The readings came back at 160 on June 16 and 120 at June 23 at one of three sampling sites at Huntington Beach.' 
similarity : [0.29999995] 

构建编码器模型。

现在,我们将构建编码器模型,以生成句子嵌入。它由以下部分组成:

  • 一个预处理层,用于对句子进行标记处理并生成填充掩码。
  • 一个骨干模型,用于生成句子中每个标记的上下文表示。
  • 一个平均池化层,用于生成嵌入。我们将使用 keras.layers.GlobalAveragePooling1D 将平均池化应用于骨干输出。我们将填充掩码传递给该层,以排除填充标记的平均值计算。
  • 一个归一化层,用于归一化嵌入,因为我们使用余弦相似度。
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()
模型: "functional_1"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓
┃ 层 (类型)         输出形状       参数 #  连接到         ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩
│ sentence            │ (None, 1)         │       0 │ -                    │
│ (InputLayer)        │                   │         │                      │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ roberta_preprocess… │ [(None, 512),     │       0 │ sentence[0][0]       │
│ (RobertaPreprocess… │ (, 512)]      │         │                      │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ roberta_backbone    │ (, 512, 768)  │ 124,05… │ roberta_preprocesso… │
│ (RobertaBackbone)   │                   │         │ roberta_preprocesso… │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ pooling_layer       │ (, 768)       │       0 │ roberta_backbone[0]… │
│ (GlobalAveragePool… │                   │         │ roberta_preprocesso… │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ unit_normalization  │ (, 768)       │       0 │ pooling_layer[0][0]  │
│ (UnitNormalization) │                   │         │                      │
└─────────────────────┴───────────────────┴─────────┴──────────────────────┘
 总参数: 124,052,736 (473.22 MB)
 可训练参数: 124,052,736 (473.22 MB)
 非训练参数: 0 (0.00 B)

构建带有回归目标函数的Siamese网络。

如上所述,Siamese网络有两个或多个子网络,对于这个Siamese模型,我们需要两个编码器。但我们只有一个编码器,我们将通过它传递两个句子。这样,我们可以有两个路径来获取嵌入,并在这两条路径之间共享权重。

在将两个句子传递给模型并获取归一化的嵌入后,我们将相乘这两个归一化的嵌入,以获得两个句子之间的余弦相似度。

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

训练模型

在训练之前,让我们尝试这个例子,并将其与训练后的输出进行比较。

sentences = [
    "今天是非常阳光明媚的一天。",
    "我饿了,我要去吃饭。",
    "狗在吃他的食物。",
]
query = ["狗在享受他的饭。"]

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} ")
句子1与查询的余弦相似度得分 = 0.96630859375 
句子2与查询的余弦相似度得分 = 0.97607421875 
句子3与查询的余弦相似度得分 = 0.99365234375 

对于训练,我们将使用 MeanSquaredError() 作为损失函数,并使用学习率为 2e-5 的 Adam() 优化器。

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)
 300/300 ━━━━━━━━━━━━━━━━━━━━ 115s 297ms/step - loss: 0.4751 - val_loss: 0.4025

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

训练后让我们尝试一下模型,我们将注意到输出的巨大差异。这意味着经过微调后的模型能够生成语义上有意义的嵌入,其中语义相似的句子之间角度很小,而语义不相似的句子之间角度很大。

sentences = [
    "今天是一个非常阳光明媚的日子。",
    "我饿了,我要去吃饭。",
    "狗在吃他的食物。",
]
query = ["狗在享受他的食物。"]

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"查询与句子 {i+1} 之间的余弦相似度 = {sim} ")
查询与句子 1 之间的余弦相似度 = 0.10986328125 
查询与句子 2 之间的余弦相似度 = 0.53466796875 
查询与句子 3 之间的余弦相似度 = 0.83544921875 

使用三元组目标函数进行微调

对于具有三元组目标函数的孪生网络,三个句子被传递给孪生网络 anchorpositivenegative 句子。anchorpositive 句子是语义相似的,而 anchornegative 句子是语义不相似的。其目标是最小化 anchor 句子与 positive 句子之间的距离,同时最大化 anchor 句子与 negative 句子之间的距离。

加载数据集

我们将使用 Wikipedia-sections-triplets 数据集进行微调。该数据集由来自维基百科网站的句子构成。它包含三句 anchorpositivenegative 的句子。anchorpositive 来源于同一部分,而 anchornegative 来源于不同部分。

该数据集有 180 万个训练三元组和 22 万个测试三元组。在这个例子中,我们将只使用 1200 个三元组进行训练,300 个进行测试。

!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)
Archive:  wikipedia-sections-triplets.zip
  inflating: wikipedia-sections-triplets/validation.csv  
  inflating: wikipedia-sections-triplets/Readme.txt  
  inflating: wikipedia-sections-triplets/test.csv  
  inflating: wikipedia-sections-triplets/train.csv  

构建编码器模型

对于这个编码器模型,我们将使用带有均值池化的 RoBERTa,并且不对输出嵌入进行归一化。编码器模型包括:

  • 用于对句子进行标记化和生成填充掩码的预处理层。
  • 生成句子中每个标记上下文表示的主干模型。
  • 用于生成嵌入的均值池化层。
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()
模型: "functional_3"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓
┃ 层 (类型)            输出形状        参数 #  连接到             ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩
│ 句子               │ (, 1)         │       0 │ -                    │
│ (输入层)        │                   │         │                      │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ roberta_preprocess… │ [(, 512),     │       0 │ 句子[0][0]       │
│ (Roberta预处理… │ (, 512)]      │         │                      │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ roberta_backbone_1  │ (, 512, 768)  │ 124,05… │ roberta_preprocesso… │
│ (Roberta主干)   │                   │         │ roberta_preprocesso… │
├─────────────────────┼───────────────────┼─────────┼──────────────────────┤
│ pooling_layer       │ (, 768)       │       0 │ roberta_backbone_1[ │
│ (全局平均池化… │                   │         │ roberta_preprocesso… │
└─────────────────────┴───────────────────┴─────────┴──────────────────────┘
 总参数: 124,052,736 (473.22 MB)
 可训练参数: 124,052,736 (473.22 MB)
 不可训练参数: 0 (0.00 B)

构建具有三元组目标函数的Siamese网络

对于具有三元组目标函数的Siamese网络,我们将构建一个带有编码器的模型,并将三个句子传递给该编码器。我们将为每个句子获得一个嵌入,并计算将传递给下面描述的损失函数的positive_distnegative_dist

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

我们将为三元组目标使用自定义损失函数。该损失函数将接收 anchorpositive 嵌入之间的距离 positive_dist,以及 anchornegative 嵌入之间的距离 negative_dist,它们将被组合在 y_pred 中。

我们将使用 positive_distnegative_dist 来计算损失,以确保 negative_dist 至少比 positive_dist 大一个特定的边际。数学上,我们将最小化这个损失函数:max( positive_dist - negative_dist + margin, 0)

该损失函数中没有使用 y_true。请注意,我们在数据集中将标签设置为零,但不会使用它们。

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)

拟合模型

对于训练,我们将使用自定义的 TripletLoss() 损失函数,以及学习率为 2e-5 的 Adam() 优化器。

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)
 200/200 ━━━━━━━━━━━━━━━━━━━━ 128s 467ms/step - loss: 0.7822 - val_loss: 0.7126

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

让我们在一个聚类示例中尝试这个模型。这里有 6 个问题。前 3 个问题关于学习英语,最后 3 个问题关于在线工作。让我们看看我们编码器产生的嵌入是否能正确地将它们聚类。

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}")
sentence (What should I do to improve my English writting?) belongs to cluster 1
sentence (How to be good at speaking English?) belongs to cluster 1
sentence (How can I improve my English?) belongs to cluster 1
sentence (How to earn money online?) belongs to cluster 0
sentence (How do I earn money online?) belongs to cluster 0
sentence (How to work and earn money through internet?) belongs to cluster 0