作者: Mohammed Abu El-Nasr
建立日期 2023/07/14
上次修改日期 2023/07/14
描述: 使用 KerasHub 微調 RoBERTa 模型以產生句子嵌入。
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 網路是一種神經網路架構,其中包含兩個或多個子網路。子網路共享相同的權重。它用於為每個輸入產生特徵向量,然後比較它們的相似性。
對於我們的範例,子網路將是一個 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 模型,我們需要兩個編碼器。但是我們沒有兩個編碼器;我們只有一個編碼器,但我們會讓兩個句子通過它。這樣,我們就可以有兩個路徑來取得嵌入,並且在兩個路徑之間共享權重。
在將兩個句子傳遞給模型並取得正規化的嵌入之後,我們將將兩個正規化的嵌入相乘,以取得兩個句子之間的餘弦相似度。
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 網路,我們將使用一個編碼器建立模型,並且我們將讓三個句子通過該編碼器。我們將取得每個句子的嵌入,並且我們將計算 positive_dist
和 negative_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_dist
和 negative_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