手写字体识别实验:K-means、GMM与感知机的MNIST分类代码解析 简介面向哈工大模式识别课程及机器学习初学者的课程实验资源涵盖均值聚类K-means、高斯混合模型GMM、感知机与MNIST手写数字识别等核心内容可直接用于课程作业参考与算法原理验证。压缩包共7个文件以6个Python代码文件为主体分别对应各实验的主程序与备用实现另附1个Markdown说明文档整体仅12KB轻量便于下载查阅。已有167人学习下载适合正在完成模式识别作业、或希望从代码层面理解无监督聚类与线性分类原理的学生使用。代码中体现了K-means的质心迭代与组内误差最小化、GMM参数的均值方差权重估计、感知机基于最小二乘法LMSE的权重更新以及MNIST十类数字识别任务README文档对实验结构与运行方式作了说明可辅助读者快速复现结果并对照理论推导也可迁移到其他分类场景进行二次开发。1. 手写字体识别课程实验一份能直接跑通的经典模型代码包手写字体识别实验做到一半最容易卡住的往往不是数学推导而是代码里三件说不清的事K均值聚类到底怎么初始化质心才不会空簇GMM的对数似然为什么跑着跑着变成nan感知机明明把训练集学完了却总把MNIST的“6”认成“0”。这份哈工大模式识别课程实验资源把均值聚类、高斯混合模型、感知机三个经典模型和MNIST手写数字分类考试串成了四个实验每个实验都配有main.pyREADME里还说明了文件之间的版本关系。适合两类人正在赶课程实验报告的学生以及想快速拿到可复现代码骨架的一线工程师。下文按实验1到实验4的顺序把每个脚本的参数设置、收敛判据和踩坑点逐层拆开。2. 实验1均值聚类质心初始化与收敛判据在main.py里怎么落地2.1 main.py的流程骨架一份手写K-means的典型循环实验1的main.py实现的是最经典的K-means输入一个(n_samples, n_features)的矩阵输出每个样本的簇标签。手写字体数据经过展平之后每个样本就是784维的像素向量K-means在这里的用途是观察“无监督聚类能不能按数字形态把样本分开”。代码的核心循环一般长这样import numpy as np def kmeans(X, K, max_iter100, tol1e-4, random_state42): # X: (n_samples, n_features)例如MNIST展平后的784维像素向量 rng np.random.RandomState(random_state) n, d X.shape # 随机选取K个样本作为初始质心 idx rng.choice(n, K, replaceFalse) centers X[idx].copy() for it in range(max_iter): # 计算每个样本到K个质心的欧氏距离得到 (n, K) 矩阵 dists np.linalg.norm(X[:, None, :] - centers[None, :, :], axis2) labels np.argmin(dists, axis1) # 更新质心每个簇内样本的均值 new_centers np.zeros_like(centers) for k in range(K): mask labels k if mask.sum() 0: new_centers[k] X[mask].mean(axis0) else: new_centers[k] centers[k] # 空簇保留原质心避免崩溃 shift np.linalg.norm(new_centers - centers) centers new_centers if shift tol: break return labels, centers这段逻辑里有两个关键参数max_iter控制最多迭代次数tol控制质心位移的收敛阈值。tol1e-4是比较常见的设置意思是相邻两轮质心欧氏距离的总体变化小于0.0001就认为已经收敛继续迭代只是浪费算力。random_state在课程实验里非常值得保留——复现报告结果时同样的随机种子才能得到同样的聚类图否则每次跑出来的簇都不一样写报告的时候会很被动。2.2 质心初始化随机选点和K-means有多大差别上面代码用的是纯随机初始化从样本里挑K个点当质心。这种做法在MNIST这种高维数据上会出现一个很典型的问题随机抽到的K个点常常扎堆在某个区域导致一开始就有一部分簇是空的或者收敛到明显的局部最优解。我一般会先在main.py里做一次最简单的验证固定数据集把random_state从0到20跑一遍记录每次的组内平方误差和SSE。如果20次里出现两三种差别很大的结果就说明初始化对结果影响太大需要换成K-means。K-means的改动很小核心是“第一个质心随机选后续质心按距离平方加权概率选”def kmeans_plusplus_init(X, K, rng): n X.shape[0] centers [] first_idx rng.randint(n) centers.append(X[first_idx]) for _ in range(1, K): # 每个样本到最近质心的距离平方 dists np.array([ np.min(np.sum((X - c) ** 2, axis1)) for c in centers ]) # 距离平方越大被选为下一个质心的概率越高 probs dists / dists.sum() next_idx rng.choice(n, pprobs) centers.append(X[next_idx]) return np.array(centers)实验1的main.py如果用的是随机初始化你在报告里对比一组K-means的结果往往会发现SSE更低、簇更均匀。课程实验不会强制要求这一点但能写明白“初始化策略对结果的影响”评分时是明显的加分项。2.3 K值选择与收敛判据看SSE曲线还是看质心位移K值的选取在实验1里通常是手动的。main.py里一般不会有自动选K的逻辑常见做法是跑K3、5、7、10画一张SSE随K变化的折线图找“肘部”。SSE的计算方式很简单sse 0.0 for k in range(K): mask labels k if mask.sum() 0: sse np.sum((X[mask] - centers[k]) ** 2)需要注意一点K-means的收敛判据有两种常见实现一种是看质心位移一种是看样本分配是否变化。这份实验代码用的是质心位移shift tol好处是数值平滑坏处是偶尔会出现质心还在小幅抖动、但标签已经不变的情况。实际调试时如果发现迭代次数总在max_iter附近才停可以把tol调大到1e-3试试通常能提前几轮收敛结果几乎不变。还有一个小细节容易被忽略MNIST的像素值范围是0到255而K-means用欧氏距离对尺度非常敏感。跑实验1之前把X除以255或者做z-score归一化是常规操作。main.py里如果直接用原始像素跑聚类中心可视化出来还能看清数字形态但距离计算里亮度分量会占主导暗背景上的浅色噪点会被当成主要区分特征。归一化之后聚类结果往往更符合“数字形状”的直觉。3. 实验2 GMM从main.py到back.pyEM迭代里的三处数值坑3.1 三份Python文件怎么选main.py、back.py、main_back2.py的关系实验2的压缩包里给了三个文件main.py、back.py、main_back2.py。这个命名方式一看就是调试过程中留下的版本痕迹——back.py是某个版本的回滚备份main_back2.py是改到一半的第二个版本。遇到这种情况我一般先看文件修改时间和代码行数再对比main.py和main_back2.py的差异差异最小的那个就是最终可用版本。真正需要关注的是GMM代码本身。这份实验的核心是手写EM算法E步计算每个样本属于每个高斯分量的后验概率M步更新均值、协方差、权重反复迭代直到对数似然收敛。3.2 E步的数值稳定性log-sum-exp 与下溢处理GMM的E步最容易踩的坑是概率下溢。一个784维的高斯密度协方差矩阵行列式往往非常小直接计算N(x | mu, sigma)会得到接近0的数再取对数就是负无穷后续计算全部变成nan。main.py里如果直接写np.exp(...)的形式跑几轮就会出现nan。标准解法是全程在对数域计算最后用log-sum-exp归一化def e_step(X, weights, means, covs): n, d X.shape K len(weights) log_resp np.zeros((n, K)) for k in range(K): diff X - means[k] # (n, d) sign, logdet np.linalg.slogdet(covs[k]) inv_cov np.linalg.inv(covs[k]) # 多元高斯对数密度-0.5 * (d * ln(2pi) ln|Sigma| diff^T Sigma^-1 diff) mahal np.sum(diff inv_cov * diff, axis1) log_resp[:, k] -0.5 * (d * np.log(2 * np.pi) logdet) - 0.5 * mahal log_resp[:, k] np.log(weights[k]) # log-sum-exp防止 exp 后归零 log_max log_resp.max(axis1, keepdimsTrue) resp np.exp(log_resp - log_max) resp / resp.sum(axis1, keepdimsTrue) return resp这里有两个容易写错的位置。第一个是np.linalg.slogdet返回的是(log_determinant, sign)很多人习惯用np.linalg.det直接算d784时det会直接下溢成0logdet就是-inf。第二个是马氏距离的写法np.sum(diff inv_cov * diff, axis1)比np.diag(diff inv_cov diff.T)高效得多后者会生成一个(n, n)的冗余矩阵课堂数据量小看不出差别但换成完整MNIST会明显卡顿。3.3 M步更新与协方差奇异加正则项是惯例M步的公式本身不复杂均值是后验概率加权平均协方差是加权外积累加权重是每类后验概率之和除以样本数。真正会翻车的是协方差矩阵奇异。高维数据下如果某个高斯分量的有效样本数少于维度数协方差矩阵就是奇异矩阵下一次E步里np.linalg.inv直接报错。常见的做法是在协方差矩阵的对角线上加一个很小的正则项def m_step(X, resp): n, d X.shape K resp.shape[1] Nk resp.sum(axis0) # (K,) means np.zeros((K, d)) covs np.zeros((K, d, d)) weights Nk / n for k in range(K): rk resp[:, k:k1] # (n, 1) means[k] (X * rk).sum(axis0) / Nk[k] diff X - means[k] covs[k] (diff * rk).T diff / Nk[k] covs[k] 1e-6 * np.eye(d) # 正则项防止奇异 return weights, means, covs1e-6 * np.eye(d)这行的作用是让协方差矩阵至少在理论上可逆。这个值不要加太大否则协方差会被稀释成近似单位矩阵GMM退化成K-means的软版本。课程实验里GMM到手写字体识别上的定位通常不是直接分类而是拿来做无监督特征分布建模——对比K-means的硬分配GMM给出的是“这张图有40%像3、30%像5”的软概率。如果实验报告需要可视化把每个数字类别单独建模再看重叠度是比直接画聚类散点图更有说服力的展示方式。3.4 迭代终止与对数似然曲线判断收敛别只看参数变化GMM的收敛判断一般看对数似然的增量而不是参数变化量。代码里会维护一个log_likelihood变量每轮E步之后算一次log_likelihood np.sum(np.log(np.exp(log_resp).sum(axis1)))如果相邻两轮的对数似然差小于比如1e-3就停止。但这里也有个细节手工实现的EM偶尔会出现对数似然不增反降的情况原因多半是某一步M步更新后的参数导致数值误差累积。遇到这种情况先检查M步里用的resp是不是上一轮E步的结果很多疏漏是把E步和M步的计算顺序写反了。4. 实验3感知器LMSE权重更新规则与MNIST多分类的边界条件4.1 感知机训练循环权重和偏置什么时候更新实验3的标题是“感知器-LMSE”对应的是监督学习部分。感知机是最简单的线性分类器对每条样本计算加权和z w·x b预测错误的样本才触发权重的更新。main.py里的训练逻辑大概率是这个结构def train_perceptron(X, y, epochs50, eta0.01): n, d X.shape w np.zeros(d) b 0.0 for epoch in range(epochs): err_count 0 for i in range(n): z np.dot(w, X[i]) b pred 1 if z 0 else -1 if pred ! y[i]: w eta * y[i] * X[i] b eta * y[i] err_count 1 if err_count 0: break return w, b有几个点要解释清楚。eta是学习率感知机的收敛性和eta的取值关系不大只要eta是正数就能收敛在线性可分的前提下但eta过大会导致权重在边界附近震荡得比较厉害。y[i]用的是1和-1而不是0和1如果标签写成0和1权重更新公式就会失效这是实验3里最常出现的问题之一。err_count 0判断的是“这一轮没有错分样本”在线性可分数据集上相当于提前终止。4.2 LMSE与感知机的区别硬阈值 vs 最小均方误差LMSELeast Mean Square Error在课程里通常和感知机放在一起讲但它俩其实是两种思路。感知机用的是0/1误分类驱动只要预测方向正确就不更新权重。LMSE用的是平方误差驱动不管预测对错只要模型输出和期望标签有偏差就要按梯度下降方向更新权重。实验3的main.py如果同时实现了感知机和LMSE两种训练方式它们的权重更新公式会有一个微妙的差别# 感知机只有错误样本才更新 if pred ! y[i]: w eta * y[i] * X[i] # LMSE所有样本都参与更新误差是连续值 error y[i] - np.dot(w, X[i]) - b w eta * error * X[i] b eta * errorLMSE的好处是即使数据集线性不可分它也会收敛到一个最小平方误差解——这个解不一定是0误分类但至少是均方误差意义下的最优线性判别。感知机在线性不可分时会一直震荡永远停不下来所以实验代码里必须设置max_epochs做保护。写报告时可以强调这个对比感知机找的是“能分开就分开”的解LMSE找的是“分不开时误差最小”的解。4.3 从二分类到10分类MNIST上的一对多方案感知机天生是二分类器MNIST有10类实验4里要处理这个问题。最常见的做法是一对多one-vs-rest训练10个感知机第k个感知机负责区分“是k”和“不是k”预测时取输出值最大的那个类别。这部分的代码在实验3或实验4里都会有def train_ovr(X_train, y_train, num_classes10, epochs50): n, d X_train.shape models [] for k in range(num_classes): # 第k类记1其余记-1 y_binary np.where(y_train k, 1, -1) w, b train_perceptron(X_train, y_binary, epochsepochs) models.append((w, b)) return models def predict_ovr(X, models): # 取输出值最大的类别 scores np.zeros((X.shape[0], len(models))) for k, (w, b) in enumerate(models): scores[:, k] X w b return np.argmax(scores, axis1)注意一对多策略的预测阶段不是直接看谁“大于0”而是选输出值最大的那个。因为每个二分类器的训练数据不平衡正例只有10%负例占90%不同分类器的输出值尺度会有差异取最大输出是目前相对稳定的启发式方案。手写字体识别里感知机的表现不会太好MNIST的高维像素上线性分类器的准确率通常在85%到90%之间比KNN和神经网络都要低一些。实验3的意义在于让你看到线性模型的边界——知道它在什么情况下失效比知道它怎么工作更重要。5. 避坑与常见问题手写字体识别实验的四类翻车现场5.1 GMM跑出nan对数似然变成-inf现象实验2的main.py运行到第几轮迭代后所有输出变成nan或者分数显示-inf。原因多半是E步里直接对密度函数取对数概率下溢成0。另一个常见原因是协方差矩阵奇异某类的有效样本数太少矩阵不可逆inv直接返回错误或无穷大。解决按3.2节的方式改写成对数域计算E步里用log-sum-exp归一化M步里给协方差加1e-6 * np.eye(d)正则。改完之后跑一轮确认对数似然是单调递增的再继续后面的迭代。5.2 K-means聚类结果全是空簇或每次跑出来都不一样现象实验1里K10时某些簇的标签数一直为0聚类中心的图片里出现重复的数字形态。原因随机初始化质心太集中或者K值超过了数据本身合理的类别结构。解决先用2.2节的K-means替换随机初始化如果还有空簇把迭代里的逻辑改成“空簇则重新随机选一个样本当质心”而不是保留上一轮的质心。报告中可以加一段不同random_state下SSE的对比表说明初始化策略的影响。5.3 感知机训练永远停不下来准确率在50%附近徘徊现象实验3的感知机训练循环跑满epochs也没有触发err_count 0测试集准确率一直在50%附近。原因数据集线性不可分。MNIST这种真实图像数据像素特征和高维边界之间没有线性可分的关系感知机的假设天然不成立。解决设置合理的epochs上限并打印每一轮的误分类数观察是否在震荡同时确认标签用的是1和-1而不是0和1。如果要做MNIST分类直接上一对多的感知机方案不要指望线性模型能刷高分。5.4 数据加载失败MNIST下载超时或路径带中文现象实验4的main.py一运行就报FileNotFoundError或ConnectionError尤其是在下载MNIST数据集的环节。原因很多MNIST加载脚本默认从网上下载网络不稳定时会失败另一些情况下是因为文件放在了带中文的路径下numpy或者PIL读取时秒报错。解决先把MNIST数据集下载到本地目录修改main.py里的数据加载路径指向本地文件。路径里不要有中文用绝对路径或相对路径都行。如果加载的是.mat文件或npy文件检查numpy版本兼容性——np.load在版本差异下偶尔会报ValueError。5.5 实验报告里的图表和代码结果对不上现象报告里贴的聚类图和实际跑出来的结果颜色都对不上或者报告用的K值和代码默认值不一致。原因大部分课程实验的main.py里都会写死一组默认参数跑完的人把控制台结果截图放进报告但改参数之后没重新跑报告和实际结果就分离了。解决每次修改参数后把控制台输出和图表图片保存到一个以实验名命名的文件夹里报告中引用的图必须来自最后一次运行结果。这也是为什么前面强调要固定random_state否则报告里的图永远无法被第二次复现。6. MNIST考试代码的验证与进阶降维、对比与调试习惯实验4的main.py是MNIST分类考试前面三个实验的模型在这里汇合。跑通只是最低要求真正拉开分数差距的是验证方法和对比实验。我用这类代码时习惯按三个层面做验证。第一层是数据降维。MNIST原始784维像素直接喂给分类器线性模型能跑但效率不高而且许多维度的像素值在绝大多数样本上都是0属于冗余特征。PCA降到40到80维是比较稳妥的选择。降维后再跑感知机或GMM训练时间会成倍缩短准确率通常还能小幅提升这是因为去掉了噪声维度的干扰。main.py里如果没写PCA步骤可以自己加一个sklearn.decomposition.PCA片段两张对比表放在报告里效果很好。第二层是模型横向对比。四个实验分别涉及K-means、GMM、感知机、LMSE正好可以做成一张对比表训练集准确率、测试集准确率、是否无监督、是否能处理多分类。这张表是课程报告里最直接的加分项而且不需要任何额外实验只要把四个main.py的输出汇总就行。第三层是特征预处理的影响对比。把“原始像素直接跑”和“归一化后跑”的结果放一起往往能发现1到3个百分点的差距这种验证成本很低但能证明思考深度。最后说一个我的习惯。拿到任何课程实验代码我第一件事不是读理论而是先看一眼README和文件命名然后直接把main.py跑一遍记录默认参数下的结果再逐步改参数做对照。阅读顺序是从最完整的入口文件开始先理解数据流再关注具体算法实现。这个习惯来自一次教训有一阵子我拿到代码就急着改参数结果改完发现原始脚本里埋着一个random_seed0所有对比实验其实都在同一份结果上打转。从那以后每份实验代码我都会先跑通原版、记录输出再动参数的循环——先确认基线再谈优化。希望帮到你。本文还有配套的精品资源点击获取