Skip to content

4、利用人脸数据训练一个简单的神经网络模型

Bill Zhang edited this page May 15, 2019 · 3 revisions

完成了人脸图片数据的预处理,接着我们就要用准备好的数据来训练一个简单的神经网络,最终用于人脸识别。

还是先讲一下后续思路: 1、建立卷积神经网络模型; 2、训练模型并保存; 3、载入训练好的模型,并用其建立预测函数; 4、最终程序类似于之前的从摄像头视频提取人脸部分,只是在提取人脸后增加了用预测函数判断是否检测到我,如果检测到我就增加文字提示的功能。

这篇手记暂时不深入卷积神经网络的相关理论(那是长篇大论,还是结合网课和论文系统学习比较好),这里主要讲讲自己训练网络的实践过程中遇到的一些问题以及解决的过程。

face_train_keras.py文件中新建一个Model类,用于建立卷积神经网络模型

# 建立卷积神经网络模型
class Model:
    # 初始化构造方法
    def __init__(self):
        self.model = None
    # 建立模型
    def build_model(self, dataset, nb_classes = 2):
        self.model = Sequential()
        self.model.add(Conv2D(32, (3, 3), padding = 'same', input_shape = dataset.input_shape)) # 当使用该层作为模型第一层时,需要提供 input_shape 参数 (整数元组,不包含batch_size)
        self.model.add(Activation('relu'))
        self.model.add(Conv2D(32, (3, 3)))
        self.model.add(Activation('relu'))
        self.model.add(MaxPooling2D(pool_size = (2,2))) # strides默认等于pool_size
        self.model.add(Conv2D(64, (3, 3), padding = 'same'))
        self.model.add(Activation('relu'))
        self.model.add(Conv2D(64, (3, 3)))
        self.model.add(Activation('relu'))
        self.model.add(MaxPooling2D(pool_size = (2,2)))
        self.model.add(Dropout(0.25))
        self.model.add(Flatten())
        self.model.add(Dense(512))
        self.model.add(Activation('relu'))
        self.model.add(Dropout(0.25))
        self.model.add(Dense(nb_classes))
        self.model.add(Activation('softmax'))

build_model函数用来建立卷积神经网络(CNN)模型。 这里在贴代码之余,想记录一下自己了解到的为什么要在计算机视觉领域要用卷积神经网络: 卷积神经网络结构的三个主要思想:

  • 局部感受域
  • 参数共享
  • 空间亚采样
  1. 首先,图片文件一般数据量都较大,通常至少有几千像素,如果用全连接的神经网络,就需要大量的权重参数,网络容量增大,训练起来就会需要大量的训练集数据,而且大量的参数也需要大量的存储空间。 全连接神经网络对图片数据主要的劣势在于不具备平移不变性。以人脸识别为例,假设相同的人脸(包括姿态、光照、表情等都相同),出现在图片中不同的位置,人脸的特征也会移动到不同的位置。全连接网络要捕捉这种平移不变性,就可能会有重复的权重,同时也要求训练数据中包括很多不同位置的人脸。 在卷积神经网络中,通过空间上的参数共享,可以以较少的参数数量捕捉到特征的平移不变性,大大减少的参数量能降低模型的存储需求。
  2. 其次,全连接网络完全忽略了图片数据的拓扑结构,输入像素数据无论以什么顺序排列都不影响训练的输出结果,而实际上,图片数据有着很强的局部结构,空间上临近的像素都是高度相关的。

这一部分理论分析在Yann Lecun的LeNet论文里有深入的讲解,对吴恩达的深度学习课程是很好的补充,感兴趣的童鞋可以去看看。

继续回到这个项目,建立好模型结构以后我们来配置一下模型的训练,定义一个train函数:

