程式碼範例 / 自然語言處理 / 使用 Siamese RoBERTa 網路的句子嵌入

使用 Siamese RoBERTa 網路的句子嵌入

作者: Mohammed Abu El-Nasr
建立日期 2023/07/14
上次修改日期 2023/07/14
描述: 使用 KerasHub 微調 RoBERTa 模型以產生句子嵌入。

ⓘ 此範例使用 Keras 3

在 Colab 中檢視 GitHub 原始碼


簡介

BERT 和 RoBERTa 可以用於語義文本相似性任務,其中將兩個句子傳遞給模型,並且網路預測它們是否相似。但是,如果我們有大量的句子集合,並且想要找到該集合中最相似的配對呢?這將需要 n*(n-1)/2 次推論計算,其中 n 是集合中句子的數量。例如,如果 n = 10000,則在 V100 GPU 上所需的時間將為 65 小時。

克服時間開銷問題的常用方法是將一個句子傳遞給模型,然後對模型的輸出取平均值,或者取得第一個詞符([CLS] 詞符)並將它們用作句子嵌入,然後使用向量相似度度量(例如餘弦相似度或曼哈頓/歐幾里得距離)來尋找接近的句子(語義上相似的句子)。這會將在 10,000 個句子的集合中找到最相似配對的時間從 65 小時縮短到 5 秒!

如果我們直接使用 RoBERTa,會產生相當差的句子嵌入。但是,如果我們使用 Siamese 網路微調 RoBERTa,則會產生語義上有意義的句子嵌入。這將使 RoBERTa 可用於新的任務。這些任務包括

  • 大規模語義相似度比較。
  • 分群。
  • 透過語義搜尋進行資訊檢索。

在此範例中,我們將展示如何使用 Siamese 網路微調 RoBERTa 模型,使其能夠產生語義上有意義的句子嵌入,並在語義搜尋和分群範例中使用它們。這種微調方法是在 Sentence-BERT 中介紹的


設定

讓我們安裝並匯入我們需要的函式庫。在此範例中,我們將使用 KerasHub 函式庫。

我們也將啟用 混合精度訓練。這將幫助我們縮短訓練時間。

!pip install -q --upgrade keras-hub
!pip install -q --upgrade keras  # Upgrade to Keras 3.
import os

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

import keras
import keras_hub
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_hub.models.RobertaPreprocessor.from_preset("roberta_base_en")
backbone = keras_hub.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()
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)

使用迴歸目標函數建立 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 = [
    "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} ")
cosine similarity score between sentence 1 and the query = 0.96630859375 
cosine similarity score between sentence 2 and the query = 0.97607421875 
cosine similarity score between sentence 3 and the query = 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 = [
    "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} ")
cosine similarity between sentence 1 and the query = 0.10986328125 
cosine similarity between sentence 2 and the query = 0.53466796875 
cosine similarity between sentence 3 and the query = 0.83544921875 

使用三元組目標函數進行微調

對於具有三元組目標函數的 Siamese 網路,三個句子被傳遞給 Siamese 網路錨點正例負例句子。錨點正例句子在語義上相似,而錨點負例句子在語義上不相似。目標是最小化錨點句子和正例句子之間的距離,並最大化錨點句子和負例句子之間的距離。

載入資料集

我們將使用 Wikipedia-sections-triplets 資料集進行微調。此資料集包含從維基百科網站衍生的句子。它收集了 3 個句子錨點正例負例錨點正例衍生自同一章節。錨點負例衍生自不同的章節。

此資料集有 180 萬個訓練三元組和 220,000 個測試三元組。在此範例中,我們僅使用 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_hub.models.RobertaPreprocessor.from_preset("roberta_base_en")
backbone = keras_hub.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()
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)

使用三元組目標函數建立 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

我們將為三元組目標使用自訂損失函數。損失函數將接收錨點正例嵌入之間的距離 positive_dist,以及錨點負例嵌入之間的距離 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