用TensorFlow/Keras在Python中生成5行GPT风格的文本

392 阅读15分钟

变形金刚即使在2017年发布,也是在最近几年才开始获得巨大的吸引力。随着该技术通过HuggingFace等平台的扩散,NLP和*大型语言模型(LLM)*变得比以往任何时候都更容易获得。

然而,即使有了围绕它们的所有炒作和许多以理论为导向的指南,网上也没有许多定制的实现,而且资源也不像其他一些网络类型那样容易获得,这些网络已经存在了很久。虽然你可以通过使用来自HuggingFace的预构建的Transformer(另一个指南的主题)来简化你的工作周期,但你可以通过自己构建一个来感受它是如何工作的,然后再通过一个库将它抽象出来。在这里我们将专注于构建,而不是理论和优化。

在本指南中,我们将建立一个自回归语言模型生成文本。我们将专注于加载数据、分割数据、向量化数据、建立模型、编写自定义回调和训练/推理等实用和简约的方面。这些任务中的每一项都可以衍生出更详细的指南,所以我们将保持通用的实现方式,根据你自己的数据集留下定制和优化的空间。

LLMs的类型和GPT-Fyodor

虽然分类可以变得更加复杂--但你可以大致将基于Transformer的语言模型归为三类:

  • 基于编码器的模型- ALBERT, BERT, DistilBERT, RoBERTa
  • 基于解码器的模型--GPT, GPT-2, GPT-3, TransformerXL
  • Seq2Seq模型--BART, mBART, T5

基于编码器的模型在其架构中只使用一个Transformer编码器(通常是堆叠的),对于理解句子(分类、命名实体识别、问题回答)非常好。

基于解码器的模型在其架构中只使用一个Transformer解码器(通常也是堆叠的),对未来的预测非常好,这使它们适合于文本生成。

Seq2Seq模型结合了编码器和解码器,在文本生成、总结和最重要的--翻译方面非常出色。

GPT系列模型在过去几年中获得了很大的发展,是基于解码器的转化器模型,在产生类似人类的文本方面非常出色,在大型数据语料库上进行训练,并给出一个提示作为生成的新起始种子。比如说:

generate_text('the truth ultimately is')

其中在引擎盖下将这个提示送入一个类似GPT的模型,并产生:

'the truth ultimately is really a joy in history, this state of life through which is almost invisible, superfluous  teleological...'

事实上,这是本指南结尾处的一个小破坏者!另一个小破坏者是产生该文本的架构:

inputs = layers.Input(shape=(maxlen,))
embedding_layer = keras_nlp.layers.TokenAndPositionEmbedding(vocab_size, maxlen, embed_dim)(inputs)
transformer_block = keras_nlp.layers.TransformerDecoder(embed_dim, num_heads)(embedding_layer)
outputs = layers.Dense(vocab_size, activation='softmax')(transformer_block)
    
model = keras.Model(inputs=inputs, outputs=outputs)

5行就可以建立一个仅有解码器的变压器模型--模拟一个小型GPT。由于我们将在费奥多尔-陀思妥耶夫斯基的小说上训练这个模型(你可以用其他任何东西代替,从维基百科到Reddit评论)--我们暂且称这个模型为GPT-Fyodor

KerasNLP

5线GPT-Fyodor的诀窍在于 KerasNLP,它是由官方的Keras团队开发的,作为Keras的横向扩展,它以真正的Keras方式,旨在将业界强大的NLP带到你的指尖,有新的层(编码器、解码器、标记嵌入、位置嵌入、度量、标记器等)。

KerasNLP并不是一个模型动物园。它是Keras的一部分(作为一个独立的包),它降低了NLP模型开发的门槛,就像它降低了一般深度学习开发的主包的门槛。

注意:截至发稿时,KerasNLP仍在制作中,且处于早期阶段。在未来的版本中可能会出现细微的差别。本篇文章利用的是0.3.0 版本。

为了能够使用KerasNLP,你必须通过pip 安装它:

$ pip install keras_nlp

你可以通过以下方式验证该版本:

