☰
KNN算法手写数字识别实战:从零实现到调参优化
2026/10/3 3:23:42 网站建设 项目流程

简介:面向机器学习初学者的KNN算法实战资源,以手写数字识别为核心案例,完整演示从数据读取、距离计算到分类决策的流程,适合高校学生与入门开发者用于课程设计或算法对比实验。压缩包共2882个文件,其中2880个txt文件为手写数字样本,按训练集与测试集分目录组织,可直接用于模型训练与效果验证;另含1个KNN_digits.py源码文件以及1个readme说明,整体仅972KB,轻量易用,目录结构清晰。已有2807人学习。通过这份资源,读者不仅能获得可运行的Python实现,还能借助规范化的数据集复现KNN分类效果,观察不同K值、距离度量方式对识别准确率的影响;源码与注释有助于逐行理解算法原理,可进一步扩展用于其他图像分类任务,是机器学习入门与实验报告的优质参考。

1. KNN算法做手写数字识别:不调GPU也能到97%准确率的老项目

KNN算法是机器学习里最朴素也最容易被低估的分类器,它没有真正意义上的训练阶段,把数据记下来、到预测时算距离投票就完成了分类。把KNN和手写数字识别放在一起做成项目,是我带新人入门视觉分类时的固定路线:MNIST数据集规模适中、标签干净,不需要GPU就能在几分钟内跑完,最终准确率能到97%上下,和早期卷积网络相差不大。这个项目适合刚学完Python和numpy、想验证“分类到底是怎么回事”的工程师,也适合想准备简历项目但暂时不打算上深度学习的同学。它不解决工业级识别问题,却能把特征工程、距离度量、调参和模型评估这条路完整走一遍。

2. KNN算法和MNIST数据集的耦合点:距离计算与数据格式

2.1 KNN的分类逻辑:训练集是记忆库,推理时才真正开始计算

KNN是典型的懒学习模型。fit或者说训练阶段,它只做一件事:把训练样本原样存下来。真正的计算全部发生在predict阶段,每次来一个未知样本,它都要遍历一遍记忆库,算出这个样本到每一条训练样本的距离,把最近的k条找出来,让k个邻居投票决定类别。这就带来一个反直觉的结论:KNN的训练几乎瞬时完成,预测却很慢,和神经网络正好相反。

MNIST手写数字识别之所以适合KNN,是因为数字图片被展平成向量之后,像素的灰度值天然具备可比性。同一个数字在不同人笔下的笔画位置大体接近,所以两张“3”的图片对应的784维向量在欧氏空间里距离较近,而“3”和“8”虽然形状相似,但像素分布差异足够大到可以用距离区分。当然,模糊、连笔、偏移这些情况会让距离失真,这正是后面调参要处理的问题。

KNN的完整分类过程可以拆成三个步骤:先按选定的距离度量计算测试样本和所有训练样本的距离,再对距离排序取出前k个最小值的下标,最后让这k个邻居的标签投票,票数多的类别作为预测结果。三个步骤里,距离度量和k值是需要我们拍板的超参数,投票方式相对固定,少数服从多数,平票时通常取类别索引更小的那个,这一点在从零实现时要特别注意,否则会和sklearn的结果对不上。

2.2 MNIST数据集:图片和标签是怎么配对的

标题里的压缩包中,除了源码,最值钱的部分就是数据集。MNIST手写数字识别用的标准数据是Yann LeCun整理的MNIST库,包含60000张训练图片和10000张测试图片,每张图片是28×28的灰度图,内容是一个0到9的手写数字。图片和标签分开存放,图片文件记录像素值,标签文件记录每个样本对应的数字,两者按行号一一对应,顺序完全一致。

常见的数据集存储形态有两类。一类是原始的idx格式,四个文件分别是训练图片、训练标签、测试图片、测试标签,文件名通常是这种风格:

train-images-idx3-ubyte.gz train-labels-idx1-ubyte.gz t10k-images-idx3-ubyte.gz t10k-labels-idx1-ubyte.gz

另一类是转好的CSV形态,第一列是标签,后面784列是像素值。两种形态我都遇到过,idx格式更接近原始数据,CSV格式更方便直接丢进pandas处理。拿到zip先判断数据集后缀,再决定走哪条解析路径,这比强行套代码更重要。

idx文件是二进制的,头部有固定字节数的元信息。图片文件头16字节记录magic number、图片数量、行数和列数,标签文件头8字节记录magic number和标签数量。用Python的struct模块把头部解出来,剩下的字节按uint8读取,就是干净的图像矩阵。这里我固定用这段代码解析:

import numpy as np import struct def load_idx_images(filepath): # 解析idx格式的图像文件,返回形状为 (样本数, 784) 的uint8数组 with open(filepath, 'rb') as f: magic, num, rows, cols = struct.unpack('>IIII', f.read(16)) print(f'magic={magic}, num={num}, rows={rows}, cols={cols}') data = np.frombuffer(f.read(), dtype=np.uint8) return data.reshape(num, rows * cols) def load_idx_labels(filepath): # 解析idx格式的标签文件,返回形状为 (样本数,) 的uint8数组 with open(filepath, 'rb') as f: magic, num = struct.unpack('>II', f.read(8)) print(f'magic={magic}, num={num}') return np.frombuffer(f.read(), dtype=np.uint8)

这里的关键参数是struct.unpack里的>,它表示按大端字节序读取。MNIST的idx格式规定元信息以大端序存储,如果漏掉>、按本机默认的小端序解析,读出来的num可能是25165824这样的天文数字,后续reshape全部错乱。16字节对应四个无符号整型,8字节对应两个无符号整型,顺序不能换:先magic再数量,图像文件还要再读行数和列数。

注意:CSV形态的MNIST不需要这段代码,直接用np.loadtxt或pd.read_csv读取即可,但务必要确认第一列是不是标签,有些二次处理过的CSV会把标签放在最后一列,错列会让准确率直接崩到10%左右。

2.3 从像素到向量:归一化、展平和标签对齐

图像文件解析出来之后,每个样本是一行784维的向量,取值范围是0到255。直接拿这个向量去算欧氏距离不是不行,但高亮度像素的价值会被放大:一个230和255的差异看起来不大,投影到距离上却贡献了较大的平方差,相当于把0到255的尺度强行当成了线性距离的权重。常见做法是除以255,把像素压缩到0到1区间,让距离计算更关注相对灰度差异而不是绝对亮度。

归一化和标签对齐可以写成一个预处理函数,顺便做数据类型转换。uint8直接做减法没问题,但涉及平方累加时容易在边界产生精度损失,先转成float32更稳妥。

def preprocess(X_train, X_test): # 像素值从uint8归一化到[0,1],并转为float32,避免距离累加时的精度损失 X_train = X_train.astype(np.float32) / 255.0 X_test = X_test.astype(np.float32) / 255.0 return X_train, X_test

预处理阶段还有两个细节值得注意。第一,归一化参数只需要训练集的最大值,MNIST固定是255,所以训练测试共用255是安全的;换到其他数据集时,应该用训练集的统计值去变换测试集,不能拿测试集的最大值去归一化训练集,那叫数据泄露。第二,标签和图像的行号必须对齐,加载后用np.bincount(y_train)看一眼每个类别的数量,0到9各有6000上下就是正常的,如果某个数字数量异常,多半是文件配对时张冠李戴了。

我还会顺手做一个随机抽样可视化:抽25个样本画成5×5的网格,视觉确认图片内容和标签文字一致。这一步花不了几秒钟,却能避免后面所有评估结果建立在错位数据上。图像展平本身没什么技术含量,reshape(-1, 784)一行搞定,但理解“28×28的二维数组塌缩成一维向量”对后面理解距离计算至关重要。

3. 项目实战:在本地跑通KNN手写数字识别的最小代码

3.1 项目文件怎么组织

拿到zip后我习惯先把目录整理成src、data、output三个部分。data放数据集,src放三个脚本,output放可视化结果。这样后面换参数、跑对比实验时,不会被一堆同名文件搞乱。

mkdir -p knn_mnist/src mkdir -p knn_mnist/data mkdir -p knn_mnist/output cd knn_mnist touch src/knn_from_scratch.py src/knn_sklearn.py src/visualize_errors.py

这段命令做的事情很简单:建立目录结构并预留三个Python文件的位置。实际运行时先把数据文件放进data目录,src下的脚本通过相对路径../data/xx引用,输出图片统一写到output目录。如果你只是临时验证KNN效果,不用这么多目录,两个脚本加一个数据集就够;但项目一旦涉及调参对比,目录不规范迟早要吃亏。

3.2 用numpy从零实现KNN核心逻辑

从零实现的目的是把黑匣子拆开看一眼。这里我按KNN的三个步骤写:广播算距离、argsort取近邻、bincount投票。

