简介武汉理工大学2020年数学建模暑期培训成果《基于卷积自编码的指纹编码与k聚类模型》提供完整论文与配套代码。研究将卷积自编码器与k-均值聚类相结合面向指纹识别场景解决高维指纹图像的特征提取与分类问题适合数学建模竞赛者、机器学习初学者及生物识别研究者参考。压缩包为zip格式约9.71MB包含论文文档与可运行代码可对照学习特征编码、图像重构与聚类调参等关键环节。资源内论文详细阐述了从指纹图像预处理、卷积层多尺度特征提取、池化降维到解码重构的完整流程代码部分实现了上述模型并支持调整k值观察聚类效果。目前已有60人浏览学习整体脉络清晰既能帮助理解深度学习与传统聚类融合的建模思路也能为类似图像识别赛题提供可直接借鉴的完整方案。1. 这篇培训论文在解决什么问题把无标注指纹图压成向量再交给k聚类数学建模暑期培训里拿到指纹图像数据是常有的事难点在于这些图片通常没有类别标签或者标签残缺你根本没法直接训练一个分类网络。武汉理工大学2020数学建模暑期培训论文及代码里给的就是一条不需要标注的路先用卷积自编码把每张指纹图压缩成一个低维向量这段向量就叫指纹编码再对编码向量做k聚类把指纹按形态自动分组。整套流程核心代码量不大但每一步都有讲究编码器怎么设计、聚类前要不要标准化、k选几、怎么在论文里说服评委。这篇笔记就把这条方案从模型搭建到避坑细节完整过一遍适合打算在数学建模里使用深度学习的队伍参考。2. 先搭卷积自编码为什么指纹编码不选PCA也不选SIFT2.1 自编码器输出的“指纹编码”到底是一段什么向量指纹识别的传统做法是手工提特征最常见的是细节点脊线端点、分叉点、脊线方向场。这些特征提取在指纹图像质量好的时候很稳定但培训数据里经常有模糊、断脊、背景噪声大的图手工提取的鲁棒性会明显下降。PCA则是直接对像素做线性降维你能压到几十维但它学不出脊线的局部朝向、密度这类非线性结构。SIFT这类通用特征是为自然图像设计的用在小尺寸、低纹理对比的指纹图上效果也不理想。卷积自编码的思路不同它用卷积层和池化层把图像逐步压缩全连接层把压缩后的特征图拉成一段固定长度的向量这段向量就是无监督学习下的指纹编码。因为整个训练只依赖图像本身不依赖标签非常契合数学建模里“给你一堆图但没人告诉你类别”的场景。训练目标是让解码器能把这段向量尽量还原成原图所以向量里保留了脊线结构、纹理密度等可重建的关键信息。和手工特征相比它的优势是自动学习、抗噪能力更强和PCA相比它能捕捉非线性结构。2.2 用PyTorch搭一个可跑的卷积自编码Encoder和Decoder逐层拆解常见做法是用PyTorch写一个轻量模型输入统一成128x128的单通道灰度图通过四个卷积块压缩到8x8x128再用全连接层映射到latent_dim维的编码向量。下面这段代码可以直接当作培训代码的雏形。import torch import torch.nn as nn class ConvAutoEncoder(nn.Module): def __init__(self, latent_dim64): super().__init__() # 编码器4个卷积块每次池化尺寸减半 self.encoder nn.Sequential( nn.Conv2d(1, 16, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), # 128 - 64 nn.Conv2d(16, 32, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), # 64 - 32 nn.Conv2d(32, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), # 32 - 16 nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), # 16 - 8 ) # 把特征图展平映射到latent_dim self.fc_encode nn.Linear(8 * 8 * 128, latent_dim) # 从编码向量还原成特征图 self.fc_decode nn.Linear(latent_dim, 8 * 8 * 128) # 解码器转置卷积逐步放大 self.decoder nn.Sequential( nn.ConvTranspose2d(128, 64, 2, stride2), nn.ReLU(), # 8 - 16 nn.ConvTranspose2d(64, 32, 2, stride2), nn.ReLU(), # 16 - 32 nn.ConvTranspose2d(32, 16, 2, stride2), nn.ReLU(), # 32 - 64 nn.ConvTranspose2d(16, 1, 2, stride2), nn.Sigmoid(), # 64 - 128 ) def encode(self, x): h self.encoder(x) h h.view(h.size(0), -1) return self.fc_encode(h) def forward(self, x): z self.encode(x) h self.fc_decode(z) h h.view(h.size(0), 128, 8, 8) return self.decoder(h)这段代码有几个参数需要理解否则改起来容易翻车。latent_dim是编码向量的维度也就决定后续k聚类的输入维度64够用图像量大或者想要更多信息时可以用128。输入必须是单通道灰度图彩色图要用cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)转一下。输出层用了Sigmoid要求输入像素归一化到0到1之间如果忘记归一化会对不上值域训练loss会卡住。解码器里的ConvTranspose2d是转置卷积作用是把尺寸放大一倍。第一层的输入通道数要和编码器最后一层输出的通道数一致也就是128后面逐层减半回到1。这里有一个指标容易忽略中间的ReLU不能乱换换成LeakyReLU对结果影响不大但换掉最后一层的Sigmoid会导致输出值域不在0到1重建效果没法直观对比。2.3 训练配置损失函数为什么用MSElr和epoch怎么设loss降到多少算可以训练用MSE做重建损失是这类模型最稳妥的起点。每一轮把原图输入模型让输出尽量接近输入loss就是逐像素的均方误差。MSE的优点是收敛平稳梯度计算简单而且它天然惩罚大误差像素对指纹断脊这类局部差异敏感。训练循环写起来也比较直白。import torch.optim as optim from torch.utils.data import DataLoader model ConvAutoEncoder(latent_dim64) criterion nn.MSELoss() optimizer optim.Adam(model.parameters(), lr1e-3) for epoch in range(60): total_loss 0.0 for imgs in train_loader2: # 不取标签只取图像 imgs imgs.to(device) recon model(imgs) loss criterion(recon, imgs) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) avg_loss total_loss / len(train_loader2.dataset) print(fepoch {epoch 1:03d} loss {avg_loss:.4f})这里的train_loader2指的是不返回标签的DataLoader类似DataLoader(dataset, batch_size32, shuffleTrue)。学习率从1e-3起步epoch设60轮每轮记录平均loss。训练结束后loss大概在0.01到0.05之间就算正常具体取决于图像的复杂度和压缩强度。有一个常见误区以为loss越低越好于是把latent_dim改成512结果聚类效果反而变差因为编码向量保留了过多与类别无关的细节。对聚类任务来说编码向量应该留全局结构丢弃细枝末节。2.4 一个容易被忽略的细节先看重建图再谈聚类很多初学者训练完模型直接提取向量去做聚类结果聚类效果不好却不知道问题出在编码器还是聚类。我的习惯是训练完立刻做一次重建可视化把原图和重建图并排显示确认模型确实学到了脊线结构再继续往下走。这一步是整套流程的“后悔药”它能把问题定位在编码阶段还是聚类阶段。import matplotlib.pyplot as plt model.eval() with torch.no_grad(): sample next(iter(train_loader2))[0][:8].to(device) recon model(sample) grid torch.cat([sample.cpu(), recon.cpu()], dim0) fig, axes plt.subplots(2, 8, figsize(16, 4)) for i in range(8): axes[0, i].imshow(sample[i, 0], cmapgray) axes[0, i].axis(off) axes[1, i].imshow(recon[i, 0], cmapgray) axes[1, i].axis(off) plt.show()如果上半部分是清晰的指纹下半部分却是一片模糊说明编码维度太低信息丢太多可以把latent_dim调大。如果下半部分能看出脊线走向和大致亮暗分布只是细节模糊这是正常的压缩损失可以继续做聚类。这一条经验在论文写作阶段也会派上用场重建对比图放在论文的模型验证部分能直观说明编码器的有效性。3. 把编码向量交给k聚类标准化、距离和k值判断3.1 为什么k-means之前要先做标准化而不是归一化训练完编码器之后把所有图像输入encode方法得到每张图的编码向量这些向量就是后续聚类的输入。有一点很多代码会直接跳过去先对编码向量做标准化再喂给k-means顺序错了聚类结果会明显变差。原因是k-means基于欧氏距离计算样本相似度编码向量的不同维度数值范围可能差很多比如第3列的方差是10第7列的方差只有0.1那欧氏距离几乎完全由第3列决定其他维度形同虚设。标准化是按列做的也就是每个维度单独做均值0、方差1这和把整个向量归一化到单位长度不是一回事。向量归一化会改变向量间的夹角关系适合余弦距离标准化保留向量间的相对位置适合欧氏距离。实际做k-means时绝大多数情况先做标准化。少数场景下比如发现聚类结果把过亮的指纹和过暗的指纹分成两类可以再试L2归一化加余弦距离。import numpy as np from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans model.eval() feats [] with torch.no_grad(): for imgs in all_loader: z model.encode(imgs.to(device)).cpu().numpy() feats.append(z) X np.vstack(feats) scaler StandardScaler() X_scaled scaler.fit_transform(X) kmeans KMeans(n_clusters4, random_state42, n_init10) labels kmeans.fit_predict(X_scaled)这里的all_loader是包含全部图像的DataLoaderbatch_size大一点没关系因为推理阶段不计算梯度显存占用小。random_state要固定否则不同次运行得到不同的labels论文结果无法复现。n_init10表示k-means用10组随机质心初始化取最优结果能缓解初始点选得差导致的局部最优。3.2 k值的选择肘部图与轮廓系数数模论文里更认可的是两个图配一段话k聚类的k值不能拍脑袋定数学建模论文更需要一套可复现的决策依据。最常用的组合是肘部法加轮廓系数肘部法看下降拐点轮廓系数看聚类分离程度。from sklearn.metrics import silhouette_score import matplotlib.pyplot as plt inertia_list [] sil_list [] k_range range(2, 11) for k in k_range: km KMeans(n_clustersk, random_state42, n_init10) km.fit(X_scaled) inertia_list.append(km.inertia_) sil_list.append(silhouette_score(X_scaled, km.labels_)) plt.figure(figsize(10, 4)) plt.subplot(1, 2, 1) plt.plot(list(k_range), inertia_list, markero) plt.xlabel(k) plt.ylabel(inertia) plt.title(Elbow Method) plt.subplot(1, 2, 2) plt.plot(list(k_range), sil_list, markero) plt.xlabel(k) plt.ylabel(silhouette score) plt.title(Silhouette Score) plt.tight_layout() plt.show()inertia是簇内平方和k越大inertia越小理论上无限趋近0所以关键是找下降速度骤减的位置。轮廓系数越接近1越好通常低于0.2说明簇重叠严重0.3以上勉强能看0.5以上可以接受。数模论文里常见的问题是只放一张肘部图然后直接说“拐点在k4”评阅人会存疑。更稳的做法是两图并排肘部图给出一个候选范围轮廓系数圈定最优点再结合指纹领域的先验知识做最终确认。3.3 没有标签时怎么验证聚类质量按簇看图和重构误差数学建模比赛里没有标准答案也没有测试集标签怎么让评委相信聚类结果是真的有意义的最直接的验证方式是把每个簇的指纹原图拼成网格人眼判断同一簇内是否形态相近、不同簇之间是否有明显差异。这个验证虽然看起来原始但其实是评委最能接受的直观证据。import matplotlib.pyplot as plt cluster_img {} for idx, label in enumerate(labels): cluster_img.setdefault(label, []).append(imgs_all[idx]) fig, axes plt.subplots(len(cluster_img), 6, figsize(12, 2 * len(cluster_img))) for row, (label, imgs) in enumerate(cluster_img.items()): for col in range(6): axes[row, col].imshow(imgs[col], cmapgray) axes[row, col].axis(off) if col 0: axes[row, col].set_title(fcluster {label}) plt.tight_layout() plt.show()另外一种量化手段是计算每个簇的平均重构误差。把同一簇的图像输入自编码器计算MSE如果某个簇的重构误差明显高于其他簇通常说明那个簇里的图像质量差噪声大而不是类别特殊。这可以当作数据清洗的依据把高误差样本单独剔除或重新调整预处理。聚类数k的选择、标准化策略、编码维度这些变量对聚类结果的影响也可以通过同一个可视化模板快速对比。4. 避坑/常见问题/排查4.1 训练loss不降卡在0.7左右不动先看loss值本身如果输入像素已经归一化到0到1MSE为0.7意味着模型基本在输出平均值没学到任何结构。原因通常是输入没归一化像素值在0到255而输出层Sigmoid的值域是0到1模型再怎么学也拉不到那么高。这种情况loss会在0.7附近震荡不动因为它是误差平方的均值模型输出接近0.5时误差已经很小梯度也随之消失。解决方法是确保数据加载时统一除以255把像素压到0到1之间。另一个原因是输出层激活函数被误改有人把最后一层的Sigmoid换成了ReLU值域变成0到正无穷MSE目标混乱训练也会不稳定。排查时先打印一批输入的min和max再检查模型输出层的值域两步就能定位。4.2 聚类跑出来的簇肉眼一看是按亮度分的现象是聚类结果确实分成几组但每组内部指纹形态差异很大组间的差异主要是图像明暗或者背景噪声。原因是编码向量里混入了太多亮度信息这个问题往往出在预处理环节输入图像没有做亮度归一化或者用了灰度值直接编码。自编码器为了降低重建误差会把亮度的均值也编码进向量聚类时亮度就成了主导特征。解决方法是先对指纹图像做数据清洗把亮度尽量拉平常见做法是z-score归一化后截断到0到1或者先做直方图均衡化再归一化。还要检查编码向量是否存在某些维度方差特别大把这些维度去掉也能缓解但根治法还是从图像预处理入手。如果清洗后仍然按亮度分考虑改用L2归一化加余弦距离的聚类方案。4.3 图片尺寸不统一一训练就报维度错误指纹图像数据集的尺寸经常不一致有的100x100有的128x96还有的可能宽度和高度反了。PyTorch在DataLoader里做collate时会对同一batch的图片stack尺寸不一致直接报size mismatch或者stack expects each tensor to be equal size, but got。解决方法是统一数据加载时resize。用torchvision的transforms.Resize((128, 128))是最常见的做法要在Dataset里做不要在读取图片之后临时改。指纹图像是灰度图resize时建议用Image.BILINEAR或cv2.INTER_AREA前者插值平滑后者压缩时能保留脊线细节。尺寸统一之后还要确认通道数是1不要因为某些图片是三通道彩色图导致维度对不上。4.4 CPU上跑几十个epoch等不起培训场景下常有队伍只有CPU训练上百张128x128的图片一个epoch可能要几分钟60个epoch就是几个小时。解决思路有三条按优先级排序第一把输入尺寸降到64x64指纹结构信息在64x64下仍然清晰训练时间直接降到四分之一第二batch_size降到16减少单次前向的矩阵计算量第三epoch降到30到40因为自编码器在低维度编码时30轮一般已经收敛。如果手头有GPU但显存只有4G把latent_dim降到32图像尺寸降到64batch_size设为16整个训练过程大概几分钟就能完成。混合精度训练也可以考虑但培训环境里很多机器不支持代码复杂度还会提升效率优先时没必要开。4.5 k-means每次跑出来的结果都不一样k-means对初始质心敏感同样的k值、同一份数据两次运行可能得到完全不同的labels。这在数学建模比赛里很致命因为你论文里写的结论和代码实际跑出来的结果可能对不上评委复现时也会发现差异。解决方法是固定random_state42同时设置n_init10这样结果在相同环境下可以复现。更稳的做法是选多个随机种子分别跑比如[0, 42, 2020]取聚类结果最一致的作为最终答案。如果多个种子跑出的结果差异很大说明数据本身没有明显的簇结构这时应该回去调编码器或者k值而不是硬挑一个结果写进论文。5. 论文里怎么把“编码聚类”讲得让评阅人信服三张图和一张参数表评阅人最反感的是“我调了k4聚类效果很好”这种没有论证的结论。真正能支撑方案的是三张图和一张参数表。第一张是训练loss下降曲线横轴是epoch纵轴是MSE它说明编码器的训练过程是收敛的不是随便跑的。第二张是重建对比图上下两排上排原图下排重建图它说明指纹编码确实保住了脊线结构。第三张是聚类结果图每个簇单独一行网格图它说明聚类分组符合肉眼观察的形态差异。参数表放在这三张图之后列出图像尺寸、latent_dim、batch_size、学习率、epoch数、k值、轮廓系数、肘部拐点。这张表让评委可以完整复现你的实验条件。一个我自己的习惯是论文里额外放一张t-SNE可视化图把编码向量降到二维然后按聚类着色虽然它不能用来证明聚类质量但能让评委快速想象出编码空间长什么样对阅读体验帮助很大。需要留意的是t-SNE图只能作为辅助不能替代肘部图和轮廓系数因为它本身有随机性和失真。这套方案适合放在数学建模论文的模型建立小节写法上先说问题指纹图像无标签无法直接监督分类再说思路用卷积自编码无监督提取指纹编码最后给验证聚类指标加可视化。整套流程实现下来代码量不大属于性价比很高的方向希望帮到你。本文还有配套的精品资源点击获取