代码示例 / 生成式深度学习 / 从头开始使用 KerasNLP 生成 GPT 文本

从头开始使用 KerasNLP 生成 GPT 文本

作者: Jesse Chan
创建日期: 2022/07/25
最后修改日期: 2022/07/25
描述: 使用 KerasNLP 训练一个迷你 GPT 模型进行文本生成。

在 Colab 中查看 GitHub 源代码


介绍

在这个例子中,我们将使用 KerasNLP 构建一个缩小版的生成预训练 (GPT) 模型。GPT 是一个基于 Transformer 的模型,可以从提示生成复杂文本。

我们将模型训练在 simplebooks-92 语料库上,该数据集由几部小说组成。由于它具有较小的词汇量和较高的词频,因此非常适合用于这个例子,这对训练具有较少参数的模型是有益的。

这个例子结合了来自 使用迷你 GPT 进行文本生成 的概念与 KerasNLP 抽象。我们将演示 KerasNLP 的分词、层和指标如何简化训练过程,然后展示如何使用 KerasNLP 采样工具生成输出文本。

注意:如果您在 Colab 上运行此示例,请确保启用 GPU 运行时以加快训练速度。

此示例需要 KerasNLP。您可以通过以下命令安装它: pip install keras-nlp


设置

!pip install -q --upgrade keras-nlp
!pip install -q --upgrade keras  # 升级到 Keras 3.
import os
import keras_nlp
import keras

import tensorflow.data as tf_data
import tensorflow.strings as tf_strings

设置与超参数

# 数据
BATCH_SIZE = 64
MIN_STRING_LEN = 512  # 短于此长度的字符串将被丢弃
SEQ_LEN = 128  # 训练序列的长度,以标记为单位

# 模型
EMBED_DIM = 256
FEED_FORWARD_DIM = 128
NUM_HEADS = 3
NUM_LAYERS = 2
VOCAB_SIZE = 5000  # 限制模型中的参数。

# 训练
EPOCHS = 5

# 推理
NUM_TOKENS_TO_GENERATE = 80

加载数据

现在,让我们下载数据集!SimpleBooks 数据集包含 1,573 本古登堡书籍,并且具有相对较小的词汇量与单词级标记的比例。它的词汇量约为 98k,约为 WikiText-103 的三分之一,标记数量大致相同(约 100M)。这使得适合拟合小模型变得容易。

keras.utils.get_file(
    origin="https://dldata-public.s3.us-east-2.amazonaws.com/simplebooks.zip",
    extract=True,
)
dir = os.path.expanduser("~/.keras/datasets/simplebooks/")

# 加载 simplebooks-92 训练集并过滤短行。
raw_train_ds = (
    tf_data.TextLineDataset(dir + "simplebooks-92-raw/train.txt")
    .filter(lambda x: tf_strings.length(x) > MIN_STRING_LEN)
    .batch(BATCH_SIZE)
    .shuffle(buffer_size=256)
)

# 加载 simplebooks-92 验证集并过滤短行。
raw_val_ds = (
    tf_data.TextLineDataset(dir + "simplebooks-92-raw/valid.txt")
    .filter(lambda x: tf_strings.length(x) > MIN_STRING_LEN)
    .batch(BATCH_SIZE)
)
从 https://dldata-public.s3.us-east-2.amazonaws.com/simplebooks.zip 下载数据
 282386239/282386239 ━━━━━━━━━━━━━━━━━━━━ 7s 0us/step

训练分词器

我们从训练数据集中训练分词器,以获得 VOCAB_SIZE 的词汇量,这是一个调整过的超参数。我们希望尽可能限制词汇量,因为稍后我们将看到它对模型参数的数量有很大的影响。我们也不希望包含 过少 的词汇条目,否则会有太多的超出词汇 (OOV) 子词。此外,词汇中保留了三个标记:

  • "[PAD]" 用于填充序列到 SEQ_LEN。这个标记在 reserved_tokensvocab 中的索引都是 0,因为 WordPieceTokenizer(和其他层)将 0/vocab[0] 视为默认填充。
  • "[UNK]" 用于 OOV 子词,应与 WordPieceTokenizer 中的默认 oov_token="[UNK]" 匹配。
  • "[BOS]" 代表句子的开始,但在这里实际上它是一个表示训练数据每行开始的标记。
