☰
MNIST手写数字识别实战:KNN、K-means与BP神经网络对比
2026/10/30 1:08:36 网站建设 项目流程

简介:这份程序包面向模式识别与机器学习初学者及课程实践者,围绕手写数字识别这一经典任务,提供KNN近邻、K-means聚类与BP神经网络三种方法的完整实现,可用于ATM、智能终端等场景的入门练手与结课作业参考。压缩包共12个文件、约60.11MB,包含3个Python脚本分别对应自写算法、调库K-means与sklearn直接识别,另有MNIST二进制数据文件、老师提供的手写体数据集压缩包、作业格式参考文档及readme说明,覆盖从数据读取到模型对比的完整流程。目前已有1230人学习下载。读者可借此对比三种算法在同一数据集上的效果差异,理解无监督聚类与监督分类的适用边界,并借助现成数据集与参考文档快速复现实验、完成课程作业或搭建自己的识别系统。

1. 拆开这个手写数字识别包:三种方法到底该怎么选

带过模式识别课设的人多半有过这种经历:老师扔过来一个压缩包,里面躺着train-images.idx3-ubyte这种连后缀都看不懂的文件,外加几个命名随意的.py脚本,要求用三种方法做手写数字识别并对比结果。这个包就是为这种场景准备的——它把 MNIST 原始二进制文件、自写实现、调库实现和一份参考文档打包在一起,核心覆盖 KNN、K-means、BP 神经网络三条技术路线。适合正在做模式识别结课作业的学生,也适合想拿 MNIST 练手但不想从官网一步步下数据的从业者。下面我按「数据怎么读 → 三种方法怎么跑 → 哪里容易翻车」的顺序拆一遍。

2. 先把 idx 二进制文件读明白:MNIST 原始格式解析与验证

很多人拿到train-images.idx3-ubyte第一反应是「这什么东西」,直接跳过,转头去用sklearn.datasets.load_digits里那个 8×8 的小数据集。但那个只有 1797 个样本,和 MNIST 的 70000 张 28×28 图像完全不是一个量级,作业要求用老师给的数据集时根本对不上。所以第一步必须把 idx 格式吃透。

2.1 idx 文件头结构与字节偏移

MNIST 的 idx 格式其实很简单,就是「魔数 + 维度信息 + 像素数据」的平铺。图像文件和标签文件的头部结构不同,读的时候偏移量要分开算。

图像文件(如train-images.idx3-ubyte)的头部:

字节偏移长度含义典型值
04魔数2051
44图像数量60000
84行数28
124列数28
16784×N像素数据0-255

标签文件(如train-labels.idx1-ubyte)的头部:

字节偏移长度含义典型值
04魔数2049
44标签数量60000
8N标签数据0-9

魔数 2051 和 2049 是固定值,读的时候可以用来校验文件有没有拿错。所有多字节整数都是大端序(big-endian),这一点在 x86 机器上直接用int.from_bytes不指定字节序就会读反。

2.2 用 numpy 读取并做形状校验

下面这段代码是我常用的读法,不依赖任何深度学习框架,纯 numpy 就能跑:

