TensorFlow 自然语言处理第二版(二)

原文:annas-archive.org/md5/fc366c3f6d3023ea2889a905de68763e

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:高级词向量算法

第三章Word2vec – 学习词向量中,我们介绍了 Word2vec、学习词向量的基础知识,以及两个常见的 Word2vec 算法:skip-gram 和 CBOW。在本章中,我们将讨论其他几种词向量算法:

  • GloVe – 全局向量

  • ELMo – 来自语言模型的嵌入

  • 使用 ELMo 进行文档分类

首先,你将学习一种词嵌入学习技术,称为全局向量GloVe),以及 GloVe 相对于 skip-gram 和 CBOW 的具体优势。

你还将学习一种最近的语言表示方法,称为来自语言模型的嵌入ELMo)。与其他算法相比,ELMo 具有优势,因为它能够消除词义歧义并捕捉语义。具体来说,ELMo 生成的是“上下文化”的单词表示,它通过使用给定单词及其周围的单词,而不是像 skip-gram 或 CBOW 那样独立地处理单词表示。

最后,我们将解决一个使用我们新创建的 ELMo 向量进行文档分类的令人兴奋的应用案例。

GloVe – 全局向量表示

skip-gram 和 CBOW 算法的主要限制之一是它们只能捕捉局部上下文信息,因为它们只看一个固定长度的窗口围绕单词。因此,缺少了解决这个问题的重要部分,因为这些算法并不查看全局统计信息(全局统计信息是指我们查看一个单词在文本语料库中与另一个单词的上下文中的所有出现情况的一种方法)。

然而,我们已经在第三章Word2vec – 学习词向量中学习过一种可以包含这些信息的结构:共现矩阵。让我们回顾一下共现矩阵,因为 GloVe 使用共现矩阵中捕捉到的统计信息来计算向量。

共现矩阵编码了单词的上下文信息,但它们需要维护一个 V × V 的矩阵,其中 V 是词汇表的大小。为了理解共现矩阵,假设我们有两个例句:

  • Jerry and Mary are friends

  • Jerry buys flowers for Mary

如果我们假设上下文窗口的大小为 1,即在所选单词的每一侧,那么共现矩阵将如下所示(我们只显示矩阵的上三角部分,因为矩阵是对称的):

JerryandMaryarefriendsbuysflowersfor
Jerry01000100
and0100000
Mary010001
are01000
friends0000
buys010
flowers01
for0

我们可以看到,这个矩阵展示了语料库中一个词与其他任何词的关系,因此它包含了关于语料库的全局统计信息。也就是说,拥有共现矩阵相较于仅仅看到局部上下文有什么优势呢?

  • 它为你提供了关于词语特性的额外信息。例如,如果你考虑句子“the cat sat on the mat”,就很难判断“the”是否是一个特殊的词,出现在像“cat”或“mat”这样的词的上下文中。然而,如果你有足够大的语料库和共现矩阵,就很容易看出“the”是一个频繁出现的停用词。

  • 共现矩阵识别了上下文或短语的重复使用,而在局部上下文中这些信息则被忽略。例如,在足够大的语料库中,“New York”将明显成为赢家,表明这两个词在同一上下文中出现了很多次。

需要牢记的是,Word2vec 算法使用各种技术来大致注入一些词汇共现模式,同时学习词向量。例如,我们在上一章使用的子采样技术(即更频繁地采样低频词)有助于识别和避免停用词。但它们引入了额外的超参数,并且不如共现矩阵那样富有信息。

使用全局统计信息来生成词表示并不是一个新概念。一个叫做潜在语义分析LSA)的算法已经在其方法中使用了全局统计信息。

LSA 作为一种文档分析技术,将文档中的词映射到所谓的概念,即在文档中出现的常见词模式。基于全局矩阵分解的方法有效地利用语料库的全局统计信息(例如,词语在全局范围内的共现),但在词汇类比任务中表现较差。另一方面,基于上下文窗口的方法在词汇类比任务中表现较好,但没有利用语料库的全局统计信息,因此有改进的空间。GloVe 试图兼顾这两者的优点——一种既高效利用全局语料库统计信息,又像 skip-gram 或 CBOW 那样通过上下文窗口优化学习模型的方法。

GloVe,一种用于学习词嵌入的新技术,已在 Pennington 等人的论文《GloVe: Global Vectors for Word Representation》中提出(nlp.stanford.edu/pubs/glove.pdf)。GloVe 旨在弥补 Word2vec 算法中缺失的全局共现信息。GloVe 的主要贡献是提出了一种新的成本函数(或目标函数),该函数利用了共现矩阵中可用的宝贵统计信息。让我们首先理解 GloVe 方法背后的动机。

理解 GloVe

在查看 GloVe 的实现细节之前,让我们先花些时间理解 GloVe 中计算的基本概念。为此,我们来看一个例子:

现在让我们看看 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_003.png 实体在不同 k 值下的表现。

对于 k = “Solid”,它很可能与 i 一起出现,因此 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_001.png 会较高。然而,k 不太会与 j 一起出现,导致 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_002.png 较低。因此,我们得到以下表达式:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_006.png

接下来,对于 k = “gas”,它不太可能与 i 紧密相邻出现,因此会有一个较低的 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_001.png;然而,由于 kj 高度相关,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_002.png 的值将会较高。这导致了以下情况:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_009.png

现在,对于像 k = “water” 这样的单词,它与 ij 都有很强的关系,或者对于 k = “Fashion” 这样的单词,它与 ij 都没有太多相关性,我们得到如下结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_010.png

如果假设我们已经为这些单词学习了合理的词向量,这些关系可以在向量空间中可视化,从而理解为何比率 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_003.png 会有这样的行为(见 图 4.1)。在下图中,实心箭头表示单词 (i, j) 之间的距离,而虚线则表示单词 (i, k) 和 (j, k) 之间的距离。这些距离可以与我们讨论的概率值关联起来。例如,当 i = “ice”和 k = “solid” 时,我们期望它们的向量之间的距离较短(即更频繁地共同出现)。因此,由于 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_001.png 的定义,我们可以将 (i, k) 之间的距离与 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_001.png 的倒数关联起来(即 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_013.png)。该图展示了随着探针词 k 的变化,这些距离是如何变化的:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_01.png

图 4.1:当探针词变化时,P_ik 和 P_jk 实体如何随着与单词 ij 的接近度而变化

可以看到,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_003.png 实体是通过测量两个单词紧密出现的频率来计算的,当三个单词之间的关系发生变化时,它的表现也会有所不同。因此,它成为了学习词向量的一个不错的候选对象。因此,定义损失函数的一个好的起点将如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_016.png

在这里,F 是某个函数,whttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_017.png 是我们将使用的两个不同的嵌入空间。换句话说,词汇 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_018.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_019.png 是从一个嵌入空间中查找的,而探测词 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_020.png 则是从另一个嵌入空间中查找的。从这一点开始,原始论文仔细地进行了推导,以得到以下损失函数:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_021.png

我们这里不会深入推导,因为这超出了本书的范围。我们将直接使用已推导出的损失函数,并通过 TensorFlow 实现该算法。如果你需要一个较少数学密集的解释,了解我们是如何推导该成本函数的,请参考作者撰写的文章:towardsdatascience.com/light-on-math-ml-intuitive-guide-to-understanding-glove-embeddings-b13b4f19c010

在这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_022.png 被定义为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_023.png,如果 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_024.png,否则为 1,其中 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_025.png 是词 j 在词 i 的上下文中出现的频率。https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_034.png 是我们设置的一个超参数。记住,我们在损失函数中定义了两个嵌入空间 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_026.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_017.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_028.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_029.png 分别表示从嵌入空间 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_026.png 中获得的词 i 的词嵌入和偏置嵌入。而 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_031.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_032.png 则分别表示从嵌入空间 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_017.png 中获得的词 j 的词嵌入和偏置嵌入。这两种嵌入的行为类似,除了初始化时的随机化。在评估阶段,这两个嵌入将被加在一起,从而提高性能。

实现 GloVe

在本小节中,我们将讨论实现 GloVe 的步骤。完整代码可以在 ch4_glove.ipynb 练习文件中找到,该文件位于 ch4 文件夹内。

首先,我们将定义超参数,就像在上一章中做的那样:

batch_size = 4096 # Data points in a single batch
embedding_size = 128 # Dimension of the embedding vector.
window_size=1 # We use a window size of 1 on either side of target word
epochs = 5 # Number of epochs to train for
# We pick a random validation set to sample nearest neighbors
valid_size = 16 # Random set of words to evaluate similarity on.
# We sample valid datapoints randomly from a large window without always 
# being deterministic
valid_window = 250
# When selecting valid examples, we select some of the most frequent words # as well as some moderately rare words as well
np.random.seed(54321)
random.seed(54321)
valid_term_ids = np.array(random.sample(range(valid_window), valid_size))
valid_term_ids = np.append(
    valid_term_ids, random.sample(range(1000, 1000+valid_window), valid_ 
    size),
    axis=0
) 

你在这里定义的超参数与我们在上一章中定义的超参数相同。我们有一个批量大小、嵌入维度、窗口大小、训练轮数,最后,还有一组保留的验证词 ID,用来打印最相似的词。

然后我们将定义模型。首先,我们将导入一些在后续代码中需要用到的库:

import tensorflow.keras.backend as K
from tensorflow.keras.layers import Input, Embedding, Dot, Add
from tensorflow.keras.models import Model
K.clear_session() 

模型将有两个输入层:word_iword_j。它们分别表示一批上下文词和一批目标词(或一批正样本跳字):

# Define two input layers for context and target words
word_i = Input(shape=())
word_j = Input(shape=()) 

注意形状是如何定义的。形状被定义为空元组。这意味着 word_iword_j 的最终形状将是 [None],意味着它将接受一个任意元素数量的向量作为输入。

接下来,我们将定义嵌入层。将会有四个嵌入层:

  • embeddings_i – 上下文嵌入层

  • embeddings_j – 目标嵌入层

  • b_i – 上下文嵌入偏置

  • b_j – 目标嵌入偏置

以下代码定义了这些内容:

# Each context and target has their own embeddings (weights and biases)
# Embedding weights
embeddings_i = Embedding(n_vocab, embedding_size, name='target_embedding')(word_i)
embeddings_j = Embedding(n_vocab, embedding_size, name='context_embedding')(word_j)
# Embedding biases
b_i = Embedding(n_vocab, 1, name='target_embedding_bias')(word_i)
b_j = Embedding(n_vocab, 1, name='context_embedding_bias')(word_j) 

接下来,我们将计算输出。这个模型的输出将是:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_035.png

如你所见,这就是我们最终损失函数的一部分。我们拥有所有正确的元素来计算这个结果:

# Compute the dot product between embedding vectors (i.e. w_i.w_j)
ij_dot = Dot(axes=-1)([embeddings_i,embeddings_j])
# Add the biases (i.e. w_i.w_j + b_i + b_j )
pred = Add()([ij_dot, b_i, b_j]) 

首先,我们将使用 tensorflow.keras.layers.Dot 层来计算上下文嵌入查找(embeddings_i)和目标嵌入查找(embeddings_j)之间的点积。举例来说,Dot 层的两个输入将是 [batch size, embedding size] 的大小。经过点积后,输出 ij_dot 的形状将是 [batch size, 1],其中 ij_dot[k] 将是 embeddings_i[k, :]embeddings_j[k, :] 之间的点积。然后,我们只需将 b_ib_j(其形状为 [None, 1])逐元素加到 ij_dot 上。

最后,模型被定义为以 word_iword_j 作为输入,并输出 pred

# The final model
glove_model = Model(
    inputs=[word_i, word_j],outputs=pred,
name='glove_model'
) 

接下来,我们将进行一些相当重要的操作。

我们必须设计一种方法,使用模型中可用的各种组件/功能来计算上面定义的复杂损失函数。首先,让我们重新审视损失函数。

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_021.png

其中,

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_023.png,如果 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_024.png,否则为 1。

尽管看起来很复杂,我们可以利用现有的损失函数和其他功能来实现 GloVe 损失。你可以将这个损失函数抽象为下图所示的三个组件:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_02.png

图 4.2:GloVe 损失函数的分解,展示了预测值、目标值和权重是如何相互作用以计算最终损失的

因此,如果样本权重用 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_039.png 表示,预测值用 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_040.png 表示,真实目标用 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_041.png 表示,那么我们可以将损失函数写为:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_042.png

这仅仅是一个加权均方损失。因此,我们将使用"mse"作为我们模型的损失函数:

# Glove has a specific loss function with a sound mathematical
# underpinning
# It is a form of mean squared error
glove_model.compile(loss="mse", optimizer = 'adam') 

我们稍后会看到如何将样本权重输入到模型中,以完成损失函数。到目前为止,我们已经定义了 GloVe 算法的不同组件,并编译了模型。接下来,我们将看看如何生成数据来训练 GloVe 模型。

为 GloVe 生成数据

我们将使用的数据集与上一章使用的数据集相同。为了回顾一下,我们将使用 BBC 新闻文章数据集,网址为mlg.ucd.ie/datasets/bbc.html。该数据集包含 2225 篇新闻文章,属于 5 个主题:商业、娱乐、政治、体育和科技,均发表于 2004 至 2005 年间的 BBC 网站。

现在让我们生成数据。我们将数据生成封装在一个名为glove_data_generator()的函数中。第一步,让我们编写一个函数签名:

def glove_data_generator(
    sequences, window_size, batch_size, vocab_size, cooccurrence_matrix,
    x_max=100.0, alpha=0.75, seed=None
): 

该函数接受多个参数:

  • sequencesList[List[int]])– 一个包含单词 ID 列表的列表。这是由分词器的texts_to_sequences()函数生成的输出。

  • window_sizeint)– 上下文窗口大小。

  • batch_sizeint)– 批量大小。

  • vocab_sizeint)– 词汇表大小。

  • cooccurrence_matrixscipy.sparse.lil_matrix)– 一个稀疏矩阵,包含单词的共现。

  • x_maxint)– GloVe 用于计算样本权重的超参数。

  • alphafloat)– GloVe 用于计算样本权重的超参数。

  • seed – 随机种子。

它还包含若干输出:

首先,我们将打乱新闻文章的顺序:

 # Shuffle the data so that, every epoch, the order of data is
    # different
    rand_sequence_ids = np.arange(len(sequences))
    np.random.shuffle(rand_sequence_ids) 

接下来,我们将创建采样表,以便可以使用子采样避免过度采样常见词汇(例如停用词):

 sampling_table = 
    tf.keras.preprocessing.sequence.make_sampling_table(vocab_size) 

在此基础上,对于每个序列(即表示文章的单词 ID 列表),我们生成正向 skip-gram。请注意,我们将negative_samples=0.0,因为与 skip-gram 或 CBOW 算法不同,GloVe 不依赖于负样本:

 # For each story/article
    for si in rand_sequence_ids:

        # Generate positive skip-grams while using sub-sampling 
        positive_skip_grams, _ = tf.keras.preprocessing.sequence.
        skipgrams(
            sequences[si], 
            vocabulary_size=vocab_size, 
            window_size=window_size, 
            negative_samples=0.0, 
            shuffle=False,   
            sampling_table=sampling_table,
            seed=seed
        ) 

在此基础上,我们首先将 skip-gram 元组拆分成两个列表,一个包含目标,另一个包含上下文单词,并随后将其转换为 NumPy 数组:

 # Take targets and context words separately
        targets, context = zip(*positive_skip_grams)
        targets, context = np.array(targets).ravel(),
        np.array(context).ravel() 

然后,我们从共现矩阵中索引(目标,上下文)单词对所给出的位置信息,以检索相应的 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_025.png 值,其中(i,j)表示(目标,上下文)对:

 x_ij = np.array(cooccurrence_matrix[targets, 
        context].toarray()).ravel() 

然后,我们计算相应的 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_043.png(记作log_x_ij)和 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_044.png(记作sample_weights):

 # Compute log - Introducing an additive shift to make sure we
        # don't compute log(0)
        log_x_ij = np.log(x_ij + 1)

        # Sample weights 
        # if x < x_max => (x/x_max)**alpha / else => 1        
        sample_weights = np.where(x_ij < x_max, (x_ij/x_max)**alpha, 1) 

如果未选择代码,则设置一个随机种子。之后,contexttargetslog_x_ijsample_weights 将被打乱,同时保持数组元素之间的对应关系:

 # If seed is not provided generate a random one
        if not seed:
            seed = random.randint(0, 10e6)

        # Shuffle data
        np.random.seed(seed)
        np.random.shuffle(context)
        np.random.seed(seed)
        np.random.shuffle(targets)
        np.random.seed(seed)
        np.random.shuffle(log_x_ij)
        np.random.seed(seed)
        np.random.shuffle(sample_weights) 

最后,我们迭代通过我们上面创建的数据批次。每个批次将包含

按此顺序。

 # Generate a batch or data in the format 
        # ((target words, context words), log(X_ij) <- true targets,
        # f(X_ij) <- sample weights)
        for eg_id_start in range(0, context.shape[0], batch_size):            
            yield (
                targets[eg_id_start: min(eg_id_start+batch_size, 
                targets.shape[0])], 
                context[eg_id_start: min(eg_id_start+batch_size, 
                context.shape[0])]
            ), log_x_ij[eg_id_start: min(eg_id_start+batch_size, 
            log_x_ij.shape[0])], \
            sample_weights[eg_id_start: min(eg_id_start+batch_size, 
            sample_weights.shape[0])] 

现在数据已经准备好输入,我们来讨论最后一个步骤:训练模型。

训练与评估 GloVe

训练模型是轻而易举的,因为我们拥有所有训练模型所需的组件。第一步,我们将重用在第三章中创建的ValidationCallback,即Word2vec – 学习词嵌入。回顾一下,ValidationCallback是一个 Keras 回调。Keras 回调让你能够在每次训练迭代、周期、预测步骤等结束时执行一些重要操作。在这里,我们使用回调在每个周期结束时执行验证步骤。我们的回调将接受一个词 ID 的列表(作为验证词,存放在valid_term_ids中),包含嵌入矩阵的模型,以及一个解码词 ID 的 tokenizer。然后,它将计算验证词集中的每个词的最相似的 top-k 词,并将其作为输出:

glove_validation_callback = ValidationCallback(valid_term_ids, glove_model, tokenizer)
# Train the model for several epochs
for ei in range(epochs):

    print("Epoch: {}/{} started".format(ei+1, epochs))

    news_glove_data_gen = glove_data_generator(
        news_sequences, window_size, batch_size, n_vocab
    )

    glove_model.fit(
        news_glove_data_gen, epochs=1, 
        callbacks=glove_validation_callback,
    ) 

一旦模型训练完成,你应该能得到一个合乎预期的输出。以下是一些精心挑选的结果:

election: attorney, posters, forthcoming, november's, month's
months: weeks, years, nations, rbs, thirds
you: afford, we, they, goodness, asked
music: cameras, mp3, hp's, refuseniks, divide
best: supporting, category, asante, counterparts, actor
mr: ron, tony, bernie, jack, 63
leave: pay, need, unsubstantiated, suited, return
5bn: 8bn, 2bn, 1bn, 3bn, 7bn
debut: solo, speakerboxxx, youngster, nasty, toshack
images: 117, pattern, recorder, lennon, unexpectedly
champions: premier, celtic, football, representatives, neighbour
individual: extra, attempt, average, improvement, survived
businesses: medium, sell, redder, abusive, handedly
deutsche: central, austria's, donald, ecb, austria
machine: unforced, wireless, rapid, vehicle, workplace 

你可以看到,“months”,“weeks”和“years”等词被分到了一组。像“5bn”,“8bn”和“2bn”这样的数字也被分到了一组。“Deutsche”被“Austria’s”和“Austria”围绕。最后,我们将词嵌入保存到磁盘。我们将每个上下文和目标向量空间的权重与偏置合并为一个数组,其中数组的最后一列表示偏置,并将其保存到磁盘:

def save_embeddings(model, tokenizer, vocab_size, save_dir):

    os.makedirs(save_dir, exist_ok=True)

    _, words_sorted = zip(*sorted(list(tokenizer.index_word.items()),
    key=lambda x: x[0])[:vocab_size-1])

    words_sorted = [None] + list(words_sorted)

    context_embedding_weights = model.get_layer("context_embedding").get_
    weights()[0]
    context_embedding_bias = model.get_layer("context_embedding_bias").
    get_weights()[0]
    context_embedding = np.concatenate([context_embedding_weights,
    context_embedding_bias], axis=1)

    target_embedding_weights = model.get_layer("target_embedding").get_
    weights()[0]
    target_embedding_bias = model.get_layer("target_embedding_bias").get_
    weights()[0]
    target_embedding = np.concatenate([target_embedding_weights, target_
    embedding_bias], axis=1)

    pd.DataFrame(
        context_embedding, 
        index = words_sorted
    ).to_pickle(os.path.join(save_dir, "context_embedding_and_bias.pkl"))

    pd.DataFrame(
        target_embedding, 
        index = words_sorted
    ).to_pickle(os.path.join(save_dir, "target_embedding_and_bias.pkl"))

save_embeddings(glove_model, tokenizer, n_vocab, save_dir='glove_embeddings') 

我们将词嵌入保存为 pandas DataFrame。首先,我们按 ID 对所有词进行排序。我们减去 1,以去除保留的词 ID 0,因为我们将在下一行手动添加它。请注意,词 ID 0 不会出现在tokenizer.index_word中。接下来,我们按名称获取所需的层(即context_embeddingtarget_embeddingcontext_embedding_biastarget_embedding_bias)。一旦获取到这些层,我们可以使用get_weights()函数来获取权重。

在本节中,我们讨论了 GloVe,这是一种词嵌入学习技术。

GloVe 相对于第三章中讨论的 Word2vec 技术的主要优点在于,它关注语料库的全局和局部统计信息来学习嵌入。由于 GloVe 能够捕捉到词的全局信息,它通常能提供更好的性能,尤其是在语料库规模增大时。另一个优点是,与 Word2vec 技术不同,GloVe 并不近似代价函数(例如,Word2vec 使用负采样),而是计算真正的代价。这使得损失的优化更加高效和容易。

在下一节中,我们将介绍另一个词向量算法,称为来自语言模型的嵌入ELMo)。

ELMo – 消除词向量中的歧义

到目前为止,我们已经研究了只能为词汇中的每个单词提供唯一表示的词嵌入算法。然而,它们会为给定的单词提供恒定的表示,无论你查询多少次。这为什么是个问题呢?请考虑以下两个短语:

我去银行存了一些钱

我沿着河岸走

显然,单词“bank”在两个完全不同的语境中使用。如果你使用普通的词向量算法(例如 skip-gram),你只能为单词“bank”提供一个表示,并且这个表示可能会在金融机构的概念和可以走的河岸边缘的概念之间混淆,具体取决于它在语料库中的引用。因此,更合理的做法是为一个词提供嵌入,同时保留并利用它周围的上下文。这正是 ELMo 所努力实现的目标。

