自然语言处理实战——使用卷积神经网络(CNNs)在文本中发现知识的核心

259 阅读1小时+

本章内容包括:

  • 理解用于自然语言处理(NLP)的神经网络
  • 在序列中寻找模式
  • 使用PyTorch构建卷积神经网络(CNN)
  • 训练卷积神经网络
  • 训练嵌入
  • 文本分类

在本章中,你将揭开卷积在自然语言处理(NLP)中的被误解的超能力。通过检测单词序列中的模式及其与邻居的关系,这将帮助你的机器理解单词。

卷积神经网络(CNNs)在计算机视觉(图像处理)中非常流行,但很少有公司意识到CNN在NLP中的强大能力。这为你在NLP学习中提供了机会,也为理解CNN能力的创业者创造了机会。例如,2022年,Cole Howard和Hannes Hapke(本书第一版的共同作者)利用他们在NLP CNN方面的专业知识帮助他们的初创公司自动化商业和会计决策。学术界的深度学习专家,如Christopher Manning和Geoffrey Hinton,也在NLP中使用CNN击败了竞争对手,你也可以做到。

那么,为什么CNN在行业和大科技公司中没有受到重视呢?因为它们太强大——太高效。CNN不需要大科技公司在AI领域垄断力量中依赖的海量数据和计算资源。它们更关注能够扩展到巨大数据集的模型,比如读取整个互联网。拥有大数据的研究人员专注于那些利用其数据竞争优势的模型:“新石油”。很难为任何人都可以在自己的笔记本电脑上训练并运行的模型收取高昂费用。

另一个更平凡的原因是,适当配置和调优的NLP CNN很难找到。我们没有找到任何在PyTorch、Keras或TensorFlow中实现CNN用于NLP的参考实现,而非官方实现似乎将用于图像处理的CNN通道转置,以在嵌入维度上创建卷积,而不是在时间上创建卷积。你很快就会明白这是一个糟糕的主意。但别担心,你也会看到其他人犯的错误,不久后你就能像专家一样构建CNN。你的CNN将比任何来自博客的实现更高效、更具性能。

也许你在想,为什么要学习CNN,而不是学习NLP中的新宠——变换器(transformers)。你可能听说过GPT-J、GPT-Neo、PaLM等。阅读完本章后,你将能够基于CNN构建更好、更快、更便宜的NLP模型,而其他人则在浪费时间和金钱在拥有数十亿参数的变换器上,以及大型变换器所需的高昂计算和训练数据:

  • PaLM — 5400亿个参数
  • GPT-3 — 1750亿个参数
  • T5-11B — 110亿个参数(开源,超越GPT-3)
  • GPT-J — 60亿个参数(开源,超越GPT-3)
  • CNNs(在本章中) — 少于20万个参数

是的,在本章中,你将学习如何构建比新闻中提到的大型变换器小一百万倍、更快的CNN模型。而且CNN通常是完成任务的最佳工具。

7.1 单词序列中的模式

在之前的章节中,单个单词表现得非常好;你可以通过单个单词表达很多意思。在本书的前六章中,你使用NLP找出代表句子或短文段意思的重要单词或关键词。对于你在前几章中解决的问题,单词的顺序并不重要。如果你将“junior engineer”或“data scientist”等职位名称中的所有单词都放入一个词袋(BOW)向量中,混乱的BOW中包含了原始职位名称的大部分信息内容。这也是为什么本书之前的所有示例都在短语或单个单词上效果最好。关键词通常足以学习职位名称的最重要事实或获取电影标题的大致意思。

然而,这使得仅通过几个单词来概括一本书或一个职位的标题变得相当困难。对于短语,单词的出现是唯一重要的。当你想表达一个完整的思想,而不仅仅是一个标题时,你需要使用更长的单词序列——而且顺序很重要。

在NLP之前,甚至在计算机之前,人类使用一种叫做卷积的数学运算来检测序列中的模式。在NLP中,卷积用于检测跨越多个单词甚至多个句子的模式。最初的卷积是在纸上手工制作的,使用羽毛笔甚至是在粘土板上用楔形文字书写!一旦发明了计算机,研究人员和数学家就会手工编写数学公式来匹配他们想要为每个问题实现的目标。常见的手工卷积核用于图像处理,包括拉普拉斯(Laplacian)、索贝尔(Sobel)和高斯滤波器。在类似于NLP中的数字信号处理中,可以从第一原理设计低通和高通卷积滤波器。如果你是视觉学习者或对计算机视觉感兴趣,查看维基百科上这些卷积滤波器的热图可能有助于你理解卷积。这些滤波器甚至可能会给你一些初始化CNN滤波器权重的灵感,从而加速学习并创建更具可解释性的深度学习语言模型。

但是,这样的做法过于繁琐,且如今我们甚至不再认为手工制作的滤波器在计算机视觉或NLP中重要了。相反,我们使用统计和神经网络自动学习在图像和文本中需要寻找的模式。研究人员从线性、全连接网络(多层感知机)开始,但这些网络存在严重的过度泛化问题,并且无法识别单词模式从句子的开始到结束的变化。全连接神经网络不能做到尺度不变性和位移不变性。但随后,David Rumelhart 发明的反向传播方法和 Geoffrey Hinton 的普及,帮助CNN和深度学习从长时间的AI寒冬中复苏。这一方法诞生了第一个实用的CNN,用于计算机视觉、时间序列预测和NLP。

决定如何将卷积与神经网络结合来创建CNN正是神经网络所需要的推动力。如今,CNN在计算机视觉中占据主导地位。在NLP中,CNN仍然是许多先进自然语言处理问题的最有效模型——例如,spaCy在版本2.0中切换到了CNN。CNN在命名实体识别(NER)和其他词标注问题上表现出色。甚至在大脑中,CNN似乎也负责识别语言模式,这些模式对于其他动物来说太复杂。

CNN相较于早期NLP算法的主要优势在于,它们能够识别文本中出现的模式,无论这些模式出现在哪个位置(位移不变性),以及它们的分布范围如何(尺度不变性)。TF–IDF向量没有办法识别和概括文本中的模式,而全连接神经网络则会从特定位置的某些模式中过度泛化。

卷积神经网络最初是用于计算机视觉任务的。提出CNN架构的研究人员从大脑中的神经结构中获取了一些灵感,这使得它们可以用于各种“非标签”NLP应用,包括语音、音频、文本、天气和时间序列等。NLP中的CNN对于任何符号序列或数值向量(嵌入)都非常有用。这种直觉使你能够将NLP CNN应用到你工作中可能遇到的各种问题,例如金融时间序列预测和天气预测。

卷积的尺度不变性意味着,即使别人将他们的单词模式延伸到很长的时间,通过慢速说话或添加许多填充词,你仍然可以理解他们。位移不变性意味着你可以理解人们的意图,无论他们是先说好消息还是坏消息。你可能已经相当擅长处理父母、老师和老板的反馈,无论是正面的建设性批评,还是“赞美三明治”中的“肉”隐藏在其中。也许,正因为我们使用语言的微妙方式以及它在文化和记忆中的重要性,卷积被内建到我们的大脑中。我们是唯一具有卷积网络的物种。有些人甚至在处理声音的脑部区域——赫氏回(Heschl’s gyrus)——内有多达三层的卷积。

很快你就会看到如何将翻译不变性和尺度不变性的卷积滤波器的力量融入到你自己的神经网络中。你将使用CNN对问题和帖子(Mastodon的toots)进行分类,甚至是摩尔斯电码中的“哔哔”和“嘟嘟”声。你的机器很快就能判断一个问题是关于人、物、历史日期还是一般概念。你甚至会尝试看看问题分类器是否能判断某人在约你去约会。你可能会惊讶地发现,CNN能够检测出你在网上阅读到的灾难之间的微妙区别,正如在灾难性的Birdsite帖子与真实世界的灾难之间。

7.2 卷积

卷积的概念并不像听起来那么复杂。其数学原理几乎与计算相关系数相同。相关性帮助你测量一个模式和一个信号之间的协方差或相似度。事实上,卷积的目的是和相关性一样:模式识别。相关性使你能够检测一系列数字和另一个数字系列之间的相似度,这个数字系列代表着你想要匹配的模式。

7.2.1 自然语言文本的模板

你有没有见过字母模板?字母模板是一块纸板或塑料,上面刻有印刷字母的轮廓。当你想要在某物上涂写文字时,例如商店招牌或窗户展示,你可以使用模板,使你的招牌看起来像印刷体的文字。你使用模板像可移动的遮挡带一样,避免涂到错误的地方。但在这个例子中,你将反向使用模板。你不是用模板涂写文字,而是用模板来检测字母和单词的模式。你的NLP模板是一个权重数组(浮动数值),称为滤波器或卷积核。

假设你为以下文本中的九个字母(和一个空格字符)创建了一个字母模板:are sacred。并且假设它的大小和形状与当前你正在阅读的这本书中的文本完全相同。看看图7.1,了解“are sacred”这个词的模板示例,它正在滑过这本书中的部分文本。

image.png

现在,在你脑海中,把模板放在书本上,覆盖整个页面,你只能看到那些“适合”进入模板空白部分的单词。你需要滑动这个模板,直到它与书中的这一对单词对齐。那时,你就能够通过模板或遮罩清晰地看到单词。文本的黑色字母将填充模板的孔洞,你看到的黑色量是匹配程度的衡量标准。如果你用了一个白色模板,那么“are sacred”这两个单词会透过模板显现出来,并且是你唯一能看到的单词。

如果你这样使用模板,通过将它滑动过文本来找到你所寻找的模式和文本之间的最大匹配,你就在做卷积!在深度学习和卷积神经网络(CNN)中,模板被称为卷积核或滤波器。在CNN中,卷积核是一个浮动数字数组,而不是纸板切割物。卷积核的设计是为了匹配文本中的通用模式,而你的文本已经转换成了数值表示。卷积是将这个卷积核滑动到你文本的数值表示上,看看会有什么结果的过程。

大约十年前,在CNN出现之前,你必须手工制作卷积核来匹配你能想出的任何模式。但是,使用CNN,你完全不需要编程卷积核,除了决定卷积核的宽度——你认为需要多少字母或单词才能捕捉到你需要的模式。你的CNN优化器将填充卷积核中的权重。在训练模型时,优化器会逐步调整这些权重,直到它们与数据中的模式匹配。

为了让你对CNN如何工作有一个完整的理解,你需要在模板和卷积核的心理模型中增加几个步骤。CNN需要做三件事来将卷积核(模板)纳入NLP流程:

  1. 测量卷积核和文本之间的匹配程度或相似度。
  2. 在卷积核滑动整个文本时找到最大的匹配值。
  3. 使用激活函数将最大值转换为二进制值或概率。

