程式碼範例 / 自然語言處理 / 使用 KerasHub 進行英翻西翻譯

使用 KerasHub 進行英翻西翻譯

作者: Abheesht Sharma
建立日期 2022/05/26
上次修改日期 2024/04/30
說明: 使用 KerasHub 在機器翻譯任務上訓練序列到序列 Transformer 模型。

ⓘ 此範例使用 Keras 3

在 Colab 中檢視 GitHub 原始碼


簡介

KerasHub 為 NLP 提供建構模塊(模型層、分詞器、指標等),使其方便構建 NLP 流程。

在此範例中,我們將使用 KerasHub 層來建構編碼器-解碼器 Transformer 模型,並在英翻西機器翻譯任務上訓練它。

此範例基於 fchollet 的英翻西 NMT 範例。原始範例更底層,從頭開始實現各層,而此範例使用 KerasHub 來展示一些更進階的方法,例如子詞分詞和使用指標來計算生成翻譯的品質。

您將學會如何

如果您不熟悉 KerasHub,請不用擔心。本教學課程將從基礎知識開始。讓我們直接開始吧!


設定

在我們開始實作流程之前,讓我們先匯入所有需要的程式庫。

!pip install -q --upgrade rouge-score
!pip install -q --upgrade keras-hub
!pip install -q --upgrade keras  # Upgrade to Keras 3.
import keras_hub
import pathlib
import random

import keras
from keras import ops

import tensorflow.data as tf_data
from tensorflow_text.tools.wordpiece_vocab import (
    bert_vocab_from_dataset as bert_vocab,
)
ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
tensorflow 2.15.1 requires keras<2.16,>=2.15.0, but you have keras 3.3.3 which is incompatible.

我們也來定義參數/超參數。

BATCH_SIZE = 64
EPOCHS = 1  # This should be at least 10 for convergence
MAX_SEQUENCE_LENGTH = 40
ENG_VOCAB_SIZE = 15000
SPA_VOCAB_SIZE = 15000

EMBED_DIM = 256
INTERMEDIATE_DIM = 2048
NUM_HEADS = 8

下載資料

我們將使用 Anki 提供的英翻西翻譯資料集。讓我們下載它

text_file = keras.utils.get_file(
    fname="spa-eng.zip",
    origin="http://storage.googleapis.com/download.tensorflow.org/data/spa-eng.zip",
    extract=True,
)
text_file = pathlib.Path(text_file).parent / "spa-eng" / "spa.txt"
Downloading data from http://storage.googleapis.com/download.tensorflow.org/data/spa-eng.zip
 2638744/2638744 ━━━━━━━━━━━━━━━━━━━━ 0s 0us/step

剖析資料

每行包含一個英文句子及其對應的西班牙文句子。英文句子是來源序列,而西班牙文句子是目標序列。在將文字加入列表之前,我們會將其轉換為小寫。

with open(text_file) as f:
    lines = f.read().split("\n")[:-1]
text_pairs = []
for line in lines:
    eng, spa = line.split("\t")
    eng = eng.lower()
    spa = spa.lower()
    text_pairs.append((eng, spa))

以下是我們的句子配對範例

for _ in range(5):
    print(random.choice(text_pairs))
('tom heard that mary had bought a new computer.', 'tom oyó que mary se había comprado un computador nuevo.')
('will you stay at home?', '¿te vas a quedar en casa?')
('where is this train going?', '¿adónde va este tren?')
('tom panicked.', 'tom entró en pánico.')
("we'll help you rescue tom.", 'te ayudaremos a rescatar a tom.')

現在,讓我們將句子配對分成訓練集、驗證集和測試集。

random.shuffle(text_pairs)
num_val_samples = int(0.15 * len(text_pairs))
num_train_samples = len(text_pairs) - 2 * num_val_samples
train_pairs = text_pairs[:num_train_samples]
val_pairs = text_pairs[num_train_samples : num_train_samples + num_val_samples]
test_pairs = text_pairs[num_train_samples + num_val_samples :]

print(f"{len(text_pairs)} total pairs")
print(f"{len(train_pairs)} training pairs")
print(f"{len(val_pairs)} validation pairs")
print(f"{len(test_pairs)} test pairs")
118964 total pairs
83276 training pairs
17844 validation pairs
17844 test pairs

對資料進行分詞

