圖像標題生成與視覺注意力

在 TensorFlow.org 上檢視 在 Google Colab 中執行 在 GitHub 上檢視 下載筆記本

以下圖為例,您的目標是產生標題,例如「衝浪者乘浪」。

一位男士在衝浪,來自 wikimedia

此處使用的模型架構靈感來自 Show, Attend and Tell: Neural Image Caption Generation with Visual Attention,但已更新為使用 2 層 Transformer 解碼器。為了充分利用本教學課程,您應該具備 文字生成seq2seq 模型與注意力機制Transformer 的一些經驗。

本教學課程中建構的模型架構如下所示。特徵從影像中擷取,並傳遞至 Transformer 解碼器的交叉注意力層。

模型架構

Transformer 解碼器主要由注意力層建構而成。它使用自我注意力來處理正在產生的序列,並使用交叉注意力來關注影像。

透過檢查交叉注意力層的注意力權重,您將看到模型在產生文字時關注影像的哪些部分。

Prediction

本筆記本是一個端對端範例。當您執行筆記本時,它會下載資料集、擷取並快取影像特徵,並訓練解碼器模型。然後,它會使用該模型在新影像上產生標題。

設定

apt install --allow-change-held-packages libcudnn8=8.6.0.163-1+cuda11.8
pip uninstall -y tensorflow estimator keras
pip install -U tensorflow_text tensorflow tensorflow_datasets
pip install einops

本教學課程使用大量匯入,主要用於載入資料集。

[選用] 資料處理

本節下載標題資料集並準備用於訓練。它將輸入文字符號化,並快取透過預先訓練的特徵擷取器模型執行所有影像的結果。不一定要理解本節中的所有內容。

資料已準備好用於訓練

經過這些預先處理步驟後,以下是資料集

train_ds = load_dataset('train_cache')
test_ds = load_dataset('test_cache')
train_ds.element_spec

資料集現在傳回適用於 keras 訓練的 (輸入,標籤) 配對。inputs(影像,輸入符號) 配對。images 已使用特徵擷取器模型進行處理。對於 input_tokens 中的每個位置,模型會查看到目前為止的文字,並嘗試預測下一個文字,該文字與 labels 中的相同位置對齊。

for (inputs, ex_labels) in train_ds.take(1):
  (ex_img, ex_in_tok) = inputs

print(ex_img.shape)
print(ex_in_tok.shape)
print(ex_labels.shape)

輸入符號和標籤相同,只是偏移了 1 個步驟

print(ex_in_tok[0].numpy())
print(ex_labels[0].numpy())

Transformer 解碼器模型

此模型假設預先訓練的影像編碼器已足夠,並且僅專注於建構文字解碼器。本教學課程使用 2 層 Transformer 解碼器。

實作幾乎與 Transformer 教學課程 中的實作完全相同。請參閱該教學課程以取得更多詳細資訊。

Transformer 編碼器和解碼器。

模型將在三個主要部分中實作

  1. 輸入 - 符號嵌入和位置編碼 (SeqEmbedding)。
  2. 解碼器 - 一疊 Transformer 解碼器層 (DecoderLayer),其中每個都包含
    1. 因果自我注意力層 (CausalSelfAttention),其中每個輸出位置都可以關注到目前為止的輸出。
    2. 交叉注意力層 (CrossAttention),其中每個輸出位置都可以關注輸入影像。
    3. 前饋網路 (FeedForward) 層,進一步獨立處理每個輸出位置。
  3. 輸出 - 輸出詞彙表上的多類別分類。

輸入

輸入文字已分割成符號並轉換為 ID 序列。

請記住,與 CNN 或 RNN 不同,Transformer 的注意力層對於序列的順序是不變的。如果沒有一些位置輸入,它只會看到一個無序的集合,而不是一個序列。因此,除了每個符號 ID 的簡單向量嵌入之外,嵌入層還將包含序列中每個位置的嵌入。

以下定義的 SeqEmbedding

  • 它會查閱每個符號的嵌入向量。
  • 它會查閱每個序列位置的嵌入向量。
  • 它將兩者加在一起。
  • 它使用 mask_zero=True 初始化模型的 keras 遮罩。