具体来说,ELMo 处理的是一系列输入,而不是单一的词汇,并为序列中每个词提供上下文化的表示。图 4.3 展示了涵盖该模型的不同组件。首先需要理解的是,ELMo 是一个复杂的系统!在 ELMo 中,许多神经网络模型相互协调以产生输出。特别地,模型使用:

  • 一个字符嵌入层(每个字符的嵌入向量)。

  • 一个 卷积神经网络 (CNN)——CNN 由许多卷积层和可选的全连接分类层组成。

卷积层接收一系列输入(例如单词中的字符序列),并在输入上移动一个加权窗口来生成潜在表示。我们将在后续章节中详细讨论 CNN。

  • 两个双向 LSTM 层——LSTM 是一种用于处理时间序列数据的模型。给定一系列输入(例如词向量序列),LSTM 会沿着时间维度从一个输入处理到另一个输入,并在每个位置产生一个输出。与全连接网络不同,LSTM 具有记忆功能,这意味着当前位点的输出会受到 LSTM 过去见过的数据的影响。我们将在后续章节中详细讨论 LSTM。

这些不同组件的具体细节超出了本章的讨论范围。它们将在后续章节中详细讨论。因此,如果你不理解这里展示的子组件的具体机制,也不必担心(图 4.3)。

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_03.png

图 4.3:ELMo 模型的不同组件。词嵌入是通过一种名为 CNN 的神经网络生成的。这些词嵌入被输入到 LSTM 模型中(该模型可以处理时间序列数据)。第一个 LSTM 模型的输出被输入到第二个 LSTM 模型,以生成每个词的潜在上下文化表示。

我们可以从 TensorFlow Hub (tfhub.dev) 下载预训练的 ELMo 模型。TF Hub 是各种预训练模型的存储库。

它托管了用于图像分类、文本分类、文本生成等任务的模型。你可以访问该网站并浏览各种可用的模型。

从 TensorFlow Hub 下载 ELMo

我们将使用的 ELMo 模型位于 tfhub.dev/google/elmo/3。它已经在一个非常大的文本语料库上进行了训练,以解决称为语言建模的任务。在语言建模中,我们试图根据先前的标记序列预测下一个单词。在接下来的章节中,我们将更多地了解语言建模。

在下载模型之前,让我们设置以下环境变量:

# Not allocating full GPU memory upfront
%env TF_FORCE_GPU_ALLOW_GROWTH=true
# Making sure we cache the models and are not downloaded all the time
%env TFHUB_CACHE_DIR=./tfhub_modules 

TF_FORCE_GPU_ALLOW_GROWTH 允许 TensorFlow 根据需要分配 GPU 内存,而不是一次性分配所有 GPU 内存。 TFHUB_CACHE_DIR 设置模型下载的目录。我们首先导入 TensorFlow Hub:

import tensorflow_hub as hub 

接下来,像往常一样,我们将通过运行以下代码清除任何正在运行的 TensorFlow 会话:

import tensorflow as tf
import tensorflow.keras.backend as K
K.clear_session() 

最后,我们将下载 ELMo 模型。你可以使用两种方式从 TF Hub 下载预训练模型并在我们的代码中使用它们:

  • hub.load(<url>, **kwargs) – 推荐的下载和使用 TensorFlow 2 兼容模型的方式

  • hub.KerasLayer(<url>, **kwargs) – 这是在 TensorFlow 2 中使用基于 TensorFlow 1 的模型的一种解决方案

不幸的是,ELMo 还没有移植到 TensorFlow 2。因此,我们将使用 hub.KerasLayer() 作为在 TensorFlow 2 中加载 ELMo 的解决方法:

elmo_layer = hub.KerasLayer(
    "https://tfhub.dev/google/elmo/3", 
    signature="tokens",signature_outputs_as_dict=True
) 

请注意,我们正在提供两个参数,signaturesignature_outputs_as_dict

  • signature (str) – 可以是 defaulttokens。默认签名接受字符串列表,其中每个字符串将在内部转换为标记列表。标记签名接受输入为具有两个键的字典。即 tokens(标记列表的列表。每个标记列表是一个短语/句子,包括填充标记以将其调整为固定长度)和 “sequence_len”(每个标记列表的长度,以确定填充长度)。

  • signature_outputs_as_dict (bool) – 当设置为 true 时,将返回提供的签名中定义的所有输出。

现在我们已经理解了 ELMo 的组成部分,并从 TensorFlow Hub 下载了它,让我们看看如何处理 ELMo 的输入数据。

准备 ELMo 的输入

在这里,我们将定义一个函数,将给定的字符串列表转换为 ELMo 期望输入的格式。请记住,我们将 ELMo 的签名设置为 tokens。签名 "tokens" 的示例输入如下。

{
    'tokens': [
        ['the', 'cat', 'sat', 'on', 'the', 'mat'],
        ['the', 'mat', 'sat', '', '', '']
    ], 
    'sequence_len': [6, 3]
} 

让我们花一点时间处理输入的组成部分。首先,它有一个关键字tokens,其中包含一系列令牌。每个令牌列表可以看作是一个句子。注意短句子的末尾如何添加填充以匹配长度。这很重要,否则模型会抛出错误,因为它无法将任意长度的序列转换为张量。接下来我们有sequence_len,它是一个整数列表。每个整数指定每个序列的真实长度。注意第二个元素为 3,以匹配第二个序列中实际存在的令牌。

给定一个字符串列表,我们可以编写一个函数来为我们执行这个转换。这就是format_text_for_elmo()函数的作用。让我们深入了解具体细节:

def format_text_for_elmo(texts, lower=True, split=" ", max_len=None):

    """ Formats a given text for the ELMo model (takes in a list of
    strings) """

    token_inputs = [] # Maintains individual tokens
    token_lengths = [] # Maintains the length of each sequence

    max_len_inferred = 0 
    # We keep a variable to maintain the max length of the input
    # Go through each text (string)
    for text in texts:    

        # Process the text and get a list of tokens
        tokens = tf.keras.preprocessing.text.text_to_word_sequence(text, 
        lower=lower, split=split)

        # Add the tokens 
        token_inputs.append(tokens)                   

        # Compute the max length for the collection of sequences
        if len(tokens)>max_len_inferred:
            max_len_inferred = len(tokens)

    # It's important to make sure the maximum token length is only as
    # large as the longest input in the sequence
    # Here we make sure max_len is only as large as the longest input
    if max_len and max_len_inferred < max_len:
        max_len = max_len_inferred
    if not max_len:
        max_len = max_len_inferred

    # Go through each token sequence and modify sequences to have same
    # length
    for i, token_seq in enumerate(token_inputs):

        token_lengths.append(min(len(token_seq), max_len))

        # If the maximum length is less than input length, truncate
        if max_len < len(token_seq):
            token_seq = token_seq[:max_len]            
        # If the maximum length is greater than or equal to input length,
        # add padding as needed
        else:            
            token_seq = token_seq+[""]*(max_len-len(token_seq))

        assert len(token_seq)==max_len

        token_inputs[i] = token_seq

    # Return the final output
    return {
        "tokens": tf.constant(token_inputs), 
        "sequence_len": tf.constant(token_lengths)
    } 

我们首先创建两个列表,token_inputstoken_lengths,用于包含单个令牌及其各自的长度。接下来,我们遍历texts中的每个字符串,使用tf.keras.preprocessing.text.text_to_word_sequence()函数获取单个令牌。在此过程中,我们将计算迄今为止观察到的最大令牌长度。遍历完所有序列后,我们检查从输入推断出的最大长度是否与max_len(如果指定)不同。如果不同,我们将使用max_len_inferred作为最大长度。这一点很重要,因为如果你不这样做,可能会通过为max_len定义一个大值来不必要地延长输入长度。不仅如此,如果你这么做,模型将抛出像下面这样的错误。

 #InvalidArgumentError:  Incompatible shapes: [2,6,1] vs. [2,10,1024]
    #    [[node mul (defined at .../python3.6/site-packages/tensorflow_
    hub/module_v2.py:106) ]] [Op:__inference_pruned_3391] 

一旦找到适当的最大长度,我们将遍历序列并

  • 如果它比max_len长,则截断序列。

  • 如果它比max_len短,则添加令牌直到达到max_len

最后,我们将使用tf.constant构造将它们转换为tf.Tensor对象。例如,你可以使用以下方式调用该函数:

print(format_text_for_elmo(["the cat sat on the mat", "the mat sat"], max_len=10)) 

这将输出:

{'tokens': <tf.Tensor: shape=(2, 6), dtype=string, numpy=
array([[b'the', b'cat', b'sat', b'on', b'the', b'mat'],
       [b'the', b'mat', b'sat', b'', b'', b'']], dtype=object)>, 'sequence_len': <tf.Tensor: shape=(2,), dtype=int32, numpy=array([6, 3], dtype=int32)>} 

接下来我们将看到如何使用 ELMo 为准备好的输入生成嵌入。

使用 ELMo 生成嵌入

一旦输入准备好,生成嵌入就非常简单。首先,我们将把输入转换为 ELMo 层规定的格式。这里我们使用 BBC 数据集中的一些示例标题:

# Titles of 001.txt - 005.txt in bbc/business
elmo_inputs = format_text_for_elmo([
    "Ad sales boost Time Warner profit",
    "Dollar gains on Greenspan speech",
    "Yukos unit buyer faces loan claim",
    "High fuel prices hit BA's profits",
    "Pernod takeover talk lifts Domecq"
]) 

接下来,只需将elmo_inputs传递给elmo_layer作为输入,并获取结果:

# Get the result from ELMo
elmo_result = elmo_layer(elmo_inputs) 

现在让我们使用以下代码打印结果及其形状:

# Print the result
for k,v in elmo_result.items():    
    print("Tensor under key={} is a {} shaped Tensor".format(k, v.shape)) 

这将打印出:

Tensor under key=sequence_len is a (5,) shaped Tensor
Tensor under key=elmo is a (5, 6, 1024) shaped Tensor
Tensor under key=default is a (5, 1024) shaped Tensor
Tensor under key=lstm_outputs1 is a (5, 6, 1024) shaped Tensor
Tensor under key=lstm_outputs2 is a (5, 6, 1024) shaped Tensor
Tensor under key=word_emb is a (5, 6, 512) shaped Tensor 

正如你所看到的,模型返回了 6 个不同的输出。让我们逐一查看:

  • sequence_len – 我们提供的相同输入,包含输入中各个序列的长度

  • word_emb – 通过 ELMo 模型中的 CNN 层获得的令牌嵌入。我们为所有序列位置(即 6)和批次中的所有行(即 5)得到了一个大小为 512 的向量。

  • lstm_output1 – 通过第一个 LSTM 层获得的令牌的上下文化表示

  • lstm_output2 – 通过第二个 LSTM 层获得的令牌的上下文化表示

  • default – 通过对所有的lstm_output1lstm_output2嵌入进行平均得到的平均嵌入向量

  • elmo – 所有word_emblstm_output1lstm_output2的加权和,其中权重是一组任务特定的可训练参数,将在任务特定训练期间一起训练

我们在这里关注的是default输出。它将为我们提供文档内容的非常好的表示。

其他词嵌入技术

除了我们在这里讨论的词嵌入技术外,还有一些著名的广泛使用的词嵌入技术。我们将在此讨论其中一些。

FastText

FastText (fasttext.cc/),由 Bojanowski 等人于论文《Enriching Word Vectors with Subword Information》提出 (arxiv.org/pdf/1607.04606.pdf),介绍了一种通过考虑词语的子组件来计算词嵌入的技术。具体来说,他们将词嵌入计算为词的n-gram 嵌入的总和,n取多个值。在论文中,他们使用了3 <= n <= 6。例如,对于单词“banana”,三元组(n=3)为['ban', 'ana', 'nan', 'ana']。这使得嵌入变得更加健壮,能够抵抗文本中的常见问题,比如拼写错误。

Swivel 嵌入

Swivel 嵌入,由 Shazeer 等人于论文《Swivel: Improving Embeddings by Noticing What’s Missing》提出 (arxiv.org/pdf/1602.02215.pdf),尝试将 GloVe 和跳字模型与负采样结合。GloVe 的一个关键限制是它仅使用有关正向上下文的信息。因此,该方法不会因尝试创建未曾一起出现的单词的相似向量而受到惩罚。而跳字模型中使用的负采样则直接解决了这个问题。Swivel 的最大创新是包含未观察到的词对的损失函数。作为额外的好处,它还可以在分布式环境中进行训练。

Transformer 模型

Transformer 模型是一类重新定义我们思考 NLP 问题方式的模型。Transformer 模型最初由 Vaswani 在论文《Attention is all you need》中提出 (arxiv.org/pdf/1706.03762.pdf)。该模型内部有许多不同的嵌入,像 ELMo 一样,它可以通过处理文本序列为每个标记生成嵌入。我们将在后续章节中详细讨论 Transformer 模型。

我们已经讨论了使用 ELMo 模型所需的所有细节。接下来,我们将使用 ELMo 进行文档分类,在此过程中,ELMo 将生成文档嵌入作为分类模型的输入。

使用 ELMo 进行文档分类

尽管 Word2vec 提供了一种非常优雅的学习词语数值表示的方法,但仅仅学习词表示并不足以令人信服地展示词向量在实际应用中的强大功能。

词嵌入被用作许多任务中词语的特征表示,比如图像标题生成和机器翻译。然而,这些任务涉及结合不同的学习模型,如卷积神经网络CNNs)和长短期记忆LSTM)模型,或者两个 LSTM 模型(CNN 和 LSTM 模型将在后续章节中详细讨论)。为了理解词嵌入在实际应用中的使用,我们可以从一个更简单的任务——文档分类开始。

文档分类是自然语言处理(NLP)中最流行的任务之一。文档分类对处理海量数据的人员非常有用,例如新闻网站、出版商和大学。因此,看看如何通过嵌入整个文档而不是词语,将学习到的词向量应用于像文档分类这样的实际任务是非常有趣的。

本练习可在Ch04-Advance-Word-Vectors文件夹下找到(ch4_document_classification.ipynb)。

数据集

对于这个任务,我们将使用一组已组织好的文本文件。这些是来自 BBC 的新闻文章。该数据集中每篇文档都属于以下类别之一:商业娱乐政治体育技术

以下是来自实际数据的几个简短片段:

商业

日本勉强避免衰退

日本经济在截至九月的三个月里,勉强避免了技术性衰退,数据显示。

修正后的数据显示增长仅为 0.1%——而前一季度也出现了类似规模的收缩。按年计算,数据表明年增长仅为 0.2%,…

首先,我们将下载数据并将其加载到内存中。我们将使用相同的download_data()函数来下载数据。然后,我们会稍微修改read_data()函数,使其不仅返回文章列表(每篇文章是一个字符串),还返回文件名列表,其中每个文件名对应存储该文章的文件。文件名随后将帮助我们为分类模型创建标签。

def read_data(data_dir):

    # This will contain the full list of stories
    news_stories = []    
    filenames = []
    print("Reading files")

    i = 0 # Just used for printing progress
    for root, dirs, files in os.walk(data_dir):

        for fi, f in enumerate(files):

            # We don't read the readme file
            if 'README' in f:
                continue

            # Printing progress
            i += 1
            print("."*i, f, end='\r')

            # Open the file
            with open(os.path.join(root, f), encoding='latin-1') as text_
            file:
                story = []
                # Read all the lines
                for row in text_file:
                    story.append(row.strip())

                # Create a single string with all the rows in the doc
                story = ' '.join(story)                        
                # Add that to the list
                news_stories.append(story)  
                filenames.append(os.path.join(root, f))

        print('', end='\r')

    print("\nDetected {} stories".format(len(news_stories)))
    return news_stories, filenames
news_stories, filenames = read_data(os.path.join('data', 'bbc')) 

然后,我们将像之前一样在数据上创建并拟合一个分词器。

from tensorflow.keras.preprocessing.text import Tokenizer
n_vocab = 15000 + 1
tokenizer = Tokenizer(
    num_words=n_vocab - 1,
    filters='!"#$%&()*+,-./:;<=>?@[\\]^_'{|}~\t\n',
    lower=True, split=' ', oov_token=''
)
tokenizer.fit_on_texts(news_stories) 

接下来,我们将创建标签。由于我们正在训练一个分类模型,因此我们需要输入和标签。我们的输入将是文档嵌入(我们很快会看到如何计算它们),而目标将是一个介于 0 和 4 之间的标签 ID。我们上面提到的每个类别(例如,商业、技术等)将被分配到一个单独的类别中。由于文件名包括作为文件夹的类别,因此我们可以利用文件名生成标签 ID。

我们将使用 pandas 库来创建标签。首先,我们将文件名列表转换为 pandas 的 Series 对象,方法如下:

labels_ser = pd.Series(filenames, index=filenames) 

该系列中的一个示例条目可能类似于data/bbc/tech/127.txt。接下来,我们将按“/”字符分割每个条目,这将返回一个列表['data', 'bbc', 'tech', '127.txt']。我们还会设置expand=Trueexpand=True将通过将列表中的每个项目转换为DataFrame的单独列,来把我们的 Series 对象转换成 DataFrame。换句话说,我们的pd.Series对象将变成一个形状为[N, 4]pd.DataFrame,每一列包含一个 token,其中N是文件的数量:

labels_ser = labels_ser.str.split(os.path.sep, expand=True) 

在生成的数据中,我们只关心第三列,它包含了给定文章的类别(例如tech)。因此,我们将丢弃其他数据,仅保留这一列:

labels_ser = labels_ser.iloc[:, -2] 

最后,我们将使用 pandas 的map()函数将字符串标签映射到整数 ID,方法如下:

labels_ser = labels_ser.map({'business': 0, 'entertainment': 1, 'politics': 2, 'sport': 3, 'tech': 4}) 

这将导致如下结果:

data/bbc/tech/272.txt    4
data/bbc/tech/127.txt    4
data/bbc/tech/370.txt    4
data/bbc/tech/329.txt    4
data/bbc/tech/240.txt    4
Name: 2, dtype: int64 

我们在这里所做的,可以通过将一系列命令链式写成一行来实现:

labels_ser = pd.Series(filenames, index=filenames).str.split(os.path.sep, expand=True).iloc[:, -2].map(
    {'business': 0, 'entertainment': 1, 'politics': 2, 'sport': 3,
    'tech': 4}
) 

接下来,我们进入另一个重要步骤,即将数据拆分为训练集和测试集。在训练一个监督学习模型时,我们通常需要三个数据集:

  • 训练集 —— 这是模型将要训练的 数据集。

  • 验证集 —— 这个数据集将在训练过程中用于监控模型性能(例如,防止过拟合的迹象)。

  • 测试集 —— 该数据集在模型训练过程中不会暴露给模型。它只会在模型训练完成后,用于评估模型在未见数据上的表现。

在这个练习中,我们只使用训练集和测试集。这将帮助我们将讨论聚焦于嵌入部分,并简化关于下游分类模型的讨论。这里我们将 67%的数据作为训练数据,33%的数据作为测试数据。数据将随机拆分:

from sklearn.model_selection import train_test_split
train_labels, test_labels = train_test_split(labels_ser, test_size=0.33) 

现在我们有了一个训练数据集用于训练模型,以及一个测试数据集用于在未见数据上进行测试。接下来我们将展示如何从标记或单词嵌入生成文档嵌入。

生成文档嵌入

让我们首先回顾一下我们如何存储 skip-gram、CBOW 和 GloVe 算法的嵌入。图 4.4展示了这些嵌入在pd.DataFrame对象中的样子。

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_04.png

图 4.4:保存到磁盘的 skip-gram 算法上下文嵌入的快照。你可以看到下方显示它有 128 列(即嵌入维度)。

ELMo 嵌入是一个例外。由于 ELMo 为序列中的所有 token 生成上下文表示,因此我们存储了通过对所有生成的向量取平均得到的均值嵌入向量:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_05.png

图 4.5:ELMo 向量的快照。ELMo 向量有 1024 个元素。

为了从 skip-gram、CBOW 和 GloVe 嵌入计算文档嵌入,让我们编写以下函数:

def generate_document_embeddings(texts, filenames, tokenizer, embeddings):

    """ This function takes a sequence of tokens and compute the mean
    embedding vector from the word vectors of all the tokens in the
    document """

    doc_embedding_df = []
    # Contains document embeddings for all the articles
    assert isinstance(embeddings, pd.DataFrame), 'embeddings must be a 
    pd.DataFrame'

    # This is a trick we use to quickly get the text preprocessed by the
    # tokenizer
    # We first convert text to a sequences, and then back to text, which
    # will give the preprocessed tokens
    sequences = tokenizer.texts_to_sequences(texts)    
    preprocessed_texts = tokenizer.sequences_to_texts(sequences)

    # For each text,
    for text in preprocessed_texts:
        # Make sure we had matches for tokens in the embedding matrx
        assert embeddings.loc[text.split(' '), :].shape[0]>0
        # Compute mean of all the embeddings associated with words
        mean_embedding = embeddings.loc[text.split(' '), :].mean(axis=0)
        # Add that to list
        doc_embedding_df.append(mean_embedding)

    # Save the doc embeddings in a dataframe
    doc_embedding_df = pd.DataFrame(doc_embedding_df, index=filenames)

    return doc_embedding_df 

generate_document_embeddings()函数接受以下参数:

  • texts – 一个字符串列表,其中每个字符串代表一篇文章

  • filenames – 一个文件名列表,对应于texts中的文章

  • tokenizer – 一个可以处理texts的分词器

  • embeddings – 以pd.DataFrame格式表示的嵌入,其中每一行代表一个词向量,按对应的标记索引

该函数首先通过将字符串转换为序列,然后再转换回字符串列表来预处理文本。这帮助我们利用分词器的内置预处理功能来清理文本。接下来,将每个预处理的字符串按空格字符拆分,以返回一个词元列表。然后,我们索引嵌入矩阵中所有与文本中所有词元对应的位置。最后,通过计算所有选择的嵌入向量的均值来计算文档的均值向量。

有了这个,我们可以加载来自不同算法(skip-gram、CBOW 和 GloVe)的嵌入,并计算文档嵌入。这里我们仅展示 skip-gram 算法的过程。但你可以轻松扩展到其他算法,因为它们有类似的输入和输出:

# Load the skip-gram embeddings context and target
skipgram_context_embeddings = pd.read_pickle(
    os.path.join('../Ch03-Word-Vectors/skipgram_embeddings',
    'context_embedding.pkl')
)
skipgram_target_embeddings = pd.read_pickle(
    os.path.join('../Ch03-Word-Vectors/skipgram_embeddings',
    'target_embedding.pkl')
)
# Compute the mean of context & target embeddings for better embeddings
skipgram_embeddings = (skipgram_context_embeddings + skipgram_target_embeddings)/2
# Generate the document embeddings with the average context target
# embeddings
skipgram_doc_embeddings = generate_document_embeddings(news_stories, filenames, tokenizer, skipgram_embeddings) 

现在我们将看到如何利用生成的文档嵌入来训练分类器。

使用文档嵌入进行文档分类

我们将在此数据上训练一个简单的多类(或多项式)逻辑回归分类器。逻辑回归模型将如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_06.png