我們將定義兩個分詞器 - 一個用於來源語言(英文),另一個用於目標語言(西班牙文)。我們將使用 keras_hub.tokenizers.WordPieceTokenizer 來對文字進行分詞。keras_hub.tokenizers.WordPieceTokenizer 採用 WordPiece 詞彙表,並具有用於對文字進行分詞和將詞符序列還原為原始文字的函數。

在我們定義兩個分詞器之前,我們首先需要在我們擁有的資料集上訓練它們。WordPiece 分詞演算法是一種子詞分詞演算法;在語料庫上訓練它會給我們提供一個子詞詞彙表。子詞分詞器是詞分詞器(詞分詞器需要非常大的詞彙表才能很好地涵蓋輸入詞)和字元分詞器(字元並不真正像詞一樣編碼意義)之間的折衷方案。幸運的是,KerasHub 可以使用 keras_hub.tokenizers.compute_word_piece_vocabulary 公用程式在語料庫上訓練 WordPiece。

def train_word_piece(text_samples, vocab_size, reserved_tokens):
    word_piece_ds = tf_data.Dataset.from_tensor_slices(text_samples)
    vocab = keras_hub.tokenizers.compute_word_piece_vocabulary(
        word_piece_ds.batch(1000).prefetch(2),
        vocabulary_size=vocab_size,
        reserved_tokens=reserved_tokens,
    )
    return vocab

每個詞彙表都有一些特殊的保留詞符。我們有四個這樣的詞符

  • "[PAD]" - 填充詞符。當輸入序列長度小於最大序列長度時,填充詞符會附加到輸入序列長度。
  • "[UNK]" - 未知詞符。
  • "[START]" - 標記輸入序列開始的詞符。
  • "[END]" - 標記輸入序列結束的詞符。
reserved_tokens = ["[PAD]", "[UNK]", "[START]", "[END]"]

eng_samples = [text_pair[0] for text_pair in train_pairs]
eng_vocab = train_word_piece(eng_samples, ENG_VOCAB_SIZE, reserved_tokens)

spa_samples = [text_pair[1] for text_pair in train_pairs]
spa_vocab = train_word_piece(spa_samples, SPA_VOCAB_SIZE, reserved_tokens)

讓我們看看一些詞符!

print("English Tokens: ", eng_vocab[100:110])
print("Spanish Tokens: ", spa_vocab[100:110])
English Tokens:  ['at', 'know', 'him', 'there', 'go', 'they', 'her', 'has', 'time', 'will']
Spanish Tokens:  ['le', 'para', 'te', 'mary', 'las', 'más', 'al', 'yo', 'tu', 'estoy']

現在,讓我們定義分詞器。我們將使用上述訓練的詞彙表配置分詞器。

eng_tokenizer = keras_hub.tokenizers.WordPieceTokenizer(
    vocabulary=eng_vocab, lowercase=False
)
spa_tokenizer = keras_hub.tokenizers.WordPieceTokenizer(
    vocabulary=spa_vocab, lowercase=False
)

讓我們嘗試對資料集中的樣本進行分詞!為了驗證文字是否已正確分詞,我們也可以將詞符列表還原為原始文字。

eng_input_ex = text_pairs[0][0]
eng_tokens_ex = eng_tokenizer.tokenize(eng_input_ex)
print("English sentence: ", eng_input_ex)
print("Tokens: ", eng_tokens_ex)
print(
    "Recovered text after detokenizing: ",
    eng_tokenizer.detokenize(eng_tokens_ex),
)

print()

spa_input_ex = text_pairs[0][1]
spa_tokens_ex = spa_tokenizer.tokenize(spa_input_ex)
print("Spanish sentence: ", spa_input_ex)
print("Tokens: ", spa_tokens_ex)
print(
    "Recovered text after detokenizing: ",
    spa_tokenizer.detokenize(spa_tokens_ex),
)
English sentence:  i am leaving the books here.
Tokens:  tf.Tensor([ 35 163 931  66 356 119  12], shape=(7,), dtype=int32)
Recovered text after detokenizing:  tf.Tensor(b'i am leaving the books here .', shape=(), dtype=string)
Spanish sentence:  dejo los libros aquí.
Tokens:  tf.Tensor([2962   93  350  122   14], shape=(5,), dtype=int32)
Recovered text after detokenizing:  tf.Tensor(b'dejo los libros aqu\xc3\xad .', shape=(), dtype=string)

格式化資料集

接下來,我們將格式化我們的資料集。

在每個訓練步驟中,模型將嘗試使用來源句子和目標詞 0 到 N 來預測目標詞 N+1(及之後)。