# 训练分词器词汇
vocab = keras_nlp.tokenizers.compute_word_piece_vocabulary(
    raw_train_ds,
    vocabulary_size=VOCAB_SIZE,
    lowercase=True,
    reserved_tokens=["[PAD]", "[UNK]", "[BOS]"],
)

加载分词器

我们使用词汇数据来初始化 keras_nlp.tokenizers.WordPieceTokenizer。WordPieceTokenizer 是一个高效的 实现了 BERT 和其他模型中使用的 WordPiece 算法。它将去除、转换为小写并执行其他不可逆的预处理操作。

tokenizer = keras_nlp.tokenizers.WordPieceTokenizer(
    vocabulary=vocab,
    sequence_length=SEQ_LEN,
    lowercase=True,
)

分词数据

我们通过分词将数据集预处理并分为 featureslabels

# packer 添加一个开始标记
start_packer = keras_nlp.layers.StartEndPacker(
    sequence_length=SEQ_LEN,
    start_value=tokenizer.token_to_id("[BOS]"),
)


def preprocess(inputs):
    outputs = tokenizer(inputs)
    features = start_packer(outputs)
    labels = outputs
    return features, labels


# 分词并分成训练和标签序列。
train_ds = raw_train_ds.map(preprocess, num_parallel_calls=tf_data.AUTOTUNE).prefetch(
    tf_data.AUTOTUNE
)
val_ds = raw_val_ds.map(preprocess, num_parallel_calls=tf_data.AUTOTUNE).prefetch(
    tf_data.AUTOTUNE
)

构建模型

我们创建一个缩小版的 GPT 模型,包含以下层:

inputs = keras.layers.Input(shape=(None,), dtype="int32")
# 嵌入。
embedding_layer = keras_nlp.layers.TokenAndPositionEmbedding(
    vocabulary_size=VOCAB_SIZE,
    sequence_length=SEQ_LEN,
    embedding_dim=EMBED_DIM,
    mask_zero=True,
)
x = embedding_layer(inputs)
# Transformer 解码器。
for _ in range(NUM_LAYERS):
    decoder_layer = keras_nlp.layers.TransformerDecoder(
        num_heads=NUM_HEADS,
        intermediate_dim=FEED_FORWARD_DIM,
    )
    x = decoder_layer(x)  # 仅传递一个参数跳过交叉注意力。
# 输出。
outputs = keras.layers.Dense(VOCAB_SIZE)(x)
model = keras.Model(inputs=inputs, outputs=outputs)
loss_fn = keras.losses.SparseCategoricalCrossentropy(from_logits=True)
perplexity = keras_nlp.metrics.Perplexity(from_logits=True, mask_token_id=0)
model.compile(optimizer="adam", loss=loss_fn, metrics=[perplexity])

让我们看一下模型摘要 - 大部分参数都在 token_and_position_embedding 和输出的 dense 层中! 这意味着词汇大小 (VOCAB_SIZE) 对模型大小有很大影响,而 Transformer 解码器层的数量 (NUM_LAYERS) 则影响较小。

model.summary()
模型: "functional_1"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
┃ 层 (类型)                       输出形状                   参数数量 ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
│ input_layer (InputLayer)        │ (None, None)              │          0 │
├─────────────────────────────────┼───────────────────────────┼────────────┤
│ token_and_position_embedding    │ (None, None, 256)         │  1,312,768 │
│ (TokenAndPositionEmbedding)     │                           │            │
├─────────────────────────────────┼───────────────────────────┼────────────┤
│ transformer_decoder             │ (None, None, 256)         │    329,085 │
│ (TransformerDecoder)            │                           │            │
├─────────────────────────────────┼───────────────────────────┼────────────┤
│ transformer_decoder_1           │ (没有, 没有, 256)         │    329,085 │
│ (TransformerDecoder)            │                           │            │
├─────────────────────────────────┼───────────────────────────┼────────────┤
│ dense (Dense)                   │ (没有, 没有, 5000)        │  1,285,000 │
└─────────────────────────────────┴───────────────────────────┴────────────┘
 总参数: 3,255,938 (12.42 MB)
 可训练参数: 3,255,938 (12.42 MB)
 不可训练参数: 0 (0.00 B)

训练

现在我们有了模型,让我们用 fit() 方法进行训练。

model.fit(train_ds, validation_data=val_ds, epochs=EPOCHS)
第 1/5 轮
 2445/2445 ━━━━━━━━━━━━━━━━━━━━ 216s 66ms/步 - loss: 5.0008 - perplexity: 180.0715 - val_loss: 4.2176 - val_perplexity: 68.0438
第 2/5 轮
 2445/2445 ━━━━━━━━━━━━━━━━━━━━ 127s 48ms/步 - loss: 4.1699 - perplexity: 64.7740 - val_loss: 4.0553 - val_perplexity: 57.7996
第 3/5 轮
 2445/2445 ━━━━━━━━━━━━━━━━━━━━ 126s 47ms/步 - loss: 4.0286 - perplexity: 56.2138 - val_loss: 4.0134 - val_perplexity: 55.4446
第 4/5 轮
 2445/2445 ━━━━━━━━━━━━━━━━━━━━ 134s 50ms/步 - loss: 3.9576 - perplexity: 52.3643 - val_loss: 3.9900 - val_perplexity: 54.1153
第 5/5 轮
 2445/2445 ━━━━━━━━━━━━━━━━━━━━ 135s 51ms/步 - loss: 3.9080 - perplexity: 49.8242 - val_loss: 3.9500 - val_perplexity: 52.0006

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

推理

使用我们训练好的模型,我们可以进行测试以评估其性能。为此,我们可以用以 "[BOS]" 令牌开头的输入序列对模型进行初始化,并在循环中逐步预测每个后续令牌。

首先构建一个与模型输入形状相同、仅包含 "[BOS]" 令牌的提示。