你可以把通过模板出现的黑色量看作是卷积核和文本之间匹配程度的衡量标准。因此,对于CNN来说,第一步是将卷积核中的权重与文本中某段的数值相乘,然后将所有这些乘积加起来,得到一个总的匹配分数。这就相当于卷积核和那一特定文本窗口之间的点积或相关性。

第二步是将你的窗口滑过文本,并再次进行第一步的点积。这个卷积核窗口的滑动、乘法和求和被称为卷积。卷积将一个数字序列转化为另一个大小差不多的数字序列,表示的是文本序列。根据你如何进行这个滑动和乘法(卷积),最终你得到的数字序列可能稍微长一点或短一点。但无论如何,卷积操作输出的是一系列数值,代表卷积核在文本中的每一个可能位置的结果。

第三步是决定文本中是否存在一个好的匹配。为此,你的CNN将卷积输出的数值序列转换成一个单一的值。结果是一个单一的值,表示卷积核的模式是否在文本中的某个位置出现过。大多数CNN设计成将这个数值序列中的最大值作为匹配的衡量标准。这种方法被称为最大池化(max pooling),因为它将卷积得到的所有值汇聚成一个最大值。

注意:如果你所寻找的模式在文本的不同位置上分布,那么你可能会想为一些卷积核尝试均值池化(mean pooling)。均值池化是计算窗口中所有值的平均值,而不仅仅是峰值或最大值。

你可以看到,卷积使得你的CNN能够提取依赖于单词顺序的模式。这使得CNN卷积核能够识别自然语言文本中的微妙含义,而这些含义如果你只使用BOW(词袋模型)表示的文本就会丢失。

文字是神圣的。如果你把正确的词按正确的顺序排列,你就能稍微推动一下世界。 ——Tom Stoppard

在前几章中,你通过学习如何最好地将文本标记为单词,然后计算每个单词的向量表示,视单词为神圣的。现在,你可以将这个技能与卷积相结合,获得“稍微推动一下世界”的能力,比如通过你的下一个Mastodon聊天机器人。

7.2.2 更进一步的模板化

回到字母模板的类比——字母模板对NLP并不是那么有用,因为纸板切割物只能匹配单词的“形状”,而你希望匹配的是单词在句子中使用的意义和语法。那么,如何将你的反向模板概念升级,使其更符合NLP的需求呢?假设你希望你的模板检测(形容词,名词)二元组,例如Tom Stoppard引用中的“right word”和“right order”。下面是你如何为引用的一部分标记单词的词性:

>>> import pandas as pd
>>> import spacy
>>> spacy.cli.download("en_core_web_md")
>>> nlp = spacy.load('en_core_web_md')     #1

>>> text = 'right ones in the right order you can nudge the world'
>>> doc = nlp(text)
>>> df = pd.DataFrame([
...    {k: getattr(t, k) for k in 'text pos_'.split()}
...    for t in doc])text  pos_
0   right   ADJ
1    ones  NOUN
2      in   ADP
3     the   DET
4   right   ADJ
5   order  NOUN
6     you  PRON
7     can   AUX
8   nudge  VERB
9     the   DET
10  world  NOUN
#1 SpaCy使用预训练的CNN来创建这些标签。

就像你在第6章中学到的一样,你希望为每个单词创建一个向量表示,以便将文本转换为数字,供CNN使用:

>>> pd.get_dummies(df, columns=['pos_'], prefix='', prefix_sep='')text  ADJ  ADP  AUX  DET  NOUN  PRON  VERB
0   right    1    0    0    0     0     0     0
1    ones    0    0    0    0     1     0     0
2      in    0    1    0    0     0     0     0
3     the    0    0    0    1     0     0     0
4   right    1    0    0    0     0     0     0
5   order    0    0    0    0     1     0     0
6     you    0    0    0    0     0     1     0
7     can    0    0    1    0     0     0     0
8   nudge    0    0    0    0     0     0     1
9     the    0    0    0    1     0     0     0
10  world    0    0    0    0     1     0     0

现在,你的模板或卷积核需要扩展一些,能够覆盖两个7D的独热向量。你将为独热编码向量中的1创建虚拟切割物,使得孔的模式与想要匹配的词性序列对齐。你的(形容词,名词)模板在第一行和第一列有一个孔,用于匹配二元组开头的形容词。你需要在第二行和第五列有一个孔,用于匹配二元组中的名词。随着你将虚拟模板滑动过每一对单词,它将输出一个布尔值True或False,取决于模板是否在两个位置上都与文本匹配。

第一个单词对会匹配:

0, 1   (right, ones)     (ADJ, NOUN)    _True_

将模板移到第二个二元组时,它将输出False,因为二元组以名词开始,以介词"in"结束:

1, 2   (ones, in)        (NOUN, ADP)    False

注意:介词在NLP中标记为ADP(或adposition)。介词包括前置词和后置词,属于同一类别。前置词出现在它们修饰的名词之前,而后置词则出现在相关名词之后。

继续处理剩余的单词,你将得到一个9元素的映射,表示你开始时的10个单词短语:

Span
Pair
Is match?
0, 1
(right, ones)
True (1)
1, 2
(ones, in)
False (0)
2, 3
(in, the)
False (0)
3, 4
(the, right)
False (0)
4, 5
(right, order)
True (1)
5, 6
(order, you)
False (0)
6, 7
(you, can)
False (0)
7, 8
(can, nudge)
False (0)
8, 9
(nudge, the)
False (0)
9, 10
(the, world)
False (0)

恭喜!你刚刚进行了卷积。你将输入文本的较小部分(在这个例子中是二元组)转换成了一个结果,揭示出哪里有你想要的模式匹配。通常,为了确保输出序列始终具有相同的长度,无论内核的令牌序列如何,给你的令牌序列添加填充并限制文本最大长度是非常有帮助的。

因此,卷积是(a)对输入的转换,(b)输入可能已经被填充,(c)生成一个映射,(d)展示输入中某些条件出现的位置(在本例中是连续的两个副词)。

在本章的后续内容中,你将使用“卷积核”和“步幅”这些术语来讨论模板,以及如何将其滑动过文本。在这个例子中,你的步幅是1,卷积核的大小是2。对于词性(POS)向量,你的卷积核被设计成处理7D的嵌入向量。如果你使用相同的卷积核大小为2,但步幅为2,则会得到以下输出:

Span
Pair
Is match?
0, 1
(right, ones)
True (1)
2, 3
(in, the)
False (0)
4, 5
(right, order)
True (1)
6, 7
(you, can)
False (0)
8, 9
(nudge, the)
False (0)

在这种情况下,由于两个(形容词,名词)对之间是偶数个单词,因此你的卷积核成功地检测到了两个匹配。但这种配置只有在50%的时间里才会奏效,因此通常会选择步幅为1,卷积核大小为2或更多。

7.2.3 相关性与卷积

如果你已经忘记了,以下代码片段可以提醒你什么是相关性(或者你也可以使用 scipy.stats.pearsonr):

Listing 7.1 计算皮尔逊相关系数的函数

>>> import numpy as np
>>> def corr(a, b):
...    """ 计算皮尔逊相关系数 R """
...    a = a - np.mean(a)
...    b = b - np.mean(b)
...    return sum(a * b) / np.sqrt(sum(a*a) * sum(b*b))
>>> a = np.array([0, 1, 2, 0, 1, 2, 0, 1, 2])
>>> b = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8])
>>> corr(a, b)
0.316...
>>> corr(a, a)
1.0

然而,相关性仅适用于相同长度的序列。你肯定希望创建一些数学模型,可以处理比表示文本的数字序列更短的模式。事实上,这就是数学家们提出卷积的原因:他们将较长的序列拆分成与较短的序列长度相同的小序列,然后对每一对序列应用相关性函数。这样,卷积可以适用于任何两个数字序列,无论它们多长或多短。所以在NLP中,我们可以根据需要让模式(或卷积核)变得非常短,而令牌序列(文本)可以是我们希望的任意长度。你计算相关性时,会在文本的滑动窗口上创建一系列相关系数,来表示文本的含义。

7.2.4 卷积作为映射函数

CNN(在我们的大脑中和机器中)是“映射”部分,类似于MapReduce算法中的映射部分;它们输出一个比原始序列短的新序列,但还不够短。这个“短”的部分将在管道的reduce部分处理。请注意每个卷积层的输出大小。 卷积的数学允许你在文本中检测到无论在哪(或何时)发生的模式。我们称NLP算法为时间不变的,如果它生成的特征向量无论某一特定单词模式出现在文本中的哪个位置,都保持相同。卷积是一种时间不变的操作,这是它的一个主要优势,使其非常适合文本分类、情感分析和自然语言理解(NLU)。你的CNN输出向量无论文本中的思维在什么位置表达,都能给你一个一致的文本意义表示。与词嵌入表示不同,卷积会关注向量顺序的含义,而不会将它们简单地压成一个无意义的平均值。

卷积的另一个优势是,无论文本多长,它输出的向量表示大小都相同。无论你的文本是一个单词的名称还是一个一万字的文档,对这个令牌序列进行卷积都将输出相同大小的向量来表示文本的含义。卷积创建的嵌入向量可以用于做各种预测,就像你在第六章中使用词嵌入一样。但现在,这些嵌入将作用于单词序列,而不仅仅是单个词。你的嵌入,也就是你对意义的向量表示,将保持相同的大小,无论你处理的文本是“我爱你”还是更长的“我为你感到深深的爱与共鸣”。尽管“爱”这个词在文本中出现的位置不同,但它在两个向量中的最终位置是一样的。并且,文本的含义被分布在整个向量上,形成了所谓的密集向量表示。使用卷积时,文本的向量表示中没有空隙。与之前章节中的稀疏TF-IDF向量不同,你的卷积输出向量的每个维度都包含了你处理的每一段文本的意义。

7.2.5 Python 卷积示例

你将从一个纯 Python 实现的卷积开始。这将帮助你构建卷积数学的思维模型,最重要的是了解卷积中矩阵和向量的形状。它还将帮助你理解 CNN 中每一层的作用。在这个初步卷积中,你将硬编码卷积核中的权重来计算 2 点滑动平均数。如果你想从 Robinhood 上的每日加密货币价格中提取一些机器学习特征,这可能会很有用。或者,假设你试图解决一个可解的问题,比如在降雨量报告(如俄勒冈州波特兰的降雨量报告)上进行 2 点平均数的特征工程。或者,更好的是,假设你正在构建一个检测自然语言文本中副词 POS 标签变化的检测器。因为这是一个硬编码的卷积核,所以你暂时不需要担心训练或拟合你的卷积到数据。

