cienanos 发布的文章

使用不完整的线性自动编码器执行PCA

如果自动编码器仅使用线性激活,并且成本函数是均方误差MSE,则最后执行的是主成分分析(PCA)

以下代码构建了一个简单的线性自动编码器,来对3D数据集执行PCA,将其投影到2D:

from tensorflow import keras

encoder = keras.models.Sequential([
    keras.layers.Dense(2, input_shape=[3])
])
decoder = keras.models.Sequential([
    keras.layers.Dense(3, input_shape=[2])
])
autoencoder = keras.models.Sequential([encoder, decoder])
autoencoder.compile(loss='mse', optimizer=keras.optimizers.SGD(lr=.1))
  • 将自动编码器分为两个子主件:编码器和解码器。两者都具有单一Dense层的常规Sequential模型,而自动编码器是包含编码器和解码器的Sequential模型(一个模型可以用作另一个模型中的层)
  • 自动编码器的输出数量等于输入的数量
  • 为了执行简单的PCA,不使用任何激活函数(即所有神经元都是线性的),成本函数为MSE
import tensorflow as tf

X_train = tf.random.normal([1000, 3], 0, 1)
print(X_train.shape)
history = autoencoder.fit(X_train, X_train, epochs=20)
# X_train=X_train[np.newaxis,...]
codings = encoder.predict(X_train)
print(codings.shape)
(1000, 3)
Epoch 1/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3010
Epoch 2/20
32/32 [==============================] - 0s 2ms/step - loss: 0.3008
Epoch 3/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3005
Epoch 4/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3007
Epoch 5/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3009
Epoch 6/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3004
Epoch 7/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3004
Epoch 8/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3011
Epoch 9/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3001
Epoch 10/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3007
Epoch 11/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3010
Epoch 12/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3004
Epoch 13/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3004
Epoch 14/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3003
Epoch 15/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3007
Epoch 16/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3004
Epoch 17/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3005
Epoch 18/20
32/32 [==============================] - 0s 1ms/step - loss: 0.2999
Epoch 19/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3003
Epoch 20/20
32/32 [==============================] - 0s 1ms/step - loss: 0.3005
(1000, 2)
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

fig = plt.figure()
ax = fig.add_subplot(121, projection='3d')
ax.plot(X_train[:, 0], X_train[:, 1], X_train[:, 2])
ax = fig.add_subplot(122)
ax.plot(codings[:, 0], codings[:, 1])


有效的数据表示

你发现以下哪个数字顺序最容易记忆?

  • 40,27,25,36,81,57,10,73,19,68
  • 50,48,46,44,42,40,38,36,34,32,30,28,26,24,22,20,18,16,14

乍一看,第一个序列似乎应该更容易些,因为它要短得多。但是,如果仔细看第二个序列,会发现它只是从50到14的偶数列表。一旦注意到这个模型,第二个序列就比第一个序列更容易记忆,因为只需要记住模式(偶数递减)以及开始和结束的数字(即50和14)。如果可以快速轻松地记住很长的序列们就不会关心第二个序列中是否存在模式。会认真得记忆每个数字,就是这样。很难记住长序列这一事实使得模式识别变得很有用。这能澄清为什么在训练过程中约束自动编码器会促使其发现和利用数据中的模式

记忆、感知和模式匹配之间的关系在1970年代初期由Willian Chase和Herbert Simon进行了著名的研究。他们观察到,国际象棋专家可以通过观察棋盘上的位置只需要5秒钟就能记住所有棋子的位置,这是大多数人无法实现的任务。但是,只有把棋子放在真实位置(根据具体棋局)时才是这种情况,而不是将棋子随机放置。国际象棋专家的记忆力并不比你我的好。多亏了他们在象棋中的经验,他们才更容易看到国际象棋的模式。注意到模式可以帮助他们有效地存储信息。

就像这个记忆实验中的国际棋手一样,自动编码器会查看输入,将其转换为有效的潜在特征,然后输出一些看起来非常接近输出的东西。自动编码器通常由两部分组成:将输入转换为潜在特征的编码器(或识别网络),然后是将内部表征转化为输出的解码器(或生成网络)

自动编码器通常具有多层感知器(MLP)相同的架构,除了输出层中的神经元数量必须等于输入的数量。输出通常称为重构,因为自动编码器会试图重构输入,并且成本函数包含一个重构损失,当重构与输入不同时会惩罚这个模型

因为内部表征的维度比输入数据的维度低,所以自动编码器被认为是不完整的。不完整的自动编码器无法将其输入简单地复制到编码中,必须找到一种输出其输入副本的方法。它被迫学习输入数据中最重要的特征(并删除不重要的特征)

注意力机制

这是Dzmitry Bahdanai等人在2014年的突破性论文中的核心思想。他们介绍了一种技术,该技术允许编码器在每个时间步长中专注于适当的单词(由编码器编码)。例如,在编码器需要输出单词'lait'的时间步长上,它会把注意力集中在单词'milk'上。这意味着从输入单词到其翻译的路径变短了,因此RNN的短期记忆限制的影响变小了。注意力机制彻底改变了神经机器翻译(一般来说是NLP),极大地改善了现有技术,特别是对于长句子。