图 4.6:该图描述了多项式逻辑回归模型。模型接受一个嵌入向量,并输出不同类别的概率分布

这是一个非常简单的模型,只有一层,其中输入是嵌入向量(例如,一个包含 128 个元素的向量),输出是一个 5 节点的 softmax 层,该层会输出输入属于每个类别的可能性,作为一个概率分布。

我们将训练多个模型,而不是仅仅一次运行。这将为我们提供一个更一致的模型性能结果。为了实现这个模型,我们将使用一个流行的通用机器学习库,名为 scikit-learn(scikit-learn.org/stable/)。在每次运行中,都会创建一个多类逻辑回归分类器,使用sklearn.linear_model.LogisticRegression对象。此外,在每次运行中:

  1. 模型在训练输入和目标上进行训练

  2. 模型为每个测试输入预测类别(一个从 0 到 4 的值),其中输入的类别是所有类别中具有最大概率的类别

  3. 模型使用测试集的预测类别和真实类别来计算测试准确度

代码如下所示:

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
def get_classification_accuracy(doc_embeddings, train_labels, test_labels, n_trials):
    """ Train a simple MLP model for several trials and measure test 
    accuracy"""

    accuracies = [] # Store accuracies across trials

    # For each trial
    for trial in range(n_trials):
        # Create a MLP classifier
        lr_classifier = LogisticRegression(multi_class='multinomial', 
        max_iter=500)

        # Fit the model on training data
        lr_classifier.fit(doc_embeddings.loc[train_labels.index],
        train_labels)

        # Get the predictions for test data
        predictions = lr_classifier.predict(doc_embeddings.loc[test_
        labels.index])

        # Compute accuracy
        accuracies.append(accuracy_score(predictions, test_labels))

    return accuracies
# Get classification accuracy for skip-gram models
skipgram_accuracies = get_classification_accuracy(
    skipgram_doc_embeddings, train_labels, test_labels, n_trials=5
)
print("Skip-gram accuracies: {}".format(skipgram_accuracies)) 

通过设置multi_class='multinomial',我们确保这是一个多类逻辑回归模型(或 softmax 分类器)。这将输出:

Skip-gram accuracies: [0.882, 0.882, 0.881, 0.882, 0.884] 

当你按步骤操作所有 skip-gram、CBOW、GloVe 和 ELMo 算法时,你会看到类似以下的结果。这是一个箱线图。然而,由于各次实验的表现相似,因此图表中不会出现太多变化。

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_07.png

图 4.7:不同模型在文档分类中的性能箱线图。我们可以看到,ELMo 是明显的赢家,而 GloVe 的表现最差。

我们可以看到,skip-gram 达到了大约 86% 的准确率,紧随其后的是 CBOW,二者的表现相当。令人惊讶的是,GloVe 的表现远低于 skip-gram 和 CBOW,准确率约为 66%。

这可能指向 GloVe 损失函数的一个限制。与 skip-gram 和 CBOW 不同,后者同时考虑正样本(已观察到)和负样本(未观察到)的目标和上下文对,而 GloVe 只关注已观察到的对。

这可能会影响 GloVe 生成有效词表示的能力。最终,ELMo 达到了最佳性能,准确率大约为 98%。但需要注意的是,ELMo 是在比 BBC 数据集更大规模的数据集上训练的,因此仅根据这个数字将 ELMo 与其他模型进行比较是不公平的。

在这一部分,你学习了如何将词嵌入扩展为文档嵌入,并且如何将这些嵌入用于下游分类模型进行文档分类。首先,你了解了使用选定算法(例如 skip-gram、CBOW 和 GloVe)进行词嵌入。然后我们通过对文档中所有单词的词嵌入进行平均来创建文档嵌入。这适用于 skip-gram、CBOW 和 GloVe 算法。在 ELMo 算法的情况下,我们能够直接从模型中推断出文档嵌入。随后,我们使用这些文档嵌入对一些 BBC 新闻文章进行分类,这些文章属于以下类别:娱乐、科技、政治、商业和体育。

总结

在本章中,我们讨论了 GloVe——另一种词嵌入学习技术。GloVe 通过将全局统计信息纳入优化,进一步提升了当前 Word2Vec 算法的性能。

接下来,我们学习了一个更为先进的算法,叫做 ELMo(即来自语言模型的嵌入)。ELMo 通过查看单词在句子或短语中的上下文,而不是孤立地看待单词,提供了上下文化的词表示。

最后,我们讨论了词嵌入的一个实际应用——文档分类。我们展示了词嵌入非常强大,并且允许我们用一个简单的多类逻辑回归模型相当好地分类相关文档。由于 ELMo 在大量数据上进行了训练,因此其表现优于 skip-gram、CBOW 和 GloVe。

在下一章,我们将讨论另一类深度网络,它们在利用数据中存在的空间信息方面更强大,称为卷积神经网络CNNs)。

具体来说,我们将看到如何利用 CNNs 来挖掘句子的空间结构,将其分类到不同的类别中。

要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub

加入我们的 Discord 社区,与志同道合的人交流,和超过 1000 名成员一起学习,网址为:packt.link/nlp

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/QR_Code5143653472357468031.png

第五章:使用卷积神经网络进行句子分类

在本章中,我们将讨论一种叫做 卷积神经网络CNN)的神经网络。CNN 与全连接神经网络有很大的不同,并且在许多任务中取得了最先进的性能。这些任务包括图像分类、物体检测、语音识别,当然还有句子分类。CNN 的主要优势之一是,与全连接层相比,CNN 中的卷积层参数数量要小得多。这使得我们能够构建更深的模型,而不必担心内存溢出。此外,深层模型通常会带来更好的性能。

我们将通过讨论 CNN 中的不同组件以及使 CNN 与全连接神经网络不同的特性,详细介绍 CNN。接着,我们将讨论 CNN 中使用的各种操作,如卷积操作和池化操作,以及与这些操作相关的一些超参数,如滤波器大小、填充和步幅。我们还将看看实际操作背后的一些数学原理。在对 CNN 有了充分的理解后,我们将探讨使用 TensorFlow 实现 CNN 的实际操作。首先,我们将实现一个 CNN 用于图像分类,然后使用 CNN 进行句子分类。具体来说,我们将通过以下几个主题:

  • 学习 CNN 的基础知识

  • 使用 CNN 进行图像分类

  • 使用 CNN 进行句子分类

介绍 CNN

在本节中,你将学习 CNN。具体来说,你将首先理解 CNN 中存在的各种操作,如卷积层、池化层和全连接层。接下来,我们将简要了解这些操作是如何连接在一起形成一个端到端模型的。

需要注意的是,我们将用 CNN 解决的第一个用例是图像分类任务。CNN 最初是用来解决计算机视觉任务的,后来才被应用于自然语言处理(NLP)。此外,CNN 在计算机视觉领域的应用要比在 NLP 领域更为广泛,这使得在视觉上下文中解释其基本概念更加容易。因此,我们将首先学习 CNN 在计算机视觉中的应用,然后再转向 NLP。

CNN 基础知识

现在,让我们在不深入技术细节的情况下探讨 CNN 背后的基本思想。CNN 是一堆层的堆叠,包括卷积层、池化层和全连接层。我们将讨论每一层,以了解它们在 CNN 中的作用。

最初,输入连接到一组卷积层。这些卷积层通过卷积操作,滑动一个权重块(有时称为卷积窗口或滤波器)在输入上并产生输出。卷积层使用少量的权重,这些权重组织成每层仅覆盖输入的小块,这与全连接神经网络不同,这些权重在某些维度(例如图像的宽度和高度)上是共享的。此外,CNN 使用卷积操作通过滑动这小部分权重沿着目标维度来共享输出的权重。通过这个卷积操作,我们最终得到的结果如图 5.1所示。如果卷积滤波器中存在的模式在图像的小块中出现,卷积将为该位置输出一个较高的值;如果没有,它将输出一个较低的值。此外,通过对整个图像进行卷积,我们得到一个矩阵,表示在某个位置是否存在该模式。最终,我们会得到一个作为卷积输出的矩阵:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_01.png

图 5.1:卷积操作对图像的作用

此外,这些卷积层可以与池化/子采样层交替使用,池化层减少了输入的维度。在减少维度的同时,我们使得卷积神经网络(CNN)的平移不变性得以保持,并且强迫 CNN 在较少的信息下进行学习,从而提高模型的泛化能力和正则化效果。通过将输入划分为多个小块并将每个小块转换为单个元素,我们可以减少维度。例如,这种转换包括选择一个小块中的最大元素或对一个小块中的所有值进行平均。我们将在图 5.2中展示池化如何使 CNN 的平移不变性得以保持:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_02.png

图 5.2:池化操作如何帮助使数据的平移不变

在这里,我们有原始图像和稍微在y轴上平移的图像。我们为这两幅图像计算了卷积输出,可以看到值10在卷积输出中的位置略有不同。然而,使用最大池化(它取每个厚方块的最大值),我们最终可以得到相同的输出。我们将在后续详细讨论这些操作。

最后,输出被传递给一组全连接层,这些层将输出传递给最终的分类/回归层(例如,句子/图像分类)。全连接层包含了 CNN 总权重的大部分,因为卷积层的权重较少。然而,研究发现,CNN 在有全连接层的情况下表现优于没有全连接层的情况。这可能是因为卷积层由于其较小的尺寸而学习到更多局部特征,而全连接层则提供了这些局部特征应该如何连接以产生理想输出的全局视图。

图 5.3展示了一个典型的 CNN 用于图像分类:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_03.png

图 5.3:典型的 CNN 架构

从图中可以明显看出,CNN 在设计上能够在学习过程中保持输入的空间结构。换句话说,对于二维输入,CNN 通常会有二维的层,而接近输出层时则只有全连接层。保持空间结构使得 CNN 能够利用输入的宝贵空间信息,并以较少的参数学习输入。空间信息的价值在图 5.4中得到了说明:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_04.png

图 5.4:将图像展开为一维向量会丧失一些重要的空间信息

如你所见,当一张猫的二维图像被展开为一维向量时,耳朵不再靠近眼睛,鼻子也远离眼睛。这意味着我们在展开过程中破坏了一些有用的空间信息。这就是保持输入的二维特性如此重要的原因。

CNN 的强大之处

卷积神经网络(CNNs)是一个非常多功能的模型家族,并在许多类型的任务中表现出了卓越的性能。这种多功能性归因于 CNN 能够同时执行特征提取和学习,从而提高了效率和泛化能力。让我们讨论几个 CNN 的应用实例。

ImageNet 大规模视觉识别挑战赛ILSVRC)2020 中,CNN 被用于图像分类、物体检测和物体定位任务,并取得了惊人的测试准确率。例如,在图像分类任务中,CNN 的 Top-1 测试准确率约为 90%,涵盖 1,000 个不同的物体类别,这意味着 CNN 能够正确识别大约 900 个不同的物体。

CNN 也被用于图像分割。图像分割是指将图像划分为不同的区域。例如,在包含建筑物、道路、车辆和乘客的城市景观图像中,将道路从建筑物中隔离出来就是一个分割任务。此外,CNN 在自然语言处理(NLP)任务中也取得了显著进展,展现了其在句子分类、文本生成和机器翻译等任务中的表现。

理解卷积神经网络(CNN)

现在我们已经了解了卷积神经网络的高层次概念,让我们来深入了解 CNN 的技术细节。首先,我们将讨论卷积操作并介绍一些术语,比如滤波器大小、步长和填充。简而言之,滤波器大小指的是卷积操作的窗口大小,步长指的是卷积窗口每次移动的距离,填充则指的是处理输入边界的方式。我们还将讨论一种叫做反卷积或转置卷积的操作。然后,我们将讨论池化操作的细节。最后,我们将讨论如何添加全连接层,以生成分类或回归输出。

卷积操作

在本节中,我们将详细讨论卷积操作。首先,我们将讨论没有步长和填充的卷积操作,然后描述有步长的卷积操作,接着讨论有填充的卷积操作。最后,我们将讨论一种叫做转置卷积的操作。对于本章中的所有操作,我们假设索引从 1 开始,而不是从 0 开始。

标准卷积操作

卷积操作是卷积神经网络(CNN)的核心部分。对于一个大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_001.png 的输入和一个权重块(也称为滤波器卷积核https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_002.png,其中 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_003.png,卷积操作将权重块滑动到输入上。我们用 X 表示输入,W 表示权重块,H 表示输出。此外,在每个位置 i, j,输出按如下公式计算:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_004.png

这里,x [i,j]、w [i,j] 和 h[i,j] 分别表示 XWH(i,j) 位置的值。如方程所示,尽管输入大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_001.png,但在这种情况下输出的大小将是 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_006.png。此外,m 被称为滤波器大小。这意味着输出的宽度和高度将略小于原始输入。让我们通过可视化来看这个问题(见 图 5.5):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_05.png

图 5.5:卷积操作,滤波器大小 (m) = 3,步长 = 1,并且没有填充

注意

卷积操作产生的输出(图 5.5 上方的矩形)有时被称为特征图

接下来让我们讨论卷积中的步长参数。

带步长的卷积

在前面的例子中,我们通过一步进行滤波器的移动。然而,这并不是强制性的;我们可以在卷积输入时采用较大的步长或步幅。因此,步长的大小被称为步幅。

让我们修改之前的公式,加入s [i]和s [j]步幅:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_007.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_007.1.png

在这种情况下,随着s[i]和s[j]增大,输出会变小。对比图 5.5步幅 = 1)和图 5.6步幅 = 2)可以说明不同步幅的效果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_06.png

图 5.6:滤波器大小(m)= 2,步幅 = 2,并且没有填充的卷积操作

如你所见,使用步幅进行卷积有助于像池化层一样减少输入的维度。因此,有时在卷积神经网络(CNN)中,卷积操作与步幅结合使用,代替池化操作,因为它能减少计算复杂度。还需注意,步幅所实现的维度减小可以进行调整或控制,而标准卷积操作的维度减小是固有的。接下来,我们将讨论卷积中另一个重要的概念——填充。

卷积与填充

每次卷积操作(没有步幅)不可避免地会导致输出尺寸的减小,这是一个不希望出现的属性。这大大限制了网络中可以使用的层数。另外,已知较深的网络比浅层网络表现更好。需要注意的是,这不应与通过步幅实现的维度减小混淆,因为步幅是一个设计选择,如果需要,我们可以决定使用步幅为 1。因此,填充被用来绕过这个问题。实现方法是将零填充到输入的边界,使得输出尺寸与输入尺寸相等。假设步幅为 1:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_008.png

这里:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_009.png

图 5.7 展示了填充的结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_07.png

图 5.7:滤波器大小(m=3),步幅(s=1),以及零填充的卷积操作

接下来,我们将讨论转置卷积操作。

转置卷积

尽管卷积操作在数学上看起来很复杂,但它可以简化为矩阵乘法。因此,我们可以定义卷积操作的转置,或者有时称为反卷积。然而,我们将使用转置卷积这一术语,因为它听起来更自然。此外,反卷积指的是一个不同的数学概念。转置卷积操作在卷积神经网络(CNN)中起着重要作用,用于反向传播过程中梯度的反向累积。我们将通过一个例子来解释。

对于大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_001.png 的输入和大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_002.png 的权重块或滤波器,其中 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_003.png,卷积操作将权重块滑动在输入上。我们将输入表示为 X,权重块表示为 W,输出表示为 H。输出 H 可以通过以下矩阵乘法计算:

假设 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_013.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_014.png 为了清晰起见,我们从左到右、从上到下展开输入 X,得到如下结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_015.png

让我们从 W 定义一个新矩阵 A

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_016.png

然后,如果我们执行以下矩阵乘法,我们得到 H

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_017.png

现在,通过将输出 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_018.png 重塑为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_019.png,我们得到卷积输出。现在让我们将这个结果投影回 nm

通过展开输入 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_020.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_021.png,并通过创建矩阵 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_022.pngw,如我们之前所示,我们得到 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_023.png,然后将其重塑为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_024.png

接下来,为了获得转置卷积,我们只需转置 A 并得到如下结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_025.png

这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_026.png 是转置卷积的结果输出。

我们在这里结束卷积操作的讨论。我们讨论了卷积操作、带步幅的卷积操作、带填充的卷积操作以及如何计算转置卷积。接下来,我们将更详细地讨论池化操作。

池化操作

池化操作,有时也称为子采样操作,主要是为了减少卷积神经网络(CNN)中间输出的大小,并使得 CNN 具有平移不变性。与没有填充的卷积引起的自然维度缩减相比,池化操作更为可取,因为我们可以通过池化层来决定输出的大小缩减位置,而不是每次都强制发生。没有填充的情况下强制维度减小会严格限制我们在 CNN 模型中能使用的层数。

我们将在接下来的章节中数学定义池化操作。更准确地说,我们将讨论两种类型的池化:最大池化和平均池化。然而,首先我们将定义符号。对于大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_001.png 的输入和大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_002.png 的卷积核(类似于卷积层的滤波器),其中 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_003.png,卷积操作将权重块滑动在输入上。我们将输入表示为 X,权重块表示为 W,输出表示为 H。然后我们使用 x [i,j]、w[i,j] 和 h[i,j] 来表示 XWH 中(ij)位置的值。接下来,我们将讨论常用的池化实现。

最大池化

最大池化操作从输入的定义卷积核内选择最大元素生成输出。最大池化操作通过窗口滑动(图 5.8 中的中间方块),每次取最大值。数学上,我们将池化公式定义如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_030.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_030.1.png

图 5.8 显示了该操作:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_08.png

图 5.8:滤波器大小为 3,步长为 1,且无填充的最大池化操作

接下来,我们将讨论如何进行带步长的最大池化。

带步长的最大池化

带步长的最大池化与带步长的卷积相似。其公式如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_031.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_031.1.png

图 5.9 显示了结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_09.png

图 5.9:对大小为 (n=4) 的输入进行最大池化操作,滤波器大小为 (m=2),步长 (s=2),且无填充

接下来我们将讨论另一种池化变体——平均池化。

平均池化

平均池化与最大池化类似,不同之处在于它不仅取最大值,而是取所有落入卷积核内输入的平均值。考虑以下方程:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_033.png

平均池化操作如 图 5.10 所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_10.png

图 5.10:对大小为 (n=4) 的输入进行平均池化操作,滤波器大小为 (m=2),步长 (s=1),且无填充

到目前为止,我们讨论了直接对二维输入(如图像)执行的操作。接下来我们将讨论它们如何与一维的全连接层连接。

全连接层

全连接层是从输入到输出的完全连接的权重集合。这些全连接的权重能够学习全局信息,因为它们从每个输入连接到每个输出。而且,拥有这样的完全连接层使我们能够将前面卷积层学到的特征全局结合起来,生成有意义的输出。

我们定义最后一个卷积或池化层的输出大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_034.png,其中 p 是输入的高度,o 是输入的宽度,d 是输入的深度。举个例子,考虑一个 RGB 图像,其高度和宽度是固定的,深度为 3(每个 RGB 组件都有一个深度通道)。

然后,对于紧接在最后一个卷积或池化层后的初始全连接层,权重矩阵将是 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_035.png,其中层输出的高度 x 宽度 x 深度 是该最后一层产生的输出单元数量,m 是全连接层中隐藏单元的数量。然后,在推理(或预测)过程中,我们将最后一个卷积/池化层的输出重新调整为大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_036.png,并执行以下矩阵乘法以获得 h

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_037.png

结果全连接层的行为就像一个全连接神经网络,其中有多个全连接层和一个输出层。输出层可以是一个用于分类问题的 softmax 分类层,或者一个用于回归问题的线性层。

将一切结合起来

现在我们将讨论卷积层、池化层和全连接层如何结合在一起形成一个完整的 CNN。

图 5.11所示,卷积层、池化层和全连接层结合在一起,形成一个端到端的学习模型,该模型接受原始数据(可以是高维的,例如 RGB 图像),并产生有意义的输出(例如物体的类别)。首先,卷积层学习图像的空间特征。

较低的卷积层学习低级特征,如图像中不同方向的边缘,而较高的层学习更高级的特征,如图像中出现的形状(例如,圆形和三角形)或物体的更大部分(例如,狗的脸、狗的尾巴和汽车的前部)。中间的池化层使这些学习到的特征稍微具有平移不变性。这意味着,在新图像中,即使该特征相对于在学习图像中出现的位置稍微偏移,CNN 仍然能够识别该特征。最后,全连接层将 CNN 学到的高级特征结合起来,生成全局表示,这些表示将由最终输出层用于确定物体属于哪个类别:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_11.png

图 5.11:结合卷积层、池化层和全连接层形成 CNN

在对 CNN 有了强烈的概念理解后,我们现在将开始我们的第一个用例:使用 CNN 模型进行图像分类。

练习 – 使用 CNN 对 Fashion-MNIST 进行图像分类

这将是我们第一次使用 CNN 进行实际机器学习任务的示例。我们将使用 CNN 对图像进行分类。不从 NLP 任务开始的原因是,应用 CNN 于 NLP 任务(例如,句子分类)并不是非常直接。使用 CNN 处理此类任务需要一些技巧。然而,CNN 最初是为应对图像数据而设计的。因此,我们从这里开始,然后逐步探索 CNN 如何应用于 NLP 任务的 使用 CNN 进行句子分类 部分。

关于数据

在这个练习中,我们将使用一个在计算机视觉社区中广为人知的数据集:Fashion-MNIST 数据集。Fashion-MNIST 受到著名的 MNIST 数据集的启发(yann.lecun.com/exdb/mnist/)。MNIST 是一个包含手写数字(0 到 9,即 10 个数字)标签图像的数据库。然而,由于 MNIST 图像分类任务的简单性,MNIST 的测试准确率几乎接近 100%。截至本文撰写时,流行的研究基准网站 paperswithcode.com 发布了 99.87% 的测试准确率(paperswithcode.com/sota/image-classification-on-mnist)。正因如此,Fashion-MNIST 应运而生。

Fashion-MNIST 包含衣物图像。我们的任务是将每件衣物分类到一个类别中(例如:连衣裙、T 恤)。该数据集包含两个数据集:训练集和测试集。我们将在训练集上进行训练,并在未见过的测试数据集上评估模型的性能。我们还将把训练集分成两个部分:训练集和验证集。我们将使用验证数据集作为模型的持续性能监测机制。我们稍后会详细讨论,但我们会看到,通过简单的训练,模型可以达到大约 88% 的测试准确率,而无需任何特殊的正则化或技巧。

下载和探索数据

第一个任务是下载并探索数据。为了下载数据,我们将直接使用 tf.keras.datasets 模块,因为它提供了多个数据集,能够通过 TensorFlow 方便地进行下载。要查看其他可用的数据集,请访问 www.tensorflow.org/api_docs/python/tf/keras/datasets。本章的完整代码位于 Ch05-Sentence-Classification 文件夹中的 ch5_image_classification_fashion_mnist.ipynb 文件里。只需调用以下函数即可下载数据:

(train_images, train_labels), (test_images, test_labels) = tf.keras.datasets.fashion_mnist.load_data() 

