如果你处理过手写数字识别USPS数据集应该不会陌生。它来自美国邮政系统对信封上手写数字的扫描图尺寸小、分辨率低但噪声和形变非常真实是图像分类入门绕不开的经典数据。我最早一次拿它练手是在某实验室做数字识别Demo当时第一版模型效果很差后来才发现问题不在模型而在数据处理环节太粗糙。这篇文章就把USPS数据集处理的全过程拆开讲一遍怎么正确读取、可视化分析、预处理、特征工程、划分数据集以及用SVM和CNN验证处理效果最后把常见的坑也一起列出来。想把手写数字数据集从原始文件变成可训练、可评估的干净数据这篇应该能给你一条可以直接照做的路径。1. 先认识数据集它到底长什么样、为什么适合练手1.1 数据来源和基本构成USPS数据集全称是United States Postal Service手写数字数据集内容是0到9这10个数字类别的灰度扫描图。和MNIST不同这组数据不是从MNIST里随便抽出来的而是直接来自邮政分拣场景也就是说它的手写风格更随意笔画断裂、倾斜、噪声也更自然。我第一次看到它的样本图时第一反应是这比MNIST难认多了很多数字边缘模糊甚至人眼看都要犹豫一下。常见的USPS数据集版本包含7291个训练样本和2007个测试样本每张图是16x16像素的灰度图像素值范围落在0到255之间也有处理成0到1的变体。数据文件通常以.mat格式保存里面有几个键名比如train_data、train_labels、test_data、test_labels直接可以用科学计算库读取。不过需要提醒一句由于这个数据集流通较广网上也存在重打包的csv版本、npy版本字段命名可能不一样拿到手先不要急着训练第一步永远是检查数据结构和值的范围。训练集和测试集的类别分布相对均匀每个数字大概有700个左右的训练样本、200个左右的测试样本。对深度学习来说这个量级很小但正因为小适合用来快速验证一个思路数据预处理有没有做对特征提取有没有效果模型设计是否合理。它不是用来刷榜的数据集而是用来练手和踩坑的数据集。1.2 与MNIST的差异和选型理由很多人会把USPS和MNIST混为一谈两者确实非常像都是灰度手写数字都是28x28或更小尺寸甚至有些模型代码可以互相换数据集跑。但差异也很关键。MNIST是28x28每张图有784个像素USPS只有16x16也就是256个像素。信息量少了三分之二但识别难度并没有成比例下降。因为MNIST经过了一定程度的居中裁剪数字主体更规整USPS则保留了更多扫描原始状态边缘噪声、笔画残缺更明显。从实际工程角度看处理USPS更贴近真实OCR场景输入图像质量不稳定目标区域不总是居中对比度也高低不一。选USPS做练习还有一个理由它数据量小形态够老但又不像纯玩具数据那样随便一个线性模型就能轻松刷到高分。想拿到好的结果必须在预处理和特征表达上下功夫这恰好是很多人跳过的环节。我见过不少人在MNIST上跑CNN跑得很顺换成USPS就崩原因就是MNIST太好欺负了即使不做数据归一化都能有不错的准确率而USPS会对这些偷懒行为给出真实的反馈。2. 数据加载与第一次摸底2.1 从.mat文件读取数据拿到USPS数据集的原始.mat文件后首先要确认文件的内部结构。一般我会用一小段代码做一个快速探测把键打印出来看看到底有哪些字段每个字段的形状是什么。不要想当然地认为train_data一定就是训练数据有的版本会包一层结构体有的会带着额外的元信息。import scipy.io as sio import numpy as np mat sio.loadmat(usps.mat) for key, value in mat.items(): if not key.startswith(__): print(key, value.shape, value.dtype)如果输出里出现了train_data、train_labels、test_data、test_labels那么处理起来就很直接train_data mat[train_data] train_labels mat[train_labels] test_data mat[test_data] test_labels mat[test_labels]这里有个特别容易踩的坑有的.mat版本里train_data的形状不是(7291, 256)而是(256, 7291)需要转置标签也不是一维数组而是(7291, 1)的二维列向量直接拿去给某些模型运行时会报维度错误或者被当成多标签任务。所以在读取后第一件事就是统一数据形状和标签形状。我的习惯是统一成(样本数, 特征数)和(样本数,)if train_data.shape[0] ! train_data.shape[1]: train_data train_data.T if train_labels.ndim 2: train_labels train_labels.ravel()这一步看起来简单但能避免后面90%的维度错误。我见过有人在这上面浪费了半天最后发现只是原始数组是行向量的坑。2.2 摸清形状、类别和像素分布读取成功之后不要急着做预处理先做一次“体检”。具体来说要看三个东西数据形状是否和预期一致每个类别的样本数量是否均衡像素值的取值范围到底是多少。print(train_data shape:, train_data.shape) print(test_data shape:, test_data.shape) train_unique, train_counts np.unique(train_labels, return_countsTrue) print(train label distribution:, dict(zip(train_unique, train_counts))) print(pixel min:, train_data.min(), pixel max:, train_data.max(), pixel dtype:, train_data.dtype)如果像素值范围是0到255我建议后续转成浮点并归一化到0到1如果已经是0到1那就不用重复归一化。有些数据集在保存时会把像素值存成0到2之间的值表示背景、模糊边缘和前景这种数据就不能用常规的全局归一化去处理要优先看可视化结果。可视化也很重要。把每个类别的数字各抽出几张拼成一张画布用matplotlib显示。不要省这一步因为只有实际看到图像长什么样才能判断预处理到什么程度是合适的。我记得第一次可视化时发现某些样本的背景并不是纯白而是有灰度噪点边缘还带一些扫描条纹如果不看这些细节直接写死阈值为0或者255就会出问题。import matplotlib.pyplot as plt def show_images(data, labels, n_per_class5): fig, axes plt.subplots(10, n_per_class, figsize(n_per_class * 1.5, 15)) for i in range(10): indices np.where(labels i)[0][:n_per_class] for j, idx in enumerate(indices): img data[idx].reshape(16, 16) axes[i, j].imshow(img, cmapgray) axes[i, j].axis(off) plt.show() show_images(train_data, train_labels)这一步做完你对数据的整体手感就出来了。比如有的数字在图像中只占了小部分区域四周留白很多有的数字笔画特别细像用极细的笔尖写的还有的数字边缘出现抗锯齿过渡带如果直接二值化就可能把过渡带丢掉。3. 图像预处理让16x16的小图发挥最大价值3.1 灰度归一化的细节USPS的像素值和MNIST一样是灰度值但规范化方式会直接影响训练稳定性和最终指标。常见做法有三种除以255、均值方差标准化、像素值缩放到0到1。对于CNN来说除以255通常就够用对于SVM这类距离敏感的模型标准化到均值为0、方差为1往往更好。我的实操习惯是先除以255转成浮点再考虑是否需要标准化。除以255之后背景像素一般会落在0.9到1.0之间数字笔画落在0到0.3之间但这只是一个粗略描述具体要看你的数据版本。如果在二值化后发现数字笔画断开严重就不要急着做二值化保留灰度过渡反而对模型更友好。有一个细节值得单独写一下乘以255后如果某些图像在保存时用了sRGB非线性编码那像素值的线性度和真实亮度不完全一致。但USPS不是高质量摄影数据集没必要做sRGB解码直接当线性灰度处理即可。这个细节不算问题但知道就好免得在研究像素分布时被奇怪的直方图形状带偏。train_data train_data.astype(np.float32) / 255.0 test_data test_data.astype(np.float32) / 255.0如果后面要跑SVM我会再加上无量纲化from sklearn.preprocessing import StandardScaler scaler StandardScaler() train_data_scaled scaler.fit_transform(train_data) test_data_scaled scaler.transform(test_data)注意标准化一定要在划分出验证集之后进行而且只能用训练集的数据去fit。如果提前把整个训练集加测试集一起标准化那测试集的信息就提前泄露进去了评估结果会虚高。这个错误在初学者里非常常见评估起来“准确率99%”一上真实场景就打回原形。3.2 去噪和边缘增强的实验16x16的图像分辨率太低做复杂的去噪其实很容易把有用的笔画信息也去掉。很多人一上来就想着用高斯滤波结果数字边缘模糊小尺寸图像的信息被进一步破坏训练准确率反而下降。对USPS来说去噪要非常克制一般只做两件事去除孤立的背景噪点以及可选的中值滤波。我比较推荐先观察直方图分布如果背景区域有明显的孤立噪点可以用一个小尺寸的中值滤波比如3x3来去除。中值滤波对斑点噪声效果比均值滤波好而且对边缘的保留能力强一些。但如果原图本身噪声不严重这一步完全可以跳过。另一个有用的操作是边缘增强。小尺寸数字图像的识别难点在于笔画边界不清晰可以通过Prewitt或Sobel算子提取梯度信息然后把原始灰度图和梯度图在特征层面拼接起来相当于给模型额外提供了一组“轮廓特征”。我记得在某次实验中把灰度图加横向和纵向梯度特征拼在一起后SVM的准确率提升了大约1.2个百分点这个提升在USPS上已经不少了。不过要说明边缘增强不一定总是正向收益尤其是在CNN里网络自己会学边缘滤波器人为再拼梯度图可能只是增加计算量。所以我通常会把这招用在传统机器学习模型里深度学习则简单归一化后直接喂进去。处理数据没有绝对标准多对比几次才知道哪种组合适合你的特征结构。3.3 数据增强的尺度控制看到USPS只有7291个训练样本很多人第一反应是做数据增强。但我对USPS做增强非常谨慎因为原始分辨率只有16x16很多常规增强操作会产生严重副作用。我做过的增强实验包括微小旋转、水平/垂直平移、随机缩放。最关键的是幅度要小。旋转角度控制在正负5度以内平移控制在1到2个像素左右缩放控制在0.9到1.1之间。一旦加大旋转到15度很容易出现“7”和“1”混淆加大缩放后笔画粗细分不清反而破坏原始数据分布。实操时我建议对增强后的样本做一次可视化抽检。每生成一批新数据把增强前后的图像放在一起对比确认语义没有发生变化。如果你看到一张被旋转成横躺的“6”或者被拉伸到比例失调的“9”那就说明增强参数设置过头了。数据增强的正确做法是先对训练集进行增强测试集保持原样。千万不要把测试集增强后拿去评估那会让指标失真。另外增强样本通常和原始样本混在一起训练但要注意验证集应该从增强前的原始训练集中划分不包含增强样本避免伪造分布。4. 特征工程与数据划分4.1 扁平化特征与PCA降维最直接的特征表达是把16x16图像展开成256维的向量。对SVM来说256维直接输入通常也可以跑但PCA降维后往往能去掉一些无关噪声速度和稳定性都有提升。使用PCA前要先选择保留多少方差。一般的经验是保留90%到95%的累计方差。我拿USPS做过实验256维数据保留95%方差时大约需要40到60个主成分此时训练速度明显加快SVM准确率下降通常不到0.3%有时候甚至会略微上升因为PCA去掉了部分背景噪声导致过拟合的部分。from sklearn.decomposition import PCA pca PCA(n_components0.95) train_pca pca.fit_transform(train_data_scaled) test_pca pca.transform(test_data_scaled) print(PCA保留维度:, train_pca.shape[1])注意PCA是用缩放后的训练数据拟合出来的然后同时转换训练集和测试集。这个过程里测试集的均值方差都来自训练集不存在信息泄露。如果先对所有数据做PCA再划分又会埋下隐患。有一类问题容易被忽略PCA适合线性降维对图像的全局结构提取效果一般。如果你后面要接CNN那就不要做PCA直接保留空间结构信息。PCA更多是配合SVM、逻辑回归这类模型使用。4.2 引入HOG特征加强轮廓表达如果想要比扁平化像素更强的特征HOG方向梯度直方图是一个很适合手写数字的选择。HOG描述的是局部区域的梯度方向分布对光照变化和轻微形变有不错的鲁棒性。16x16的图像虽然小但依然可以提取HOG特征。在sklearn里可以直接用hog函数。针对USPS我常用参数是pixels_per_cell(4, 4)cells_per_block(2, 2)orientations9。这个配置会把16x16图像划分成4x4个block每个block里再计算方向直方图最终特征维度不算太高信息量比原始像素更集中。from skimage.feature import hog def extract_hog_features(data): features [] for img in data: img_2d img.reshape(16, 16) feat hog(img_2d, orientations9, pixels_per_cell(4, 4), cells_per_block(2, 2), block_normL2-Hys) features.append(feat) return np.array(features) train_hog extract_hog_features(train_data) test_hog extract_hog_features(test_data)提取HOG时要统一输入图像尺寸。如果对数据做过不同方式的resize那提取出的HOG特征维度也会不一样放进同一个模型就会报错。另外HOG的区块大小直接决定了特征粒度4x4的cell对16x16图像来说能保留较多局部细节换成8x8就太粗了数字笔画的起止位置很容易丢失信息。4.3 训练集、验证集和测试集的划分策略原始USPS通常自带训练集和测试集但我们做实验时不能依赖这个固定划分来做模型选择尤其是需要反复调参时建议从原始训练集中再切出一部分验证集。我的划分策略是原始训练集7291个样本按8:2拆成训练子集约5833个和验证集约1458个测试集2007个样本完全不动。验证集用来对比不同预处理方案、不同超参数最后确定最佳模型后再用全部原始训练集训练一次然后在原始测试集上做最终评估。如果过程中使用早停法比如深度学习里的EarlyStopping那验证集必须保留用于确定早停时机不能提前合并。划分时要保证类别均衡。sklearn的train_test_split默认会做stratify分层要显式指定from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val train_test_split( train_data_scaled, train_labels, test_size0.2, random_state42, stratifytrain_labels )random_state固定一个值很重要这样别人复现你的实验时得到的数据划分是一致的。分层采样能防止某一类数字在验证集中数量过少导致评估指标波动。USPS本身的类别分布比较均衡但小型数据集里随机划分也可能出现偏差分层是必须的。5. 用SVM和CNN各跑一遍验证处理效果5.1 先上SVM做基线数据处理到底做得怎么样用一个经典模型跑出基线就知道了。对于256维图像特征RBF核的SVM是个不错的起点。它不像深度网络那样需要大量调参但能体现特征工程的效果。在训练SVM前我习惯先对特征做标准化然后用网格搜索找C和gamma。因为数据量不到6000用GridSearchCV暴力搜索也能接受。一个比较稳的搜索范围是C取0.1到10gamma取0.001到0.1。from sklearn.svm import SVC from sklearn.model_selection import GridSearchCV param_grid {C: [0.1, 1, 5, 10], gamma: [0.001, 0.01, 0.05, 0.1]} svm SVC(kernelrbf) grid GridSearchCV(svm, param_grid, cv3, scoringaccuracy, n_jobs-1) grid.fit(X_train, y_train) print(最佳参数:, grid.best_params_) print(验证集准确率:, grid.best_estimator_.score(X_val, y_val))我拿原始灰度特征跑完一遍SVM验证集准确率大概在93%到94%左右。如果换成HOG特征不调参的情况下能提升到95%到96%。这说明对USPS来说梯度方向信息确实比原始像素值更有判别力。这个对比也说明传统模型遇到低分辨率图像时特征工程的价值依然很大。5.2 用一个小尺寸CNN验证深度模型的兼容性CNN处理16x16图像时不需要设计太深的网络。输入太小一旦下采样次数过多特征图最后会缩成1x1信息全丢了。我给USPS设计的CNN非常简单输入(16,16,1)第一个卷积层8个3x3卷积核第二个卷积层16个3x3卷积核每层后面接BatchNormalization和ReLU然后做一次2x2最大池化展平后接一个128维的全连接层最后softmax输出10类。from tensorflow.keras import layers, models model models.Sequential([ layers.Input(shape(16, 16, 1)), layers.Conv2D(8, 3, paddingsame, activationrelu), layers.BatchNormalization(), layers.MaxPooling2D(2), layers.Conv2D(16, 3, paddingsame, activationrelu), layers.BatchNormalization(), layers.MaxPooling2D(2), layers.Flatten(), layers.Dense(128, activationrelu), layers.Dense(10, activationsoftmax) ]) model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy]) model.summary()训练时batch_size取32或者64都可以epoch大概20轮搭配EarlyStopping监控验证集准确率。我通常在数据增强打开后训练曲线会比无增强更稳但也不会出现巨大的提升。在这个数据集上CNN的准确率大概在96%到98%之间相比SVM优势有但没有MNIST上那么夸张。CNN对数据形状的要求是(N, 16, 16, 1)需要在输入前做reshape。如果训练数据是(N, 256)一定要显式转换而不是直接丢给模型。同时标签如果是int用sparse_categorical_crossentropy损失不需要做one-hot编码如果已经做了one-hot就要改成categorical_crossentropy否则可能莫名其妙报错。5.3 结果对比和评估指标选择把常见方案放到一起才能看清每种数据处理方式的影响。我列一个自己跑出来的对比参考不代表每个版本的USPS都完全一致但量级和趋势可以给你一个预期。方案特征处理验证集准确率测试集准确率原始像素 SVM归一化后直接展平约93.2%约92.8%PCA(95%) SVM标准化后PCA降维约93.0%约92.5%HOG SVMHOG特征标准化约95.8%约95.2%原始像素 CNN归一化后reshape约96.5%约96.1%增强 原始像素 CNN小幅度增强约97.2%约96.8%看表格不能只看准确率还要关注验证集和测试集之间的差距。如果验证集高、测试集低很多说明过拟合或者测试集数据分布有差异。对于USPS这种规模小的数据集相差1.5个百分点以内算正常超过两个百分点就要仔细检查是否有泄漏或样本覆盖不均。除了准确率我建议至少再看混淆矩阵。手写数字识别中有些类别经常互相混淆比如9和4、7和1、3和5。只看准确率发现不了这些细节混淆矩阵可以帮你定位是数据标注问题还是模型判别力问题。from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay train_pred grid.best_estimator_.predict(X_val) cm confusion_matrix(y_val, train_pred) disp ConfusionMatrixDisplay(confusion_matrixcm) disp.plot()6. 我踩过的那些坑USPS数据处理的常见问题6.1 标签维度不匹配训练时维度爆炸这个坑几乎每个处理.mat版本的人都可能遇到。原始标签常常是(7291, 1)的二维列向量而sklearn的fit接口要求标签是一维数组。直接fit往往不报错但部分模型会把它当成多输出问题训练出来的结果完全失控。另一种极端是标签直接是0到9以外的数比如把1映射到0、2映射到1需要查看元信息确认编码方式。我的检查技巧是在训练前做一个断言assert y_train.ndim 1, 标签应该是一维数组 assert set(y_train) set(range(10)), 标签类别不是0-9如果断言失败就用ravel()或者reshape(-1)转换。转换后再次检查train_labels和test_labels的类别集合是否完全一致防止测试集缺类别或者多出异常类别。6.2 归一化范围搞错模型一直不收敛USPS数据版本多有的像素值范围是0到255有的是0到1还有的经过特殊处理是-1到1附近。如果你直接用除以255处理一个本来就归一化的数据值会变得非常小CNN的前向传播输出也会变得极小loss下降缓慢。反过来如果数据是0到255但你没归一化就放进SVM距离计算会被大数值特征主导准确率会低得离谱。判断方法是先打印min和max看和你的预期是否一致。然后分别用0到1和标准化两种方式跑一个简易模型对比验证集的收敛情况。我一般建议深度学习优先用0到1归一化传统机器学习优先用StandardScaler实验到后面就不会在范围上翻车。6.3 数据增强过度导致语义漂移我在一次实验里把旋转角度设成正负15度训练完以后发现测试准确率反而比无增强低检查增强后的样本发现很多“7”被旋转成了斜躺的“L”还有“2”被轻微缩放后看起来像“1”。人眼看这些增强样本都觉得变味模型学到的自然就是错误映射。正确做法是先在原图上做小范围几何变换然后随机抽24张增强图人工检查。如果某类数字的增强样本里出现明显语义歧义就要调小对应参数范围。USPS数据集的像素少任何变换都容易产生插值伪影所以增强幅度必须比MNIST更保守。6.4 评估时只看准确率忽略混淆矩阵我在一开始做结果汇总时只看准确率结果模型在9和4之间反复错分整体准确率还说得过去但落到具体场景时这类错误就不可接受。尤其是在“识别单个数字”的任务里一个9被当成4可能导致后续业务逻辑完全走错。后来我把混淆矩阵打印出来才发现test数据里9错分为4的样本占了所有错误样本的30%以上。因此处理USPS数据后做评估至少要查看两类信息每个类别的精确率和召回率以及混淆矩阵中所有非对角线元素的数量。如果某个类别的召回率明显低于其他类别优先去查看该类别的原始图像判断是数据噪声、标注问题还是预处理损坏了特征。这个排查过程往往比单纯调模型参数学到更多。个人经验是处理USPS数据集数据清洗和特征表达的重要性远超模型选型。我后来把同样的预处理思路迁移到其他低分辨率单据识别上效果依旧稳定。如果你也在为小尺寸手写数字数据集发愁先从像素分布和可视化开始再做克制的增强最后用混淆矩阵收尾基本就能把准确率稳定在一个可信的水平上。