import numpy as np def knn_predict(X_train, y_train, X_test, k=5): """ 从零实现KNN分类 X_train: (n_train, n_features) float32 y_train: (n_train,) uint8 X_test: (n_test, n_features) k: 近邻数量 """ preds = np.zeros(len(X_test), dtype=np.uint8) for i, x in enumerate(X_test): # 第一步:广播计算当前样本到所有训练样本的欧氏距离 diff = X_train - x.reshape(1, -1) dists = np.sqrt(np.sum(diff * diff, axis=1)) # 第二步:距离从小到大排序,取前k个下标 top_k_idx = np.argsort(dists)[:k] # 第三步:k个邻居的标签投票,平票时返回类别编号较小的 top_k_labels = y_train[top_k_idx] preds[i] = np.argmax(np.bincount(top_k_labels)) return preds

逻辑说明:X_train - x.reshape(1, -1)利用numpy广播,一次性算出当前测试样本到全部60000条训练样本的差值矩阵;diffs * diff是逐元素平方,sum(axis=1)按特征维度求和,开根号后就是欧氏距离向量。argsort拿到从小到大的索引,切前k个就是最近邻的编号。最后用bincount统计0到9每个数字出现的次数,argmax取出出现最多的那个。

参数说明:k的默认值设5,是MNIST上的经验区间;如果改成1,准确率会略降且预测边界非常不平滑。这段代码的问题是慢,500个测试样本在60000条训练数据上要循环500次,每次算60000×784的矩阵减法,普通笔记本跑完需要几分钟。验证逻辑时建议先用np.random.choice抽1000个测试样本,或者把训练集临时截到10000条,等确认逻辑无误再上全量。

X_train, y_train, X_test, y_test = ... # 前面预处理得到的数据 sample_idx = np.random.choice(len(y_test), 1000, replace=False) preds = knn_predict(X_train, y_train, X_test[sample_idx], k=5) acc = np.mean(preds == y_test[sample_idx]) print(f'knn from scratch accuracy: {acc * 100:.2f}%')

准确率的计算方式是对比预测值和真实标签做布尔比较,取均值就是正确率。这里踩过坑的人都知道,测试集抽样后必须重新对齐索引,曾经有人直接用y_test[:1000],结果样本和标签对不上,准确率异常低。

3.3 用scikit-learn快速对照

从零实现验证了原理,实际工程中没人手写KNN,直接用sklearn的KNeighborsClassifier,而且它支持多核并行和多距离度量,比自己循环快几个数量级。

from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score knn = KNeighborsClassifier( n_neighbors=5, weights='uniform', algorithm='auto', p=2, n_jobs=-1 ) knn.fit(X_train, y_train) y_pred = knn.predict(X_test) print(f'sklearn knn accuracy: {accuracy_score(y_test, y_pred) * 100:.2f}%')

参数说明:n_neighbors对应KNN的k,核心超参数;weights有两个选择,uniform是平等投票,distance是按距离倒数加权,距离越近的邻居话语权越大;p=2是欧氏距离,改成1就是曼哈顿距离;n_jobs=-1使用所有CPU核心,预测阶段能明显提速。

一个反直觉的点是algorithm='auto'并非永远最优。sklearn会在kd-tree、ball-tree和brute三种算法里自动选,但在784维的高维数据上,kd-tree的剪枝效率很差,auto经常会回退到暴力计算。数据量不大时可以直接指定algorithm='brute',省去树构建的时间;数据量大到内存紧张时再考虑ball-tree。

3.4 可视化预测错误的样本

只报一个准确率不能说明模型行为,把预测错的样本打出来看,是最直观的排查手段。错误样本往往集中在形状相近的数字对,比如3和8、4和9、7和1。

import matplotlib.pyplot as plt errors = np.where(y_pred != y_test)[0] print(f'total errors: {len(errors)}') fig, axes = plt.subplots(2, 5, figsize=(12, 5)) for i, idx in enumerate(errors[:10]): ax = axes[i // 5][i % 5] ax.imshow(X_test[idx].reshape(28, 28), cmap='gray') ax.set_title(f'true={y_test[idx]}, pred={y_pred[idx]}') ax.axis('off') plt.tight_layout() plt.savefig('output/knn_errors.png', dpi=150)

这段代码用np.where找出预测和标签不一致的下标,取前10个画成2行5列的子图。i // 5和i % 5是子图定位的常见写法,把一维循环序号映射到二维网格。画出来的图一旦发现某一对数字反复出错,说明距离度量对这类形变不敏感,接下来就要从调参上找补。

4. 调参:k值、距离度量、样本量怎么影响准确率和耗时

4.1 扫描k值:从1到15看准确率曲线

k值是最直观的超参数,我一般不拍脑袋定,而是直接写个循环扫描。k太小,模型被噪声样本带着跑;k太大,把远处的异类样本也拉进来投票,边界被抹平。MNIST上常见的最优k落在3到7之间,具体看训练样本量。

import time from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score X_test_sub = X_test[:2000] y_test_sub = y_test[:2000] for k in [1, 3, 5, 7, 9, 11, 13, 15]: knn = KNeighborsClassifier(n_neighbors=k, n_jobs=-1) t0 = time.time() knn.fit(X_train, y_train) y_pred = knn.predict(X_test_sub) acc = accuracy_score(y_test_sub, y_pred) print(f'k={k:2d} acc={acc:.4f} time={time.time() - t0:.1f}s')

逻辑说明:这里故意把测试集截到2000条,因为调参过程要跑多组实验,全量10000条会让等待时间线性放大。打印结果里主要看两件事:准确率随k的走势,以及时间随k的变化。在MNIST上,k从1升到5时准确率明显往上走,再往大就会开始缓慢下滑,这是因为样本存在类间重叠区域,过大的k会把重叠区另一侧的样本拉进来。

注意:扫描k值时固定其他参数,一次只动一个变量。想同时调k和weights时,用GridSearchCV或ParameterGrid,不要手动排列组合,容易漏组合而且看不出交互效应。

4.2 距离度量:欧氏距离、曼哈顿距离和distance权重

距离度量决定了两个图片在向量空间里“怎么算接近”。欧氏距离对每个像素的差异一视同仁,高亮像素的贡献会被平方放大;曼哈顿距离是像素差绝对值之和,对大噪声更鲁棒一点,但在MNIST上通常差距不大。weights='distance'则是按距离倒数加权,让近邻说话更响。

参数配置距离含义MNIST上的常见表现
p=2, weights=uniform欧氏距离,邻居平等投票默认组合,准确率97%上下
p=1, weights=uniform曼哈顿距离准确率略低,但对离群点不敏感
p=2, weights=distance欧氏距离,按距离倒数加权准确率可能高0.1%到0.3%,但预测更慢
p=1, weights=distance曼哈顿距离加权与第二行接近,偶尔反超欧氏

我的固定做法是先跑p=2和uniform做基准,再换distance加权看看提升幅度。如果提升不到0.2%,就回到简单配置,因为distance加权会让每个类别的决策边界更崎岖,可视化时错误样本更不直观。距离度量的选择本质是给“相似”下一个定义,在MNIST上没有标准答案,只有针对实际预测目标的取舍。

4.3 训练样本量:从1万到6万的边际收益

KNN是记忆型算法,训练样本量直接决定预测阶段的计算量。样本翻倍,准确率提升逐渐饱和,但耗时线性上升。这是典型的规模与收益的权衡,也是调参时最容易忽略的边界。

train_subset_sizes = [10000, 20000, 40000, 60000] for n in train_subset_sizes: idx = np.random.choice(len(y_train), n, replace=False) knn = KNeighborsClassifier(n_neighbors=5, n_jobs=-1) knn.fit(X_train[idx], y_train[idx]) y_pred = knn.predict(X_test[:2000]) acc = accuracy_score(y_test[:2000], y_pred) print(f'n_train={n:5d} acc={acc:.4f}')

逻辑说明:np.random.choice随机抽样索引,抽样后训练集变小,预测变快。这里不重置随机种子,每次结果会有波动,所以对比时要用同一组测试子集,否则噪声会盖过样本量带来的差异。1万条训练样本时准确率大约在95%出头,4万条能到96.5%以上,再往上增速放缓,这就是边际收益递减。

如果你是做课程设计,60000条全量能让你交出一张漂亮的准确率曲线;如果模型要反复迭代调参,长期驻留在25000条左右性价比最高,准确率只比全量低0.5个百分点,但每次实验快一倍。这一点在写报告时可以量化对比,比只丢出一个最终准确率更有说服力。

5. 避坑:KNN手写数字识别最常见的四个翻车现场

5.1 现象:加载数据集时报struct.error或EOFError

运行load_idx_images时抛异常,提示需要更多字节才能解包,或者解码出来的图片数量明显不对。原因通常是下载的文件不完整,gz包只下了一半,解压出来字节数偏少,头部读出来的数量远超实际剩余字节。还有一种情况是下载工具没有跟随重定向,把服务器返回的HTML页面存成了.gz文件。

解决:先检查文件大小是否和标准一致。MNIST的测试图片文件解压后是7840016字节左右,如果差得远,直接重新下载。用命令行下载时加上跟随重定向参数,不要用浏览器右键另存为,很多浏览器在这种静态文件上会做奇怪的处理。如果数据集来源不固定,加载前加一个简单检查:

import os data_path = 'data/t10k-images-idx3-ubyte' # 校验文件大小,7840016是10000张28x28图片加16字节头部的解压后大小 if os.path.exists(data_path) and os.path.getsize(data_path) != 7840016: print('数据文件大小异常,建议重新下载')

5.2 现象:准确率只有80%出头,远低于97%的预期

模型跑通了,但准确率卡在80%到85%,排除代码bug后最可能的原因是没归一化。直接用0到255的像素值算距离,高亮像素的平方差会主导整个距离值,相当于把分类决策权交给几个最亮的点,前景像素的位置信息被稀释。还有一个常见原因是k设成了1,单近邻对噪声零容忍,个别训练样本的标注错误直接带偏预测。

解决:在预处理阶段统一除以255,转成float32再进模型;k放到3到7区间重跑一次。如果准确率还是上不去,检查是否有样本和标签错位,用前面提到的网格可视化抽25张人工核对。跑完这两个修正,准确率通常会直接跳到95%以上,剩下的差距才是算法本身的边界。

5.3 现象:预测结果几乎全是同一个数字

准确率掉到10%左右,和随机猜测一样,而且np.unique(y_pred)显示预测值集中在某一个类别。这种现象在CSV格式的数据集上特别常见:标签列被放在了最后一列,而代码默认第一列是标签,于是标签向量变成了像素向量的一部分,实际标签变成无意义的像素值。另一个原因是标签和图片文件配对错了,比如训练图片配上了测试标签,行号对不上。

解决:先检查数据形状。y_train.min()和y_train.max()如果不在0到9范围内,立即回头检查读取逻辑;np.bincount(y_train)输出每个数字的计数,正常应该约6000附近,某个数字为零就要警惕。最后用图像网格可视化,确认图片内容与标签文字一致。这一套检查下来,90%的错位问题都能暴露。

5.4 现象:从零实现的KNN内存占用激增,程序卡死

自己写的循环版本每次计算X_train - x时生成一个60000×784的float32矩阵,约188MB,看起来还能接受;但如果为了省事把测试样本也做全量广播,一次生成(10000, 60000, 784)的浮点张量,直接占掉180GB内存,任何机器都会卡死。还有人在循环里没有及时释放中间变量,Python的引用计数没把上一轮矩阵回收,内存峰值叠加到爆缸。

解决:分段计算,一次只处理一个或一小批测试样本;用float32而不是float64,内存减半;循环末尾可写del diff, dists,也可以依赖Python自动回收。如果数据量实在太大,改用sklearn的KNeighborsClassifier,底层实现本身做了分块优化,比自己手写的循环省得多。我自己的习惯是手写版只用来验证逻辑,验证完立刻切到sklearn跑全量。

6. 进阶:用PCA降维后再跑KNN,看784维里的冗余被去掉多少

784维像素向量里存在大量冗余:数字图片的背景大片都是黑色,真实有效的信息集中在前景笔画附近。KNN对维度很敏感,高维空间里距离会趋于集中,这也是“维度诅咒”的一种体现。用PCA先降维,再跑KNN,是验证这个现象最直接的方式。

from sklearn.decomposition import PCA # 只用训练集拟合PCA,再用同一组变换处理测试集,避免数据泄露 pca = PCA(n_components=0.95) X_train_pca = pca.fit_transform(X_train) X_test_pca = pca.transform(X_test) print(f'保留95%方差需要降到: {pca.n_components_} 维') knn_pca = KNeighborsClassifier(n_neighbors=5, n_jobs=-1) knn_pca.fit(X_train_pca, y_train) y_pred_pca = knn_pca.predict(X_test_pca) print(f'PCA降维后准确率: {accuracy_score(y_test, y_pred_pca) * 100:.2f}%')

逻辑说明:n_components=0.95告诉PCA保留95%的方差,降到多少维由算法自动决定,MNIST通常落在100维到150维之间。关键点在于fit和transform必须分离:fit_transform(X_train)先学习训练集的主成分方向,再用transform(X_test)把测试集投影到同一个坐标系,如果拿全量数据一起fit,测试信息就泄漏进了降维过程,评估结果会虚高。

MNIST上跑出来的结果是:784维降到约110维,准确率从97%掉到95.5%上下,预测时间明显缩短。这就是我遇到维度诅咒时的固定实验路径——先全量跑基准,再做一次降维对比,找到速度和精度的拐点。我的习惯是把这个对比写进项目报告,比单纯刷高分更能体现对模型边界的理解。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询