首页 > 其他分享 >LeNet-5卷积神经网络的实现与改进-实验报告

LeNet-5卷积神经网络的实现与改进-实验报告

时间:2024-11-10 14:30:01浏览次数:1  
标签:Mat 卷积 mapSize ++ int train LeNet 实验报告

摘要

在本次实验中,我实现了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基本结构)
  • 模块功能: 下面是对各个层的详细介绍:

    1. 输入层(Input layer)
      输入层接收大小为 32×32 的手写数字图像,其中包括灰度值(0-255)。在实际应用中,我们通常会对输入图像进行预处理,以加快训练速度和提高模型的准确性。

    2. 卷积层C1(Convolutional layer C1)
      卷积层C1包括6个卷积核,每个卷积核的大小为 5×5 ,步长为1,填充为0。因此,每个卷积核会产生一个大小为 28×28 的特征图(输出通道数为6)。

    3. 池化层S2(Subsampling layer S2)
      在标准LeNet-5网络中,采样层S2采用平均池化(averange-pooling)操作,每个窗口的大小为 2×2 ,步长为2。因此,每个池化操作会从4个相邻的特征图中选择最大值,产生一个大小为 14×14 的特征图(输出通道数为6)。这样可以减少特征图的大小,提高计算效率,并且对于轻微的位置变化可以保持一定的不变性。

    4. 卷积层C3(Convolutional layer C3)
      卷积层C3包括16个卷积核,每个卷积核的大小为 5×5 ,步长为1,填充为0。因此,每个卷积核会产生一个大小为 10×10 的特征图(输出通道数为16)。

    5. 池化层S4(Subsampling layer S4)
      池化层S4也采用平均池化,每个窗口的大小为 2×2 ,步长为2。因此,每个池化操作会从4个相邻的特征图中选择最大值,产生一个大小为 5×5 的特征图(输出通道数为16)。

    6. 全连接层C5(Fully connected layer C5)
      C5将每个大小为 5×5 的特征图拉成一个长度为400的向量,并通过一个带有120个神经元的全连接层进行连接。120是由LeNet-5的设计者根据实验得到的最佳值。

    7. 全连接层F6(Fully connected layer F6)
      全连接层F6将120个神经元连接到84个神经元。

    8. 输出层(Output layer)
      输出层由10个神经元组成,每个神经元对应0-9中的一个数字,并输出最终的分类结果。在训练过程中,使用交叉熵损失函数计算输出层的误差,并通过反向传播算法更新卷积核和全连接层的权重参数。

    图片进入模型的流程为:
    灰度处理/大小处理 -> C1卷积层增大特征维度 -> S2池化层降低数据维度 -> C3卷积层再次增大特征维度 -> S4池化层降低数据维度 -> C5 F6与神经元连接并实现分类 -> O8输出

    但是,在实际应用中,通常会对LeNet-5进行一些改进,如增加网络深度、增加卷积核数量、添加正则化等方法,来进一步提高模型的准确性和泛化能力。

  • 各层实现原理
    1.卷积层
    在卷积神经网络中,卷积操作是指将一个可移动的小窗口(称为数据窗口,如下图蓝色矩形)与图像进行逐元素相乘然后相加的操作。这个小窗口其实是一组固定的权重,它可以被看作是一个特定的滤波器(filter)或卷积核。这个操作的名称“卷积”,源自于这种元素级相乘和求和的过程。这一操作是卷积神经网络名字的来源。该小窗口以特定的步长滚动,用其中的每一个元素(权重)与映射的图像元素进行相乘后相加,完成了图像特征的提取。

    2.池化层
    池化层用于降低特征图的空间分辨率,并增强模型对输入图像的平移不变性。常用的池化方式包括最大池化和平均池化。最大池化的操作是在一个滑动窗口中取最大值作为输出,平均池化的操作是在一个滑动窗口中取平均值作为输出。
    (最大池化)

    (平均池化)
    3.全连接层
    全连接层通常用于将卷积层和池化层提取的特征进行分类或回归。它的输入是一维向量,其输出的维度与任务的分类数或回归值的维度相同。

  • 改进方法: 在本次实验中,笔者对LeNet-5网络进行了如下进一步的改进:

    1. 笔者在本次的项目实现中,为了提高LeNet-5的效果,将C1卷积层卷积核的数量改为了8,、C31卷积层卷积核的数量改为了20。
    2. 池化层S2、S4采用最大池化(max-pooling)操作代替平均池化(averange-pooling),以保留更多显著特征。
    3. LeNet-5网络中,使用的激活函数为Sigmoid函数。

    这种函数虽然处处可导,但在两边容易出现梯度较小(即梯度消失)的问题。为了解决这个问题笔者采用了ReLU函数。

    这个函数在(0,+∞)上的导数为1,可以快速实现梯度下降,减少训练时间(但是,这个函数在小于0的地方容易出现神经元死亡问题,在本次实验中没有出现)
    4. 作者在模型训练时,适当提升了训练次数,增加了一部分准确率

  • 实现方法: 下面是我的三种实现方法:

    • Pytorch实现:基于Pytorch框架搭建模型结构,定义损失函数和优化器,进行模型训练和测试,最终识别准确率达87%。

    • C++实现:在C++中实现卷积、池化和全连接操作,尝试构建类似的LeNet-5网络架构,但由于调试时间和实现难度,最终分类效果不理想。

    • 硬件移植:通过参考ChatGPT建议选择单片机型号,准备在嵌入式平台上进行模型部署。