# 训练模型
    def train(self, dataset, batch_size = 128, nb_epoch = 15, data_augmentation = True):
        # https://jovianlin.io/cat-crossentropy-vs-sparse-cat-crossentropy/
        # If your targets are one-hot encoded, use categorical_crossentropy, if your targets are integers, use sparse_categorical_crossentropy.
        self.model.compile(loss = 'categorical_crossentropy', 
                           optimizer = 'ADAM',
                           metrics = ['accuracy'])
        if not data_augmentation:
            self.model.fit(dataset.train_images, 
                           dataset.train_labels, 
                           batch_size = batch_size,
                           epochs = nb_epoch, 
                           shuffle = True)
        # 图像预处理
        else:
            #是否使输入数据去中心化(均值为0),是否使输入数据的每个样本均值为0,是否数据标准化(输入数据除以数据集的标准差),是否将每个样本数据除以自身的标准差,是否对输入数据施以ZCA白化,数据提升时图片随机转动的角度(这里的范围为0~20),数据提升时图片水平偏移的幅度(单位为图片宽度的占比,0~1之间的浮点数),和rotation一样在0~0.2之间随机取值,同上,只不过这里是垂直,随机水平翻转,不是对所有图片翻转,随机垂直翻转,同上
            # 每个epoch内都对每个样本以以下配置生成一个对应的增强样本,最终生成了1969*(1-0.3)=1378*10=13780个训练样本,因为下面配置的很多参数都是在一定范围内随机取值,因此每个epoch内生成的样本都不一样
            datagen = ImageDataGenerator(rotation_range = 20, 
                                         width_shift_range  = 0.2, 
                                         height_shift_range = 0.2, 
                                         horizontal_flip = True)                           
            #计算数据增强所需要的统计数据,计算整个训练样本集的数量以用于特征值归一化、ZCA白化等处理
#            当且仅当 featurewise_center 或 featurewise_std_normalization 或 zca_whitening 设置为 True 时才需要。
            #利用生成器开始训练模型
            # flow方法输入原始训练数据,生成批量增强数据
            self.model.fit_generator(datagen.flow(dataset.train_images, dataset.train_labels,
                                                    batch_size = batch_size),
                                      epochs = nb_epoch # 这里注意keras2里参数名称是epochs而不是nb_epoch,否则会warning,参考https://stackoverflow.com/questions/46314003/keras-2-fit-generator-userwarning-steps-per-epoch-is-not-the-same-as-the-kera
                                      )

实际训练的时候一开始用SGD算法的时候收敛比较慢,后来改用ADAM速度快了不少;注意训练时的batch_size是一个值得调整的数值,batch_size大一些可能训练震荡较小收敛更快也能更充分利用向量化计算的速度优势,但是对内存的要求也更高,实际选取的时候以电脑不卡机为准,我12g内存的笔记本,batch_size为256的时候就有点卡了,所以我取了128。 接着是在测试集上验证,定义一个evaluate函数:

def evaluate(self, dataset):
    score = self.model.evaluate(dataset.test_images, dataset.test_labels) # evaluate返回的结果是list,两个元素分别是test loss和test accuracy
    print("%s: %.3f%%" % (self.model.metrics_names[1], score[1] * 100)) # 注意这里.3f后面的第二个百分号就是百分号,其余两个百分号则是格式化输出浮点数的语法。

接下来就开始了训练、验证和查看实际人脸识别效果的摸索过程,个人认为这一部分也是最有价值的。

最开始我准备了我和我女朋友两个人的图片各1000张左右,都是晚上室内光线条件较差的情况下获取的人脸图片,训练模型的时候经过10个epoch以后,训练准确率达到95.72%,测试准确率达到99.83%,看起来训练结果不错,但是后面在实际运行人脸识别程序时发现很容易把女朋友识别成我,这显然不行,一开始遇到这个问题我也没什么思路,直觉上感觉可能有两点需要改进的地方:

  1. 模型学习的人脸数据不够丰富多样,使得模型学习到的知识比较少,分辨能力不强,于是我决定引入LFW数据集的一部分人脸图片,首先到LFW数据集官网下载数据集,解压到当前工作目录下,最开始我对LFW的图片不做处理,直接全部用来训练;
  2. 用笔记本摄像头提取人脸的时候光线太差,图片质量不高,于是我在白天打开灯和窗帘的光线条件下重新提取了938张自己的人脸图片,采集的时候让自己的脸尽量转变不同的角度、姿态还有表情,并且去掉了比较模糊的图片。

接下来训练了很长时间,训练准确率达到了99.8%+,但是模型实际使用中却识别不出来我,我想也许是因为没有将LFW图片中的人脸提取出来,于是我在当前工作目录下新建一个read_lfw.py文件,用来提取LFW图片中的人脸并保存到training_data_others目录下作为其他人脸数据的一部分:

# -*- coding: utf-8 -*-
"""
Created on Sat Sep 22 19:03:03 2018

@author: 123
"""
import os
import cv2


num = 0
finished = False
def read_lfw(lfw_path):
    global num, finished
    for dir_item in os.listdir(lfw_path): # os.listdir() 方法用于返回指定的文件夹包含的文件或文件夹的名字的列表
        # 从当前工作目录寻找训练集图片的文件夹
        full_path = os.path.abspath(os.path.join(lfw_path, dir_item))
        
        if os.path.isdir(full_path): # 如果是文件夹,继续递归调用,去读取文件夹里的内容
            read_lfw(full_path)
        else: # 如果是文件了
            if dir_item.endswith('.jpg'):
                image = cv2.imread(full_path)
                classifier = cv2.CascadeClassifier('haarcascade_frontalface_alt2.xml') # 加载分类器
                path_name = 'dataset/training_data_others/'
                gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 灰度化
                faceRects=classifier.detectMultiScale(gray,scaleFactor=1.2,minNeighbors=3,minSize=(32,32))
        
                if len(faceRects) > 0:
                    for faceRect in faceRects:
                        x,y,w,h = faceRect
                        # 捕捉到的图片的名字,这里用到了格式化字符串的输出
                        # 注意这里图片名一定要加上扩展名,否则后面imwrite的时候会报错:could not find a writer for the specified extension in function cv::imwrite_ 
                        # 参考:https://stackoverflow.com/questions/9868963/cvimwrite-could-not-find-a-writer-for-the-specified-extension
                        image_name = '%s%d.jpg' % (path_name, num) 
                        image = image[y:y+h, x:x+w] # 将当前帧含人脸部分保存为图片,注意这里存的还是彩色图片,前面检测时灰度化是为了降低计算量;这里访问的是从y位开始到y+h-1位
                        cv2.imwrite(image_name, image)
                        num += 1
                        if num > 3000:
                            finished = True
                            break
        if finished:
            print('Finished.')
            break 
    

if __name__ =='__main__':
    print ('Processing lfw dataset...')
    read_lfw('lfw/') # 注意这里的training_data 文件夹就在程序工作目录下

上面程序用到的还是OpenCV中图片的一些基本操作,提取人脸的方法也和提取自己人脸的方法一样,参考系列前几篇手记即可,就不再详细讲解,注意提取的人脸图片名字要包含图片要存储的绝对路径。我提取了3000张LFW中的图片,然后直接放到dataset/training_data_others/路径下重新训练,训练和测试准确率分别达到了93.12%和97.79%,但是实际在识别的时候发现还是识别不出我,我意识到这应该是不同类别数据量不平衡的问题,其他人脸类别下有3000张左右人脸图片,而“我”类别下只有不到1000张图片,我在知乎上看到一篇文章,实验证明了不同类别样本数量不平衡的情况下准确率明显低于样本数量平衡的情况