keras_nlp.__version__
# 0.3.0

用Keras实现一个GPT风格的模型

让我们从导入我们将要使用的库开始--TensorFlow、Keras、KerasNLP和NumPy:

import tensorflow as tf
from tensorflow import keras
import keras_nlp
import numpy as np

加载数据

让我们加载一些陀思妥耶夫斯基的小说--对于一个模型来说,一部小说太短了,从早期阶段开始就会有相当多的过度拟合。我们将优雅地使用来自古腾堡计划的原始文本文件,因为处理这种数据很简单。

crime_and_punishment_url = 'https://www.gutenberg.org/files/2554/2554-0.txt'
brothers_of_karamazov_url = 'https://www.gutenberg.org/files/28054/28054-0.txt'
the_idiot_url = 'https://www.gutenberg.org/files/2638/2638-0.txt'
the_possessed_url = 'https://www.gutenberg.org/files/8117/8117-0.txt'

paths = [crime_and_punishment_url, brothers_of_karamazov_url, the_idiot_url, the_possessed_url]
names = ['Crime and Punishment', 'Brothers of Karamazov', 'The Idiot', 'The Possessed']
texts = ''
for index, path in enumerate(paths):
    filepath = keras.utils.get_file(f'{names[index]}.txt', origin=path)
    text = ''
    with open(filepath, encoding='utf-8') as f:
        text = f.read()
        # First 50 lines are the Gutenberg intro and preface
        # Skipping first 10k characters for each book should be approximately
        # removing the intros and prefaces.
        texts += text[10000:]

我们简单地下载了所有的文件,浏览了它们,并将它们一个个串联起来。这包括所使用的语言的一些多样性,同时仍然保持明显的费奥多尔风格对于每个文件,我们都跳过了前一万个字符,这大约是序言和古腾堡介绍的平均长度,所以我们在每次迭代中都留下了一个基本完整的书体。现在让我们看看texts 字符串中的一些随机的500个字符。

# 500 characters
texts[25000:25500]
'nd that was why\nI addressed you at once. For in unfolding to you the story of my life, I\ndo not wish to make myself a laughing-stock before these idle listeners,\nwho indeed know all about it already, but I am looking for a man\nof feeling and education. Know then that my wife was educated in a\nhigh-class school for the daughters of noblemen, and on leaving she\ndanced the shawl dance before the governor and other personages for\nwhich she was presented with a gold medal and a certificate of merit.\n'

在做任何其他处理之前,让我们把这个字符串分成几个句子。

text_list = texts.split('.')
len(text_list) # 69181

我们已经有了69k个句子。当你把\n 字符替换成空白,并计算单词。

len(texts.replace('\n', ' ').split(' ')) # 1077574

注意:一般来说,你希望在一个数据集中至少有一百万个词,最好是比这多得多。我们正在处理几兆字节的数据(约5MB),而语言模型通常是在几十兆字节的文本上进行训练。这自然会使我们很容易对文本输入进行过度拟合,而很难进行归纳(在没有过度拟合的情况下有很高的困惑度,或者在有很多过度拟合的情况下有很低的困惑度)。对这些结果持谨慎态度。

尽管如此,让我们把这些分成一个训练测试验证集。首先,让我们删除空字符串,并对句子进行洗牌。

# Filter out empty strings ('') that are to be found commonly due to the book's format
text_list = list(filter(None, text_list))

import random
random.shuffle(text_list)

然后,我们做一个70/15/15的分割:

length = len(text_list)
text_train = text_list[:int(0.7*length)]
text_test = text_list[int(0.7*length):int(0.85*length)]
text_valid = text_list[int(0.85*length):]

这是一个简单而有效的方法来进行训练-测试-验证的分割。让我们看一下text_train

