JavaScript 深度学习(三)(4)https://developer.aliyun.com/article/1516964
9.2.4. 1D 卷积网络
在 chapter 4,我们展示了 2D 卷积层在深度神经网络中对图像输入的关键作用。conv2d 层学习在图像中的小 2D 补丁中表示局部特征的方法。卷积的思想可以扩展到序列中。由此产生的算法称为1D 卷积,在 TensorFlow.js 中通过tf.layers.conv1d()
函数提供。conv1d 和 conv2d 的基本思想是相同的:它们都是可训练的提取平移不变局部特征的工具。例如,一个 conv2d 层在图像任务训练后可能变得对某个方向的特定角落模式和颜色变化敏感,而一个 conv1d 层可能在文本相关任务训练后变得对“一个否定动词后跟一个赞美形容词”的模式敏感。^([10])
¹⁰
正如你可能已经猜到的那样,确实存在 3D 卷积,并且它对涉及 3D(体积)数据的深度学习任务非常有用,例如某些类型的医学图像和地质数据。
图 9.8 详细说明了 conv1d 层的工作原理。回想一下,第四章中的 图 4.3 表明,conv2d 层涉及将一个核在输入图像的所有可能位置上滑动。1D 卷积算法也涉及滑动一个核,但更简单,因为滑动仅在一个维度上发生。在每个滑动位置,都会提取输入张量的一个片段。该片段的长度为 kernelSize
(conv1d 层的配置字段),在此示例中,它具有与嵌入维度数量相等的第二个维度。然后,在输入片段和 conv1d 层的核之间执行 点(乘法和加法)操作,得到一个输出序列的单个片段。这个操作会在所有有效的滑动位置上重复,直到生成完整的输出。与 conv1d 层的输入张量一样,完整的输出是一个序列,尽管它具有不同的长度(由输入序列长度、kernelSize
和 conv1d 层的其他配置确定)和不同数量的特征维度(由 conv1d 层的 filters
配置确定)。这使得可以堆叠多个 conv1d 层以形成深度的 1D convnet,就像堆叠多个 conv2d 层一样,是 2D convnet 中经常使用的技巧之一。
图 9.8. 示意图说明了 1D 卷积 (tf.layers.conv1d()
) 的工作原理。为简单起见,仅显示一个输入示例(图像左侧)。假设输入序列的长度为 12,conv1d 层的核大小为 5。在每个滑动窗口位置,都会提取输入序列的长度为 5 的片段。该片段与 conv1d 层的核进行点乘,生成一个输出序列的滑动。这一过程对所有可能的滑动窗口位置重复进行,从而产生输出序列(图像右侧)。
序列截断和填充
现在我们在文本导向的机器学习中使用 conv1d,准备好在 IMDb 数据上训练 1D convnet 了吗?还不太行。还有一件事要解释:序列的截断和填充。为什么我们需要截断和填充?TensorFlow.js 模型要求 fit()
的输入是一个张量,而张量必须具有具体的形状。因此,尽管我们的电影评论长度不固定(回想一下,它们在 10 到 2,400 个单词之间变化),但我们必须选择一个特定的长度作为输入特征张量的第二个维度(maxLen
),这样输入张量的完整形状就是 [numExamples, maxLen]
。在前一节使用多热编码时不存在这样的问题,因为来自多热编码的张量具有不受序列长度影响的第二个张量维度。
选择 maxLen
值的考虑如下:
- 应该足够长以捕获大多数评论的有用部分。如果我们选择
maxLen
为 20,可能会太短,以至于会剪掉大多数评论的有用部分。 - 它不应该太大,以至于大多数评论远远短于该长度,因为那将导致内存和计算时间的浪费。
两者的权衡使我们选择了每个评论的最大词数为 500(最大值)作为示例。这在用于训练 1D convnet 的命令中通过 --maxLen
标志指定:
yarn train --maxLen 500 cnn
一旦选择了 maxLen
,所有的评论示例都必须被调整为这个特定的长度。特别是,比较长的评论被截断;比较短的评论被填充。这就是函数 padSequences()
做的事情(列表 9.7)。截断长序列有两种方式:切掉开头部分(列表 9.7 中的 'pre'
选项)或结尾部分。这里,我们选择了前一种方法,理由是电影评论的结尾部分更有可能包含与情感相关的信息。类似地,填充短序列到期望的长度有两种方式:在句子之前添加填充字符(PAD_CHAR
)(列表 9.7 中的 'pre'
选项)或在句子之后添加。在这里,我们也是任意选择了前一种选项。此列表中的代码来自 sentiment/sequence_utils.js。
列表 9.7. 将文本特征加载的一步截断和填充序列
export function padSequences( sequences, maxLen, padding = 'pre', truncating = 'pre', value = PAD_CHAR) { return sequences.map(seq => { ***1*** if (seq.length > maxLen) { ***2*** if (truncating === 'pre') { ***3*** seq.splice(0, seq.length - maxLen); } else { seq.splice(maxLen, seq.length - maxLen); } } if (seq.length < maxLen) { ***4*** const pad = []; for (let i = 0; i < maxLen - seq.length; ++i) { pad.push(value); ***5*** } if (padding === 'pre') { ***6*** seq = pad.concat(seq); } else { seq = seq.concat(pad); } } return seq; ***7*** }); }
- 1 遍历所有的输入序列
- 2 这个特定序列比指定的长度(maxLen)长:将其截断为该长度。
- 3 有两种截断序列的方式:切掉开头 (‘pre’) 或结尾
- 4 序列比指定的长度短:需要填充。
- 5 生成填充序列
- 6 与截断类似,填充子长度序列有两种方式:从开头 (‘pre’) 或从后面开始。
- 7 注意:如果 seq 的长度恰好为 maxLen,则将原样返回。
在 IMDb 数据集上构建并运行 1D convnet
现在我们已经准备好了 1D convnet 的所有组件;让我们把它们放在一起,看看我们是否可以在 IMDb 情感分析任务上获得更高的准确率。列表 9.8 中的代码创建了我们的 1D convnet(从 sentiment/train.js 中摘录,简化了)。在此之后展示了生成的 tf.Model
对象的摘要。
列表 9.8. 构建 IMDb 问题的 1D convnet
const model = tf.sequential(); model.add(tf.layers.embedding({ ***1*** inputDim: vocabularySize, ***2*** outputDim: embeddingSize, inputLength: maxLen })); model.add(tf.layers.dropout({rate: 0.5})); ***3*** model.add(tf.layers.conv1d({ ***4*** filters: 250, kernelSize: 5, strides: 1, padding: 'valid', activation: 'relu' })); model.add(tf.layers.globalMaxPool1d({})); ***5*** model.add(tf.layers.dense({ ***6*** units: 250, ***6*** activation: 'relu' ***6*** })); ***6*** model.add(tf.layers.dense({units: 1, activation: 'sigmoid'})); ________________________________________________________________ Layer (type) Output shape Param # ================================================================= embedding_Embedding1 (Embedd [null,500,128] 1280000 _________________________________________________________________ dropout_Dropout1 (Dropout) [null,500,128] 0 _________________________________________________________________ conv1d_Conv1D1 (Conv1D) [null,496,250] 160250 _________________________________________________________________ global_max_pooling1d_GlobalM [null,250] 0 _________________________________________________________________ dense_Dense1 (Dense) [null,250] 62750 _________________________________________________________________ dense_Dense2 (Dense) [null,1] 251 ================================================================= Total params: 1503251 Trainable params: 1503251 Non-trainable params: 0 _________________________________________________________________
- 1 模型以嵌入层开始,它将输入的整数索引转换为相应的词向量。
- 2 嵌入层需要知道词汇量的大小。否则,它无法确定嵌入矩阵的大小。
- 3 添加一个 dropout 层以防止过拟合
- 4 接下来是 conv1D 层。
- 5 globalMaxPool1d 层通过提取每个过滤器中的最大元素值来折叠时间维度。输出准备好供后续的密集层(MLP)使用。
- 6 在模型顶部添加了一个两层的 MLP
将 JavaScript 代码和文本摘要一起查看是有帮助的。这里有几个值得注意的地方:
- 模型的形状为
[null, 500]
,其中null
是未确定的批次维度(示例数量),500 是每个评论的最大允许单词长度(maxLen
)。输入张量包含截断和填充的整数单词索引序列。 - 模型的第一层是嵌入层。它将单词索引转换为它们对应的单词向量,导致形状为
[null, 500, 128]
。正如你所看到的,序列长度(500)得到保留,并且嵌入维度(128)反映在形状的最后一个元素上。 - 跟在嵌入层后面的层是 conv1d 层——这个模型的核心部分。它配置为具有大小为 5 的卷积核,默认步幅大小为 1,并且采用“valid”填充。因此,沿着序列维度有 500-5+1=496 个可能的滑动位置。这导致输出形状的第二个元素(
[null, 496, 250]
)中有一个值为 496。形状的最后一个元素(250)反映了 conv1d 层配置为具有的过滤器数量。 - 接在 conv1d 层后面的 globalMaxPool1d 层与我们在图像卷积网络中看到的 maxPooling2d 层有些相似。但它进行了更激烈的汇集,将沿着序列维度的所有元素折叠成一个单一的最大值。这导致输出形状为
[null, 250]
。 - 现在张量具有 1D 形状(忽略批次维度),我们可以在其上构建两个密集层,形成 MLP 作为整个模型的顶部。
用命令yarn train --maxLen 500 cnn
开始训练 1D 卷积网络。经过两到三个训练周期后,你会看到模型达到了约 0.903 的最佳验证准确率,相对于基于多热编码的 MLP 得到的准确率(0.890),这是一个小但坚实的提升。这反映了我们的 1D 卷积网络设法学习到的顺序信息,而这是多热编码 MLP 无法学习到的。
那么 1D 卷积网络如何捕捉顺序信息呢?它通过其卷积核来实现。卷积核的点积对元素的顺序敏感。例如,如果输入由五个单词组成,I like it so much,1D 卷积将输出一个特定的值;然而,如果单词的顺序改变为much so I like it,尽管元素集合完全相同,但 1D 卷积的输出将不同。
但需要指出的是,一维卷积层本身无法学习超出其核大小的连续模式。 例如,假设两个远离的单词的顺序影响句子的含义; 具有小于距离的核大小的 conv1d 层将无法学习长距离交互。 这是 RNN(如 GRU 和 LSTM)在一维卷积方面优于的方面之一。
一种一维卷积可以改善这一缺点的方法是深入研究-即,堆叠多个 conv1d 层,以便较高级别的 conv1d 层的“接受场”足够大,以捕获这种长距离依赖关系。 然而,在许多与文本相关的机器学习问题中,这种长距离依赖关系并不起重要作用,因此使用少量 conv1d 层的一维卷积网络就足够了。 在 IMDb 情感示例中,您可以尝试根据相同的 maxLen
值和嵌入维度训练基于 LSTM 的模型:
yarn train --maxLen 500 lstm
注意,LSTM 的最佳验证准确度(类似于但略为复杂于 GRU;请参见 figure 9.4)与一维卷积网络的最佳验证准确度大致相同。 这可能是因为长距离的单词和短语之间的相互作用对于这些电影评论和情感分类任务并不太重要。
因此,您可以看到一维卷积网络是这种文本问题的一种有吸引力的替代选择,而不是 RNN。 这在考虑到一维卷积网络的计算成本远低于 RNN 的计算成本时尤为明显。 从 cnn
和 lstm
命令中,您可以看到训练一维卷积网络的速度约为训练 LSTM 模型的六倍。 LSTM 和 RNN 的性能较慢与它们的逐步内部操作有关,这些操作无法并行化; 卷积是可以通过设计进行并行化的。
使用嵌入式投影仪可视化学习到的嵌入向量
使用嵌入式投影仪在嵌入式投影器中使用 t-SNE 维度约减可视化经过训练的一维卷积网络的词嵌入。
在训练后,一维卷积网络的词嵌入中是否出现了任何有趣的结构? 要找出,请使用 yarn train
命令的可选标志 --embeddingFilesPrefix
:
yarn train --maxLen 500 cnn --epochs 2 --embeddingFilesPrefix /tmp/imdb_embed
此命令将生成两个文件:
- /tmp/imdb_embed_vectors.tsv-一个包含单词嵌入的数值的制表符分隔值文件。 每一行包含一个单词的嵌入向量。 在我们的情况下,有 10,000 行(我们的词汇量大小),每行包含 128 个数字(我们的嵌入维度)。
- /tmp/imdb_embed_labels.tsv-一个由与前一个文件中的向量对应的单词标签组成的文件。 每一行是一个单词。
这些文件可以上传到嵌入投影仪(projector.tensorflow.org
)进行可视化(见前面的图)。因为我们的嵌入向量驻留在一个高维(128D)空间中,所以需要将它们的维度降低到三个或更少的维度,以便人类能够理解。嵌入投影仪工具提供了两种降维算法:t-分布随机邻域嵌入(t-SNE)和主成分分析(PCA),我们不会详细讨论。但简要地说,这些方法将高维嵌入向量映射到 3D,同时确保向量之间的关系损失最小。t-SNE 是两者中更复杂、计算更密集的方法。它产生的可视化效果如图所示。
每个点云中的点对应我们词汇表中的一个单词。将鼠标光标移动到点上方,悬停在点上以查看它们对应的单词。我们在较小的情感分析数据集上训练的嵌入向量已经显示出与单词语义相关的一些有趣结构。特别是,点云的一端包含许多在积极的电影评论中经常出现的词语(例如优秀、鼓舞人心和令人愉快),而另一端则包含许多听起来消极的词语(糟糕、恶心和自命不凡)。在更大的文本数据集上训练更大的模型可能会出现更有趣的结构,但是这个小例子已经给你一些关于词嵌入方法的威力的暗示。
因为词嵌入是文本导向的深度神经网络的重要组成部分,研究人员创建了预训练词嵌入,机器学习从业者可以直接使用,无需像我们在 IMDb 卷积神经网络示例中那样训练自己的词嵌入。最著名的预训练词嵌入集之一是斯坦福自然语言处理组的 GloVe(全局向量)(参见nlp.stanford.edu/projects/glove/
)。
使用预训练的词嵌入(如 GloVe)的优势是双重的。首先,它减少了训练过程中的计算量,因为嵌入层不需要进一步训练,因此可以直接冻结。其次,像 GloVe 这样的预训练嵌入是从数十亿个单词中训练出来的,因此质量比在小数据集上训练可能得到的要高得多,比如这里的 IMDb 数据集。从这些意义上讲,预训练词嵌入在自然语言处理问题中的作用类似于在计算机视觉中所见到的预训练深度卷积基(例如 MobileNet,在第五章中见过)在计算机视觉中的作用。
在网页中使用 1D 卷积神经网络进行推理
在 sentiment/index.js 中,你可以找到部署在 Node.js 中训练的模型以在客户端使用的代码。要查看客户端应用程序的运行情况,请运行命令 yarn watch
,就像本书中的大多数其他示例一样。该命令将编译代码,启动一个 web 服务器,并自动打开一个浏览器选项卡以显示 index.html 页面。在页面中,你可以点击一个按钮通过 HTTP 请求加载训练好的模型,并在文本框中执行情感分析。文本框中的电影评论示例可编辑,因此你可以对其进行任意编辑,并观察实时观察到这如何影响二进制预测。页面带有两个示例评论(一个积极的评论和一个消极的评论),你可以将其用作你调试的起点。加载的 1D convnet 运行速度足够快,可以在你在文本框中输入时实时生成情感分数。
推断代码的核心很简单(参见 列表 9.9,来自 sentiment/index.js),但有几个有趣的地方值得指出:
- 该代码将所有输入文本转换为小写,丢弃标点符号,并在将文本转换为单词索引之前删除额外的空白。这是因为我们使用的词汇表只包含小写单词。
- 超出词汇表的词汇——即词汇表之外的词汇——用特殊的单词索引(
OOV_INDEX
)表示。这些词汇包括罕见的词汇和拼写错误。 - 我们在训练中使用的相同
padSequences()
函数(参见 列表 9.7)在此处用于确保输入到模型的张量具有正确的长度。通过截断和填充来实现这一点,正如我们之前所见。这是使用 TensorFlow.js 进行像这样的机器学习任务的一个好处的一个例子:你可以在后端训练环境和前端服务环境中使用相同的数据预处理代码,从而减少数据偏差的风险(有关数据偏差风险的更深入讨论,请参见 第六章)。
列表 9.9. 在前端使用训练好的 1D convnet 进行推断
predict(text) { const inputText = ***1*** text.trim().toLowerCase().replace(/(\.|\,|\!)/g, '').split(' '); ***1*** const sequence = inputText.map(word => { let wordIndex = ***2*** this.wordIndex[word] + this.indexFrom; ***2*** if (wordIndex > this.vocabularySize) { wordIndex = OOV_INDEX; ***3*** } return wordIndex; }); const paddedSequence = ***4*** padSequences([sequence], this.maxLen); ***4*** const input = tf.tensor2d( ***5*** paddedSequence, [1, this.maxLen]); ***5*** const beginMs = performance.now(); ***6*** const predictOut = this.model.predict(input); ***7*** const score = predictOut.dataSync()[0]; predictOut.dispose(); const endMs = performance.now(); return {score: score, elapsed: (endMs - beginMs)}; }
- 1 转换为小写;从输入文本中删除标点符号和额外的空白
- 2 将所有单词映射到单词索引。this.wordIndex 已从 JSON 文件加载。
- 3 超出词汇表的单词被表示为特殊的单词索引:OOV_INDEX。
- 4 截断长评论,并填充短评论到所需长度
- 5 将数据转换为张量表示,以便馈送到模型中
- 6 跟踪模型推断所花费的时间
- 7 实际推断(模型的前向传递)发生在这里。
9.3. 使用注意力机制的序列到序列任务
在 Jena-weather 和 IMDb 情感示例中,我们展示了如何从输入序列中预测单个数字或类别。然而,一些最有趣的序列问题涉及根据输入序列生成输出序列。这些类型的任务被恰当地称为序列到序列(或简称为 seq2seq)任务。seq2seq 任务有很多种,以下列表只是其中的一个小子集:
- 文本摘要—给定一篇可能包含数万字的文章,生成其简洁摘要(例如,100 字或更少)。
- 机器翻译—给定一种语言(例如英语)中的一个段落,生成其在另一种语言(例如日语)中的翻译。
- 自动补全的单词预测—给定句子中的前几个单词,预测它们之后会出现什么单词。这对电子邮件应用程序和搜索引擎 UI 中的自动补全和建议非常有用。
- 音乐创作—给定一系列音符的前导序列,生成以这些音符开头的旋律。
- 聊天机器人—给定用户输入的一句话,生成一个满足某种对话目标的回应(例如,某种类型的客户支持或简单地用于娱乐聊天)。
注意力机制^([11])是一种强大且流行的用于 seq2seq 任务的方法。它通常与 RNNs 一起使用。在本节中,我们将展示如何使用注意力和 LSTMs 来解决一个简单的 seq2seq 任务,即将各种日历日期格式转换为标准日期格式。尽管这是一个有意简化的例子,但你从中获得的知识适用于像之前列出的更复杂的 seq2seq 任务。让我们首先制定日期转换问题。
¹¹
参见 Alex Graves,“Generating Sequences with Recurrent Neural Networks,”2013 年 8 月 4 日提交,
arxiv.org/abs/1308.0850
;以及 Dzmitry Bahdanau,Kyunghyun Cho 和 Yoshua Bengio,“Neural Machine Translation by Jointly Learning to Align and Translate,”2014 年 9 月 1 日提交,arxiv.org/abs/1409.0473
。
9.3.1. 序列到序列任务的制定
如果你像我们一样,你可能会因为写日历日期的可能方式太多而感到困惑(甚至可能有点恼火),特别是如果你去过不同的国家。有些人喜欢使用月-日-年的顺序,有些人采用日-月-年的顺序,还有些人使用年-月-日的顺序。即使在同一顺序中,对于月份是否写为单词(January)、缩写(Jan)、数字(1)或零填充的两位数字(01),也存在不同的选择。日期的选项包括是否在前面加零以及是否将其写为序数(4th 与 4)。至于年份,你可以写全四位数或只写最后两位数。而且,年、月和日的部分可以用空格、逗号、句点或斜杠连接,或者它们可以在没有任何中间字符的情况下连接在一起!所有这些选项以组合的方式结合在一起,至少产生了几十种写相同日期的方式。
因此,拥有一种算法可以将这些格式的日历日期字符串作为输入,并输出对应的 ISO-8601 格式的日期字符串(例如,2019-02-05)会很好。我们可以通过编写传统程序来非机器学习方式解决这个问题。但考虑到可能的格式数量庞大,这是一项有些繁琐且耗时的任务,结果代码很容易达到数百行。让我们尝试一种深度学习方法——特别是使用基于 LSTM 的注意力编码器-解码器架构。
为了限制本示例的范围,我们从以下示例展示的 18 种常见日期格式开始。请注意,所有这些都是写相同日期的不同方式:
"23Jan2015", "012315", "01/23/15", "1/23/15", "01/23/2015", "1/23/2015", "23-01-2015", "23-1-2015", "JAN 23, 15", "Jan 23, 2015", "23.01.2015", "23.1.2015", "2015.01.23", "2015.1.23", "20150123", "2015/01/23", "2015-01-23", "2015-1-23"
当然,还有其他日期格式。[12] 但是一旦模型训练和推理的基础奠定,添加对其他格式的支持基本上将是一项重复性的任务。我们把添加更多输入日期格式的部分留给了本章末尾的练习(练习 3)。
¹²
你可能已经注意到的另一件事是,我们使用了一组没有任何歧义的日期格式。如果我们在我们的格式集中同时包含 MM/DD/YYYY 和 DD/MM/YYYY,那么就会有含糊不清的日期字符串:即,无法确定地解释的字符串。例如,字符串“01/02/2019”可以被解释为 2019 年 1 月 2 日或 2019 年 2 月 1 日。
首先,让我们让示例运行起来。就像先前的情感分析示例一样,这个示例包括一个训练部分和一个推理部分。训练部分在后端环境中使用tfjs-node
或tfjs-node-gpu
运行。要启动训练,请使用以下命令:
git clone https://github.com/tensorflow/tfjs-examples.git cd tfjs-examples/sentiment yarn yarn train
要使用 CUDA GPU 执行训练,请在yarn train
命令中使用--gpu
标志:
yarn train --gpu
默认情况下,训练运行两个时期,这应该足以将损失值接近零并且转换精度接近完美。 在训练作业结束时打印的样本推断结果中,大多数,如果不是全部,结果应该是正确的。 这些推断样本来自与训练集不重叠的测试集。 训练好的模型将保存到相对路径dist/model
,并将在基于浏览器的推断阶段使用。 要启动推断 UI,请使用
yarn watch
在弹出的网页中,您可以在输入日期字符串文本框中键入日期,然后按 Enter 键,观察输出日期字符串如何相应更改。 此外,具有不同色调的热图显示了转换期间使用的注意矩阵(请参见图 9.9)。 注意矩阵包含一些有趣的信息,并且是此 seq2seq 模型的核心。 它特别适合人类解释。 您应该通过与之互动来熟悉它。
图 9.9. 基于注意力的编码器-解码器在工作中进行日期转换,底部右侧显示了特定输入-输出对的注意力矩阵
让我们以图 9.9 中显示的结果为例。 模型的输出("2034-07-18"
)正确地转换了输入日期("JUL 18, 2034"
)。 注意矩阵的行对应于输入字符("J"
, "U"
, "L"
, " "
, 等等),而列对应于输出字符("2"
, "0"
, "3"
, 等等)。 因此,注意矩阵的每个元素指示了在生成相应输出字符时有多少关注力放在相应的输入字符上。 元素的值越高,关注度就越高。 例如,看看最后一行的第四列: 也就是说,对应于最后一个输入字符("4"
)和第四个输出字符("4"
)的那个。 根据颜色刻度表,它具有相对较高的值。 这是有道理的,因为输出的年份部分的最后一位数字确实应该主要依赖于输入字符串的年份部分的最后一位数字。 相比之下,该列中的其他元素具有较低的值,这表明输出字符串中字符"4"
的生成并未使用来自输入字符串的其他字符的太多信息。 在输出字符串的月份和日期部分也可以看到类似的模式。 鼓励您尝试使用其他输入日期格式,并查看注意矩阵如何变化。
9.3.2. 编码器-解码器架构和注意力机制
本节帮助您了解编码器-解码器架构如何解决 seq2seq 问题以及注意力机制在其中起什么作用的直觉。 机制的深入讨论将与下面的深入研究部分中的代码一起呈现。
到目前为止,我们见过的所有神经网络都输出单个项目。对于回归网络,输出只是一个数字;对于分类网络,它是对可能类别的单个概率分布。但是我们面临的日期转换问题不同:我们不是要预测单个项目,而是需要预测多个项目。具体来说,我们需要准确预测 ISO-8601 日期格式的 10 个字符。我们应该如何使用神经网络实现这一点?
解决方案是创建一个输出序列的网络。特别是,由于输出序列由来自具有确切 11 个项目的“字母表”的离散符号组成,我们让网络的输出张量形状为 3D 形状:[numExamples, OUTPUT_LENGTH, OUTPUT_VOCAB_SIZE]
。第一个维度(numExamples
)是传统的示例维度,使得像本书中看到的所有其他网络一样可以进行批处理。OUTPUT_LENGTH
为 10,即 ISO-8601 格式输出日期字符串的固定长度。OUTPUT_VOCAB_SIZE
是输出词汇表的大小(或更准确地说,“输出字母表”),其中包括数字 0 到 9 和连字符(-),以及我们稍后将讨论的一些具有特殊含义的字符。
这样就涵盖了模型的输出。那么模型的输入呢?原来,模型不是一个输入,而是两个输入。模型可以大致分为两部分,编码器和解码器,如图 9.10 所示。模型的第一个输入进入编码器部分。它是输入日期字符串本身,表示为形状为[numExamples, INPUT_LENGTH]
的字符索引序列。INPUT_LENGTH
是支持的输入日期格式中最大可能的长度(结果为 12)。比该长度短的输入在末尾用零填充。第二个输入进入模型的解码器部分。它是右移一个时间步长的转换结果,形状为[numExamples, OUTPUT_LENGTH]
。
图 9.10. 编码器-解码器架构如何将输入日期字符串转换为输出字符串。ST
是解码器输入和输出的特殊起始标记。面板 A 和 B 分别显示了转换的前两个步骤。在第一个转换步骤之后,生成了输出的第一个字符("2"
)。在第二步之后,生成了第二个字符("0"
)。其余步骤遵循相同的模式,因此被省略。
等等,第一个输入是有意义的,因为它是输入日期字符串,但是为什么模型将转换结果作为额外的输入呢?这不是模型的输出吗?关键在于转换结果的偏移。请注意,第二个输入并不完全是转换结果。相反,它是转换结果的时延版本。时延为一步。例如,在训练期间,期望的转换结果是 "2034-07-18"
,那么模型的第二个输入将是 "2034-07-1"
,其中 是一个特殊的序列起始符号。这个偏移的输入使解码器能够意识到到目前为止已经生成的输出序列。它使解码器更容易跟踪转换过程中的位置。
这类似于人类说话的方式。当你将一个想法用语言表达出来时,你的心智努力分为两个部分:想法本身和你到目前为止所说的内容。后者对于确保连贯、完整和不重复的言论至关重要。我们的模型以类似的方式工作:为了生成每个输出字符,它使用来自输入日期字符串和到目前为止已生成的输出字符的信息。
在训练阶段,转换结果的时延效果是有效的,因为我们已经知道正确的转换结果是什么。但是在推断过程中它是如何工作的呢?答案可以在 图 9.10 的两个面板中看到:我们逐个生成输出字符。如图的面板 A 所示,我们从将一个 ST
符号置于解码器输入的开头开始。通过一步推断(一个 Model.predict()
调用),我们得到一个新的输出项(面板中的 "2"
)。然后,这个新的输出项被附加到解码器输入中。然后进行转换的下一步。它在解码器输入中看到了新生成的输出字符 "2"
(请参阅 图 9.10 的面板 B)。这一步涉及另一个 Model.predict()
调用,并生成一个新的输出字符("0"
),然后再次附加到解码器输入中。这个过程重复,直到达到所需的输出长度(在本例中为 10)。注意,输出不包括 ST
项目,因此可以直接用作整个算法的最终输出。
¹³
实现逐步转换算法的代码是
date-conversion-attention/model.js
中的函数runSeq2SeqInference()
。
注意机制的作用
注意机制的作用是使每个输出字符能够“关注”输入序列中的正确字符。例如,输出字符串"2034-07-18"
的"7"
部分应关注输入日期字符串的"JUL"
部分。这与人类生成语言的方式类似。例如,当我们将语言 A 的句子翻译成语言 B 时,输出句子中的每个单词通常由输入句子中的少数单词确定。
这可能看起来显而易见:很难想象还有什么其他方法可能效果更好。但是,深度学习研究人员在 2014 年至 2015 年左右引入的注意机制的介绍是该领域的重大进展。要理解其历史原因,请查看图 9.10 A 面板中连接编码器框与解码器框的箭头。此箭头表示模型中编码器部分中 LSTM 的最后输出,该输出被传递到模型中解码器部分中的 LSTM 作为其初始状态。回想一下 RNN 的初始状态通常是全零的(例如,我们在 section 9.1.2 中使用的 simpleRNN);但是,TensorFlow.js 允许您将 RNN 的初始状态设置为任何给定形状的张量值。这可以用作向 LSTM 传递上游信息的一种方式。在这种情况下,编码器到解码器的连接使用此机制使解码器 LSTM 能够访问编码的输入序列。
但是,初始状态是将整个输入序列打包成单个向量。事实证明,对于更长且更复杂的序列(例如典型的机器翻译问题中看到的句子),这种表示方式有点太简洁了,解码器无法解压缩。这就是注意机制发挥作用的地方。
注意机制扩展了解码器可用的“视野”。不再仅使用编码器的最终输出,注意机制访问整个编码器输出序列。在转换过程的每一步中,该机制会关注编码器输出序列中特定的时间步,以决定生成什么输出字符。例如,第一次转换步骤可能会关注前两个输入字符,而第二次转换步骤则关注第二个和第三个输入字符,依此类推(见图 9.10 ,其中提供了这种注意矩阵的具体示例)。就像神经网络的所有权重参数一样,注意模型 学习 分配注意力的方式,而不是硬编码策略。这使得模型灵活且强大:它可以根据输入序列本身以及迄今为止在输出序列中生成的内容学习关注输入序列的不同部分。
在不看代码或打开编码器、解码器和注意力机制这些黑盒子的情况下,我们已经尽可能深入地讨论了编码器-解码器机制。如果你觉得这个处理过程对你来说太过高层或太模糊,请阅读下一节,我们将更深入地探讨模型的细节。这对于那些希望更深入了解基于注意力机制的编码器-解码器架构的人来说是值得付出的心智努力。要激励你去阅读它,要意识到相同的架构也支撑着一些系统,比如最先进的机器翻译模型(Google 神经网络机器翻译,或 GNMT),尽管这些生产模型使用了更多层的 LSTM 并且在比我们处理的简单日期转换模型大得多的数据上进行了训练。
9.3.3. 深入理解基于注意力机制的编码器-解码器模型
图 9.11 扩展了图 9.10 中的方框,并提供了它们内部结构的更详细视图。将它与构建模型的代码一起查看最具说明性:date-conversion-attention/model.js
中的createModel()
函数。接下来我们将逐步介绍代码的重要部分。
图 9.11. 深入理解基于注意力机制的编码器-解码器模型。你可以把这个图像看作是对图 9.10 中概述的编码器-解码器架构的扩展视图,显示了更细粒度的细节。
首先,我们为编码器和解码器中的嵌入和 LSTM 层定义了一些常量:
const embeddingDims = 64; const lstmUnits = 64;
我们将构建的模型接受两个输入,因此我们必须使用功能模型 API 而不是顺序 API。我们从模型的符号输入开始,分别是编码器输入和解码器输入:
const encoderInput = tf.input({shape: [inputLength]}); const decoderInput = tf.input({shape: [outputLength]});
编码器和解码器都对它们各自的输入序列应用了一个嵌入层。编码器的代码看起来像这样
let encoder = tf.layers.embedding({ inputDim: inputVocabSize, outputDim: embeddingDims, inputLength, maskZero: true }).apply(encoderInput);
这类似于我们在 IMDb 情感问题中使用的嵌入层,但它是对字符而不是单词进行嵌入。这表明嵌入方法并不局限于单词。事实上,它足够灵活,可以应用于任何有限的、离散的集合,比如音乐类型、新闻网站上的文章、一个国家的机场等等。嵌入层的maskZero: true
配置指示下游的 LSTM 跳过所有零值的步骤。这样就可以节省在已经结束的序列上的不必要计算。
LSTM 是一种我们尚未详细介绍的 RNN 类型。我们不会在这里讨论其内部结构。简而言之,它类似于 GRU(图 9.4), 通过使得在多个时间步中传递状态变得更容易来解决梯度消失的问题。Chris Olah 的博文“理解 LSTM 网络”,在本章末尾提供了指针在 “进一步阅读资料” 中,对 LSTM 结构和机制进行了出色的评述和可视化。我们的编码器 LSTM 应用在字符嵌入向量上:
encoder = tf.layers.lstm({ units: lstmUnits, returnSequences: true }).apply(encoder);
returnSequences:
true
配置使得 LSTM 的输出是输出向量序列,而不是默认的单个向量输出(就像我们在温度预测和情感分析模型中所做的那样)。这一步是下游注意力机制所需的。
跟随编码器 LSTM 的 GetLastTimestepLayer
层是一个自定义定义的层:
const encoderLast = new GetLastTimestepLayer({ name: 'encoderLast' }).apply(encoder);
它简单地沿着时间维度(第二维度)切片时间序列张量并输出最后一个时间步。这使我们能够将编码器 LSTM 的最终状态发送到解码器 LSTM 作为其初始状态。这种连接是解码器获取有关输入序列信息的方式之一。这在 图 9.11 中用将绿色编码器块中的 h[12] 与蓝色解码器块中的解码器 LSTM 层连接的箭头进行了说明。
代码的解码器部分以类似于编码器的拓扑结构的嵌入层和 LSTM 层开始:
let decoder = tf.layers.embedding({ inputDim: outputVocabSize, outputDim: embeddingDims, inputLength: outputLength, maskZero: true }).apply(decoderInput); decoder = tf.layers.lstm({ units: lstmUnits, returnSequences: true }).apply(decoder, {initialState: [encoderLast, encoderLast]});
在代码片段的最后一行,注意编码器的最终状态如何用作解码器的初始状态。如果你想知道为什么在这里的代码的最后一行中重复使用符号张量 encoderLast
,那是因为 LSTM 层包含两个状态,不像我们在 simpleRNN 和 GRU 中看到的单状态结构。
解码器更强大的另一种方式是获得输入序列的视图,当然,这是通过注意力机制实现的。注意力是编码器 LSTM 输出和解码器 LSTM 输出的点积(逐元素相乘),然后是 softmax 激活:
let attention = tf.layers.dot({axes: [2, 2]}).apply([decoder, encoder]); attention = tf.layers.activation({ activation: 'softmax', name: 'attention' }).apply(attention);
编码器 LSTM 的输出形状为 [null, 12, 64]
,其中 12 是输入序列的长度,64 是 LSTM 的大小。解码器 LSTM 的输出形状为 [null, 10, 64]
,其中 10 是输出序列的长度,64 是 LSTM 的大小。在最后一个(LSTM 特征)维度上执行两者的点积,得到 [null, 10, 12]
的形状(即 [null, inputLength, outputLength]
)。对点积应用 softmax 将值转换为概率分数,保证它们在矩阵的每一列上都是正数且总和为 1。这是我们模型中心的注意力矩阵。其值是早期 图 9.9 中可视化的。
然后,注意力矩阵应用于编码器 LSTM 的序列输出。这是转换过程学习如何在每个步骤上关注输入序列(以其编码形式)中的不同元素的方式。将注意力应用于编码器输出的结果称为上下文:
const context = tf.layers.dot({ axes: [2, 1], name: 'context' }).apply([attention, encoder]);
上下文的形状为[null, 10, 64]
(即[null, outputLength, lstmUnits]
)。它与解码器的输出连接在一起,解码器的输出形状也为[null, 10, 64]
。因此,连接的结果形状为[null, 10, 128]
:
const decoderCombinedContext = tf.layers.concatenate().apply([context, decoder]);
decoderCombinedContext
包含进入模型最终阶段的特征向量,即生成输出字符的阶段。
输出字符使用包含一个隐藏层和一个 softmax 输出层的 MLP 生成:
let output = tf.layers.timeDistributed({ layer: tf.layers.dense({ units: lstmUnits, activation: 'tanh' }) }).apply(decoderCombinedContext); output = tf.layers.timeDistributed({ layer: tf.layers.dense({ units: outputVocabSize, activation: 'softmax' }) }).apply(output);
多亏了timeDistributed
层,所有步骤共享同一个 MLP。timeDistributed
层接受一个层,并在其输入的时间维度(即第二维度)上重复调用它。这将输入特征形状从[null, 10, 128]
转换为[null, 10, 13]
,其中 13 对应于 ISO-8601 日期格式的 11 个可能字符,以及 2 个特殊字符(填充和序列起始)。
所有组件齐备后,我们将它们组装成一个具有两个输入和一个输出的tf.Model
对象:
const model = tf.model({ inputs: [encoderInput, decoderInput], outputs: output });
为了准备训练,我们使用分类交叉熵损失函数调用compile()
方法。选择这个损失函数是基于转换问题本质上是一个分类问题——在每个时间步,我们从所有可能字符的集合中选择一个字符:
model.compile({ loss: 'categoricalCrossentropy', optimizer: 'adam' });
推理时,对模型的输出张量应用argMax()
操作以获取获胜的输出字符。在转换的每一步中,获胜的输出字符都会附加到解码器的输入中,因此下一转换步骤可以使用它(参见图 9.11 右端的箭头)。正如我们之前提到的,这个迭代过程最终产生整个输出序列。
进一步阅读的材料
- Chris Olah,《理解 LSTM 网络》,博客,2015 年 8 月 27 日,
mng.bz/m4Wa
。 - Chris Olah 和 Shan Carter,《注意力和增强递归神经网络》,Distill,2016 年 9 月 8 日,
distill.pub/2016/augmented-rnns/
。 - Andrej Karpathy,《递归神经网络的不合理有效性》,博客,2015 年 5 月 21 日,
mng.bz/6wK6
。 - Zafarali Ahmed,《如何使用 Keras 可视化您的递归神经网络和注意力》,Medium,2017 年 6 月 29 日,
mng.bz/6w2e
。 - 在日期转换示例中,我们描述了一种基于
argMax()
的解码技术。这种方法通常被称为贪婪解码技术,因为它在每一步都提取具有最高概率的输出符号。贪婪解码方法的一个流行替代方案是波束搜索解码,它检查更大范围的可能输出序列,以确定最佳序列。你可以从 Jason Brownlee 的文章“如何为自然语言处理实现波束搜索解码器”中了解更多信息,2018 年 1 月 5 日,machinelearningmastery.com/beam-search-decoder-natural-language-processing/
。 - Stephan Raaijmakers,《自然语言处理的深度学习》,Manning Publications,在出版中,www.manning.com/books/deep-learning-for-natural-language-processing。
练习
- 尝试重新排列各种非连续数据的数据元素顺序。确认这种重新排序对建模的损失指标值(例如准确度)没有影响(超出由权重参数的随机初始化引起的随机波动)。你可以为以下两个问题进行此操作:
- 在鸢尾花示例(来自第三章)中,通过修改行来重新排列四个数字特征(花瓣长度、花瓣宽度、萼片长度和萼片宽度)的顺序
shuffledData.push(data[indices[i]]);
- 在 tfjs-examples 仓库的 iris/data.js 文件中。特别是,改变
data[indices[i]]
中四个元素的顺序。这可以通过 JavaScript 数组的slice()
和concat()
方法来完成。请注意,所有示例的重新排列顺序应该是相同的。你可以编写一个 JavaScript 函数来执行重新排序。 - 在我们为 Jena 气象问题开发的线性回归器和 MLP 中,尝试重新排列 240 个时间步长和14 个数字特征(气象仪器测量)。具体来说,你可以通过修改 jena-weather/data.js 中的
nextBatchFn()
函数来实现这一点。实现重新排序最容易的地方是
samples.set(value, j, exampleRow, exampleCol++);
- 在这里,你可以使用一个执行固定排列的函数将索引
exampleRow
映射到一个新值,并以类似的方式映射exampleCol
。
- 我们为 IMDb 情感分析构建的 1D 卷积神经网络仅包含一个 conv1d 层(参见清单 9.8)。正如我们讨论的那样,在其上叠加更多的 conv1d 层可能会给我们一个更深的 1D 卷积神经网络,能够捕捉到更长一段单词的顺序信息。在这个练习中,尝试修改 sentiment/train.js 中
buildModel()
函数中的代码。目标是在现有的层之后添加另一个 conv1d 层,重新训练模型,并观察其分类精度是否有所提高。新的 conv1d 层可以使用与现有层相同数量的滤波器和内核大小。此外,请阅读修改后模型的摘要中的输出形状,并确保您理解filters
和kernelSize
参数如何影响新 conv1d 层的输出形状。 - 在日期转换注意事项示例中,尝试添加更多的输入日期格式。以下是您可以选择的新格式,按照编码难度递增的顺序排序。您也可以自己想出自己的日期格式:
- YYYY-MMM-DD 格式:例如,“2012 年 3 月 8 日”或“2012 年 3 月 18 日”。根据单个数字日期是否在前面补零(如 2015/03/12),这实际上可能是两种不同的格式。但是,无论如何填充,此格式的最大长度都小于 12,并且所有可能的字符都已经在 date-conversion-attention/date_format.js 中的
INPUT_VOCAB
中。因此,只需向文件添加一个或两个函数即可,这些函数可以模仿现有函数,例如dateTupleToMMMSpaceDDSpaceYY()
。确保将新函数添加到文件中的INPUT_FNS
数组中,以便它们可以包含在训练中。作为最佳实践,您还应该为新的日期格式函数添加单元测试到 date-conversion-attention/date_format_test.js 中。 - 一个使用序数作为日期部分的格式,比如“3 月 8 日,2012 年”。请注意,这与现有的
dateTupleToMMMSpaceDDComma-SpaceYYYY()
格式相同,只是日期数字后缀了序数后缀("st"
,"nd"
和"th"
)。你的新函数应该包括根据日期值确定后缀的逻辑。此外,你需要将date_format_test.js
中的INPUT_LENGTH
常量修改为一个更大的值,因为此格式中日期字符串的最大可能长度超过了当前值 12。此外,需要将字母"t"
和"h"
添加到INPUT_VOCAB
中,因为它们不出现在任何三个字母月份字符串中。 - 现在考虑一个使用完整的英文月份名称拼写的格式,比如“2012 年 3 月 8 日”。输入日期字符串的最大可能长度是多少?你应该如何相应地更改
date_format.js
中的INPUT_VOCAB
?
摘要
- 由于能够提取和学习事物的顺序信息,循环神经网络(RNN)可以在涉及顺序输入数据的任务中胜过前馈模型(例如 MLP)。我们通过将 simpleRNN 和 GRU 应用于温度预测问题的示例来看到这一点。
- TensorFlow.js 提供了三种类型的 RNN:simpleRNN、GRU 和 LSTM。后两种类型比 simpleRNN 更复杂,因为它们使用更复杂的内部结构来使得能够在许多时间步骤中保持内存状态,从而缓解了梯度消失问题。GRU 的计算量比 LSTM 小。在大多数实际问题中,您可能希望使用 GRU 和 LSTM。
- 在构建文本的神经网络时,文本输入首先需要表示为数字向量。这称为文本向量化。文本向量化的最常用方法包括 one-hot 和 multi-hot 编码,以及更强大的嵌入方法。
- 在词嵌入中,每个单词被表示为一个稀疏向量,其中元素值通过反向传播学习,就像神经网络的所有其他权重参数一样。在 TensorFlow.js 中执行嵌入的函数是
tf.layers.embedding()
。 - seq2seq 问题与基于序列的回归和分类问题不同,因为它们涉及生成一个新序列作为输出。循环神经网络(RNN)可以与其他类型的层一起用于形成编码器-解码器架构来解决 seq2seq 问题。
- 在 seq2seq 问题中,注意机制使得输出序列的不同项能够选择性地依赖于输入序列的特定元素。我们演示了如何训练基于注意力的编码器-解码器网络来解决简单的日期转换问题,并在推断过程中可视化注意力矩阵。