简介:武汉理工大学2020年数学建模暑期培训成果《基于卷积自编码的指纹编码与k聚类模型》提供完整论文与配套代码。研究将卷积自编码器与k-均值聚类相结合,面向指纹识别场景,解决高维指纹图像的特征提取与分类问题,适合数学建模竞赛者、机器学习初学者及生物识别研究者参考。压缩包为zip格式,约9.71MB,包含论文文档与可运行代码,可对照学习特征编码、图像重构与聚类调参等关键环节。资源内论文详细阐述了从指纹图像预处理、卷积层多尺度特征提取、池化降维到解码重构的完整流程,代码部分实现了上述模型并支持调整k值观察聚类效果。目前已有60人浏览学习,整体脉络清晰,既能帮助理解深度学习与传统聚类融合的建模思路,也能为类似图像识别赛题提供可直接借鉴的完整方案。
1. 这篇培训论文在解决什么问题:把无标注指纹图压成向量,再交给k聚类
数学建模暑期培训里拿到指纹图像数据是常有的事,难点在于这些图片通常没有类别标签,或者标签残缺,你根本没法直接训练一个分类网络。武汉理工大学2020数学建模暑期培训论文及代码里给的就是一条不需要标注的路:先用卷积自编码把每张指纹图压缩成一个低维向量,这段向量就叫指纹编码,再对编码向量做k聚类,把指纹按形态自动分组。整套流程核心代码量不大,但每一步都有讲究:编码器怎么设计、聚类前要不要标准化、k选几、怎么在论文里说服评委。这篇笔记就把这条方案从模型搭建到避坑细节完整过一遍,适合打算在数学建模里使用深度学习的队伍参考。
2. 先搭卷积自编码:为什么指纹编码不选PCA也不选SIFT
2.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_dim=64): super().__init__() # 编码器:4个卷积块,每次池化尺寸减半 self.encoder = nn.Sequential( nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 128 -> 64 nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 64 -> 32 nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 32 -> 16 nn.Conv2d(64, 128, 3, padding=1), 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, stride=2), nn.ReLU(), # 8 -> 16 nn.ConvTranspose2d(64, 32, 2, stride=2), nn.ReLU(), # 16 -> 32 nn.ConvTranspose2d(32, 16, 2, stride=2), nn.ReLU(), # 32 -> 64 nn.ConvTranspose2d(16, 1, 2, stride=2), 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 训练配置:损失函数为什么用MSE,lr和epoch怎么设,loss降到多少算可以
训练用MSE做重建损失是这类模型最稳妥的起点。每一轮把原图输入模型,让输出尽量接近输入,loss就是逐像素的均方误差。MSE的优点是收敛平稳,梯度计算简单,而且它天然惩罚大误差像素,对指纹断脊这类局部差异敏感。训练循环写起来也比较直白。
import torch.optim as optim from torch.utils.data import DataLoader model = ConvAutoEncoder(latent_dim=64) criterion = nn.MSELoss() optimizer = optim.Adam(model.parameters(), lr=1e-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(f"epoch {epoch + 1:03d} loss {avg_loss:.4f}")这里的train_loader2指的是不返回标签的DataLoader,类似DataLoader(dataset, batch_size=32, shuffle=True)。学习率从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()], dim=0) fig, axes = plt.subplots(2, 8, figsize=(16, 4)) for i in range(8): axes[0, i].imshow(sample[i, 0], cmap="gray") axes[0, i].axis("off") axes[1, i].imshow(recon[i, 0], cmap="gray") 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_clusters=4, random_state=42, n_init=10) labels = kmeans.fit_predict(X_scaled)这里的all_loader是包含全部图像的DataLoader,batch_size大一点没关系,因为推理阶段不计算梯度,显存占用小。random_state要固定,否则不同次运行得到不同的labels,论文结果无法复现。n_init=10表示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_clusters=k, random_state=42, n_init=10) 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, marker="o") plt.xlabel("k") plt.ylabel("inertia") plt.title("Elbow Method") plt.subplot(1, 2, 2) plt.plot(list(k_range), sil_list, marker="o") 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以上可以接受。数模论文里常见的问题是只放一张肘部图,然后直接说“拐点在k=4”,评阅人会存疑。更稳的做法是两图并排,肘部图给出一个候选范围,轮廓系数圈定最优点,再结合指纹领域的先验知识做最终确认。
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], cmap="gray") axes[row, col].axis("off") if col == 0: axes[row, col].set_title(f"cluster {label}") plt.tight_layout() plt.show()另外一种量化手段是计算每个簇的平均重构误差。把同一簇的图像输入自编码器,计算MSE,如果某个簇的重构误差明显高于其他簇,通常说明那个簇里的图像质量差,噪声大,而不是类别特殊。这可以当作数据清洗的依据:把高误差样本单独剔除或重新调整预处理。聚类数k的选择、标准化策略、编码维度这些变量对聚类结果的影响,也可以通过同一个可视化模板快速对比。
4. 避坑/常见问题/排查
4.1 训练loss不降,卡在0.7左右不动
先看loss值本身,如果输入像素已经归一化到0到1,MSE为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,图像尺寸降到64,batch_size设为16,整个训练过程大概几分钟就能完成。混合精度训练也可以考虑,但培训环境里很多机器不支持,代码复杂度还会提升,效率优先时没必要开。
4.5 k-means每次跑出来的结果都不一样
k-means对初始质心敏感,同样的k值、同一份数据,两次运行可能得到完全不同的labels。这在数学建模比赛里很致命,因为你论文里写的结论和代码实际跑出来的结果可能对不上,评委复现时也会发现差异。
解决方法是固定random_state=42,同时设置n_init=10,这样结果在相同环境下可以复现。更稳的做法是选多个随机种子分别跑,比如[0, 42, 2020],取聚类结果最一致的作为最终答案。如果多个种子跑出的结果差异很大,说明数据本身没有明显的簇结构,这时应该回去调编码器或者k值,而不是硬挑一个结果写进论文。
5. 论文里怎么把“编码+聚类”讲得让评阅人信服:三张图和一张参数表
评阅人最反感的是“我调了k=4,聚类效果很好”这种没有论证的结论。真正能支撑方案的是三张图和一张参数表。第一张是训练loss下降曲线,横轴是epoch,纵轴是MSE,它说明编码器的训练过程是收敛的,不是随便跑的。第二张是重建对比图,上下两排,上排原图,下排重建图,它说明指纹编码确实保住了脊线结构。第三张是聚类结果图,每个簇单独一行网格图,它说明聚类分组符合肉眼观察的形态差异。
参数表放在这三张图之后,列出图像尺寸、latent_dim、batch_size、学习率、epoch数、k值、轮廓系数、肘部拐点。这张表让评委可以完整复现你的实验条件。一个我自己的习惯是:论文里额外放一张t-SNE可视化图,把编码向量降到二维然后按聚类着色,虽然它不能用来证明聚类质量,但能让评委快速想象出编码空间长什么样,对阅读体验帮助很大。需要留意的是,t-SNE图只能作为辅助,不能替代肘部图和轮廓系数,因为它本身有随机性和失真。
这套方案适合放在数学建模论文的模型建立小节,写法上先说问题:指纹图像无标签,无法直接监督分类;再说思路:用卷积自编码无监督提取指纹编码;最后给验证:聚类指标加可视化。整套流程实现下来代码量不大,属于性价比很高的方向,希望帮到你。
本文还有配套的精品资源,点击获取