class SeqEmbedding(tf.keras.layers.Layer):
  def __init__(self, vocab_size, max_length, depth):
    super().__init__()
    self.pos_embedding = tf.keras.layers.Embedding(input_dim=max_length, output_dim=depth)

    self.token_embedding = tf.keras.layers.Embedding(
        input_dim=vocab_size,
        output_dim=depth,
        mask_zero=True)

    self.add = tf.keras.layers.Add()

  def call(self, seq):
    seq = self.token_embedding(seq) # (batch, seq, depth)

    x = tf.range(tf.shape(seq)[1])  # (seq)
    x = x[tf.newaxis, :]  # (1, seq)
    x = self.pos_embedding(x)  # (1, seq, depth)

    return self.add([seq,x])

解碼器

解碼器是標準 Transformer 解碼器,它包含一疊 DecoderLayers,其中每個都包含三個子層:CausalSelfAttentionCrossAttentionFeedForward。實作幾乎與 Transformer 教學課程 完全相同,請參閱該教學課程以取得更多詳細資訊。

以下是 CausalSelfAttention

class CausalSelfAttention(tf.keras.layers.Layer):
  def __init__(self, **kwargs):
    super().__init__()
    self.mha = tf.keras.layers.MultiHeadAttention(**kwargs)
    # Use Add instead of + so the keras mask propagates through.
    self.add = tf.keras.layers.Add() 
    self.layernorm = tf.keras.layers.LayerNormalization()

  def call(self, x):
    attn = self.mha(query=x, value=x,
                    use_causal_mask=True)
    x = self.add([x, attn])
    return self.layernorm(x)

以下是 CrossAttention 層。請注意 return_attention_scores 的使用。

class CrossAttention(tf.keras.layers.Layer):
  def __init__(self,**kwargs):
    super().__init__()
    self.mha = tf.keras.layers.MultiHeadAttention(**kwargs)
    self.add = tf.keras.layers.Add() 
    self.layernorm = tf.keras.layers.LayerNormalization()

  def call(self, x, y, **kwargs):
    attn, attention_scores = self.mha(
             query=x, value=y,
             return_attention_scores=True)

    self.last_attention_scores = attention_scores

    x = self.add([x, attn])
    return self.layernorm(x)

以下是 FeedForward 層。請記住,layers.Dense 層會套用至輸入的最後一個軸。輸入的形狀將為 (批次,序列,通道),因此它會自動在 batchsequence 軸上逐點套用。

class FeedForward(tf.keras.layers.Layer):
  def __init__(self, units, dropout_rate=0.1):
    super().__init__()
    self.seq = tf.keras.Sequential([
        tf.keras.layers.Dense(units=2*units, activation='relu'),
        tf.keras.layers.Dense(units=units),
        tf.keras.layers.Dropout(rate=dropout_rate),
    ])

    self.layernorm = tf.keras.layers.LayerNormalization()

  def call(self, x):
    x = x + self.seq(x)
    return self.layernorm(x)

接下來將這三層排列成更大的 DecoderLayer。每個解碼器層依序套用三個較小的層。在每個子層之後,out_seq 的形狀為 (批次,序列,通道)。解碼器層也會傳回 attention_scores 以供稍後視覺化。

class DecoderLayer(tf.keras.layers.Layer):
  def __init__(self, units, num_heads=1, dropout_rate=0.1):
    super().__init__()

    self.self_attention = CausalSelfAttention(num_heads=num_heads,
                                              key_dim=units,
                                              dropout=dropout_rate)
    self.cross_attention = CrossAttention(num_heads=num_heads,
                                          key_dim=units,
                                          dropout=dropout_rate)
    self.ff = FeedForward(units=units, dropout_rate=dropout_rate)


  def call(self, inputs, training=False):
    in_seq, out_seq = inputs

    # Text input
    out_seq = self.self_attention(out_seq)

    out_seq = self.cross_attention(out_seq, in_seq)

    self.last_attention_scores = self.cross_attention.last_attention_scores

    out_seq = self.ff(out_seq)

    return out_seq

輸出

最起碼,輸出層需要一個 layers.Dense 層,以產生每個位置每個符號的 logit 預測。