import numpy as np import os def load_mnist_images(filepath): """读取 MNIST idx3-ubyte 图像文件,返回 (N, 28, 28) 的 uint8 数组""" with open(filepath, 'rb') as f: # 前 4 字节是魔数,大端序读取 magic = int.from_bytes(f.read(4), 'big') if magic != 2051: raise ValueError(f"魔数不对,期望 2051,实际 {magic},检查文件是否拿错") num_images = int.from_bytes(f.read(4), 'big') rows = int.from_bytes(f.read(4), 'big') cols = int.from_bytes(f.read(4), 'big') # 剩余字节全部是像素,按 uint8 读入后 reshape buf = f.read(num_images * rows * cols) data = np.frombuffer(buf, dtype=np.uint8) return data.reshape(num_images, rows, cols) def load_mnist_labels(filepath): """读取 MNIST idx1-ubyte 标签文件,返回 (N,) 的 uint8 数组""" with open(filepath, 'rb') as f: magic = int.from_bytes(f.read(4), 'big') if magic != 2049: raise ValueError(f"魔数不对,期望 2049,实际 {magic}") num_labels = int.from_bytes(f.read(4), 'big') buf = f.read(num_labels) return np.frombuffer(buf, dtype=np.uint8) # 使用示例:四个文件分别加载 train_X = load_mnist_images('train-images.idx3-ubyte') train_y = load_mnist_labels('train-labels.idx1-ubyte') test_X = load_mnist_images('t10k-images.idx3-ubyte') test_y = load_mnist_labels('t10k-labels.idx1-ubyte') print(f"训练集: {train_X.shape}, 标签: {train_y.shape}") print(f"测试集: {test_X.shape}, 标签: {test_y.shape}") # 预期输出: 训练集: (60000, 28, 28), 标签: (60000,) # 测试集: (10000, 28, 28), 标签: (10000,)

逻辑说明:int.from_bytes(f.read(4), 'big')负责按大端序解析头部四个整数;np.frombuffer直接把字节缓冲区映射成数组,比逐字节循环快几个数量级。参数方面,rows和cols从文件头读出来而不是写死 28,这样同一段代码也能读 Fashion-MNIST 等同样格式的数据集。

2.3 读完之后先做三件事

数据加载完不要急着往模型里灌,先做三个检查。第一,确认train_X.shape[0] == train_y.shape[0],图像和标签数量必须一致,不一致说明文件配对错了。第二,随机抽几张图用 matplotlib 显示出来,肉眼确认像素值在 0-255 之间且数字可辨认,如果显示出来是全黑或全白,多半是字节序读反了。第三,统计标签分布,MNIST 训练集每个数字大约 6000 张,如果某个数字只有几百张,说明文件被截断过。

import matplotlib.pyplot as plt # 随机抽 5 张看看 fig, axes = plt.subplots(1, 5, figsize=(10, 2)) for i, ax in enumerate(axes): idx = np.random.randint(0, len(train_X)) ax.imshow(train_X[idx], cmap='gray') ax.set_title(f"标签: {train_y[idx]}") ax.axis('off') plt.show() # 标签分布统计 unique, counts = np.unique(train_y, return_counts=True) for digit, cnt in zip(unique, counts): print(f"数字 {digit}: {cnt} 张")

提示:如果readme.txt里写了数据集的来源或预处理方式,先读一遍再动手。有些老师给的包会把图像做过归一化或二值化,直接套用标准 MNIST 的参数会出问题。

3. 三种方法跑通:KNN、K-means、BP 神经网络的实现与参数

数据读进来之后,三种方法各有各的脾气。KNN 几乎不用训练但预测慢,K-means 是无监督的得额外做映射,BP 神经网络效果最好但调参最费时间。这一章把三种方法的可运行代码和关键参数都过一遍。

3.1 KNN:距离度量选择与 K 值调优

KNN 的思路直白:把 28×28 拉平成 784 维向量,算测试样本和所有训练样本的距离,取最近的 K 个,投票决定类别。手写数字识别(自写).py里应该就是这套逻辑。自己写的时候有两个参数要定:距离度量和 K 值。

import numpy as np from collections import Counter def knn_predict(train_X, train_y, test_X, k=3, metric='euclidean'): """KNN 分类,支持欧氏距离和曼哈顿距离""" # 拉平为 (N, 784) train_flat = train_X.reshape(len(train_X), -1).astype(np.float32) test_flat = test_X.reshape(len(test_X), -1).astype(np.float32) predictions = [] for i, test_sample in enumerate(test_flat): if metric == 'euclidean': # 欧氏距离:sqrt(sum((a-b)^2)) distances = np.sqrt(np.sum((train_flat - test_sample) ** 2, axis=1)) elif metric == 'manhattan': # 曼哈顿距离:sum(|a-b|) distances = np.sum(np.abs(train_flat - test_sample), axis=1) else: raise ValueError(f"不支持的距离度量: {metric}") # 取距离最小的 k 个索引 k_indices = np.argsort(distances)[:k] k_labels = train_y[k_indices] # 投票取众数 most_common = Counter(k_labels).most_common(1)[0][0] predictions.append(most_common) if (i + 1) % 1000 == 0: print(f"已预测 {i+1}/{len(test_flat)}") return np.array(predictions) # 注意:全量 60000 训练集跑 KNN 会非常慢 # 建议先取子集测试流程 subset = 5000 pred = knn_predict(train_X[:subset], train_y[:subset], test_X[:200], k=3) acc = np.mean(pred == test_y[:200]) print(f"KNN 准确率({subset} 训练样本,200 测试样本): {acc:.4f}")

逻辑说明:np.argsort(distances)[:k]拿到距离最小的 K 个索引,Counter投票。参数方面,K 一般取 3 或 5,取 1 容易受噪声影响,取太大则边界模糊。距离度量上,欧氏距离对像素差异更敏感,曼哈顿距离对离群点更鲁棒,作业里可以两种都跑一遍对比。注意全量 60000 样本跑 KNN 预测 10000 张测试图,纯 Python 循环大概要几十分钟,建议先用子集验证流程,或者用sklearn.neighbors.KNeighborsClassifier加速。

3.2 K-means:聚类数设定与标签映射

K-means 是无监督的,它只负责把数据分成 K 个簇,不告诉你每个簇对应哪个数字。调库的kmeans手写数字识别.py用的是 sklearn 的KMeans,跑完之后需要额外做一步「簇到数字的映射」。

from sklearn.cluster import KMeans from scipy.stats import mode # 为了速度,先取子集 n_samples = 10000 X_subset = train_X[:n_samples].reshape(n_samples, -1).astype(np.float32) y_subset = train_y[:n_samples] # 聚类数为 10,对应 0-9 kmeans = KMeans(n_clusters=10, init='k-means++', n_init=10, random_state=42) kmeans.fit(X_subset) # 映射:对每个簇,找训练集中该簇样本最多的真实标签 labels = kmeans.labels_ mapping = {} for cluster_id in range(10): mask = labels == cluster_id if mask.sum() > 0: # 取该簇内出现最多的真实标签作为该簇的数字 most_common = mode(y_subset[mask], keepdims=True)[0][0] mapping[cluster_id] = most_common print("簇到数字的映射:", mapping) # 在测试集上评估 test_flat = test_X[:2000].reshape(2000, -1).astype(np.float32) test_clusters = kmeans.predict(test_flat) test_pred = np.array([mapping.get(c, -1) for c in test_clusters]) acc = np.mean(test_pred == test_y[:2000]) print(f"K-means 映射后准确率: {acc:.4f}")

逻辑说明:KMeans的n_clusters=10是因为有 10 个数字类别;init='k-means++'比随机初始化更稳定;n_init=10表示跑 10 次取最优结果,避免陷入局部最优。映射这一步是 K-means 做分类的关键,mode取簇内众数标签,如果某个簇里标签很分散,说明这个簇没有对应好某个数字,准确率就会掉。参数方面,random_state固定住方便复现,n_init调大更稳但更慢。

3.3 BP 神经网络:网络结构、学习率与训练轮次

BP 神经网络是三种方法里效果最好的。sklearn库直接手写数字识别.py可能用的是MLPClassifier,但作业通常要求自己实现反向传播。下面是一个两层网络(784-128-10)的 numpy 实现核心部分:

