简介:面向哈工大模式识别课程及机器学习初学者的课程实验资源,涵盖均值聚类(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.py,README里还说明了文件之间的版本关系。适合两类人:正在赶课程实验报告的学生,以及想快速拿到可复现代码骨架的一线工程师。下文按实验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_iter=100, tol=1e-4, random_state=42): # X: (n_samples, n_features),例如MNIST展平后的784维像素向量 rng = np.random.RandomState(random_state) n, d = X.shape # 随机选取K个样本作为初始质心 idx = rng.choice(n, K, replace=False) centers = X[idx].copy() for it in range(max_iter): # 计算每个样本到K个质心的欧氏距离,得到 (n, K) 矩阵 dists = np.linalg.norm(X[:, None, :] - centers[None, :, :], axis=2) labels = np.argmin(dists, axis=1) # 更新质心:每个簇内样本的均值 new_centers = np.zeros_like(centers) for k in range(K): mask = labels == k if mask.sum() > 0: new_centers[k] = X[mask].mean(axis=0) 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控制质心位移的收敛阈值。tol=1e-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, axis=1)) for c in centers ]) # 距离平方越大,被选为下一个质心的概率越高 probs = dists / dists.sum() next_idx = rng.choice(n, p=probs) centers.append(X[next_idx]) return np.array(centers)实验1的main.py如果用的是随机初始化,你在报告里对比一组K-means++的结果,往往会发现SSE更低、簇更均匀。课程实验不会强制要求这一点,但能写明白“初始化策略对结果的影响”,评分时是明显的加分项。
2.3 K值选择与收敛判据:看SSE曲线还是看质心位移
K值的选取在实验1里通常是手动的。main.py里一般不会有自动选K的逻辑,常见做法是跑K=3、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.py,EM迭代里的三处数值坑
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, axis=1) 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(axis=1, keepdims=True) resp = np.exp(log_resp - log_max) resp /= resp.sum(axis=1, keepdims=True) return resp这里有两个容易写错的位置。第一个是np.linalg.slogdet返回的是(log_determinant, sign),很多人习惯用np.linalg.det直接算,d=784时det会直接下溢成0,logdet就是-inf。第二个是马氏距离的写法,np.sum(diff @ inv_cov * diff, axis=1)比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(axis=0) # (K,) means = np.zeros((K, d)) covs = np.zeros((K, d, d)) weights = Nk / n for k in range(K): rk = resp[:, k:k+1] # (n, 1) means[k] = (X * rk).sum(axis=0) / 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(axis=1)))如果相邻两轮的对数似然差小于比如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, epochs=50, eta=0.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 最小均方误差
LMSE(Least 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_classes=10, epochs=50): 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, epochs=epochs) 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, axis=1)注意一对多策略的预测阶段不是直接看谁“大于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里K=10时,某些簇的标签数一直为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_seed=0,所有对比实验其实都在同一份结果上打转。从那以后,每份实验代码我都会先跑通原版、记录输出,再动参数的循环——先确认基线,再谈优化。希望帮到你。
本文还有配套的精品资源,点击获取