现在,不是把编码器的最终隐藏状态发送给解码器,而是把其所有输出发送给解码器。在每个时间步长,解码器的记忆单元会计算这些编码器输出的加权总和:这确定了该步长会把重点关注在哪个单词。权重$\alpha_{(t,i)}$是在第t个解码器时间步长处的第$i$个编码器输出的权重。例如,如果权重$\alpha_{(3,2)}$远大于权重$\alpha_{(3,0)}$和权重$\alpha_{(3,1)}$,则解码器将更注意第二个单词,而不是其他两个单词,至少在这个时间步长。解码器的其余部分的工作方式与之前类似:在每个时间步长,记忆单元都会接收输入,再加上前一个时间步长的隐藏状态,最后,它接收前一个时间步长中的目标单词。

这些$\alpha_{(t,i)}$权重从何而来:它们是由一种对齐模型(或注意力层)的小型神经网络生成,该网络与其余的编码器-解码器一起进行训练。它始于有单个神经元的时间分布Dense层,该层接受所有编码器输出作为输入,与解码器先前的隐藏状态合并。该层为每个编码器的输出而输出一个分数:该分数用于衡量每个输出与解码器先前的隐藏状态对齐程度。最后,所有分数都通过softmax层,以获取每个编码器输出的最终权重。给定编码器时间步长的所有权重加起来为1(因为softmax层未按时间分布)。这种特殊的注意力机制称为Bahdanau注意力。由于它将编码器的输出和解码器的先前隐藏状态合并在一起,因此有时称为合并注意力(或加法注意力)

如果输入句子的长度为n个单词,并且假设输出句子的长度大致相同,则此模型需要计算大约$n^2$个权重。幸运的是,这种二次计算复杂度仍然是很容易处理的,因为即使长句子也没有成千上万个单词。

之后不久,Minh-Thang Luong等人在2015年发表的论文中提出了另一种常见的注意力机制。由于注意力机制的目的是测量编码器的输出之一与解码器的先前隐藏状态之间的相似性,因此作者提出了简单计算这两个向量的点积。因为这通常是一个相当好的相似度度量,当前的硬件可以更快地进行计算。为此,两个向量必须具有相同的维度。这被称为Luong注意力,有时也称为乘法注意力。点积给出一个分数,所有的分数都经过softmax层以给出最终的权重,就像在Bahdanau注意力中一样。他们提出的另一种简化方法是在每个时间步长而不是在前一个时间步长使用解码器的隐藏状态,然后使用注意力机制的输出直接计算解码器的预测(而不是使用它计算解码器的当前隐藏状态)。他们还提出了一种点积机制的变体,其中编码器的输出在计算点积之前先经过线性变换(即没有偏置项地时间分布Dense层)。这成为“通用”点积方法。他们将两种点积方法与合并注意力机制进行了对比(添加了一个重新缩放参数向量$v$),他们观察到,点积变体的效果要好于合并注意力。因此,现在很少使用合并注意力

注意力机制:

$$ \tilde{h}_{(t)}=\sum_i\alpha_{(t,i)}y_{(i)} $$

$$ \text{with }\alpha_{(t,i)}=\frac{\exp(e_{(t,i)})}{\sum_{i'}\exp(e_{(t,i')})} $$

$$ \text{and } e_{(t,i)}= \begin{cases} h_{(t)}^T & \text{点} \\ h_{(t)}^TWy_{(i)} & \text{通用} \\ v^T\tanh(W[h_{(t)};y_{(i)}]) & \text{合并} \end{cases} $$

# 使用TensorFlow Addons将Lulong注意力添加到编码器-解码器模型的方法
import tensorflow_addons as tfa
from tensorflow import keras
import tensorflow as tf

attention_mechanism = tfa.seq2seq.attention_wrapper.LuongAttention(
    units, encoder_state, memory_sequence_length=encoder_sequence_length
)
attention_decoder_cell = tfa.seq2seq.attention_wrapper.AttentionWrapper(
    decoder_cell, attention_mechanism, attention_layer_size=n_units
)
# 简单地将解码器单元包在AttentionWrapper中,提供了所需的注意力机制

视觉注意力

注意力机制可以用于多种目的。它们在NMT(神经网络机器翻译)之外的第一个应用之一是使用视觉注意力生成图像标题:卷积神经网络首先处理图像并输出一些特征图,然后具有注意力机制的解码器RNN生成标题,一次生成一个单词。在每个解码器时间步长(每个单词),解码器使用注意力模型将注意力集中在图像的正确部分

可解释性

注意力机制的另一个好处是,它们使人们更容易理解导致模型产生其输出的原因。这成为可解释性。当模型出错时,它特别有用:例如,如果在雪地里行走的狗的图片被标记为“在雪地里行走的狼”,那么可以返回并检查模型输出单词:狼“时所关注的是什么。可能会发现它不仅关注狗,还关注大雪,还暗示了可能的解释:也许许多模型学会区分狼和狗的方式是通过检查周围是否有大雪。然后,可以通过使用更多没有雪的狼和有雪的狗的图像训练模型来解决此问题。它对可解释性采用了不同的方法:在分类器的预测局部周围学习一个可解释的模型

在某些应用程序中,可解释性不仅仅是调试模型的工具,还可能是一项法律要求

Transformer架构