因此,訓練資料集將產生一個元組 (inputs, targets),其中

  • inputs 是一個帶有鍵 encoder_inputsdecoder_inputs 的字典。encoder_inputs 是分詞後的來源句子,而 decoder_inputs 是「到目前為止」的目標句子,也就是說,目標句子中用於預測詞 N+1(及之後)的詞 0 到 N。
  • target 是目標句子偏移一個步驟:它提供了目標句子中的下一個詞 - 模型將嘗試預測的內容。

我們將在分詞文字後,將特殊詞符 "[START]""[END]" 新增到輸入的西班牙文句子中。我們還將輸入填充到固定長度。這可以使用 keras_hub.layers.StartEndPacker 輕鬆完成。

def preprocess_batch(eng, spa):
    batch_size = ops.shape(spa)[0]

    eng = eng_tokenizer(eng)
    spa = spa_tokenizer(spa)

    # Pad `eng` to `MAX_SEQUENCE_LENGTH`.
    eng_start_end_packer = keras_hub.layers.StartEndPacker(
        sequence_length=MAX_SEQUENCE_LENGTH,
        pad_value=eng_tokenizer.token_to_id("[PAD]"),
    )
    eng = eng_start_end_packer(eng)

    # Add special tokens (`"[START]"` and `"[END]"`) to `spa` and pad it as well.
    spa_start_end_packer = keras_hub.layers.StartEndPacker(
        sequence_length=MAX_SEQUENCE_LENGTH + 1,
        start_value=spa_tokenizer.token_to_id("[START]"),
        end_value=spa_tokenizer.token_to_id("[END]"),
        pad_value=spa_tokenizer.token_to_id("[PAD]"),
    )
    spa = spa_start_end_packer(spa)

    return (
        {
            "encoder_inputs": eng,
            "decoder_inputs": spa[:, :-1],
        },
        spa[:, 1:],
    )


def make_dataset(pairs):
    eng_texts, spa_texts = zip(*pairs)
    eng_texts = list(eng_texts)
    spa_texts = list(spa_texts)
    dataset = tf_data.Dataset.from_tensor_slices((eng_texts, spa_texts))
    dataset = dataset.batch(BATCH_SIZE)
    dataset = dataset.map(preprocess_batch, num_parallel_calls=tf_data.AUTOTUNE)
    return dataset.shuffle(2048).prefetch(16).cache()


train_ds = make_dataset(train_pairs)
val_ds = make_dataset(val_pairs)

讓我們快速看一下序列形狀(我們有 64 個配對的批次,並且所有序列的長度均為 40 個步驟)

for inputs, targets in train_ds.take(1):
    print(f'inputs["encoder_inputs"].shape: {inputs["encoder_inputs"].shape}')
    print(f'inputs["decoder_inputs"].shape: {inputs["decoder_inputs"].shape}')
    print(f"targets.shape: {targets.shape}")
inputs["encoder_inputs"].shape: (64, 40)
inputs["decoder_inputs"].shape: (64, 40)
targets.shape: (64, 40)

建構模型

現在,讓我們繼續進入令人興奮的部分 - 定義我們的模型!我們首先需要一個嵌入層,也就是說,我們輸入序列中每個詞符的向量。此嵌入層可以隨機初始化。我們還需要一個位置嵌入層,該層會編碼序列中的詞順序。慣例是新增這兩個嵌入。KerasHub 有一個 keras_hub.layers.TokenAndPositionEmbedding 層,它會為我們執行上述所有步驟。

我們的序列到序列 Transformer 由一個 keras_hub.layers.TransformerEncoder 層和一個 keras_hub.layers.TransformerDecoder 層鏈接在一起組成。

來源序列將傳遞給 keras_hub.layers.TransformerEncoder,它將產生一個新的表示形式。然後,此新的表示形式將與「到目前為止」的目標序列(目標詞 0 到 N)一起傳遞給 keras_hub.layers.TransformerDecoderkeras_hub.layers.TransformerDecoder 隨後將嘗試預測目標序列中的下一個詞(N+1 及之後)。

其中一個關鍵細節是因果遮罩 (causal masking)。keras_hub.layers.TransformerDecoder 會一次看到整個序列,因此我們必須確保它在預測第 N+1 個 token 時,只使用目標 tokens 0 到 N 的資訊 (否則,它可能會使用來自未來的資訊,這會導致模型在推論時無法使用)。在 keras_hub.layers.TransformerDecoder 中,因果遮罩預設為啟用。

