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

摘要

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

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

参考文档

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/830634.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【Mplus 8.7软件下载与安装教程】

1、安装包 Mplus 8.7: 链接:https://pan.quark.cn/s/128e81c51dbe 提取码:1X7B Mplus 8.3: 链接:https://pan.quark.cn/s/5ea5ff583480 提取码:Vdjt Mplus 7.4: 链接:https://pan.quark.cn/s/414ec0c8cb14 提取码:8jhm 2、安装教程 1) 双击Mplus8.7 Demo(64-bi…

Day13 备战CCF-CSP练习

202312-3Day 13 题目描述 题目分析 大模拟,用栈储存每一个多项式,最后根据导数的加法原则依次求导相加,注意取模。 C++代码 #pragma GCC optimize(3, "Ofast", "inline") #include <bits/stdc++.h> #define int long longusing namespace std; co…

OOP实验三

任务1: 源码:1 #pragma once2 3 #include <iostream>4 #include <string>5 6 using std::string;7 using std::cout;8 9 // 按钮类 10 class Button { 11 public: 12 Button(const string &text); 13 string get_label() const; 14 void click()…

鸿蒙NEXT开发案例:转盘1W

【1】引言(完整代码在最后面) 在鸿蒙NEXT系统中,开发一个有趣且实用的转盘应用不仅可以提升用户体验,还能展示鸿蒙系统的强大功能。本文将详细介绍如何使用鸿蒙NEXT系统开发一个转盘应用,涵盖从组件定义到用户交互的完整过程。 【2】环境准备 电脑系统:windows 10 开发工…

开源 - Ideal库 - 常用时间转换扩展方法(二)Qv

合集 - Ideal库 - Common库(2)1.开源 - Ideal库 - 常用时间转换扩展方法(一)11-07:悠兔机场2.开源 - Ideal库 - 常用时间转换扩展方法(二)11-09收起 书接上回,我们继续来分享一些关于时间转换的常用扩展方法。01、时间转日期时间 TimeOnly 该方式是把TimeOnly类型转为Date…

存储器的知识

以W25Q256为例子(外部Flash),结构如下:扇区的内部结构以F10系列为例子 64位代表的是8个字节的数据,一个地址最多可以存储8个字节的数据(double数据类型就是8个字节),4k,16k,64k,32k代表的是地址的总数,主存储块又可以分为很多个页,页里面也有存储的空间大小嵌入式闪存的组成

什么是git,什么是github,git和github的使用

Git实战注意:本项目是学习笔记,来自于哔哩哔哩武沛齐老师的Git实战视频, 网址:【武沛齐老师讲git,看完绝对上瘾!!!】 https://www.bilibili.com/video/BV1ne4y1E7np/?share_source=copy_web&vd_source=2c9a5d5590d3759367594e264ff079c4另外,因为这个博客是我直接…

law Intermediate walkthrough pg

靶场很简单分数只有10分跟平常做的20分的中级靶场比确实简单 我拿来放松的 算下来30分钟解决战斗 nmap 扫到80端口web界面 是个框架 搜exp https://www.exploit-db.com/exploits/52023 他的脚本可能有点问题看不到回显 我们审脚本直接看到漏洞点所在 命令执行 curl -s -d "…

streamlit run执行报错,Invalid value: File does not exist: XXX.py

streamlit run执行报错,Invalid value: File does not exist: XXX.py 在终端执行 streamlit run xxx.py 的时候报错提示 Invalid value: File does not exist: XXX.py 网上众说纷纭,但是我个人的解决方法其实非常简单 在终端中执行的时候会发现中间多了个warning翻译过来就是…

03_muduo_base3

5.6 互斥锁和条件变量的封装 类图该类是封装了互斥锁的一些基本操作,包括互斥锁的初始化、销毁、上锁、解锁等功能。但是实际上使用RAII技术又封装了一个类,那就是MutexLockGuard。这主要也是采取了类似智能指针的封装思路,让互斥锁的生命周期交给操作系统去管理,释放的时机…

B样条(BSpline,即 Basis Spline)

B 样条(BSpline)是一种在计算机图形学、计算机辅助设计、数值分析等领域广泛应用的数学曲线和曲面表示方法。以下是对 B 样条的详细定义: 一、基本概念 B 样条是基于一系列控制点(Control Points)来定义曲线或曲面的。它通过一个特定的基函数(Basis Functions)集合与这些…

实验3 类和对象

实验任务1: button.hpp#pragma once#include <iostream> #include <string>using std::string; using std::cout;// 按钮类 class Button { public:Button(const string &text);string get_label() const;void click();private:string label; };Button::Butto…