字符验证码识别之模型构建

验证码识别之模型构建

Keras函数式API

之前学习使用的神经网络都是用Sequential模型实现的,网络只有一个输入和一个输出,而且网络是层的线性堆叠,这种网络配置非常常见,只使用Sequential模型类就能够涵盖许多主题和实际应用,但是有些情况下这种假设过于死板,有些网络需要多个独立的输入,有些网络则需要多个输出,而有些网络在层与层之间具有内部分支,这使得网络看起来像是层构成的图(graph),而不是层的线性堆叠。

也就是针对类似多输入模型、多输出模型和类图模型的使用案例,只用Keras中的Sequentail模型是无法实现的,但是还有另一种更加更加通用、更加灵活的使用keras的方式,就是函数式API

有机会我会在之外的文章中记录这方面更详细的内容,这里先简单了解下就好。

构建模型

对于验证码识别来说,一般是多输出模型,所以我们需要使用函数式API来构建模型

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
38
39
40
41
42
43
44
45
import keras
from keras.models import Model
from keras.layers import Input, Flatten, Conv2D, Activation, MaxPooling2D, Dropout, Dense,
import numpy as np
from keras.preprocessing.image import ImageDataGenerator
from keras.callbacks import EarlyStopping


# Data parameters
num_classes = 36
img_shape = (40, 120, 1)
train_nums = 3000
val_nums = 1000

# Network parameters
batch_size = 64
epochs = 50

# 创建CNN模型
main_input = Input(shape=img_shape, name="inputs")
# 32个卷积核,卷积核大小3*3
conv1 = Conv2D(32, (3, 3), name="conv1", padding="same")(main_input)
# 激活函数优先使用relu
relu1 = Activation('relu', name="relu1")(conv1)
# 最大池化
pool1 = MaxPooling2D(pool_size=(2, 2), padding="same", name="pool1")(relu1)

conv2 = Conv2D(32, (3, 3), name="conv2", padding="same")(pool1)
relu2 = Activation('relu', name="relu2")(conv2)
pool2 = MaxPooling2D(pool_size=(2, 2), padding='same', name='pool2')(relu2)

# 全连接层
x = Flatten(name="flatten")(pool2)

x = Dense(256, activation="relu", name="dense1")(x)
out_put = [Dense(num_classes, activation='softmax', name='out%d' % (i+1))(x) for i in range(1, 5)]

# 定义模型的输入和输出
model = Model(inputs=main_input, outputs=out_put)
# 查看模型
model.summary()
opt = 'rmsprop'
# opt = keras.optimizers.Adam(lr=0.001)
loss = 'categorical_crossentropy'
model.compile(optimizer=opt, loss=loss, loss_weights=[1., 1., 1., 1.], metrics=['acc'])

这里定义了一个两层的神经网络,按照我测试的情况,像一般几千张的图片,两层的网络基本就可以了,层数多了反而准确度提升很慢,如果训练数据量很大,可以尝试增加网络层数;

之后全连接展开之后,定义了一个256维空间的隐藏层,这个大小实际上一般取决于输出空间的维度大小,这里是36(共36个验证码字符分类),隐藏层的维度大小至少要比输出空间的维度大(个人意见),否则太小可能无法学会区分这么多类别,这种维度较小的层可能成为信息瓶颈,永久地丢失信息。

因为每张验证码图片是4个字符,所以网络是4个输出,并且每个输出是36个分类中的一个,所以使用 softmax 激活,这样可以输出在 N 个输出类别上的概率分布。

model.summary()可以控制台打印我们的网络结构;

代码中的两个优化器,我都有测试,差别甚微,像这种验证码识别的项目其实属于比较简单地项目,无论哪种优化器应该都可以胜任,只是效果上有一些差距。

loss_weight:用来计算总的loss 的权重。默认为1,多个输出时,可以设置不同输出loss的权重来决定训练过程。因为这里是多输出,训练时每个输出都有一个损失值,此外还有一个总的损失值,这里设置每个损失值的权重相同,表示每个输出地位相同,为其降低损失值的优先级是相同的。

加载数据

上面已经完成了神经网络构建,只要调用model.fit就可以进行训练了,但是这里有一个重点需要注意,因为我们的网络是多输出(这里是4个输出),所以在将训练标签传入fit方法时要注意传入形式,标准的传入形式为

1
model.fit(x_train, [y1, y2, y3, y4])

