作者 | dylan wenzlau
本文介绍如何构建深度转换网络实现端到端的文本生成。在这一过程中,包括有关数据清理,训练,模型设计和预测算法相关的内容。
第1步:构建训练数据
数据集使用了Imgflip Meme Generator(一款根据文本生成表情包的工具)用户的~100M公共memes标题。为了加速训练并降低模型的复杂性,仅使用48个最受欢迎的Meme(表情包)和每个Meme(表情包)准确的20,000个字幕,总计960,000个字幕作为训练数据。每个角色都会有一个训练示例在标题中,总计约45,000,000个训练样例。这里选择了角色级生成而不是单词级别,因为Meme(表情包)倾向于使用拼写和语法。此外字符级深度学习是单词级深度学习的超集,因此如果有足够的数据并且模型设计足以了解所有复杂性,则可以实现更高的准确性。如果尝试下面的完成模型,还会看到char级别可以更有趣!
https://imgflip.com/memegenerator
以下是第一个Meme(表情包)标题是“制作所有memes”时的训练数据。省略了从数据库中读取代码并执行初始清理的代码,因为它非常标准,可以通过多种方式完成。
training_data = [
["000000061533 0 ", "m"],
["000000061533 0 m", "a"],
["000000061533 0 ma", "k"],
["000000061533 0 mak", "e"],
["000000061533 0 make", "|"],
["000000061533 1 make|", "a"],
["000000061533 1 make|a", "l"],
["000000061533 1 make|al", "l"],
["000000061533 1 make|all", " "],
["000000061533 1 make|all ", "t"],
["000000061533 1 make|all t", "h"],
["000000061533 1 make|all th", "e"],
["000000061533 1 make|all the", " "],
["000000061533 1 make|all the ", "m"],
["000000061533 1 make|all the m", "e"],
["000000061533 1 make|all the me", "m"],
["000000061533 1 make|all the mem", "e"],
["000000061533 1 make|all the meme", "s"],
["000000061533 1 make|all the memes", "|"],
... 45 million more rows here ...
]# we'll need our feature text and labels as separate arrays later
texts = [row[0] for row in training_data]
labels = [row[1] for row in training_data]
像机器学习中的大多数事情一样,这只是一个分类问题。将左侧的文本字符串分类为~70个不同的buckets 中的一个,其中buckets 是字符。
解压缩格式:
- 前12个字符是Meme(表情包)模板ID。这允许模型区分正在训练它的48个不同的Meme(表情包)。字符串左边用零填充,因此所有ID都是相同的长度。
- 0或1是被预测的当前文本框的索引,一般0是机顶盒和1是底盒,虽然许多记因是更复杂的。这两个空格只是额外的间距,以确保模型可以将框索引与模板ID和Meme(表情包)文本区分开来。注意:至关重要的是卷积内核宽度(在本文后面看到)不比4个空格加上索引字符(也就是≤5)宽。
- 之后是meme的文本,用|作为文本框的结尾字符。
- 最后一个字符(第二个数组项)是序列中的下一个字符。
在训练之前,数据使用了几种清洗技术:
- 调整前导和尾随空格,并用\s+单个空格字符替换重复的空格()。
- 应用最少10个字符的字符串长度,这样就不会生成无聊的单字或单字母Memes(表情包文本)。
- 应用最大字符串长度为82个字符,因此不会生成超长表情包字符,因为模型将更快地训练。82是任意的,它只是使整个训练字符串大约100个字符。
- 将所有内容转换为小写以减少模型必须学习的字符数,并且因为许多Memes(表情包文本)只是全部大写。
- 使用非ascii字符跳过meme标题可以降低模型必须学习的复杂性。这意味着特征文本和标签都将来自一组仅约70个字符,具体取决于训练数据恰好包含哪些ascii字符。
- 跳过包含竖线字符的meme标题,|因为它是特殊的文本框结尾字符。
- 通过语言检测库运行文本,并跳过不太可能是英语的meme标题。提高生成的文本的质量,因为模型只需要学习一种语言,相同的字符序列可以在多种语言中有意义。
- 跳过已添加到训练集中的重复Memes(表情包文本)标题,以减少模型简单记忆整个Memes(表情包文本)标题的机会。
数据现在已准备就绪,可以输入神经网络!
第2步:数据转换
首先,在代码中导入python库:
from keras import Sequentialfrom keras.preprocessing.sequence import pad_sequencesfrom keras.callbacks import ModelCheckpointfrom keras.layers import Dense, Dropout, GlobalMaxPooling1D, Conv1D, MaxPooling1D, Embeddingfrom keras.layers.normalization import BatchNormalizationimport numpy as npimport util # util is a custom file I wrote, see github link below
因为神经网络只能对张量(向量/矩阵/多维数组)进行操作,所以需要对文本进行转化。每个训练文本将通过从数据中找到的约70个唯一字符的数组中用相应的索引替换每个字符,将其转换为整数数组(等级1张量)。字符数组的顺序是任意的,但选择按字符频率对其进行排序,以便在更改训练数据量时保持大致一致。Keras有一个Tokenizer类,可以使用它(使用char_level = True),这里使用的是自己的util函数,因为它比Keras tokenizer更快。
# output: {' ': 1, '0': 2, 'e': 3, ... }
char_to_int = util.map_char_to_int(texts)# output: [[2, 2, 27, 11, ...], ... ]
sequences = util.texts_to_sequences(texts, char_to_int)
labels = [char_to_int[char] for char in labels]
这些是数据按频率顺序包含的字符:
0etoains|rhl1udmy2cg4p53wf6b897kv."!?j:x,*"z-q/&$)(#%+_@=>;
接下来将填充带有前导零的整数序列,因此它们的长度都相同,因为模型的张量数学要求每个训练示例的形状相同。(注意:可以在这里使用低至100的长度,因为文本只有100个字符,但希望以后所有的池操作都可以被2完全整除。)
SEQUENCE_LENGTH = 128
data = pad_sequences(sequences, maxlen=SEQUENCE_LENGTH)
最后将调整训练数据并将其分为训练和验证集。改组(随机化顺序)确保数据的特定子集不总是用于验证准确性的子集。将一些数据拆分成验证集使能够衡量模型在不允许它用于训练的示例上的表现。
# randomize order of training data
indices = np.arange(data.shape[0])
np.random.shuffle(indices)
data = data[indices]
labels = labels[indices]# validation set can be much smaller if we use a lot of data
validation_ratio = 0.2 if data.shape[0] 1000000 else 0.02
num_validation_samples = int(validation_ratio * data.shape[0])
x_train = data[:-num_validation_samples]
y_train = labels[:-num_validation_samples]
x_val = data[-num_validation_samples:]
y_val = labels[-num_validation_samples:]
第3步:模型设计
这里选择使用卷积网络,在Keras上构建conv网络模型的代码如下:
EMBEDDING_DIM = 16
model = Sequential()
model.add(Embedding(len(char_to_int) + 1, EMBEDDING_DIM, input_length=SEQUENCE_LENGTH))
model.add(Conv1D(1024, 5, activation='relu', padding='same'))
model.add(BatchNormalization())
model.add(MaxPooling1D(2))
model.add(Dropout(0.25))
model.add(Conv1D(1024, 5, activation='relu', padding='same'))
model.add(BatchNormalization())
model.add(MaxPooling1D(2))
model.add(Dropout(0.25))
model.add(Conv1D(1024, 5, activation='relu', padding='same'))
model.add(BatchNormalization())
model.add(MaxPooling1D(2))
model.add(Dropout(0.25))
model.add(Conv1D(1024, 5, activation='relu', padding='same'))
model.add(BatchNormalization())
model.add(MaxPooling1D(2))
model.add(Dropout(0.25))
model.add(Conv1D(1024, 5, activation='relu', padding='same'))
model.add(BatchNormalization())
model.add(GlobalMaxPooling1D())
model.add(Dropout(0.25))
model.add(Dense(1024, activation='relu'))
model.add(BatchNormalization())
model.add(Dropout(0.25))
model.add(Dense(len(labels_index), activation='softmax'))
model.compile(loss='sparse_categorical_crossentropy', optimizer='rmsprop', metrics=['acc'])
代码步骤如下:
首先,模型使用Keras嵌入将每个输入示例从128个整数的数组(每个表示一个文本字符)转换为128x16矩阵。嵌入是一个层,它学习将每个字符转换为表示为整数的最佳方式,而不是表示为16个浮点数的数组[0.02, ..., -0.91]。这允许模型通过在16维空间中将它们彼此靠近地嵌入来了解哪些字符的使用类似,并最终提高模型预测的准确性。
接下来,添加5个卷积层,每个层的内核大小为5,1024个过滤器,以及ReLU激活。从概念上讲,第一个转换层正在学习如何从字符构造单词,后来的层正在学习构建更长的单词和单词链(n-gram),每个单词都比前一个更抽象。
- padding='same' 用于确保图层的输出尺寸与输入尺寸相同,因为否则宽度5卷积会使内核的每一侧的图层尺寸减小2。
- 选择1024作为滤波器的数量,因为它是训练速度和模型精度之间的良好折衷,由试验和错误确定。对于其他数据集,我建议从128个过滤器开始,然后将其增加/减少两倍,以查看会发生什么。更多过滤器通常意味着更好的模型准确性,但训练速度较慢,运行时预测较慢,模型尺寸较大。但是如果数据太少或过滤器太多,模型可能会过度拟合,精度会下降,在这种情况下,应该减少过滤器。
- 在测试尺寸为2,3,5和7之后选择大小为5的卷积核。其中2和3的卷积确实更差, 7需要更多的参数,这会使训练变慢。在研究中,其他人已经成功地使用了3到7种不同组合的卷积大小,大小为5的卷积核通常在文本数据上表现得相当不错。
- 选择ReLU激活是因为它快速,简单,并且非常适用于各种各样的用例。
- 在每个conv层之后添加批量标准化,以便基于给定批次的均值和方差对下一层的输入参数进行标准化。深度学习工程师尚未完全理解这种机制,归一化输入参数可以提高训练速度,并且由于消失/爆炸的梯度,对于更深的网络变得更加重要。在每个转换层之后添加一个Dropout层,以帮助防止该层简单地记忆数据和过度拟合。Dropout(0.25)随机丢弃25%的参数(将它们设置为零)。
- 在每个转换层之间添加MaxPooling1D(2),以将128个字符的序列“挤压”成下列层中的64,32,16和8个字符的序列。从概念上讲,这允许卷积滤波器从更深层中的文本中学习更多抽象模式,因为在每个最大池操作将维度减少2倍之后,宽度5内核将跨越两倍的字符。
在所有转换图层之后,使用全局最大合并图层,它与普通的最大合并图层相同,只是它会自动选择缩小输入尺寸以匹配下一图层的大小。最后一层只是标准的密集(完全连接)层,有1024个神经元,最后是70个神经元,因为分类器需要为70个不同的标签输出概率。
model.compile步骤非常标准。RMSprop优化器是一个不错的优化器,没有尝试为这个神经网络改变它。loss=sparse_categorical_crossentropy告诉希望它优化的模型,以便在一组2个或更多类别(又名标签)中选择最佳类别。“稀疏”部分指的是标签是0到70之间的整数,而不是长度为70的一个one-hot阵列。使用一个one-hot阵列作为标签需要更多的内存,更多的处理时间,并且不会影响模型的准确性。不要使用一个one-hot标签!
Keras有一个很好的model.summary()功能,可以查看模型:
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
embedding_1 (Embedding) (None, 128, 16) 1136
_________________________________________________________________
conv1d_1 (Conv1D) (None, 128, 1024) 82944
_________________________________________________________________
batch_normalization_1 (Batch (None, 128, 1024) 4096
_________________________________________________________________
max_pooling1d_1 (MaxPooling1 (None, 64, 1024) 0
_________________________________________________________________
dropout_1 (Dropout) (None, 64, 1024) 0
_________________________________________________________________
conv1d_2 (Conv1D) (None, 64, 1024) 5243904
_________________________________________________________________
batch_normalization_2 (Batch (None, 64, 1024) 4096
_________________________________________________________________
max_pooling1d_2 (MaxPooling1 (None, 32, 1024) 0
_________________________________________________________________
dropout_2 (Dropout) (None, 32, 1024) 0
_________________________________________________________________
conv1d_3 (Conv1D) (None, 32, 1024) 5243904
_________________________________________________________________
batch_normalization_3 (Batch (None, 32, 1024) 4096
_________________________________________________________________
max_pooling1d_3 (MaxPooling1 (None, 16, 1024) 0
_________________________________________________________________
dropout_3 (Dropout) (None, 16, 1024) 0
_________________________________________________________________
conv1d_4 (Conv1D) (None, 16, 1024) 5243904
_________________________________________________________________
batch_normalization_4 (Batch (None, 16, 1024) 4096
_________________________________________________________________
max_pooling1d_4 (MaxPooling1 (None, 8, 1024) 0
_________________________________________________________________
dropout_4 (Dropout) (None, 8, 1024) 0
_________________________________________________________________
conv1d_5 (Conv1D) (None, 8, 1024) 5243904
_________________________________________________________________
batch_normalization_5 (Batch (None, 8, 1024) 4096
_________________________________________________________________
global_max_pooling1d_1 (Glob (None, 1024) 0
_________________________________________________________________
dropout_5 (Dropout) (None, 1024) 0
_________________________________________________________________
dense_1 (Dense) (None, 1024) 1049600
_________________________________________________________________
batch_normalization_6 (Batch (None, 1024) 4096
_________________________________________________________________
dropout_6 (Dropout) (None, 1024) 0
_________________________________________________________________
dense_2 (Dense) (None, 70) 71750
=================================================================
Total params: 22,205,622
Trainable params: 22,193,334
Non-trainable params: 12,288
_________________________________________________________________
在调整上面讨论的超参数时,关注模型的参数计数很有用,它大致代表模型的学习能力总量。
第4步:训练
现在将让模型训练并使用“检查点”来保存历史和最佳模型,以便可以在训练期间的任何时候检查进度并使用最新模型进行预测。
# the path where you want to save all of this model's files
MODEL_PATH = '/home/ubuntu/imgflip/models/conv_model'# just make this large since you can stop training at any time
NUM_EPOCHS = 48# batch size below 256 will reduce training speed since# CPU (non-GPU) work must be done between each batch
BATCH_SIZE = 256# callback to save the model whenever validation loss improves
checkpointer = ModelCheckpoint(filepath=MODEL_PATH + '/model.h5', verbose=1, save_best_only=True)# custom callback to save history and plots after each epoch
history_checkpointer = util.SaveHistoryCheckpoint(MODEL_PATH)# the main training function where all the magic happens!
history = model.fit(x_train, y_train, validation_data=(x_val, y_val), epochs=NUM_EPOCHS, batch_size=BATCH_SIZE, callbacks=[checkpointer, history_checkpointer])
这就是坐下来观看神奇数字在几个小时内上升的地方......
Train on 44274928 samples, validate on 903569 samples
Epoch 1/4844274928/44274928 [==============================] - 16756s 378us/step - loss: 1.5516 - acc: 0.5443 - val_loss: 1.3723 - val_acc: 0.5891
Epoch 00001: val_loss improved from inf to 1.37226, saving model to /home/ubuntu/imgflip/models/gen_2019_04_04_03_28_00/model.h5
Epoch 2/4844274928/44274928 [==============================] - 16767s 379us/step - loss: 1.4424 - acc: 0.5748 - val_loss: 1.3416 - val_acc: 0.5979
Epoch 00002: val_loss improved from 1.37226 to 1.34157, saving model to /home/ubuntu/imgflip/models/gen_2019_04_04_03_28_00/model.h5
Epoch 3/4844274928/44274928 [==============================] - 16798s 379us/step - loss: 1.4192 - acc: 0.5815 - val_loss: 1.3239 - val_acc: 0.6036
Epoch 00003: val_loss improved from 1.34157 to 1.32394, saving model to /home/ubuntu/imgflip/models/gen_2019_04_04_03_28_00/model.h5
Epoch 4/4844274928/44274928 [==============================] - 16798s 379us/step - loss: 1.4015 - acc: 0.5857 - val_loss: 1.3127 - val_acc: 0.6055
Epoch 00004: val_loss improved from 1.32394 to 1.31274, saving model to /home/ubuntu/imgflip/models/gen_2019_04_04_03_28_00/model.h5
Epoch 5/481177344/44274928 [..............................] - ETA: 4:31:59 - loss: 1.3993 - acc: 0.5869
发现当训练损失/准确性比验证损失/准确性更差时,这表明该模型学习良好且不过度拟合。
如果使用AWS服务器进行训练,发现最佳实例为p3.2xlarge。这使用了自2019年4月以来最快的GPU(Tesla V100),并且该实例只有一个GPU,因为模型无法非常有效地使用多个GPU。确实尝试过使用Keras的multi_gpu_model,但它需要使批量大小更大,以实际实现速度提升,这可能会影响模型的收敛能力,即使使用4个GPU也几乎不会快2倍。带有4个GPU的p3.8xlarge的成本是4倍。
第5步:预测
现在有一个模型可以输出meme标题中下一个字符应该出现的概率,但是如何使用它来实际创建一个完整的meme(表情包)标题?
基本前提是用想要为其生成文本的Memes(表情包标题)初始化一个字符串,然后model.predict为每个字符调用一次,直到模型输出结束文本字符的|次数与文本框中的文本框一样多次。对于上面看到的“X All The Y”memes,默认的文本框数为2,初始文本为:
"000000061533 0 "
考虑到模型输出的70个概率,尝试了几种不同的方法来选择下一个字符:
- 每次选择得分最高的角色。这会生成非常单一的结果,因为它每次为给定的Meme(表情包)选择完全相同的文本,并且它在Meme(表情包)中反复使用相同的单词。”when you find out your friends are the best party”,它会一遍又一遍地吐出"X All The Y meme"。它喜欢在其他Meme(表情包)中使用"best"和"party"这两个词。
- 给每个角色一个被选中的概率等于模型给出的分数,但只有当分数高于某个阈值时(≥最高分的10%才适用于该模型)。这意味着可以选择多个字符,但偏向更高的得分字符。这种方法成功地增加了多样性,但较长的短语有时缺乏凝聚力。这是Futurama Frymemes中的一个:"not sure if she said or just put out of my day"。
- 给每个角色选择相同的概率,但前提是它的分数足够高(≥最高分的10%适用于此模型)。此外使用beam搜索在任何给定时间保留N个文本的运行列表,并使用所有角色分数的乘积而不是最后一个角色的分数。这需要花费N倍的时间来计算,但在某些情况下似乎可以提高句子的凝聚力。
这里选择使用方法2,因为速度快,效果好。以下是一些随机生成的例子:
在imgflip.com/ai-meme的48个Meme(表情包)中生成。
使用方法2进行运行时预测的代码如下。Github上的完整实现是一种通用的Beam搜索算法,因此只需将波束宽度增加到1以上即可启用Beam搜索。
# min score as percentage of the maximum score, not absolute
MIN_SCORE = 0.1
int_to_char = {v: k for k, v in char_to_int.items()}def predict_meme_text(template_id, num_boxes, init_text = ''):
template_id = str(template_id).zfill(12)
final_text = ''for char_count in range(len(init_text), SEQUENCE_LENGTH):
box_index = str(final_text.count('|'))
texts = [template_id + ' ' + box_index + ' ' + final_text]
sequences = util.texts_to_sequences(texts, char_to_int)
data = pad_sequences(sequences, maxlen=SEQUENCE_LENGTH)
predictions_list = model.predict(data)
predictions = []for j in range(0, len(predictions_list[0])):
predictions.append({'text': final_text + int_to_char[j],'score': predictions_list[0][j]
})
predictions = sorted(predictions, key=lambda p: p['score'], reverse=True)
top_predictions = []
top_score = predictions[0]['score']
rand_int = random.randint(int(MIN_SCORE * 1000), 1000)for prediction in predictions:# give each char a chance of being chosen based on its scoreif prediction['score'] >= rand_int / 1000 * top_score:
top_predictions.append(prediction)
random.shuffle(top_predictions)
final_text = top_predictions[0]['text']if char_count >= SEQUENCE_LENGTH - 1 or final_text.count('|') == num_boxes - 1:return final_text
在github中,该文档对应的代码如下: