尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

KNN中文手写识别实战:ChineseMNIST数据集与PCA降维调优全解析

KNN中文手写识别实战:ChineseMNIST数据集与PCA降维调优全解析 简介面向Python初学者与机器学习实践者的中文手写字符识别实战资源基于ChineseMnist数据集包含15000张手写汉字图像及标签相比经典MNIST更具笔画与结构多样性可用于训练和评估KNN等分类模型也适合作为课程设计或毕业设计选题。压缩包共2000个文件大小10.47MB主要包含JPG手写图片、CSV标签文件、Python脚本与Jupyter Notebook等并配有少量可视化图片结构清晰。已有1825人浏览学习。配套代码覆盖数据读取、像素归一化、标签编码、KNN构建、测试集评估与混淆矩阵分析等关键环节读者可直接运行复现完整流程并尝试调整K值、特征工程等手段提升准确率在实践中掌握中文手写字符识别的核心方法。附带的Jupyter Notebook笔记便于逐步跟踪运行结果适合边学边练。1. 为什么我用 KNN 啃中文手写字识别数据规模与算法特性的匹配点手写数字识别 MNIST 已经是老生常谈但换成中文手写字情况立刻不一样——类别从 10 个陡增到 100 个字形结构复杂相似字多而且很多人拿到 ChineseMNIST 数据集的第一反应是直接上个 CNN反而忽略了 KNN 在 15000 张这种中小规模数据集上的真实战斗力。我最初也是先试了 CNN调参调到怀疑人生后来回头用 KNN 搭了一条完整识别管线准确率不差代码量却少了一个数量级。这篇笔记就把这个数据集从文件结构、标签映射到 KNN 参数调优完整拆一遍适合刚接触中文 OCR 方向、想用最小成本跑通一个可验证识别任务的人也适合已经跑过 MNIST、想知道 ChineseMNIST 和它差在哪的老手。2. ChineseMNIST 数据集拆解15000 张图片的目录结构、标签映射与可视化验证2.1 文件结构与像素格式ChineseMNIST 和经典 MNIST 最大的区别在存储形态上。MNIST 给的是打包好的 idx 二进制文件而 ChineseMNIST 在社区里流通的版本基本都是图片文件夹形态常见做法是一个根目录下放 100 个子目录子目录名就是汉字标签每个子目录里是 500 张 PNG 灰度图。我拿到的这个版本正是如此目录结构长这样ChineseMNIST/ ├── 一/ │ ├── 1.png │ ├── 2.png │ └── ... ├── 丁/ ├── 万/ ├── 上/ └── ...100 类 × 500 张 15000 张这就是标题里那个数字的来源。用目录名做标签有个天然好处不用额外维护一份 label 映射表遍历目录时标签就挂在路径上不容易出现 index 错位。需要注意图片尺寸在版本间并不统一我见过 240×240 的原始扫描版也见过已经缩到 64×64 的预处理版所以加载时代码里不要写死尺寸统一做一次 resize 更稳。像素格式上这些 PNG 是单通道灰度图但不同版本灰度范围不一样有的已经是 0–255 归一化好了有的还带着扫描噪声。我一般会在加载时用 Pillow 的convert(L)强制转灰度再转成 numpy 数组把范围统一压到 0–1后面的距离计算才不会因为量纲问题出偏差。2.2 标签映射与类别分布100 个类别对应 100 个常用汉字这 100 个字覆盖了日常书写里出现频率最高的一批比如一、丁、万、上、下、不、与、专这类基础字也有不少结构相似、容易让识别器翻车的近似字对。实际构建分类器时标签是中文文本必须编码成数值 sklearn 才能消费。我用的映射方案是给每个类别一个稳定的整数 IDimport os from pathlib import Path root Path(./ChineseMNIST) label_to_id {} id_to_label {} for idx, label_dir in enumerate(sorted([d for d in root.iterdir() if d.is_dir()])): label_to_id[label_dir.name] idx id_to_label[idx] label_dir.name print(label_to_id[一]) # 0 print(id_to_label[0]) # 一逻辑说明sorted保证类别顺序稳定这样同一份数据在不同机器上跑出来的标签编码是一致的调参结果可以互相比较。label_to_id用于训练id_to_label用于预测后把数字 ID 还原成汉字展示给用户。如果之后要导出混淆矩阵这个双向映射就是唯一的翻译器。类别分布上这个版本是严格均衡的每类 500 张。这给评估省了很多事——直接用全局准确率就能公平反映模型能力不需要加权。但要注意均衡只能说明数量均衡书写风格并没有均衡每个人写字习惯不同同一类字在笔画粗细、倾斜角度、连笔程度上差异很大。这些差异恰恰是 KNN 的敏感点后面调参时会反复遇到。2.3 可视化验证把数据集打开看一眼加载完数据别急着训练先可视化。这一步看着简单但能提前暴露一大批问题图片是不是反的、标签和内容对不对得上、有没有空白样本、有没有损坏文件。我用下面的代码把训练集前 10 张图打出来import matplotlib.pyplot as plt import numpy as np def visualize_samples(images, labels, id_to_label, num10): fig, axes plt.subplots(2, 5, figsize(12, 5)) axes axes.flatten() for i in range(num): axes[i].imshow(images[i].reshape(28, 28), cmapgray) axes[i].set_title(id_to_label[labels[i]], fontpropertiesSimHei) axes[i].axis(off) plt.tight_layout() plt.show()参数说明num10是展示张数28, 28是展示时的 reshape 尺寸和加载时统一保持一致fontpropertiesSimHei解决 matplotlib 中文乱码问题。如果你在 Linux 服务器上没有 SimHei 字体中文标题会显示成方框这时可以改成英文标签或者安装中文字体。这步跑完如果看到的字形明显扭曲、方向不对多半是加载时通道顺序或者旋转方向出了问题趁早修正比训完再排查成本低得多。3. 搭一条能跑的 KNN 识别管线特征降维、距离度量与参数搜索3.1 数据加载与预处理KNN 的核心是按距离找近邻所以数据进入模型前的形态直接影响距离计算的质量。原始图片如果以 28×28 展开每张图就是 784 维向量100 类 × 500 张数据撑起这个维度没问题但距离会被大片背景像素稀释。预处理的目标是把图片内容对齐、把灰度范围统一、把维度压到有效信息集中的区域。我习惯的加载方式是把每个样本统一 resize 到 28×28这个尺寸是从 MNIST 惯例借来的足够保留汉字笔画结构又不会让向量维度太高。from PIL import Image import numpy as np from pathlib import Path def load_chinese_mnist(root, size(28, 28)): images, labels [], [] for label_dir in sorted(Path(root).iterdir()): if not label_dir.is_dir(): continue label label_dir.name for img_path in label_dir.glob(*.png): img Image.open(img_path).convert(L).resize(size) images.append(np.array(img, dtypenp.float32) / 255.0) labels.append(label) X np.array(images).reshape(len(images), -1) return X, np.array(labels)逻辑说明convert(L)转灰度resize(size)统一尺寸/ 255.0归一化到 0–1。最后reshape(len(images), -1)把每张 28×28 的二维矩阵拉平成 784 维向量。参数size可以根据需求调整如果追求更高分辨率就改成 48×48但 KNN 的推理时间会随维度升高而明显变慢。这步做完必须检查 X 的形状应该是(15000, 784)labels 是(15000,)。shape 对不上后面 train_test_split 会直接报错或给出诡异结果这是最常见的起步翻车点之一。3.2 特征降维为什么先用 PCA 压到 40 维直接在 784 维原始像素上跑 KNN准确率不是不能用但有两个问题。第一距离计算开销大调参时每组参数都要跑全量距离矩阵迭代起来很慢。第二原始像素维度里大量是背景和笔画粗细信息这些维度对区分相似字没有贡献反而会把真正的结构差异稀释掉。我用 PCA 把维度压到 40 维。40 这个数字怎么来的对 100 类中文汉字识别经验上 30–60 维能保留大部分判别信息低于 20 维会丢笔画结构高于 80 维收益就很小了。我用的是先试 40再往两边看准确率曲线变化的策略。from sklearn.model_selection import train_test_split from sklearn.decomposition import PCA X, y load_chinese_mnist(./ChineseMNIST) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) pca PCA(n_components40) X_train_pca pca.fit_transform(X_train) X_test_pca pca.transform(X_test) print(f训练集 PCA 后形状: {X_train_pca.shape}) print(fPCA 保留方差比: {pca.explained_variance_ratio_.sum():.4f})逻辑说明先fit_transform训练集再用训练集拟合好的pca.transform转换测试集这是最容易出错的地方——如果在测试集上重新fitPCA 会学到测试集的分布等于把测试信息泄露进训练流程评估结果虚高。explained_variance_ratio_.sum()告诉我们当前 40 维保留了多少原始信息中文手写体通常 0.85–0.92 之间低于 0.85 说明维度压太狠了。PCA 在这里的角色不光降维还相当于一个去噪器。原始像素里笔画边缘的抖动、扫描噪声在 PCA 投影后落到低能量分量上被丢弃留下来的主成分更多对应字形结构的整体差异。这也是同样的 KNN 参数在原始像素上和在 PCA 特征上准确率差异显著的原因。3.3 参数搜索k 值与距离度量的组合对比KNN 在中文手写任务上有三个关键参数k 值、距离度量、权重策略。k 值控制近邻投票范围距离度量决定相似怎么定义权重策略决定距离近的是否有更大发言权。我固定使用weightsdistance因为对汉字这类类别间差异细微的任务距离越近的样本越应该主导投票。from sklearn.neighbors import KNeighborsClassifier from sklearn.model_selection import cross_val_score results [] for k in [1, 3, 5, 7, 9, 11]: for metric in [euclidean, manhattan, cosine]: clf KNeighborsClassifier( n_neighborsk, metricmetric, weightsdistance, n_jobs-1 ) scores cross_val_score(clf, X_train_pca, y_train, cv5) results.append((k, metric, scores.mean(), scores.std())) print(fk{k:2d} metric{metric:10s} acc{scores.mean():.4f} ± {scores.std():.4f})逻辑说明cv5做五折交叉验证每组参数组合跑五轮取均值避免一组参数在单一验证集上运气好。n_jobs-1让 sklearn 用满全部 CPU 核心KNN 是计算密集型这个参数在参数搜索阶段能省大量时间。metric 的三选里euclidean是默认欧氏距离manhattan是曼哈顿距离cosine计算余弦相似度。按我跑下来的经验中文手写识别场景下manhattan通常优于euclidean原因是像素灰度直方图在高维空间下曼哈顿距离对稀疏的非零像素更敏感而汉字图像本质上是稀疏的——大部分区域是背景。余弦相似度对笔画方向比较敏感但会忽略整体灰度强度在归一化不足的版本上表现不稳定。k 值方面1 到 3 之间能拿到最高分k 超过 7 准确率明显下降因为投票窗口跨过了类别边界把相似字里的错误近邻也拉进来了。选定参数后用全量训练集重新训练再在测试集上做一次性评估final_clf KNeighborsClassifier( n_neighbors3, metricmanhattan, weightsdistance, n_jobs-1 ) final_clf.fit(X_train_pca, y_train) test_acc final_clf.score(X_test_pca, y_test) print(f测试集准确率: {test_acc:.4f})这一步得到的分数才是可信的最终成绩。整个过程的核心思路是调参阶段用交叉验证选参数最终评估只用一次测试集避免在测试集上反复试参数造成的隐性过拟合。4. 避坑指南KNN 跑中文手写的五个常见翻车点4.1 训练集和测试集混入了同一类别的相同样本现象交叉验证准确率很高但换到新采集的样本上识别效果骤降感觉模型一换数据就变傻。原因某些 ChineseMNIST 版本在采集时存在连拍现象同一书写者的多张图片内容高度相似甚至逐像素一致。如果随机切分时这些近似副本同时出现在训练集和测试集评估分数会被虚高实际泛化能力并没有那么强。解决代码里没有简单判定最实用的手段是按样本文件名或书写者 ID 分组切分。拿到新数据时要询问来源如果文件名带书写者标识用GroupShuffleSplit代替train_test_split确保同一书写者的样本不跨集。项目里没有书写者信息的话至少做一次去重计算样本间欧氏距离把距离为 0 的重复项只保留一份。4.2 直接用原始 784 维像素跑距离准确率被背景噪声压低现象同样的训练集PCA 前后 KNN 准确率差 10 个百分点以上而且欧氏距离下更明显。原因784 维里真正有用的只有笔画覆盖的像素其余全是背景。欧氏距离在高维下会累加大量微小差异这些差异来自扫描噪声、灰度抖动和字形类别无关属于纯粹的干扰信号。另一种更隐蔽的情况是图片没缩放到统一尺寸不同写法的字占据的面积不同距离计算完全错位。解决我一般把特征处理做成先 resize 统一、再归一化、再 PCA三步而不是直接喂原始像素。PCA 之后的特征空间里距离计算的重点落在主成分方向噪声分量被剔除。如果你不想用 PCA退而求其次可以先把图片做中心化裁剪只保留笔画所在的 bounding box效果也远好于整图直接展开。4.3 PCA 在测试集上重新 fit评估结果虚高现象交叉验证分数和测试集分数都很好看但部署到真实数据时准确率崩得很厉害。原因代码里如果写了pca.fit_transform(X_test)PCA 会取测试集自己的主成分方向。测试集的统计分布被融入了降维转换相当于让模型在测试时偷看了测试集的整体结构。交叉验证里每个 fold 也会被单独 fit每一折分数都会被污染最终调出来的参数针对性过强。解决没有捷径只能把流程写对。pca.fit(X_train)之后训练集和测试集统一用这个对象做transform。我后来干脆把整个管线包成 sklearn 的Pipeline让 fit 和 transform 的生命周期由框架管理从流程上杜绝这类错误。4.4 中文字体缺失matplotlib 可视化全是方框现象跑可视化代码时标题标签全部显示为方框看不出每个图对应哪个字调参时没法凭肉眼校验分类结果。原因matplotlib 默认字体不含中文字形Windows 上常见的是 SimHeiLinux 服务器上往往一个中文字体都没有。方框不代表数据问题只是显示层缺字体。解决先fc-list :langzh查系统有无中文字体没有就安装fonts-wqy-zenhei或fonts-noto-cjk。代码里显式指定字体路径不要只写fontpropertiesSimHei因为 Linux 上这个字体名不存在import matplotlib import matplotlib.font_manager as fm zh_font fm.FontProperties(fname/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc) axes[i].set_title(id_to_label[labels[i]], fontpropertieszh_font)参数说明fname直接指向字体文件绝对路径绕开字体名注册问题。这一步解决后混淆矩阵、近邻样本图里的汉字才能正常渲染这对后续排查误分类样本至关重要。4.5 k 取太大相似字互相干扰导致准确率不升反降现象把 k 从 3 调到 9、11准确率没有如预期提升反而掉了 5 个点以上。原因中文手写字里存在大量结构近似字对比如人和入、土和工、日和目。这些字在像素层面差异极小单个近邻可能因为笔画偏移覆盖了这些微差。当 k 增大投票窗口内混入错误类别的近邻数量增加而正确的邻居又没有压倒性优势误分类便不可避免。这和 MNIST 数字识别不同数字类别间形状隔离明显而汉字类别间是连续过渡的。解决我一般把 k 搜索范围收敛到奇数且不超过 7。如果 k3 附近准确率峰值不明显优先调整距离度量和 PCA 维度而不是继续加大 k。另一个可尝试的做法是weightsdistance明确加大近邻中近距离样本的权重让远处样本即便被拉进窗口也不会平权投票。5. 进阶把识别器封装成命令行脚本并验证边界5.1 封装成可复用的命令行工具前面所有步骤都在 Jupyter 里验证之后我一般会顺手封装成一个脚本predict_char.py核心诉求是给定一张手写汉字图片输出预测类别和近邻距离。这比每次重新跑一遍加载、训练、预测要实用得多。import argparse import joblib import numpy as np from PIL import Image def load_pipeline(model_path): return joblib.load(model_path) def predict_single_image(pipeline, image_path, resize_shape(28, 28)): img Image.open(image_path).convert(L).resize(resize_shape) x np.array(img, dtypenp.float32).reshape(1, -1) / 255.0 y_pred pipeline.predict(x) proba pipeline.predict_proba(x) return y_pred[0], proba.max() if __name__ __main__: parser argparse.ArgumentParser(descriptionKNN 中文手写字识别) parser.add_argument(--model, typestr, requiredTrue, help训练好的模型文件路径) parser.add_argument(--image, typestr, requiredTrue, help待预测图片路径) args parser.parse_args() pipeline load_pipeline(args.model) label, confidence predict_single_image(pipeline, args.image) print(f预测结果: {label}, 置信度: {confidence:.4f})逻辑说明训练阶段把 PCA 和 KNN 串成Pipeline后joblib.dump保存这里只负责加载和预测。reshape(1, -1)把单张图片转换成 sklearn 期待的二维矩阵形状。参数resize_shape要和训练时保持一致选项上用--model和--image两个必填参数便于在 shell 里直接调用或批量跑目录。5.2 边界验证你给的图片模型真的见过吗这个脚本上线前我习惯做三组边界测试第一组是训练集里的图准确率应当接近 100%低于 95% 说明模型保存或加载环节出了问题第二组是同一汉字但不同书写风格的图这里能暴露数据版本差异问题——训练时如果只见过一种字体风格换一种风格准确率会掉得很明显第三组是空白图片、全黑图片、模糊图片这些应该被低置信度识别而不是强行给出一个高置信度的错误答案。predict_proba返回的是近邻投票的归一化比例在 KNN 里并不是严格概率但可以当置信度参考。我设置了一个经验阈值置信度低于1.0 / k时说明投票窗口内多个类别竞争激烈结果不可信脚本应该提示无法确认而不是硬报一个类别。从那以后我每换一个数据集版本都会强制走一遍这三组边界测试再谈调参优化。识别任务里真正能省时间的往往不是更高的准确率而是先知道自己什么时候该说不知道。希望这些步骤和踩坑记录帮到你少走我走过的弯路。本文还有配套的精品资源点击获取
返回列表