y1指的是每张验证码图片4个字符的第一个字符,其自身也是一个二维数组,第一维是训练集样本数量,与x_train的长度保持一致,第二维是一个长度为36的数组,只有其代表的字符所在的索引的值为1,其余为0(one-hot编码,前面文章有具体讲过数据处理过程,这里不详细讲了)。

我们先加载数据,然后看如何将数据传入到fit函数。

1
2
3
4
x_train = np.load("x_train.npy")  # shape: (3000, 40, 120, 1)
y_train = np.load("y_train.npy") # shape: (3000, 4, 36)
x_val = np.load("x_val.npy") # shape: (1000, 40, 120, 1)
y_val = np.load("y_val.npy") # shape: (1000, 4, 36)

y_train是3000个样本,然后每个样本是一个4x36的二维数组,我们要把这4个字符拆分开

1
2
3
4
5
6
7
8
9
# 将y_train,y_val作为参数传入该函数
def convert_labels(y):
y1, y2, y3, y4 = [], [], [], []
for capt in y:
y1.append(capt[0])
y2.append(capt[1])
y3.append(capt[2])
y4.append(capt[3])
return [y1, y2, y3, y4]

然后调用fit函数进行训练

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
early_stopping = EarlyStopping(monitor='loss', patience=10)

# 开始训练
history = model.fit(
x_train,
convert_labels(y_train),
batch_size=batch_size,
epochs=epochs,
validation_data=(x_val, convert_labels(y_val)),
callbacks=[early_stopping]
)

# 保存模型
model_path = 'cnn_captcha.h5'
model.save(model_path)

EarlyStopping是用于提前停止训练的callbacks,如当某些指标(损失值,准确率等)在经过一定次数的epoch后,没有得到improvement就提前停止训练,可以在一定程度上避免过拟合(当网络在训练集上表现越来越好,错误率越来越低的时候,实际上在某一刻,它在测试集的表现已经开始变差)。

这里使用的参数含义

  • monitor:监控的数据接口,有acc,val_acc,loss,val_loss等等。正常情况下如果有验证集,就用val_acc或者val_loss
  • patience:能够容忍多少个epoch内都没有improvement。这个设置其实是在抖动和真正的准确率下降之间做tradeoff。如果patience设的大,那么最终得到的准确率要略低于模型可以达到的最高准确率。如果patience设的小,那么模型很可能在前期抖动,还在全图搜索的阶段就停止了,准确率一般很差。patience的大小和learning rate直接相关。在learning rate设定的情况下,前期先训练几次观察抖动的epoch number,比其稍大些设置patience。在learning rate变化的情况下,建议要略小于最大的抖动epoch number。

一般是在model.fit函数中调用callbacks,fit函数中有一个参数为callbacks。注意这里需要输入的是list类型的数据,所以通常情况只用EarlyStopping的话也要是[EarlyStopping()]。

利用History作图

因为这里是多输出,history的key和以前不太一样,我们可以打印其key值看看:

1
2
3
4
history_dict = history.history
print(history_dict.keys())

dict_keys(['val_loss', 'val_out2_loss', 'val_out3_loss', 'val_out4_loss', 'val_out5_loss', 'val_out2_acc', 'val_out3_acc', 'val_out4_acc', 'val_out5_acc', 'loss', 'out2_loss', 'out3_loss', 'out4_loss', 'out5_loss', 'out2_acc', 'out3_acc', 'out4_acc', 'out5_acc'])

可以看到,这里损失值分为总的损失值和各个输出的损失值,准确率没有总的准确率,只有各个输出的准确率,实际上我们在作图时,损失值的变化情况看总的就可以了(主要是看变化情况),准确率的话可以看任一输出的,因为我们在调整网络参数时,针对每个输出都是一样的,一般不会单独调整某一个验证码字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
loss_values = history_dict["loss"]  # 训练数据的损失(总损失)
acc_values = history_dict["out2_acc"] # 训练数据的准确率
val_loss_values = history_dict["val_loss"] # 验证数据的损失(总损失)
val_acc_values = history_dict["val_out2_acc"] # 验证数据的准确率

# 绘制训练损失和验证损失(第一个和第二个参数的维度要一致)
plt.plot(range(1, epochs+1), loss_values, 'bo', label="Training loss")
plt.plot(range(1, epochs+1), 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()
# 绘制训练精度和验证精度
plt.plot(range(1, epochs+1), acc_values, 'bo', label="Training acc")
plt.plot(range(1, epochs+1), val_acc_values, 'b', label="Validation acc")
plt.title("Training and validation accuracy")
plt.xlabel("Epochs")
plt.ylabel("Accuracy")
plt.legend()
plt.show()

损失图像:

准确率图像:

由图像可以粗略看到,基本在epochs达到40左右时,验证的损失和准确率基本已经达到了峰值,无法再通过增加轮数来提升(再增加过多的epochs,可能会出现准确率下降的情况),只能通过调整参数来提升,这也是一个避免过拟合的方式(类似人工earlystopping)。

记录一个坑

因为这里是多输出,所以fit函数参数的传入形式有特别的要求(上面我们已经介绍过了),但是我们将数据加载好后,实际上我们的训练/验证标签集的shape是(samples, 4, 36),一开始我想的比较简单,直接利用了numpyreshape方法:

1
2
3
4
5
6
7
8
y_train = np.load("y_train.npy")  # shape: (3000, 4, 36)
y_train.reshape((4, 3000, 36))
model.fit(
x_train,
[y for y in y_train],
batch_size=batch_size,
epochs=epochs
)

之后我无论怎么训练,训练集数据的正确率能提升至百分十八九十,而验证数据的准确率都极低(小于0.1),曾上网查过可能导致此类问题的原因,1、过拟合(一般原因:训练数据过少,网络过于复杂);2、没有将数据规格化(如图片,img/255);3、没有在分验证集之前打乱数据;4、数据和标签没有对上;5、网络结构有问题。

针对以上问题,我进行了一一排查,首先第2点我肯定是做了img/255规格化操作,其次网络结构如果有问题,那么我的训练集准备率也不可能提升至百分之八九十,所以首先排除了2和5;其实第3点“没有在分验证集之前打乱数据”,我确实犯了这个错误,并且后续做了调整,但是验证集的准确率依然没有得到提升,而且仔细思考,如果仅仅存在这个问题,验证集的准确率不会极低(小于0.1),只会和训练集相比低很多,如训练集0.9,验证集0.3;那么会不会是过拟合呢,这个不是特别好判断,理论上讲我的训练数据3000张,网络也只有两层,不应该出现过拟合的问题,于是我进一步进行了验证,先保存训练的模型,然后利用model.predict方法对训练集进行了准确率测试,发现准确率是0,这说明不是过拟合(过拟合训练的模型至少对训练集数据还是很准确的),而是训练模型时数据和标签没有对上。

最后经过进一步排查和思考,发现就是reshape操作导致数据和标签没有对上,我一开始是想当然的认为reshape操作可以达到我想要的变化,实际上reshape操作是先将整个数组(不管几维)按顺序展开成一维,然后再按照你设定的shape做变化,所以完全打乱了标签的顺序,因此在训练时,图片和标签就无法对应上,自然训练的模型是有问题的。

测试模型

当我们训练完一个模型后,一般会先测试下其在新的数据集上的准确率(如测试集),如果觉得其准确率可以满足需求,就可以将其应用到实际项目中。我们一般使用model.predict方法测试新的数据(单张验证码图片),具体方法如下:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
from keras.models import load_model
import numpy as np
import cv2
import os
from string import digits, ascii_lowercase

alphanumeric = ascii_lowercase + digits
# 加载模型
model = load_model('cnn_captcha.h5')

img = cv2.imread('D:/captcha/2BUY_0.4279397312997719.jpg')
# 这里模糊处理、灰度化、二值化操作与我们训练数据前的预处理操作一致
img = cv2.medianBlur(img, 3)
img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
img = cv2.adaptiveThreshold(img, 1, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 11, 2)
x = np.reshape(img, [1, 40, 120, 1])
x = x.astype('float32')
y = model.predict(x)
print(y)
print(np.array(y).shape)

[array([[5.28798296e-07, 3.42879761e-12, 1.27859680e-12, 1.51475721e-10,
1.54283617e-11, 4.58582120e-13, 6.79610139e-06, 4.35170048e-13,
9.55004025e-16, 1.08291341e-14, 2.69922586e-16, 1.01885084e-10,
4.44191640e-13, 1.14316467e-15, 5.36134357e-22, 2.23994608e-11,
6.06738063e-11, 2.43026763e-13, 6.78680934e-09, 2.18888582e-03,
4.14231410e-12, 1.86173295e-07, 4.49651261e-10, 9.76482957e-12,
2.61363240e-11, 2.52953911e-27, 5.78615527e-19, 4.92441056e-21,
9.97238040e-01, 5.64109650e-04, 5.19347791e-15, 4.78807966e-19,
2.59984375e-16, 5.82205351e-09, 7.84171252e-12, 1.48196716e-06]],
dtype=float32),
array([[1.5315172e-13, 9.9999976e-01, 1.5357235e-13, 7.2629148e-13,
2.1462998e-07, 7.6742697e-13, 2.5091420e-11, 4.1176382e-10,
1.9094962e-29, 7.7268429e-15, 5.0311681e-18, 2.8639531e-17,
1.7258057e-19, 5.9554156e-16, 7.7035002e-31, 1.8753172e-12,
1.2075517e-22, 3.4183542e-13, 1.8748737e-12, 5.4038486e-21,
6.9473838e-16, 1.9005614e-25, 1.2699458e-17, 1.6363264e-16,
3.4829533e-19, 3.0176148e-27, 5.4507461e-27, 5.6286962e-31,
5.7909650e-09, 6.1705683e-20, 1.6654239e-12, 5.3886505e-16,
3.9584940e-19, 6.8287107e-20, 1.9061467e-12, 3.9144452e-22]],
dtype=float32),
array([[9.1667207e-14, 4.5887819e-10, 1.8035324e-08, 4.1263881e-09,
2.1240913e-16, 2.2113813e-13, 4.3324260e-09, 1.7904261e-02,
2.4622420e-23, 4.4261217e-10, 9.8902886e-10, 5.9147430e-07,
1.6013598e-07, 8.2238061e-05, 6.3615531e-22, 6.0304046e-13,
2.8841313e-12, 1.6485173e-09, 1.3376454e-09, 5.8543783e-08,
9.8201251e-01, 8.5446914e-08, 5.3870757e-08, 1.6634169e-19,
4.6868596e-11, 9.3067975e-25, 3.3543603e-23, 8.2183687e-23,
4.8181098e-10, 4.7426272e-09, 1.0263661e-12, 4.9370295e-13,
6.2335454e-12, 2.7922051e-15, 5.1308588e-13, 3.9786208e-14]],
dtype=float32),
array([[2.0625102e-19, 2.1865435e-21, 2.3202852e-21, 1.1478310e-22,
7.0164137e-24, 1.6291809e-17, 1.2901671e-22, 5.0137744e-16,
3.0980038e-18, 1.6256985e-09, 3.3972040e-12, 6.6958219e-17,
3.2773488e-13, 2.2989457e-14, 2.4578894e-22, 2.8838988e-21,
1.6804537e-25, 1.8011018e-20, 1.2620437e-18, 5.8037561e-09,
7.3418249e-21, 1.5270443e-10, 1.1045169e-12, 5.7356374e-07,
9.9999928e-01, 7.2028659e-30, 4.2386959e-29, 8.5332044e-32,
1.6475497e-07, 2.5067137e-20, 7.7604845e-15, 2.9342590e-27,
3.5429564e-28, 3.3450085e-15, 1.1052841e-08, 8.7345167e-20]],
dtype=float32)]
(4, 1, 36)

可以看到,predict方法返回一个(4, 1, 36)的三维数组,其值是一个浮点数,表示概率;我们只要分别取出4个(1, 36)的二维数组中的值最大的下标值,然后找出该下标在alphanumeric中对应哪个字符即可。

这里需要用到np.argmax方法,可以较快速地找到相应的下标(当然也可以自己遍历数组,一层层找出),该方法的主要作用就是在张量中找出最大值所对应的索引。针对一维张量,没有任何疑问;针对二维张量,若不设置axis参数,默认就是将张量展开成一维张量,然后找出索引,axis=0表示按列搜索,axis=1表示按行搜索;对于三维张量,比较复杂,很难简单地表述清楚,这里仅简要说明(具体内容可以查询相关资料),设置axis=2表示在每个矩阵内部按行搜索,例如:

1
2
3
4
5
6
7
8
9
10
print(np.argmax(y, axis=2))

[[28]
[ 1]
[20]
[24]]

result = ''.join((alphanumeric[i[0]] for i in np.argmax(y, axis=2)))
print(result)
2buy

上面是一个完整地拿训练好的模型测试新的数据的过程,我们可以写个循环,将所有的测试集图片都预测一遍,并且我们是知道每张验证码真实的答案的,所以可以将预测值和真实值进行比较,计算出准确率,如:

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
path = 'D:/captcha/test/'
i = 0
for f in os.listdir(_path):
if not f.endswith('jpg'):
continue
img = cv2.imread(_path + f)
# 中值模糊
img = cv2.medianBlur(img, 3)
# 转灰度图
img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
# 自适应阈值二值化
img = cv2.adaptiveThreshold(img, 1, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 11, 2)
x = np.reshape(img, [1, 40, 120, 1])
x = x.astype('float32')
# x /= 255
y = model.predict(x)
captcha = f.split('_')[0].lower()
result = ''.join((alphanumeric[i[0]] for i in np.argmax(y, axis=2)))
print('img {} predict: {}'.format(captcha, result))
if result == captcha.lower():
i += 1

total_imgs = len(os.listdir(path))
print("{}/{}".format(i, total_imgs))
accuracy = i / total_imgs
print('accuracy: {}'.format(accuracy))

除利用上面的方法之外,其实还有一个计算测试集准确率的方法,model.evaluate,该方法的调用方式与fit方法类似,也就是把数据先预处理后直接传入,直接帮我们计算出准确率。这里假设我们已经将测试集数据预处理过了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
x_test = np.load("x_test.npy")
y_test = np.load("y_test.npy")


def convert_labels(y):
y1, y2, y3, y4 = [], [], [], []
for capt in y:
y1.append(capt[0])
y2.append(capt[1])
y3.append(capt[2])
y4.append(capt[3])
return [y1, y2, y3, y4]


result = model.evaluate(x_test, convert_labels(y_test))
print(model.metrics_names)
print(result)

['loss', 'out2_loss', 'out3_loss', 'out4_loss', 'out5_loss', 'out2_acc', 'out3_acc', 'out4_acc', 'out5_acc']
[3.710993268966675, 0.6383588314056396, 0.7733393311500549, 0.7200027704238892, 1.5855121612548828, 0.8579999804496765, 0.8489999771118164, 0.8579999804496765, 0.7419999837875366]

如果是单输出的模型,model.evaluate的返回值,第一个是损失值,第二个是准确率;这里是多输出,所以可以将返回值的标签打印出来看一下,一共是9个返回值。而且这里得到的准确率一般比我们上面那种主动循环使用predict的方法看上去高,实际上这里有个隐藏的问题,这里只给出了每给字符的准确率,如0.8左右,而实际上我们predict后和真实值比较时,是4个字符同时比较的,那么准确率其实就是0.8x0.8x0.8x0.8,准确率约等于0.4,可能实际会比0.4稍高一些,但是明显比我们训练完的单个字符准确率低很多。

补充

以上基本是一个完整地搭建神经网络的过程,这里要补充的一点是利用model.fit_generator方法分批将数据加载入内存,以降低内存的占用,我们在使用fit方法时,训练数据是被完整地加载进内存的(其实训练过程是分批进行的),如果我们数据量很大,无法将数据一次性全部载入内存,这时,使用model.fit_generator就很方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from keras.preprocessing.image import ImageDataGenerator

batch_size = 64
epochs = 50
train_nums = 3000
val_nums = 1000

datagen = ImageDataGenerator()

def data_generator(p_x, p_y, p_batch_size=64):
x_y_gen = datagen.flow(p_x, p_y, batch_size=p_batch_size, shuffle=True)
while True:
x, y = x_y_gen.next()
y1, y2, y3, y4 = (np.array(l) for l in zip(*y))
yield x, [y1, y2, y3, y4]

history = model.fit_generator(
data_generator(x_train, y_train, batch_size),
steps_per_epoch=train_nums // batch_size,
validation_data=data_generator(x_val, y_val, batch_size),
validation_steps=val_nums // batch_size,
epochs=epochs,
callbacks=[early_stopping])

之前有用到过ImageDataGenerator,当时是用来做数据增强的,这里不设置任何参数,就是没有去使用它的数据增强的功能,仅把它当做一个分批加载数据的方法。

model.fit_generator的第一个参数要求为一个生成器,生成器的输出应该为以下之一:

  • 一个(inputs, targets)元组
  • 一个(inputs, targets, sample_weights)元组

所以这里写了一个data_generator方法,用来分批生成数据,shuffle参数表示是否随机打乱顺序,fit方法也是有该参数的,默认为True

其他参数:

  • steps_per_epoch: 在声明一个 epoch 完成并开始下一个 epoch 之前从 generator 产生的总步数(批次样本)。 它通常应该等于你的数据集的样本数量除以批量大小;
  • verbose: 0, 1 或 2。日志显示模式。 0 = 安静模式, 1 = 进度条, 2 = 每轮一行;
  • validation_data: 与第一个参数格式相同, 在每个 epoch 结束时评估损失和任何模型指标。该模型不会对此数据进行训练;
  • validation_steps: 仅当 validation_data 是一个生成器时才可用。 在停止前 generator 生成的总步数(样本批数)。