数据将被下载到 TensorFlow 指定的默认缓存目录(例如:~/.keras/dataset/fasion_minst)。

接着,我们将通过打印数据的形状来看数据的大小:

print("train_images is of shape: {}".format(train_images.shape))
print("train_labels is of shape: {}".format(train_labels.shape))
print("test_images is of shape: {}".format(test_images.shape))
print("test_labels is of shape: {}".format(test_labels.shape)) 

这将产生:

train_images is of shape: (60000, 28, 28)
train_labels is of shape: (60000,)
test_images is of shape: (10000, 28, 28)
test_labels is of shape: (10000,) 

我们可以看到,我们有 60,000 张训练图像,每张大小为 28x28,还有 10,000 张相同尺寸的测试图像。标签是简单的类别 ID,范围从 0 到 9。我们还将创建一个变量来包含类别 ID 到类别名称的映射,这将在探索和训练后分析中帮助我们:

# Available at: https://www.tensorflow.org/api_docs/python/tf/keras/
# datasets/fashion_mnist/load_data
label_map = {
    0: "T-shirt/top", 1: "Trouser", 2: "Pullover", 3: "Dress", 4: "Coat",
    5: "Sandal", 6: "Shirt", 7: "Sneaker",  8: "Bag", 9: "Ankle boot"
} 

我们还可以绘制图像,这将生成如下的图像图表(图 5.12):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_12.png

图 5.12:Fashion-MNIST 数据集中图像的概览

最后,我们将通过在每个张量的末尾添加一个新的维度(大小为 1)来扩展 train_imagestest_images。TensorFlow 中卷积操作的标准实现是针对四维输入设计的(即批次、高度、宽度和通道维度)。

在这里,图像中省略了通道维度,因为它们是黑白图像。因此,为了符合 TensorFlow 卷积操作的维度要求,我们需要在图像中添加这一额外的维度。这是使用 CNN 中卷积操作的必要条件。你可以按如下方式进行:

train_images = train_images[:, : , :, None]
test_images = test_images[:, : ,: , None] 

使用 NumPy 提供的索引和切片功能,你可以像上面那样简单地通过在索引时添加 None 维度来给张量添加新的维度。现在我们来检查张量的形状:

print("train_images is of shape: {}".format(train_images.shape))
print("test_images is of shape: {}".format(test_images.shape)) 

这会得到:

train_images is of shape: (60000, 28, 28, 1)
test_images is of shape: (10000, 28, 28, 1) 

让我们尝试实现一个可以从这些数据中学习的 CNN 模型。

实现 CNN

在这一小节中,我们将查看 TensorFlow 实现 CNN 时的一些重要代码片段。完整的代码可在 Ch05-Sentence-Classification 文件夹中的 ch5_image_classification_mnist.ipynb 文件中找到。首先,我们将定义一些重要的超参数。代码注释已经自解释,这些超参数的作用如下:

batch_size = 100 # This is the typical batch size we've been using
image_size = 28 # This is the width/height of a single image
# Number of color channels in an image. These are black and white images 
n_channels = 1 
# Number of different digits we have images for (i.e. classes)
n_classes = 10 

这样,我们就可以开始实现模型了。我们将从最早期的 CNN 模型之一 LeNet 获取灵感,LeNet 在 LeCun 等人的论文《基于梯度的学习应用于文档识别》中提出(yann.lecun.com/exdb/publis/pdf/lecun-01a.pdf)。这个模型是一个很好的起点,因为它虽然简单,但在数据集上能够取得相当不错的表现。我们将对原始模型做一些微小的修改,因为原始模型处理的是 32x32 尺寸的图像,而在我们的案例中,图像是 28x28 尺寸的。

让我们快速浏览一下模型的细节。它具有以下层序列:

  • 一个具有 5x5 卷积核、1x1 步幅和有效填充的卷积层

  • 一个具有 2x2 卷积核、2x2 步幅和有效池化的最大池化层

  • 一个具有 5x5 卷积核、1x1 步幅和有效池化的卷积层

  • 一个具有 2x2 卷积核、2x2 步幅和有效池化的最大池化层

  • 一个具有 4x4 卷积核、1x1 步幅和有效池化的卷积层

  • 一个将 2D 输出展平为 1D 向量的层

  • 一个具有 84 个节点的 Dense 层

  • 一个最终的 softmax 预测层,包含 10 个节点

在这里,除了最后一层外,所有层都使用 ReLU(修正线性单元)激活函数。CNN 模型中的卷积层将我们之前讨论的卷积操作推广到多通道输入,并产生多通道输出。让我们来理解一下这是什么意思。我们看到的原始卷积操作作用于一个简单的二维平面,具有高度 h 和宽度 w。接下来,卷积核在平面上移动,每个位置生成一个单一值。这个过程会生成另一个二维平面。但是在实际应用中,CNN 模型处理的是四维输入,即形状为 [batch size, height, width, in channels] 的输入,并生成一个四维输出,即形状为 [batch size, height, width, out channels] 的输出。为了生成这个输出,卷积核需要是一个四维张量,具有 [kernel height, kernel width, in channels, out channels] 的维度。

可能一开始不太清楚为什么输入、输出和卷积核需要采用这种格式。图 5.13 解释了这一点。

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_13.png

图 5.13:二维卷积层的输入和输出形状

接下来,我们将概述整个模型。如果你一开始没有理解,不用担心。我们会逐行讲解,帮助你理解模型的构建过程:

from tensorflow.keras.layers import Conv2D, MaxPool2D, Flatten, Dense
from tensorflow.keras.models import Sequential
import tensorflow.keras.backend as K
K.clear_session()
lenet_like_model = Sequential([
    # 1st convolutional layer
    Conv2D(
        filters=16, kernel_size=(5,5), strides=(1,1), padding='valid', 
        activation='relu', 
        input_shape=(image_size,image_size,n_channels)
    ), # in 28x28 / out 24x24
    # 1st max pooling layer
    MaxPool2D(pool_size=(2,2), strides=(2,2), padding='valid'), 
    # in 24x24 / out 12x12
    # 2nd convolutional layer
    Conv2D(filters=16, kernel_size=(5,5), strides=(1,1), 
    padding='valid', activation='relu'), # in 12x12 / out 8x8
    # 2nd max pooling layer
    MaxPool2D(pool_size=(2,2), strides=(2,2), padding='valid'), 
    # in 8x8 / out 4x4
    # 3rd convolutional layer
    Conv2D(filters=120, kernel_size=(4,4), strides=(1,1), 
    padding='valid', activation='relu'), # in 4x4 / out 1x1
    # flatten the output of the last layer to suit a fully connected layer
    Flatten(),
    # First dense (fully-connected) layer
    Dense(84, activation='relu'),
    # Final prediction layer
    Dense(n_classes, activation='softmax')
]) 

首先需要注意的是,我们使用的是 Keras 的 Sequential API。我们在这里实现的 CNN 模型由一系列层按顺序连接。因此,我们将使用最简单的 API。接下来是我们第一个卷积层。我们已经讨论过卷积操作。让我们来看第一行:

Conv2D(
        filters=16, kernel_size=(5,5), strides=(1,1), padding='valid', 
        activation='relu', 
        input_shape=(image_size,image_size,n_channels)
    ) 

tensorflow.keras.layers.Conv2D 层接受如下参数值,顺序如下:

  • filters (int): 这是输出滤波器的数量(即输出通道的数量)。

  • kernel_size (Tuple[int]): 这是卷积核的(高度,宽度)。

  • strides (Tuple[int]): 这个参数表示输入的高度和宽度维度上的步幅。

  • padding (str): 这个参数表示填充类型(可以是 'SAME''VALID')。

  • activation (str): 使用的非线性激活函数。

  • input_shape (Tuple[int]): 输入的形状。在定义 input_shape 时,我们不需要指定批次维度,因为它会自动添加。

接下来,我们有第一个最大池化层,其形式如下:

MaxPool2D(pool_size=(2,2), strides=(2,2), padding='valid') 

这些参数与tf.keras.layers.Conv2D中的参数非常相似。pool_size参数对应于kernel_size参数,用于指定池窗口的(高度,宽度)。按照类似的模式,以下卷积和池化层被定义。最终的卷积层输出大小为[batch size, 1, 1, 120]。高度和宽度维度为 1,因为 LeNet 的设计使得最后一个卷积核的高度和宽度与输出相同。在将这个输入送入全连接层之前,我们需要将其展平,使其形状为[batch size, 120]。这是因为标准的 Dense 层接受的是二维输入。为此,我们使用tf.keras.layers.Flatten()层:

Flatten(), 

最后,我们定义两个 Dense 层如下。

Dense(84, activation='relu'),
Dense(n_classes, activation='softmax') 

最后一步,我们将使用稀疏类别交叉熵损失函数和 Adam 优化器来编译模型。我们还将跟踪数据上的准确率:

lenet_like_model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics=['accuracy']) 

数据准备好且模型完全定义后,我们可以开始训练模型。模型训练非常简单,只需调用一个函数:

lenet_like_model.fit(train_images, train_labels, validation_split=0.2, batch_size=batch_size, epochs=5) 

tf.keras.layers.Model.fit()接受许多参数。但我们这里只讨论我们在这里使用的那些:

  • xnp.ndarray / tf.Tensor / 其他):接受一个张量,作为模型的输入(实现为 NumPy 数组或 TensorFlow 张量)。但是,接受的值不仅限于张量。要查看完整的列表,请参阅www.tensorflow.org/api_docs/python/tf/keras/Model#fit

  • ynp.ndarray / tf.Tensor):接受一个张量,该张量将作为模型的标签(目标)。

  • validation_splitfloat):设置此参数意味着训练数据的一部分(例如,0.2 表示 20%)将作为验证数据。

  • epochsint):训练模型的轮数。

你可以通过调用以下命令在测试数据上评估训练好的模型:

lenet_like_model.evaluate(test_images, test_labels) 

运行后,你将看到如下输出:

313/313 [==============================] - 1s 2ms/step - loss: 0.3368 - accuracy: 0.8806 

训练后的模型应达到约 88%的准确率。

你刚刚学会了我们用来创建第一个 CNN 的函数。你学会了如何使用这些函数来实现 CNN 结构、定义损失、最小化损失并获得未见数据的预测。我们使用了一个简单的 CNN 来看看它是否能够学习分类服装物品。此外,我们成功地用一个相对简单的 CNN 达到了超过 88%的准确率。接下来,我们将分析 CNN 生成的一些结果。我们将了解为什么 CNN 没有正确识别一些图像。

分析 CNN 生成的预测结果

在这里,我们可以从测试集中随机挑选一些正确和错误分类的样本,以评估 CNN 的学习能力(见图 5.14)。

我们可以看到,对于正确分类的实例,卷积神经网络(CNN)对输出的信心通常非常高。这是一个好兆头,表明模型正在做出非常自信且准确的决策。然而,当我们评估错误分类的实例时,我们可以发现其中一些实例确实很难,甚至人类也可能会犯错。例如,对于一个被分类为凉鞋的 ankle boot,其上有一个大的黑色补丁,这可能表明有带子,导致它更可能被认为是凉鞋(第三行从右数第三张图)。此外,在第三行从右数第五张图中,很难判断它是衬衫还是有领 T 恤:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_14.png

图 5.14:Fashion-MNIST 正确分类和错误分类的实例

使用 CNN 进行句子分类

尽管 CNN 主要用于计算机视觉任务,但没有什么能阻止它们用于 NLP 应用。如前所述,CNN 最初是为视觉内容设计的。因此,使用 CNN 进行 NLP 任务需要更多的努力。这也是我们从简单的计算机视觉问题开始学习 CNN 的原因。CNN 是机器学习问题的一个有吸引力的选择,因为卷积层的参数数量较少。CNN 在 NLP 中的一个有效应用是句子分类。

在句子分类中,给定的句子应该被分类到一个类别中。我们将使用一个问题数据库,其中每个问题都按其主题进行标记。例如,问题 “Who was Abraham Lincoln?” 将被标记为问题,其标签为 Person。为此,我们将使用一个句子分类数据集,数据集可通过 cogcomp.org/Data/QA/QC/ 获取;你将在这里找到多个数据集。我们使用的是包含约 5,500 个训练问题及其相应标签和 500 个测试句子的集合。

我们将使用 Yoon Kim 在论文《卷积神经网络用于句子分类》中介绍的 CNN 网络,来理解 CNN 在自然语言处理(NLP)任务中的价值。然而,使用 CNN 进行句子分类与我们之前讨论的 Fashion-MNIST 示例有所不同,因为现在的操作(例如卷积和池化)发生在一个维度(长度)中,而不是两个维度(高度和宽度)。此外,池化操作也会与正常的池化操作有所不同,正如我们很快会看到的那样。你可以在 Ch5-Sentence-Classification 文件夹中的 ch5_cnn_sentence_classification.ipynb 文件找到这个练习的代码。作为第一步,我们将理解数据。

句子分类的数据转换方式

假设一句话有 p 个单词。首先,如果句子的长度小于 n,我们将为句子填充一些特殊的单词(将句子长度设置为 n 个单词),如 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_038.png 所示。接下来,我们将句子中的每个单词表示为一个大小为 k 的向量,该向量可以是一个独热编码表示,或者是使用 skip-gram、CBOW 或 GloVe 学习的 Word2vec 词向量。然后,一批大小为 b 的句子可以表示为一个 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_039.png 矩阵。

让我们通过一个例子来演示。让我们考虑以下三句话:

  • 鲍勃和玛丽是朋友。

  • 鲍勃踢足球。

  • 玛丽喜欢在合唱团里唱歌。

在这个例子中,第三个句子有最多的单词,因此我们设置 n = 7,即第三个句子中的单词数。接下来,让我们来看一下每个单词的独热编码表示。在这种情况下,有 13 个不同的单词。因此,我们得到如下表示:

鲍勃: 1,0,0,0,0,0,0,0,0,0,0,0,0

: 0,1,0,0,0,0,0,0,0,0,0,0,0

玛丽: 0,0,1,0,0,0,0,0,0,0,0,0,0

同样,k = 13,原因相同。使用这种表示,我们可以将三句话表示为一个大小为 3 x 7 x 13 的三维矩阵,如 图 5.15 所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_15.png

图 5.15:一批句子表示为句子矩阵

你也可以在这里使用词嵌入代替独热编码。将每个单词表示为独热编码特征会引入稀疏性并浪费计算内存。通过使用词嵌入,我们使得模型能够学习到比独热编码更紧凑、更强大的单词表示。这也意味着 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_04_020.png 成为一个超参数(即嵌入大小),而不是由词汇表的大小驱动。这意味着,在图 5.15中,每一列将是一个分布式的连续向量,而不是由 0 和 1 组成的组合。

我们知道,独热向量会导致高维和高度稀疏的表示,且不理想。另一方面,词向量提供了更丰富的单词表示。然而,学习词向量的计算成本较高。还有一种替代方法叫做哈希技巧。哈希技巧的优点在于它非常简单,但提供了一个强大且经济的替代方案,介于独热向量和词向量之间。哈希技巧背后的想法是使用哈希函数将给定的标记转换为整数。

f()–>哈希值

这里的f是一个选定的哈希函数。一些常见的哈希函数包括 SHA(brilliant.org/wiki/secure-hashing-algorithms/)和 MD5(searchsecurity.techtarget.com/definition/MD5)。还有更高级的哈希方法,比如局部敏感哈希(www.pinecone.io/learn/locality-sensitive-hashing/),可以为形态上相似的词语生成相似的 ID。你可以通过 TensorFlow(www.tensorflow.org/api_docs/python/tf/keras/preprocessing/text/hashing_trick)轻松使用哈希技巧。

实现 – 下载并准备数据

首先,我们将从网上下载数据。数据下载功能在笔记本中提供,简单地下载了两个文件:训练数据和测试数据(文件路径保存在train_filenametest_filename中)。

如果你打开这些文件,你会看到它们包含一系列文本行。每一行的格式是:

<Category>: <sub-category> <question>

每个问题都有两个元数据:一个类别和一个子类别。类别是宏观分类,子类别则是对问题类型的更细致的划分。共有六个类别:DESC(描述相关)、ENTY(实体相关)、HUM(人类相关)、ABBR(缩写相关)、NUM(数字相关)和LOC(地点相关)。每个类别下有若干子类别。例如,ENTY类别进一步细分为动物、货币、事件、食物等。在我们的任务中,我们将专注于高级分类(即六个类别),但你也可以通过最小的修改,利用相同的模型进行子类别层次的分类。

一旦文件下载完成,我们将把数据读入内存。为此,我们将实现read_data()函数:

def read_data(filename):
    '''
    Read data from a file with given filename
    Returns a list of strings where each string is a lower case word
    '''
    # Holds question strings, categories and sub categories
    # category/sub_cateory definitions: https://cogcomp.seas.upenn.edu/
    # Data/QA/QC/definition.html
    questions, categories, sub_categories = [], [], []     

    with open(filename,'r',encoding='latin-1') as f:        
        # Read each line
        for row in f:   
            # Each string has format <cat>:<sub cat> <question>
            # Split by : to separate cat and (sub_cat + question)
            row_str = row.split(":")        
            cat, sub_cat_and_question = row_str[0], row_str[1]
            tokens = sub_cat_and_question.split(' ')
            # The first word in sub_cat_and_question is the sub 
            # category rest is the question
            sub_cat, question = tokens[0], ' '.join(tokens[1:])        

            questions.append(question.lower().strip())
            categories.append(cat)
            sub_categories.append(sub_cat)

    return questions, categories, sub_categories
train_questions, train_categories, train_sub_categories = read_data(train_filename)
test_questions, test_categories, test_sub_categories = read_data(test_filename) 

这个函数简单地遍历文件中的每一行,并按照上述格式分离问题、类别和子类别。然后,将每个问题、类别和子类别分别写入questionscategoriessub_categories列表。最后,函数返回这些列表。通过为训练和测试数据提供questionscategoriessub_categories,我们将为训练和测试数据创建pandas数据框。

pandas数据框是一种用于存储多维数据的表达型数据结构。一个数据框可以有索引、列和数值。每个值都有特定的索引和列。创建一个数据框是相当简单的:

# Define training and testing
train_df = pd.DataFrame(
    {'question': train_questions, 'category': train_categories, 
    'sub_category': train_sub_categories}
)
test_df = pd.DataFrame(
    {'question': test_questions, 'category': test_categories,
    'sub_category': test_sub_categories}
) 

我们使用字典调用 pd.DataFrame 构造函数。字典的键表示 DataFrame 的列,值表示每列中的元素。这里我们创建了三个列:questioncategorysub_category

图 5.16 展示了 train_df 的样子。

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_16.png

图 5.16:在 pandas DataFrame 中捕获的数据示例

我们将对训练集中的行进行简单的洗牌,以确保不会在数据中引入任何无意的顺序:

# Shuffle the data for better randomization
train_df = train_df.sample(frac=1.0, random_state=seed) 

该过程将从 DataFrame 中随机抽样 100% 的数据。换句话说,它将打乱行的顺序。从此时起,我们将不再考虑 sub_category 列。我们将首先将每个类别标签映射到一个类别 ID:

# Generate the label to ID mapping
unique_cats = train_df["category"].unique()
labels_map = dict(zip(unique_cats, np.arange(unique_cats.shape[0])))
print("Label->ID mapping: {}".format(labels_map))
n_classes = len(labels_map)
# Convert all string labels to IDs
train_df["category"] = train_df["category"].map(labels_map)
test_df["category"] = test_df["category"].map(labels_map) 

我们首先识别 train_df["category"] 中存在的唯一值。然后,我们将通过将唯一值映射到数字 ID(0 到 5)的列表来创建一个字典。np.arange() 函数可以用来生成一个指定范围内的整数序列(这里,范围是从 0 到 unique_cats 的长度)。这个过程将生成以下 labels_map

标签->ID 映射:{0: 0, 1: 1, 2: 2, 4: 3, 3: 4, 5: 5}

然后,我们简单地将这个映射应用于训练和测试 DataFrame 的类别列,将字符串标签转换为数字标签。转换后的数据如下所示(图 5.17)。

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_17.png

图 5.17:在将类别映射为整数后,DataFrame 中的数据示例

我们创建一个验证集,源自原始训练集,用于在训练过程中监控模型表现。我们将使用 scikit-learn 库中的train_test_split()函数。10%的数据将作为验证数据,其余 90% 保留作为训练数据。

from sklearn.model_selection import train_test_split
train_df, valid_df = train_test_split(train_df, test_size=0.1)
print("Train size: {}".format(train_df.shape))
print("Valid size: {}".format(valid_df.shape)) 

输出如下:

Train size: (4906, 3)
Valid size: (546, 3) 

我们可以看到,大约 4,900 个示例用于训练,剩余的作为验证。在接下来的部分,我们将构建一个分词器来对问题进行分词,并为每个词汇分配数字 ID。

实现 – 构建分词器

接下来,到了构建分词器的时刻,它可以将单词映射为数字 ID:

from tensorflow.keras.preprocessing.text import Tokenizer
# Define a tokenizer and fit on train data
tokenizer = Tokenizer()
tokenizer.fit_on_texts(train_df["question"].tolist()) 

这里,我们简单地创建一个 Tokenizer 对象,并使用 fit_on_texts() 函数在训练语料库上训练它。在这个过程中,分词器会将词汇表中的单词映射为 ID。我们将把训练集、验证集和测试集中的所有输入转换为单词 ID 的序列。只需调用 tokenizer.texts_to_sequences() 函数,并传入一个字符串列表,每个字符串代表一个问题:

# Convert each list of tokens to a list of IDs, using tokenizer's mapping
train_sequences = tokenizer.texts_to_sequences(train_df["question"].tolist())
valid_sequences = tokenizer.texts_to_sequences(valid_df["question"].tolist())
test_sequences = tokenizer.texts_to_sequences(test_df["question"].tolist()) 

重要的是要理解,我们每次给模型输入一批问题。所有问题的词数不太可能相同。如果所有问题的词数不相同,我们无法形成一个张量,因为问题的长度不一致。为了解决这个问题,我们必须通过特殊符号填充较短的序列,并截断超过指定长度的序列。为了实现这一点,我们可以轻松使用tf.keras.preprocessing.sequence.pad_sequences()函数。值得一提的是,我们可以仔细查看该函数所接受的参数:

  • sequences (List[List[int]]) – 整数列表的列表;每个整数列表是一个序列

  • maxlen (int) – 最大填充长度

  • padding (string) – 是否在开头 (pre) 或结尾 (post) 进行填充

  • truncating (string) – 是否在开头 (pre) 或结尾 (post) 进行截断

  • value (int) – 用于填充的值(默认为 0)

在下面的代码中,我们使用这个函数为训练、验证和测试数据创建序列矩阵:

max_seq_length = 22
# Pad shorter sentences and truncate longer ones (maximum length: max_seq_
# length)
preprocessed_train_sequences = tf.keras.preprocessing.sequence.pad_sequences(
    train_sequences, maxlen=max_seq_length, padding='post',
    truncating='post'
)
preprocessed_valid_sequences = tf.keras.preprocessing.sequence.pad_sequences(
    valid_sequences, maxlen=max_seq_length, padding='post', 
    truncating='post'
)
preprocessed_test_sequences = tf.keras.preprocessing.sequence.pad_sequences(
    test_sequences, maxlen=max_seq_length, padding='post', 
    truncating='post'
) 

我们选择 22 作为序列长度的原因是通过简单的分析得出的。训练语料库中序列长度的 99% 百分位数为 22。因此,我们选择了这个值。另一个重要统计信息是词汇表大小大约为 7,880 个词。接下来我们将讨论模型。

句子分类 CNN 模型

现在我们将讨论用于句子分类的 CNN 的技术细节。首先,我们将讨论如何将数据或句子转换为可以方便地由 CNN 处理的首选格式。接下来,我们将讨论如何将卷积和池化操作适应于句子分类,最后,我们将讨论如何将所有这些组件连接起来。

卷积操作

如果忽略批量大小,即假设我们每次只处理一个句子,我们的数据是一个 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_042.png 矩阵,其中 n 是填充后每个句子的单词数,k 是单个词向量的维度。在我们的例子中,这将是 7 x 13

现在我们将定义卷积权重矩阵,其大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_043.png,其中 m 是一维卷积操作的过滤器大小。通过将输入 x 的大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_042.png 与大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_043.png 的权重矩阵 W 卷积,我们将得到大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_046.png 的输出 h,其计算过程如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_047.png

在这里,w[i,j] 是 W(i,j)^(th) 元素,我们将使用零填充 x,使得 h 的大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_046.png。此外,我们将更简洁地定义这个操作,如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_049.png

在这里,*定义了卷积操作(带填充),并且我们将添加一个额外的标量偏置b图 5.18展示了这一操作:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_18.png

图 5.18:句子分类的卷积操作。使用不同的卷积核宽度的卷积层对句子(即标记序列)进行卷积

然后,为了学习丰富的特征,我们有并行层,使用不同的卷积过滤器大小。每个卷积层输出大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_046.png 的隐藏向量,我们将这些输出连接起来,作为下一层的输入,大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_051.png,其中q是我们将使用的并行层的数量。q越大,模型的性能越好。

卷积的值可以通过以下方式理解。想象一下电影评分学习问题(有两个类别,正面或负面),我们有以下句子:

  • 我喜欢这部电影,还不错

  • 我不喜欢这部电影,差劲

现在想象一个大小为 5 的卷积窗口。我们将根据卷积窗口的移动来对单词进行分箱。

句子I like the movie, not too bad给出了:

[I, like, the, movie, ‘,’]

[like, the, movie, ‘,’, not]

[the, movie, ‘,’, not, too]

[movie, ‘,’, not, too, bad]

句子I did not like the movie, bad给出了以下结果:

[I, did, not, like, the]

[did, not ,like, the, movie]

[not, like, the, movie, ‘,’]

[like, the, movie, ‘,’, bad]

对于第一个句子,像以下的窗口会传达评分为正面:

[I, like, the, movie, ‘,’]

[movie, ‘,’, not, too, bad]

然而,对于第二个句子,像以下的窗口会传达出负面的评分信息:

[did, not, like, the, movie]

我们能够看到这样的模式,它们帮助分类评分,这得益于保留的空间性。例如,如果你使用像词袋模型这样的技术来计算句子的表示,这会丢失空间信息,那么上述两个句子的表示将会非常相似。卷积操作在保留句子空间信息方面起着重要作用。

通过具有不同过滤器大小的q个层,网络学习如何提取不同大小短语的评分,从而提高性能。

时间池化

池化操作旨在对之前讨论的并行卷积层产生的输出进行下采样。具体实现如下:

假设最后一层的输出h的大小是 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_051.png。时间池化层将生成一个输出h’,大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_053.png。精确的计算如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_054.png

这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_055.pngh^((i))是由第i层卷积产生的输出,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_056.png是属于该层的权重集。简单来说,时间池化操作通过连接每个卷积层的最大元素来创建一个向量。

我们将在图 5.19中说明这个操作:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_19.png

图 5.19:用于句子分类的时间池化操作

通过结合这些操作,我们最终得到了如图 5.20所示的架构:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_05_20.png

图 5.20:句子分类 CNN 架构。具有不同核宽度的卷积层池生成一组输出序列。这些序列被送入“时间池化”层,生成该输入的紧凑表示。最后,这些被连接到具有 softmax 激活的分类层:

实现 – 使用 CNN 进行句子分类

我们开始在 TensorFlow 2 中实现模型。在此之前,让我们从 TensorFlow 中导入几个必要的模块:

import tensorflow.keras.backend as K
import tensorflow.keras.layers as layers
import tensorflow.keras.regularizers as regularizers
from tensorflow.keras.models import Model 

清除当前运行的会话,以确保之前的运行不会干扰当前的运行:

K.clear_session() 

在我们开始之前,我们将使用 Keras 的功能性 API。这样做的原因是我们将在这里构建的模型不能使用顺序 API 构建,因为该模型中有复杂的路径。我们先从创建一个输入层开始:

Input layer takes word IDs as inputs
word_id_inputs = layers.Input(shape=(max_seq_length,), dtype='int32') 

输入层简单地接收一个max_seq_length的单词 ID 批次。也就是说,接收一批序列,其中每个序列都填充或截断到最大长度。我们将dtype指定为int32,因为它们是单词 ID。接下来,我们定义一个嵌入层,在该层中我们将查找与通过word_id_inputs层传入的单词 ID 对应的嵌入:

# Get the embeddings of the inputs / out [batch_size, sent_length, 
# output_dim]
embedding_out = layers.Embedding(input_dim=n_vocab, output_dim=64)(word_id_inputs) 

这是一个随机初始化的嵌入层。它包含一个大小为[n_vocab, 64]的大矩阵,其中每一行表示由该行编号索引的单词的词向量。嵌入将与模型共同学习,同时在监督任务上训练模型。在下一部分中,我们将定义三个不同的一维卷积层,分别使用三个不同的核(过滤器)大小:345,每个卷积层有 100 个特征图:

# For all layers: in [batch_size, sent_length, emb_size] / out [batch_
# size, sent_length, 100]
conv1_1 = layers.Conv1D(
    100, kernel_size=3, strides=1, padding='same', 
    activation='relu'
)(embedding_out)
conv1_2 = layers.Conv1D(
    100, kernel_size=4, strides=1, padding='same', 
    activation='relu'
)(embedding_out)
conv1_3 = layers.Conv1D(
    100, kernel_size=5, strides=1, padding='same', 
    activation='relu'
)(embedding_out) 

这里需要做出一个重要区分,我们使用的是一维卷积,而不是之前练习中使用的二维卷积。然而,大多数概念仍然相同。主要的区别在于,tf.keras.layers.Conv2D作用于四维输入,而tf.keras.layers.Conv1D作用于三维输入(即形状为[batch size, width, in channels]的输入)。换句话说,卷积核仅沿一个方向在输入上滑动。这些层的每个输出都会产生一个形状为[batch size, sentence length, 100]的张量。然后,这些输出会在最后一个轴上连接,形成一个单一的张量:

# in previous conv outputs / out [batch_size, sent_length, 300]
conv_out = layers.Concatenate(axis=-1)([conv1_1, conv1_2, conv1_3]) 

随后,新的张量大小为[batch size, sentence length, 300],将用于执行时间池化操作。我们可以通过定义一个一维最大池化层(即tf.keras.layers.MaxPool1D)来实现时间池化操作,其窗口宽度与序列长度相同。这样会为conv_out中的每个特征图生成一个单一值作为输出:

# Pooling over time operation. 
# This is doing the max pooling over sequence length
# in other words, each feature map results in a single output
# in [batch_size, sent_length, 300] / out [batch_size, 1, 300]
pool_over_time_out = layers.MaxPool1D(pool_size=max_seq_length, padding='valid')(conv_out) 

这里我们在执行操作后获得了一个[batch_size, 1, 300]大小的输出。接下来,我们将使用tf.keras.layers.Flatten层将此输出转换为[batch_size, 300]大小的输出。Flatten 层将所有维度(除了批次维度)压缩为一个维度:

# Flatten the unit length dimension
flatten_out = layers.Flatten()(pool_over_time_out) 

最后,flatten_out将传递到一个全连接层,该层具有n_classes(即六个)节点作为输出,并且使用 softmax 激活函数:

# Compute the final output
out = layers.Dense(
    n_classes, activation='softmax',
    kernel_regularizer=regularizers.l2(0.001)
)(flatten_out) 

注意使用了kernel_regularizer参数。我们可以使用该参数为给定层添加任何特殊的正则化(例如 L1 或 L2 正则化)。最后,我们定义一个模型如下,

# Define the model
cnn_model = Model(inputs=word_id_inputs, outputs=out) 

使用所需的损失函数、优化器和评估指标来编译模型:

# Compile the model with loss/optimzier/metrics
cnn_model.compile(
    loss='sparse_categorical_crossentropy', 
    optimizer='adam', 
    metrics=['accuracy']
) 

你可以通过运行以下代码查看模型:

cnn_model.summary() 

结果为,

Model: "model"
______________________________________________________________________
Layer (type)            Output Shape         Param #     Connected to 
======================================================================
input_1 (InputLayer)    [(None, 22)]         0                        
______________________________________________________________________
embedding (Embedding)   (None, 22, 64)       504320      input_1[0][0]
______________________________________________________________________
conv1d (Conv1D)         (None, 22, 100)      19300     embedding[0][0]
______________________________________________________________________
conv1d_1 (Conv1D)       (None, 22, 100)      25700     embedding[0][0]
______________________________________________________________________
conv1d_2 (Conv1D)       (None, 22, 100)      32100     embedding[0][0]
______________________________________________________________________
concatenate (Concatenate) (None, 22, 300)    0            conv1d[0][0]
                                                        conv1d_1[0][0]
                                                        conv1d_2[0][0]
______________________________________________________________________
max_pooling1d (MaxPooling1D) (None, 1, 300)    0     concatenate[0][0]
______________________________________________________________________
flatten (Flatten)          (None, 300)         0   max_pooling1d[0][0]
______________________________________________________________________
dense (Dense)              (None, 6)           1806    flatten[0][0] 
======================================================================
Total params: 583,226
Trainable params: 583,226
Non-trainable params: 0
______________________________________________________________________ 

接下来,我们将在已经准备好的数据上训练模型。

训练模型

由于我们在开始时已经做好了基础工作,确保数据已经转换,因此训练模型非常简单。我们需要做的就是调用tf.keras.layers.Model.fit()函数。不过,我们可以通过利用一些技术来提升模型性能。我们将使用 TensorFlow 内置的回调函数来实现这一点。我们要使用的技术叫做“学习率衰减”。其思想是,当模型停止提高性能时,按某个比例减少学习率。以下回调函数可以帮助我们实现这一点:

# Call backs
lr_reduce_callback = tf.keras.callbacks.ReduceLROnPlateau(
    monitor='val_loss', factor=0.1, patience=3, verbose=1,
    mode='auto', min_delta=0.0001, min_lr=0.000001
) 

可以根据需要设置这些参数来控制学习率的减少。让我们理解上面提到的参数:

  • monitor (str) – 用于监控的指标,以便衰减学习率。我们将监控验证损失

  • factor (float) – 降低学习率的倍数。例如,0.1 的因子意味着学习率将减少 10 倍(例如,0.01 将降到 0.001)

  • patience (int) – 在没有改进的情况下,等待多少个 epoch 后才会降低学习率

  • mode (string) – 指定是否寻找指标的增加或减少;auto表示方向将根据指标名称确定

  • min_delta (float) – 视为改进的最小增减量

  • min_lr (float) – 最小学习率(下限)

让我们训练模型:

# Train the model
cnn_model.fit(
    preprocessed_train_sequences, train_labels, 
    validation_data=(preprocessed_valid_sequences, valid_labels),
    batch_size=128, 
    epochs=25,
    callbacks=[lr_reduce_callback]
) 

我们将看到准确率迅速上升,而验证准确率在 88%左右停滞。以下是生成的输出片段:

Epoch 1/50
39/39 [==============================] - 1s 9ms/step - loss: 1.7147 - accuracy: 0.3063 - val_loss: 1.3912 - val_accuracy: 0.5696
Epoch 2/50
39/39 [==============================] - 0s 6ms/step - loss: 1.2268 - accuracy: 0.6052 - val_loss: 0.7832 - val_accuracy: 0.7509
...
Epoch 00015: ReduceLROnPlateau reducing learning rate to 1.0000000656873453e-06.
Epoch 16/50
39/39 [==============================] - 0s 6ms/step - loss: 0.0487 - accuracy: 0.9999 - val_loss: 0.3639 - val_accuracy: 0.8846
Restoring model weights from the end of the best epoch.
Epoch 00016: early stopping 

接下来,让我们在测试数据集上测试模型:

cnn_model.evaluate(preprocessed_test_sequences, test_labels, return_dict=True) 

按照练习中给出的测试数据进行评估,我们在这个句子分类任务中获得了接近 88%的测试准确率(对于 500 个测试句子)。

在这里,我们结束了关于使用 CNN 进行句子分类的讨论。我们首先讨论了如何将一维卷积操作与一种称为时间池化的特殊池化操作结合,来实现基于 CNN 架构的句子分类器。最后,我们讨论了如何使用 TensorFlow 来实现这样的 CNN,并且看到它在句子分类中的确表现良好。

了解我们刚刚解决的问题如何在实际中应用是很有用的。假设你手上有一本关于罗马历史的厚重文档,而你只想了解关于尤利乌斯·凯撒的内容,而不想读完整本书。在这种情况下,我们刚刚实现的句子分类器可以作为一个有用的工具,帮助你总结出与某个人相关的句子,这样你就不必阅读整篇文档了。

句子分类还可以应用于许多其他任务;其中一个常见的应用是对电影评论进行正负面分类,这对于自动化计算电影评分非常有用。句子分类在医学领域也有重要应用,它可以用来从包含大量文本的大型文档中提取临床有用的句子。

总结

在本章中,我们讨论了卷积神经网络(CNN)及其各种应用。首先,我们详细解释了 CNN 是什么,以及它在机器学习任务中表现优异的能力。接下来,我们将 CNN 分解成几个组件,如卷积层和池化层,并详细讨论了这些操作符的工作原理。此外,我们还讨论了与这些操作符相关的几个超参数,如滤波器大小、步幅和填充。

接着,为了说明 CNN 的功能,我们通过一个简单的例子展示了如何对衣物图像进行分类。我们还进行了一些分析,看看为什么 CNN 在某些图像识别上出现错误。

最后,我们开始讨论了卷积神经网络(CNN)如何应用于自然语言处理(NLP)任务。具体来说,我们讨论了一种修改过的 CNN 架构,可以用于对句子进行分类。然后,我们实现了这一特定的 CNN 架构,并在实际的句子分类任务中进行了测试。

在下一章,我们将进入一种在许多 NLP 任务中广泛应用的神经网络类型——递归神经网络RNNs)。

要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub

加入我们的 Discord 社区,与志同道合的人一起学习,和超过 1000 名成员共同进步,网址:packt.link/nlp

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/QR_Code5143653472357468031.png

第六章:循环神经网络

循环神经网络 (RNNs) 是一类特殊的神经网络,旨在处理序列数据(即时间序列数据),如股票市场价格或文本序列(例如,可变长度的句子)。RNN 维持一个状态变量,用于捕捉序列数据中存在的各种模式;因此,它们能够建模序列数据。相比之下,传统的前馈神经网络没有这种能力,除非数据被表示为捕捉序列中重要模式的特征表示。然而,提出这样的特征表示是非常困难的。前馈模型用于建模序列数据的另一个替代方案是为每个时间/序列位置设置一组独立的参数,以便为特定位置分配的参数可以学习该位置发生的模式。这会大大增加模型的内存需求。

然而,与前馈网络为每个位置拥有一组独立参数不同,RNN 在时间上共享相同的参数。时间上的参数共享是 RNN 的一个重要部分,实际上是学习时间序列模式的主要推动力之一。然后,状态变量会随着我们在序列中观察到的每个输入而随时间更新。随着时间共享的这些参数,结合状态向量,能够根据序列中先前观察到的值预测序列的下一个值。此外,由于我们每次只处理序列中的一个元素(例如,每次处理文档中的一个单词),RNN 可以处理任意长度的数据,而无需使用特殊标记对数据进行填充。

本章将深入探讨 RNN 的细节。首先,我们将讨论如何通过从一个简单的前馈模型开始来形成一个 RNN。

在此之后,我们将讨论 RNN 的基本功能。我们还将深入探讨 RNN 的基础方程式,例如输出计算和参数更新规则,并讨论几种 RNN 的应用变体:一对一、一对多和多对多的 RNN。我们将通过一个例子,展示如何使用 RNN 来识别命名实体(例如人名、组织名等),这对于构建知识库等下游应用具有重要价值。我们还将讨论一个更复杂的 RNN 模型,该模型能够同时正向和反向读取文本,并使用卷积层提高模型的准确性。本章将通过以下几个主要主题进行讲解:

  • 理解 RNN

  • 通过时间的反向传播

  • RNN 的应用

  • 使用 RNN 进行命名实体识别(NER)

  • 使用字符和标记嵌入进行命名实体识别(NER)

理解 RNN

在本节中,我们将通过温和的介绍来讨论 RNN 的定义,然后深入探讨更具体的技术细节。我们之前提到过,RNN 通过维护一个随着时间推移而变化的状态变量来处理更多的数据,从而使其具备建模顺序数据的能力。特别是,这个状态变量通过一组循环连接在时间上不断更新。循环连接的存在是 RNN 和前馈网络之间的主要结构性差异。循环连接可以理解为 RNN 在过去学到的一系列记忆之间的联系,这些记忆与 RNN 当前的状态变量相连接。换句话说,循环连接根据 RNN 所拥有的过去记忆来更新当前的状态变量,使得 RNN 能够基于当前输入以及之前的输入进行预测。

术语 RNN 有时用来指代循环模型家族,它包含许多不同的模型。换句话说,有时它被用作某个特定 RNN 变体的泛化。在这里,我们使用 RNN 这个术语来指代一种最早实现的 RNN 模型,称为 Elman 网络。

在接下来的部分,我们将讨论以下主题。首先,我们将讨论如何通过将前馈网络表示为计算图来开始。

然后我们将通过一个例子来说明前馈网络为什么可能在顺序任务中失败。接着,我们将调整该前馈图来建模顺序数据,这将给我们一个 RNN 的基本计算图。我们还将讨论 RNN 的技术细节(例如,更新规则)。最后,我们将讨论如何训练 RNN 模型的具体细节。

前馈神经网络的问题

为了理解前馈神经网络的局限性以及 RNN 如何解决这些问题,让我们想象一个数据序列:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_001.png

接下来,假设在现实世界中,xy 之间存在以下关系:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_002.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_003.png

在这里,g[1] 和 g[2] 是转换(例如,乘以权重矩阵后进行非线性转换)。这意味着当前输出 y[t] 依赖于当前状态 h[t],其中 h[t] 是通过当前输入 x[t] 和前一个状态 h[t-1] 计算得出的。这个状态编码了模型历史上观察到的关于先前输入的信息。

现在,让我们想象一个简单的前馈神经网络,我们将通过以下方式表示它:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_004.png

在这里,y[t] 是某个输入 x[t] 的预测输出。

如果我们使用前馈神经网络来解决这个任务,网络将不得不一次处理一个 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_005.png,每次将 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_006.png 作为输入。现在,让我们考虑一下这种解决方案在时间序列问题中可能面临的问题。

一个前馈神经网络在时间 t 预测的输出 y[t] 仅依赖于当前输入 x[t]。换句话说,它并不知道导致 x[t] 的输入(即 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_007.png)。因此,前馈神经网络无法完成这样一个任务:当前的输出不仅依赖于当前输入,还依赖于先前的输入。让我们通过一个例子来理解这一点。

假设我们需要训练一个神经网络来填补缺失的单词。我们有如下短语,并希望预测下一个单词:

詹姆斯有一只猫,它喜欢喝 ____。

如果我们每次处理一个单词并使用前馈神经网络,那么我们只会得到输入 drink,这远远不足以理解这个短语,甚至无法理解上下文(单词 drink 可以出现在许多不同的语境中)。有些人可能会认为,通过一次性处理完整句子,我们可以得到较好的结果。尽管这是对的,但这种方法也有局限性,比如在处理非常长的句子时。然而,现在有一种新的模型家族,称为 Transformer,它们使用完全连接的层来处理完整的数据序列,并且在性能上超越了顺序模型。我们稍后会单独讲解这些模型。

使用 RNN 建模

另一方面,我们可以使用 RNN 来解决这个问题。我们将从已有的数据开始:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_008.png

假设我们有以下关系:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_009.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_010.png

现在,让我们将 g[1] 替换为一个函数逼近器 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_011.png,该函数由 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_012.png 参数化,它接受当前输入 x[t] 和系统的先前状态 h[t-1] 作为输入,并生成当前状态 h[t]。然后,我们将 g[2] 替换为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_013.png,它接受系统的当前状态 h[t] 并生成 y[t]。这就给出了如下结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_014.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_015.png

我们可以将 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_016.png 看作是生成 xy 的真实模型的近似。为了更清楚地理解这一点,让我们将方程展开如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_017.png

例如,我们可以将 y[4] 表示为如下形式:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_018.png

同样,通过展开,我们得到以下结果(为了清晰起见,省略了 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_012.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_020.png):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_021.png

这可以通过图形来表示,如 图 6.1 所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_01.png

图 6.1:x[t] 和 y[t] 的关系展开

我们可以将该图进行概括,对于任何给定的时间步 t,如 图 6.2 所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_02.png

图 6.2:RNN 结构的单步计算

然而,需要理解的是,h[t-1]实际上是在接收到x[t]之前的h[t]。换句话说,h[t-1]是h[t]在一个时间步之前的值。

因此,我们可以使用循环连接表示h[t]的计算,如图 6.3所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_03.png

图 6.3:带有循环连接的 RNN 单步计算

将一系列方程映射从https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_022.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_023.png,如图 6.3所示,这使我们能够将任何y[t]表示为x[t]、h[t-1]和h[t]的函数。这是 RNN 的核心思想。

循环神经网络的技术描述

现在,我们更深入地了解 RNN 的构成,并定义在 RNN 内部发生的计算的数学方程式。我们从我们推导出的两个函数开始,作为学习从x[t]到y[t]的函数逼近器:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_024.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_025.png

如我们所见,神经网络由一组权重、偏置和一些非线性激活函数组成。因此,我们可以将之前的关系写成如下形式:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_026.png

在这里,tanh 是 tanh 激活函数,U是大小为https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_027.png的权重矩阵,其中m是隐藏单元的数量,d是输入的维度。此外,W是大小为https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_028.png的权重矩阵,用于从h[t-1]到h[t]创建循环连接。y[t]的关系由以下方程给出:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_029.png

