3.4 电影评论分类:二分类问题
二分类问题可能是应用最广泛的机器学习问题。在这个例子中,你将学习根据电影评论的文字内容将其划分为正面或负面。
3.4.1 IMDB数据集
本节使用 IMDB数据集,它包含来自互联网电影数据库( IMDB)的 50 000条严重两极分化的评论。数据集被分为用于训练的 25 000条评论与用于测试的 25 000条评论,训练集和测试集都包含 50%的正面评论和 50%的负面评论。
为什么要将训练集和测试集分开?因为你不应该将训练机器学习模型的同一批数据再用于测试模型!模型在训练数据上的表现很好,并不意味着它在前所未见的数据上也会表现得很好,而且你真正关心的是模型在新数据上的性能(因为你已经知道了训练数据对应的标签,显然不再需要模型来进行预测)。例如,你的模型最终可能只是记住了训练样本和目标值之间的映射关系,但这对在前所未见的数据上进行预测毫无用处。下一章将会更详细地讨论这一点。
与 MNIST数据集一样,IMDB数据集也内置于 Keras库。它已经过预处理:评论(单词序列)已经被转换为整数序列,其中每个整数代表字典中的某个单词。
下列代码将会加载 IMDB数据集(第一次运行时会下载大约 80MB的数据)。
代码清单 3-1 加载 IMDB数据集
from keras.datasets import imdb (train_data, train_labels), (test_data, test_labels) = imdb.load_data(num_words=10000)
参数num_words=10000的意思是仅保留训练数据中前 10 000个最常出现的单词。低频单词将被舍弃。这样得到的向量数据不会太大,便于处理。
train_data和test_data这两个变量都是评论组成的列表,每条评论又是单词索引组成的列表(表示一系列单词)。train_labels和test_labels都是 0和 1组成的列表,其中 0代表负面(negative),1代表正面(positive)。
>>> train_data[0] [1, 14, 22, 16, ... 178, 32] >>> train_labels[0] 1
由于限定为前 10 000个最常见的单词,单词索引都不会超过 10 000。
>>> max([max(sequence) for sequence in train_data]) 9999
下面这段代码很有意思,你可以将某条评论迅速解码为英文单词。
3.4.2 准备数据
你不能将整数序列直接输入神经网络。你需要将列表转换为张量。转换方法有以下两种。
- 填充列表,使其具有相同的长度,再将列表转换成形状为(samples, word_indices)的整数张量,然后网络第一层使用能处理这种整数张量的层(即 Embedding层,本书后面会详细介绍)。
- 对列表进行 one-hot编码,将其转换为 0和 1组成的向量。举个例子,序列[3, 5]将会被转换为 10 000维向量,只有索引为 3和 5的元素是 1,其余元素都是 0。然后网络第一层可以用Dense层,它能够处理浮点数向量数据。
下面我们采用后一种方法将数据向量化。为了加深理解,你可以手动实现这一方法,如下所示。
代码清单 3-2 将整数序列编码为二进制矩阵
3.4.3 构建网络
输入数据是向量,而标签是标量( 1和 0),这是你会遇到的最简单的情况。有一类网络在这种问题上表现很好,就是带有relu激活的全连接层(Dense)的简单堆叠,比如Dense(16, activation=’relu’)。
传入Dense层的参数(16)是该层隐藏单元的个数。一个隐藏单元(hidden unit)是该层表示空间的一个维度。我们在第 2章讲过,每个带有 relu激活的Dense层都实现了下列张量运算:
output = relu(dot(W, input) + b)
16个隐藏单元对应的权重矩阵W的形状为(input_dimension, 16),与W做点积相当于将输入数据投影到 16维表示空间中(然后再加上偏置向量 b并应用relu运算)。你可以将表示空间的维度直观地理解为“网络学习内部表示时所拥有的自由度”。隐藏单元越多(即更高维的表示空间),网络越能够学到更加复杂的表示,但网络的计算代价也变得更大,而且可能会导致学到不好的模式(这种模式会提高训练数据上的性能,但不会提高测试数据上的性能)。
对于这种Dense层的堆叠,你需要确定以下两个关键架构:
- 网络有多少层;
- 每层有多少个隐藏单元。
第 4章中的原则将会指导你对上述问题做出选择。现在你只需要相信我选择的下列架构:
- 两个中间层,每层都有 16个隐藏单元;
- 第三层输出一个标量,预测当前评论的情感。
中间层使用relu作为激活函数,最后一层使用 sigmoid激活以输出一个 0~1范围内的概率值(表示样本的目标值等于 1的可能性,即评论为正面的可能性)。relu(rectified linear unit,整流线性单元)函数将所有负值归零(见图 3-4),而 sigmoid函数则将任意值“压缩”到 [0,1]区间内(见图 3-5),其输出值可以看作概率值。
图 3-4 整流线性单元函数
图 3-5 sigmoid函数
图 3-6显示了网络的结构。代码清单 3-3是其 Keras实现,与前面见过的 MNIST例子类似。
图 3-6 三层网络
代码清单 3-3 模型定义
from keras import models from keras import layers model = models.Sequential() model.add(layers.Dense(16, activation='relu', input_shape=(10000,))) model.add(layers.Dense(16, activation='relu')) model.add(layers.Dense(1, activation='sigmoid'))
什么是激活函数?为什么要使用激活函数?
如果没有relu等激活函数(也叫非线性),Dense层将只包含两个线性运算——点积和加法:
output = dot(W, input) + b
这样Dense层就只能学习输入数据的线性变换(仿射变换):该层的假设空间是从输入数据到 16位空间所有可能的线性变换集合。这种假设空间非常有限,无法利用多个表示层的优势,因为多个线性层堆叠实现的仍是线性运算,添加层数并不会扩展假设空间。
为了得到更丰富的假设空间,从而充分利用多层表示的优势,你需要添加非线性或激活函数。relu是深度学习中最常用的激活函数,但还有许多其他函数可选,它们都有类似的奇怪名称,比如prelu、elu等。
最后,你需要选择损失函数和优化器。由于你面对的是一个二分类问题,网络输出是一个概率值(网络最后一层使用 sigmoid激活函数,仅包含一个单元),那么最好使用 binary_crossentropy(二元交叉熵)损失。这并不是唯一可行的选择,比如你还可以使用 mean_squared_error(均方误差)。但对于输出概率值的模型,交叉熵(crossentropy)往往是最好的选择。交叉熵是来自于信息论领域的概念,用于衡量概率分布之间的距离,在这个例子中就是真实分布与预测值之间的距离。
下面的步骤是用 rmsprop优化器和binary_crossentropy损失函数来配置模型。注意,我们还在训练过程中监控精度。
代码清单 3-4 编译模型
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['accuracy'])
上述代码将优化器、损失函数和指标作为字符串传入,这是因为rmsprop、binary_crossentropy和accuracy都是 Keras内置的一部分。有时你可能希望配置自定义优化器的参数,或者传入自定义的损失函数或指标函数。前者可通过向optimizer参数传入一个优化器类实例来实现,如代码清单 3-5所示;后者可通过向loss和metrics参数传入函数对象来实现,如代码清单 3-6所示。
代码清单 3-5 配置优化器
from keras import optimizers model.compile(optimizer=optimizers.RMSprop(lr=0.001), loss='binary_crossentropy', metrics=['accuracy'])
代码清单 3-6 使用自定义的损失和指标
from keras import losses from keras import metrics model.compile(optimizer=optimizers.RMSprop(lr=0.001), loss=losses.binary_crossentropy, metrics=[metrics.binary_accuracy])
3.4.4 验证你的方法
为了在训练过程中监控模型在前所未见的数据上的精度,你需要将原始训练数据留出 10 000个样本作为验证集。
代码清单 3-7 留出验证集
x_val = x_train[:10000] partial_x_train = x_train[10000:] y_val = y_train[:10000] partial_y_train = y_train[10000:]
现在使用 512个样本组成的小批量,将模型训练 20个轮次(即对x_train和y_train两个张量中的所有样本进行 20次迭代)。与此同时,你还要监控在留出的 10 000个样本上的损失和精度。你可以通过将验证数据传入validation_data参数来完成。
代码清单 3-8 训练模型
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc']) history = model.fit(partial_x_train, partial_y_train, epochs=20, batch_size=512, validation_data=(x_val, y_val))
在 CPU上运行,每轮的时间不到 2秒,训练过程将在 20秒内结束。每轮结束时会有短暂的停顿,因为模型要计算在验证集的 10 000个样本上的损失和精度。
注意,调用model.fit()返回了一个History对象。这个对象有一个成员history,它是一个字典,包含训练过程中的所有数据。我们来看一下。
>>> history_dict = history.history >>> history_dict.keys() dict_keys(['val_acc', 'acc', 'val_loss', 'loss'])
字典中包含 4个条目,对应训练过程和验证过程中监控的指标。在下面两个代码清单中,我们将使用 Matplotlib在同一张图上绘制训练损失和验证损失(见图 3-7),以及训练精度和验证精度(见图 3-8)。请注意,由于网络的随机初始化不同,你得到的结果可能会略有不同。
代码清单 3-9 绘制训练损失和验证损失
图 3-7 训练损失和验证损失
代码清单 3-10 绘制训练精度和验证精度
图 3-8 训练精度和验证精度
如你所见,训练损失每轮都在降低,训练精度每轮都在提升。这就是梯度下降优化的预期结果——你想要最小化的量随着每次迭代越来越小。但验证损失和验证精度并非如此:它们似乎在第四轮达到最佳值。这就是我们之前警告过的一种情况:模型在训练数据上的表现越来越好,但在前所未见的数据上不一定表现得越来越好。准确地说,你看到的是过拟合(overfit):在第二轮之后,你对训练数据过度优化,最终学到的表示仅针对于训练数据,无法泛化到训练集之外的数据。
在这种情况下,为了防止过拟合,你可以在 3轮之后停止训练。通常来说,你可以使用许多方法来降低过拟合,我们将在第 4章中详细介绍。
我们从头开始训练一个新的网络,训练 4轮,然后在测试数据上评估模型。
代码清单 3-11 从头开始重新训练一个模型
model = models.Sequential() model.add(layers.Dense(16, activation='relu', input_shape=(10000,))) model.add(layers.Dense(16, activation='relu')) model.add(layers.Dense(1, activation='sigmoid')) model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['accuracy']) model.fit(x_train, y_train, epochs=4, batch_size=512) results = model.evaluate(x_test, y_test)
最终结果如下所示。
>>> results [0.2929924130630493, 0.88327999999999995]
这种相当简单的方法得到了 88%的精度。利用最先进的方法,你应该能够得到接近 95%的精度。
3.4.5 使用训练好的网络在新数据上生成预测结果
训练好网络之后,你希望将其用于实践。你可以用predict方法来得到评论为正面的可能性大小。
>>> model.predict(x_test) array([[ 0.98006207] [ 0.99758697] [ 0.99975556] ..., [ 0.82167041] [ 0.02885115] [ 0.65371346]], dtype=float32)
如你所见,网络对某些样本的结果非常确信(大于等于 0.99,或小于等于 0.01),但对其他结果却不那么确信(0.6或 0.4)。
3.4.6 进一步的实验
通过以下实验,你可以确信前面选择的网络架构是非常合理的,虽然仍有改进的空间。
- 前面使用了两个隐藏层。你可以尝试使用一个或三个隐藏层,然后观察对验证精度和测试精度的影响。
- 尝试使用更多或更少的隐藏单元,比如 32个、64个等。
- 尝试使用mse损失函数代替binary_crossentropy。
- 尝试使用tanh激活(这种激活在神经网络早期非常流行)代替relu。
3.4.7 小结
下面是你应该从这个例子中学到的要点。
- 通常需要对原始数据进行大量预处理,以便将其转换为张量输入到神经网络中。单词序列可以编码为二进制向量,但也有其他编码方式。
- 带有relu激活的Dense层堆叠,可以解决很多种问题(包括情感分类),你可能会经常用到这种模型。
- 对于二分类问题(两个输出类别),网络的最后一层应该是只有一个单元并使用sigmoid激活的Dense层,网络输出应该是 0~1范围内的标量,表示概率值。
- 对于二分类问题的 sigmoid标量输出,你应该使用binary_crossentropy损失函数。
- 无论你的问题是什么,rmsprop优化器通常都是足够好的选择。这一点你无须担心。
- 随着神经网络在训练数据上的表现越来越好,模型最终会过拟合,并在前所未见的数据上得到越来越差的结果。一定要一直监控模型在训练集之外的数据上的性能。