代码描述:

考虑到访问的便捷性,笔者将代码全部上传到gitee,读者可以直接访问我的仓库来获取全部代码。

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,将参数填入以执行卷积操作。其它每一层类似,根据要执行的操作选择对应的函数,计算相应的参数并填入即可完成对应操作。
值得一提的是,在卷积神经网络中,卷积操作输出层计算公式为:

\[OH=\frac{H+2P-FH}{S}+1 \]

\[OW=\frac{W+2P-FW}{S}+1 \]

  • 前向传播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.01
	criterion = 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/平均值模式0

    poolL.basicData = Mat::zeros(1, outChannels, CV_32FC1);    //池化层无偏置,无激活,这里只是预留偏置内存

    int outW = inputWidth / mapSize;   //池化层的卷积核为2*2
    int 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*12
    pool_layer_ff(cnn.C1.y, MaxPool, cnn.S2);

    //C3
    //12*12-->8*8
    cov_layer_ff(cnn.S2.y, valid, cnn.C3);

    //S4
    //8*8-->4*4
    pool_layer_ff(cnn.C3.y, MaxPool, cnn.S4);

    //O5
    //12*4*4-->192-->1*10
    out_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 0
                3 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.5
           0.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*8
        else 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_USE
    return (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;
    else
    return 0.0;*/
    /*if(y >= 0.0)
    return 1.0;
    else
    return (1.0/A1);*/

    /*if (y>0.0 && y<6.0)
        return 1.0;
    else if (y >= 6.0 || y <= -6.0)
        return 0.0;
    else
        return (1.0 / A1);*/

#else
    return 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模型在图像分类任务中的有效性,并通过适当的改进提升了其性能。此外,通过深入了解并实践底层实现,我对深度学习模型的原理和优化有了更全面的认识。

参考文档

  1. 卷积神经网络原理及其C++/Opencv实现(3)-CSDN博客
  2. 卷积神经网络经典回顾之LeNet-5 - 知乎 (zhihu.com)![]

标签:Mat,卷积,mapSize,++,int,train,LeNet,实验报告
From: https://www.cnblogs.com/xiaoxing-chen/p/18537910

相关文章

  • 从0开始深度学习(27)——卷积神经网络(LeNet)
    1LeNet神经网络LeNet是最早的卷积神经网络之一,由YannLeCun等人在1990年代提出,并以其名字命名。最初,LeNet被设计用于手写数字识别,最著名的应用是在美国的邮政系统中识别手写邮政编码。LeNet架构的成功证明了卷积神经网络在解决实际问题中的有效性,为后续更复杂、更强大的CN......
  • 20222415 2024-2025-1《网络与系统攻防技术》实验四实验报告
    1.实验内容1.1恶意代码文件类型标识、脱壳与字符串提取1.2使用IDAPro静态或动态分析crackme1.exe与crakeme2.exe,寻找特定输入,使其能够输出成功信息。1.3分析一个自制恶意代码样本rada1.4取证分析实践2.实验过程2.1恶意代码文件类型标识、脱壳与字符串提取使用fileRaDa.ex......
  • 20222411 2024-2025-1 《网络与系统攻防技术》实验四实验报告
    1.实验内容1.1实践内容一、恶意代码文件类型标识、脱壳与字符串提取对提供的rada恶意代码样本,进行文件类型识别,脱壳与字符串提取,以获得rada恶意代码的编写作者,具体操作如下:(1)使用文件格式和类型识别工具,给出rada恶意代码样本的文件格式、运行平台和加壳工具;(2)使用超级巡警脱壳......
  • 20222308 2024-2025-4 《网络与系统攻防技术》实验四实验报告
    1.实验内容本次实验主要是通过各种工具,对目标恶意代码进行文件类型的分析,通过脱壳软件将恶意代码的upx壳脱去,并对恶意代码进行字符串分析,通过逆向技术将二进制代码转换为汇编代码进行分析。了解代码中不同函数之间的调用和流程运行图。通过流程图及相关信息去推测恶意代码的运行......
  • 【CNN-GRU-Attention】基于卷积神经网络和门控循环单元网络结合注意力机制的多变量回
    ......
  • YOLO系列基础(一)卷积神经网络原理详解与基础层级结构说明
    系列文章地址YOLO系列基础(一)卷积神经网络原理详解与基础层级结构说明-CSDN博客YOLO系列基础(二)Bottleneck瓶颈层原理详解-CSDN博客目录卷积神经网络的原理及卷积核详解一、卷积神经网络的原理二、卷积层与卷积核详解卷积核的作用卷积核的设计卷积样例与代码说明:卷积核......
  • 20222305 2024-2025-1 《网络与系统攻防技术》实验四实验报告
    网络攻防实验报告姓名:田青学号:20222305实验日期:2024/11/01—2024/11/10实验名称:恶意代码分析实践指导教师:王志强1.学习内容1.指令集合:二进制执行代码,脚本,宏,指令流。2.恶意代码命名规则:前缀+名称+后缀3.BIOS->MDR->分区引导记录->操作系统(电脑启动)4.逆向工程:程序结构C......
  • 《计算机视觉技术》实验报告——6.生成并去除图像噪声
    首次完成时间:2024年 10月22日去除图像噪声一.实验内容1.根据均匀噪声的原理,设计添加均匀噪声。1)程序代码:importcv2importrandomasrdimportnumpyasnpdefavg_noise(src,noise_num):    img_noise=src.copy()    rows,cols,chn=img_nois......
  • BO-CNN-LSTM回归预测 | MATLAB实现BO-CNN-LSTM贝叶斯优化卷积神经网络-长短期记忆网络
    BO-CNN-LSTM回归预测|MATLAB实现BO-CNN-LSTM贝叶斯优化卷积神经网络-长短期记忆网络多输入单输出回归预测目录BO-CNN-LSTM回归预测|MATLAB实现BO-CNN-LSTM贝叶斯优化卷积神经网络-长短期记忆网络多输入单输出回归预测效果一览基本介绍模型搭建程序设计参考资料......
  • 20222311 2024-2025-1 《网络与系统攻防技术》实验四实验报告
    1.实验内容1.1恶意代码文件类型标识、脱壳与字符串提取对提供的rada恶意代码样本,进行文件类型识别,脱壳与字符串提取,以获得rada恶意代码的编写作者,具体操作如下:(1)使用文件格式和类型识别工具,给出rada恶意代码样本的文件格式、运行平台和加壳工具;(2)使用超级巡警脱壳机等脱壳软件,......