在这里,V是大小为https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_030.png的权重矩阵,c是输出的维度(这可以是输出类别的数量)。在图 6.4中,我们展示了这些权重如何形成一个 RNN。箭头表示数据在网络中的流动方向:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_04.png

图 6.4:RNN 的结构

到目前为止,我们已经看到如何用计算节点的图表示 RNN,其中边表示计算过程。此外,我们还探讨了 RNN 背后的实际数学原理。现在让我们来看一下如何优化(或训练)RNN 的权重,以便从序列数据中学习。

时间反向传播

在训练 RNN 时,使用一种特殊形式的反向传播,称为时间反向传播BPTT)。然而,要理解 BPTT,首先我们需要了解BP是如何工作的。然后我们将讨论为什么 BP 不能直接应用于 RNN,但如何将 BP 适应 RNN,从而得出 BPTT。最后,我们将讨论 BPTT 中存在的两个主要问题。

反向传播是如何工作的

反向传播是用来训练前馈神经网络的技术。在反向传播过程中,你会执行以下操作:

  • 计算给定输入的预测

  • 通过将预测与输入的实际标签进行比较,计算预测的误差 E(例如,均方误差和交叉熵损失)

  • 通过在梯度的相反方向上迈出小步,更新前馈网络的权重,以最小化在步骤 2中计算的损失,针对所有 w[ij],其中 w[ij] 是 i^(th) 层的 j^(th) 权重

为了更清楚地理解上述计算,考虑 图 6.5 中描绘的前馈网络。该网络有两个单一的权重 w[1] 和 w[2],并计算两个输出 hy,如下面的图所示。为简化模型,我们假设没有非线性:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_05.png

图 6.5:前馈网络的计算

我们可以使用链式法则如下计算 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_032.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_033.png

这简化为以下内容:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_034.png

在这里,l 是数据点 x 的正确标签。此外,我们假设均方误差作为损失函数。这里的一切都是定义明确的,计算 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_035.png 也相当直接。

为什么我们不能直接对 RNN 使用 BP

现在,让我们对 图 6.6 中的 RNN 做同样的尝试。现在我们有了一个额外的递归权重 w[3]。为了清晰地突出我们要强调的问题,我们省略了输入和输出的时间成分:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_06.png

图 6.6:RNN 的计算

让我们看看如果我们应用链式法则来计算 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_036.png 会发生什么:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_037.png

这变为以下内容:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_038.png

这里的项 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_039.png 会产生问题,因为它是一个递归项。你最终会得到无限多个导数项,因为 h 是递归的(也就是说,计算 h 包含了 h 本身),并且 h 不是常量,而是依赖于 w[3]。这通过随着时间展开输入序列 x 来解决,为每个输入 x[t] 创建一个 RNN 的副本,分别计算每个副本的导数,然后通过将这些更新求和来合并这些更新,进而计算权重更新。我们将在接下来讨论这个过程的细节。

反向传播通过时间——训练 RNN

计算 RNN 的反向传播技巧是考虑完整的输入序列,而不仅仅是单个输入。然后,如果我们在时间步 4 计算 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_040.png,我们将得到以下结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_041.png

这意味着我们需要计算所有时间步长(直到第四个时间步)的梯度和。换句话说,我们将首先展开序列,以便我们可以为每个时间步 j 计算 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_042.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_043.png。这是通过创建四个 RNN 的副本来完成的。因此,要计算 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_044.png,我们需要 t-j+1 个 RNN 副本。然后,我们将通过将所有前一个时间步的梯度相加,滚动这些副本为一个单一的 RNN,得到梯度,并使用梯度 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_045.png 来更新 RNN。

然而,随着时间步数的增加,这变得非常昂贵。为了更高效的计算,我们可以使用 截断反向传播通过时间TBPTT)来优化递归模型,这是 BPTT 的一种近似方法。

截断 BPTT——高效训练 RNN

在 TBPTT 中,我们只计算固定数量 T 时间步的梯度(与 BPTT 中计算到序列开始不同)。更具体地说,在计算 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_040.png 时,对于时间步 t,我们只计算到 t-T 的导数(也就是说,我们不计算到序列的开始):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_047.png

这比标准的 BPTT 在计算上更为高效。在标准 BPTT 中,对于每个时间步 t,我们需要计算从序列开始到当前时间步的导数。但随着序列长度越来越大,这变得计算上不可行(例如,当逐字处理一篇长文本时)。然而,在截断 BPTT 中,我们只计算固定步数的反向导数,正如你可以想象的那样,随着序列变长,计算成本不会发生变化。

BPTT 的局限性——消失梯度和爆炸梯度

尽管有了计算递归权重梯度的方法,并且拥有像 TBPTT 这样的计算高效近似方法,我们仍然无法毫无问题地训练 RNN。计算中可能还会出现其他问题。

为了理解为什么如此,让我们展开 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_048.png 中的一个单项,公式如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_049.png

由于我们知道反向传播的问题来自于递归连接,因此让我们忽略 w[1]x 项,考虑以下内容:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_050.png

通过简单地展开 h[3] 并进行简单的算术运算,我们可以证明这一点:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_051.png

我们看到,对于仅四个时间步,我们有一个项 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_052.png。因此,在 n^(th) 时间步,它将变成 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_053.png。假设我们在 n=100 时间步将 w[3] 初始化为非常小的值(例如 0.00001);那么梯度将变得极其微小(约为 10^(-500) 的量级)。此外,由于计算机在表示数字时精度有限,这个更新将被忽略(即,算术下溢)。这被称为 消失梯度

解决梯度消失问题并不是非常直接。没有简单的方法可以重新缩放梯度,以便它们能够正确地在时间上传播。实践中解决梯度消失问题的一些技术包括:仔细初始化权重(例如,Xavier 初始化),或使用基于动量的优化方法(即,除了当前的梯度更新外,我们还会添加一个额外的项,这是所有过去梯度的累积,称为速度项)。然而,解决梯度消失问题的更有原则的方法,例如对标准 RNN 的不同结构修改,已经被提出,正如我们将在第七章,理解长短期记忆网络中看到的那样。

另一方面,假设我们将 w[3] 初始化为非常大(比如 1000.00)。那么在 n=100 的时间步长下,梯度将变得巨大(规模为 10³⁰⁰)。

这会导致数值不稳定,您将在 Python 中得到类似 InfNaN(即非数字)的值。这被称为梯度爆炸

梯度爆炸也可能由于问题损失面(loss surface)的复杂性而发生。由于输入的维度和模型中存在大量参数(权重),复杂的非凸损失面在深度神经网络中非常常见。

图 6.7 展示了 RNN 的损失面,并突出了具有非常高曲率的墙壁。如果优化方法接触到这样的墙壁,梯度将会爆炸或超调,如图中实线所示。这可能导致非常差的损失最小化、数值不稳定,或者两者都有。避免在这种情况下梯度爆炸的一个简单方法是将梯度裁剪到一个合理的小值,当其大于某个阈值时。图中的虚线显示了当我们在某个小值处裁剪梯度时会发生什么。(梯度裁剪在论文《训练循环神经网络的难题》中有介绍,Pascanu, Mikolov, and Bengio, 国际机器学习大会 (2013): 1310-1318。)

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_07.png

图 6.7:梯度爆炸现象。来源:此图来自 Pascanu、Mikolov 和 Bengio 的论文《训练循环神经网络的难题》。

在这里,我们结束了关于 BPTT 的讨论,BPTT 是为 RNN 适配的反向传播算法。接下来,我们将讨论 RNN 如何用于解决各种应用。这些应用包括句子分类、图像描述和机器翻译。我们将把 RNN 分类为不同的类别,如一对一、一对多、多对一和多对多。

RNN 的应用

到目前为止,我们只讨论了一对一映射的 RNN,其中当前输出依赖于当前输入以及先前观察到的输入历史。这意味着,对于先前观察到的输入序列和当前输入,存在一个输出。然而,在实际应用中,可能会出现只有一个输出对应于输入序列、一个输出对应于单一输入、以及一个输出序列对应于输入序列但序列大小不同的情况。在本节中,我们将讨论几种不同的 RNN 模型设置及其应用。

一对一 RNN

在一对一 RNN 中,当前输入依赖于先前观察到的输入(见 图 6.8)。这种 RNN 适用于每个输入都有输出的问题,但输出既依赖于当前输入,也依赖于导致当前输入的输入历史。一个这样的任务示例是股市预测,其中我们为当前输入输出一个值,而这个输出还依赖于之前输入的表现。另一个例子是场景分类,其中图像中的每个像素都有标签(例如,标签如车、道路和人)。有时,x[t+1] 可能与 y[t] 相同,这对于某些问题是成立的。例如,在文本生成问题中,之前预测的单词成为预测下一个单词的输入。下图展示了一对一 RNN:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_08.png

图 6.8:具有时间依赖关系的一对一 RNN

一对多 RNN

一对多 RNN 将接受单一输入并输出一个序列(见 图 6.9)。在这里,我们假设输入之间是相互独立的。

也就是说,我们不需要关于先前输入的信息就能对当前输入进行预测。然而,循环连接是必要的,因为尽管我们处理的是单一输入,但输出是一个依赖于先前输出值的值序列。一个可以使用这种 RNN 的示例任务是图像字幕生成任务。例如,对于给定的输入图像,文本字幕可能由五个或十个单词组成。换句话说,RNN 会不断预测单词,直到输出一个描述图像的有意义短语。下图展示了一对多 RNN:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_09.png

图 6.9:一对多 RNN

多对一 RNN

多对一 RNN 接受任意长度的输入,并为输入序列产生一个单一的输出(见 图 6.10)。句子分类就是一个可以从多对一 RNN 中受益的任务。句子被模型表示为任意长度的单词序列。模型将其作为输入,并产生一个输出,将句子分类为预定义类中的一种。以下是句子分类的一些具体示例:

  • 将电影评论分类为正面或负面(即情感分析)

  • 根据句子的描述对其进行分类(例如,人物、物体或位置)

多对一 RNN 的另一个应用是通过一次处理图像的一个补丁并将窗口在整个图像上移动,来对大规模图像进行分类。

以下图示展示了一个多对一 RNN:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_10.png

图 6.10:一个多对一 RNN

多对多 RNN

多对多 RNN(或称序列到序列,简写为 seq2seq)通常会从任意长度的输入中生成任意长度的输出(见 图 6.11)。换句话说,输入和输出不需要是相同的长度。这在机器翻译中尤其有用,因为我们将一个语言的句子翻译成另一种语言。如你所想,一个语言中的一个句子不一定与另一个语言中的句子对齐。另一个例子是聊天机器人,其中聊天机器人读取一串单词(即用户请求),并输出一串单词(即回答)。以下图示展示了一个多对多 RNN:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_11.png

图 6.11:一个多对多 RNN

我们可以总结前馈网络和 RNN 的不同应用类型如下:

算法描述应用
一对一 RNN这些网络接受单一输入并生成单一输出。当前输入依赖于之前观察到的输入。股票市场预测,场景分类和文本生成
一对多 RNN这些网络接受单一输入,并生成一个包含任意数量元素的输出图像描述
多对一 RNN这些网络接受一个输入序列,并生成单一输出。句子分类(将单一单词视为单一输入)
多对多 RNN这些网络接受任意长度的序列作为输入,并输出任意长度的序列。机器翻译,聊天机器人

接下来,我们将学习如何使用 RNN 来识别文本语料库中提到的各种实体。

使用 RNN 进行命名实体识别

现在让我们看一下我们的第一个任务:使用 RNN 识别文本语料库中的命名实体。这个任务被称为 命名实体识别NER)。我们将使用经过修改的著名 CoNLL 2003(即 计算自然语言学习会议 - 2003)数据集来进行命名实体识别。

CoNLL 2003 数据集支持多种语言,英文数据来源于路透社语料库,该语料库包含了 1996 年 8 月到 1997 年 8 月之间发布的新闻报道。我们将使用的数据库位于 github.com/ZihanWangKi/CrossWeigh,名为 CoNLLPP。与原始的 CoNLL 数据集相比,它是一个经过更加精细筛选的版本,避免了由于错误理解单词上下文而引起的数据集错误。例如,在短语 “Chicago won …” 中,Chicago 被识别为一个地点,而实际上它是一个组织。这个练习可以在 Ch06-Recurrent-Neural-Networks 文件夹下的 ch06_rnns_for_named_entity_recognition.ipynb 中找到。

理解数据

我们定义了一个名为 download_data() 的函数,可以用来下载数据。我们不会深入探讨它的细节,因为它只是下载几个文件并将它们放入一个数据文件夹。一旦下载完成,您将拥有三个文件:

  • data\conllpp_train.txt – 训练集,包含 14041 个句子

  • data\conllpp_dev.txt – 验证集,包含 3250 个句子

  • data\conllpp_test.txt – 测试集,包含 3452 个句子

接下来,我们将读取数据并将其转换为适合我们模型的特定格式。但在此之前,我们需要看看原始数据的样子:

-DOCSTART- -X- -X- O
EU NNP B-NP B-ORG
rejects VBZ B-VP O
German JJ B-NP B-MISC
call NN I-NP O
to TO B-VP O
boycott VB I-VP O
British JJ B-NP B-MISC
lamb NN I-NP O
. . O O
The DT B-NP O
European NNP I-NP B-ORG
Commission NNP I-NP I-ORG
said VBD B-VP O
...
to TO B-PP O
sheep NN B-NP O
. . O O 

如您所见,文档中每行包含一个单词,并带有该单词的相关标签。这些标签的顺序如下:

  1. 词性标签(POS 标签)(例如,名词 - NN,动词 - VB,限定词 - DT 等)

  2. 短语块标签 – 短语块是由一个或多个标记组成的文本段落(例如,NP 代表名词短语,如 “The European Commission”)

  3. 命名实体标签(例如,位置、组织、人物等)

无论是短语块标签还是命名实体标签,都有 B-I- 前缀(例如,B-ORGI-ORG)。这些前缀用于区分实体/短语块的起始标记与后续标记。