import numpy as np def relu(x): return np.maximum(0, x) def relu_derivative(x): return (x > 0).astype(np.float32) def softmax(x): # 减去最大值防止溢出 exp_x = np.exp(x - np.max(x, axis=1, keepdims=True)) return exp_x / np.sum(exp_x, axis=1, keepdims=True) class BPNetwork: def __init__(self, input_size=784, hidden_size=128, output_size=10, lr=0.01): # He 初始化,适合 ReLU self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size) self.b1 = np.zeros(hidden_size) self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size) self.b2 = np.zeros(output_size) self.lr = lr def forward(self, X): self.z1 = X @ self.W1 + self.b1 self.a1 = relu(self.z1) self.z2 = self.a1 @ self.W2 + self.b2 self.a2 = softmax(self.z2) return self.a2 def backward(self, X, y_true): batch_size = X.shape[0] # 输出层梯度:softmax + 交叉熵的导数简化为 (p - y) y_onehot = np.zeros((batch_size, 10)) y_onehot[np.arange(batch_size), y_true] = 1 dz2 = (self.a2 - y_onehot) / batch_size dW2 = self.a1.T @ dz2 db2 = np.sum(dz2, axis=0) # 隐藏层梯度 da1 = dz2 @ self.W2.T dz1 = da1 * relu_derivative(self.z1) dW1 = X.T @ dz1 db1 = np.sum(dz1, axis=0) # 更新权重 self.W2 -= self.lr * dW2 self.b2 -= self.lr * db2 self.W1 -= self.lr * dW1 self.b1 -= self.lr * db1 def train(self, X, y, epochs=10, batch_size=64): X = X.reshape(len(X), -1).astype(np.float32) / 255.0 # 归一化 for epoch in range(epochs): # 打乱顺序 indices = np.random.permutation(len(X)) X_shuffled, y_shuffled = X[indices], y[indices] for i in range(0, len(X), batch_size): X_batch = X_shuffled[i:i+batch_size] y_batch = y_shuffled[i:i+batch_size] self.forward(X_batch) self.backward(X_batch, y_batch) # 每轮报告训练准确率 pred = np.argmax(self.forward(X[:2000]), axis=1) acc = np.mean(pred == y[:2000]) print(f"Epoch {epoch+1}/{epochs}, 训练子集准确率: {acc:.4f}") # 训练 net = BPNetwork(hidden_size=128, lr=0.01) net.train(train_X, train_y, epochs=10, batch_size=64) # 测试 test_flat = test_X.reshape(len(test_X), -1).astype(np.float32) / 255.0 test_pred = np.argmax(net.forward(test_flat), axis=1) test_acc = np.mean(test_pred == test_y) print(f"BP 神经网络测试准确率: {test_acc:.4f}")

逻辑说明:forward里先线性变换再 ReLU 激活,输出层用 softmax 转概率;backward里输出层梯度简化为(预测概率 - 真实标签),这是 softmax 加交叉熵的标准结论。参数方面,hidden_size=128是常见起点,调到 256 或 512 可能更好但更慢;lr=0.01比较稳,太大容易震荡,太小收敛慢;batch_size=64兼顾速度和稳定性。归一化除以 255 很重要,不除的话梯度会爆炸。

4. 避坑与排查:三种方法跑不通时先看这几条

这一章记录的是我在跑这个包时踩过的坑,按「现象 → 原因 → 解决」写,遇到问题可以逐条对照。

4.1 现象:KNN 跑了几十分钟还没出结果

原因:全量 60000 训练样本、10000 测试样本,纯 Python 双重循环计算距离,复杂度是 O(N×M×784),算下来几十亿次操作。解决:先用 5000 训练样本和 200 测试样本验证流程,确认代码没问题后再考虑用sklearn的KNeighborsClassifier(底层用树结构加速),或者用 numpy 的广播机制批量算距离而不是逐样本循环。

4.2 现象:K-means 准确率只有 30% 左右

