使用TensorFlow解决电影评论分类--二分类问题

620 阅读13分钟

写在前面

二分类问题可能是应用最广泛的机器学习问题。在这个例子中,你将学习根据电影评论的 文字内容将其划分为正面或负面。本节使用IMDB 数据集,它包含来自互联网电影数据库(IMDB)的50 000 条严重两极分 化的评论。数据集被分为用于训练的 25 000 条评论与用于测试的 25 000 条评论,训练集和测试 集都包含 50% 的正面评论和 50% 的负面评论。再此之前,先说说相关的流程定义。相关代码基于TensorFlow实现,你也可以只用Keras实现,整个项目就是围绕Keras进行开发的。

不同的张量格式与不同的数据处理类型需要用到不同的层。例如,简单的向量数据保存在 形状为 (samples, features) 的 2D 张量中,通常用密集连接层[densely connected layer,也叫全连接层(fully connected layer)或密集层(dense layer),对应于Keras 的 Dense 类]来处 理。序列数据保存在形状为 (samples, timesteps, features) 的 3D 张量中,通常用循环层(recurrent layer,比如Keras 的 LSTM 层)来处理。图像数据保存在4D 张量中,通常用二维卷积层(Keras 的 Conv2D)来处理。

你可以将层看作深度学习的乐高积木,Keras 等框架则将这种比喻具体化。在 Keras 中,构 建深度学习模型就是将相互兼容的多个层拼接在一起,以建立有用的数据变换流程。这里层兼 容性(layer compatibility)具体指的是每一层只接受特定形状的输入张量,并返回特定形状的输 出张量。看看下面这个例子。

from tensorflow.keras import layers
layer = layers.Dense(32, input_shape(784,))

我们创建了一个层,只接受第一个维度大小为784 的 2D 张量(第0 轴是批量维度,其大 小没有指定,因此可以任意取值)作为输入。这个层将返回一个张量,第一个维度的大小变成 了 32。

模型

深度学习模型是层构成的有向无环图。最常见的例子就是层的线性堆叠,将单一输入映射 为单一输出。 但随着深入学习,你会接触到更多类型的网络拓扑结构。一些常见的网络拓扑结构如下。

  • 双分支(two-branch)网络 +
  • 多头(multihead)网络
  • Inception 模块

损失函数与优化器

一旦确定了网络架构,你还需要选择以下两个参数。

  • 损失函数(目标函数)——在训练过程中需要将其最小化。它能够衡量当前任务是否已 成功完成。
  • 优化器——决定如何基于损失函数对网络进行更新。它执行的是随机梯度下降(SGD) 的某个变体。

具有多个输出的神经网络可能具有多个损失函数(每个输出对应一个损失函数)。但是,梯 度下降过程必须基于单个标量损失值。因此,对于具有多个损失函数的网络,需要将所有损失函数取平均,变为一个标量值。

对于分类、回归、序列预测等常见问题,你可以遵循一些简单的指导原则来选 择正确的损失函数。例如,对于二分类问题,你可以使用二元交叉熵(binary crossentropy)损失函数;对于多分类问题,可以用分类交叉熵(categorical crossentropy)损失函数;对于回归问题,可以用均方误差(mean-squared error)损失函数;对于序列学习问题,可以用联结主义 时序分类(CTC,connectionist temporal classification)损失函数,等等。

二分类问题

IMDB 数据集

本节使用IMDB 数据集,它包含来自互联网电影数据库(IMDB)的50 000 条严重两极分 化的评论。数据集被分为用于训练的 25 000 条评论与用于测试的 25 000 条评论,训练集和测试 集都包含 50% 的正面评论和 50% 的负面评论。

为什么要将训练集和测试集分开?因为你不应该将训练机器学习模型的同一批数据再用于 测试模型!模型在训练数据上的表现很好,并不意味着它在前所未见的数据上也会表现得很好, 而且你真正关心的是模型在新数据上的性能(因为你已经知道了训练数据对应的标签,显然不 再需要模型来进行预测)。例如,你的模型最终可能只是记住了训练样本和目标值之间的映射关系,但这对在前所未见的数据上进行预测毫无用处。下一章将会更详细地讨论这一点。 与 MNIST 数据集一样,IMDB 数据集也内置于 Keras 库。它已经过预处理:评论(单词序列) 已经被转换为整数序列,其中每个整数代表字典中的某个单词。 下列代码将会加载 IMDB 数据集(第一次运行时会下载大约 80MB 的数据)。

from tensorflow.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)。

print( train_data[0])
# [1, 14, 22, 16, ... 178, 32] 
 
print(train_labels[0])
# 1

# 由于限定为前 10 000 个最常见的单词,单词索引都不会超过 10 000。
print(max([max(sequence) for sequence in train_data]))
# 9999

我们可以将某条评论迅速解码为英文单词。

word_index = imdb.get_word_index()
reverse_word_index = dict(
    [(value, key) for (key, value) in word_index.items()])
decoded_review = ' '.join(
    [reverse_word_index.get(i - 3, '?') for i in train_data[0]])

print(decoded_review)

准备数据

你不能将整数序列直接输入神经网络。你需要将列表转换为张量。转换方法有以下两种。

  • 填充列表,使其具有相同的长度,再将列表转换成形状为 (samples, word_indices) 的整数张量,然后网络第一层使用能处理这种整数张量的层(即 Embedding 层)。
  • 对列表进行 one-hot 编码,将其转换为 0 和 1 组成的向量。举个例子,序列 [3, 5] 将会被转换为10 000 维向量,只有索引为3 和 5 的元素是1,其余元素都是0。然后网络第一层可以用 Dense 层,它能够处理浮点数向量数据。 下面我们采用后一种方法将数据向量化。为了加深理解,你可以手动实现这一方法,如下所示。
def vectorize_sequences(sequences, dimension=10000):
    results = np.zeros((len(sequences), dimension))
    for i, sequence in enumerate(sequences):
        results[i, sequence] = 1.
    return results

x_train = vectorize_sequences(train_data)
x_test = vectorize_sequences(test_data)

y_train = np.asarray(train_labels).astype('float32')
y_test = np.asarray(test_labels).astype('float32')

构建网络

输入数据是向量,而标签是标量(1 和 0),这是你会遇到的最简单的情况。有一类网络在这种问题上表现很好,就是带有relu 激活的全连接层(Dense)的简单堆叠,比如 Dense(16, activation='relu')。 传入 Dense 层的参数(16)是该层隐藏单元的个数。一个隐藏单元(hidden unit)是该层 表示空间的一个维度。每个带有 relu 激活的 Dense 层都实现了下列张量 运算:

output = relu(dot(W, input) + b) 

16 个隐藏单元对应的权重矩阵 W 的形状为 (input_dimension, 16),与 W 做点积相当于 将输入数据投影到16 维表示空间中(然后再加上偏置向量 b 并应用 relu 运算)。你可以将表 示空间的维度直观地理解为“网络学习内部表示时所拥有的自由度”。隐藏单元越多(即更高维 的表示空间),网络越能够学到更加复杂的表示,但网络的计算代价也变得更大,而且可能会导 致学到不好的模式(这种模式会提高训练数据上的性能,但不会提高测试数据上的性能)。

对于这种 Dense 层的堆叠,你需要确定以下两个关键架构:

  • 网络有多少层;
  • 每层有多少个隐藏单元。

而现在这个例子中,我们选择的下列架构:

  • 两个中间层,每层都有 16 个隐藏单元;
  • 第三层输出一个标量,预测当前评论的情感。

中间层使用 relu 作为激活函数,最后一层使用 sigmoid 激活以输出一个 0~1 范围内的概率 值(表示样本的目标值等于1 的可能性,即评论为正面的可能性)。relu(rectified linear unit, 整流线性单元)函数将所有负值归零,而 sigmoid 函数则将任意值“压缩”到 [0, 1] 区间内,其输出值可以看作概率值。

在这里插入图片描述

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 损失函数来配置模型。注意, 我们还在训练过程中监控精度。

model.compile(optimizer='rmsprop',
              loss='binary_crossentropy',
              metrics=['accuracy'])

上述代码将优化器、损失函数和指标作为字符串传入,这是因为rmsprop、binary_ crossentropy 和 accuracy 都是Keras 内置的一部分。有时你可能希望配置自定义优化器的 参数,或者传入自定义的损失函数或指标函数。前者可通过向 optimizer 参数传入一个优化器 类实例来实现。后者可通过向 loss 和 metrics 参数传入函数对象来实现。

from tensorflow.keras import optimizers 
model.compile(optimizer=optimizers.RMSprop(lr=0.001),              
				loss='binary_crossentropy',               
				metrics=['accuracy'])
from keras import losses 
from keras import metrics 
 
model.compile(optimizer=optimizers.RMSprop(lr=0.001),               
			loss=losses.binary_crossentropy,               
			metrics=[metrics.binary_accuracy])

验证你的方法

为了在训练过程中监控模型在前所未见的数据上的精度,你需要将原始训练数据留出 10 000 个样本作为验证集。

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 参数来完成。

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
print(history_dict.keys())

字典中包含4 个条目,对应训练过程和验证过程中监控的指标。在下面两个代码清单中, 我们将使用Matplotlib 在同一张图上绘制训练损失和验证损失,以及训练精度和验证精度。请注意,由于网络的随机初始化不同,你得到的结果可能会略有不同。

import matplotlib.pyplot as plt 
 
history_dict = history.history 
loss_values = history_dict['loss'] 
val_loss_values = history_dict['val_loss'] 
 
epochs = range(1, len(loss_values) + 1) 
 
plt.plot(epochs, loss_values, 'bo', label='Training loss')   
plt.plot(epochs, val_loss_values, 'b', label='Validation loss')   
plt.title('Training and validation loss') 
plt.xlabel('Epochs') 
plt.ylabel('Loss') 
plt.legend() 
 
plt.show()

在这里插入图片描述

plt.clf()    
acc = history_dict['acc']  
val_acc = history_dict['val_acc'] 
 
plt.plot(epochs, acc, 'bo', label='Training acc') 
plt.plot(epochs, val_acc, 'b', label='Validation acc') 
plt.title('Training and validation accuracy') 
plt.xlabel('Epochs') 
plt.ylabel('Accuracy') 
plt.legend() 
 
plt.show()

在这里插入图片描述

如你所见,训练损失每轮都在降低,训练精度每轮都在提升。这就是梯度下降优化的预期 结果——你想要最小化的量随着每次迭代越来越小。但验证损失和验证精度并非如此:它们似 乎在第四轮达到最佳值。这就是我们之前警告过的一种情况:模型在训练数据上的表现越来越好, 但在前所未见的数据上不一定表现得越来越好。准确地说,你看到的是过拟合(overfit):在第二轮之后,你对训练数据过度优化,最终学到的表示仅针对于训练数据,无法泛化到训练集之 外的数据。

在这种情况下,为了防止过拟合,你可以在3 轮之后停止训练。通常来说,你可以使用许多方法来降低过拟合。 我们从头开始训练一个新的网络,训练 4 轮,然后在测试数据上评估模型。

使用训练好的网络在新数据上生成预测结果

训练好网络之后,你希望将其用于实践。你可以用 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)。

完整代码

from tensorflow.keras.datasets import imdb
from tensorflow.keras import layers
from tensorflow.keras import models
import matplotlib.pyplot as plt
import numpy as np

(train_data, train_labels), (test_data, test_labels) = imdb.load_data(num_words=10000)

print(max([max(sequence) for sequence in train_data]))


def vectorize_sequences(sequences, dimension=10000):
    results = np.zeros((len(sequences), dimension))
    for i, sequence in enumerate(sequences):
        results[i, sequence] = 1.
    return results

x_train = vectorize_sequences(train_data)
x_test = vectorize_sequences(test_data)

y_train = np.asarray(train_labels).astype('float32')
y_test = np.asarray(test_labels).astype('float32')

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'])

x_val = x_train[:10000]
partial_x_train = x_train[10000:]

y_val = y_train[:10000]
partial_y_train = y_train[10000:]

history = model.fit(partial_x_train, partial_y_train,
                    epochs=20,
                    batch_size=512,
                    validation_data=(x_val, y_val))

history_dict = history.history
loss_values = history_dict['loss']
val_loss_value = history_dict['val_loss']

epochs = range(1, len(loss_values) + 1)

plt.plot(epochs, loss_values, 'bo', label='训练损失')
plt.plot(epochs, val_loss_value, 'b', label='验证损失')
plt.title('训练和验证损失')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend

plt.show()