基于Matlab的PCA与决策树手写数字识别实现

基于Matlab的PCA与决策树手写数字识别实现 做手写数字识别第一反应可能是上卷积神经网络但说实话在数据量不大、没有GPU的环境下传统图像处理加机器学习算法的组合反而更实用也更适合理解整个识别链路。这篇文章我直接用Matlab完成一个完整的手写数字识别流程包括数字定位、5乘5分块分割、二值化处理、PCA主成分分析降维以及决策树建模和交叉验证评估。这套方案非常适合做课程设计、毕业设计或者想快速搭建一个可解释性强的OCR原型。整个流程不依赖深度学习工具箱只需要Matlab基础环境就能跑通。我尽量把每个环节的原理和代码都拆开讲最后附上完整可运行的思路和常见坑方便你直接复现。1. 项目整体设计与方案选型1.1 为什么在深度学习时代还用决策树很多人会问现在手写数字识别不都是用LeNet、ResNet这类卷积网络吗为什么还要用决策树加PCA这种老思路我个人的观点是不同场景选不同工具。决策树方案最大的优势是可解释性极强每一步分类依据都能回溯对理解特征工程和模型原理非常有帮助。而且决策树训练快、不需要大量数据、不需要GPU在一台普通笔记本上几秒钟就能完成训练和评估。对比一下两种方案的差异方案数据需求训练速度可解释性硬件要求CNN数万级别慢分钟到小时弱黑盒需要GPU更佳决策树PCA数百到数千即可极快秒级强白盒纯CPU即可如果你的项目要求是理解识别原理、能讲清楚每个环节或者做教学演示传统方案明显更合适。另外这套流程里的图像定位、分块、二值化、PCA降维这些步骤在任何现代识别系统里都是通用前置技术学会了不亏。1.2 完整识别流程拆解整个项目本质是一条标准图像分类流水线从原始图片到最终输出数字标签中间有五个关键环节图像预处理与二值化把灰度图变成黑白图抑制背景干扰。数字区域定位从整张图中找到数字所在的区域裁掉多余背景。5乘5分块分割把定位后的数字图缩放成统一尺寸再切成25个小格子。PCA特征降维把25维的子块特征压缩到更低维度同时保留大部分信息。决策树建模与交叉验证用降维后的特征训练分类器评估泛化精度。听起来步骤多但实际串联起来就是几段Matlab脚本的事。每一步的目的都很明确下面我逐个展开讲。2. 图像预处理核心二值化、定位与5乘5分割2.1 二值化为什么不能让灰度图直接入场手写数字图片在拍摄或扫描过程中背景往往不是纯白色纸张纹理、阴影、墨迹深浅都会干扰识别。如果直接用灰度图提取特征光照不均会造成同一数字的特征差异很大。二值化的作用是把每个像素从0到255的灰度值归为两类0代表背景1代表前景文字。这样相当于把图像变成一张“黑白简笔画”数字结构保留下来了但冗余细节被删掉了。Matlab里最简单的方法是设定一个全局阈值imgGray rgb2gray(img); % 如果读进来是彩图先转灰度 bw imgGray threshold; % 高于阈值为白低于为黑但实际项目中阈值不能拍脑袋定。比如用手机拍的作业纸环境亮度不同同一支笔写出来的字灰度分布差别很大。固定阈值常常会出现整张图全黑或全白的情况。推荐的做法是用Otsu方法自动计算阈值Matlab里一句话搞定level graythresh(imgGray); % Otsu全局阈值 bw imbinarize(imgGray, level);做的时候还要根据前景背景颜色决定是否取反保证数字区域是1白色背景是0黑色。如果数字是深色、背景是浅色需要取反bw ~bw; % 确保数字为前景实操中我见过不少同学二值化后忘记取反导致后边定位找的全是背景区域整个流程直接报废所以这一步建议可视化检查一下。2.2 数字定位先用连通域锁目标再裁边界定位要解决的是“数字在图像哪个位置”的问题。最实用的是连通域分析法。二值化之后图像中目标文字通常形成若干个连通的白色区域我们只需要找到面积最大的那个区域或合并距离较近的区域就能锁定手写数字的大致范围。cc bwconncomp(bw); % 查找连通域 stats regionprops(cc, BoundingBox, Area); [~, idx] max([stats.Area]); % 找到最大连通域 bbox stats(idx).BoundingBox; % 外接矩形拿到外接矩形后再做一步扩展裁切保证别把数字边缘切掉x1 max(floor(bbox(1)) - pad, 1); y1 max(floor(bbox(2)) - pad, 1); x2 min(ceil(bbox(1) bbox(3)) pad, size(bw, 2)); y2 min(ceil(bbox(2) bbox(4)) pad, size(bw, 1)); digitROI bw(y1:y2, x1:x2);这一步我踩过的坑是当图片中有多个数字或噪声点的时候面积最大不一定是要找的目标。比如纸张边缘的阴影在二值化后也可能形成大块连通区域。后来我在项目中加入了形态学开运算先去掉细小的噪声点再做连通域分析效果稳定很多bw imopen(bw, strel(disk, 2)); % 去除细小噪点2.3 5乘5分割为什么一定要切块而不是直接缩放定位并裁出数字区域后不同图片里的数字大小不一样、笔画粗细不一样。这时候就涉及特征统一的问题。一种直接的思路是把整张数字图缩放成固定尺寸比如28乘28像素直接把784个像素灰度值作为特征。这样当然可以但维度太高决策树容易过拟合而且像素级特征对笔画偏移非常敏感训练时稍微一歪就判错。5乘5分块分割的核心思想是把归一化后的数字图均匀划分成5行5列共25个子区域再对每个子区域提取一个能代表局部像素分布的统计值比如前景像素占比。这样原始图像的局部空间信息被压缩成25维向量特征维度低同时带有一定的抗偏移能力。具体操作如下将数字ROI缩放为统一尺寸我用的是50乘50像素。把50乘50图像切成5乘5的网格每个子块大小为10乘10像素。计算每个子块中前景像素的比例作为该子块的特征值。把所有子块的特征拼成1行25列的向量。imgResized imresize(digitROI, [50, 50]); blockSize 10; features zeros(1, 25); for row 1:5 for col 1:5 r1 (row-1)*blockSize 1; r2 row*blockSize; c1 (col-1)*blockSize 1; c2 col*blockSize; block imgResized(r1:r2, c1:c2); features((row-1)*5 col) sum(block(:)) / numel(block); end end你说为什么是5乘5而不是3乘3或10乘10这块我一开始也犹豫过。说实话这个参数没有绝对标准取决于图像复杂度和数据量。5乘5算是一个比较均衡的设定每个子块10乘10像素既保持了左上、右上、下弯、竖线这些局部结构又不会因为分块太细导致维度爆炸和过拟合。如果你想追求更高精度可以试试7乘7的分块但需要更多训练样本。我还额外试过另一种改进在每个子块中同时提取均值、方差和最大像素值作为特征这样每个数字得到75维的特征向量。效果有一点提升但是决策树的训练时间明显变长且提升幅度有限。最终以效果和效率的平衡来看单独使用前景像素比例已经足够。2.4 完整预处理函数参考在实际工程中我会把上述流程封装成一个函数方便对多张图片批量处理function feat preprocessDigitImage(imgPath) img imread(imgPath); if size(img, 3) 3 img rgb2gray(img); end level graythresh(img); bw imbinarize(img, level); bw ~bw; bw imopen(bw, strel(disk, 2)); stats regionprops(bwconncomp(bw), Area, BoundingBox); [~, idx] max([stats.Area]); bbox stats(idx).BoundingBox; pad 3; x1 max(floor(bbox(1)) - pad, 1); y1 max(floor(bbox(2)) - pad, 1); x2 min(ceil(bbox(1) bbox(3)) pad, size(bw, 2)); y2 min(ceil(bbox(2) bbox(4)) pad, size(bw, 1)); roi bw(y1:y2, x1:x2); resized imresize(roi, [50, 50]); feat zeros(1, 25); idx 1; for i 1:5 for j 1:5 block resized((i-1)*101:i*10, (j-1)*101:j*10); feat(idx) sum(block(:)) / 100; idx idx 1; end end end3. PCA主成分分析降维不是玄学是线性代数的基本功3.1 为什么要降维决策树不是不怕高维吗有一部分同学可能会认为决策树每次切分只选一个特征高维数据好像也能处理。这个理解不够准确。决策树单次分裂确实只选择一个特征但它的分裂思想是“贪心地从所有特征里选最优”在特征维度较高的时候大量无关或冗余特征会干扰最佳分裂点的选择训练出来的树层面更复杂极容易过拟合。25维不算高但其中包含大量相关性强的特征。比如数字“1”让左上和左下的格子同时产生高响应这种特征具有天然相关性全量喂进去其实给模型引入了噪声。PCA做的事就是把原始的25维特征通过线性变换映射到新的正交坐标系中并且让新坐标系中前几个轴即主成分能解释原始数据的大部分方差。随后我们只保留前K个主成分丢弃后面的维度。这样做有三个明显的好处消除特征间的多重相关性让决策树的特征选择更稳定。降低维度之后训练时间缩短、树结构更简单泛化能力更强。通过累计方差贡献率能直观判断特征压缩的信息损失。3.2 PCA的数学本质用一个例子说明白我们假设所有图片的25维特征都存储在矩阵X里。每一列是一个“子块位置”每一行是一张图。PCA目标就是找到一组新的基向量把样本点投影到新基上让投影后样本在每一个基向量方向上的方差最大化。这里有一个关键点PCA前一定要对每一维特征做标准化即减去均值并除以标准差。如果不做标准化前景像素占比本来就比较小、方差大的子块会主导主成分方向维度之间失去可比性结果会偏向大数值特征。Matlab实现PCA非常简洁[coeff, score, ~, ~, explained] pca(featureMatrix);其中coeff是主成分系数矩阵每一列是一个主成分方向。score是样本在主成分空间中的坐标也就是降维之后的特征表示。explained是每个主成分解释的方差百分比可以用来判断保留几个主成分。举个例子如果计算得到前5个主成分解释了总方差的87%前8个解释了95%那么保留8个主成分通常能覆盖足够多的原始信息。不要强行保留太少维度不然数字“0”和“6”这种只在某个小区域有差异的情况会被抹掉信息。3.3 训练集与测试集必须共享同一投影矩阵这一步是新手最容易犯的错误我必须专门提醒PCA的投影矩阵只能从训练集上计算得到测试集必须沿用训练集的coeff进行投影而不是重新对测试集单独做PCA。如果对测试集单独做PCA得到的投影方向是测试集自己的最优方向和训练集得到的方向完全不一致等于是“各说各话”训练出的模型在测试集上的评估结果毫无意义。正确的做法是% 训练集 [coeff, scoreTrain, ~, ~, explained] pca(XTrain); % 测试集直接用同一个 coeff scoreTest XTest * coeff;如果你用Matlab自带的函数做交叉验证比如fitctree配合crossval可以先把所有样本放在一起做PCA再划分训练集和测试集。但我更推荐的是在交叉验证的每一折内部重新执行PCA这样评估结果更诚实。不过要说明的是这类操作会显著拖长计算时间在数据量不大时可以这么较真数据量大点就直接全局PCA了。关于保留多少维我给出一个可直接参考的经验值分块方案原始维度建议保留主成分数累计方差贡献率5x5258~10约90%-95%7x74912~15约90%-95%拿5乘5分块来说25维降到8-10维压缩率超过60%但识别精度基本不丢失这就是PCA的实用价值。4. 决策树建模与交叉验证实操4.1 决策树为什么适合这种小型多分类任务决策树分类器的核心思想是通过一系列“if-then”规则把样本空间递归划分成不同类别区域。每次划分都会从候选中选择一个特征并确定一个切分阈值使得划分后子节点的“纯度”提升最大。Matlab默认采用CART算法分裂标准是分类错误率或基尼指数。决策树对这个项目的适配性很好原因有三个样本量通常不大决策树几秒就能完成训练。特征经过PCA降维和分块提取后数值范围相近分裂点清晰稳定。训练出的树可以通过view函数可视化直观看到模型是依赖哪些区域进行判断的。不过决策树有个天然弱点单棵树方差较大对训练数据敏感样本稍微变化可能树结构就完全变了。这也意味着我们要重点观察交叉验证精度看是否存在过拟合倾向。4.2 训练与交叉验证的Matlab代码假设已经把全部样本的特征矩阵存放于allFeatures中标签存放于allLabels中。从特征矩阵中随机划分80%训练、20%测试常规做法如下rng(42); % 固定随机种子确保实验可复现 cv cvpartition(allLabels, HoldOut, 0.2); trainIdx cv.training; testIdx cv.test; XTrain allFeatures(trainIdx, :); YTrain allLabels(trainIdx, :); XTest allFeatures(testIdx, :); YTest allLabels(testIdx, :); % 决策树建模 treeModel fitctree(XTrain, YTrain, ... MaxNumSplits, 50, ... % 限制最大分裂数 MinParentSize, 10, ... % 叶子节点最少样本数 CrossVal, off); % 预测 pred predict(treeModel, XTest); accuracy sum(pred YTest) / numel(YTest) * 100;如果你要做严格的K折交叉验证而非单次留出验证推荐用cvpartition进行10折rng(42); cv10 cvpartition(allLabels, KFold, 10); accuracies zeros(cv10.NumTestSets, 1); for k 1:cv10.NumTestSets trainIdx cv10.training(k); testIdx cv10.test(k); XTr allFeatures(trainIdx, :); YTr allLabels(trainIdx, :); XTe allFeatures(testIdx, :); YTe allLabels(testIdx, :); model fitctree(XTr, YTr, MaxNumSplits, 40, MinParentSize, 10); pred predict(model, XTe); accuracies(k) sum(pred YTe) / numel(YTe) * 100; end meanAcc mean(accuracies); stdAcc std(accuracies);在真实实验中10折交叉验证的平均精度大概稳定在85%92%之间具体会受图片质量影响。例如训练数据是某种字体扫描出来的测试数据是手写体精度会掉到80%以下。这很常见原因是手写风格差异太大单纯的像素统计特征只能捕获粗粒度结构。4.3 决策树剪枝控制过拟合的关键如果不设置任何限制决策树会一直分裂到每个叶子节点只包含一个样本为止。这在训练集上效果极好甚至能达到100%准确率但测试集上立刻原形毕露。控制过拟合有两种手段一种是前置剪枝在fitctree里直接限制树深度和叶子最小样本数另一种是后置剪枝先生成一棵完整树再调用prune方法裁剪冗余节点。我以前长期用前置剪枝简单直接推荐关注三个参数model fitctree(XTrain, YTrain, ... MaxNumSplits, 20, ... % 最多分裂20次 MinLeafSize, 5, ... % 每个叶子最少5个样本 MinParentSize, 10, ... % 父节点最少10个样本才会继续分裂 );其中MinLeafSize是最重要的参数它直接规定了单类样本不能少于5个才允许叶子节点出现。调大到15-20树会更简单交叉验证精度可能出现先升后降的趋势可以通过实验对比。4.4 画出混淆矩阵比只看准确率有信息量得多评估分类模型不能只看整体精度我建议至少画一次混淆矩阵看到底哪些数字容易混。cm confusionmat(YTest, pred); heatmap(cm, Colormap, parula);真实测试中“0”和“6”的混淆出现在子块下侧圆弧特征接近的情况“7”和“9”也偶尔混。发现这些混淆模式后如果精度瓶颈集中在某几组数字可以考虑针对性地增加对应样本或者在分块特征中单独提取一些抗混淆的判别特征。5. 主流程串联一份可直接参考的Matlab脚本结构5.1 整体工程文件目录建议|-- data/ | |-- train/ | | |-- 0/ | | |-- 1/ | | |-- ... | | -- 9/ | -- test/ | |-- 0/ | |-- 1/ | |-- ... | -- 9/ |-- preprocessDigitImage.m |-- trainDecisionTree.m -- main.m用文件夹名作为标签好处是不需要额外维护标签文件遍历目录时就自动获得标签了。这也是很多传统图像分类项目的通用做法。5.2 主脚本逻辑%% 1. 读取训练集图片并提取特征 trainRoot data/train; categories dir(trainRoot); categories categories([categories.isdir]); categories categories(~ismember({categories.name}, {., ..})); allFeatures []; allLabels []; for i 1:length(categories) label str2double(categories(i).name); imgFolder fullfile(trainRoot, categories(i).name); imgFiles dir(fullfile(imgFolder, *.png)); imgFiles [imgFiles; dir(fullfile(imgFolder, *.jpg))]; for j 1:length(imgFiles) imgPath fullfile(imgFolder, imgFiles(j).name); feat preprocessDigitImage(imgPath); % 若不同文件夹下图片格式不统一用dir的覆盖方式还是不够稳 allFeatures [allFeatures; feat]; allLabels [allLabels; label]; end end读取全部样本后再执行PCA K折交叉验证 混淆矩阵输出%% 2. PCA降维 [coeff, scoreTrain, ~, ~, explained] pca(allFeatures); % 选择保留 90% 方差所需的主成分数 cumVar cumsum(explained); k find(cumVar 90, 1); fprintf(保留 %d 个主成分累计方差贡献率 %.2f%%\n, k, cumVar(k)); reducedFeatures scoreTrain(:, 1:k);5.3 预测新图片在验证完整体流程后真正用于新图片预测时要记得把PCA步骤再次跑一遍function label predictDigit(model, coeff, k, imgPath) feat preprocessDigitImage(imgPath); featReduced feat * coeff(:, 1:k); % 使用训练阶段的投影矩阵 label predict(model, featReduced); end这里最值得记住的就是featReduced的计算方式不是对feat单独做PCA而是直接用训练阶段学到的coeff乘以新样本的特征向量。6. 常见问题与排错实录我在实际调试这套流程时遇到过不少问题挑几个典型的列在这里做个速查方便你少走弯路。现象可能原因解决方法二值化后图像整体全黑或全白彩色图转灰度后未正确取反或Otsu受强噪声干扰检查bw中数字是否为白色前景必要时bw~bw数字定位总是框到背景噪声图像边缘存在阴影或小墨点先做imopen形态学去噪再限制连通域面积阈值5乘5分块特征很多为0或全为1二值化后前景背景反转ROI裁切到了空白区域可视化每一张预处理后的ROI检查前景占比是否正常决策树训练精度100%但交叉验证只有70%严重过拟合树没做剪枝限制增大MinLeafSize设置MaxNumSplits必要时降低特征维度训练集精度90%但测试集只有60%训练集和测试集图像分布差异太大统一图像的预处理和归一化流程检查有无反色图片被当作样本6.1 多数字图片怎么处理如果一张图里有多个手写数字需要识别比如一行5个数字最稳妥的思路是先用投影法或连通域分列切出每个数字的独立区域再逐个调用preprocessDigitImage进行识别。这里要额外注意字符重叠或连笔造成的切分失败。我用过的切分方案是先做垂直方向投影统计每一列的前景像素数量某列完全没有前景像素即视为字符间隔顺着这个间隔把数字切成独立子图。如果两个数字粘连很严重投影法会失效此时通常需要引入更精细的分割算法比如滴水算法或基于深度学习的检测模型。6.2 数据增强是否有必要由于决策树方案可解释性强但抗扰动能力弱于CNN适当的训练数据增强能直接提升泛化精度。最简单的做法是对原始图像做随机小幅平移、旋转或缩放augmented imtranslate(roi, [randi([-2, 2]), randi([-2, 2])]); augmented imrotate(augmented, randi([-5, 5]), bilinear, crop);平移2个像素、旋转5度以内、缩放0.9到1.1倍的组合对MNIST这类居中数字效果不错。需要注意的是增强后的图片要裁剪回原始尺寸统一归一化后再分块避免引入空白边框。6.3 主成分数选择的经验法则如果你的数据里数字风格比较单一前5个主成分可能就贡献了90%以上方差降到5维已经能取得较好的精度。但遇到手写风格差异大的数据方差会比较分散可能要保留10个左右的成分。不要死记数字直接用累计贡献率曲线来决策figure; plot(cumsum(explained), o-); xlabel(主成分个数); ylabel(累计方差贡献率(%)); grid on;观察曲线的肘部位置通常取曲线明显变平缓的点作为保留数量。这个方法比拍脑袋选一个数可靠得多。7. 进一步优化与个人经验小结如果觉得当前精度不够有几个方向可以继续打磨。第一个方向是特征维度的扩展。比如原始5乘5分块只用前景占比可以同时统计每个子块中前景像素的质心坐标、笔画密度、水平或垂直方向的连续像素长度等这样每个子块不再是一个标量而是一个小向量特征表达能力更强。第二个方向是换用更先进的基学习器。决策树作为单一模型上限有限但当你把Bagging或随机森林引入后精度一般能再提升3到6个百分点实现也简单bagModel TreeBagger(200, reducedFeatures, allLabels, ... Method, classification, MinLeafSize, 5);我个人在实际操作中的体会是这套预处理和特征工程流程才是核心资产模型反而是最简单的部分。你以后从决策树换到随机森林、甚至浅层神经网络前面那些定位、分割、二值化、PCA的代码都能无缝复用。最后再分享一个小技巧在正式训练前务必把所有经过预处理的ROI图像单独保存在一个debug文件夹里用subplot拼成网格图快速目检。比如你可以用montage函数一次性看几十张图。这一步虽然看起来不起眼但它能帮你迅速发现加载错文件、图片反色、裁切错误这三类最隐蔽的问题。确认输入干干净净再谈模型调参才有意义。