# “packer” 层为我们添加 [BOS] 令牌。
prompt_tokens = start_packer(tokenizer([""]))
prompt_tokens
<tf.Tensor: shape=(1, 128), dtype=int32, numpy=
array([[2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
      dtype=int32)>

我们将使用 keras_nlp.samplers 模块进行推理,这需要一个包装我们刚刚训练的模型的回调函数。该封装函数调用模型并返回我们正在生成的当前令牌的对数预测。

注意:在定义回调时,有两个更高级的功能可用。第一个是能够接收在之前生成步骤中计算的“状态”缓存,这可以用来加速生成。第二个是能够输出每个生成令牌的最终密集“隐藏状态”。这由 keras_nlp.samplers.ContrastiveSampler 使用,通过惩罚重复的隐藏状态来避免重复。两者都是可选的,我们暂时将忽略它们。

def next(prompt, cache, index):
    logits = model(prompt)[:, index - 1, :]
    # 目前忽略隐藏状态;仅在对比搜索时需要。
    hidden_states = None
    return logits, hidden_states, cache

创建包装函数是使用这些函数中最复杂的部分。现在它已经完成,让我们测试一下不同的实用工具,从贪婪搜索开始。

贪婪搜索

我们在每个时间步贪婪地选择最可能的标记。换句话说,我们获取模型输出的 argmax。

sampler = keras_nlp.samplers.GreedySampler()
output_tokens = sampler(
    next=next,
    prompt=prompt_tokens,
    index=1,  # 从 [BOS] 标记后立即开始采样。
)
txt = tokenizer.detokenize(output_tokens)
print(f"贪婪搜索生成的文本: \n{txt}\n")
贪婪搜索生成的文本: 
[b'[BOS] " 我将告诉你 , " 男孩说 , " 我会告诉你 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友 , 你会是一个好朋友']

如你所见,贪婪搜索一开始是有些意义的,但很快就开始重复自己。这是文本生成中一个常见的问题,可以通过稍后展示的一些概率文本生成工具来解决!

幅束搜索

从高层次来看,幅束搜索在每个时间步跟踪 num_beams 个最可能的序列,并从所有序列中预测下一个最佳标记。与贪婪搜索相比,它的改进在于存储了更多的可能性。然而,它的效率低于贪婪搜索,因为它必须计算和存储多个潜在序列。

注意: 使用 num_beams=1 的幅束搜索与贪婪搜索是相同的。

sampler = keras_nlp.samplers.BeamSampler(num_beams=10)
output_tokens = sampler(
    next=next,
    prompt=prompt_tokens,
    index=1,
)
txt = tokenizer.detokenize(output_tokens)
print(f"幅束搜索生成的文本: \n{txt}\n")
幅束搜索生成的文本: 
[b'[BOS] " 我什么都不知道 , " 她说 。 " 我什么都不知道 。 我什么都不知道 , 但是我什么都不知道 。 我什么都不知道 , 但是我什么都不知道 。 我什么都不知道 , 但是我不知道 。 我不知道 , 但是我不知道 。 我不知道 , 但是我不知道 。 我不知道 , 但是我不知道 。 我不知道 , 但是我不知道 。']

与贪婪搜索类似,幅束搜索也很快开始重复自己,因为它仍然是一种确定性方法。

随机搜索

随机搜索是我们第一个概率方法。在每个时间步,它使用模型提供的 softmax 概率来抽样下一个标记。

sampler = keras_nlp.samplers.RandomSampler()
output_tokens = sampler(
    next=next,
    prompt=prompt_tokens,
    index=1,
)
txt = tokenizer.detokenize(output_tokens)
print(f"随机搜索生成的文本: \n{txt}\n")
随机搜索生成的文本: 
[b'[BOS] 艾莉诺 。 像冰 , 而不是孩子们会有可疑的额头 。 他们会看到他 , 在她的李子里没有好处 。 我做了一个桩 , 在那个场合 , - - 这是神圣的 , 而一个是不洁的 - 玩具 - - 部分后果 , 以及一个在一个男孩的风格中的避难所 , 他是他的祖母 。 他是一位年轻的绅士 , 在一天的中间带走了他 , 冲了过去 , 当他虐待女性社会时 , 正在迅速成长 。 在那个小小的剧中 , 停止']

瞧,没有重复!然而,使用随机搜索,我们可能会看到一些无意义的单词出现,因为在这种采样方法中,词汇表中的任何单词都有可能出现。这可以通过我们的下一个搜索工具,top-k 搜索来解决。

Top-K 搜索

与随机搜索类似,我们从模型提供的概率分布中抽样下一个标记。唯一的区别在于,这里我们选择最可能的前 k 个标记,并在它们之间分配概率质量,然后再进行抽样。这样,我们就不会从低概率标记中进行抽样,因此我们会减少无意义的单词!

sampler = keras_nlp.samplers.TopKSampler(k=10)
output_tokens = sampler(
    next=next,
    prompt=prompt_tokens,
    index=1,
)
txt = tokenizer.detokenize(output_tokens)
print(f"Top-K 搜索生成的文本: \n{txt}\n")
Top-K 搜索生成的文本: 
[b'[BOS] " 年轻人不是那个 , 而男孩走向绿色森林 。 他们是一个小女孩的妻子 , 孩子爱他, 就像他爱她一样 , 他常常听说有一个小女孩住在那所房子附近 。 他们太累了无法去 , 当他们下到谷仓并进入谷仓时 , 他们得到了他们曾经学过的第一批谷仓 , 小人们回到家中 。 她这样做 , 她告诉他们她一直很聪明 , 他们得到了第一批 。 她知道他们']

Top-P 搜索

即使是使用 top-k 搜索,还有改进的空间。使用 top-k 搜索,数字 k 是固定的,这意味着它会选择相同数量的标记用于任何概率分布。考虑两种情况,一种是概率质量集中在 2 个单词上,另一种是概率质量均匀分布在 10 个单词上。我们应该选择 k=2 还是 k=10?这里没有适合所有情况的 k

这就是 top-p 搜索的用武之地!与其选择一个 k,不如选择一个我们希望前几个标记的概率总和为 p。这样,我们可以根据概率分布动态调整 k。通过设置 p=0.9,如果 90% 的概率质量集中在前 2 个标记上,我们可以从中筛选出这 2 个标记进行抽样。如果 90% 的概率分布在 10 个标记上,它也将相应地筛选出这 10 个标记进行抽样。

sampler = keras_nlp.samplers.TopPSampler(p=0.5)
output_tokens = sampler(
    next=next,
    prompt=prompt_tokens,
    index=1,
)
txt = tokenizer.detokenize(output_tokens)
print(f"Top-P搜索生成的文本: \n{txt}\n")
Top-P搜索生成的文本: 
[b'[BOS] 孩子们都是在春天出生的,最小的妹妹与其他孩子非常相像,但他们并没有看到他们。他们非常快乐,他们的母亲是个美丽的人。最小的孩子是最小的妹妹之一,最小的婴儿非常喜欢这些孩子。当他们回到家时,他们会看到房子里有一个小女孩,还有一个美丽的家庭,孩子们不得不坐着看着他们的背影,大的孩子们非常高,他们是如此明亮和快乐,正如他们那样,他们从未注意到他们的头发,']

使用回调进行文本生成

我们还可以将实用程序包装在回调中,这样您就可以在模型的每个epoch中打印出预测序列!以下是top-k搜索的回调示例:

class TopKTextGenerator(keras.callbacks.Callback):
    """一个使用top-k从训练模型生成文本的回调。"""

    def __init__(self, k):
        self.sampler = keras_nlp.samplers.TopKSampler(k)

    def on_epoch_end(self, epoch, logs=None):
        output_tokens = self.sampler(
            next=next,
            prompt=prompt_tokens,
            index=1,
        )
        txt = tokenizer.detokenize(output_tokens)
        print(f"Top-K搜索生成的文本: \n{txt}\n")


text_generation_callback = TopKTextGenerator(k=10)
# 虚拟训练循环以演示回调。
model.fit(train_ds.take(1), verbose=2, epochs=2, callbacks=[text_generation_callback])
Epoch 1/2
Top-K搜索生成的文本: 
[b"[BOS] 年轻人正处在一个月的中间,他能够抓住胯部,但很久,因为他对自己感到很好,塞波伊的手中是粉笔。他是唯一的男孩,并且在几年前已经结婚,那人说他是个高个子。他非常英俊,并且是一个非常英俊的年轻小伙子,以及一个英俊的、高贵的年轻人,但一个男孩和男人。他是一个非常英俊的男人,身材高大且英俊,看起来像个绅士。他是一个"]
1/1 - 16s - 16s/step - loss: 3.9454 - perplexity: 51.6987
Epoch 2/2
Top-K搜索生成的文本: 
[b'[BOS] " 好吧,确实。的确,我应该去一位收藏家的家,关于普鲁士的事情那里没有其他办法。没有机会习惯于被入侵。我不知道我做了什么,但我看到了那个人在一天的中间。第二天早上,我会把他带到我父亲那里,因为我不是城里的那一天,这应该比某人的女儿多一点,我想过这一切,整个事情将会是']
1/1 - 17s - 17s/step - loss: 3.7860 - perplexity: 44.0932

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

结论

总而言之,在这个例子中,我们使用KerasNLP层来训练一个子词词汇,标记化训练数据,创建一个迷你GPT模型,并利用文本生成库进行推理。

如果您想了解Transformers是如何工作的,或者想了解更多关于训练完整GPT模型的信息,这里有一些进一步阅读的资料: