Kaggle机器学习实战(4)——MNIST(上)

MNIST是非常经典的深度学习入门数据集。Kaggle上也为新手安排了这样一个练习。它是一个10分类任务,图像每幅图像是一个手写数字(09),其尺寸为28×28,每个像素为一个灰度值通道(0255),因此每个图片包含784个维度。我们要做的就是预测测试集中图像所示的数字,评价的标准是准确度。

本文翻译自一篇kernel,原作者运用Keras这一简易的深度学习工具来入门卷积神经网络。

读取数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 调包
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
import seaborn as sns
%matplotlib inline

np.random.seed(2)

from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix
import itertools

from keras.utils.np_utils import to_categorical
from keras.models import Sequential
from keras.layers import Dense, Dropout, Flatten, Conv2D, MaxPool2D
from keras.optimizers import RMSprop
from keras.preprocessing.image import ImageDataGenerator
from keras.callbacks import ReduceLROnPlateau

sns.set(style='white', context='notebook', palette='deep')

整理数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
train = pd.read_csv("train.csv")
test = pd.read_csv("test.csv")

Y_train = train["label"]

# 删除“label”列
X_train = train.drop(labels = ["label"],axis = 1)

# 释放内存
del train

# 显示10个数字分布的柱状图
g = sns.countplot(Y_train)

Y_train.value_counts()

10个数字的分布比较均匀。

数据预处理

缺失值

1
2
3
4
# 检查缺失值
X_train.isnull().any().describe()

test.isnull().any().describe()

没有缺失值。

标准化

1
2
3
# 标准化
X_train = X_train / 255.0
test = test / 255.0

Reshape数据

在当前的数据集中,每一行784个特征代表一张图像,每个图宽28个像素、高28个像素,每个像素1个通道(如果图片是彩色的,则需要3个通道),将其转换为三维的张量以便于后续处理。

1
2
3
# 把每一行数据转换为28*28*1
X_train = X_train.values.reshape(-1,28,28,1)
test = test.values.reshape(-1,28,28,1)

标签编码

原始数据集给出的标签是0~9十个数字,需要将其one-hot化。例如,”2”转化为[0,0,1,0,0,0,0,0,0,0]。

1
2
# 把标签变为one-hot向量
Y_train = to_categorical(Y_train, num_classes = 10)

分割训练集与验证集

将训练集分成两部分:一小部分(10%)作为评估模型的验证集,剩下的(90%)用于训练模型。从前面的探索性分析可以得知10个数字出现的频率较为均衡,随机分割训练集不会导致某些数字在验证集中过于频繁地出现。注意:在一些非平衡数据集中,简单的随机分割可能会导致不准确的评估。为了避免这种情况,可以在train_test_split中使用stratify=True选项。

1
2
3
4
5
# 设置随机种子(非必要)
random_seed = 2

# 分割训练集和验证集
X_train, X_val, Y_train, Y_val = train_test_split(X_train, Y_train, test_size = 0.1, random_state=random_seed)

这样就可以显示一张图片。

1
2
# 例子
g = plt.imshow(X_train[0][:,:,0])

模型:卷积神经网络

这次要利用Keras来从头到尾构建CNN。
Keras相对于Tensorflow、Theano等框架,最大的好处就是简单易懂,新手用几遍就很容易上手,而且有中文版的文档。缺点是无法理解深度模型背后的原理,也就是不懂如何“造轮子”。因此之后我会写一篇如何使用Tensorflow来复现LeNet5的博客。

定义模型

原作者利用的是Keras的序贯模型(Sequential),通过.add()方法一个个的将layer加入模型中。

