做手写数字识别,是很多搞图像处理和机器学习的同学绕不开的一个课题。之前帮人搭过一套完整的 matlab 方案,链路是图像定位、二值化、5×5 分割、主成分分析法(PCA)降维,最后用决策树做分类,再用交叉验证评估效果。这套流程走下来,模型精度虽然不是最顶尖的,但整个方案的工程意义很强,尤其适合特征工程的练手,以及做一些轻量级的数字识别场景,比如答题卡数字识别、表单编号自动读取、票据金额区域识别这类对模型大小和可解释性有要求的任务。
这篇文章就把这套基于决策树的手写数字识别 matlab 实现完整拆开,从图像预处理开始讲到交叉验证的结果评估。整个项目最核心的关键词就五个:matlab、决策树、手写数字识别、主成分分析法、二值化,下文会逐个展开,把每一步为什么这么做、怎么做、踩过什么坑都交代清楚。无论你是刚入门图像处理的新手,还是在做课程设计、毕业设计,这套方案都可以直接参考复现。
1. 项目整体设计与思路拆解
1.1 核心需求解析
这个项目要解决的并不是简单的"给一张数字图片输出一个标签",而是一个完整的技术链路。我们在实际场景里拿到的手写数字图像,往往是整张纸、整块区域,而不是一张干干净净的单数字图片。所以项目的完整链路是:
- 从原始图片中定位出数字所在的区域;
- 对定位区域做灰度化和二值化处理,把背景和数字前景清晰分离;
- 把每个数字图像归一化后做 5×5 网格分割,提取结构化特征;
- 用主成分分析法对高维特征做降维,去掉冗余信息;
- 用降维后的特征训练决策树分类器;
- 最后用交叉验证评估模型的稳定性和泛化能力。
这个设计思路其实是传统机器学习解决图像识别问题的标准范式:图像预处理 → 特征提取 → 特征降维 → 分类器训练 → 模型评估。相比现在动辄上百万参数的卷积神经网络,这条路线的计算开销小很多,并且在数据量不大的情况下,模型的训练速度和可解释性都要好得多。如果你想理解"特征工程到底在做什么",这套流程就是最好的教材。
1.2 为什么选择"决策树 + PCA"而不是神经网络
很多人看到手写数字识别,第一反应是上卷积神经网络(CNN),MNIST 数据集上 CNN 的准确率确实可以轻松做到 99% 以上。但决策树方案也有自己独特的价值,不能一概而论。
首先是数据量的现实问题。真实业务场景里,手写数字样本往往只有几百到几千个,这个量级下深度学习模型的优势发挥不出来,反而容易过拟合。决策树天生对中小数据集友好,训练速度快,参数少,不太容易翻车。
其次是可解释性。决策树可以把分类规则直接可视化出来,比如"特征 12 > 0.35 并且特征 7 < 0.21 时判定为数字 3",这对需要向非技术同事解释模型逻辑的场景非常关键。我见过不少银行票据识别项目,风控人员对"黑箱"模型是天然不信任的,这时候决策树就比神经网络有优势。
第三是计算资源。跑 matlab 决策树,一颗普通 CPU 几十秒就能完成训练和交叉验证,而训练 CNN 往往需要几分钟到几十分钟。对于课程设计、毕业设计这种场景,决策树方案时间成本低,出成果快,放在论文里技术逻辑也更清晰。
1.3 技术流程总览
整个方案在 matlab 中的实现可以分为六个模块:
- 图像读取与灰度化
- 数字区域定位(连通域分析 / 投影法)
- 二值化与形态学去噪
- 5×5 网格分割与特征向量构建
- PCA 降维
- 决策树训练与交叉验证
后面每个模块我都会给出能直接跑的 matlab 代码片段,并解释关键参数的含义,这样你拿到手就能在此基础上改。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 图像预处理:定位、二值化详细实现
2.1 读图与灰度化处理
matlab 中读图用 imread,这一步几乎没什么坑,但有一个细节容易被忽略:读进来的彩色图是三维矩阵(H×W×3),需要转成灰度图才能做阈值分割。如果你拿到的是扫描件或者手机拍照的图片,建议先用 imresize 统一尺寸,避免后续处理时不同图片分辨率差异过大。
matlab复制% 读入图像
img = imread('handwritten_digits.png');
% 如果是彩色图,转为灰度图
if size(img, 3) == 3
gray = rgb2gray(img);
else
gray = img;
end
% 可选:降采样统一尺寸,加快处理速度
gray = imresize(gray, [400, 400]);
这里 imresize 到 400×400 是我常用的做法。原始图像如果很大,比如 3000×2000 的扫描件,连通域分析和形态学操作都会变慢。缩小到 400×400 能保留足够的数字轮廓信息,同时处理速度快一个量级。
2.2 数字区域定位:投影法与连通域分析
定位是手写数字识别里最容易翻车的一步。如果图片背景干净,数字是黑色手写体、白色纸张,定位其实非常简单。我常用的方法是连通域分析,配合 regionprops 提取每个连通域的包围盒。
所谓连通域,就是二值图像中像素值为 1 且相邻的像素组成的区块。每个手写数字在二值图里就是一个连通域(写得不潦草的情况下)。通过 regionprops 的 'BoundingBox' 和 'Area' 属性,就能拿到每个数字的大致位置。
matlab复制% 先用 Otsu 阈值做初版二值化(后续小节再细说)
level = graythresh(gray);
bw = imbinarize(gray, level);
% 假设数字是深色,背景是浅色,取反让数字前景为1
if mean(bw(:)) > 0.5
bw = ~bw;
end
% 连通域分析
cc = bwconncomp(bw);
stats = regionprops(cc, 'BoundingBox', 'Area');
% 过滤掉面积过小的噪声区域
minArea = 50;
validIdx = [stats.Area] > minArea;
boxes = round(vertcat(stats(validIdx).BoundingBox));
过滤面积小于 minArea 的连通域,是为了去掉纸张上的噪点、污渍、扫描杂质。这个阈值要看你的实际图片尺寸,一般取图像总像素数的千分之一量级比较合理。比如 400×400 的图,总像素 16 万,千分之一就是 160,但手写数字笔画本身面积会比较大,所以取 50 是给噪声留了一点放宽空间。
如果你的图片数字排列比较整齐,比如一行数字,还可以用投影法来定位。投影法的思路是:把二值图在水平方向和垂直方向分别做像素累加,得到两个投影曲线,曲线的谷底就是数字行、列之间的空隙,峰顶就是数字密集区域。这种方法对"整行数字在同一水平线"的场景非常好用,比如答题卡上的数字编号。
matlab复制% 水平投影:统计每行的前景像素数
rowProj = sum(bw, 2);
% 垂直投影:统计每列的像素数
colProj = sum(bw, 1);
% 通过阈值判断数字区域的行范围
rowMask = rowProj > 0;
rowIdx = find(rowMask);
% 数字区域的上下边界就是连续行段的端点
实际项目中我建议两种方法结合:先用投影法框出数字存在的区域大范围,再用连通域分析在框内精确定位每个数字。这样能避免某个数字笔画分离导致被识别成多个连通域,也能避免多个数字粘连导致被识别成一个区域。
2.3 二值化处理:Otsu 阈值法细节
二值化的目标是把灰度图变成只有 0 和 1 的图,这一步的质量直接决定后续分割和特征提取的效果。matlab 中最省事的方式就是用 graythresh 计算 Otsu 阈值,再用 imbinarize 应用阈值。
Otsu 算法的原理可以这么理解:它遍历所有可能的灰度阈值,对每个阈值把图像分成前景和背景两类,然后计算类间方差。类间方差越大,说明前景和背景的区分度越高。Otsu 自动选取让类间方差最大的那个阈值。
matlab复制level = graythresh(gray);
bw = imbinarize(gray, level);
这里有一个初学者最容易踩的坑:二值化之后数字是白色还是黑色取决于你图片的实际情况。如果你的数字是黑色写在白纸上,imbinarize 出来数字是 0(黑),背景是 1(白)。后续区域定位和特征提取一般都约定前景为 1,所以需要判断是否需要取反。
matlab复制% 如果前景像素占比太大,说明可能反了,取反
if mean(bw(:)) > 0.5
bw = ~bw;
end
还有一个常见问题:光照不均匀导致图片一边亮一边暗时,全局阈值 Otsu 会失效。这种情况下可以考虑用 matlab 的局部自适应阈值 adaptthresh:
matlab复制% 局部自适应阈值二值化
levelLocal = adaptthresh(gray, 0.4);
bwLocal = imbinarize(gray, levelLocal);
0.4 是灵敏度参数,取值范围是 0 到 1,数值越小,阈值越容易贴近局部均值,抗光照不均的能力越强,但也容易把背景噪声带进来。这个参数我一般从 0.4 开始调,根据实际效果微调。
二值化之后建议加一步形态学去噪,用 bwareaopen 删除面积小于指定像素数的小连通域,这和前面连通域分析里过滤噪点的道理一致,但更直接。
matlab复制% 删除面积小于50像素的连通域
bw = bwareaopen(bw, 50);
这一步做完,图像的预处理部分基本就收敛了。此时我们得到的是"白字黑底"或"黑字白底"的干净二值图,下一步就可以进入 5×5 分割。
3. 5×5 分割与特征向量构建
3.1 5×5 网格分割的基本思路
把每个数字图像归一化到固定尺寸,然后划分成 5×5 个网格,统计每个网格内的前景像素密度,得到一个 25 维的特征向量。这就是 5×5 分割最核心的逻辑。
为什么要用 5×5 网格,而不是直接拿像素矩阵当特征?如果一个数字图是 100×100,直接展开就是 10000 维特征,决策树在这种超高维稀疏特征下不仅训练慢,而且很容易学到噪声。5×5 网格相当于做了初步的特征压缩,把"某个位置的像素值"抽象为"某个区域里有百分之多少的笔画",这种区域密度特征对笔画的粗细变化、轻微位移都有一定的鲁棒性。
具体做法是:先 imresize 把数字图像统一成 100×100,然后每 20×20 像素划分一个网格,一共 25 个网格,统计每个网格内前景像素占比,得到一个 25×1 的特征向量。
matlab复制function fea = extractGridFeature(digitImg, gridSize)
% digitImg: 二值化后的单数字图像
% gridSize: 网格划分维度,5表示5×5
digitImg = imresize(double(digitImg), [100, 100]);
cellH = floor(100 / gridSize);
cellW = floor(100 / gridSize);
fea = zeros(gridSize * gridSize, 1);
idx = 1;
for i = 1:gridSize
for j = 1:gridSize
block = digitImg((i-1)*cellH+1 : i*cellH, ...
(j-1)*cellW+1 : j*cellW);
fea(idx) = sum(block(:)) / numel(block);
idx = idx + 1;
end
end
end
这段代码我一般单独存成函数文件,后面批量提取特征时循环调用就行。gridSize 可以调,如果你想试试 7×7 或者 10×10,直接改参数即可,不用改函数逻辑。
3.2 归一化与特征向量构建的实操要点
用 5×5 分割前,有几个细节如果不注意,特征质量会打折扣。
第一,每个数字的原始包围盒尺寸可能差很多。有的是扁的,有的是瘦长的,直接统一拉伸成 100×100 会导致字形变形,影响特征质量。我在实际项目中会先做一步"保持宽高比的补边归一化":把数字图像缩放到最长边为 100,然后在短边两侧补空白,让整体图变成 100×100。这样字形比例不变,只是周围多了白边,大大减少了变形对特征的影响。
matlab复制function resized = padAndResize(digitImg, targetSize)
[h, w] = size(digitImg);
scale = targetSize / max(h, w);
nh = round(h * scale);
nw = round(w * scale);
resized = imresize(digitImg, [nh, nw]);
% 补边到 targetSize x targetSize
padded = zeros(targetSize, targetSize);
rowStart = floor((targetSize - nh) / 2) + 1;
colStart = floor((targetSize - nw) / 2) + 1;
padded(rowStart:rowStart+nh-1, colStart:colStart+nw-1) = resized;
resized = padded;
end
第二,特征向量要不要归一化到 [0,1]。5×5 分割得到的本来就是密度值,范围天然在 [0,1],不需要额外归一化。但如果特征后面还要拼接别的信息,比如数字包围盒的宽高比、面积、重心位置等,就需要对全部特征做统一归一化,否则决策树分裂时可能会偏向数值范围大的特征。决策树虽然不像 SVM、KNN 那样对特征尺度敏感,但归一化之后树的可视化规则会更直观。
第三,训练集特征矩阵的组织方式。所有训练样本的特征应该按行堆叠成一个矩阵,每一行是一个样本,每一列是一个特征。比如有 800 个训练数字,每个数字提取 25 维特征,特征矩阵就是 800×25。对应的标签是 800×1 的列向量,值是 0 到 9。
matlab复制% 假设 digitImages 是归一化后的图像 cell 数组,labels 是对应标签
numSamples = length(digitImages);
featureMatrix = zeros(numSamples, 25);
for i = 1:numSamples
featureMatrix(i, :) = extractGridFeature(digitImages{i}, 5)';
end
这一步做完,我们就有了一份可以直接喂给 PCA 和决策树的特征数据集。
4. 主成分分析法(PCA)降维的原理与实现
4.1 为什么需要 PCA 降维
5×5 分割只产生了 25 维特征,看起来已经不高了。但如果你把网格数调大,比如 10×10,特征就是 100 维,或者你直接使用像素特征,比如 100×100 展开就是 10000 维。高维特征带来的问题有三个:
一是特征之间存在相关性。比如"左上角网格的特征"和"上方中间网格的特征",在手写数字里往往同时高或同时低,这说明它们携带的信息有一部分是重复的。PCA 可以通过正交变换,把这些相关特征压缩成互不相关的综合特征。
二是决策树在高维空间容易过拟合。决策树的每一次分裂都是在找一个特征的一个阈值,特征越多,找到"碰巧能把训练集分开但泛化很差"的分裂点的概率越大。降维能显著缓解这个问题。
三是计算效率。虽然 25 维的规模决策树跑起来毫无压力,但如果做网格搜索调参或者交叉验证很多轮,每多一维特征,计算量都会增加。PCA 在数据规模大的时候收益非常明显。
4.2 PCA 的数学原理与 matlab 实现
PCA 的本质是在高维空间中找到一组正交方向(主成分),使得数据在这些方向上的投影方差最大。第一个主成分是数据方差最大的方向,第二个主成分是与第一个主成分正交且方差次大的方向,依此类推。
直观理解就是:把数据想象成一片云,PCA 找到这片云"延伸得最长"的方向,把它作为新的坐标轴第一维,然后找与之垂直的第二长的方向做第二维。取前几个主成分,就相当于在这片云的形状上取最主要的几个伸展方向,忽略掉那些几乎没有变化的方向。
matlab 中 PCA 的调用非常简洁:
matlab复制% featureMatrix: 样本数 × 特征数 矩阵
% 需要先做中心化,pca 函数内部会自动做,但要注意数据是行样本排列
[coeff, score, latent, ~, explained] = pca(featureMatrix);
各输出参数的含义:
coeff:主成分系数矩阵,每一列是一个主成分方向,维度是 特征数 × 特征数;score:原始数据在主成分方向上的投影,也就是降维后的新特征矩阵,维度是 样本数 × 特征数;latent:每个主成分对应的特征值(方差),数值越大说明该主成分携带的信息越多;explained:每个主成分解释的方差百分比,加起来是 100。
关键问题来了:到底取多少个主成分? 行业里最常用的标准是"累计贡献率超过 85%~95%"。比如 25 维特征,可能前 8 个主成分的累计解释方差就已经到了 95%,那就可以放心地只保留前 8 列 score,把维度从 25 降到 8。
matlab复制% 计算累计贡献率
cumRatio = cumsum(explained);
% 找到累计贡献率首次超过 95% 的主成分数量
k = find(cumRatio >= 95, 1);
fprintf('保留 %d 个主成分,累计贡献率 %.2f%%\n', k, cumRatio(k));
% 降维后的特征矩阵
featureReduced = score(:, 1:k);
我在做这个项目的时候,25 维特征 PCA 降维后通常能降到 8~12 维,累计贡献率就到 95% 了。这说明 5×5 分割产生的特征里有大量冗余,很多网格的密度变化是高度相关的。降维后决策树的训练速度明显提升,而且测试准确率往往不降反升,这就是去除噪声特征的好处。
4.3 降维与可视化的延伸应用
PCA 降维还有个额外的好处:如果保留前三个主成分,可以直接用 scatter3 画三维散点图,看不同数字类别在特征空间里的分布情况。我在调试特征提取效果时经常这么干——如果同一数字的样本在三维图里聚成一团,说明特征提取效果好;如果不同数字完全混在一起,说明前面的预处理或分割参数要重新调。
matlab复制% 用前三主成分可视化
score3 = score(:, 1:3);
figure;
scatter3(score3(:,1), score3(:,2), score3(:,3), 20, labels, 'filled');
xlabel('PC1'); ylabel('PC2'); zlabel('PC3');
title('PCA 降维后的样本分布');
这个可视化的价值在于,它把抽象的"特征好坏"变成了直观的"点的聚类效果",对于向别人解释特征工程的重要性很有说服力。
5. 决策树分类器的构建与调优
5.1 决策树原理与场景适用性
决策树的分类逻辑,本质上是一连串 "if-then" 规则的嵌套。它从根节点开始,每次选择一个特征和一个切分阈值,把样本分成左右两个子节点,然后递归重复这个过程,直到满足停止条件。
这个过程中最关键的是特征选择。决策树每一步都在寻找"最能降低节点不纯度"的特征和阈值。所谓不纯度,指节点内样本类别的混乱程度。如果某个节点里全是数字 3,不纯度就是 0;如果里面有 3、5、8 混合,不纯度就高。常用不纯度指标有信息熵和基尼系数,matlab 的 fitctree 默认使用的是基尼系数,因为它的计算速度比信息熵快。
决策树在手写数字识别这个场景有几个天然优势:
- 对特征尺度不敏感,PCA 降维后的特征直接用就行;
- 能自动捕捉特征之间的非线性关系;
- 训练出的规则可以可视化,方便检查模型到底学到了什么;
- 分类速度快,预测一个样本就是走一遍树路径,非常轻量。
5.2 matlab 决策树训练与关键参数
matlab 训练分类决策树的函数是 fitctree。基本调用方式如下:
matlab复制% 训练决策树分类器
treeModel = fitctree(featureReduced, labels, ...
'MaxNumSplits', 20, ... % 最大分裂次数
'MinLeafSize', 5, ... % 叶子节点最少样本数
'SplitCriterion', 'gdi'); % 分裂准则:gdi 是基尼指数
几个参数的调优心得:
MaxNumSplits 控制决策树的规模。数值越大,树可以越深,对训练集拟合得越好,但也越容易过拟合。20 是一个比较保守的起点,如果你的特征降维后只有 8~12 维,20 次分裂已经足够复杂。数据量大时可以适当调高到 50~100。
MinLeafSize 控制叶子节点最少包含多少样本。这个参数有很强的正则化效果,调大一点,树自动变浅。我在样本量几百个的时候会设成 3~5,样本量上千时设成 10~20,效果都不错。
还有一个容易被忽略的参数是 'Prune',也就是剪枝策略。matlab 的 fitctree 默认会生成完整的树并计算剪枝序列,训练完成后可以用 prune 方法手动选择剪枝程度。但更实用的做法是直接通过 MaxNumSplits 和 MinLeafSize 在训练时就限制树的复杂度,在大多数数据集上这样调参的效果已经足够好,不用再走一遍剪枝步骤。
训练之后,可以用 view 可视化决策树:
matlab复制view(treeModel, 'Mode', 'graph');
弹出的窗口里能看到树的完整分裂规则,比如某个节点写着"x5 < -0.32 时去左子节点,否则去右子节点"。这条规则能明显看出模型在关注什么特征,这是神经网络完全给不了的。比如我训练完发现前几层分裂多用的是第 2、7、15 个主成分对应的特征,就可以回溯到这些主成分主要受哪些网格影响,相当于给模型的行为找到了一个解释通道。
5.3 超参数选择的经验方法
决策树超参数首先推荐用交叉验证来选。简单的做法是嵌套在 cvpartition 里,对候选参数组合逐个跑 K 折交叉验证,选出平均准确率最高的那组参数。
matlab复制% 候选参数
maxSplits = [10, 20, 30];
minLeafs = [3, 5, 10];
bestAcc = 0;
bestParams = [];
for ms = maxSplits
for ml = minLeafs
acc = evaluateModel(featureReduced, labels, ms, ml);
if acc > bestAcc
bestAcc = acc;
bestParams = [ms, ml];
end
end
end
fprintf('最优参数: MaxNumSplits=%d, MinLeafSize=%d, 准确率=%.2f%%\n', ...
bestParams(1), bestParams(2), bestAcc);
这里 evaluateModel 就是把 K 折交叉验证封装成一个函数。用这个方式扫一遍参数组合,一般几分钟就出结果,比凭感觉拍参数靠谱得多。
6. 交叉验证实验与模型评估
6.1 K 折交叉验证的实施方式
交叉验证是评估模型泛化能力最常用的手段。它的逻辑是:把数据集分成 K 份,每次用 K-1 份训练、1 份测试,轮转 K 次,让每一份数据都当过测试集,最后取 K 次结果的平均值。
K 的取值常见是 5 或 10。K 越大,训练集占比越高,评估结果越接近真实性能,但计算量也越大。样本量在 1000 以下时我一般用 10 折,样本量超过 1000 时用 5 折就够了。matlab 中 cvpartition 可以很方便地生成交叉验证的分割索引:
matlab复制rng(42); % 固定随机种子,保证结果可复现
cv = cvpartition(labels, 'KFold', 5);
accList = zeros(cv.NumTestSets, 1);
for i = 1:cv.NumTestSets
trainIdx = cv.training(i);
testIdx = cv.test(i);
% 训练决策树
mdl = fitctree(featureReduced(trainIdx, :), labels(trainIdx), ...
'MaxNumSplits', 20, 'MinLeafSize', 5);
% 预测测试集
pred = predict(mdl, featureReduced(testIdx, :));
% 计算准确率
accList(i) = sum(pred == labels(testIdx)) / numel(labels(testIdx));
end
meanAcc = mean(accList);
stdAcc = std(accList);
fprintf('5折交叉验证准确率: %.2f%% ± %.2f%%\n', meanAcc * 100, stdAcc * 100);
这里有一个非常值得注意的操作:PCA 的拟合必须在每一折的训练集上做,而不是在整个数据集上做。这是我见过最多人犯的错误。如果你先在整个数据集上 PCA,再把数据分折,那就相当于测试集的信息在训练阶段已经泄露了,交叉验证的结果会虚高。正确的做法是每一折都重新用训练集拟合 PCA,然后用这个 PCA 变换测试集。
matlab复制% 正确做法:每折内部分别对训练集拟合 PCA
for i = 1:cv.NumTestSets
trainIdx = cv.training(i);
testIdx = cv.test(i);
% 拟合 PCA 只基于训练集
[coeffTmp, ~, ~, ~, ~] = pca(featureMatrix(trainIdx, :));
trainReduced = featureMatrix(trainIdx, :) * coeffTmp(:, 1:k);
testReduced = featureMatrix(testIdx, :) * coeffTmp(:, 1:k);
mdl = fitctree(trainReduced, labels(trainIdx), ...
'MaxNumSplits', 20, 'MinLeafSize', 5);
pred = predict(mdl, testReduced);
accList(i) = sum(pred == labels(testIdx)) / numel(labels(testIdx));
end
注意这里 k 的选择也要在训练集内部确定,为了简便可以提前在训练集上算好累计贡献率对应的 k,再应用到测试集变换上。这个细节在面试和答辩里经常被问到,能说清楚会非常加分。
6.2 混淆矩阵与误差分析
准确率只是一个总的数字,实际项目里更关心的是哪两个数字容易混淆。matlab 的 confusionmat 可以直接生成混淆矩阵:
matlab复制cm = confusionmat(labels(testIdx), pred);
disp(cm);
混淆矩阵的每一行是真实类别,每一列是预测类别。对角线上的数字是分对的样本数,非对角线上的数字是分错的样本。
我在多次实验里发现,手写数字最常见的混淆对是"3 和 8"、"5 和 6"、"7 和 9"。原因很直观:这些数字的笔画结构本来就有重叠,如果写得不规范,更难看清楚。5×5 分割对这种全局结构相似的数字区分力不足,如果你用 PCA 降维后只保留了 5~6 个主成分,信息损失过多,混淆会更明显。
定位混淆矩阵之后,可以针对性地做一些改进:
- 增加易混淆数字的训练样本;
- 把 5×5 网格改为 7×7 或者非均匀分割(比如中心区域网格更细);
- 在特征里增加"数字重心位置"、"笔画交叉点数量"等结构化特征;
- 把决策树换成随机森林或 AdaBoost 集成模型,精度通常能再涨 2~5 个百分点。
我实测下来的经验是,5×5 分割 + PCA + 决策树这个组合,在 800/200 的训练/测试划分下,准确率一般能到 88%~94%。如果换成随机森林(matlab 里是 TreeBagger),同条件下能到 95% 左右。但决策树方案的优势是参数少、简单、快,作为基线模型非常合适。
7. 常见问题与排查技巧实录
7.1 常见问题速查表
下面这张表整理了我实现过程中遇到的典型问题,以及对应的排查思路,基本覆盖了从图像预处理到模型评估的各个阶段。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 二值化后数字和背景反了 | 图片是黑底白字,或 imbinarize 输出前后景约定不同 |
检查 mean(bw(:)),若前景占比过高则取反 |
| 定位框把多个数字包在一起 | 数字之间距离太近或笔画粘连 | 缩小连通域面积阈值,或结合垂直投影列切分 |
| 定位框只截到数字的一部分 | 数字笔画断裂,被识别为多个连通域 | 先用 imdilate 做形态学闭运算,再找连通域 |
| 特征矩阵训练集测试集维度不匹配 | PCA 在整体数据集上拟合后再分折 | 改成每折内部分别拟合 PCA |
| 交叉验证准确率高但新图片准率低 | 预处理和特征提取与训练数据不一致 | 确保测试图片走完全相同的预处理流程 |
| 决策树可视化后太深,规则看不完 | 树过拟合,节点太多 | 调小 MaxNumSplits,调大 MinLeafSize |
| 4 和 9 混淆严重 | 5×5 特征对局部细节区分不够 | 增大网格数,或增加特征维度 |
这里重点说一下定位框截断的问题。手写数字笔画断裂很常见,比如数字 5 的横和竖如果断开了一点,连通域分析就会把它当成两个区域。解决方式是先做一步形态学闭运算,把距离近的笔画"粘"起来:
matlab复制% 结构元素大小一般取 3~5 像素
se = strel('disk', 3);
bwClosed = imclose(bw, se);
imclose 会先膨胀再腐蚀,膨胀能让断开的笔画接上,腐蚀再把整体轮廓恢复回去。结构元素半径越大,能粘连的缝隙就越宽,但也越容易把两个相邻数字粘到一起,所以这个参数要谨慎调。
7.2 实操心得与调参方向建议
最后分享几个我反复踩坑后总结下来的经验。
第一个心得:图像预处理环节花费的时间应该占整个项目的 60% 以上。很多人拿到项目就直接调模型,结果模型怎么调准确率都上不去,最后发现是二值化把数字的细笔划都腐蚀掉了。图像质量决定了特征质量的上限,特征质量决定了模型的精度上限。先把预处理可视化做好,每一步都输出一张图看看效果,再往下走。
第二个心得:5×5 分割不是固定的铁律,而是起点。如果你的数据集里数字笔画比较粗,可以考虑把网格改成 5×5,但如果精度卡在 90% 上不去,把网格改成 7×7 或者 10×10 往往能涨几个点。我做过一个对比实验,同一个数据集用 5×5 网格得到 25 维特征,准确率 90.5%;改成 7×7 得到 49 维特征,PCA 降维到 15 维后准确率到了 93.2%。代价是特征提取时间多了不到一倍,但决策树训练依然很快。
第三个心得:尽量保留 PCA 降维后解释率排名靠前的特征,并检查它们的实际含义。我做过一次回溯分析,把 PCA 的 coeff 矩阵画成热力图,发现贡献最大的主成分主要对应数字图像中心的几个网格,这符合直觉——手写数字的结构差异主要在中心区域。如果你发现某个主成分主要受边缘网格控制,那很可能你的图片裁切边缘有大量噪声干扰。
第四个心得:交叉验证之前一定要固定随机种子。matlab 里用 rng(42) 一行代码就能做到。不要在没固定随机种子的情况下反复跑实验,那样每次结果都不一样,你很难判断一个参数的改动到底是"真的有效"还是"随机波动"。固定随机种子后,同参数下结果稳定可复现,调参效率大幅提升。
这套基于决策树的手写数字识别方案,完整跑通之后,后续的扩展路径也很清晰。把决策树换成随机森林或者 AdaBoost,几乎不用改前面的代码,精度还能提升一截;把 5×5 网格特征换成 HOG 特征,对笔画形状的刻画会更强;把二值化和定位换成更鲁棒的自适应阈值方案,就能应对更多样的现场拍摄图片。很多实际的数字识别项目,比如答题卡识别、票据编号识别,底层思路和这套流程是一样的,掌握这一套,再迁移到别的图像分类任务时就不会觉得无从下手。