在2017年的一篇开创性论文中,一个Google研究团队提出了”注意力就是你所需要的一切。他们设法创建了一个名为Transformer的架构,该架构显著改善了NMT的现有水平,没有使用任何循环层或卷积层,只有注意力机制(加上嵌入层、密集层、归一化层以及其它一些小东西)。额外的好处是该架构的训练速度更快且更易于并行化,因此他们使用了比以前最先进的模型更少的时间和成本来训练。

  • 左边是编码器。就像前面一样,它以一批表示为单词ID序列的句子作为输入(输如形状为[批处理大小,最大输出句子长度]),并将每个单词编码为512维的表征(因此编码器的输出形状为[批处理大小,最大输出句子长度,512])。编码器的顶部堆叠了N次
  • 右侧是解码器。在训练期间,它以目标句子作为输入(也表示为单词ID的序列),向右移动一个时间步长(即在序列的开头插入一个序列开始令牌)。它也接收编码器的输出(即来自左侧的箭头)。解码器顶部也堆叠了N次,编码器堆的最终输出在N层的每层馈送到解码器。解码器在每个时间步长输出每个可能的下一个单词的概率(其输出形状为[批处理大小,最大输出句子长度,词汇表长度])
  • 在训练后,无法向编码器提供目标值,因此需要向其提供先前输出的单词(从序列开始令牌开始),因此,需要反复调用该模型,并在每个回合中预测下一个字
  • 有两个嵌入层;5XN个跳过连接,每个后面都有一个归一化层;2XN个前馈模块,每个模块由两个密集层组成(第一个使用ReLU激活函数,第二个没有激活函数),最后输出层是使用softmax激活函数的密集层,如何一次只看一个单词就能翻译一个句子,这就需要新组件出现:

    • 编码器的多头注意力(Multi-Head Attention)层对同一句子中每个单词与其他单词之间的关系进行编码,更关注最相关的单词。例如,句子“They welcomed the Queen of the United Kingdom”中的“Queen”单词的这一层的输出取决于句子中的所有单词,它可能会更关注“United”和“Kingdom”,而不是“They”或“Welcomed”。这个注意力机制被称为“自我注意力”(句子关注自身)。解码器的掩码多头注意力(Masked Multi-Head Attention)层执行相同的操作,但是每个单词只能被允许关注位于其前面的单词。最后,解码器的多头注意力层上部是解码器关注输入句子中单词的地方。例如,当解码器要输出这个单词的翻译时,解码器可能会密切注意输入句子中的单词“Queen”
    • 位置嵌入只是表示单词在句子中的位置的密集向量(就像词嵌入)。第n个位置嵌入被添加到每个句子中的第n个单词的词嵌入中。这是的模型可以访问每个单词的位置,这是必须的,因为“多头注意力”层不考虑单词的顺序或位置,只看它们的关系。由于所有其他层都是由于时间分布的,它们无法直到每个单词的位置(无论是相对还是绝对)。显然,相对和绝对的词位置很重要,所以需要将此信息提供给Transformer,而位置嵌入是实现此目的的好方法

    位置嵌入

    位置嵌入是对单词在句子中的位置进行编码的密集向量:第i个位置嵌入被添加到句子中第i个单词的词嵌入中。这些位置嵌入可以通过模型学习,但是在本论文中,作者更喜欢使用固定的位置嵌入,该固定位置嵌入是使用不同频率的正弦和余弦函数定义的。

正弦/余弦位置嵌入:

$$ \begin{cases} P_{p,2i}=\sin(p/10000^{2i/d}) \\ P_{p,2i+1}=\cos(p/10000^{2i/d}) \end{cases} $$

其中,$P_{p,i}$ 是位于句子中第 $p$ 个位置的单词嵌入的第 $i$ 个分量。

该方法具有与学习的位置相同的性能,但可以拓展到任意长的句子,因此受到青睐。在将位置嵌入添加到单词嵌入之后,模型的其余部分可以访问句子中每个单词的绝对位置,因为每个位置都有唯一的位置嵌入。此外,震荡函数(正弦和余弦)的选择使模型也可以学习相对位置。例如,相隔38个单词的单词在嵌入维度i=100和i=101中始终具有相同的位置嵌入值

TensorFlow中没有PositionalEmbedding层,但是创建起来很容易。处于效率原因,预先计算了构造函数中的位置嵌入矩阵(因此需要知道最大句子长度max_steps和每个单词表示的维度max_dims)。然后call()方法将该嵌入矩阵裁剪为输入的大小,将其添加到输入中。由于在创建位置嵌入矩阵时增加了一个额外的大小为1的维度,因此广播法则确保把矩阵添加到输入的每个句子中:

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