原因:K-means 是无监督的,聚类结果和真实标签的对应关系是随机的,如果映射时某个簇的众数标签选错了,整个簇的预测都会错。解决:检查映射逻辑,确保每个簇取的是簇内出现最多的真实标签;另外可以尝试n_init=20多跑几次取最优,或者对图像做 PCA 降维后再聚类,减少噪声干扰。

4.3 现象:BP 神经网络 loss 不下降或变成 NaN

原因:常见的有三种——学习率太大导致梯度爆炸、没有做输入归一化、权重初始化全部为零。解决:先把学习率降到 0.001 试试;确认输入除以了 255;权重用 He 初始化或 Xavier 初始化,不要用np.zeros。如果 loss 变成 NaN,在 softmax 里减最大值那一步不能省。

4.4 现象:读 idx 文件时报「魔数不对」

原因:文件拿错了,比如把标签文件当图像文件读,或者文件下载不完整被截断。解决:先确认文件名对应关系——train-images配train-labels,t10k-images配t10k-labels;然后用os.path.getsize检查文件大小,训练图像文件应该是 47040016 字节(16 + 60000×784),对不上就是文件有问题。

4.5 现象:三种方法准确率差距很大,不知道以哪个为准

原因:KNN 和 K-means 在 MNIST 上本身就不如神经网络,KNN 大概 95% 左右,K-means 映射后可能只有 50-70%,BP 神经网络能到 97% 以上。解决:作业要求对比时,把三种方法的准确率、训练时间、预测时间都列出来,分析差异原因——KNN 没有训练但预测慢,K-means 无监督所以精度低,BP 有监督且能学到非线性特征所以效果好。

5. 进阶技巧:用混淆矩阵和 PCA 可视化把作业做出深度

如果作业只要求跑通三种方法,上面几章已经够了。但想拿高分或者真正理解模型哪里出了问题,可以加两个分析:混淆矩阵和 PCA 降维可视化。

混淆矩阵能看出模型在哪些数字上容易混。比如 4 和 9、3 和 8、1 和 7 是常见的混淆对。用 sklearn 的confusion_matrix几行就能画出来:

from sklearn.metrics import confusion_matrix import seaborn as sns # 以 BP 神经网络为例 cm = confusion_matrix(test_y, test_pred) plt.figure(figsize=(10, 8)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues') plt.xlabel('预测标签') plt.ylabel('真实标签') plt.title('BP 神经网络混淆矩阵') plt.show() # 找出混淆最多的数字对 cm_norm = cm / cm.sum(axis=1, keepdims=True) for i in range(10): for j in range(10): if i != j and cm_norm[i][j] > 0.02: print(f"数字 {i} 被误判为 {j} 的比例: {cm_norm[i][j]:.2%}")

PCA 可视化则是把 784 维的图像降到 2 维,看不同数字在空间里是否分得开。如果两个数字的簇重叠严重,模型在这两个数字上就容易出错:

from sklearn.decomposition import PCA # 取 5000 个样本做 PCA pca = PCA(n_components=2) X_pca = pca.fit_transform(train_X[:5000].reshape(5000, -1).astype(np.float32)) plt.figure(figsize=(10, 8)) scatter = plt.scatter(X_pca[:, 0], X_pca[:, 1], c=train_y[:5000], cmap='tab10', s=5, alpha=0.6) plt.colorbar(scatter, label='数字') plt.title('MNIST PCA 降维可视化(前两个主成分)') plt.show() print(f"前两个主成分解释方差比例: {pca.explained_variance_ratio_.sum():.2%}")

这两个分析加起来不到 30 行代码,但能让作业从「跑通了」变成「有分析」。我一般还会把三种方法的准确率、训练时间、预测时间整理成一张对比表,放在报告里一目了然。

从那以后我每次拿到新的数据集包,都强制先走一遍「读文件头 → 校验形状 → 可视化抽样 → 跑子集」的流程,确认数据没问题再上模型。这个习惯帮我省了很多事后排查的时间。希望帮到你。

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

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

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

立即咨询