接着我去掉了3000张LFW人脸中太小、太模糊、人脸角度太偏的数据,又从剩下的数据中留下1134张作为其他人脸类别数据来训练,此时“我”的人脸有938张,其他人脸是1134张,比之前的数据量要平衡不少,注意这次没有用到女朋友的图片,这次的训练结果训练准确率和测试准确率分别达到了98.3%和98.071%,实际识别的时候能识别出我了,但是识别女朋友的时候会误识别成我,这时我认为是因为训练数据中没有用女朋友的人脸数据的缘故,模型没有学到我女朋友的人脸的知识,因此无法准确识别,于是我又在光线好的条件下重新采集了177张女朋友的人脸放到其他人类别重新训练。 经过15个epoch的训练,训练准确率达到97.71%,测试准确率达到99.407%,实际进行人脸识别的时候发现:不开台灯比较暗的情况下(我在写字桌前)不稳定,很多时候会识别不出来我,女朋友开灯不开灯都能正确识别成Unknown(代表其他人),开灯后能稳定识别出我,女朋友在大部分时候也能正确识别为Unknown,不过发现女朋友低头的时候也容易错误识别成我,这可能说明人脸姿态也有一定影响。 至此,这个基于简单CNN的人脸识别模型已经训练好,不过我还是想进一步再探究一下光线的问题,于是我又采集了一些我自己在光线不好的情况下的人脸数据来重新训练,这一次训练了20个epoch训练准确率才达到比较高的值,但是在实际人脸识别的时候,又经常把女朋友错误识别成我,另外还发现一个OpenCV自带的人脸探测器的缺陷,就是会把鼻子和嘴一起框出来(可能是把鼻孔当成了眼睛),后面考虑用别的效果更好的人脸探测器

总结一下自己对训练和数据摸索的这个过程: 人脸识别的准确度受光照和姿态(姿态可以考虑alignment)的影响很大。感觉光线不好的时候采集的人脸图片可能有很多噪点,影响学习。

一个值得注意的现象是之前的训练结果中大部分都是测试数据的准确率高于训练数据,关于这个现象的解释我找到了一下几点:

  • Keras 模型有两种模式:训练和测试。正则化机制,如 Dropout 和 L1/L2 权重正则化,在测试时是关闭的。 此外,训练误差是每批训练数据的平均误差。由于你的模型是随着时间而变化的,一个 epoch 中的第一批数据的误差通常比最后一批的要高。另一方面,测试误差是模型在一个 epoch 训练完后计算的,因而误差较小
  • data augmentation也会导致这样的现象。因为data augmentation的本质就是把训练集变得丰富,制造数据的多样性和学习的困难来让network更robust(比如旋转,随机crop,scale),但是val和test的时候一般是不对数据进行data augmentation的。
  • 数据量太小,这个简单的CNN模型虽然在深度学习领域里算不上复杂,但相对于一些传统的机器学习模型参数量还是很大的,因此对数据的需求量也远大于我目前准备的数据量。

在基于CNN的计算机视觉中,对于我这种小数据量的问题,常用的做法是迁移学习,在后面的几篇手记里,我会介绍用迁移学习来完成这个人脸识别项目的方案。不过,在下一篇手记里,我还是先把最终实现人脸识别的方法展示出来,把整个人脸识别功能的完整框架搭起来,至于识别人脸用的是什么模型,只是作为一个API给最终的人脸识别程序调用。


总结

  • 人脸识别的准确度受光照和姿态的影响很大,这也是目前人脸识别准确度的一个挑战。
  • 对于数据量较小的计算机视觉任务可以考虑迁移学习

参考资料

  1. 人脸检测及识别python实现系列(5)——利用keras库训练人脸识别模型——博客园
  2. Gradient Based Learning Applied to Document Recognition(LeNet)
  3. categorical_crossentropy VS. sparse_categorical_crossentropy
  4. Keras 2 fit_generator UserWarning: steps_per_epoch is not the same as the Keras 1 argument samples_per_epoch——StackOverflow
  5. Labeled Faces in the Wild
  6. 训练集样本不平衡问题对CNN的影响——知乎
  7. 机器学习中,测试集的误差反而比训练集的误差要低,这个该怎么解释?——知乎
  8. 深度学习为什么会出现validation accuracy大于train accuracy的现象?