将正解做 One-hot Encoding
到目前为止,我们已经将所有的新闻标题以数字型态表示,只剩分类栏位 label
要进行从文本到数字的转换了:
train.label[:5]
不过 label
的处理相对简单。跟新闻标题相同,我们一样需要一个字典将分类的文字转换成索引:
import numpy as np # 定义每一个分类对应到的索引数字 label_to_index = { 'unrelated': 0, 'agreed': 1, 'disagreed': 2 } # 将分类标籤对应到刚定义的数字 y_train = train.label.apply( lambda x: label_to_index[x]) y_train = np.asarray(y_train) \ .astype('float32') y_train[:5]
现在每个分类的文字标籤都已经被转成对应的数字,接著让我们利用 Keras 做 One Hot Encoding:
y_train = keras \ .utils \ .to_categorical(y_train) y_train[:5]
上述矩阵的每一列即为 1 个 label,而你可以看到现在每个 label 都从 1 个数字变成一个 3 维的向量(Vector)。
每 1 维度则对应到 1 个分类:
-
[1, 0, 0]
代表 label 为unrelated
-
[0, 1, 0]
代表 label 为agreed
-
[0, 0, 1]
代表 label 为disagreed
用这样的方式表达 label 的好处是我们可以把分类结果想成机率分佈。[1, 0, 0]
就代表一组新闻标题 A、B 为 unrelated
的机率等于 100 %。
[图片上传失败...(image-9343c1-1589534455753)]
One Hot Encoding 示意图
在决定如何衡量模型的表现一节我们会看到,给定一组新闻标题 A、B,我们的模型会预测此成对标题属于每个分类的机率值,比方说 [0.7, 0.2, 0.1]
。而此预测结果代表模型认为这 2 个新闻标题的关係有 70 % 的机率为 unrelated
、20 % 的机率是 agreed
而 10 % 为 disagreed
。
因此,如果正解也事先用同样的方式表达的话,会让我们比较好计算以下两者之间的差距:
- 正确的分类的机率分佈(
[1, 0, 0]
) - 模型预测出的机率分佈(
[0.7, 0.2, 0.1]
)
在知道预测结果跟正确解答之间差距多少之后,深度学习模型就会自动修正学习方向,想尽办法拉近这个差距。
好,到此为止所有的数据都已经被我们转换成方便机器使用的格式了。最后,让我们将整个资料集拆成训练资料集 & 验证资料集 以方便之后测试模型的效能。
(别哀号,我保证这是最后的前处理步骤了!)
切割训练资料集 & 验证资料集
这部分很简单,我们只需决定要将整个训练资料集(Training Set)的多少比例切出来当作验证资料集(Validation Set)。此例中我们用 10 %。
但为何要再把本来的训练资料集切成 2 个部分呢?
一般来说,我们在训练时只会让模型看到训练资料集,并用模型没看过的验证资料集来测试该模型在真实世界的表现。(毕竟我们没有测试资料集的答案)
我们会反覆在 Train / Valid Set 上训练并测试模型,最后用 Test Set 一决生死 (图片来源)
等到模型在验证资料集也表现得够好后,便在最终的测试资料集(Test Set)进行最后一次的预测并将该结果上传到 Kaggle。
要了解为何我们需要验证资料集可以查看这边的讨论。
简而言之,当你多次利用验证资料集的预测结果以修正模型,并让它在该资料集表现更好时,过适(Overfitting)的风险就已经产生了。
反覆利用验证资料集的结果来修正模型表现,事实上就等于让模型「偷看」到验证资料集本身的资讯了
儘管你没有直接让模型看到验证资料集(Validation Set)内的任何数据,你还是间接地洩漏了该资料集的重要资讯:你让模型知道怎样的参数设定会让它在该资料集表现比较好,亦或表现较差。
因此有一个完全跟模型训练过程独立的测试资料集(Test Set)就显得重要许多了。(这也是为何我到现在都还没有碰它的原因)
机器学习模型努力从夏令营(训练及验证资料集)学习技能,并在真实世界(测试资料集)展示其学习结果。
回归正题,要切训练资料集 / 验证资料集,scikit-learn 中的 train_test_split
函式是一个不错的选择:
from sklearn.model_selection \ import train_test_split VALIDATION_RATIO = 0.1 # 小彩蛋 RANDOM_STATE = 9527 x1_train, x1_val, \ x2_train, x2_val, \ y_train, y_val = \ train_test_split( x1_train, x2_train, y_train, test_size=VALIDATION_RATIO, random_state=RANDOM_STATE )
在这边,我们分别将新闻标题 A x1_train
、新闻标题 B x2_train
以及分类标籤 y_train
都分成两个部分:训练部分 & 验证部分。
以假新闻 A 的标题 x1_train
为例,本来完整 32 万笔的 x1_train
会被分为包含 90 % 数据的训练资料集 x1_train
以及 10 % 的验证资料集 x1_val
。
print("Training Set") print("-" * 10) print(f"x1_train: {x1_train.shape}") print(f"x2_train: {x2_train.shape}") print(f"y_train : {y_train.shape}") print("-" * 10) print(f"x1_val: {x1_val.shape}") print(f"x2_val: {x2_val.shape}") print(f"y_val : {y_val.shape}") print("-" * 10) print("Test Set")
我们可以看到,切割后的训练资料集有 288,488 笔资料。每一笔资料裡头,成对新闻标题 A & B 的长度皆为 20 个 Tokens,分类结果则有 3 个;验证资料集的内容一模一样,仅差在资料笔数较少(32,055 笔)。
到此为此,资料前处理大功告成!
既然我们已经为机器准备好它们容易理解的数字序列资料,接著就让我们来看看要使用怎麽样的 NLP 模型来处理这些数据。
有记忆的循环神经网路
针对这次的 Kaggle 竞赛,我们将使用循环神经网路(Recurrent Neural Network, 后简称 RNN)来处理刚刚得到的序列数据。
RNN 是一种有「记忆力」的神经网路,其最为人所知的形式如下:
如同上图等号左侧所示,RNN 跟一般深度学习中常见的前馈神经网路(Feedforward Neural Network, 后简称 FFNN)最不一样的地方在于它有一个迴圈(Loop)。
要了解这个迴圈在 RNN 里头怎麽运作,现在让我们想像有一个输入序列 X(Input Sequence)其长相如下:
不同于 FFNN,RNN 在第一个时间点 t0
并不会直接把整个序列 X 读入。反之,在第一个时间点 t0
,它只将该序列中的第一个元素 x0
读入中间的细胞 A。细胞 A 则会针对 x0
做些处理以后,更新自己的「状态」并输出第一个结果 h0
。
在下个时间点 t1
,RNN 如法炮製,读入序列 X 中的下一个元素 x1
,并利用刚刚处理完 x0
得到的细胞状态,处理 x1
并更新自己的状态(也被称为记忆),接著输出另个结果 h1
。
剩下的 xt
都会被以同样的方式处理。但不管输入的序列 X 有多长,RNN 的本体从头到尾都是等号左边的样子:迴圈代表细胞 A 利用「上」一个时间点(比方说 t1
)储存的状态,来处理当下的输入(比方说 x2
)。
但如果你将不同时间点(t0
、t1
...)的 RNN 以及它的输入一起截图,并把所有截图从左到右一字排开的话,就会长得像等号右边的形式。
将 RNN 以右边的形式表示的话,你可以很清楚地了解,当输入序列越长,向右展开的 RNN 也就越长。(模型也就需要训练更久时间,这也是为何我们在资料前处理时设定了序列的最长长度)
为了确保你 100 % 理解 RNN,让我们假设刚刚的序列 X 实际上是一个内容如下的英文问句:
而且 RNN 已经处理完前两个元素 What
和 time
了。
则接下来 RNN 会这样处理剩下的句子:
RNN 一次只读入并处理序列的「一个」元素 (图片来源)
现在你可以想像为何 RNN 非常适合拿来处理像是自然语言这种序列数据了。
就像你现在阅读这段话一样,你是由左到右逐字在大脑裡处理我现在写的文字,同时不断地更新你脑中的记忆状态。
每当下个词彙映入眼中,你脑中的处理都会跟以下两者相关:
- 前面所有已读的词彙
- 目前脑中的记忆状态
当然,实际人脑的阅读机制更为複杂,但 RNN 抓到这个处理精髓,利用内在迴圈以及细胞内的「记忆状态」来处理序列资料。
RNN 按照顺序,处理一连串词彙的机制跟我们理解自然语言的方式有许多相似之处
到此为止,你应该已经了解 RNN 的基本运作方式了。现在你可能会问:「那我们该如何实作一个 RNN 呢?」
好问题,以下是一个简化到不行的 RNN 实现:
state_t = 0 for input_t in input_sequence: output_t = f(input_t, state_t) state_t = output_t
在 RNN 每次读入任何新的序列数据前,细胞 A 中的记忆状态 state_t
都会被初始化为 0。
接著在每个时间点 t
,RNN 会重複以下步骤:
- 读入
input_sequence
序列中的一个新元素input_t
- 利用
f
函式将当前细胞的状态state_t
以及输入input_t
做些处理产生output_t
- 输出
output_t
并同时更新自己的状态state_t
不需要自己发明轮子,在 Keras 里头只要 2 行就可以建立一个 RNN layer:
from keras import layers rnn = layers.SimpleRNN()
使用深度学习框架可以帮我们省下非常多的宝贵时间并避免可能的程式错误。
我们后面还会看到,一个完整的神经网路通常会分成好几层(layer):每一层取得前一层的结果作为输入,进行特定的资料转换后再输出给下一层。
常见的神经网路形式。图中框内有迴圈的就是 RNN 层 (图片来源)
好啦,相信你现在已经掌握基本 RNN 了。事实上,除了 SimpleRNN
以外,Keras 裡头还有其他更常被使用的 Layer,现在就让我们看看一个知名的例子:长短期记忆。
记忆力好的 LSTM 细胞
让我们再看一次前面的简易 RNN 实作:
state_t = 0 # 细胞 A 会重複执行以下处理 for input_t in input_sequence: output_t = f(input_t, state_t) state_t = output_t
在了解 RNN 的基本运作方式以后,你会发现 RNN 真正的魔法,事实上藏在细胞 A 的 f
函式里头。
要如何将细胞 A 当下的记忆 state_t
与输入 input_t
结合,才能产生最有意义的输出 output_t
呢?
在 SimpleRNN
的细胞 A 裡头,这个 f
的实作很简单。而这导致其记忆状态 state_t
没办法很好地「记住」前面处理过的序列元素,造成 RNN 在处理后来的元素时,就已经把前面重要的资讯给忘记了。
这就好像一个人在讲了好长一段话以后,忘了前面到底讲过些什麽的情境。
长短期记忆(Long Short-Term Memory, 后简称 LSTM)就是被设计来解决 RNN 的这个问题。
如下图所示,你可以把 LSTM 想成是 RNN 中用来实现细胞 A 内部处理逻辑的一个特定方法:
以抽象的层次来看,LSTM 就是实现 RNN 中细胞 A 逻辑的一个方式 (图片来源)
基本上一个 LSTM 细胞裡头会有 3 个闸门(Gates)来控制细胞在不同时间点的记忆状态:
- Forget Gate:决定细胞是否要遗忘目前的记忆状态
- Input Gate:决定目前输入有没有重要到值得处理
- Output Gate:决定更新后的记忆状态有多少要输出
透过这些闸门控管机制,LSTM 可以将很久以前的记忆状态储存下来,在需要的时候再次拿出来使用。值得一提的是,这些闸门的参数也都是神经网路自己训练出来的。
下图显示各个闸门所在的位置:
LSTM 细胞顶端那条 cell state 正代表著细胞记忆的转换过程 (图片来源)
想像 LSTM 细胞裡头的记忆状态是一个包裹,上面那条直线就代表著一个输送带。
LSTM 可以把任意时间点的记忆状态(包裹)放上该输送带,然后在未来的某个时间点将其原封不动地取下来使用。
因为这样的机制,让 LSTM 即使面对很长的序列数据也能有效处理,不遗忘以前的记忆。
因为效果卓越,LSTM 非常广泛地被使用。事实上,当有人跟你说他用 RNN 做了什麽 NLP 专案时,有 9 成机率他是使用 LSTM 或是 GRU(LSTM 的改良版,只使用 2 个闸门) 来实作,而不是使用最简单的 SimpleRNN
。
因此,在这次 Kaggle 竞赛中,我们的第一个模型也将使用 LSTM。而在 Keras 裡头要使用 LSTM 也是轻鬆写意:
from keras import layers lstm = layers.LSTM()
现在,既然我们已经有了在资料前处理步骤被转换完成的序列数据,也决定好要使用 LSTM 作为我们的 NLP 模型,接著就让我们试著将这些数据读入 LSTM 吧!
词向量:将词彙表达成有意义的向量
在将序列数据塞入模型之前,让我们重新检视一下数据。比方说,以下是在训练资料集裡头前 5 笔的假新闻标题 A:
for i, seq in enumerate(x1_train[:5]): print(f"新闻标题 {i + 1}: ") print(seq) print()
你可以看到,每个新闻标题都被转成长度为 20 的数字序列。裡头的每个数字都代表著一个词彙( 0
代表 Zero Padding)。
x1_train.shape
而我们在训练资料集则总共有 288,488 笔新闻标题,每笔标题如同刚刚所说的,是一个包含 20 个数字的序列。
当然,我们可以用 tokenizer
裡头的字典 index_word
还原文本看看:
for i, seq in enumerate(x1_train[:5]): print(f"新闻标题 {i + 1}: ") print([tokenizer.index_word.get(idx, '') for idx in seq]) print()
其他新闻标题像是:
- 训练资料集中的新闻标题 B
x2_train
- 验证资料集中的新闻标题 A
x1_val
- 验证资料集中的新闻标题 B
x2_val
也都是以这样的数字序列形式被储存。
但事实上要让神经网路能够处理标题序列内的词彙,我们要将它们表示成向量(更精准地说,是张量:Tensor),而不是一个单纯数字。
如果我们能做到这件事情,则 RNN 就能用以下的方式读入我们的资料:
注意:在每个时间点被塞入 RNN 的「词彙」不再是 1 个数字,而是一个 N 维向量(图中 N 为 3) (图片来源)
所以现在的问题变成:
「要怎麽将一个词彙表示成一个 N 维向量 ?」
其中一个方法是我们随便决定一个 N,然后为语料库裡头的每一个词彙都指派一个随机生成的 N 维向量。
假设我们现在有 5 个词彙:
- 野狼
- 老虎
- 狗
- 猫
- 喵咪
依照刚刚说的方法,我们可以设定 N = 2,并为每个词彙随机分配一个 2 维向量后将它们画在一个平面空间裡头:
这些代表词彙的向量被称之为词向量,但是你可以想像这样的随机转换很没意义。
比方说上图,我们就无法理解:
- 为何「狗」是跟「老虎」而不是跟同为犬科的「野狼」比较接近?
- 为何「猫」的维度 2 比「狗」高,但却比「野狼」低?
- 维度 2 的值的大小到底代表什麽意义?
- 「喵咪」怎麽会在那裡?
这是因为我们只是将词彙随机地转换到 2 维空间,并没有让这些转换的结果(向量)反应出词彙本身的语意(Semantic)。
一个理想的转换应该是像底下这样:
在这个 2 维空间裡头,我们可以发现一个好的转换有 2 个特性:
- 距离有意义:「喵咪」与意思相近的词彙「猫」距离接近,而与较不相关的「狗」距离较远
- 维度有意义:看看(狗, 猫)与(野狼, 老虎)这两对组合,可以发现我们能将维度 1 解释为猫科 VS 犬科;维度 2 解释为宠物与野生动物
如果我们能把语料库(Corpus)里头的每个词彙都表示成一个像是这样有意义的词向量,神经网路就能帮我们找到潜藏在大量词彙中的语义关係,并进一步改善 NLP 任务的精准度。
好消息是,大部分的情况我们并不需要自己手动设定每个词彙的词向量。我们可以随机初始化所有词向量(如前述的随机转换),并利用平常训练神经网路的反向传播算法(Backpropagation),让神经网路自动学到一组适合当前 NLP 任务的词向量(如上张图的理想状态)。
反向传播让神经网路可以在训练过程中修正参数,持续减少预测错误的可能性 (图片来源)
在 NLP 里头,这种将一个词彙或句子转换成一个实数词向量(Vectors of real numbers)的技术被称之为词嵌入(Word Embedding)。
而在 Keras 裡头,我们可以使用 Embedding
层来帮我们做到这件事情:
from keras import layers embedding_layer = layers.Embedding( MAX_NUM_WORDS, NUM_EMBEDDING_DIM)
MAX_NUM_WORDS
是我们的字典大小(10,000 个词彙)、NUM_EMBEDDING_DIM
则是词向量的维度。常见的词向量维度有 128、256 或甚至 1,024。
Embedding
层一次接收 k 个长度任意的数字序列,并输出 k 个长度相同的序列。输出的序列中,每个元素不再是数字,而是一个 NUM_EMBEDDING_DIM
维的词向量。
假如我们将第一笔(也就是 k = 1)假新闻标题 A 丢入 Embedding
层,并设定 NUM_EMBEDDING_DIM
为 3 的话,原来的标题 A:
就会被转换成类似以下的形式:
序列裡头的每个数字(即词彙)都被转换成一个 3 维的词向量,而相同数字则当然都会对应到同一个词向量(如前 3 个 0
所对应到的词向量)。
Keras 的 Embedding Layer 让我们可以轻鬆地将词彙转换成适合神经网路的词向量 (图片来源)
有了这样的转换,我们就能将转换后的词向量丢入 RNN / LSTM 里头,让模型逐步修正随机初始化的词向量,使得词向量裡头的值越来越有意义。
有了两个新闻标题的词向量,接著让我们瞧瞧能够处理这些数据的神经网路架构吧!
一个神经网路,两个新闻标题
一般来说,多数你见过的神经网路只会接受一个资料来源:
- 输入一张图片,判断是狗还是猫
- 输入一个音讯,将其转成文字
- 输入一篇新闻,判断是娱乐还是运动新闻
单一输入的神经网路架构可以解决大部分的深度学习问题。但在这个 Kaggle 竞赛裡头,我们想要的是一个能够读入成对新闻标题,并判断两者之间关係的神经网路架构:
- 不相关(unrelated)
- 新闻 B 同意 A(agreed)
- 新闻 B 不同意 A(disagreed)
要怎麽做到这件事情呢?
我们可以使用孪生神经网路(Siamese Network)架构:
使用孪生神经网路架构来处理同类型的 2 个新闻标题
这张图是本文最重要的一张图,但现在你只需关注红框的部分即可。剩馀细节我会在后面的定义神经网路的架构小节详述。
重複观察几次,我相信你就会知道何谓孪生神经网路架构:一部份的神经网路(红框部分)被重複用来处理多个不同的资料来源(在本篇中为 2 篇不同的新闻标题)。
而会想这样做,是因为不管标题内容是新闻 A 还是新闻 B,其标题本身的语法 & 语义结构大同小异。
神经网路说到底,就跟其他机器学习方法相同,都是对输入进行一连串有意义的数据转换步骤。神经网路将输入的数据转换成更适合解决当前任务的数据格式,并利用转换后的数据进行预测。
以这样的观点来看的话,我们并不需要两个不同的 LSTM 来分别将新闻 A 以及新闻 B 的词向量做有意义的转换,而是只需要让标题 A 与标题 B 共享一个 LSTM 即可。毕竟,标题 A 跟标题 B 的数据结构很像。
如果我们只写一个 Python 函式就能处理 2 个相同格式的输入的话,为何要写 2 个函式呢?
孪生神经网路也是相同的概念。
孪生神经网路名称概念来自 Siamese Twins,这是发生在美国 19 世纪的一对连体泰国人兄弟的故事。你可以想像孪生神经网路架构裡头也有 2 个一模一样的神经网路双胞胎。(感谢网友 Hu Josh 纠正) (图片来源)
好了,在了解如何同时读入 2 个资料来源后,就让我们实际用 Keras 动手将此模型建出来吧!