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