层的顺序是:[(CONV → Relu) × 2 → POOL → Dropout] × 2 → Flatten → Dense → Dropout → Softmax。

  1. Conv2D(二维卷积层):该层对二维输入进行滑动窗卷积。当使用该层作为第一层时,应提供input_shape参数,例如input_shape = (28,28,1)代表28×28的灰度图像。之后的层无需提供shape参数。filters输出空间的维度 (即卷积中滤波器的输出数量)。kernel_size 指明 2D 卷积窗口的宽度和高度。strides指明卷积沿宽度和高度方向的步长。padding指明是否填充输入数据以及填充的方法。
  2. MaxPool2D(最大池化层):主要是在保留主要特征的同时减少参数,防止过拟合。pool_size为缩小比例的因数,例如通常情况下会使用pool_size=(2,2),把输入张量的两个维度都缩小一半。strides是步长值, 如果是 None,那么默认值是pool_size
  3. Dropout:是一种正则化方法,对于每个训练样本,随机抽取一定比例的节点将其值设为零。该方法可以提高模型泛化度,减少过拟合。主要参数为rate,取0~1,是需要drop掉的比例。
  4. relu:是一种常用的激活函数,返回max(x,0)。
  5. Flatten:用于将最终的feature map转换为一维向量,以传入全连接层。
  6. Dense(全连接层):所实现的运算是output = activation(dot(input, kernel)+bias)。第一个参数units指定输出维度。activation指定激活函数。
  7. Softmax:作为输出层的激活函数,输出样本属于每个类的概率分布。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# CNN序贯模型
model = Sequential()