我們還需要遮罩填充 tokens ("[PAD]")。為此,我們可以將 keras_hub.layers.TokenAndPositionEmbedding 層的 mask_zero 參數設定為 True。這會傳播到所有後續的層。

# Encoder
encoder_inputs = keras.Input(shape=(None,), name="encoder_inputs")

x = keras_hub.layers.TokenAndPositionEmbedding(
    vocabulary_size=ENG_VOCAB_SIZE,
    sequence_length=MAX_SEQUENCE_LENGTH,
    embedding_dim=EMBED_DIM,
)(encoder_inputs)

encoder_outputs = keras_hub.layers.TransformerEncoder(
    intermediate_dim=INTERMEDIATE_DIM, num_heads=NUM_HEADS
)(inputs=x)
encoder = keras.Model(encoder_inputs, encoder_outputs)


# Decoder
decoder_inputs = keras.Input(shape=(None,), name="decoder_inputs")
encoded_seq_inputs = keras.Input(shape=(None, EMBED_DIM), name="decoder_state_inputs")

x = keras_hub.layers.TokenAndPositionEmbedding(
    vocabulary_size=SPA_VOCAB_SIZE,
    sequence_length=MAX_SEQUENCE_LENGTH,
    embedding_dim=EMBED_DIM,
)(decoder_inputs)

x = keras_hub.layers.TransformerDecoder(
    intermediate_dim=INTERMEDIATE_DIM, num_heads=NUM_HEADS
)(decoder_sequence=x, encoder_sequence=encoded_seq_inputs)
x = keras.layers.Dropout(0.5)(x)
decoder_outputs = keras.layers.Dense(SPA_VOCAB_SIZE, activation="softmax")(x)
decoder = keras.Model(
    [
        decoder_inputs,
        encoded_seq_inputs,
    ],
    decoder_outputs,
)
decoder_outputs = decoder([decoder_inputs, encoder_outputs])

transformer = keras.Model(
    [encoder_inputs, decoder_inputs],
    decoder_outputs,
    name="transformer",
)

訓練我們的模型

我們將使用準確度作為快速監控驗證資料訓練進度的方法。請注意,機器翻譯通常使用 BLEU 分數以及其他指標,而不是準確度。但是,為了使用 ROUGE、BLEU 等指標,我們需要解碼機率並產生文本。文本生成在計算上很耗費資源,不建議在訓練期間執行此操作。

這裡我們只訓練 1 個 epoch,但是為了讓模型真正收斂,您應該至少訓練 10 個 epoch。

