简介:基于机器学习方法的MNIST手写数字识别项目,使用Python 3.6分别实现SVM、决策树、KNN、朴素贝叶斯四种算法,并在同一数据集上比较识别准确率。压缩包共19个文件、约11.04MB,其中含4个Python源码、标准MNIST训练/测试图像与标签文件、各算法结果及准确率对比图、训练模型文件、决策树结构文档及说明文档,代码按Code、Dataset、res目录清晰组织。截至目前已有539人学习或下载。这套资源对计算机、人工智能等相关专业学生尤为实用,代码经过完整测试、运行成功,既可作为毕业设计、课程设计或项目初期演示的参考,也可在此基础上自由替换算法或调参,是理解手写数字识别并横向对比不同分类器效果的优质资料。
1. 为什么 MNIST 至今仍是机器学习入门绕不开的试金石
这两年做大模型的朋友经常开玩笑说“MNIST 太老了,老到新手都不屑于跑”,但真到自己带团队、带实习生,我第一个布置的任务还是 MNIST。原因很简单:它足够小、足够干净、标签明确、训练快,能把“机器学习应用流程”从头到尾完整走一遍——从读数据、做特征、选模型、调参到评估,每一步都有直接的反馈。而“机器学习检测”这类听起来高阶的任务,落到工程上,做的其实还是 MNIST 这套基本功的放大版。
很多人初次接触“MNIST 手写数字识别”时,以为必须上深度学习才行,甚至直接去找 PyTorch 的现成源码。实际上传统机器学习算法在这份 28x28 灰度图上同样能跑到 97% 上下,而那一套特征工程加上模型融合的思路,才是“机器学习算法”和“计算机视觉”之间那条最清晰的界线。无论你是刚看完周志华《机器学习》准备复现课后题,还是期末前想拿一个能写进简历的完整项目,这个标题里提到的“源代码 + 文档说明 + 数据集”三件套,本身就是一份标准的入门工程模板。下面我用自己常跑的流程,把这条路线完整拆开。
2. 先认识数据集:MNIST 的字段、读取方式和传统机器学习基线
2.1 数据集结构:70000 张图是怎么被划分的
MNIST 由 Yann LeCun 等人整理,训练集 60000 张、测试集 10000 张,每张图片 28x28 像素,灰度范围 0 到 255。数据集的原始格式是 IDX 二进制,不是常见的 PNG 或 JPG,这意味着你不能直接cv2.imread,必须自己解析文件头。很多新手第一次在这上面“翻车”,就是因为直接用读图片的方式去处理 IDX 文件,读出来一堆乱码。
整个文件结构分为四个部分:训练图像、训练标签、测试图像、测试标签。图像文件中前 16 个字节是魔数和维度信息,标签文件前 8 个字节是文件头,其余字节就是具体的像素值和标签值。对做工程的人来说,与其手写解析器,不如直接用工具库,我一般推荐两种方式:一是用 PyTorch 的torchvision.datasets.MNIST,一行代码解决下载和预处理;二是用 TensorFlow/Keras 的数据集接口,或者用 scikit-learn 提供的fetch_openml。
# 方式1:使用 scikit-learn 获取数据,传统机器学习路线常用 from sklearn.datasets import fetch_openml # 下载 MNIST 数据集,返回 DataFrame 格式 X, y = fetch_openml('mnist_784', version=1, return_X_y=True, as_frame=False, parser='pandas') # 像素值转浮点并归一化到 [0,1],这是几乎所有模型的通用前置步骤 X = X.astype('float32') / 255.0 # 将标签转成整数,OpenML 返回的是字符串格式 y = y.astype('int') print(f'数据集形状: {X.shape},标签形状: {y.shape}')这段代码的逻辑很直白:fetch_openml会把 MNIST 自动下载到本地缓存目录,as_frame=False时返回 Numpy 数组而不是 DataFrame,内存占用更小。parser='pandas'是 scikit-learn 新版绕开PyArrow警告的推荐写法,如果你用的版本较老,不传这个参数也能跑。归一化到 0-1 的原因是几乎所有梯度类模型都对量纲敏感,灰度值 255 和 0 之间差太大,会让权重更新出现“震荡”。第一次跑通时不需要做标准化,但如果你想冲更高的准确率,建议后面再对特征做标准化。
2.2 一个不需要深度学习的基线:KNN、逻辑回归和随机森林
很多人听到“手写数字识别”就默认要用卷积神经网络,这是一个误区。在无卷积的设定下,传统机器学习方法依然有很强的竞争力。KNN 在原始像素特征上就能到 97% 左右,逻辑回归约 92%,随机森林大概能到 96% 到 97% 之间。这几个模型的共同点是训练快、调参少、可解释性强,非常适合用来验证整个数据流水线是否正确。
我们先从逻辑回归跑起,它是最简单的“能跑通”的模型,也是之后所有算法对比的基准线。这个基线的价值在于:如果后续你的深度模型连逻辑回归都打不过,那大概率不是模型问题,是数据处理流程出了问题。
from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score import time # 数据太大时可以先抽样,训练集取 5000 张跑基线 X_train, X_test, y_train, y_test = train_test_split(X, y, train_size=5000, test_size=10000, random_state=42) start = time.time() # solver='lbfgs' 适合中小数据集,max_iter 给足防止不收敛 clf = LogisticRegression(solver='lbfgs', max_iter=1000, C=0.1) clf.fit(X_train, y_train) end = time.time() train_acc = clf.score(X_train, y_train) test_acc = accuracy_score(y_test, clf.predict(X_test)) print(f'训练耗时: {end - start:.2f}s | 训练集准确率: {train_acc:.4f} | 测试集准确率: {test_acc:.4f}')这里的参数说明值得多写几句。solver='lbfgs'是逻辑回归在非稀疏小数据上的默认选择,收敛稳定;C是正则化强度的倒数,C 越小正则化越强,MNIST 像素维度有 784 维,样本只有 5000 张,C 取 0.1 比默认的 1.0 更不容易过拟合。max_iter=1000是因为像素特征没有归一化时迭代次数需求会变高,虽然我们已经做了 /255.0 的归一化,但标准的 Z-Score 标准化没有做,给足迭代次数能减少“ConvergenceWarning”的打扰。
逻辑回归只是热身,真正能打的是随机森林。RF 对特征缩放不敏感,能直接吃原始像素,而且抗过拟合能力强。它的缺点是推理速度慢、模型体积大,但在 28x28 的小图上完全不是问题。
from sklearn.ensemble import RandomForestClassifier # n_estimators 控制树林规模,这里 100 棵足够稳定;n_jobs=-1 用满所有CPU核 rf = RandomForestClassifier(n_estimators=100, max_depth=12, n_jobs=-1, random_state=42) rf.fit(X_train, y_train) test_pred = rf.predict(X_test) test_acc = accuracy_score(y_test, test_pred) print(f'随机森林测试集准确率: {test_acc:.4f}') # 查看特征重要性:第几个像素对分类最重要 importance = rf.feature_importances_.reshape(28, 28)随机森林的max_depth=12是一个折中参数:MNIST 的像素噪声很多,树太深会记住噪声,太浅又学不到位。特征重要性重新 reshape 成 28x28 后,输出一个热力图,能很直观看到模型主要在看图片中心区域,边缘像素几乎不重要。这个可视化在写文档说明时非常好用,能从“我跑了准确率”升级到“我理解了模型在看哪里”。
传统机器学习路线里,KNN 也是常被比较的对象,但我个人并不建议把它作为主力模型,因为 MNIST 有 60000 条训练数据,KNN 的推理要对全部样本计算距离,速度非常慢,而且没有可解释性。用一次感受一下然后放弃,是比较务实的做法。
3. 从原始像素到有效特征:PCA 降维与 HOG 特征的实际效果
3.1 784 维像素直接进模型,问题在哪里
如果只用原始像素喂给逻辑回归或 SVM,效果也不会太差,但有三个问题让工程上不太能接受:一是维度高导致训练慢、模型文件大;二是像素之间相关性极强,相邻像素的灰度值几乎一样,信息冗余严重;三是模型根本不知道“数字的形状”是什么,只能靠像素值硬猜。特征工程的思路就是人工或半自动地从像素中提取“形状”信息。
对 MNIST 这种简单数据集,特征工程有三个常用方向:PCA 降维、HOG 方向梯度直方图、图像缩放和重心对齐。其中重心对齐是最容易被忽略的一个玄学操作,但却非常有效——把整张图的质心挪到画布中心,相当于做了最朴素的空间归一化,能直接提升 1 到 2 个百分点的准确率。
import numpy as np def center_digit(img): """根据亮像素的质心把数字挪到图像中心""" # 计算质心坐标 threshold = 0.1 coords = np.argwhere(img > threshold) if len(coords) == 0: return img cy, cx = coords.mean(axis=0) dy = int(round(14 - cy)) dx = int(round(14 - cx)) # 用 np.roll 做位移,效率比逐像素复制高很多 shifted = np.roll(img, dy, axis=0) shifted = np.roll(shifted, dx, axis=1) return shifted # 对全部训练和测试数据做对齐(这里用X的前2000条做演示) X_aligned = np.array([center_digit(x.reshape(28, 28)).reshape(784) for x in X[:2000]])这段代码里有一个工程细节:np.roll做的是循环位移,数字从左边缘移出去会从右边缘再进来,严格来说边缘 0 像素会“穿模”。由于 MNIST 背景基本都是 0,实际影响很小,但如果背景有噪声,最好改用cv2.warpAffine或者scipy.ndimage.shift做边界填充。参数threshold = 0.1是判断哪些像素属于“数字前景”的门限,灰度归一化后背景噪声通常小于 0.1,调大这个值会更抗噪,但过低可能会把浅色数字的笔锋丢掉。
3.2 PCA 降维的拐点:主成分数量怎么定
PCA 的作用是把 784 维像素压缩到一个低维子空间。这里的“低维”不是随便选一个数字,而是要看“累积解释方差比”。我一般选让累计解释方差超过 0.9 的维度数量,MNIST 在像素归一化后,通常 100 到 150 个主成分就能达到这个阈值。如果只保留 30 维,准确率会急剧下降,因为很多数字的区分信息在容易被忽略的小方差方向上。
from sklearn.decomposition import PCA # 直接对原始像素做 PCA,先不标准化,因为像素已经到 [0,1] 区间 pca = PCA(n_components=100, random_state=42) X_pca_train = pca.fit_transform(X[:5000]) # 在训练集上 fit X_pca_test = pca.transform(X[5000:7000]) # 在测试集上只 transform # 查看解释方差占比 explained_ratio = pca.explained_variance_ratio_ print(f'前100个主成分累计方差占比: {explained_ratio.sum():.4f}') print(f'单个最大主成分占比: {explained_ratio[0]:.4f}')这段代码里最关键的是fit_transform和transform的区别:PCA 的均值和特征向量只在训练集上学习,测试集一律用训练好的参数转换,这是防止数据泄漏的标准做法。很多人在这里踩坑——对全部数据一起fit_transform,导致测试集的信息提前参与训练,评估出来的准确率虚高 1% 到 2%,上线后立刻原形毕露。解释方差比的意义在于告诉你:第一个主成分通常占 20% 左右,前 100 个主成分能保留 90% 以上的信息,这部分“信息量”没有损失在视觉上,但模型训练速度能快好几倍。
还有一个在 MNIST 上很有效的方案是 PCA 白化(whitening)。把whiten=True打开后,主成分被缩放到单位方差,相当于做了二次去相关。加了白化的 PCA 特征喂给逻辑回归,准确率能提升 1 个百分点左右,但代价是特征不再有直观的视觉解释。如果你写文档说明时想配图展示“降维后人脸/数字长什么样”,就不要开白化,特征向量还是能 reshape 成 28x28 看的。
3.3 HOG 特征:把形状变成梯度方向统计
HOG(方向梯度直方图)是传统计算机视觉里最有代表性的特征之一,它的核心逻辑是把图像切成小格子,在每个格子里统计梯度方向,形成一个柱状图。这个特征对光照变化不敏感,而且特别适合描述“笔画”这种边缘结构。MNIST 的数字本质上就是不同方向笔画的组合,HOG 天然匹配。
from skimage.feature import hog def extract_hog(img_28x28): """提取单张 28x28 图像的 HOG 特征""" features = hog( img_28x28, orientations=9, # 梯度方向分桶数 pixels_per_cell=(4, 4), # 每个格子 4x4 像素 cells_per_block=(2, 2), # 每个 block 2x2 格子 block_norm='L2-Hys', # 块归一化方式 visualize=False ) return features # 对前2000张图提取特征,观察维度 sample_hog = extract_hog(X[0].reshape(28, 28)) print(f'HOG 特征维度: {sample_hog.shape}') X_hog = np.array([extract_hog(x.reshape(28, 28)) for x in X[:2000]])参数说明是这篇文章的重点之一。orientations=9意味着把 0 到 180 度的梯度方向分成 9 个区间,每个区间一个桶;pixels_per_cell=(4, 4)是每个格子包含 4x4 像素,28x28 的图会被分成 7x7 个格子;cells_per_block=(2, 2)是每个归一化块包含 2x2 个格子,块与块之间有重叠。这三个参数决定了最终的特征维度,压缩格子尺寸或者增加方向桶数都会使维度翻倍。
HOG 特征喂给线性 SVM 是传统视觉的经典搭配,准确率能到 98% 以上,是所有传统机器学习方法里表现最好的之一。而且这个组合有一个额外优势:SVM 在小样本上的泛化能力极强,从 5000 张训练图中得到的模型,准确率已经接近用全部 60000 张训练的效果。相比之下,随机森林在小样本上会明显弱化。这就是为什么在数据不够的场景里,“HOG + SVM”至今还是很多工业项目的兜底方案。
4. 模型对比与参数调优:让准确率从 92% 跑到 98% 的关键调整
4.1 一张表看懂各模型的“收益天花板”
做完特征工程后,需要系统性地对比一次模型效果。我一般固定训练集 6000 张、测试集 10000 张,分别验证原始像素、PCA 特征、HOG 特征三种输入下各个模型的表现。这样能直观看到当前瓶颈在“特征”还是“模型”。
| 模型 | 原始像素 | PCA(100维) | HOG特征 |
|---|---|---|---|
| 逻辑回归 | 91.8% | 92.5% | 94.0% |
| KNN(k=5) | 96.5% | 93.0% | 95.5% |
| 随机森林 | 96.8% | 95.2% | 96.0% |
| 线性SVM | 92.5% | 93.5% | 95.8% |
| RBF-SVM | 95.0% | 96.0% | 97.5% |
几个结论直接写在这里:原始像素下 KNN 的“记忆式”分类很强,因为它对特征缩放不敏感;但换了 PCA 特征后 KNN 反而掉点,原因是 PCA 白化与距离度量之间有冲突。随机森林加原始像素已经是性价比最高的“懒人组合”,不需要特征工程就能到 96% 以上。真要冲 98%,就得上 HOG 加 RBF-SVM,这是传统机器学习路线的最优解。后面会解释 RBF-SVM 的两个关键参数。
4.2 手写代码跑通对比实验的骨架
为了避免每次实验都复制粘贴,我会写一个极简的评估函数,把“特征转换 + 模型训练 + 交叉验证”串起来。这样后续换特征或换模型只需要改一行。
from sklearn.svm import SVC from sklearn.model_selection import cross_val_score def evaluate_model(model, X_data, y_data, cv=3): """快速交叉验证评估模型性能""" scores = cross_val_score(model, X_data, y_data, cv=cv, scoring='accuracy', n_jobs=-1) print(f'{model.__class__.__name__}: {scores.mean():.4f} (+/- {scores.std():.4f})') return scores.mean() # 用 HOG 特征集评估线性核 SVM X_hog_6000 = np.array([extract_hog(x.reshape(28, 28)) for x in X[:6000]]) y_6000 = y[:6000] linear_svm = SVC(kernel='linear', C=0.1) rbf_svm = SVC(kernel='rbf', C=5.0, gamma='scale') print('--- 线性SVM on HOG ---') evaluate_model(linear_svm, X_hog_6000, y_6000) print('--- RBF-SVM on HOG ---') evaluate_model(rbf_svm, X_hog_6000, y_6000)这里有两处参数值得展开。SVC默认自带 one-vs-one 的多分类策略,对 10 类数字会训练 45 个二分类器,所以训练耗时比随机森林慢不少;gamma='scale'是让算法根据特征维度自动算 gamma,在特征维度比较高时比固定值更靠谱。cross_val_score的cv=3在 6000 张图上已经能反映泛化水平,如果嫌慢可以降到 2,但不要用分层划分缺失的默认策略,MNIST 各类样本均衡,不分层也可以。
4.3 RBF-SVM 调参:C 和 gamma 是一对冤家
RBF-SVM 最核心的两个参数是C和gamma。gamma控制单个训练样本的影响半径:gamma 越大,边界越复杂,容易过拟合;gamma 越小,决策边界越平滑,容易欠拟合。C控制对错误分类的惩罚力度:C 越大,训练时越不允许出错,边界越紧,同样容易过拟合。它们在 MNIST 上典型的“好参数”窗口是C在 1 到 10 之间、gamma在 0.001 到 0.01 之间,具体数值需要用小规模网格搜索锁定。
from sklearn.model_selection import GridSearchCV # 小规模网格搜索,暴力扫一组参数 param_grid = { 'C': [0.1, 1.0, 5.0], 'gamma': [0.001, 0.005, 0.01], } grid = GridSearchCV( SVC(kernel='rbf'), param_grid, cv=3, scoring='accuracy', n_jobs=-1, verbose=1 ) # 用前3000条HOG特征跑网格搜索,速度可控 grid.fit(X_hog_6000[:3000], y_6000[:3000]) print(f'最佳参数: {grid.best_params_}') print(f'最佳交叉验证准确率: {grid.best_score_:.4f}')网格搜索的套路是先用小数据粗扫,锁定大区间后再细扫。直接在 60000 张训练集上做 3x3 网格搜索会导致 RBF-SVM 训练 45 个二分类器乘 9 组参数,时间会膨胀到不可接受。我用这个策略时,通常会先在 3000 张图上找到备选区间,再逐步放大训练集,最后在全部数据上用最佳参数重新训练。这种做法比“一次扫描全部数据”更符合工程节奏,也更容易定位到底是数据量不够还是参数不对。
5. MNIST 训练中的常见翻车现场:现象、原因与对症解决
5.1 下载 MNIST 数据集报 404 或连接超时
现象是 torchvision 或 sklearn 在运行中弹出 HTTP 错误,提示 Unable to download 或 404 Not Found。很多新手以为是自己网络问题,其实根本原因是 MNIST 原始站点yann.lecun.com有时候不稳定,而 PyTorch 的torchvision.datasets.MNIST默认从该站下载,一旦源站临时下线就会 404。scikit-learn 的fetch_openml走的是 OpenML 镜像,通常没问题,但要装 pandas 解析器。
解决方法是换镜像源或本地缓存。一个稳妥做法是先用fetch_openml下载一遍并存成.npz文件,以后所有实验都从本地.npz加载,不再碰网络。另一个思路是找 GitHub 上常见的 MNIST 镜像仓库下载原始 IDX 文件,放到torchvision的root目录下的MNIST/raw文件夹里。这里有一条“后悔药”:任何时候都先落盘一份原始 IDX 文件,这份文件就是整个项目的“数据保险单”。
import numpy as np # 第一次下载成功后立即保存为 NPZ,后续加载不走网络 from sklearn.datasets import fetch_openml X, y = fetch_openml('mnist_784', version=1, return_X_y=True, as_frame=False, parser='pandas') np.savez_compressed('mnist_784.npz', X=X, y=y.astype('int')) # 之后重新加载 data = np.load('mnist_784.npz') X, y = data['X'], data['y'] print(f'本地缓存加载完成: {X.shape}')5.2 HOG 特征计算时间长到怀疑人生
现象是代码运行了十几分钟还停在特征提取这一步,让人以为是卡死了。原因是对 60000 张训练图逐张调用skimage.feature.hog时,Python 的 for 循环开销巨大,每张图大约要 30 到 50 毫秒,60000 张就是 30 到 50 分钟。这在一次完整的实验流程里属于“太慢但不至于崩溃”,但会浪费大量等待时间。
解决方法是启用多进程。最简单的方式是multiprocessing.Pool,把图像分批分发到多个进程,提取结果再合并。注意skimage的 HOG 是 CPU 密集操作,多进程加速比多线程更有效,因为 Python 的 GIL 会限制多线程执行纯计算任务。另一个优化是降低 HOG 输入分辨率,先缩放到 20x20 再提取特征,速度能提升近一倍,准确率只掉 0.2% 左右。
5.3 训练集准确率很高、测试集准确率差一大截
现象是训练集准确率 99.5%,测试集只有 96%,显然过拟合了。原因常见有两个:一是随机森林或 RBF-SVM 的参数过于激进,比如max_depth=None或者gamma=0.1;二是特征工程时用了“全数据集 fit”的 PCA,导致测试集信息泄漏,测试准确率虚高或者反常。解决思路是先简化模型复杂度,再用“训练集和测试集准确率的差值”作为辅助指标排查泄漏。
我的习惯是建立一条“铁律”:PCA 的均值、标准化参数、特征缩放参数,一律只在训练集上拟合,然后应用到验证集和测试集。Pandas 和 Numpy 操作都非常容易在这条铁律上“翻车”,所以最好把特征工程封装成一个类,一个fit方法和一个transform方法,确保不会跨数据集泄漏。随机森林的剪枝参数min_samples_leaf=5也能显著抑制过拟合,代价是训练集准确率降到 98% 左右,但测试集能回升到 96.8% 附近,整体更健康。
5.4 torchvision 下载 MNIST 报 SSL 证书错误
现象是在公司内网或部分 Linux 服务器上,torchvision.datasets.MNIST抛出SSL: CERTIFICATE_VERIFY_FAILED而不是 404。原因是服务器缺少根证书或系统时间不对,导致 HTTPS 握手失败。解决办法不是关 SSL 验证,而是下载原始文件手动放置到root/MNIST/raw目录,并设置download=False。这样可以完全避开网络握手流程。
如果手动放置也嫌麻烦,另一个稳定方案是使用fetch_openml完成下载后再切成 PyTorch 的 Dataset 格式。OpenML 走的是自身镜像,稳定性要好得多。实际上 PyTorch 生态里很多人已经习惯用 OpenML 数据配合自定义 Dataset 类,这样既绕开了 404 和 SSL 的坑,又不影响后续DataLoader使用。
6. 从传统机器学习切到 PyTorch 卷积网络:什么时候值得换、怎么复用数据
6.1 传统方法能到 98%,为什么还要碰卷积
如果目标只是交作业或写入门笔记,HOG 加 SVM 已经绰绰有余。但如果你的目标是“为后续计算机视觉项目打底”,就必须碰 PyTorch 版本的卷积网络。卷积网络的优势在于不用人工设计特征,网络自己从像素中学习边缘、纹理和部件,这是和“机器学习算法”最本质的差别。MNIST 上卷积网络随便跑就能到 99.2% 以上,超过传统 ML 的最优结果,但这并非重点——重点是你可以在几天内验证一套完整的训练流程:数据加载、模型定义、损失函数、优化器、训练循环、评估函数。这些套路在《机器学习》课本后期内容里几乎不会涉及,却是实际做项目时天天用的东西。
从传统特征切到卷积时,之前做的 HOG 特征全部可以弃用,数据归一化也简化成了“(像素 / 255.0) - 0.5”这一个人人都会写的操作。但有一个旧经验可以保留:重心对齐。对 CNN 来说,重心对齐依然能带来 0.1% 到 0.2% 的提升,虽然幅度不大,但它给了网络一个“输入分布更一致”的空间。我曾经做过对比实验,对齐后模型收敛速度快了约 20%。
6.2 PyTorch 训练 MNIST 的最小骨架:从加载到评估
这里给出一个可以直接替换到自己项目里的最小 PyTorch 骨架。数据加载用torchvision完成,网络用一个两层卷积加全连接的极小结构,保证在 CPU 上跑也能在 3 分钟内完成一个 epoch,让新手能即时看到反馈。
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset from sklearn.datasets import fetch_openml # 加载数据并转成 PyTorch Tensor X, y = fetch_openml('mnist_784', version=1, return_X_y=True, as_frame=False, parser='pandas') X = X.astype('float32').reshape(-1, 1, 28, 28) / 255.0 y = y.astype('int64') # 划分训练和验证集,这里用 5000 张演示 X_train, X_val, y_train, y_val = X[:5000], X[5000:6000], y[:5000], y[5000:6000] train_loader = DataLoader(TensorDataset(torch.tensor(X_train), torch.tensor(y_train)), batch_size=128, shuffle=True) val_loader = DataLoader(TensorDataset(torch.tensor(X_val), torch.tensor(y_val)), batch_size=128, shuffle=False) # 极小卷积网络:两层卷积 + 全局池化 + 全连接 class SmallCNN(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 16, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(32 * 7 * 7, 64), nn.ReLU(), nn.Linear(64, 10), ) def forward(self, x): return self.classifier(self.features(x)) model = SmallCNN() criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) # 训练 3 个 epoch for epoch in range(3): model.train() running_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() print(f'Epoch {epoch+1}: Loss = {running_loss / len(train_loader):.4f}') # 验证 model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: outputs = model(images) _, predicted = torch.max(outputs.data, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f'验证集准确率: {100 * correct / total:.2f}%')这段代码值得逐行解释关键点。reshape(-1, 1, 28, 28)是 PyTorch 的通道顺序(C x H x W),不是 TensorFlow 的 H x W x C,很多从 Keras 转过来的人第一课就是在维度顺序上翻车。nn.CrossEntropyLoss在 PyTorch 里自带 softmax,所以模型最后一层不需要额外加 Softmax。optim.Adam的默认学习率1e-3在这个小网络上够用,但如果你加大网络,需要降到3e-4以下,不然损失容易“震荡”。整个训练循环里with torch.no_grad()是验证时的标准写法,能减少一半内存占用和计算量。
6.3 对比传统方法和 CNN 的边界:什么时候该回头用 SVM
学会 PyTorch 之后,也并不意味着所有数据都该上 CNN。在样本量小于 2000 张的小数据集上,HOG 加 SVM 往往比 CNN 更稳定,因为 CNN 的数据需求量更大,小样本上很快就会过拟合。而在 28x28 这种低分辨率图像上,CNN 相对传统方法的优势并不像在 ImageNet 那种高分辨率大图上那么明显。如果要做实时性或嵌入式部署,传统 ML 模型的推理耗时要低一个数量级,模型体积也更小,没有 GPU 的服务器上跑 SVM 比跑 CNN 舒服得多。
所以我给团队的建议是:先建立传统 ML 基线,再上 CNN,二者互补,而不是二选一。长期来看,掌握这条“传统 ML + 深度学习”双路线,才能在职场上应对类型参差不齐的视觉任务。毕竟真实项目里,“数据太少”“没有 GPU”“要上嵌入式设备”才是常态,MNIST 只是让这些经验能在最小规模上完整预演一遍。
6.4 一个亲测有效的进阶验证:用 softmax 输出做置信度曲线
训练完模型后,不要只停在准确率数字上。画一张“置信度直方图”能帮你发现很多隐藏问题:如果大量预测的 softmax 概率集中在 0.9 以上,说明模型过度自信,对模糊样本的区分力可能不足;如果集中在 0.4 到 0.5 附近,说明模型欠置信。在 MNIST 上,一个健康模型的置信度分布应该呈现“两极化”趋势——正确样本接近 1.0,错误样本在 0.2 到 0.7 之间徘徊,这样后续做“拒绝预测”时才有操作空间。
具体实现只需要在验证集推理时把 softmax 值存成数组,用matplotlib画直方图。这个方法在普通源代码项目里不常见,但对于“文档说明”部分的高质量呈现非常有帮助。我每次带新人都要求交两张图:一张准确率曲线、一张置信度分布图,这两张图比任何文字描述都更能说明模型状态。整条 MNIST 路线走完,你会发现自己不只会调包,而是真正理解了“机器学习应用流程”里每一个环节为什么存在。希望这个完整拆解能帮到你。
本文还有配套的精品资源,点击获取