class PositionalEncoding(keras.layers.Layer):
    def __init__(self, max_steps, max_dims, dtype=tf.float32, **kwargs):
        super().__init__(dtype=dtype, **kwargs)
        if max_dims % 2 == 1: max_dims += 1
        p, i = np.meshgrid(np.arange(max_steps), np.arange(max_dims // 2))
        pos_emb = np.empty((1, max_steps, max_dims))
        pos_emb[0, :, ::2] = np.sin(p / 10000 ** (2 * i / max_dims)).T
        pos_emb[0, :, 1::2] = np.cos(p / 10000 ** (2 * i / max_dims)).T
        self.positional_embedding = tf.constant(pos_emb.astype(self.dtype))

    def call(self, inputs):
        shape = tf.shape(inputs)
        return inputs + self.positional_embedding[:, :shape[-2], :shape[-1]]


# 然后可以创建Transformer的第一层:
embed_size = 512
max_steps = 500
vocab_size = 10000
encoder_inputs = keras.layers.Input(shape=[None], dtype=np.int32)
decoder_inputs = keras.layers.Input(shape=[None], dtype=np.int32)
embeddings = keras.layers.Embedding(vocab_size, embed_size)
encoder_embeddings = embeddings(encoder_inputs)
decoder_embeddings = embeddings(decoder_inputs)
positional_encoding = PositionalEncoding(max_steps, max_dims=embed_size)
encoder_in = positional_encoding(encoder_embeddings)
decoder_in = positional_encoding(decoder_embeddings)

多头注意力

要理解“多头注意力”层如何工作,必须理解“缩放点积注意力”层。假设编码器分析了输入句子‘The played chess’,并设法理解单词‘They’是主语,单词‘played’是动词,因此用这些词的表征来编码这些信息。现在假设解码器已经翻译了主语,认为接下来应该翻译动词。为此,它需要从输入句子中获取动词。这类似于字典查找:好像编码器创建了一个字典{‘subject’:‘They’,‘verb’:‘played’,···},解码器想要查找与键‘verb’相对应的值。但是,该模型没有具体的令牌来表示键;它具有这些概念的向量化表示(在训练期间学到的),因此用于查找的键不完全匹配字典中的任何键。解决方法是计算查询和字典中每个键的相似度,然后使用softmax函数将这些相似度分数转换为加起来为1的权重。如果表示动词的键到目前为止是最相似的查询,那么该键的权重将接近1。然后,该模型可以计算相应值的加权和,因此,如果‘verb’键的权重接近于1,则该加权和将非常接近单词‘played‘的表征。简而言之,可以将整个过程视为可区分的字典查找。像Luong注意力一样,Transformer使用的相似性度量只是点积。实际上,除了比例因子外,该公式与Luong注意力的相同

缩放点积注意力:$$Attention(Q,K,V)=softmax(\frac{QK^T}{\sqrt{d_{keys}}})V$$
在此等式中:

  • $Q$是一个矩阵,每个查询包含一行。其形状为$[n_{queries},d_{keys}]$,其中$n_{queries}$是查询数,而$d_{keys}$是每个查询和键的维度
  • $K$是一个矩阵,每个键包含一行。其形状为$[n_{keys},d_{keys}]$,其中$n_{keys}$是键和值的数量
  • $V$是一个矩阵,每个值包含一行。其形状为$[n_{keys},d_{values}]$,其中$d_{values}$是每个值的数量
  • $QK^T$的形状是$[n_{queries},n_{keys}]$:每一对查询/键有一个相似性分数。$softmax$函数的输出具有相同的形状,但所有行的总和为1。最终输出的形状为$[n_{queries},d_{values}]$:每个查询有一行,其中每一行代表了查询结果(值的加权和)
  • 比例因子会缩小相似度分数,以避免softmax函数饱和,这会导致很小的梯度
  • 可以在计算softmax之前,通过一个非常大的负值加到相应的相似性分数中来屏蔽一些键/值对。这在”掩码多头注意力“层中很有用

在编码器中,这个等式应用于批处理中的每个输入句子,其中$Q,K,V$均等于输入句子中的单词列表(因此,这个句子里的每个单词都会和同一个句子里的每个单词进行比较,包括它自己)。类似地,在解码器的掩码注意力层中,该等式会应用于批处理中的每个目标句子,其中$Q,K,V$都等于目标句子中的单词列表,但这次使用掩码来防止任何单词将自己与其后面的单词进行比较(在推理时,解码器只能访问它已经输出的单词,而不能访问将来的单词,因此在训练期间,必须屏蔽掉以后输出的令牌)。在解码器的注意力层上部,键$K$和值$V$仅是编码器生成的单词编码的列表,而查询$Q$是解码器生成的单词编码的列表

keras.layers.Attention层实现了缩放点积注意力,将等式有效地应用于一个批量中的多个句子。它的输入就像$Q,K,V$一样,除了有额外的批处理维度

如果忽略跳过连接、归一化层、前馈块,那事实是这是缩放点积注意力而不是多头注意力,可以像下面这样实现Transformer模型的其余部分:

z = encoder_in
for N in range(6):
    Z = keras.layers.Attention(use_scale=True)([Z, Z])
encoder_outputs = Z
Z = decoder_in
for N in range(6):
    Z = keras.layers.Attention(use_scale=True, causal=True)([Z, Z])
    Z = keras.layers.Attention(use_scale=True, causal=True)([Z, encoder_outputs])
outputs = keras.layers.TimeDistributed(
    keras.layers.Dense(vocab_size, activation='softmax'))(Z)

use_scale=True参数创建了一个附加参数,该参数可以让层来学习如何适当降低相似性分数。这个Transformer模型有所不同,后者始终使用相同的因子$\sqrt{d_{keys}}$来降低相似度分数。创建第二个注意力层时,causal=True参数可确保每个输出令牌仅注意先前的输出令牌,而不是将来的

情感分析

如果MNIST是计算机视觉的“hello world”,那么IMDB评论数据集就是自然语言处理的“hello world”:它提取了来自著名的互联网电影数据库的50000条英文电影评论(其中25000条用于训练,25000条用于测试),每条评论的简单二元目标值表明了该评论是负面(0)还是正面(1)。就像MNIST一样,IMDB评论数据集也很受欢迎,这有充分的理由:它足够简单,可以在合理的时间内利用笔记本电脑来处理,但又具有挑战性。Keras提供了一个简单的函数来加载它:

from tensorflow import keras

(X_train, y_train), (X_test, y_test) = keras.datasets.imdb.load_data()
X_train[0][:10]
Downloading data from https://storage.googleapis.com/tensorflow/tf-keras-datasets/imdb.npz
17465344/17464789 [==============================] - 1s 0us/step
17473536/17464789 [==============================] - 1s 0us/step





[1, 14, 22, 16, 43, 530, 973, 1622, 1385, 65]

数据集已经进行了预处理:X_train由一个评论列表组成,每条评论都由一个NumPy整数数组表示,其中每个整数代表一个单词。删除了所有标点符号,然后将单词转换为小写字母,用空格分隔,最后按频率索引(因此,小的整数对应于常用单词),整数0、1和2是特殊的:它们分别表示填充令牌、序列开始(SSS)令牌和位置单词。如果想可视化评论可以按以下方式对其进行解码:

word_index = keras.datasets.imdb.get_word_index()
id_to_word = {id_ + 3: word for word, id_ in word_index.items()}
for id_, token in enumerate(("<pad>", "<sos>", "<unk>")):  # 将整数0、1和2可视化为pad、sos和unk
    id_to_word[id_] = token
' '.join([id_to_word[id_] for id_ in X_train[0][:10]])
'<sos> this film was just brilliant casting location scenery story'

在实际的项目中,不得不自己处理文本。但是可以使用与之前使用的相同的Tokenizer类来执行此操作,但是这次要设置char_level=False(这是默认值)。在对单词进行编码时,它滤掉很多字符,包括大多数标点符号、换行符和制表符(但是可以通过filter参数来更改)。最重要的是,它使用空格来表示单词边界。对于英文和许多其他在单词之间使用空格的脚本(书面语言)这是可以的,但并非所有脚本都用此方式。中文在单词之间不使用空格,越南语甚至在单词中也使用空格,例如德语之类的语言经常将多个单词附加在一起,而没有空格。即使在英文中,空格也不总是分词的最佳方法:例如“San Francisco”
还有更好的选择,Taku Kudo在20118年发表的论文引入了一种无监督学习技术,用一种独立于语言的方式在子单词级别对文本进行分词和组词,像对待其他字符一样对待空格。使用这种方法,即使模型遇到一个从未见过的单词,也仍然可以合理地猜出其含义。例如,它可能在训练期间从未见过“smartest”一词,但可能学会了“smart”一词,并且还学习到后缀“est”的意思是“the most”,因此它可以推断出“smartest”的意思。Google的SentencePiece项目提供了一个开源实现,Takku Kudo和John Rihardson在论文中对此进行了描述
Rico Sennrich等人在较早的论文中提出了另一种选择,探索了创建子单词编码的其他方式(例如使用字节对编码)。TensorFlow团队于2019年6月发布了TF.Text库,该库实现了各种分词策略,包括WordPiece(字节对编码的一种变体)
如果想将模型部署到移动设备或Web浏览器上,而又不想每次都编写不同的预处理函数,那么可以使用TensorFlow操作来做预处理,因此其包含在模型本身中。首先使用TensorFlow数据集以文本(字节字符串)的形式加载原始的IMDB评论:

import tensorflow as tf
import tensorflow_datasets as tfds

datasets, info = tfds.load('imdb_reviews', as_supervised=True, with_info=True)
train_size = info.splits['train'].num_examples


# 编写预处理函数
def preprocess(X_batch, y_batch):
    X_batch = tf.strings.substr(X_batch, 0, 300)
    X_batch = tf.strings.regex_replace(X_batch, b'<br\\s*/?>', b' ')
    X_batch = tf.strings.regex_replace(X_batch, b"[^a-zA-z']", b' ')
    X_batch = tf.strings.split(X_batch)
    return X_batch.to_tensor(default_value=b'<pad>'), y_batch

它从阶段评论开始,每条评论仅保留前300个字符:这会加快训练速度,并且不会对性能产生太大的影响,因此通常可以在第一句话或第二句话中判断出评论是正面还是负面的。它使用正则表达式用空格来替换
"来填充所有评论,以使它们都具有相同的长度
接下来,需要构建词汇表。这需要依次遍历整个数据集,应用preprocess()函数,并使用Counter来对每个单词出现的次数进行计数:

from collections import Counter

vocabulary = Counter()
for X_batch, y_batch in datasets['train'].batch(32).map(preprocess):
    for review in X_batch:
        vocabulary.update(list(review.numpy()))
# 最常见的三个单词
vocabulary.most_common()[:3]
[(b'<pad>', 224533), (b'the', 61156), (b'a', 38567)]

但是,为了良好的性能,可能不需要模型知道字典中的所有单词,因此截断词汇表,只保留10000个最常见的单词:

vocab_size = 10000
truncated_vocabulary = [word for word, count in vocabulary.most_common()[:vocab_size]]

现在需要添加一个预处理步骤,以便把每个单词替换为其ID(即其在词汇表中的索引),使用out-of-vocabulary(oov)存储桶来创建一个查找表

words = tf.constant(truncated_vocabulary)
word_ids = tf.range(len(truncated_vocabulary), dtype=tf.int64)
vocab_init = tf.lookup.KeyValueTensorInitializer(words, word_ids)
num_oov_buckets = 1000
table = tf.lookup.StaticVocabularyTable(vocab_init, num_oov_buckets)
# 然后可以使用此表来查找几个单词的ID:
table.lookup(tf.constant([b'This movie was faaaaaantastic'.split()]))
<tf.Tensor: shape=(1, 4), dtype=int64, numpy=array([[   24,    12,    13, 10053]], dtype=int64)>

在表中可以找到单词’this‘、’movie‘和’was‘,因此它们的ID低于10000,而单词“faaaaaantastic”没找到,因此被映射的ID大于或等于10000的一个ovv桶中
TF Transform提供了一些有用的函数来处理此类词汇表。例如,查看tft.compute_and_apply_vocabulary()函数:它会遍历数据集来查早所有不同的的单词并构建词汇表,并将生成对使用此词汇表的单词进行编码所需的TF操作
现在,准备创建最终的训练集,对评论进行批处理,然后使用preprocess()函数将它们转换为单词的短序列,然后使用简单的encode_words()函数来对这些单词进行编码,该函数会使用刚才构建的单词表,最后预取一下批次:

def encode_words(X_batch, y_batch):
    return table.lookup(X_batch), y_batch


train_set = datasets['train'].batch(32).map(preprocess)
train_set = train_set.map(encode_words).prefetch(1)
# 最后创建模型并对其进行训练
embed_size = 128
model = keras.models.Sequential([
    keras.layers.Embedding(vocab_size + num_oov_buckets, embed_size, input_shape=[None]),
    keras.layers.GRU(128, return_sequences=True),
    keras.layers.GRU(128),
    keras.layers.Dense(1, activation='sigmoid')
])
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
history = model.fit(train_set, epochs=5)
Epoch 1/5
782/782 [==============================] - 9s 10ms/step - loss: 0.6192 - accuracy: 0.6206
Epoch 2/5
782/782 [==============================] - 8s 10ms/step - loss: 0.3875 - accuracy: 0.8297
Epoch 3/5
782/782 [==============================] - 8s 10ms/step - loss: 0.2304 - accuracy: 0.9146
Epoch 4/5
782/782 [==============================] - 8s 10ms/step - loss: 0.1442 - accuracy: 0.9513
Epoch 5/5
782/782 [==============================] - 8s 10ms/step - loss: 0.1087 - accuracy: 0.9624

第一层是嵌入层,它将单词ID转换为嵌入。嵌入矩阵需要每个ID(vocab_size+num_oov_buckets)一行,每个嵌入维度一列(这里使用128维,这是可以调整的超参数)。模型的输入使形状为[批处理大小,时间步长]的2D张量,而嵌入层的输出为[批处理大小,时间步长,嵌入大小]的3D张量。
该模型其余部分先当简单直接:它由两个GRU层组成,第二个GRU层仅返回最后一个时间步长的输出。输出层只是使用sigmoid激活函数来输出估计概率的单个神经元。该概率反映了评论表达了与电影有关的正面情绪。然后可以简单地编译模型,并将其拟合到之前准备的数据集中,进行几个轮次的训练。

创建训练数据集

首先,使用Keras的get_file()函数来下载莎士比亚的所有作品,并从Andrej Karpathy的Char-RNN项目中下载数据:

import tensorflow as tf
from tensorflow import keras

shakespeare_url = 'https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare'
filepath = keras.utils.get_file('input.txt', shakespeare_url)
with open(filepath) as f:
    shakespeare_text = f.read()

接下来,必须将每个字符编码为整数。一种选择是创建自定义的预处理层。在这种情况下,使用Keras的Tokenizer类会更简单。首先,为文本添加一个分词器:它会找到文本中使用的所有字符,并将它们映射到不同的字符ID,从1到不同字符的数量(它不从0开始,所以可以使用该值进行屏蔽)

tokenizer = keras.preprocessing.text.Tokenizer(char_level=True)
tokenizer.fit_on_texts(shakespeare_text)

设置char_level=True来得到字符级编码,而不是默认的单词级编码。默认情况下,该分词器将文本转换为小写(但是,如果不希望这样,可以将其设置为lower=False)。现在,分词器可以将一个句子(或句子列表)编码为字符ID列表并返回,并告诉有多少个不同的字符以及文本中的字符总数

tokenizer.texts_to_sequences(['First'])
[[20, 6, 9, 8, 3]]



tokenizer.sequences_to_texts([[20, 6, 9, 8, 3]])
['f i r s t']



max_id = len(tokenizer.word_index)
dataset_size = tokenizer.document_count
print(max_id)
print(dataset_size)
39
1115394

对全文进行编码,以便每个字符都由其ID表示(减去1即可得到从0到38的ID,而不是1到39的ID):

import numpy as np

[encoded] = np.array(tokenizer.texts_to_sequences([shakespeare_text])) - 1

在继续之前,需要将训练集分为训练集、验证集和测试集。不能只是将文本中的所有字符进行混洗

如何拆分数据集

避免训练集、验证集和测试集之间的任何重合都是非常重要的。例如,可以将文本的前90%用作训练集,其后的5%用作验证集,最后的5%作为测试集。在两个集合之前留一个空袭也是一个好主意,可以避免出现两个集合中的段落重合。

在处理时间序列时,通常会跨时间划分:例如,使用2000年到2012年之间的作为训练集,在2013年到2015年之间的作为验证集,在2016年到2018年之间的作为测试集。但是,在某些情况下,可以沿着其他维度来拆分,这将使得拥有更长的的训练时间。如果有2000年到2018年间10000家公司的财务状况数据,可以按照不同的公司将这些数据拆分。但是,其中许多公司很有可能会高度相关(例如,整个经济部门可能会一起上升或下降),如果在训练集和测试集中有相关联的公司,那测试集不会很有用,因为其对泛化误差的度量偏向于乐观

因此,跨时间划分通常更安全。但这隐含地假设RNN过去(在训练集中)学习到的模式将来仍会存在。换句话说,假设时间序列是稳定的(至少在广义上是这样)。对于许多时间序列,此假设是合理的(例如,化学反应就很好,因为化学定律不会每天改变),但是对于其他的并非如此(例如,金融市场并非一成不变,因为一旦交易者发现并开始利用弄个它们,模式就会消失)。为了确保时间序列足够稳定,可以按时间在验证集上绘制模型的误差:如果模型在验证集地第一部分比最后一部分表现得更好,则时间序列可能不过稳定,因此最好在较短的时间范围内训练模型

简而言之,将时间序列分为训练集、验证集和测试集不是一件容易的事,而如何划分将在很大程度上取决于手头的任务

回到莎士比亚,使用文本的前90%作为训练集(其余部分保留为验证集和测试集),并创建一个tf.data.Dataset,它将从该集合中逐个返回每个字符:

train_size = dataset_size * 90 // 100
dataset = tf.data.Dataset.from_tensor_slices(encoded[:train_size])

将顺序数据集切成多个窗口

训练集现在由超过一百万个字符的单个序列组成,所以不能直接在其上训练神经网络:RNN相当于一个超过一百万层的深度网络,会有一个很长的实例来训练它。取而代之的是,使用数据集的window()方法将这个长字符串序列转换为许多较小的文本窗口,数据集中的每个实例是将整个文本的很短的子字符串,并且RNN仅在这些子字符串的长度上展开。这称为时间截断反向传播。调用window()方法来创建短文本窗口数据集:

n_steps = 100
window_length = n_steps + 1
dataset = dataset.window(size=window_length, shift=1,
                         drop_remainder=True)  # 可以调整n_steps:在较短的输入序列上训练RNN会更容易,但是RNN不能学习比n_steps更长的模式,因此不要使其太短

默认情况下,window()方法会创建不重叠的窗口,但是为了获得最大可能地数据集,使用shift=1,以便使得第一个窗口包含0到100的字符,第二个窗口包含1到101的字符,依次类推。为确保所有窗口正好是101个字符长度(这使得不需要进行任何填充就可以创建批处理),设置drop_remainder=True(否则最后100个窗口将包含100个字符,99个字符,以此类推直至1个字符)。

window()方法创建一个包含窗口的数据集,每个窗口也表示为一个数据集。它是一个嵌套的数据集,类似列表的列表。想通过调用窗口数据集方法来转换每个窗口时(例如对它们进行混洗和批处理),此功能非常有用。但是,不能直接使用嵌套数据集进行训练,因为模型希望输入张量,而不是数据集。因此,必须调用flat_map()方法:它将嵌套的数据集转换为一个展平的数据集(一个不包含数据集的数据集)。例如,假设{1,2,3}表示一个包含张量1、2、3的序列的数据集。如果展平嵌套的数据集{{1,2},{3,4,5,6}},将获得展平的数据集{1,2,3,4,5,6}。此外,flat_map()方法将一个函数用作参数,它允许在展平前变换嵌套数据集中的每个数据集。例如,如果将函数lambda ds:ds.batch(2)传递给flat_map(),则它将嵌套数据集{{1,2},{3,4,5,6}}转换为平数据集{[1,2],[3,4],[5,6]}:它是大小为2的张量的数据集。考虑到这一点,这里需要将数据展平:

dataset = dataset.flat_map(lambda window: window.batch(window_length))

在每个窗口上调用batch(window_length):由于所有窗口的长度都恰好相同,因此每个窗口都获得一个张量。现在数据集包含101个字符的连续窗口。由于当训练集中的实例独立且分布相同时,梯度下降效果最好,因此需要对这些窗口进行混洗,然后,可以批量处理这些窗口并将输入(前100个字符)与目标(最后一个字符)分开:

batch_size = 32
dataset = dataset.shuffle(10000).batch(batch_size)
dataset = dataset.map(lambda windows: (windows[:, :-1], windows[:, 1:]))  # X从0到100,Y从1到101

将类别输入特征编码为独热向量或嵌入。在这里,将使用独热向量对每个字符进行编码,因为只有很少的不同字符:

dataset = dataset.map(lambda X_batch, Y_batch: (tf.one_hot(X_batch, depth=max_id), Y_batch))
# 添加预取
dataset = dataset.prefetch(1)

创建和训练Char-RNN模型

要基于前100个字符来预测下一个字符,可以使用有2个GRU层的RNN,每个GRU层有128个单元,输入(dropout)和隐藏状态(recurrent_dropout)的dropout率均为20%。如果需要,可以稍后调整这些超参数。输出层是一个时间分布的Dense层,这一层必须有39个单元(max_id),因为文本中有39个不同的字符,并且想为每个可能的字符输出一个概率(在每个时间步长)。每个时间步长的输出概率总和为1,因此将softmax激活函数应用于Dense层的输出。然后,使用‘sparse_categorical_crossentropy’损失和Adam优化器来编译此模型:

model = keras.models.Sequential([
    keras.layers.GRU(128, return_sequences=True, input_shape=[None, max_id], dropout=0, recurrent_dropout=0),
    keras.layers.GRU(128, return_sequences=True, dropout=0, recurrent_dropout=0),
    # 设置了dropout不为0,发现不能在GPU上运行,在CPU上太慢了,就改成了0
    keras.layers.TimeDistributed(keras.layers.Dense(max_id, activation='softmax'))
])
model.compile(loss='sparse_categorical_crossentropy', optimizer='adam')
history = model.fit(dataset, epochs=1)
31368/31368 [==============================] - 343s 11ms/step - loss: 0.9550

使用Char-RNN模型

现在有了一个可以预测莎士比亚写的文本中下一个字符的模型。为了提供一些文本,需要向之前一样对它进行预处理:

def preprocess(texts):
    X = np.array(tokenizer.texts_to_sequences(texts)) - 1
    return tf.one_hot(X, max_id)


X_new = preprocess(['How are yo'])
Y_pred = np.argmax(model.predict(X_new), axis=2)
tokenizer.sequences_to_texts(Y_pred + 1)[0][-1]  # 输出是0到38索引表示,输出+1为ID表示从1到39,第一句最后一个字母
'u'


生成假莎士比亚文本

要使用Char-RNN模型来生成新文本,可以向其提供一些文本,使模型预测最可能的下一个字母,把它添加在文本末尾,然后将拓展的文本提供给模型来预测下一个字母,以此类推。但是实际上,这经常导致相同的单词一遍又一遍重复。相反,可以使用tf.random.categorical()函数估计出来的概率随机选择下一个字符。这将产生更多不同和有趣的文本。对给定类对数概率(logits),categorical()函数会对随机索引进行采样。为了更好地控制生成的文本的多样性,可以把logits除以一个称为温度的数字,这样可以根据需要进行调整:接近0的温度倾向于高概率的字符,而非常高的温度会给予所有的字符相同的概率。下面的next_char()函数使用这种方法来选择要添加到输入文本的下一个字符:

def next_char(text, temperature=1):
    X_new = preprocess([text])
    y_proba = model.predict(X_new)[0, -1:, :]
    rescaled_logits = tf.math.log(y_proba) / temperature
    char_id = tf.random.categorical(rescaled_logits, num_samples=1) + 1
    return tokenizer.sequences_to_texts(char_id.numpy())[0]


# 编写函数,反复调用next_char()来获得下一个字符并将其添加到给定的文本中
def complete_text(text, n_chars=50, temperature=1):
    for _ in range(n_chars):
        text += next_char(text, temperature)
    return text

有状态RNN

早目前为止,仅仅使用了无状态RNN:在每次训练迭代中,模型都从一个充满零的隐藏状态开始,然后在每个时间步长更新状态,在最后一个时间步长之后将其丢弃,因为不再需要了。如果告诉RNN在处理一个训练批次后保留此最终状态并将其作为下一个训练批次的初始状态。这样,尽管反向传播只是通过短序列,模型仍可以学习长期模式。这称为有状态RNN。

首先,只有当批次中的每个输入序列均从上一批次中对应序列中断的确切位置开始,有状态RNN才有意义。因此,创建有状态RNN所需要做的第一件事时使用顺序和非重合的输入序列(而不是用来训练无状态RNN的混洗和重叠的序列)。因此,在创建Dataset时,在调用window()方法时必须使用shift=n_steps(而不是shift=1)。而且,不能调用shuffle方法。在为有状态RNN准备数据集时,批处理要比无状态RNN困难得多。如果要调用batch(32),那32个连续的窗口应该放入同一批处理中,而下一批处理不会在这些窗口中的每个中断处继续。第一批包含窗口1至32,第二批包含窗口33至64,因此,如果考虑每个批次中的第一个窗口(即窗口1和窗口33),可以看到它们是不连续的。解决此问题的最简单方法是只是用包含单个窗口的“批处理”:

dataset = tf.data.Dataset.from_tensor_slices(encoded[:train_size])
dataset = dataset.window(window_length, shift=n_steps, drop_remainder=True)
dataset = dataset.flat_map(lambda windows: windows.batch(window_length))
dataset = dataset.batch(1)
dataset = dataset.map(lambda windows: (windows[:, :-1], windows[:, 1:]))
dataset = dataset.map(lambda X_batch, Y_batch: (tf.one_hot(X_batch, depth=max_id), Y_batch))
dataset = dataset.prefetch(1)

批处理比较困难,但并非不可能。例如,可以将莎士比亚的文本切成等长的32个文本,为每个文本创建一个连续输入序列的数据集,最后使用tf.train.Dataset.zip(dataset).map(lambda *windows:tf.stack(windows))来创建合适的连续批处理,其中批处理中的第n个输入序列恰好是从上一个批处理中第n个输入序列结束的位置开始

创建有状态RNN,在创建每个循环层时设置stateful=True,其次,有状态RNN需要知道批处理大小(因为它会为批处理中每个输入序列保留一个状态),因此必须在第一层中设置batch_input_shape参数,可以不指定第二个维度,因为输入可以有任意长度:

model = keras.models.Sequential([
    keras.layers.GRU(128, return_sequences=True, state_ful=True, dropout=0.2, recurrent_dropout=0.2,
                     batch_input_shape=[batch_size, None, max_id]),
    keras.layers.GRU(128, return_sequences=True, state_ful=True, dropout=0.2, recurrent_dropout=0.2),
    keras.layers.TimeDistributed(keras.layers.Dense(max_id, activation='softmax'))
])

在每个轮次结束时,需要先重置状态,然后再回到文本的开头,为此,可以使用一个回调函数:

class ResetStatesCallback(keras.callbacks.Callback):
    def on_epoch_begin(self, epoch, logs):
        self.model.reset_states()


model.compile(loss='sparse_categorical_crossentropy', optimizer='adam')
model.fit(dataset, epochs=50, callbacks=[ResetStatesCallback()])