简介:面向图像分类任务的卷积神经网络实现,配套完整MATLAB代码,适合深度学习初学者、视觉方向学生与工程人员。内容围绕CNN训练流程展开,包括网络结构搭建、数据加载与预处理、前向传播与反向传播、梯度数值检查、参数更新、训练与测试、主成分分析特征提取、准确率统计与混淆矩阵打印等模块,并给出典型数据上的验证流程,可直接复现常见分类任务。压缩包共18个文件,含16个m脚本与2个mat数据文件,脚本按流程分段封装,数据文件提供示例样本,便于对比验证。资源包整体约41.8MB,结构简洁,适合按目录逐段学习。目前已有2262人学习下载,可作为课程设计、毕业设计或科研入门的工程模板,也方便在此基础上扩展网络层数或更换数据集做二次开发。
1. 基于卷积神经网络CNN的图像分类:拿到MATLAB代码后先看这三件事
我帮人排查过一次“跑不起来”的CNN图像分类代码,最后发现问题不在环境,而在对调用链没概念。这份基于卷积神经网络CNN实现图像分类的MATLAB代码,是一套完整的训练与测试工程,包含网络初始化、前向传播、反向传播、梯度校验全部环节,还自带两个.mat数据文件可以直接训练。它不是那种打包好一句话出结果的黑匣子,也不是只贴几个函数片段的教学残卷,而是能让你一行行断点调试、改参数看效果的全流程实现。适合两类人:一是刚学完CNN基本结构、想在MATLAB里看真实实现细节的人;二是做图像分类模型但不想从头写反向传播、想在一个可运行框架上验证想法的人。
2. 代码结构与训练调用链:从cnnsetup到cnnbp的18个文件怎么串起来
2.1 文件功能一览:入口、训练、网络骨架各司其职
拿到压缩包后,第一件事不是双击运行,而是先理清文件角色。这套代码一共18个文件,我习惯先在MATLAB里逐个打开看一眼函数头,再按调用关系分类。整体可以分成四组。
第一组是入口与流程控制:NewMain.m和cnn_start.m。NewMain.m是主入口,负责加载数据、初始化网络、调用训练和测试;cnn_start.m更像是前置环境脚本,处理路径、随机种子和数据划分。第二组是网络构建:cnnsetup.m负责搭层结构,卷积层、池化层、全连接层的参数都在这里声明。第三组是训练核心:cnntrain.m是训练主循环,cnnff.m做前向传播,cnnbp.m做反向传播,cnnapplygrads.m把梯度更新到权重上。第四组是工具函数:sigm.m是激活函数,flipall.m做数据翻转增强,expand.m扩展标签维度,extrc_pca.m做PCA特征提取,accuracy.m计算准确率,printConMat.m打印混淆矩阵,还有cnnnumgradcheck.m做数值梯度校验。
| 文件 | 功能 | 属于 |
|---|---|---|
| NewMain.m | 主入口,加载数据和执行训练测试 | 入口控制 |
| cnn_start.m | 环境准备与参数设置 | 入口控制 |
| cnnsetup.m | 构建CNN网络层结构 | 网络构建 |
| cnntrain.m | 训练主循环 | 训练核心 |
| cnnff.m | 前向传播计算 | 训练核心 |
| cnnbp.m | 反向传播计算梯度 | 训练核心 |
| cnnapplygrads.m | 梯度更新权重 | 训练核心 |
| cnntest.m | 测试集推理 | 训练核心 |
| TrainTest.m | 训练测试流程封装 | 流程控制 |
| cnnnumgradcheck.m | 数值梯度校验 | 调试工具 |
| accuracy.m | 准确率计算 | 评估工具 |
| printConMat.m | 混淆矩阵打印 | 评估工具 |
| sigm.m | sigmoid激活函数 | 工具函数 |
| flipall.m | 水平翻转数据增强 | 工具函数 |
| expand.m | 标签维度扩展 | 工具函数 |
| extrc_pca.m | PCA降维 | 工具函数 |
| datalab.mat | 训练数据集 | 数据 |
| datafet.mat | 测试数据集 | 数据 |
这套代码里的TrainTest.m是另一个流程入口,它和NewMain.m的区别在于封装粒度。NewMain.m面向单次完整实验,而TrainTest.m更适合批量做对比实验时反复调用,内部把训练和测试拆成了两个独立阶段。对刚开始用的人来说,先盯住NewMain.m就够了。
2.2 训练主链路:从NewMain.m到cnnapplygrads.m的调用顺序
下面用伪代码还原NewMain.m的结构,这样你能在脑子里建立一条完整调用链:
% NewMain.m 主入口结构还原 load('datalab.mat'); % 加载训练样本 load('datafet.mat'); % 加载测试样本 % 1. 初始化网络结构 cnn = cnnsetup(cnn, train_x, train_y); % 2. 执行训练 cnn = cnntrain(cnn, train_x, train_y); % 3. 在测试集上评估 [er, bad] = cnntest(cnn, test_x, test_y); % 4. 打印混淆矩阵 printConMat(cnn, test_x, test_y);这段逻辑很直白:load加载数据,cnnsetup根据输入维度初始化网络层,cnntrain内部反复调用cnnff算前向输出、cnnbp算梯度、cnnapplygrads更新参数,cnntest在测试集上算错误率,printConMat把每个类别的分类结果可视化。
train_x的每一列是一个展平后的图像向量,train_y是对应的one-hot标签矩阵;test_x、test_y同理。这些变量的行数等于特征维度,列数等于样本数。在MATLAB工作区双击变量就能看到维度信息,建议先看一眼再往下跑。
2.3 cnnsetup.m里改网络结构:卷积核尺寸、特征图数量与池化区域
cnnsetup.m是这套代码里最值得细看的文件。它用一个结构体数组逐层描述网络,每一层的类型和行为都写得很清楚。标准的LeNet风格结构是这样:
% cnnsetup.m 网络层配置还原 cnn.layers = { struct('type', 'i', 'mapsize', [28 28], 'outputmaps', 1) struct('type', 'c', 'kernelsize', 5, 'outputmaps', 6) struct('type', 's', 'scale', 2) struct('type', 'c', 'kernelsize', 5, 'outputmaps', 12) struct('type', 's', 'scale', 2) };type有三个取值:'i'是输入层,'c'是卷积层,'s'是池化层。卷积层的kernelsize是卷积核边长,outputmaps是输出特征图数量。比如第一个卷积层用5×5卷积核输出6张特征图,第二个卷积层输出12张。池化层的scale是下采样倍数,scale=2表示特征图边长缩一半,面积缩到四分之一。
改网络时,输入层mapsize必须和你的数据尺寸一致。比如你的图像是64×64,这里就要改成[64 64]。卷积核尺寸不能超过输入特征图尺寸,否则convn会直接报维度错误。另外,卷积层输出特征图尺寸会随着卷积和池化逐层缩小,最后一层全连接输入的维度必须和cnnsetup里全连接层的权重矩阵匹配,这是新手最容易改错的地方。
3. 跑到第一次出结果:数据集准备、入口脚本与完整复现步骤
3.1 datalab.mat与datafet.mat:训练集和测试集是怎么划分的
datalab.mat和datafet.mat承载了实验所需的数据。资源包里没有单独的图片文件夹,数据已经打包成.mat矩阵格式,对MATLAB用户来说反而省事,不用自己写批量读取和预处理。
加载之前,我建议先用whos看一眼数据结构:
load('datalab.mat'); whoswhos会列出这个.mat文件里所有变量、维度和类型。常见布局是train_x和train_y两个变量,train_x每一列是一张展平图像,train_y是对应的one-hot标签。datafet.mat则存放测试集。这种命名风格和DeepLearnToolbox一致,如果你之前用过那个工具箱,上手会非常快。
% 查看数据维度并确认标签格式 size(train_x) % 期望 [特征维度, 样本数] size(train_y) % 期望 [类别数, 样本数]注意train_y如果是单列数值编号,说明标签没有做one-hot编码,需要先转换。用circulant或者手写一个循环都能转,但最直接的方式是用sub2ind:
% 将单列标签转为one-hot矩阵 numClasses = max(train_y); train_y_onehot = zeros(numClasses, numel(train_y)); idx = sub2ind(size(train_y_onehot), train_y, 1:numel(train_y)); train_y_onehot(idx) = 1;这段代码根据最大编号确定类别数,把每个样本的标签位置置1,其余为0。转换后train_y_onehot的每一列就是对应样本的one-hot向量。
3.2 从零开始完整复现:配置路径到输出准确率的六个步骤
第一步,把解压文件夹放到一个纯英文路径下,然后添加路径并清理环境:
% 添加路径并清空工作区 addpath('D:/code/cnn_image_classification'); clear; close all; clc;强调英文路径是因为MATLAB在Windows下对中文路径的兼容性有时会出现奇怪问题,尤其涉及save、load或文件搜索时。第二步直接运行主入口:
NewMain;跑完后命令行会打印训练过程中的误差变化和最终测试准确率。顺利的话会看到类似Accuracy = 94.3%的输出。第三步,如果用自定义数据集,把datalab.mat和datafet.mat替换成自己的.mat文件,保持train_x、train_y、test_x、test_y四个变量名不变即可。
如果你的数据量比较大,训练会很慢,这需要在cnntrain.m里调整三个参数:
% cnntrain.m 中训练参数设置 opts.numepochs = 10; % 训练轮数 opts.batchsize = 50; % 每批样本数 opts.alpha = 1; % 学习率numepochs越大拟合越充分但耗时越长;batchsize影响梯度估计的稳定性和内存占用;alpha学习率过大容易震荡、过小收敛太慢。新手我建议先跑5轮,确认流程走通再加轮数。
3.3 flipall.m与extrc_pca.m:数据增强和降维在什么时候用
flipall.m把所有训练样本水平翻转,扩充一倍数据量。extrc_pca.m是PCA特征提取,把高维图像向量压缩到低维空间,再送入后续网络。
很多人第一次跑的时候会发现这两个函数根本没有被主流程调用。实际上,数据增强和PCA降维是可选预处理步骤,不是核心训练链路的必经环节。你可以在NewMain.m里把train_x先做flipall再训练,也可以在特征维度太高时用extrc_pca先压缩再送入网络。
% 可选预处理:先翻转增强,再PCA降维 train_x = flipall(train_x); [coeff, score, latent] = pca(train_x'); train_x_reduced = score(:, 1:100)';flipall只适用于对称性较强的分类任务。如果做的是手写数字识别,翻转6和9会互相混淆;如果做场景分类,水平翻转通常无害。PCA降维的保留维度是一个权衡:保留太少丢信息,保留太多达不到降维目的。我一般会看latent的累计贡献率,取95%附近对应的主成分数。
4. 前向传播与反向传播的实现细节:这套CNN代码的数值正确性靠什么保障
4.1 cnnff.m前向传播:卷积、池化、sigmoid的逐层计算
cnnff.m是前向传播的实现。输入是训练样本,输出是网络对每个类别的预测。核心逻辑是逐层把数据经过卷积层、激活函数、池化层,最后得到输出向量。
% cnnff.m 前向传播核心段还原 for l = 2 : numel(cnn.layers) if strcmp(cnn.layers{l}.type, 'c') % 卷积层:每个输出特征图是前层所有特征图的加权卷积累加 for j = 1 : cnn.layers{l}.outputmaps z = zeros(size(cnn.layers{l-1}.a{1}, 1) - cnn.layers{l}.kernelsize + 1, ... size(cnn.layers{l-1}.a{1}, 2) - cnn.layers{l}.kernelsize + 1); for i = 1 : cnn.layers{l-1}.outputmaps z = z + convn(cnn.layers{l-1}.a{i}, cnn.layers{l}.k{i}{j}, 'valid'); end % 加偏置并过sigmoid激活 cnn.layers{l}.a{j} = sigm(z + cnn.layers{l}.b{j}); end elseif strcmp(cnn.layers{l}.type, 's') % 池化层:按scale区域取均值并下采样 for j = 1 : cnn.layers{l-1}.outputmaps cnn.layers{l}.a{j} = mean_pool(cnn.layers{l-1}.a{j}, cnn.layers{l}.scale); end end endconvn是MATLAB的多维卷积函数,'valid'表示只保留完全卷积区域,输出尺寸比输入小kernelsize-1。mean_pool把每个scale * scale区域压缩成一个均值,实现下采样。sigm是sigmoid激活,输出范围0到1,引入非线性。
前向传播的最终输出会和真实标签做比较计算损失。这套代码的损失函数定义在cnnbp.m的起点,通常用均方误差或交叉熵。前向传播中每一层的激活值a都被缓存下来,因为反向传播计算梯度时需要复用这些值。
4.2 cnnbp.m反向传播:梯度怎么从输出层传回每个卷积核
cnnbp.m是反向传播实现,负责计算每一层每个参数的梯度。核心是链式法则:从输出层得到损失对输出的导数,逐层往输入方向传播,卷积层的导数要经过卷积核旋转,池化层的导数要经过上采样还原。
% cnnbp.m 反向传播核心逻辑还原 % 输出层误差:预测与真实标签的差 * sigmoid导数 cnn.layers{end}.d = (cnn.layers{end}.a - train_y) .* sigm_deriv(cnn.layers{end}.a); % 从倒数第二层开始逐层反向传播 for l = numel(cnn.layers) : -1 : 2 if strcmp(cnn.layers{l}.type, 'c') % 把当前层误差通过旋转后的卷积核传到上一层 cnn.layers{l-1}.d = convn(cnn.layers{l}.d, rot180(cnn.layers{l}.k{i}{j}), 'full'); % 计算该卷积核的梯度 cnn.layers{l}.dk = convn(cnn.layers{l-1}.a{i}, cnn.layers{l}.d, 'valid'); elseif strcmp(cnn.layers{l}.type, 's') % 池化层误差上采样回原始尺寸 cnn.layers{l-1}.d = upsample(cnn.layers{l}.d, cnn.layers{l}.scale); end endrot180把卷积核旋转180度,这是卷积反向传播的关键步骤。很多人手写CNN时在这里出错,把卷积核方向搞反,梯度符号就不对,训练结果会一直停在随机水平。upsample是池化的逆操作,把误差矩阵每个值复制到scale*scale的区域里。sigm_deriv是sigmoid的导数,实现为sigm(x) * (1 - sigm(x))。
cnnapplygrads.m拿到dk和db后执行更新,公式是w = w - alpha * dk,这也是学习率alpha出现的第一个位置。
4.3 cnnnumgradcheck.m梯度校验:数值法验证反向传播有没有写错
cnnnumgradcheck.m是这个资源包里最有价值的调试工具。它的作用是拿解析梯度跟数值梯度做对比,确认反向传播实现没有bug。用法是在正式训练前调用一次,只取少量样本即可:
% 在cnnsetup之后调用梯度校验 cnnnumgradcheck(cnn, train_x(:, 1:10), train_y(:, 1:10));梯度校验的原理是:对每个参数w,计算损失在w+eps和w-eps处的差值,除以2*eps得到数值梯度,然后与反向传播算出的解析梯度做比较。如果两者相对误差在1e-4以内,说明反向传播的代码是对的。
% 梯度校验核心逻辑 numGrad = (loss(w + eps) - loss(w - eps)) / (2 * eps); relativeError = abs(numGrad - analyticGrad) / max(abs(numGrad) + abs(analyticGrad), 1e-12);eps通常取1e-4或1e-5。loss(w+eps)和loss(w-eps)必须用前向传播重新计算,不能复用缓存,否则就失去校验意义了。数值校验比较耗时,取10到20个样本跑一遍就够了,不需要全量数据。
我的建议是:每次修改网络结构或损失函数之后,都先跑一遍梯度校验再全量训练。否则训练很久才发现梯度算错了,浪费的时间非常可惜。这是这套代码教会我最重要的一课。
5. 避坑与常见问题:跑这套CNN代码时我踩过的六个翻车现场
5.1 现象:运行NewMain.m报错"未定义函数或变量cnn"
原因:MATLAB没有把当前文件夹加入工作路径,或者文件夹不在搜索路径里。这个错误90%的初学者都会遇到,它不代表代码有问题,只是MATLAB找不到文件。
解决:先切换目录再添加路径,然后确认文件确实存在:
cd('D:/code/cnn_image_classification'); addpath(pwd); which NewMainwhich如果返回了文件完整路径,说明MATLAB已经能找到了;如果返回“未找到”,检查文件名大小写是否一致。NewMain.m是混合大小写,但在Windows上一般不敏感,在Linux上则必须完全匹配。
5.2 现象:训练正常,但准确率一直停留在随机水平
原因:标签格式不对,或者网络初始化参数和数据维度不匹配。最常见的是train_y是类别编号而非one-hot向量,导致损失计算完全错误。另一种可能是卷积核初始化范围不合理,特征在过sigmoid时全部饱和,梯度消失。
解决:打印train_y前几行确认编码格式。如果是单列编号,用前面提到的sub2ind方法转成one-hot。同时检查cnnsetup.m里输入层mapsize是否和数据维度匹配,第一层不匹配整个网络都在学噪声。
% 检查标签格式 train_y(:, 1:5)5.3 现象:cnnnumgradcheck数值梯度误差很大
原因:梯度校验数据量太少,或者网络层中使用了不可导操作。比如max池化的索引处理不当,或者是sigmoid函数饱和区导数接近0导致数值精度问题。
解决:先用sigmoid激活跑校验,数据量增加到50个样本再试。如果仍有偏差,重点检查cnnbp.m里卷积核旋转方向是否用了rot180,这是最常见的梯度错误来源。数值梯度计算中eps如果太小会引入浮点误差,调到1e-4通常能改善。
5.4 现象:训练到一半MATLAB报"内存不足"
原因:卷积层的特征图在每次前向传播时被全部缓存,数据量大、特征图数量多时内存增长非常快。outputmaps设置过大、batchsize过大都会加剧这个问题。
解决:把batchsize从50降到20或10,减少每次前向传播的激活缓存。或者减少卷积层outputmaps数量,比如从12改成8。另一个办法是在cnntrain.m的迭代循环末尾加一句clear释放不再使用的中间变量。
% cnntrain.m 循环内释放内存 clear activations;5.5 现象:printConMat输出的混淆矩阵行列对不上
原因:混淆矩阵的行和列对应真实类别和预测类别,这套代码按训练集中类别出现的顺序排列。如果你的数据类别顺序和打印结果不一致,会导致误判。
解决:先打印train_y的列编号对应的类别顺序,再对照printConMat的显示。如果需要调整,可以在调用前对数据按类别排序,或者在printConMat.m里传一个类别标签数组进去重新排列。
% 查看类别顺序 [~, classOrder] = find(train_y(:, 1:100)); unique(classOrder)5.6 现象:卷积层输出尺寸与自己算的不一致
原因:convn在边界处理上有'valid'、'same'、'full'三种模式,这套代码用的是'valid'。如果你手动计算尺寸时按'same'的公式算,结果必然对不上。
解决:记住'valid'模式下输出尺寸公式是input_size - kernelsize + 1。如果输入是28×28,卷积核5×5,输出就是24×24。池化后再乘以1/scale,逐层往下算就能得到最终全连接层的输入维度。
6. 把这份CNN代码改造成自己的分类器:换数据集、调参和结果验证的最后一公里
6.1 从图片文件夹生成.mat数据集:三步转换法
如果不用自带的datalab.mat,而是用自己的图片,需要先把图片转成.mat格式。我一般写一个批量读取脚本,统一缩放到相同尺寸后展平:
% 批量读取图片并生成train_x和train_y imgDir = 'D:/dataset/train'; files = dir(fullfile(imgDir, '*.jpg')); train_x = zeros(numel(files), 4096); % 64*64=4096 train_y = zeros(10, numel(files)); % 10类,one-hot for i = 1 : numel(files) img = imresize(imread(fullfile(imgDir, files(i).name)), [64 64]); train_x(i, :) = img(:)'; label = getLabelFromFileName(files(i).name); % 从文件名解析类别 train_y(label, i) = 1; end save('mydata.mat', 'train_x', 'train_y');这段代码把每张图缩放到64×64再展平成行向量,train_y按类别编号转成one-hot。生成后替换原来的datalab.mat并保持变量名不变,训练流程完全不用改。如果类别数不是10,记得把train_y的第二维改成实际类别数。
6.2 调参的一个习惯:先固定网络结构,再单独调学习率
这套代码里alpha默认是1,对简单的MNIST类数据效果尚可,但对复杂数据集往往不合适。我现在的方法是:先用默认参数跑一次,记录损失曲线。如果损失震荡,把alpha乘以0.1;如果收敛太慢,乘以1.5。一次只改一个参数,不要同时动学习率、batchsize和网络层数,否则出了问题根本定位不到是哪个改动导致的。
6.3 一个验证技巧:用混淆矩阵定位是哪几个类在互相混淆
printConMat输出的混淆矩阵不只是看整体准确率,更重要的是看哪些类别互相混淆。比如四和九经常分不清,说明卷积核提取的特征对这两个类的区分度不够。这时有两个方向:增加第二个卷积层的outputmaps数量,或者加一层池化增大感受野。改完再跑,观察混淆矩阵里那个分块的数值是否下降。
从那以后,我每次拿到别人的CNN代码,都会先跑一遍梯度校验、看一眼混淆矩阵、再决定改不改结构,这三个习惯帮我避免了很多次“训练了俩小时发现梯度写错了”的血泪场景。希望帮到你。
本文还有配套的精品资源,点击获取