[' It was a dull morning, but the snow had ceased',
 '\n\n"Pierre, you who know so much of what goes on here, can you really have\nknown nothing of this business and have heard nothing about it?"\n\n"What? What a set! So it\'s not enough to be a child in your old age,\nyou must be a spiteful child too! Varvara Petrovna, did you hear what he\nsaid?"\n\nThere was a general outcry; but then suddenly an incident took place\nwhich no one could have anticipated', ...

是时候进行标准化和矢量化了!

文本矢量化

网络不理解文字--它们理解数字。我们要对单词进行标记:

...
sequence = ['I', 'am', 'Wall-E']
sequence = tokenize(sequence)
print(sequence) # [4, 26, 472]
...

此外,由于句子的长度不同--通常会在左边或右边添加填充物,以确保被输入的句子具有相同的形状。假设我们最长的句子是5个字(tokens)长。在这种情况下,Wall-E的句子将被填充两个零,这样我们就能确保输入的形状相同。

sequence = pad_sequence(sequence)
print(sequence) # [4, 26, 472, 0, 0]

传统上,这是用TensorFlowTokenizer 和Keras的pad_sequences() 方法完成的--然而,可以使用一个更方便的层,TextVectorization ,它对你的输入进行标记填充,允许你提取词汇和它的大小,而不需要预先知道词汇!

让我们改编一下,装一个TextVectorization 层:

from tensorflow.keras.layers import TextVectorization

def custom_standardization(input_string):
    sentence = tf.strings.lower(input_string)
    sentence = tf.strings.regex_replace(sentence, "\n", " ")
    return sentence

maxlen = 50
# You can also set calculate the longest sentence in the data - 25 in this case
# maxlen = len(max(text_list).split(' ')) 

vectorize_layer = TextVectorization(
    standardize = custom_standardization,
    output_mode="int",
    output_sequence_length=maxlen + 1,
)

vectorize_layer.adapt(text_list)
vocab = vectorize_layer.get_vocabulary()

custom_standardization() 方法可以比这个长很多。我们只是简单地将所有的输入小写,并用" " 来代替\n 。这就是你真正可以把大部分的文本预处理的地方--通过可选的standardize 参数提供给矢量化层。一旦你adapt() 到文本层(NumPy数组或文本列表)--你可以从那里得到词汇,以及它的大小:

vocab_size = len(vocab)
vocab_size # 49703

最后,为了去标记单词,我们将创建一个index_lookup 字典:

index_lookup = dict(zip(range(len(vocab)), vocab))    
index_lookup[5] # of

它将所有的标记([1, 2, 3, 4, ...])映射到词汇表(['a', 'the', 'i', ...])中的词语。通过传入一个键(token index),我们可以很容易地得到单词的信息。现在你可以在任何输入上运行vectorize_layer() ,并观察矢量的句子:

vectorize_layer(['hello world!'])

其结果是:

<tf.Tensor: shape=(1, 51), dtype=int64, numpy=
array([[   1, 7509,    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=int64)>

Hello的索引是1 ,而world的索引是7509!剩下的就是对我们计算的maxlen 的填充。

我们有了对文本进行矢量化的方法--现在,让我们从text_traintext_testtext_valid ,利用我们的矢量化层作为单词和矢量之间的转换媒介,创建数据集,并将其输入GPT-Fyodor。

数据集的创建

我们将为我们的每个数据集创建一个tf.data.Dataset ,使用from_tensor_slices() ,并提供一个列表,嗯,张量切片(句子)。

batch_size = 64

train_dataset = tf.data.Dataset.from_tensor_slices(text_train)
train_dataset = train_dataset.shuffle(buffer_size=256)
train_dataset = train_dataset.batch(batch_size)

test_dataset = tf.data.Dataset.from_tensor_slices(text_test)
test_dataset = test_dataset.shuffle(buffer_size=256)
test_dataset = test_dataset.batch(batch_size)

valid_dataset = tf.data.Dataset.from_tensor_slices(text_valid)
valid_dataset = valid_dataset.shuffle(buffer_size=256)
valid_dataset = valid_dataset.batch(batch_size)

一旦创建和洗牌(再次,为了良好的措施)--我们可以应用预处理(矢量化和序列分割)功能:

def preprocess_text(text):
    text = tf.expand_dims(text, -1)
    tokenized_sentences = vectorize_layer(text)
    x = tokenized_sentences[:, :-1]
    y = tokenized_sentences[:, 1:]
    return x, y


train_dataset = train_dataset.map(preprocess_text)
train_dataset = train_dataset.prefetch(tf.data.AUTOTUNE)

test_dataset = test_dataset.map(preprocess_text)
test_dataset = test_dataset.prefetch(tf.data.AUTOTUNE)

valid_dataset = valid_dataset.map(preprocess_text)
valid_dataset = valid_dataset.prefetch(tf.data.AUTOTUNE)

preprocess_text() 函数只是通过最后一个维度进行扩展,使用我们的vectorize_layer ,对文本进行矢量化,并创建输入和目标,偏移一个标记。该模型将使用[0..n] 来推断n+1 ,为每个词产生一个预测,并考虑到之前的所有词。让我们看一下任何一个数据集中的一个条目:

for entry in train_dataset.take(1):
    print(entry)

调查返回的输入和目标,以64为一批(每批长度为30),我们可以清楚地看到它们是如何被抵消的:

(<tf.Tensor: shape=(64, 50), dtype=int64, numpy=
array([[17018,   851,     2, ...,     0,     0,     0],
       [  330,    74,     4, ...,     0,     0,     0],
       [   68,   752, 30273, ...,     0,     0,     0],
       ...,
       [    7,    73,  2004, ...,     0,     0,     0],
       [   44,    42,    67, ...,     0,     0,     0],
       [  195,   252,   102, ...,     0,     0,     0]], dtype=int64)>, <tf.Tensor: shape=(64, 50), dtype=int64, numpy=
array([[  851,     2,  8289, ...,     0,     0,     0],
       [   74,     4,    34, ...,     0,     0,     0],
       [  752, 30273,  7514, ...,     0,     0,     0],
       ...,
       [   73,  2004,    31, ...,     0,     0,     0],
       [   42,    67,    76, ...,     0,     0,     0],
       [  252,   102,  8596, ...,     0,     0,     0]], dtype=int64)>)

最后--是时候建立模型了!

模型的定义

我们将在这里使用KerasNLP层。在一个Input ,我们将通过一个TokenAndPositionEmbedding 层对输入进行编码,然后传入我们的vocab_sizemaxlenembed_dim 。该层输出和输入到TransformerDecoder 的相同的embed_dim ,将保留在解码器中。截止到目前,解码器自动保留了输入的维度,不允许你把它投射到不同的输出中,但它确实让你通过intermediate_dim 参数来定义潜伏维。

我们将把嵌入维度乘以2来表示潜伏维度,但是你可以保持不变或者使用一个从嵌入维度中分离出来的数字。

embed_dim = 128
num_heads = 4

def create_model():
    inputs = keras.layers.Input(shape=(maxlen,), dtype=tf.int32)
    embedding_layer = keras_nlp.layers.TokenAndPositionEmbedding(vocab_size, maxlen, embed_dim)(inputs)
    decoder = keras_nlp.layers.TransformerDecoder(intermediate_dim=embed_dim, 
                                                            num_heads=num_heads, 
                                                            dropout=0.5)(embedding_layer)
    
    outputs = keras.layers.Dense(vocab_size, activation='softmax')(decoder)
    
    model = keras.Model(inputs=inputs, outputs=outputs)
    
    model.compile(
        optimizer="adam", 
        loss='sparse_categorical_crossentropy',
        metrics=[keras_nlp.metrics.Perplexity(), 'accuracy']
    )
    return model

model = create_model()
model.summary()

在解码器的上面,我们有一个Dense 层来选择序列中的下一个词,有一个softmax 激活(产生每个下一个标记的概率分布)。让我们来看看这个模型的总结。

Model: "model_5"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 input_6 (InputLayer)        [(None, 30)]              0         
                                                                 
 token_and_position_embeddin  (None, 30, 128)          6365824   
 g_5 (TokenAndPositionEmbedd                                     
 ing)                                                            
                                                                 
 transformer_decoder_5 (Tran  (None, 30, 128)          132480    
 sformerDecoder)                                                 
                                                                 
 dense_5 (Dense)             (None, 30, 49703)         6411687   
                                                                 
=================================================================
Total params: 13,234,315
Trainable params: 13,234,315
Non-trainable params: 0
_________________________________________________________________

GPT-2堆叠了许多解码器--GPT-2小型有12个堆叠的解码器(117M参数),而GPT-2特大型有48个堆叠的解码器(1.5B参数)。我们的单解码器模型有13M的参数,对于教育目的来说应该足够好。对于LLM来说--扩大规模已经被证明是一个非常好的策略,而变形金刚允许良好的扩展,使得训练极其庞大的模型是可行的。

GPT-3有一个*"微薄的 "*175B参数。谷歌大脑的团队训练了一个1.6T参数的模型来进行稀疏性研究,同时将计算量保持在与小得多的模型相同的水平上。

事实上,如果我们将解码器的数量从1增加到3:

def create_model():
    inputs = keras.layers.Input(shape=(maxlen,), dtype=tf.int32)
    x = keras_nlp.layers.TokenAndPositionEmbedding(vocab_size, maxlen, embed_dim)(inputs)
    for i in range(4):
        x = keras_nlp.layers.TransformerDecoder(intermediate_dim=embed_dim*2, num_heads=num_heads,                                                             dropout=0.5)(x)
    do = keras.layers.Dropout(0.4)(x)
    outputs = keras.layers.Dense(vocab_size, activation='softmax')(do)
    
    model = keras.Model(inputs=inputs, outputs=outputs)

我们的参数数将增加400k:

Total params: 13,631,755
Trainable params: 13,631,755
Non-trainable params: 0

我们网络中的大部分参数来自于TokenAndPositionEmbeddingDense 层!

试试不同深度的解码器--从1到你的机器能处理的所有方式,并注意结果。在任何情况下--我们几乎已经准备好训练模型了!让我们创建一个自定义回调,在每个 epoch 上产生一个文本样本,这样我们就可以看到模型是如何通过训练来学习形成句子的。

自定义回调

class TextSampler(keras.callbacks.Callback):
    def __init__(self, start_prompt, max_tokens):
        self.start_prompt = start_prompt
        self.max_tokens = max_tokens
        
    # Helper method to choose a word from the top K probable words with respect to their probabilities
    # in a sequence
    def sample_token(self, logits):
        logits, indices = tf.math.top_k(logits, k=5, sorted=True)
        indices = np.asarray(indices).astype("int32")
        preds = keras.activations.softmax(tf.expand_dims(logits, 0))[0]
        preds = np.asarray(preds).astype("float32")
        return np.random.choice(indices, p=preds)

    def on_epoch_end(self, epoch, logs=None):
        decoded_sample = self.start_prompt
        
        for i in range(self.max_tokens-1):
            tokenized_prompt = vectorize_layer([decoded_sample])[:, :-1]
            predictions = self.model.predict([tokenized_prompt], verbose=0)
            # To find the index of the next word in the prediction array.
            # The tokenized prompt is already shorter than the original decoded sample
            # by one, len(decoded_sample.split()) is two words ahead - so we remove 1 to get
            # the next word in the sequence
            sample_index = len(decoded_sample.strip().split())-1
            
            sampled_token = self.sample_token(predictions[0][sample_index])
            sampled_token = index_lookup[sampled_token]
            decoded_sample += " " + sampled_token
            
        print(f"\nSample text:\n{decoded_sample}...\n")

# First 5 words of a random sentence to be used as a seed
random_sentence = ' '.join(random.choice(text_valid).replace('\n', ' ').split(' ')[:4])
sampler = TextSampler(random_sentence, 30)
reducelr = keras.callbacks.ReduceLROnPlateau(patience=10, monitor='val_loss')

训练模型

最后,是时候进行训练了!让我们把我们的train_datasetvalidation_dataset ,回调已经到位:

model = create_model()
history = model.fit(train_dataset, 
                    validation_data=valid_dataset,
                    epochs=10, 
                    callbacks=[sampler, reducelr])

取样器选择了一个不幸的句子,它以结束语和开始语开始,但它在训练时仍然产生了有趣的结果:

# Epoch training
Epoch 1/10
658/658 [==============================] - ETA: 0s - loss: 2.7480 - perplexity: 15.6119 - accuracy: 0.6711
# on_epoch_end() sample generation
Sample text:
”  “What do you had not been i had been the same man was not be the same eyes to been a whole man and he did a whole man to the own...
# Validation
658/658 [==============================] - 158s 236ms/step - loss: 2.7480 - perplexity: 15.6119 - accuracy: 0.6711 - val_loss: 2.2130 - val_perplexity: 9.1434 - val_accuracy: 0.6864 - lr: 0.0010
...
Sample text:
”  “What do you know it is it all this very much as i should not have a great impression  in the room to be  able of it in my heart...

658/658 [==============================] - 149s 227ms/step - loss: 1.7753 - perplexity: 5.9019 - accuracy: 0.7183 - val_loss: 2.0039 - val_perplexity: 7.4178 - val_accuracy: 0.7057 - lr: 0.0010

它的开头是

"你没有什么我一直都是一样的"......

这其实并没有什么意义。到了10个短暂的纪元结束时,它产生了一些类似的结果。

"你的意思是,这是一个人最普通的人,当然"......

虽然第二句话仍然没有太多意义--但它比第一句话有意义多了。在更多的数据上进行更长时间的训练(有更复杂的预处理步骤)会产生更好的结果。我们只在10个历时上进行了训练,为了应对小数据集的问题,我们采用了高辍学率。如果让它训练更长的时间,它将产生非常像费奥多尔的文本,因为它已经记住了大块的文本。

注意:由于输出相当冗长,你可以在拟合模型时调整verbose 参数,以减少屏幕上的文字量。

模型推理

为了进行推断,我们要复制TextSampler 的接口--一个接受种子和response_length (max_tokens) 的方法。我们将使用与采样器内相同的方法:

def sample_token(logits):
        logits, indices = tf.math.top_k(logits, k=5, sorted=True)
        indices = np.asarray(indices).astype("int32")
        preds = keras.activations.softmax(tf.expand_dims(logits, 0))[0]
        preds = np.asarray(preds).astype("float32")
        return np.random.choice(indices, p=preds)

def generate_text(prompt, response_length=20):
    decoded_sample = prompt
    for i in range(response_length-1):
        tokenized_prompt = vectorize_layer([decoded_sample])[:, :-1]
        predictions = model.predict([tokenized_prompt], verbose=0)
        sample_index = len(decoded_sample.strip().split())-1

        sampled_token = sample_token(predictions[0][sample_index])
        sampled_token = index_lookup[sampled_token]
        decoded_sample += " " + sampled_token
    return decoded_sample

现在,你可以在新的样本上运行这个方法:

generate_text('the truth ultimately is')
# 'the truth ultimately is really a joy in history, this state of life through which is almost invisible, superfluous  teleological'

generate_text('the truth ultimately is')
# 'the truth ultimately is not to make it a little   thing to go into your own  life for some'

改进结果?

那么,你怎样才能改善结果呢?有一些相当可操作的事情你可以做:

  • 数据清理(更细致地清理输入数据,我们只是从开始就修剪了一个近似的数字,并删除了换行字符)
  • 获得更多的数据(我们只用了几兆字节的文本数据)。
  • 将模型与数据一起放大(堆叠解码器并不难!)。

结论

虽然预处理管道是简约的,而且可以改进--本指南中概述的管道产生了一个体面的GPT风格的模型,只需要5行代码就可以建立一个自定义的仅有解码器的转化器,使用Keras!这是很重要的。

变换器在通用序列建模中很受欢迎,而且应用广泛(很多东西都可以表达为序列)。到目前为止,进入的主要障碍是繁琐的实现,但有了KerasNLP--深度学习实践者可以利用实现来快速、轻松地建立模型。