但是您可以新增一些其他功能,使此功能運作得更好一些

  1. 處理錯誤符號:模型將產生文字。它絕不應產生填充、未知或開始符號 ('''[UNK]''[START]')。因此,將這些符號的偏差設定為較大的負值。

  2. 智慧型初始化:密集層的預設初始化將提供一個模型,該模型最初以幾乎均勻的可能性預測每個符號。實際的符號分佈遠非均勻。輸出層初始偏差的最佳值是每個符號機率的對數。因此,包含一個 adapt 方法來計算符號並設定最佳初始偏差。這將初始損失從均勻分佈的熵 (log(vocabulary_size)) 減少到分佈的邊際熵 (-p*log(p))。

智慧型初始化將顯著減少初始損失

output_layer = TokenOutput(tokenizer, banned_tokens=('', '[UNK]', '[START]'))
# This might run a little faster if the dataset didn't also have to load the image data.
output_layer.adapt(train_ds.map(lambda inputs, labels: labels))

建構模型

若要建構模型,您需要結合幾個部分

  1. 影像 feature_extractor 和文字 tokenizer 和。
  2. seq_embedding 層,用於將批次符號 ID 轉換為向量 (批次,序列,通道)
  3. 將處理文字和影像資料的 DecoderLayers 層堆疊。
  4. output_layer,其傳回下一個單字應為何者的逐點預測。
class Captioner(tf.keras.Model):
  @classmethod
  def add_method(cls, fun):
    setattr(cls, fun.__name__, fun)
    return fun

  def __init__(self, tokenizer, feature_extractor, output_layer, num_layers=1,
               units=256, max_length=50, num_heads=1, dropout_rate=0.1):
    super().__init__()
    self.feature_extractor = feature_extractor
    self.tokenizer = tokenizer
    self.word_to_index = tf.keras.layers.StringLookup(
        mask_token="",
        vocabulary=tokenizer.get_vocabulary())
    self.index_to_word = tf.keras.layers.StringLookup(
        mask_token="",
        vocabulary=tokenizer.get_vocabulary(),
        invert=True) 

    self.seq_embedding = SeqEmbedding(
        vocab_size=tokenizer.vocabulary_size(),
        depth=units,
        max_length=max_length)

    self.decoder_layers = [
        DecoderLayer(units, num_heads=num_heads, dropout_rate=dropout_rate)
        for n in range(num_layers)]

    self.output_layer = output_layer

當您呼叫模型以進行訓練時,它會接收 image, txt 配對。為了使此函數更易於使用,請靈活處理輸入

  • 如果影像有 3 個通道,請透過 feature_extractor 執行。否則假設它已經執行過。同樣地
  • 如果文字具有 dtype tf.string,請透過 tokenizer 執行。

在那之後,執行模型僅需幾個步驟

  1. 展平擷取的影像特徵,以便將其輸入到解碼器層。
  2. 查閱符號嵌入。
  3. 在影像特徵和文字嵌入上執行 DecoderLayers 堆疊。
  4. 執行輸出層以預測每個位置的下一個符號。
@Captioner.add_method
  def call(self, inputs):
    image, txt = inputs

    if image.shape[-1] == 3:
      # Apply the feature-extractor, if you get an RGB image.
      image = self.feature_extractor(image)

    # Flatten the feature map
    image = einops.rearrange(image, 'b h w c -> b (h w) c')


    if txt.dtype == tf.string:
      # Apply the tokenizer if you get string inputs.
      txt = tokenizer(txt)

    txt = self.seq_embedding(txt)

    # Look at the image
    for dec_layer in self.decoder_layers:
      txt = dec_layer(inputs=(image, txt))

    txt = self.output_layer(txt)

    return txt
model = Captioner(tokenizer, feature_extractor=mobilenet, output_layer=output_layer,
                  units=256, dropout_rate=0.5, num_layers=2, num_heads=2)

產生標題

在開始訓練之前,先編寫一些程式碼來產生標題。您將使用它來查看訓練的進度。

首先下載測試影像

image_url = 'https://tensorflow.dev.org.tw/images/surf.jpg'
image_path = tf.keras.utils.get_file('surf.jpg', origin=image_url)
image = load_image(image_path)

若要使用此模型為影像加上標題

  • 擷取 img_features
  • 使用 [START] 符號初始化輸出符號清單。
  • img_featurestokens 傳遞到模型中。
    • 它會傳回 logit 清單。
    • 根據這些 logit 選擇下一個符號。
    • 將其新增至符號清單,然後繼續迴圈。
    • 如果它產生 '[END]' 符號,則跳出迴圈。

因此,新增一個「簡單」方法來執行此操作

@Captioner.add_method
def simple_gen(self, image, temperature=1):
  initial = self.word_to_index([['[START]']]) # (batch, sequence)
  img_features = self.feature_extractor(image[tf.newaxis, ...])

  tokens = initial # (batch, sequence)
  for n in range(50):
    preds = self((img_features, tokens)).numpy()  # (batch, sequence, vocab)
    preds = preds[:,-1, :]  #(batch, vocab)
    if temperature==0:
        next = tf.argmax(preds, axis=-1)[:, tf.newaxis]  # (batch, 1)
    else:
        next = tf.random.categorical(preds/temperature, num_samples=1)  # (batch, 1)
    tokens = tf.concat([tokens, next], axis=1) # (batch, sequence) 

    if next[0] == self.word_to_index('[END]'):
      break
  words = index_to_word(tokens[0, 1:-1])
  result = tf.strings.reduce_join(words, axis=-1, separator=' ')
  return result.numpy().decode()

以下是該影像的一些產生標題,模型尚未訓練,因此它們還沒有太多意義

for t in (0.0, 0.5, 1.0):
  result = model.simple_gen(image, temperature=t)
  print(result)

溫度參數可讓您在 3 種模式之間進行內插

  1. 貪婪解碼 (temperature=0.0) - 在每個步驟選擇最有可能的下一個符號。
  2. 根據 logit 進行隨機取樣 (temperature=1.0)。
  3. 均勻隨機取樣 (temperature >> 1.0)。

由於模型尚未訓練,並且使用了基於頻率的初始化,「貪婪」輸出 (第一個) 通常僅包含最常見的符號:['a', '.', '[END]']

訓練

若要訓練模型,您需要幾個額外的元件

  • 損失和指標
  • 最佳化器
  • 選用回呼

損失和指標

以下是遮罩損失和準確度的實作

在計算損失的遮罩時,請注意 loss < 1e8。此詞彙會捨棄 banned_tokens 的人為、極高的損失。

def masked_loss(labels, preds):  
  loss = tf.nn.sparse_softmax_cross_entropy_with_logits(labels, preds)

  mask = (labels != 0) & (loss < 1e8) 
  mask = tf.cast(mask, loss.dtype)

  loss = loss*mask
  loss = tf.reduce_sum(loss)/tf.reduce_sum(mask)
  return loss

def masked_acc(labels, preds):
  mask = tf.cast(labels!=0, tf.float32)
  preds = tf.argmax(preds, axis=-1)
  labels = tf.cast(labels, tf.int64)
  match = tf.cast(preds == labels, mask.dtype)
  acc = tf.reduce_sum(match*mask)/tf.reduce_sum(mask)
  return acc

回呼

為了在訓練期間獲得回饋,請設定一個 keras.callbacks.Callback,以便在每個 epoch 結束時為衝浪者影像產生一些標題。

class GenerateText(tf.keras.callbacks.Callback):
  def __init__(self):
    image_url = 'https://tensorflow.dev.org.tw/images/surf.jpg'
    image_path = tf.keras.utils.get_file('surf.jpg', origin=image_url)
    self.image = load_image(image_path)

  def on_epoch_end(self, epochs=None, logs=None):
    print()
    print()
    for t in (0.0, 0.5, 1.0):
      result = self.model.simple_gen(self.image, temperature=t)
      print(result)
    print()

它會產生三個輸出字串,就像先前的範例一樣,就像之前一樣,第一個是「貪婪」,在每個步驟選擇 logit 的 argmax。

g = GenerateText()
g.model = model
g.on_epoch_end(0)

也使用 callbacks.EarlyStopping 在模型開始過度擬合時終止訓練。

callbacks = [
    GenerateText(),
    tf.keras.callbacks.EarlyStopping(
        patience=5, restore_best_weights=True)]

訓練

設定並執行訓練。

model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=1e-4),
           loss=masked_loss,
           metrics=[masked_acc])

為了更頻繁地報告,請使用 Dataset.repeat() 方法,並將 steps_per_epochvalidation_steps 引數設定為 Model.fit

透過在 Flickr8k 上的設定,完整傳遞資料集是 900 多個批次,但以下報告 epoch 為 100 個步驟。

history = model.fit(
    train_ds.repeat(),
    steps_per_epoch=100,
    validation_data=test_ds.repeat(),
    validation_steps=20,
    epochs=100,
    callbacks=callbacks)

繪製訓練運行的損失和準確度

plt.plot(history.history['loss'], label='loss')
plt.plot(history.history['val_loss'], label='val_loss')
plt.ylim([0, max(plt.ylim())])
plt.xlabel('Epoch #')
plt.ylabel('CE/token')
plt.legend()
plt.plot(history.history['masked_acc'], label='accuracy')
plt.plot(history.history['val_masked_acc'], label='val_accuracy')
plt.ylim([0, max(plt.ylim())])
plt.xlabel('Epoch #')
plt.ylabel('CE/token')
plt.legend()

注意力圖

現在,使用訓練後的模型,在影像上運行該 simple_gen 方法

result = model.simple_gen(image, temperature=0.0)
result

將輸出分割回符號

str_tokens = result.split()
str_tokens.append('[END]')

DecoderLayers 各自快取其 CrossAttention 層的注意力分數。每個注意力圖的形狀為 (batch=1, heads, sequence, image)

attn_maps = [layer.last_attention_scores for layer in model.decoder_layers]
[map.shape for map in attn_maps]

因此,沿著 batch 軸堆疊地圖,然後在 (batch, heads) 軸上平均,同時將 image 軸分割回 height, width

attention_maps = tf.concat(attn_maps, axis=0)
attention_maps = einops.reduce(
    attention_maps,
    'batch heads sequence (height width) -> sequence height width',
    height=7, width=7,
    reduction='mean')

現在,您有了每個序列預測的單個注意力圖。每個地圖中的值應總和為 1.

einops.reduce(attention_maps, 'sequence height width -> sequence', reduction='sum')

因此,這是模型在產生輸出中的每個符號時關注的位置

def plot_attention_maps(image, str_tokens, attention_map):
    fig = plt.figure(figsize=(16, 9))

    len_result = len(str_tokens)

    titles = []
    for i in range(len_result):
      map = attention_map[i]
      grid_size = max(int(np.ceil(len_result/2)), 2)
      ax = fig.add_subplot(3, grid_size, i+1)
      titles.append(ax.set_title(str_tokens[i]))
      img = ax.imshow(image)
      ax.imshow(map, cmap='gray', alpha=0.6, extent=img.get_extent(),
                clim=[0.0, np.max(map)])

    plt.tight_layout()
plot_attention_maps(image/255, str_tokens, attention_maps)

現在將其整合到更易於使用的函數中

@Captioner.add_method
def run_and_show_attention(self, image, temperature=0.0):
  result_txt = self.simple_gen(image, temperature)
  str_tokens = result_txt.split()
  str_tokens.append('[END]')

  attention_maps = [layer.last_attention_scores for layer in self.decoder_layers]
  attention_maps = tf.concat(attention_maps, axis=0)
  attention_maps = einops.reduce(
      attention_maps,
      'batch heads sequence (height width) -> sequence height width',
      height=7, width=7,
      reduction='mean')

  plot_attention_maps(image/255, str_tokens, attention_maps)
  t = plt.suptitle(result_txt)
  t.set_y(1.05)
run_and_show_attention(model, image)

在您自己的圖片上試試看

為了好玩,下面提供了您可以使用的方法,使用您剛訓練的模型為您自己的影像加上標題。請記住,它是在相對少量資料上訓練的,並且您的影像可能與訓練資料不同 (因此請為奇怪的結果做好準備!)

image_url = 'https://tensorflow.dev.org.tw/images/bedroom_hrnet_tutorial.jpg'
image_path = tf.keras.utils.get_file(origin=image_url)
image = load_image(image_path)

run_and_show_attention(model, image)