model.add(Conv2D(filters = 32, kernel_size = (5,5),padding = 'Same',
activation ='relu', input_shape = (28,28,1)))
model.add(Conv2D(filters = 32, kernel_size = (5,5),padding = 'Same',
activation ='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Dropout(0.25))


model.add(Conv2D(filters = 64, kernel_size = (3,3),padding = 'Same',
activation ='relu'))
model.add(Conv2D(filters = 64, kernel_size = (3,3),padding = 'Same',
activation ='relu'))
model.add(MaxPool2D(pool_size=(2,2), strides=(2,2)))
model.add(Dropout(0.25))


model.add(Flatten())
model.add(Dense(256, activation = "relu"))
model.add(Dropout(0.5))
model.add(Dense(10, activation = "softmax"))

优化器

定义完模型,还需要建立一个得分函数,一个损失函数和一个优化算法。

损失函数用于度量模型在已知标签的图像集合上的表现。对于多分类任务,通常采用categorical_crossentropy,即多分类的对数损失函数。

优化器(optimizer)的目的是最小化损失函数。大多数机器学习都是基于梯度的优化,选择优化器就是选择对于梯度下降算法的优化。常见的优化器包括SGD、Adam、Adagrad、RMSProp等几种,这里原作者选择了RMSprop(使用默认参数),它以一种非常简单的方式改进了Adagrad方法,可缓解Adagrad算法学习率下降较快的问题。

得分函数用精度(正确的样本数/总样本数)来评估模型的性能。

compile配置训练模型,前三个参数分别为optimizer(优化器),loss(损失函数)和metrics(得分函数)。

1
2
3
4
5
# 优化方法
optimizer = RMSprop(lr=0.001, rho=0.9, epsilon=1e-08, decay=0.0)

# 编译模型
model.compile(optimizer = optimizer , loss = "categorical_crossentropy", metrics=["accuracy"])

为了使优化器收敛得更快,更接近损失函数的全局最小值,原作者使用了学习率退火方法,也就是一种学习率自适应方法。这样就能在保证模型训练速度时防止收敛到局部最优点。本例使用了Keras.callbacksReduceLROnPlateau函数,这个回调函数被设置为如果3个epoch后精度没有提高,那么学习率就会变为原来的一半。

1
2
3
4
5
6
# 学习率退火
learning_rate_reduction = ReduceLROnPlateau(monitor='val_acc',
patience=3,
verbose=1,
factor=0.5,
min_lr=0.00001)

依据机器的性能设置迭代次数。原作者的epoch设置为30,准确率可达0.9967。

1
2
3
# 在完整训练集上的迭代次数
epochs = 1
batch_size = 86

数据增强

为了避免过拟合,需要用一些技巧扩展数据集。

以保持标签不变的方式改变训练数据的方法称为数据增强。常用的增强方法有裁剪、缩放、彩色变换、翻转等。在训练数据中应用数据增强方法可以轻松地将训练样本数量增加一倍或三倍,可以得到更加健壮的模型。例如本例,原作者在没有数据增强的情况下,准确率为98.114%;通过数据增强,准确率达到99.67%。

这里使用的方法包括:

  • 随机旋转训练图像,10度以内;
  • 随机缩放训练图像,10%以内;
  • 随机水平平移图像,宽度的10%以内;
  • 随机垂直平移图像,高度的10%以内;
  • 没有翻转图像,避免“6”和“9”的冲突。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 数据增强
datagen = ImageDataGenerator(
featurewise_center=False, # 将输入数据的均值设置为0,逐特征进行
samplewise_center=False, # 将每个样本的均值设置为0
featurewise_std_normalization=False, # 将输入除以数据标准差,逐特征进行
samplewise_std_normalization=False, # 将每个输入除以其标准差
zca_whitening=False, # 是否应用ZCA白化
rotation_range=10, # 随机旋转的度数范围
zoom_range = 0.1, # 随机缩放范围
width_shift_range=0.1, # 随机水平平移
height_shift_range=0.1, # 随机垂直平移
horizontal_flip=False, # 随机水平翻转
vertical_flip=False) # 随机垂直翻转


datagen.fit(X_train)

训练模型。默认情况下用fit方法载入数据是一次性全部载入。此处使用fit_generator方法,用yield分批将训练集送入内存/显存,避免内存/显存不足的情况。

fit_generator接收第一个参数generator为一个生成器,这里用了ImageDataGenerator类的.flow()方法,每次调用输出batch_size个样本及其对应的标签用于训练。steps_per_epoch为一个epoch分成多少个batch_sizeepochs为数据的迭代总轮数。verbose为日志显示模式,可选0、1或2。callbacks为在训练时调用的一系列回调函数,例如学习率衰减方法。

1
2
3
4
5
# 训练
history = model.fit_generator(datagen.flow(X_train,Y_train, batch_size=batch_size),
epochs = epochs, validation_data = (X_val,Y_val),
verbose = 2, steps_per_epoch=X_train.shape[0] // batch_size
, callbacks=[learning_rate_reduction])

输出:Epoch 1/1 …… loss: 0.4215 - acc: 0.8656 - val_loss: 0.0649 - val_acc: 0.9781(epochs设置为1)

模型评估

训练集和验证集曲线

绘制训练集和验证集上的loss函数以及精度曲线。设置epochs=30,模型在验证集上的精度接近99%,并且高于训练集上的精度,说明这个模型没有过拟合。

1
2
3
4
5
6
7
8
9
# 绘制训练集和验证集上的loss函数和精度曲线
fig, ax = plt.subplots(2,1)
ax[0].plot(history.history['loss'], color='b', label="Training loss")
ax[0].plot(history.history['val_loss'], color='r', label="validation loss",axes =ax[0])
legend = ax[0].legend(loc='best', shadow=True)

ax[1].plot(history.history['acc'], color='b', label="Training accuracy")
ax[1].plot(history.history['val_acc'], color='r',label="Validation accuracy")
legend = ax[1].legend(loc='best', shadow=True)

混淆矩阵

混淆矩阵每一列代表预测的标签,每一行代表真正的标签。通过混淆矩阵可以直观看出哪些容易被错误分类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 绘制混淆矩阵

def plot_confusion_matrix(cm, classes,
normalize=False,
title='Confusion matrix',
cmap=plt.cm.Blues):
plt.imshow(cm, interpolation='nearest', cmap=cmap)
plt.title(title)
plt.colorbar()
tick_marks = np.arange(len(classes))
plt.xticks(tick_marks, classes, rotation=45)
plt.yticks(tick_marks, classes)

# normalize参数决定是否归一化,默认为否
if normalize:
cm = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis]

thresh = cm.max() / 2.
for i, j in itertools.product(range(cm.shape[0]), range(cm.shape[1])):
plt.text(j, i, cm[i, j],
horizontalalignment="center",
color="white" if cm[i, j] > thresh else "black")

plt.tight_layout()
plt.ylabel('True label')
plt.xlabel('Predicted label')

# 验证集上的预测
Y_pred = model.predict(X_val)
# 将其转化为one-hot向量
Y_pred_classes = np.argmax(Y_pred,axis = 1)
# 转化验证集的真实标签为one-hot向量
Y_true = np.argmax(Y_val,axis = 1)
# 计算混淆矩阵
confusion_mtx = confusion_matrix(Y_true, Y_pred_classes)
# 绘图
plot_confusion_matrix(confusion_mtx, classes = range(10))

如果按照原作者的参数设置(迭代30个epoch),误分类的情况已经相当少了。较为多见的误分类是把“4”识别为“9”。

进一步探索误分类样本种预测标签的概率与实际标签的概率分布的差值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 预测标签与实际标签的概率差值
errors = (Y_pred_classes - Y_true != 0)
Y_pred_classes_errors = Y_pred_classes[errors]
Y_pred_errors = Y_pred[errors]
Y_true_errors = Y_true[errors]
X_val_errors = X_val[errors]

def display_errors(errors_index,img_errors,pred_errors, obs_errors):
""" 这个函数展示6幅图像及其预测标签和真实标签"""
n = 0
nrows = 2
ncols = 3
fig, ax = plt.subplots(nrows,ncols,sharex=True,sharey=True)
for row in range(nrows):
for col in range(ncols):
error = errors_index[n]
ax[row,col].imshow((img_errors[error]).reshape((28,28)))
ax[row,col].set_title("Predicted label :{}\nTrue label :{}".format(pred_errors[error],obs_errors[error]))
n += 1

# 误分类项的预测标签的预测概率
Y_pred_errors_prob = np.max(Y_pred_errors,axis = 1)

# 误分类项的真实标签的预测概率
true_prob_errors = np.diagonal(np.take(Y_pred_errors, Y_true_errors, axis=1))

# 两者的差值
delta_pred_true_errors = Y_pred_errors_prob - true_prob_errors

# 排序
sorted_dela_errors = np.argsort(delta_pred_true_errors)

# Top6误差
most_important_errors = sorted_dela_errors[-6:]

# 展示Top6误差
display_errors(most_important_errors, X_val_errors, Y_pred_classes_errors, Y_true_errors)

通过展示一些误分类的图像,可以发现一些误分类可能并非模型的问题,例如非常像“4”的“9”可能属于人为标记时的错误。

到此,这个模型已经基本构建完成。接下来在测试集上运用模型。

1
2
3
4
5
6
7
# 在测试集上预测
results = model.predict(test)

# 选取预测结果
results = np.argmax(results,axis = 1)

results = pd.Series(results,name="Label")

总结

  • MNIST是一个被“用烂了”的数据集,在深度学习领域相当于“Hello World”。但动手实现一遍还是会有不小的成就感。
  • Keras的序贯模型层次清晰,有助于新手理解卷积网络。如果能用它从头到尾实现一遍CNN,就恭喜你成为了“调参侠”。
  • 本例用了一些CNN中常见(甚至说必见)的小Trick,比如数据增强、学习率衰减等,没有用到什么奇技淫巧,回归数据竞赛的本真。
  • 对于新手而言,不必过于纠结网络结构。不如尝试复现一些经典网络,例如LeNet、AlexNet等,加深对深度网络的理解。之后可以尝试Inception,ResNet等当下热门的网络,处理真正的图片。当然,首先需要一台配备高档显卡的电脑。