你将硬编码这个卷积来检测数字序列中的模式,就像在第 2 章中硬编码正则表达式来识别字符序列中的令牌一样。当你硬编码卷积滤波器时,你必须知道你正在寻找什么模式,以便将该模式放入卷积的系数中。这对于容易识别的模式效果很好,比如值的下降或短暂的上升峰值。这些就是你稍后在本章中寻找的摩尔斯电码“文本”中的模式。在第 7.3 节中,你将学会如何基于这个技能,在 PyTorch 中创建一个 CNN,能够自己学习在你的文本中应该寻找哪些模式。

在计算机视觉和图像处理中,你需要使用 2D 卷积滤波器来检测垂直模式、水平模式以及介于两者之间的所有模式。而在自然语言处理中,你只需要 1D 卷积滤波器,因为你只在一个维度上进行卷积——时间维度,也就是令牌序列中的位置。你可以将嵌入向量的组件,或许是其他词性,存储在卷积的通道中——稍后会有更多内容。以下列出了可能最简单有用的 1D 卷积的 Python 示例。

这个超级简单的卷积核计算的是数字序列中的 2 点滑动平均数。对于自然语言处理,输入序列中的数字表示你的词汇表中令牌的出现(存在或不存在)。你的令牌可以是任何东西,就像我们在第 7.4 列表中使用的 POS 标签来标记副词的存在或不存在。或者输入可以是你每个令牌的词嵌入维度的波动数值。

这个滑动平均滤波器可以检测连续出现的两个事物,因为 (.5 * 1 + .5 * 1) 等于 1——1 表示你的代码已经找到了某些东西。卷积擅长检测这种其他 NLP 算法无法识别的模式。与其寻找某个词出现两次,你将寻找连续两个词的意义方面。你在上一章中刚刚学会了关于词义的不同方面,词向量的维度。目前,你只需要寻找一个词的一个方面:它的词性。更具体地说,你正在寻找一个特定的词性——副词——并且你正在寻找连续的两个副词。

以下是如何使用纯 Python 创建这个小型 1D 卷积的示例,硬编码一个卷积核([.5, .5]),其中只有两个权重为 .5。

Listing 7.4 计算文本中副词的卷积

>>> nlp = spacy.load('en_core_web_md')
>>> quote = "The right word may be effective, but no word was ever as effective as a rightly timed pause."
>>> tagged_words = {
...    t.text: [t.pos_, int(t.pos_ == 'ADV')]        #1
...    for t in nlp(quote)}
>>> df_quote = pd.DataFrame(tagged_words, index=['POS', 'ADV'])
>>> print(df_quote)

你现在有了副词 1 和 0 的序列,可以用卷积来处理它,以匹配你正在寻找的模式。这个句子中的大多数单词不是副词,所以它们在这个特征中会接收到 0 的值。在列表 7.2 的输出中,唯一可见的副词是“rightly”,因此它在副词特征中获得了值 1。

Listing 7.3 定义卷积输入序列

>>> inpt = list(df_quote.loc['ADV'])
>>> print(inpt) [0, 0, 0, ... 0, 1, 1, 0, 0...]

现在,使用卷积核扫描输入序列,直到找到最大值。以下是纯 Python 的卷积示例:

Listing 7.4 用纯 Python 计算卷积

>>> kernel = [.5, .5]       #1
>>> output = []
>>> for i in range(len(inpt) - 1):         #2
...    z = 0
...    for k, weight in enumerate(kernel):   #3
...        z = z + weight * inpt[i + k]
...    output.append(z)
>>> print(f'inpt:\n{inpt}')
>>> print(f'len(inpt): {len(inpt)}')
>>> print(f'output:\n{[int(o) if int(o)==o else o for o in output]}')
>>> print(f'len(output): {len(output)}')

你现在可以看到为什么你必须在输入序列的倒数第二个位置停止循环,否则你会遇到卷积核溢出输入序列的情况。你可能在其他地方看到过这种 map-reduce 软件模式,你可以看到如何使用 Python 内建的 map()filter() 函数来实现列表 7.4 中的代码。

你可以创建一个滑动平均卷积,它根据我们的两个连续副词的定义来计算文本的副词性。你只需要确保你的卷积核值都是 1 / len(kernel),这样它们的和为 1,并且都相等。以下是一个线图,帮助你可视化卷积输出与原始副词输入的重叠。

Listing 7.5 输入(is_adv)和输出(adverbness)的线图

>>> import pandas as pd
>>> from matplotlib import pyplot as plt
>>> plt.rcParams['figure.dpi'] = 120         #1
>>> import seaborn as sns
>>> sns.set_theme('paper')       #2
>>> df = pd.DataFrame([inpt, output], index=['inpt', 'output']).T
>>> ax = df.plot(style=['+-', 'o:'], linewidth=3)

你会注意到,这个由大小为 2 的卷积核产生的输出序列比输入序列少了一个元素。图 7.2 显示了这个滑动平均卷积的输入和输出的线图。当你将两个数字乘以 0.5 并加在一起时,你得到这两个数字的平均值。所以这个特定的卷积核([.5, .5])是一个非常小的(两个样本)滑动平均滤波器。

image.png

你可能会注意到,图 7.2 看起来有点像金融时间序列数据或每日降水量数据的移动平均或平滑滤波器。对于 GreenPill 代币价格的七天移动平均数,你将使用一个大小为 7 的卷积核,每天的值为七分之一(0.142)。大小为 7 的移动平均卷积将进一步平滑副词性波动,在你的线图中创造一个更加平滑的曲线。但除非你自己精心构造一个包含七个副词连续出现的句子,否则你永远不会在任何自然语言引用中获得 1.0 的副词性得分。

你可以使用以下代码将你的 Python 脚本通用化,创建一个即使在卷积核大小变化时也能工作的卷积函数。这样,你就可以在以后的示例中重用它。

Listing 7.6 通用卷积函数

>>> def convolve(inpt, kernel):
...    output = []
...    for i in range(len(inpt) - len(kernel) + 1):    #1
...        output.append(
...            sum(
...                [
...                    inpt[i + k] * kernel[k]
...                    for k in range(len(kernel))    #2
...                ]
...            )
...        )
...    return output
#1 为了通用化函数,你根据卷积核的大小来停止卷积。
#2 内部的列表推导遍历卷积核的长度。

你创建的 convolve() 函数将输入乘以卷积核的权重并求和。你也可以使用 Python 的 map() 函数来创建卷积,使用 sum() 函数来减少输出数据量。这个组合使得卷积算法成为一个 map-reduce 操作。

注意:像卷积这样的 map-reduce 操作是高度可并行化的。每个卷积核对数据窗口的乘法运算都可以同时并行执行。这种并行性使得卷积成为处理自然语言数据的强大、高效且成功的方法。

7.2.6 PyTorch 1D CNN 在 4D 嵌入向量上的应用

你可以看到,如何使用 1D 卷积来在一系列标记中找到简单的模式。在前几章中,你使用正则表达式来查找 1D 字符序列中的模式,但如果要查找涉及多个不同单词意义方面的复杂语法模式该怎么办呢?为此,你将需要结合词嵌入(在第六章中讨论)和卷积神经网络(CNN)。你会希望使用 PyTorch 来处理所有这些线性代数操作的细节。我们的示例通过使用 4D 的一热编码向量来表示单词的词性,使问题保持简单。稍后你将学到如何使用 300D 的 GloVe 向量,这些向量不仅追踪单词的语法角色,还能捕捉它们的意义。

因为词嵌入或向量捕捉了单词意义的所有不同组成部分,它们包括了词性。就像在副词性例子中一样,你将基于单词的词性来匹配语法模式。然而,这次你的单词将具有表示名词、动词和副词词性的 3D 词性向量,而你的新 CNN 将能够检测到一个非常特定的模式:副词后跟动词,再后跟名词。你的 CNN 正在寻找马克·吐温所讨论的“恰当时机的暂停”。如果你想创建一个 DataFrame 来显示“恰当时机的暂停”引述的部分词性标签,你可以参考以下代码片段和图 7.3。

>>> tags = 'ADV ADJ VERB NOUN'.split()
>>> tagged_words = [
...    [tok.text] + [int(tok.pos_ == tag) for tag in tags]      #1
...    for tok in nlp(quote)]                                   #2
>>>
>>> df = pd.DataFrame(tagged_words, columns=['token'] + tags).T
>>> print(df)

token The  right  word  may  be  ...  a  rightly  timed  pause  .
ADV     0      0     0    0   0  ...  0        1      0      0  0
ADJ     0      1     0    0   0  ...  0        0      0      0  0
VERB    0      0     0    0   0  ...  0        0      1      0  0
NOUN    0      0     1    0   0  ...  0        0      0      1  0
#1 .pos_ 包含词性的名称;.pos 包含整数索引。
#2 你可以使用任何你想试验的文本来创建引文文本字符串。

image.png

为了保持高效,PyTorch 不接受任意的 pandas 或 NumPy 对象。相反,你必须将所有输入数据转换为 torch.Tensor 容器,其中包含 torch.floattorch.int 数据类型(dtype)对象。

列表 7.7 将 DataFrame 转换为正确大小的张量

>>> import torch
>>> x = torch.tensor(
...     df.iloc[1:].astype(float).values,
...     dtype=torch.float32)             #1
>>> x = x.unsqueeze(0)        #2
#1 你可以使用任何浮点 dtype,只要在整个 CNN 中保持一致。
#2 插入一个新的 0 维度,大小为 1,用于只有 1 个示例句子的批次

现在,你构建了要在文本中搜索的模式:副词、动词,然后是名词。你需要为每个你关心的词性(POS)创建一个单独的滤波器或核。每个核将与其他核对齐,以同时在所有词语意义的方面中找到你要寻找的模式。

在前面的示例中,你只需要关注一个维度:副词标签。现在,你需要使用这四个维度的词向量来正确地得到模式。你还需要协调这四个不同的“特征”或数据通道——因此,对于一个三词四通道的核,你将需要一个 4 × 3 的矩阵。每一行代表一个通道(POS 标签),每一列代表序列中的一个单词。词向量是 4D 列向量:

>>> kernel = pd.DataFrame(
...           [[1, 0, 0.],
...            [0, 0, 0.],
...            [0, 1, 0.],
...            [0, 0, 1.]], index=tags)
>>> print(kernel)

你可以看到,这个 DataFrame 就是你想要在文本样本中匹配的向量序列的精确副本。当然,你之所以能够做到这一点,是因为你知道在这个玩具示例中你在寻找什么。在一个真实的神经网络中,深度学习优化器将使用反向传播来学习那些最有助于预测目标变量(标签)的向量序列。

机器如何匹配模式呢?使核总是匹配其中包含的模式的数学原理是什么?在图 7.4 中,你可以自己计算滤波器跨数据的几步步长。这将帮助你了解所有这一切是如何工作的,以及为什么它既简单又强大。

image.png

你检查过图 7.4 中的数学吗?在让 PyTorch 进行数学运算之前,确保你先做了这个步骤,这样你就能将这种数学模式嵌入到你的神经网络中,以便将来在需要调试 CNN 问题时使用。

在 PyTorch 或任何其他旨在并行处理多个样本的深度学习框架中,你必须对核进行“unsqueeze”,以添加一个维度来存储额外的样本。你的扩展后的核(权重矩阵)需要与输入数据的批次形状相同。第一维用于存储来自训练集或测试集的样本,这些样本被输入到卷积层中。通常,这是嵌入层的输出,并且已经是合适的大小。但是,由于你硬编码了所有的权重和输入数据来了解 Conv1d 层的工作原理,因此你需要扩展这个 2D 张量矩阵,以创建一个 3D 张量立方体。由于你只希望在数据集中使用一个句子进行卷积,你只需要第一维的大小为 1。

列表 7.8 加载硬编码权重到 Conv1d 层

>>> kernel = torch.tensor(kernel.values, dtype=torch.float32)
>>> kernel = kernel.unsqueeze(0)              #1
>>> conv = torch.nn.Conv1d(in_channels=4,
...                     out_channels=1,
...                     kernel_size=3,
...                     bias=False)
>>> conv.load_state_dict({'weight': kernel})
>>> print(conv.weight)

tensor([[[1., 0., 0.],
         [0., 0., 0.],
         [0., 1., 0.],
         [0., 0., 1.]]])
#1 为包含单个示例句子的数据库插入一个大小为 1 的新 0 维度

最后,你可以看到你的手工核是否能够在下面的文本中检测到(副词、动词、名词)序列。

列表 7.9 通过卷积层运行单个示例

>>> y = np.array(conv.forward(x).detach()).squeeze()
>>> df.loc['y'] = pd.Series(y)
>>> df
        0      1     2    3    4   ...   15       16     17     18   19
token  The  right  word  may   be  ...    a  rightly  timed  pause    .
ADV      0      0     0    0    0  ...    0        1      0      0    0
ADJ      0      1     0    0    0  ...    0        0      0      0    0
VERB     0      0     0    1    0  ...    0        0      1      0    0
NOUN     0      0     1    0    0  ...    0        0      0      1    0
y      1.0    0.0   1.0  0.0  0.0  ...  0.0      3.0    0.0    NaN  NaN

图 7.5 给出了这个输出表的精确格式,但以更易读的形式呈现。查找卷积滤波器在标记为 y 的行上的最大值。

image.png

在图 7.5 中,查看 y 卷积输出变量的最大值。你可以看到,y 变量的值在单词 "rightly" 处达到了最大值 3。这是三个部分语法的序列,以 1 为值,完美地与内核中的三个 1 对齐。部分语法的序列与内核中的模式匹配。你的内核正确地检测到了句子末尾的(副词、动词、名词)序列。卷积输出的值 3 与单词 "rightly" 对应,这个单词是序列中的第 16 个单词。匹配模式的三个单词序列位于位置 16、17 和 18。输出为 3 是有意义的,因为与内核中的每个部分语法匹配的三个单词都有权重 1,总和为 3。

但是别担心,你再也不需要手工制作卷积神经网络的内核了……除非你想要提醒自己数学是如何工作的,以便向别人解释。

7.2.7 自然示例

考虑一下斑马站在栅栏后的例子。斑马的条纹可以被视为一种视觉自然语言,因为它们向捕食者和潜在配偶发出关于斑马健康的信号。此外,当斑马在草地、竹子或树干之间奔跑时发生的卷积效应可以创造出一种闪烁的效果,使得斑马难以被捕捉。

在图 7.6 中,你可以将卡通栅栏看作是交替数值的内核。背景中的斑马就像你的数据,条纹中的明暗区域交替的数值。由于乘法和加法是可交换的运算,卷积是对称的,所以如果你愿意,你可以把斑马条纹看作是滤波器,而长长的栅栏看作数据。

image.png

想象一下图7.6中的斑马走在栅栏后面,或者栅栏滑过斑马。当斑马行走时,栅栏的缝隙会周期性地与斑马的条纹对齐,这将创建一个明暗交替的图案,随着栅栏(内核)或斑马的移动。当斑马的黑色条纹与栅栏中的缝隙对齐时,某些地方会变暗;而当斑马白色部分的毛发与栅栏缝隙对齐并透过时,斑马看起来会变亮。根据同样的原理,如果你想识别交替的黑白值或交替的数值,你可以在内核中使用交替的高(1)和低(0)值。

如果你不常看到斑马走在栅栏后面,你可以试着用这个类比来帮助理解。假设你在海滩上度过时光,你可以将海浪想象成自然的机械卷积作用,作用于海底。当海浪越过海床接近海滩时,波浪会根据海底隐藏的物体的不同而升高或下降,比如沙洲、大岩石或珊瑚礁。沙洲和岩石就像是你在使用CNN时试图检测的词语含义的组成部分,而波浪在沙洲上升起的过程,就像卷积的乘法操作在数据上滚动传播。

现在,想象你在水边挖了一个小坑。当海浪爬上岸时,取决于波浪的高度,部分海浪可能会溢入你的池塘。沙滩上你的小池塘或护城河就像卷积中的减少或求和操作。稍后我们将在本章中介绍一种叫做最大池化(max pooling)的操作,它可以帮助你的卷积测量某个特定词语模式的影响,就像你在沙滩上的小洞积累海浪对岸边的影响一样。

7.3 摩尔斯电码

在ASCII文本和计算机,甚至电话之前,还有另一种方式可以传递自然语言:摩尔斯电码。摩尔斯电码是一种文本编码,它用点和划代替自然语言中的字母和单词。这些点和划在电报线或无线电中变成长短不一的蜂鸣音。摩尔斯电码的声音听起来像极其缓慢的拨号上网连接的蜂鸣声。稍后在本节中,你可以播放Python示例中使用的音频文件,亲自听听看。业余无线电爱好者通过敲击一个单一的键发送世界各地的信息。你能想象在一个只有一个键的电脑键盘上打字吗?就像图7.7中的Framework笔记本的空格键那样?!

image.png

现实世界中的摩尔斯电码键看起来与你键盘上的空格键稍有不同。图7.8展示了一个实际的摩尔斯电码键。就像计算机键盘上的按键或游戏控制器上的火按钮一样,摩尔斯电码键在按下时会关闭电气接触。

image.png

摩尔斯电码是一种设计用来通过单个按键敲击的语言。在电话能够通过电线传输语音和数据之前,它在电报时代被广泛使用。为了在纸上可视化摩尔斯电码,人们绘制点和划线来表示按键的短暂和较长的敲击。当按键被按下短暂时间时,发送一个点,按下较长时间则发送一个划线。当你不按下按键时,只有沉默,所以这与输入文本有所不同。你可以将摩尔斯电码键想象成视频游戏激光的开火按钮,或任何仅在按下时发送能量的东西。你甚至可以在多人游戏中用你的武器当做电报机来发送秘密消息!

如果没有塞缪尔·莫尔斯的工作来创造这种新的自然语言,在计算机键盘上用单个按键进行通信几乎是不可能的(见图7.9)。莫尔斯在设计摩尔斯电码的语言时做得非常出色,甚至连笨拙的业余无线电操作员也能在紧急情况下使用它。你将要学习这个语言中最重要的两个部分,这样你也可以在紧急情况下使用它。别担心,这只需要学会两个字母,但这应该足以帮助你更清楚地理解卷积及其在自然语言中的作用。

image.png

摩尔斯电码今天仍然在无线电波太嘈杂以至于无法听清语音的情况下被使用。当你急需发送信息时,它特别有用。例如,困在沉没潜艇或船只中的水手曾在金属船体上敲打摩尔斯电码与救援人员沟通,发生地震或矿难后被埋在瓦砾下的人们也曾用金属管道和钢梁敲打摩尔斯电码与救援人员联系。事实上,如果你了解一些摩尔斯电码,使用它进行双向对话是完全可能的。

这里是一个摩尔斯电码广播的秘密信息的示例音频数据。你将在下一节使用手工制作的卷积核来处理它。现在,你可能只想播放音频文件,听听摩尔斯电码的声音是什么样的。

列出7.10:下载一个秘密信息

>>> from nlpia2.init import maybe_download

>>> url = 'https://upload.wikimedia.org/wikipedia/' \
      'commons/7/78/1210secretmorzecode.wav'
>>> filepath = maybe_download(url)            #1
>>> filepath
'/home/hobs/.nlpia2-data/1210secretmorzecode.wav'
#1 maybe_download 确保数据文件在你的$HOME目录中可用。

当然,你的 .nlpia2-data 目录将位于你的 $HOME 目录中,而不是我的。你将在那里找到本例中使用的所有数据。现在,你可以加载 WAV 文件,创建一个音频信号的数值数组,稍后可以用卷积进行处理。

7.3.1 使用卷积解码摩尔斯电码

如果你了解一些 Python,你可以构建一个可以为你解读摩尔斯电码的机器(见列出7.11),这样你就不需要记住图7.9中的所有点。这在僵尸 apocalypse 或者灾难性自然灾害中可能会派上用场。只要确保你能使用可以运行 Python 的计算机或手机。

列出7.11:加载秘密的摩尔斯电码 WAV 文件

>>> from scipy.io import wavfile

>>> sample_rate, audio = wavfile.read(filepath)
>>> print(f'sample_rate: {sample_rate}')
>>> print(f'audio:\n{audio}')
sample_rate: 4000
audio:
[255   0 255 ...   0 255   0]

这个 WAV 文件中的音频信号在有滴答声时会在 255 和 0(最大和最小的 uint8 值)之间振荡。因此,你需要使用 abs() 对信号进行整流,然后将其标准化,使信号在播放音调时为 1,没有音调时为 0。你还需要将样本数转换为毫秒,并对信号进行降采样,以便更容易检查单个值并了解发生了什么。下面的代码将音频数据进行中心化、标准化和降采样,并提取前两秒的数据。

列出7.12:标准化和降采样音频信号

>>> pd.options.display.max_rows = 7

>>> audio = audio[:sample_rate * 2]        #1
>>> audio = np.abs(audio - audio.max() / 2) - .5    #2
>>> audio = audio / audio.max()                    #3
>>> audio = audio[::sample_rate // 400]              #4
>>> audio = pd.Series(audio, name='audio')
>>> audio.index = 1000 * audio.index / sample_rate  #5
>>> audio.index.name = 'time (ms)'
>>> print(f'audio:\n{audio}')
#1 从音频数据中提取2秒的片段
#2 整流并将振荡信号中心化
#3 标准化信号(转换为 0 和 1)
#4 将样本降采样至仅在2秒内包含400个样本(200Hz)
#5 将样本(行)编号转换为毫秒

现在,你可以使用 audio.plot() 绘制你新生成的摩尔斯电码点和短划线(图7.10)。

image.png

你能在图7.10中看到点的位置吗?点是60毫秒的静音(信号值为0),接着是60毫秒的音调(信号值为1),然后再次是60毫秒的静音(信号值为0)。

为了通过卷积检测到点,你需要设计一个匹配这种(低,高,低)模式的卷积核。唯一的区别是,对于低信号,你需要使用-1而不是0,这样数学运算才能成立。你希望卷积的输出值为1,当检测到点符号时。以下代码展示了如何构建一个点检测卷积核,图7.11对此进行了说明。

列出7.13:一个点检测卷积核

>>> kernel = [-1] * 24 + [1] * 24 + [-1] * 24      #1
>>> kernel = pd.Series(kernel, index=2.5 * np.arange(len(kernel)))
>>> kernel.index.name = 'Time (ms)'
>>> ax = kernel.plot(linewidth=3, ylabel='Kernel weight')
#1 24个样本(每个2.5毫秒)加起来的总时长为每个(低,高,低)段的60毫秒。

image.png

你可以通过将你手工制作的卷积核与音频信号进行卷积,来检测它是否能够成功地检测到点。目标是让卷积后的信号在点符号出现的地方,音频中的短促响声附近,变得较高,接近1。你还希望点检测卷积在任何破折号符号或点之前或之后的静默部分返回较低的值(接近0)(见图7.12)。

列出7.14:一个卷积后的点检测器与秘密信息进行卷积

>>> kernel = np.array(kernel) / sum(np.abs(kernel))        #1
>>> pad = [0] * (len(kernel) // 2)         #2
>>> isdot = convolve(audio.values, kernel)
>>> isdot =  np.array(pad[:-1] + list(isdot) + pad)     #3
>>> df = pd.DataFrame()
>>> df['audio'] = audio
>>> df['isdot'] = isdot - isdot.min()
>>> ax = df.plot()
#1 通过除以卷积核权重绝对值之和来归一化卷积核
#2 用卷积核丢失的数据的一半来填充两边
#3 你丢失了len(kernel) – 1个信号值,因此在一侧的填充少了1个

image.png

看起来手工制作的卷积核效果不错!卷积输出只有在点符号的中间才接近1。

现在你已经了解了卷积是如何工作的,可以随意使用 np.convolve() 函数(见图7.13)。它运行更快,并且为你提供了更多处理填充模式的选项。

列出7.15:NumPy卷积

>>> isdot = np.convolve(audio.values, kernel, mode='same')   #1
>>> df['isdot'] = isdot - isdot.min()
>>> ax = df.plot()
#1 np.convolve有3种可能的模式——'same'表示输出长度将与输入长度相等。

image.png

NumPy卷积为你提供了三种可能的模式,按输出长度递增的顺序:

  • valid:仅输出 len(kernel) - 1 个卷积值,就像我们在纯Python中做的那样。
  • same:通过在数组的开头和结尾外推信号,输出一个与输入长度相同的信号。
  • full:输出信号将比输入信号有更多的样本。

在我们的摩尔斯代码音频信号上,设置为 same 模式的NumPy卷积似乎效果更好,因此你需要确保你的神经网络库在进行卷积时使用了类似的模式。

这是一项艰苦的工作,构建一个卷积滤波器来检测摩尔斯代码音频文件中的单个符号。更重要的是,这还只是字母S的三分之一!幸运的是,你的辛勤手工制作工作已经结束。现在可以利用神经网络中的反向传播力量,学习正确的卷积核,以检测与你的问题相关的所有不同信号。

7.4 使用PyTorch构建CNN

图7.14展示了文本如何流入CNN网络,并输出一个嵌入(embedding)。与前面的NLP管道一样,首先需要对文本进行分词。然后,识别文本中使用的所有令牌的集合。你可以忽略不需要计数的令牌,并为你的词汇表中的每个单词分配一个整数索引。输入句子有四个令牌,因此我们从一个包含四个整数索引的序列开始,每个令牌对应一个索引。

CNN通常使用词嵌入(word embeddings),而不是one-hot编码,来表示每个单词。你初始化一个词嵌入矩阵,该矩阵的行数与词汇表中的单词数量相同,如果你想使用300维的词嵌入,则有300列。你可以将所有初始词嵌入设置为0或一些小的随机值。如果你想进行知识迁移并使用预训练的词嵌入,你可以在GloVe、Word2Vec、fastText或任何你喜欢的词嵌入中查找你的令牌。然后,根据词汇表索引将这些向量插入到词嵌入矩阵中的匹配行。

对于这个四个令牌的句子,一旦你查找了每个词的词嵌入,就可以得到一个包含四个词嵌入向量的序列。你还会获得额外的填充令牌嵌入,通常将它们设置为0,以免干扰卷积。如果你使用的是最小的GloVe嵌入,词嵌入是50维的,那么你会得到一个50 × 4的数值矩阵,表示这个短句子。

你的卷积层可以使用1D卷积核处理这些50维度的信息,对这个句子的矩阵进行压缩。如果你使用一个大小为2、步长为2的卷积核,你最终将得到一个50 × 2的矩阵,表示四个50D词向量的序列。

池化层,通常是最大池化层,用于进一步减少输出的大小。具有1D卷积核的最大池化层将把四个50D向量的序列压缩成一个50D向量。正如名字所示,最大池化会取每个通道(维度)中最重要的输出,代表原始文本中每个n-gram最具影响力的维度。最大池化通常是相对有效的,因为它允许你的卷积找到每个n-gram中最重要的意义维度。在多个卷积核的情况下,每个卷积核都可以专注于文本中的一个独立方面,这个方面正在影响你的目标变量。

注意:你应该将卷积层的输出称为编码(encoding)而不是嵌入(embedding)。这两个词都用于描述高维向量,但编码(encoding)这个词意味着在时间或序列中进行处理。卷积数学在你的词向量序列中随着时间进行,而词嵌入向量是处理单一、不变令牌的结果。词嵌入没有编码任何关于单词顺序或序列的信息,而编码则是文本意义的更完整的表示,因为它考虑了单词的顺序,就像你的大脑处理语言一样。

CNN层输出的编码向量是你指定的任何大小(长度)的向量。编码向量的长度(维度数)与输入文本的长度没有任何关系。

你需要利用前几章的所有技能来处理文本,以便将其输入到神经网络中。图7.14中管道的前几个阶段就是你在前几章中做过的分词和大小写折叠。你将使用前面的经验来决定忽略哪些单词,比如停用词、标点符号、专有名词或非常罕见的单词。

image.png

过滤掉并忽略基于手工制作的停用词列表的单词通常是一个坏主意,尤其是对于神经网络,如CNN。词形还原和词干提取通常也不是好主意。模型会比你通过直觉猜测的方式了解更多关于令牌的统计信息。你在Kaggle、DataCamp和其他数据科学网站上看到的大多数例子会鼓励你手动制作这些管道部分,但你现在知道更好的方法。

你也不会手工制作卷积核。你将依赖反向传播的魔力来完成这一点。神经网络可以学习模型的大多数参数,比如哪些单词需要忽略,哪些单词应该被归为一类,因为它们有相似的含义。事实上,在第6章中,你学习了如何通过嵌入向量来表示单词的含义,这些向量精确地捕捉了它们与其他单词的相似度。只要你有足够的数据来创建这些嵌入,你就不再需要处理词形还原和词干提取了。

7.4.1 截断与填充

CNN模型需要一致长度的输入文本,以确保编码中的所有输出值都位于该向量中的一致位置。这确保了CNN输出的编码向量在输入文本长度的不同情况下总是具有相同的维度数。你的目标是创建单字符字符串和整页文本的向量表示。不幸的是,CNN不能处理可变长度的文本,因此,如果你的文本太长,需要将许多单词和字符“截断”掉,或者需要插入填充令牌来填补文本中太短的部分。

记住,卷积操作会减少输入序列的长度,减少的量与卷积核的大小相同。卷积总是将输入序列的长度减少一个,池化操作(如最大池化)也会一致地减少输入序列的长度。因此,如果没有做任何填充或截断,长句子会生成比短句子更长的编码向量。这样做会导致编码向量的长度不一致,这在编码中是不可行的。你希望编码向量的长度始终保持一致,无论输入的大小如何。

这是向量的一个基本属性:它们在你工作空间中的所有向量都有相同的维度数。你希望你的NLP管道能够在文本中的相同位置(或向量维度)找到特定的意义片段,无论该情感在文本中的哪里出现。填充和截断确保了你的CNN对位置(时间)和大小(持续时间)具有不变性。基本上,只要这些模式出现在CNN可以处理的最大长度范围内,你的CNN就能在文本中找到模式。

你可以选择任何符号来表示填充令牌。许多人使用< PAD >令牌,因为它在任何自然语言字典中都不存在,大多数讲英语的NLP工程师都会猜到< PAD >意味着什么。你的NLP管道会看到这些令牌在许多字符串的末尾被重复使用,这有助于它在嵌入层中创建适当的“填充”情感。如果你对填充情感感兴趣,可以加载你的嵌入向量,并将< PAD >的嵌入与blah(如blah blah blah)的嵌入进行比较。你只需要确保使用一致的令牌,并告诉嵌入层你为填充令牌使用了哪个令牌。通常,这会成为id2token或词汇表序列中的第一个令牌,这样它就有一个索引和ID值为0。

一旦让大家知道你的填充令牌是什么,你必须决定一种一致的填充方法。就像在计算机视觉中一样,你可以在令牌序列的两侧、开头或结尾填充。你甚至可以将填充分成两部分,一半放在开始,一半放在末尾——只是不要将其插入单词之间,因为那样会干扰卷积数学。并确保你添加了所需的填充令牌总数,以创建适合你CNN的正确长度序列。

在接下来的列表中,你将加载Kaggle贡献者标记了新闻价值的X(以前是Twitter或如fedis所称的Birdsite)推文。

Listing 7.16 加载新闻帖子

URL = 'https://gitlab.com/tangibleai/nlpia2/-/raw/' \
'main/src/nlpia2/ch07/cnn/data/disaster-tweets.csv'
df = pd.read_csv(URL)
df = df[['text', 'target']]          #1
print(df)

#1 你只需要文本和二进制新闻价值标签来进行CNN训练。

你可以看到这些例子中,一些微博帖子恰好推到了Birdsite的字符限制上。其他则通过较少的单词传达了信息。因此,你需要填充这些较短的文本,使数据集中的所有示例都具有相同数量的令牌。如果你计划稍后在管道中过滤掉使用频率非常高或非常低的单词,填充功能也需要填补这些空缺。接下来的列表将对这些文本进行分词并过滤掉一些最常见的令牌。

Listing 7.17 为你的词汇表过滤最常见的单词

import re
from collections import Counter
from itertools import chain

counts = Counter(chain(*[
    re.findall(r'\w+', t.lower()) for t in df['text']]))      #1
vocab = [tok for tok, count in counts.most_common(4000)[3:]]    #2

print(counts.most_common(10))

#1 进行分词、大小写折叠和计数。

#2 忽略三个最常见的令牌(“t”,“co”和“http”)。

你可以看到,令牌t的出现次数几乎与帖子数量相同(5199次和7613次)。这看起来像是一个URL缩短器用来追踪微博用户生成的部分URL。如果你的目标是构建一个CNN来理解像人类一样的语言,你将创建一个更复杂的分词器和令牌过滤器,以剥离掉人类不会注意的任何文本,比如URL和地理坐标。

一旦你调整好词汇表和分词器,你可以构建一个填充函数,以便在需要时重复使用。如果你使pad()函数通用,如下所示,你就可以在字符串令牌和整数索引上都使用它。

Listing 7.18 一个多功能的填充函数

def pad(sequence, pad_value, seq_len):
    padded = list(sequence)[:seq_len]
    padded = padded + [pad_value] * (seq_len - len(padded))
    return padded

完成了这个最后的预处理步骤后,你的CNN就能很好地工作:包括第6章中学到的令牌嵌入。

7.4.2 使用词嵌入的更好表示

想象一下,你正在将一小段文本传入你的管道。图7.15展示了在你将单词序列转换为数字(或向量——提示,提示)以供卷积操作使用之前,它的样子。

image.png

现在你已经组装了一个单词序列,你需要很好地表示它们的意义,以便卷积能够压缩并编码所有这些意义。对于我们在第五章和第六章中使用的全连接神经网络,你可以使用独热编码(one-hot encoding)。但独热编码会创建非常大且稀疏的矩阵,而现在你可以做得更好。你在第六章学到了一种非常强大的方法来表示单词:词嵌入(word embeddings)。词嵌入是更加丰富且密集的单词向量表示。使用词嵌入表示单词时,卷积神经网络(CNN)和几乎所有其他深度学习或NLP模型的效果都会更好。

图7.16展示了PyTorch中nn.Embedding层背后的工作原理。为了帮助你了解1D卷积如何滑动过数据,图示展示了一个长度为2的卷积核在数据上的三步操作。但是,1D卷积如何在300D GloVe词嵌入的序列上工作呢?你只需要为每个你想要找到模式的维度创建一个卷积核(滤波器)。这意味着,词向量的每个维度都是卷积层中的一个通道。

image.png

不幸的是,许多博客文章和教程可能会误导你关于卷积层的适当大小。许多PyTorch初学者认为,嵌入层的输出可以直接流入卷积层,而无需调整大小。不幸的是,这样会在词嵌入的维度上执行1D卷积,而不是在单词序列上执行卷积。因此,你需要转置嵌入层的输出,使得通道(词嵌入维度)与卷积通道对齐。

PyTorch有一个nn.Embedding层,可以在所有的深度学习管道中使用。如果你希望你的模型从头开始学习嵌入,你只需要告诉PyTorch你需要的嵌入数量,这与你的词汇表大小相同。嵌入层还需要你告诉它为每个嵌入向量分配的维度数,如以下代码所示。你还可以选择定义填充标记的索引ID。

from torch import nn

embedding = nn.Embedding(
    num_embeddings=2000,      #1
    embedding_dim=64,     #2
    padding_idx=0)
  • #1 你的词汇表必须与你的分词器相同。
  • #2 对于小词汇表和语料库,50到100维是合适的。

嵌入层将是你的CNN中的第一层。它将把你的标记ID转换为各自唯一的64维词向量。训练期间的反向传播将调整每个维度中每个单词的权重,以匹配描述新闻灾难信息的64种不同方式。这些嵌入不会像第六章中的fastText和GloVe向量那样代表单词的完整含义;实际上,它们仅用于判断一个帖子是否包含新闻灾难信息。

最后,你可以训练你的CNN,看看它在非常窄的数据集上表现如何,比如Kaggle的灾难帖子数据集。你花费的那些辛勤的努力将换来非常快速的训练时间和令人印象深刻的准确性。

from nlpia2.ch07.cnn.train79 import Pipeline    #1

pipeline = Pipeline(
    vocab_size=2000,
    embeddings=(2000, 64),
    epochs=7,
    torch_random_state=433994,     #2
    split_random_state=1460940,
)

pipeline = pipeline.train()
  • #1 nlpia2/src/nlpia2/ch07/cnn/train79.py
  • #2 设置随机种子,以便其他人可以重现你的结果

经过7轮训练,你在测试集上达到了79%的准确率。在一台现代笔记本CPU上,这应该不到一分钟就能完成,而且通过最小化模型中的参数数量,你将过拟合保持到了最低。CNN使用的参数比嵌入层要少得多。

如果你继续训练更长时间,会发生什么呢?

pipeline.epochs = 13          #1
pipeline = pipeline.train()
  • #1 7 + 13将使总共训练20轮。

看起来有些问题。过拟合太严重了——训练集上是94%,测试集上是78%。训练集准确度不断上升,最终超过了90%。到第20轮时,模型在训练集上达到了94%的准确率——甚至比专家人类能做得更好!自己阅读几个例子,不看标签,你能做到94%正确吗?以下是经过分词、忽略词汇表外单词和添加填充后的前四个例子:

pipeline.indexes_to_texts(pipeline.x_test[:4])['getting in the poor girl <PAD> <PAD> ...',
 'Spot Flood Combo Cree LED Work Light Bar Offroad Lamp Full ...',
 'ice the meltdown <PAD> <PAD> <PAD> <PAD> ...',
 'and burn for bush fires in St http t co <PAD> <PAD> ...']

如果你回答是灾难、不是、不是、灾难,那么你就把这四个例子都答对了。但继续做下去,你能做到19个正确答案中的18个吗?这就是你需要做的,才能打破CNN的训练集准确率。毫不奇怪,这是一个难题,CNN在测试集上只得到了79%的准确率。毕竟,机器人在Birdsite上充斥着暗示灾难的推文。而且,有时,即使是人类也会对世界事件表现出讽刺或耸人听闻的态度。

是什么原因导致了这种过拟合?是参数太多吗?神经网络的容量太大了吗?这里有一个很好的函数可以显示你在PyTorch神经网络中的每一层的参数:

def describe_model(model):    #1
    state = model.state_dict()
    names = state.keys()
    weights = state.values()
    params = model.parameters()
    df = pd.DataFrame([
        dict(
            name=name,
            learned_params=int(p.requires_grad) * p.numel(),   #2
            all_params=p.numel(),    #3
            size=p.size(),
        )
        for name, w, p in zip(names, weights, params)
    ]
    )
    df = df.set_index('name')
    return df

describe_model(pipeline.model) #4
  • #1 这个函数适用于任何继承自torch.nn.Module的模型。
  • #2 requires_grad为True的层表示这些参数在训练过程中被学习(反向传播)。
  • #3 这个总数包括任何没有学习的常量。
  • #4 pipeline包含你刚刚训练的灾难推文分类器。
  • #5 2000个词汇标记加上1个<PAD>标记。

当出现过拟合时,你可以在管道中使用预训练模型,帮助它更好地泛化。

7.4.3 迁移学习

另一个可以帮助你的CNN模型的增强方法是使用预训练的词嵌入,例如GloVe。这并不是作弊,因为这些模型是通过自监督学习的方式训练的,并没有使用你灾难推文数据集中的任何标签。你可以传递这些GloVe向量所包含的所有学习成果,它们是在斯坦福大学通过对整个Wikipedia和其他大型语料库的训练中得来的。通过这种方式,你的模型可以通过使用单词的更一般的意义来为学习关于灾难的词汇打个基础。你只需要为你的嵌入层调整大小,以容纳你希望用来初始化CNN的GloVe嵌入的大小。

列表 7.22 为GloVe嵌入腾出空间

>>> from torch import nn
>>> embedding = nn.Embedding(
...     num_embeddings=2000,      #1
...     embedding_dim=50,         #2
...     padding_idx=0)
  • #1 使用与你的分词器中相同的大小。
  • #2 最小的有效GloVe嵌入具有50维。

就这样!一旦PyTorch知道了嵌入的数量和它们的维度,它就可以分配内存来保存具有num_embeddings行和embedding_dim列的嵌入矩阵。这将使你的嵌入从头开始训练,同时训练你的CNN的其他部分,你的领域特定的词汇和嵌入将会为你的语料库量身定制。但从头开始训练嵌入并没有利用到词汇在多个领域间共享意义这一事实。

如果你希望你的管道具有“跨领域适应性”,你可以使用在其他领域训练的嵌入。这种“跨训练”词嵌入的方式称为迁移学习。通过这种方式,嵌入层可以通过使用在更广泛的语料库上训练的预训练词嵌入,获得学习单词意义的先发优势。为此,你需要过滤掉在其他领域使用的所有单词,以便CNN管道的词汇表仅基于你的数据集中的单词,如以下代码所示。然后,你可以将这些单词的嵌入加载到你的nn.Embedding层中。

列表 7.23 加载嵌入并与词汇对齐

>>> from nessvec.files import load_vecs_df
>>> URL = 'https://gitlab.com/tangibleai/nlpia2/-/' \
... 'raw/main/src/nlpia2/data/glove.6B.50d.txt.bz2'
>>> glove = load_vecs_df(URL)
>>> zeroes = [0.] * 50
>>> embed = []
>>> for tok in vocab:            #1
...     if tok in glove.index:
...         embed.append(glove.loc[tok])
...     else:
...         embed.append(zeroes.copy())     #2
>>> embed = np.array(embed)
>>> embed.shape
(4000, 50)
  • #1 确保嵌入矩阵的行与词汇表的顺序相同。
  • #2 为未知的嵌入创建零向量。

现在你有了一个包含4,000个标记的词汇表,并将其转换为一个4000 × 50的嵌入矩阵。矩阵中的每一行代表该词汇标记的意义,使用50维向量。如果词汇表中的某个标记没有对应的GloVe嵌入,它将有一个零向量。这本质上使得该标记在理解文档的意义时无用,类似于一个词汇外(OOV)标记:

>>> pd.Series(vocab)
0               a
1              in
2              to
          ...
3831         43rd
3832    beginners
3833        lover
Length: 3834, dtype: object

你已经从推文中提取了最常见的4,000个标记。在这4,000个词中,3,834个词可以在最小的GloVe词嵌入词汇表中找到,因此你为缺失的166个标记填充了零向量。这些词将随着你训练神经网络中的嵌入层而学习它们的含义。现在你有了一种一致的方式来使用整数标识标记,你可以将GloVe嵌入矩阵加载到nn.Embedding层中。

列表 7.24 使用GloVe向量初始化嵌入层

embed = torch.Tensor(embed)                  #1
print(f'embed.size(): {embed.size()}')
embed = nn.Embedding.from_pretrained(embed, freeze=False)    #2
print(embed)
  • #1 将pandas DataFrame转换为torch.Tensor。
  • #2 freeze=False 允许你的嵌入层微调你的嵌入。

检测有意义的模式

你说话的方式——单词的顺序——非常重要。你将单词结合在一起,创造出对你有重要意义的模式,这样你就能将这个意义传达给别人。

如果你希望你的机器成为一个有意义的自然语言处理器,它不仅需要能够检测特定标记的存在或缺失。你希望你的机器能够检测隐藏在单词序列中的有意义的模式。卷积是从单词中提取有意义模式的过滤器。最棒的是,你不再需要手工编写这些模式到卷积核中。训练过程会为你的问题搜索最佳的模式匹配卷积。每次你通过网络传播标签数据集中的误差(反向传播)时,优化器会调整每个卷积核中的权重,使它们在检测意义和分类你的文本示例时变得越来越好。

7.4.4 通过dropout增强CNN的鲁棒性

大多数神经网络容易受到对抗样本的影响,这些样本会欺骗神经网络输出错误的分类或文本。有时,神经网络会受到简单变动的影响,比如同义词替换、拼写错误或俚语的插入。在某些情况下,所有它需要的只是一些“词语沙拉”——无意义的随机词语——来分散和困扰NLP算法。人类知道如何忽略噪音并过滤掉干扰,但机器有时很难做到这一点。

鲁棒性NLP是研究如何构建足够智能、能处理来自不同来源的异常文本的机器的方法和技术。实际上,鲁棒性NLP的研究可能会揭示通向人工通用智能的道路。人类能够从少数例子中学习新词和新概念,我们能够很好地进行概括——既不过度也不不足。机器需要一点帮助。如果你能找出使我们人类擅长这一点的“秘诀”,那么你可以将其编码到你的NLP管道中。

一种常见的增强神经网络鲁棒性的方法是随机丢弃(dropout)。随机丢弃,或简称为dropout,因其简便和高效而受到广泛使用。神经网络几乎总能从dropout层中受益。dropout层会随机隐藏一些神经元的输出,导致这些神经元不再传递信息。这样,你的人工大脑中的路径就会暂时静默,并迫使其他神经元在dropout期间从当前的示例中学习。

这看起来有些反直觉,但dropout有助于你的神经网络扩展学习的范围。如果没有dropout层,网络将专注于那些帮助它提高准确性的单词、模式和卷积滤波器。但你需要让神经元多样化它们的模式,这样网络才能在常见的自然语言文本变体面前保持鲁棒性。

在神经网络中安装dropout层的最佳位置是在靠近网络末尾的地方,即在执行全连接的线性层之前,该层计算数据批次的预测结果。进入线性层的这一权重向量是来自CNN和池化层的输出。这些值代表了一系列的单词,或者是意义和语法的模式。从你的预测层隐藏一些这些模式,迫使预测层多样化它的“思维”。虽然你的软件并不真正“思考”任何东西,但如果这有助于你形成关于为什么像随机dropout这样的技术可以提高模型准确性的直觉,适当的拟人化是可以接受的。

7.5 使用PyTorch CNN处理灾难推文

现在是时候进行有趣的部分了。你将构建一个可以区分现实世界新闻和煽动性言论的CNN模型。你的模型将帮助你过滤掉文化战争的推文,从而可以专注于来自真实战区的新闻。

首先,你将看到新的卷积层在管道中的位置。然后,你将组合所有组件,在灾难推文的数据集上训练CNN。如果灾难推文和末日滚动不是你的兴趣,CNN可以轻松适应任何标签化的推文数据集。你甚至可以选择你喜欢的标签并使用它作为目标标签。然后,即使发布者没有使用标签,你也可以找到与该标签话题匹配的推文。

7.5.1 网络架构

以下是CNN NLP管道各个阶段的处理步骤和相应的张量形状。事实证明,构建一个新的CNN时,最棘手的事情之一是跟踪张量的形状。就像堆积的乐高积木的凸起和孔洞一样,神经网络层需要输出具有与上层期望形状相符的张量。下面是卷积神经网络的形状元组:

  • Tokenization — (N_, )
  • Padding — (N,)
  • Embedding — (M, N)
  • Convolution(s) — (M, N - K)
  • Activation(s) — (M, N - K)
  • Pooling(s) — (M, N - K)
  • Dropout (optional) — (M, N - K)
  • Linear combination — (L, )
  • Argmax, softmax or thresholding — (L, )

其中:

  • N_ 是输入文本中的标记数量。
  • N 是填充后的序列中的标记数量。
  • M 是单词嵌入的维度数量。
  • K 是卷积核的大小。
  • L 是你想要预测的类别标签或值的数量。

你的PyTorch CNN模型比第5章和第6章中的模型有更多的超参数。然而,和之前一样,最好在CNNTextClassifier模型的__init__构造函数中设置超参数。

Listing 7.25 CNN超参数

class CNNTextClassifier(nn.Module):

    def __init__(self, embeddings):
        super().__init__()

        self.seq_len = 40                #1
        self.vocab_size = 10000            #2
        self.embedding_size = 50        #3
        self.out_channels = 5                #4
        self.kernel_lengths = [2, 3, 4, 5, 6]   #5
        self.stride = 1                          #6
        self.dropout = nn.Dropout(0)               #7
        self.pool_stride = self.stride               #8
        self.conv_out_seq_len = calc_out_seq_len(    #9
            seq_len=self.seq_len,
            kernel_lengths=self.kernel_lengths,
            stride=self.stride,
            )
  • 1: 假设文本的最大长度为40个标记
  • 2: 词汇表中独特标记的数量
  • 3: 单词嵌入维度的数量(卷积输入通道)
  • 4: 卷积核输出通道的数量
  • 5: 每个卷积核的权重列数
  • 6: 每次滑动卷积核时的步长
  • 7: 卷积输出中要忽略的部分,dropout为0时会增加过拟合
  • 8: 如果池化步长大于1,将增加特征减少
  • 9: 根据卷积核和池化超参数确定卷积输出的总大小

如同在本章之前手工实现的卷积一样,每次卷积操作都会减少序列的长度,减少的量取决于卷积核的大小和步长。PyTorch文档提供了这个公式和术语的详细解释:

def calc_conv_out_seq_len(seq_len, kernel_len,
                          stride=1, dilation=1, padding=0):
    """
    L_out =     (L_in + 2 * padding - dilation * (kernel_size - 1) - 1)
            1 + _______________________________________________________
                                        stride
    """
    return (
        1 + (seq_len +
             2 * padding - dilation * (kernel_len - 1) - 1
            ) //
        stride
        )

Listing 7.26所示,我们的第一个CNN层是一个nn.Embedding层,将单词ID整数序列转换为嵌入向量序列。它的行数等于词汇表中唯一标记的数量,包括新的填充标记。每列代表嵌入向量的每个维度。你可以从GloVe或任何其他预训练嵌入中加载这些嵌入向量。

Listing 7.26 初始化CNN嵌入

self.embed = nn.Embedding(
    self.vocab_size,            #1
    self.embedding_size,           #2
    padding_idx=0)
state = self.embed.state_dict()
state['weight'] = embeddings        #3
self.embed.load_state_dict(state)
  • 1: vocab_size包括填充标记的行向量
  • 2: 对于预训练的50D GloVe向量,将embedding_size设置为50
  • 3: 预训练嵌入必须包括一个填充标记嵌入(通常为零)

接下来,你可以构建卷积和池化层。根据每个卷积层的输出大小,可以定义一个池化层,其卷积核会占据整个卷积层的输出序列,如Listing 7.27所示。实际上,这与NLP专家如Christopher Manning和Yoon Kim使用的策略相同,通过对每个卷积滤波器(卷积核)的输出产生单一的最大值,从而实现全局最大池化,并获得最先进的性能。

Listing 7.27 构建卷积和池化层

self.convolvers = []
self.poolers = []
total_out_len = 0
for i, kernel_len in enumerate(self.kernel_lengths):
    self.convolvers.append(
        nn.Conv1d(in_channels=self.embedding_size,
                  out_channels=self.out_channels,
                  kernel_size=kernel_len,
                  stride=self.stride))
    print(f'conv[{i}].weight.shape: {self.convolvers[-1].weight.shape}')
    conv_output_len = calc_conv_out_seq_len(
        seq_len=self.seq_len, kernel_len=kernel_len, stride=self.stride)
    print(f'conv_output_len: {conv_output_len}')
    self.poolers.append(
        nn.MaxPool1d(kernel_size=conv_output_len, stride=self.stride))
    total_out_len += calc_conv_out_seq_len(
        seq_len=conv_output_len, kernel_len=conv_output_len,
        stride=self.stride)
    print(f'total_out_len: {total_out_len}')
    print(f'poolers[{i}]: {self.poolers[-1]}')
print(f'total_out_len: {total_out_len}')
self.linear_layer = nn.Linear(self.out_channels * total_out_len, 1)
print(f'linear_layer: {self.linear_layer}')

与之前的示例不同,这次你将创建多个卷积和池化层。你不会像计算机视觉中那样将它们堆叠起来,而是将卷积和池化的输出合并在一起。这种方法有效,因为你通过执行全局最大池化,限制了卷积和池化输出的维度,并且保持输出通道的数量远小于嵌入维度的数量。

你可以使用print语句来帮助调试CNN每层的矩阵形状不匹配问题。你希望确保不会无意中创建过多的可训练参数,导致过拟合超出预期。每个池化输出包含一个长度为1的序列,但它们也包含卷积过程中合并的5个通道,正如下面的Listing 7.28所示。因此,连接和池化后的卷积输出是一个5×5的张量,生成一个25D的线性层,用于编码每个文本的意义。

Listing 7.28 CNN层形状

conv[0].weight.shape: torch.Size([5, 50, 2])
conv_output_len: 39
total_pool_out_len: 1
poolers[0]: MaxPool1d(kernel_size=39, stride=1, padding=0, dilation=1,
    ceil_mode=False)
conv[1].weight.shape: torch.Size([5, 50, 3])
conv_output_len: 38
total_pool_out_len: 2
poolers[1]: MaxPool1d(kernel_size=38, stride=1, padding=0, dilation=1,
    ceil_mode=False)
conv[2].weight.shape: torch.Size([5, 50, 4])
conv_output_len: 37
total_pool_out_len: 3
poolers[2]: MaxPool1d(kernel_size=37, stride=1, padding=0, dilation=1,
    ceil_mode=False)
conv[3].weight.shape: torch.Size([5, 50, 5])
conv_output_len: 36
total_pool_out_len: 4
poolers[3]: MaxPool1d(kernel_size=36, stride=1, padding=0, dilation=1,
    ceil_mode=False)
conv[4].weight.shape: torch.Size([5, 50, 6])
conv_output_len: 35
total_pool_out_len: 5
poolers[4]: MaxPool1d(kernel_size=35, stride=1, padding=0, dilation=1,
     ceil_mode=False)
total_out_len: 5
linear_layer: Linear(in_features=25, out_features=1, bias=True)

最终,你将得到一个快速过拟合的语言模型和文本分类器。在第55个epoch时,你的模型在测试集上达到了最高73%的准确率,而在第75个epoch时,训练集的准确率达到了81%。通过增加卷积层的通道数,你可以进一步增加过拟合的程度。你通常希望确保你的初始训练运行能够完成过拟合,以确保所有层正确配置,并为特定问题或数据集设置一个可达到的准确性上限:

Epoch:  1, loss: 0.76782, Train accuracy: 0.59028, Test accuracy: 0.64961
Epoch:  2, loss: 0.64052, Train accuracy: 0.65947, Test accuracy: 0.67060
...
Epoch: 55, loss: 0.04995, Train accuracy: 0.80558, Test accuracy: 0.72966

通过将每个嵌入的通道数从5减少到3,你可以将总输出维度从25减少到15。这将限制过拟合,但会减少收敛速度,除非你增加学习系数:

Epoch:  1, loss: 0.61644, Train accuracy: 0.57773, Test accuracy: 0.58005
Epoch:  2, loss: 0.52941, Train accuracy: 0.63232, Test accuracy: 0.64567
...
Epoch: 55, loss: 0.21011, Train accuracy: 0.79200, Test accuracy: 0.69816

7.5.2 池化

池化通过从一个大张量中聚合数据,将信息压缩为较少的值。这在大数据的世界里通常被称为减少操作,其中map-reduce软件模式非常常见。卷积和池化非常适合map-reduce,并且可以通过PyTorch在GPU中自动并行化。你甚至可以使用多服务器高性能计算(HPC)系统来加速训练,但由于CNN的高效性,你不太可能需要这种强大的计算能力。

你习惯于在数据矩阵上计算的所有统计量都可以作为CNN的池化函数:

  • min
  • max
  • std
  • sum
  • mean

最大池化是最常见和最成功的聚合方法,而平均池化是另一种常用的池化方法。如本章所述,最大池化指的是选择滤波器输出的最大值,你应该可以猜到平均池化会对前一层的结果执行什么计算。

7.5.3 线性层

连接编码的方法给了你很多关于每个微博帖子的信息——编码向量有1,856个值,而你在第6章中处理的最大词向量有300个维度——但你真正想从这个管道中学到的只是我们总的核心问题的二元答案:它是否值得关注?你还记得在第6章时,你试图让神经网络预测“是或否”问题,判断特定单词的出现或缺失吗?尽管你并没有真正关注所有这些数千个问题(每个词汇中的一个),你还是必须解决和现在一样的问题。这意味着你可以使用相同的方法;torch.nn.Linear层将从高维向量中最优地组合所有信息,以回答你提出的任何问题。

因此,你需要添加一个线性层,具有与池化层输出的编码维度相同数量的权重。以下代码展示了如何计算线性层的大小。

Listing 7.29 计算1D卷积输出的张量大小

>>> out_pool_total = 0                                         #1
>>> for kernel_len, stride in zip(kernel_lengths, strides):
>>>     out_conv = (
...         (in_seq_len - dilation * (kernel_len - 1) - 1)
...         // stride
...         ) + 1
>>>     out_pool_total +=  (
...         (out_conv - dilation * (kernel_len - 1) - 1)
...         // stride
...         ) + 1
#1 out_pool_total 累加你所需的线性层总大小。

out_pool_total的最终值可以用来确定CNN层输出中你所需的全连接线性层的输入大小。CNN输出的线性层将把你的潜在空间表示的维度减少到回归或分类问题中的目标变量所需的大小。

7.5.4 适应

在训练你的CNN之前,你需要告诉它如何在每一批训练数据中调整权重(参数)。你需要计算两个部分:权重相对于损失函数的斜率(梯度)和尝试下降该斜率的距离(学习率)。对于单层感知机甚至之前章节中的逻辑回归,你可以使用一些通用的优化器,如Adam。你通常可以将学习率设置为固定值,这些优化器对于CNN也能很好地工作。然而,如果你想加速训练,你可以尝试找到一个更聪明的优化器,它可以更巧妙地调整模型的所有参数。Geoffrey Hinton称这种方法为RMSprop,因为他使用均方根(RMS)公式来计算最近梯度的移动平均。RMSprop为每一批数据聚合一个指数衰减的权重窗口,以提高参数梯度(斜率)的估计并加速学习。28,29 对于CNN在NLP中的反向传播,这通常是一个不错的选择。

7.5.5 超参数调优

探索超参数空间,看看你是否能够超越我们的性能。Fernando Lopez等人使用1D卷积在该数据集上达到了80%的验证集和测试集准确率。显然,这个空间还有很多提升的空间。

nlpia2包包含一个命令行脚本,可以接受许多你可能想调整的超参数作为参数。试试看,看看你能否找到超参数空间中更具潜力的部分。你可以在以下列表中看到一组合理的超参数。

Listing 7.30 优化超参数的命令行脚本

$ python src/nlpia2/ch07/cnn/train_ch07.py
    --dropout_portion=.35 \
    --epochs=16 \
    --batch_size=8 \
    --win=True
Epoch:  1, loss: 0.44480, Train accuracy: 0.58152, Test accuracy: 0.64829
Epoch:  2, loss: 0.27265, Train accuracy: 0.63640, Test accuracy: 0.69029
...
Epoch: 15, loss: 0.03373, Train accuracy: 0.83871, Test accuracy: 0.79396
Epoch: 16, loss: 0.09545, Train accuracy: 0.84718, Test accuracy: 0.79134

你注意到win=True标志吗?这是我们在CNN管道中创建的一个“彩蛋”。每当我们在“彩票票假设”游戏中发现一个中奖票时,我们就会把它硬编码到我们的管道中。为了使其工作,你必须跟踪你使用的随机种子,以及你使用的确切数据集和软件。如果你能重现所有这些部分,通常可以重现一个特别幸运的“抽奖”,然后在此基础上改进,随着你想到新的架构或参数调整进行提升。

事实上,这个中奖的随机数序列初始化了模型的权重,甚至让测试准确率一开始就超过了训练集准确率。训练准确度在8个周期后才超过测试集准确度。在经过16个周期(epoch)后,模型的训练集准确度比测试集高出5%。

如果你想提高测试集的准确率并减少过拟合,你可以尝试添加一些正则化或增加dropout层中忽略的数据量, 如以下列表所示。对于大多数神经网络,30%到50%的dropout比例通常能够有效防止过拟合,同时不会使学习过程过于缓慢。单层CNN通常不会从超过20%的dropout比例中获益。

Listing 7.31 CNN超参数调优

learning  seq  case vocab           training      test
kernel_sizes    rate  len  sens  size dropout  accuracy  accuracy
[2] 0.0010   32 False  2000     NaN    0.5790    0.5459
[1 2 3 4 5 6]  0.0010   40 False  2000     NaN    0.7919    0.7100
[2 3 4 5]  0.0015   40 False  2000     NaN    0.8038    0.7152
[1 2 3 4 5 6]  0.0010   40  True  2000     NaN    0.7685    0.7520
[2]  0.0010   32  True  2000     0.2    0.8472    0.7533
[2 3 4 5]  0.0010   32  True  2000     0.2    0.8727    0.7900

你能找到一个更好的超参数组合来提高这个模型的准确性吗?这是一个相当困难的问题,所以不要指望比80%的测试集准确率好太多。即使是人类读者也无法可靠地判断一条推文是否代表了一条有新闻价值的灾难。毕竟,许多人(和机器人)写这些推文时试图欺骗读者。这是一个对抗性问题。即便是一个小型的单层CNN,正如你在图7.17中看到的学习曲线,它也能做得相当不错。

image.png

超参数调优的关键是认真记录每次实验,并为下次实验进行深思熟虑的超参数调整。你可以使用贝叶斯优化器来自动化这一决策过程,但在大多数情况下,利用你自己通过实践开发的生物神经网络——直觉来进行贝叶斯优化,将有助于更快地调节超参数。如果你对转置操作在嵌入层上的效果感到好奇,可以尝试两种方式,看看哪种在你的问题上效果最好,但如果你的目标是实现解决困难问题的最先进解决方案,最好还是遵循专家的意见。不要相信互联网上所有的内容,尤其是在处理NLP的CNN时。

7.6 测试自己

  1. 对于一个长度为3的卷积核和长度为8的输入数组,输出的长度是多少?
  2. 在本章的秘密消息音频文件中,检测SOS求救信号的卷积核是什么?
  3. 在调优新闻价值微博客帖子问题的超参数后,你能够实现的最佳训练集准确度是多少?
  4. 你会如何扩展模型以适应一个额外的类别?在GitLab上nlpia2包中提供的news.csv文件包含著名的引用,你可以尝试使用CNN对其进行分类,从而提升深度。
  5. 写出三个卷积核,分别用于检测点、破折号和暂停。编写一个池化函数,统计这些符号的独特出现次数。附加任务:创建一组函数,将秘密消息音频文件转换为符号“.”,“-” 和“ ”。
  6. 找出一些超参数(别忘了随机种子),使得灾难推文数据集的测试集准确率超过80%。
  7. 使用基于词汇的CNN并使用Hugging Face(huggingface.co)上的数据集和示例,创建一个讽刺检测器。你是否认为几篇已发布的论文中提出的基于单条无上下文推文检测讽刺,准确率为91%的说法是可信的?

总结

  • 卷积是一个窗口过滤器,它滑动在你的单词序列上,将其意义压缩成一个编码向量。
  • 手工制作的卷积滤波器在可预测的信号(如摩尔斯电码)上效果很好,但对于NLP,你将需要CNN来学习自己的滤波器。
  • 神经网络可以提取单词序列中的模式,而其他NLP方法则无法做到这一点。
  • 在训练过程中,如果你在模型中加入一个dropout层,稍微“限制”一下模型,可以防止它在训练数据上表现过好(过拟合)。
  • 神经网络的超参数调优比传统的机器学习模型提供更多的创造空间。
  • 如果你的CNN能够将嵌入维度与卷积通道对齐,你将能够在NLP竞赛中超越90%的博主。
  • 传统的CNN可能会令你惊讶于其在解决困难问题(例如检测新闻价值推文)时的高效性。