transformer.summary()
transformer.compile(
    "rmsprop", loss="sparse_categorical_crossentropy", metrics=["accuracy"]
)
transformer.fit(train_ds, epochs=EPOCHS, validation_data=val_ds)
Model: "transformer"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type)         Output Shape          Param #  Connected to      ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━┩
│ encoder_inputs      │ (None, None)      │          0 │ -                 │
│ (InputLayer)        │                   │            │                   │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ token_and_position… │ (None, None, 256) │  3,850,240 │ encoder_inputs[0… │
│ (TokenAndPositionE… │                   │            │                   │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ decoder_inputs      │ (None, None)      │          0 │ -                 │
│ (InputLayer)        │                   │            │                   │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ transformer_encoder │ (None, None, 256) │  1,315,072 │ token_and_positi… │
│ (TransformerEncode… │                   │            │                   │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ functional_3        │ (None, None,      │  9,283,992 │ decoder_inputs[0… │
│ (Functional)        │ 15000)            │            │ transformer_enco… │
└─────────────────────┴───────────────────┴────────────┴───────────────────┘
 Total params: 14,449,304 (55.12 MB)
 Trainable params: 14,449,304 (55.12 MB)
 Non-trainable params: 0 (0.00 B)
 1302/1302 ━━━━━━━━━━━━━━━━━━━━ 1701s 1s/step - accuracy: 0.8168 - loss: 1.4819 - val_accuracy: 0.8650 - val_loss: 0.8129

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

解碼測試句子 (定性分析)

最後,讓我們示範如何翻譯全新的英文句子。我們只需將 token 化的英文句子以及目標 token "[START]" 輸入到模型中。模型輸出下一個 token 的機率。然後,我們反覆生成下一個 token,並以目前生成的 tokens 為條件,直到我們遇到 token "[END]"

對於解碼,我們將使用 KerasHub 中的 keras_hub.samplers 模組。貪婪解碼是一種文本解碼方法,它在每個時間步輸出最有可能的下一個 token,也就是機率最高的 token。

def decode_sequences(input_sentences):
    batch_size = 1

    # Tokenize the encoder input.
    encoder_input_tokens = ops.convert_to_tensor(eng_tokenizer(input_sentences))
    if len(encoder_input_tokens[0]) < MAX_SEQUENCE_LENGTH:
        pads = ops.full((1, MAX_SEQUENCE_LENGTH - len(encoder_input_tokens[0])), 0)
        encoder_input_tokens = ops.concatenate(
            [encoder_input_tokens.to_tensor(), pads], 1
        )

    # Define a function that outputs the next token's probability given the
    # input sequence.
    def next(prompt, cache, index):
        logits = transformer([encoder_input_tokens, prompt])[:, index - 1, :]
        # Ignore hidden states for now; only needed for contrastive search.
        hidden_states = None
        return logits, hidden_states, cache

    # Build a prompt of length 40 with a start token and padding tokens.
    length = 40
    start = ops.full((batch_size, 1), spa_tokenizer.token_to_id("[START]"))
    pad = ops.full((batch_size, length - 1), spa_tokenizer.token_to_id("[PAD]"))
    prompt = ops.concatenate((start, pad), axis=-1)

    generated_tokens = keras_hub.samplers.GreedySampler()(
        next,
        prompt,
        stop_token_ids=[spa_tokenizer.token_to_id("[END]")],
        index=1,  # Start sampling after start token.
    )
    generated_sentences = spa_tokenizer.detokenize(generated_tokens)
    return generated_sentences


test_eng_texts = [pair[0] for pair in test_pairs]
for i in range(2):
    input_sentence = random.choice(test_eng_texts)
    translated = decode_sequences([input_sentence])
    translated = translated.numpy()[0].decode("utf-8")
    translated = (
        translated.replace("[PAD]", "")
        .replace("[START]", "")
        .replace("[END]", "")
        .strip()
    )
    print(f"** Example {i} **")
    print(input_sentence)
    print(translated)
    print()
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1714519073.816969   34774 device_compiler.h:186] Compiled cluster using XLA!  This line is logged at most once for the lifetime of the process.

** Example 0 **
i got the ticket free of charge.
me pregunto la comprome .
** Example 1 **
i think maybe that's all you have to do.
creo que tom le dije que hacer eso .

評估我們的模型 (定量分析)

有許多指標用於文本生成任務。在這裡,為了評估我們的模型產生的翻譯,讓我們計算 ROUGE-1 和 ROUGE-2 分數。基本上,ROUGE-N 是一個基於參考文本和生成文本之間共同 n-gram 數量的分數。ROUGE-1 和 ROUGE-2 分別使用共同 unigram 和 bigram 的數量。

我們將在 30 個測試樣本上計算分數 (因為解碼是一個耗費資源的過程)。

rouge_1 = keras_hub.metrics.RougeN(order=1)
rouge_2 = keras_hub.metrics.RougeN(order=2)

for test_pair in test_pairs[:30]:
    input_sentence = test_pair[0]
    reference_sentence = test_pair[1]

    translated_sentence = decode_sequences([input_sentence])
    translated_sentence = translated_sentence.numpy()[0].decode("utf-8")
    translated_sentence = (
        translated_sentence.replace("[PAD]", "")
        .replace("[START]", "")
        .replace("[END]", "")
        .strip()
    )

    rouge_1(reference_sentence, translated_sentence)
    rouge_2(reference_sentence, translated_sentence)

print("ROUGE-1 Score: ", rouge_1.result())
print("ROUGE-2 Score: ", rouge_2.result())
ROUGE-1 Score:  {'precision': <tf.Tensor: shape=(), dtype=float32, numpy=0.30989552>, 'recall': <tf.Tensor: shape=(), dtype=float32, numpy=0.37136248>, 'f1_score': <tf.Tensor: shape=(), dtype=float32, numpy=0.33032653>}
ROUGE-2 Score:  {'precision': <tf.Tensor: shape=(), dtype=float32, numpy=0.08999339>, 'recall': <tf.Tensor: shape=(), dtype=float32, numpy=0.09524643>, 'f1_score': <tf.Tensor: shape=(), dtype=float32, numpy=0.08855649>}

經過 10 個 epoch 後,分數如下

ROUGE-1 ROUGE-2
精確率 (Precision) 0.568 0.374
召回率 (Recall) 0.615 0.394
F1 分數 (F1 Score) 0.579 0.381