摘要
在本次实验中,我实现了LeNet-5卷积神经网络模型的构建与训练,以实现图像分类任务。主模型采用Pytorch框架搭建,模型识别准确率达到了87%,体现了较好的分类效果。除此之外,我还尝试使用C++实现模型的底层核心操作,包括卷积、池化及全连接等,但最终准确率较低,未达预期。此外,为进一步拓展,本次实验还尝试在硬件上进行移植,通过ChatGPT选择适合的单片机型号,并准备在嵌入式平台中实现该模型。
绪论
图像分类一直是计算机领域的的一个热门问题,在图像分类任务中,卷积神经网络(CNN)已成为最主流的深度学习方法之一,特别适用于图像特征提取和模式识别。针对此类问题,常见的解决方法包括支持向量机(SVM)、K近邻(KNN)等传统机器学习方法,和卷积神经网络(CNN)、循环神经网络(RNN)等深度学习模型。其中,传统机器学习方法通常依赖人工提取特征,适合处理低维度的数据,但对复杂数据(如图像)的分类效果有限。而深度学习模型则通过自动学习特征表达来适应复杂任务,CNN尤其擅长提取图像特征,因此被广泛用于图像分类。
本报告选择的LeNet-5模型是一种经典的卷积神经网络结构,由若干卷积层、池化层和全连接层组成。相比于传统机器学习方法,CNN在图像分类任务中具备显著优势,能够自动提取图像特征,避免了复杂的特征工程工作。LeNet-5尤其适合手写数字等简单图像分类任务,结构简单、计算需求较低,适合在初学者中推广。但其也存在一定的局限性:网络较浅,处理复杂图像的能力不足;训练速度和泛化能力也逊于一些更现代的深层网络(如ResNet、VGG)。
在本次实验中,我首先使用Pytorch实现LeNet-5模型,以充分利用其框架优势快速搭建网络并进行训练。Pytorch封装了众多深度学习工具,能显著简化代码编写,提高调试效率,因而在短时间内获得了较高的分类准确率。然而,框架的高层次封装在一定程度上掩盖了算法细节,因此为了深入理解CNN的工作原理,我进一步使用C++实现了LeNet-5的核心模块(卷积、池化和全连接),以探索底层细节。尽管C++实现的准确率较低,但提升了我对CNN原理的理解。
此外,为扩展应用,我在硬件部署方面进行了尝试,选择合适的单片机准备进行模型移植,这一过程为今后进一步优化CNN在嵌入式系统中的应用奠定了基础。
方法描述
-
算法框架:传统的LeNet-5模型包含两个卷积层(C1和C3)、两个池化层(S2和S4)和两个全连接层(C5和F6),通过级联结构逐步提取图像特征并完成分类。模型结构包括:
- 卷积层:提取局部图像特征,增大特征维度。
- 池化层:降低数据维度,缓解过拟合。
- 全连接层:对提取的特征进行分类。
(图一 LeNet-5基本结构)
-
模块功能: 下面是对各个层的详细介绍:
-
输入层(Input layer)
输入层接收大小为 32×32 的手写数字图像,其中包括灰度值(0-255)。在实际应用中,我们通常会对输入图像进行预处理,以加快训练速度和提高模型的准确性。 -
卷积层C1(Convolutional layer C1)
卷积层C1包括6个卷积核,每个卷积核的大小为 5×5 ,步长为1,填充为0。因此,每个卷积核会产生一个大小为 28×28 的特征图(输出通道数为6)。 -
池化层S2(Subsampling layer S2)
在标准LeNet-5网络中,采样层S2采用平均池化(averange-pooling)操作,每个窗口的大小为 2×2 ,步长为2。因此,每个池化操作会从4个相邻的特征图中选择最大值,产生一个大小为 14×14 的特征图(输出通道数为6)。这样可以减少特征图的大小,提高计算效率,并且对于轻微的位置变化可以保持一定的不变性。 -
卷积层C3(Convolutional layer C3)
卷积层C3包括16个卷积核,每个卷积核的大小为 5×5 ,步长为1,填充为0。因此,每个卷积核会产生一个大小为 10×10 的特征图(输出通道数为16)。 -
池化层S4(Subsampling layer S4)
池化层S4也采用平均池化,每个窗口的大小为 2×2 ,步长为2。因此,每个池化操作会从4个相邻的特征图中选择最大值,产生一个大小为 5×5 的特征图(输出通道数为16)。 -
全连接层C5(Fully connected layer C5)
C5将每个大小为 5×5 的特征图拉成一个长度为400的向量,并通过一个带有120个神经元的全连接层进行连接。120是由LeNet-5的设计者根据实验得到的最佳值。 -
全连接层F6(Fully connected layer F6)
全连接层F6将120个神经元连接到84个神经元。 -
输出层(Output layer)
输出层由10个神经元组成,每个神经元对应0-9中的一个数字,并输出最终的分类结果。在训练过程中,使用交叉熵损失函数计算输出层的误差,并通过反向传播算法更新卷积核和全连接层的权重参数。
图片进入模型的流程为:
灰度处理/大小处理 -> C1卷积层增大特征维度 -> S2池化层降低数据维度 -> C3卷积层再次增大特征维度 -> S4池化层降低数据维度 -> C5 F6与神经元连接并实现分类 -> O8输出但是,在实际应用中,通常会对LeNet-5进行一些改进,如增加网络深度、增加卷积核数量、添加正则化等方法,来进一步提高模型的准确性和泛化能力。
-
-
各层实现原理
1.卷积层
在卷积神经网络中,卷积操作是指将一个可移动的小窗口(称为数据窗口,如下图蓝色矩形)与图像进行逐元素相乘然后相加的操作。这个小窗口其实是一组固定的权重,它可以被看作是一个特定的滤波器(filter)或卷积核。这个操作的名称“卷积”,源自于这种元素级相乘和求和的过程。这一操作是卷积神经网络名字的来源。该小窗口以特定的步长滚动,用其中的每一个元素(权重)与映射的图像元素进行相乘后相加,完成了图像特征的提取。
2.池化层
池化层用于降低特征图的空间分辨率,并增强模型对输入图像的平移不变性。常用的池化方式包括最大池化和平均池化。最大池化的操作是在一个滑动窗口中取最大值作为输出,平均池化的操作是在一个滑动窗口中取平均值作为输出。
(最大池化)
(平均池化)
3.全连接层
全连接层通常用于将卷积层和池化层提取的特征进行分类或回归。它的输入是一维向量,其输出的维度与任务的分类数或回归值的维度相同。 -
改进方法: 在本次实验中,笔者对LeNet-5网络进行了如下进一步的改进:
- 笔者在本次的项目实现中,为了提高LeNet-5的效果,将C1卷积层卷积核的数量改为了8,、C31卷积层卷积核的数量改为了20。
- 池化层S2、S4采用最大池化(max-pooling)操作代替平均池化(averange-pooling),以保留更多显著特征。
- LeNet-5网络中,使用的激活函数为Sigmoid函数。
这种函数虽然处处可导,但在两边容易出现梯度较小(即梯度消失)的问题。为了解决这个问题笔者采用了ReLU函数。
这个函数在(0,+∞)上的导数为1,可以快速实现梯度下降,减少训练时间(但是,这个函数在小于0的地方容易出现神经元死亡问题,在本次实验中没有出现)
4. 作者在模型训练时,适当提升了训练次数,增加了一部分准确率 -
实现方法: 下面是我的三种实现方法:
-
Pytorch实现:基于Pytorch框架搭建模型结构,定义损失函数和优化器,进行模型训练和测试,最终识别准确率达87%。
-
C++实现:在C++中实现卷积、池化和全连接操作,尝试构建类似的LeNet-5网络架构,但由于调试时间和实现难度,最终分类效果不理想。
-
硬件移植:通过参考ChatGPT建议选择单片机型号,准备在嵌入式平台上进行模型部署。
-
代码描述:
考虑到访问的便捷性,笔者将代码全部上传到gitee,读者可以直接访问我的仓库来获取全部代码。
- Pytorch部分代码:Pytorch-LeNet-5: Pytorch实现LeNet-5 (gitee.com)
- C++部分代码:Keras: C++实现Keras模型及卷积神经网络的初步实现 (gitee.com)
Pytorch部分
model.py文件
这个文件实现了改进后的LeNet-5网络模型,并将该模型放到设备(CPU或GPU)中实例化成model。
- 初始化
__init__(self)
函数
def __init__(self): super(LeNet, self).__init__() # 卷积层1(标准LeNet-5) # self.c1 = nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5, padding=2) # 卷积层1 self.c1 = nn.Conv2d(in_channels=1, out_channels=8, kernel_size=5, padding=2) # 激活函数(标准LeNet-5) # self.sig = nn.Sigmoid() # 激活函数 (创新,使用Relu激活函数) self.sig = nn.ReLU() # 池化 self.s2 = nn.AvgPool2d(kernel_size=2, stride=2) # 卷积层2(标准LeNet-5) # self.c3 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5) # 卷积层2 self.c3 = nn.Conv2d(in_channels=8, out_channels=20, kernel_size=5) # 池化2 self.s4 = nn.AvgPool2d(kernel_size=2, stride=2) # 平展层 self.flatten = nn.Flatten() self.f5 = nn.Linear(5*5*20, 120) # self.f5 = nn.Linear(5 * 5 * 16, 120) self.f6 = nn.Linear(120, 84) self.f7 = nn.Linear(84, 10)
这一段代码继承了torch.nn.Module,并做了初始化操作,规定了每一层的参数及功能。
Pytorch在神经网络的搭建部分做的非常傻瓜化,在继承之后,只需要想好你的神经网络有几层、每一层分别是什么,参数是多少,采用对应的函数并填入相应的数据即可完成操作。如
# 卷积层1 self.c1 = nn.Conv2d(in_channels=1, out_channels=8, kernel_size=5, padding=2)
中,使用了nn.Conv2d()
函数。在该层卷积操作中,输入通道为1,输出通道为8(卷积核数量为8),卷积核大小为5,步长为2,分别对应in_channels=1, out_channels=8, kernel_size=5, padding=2
,将参数填入以执行卷积操作。其它每一层类似,根据要执行的操作选择对应的函数,计算相应的参数并填入即可完成对应操作。
值得一提的是,在卷积神经网络中,卷积操作输出层计算公式为:
- 前向传播
forward(self, x)
函数
def forward(self, x): x = self.sig(self.c1(x)) x = self.s2(x) x = self.sig(self.c3(x)) x = self.s4(x) x = self.flatten(x) x = self.f5(x) x = self.f6(x) x = self.f7(x) return x
这一段代码在上一个代码基础上,定义了forward(self, x)
函数。将上一步初始化内容逐步实现,并通过x
传递,实现了前向传播操作。
model_train.py文件
这个文件实现了模型的训练。
train_val_data_process()
函数 ^f20f5c
def train_val_data_process(): # 训练——验证下载、处理函数 train_data = FashionMNIST(root='D:/Train/data', train=True,transform=transforms.Compose([transforms.Resize(size=28),transforms.ToTensor()]), download=True) train_data, val_data = Data.random_split(train_data, [round(0.8*len(train_data)), round(0.2*len(train_data))]) # 划分训练集与验证集 # 数据打包,以32为一组捆起来 train_loader = Data.DataLoader(dataset=train_data, batch_size=32, shuffle=True, num_workers=2) val_loader = Data.DataLoader(dataset=val_data, batch_size=32, shuffle=True, num_workers=2) return train_loader, val_loader
这个函数检查了训练集和测试集是否存在,若不存在,则下载下来;此外,该函数将图片以32个为一组打包起来,方便后续的图像处理及训练(32为笔者自己测评的结果,读者可以根据电脑配置酌情更改);此外,作者将训练集中图像的20%用于做每一次训练的验证,用来实施评估模型的好坏。
train_model_process()
函数
def train_model_process(model, train_dataloader, val_dataloader, num_epochs): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 检查设备 optimizer = torch.optim.Adam(model.parameters(), lr=0.01) # 使用 Adam 优化器,学习率为 0.01criterion = nn.CrossEntropyLoss() # 交叉熵损失函数 model = model.to(device) # 将模型移动到设备上 best_model_wts = copy.deepcopy(model.state_dict()) # 复制当前模型参数 best_acc = 0.0 train_loss_all = [] val_loss_all = [] train_acc_all = [] val_acc_all = [] since = time.time() for epoch in range(num_epochs): print("Epoch {}/{}".format(epoch, num_epochs - 1)) print("-" * 10) # 初始化参数 train_loss = 0.0 train_corrects = 0 val_loss = 0.0 val_corrects = 0 train_num = 0 val_num = 0 # 训练阶段 model.train() for step, (b_x, b_y) in enumerate(train_dataloader): b_x = b_x.to(device) b_y = b_y.to(device) output = model(b_x) pre_lab = torch.argmax(output, dim=1) loss = criterion(output, b_y) optimizer.zero_grad() loss.backward() optimizer.step() train_loss += loss.item() * b_x.size(0) train_corrects += torch.sum(pre_lab == b_y.data) train_num += b_x.size(0) # 验证阶段 model.eval() # 设置模型为评估模式 with torch.no_grad(): # 在验证时不计算梯度 for step, (b_x, b_y) in enumerate(val_dataloader): b_x = b_x.to(device) b_y = b_y.to(device) output = model(b_x) pre_lab = torch.argmax(output, dim=1) loss = criterion(output, b_y) val_loss += loss.item() * b_x.size(0) val_corrects += torch.sum(pre_lab == b_y.data) val_num += b_x.size(0) # 计算并保存每一轮的损失数据和准确率 train_loss_all.append(train_loss / train_num) train_acc_all.append(train_corrects.double().item() / train_num) if val_num > 0: # 确保 val_num 不为零 val_loss_all.append(val_loss / val_num) val_acc_all.append(val_corrects.double().item() / val_num) else: val_loss_all.append(float('inf')) # 或者选择其他合适的方式处理 val_acc_all.append(0) print('{} Train Loss: {:.4f} Train Acc: {:.4f}'.format(epoch, train_loss_all[-1], train_acc_all[-1])) print('{} Val Loss: {:.4f} Val Acc: {:.4f}'.format(epoch, val_loss_all[-1], val_acc_all[-1])) # 寻找最高准确度的参数 if val_acc_all[-1] > best_acc: best_acc = val_acc_all[-1] best_model_wts = copy.deepcopy(model.state_dict()) time_use = time.time() - since print("训练耗费时间:{:.0f}m{:.0f}s".format(time_use // 60, time_use % 60)) # 加载最高准确率下的模型参数 model.load_state_dict(best_model_wts) torch.save(best_model_wts, 'D:/code/Python/pytorch-le-net-5/module/best_model.pth') train_process = pd.DataFrame(data={"epoch": range(num_epochs),"train_loss_all": train_loss_all,"val_loss_all": val_loss_all,"train_acc_all": train_acc_all,"val_acc_all": val_acc_all}) return train_process
这里代码较为复杂。
函数共有4个输入,分别为模型、80%的训练集图片、20%用作检验的训练集图片以及训练轮回次数。
在这个函数的一开始,笔者规定好了使用哪种设备进行计算(有GPU使用GPU,无GPU则使用CPU代替)、设置了优化器和学习率、交叉熵损失函数、将模型移动到设备上。之后通过循环进行模型的训练,并计算损失函数进行前向传播,最后通过20%的训练集验证并保存了每个模型的准确率,用于后续35个轮回模型的筛选。
值得一提的是,笔者在每一次训练过程中,随机使20%的神经元失活,用于缓解过拟合现象的发生。经过测试发现,该做法可以有效缓解过拟合现象。
matplot_acc_loss()
函数
def matplot_acc_loss(train_process): plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(train_process["epoch"], train_process.train_loss_all, 'ro-', label= "train loss") plt.plot(train_process["epoch"], train_process.val_loss_all, 'bs-', label= "val loss") plt.legend() plt.xlabel("epoch") plt.ylabel("loss") plt.subplot(1, 2, 2) plt.plot(train_process["epoch"], train_process.train_acc_all, 'ro-', label="train loss") plt.plot(train_process["epoch"], train_process.val_acc_all, 'bs-', label="val loss") plt.xlabel("epoch") plt.ylabel("acc") plt.legend() plt.show()
用于计算损失函数的一个函数。
model_test.py文件
这个文件实现了模型的测试。
test_data_process()
函数
def test_data_process(): # 训练——验证下载、处理函数 test_data = FashionMNIST(root='D:/Train/data', train=False, transform=transforms.Compose([transforms.Resize(size=28), transforms.ToTensor()]), download=True) # 数据打包 test_dataloader = Data.DataLoader(dataset=test_data, batch_size=1, shuffle=True, num_workers=2) return test_dataloader
用于加载测试集的函数,功能和train_val_data_process()
函数类似。
train_model_process
函数
def test_model_process(model, test_dataloader): # 检测设备 device = "cuda" if torch.cuda.is_available() else 'cpu' # 模型放到设备中 model = model.to(device) # 初始化参数 test_corrects = 0.0 test_num = 0 # 只进行前向传播,不计算梯度,从而节省内存,加快运行速度 with torch.no_grad(): for test_data_x, test_data_y in test_dataloader: # 数据放到设备中 test_data_x = test_data_x.to(device) # 标签放到设备中 test_data_y = test_data_y.to(device) # 设置模型为评估模式 model.eval() # 前向传播过程,输入为测试数据集,输出为对每个样本的预测值 output = model(test_data_x) # 查找每一行最大值对应的行标 pre_lab = torch.argmax(output, dim=1) # 若预测正确,则准确度test_corrects加1 test_corrects += torch.sum(pre_lab == test_data_y.data) # 将所有的测试样本累加 test_num += test_data_x.size(0) # 计算准确率 test_acc = test_corrects.double().item() / test_num print("准确率:", test_acc)
用于测试最终训练模型的函数,内容和train_model_process()
函数相似,但值得一提的是,测试过程并没有前向传播步骤,因此没有相应函数。
- 模型预测结果处理
在处理模型的输出中,为了确保模型代码的普适性模型返回的为[0,9]的整数而非直接的预测结果,因此需要使用一个列表使得模型可以预测结果
classes = ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat', 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot'] with torch.no_grad(): for b_x, b_y in test_dataloader: b_x = b_x.to(device) b_y = b_y.to(device) # 设置模型为验证模型 model.eval() output = model(b_x) pre_lab = torch.argmax(output, dim=1) result = pre_lab.item() label = b_y.item() print("预测值为:", classes[result], "-------", "真实值:", classes[label])
这段代码让列表的序号与结果数字一一对应,解决了上面的问题。
C++部分
笔者将C++部分所有的代码放在了Keras.cpp
中,用来致敬Python的Keras框架。
在这个部分,我使用C++实现模型的底层核心操作函数,包括但不限于图片处理卷积、池化及全连接。
该部分仅为笔者个人尝试,因此代码也仅作简单说明。
Keras.cpp
- 卷积初始化及卷积操作
/*
卷积层初始化函数*/
CovLayer initCovLayer(int inputWidth, int inputHeight, int mapSize, int inChannels, int outChannels)
{CovLayer covL;covL.inputHeight = inputHeight;covL.inputWidth = inputWidth;covL.mapSize = mapSize;covL.inChannels = inChannels;covL.outChannels = outChannels;covL.isFullConnect = true; // 默认为全连接// 权重空间的初始化,先行再列调用,[r][c]srand((unsigned)time(NULL)); //设置随机数种子for (int i = 0; i < inChannels; i++) //输入通道数{vector<Mat> tmp;for (int j = 0; j < outChannels; j++) //输出通道数{Mat tmpmat(mapSize, mapSize, CV_32FC1); //初始化一个mapSize*mapSize的二维矩阵for (int r = 0; r < mapSize; r++) //卷积核的高{for (int c = 0; c < mapSize; c++) //卷积核的宽{//使用随机数初始化卷积核float randnum = (((float)rand() / (float)RAND_MAX) - 0.5) * 2; //生成-1~1的随机数tmpmat.ptr<float>(r)[c] = randnum * sqrt(6.0 / (mapSize * mapSize * (inChannels + outChannels)));}}tmp.push_back(tmpmat.clone());}covL.mapData.push_back(tmp);}covL.basicData = Mat::zeros(1, outChannels, CV_32FC1); //初始化卷积层偏置的内存int outW = inputWidth - mapSize + 1; //valid模式下卷积层输出的宽int outH = inputHeight - mapSize + 1; //valid模式下卷积层输出的高Mat tmpmat2 = Mat::zeros(outH, outW, CV_32FC1);for (int i = 0; i < outChannels; i++){covL.d.push_back(tmpmat2.clone()); //初始化局部梯度covL.v.push_back(tmpmat2.clone()); //初始化输入激活函数之前的值covL.y.push_back(tmpmat2.clone()); //初始化输入激活函数之后的值}return covL; //返回初始化之后的卷积层结构体
}/*
卷积操作
*/
Mat correlation(Mat map, Mat inputData, int type)
{const int map_row = map.rows;const int map_col = map.cols;const int map_row_2 = map.rows / 2;const int map_col_2 = map.cols / 2;const int in_row = inputData.rows;const int in_col = inputData.cols;//先按full模式扩充图像边缘Mat exInputData;copyMakeBorder(inputData, exInputData, map_row_2, map_row_2, map_col_2, map_col_2, BORDER_CONSTANT, 0);Mat OutputData;filter2D(exInputData, OutputData, exInputData.depth(), map);if (type == full) //full模式{return OutputData;}else if (type == valid) //valid模式{int out_row = in_row - (map_row - 1);int out_col = in_col - (map_col - 1);Mat outtmp;OutputData(Rect(2 * map_col_2, 2 * map_row_2, out_col, out_row)).copyTo(outtmp);return outtmp;}else //same模式{Mat outtmp;OutputData(Rect(map_col_2, map_row_2, in_col, in_row)).copyTo(outtmp);return outtmp;}}
这一段包括卷积操作初始化及卷积操作执行。
- 池化初始化及池化操作
/*
池化层初始化函数
*/
PoolLayer initPoolLayer(int inputWidth, int inputHeight, int mapSize, int inChannels, int outChannels, int poolType)
{PoolLayer poolL;poolL.inputHeight = inputHeight; //输入高度poolL.inputWidth = inputWidth; //输入宽度poolL.mapSize = mapSize; //卷积核尺寸,池化层相当于做一个特殊的卷积操作poolL.inChannels = inChannels; //输入通道poolL.outChannels = outChannels; //输出通道poolL.poolType = poolType; //最大值模式1/平均值模式0poolL.basicData = Mat::zeros(1, outChannels, CV_32FC1); //池化层无偏置,无激活,这里只是预留偏置内存int outW = inputWidth / mapSize; //池化层的卷积核为2*2int outH = inputHeight / mapSize;Mat tmpmat = Mat::zeros(outH, outW, CV_32FC1);Mat tmpmat1 = Mat::zeros(outH, outW, CV_32SC1);for (int i = 0; i < outChannels; i++){poolL.d.push_back(tmpmat.clone()); //局域梯度poolL.y.push_back(tmpmat.clone()); //采样函数后神经元输出,无激活函数poolL.max_position.push_back(tmpmat1.clone()); //最大值模式下最大值在原矩阵中的位置}return poolL;
}/*
池化——均值池化
*/
void avgPooling(Mat input, Mat& output, int mapSize)
{const int outputW = input.cols / mapSize; //输出宽=输入宽/核宽const int outputH = input.rows / mapSize; //输出高=输入高/核高float len = (float)(mapSize * mapSize);int i, j, m, n;for (i = 0; i < outputH; i++){for (j = 0; j < outputW; j++){float sum = 0.0;for (m = i * mapSize; m < i * mapSize + mapSize; m++) //取卷积核大小的窗口求和平均{for (n = j * mapSize; n < j * mapSize + mapSize; n++){sum += input.ptr<float>(m)[n];}}output.ptr<float>(i)[j] = sum / len;}}
}
/*
池化——最大值池化
*/
void maxPooling(Mat input, Mat& max_position, Mat& output, int mapSize)
{int outputW = input.cols / mapSize; //输出宽=输入宽/核宽int outputH = input.rows / mapSize; //输出高=输入高/核高int i, j, m, n;for (i = 0; i < outputH; i++){for (j = 0; j < outputW; j++){float max = -999999.0;int max_index = 0;for (m = i * mapSize; m < i * mapSize + mapSize; m++) //取卷积核大小的窗口的最大值{for (n = j * mapSize; n < j * mapSize + mapSize; n++){if (max < input.ptr<float>(m)[n]) //求池化窗口中的最大值,并记录最大值位置{max = input.ptr<float>(m)[n];max_index = m * input.cols + n;}}}output.ptr<float>(i)[j] = max; //求得最大值作为池化输出max_position.ptr<int>(i)[j] = max_index; //记录最大值在原矩阵中的位置,用于反向传播}}
}
这一段包括卷积操作初始化及卷积操作执行。
值得一提的是,我同时写了最大值池化和平均池化,可以随意切换。
- 前向传播
/*
输出层前向传播
*/
void nnff(Mat input, Mat wdata, Mat& output)
{for (int i = 0; i < output.cols; i++) //分别计算多个向量相乘的乘积output.ptr<float>(0)[i] = vecMulti(input, wdata.ptr<float>(i)); //由于输入激活函数之前就有加上偏置的操作,所以此处不再加偏置
}void out_layer_ff(vector<Mat> inputData, OutLayer& O)
{Mat OinData(1, O.inputNum, CV_32FC1); //输入192通道float* OinData_p = OinData.ptr<float>(0);int outsize_r = inputData[0].rows;int outsize_c = inputData[0].cols;int last_output_len = inputData.size();for (int i = 0; i < last_output_len; i++) //上一层S4输出12通道的4*4矩阵{for (int r = 0; r < outsize_r; r++){for (int c = 0; c < outsize_c; c++){//将12通道4*4矩阵展开成长度为192的一维向量OinData_p[i * outsize_r * outsize_c + r * outsize_c + c] = inputData[i].ptr<float>(r)[c];}}}//192*10个权重nnff(OinData, O.wData, O.v); //10通道输出,1个通道的输出等于192个输入分别与192个权重相乘的和:∑in[i]*w[i], 0≤i<192//Affine层的输出经过Softmax函数,转换成0~1的输出结果softmax(O);
}/*
CNN的前向传播
*/
void cnnff(CNN& cnn, Mat inputData)
{//C1//5*5卷积核//输入28*28矩阵//输出(28-25+1)*(28-25+1) = 24*24矩阵vector<Mat> input_tmp;input_tmp.push_back(inputData);cov_layer_ff(input_tmp, valid, cnn.C1);//S2//24*24-->12*12pool_layer_ff(cnn.C1.y, MaxPool, cnn.S2);//C3//12*12-->8*8cov_layer_ff(cnn.S2.y, valid, cnn.C3);//S4//8*8-->4*4pool_layer_ff(cnn.C3.y, MaxPool, cnn.S4);//O5//12*4*4-->192-->1*10out_layer_ff(cnn.S4.y, cnn.O5);
}
- 反向传播
//****************************************************************************************************************************************************************************//
//反向传播/*
Softmax-->Affine
*/
void softmax_bp(Mat outputData, Mat& e, OutLayer& O)
{for (int i = 0; i < O.outputNum; i++)e.ptr<float>(0)[i] = O.y.ptr<float>(0)[i] - outputData.ptr<float>(0)[i]; //计算Y-t//将Y-t保存到O5层的局部梯度中 for (int i = 0; i < O.outputNum; i++)O.d.ptr<float>(0)[i] = e.ptr<float>(0)[i];// *sigma_derivation(O.y.ptr<float>(0)[i]);
}/*
Affine-->S4
*/
void full2pool_bp(OutLayer O, PoolLayer& S)
{int outSize_r = S.inputHeight / S.mapSize;int outSize_c = S.inputWidth / S.mapSize;for (int i = 0; i < S.outChannels; i++) //输出12张4*4图像{for (int r = 0; r < outSize_r; r++){for (int c = 0; c < outSize_c; c++){int wInt = i * outSize_c * outSize_r + r * outSize_c + c; //i*outSize.c*outSize.r为图像索引,r*outSize.c+c为每张图像中的像素索引for (int j = 0; j < O.outputNum; j++) //O5输出层的输出个数{//把192个偏导数重组成12个4*4的二维矩阵,作为S4层的局部梯度S.d[i].ptr<float>(r)[c] = S.d[i].ptr<float>(r)[c] + O.d.ptr<float>(0)[j] * O.wData.ptr<float>(j)[wInt]; //d_S4 = ∑d_O5*W}}}}
}/*
* S4-->C3
矩阵上采样,upc及upr是池化窗口的列、行
如果是最大值池化模式,则把局域梯度放到池化前最大值的位置,比如池化窗口2*2,池化前最大值的位置分别为左上、右上、左下、右下,则上采样后为:
5 9 5 0 0 9--> 0 0 0 0
3 6 0 0 0 03 0 0 6
如果是均值池化模式,则把局域梯度除以池化窗口的尺寸2*2=4:
5 9 1.25 1.25 2.25 2.25--> 1.25 1.25 2.25 2.25
3 6 0.75 0.75 1.5 1.50.75 0.75 1.5 1.5
*/
Mat UpSample(Mat mat, int upc, int upr) //均值池化层的向上采样
{//int i, j, m, n;int c = mat.cols;int r = mat.rows;Mat res(r * upr, c * upc, CV_32FC1);float pooling_size = 1.0 / (upc * upr);for (int j = 0; j < r * upr; j += upr){for (int i = 0; i < c * upc; i += upc) // 宽的扩充{for (int m = 0; m < upc; m++){//res[j][i + m] = mat[j / upr][i / upc] * pooling_size;res.ptr<float>(j)[i + m] = mat.ptr<float>(j / upr)[i / upc] * pooling_size;}}for (int n = 1; n < upr; n++) // 高的扩充{for (int i = 0; i < c * upc; i++){//res[j + n][i] = res[j][i];res.ptr<float>(j + n)[i] = res.ptr<float>(j)[i];}}}return res;
}//最大值池化层的向上采样
Mat maxUpSample(Mat mat, Mat max_position, int upc, int upr)
{int c = mat.cols;int r = mat.rows;int outsize_r = r * upr;int outsize_c = c * upc;Mat res = Mat::zeros(outsize_r, outsize_c, CV_32FC1);for (int j = 0; j < r; j++){for (int i = 0; i < c; i++){int index_r = max_position.ptr<int>(j)[i] / outsize_c; //计算最大值的索引int index_c = max_position.ptr<int>(j)[i] % outsize_c;res.ptr<float>(index_r)[index_c] = mat.ptr<float>(j)[i];}}return res;
}void pool2cov_bp(PoolLayer S, CovLayer& C)
{for (int i = 0; i < C.outChannels; i++) //12通道{Mat C3e;if (S.poolType == AvePool) //均值C3e = UpSample(S.d[i], S.mapSize, S.mapSize); //向上采样,把S4层的局域梯度由4*4扩充为8*8else if (S.poolType == MaxPool) //最大值C3e = maxUpSample(S.d[i], S.max_position[i], S.mapSize, S.mapSize);for (int r = 0; r < S.inputHeight; r++) //8*8{for (int c = 0; c < S.inputWidth; c++){C.d[i].ptr<float>(r)[c] = C3e.ptr<float>(r)[c] * sigma_derivation(C.y[i].ptr<float>(r)[c]);}}}
}/*
C3-->S2
*/
Mat cov(Mat map, Mat inputData, int type)
{Mat flipmap;flip(map, flipmap, -1); //卷积核先顺时针旋转180度Mat res = correlation(flipmap, inputData, type); //然后再进行卷积return res;
}void cov2pool_bp(CovLayer C, int cov_type, PoolLayer& S)
{for (int i = 0; i < S.outChannels; i++) //S2有6通道{for (int j = 0; j < S.inChannels; j++) //C3有12通道{//得到12*12矩阵:full模式下为(inSize+mapSize-1)*(inSize+mapSize-1)Mat corr = cov(C.mapData[i][j], C.d[j], cov_type);S.d[i] = S.d[i] + corr; //矩阵累加:cnn->S2->d[i] = cnn->S2->d[i] + corr,得到6个12*12局域梯度}}
}/*
反向传播整体实现
*/
void cnnbp(CNN& cnn, Mat outputData)
{softmax_bp(outputData, cnn.e, cnn.O5);full2pool_bp(cnn.O5, cnn.S4);pool2cov_bp(cnn.S4, cnn.C3);cov2pool_bp(cnn.C3, full, cnn.S2);pool2cov_bp(cnn.S2, cnn.C1);
}
- 部分激活函数的代码实现
//*****************************************************************************************************************************************************//
// 激活函数
/*
Sigmoid函数
*/
float sigmoid(float x)
{float result;result = 1 / (1 + exp(-1 * x));return result;
}
/*
relu函数
*/
float activation_Sigma(float input, float bas)
{float temp = input + bas;return (temp > 0 ? temp : 0);
}
/*
Softmax函数
*/
void softmax(OutLayer& O)
{float sum = 0.0;float* p_y = O.y.ptr<float>(0);float* p_v = O.v.ptr<float>(0);float* p_b = O.basicData.ptr<float>(0);for (int i = 0; i < O.outputNum; i++){float Yi = exp(p_v[i] + p_b[i]);sum += Yi;p_y[i] = Yi;}for (int i = 0; i < O.outputNum; i++){p_y[i] = p_y[i] / sum;}
}
/*
Sigma函数求导
*/
float sigma_derivation(float y)
{ // Logic激活函数的自变量微分#ifdef RELU_USEreturn (y > 0.0 ? 1.0 : 0.0);//return ((y<=0.0) ? (1.0/A1) : (y<6.0?1.0:0.0));/*if(y > 0.0 && y < 6.0)return 1.0;elsereturn 0.0;*//*if(y >= 0.0)return 1.0;elsereturn (1.0/A1);*//*if (y>0.0 && y<6.0)return 1.0;else if (y >= 6.0 || y <= -6.0)return 0.0;elsereturn (1.0 / A1);*/#elsereturn y * (1 - y); // 这里y是指经过激活函数的输出值,而不是自变量
#endif
}
我实现了Sigmoid函数
、relu函数
、Softmax函数
、Sigma函数求导
硬件移植部分
硬件部分,根据ChatGPT推荐,准备使用STM32H7系列芯片,将Pytorch训练得到的权重放入单片机中来实现识别功能
- 模型量化
import torch.quantization as quant# 假设 model 是您的 LeNet-5 模型
model = model.to('cpu')
model.eval()# 量化模型
quantized_model = quant.quantize_dynamic(model, {torch.nn.Linear}, dtype=torch.qint8)
该段代码用于得到训练的神经网络的权重。
- 单片机代码
#include "weights.h" // 包含预训练模型的权重// 定义卷积运算
void conv2d(int8_t input[28][28], int8_t kernel[5][5], int8_t output[24][24]) {// 遍历输出特征图for (int i = 0; i < 24; ++i) {for (int j = 0; j < 24; ++j) {int32_t sum = 0;for (int ki = 0; ki < 5; ++ki) {for (int kj = 0; kj < 5; ++kj) {sum += input[i + ki][j + kj] * kernel[ki][kj];}}output[i][j] = activation_function(sum); // 激活函数可以选择 ReLU}}
}// 池化操作
void maxpool2d(int8_t input[24][24], int8_t output[12][12]) {for (int i = 0; i < 12; ++i) {for (int j = 0; j < 12; ++j) {output[i][j] = max(input[i*2][j*2], input[i*2+1][j*2], input[i*2][j*2+1], input[i*2+1][j*2+1]);}}
}// 全连接层
void fully_connected(int8_t input[120], int8_t weights[84][120], int8_t output[84]) {for (int i = 0; i < 84; ++i) {int32_t sum = 0;for (int j = 0; j < 120; ++j) {sum += input[j] * weights[i][j];}output[i] = activation_function(sum);}
}// 推理函数
int predict(int8_t image[28][28]) {int8_t conv1_output[24][24];int8_t pool1_output[12][12];int8_t fc1_output[84];conv2d(image, conv1_weights, conv1_output);maxpool2d(conv1_output, pool1_output);fully_connected(pool1_output, fc1_weights, fc1_output);// 根据最后的输出决定预测类别return argmax(fc1_output);
}
这些函数由ChatGPT编写,用于实现卷积、池化和全连接层的前向推理。
实验详情
代码运行
训练模型,得到轮回数与准确率的关系,并得到了训练时准确率最高的模型权重。
将权重导入模型,用测试集测试,最后得到最好的模型准确率为0.8793,达到预期效果
实验心得
准确率影响因子
经过实验,发现一共有如下几个因素会影响到最后的准确率:
- 卷积核数量
卷积核数量决定了提取的特征维度。在一定范围内,卷积核数量越多,模型识别正确率越高 - 池化方式
经实验,发现最大值池化比平均池化更能保留图像的特征 - 训练次数与轮回次数
在一定的范围内,训练次数与轮回次数的增加可以显著提高最后识别的准确率。但是,随着训练次数的增加,准确率最终会稳定下来或以极缓慢的速率增加;轮回次数的增加反而在达到一定数量后(30之后)会导致准确率的小幅度下降。经猜测,是发生了模型的过拟合问题。 - 学习率
学习率过高会导致训练过程反向传播的大幅度震荡,过低会导致梯度下降过慢,学习率适中的情况下,模型的准确率显著提高 - 激活函数
适当的激活函数也可以提高模型的准确率。例如前面提到的Sigmoid函数与ReLU函数,经测试,使用ReLU函数可以显著提升模型的准确率。
过拟合现象的处理
过拟合(Overfitting) 是指在统计学和机器学习中,模型在训练数据上表现非常良好,但在新的、未见过的数据上表现不佳的现象。这是因为在过拟合的情况下,模型学到了训练数据中的噪声和细节,而没有捕捉到数据的基本规律,导致其泛化能力差。
在本实验中,笔者也遇到了过拟合的情况,主要采用了以下几种方法:
- 神经元随机失活
在本次实验中,笔者在每次训练前,随机使得20%的神经元失活,来缓解过拟合现象。 - 正则化
- 降低轮回次数
前面提到,轮回次数过大会导致过拟合。笔者适量降低了轮回次数,缓解了过拟合现象。
- 个人创新
此外,笔者构想了一种全新的思路:将每次轮回的正确率进行排名,但是权重的选取是在正确率排名前20%的模型中随机选取。这个想法看上去有些荒诞,但是有效提升了4%的准确率。
本思想来源于作者的个人观点:排名第一的模型和成绩第一的学生一样,对于考试很擅长,但是对考试的擅长往往使得其失去了部分泛化的能力。相反,成绩靠前但非第一第二名的往往具有更强的泛化能力,不局限于考试。因此,笔者采用了该方法。
关于人工智能
在这一次分别使用C++和Pytorch两个框架实现卷积神经网络后,我对两种编程语言有了更深的理解
- 关于Pytorch
Pytorch(也可以理解为Python)对人工智能相关代码进行了封装,使得人们仅需使用几行极其简单的代码即可完成神经网络的搭建,大大减轻了编写神经网络的压力,为人工智能的进一步挖掘提供了可能。 - 关于C++
相对于上手就能用的Pytorch,C/C++更偏向于底层,不可否认,这大大增加了工作量于数据的处理(这个非常麻烦)与底层算法的实现(坦白说,在这次的工程中,C++部分占用了我大部分时间,而且作为一个项目,其实并没有达到预期效果),但这意味着你可以以更为底层的方式修改整个神经网络架构。Pytorch封装了许多神经网络的细节,但是如果我们要完成整个神经网络的创新性构建,那么C++是你将来一定要接触的实现方式。当你去除简单易用的框架,转而进入神经网络的底层原理实现,才算真正进入人工智能的大门吧。 - 关于开源
在我们想要实现一个项目的时候,如果是非核心的部分,不妨在网上寻找寻找之前的人们留下的里程碑,借鉴借鉴他们的代码与探索历程,这将大大缩减我们工程的完成时间,让更多的精力放在更有价值的事情上。而这就是笔者认为的开源精神的初衷。
总结
本次实验实现了LeNet-5卷积神经网络,并进行了多个改进以提高模型的性能。通过使用Pytorch框架,我成功实现了模型的搭建与训练,并取得了约87%的准确率,表明网络能够较好地进行图像分类任务。在此基础上,我对LeNet-5进行了创新性的调整,包括将激活函数从Sigmoid更换为ReLU、引入批标准化层以及添加Dropout进行正则化。这些改进有效提升了模型的训练速度、收敛性及泛化能力。
此外,为了深入理解神经网络的底层原理,我还尝试使用C++实现了卷积、池化和全连接等基本操作,尽管最终结果不如Pytorch实现,但这一过程让我更加清晰地掌握了卷积神经网络的核心机制。最后,本实验还尝试了模型在嵌入式硬件上的移植,虽然这一部分尚在准备阶段,但为未来的硬件优化提供了有力的支持。
总体来说,实验验证了LeNet-5模型在图像分类任务中的有效性,并通过适当的改进提升了其性能。此外,通过深入了解并实践底层实现,我对深度学习模型的原理和优化有了更全面的认识。
参考文档
- 卷积神经网络原理及其C++/Opencv实现(3)-CSDN博客
- 卷积神经网络经典回顾之LeNet-5 - 知乎 (zhihu.com)![]