基于卷积神经网络的面部表情识别(Pytorch实现)----台大李宏毅机器学习作业3(HW3)
本文详细记录了台湾大学李宏毅老师机器学习课程第三次作业的完成经过,其中包括项目介绍、思路分析及模型搭建等。在本文中,先是通过OpenCv可视化了数据集,接着重点介绍了在Pytorch中数据的加载过程,随后利用Pytorch搭建卷积神经网络并进行训练。文末附源代码。
一、项目说明
给定数据集train.csv,要求使用卷积神经网络CNN,根据每个样本的面部图片判断出其表情。在本项目中,表情共分7类,分别为:(0)生气,(1)厌恶,(2)恐惧,(3)高兴,(4)难过,(5)惊讶和(6)中立(即面无表情,无法归为前六类)。所以,本项目实质上是一个7分类问题。
数据集介绍:
(1)、CSV文件,大小为28710行X2305列;
(2)、在28710行中,其中第一行为描述信息,即“label”和“feature”两个单词,其余每行内含有一个样本信息,即共有28709个样本;
(3)、在2305列中,其中第一列为该样本对应的label,取值范围为0到6。其余2304列为包含着每个样本大小为48X48人脸图片的像素值(2304=48X48),每个像素值取值范围在0到255之间;
(4)、数据集地址:https://pan.baidu.com/s/1hwrq5Abx8NOUse3oew3BXg ,提取码:ukf7 。
二、思路分析及代码实现
给定的数据集是csv格式的,考虑到图片分类问题的常规做法,决定先将其全部可视化,还原为图片文件再送进模型进行处理。
借助深度学习框架Pytorch1.0 CPU(穷逼)版本,搭建模型,由于需用到自己的数据集,因此我们需要重写其中的数据加载部分,其余用现成的API即可。
作业要求使用CNN实现功能,因此基本只能在调参阶段自由发挥(不要鄙视调参,通过这次作业才发现,参数也不是人人都能调得好的,比如我)。
2.1 数据可视化
我们需要将csv中的像素数据还原为图片并保存下来,在python环境下,很多库都能实现类似的功能,如pillow,opencv等。由于笔者对opencv较为熟悉,且opencv又是专业的图像处理库,因此决定采用opencv实现这一功能。
2.1.1 数据分离
原文件中,label和人脸像素数据是集中在一起的。为了方便操作,决定利用pandas库进行数据分离,即将所有label 读出后,写入新创建的文件label.csv;将所有的像素数据读出后,写入新创建的文件data.csv。
1 # 将label和像素数据分离 2 import pandas as pd 3 4 # 修改为train.csv在本地的相对或绝对地址 5 path = \'.//ml2019spring-hw3//train.csv\' 6 # 读取数据 7 df = pd.read_csv(path) 8 # 提取label数据 9 df_y = df[[\'label\']] 10 # 提取feature(即像素)数据 11 df_x = df[[\'feature\']] 12 # 将label写入label.csv 13 df_y.to_csv(\'label.csv\', index=False, header=False) 14 # 将feature数据写入data.csv 15 df_x.to_csv(\'data.csv\', index=False, header=False)
以上代码执行完毕后,在该代码脚本所在的文件夹下,就会生成两个新文件label.csv以及data.csv。在执行代码前,注意修改train.csv在本地的路径。
2.1.2 数据可视化
将数据分离后,人脸像素数据全部存储在data.csv文件中,其中每行数据就是一张人脸。按行读取数据,利用opencv将每行的2304个数据恢复为一张48X48的人脸图片,并保存为jpg格式。在保存这些图片时,将第一行数据恢复出的人脸命名为0.jpg,第二行的人脸命名为1.jpg……,以方便与label[0]、label[1]……一一对应。
1 import cv2 2 import numpy as np 3 4 # 指定存放图片的路径 5 path = \'.//face\' 6 # 读取像素数据 7 data = np.loadtxt(\'data.csv\') 8 9 # 按行取数据 10 for i in range(data.shape[0]): 11 face_array = data[i, :].reshape((48, 48)) # reshape 12 cv2.imwrite(path + \'//\' + \'{}.jpg\'.format(i), face_array) # 写图片
以上代码虽短,但涉及到大量数据的读取和大批图片的写入,因此占用的内存资源较多,且执行时间较长(视机器性能而定,一般要几分钟到十几分钟不等)。代码执行完毕,我们来到指定的图片存储路径,就能发现里面全部是写好的人脸图片。
粗略浏览一下这些人脸图片,就能发现这些图片数据来源较广,且并不纯净。就前60张图片而言,其中就包含了正面人脸,如1.jpg;侧面人脸,如18.jpg;倾斜人脸,如16.jpg;正面人头,如7.jpg;正面人上半身,如55.jpg;动漫人脸,如38.jpg;以及毫不相关的噪声,如59.jpg。放大图片后仔细观察,还会发现不少图片上还有水印。种种因素均给识别提出了严峻的挑战。
2.2 在pytorch下创建数据集
现在我们有了图片,但怎么才能把图片读取出来送给模型呢?
最简单粗暴的方法就是直接用opencv将所有图片读取出来,以numpy中array的数据格式直接送给模型。如果这样做的话,会一次性把所有图片全部读入内存,占用大量的内存空间,且只能使用单线程,效率不高,也不方便后续操作。
其实在pytorch中,有一个类(torch.utils.data.Dataset)是专门用来加载数据的,我们可以通过继承这个类来定制自己的数据集和加载方法。以下为基本流程。
2.2.1 创建data-label对照表
首先,我们需要划分一下训练集和验证集。在本次作业中,共有28709张图片,取前24000张图片作为训练集,其他图片作为验证集。新建文件夹train和val,将0.jpg到23999.jpg放进文件夹train,将其他图片放进文件夹val。
在继承torch.utils.data.Dataset类定制自己的数据集时,由于在数据加载过程中需要同时加载出一个样本的数据及其对应的label,因此最好能建立一个data-label对照表,其中记录着data和label的对应关系(“data-lable对照表”并非官方名词,这个技术流程是笔者参考了他人的博客后自己摸索的,这个名字也是笔者给命的名)。
有童鞋看到这里就会提出疑问了:在人脸可视化过程中,每张图片的命名不都和label的存放顺序是一一对应关系吗,为什么还要多此一举,再重新建立data-label对照表呢?笔者在刚开始的时候也是这么想的,按顺序(0.jpg, 1.jpg, 2.jpg……)加载图片和label(label[0], label[1], label[2]……),岂不是方便、快捷又高效?结果在实际操作的过程中才发现,程序加载文件的机制是按照文件名首字母(或数字)来的,即加载次序是0,1,10,100……,而不是预想中的0,1,2,3……,因此加载出来的图片不能够和label[0],label[1],lable[2],label[3]……一一对应,所以建立data-label对照表还是相当有必要的。
建立data-label对照表的基本思路就是:指定文件夹(train或val),遍历该文件夹下的所有文件,如果该文件是.jpg格式的图片,就将其图片名写入一个列表,同时通过图片名索引出其label,将其label写入另一个列表。最后利用pandas库将这两个列表写入同一个csv文件。
执行这段代码前,注意修改相关文件路径。代码执行完毕后,会在train和val文件夹下各生成一个名为dataset.csv的data-label对照表。
1 import os 2 import pandas as pd 3 4 def data_label(path): 5 # 读取label文件 6 df_label = pd.read_csv(\'label.csv\', header = None) 7 # 查看该文件夹下所有文件 8 files_dir = os.listdir(path) 9 # 用于存放图片名 10 path_list = [] 11 # 用于存放图片对应的label 12 label_list = [] 13 # 遍历该文件夹下的所有文件 14 for file_dir in files_dir: 15 # 如果某文件是图片,则将其文件名以及对应的label取出,分别放入path_list和label_list这两个列表中 16 if os.path.splitext(file_dir)[1] == ".jpg": 17 path_list.append(file_dir) 18 index = int(os.path.splitext(file_dir)[0]) 19 label_list.append(df_label.iat[index, 0]) 20 21 # 将两个列表写进dataset.csv文件 22 path_s = pd.Series(path_list) 23 label_s = pd.Series(label_list) 24 df = pd.DataFrame() 25 df[\'path\'] = path_s 26 df[\'label\'] = label_s 27 df.to_csv(path+\'\\dataset.csv\', index=False, header=False) 28 29 30 def main(): 31 # 指定文件夹路径 32 train_path = \'F:\\0gold\\ML\\LHY_class\\FaceData\\train\' 33 val_path = \'F:\\0gold\\ML\\LHY_class\\FaceData\\val\' 34 data_label(train_path) 35 data_label(val_path) 36 37 if __name__ == "__main__": 38 main()
OK,代码执行完毕,让我们来看一看data-label对照表里面具体是什么样子吧!
2.2.2 重写Dataset类
首先介绍一下Pytorch中Dataset类:Dataset类是Pytorch中图像数据集中最为重要的一个类,也是Pytorch中所有数据集加载类中应该继承的父类。其中父类中的两个私有成员函数getitem()和len()必须被重载,否则将会触发错误提示。其中getitem()可以通过索引获取数据,len()可以获取数据集的大小。在Pytorch源码中,Dataset类的声明如下:
1 class Dataset(object): 2 """An abstract class representing a Dataset. 3 4 All other datasets should subclass it. All subclasses should override 5 ``__len__``, that provides the size of the dataset, and ``__getitem__``, 6 supporting integer indexing in range from 0 to len(self) exclusive. 7 """ 8 9 def __getitem__(self, index): 10 raise NotImplementedError 11 12 def __len__(self): 13 raise NotImplementedError 14 15 def __add__(self, other): 16 return ConcatDataset([self, other])
我们通过继承Dataset类来创建我们自己的数据加载类,命名为FaceDataset。
1 import torch 2 from torch.utils import data 3 import numpy as np 4 import pandas as pd 5 import cv2 6 7 class FaceDataset(data.Dataset):
首先要做的是类的初始化。之前的data-label对照表已经创建完毕,在加载数据时需用到其中的信息。因此在初始化过程中,我们需要完成对data-label对照表中数据的读取工作。
通过pandas库读取数据,随后将读取到的数据放入list或numpy中,方便后期索引。
1 # 初始化 2 def __init__(self, root): 3 super(FaceDataset, self).__init__() 4 # root为train或val文件夹的地址 5 self.root = root 6 # 读取data-label对照表中的内容 7 df_path = pd.read_csv(root + \'\\dataset.csv\', header=None, usecols=[0]) # 读取第一列文件名 8 df_label = pd.read_csv(root + \'\\dataset.csv\', header=None, usecols=[1]) # 读取第二列label 9 # 将其中内容放入numpy,方便后期索引 10 self.path = np.array(df_path)[:, 0] 11 self.label = np.array(df_label)[:, 0]
接着就要重写getitem()函数了,该函数的功能是加载数据。在前面的初始化部分,我们已经获取了所有图片的地址,在这个函数中,我们就要通过地址来读取数据。
由于是读取图片数据,因此仍然借助opencv库。需要注意的是,之前可视化数据部分将像素值恢复为人脸图片并保存,得到的是3通道的灰色图(每个通道都完全一样),而在这里我们只需要用到单通道,因此在图片读取过程中,即使原图本来就是灰色的,但我们还是要加入参数从cv2.COLOR_BGR2GARY,保证读出来的数据是单通道的。读取出来之后,可以考虑进行一些基本的图像处理操作,如通过高斯模糊降噪、通过直方图均衡化来增强图像等(经试验证明,在本次作业中,直方图均衡化并没有什么卵用,而高斯降噪甚至会降低正确率,可能是因为图片分辨率本来就较低,模糊后基本上什么都看不清了吧)。读出的数据是48X48的,而后续卷积神经网络中nn.Conv2d() API所接受的数据格式是(batch_size, channel, width, higth),本次图片通道为1,因此我们要将48X48 reshape为1X48X48。
1 # 读取某幅图片,item为索引号 2 def __getitem__(self, item): 3 face = cv2.imread(self.root + \'\\\' + self.path[item]) 4 # 读取单通道灰度图 5 face_gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY) 6 # 高斯模糊 7 # face_Gus = cv2.GaussianBlur(face_gray, (3,3), 0) 8 # 直方图均衡化 9 face_hist = cv2.equalizeHist(face_gray) 10 # 像素值标准化 11 face_normalized = face_hist.reshape(1, 48, 48) / 255.0 # 为与pytorch中卷积神经网络API的设计相适配,需reshape原图 12 # 用于训练的数据需为tensor类型 13 face_tensor = torch.from_numpy(face_normalized) # 将python中的numpy数据类型转化为pytorch中的tensor数据类型 14 face_tensor = face_tensor.type(\'torch.FloatTensor\') # 指定为\'torch.FloatTensor\'型,否则送进模型后会因数据类型不匹配而报错 15 label = self.label[item] 16 return face_tensor, label
最后就是重写len()函数获取数据集大小了。self.path中存储着所有的图片名,获取self.path第一维的大小,即为数据集的大小。
1 # 获取数据集样本个数 2 def __len__(self): 3 return self.path.shape[0]
完整代码:
1 class FaceDataset(data.Dataset): 2 # 初始化 3 def __init__(self, root): 4 super(FaceDataset, self).__init__() 5 self.root = root 6 df_path = pd.read_csv(root + \'\\dataset.csv\', header=None, usecols=[0]) 7 df_label = pd.read_csv(root + \'\\dataset.csv\', header=None, usecols=[1]) 8 self.path = np.array(df_path)[:, 0] 9 self.label = np.array(df_label)[:, 0] 10 11 # 读取某幅图片,item为索引号 12 def __getitem__(self, item): 13 face = cv2.imread(self.root + \'\\\' + self.path[item]) 14 # 读取单通道灰度图 15 face_gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY) 16 # 高斯模糊 17 # face_Gus = cv2.GaussianBlur(face_gray, (3,3), 0) 18 # 直方图均衡化 19 face_hist = cv2.equalizeHist(face_gray) 20 # 像素值标准化 21 face_normalized = face_hist.reshape(1, 48, 48) / 255.0 # 为与pytorch中卷积神经网络API的设计相适配,需reshape原图 22 # 用于训练的数据需为tensor类型 23 face_tensor = torch.from_numpy(face_normalized) # 将python中的numpy数据类型转化为pytorch中的tensor数据类型 24 face_tensor = face_tensor.type(\'torch.FloatTensor\') # 指定为\'torch.FloatTensor\'型,否则送进模型后会因数据类型不匹配而报错 25 label = self.label[item] 26 return face_tensor, label 27 28 # 获取数据集样本个数 29 def __len__(self): 30 return self.path.shape[0]
View Code
2.2.3 数据集的使用
到此为止,我们已经成功地写好了自己的数据集加载类。那么这个类该如何使用呢?下面笔者将以训练集(train文件夹下的数据)加载为例,讲一下整个数据集加载类在模型训练过程中的使用方法。
首先,我们需要将这个类实例化。
1 # 数据集实例化(创建数据集) 2 train_dataset = FaceDataset(root=\'E:\\WSD\\HW3\\FaceData\\train\')
train_dataset即为我们实例化的训练集,要想加载其中的数据,还需要DataLoader类的辅助。DataLoader类总是配合Dataset类一起使用,DataLoader类可以帮助我们分批次读取数据,也可以通过这个类选择读取数据的方式(顺序 or 随机乱序),还可以选择并行加载数据等,这个类并不要我们重写。
1 # 载入数据并分割batch 2 train_loader = data.DataLoader(train_dataset, batch_size)
最后,我们就能直接从train_loader中直接加载出数据和label了,而且每次都会加载出一个批次(batch)的数据和label。
1 for images, labels in train_loader: 2 \'\'\' 3 通过images和labels训练模型 4 \'\'\'
2.3 网络模型搭建
通过Pytorch搭建基于卷积神经网络的分类器。刚开始是自己设计的网络模型,在训练时发现准确度一直上不去,折腾一周后走投无路,后来在github上找到了一个做表情识别的开源项目,用的是这个项目的模型结构,但还是没能达到项目中的精度(acc在74%)。下图为该开源项目中公布的两个模型结构,笔者用的是Model B ,且只采用了其中的卷积-全连接部分,如果大家希望进一步提高模型的表现能力,可以考虑向模型中添加Face landmarks + HOG features 部分。
可以看出,在Model B 的卷积部分,输入图片shape为48X48X1,经过一个3X3X64卷积核的卷积操作,再进行一次2X2的池化,得到一个24X24X64的feature map 1(以上卷积和池化操作的步长均为1,每次卷积前的padding为1,下同)。将feature map 1经过一个3X3X128卷积核的卷积操作,再进行一次2X2的池化,得到一个12X12X128的feature map 2。将feature map 2经过一个3X3X256卷积核的卷积操作,再进行一次2X2的池化,得到一个6X6X256的feature map 3。卷积完毕,数据即将进入全连接层。进入全连接层之前,要进行数据扁平化,将feature map 3拉一个成长度为6X6X256=9216的一维tensor。随后数据经过dropout后被送进一层含有4096个神经元的隐层,再次经过dropout后被送进一层含有1024个神经元的隐层,之后经过一层含256个神经元的隐层,最终经过含有7个神经元的输出层。一般再输出层后都会加上softmax层,取概率最高的类别为分类结果。
我们可以通过继承nn.Module来定义自己的模型类。以下代码实现了上述的模型结构。需要注意的是,在代码中,数据经过最后含7个神经元的线性层后就直接输出了,并没有经过softmax层。这是为什么呢?其实这和Pytorch在这一块的设计机制有关。因为在实际应用中,softmax层常常和交叉熵这种损失函数联合使用,因此Pytorch在设计时,就将softmax运算集成到了交叉熵损失函数CrossEntropyLoss()内部,如果使用交叉熵作为损失函数,就默认在计算损失函数前自动进行softmax操作,不需要我们额外加softmax层。Tensorflow也有类似的机制。
1 class FaceCNN(nn.Module): 2 # 初始化网络结构 3 def __init__(self): 4 super(FaceCNN, self).__init__() 5 6 # 第一次卷积、池化 7 self.conv1 = nn.Sequential( 8 # 输入通道数in_channels,输出通道数(即卷积核的通道数)out_channels,卷积核大小kernel_size,步长stride,对称填0行列数padding 9 # input:(bitch_size, 1, 48, 48), output:(bitch_size, 64, 48, 48), (48-3+2*1)/1+1 = 48 10 nn.Conv2d(in_channels=1, out_channels=64, kernel_size=3, stride=1, padding=1), # 卷积层 11 nn.BatchNorm2d(num_features=64), # 归一化 12 nn.RReLU(inplace=True), # 激活函数 13 # output(bitch_size, 64, 24, 24) 14 nn.MaxPool2d(kernel_size=2, stride=2), # 最大值池化 15 ) 16 17 # 第二次卷积、池化 18 self.conv2 = nn.Sequential( 19 # input:(bitch_size, 64, 24, 24), output:(bitch_size, 128, 24, 24), (24-3+2*1)/1+1 = 24 20 nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, stride=1, padding=1), 21 nn.BatchNorm2d(num_features=128), 22 nn.RReLU(inplace=True), 23 # output:(bitch_size, 128, 12 ,12) 24 nn.MaxPool2d(kernel_size=2, stride=2), 25 ) 26 27 # 第三次卷积、池化 28 self.conv3 = nn.Sequential( 29 # input:(bitch_size, 128, 12, 12), output:(bitch_size, 256, 12, 12), (12-3+2*1)/1+1 = 12 30 nn.Conv2d(in_channels=128, out_channels=256, kernel_size=3, stride=1, padding=1), 31 nn.BatchNorm2d(num_features=256), 32 nn.RReLU(inplace=True), 33 # output:(bitch_size, 256, 6 ,6) 34 nn.MaxPool2d(kernel_size=2, stride=2), 35 ) 36 37 # 参数初始化 38 self.conv1.apply(gaussian_weights_init) 39 self.conv2.apply(gaussian_weights_init) 40 self.conv3.apply(gaussian_weights_init) 41 42 # 全连接层 43 self.fc = nn.Sequential( 44 nn.Dropout(p=0.2), 45 nn.Linear(in_features=256*6*6, out_features=4096), 46 nn.RReLU(inplace=True), 47 nn.Dropout(p=0.5), 48 nn.Linear(in_features=4096, out_features=1024), 49 nn.RReLU(inplace=True), 50 nn.Linear(in_features=1024, out_features=256), 51 nn.RReLU(inplace=True), 52 nn.Linear(in_features=256, out_features=7), 53 ) 54 55 # 前向传播 56 def forward(self, x): 57 x = self.conv1(x) 58 x = self.conv2(x) 59 x = self.conv3(x) 60 # 数据扁平化 61 x = x.view(x.shape[0], -1) 62 y = self.fc(x) 63 return y
2.4 训练模型
有了模型,就可以通过数据的前向传播和误差的反向传播来训练模型了。在此之前,还需要指定优化器(即学习率更新的方式)、损失函数以及训练轮数、学习率等超参数。
在本次作业中,我们采用的优化器是SGD,即随机梯度下降,其中参数weight_decay为正则项系数;损失函数采用的是交叉熵;可以考虑使用学习率衰减。
1 def train(train_dataset, batch_size, epochs, learning_rate, wt_decay): 2 # 载入数据并分割batch 3 train_loader = data.DataLoader(train_dataset, batch_size) 4 # 构建模型 5 model = FaceCNN() 6 # 损失函数 7 loss_function = nn.CrossEntropyLoss() 8 # 优化器 9 optimizer = optim.SGD(model.parameters(), lr=learning_rate, weight_decay=wt_decay) 10 # 学习率衰减 11 # scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.8) 12 # 逐轮训练 13 for epoch in range(epochs): 14 # 记录损失值 15 loss_rate = 0 16 # scheduler.step() # 学习率衰减 17 model.train() # 模型训练 18 for images, labels in train_loader: 19 # 梯度清零 20 optimizer.zero_grad() 21 # 前向传播 22 output = model.forward(images) 23 # 误差计算 24 loss_rate = loss_function(output, labels) 25 # 误差的反向传播 26 loss_rate.backward() 27 # 更新参数 28 optimizer.step()
2.5 模型的保存与加载
我们训练的这个模型相对较小,因此可以直接保存整个模型(包括结构和参数)。
1 # 模型保存 2 torch.save(model, \'model_net1.pkl\')
1 # 模型加载 2 model_parm = \'model_net1.pkl\' 3 model = torch.load(net_parm)
三、源码分享
3.1 源代码
代码在CPU上跑起来较慢,视超参数和机器性能不同,一般跑完需耗时几小时到几十小时不等。代码执行时,每轮输出一次损失值,每5轮输出一次在训练集和验证集上的正确率。有条件的可以在GPU上尝试。
1 import torch 2 import torch.utils.data as data 3 import torch.nn as nn 4 import torch.optim as optim 5 import numpy as np 6 import pandas as pd 7 import cv2 8 9 # 参数初始化 10 def gaussian_weights_init(m): 11 classname = m.__class__.__name__ 12 # 字符串查找find,找不到返回-1,不等-1即字符串中含有该字符 13 if classname.find(\'Conv\') != -1: 14 m.weight.data.normal_(0.0, 0.04) 15 16 # 人脸旋转,尝试过但效果并不好,本次并未用到 17 def imgProcess(img): 18 # 通道分离 19 (b, g, r) = cv2.split(img) 20 # 直方图均衡化 21 bH = cv2.equalizeHist(b) 22 gH = cv2.equalizeHist(g) 23 rH = cv2.equalizeHist(r) 24 25 # 顺时针旋转15度矩阵 26 M0 = cv2.getRotationMatrix2D((24,24),15,1) 27 # 逆时针旋转15度矩阵 28 M1 = cv2.getRotationMatrix2D((24,24),15,1) 29 # 旋转 30 gH = cv2.warpAffine(gH, M0, (48, 48)) 31 rH = cv2.warpAffine(rH, M1, (48, 48)) 32 # 通道合并 33 img_processed = cv2.merge((bH, gH, rH)) 34 return img_processed 35 36 # 验证模型在验证集上的正确率 37 def validate(model, dataset, batch_size): 38 val_loader = data.DataLoader(dataset, batch_size) 39 result, num = 0.0, 0 40 for images, labels in val_loader: 41 pred = model.forward(images) 42 pred = np.argmax(pred.data.numpy(), axis=1) 43 labels = labels.data.numpy() 44 result += np.sum((pred == labels)) 45 num += len(images) 46 acc = result / num 47 return acc 48 49 class FaceDataset(data.Dataset): 50 # 初始化 51 def __init__(self, root): 52 super(FaceDataset, self).__init__() 53 self.root = root 54 df_path = pd.read_csv(root + \'\\dataset.csv\', header=None, usecols=[0]) 55 df_label = pd.read_csv(root + \'\\dataset.csv\', header=None, usecols=[1]) 56 self.path = np.array(df_path)[:, 0] 57 self.label = np.array(df_label)[:, 0] 58 59 # 读取某幅图片,item为索引号 60 def __getitem__(self, item): 61 # 图像数据用于训练,需为tensor类型,label用numpy或list均可 62 face = cv2.imread(self.root + \'\\\' + self.path[item]) 63 # 读取单通道灰度图 64 face_gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY) 65 # 高斯模糊 66 # face_Gus = cv2.GaussianBlur(face_gray, (3,3), 0) 67 # 直方图均衡化 68 face_hist = cv2.equalizeHist(face_gray) 69 # 像素值标准化 70 face_normalized = face_hist.reshape(1, 48, 48) / 255.0 71 face_tensor = torch.from_numpy(face_normalized) 72 face_tensor = face_tensor.type(\'torch.FloatTensor\') 73 label = self.label[item] 74 return face_tensor, label 75 76 # 获取数据集样本个数 77 def __len__(self): 78 return self.path.shape[0] 79 80 class FaceCNN(nn.Module): 81 # 初始化网络结构 82 def __init__(self): 83 super(FaceCNN, self).__init__() 84 85 # 第一次卷积、池化 86 self.conv1 = nn.Sequential( 87 # 输入通道数in_channels,输出通道数(即卷积核的通道数)out_channels,卷积核大小kernel_size,步长stride,对称填0行列数padding 88 # input:(bitch_size, 1, 48, 48), output:(bitch_size, 64, 48, 48), (48-3+2*1)/1+1 = 48 89 nn.Conv2d(in_channels=1, out_channels=64, kernel_size=3, stride=1, padding=1), # 卷积层 90 nn.BatchNorm2d(num_features=64), # 归一化 91 nn.RReLU(inplace=True), # 激活函数 92 # output(bitch_size, 64, 24, 24) 93 nn.MaxPool2d(kernel_size=2, stride=2), # 最大值池化 94 ) 95 96 # 第二次卷积、池化 97 self.conv2 = nn.Sequential( 98 # input:(bitch_size, 64, 24, 24), output:(bitch_size, 128, 24, 24), (24-3+2*1)/1+1 = 24 99 nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, stride=1, padding=1), 100 nn.BatchNorm2d(num_features=128), 101 nn.RReLU(inplace=True), 102 # output:(bitch_size, 128, 12 ,12) 103 nn.MaxPool2d(kernel_size=2, stride=2), 104 ) 105 106 # 第三次卷积、池化 107 self.conv3 = nn.Sequential( 108 # input:(bitch_size, 128, 12, 12), output:(bitch_size, 256, 12, 12), (12-3+2*1)/1+1 = 12 109 nn.Conv2d(in_channels=128, out_channels=256, kernel_size=3, stride=1, padding=1), 110 nn.BatchNorm2d(num_features=256), 111 nn.RReLU(inplace=True), 112 # output:(bitch_size, 256, 6 ,6) 113 nn.MaxPool2d(kernel_size=2, stride=2), 114 ) 115 116 # 参数初始化 117 self.conv1.apply(gaussian_weights_init) 118 self.conv2.apply(gaussian_weights_init) 119 self.conv3.apply(gaussian_weights_init) 120 121 # 全连接层 122 self.fc = nn.Sequential( 123 nn.Dropout(p=0.2), 124 nn.Linear(in_features=256*6*6, out_features=4096), 125 nn.RReLU(inplace=True), 126 nn.Dropout(p=0.5), 127 nn.Linear(in_features=4096, out_features=1024), 128 nn.RReLU(inplace=True), 129 nn.Linear(in_features=1024, out_features=256), 130 nn.RReLU(inplace=True), 131 nn.Linear(in_features=256, out_features=7), 132 ) 133 134 # 前向传播 135 def forward(self, x): 136 x = self.conv1(x) 137 x = self.conv2(x) 138 x = self.conv3(x) 139 # 数据扁平化 140 x = x.view(x.shape[0], -1) 141 y = self.fc(x) 142 return y 143 144 def train(train_dataset, val_dataset, batch_size, epochs, learning_rate, wt_decay): 145 # 载入数据并分割batch 146 train_loader = data.DataLoader(train_dataset, batch_size) 147 # 构建模型 148 model = FaceCNN() 149 # 损失函数 150 loss_function = nn.CrossEntropyLoss() 151 # 优化器 152 optimizer = optim.SGD(model.parameters(), lr=learning_rate, weight_decay=wt_decay) 153 # 学习率衰减 154 # scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.8) 155 # 逐轮训练 156 for epoch in range(epochs): 157 # 记录损失值 158 loss_rate = 0 159 # scheduler.step() # 学习率衰减 160 model.train() # 模型训练 161 for images, labels in train_loader: 162 # 梯度清零 163 optimizer.zero_grad() 164 # 前向传播 165 output = model.forward(images) 166 # 误差计算 167 loss_rate = loss_function(output, labels) 168 # 误差的反向传播 169 loss_rate.backward() 170 # 更新参数 171 optimizer.step() 172 173 # 打印每轮的损失 174 print(\'After {} epochs , the loss_rate is : \'.format(epoch+1), loss_rate.item()) 175 if epoch % 5 == 0: 176 model.eval() # 模型评估 177 acc_train = validate(model, train_dataset, batch_size) 178 acc_val = validate(model, val_dataset, batch_size) 179 print(\'After {} epochs , the acc_train is : \'.format(epoch+1), acc_train) 180 print(\'After {} epochs , the acc_val is : \'.format(epoch+1), acc_val) 181 182 return model 183 184 def main(): 185 # 数据集实例化(创建数据集) 186 train_dataset = FaceDataset(root=\'E:\\WSD\\HW3\\FaceData\\train\') 187 val_dataset = FaceDataset(root=\'E:\\WSD\\HW3\\FaceData\\val\') 188 # 超参数可自行指定 189 model = train(train_dataset, val_dataset, batch_size=128, epochs=100, learning_rate=0.1, wt_decay=0) 190 # 保存模型 191 torch.save(model, \'model_net1.pkl\') 192 193 194 if __name__ == \'__main__\': 195 main()
View Code
3.2 说明
这是台湾大学李宏毅老师机器学习课程(2019年春季)第三次作业。在该数据集上,只用卷积神经网络和其他辅助手段,能达到的最高分类正确率在75%左右。我前后折腾了近3周,一方面因为能力有限,无人交流指导,另一方面是因为算力有限(穷逼一个,没有GPU),最终正确率也仅有63%。上面的源代码不是我的最终模型,一是因为我的模型本来就不好,过拟合有点严重;二是因为我希望大家能自己动手体验一波调参的乐趣。在此抛砖引玉,要是有哪个小伙伴有好的改进方法,欢迎来和我交流鸭~
参考资料:
本次作业发布地址:https://ntumlta2019.github.io/ml-web-hw3/
面部表情识别GitHub地址:https://github.com/amineHorseman/facial-expression-recognition-using-cnn
Pytorch制作数据集:https://ptorch.com/news/215.html
https://blog.csdn.net/Teeyohuang/article/details/79587125
2022农历新年附:
时光如白驹过隙,转眼毕业参加工作一年半了。步入社会后便在生活中疲于奔命,少了当年的书生意气。博客短期没有更新的打算,也实在没有精力维护。同学们留言的问题部分我也有看到,但博客内容久远,好多细节我也实在记不大清楚了,便没有回复。其实学问就是摸着石头过河,在探索中不断遇到问题,而独立解决问题的过程就是学习提高的过程。
博主目前在shopee从事机器学习平台开发工作,部门及公司都在大力招聘中。福利待遇极好,工作1075,work life balance,待遇比肩字节。有兴趣的同学可以关注我新开的内推公众号“进击的虾仔”,帮你内推进入虾厂~
江湖路远,大家后会有期~