尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

中文手写字识别实战:基于ChineseMnist与KNN的完整指南

发布时间:2026/9/26 14:46:20

资讯中心
01
ARTICLE

中文手写字识别实战:基于ChineseMnist与KNN的完整指南

中文手写字识别实战:基于ChineseMnist与KNN的完整指南
简介一份面向Python初学者与AI入门者的中文手写字符识别实战包主题是使用KNN最近邻算法结合ChineseMNIST数据集完成中文手写字符的分类识别。压缩包共2000个文件大小约10.47MB核心内容涵盖15000张jpg手写图像、chinese_mnist.csv标签文件、Main.ipynb教学笔记本与Main.py完整脚本辅以少量xml配置文件与png示例图片目录结构清晰便于按流程阅读与复现。资源已有1825人学习下载适用于课程设计、算法对比或MNIST迁移实践。完整包含数据读取与预处理、像素归一化、标签编码、训练测试集划分、KNN模型构建、准确率与F1等指标评估并提供可视化手写图片以辅助混淆矩阵分析。通过调整K值观察性能变化可直观理解过拟合与噪声影响为后续拓展到其他中文识别场景打下基础。1. 中文手写字识别为什么绕不开 ChineseMnist 和 KNN很多人一听到中文手写字识别第一反应是直接上 CNN、上 Transformer。但如果你手里拿的是 ChineseMnist 这种 15000 张的小规模数据集我反而建议你先用 KNN 跑一遍。原因很朴素KNN 不需要训练把图片转成向量后算距离就能出结果五分钟内能看到准确率而且能快速暴露数据预处理里的坑。这个组合特别适合刚接触机器学习、想做中文手写字识别的开发者也适合那些想先验证数据集质量再决定是否投入深度模型的工程师。文章会顺着 ChineseMnist 数据集把 KNN 的实现、参数选型和踩坑点一次说透。2. 先认识 ChineseMnist 数据集15000 张图的组织方式与读取踩坑2.1 15000 张图片是怎么分布的类别均衡是最大的红利常见的 ChineseMnist 数据集收集了 100 个常用汉字的手写样本每个字 150 张加起来正好 15000 张。这个设计对 KNN 特别友好类别均衡意味着投票策略不会被多数类带偏。真实 OCR 项目里“的”“了”这种高频字可能几千张生僻字只有几十张KNN 在这种不均衡数据上表现会明显打折扣。但在 ChineseMnist 上你可以放心用最朴素的多数投票。不过要注意它只是“MNIST 风格”不是标准的 MNIST 文件格式。MNIST 原版是 28x28 字节数组打包的 idx 文件而 ChineseMnist 通常以图片文件夹的形式存在常见的是 PNG 或 JPG。我拿到的版本里每个类是一个子目录目录名可能是数字编号也可能是汉字本身。这一步如果没确认好后面标签全错准确率会低得像抽签。2.2 图片规格与文件名先确认三件事再动手读取之前先看三件事图片尺寸是否统一。有的版本是 64x64有的是 128x128极少数是 32x32。如果不统一必须用 PIL 强制 resize否则 NumPy 拼接成数组时直接报错。颜色通道。有的是 RGB 彩图有的是灰度图。汉字识别不需要颜色信息统一转灰度。标签来源。目录名、文件名、配对的 CSV 都可能存标签。最稳的做法是全部打印出来看一遍不要猜。有些版本是白底黑字有些是黑底白字。这个极性差异会在二值化时造成天壤之别后续章节会专门讲。2.3 用一小段 Python 把数据集读成 NumPy 数组下面是我一般会用的加载函数关键是让目录结构透明方便遇到问题时排查import numpy as np from PIL import Image from pathlib import Path def load_chinese_mnist(data_dir, label_mapNone, image_size(64, 64)): images, labels [], [] for cls_dir in sorted(Path(data_dir).iterdir()): if not cls_dir.is_dir(): continue # 如果 label_map 没传就把目录名当作原始标签 label label_map[cls_dir.name] if label_map else cls_dir.name for img_file in sorted(cls_dir.glob(*.png)): img Image.open(img_file).convert(L) img img.resize(image_size, Image.LANCZOS) images.append(np.array(img, dtypenp.float32).flatten()) labels.append(label) return np.array(images), np.array(labels)这段代码的逻辑是遍历每个类别目录把目录名映射成标签然后读取目录下所有 PNG先转灰度、再统一尺寸最后拉平成一维向量。dtypenp.float32是刻意的Python 列表存 float64 会让内存翻倍15000 张图在后续处理时差距很明显。参数上image_size(64, 64)是我常用的初始值。如果原始图已经很大比如 128x128先缩到 64 能减掉四分之三的计算量识别率通常不会掉。label_map是个字典用来把目录编号映射成真实汉字。假设目录名是0到99而标签清单在 CSV 里你可以这样读import pandas as pd label_df pd.read_csv(labels.csv) label_map dict(zip(label_df[id].astype(str), label_df[char])) X, raw_labels load_chinese_mnist(data, label_maplabel_map)注意这里读出来的标签是字符串比如“人”“入”。在送入 sklearn 之前需要把字符串编码成整数。用LabelEncoder最省事from sklearn.preprocessing import LabelEncoder encoder LabelEncoder() y encoder.fit_transform(raw_labels)很多人在这一步翻车直接用目录名当 y 训练预测出的标签是编号还得手动查回去。LabelEncoder的好处是它保存了编码和原始汉字的对应关系后续算混淆矩阵时可以直接用encoder.classes_恢复中文标签。3. KNN 原理与距离度量为什么它能认出手写汉字选哪个距离3.1 KNN 不是“训练”出来的模型惰性学习让基线跑得更快KNN 的全称是 K Nearest Neighbors思路极简单给定一张待识别图片把它转成特征向量然后和训练集里所有向量算距离取出距离最近的 K 个样本让它们投票票数最多的类别就是预测结果。它的训练过程几乎什么都没做只是把样本记下来。这就是“惰性学习”的本质。KNN 算法因此也被用在股票量化分析里做相似 K 线匹配原理都是找“和当前样本最像的历史样本”然后看它后续怎么走。但中文手写字识别比量化场景干净得多特征空间是确定的只要距离度量合理效果就能稳定体现。对 15000 张图片来说惰性学习的优势是训练时间约为零。你不需要像训练神经网络那样去调学习率、等反向传播数据读进来就可以开始预测。代价是预测时的计算量一次预测要算 15000 次距离。如果每张图片是 64x64 拉平的 4096 维向量一次预测就是 15000 次 4096 维距离计算。听起来吓人但在现代 CPU 上也就几十毫秒完全能接受。3.2 三种距离度量L1、L2、余弦怎么选距离度量是 KNN 唯一的“模型参数”。下面是常用的三种度量公式在中文手写场景里的直觉L1 曼哈顿距离sum(|x_i - y_i|)对单个像素差异更敏感能容忍笔画粗细的轻微变化L2 欧氏距离sqrt(sum((x_i - y_i)^2))默认选择放大差异较大的像素适合整体轮廓余弦距离1 - (x·y) / (|x||y|)不关心整体亮度缩放适合形状相似但浓淡不同的字大部分教程默认用 L2但我实测下来中文手写场景里 L1 往往更好。原因是手写汉字的笔画有粗细差异同一个“天”字有人写得轻有人写得重。在 L2 里某个像素误差被平方后粗笔画和细笔画之间的差异会被放大而 L1 只做绝对值累加对这类差异更钝感。余弦距离适合浓淡变化大、但形状清晰的场景。比如有人用毛笔写墨迹边缘晕染整体像素亮度偏低但笔画方向依然清楚。余弦距离可以抹掉这种亮度偏移。不过它也有个问题如果两幅图背景很干净、笔画稀疏余弦值常常偏高区分度不够。我的建议是先用 L2 跑一个基线然后换成 L1 看准确率变化。如果 L1 更高就留在 L1。量化分析里常用 L2 做相似 K 线匹配因为数值序列的振幅差异本身就是信号但图像像素不是序列值L1 的稳定性在笔画任务里更值钱。3.3 样本量 15000 带来的计算压力与 KD Tree 的局限样本量到 15000KNN 的暴力计算还能扛但一定不是最优。sklearn 的KNeighborsClassifier默认会用 KD Tree 或 Ball Tree 加速近邻搜索。问题在于KD Tree 在低维空间比如 2D、3D非常高效但维度升到几百上千时它几乎退化成线性扫描。381 维的展开像素特征KD Tree 的效果和暴力扫描差不多甚至因为树结构开销反而更慢。所以不要指望 KD Tree 能救你。真正有效的路径是先降维。常见做法有两个一是把图片缩到 16x16 或更小二是用 PCA 把特征压到 50 到 100 维。我一般先做前者因为汉字识别的结构信息在低分辨率下保留得足够好。16x16 的 256 维向量配合 15000 样本暴力计算一次预测需要 15000 次 256 维距离在 Python 里大概几毫秒完全能接受。维度灾难的另一个表现是特征维度过高时所有样本之间的距离都趋向于接近最近邻的“最近”也就不再可信。中文手写字识别里笔画位置稍微偏移4096 维的像素距离就可能剧烈波动。所以特征维度控制在 256 以下是 KNN 能正常工作的一条经验边界。4. 特征工程把 15000 张图变成 256 维向量而不是直接展开像素4.1 直接展开原始像素的后果维度灾难与笔画抖动有人会直接把 64x64 灰度图flatten()成 4096 维向量送进 KNN。这样做的准确率往往很难看。原因有两个维度太高导致距离失真以及笔画位置抖动造成特征错位。所谓笔画抖动指的是同一个字在不同样本里笔画的具体位置可能偏移一两个像素。手写时手肘悬空程度、下笔位置都不一样这些偏移在 64x64 的网格下经常达到 2 到 3 个像素。对 L2 距离来说偏移 2 个像素意味着笔画落在完全不同的网格里距离一下子被推得很大。于是 KNN 找到的“近邻”不一定是字型相同的样本而是笔画位置恰好接近的样本。解决办法不是硬上 CNN 做平移不变性而是通过预处理把笔画位置归一化。常见做法是计算图片的重心然后把重心平移到图像中心。这个操作不复杂但对 KNN 准确率的提升非常明显。4.2 预处理流程灰度、二值化、缩小、居中是四板斧我的标准流程如下灰度化去掉颜色信息只留亮度。二值化阈值设为 128把小于等于 128 的像素变成 0大于 128 的变成 255。这一步能消除纸张纹理和轻度阴影。缩小到 16x16用Image.LANCZOS重采样保留主要笔画结构同时把特征维度压到 256。重心居中找到笔画像素的重心把整个图平移让重心落在图像几何中心。二值化看起来简单但要注意极性。如果数据集是黑底白字二值化后背景为 0、笔画为 255这时重心计算依然正确但距离计算会把大部分权重放在“什么位置没有笔画”这不对。更稳的做法是二值化后检查像素均值如果均值大于 127就反色保证笔画为 255、背景为 0。4.3 代码用 NumPy 完成预处理并切分训练/测试集下面是完整的预处理函数包含二值化和重心居中import numpy as np from PIL import Image def binarize_image(img, thresh128): img img.convert(L) img img.point(lambda p: 255 if p thresh else 0) return img def center_by_mass(img): arr np.array(img) # 如果图片是黑底白字这里直接找亮像素的重心 ys, xs np.where(arr 0) if len(xs) 0: return img cy, cx ys.mean(), xs.mean() img np.roll(arr, int(arr.shape[0] / 2 - cy), axis0) img np.roll(arr, int(arr.shape[1] / 2 - cx), axis1) return Image.fromarray(img) def preprocess_image(img, target(16, 16)): img binarize_image(img) img center_by_mass(img) img img.resize(target, Image.LANCZOS) arr np.array(img) / 255.0 return arr.flatten()其中np.roll是循环平移会把图像边缘的像素绕到另一边。原理上如果重心偏左它会向右平移到中心但最左列的内容不会丢失而是绕到右侧。这个实现简单但边缘处可能产生误差。更好的方案是用cv2.warpAffine做普通平移并裁掉移出边界的部分。不过在 16x16 的尺度下np.roll的误差对 KNN 影响不大属于够用的快速做法。然后加载并切分数据集from sklearn.model_selection import train_test_split X, raw_labels load_chinese_mnist(data, label_maplabel_map) # 对每张原始图做预处理 X_processed [] for img_vec in X: img Image.fromarray(img_vec.reshape(64, 64).astype(np.uint8)) X_processed.append(preprocess_image(img)) X np.array(X_processed) y encoder.fit_transform(raw_labels) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, stratifyy, random_state42 )test_size0.2意味着训练集 12000 张、测试集 3000 张。stratifyy保证每个汉字在测试集里的占比都是 20%不会出现某个字只在训练集、另一个字只在测试集的情况。random_state42是为了让结果可复现你换任何固定整数都行但不要每次随机。这一步做完你的X_train是 (12000, 256) 的数组X_test是 (3000, 256)。256 维对 KNN 来说非常舒服既保留了足够结构信息又不会触发维度灾难。5. 避坑跑 ChineseMnist KNN 最容易翻车的 5 个问题5.1 现象准确率只有 30%像随机猜原因标签和目录没对齐是最常见的事。很多数据集下载下来目录名是0到99的编号但编号对应的汉字在另一个 CSV 或者 README 里。如果你直接把目录名当标签那么编号 0 可能本来对应“人”你的测试集标签却是 0真实类别是“人”机器学习完全没有概念它只是在拟合编号之间的数字距离。解决加载数据时一定要传label_map把目录编号映射回真实汉字再把汉字用LabelEncoder转成0到99。验证方法很简单训练一个 KNN 后预测几个样本用encoder.inverse_transform把预测结果转回汉字亲眼看一眼是不是正确的字。5.2 现象预测结果几乎都集中在一个类上原因二值化极性反了。假设数据集是黑底白字你用point(lambda p: 255 if p 128 else 0)背景是黑色所以是 0笔画是白色所以是 255一切正常。但如果你拿到的数据恰好是白底黑字这个操作会把背景变成 255、笔画变成 0。这时候每一张图大部分像素都是 255距离计算主要看背景相似程度所有样本都被拉近KNN 自然总投给同一个多数类。解决二值化后检查整张图的像素均值。如果均值大于 127说明背景是亮的需要反色arr np.array(img) if arr.mean() 127: arr 255 - arr加上这一步之后所有图片统一成黑底白字笔画为亮色背景为暗色。注意反色操作要在缩放之前做否则 16x16 的均值判断会受到边缘插值影响。5.3 现象k 越大准确率反而越低原因100 类、每类 150 个样本这个配置下大 k 很危险。比如 k21KNN 要找到 21 个最近邻。对于一个真实属于“人”的样本它的邻近区域里可能有 8 个“人”、6 个“入”、4 个“个”、3 个“大”最终“入”的票数可能超过“人”。在小数据集、多类别场景里小 k 往往更适合。解决k 取值范围试 3、5、7、9用交叉验证选。sklearn 里可以用GridSearchCV但更快的办法是直接循环from sklearn.neighbors import KNeighborsClassifier from sklearn.model_selection import cross_val_score for k in [3, 5, 7, 9]: knn KNeighborsClassifier(n_neighborsk, metricmanhattan) scores cross_val_score(knn, X_train, y_train, cv3) print(k, scores.mean())我实测下来k5 通常比 k1 更稳而 k 超过 9 后准确率会明显下滑。不要迷信默认值 5根据你的特征维度试了再定。5.4 现象程序跑一会儿就把内存打满原因全量图片一次性读入并且保存了多份高分辨率副本。如果原始图片是 128x128 的 RGB读入后转成 float64一张图就是 1281283*8 字节约 384KB。15000 张就是 5.6GB内存直接爆掉。解决读入后立刻转灰度并缩到 16x16用 float32 存。16x16 单通道 float32一张图只要 1KB15000 张总共约 15MB随便跑。如果中途还要做重心居中直接在预处理流程里完成不要保留中间的高分辨率数组。另一个建议是用np.array(images, dtypenp.float32)而不是 Python 列表后者会把数组变成 object 类型内存翻好几倍。5.5 现象训练集上 100%测试集上只有 70%原因过拟合到噪点而且没有做位置归一化。KNN 理论上没有训练过程但如果特征维度太高、图片里有扫描噪点距离会被噪点主导。更常见的是笔画位置漂移同一个字在不同样本里重心位置可能差 3 个像素。训练集里样本 A 和样本 B 的笔画位置恰好对齐距离很小测试集里位置偏移距离变大导致泛化失败。解决先做重心居中再看准确率。如果居中后仍然差距过大尝试把 16x16 缩到 12x12让笔画位置误差占整体图像比例变小。另一种做法是用 PCA 降到 50 维去掉高频像素细节。PCA 的本质是保留主要灰度变化相当于给图像特征做平滑对 KNN 的鲁棒性有帮助。6. 进阶用混淆矩阵和置信度给识别结果装上一颗后悔药KNN 有一个天然优势它不仅能给出预测类别还能给出投票比例也就是置信度。当置信度偏低时说明这个样本落在类别边界上强行预测很可能出错。在真实业务里这种样本交给人工复核才靠谱。下面是一个简单的置信度拒识流程knn KNeighborsClassifier(n_neighbors5, metricmanhattan) knn.fit(X_train, y_train) proba knn.predict_proba(X_test) confidence proba.max(axis1) # 置信度低于 0.6 的样本先不预测打标记交给人工 low_conf_mask confidence 0.6 print(f需要人工复核的样本数: {low_conf_mask.sum()})predict_proba返回的是 K 个邻居的类别占比。比如 5 个邻居里 4 个投“人”、1 个投“入”置信度就是 0.8。你可以自己定阈值一般 0.6 到 0.7 比较合理。阈值设得越高拒识越多但剩下的预测准确率也越高。我还会用混淆矩阵找“长得像”的字对。训练完模型后把测试集的预测结果和真实标签做成混淆矩阵重点看哪里非对角元素占比高。中文手写里有几对经典冤家人 vs 入、手 vs 毛、未 vs 末、土 vs 士。这些字结构接近KNN 在低分辨率下很容易混。找到它们之后可以单独用这两个字的样本训练一个二分类 KNN作为第一级判断的修正器。from sklearn.metrics import confusion_matrix cm confusion_matrix(y_test, knn.predict(X_test), labelsencoder.classes_) np.fill_diagonal(cm, 0) # 找出混淆次数最多的类别对 confused_pairs np.argwhere(cm 5) for i, j in confused_pairs: print(encoder.classes_[i], encoder.classes_[j], cm[i][j])这一步会让你把注意力从“调 k”转向“看数据”。我自己的血泪经验是一开始准确率卡在 80%怎么调参都上不去后来打印混淆矩阵发现“人”和“入”几乎各占一半才意识到是重心居中后这两个字的笔画位置分布太像了。后来我把二值化阈值从 128 调到 160并换成 L1 距离准确率才涨到 89%。KNN 的价值不只是当基线它逼着你去理解数据里的每个细节。希望这个方案能帮你少走一段弯路。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。