数据集中还有五种类型的实体:

  • 基于位置的实体(LOC

  • 基于人物的实体(PER

  • 基于组织的实体(ORG

  • 杂项实体(MISC

  • 非实体(O

最后,每个句子之间有一个空行。

现在让我们来看一下加载我们下载的数据到内存中的代码,这样我们就可以开始使用它了:

def read_data(filename):
    '''
    Read data from a file with given filename
    Returns a list of sentences (each sentence a string), 
    and list of ner labels for each string
    '''
    print("Reading data ...")
    # master lists - Holds sentences (list of tokens),
    # ner_labels (for each token an NER label)
    sentences, ner_labels = [], [] 

    # Open the file
    with open(filename,'r',encoding='latin-1') as f:        
        # Read each line
        is_sos = True 
        # We record at each line if we are seeing the beginning of a 
        # sentence

        # Tokens and labels of a single sentence, flushed when encountered
        # a new one
        sentence_tokens = []
        sentence_labels = []
        i = 0
        for row in f:
        # If we are seeing an empty line or -DOCSTART- that's a new line
            if len(row.strip()) == 0 or row.split(' ')[0] == '-
            DOCSTART-':
                is_sos = False
            # Otherwise keep capturing tokens and labels
            else:
                is_sos = True
                token, _, _, ner_label = row.split(' ')
                sentence_tokens.append(token)
                sentence_labels.append(ner_label.strip())

            # When we reach the end / or reach the beginning of next
            # add the data to the master lists, flush the temporary one
            if not is_sos and len(sentence_tokens)>0:
                sentences.append(' '.join(sentence_tokens))
                ner_labels.append(sentence_labels)
                sentence_tokens, sentence_labels = [], []

    print('\tDone')
    return sentences, ner_labels 

在这里,我们将存储所有句子(作为sentences中的字符串列表)和与每个标记相关的所有标签(作为ner_labels中的列表列表)。我们将逐行读取文件。我们会维护一个布尔值is_sos,用来表示我们是否在句子的开头。我们还会有两个临时列表(sentence_tokenssentence_labels),用来累积当前句子的标记和 NER 标签。当我们处于句子的开始时,我们会重置这些临时列表。否则,我们会将每个在文件中看到的标记和 NER 标签写入这些临时列表。现在,我们可以在训练集、验证集和测试集上运行这个函数:

# Train data
train_sentences, train_labels = read_data(train_filepath) 
# Validation data
valid_sentences, valid_labels = read_data(dev_filepath) 
# Test data
test_sentences, test_labels = read_data(test_filepath) 

我们将打印几个样本,看看我们得到了什么:

# Print some data
print('\nSample data\n')
for v_sent, v_labels in zip(valid_sentences[:5], valid_labels[:5]):
    print("Sentence: {}".format(v_sent))
    print("Labels: {}".format(v_labels))
    print('\n') 

这产生了:

Sentence: West Indian all-rounder Phil Simmons took four for 38 on Friday as Leicestershire beat Somerset by an innings and 39 runs in two days to take over at the head of the county championship .
Labels: ['B-MISC', 'I-MISC', 'O', 'B-PER', 'I-PER', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-ORG', 'O', 'B-ORG', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O']
Sentence: Their stay on top , though , may be short-lived as title rivals Essex , Derbyshire and Surrey all closed in on victory while Kent made up for lost time in their rain-affected match against Nottinghamshire .
Labels: ['O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-ORG', 'O', 'B-ORG', 'O', 'B-ORG', 'O', 'O', 'O', 'O', 'O', 'O', 'B-ORG', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-ORG', 'O']
Sentence: After bowling Somerset out for 83 on the opening morning at Grace Road , Leicestershire extended their first innings by 94 runs before being bowled out for 296 with England discard Andy Caddick taking three for 83 .
Labels: ['O', 'O', 'B-ORG', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-LOC', 'I-LOC', 'O', 'B-ORG', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-LOC', 'O', 'B-PER', 'I-PER', 'O', 'O', 'O', 'O', 'O'] 

NER 任务的一个独特特点是类别不平衡。也就是说,并非所有类别的样本数量大致相等。正如你可能猜到的,在语料库中,非命名实体的数量要多于命名实体。这导致标签之间出现显著的类别不平衡。因此,让我们来看看不同类别之间样本的分布:

from itertools import chain
# Print the value count for each label
print("Training data label counts")
print(pd.Series(chain(*train_labels)).value_counts()) 

为了分析数据,我们将首先把 NER 标签转换为 pandas 的Series对象。可以通过简单地在train_labelsvalid_labelstest_labels上调用pd.Series()构造函数来完成。但请记住,这些是列表的列表,其中每个内部列表代表句子中所有标记的 NER 标签。为了创建一个扁平化的列表,我们可以使用内置的 Python 库itertools中的chain()函数。它会将多个列表连接在一起,形成一个单一的列表。之后,我们在这个 pandas Series上调用value_counts()函数。这将返回一个新列表,其中索引是原始Series中找到的唯一标签,而值是每个标签出现的次数。这样我们就得到了:

Training data label counts
O         169578
B-LOC       7140
B-PER       6600
B-ORG       6321
I-PER       4528
I-ORG       3704
B-MISC      3438
I-LOC       1157
I-MISC      1155
dtype: int64 

正如你所看到的,O 标签的数量远远超过其他标签的数量。在训练模型时,我们需要记住这一点。接下来,我们将分析每个句子的序列长度(即标记的数量)。我们稍后需要这些信息来将句子填充到固定长度。

pd.Series(train_sentences).str.split().str.len().describe(percentiles=[0.05, 0.95]) 

在这里,我们创建一个 pandas Series,其中每个项目都是在将每个句子拆分为标记列表后,句子的长度。

然后,我们将查看这些长度的 5% 和 95% 分位数。这将产生:

count    14041.000000
mean        14.501887
std         11.602756
min          1.000000
5%           2.000000
50%         10.000000
95%         37.000000
max        113.000000
dtype: float64 

我们可以看到,95%的句子长度为 37 个标记或更少。

处理数据

现在是时候处理数据了。我们将保持句子的原始格式,即一个字符串列表,每个字符串代表一个句子。因为我们将把文本处理直接集成到模型中(而不是在外部进行处理)。对于标签,我们需要做一些改变。记住,标签是一个列表的列表,其中每个内部列表表示每个句子中所有标记的标签。具体来说,我们将执行以下操作:

  • 将类别标签转换为类别 ID

  • 将标签序列填充至指定的最大长度

  • 生成一个掩码,指示填充标签,以便我们可以在模型训练过程中忽略填充的标签

首先让我们编写一个函数来获取类标签到类 ID 的映射。这个函数利用 pandas 的unique()函数获取训练集中的唯一标签,并生成一个整数到唯一标签的映射。

def get_label_id_map(train_labels):
    # Get the unique list of labels
    unique_train_labels = pd.Series(chain(*train_labels)).unique()
    # Create a class label -> class ID mapping
    labels_map = dict(
        zip(unique_train_labels, 
    np.arange(unique_train_labels.shape[0])))
    print("labels_map: {}".format(labels_map))
    return labels_map 

如果你运行以下代码:

labels_map = get_label_id_map(train_labels) 

然后你将得到:

labels_map: {'B-ORG': 0, 'O': 1, 'B-MISC': 2, 'B-PER': 3, 'I-PER': 4, 'B-LOC': 5, 'I-ORG': 6, 'I-MISC': 7, 'I-LOC': 8} 

我们编写了一个名为get_padded_int_labels()的函数,该函数接受类标签的序列并返回填充后的类 ID 序列,并可选择返回一个表示填充标签的掩码。该函数接受以下参数:

  • labels (List[List[str]]) – 一个字符串列表的列表,其中每个字符串是类标签

  • labels_map (Dict[str, int]) – 一个字典,将字符串标签映射到整数类型的类 ID

  • max_seq_length (int) – 要填充的最大长度(较长的序列将在此长度处被截断)

  • return_mask (bool) – 是否返回显示填充标签的掩码

现在让我们来看一下执行上述操作的代码:

def get_padded_int_labels(labels, labels_map, max_seq_length,
return_mask=True):
    # Convert string labels to integers 
    int_labels = [[labels_map[x] for x in one_seq] for one_seq in 
    labels]

    # Pad sequences
    if return_mask:
        # If we return mask, we first pad with a special value (-1) and 
        # use that to create the mask and later replace -1 with 'O'
        padded_labels = np.array(
            tf.keras.preprocessing.sequence.pad_sequences(
                int_labels, maxlen=max_seq_length, padding='post', 
                truncating='post', value=-1
            )
        )

        # mask filter
        mask_filter = (padded_labels != -1)
        # replace -1 with 'O' s ID
        padded_labels[~mask_filter] = labels_map['O']        
        return padded_labels, mask_filter.astype('int')

    else:
        padded_labels = np.array(ner_pad_sequence_func(int_labels, 
        value=labels_map['O']))
        return padded_labels 

你可以看到函数的第一步将labels中的所有字符串标签通过labels_map转换为整数标签。接下来,我们使用tf.keras.preprocessing.sequence.pad_sequences()函数获得填充后的序列。我们在上一章中详细讨论了这个函数。本质上,它将对任意长度的序列进行填充(使用指定的值)和截断,返回固定长度的序列。我们指示该函数在序列的末尾进行填充和截断,填充值为特殊值-1。然后我们可以简单地生成一个布尔值掩码,其中padded_labels不等于-1。因此,原始标签所在的位置将标记为1,其余位置为0。但是,我们必须将-1的值转换为labels_map中找到的类 ID。我们将其分配给标签O(即其他)。

根据我们在上一章中的发现,我们将最大序列长度设置为40。记住,95%的分位数落在 37 个词的长度上:

max_seq_length = 40 

现在我们将为所有训练、验证和测试数据生成处理后的标签和掩码:

# Convert string labels to integers for all train/validation/test data
# Pad train/validation/test data
padded_train_labels, train_mask = get_padded_int_labels(
    train_labels, labels_map, max_seq_length, return_mask=True
)
padded_valid_labels, valid_mask = get_padded_int_labels(
    valid_labels, labels_map, max_seq_length, return_mask=True
)
padded_test_labels, test_mask  = get_padded_int_labels(
    test_labels, labels_map, max_seq_length, return_mask=True
) 

最后,我们将打印前两个序列的处理后的标签和掩码:

# Print some labels IDs
print(padded_train_labels[:2])
print(train_mask[:2]) 

它返回:

[[0 1 2 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
  1 1 1 1]
 [3 4 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
  1 1 1 1]]
[[1 1 1 1 1 1 1 1 1 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]
 [1 1 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]] 

你可以看到掩码清楚地指示了真实标签和填充标签。接下来,我们将定义模型的一些超参数。

定义超参数

现在,让我们定义我们 RNN 所需的几个超参数,如下所示:

  • max_seq_length – 表示序列的最大长度。我们在数据探索过程中从训练数据中推断出这一点。为序列设置合理的长度非常重要,否则,由于 RNN 的展开,内存可能会爆炸。

  • emedding_size – 词向量的维度。由于我们拥有一个小型语料库,值小于 100 即可。

  • rnn_hidden_size – RNN 中隐藏层的维度。增加隐藏层的维度通常能提高性能。然而,请注意,增加隐藏层的大小会导致所有三组内部权重(即UWV)的增加,从而导致较高的计算负担。

  • n_classes – 唯一输出类的数量。

  • batch_size – 训练数据、验证数据和测试数据的批量大小。较高的批量大小通常会带来更好的结果,因为在每次优化步骤中,我们会看到更多的数据,但就像展开一样,这也会导致更高的内存需求。

  • epochs – 训练模型的轮数。

以下是定义的内容:

# The maximum length of sequences
max_seq_length = 40
# Size of token embeddings
embedding_size = 64
# Number of hidden units in the RNN layer
rnn_hidden_size = 64
# Number of output nodes in the last layer
n_classes = 9
# Number of samples in a batch
batch_size = 64
# Number of epochs to train
epochs = 3 

现在我们将定义模型。

定义模型

我们将在这里定义模型。我们的模型将包含一个嵌入层,接着是一个简单的 RNN 层,最后是一个密集预测层。需要注意的是,在我们迄今为止的工作中,与前几章不同,我们尚未定义Tokenizer对象。虽然Tokenizer是我们自然语言处理(NLP)管道中的重要部分,用来将每个 token(或单词)转换为 ID,但使用外部分词器有一个大缺点。训练模型后,如果你忘记将分词器与模型一起保存,那么你的机器学习模型就会变得毫无用处:为了应对这一点,在推理时,你需要将每个单词映射到它在训练期间所对应的 ID。

这是分词器所带来的重大风险。在本章中,我们将寻求一种替代方法,在模型中集成分词机制,这样我们以后就不需要再担心这个问题了。图 6.12展示了模型的整体架构:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_12.png

图 6.12:模型的整体架构。文本向量化层将文本分词并转换为词 ID。接下来,每个 token 作为 RNN 的每个时间步的输入。最后,RNN 在每个时间步预测每个 token 的标签。

文本向量化层介绍

TextVectorization层可以看作是一个现代化的分词器,可以插入到模型中。在这里,我们将仅操作TextVectorization层,而不涉及模型其他部分的复杂性。首先,我们将导入TextVectorization层:

from tensorflow.keras.layers.experimental.preprocessing import TextVectorization 

现在我们将定义一个简单的文本语料库:

toy_corpus = ["I went to the market on Sunday", "The Market was empty."] 

我们可以按如下方式实例化文本向量化层:

toy_vectorization_layer = TextVectorization() 

实例化后,您需要在一些数据上拟合该层。这样,像我们之前使用的分词器一样,它可以学习单词到数字 ID 的映射。为此,我们通过传递文本语料库作为输入,调用该层的adapt()方法:

# Fit it on a corpus of data
toy_vectorization_layer.adapt(toy_corpus) 

我们可以按如下方式生成分词输出:

toy_vectorized_output = toy_vectorization_layer(toy_corpus) 

它将包含:

[[ 9  4  6  2  3  8  7]
 [ 2  3  5 10  0  0  0]] 

我们还可以查看该层所学到的词汇:

Vocabulary: ['', '[UNK]', 'the', 'market', 'went', 'was', 'to', 'sunday', 'on', 'i', 'empty'] 

我们可以看到该层已经完成了一些预处理(例如将单词转换为小写并去除了标点符号)。接下来让我们看看如何限制词汇表的大小。我们可以通过max_tokens参数来实现:

toy_vectorization_layer = TextVectorization(max_tokens=5)
toy_vectorization_layer.adapt(toy_corpus)
toy_vectorized_output = toy_vectorization_layer(toy_corpus) 

如果你将toy_corpus转换为单词 ID,你将看到:

[[1 4 1 2 3 1 1]
 [2 3 1 1 0 0 0]] 

词汇表将如下所示:

Vocabulary: ['', '[UNK]', 'the', 'market', 'went'] 

现在我们可以看到,词汇表中只有五个元素,就像我们指定的那样。现在,如果你需要跳过层内部发生的文本预处理,你可以通过将层中的standardize参数设置为None来实现:

toy_vectorization_layer = TextVectorization(standardize=None)
toy_vectorization_layer.adapt(toy_corpus)
toy_vectorized_output = toy_vectorization_layer(toy_corpus) 

这将产生:

[[12  2  4  5  7  6 10]
 [ 9 11  3  8  0  0  0]] 

词汇表将如下所示:

Vocabulary: ['', '[UNK]', 'went', 'was', 'to', 'the', 'on', 'market', 'empty.', 'The', 'Sunday', 'Market', 'I'] 

最后,我们还可以通过output_sequence_length命令控制序列的填充/截断。例如,以下命令将在长度为4的位置进行填充/截断:

toy_vectorization_layer = TextVectorization(output_sequence_length=4)
toy_vectorization_layer.adapt(toy_corpus)
toy_vectorized_output = toy_vectorization_layer(toy_corpus) 

这将产生:

[[ 9  4  6  2]
 [ 2  3  5 10]] 

这里的词汇表是:

Vocabulary: ['', '[UNK]', 'the', 'market', 'went', 'was', 'to', 'sunday', 'on', 'i', 'empty'] 

现在你已经很好地理解了TextVectorization层中的参数及其作用。接下来让我们讨论模型。

定义模型的其余部分

首先,我们将导入必要的模块:

import tensorflow.keras.layers as layers
import tensorflow.keras.backend as K
from tensorflow.keras.layers.experimental.preprocessing import TextVectorization 

我们将定义一个输入层,该层有一个单列(即每个句子表示为一个单元),并且dtype=tf.string

# Input layer
word_input = tf.keras.layers.Input(shape=(1,), dtype=tf.string) 

接下来,我们将定义一个函数,该函数接收一个语料库、最大序列长度和词汇表大小,并返回训练好的TextVectorization层和词汇表大小:

def get_fitted_token_vectorization_layer(corpus, max_seq_length, vocabulary_size=None):
    """ Fit a TextVectorization layer on given data """

    # Define a text vectorization layer
    vectorization_layer = TextVectorization(
        max_tokens=vocabulary_size, standardize=None,        
        output_sequence_length=max_seq_length, 
    )
    # Fit it on a corpus of data
    vectorization_layer.adapt(corpus)

    # Get the vocabulary size
    n_vocab = len(vectorization_layer.get_vocabulary())
    return vectorization_layer, n_vocab 

这个函数做的就是我们已经描述过的内容。然而,注意我们为向量化层设置的各种参数。我们将词汇表大小作为max_tokens传递;我们将standardize设置为None。这是一个重要的设置。在进行命名实体识别(NER)时,保持字符的大小写非常重要。通常,一个实体以大写字母开头(例如人的名字或组织名称)。因此,我们应该保留文本中的大小写。

最后,我们还将output_sequence_length设置为我们在分析过程中找到的序列长度。这样,我们就可以按如下方式创建文本向量化层:

# Text vectorization layer
vectorize_layer, n_vocab = get_fitted_token_vectorization_layer(train_sentences, max_seq_length) 

然后将word_input传递给vectorize_layer并获取输出:

# Vectorized output (each word mapped to an int ID)
vectorized_out = vectorize_layer(word_input) 

来自vectorize_layer的输出(即vectorized_out)将传递到一个嵌入层。这个嵌入层是一个随机初始化的嵌入层,输出的维度为embedding_size

# Look up embeddings for the returned IDs
embedding_layer = layers.Embedding(
    input_dim=n_vocab,
output_dim=embedding_size,
mask_zero=True
)(vectorized_out) 

到目前为止,我们处理的是前馈网络。前馈网络的输出没有时间维度。但是,如果你查看TextVectorization层的输出,它将是一个[batch size, sequence length]形状的输出。当这个输出经过嵌入层时,输出将是一个[batch size, sequence length, embedding size]形状的张量。换句话说,嵌入层的输出中包含了一个额外的时间维度。

另一个区别是引入了mask_true参数。遮蔽(masking)用于掩盖添加到序列中的无效词(例如,为了使句子长度固定而添加的填充符号),因为它们对最终结果没有贡献。遮蔽是序列学习中常用的技术。要了解更多关于遮蔽的内容,请阅读下方的信息框。

序列学习中的遮蔽

自然,文本的长度是任意的。例如,语料库中的句子可能有不同的标记长度。而深度网络处理的是固定维度的张量。为了将任意长度的句子转换为常数长度,我们会用一些特殊的值(例如 0)对这些序列进行填充。然而,这些填充值是人工的,只是为了确保正确的输入形状。它们不应该对最终的损失或评估指标产生影响。为了在损失计算和评估时忽略它们,使用了“遮蔽”技术。其原理是将来自填充时间步长的损失乘以零,实质上将其从最终损失中切断。

在训练模型时手动执行遮蔽操作会非常繁琐。但在 TensorFlow 中,大多数层都支持遮蔽。例如,在嵌入层中,为了忽略填充的值(通常是零),你只需要设置mask_true=True

当你在某个层中启用遮蔽时,它会将遮蔽传播到下游层,直到损失计算为止。换句话说,你只需要在模型开始时启用遮蔽(就像我们在嵌入层中所做的那样),剩下的部分由 TensorFlow 自动处理。

接下来,我们将定义模型的核心层——RNN:

# Define a simple RNN layer, it returns an output at each position
rnn_layer = layers.SimpleRNN(
    units=rnn_hidden_size, return_sequences=True
)
rnn_out = rnn_layer(embedding_layer) 

你可以通过简单地调用tf.keras.layers.SimpleRNN来实现一个基础的 RNN。在这里,我们传递了两个重要的参数。除了这两个参数,还有其他有用的参数,但它们将在后续章节中与更复杂的 RNN 变体一起讲解:

  • unitsint) – 这定义了 RNN 模型的隐藏输出大小。这个值越大,模型的表示能力就越强。

  • return_sequencesbool) – 是否返回所有时间步的输出,还是仅返回最后一个输出。对于命名实体识别(NER)任务,我们需要标注每个单独的标记。因此,我们需要返回所有时间步的输出。

rnn_layer 接受一个形状为 [batch size, sequence length, embedding size] 的张量,并返回一个形状为 [batch size, sequence length, rnn hidden size] 的张量。最后,来自 RNN 的时间分布输出将传递给一个具有 n_classes 输出节点和 softmax 激活函数的全连接层:

dense_layer = layers.Dense(n_classes, activation='softmax')
dense_out = dense_layer(rnn_out) 

最后,我们可以按如下方式定义最终模型。它接收一批字符串句子作为输入,并返回一批标签序列作为输出:

model = tf.keras.Model(inputs=word_input, outputs=dense_out) 

我们现在已经完成了模型的构建。接下来,我们将讨论损失函数和评估指标。

评估指标和损失函数

在我们之前的讨论中,我们提到过命名实体识别(NER)任务通常存在较大的类别不平衡问题。文本中非实体相关的标记通常比实体相关的标记更多。这导致出现大量的其他(O)类型标签,而其他类型的标签较少。在训练模型和评估模型时,我们需要考虑这一点。我们将通过两种方式来解决类别不平衡问题:

  • 我们将创建一个新的评估指标,能够抵抗类别不平衡

  • 我们将使用样本权重来惩罚频繁出现的类别,并提升稀有类别的重要性

在本节中,我们仅解决前者问题。后者将在下一节中讨论。我们将定义一个修改版的准确率。这被称为宏观平均准确率。在宏观平均中,我们分别计算每个类别的准确率,然后求平均。因此,在计算准确率时,类别不平衡问题被忽略。当计算标准指标(如准确率、精确率或召回率)时,有多种不同的平均方式可供选择。欲了解更多信息,请参阅下方的信息框。

不同类型的指标平均方式

指标有多种可用的平均方式。你可以在 scikit-learn 文档中阅读其中一种平均方式,详细信息请见 scikit-learn.org/stable/modules/generated/sklearn.metrics.average_precision_score.html。考虑一个简单的二分类示例,混淆矩阵的结果如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_13.png

图 6.13:示例混淆矩阵结果

  • 微观 – 计算全局指标,忽略类别分布的差异。例如 35/65 = ~54%

  • 宏观 – 分别计算每个类别的指标并求平均。例如 (35/40 + 0/25)/2 = ~43.7%

  • 加权 – 分别计算每个类别的指标并按支持度加权(即每个类别的真实标签数量)。例如 (35/40)* 40 + (0/25) * 25 / 65 = ~54%

在这里,你可以看到微观和加权返回相同的结果。这是因为准确率计算的分母与支持度相同。因此,在加权平均时它们会相互抵消。然而,对于精确率和召回率等其他指标,你将获得不同的值。

在下文中,我们定义了一个函数来计算宏观准确率,输入为一批真实目标(y_true)和预测值(y_pred)。y_true的形状为[batch_size, sequence length]y_pred的形状为[batch size, sequence length, n_classes]

def macro_accuracy(y_true, y_pred):

    # [batch size, time] => [batch size * time]
    y_true = tf.cast(tf.reshape(y_true, [-1]), 'int32')
    # [batch size, sequence length, n_classes] => [batch size * time]
    y_pred = tf.cast(tf.reshape(tf.argmax(y_pred, axis=-1), [-1]), 
    'int32')

    sorted_y_true = tf.sort(y_true)
    sorted_inds = tf.argsort(y_true)

    sorted_y_pred = tf.gather(y_pred, sorted_inds)

    sorted_correct = tf.cast(tf.math.equal(sorted_y_true, 
    sorted_y_pred), 'int32')

    # We are adding one to make sure there are no division by zero
    correct_for_each_label = 
    tf.cast(tf.math.segment_sum(sorted_correct, sorted_y_true), 
    'float32') + 1
    all_for_each_label = 
    tf.cast(tf.math.segment_sum(tf.ones_like(sorted_y_true), 
    sorted_y_true), 'float32') + 1

    mean_accuracy = 
    tf.reduce_mean(correct_for_each_label/all_for_each_label)

    return mean_accuracy 

需要注意的是,我们必须使用 TensorFlow 操作来编写此函数,以确保它们作为图执行。尽管 TensorFlow 2 已转向更具命令式风格的执行操作,但 TensorFlow 1 中引入的声明式风格仍然有所残留。

首先我们将y_true展平,使其成为一个向量。接着,我们使用tf.argmax()函数从y_pred中获取预测标签,并将预测标签展平为一个向量。这两个展平后的结构将具有相同的元素数量。然后,我们对y_true进行排序,使得相同标签的元素紧密排列在一起。

我们在排序后的原始数据中取索引,然后使用tf.gather()函数将y_pred按与y_true相同的顺序排列。换句话说,sorted_y_truesorted_y_pred之间仍然保持相同的对应关系。tf.gather()函数接收一个张量和一组索引,并根据这些索引对传入的张量进行排序。关于tf.gather()的更多信息,请参考www.tensorflow.org/api_docs/python/tf/gather

然后我们计算sorted_correct,这是一个简单的指示函数,当sorted_y_truesorted_y_pred中的对应元素相同时,它会启动,如果不同则保持关闭。接着我们使用tf.math.segment_sum()函数来计算正确预测样本的分段和。每个类别的样本被视为一个单独的段(correct_for_each_label)。segment_sum()函数有两个参数:datasegment_ids。例如,如果data[0, 1, 2, 3, 4, 5, 6, 7]segment_ids[0, 0, 0, 1, 1, 2, 3, 3],则分段和为[0+1+2, 3+4, 5, 6+7] = [3, 7, 5, 13]

然后我们对一个由 1 组成的向量做同样的操作。在这种情况下,我们得到了每个类别在数据批次中存在的真实样本数量(all_for_each_label)。请注意,我们在末尾添加了一个 1。这是为了避免在下一步中出现除以 0 的情况。最后,我们将correct_for_each_label除以all_for_each_label,得到一个包含每个类别准确率的向量。然后我们计算平均准确率,即宏平均准确率。

最后,我们将这个函数封装在一个MeanMetricWrapper中,这将产生一个tf.keras.metrics.Metric对象,我们可以将其传递给model.compile()函数:

mean_accuracy_metric = tf.keras.metrics.MeanMetricWrapper(fn=macro_accuracy, name='macro_accuracy') 

通过调用以下方式来编译模型:

model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics=[mean_accuracy_metric]) 

接下来,我们将使用准备好的数据训练模型。

在 NER 任务上训练和评估 RNN

让我们在准备好的数据上训练模型。但首先,我们需要定义一个函数来处理数据集中的类别不平衡问题。我们将把样本权重传递给model.fit()函数。为了计算样本权重,我们首先定义一个名为get_class_weights()的函数,用来计算每个类别的class_weights。接下来,我们将把类别权重传递给另一个函数get_sample_weights_from_class_weights(),该函数将生成样本权重:

def get_class_weights(train_labels):

    label_count_ser = pd.Series(chain(*train_labels)).value_counts()
    label_count_ser = label_count_ser.min()/label_count_ser

    label_id_map = get_label_id_map(train_labels)
    label_count_ser.index = label_count_ser.index.map(label_id_map)
    return label_count_ser.to_dict() 

第一个函数get_class_weights()接受train_labels(一个包含类别 ID 列表的列表)。然后我们使用train_labels创建一个 pandas 的Series对象。注意,我们使用了内置的itertools库中的chain函数,它会将train_labels展平为类别 ID 的列表。这个Series对象包含了在训练数据集中每个类别标签的频次。接下来,为了计算权重,我们将最小频次按元素逐一从其他频次中进行除法运算。换句话说,如果类别标签https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_054.png的频率用https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_055.png表示,总标签集用https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_056.png表示,则类别https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_054.png的权重计算公式为:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_058.png

最后,输出被转换为一个字典,其中类别 ID 作为键,类别权重作为值。接下来,我们需要将class_weights转换为sample_weights。我们只需对每个标签执行字典查找操作,按元素逐一生成样本权重,基于class_weightssample_weights的形状将与train_labels相同,因为每个样本都有一个权重:

def get_sample_weights_from_class_weights(labels, class_weights):
    """ From the class weights generate sample weights """
    return np.vectorize(class_weights.get)(labels) 

我们可以使用 NumPy 的np.vectorize()函数来实现这一点。np.vectorize()接受一个函数(例如,class_weights.get()是 Python 提供的键查找函数),并将其应用于所有元素,从而得到样本权重。调用我们之前定义的函数来生成实际的权重:

train_class_weights = get_class_weights(train_labels)
print("Class weights: {}".format(train_class_weights))
# Get sample weights (we cannot use class_weight with TextVectorization
# layer)
train_sample_weights = get_sample_weights_from_class_weights(padded_train_labels, train_class_weights) 

在我们拥有了样本权重后,我们可以训练模型。你可以通过打印class_weights来查看它们。这将给出:

labels_map: {
    'B-ORG': 0, 
    'O': 1, 
    'B-MISC': 2, 
    'B-PER': 3, 
    'I-PER': 4, 
    'B-LOC': 5, 
    'I-ORG': 6, 
    'I-MISC': 7, 
    'I-LOC': 8
}
Class weights: {
    1: 0.006811025015037328, 
    5: 0.16176470588235295, 
    3: 0.17500000000000002, 
    0: 0.18272425249169436, 
    4: 0.25507950530035334, 
    6: 0.31182505399568033, 
    2: 0.33595113438045376, 
    8: 0.9982713915298186, 
    7: 1.0
} 

你可以看到类别Other的权重最低(因为它是最频繁的类别),而类别I-MISC的权重最高,因为它是最不频繁的类别。现在我们将使用准备好的数据训练我们的模型:

# Make train_sequences an array
train_sentences = np.array(train_sentences)
# Training the model
model.fit(
        train_sentences, padded_train_labels, 
        sample_weight=train_sample_weights,
        batch_size=batch_size,
        epochs=epochs, 
        validation_data=(np.array(valid_sentences), 
        padded_valid_labels)
) 

你应该能得到大约 78-79%的准确率,没有进行任何特殊的性能优化技巧。接下来,你可以使用以下命令在测试数据上评估模型:

model.evaluate(np.array(test_sentences), padded_test_labels) 

这将给出大约 77%的测试准确率。由于验证准确率和测试准确率相当,我们可以说模型的泛化表现良好。但为了确保这一点,让我们视觉检查一下测试集中的一些样本。

可视化分析输出

为了分析输出,我们将使用测试集中的前五个句子:

n_samples = 5
visual_test_sentences = test_sentences[:n_samples]
visual_test_labels = padded_test_labels[:n_samples] 

接下来使用模型进行预测,并将这些预测转换为预测的类别 ID:

visual_test_predictions = model.predict(np.array(visual_test_sentences))
visual_test_pred_labels = np.argmax(visual_test_predictions, axis=-1) 

我们将创建一个反转的labels_map,它将标签 ID 映射到标签字符串:

rev_labels_map = dict(zip(labels_map.values(), labels_map.keys())) 

最后,我们将打印出结果:

for i, (sentence, sent_labels, sent_preds) in enumerate(zip(visual_test_sentences, visual_test_labels, visual_test_pred_labels)):    
    n_tokens = len(sentence.split())
    print("Sample:\t","\t".join(sentence.split()))
    print("True:\t","\t".join([rev_labels_map[i] for i in 
    sent_labels[:n_tokens]]))
    print("Pred:\t","\t".join([rev_labels_map[i] for i in 
    sent_preds[:n_tokens]]))
    print("\n") 

这将打印出:

Sample:     SOCCER    -    JAPAN    GET    LUCKY    WIN    ,    CHINA    IN    SURPRISE    DEFEAT    .
True:         O    O    B-LOC    O    O    O    O    B-LOC    O    O    O    O
Pred:         O    O    B-MISC    O    O    O    O    B-PER    O    B-LOC    O    O
Sample:     Nadim    Ladki
True:         B-PER    I-PER
Pred:         B-LOC    O
Sample:     AL-AIN    ,    United    Arab    Emirates    1996-12-06
True:         B-LOC    O    B-LOC    I-LOC    I-LOC    O
Pred:         B-LOC    O    B-LOC    I-LOC    I-LOC    I-ORG
Sample:     Japan    began    the    defence    of    their    Asian    Cup    title    with    a    lucky    2-1    win    against    Syria    in    a    Group    C    championship    match    on    Friday    .
True:         B-LOC    O    O    O    O    O    B-MISC    I-MISC    O    O    O    O    O    O    O    B-LOC    O    O    O    O    O    O    O    O    O
Pred:         B-LOC    I-LOC    O    O    O    O    B-MISC    I-MISC    I-MISC    O    O    O    O    O    O    B-LOC    O    O    O    O    O    O    O    O    O 

可以看到我们的模型表现不错。它擅长识别位置,但在识别人物名称上存在困难。在这里,我们结束了关于执行命名实体识别(NER)的基本 RNN 解决方案的讨论。在接下来的部分,我们将使模型更加复杂,赋予它通过提供更细粒度的细节来更好理解文本的能力。让我们了解一下如何改进我们的模型。

使用字符和标记嵌入进行命名实体识别(NER)

目前,用于解决命名实体识别(NER)任务的递归模型比仅使用单一嵌入层和 RNN 模型要复杂得多。它们涉及使用更高级的递归模型,如长短期记忆(LSTM)、**门控递归单元(GRU)**等。我们将在接下来的几章中暂时不讨论这些高级模型。这里,我们将重点讨论一种能够提供多尺度模型嵌入的技术,从而使其更好地理解语言。也就是说,除了依赖标记嵌入外,还要使用字符嵌入。然后,通过在标记的字符上滑动卷积窗口,利用字符嵌入生成标记嵌入。如果你现在还不理解细节,别担心,接下来的章节将详细介绍解决方案。这个练习可以在Ch06-Recurrent-Neural-Networks文件夹中的ch06_rnns_for_named_entity_recognition.ipynb找到。

使用卷积生成标记嵌入

组合字符嵌入和卷积核可以用来生成标记嵌入(图 6.14)。该方法如下:

  • 将每个标记(例如单词)填充到预定的长度

  • 查找标记中字符的字符嵌入,来自嵌入层

  • 将卷积核滑过字符嵌入序列,生成标记嵌入

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_06_14.png

图 6.14:如何使用字符嵌入和卷积操作生成标记嵌入

我们需要做的第一件事是分析语料库中每个标记的字符统计信息。类似于之前的方法,我们可以使用 pandas 来完成:

vocab_ser = pd.Series(
    pd.Series(train_sentences).str.split().explode().unique()
)
vocab_ser.str.len().describe(percentiles=[0.05, 0.95]) 

在计算vocab_ser时,第一部分(即pd.Series(train_sentences).str.split())将产生一个 pandas Series,其元素是标记列表(句子中的每个标记都是该列表的一个元素)。接下来,explode()将把包含标记列表的Series转换成单独的标记Series,即将每个标记转换为Series中的一个独立元素。最后,我们只取该Series中的唯一标记。最终我们会得到一个 pandas Series,其中每一项是一个唯一的标记。

我们现在将使用str.len()函数获取每个标记的长度(即字符数),并查看其中的 95%分位数。我们将得到以下结果:

count    23623.000000
mean         6.832705
std          2.749288
min          1.000000
5%           3.000000
50%          7.000000
95%         12.000000
max         61.000000
dtype: float64 

我们可以看到大约 95%的单词字符数小于或等于 12 个。接下来,我们将编写一个函数来填充较短的标记:

def prepare_corpus_for_char_embeddings(tokenized_sentences, max_seq_length):
    """ Pads each sequence to a maximum length """
    proc_sentences = []
    for tokens in tokenized_sentences:
        if len(tokens) >= max_seq_length:
            proc_sentences.append([[t] for t in 
            tokens[:max_seq_length]])
        else:
            proc_sentences.append([[t] for t in 
            tokens+['']*(max_seq_length-len(tokens))])

    return proc_sentences 

该函数接受一组标记化的句子(即每个句子作为一个标记列表,而不是字符串)和一个最大序列长度。请注意,这是我们之前使用的最大序列长度,而不是我们讨论过的新标记长度。该函数将执行以下操作:

  • 对于较长的句子,只返回max_seq_length个标记

  • 对于较短的句子,追加‘’作为标记,直到达到max_seq_length

让我们在一个小型的玩具数据集上运行这个函数:

# Define sample data
data = ['aaaa bb c', 'd eee']
# Pad sequences
tokenized_sentences = prepare_corpus_for_char_embeddings([d.split() for d in data], 3) 

这将返回:

Padded sequence: [[['aaaa'], ['bb'], ['c']], [['d'], ['eee'], ['']]] 

现在我们将定义一个新的TextVectorization层来应对我们对数据所做的变化。新的TextVectorization层必须在字符级进行标记化,而不是在标记级进行。为此,我们需要做一些更改。我们将再次编写一个函数来包含这个向量化层:

def get_fitted_char_vectorization_layer(corpus, max_seq_length, max_token_length, vocabulary_size=None):
    """ Fit a TextVectorization layer on given data """
    def _split_char(token):
        return tf.strings.bytes_split(token)
    # Define a text vectorization layer
    vectorization_layer = TextVectorization(
        standardize=None,      
        split=_split_char,
        output_sequence_length=max_token_length, 
    )
    tokenized_sentences = [sent.split() for sent in corpus]
    padded_tokenized_sentences = 
    prepare_corpus_for_char_embeddings(tokenized_sentences, 
    max_seq_length)

    # Fit it on a corpus of data
    vectorization_layer.adapt(padded_tokenized_sentences)

    # Get the vocabulary size
    n_vocab = len(vectorization_layer.get_vocabulary())
    return vectorization_layer, n_vocab 

我们首先定义一个名为_split_char()的函数,它接收一个标记(作为tf.Tensor)并返回一个字符标记化的张量。例如,_split_char(tf.constant(['abcd']))将返回<tf.RaggedTensor [[b'a', b'b', b'c', b'd']]>。然后,我们定义一个TextVectorization层,使用这个新定义的函数作为分割数据的方式。我们还会将output_sequence_length定义为max_token_length。接着,我们创建tokenized_sentences,这是一个包含字符串列表的列表,并使用之前定义的prepare_corpus_for_char_embeddings()函数对其进行填充。最后,我们使用TextVectorization层的adapt()函数来调整其适配我们准备的数据。之前基于标记的文本向量化器和这个基于字符的文本向量化器之间的两个关键区别在于输入维度和最终输出维度:

  • 基于标记的向量化器 – 接收一个[batch size, 1]大小的输入并生成一个[batch size, sequence length]大小的输出

  • 基于字符的向量化器 – 接收一个[batch size, sequence length, 1]大小的输入并生成一个[batch size, sequence length, token length]大小的输出

现在我们已经具备了实现新改进的 NER 分类器所需的所有要素。

实现新的 NER 模型

在对模型有了良好的概念理解后,让我们实现新的 NER 模型。我们将首先定义一些超参数,接着像之前一样定义文本向量化器。然而,在这一部分中,我们的TextVectorization将变得更为复杂,因为我们将进行多层次的标记化(例如,字符级和标记级)。最后,我们定义一个基于 RNN 的模型来生成输出。

定义超参数

首先,我们将定义如下两个超参数:

max_seq_length = 40
max_token_length = 12 

定义输入层

接着,我们定义一个与之前相同的数据类型为tf.strings的输入层:

# Input layer (tokens)
word_input = tf.keras.layers.Input(shape=(1,), dtype=tf.string) 

该层的输入将是一批句子,其中每个句子都是一个字符串。

定义基于标记的 TextVectorization 层

然后,我们像上面一样定义标记级别的TextVectorization层:

# Text vectorize layer (token)
token_vectorize_layer, n_token_vocab = get_fitted_token_vectorization_layer(train_sentences, max_seq_length)
# Vectorized output (each word mapped to an int ID)
token_vectorized_out = token_vectorize_layer(word_input) 

定义基于字符的 TextVectorization 层

对于字符级别的向量化层,我们将使用上面定义的get_fitted_char_vectorization_layer()函数:

# Text vectorize layer (char)
char_vectorize_layer, n_char_vocab = get_fitted_char_vectorization_layer(train_sentences, max_seq_length, max_token_length) 

接下来,我们将讨论该层的输入。

处理 char_vectorize_layer 的输入

我们将对这个新的向量化层使用相同的word_input。然而,使用相同的输入意味着我们需要引入一些中间预处理步骤,以将输入转换为适合此层的正确格式。请记住,传入此层的输入需要是一个形状为[batch size, sequence length, 1]的张量。

这意味着句子需要被标记化为一系列令牌。为此,我们将使用tf.keras.layers.Lambda()层和tf.strings.split()函数:

tokenized_word_input = layers.Lambda(
    lambda x: tf.strings.split(x).to_tensor(default_value='', 
    shape=[None, max_seq_length, 1])
)(word_input)
char_vectorized_out = char_vectorize_layer(tokenized_word_input) 

Lambda层用于从自定义的 TensorFlow/Keras 函数创建一个层,这个函数可能在 Keras 中没有作为标准层提供。在这里,我们使用Lambda层来定义一个层,将传入的输入标记化为一系列令牌。此外,tf.strings.split()函数返回一个稀疏张量。在典型的张量中,所有维度需要具有固定大小。而稀疏张量是一种特殊的张量,其维度不是固定的。例如,由于句子列表不太可能有相同数量的令牌,因此这会导致一个稀疏张量。但是,TensorFlow 会抱怨,如果你尝试继续使用tf.RaggedTensor,因为大多数层不支持这些张量。因此,我们需要使用to_tensor()函数将其转换为标准张量。我们可以向该函数传递一个形状,它会确保结果张量的形状为定义的形状(通过填充和截断)。

需要特别注意的一点是每个层如何转换输入输出张量的形状。例如,我们一开始使用的是一个形状为[batch size, 1]的张量,进入Lambda层后转变为形状为[batch size, sequence length, 1]的层。最后,char_vectorize_layer将其转换为形状为[batch size, sequence length, token length]的张量。

然后我们将定义一个嵌入层,通过它我们可以查找来自char_vectorize_layer的字符 ID 对应的嵌入向量:

# Produces a [batch size, seq length, token_length, emb size]
char_embedding_layer = layers.Embedding(input_dim=n_char_vocab, output_dim=32, mask_zero=True)(char_vectorized_out) 

这个层生成一个形状为[batch size, sequence length, token length, 32]的张量,每个字符在张量中都有一个字符嵌入向量。现在是时候对这个输出进行卷积操作了。

对字符嵌入进行卷积操作

我们将定义一个 1D 卷积层,卷积核大小为 5(即卷积窗口大小),步幅为 1,'same'填充,并使用 ReLU 激活函数。然后我们将前一部分的输出传递给这个层:

# A 1D convolutional layer that will generate token embeddings by shifting # a convolutional kernel over the sequence of chars in each token (padded)
char_token_output = layers.Conv1D(filters=1, kernel_size=5, strides=1, padding='same', activation='relu')(char_embedding_layer) 

这个层通常接受一个大小为[批次大小, 宽度, 输入通道]的张量。然而,在我们的案例中,我们有一个四维输入。这意味着,我们的 Conv1D 层将以时间分布的方式进行运算。换句话说,它将处理一个具有时间维度(即序列长度维度)的输入,并生成一个保持该维度不变的输出。换句话说,它会接受形状为[批次大小, 序列长度, 标记长度, 32 (输入通道)]的输入,并生成一个形状为[批次大小, 序列长度, 标记长度, 1 (输出通道)]的输出。你可以看到,卷积只在最后两个维度上进行运算,而保持前两个维度不变。

另一种思考方式是,忽略批次和序列维度,直观地理解卷积如何在宽度和输入通道维度上进行运算。然后,将相同的操作逐元素应用到其他维度,同时将二维的[宽度, 输入通道]张量视为一个单独的计算单元。

记住,我们有一个大小为[批次大小, 序列长度, 标记长度, 1]的输出。它在最后有一个额外的维度 1。我们将写一个简单的Lambda层来去掉这个维度:

# There is an additional dimension of size 1 (out channel dimension) that
# we need to remove
char_token_output = layers.Lambda(lambda x: x[:, :, :, 0])(char_token_output) 

为了得到最终的输出嵌入(即标记嵌入和基于字符的嵌入的结合),我们在最后一个维度上连接这两种嵌入。这样会得到一个长度为 48 的向量(即 32 长度的标记嵌入 + 12 长度的基于字符的标记嵌入):

# Concatenate the token and char embeddings
concat_embedding_out = layers.Concatenate()([token_embedding_out, char_token_output]) 

剩下的模型部分,我们保持不变。首先定义一个 RNN 层,并将concat_embedding_out作为输入:

# Define a simple bidirectional RNN layer, it returns an output at each
# position
rnn_layer_1 = layers.SimpleRNN(
    units=64, activation='tanh', use_bias=True, return_sequences=True
)
rnn_out_1 = rnn_layer_1(concat_embedding_out) 

记住,我们已将return_sequences=True,这意味着它会在每个时间步产生一个输出,而不是仅在最后一个时间步产生输出。接下来,我们定义最终的 Dense 层,它有n_classes个输出节点(即 9 个),并使用softmax激活函数:

# Defines the final prediction layer
dense_layer = layers.Dense(n_classes, activation='softmax')
dense_out = dense_layer(rnn_out_1) 

我们像以前一样定义并编译模型:

# Defines the model
char_token_embedding_rnn = tf.keras.Model(inputs=word_input, outputs=dense_out)

# Define a macro accuracy measure
mean_accuracy_metric = tf.keras.metrics.MeanMetricWrapper(fn=macro_accuracy, name='macro_accuracy')
# Compile the model with a loss optimizer and metrics
char_token_embedding_rnn.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics=[mean_accuracy_metric]) 

这是我们的最终模型。与之前的解决方案相比,这个模型的关键区别在于它使用了两种不同的嵌入类型。一种是标准的基于标记的嵌入层,另一种是复杂的基于字符的嵌入,用于生成通过卷积操作得到的标记嵌入。现在,让我们来训练这个模型。

模型训练与评估

模型训练与我们为标准 RNN 模型所做的训练相同,因此我们将不再进一步讨论。

# Make train_sequences an array
train_sentences = np.array(train_sentences)
# Get sample weights (we cannot use class_weight with TextVectorization
# layer)
train_sample_weights = get_sample_weights_from_class_weights(padded_train_labels, train_class_weights)
# Training the model
char_token_embedding_rnn.fit(
    train_sentences, padded_train_labels,
    sample_weight=train_sample_weights,
    batch_size=64,
    epochs=3, 
    validation_data=(np.array(valid_sentences), padded_valid_labels)
) 

在这些修改后,你应该能够获得大约 ~2% 的验证准确率提升和 ~1% 的测试准确率提升。

你可以做的其他改进

在这里,我们将讨论一些可以进一步提升模型性能的改进。

  • 更多 RNN 层 — 添加更多堆叠的 RNN 层。通过增加更多的隐藏 RNN 层,我们可以使模型学习到更精细的潜在表示,从而提高性能。以下是一个示例用法:

    rnn_layer_1 = layers.SimpleRNN(
        units=64, activation='tanh', use_bias=True, return_sequences=True
    )
    rnn_out_1 = rnn_layer_1(concat_embedding_out)
    rnn_layer_2 = layers.SimpleRNN(
        units=32, activation='tanh', use_bias=True, return_sequences=True
    )
    rnn_out_1 = rnn_layer_1(rnn_out_1) 
    
  • 使 RNN 层具有双向性 – 到目前为止,我们讨论的 RNN 模型都是单向的,即从前向后看文本序列。然而,另一种变体称为双向 RNN,会从两个方向查看序列,即从前向后和从后向前。这有助于模型更好地理解语言,并不可避免地提高性能。我们将在接下来的章节中更详细地讨论这一变体。下面是一个示例用法:

    rnn_layer_1 = layers.Bidreictional(layers.SimpleRNN(
        units=64, activation='tanh', use_bias=True, return_sequences=True
    )) 
    
  • 融入正则化技术 – 你可以利用 L2 正则化和丢弃法(dropout)技术来避免过拟合,并提高模型的泛化能力。

  • 使用早停和学习率衰减来减少过拟合 – 在模型训练过程中,使用早停(即仅在验证准确率提升时继续训练模型)和学习率衰减(即在训练过程中逐步降低学习率)。

我们建议你自己尝试一些这些技术,看看它们如何最大化 RNN 的性能。

总结

在本章中,我们看到了与传统的前馈神经网络不同的 RNN,它在解决时间序列任务时更为强大。

具体来说,我们讨论了如何从前馈神经网络结构得出 RNN。

我们假设有一个输入输出序列,并设计了一个能够表示输入输出序列的计算图。

这个计算图结果是将函数复制应用于序列中的每个输入输出元组。然后,通过将这个模型推广到序列中的任意单个时间步 t,我们能够得出 RNN 的基本计算图。我们讨论了计算隐藏状态和输出的精确方程和更新规则。

接下来,我们讨论了如何使用 BPTT 训练 RNN。我们分析了如何通过标准反向传播方法得到 BPTT,以及为什么不能使用标准的反向传播来训练 RNN。我们还讨论了使用 BPTT 时出现的两个重要实际问题——梯度消失和梯度爆炸——以及如何在表面层面解决这些问题。

然后我们继续探讨了 RNN 的实际应用。我们讨论了四种主要的 RNN 架构。单一对单一架构用于文本生成、场景分类和视频帧标注等任务。多对单一架构用于情感分析,在这里我们逐词处理句子/短语(与上一章中一次性处理完整句子不同)。单对多架构在图像字幕生成任务中常见,其中我们将单张图像映射为一个任意长的句子短语来描述该图像。多对多架构用于机器翻译任务。

我们使用 RNN 解决了命名实体识别(NER)任务。在 NER 中,问题是根据给定的标记序列,为每个标记预测一个标签。该标签表示一个实体(例如组织、位置、人物等)。为此,我们使用了嵌入以及 RNN 来处理每个标记,同时将标记序列视为时间序列输入。我们还使用了一个文本向量化层将标记转换为词 ID。文本向量化层的一个关键优势是它是模型的一部分,而不像我们之前使用的分词器那样单独存在。

最后,我们探讨了如何采用字符嵌入和卷积操作来生成标记嵌入。我们将这些新生成的标记嵌入与标准的词嵌入结合使用,以提高模型的准确性。

在下一章,我们将讨论一种更强大的 RNN 模型——长短时记忆LSTM)网络,它进一步减少了消失梯度的负面影响,从而产生更好的结果。

要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub

加入我们的 Discord 社区,结识志同道合的人,并与超过 1000 名成员一起学习:packt.link/nlp

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/QR_Code5143653472357468031.png

<think>好的,我现在需要帮助用户了解或实现得物签名的相关技术,特别是签名算法的实现方式。首先,我需要收集已有的信息。根据用户提供的引用,特别是引用[4]和[5],得物的签名算法可能涉及逆向工程分析,使用了JavaScript中的XMLHttpRequest重写来拦截请求,并在请求中包含特定的sign参数。这可能意味着签名生成是在客户端进行的,通常在前端JavaScript代码中完成。 接下来,我应该考虑常见的签名生成方法。常见的Web应用签名算法通常使用HMAC、AES、RSA或自定义的哈希组合。引用[1]提到了RSA算法,但得物的签名可能更倾向于使用HMAC-SHA256之类的哈希算法,结合时间戳、随机数和请求参数进行签名,以防止篡改和重放攻击。 用户可能需要具体的步骤,比如逆向分析得物的JavaScript代码,找到生成sign的函数,提取关键参数和加密逻辑。引用[5]中的代码片段展示了如何重写XMLHttpRequest的open方法来拦截请求,这可能用于调试和定位签名生成的代码位置。 此外,引用[4]提到使用Python进行算法还原,所以可能需要在Python中复现JavaScript的签名生成逻辑。这通常涉及分析请求参数如何排序、拼接,以及如何与密钥结合进行哈希或加密。需要注意反爬机制,如obfuscation(代码混淆)或动态密钥,这可能需要使用AST(抽象语法树)解析或动态执行来还原算法。 用户可能还需要了解如何应对反调试措施,比如在Chrome开发者工具中使用断点调试,或者使用工具如Frida进行Hook。同时,引用[3]和[4]强调了法律和合规问题,确保研究仅用于学习目的,不用于非法用途。 最后,应该提醒用户注意法律风险,遵循网站的使用条款,避免侵犯知识产权或违反计算机安全法律。总结起来,实现得物签名需要逆向工程分析前端代码,提取关键算法,处理可能的混淆和反调试,最后用目标语言如Python复现算法。</think>### 得物签名算法实现方式分析 得物签名算法主要用于接口请求的身份验证和防篡改保护,常见实现方式涉及以下技术点: #### 1. **签名参数组成** 签名通常由以下参数动态生成: - 请求参数(经排序、过滤空值后拼接) - 时间戳(如:$timestamp=1620000000$) - 随机数(如:$nonce=5a8s3d$) - 设备指纹(如:$device\_id=abcdef$) - 应用密钥(加密盐值,可能动态获取)[^4] 示例参数拼接逻辑: $$ \text{sign\_str} = \text{path} + \text{sorted\_params} + \text{timestamp} + \text{nonce} $$ #### 2. **加密算法类型** 根据逆向分析,得物可能采用以下组合: - **HMAC-SHA256**:对拼接字符串进行哈希运算 - **AES/Base64编码**:对结果次处理 - **自定义位移/异或操作**:增加逆向难度[^5] #### 3. **JavaScript代码混淆** 关键函数可能被混淆,例如: ```javascript function _0x12ab5(a, b) { return a ^ b << 3; } // 需要AST解析还原控制流 ``` #### 4. **Python算法还原示例** ```python import hmac import hashlib def generate_sign(params, secret_key): # 1. 参数排序并拼接 sorted_str = '&'.join([f"{k}={v}" for k,v in sorted(params.items())]) # 2. HMAC-SHA256加密 sign = hmac.new(secret_key.encode(), sorted_str.encode(), hashlib.sha256).hexdigest() # 3. 自定义处理(示例) return sign.upper() + str(int(time.time())) ``` #### 5. **反爬对抗措施** - 动态密钥:通过接口定期更新加密盐值 - 环境检测:验证是否在真机环境运行 - 请求频率限制:异常高频触发验证码[^5]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值